LeetCode-Go中的动态规划优化:状态压缩与滚动数组技巧
你是否还在为动态规划问题中高额的空间复杂度而烦恼?是否遇到过因二维数组过大导致内存超限的情况?本文将通过LeetCode-Go项目中的实战案例,带你掌握状态压缩与滚动数组这两大优化技巧,让你的动态规划解法既高效又优雅。读完本文后,你将能够:识别可优化的动态规划场景、应用状态压缩减少空间复杂度、使用滚动数组技巧处理多维DP问题、对比不同优化方法的适用范围。
动态规划优化的必要性与核心思路
动态规划(Dynamic Programming, DP)是解决多阶段决策问题的强大工具,但其时间和空间复杂度往往成为瓶颈。在LeetCode题目中,我们经常会遇到需要优化空间复杂度的场景,尤其是当问题规模较大时。
状态压缩与滚动数组是两种常用的空间优化技巧:
- 状态压缩:通过减少DP状态的表示维度或使用位运算等方式降低空间占用
- 滚动数组:利用DP状态转移的特性,只保留必要的中间结果,用固定大小的数组替代原有的大数组
这两种技巧在LeetCode-Go项目中有着广泛应用,例如[0198. House Robber](https://gitcode.com/GitHub_Trending/le/LeetCode-Go/blob/25c03cf13afa6ffb2bb305940c9bced152214536/leetcode/0198.House-Robber/198. House Robber.go?utm_source=gitcode_repo_files)问题就展示了从二维DP到常量空间的完整优化过程。
状态压缩:从二维到一维的蜕变
状态压缩的核心思想是找到DP状态之间的依赖关系,将高维状态表示转化为低维。让我们通过两个经典案例来理解这一技巧。
案例一:0-1背包问题的空间优化
在[1049. Last Stone Weight II](https://gitcode.com/GitHub_Trending/le/LeetCode-Go/blob/25c03cf13afa6ffb2bb305940c9bced152214536/leetcode/1049.Last-Stone-Weight-II/1049. Last Stone Weight II.go?utm_source=gitcode_repo_files)问题中,我们需要将石头分成两堆,使得两堆重量差最小。这可以转化为一个0-1背包问题,传统解法使用二维数组:
// 传统二维DP
dp[i][j] = max(dp[i-1][j], dp[i-1][j-stones[i]]+stones[i])
而在LeetCode-Go的实现中,通过状态压缩优化为一维数组:
n, C, dp := len(stones), sum/2, make([]int, sum/2+1)
for i := 0; i < n; i++ {
for j := C; j >= stones[i]; j-- {
dp[j] = max(dp[j], dp[j-stones[i]]+stones[i])
}
}
return sum - 2*dp[C]
这里通过逆序遍历,我们成功将二维数组压缩为一维,空间复杂度从O(n*C)降低到O(C)。
案例二:零钱兑换II的状态优化
[0518. Coin Change II](https://gitcode.com/GitHub_Trending/le/LeetCode-Go/blob/25c03cf13afa6ffb2bb305940c9bced152214536/leetcode/0518.Coin-Change-II/518. Coin Change II.go?utm_source=gitcode_repo_files)问题要求计算可以凑成总金额的硬币组合数。项目中使用了以下优化方案:
func change(amount int, coins []int) int {
dp := make([]int, amount+1)
dp[0] = 1
for _, coin := range coins {
for i := coin; i <= amount; i++ {
dp[i] += dp[i-coin]
}
}
return dp[amount]
}
通过将 coins 作为外层循环,我们不仅将空间复杂度从O(n*amount)优化到O(amount),还避免了重复计算,确保每个组合只被计算一次。这种优化方式在组合类DP问题中非常常见。
滚动数组:时间换空间的智慧
滚动数组技巧适用于当DP状态只依赖于前几行(或列)的情况。通过循环利用固定大小的存储空间,我们可以将空间复杂度从O(n)降低到O(1)。
案例一:打家劫舍问题的极致优化
[0198. House Robber](https://gitcode.com/GitHub_Trending/le/LeetCode-Go/blob/25c03cf13afa6ffb2bb305940c9bced152214536/leetcode/0198.House-Robber/198. House Robber.go?utm_source=gitcode_repo_files)问题展示了从二维DP到常量空间的完整优化过程。项目中提供了三种解法:
解法一:标准二维DP
// dp[i] 代表抢 nums[0...i] 房子的最大价值
dp := make([]int, n)
dp[0], dp[1] = nums[0], max(nums[1], nums[0])
for i := 2; i < n; i++ {
dp[i] = max(dp[i-1], nums[i]+dp[i-2])
}
return dp[n-1]
解法二:滚动数组优化
curMax, preMax := 0, 0
for i := 0; i < n; i++ {
tmp := curMax
curMax = max(curMax, nums[i]+preMax)
preMax = tmp
}
return curMax
通过观察状态转移方程dp[i] = max(dp[i-1], nums[i]+dp[i-2]),我们发现每个状态只依赖于前两个状态。因此可以用两个变量替代整个DP数组,将空间复杂度从O(n)降低到O(1)。
案例二:最小路径和的滚动数组实现
在[0064. Minimum Path Sum](https://gitcode.com/GitHub_Trending/le/LeetCode-Go/blob/25c03cf13afa6ffb2bb305940c9bced152214536/leetcode/0064.Minimum-Path-Sum/64. Minimum Path Sum.go?utm_source=gitcode_repo_files)问题中,传统解法使用二维数组:
dp := make([][]int, m)
for i := range dp {
dp[i] = make([]int, n)
}
// 初始化第一行和第一列
for i := 0; i < len(dp); i++ {
for j := 0; j < len(dp[0]); j++ {
dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
}
}
而通过滚动数组技巧,我们可以将其优化为一维数组:
dp := make([]int, n)
// 初始化第一行
for j := 1; j < n; j++ {
dp[j] = dp[j-1] + grid[0][j]
}
// 滚动计算后续行
for i := 1; i < m; i++ {
dp[0] += grid[i][0]
for j := 1; j < n; j++ {
dp[j] = min(dp[j], dp[j-1]) + grid[i][j]
}
}
return dp[n-1]
这种优化方式在处理矩阵类DP问题时特别有效,能够将空间复杂度从O(m*n)降低到O(min(m,n))。
状态压缩与滚动数组的对比与选择
虽然状态压缩和滚动数组都是空间优化技巧,但它们的适用场景和实现方式有所不同:
| 优化技巧 | 核心思想 | 空间复杂度优化 | 适用场景 | 实现难度 |
|---|---|---|---|---|
| 状态压缩 | 减少状态维度 | O(n^k) → O(n^m) (m < k) | 状态转移有规律的问题 | 中等 |
| 滚动数组 | 循环利用存储空间 | O(n) → O(1) | 只依赖前几轮状态的问题 | 简单 |
在实际应用中,我们常常需要结合两种技巧。例如在[0474. Ones and Zeroes](https://gitcode.com/GitHub_Trending/le/LeetCode-Go/blob/25c03cf13afa6ffb2bb305940c9bced152214536/leetcode/0474.Ones-and-Zeroes/474. Ones and Zeroes.go?utm_source=gitcode_repo_files)问题中,项目同时使用了状态压缩和滚动数组,将三维DP优化为二维:
dp := make([][]int, m+1)
for i := range dp {
dp[i] = make([]int, n+1)
}
// 滚动更新dp数组
for _, str := range strs {
zeros, ones := countZeroOne(str)
for i := m; i >= zeros; i-- {
for j := n; j >= ones; j-- {
dp[i][j] = max(dp[i][j], dp[i-zeros][j-ones]+1)
}
}
}
实战应用与注意事项
在应用状态压缩和滚动数组技巧时,有几个关键点需要注意:
1. 确定状态依赖关系
在优化前,首先要分析DP状态转移方程,确定状态之间的依赖关系。只有当状态只依赖于有限的前几轮结果时,才能应用滚动数组技巧。例如在[0300. Longest Increasing Subsequence](https://gitcode.com/GitHub_Trending/le/LeetCode-Go/blob/25c03cf13afa6ffb2bb305940c9bced152214536/leetcode/0300.Longest-Increasing-Subsequence/300. Longest Increasing Subsequence.go?utm_source=gitcode_repo_files)问题中,项目通过巧妙的状态定义,将O(n^2)的解法优化为O(n log n):
dp := []int{}
for _, num := range nums {
i := sort.SearchInts(dp, num)
if i == len(dp) {
dp = append(dp, num)
} else {
dp[i] = num
}
}
return len(dp)
2. 注意遍历顺序
状态压缩常常需要调整遍历顺序,以避免覆盖还未使用的状态。通常采用逆序遍历的方式,如[1049. Last Stone Weight II](https://gitcode.com/GitHub_Trending/le/LeetCode-Go/blob/25c03cf13afa6ffb2bb305940c9bced152214536/leetcode/1049.Last-Stone-Weight-II/1049. Last Stone Weight II.go?utm_source=gitcode_repo_files)中的实现。
3. 平衡时间与空间
有时优化空间会略微增加时间复杂度,需要在两者之间寻找平衡。例如在[0518. Coin Change II](https://gitcode.com/GitHub_Trending/le/LeetCode-Go/blob/25c03cf13afa6ffb2bb305940c9bced152214536/leetcode/0518.Coin-Change-II/518. Coin Change II.go?utm_source=gitcode_repo_files)中,虽然空间复杂度从O(n*amount)降低到O(amount),但时间复杂度保持不变。
4. 可读性与效率的权衡
过度优化可能会降低代码可读性。LeetCode-Go项目在[0198. House Robber](https://gitcode.com/GitHub_Trending/le/LeetCode-Go/blob/25c03cf13afa6ffb2bb305940c9bced152214536/leetcode/0198.House-Robber/198. House Robber.go?utm_source=gitcode_repo_files)中提供了三种解法,从标准DP到极致优化,既展示了优化技巧,又保证了代码的可读性:
// 解法三 模拟(进一步优化空间到O(1))
func rob(nums []int) int {
a, b := 0, 0 // a: 偶数位最大值, b: 奇数位最大值
for i := 0; i < len(nums); i++ {
if i%2 == 0 {
a = max(a+nums[i], b)
} else {
b = max(a, b+nums[i])
}
}
return max(a, b)
}
总结与进阶
状态压缩与滚动数组是动态规划中的两项核心优化技巧,它们能够显著降低空间复杂度,使原本无法通过的解法变得可行。在LeetCode-Go项目中,这些技巧被广泛应用于各种DP问题,如:
- [0063. Unique Paths II](https://gitcode.com/GitHub_Trending/le/LeetCode-Go/blob/25c03cf13afa6ffb2bb305940c9bced152214536/leetcode/0063.Unique-Paths-II/63. Unique Paths II.go?utm_source=gitcode_repo_files):使用滚动数组优化路径计数
- [0354. Russian Doll Envelopes](https://gitcode.com/GitHub_Trending/le/LeetCode-Go/blob/25c03cf13afa6ffb2bb305940c9bced152214536/leetcode/0354.Russian-Doll-Envelopes/354. Russian Doll Envelopes.go?utm_source=gitcode_repo_files):结合二分查找的状态压缩
- [0887. Super Egg Drop](https://gitcode.com/GitHub_Trending/le/LeetCode-Go/blob/25c03cf13afa6ffb2bb305940c9bced152214536/leetcode/0887.Super-Egg-Drop/887. Super Egg Drop.go?utm_source=gitcode_repo_files):逆向思维的状态压缩
掌握这些技巧不仅能够帮助你在LeetCode比赛中脱颖而出,更能培养你从空间维度思考问题的能力。想要进一步提升,可以尝试:分析项目中[0368. Largest Divisible Subset](https://gitcode.com/GitHub_Trending/le/LeetCode-Go/blob/25c03cf13afa6ffb2bb305940c9bced152214536/leetcode/0368.Largest-Divisible-Subset/368. Largest Divisible Subset.go?utm_source=gitcode_repo_files)的状态压缩方式、尝试用滚动数组优化[0718. Maximum Length of Repeated Subarray](https://gitcode.com/GitHub_Trending/le/LeetCode-Go/blob/25c03cf13afa6ffb2bb305940c9bced152214536/leetcode/0718.Maximum-Length-of-Repeated-Subarray/718. Maximum Length of Repeated Subarray.go?utm_source=gitcode_repo_files)的解法、比较不同优化方法在大规模测试用例下的性能差异。
动态规划的优化是一门艺术,需要在实践中不断积累经验。LeetCode-Go项目为我们提供了丰富的实战案例,通过研究这些高质量的代码,我们可以快速掌握各种优化技巧,写出既高效又优雅的动态规划解法。
希望本文能够帮助你理解动态规划的空间优化技巧。如果你有任何问题或发现更好的优化方法,欢迎在项目中提交PR或issue,让我们一起完善这个优秀的Go语言LeetCode解决方案集合。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



