cswork / 模板打开 500 路题单

第 22 站 · 动态规划

动态规划入门与状态设计

一维 DP + 滚动变量 时间 O(n) · 空间 O(1)

周赛难度分 难度分 1358–难度分 1780· 中位 难度分 1569(2/10 道题有 zerotrac 周赛分)

打开 500 路题单 · 第 22 站 · 10 题
先学
学完去
面试频次
9 家公司考过 · 面经出现 10 次Snap 2、Intuit 1、Microsoft 1、Morgan Stanley 1、Paradromics 1

先做这道

LC 509斐波那契数简单
卡住再看

用递归、记忆化和滚动变量对照理解重复子问题;能够说明时间与空间复杂度。

要点

  1. 先用一句话定义状态,比如 dp[i] = 只看前 i 个元素时的最优值;状态说清楚了,转移通常就出来了。
  2. 转移只看「最后一步做了什么」:第 i 个选还是不选,最后一跳是 1 步还是 2 步。
  3. dp[i] 只依赖前一两项时,用两个变量滚动,空间从 O(n) 降到 O(1)。

易错点

  • dp 和输入的下标错位:dp 多开一位时 dp[0] 表示「什么都没选」,答案在 dp[n]。
  • 滚动更新时先覆盖了 prev1 再拿它算新值,用到的已经是新数据了。
  • 方案数很快会超出 64 位;题目要求取模时,每一步都要取模。

看动画

一步一步播放这个模式在小例子上的执行过程。可以暂停、单步前进或后退,拖动进度条跳到任意一步。

交互演示 · 逐步观察

动态规划 · 零钱兑换

硬币 1、3、4,每种无限枚,凑出 6

换个输入
0dp[0]
∞dp[1]
∞dp[2]
∞dp[3]
∞dp[4]
∞dp[5]
∞dp[6]
当前处理已发现 / 已计算答案
目标金额
6
当前金额
—
最少硬币
尚不可达

dp[0] = 0,其余状态为 ∞(不可达)。dp[x] 表示凑出金额 x 的最少硬币数。

始终成立按金额递增计算;更新 dp[x] 时,所有更小金额的最优解都已确定。

模板

选一种语言展开。四种写法实现的是同一个模板,每一份都编译运行过,并用测试数据核对了输出。看完用它重写热身题 LC 509。

Python
def rob(nums: list[int]) -> int:
    """Largest sum of elements with no two adjacent."""
    prev2 = prev1 = 0  # best totals up to i-2 and up to i-1
    for x in nums:
        prev2, prev1 = prev1, max(prev1, prev2 + x)  # skip x, or take it after i-2
    return prev1
C++
// Largest sum of elements with no two adjacent.
long long rob(const vector<int>& nums) {
    long long prev2 = 0, prev1 = 0;  // best totals up to i-2 and up to i-1
    for (int x : nums) {
        long long cur = max(prev1, prev2 + x);  // skip x, or take it after i-2
        prev2 = prev1;
        prev1 = cur;
    }
    return prev1;
}
Java
class Solution {
    // Largest sum of elements with no two adjacent.
    long rob(int[] nums) {
        long prev2 = 0, prev1 = 0; // best totals up to i-2 and up to i-1
        for (int x : nums) {
            long cur = Math.max(prev1, prev2 + x); // skip x, or take it after i-2
            prev2 = prev1;
            prev1 = cur;
        }
        return prev1;
    }
}
Go
// rob returns the largest sum of elements with no two adjacent.
func rob(nums []int) int {
	prev2, prev1 := 0, 0 // best totals up to i-2 and up to i-1
	for _, x := range nums {
		prev2, prev1 = prev1, max(prev1, prev2+x) // skip x, or take it after i-2
	}
	return prev1
}

常见变体

这一站的题还会用到下面 2 种写法。展开看什么时候换用它,代码同样四种语言都跑过测试。

自顶向下记忆化递归时间 O(n) · 空间 O(n)(备忘表 + 递归栈)

先写出「从 i 开始还有几种 / 最少要多少」的递归,再加一张备忘表,就是 DP 的另一种写法:转移不好排顺序、或者只有一部分状态会被用到时更顺手。LC 91 解码方法、LC 70 爬楼梯、LC 343 整数拆分都能这样写;Python 用 functools.cache。

Python
from functools import cache


def num_decodings(s: str) -> int:
    """Ways to read a digit string as letters, 1 = A ... 26 = Z (LC 91)."""

    @cache
    def ways(i: int) -> int:  # ways to decode s[i:]
        if i == len(s):
            return 1
        if s[i] == "0":
            return 0  # no letter's code starts with 0
        total = ways(i + 1)  # read one digit
        if i + 1 < len(s) and int(s[i : i + 2]) <= 26:
            total += ways(i + 2)  # or two
        return total

    return ways(0)
C++
// Ways to read a digit string as letters, 1 = A ... 26 = Z (LC 91).
long long numDecodings(const string& s) {
    int n = s.size();
    vector<long long> memo(n + 1, -1);  // memo[i]: ways to decode s[i..], -1 = not computed yet
    function<long long(int)> ways = [&](int i) -> long long {
        if (i == n) return 1;
        if (memo[i] >= 0) return memo[i];
        if (s[i] == '0') return memo[i] = 0;  // no letter's code starts with 0
        long long total = ways(i + 1);  // read one digit
        if (i + 1 < n && stoi(s.substr(i, 2)) <= 26) total += ways(i + 2);  // or two
        return memo[i] = total;
    };
    return ways(0);
}
Java
class Solution {
    private Long[] memo; // memo[i]: ways to decode s[i..], null = not computed yet
    private String s;

    // Ways to read a digit string as letters, 1 = A ... 26 = Z (LC 91).
    long numDecodings(String s) {
        this.s = s;
        memo = new Long[s.length() + 1];
        return ways(0);
    }

    private long ways(int i) {
        if (i == s.length()) return 1;
        if (memo[i] != null) return memo[i];
        long total = 0;
        if (s.charAt(i) != '0') { // no letter's code starts with 0
            total = ways(i + 1); // read one digit
            if (i + 1 < s.length() && Integer.parseInt(s.substring(i, i + 2)) <= 26) total += ways(i + 2); // or two
        }
        return memo[i] = total;
    }
}
Go
// numDecodings returns the ways to read a digit string as letters, 1 = A ... 26 = Z (LC 91).
func numDecodings(s string) int {
	n := len(s)
	memo := make([]int, n+1) // memo[i]: ways to decode s[i:], -1 = not computed yet
	for i := range memo {
		memo[i] = -1
	}
	var ways func(i int) int
	ways = func(i int) int {
		if i == n {
			return 1
		}
		if memo[i] >= 0 {
			return memo[i]
		}
		total := 0
		if s[i] != '0' { // no letter's code starts with 0
			total = ways(i + 1) // read one digit
			if i+1 < n && (s[i]-'0')*10+(s[i+1]-'0') <= 26 {
				total += ways(i + 2) // or two
			}
		}
		memo[i] = total
		return total
	}
	return ways(0)
}
环形拆成两条链时间 O(n) · 空间 O(1)

数组首尾相连、首尾不能同时选(环形打家劫舍):拆成「不要最后一个」和「不要第一个」两条普通链,各跑一遍线性 DP,取较大者。LC 213 打家劫舍 II;环形子数组最大和是另一种处理方式,见状态机 DP 那一站。

Python
def rob_circle(nums: list[int]) -> int:
    """House robber with the houses in a circle: the first and last are neighbours (LC 213)."""

    def rob_line(lo: int, hi: int) -> int:  # the plain robber on nums[lo:hi]
        prev2 = prev1 = 0
        for x in nums[lo:hi]:
            prev2, prev1 = prev1, max(prev1, prev2 + x)
        return prev1

    if len(nums) == 1:
        return nums[0]
    return max(rob_line(0, len(nums) - 1), rob_line(1, len(nums)))  # without the last, or without the first
C++
// House robber with the houses in a circle: the first and last are neighbours (LC 213).
int robCircle(const vector<int>& nums) {
    auto robLine = [&](int lo, int hi) {  // the plain robber on nums[lo, hi)
        int prev2 = 0, prev1 = 0;
        for (int i = lo; i < hi; i++) {
            int cur = max(prev1, prev2 + nums[i]);
            prev2 = prev1;
            prev1 = cur;
        }
        return prev1;
    };
    int n = nums.size();
    if (n == 1) return nums[0];
    return max(robLine(0, n - 1), robLine(1, n));  // without the last, or without the first
}
Java
class Solution {
    // House robber with the houses in a circle: the first and last are neighbours (LC 213).
    int robCircle(int[] nums) {
        int n = nums.length;
        if (n == 1) return nums[0];
        return Math.max(robLine(nums, 0, n - 1), robLine(nums, 1, n)); // without the last, or without the first
    }

    // The plain robber on nums[lo, hi).
    private int robLine(int[] nums, int lo, int hi) {
        int prev2 = 0, prev1 = 0;
        for (int i = lo; i < hi; i++) {
            int cur = Math.max(prev1, prev2 + nums[i]);
            prev2 = prev1;
            prev1 = cur;
        }
        return prev1;
    }
}
Go
// robCircle is the house robber with the houses in a circle: the first and last are neighbours (LC 213).
func robCircle(nums []int) int {
	robLine := func(line []int) int { // the plain robber on a line of houses
		prev2, prev1 := 0, 0
		for _, x := range line {
			prev2, prev1 = prev1, max(prev1, prev2+x)
		}
		return prev1
	}
	n := len(nums)
	if n == 1 {
		return nums[0]
	}
	return max(robLine(nums[:n-1]), robLine(nums[1:])) // without the last, or without the first
}

为什么对

  1. 最优子结构:前 i 个元素的最优解去掉最后一个决策,剩下的必须是对应子问题的最优解;否则换成更优的子解,整体会更好,矛盾。
  2. 按最后一个决策分类(偷不偷第 i 家、最后一步跨 1 级还是 2 级),把所有方案分成互不相交、又覆盖全部的几类,所以取 max 或求和不漏也不重。
  3. 滚动变量只在 dp[i] 只依赖 dp[i−1] 和 dp[i−2] 时成立,而且要先用旧值算出新值,再整体前移。

30 秒口述稿

面试官让你讲思路时,按做法、为什么对、复杂度的顺序说。中英文各一版,可以对着读几遍。

中文
动态规划我先用一句话定义状态,比如 dp[i] 表示用前 i 个元素能得到的最优值。然后按最后一个决策分类写转移:第 i 个选或不选,这几类互不重叠又覆盖所有方案,所以取最大值不会漏。正确性靠最优子结构:最优解去掉最后一步,剩下的也一定是子问题的最优解。如果只依赖前两项,就用两个变量滚动,空间降到 O(1)。
English
For DP I first define the state in one sentence, such as dp[i] being the best value using the first i elements. Then I write the transition by the last decision, take element i or not, and since those cases are disjoint and cover every plan, the max misses nothing. It is correct by optimal substructure: remove the last step from a best answer and what remains is a best answer to the smaller problem. When dp[i] needs only the previous two values, I roll two variables and use O(1) space.

自评一下

对照上面的答案给自己打个分。没掌握的题会按 1、3、7、21 天的间隔回来,到期的题在「我的进度 · 到期复习」里。 只存在这台设备上;登录后会合并到你的账号。

对照要点(0/3)
这道题你会了吗?

小测

4 道概念题和复杂度题。答过的题会和 OA 选择题练习一起按间隔回来复习。 去 OA 选择题练习

4 道题
  1. 哪两条性质让一个问题适合用动态规划?
  2. 关于记忆化搜索(自顶向下)和递推(自底向上),哪些说法正确?(多选)
  3. 下面代码的时间复杂度是?
    def fib(n):
        if n < 2:
            return n
        return fib(n - 1) + fib(n - 2)
  4. 第一次调用 fib(n) 的时间复杂度是?(把整数加法看作 O(1))
    from functools import lru_cache
    
    @lru_cache(maxsize=None)
    def fib(n):
        if n < 2:
            return n
        return fib(n - 1) + fib(n - 2)

作答记录只保存在这台设备的浏览器里;登录后打开OA 选择题练习页,会存进账号。

模板练习

用上面的模板和变体出题:把打乱的行排回原序,把挖掉的边界条件填回去,或者在空白编辑器里整段默写,跑本站的测试并逐行对比。每次「换一题」都会重新出题。

下面几行被打乱了。点一行选中,再点另一行和它交换,排回模板原来的顺序;缩进是提示。

分档练习

本站在 500 路里的 10 道题,按基础、核心、进阶分三档。每题附一句话思路,先自己想,想不出再看。

基础 3

#题目一句话思路难度分
509斐波那契数用递归、记忆化和滚动变量对照理解重复子问题;能够说明时间与空间复杂度。
70爬楼梯从最后一步推导计数递推,训练空前缀和最小输入的初始化。
746使用最小花费爬楼梯区分到达位置与支付台阶费用两种状态定义,处理可从两个起点出发。难度分 1358

核心 5

#题目一句话思路难度分
198打家劫舍建立选或不选的相邻排斥模型,并将完整表压缩为常数空间。
213打家劫舍 II通过固定首尾选择拆解环形依赖,检查单元素和区间边界。
740删除并获得点数把值之间的冲突转成打家劫舍,训练聚合计数后的模型转换。
91解码方法按末尾一位或两位划分解码方案,严谨处理零和非法编码。
2369检查数组是否存在有效划分将固定长度分组规则转成前缀可达性,分清连续递增与相等三元组。难度分 1780

进阶 2

#题目一句话思路难度分
343整数拆分训练必须拆分与允许继续拆分两种状态的区别,并解释DP与数学解法的联系。
650两个键的键盘根据最后一次复制确定子问题,把操作序列化简为因子分解的代价。