写给 C++ 工程师的动态规划:从 DAG 依赖到 CPU 缓存行级空间压缩

很多做 C++ 底层系统开发的工程师都有一个说不出口的痛点:简历上能手撕无锁环形队列、能用 perf 抓 CPU 缓存缺失(Cache Miss)、能把模板元编程与内存屏障玩得滴水不漏;但面试一遇到动态规划,对着一张二维表格僵住三分钟,就是说不清 dp[i][j] 到底代表什么量。

我自己也曾是那个卡住的人。后来在做高并发存储引擎与编译器后端时我才彻底想通:动态规划从来不是脱离工程的数学技巧,它本质上就是最纯粹的 C++ 系统状态机设计与内存局部性极致优化。

我们在网络栈里维护 TCP 状态机,在分布式事务中对齐 Raft Commit Index,在交易系统里用 std::atomic<uint64_t> 打包复合状态位——这些工程实践的核心只有一个:用有限且精确的内存量概括整段历史,让后续的一切决策彻底解耦于历史路径。这在算法理论中叫“无后效性”,在 C++ 系统架构里叫“类不变量(Class Invariant)”。

而在工程落地层面,当应用层开发者习惯性地 new 出多层嵌套容器时,受过严格 C++ 训练的工程师会一眼看出问题:vector<vector<int>> 意味着 $M$ 次离散堆内存分配与灾难般的指针追逐(Pointer Chasing),直接把 CPU L1D Cache 砸得粉碎。真正的 DP 高手,是在设计阶段完成数据流活跃变量分析(Liveness Analysis),把高维依赖拓扑坍缩为单行扁平缓冲区(Flat Buffer),让 CPU 硬件预取器(Hardware Prefetcher)跑满带宽。

带你从最朴素的递归暴力解,一步步推导至零多余开销的 C++ 空间压缩终态。让你看清楚:所谓搞懂 DP,就是稳定驾驭三个正交动作——提炼无后效性状态、构建数据依赖 DAG、利用生命周期压缩内存布局

状态、转移、压缩——动态规划其实只有这三件事

先把手艺的骨架立起来,后面六道题都是往这个骨架上挂肉。

《算法导论》讲动态规划那一章写道,它能用的前提是两个性质缺一不可。第一个是最优子结构:一个规模为 n 的问题的最优解,可以由若干规模更小的子问题的最优解拼出来。第二个是无后效性:当你用一个状态概括了“走到这一步的历史”之后,接下来怎么走,只跟这个状态有关,跟你是“怎么走到这个状态”的无关。书里还补了第三个隐含前提——子问题重叠:如果子问题各不相同、互不复用,那记不记表都一样,DP 相对暴力递归就没有任何便宜可占。这三条里,前两条决定这题能不能用 DP,第三条决定用 DP 值不值。

第二条是架构师最熟悉、却最容易在算法题里忘记的。它就是状态机的定义:一个良好的状态,应该让你把到达它之前的所有路径都忘掉。如果你发现“走到 (i,j) 之后能不能继续,还取决于我前面第几步做了什么”,那说明你的状态没定对——它漏掉了必须记住的信息,你得把那部分信息加进状态里(升维),或者换一个定义。

有了这两条,DP 的施工就固定成了三步。

第一步,定义状态。 用一句人话写清楚 dp[i] 或 dp[i][j] 到底是什么量。这句话是整道题的地基,也是唯一真正需要动脑的地方。地基一歪,后面全塌。我给你一个检验:你定义的状态,必须能让你“站在这个格子上,只看有限个相邻格子,就能算出自己”。做不到,就是定义错了。

第二步,写转移方程。 站在 dp[i][j] 这个格子上,问自己:我这个最优解的“最后一步”有几种可能?每一种可能,都对应一个更小的、已经算好的子问题。把这些可能取个 min / max / sum,就是转移方程。转移方程的本质是“对最后一步做分类讨论”。

第三步,定边界与顺序。 最小的那些子问题(空串、第一行、金额为零)没有更小的可依赖,得手工填。填表的顺序必须保证:算 dp[i][j] 时,它依赖的格子都已经算好了。

三步走完,你已经有了一个能出正确答案的解——通常是 O(状态数 × 每次转移的分支数) 的时间,和“状态数”那么大的空间。这就是入门。从入门到高阶,中间只隔着一件事:空间压缩。

这里插一句关于“两个方向”的话,后面每道题都用得上。刚才那个记忆化递归,是自顶向下——从你要的那个大问题出发,需要哪个子问题才去算哪个,天然只碰“可达”的状态,代码贴着递归定义、好写好懂,代价是函数调用开销与爆栈风险。而两层循环填表是自底向上——从最小的子问题开始,按依赖顺序把整张表推满,没有递归开销,但可能算了一些最终用不上的状态。两个方向算出的是同一张表,选哪个看工程约束:状态空间稀疏、只有一小片可达,自顶向下更省;状态密集、还想接着压空间,自底向上是唯一能压的形态(滚动数组没法在递归里做)。这篇后面清一色走自底向上,因为我们的重头戏是压缩。

还有一个和压缩相反的动作,叫升维,同样值得先记住。当你发现状态没法只靠有限个相邻状态推出自己时,往往是状态漏了信息,得给它加一个维度把漏掉的那部分补回来——比如“背包还剩多少容量”“上一步选的是谁”“当前是买入还是卖出状态”。压缩是发现状态里有冗余、把维度拿掉;升维是发现状态里有缺失、把维度加上。两个动作方向相反,判据却是同一条:盯着无后效性,看这个状态到底够不够、又多不多。 这条判据,后面六道题会反复用到。

空间压缩的原理,是一次朴素的数据依赖分析。你盯着转移方程看:dp[i][j] 到底依赖哪几个格子?如果它只依赖上一行(dp[i-1][*])和本行左边(dp[i][*]),那“上上行”及更早的整张表,从这一刻起就再没人会读了——它们是死变量。既然是死变量,就没必要留着。把二维表压成一维滚动数组,空间从 O(mn) 掉到 O(n)。

这套“识别死变量、就地覆盖”的手法,你在别处见过。编译器做寄存器分配时的活跃变量分析(liveness analysis),判断一个值算出来之后还会不会被用到、不会就释放它占的寄存器——和 DP 空间压缩是同一个思想的两个应用。流式计算里的滑动窗口、只保留最近 k 个状态的在线算法,也是。所以空间压缩说到底就是“依赖分析 + 就地复用”这个系统设计基本功在一维数组上的一次演出,算不上什么专门的算法技巧。 你之所以觉得它高阶,只是因为没人告诉过你它和你天天做的事是一回事。

下面六道题,我们就按“定义状态 → 写转移 → 压空间”这三步走,每道题都会在某一步上给你一个新的难点。第一道题最简单,用来把三步跑通。

矩阵最小路径和,怎么把暴力递归逼成一张表

题目(对应 LeetCode 64):给一个 m × n 的非负整数矩阵,从左上角走到右下角,每步只能向右或向下,求经过的数字之和最小的那条路径的和。

左程云在《程序员代码面试指南》里也把这道题摆在动态规划章节的开头,理由和我一样:它的状态几乎是白送的——难度全在别处,正好用来把三步流程跑通,也用来看清楚“为什么暴力解会死”。

先写一个几乎不用想的解:从终点倒推,走到 (i,j) 的最小代价,等于 grid[i][j] 加上“从起点走到它上面那个格子”和“从起点走到它左边那个格子”里更小的那个。直接翻译成递归:

C++

// 会 TLE 的写法,先别急着优化,看它错在哪
int minPath(const vector<vector<int>>& g, int i, int j) {
    if (i == 0 && j == 0) return g[0][0];
    int up   = (i > 0) ? minPath(g, i - 1, j) : INT_MAX;
    int left = (j > 0) ? minPath(g, i, j - 1) : INT_MAX;
    return g[i][j] + min(up, left);
}
// 调用:minPath(g, m - 1, n - 1)

这段代码是对的,它能出正确答案。问题是它慢得没法用。慢在哪?你手工展开一下 minPath(g, 2, 2) 就会发现,它要算 minPath(g,1,2) 和 minPath(g,2,1),而这两个又都要算 minPath(g,1,1)——(1,1) 被算了两遍。格子越靠右下,重复计算越离谱,整棵递归树的节点数是组合数级的(和从角到角的单调路径数同阶),随 m+n 指数增长。

这是所有 DP 题的起点,也是 DP 存在的理由:一个正确的暴力递归,因为反复求解同一批子问题而爆炸。 DP 干的事,本质上就是给这些子问题的答案找个地方存起来,算过一次就不再算第二次。

最直接的存法叫记忆化:开一张和矩阵同样大的表,算出一个 (i,j) 就把结果记进去,下次要用先查表。这一步几乎不用改逻辑,只是在递归外面套了个缓存。它把时间从指数级压回了 O(mn)——因为每个格子最多真正计算一次。

但记忆化还是递归,有函数调用开销,还可能爆栈。既然我们已经知道每个格子只依赖它上面和左边,那就干脆按顺序把表填出来,用两层循环代替递归。这就是标准的二维 DP:

C++

int minPathSum(vector<vector<int>>& g) {
    int m = g.size(), n = g[0].size();
    vector<vector<int>> dp(m, vector<int>(n));
    dp[0][0] = g[0][0];
    for (int j = 1; j < n; ++j) dp[0][j] = dp[0][j - 1] + g[0][j]; // 第一行只能从左边来
    for (int i = 1; i < m; ++i) dp[i][0] = dp[i - 1][0] + g[i][0]; // 第一列只能从上边来
    for (int i = 1; i < m; ++i)
        for (int j = 1; j < n; ++j)
            dp[i][j] = g[i][j] + min(dp[i - 1][j], dp[i][j - 1]);
    return dp[m - 1][n - 1];
}

三步流程在这里全齐了:状态 dp[i][j] = 从左上角走到 (i,j) 的最小路径和;转移 dp[i][j] = grid[i][j] + min(上, 左),“最后一步”只有两种可能,向下来的或向右来的;边界是第一行第一列,它们各自只有一个方向能来,得单独填。填表顺序从左到右、从上到下,保证算每个格子时上边和左边都已就绪。时间 O(mn),空间 O(mn)。到这里,入门就完成了。

现在做那件把入门变高阶的事——压空间。盯着转移方程:dp[i][j] 只依赖 dp[i-1][j](正上方)和 dp[i][j-1](正左方)。它从不回头看第 i-2 行。这意味着当我们逐行往下填时,“上上行”就是死变量。

那就只留一行。用一个长度为 n 的一维数组 dp,逐行滚动更新。关键在更新的那一瞬间理解每个变量代表什么:

C++

int minPathSum(vector<vector<int>>& g) {
    int m = g.size(), n = g[0].size();
    vector<int> dp(n);
    dp[0] = g[0][0];
    for (int j = 1; j < n; ++j) dp[j] = dp[j - 1] + g[0][j]; // 先填出第一行
    for (int i = 1; i < m; ++i) {
        dp[0] += g[i][0];                                    // 每行第一个格子只能从上边来
        for (int j = 1; j < n; ++j)
            // 此刻 dp[j] 还是"上一行"的值(正上方),dp[j-1] 已是"本行"新值(正左方)
            dp[j] = g[i][j] + min(dp[j], dp[j - 1]);
    }
    return dp[n - 1];
}

这里有个初学者常栽的坑,值得停一下。dp[j] = g[i][j] + min(dp[j], dp[j-1]) 这一行里,等号右边的 dp[j] 和 dp[j-1] 代表的是不同时刻的值:dp[j] 还没被本轮改写,所以它是上一行同列的值,正好是“正上方”;而 dp[j-1] 在本轮循环刚才那一步已经被改写成本行的新值,正好是“正左方”。整个压缩能成立,靠的就是“从左往右”这个更新方向恰好让每个格子在被覆盖前,最后一次被读到的就是它作为“正上方”的那次。方向反了,压缩就错了。空间从 O(mn) 降到 O(n)——如果矩阵瘦长,还可以沿短边滚,取 O(min(m,n))。

一张图看清楚滚动那一瞬间发生了什么:

text

填第 i 行、正要算第 j 列:
   dp[0] .. dp[j-1]  dp[j]  dp[j+1] .. dp[n-1]
   └── 本行新值 ──┘   ↑      └── 仍是上一行 ──┘
                   正在算
   min( dp[j]=正上方 , dp[j-1]=正左方 )

到这儿,这道题从暴力递归压到了 O(n) 空间。但我要在这里插一句和后面所有题都相关的话,也是我自己交过学费换来的:空间压缩不总是该做。

有一次做一个跨区域的资源调度计费,模型正好是这类网格最小代价路径——每个格子是一档资源的单位成本,一条路径是一种分配方案,要选总成本最低的。我很自然地把二维表压成了一维,代码干净,内存也省。上线小半年,财务对账对出一笔差异,要求我回答“某个客户当月为什么被算成走了这条路径而不是那条”。这时候我傻了:一维滚动数组在算完最后一格的那一刻,中间所有决策痕迹都被覆盖掉了,我连“每个格子当时是从上边来还是左边来”都没留。为了回答一个审计问题,我只能把整个计算重跑一遍、并额外记录路径,前后排查花了三天。

那次之后我给自己立了条规矩:空间压缩省的是内存,赔的是可回溯性。 二维表本身就是一份完整的计算留痕,你想回溯任何一个中间决策都能查;压成一维,你换来了内存,却把这份留痕烧了。这笔账和系统里“用缓存换算力,但丢了中间态导致没法调试”是同一笔。当这道 DP 是线上系统的一部分、结果要被审计、要支持增量重算时,我现在会刻意保留二维表,甚至额外存一张记录“每格从哪来”的方向表。面试里压到 O(1) 是加分项;生产里压掉可观测性,是给未来的自己挖坑。

矩阵最小路径和到此为止。它的状态是白送的,所以我们能把全部注意力放在流程和压缩上。下一道题,状态还是不难,但“转移”会第一次分叉——同一张表,我们要让它同时回答两个不同的问题。

为什么换钱能用同一张表同时数方法和求最少

两道题一起看,因为它们是同一个状态骨架长出来的两个头,摆在一起最能看清“转移方程里那个聚合函数”有多要紧。

  • 换钱的方法数(对应 LeetCode 518):给一组币值 arr(每种币可以用无限张),给一个目标金额 aim,问凑出 aim 一共有多少种不同的组合方式({1,1,2} 和 {2,1,1} 算同一种)。

  • 换钱的最少货币数(对应 LeetCode 322):同样的币值和目标,问最少用多少张能凑出 aim,凑不出返回 -1。

先定状态。这类“从一堆物品里选,凑一个目标量”的问题,标准状态是二维的:dp[i][j] = 只允许使用前 i 种币、凑出金额 j 时的答案。至于“答案”是方法数还是最少张数,先不管,骨架是共用的。

转移方程的骨架也共用,它来自对“最后一步”的分类讨论——面对第 i 种币,你只有两类选择:一张都不用它,问题退化成用前 i-1 种币凑 j至少用一张它,那先拿掉一张(金额减去 arr[i]),剩下的问题是“仍然允许用第 i 种币”(因为无限张)凑 j - arr[i]。注意第二类里,用掉一张之后允许继续用同一种币,这正是“完全背包”和“每种只能用一次的 0-1 背包”的分水岭。

两类选择怎么合并,就是这两道题唯一的区别,也是全部的区别:

text

方法数: dp[i][j] = dp[i-1][j] + dp[i][j-arr[i]]
最少数: dp[i][j] = min(dp[i-1][j], dp[i][j-arr[i]] + 1)

一个用加法把两类方案数并起来,一个用 min 在两类里挑更省的、并且“至少用一张”那支要给张数 +1。状态定义一模一样,转移的依赖来源一模一样,只有那个聚合算子从 + 换成了 min 这就是我说“同一张表回答两个问题”的意思——你把手艺练到这个份上,看到一道新的背包题,脑子里跳出来的不该是“这题怎么做”,而是“状态是前 i 种物品凑金额 j,转移是选或不选,聚合看它问的是计数、极值还是存在性”。

先看方法数的完整二维实现,边界要格外小心:

C++

int coinWays(vector<int>& arr, int aim) {
    int n = arr.size();
    vector<vector<int>> dp(n, vector<int>(aim + 1, 0));
    for (int i = 0; i < n; ++i) dp[i][0] = 1; // 凑金额 0:什么都不选,是 1 种方法,不是 0 种
    for (int j = 1; j <= aim; ++j)            // 只用第 0 种币能凑出 j 的方法数
        dp[0][j] = (j % arr[0] == 0) ? 1 : 0;
    for (int i = 1; i < n; ++i)
        for (int j = 1; j <= aim; ++j) {
            dp[i][j] = dp[i - 1][j];                       // 不用第 i 种币
            if (j >= arr[i]) dp[i][j] += dp[i][j - arr[i]]; // 至少用一张第 i 种币
        }
    return dp[n - 1][aim];
}

dp[i][0] = 1 这行边界是最容易写错、也最能体现“状态定义要说人话”的地方。凑金额为 0 有几种方法?答案是 1 种——“一张都不拿”本身就是一种合法方法。如果你把它填成 0,整张表会因为乘不进这个“空组合”而全线归零。状态定义一旦含糊,边界就会咬你。

最少货币数的二维实现,骨架完全平行,只是把“不可达”用一个大数标记出来:

C++

int coinMin(vector<int>& arr, int aim) {
    int n = arr.size();
    const int INF = INT_MAX;
    vector<vector<int>> dp(n, vector<int>(aim + 1, INF));
    for (int i = 0; i < n; ++i) dp[i][0] = 0; // 凑金额 0 需要 0 张
    for (int j = arr[0]; j <= aim; j += arr[0]) dp[0][j] = j / arr[0]; // 只用第 0 种币
    for (int i = 1; i < n; ++i)
        for (int j = 1; j <= aim; ++j) {
            dp[i][j] = dp[i - 1][j];                        // 不用第 i 种币
            if (j >= arr[i] && dp[i][j - arr[i]] != INF)    // 用一张第 i 种,注意防 INF+1 溢出
                dp[i][j] = min(dp[i][j], dp[i][j - arr[i]] + 1);
        }
    return dp[n - 1][aim] == INF ? -1 : dp[n - 1][aim];
}

这里那句 dp[i][j-arr[i]] != INF 的判断不是可有可无的防御性代码,它是逻辑的一部分:INF 代表“这个金额根本凑不出”,一个凑不出的状态再加一张币还是凑不出,你不能让它参与 min 比较、更不能给 INF + 1 让它悄悄溢出成一个很小的负数——那会污染整张表。用极值当哨兵时,永远要先问一句“这个哨兵参与运算会怎样”。

现在压空间。两道题的转移都只依赖“上一行同列”(dp[i-1][j],不用当前币)和“本行左侧”(dp[i][j-arr[i]],用当前币),和矩阵最小路径和的依赖形状几乎一样。所以同样能压成一维滚动数组。压完之后代码短得会让人怀疑它凭什么对:

C++

int coinWays(vector<int>& arr, int aim) {
    vector<int> dp(aim + 1, 0);
    dp[0] = 1;
    for (int coin : arr)                          // 外层枚举币种
        for (int j = coin; j <= aim; ++j)         // 内层金额从小到大
            dp[j] += dp[j - coin];
    return dp[aim];
}

int coinMin(vector<int>& arr, int aim) {
    vector<int> dp(aim + 1, INT_MAX);
    dp[0] = 0;
    for (int coin : arr)
        for (int j = coin; j <= aim; ++j)
            if (dp[j - coin] != INT_MAX)
                dp[j] = min(dp[j], dp[j - coin] + 1);
    return dp[aim] == INT_MAX ? -1 : dp[aim];
}

一维版里藏着一个几乎所有人第一次都会踩、而且极难 debug 的点:内层金额循环的方向。这里我们从小到大 (j 递增),得到的是完全背包(每种币无限张);如果你把内层改成从大到小,得到的就是 0-1 背包(每种币最多一张)。差别为什么在方向上?

想清楚 dp[j] += dp[j - coin] 里 dp[j-coin] 代表谁。金额从小到大时,算 dp[j] 时 dp[j-coin] 已经在本轮被这枚币更新过了——也就是说 dp[j-coin] 里已经包含了“用过这枚币”的方案,于是这枚币可以被反复叠加使用,这正是“无限张”。反过来,金额从大到小时,dp[j-coin] 还是上一轮(没用这枚币)的旧值,这枚币最多被用一次,就是“每种一张”。一个循环方向,就是无限张和一张的分水岭——这跟矩阵那道题里“从左往右更新才能让 dp[j] 恰好是正上方”是完全同源的道理:一维压缩的正确性,永远系在“你读到的那个格子此刻代表哪个时刻的值”上。方向选错,代码照样跑、照样出一个答案,只是答错,还没有任何报错提示你。这是 DP 一维压缩最阴的地方,也是我建议你在生产代码里对着这类循环写一行注释说明方向语义的原因。

既然提到了方向,就把另一半讲完,这也是这道题最值得下探的一层。如果把题目改成“每种货币只有一张”(这正是 0-1 背包,左程云书里的换钱题也有这个变体),代码只需要把内层金额循环从“从小到大”改成“从大到小”:

C++

int coinWaysZeroOne(vector<int>& arr, int aim) {
    vector<int> dp(aim + 1, 0);
    dp[0] = 1;
    for (int coin : arr)
        for (int j = aim; j >= coin; --j)  // 金额从大到小 = 每种币最多用一次
            dp[j] += dp[j - coin];
    return dp[aim];
}

一个字符的改动(++j 变 --j、循环边界对调),语义天翻地覆。崔添翼那份流传很广的《背包问题九讲》里把这件事写死了:完全背包与 0-1 背包在一维写法上的唯一区别,就是内层循环的方向。为什么?回到那个“你读到的格子代表哪个时刻”的老问题。金额从大到小时,算 dp[j] 用到的 dp[j-coin] 还没在本轮被这枚币碰过,它是“没用这枚币”的旧状态,于是这枚币对每个金额只贡献一次,就是“一张”。方向一反,同一段代码就在“无限张”和“一张”之间切换——这是我见过最能说明“一维压缩的正确性系于遍历方向”的例子,没有之一。你在生产里但凡写这类背包滚动数组,务必对着内层循环写一行注释交代它是哪种背包,否则半年后没人(包括你自己)敢动它。

两道换钱题的账:时间都是 O(n × aim),一维压缩后空间 O(aim)。收获不在这两道题本身,而在那个可迁移的判断——背包类问题的状态骨架是固定的(前 i 个物品 × 目标量 j),选或不选是固定的转移,真正决定你写哪套代码的,是“聚合算子”和“内层循环方向”这两个旋钮。 记住这两个旋钮,一大类看起来五花八门的题会瞬间坍缩成同一道题。

下一道题,状态要第一次升到“两个序列各走一维”,而且我们会看到:状态定义里一个词的差别,会让“子序列”和“子串”变成两道题。

子序列还是子串?状态的含义只差一个 else

先把两个概念钉死,因为这道题一半的坑在概念上。子序列(subsequence)允许不连续:从原串里按顺序挑字符,可以跳过一些,"ace" 是 "abcde" 的子序列。子串(substring)必须连续:"bcd" 是子串,"bd" 不是。最长公共子序列(LCS,对应 LeetCode 1143)求的是两个串里那条最长的、可以带缺口的公共挑法;最长公共子串求的是那段最长的、必须连续的公共片段。

插一句来历:最长公共子序列是《算法导论》拿来当动态规划招牌例子的那道题,书里写道,正因为它的最优子结构清晰、子问题重叠明显,它几乎是演示 DP 三要素的最佳标本。下面我用一问一验的方式带你走一遍:每一步先问“状态该定成什么”,再用一个小例子验证它扛不扛得住。

第一问:状态定成什么? 两个串,自然想到二维。定义 dp[i][j] = str1 前 i 个字符与 str2 前 j 个字符的最长公共子序列长度。注意“前 i 个”这种用长度而非下标的定法,好处是天然容纳了“空串”这个边界(i=0 或 j=0 时 LCS 为 0),省掉一堆下标越界的判断。

第二问:转移怎么写? 老规矩,对“最后一步”分类。看 str1 的第 i 个字符和 str2 的第 j 个字符(下标是 i-1j-1):

  • 如果它俩相等,那这个公共字符值得要,接在 dp[i-1][j-1] 后面:dp[i][j] = dp[i-1][j-1] + 1

  • 如果不相等,这两个字符至少有一个不能出现在公共子序列的末尾,那就丢掉其一取更优:dp[i][j] = max(dp[i-1][j], dp[i][j-1])

C++

int lcs(const string& a, const string& b) {
    int m = a.size(), n = b.size();
    vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0)); // 第 0 行/列天然为 0
    for (int i = 1; i <= m; ++i)
        for (int j = 1; j <= n; ++j)
            if (a[i - 1] == b[j - 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];
}

第三问:验证一下。 拿 a = "1A2C3D4B56"b = "B1D23CA45B6A",填出来 dp[m][n] = 6,对应公共子序列 "123456"。你可以把表画出来盯着看它怎么长的,这里我更想让你注意转移的依赖形状:dp[i][j] 依赖左上 dp[i-1][j-1]、上 dp[i-1][j]、左 dp[i][j-1] 三个方向。比前两道题多了一个“左上角”的依赖,这个细节等下压空间时会变成一个小麻烦。

只求长度往往不够,实战里你常要把那条子序列本身还原出来。这就用到二维表相比一维滚动数组的那个好处——它留了完整的留痕,回溯路径全靠它。从右下角 (m,n) 往回走:

C++

string lcsString(const string& a, const string& b) {
    int m = a.size(), n = b.size();
    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)
            dp[i][j] = (a[i-1] == b[j-1]) ? dp[i-1][j-1] + 1
                                          : max(dp[i-1][j], dp[i][j-1]);
    // 从 (m,n) 回溯:每一步问"当初这个格子是怎么来的"
    string res;
    int i = m, j = n;
    while (i > 0 && j > 0) {
        if (a[i-1] == b[j-1]) { res.push_back(a[i-1]); --i; --j; } // 来自左上,这个字符入选
        else if (dp[i-1][j] >= dp[i][j-1]) --i;                    // 来自上方
        else --j;                                                  // 来自左方
    }
    reverse(res.begin(), res.end());
    return res;
}

回溯这一段值得你多看一眼:它证明了那张二维表不只是“求值的脚手架”,它是一份可查询的决策日志。这跟我在矩阵那道题里赔的三天是同一件事的正面——保留完整状态表,你就保留了回溯任意决策的能力。 求 LCS 内容必须用二维表回溯,恰恰是“该不该压空间”这个判断的一个反面教材:如果你只压成一维、扔掉了历史,就再也还原不出那条子序列了。

现在,把问题从子序列换成子串,看状态怎么变。第四问:子串的状态还能沿用吗? 不能,而且失败得很干脆。假设你还用“前 i 个和前 j 个的最长公共子串长度”,那当 str1[i-1] != str2[j-1] 时你打算怎么转移?子串要求连续,末尾两个字符对不上,就意味着“以这两个位置结尾的公共子串”长度归零——可你的状态定义的是“前 i 个和前 j 个之内的最长公共子串”,它可能出现在更靠前的位置,你没法从相邻格子推出它。状态漏掉了“必须以当前位置结尾”这个约束,就丧失了无后效性。

修法是把状态收窄,让它带上“结尾”这个约束:重新定义 dp[i][j] = “以 str1 第 i 个字符、str2 第 j 个字符同时结尾的最长公共子串长度”。转移立刻变得干净:

text

相等分支(两题一致): dp[i-1][j-1] + 1
子序列的 else 分支:   max(dp[i-1][j], dp[i][j-1])
子串的   else 分支:   0

看那个 else:子序列在字符对不上时向左、向上的邻居“借”一个已有的最长值(允许中间有缺口),子串在字符对不上时直接清零(连续性一断,以此结尾的公共子串就没了)。两道题的状态定义、转移的“相等”分支一模一样,全部差别浓缩在一个 else 分支上——一个 max(邻居),一个 0 这就是我这一节的标题想说的:状态含义只差一个词(“之内的最长” vs “以此结尾的最长”),代码只差一个 else,但它们是两道题。你能不能一眼看穿这个差别,就是“背了题解”和“懂了状态设计”的分界线。

因为子串的 dp[i][j] 求的是“以此结尾”,全局答案不在右下角,得在填表过程中用一个变量记录见过的最大值:

C++

string longestCommonSubstr(const string& a, const string& b) {
    int m = a.size(), n = b.size();
    vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
    int maxLen = 0, endI = 0;                 // endI 记最长子串在 a 中的结尾下标
    for (int i = 1; i <= m; ++i)
        for (int j = 1; j <= n; ++j)
            if (a[i-1] == b[j-1]) {
                dp[i][j] = dp[i-1][j-1] + 1;  // 只依赖左上角一个格子
                if (dp[i][j] > maxLen) { maxLen = dp[i][j]; endI = i; }
            } // else dp[i][j] 保持初值 0,不用写
    return a.substr(endI - maxLen, maxLen);
}

现在做压缩,而子串这道题的压缩正好是个高阶漂亮活,值得单拿出来讲。注意子串的转移只依赖一个格子——正左上方的 dp[i-1][j-1]。它既不看正上方也不看正左方。依赖只有一个方向,意味着我们可以沿着对角线方向遍历整张表:从矩阵的每一条对角线出发,一路往右下走,dp[i-1][j-1] 就是你在这条对角线上的前一个值。于是整张二维表可以用一个变量代替,空间从 O(mn) 干到 O(1):

text

沿对角线走,每条对角线上只留一个变量:
  cur = (a[i-1]==b[j-1]) ? prev + 1 : 0;
  更新全局 max,令 prev = cur,向右下移一格
  (prev 就是同一条对角线的上一格)

一张图看对角线遍历的路线:

text

      j=1 j=2 j=3 j=4
 i=1   ↘               每条对角线独立,只需一个滚动变量
 i=2       ↘   ↘        沿 ↘ 方向:cur 依赖的 prev
 i=3           ↘        恰好是这条线的上一格
 i=4               ↘

这个 O(1) 空间的子串解法,是“依赖分析决定能压到几维”这条主线最锋利的一次演示:转移依赖几个方向、依赖的格子在什么相对位置,直接决定了你能压到几维、以及要沿哪个方向遍历。 矩阵最小路径依赖上和左两个方向,压到一维;换钱依赖上和左,压到一维;子串只依赖唯一的左上一格,于是能压到常数。反过来,子序列因为那个 else 要看上、左两个方向,你就压不到常数、最多压到一维(滚动一行,但还得用一个临时变量小心救回被覆盖的“左上角”值)。空间能压到几维,从来都是转移方程的依赖宽度算出来的,跟灵机一动没有半点关系。

顺带把子序列那一维的压缩也补全,因为它藏着一个特别容易写错的细节,也是“依赖宽度决定压缩难度”的好注脚。子序列的 dp[i][j] 依赖上、左、左上三个格子,其中“左上”那个 dp[i-1][j-1] 在滚动数组里最麻烦:当你用一维数组逐行更新、算到 dp[j] 时,dp[j] 本身还是上一行的值(正上方),dp[j-1] 已经是本行新值(正左方),可你要的“左上”是上一行的 dp[j-1]——而它刚刚在上一步被覆盖掉了。解法是拿一个临时变量在覆盖前把它救下来:

C++

int lcsRolling(const string& a, const string& b) {
    int m = a.size(), n = b.size();
    vector<int> dp(n + 1, 0);
    for (int i = 1; i <= m; ++i) {
        int leftUp = 0;               // dp[i-1][j-1]:进入本行时是 dp[i-1][0]=0
        for (int j = 1; j <= n; ++j) {
            int tmp = dp[j];          // 覆盖前先存下"正上方",它是下一列的"左上"
            dp[j] = (a[i-1] == b[j-1]) ? leftUp + 1
                                       : max(dp[j], dp[j-1]);
            leftUp = tmp;             // 交棒:本列的"正上方"成为下一列的"左上"
        }
    }
    return dp[n];
}

对比子串那个沿对角线走、一个变量搞定的 O(1) 压法,子序列因为多了一个“左上”依赖,就得多养一个 leftUp 变量来接力,压到一维已是极限,再往下压不动了。这就是依赖宽度的实感:多依赖一个方向,压缩就多一分手脚。空间省了一个数量级(O(mn) → O(n)),代价还是那个老代价——这个滚动版没法回溯出子序列内容,要还原那条串,仍得请出完整的二维表。

到这里,我们已经把“定义状态、写转移、按依赖压空间”这套手艺在网格型和双序列型问题上跑通了。接下来两道题,我要给你泼一盆冷水:不是每道叫“最长”的题都该用 DP,识别锤子该不该出手,比会挥锤子更重要。

最长连续序列根本不是 DP,识别锤子比会挥锤子更重要

题目(对应 LeetCode 128):给一个未排序的整数数组,找出其中最长的一段“数值连续”的序列的长度。注意,这里的“连续”指数值上连续(差 1),跟它们在数组里的位置无关。比如 [100, 4, 200, 1, 3, 2],最长连续序列是 [1, 2, 3, 4],长度 4。

我把它放进这篇讲 DP 的文章,恰恰是因为它不该用 DP。而一个熟练工最容易犯的错,就是学会了一把好锤子之后,看什么都像钉子。这道题标题里有“最长”,形态上像极了子序列题,很多人条件反射就去设计 dp[i]。我们先认真试一次,看它为什么走不通。

假设你想定义 dp[i] = “以数组第 i 个元素结尾的最长连续序列长度”。问题立刻来了:要算 dp[i],你得知道“数值等于 arr[i]-1 的那个元素”的 dp 值是多少——可那个元素在数组里的下标是几?你不知道,它可能在 i 前面,也可能在 i 后面,甚至根本不存在。“连续”是定义在数值上的,而数组下标的顺序跟数值顺序毫无关系,于是你的状态 dp[i](按下标索引)根本无法从相邻的、已算好的状态推出来——无后效性不成立,这不是一个 DP 结构。

那把数组先排序呢?排完序确实能一遍扫过去数连续段,O(n log n)。能用,但多花了一个 log 因子,而且它绕过了问题的本质:这道题要的是“某个数值在不在集合里”的成员查询,元素在数组里的先后顺序在这里毫无意义。看清这一点,正解就浮出来了——用哈希集合把 O(1) 成员查询这个能力拿到手。

核心技巧是只从每段连续序列的起点开始数:一个数 x 是某段连续序列的起点,当且仅当 x-1 不在集合里。这样每段序列只会被它的起点触发一次完整计数,总的计数步数不会超过 2n。

C++

int longestConsecutive(vector<int>& nums) {
    unordered_set<int> s(nums.begin(), nums.end()); // 去重 + O(1) 成员查询
    int best = 0;
    for (int x : s) {
        if (s.count(x - 1)) continue;   // x 不是起点,跳过——保证每段只从起点数一次
        int len = 1, cur = x;
        while (s.count(cur + 1)) { ++cur; ++len; } // 从起点一路往上数
        best = max(best, len);
    }
    return best;
}

为什么这是 O(n) 而不是 O(n²)?关键就在那句 if (s.count(x - 1)) continue。它保证 while 内层的自增只发生在“起点”上,而每个元素在整个算法里最多作为某段序列的一员被内层访问一次。外层循环 n 次、内层总共走 n 步,合起来 O(n)。去掉那句起点判断,一段长度为 k 的序列会被它的 k 个成员各完整数一遍,退化成 O(n²)——这是这道题最经典的性能陷阱,代码看着对,数据一大就慢,还是那种没有报错的慢。

左程云书里给了另一种等价思路:用一张 unordered_map<int,int> 存“某个值所在连续区间的长度”,每来一个新值 v,去查 v-1 和 v+1 所在区间的长度,把左右两段和自己合并成一段,只更新新区间两个端点的长度记录(中间的值以后不会再作为端点被查到,不必维护)。它同样是 O(n),思路是“并查集式的区间合并”。两种解法我更推荐第一种,因为它更短、更难写错;但第二种的价值在于它揭示了这道题的另一副面孔——它是个动态区间合并问题。

这一节的收获跟具体代码关系不大,是一条判断纪律:动手设计 DP 状态之前,先花十秒验一遍无后效性——“我这个状态,能不能只看有限个已算好的相邻状态就推出自己”。 验不过,就不是 DP,别硬套。这道题的“连续”长在数值维度上、而你的数组是按下标排的,两个维度对不齐,DP 的地基就打不下去。识别出“这题要的是成员查询不是最优子结构”,比你把 DP 写得多熟都值钱。作为架构师你太熟悉这种时刻了:选错了数据结构或抽象,后面再多的努力都是在错误的地基上盖楼。

下一道题反过来——它是如假包换的 DP,而且我们会看到,同一道题,把状态定义换一个维度,复杂度能从平方级掉到 O(n log n)。这是“状态定义”这门手艺最见功力的一手。

最长递增子序列,把状态从位置换到长度

题目(对应 LeetCode 300):给一个整数数组,找出其中最长的严格递增子序列的长度。子序列仍然允许不连续。例如 [10, 9, 2, 5, 3, 7, 101, 18],最长递增子序列是 [2, 3, 7, 101](或 [2,3,7,18][2,5,7,18] 等),长度 4。

我先把两个结果摆给你,再回头拆实现,因为这道题的看点不在“能不能做出来”,而在“同一道题,两套状态定义,复杂度差一个数量级”:

  • 一套状态定义给你 O(n²),直白、好想、能顺手还原出那条子序列。

  • 换一个维度重新定义状态,给你 O(n log n)——但它长得不像 DP 了,更像一种带二分的贪心。

第一套:状态按“位置”索引。 定义 dp[i] = “以第 i 个元素结尾的最长递增子序列长度”。为什么必须带“以第 i 个结尾”这个约束?跟前面子串那道题同一个道理——不带它,你就没法保证下一个元素能接在后面(你得知道当前这条子序列的末尾值是多少,而“末尾就是 arr[i]”这个信息正是靠这个约束锁住的)。转移是:往前看所有比 arr[i] 小的元素 arr[j],在它们的 dp[j] 里挑最大的接上:

C++

int lisN2(vector<int>& a) {
    int n = a.size();
    vector<int> dp(n, 1);          // 每个元素自成一条长度为 1 的子序列
    int best = 1;
    for (int i = 1; i < n; ++i) {
        for (int j = 0; j < i; ++j)
            if (a[j] < a[i])       // 严格递增;要"非降"就改成 <=
                dp[i] = max(dp[i], dp[j] + 1);
        best = max(best, dp[i]);
    }
    return best;
}

注意全局答案 best 要扫整张 dp 表取最大值,末尾那个 dp[n-1] 未必最大——因为最长的那条未必以最后一个元素结尾。这跟子串那道题“答案要边填边记 max、不能只看右下角”是同一个注意事项,都是“以此结尾”型状态的通病。时间 O(n²),空间 O(n)。

还原那条子序列,再开一个 from 数组记录每个位置是从谁转移来的,从 best 所在的位置回溯即可:

C++

vector<int> lisSequence(vector<int>& a) {
    int n = a.size();
    vector<int> dp(n, 1), from(n, -1);
    int best = 1, endIdx = 0;
    for (int i = 0; i < n; ++i) {
        for (int j = 0; j < i; ++j)
            if (a[j] < a[i] && dp[j] + 1 > dp[i]) { dp[i] = dp[j] + 1; from[i] = j; }
        if (dp[i] > best) { best = dp[i]; endIdx = i; }
    }
    vector<int> res;
    for (int i = endIdx; i != -1; i = from[i]) res.push_back(a[i]);
    reverse(res.begin(), res.end());
    return res;
}

O(n²) 在 n 是几千的规模内够用了。但当 n 上到十万、百万——比如你在做一个时序数据里的最长上升段分析——平方级就顶不住了。这时候得换脑子。

第二套:状态按“长度”索引。 这是这道题真正值得学的一手,也是“状态定义”这门手艺最反直觉的一次施展。我们不再问“以第 i 个元素结尾能多长”,而是反过来定义一个数组 tails,让 tails[k] = “所有长度为 k+1 的递增子序列里,结尾元素能取到的最小值”。

为什么盯着“最小的结尾”?因为一条递增子序列的结尾越小,它后面能接的元素就越多、越有潜力长得更长。这是一个贪心直觉:在长度相同的所有子序列里,我永远只关心结尾最小的那条,其余的都没有它有前途,可以扔。

关键性质:tails 数组一定是严格递增的(长度更长的子序列,其最小结尾一定比长度更短的那个大,可以用反证法说服自己)。既然 tails 有序,来一个新元素 x 时,我们就能用二分查找 O(log n) 地决定它该干什么:

  • 在 tails 里找第一个 大于等于 x 的位置。这一步正好是 std::lower_bound 的语义——它的官方文档写明,返回的是有序区间里第一个不小于给定值的位置,用在这里一行搞定,还免了自己写二分的边界地狱。

  • 如果找到了,说明存在某个长度的子序列,其最小结尾 ≥ x,那用 x 替换它——x 更小,更有前途,长度不变但“潜力”变强了。

  • 如果没找到(x 比 tails 里所有元素都大),说明 x 能接在最长的那条后面,tails 追加一位,最长长度 +1。

tails 的长度,就是答案。

C++

int lisNLogN(vector<int>& a) {
    vector<int> tails;                       // tails[k]: 长度 k+1 的递增子序列的最小结尾
    for (int x : a) {
        auto it = lower_bound(tails.begin(), tails.end(), x); // 第一个 >= x 的位置
        if (it == tails.end()) tails.push_back(x); // x 最大,接到最长序列后面
        else *it = x;                              // 用更小的 x 替换,压低这个长度的结尾
    }
    return tails.size();
}

这段代码只有几行,但它里面发生的事情很微妙,值得你盯着一个例子走一遍。拿 [10,9,2,5,3,7,101,18]

text

读入   tails            动作
 10    [10]             长度 1
  9    [9]              9 替换,压低结尾
  2    [2]              再压到 2
  5    [2,5]            追加,长度 2
  3    [2,3]            3 替换掉 5
  7    [2,3,7]          追加,长度 3
101    [2,3,7,101]      追加,长度 4
 18    [2,3,7,18]       18 替换 101,长度不变
最终 tails 长度 = 4,即答案

这里有个必须讲清楚、否则你会误用它的点:tails 数组本身不是那条最长递增子序列。 上例最后 tails = [2,3,7,18] 恰好是一条合法的 LIS,但这只是巧合;一般情况下 tails 里的元素来自数组的不同位置、甚至违反原始先后顺序,它只是一个“各长度的最小结尾”的账本,长度对,内容不一定对。如果你要还原真正的子序列,得额外记录每个元素被放进 tails 时所在的下标层级,再配一个前驱数组回溯——能做,但代码会长不少,面试里点到为止、生产里按需再加。

具体怎么还原,我把机理讲清楚,代码留给你自己补:每次把 x 放进 tails 的第 k 个位置时,同时记下“x 这个元素被指派到了长度 k+1”(存进一个 posInTails 数组),并记下“当 x 放到位置 k 时,tails[k-1] 当前对应的是哪个元素”作为它的前驱。全部扫完后,找到被指派到最大长度的那个元素,顺着前驱链往回走,就串出了一条真正的最长递增子序列。这套记账的额外空间是 O(n),不改变 O(n log n) 的时间。

顺便给这个解法一个更好记的心智模型:它其实就是**耐心排序(patience sorting)**那套发牌规则。想象你在玩接龙——每张牌尽量压到“牌堆顶比它大”的最左边那一堆上,压不上就新开一堆,最后堆的数量就是最长递增子序列的长度。tails[k] 就是第 k 堆的堆顶,lower_bound 就是“找最左边能压的那一堆”。把抽象的二分贪心翻译成一副扑克,你就再也不会忘了它为什么对。

这道题最该被你带走的,是“状态可以换维度”这件事本身。 同一个问题,第一套状态沿“数组位置”这根轴展开,转移要回望所有更小的前驱,天然 O(n²);第二套把状态的索引轴从“位置”换成了“子序列长度”,并往每个格子里塞了一个更精炼的量(该长度的最小结尾),于是转移退化成一次二分,复杂度掉到 O(n log n)。换掉的其实是状态定义的坐标系,算法本身一行没动。 当你觉得一道 DP 慢得不可接受时,先别急着优化转移的常数,回头盯着状态定义问一句:“我是不是选错了展开的维度?换一根轴,会不会让转移变简单?”这是从“会做 DP”迈向“设计 DP”的那一步,也是我见过区分资深和顶尖最明显的一道分水岭。

最后一道题,我们把维度这件事推到极致:一个二维的问题,怎么被“降维”成反复求解一个一维的子问题。

子矩阵最大累加和:把二维问题投影成反复解一维

题目:给一个可以含负数的 m × n 矩阵,找出其中一块矩形子矩阵,使它内部所有元素之和最大,返回这个最大和。含负数是关键,否则整个矩阵就是答案、题目不成立。

这道题放在最后,因为它是本文主线的收束点:前面所有的空间压缩都是“把高维的表压成低维的表”,而这道题要做的是一次更彻底的降维——把一个二维问题,投影成一个一维问题的反复求解。要讲清楚它,得先有那把一维的尺子。

那把尺子叫 Kadane 算法,解的是它的一维版本:子数组最大累加和(对应 LeetCode 53)——一个一维数组里,求和最大的那段连续子数组。《算法导论》在讲最大子数组问题时,先给了一个 O(n log n) 的分治解,又在习题里写道存在一个 O(n) 的线性扫法,那个线性扫法就是 Kadane。它本身就是一道极简的 DP,简单到状态只需要一个变量,正好用来复习三步流程:

C++

int maxSubArray(const vector<int>& a) {
    int cur = a[0], best = a[0];
    for (int i = 1; i < (int)a.size(); ++i) {
        cur = max(a[i], cur + a[i]); // 以 i 结尾的最大子数组和
        best = max(best, cur);
    }
    return best;
}

它的状态是“以第 i 个元素结尾的最大子数组和”,记作 cur。转移只有两种可能:要么把前面那段(cur)接上当前元素,要么前面那段是负的、拖后腿,干脆从当前元素重新开一段——max(a[i], cur + a[i]) 就是这个二选一。因为状态只依赖前一个状态,空间直接是 O(1)。这是“依赖宽度决定压缩维度”那条规律的极端情形:依赖宽度为 1,压到常数。

有了这把一维的尺子,二维怎么办?先想清楚一件事:一个矩形子矩阵,由四条边确定——上边界行、下边界行、左边界列、右边界列。四个自由度,暴力枚举是 O(m²n²) 定位子矩阵,再花时间求和,总共到 O(m³n³) 那个量级,没法看。

降维的思路是:把“上下边界”这两个自由度用两层循环枚举死,一旦上下边界固定,剩下的问题就塌回成一维。 具体地,固定上边界行 top 和下边界行 bottom 之后,把这两行之间(含)每一列的元素在竖直方向上加起来,得到一个长度为 n 的一维数组——第 c 个元素是“第 c 列在 top..bottom 这个行区间内的列和”。这一步之后,“在原矩阵里找一块上下边界为 top/bottom 的最大子矩阵”就等价于“在这个一维列和数组里,找一段最大连续子数组”——而这恰好是 Kadane 能一遍 O(n) 解掉的问题。

text

固定 top=1, bottom=2,把这两行按列压成一维:

原矩阵           压成一维列和
 . . . .
[a b c d]  ┐
[e f g h]  ┘ →  [a+e, b+f, c+g, d+h]
 . . . .
        对这个一维列和数组跑 Kadane 求最大段

枚举上下边界有 O(m²) 种组合,每种组合里跑一遍 Kadane 是 O(n),所以总复杂度 O(m²·n)。剩下唯一要优化的是“求列和”这一步——如果每换一次上下边界都重新累加一遍列,会多一个 n 的因子。技巧是:固定 top 之后,让 bottom 从 top 往下逐行推进,维护一个“列和累加数组” colSumbottom 每下移一行,就把新那一行加进 colSum,增量维护,不重算。

C++

int maxSubMatrix(const vector<vector<int>>& mat) {
    int m = mat.size(), n = mat[0].size();
    int best = INT_MIN;
    for (int top = 0; top < m; ++top) {
        vector<int> colSum(n, 0);              // 列和数组,随 bottom 下移增量累加
        for (int bottom = top; bottom < m; ++bottom) {
            for (int c = 0; c < n; ++c)
                colSum[c] += mat[bottom][c];   // 把新的一行并进列和,O(n)
            // 此刻问题塌成一维:在 colSum 上求最大连续子数组和
            int cur = colSum[0], localBest = colSum[0];
            for (int c = 1; c < n; ++c) {
                cur = max(colSum[c], cur + colSum[c]);
                localBest = max(localBest, cur);
            }
            best = max(best, localBest);
        }
    }
    return best;
}

三层循环,top 和 bottom 枚举上下边界共 O(m²),最内层的列和维护与 Kadane 各 O(n),合起来 O(m²·n),空间只用了一个 O(n) 的 colSum。如果矩阵行多列少,把行列角色对调、枚举左右边界,可以做到 O(n²·m)——永远拿短的那个维度去做那个平方,是个顺手的小优化。

有人会问:为什么不用二维前缀和?确实可以——预处理一张前缀和表,任意子矩阵的和都能 O(1) 算出,但那样枚举四条边界仍是 O(m²n²),比这个 O(m²n) 的解法慢一个量级。原因就在于我们这里没有去“枚举左右边界再求和”,而是把左右边界这两个自由度交给了 Kadane:Kadane 在一维列和数组上一趟扫过去,隐式地试遍了所有左右边界,还只花 O(n)。前缀和优化的是“给定矩形快速求和”,Kadane 优化的是“在所有左右边界里挑最优”——后者才是这道题的瓶颈所在。这也是我反复强调“先想清楚瓶颈在哪一维”的原因:优化错了维度,前缀和这种好工具照样帮倒忙。

再补一句边界情形,这是实战里真会咬人的地方:矩阵全是负数时,最大子矩阵就是那个绝对值最小的单元素(比如全 -1 的矩阵,答案是 -1)。上面的 Kadane 用 max(colSum[c], cur + colSum[c]) 且初值取第一个元素,天然处理了这种情况;但如果你手滑把 Kadane 的初值写成 0、或允许“空子矩阵”,就会在全负输入上错误地返回 0。极值型问题永远要单独想一遍“全负 / 全零 / 单元素”这几个退化输入,它们是这类题最常见的翻车点。

这段代码本身不重要,我想让你记住的是它体现的那个降维范式:当一个问题的维度太多、直接 DP 状态爆炸时,一个极其常用的招法是“枚举掉其中几个维度,把问题打到一个你已经会高效解的低维子问题上”。这里我们枚举掉上下两条边界,把二维矩形和问题打回到一维的 Kadane。这个套路在算法题里反复出现,在工程里也一样——你在做多维度的数据聚合、OLAP 的 cube 计算、或者给一个高维的搜索空间做剪枝时,“固定几个维度、在剩下的维度上跑一个已知的快算法”是同一个思想。会一维 Kadane 的人很多,能看出“二维最大子矩阵不过是被枚举了上下边界的一维 Kadane”的人,才算真的理解了降维。

到这里,六组题走完了。回头看,它们从没离开过开篇立的那三件事——每一道题都是“定义一个无后效性的状态、写出它的转移来源、再按依赖宽度把空间压掉”,只是每道题在某一步上刁难了你一下:矩阵路径刁难压缩方向,换钱刁难聚合算子与循环方向,LCS 刁难状态含义的一词之差,连续序列刁难“到底该不该用 DP”,LIS 刁难状态的维度选择,子矩阵刁难降维。手艺是同一套,题目只是它的不同投影。

把这六道题的三件套摆成一张表,你能一眼看出那条贯穿始终的主线——每一列都是同一个动作在不同问题上的不同长相:

问题

状态定义

转移的关键

空间压到

矩阵最小路径和

走到 (i,j) 的最小路径和

min(上, 左)

O(n) 一行,靠更新方向

换钱方法数 / 最少数

前 i 种币凑金额 j 的答案

聚合算子(加法 / min)+ 循环方向

O(aim) 一维

最长公共子序列

前 i、前 j 的 LCS 长度

相等取左上+1,否则取邻居 max

O(n) 一行 + 一个 leftUp

最长公共子串

以 i、j 同时结尾的公共长度

相等取左上+1,否则归零

O(1),沿对角线走

最长连续序列

不是 DP(哈希成员查询)

只从每段起点数一遍

O(n) 哈希集合

最长递增子序列

各长度的最小结尾(换维度)

lower_bound 定位替换点

O(n),时间降到 O(n log n)

子矩阵最大累加和

固定上下边界后的一维列和

枚举上下边界 + Kadane

O(n) 列和数组

这张表里,“最长连续序列”那一行是故意留的一个刺——它长在“最长 X”的题目里,却在“状态定义”这一列直接出局。能在动手前就把它从 DP 家族里认出来,比把其余六行的代码背得多熟都重要。

把这套手艺搬到下一道题上

文章的价值得能落到你下一次真遇到 DP 题的时候,所以我把这套手艺压成一条可以照着走的检验流程。下次拿到一道疑似 DP 的题,别急着写 dp[],先按这个顺序盘问它一遍。

先问它配不配用 DP。 找出问题里的“决策序列”——凑金额是一步步选币,走网格是一步步选方向,构造子序列是一个个字符决定要不要。然后问无后效性那一句:我能不能找到一个量,让它概括“走到这一步的历史”,使得接下来的决策只依赖这个量、不依赖我具体怎么走到这里的?找得到,是 DP;找不到,回头看它是不是别的结构——最长连续序列那道题就在这一关被刷下来,它要的是成员查询,不是最优子结构。这一步花十秒,能帮你省下在错误地基上盖楼的几小时。

再逼自己用一句人话定义状态。 说不出 dp[i] 或 dp[i][j] 到底是什么量,就还没资格写代码。定义里如果需要“以第 i 个结尾”“只用前 i 种”“同时以 i、j 结尾”这类约束才能自洽,就老老实实带上——LCS 的子串版和 LIS 都是靠这个约束才锁住无后效性的。检验标准就一条:站在这个状态的格子上,只看有限个相邻格子,能不能算出自己。算不出,是状态漏了信息,升维或改定义。

然后对“最后一步”做分类讨论,写出转移。 我这个最优解的最后一个决策有几种可能?每种可能对应哪个更小的、已算好的子问题?把它们用一个聚合算子合起来——求极值用 min/max,求计数用加法,求存在性用或。换钱两道题告诉过你:状态骨架相同时,真正决定你写哪套代码的,就是这个聚合算子。

填表前把边界和顺序定死。 最小的子问题(空串、金额零、第一行第一列)没有更小的可依赖,手工填,而且要填对——“凑金额 0 有 1 种方法”这种边界填错,会静默地毁掉整张表。顺序要保证算每个格子时它依赖的都已就绪。

最后才谈压缩,而且要带着那笔账谈。 盯住转移方程的依赖宽度:只依赖上一行和本行左侧,压到一维;只依赖唯一的左上一格,沿对角线压到常数;依赖宽度就是你能压到的维度的倒数。但压之前先回答一个工程问题——这份计算的中间留痕,将来有没有人要回来查? 要做审计、要支持增量重算、要还原出那条具体的子序列或路径,就把二维表留着,它是一份免费的决策日志;纯粹算个数值、算完就扔,才放心压到极致。我用三天的线上排查换来这条规矩,你可以直接拿去用:面试里压到 O(1) 是本事,生产里为省几兆内存烧掉可观测性,是给未来的自己挖坑。

这五步走下来,你会发现自己不再是“背过这道题”或“没背过这道题”的二元状态,而是握着一把能拆解任意 DP 题的螺丝刀。经典题会一直有新的变种冒出来,但状态、转移、压缩这三颗螺丝,拧的永远是同样的方向。回到开头那个在白板前卡住的候选人——他缺的从来跟刷题量无关:没人告诉过他,他每天在系统里定义的那些不变量和状态机,和 dp[i][j] 是同一门手艺。现在这话有人告诉你了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

古法编程大师

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值