【笔记】动态规划总结 2.0

【笔记】动态规划刷题总结 2.0

个人心得

问题本质

动态规划名字看起来高大上,感觉是种很复杂的算法,令人“望文生畏”,其实一句话概括,就是 数学归纳法,推公式 。动态规划问题的每一个状态是由上一个状态通过 状态转移方程 推导得出(对于存在很多状态的问题,需要画状态图辅助推导出正确的状态转移方程,类似《编译原理》的自动状态机)。动态规划是一种 “聪明的穷举” ,所谓具备 “最优子结构” 和存在 “重叠子问题” 。本质上来说,动态规划是通过引入dp数组这种 “空间换时间” 的方法来降低暴力算法的时间复杂度。

解题步骤

参考Carl的方法,牢牢把握“五部曲”:

  1. 确定dp数组以及下标的含义

  2. 确定递推公式

  3. dp数组如何初始化

  4. 确定遍历顺序

  5. 举例推导dp数组

解释说明:

  • 根据题目选择二维数组还是滚动数组(一维),状态数决定二维dp数组的列数
  • 2先于3是因为不同的状态转移方程需要不同的初始化方法
  • 深刻理解1,才能初始化好3
  • 遍历顺序主要集中于背包问题:用滚动数组解决01背包时,注意先物品再背包且背包倒序遍历(防止重复)。完全背包正序、倒序均可,若求排列问题只能先遍历背包,若求组合问题只能先遍历物品
  • 如果存在错误,打印dp数组日志来验证每一步过程,下附“灵魂三问”,解决好这三个问题debug将不再困难:
    • 这道题目我举例推导状态转移公式了么?
    • 我打印dp数组的日志了么?
    • 打印出来了dp数组和我想的一样么?

一、基础题目,用于熟悉“五部曲”解题

509.斐波那契数

// 时间、空间复杂度O(N)
class Solution {
public:
    int fib(int n) {
        if (n == 0 || n == 1) return n;
        vector<int> dp(n + 1, 0);
        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(1)
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];
    }
};

70.爬楼梯

class Solution {
public:
    int climbStairs(int n) {
        if (n < 3) return n;
        vector<int> dp(n + 1);
        // 本题考虑关于dp[0]的初始化没意义
        // i从3开始,直接跳过dp[0]
        dp[1] = 1;
        dp[2] = 2;
        for (int i = 3; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
};
// 还可以优化空间复杂度
// 【扩展】完全背包:每次走m下,几种方法到n层台阶

746.使用最小花费爬楼梯

class Solution {
public:
    int minCostClimbingStairs(vector<int>& cost) {
        // 初始化包含了将dp[0],dp[1]设为0
        // 此题并非跳到最后一级台阶截止,而是要跳出所有楼梯,dp要多一个
        vector<int> dp(cost.size() + 1, 0);
        for (int i = 2; i <= cost.size(); i++) {
            dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
        }
        return dp[cost.size()];
    }
};
// 还可以优化空间复杂度O(1)

62.不同路径

class Solution {
public:
    int uniquePaths(int m, int n) {
        vector<vector<int>> dp(m, vector(n, 0));
        // 第一行,第一列的所有元素均只有一种到达方式
        for (int i = 0; i < m; i++) dp[i][0] = 1;
        for (int j = 0; j < n; j++) dp[0][j] = 1;
        // 从(1,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];
    }
};

62.不同路径II

class Solution {
public:
    int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
        //如果在起点或终点出现了障碍,直接返回0
        if (obstacleGrid[obstacleGrid.size() - 1][obstacleGrid[0].size() - 1] == 1 || obstacleGrid[0][0] == 1) return 0;
        vector<vector<int>> dp(obstacleGrid.size(), vector<int>(obstacleGrid[0].size(), 0));
        for (int i = 0; i < obstacleGrid.size(); i++) {
            if (obstacleGrid[i][0] == 1)
                break;
            dp[i][0] = 1;
        }
        for (int j = 0; j < obstacleGrid[0].size(); j++) {
            if (obstacleGrid[0][j] == 1)
                break;
            dp[0][j] = 1;
        }
        for (int i = 1; i < obstacleGrid.size(); i++) {
            for (int j = 1; j < obstacleGrid[0].size(); j++) {
                if (obstacleGrid[i][j] == 0) {
                    dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
                }
            }
        }
        return dp[obstacleGrid.size() - 1][obstacleGrid[0].size() - 1];
    }
};

343.整数拆分(比较难想,注意理解)

class Solution {
public:
    int integerBreak(int n) {
        if (n == 2) return 1;
        vector<int> dp(n + 1, 0);
        dp[2] = 1;
        for (int i = 3; i <= n; i++) {
            // 因为拆分一个数 n 使之乘积最大,那么一定是拆分成m个近似相同的子数相乘才是最大的
            // 只不过我们不知道m究竟是多少而已,但可以明确的是m一定大于等于2,既然m大于等于2,也就是 最差也应该是拆成两个相同的 可能是最大值。
            // 那么 j 遍历,只需要遍历到 i/2 就可以,后面就没有必要遍历了,一定不是最大值。
            for (int j = 1; j <= i / 2; j++) {
                // 从1遍历j,然后有两种渠道得到dp[i]
                // 一个是j * (i - j) 直接相乘,一个是j * dp[i - j]
                // j * (i - j) 是单纯的把整数拆分为两个数相乘,而j * dp[i - j]是拆分成两个以及两个以上的个数相乘
                // j怎么就不拆分呢?j是从1开始遍历,拆分j的情况,在遍历j的过程中其实都计算过了
                dp[i] = max(dp[i], max(dp[i - j] * j, (i - j) * j));
            }
        }
        return dp[n];
    }
};

96.不同的二叉搜索树(很难想,注意理解)

class Solution {
public:
    int numTrees(int n) {
        if (n == 1) return n;
        vector<int> dp(n + 1, 0);
        dp[0] = 1;
        dp[1] = 1;
        for (int i = 2; i <= n; i++) {
            for (int j = 1; j <= i; j++) {
                //  dp[i] += dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量]
                // j 相当于是头结点的元素,从1遍历到i为止
                dp[i] += dp[j - 1] * dp[i - j];
            }
        }
        return dp[n];
    }
};

二、背包问题(难点是遍历顺序,其次是递推公式)

引用自:代码随想录-背包总结篇

【笔记】动态规划总结 2.0_第1张图片

1、0-1背包理论基础

二维数组版本

有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

1、确定dp数组以及下标的含义

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

2、确定递推公式

有两个方向推出来dp[i] [j],

  • 不放物品i:由dp[i - 1] [j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i] [j]就是dp[i - 1] [j]。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以被背包内的价值依然和前面相同。)
  • 放物品i:由dp[i - 1] [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]);

3、dp数组如何初始化

首先从dp[i] [j]的定义出发,如果背包容量j为0的话,即dp[i] [0],无论是选取哪些物品,背包价值总和一定为0;那么很明显当 j < weight[0]的时候,dp[0] [j] 应该是 0,因为背包容量比编号0的物品重量还小,当j >= weight[0]时,dp[0] [j] 应该是value[0],因为背包容量放足够放编号0物品

动态规划-背包问题7

4、确定遍历顺序

递推数据由上、左上得来,先遍历物品、背包均可。为了好理解建议先遍历物品,即i在外循环j在内循环

5、举例推导dp数组

最终结果就是dp[2] [4]

动态规划-背包问题4

一维dp数组(滚动数组)

背包问题其实状态都是可以压缩的。可以发现如果把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遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。为什么呢?倒序遍历是为了保证物品i只被放入一次!。但如果一旦正序遍历了,那么物品0就会被重复加入多次!

【为什么二维dp数组历的时候不用倒序?】

因为对于二维dp,dp[i] [j]都是通过上一层即dp[i - 1] [j]计算而来,本层的dp[i] [j]并不会被覆盖!

【为什么不能先遍历背包容量嵌套遍历物品呢?】

因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。倒序遍历的原因是,本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖。

动态规划-背包问题9

五部曲省略,直接上测试代码

void test_1_wei_bag_problem() {
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    int bagWeight = 4;

    // 初始化
    vector<int> dp(bagWeight + 1, 0);
    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]);
        }
    }
    cout << dp[bagWeight] << endl;
}

int main() {
    test_1_wei_bag_problem();
}

2、递推公式分类总结

① 问能否能装满背包(或者最多装多少)

dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]); ,对应题目如下:

动态规划:416.分割等和子集

只有确定了如下四点,才能把0-1背包问题套到本题上来。

  • 背包的体积为sum / 2
  • 背包要放入的商品(集合里的元素)重量为 元素的数值,价值也为元素的数值
  • 背包如果正好装满,说明找到了总和为 sum / 2 的子集
  • 背包中每一个元素是不可重复放入
class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int sum = 0;
        for (int num : nums)
            sum += num;
        if (sum % 2 == 1) return false;
        int target = sum / 2;
        // 题目中说:每个数组中的元素不会超过 100,数组的大小不会超过 200
        // 总和不会大于20000,背包最大只需要其中一半,所以dp大小设置为10001就可以了
        // 但我们已经计算出了实际上总和的一半,因此比一半大即可
        vector<int> dp(target + 1, 0);
        
        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]);
            }
        }
        if (dp[target] == target) return true;
        return false;   
    }
};
动态规划:1049.最后一块石头的重量 II

本题其实就是尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小,这样就化解成01背包问题了

class Solution {
public:
    int lastStoneWeightII(vector<int>& stones) {
        int sum = 0;
        for (int stone : stones)
            sum += stone;
        int half = sum / 2;
        vector<int> dp(half + 1, 0);
        for (int i = 0; i < stones.size(); i++) {
            for (int j = half; j >= stones[i]; j--) {
                dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
            }
        }
        // 与 416 仅返回值的处理不同
        // 此时dp[half]中存放最接近但一定小于等于一半重量的石头,另一堆重量是sum - dp[half]
        return sum - 2 * dp[half];
    }
};

② 问装满背包有几种方法:

dp[j] += dp[j - nums[i]] ,注意到有累加求和,对应题目如下:

动态规划:494.目标和
class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        // 本质是找两堆数,差为target,也可以剪枝回溯,但是carl这版的思路还需要在理解一下
        // 假设正数和为x,即将但还未添加负号的负数和为sum - x,目标和target = x - (sum - x)
        // 则 x = (target + sum) / 2,为背包大小
        int sum = accumulate(nums.begin(), nums.end(), 0);
        if (abs(target) > sum) return 0;
        if ((target + sum) % 2 == 1) return 0;
        int bagSize = (target + sum) / 2;
        vector<int> dp(bagSize + 1, 0);
        // 只有0,无论正负都只有一种方法
        dp[0] = 1;
        // 0-1背包一维数组遍历准则,先物品再背包,且背包倒序
        for (int i = 0; i < nums.size(); i++) {
            for (int j = bagSize; j >= nums[i]; j--) {
                dp[j] += dp[j - nums[i]];
            }
        }
        return dp[bagSize];
    }
};
动态规划:518. 零钱兑换 II

不需要对零钱排序

class Solution {
public:
    int change(int amount, vector<int>& coins) {
        vector<int> dp(amount + 1, 0);
        dp[0] = 1;
        for (int i = 0; i < coins.size(); i++) {
            for (int j = coins[i]; j <= amount; j++) {
                dp[j] += dp[j - coins[i]];
            }
        }
        return dp[amount];
    }
};
动态规划:377.组合总和Ⅳ
class Solution {
public:
    // 仅仅问个数,不需要输出全部组合,因此不建议用回溯
    int combinationSum4(vector<int>& nums, int target) {
        vector<int> dp(target + 1, 0);
        dp[0] = 1;
        // 本题求排列数,就是外层for遍历背包,内层for循环遍历物品
        for (int i = 1; i <= target; i++) {
            for (int j = 0; j < nums.size(); j++) {
                // C++测试用例有两个数相加超过int的数据,所以需要在if里加上
                if (i - nums[j] >= 0 && dp[i] < INT_MAX - dp[i - nums[j]]) {
                    dp[i] += dp[i - nums[j]];
                }
            }
        }
        return dp[target];
    }
};
动态规划:70. 爬楼梯进阶版(完全背包)

每次上 1~m 阶,一共需要爬到 n 位置,有几种方式

class Solution {
public:
    int climbStairs(int n) {
        return generalClimbStairs(2, n);
    }
    
    int generalClimbStairs(int m, int n) {
        // m物品n完全背包
        vector<int> dp(n + 1, 0);
        dp[0] = 1;
        // 求组和,先背包再物品
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= m; j++) {
                if (i - j >= 0) {
                    dp[i] += dp[i - j];
                }
            }
        }
        return dp[n];
    }
};

③ 问背包装满最大价值:

dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); ,对应题目如下:

动态规划:474.一和零

【难】这题的dp数组含义不好想。遍历方式虽然是先物品再背包,但是物品是以str形式遍历,且遍历背包时,背包有两个维度。

class Solution {
public:
    int findMaxForm(vector<string>& strs, int m, int n) {
        // dp[i][j]含义,最多有i个0和j个1的strs的最大子集的大小为dp[i][j]
        vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
        // 遍历物品
        for (string str : strs) {
            int zeroNum = 0, oneNum = 0;
            for (char c : str) {
                if (c == '0') zeroNum++;
                else oneNum++;
            }
            // 倒序遍历背包,背包有两个维度
            for (int i = m; i >= zeroNum; i--) {
                for (int j = n; j >= oneNum; j--) {
                    dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
                }
            }
        }
        return dp[m][n];
    }
};

④ 问装满背包所有物品的最小个数:

dp[j] = min(dp[j], dp[j - coins[i]] + 1); ,对应题目如下:

动态规划:322.零钱兑换
class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        // 本题求最少
        vector<int> dp(amount + 1, INT_MAX);
        dp[0] = 0;
        // 本题求钱币最小个数,那么钱币有顺序和没有顺序都可以,都不影响钱币的最小个数
        // 所以本题并不强调集合是组合还是排列,先物品后背包
        for (int i = 0; i < coins.size(); i++) {
            for (int j = coins[i]; j <= amount; j++) {
                if (dp[j - coins[i]] != INT_MAX) { // 如果dp[j - coins[i]]是初始值则跳过
                    dp[j] = min(dp[j - coins[i]] + 1, dp[j]);
                }
            }
        }
        return dp[amount] == INT_MAX ? -1 : dp[amount];
    }
};
动态规划:279.完全平方数
class Solution {
public:
    int numSquares(int n) {
        vector<int> dp(n + 1, INT_MAX);
        dp[0] = 0;
        // 求最小数,for的内外顺序无所谓
        for (int i = 1; i * i <= n; i++) { // 遍历物品
            for (int j = i * i; j <= n; j++) { // 遍历背包
                dp[j] = min(dp[j - i * i] + 1, dp[j]);
            }
        }
        return dp[n];
    }
};

⑤ 单词拆分:

如果 dp[j] = true,且 [j, i] 这个区间子串出现在字典,那么 dp[i]也一定是true(j < i )

139. 单词拆分 - 力扣(LeetCode)
class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
        vector<bool> dp(s.size() + 1, false);
        // 无意义,完全是为了初始化
        dp[0] = true;
        // 本题是求排列,因为字符串现后顺序不能乱
        // 先遍历 背包,再遍历物品
        for (int i = 1; i <= s.size(); i++) {
            for (int j = 0; j < i; j++) {
                string word = s.substr(j, i - j);
                // 如果dp[j] = true,且 [j, i] 这个区间子串出现在字典,那么dp[i]也一定是true(j < i )
                if (wordSet.find(word) != wordSet.end() && dp[j]) {
                    dp[i] = true;
                }
            }
        }
        return dp[s.size()];
    }
};

3、遍历顺序分类总结

① 0-1背包

【少见】二维dp数组01背包先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历

【常用】一维dp数组01背包 只能先遍历物品再遍历背包容量,且第二层for循环是从大到小遍历,防止重复!

② 完全背包

对纯完全背包,两个for循环先后循序无所谓,那么题目稍有变化,可就有所谓了

【少】纯完全背包的一维dp数组实现,先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历

如果求组合数就是外层for循环遍历物品,从小到大,内层for遍历背包,从小到大

如果求排列数就是外层for遍历背包,从小到大,内层for循环遍历物品,从小到大

相关题目之前已经介绍过,再次总结如下:

  • 求组合数:动态规划:518.零钱兑换II
  • 求排列数:动态规划:377. 组合总和 Ⅳ、动态规划:70. 爬楼梯进阶版(完全背包)、动态规划:139. 单词拆分

如果求最小数,那么两层for循环的先后顺序就无所谓了,相关题目如下:

  • 求最小数:动态规划:322. 零钱兑换、动态规划:279.完全平方数

4、多重背包

每件物品最多有Mi件可用,把Mi件摊开,按每行只有一件物品排开,其实就是一个01背包问题了。多重背包在面试中基本不会出现,力扣上也没有对应的题目,大家对多重背包的掌握程度知道它是一种01背包,并能在01背包的基础上写出对应代码就可以了。

  • 时间复杂度:O(m × n × k),m:物品种类个数,n背包容量,k单类物品数量
void test_multi_pack() {
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    vector<int> nums = {2, 3, 2};
    int bagWeight = 10;
    for (int i = 0; i < nums.size(); i++) {
        while (nums[i] > 1) { // nums[i]保留到1,把其他物品都展开
            weight.push_back(weight[i]);
            value.push_back(value[i]);
            nums[i]--;
        }
    }

    vector<int> dp(bagWeight + 1, 0);
    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]);
        }
        for (int j = 0; j <= bagWeight; j++) {
            cout << dp[j] << " ";
        }
        cout << endl;
    }
    cout << dp[bagWeight] << endl;

}

int main() {
    test_multi_pack();
}

三、打家劫舍

198. 打家劫舍 - 力扣(LeetCode)

从一维数组上打劫

class Solution {
public:
    int rob(vector<int>& nums) {
        if (nums.size() == 0) return 0;
        if (nums.size() == 1) return nums[0];
        vector<int> dp(nums.size(), 0);
        dp[0] = nums[0];
        dp[1] = max(nums[0], nums[1]);
        // 后面的状态依赖前面,一定是从前向后推出
        for (int i = 2; i < nums.size(); i++) {
            dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
        }
        return dp[nums.size() - 1];
    }
};

213. 打家劫舍 II - 力扣(LeetCode)

从环形一维数组上打劫

class Solution {
public:
    // 不用%,二是考虑两种情况
    int rob(vector<int>& nums) {
        int size = nums.size();
        if (size == 0) return 0;
        if (size == 1) return nums[0];
        return max(robRange(nums, 0, size - 2), robRange(nums, 1, size - 1));
    }
    
    // 仅对[begin, end]区间打劫
    int robRange(vector<int>& nums, int begin, int end) {
        if (begin == end) return nums[begin];
        vector<int> dp(nums.size(), 0);
        dp[begin] = nums[begin];
        dp[begin + 1] = max(nums[begin], nums[begin + 1]);
        // 后面的状态依赖前面,一定是从前向后推出
        for (int i = begin + 2; i <= end; i++) {
            dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
        }
        return dp[end];
    }
};

【树形dp + 记忆化递归】337. 打家劫舍 III - 力扣(LeetCode)

从树的根节点上打劫

class Solution {
public:
    int rob(TreeNode* root) {
        vector<int> result = robTree(root);
        return max(result[0], result[1]);
    }
    
    // 树形dp,{偷当前节点最大打劫值,不偷当前节点最大打劫值}
    vector<int> robTree(TreeNode* cur) {
        if (cur == nullptr) return vector<int>{0, 0};
        // 记录左右子树最大打劫值
        // 后序遍历,左、右
        vector<int> left = robTree(cur->left);
        vector<int> right = robTree(cur->right);
        // 根
        // 偷cur,那么就不能偷其左右节点
        int val1 = cur->val + left[0] + right[0];
        // 不偷cur,那么可以偷也可以不偷左右节点,则取较大的情况
        int val2 = max(left[0], left[1]) + max(right[0], right[1]);
        return {val2, val1};
    }
};

class Solution {
public:
    // 记忆化递归
    unordered_map<TreeNode* , int> umap; // 记录计算过的结果
    
    int rob(TreeNode* root) {
        if (root == NULL) return 0;
        if (root->left == NULL && root->right == NULL) return root->val;
        
        if (umap[root] != 0) return umap[root]; // 如果umap里已经有记录则直接返回
        // 偷父节点
        int val1 = root->val;
        if (root->left) val1 += rob(root->left->left) + rob(root->left->right); // 跳过root->left
        if (root->right) val1 += rob(root->right->left) + rob(root->right->right); // 跳过root->right
        // 不偷父节点
        int val2 = rob(root->left) + rob(root->right); // 考虑root的左右孩子
        umap[root] = max(val1, val2); // umap记录一下结果
        return max(val1, val2);
    }
};

四、买卖股票

总结

1、根据题目中所包含的状态数确定二维 dp 数组的列数,如309题,包含了4种状态和7种转移方程,因此二维dp数组有4列

2、对于仅有买入卖出状态,dp[i] [0]表示第 i 天持有股票时的最大资产,dp[i] [1]表示第 i 天不持有股票时的最大资产

动态规划:121.买卖股票的最佳时机,只能买卖一次

还可以贪心,求左最小和右最大的差值

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int len = prices.size();
        // 只需要记录 当前天的dp状态和前一天的dp状态就可以了,可以使用滚动数组来节省空间
        // 开一个2*2的数组即可
        vector<vector<int>> dp(len, vector<int>(2, 0));
        // 第1天持有股票,必定是先买入尚未盈利,第1天不持有股票,不买就不亏
        dp[0][0] = -prices[0];
        dp[0][1] = 0;
        // 第2天开始
        for (int i = 1; i < len; i++) {
            // 持有股票时的最大资产,是前一天持有股票时的资产递推到今天/或是今天刚买入
            dp[i][0] = max(dp[i - 1][0], -prices[i]);
            // 不持有股票时的最大资产,是前一天不持有股票时的资产递推到今天/或是今天刚卖出
            dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);
        }
        // 实际上,本题一定是最后不持有股票的资产高
        return max(dp[len - 1][1], dp[len - 1][0]);
    }
};

动态规划:122.买卖股票的最佳时机II,可以买卖多次

还可以贪心,拆分利润,累加每天的利润和

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int len = prices.size();
        vector<vector<int>> dp(len, vector<int>(2, 0));
        dp[0][0] = -prices[0];
        dp[0][1] = 0;
        for (int i = 1; i < len; i++) {
            dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i]); // 注意这里是和上一题的唯一不同
            dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);
        }
        // 最后一定是不持有股票(全部卖出)的资产高
        return dp[len - 1][1];
    }
};

动态规划:123.买卖股票的最佳时机III,最多买卖两次

第i天,一共有五个状态

  1. 没有操作 (其实我们也可以不设置这个状态)
  2. 第一次持有股票
  3. 第一次不持有股票
  4. 第二次持有股票
  5. 第二次不持有股票
class Solution {
public:
    int maxProfit(vector<int>& prices) {
        if (prices.size() == 0) return 0;
        // 五个状态
        vector<vector<int>> dp(prices.size(), vector<int>(5, 0));
        // dp[0][0]不操作
        dp[0][1] = -prices[0]; // 第一次持有
        // dp[0][2] dp[0][4]第一、二次买卖仍为0
        dp[0][3] = -prices[0]; // 第二次持有

        for (int i = 1; i < prices.size(); i++) {
            dp[i][0] = dp[i - 1][0];
            dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] - prices[i]);
            dp[i][2] = max(dp[i - 1][2], dp[i - 1][1] + prices[i]);
            dp[i][3] = max(dp[i - 1][3], dp[i - 1][2] - prices[i]);
            dp[i][4] = max(dp[i - 1][4], dp[i - 1][3] + prices[i]);
        }
        // 第二次卖出手里所剩的钱一定是最多的
        return dp[prices.size() - 1][4];
    }
};

动态规划:188.买卖股票的最佳时机IV,最多买卖K次

第i天,一共有 n 个状态

0 没有操作 (其实我们也可以不设置这个状态)

1 第一次持有股票

2 第一次不持有股票

3 第二次持有股票

4 第二次不持有股票

5 …

除了0以外,偶数就是卖出,奇数就是买入

题目要求是至多有K笔交易,那么j的范围就定义为 2 * k + 1 就可以了

class Solution {
public:
    int maxProfit(int k, vector<int>& prices) {
        if (prices.size() == 0) return 0;
        // 一天最多两次交易,总交易数不超过 2 * k
        vector<vector<int>> dp(prices.size(), vector<int>(2 * k + 1, 0));
        // 奇数是买入,初始化;偶数就是卖出
        for (int j = 1; j < 2 * k; j += 2) {
            dp[0][j] = -prices[0];
        }
        for (int i = 1; i < prices.size(); i++) {
            // 对于第 i 天股票,每次买入、卖出两种情况,直到 k 笔交易均完成
            for (int j = 0; j < 2 * k - 1; j += 2) {
                dp[i][j + 1] = max(dp[i - 1][j + 1], dp[i - 1][j] - prices[i]);
                dp[i][j + 2] = max(dp[i - 1][j + 2], dp[i - 1][j + 1] + prices[i]);
            }
        }
        // 一定是交易数多的、最后卖出时获利最大
        return dp[prices.size() - 1][2 * k];
    }
};

动态规划:309.最佳买卖股票时机含冷冻期,可以买卖多次,卖出后有一天冷冻期

具体可以区分出如下四个状态:

  • 状态一:持有股票状态(今天买入股票,或者是之前就买入了股票然后没有操作,一直持有)
  • 不持有股票状态,这里就有两种卖出股票状态
    • 状态二:保持卖出股票的状态(两天前就卖出了股票,度过一天冷冻期。或者是前一天就是卖出股票状态,一直没操作)
    • 状态三:今天卖出股票
  • 状态四:今天为冷冻期状态,但冷冻期状态不可持续,只有一天!

j的状态为:

  • 0:状态一
  • 1:状态二
  • 2:状态三
  • 3:状态四

【笔记】动态规划总结 2.0_第2张图片

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int n = prices.size();
        if (n == 0) return 0;
        vector<vector<int>> dp(n, vector<int>(4, 0));
        dp[0][0] -= prices[0]; // 持股票
        for (int i = 1; i < n; i++) {
            dp[i][0] = max(dp[i - 1][0], max(dp[i - 1][3] - prices[i], dp[i - 1][1] - prices[i]));
            dp[i][1] = max(dp[i - 1][1], dp[i - 1][3]);
            dp[i][2] = dp[i - 1][0] + prices[i];
            dp[i][3] = dp[i - 1][2];
        }
        // 最后结果是取 状态二,状态三,和状态四的最大值
        // 状态四是冷冻期,最后一天如果是冷冻期也可能是最大值
        return max(dp[n - 1][3], max(dp[n - 1][1], dp[n - 1][2]));
    }
};

动态规划:714.买卖股票的最佳时机含手续费,可以买卖多次,卖出时有手续费

仅比122多了手续费

class Solution {
public:
    int maxProfit(vector<int>& prices, int fee) {
        int n = prices.size();
        vector<vector<int>> dp(n, vector<int>(2, 0));
        dp[0][0] = -prices[0]; // 持股票
        for (int i = 1; i < n; i++) {
            dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i]);
            dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i] - fee); // 仅需要减去手续费
        }
        return max(dp[n - 1][0], dp[n - 1][1]);
    }
};

五、子序列

引用自:代码随想录-动态规划总结篇

1、子序列(不连续)

300. 最长递增子序列 - 力扣(LeetCode),基于数组
class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        if (nums.size() == 1) return 1;
        vector<int> dp(nums.size(), 1);
        int result = 0;
        // dp[i]表示i之前包括i的,以nums[i]结尾的最长递增子序列的长度
        for (int i = 1; i < nums.size(); i++) {
            for (int j = 0; j < i; j++) {
                // 位置i的最长升序子序列等于0到j(j < i)的最长升序子序列 + 1 和dp[i]的最大值
                if (nums[i] > nums[j])
                    dp[i] = max(dp[i], dp[j] + 1);
            }
            result = max(result, dp[i]);
        }
        return result;
    }
};
1143. 最长公共子序列 - 力扣(LeetCode),基于字符串

为了初始化方便,不需要额外操作,本题dp数组的定义比较难理解,且是在遇到text1[i - 1] == text2[j - 1]时给dp[i] [j]赋值,相当于是从左上方到右下方转移

class Solution {
public:
    int longestCommonSubsequence(string text1, string text2) {
        int len1 = text1.size(), len2 = text2.size();
        // dp[i][j]:以下标i - 1为结尾的txet1,和以下标j - 1为结尾的text2,最长离散子序列的长度
        // dp[0][0]虽无意义,但只有为 0 时才符合后面的递推公式
        vector<vector<int>> dp(len1 + 1, vector<int>(len2 + 1, 0));
        for (int i = 1; i <= len1; i++) {
            for (int j = 1; j <= len2; j++) {
                if (text1[i - 1] == text2[j - 1])
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                else
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
            }
        }
        // 不需要result,直接返回最大的dp
        return dp[len1][len2];
    }
};
1035. 不相交的线 - 力扣(LeetCode),与1143题一样
class Solution {
public:
    int maxUncrossedLines(vector<int>& A, vector<int>& B) {
        vector<vector<int>> dp(A.size() + 1, vector<int>(B.size() + 1, 0));
        for (int i = 1; i <= A.size(); i++) {
            for (int j = 1; j <= B.size(); j++) {
                if (A[i - 1] == B[j - 1])
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                else
                    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
            }
        }
        return dp[A.size()][B.size()];
    }
};

2、子序列(连续)

674. 最长连续递增序列 - 力扣(LeetCode),基于数组
class Solution {
public:
    int findLengthOfLCIS(vector<int>& nums) {
        vector<int> dp(nums.size() ,1);
        int result = 1;
        for (int i = 1; i < nums.size(); i++) {
            if (nums[i] > nums[i - 1]) { // 连续记录
                dp[i] = dp[i - 1] + 1;
            }
            result = max(dp[i], result);
        }
        return result;
    }
};
718. 最长重复子数组 - 力扣(LeetCode),返回两个数组中公共的 、长度最长的子数组的长度
class Solution {
public:
    int findLength(vector<int>& nums1, vector<int>& nums2) {
        int len1 = nums1.size(), len2 = nums2.size();
        // dp[i][j] 以下标i - 1为结尾的A,和以下标j - 1为结尾的B,最长重复子数组长度为dp[i][j]
        // dp[0][0]虽无意义,但只有为0时才符合后面的递推公式
        vector<vector<int>> dp(len1 + 1, vector<int>(len2 + 1, 0));
        int result = 0;
        for (int i = 1; i <= len1; i++) {
            for (int j = 1; j <= len2; j++) {
                if (nums1[i - 1] == nums2[j - 1])
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                result = max(result, dp[i][j]);
            }
        }
        return result;
    }
};
53. 最大子数组和 - 力扣(LeetCode),求连续子数组最大和,也可以使用滑动窗口,前缀和来做
class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        vector<int> dp(nums.size(), 0);
        dp[0] = nums[0];
        int result = dp[0];
        for (int i = 1; i < nums.size(); i++) {
            // dp[i] 有两种「选择」,要么与前面的相邻子数组连接,形成一个和更大的子数组
            // 要么不与前面的子数组连接,自成一派,自己作为一个子数组
            dp[i] = max(dp[i - 1] + nums[i], nums[i]);
            result = max(result, dp[i]);
        }
        return result;
    }
};

3、编辑距离(由三道基础题目引出)

392. 判断子序列 - 力扣(LeetCode)

【仅删除】判断s是否是t的子序列

class Solution {
public:
    bool isSubsequence(string s, string t) {
        // if (s.size() == 0) return true;
        // if (t.size() == 0) return false;
        // dp[i] [j]表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度
        vector<vector<int>> dp(s.size() + 1, vector<int>(t.size() + 1, 0));
        for (int i = 1; i <= s.size(); i++) {
            for (int j = 1; j <= t.size(); j++) {
                if (s[i - 1] == t[j - 1])
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                // 由于判断 s 是否为 t 的子序列,一定是 t 不匹配回退
                else
                    dp[i][j] = dp[i][j - 1];
            }
        }
        if (dp[s.size()][t.size()] == s.size()) return true;
        return false;
    }
};
115. 不同的子序列 - 力扣(LeetCode)

【仅删除】计算t在s的子序列中的出现次数,即s中有多少种删除方式,可以使得剩下的子串为t

class Solution {
public:
    int numDistinct(string s, string t) {
        // 初始化第一行,s为空时,怎么删除都不能包含t,因此0方法,只有dp[0] [0]有一种方法,后面会覆盖到
        vector<vector<uint64_t>> dp(s.size() + 1, vector<uint64_t>(t.size() + 1, 0));
        // 初始化第一列,即s中的各子串有几种删除方法,删除之后等于空,显然每个子串只有全删才是空,一种方法
        for (int i = 0; i <= s.size(); i++) {
            dp[i][0] = 1;
        }
        for (int i = 1; i <= s.size(); i++) {
            for (int j = 1; j <= t.size(); j++) {
                // s t 添加了相同的字符时,有两个来源,不好理解
                // 不用该元素进行匹配
                if (s[i - 1] == t[j - 1])
                    dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
                // 不相等时肯定无法匹配,相当于s删除了当前这个元素后进行匹配
                else
                    dp[i][j] = dp[i - 1][j];
            }
        }
        return dp[s.size()][t.size()];
    }
};
583. 两个字符串的删除操作 - 力扣(LeetCode)

【仅删除】给定两个单词 word1word2 ,返回使得 word1word2 相同所需的最小步数

class Solution {
public:
    int minDistance(string word1, string word2) {
        // dp[i] [j]:以i-1为结尾的字符串word1,和以j-1位结尾的字符串word2,想要达到相等,所需要删除元素的最少次数
        vector<vector<int>> dp(word1.size() + 1, vector<int>(word2.size() + 1, 0));
        // 第一列,word1 删除直到空的最小次数
        // 第一列,word2 删除直到空的最小次数
        for (int i = 0; i <= word1.size(); i++) dp[i][0] = i;
        for (int j = 0; j <= word2.size(); j++) dp[0][j] = j;

        for (int i = 1; i <= word1.size(); i++) {
            for (int j = 1; j <= word2.size(); j++) {
                // 相等不用删,上一步的状态直接转移过来
                if (word1[i - 1] == word2[j - 1]) 
                    dp[i][j] = dp[i - 1][j - 1];
                // 不相等,要么删除第一个,要么删除第二个,取最小值
                // 同时删 dp[i - 1][j - 1] + 2 ,被包含,可不写出
                else
                    dp[i][j] = min({dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + 2});
                
            }
        }
        return dp[word1.size()][word2.size()];
    }
};
72. 编辑距离 - 力扣(LeetCode)

【删除、添加、替换】给你两个单词 word1word2, 请返回将 word1 转换成 word2 所使用的最少操作数

class Solution {
public:
    int minDistance(string word1, string word2) {
        // dp 为最小的编辑距离
        vector<vector<int>> dp(word1.size() + 1, vector<int>(word2.size() + 1, 0));
        // 子串删除至空串、空串添加至子串的操作次数,添加、删除本质上操作一样,后面看作一类
        for (int i = 0; i <= word1.size(); i++) dp[i][0] = i;
        for (int j = 0; j <= word2.size(); j++) dp[0][j] = j;

        for (int i = 1; i <= word1.size(); i++) {
            for (int j = 1; j <= word2.size(); j++) {
                // 相等不用操作
                if (word1[i - 1] == word2[j - 1]) {
                    dp[i][j] = dp[i - 1][j - 1];
                }
                // 不等,用删除(即添加)和替换的操作的最小值更新
                // 替换不需要增删,仅在原来的基础上多一次操作
                else {
                    dp[i][j] = min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]}) + 1;
                }
            }
        }
        return dp[word1.size()][word2.size()];
    }
};
2022建行Fintech大赛第二道编程题:
// 文本相似度 = 1 - 最小编辑距离 / 最长的字符串长度
// 每次插入、删除、增加操作使得最小编辑距离 + 1
// 输出保留两位小数
#include
using namespace std;

int editDistance(string s1, string s2) {
    int len1 = s1.size(), len2 = s2.size();
    vector<vector<int>> dp(len1 + 1, vector<int>(len2 + 1, 0));
    for (int i = 1; i <= len1; i++) {
        dp[i][0] = i;
    }
    for (int j = 1; j <= len2; j++) {
        dp[0][j] = j;
    }
    for (int i = 1; i <= len1; i++) {
        for (int j = 1; j <= len2; j++) {
            if (s1[i - 1] == s2[j - 1])
                dp[i][j] = dp[i - 1][j - 1];
            else
                dp[i][j] = min(dp[i - 1][j] + 1, min(dp[i][j - 1] + 1, dp[i - 1][j - 1] + 1));
        }
    }
    return dp[len1 - 1][len2 - 1];
}

int main(){
    string s1 = "abcde", s2 = "ace";  
    int maxLen = max(s1.size(), s2.size());
    // 两数相除,只有分子本身是double结果才能显示小数,否则强制转换都没用
    double similarity = 1 - (double)editDistance(s1, s2) / maxLen;
    // 使用fixed(不用则只保留有效数字)配合setprecision(2)来确保cout输出保留两位小数
    cout << fixed << setprecision(2) <<similarity << endl;
    return 0;
}

4、回文串

很多这种子序列相关的题目,在定义dp数组的时候很自然就会想题目求什么,我们就如何定义dp数组。绝大多数题目确实是这样,不过本题如果我们定义,dp[i] 为下标i结尾的字符串有dp[i]个回文串的话,我们会发现很难找到递归关系。dp[i] 和 dp[i-1] ,dp[i + 1] 看上去都没啥关系。所以我们要看回文串的性质。

我们在判断字符串S是否是回文,那么如果我们知道 s[1],s[2],s[3] 这个子串是回文的,那么只需要比较 s[0]和s[4]这两个元素是否相同,如果相同的话,这个字符串s 就是回文串。

那么此时我们是不是能找到一种递归关系,也就是判断一个子字符串(字符串的下表范围[i,j])是否回文,依赖于,子字符串(下表范围[i + 1, j - 1])) 是否是回文。

647. 回文子串 - 力扣(LeetCode),连续,定义布尔dp
class Solution {
public:
    int countSubstrings(string s) {
        // 布尔类型的dp[i] [j]:表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i] [j]为true,否则为false
        // 还未匹配 全部初始化为false
        vector<vector<bool>> dp(s.size(), vector<bool>(s.size(), false));
        int result = 0;
        // 由下向上 由左到右
        for (int i = s.size() - 1; i >= 0; i--) {
            for (int j = i; j < s.size(); j++) {
                if (s[i] == s[j]) {
                    // i j指向同一个元素,当然是回文子串
                    // 下标i 与 j相差为1,例如aa,也是回文子串
                    // 三个字符,只要首尾相同,不论中间是什么,必是回文串
                    if (j - i <= 2) {
                        result++;
                        dp[i][j] = true;
                    } else if (dp[i + 1][j - 1]){
                        // i j间隔多个元素时,需要看[i+1,j-1]是否回文
                        result++;
                        dp[i][j] = true;
                    }

                }
            }
        }
        return result;
    }
};

class Solution {
public:
    // 双指针法空间复杂度低,首先确定回文串,找中心然后向两边扩散看是不是对称的
    // 在遍历中心点的时候,要注意中心点有两种情况,一个元素可以作为中心点,两个元素也可以作为中心点
    int countSubstrings(string s) {
        int result = 0;
        for (int i = 0; i < s.size(); i++) {
            result += extend(s, i, i, s.size()); // 以i为中心,至少返回1
            result += extend(s, i, i + 1, s.size()); // 以i和i+1为中心,至少返回0
        }
        return result;
    }
    int extend(const string& s, int i, int j, int n) {
        int res = 0;
        while (i >= 0 && j < n && s[i] == s[j]) {
            i--;
            j++;
            res++;
        }
        return res;
    }
};
5. 最长回文子串 - 力扣(LeetCode),连续,定义布尔dp

贴一个自己根据“五部曲”来分析的dp题解:https://leetcode.cn/problems/longest-palindromic-substring/solution/by-joeyma-nqf0/

返回string子串,用双指针法

class Solution {
public:
    int maxLength = INT_MIN;
    int left = 0;
    int right = 0;
    string str = "";

    string longestPalindrome(string s) {
        int result = 0;
        for (int i = 0; i < s.size(); i++) {
            extend(s, i, i, s.size()); // 以i为中心,至少返回1
            extend(s, i, i + 1, s.size()); // 以i和i+1为中心,至少返回0
        }
        return s.substr(left, maxLength);
    }

    void extend(const string& s, int i, int j, int n) {
        while (i >= 0 && j < n && s[i] == s[j]) {
            if (j - i + 1 > maxLength) {
                // 每当遇到更大的子串就记录长度和子串,这样会调用太多次substr
                // 也可仅记录长度,最后再返回子串
                maxLength = j - i + 1;
                left = i;
                right = j;
            }
            i--;
            j++;
        }
    }
};
516. 最长回文子序列 - 力扣(LeetCode),不连续

仅返回子序列长度int,不返回string,优先使用dp

class Solution {
public:
    int longestPalindromeSubseq(string s) {
        // dp[i] [j]:字符串s在[i, j]范围内最长的回文子序列的长度为dp[i] [j]
        vector<vector<int>> dp(s.size(), vector<int>(s.size(), 0));
        // 递推公式计算不到 i 和j相同时候的情况,所以需要手动初始化一下
        for (int i = 0; i < s.size(); i++) dp[i][i] = 1;
        // 遍历顺序由下到上,由左到右
        for (int i = s.size() - 1; i >= 0; i--) {
            for (int j = i + 1; j < s.size(); j++) {
                // 本题要比求回文子串简单一点,因为情况少了一点
                if (s[i] == s[j]) {
                    dp[i][j] = dp[i + 1][j - 1] + 2;
                } else {
                    // 如果s[i]与s[j]不相同,说明s[i]和s[j]的同时加入并不能增加[i,j]区间回文子序列的长度,那么分别加入s[i]、s[j]看看哪一个可以组成最长的回文子序列,取最大值
                    dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
                }
            }
        }
        // 返回值是第一行最后一个数
        return dp[0][s.size() - 1];
    }
};
132. 分割回文串 II - 力扣(LeetCode),子串,连续

1、首先用 647 5 的二维dp数组,记录[i,j]区间内是否是子串,便于后续切割时候做判断

2、dp[i]:范围是[0, i]的回文子串,最少分割次数是dp[i],可用一个最大值全部初始化,但 dp[0] = 0,表明长度为1,不需切割,本身就是回文串

如果分割后,区间[j + 1, i]是回文子串(用二维数组快速判断),那么dp[i] 就等于 dp[j] + 1,或者是dp[i],二者取较小值

class Solution {
public:
    // 返回int,推荐dp,否则可以用记忆化回溯
    int minCut(string s) {
        int n = s.size();
        if (n == 1) return 0;
        // 布尔类型的dp[i] [j]:表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i] [j]为true,否则为false
        // 647 5 的dp定义
        vector<vector<bool>> isPalindromic(n, vector<bool>(n, false));
        for (int i = n - 1; i >= 0; i--) {
            for (int j = i; j < n; j++) {
                // i j指向同一个元素,当然是回文子串;下标i 与 j相差为1,例如aa,也是回文子串;三个字符,只要首尾相同,不论中间是什么,必是回文串
                // i j间隔多个元素时,才需要看[i+1,j-1]是否回文
                if (s[i] == s[j] && (j - i <= 2 || isPalindromic[i + 1][j - 1])) {
                    isPalindromic[i][j] = true;
                }
            }
        }

        // dp[i]:范围是[0, i]的回文子串,最少分割次数是dp[i]
        vector<int> dp(n);
        // 初始化为最多的分割次数,这样才能被后面覆盖
        for (int i = 0; i < n; i++) dp[i] = i;

        for (int i = 1; i < n; i++) {
            // 首先检测以 0 开头字符串是否回文,更新dp
            if (isPalindromic[0][i]) {
                dp[i] = 0;
                continue;
            }
            // 然后在0~i中遍历每一个分割点 j
            for (int j = 0; j < i; j++) {
                // dp[j]已有[0,j]的最小分割次数,若[j+1,i]是回文串,更新dp
                if (isPalindromic[j + 1][i]) {
                    dp[i] = min(dp[i], dp[j] + 1);
                }
            }
        }
        return dp[s.size() - 1];
        
    }
};

5、补充题目

673. 最长递增子序列的个数 - 力扣(LeetCode)

比 300 难,需要维护两个数组,dp[i]:i之前(包括i)最长递增子序列的长度为dp[i](即300题数组),count[i]:以nums[i]为结尾的字符串,最长递增子序列的个数为count[i]

class Solution {
public:
    int findNumberOfLIS(vector<int>& nums) {
        int n = nums.size();
        if (n == 1) return 1;
        int maxCount = 0;
        // 均初始化为最小值1
        // 以i为结尾子序列,最长递增子序列长度
        vector<int> dp(n, 1);
        // 以i为结尾子序列,最长递增子序列个数
        vector<int> count(n, 1);
        for (int i = 1; i < n; i++) {
            for (int j = 0; j < i; j++) {
                if (nums[i] > nums[j]) {
                    // count 更新思路,不好理解
                    // 找到了一个更长的递增子序列,那么j为结尾的子串的最长递增子序列的个数,就是最新的以i为结尾的子串的最长递增子序列的个数
                    if (dp[j] + 1 > dp[i]) {
                        count[i] = count[j];
                    }
                    // 找到了两个相同长度的递增子序列,那么以i为结尾的子串的最长递增子序列的个数 就应该加上以j为结尾的子串的最长递增子序列的个数
                    else if (dp[j] + 1 == dp[i]) {
                        count[i] += count[j];
                    }
                    // dp 更新同 300 思路
                    dp[i] = max(dp[i], dp[j] + 1);
                }
                if (dp[i] > maxCount) maxCount = dp[i];
            }
        }
        // maxCount可能由多个,需要逐个统计出来
        int result = 0;
        for (int i = 0; i < nums.size(); i++) {
            if (maxCount == dp[i]) 
                result += count[i];
        }
        return result;
    }
};

你可能感兴趣的:(动态规划,算法,leetcode)