👇
++核心:从数组到滚动数组:&1就行! dpij <=> fi\&1j (若有 int dpNM,f2M;)++
👆
滚动数组是动态规划中一种非常实用的空间优化技巧。它的核心思想很简单:**既然当前状态只依赖于前面少数几个状态,那就没必要把整个表格都存下来,用少数几个变量或一小块数组空间"滚动"着用就行了。**
你可以把它想象成一个滑动的窗口,只保留计算当前状态所必需的历史信息,旧的数据一旦不再需要,就被新数据覆盖掉。
一、为什么需要滚动数组?
在经典的动态规划问题中,我们通常会用一个二维甚至三维的数组来存储所有状态。比如在 0-1 背包问题里,dp[i][j] 表示前 i 件物品放入容量为 j 的背包能获得的最大价值。但仔细观察状态转移方程:
text
dp[i][j] = max(dp[i-1][j], dp[i-1][j - weight[i]] + value[i])
你会发现,dp[i][j] 的值只依赖于上一行 i-1 的状态,和更早的行完全没有关系。既然如此,我们为什么要保留整个二维数组呢?完全可以用一个一维数组来模拟"上一行"和"当前行"的交替更新,这就是滚动数组的由来。
二、滚动数组的两种常见形式
1. 交替行滚动(二维降为两行)
如果状态转移需要用到上一行的多个位置,但只涉及相邻两行,我们可以只开一个 dp[m] 的数组。通过 i % 2 或 i & 1 来交替使用第 0 行和第 1 行,新一行覆盖旧一行的数据。这样空间复杂度就从 O(n*m) 降到了 O(2*m),也就是 O(m)。
2. 一维滚动数组(进一步压缩为一行)
在 0-1 背包中,我们甚至可以直接用一个一维数组 dp[j] 来表示容量为 j 的背包当前能装的最大价值。状态转移方程变为:
text
dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
但这里有一个非常关键的细节:遍历顺序必须倒序。
为什么?因为如果正序遍历 j,当你计算 dp[j] 时,dp[j - weight[i]] 可能已经被当前这一轮更新过了,它代表的就不再是"上一轮"的状态,而是"本轮已经可能放过物品 i"的状态,这会导致同一个物品被重复放入多次,违背了 0-1 背包的规则。倒序遍历则能保证在更新 dp[j] 时,dp[j - weight[i]] 还是上一轮留下来的旧值,逻辑上完全等价于二维数组中的 dp[i-1][j - weight[i]]。
三、经典例子:斐波那契数列
斐波那契数列的递推公式是 f[i] = f[i-1] + f[i-2]。如果求第 n 项,我们完全不需要开一个长度为 n 的数组,只需要三个变量(或一个长度为 3 的数组)不断滚动更新即可:
text
f0 = 0, f1 = 1 for i = 2 to n: f2 = f1 + f0 f0 = f1 f1 = f2
这里每次计算只用到前两个状态,旧的状态被新值覆盖,空间复杂度从 O(n) 降到了 O(1)。这就是滚动数组最直观的体现。
四、完全背包的滚动数组有何不同?
完全背包问题中,每个物品可以选无限次。此时状态转移方程在形式上和一维 0-1 背包一样,但遍历顺序必须正序 。因为正序遍历允许在同一轮中,dp[j - weight[i]] 已经包含了"可能已经选过当前物品"的状态,从而实现了物品的重复选取。这恰好符合完全背包的定义。
五、适用场景总结
滚动数组适用于当前状态只依赖于前一个或前几个固定数量状态的动态规划问题。常见场景包括:
- 0-1 背包、完全背包、多重背包
- 最长公共子序列(LCS)
- 编辑距离
- 斐波那契数列及类似递推问题
- 任何状态转移只涉及相邻几行或几个阶段的问题
它的本质是用时间换空间------计算量不变,但内存占用大幅降低,尤其在数据规模很大时,这种优化往往是必须的。