1. 什么是动态规划?
动态规划并非一种特定的算法,而是一种思想,即将一个大问题划分成子问题,并以子问题的答案推导出原问题的解。应用动态规划前,需要对子问题与原问题的关系,以及子问题之间的关系进行分析。这两个方面分别对应了“最优子结构”和“重复子问题”。动态规划的代码长度通常都不长,而难点在于,没有统一的公式或方法来确定最优子结构和重复子问题,因此常常变成“能想出来状态转移方程就能通过,想不出来就一直卡壳”的状况。对于这一点,除了多看经典动态规划问题,多写状态转移方程积累经验外,似乎没有更好的办法…
a) 最优子结构
最优子结构指的是子问题与原问题的关系,即一个问题的最优解是由它的各个子问题的最优解决定的。通常动态规划要解决的都是一些问题的最优解,如果我们可以把问题拆解成多个子问题,然后递归地找到每个子问题的最优解,最后通过一定的数学方法对各个子问题的最优解进行组合得出最终的结果的话,就称这个问题具有最优子结构。最优子结构主要有三要素构成:初始状态,状态转移方程,边界条件。
通过将子问题的解进行组合,从而得到原问题的解的过程被称为“状态转移”,一般用状态转移方程描述这种组合。
例子:斐波那契数列
一个数列,如果元素均满足满足第i个数(i>=2)是由第i-1个数和第i-2个数的和,那么称这个数列为斐波那契数列。
斐波那契数列的第0项和第1项是确定的,这就是初始状态。
而对于剩下的数列元素,都存在着这样一个状态转移方程,即:
F
(
i
)
=
F
(
i
−
1
)
+
F
(
i
−
2
)
(
i
>
=
2
)
F(i) = F(i-1) + F(i-2)\;(i>=2)
F(i)=F(i−1)+F(i−2)(i>=2)
而 F ( i − 1 ) 和 F ( i − 2 ) F(i-1)和F(i-2) F(i−1)和F(i−2)也满足斐波那契数列的定义,所以 F ( i − 1 ) = F ( i − 2 ) + F ( i − 3 ) F ( i − 2 ) = F ( i − 3 ) + F ( i − 4 ) F(i-1) = F(i-2)+F(i-3) \\ F(i-2) = F(i-3)+F(i-4) F(i−1)=F(i−2)+F(i−3)F(i−2)=F(i−3)+F(i−4)
如此一直递归下去,直至第0项和第1项,因为斐波那契数列的第0项 F(0) 和 第1项 F(1)是确定的,所以终止递归,开始返回结果,最终得到F(i)。这里的“到达第0项和第1项就停止递归”,就是“边界条件”。
需要注意的是,同一个问题,在不同条件下可能包含不同的状态转移方程。比如,如果我们规定,只有当i为偶数时,F(i)=F(i-1)+F(i-2),而当i为奇数时,F(i) = F(i-1),那么状态转移方程就会变为:
F
(
i
)
=
{
f
(
i
−
1
)
+
f
(
i
−
2
)
i
=
2
k
f
(
i
−
1
)
i
=
2
k
+
1
F(i)=\left\{ \begin{aligned} f(i-1)+f(i-2)\;\;\;\;\;i=2k\\ f(i-1)\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;i=2k+1 \end{aligned}\right.
F(i)={f(i−1)+f(i−2)i=2kf(i−1)i=2k+1
b) 重复子问题
重复子问题规定的是子问题之间的关系。在我们递归寻找每个子问题的最优解时,可能会重复地遇到一些更小的子问题,而这些子问题会重叠地出现在子问题中,导致许多重复计算。而动态规划的优势就在于,对于每个重叠的子问题只需要计算一次,从而减少了计算冗余,提升了效率,相比暴力搜索快很多。这里我们依然举斐波那契数列的例子,按照上面的状态转移方程,我们想要计算f(i),就需要先知道f(i-1)和f(i-2),而计算f(i-1)又需要先计算f(i-2)和f(i-3),计算f(i-2)需要知道f(i-3)和f(i-4)。在这几步计算中,f(i-2)被计算了2次,f(i-3)也被计算了两次,如果我们在计算过程中将子问题的结果保存,遇到重复的子问题时直接返回,就可以避免重复计算了。
2. 如何动态规划?
虽然不同题目的状态转移方程千变万化,但在思考问题的路线上或许存在着一些相似之处:首先阅读题面,思考问题的本质在问什么(e.g., 区间?序列?矩阵?背包?etc.),然后思考能否将问题的规模缩小。根据缩小的方法不同,我们可以将动态规划分为不同类别,比如线性,区间,树形等等。
例子:LeetCode 300. 最长上升子序列
题目大意:给定一个无序的整数数组,找到其中最长上升子序列的长度。
样例输入:[10,9,2,5,3,7,101,18]
样例输出:4
样例解释:最长的上升子序列是 [2,3,7,101],它的长度是4。[2,5,7,101], [2,5,7,18], [2,3,7,18]同样也是长度为4的上升序列
题面很直接,问上升序列的最大长度。对于一个长度n的数组,我们可以一下子想不出什么好办法能一下子就知道结果,因此考虑将问题规模缩小。数组上常用的两种缩小思路:缩小一半,缩小1个。
如果是缩小一半,则原问题变成求[10,9,2,5]和[3,7,101,18]的最长上升序列,两个子问题的最优解分别为[2,5]和[3,7,101],但是找不到好的方法将两个子问题最优解组合为[2,3,7,101]。
如果是缩小一个,则原问题可以划分成如下子问题:
[10,9,2,5,3,7,101]
→
\rightarrow
→ [2,3,7,101]=4
[10,9,2,5,3,7]
→
\rightarrow
→ [2,3,7]=3
[10,9,2,5,3]
→
\rightarrow
→ [2,3]=2
[10,9,2,5]
→
\rightarrow
→ [2,5]=2
[10,9,2]
→
\rightarrow
→ [2]=1
[10,9]
→
\rightarrow
→ [9]=1
[10]
→
\rightarrow
→ [10]=1
初始状态:每个元素在没有与别的元素组合之前,自己就是最长上升序列,长度为1。
状态转移:对于每个元素i,遍历其左边所有元素,寻找比i小的元素,看看能否构成更长的上升子序列。比如,对于18,其左边的元素中,以7为末尾的最长上升子序列长度为3,因此18可以加到7的后面,形成长度为4的最长上升子序列。
f
(
n
)
=
m
a
x
f
(
i
)
+
1
,
i
<
n
且
a
[
i
]
<
a
[
n
]
f(n)=max f(i) +1\;\;,\;\;i<n且a[i]<a[n]
f(n)=maxf(i)+1,i<n且a[i]<a[n]
边界条件:对于每个元素i,只遍历其左边的元素到数组左边界
关键:如何定义f(n),以及如何通过f(1), f(2), …, f(n-1)推导出f(n),即状态转移方程
实现方式:
1. 递归:
根据状态转移方程,直接用递归函数的方式实现。
2. 自顶向下(记忆化):
递归会产生很多重复计算,因此我们对递归的每一步结果都进行保存(记忆化),当再次遇到相同计算时直接返回记忆的结果,由此避免重复计算,提升效率。因为递归是从原始问题向下划分寻找子问题的结果,然后再向上一步步返回,更新上一层问题的解。因此称为“自顶向下”的解法
3. 自下而上(迭代):
自顶向下由于递归的存在,会消耗一部分性能。而在知道状态转移方程的情况下,从最小的问题规模入手,直到所要求的问题规模为止。这个过程中,我们同样记忆每个问题规模的解,来避免重复的计算,又避免了递归。但是迭代法需要有一个明确的迭代方向,如果方向不明确,则可以退而求其次,用记忆化来解决。
3. 动态规划与其他算法的比较
从算法思想的定义上,动态规划与分治,贪心存在着很多相似之处。
分治:
分治也是将原问题分割成多个子问题,有时候减少一个,有时候减少一般,然后将每个子问题的解以及当前的情况组合起来得出最终的结果,比如归并排序和快速排序。分治也存在最优子结构,但不存在重复子问题,因为对问题不断分化时,被分化的数据之间并不重叠,分别解决左边和右边的子问题后,没有子问题重复出现。
贪心:
- 贪心也存在最优子结果,但每一步的最优解一定包含上一步的最优解,因此不需要记录上一步之前的最优解。而动态规划的最优解一定包含某个局部最优解,但不一定包含上一步的局部最优解。
- 如果把所有子问题看作一棵树,那么贪心就是从根出发,每次向下寻找最优子树,不需要知道一个节点的所有子树的情况。但动态规划需要对每个子树求最优解,直到每一个叶子节点的值,最后得到一棵完整的数,在所有子树都得到最优解后,将他们组合成答案。
- 结果正确性上,贪心不能保证求得的最后解一定是最佳的,动态规划本质是穷举法,因此可以保证结果是最佳的。
4. 线性动态规划
线性的动态规划特点:状态的推导是按照问题规模i从小到大依次推过去,较大规模的问题的解依赖较小规模的问题的解。这里的问题规模为i的含义是考虑前i个元素[0…i]时问题的解,即:
状
态
定
义
:
d
p
[
n
]
:
=
[
0..
n
]
状
态
转
移
:
d
p
[
n
]
=
f
(
d
p
[
n
−
1
]
,
d
p
[
n
−
2
]
,
.
.
,
d
p
[
0
]
)
状态定义:dp[n]:=[0..n]\\状态转移:dp[n] = f(dp[n-1], dp[n-2], .., dp[0])
状态定义:dp[n]:=[0..n]状态转移:dp[n]=f(dp[n−1],dp[n−2],..,dp[0])
A. 单串:线性动态规划中最简单的一类问题,状态一般定义为:
d
p
[
i
]
:
=
在
[
0..
i
]
上
原
问
题
的
解
dp[i]\;:=\;在[0..i]上原问题的解
dp[i]:=在[0..i]上原问题的解
其中i位置的处理,根据不同的问题,主要有两种方式:
- 第一种:第i个位置必须包含在结果中,此时状态变为:在[0…i]上且取i时原问题的解
- 第二种:第i个位置不必包含在结果中
采用第一种方式的问题比较常见,类似的有:爬楼梯问题,使序列递增的最小交换次数等。
A1. 最长上升子序列问题(经典LIS系列,i非必须取)
练习题1:LeetCode 300. 最长上升子序列
题目大意:给定一个整数数组nums,找到其中最长严格递增子序列的长度
状态定义:设dp[i]表示以第i个元素为末尾的最长严格递增子序列的长度
初始状态:每个元素在没有和别的元素进行匹配的时候,只有与自己构成最长上升子序列。因此,dp[i]初始等于1
状态转移:对于每一个元素nums[i],如果存在
j
<
i
j<i
j<i 且
n
u
m
s
[
j
]
<
n
u
m
s
[
i
]
nums[j]<nums[i]
nums[j]<nums[i],那么nums[i]可以接在以nums[j]为末尾的最长上升子序列的后面,因此,以nums[i]为末尾的最长上升子序列的长度就是nums[j]的上升子序列长度+1。又因为可能存在多个比nums[i]小的元素,因此我们选取其中最长的序列。得到状态转移方程:
d
p
[
i
]
=
m
a
x
(
d
p
[
i
]
,
d
p
[
j
]
+
1
)
j
<
i
且
n
u
m
s
[
j
]
<
n
u
m
s
[
i
]
dp[i] = max(dp[i], dp[j]+1)\;\;\;j<i \;且\;nums[j]<nums[i]
dp[i]=max(dp[i],dp[j]+1)j<i且nums[j]<nums[i]
练习题2:LeetCode 673. 最长递增子序列的个数
题目大意:给定一个整数数组nums,找到其中最长递增子序列的个数。
状态定义:设lens[i]表示以第i个元素为末尾的最长严格递增子序列的长度,设dp[i]为以第i个元素为末尾的最长严格递增子序列的个数。
初始状态:每个元素在没有和别的元素进行匹配的时候,只有与自己构成最长上升子序列。因此,dp[i]初始等于1,lens[i]初始也等于1。
状态转移:对于每一个元素nums[i],如果存在 j < i j<i j<i 且 n u m s [ j ] < n u m s [ i ] nums[j]<nums[i] nums[j]<nums[i],那么nums[i]可以接在以nums[j]为末尾的最长上升子序列的后面,因此,以nums[i]为末尾的最长上升子序列的长度就是nums[j]的上升子序列长度+1,而达到nums[i]的最长上升子序列的路径数量与到达nums[j]的最长上升子序列的路径数量。如果lens[j]+1==lens[i],那么到达nums[i]最长上升子序列的路径可以增加dp[j]个。得到状态转移方程,同时需要更新lens:
d p [ i ] = { d p [ i ] + d p [ j ] , l e n s [ i ] = = l e n s [ j ] + 1 d p [ j ] , l e n s [ i ] < l e n s [ j ] + 1 dp[i] = \left\{ \begin{aligned} dp[i]+dp[j]\;\;,\;\;lens[i]==lens[j]+1\\ dp[j]\;\;\;\;, \;\;\;lens[i]<lens[j]+1\\ \end{aligned}\right. dp[i]={dp[i]+dp[j],lens[i]==lens[j]+1dp[j],lens[i]<lens[j]+1
练习题3:LeetCode 354. 俄罗斯套娃信封问题
题目大意:给定一个二维数组envelopes,其中envelopes[i]=[wi, hi],表示第i个信封的宽度和高度。当另一个信封的宽度和高度逗比这个信封大的时候,这个信封可以放进另一个信封里,如同俄罗斯套娃一样。求,最多能有多少个信封套在一起?
状态定义:设dp[i]为第i枚信封可以套的最大的信封数量
初始状态:每个信封在没有和别的信封进行匹配的时候,只有自己,因此dp[i]初始等于1。
状态转移:首先,我们对数组的顺序没有要求,且当信封 j 的尺寸只有在小于信封 i 的尺寸时才能放入信封i,所以为了加快匹配的速度,我们可以先对数组进行排序。对于每个信封 i, 如果
j
<
i
j<i
j<i 且 $ 信封j的尺寸<信封i的尺寸$,那么j及放入j的信封可以统一放入信封i。因为能放入信封i的信封可能有多个,所以我们选择所有可能中信封数量最多的。得到状态转移方程:
d
p
[
i
]
=
m
a
x
(
d
p
[
i
]
,
d
p
[
j
]
+
1
)
j
<
i
且
n
u
m
s
[
j
]
<
n
u
m
s
[
i
]
dp[i] = max(dp[i], dp[j]+1)\;\;\;j<i \;且\;nums[j]<nums[i]
dp[i]=max(dp[i],dp[j]+1)j<i且nums[j]<nums[i]
A2. 最大子数组和系列 (连续子数组,i必须取)
练习题1:LeetCode 53. 最大子数组和
题目大意:给你一个整数数组nums,求其中连续子数组的最大和。
状态定义:设dp[i]为以第i个元素为末尾的连续数组最大和
初始状态:每个信封在没有和别的信封进行匹配的时候,只有自己,因此dp[i]初始等于nums[i],也可以令其为0
状态转移:因为是连续子数组,因此不能对数组排序。对于每个元素 i ,以第i个元素为末尾的连续数组的最大和只能来源于自己或者以第i-1个元素为末尾的连续数组的和+自己,取最大值。得到状态转移方程:
d
p
[
i
]
=
m
a
x
(
n
u
m
s
[
i
]
,
d
p
[
i
−
1
]
+
n
u
m
s
[
i
]
)
dp[i] = max(nums[i], dp[i-1]+nums[i])
dp[i]=max(nums[i],dp[i−1]+nums[i])
练习题2:LeetCode 152. 乘积子数组和
题目大意:给你一个整数数组nums,求其中连续子数组的最大乘积。
状态定义:设dp[i][0]为以第i个元素为末尾的连续数组最大乘积,dp[i][1]为以第i个元素为末尾的连续数组的最小乘积。本题找的是最大乘积,而最大乘积既可能是一个正数乘以一个正数,也可能是一个很小的负数乘以一个负数得来的,因此我们需要同时记录最大乘积和最小乘积。
初始状态:每个数字在没有和别的数字进行组合的时候,只有自己,因此dp[i][0]和dp[i][1]初始都等于nums[i],也可以令其为0
状态转移:因为是连续子数组,因此不能对数组排序。对于每个元素 i ,以第i个元素为末尾的连续数组的最大乘积可能来源于以第i-1个元素为末尾的连续数组的最大乘积乘以nums[i],也可能来自于以第i-1元素为末尾的最小乘积乘以nums[i],还可能是nums[i]本身。同时,我们还需要更新最小乘积,最小乘积可能来自nums[i], 最大乘积乘以nums[i], 最小乘积乘以nums[i]。得到状态转移方程:
d
p
[
i
]
[
0
]
=
m
a
x
(
n
u
m
s
[
i
]
,
d
p
[
i
−
1
]
[
0
]
∗
n
u
m
s
[
i
]
,
d
p
[
i
−
1
]
[
1
]
∗
n
u
m
s
[
i
]
)
d
p
[
i
]
[
1
]
=
m
i
n
(
n
u
m
s
[
i
]
,
d
p
[
i
−
1
]
[
0
]
∗
n
u
m
s
[
i
]
,
d
p
[
i
−
1
]
[
1
]
∗
n
u
m
s
[
i
]
)
dp[i][0] = max(nums[i], dp[i-1][0]*nums[i], dp[i-1][1]*nums[i])\\ dp[i][1] = min(nums[i], dp[i-1][0]*nums[i], dp[i-1][1]*nums[i])
dp[i][0]=max(nums[i],dp[i−1][0]∗nums[i],dp[i−1][1]∗nums[i])dp[i][1]=min(nums[i],dp[i−1][0]∗nums[i],dp[i−1][1]∗nums[i])
练习题3:LeetCode 918. 环形子数组的最大和
题目大意:给你一个整数数组nums表示的环形数组,求其中连续子数组的最大和。环形数组就是头尾相接,下标为n-1的元素之后是下标为0的元素。每个元素只能使用一次。
状态定义:设dp[i][0]为以第i个元素为末尾的连续数组最大和,dp[i][1]为以第i个元素为末尾的连续数组的最小和。对于一个环形数组,如果最大和子数组包含在数组nums中,则我们找到的连续数组最大和就是答案;如果最大和子数组横跨边界,实际上是nums的两边,那么为了使这个数组和最大,那么数组中间的部分应该尽可能小,因此我们也记录最小值,最终用数组的总和减去最小值,就是跨界的子数组的最大和。
初始状态:每个数字在没有和别的数字进行组合的时候,只有自己,因此dp[i][0]和dp[i][1]初始都等于nums[i]。
状态转移:因为是连续子数组,因此不能对数组排序。对于每个元素 i ,以第i个元素为末尾的连续数组的最大和只能来源于自己或者以第i-1个元素为末尾的连续数组的最大和+自己,取最大值。而最小和,则是自己或者以第i-1个元素为末尾的连续数组的最小和+自己。得到状态转移方程:
d
p
[
i
]
[
0
]
=
m
a
x
(
n
u
m
s
[
i
]
,
d
p
[
i
−
1
]
[
0
]
+
n
u
m
s
[
i
]
)
d
p
[
i
]
[
1
]
=
m
i
n
(
n
u
m
s
[
i
]
,
d
p
[
i
−
1
]
[
1
]
+
n
u
m
s
[
i
]
)
dp[i][0] = max(nums[i], dp[i-1][0]+nums[i])\\ dp[i][1] = min(nums[i], dp[i-1][1]+nums[i])
dp[i][0]=max(nums[i],dp[i−1][0]+nums[i])dp[i][1]=min(nums[i],dp[i−1][1]+nums[i])
注意:如果数组nums的元素全为负数,则总和减去最小值的结果必定为0,但这个最大和的部分为空导致的0,不合规。因此,当最大和小于0时,说明nums均为负数,直接返回最大和即可。否则,返回最大和与总和减去最小和中的最大值。
A3. 打家劫舍系列 (不相邻子序列的最大和问题,i非必须取)
练习题1:LeetCode 198. 打家劫舍
题目大意:给你一个整数数组nums,可以选择一个数组的元素,获得它的点数,但相邻的元素将不能再被选择,继续操作直至没有元素可以选择。求能获得的最大分数。
状态定义:设dp[i]为前i个元素中能获得的最大和
初始状态:最初没有选择时,每个元素只有自己,因此dp[i]初始等于nums[i],也可以令其为0
状态转移:对于第i个元素,我们有两个选择,选择或不选择。如果选择,则我们能获得的最大分数为前i-2个元素能获得的最大分数+第i个元素的分数;如果不选择,则我们能获得的最大分数为前i-1个元素能获得的最大分数。得到状态转移方程:
d
p
[
i
]
=
m
a
x
(
d
p
[
i
−
2
]
+
n
u
m
s
[
i
]
,
d
p
[
i
−
1
]
)
dp[i] = max(dp[i-2]+nums[i], dp[i-1])
dp[i]=max(dp[i−2]+nums[i],dp[i−1])
练习题2:LeetCode 213. 打家劫舍II
题目大意:给你一个环形整数数组nums,可以选择一个数组的元素,获得它的点数,但相邻的元素将不能再被选择。注意,如果数组的头部被选择,则末尾的元素因为与头部相邻,将不再能被选择,反之亦然。如果。继续操作直至没有元素可以选择。求能获得的最大分数。
状态定义:设dp[i]为前i个元素中能获得的最大和
初始状态:最初没有选择时,每个元素只有自己,因此dp[i]初始等于nums[i],也可以令其为0
状态转移:对于第i个元素,我们有两个选择,选择或不选择。如果选择,则我们能获得的最大分数为前i-2个元素能获得的最大分数+第i个元素的分数;如果不选择,则我们能获得的最大分数为前i-1个元素能获得的最大分数。同时,因为头尾不能同时选择,所以我们需要分开计算选择头时的情况,和不选择头的情况下得到的最大分数。得到状态转移方程:
d
p
[
i
]
=
m
a
x
(
d
p
[
i
−
2
]
+
n
u
m
s
[
i
]
,
d
p
[
i
−
1
]
)
dp[i] = max(dp[i-2]+nums[i], dp[i-1])
dp[i]=max(dp[i−2]+nums[i],dp[i−1])
练习题3:LeetCode 740. 删除并获得点数
题目大意:给你一个整数数组nums,每次操作中选择任意一个nums[i],删除它并获得nums[i]的点数,之后必须删除所有等于nums[i]-1和nums[i]+1的元素,求能获得的最大分数。
状态定义:设dp[i]为前i个元素中能获得的最大和
初始状态:最初没有选择时,每个元素只有自己,因此dp[i]初始等于nums[i],也可以令其为0
状态转移:首先统计每个数字的总点数,构造一个分数数组。之后就与打家劫舍一样操作即可。得到状态转移方程:
d
p
[
i
]
=
m
a
x
(
d
p
[
i
−
2
]
+
n
u
m
s
[
i
]
,
d
p
[
i
−
1
]
)
dp[i] = max(dp[i-2]+nums[i], dp[i-1])
dp[i]=max(dp[i−2]+nums[i],dp[i−1])
练习题4:LeetCode 1388. 3n披萨
题目大意:给你一个环形数组nums,长度为3*n。每次操作中选择任意一个nums[i],删除它并获得nums[i]的点数,之后必须删除其相邻的元素,求能获得的最大分数。
状态定义:设dp[i][j]为前i个元素中选择j个元素能获得的最大分数
初始状态:最初没有选择时,每个元素只有自己,因此dp[i]初始等于nums[i],也可以令其为0
状态转移:本题与LeetCode 213. 打家劫舍II有相似之处,均为环形数组,并且是求不相邻元素最大和。但是,本题的区别在于,打家劫舍II中偷走某个房子的财富后,相邻两家虽然不能偷,但并不会删除,因此每家的相邻位置关系是不变的,而本题中,选择一个元素后,相邻的两个元素会被删除,而剩余的部分会自动补位,形成一个新的环。设有一个长度为6的环形数组,按照打家劫舍II的策略,我们可以选3次,但是本题中我们实际最多只能选2次。
假如本题不是环形数组,那么对于第i个元素,我们有两种选择,选/不选,取两种操作的最大值:
- 选:那么第i-1个元素会被删除,我们只能从第1~i-2个元素中选择j-1个元素
- 不选:那么我们需要从第1~i-1个元素中选择j个元素
得到状态转移方程:
d
p
[
i
]
[
j
]
=
{
m
a
x
(
d
p
[
i
−
2
]
[
j
]
+
n
u
m
s
[
i
]
,
d
p
[
i
−
1
]
[
j
]
)
,
i
>
=
2
m
a
x
(
n
u
m
s
[
i
]
,
d
p
[
i
−
1
]
[
j
]
)
,
i
<
2
dp[i][j] = \left \{ \begin{aligned} max(dp[i-2][j]+nums[i], dp[i-1][j])\;\;,\;\;i>=2\\ max(nums[i], dp[i-1][j])\;\;\;\;\;\;,\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;i<2 \end{aligned} \right.
dp[i][j]={max(dp[i−2][j]+nums[i],dp[i−1][j]),i>=2max(nums[i],dp[i−1][j]),i<2
那么,换成环形数组,也只需要排除头部元素或者尾部元素即可。分别构造0 ~ n-2的数组和1 ~ n-1的数组,按照上面的思路计算即可。
A4. 需要末两位状态的单串问题(i必须取)
练习题1:LeetCode 873. 最长的斐波那契子序列的长度
题目大意:给你一个严格递增的正整数数组nums,请找出数组中最长的斐波那契式的子序列长度,如果不存在,返回0。
状态定义:设dp[i][j]为以i和j为末尾的斐波那契子序列的最大长度
初始状态:最初没有选择时,每个元素只有自己,令dp[i][j]为0
状态转移:与之前的单串问题不同,本题因为是斐波那契数列,需要至少两个元素才能确定第三个元素,构成斐波那契数列。因此,我们通过固定末尾元素i,枚举其左边的元素j,则第三个构成斐波那契数列的元素应为k=nums[i]-nums[j]。寻找k的过程可以用哈希提前预处理每个元素的下标,也可以用二分查找(因为数组严格递增),搜索范围为0~j-1。如果找到了k,下标为left,且left<j,则我们可以将nums[i]插到以j和k为末尾的斐波那契数列后,长度+1,即:
d
p
[
i
]
[
j
]
=
m
a
x
(
d
p
[
i
]
[
j
]
,
d
p
[
j
]
[
k
]
+
1
)
dp[i][j]=max(dp[i][j], dp[j][k]+1)
dp[i][j]=max(dp[i][j],dp[j][k]+1)
同时尝试更新答案,
a
n
s
=
m
a
x
(
a
n
s
,
d
p
[
i
]
[
j
]
+
2
)
ans=max(ans,dp[i][j]+2)
ans=max(ans,dp[i][j]+2),这里的+2是为了弥补最开始的没有前驱元素的两个元素的长度。
练习题2:LeetCode 1027. 最长等差数列
题目大意:给你一个整数数组nums,返回nums中最长等差子序列的长度。
状态定义:设dp[i][j]为以i为末尾,公差为k的等差序列的最大长度
初始状态:最初没有选择时,每个元素只有自己,任意公差k下,长度都为1
状态转移:
等差子序列需要至少2个元素构成,因此需要两个元素来确定公差。我们固定末尾元素i,枚举其左边的元素j,公差为k=nums[i]-nums[j]。末尾为i,公差为k的等差序列长度 = 末尾为j,公差为k的等差序列+1,即:
d
p
[
i
]
[
k
]
=
m
a
x
(
d
p
[
i
]
[
k
]
,
d
p
[
j
]
[
k
]
+
1
)
dp[i][k]=max(dp[i][k], dp[j][k]+1)
dp[i][k]=max(dp[i][k],dp[j][k]+1)
与之前的单串问题不同,本题因为是斐波那契数列,需要至少两个元素才能确定第三个元素,构成斐波那契数列。因此,我们通过固定末尾元素i,枚举其左边的元素j,则第三个构成斐波那契数列的元素应为k=nums[i]-nums[j]。寻找k的过程可以用哈希提前预处理每个元素的下标,也可以用二分查找(因为数组严格递增),搜索范围为0~j-1。如果找到了k,下标为left,且left<j,则我们可以将nums[i]插到以j和k为末尾的斐波那契数列后,长度+1,即:
d
p
[
i
]
[
j
]
=
m
a
x
(
d
p
[
i
]
[
j
]
,
d
p
[
j
]
[
k
]
+
1
dp[i][j]=max(dp[i][j], dp[j][k]+1
dp[i][j]=max(dp[i][j],dp[j][k]+1
同时尝试更新答案,
a
n
s
=
m
a
x
(
a
n
s
,
d
p
[
i
]
[
k
]
)
ans=max(ans,dp[i][k])
ans=max(ans,dp[i][k])
A5. 其他单串问题
练习题1:LeetCode 32. 最长有效括号
题目大意:给你一个只包含’(‘和’)'的字符串,找出最长有效(格式正确且连续)括号子串的长度。
状态定义:设dp[i]为以第i个字符为末尾的最长有效括号子串的长度
初始状态:最初没有配对时,有效长度为0,因此dp[i]=0
状态转移:根据状态定义,当s[i]为左括号时必定非有效括号字符串,因此长度为0。当s[i]为右括号时,分两种情况:
- s[i-1]为左括号时,可以和s[i]配对,因此dp[i] = dp[i-2]+2,dp[i-2]为s[i-2]为末尾的最长有效子串长度
- s[i-1]为右括号时,我们需要越过以s[i-1]为末尾的最长有效子串,去判断s[i-dp[i-1]-1]是否为左括号,如果是,那么以s[i]为末尾的最长有效字符串为:匹配的左右括号(2)+s[i-1]的最长有效字符串长度(dp[i-1])+匹配的左括号之前的有效字符串部分(dp[i-dp[i-1]-2]。得到状态转移方程:
d
p
[
i
]
[
j
]
=
{
d
p
[
i
−
2
]
+
2
,
s
[
i
−
1
]
为
左
括
号
d
p
[
i
−
1
]
+
d
p
[
i
−
d
p
[
i
−
1
]
−
2
]
+
2
,
s
[
i
−
1
]
为
)
且
s
[
i
−
d
p
[
i
−
1
]
−
1
]
为
(
dp[i][j] = \left \{ \begin{aligned} dp[i-2]+2\;\;\;\;\;\;\;\;,\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;s[i-1]为左括号\\ dp[i-1]+dp[i-dp[i-1]-2]+2\;\;\;\;\;\;,\;\;s[i-1]为)且s[i-dp[i-1]-1]为(\\ \end{aligned} \right.
dp[i][j]={dp[i−2]+2,s[i−1]为左括号dp[i−1]+dp[i−dp[i−1]−2]+2,s[i−1]为)且s[i−dp[i−1]−1]为(
边界问题:需要注意 i-2,i-dp[i-1]-1, i-dp[i-1]-2, 是否越界
练习题2:LeetCode 413. 等差数列划分
题目大意:给定一个整数数组nums,返回数组nums中所有为等差数组的子数组个数。
状态定义:设dp[i][j]为以第i个元素为末尾,长度为j的子数组是否为等差数组
初始状态:最初没有配对时,因此dp[i][j]均为false。从i=2开始遍历nums,如果一个元素nums[i]满足:
n
u
m
s
[
i
]
−
n
u
m
s
[
i
−
1
]
=
=
n
u
m
s
[
i
−
1
]
−
n
u
m
s
[
i
−
2
]
nums[i]-nums[i-1]==nums[i-1]-nums[i-2]
nums[i]−nums[i−1]==nums[i−1]−nums[i−2]
那么以i为末尾,长度为3的子数组为等差数组,dp[i][3]为true,同时ans++。
状态转移:一个长度为n的nums,等差数列的最大长度必定≤n,而一个长度L (L>3) 的等差数列,必定由两个长度为L-1的等差数列组成,这两个等差数列的末尾一定是长度为L的等差数列末尾的两个元素。比如[1,2,3,4]就是由[1,2,3]和[2,3,4]构成。因此,以第i-1个元素为末尾,长度为L-1的数列为true,且第i1个元素为末尾,长度为L-1的数列为true,则以i为末尾,长度为L的数列为true。得到状态转移方程:
d
p
[
i
]
[
j
]
=
d
p
[
i
−
1
]
[
j
−
1
]
∧
d
p
[
i
]
[
j
−
1
]
dp[i][j] = dp[i-1][j-1] \land dp[i][j-1]
dp[i][j]=dp[i−1][j−1]∧dp[i][j−1]
练习题3:LeetCode 91. 解码方法
题目大意:一条包含字符A-Z的消息通过A=1,B=2,…, Z=26的方式进行了编码。现在拿到一串只包含0-9的字符串,进行解码,求解码方法的总数。
状态定义:设dp[i]为以第i个字符为末尾的字符串解法方式总数
初始状态:最初没有配对时,均单独解码,方法为1,因此dp[i]=1
状态转移:根据情况讨论
- 如果s[i]为0,则检查之前是否为1或者2,因为0无法单独解码,必须与前一位组合解码。如果前一位非1或者2,则不能正确解码,返回0;如果前一位为1或者2,则s[i]的解码总数依赖于s[i-2]之前的字符的解码总数,dp[i]=dp[i-2];
- 如果s[i]非0,则检查之前是否为1或者2。如果为1,则s[i]有两种选择:独立解码,组合解码。如果是独立解码,则dp[i]=dp[i-1];如果组合解码,则dp[i]=dp[i-2]。所以s[i]的解码总数为dp[i-1]+dp[i-2];如果为2,且s[i]小于等于6,则和s[i-1]为1的情况相同,否则只能独立解码。得到状态转移方程:
d p [ i ] = { d p [ i − 1 ] + d p [ i − 2 ] , s [ i − 1 ] = 1 ∨ ( s [ i − 1 ] = 2 ∧ s [ i ] ≤ 6 ) d p [ i − 1 ] , s [ i ] = 0 ∨ ( s [ i − 1 ] = 2 ∧ s [ i ] ≥ 7 ) dp[i] = \left \{ \begin{aligned} dp[i-1]+dp[i-2],s[i-1]=1\lor (s[i-1]=2 \land s[i] \leq6)\\ dp[i-1]\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;,\;\;\;\;\;\;s[i]=0\lor (s[i-1]=2 \land s[i] \geq7)\\ \end{aligned} \right. dp[i]={dp[i−1]+dp[i−2],s[i−1]=1∨(s[i−1]=2∧s[i]≤6)dp[i−1],s[i]=0∨(s[i−1]=2∧s[i]≥7)
边界问题:需要注意 i-1是否越界
练习题4:LeetCode 132. 分割回文串II
题目大意:给定一个字符串s,请你将s分割成一些子串,使每个子串都是回文。返回符合要求的最少分割次数。
状态定义:设dp[i][j]为以第i个字符为末尾的字符串是否为回文串,设cut[i]为以第i个字符为末尾的字符串的最少切割次数。
初始状态:字符串匹配之前,均可能会回文串,因此dp[i][j]=true;而对于长度为n的字符串,最多切割n-1次,因此cut初始化为n
状态转移:首先判断从i开始j结束的字符串是否为回文串,如果s[i]与s[j]相同,则如果s[i+1]~s[j-1]的部分为回文串,则s[i] ~ s[j]也为回文串:
d
p
[
i
]
[
j
]
=
s
[
i
]
=
=
s
[
j
]
∧
d
p
[
i
+
1
]
[
j
−
1
]
dp[i][j] = s[i]==s[j] \land dp[i+1][j-1]
dp[i][j]=s[i]==s[j]∧dp[i+1][j−1]
判断完回文串的部分后,开始对字符串s进行切割。
- 如果0~i为回文串,则不需要切割,cut[i]=0;
- 如果0~i为非回文串,则从j=1开始枚举切割位置,如果j+1 ~ i为回文串,则我们只需要在0 ~ j 与j+1 ~ i 之间切一刀即可。
c u t [ i ] = m i n ( c u t [ i ] , c u t [ j ] + 1 ) , i f : d p [ j + 1 ] [ i ] = t r u e cut[i] = min(cut[i], cut[j]+1)\;\;,\;\; if:\;\;dp[j+1][i]=true cut[i]=min(cut[i],cut[j]+1),if:dp[j+1][i]=true
边界问题:需要注意 i-1是否越界
练习题5:LeetCode 338. 比特位计数
题目大意:给定一个n,请返回一个长度为n+1的数组,每个元素为下标的二进制中包含的1的个数。
状态定义:ans[i]表示i的二进制中包含的1的个数。
初始状态:ans[0]=0;
状态转移:如果i为奇数,那么它的二进制个数一定是i-1多1,因为偶数二进制的最低位一定是0。如果i为偶数,那么它的二进制的个数一定与i/2的二进制1的个数相同。得到状态转移方程:
a
n
s
[
i
]
=
(
i
m
o
d
2
=
=
0
?
a
n
s
[
i
/
2
]
:
a
n
s
[
i
−
1
]
+
1
)
ans[i] = (i\;mod\;2\;==\;0\;?\;ans[i/2]\;:\;ans[i-1]+1)
ans[i]=(imod2==0?ans[i/2]:ans[i−1]+1)
练习题6:LeetCode 801. 使序列递增的最小交换次数
题目大意:给定两个长度相等且不为空的整数数组A和B。我们可以交换A[i]和B[i]的元素,注意这两个元素在各自的序列中应处于相同的位置,在交换过一些元素之后,数组A和B都应该是严格递增的。问使A和B保持严格递增状态的最小交换次数。
状态定义:dp[i][0]表示不交换i的情况下达到严格递增的最小交换次数,dp[i][1]表示交换i的情况下达到严格递增的最小交换次数
初始状态:dp[0][0]=0, dp[0][1]=1
状态转移:需要分类讨论
- 如果nums1[i-1]<nums1[i] 且 nums2[i-1]<nums2[i] 且 nums1[i-1]<nums2[i] 且 nums2[i-1]<nums1[i]:那么既可以交换也可以不交换,如果不交换,那么dp[i][0] = min(dp[i-1][0], dp[i-1][1]) ;如果交换,那么dp[i][1] = min(dp[i-1][0], dp[i-1][1]) +1;
- 如果nums1[i-1]<nums1[i] 且 nums2[i-1]<nums2[i](且nums1[i]和nums2[i]不需要交换):如果不交换,则之前也不应该交换,dp[i][0] = dp[i-1][0];如果交换,则之前也应该交换,dp[i][1] = dp[i-1][1]+1。
- 如果nums1[i-1]<nums2[i] 且 nums2[i-1]<nums1[i](且nums1[i]和nums2[i]需要交换):如果不交换,则之前必须交换,dp[i][0] = dp[i-1][1];如果交换,则之前不能交换,dp[i][1] = dp[i-1][0]+1。
练习题7:LeetCode 871. 最低加油次数
题目大意:有一个起始的油量startFuel,想开车去target,中间有n个加油站,每个加油站都有两个信息:位置和可以加的油量,问到达target所需要加油的最少次数。如果不能到达target,则返回-1。
状态定义:dp[i][j]表示前i个加油站加j次油可以到达的最远距离。
初始状态:dp[i][0]=startFuel
状态转移:对于前i个加油站,加j次油,求最远距离。首先需要判断能否到达它。到达的方式有两种:前i-1个加油站加了j-1次油就能到达i,或者 前i-1个加油站加j次油才能到i。
- 前i-1个加油站加j-1次油:dp[i][j] = max(dp[i-1][j], dp[i-1][j-1]+stations[i-1][1]
- 前i-1个加油站加j次油:dp[i][j] = dp[i-1][j]
状态定义2:dp[i]表示加i次油可以到达的最远距离。
初始状态:dp[0]=startFuel
状态转移:对于第i个加油站,如果dp[t]可以到达第i个加油站,那么dp[t+1] = max(dp[t+1], dp[t]+stations[i][1])
B. 带维度单串 dp[i][k]
单串问题dp[i],子问题仅与位置i有关。在此基础上,如果子问题还与某种指标k有关,k的物理意义比较常见的有长度,个数,次数,颜色等,就变成了带维度单串,状态通常写成dp[i][k]。
带维度单串问题与朴素单串问题的区别在于,带维度单串问题下,当i变小时,形成小规模子问题;当k变小时,也能形成小规模子问题,因此推到dp[i][k]时,i和k两个维度分别是一个独立的单串dp[i]问题。推到k时,k可能与k-1,…, 1中所有小规模问题有关,也可能只与其中常数个有关。
单串dp[i][k]的问题,推导状态时可以先枚举k,再枚举i,对于固定的k,求dp[i][k]相当于就是在求一个单串dp[i]的问题,但是计算dp[i][k]时可能会需要k-1时的状态。
练习题1:LeetCode 813. 最大平均值和的分组
题目大意:给定一个数组A分成K个相邻的非空子数组,我们的分数由每个子数组内的平均值的总和构成,计算我们所能得到的最大分数是多少?
状态定义:presum[i]为前i个元素的和(即,前缀和),dp[i][p]表示将前i个元素分成p组能得到的最大分数
初始状态:前缀和需要预处理计算。dp[i][1]表示将前i个元素分成1个组,则分数就是前i个元素的平均值,即 presum[i] / i
状态转移:首先我们固定分组数量p,从p=2开始枚举至k。之后从i=p开始枚举i,因为想要将前i个元素分成p组,则至少要有p个元素才能分,因此从i=p开始枚举。再然后,我们从j=i-1枚举分界线j。这个分界线表示,我们将j+1~i分为最后一个组,而0 ~ j分为剩下的p-1个组,则我们的分数为:
d
p
[
i
]
[
p
]
=
d
p
[
j
]
[
p
−
1
]
+
1.0
∗
(
p
r
e
s
u
m
[
i
]
−
p
r
e
s
u
m
[
j
]
)
/
(
i
−
j
)
dp[i][p]=dp[j][p-1]+1.0*(presum[i]-presum[j])/(i-j)
dp[i][p]=dp[j][p−1]+1.0∗(presum[i]−presum[j])/(i−j)
又因为dp[i][p]根据分界线j的变化而可能不同,因此我们取所有分界线的最大值
d p [ i ] [ p ] = m a x ( d p [ i ] [ p ] , d p [ j ] [ p − 1 ] + 1.0 ∗ ( p r e s u m [ i ] − p r e s u m [ j ] ) / ( i − j ) ) dp[i][p]=max(dp[i][p], dp[j][p-1]+1.0*(presum[i]-presum[j])/(i-j)) dp[i][p]=max(dp[i][p],dp[j][p−1]+1.0∗(presum[i]−presum[j])/(i−j))
练习题2:LeetCode 887. 鸡蛋掉落
题目大意:给k枚相同的鸡蛋,并可以使用一栋从第1层到第n层共有n层楼的建筑。现在找到楼层f,满足:任何高于f的楼层扔鸡蛋必碎,从f及其以下的楼层扔鸡蛋必定不会碎。请计算确定f的确切值的最小操作次数是多少?
状态定义:dp(k,n)表示有k个鸡蛋,n层楼,确定f的确切值所需要的最小操作数。
初始状态:dp(1,n)=n,当手里只有1个鸡蛋时,我们只能从第一层开始一层层向上实验,最差情况下是从n楼扔,鸡蛋都没碎,则f为n。dp(k,0)=0,当楼层为0时,不需要扔鸡蛋也知道f就是第0层。
状态转移:也许我们会想,这个题是二分搜索,只要每次枚举mid层,扔个鸡蛋,观察结果,就能排除一半的楼层,然后继续实验。鸡蛋充足的时候,这样做是没问题的。但是,现在鸡蛋有限,如果我们只有2个鸡蛋,要去测100层楼的F层,采用上面的方法,从50楼扔一个鸡蛋,万一鸡蛋碎了,那么我们只能用剩下的唯一一个鸡蛋去尝试剩下的49层楼,最小操作数为50,而实际正确答案为14次,因此,每次挑中间楼层抛鸡蛋的策略,在鸡蛋有限的情况下是不可取的。
假设我们从第x层(1<=x<=n)扔鸡蛋,会有两种结果:碎,不碎。如果碎了,那么我们就排除了x+1~n层楼,dp(k,n) = dp(k-1,x-1),k-1是因为鸡蛋碎了,少了1个;如果没有碎,那么我们就排除了0 ~ x层,dp(k,n) = dp(k,n-x),因为鸡蛋没碎,所以是k。
如果我们直接暴力递归,那么复杂度会达到 O ( k n 2 ) O(kn^{2}) O(kn2),十分耗时。因此我们必须优化上面的策略。
通过观察两个函数:我们设,T1=dp(k-1,x-1),T2= dp(k,n-x),当x上升时,T1增加,T2减少,因此T1为单调递增函数,T2位单调递减函数。我们为了保证能获得确切的F的值,必须选取T1和T2中的最大值,而为了让这个最大值最小,我们可以采用二分,来寻找这个最佳的x层。如果T1和T2是连续函数,那么最佳x无疑是两个函数的交点,可惜这两个函数都是离散函数,因此我们寻找x,得到的可能只是x左右两边的整数值,需要进行进一步的比较决策。
二分步骤:首先,设left=1,right=n,因为我们要同时检查左右邻居,因此采用二分查找第三模板,即在left+1<right时循环。去中间值mid,分别计算T1和T2,比较结果。如果T1>T2,说明mid太右了,right=mid;如果T1<T2,说明mid太左了,left=mid;如果二者相等,说明有个完美x层,left=right=mid。
之后,我们分别计算 max( dp(k-1,left-1), dp(k,n-left) 和 max(dp(k-1,right-1), dp(k, n-right)),取最小值,然后+1,就是dp(k,n)的答案。
同时,为了避免重复计算,我们使用哈希表记录每个(k,n)的结果,如果遇到已经计算过的k和n,就直接返回答案。记录方式有,使用map,用pair<int,int>为key,也可以用n*100+k为key,因为k最大为100,因此n*100就能将每个k的可能解区分开。
练习题3:LeetCode 256. 粉刷房子
题目大意:假如有一排房子,共n个,需要从三种颜色中选一个上色,且相邻两栋房子不能选择同一个颜色。现在给出一个成本列表costs,表示第i个房子涂成第j种颜色需要的成本。求涂完所有房子的最小成本?
状态定义:dp[i]/[j]表示前i栋房子涂色,且第i栋房子涂成第j种颜色的最小成本
初始状态:还没有涂色的时候,成本均为0,dp[i][j]=0
状态转移:当我们选择第j个颜色时,最低花销就是前一栋房子涂成另外两种颜色的最小花销中的最小值+cost[i][j],即
d
p
[
i
]
[
j
]
=
m
i
n
(
d
p
[
i
−
1
]
[
(
j
+
1
)
%
3
]
,
d
p
[
i
−
1
]
[
(
j
+
2
)
%
3
]
)
+
c
o
s
t
[
i
]
[
j
]
dp[i][j]=min(dp[i-1][(j+1)\%3],dp[i-1][(j+2)\%3])+cost[i][j]
dp[i][j]=min(dp[i−1][(j+1)%3],dp[i−1][(j+2)%3])+cost[i][j]
练习题4:LeetCode 265. 粉刷房子II
题目大意:假如有一排房子,共n个,需要从k种颜色中选一个上色,且相邻两栋房子不能选择同一个颜色。现在给出一个成本列表costs,表示第i个房子涂成第j种颜色需要的成本。求涂完所有房子的最小成本?
状态定义:dp为当前房屋的成本
初始状态:还没有涂色的时候,成本均为0,Min=0, Min2=0, c=-1
状态转移:如果我们采用和LeetCode 256. 粉刷房子相同的方法,对于第i栋房子,每种颜色,我们需要检查前一栋房子的k-1种颜色的成本,复杂度为 O ( n k 2 ) O(nk^{2}) O(nk2),在k很大时会超时。所以我们需要优化这个思路。
我们发现,上面的策略耗时的主要是没有保存上一个状态的最优解,或者说,保存的是涂成各种颜色的情况下的最小成本,但我们实际上只需要知道前面所有颜色中最小的成本,以及上一栋房子涂得颜色,就能计算当前房子涂色的最小成本。当前房屋涂成j色的成本为:
- 如果j与之前房子的最小成本颜色c不冲突,则 dp = cost[i][j]+min
- 如果j与之前房子的最小成本颜色c冲突,则我们可以尝试第二小成本方案,dp = cost[i][j]+min2
即:
d p = { c o s t [ i ] [ j ] + m i n , j ! = c c o s t [ i ] [ j ] + m i n 2 , j = = c dp = \left \{ \begin{aligned} cost[i][j]+min\;\;,\;\;\;\; j\;!=c\\ cost[i][j]+min2\;\;,\;\; j==c \end{aligned} \right. dp={cost[i][j]+min,j!=ccost[i][j]+min2,j==c
获得涂成j色的成本后,我们去与当前房屋的最小成本进行比较,如果比Lmin1小,则复制Lmin1的信息给Lmin2,再更新Lmin1和颜色;如果比Lmin2小,则更新Lmin2和颜色。遍历完毕后,更新Min和Min2,以及最小成本颜色c。
练习题5:LeetCode 975. 奇偶跳
题目大意:给定一个整数数组A,你可以从某一起始索引出发,跳跃一定次数。在跳跃的过程中,第1、3、5…次跳跃称为奇数跳跃,而第2、4、6…次称为偶数跳跃。奇数跳需要满足三个条件:
- 只能往后跳,i<j
- 只能往大于等于自己的格子跳,nums[i]<=nums[j]
- 如果有多个可选格子,选择最小的索引
问题的关键就在于,我们需要知道每一个位置下一步跳的格子是哪个。奇数跳的目标为odd数组,偶数跳的目标为even数组。为了满足条件2,我们对数组A进行排序,但是排序之后,下标信息就会消失。因此,我们构造一个下标数组index,然后对index进行排序,如果A[i]==A[j],则下标小的优先;如果A[i]!=A[j],则数值小的优先。由此,我们得到了一个排序的index数组,数组的每个元素都是下标,且下标对应的元素为升序排序。
举个例子:A=【3,2,3,3,4】,index排序后得到【1(2),0(3),2(3),3(3),4(4)】。之后我们建立一个最小堆,用于遍历index。如果堆顶下标小于index[i],说明index[i]就是堆顶下标奇数跳的目标,odd[q.top()]=index[i],q.pop()。之后将index[i]加入堆。遍历完index后,如果堆内还有元素,这些元素均没有办法到达别的点,因此依次弹出,将odd的对应位置标记为-1。
对于偶数跳,我们可以用类似的方式获得even数组。之后就可以进行动态规划了。
状态定义:dp[i]/[0]表示从第i个格子出发,偶数跳,能否到达终点,dp[i]/[1]表示从第i个格子出发,奇数跳,能否到达终点
初始状态:最后一个格子不论奇数跳还是偶数跳,必定为真,dp[n-1][0]=dp[n-1][1]=true
状态转移:当我们从第i个位置奇数跳时,next=odd[i]。如果odd[i] != -1且dp[next][0]为真,则dp[i][1]=true,ans++。当我们从第i个位置偶数跳是,next=even[i]。如果even[i] != -1且dp[next][1]为真,则dp[i][0]=true。
练习题6:LeetCode 403. 青蛙过河
题目大意:一只青蛙想要过河,假定河流被等分为若干个单元格,且每个单元格内都有可能放油一个石头(也可能没有),青蛙可以跳上石头,但是不可以跳入水里。给定石头的坐标列表stones(用单元格序号升序表示),请判定青蛙能否成功过河。开始时,青蛙默认站在第一块石子上,并可以假定它第一步只能跳跃一个单位(即只能从单元格1跳至单元格2)。如果青蛙上一步跳跃了k个单位,那么它接下来的跳跃距离只能选择k-1,k或者k+1个单位。此外,青蛙只能向终点的方向跳跃。
状态定义:dp[i]/[j]表示以j的步幅能否到达i
初始状态:dp[0]/[0]=true
状态转移:步长为k的状态到达i,那么上一个位置j可能是以k-1,k,或者k+1到达的。因此,如果可以以k-1,k,k+1到达j的话,那么也应该可以以k的步长到达i。即:dp[i][k] = dp[j][k-1] || dp[j][k] || dp[j][k+1]。到达第i个石头时的最大步幅为j+1,而两个石头之间的距离k=stones[i]-stones[j],如果k大于j+1,则不论如何都不可能从j到达i。
练习题7:LeetCode 1478. 安排邮筒
题目大意:给你一个房屋数组houses 和一个整数 k ,其中 houses[i] 是第 i 栋房子在一条街上的位置,现需要在这条街上安排 k 个邮筒。请你返回每栋房子与离它最近的邮筒之间的距离的 最小 总和。
状态定义:mid[i][j]是i至j号房子到中位数的距离总和,dp[i]/[j]表示前i个房子安排j个邮筒的最小总和。
初始状态:mid[i][j]=mid[i+1][j-1]+houses[j]-houses[i] (i<j), dp[i][1] = mid[0][i]
状态转移:对于前i个房子,安排j个邮箱,我们可以考虑给前p (p<i) 个房子安排j-1个邮箱,给p+1到i的房子安排第j个邮箱,可以得到一个总和,而我们从p=0枚举p,取所有p的结果的最小值。得到状态转移方程:
d
p
[
i
]
[
j
]
=
m
i
n
(
d
p
[
i
]
[
j
]
,
d
p
[
p
]
[
j
−
1
]
+
m
i
d
[
p
+
1
]
[
i
]
)
p
=
[
0
,
.
.
.
,
i
−
1
]
dp[i][j]=min(dp[i][j],dp[p][j-1]+mid[p+1][i]) \;\; p=[0,...,i-1]
dp[i][j]=min(dp[i][j],dp[p][j−1]+mid[p+1][i])p=[0,...,i−1]
练习题8:LeetCode 1230. 抛掷硬币
题目大意:有一些不规则的硬币。在这些硬币中,prob[i] 表示第 i 枚硬币正面朝上的概率。请对每一枚硬币抛掷 一次,然后返回正面朝上的硬币数等于 target 的概率。
状态定义:dp[i]/[j]表示前i枚硬币有j枚硬币正面朝上的概率
初始状态:dp[0][0]=1,即0枚硬币有0枚正面朝上的概率必定为100%,dp[i][0]=dp[i-1][0]*(1-prob[i-1])
状态转移:对于前i个枚硬币有j枚朝上的概率依赖于前i-1枚硬币的概率:
- 前i-1枚硬币有j-1枚正面朝上,则需要第i枚硬币朝上才能达到有j枚正面朝上,即dp[i][j] = dp[i-1][j-1]*prob[i-1]
- 前i-1枚硬币有j枚正面朝上,则需要第i枚硬币朝下才能达到有j枚正面朝上,即dp[i][j] = dp[i-1][j]*(1-prob[i-1])
得到状态转移方程:
d
p
[
i
]
[
j
]
=
d
p
[
i
−
1
]
[
j
−
1
]
∗
p
r
o
b
[
i
−
1
]
+
d
p
[
i
−
1
]
[
j
]
∗
(
1
−
p
r
o
b
[
i
−
1
]
)
dp[i][j]= dp[i-1][j-1]*prob[i-1]+dp[i-1][j]*(1-prob[i-1])
dp[i][j]=dp[i−1][j−1]∗prob[i−1]+dp[i−1][j]∗(1−prob[i−1])
练习题9:LeetCode 410. 分割数组的最大值
题目大意:给定一个非负整数数组 nums 和一个整数 m ,你需要将这个数组分成 m 个非空的连续子数组。设计一个算法使得这 m 个子数组各自和的最大值最小。
状态定义:presum[i]是前i个元素的和,即前缀和,dp[i]/[j]表示前i个数组分为m个组的最小的最大值。
初始状态:presum[i]=presum[i-1]+nums[i-1], dp[i][1] = presum[i]
状态转移:对于前i个元素,分成j个组,我们可以考虑给前p (p<i) 个元素分成j-1个组,将p+1到i的元素分为第j个组,可以得前j-1个组的数组和最小的最大值以及第j组的总和,而我们从p=1枚举p,取所有p的结果的最小值。得到状态转移方程:
d
p
[
i
]
[
j
]
=
m
i
n
(
d
p
[
i
]
[
j
]
,
m
a
x
(
d
p
[
p
]
[
j
−
1
]
,
p
r
e
s
u
m
[
i
]
−
p
r
e
s
u
m
[
p
]
)
)
,
p
=
[
1
,
.
.
.
,
i
−
1
]
dp[i][j]=min(dp[i][j], max(dp[p][j-1], presum[i]-presum[p])) \;,\; p=[1,...,i-1]
dp[i][j]=min(dp[i][j],max(dp[p][j−1],presum[i]−presum[p])),p=[1,...,i−1]
练习题10:LeetCode 1473. 粉刷房子III
题目大意:在一个小城市里,有 m 个房子排成一排,你需要给每个房子涂上 n 种颜色之一(颜色编号为 1 到 n )。有的房子去年夏天已经涂过颜色了,所以这些房子不可以被重新涂色。我们将连续相同颜色尽可能多的房子称为一个街区(比方说 houses = [1,2,2,3,3,2,1,1] ,它包含 5 个街区 [{1}, {2,2}, {3,3}, {2}, {1,1}] )。给你一个数组 houses ,一个 m * n 的矩阵 cost 和一个整数 target ,其中:
- houses[i]:是第 i 个房子的颜色,0 表示这个房子还没有被涂色。
- cost[i][j]:是将第 i 个房子涂成颜色 j+1 的花费。
请你返回房子涂色方案的最小总花费,使得每个房子都被涂色后,恰好组成 target 个街区。如果没有可用的涂色方案,请返回 -1
状态定义:dp[i][j][k]表示第i栋房子刷成j色,且属于第k个街区时的最小化费
初始状态:第0号房子之前没有房子,因此街区k也必须为0,同时我们将houses集体-1,这样值为-1的房子是没涂色的
d
p
[
0
]
[
j
]
[
0
]
=
{
∞
,
i
f
:
h
o
u
s
e
s
[
i
]
!
=
−
1
∧
h
o
u
s
e
s
[
i
]
≠
j
0
,
i
f
:
h
o
u
s
e
s
[
i
]
!
=
−
1
∧
h
o
u
s
e
s
[
i
]
=
j
c
o
s
t
[
i
]
[
j
]
,
i
f
:
h
o
u
s
e
s
[
i
]
=
−
1
dp[0][j][0] = \left \{ \begin{aligned} \infty, \;\; if: houses[i]!=-1\;\land houses[i]\neq j\\ 0, \;\;if: houses[i]!=-1\;\land houses[i] = j\\ cost[i][j]\;,\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;if: houses[i]=-1\\ \end{aligned} \right.
dp[0][j][0]=⎩⎪⎨⎪⎧∞,if:houses[i]!=−1∧houses[i]=j0,if:houses[i]!=−1∧houses[i]=jcost[i][j],if:houses[i]=−1
状态转移:设第i-1个房子的颜色为j0,分类讨论:
- 如果当前房子已经被涂色,则房子的颜色必定为j,所以dp[i][ 非j ][k]=INF。而如果j==j0,那么第i-1个房子和第i个房子属于同一个街区,则:dp[i][j][k] = dp[i-1][j][k]。如果j != j0,那么第i-1个房子和第i个房子不属于同一个街区,dp[i][j][k]=min(dp[i-1][j0][k-1])
- 如果当前房子没有被涂色,则我们需要枚举颜色j,花费为cost[i][j]。而如果j==j0,则:dp[i][j][k] = dp[i-1][j][k]+cost[i][j]。如果j != j0,那么第i-1个房子和第i个房子不属于同一个街区,dp[i][j][k]=min(dp[i-1][j0][k-1])+cost[i][j]
d p [ i ] [ j ] [ k ] = { ∞ , h o u s e s [ i ] ! = − 1 ∧ h o u s e s [ i ] ! = j d p [ i − 1 ] [ j ] [ k ] , h o u s e s [ i ] ! = − 1 ∧ h o u s e s [ i ] = j 0 m i n ( d p [ i − 1 ] [ j 0 ] [ k − 1 ] ) , h o u s e s [ i ] ! = − 1 ∧ j ! = j 0 d p [ i − 1 ] [ j ] [ k ] + c o s t [ i ] [ j ] , h o u s e s [ i ] = − 1 ∧ j = j 0 m i n ( d p [ i − 1 ] [ j 0 ] [ k − 1 ] ) + c o s t [ i ] [ j ] , h o u s e s [ i ] = − 1 ∧ j ! = j 0 dp[i][j][k] = \left \{ \begin{aligned} \infty,\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\; houses[i]!=-1\land houses[i]\;!=j\\ dp[i-1][j][k],\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\; houses[i]!=-1\land houses[i]=j0\\ min(dp[i-1][j0][k-1]),\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\; houses[i]!=-1\land j\;!=j0\\ dp[i-1][j][k]+cost[i][j],\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\; houses[i]=-1\land j=j0\\ min(dp[i-1][j0][k-1])+cost[i][j],\;\;\;\;\; houses[i]=-1\land j\;!=j0\\ \end{aligned} \right. dp[i][j][k]=⎩⎪⎪⎪⎪⎪⎪⎨⎪⎪⎪⎪⎪⎪⎧∞,houses[i]!=−1∧houses[i]!=jdp[i−1][j][k],houses[i]!=−1∧houses[i]=j0min(dp[i−1][j0][k−1]),houses[i]!=−1∧j!=j0dp[i−1][j][k]+cost[i][j],houses[i]=−1∧j=j0min(dp[i−1][j0][k−1])+cost[i][j],houses[i]=−1∧j!=j0
C. 买卖股票的最佳时机 dp[i][k] (i非必取)
练习题1:LeetCode 121. 买卖股票的最佳时机
题目大意:给定一个数组prices,第i个元素表示某支给定股票第i天的价格。如果只能选择某一天买入,并在未来不同的某一天卖出,求获得最大的利润。如果不能获得任何利润,返回0
状态定义:dp[i][0]表示第i天未持有股票的最大收益(可能是卖掉了,也可能是没有买入),dp[i][1]表示第i天持有股票的最小成本
初始状态:dp[0][0] = 0, dp[0][1] = prices[0],第0天之前不可能交易,所以收益为0,如果买入股票则成本为prices[0]
状态转移:对于第i天,如果未持有股票,则有可能是之前卖掉了,也可能是今天卖掉,取二者收益的最大值。如果持有股票,则可能是之前就买了,也可能是今天买的,因为我们只能交易一次,因此只能选择一天买入,买入之前的收益均为0。得到状态转移方程:
d
p
[
i
]
[
0
]
=
m
a
x
(
d
p
[
i
−
1
]
[
0
]
,
p
r
i
c
e
s
[
i
]
−
d
p
[
i
−
1
]
[
1
]
)
d
p
[
i
]
[
1
]
=
m
i
n
(
d
p
[
i
−
1
]
[
1
]
,
p
r
i
c
e
s
[
i
]
)
dp[i][0]=max(dp[i-1][0], prices[i]-dp[i-1][1])\\ dp[i][1]=min(dp[i-1][1], prices[i])
dp[i][0]=max(dp[i−1][0],prices[i]−dp[i−1][1])dp[i][1]=min(dp[i−1][1],prices[i])
练习题2:LeetCode 122. 买卖股票的最佳时机II
题目大意:给定一个数组prices,第i个元素表示某支给定股票第i天的价格。可以多次交易,但买入之前必须把手上的股票卖出,求获得最大的利润。
状态定义:dp[i][0]表示第i天未持有股票的最大收益(可能是卖掉了,也可能是没有买入),dp[i][1]表示第i天持有股票下的最大收益(可能是今天买的,也可能是之前买的)
初始状态:dp[0][0] = 0, dp[0][1] = -prices[0],第0天之前不可能交易,所以收益为0,如果买入股票则收益为-prices[0]
状态转移:对于第i天,如果未持有股票,则有可能是之前卖掉了,也可能是今天卖掉(需要加今天股票价格带来的收益),取二者收益的最大值。如果持有股票,则可能是之前就买了,也可能是今天买的(需要减去今天买入股票的成本),收益取二者的最大值。得到状态转移方程:
d
p
[
i
]
[
0
]
=
m
a
x
(
d
p
[
i
−
1
]
[
0
]
,
d
p
[
i
−
1
]
[
1
]
+
p
r
i
c
e
s
[
i
]
)
d
p
[
i
]
[
1
]
=
m
a
x
(
d
p
[
i
−
1
]
[
1
]
,
d
p
[
i
−
1
]
[
0
]
−
p
r
i
c
e
s
[
i
]
)
dp[i][0]=max(dp[i-1][0], dp[i-1][1]+prices[i])\\ dp[i][1]=max(dp[i-1][1], dp[i-1][0]-prices[i])
dp[i][0]=max(dp[i−1][0],dp[i−1][1]+prices[i])dp[i][1]=max(dp[i−1][1],dp[i−1][0]−prices[i])
练习题3:LeetCode 122. 买卖股票的最佳时机III
题目大意:给定一个数组prices,第i个元素表示某支给定股票第i天的价格,最多完成2次交易,但买入之前必须把手上的股票卖出,求获得最大的利润。
状态定义:dp[i][0]表示第i天第一次买入股票时的最大收益,dp[i][1]表示第i天第一次卖出股票时的最大收益,dp[i][2]表示第i天第二次买入股票时的最大收益(可能是今天买的,也可能是之前买的),dp[i][3]表示第i天第二次卖出股票时的最大收益(可能是今天卖的,也可能是之前卖的)
初始状态:dp[0][0] = -prices[0], dp[0][1] = 0 ,dp[0][2] = -prices[0], dp[0][3] = 0,第0天之前不可能交易,所以收益为0,如果买入股票则收益为-prices[0](对于dp[0][2] = -prices[0],还可以理解为第0天买入股票又卖出又买入的收益;dp[0][3]理解为第0天买入又卖出又买入又卖出的收益。
状态转移:对于第i天,如果是第一次交易,则之前不可能交易过,那么如果第i天第一次交易持有股票要么是之前买的没有卖出,要么是第i天买的,之前均无收益,故:
d
p
[
i
]
[
0
]
=
m
a
x
(
d
p
[
i
−
1
]
[
0
]
,
0
−
p
r
i
c
e
s
[
i
]
)
dp[i][0]=max(dp[i-1][0], 0-prices[i])
dp[i][0]=max(dp[i−1][0],0−prices[i])
如果第i天第一次交易卖出过股票,要么是之前就卖出了,要么是第i天才卖出的,故:
d
p
[
i
]
[
1
]
=
m
a
x
(
d
p
[
i
−
1
]
[
1
]
,
d
p
[
i
−
1
]
[
0
]
+
p
r
i
c
e
s
[
i
]
)
dp[i][1]=max(dp[i-1][1], dp[i-1][0]+prices[i])
dp[i][1]=max(dp[i−1][1],dp[i−1][0]+prices[i])
如果第i天第二次交易买入股票,要么是之前就买入了第二次,要么是第i天才买入第二次,故:
d
p
[
i
]
[
1
]
=
m
a
x
(
d
p
[
i
−
1
]
[
2
]
,
d
p
[
i
−
1
]
[
1
]
−
p
r
i
c
e
s
[
i
]
)
dp[i][1]=max(dp[i-1][2], dp[i-1][1]-prices[i])
dp[i][1]=max(dp[i−1][2],dp[i−1][1]−prices[i])
如果第i天第二次交易卖出股票,要么是之前就卖出了两次,要么是第i天才卖出第二次,故:
d
p
[
i
]
[
3
]
=
m
a
x
(
d
p
[
i
−
1
]
[
3
]
,
d
p
[
i
−
1
]
[
2
]
+
p
r
i
c
e
s
[
i
]
)
dp[i][3]=max(dp[i-1][3], dp[i-1][2]+prices[i])
dp[i][3]=max(dp[i−1][3],dp[i−1][2]+prices[i])
练习题4:LeetCode 188. 买卖股票的最佳时机IV
题目大意:给定一个数组prices,第i个元素表示某支给定股票第i天的价格,最多完成k次交易,但买入之前必须把手上的股票卖出,求获得最大的利润。
状态定义:dp[i][j] (j为偶数)表示第i天第j次买入股票时的最大收益,dp[i][j+1]表示第i天第j次卖出股票时的最大收益.
初始状态:dp[0][j] = -prices[0], dp[0][j+1] = 0 , j代表买入,j+1代表卖出。也可以拆成两个k+1长度的数组buy和sell
状态转移:
对于第i天,买入j次的话,要么是i-1天买入了j次,或者i-1天卖出了j-1次,第i天买入第j次,故:
d
p
[
i
]
[
j
]
=
m
a
x
(
d
p
[
i
−
1
]
[
j
]
,
d
p
[
i
−
1
]
[
j
−
1
]
−
p
r
i
c
e
s
[
i
]
)
dp[i][j]=max(dp[i-1][j], dp[i-1][j-1]-prices[i])
dp[i][j]=max(dp[i−1][j],dp[i−1][j−1]−prices[i])
如果第i天,卖出j次的话,要么是i-1天卖出了j次,或者i-1天买入了j次,第i天卖出第j次,故:
d
p
[
i
]
[
j
+
1
]
=
m
a
x
(
d
p
[
i
−
1
]
[
j
+
1
]
,
d
p
[
i
−
1
]
[
j
]
+
p
r
i
c
e
s
[
i
]
)
dp[i][j+1]=max(dp[i-1][j+1], dp[i-1][j]+prices[i])
dp[i][j+1]=max(dp[i−1][j+1],dp[i−1][j]+prices[i])
练习题5:LeetCode 309. 最佳买卖股票时机含冷冻期
题目大意:给定一个数组prices,第i个元素表示某支给定股票第i天的价格。可以多次交易,但卖出之后第二天不能买入股票,需要隔一天才能买,求获得最大的利润。
状态定义:dp[i][0]表示第i天未持有股票时的最大收益(可能是之前就未持有,或者前一天卖出了),dp[i][1]表示第i天持有股票时的最大收益(可能是之前就持有了,也可能是之前没有持有,所以今天买入了).dp[i][2]表示第i天卖出(前一天必须持有)
初始状态:dp[0][1] = -prices[0], dp[0][0] = dp[0][2] =0
状态转移:
对于第i天,如果未持有的话,要么是i-1天也未持有,或者i-1天卖出了,导致今天未持有(今天为冷冻期),故:
d
p
[
i
]
[
0
]
=
m
a
x
(
d
p
[
i
−
1
]
[
0
]
,
d
p
[
i
−
1
]
[
2
]
)
dp[i][0]=max(dp[i-1][0], dp[i-1][2])
dp[i][0]=max(dp[i−1][0],dp[i−1][2])
如果是持有的话,要么是i-1天就持有了,要么是昨天未持有(可能是冷冻期,今天解冻了),今天买入,故:
d
p
[
i
]
[
1
]
=
m
a
x
(
d
p
[
i
−
1
]
[
1
]
,
d
p
[
i
−
1
]
[
0
]
−
p
r
i
c
e
s
[
i
]
)
dp[i][1]=max(dp[i-1][1], dp[i-1][0]-prices[i])
dp[i][1]=max(dp[i−1][1],dp[i−1][0]−prices[i])
如果是今天卖出的话,那么必然前一天是持有的,故:
d
p
[
i
]
2
]
=
d
p
[
i
−
1
]
[
1
]
+
p
r
i
c
e
s
[
i
]
dp[i]2] = dp[i-1][1]+prices[i]
dp[i]2]=dp[i−1][1]+prices[i]
练习题6:LeetCode 714. 买卖股票的最佳时机含手续费
题目大意:给定一个数组prices,第i个元素表示某支给定股票第i天的价格。可以多次交易,但卖出后需要缴纳手续费,求获得最大的利润。
状态定义:dp[i][0]表示第i天未持有股票的最大收益(可能是卖掉了的收入减去手续费,也可能是没有买入),dp[i][1]表示第i天持有股票下的最大收益(可能是今天买的,也可能是之前买的)
初始状态:dp[0][0] = 0, dp[0][1] = -prices[0],第0天之前不可能交易,所以收益为0,如果买入股票则收益为-prices[0]
状态转移:对于第i天,如果未持有股票,则有可能是之前卖掉了,也可能是今天卖掉(需要加今天股票价格带来的收益减去手续费),取二者收益的最大值。如果持有股票,则可能是之前就买了,也可能是今天买的(需要减去今天买入股票的成本),收益取二者的最大值。得到状态转移方程:
d
p
[
i
]
[
0
]
=
m
a
x
(
d
p
[
i
−
1
]
[
0
]
,
d
p
[
i
−
1
]
[
1
]
+
p
r
i
c
e
s
[
i
]
−
f
e
e
)
d
p
[
i
]
[
1
]
=
m
a
x
(
d
p
[
i
−
1
]
[
1
]
,
d
p
[
i
−
1
]
[
0
]
−
p
r
i
c
e
s
[
i
]
)
dp[i][0]=max(dp[i-1][0], dp[i-1][1]+prices[i]-fee)\\ dp[i][1]=max(dp[i-1][1], dp[i-1][0]-prices[i])
dp[i][0]=max(dp[i−1][0],dp[i−1][1]+prices[i]−fee)dp[i][1]=max(dp[i−1][1],dp[i−1][0]−prices[i])