动态规划这四个字,在C++学习者的必经之路上几乎是绕不过去的坎。我第一次刷题时看到“动态规划”还以为是什么高深莫测的数学理论,结果翻开代码发现不过是一堆循环加数组,可偏偏自己写的时候就是怎么都想不出转移方程。后来刷了上百道题,调了无数遍数组越界和初始化错误之后,才算真正摸透了这东西的脾性。这篇东西我不想写成教科书式的定义堆砌,只想从一个踩过坑的人的视角,带你把动态规划拆开看清楚,知道它到底在“规划”什么,C++写它的时候又有哪些隐藏注意点。
这篇文章适合这几类人看:刚开始刷题但面对dp题目不知道从哪里入手的初学者;已经把模板题背下来了,但换一道变体就不会做的人;以及想系统整理一遍DP思想、顺便补齐C++实现细节的进阶学习者。全文会围绕“状态、转移、边界”这三个核心词来展开,用C++代码一步步展示实现过程,再把我实际写代码时遇到的坑全部抖出来,保证你看完不是只会背模板,而是真的能自己设计解法。
1. 动态规划到底在“规划”什么
1.1 从递归到递推:一个省略重复计算的思路
先讲一个最简单的例子。斐波那契数列,很多人第一次接触递归就是它:
long long fib(int n) { if (n <= 1) return n; return fib(n - 1) + fib(n - 2); }代码很简洁,但你真去跑一下 n = 50 就知道什么叫慢。原因在于这个递归会重复计算大量子问题:fib(5) 会去算 fib(4) 和 fib(3),而 fib(4) 又会去算 fib(3) 和 fib(2),同一个 fib(3) 被算了不知道多少遍。我实测过,纯递归算 fib(45) 在我的机器上已经要好几秒了,n 再大一点直接卡死。
动态规划的思路就一句话:既然子问题会重复,那我把它算一次存起来,下次直接用。这就是所谓的“记忆化”。说白了,动态规划就是“有记忆的递归”,只不过大多数时候我们喜欢用循环从前往后推,也就是递推。这个思想转变看着不起眼,却是整座dp大厦的地基。
1.2 状态、转移、边界:动态规划的三大支柱
我刷了这么多题,总结下来任何动态规划题目只要抓住三个东西就能解出来:
第一是状态,也就是dp数组的每个下标代表什么。比如斐波那契里 dp[i] 表示第 i 个斐波那契数。这个设计是整个解法的灵魂,状态设不好,后面全白搭。
第二是转移方程,用数学式子描述状态之间怎么跳转。斐波那契的转移就是 dp[i] = dp[i-1] + dp[i-2],它描述的是“当前状态由哪些更小的状态推导而来”。
第三是边界条件,也就是递推的起点,解决“从哪个状态开始推”的问题。斐波那契的边界是 dp[0] = 0,dp[1] = 1。
用生活类比的话,状态是地图上的地点,转移方程是连接地点的路,边界是你出发的位置。动态规划解题就是设计一条合理的路线图,从起点出发,沿着路把所有地点都走到。很多新手一上来直接背模板,看到题目首先想“这是背包还是区间dp”,结果背了一堆模板还是不会做。真正的做法应该是先想清楚这三个要素,再套模板,而不是反过来。
2. 状态设计:把题目翻译成dp数组
2.1 一维状态:经典题目怎么定dp[i]
先说最容易上手的一维DP,拿爬楼梯问题举例:你一次可以爬1级或2级台阶,爬到第 n 级一共有多少种方法?
思路很简单,你想爬到第 i 级台阶,最后一步可能是从第 i-1 级跨上来的,也可能是从第 i-2 级跨上来的。所以到达第 i 级的方法数,就是到达第 i-1 级的方法数加上到达第 i-2 级的方法数。于是状态和转移方程就出来了:
#include <iostream> #include <vector> using namespace std; int climbStairs(int n) { if (n <= 2) return n; vector<int> dp(n + 1, 0); dp[1] = 1; dp[2] = 2; for (int i = 3; i <= n; ++i) { dp[i] = dp[i - 1] + dp[i - 2]; } return dp[n]; } int main() { cout << climbStairs(10) << endl; // 输出89 return 0; }这个例子的核心是:dp[i] 的定义想清楚了,转移方程就是顺理成章的事情。状态设计最忌讳的是“为了凑一个dp数组而凑”,你必须问自己一个问题:这个状态能不能递推出来?如果 dp[i] 依赖的所有子状态都能被明确定义并且能用同样的方式推导,那这个设计就是成立的。
2.2 二维状态:背包问题里的状态拆解
一维状态只是开胃菜,真正让很多人头皮发麻的是二维dp。以01背包为例:有 n 个物品,每个物品有重量 w[i] 和价值 v[i],背包容量为 W,问能装下的最大价值是多少。
这里的关键在于,你光用一维状态没法同时表达“考虑到了哪几个物品”和“用了多少容量”这两件事。所以状态设计成二维:dp[i][j] 表示前 i 个物品中挑选,放入容量为 j 的背包能获得的最大价值。
转移方程分成两种情况考虑:
- 不拿第 i 个物品:dp[i][j] = dp[i-1][j]
- 拿第 i 个物品:dp[i][j] = dp[i-1][j-w[i]] + v[i],前提是 j >= w[i]
两者取最大值:
int knapsack(int W, vector<int>& weights, vector<int>& values, int n) { vector<vector<int>> dp(n + 1, vector<int>(W + 1, 0)); for (int i = 1; i <= n; ++i) { for (int j = 1; j <= W; ++j) { dp[i][j] = dp[i - 1][j]; if (j >= weights[i - 1]) { dp[i][j] = max(dp[i][j], dp[i - 1][j - weights[i - 1]] + values[i - 1]); } } } return dp[n][W]; }为什么状态要带“前 i 个物品”这个维度?因为它隐含了一个决策顺序:我从第一件物品开始逐个考虑,每个物品只有拿或不拿两种可能。这个“逐个决策”的思路是二维DP最常见的构建方式,很多看起来复杂的题目,本质上都是在做“对一系列元素逐个做选择”的过程。
3. C++实现动态规划的实际细节
3.1 数组开辟与初始化:vector的坑一定要踩明白
C++写动态规划和Python写完全是两种体验。Python里列表想怎么加就怎么加,但C++里你必须在动手前就把数组开好、初始化好。这一块是新手翻车重灾区。
最稳妥的做法是用vector而不是裸数组。vector不仅自动管理内存,还提供了统一的初始化方式:
vector<int> dp(n + 1, 0); // 一维数组,全部初始化为0 vector<vector<int>> dp2(m + 1, vector<int>(n + 1, 0)); // 二维数组,m+1行n+1列这里有个细节我吃了不少亏:初始化二维vector时,内层vector必须显式写出来,不能图省事写成 vector<vector > dp2(m + 1, vector (n + 1, 0)) 少一个参数,或者用错成 vector<vector > dp2(m + 1, n + 1),后者编译直接就不过。还有一点,二维vector的每一行都是独立的vector对象,所以 dp2.size() 是行数,dp2[0].size() 是列数,别搞混了。
有些C语言转过来的朋友喜欢用 memset 来初始化数组,这也要小心。memset 是按字节填充的,对 int 数组来说只有填 0 或 -1 才是你想要的效果。你要是 memset(dp, 1, sizeof(dp)),数组里每个元素会变成 0x01010101,也就是 16843009,和你预期的“全部置为1”差了十万八千里。所以建议统一用 vector + fill 或直接初始化,别折腾裸数组了。
3.2 遍历顺序为何关键:从01背包到完全背包的对比
这是动态规划里最让我觉得“差一行代码就天差地别”的地方。同样是背包问题,01背包要求每个物品只能拿一次,而完全背包允许每个物品无限拿,它们的核心区别就体现在内层循环的遍历顺序上。
01背包的二维写法里,内层对容量 j 的遍历是正序还是倒序其实都行,因为 dp[i][j] 依赖的是 dp[i-1][...] 这一行,用的是上一行的数据。但一旦用滚动数组把空间压成一维,内层遍历必须倒序:
vector<int> dp(W + 1, 0); for (int i = 1; i <= n; ++i) { for (int j = W; j >= weights[i - 1]; --j) { dp[j] = max(dp[j], dp[j - weights[i - 1]] + values[i - 1]); } }为什么必须倒序?因为正序的话,dp[j - w] 可能已经在当前这一轮被更新过了,也就是物品 i 被拿了不止一次,这正好把01背包变成了完全背包。一个循环方向的改变就能造成完全不同的语义,这种事情只有C++这种直接操作内存的语言里表现得如此明显,也是我强烈建议你用一维写法去手推一遍数据流的原因。自己拿笔在纸上走一遍 dp 数组的变化过程,比看十篇博客都管用。
3.3 空间优化:滚动数组背后的原理
刚才提到的滚动数组,是动态规划空间优化的核心手段。它的思想是:如果转移方程只依赖上一轮的状态,那我只需要保留两行(甚至一行)数据,而不需要把整个二维表格都存下来。
以斐波那契为例,你其实不需要一个长度为 n 的数组,只需要两个变量滚动:
int fib(int n) { if (n <= 1) return n; int prev = 0, cur = 1; for (int i = 2; i <= n; ++i) { int next = prev + cur; prev = cur; cur = next; } return cur; }空间从 O(n) 降到了 O(1)。在背包问题里,从二维dp数组压到一维滚动数组,空间从 O(n * W) 降到 O(W),当 W 是十万级别的时候,这个优化就是能不能过题的关键。
不过我得提醒一句:滚动数组省的是空间,牺牲的是“过程记录”。有些题目不仅要求最优值,还要你还原出具体方案(比如哪些物品被选了),这时候全量二维dp反而更好用,因为你能从结果往前回溯,一步步找回状态转移的路径。所以做优化之前先想清楚题目到底要什么,别盲目压维。
4. 几类经典转移方程与常用套路
4.1 线性DP:最长递增子序列(LIS)
线性DP是刷题碰得最多的一类,它的问题模型是:给定一个序列,要求满足某种条件的子序列的最长/最短/最大数值。最典型的莫过于最长递增子序列,dp[i] 表示以第 i 个元素结尾的最长递增子序列长度:
int lengthOfLIS(vector<int>& nums) { int n = nums.size(); if (n == 0) return 0; vector<int> dp(n, 1); // 每个元素自身也是一个长度为1的子序列 int ans = 1; for (int i = 1; i < n; ++i) { for (int j = 0; j < i; ++j) { if (nums[j] < nums[i]) { dp[i] = max(dp[i], dp[j] + 1); } } ans = max(ans, dp[i]); } return ans; }转移方程的含义是:以 nums[i] 结尾的递增子序列,可以由前面任何一个“值比 nums[i] 小”的位置 j 的答案加一得到。为什么初始都设置为1?因为每个元素至少可以单独构成一个长度为1的递增子序列,这个初始化的道理很多人容易忽略。
这个代码的时间复杂度是 O(n^2),在 n 是十万级别时会超时。进阶优化方案是用贪心 + 二分查找把复杂度降到 O(n log n),但那个思路完全是另一套逻辑了,学有余力的人再去看。
4.2 区间DP:最长回文子序列
区间DP和线性DP最直观的区别是,它的状态表示的是一个“区间”内的最优解,dp[i][j] 通常表示下标从 i 到 j 这个闭区间内的答案。以最长回文子序列为例,给你一个字符串 s,找出其中最长的回文子序列长度。
思路是这样:如果 s[i] == s[j],那么 dp[i][j] = dp[i+1][j-1] + 2,因为首尾两个字符可以同时加入回文子序列;如果不相等,就取 dp[i+1][j] 和 dp[i][j-1] 的较大值,相当于丢掉左边或右边的字符再比较。
int longestPalindromeSubseq(string s) { int n = s.size(); vector<vector<int>> dp(n, vector<int>(n, 0)); for (int i = 0; i < n; ++i) dp[i][i] = 1; // 单字符一定是回文 for (int len = 2; len <= n; ++len) { for (int i = 0; i + len - 1 < n; ++i) { int j = i + len - 1; if (s[i] == s[j]) { dp[i][j] = dp[i + 1][j - 1] + 2; } else { dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]); } } } return dp[0][n - 1]; }区间DP的外层循环一定是在枚举区间长度,这一点非常反直觉。新手很容易先写 for i 再写 for j,然后发现 dp[i+1][j-1] 的值还没算出来。因为 dp[i][j] 依赖的是更短的区间,只有从短区间往长区间推,才能保证计算当前区间时,所有依赖的子区间都已经有了答案。这个“按长度而不是按下标来遍历”的顺序,是区间DP最大的坑。
4.3 记忆化搜索:递归版的动态规划
写区间DP这类题目的时候,你可能会觉得按长度枚举太绕,有一种更符合直觉的写法叫记忆化搜索,本质就是带备忘录的递归:
int solve(vector<vector<int>>& memo, string& s, int i, int j) { if (i > j) return 0; if (i == j) return 1; if (memo[i][j] != -1) return memo[i][j]; if (s[i] == s[j]) { memo[i][j] = solve(memo, s, i + 1, j - 1) + 2; } else { memo[i][j] = max(solve(memo, s, i + 1, j), solve(memo, s, i, j - 1)); } return memo[i][j]; }记忆化搜索和递推DP是同一个思想的两副面孔,递归是先想“我需要什么子问题的答案”再从顶往下钻,递推是先算好所有子问题再从底往上推。用哪种完全看个人习惯。不过C++写递归有个隐患是栈溢出,状态数量特别大的时候建议还是用递推,递归深度超过十万次基本就要出问题,我自己实测过深度超过百万时的程序直接崩溃到没有报错信息。
5. 常见错误与Debug技巧实录
5.1 初始化与边界检查的坑
动态规划报错的时候,编译器不会告诉你逻辑错在哪,你只能通过输出dp数组来排查。我总结了几个高频翻车点:
- 数组大小开错。dp[n+1] 写成 dp[n] 是最常见的错误,循环里用到 dp[n] 直接越界。养成习惯:如果状态需要从0到n,数组长度就是n+1。
- 边界条件设置错误。比如爬楼梯题边界应该是 dp[1]=1、dp[2]=2,有人写成 dp[0]=1、dp[1]=1,虽然结果碰巧一样,但换个题目就不成立了。
- 忘记处理j < w[i]的情况。背包问题里如果容量小于当前物品重量必须跳过,否则下标会变成负数,这种错误不会立即报错而是会返回一个垃圾值,非常难查。
5.2 大数溢出问题
动态规划里的数值往往会快速增长,斐波那契数列的第50项已经超过 int 的表达范围了。在LeetCode这类平台上,很多dp题的答案要求对 10^9+7 取模,这时候就要注意每一步计算后都取模,不能等最后再取,否则中间就溢出了。
const int MOD = 1e9 + 7; for (int i = 2; i <= n; ++i) { dp[i] = (dp[i - 1] + dp[i - 2]) % MOD; }如果你不确定答案数量级,直接用 long long 是更稳妥的做法。C++的 int 只有32位,最大值约21亿,而动态规划涉及的组合数分分钟就超过它。因为写习惯了Python不用担心整数溢出,转回C++就特别容易在这上面栽跟头。
5.3 调试利器:打印dp数组
我在调dp题时最常用的方法不是上调试器设断点,而是直接打印dp数组,观察它是不是符合自己的直觉预期。比如写背包问题时,我会打印整个二维表,看到一个格子不对就顺着往上查它的依赖。
这里有个实用的小技巧:写一个 debug 函数专门输出dp数组,定位完问题后再删掉。用文本文件的输出再配合 diff 工具对比两组数据,排错速度会快很多。
void printDP(vector<vector<int>>& dp) { for (auto& row : dp) { for (int val : row) { cout << val << "\t"; } cout << endl; } }还有一个笨但有效的方法:给自己准备的几组小规模测试数据,手动在纸上算一遍整体过程(纸上推算dp数组),然后用程序跑,逐个核对输出。这个方法虽然慢,但往往能帮你从根子上理解一道题,而不是靠猜。
5.4 常见错误速查表
| 问题类型 | 典型表现 | 排查方向 |
|---|---|---|
| 数组越界 | 程序崩溃或结果随机 | 检查所有dp[i-j]类型的下标是否可能为负 |
| 初始化错误 | 结果整体偏小/偏大 | 重新审视边界状态赋的初值是否合理 |
| 遍历顺序错误 | 结果完全不符合预期 | 检查内层循环正序还是倒序,外层是否枚举长度 |
| 溢出 | 结果为负或明显不正确 | 改用long long或提前取模 |
| 状态定义含糊 | 转移方程写不出来 | 回头重新设计dp[i]的含义,想清楚下标代表什么 |
6. 学动态规划的路线与心得
做了一阵子动态规划之后我发现,这类题目最锻炼的其实不是算法能力,而是“把问题抽象成状态”的能力。刚上手的时候看不懂答案很正常,我刚开始刷题时看大佬的题解,光是理解 dp[i][j] 为什么这样定义就要花一晚上。这东西真的需要时间沉淀,急不来。
我的建议是拿到一道题不要马上看答案,先逼自己把下面三个问题的答案写出来再动键盘:dp数组是什么含义?边界值怎么设定?转移方程用文字怎么表述?这三个问题只要答得上其中两个,即使最终代码写得磕磕绊绊,也说明思路是通的。写不出来就说明根本没理解题目,再看题解也不迟。
具体到C++语言本身,我强烈推荐用 vector 而不是原生数组,因为vector可以动态分配大小,调试时也容易在IDE里查看内容。编译的时候打开 -Wall 和 -fsanitize=address 这两个编译选项,前者会把一些可疑的代码警告打出来,后者能在程序越界时立刻报错而不是返回一个随机值。这两样东西能让你的debug效率高一大截,千万别省。
最后再分享一个我个人的习惯:学动态规划的时候一定要把“最优子结构”和“重叠子问题”这两个特征内化成直觉,而不是当成空洞的概念。碰到一个题先问自己:这个问题能不能拆成子问题?子问题答案能不能被复用?如果答案都是肯定,那十有八九就是动态规划能解的题。剩下的就是练状态设计的手感了。我自己是把LeetCode上动态规划标签的题目按从易到难刷三遍,第一遍看题解理解,第二遍合上书自己写,第三遍隔两周再来一遍彻底独立完成,这样一轮下来才算真正把常见套路吃透。