动态规划DP问题详解,超全,思路全收集
1. 01背包问题 (01 Knapsack Problem)
问题描述: 有 N 件物品和一个容量为 V 的背包。第 i 件物品的体积是 v[i],价值是 w[i]。求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
-
第一步:dp[i][j] 的定义是:
dp[i][j]表示从前i件物品中任意选择,放入容量为j的背包中所能获得的最大价值。 -
第二步:为了达到 dp[i][j],最后一步可能是:
这取决于我们是否选择第i件物品。- 不放入第
i件物品:那么dp[i][j]的值就等于将前i-1件物品放入容量为j的背包中的最大价值,即dp[i-1][j]。 - 放入第
i件物品:前提是背包的容量j必须大于或等于第i件物品的体积v[i]。此时的价值等于将前i-1件物品放入容量为j - v[i]的背包中的最大价值,再加上第i件物品的价值w[i],即dp[i-1][j - v[i]] + w[i]。
- 不放入第
-
第三步:因此,状态转移方程是:
dp[i][j] = max(dp[i-1][j], dp[i-1][j - v[i]] + w[i])(需要保证j >= v[i])。 -
第四步:初始条件是:
dp[0][j] = 0,表示不选择任何物品时,总价值为0。
dp[i][0] = 0,表示背包容量为0时,无法放入任何物品,总价值为0。
2. 最长上升子序列 (Longest Increasing Subsequence, LIS)
问题描述: 给定一个无序的整数数组,找到其中最长上升子序列的长度。
-
第一步:dp[i] 的定义是:
dp[i]表示以数组中第i个元素nums[i]结尾的最长上升子序列的长度。 -
第二步:为了达到 dp[i],最后一步可能是:
nums[i]是这个最长上升子序列的最后一个元素。它必然是接在前面某个元素nums[j](其中j < i且nums[j] < nums[i]) 之后构成的。 我们需要遍历所有在i之前的元素j,找到一个能让dp[i]最大的dp[j]。 -
第三步:因此,状态转移方程是:
dp[i] = max(dp[j]) + 1,其中0 <= j < i且nums[j] < nums[i]。 -
第四步:初始条件是:
dp[i] = 1对于所有的i。因为每个元素自身都可以构成一个长度为1的上升子序列。
3. 最长公共子序列 (Longest Common Subsequence, LCS)
问题描述: 给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。
-
第一步:dp[i][j] 的定义是:
dp[i][j]表示字符串text1的前i个字符(text1[0...i-1])与text2的前j个字符(text2[0...j-1])的最长公共子序列的长度。 -
第二步:为了达到 dp[i][j],最后一步可能是:
这取决于text1的第i个字符 (text1[i-1]) 和text2的第j个字符 (text2[j-1]) 是否相等。- 如果
text1[i-1] == text2[j-1]:那么这个相等的字符必然在最长公共子序列中。此时的长度等于text1的前i-1个字符与text2的前j-1个字符的最长公共子序列长度加1,即dp[i-1][j-1] + 1。 - 如果
text1[i-1] != text2[j-1]:那么这两个字符至少有一个不在最长公共子序列中。此时的长度等于“text1的前i-1个字符与text2的前j个字符的最长公共子序列”和“text1的前i个字符与text2的前j-1个字符的最长公共子序列”中的较大者,即max(dp[i-1][j], dp[i][j-1])。
- 如果
-
第三步:因此,状态转移方程是:
如果text1[i-1] == text2[j-1],则dp[i][j] = dp[i-1][j-1] + 1。
如果text1[i-1] != text2[j-1],则dp[i][j] = max(dp[i-1][j], dp[i][j-1])。 -
第四步:初始条件是:
dp[0][j] = 0且dp[i][0] = 0。表示任何字符串与空字符串的最长公共子序列的长度都为0。
4. 石子合并 (Stone Merging)
问题描述: 有 N 堆石子排成一行,每堆石子有一定的质量。每次可以合并相邻的两堆石子,合并的代价为这两堆石子的质量之和。求将所有石子合并成一堆的最小总代价。
-
第一步:dp[i][j] 的定义是:
dp[i][j]表示将从第i堆到第j堆的石子合并成一堆所需的最小代价。 -
第二步:为了达到 dp[i][j],最后一步可能是:
区间[i, j]内的石子最后一次合并,必然是由两个已经合并好的子区间[i, k]和[k+1, j]合并而成的,其中i <= k < j。 -
第三步:因此,状态转移方程是:
dp[i][j] = min(dp[i][k] + dp[k+1][j] + sum(i, j)),其中i <= k < j。sum(i, j)表示第i堆到第j堆石子的总质量,也就是最后一次合并的代价。 -
第四步:初始条件是:
dp[i][i] = 0。因为单独一堆石子不需要合并,所以代价为0。
当然,我们继续按照这个模板来解析更多经典的动态规划问题。
5. 数字三角形 (Number Triangle)
问题描述: 在一个由数字组成的三角形中,从顶部出发,每次可以走到下一层的相邻两个节点中的一个,求一条从顶部到底部的路径,使得路径上的数字总和最大。
-
第一步:dp[i][j] 的定义是:
dp[i][j]表示从三角形顶部(第一行)走到第i行第j个位置时,所能获得的最大路径和。 -
第二步:为了达到 dp[i][j],最后一步可能是:
要想到达第i行第j个位置,只能从上一层(第i-1行)的两个位置走过来:- 从左上方的位置
(i-1, j-1)走过来(如果j-1存在)。 - 从正上方的位置
(i-1, j)走过来(如果j在上一行存在)。
- 从左上方的位置
-
第三步:因此,状态转移方程是:
dp[i][j] = max(dp[i-1][j-1], dp[i-1][j]) + triangle[i][j]。你需要处理边界情况,例如当j=0时只能从dp[i-1][j]过来,当j等于第i行的最后一个元素索引时,只能从dp[i-1][j-1]过来。 -
第四步:初始条件是:
dp[0][0] = triangle[0][0]。起点就是三角形顶部的那个数字。
(注意:这个问题也可以自底向上求解,此时 dp[i][j] 的定义会变成从 (i, j) 出发走到最底层的最大路径和,状态转移方程则如您列表中所示:f[i,j] = max(f[i+1,j], f[i+1,j+1]) + a[i,j])。
6. 乘积最大 (Maximum Product)
问题描述: 给定一个长度为 N 的数字字符串和 K 个乘号,要求将这 K 个乘号插入到数字串中,使得最终表达式的计算结果最大。
-
第一步:dp[i][j] 的定义是:
dp[i][j]表示在数字串的前i个数字中,插入j个乘号所能获得的最大乘积。 -
第二步:为了达到 dp[i][j],最后一步可能是:
最后一个(第j个)乘号可以放在任意位置k(其中j <= k < i)的后面。这会将问题分为两部分:- 在前
k个数字中插入j-1个乘号,得到最大乘积dp[k][j-1]。 - 将第
k+1到第i个数字组成的数作为最后一个乘数。
- 在前
-
第三步:因此,状态转移方程是:
dp[i][j] = max(dp[k][j-1] * number(k+1, i)),其中j <= k < i。number(k+1, i)代表由第k+1到第i位数字组成的整数。 -
第四步:初始条件是:
dp[i][0] = number(1, i)。即不插入任何乘号时,最大乘积就是由前i个数字本身组成的整数。
7. Floyd-Warshall 算法
问题描述: 在一个加权有向图中,求任意两个顶点之间的最短路径长度。
-
第一步:dp[k][i][j] 的定义是:
dp[k][i][j]表示从顶点i到顶点j,只允许经过编号从1到k的中间顶点时,所能得到的最短路径长度。 -
第二步:为了达到 dp[k][i][j],最后一步可能是:
从顶点i到顶点j且只经过1...k的最短路径,有两种可能:- 不经过 顶点
k。那么这条路径的长度就是dp[k-1][i][j]。 - 经过 顶点
k。那么这条路径可以被分解为从i到k的路径和从k到j的路径,这两条子路径都只允许经过1...k-1的顶点。其长度为dp[k-1][i][k] + dp[k-1][k][j]。
- 不经过 顶点
-
第三步:因此,状态转移方程是:
dp[k][i][j] = min(dp[k-1][i][j], dp[k-1][i][k] + dp[k-1][k][j])。
(在实际实现中,k这一维度可以被优化掉,方程简化为dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j]))。 -
第四步:初始条件是:
dp[0][i][j]是图的邻接矩阵。如果i和j之间有直接的边,则为该边的权重;如果i == j,则为0;否则为无穷大。
8. 编辑距离 (Edit Distance)
问题描述: 给定两个单词 word1 和 word2,计算将 word1 转换成 word2 所使用的最少操作数。你可以对一个单词进行如下三种操作:插入一个字符、删除一个字符、替换一个字符。
- 第一步:dp[i][j] 的定义是:
dp[i][j]表示将word1的前i个字符转换成word2的前j个字符所需要的最少操作数。 - 第二步:为了达到 dp[i][j],最后一步可能是:
考虑word1[i-1]和word2[j-1]这两个字符:- 如果
word1[i-1] == word2[j-1]:那么这个字符不需要操作,最少操作数等于将word1的前i-1个字符转换成word2的前j-1个字符的操作数,即dp[i-1][j-1]。 - 如果
word1[i-1] != word2[j-1]:我们有三种选择:- 替换:将
word1[i-1]替换成word2[j-1],操作数 =dp[i-1][j-1] + 1。 - 删除:删除
word1[i-1],然后将word1的前i-1个字符变成word2的前j个字符,操作数 =dp[i-1][j] + 1。 - 插入:在
word1的前i个字符后插入word2[j-1],这等价于将word1的前i个字符变成word2的前j-1个字符,操作数 =dp[i][j-1] + 1。
我们需要取这三种选择中的最小值。
- 替换:将
- 如果
- 第三步:因此,状态转移方程是:
如果word1[i-1] == word2[j-1],则dp[i][j] = dp[i-1][j-1]。
否则,dp[i][j] = 1 + min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1])。 - 第四步:初始条件是:
dp[i][0] = i(将长度为i的字符串变为空串需要i次删除)。
dp[0][j] = j(将空串变为长度为j的字符串需要j次插入)。
好的,我们继续。这里为您带来更多不同类型的经典动态规划问题,并同样遵循四步模板进行解析。
9. 爬楼梯 (Climbing Stairs)
问题描述: 假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
-
第一步:dp[i] 的定义是:
dp[i]表示爬到第i级台阶所拥有的不同方法的总数。 -
第二步:为了达到 dp[i],最后一步可能是:
要想到达第i级台阶,只有两种可能的方式:- 从第
i-1级台阶向上爬 1 步。 - 从第
i-2级台阶向上爬 2 步。
因此,到达第i级台阶的总方法数,就是到达第i-1级台阶的方法数与到达第i-2级台阶的方法数之和。
- 从第
-
第三步:因此,状态转移方程是:
dp[i] = dp[i-1] + dp[i-2]。 -
第四步:初始条件是:
dp[1] = 1(到达第1级台阶只有一种方法:爬1步)。
dp[2] = 2(到达第2级台阶有两种方法:1+1 或 2)。
10. 打家劫舍 (House Robber)
问题描述: 你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,但相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被闯入,系统会自动报警。给定一个代表每间房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,一夜之内能够偷窃到的最高金额。
-
第一步:dp[i] 的定义是:
dp[i]表示在前i间房屋中(包括第i间),能够偷窃到的最高总金额。 -
第二步:为了达到 dp[i],最后一步可能是:
对于第i间房屋,你有两种选择:- 不偷第
i间房屋:那么你所能获得的最大金额就等于在前i-1间房屋中能偷到的最大金额,即dp[i-1]。 - 偷第
i间房屋:由于不能偷相邻的房屋,所以你不能偷第i-1间。此时的最大金额等于在前i-2间房屋中能偷到的最大金额,再加上第i间房屋的金额,即dp[i-2] + nums[i]。
- 不偷第
-
第三步:因此,状态转移方程是:
dp[i] = max(dp[i-1], dp[i-2] + nums[i])。 -
第四步:初始条件是:
dp[0] = nums[0](只有一间房时,只能偷这一间)。
dp[1] = max(nums[0], nums[1])(有两间房时,选择金额较大的那间偷)。
11. 零钱兑换 (Coin Change)
问题描述: 给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。你可以认为每种硬币的数量是无限的。
-
第一步:dp[i] 的定义是:
dp[i]表示凑成总金额为i所需的最少硬币数量。 -
第二步:为了达到 dp[i],最后一步可能是:
要凑成总金额i,我们的最后一枚硬币可以是coins数组中的任意一个面值c(只要i >= c)。如果我们选择了面值为c的硬币作为最后一枚,那么问题就变成了“凑成总金额为i-c所需的最少硬币数量”再加 1。我们需要遍历所有可能的最后一枚硬币c,并找出能使dp[i]最小的那个选择。 -
第三步:因此,状态转移方程是:
dp[i] = min(dp[i - c]) + 1,其中c是coins数组中的一个面值,且i >= c。 -
第四步:初始条件是:
dp[0] = 0(凑成金额 0 需要 0 个硬币)。
数组的其他所有位置可以初始化为一个特殊值(例如,无穷大或amount + 1),用来表示该金额当前还无法凑成。
12. 分割等和子集 (Partition Equal Subset Sum)
问题描述: 给定一个只包含正整数的非空数组 nums。判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
- 这个问题可以转化为01背包问题:首先计算整个数组的总和
sum。如果sum是奇数,则不可能分割成两个和相等的子集,直接返回false。如果sum是偶数,问题就变成了:能否从数组nums中找到一个子集,使得这个子集的和恰好等于sum / 2。
这等价于:我们有一个容量为sum / 2的背包,和N个物品(nums中的每个数都是一个物品),每个物品的体积和价值都等于其数值。问能否恰好装满这个背包。 - 第一步:dp[j] 的定义是:
dp[j]是一个布尔值,表示背包容量为j时,能否被恰好装满(即dp[j] = true)或不能(dp[j] = false)。 - 第二步:为了达到 dp[j],最后一步可能是:
对于数组中的每一个数字num,我们在更新dp数组时考虑是否要将num放入背包。- 不放入
num:那么dp[j]的状态不变。 - 放入
num:前提是j >= num。如果dp[j - num]为true(即容量为j - num的背包能被装满),那么现在再放入num,容量为j的背包也就能被装满了。
- 不放入
- 第三步:因此,状态转移方程是:
(使用一维数组优化后,从后向前遍历)
dp[j] = dp[j] || dp[j - num]。 - 第四步:初始条件是:
dp[0] = true(容量为0的背包可以被“空集”装满)。其余dp[j]初始化为false。
好的,我们继续深入探讨更多经典的动态规划模型,并严格按照您的四步模板进行解析。
13. 完全背包问题 (Unbounded Knapsack Problem)
问题描述: 有 N 种物品和一个容量为 V 的背包,每种物品都有无限件可用。第 i 种物品的体积是 v[i],价值是 w[i]。求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
-
第一步:dp[j] 的定义是:
dp[j]表示容量为j的背包,所能装入物品的最大总价值。 -
第二步:为了达到 dp[j],最后一步可能是:
为了达到容量j的最大价值,我们可以考虑最后放入背包的是哪种物品。假设最后放入的是第i种物品,那么背包在此之前的状态是:容量为j - v[i],并且已经达到了该容量下的最大价值dp[j - v[i]]。然后我们再放入一个物品i,总价值就变成了dp[j - v[i]] + w[i]。我们需要遍历所有物品,找出能使这个总价值最大的选择。 -
第三步:因此,状态转移方程是:
dp[j] = max(dp[j], dp[j - v[i]] + w[i])(对于所有物品i,且j >= v[i])。
注意: 这与01背包的核心区别在于dp[j]是由dp[j - v[i]]推导而来,使用的是当前轮次的dp值。这允许了物品i被重复选择,从而体现了“无限件”的特性。 -
第四步:初始条件是:
dp[0] = 0。容量为0的背包无法装任何物品,价值为0。
14. 最长回文子序列 (Longest Palindromic Subsequence)
问题描述: 给定一个字符串 s,找到 s 中最长的回文子序列的长度。子序列不要求字符是连续的。
-
第一步:dp[i][j] 的定义是:
dp[i][j]表示在字符串s的子串s[i...j](从索引i到j)中,最长回文子序列的长度。 -
第二步:为了达到 dp[i][j],最后一步可能是:
这取决于子串s[i...j]两端的字符s[i]和s[j]的关系:- 如果
s[i] == s[j]:那么这两个字符可以作为回文序列的两端。最长长度就是 2 加上内部子串s[i+1...j-1]的最长回文子序列长度,即dp[i+1][j-1] + 2。 - 如果
s[i] != s[j]:那么s[i]和s[j]不可能同时出现在最长回文子序列的两端。因此,我们需要分别放弃一个字符,看哪种情况能得到更长的结果。即比较“在s[i+1...j]中的最长回文子序列”和“在s[i...j-1]中的最长回文子序列”的长度,取较大者。也就是max(dp[i+1][j], dp[i][j-1])。
- 如果
-
第三步:因此,状态转移方程是:
如果s[i] == s[j],则dp[i][j] = dp[i+1][j-1] + 2。
如果s[i] != s[j],则dp[i][j] = max(dp[i+1][j], dp[i][j-1])。 -
第四步:初始条件是:
dp[i][i] = 1。对于任意i,单个字符本身就是一个长度为1的回文子序列。
15. 单词拆分 (Word Break)
问题描述: 给定一个非空字符串 s 和一个包含非空单词的列表 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。
-
第一步:dp[i] 的定义是:
dp[i]是一个布尔值,表示字符串s中长度为i的前缀s[0...i-1]是否可以被成功拆分。 -
第二步:为了达到 dp[i],最后一步可能是:
要判断s[0...i-1]能否被拆分,我们可以寻找一个分割点j(0 <= j < i)。如果s[0...j-1]本身可以被成功拆分(即dp[j]为true),并且从j到i-1的子串s[j...i-1]恰好是字典中的一个单词,那么dp[i]就是true。我们需要遍历所有可能的分割点j。 -
第三步:因此,状态转移方程是:
dp[i] = OR(dp[j] && s[j...i-1] in wordDict),对于所有0 <= j < i。只要有一个j满足条件,dp[i]就为true。 -
第四步:初始条件是:
dp[0] = true。一个空字符串(长度为0的前缀)可以被认为是一个有效的拆分,这是递推的起点。
16. 不同路径 (Unique Paths / 过河卒简化版)
问题描述: 一个机器人在一个 m x n 的网格的左上角(grid[0][0])。机器人每次只能向下或向右移动一步。机器人试图达到网格的右下角(grid[m-1][n-1])。问总共有多少条不同的路径?
- 第一步:dp[i][j] 的定义是:
dp[i][j]表示从起点(0,0)到达网格中位置(i,j)的不同路径的总数。 - 第二步:为了达到 dp[i][j],最后一步可能是:
由于机器人只能向下或向右移动,要想到达位置(i,j),它必须从紧邻的上方或左方格子过来:- 从上方格子
(i-1, j)向下移动一步。 - 从左方格子
(i, j-1)向右移动一步。
因此,到达(i,j)的总路径数等于到达(i-1,j)的路径数加上到达(i,j-1)的路径数。
- 从上方格子
- 第三步:因此,状态转移方程是:
dp[i][j] = dp[i-1][j] + dp[i][j-1]。 - 第四步:初始条件是:
dp[0][j] = 1对于所有j,并且dp[i][0] = 1对于所有i。这是因为对于网格的第一行,机器人只能一直向右走,路径只有1条;同理,对于第一列,只能一直向下走,路径也只有1条。dp[0][0]自然是1。
当然可以,我们继续为您解析更多具有代表性的动态规划问题。
17. 合唱队形 (Chorus Formation)
问题描述: N 位同学站成一排,音乐老师要请其中的 (N-K) 位同学出列,使得剩下的 K 位同学排成合唱队形。合唱队形是指这样的一种队形:设 K 位同学从左到右依次为 1, 2, ..., K,他们的身高分别为 T1, T2, ..., TK,则他们的身高满足 T1 < T2 < ... < Ti > Ti+1 > ... > TK (1 <= i <= K)。你的任务是,已知所有 N 位同学的身高,计算最少需要几位同学出列,才能使得剩下的同学排成合唱队形。
-
这个问题可以分解为两次最长上升子序列(LIS)的计算。 核心思想是枚举每一个同学作为合唱队形的“最高点”,然后计算以他为最高点时,能形成的最长合唱队形有多长。
-
第一步:dp 数组的定义是:
left_dp[i]:以第i位同学为结尾的最长上升子序列的长度(从左向右看)。right_dp[i]:以第i位同学为开头的最长下降子序列的长度(从左向右看),这等价于从右向左看,以第i位同学为结尾的最长上升子序列的长度。
-
第二步:为了达到
dp[i],最后一步可能是:- 对于
left_dp[i]:第i位同学可以接在任何他左边(j < i)且比他矮(height[j] < height[i])的同学j之后,形成一个更长的上升序列。 - 对于
right_dp[i]:同理,可以从右边找到比他矮的同学来构造。 - 一个以第
i位同学为顶点的合唱队形,其总人数为left_dp[i](左边的上升部分)加上right_dp[i](右边的下降部分),但因为第i位同学被计算了两次,所以需要减 1。
- 对于
-
第三步:因此,状态转移方程是:
left_dp[i] = max(left_dp[j]) + 1,其中0 <= j < i且height[j] < height[i]。right_dp[i] = max(right_dp[j]) + 1,其中i < j < N且height[j] < height[i]。- 最终合唱队形的最大长度
max_len = max(left_dp[i] + right_dp[i] - 1),遍历所有i。 - 最少出列人数 =
N - max_len。
-
第四步:初始条件是:
left_dp[i] = 1和right_dp[i] = 1对所有i成立。因为每个同学自己都能构成一个长度为1的序列。
18. 数的划分 (Integer Partition)
问题描述: 将整数 n 划分成 k 份,且每份不能为空,任意两份的顺序不被区分(例如 1,1,5 和 1,5,1 被认为是同一种划分)。求总共有多少种不同的划分方法。
-
第一步:dp[i][j] 的定义是:
dp[i][j]表示将整数i划分成j份的总方法数。 -
第二步:为了达到 dp[i][j],最后一步可能是:
对于i的j份划分,可以根据这j份中是否含有1来分类:- 划分中不包含
1:这意味着每一份都至少是2。我们可以从每一份中都拿走一个1,总共拿走j个1,剩下的整数是i-j,它需要被划分为j份。这种情况下的方法数等于将i-j划分为j份的方法数,即dp[i-j][j]。 - 划分中至少包含一个
1:我们可以先拿走一个1,剩下的整数是i-1,它需要被划分为j-1份。这种情况下的方法数等于将i-1划分为j-1份的方法数,即dp[i-1][j-1]。
- 划分中不包含
-
第三步:因此,状态转移方程是:
dp[i][j] = dp[i-j][j] + dp[i-1][j-1]。 -
第四步:初始条件是:
dp[k][k] = 1(将k划分为k份,只有1,1,...,1一种方法)。
dp[i][1] = 1(将i划分为1份,只有i本身这一种方法)。
当i < j时,dp[i][j] = 0(无法将一个小数划分成更多份)。
19. 多边形剖分 (Polygon Triangulation)
问题描述: 在一个凸 n 边形的顶点上,赋有 n 个权值。要求用 n-3 条不相交的对角线将该凸 n 边形剖分成 n-2 个三角形,并使得这些三角形的顶点权值乘积之和最小。
-
这是一个典型的区间DP问题,与石子合并非常相似。
-
第一步:dp[i][j] 的定义是:
dp[i][j]表示由顶点v_i, v_{i+1}, ..., v_j构成的子多边形进行三角剖分的最小代价(顶点按顺序编号)。 -
第二步:为了达到 dp[i][j],最后一步可能是:
考虑连接顶点v_i和v_j的边,它必然是某个三角形的一条边。这个三角形的第三个顶点必然是v_k,其中i < k < j。这条对角线(v_i, v_k)和(v_k, v_j)将原问题dp[i][j]分割成了两个子问题dp[i][k]和dp[k][j],以及一个新形成的三角形(v_i, v_k, v_j)的代价。 -
第三步:因此,状态转移方程是:
dp[i][j] = min(dp[i][k] + dp[k][j] + w[i]*w[k]*w[j]),需要遍历所有可能的中间点k(i < k < j)。 -
第四步:初始条件是:
dp[i][i+1] = 0。因为由相邻两个顶点构成的“多边形”(即一条边)无法再剖分,代价为0。
20. 买卖股票的最佳时机 (Best Time to Buy and Sell Stock - multiple variations)
我们以一个常见的变种为例:最多可完成两笔交易。
问题描述: 给定一个数组 prices,它的第 i 个元素是给定股票在第 i 天的价格。设计一个算法来计算你所能获取的最大利润。你最多可以完成两笔交易。注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
- 第一步:dp[i][k][s] 的定义是:
dp[i][k][s]表示在第i天结束时,已经进行了k笔交易,并且手中持有状态为s时所能获得的最大利润。i:天数 (from 0 to n-1)k:已完成的交易次数 (0, 1, or 2)s:持有状态 (0 for no stock, 1 for has stock)
- 第二步:为了达到 dp[i][k][s],最后一步可能是:
- 为了达到
dp[i][k][0](第i天,k笔交易,不持股):- 可能是前一天就不持股,今天什么都不做(rest)。利润继承自
dp[i-1][k][0]。 - 可能是今天卖出了股票。这笔卖出操作完成了第
k笔交易。那么利润就是前一天持有股票(dp[i-1][k][1])的基础上加上今天的卖出价prices[i]。
- 可能是前一天就不持股,今天什么都不做(rest)。利润继承自
- 为了达到
dp[i][k][1](第i天,k笔交易,持股):- 可能是前一天就持股,今天什么都不做(rest)。利润继承自
dp[i-1][k][1]。 - 可能是今天买入了股票。这次买入是第
k笔交易的开始。那么利润就是前一天不持股且已完成k-1笔交易(dp[i-1][k-1][0])的基础上减去今天的买入价prices[i]。
- 可能是前一天就持股,今天什么都不做(rest)。利润继承自
- 为了达到
- 第三步:因此,状态转移方程是:
dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i]) - 第四步:初始条件是:
dp[-1][k][0] = 0(开始前利润为0)
dp[-1][k][1] = -infinity(开始前不可能持有股票,设为负无穷防止被错误选择)
dp[i][0][1] = -infinity(不允许交易时不可能持有股票)
好的,我们继续探索动态规划的世界,这里是更多经典问题的模板化解析。
21. 树形DP - 没有上司的舞会
问题描述: 某公司要举办一场晚会,共 N 名员工,构成一个树形的上下级关系(除了老板,每个人都有且只有一个直属上司)。如果邀请了某位员工,那么为了避免尴尬,就不能邀请他的直属上司。每位员工参加晚会都会带来一定的“快乐指数”。求邀请哪些员工可以使得晚会的总快乐指数最大。
-
第一步:dp[u][s] 的定义是:
dp[u][0]表示在以员工u为根的子树中(即u和他的所有下属),在 不邀请 员工u的情况下,能获得的最大快乐指数。
dp[u][1]表示在以员工u为根的子树中,在 邀请 员工u的情况下,能获得的最大快乐指数。 -
第二步:为了达到 dp[u][s],最后一步可能是:
状态dp[u]的值取决于其所有直接下属v的状态。- 如果不邀请
u(dp[u][0]):那么对于u的每一个直接下属v,我们既可以邀请他,也可以不邀请他。为了使快乐指数最大,我们应该选择这两种情况中能带来更大快乐值的那一种,即max(dp[v][0], dp[v][1])。我们需要将u的所有直接下属的这个最大值累加起来。 - 如果邀请
u(dp[u][1]):那么u的所有直接下属v都不能被邀请。所以我们只能累加在不邀请v的情况下的最大快乐指数,即dp[v][0]。最终还要加上u本身的快乐指数。
- 如果不邀请
-
第三步:因此,状态转移方程是:
(遍历u的所有直接下属v)
dp[u][0] = sum(max(dp[v][0], dp[v][1]))
dp[u][1] = happiness[u] + sum(dp[v][0]) -
第四步:初始条件是:
对于一个叶子节点(没有下属的员工)l:
dp[l][0] = 0(不邀请他,快乐指数增加0)。
dp[l][1] = happiness[l](邀请他,快乐指数就是他自己的值)。
计算通常使用深度优先搜索(DFS),从叶子节点开始向上计算,直到根节点。
22. 状态压缩DP - 旅行商问题 (TSP)
问题描述: 给定 N 个城市和每对城市之间的距离,求解访问每一座城市一次并回到起始城市的最短回路距离。
-
第一步:dp[S][j] 的定义是:
dp[S][j]表示已经访问过的城市集合为S(S是一个用二进制位表示的集合,例如1101表示访问了第0, 2, 3号城市),且当前停留在城市j时,所经过的最短路径总长度。 -
第二步:为了达到 dp[S][j],最后一步可能是:
要想到达“访问了集合S并停在j”这个状态,上一步必然是处于“访问了集合S中除去j的所有城市,并停在某个城市k”的状态,其中k属于S且k != j。然后从城市k直接移动到城市j。 -
第三步:因此,状态转移方程是:
dp[S][j] = min(dp[S XOR (1 << j)][k] + distance(k, j))
这个方程的含义是:遍历集合S中除了j以外的所有城市k,找出那个能使“从起点经过S-{j}到达k,再从k到j”的路径最短的k。 -
第四步:初始条件是:
假设从城市 0 出发。
dp[1][0] = 0(只访问了城市0,当前停在城市0,路径长度为0)。
所有其他的dp[S][j]初始化为无穷大。
23. 多重背包问题
问题描述: 有 N 种物品和一个容量为 V 的背包。第 i 种物品最多有 c[i] 件,每件体积是 v[i],价值是 w[i]。求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
-
第一步:dp[i][j] 的定义是:
dp[i][j]表示将前i种物品放入容量为j的背包中,可以获得的最大价值。 -
第二步:为了达到 dp[i][j],最后一步可能是:
对于第i种物品,我们可以选择放入k件,其中0 <= k <= c[i]。如果我们决定放入k件,那么问题就转化为:将前i-1种物品放入容量为j - k * v[i]的背包中,能获得的最大价值,再加上我们这k件物品的价值k * w[i]。 -
第三步:因此,状态转移方程是:
dp[i][j] = max(dp[i-1][j - k * v[i]] + k * w[i]),需要遍历所有可能的k(0 <= k <= c[i]且j >= k * v[i])。
(注:这个问题通常通过“二进制优化”将多重背包转化为01背包问题来高效求解,但以上是其最朴素的状态转移思想。) -
第四步:初始条件是:
dp[0][j] = 0。不考虑任何物品时,价值为0。
24. 矩阵链乘法 (Matrix Chain Multiplication)
问题描述: 给定 n 个矩阵的序列 <A1, A2, ..., An>,计算它们的乘积。由于矩阵乘法满足结合律,我们可以任意加括号决定计算顺序。不同的计算顺序可能导致截然不同的计算量(总的标量乘法次数)。求一个最优的加括号方案,使得总的标量乘法次数最少。
- 这是一个经典的区间DP问题,与石子合并、多边形剖分同源。
- 第一步:dp[i][j] 的定义是:
dp[i][j]表示计算矩阵序列Ai到Aj的乘积Ai...j所需的最小标量乘法次数。 - 第二步:为了达到 dp[i][j],最后一步可能是:
为了计算Ai...j,最后一步一定是将两个已经计算好的子矩阵链相乘。这个分割点可以在k处,其中i <= k < j。也就是说,我们先计算出Ai...k的结果,再计算出Ak+1...j的结果,最后将这两个结果矩阵相乘。 - 第三步:因此,状态转移方程是:
dp[i][j] = min(dp[i][k] + dp[k+1][j] + p[i-1]*p[k]*p[j]),需要遍历所有可能的分割点k(i <= k < j)。
其中p数组是矩阵的维度,矩阵Am的维度是p[m-1] x p[m]。p[i-1]*p[k]*p[j]是将(p[i-1] x p[k])的矩阵和(p[k] x p[j])的矩阵相乘的计算代价。 - 第四步:初始条件是:
dp[i][i] = 0。单个矩阵不需要任何乘法,计算代价为0。
当然,这里有更多不同领域的经典动态规划问题,它们展示了DP思想的广泛应用。
25. 最小路径和 (Minimum Path Sum)
问题描述: 给定一个包含非负整数的 m x n 网格 grid,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。每次只能向下或者向右移动一步。
-
第一步:dp[i][j] 的定义是:
dp[i][j]表示从左上角起点(0,0)到达网格中位置(i,j)的最小路径和。 -
第二步:为了达到 dp[i][j],最后一步可能是:
要想到达位置(i,j),必然是从其上方格子(i-1, j)或者左方格子(i, j-1)移动过来的。为了使得到达(i,j)的路径和最小,我们应该选择从这两条路径中和较小的那一条走过来。 -
第三步:因此,状态转移方程是:
dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]。 -
第四步:初始条件是:
dp[0][0] = grid[0][0]。
对于第一行:dp[0][j] = dp[0][j-1] + grid[0][j],因为只能从左边过来。
对于第一列:dp[i][0] = dp[i-1][0] + grid[i][0],因为只能从上边过来。
26. 目标和 (Target Sum)
问题描述: 给定一个非负整数数组 nums 和一个目标数 S。现在你有 + 和 - 两种符号。对于数组中的任意一个整数,你都可以从 + 或 - 中选择一个符号添加在前面。返回可以使最终数组和为目标数 S 的所有添加符号的方法数。
-
这个问题可以转化为一个0/1背包的计数问题。 假设给一部分数字
P加上+号,给另一部分数字N加上-号。我们有sum(P) - sum(N) = S。同时,sum(P) + sum(N) = total_sum(数组总和)。两个式子相加得到2 * sum(P) = S + total_sum,所以sum(P) = (S + total_sum) / 2。问题就变成了:从nums数组中找出若干个数字,使得它们的和恰好等于(S + total_sum) / 2。有多少种找法? -
第一步:dp[j] 的定义是:
dp[j]表示从数组中选取若干个数,使其和恰好为j的方法总数。 -
第二步:为了达到 dp[j],最后一步可能是:
对于数组中的每一个数字num,我们可以选择将它加入或者不加入构成和为j的集合。- 不加入
num:那么构成和为j的方法数保持不变。 - 加入
num:那么我们需要在考虑num之前,就已经构成了和为j - num。所以,方法数增加了dp[j - num]种。
- 不加入
-
第三步:因此,状态转移方程是:
(遍历nums中的每个num,并从后向前更新dp数组)
dp[j] = dp[j] + dp[j - num]。 -
第四步:初始条件是:
dp[0] = 1。和为0有一种方法,就是不选择任何数(空集)。
27. 最长公共子串 (Longest Common Substring)
问题描述: 给定两个字符串 str1 和 str2,找到两个字符串中连续且相等的最长子串的长度。
-
这与最长公共子序列(LCS)不同,子串要求是连续的。
-
第一步:dp[i][j] 的定义是:
dp[i][j]表示以str1的第i个字符(str1[i-1])和str2的第j个字符(str2[j-1])为结尾 的最长公共子串的长度。 -
第二步:为了达到 dp[i][j],最后一步可能是:
我们只关心str1[i-1]和str2[j-1]是否相等。- 如果
str1[i-1] == str2[j-1]:那么这两个字符可以延续它们前一个字符构成的公共子串。所以,长度就是以str1[i-2]和str2[j-2]为结尾的最长公共子串长度加 1。 - 如果
str1[i-1] != str2[j-1]:那么公共子串在这里就断开了。以这两个字符为结尾的公共子串长度为 0。
- 如果
-
第三步:因此,状态转移方程是:
如果str1[i-1] == str2[j-1],则dp[i][j] = dp[i-1][j-1] + 1。
如果str1[i-1] != str2[j-1],则dp[i][j] = 0。
最终的答案是整个dp表中的最大值,而不是dp[m][n]。 -
第四步:初始条件是:
dp[0][j] = 0且dp[i][0] = 0。与空串的公共子串长度为0。
28. 石子游戏 (Stone Game)
问题描述: 有一排 N 堆石子,piles[i] 是每堆石子的数量。两名玩家轮流取石子,每次可以从行的开头或结尾取走整堆石子。取走石子总数最多的玩家获胜。假设两名玩家都采取最佳策略,判断先手玩家是否能获胜。
- 这是一个典型的博弈型DP。
- 第一步:dp[i][j] 的定义是:
dp[i][j]表示在只剩下从i到j堆石子的情况下,当前轮到的玩家(先手)相对于另一位玩家(后手),能 多 拿到的最大石子数量。 - 第二步:为了达到 dp[i][j],最后一步可能是:
当前玩家面对piles[i...j],他有两种选择:- 取走
piles[i]:他获得piles[i]的石子。然后,对于剩下的piles[i+1...j],他的对手会成为先手,并采取最优策略,多拿dp[i+1][j]的石子。因此,当前玩家最终多拿的石子数是piles[i] - dp[i+1][j]。 - 取走
piles[j]:他获得piles[j]的石子。然后,对于剩下的piles[i...j-1],他的对手会成为先手,多拿dp[i][j-1]的石子。因此,当前玩家最终多拿的石子数是piles[j] - dp[i][j-1]。
当前玩家会选择对自己最有利的,即能让他多拿更多石子的那种选择。
- 取走
- 第三步:因此,状态转移方程是:
dp[i][j] = max(piles[i] - dp[i+1][j], piles[j] - dp[i][j-1])。 - 第四步:初始条件是:
当i == j时,只剩下一堆石子。当前玩家会把它全部拿走。
dp[i][i] = piles[i]。
好的,我们继续。动态规划的世界非常广阔,这里还有一些涵盖了不同思想和技巧的经典问题。
29. 最大子数组和 (Maximum Subarray)
问题描述: 给定一个整数数组 nums,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
-
第一步:dp[i] 的定义是:
dp[i]表示以数组中第i个元素nums[i]为结尾 的所有连续子数组中,和最大的那个子数组的和。 -
第二步:为了达到 dp[i],最后一步可能是:
对于以nums[i]结尾的最大和子数组,只有两种可能:- 这个子数组只包含
nums[i]本身。 - 这个子数组包含了
nums[i]以及它前面的元素。在这种情况下,它必然是“以nums[i-1]结尾的最大和子数组”再加上nums[i]构成的。
- 这个子数组只包含
-
第三步:因此,状态转移方程是:
dp[i] = max(nums[i], dp[i-1] + nums[i])。
最终的结果是整个dp数组中的最大值,因为最大子数组不一定在数组末尾结束。 -
第四步:初始条件是:
dp[0] = nums[0]。以第一个元素结尾的最大子数组和就是它本身。
30. 戳气球 (Burst Balloons)
问题描述: 有 n 个气球,编号为 0 到 n-1,每个气球上都有一个数字,这些数字存在数组 nums 中。现在要求你戳破所有的气球。每当你戳破一个气球 i 时,你可以获得 nums[left] * nums[i] * nums[right] 个硬币。这里的 left 和 right 代表和 i 相邻的两个气球的序号。当一个气球被戳破后,它左右两边的气球就变成了相邻的气球。求所能获得硬币的最大数量。
(可以假设 nums[-1] = nums[n] = 1,但它们不能被戳破)。
-
这是一个非常经典的区间DP,但需要逆向思维。 正向思考(先戳哪个)会导致后续状态不独立。我们反过来想:最后戳破哪个气球。
-
第一步:dp[i][j] 的定义是:
dp[i][j]表示戳破开区间(i, j)内所有气球,所能获得的最大硬币数量。为了方便处理边界,我们可以在原nums数组两边各加上一个1。 -
第二步:为了达到 dp[i][j],最后一步可能是:
我们考虑在区间(i, j)中,最后一个被戳破 的气球是k(i < k < j)。当我们戳破k时,它左右两边相邻的气球一定是i和j(因为(i,k)和(k,j)之间的气球都已经被戳破了)。戳破k的收益是nums[i] * nums[k] * nums[j]。而在此之前,我们需要先戳破区间(i, k)和(k, j)的所有气球,这两部分是独立的子问题,其最大收益分别是dp[i][k]和dp[k][j]。 -
第三步:因此,状态转移方程是:
dp[i][j] = max(dp[i][k] + nums[i] * nums[k] * nums[j] + dp[k][j]),需要遍历所有可能的k(i < k < j)。 -
第四步:初始条件是:
dp[i][i+1] = 0。当区间长度为0或1时(即j = i+1),开区间(i, i+1)内没有气球,所以收益为0。
31. 交错字符串 (Interleaving String)
问题描述: 给定三个字符串 s1, s2, s3,请你帮忙验证 s3 是否是由 s1 和 s2 交错组成的。交错的定义是:如果 s3 中可以找到一种划分,使得 s1 和 s2 的所有字符都恰好出现一次,并且保持了它们在原字符串中的相对顺序。
-
第一步:dp[i][j] 的定义是:
dp[i][j]是一个布尔值,表示s1的前i个字符和s2的前j个字符,能否成功交错组成s3的前i+j个字符。 -
第二步:为了达到 dp[i][j],最后一步可能是:
为了构成s3的前i+j个字符,s3的最后一个字符s3[i+j-1]必然是来自s1的最后一个字符s1[i-1]或者是s2的最后一个字符s2[j-1]。- 如果
s3[i+j-1]等于s1[i-1]:我们需要看s1的前i-1个字符和s2的前j个字符能否构成s3的前i+j-1个字符,即dp[i-1][j]是否为true。 - 如果
s3[i+j-1]等于s2[j-1]:我们需要看s1的前i个字符和s2的前j-1个字符能否构成s3的前i+j-1个字符,即dp[i][j-1]是否为true。
- 如果
-
第三步:因此,状态转移方程是:
dp[i][j] = (dp[i-1][j] && s1[i-1] == s3[i+j-1]) || (dp[i][j-1] && s2[j-1] == s3[i+j-1])。 -
第四步:初始条件是:
dp[0][0] = true(两个空字符串可以交错组成一个空字符串)。
对于第一行:dp[0][j] = dp[0][j-1] && (s2[j-1] == s3[j-1])。
对于第一列:dp[i][0] = dp[i-1][0] && (s1[i-1] == s3[i-1])。
32. 最长有效括号 (Longest Valid Parentheses)
问题描述: 给你一个只包含 '(' 和 ')' 的字符串,找出最长有效(格式正确且连续)括号子串的长度。
- 第一步:dp[i] 的定义是:
dp[i]表示以第i个字符s[i]为结尾 的最长有效括号子串的长度。 - 第二步:为了达到 dp[i],最后一步可能是:
- 如果
s[i]是'(':以开括号结尾的子串不可能是有效的,所以dp[i] = 0。 - 如果
s[i]是')':我们需要寻找一个与之匹配的'('。- 情况一:
s[i-1]是'('。 那么s[i-1]和s[i]构成了一对()。这个长度为2的有效串可以和i-2处结尾的有效串连接起来。所以dp[i] = dp[i-2] + 2。 - 情况二:
s[i-1]是')'。 这意味着s[i]的匹配括号可能在更前面。如果i-1结尾是一个长度为dp[i-1]的有效串,那么我们去看这个有效串之前的位置j = i - dp[i-1] - 1是否是'('。如果是,那么这个'('就和s[i]匹配上了。此时的长度等于dp[i-1](中间的有效串) +2(新匹配的一对) +dp[j-1](前面可能还连着一个有效串)。
- 情况一:
- 如果
- 第三步:因此,状态转移方程是:
如果s[i] == ')':
如果s[i-1] == '(',则dp[i] = dp[i-2] + 2。
如果s[i-1] == ')'且s[i - dp[i-1] - 1] == '(',则dp[i] = dp[i-1] + 2 + dp[i - dp[i-1] - 2]。
(注意处理数组越界的情况)。
最终答案是整个dp数组中的最大值。 - 第四步:初始条件是:
dp数组全部初始化为0。
好的,我们继续。这里有另一组经典的动态规划问题,它们分别代表了不同的DP子类型和思考角度。
33. 完全平方数 (Perfect Squares)
问题描述: 给定一个正整数 n,找到若干个完全平方数(例如 1, 4, 9, 16, ...)使得它们的和等于 n。你需要让组成和的完全平方数的个数最少。
-
这是一个完全背包问题的变体。 物品是所有小于等于
n的完全平方数,物品的“体积”和“价值”都是1,背包容量是n。 -
第一步:dp[i] 的定义是:
dp[i]表示和为i的最少完全平方数的个数。 -
第二步:为了达到 dp[i],最后一步可能是:
为了凑成和i,我们添加的最后一个数必然是一个完全平方数,比如j*j。那么,在这之前,我们必须已经凑成了和为i - j*j。我们需要遍历所有可能的最后一个完全平方数j*j,并从中找到能使总数最小的方案。 -
第三步:因此,状态转移方程是:
dp[i] = min(dp[i - j*j]) + 1,其中j*j <= i。 -
第四步:初始条件是:
dp[0] = 0。和为0需要0个完全平方数。
34. 解码方法 (Decode Ways)
问题描述: 一条包含字母 A-Z 的消息通过以下方式进行了编码:'A' -> "1", 'B' -> "2", ..., 'Z' -> "26"。给定一个只包含数字的非空字符串 s,请计算解码方法的总数。
-
第一步:dp[i] 的定义是:
dp[i]表示字符串s的前i个字符(s[0...i-1])所拥有的解码方法总数。 -
第二步:为了达到 dp[i],最后一步可能是:
解码到第i个字符时,我们关注最后的一位或两位数字是如何解码的。- 解码最后一位:如果
s的第i个字符s[i-1]不是 '0',那么它可以被独立解码。这种情况下,解码方法数继承自前i-1个字符的解码方法数,即dp[i-1]。 - 解码最后两位:如果
s的第i-1和第i个字符s[i-2...i-1]组成的两位数在 "10" 到 "26" 之间,那么它们可以一起被解码。这种情况下,解码方法数继承自前i-2个字符的解码方法数,即dp[i-2]。
总方法数是这两种独立情况的和。
- 解码最后一位:如果
-
第三步:因此,状态转移方程是:
dp[i] = (符合条件的 dp[i-1]) + (符合条件的 dp[i-2])。
具体来说:
如果s[i-1]在 '1'~'9' 之间,则dp[i] += dp[i-1]。
如果s[i-2...i-1]在 "10"~"26" 之间,则dp[i] += dp[i-2]。 -
第四步:初始条件是:
dp[0] = 1。这表示一个空字符串有一种解码方式(即什么都不做),这是为了让递推在i=1和i=2时能够正确进行。
35. 最大正方形 (Maximal Square)
问题描述: 在一个由 '0' 和 '1' 组成的二维矩阵内,找到只包含 '1' 的最大正方形,并返回其面积。
-
第一步:dp[i][j] 的定义是:
dp[i][j]表示以矩阵中(i-1, j-1)位置的元素为 右下角 的最大正方形的 边长。 -
第二步:为了达到 dp[i][j],最后一步可能是:
一个以(i-1, j-1)为右下角的正方形要存在,首先matrix[i-1][j-1]必须为 '1'。其次,它的左边、上边、左上角三个方向必须也都能形成正方形。这个新正方形的边长,受限于它左、上、左上三个方向的邻居所能形成的正方形的最小边长。例如,如果左、上、左上三个位置形成的正方形边长分别是a, b, c,那么以当前位置为右下角能形成的最大正方形边长就是min(a, b, c) + 1。 -
第三步:因此,状态转移方程是:
如果matrix[i-1][j-1] == '1',则dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1。
如果matrix[i-1][j-1] == '0',则dp[i][j] = 0。
最终答案是整个dp表中最大值的平方。 -
第四步:初始条件是:
dp数组的第一行和第一列可以直接根据matrix的值来初始化。如果matrix[i-1][0] == '1',则dp[i][0] = 1,反之亦然。
36. 最佳买卖股票时机含冷冻期 (Best Time to Buy and Sell Stock with Cooldown)
问题描述: 给定一个整数数组 prices,其中 prices[i] 表示第 i 天的股票价格。设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易:卖出股票后,你无法在第二天买入股票 (即存在1天的冷冻期)。
-
第一步:dp 数组的定义是:
由于存在冷冻期,我们需要定义多个状态。
dp[i][0]:第i天结束时,手中 持有 股票的情况下,至今为止的最大利润。
dp[i][1]:第i天结束时,手中 不持有 股票,且 处于冷冻期 的情况下,至今为止的最大利润。
dp[i][2]:第i天结束时,手中 不持有 股票,且 不处于冷冻期 的情况下,至今为止的最大利润。 -
第二步:为了达到 dp[i] 的各个状态,最后一步可能是:
- 为了达到
dp[i][0](持有):可能是前一天就持有(dp[i-1][0]),今天休息;也可能是前一天不处于冷冻期(dp[i-1][2]),今天买入。 - 为了达到
dp[i][1](不持有, 冷冻期):必然是前一天持有股票(dp[i-1][0]),今天卖出。 - 为了达到
dp[i][2](不持有, 非冷冻期):可能是前一天就不处于冷冻期(dp[i-1][2]),今天休息;也可能是前一天处于冷冻期(dp[i-1][1]),今天冷冻期结束。
- 为了达到
-
第三步:因此,状态转移方程是:
dp[i][0] = max(dp[i-1][0], dp[i-1][2] - prices[i])
dp[i][1] = dp[i-1][0] + prices[i]
dp[i][2] = max(dp[i-1][2], dp[i-1][1])
最终的答案是max(dp[n-1][1], dp[n-1][2]),因为最后一天肯定是不持有股票利润才最大。 -
第四步:初始条件是:
dp[0][0] = -prices[0](第0天买入)
dp[0][1] = 0(不可能,但设为0方便计算)
dp[0][2] = 0(第0天什么都不做)
当然,动态规划的宝库还远未穷尽。这里有另一组代表性的、技巧性更强的经典动态规划问题,同样遵循您的四步模板。
37. 正则表达式匹配 (Regular Expression Matching)
问题描述: 给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 . 和 * 的正则表达式匹配。. 匹配任意单个字符,* 匹配零个或多个前面的那一个元素。所谓匹配,是要涵盖整个输入字符串 s 的,而不是部分字符串。
-
第一步:dp[i][j] 的定义是:
dp[i][j]是一个布尔值,表示字符串s的前i个字符s[0...i-1]是否能被模式p的前j个字符p[0...j-1]成功匹配。 -
第二步:为了达到 dp[i][j],最后一步可能是:
这取决于模式的最后一个字符p[j-1]是什么。- 如果
p[j-1]不是*:那么p[j-1]必须匹配s[i-1](p[j-1]是.或者等于s[i-1])。要使dp[i][j]为真,前提是dp[i-1][j-1]也必须为真。 - 如果
p[j-1]是*:*会作用于它前面的字符p[j-2]。这里有两种情况:*匹配零次:我们直接忽略p[j-2]*这部分,看s的前i个字符是否能被p的前j-2个字符匹配,即看dp[i][j-2]是否为真。*匹配一次或多次:前提是s[i-1]必须能被p[j-2]匹配。如果可以,那么p[0...j]对s[0...i]的匹配结果就取决于p[0...j]对s[0...i-1]的匹配结果,即看dp[i-1][j]是否为真。(相当于*帮助p[j-2]吃掉了s[i-1],然后继续用*去尝试匹配s的前i-1个字符)。
- 如果
-
第三步:因此,状态转移方程是:
如果p[j-1]不是*:dp[i][j] = i > 0 && dp[i-1][j-1] && (s[i-1] == p[j-1] || p[j-1] == '.')
如果p[j-1]是*:dp[i][j] = dp[i][j-2] || (i > 0 && dp[i-1][j] && (s[i-1] == p[j-2] || p[j-2] == '.')) -
第四步:初始条件是:
dp[0][0] = true(空字符串可以被空模式匹配)。
dp[i][0] = false(非空字符串无法被空模式匹配)。
dp[0][j]的情况需要特殊处理,因为像a*或a*b*这样的模式可以匹配空字符串。所以dp[0][j] = j > 1 && p[j-1] == '*' && dp[0][j-2]。
38. 俄罗斯套娃信封 (Russian Doll Envelopes)
问题描述: 给定一些标记了宽度和高度的信封,宽度和高度都为正整数。当另一个信封的宽度和高度都比这个信封大的时候,这个信封就可以放进另一个信封里。请计算最多能有多少个信封能组成一组“俄罗斯套娃”信封(即一个可以套一个)。
-
这是一个二维的最长上升子序列问题。 核心思想是通过巧妙的排序,将二维问题降维成一维的LIS问题。
预处理步骤: 首先,将所有信封按照宽度w进行升序排序。如果宽度相同,则按照高度h进行 降序 排序。
这样做的目的是:宽度相同时,高度更高的信封排在前面,保证了在后续求高度的LIS时,宽度相同的信封不可能被选入同一个上升子序列。 -
第一步:dp[i] 的定义是:
在经过排序后的信封数组的 高度 序列上,dp[i]表示长度为i的最长上升子序列的 末尾元素 的最小值。这是一个经典的、使用贪心+二分法优化LIS的dp定义。 -
第二步:为了达到 dp[i],最后一步可能是:
我们遍历排序后的每一个信封的高度h。对于每个h,我们要在dp数组中找到一个合适的位置来更新它。- 如果
h比dp数组中所有已知上升子序列的末尾元素都大,说明它可以接在最长的那个子序列后面,形成一个更长的子序列。 - 如果
h不比所有末尾元素都大,我们就找到dp数组中第一个大于等于h的元素,并用h替换它。这表示我们找到了一个潜在的、结尾更小(因此更有潜力)的同样长度的上升子序列。
- 如果
-
第三步:因此,状态转移方程是:
这不是一个传统的dp[i] = ...形式,而是通过二分查找来维护dp数组的过程。
令len为当前最长上升子序列的长度。
对每个高度h:
binary_search在dp[1...len]中找到h的位置。
如果h能插入到len之后,则len++,dp[len] = h。
否则,在dp中找到第一个大于等于h的位置j,更新dp[j] = h。
最终答案就是len。 -
第四步:初始条件是:
dp数组为空,len = 0。
39. 矩阵中的最长递增路径 (Longest Increasing Path in a Matrix)
问题描述: 给定一个整数矩阵 matrix,找出其中最长递增路径的长度。对于每个单元格,你可以往上、下、左、右四个方向移动。你不能在对角线方向上移动或移动到边界外。
-
这是一个在图(由矩阵隐式定义)上寻找最长路径的问题,适合使用记忆化搜索(自顶向下的DP)。
-
第一步:dp[i][j] 的定义是:
dp[i][j]表示从矩阵中单元格(i, j)出发,所能走出的最长递增路径的长度。 -
第二步:为了达到 dp[i][j],最后一步可能是:
从(i, j)出发,我们可以移动到其四周(上、下、左、右)的邻居(ni, nj),只要邻居的值matrix[ni][nj]大于当前值matrix[i][j]。从(i, j)出发的路径长度,就是1加上从那个符合条件的邻居出发的最长递增路径长度。我们需要在所有可能的下一步中选择能使总长度最长的那一个。 -
第三步:因此,状态转移方程是:
dp[i][j] = 1 + max(dp[ni][nj]),其中(ni, nj)是(i, j)的有效邻居,且matrix[ni][nj] > matrix[i][j]。如果没有这样的邻居,则max部分为0。 -
第四步:初始条件是:
dp数组所有元素初始化为0或-1,表示尚未计算。这是一个递归的终止条件。当dp[i][j]不为0时,直接返回其值,避免重复计算。最终答案是整个dp表中的最大值。
40. 数字DP (Digit DP)
问题描述: (这是一个问题类型的泛称)计算在区间 [L, R] 内,满足特定属性的整数有多少个。例如,计算 [1, N] 中不包含 "49" 这个子串的数字个数。
通用解法: 通常将问题 count(R) - count(L-1) 转化为求解 [1, N] 范围内满足条件的数的个数。
-
第一步:
dp(pos, state, is_limit, is_leading_zero)的定义是:
这是一个记忆化搜索的函数。它返回从第pos位开始构造数字,在之前的状态为state的情况下,总共有多少种合法的构造方法。pos: 当前正在处理的数字位(从高位到低位)。state: 一个或多个状态变量,用于记录构造到目前为止的关键信息。例如,前一位数字是什么(为了判断 "49"),是否已经满足了某个条件等。is_limit: 一个布尔值,表示当前位是否受到N对应位的限制。如果是,当前位数字最大只能取到N[pos]。is_leading_zero: 一个布尔值,表示当前是否处于前导零阶段。
-
第二步:为了达到
dp(pos, ...),最后一步可能是:
在当前pos位,我们可以尝试填入数字d。d的取值范围是[0, up],其中up由is_limit决定(如果is_limit为真,up = N[pos];否则up = 9)。对于每一个合法的d(即满足题目条件的d,例如不能与前一位组成 "49"),我们递归地调用函数去解决子问题:dp(pos+1, new_state, new_is_limit, ...)。 -
第三步:因此,状态转移方程是:
这是一个递归求和的形式:
ans = 0
for d from 0 to up:
if d is valid:
ans += dp(pos+1, new_state, is_limit && (d == up), ...)
memo[pos][state] = ans(在不is_limit的情况下缓存结果)
return ans -
第四步:初始条件是:
递归的基准情况(出口):当pos超出N的长度时,说明我们成功构造了一个完整的、合法的数字,此时应该返回1。整个过程由初始调用dp(0, initial_state, true, true)启动。

浙公网安备 33010602011771号