力扣算法解析按专题吃透,不做题海 速查 90 天路线 ⌂ 全部教程

专题 10动态规划(进阶):子序列与背包

专题 09 你学会了「一维 DP 五步法」。这一章进入 DP 面试真正的深水区——子序列和背包。 这两类题占了中等/困难 DP 的大头,而且套路极其固定:子序列靠「二维 dp + 字符相等与否的分支」,背包靠「一维数组 + 遍历顺序决定 01 还是完全」。 本章重点不教你怎么背,而是让你看懂「为什么 01 背包必须倒序、完全背包必须正序」这个最常被问、也最常被糊弄过去的点。

学习目标
掌握 LIS/LCS 与 01、完全背包
前置要求
专题 09 的 DP 五步法
本章产出
4 道子序列/背包题 + 一张背包对照表
建议学时
8–10 小时

10.1DP 的两大主线:子序列与背包

进阶 DP 看起来题海茫茫,其实绝大多数都能归到两条主线上。先一句话分清它们的形态差异,后面就不乱了:

主线问题长什么样典型题型
子序列型 给定一/两个序列,问「最长的 / 最多的 / 能不能拼出」某种保持相对顺序的子序列。 最长递增子序列、最长公共子序列、编辑距离
背包型 给定一堆「物品」(有重量/价值/数量),问在容量限制下怎么选,求最大价值或能否凑出某数。 分割等和子集、零钱兑换、组合总和 IV
判别诀窍:题目里出现「顺序不能乱」「子序列」这类词,多半是子序列型(二维 dp); 出现「选若干个数」「恰好凑出」「最少几个硬币」,多半是背包型(一维 dp)。 两条线的核心技巧完全不同,本章分两半讲。

先分清「子序列」和「子串」

这两个词经常被混用,但 DP 写法天差地别,面试也常考你这个区分:

概念含义是否连续例题
子串原串中连续的一段必须连续最长重复子串、无重复字符的最长子串(见专题 03)
子序列删掉若干字符后剩下的,保持原顺序但不必连续可不连续最长递增子序列、最长公共子序列(本题)

一句话:子串用「滑动窗口/双指针」(因为连续,左右边界能滑动); 子序列用「二维 DP」(因为不连续,必须枚举「以谁结尾 / 前面配对到哪」)。看到「子序列」三个字,基本就该往本章的二维 dp 上想了。


10.2最长递增子序列(中等,力扣 300)

中等 核心:以谁结尾 + 递推

题意:给你一个整数数组,找出其中严格递增的最长子序列的长度(子序列不要求连续)。 例如 nums = [10,9,2,5,3,7,101,18],最长递增子序列是 [2,3,7,101] 或 [2,5,7,101],长度 4。

思路推导

暴力枚举所有子序列是指数级的,显然不行。关键观察:一个递增子序列一定「以某个数结尾」。 那我们换个角度——不去想「全局最长是哪个」,而是先算「以每个位置 i 结尾,最长能多长」,最后再取全局最大。

状态:dp[i] = 以 nums[i] 结尾的最长递增子序列的长度。

怎么求 dp[i]?看 i 前面所有比 nums[i] 小的数 j(即 nums[j] < nums[i])。 如果选了 nums[i],那它一定能接在以 nums[j] 结尾的递增子序列后面,长度变成 dp[j] + 1。 我们取所有满足条件的 j 里 dp[j]+1 的最大值;如果前面没有更小的,那 nums[i] 自己开头,长度为 1:

dp[i] = 1
对每一个 j < i 且 nums[j] < nums[i]:
    dp[i] = max(dp[i], dp[j] + 1)

初始化:每个 dp[i] 至少是 1(只包含自己)。答案:不是 dp[n-1],而是整个 dp 数组的最大值——因为最长子序列不一定以最后一个元素结尾。

参考代码(O(n²) 定义法)

class Solution {
    public int lengthOfLIS(int[] nums) {
        int n = nums.length;
        // dp[i] 表示以 nums[i] 结尾的最长递增子序列长度
        int[] dp = new int[n];
        int ans = 1;                         // 至少有一个元素,长度至少 1
        for (int i = 0; i < n; i++) {
            dp[i] = 1;                       // 初始化:自身构成一个长度为 1 的子序列
            for (int j = 0; j < i; j++) {
                if (nums[j] < nums[i]) {     // 只有更小的才能接在后面
                    dp[i] = Math.max(dp[i], dp[j] + 1);
                }
            }
            ans = Math.max(ans, dp[i]);       // 全局最大值,不一定在末尾
        }
        return ans;
    }
}

复杂度(O(n²) 版):时间 O(n²)(两层循环),空间 O(n)。n ≤ 2500 时可通过。

进阶:O(n log n) 的贪心 + 二分(理解优先级低)

有一个更快但更绕的思路:维护一个数组 tails,tails[k] 表示「长度为 k+1 的递增子序列,其结尾元素能取到的最小值」。 遍历 nums,对每个 x 用二分在 tails 里找第一个 ≥ x 的位置替换掉(保持 tails 递增),x 比所有都大就追加。 tails 的最终长度就是答案。下面给出代码,面试通常 O(n²) 已够,这段代码看懂即可,不必强背:

class Solution {
    public int lengthOfLIS(int[] nums) {
        // tails[k] 存「长度为 k+1 的 LIS 的最小可能结尾」
        int[] tails = new int[nums.length];
        int size = 0;                        // 当前tails有效长度
        for (int x : nums) {
            int lo = 0, hi = size;
            while (lo < hi) {                // 二分找第一个 >= x 的位置
                int mid = (lo + hi) / 2;
                if (tails[mid] < x) lo = mid + 1;
                else hi = mid;
            }
            tails[lo] = x;                   // 替换(或追加)以维持「更小结尾」
            if (lo == size) size++;          // 找到了更长的序列
        }
        return size;
    }
}
关键点:「以谁结尾」是子序列 DP 的核心视角转换。 不要总想着全局最优,先固定结尾、算局部最优,最后再聚合。这个思路在「最长公共子序列」「最长摆动子序列」里都复用。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 最长递增子序列的个数(力扣 673,中等)——与本题共用「以谁结尾 + 递推」技巧;差异在除了求最长长度,还要统计达到该长度的方案数(提示:再维护一个 cnt[i] 表示以 i 结尾的 LIS 个数,长度更新时分「严格变长」与「相等刷新」两种情况处理)。
  • 最长公共子序列(力扣 1143,中等)——与本题共用「子序列型 DP」视角;差异在本题是「两个序列」求公共子序列,状态变成二维 dp[i][j],转移靠「相等取左上+1、不等取 max(左,上)」(提示:可先在本章 10.3 学完再回来对照)。

10.3最长公共子序列(中等,力扣 1143)

中等 核心:相等取左上 +1,否则取左/上最大

题意:给定两个字符串 text1、text2,求它们的最长公共子序列(LCS)长度,子序列可不连续。 例如 text1 = "abcde",text2 = "ace",LCS 是 "ace",长度 3。

思路推导

两个序列,自然想到二维 dp。状态:dp[i][j] = text1 的前 i 个字符(下标 0..i-1)与 text2 的前 j 个字符(下标 0..j-1)的 LCS 长度。

核心在两字符「相等与否」分两种情形(这是子序列型二维 DP 的标志性分支):

为什么相等时取「左上 +1」?因为当前这对匹配字符,是在「两者都还没用到这对字符」的基础上延长出来的公共子序列, 而「两者都没用这对字符」的状态恰好就是同时去掉最后一个字符——即 dp[i-1][j-1](左上方格)。它不是 dp[i-1][j] 也不是 dp[i][j-1], 那两个都只退了一边,会把刚匹配的那个字符算漏或重复。

初始化:第 0 行、第 0 列表示「有一个字符串为空」,LCS 长度自然是 0,所以 dp 数组默认 0 即可,不必额外赋值。 从 i=1、j=1 开始填。

参考代码

class Solution {
    public int longestCommonSubsequence(String text1, String text2) {
        int m = text1.length(), n = text2.length();
        // dp[i][j]:text1前i个字符 与 text2前j个字符 的 LCS 长度
        int[][] dp = new int[m + 1][n + 1];   // 默认全 0,正好是第0行/列=0
        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
                    // 两字符相等:左上方状态 + 1
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                } else {
                    // 不相等:退掉一边,取较大者
                    dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
                }
            }
        }
        return dp[m][n];
    }
}

复杂度:时间 O(m×n),空间 O(m×n)。两字符串长度都 ≤ 1000,约 10⁶ 次操作,稳。

亲手填一遍:text1="abc"、text2="ac"

把 dp 表画出来,你会更确信「相等取左上、不相等取 max(左,上)」:

      ""  a   c        (表头是 text2 的前 j 个字符)
""    0   0   0
a     0   1   1        a==a -> 左上0+1=1;a!=c -> max(左1,上0)=1
b     0   1   1        b!=a -> max(0,1)=1;b!=c -> max(1,1)=1
c     0   1   2        c!=a -> max(0,1)=1;c==c -> 左上1+1=2

右下角是 2,对应 LCS "ac"。注意第 3 行 c 列:text1 的 'c' 遇到 text2 的 'c' 相等,取的是左上方 dp[2][1]=1 再加 1 = 2—— 左上方正是「text1 前 2 个字符 'ab' 与 text2 前 1 个字符 'a' 的 LCS」,把这对匹配的 'c' 接到它后面,长度 +1。这就是「左上 +1」的几何意义。

关键点:「相等取左上 +1、不相等取 max(左, 上)」这个公式是子序列二维 DP 的通用骨架。 后面遇到「编辑距离」「最长重复子数组」都是它的近亲,先把这题的转移画到纸上彻底想通。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 两个字符串的删除操作(力扣 583,中等)——与本题共用「相等取左上+1、不等取 max」二维 DP 框架;差异在求的是「让两串变成相同所需最少删除字符数」,答案 = 两串长度之和 − 2 × LCS 长度(提示:先算出 LCS,再用总长减去两倍 LCS;也可直接 DP 求最小删除数)。
  • 两个字符串的最小 ASCII 删除和(力扣 712,中等)——与本题共用「两串二维 DP + 相等/不等分支」;差异在代价从「次数」变成「ASCII 值之和」,求删除字符的 ASCII 和最小(提示:dp[i][j] 存最小删除代价,相等时直接继承左上,不等时取「删左」与「删右」的较小值)。

10.4分割等和子集:01 背包(中等,力扣 416)

中等 核心:为什么一维背包必须倒序遍历

题意:给你一个正整数数组 nums,问能否把它分成和相等的两部分。例如 nums = [1,5,11,5],可以分成 [1,5,5] 和 [11],和都是 11,返回 true。

思路推导(把问题翻译成背包)

能平分,意味着存在一个子集,其和恰好等于 sum / 2。如果 sum 是奇数,直接不可能(返回 false)。 所以问题变成:从数组里选若干个(每个最多选一次)数,能否恰好凑出 sum/2?

这正是一个01 背包:每个数是一件「物品」,它的「重量」和「价值」都等于自己,背包容量 = sum/2, 问能否装满(价值能否达到容量)。每个物品只能选一次,所以是「01」背包(0 个或 1 个)。

状态:用一维数组 dp[j] 表示「能否凑出总和 j」(布尔)。初始化 dp[0] = true(凑出 0 总是可以的,一个都不选)。

转移:对每个数 num,我们要看「选了 num 之后,哪些 j 变得可凑」:如果 dp[j - num] 本来可凑,那 dp[j] 也可凑(j - num + num = j)。即 dp[j] = dp[j] || dp[j - num]。

为什么必须「倒序」遍历 j —— 本题最该讲清的点

关键就在这:每个数 只能选一次。如果我们正序从 num 遍历到容量 target,会发生什么?

正序(错误):for j = num -> target:
    dp[j] = dp[j] || dp[j - num]
// 当处理到某个 j 时,dp[j-num] 已经被本轮这个 num 更新过了!
// 意味着同一个 num 被「再次使用」了,等价于完全背包,错。

举例:num = 3,target = 6。正序时 j=3 把 dp[3] 置 true;接着 j=6 看 dp[3] 已经是 true,于是 dp[6] 也变 true—— 但这是「用了两次 3」(3+3=6),而 01 背包里 3 只有一个!所以正序会让一个物品被重复选。

倒序则从大到小遍历:当更新 dp[j] 时,它依赖的 dp[j - num] 还是上一轮(没考虑当前 num)的旧值, 从而保证当前 num 在这一轮里最多贡献一次:

倒序(正确):for j = target -> num (步长 -1):
    dp[j] = dp[j] || dp[j - num]
// 更新 dp[j] 时,dp[j-num] 尚未被本轮修改,仍是上一轮结果

一句话记忆:要「每个物品只用一次」就倒序;要「每个物品可用无限次」就正序(后者是 10.5 的完全背包)。

参考代码

class Solution {
    public boolean canPartition(int[] nums) {
        int sum = 0;
        for (int x : nums) sum += x;
        if (sum % 2 != 0) return false;        // 和是奇数,无法平分
        int target = sum / 2;
        // dp[j] 表示「能否凑出总和 j」
        boolean[] dp = new boolean[target + 1];
        dp[0] = true;                           // 凑 0 总是可以(什么都不选)
        for (int num : nums) {                  // 外层遍历每个物品(每个只能选一次)
            // 内层倒序:避免同一个 num 被重复使用
            for (int j = target; j >= num; j--) {
                dp[j] = dp[j] || dp[j - num];
            }
        }
        return dp[target];
    }
}

复杂度:时间 O(n × target),空间 O(target)。n ≤ 200,target ≤ 20000 量级,约 400 万次,稳。

最重要的一点:01 背包内层循环必须倒序,这是「物品不重复」的几何保证。 把上面「正序会重复使用」的小例子在纸上推一遍,比背十遍都管用。面试被追问「为什么倒序」时,就举 3+3=6 这个反例。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 目标和(力扣 494,中等)——与本题共用「01 背包 + 倒序遍历」技巧;差异在把数组分成正负两堆使差为 target,可转化为「选若干个数使其和等于 (sum−target)/2」的背包计数(提示:dp[j] 存方案数,转移 dp[j] += dp[j−num],仍是 01 倒序,最后取 dp[目标])。
  • 最后一块石头的重量 II(力扣 1049,中等)——与本题共用「凑出 sum/2 的 01 背包」思路;差异在把石头分成两堆使重量差最小,即尽量让一堆接近总和的一半(提示:用本题背包求「最大能凑到多接近 sum/2」的载重,答案 = sum − 2 × 最大可凑值)。

为什么能压成一维(顺便理解倒序)

01 背包最原始的写法是二维:dp[i][j] 表示「考虑前 i 个物品、容量 j 时能否凑出」。 转移是 dp[i][j] = dp[i-1][j] || dp[i-1][j-num]——注意它只用到上一行 dp[i-1],从不依赖本行更前的格子。 既然每次只用到上一行,我们就能把两行合并成一行:边算边覆盖,但必须保证「覆盖前用的还是上一行的值」。

倒序遍历恰好满足这点:更新 dp[j] 时,dp[j] 和 dp[j-num] 都还是「还没被第 i 个物品改过」的旧值(即上一行的 dp[i-1])。 如果正序,dp[j-num] 已经被第 i 个物品改成新值(本行),那就等于用了 dp[i][j-num],违背了「只用上一行」的前提—— 也就是允许了重复选。所以「倒序」不是玄学,它是「二维压一维时,保证只读上一行」的直接后果。


10.5零钱兑换:完全背包(中等,力扣 322)

中等 核心:与 01 背包唯一区别 = 正序

题意:给定不同面额的硬币 coins 和总金额 amount,每种硬币无限个,求凑出 amount 的最少硬币数;凑不出返回 -1。 例如 coins = [1,2,5], amount = 11,最优 5+5+1 = 11,用 3 个硬币,返回 3。

思路推导

这题和 10.4 几乎一样,只是两点不同:(1) 每种硬币无限个(完全背包);(2) 求的是「最少个数」而不是「能否」。 把 dp 含义改成「凑出金额 j 的最少硬币数」:

完全背包为什么「正序」

因为硬币无限,我们希望同一个 coin 能被反复使用。正序遍历时,更新 dp[j] 用到的 dp[j-coin] 已经包含了「本轮这个 coin」的贡献, 于是 coin 自然可以叠加多次——这恰恰是我们要的。所以完全背包的内层是正序,与 01 背包恰好相反。

另外注意:本题 coins 在外层循环(每个面额分别处理),amount 在内层。这和「求组合数」的写法(amount 在外、coins 在内)不同, 本题求的是最少个数,两种循环顺序对结果无影响,习惯上把 coins 放外层更直观。

参考代码

class Solution {
    public int coinChange(int[] coins, int amount) {
        // dp[j] 表示凑出金额 j 的最少硬币数
        int[] dp = new int[amount + 1];
        // 初始化为一个不可能的大数,表示「暂不可凑出」
        for (int j = 1; j <= amount; j++) dp[j] = amount + 1;
        dp[0] = 0;                              // 凑 0 元用 0 个硬币
        for (int coin : coins) {               // 外层:每种面额
            // 内层正序:同一 coin 可重复使用(完全背包)
            for (int j = coin; j <= amount; j++) {
                dp[j] = Math.min(dp[j], dp[j - coin] + 1);
            }
        }
        // 若仍是初始大数,说明凑不出
        return dp[amount] > amount ? -1 : dp[amount];
    }
}

复杂度:时间 O(coins.length × amount),空间 O(amount)。amount ≤ 10⁴,完全够。

关键点:完全背包与 01 背包唯一的代码差别就是内层循环的方向。 01 倒序(防重复)、完全正序(允重复)。把这一个差别记住,两类背包题就通了。 求「能否」用布尔数组、求「最少」用大数初始化——见 10.7 的坑三。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 零钱兑换 II(力扣 518,中等)——与本题共用「完全背包 + 正序遍历」框架;差异在求的是「凑出 amount 的组合数」而非最少个数(提示:dp[j] 存方案数,转移 dp[j] += dp[j−coin],且须把 amount 放外层、coins 放内层以保证不重复计数顺序)。
  • 完全平方数(力扣 279,中等)——与本题共用「完全背包求最少个数」;差异在「物品」不是给定硬币,而是 1²、2²、3²…这些完全平方数且可无限使用(提示:把平方数当作 coin 列表,直接套本题完全背包正序求最小数量即可)。

10.6背包问题一张表总结

背包装类题经常混,下面这张表把「01 vs 完全」「遍历顺序」「适用问题」一次性列清,建议截图背下来:

维度01 背包(每物 1 个)完全背包(每物无限)
物品含义每个物品最多选 1 次(如分割等和子集)每个物品可选任意多次(如零钱兑换)
内层顺序倒序 j = cap → w正序 j = w → cap
为什么倒序保证 dp[j-w] 是上一轮旧值,物品不重复正序让 dp[j-w] 含本轮贡献,物品可重复
求「能否」dp 用 boolean,dp[0]=true同左
求「最少/最大」dp 用 int,dp[0]=0,其余 ±∞同左
典型题416 分割等和、474 一和零、494 目标和322 零钱兑换、518 零钱组合、279 完全平方数
速记:「01 倒、完全正;能否布尔、最值大数」。 拿到背包题先判是 01 还是完全,再判求「能否」还是「最值」,然后套这张表,基本不会写错遍历方向。

10.7本专题常见坑

  • 坑一:一维背包方向写反。 01 写了正序、或完全写了倒序,结果一个物品被重复/无法使用。解法:写循环前先在注释里写清「这是 01 还是完全」,再决定方向。
  • 坑二:LCS 的 0 行 0 列含义不清。 dp 数组开成 (m+1)×(n+1),第 0 行/列代表「有一个串为空」,LCS 必为 0,正好用默认值 0,不要开成 m×n 然后手动处理边界,极易越界。
  • 坑三:求「能否」与求「最少」初始化不同。 「能否凑出」用 boolean[],dp[0]=true;「最少硬币」用 int[],dp[0]=0、其余设 amount+1(或 Integer.MAX_VALUE,但相加会溢出,故用 amount+1 更安全)。混用会导致永远凑不出或溢出。
  • 坑四:LIS 答案取错位置。 LIS 的最长不一定以最后一个元素结尾,必须全程维护全局最大值,不能 return dp[n-1]。
  • 坑五:背包容量边界。 完全背包内层从 j=coin 开始(j < coin 放不下),01 背包倒序到 j=num 停。从 0 开始会数组越界(j-coin 为负)。
  • 坑六:忽略「整除/不可达」特判。 分割等和子集 sum 为奇数直接 false;零钱兑换最后要检查是否仍为大数再返回 -1,否则会返回错误的大数。
自检:背包题写完,问自己三句——是 01 还是完全(决定方向)?求能否还是最值(决定初始化)? 不可达/奇数/越界这些边界处理了吗?三句都答上,提交前就有底了。

10.8自测题

Q1.【动手题】把 10.4 的「能否分割等和子集」改成「求子集和等于 target 的方案数」(即组合总和),dp 含义和转移怎么改?

dp[j] 改为「凑出 j 的方案数」,用 int(或 long)数组,dp[0]=1,其余 0。 转移:dp[j] += dp[j - num](不再是 ||)。注意这变成「求组合数」时通常把 amount 放外层、物品放内层; 若仍 01 风格(物品外层、倒序),得到的是考虑顺序的组合数。题目若问「子集个数」用 01 倒序即可。

Q2. 为什么 01 背包「倒序」能保证不重复,而正序的 dp[j-coin] 就一定被本物品更新过?用 num=2、target=4 推一遍。

倒序 j=4,3,2:先算 dp[4] 用到的 dp[2] 是上一轮(没考虑当前 num=2)的旧值; 再算 dp[3]、dp[2] 时 dp[2] 仍未被本轮改(因为是从大到小,dp[2] 在 dp[4] 之后才轮到)。所以 2 只贡献一次。 正序则 j=2 先把 dp[2] 置 true(选了一个 2),随后 j=4 看 dp[2] 已是 true,于是再叠一个 2 得到 4——同一物品用了两次。

Q3.【动手题】力扣 518「零钱兑换 II」求组合数,和 10.5 求最少个数的遍历顺序有何不同?为什么?

求「最少个数」时 coins 内外层无所谓;求「组合数」(把 1+2 和 2+1 视为同一种)必须 amount 在外层、coins 在内层、coins 正序, 这样保证「先固定用哪几种面额的组合」,避免把顺序不同的同一种组合重复计数。若 coins 在外、amount 在内,会把不同顺序算成不同方案。

学完这章,你应该能回答:「01 背包和完全背包,代码上到底差哪一行?为什么?」 答案:差在内层循环的遍历方向——01 倒序(dp[j] 用到的 dp[j-w] 是上一轮旧值,保证每件最多一次), 完全正序(dp[j-w] 已含本轮贡献,允许重复)。再补一句:求最值用大数初始化、求能否用布尔。 能讲清这点,就可以进 专题 11 · 贪心算法,用 DP 作对比理解「贪心为什么敢贪」。