滚动数组
引入
在介绍滚动数组之前,我们先来看一个斐波那契数列的例子,来感受滚动数组的魅力:
假设我们要以动态规划的方法,求第100位的斐波那契数,代码如下:
#include <stdio.h>
long long fibonacci(int n) {
long long fib[n+1];
int i;
fib[0] = 0;
fib[1] = 1;
for (i = 2; i <= n; i++) {
fib[i] = fib[i-1] + fib[i-2];
}
return fib[n];
}
int main() {
int n = 100;
long long result = fibonacci(n);
printf("第 %d 位斐波那契数为 %lld\n", n, result);
return 0;
}
可以看到,在程序中,如果我们要要求出第100位的斐波那契数,需要申请一个大小为
101*sizeof(int)
的空间来进行操作,所占的空间随着我们要求的位数越大而增加,于是我们需要引入一些方法来减小它的空间复杂度。
- 通过仔细观察斐波那契数列的递推方程,
f(n) = f(n-1) + f(n-2)
,我们不难发现,想要知道下一位的斐波那契数,我们其实只需要,知道前两个的斐波那契数。于是,我们可以使用长度为3的数组来存储数据,通过递推来得到新数据将旧数据进行覆盖。
用滚动数组优化后的代码如下:
#include <stdio.h>
long long fibonacci(int n) {
if (n <= 1) {
return n;
}
long long a[3];
a[0] = 1,a[1] = 1;
for (int i = 2; i <= n; i++) {
a[2] = a[0] + a[1];
a[0] = a[1];
a[1] = a[2];
}
return a[2];
}
int main() {
int n = 100;
long long result = fibonacci(n);
printf("The %dth Fibonacci number is: %lld\n", n, result);
return 0;
}
在以上代码中,原本需要使用大小为101*sizeof(int)
的数组,最后却只用了三个位置,大大减少了空间的复杂度。
滚动数组的定义
滚动数组(Sliding Window)是种常见的动态规划优化方法,通常用于解决涉及连续子数组或子序列的问题。它通过维护一个固定长度的窗口来减少计算的时间复杂度,给空间复杂度「降维」。
滚动数组思想的基本原理是,通过移动窗口的起始位置和结束位置来更新计算结果,以避免重复计算。在处理连续子数组或子序列的问题时,我们通常需要对窗口内的元素进行计算,并根据问题的要求更新结果。
具体而言,我们可以将滚动数组思想分为以下步骤:
- 初始化窗口的起始位置和结束位置。
- 进入循环,不断移动窗口的结束位置,直到窗口无法再向右移动。
- 在每次移动窗口时,更新窗口内的计算结果。
- 根据问题的要求,更新最终的结果。
使用滚动数组思想可以有效地减少计算量,尤其是在处理大规模数据或需要遍历所有子数组或子序列的问题时。对于动态规划题目来说,我们可以先写出最原始的dp
方程,再通过观察dp
方程,使用滚动数组进行优化,我们需要思考如何更新数据和覆盖数据来达到降维的目的。
对一维动态规划数组进行滚动优化
知道了滚动数组的原理,我们马上来一道题,巩固一下学的成果吧
题目
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
示例 2:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。
提示:
1 <= nums.length <= 100
0 <= nums[i] <= 400
解题过程:
找到dp
数组的含义:
当我们拿到动态规划的题目的时候,我们第一个反应就是去找到该问题的子问题,即与原来问题相似但规模较小的问题,原问题为从全部房子中偷到最大的金额
,它的子问题可以是从k个房间偷到最大的金额
。若题目中有n个房间,那么子问题就有n个。
子问题应当遵循以上原则:
-
原问题要能由子问题表示。例如这道小偷问题中,
k=n
时实际上就是原问题。否则,解决了子问题还是解不出原问题。 -
一个子问题的解要能通过其他子问题的解求出。
通过对子问题的分析,我们不难确定dp
数组的含义,即当前房间能偷到的最大金额。
找到子问题(dp
数组)的递推关系
根据题目的意思,在第k个房子能偷到的最大金额(我们把该子问题设为f(k)
),有两种可能性:
如果选择偷第k
间房能获得最大的金额,那么k-1
的房子不能偷,于是偷到第k
间房的最大金额f(k)
就变成了第k间房的金额+偷到第二间的最大金额,即:f(k)=f(k-2)+num[k]
;另一种情况,当选择不偷第k间房能偷到最大金额,即偷到第k-1
间房的金额最大,那么问题就转变在偷到k-1
个房子能偷到的最大金额:f(k)=f(k-1)
最后的答案当然是选择这两种情况中金额最大的一种,
写为递推公式则为:f(k)=max{f(k−1),f(k-2)+num[k]}
动态规划数组的初始化
当我们明白了动归数组的递推公式后,便需要对其进行初始化,f(0)
就是指偷第一间房得到的最大金额,那便是nums[0]
,那么偷第二间房的最大金额即为f(1)=fmax(nums[0],nums[1])
明白了递推公式,我们也不难用代码来进行实现:
int rob(int* nums, int numsSize) {
if(numsSize==1){
return nums[0];
}
int dp[numsSize];
int i;
dp[0]=nums[0];
dp[1]=fmax(nums[0],nums[1]);
for(i=2;i<numsSize;i++){
dp[i]=fmax(dp[i-1],dp[i-2]+nums[i]);
}
return dp[i-1];
}
滚动数组进行空间优化
与上面求斐波那契数一样,本题动态规划的递推公式中下一项永远只与它之前的第一项和第二项有关,于是我们申请单位数为3的数组,进行本题递推的实现,代码如下:
int rob(int* nums, int numsSize) {
if(numsSize==1){
return nums[0];
}
int a[0];
a[0]=nums[0],a[1]=fmax(nums[0],nums[1]);
for(int i=2;i<numsSize;i++){
a[2]=fmax(nums[i]+a[0],a[1]);
a[1]=a[1],a[1]=a[2];
}
return a[2];
}
滚动数组在二维数组中的运用
题目
我们感受到了滚动数组在一维动态规划数组的运用,接下来我们来领略一下,滚动数组在优化二维数组的独到之处
一个机器人位于一个 m x n
网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
示例 1:
输入:m = 3, n = 7
输出:28
示例 2:
输入:m = 3, n = 2
输出:3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。
1. 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右
3. 向下 -> 向右 -> 向下
示例 3:
输入:m = 7, n = 3
输出:28
示例 4:
输入:m = 3, n = 3
输出:6
解题过程
看到这道题,不知道大家是否有些高中数学的既视感,我们的原问题为到达最后节点的路径数
,与上一题的思路大致相同,子问题就可以为到达某个格的路径数
,由于只有向下和向左两个方向,我们就不难得出,某个点最多的路径是它到达上面一格与到达左边一格的路径之和。即:
dp[i][j]=dp[i-1][j]+dp[i][j-1]
注意:在本题之中,左边与上边属于边界的地方需要进行特殊处理,由于其为边界所以他们的路径数只能为1
,我们对其先进行初始化。
代码实现:
int uniquePaths(int m, int n) {
int a[m][n];
int i,j;
for(i=0;i<m;i++){
a[i][0]=1;
}
for(j=0;j<n;j++){
a[0][j]=1;
}
//对左边和上边的边界进行初始化
for(i=1;i<m;i++){
for(j=1;j<n;j++){
a[i][j]=a[i-1][j]+a[i][j-1];
}
}
return a[i-1][j-1];
}
其中标红的为初始化的位置
使用滚动数组进行优化
我们想到,由于dp[i][j]
仅与第 i
行和第 i−1
行的状态有关,,而我们只需要找到最后节点的所有路径数即可,那么过程中的数据都可以被新数据给覆盖,因此我们可以使用滚动数组代替代码中的二维数组,使空间复杂度从降低为 O(n)
。
int uniquePaths(int m, int n) {
int a[m];
int i,j;
memset(a,0,sizeof(a));//初始化数组
a[0]=1;
for(i=0;i<n;i++){
for(j=0;j<m;j++){
if(j-1>=0){
a[j]+=a[j-1];
//a[j]表示的是二维数组中的a[i][j-1],a[j-1]即为二维数组中的a[i-1][j]
}
}
}
return a[j-1];//返回滚动数组的最后一项,即最后的节点
}
滚动数组模拟二维数组的过程
不难看出滚动数组在初始化方面,相对于普通的动态规划也有一定的优势,将二维数组简化为一维数组也大大减少了程序的空间复杂度{O2→O}。这样滚动数组的优化其实就是一个时间换空间
的过程(运行时间长些,但所用的空间变少)。