LeetCode动态规划(一)状态分析/滚动数组/二维化一维/&运算

1,185 阅读13分钟

(一)leetcode53:最大子序和

原题链接

思路分析

本题给了一个整数数组,题目要求我们在其中找一个连续子数组,该子数组应满足其中所有数加起来和最大。 根据题意,我们可以枚举出一个整数数组所有的子数组情况,例如 [-2, 1, -3, 4, -1, 2, 1, -5, 4],其子数组:

①. 子数组中只有一个元素:[-2], [1], [-3], [4], [-1], [2], [1], [-5], [4]

②. 子数组中有两个元素: [-2, 1], [1, -3], [-3, 4], [4, -1], [-1, 2], [2, 1], [1, -5], [-5, 4]

由于子数组是连续的,所以规律非常明显。根据以上情况以此类推,可以得知:子数组只有1个元素,则有n种情况(n为原数组中元素个数);2个元素,n - 1种情况;3个元素,n - 2种情况 ... ... 直到子数组为原数组n个元素,只有1种情况。

通过枚举,我们可以在这所有情况中选取最大值,不过这种方式有1/2^n^2 + 1/2^n情况,即时间复杂度为O(n^2),明显是不可取的。   而本题既然可以使用上面的暴搜解决(经过之前所有阶段的状态的组合得到最终阶段的最优解),那么就可以考虑使用动态规划来解决,能否使用的关键就在于当前阶段状态向下一个阶段状态的转移是否需要之前所有的状态,经过分析过后是不需要的(即存在最优子结构,无后效性,状态转移方程的右边不会用到所有的下标情况):

1、 状态表示:f(i)

①. 集合 :以下标 i 结尾的子数组的和的所有集合

②. 属性 : 求最大值

2、 状态计算:将集合 f(i) 进行划分(根据f(i)定义,子数组必须包含第i个元素),可以根据 子数组中元素个数是否为1个 分为两个集合 (这里和高中数列中的首个元素特判很像,由于当子数组中只有一个元素时,f[i] = f[i - 1] +nums[i] 的状态方程不满足该情况,所以要把只有一个元素的情况单独拎出来讨论)

 集合1:f[ i - 1 ] + nums[i]

 集合2:nums[i]

】本题中不同的阶段就是以 i 结尾的不同情况,从前往后遍历即经过了不同的阶段,本题中同一阶段则又有不同的状态,同一阶段的状态集合可以被划分(如上划分过程)。

理解dp的两个关键

每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到(且不用管之前的状态是如何获得的)

1、 状态:所有之前阶段做出一个选择的集合

2、 阶段:阶段是指随着问题的解决,在同一个时刻可能会得到的不同状态的集合

代码示例(javascript)

/**
 * @param {number[]} nums
 * @return {number}
 */
var maxSubArray = function(nums) {
    let f = new Array(nums.length), // 定义存储状态的数组 
        res = f[0] = nums[0] // f[0]状态初始化,同时初始化了最大值res
    for(let i = 1; i < nums.length; i ++) { // 下标遍历从1开始,因为f[i - 1]的缘故,这样更方便
        f[i] = nums[i] + Math.max(0, f[i - 1]) // // 两个状态中求最大值赋给f[i]
        res = Math.max(res, f[i]) 
    }
    return res
};

补充思路:分支(线段树)

区间被分割后,左右区间怎么得到整个区间的最大子段和

本题除了可以采用动态规划外,还可以采用分治的策略,把整个数组区间分成左右段,通过左右两段的相关性质信息整合出整个区间的最大子段和,整个区间的最大子段和在由左/右两段整合时 分三种情况:

①. 整个区间的最大子段和位于左区间: == 左区间最大子段和

②. 整个区间的最大子段和位于右区间:== 右区间最大子段和

③. 横跨左右区间: == 左区间最大后缀 + 右区间最大前缀,

而在求一个区间的最大后缀 和 最大前缀时,又会用到一个区间的总和,比如在求一个整个区间的最大前缀时,其最大前缀可能就是左区间的最大前缀,但也可能是横跨了左右区间的,那么这时其最大前缀就 == 左区间总和 + 右区间的最大前缀。

以上三种情况中,总结起来,对一个区间而言,要求的量包括:

1、总和

2、最大子段和

3、最大前缀

4、最大后缀

左右分隔而治

上面明确了要对一个区间要求的量之后,就要开始做分治了(如果你不了解分治,可以将分治简单地理解为 “写出递归关系式子”)。

本题分治实际上就是一个建树的过程: 以数组 [5,4,-1,7,8]为例,递归: image.png

if(left == right) return ...
let mid = left + right >> 1,
L = build(nums, left, mid),
R = build(nums, mid + 1, right)

按照以上代码首先会一直向左遍历:(0, 4) -> (0, 2) -> (0, 1) -> (0, 0),

当向左已经遍历到叶子节点时,即left == right就return ...,(0, 0)出栈,此时栈顶是(0, 1);

(0, 1)左边已经遍历完了,然后执行向右遍历,向右遍历一步就到了(0, 1)右边的叶子节点(1, 1),left == right return...,(1, 1)出栈,此时在栈顶的再次是(0, 1);

此时代码执行完了右子树就继续往下执行到底,return ...,此时在栈顶的(0, 1)也出栈了,在栈顶的是(0, 2),并且代码将要执行遍历右子树,以此类推 ... ...

遍历完左子树,return往上回溯一格,遍历右子树,右子树遍历完,往上回溯,此时该节点的左/右子树均遍历完,则又return往上回溯到它的上一个节点...,当整棵树的左子树都遍历完时,就去遍历整棵树的右子树,并类似地以上过程

】以上是建树的过程,每次左右分割后,都会利用左右区间的相关性质计算整个区间的相关性质。

以下是分治做法的完整代码:

代码示例

/**
 * @param {number[]} nums
 * @return {number}
 */

function build(nums, l, r) {
    // 分治,在本题中就是 建树    
    if(l == r) { // 已经到了叶子节点,整个区间中只有一个元素
        v = nums[l];
        return {
            sum : v, // 总和
            s: v, // 最大子段和
            ls: v, // 最大前缀
            rs : v // 最大后缀
        };
    }
    let mid = (l + r) >> 1, 
        L = build(nums, l, mid), // 递归是自顶向下的,而动态规划则是自底向上的
        R = build(nums, mid + 1, r),
        res = {};
        // 把一个整区间分割成左右两边之后,就要考虑怎么把左右两边区间的性质合成为一个整区间的性质
        res.sum = L.sum + R.sum; // 除了整个区间的和可以直接由左右两边的之和拼合外,下面几个“最大”都要分情况讨论
        res.s = Math.max(Math.max(L.s, R.s), L.rs + R.ls); // 此时整个区间的字段最大3种情况,一是该字段位于左边,二是位于右边,三则是该字段横跨左右两个区间
        // 为什么要计算最大前缀和最大后缀:这是为了求上面的子段和准备的,因为存在最大子段横跨左右两边的情况!
        // 求一个完整区间的最大前/后缀也是为两种情况的:一种是该前/后缀横跨左右区间,另一种就是前缀就位于左区间,后缀则位于右区间
        res.ls = Math.max(L.ls, L.sum + R.ls); 
        res.rs = Math.max(R.rs, L.rs + R.sum);
        return res;
}

var maxSubArray = function(nums) {
   res = build(nums, 0, nums.length - 1);
   return res.s;
};

(二)LeetCode70:爬楼梯

原题链接

思路分析

从本题题干可以得知爬楼梯时每次要么跨1步要么2步,我们当然可以用枚举来做,枚举一下找规律:

第n级台阶012345678
方案数112358132134

从上述表格可知,当前阶段状态的方案数等于前两个阶段方案数之和(斐波那契数列),了解了这一点,就减少了许多情况的讨论,意味着从一个阶段的状态转移到下一个阶段的状态并不需要之前所有状态,故具有无后效性,可以采用动态规划:

①. 状态表示

 集合:f(i)表示第i级台阶的方案数

 属性:count个数

②. 状态计算

f(i) = f(i - 1) + f(i - 2)

代码示例

/**
 * @param {number} n
 * @return {number}
 */
var climbStairs = function(n) {
    let f = new Array(n + 1);
    f[0] = 1, f[1] = 1;
    for(let i = 2; i <= n; i ++) f[i] = f[i - 1] + f[i - 2];
    return f[n]; 
};

滚动数组

然而本题想讲的并不是上面动态规划的基本流程,而是用于优化动态规划的 “滚动数组”。动态规划是一个记录再利用的算法,由于其要记录之前的状态,必然会使用大量空间,要优化动态规划算法的空间,我们必然要合理利用dp数组,有一种优化方法就是利用滚动数组来进行状态转移。(参考自blog.csdn.net/qq_36378681…

先看一段代码实现 爬楼梯

/**
 * @param {number} n
 * @return {number}
 */
var climbStairs = function(n) {
    let a = 1, b = 1, c = 1; 
    for(let i = 2; i <= n; i ++) {
        c = a + b; // 因为当前阶段的状态仅仅是由前两个阶段的状态推导出的
        // 所以只需要a,b两个变量记录前两个状态的方案数,让这两个变量不断“滚动”,更新值即可
        a = b; // 不需要定义一个存储所有情况的数组f[n]
        b = c;
    }
    return c; 
};

通过上述代码可知,滚动数组其实就是通过减少存储状态方程的数组空间,进而减小整个算法的空间复杂度。当然,上面“斐波那契”的例子还不够典型,因为它只是运用了两个变量的轮巡而已,并不是数组内部元素在滚动,0-1背包问题的一维滚动数组还是最典型的:

0-1背包问题

0-1背包问题 题干

有 N 件物品和一个容量为 V 的背包,每件物品有各自的价值且只能被选择一次,要求在有限的背包容量下,装入的物品总价值最大。

问题分析

「0-1 背包」中的一个阶段就是指对第 i 个物品做出决策的阶段,该阶段这个物品有两种状态「0-1」,代表不选与选两种状态。

对本题的一般二维分析

(1)N个一维数组(二维数组)

求背包中物品的最大价值,如果暴搜就是要遍历每个物品放入背包中后的组合情况,并在其中求最大值。考虑动态规划就是,一个物品将要放入背包中时,其放/不放入背包中的总价值最大值可以从前面的最大值中推知。

①. 状态表示:

 1、集合 f[i][j]:总体积为j情况下(该总体积>=背包中物品体积总和),只看前 i 个物品,总价值的集合

 2、属性:max总价值的最大值。最大值 == max(f[0-N][0-V])

②. 状态计算:(两种情况)

 装不下第 i 个,即不选:f[i][j] = f[i - 1][j]

 可以装下:如果从前往后遍历,那么若能将第 i 个物品放入背包中,其放入后的总价值是可以由第 i - 1的情况推知的(这一点的理解对后面转为一维有重大作用),即 f[i - 1][k] + w[i] , k = j - v[i]。    其中从侧面也要求我们把第 i - 1 个物品时的所有体积情况的最大值都计算出来。

   i) 选择第i个装入:f[i][j] = f[i - 1][j - v[i]] + w[i]

代码示例

for(let i = 0; i <= N) f[i][0] = 0; // 初始化:只要体积为0,价值一定是0
for(let i = 1; i <= N; i ++) // 从第1个物品选起
    for(let j = 0; j <= V; j ++) { // 体积则要从0开始
        if(j < v[i]) f[i][j] = f[i - 1][j]; // 装不下
        else f[i][j] = Math.max(f[i - 1][j], f[i - 1][j - v[i]] + w[i]);
        // 装得下,并在装入与不装入的两种情况中取最大值
        // 这里如果不做if判断,那么可能会j<v[i]时,数组索引越界!
    }
}
res = f[N][V]; // 为什么最后结果可以直接是f[N][V],不用再在 i = N 中去做筛选了吗?
// 比如比较f[N][0], f[N][1], f[N][2] ... f[N][V],然后选取最大值?
// 如果要筛选,那么就是:
// res = 0;
// for(let i = 0; i <= V; i ++) res = Math.max(res, f[N][i]); 
// 不筛选是因为:f[N][V] 代表的是前N个物品在容量 <=V 的情况下取得的最大价值!
// f[N][V]其实是>= f[N][0-V]的值的,因为它的V是最大的,它的前一阶段的最大值大于其他0-V的最大值,转移一下,它当前的最大值一定是 >= 0-V的当前的最大值的,它包括了0-V情况中的最大值!
// 且在初始化时要把 f[0][j] (0 <= j <= V) 都赋值成了0,这意味着i = 0时我们求的j <= V的价值为0都是合法方案。

// 但如果仅仅是求体积恰好为V时的最大价值,那么初始化时只把f[0][0]赋值为0,
// 其余的都赋为NaN,因为是“恰好”,所以f[0][1],f[0][2]等都是不合法的,
// 因为如果是恰好,没往背包中放东西,体积怎么可能会>0呢,所以才要这样初始化赋值!

(2)两个一维数组:滚动数组🌼🌼🌼

观察本题中的状态转移,当前阶段的状态都仅仅是从上一阶段的状态转移而来的,我们完全没必要存下所有阶段的状态,我们只要保存上一阶段的所有状态和当前阶段的所有状态就行了。     利用这两个数组(当前阶段数组与上一阶段数组)不断滚动计算,就可以得到我们想要的最后一阶段的最后一个状态dp[N][V],即题目要求的最优解的状态。 优化情况

for(let i = 1; i < N; i ++)
    for(let j = 0; j < V; j ++) {
        if(j < v[i]) f[i & 1][j] = f[(i - 1) & 1][j]; // 装不下
        else f[i & 1][j] = Math.max(f[(i - 1) & 1][j], f[(i - 1) & 1][j - v[i]] + w[i]); 
        // i & 1:i为奇数则为1,i为偶数为0
    }
res = f[N & 1][V];
// 这里我们表面上虽然还是用了二维数组,但二维数组的第一维实际上只用了两个而已,故空间上只定义了2个一维数组

以前要用到 N * V 的空间,现在我们只需要 2 * V 的空间,让两个一维数组不断重复使用,就是所谓的 “滚动数组” 了 !

小结:滚动数组

将一个二维的数组转化为滚动数组非常简单,就是把第一维的元素 & 1 即可。

其实可以用一个矩阵来解释: image.png

第一行用完(“用完”指的是已经用到它计算状态的地方已经没有了),就要第三行覆盖它;第二行用完,就被第四行覆盖,我们可以从中发现一个规律:

1, 3, 5, 7 ...

2, 4, 6, 8 ...     这就是上面 & 1 思想的由来(奇数 & 1等于1,偶数 & 1等于0),奇数用一个数组,偶数用一个数组。

i & 1

i 和 1 做二进制的且运算,即看 i 二进制的最后一位是不是 1,这样也可以用于判断该 i 的奇偶性。       比如,8 & 1:1000 & 0001,根据与运算&的规则,对二进制的每位数:i & 1 就是 i,i & 0 是 0(i为0或1)。 现对于一个数 i & 1,即 & 00...1,就是判断i最后一位是否为 1,那么为什么能够判断一个数奇偶性呢?      我们知道一个二进制数等于: 2^0 + 2^1 + 2^2 + ... + 2^i,其中偶数相加一定为偶数,整个表达式中只有一个奇数 2 ^ 0 (1),整个数的奇偶都是由它来决定的,如果它是0,那么该数就是偶数;否则就是奇数。 (这在第3题比特位计数中也会用到)  

(3)转为一维数组

其实0-1背包可以不用“滚动数组”,而可以进一步优化,用一维数组去存储状态,做状态转移。 首先要想,去掉一维是哪一维?第一维存储的是阶段,第二维存储的是各种不同的状态,状态转移更新是核心,不能去掉。    所以我们可以尝试去掉阶段(即第一维),去掉第一维,相当于数组的第一个参数i就不会变了(但算体积和价值时还是会用到):(直接生硬到去掉一维)

for(let i = 1; i < N; i ++)
    for(let j = 0; j < V; j ++) {
        if(j >= v[i]) f[j] = Math.max(f[j], f[j - v[i]] + w[i]); 
    }

上面这样直接生硬地去掉一维是不行的,因为我们这里状态转移的基础是当前阶段 j下的最大价值可以由上一个阶段的某个最大价值推出来,比如第2个物品要转入体积为3的背包时,它的最大价值 == 它自己的价值 + 放入第1个物品且背包体积为3 -v[2]时的最大价值构成的:

f[2][3] = f[2- 1][3 - v[2]] + w[2]

现在生硬的一维就是

f[3 - v[2]] = f[0] + w[1] ,

f[3] = f[3 - v[2]] + w[2]

转移成二维就是

f[2][3] = f[2][3 - v[2]] + w[2],用除去自己体积而数量与自己一致的最大值 + 自己价值 求 包括自己体积而数量一致的最大值,相当于你要用3的体积装下2 + 1样东西,这明显是违背题意的!

有什么办法可以解决这一问题吗? 💐💐💐

  上面那种情况的出现实际上就是 j 从前往后遍历时,f[j]用到的f[j - v[i]]是刚刚自己这个i循环更新过的,而不是上一个 i - 1 循环时的 f[j - v[i]] ,那么如何才能让 f[j] 用到i - 1时的 f[j - v[i]] 呢?

  很简单,让j从后往前遍历,这样 f[j] 就可以利用到没有被自己这层i循环刚刚更新过的 f[j - v[i]] 和 f[j] 了。 !!!

】这样1维的从后往前遍历为什么可以呢,不是说动态规划是自底向上的吗?

这里是因为首先开始 f[j] 的每个值都已经被赋值为0了,第二就是实际上我们的确还是自底向上的,只是现在 ( 一维 + 逆序 ) 可以使 f[j] 还是由上一阶段的 f[j] 和 f[j - v[i]] 构成 !

代码示例:

for (int i = 1; i <= n; i ++ )
        for (int j = m; j >= 0; j -- ) {
            f[j] = f[j];
            if(j >= v[i]) f[j] = Math.max(f[j], f[j - v[i]] + w[i]);
        }     
res = f[V];

当然代码可以更加优化一下:第二次循环只循环能装入的情况,因为现在是一维的了,即使你装不进去,第i个循环的f[j]不更新,这时的f[j]其实就已经是第i - 1循环的f[j]的值了,所以循环只需判断能装入的情况:

for (int i = 1; i <= n; i ++ )
        for (int j = m; j >= v[i]; j -- ) 
            f[j] = Math.max(f[j], f[j - v[i]] + w[i]);
res = f[V];

(三)LeetCode338:比特位计数

原题链接 本题是一个典型的动态规划问题,先找找规律:

十进制数二进制数0的个数
000
111
2101
3112
41001
51012

我们把一个数字就当做一个阶段,那么它是否能由前面的阶段转移而来呢?

这个状态转移是如何进行的呢?

一个数一定可以找到 去掉它个位后那个数的1的个数,这时再去判断该数的个位数是否为1,如果为1,那么就加1即可! 即:

f[i] = f[i >> 1] + (i & 1);

i >> 1:某个数的二进制去掉个位后形成的数,先更多了解位运算,可参考c++位运算中最常用的两种操作

i & 1:该数的个位是否为1,如果为1,那么i & 1为1,(讲滚动数组时有讲到)

代码示例

/**
 * @param {number} n
 * @return {number[]}
 */
var countBits = function(n) {
    let f = new Array(n + 1);
    f[0] = 0;
    for(let i = 1; i <= n; i ++) 
        f[i] = f[i >> 1] + (i & 1);
    return f;
};

小结 🌱🌱🌱

1、动态规划问题的第一步就是判断该问题是否为动态规划问题: ①. 首先,我们可以看一下这个问题是否要让我们求最优解; ②. 然后,再看一下是求什么的最优解呢?应该是求所有选法的最优解; ③. 当要让我们在指数级别的选法中求最优解,那么一般要么Dp,要么贪心(大部分是Dp)。 (比如上面所讲的“最大子序和”,“0-1背包”都是求选法中的最优解,而“爬楼梯”,“比特位计数”则是求所有的选法)

贪心和Dp本身就是为最优解问题而诞生的:

每个阶段的最优状态都是由上一个阶段的最优状态得到的 -> 贪心

每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到而不管之前这个状态是如何得到的 -> 动态规划

2、判断完这是一个Dp问题后,也许你会想是不是要开始做状态表示,求状态转移方程了? 答案是No! 动态规划的第二步是将该问题的暴力解法想出来,无论是循环枚举还是dfs暴搜,都行,了解了暴力解法后无论是状态表示还是求状态转移方程都有好处

3、做好之前的准备工作后,开始正式分析Dp问题:首先就是要划分阶段,搞清楚阶段划分的条件,该阶段常常就是第i个元素。

4、阶段划分清楚后,就要开始做状态分析,分析一个阶段中有多少种状态,都是如何进行状态转移的:

①. 状态表示:从集合(集合的特点就是集合中的每个元素都有共同点,符合某个划分条件)的角度去考虑表示一个状态,这也是动态规划最具优势的地方之一,能把很多具有共同点的东西划分到一类中,避免了暴搜的麻烦;   当然,我们要从集合(选法)中求最优解,这些最优解可能是集合中元素的 max,min或集合元素的个数(多少种选法)等。(化零为整

②. 状态计算:状态计算就涉及到了求最优解的过程,即求递推方程,并在求的时候一般可以从倒数第二个状态 -> 最后一个状态的转移去分析!(化整为零