LeetCode 题解随笔:动态规划(一)

目录

零、前言

一、基础题目

509. 斐波那契数

70. 爬楼梯

746. 使用最小花费爬楼梯[*]

62. 不同路径

63. 不同路径 II

343. 整数拆分[*]​​​​​​

96. 不同的二叉搜索树

二、背包问题

2.1 01背包

二维DP数组01背包

一维滚动数组01背包

补充:二维DP数组的降维

416. 分割等和子集

1049. 最后一块石头的重量 II

494. 目标和[*]

474. 一和零[*]

2.2 完全背包

518. 零钱兑换 II[*]

377. 组合总和 Ⅳ

322. 零钱兑换

279. 完全平方数

 139. 单词拆分[*]

2.3 多重背包

2.4 总结

三、打家劫舍

198. 打家劫舍

213. 打家劫舍 II[*]

337. 打家劫舍 III[*]

四、股票问题

121. 买卖股票的最佳时机

122. 买卖股票的最佳时机 II

123. 买卖股票的最佳时机 III[*]

188. 买卖股票的最佳时机 IV

309. 最佳买卖股票时机含冷冻期

714. 买卖股票的最佳时机含手续费

五、子序列问题

5.1 子序列(不连续)

300. 最长递增子序列[*]

1143. 最长公共子序列

 1035. 不相交的线

5.2 子序列(连续)

674. 最长连续递增序列

718. 最长重复子数组[*]

53. 最大子数组和

5.3 编辑距离

392. 判断子序列

115. 不同的子序列[*]

583. 两个字符串的删除操作

72. 编辑距离[*]

5.4 回文

647. 回文子串[*]

516. 最长回文子序列[*]

1312. 让字符串成为回文串的最少插入次数

六、博弈问题

486. 预测赢家

877. 石子游戏


零、前言

Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。动态规划中每一个状态是由上一个状态推导出来的,这一点区别于贪心,贪心没有状态推导,而是从局部直接选最优的。

动态规划解题步骤

  1. 确定dp数组(dp table)以及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 举例推导dp数组

动态规划问题Debug方式

写代码之前把状态转移在dp数组的上具体情况模拟一遍;如果代码没通过就打印dp数组,思考状态转移公式及dp数组初始化是否正确。

一、基础题目

509. 斐波那契数

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];
    }

本题dp数组下标表示第i个数,值为斐波那契数列,状态转移方程 dp[i] = dp[i - 1] + dp[i - 2]。

70. 爬楼梯

int climbStairs(int n) {
        if (n <= 1)    return n;
        int dp[2];
        dp[0] = 1;
        dp[1] = 2;
        for (int i = 3; i <= n; i++) {
            int sum = dp[0] + dp[1];
            dp[0] = dp[1];
            dp[1] = sum;
        }
        return dp[1];
    }

dp[i]:对于第i阶楼梯,有dp[i]种爬法。

到第i阶楼梯的所有情况都可以归纳为以下两种:若先到i-2阶,已知dp[i-2],再爬两阶楼梯就可以到达i;若已知dp[i-1],再爬一阶楼梯就可以到达i。因此递推公式为:dp[i] = dp[i - 1] + dp[i - 2],从前向后遍历。(上述代码已经优化了空间复杂度)

本题其实是完全背包问题,完全背包的排列问题代码如下:

int climbStairs(int 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];
    }

746. 使用最小花费爬楼梯[*]

int minCostClimbingStairs(vector<int>& cost) {
        // dp[i]:从台阶i向上爬,需要花费的最小体力
        vector<int> dp(cost.size());
        // 初始化
        dp[0] = cost[0];
        dp[1] = cost[1];
        for (int i = 2; i < cost.size(); i++) {
            //从i向上爬,一定要花费当前的cost[i],并从i-1和i-2处选一个花费最小的位置
            dp[i] = cost[i] + min(dp[i - 1], dp[i - 2]);
        }
        // 选从倒数第一阶还是倒数第二阶到达顶层
        return min(dp[cost.size() - 1], dp[cost.size() - 2]);
    }

dp[i]:从台阶i向上爬,需要花费的最小体力。

尤其注意返回值,dp的值表示已经从i向上出发了,因此不需要再加上cost[i-1]和cost[i-2]。

62. 不同路径

int uniquePaths(int m, int n) {
        // dp[m][n]:到达(m,n)的路径数
        vector<vector<int>> dp(m, vector<int>(n, 0));
        // 初始化
        for (int i = 0; i < m; i++) {
            dp[i][0] = 1;
        }
        for (int j = 0; j < n; j++) {
            dp[0][j] = 1;
        }
        // 状态转移
        for (int i = 1; i < m; i++) {
            for (int j = 1; j < n; j++) {
                dp[i][j] = dp[i][j - 1] + dp[i - 1][j];
            }
        }
        return dp[m - 1][n - 1];
    }

dp[m][n]:到达(m,n)的路径数;

初始化:由于只能下右下走,第一行和第一列上所有位置的可能路径数一定都是1;

需要注意二维数组的定义方式

63. 不同路径 II

int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
        // dp[m][n]:到达(m,n)的路径数
        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++) {
                // 如果此处有石头,直接置0
                if (obstacleGrid[i][j] == 1) {
                    dp[i][j] = 0;
                }
                else {
                    // 有障碍物的dp值为0,不影响结果
                    dp[i][j] = dp[i][j - 1] + dp[i - 1][j];
                }
            }
        }
        return dp[obstacleGrid.size() - 1][obstacleGrid[0].size() - 1];
    }

本题与上题相比,首先是初始化时,若遇到障碍物,则从此以后所有位置的值都为0;状态更新时,若遇到该地点为障碍物,直接置0。

343. 整数拆分[*]​​​​​​

int integerBreak(int n) {
        // dp[i]:i拆分后乘积的最大值,下标从0至n
        vector<int> dp(n + 1);
        // 初始化
        dp[2] = 1;
        for (int i = 3; i <= n; i++) {
            for (int j = 1; j < i - 1; j++) {
                dp[i] = max(dp[i], max(j * (i - j), j * dp[i - j]));
            }
        }
        return dp[n];
    }

正整数可以拆分成至少两个正整数的和。令 k 是拆分出的第一个正整数,则剩下的部分是 n-k,n-k可以不拆分,也可以继续拆分成至少两个正整数的和。因此每个正整数对应的最大乘积取决于比它小的正整数对应的最大乘积,可以使用动态规划求解。

dp[i]:正整数i经拆分后的最大乘积;

对每个正整数i,从j=1到j=i-2开始遍历,计算不拆分(j*(i-j))/拆分j*dp[i-j]两种情况下的最大值,并取j=1到j=i-2中的最大值作为dp[i]。要使递推公式能够进行下去,从前向后一定要现有dp[i-j]再有dp[i],由于i-j最大为i-1,而递推时,i之前的元素的结果均已被计算过了,所以递推公式可以顺利进行。

96. 不同的二叉搜索树

int numTrees(int n) {
        // dp[i]:i个正整数对应的二叉搜索树个数
        vector<int> dp(n + 1, 0);
        // 初始化(这里初始化dp[0] = 1以便于计算左右子树为空的情况)
        dp[0] = 1;
        dp[1] = 1;
        // 递推公式:dp[i] = 1*dp[n-1] + dp[1]*dp[n-2] + ... + dp[n-2]*dp[1] + dp[n-1]*1
        for (int i = 2; i <= n; i++) {
            // 变换根节点,从左子树为空到左子树有n-1个元素
            for (int j = 0; j < i; j++) {              
                dp[i] += dp[j] * dp[i - 1 - j];
            }
        }
        return dp[n];
    }

如下图所示(来源:代码随想录),n个整数的搜索二叉树,分别更换根节点,则左子树元素数从0至n-1变化,相应的右子树元素数从n-1至0变化。左右子树的节点构成的搜索二叉树个数决定了n个整数的搜索二叉树个数,因此可以采用动态规划完成。

96.不同的二叉搜索树2

 dp[i]:i个整数构成的搜索二叉树个数

递推公式:dp[i] = 1*dp[n-1] + dp[1]*dp[n-2] + ... + dp[n-2]*dp[1] + dp[n-1]*1


二、背包问题

背包问题总结如下图所示(图片来源:代码随想录):

416.分割等和子集1

2.1 01背包

二维DP数组01背包

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]);

初始化

  1. 如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0;
  2. dp[0][j],即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。当 j < weight[0],dp[0][j] = 0;当j >= weight[0],dp[0][j] = value[0]。

一维滚动数组01背包

二维数组解决01背包问题的递推公式可以被压缩为:dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]),从而可以舍弃维度i,递推公式变为:

dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

dp[j]:容量为j的背包,所背的物品价值可以最大为dp[j];

递推公式的含义为:dp[j]有两个选择,一个是取自己dp[j],相当于二维dp数组中的dp[i-1][j],即不放物品i;另一个是取dp[j - weight[i]] + value[i],即放物品i

初始化:dp[0] = 0,因为背包容量为0所背的物品的最大价值就是0。 

dp数组遍历顺序:先遍历物品嵌套遍历背包容量,背包容量倒序遍历。

补充:二维DP数组的降维

适用条件:在状态转移方程中,如果计算状态 dp[i][j] 需要的都是 dp[i][j] 相邻的状态,那么就可以使用空间压缩技巧;一般是把第一个维度(也就是 i 这个维度)去掉,只剩下 j 这个维度。

思考两个问题:

1、在对 dp[j] 赋新值之前,dp[j] 对应着二维 dp 数组中的什么位置?【dp[j] 的值是外层 for 循环上一次迭代算出来的值,也就是对应二维 dp 数组中 dp[i-1][j] 的位置】

2、dp[j-1] 对应着二维 dp 数组中的什么位置?【dp[j-1] 的值是内层 for 循环上一次迭代算出来的值,也就是对应二维 dp 数组中 dp[i][j-1] 的位置】

思考二维数组遍历过程中,哪些元素被覆盖掉了,在其被覆盖之前用临时变量保存之。

或采用倒序遍历。原因是:本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖。

416. 分割等和子集

bool canPartition(vector<int>& nums) {
        int sum = accumulate(nums.begin(), nums.end(), 0);
        if (sum % 2 != 0)    return false;
        else    sum = sum / 2;
        // dp[j]:背包总容量是j,最大可以凑成j的子集总和为dp[j]
        // 1 <= nums.length <= 200  ; 1 <= nums[i] <= 100 ; 从而最大和为20000
        vector<int> dp(10001, 0);
        // 遍历每个数装入背包
        for (int i = 0; i < nums.size(); i++) {
            // 更新不同容量下背包内子集和的最大值
            for (int j = sum; j >= nums[i]; j--) {
                dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
            }
        }
        // 判断sum容量下背包内最大子集和能否达到sum
        if (dp[sum] == sum) {
            return true;
        }
        return false;
    }

将本题转化为背包问题:背包容量为sum/2(若sum为奇数可以直接返回false),求该容量下装入数组元素的最大子集和,若最大子集和等于容量sum/2,则返回true。

dp[j] : 背包总容量是j,装入元素的最大子集总和为dp[j];

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

本题,相当于背包里放入数值,物品i的重量是nums[i],其价值也是nums[i]。

1049. 最后一块石头的重量 II

int lastStoneWeightII(vector<int>& stones) {
        // 构建背包容量为石头总重量的一半
        int sum = accumulate(stones.begin(), stones.end(), 0);
        int target = sum / 2;
        vector<int> dp(target + 1, 0);
        // 遍历石块,装入容量为j的背包
        for (int i = 0; i < stones.size(); i++) {
            // 递推公式来源:可选装入石块i或不装入石块i
            for (int j = target; j >= stones[i]; j--) {
                dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
            }
        }
        // dp[target]: 半容量背包所装最大石块重量;sum - dp[target]:剩余一半石块重量(向下取整,后者一定大于前者)
        return sum - dp[target] - dp[target];
    }

本题目标是让石头分成尽可能重量相同的两堆,这样相撞之后剩下的石头最小。从而本题和上一题类似,构建一个半容量的背包,求取半容量背包的最大子集和,进而计算剩余半重量石块和半容量背包内的最大子集和的差值。

494. 目标和[*]

int findTargetSumWays(vector<int>& nums, int target) {
        int sum = accumulate(nums.begin(), nums.end(), 0);
        int capacity = 0;
        // 无解
        if (abs(target) > sum)    return 0;
        if ((sum + target) % 2 != 0)    return 0;
        else    capacity = (sum + target) / 2;
        // dp[j]:填满容量为j的包有dp[j]种方法
        vector<int> dp(capacity + 1, 0);
        // 初始化:装满容量为0的背包有一种方法,即不装任何物品
        dp[0] = 1;
        for (int i = 0; i < nums.size(); i++) {
            for (int j = capacity; j >= nums[i]; j--) {
                dp[j] += dp[j - nums[i]];
            }
        }
        return dp[capacity];
    }

本题转化为背包问题:若正数和为x,则减数和为sum-x,从而有x-(sum-x) = target,即x = (sum + target) / 2。构建一个容量为(sum + target) / 2的背包,寻找装满该背包的方法数即可。

本题类型为组合问题

dp[j]:填满容量为j的包有dp[j]种方法;

递推公式:要推出dp[j],不考虑nums[i]的情况下,填满容量为j - nums[i]的背包,有dp[j - nums[i]]种方法。只要搞到nums[i]的话,凑成dp[j]就有dp[j - nums[i]] 种方法。

例如:dp[j],j 为5,

  • 已经有一个1(nums[i]) 的话,有 dp[4]种方法 凑成 dp[5]。
  • 已经有一个2(nums[i]) 的话,有 dp[3]种方法 凑成 dp[5]。
  • 已经有一个3(nums[i]) 的话,有 dp[2]中方法 凑成 dp[5]
  • 已经有一个4(nums[i]) 的话,有 dp[1]中方法 凑成 dp[5]
  • 已经有一个5 (nums[i])的话,有 dp[0]中方法 凑成 dp[5]

即凑成dp[5]就是把所有的 dp[j - nums[i]] 累加起来。

可以记住组合问题的递推公式一般为

dp[j] += dp[j - nums[i]]

初始化:装满容量为0的背包有一种方法,即不装任何物品,即dp[0] = 1。

474. 一和零[*]

int findMaxForm(vector<string>& strs, int m, int n) {
        // dp[i][j]: 至多有i个0和j个1的物品数量
        vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
        // 循环遍历字符串,装入背包
        for (auto str : strs) {
            // 统计每个字符串的0、1个数
            int zeroNum = 0;
            int oneNum = 0;
            for (auto s : str) {
                if (s == '0')    zeroNum++;
            }
            oneNum = str.size() - zeroNum;
            // dp状态转移公式[这是一个二维背包]
            for (int i = m; i >= zeroNum; i--) {
                for (int j = n; j >= oneNum; j--) {
                    //  dp[i][j]更新有两种选择:
                    // 1.不要当前str(因已超过数量),不需要新增zeroNum和oneNum,dp[i][j]是上一轮的结果
                    // 2.要当前str,则至多有i - zeroNum个0和j - oneNum个1的背包结果加1即可
                    dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
                }
            }
        }
        return dp[m][n];
    }

本题难度较高,且难以想到如何构建对应的背包问题。切不可迷惑,本题不是完全背包问题,而是01背包问题,物品是strs数组中的数量,每件物品的数量都为1。但本题的背包容量有两个维度,分别是m和n。

dp[i][j]: 至多有i个0和j个1的物品数量【单个背包,但背包容量有两个数组!仍可视为之前的一维滚动数组问题】

递推公式:首先需要遍历每个字符串,分别计算出其0的数量和1的数量,构成数组(zeroNum, oneNum),这个数值对即为每件物品的weight。

dp[i][j]更新有两种选择:
1.不要当前str(因已超过数量限制),不需要新增zeroNum和oneNum,dp[i][j]是上一轮的结果
2.要当前str,则至多有i - zeroNum个0和j - oneNum个1的背包结果加1即可

2.2 完全背包

完全背包问题和01背包问题不同之处:每种物品有无限件。求解时的不同体现在遍历顺序上。

完全背包的物品是可以添加多次的,所以内循环要从小到大正序遍历

518. 零钱兑换 II[*]

int change(int amount, vector<int>& coins) {            
        // dp[j]:容量为j的钱包能够装的最大钱数
        vector<int> dp(amount + 1, 0);
        // 初始化:容量为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. 组合总和 Ⅳ

int combinationSum4(vector<int>& nums, int target) {
        // dp[j]:容量为j的背包装入序列的最大和
        vector<int> dp(target + 1, 0);
        // 初始化:容量为0有一种方案,即什么都不装
        dp[0] = 1;
        // 遍历顺序:物品顺序影响结果,外层容量内层物品
        for (int j = 0; j <= target; j++) {
            for (int i = 0; i < nums.size(); i++) {
                if (j - nums[i] >= 0 && dp[j] < INT32_MAX - dp[j - nums[i]]) {
                    dp[j] += dp[j - nums[i]];
                }   
            }
        }
        return dp[target];
    }

组合问题:外层for循环遍历物品,内层for遍历背包。

排列问题:外层for遍历背包,内层for循环遍历物品。[注意此时内循环的判断条件]

322. 零钱兑换

int coinChange(vector<int>& coins, int amount) {
        // dp[j]:凑足j的金额需要的最少硬币数
        vector<int> dp(amount + 1, INT32_MAX);
        // 初始化:凑0元不需要硬币,其他值要赋最大值,否则后续无法被覆盖
        dp[0] = 0;
        // 循环顺序不影响结果,可以先遍历物品
        for (int i = 0; i < coins.size(); i++) {
            // 物品可以重复,正向遍历
            for (int j = coins[i]; j <= amount; j++) {
                if (dp[j - coins[i]] != INT32_MAX) {
                    dp[j] = min(dp[j], dp[j - coins[i]] + 1);
                } 
            }
        }
        // 结果没有被更新,说明无解
        if (dp[amount] == INT32_MAX)     return -1;
        return dp[amount];
    }

dp[j]:凑足j的金额需要的最少硬币数

注意本题除dp[0]外其它元素都被设置为INT_MAX,若元素没有被更新,则说明无解。

279. 完全平方数

int numSquares(int n) {
        // dp[j]:和为j的完全平方数的最小数量
        vector<int> dp(n + 1, INT_MAX);
        dp[0] = 0;
        // 物品(即完全平方数)的最大下标,对应价值为i^2
        int maxIndex = sqrt(n);
        for (int i = 1; i <= maxIndex; i++) {
            // 完全背包问题,元素可以重复
            for (int j = i * i; j <= n; j++) {
                dp[j] = min(dp[j], dp[j - i * i] + 1);
            }
        }
        return dp[n];
    }

dp[j]:和为j的完全平方数的最小数量

本题背包中的物品为完全平方数,遍历物品时for循环的条件可改为:

for (int i = 1; i * i <= n; i++)

 139. 单词拆分[*]

bool wordBreak(string s, vector<string>& wordDict) {
        // 便于查找子串是否存在于wordDict
        unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
        // dp[j]:长度为j的字符串是否可以划分为目标子串
        vector<bool> dp(s.size() + 1, false);
        // 初始化:长度为0的字符串可以被划分为目标子串
        dp[0] = true;
        // 先遍历背包,从1开始
        for (int j = 1; j <= s.size(); j++) {
            for (int i = 0; i < wordDict.size(); i++) {
                if (j >= wordDict[i].size()) {
                    string word = s.substr(j - wordDict[i].size(), wordDict[i].size());
                    if (wordSet.find(word) != wordSet.end() && dp[j - wordDict[i].size()]) {
                        dp[j] = true;
                    }
                }
            }
        }
        return dp[s.size()];
    }

物品:wordDict中的单词;背包:容量为s.size()

dp[j]:长度为j的字符串是否可以划分为目标子串;

递推公式:遍历至wordDict中第i个单词时,若dp[j - wordDict[i].size()]为true,且s.substr(j - wordDict[i].size(), wordDict[i].size())在wordDict中(即是wordDict[i]),说明dp[j] = true;

初始化:dp[0] = true,否则递推公式无法继续进行。

本题由于目标字符串出现顺序影响结果,因此采用先遍历容量,再遍历物品的方式。一个技巧是利用set便于判断子串是否出现在目标字符串中。

2.3 多重背包

多重背包问题是指某种物品最多有Mi件可以用,将其摊开,视作Mi个单独的物品,即转化为了01背包问题。

2.4 总结

  • 能否能装满背包(或者最多装多少):dp[j] = max(dp[j], dp[j - nums[i]] + nums[i])
  • 装满背包有几种方法:dp[j] += dp[j - nums[i]]
  • 背包装满最大价值:dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
  • 装满背包所有物品的最小个数:dp[j] = min(dp[j - coins[i]] + 1, dp[j])

三、打家劫舍

198. 打家劫舍

int rob(vector<int>& nums) {
        if (nums.size() == 1)  return nums[0];
        // dp[i]:考虑在房间0-j内可获得的最高金额
        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]取决于两种情况:
            // 1.偷房屋i,则最高金额为考虑0~(i-2)房间时可获得的最高金额+nums[i]。因此时不能考虑房屋i-1了
            // 2.不偷房屋i,则最高金额为考虑0~(i-1)房间时可获得的最高金额
            dp[i] = max(nums[i] + dp[i - 2], dp[i - 1]);
        }
        return dp[nums.size() - 1];
    }

dp[i]:考虑在房间0-j内可获得的最高金额

递推公式

  1. 偷房屋i,则最高金额为考虑0~(i-2)房间时可获得的最高金额+nums[i]。因此时不能考虑房屋i-1了
  2. 不偷房屋i,则最高金额为考虑0~(i-1)房间时可获得的最高金额

根据递推公式可以看出,需要初始化dp[0]和dp[1]。

213. 打家劫舍 II[*]

int rob(vector<int>& nums) {
        if (nums.size() == 0)  return 0;
        if (nums.size() == 1)  return nums[0];
        int res1 = robRange(nums, 0, nums.size() - 2);
        int res2 = robRange(nums, 1, nums.size() - 1);
        return max(res1, res2);
    }
    int robRange(vector<int>& nums, int start, int end) {
        if (start == end)  return nums[start];
        // dp[i]:考虑在房间start-end内可获得的最高金额
        vector<int> dp(nums.size(), 0);
        // 初始化:
        dp[start] = nums[start];
        dp[start + 1] = max(nums[start], nums[start + 1]);
        // 递推公式
        for (int i = start + 2; i < nums.size(); i++) {
            // dp[i]取决于两种情况:
            // 1.偷房屋i,则最高金额为考虑0~(i-2)房间时可获得的最高金额+nums[i]。因此时不能考虑房屋i-1了
            // 2.不偷房屋i,则最高金额为考虑0~(i-1)房间时可获得的最高金额
            dp[i] = max(nums[i] + dp[i - 2], dp[i - 1]);
        }
        return dp[end];
    }

数组成环可分为两种情况:

  1. 考虑首元素,不考虑尾元素;
  2. 考虑尾元素,不考虑首元素。

上述“考虑”,表示首尾元素在选择范围内,而不表明一定进入了该房屋。因此复用上一题的代码,分别计算选择范围为0~(n-2)和1~(n-1)时的结果,选取最大值即可。

337. 打家劫舍 III[*]

int rob(TreeNode* root) {
        vector<int> res = Traversal(root);
        return  max(res[0], res[1]);
    }
    // 状态转移容器需要记录偷/不偷当前节点时,可获得的最大金额
    // v[0]:不偷;v[1]:偷
    vector<int> Traversal(TreeNode* node) {
        if (!node)   return { 0,0 };
        // 后序遍历
        vector<int> left = Traversal(node->left);
        vector<int> right = Traversal(node->right);
        // 递推公式:两种情况
        // 1.确定偷当前节点,则左右节点均不能被偷
        int res1 = node->val + left[0] + right[0];
        // 2.确定不偷当前节点,则可选左右节点是否被偷
        int res0 = max(left[0], left[1]) + max(right[0], right[1]);
        return { res0,res1 };
    }

本题是在树结构上完成动态规划,整体思路不难,但难点在于想到利用一个含两个元素的数组作为状态转移记录容器,分别记录确定偷/不偷当前节点时,可以获得的最大金额。利用这样一个容器,再进行递推就会比较顺利。


四、股票问题

121. 买卖股票的最佳时机

  • 贪心算法: 
int maxProfit(vector<int>& prices) {
        int low = INT_MAX;
        int res = 0;
        for (int i = 0; i < prices.size(); i++) {
            low = min(low, prices[i]);
            res = max(res, prices[i] - low);
        }
        return res;
    }

只能买卖一次,每次迭代时记录左侧的最小值,并更新右侧元素和最小值间的最大差值。

  • 动态规划[*]
int maxProfit(vector<int>& prices) {
        if (prices.size() == 0)    return 0;
        // dp[i][0]: 第i天不持有股票所得最多现金
        // dp[i][1]: 第i天持有股票所得最多现金
        vector<vector<int>> dp(prices.size(), vector<int>(2));
        // 初始化
        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        // 递推公式:两个状态各有两种情况
        // 1.第i天持有股票:i-1天已持有 或 第i天才买入
        // 2.第i天不持有股票:i-1天未持有 或 第i天卖出
        for (int i = 1; i < prices.size(); i++) {
            dp[i][0] = max(dp[i - 1][0], prices[i] + dp[i - 1][1]);
            dp[i][1] = max(dp[i - 1][1], -prices[i]);
        }
        // 最后一天一定没有持有股票了
        return dp[prices.size() - 1][0];
    }

注意本题构造的状态记录容器,也是一个二维数组,分别记录第i天持有/未持有时所得的最多现金。

dp[i][0]: 第i天不持有股票所得最多现金
dp[i][1]: 第i天持有股票所得最多现金

递推公式:两个状态各有两种情况

  1. 第i天持有股票:i-1天已持有【dp[i - 1][1]】 或 第i天才买入【-prices[i]】
  2. 第i天不持有股票:i-1天未持有【dp[i - 1][0]】 或 第i天卖出【prices[i] + dp[i - 1][1]】

初始化:根据递推公式,对dp[0][0]和dp[0][1]进行初始化即可。

122. 买卖股票的最佳时机 II

int maxProfit(vector<int>& prices) {
        if (prices.size() == 0)    return 0;
        // dp[i][0]: 第i天不持有股票所得最多现金
        // dp[i][1]: 第i天持有股票所得最多现金
        vector<vector<int>> dp(prices.size(), vector<int>(2));
        // 初始化
        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        // 递推公式:两个状态各有两种情况
        // 1.第i天持有股票:i-1天已持有 或 i-1天未持有而第i天买入
        // 2.第i天不持有股票:i-1天未持有 或 i-1天持有而第i天卖出
        for (int i = 1; i < prices.size(); i++) {
            dp[i][0] = max(dp[i - 1][0], prices[i] + dp[i - 1][1]);
            dp[i][1] = max(dp[i - 1][1], dp[i-1][0] - prices[i]);
        }
        // 最后一天一定没有持有股票了
        return dp[prices.size() - 1][0];
    }

本题和上一题的区别在于可以多次买入卖出,从而更新第i天持有股票的状态时,其包括两种情况:

i-1天已持有【dp[i - 1][1]】 或 i-1天未持有而第i天买入【dp[i-1][0] - prices[i]】

第二种情况要考虑i-1天未持有时的现金,而不是直接设为-prices[i]。更新一下上述状态转移公式即可。

123. 买卖股票的最佳时机 III[*]

int maxProfit(vector<int>& prices) {
        if (prices.size() == 0)    return 0;
        // dp[i][j]:第i天所拥有的股票状态为j时所拥有的最大现金
        // 0:暂无操作;
        // 1:已第一次买入;
        // 2:已第一次卖出;
        // 3:已第二次买入;
        // 4:已第二次卖出;
        vector<vector<int>> dp(prices.size(), vector<int>(5));
        // 初始化:
        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        // 卖出即为了收获利润,若利润小于零,没必要卖出。因此可把该状态初值置为0
        dp[0][2] = 0;
        // *第二次买入的状态依赖于第一次卖出的状态
        dp[0][3] = -prices[0];
        // 该状态初值的设置与状态二原因一致
        dp[0][4] = 0;
        // 递推公式
        for (int i = 1; i < prices.size(); i++) {
            // 始终无操作
            dp[i][0] = dp[i - 1][0];
            // 股票已第一次买入,有两种情况:第i-1天已第一次买入 / 第i天刚刚第一次买入
            dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] - prices[i]);
            // 股票已第一次卖出,有两种情况:第i-1天已第一次卖出 / 第i天刚刚第一次卖出
            dp[i][2] = max(dp[i - 1][2], dp[i - 1][1] + prices[i]);
            // 股票已第二次买入,有两种情况:第i-1天已第二次买入 / 第i天刚刚第二次买入
            dp[i][3] = max(dp[i - 1][3], dp[i - 1][2] - prices[i]);
            // 股票已第二次卖出,有两种情况:第i-1天已第二次卖出 / 第i天刚刚第二次卖出
            dp[i][4] = max(dp[i - 1][4], dp[i - 1][3] + prices[i]);
        }
        return dp[prices.size() - 1][4];
    }

动态规划类问题首先要分析清楚一共有几种状态,本题对应五种状态:

  •         0:暂无操作;
  •         1:已第一次买入;
  •         2:已第一次卖出;
  •         3:已第二次买入;
  •         4:已第二次卖出;

状态是可以描述当前以及前一时段内整体的情况的变量,它不等同于当天所做的操作。

另一个难点在于状态的初始化,尤其是2-4状态的初始化。基本原则是符合逻辑,且不影响后序迭代。

188. 买卖股票的最佳时机 IV

int maxProfit(int k, vector<int>& prices) {
        if (prices.size() == 0)    return 0;
        // dp[i][j]:第i天所拥有的股票状态为j时所拥有的最大现金
        // 0:暂无操作;
        // j = 2n-1:已第n次买入;
        // j = 2n:已第n次卖出;
        vector<vector<int>> dp(prices.size(), vector<int>(2 * k + 1, 0));
        // 初始化:所有买入操作状态初始为-prices[0],所有卖出操作状态或暂无操作状态置为0
        for (int j = 1; j < 2 * k; j += 2) {
            dp[0][j] = -prices[0];
        }
        // 递推公式
        for (int i = 1; i < prices.size(); i++) {
            dp[i][0] = dp[i - 1][0];
            for (int j = 1; j < 2 * k; j += 2) {
                // 第n次买入有两种情况:i-1天已第n次买入;第i天刚刚第n次买入
                dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - 1] - prices[i]);
                // 第n次卖出有两种情况:i-1天已第n次卖出;第i天刚刚第n次卖出
                dp[i][j + 1] = max(dp[i - 1][j + 1], dp[i - 1][j] + prices[i]);
            }
        }
        return dp[prices.size() - 1][2 * k];
    }

本题逻辑和上一题类似,将状态数组扩充为2k+1维即可。

309. 最佳买卖股票时机含冷冻期

int maxProfit(vector<int>& prices) {
        if (prices.size() <= 1)  return 0;
        // dp[i][j]:第i天股票状态为j时可获得的最大现金
        // 0:未持有股票,且不在冷冻期;
        // 1:已持有股票;
        // 2:卖出股票当日;
        // 3:卖出股票次日,即冷冻期;
        vector<vector<int>> dp(prices.size(), vector<int>(4));
        // 初始化:
        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        dp[0][2] = 0;
        dp[0][3] = 0;
        // 递推公式
        for (int i = 1; i < prices.size(); i++) {
            // 0有两种情况:第i-1天即未持有 / 第i-1天为冷冻期
            dp[i][0] = max(dp[i - 1][0], dp[i - 1][3]);
            // 1有两种情况:第i-1天已持有 / 第i天刚买入【第i-1天为状态0或状态3】
            dp[i][1] = max(dp[i - 1][1], max(dp[i - 1][0], dp[i - 1][3]) - prices[i]);
            // 2只可能第i-1天已持有
            dp[i][2] = dp[i - 1][1] + prices[i];
            // 3只可能第i-1天进行卖出操作
            dp[i][3] = dp[i - 1][2];
        }
        return max(max(dp[prices.size() - 1][0], dp[prices.size() - 1][2]), dp[prices.size() - 1][3]);
    }

可以自己模拟一下含冷冻期的股票交易,推出状态的数量:【持有股票】【卖出】【冷冻期】【暂未持有且不在冷冻期内】。因此存在上述四种状态,分别进行分析即可。

714. 买卖股票的最佳时机含手续费

int maxProfit(vector<int>& prices, int fee) {
        if (prices.size() <= 1)    return 0;
        // dp[i][j]:第i天股票状态为j时所获得的最大现金
        // 0:未持有股票
        // 1:持有股票
        vector<vector<int>> dp(prices.size(), vector<int>(2));
        // 初始化
        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        // 递推公式
        for (int i = 1; i < prices.size(); i++) {
            // 0有两种情况:第i-1天即未持有 / 第i天刚卖出
            dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i] - fee);
            // 1有两种情况:第i-1天已持有 / 第i天刚买入
            dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] - prices[i]);
        }
        return dp[prices.size() - 1][0];
    }

本题采用动态规划的逻辑相较于采用贪心算法更容易理解一些。


五、子序列问题

5.1 子序列(不连续)

300. 最长递增子序列[*]

int lengthOfLIS(vector<int>& nums) {
        // dp[i]:下标0至i最长严格递增子序列的长度(至少为1,因此初始化为1)
        vector<int> dp(nums.size(), 1);
        int res = 1;
        for (int i = 1; i < nums.size(); i++) {
            for (int j = 0; j < i; j++) {
                if (nums[i] > nums[j])    dp[i] = max(dp[i], dp[j] + 1);
            }
            res = max(res, dp[i]);
        }
        return res;
    }

dp[i]:下标0至i最长严格递增子序列的长度(至少为1,因此初始化为1)

递推公式:位置i的最长升序子序列等于j从0到i-1各个位置的最长升序子序列+1的最大值(前提是nums[i]>nums[j]时)。

1143. 最长公共子序列

int longestCommonSubsequence(string text1, string text2) {
        // dp[i+1][j+1]:以下标i和j结尾的子串的最长公共子序列长度
        vector<vector<int>> dp(text1.size() + 1, vector<int>(text2.size() + 1, 0));
        // 递推公式
        for (int i = 0; i < text1.size(); i++) {
            for (int j = 0; j < text2.size(); j++) {               
                if (text1[i] == text2[j]) {
                    dp[i + 1][j + 1] = dp[i][j] + 1;
                }
                else {
                    dp[i + 1][j + 1] = max(dp[i + 1][j], dp[i][j + 1]);
                }
            }
        }
        return dp[text1.size()][text2.size()];
    }

dp[i+1][j+1]:以下标i和j结尾的子串的最长公共子序列长度

递推公式

  •  text1[i] 与 text2[j]相同,找到了一个公共元素,所以dp[i+1][j+1] = dp[i][j] + 1;
  • text1[i] 与 text2[j]不相同,看text1[0, i - 1]与text2[0, j]的最长公共子序列 和 text1[0, i]与text2[0, j - 1]的最长公共子序列,取最大的。

【可以自己模拟一遍状态更新过程,就会比较清楚。dp[i][j]的更新来源如下图所示(来源:代码随想录)】

1143.最长公共子序列

 1035. 不相交的线

本题和上一题本质上是一样的,直线不能相交,即在字符串A中找到一个与字符串B相同的子序列,且这个子序列不能改变相对顺序。只要相对顺序不改变,连接相同数字的直线就不会相交。因此求可以绘制的最大连线数,其实就是求两个字符串的最长公共子序列的长度。

5.2 子序列(连续)

动态规划非常适合处理连续的子序列问题

674. 最长连续递增序列

  • 动态规划:
int findLengthOfLCIS(vector<int>& nums) {
        int res = 1;
        // dp[j]:下标0-j的序列最长连续递增子序列的长度
        vector<int> dp(nums.size(), 1);
        // 初始化
        dp[0] = 1;
        for (int i = 1; i < nums.size(); i++) {
            if (nums[i] > nums[i - 1]) {
                dp[i] = dp[i - 1] + 1;
                res = max(dp[i], res);
            }
        }
        return res;
    }
  • 贪心算法:
int findLengthOfLCIS(vector<int>& nums) {
        int res = 1;
        int count = 1;
        for (int i = 1; i < nums.size(); i++) {
            if (nums[i] > nums[i - 1]) {
                count++;
                res = max(count, res);
            }
            else    count = 1;
        }
        return res;
    }

718. 最长重复子数组[*]

int findLength(vector<int>& nums1, vector<int>& nums2) {
        int res = 0;
        // dp[i][j]:以下标i-1结尾的nums1数组和以下标j-1结尾的nums[2]数组最长重复子数组长度
        // 为构造递推公式,i、j要从1开始循环
        vector<vector<int>> dp(nums1.size() + 1, vector<int>(nums2.size() + 1));
        for (int i = 1; i <= nums1.size(); i++) {
            for (int j = 1; j <= nums2.size(); j++) {
                if (nums1[i - 1] == nums2[j - 1]) {
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                }
                res = max(dp[i][j], res);
            }
        }
        return res;
    }

dp[i][j] :以下标i - 1为结尾的数组1,和以下标j - 1为结尾的数组2,最长重复子数组长度。这里定义下标从i-1和j-1开始是为了便于后序构建递推公式;这样的定义在dp二维矩阵中可以留出初始化的区间,如下图所示(来源:代码随想录)

392.判断子序列

递推公式:当nums1[i - 1] 和nums2[j - 1]相等的时候,dp[i][j] = dp[i - 1][j - 1] + 1,从而i和j的循环要从1开始;

初始化:i=0和j=0时无意义,为了时递推能顺利进行,应赋值为0。

53. 最大子数组和

int maxSubArray(vector<int>& nums) {
        int res = nums[0];
        // dp[i]:在下标i处可以产生的最大子数组和【序列一定包含下标i元素】
        vector<int> dp(nums.size());
        dp[0] = nums[0];
        // 递推公式:继续累加 / 重新累加
        for (int i = 1; i < nums.size(); i++) {
            dp[i] = max(dp[i - 1] + nums[i], nums[i]);
            res = max(res, dp[i]);
        }
        // 结果不一定在i处产生,不能返回dp[nums.size()-1]
        return res;
    }

本题采用动态规划的思路比采用贪心算法更清晰,但易搞混的地方是状态转移数组的含义

dp[i]:在下标i处可以产生的最大子数组和【序列一定包含下标i元素】,因此dp[nums.size()-1]不一定是目标结果,要利用res在每次迭代时比较最大结果。

5.3 编辑距离

编辑距离题目:只需要计算删除的情况,不用考虑增加和替换的情况。

392. 判断子序列

  • 双指针法:
bool isSubsequence(string s, string t) {
        if (s.size() > t.size())   return false;
        int fast = 0;
        int slow = 0;
        for (; fast < t.size(); fast++) {
            if (s[slow] == t[fast]) {
                slow++;
            }
        }
        if (slow == s.size())   return true;
        else    return false;
    }
  • 动态规划:
// dp[i+1][j+1]:以下标i为结尾的字符串s,和以下标j为结尾的字符串t,相同子序列的长度为dp[i][j]
        vector<vector<int>> dp(s.size() + 1, vector<int>(t.size() + 1));
        for (int i = 0; i < s.size(); i++) {
            for (int j = 0; j < t.size(); j++) {
                // 找到了一个相同的字符,相同子序列长度在dp[i][j]的基础上加1
                if (s[i] == t[j]) {
                    dp[i + 1][j + 1] = dp[i][j] + 1;
                }
                // 没找到相同字符,相当于要删除t继续匹配,即看t前一位的匹配结果
                else {
                    dp[i + 1][j + 1] = dp[i + 1][j];
                }
            }
        }
        if (dp[s.size()][t.size()] == s.size())  return true;
        else    return false;

需要注意递推公式中不匹配的情况,相当于删除当前t中不匹配的字符,继续迭代。因此当前结果等同于dp[i + 1][j],即t前一位的匹配结果。

状态转移数组更新过程(来源:代码随想录):

392.判断子序列2

115. 不同的子序列[*]

int numDistinct(string s, string t) {
        // dp[i+1][j+1]:以下标j为结尾的字符串t,在以下标i为结尾的字符串s中出现的次数
        vector<vector<int>> dp(s.size() + 1, vector<int>(t.size() + 1));
        // 初始化:t长度为0时,认为在s中出现了1次(这也是为了满足后序迭代条件)
        for (int i = 0; i <= s.size(); i++) {
            dp[i][0] = 1;
        }
        // 递推公式
        for (int i = 0; i < s.size(); i++) {
            for (int j = 0; j < t.size(); j++) {
                // 若s的i和t的j字符匹配,需累加两种情况:(以匹配t=“bag”为例)
                // 1.s前一位结尾的字符串中包含t以j前一位结尾的字符串(ba)的个数
                // 2.s前一位结尾的字符串中包含t以j结尾的字符串(bag)的个数
                if (s[i] == t[j]) {
                    dp[i + 1][j + 1] = dp[i][j] + dp[i][j + 1];
                }
                // 不匹配时,删除s的当前字符继续迭代(即看s的前一位匹配情况)
                else    dp[i + 1][j + 1] = dp[i][j + 1];
            }
        }
        return dp[s.size()][t.size()];
    }

本题难度在于递推公式的构建和状态数组初始化。子序列问题一般都要考虑 s[i] == t[j] 和 s[i] = t[j] 的情况

dp[i+1][j+1]:以下标j为结尾的字符串t,在以下标i为结尾的字符串s中出现的次数

  • 若s的i和t的j字符匹配,需累加两种情况:(以匹配t=“bag”为例)
  1. s前一位结尾的字符串中包含t以j前一位结尾的字符串(ba)的个数【dp[i][j]】
  2. s前一位结尾的字符串中包含t以j结尾的字符串(bag)的个数【dp[i][j+1]】
  • 不匹配时,删除s的当前字符继续迭代(即看s的前一位匹配情况)

初始化时,dp[i][0] = 1,这是为了便于递推公式顺利进行。【此类问题自己模拟一遍,便于递推公式的构建】

583. 两个字符串的删除操作

int minDistance(string word1, string word2) {
        // dp[i+1][j+1]:0-i下标的word1的序列和0-j下标的word2的序列的最长公共子序列长度
        vector<vector<int>> dp(word1.size() + 1, vector<int>(word2.size() + 1));
        for (int i = 0; i < word1.size(); i++) {
            for (int j = 0; j < word2.size(); j++) {
                if (word1[i] == word2[j]) {
                    dp[i + 1][j + 1] = dp[i][j] + 1;
                }
                else    dp[i + 1][j + 1] = max(dp[i + 1][j], dp[i][j + 1]);
            }
        }
        return  (word1.size() + word2.size() - 2 * dp[word1.size()][word2.size()]);
    }

本题本质上即为求两个字符串最长的公共子序列长度。

72. 编辑距离[*]

int minDistance(string word1, string word2) {
        // dp[i+1][j+1]:下标0-i的word1转换成下标0-j的word2所使用的最小操作数
        vector<vector<int>> dp(word1.size() + 1, vector<int>(word2.size() + 1));
        // 初始化:dp[i][0]表示对word1字符全部删除
        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 = 0; i < word1.size(); i++) {
            for (int j = 0; j < word2.size(); j++) {
                // 1.若字符i与字符j相同,不用操作
                if (word1[i] == word2[j])    dp[i + 1][j + 1] = dp[i][j];
                // 不相同时,进行编辑操作
                else {
                    // word1删除一个元素:dp[i][j+1] + 1
                    // word1添加一个元素(相当于word2删除一个元素):dp[i+1][j] + 1
                    // word1替换一个元素(替换后满足word1[i] == word2[j]):dp[i][j] + 1
                    dp[i + 1][j + 1] = min({ dp[i][j + 1] ,dp[i + 1][j] ,dp[i][j] }) + 1;
                }
            }
        }
        return dp[word1.size()][word2.size()];
    }

dp[i+1][j+1]:下标0-i的word1转换成下标0-j的word2所使用的最小操作数

递推公式

  • 若字符i与字符j相同,不用操作:dp[i + 1][j + 1] = dp[i][j]
  • 不相同时,进行编辑操作:
  1. word1删除一个元素:dp[i][j+1] + 1
  2. word1添加一个元素(相当于word2删除一个元素):dp[i+1][j] + 1
  3. word1替换一个元素(替换后满足word1[i] == word2[j]):dp[i][j] + 1

初始化:dp[i][0]表示对word1字符全部删除;dp[0][j]表示对word2字符全部删除。

5.4 回文

647. 回文子串[*]

int countSubstrings(string s) {
        if (s.size() == 1)  return 1;
        // dp[i][j]:区间范围i-j内是否是回文串
        vector<vector<bool>> dp(s.size(), vector<bool>(s.size(), false));
        int res = 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一定是回文串
                    if (j - i <= 1) {
                        res++;
                        dp[i][j] = true;
                    }
                    else if (dp[i + 1][j - 1]) {
                        res++;
                        dp[i][j] = true;
                    }
                }
            }
        }
        return res;
    }

本题尤要注意状态数组的定义方式,利用dp记录下标i至j内字符串是否为回文串。

dp[i][j]:区间范围i-j内是否是回文串【从而j应从≥i处开始循环】

递推公式

  • 若s[i] != s[j],直接置为false即可(初始化为false,因此代码段中无需再写此步);
  • 若s[i] == s[j],有以下三种情况:
  1. i=j,即同一个字符。例如单独一个字符a,当然是回文子串。dp[i][j]置为true且res++;
  2. i和j紧挨着,那么也可以直接确定dp[i][j]为true。【情况1、2可以合并为if (j - i <= 1)】;
  3. 当j - i > 1时,dp[i][j]取决于i+1到j-1之间的字符串是否为回文串,即取决于dp[i + 1][j - 1]。

遍历顺序

本题遍历顺序的确定也尤为重要,鉴于dp[i][j]取决于dp[i + 1][j - 1],因此先有i+1和j-1,如下图所示(来源:代码随想录),应从下到上,从左到右进行遍历。

647.回文子串

 利用本题思想可以解决:5. 最长回文子串

516. 最长回文子序列[*]

// dp[i][j]:下标区间为i-j的最长回文子序列长度
        vector<vector<int>> dp(s.size(), vector<int>(s.size(), 0));
        // 初始化:i=j时,单字符最长回文子序列长度设为1
        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++) {
                // 若i字符和j字符相同,增加最长子序列的长度
                if (s[i] == s[j]) {
                    dp[i][j] = dp[i + 1][j - 1] + 2;
                }
                // 若不相同,
                else {
                    dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
                }
            }
        }
        return dp[0][s.size() - 1];

回文子串要求是连续的,回文子序列可以不是连续的。

  • 如果s[i]与s[j]相同,那么dp[i][j] = dp[i + 1][j - 1] + 2【图片来源:代码随想录】

516.最长回文子序列

  • 如果s[i]与s[j]不相同,说明s[i]和s[j]的同时加入 并不能增加[i,j]区间回文子串的长度,那么分别加入s[i]、s[j]看看哪一个可以组成最长的回文子序列,即 max(dp[i + 1][j], dp[i][j - 1])

516.最长回文子序列1

 状态更新过程如下图所示【可以自己递推一遍】:

516.最长回文子序列2

1312. 让字符串成为回文串的最少插入次数

int longestCommonSubsequence(string text1, string text2) {
        // dp[i+1][j+1]:以下标i和j结尾的子串的最长公共子序列长度
        // 定义为i+1和j+1是为了预留出初始化的空间
        vector<vector<int>> dp(text1.size() + 1, vector<int>(text2.size() + 1, 0));
        // 递推公式
        for (int i = 0; i < text1.size(); ++i) {
            for (int j = 0; j < text2.size(); ++j) {
                if (text1[i] == text2[j]) {
                    dp[i + 1][j + 1] = dp[i][j] + 1;
                }
                // 若不相同,看text1增加一个元素后与text2的最长公共子序列 / text2增加一个元素后与text1的最长公共子序列
                else {
                    dp[i + 1][j + 1] = max(dp[i + 1][j], dp[i][j + 1]);
                }
            }
        }
        return dp[text1.size()][text2.size()];
    }
    int minInsertions(string s) {
        // 等价问题:s和其反序字符串t的最长公共子序列
        string t(s.rbegin(), s.rend());
        int longest_common_subsequence = longestCommonSubsequence(s, t);
        return s.size() - longest_common_subsequence;
    }

六、博弈问题

486. 预测赢家

bool PredictTheWinner(vector<int>& nums) {
        // dp[i][j]: 表示当数组剩下的部分为下标i到下标j时,当前玩家与另一个玩家的分数之差的最大值(当前玩家不一定先手)
        // 只有当 i≤j 时,数组剩下的部分才有意义,因此当i>j时,dp[i][j] = 0
        vector<vector<int>>dp (nums.size(), vector<int>(nums.size(), 0));
        // 初始化
        for (int i = 0; i < nums.size(); i++) {
            dp[i][i] = nums[i];
        }
        // 当i<j时,当前玩家可以选择nums[i]或nums[j]。当前玩家会选择最优方案
        for (int i = nums.size() - 2; i >= 0; i--) {
            for (int j = i + 1; j < nums.size(); j++) {
                dp[i][j] = max(nums[i] - dp[i + 1][j], nums[j] - dp[i][j - 1]);
            }
        }
        return dp[0][nums.size() - 1] >= 0;
    }

dp[i][j]:当数组剩下的部分为下标 i 到下标 j 时,即在下标范围 [i,j] 中,当前玩家与另一个玩家的分数之差的最大值,注意当前玩家不一定是先手。

877. 石子游戏

bool stoneGame(vector<int>& piles) {
        int length = piles.size();
        // dp[i][j]: 表示当数组剩下的部分为下标i到下标j时,做出操作会获得的最大分差
        // 注意:当前玩家不一定是先手 Alice
        vector<vector<int>>dp(length, vector<int>(length, 0));
        // 初始化:只剩下一堆时,做出操作获得分差即为拿走这堆石子的数量
        for (int i = 0; i < length; ++i) {
            dp[i][i] = piles[i];
        }
        // 递推公式:拿走最前一堆 / 最后一堆
        // 由递推公式可以看出,当前状态依赖于左下方状态,因此应从左下向右上(--i,++j)递推
        for (int i = length - 2; i >= 0; --i) {
            for (int j = i + 1; j < length; ++j) {
                dp[i][j] = max(piles[i] - dp[i + 1][j], piles[j] - dp[i][j - 1]);
            }
        }
        return dp[0][length - 1] > 0;
    }

博弈问题的前提一般都是在两个聪明人之间进行,编程描述这种游戏的一般方法是二维 dp 数组。

之所以这样设计,是因为先手在做出选择之后,就成了后手,后手在对方做完选择后,就变成了先手。这种角色转换使得我们可以重用之前的结果,典型的动态规划标志

七、其它题目

1223. 掷骰子模拟[*]

static constexpr int mod = 1E9 + 7;
    int dieSimulator(int n, vector<int>& rollMax) {
        // d[i][j][k]: 表示已经完成了 i 次掷骰子,第 i 次掷的是 j,并且(本次还没投掷时)已经连续掷了 k 次 j 的合法序列数
        // 1 <= n <= 5000
        // rollMax.length == 6
        // 1 <= rollMax[i] <= 15
        // 为便于表示:骰子存储在下标0~5中
        vector<vector<vector<int>>> dp(n + 1, vector<vector<int>>(6, vector<int>(16, 0)));

        // 初始化:只掷了一次骰子,出现连续一次j标号的序列仅一个
        for (int j = 0; j < 6; ++j) {
            dp[1][j][1] = 1;
        }

        // 递推公式
        // 第i次投掷
        for (int i = 2; i <= n; ++i) {
            // 第i次投掷的结果
            for (int j = 0; j <= 5; ++j) {
                // 还未进行本次投掷时,当前的连续次数
                for (int k = 1; k <= rollMax[j]; ++k) {
                    // 枚举当前这一次投掷出的骰子下标 r
                    for (int r = 0; r <= 5; ++r) {
                        // r != j:不相同,r无法再累计。累加r出现一次的合法数
                        if (r != j) {
                            dp[i][r][1] = (dp[i][r][1] + dp[i - 1][j][k]) % mod;
                        }
                        // r == j,并且由于已经连续掷了k次,k + 1 <= rollMax[j]才合法:累加上次投掷时连续k次的结果
                        else if (r == j && k + 1 <= rollMax[j]) {
                            dp[i][r][k + 1] = (dp[i][r][k + 1] + dp[i - 1][j][k]) % mod;
                        }
                    }
                }
            }
        }
        
        // 累加结果
        int res = 0;
        for (int j = 0; j <= 5; ++j) {
            for (int k = 1; k <= rollMax[j]; ++k) {
                res = (res + dp[n][j][k]) % mod;
            }
        }
        return res;
    }

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值