C++ / ACM 自学笔记 · v5.0

(十三) 动态规划(DP)基础

上一节计算网格路径时,F(1,2) 要先等到 F(0,2) 与 F(1,1) 的结果,再把 1+2 写入缓存。在迭代表中,把同一个子问题的答案记成 dp[r][c],它仍表示从起点到格子 (r,c) 的路径数。现在换一个计算顺序:先写起点,再依次处理上方和左方已经算好的格子。到 (1,2) 时两个前驱就在表里,直接读出它们即可。问题的拆法没有变化,变化的是状态的计算顺序。

从网格路径拆分、前驱数量到递归和填表的对应关系

图中每个数字都表示一组路径的数量。dp[1][1]=2 保存的是到这个格子的两条路径的总数;以后接上同一个向右动作时,两条路径各产生一条新路径,所以可以直接使用这个数量,不必重新列出两条路线。动态规划(Dynamic Programming,DP)就是把这些子问题整理成状态,再用已求出的状态计算后面的状态。

推导示范:从三条路线得到一次转移

先只考虑无障碍的 2×3 网格,R 表示右移,D 表示下移。到 (1,2) 的路线是 RRD、RDR、DRR。把最后一步暂时拿掉,逐条核对:

当前完整路线 删去最后一步 剩下的路线终点 接回的动作
RRD RR 上方 (0,2) D
RDR RD 左方 (1,1) R
DRR DR 左方 (1,1) R

到上方的每条路线补一个 D,到左方的每条路线补一个 R,就还原了全部路线。两个集合的最后一步不同,一条路线不会在两边各数一次。因此 dp[1][2]=dp[0][2]+dp[1][1]=1+2=3。

把具体坐标 (1,2) 换成一般坐标 (r,c):上方的行号少 1,左方的列号少 1,得到

\[dp[r][c]=dp[r-1][c]+dp[r][c-1].\]

这里的加号来自“把两个不重叠的路线集合合在一起”。后面求最大价值时比较候选价值,求最少编辑时比较候选费用,合并方式随目标变化。

状态中的信息与后续选择

一个状态是对一个子问题的完整说明,不能只写“dp[i] 表示答案”。例如:

状态 它保存的答案 它允许的方案
最大子段和的 dp[i] 最大和 必须连续,并且必须以位置 i 结尾
LIS 的 dp[i] 最大长度 可以跳过位置,但必须以位置 i 结尾
LCS 的 dp[i][j] 最大长度 从两串的前 i、j 个字符中选择,可以不使用末尾
01 背包的 dp[i][j] 最大价值 只使用前 i 件物品,总重量不超过 j

“固定结尾”使我们知道下一项接在哪里;“前缀长度”限定当前能使用哪些字符;背包还需要容量,因为它决定下一件能否放下。状态里的每个下标都有具体用途。

再看楼梯:到同一层的 [1,1,2] 与 [2,1,1],如果下一步可以任意跳 1、2、3 级,两条路线都能接同样的动作,保存总数就够了。若题目改成“不能连续两次跳 2 级”,第一条后面接 2 不合法,第二条却合法。这时就把到第 i 级的数量分成“最后跳 2”和“最后没有跳 2”两个状态,分别计算,再按后续动作读取。

相同楼层下两条路线的后续动作,以及补充末步信息后的状态

这种“记录的信息足以决定后续操作”的性质叫无后效性。同一个子问题被不同分支请求,叫子问题重叠。求最值时,如果能把方案中的前一部分替换为同状态的更优方案,并且仍能接上当前动作,就可以只保存前驱的最优值,这叫最优子结构。后面的例子会直接展示这样的替换。

从方案拆分到程序

推导一个方程时,可以先计算一个具体格子,再把数字替换成变量:

  1. 写清当前格子允许哪些完整方案,以及保存数量、最大值还是最小值。
  2. 列出几个实际方案,按最后一步、倒数第二位置或当前物品的选择分组。
  3. 删去当前动作,读出剩余子问题的下标。剩余方案中仍可使用哪些字符、物品,容量或金额还剩多少,都在这一步确定。
  4. 把动作接回去。计数要核对每条方案恰好数一次;最值要核对换成更优前驱后仍然合法。
  5. 把每组的结果合并,才写出方程。再用最简单的状态解释初值,沿依赖安排循环,确定最后读取哪个格子。

dp 表里存的是子问题的计算结果。方程说明读取哪些结果以及如何合并;循环说明这些结果按什么顺序产生。一维压缩还要区分读取的是上一轮还是本轮,这一点将在背包中用实际数组变化解释。

1. 线性(一维)DP

1.1 斐波那契

题目原文(自拟): 定义 F(0)=0、F(1)=1,n>=2 时 F(n)=F(n-1)+F(n-2)。给定 0 <= n <= 46,返回 F(n)。示例采用常见 32 位 int 范围。

思路分析: 递归会多次请求同一项,而递推顺序 0、1、2、…、n 使每项都只计算一次。定义 fib[i]=F(i),先填第 0、1 项,再用前两项推出当前项。

下一项只依赖最近两项,因此还能用 prev2、prev1 保存它们。先将两个旧值相加得到 cur,再写入 prev2=prev1、prev1=cur;这样计算时用的是前两项,更新后保存的又是下一轮所需的最近两项。

斐波那契递推与最近两项滚动

从 (F(3),F(4))=(2,3) 算出 F(5)=5 后,保留的窗口变为 (3,5)。

从定义读取依赖。 这里的递推关系来自题目定义,状态 fib[i] 对应 F(i),无需从方案分类另猜一个公式。F(0)=0、F(1)=1 已给定;计算 F(2) 时读取 1 和 0 得到 1,F(3)=1+1=2,F(4)=2+1=3,F(5)=3+2=5。依赖下标始终比 i 小,正序填表合法。题目只要求第 n 项,答案就是 fib[n],而不是把整张表求和。

代码示例: fibonacci 保存整张表,fibonacci_optimized 只保留两个前驱。

#include <vector>
using namespace std;

int fibonacci(int n) {
    // 边界情况处理
    if (n < 2)
        return n;

    vector<int> fib(n + 1);
    fib[0] = 0, fib[1] = 1;
    for (int i = 2; i <= n; ++i)
        fib[i] = fib[i - 1] + fib[i - 2];
    return fib[n];
}

int fibonacci_optimized(int n) {
    // 边界情况处理
    if (n < 2)
        return n;

    int prev2 = 0; // fib[i-2]
    int prev1 = 1; // fib[i-1]
    int cur;       // fib[i]

    // 迭代计算斐波那契数列
    for (int i = 2; i <= n; i++) {
        cur = prev1 + prev2; // 计算当前项
        prev2 = prev1;       // 更新prev2为前一项
        prev1 = cur;         // 更新prev1为当前项
    }
    return cur;
}

样例与解释: n=0 返回 0;n=5 依次得到 0、1、1、2、3、5,返回 5;n=10 返回 55。

方法 时间 额外空间 需要考虑
朴素递归 指数级,上界 \(O(2^n)\) \(O(n)\) 栈 重复子问题很多
记忆化递归 \(O(n)\) \(O(n)\) 缓存与递归栈
迭代填表 \(O(n)\) \(O(n)\) 可以查询已经计算的各项
滚动变量 \(O(n)\) \(O(1)\) 不保留完整历史
固定大小矩阵快速幂 \(O(\log n)\) 次矩阵操作 \(O(1)\) 大指数题还需确定取模或大整数规则

复杂度表以机器整数运算为常数代价。超出类型范围时,单纯换成快速幂不会解决精确值溢出;使用大整数后还要计入大整数运算成本。

练习题: 洛谷 P1255 — 数楼梯(斐波那契型递推,完整范围需大整数)。

1.2 跳楼梯

题目原文(自拟): 从第 0 级出发,每次可以上 1、2 或 3 级,求到达第 n 级的不同跳跃序列数量。0 <= n <= 60,不跳任何一步的空方案计为到达第 0 级的一种方案,结果在 long long 范围内。

思路分析: 先求到第 4 级的方案数。这里的一条方案是完整的跳跃序列,[1,3] 与 [3,1] 是不同方案。令 f[i] 表示从第 0 级恰好到第 i 级的序列数量。

把到第 4 级的七条方案按最后一步分组,再删去末步:

最后一步 到第 4 级的完整方案 删去末步后的方案 前驱状态
跳 1 [1,1,1,1]、[1,2,1]、[2,1,1]、[3,1] [1,1,1]、[1,2]、[2,1]、[3] 到第 3 级,共 f[3]=4 条
跳 2 [1,1,2]、[2,2] [1,1]、[2] 到第 2 级,共 f[2]=2 条
跳 3 [1,3] [1] 到第 1 级,共 f[1]=1 条
七条楼梯方案删去末步、确定前驱并逐条接回

每条完整方案都有且只有一个末步,所以三类覆盖七条方案,没有重复。反过来,到第 3 级的任意序列补一个 1,到第 2 级的任意序列补一个 2,到第 1 级的序列补一个 3,都能到第 4 级。于是 f[4]=4+2+1=7。

把目标 4 换成 i,删去末步 1、2、3 后分别到达 i-1、i-2、i-3,同样逐条接回,就得到

\[f[i]=f[i-1]+f[i-2]+f[i-3]\qquad(i\ge3).\]

f[0]=1 表示空序列 []:从地面直接跳 3 时,是给这个空序列补一个 3,因此它要贡献一条方案。到第 1 级只有 [1],到第 2 级有 [1,1]、[2],所以 f[1]=1、f[2]=2。继续算第 3 级,得到 2+1+1=4,恰好对应 [1,1,1]、[2,1]、[1,2]、[3]。

计算第 i 项前,前三项都已算好,因此按 0、1、2、…、n 填表。题目问到第 n 级的数量,读取 f[n]。滚动版本只保留最近三个数,先相加得到新值,再将这三个数向前移一项。

代码示例: 两个函数分别保存整张表和最近三个状态。

#include <vector>
using namespace std;

using ll = long long;
ll stairs(int n) {
    // 边界情况处理
    if (n < 0)
        return 0;
    if (n == 0)
        return 1;
    if (n == 1)
        return 1;
    if (n == 2)
        return 2;

    vector<ll> f(n + 1);
    f[0] = 1; // 基准情况:从地面开始算1种方式
    f[1] = 1; // 爬到第1级:1种方式(爬1级)
    f[2] = 2; // 爬到第2级:2种方式(1+1或2)

    for (int i = 3; i <= n; ++i)
        f[i] = f[i - 1] + f[i - 2] + f[i - 3]; // 状态转移方程

    return f[n];
}

ll stairs_optimized(int n) {
    if (n < 0)
        return 0;
    if (n == 0)
        return 1;
    if (n == 1)
        return 1;
    if (n == 2)
        return 2;

    ll a = 1; // f[i-3]
    ll b = 1; // f[i-2]
    ll c = 2; // f[i-1]
    ll d;     // f[i]

    for (int i = 3; i <= n; ++i) {
        d = a + b + c; // 计算当前状态
        a = b;         // 滚动更新
        b = c;
        c = d;
    }

    return c; // 最后c存储的就是f[n]
}

样例与解释: n=3 的方案为 1+1+1、1+2、2+1、3,共 4 种;n=4 共 7 种。两个函数时间均为 \(O(n)\),额外空间分别为 \(O(n)\)、\(O(1)\)。

状态扩展: 每次只允许 1 或 2 级时,转移改成前两项之和;允许 1…m 级时,\(f[i]=\sum_{j=1}^{\min(i,m)} f[i-j]\),直接计算为 \(O(nm)\),也可以维护前驱窗口和来优化。若禁止连续两次跳 2 级,还需记录“最后一步是否为 2”,只记录总方案数不足以判断后续是否合法。

练习题: 洛谷 P1255 — 数楼梯(题目只允许每次 1 或 2 级,需调整转移)。

1.3 最大连续子数组和

题目原文(自拟): 给定非空整数数组,求至少包含一个元素的连续子数组最大和;各累计和在 long long 范围内。还可以要求返回一组达到最大和的起止下标,结束下标包含在区间内。函数对空输入另约定返回和 0,下标为 (-1,-1)。

思路分析: 先看 [-2,1,-3,4]。如果只记录“处理完前几个数的最大和”,最佳区间可能早就结束,接上下一项未必连续。为让接长操作成立,我们先固定右端:dp[i] 表示必须以位置 i 结尾的非空连续子数组最大和。

计算 dp[3] 时,四个允许的区间都以 4 结尾。把最后一个 4 拿掉:

当前区间 当前和 去掉 4 后 剩余和
[4] 4 没有前一段,单独从位置 3 开始 —
[-3,4] 1 [-3],结尾为位置 2 -3
[1,-3,4] 2 [1,-3],结尾为位置 2 -2
[-2,1,-3,4] 0 [-2,1,-3],结尾为位置 2 -4
固定右端的四个区间、去掉末项后的三个前驱和接长比较

长度超过 1 的区间删去末项后,都会落在“以位置 2 结尾”的状态里。三个前驱和是 -3、-2、-4,最大为 dp[2]=-2。它们接回同一个 4 后变成 1、2、0,最大仍来自原先最大的前驱。给所有候选加上同一个数,不会改变大小顺序,所以接长这一类只需计算 dp[2]+4=2。再与单项 [4] 的 4 比较,得到 dp[3]=4。

一般位置 i 也只有“单项开始”与“接在前一结尾之后”两类。连续性使前驱结尾必须是 i-1,不能跳到更早位置:

\[dp[i]=\max\bigl(nums[i],\ dp[i-1]+nums[i]\bigr).\]

式中 nums[i] 是单项的和;dp[i-1]+nums[i] 是接长这一类的最大和;max 选择两类中较好的一类。前驱和为负时,带着它会使结果比单项更小;前驱和为非负时,接长不会变差。

位置 i 单项开始 接长前驱 当前结尾 dp[i] 已处理结尾中的最大和
0 -2 第一个元素直接作为一段 -2 -2
1 1 -2+1=-1 1 1
2 -3 1-3=-2 -2 1
3 4 -2+4=2 4 4

初值 dp[0]=nums[0],从左到右计算。题目允许最优区间在任意位置结束,因此答案是所有 dp[i] 的最大值。例如 [4,-5] 的最后结尾和为 -1,整题答案仍是第一个元素的 4。

结尾状态每步可以下降,而已经得到的全局最优值继续保留

只求和时,用 cur 保存当前结尾值、ans 保存全部已处理结尾的最大值。恢复区间时,单项开始就更新当前左端;只有刷新 ans 时,才把当前左右端复制为答案的两端。

1.3.1 保存每个结尾的最优和

先固定右端 i,再比较“从 i 重新开始”和“延长到 i”。dp[0]=nums[0],其余按下标递增,最后在所有右端中取最大值。

#include <algorithm>
#include <tuple>
#include <vector>
using namespace std;
using ll = long long;
// 先固定右端 i,再比较“从 i 重新开始”和“延长到 i”。
ll max_subarray_sum(vector<ll> const &nums) {
    // 边界情况处理:空数组
    if (nums.empty())
        return 0;

    int n = nums.size();
    vector<ll> dp(n);
    dp[0] = nums[0];    // 以第0个元素结尾的最大和就是它本身
    ll max_sum = dp[0]; // 全局最大和

    for (int i = 1; i < n; ++i) {
        // 状态转移:要么从当前位置重新开始,要么延续前面的子数组
        dp[i] = max(nums[i], dp[i - 1] + nums[i]);
        max_sum = max(max_sum, dp[i]); // 更新全局最大值
    }

    return max_sum;
}
1.3.2 只保存当前结尾和

前驱只有 dp[i-1],因此 cur 替代整张表;ans 保存所有已经处理过的结尾中的最好值。不能让 ans 随 cur 的下降而下降。

#include <algorithm>
#include <tuple>
#include <vector>
using namespace std;
using ll = long long;
// 前驱只有 dp[i-1],因此 cur 替代整张表;ans 保存所有已经处理过的结尾中的最好值。
ll max_subarray_sum_optimized(vector<ll> const &nums) {
    if (nums.empty())
        return 0;

    ll cur_max = nums[0];    // 当前以i结尾的最大和
    ll global_max = nums[0]; // 全局最大和

    for (int i = 1; i < nums.size(); ++i) {
        cur_max = max(nums[i], cur_max + nums[i]); // 更新当前最大和
        global_max = max(global_max, cur_max);     // 更新全局最大和
    }

    return global_max;
}
1.3.3 同时恢复区间两端

重新开始时更新 cur_start;刷新历史最好值时才复制左右端点。返回值依次是最大和、左端、右端,右端包含在区间内。

当前区间与历史最优区间的端点分别保存

当前区间与历史最优区间的端点分别保存。

#include <algorithm>
#include <tuple>
#include <vector>
using namespace std;
using ll = long long;
// 重新开始时更新 cur_start;刷新历史最好值时才复制左右端点。
tuple<ll, int, int> max_subarray_with_indices(vector<ll> const &nums) {
    if (nums.empty())
        return {0, -1, -1};

    ll cur_max = nums[0];
    ll global_max = nums[0];
    int cur_start = 0, global_start = 0, global_end = 0;

    for (int i = 1; i < nums.size(); ++i) {
        if (nums[i] > cur_max + nums[i]) {
            // 重新开始新的子数组
            cur_max = nums[i];
            cur_start = i;
        } else {
            // 延续当前子数组
            cur_max = cur_max + nums[i];
        }

        // 更新全局最大值及其边界
        if (cur_max > global_max) {
            global_max = cur_max;
            global_start = cur_start;
            global_end = i;
        }
    }

    return {global_max, global_start, global_end};
}

样例与解释: [-2,1,-3,4,-1,2,1,-5,4] 的最大和为 6,对应 [4,-1,2,1];[-3,-1] 答案为 -1,不能用 0 代替非空子数组。所有版本时间为 \(O(n)\);表格版本额外空间 \(O(n)\),滚动和记录下标的版本为 \(O(1)\)。

扩展: 最大子矩阵和可枚举上下行,把列和压成一维后应用本算法,R×C 矩阵常见代价为 \(O(R^2C)\)。环形数组分为不跨边界、跨边界两种情况,后者可用总和减去最小子数组和;全为负数时,取出最大的单项,得到非空子数组的答案。最大乘积子数组需同时维护最大、最小乘积,因为负数可能交换二者角色。对长度有限制的最大和,可以用前缀和与单调队列维护合法左端的最小前缀值。

练习题: 洛谷 P1115 — 最大子段和。

1.4 最长递增子序列

题目原文(自拟): 给定整数数组,删除若干元素后保留其原有相对顺序,使剩余元素严格递增。求最大长度;空数组返回 0。需要时还应返回一条达到最大长度的子序列。

思路分析: 子序列可以跳过位置。与最大子段和一样,先固定结尾,令 dp[i] 表示最后一个元素必须是 nums[i]的严格递增子序列最大长度。这样接上一个新元素时,只需检查已知的末尾值。

在 [3,1,2,5,4] 中计算以位置 3 的 5 结尾的状态。单个 [5] 已是长度 1 的方案。更长的方案删去 5 后,倒数第二位置只能是 0、1 或 2:

倒数第二位置 j 以该位置结尾的最长方案 接 5 的判断 接回后的候选
0 [3],长度 1 3<5 [3,5],长度 2
1 [1],长度 1 1<5 [1,5],长度 2
2 [1,2],长度 2 2<5 [1,2,5],长度 3
按倒数第二位置拆分子序列,接回同一个末项后比较长度

每条多元素方案都有一个倒数第二位置 j。删去末项后,剩余下标仍递增、数值仍严格递增,正是以 j 结尾的子问题。即使原方案的前一段较短,也可以换成这个结尾的最长方案:两者末尾都是 nums[j],接 5 的判断完全相同,换长后仍然合法。因此每个 j 只需读 dp[j],接回当前项后的长度是 dp[j]+1。

把 5 和位置 3 换成 nums[i] 和 i,前驱必须同时满足“位置在前”和“数值更小”:

\[dp[i]=\max\left(1,\ \max_{\substack{0\le j<i\\nums[j]<nums[i]}}\bigl(dp[j]+1\bigr)\right).\]

外面的 1 是单元素方案;里面的最大值枚举所有能接上的结尾。程序先写 dp[i]=1,再逐个检查 j,每找到一个合法前驱就比较一次。没有可接前驱时,这个 1 留下。

这组数据的结尾长度为 [1,1,2,3,3]。算最后的 4 时,前面的 5 无法接上,但位置 2 的 2 可以,得到 [1,2,4]。所有前驱下标都小于 i,按 i 递增计算;整题读取所有结尾长度的最大值 3。直接算法枚举每个位置前面的全部位置,时间 \(O(n^2)\),空间 \(O(n)\)。

记录使长度变大的来源 prev[i]=j,就能从最优结尾沿前驱恢复真实方案;倒着收集之后反转,元素的原下标就按递增排列。

进一步优化时,换一种记录方式:tails[k] 保存长度为 k+1 的递增子序列能达到的最小末尾。相同长度的两个方案末尾为 2 与 5,之后能接在 5 后的数也能接在 2 后,而 3 还能只接在 2 后。因此保留较小末尾即可。

同长度较小末尾能保留更多后续连接机会

读入 x 时,lower_bound 找到第一个 >=x 的末尾。此前的末尾都小于 x,相应方案可以接长;用 x 替换该位置,或在所有末尾都小于 x 时追加。

最小末尾数组的追加与替换,不同长度可能来自不同方案

tails 各位置保存的是不同长度的代表,彼此可能来自不同方案。图中 [3,5,6,2,4] 得到 [2,4,6],这个顺序不是原数组中的子序列,只有它的长度 3 是答案。

用保存的原下标前驱链恢复真实子序列

恢复内容时沿 prev 取原数组元素;求长度的 tails 表与保存方案的前驱表作用不同。

1.4.1 枚举所有合法前驱

固定结尾 i,任何长度大于 1 的递增子序列都有一个倒数第二位置 j<i,且 nums[j]<nums[i]。删去末尾后是以 j 结尾的子问题;换成该子问题的最优解仍能接上 nums[i],因此枚举所有 j 后取最大不会漏掉最优答案。

#include <algorithm>
#include <utility>
#include <vector>
using namespace std;
// 固定结尾 i,任何长度大于 1 的递增子序列都有一个倒数第二位置 j<i,且 nums[j]<nums[i]。
int lengthOfLIS_dp(vector<int> &nums) {
    if (nums.empty())
        return 0;

    int n = nums.size();
    vector<int> dp(n, 1); // 初始化为1,每个元素本身就是一个子序列
    int max_len = 1;

    for (int i = 1; i < n; ++i) {
        // 检查i之前的所有元素
        for (int j = 0; j < i; ++j) {
            if (nums[j] < nums[i]) {
                dp[i] = max(dp[i], dp[j] + 1);
            }
        }
        max_len = max(max_len, dp[i]);
    }

    return max_len;
}
1.4.2 用最小末尾与二分优化

相同长度只留下最小末尾,因为它能接受较大末尾可以接受的所有后续值。lower_bound 找第一个不小于 x 的位置,替换末尾或扩展长度;不是把 x 插入原数组排序。

#include <algorithm>
#include <utility>
#include <vector>
using namespace std;
// 相同长度只留下最小末尾,因为它能接受较大末尾可以接受的所有后续值。
int lengthOfLIS_greedy(vector<int> &nums) {
    if (nums.empty())
        return 0;

    vector<int> tails; // tails[k] = 长度为k+1的递增子序列的最小末尾元素

    for (int x : nums) {
        // 二分查找第一个≥x的元素位置
        auto it = lower_bound(tails.begin(), tails.end(), x);

        if (it == tails.end()) {
            // x比所有元素都大,延长最长子序列
            tails.push_back(x);
        } else {
            // 替换该位置的元素,保持tails的最小末尾性质
            *it = x;
        }
    }

    return tails.size(); // tails的长度就是LIS长度
}
1.4.3 沿前驱恢复真实子序列

prev[i] 记录让 dp[i] 严格变大的前驱下标。选出最大长度的结尾,再沿 prev 倒走并反转;各元素的下标因此严格递增。tails 来自不同方案,不能直接当作恢复结果。

#include <algorithm>
#include <utility>
#include <vector>
using namespace std;
// prev[i] 记录让 dp[i] 严格变大的前驱下标。
pair<int, vector<int>> getLIS_sequence(vector<int> &nums) {
    int n = nums.size();
    if (n == 0)
        return {0, {}};

    vector<int> dp(n, 1);
    vector<int> prev(n, -1); // 记录前驱索引,用于回溯

    int max_len = 1, max_idx = 0;

    for (int i = 0; i < n; ++i) {
        for (int j = 0; j < i; ++j) {
            if (nums[j] < nums[i] && dp[j] + 1 > dp[i]) {
                dp[i] = dp[j] + 1;
                prev[i] = j; // 记录前驱
            }
        }
        if (dp[i] > max_len) {
            max_len = dp[i];
            max_idx = i;
        }
    }

    // 回溯构建LIS序列
    vector<int> lis;
    int cur = max_idx;
    while (cur != -1) {
        lis.push_back(nums[cur]);
        cur = prev[cur];
    }
    reverse(lis.begin(), lis.end());

    return {max_len, lis};
}

样例与解释: [10,9,2,5,3,7,101,18] 的最长长度为 4,例如 [2,3,7,101];[2,2,2] 的严格递增长度为 1。二分版本时间 \(O(n\log n)\)、额外空间 \(O(n)\);这里恢复方案的函数采用平方 DP。

扩展: 允许相等时,条件改为 <=,二分改用 upper_bound。严格递减可以直接使用相反比较关系;若用取负转化,要避免最小有符号整数取负溢出。二维严格嵌套可按宽度升序、同宽高度降序排序,再对高度求 LIS,避免同宽元素互相嵌套。统计最长方案数时另设 count:发现更长方案则覆盖数量,长度相同时累加数量;还需说明按下标方案还是按数值方案去重。

练习题: 洛谷 P1020 — 导弹拦截、洛谷 P1439 — 两个排列的最长公共子序列(进阶:排列映射到 LIS)。

1.5 最长不下降子序列

题目原文(自拟): 给定整数数组,求保持原相对顺序且后一项大于等于前一项的子序列最大长度;空数组返回 0。需要时返回一条对应序列。扩展任务是求后一项小于等于前一项的最长不递增子序列。

思路分析: 在 [1,3,3,2,4] 中,两个 3 位于不同位置。状态仍定义为“必须以位置 i 结尾的最大长度”,但这次后一项可以等于前一项。

计算位置 2 的第二个 3 时,先考虑单项 [3],长度为 1。然后逐个删去候选方案的末项,检查它前面可以留下哪个结尾:

前驱位置 最优前驱 严格递增时 不下降时
0,值 1 [1],长度 1 1<3,候选长度 2 1<=3,候选长度 2
1,值 3 [1,3],长度 2 3<3 不成立 3<=3,接回得到 [1,3,3],长度 3
同一前驱末尾在严格递增与不下降两个问题中的连接过程

位置依然要求 j<i,所以不能把当前元素接在自身后面;数值判断改为 nums[j]<=nums[i]。把任意合法前驱换成同一结尾的最长方案,末尾值不变,当前项仍能接上。因此

\[dp[i]=\max\left(1,\ \max_{\substack{0\le j<i\\nums[j]\le nums[i]}}\bigl(dp[j]+1\bigr)\right).\]

这组数据按结尾计算得到 [1,2,3,2,4]。第二个 3 的状态为 3;到值 2 时,两个 3 都不能接,只有 1 能接,当前长度降为 2;最后的 4 能接在第二个 3 后,得到 [1,3,3,4],长度 4。各位置从左到右计算,答案仍取全部结尾的最大值。

二分版本也要保留等值连接。upper_bound 找第一个 >x 的末尾;等于 x 的末尾位于它前面,对应的方案可以补上当前 x,长度增加一。

三个相等元素在两种末尾数组中分别替换或接长

输入 [2,2,2],严格递增只能取一个 2;不下降可以按三个原下标全部取出。二分方法分别得到长度 1 与 3,变化来自是否允许等值末尾接长。

1.5.1 推导允许相等的前驱

状态仍是“以 i 结尾的最大长度”。仅把前驱条件改为 nums[j]<=nums[i],因此 dp[i] 取所有合法前驱 dp[j]+1 与单个元素长度 1 中的最大值,最大值的范围是所有满足 j<i 且值不大的 j;单个元素提供初值 1。

#include <algorithm>
#include <functional>
#include <utility>
#include <vector>
using namespace std;
// 状态仍是“以 i 结尾的最大长度”。
int lengthOfLNDS_dp(vector<int> &nums) {
    int n = nums.size();
    if (n == 0)
        return 0;

    vector<int> dp(n, 1); // 每个元素自身就是一个子序列
    int max_len = 1;

    for (int i = 0; i < n; i++) {
        for (int j = 0; j < i; j++) {
            // 注意:这里是 <= 而不是 <
            if (nums[j] <= nums[i]) {
                dp[i] = max(dp[i], dp[j] + 1);
            }
        }
        max_len = max(max_len, dp[i]);
    }

    return max_len;
}
1.5.2 upper_bound 保留相等元素

upper_bound 找第一个大于 x 的末尾。已有末尾恰等于 x 时,允许接上 x,使更长的不下降子序列成立;不能使用会覆盖相等末尾的 lower_bound。

#include <algorithm>
#include <functional>
#include <utility>
#include <vector>
using namespace std;
// upper_bound 找第一个大于 x 的末尾。
int lengthOfLNDS_optimal(vector<int> &nums) {
    if (nums.empty())
        return 0;

    vector<int> tails; // tails[k] = 长度为k+1的不下降子序列的最小末尾元素

    for (int x : nums) {
        // 关键区别:使用upper_bound而不是lower_bound
        // 查找第一个 > x 的位置,允许相等元素接在后面
        auto it = upper_bound(tails.begin(), tails.end(), x);

        if (it == tails.end()) {
            // x不小于tails中任何元素,可以延长最长子序列
            tails.push_back(x);
        } else {
            // 替换该位置,保持tails的最小末尾性质
            *it = x;
        }
    }

    return tails.size(); // tails的长度就是LNDS长度
}
1.5.3 不递增子序列的降序末尾

改成后一项不大于前一项时,tails 按降序维护。相同长度的末尾越大,后面能接的值越多;upper_bound 配合 greater() 找第一个小于 x 的位置,相等时仍可延长。

降序末尾数组按相反的比较关系替换

降序末尾数组按相反的比较关系替换。

#include <algorithm>
#include <functional>
#include <utility>
#include <vector>
using namespace std;
// 改成后一项不大于前一项时,tails 按降序维护。
int lengthOfLNIS_optimal(vector<int> &nums) {
    // 直接使用降序比较,避免最小有符号整数取负时溢出。

    // 方法2:在降序 tails 中使用 upper_bound,允许相等元素继续延长
    if (nums.empty())
        return 0;

    vector<int> tails;

    for (int x : nums) {
        // 查找第一个 < x 的位置(降序规则下的 upper_bound)
        // greater<int>() 定义降序比较关系。
        auto it = upper_bound(tails.begin(), tails.end(), x, greater<int>());

        if (it == tails.end()) {
            tails.push_back(x);
        } else {
            *it = x;
        }
    }

    return tails.size();
}
1.5.4 恢复不下降子序列

平方 DP 保存每个结尾及前驱,判定 nums[j]<=nums[i] 后才允许连接。相同长度的候选无需覆盖已保存前驱,因此返回一条确定的最优序列。

沿前驱下标收集后反转,保留两个相等的 3

沿前驱下标收集后反转,保留两个相等的 3。

#include <algorithm>
#include <functional>
#include <utility>
#include <vector>
using namespace std;
// 平方 DP 保存每个结尾及前驱,判定 nums[j]<=nums[i] 后才允许连接。
pair<int, vector<int>> getLNDS_sequence(vector<int> &nums) {
    int n = nums.size();
    if (n == 0)
        return {0, {}};

    vector<int> dp(n, 1);
    vector<int> prev(n, -1); // 前驱索引,用于回溯

    int max_len = 1, max_idx = 0;

    for (int i = 0; i < n; i++) {
        for (int j = 0; j < i; j++) {
            // 注意是 <= 而不是 <
            if (nums[j] <= nums[i] && dp[j] + 1 > dp[i]) {
                dp[i] = dp[j] + 1;
                prev[i] = j;
            }
        }
        if (dp[i] > max_len) {
            max_len = dp[i];
            max_idx = i;
        }
    }

    // 回溯构建序列
    vector<int> lnds;
    int cur = max_idx;
    while (cur != -1) {
        lnds.push_back(nums[cur]);
        cur = prev[cur];
    }
    reverse(lnds.begin(), lnds.end());

    return {max_len, lnds};
}

样例与解释: [1,3,3,2,4] 的严格递增长度为 3,例如 [1,2,4];不下降长度为 4,例如 [1,3,3,4]。不递增扩展对 [3,3,2] 返回 3,在降序 tails 中用 greater<int>() 配合 upper_bound 找第一个 <x 的位置。

复杂度分析: 平方和恢复方案的实现为 \(O(n^2)\) 时间,二分实现为 \(O(n\log n)\) 时间;额外空间均为 \(O(n)\)。恢复方式与 LIS 相同,只改变前驱合法条件;tails 仍不能直接当作完整答案序列。

练习题: 洛谷 P1020 — 导弹拦截(同时练习非递增与严格递增)。

2. 二维 DP

2.1 最长公共子串

题目原文(自拟): 给定两个按单字节字符处理的字符串 s1、s2,求它们共同拥有的最长连续子串长度;空串时返回 0。也可以要求返回一个达到该长度的子串,没有公共字符时返回空串。

思路分析: 子串要求连续。令 dp[i][j] 表示公共子串恰好结束在 s1[i-1] 和 s2[j-1]时的最大长度。i、j 是前缀长度,末尾字符的数组下标才是 i-1、j-1;第 0 行、列用于表示一侧为空的情况。

例如 s1=ABAB、s2=BAB,计算 dp[4][3] 时指定的末尾都是 B。当前允许的非空公共子串有 B、AB、BAB。删去共同的最后一个 B:

当前片段 删去 B 后 剩余片段的结尾 接回后的长度
B 空片段 当前片段从末尾开始 0+1=1
AB A 两串前一对字符处 1+1=2
BAB BA 两串前一对字符处 2+1=3
连续公共片段逐条去掉同一末尾,前驱结尾随之同时后退

剩余的非空片段必须在 s1[2]、s2[1] 处结束:连续性使它不能跳过中间字符。因此前驱坐标是 (3,2)。该位置的最长片段为 BA,dp[3][2]=2。所有候选都接回同一个 B,最长前驱接回后仍最长,得到 dp[4][3]=2+1=3。

若当前末尾不相等,例如 s1=AB、s2=BA 的 dp[2][2],指定末尾为 B 与 A。同一非空公共片段的最后字符不可能同时是 B 和 A,所以这里写 0。上方或左方的片段停在别处,拿来填当前格就改变了指定结尾。

将这两种情况推广为

\[dp[i][j]=\begin{cases}dp[i-1][j-1]+1,&s1[i-1]=s2[j-1],\\0,&s1[i-1]\ne s2[j-1].\end{cases}\]

左上角表示两个结尾各退一位;+1 表示接回一对相同字符。左上角为 0 时,0+1 恰好从当前这一对字符开始一段新子串。

第 0 行、列都为 0,因为空串没有非空片段。逐行填写时,前一行的左上角已经算好。整题允许最佳子串在任意位置结束,答案取全表最大值。例如 ab、ax 在 (1,1) 得到 1,右下角为 0,答案仍是公共片段 a 的长度 1。

连续匹配沿对角线增长,不匹配时在当前结尾写零

图中 ABABC、BABCA 的对角线对应 BABC。长度从 1、2、3 增长到 4,依次表示这段公共子串的四个连续结尾。滚动版本只保留相邻两行,但每个不匹配位置仍要覆盖为 0。

2.1.1 填写全部结尾状态

末尾相等时把两个前驱结尾的公共子串接长一位;末尾不同就不存在该结尾的非空公共子串,于是写入 0。每个匹配候选都要更新全局最大长度。

#include <algorithm>
#include <string>
#include <vector>
using namespace std;
// 末尾相等时把两个前驱结尾的公共子串接长一位;末尾不同就不存在该结尾的非空公共子串,于是写入 0。
int longestCommonSubstring_length(string s1, string s2) {
    int m = s1.length();
    int n = s2.length();
    if (m == 0 || n == 0)
        return 0;

    // dp[i][j]: 以s1[i-1]和s2[j-1]结尾的最长公共子串长度
    vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
    int max_len = 0; // 记录最大长度

    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (s1[i - 1] == s2[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1] + 1; // 字符相等,长度+1
                max_len = max(max_len, dp[i][j]);
            } else {
                dp[i][j] = 0; // 字符不相等,重新开始(子串要求连续)
            }
        }
    }

    return max_len;
}
2.1.2 两行滚动与不匹配置零

当前状态只读上一行左上角。i%2 选择当前物理行,(i-1)%2 选择上一行;字符相同时写入左上角加一,不相同时写入 0。两种分支都覆盖本轮位置,因此这一行被再次使用时不会保留两轮前的值。

公共子串滚动时只读取上一逻辑行的左上角

公共子串滚动时只读取上一逻辑行的左上角。

#include <algorithm>
#include <string>
#include <vector>
using namespace std;
// 当前状态只读上一行左上角。
int longestCommonSubstring_space_optimized(string s1, string s2) {
    int m = s1.length();
    int n = s2.length();
    if (m == 0 || n == 0)
        return 0;

    // 列方向 s2 保持较短,减少滚动数组宽度
    if (n > m) {
        swap(s1, s2);
        swap(m, n);
    }

    // 只使用两行数组
    vector<vector<int>> dp(2, vector<int>(n + 1, 0));
    int max_len = 0;

    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (s1[i - 1] == s2[j - 1]) {
                dp[i % 2][j] = dp[(i - 1) % 2][j - 1] + 1;
                max_len = max(max_len, dp[i % 2][j]);
            } else {
                dp[i % 2][j] = 0; // 关键:子串要求连续
            }
        }
    }

    return max_len;
}
2.1.3 根据结束位置取出内容

刷新最大长度时记录前缀长度 end_pos,它是半开区间的右边界,不是最后字符的下标。起点为 end_pos-max_len;substr 的第二参数是长度。

从下标 1 截取 4 个字符得到 BABC

半开区间 [1,5) 对应字符串 BABC。

#include <algorithm>
#include <string>
#include <vector>
using namespace std;
// 刷新最大长度时记录前缀长度 end_pos,它是半开区间的右边界,不是最后字符的下标。
string longestCommonSubstring_content(string s1, string s2) {
    int m = s1.length();
    int n = s2.length();
    if (m == 0 || n == 0)
        return "";

    vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
    int max_len = 0;
    int end_pos = 0; // 记录最长子串在s1中的结束位置

    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (s1[i - 1] == s2[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1] + 1;
                if (dp[i][j] > max_len) {
                    max_len = dp[i][j];
                    end_pos = i; // s1中的结束位置
                }
            } else {
                dp[i][j] = 0;
            }
        }
    }

    // 根据结束位置和长度提取子串
    if (max_len == 0)
        return "";
    return s1.substr(end_pos - max_len, max_len);
}

代码解释: 表格版本保存所有结尾状态;滚动版本只读上一行的左上角,并在本行不匹配的位置写 0。把较短字符串放在列方向,可减少数组宽度。返回内容时记录最大长度 max_len 及其在 s1 中的半开结束位置 end_pos,取 substr(end_pos-max_len,max_len)。

样例与复杂度: ABABC、BABCA 返回长度 4、内容 BABC;abcde、ace 的最长公共子串长度为 1,而公共子序列长度为 3。时间为 \(O(mn)\)。完整表额外空间 \(O(mn)\),滚动表为 \(O(\min(m,n))\);这些函数按值接收字符串,还存在 \(O(m+n)\) 的参数副本空间,需要与 DP 辅助表的口径区分。

扩展: 多字符串的公共子串可从最短字符串的候选子串开始验证,进一步可学习后缀数组或后缀自动机;复杂度要结合总长度和验证方式计算。允许若干不匹配时需要把剩余或已用次数加入状态。循环字符串可以通过拼接处理跨尾首片段;只取原字符串长度内的片段,就对应沿环至多绕一圈。需要所有最优位置时可收集最大长度对应的结束位置。

练习题: 洛谷 P2679 — 子串(进阶:子串状态扩展,不是直接套最长公共子串模板)。

2.2 最长公共子序列

题目原文(自拟): 给定两个按单字节字符处理的字符串,求二者公共子序列的最大长度。子序列可以跳过字符,但相对顺序必须一致;任一串为空时答案为 0。需要时返回一条最长公共子序列。

思路分析: 子序列可以跳过字符,因此把状态改成前缀中的最优值:dp[i][j] 表示从 s1 的前 i 个字符与 s2 的前 j 个字符中,能选出的最长公共子序列长度。末尾可以选,也可以不选。 这与公共子串固定两个结尾的状态不同。

先计算 ab、ac 的 dp[2][2]。末尾 b、c 不同,无法把它们匹配成公共子序列的最后一对。任意匹配方案至少放弃其中一个末尾:

放弃的末尾 剩余前缀 对应状态 最优内容与长度
放弃源串 b a、ac dp[1][2] a,长度 1
放弃目标串 c ab、a dp[2][1] a,长度 1

方案 a 同时出现在两类中。我们要最长长度,因此比较 max(1,1)=1;相加会把同一个方案重复计算。每个较短前缀中的方案在较长前缀中仍合法,而每个当前方案又至少属于其中一类,所以两个前驱的最大值正好是当前最优值。

不匹配末尾的两个放弃分支,以及相同末尾的接回和移动匹配过程

再计算 aba、ba 的 dp[3][2]。末尾都是 a,先把它们留作最后一对匹配。删去这对字符,剩余前缀为 ab、b,最优子序列 b 长度 1;接回 a 得到 ba,长度 2,即 dp[2][1]+1。

还要解释这次直接选末尾会不会遗漏最优方案。取一条最长公共子序列,分两种情况:

  1. 若最后匹配的字符不是 a,两侧最后的 a 都没有被使用。把这对 a 加在末尾还能保持下标顺序,长度会增加一,因此这样的方案尚未达到最长。
  2. 若最后匹配的字符是 a,可以把它在两串中的匹配位置都移到最后一个 a。前面的匹配位置仍在它们之前,内容与长度不变。例如只匹配两串靠前的 a,可以把它们改为匹配末尾的 a。

于是一定能选到一条使用这对末尾 a 的最优方案。删去它后,剩余长度最多为 dp[2][1];而这个前驱的最优方案又确实能接回 a。两边得到同一个长度 dp[2][1]+1=2。

把前缀长度推广为 i、j,得到

\[dp[i][j]=\begin{cases}dp[i-1][j-1]+1,&s1[i-1]=s2[j-1],\\\max\bigl(dp[i-1][j],dp[i][j-1]\bigr),&s1[i-1]\ne s2[j-1].\end{cases}\]

上方去掉源末尾,左方去掉目标末尾,左上角去掉一对末尾;这里的“去掉”是缩短当前子问题的前缀,不是修改输入字符串。一侧前缀为空时只能选出空子序列,所以第 0 行、列为 0。

ab、ac 的状态 空目标 a ac
空源 0 0 0
a 0 0+1=1 max(0,1)=1
ab 0 max(1,0)=1 max(1,1)=1

逐行、每行从左到右填时,上方、左方和左上角都已计算。右下角包含两个完整字符串的全部选择,直接读取 dp[m][n]。遇到不匹配仍可保留以前匹配到的字符,所以它保留前驱最优值。

允许跳过 b 和 d 后,两个完整前缀中保留 ace

恢复内容时,从右下角沿达到最优值的来源倒走。

倒走时记录匹配字符,并在不匹配处比较两个来源

abcde、ace 从 (5,3) 匹配 e,记录 e 并去 (4,2);此处 d、c 不同,上方为 2、左方为 1,向上走。接着匹配 c,再跳过 b,最后匹配 a。逆序收集 e、c、a,反转得到 ace。它是在已算好的表中找一条最优来源链,表中的结果继续保留。平局时本节代码向左,选择其中一条最优方案。

2.2.1 按末尾匹配结果推导二维表

当末尾不同,公共子序列不可能同时使用这两个末尾,因此至少去掉一侧;两种子问题的最优值取最大即可。末尾相等时,可把这个字符补到较短前缀的公共子序列后;任何最优方案也能调整为使用这对末尾,故取左上角加一。

#include <algorithm>
#include <string>
#include <vector>
using namespace std;
// 当末尾不同,公共子序列不可能同时使用这两个末尾,因此至少去掉一侧;两种子问题的最优值取最大即可。
int longestCommonSubsequence_length(string s1, string s2) {
    int m = s1.length();
    int n = s2.length();
    if (m == 0 || n == 0)
        return 0;

    // dp[i][j]: s1前i个字符和s2前j个字符的LCS长度
    vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));

    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (s1[i - 1] == s2[j - 1]) {
                // 字符匹配,长度+1
                dp[i][j] = dp[i - 1][j - 1] + 1;
            } else {
                // 字符不匹配,取两个方向的最大值
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
            }
        }
    }

    return dp[m][n];
}
2.2.2 分清当前行与上一行

不匹配时取上方和左方最大值:上方属于上一逻辑行,左方属于当前行;匹配时读取上一行左上角。列从左到右保证当前行的左侧已计算。

LCS 滚动表同时依赖上一行与本行

LCS 滚动表同时依赖上一行与本行。

#include <algorithm>
#include <string>
#include <vector>
using namespace std;
// 不匹配时取上方和左方最大值:上方属于上一逻辑行,左方属于当前行;匹配时读取上一行左上角。
int longestCommonSubsequence_space_optimized(string s1, string s2) {
    int m = s1.length();
    int n = s2.length();
    if (m == 0 || n == 0)
        return 0;

    // 列方向 s2 保持较短,减少滚动数组宽度
    if (n > m) {
        swap(s1, s2);
        swap(m, n);
    }

    // 只使用两行数组
    vector<vector<int>> dp(2, vector<int>(n + 1, 0));

    for (int i = 1; i <= m; i++) {
        int curr = i % 2;
        int prev = 1 - curr;

        for (int j = 1; j <= n; j++) {
            if (s1[i - 1] == s2[j - 1]) {
                dp[curr][j] = dp[prev][j - 1] + 1;
            } else {
                dp[curr][j] = max(dp[prev][j], dp[curr][j - 1]);
            }
        }
    }

    return dp[m % 2][n];
}
2.2.3 沿最优来源恢复内容

从右下角倒走。相同字符收集一次并同时缩短两个前缀;不同字符走向维持最优值的一侧。相等候选可以任选,本函数在平局时向左走,最后反转收集结果。

#include <algorithm>
#include <string>
#include <vector>
using namespace std;
// 从右下角倒走。
string longestCommonSubsequence_sequence(string s1, string s2) {
    int m = s1.length();
    int n = s2.length();
    if (m == 0 || n == 0)
        return "";

    // 先计算dp表
    vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));

    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (s1[i - 1] == s2[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1] + 1;
            } else {
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
            }
        }
    }

    // 回溯构造LCS
    string lcs;
    int i = m, j = n;

    while (i > 0 && j > 0) {
        if (s1[i - 1] == s2[j - 1]) {
            // 当前字符属于LCS
            lcs.push_back(s1[i - 1]);
            i--;
            j--;
        } else if (dp[i - 1][j] > dp[i][j - 1]) {
            // 向上回溯
            i--;
        } else {
            // 向左回溯
            j--;
        }
    }

    reverse(lcs.begin(), lcs.end());
    return lcs;
}

样例与复杂度: abcde、ace 得到 ace,长度 3;ABCDGH、AEDFHR 可以得到 ADH;AGGTAB、GXTXAYB 可以得到 GTAB。时间为 \(O(mn)\);完整表和此处恢复方案的表空间为 \(O(mn)\),只求长度的滚动表为 \(O(\min(m,n))\)。按值参数还会占用字符串副本空间。若有多条最优序列,函数只返回其中一条。

扩展: 原数组与“排序去重后的数组”做 LCS 可以得到严格 LIS 长度,但平方复杂度不适合大规模。带权匹配要把长度加一改为对应匹配收益,并检查允许跳过后的最优转移。多个序列需要更多状态维度,代价通常是各长度的乘积,还要计入每状态的转移数。只允许插入和删除时,最少操作数为 \(m+n-2\operatorname{LCS}\);允许代价 1 的替换时不能直接使用这个公式。

练习题: 洛谷 P1435 — 回文字串(可转化为与反串的 LCS)、洛谷 P1439 — 两个排列的最长公共子序列(需优化到 LIS)。

2.3 编辑距离

题目原文(自拟): 给定两个按单字节字符处理的字符串,允许插入、删除、替换一个字符,每次代价为 1。求把第一个变成第二个的最少代价;允许空串。扩展任务是给三种操作分别设置非负代价,或输出一组最优操作说明。所有长度与代价的计算结果都在 int 范围内。

思路分析: 令 dp[i][j] 表示把源串前 i 个字符变成目标串前 j 个字符的最小费用。先求 ab→ac 的 dp[2][2],用字符对齐观察末尾:同一列是一个源字符与目标字符的对应,— 表示这一侧没有字符。

三种字符对齐逐列拆去末尾,显示具体前缀与费用,再接回动作

可以把完整的对齐分成三种末尾列:

末尾对齐 本列动作 删去这一列后的任务 前驱费用 接回费用
源 b、目标 — 删除 b a→ac,源前缀短一位 dp[1][2]=1:插入 c 1+1=2
源 —、目标 c 插入 c ab→a,目标前缀短一位 dp[2][1]=1:删除 b 1+1=2
源 b、目标 c 把 b 替换为 c a→a,两个前缀各短一位 dp[1][1]=0:直接匹配 0+1=1

删除这一列时,消耗了哪个串的末尾,就将哪个前缀长度减 1。因此删除的前驱是 (i-1,j),插入的前驱是 (i,j-1),替换的前驱是 (i-1,j-1)。

例如删除分支的完整对齐是:源 a、—、b 对目标 a、c、—。前两列已经把 a 变成 ac,最后一列再删除 b。这里拆的是对齐中最右的一列,实际执行操作时可以安排成先删 b 再插 c,费用相同。这样不会把“删除”误解为目标串的长度也必须减少。

每种前驱中的任意合法对齐,都可以接回对应的末尾列。把前面费用为 3 的对齐换成同前缀费用为 1 的对齐,末尾字符与动作不变,完整费用也就少 2。于是每类只保留前驱的最小费用,再加本列费用。末尾不相等时,三种末尾列覆盖候选,比较三类最小费用,ab→ac 得到 min(2,2,1)=1。

末尾相同,例如 ab→ab,两侧 b 可以直接匹配,本列费用为 0,前驱是 a→a。在这里每种编辑费用都是 1,末尾相同的一对可以保留;把它们删掉再重新产生不会降低费用。因此匹配分支直接读左上角。

\[dp[i][j]=\begin{cases}dp[i-1][j-1],&s1[i-1]=s2[j-1],\\\min\bigl(dp[i-1][j]+1,\ dp[i][j-1]+1,\ dp[i-1][j-1]+1\bigr),&s1[i-1]\ne s2[j-1].\end{cases}\]

三项的 +1 都表示接回一个编辑动作,min 对应题目要求的最少次数。匹配时没有 +1,因为保留相同字符不算编辑。

空源串变成目标前 j 个字符,只能逐个插入,费用为 j;源前 i 个字符变为空串,只能逐个删除,费用为 i。所以 dp[0][j]=j、dp[i][0]=i,它们表示实际操作费用。

ab→ac 的状态 空目标 a ac
空源 0 1 2
a 1 匹配:0 插入 c:1
ab 2 删除 b:1 比较删除 2、插入 2、替换 1,取 1

上方、左方、左上角都比当前任务少处理字符,逐行从左到右计算即可。右下角 dp[m][n] 是完整源串到完整目标串的费用。两行压缩保留上方与左上角,当前行从左到右产生左方。

把最优费用对应到 kitten 逐步变成 sitting 的实际编辑

kitten→sitting 可以替换 k→s、替换 e→i,再在末尾插入 g,总计 3 次。带权版本仍拆同样的末尾列,只把各分支的 +1 换成各自费用;空前缀的插入、删除费用也逐个累计。

2.3.1 三种操作对应三个前驱

每个单元先问:把源前缀变成目标前缀,最后处理末尾时是哪种动作?删除只消耗源末尾,插入只消耗目标末尾,替换同时消耗两侧,因此前驱分别是 (i-1,j)、(i,j-1)、(i-1,j-1)。三类都能产生合法方案,取最小值。

#include <algorithm>
#include <string>
#include <vector>
using namespace std;
// 每个单元先问:把源前缀变成目标前缀,最后处理末尾时是哪种动作?删除只消耗源末尾,插入只消耗目标末尾,替换同时消耗两侧,因此前驱分别是 (i-1,j)、(i,j-1)、(i-1,j-1)。
int minDistance(string word1, string word2) {
    int m = word1.length();
    int n = word2.length();

    // dp[i][j]: 将word1的前i个字符转换为word2的前j个字符的最小编辑距离
    vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));

    // 先填写空前缀对应的状态值。
    for (int i = 0; i <= m; i++) {
        dp[i][0] = i; // word1前i个字符转换为空串:删除i次
    }
    for (int j = 0; j <= n; j++) {
        dp[0][j] = j; // 空串转换为word2前j个字符:插入j次
    }

    // 状态转移
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (word1[i - 1] == word2[j - 1]) {
                // 字符相同,无需操作
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                // 字符不同,取三种操作的最小值
                int del_cost = dp[i - 1][j] + 1;     // 删除word1[i-1]
                int ins_cost = dp[i][j - 1] + 1;     // 插入word2[j-1]
                int rep_cost = dp[i - 1][j - 1] + 1; // 替换word1[i-1]为word2[j-1]
                dp[i][j] = min({del_cost, ins_cost, rep_cost});
            }
        }
    }

    return dp[m][n];
}
2.3.2 两行滚动:先填写空前缀的代价

上方和左上角读上一行,左方读本行。每轮先设置 dp[curr][0]=i,代表删除当前源前缀全部字符;漏掉这一步会读到上一轮的初值。

上方和左上角读上一行,左方读本行

上方和左上角读上一行,左方读本行。

#include <algorithm>
#include <string>
#include <vector>
using namespace std;
// 上方和左上角读上一行,左方读本行。
int minDistance_space_optimized(string word1, string word2) {
    int m = word1.length();
    int n = word2.length();

    // 列方向 word2 保持较短,减少滚动数组宽度
    if (n > m) {
        swap(word1, word2);
        swap(m, n);
    }

    // 只使用两行数组
    vector<vector<int>> dp(2, vector<int>(n + 1, 0));

    // 初始化第一行(空串转换为word2)
    for (int j = 0; j <= n; j++) {
        dp[0][j] = j;
    }

    // 状态转移
    for (int i = 1; i <= m; i++) {
        int curr = i % 2;
        int prev = 1 - curr;

        // 初始化当前行的第一个元素
        dp[curr][0] = i;

        for (int j = 1; j <= n; j++) {
            if (word1[i - 1] == word2[j - 1]) {
                dp[curr][j] = dp[prev][j - 1];
            } else {
                int del_cost = dp[prev][j] + 1;     // 删除
                int ins_cost = dp[curr][j - 1] + 1; // 插入
                int rep_cost = dp[prev][j - 1] + 1; // 替换
                dp[curr][j] = min({del_cost, ins_cost, rep_cost});
            }
        }
    }

    return dp[m % 2][n];
}
2.3.3 不同代价下比较总费用

还是按末尾对齐列拆分,只把本列费用换成对应单价。把 a 变成 b,设插入费用 2、删除费用 3、替换费用 10:

接回的末尾动作 前驱任务与费用 完整费用
删除源 a 空源变 b,先插入,dp[0][1]=2 2+3=5
插入目标 b a 变空,先删除,dp[1][0]=3 3+2=5
替换 a 为 b 空源变空目标,dp[0][0]=0 0+10=10
按相同对齐列接入不同费用,比较删除插入与直接替换

最小费用为 5,选择删 a 再插 b。它用了两次操作,费用却低于只替换一次的 10。不能先求最少次数,再把那条方案换算成价格;在每次转移时就要比较总费用。

末尾不匹配时,令插入、删除、替换费用分别为 I、D、R:

\[dp[i][j]=\min\bigl(dp[i-1][j]+D,\ dp[i][j-1]+I,\ dp[i-1][j-1]+R\bigr).\]

前驱坐标与上一节相同。空源到目标前缀要插入 j 个字符,故 dp[0][j]=jI;源前缀到空目标要删除 i 个字符,故 dp[i][0]=iD。两侧末尾相同时直接匹配,费用为 0,读取左上角。

#include <algorithm>
#include <string>
#include <vector>
using namespace std;
// 每个前驱加对应动作的费用,空前缀的代价也按对应的插入或删除单价计算。
int minDistance_weighted(string word1, string word2, int insert_cost = 1, int delete_cost = 1,
                         int replace_cost = 1) {
    int m = word1.length();
    int n = word2.length();

    vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));

    // 初始化边界(使用对应的操作代价)
    for (int i = 0; i <= m; i++) {
        dp[i][0] = i * delete_cost; // 删除i个字符
    }
    for (int j = 0; j <= n; j++) {
        dp[0][j] = j * insert_cost; // 插入j个字符
    }

    // 状态转移
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (word1[i - 1] == word2[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                int del_cost = dp[i - 1][j] + delete_cost;      // 删除
                int ins_cost = dp[i][j - 1] + insert_cost;      // 插入
                int rep_cost = dp[i - 1][j - 1] + replace_cost; // 替换
                dp[i][j] = min({del_cost, ins_cost, rep_cost});
            }
        }
    }

    return dp[m][n];
}
2.3.4 填表时记录最优来源

dp 保存最小代价,op 保存选择了哪个前驱。平局先选替换,再选删除,最后插入,因此相同输入会按固定规则恢复。两个表的坐标含义相同,op 的数字不是编辑距离。

按来源表逐步判断匹配与插入,移动前缀坐标并记录逆序操作

以 ab→acb 为例,dp[2][3]=1。从终点开始,b 与 b 匹配,向左上走到 (1,2),不记录编辑;此处来源 op[1][2]=2 表示插入 c,只缩短目标前缀,向左走到 (1,1);随后 a 与 a 匹配,向左上到 (0,0) 停止。操作列表只含“插入 c”,位置在 a 与 b 之间。

op 存的是方向和动作编号,dp 存的是最小代价,两者不能互换。一般情况也按此过程倒走,最后反转记录的编辑说明;匹配字符不算一次编辑。

#include <algorithm>
#include <string>
#include <vector>
using namespace std;
// dp 保存最小代价,op 保存选择了哪个前驱。
struct EditTrace {
    vector<vector<int>> dp;
    vector<vector<int>> op; // 0 匹配或替换,1 删除,2 插入。
};

EditTrace buildEditTrace(const string &word1, const string &word2) {
    int m = word1.length();
    int n = word2.length();

    // 先计算dp表
    vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
    for (int i = 0; i <= m; i++)
        dp[i][0] = i;
    for (int j = 0; j <= n; j++)
        dp[0][j] = j;

    // 同时记录操作来源
    vector<vector<int>> op(m + 1, vector<int>(n + 1, 0));
    // 0: 匹配/替换, 1: 删除, 2: 插入

    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (word1[i - 1] == word2[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1];
                op[i][j] = 0; // 匹配
            } else {
                int del_cost = dp[i - 1][j] + 1;
                int ins_cost = dp[i][j - 1] + 1;
                int rep_cost = dp[i - 1][j - 1] + 1;

                if (rep_cost <= del_cost && rep_cost <= ins_cost) {
                    dp[i][j] = rep_cost;
                    op[i][j] = 0; // 替换
                } else if (del_cost <= ins_cost) {
                    dp[i][j] = del_cost;
                    op[i][j] = 1; // 删除
                } else {
                    dp[i][j] = ins_cost;
                    op[i][j] = 2; // 插入
                }
            }
        }
    }

    return {dp, op};
}
2.3.5 从终点倒走并反转操作

每次按照 op 缩短一个或两个前缀,直到两者都为空。一侧已空时剩余字符只能全部插入或删除。倒走时先收集末尾动作,再收集前面的动作;反转后就按从左到右的对齐顺序输出。

// 每次按照 op 缩短一个或两个前缀,直到两者都为空。
vector<string> recoverEditOperations(const string &word1, const string &word2,
                                     const vector<vector<int>> &op) {
    int m = word1.length(), n = word2.length();
    // 回溯获取操作序列
    vector<string> operations;
    int i = m, j = n;

    while (i > 0 || j > 0) {
        if (i == 0) {
            operations.push_back("插入 '" + string(1, word2[j - 1]) + "'");
            --j;
            continue;
        }
        if (j == 0) {
            operations.push_back("删除 '" + string(1, word1[i - 1]) + "'");
            --i;
            continue;
        }
        if (op[i][j] == 0) { // 匹配或替换
            if (word1[i - 1] != word2[j - 1]) {
                operations.push_back("替换 '" + string(1, word1[i - 1]) + "' -> '" +
                                     string(1, word2[j - 1]) + "'");
            }
            i--;
            j--;
        } else if (op[i][j] == 1) { // 删除
            operations.push_back("删除 '" + string(1, word1[i - 1]) + "'");
            i--;
        } else { // 插入
            operations.push_back("插入 '" + string(1, word2[j - 1]) + "'");
            j--;
        }
    }

    reverse(operations.begin(), operations.end());
    return operations;
}
2.3.6 用一个入口组合两个步骤

恢复操作需要第 4、5、6 段依次放在同一个文件,先声明 EditTrace 和辅助函数。前三段是独立的求值函数;它们与恢复函数可以共享本节的头文件。

// 恢复操作需要第 4、5、6 段依次放在同一个文件,先声明 EditTrace 和辅助函数。
vector<string> getEditOperations(string word1, string word2) {
    // 先记录每格的最优来源,再从终点回溯一条操作说明。
    EditTrace trace = buildEditTrace(word1, word2);
    return recoverEditOperations(word1, word2, trace.op);
}

代码解释: 滚动实现保留上一行,当前行的第 0 列也要随 i 更新。带权函数把三个 +1 换成各自代价。getEditOperations 用来源表回溯,先处理一侧前缀为空时的剩余插入或删除,再反转步骤。它返回按字符对齐顺序排列的操作说明,没有给出动态下标;直接执行修改时还应记录作用位置,不能仅按字符值寻找第一个同名字符。

样例与复杂度: horse→ros 的最少次数为 3;空串到 abc 为 3;kitten→sitting 为 3;intention→execution 为 5。时间 \(O(mn)\),完整表和来源表为 \(O(mn)\) 空间,滚动 DP 表为 \(O(\min(m,n))\)。按值参数仍占字符串副本空间,返回的操作说明另计输出空间。

扩展: 只允许插入、删除时可用 \(m+n-2\operatorname{LCS}\);加入相邻交换则成为另一类距离,状态转移中也加入这一步交换的来源和费用。不同字符替换可设不同代价,连续插入或删除的整段惩罚也需要额外状态。近似匹配可以允许不超过 k 的编辑代价,但匹配子串与转换整个字符串的初始状态不同。

练习题: 洛谷 P2758 — 编辑距离。

3. 背包问题

3.1 01 背包

题目原文(自拟): 有 N 件物品,第 i 件的重量 weight[i] 为正整数、价值 value[i] 为非负整数,每件最多选择一次。背包容量 C>=0,求总重量不超过 C 的最大价值。N 与两个数组长度一致,价值总和在 int 范围内;还可以要求返回一组被选择的从 0 开始的物品下标。

思路分析: 先看重量 [2,1,3]、价值 [4,2,3]。状态 dp[i][j] 表示只用前 i 件物品、总重量不超过 j时的最大价值。前 i 件的下标是 0…i-1,所以当前新加入的物品是下标 i-1。

求 dp[2][3] 时,只能选下标 0、1,重量最多为 3。四个合法集合是空集、{0}、{1}、{0,1},价值分别为 0、4、2、6。按是否选择当前物品 1 分开:

当前集合这一类 允许的完整集合 去掉当前物品后的集合 剩余子问题 该类最大价值
不选物品 1 空集、{0} 集合不变 前 1 件、容量 3,dp[1][3] 4
选择物品 1 {1}、{0,1} 空集、{0} 前 1 件、容量 3-1=2,dp[1][2] 4+2=6
完整集合逐一分类,拿掉当前物品后同时缩小物品范围与容量

选择物品 1 的方案拿掉它后,可用物品减少一件,剩余重量最多为 2。反过来,任何只用物品 0、重量不超过 2 的集合,补回重量 1、价值 2 的物品 1,总重量就不超过 3。补回的价值始终是 2,所以剩余集合价值越大,完整价值也越大,可以直接读取 dp[1][2]。两类都覆盖到了,比较 max(4,6)=6。

令当前物品重量 w=weight[i-1]、价值 v=value[i-1],同样得到

\[dp[i][j]=\begin{cases}dp[i-1][j],&j<w,\\\max\bigl(dp[i-1][j],\ dp[i-1][j-w]+v\bigr),&j\ge w.\end{cases}\]

第一项保持原容量,因为没有拿当前物品;第二项先留出重量 w,再补回价值 v。两项都读 i-1 行,剩余集合里都没有当前物品。若物品重于容量,例如重量 3、容量 2,选择分支不存在,直接沿用不选分支。

没有物品时,各容量只对应空集,价值为 0,所以第 0 行全为 0。容量限制是“不超过”,空集对任意容量都合法;没有必要为了达到容量而拿物品。逐件填行,每个状态的两个前驱都在上一行,最终读取 dp[N][C]。

每件物品形成一行,选与不选两个候选共同产生当前值

容量 0…4 的四行依次为 [0,0,0,0,0]、[0,0,4,4,4]、[0,2,4,6,6]、[0,2,4,6,6]。第三件在容量 4 处比较“不选的 6”与“容量 1 的 2 加价值 3”,得到 max(6,5)=6。

压缩成一行后的读取顺序。 一行数组更新前存上一行。重量 2、价值 3 的一件物品,容量 4:逆序先算 dp[4]=max(0,dp[2]+3)=3,此时 dp[2] 仍为上一行的 0;之后才更新 dp[2]=3。这就保留了“剩余集合没有当前物品”的含义。

一行数组从大容量读取旧前驱,与正序重复使用当前物品的对照

若正序先把 dp[2] 写为 3,再用它算 dp[4],就会得到 6。这个前驱已经拿过当前物品,接回一次就拿了两件。01 背包因此逆序容量;下一节完全背包会允许这种重复连接。

下面先给出可放进 main 的一维核心片段,再给出独立求值函数与恢复函数。

#include <algorithm>
#include <vector>
using namespace std;
vector<int> w{2, 1, 3}, v{4, 2, 3};
int n = static_cast<int>(w.size()), W = 4;
vector<int> dp(W + 1, 0); // 初始化为容量不超过 W 的最优价值。
for (int i = 1; i <= n; i++) {
    for (int j = W; j >= w[i - 1]; j--) { // 注意:需要倒序遍历
        dp[j] = max(dp[j], dp[j - w[i - 1]] + v[i - 1]);
    }
}

恢复时,比较当前行与上一行:值变大则记录当前件并扣重量;相等则跳过也能保留一组最优方案。

沿相邻两行比较决定当前物品的选择,并更新剩余容量

样例从 (3,4) 开始,当前行与上一行都为 6,跳过物品 2;到 (2,4),6 大于上一行的 4,拿物品 1,容量减为 3;到 (1,3),4 大于上一行的 0,拿物品 0,容量减为 1。逆序记录 [1,0],反转为 [0,1],总重量 3、价值 6。两行相等时选择跳过只是恢复其中一组最优解,其他最优解仍可能拿当前件。

3.1.1 按选或不选划分二维状态

每个合法集合恰属于“不选当前物品”或“选当前物品”之一。选中时去掉它,剩下只能使用前 i-1 件且重量不超过 j-w;若剩余部分不是该子问题的最优解,替换它即可提高总价值,故可以使用上一行最优值。

#include <algorithm>
#include <utility>
#include <vector>
using namespace std;
// 每个合法集合恰属于“不选当前物品”或“选当前物品”之一。
int knapsack_01_2d(int N, int C, vector<int> &weight, vector<int> &value) {
    // dp[i][j]: 考虑前i件物品,背包容量为j时的最大价值
    vector<vector<int>> dp(N + 1, vector<int>(C + 1, 0));

    for (int i = 1; i <= N; i++) {
        for (int j = 1; j <= C; j++) {
            // 不选第i件物品
            dp[i][j] = dp[i - 1][j];

            // 如果背包容量足够,考虑选择第i件物品
            if (j >= weight[i - 1]) {
                dp[i][j] = max(dp[i][j], dp[i - 1][j - weight[i - 1]] + value[i - 1]);
            }
        }
    }

    return dp[N][C];
}
3.1.2 用逆序容量保留上一行

压缩后从大容量向小容量更新。读到的 dp[j-w] 还没有被当前物品覆盖,对应上一行;这保证当前物品只被使用一次。

#include <algorithm>
#include <utility>
#include <vector>
using namespace std;
// 压缩后从大容量向小容量更新。
int knapsack_01_1d(int N, int C, vector<int> &weight, vector<int> &value) {
    // dp[j]: 背包容量为j时的最大价值
    vector<int> dp(C + 1, 0);

    for (int i = 0; i < N; i++) { // 遍历物品
        // 逆序遍历容量,确保每个物品只被使用一次
        for (int j = C; j >= weight[i]; j--) {
            dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
        }

        // 正序会读取本轮已更新的前驱,导致同一物品重复使用:
        // 假设物品重量2,价值3,C=4
        // 正序:j=2时dp[2]=3, j=4时dp[4]=dp[2]+3=6(错误,使用了两次)
        // 逆序:j=4时dp[4]=3, j=2时dp[2]=3(正确,只使用一次)
    }

    return dp[C];
}
3.1.3 通过相邻两行恢复物品

从最后一件和完整容量回溯。若当前行比上一行大,当前物品一定参与这条最优来源,记录下标并扣重量;值相同则不选也能保持最优,向上一行继续。

#include <algorithm>
#include <utility>
#include <vector>
using namespace std;
// 从最后一件和完整容量回溯。
pair<int, vector<int>> knapsack_01_with_items(int N, int C, vector<int> &weight,
                                              vector<int> &value) {
    vector<vector<int>> dp(N + 1, vector<int>(C + 1, 0));

    // 计算dp表
    for (int i = 1; i <= N; i++) {
        for (int j = 1; j <= C; j++) {
            dp[i][j] = dp[i - 1][j];
            if (j >= weight[i - 1]) {
                dp[i][j] = max(dp[i][j], dp[i - 1][j - weight[i - 1]] + value[i - 1]);
            }
        }
    }

    // 回溯找出选择的物品
    vector<int> selected_items;
    int j = C;
    for (int i = N; i > 0; i--) {
        if (dp[i][j] != dp[i - 1][j]) {
            // 说明第i件物品被选中了
            selected_items.push_back(i - 1); // 记录物品索引(0-based)
            j -= weight[i - 1];              // 减少剩余容量
        }
    }

    reverse(selected_items.begin(), selected_items.end());
    return {dp[N][C], selected_items};
}

样例与解释: 重量 [2,1,3]、价值 [4,2,3]、容量 4,最大价值为 6,选择下标 0、1,重量 3,没有装满但仍最优。N=0 或 C=0 时答案为 0。

复杂度: 三种函数的填表时间均为 \(O(NC)\)。二维与恢复方案的表空间 \(O(NC)\),一维额外空间 \(O(C)\);恢复结果最多 N 个下标。容量是数值规模,输入容量非常大时不能只看物品数就开表。

扩展: 恰好装满时,只有容量 0 初始可达,其余为负无穷,转移前还要判断前驱可达;方案计数时,dp[0]=1,其余为 0,按物品逆序容量累加。可以把状态改成“达到价值 v 的最小重量”,在总价值较小而容量很大时更合适。多重背包可把有限数量拆成若干 01 物品包,例如 13 件拆成 1、2、4、6,组合能表达 0…13 件;也可以继续学习按重量余数分类的单调队列优化。分割等和子集可先检查总和是否为偶数,再用 01 可行性 DP 判断能否达到总和的一半。

练习题: 洛谷 P1048 — 采药、洛谷 P1049 — 装箱问题、洛谷 P1164 — 小 A 点菜(方案计数)。

3.2 完全背包

题目原文(自拟示例): 有 n 种物品,第 i 种重量为正整数 weight[i]、价值为非负整数 value[i],每种可以选择任意多件。背包容量为 capacity >= 0,求重量不超过容量的最大总价值;所有答案在 long long 范围内。

思路分析: 与 01 背包相比,现在同一种物品可以拿多件。仍先保留两个维度:f[i][j] 表示只用前 i 种、重量不超过 j 的最大价值。第 i 种在数组中的下标是 i-1。

只考虑重量 2、价值 3 的一种物品,容量 4。按拿了几件列出全部方案:

件数 总重量 总价值 去掉一件后
0 0 0 没有当前物品,属于不拿分支
1 2 3 剩 0 件,重量 0、价值 0
2 4 6 剩 1 件,重量 2、价值 3
按件数列出方案,删去一件后仍允许保留同种物品

把“拿至少一件”的两个方案各拿掉一件,得到 0 件和 1 件。这些剩余方案的重量不超过 4-2=2,仍然允许这一种物品,所以前驱是本行 f[1][2],最大价值为 3。给这个最优剩余方案补一件,得到 3+3=6;不拿分支是上一行 f[0][4]=0,比较后取 6。

一般容量 j 也按“不拿当前种”和“至少拿一件当前种”分为两类。后一类去掉一件,容量少 w,种类范围仍为前 i 种;任何这种剩余方案补一件后又回到当前问题。令 w=weight[i-1]、v=value[i-1],得到

\[f[i][j]=\begin{cases}f[i-1][j],&j<w,\\\max\bigl(f[i-1][j],\ f[i][j-w]+v\bigr),&j\ge w.\end{cases}\]

与 01 背包并排看:

去掉当前一件后 剩余方案中能否还有当前种 “拿”的前驱
01 背包 这一件已经拿掉,不能再出现 上一行 dp[i-1][j-w]
完全背包 可以仍有一件或多件 本行 f[i][j-w]

也可以直接枚举拿 k 件:\(f[i][j]=\max_{0\le k\le\lfloor j/w\rfloor}(f[i-1][j-kw]+kv)\)。上面的两分支式把“至少一件”先拆下一件,剩余件数交给本行较小容量处理,省去了每格再次枚举件数。

初始化仍是空集价值 0,允许不装满。逐种处理时,本行从小容量到大容量计算:j-w<j,需要的本行前驱先产生。一维数组里,更新前的 dp[j] 保留不拿分支;已经更新过的 dp[j-w] 则对应本行。

正序更新依次读到本轮已经产生的一件、两件与三件状态

重量 2、价值 3、容量 0…6 时,先写 dp[2]=3,再由它写 dp[4]=6,最后由 6 写 dp[6]=9,全表为 [0,0,3,3,6,6,9]。这三个数分别对应一件、两件、三件。若逆序读旧行,就只能接回一次,计算结果会变成 01 背包。

恰好装满的任务中,只有容量 0 一开始有空方案;其他容量先记录没有方案,再寻找能接入的前驱。重量为 0、价值为正的物品则每加一件都增加价值而不占容量,最大价值可以一直增长;本题的正重量使每次接入都缩小前驱容量。

#include <algorithm>
#include <vector>
using namespace std;

long long completeKnapsack(const vector<int> &weight, const vector<int> &value, int capacity) {
    vector<long long> dp(capacity + 1, 0);
    for (size_t i = 0; i < weight.size(); ++i) {
        // 正序容量循环,允许本轮状态继续使用同一种物品。
        for (int j = weight[i]; j <= capacity; ++j) {
            dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
        }
    }
    return dp[capacity];
}

样例: 重量 [2, 3]、价值 [3, 4]、容量 6,可以选三个第一种物品,最大价值为 9。

代码解释: 与 0/1 背包相比,关键区别是容量循环方向。正序允许重复使用本轮物品;逆序限制每种物品至多使用一次。两个数组按同一物品下标配对读取。时间 \(O(nC)\),额外空间 \(O(C)\)。

练习题: 洛谷 P1616 — 疯狂的采药。

3.3 最少硬币数量

题目原文(自拟示例): 给定一些正整数面值,每种硬币数量无限,求凑出非负金额 amount 所需的最少硬币数。无法凑出返回 -1,金额为 0 时返回 0。

思路分析: 回到贪心中面值 [1,3,4]、金额 6 的例子。先拿 4 会留下 1+1,共 3 枚;方案 3+3 只要 2 枚。因此逐个比较可能的末尾硬币,让较好的前驱方案参与组合。

令 dp[x] 表示恰好凑出金额 x的最少硬币数。金额必须恰好,金额 5 的方案不能直接当金额 6 的答案。对金额 6,拿掉选作最后一枚的硬币:

最后一枚 剩余金额 该金额的最少方案 接回后的枚数
1 6-1=5 1+4,2 枚 dp[5]+1=3
3 6-3=3 3,1 枚 dp[3]+1=2
4 6-4=2 1+1,2 枚 dp[2]+1=3
拿掉末尾硬币得到具体剩余金额,接回后比较总枚数

任意非空方案都有一枚硬币可以拿掉;剩下金额一定为 x-coin。同一金额的剩余方案若用了更多硬币,就把它换成最少枚数的方案,再接回这枚 coin,金额保持 x,总枚数更少。于是每种末尾硬币只需读取该剩余金额的最优值。

同一组硬币可能按不同顺序出现在多个末尾候选中,例如 1+4+1 与 1+1+4。这里比较枚数的最小值,重复候选不会改变最小值;若任务改为数硬币组合,就还要重新安排枚举方式来避免重复。

把 6 换成 x,逐个面值试接回来:

\[dp[x]=\min_{\substack{coin\le x\\x-coin\text{ 能凑出}}}\bigl(dp[x-coin]+1\bigr).\]

x-coin 是扣掉这一枚后的金额,+1 是补回这一枚,min 选择枚数最少的候选。没有可接前驱时,当前金额继续记录为 INF,表示尚无方案。

金额 0 的空方案需要 0 枚,所以 dp[0]=0。其他金额先写 INF。面值 [2] 时,金额 1 没有可放的硬币,保持 INF;算金额 3 时,放 2 后还剩金额 1,它没有方案,所以金额 3 也没有方案。程序最后将这种结果返回为 -1。

逐个金额试各面值,前驱金额均比当前金额小

面值 [1,3,4] 的金额 0…6 依次得到 [0,1,2,1,1,2,2]。所有面值为正,剩余金额 x-coin 比 x 小,因此从小金额到大金额填写。金额 6 的三个前驱值已经得到,直接比较 3、2、3,最终读取 dp[amount]。

#include <algorithm>
#include <limits>
#include <vector>
using namespace std;

int minCoins(const vector<int> &coins, int amount) {
    constexpr int INF = numeric_limits<int>::max() / 2;
    vector<int> dp(static_cast<size_t>(amount) + 1, INF);
    dp[0] = 0; // 凑出金额 0 不需要硬币,其他金额初始不可达。
    for (int x = 1; x <= amount; ++x) {
        for (int coin : coins) {
            if (coin <= x && dp[x - coin] != INF) {
                dp[x] = min(dp[x], dp[x - coin] + 1);
            }
        }
    }
    return dp[amount] == INF ? -1 : dp[amount];
}

样例: 面值 [1, 3, 4]、金额 6,答案为 2,选择 3 + 3;面值 [2]、金额 3,返回 -1。

代码解释: INF 表示不可达;只有前驱金额可达时才能转移。这解决了前面“先拿最大面值”贪心可能失败的问题。时间 \(O(kM)\),额外空间 \(O(M)\),其中 k 是面值数量,M 是目标金额。

练习题: 洛谷 P2842 — 纸币问题 1。

4. 网格 DP:按依赖顺序填表

题目原文(自拟): 有一个 rows×columns 的网格,1<=rows,columns<=500,只能向右或向下移动,不能越界。部分格子是障碍。求从左上角 (0,0) 到右下角 (rows-1,columns-1) 的合法路径数量,对 1000000007 取模。函数输入是等长行组成的 vector<vector<bool>> blocked,true 表示障碍;返回一个整数。起点或终点为障碍时返回 0;只有一个可走格子时,不移动计为一条路径。

从搜索递推到迭代顺序: 状态仍是 dp[r][c],表示从起点到当前格子的路径数。先把具体路线与每个加数对应起来,再安排填表顺序。

无障碍网格的三条路线删去末步,分别落在上方与左方

计算 (1,2) 时,RRD 删去 D 后是到 (0,2) 的 RR;RDR、DRR 删去 R 后是到 (1,1) 的 RD、DR。给上方的每条路线接一个 D,给左方的每条路线接一个 R,完整路线全部还原。两类最后一步不同,数量为 1+2=3。

计算一般格子 (r,c) 时也是如此,上方提供最后向下的全部路线,左方提供最后向右的全部路线。取模只是控制保存的数值,路径分类仍按同样方式相加:

\[dp[r][c]=\bigl(dp[r-1][c]+dp[r][c-1]\bigr)\bmod M.\]

第一行没有上方格子,实际只接左方路线;第一列实际只接上方路线。起点的不移动空路线计为 1。遇到障碍,所有要经过该格的路线都无法接入,格子写 0。

逐格填表时标出前驱数量、当前求和及尚未计算的格子
处理位置 已经能读到的路线数 当前写入
(0,0) 起点,空路线 1
(0,1) 左方 1 1
(0,2) 左方 1 1
(1,0) 上方 1 1
(1,1) 上方 1,左方 1 2
(1,2) 上方 1,左方 2 3

按行从上到下、行内从左到右,每个上方在上一行已经完成,每个左方在当前行已经完成。记忆化从终点发出请求,直到最简单状态返回;迭代从起点逐格写入,到终点时直接读取同一份依赖产生的结果。

封住格子后逐步显示哪些前驱路线的贡献变为零

把 (0,1) 封住,RRD 与 RDR 都经过它,只有 DRR 保留。第一行变成 1、0、0,第二行依次为 1、1、1;终点从上方得到 0、从左方得到 1,仍使用同一个相加方程得到 1。

#include <vector>
using namespace std;

int gridPaths(const vector<vector<bool>> &blocked) {
    constexpr int mod = 1000000007;
    int rows = static_cast<int>(blocked.size());
    int columns = static_cast<int>(blocked[0].size());
    vector<vector<int>> dp(rows, vector<int>(columns, 0));
    for (int row = 0; row < rows; ++row) {
        for (int column = 0; column < columns; ++column) {
            // 障碍格不参与转移,起点被封住时也保持 0 条路径。
            if (blocked[row][column]) {
                continue;
            }
            if (row == 0 && column == 0) {
                dp[row][column] = 1;
            } else {
                long long count = 0;
                if (row > 0) {
                    count += dp[row - 1][column];
                }
                if (column > 0) {
                    count += dp[row][column - 1];
                }
                dp[row][column] = static_cast<int>(count % mod);
            }
        }
    }
    return dp[rows - 1][columns - 1];
}

样例与代码解释: 无障碍 2×3 返回 3;把 (0,1) 变成障碍后第一行是 1、0、0,第二行是 1、1、1,返回 1。先处理障碍再处理起点,与记忆化版本的起点、障碍处理方式一致。每格常数工作,时间与表空间均为 \(O(rows\times columns)\);进一步压缩为一行时,上方是覆盖前的 dp[c],左方是本轮的 dp[c-1],遇到障碍时将 dp[c] 写为 0,表示没有路径穿过这个格子。

练习题: 洛谷 P1002 — 过河卒。

查阅笔记正文与全部题解 · Ctrl / ⌘ K