从超时到秒杀:LeetCode动态规划代码优化实战指南
你是否还在为动态规划题目提交时显示"Time Limit Exceeded"而烦恼?是否想知道如何将O(n²)复杂度的代码优化到O(n)?本文将通过gh_mirrors/leet/leetcode项目中的真实案例,带你掌握三种立竿见影的代码优化技巧,让你的解题效率提升10倍以上。读完本文后,你将能够:识别动态规划中的冗余计算、熟练应用滚动数组技术、掌握空间复杂度从O(n²)到O(n)的转换方法。
动态规划优化的三大黄金法则
动态规划(Dynamic Programming, DP)是解决复杂问题的强大工具,但未经优化的DP代码往往存在时间和空间上的浪费。通过分析C++/chapDynamicProgramming.tex中的经典案例,我们总结出动态规划优化的三大核心策略:
- 状态压缩:合并重复状态,减少维度
- 滚动数组:利用数据访问的局部性,复用存储空间
- 空间复用:原地修改输入数据,彻底消除额外空间
这些优化技巧在项目的多个题解中得到了充分体现。例如最小路径和(Minimum Path Sum)问题就展示了从二维DP到一维滚动数组的完整优化过程。
案例实战:从二维数组到单变量的进化之路
以"最小路径和"问题为例,我们来看看优化过程的具体实现。问题要求从m×n网格的左上角走到右下角,每次只能向右或向下移动,求路径上数字之和的最小值。
1. 基础二维DP实现
最直观的解法是使用一个二维数组存储到达每个单元格的最小路径和:
// LeetCode, Minimum Path Sum - 二维动规
class Solution {
public:
int minPathSum(vector<vector<int> > &grid) {
if (grid.size() == 0) return 0;
const int m = grid.size();
const int n = grid[0].size();
int f[m][n];
f[0][0] = grid[0][0];
for (int i = 1; i < m; i++) {
f[i][0] = f[i - 1][0] + grid[i][0];
}
for (int i = 1; i < n; i++) {
f[0][i] = f[0][i - 1] + grid[0][i];
}
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
f[i][j] = min(f[i - 1][j], f[i][j - 1]) + grid[i][j];
}
}
return f[m - 1][n - 1];
}
};
这种实现的时间复杂度为O(m×n),空间复杂度同样为O(m×n)。当网格较大时,会占用大量内存。
2. 优化一:滚动数组技术
观察状态转移方程f[i][j] = min(f[i-1][j], f[i][j-1]) + grid[i][j],我们发现计算第i行时只需要第i-1行的数据。因此可以使用一个一维数组,通过不断更新它来存储当前行的计算结果:
// LeetCode, Minimum Path Sum - 二维动规+滚动数组
class Solution {
public:
int minPathSum(vector<vector<int> > &grid) {
const int m = grid.size();
const int n = grid[0].size();
int f[n];
fill(f, f+n, INT_MAX); // 初始值是 INT_MAX,因为后面用了min函数
f[0] = 0;
for (int i = 0; i < m; i++) {
f[0] += grid[i][0];
for (int j = 1; j < n; j++) {
// 左边的f[j],表示更新后的f[j],与公式中的f[i][j]对应
// 右边的f[j],表示老的f[j],与公式中的f[i-1][j]对应
f[j] = min(f[j - 1], f[j]) + grid[i][j];
}
}
return f[n - 1];
}
};
通过滚动数组优化,空间复杂度从O(m×n)降至O(n),对于m远大于n的情况,这种优化效果尤为明显。
3. 终极优化:原地修改输入
进一步观察发现,我们可以直接在原网格上进行计算,完全不需要额外空间:
// 原地修改版本
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int m = grid.size();
int n = grid[0].size();
// 第一行只能从左边过来
for(int j = 1; j < n; j++) {
grid[0][j] += grid[0][j-1];
}
// 第一列只能从上边过来
for(int i = 1; i < m; i++) {
grid[i][0] += grid[i-1][0];
}
// 其他单元格取左边或上边的最小值加上当前值
for(int i = 1; i < m; i++) {
for(int j = 1; j < n; j++) {
grid[i][j] += min(grid[i-1][j], grid[i][j-1]);
}
}
return grid[m-1][n-1];
}
};
这种优化将空间复杂度降至O(1),但需要注意的是,它会修改输入数据。在实际开发中,如果后续还需要使用原始输入数据,这种方法就不适用了。项目中的题解采用了滚动数组的折中方案,既避免了修改输入,又保持了较低的空间复杂度。
实战技巧:如何识别可优化的DP模式
通过分析C++/chapDynamicProgramming.tex中的多个案例,我们发现可优化的动态规划问题通常具有以下特征:
- 状态转移只依赖最近的几个状态:如斐波那契数列只依赖前两项
- 二维DP中只依赖上一行/列的数据:如最长公共子序列、最小路径和等
- 可以按顺序遍历并覆盖旧数据:如0-1背包问题
下面是一个帮助你快速判断是否可以应用滚动数组优化的决策流程图:
更多优化案例分析
项目中还有多个展示动态规划优化技巧的实例,例如:
1. 编辑距离问题
编辑距离(Edit Distance)问题要求计算将一个字符串转换为另一个字符串所需的最少操作次数,允许的操作包括插入、删除和替换。项目中提供了从二维DP到空间优化的完整实现:
// LeetCode, Edit Distance - 二维动规
class Solution {
public:
int minDistance(const string &word1, const string &word2) {
const size_t n = word1.size();
const size_t m = word2.size();
// 长度为n的字符串,有n+1个隔板
int f[n + 1][m + 1];
for (size_t i = 0; i <= n; i++)
f[i][0] = i;
for (size_t j = 0; j <= m; j++)
f[0][j] = j;
for (size_t i = 1; i <= n; i++) {
for (size_t j = 1; j <= m; j++) {
if (word1[i - 1] == word2[j - 1])
f[i][j] = f[i - 1][j - 1];
else
f[i][j] = min(min(f[i - 1][j], f[i][j - 1]), f[i - 1][j - 1]) + 1;
}
}
return f[n][m];
}
};
通过分析可以发现,这个二维DP数组的每一行只依赖于上一行的数据。因此,可以使用一维数组来优化空间:
// 编辑距离的滚动数组优化版本
class Solution {
public:
int minDistance(const string& word1, const string& word2) {
if (word1.size() < word2.size())
return minDistance(word2, word1);
int n = word1.size(), m = word2.size();
vector<int> dp(m + 1);
// 初始化第一行
for (int j = 0; j <= m; j++)
dp[j] = j;
for (int i = 1; i <= n; i++) {
int prev = dp[0]; // 保存左上角的值
dp[0] = i; // 第一列的值
for (int j = 1; j <= m; j++) {
int temp = dp[j]; // 保存当前值,下一轮将成为左上角
if (word1[i - 1] == word2[j - 1]) {
dp[j] = prev;
} else {
dp[j] = min({prev, dp[j], dp[j - 1]}) + 1;
}
prev = temp;
}
}
return dp[m];
}
};
这种优化将空间复杂度从O(n×m)降至O(min(n,m)),对于长字符串比较时效果显著。
2. 交错字符串问题
交错字符串(Interleaving String)问题判断一个字符串是否可以由另外两个字符串交错组成。项目中提供了从递归到三维DP再到二维优化的完整思路:
// LeetCode, Interleaving String - 二维动规+滚动数组
class Solution {
public:
bool isInterleave(const string& s1, const string& s2, const string& s3) {
if (s1.length() + s2.length() != s3.length())
return false;
if (s1.length() < s2.length())
return isInterleave(s2, s1, s3);
vector<bool> f(s2.length() + 1, true);
for (size_t i = 1; i <= s2.length(); ++i)
f[i] = s2[i - 1] == s3[i - 1] && f[i - 1];
for (size_t i = 1; i <= s1.length(); ++i) {
f[0] = s1[i - 1] == s3[i - 1] && f[0];
for (size_t j = 1; j <= s2.length(); ++j)
f[j] = (s1[i - 1] == s3[i + j - 1] && f[j])
|| (s2[j - 1] == s3[i + j - 1] && f[j - 1]);
}
return f[s2.length()];
}
};
这里通过两个优化技巧:交换两个字符串使较长的作为外层循环,以及使用一维滚动数组,将空间复杂度从O(n×m)优化到O(min(n,m))。
总结与实践建议
动态规划优化是提升代码效率的关键技能,通过分析C++/chapDynamicProgramming.tex中的案例和实践,我们可以总结出以下优化步骤:
- 先实现基础版本:不要一开始就追求最优解,先写出清晰的多维DP版本确保逻辑正确
- 分析状态依赖:画出状态转移图,确定哪些状态可以被优化
- 选择优化策略:根据依赖关系选择合适的优化方法(滚动数组、状态压缩等)
- 验证优化结果:对比优化前后的时间和空间消耗,确保正确性和效率提升
项目中的所有代码都遵循了这一优化流程,你可以在C++/目录下找到更多不同类型问题的优化实例。
最后,建议你选择1-2个题目,尝试自己实现从基础DP到优化版本的完整过程,这将帮助你真正掌握这些优化技巧。记住,优秀的程序员不仅能解决问题,还能写出高效优雅的代码。
相关资源
- 项目完整题解:C++/chapDynamicProgramming.tex
- 更多算法类型:C++/目录下的其他章节
- 编译PDF版题解:C++/leetcode-cpp.pdf
- 项目主页:README.md
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



