
动态规划这四个字在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] 0dp[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; vectorint 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, vectorint weights, vectorint values, int n) { vectorvectorint dp(n 1, vectorint(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不仅自动管理内存还提供了统一的初始化方式vectorint dp(n 1, 0); // 一维数组全部初始化为0 vectorvectorint dp2(m 1, vectorint(n 1, 0)); // 二维数组m1行n1列这里有个细节我吃了不少亏初始化二维vector时内层vector必须显式写出来不能图省事写成 vectorvector dp2(m 1, vector (n 1, 0)) 少一个参数或者用错成 vectorvector 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][...] 这一行用的是上一行的数据。但一旦用滚动数组把空间压成一维内层遍历必须倒序vectorint 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(vectorint nums) { int n nums.size(); if (n 0) return 0; vectorint 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[i1][j-1] 2因为首尾两个字符可以同时加入回文子序列如果不相等就取 dp[i1][j] 和 dp[i][j-1] 的较大值相当于丢掉左边或右边的字符再比较。int longestPalindromeSubseq(string s) { int n s.size(); vectorvectorint dp(n, vectorint(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[i1][j-1] 的值还没算出来。因为 dp[i][j] 依赖的是更短的区间只有从短区间往长区间推才能保证计算当前区间时所有依赖的子区间都已经有了答案。这个“按长度而不是按下标来遍历”的顺序是区间DP最大的坑。4.3 记忆化搜索递归版的动态规划写区间DP这类题目的时候你可能会觉得按长度枚举太绕有一种更符合直觉的写法叫记忆化搜索本质就是带备忘录的递归int solve(vectorvectorint 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[n1] 写成 dp[n] 是最常见的错误循环里用到 dp[n] 直接越界。养成习惯如果状态需要从0到n数组长度就是n1。边界条件设置错误。比如爬楼梯题边界应该是 dp[1]1、dp[2]2有人写成 dp[0]1、dp[1]1虽然结果碰巧一样但换个题目就不成立了。忘记处理j w[i]的情况。背包问题里如果容量小于当前物品重量必须跳过否则下标会变成负数这种错误不会立即报错而是会返回一个垃圾值非常难查。5.2 大数溢出问题动态规划里的数值往往会快速增长斐波那契数列的第50项已经超过 int 的表达范围了。在LeetCode这类平台上很多dp题的答案要求对 10^97 取模这时候就要注意每一步计算后都取模不能等最后再取否则中间就溢出了。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(vectorvectorint 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 和 -fsanitizeaddress 这两个编译选项前者会把一些可疑的代码警告打出来后者能在程序越界时立刻报错而不是返回一个随机值。这两样东西能让你的debug效率高一大截千万别省。最后再分享一个我个人的习惯学动态规划的时候一定要把“最优子结构”和“重叠子问题”这两个特征内化成直觉而不是当成空洞的概念。碰到一个题先问自己这个问题能不能拆成子问题子问题答案能不能被复用如果答案都是肯定那十有八九就是动态规划能解的题。剩下的就是练状态设计的手感了。我自己是把LeetCode上动态规划标签的题目按从易到难刷三遍第一遍看题解理解第二遍合上书自己写第三遍隔两周再来一遍彻底独立完成这样一轮下来才算真正把常见套路吃透。