“动态规划”用于多阶段最优化问题的求解
斐波那契数列、LeetCode-70. 爬楼梯
斐波那契数列
为什么把 斐波那契数列 跟 LeetCode-70. 爬楼梯 放在一起哈,因为你如果做过或者看过LeetCode-70. 爬楼梯 相关的题解,里面用动态规划来解题的公式跟斐波那契数列居然是一模一样的。
那么这里就从就先从大家所熟悉的 斐波那契数列 开始讲起。
要点
斐波那契数列公式,也就是所谓的 状态转移方程:
F
(
n
)
=
F
(
n
−
1
)
+
F
(
n
−
2
)
(1.1)
F(n)=F(n-1)+F(n-2)\tag{1.1}
F(n)=F(n−1)+F(n−2)(1.1)
第一行是 下标 ,第二行是 和 。
将
N-2、N-1
的结果转移到N
的结果
解法
当前如果传入的数为N
,可以创建一个长度N+1
([0…N])空间的 dp table来记录。
斐波那契数列
N=0,Value=0
N=1,Value=1
N=2,Value=1
N=3,Value=2
N=4,Value=3
…
Value:
F
(
n
)
=
F
(
n
−
1
)
+
F
(
n
−
2
)
F(n)=F(n-1)+F(n-2)
F(n)=F(n−1)+F(n−2)
代码片段
详细题解点击这里 斐波那契数列、 Leetcode 70. 爬楼梯 矩阵(
O
(
l
o
g
N
)
O(logN)
O(logN))运算、累加(
O
(
N
)
O(N)
O(N))、递归备忘录(
O
(
N
)
O(N)
O(N))
本篇文章只针对动态规划进行讲解。
public int fibonacciDP(int n) {
if (n < 1) {//1.小于1的值都是0步
return 0;
}
if (n <= 2) {//2.由dp表可以知道 n=1、n=2 都是1直接返回
return 1;
}
int[] dp = new int[n + 1];//3.创建一个一维数组,存放转移的结果。
//4.base case
dp[0] = 0;
dp[1] = 1;
dp[2] = 1;
for (int i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];//5.从i=3开始,计算在i位置从 i-2、i-1转移过来应该走多少步。
}
return dp[n];//返回最后一格,最终结果
}
到这里同学们都知道了用dp数组来记录斐波那契数列的方法了,当然这里可以只用两个常量来写, 见上文链接。
LeetCode 70. 爬楼梯
这里主要讲一下二维dp表推导到一维
这里省略了其它的解法,你可以通过见上文链接查看详细的解法。
现在有两种步数,分别是走1
步跟走2
步
如果每次都只走一步的话,到N
的时候还是只有一种走法,所以在dp[0]
的这一列都是1
当在index=3
这一列的时候,dp[1][3]
可能是dp[1][1]
走两步到达或者dp[0][2]
走一步到达。
d
p
[
1
]
[
3
]
=
d
p
[
1
]
[
1
]
+
d
p
[
0
]
[
2
]
(1.3)
dp[1][3]=dp[1][1]+dp[0][2]\tag{1.3}
dp[1][3]=dp[1][1]+dp[0][2](1.3)
d
p
[
i
]
[
j
]
=
d
p
[
i
]
[
j
−
2
]
+
d
p
[
i
−
1
]
[
j
−
1
]
(1.4)
dp[i][j]=dp[i][j-2]+dp[i-1][j-1]\tag{1.4}
dp[i][j]=dp[i][j−2]+dp[i−1][j−1](1.4)
在index=3
这一阶不管是怎么到达的,它拥有的走法一致,同步这一阶的走法数:dp[1][3]=>dp[0][3]
,
d
p
[
i
−
1
]
[
j
]
=
>
d
p
[
i
]
[
j
]
(1.5)
dp[i-1][j]=>dp[i][j]\tag{1.5}
dp[i−1][j]=>dp[i][j](1.5)
public int climbStairsDPX(int n) {
if (n < 1) {
return 0;
}
if (n <= 2) {
return n;
}//1.已知的结果直接返回了。
int[][] dp = new int[2][n + 1];
for (int i = 1; i <= n; i++) {
dp[0][i] = 1;//2.都直走一步,那么只有一种啊
}
dp[0][0] = 0;//没有台阶都是0
dp[1][0] = 0;//没有台阶都是0
dp[0][1] = 1;//有1(1) 种步数的时候,1阶台阶只有1种方法 走一步
dp[1][1] = 1;//有2(1,2)种步数的时候,1阶台阶只有1种方法 走一步
dp[0][2] = 2;//有2(1,2)种步数的时候,2阶台阶有2种方法
dp[1][2] = 2;//有2(1,2)种步数的时候,2阶台阶有2种方法
for (int i = 1; i < 2; i++) {//遍历所有的走法,因为当i=0的时候已经都默认给值了
for (int j = 3; j <= n; j++) {
dp[i][j] = dp[i][j - 2] + dp[i - 1][j - 1];//4.根据表格可以知道dp[i][j]的来源。
dp[i - 1][j] = dp[i][j];//5.同步当前这个阶层的走法,不然i=0这列还以为自己是1种走法。
}
}
return dp[1][n];//6.返回最后一阶的结果。
}
OutPut:测试通过。
这个时候再回过头来看一下 (图1.4) ,可以明显的看到其实上下两列的结果一致,并不需要用二维数组来记录结果。
合并数组看一下:
其实就是 F ( n ) = F ( n − 1 ) + F ( n − 2 ) F(n)=F(n-1)+F(n-2) F(n)=F(n−1)+F(n−2)
不就是斐波那契数列嘛!
唯一的区别在于:
斐波那契数列:1,1,2,3,5,8,...
爬楼梯:1,2,3,5,8,...
那么这里可以将 N
往右移动一位数N+1
,那么将这个值代入斐波那契数列函数(从第二位开始取结果)得出来的结果就是 70. 爬楼梯 的结果。
64. 最小路径和
原题链接
给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:每次只能向下或者向右移动一步。
示例:
输入:
[
[1,3,1],
[1,5,1],
[4,2,1]
]
输出: 7
解释: 因为路径 1→3→1→1→1 的总和最小。
要点
计算每次到达一个格子的时候,记录到达这个位置最短的路径是多少,那么从它这个位置再往下或者往右的时候就能够通过两个目前最短的路径计算出当前这个格子的最短路径。
解法
题目告诉我们只能向右或者向下行走,那么首先可以计算出:
第1行(只能通过由左向右这一种走法)dp[0][j]
行走的距离
第1列(只能通过由上向下这一种走法)dp[i][0]
行走的距离
这里已经是通过 dp[0][1]、dp[1][0]
的值推算出dp[1][1]
的值了。
即相邻两个位置拿一个最短的距离与当前位置相加,得出到这个格式一共走了多少路径距离。
状态转移方程:
d
p
[
i
]
[
j
]
=
M
i
n
(
d
p
[
i
−
1
]
[
j
]
+
d
p
[
i
]
[
j
−
1
]
)
+
a
r
r
[
i
]
[
j
]
(2.3)
dp[i][j]=Min(dp[i-1][j]+dp[i][j-1])+arr[i][j]\tag{2.3}
dp[i][j]=Min(dp[i−1][j]+dp[i][j−1])+arr[i][j](2.3)
代码片段
public int minPathSum(int[][] m) {
if (m == null || m.length == 0 || m[0] == null || m[0].length == 0) {//1.传入的矩阵有问题就直接返回0了
return 0;
}
int col = m.length;//2.一共有多少行
int row = m[0].length;//3.一共有多少列
int[][] dp = new int[col][row];//4.创建一个行列二维dp数组。
//5.由左向右
dp[0][0] = m[0][0];
for (int i = 1; i < row; i++) {
dp[0][i] = dp[0][i - 1] + m[0][i];
}
//6.由上向下
for (int i = 1; i < col; i++) {
dp[i][0] = dp[i - 1][0] + m[i][0];
}
for (int i = 1; i < col; i++) {
for (int j = 1; j < row; j++) {
dp[i][j] = Math.min(dp[i][j - 1], dp[i - 1][j]) + m[i][j];//7.取相邻位置具有最短路径的与当前位置相加,得出当前位置最短路径。
}
}
return dp[col - 1][row - 1];//8.返回到达终点的距离。
}
完整数组
代码片段
public int minPathSum1x(int[][] m) {
if (m == null || m.length == 0 || m[0] == null || m[0].length == 0) {
return 0;
}
int more = Math.max(m.length, m[0].length);
int less = Math.min(m.length, m[0].length);
//假设directed == true 是横向
boolean directed = more == m[0].length;
int[] dp = new int[less];
dp[0] = m[0][0];
for (int i = 1; i < less; i++) {
dp[i] = dp[i - 1] + (directed ? m[i][0] : m[0][i]);
}
for (int i = 1; i < more; i++) {
dp[0] = dp[0] + (directed ? m[0][i] : m[i][0]);
for (int j = 1; j < less; j++) {
dp[j] = Math.min(dp[j - 1], dp[j]) + (directed ? m[j][i] : m[i][j]);
}
}
return dp[less - 1];
}
这里以
dp[1][1]=5
特别说明一下,这个位置的结果来自9,4
,dp[0][0]=1
这个位置的结果已经不重要了,所以这里只需要一维数组来存储结果,实现路径压缩。
322. 零钱兑换
原题链接
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
示例 1:
输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1
示例 2:
输入: coins = [2], amount = 3
输出: -1
说明:
你可以认为每种硬币的数量是无限的。
要点
每一种的金额都可能由任意的硬币组成。
如果arr
的长度为N
,生成行数为N
、列数为aim+1
的dp表。
dp[i][j]
表的含义是,在可以使用任意arr[i...0]
货币的情况下,组成j
所需的最小张数。
解法
以图片示例
coins = [2, 3, 4]
aim = 8
可以知道当只有一种硬币coin=2
的时候,将dp表格修改如下:
也就是:
只有2元的零钱时,无法兑换1,3,5,7
这些面额,
1张2元可以换2元,2张2元可以换4元,
3张2元可以换6元,4张2元可以换8元,
那么比如在兑换6元的时候,目前只有2元的硬币所以肯定是4元那个位置的张数2+1张2元:
d
p
[
0
]
[
j
]
=
d
p
[
0
]
[
j
−
c
o
i
n
[
0
]
]
+
1
(3.3)
dp[0][j] = dp[0][j-coin[0]]+1\tag{3.3}
dp[0][j]=dp[0][j−coin[0]]+1(3.3)
也就是找到一个坐标,它加上当前这种零钱能够找零,那么比之前的结果加上1张,
动态转移方程:
d
p
[
i
]
[
j
]
=
d
p
[
i
]
[
j
−
c
o
i
n
[
i
]
]
+
1
(3.4)
dp[i][j] = dp[i][j-coin[i]]+1\tag{3.4}
dp[i][j]=dp[i][j−coin[i]]+1(3.4)
我们现在填充dp[1][3]=1
,代表只能用一张3元来兑换。思考一下哪里不对劲???
我们要求能兑换这个钱的最小张数,从dp[1][0]->dp[1][2]
明显应该是1
张,但从图片上来看,之前在第一行dp[0][2]=0
的时候是0
+1=1张,最小值min(0,1)=0
,也就是说这里的默认值0
是错误的。应该是min(?,1)=1
,所以这里我们应该将默认值设置成最大值。
dp[1][1]=Max:
dp[1][1]
的位置无法从dp[1][-2]
的位置获取张数,并且在只有2元的时候,也无法找钱
dp[1][2]=1:
dp[1][2]
的位置无法从dp[1][-1]
的位置获取张数,但是在只有2元的时候,可以用一张2元
dp[1][3]=1:
dp[1][3]
的位置从dp[1][0]
的张数+1,在只有2元的时无法找,所以可以用一张3元的找
d
p
[
1
]
[
2
]
=
d
p
[
0
]
[
2
]
(3.7)
dp[1][2]=dp[0][2]\tag{3.7}
dp[1][2]=dp[0][2](3.7)
d
p
[
i
]
[
j
]
=
d
p
[
i
−
1
]
[
j
]
(3.8)
dp[i][j]=dp[i-1][j]\tag{3.8}
dp[i][j]=dp[i−1][j](3.8)
代码片段
public int minCoins1(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {//1.传入错误参数直接返回-1无法找。
return -1;
}
int maxValue = Integer.MAX_VALUE;//2.取一个最大值,用来设置无法找的情况。也就是如果遇到一个可以找的零钱,就使用它。
int[][] dp = new int[arr.length][aim + 1];//3.创建一个二维的dp数组
for (int j = 1; j <= aim; j++) {//4.填充第一行只有2元的时候,可以找就设置张数,不能找就设置为maxValue
dp[0][j] = maxValue;
if (j - arr[0] >= 0 && dp[0][j - arr[0]] != maxValue) {
dp[0][j] = dp[0][j - arr[0]] + 1;
}
}
for (int i = 1; i < arr.length; i++) {//5.遍历每一个坐标点,求对应的最小张数。
for (int j = 1; j <= aim; j++) {
int left = maxValue;
if (j - arr[i] >= 0 && dp[i][j - arr[i]] != maxValue) {//7.计算横向状态转移的的结果,如果是从一个可以计算的位置出发,那么张数+1
left = dp[i][j - arr[i]] + 1;
}
dp[i][j] = Math.min(left, dp[i - 1][j]);//8.取一个最小值作为当前坐标的结果。
}
}
return dp[arr.length - 1][aim] != maxValue ? dp[arr.length - 1][aim] : -1;//6.我们设置了最大值是无法找,所以在最后一格返回的时候对无法找的maxValue返回-1。
}
121. 买卖股票的最佳时机
给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。
如果你最多只允许完成一笔交易(即买入和卖出一支股票),设计一个算法来计算你所能获取的最大利润。
注意你不能在买入股票前卖出股票。
示例 1:
输入: [7,1,5,3,6,4]
输出: 5
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格。
示例 2:
输入: [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。
解法
- 口袋里的钱都可能由任意操作之后形成。
- 这里设置买入卖出2种动作,生成行数为
2
、列数为prices
的dp表。 dp[i][j]
表的含义是,在prices[0,prices.length]
的时候,买入或者卖出口袋剩下多少钱。- 这里需要注意卖出只能在买入之后。
- 一开始不赚不亏设置为0。
代码片段
public int maxProfitDp(int[] prices) {
if (prices == null || prices.length < 2) {//0.传入的数组错误直接返回,长度为1只能买不能卖。
return 0;
}
int[][] dp = new int[2][prices.length];//1.根据说明创建一个
dp[0][0] = -prices[0];//2.如果在第一天买入花了prices[0]
dp[1][0] = 0;//3.第一天没法卖出,只能买入。
for (int i = 1; i < prices.length; i++) {//4.看看每一天在执行买入或者卖出口袋能剩下最多钱。
dp[0][i] = Math.max(dp[0][i - 1], -prices[i]);//5.比如到了第二天,之前第一天花了dp[0][i-1],即第2步,那么与今天比较哪天花费最少。
dp[1][i] = Math.max(dp[1][i - 1], dp[0][i - 1] + prices[i]);//6.比如到了第二天,可以观望,也可以看看卖出能不能赚钱。
}
return dp[1][prices.length - 1];//7.返回到最后一天时,口袋最多能赚多少钱。
}
5. 最长回文子串
原题链接
给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
示例 1:
输入: "babad"
输出: "bab"
注意: "aba" 也是一个有效答案。
示例 2:
输入: "cbbd"
输出: "bb"
要点
-
以一个字符的位置或者两个字符中间的位置为起点,这个位置的左右两边可能以这个位置为中间位置形成一个回文串。
babad
,ab
a这个位置左右两边形成回文。
cbbc
,cb|
bc这个位置左右两边形成回文。 -
每一个字符串的子串(
s[i...j]
)都可能是 回文串 ,用一个二维的dp[j][i]
数组来表示当前的子串是否为回文串,其中 i = j = s . l e n g t h i=j=s.length i=j=s.length。当前
i
到达一个位置的时候,对于每一个dp[0...j][i]
这个区间的子串都要判断是否是回文串。比如dp[0][3]、dp[1][3]、dp[2][3] 是在逼近一个字符串确认是否回文。 -
dp[j][i]
表的含义是,在s[i,j]
这个区间是否回文串。 -
比如
cbbc
是一个回文串,必然bb
也是一个回文串。反过来说就是bb
是一个回文串,此时左右两边字符相等可以形成一串回文串。
我们以i=3,s[i]=B
解释一下(图中粉红色方格子):
这个时候我们拥有一个字符串长度为4
,内容为"acbb"
的子串。
这个时候我们就得开始判断是不是回文串了:
1.
j
=
0
,
i
=
3
j=0,i=3
j=0,i=3 ,
a
≠
b
=
F
a
l
s
e
a\not=b =False
a=b=False
2.
j
=
1
,
i
=
3
j=1,i=3
j=1,i=3 ,
c
≠
b
=
F
a
l
s
e
c\not=b=False
c=b=False
3.
j
=
2
,
i
=
3
j=2,i=3
j=2,i=3 ,
b
=
b
=
T
r
u
e
b=b=True
b=b=True,也就是在j=2,i=3
的时候,[B-2,B-3]这段子串bb
是回文串。长度
3
−
2
+
1
=
2
3-2+1 = 2
3−2+1=2
那么讲了这么多了,再看一张图片
当
j
=
1
,
i
=
4
j=1,i=4
j=1,i=4 ,
c
=
c
=
T
r
u
e
c=c=True
c=c=True,由 要点4 知道这个时候就是要判断 “c??
c” ,里面的内容是不是回文串,将区间缩小s[i-1][j+1]=bb
,对应在dp数组中表示为dp[i-1][j+1]=True
。那么 “cbbc” 是回文串,长度:4-1+1 =4
解法
我们上面的分析做一下总结
(
s
.
c
h
a
t
(
i
)
=
s
.
c
h
a
t
(
j
)
)
&
&
(
d
p
[
i
−
1
]
[
j
+
1
]
∣
∣
(
i
−
j
)
<
=
2
)
(5.1)
(s.chat(i)=s.chat(j))\&\&(dp[i-1][j+1] || (i-j)<=2) \tag{5.1}
(s.chat(i)=s.chat(j))&&(dp[i−1][j+1]∣∣(i−j)<=2)(5.1)
表示在字符串相等的时候,如果缩小这个字符串的区间还是回文串,那么它就是一个回文串,但是如果字符串只有2个或者1个字符的时候,区间无法缩小直接就是回文串。
代码片段
public String longestPalindromeDP2(String s) {
int len = s.length();
if (len < 2) {//0.如果字符串长度为0、1的时候,直接返回结果。
return s;
}
boolean[][] dp = new boolean[len][len];//1.根据解释创建一个二维dp[i][j]数组
int start = 0, end = 0;//2.计算当前最长回文串的开始和结束位置,默认取第一个字符串即可。
for (int j = 1; j < len; j++) {//3.每到达一个新的位置j,都需要在这个区间移动找出回文串,以列的方式遍历.
for (int i = 0; i < j; i++) {//4.i的位置必然在j的左边。
if (s.charAt(i) == s.charAt(j) && (j - i <= 2 || dp[i + 1][j - 1])) {//5.回文串状态转移方程。
dp[i][j] = true;
if (j - i > end - start) {//6.如果有更长的回文串,更新起点和终点。
start = i;
end = j;
}
}
}
}
return s.substring(start, end + 1);//7.返回这个区间的文本串。
}
结尾
1.博客地址
2.源代码仓库
如果你在代码里看到了用 数字标记的注释 如 //1.xxx 这是我写代码的顺序,希望能给你一点启发。