8 minute read

6/6

T416 分割等和子集

1、这一题可以先简化如下: //先加一遍成为总和,若为奇数,返回false,若为偶数,设置目标和为二分之一 //问题简化为:是否可以取出部分元素,使得和恰好等于目标和 //dp[i]:容量为i的情况下能出现的最大值

2、有一个有些绕的点:本题重量等于价值。如果背包所载重量为target, dp[target]就是装满 背包之后的总价值,因为 本题中每一个元素的数值既是重量,也是价值,所以,当 dp[target] == target 的时候,背包就装满了。

3、代码如下:

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int sum = 0;

        // dp[i]中的i表示背包内总和
        // 题目中说:每个数组中的元素不会超过 100,数组的大小不会超过 200
        // 总和不会大于20000,背包最大只需要其中一半,所以10001大小就可以了
        vector<int> dp(10001, 0);
        for (int i = 0; i < nums.size(); i++) {
            sum += nums[i];
        }
        // 也可以使用库函数一步求和
        // int sum = accumulate(nums.begin(), nums.end(), 0);
        if (sum % 2 == 1) return false;
        int target = sum / 2;

        // 开始 01背包
        for(int i = 0; i < nums.size(); i++) {
            for(int j = target; j >= nums[i]; j--) { // 每一个元素一定是不可重复放入,所以从大到小遍历
                dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
            }
        }
        // 集合中的元素正好可以凑成总和target
        if (dp[target] == target) return true;
        return false;
    }
};

【0/1背包问题】(摘录)

不放物品i:背包容量为j,里面不放物品i的最大价值是dp[i - 1][j]

放物品i:背包空出物品i的容量后,背包容量为j - weight[i],dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]且不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值

递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

初始化: for (int i = 1; i < weight.size(); i++) { // 当然这一步,如果把dp数组预先初始化为0了,这一步就可以省略,但很多同学应该没有想清楚这一点。 dp[i][0] = 0; } // 正序遍历 for (int j = weight[0]; j <= bagweight; j++) { dp[0][j] = value[0]; }

一维dp数组(滚动数组) 对于背包问题其实状态都是可以压缩的。

在使用二维数组的时候,递推公式:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

其实可以发现如果把dp[i - 1]那一层拷贝到dp[i]上,表达式完全可以是:dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]); 与其把dp[i - 1]这一层拷贝到dp[i]上,不如只用一个一维数组了,只用dp[j](一维数组,也可以理解是一个滚动数组)。

这就是滚动数组的由来,需要满足的条件是上一层可以重复利用,直接拷贝到当前层。 读到这里估计大家都忘了 dp[i][j]里的i和j表达的是什么了,i是物品,j是背包容量。

dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。

一维dp数组,其实就上上一层 dp[i-1] 这一层 拷贝的 dp[i]来。 所以在 上面递推公式的基础上,去掉i这个维度就好。 递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

以下为分析: dp[j]为 容量为j的背包所背的最大价值。 dp[j]可以通过dp[j - weight[i]]推导出来,dp[j - weight[i]]表示容量为j - weight[i]的背包所背的最大价值。 dp[j - weight[i]] + value[i] 表示 容量为 [j - 物品i重量] 的背包 加上 物品i的价值。(也就是容量为j的背包,放入物品i了之后的价值即:dp[j]) 此时dp[j]有两个选择,一个是取自己dp[j] 相当于 二维dp数组中的dp[i-1][j],即不放物品i,一个是取dp[j - weight[i]] + value[i],即放物品i,指定是取最大的,毕竟是求最大价值,

所以递归公式为: dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

可以看出相对于二维dp数组的写法,就是把dp[i][j]中i的维度去掉了。

一维dp数组如何初始化 关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱。 dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j],那么dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0。

看一下递归公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了。

这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。 那么假设物品价值都是大于0的,所以dp数组初始化的时候,都初始为0就可以了。

一维dp数组遍历顺序 代码如下: for(int i = 0; i < weight.size(); i++) { // 遍历物品 for(int j = bagWeight; j >= weight[i]; j–) { // 遍历背包容量 dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); } }

二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。 为什么呢? 倒序遍历是为了保证物品i只被放入一次。但如果一旦正序遍历了,那么物品0就会被重复加入多次!

举例:物品0的重量weight[0] = 1,价值value[0] = 15 如果正序遍历 dp[1] = dp[1 - weight[0]] + value[0] = 15 dp[2] = dp[2 - weight[0]] + value[0] = 30 此时dp[2]就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历。

为什么倒序遍历,就可以保证物品只放入一次呢? 倒序就是先算dp[2] dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp数组已经都初始化为0) dp[1] = dp[1 - weight[0]] + value[0] = 15 所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。

6/5

T96 不同的二叉搜索树

1、这一题看起来有点难,但靠举例+逻辑推理纯手搓AC啦

2、关键:吸取上一题教训+看到n最大值只有19后,意识到这一题也可以采取全覆盖遍历+动态规划的解法,也就是当根节点取不同值时,每个值天然将左右子树区分为两组节点,这些节点内在又具备逐个加一的统一有序规律性,因此可以直接等价为同样节点数目的二叉搜索树数目值(即便所有节点的值都统一加了K)。这样一来,新的dp[i]在遍历中不断根据左右子树的二叉搜索树数目乘积累增即可

3、代码如下:

class Solution {
public:
    int numTrees(int n) {
     vector<int> dp(n+5,0);
     dp[0] = 1;
     dp[1] = 1;
     dp[2] = 2;
     if(n <= 2)
       return dp[n];

     for(int i = 3;i <= n;i++){
        for(int j = 0;j <= i-1;j++){
          dp[i] = dp[i] + dp[j] * dp[i-1-j];
        }
     }
         return dp[n];
    }
};

4、这一题和上一题都有一个细节需注意: vector dp(n+5,0);这里之所以+5或其他值,是为了防止dp[0] = 1;这一步骤赋值时数组越界 dp[1] = 1; dp[2] = 2;

T343 整数拆分

1、这一题我一开始想了很久,没找到巧妙的方法,看了题解后才知道核心就是利用已有计算量的遍历,不存在什么数学上的简易解法。

2、关键:对于每个特定的数,可以选择拆分/不拆分,其中不拆分多一种拆分里不存在的解,因此要考虑max取值。例如,3的拆分可以是1x1x1 1x2 但没有3;5的拆分可以是1x4 1x2x2 2x3但没有5,这两组值不一定谁比谁大。

3、代码如下:

class Solution {
public:
    int integerBreak(int n) {
        vector<int> dp(n + 1, 0);
        dp[2] = 1;

        for (int i = 3; i <= n; i++) {
            for (int j = 1; j < i; j++) {
                dp[i] = max(
                    dp[i],
                    max(
                        j * (i - j),   // 只拆成两个数
                        j * dp[i - j]  // 右边继续拆
                    )
                );
            }
        }

        return dp[n];
    }
};

4、这一题有极其严苛的较难的数学证明法、动态规划优化法和基于高等数学函数极值证明的贪心算法,在所有专题刷完后可进行拓展

6/4

T63 不同路径二

1、这一题不能用数学公式解决了,因为新增了障碍物

2、三个关键:首先,第一行第一列如果中途遇到障碍物,后续格子无法左移/上移,因此无法绕开障碍物,值依旧是0;其次,dp遍历时遇到障碍物就要设置0,再做比较;最后,开头的[0][0]也要当心有障碍物

3、完整代码如下:

class Solution {
public:
    int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {

        int m = obstacleGrid.size();
        int n = obstacleGrid[0].size();

        vector<vector<int>> dp(
            m,
            vector<int>(n, 0)
        );

        if(obstacleGrid[0][0] == 1)
            return 0;

        dp[0][0] = 1;

        // 第一列
        for(int i = 1; i < m; i++)
        {
            if(obstacleGrid[i][0] == 1)
                dp[i][0] = 0;
            else
                dp[i][0] = dp[i - 1][0];
        }

        // 第一行
        for(int j = 1; j < n; j++)
        {
            if(obstacleGrid[0][j] == 1)
                dp[0][j] = 0;
            else
                dp[0][j] = dp[0][j - 1];
        }


        for(int i = 1; i < m; i++)
        {
            for(int j = 1; j < n; j++)
            {

                if(obstacleGrid[i][j] == 1)
                {
                    dp[i][j] = 0;
                }
                else
                {
                    dp[i][j] =
                        dp[i - 1][j]
                        +
                        dp[i][j - 1];
                }
            }
        }

        return dp[m - 1][n - 1];
    }
};

T62 不同路径

1、注意行列不要反,以及二维数组动态非固定住要用vector,语法有些复杂,需要初始化

2、赋值时一定保证之前的值是计算过的,遍历前需要把左侧、上侧网格统一赋值

3、代码如下:

class Solution {
public:
    int uniquePaths(int m, int n) {
     //本质上是在n-1次右移中插入m-1次下移,应该有数学公式可以直接计算得出
    //动态规划解法:dp[i][j]代表从左上角(0,0)到(i,j)处的路径总数
    vector<vector<int>> dp(m,vector<int>(n,0));
    dp[0][0] = 1;
    for(int i = 1;i < m;i++){
        dp[i][0] = 1;
    }
    for(int i = 1;i < n;i++){
        dp[0][i] = 1;
        }
    for(int i = 1;i < m;i++){
        for(int j = 1;j < n;j++){
            dp[i][j] = dp[i-1][j] + dp[i][j-1];
        }
    }
    return dp[m-1][n-1];
    }
};

4、这一题也可以等效为高中数学组合题,将两种走法加起来再取组合就好理解了。

从左上角到右下角,必须走:

向下 m-1 次 向右 n-1 次

总共走:

m+n-2 步

问题变成: 在 m+n-2 个位置中,选择 m-1 个位置放“向下”,其余自动是“向右”。 因此答案就是组合数:

例如:

m=3,n=7

需要:

↓↓ →→→→→→

共:

8步

从 8 个位置里选 2 个放 ↓:

直接阶乘会溢出:

(n+m-2)!

非常大。 因此边乘边除:

class Solution {
public:
    int uniquePaths(int m, int n) {

        long long ans = 1;

        int k = min(m - 1, n - 1);

        for(int i = 1; i <= k; i++)
        {
            ans = ans * (m + n - 2 - k + i) / i;
        }

        return (int)ans;
    }
};5、这里阶乘的处理方式很重要,有必要把复杂度高的数学计算统一学习理解最优解



6/3
T746 使用最小花费爬楼梯
1、这一题也手搓AC啦,中途遇到了一个小bug:忘记0...n实际上是n+1个元素,因此dp数组大小要开n+1

2、注意n的来源cost.size();语法;注意CPP自带许多库,最小值可用min()语法

3、代码如下:
class Solution {
public:
    int minCostClimbingStairs(vector<int>& cost) {
      int n = cost.size();
      vector<int>dp(n+1);
      //dp[i]:爬到下标为i的台阶的最小花费
      if(n <= 1)
        return 0;

      dp[0] = 0;
      dp[1] = 0;
      for(int i = 2;i <= n;i++){
        dp[i] = min(dp[i-1]+cost[i-1],dp[i-2]+cost[i-2]);
      }
      return dp[n];
    }
};

T70 爬楼梯

1、太经典了,高中数学题。上一层的解数目+上二层的解数目。手搓直接AC,注意一下两个语法点:vector dp(n+1);之所以N+1是因为0没有含义,位置序号直接对应输入n更好理解;定义vector种类以及大小的语法。再就是注意边界值, if(n == 1) return 1;不加的话会出现越界问题

2、代码如下:

class Solution {
public:
    int climbStairs(int n) {
       vector<int> dp(n+1);
       if(n == 1)
          return 1;
       dp[1] = 1;
       dp[2] = 2;
       for(int i = 3;i <= n;i++){
        dp[i] = dp[i-1] + dp[i-2];
       } 
       return dp[n];
    }
};

6/2

T509 斐波那契数

1、从这题开始,所有解法都手搓啦~

2、直接按递推公式写的代码AC了,但耗时较长:

class Solution {
public:
    int fib(int n) {
        if(n>=2)
           return fib(n-1) + fib(n-2);
        else if(n==1)
           return 1;
        else 
           return 0;
    }
};

3、耗时最短的解法如下:

class Solution {
public:
    int fib(int N) {
        if (N <= 1) return N;
        vector<int> dp(N + 1);
        dp[0] = 0;
        dp[1] = 1;
        for (int i = 2; i <= N; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[N];
    }
};

我们可以借此清晰学习到方才的五部曲: 动规五部曲: 这里我们要用一个一维dp数组来保存递归的结果 确定dp数组以及下标的含义 dp[i]的定义为:第i个数的斐波那契数值是dp[i] 确定递推公式 为什么这是一道非常简单的入门题目呢? 因为题目已经把递推公式直接给我们了:状态转移方程 dp[i] = dp[i - 1] + dp[i - 2]; dp数组如何初始化 题目中把如何初始化也直接给我们了,如下: dp[0] = 0; dp[1] = 1;

确定遍历顺序 从递归公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的 举例推导dp数组 按照这个递推公式dp[i] = dp[i - 1] + dp[i - 2],我们来推导一下,当N为10的时候,dp数组应该是如下的数列: 0 1 1 2 3 5 8 13 21 34 55 如果代码写出来,发现结果不对,就把dp数组打印出来看看和我们推导的数列是不是一致的。 以上我们用动规的方法分析完了,C++代码如下:

class Solution {
public:
    int fib(int N) {
        if (N <= 1) return N;
        vector<int> dp(N + 1);
        dp[0] = 0;
        dp[1] = 1;
        for (int i = 2; i <= N; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[N];
    }
};

时间复杂度:O(n) 空间复杂度:O(n) 4、然而,这样空间复杂度就上来了,所以随想录里提供了更进一步的优化(不新建数组):

class Solution {
public:
    int fib(int N) {
        if (N <= 1) return N;
        int dp[2];
        dp[0] = 0;
        dp[1] = 1;
        for (int i = 2; i <= N; i++) {
            int sum = dp[0] + dp[1];
            dp[0] = dp[1];
            dp[1] = sum;
        }
        return dp[1];
    }
};

5、分析最初解法耗时较长原因:展开时做了很多重复运算,没能把一个计算循环利用。例如: fib(5)展开后,会出现下图待计算值,可以观察到fib(3)、fib(2)等都被重复计算了许多遍。变成了平方级别的时间复杂度,不适用大型数值计算。 fib(5) ├─ fib(4) │ ├─ fib(3) │ │ ├─ fib(2) │ │ └─ fib(1) │ └─ fib(2) └─ fib(3) ├─ fib(2) └─ fib(1)

【动态规划专题】

(摘录)对于动态规划问题,拆解为如下五步曲 确定dp数组(dp table)以及下标的含义 确定递推公式 dp数组如何初始化 确定遍历顺序 举例推导dp数组

T968 监控二叉树

1、这一题最关键的是从下往上处理,直接从叶节点的父节点开始,依次向上遍历。这个思路看起来不难,然而不好想到,因为正确性证明过于模糊,看似经不起直觉验证。

2、精简代码如下(含递归):

class Solution {
private:
    int result;
    int traversal(TreeNode* cur) {
        if (cur == NULL) return 2;
        int left = traversal(cur->left);    // 左
        int right = traversal(cur->right);  // 右
        if (left == 2 && right == 2) return 0;
        else if (left == 0 || right == 0) {
            result++;
            return 1;
        } else return 2;
    }
public:
    int minCameraCover(TreeNode* root) {
        result = 0;
        if (traversal(root) == 0) { // root 无覆盖
            result++;
        }
        return result;
    }
};

3、返回值含义: 0:当前节点无覆盖 1:当前节点有摄像头 2:当前节点已被覆盖,但没有摄像头

4、此题还要动态规划解法和正确性证明,待刷完动态规划专题后补充第二种解法及证明

6/1

T738 单调递增的数字

1、这一题我秉承尽可能让数字大一点(优先保高位)的思路,伪代码如下: //38432152 //从最左位开始,观察第二个数若大于等于它,看能否保持这个值直到不满足(下一个小于了);若第二个数就小于了,最左侧减1,其他位都设置为9 //把之前那一串一样的数字(除最左位,也可能除其他,例如3456662的45)全部下降1(只下降连续段最左侧也可以,后面的反正都要被覆盖),若还大于等于最左侧的数,直接只保留左数第二位,其他位全部设置成9;若小于最左侧的数,最左侧减1,其他位置全部设置为9 //清除前置零

2、简单优化:不必拆开最左位单独考虑,把它融为一体即可,10、332依旧符合该逻辑,都是连续段不符合下一个后最左侧连续点下降1,其他赋值9

3、代码如下:

class Solution {
public:
    int monotoneIncreasingDigits(int n) {

        string s = to_string(n);

        int i = 0;

        while(i < s.size() - 1)
        {
            // 发现下降
            if(s[i] > s[i + 1])
            {
                // 找到前面连续相同数字的最左位置
                int j = i;

                while(j > 0 && s[j] == s[j - 1])
                {
                    j--;
                }

                // 该位置减1
                s[j]--;

                // 后面全部变9
                for(int k = j + 1; k < s.size(); k++)
                {
                    s[k] = '9';
                }

                break;
            }

            i++;
        }

        // 清除前导0
        return stoi(s);
    }
};

4、有时间可以尝试一下从后往前的遍历

T56 合并区间(这一题好多企业考,可能因为应用场景广泛)

1、我的初始思路是: //先按照每个区间的右侧从小到大排序,取最小的右侧值k //检测第二个排序区间的左侧是否小于等于k,若小于,更新k为这个区间的右侧值,合并区间,左侧选两个左侧的最小值 //用新K处理第三个排序区间,循环,直到处理完毕

2、成功踩坑几个点:首先,不合并也要更新K值为第二个区间的右侧,否则信息滞后;其次,这样处理后可能有遗留未合并区间,需要记录changed布尔值带一个while——[[2,3],[4,5],[1,10]]这种在第一轮就会合并为重叠的[2,3],[1,10];最后,时间复杂度较高,代码也较为复杂。

3、初始代码如下,可以AC:

class Solution {
public:
    vector<vector<int>> merge(vector<vector<int>>& intervals) {

        bool changed = true;

        while (changed) {

            changed = false;

            // 按右端点排序
            sort(
                intervals.begin(),
                intervals.end(),
                [](const vector<int>& a, const vector<int>& b) {
                    return a[1] < b[1];
                }
            );

            vector<vector<int>> next;

            int i = 0;

            while (i < intervals.size()) {

                // 尝试与后一个区间合并
                if (
                    i + 1 < intervals.size() &&
                    intervals[i + 1][0] <= intervals[i][1]
                ) {
                    next.push_back({
                        min(intervals[i][0], intervals[i + 1][0]),
                        max(intervals[i][1], intervals[i + 1][1])
                    });

                    changed = true;
                    i += 2;
                }
                else {
                    next.push_back(intervals[i]);
                    i++;
                }
            }

            intervals = next;
        }

        return intervals;
    }
};

4、最优解代码如下:

class Solution {
public:
    vector<vector<int>> merge(vector<vector<int>>& intervals) {
        vector<vector<int>> result;
        if (intervals.size() == 0) return result; // 区间集合为空直接返回
        // 排序的参数使用了lambda表达式
        sort(intervals.begin(), intervals.end(), [](const vector<int>& a, const vector<int>& b){return a[0] < b[0];});

        // 第一个区间就可以放进结果集里,后面如果重叠,在result上直接合并
        result.push_back(intervals[0]); 

        for (int i = 1; i < intervals.size(); i++) {
            if (result.back()[1] >= intervals[i][0]) { // 发现重叠区间
                // 合并区间,只更新右边界就好,因为result.back()的左边界一定是最小值,因为我们按照左边界排序的
                result.back()[1] = max(result.back()[1], intervals[i][1]); 
            } else {
                result.push_back(intervals[i]); // 区间不重叠 
            }
        }
        return result;
    }
};

5、关键:我们可以发现,这题是否最优主要在于是否可以一个循环遍历合并完所有区间,而非左右排序之分。右侧排序也是可以的,但相对应就应该由右侧往左侧遍历,左侧排序则左侧往右侧遍历,两者都要对比max/min值更新新区间边界和对比边界。核心:往前进方向遍历时,已处理的区间必须保证安心作为孤岛,即后续边界均可控制不与之重合。

对应的右侧排序右遍历代码也可以AC:

class Solution {
public:
    vector<vector<int>> merge(vector<vector<int>>& intervals) {

        vector<vector<int>> result;

        if(intervals.empty())
            return result;

        // 按右端点从小到大排序
        sort(
            intervals.begin(),
            intervals.end(),
            [](const vector<int>& a,const vector<int>& b)
            {
                return a[1] < b[1];
            }
        );

        // 从最后一个区间开始
        result.push_back(intervals.back());

        for(int i = intervals.size() - 2; i >= 0; i--)
        {
            // 当前区间与已合并区间重叠
            if(intervals[i][1] >= result.back()[0])
            {
                // 只更新左边界即可
                // 因为 result.back()[1]
                // 一定是当前最大的右边界
                result.back()[0] =
                    min(
                        result.back()[0],
                        intervals[i][0]
                    );
            }
            else
            {
                result.push_back(intervals[i]);
            }
        }

        return result;
    }
};

T763 划分字母区间

1、思路经典:每次取符合要求的最短片段,其他取法不能更优,故最终为片段数最多的解

2、关键:每新遍历一个char,必须更新right为当前已遍历所有char的最大终止界限,否则会出现将同一字母切分到不同片段的情景

3、待选切割点:每个char出现的最后位置

4、代码如下:

class Solution {
public:
    vector<int> partitionLabels(string S) {
        int hash[26] = {0}; // i为字符,hash[i]为字符出现的最后位置
        for (int i = 0; i < S.size(); i++) { // 统计每一个字符最后出现的位置
            hash[S[i] - 'a'] = i;
        }
        vector<int> result;
        int left = 0;
        int right = 0;
        for (int i = 0; i < S.size(); i++) {
            right = max(right, hash[S[i] - 'a']); // 找到字符出现的最远边界
            if (i == right) {
                result.push_back(right - left + 1);
                left = i + 1;
            }
        }
        return result;
    }
};