1. 动态规划算法基础概念
动态规划(Dynamic Programming,简称DP)是算法设计中一种非常重要的思想方法,特别适用于解决具有重叠子问题和最优子结构性质的问题。我第一次接触动态规划是在解决LeetCode上的爬楼梯问题时,当时被它精妙的解题思路所震撼。
动态规划本质上是一种"聪明"的递归。与普通递归不同的是,它会保存已经计算过的子问题的解,避免重复计算。这种思想在计算机科学中被称为"记忆化存储"。举个例子,计算斐波那契数列时,普通递归会有大量重复计算,时间复杂度是O(2^n),而动态规划版本可以优化到O(n)。
动态规划问题通常具有三个核心特征:
- 最优子结构:问题的最优解包含子问题的最优解
- 重叠子问题:在递归求解过程中会反复计算相同的子问题
- 无后效性:当前状态一旦确定,后续决策不受之前决策影响
在C++中实现动态规划通常有两种方式:
- 自顶向下的记忆化搜索(递归+备忘录)
- 自底向上的迭代方法(通常使用数组存储状态)
提示:初学者建议先从自底向上的方法入手,更容易理解和调试,等熟悉后再尝试记忆化搜索方法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 经典动态规划问题解析
2.1 斐波那契数列问题
斐波那契数列是理解动态规划最经典的入门问题。定义F(0)=0,F(1)=1,F(n)=F(n-1)+F(n-2)(n≥2)。
普通递归解法的问题在于会重复计算大量子问题。比如计算F(5)时需要计算F(4)和F(3),而计算F(4)又需要计算F(3)和F(2),这样F(3)就被计算了多次。
动态规划解法(自底向上):
cpp复制int fib(int n) {
if (n < 2) return n;
int dp[n+1];
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n; ++i) {
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
这个解法的时间复杂度是O(n),空间复杂度也是O(n)。实际上可以进一步优化空间复杂度到O(1),因为每次只需要前两个状态:
cpp复制int fib(int n) {
if (n < 2) return n;
int prev = 0, curr = 1;
for (int i = 2; i <= n; ++i) {
int sum = prev + curr;
prev = curr;
curr = sum;
}
return curr;
}
2.2 背包问题
背包问题是动态规划的另一个经典应用。0-1背包问题的描述是:给定一组物品,每种物品都有自己的重量和价值,在限定的总重量内,如何选择物品使得总价值最大。
定义状态dp[i][j]表示前i件物品放入容量为j的背包可以获得的最大价值。状态转移方程为:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]) (j >= w[i])
C++实现:
cpp复制int knapsack(int W, vector<int>& wt, vector<int>& val, int n) {
vector<vector<int>> dp(n+1, vector<int>(W+1, 0));
for (int i = 1; i <= n; ++i) {
for (int w = 1; w <= W; ++w) {
if (wt[i-1] <= w) {
dp[i][w] = max(val[i-1] + dp[i-1][w-wt[i-1]], dp[i-1][w]);
} else {
dp[i][w] = dp[i-1][w];
}
}
}
return dp[n][W];
}
这个解法的时间复杂度是O(nW),空间复杂度也是O(nW)。同样可以优化空间复杂度到O(W):
cpp复制int knapsack(int W, vector<int>& wt, vector<int>& val, int n) {
vector<int> dp(W+1, 0);
for (int i = 0; i < n; ++i) {
for (int w = W; w >= wt[i]; --w) {
dp[w] = max(dp[w], val[i] + dp[w - wt[i]]);
}
}
return dp[W];
}
注意:内层循环必须逆序,这样才能保证每个物品只被选取一次。这是0-1背包问题的关键点。
3. 动态规划解题框架
通过前面的例子,我们可以总结出解决动态规划问题的一般步骤:
- 定义状态:明确dp数组的含义
- 确定状态转移方程:找出如何从已知状态推导出未知状态
- 初始化边界条件:确定最基本、不可再分的子问题的解
- 确定计算顺序:保证在计算当前状态时,所需子状态已经计算完毕
- 考虑优化空间复杂度(可选)
在C++中实现时,还需要注意以下几点:
- 数组大小要合理设置,避免越界
- 初始化要全面,特别是边界条件
- 注意循环的顺序和方向
- 对于大规模问题,考虑使用滚动数组优化空间
4. 动态规划常见问题类型
4.1 线性动态规划
线性动态规划是指状态沿着线性维度(通常是序列)推进的问题。典型例子包括:
- 最长递增子序列(LIS)
- 最大子数组和
- 编辑距离
以最长递增子序列为例,定义dp[i]为以第i个元素结尾的最长递增子序列长度。状态转移方程为:
dp[i] = max(dp[j] + 1) 对所有j < i且nums[j] < nums[i]
C++实现:
cpp复制int lengthOfLIS(vector<int>& nums) {
int n = nums.size();
vector<int> dp(n, 1);
int max_len = 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);
}
}
max_len = max(max_len, dp[i]);
}
return max_len;
}
这个解法时间复杂度是O(n^2),可以通过二分查找优化到O(nlogn)。
4.2 区间动态规划
区间动态规划通常涉及对序列或区间的操作,状态通常表示为dp[i][j],表示区间[i,j]的某种性质。典型例子包括:
- 矩阵链乘法
- 最长回文子串
- 石子合并
以最长回文子串为例,定义dp[i][j]表示子串s[i..j]是否是回文。状态转移方程为:
dp[i][j] = (s[i] == s[j]) && dp[i+1][j-1]
C++实现:
cpp复制string longestPalindrome(string s) {
int n = s.size();
vector<vector<bool>> dp(n, vector<bool>(n, false));
int start = 0, max_len = 1;
// 所有长度为1的子串都是回文
for (int i = 0; i < n; ++i) {
dp[i][i] = true;
}
// 检查长度为2的子串
for (int i = 0; i < n - 1; ++i) {
if (s[i] == s[i+1]) {
dp[i][i+1] = true;
start = i;
max_len = 2;
}
}
// 检查长度大于2的子串
for (int len = 3; len <= n; ++len) {
for (int i = 0; i <= n - len; ++i) {
int j = i + len - 1;
if (s[i] == s[j] && dp[i+1][j-1]) {
dp[i][j] = true;
if (len > max_len) {
start = i;
max_len = len;
}
}
}
}
return s.substr(start, max_len);
}
4.3 树形动态规划
树形动态规划是在树结构上进行的动态规划,通常需要后序遍历来处理子树。典型例子包括:
- 二叉树中的最大路径和
- 打家劫舍III
- 监控二叉树
以二叉树中的最大路径和为例,我们需要考虑路径的几种可能情况:
- 当前节点作为路径的一部分
- 当前节点作为路径的转折点
- 不包含当前节点的路径
C++实现:
cpp复制struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode(int x) : val(x), left(NULL), right(NULL) {}
};
int maxPathSum(TreeNode* root) {
int max_sum = INT_MIN;
helper(root, max_sum);
return max_sum;
}
int helper(TreeNode* node, int& max_sum) {
if (!node) return 0;
int left = max(helper(node->left, max_sum), 0);
int right = max(helper(node->right, max_sum), 0);
// 当前节点作为转折点的情况
int price_newpath = node->val + left + right;
max_sum = max(max_sum, price_newpath);
// 返回当前节点作为路径一部分的最大贡献值
return node->val + max(left, right);
}
5. 动态规划优化技巧
5.1 状态压缩
当状态转移只依赖于有限的几个前驱状态时,可以使用滚动数组或更精细的状态表示来减少空间复杂度。例如在斐波那契数列问题中,我们只需要保存前两个状态。
另一个例子是0-1背包问题的空间优化版本,通过逆序遍历来避免状态覆盖。
5.2 斜率优化
对于形如dp[i] = min(dp[j] + cost(j,i))的状态转移方程,如果cost(j,i)满足某些单调性,可以使用单调队列来优化时间复杂度。
5.3 四边形不等式优化
适用于某些区间DP问题,可以优化决策点的枚举范围,将时间复杂度从O(n^3)降低到O(n^2)。
5.4 矩阵快速幂
对于线性递推式,如斐波那契数列,可以使用矩阵快速幂将时间复杂度优化到O(logn)。
斐波那契数列的矩阵表示:
code复制| F(n) | = | 1 1 | ^ (n-1) | F(1) |
| F(n-1) | | 1 0 | | F(0) |
C++实现:
cpp复制vector<vector<long long>> multiply(vector<vector<long long>>& a, vector<vector<long long>>& b) {
vector<vector<long long>> res(2, vector<long long>(2));
res[0][0] = a[0][0] * b[0][0] + a[0][1] * b[1][0];
res[0][1] = a[0][0] * b[0][1] + a[0][1] * b[1][1];
res[1][0] = a[1][0] * b[0][0] + a[1][1] * b[1][0];
res[1][1] = a[1][0] * b[0][1] + a[1][1] * b[1][1];
return res;
}
vector<vector<long long>> matrixPow(vector<vector<long long>> a, int n) {
vector<vector<long long>> res = {{1, 0}, {0, 1}}; // 单位矩阵
while (n > 0) {
if (n & 1) {
res = multiply(res, a);
}
a = multiply(a, a);
n >>= 1;
}
return res;
}
int fib(int n) {
if (n < 2) return n;
vector<vector<long long>> mat = {{1, 1}, {1, 0}};
vector<vector<long long>> res = matrixPow(mat, n - 1);
return res[0][0];
}
6. 动态规划常见错误与调试技巧
6.1 常见错误类型
- 状态定义不明确:dp数组的含义模糊,导致状态转移方程难以确定
- 边界条件处理不当:特别是数组越界、初始值设置错误
- 循环顺序错误:计算当前状态时依赖的子状态尚未计算
- 空间优化时的状态覆盖:如背包问题中内层循环方向错误
- 整数溢出:特别是涉及大数运算时
6.2 调试技巧
- 打印dp表:这是最直接的调试方法,可以直观地看到状态转移是否正确
- 从小规模测试用例开始:先验证简单情况是否正确
- 添加断言检查:确保状态转移符合预期
- 对比暴力解法:对于小规模输入,可以对比动态规划解和暴力解的结果
- 使用调试器:单步跟踪状态转移过程
例如,调试背包问题时可以这样打印dp表:
cpp复制void printDPTable(const vector<vector<int>>& dp) {
for (const auto& row : dp) {
for (int val : row) {
cout << val << " ";
}
cout << endl;
}
cout << "-----------------" << endl;
}
int knapsack(int W, vector<int>& wt, vector<int>& val, int n) {
vector<vector<int>> dp(n+1, vector<int>(W+1, 0));
for (int i = 1; i <= n; ++i) {
for (int w = 1; w <= W; ++w) {
if (wt[i-1] <= w) {
dp[i][w] = max(val[i-1] + dp[i-1][w-wt[i-1]], dp[i-1][w]);
} else {
dp[i][w] = dp[i-1][w];
}
}
printDPTable(dp); // 打印每一轮后的dp表
}
return dp[n][W];
}
7. 动态规划在竞赛中的应用
在算法竞赛中,动态规划是必考的重点内容。常见的竞赛动态规划题型包括:
- 序列DP:LIS、LCS、编辑距离等
- 背包问题及其变种:分组背包、多重背包、完全背包等
- 状态压缩DP:如旅行商问题(TSP)
- 数位DP:统计满足特定条件的数字个数
- 概率DP:涉及概率和期望的计算
以状态压缩DP解决旅行商问题为例:
cpp复制int tsp(const vector<vector<int>>& dist) {
int n = dist.size();
int VISITED_ALL = (1 << n) - 1;
vector<vector<int>> dp(n, vector<int>(1 << n, INT_MAX));
// 初始化:从0出发到其他城市
for (int i = 1; i < n; ++i) {
dp[i][1 << i] = dist[0][i];
}
// 遍历所有子集
for (int mask = 0; mask < (1 << n); ++mask) {
for (int last = 0; last < n; ++last) {
if (!(mask & (1 << last))) continue;
if (dp[last][mask] == INT_MAX) continue;
// 尝试所有未访问的城市
for (int next = 0; next < n; ++next) {
if (mask & (1 << next)) continue;
int new_mask = mask | (1 << next);
dp[next][new_mask] = min(dp[next][new_mask],
dp[last][mask] + dist[last][next]);
}
}
}
// 计算回到起点的最短路径
int min_cost = INT_MAX;
for (int i = 1; i < n; ++i) {
min_cost = min(min_cost, dp[i][VISITED_ALL] + dist[i][0]);
}
return min_cost;
}
这个解法的时间复杂度是O(n^2 * 2^n),适用于n较小的情况(通常n≤20)。
8. 动态规划在实际工程中的应用
动态规划不仅在算法竞赛中有用,在实际工程中也有广泛应用:
- 文本处理:拼写检查、模糊匹配、diff工具
- 生物信息学:DNA序列比对
- 网络路由:最短路径算法
- 资源调度:任务分配、CPU调度
- 金融领域:期权定价、投资组合优化
以文本差异比较为例,编辑距离算法可以用于实现类似git diff的功能:
cpp复制int minDistance(string word1, string word2) {
int m = word1.size(), n = word2.size();
vector<vector<int>> dp(m+1, vector<int>(n+1, 0));
for (int i = 1; i <= m; ++i) dp[i][0] = i;
for (int j = 1; j <= n; ++j) dp[0][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 {
dp[i][j] = 1 + min({dp[i-1][j], dp[i][j-1], dp[i-1][j-1]});
}
}
}
return dp[m][n];
}
这个算法计算了将word1转换为word2所需的最少操作次数(插入、删除、替换)。在实际的diff工具中,我们还可以回溯dp表来生成具体的操作序列。
9. 动态规划学习路线建议
根据我个人的学习经验,建议按照以下顺序学习动态规划:
-
基础阶段:
- 斐波那契数列
- 爬楼梯问题
- 最小路径和
- 最大子数组和
-
进阶阶段:
- 背包问题系列(01背包、完全背包、多重背包)
- 最长公共子序列
- 最长递增子序列
- 编辑距离
-
高级阶段:
- 区间DP(矩阵链乘法、石子合并)
- 树形DP
- 状态压缩DP
- 数位DP
-
实战阶段:
- 参加编程竞赛(LeetCode周赛、Codeforces等)
- 尝试解决实际工程问题
- 学习优化技巧(斜率优化、四边形不等式等)
学习资源推荐:
- 《算法导论》动态规划章节
- LeetCode动态规划专题
- 《挑战程序设计竞赛》动态规划部分
- 各大高校的算法公开课(如MIT 6.006)
10. 动态规划与其他算法的比较
动态规划常与其他算法思想进行比较:
-
动态规划 vs 分治算法:
- 都涉及将问题分解为子问题
- 动态规划的子问题有重叠,分治的子问题通常独立
- 动态规划通过记忆化避免重复计算
-
动态规划 vs 贪心算法:
- 贪心算法每次做出局部最优选择,不能保证全局最优
- 动态规划会考虑所有可能性,保证全局最优
- 贪心算法通常更高效,但适用问题较少
-
动态规划 vs 回溯算法:
- 回溯算法通过尝试所有可能性来解决问题,复杂度高
- 动态规划通过最优子结构避免重复计算
- 有些问题既可以用回溯+剪枝,也可以用动态规划
以找零钱问题为例,贪心算法在某些面值下无法得到最优解,而动态规划可以:
cpp复制// 贪心算法(不一定正确)
int coinChangeGreedy(vector<int>& coins, int amount) {
sort(coins.rbegin(), coins.rend());
int count = 0;
for (int coin : coins) {
while (amount >= coin) {
amount -= coin;
count++;
}
}
return amount == 0 ? count : -1;
}
// 动态规划(保证正确)
int coinChangeDP(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, amount + 1);
dp[0] = 0;
for (int i = 1; i <= amount; ++i) {
for (int coin : coins) {
if (coin <= i) {
dp[i] = min(dp[i], dp[i - coin] + 1);
}
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
对于硬币面值为[1,3,4]的情况,贪心算法在amount=6时会得到3+3(2枚),而最优解其实是4+1+1(3枚)。动态规划则能正确计算出最优解。
