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)
启动。