算法学习之动态规划(java版)
动态规划算法可以有效的解决穷举问题。当一个穷举问题存在「重叠子问题」这个特点时,那么就可以尝试使用动态规划算法来解决。
概念
动态规划算法有三个要素:
- 重叠子问题
- 最优子结构
- 状态转义方程
重叠子问题
以斐波那契数列问题为例,其递归方法如下:
int fib(int N) {
if (N == 1 || N == 2)
return 1;
return fib(N - 1) + fib(N - 2);
}
fib(20)=fib(19)+fib(18)
,fib(19)=fib(18)+fib(17)
。根据这两个例子就可以发现,fib(18)
被重复计算了两次。整个算法的求解过程中会重复多次计算,可以通过下面这张图发现这一点:
其中,整个问题呈一颗完全二叉树,每个节点对应一个子问题,其问题个数为
O
(
2
n
)
O(2^n)
O(2n),每个子问题的计算量为
O
(
1
)
O(1)
O(1),因此总的时间复杂度为
O
(
2
n
)
O(2^n)
O(2n)。
整棵树中存在多个重叠子问题,因此是可以优化的。
最优子结构
最优子结构指的是,问题的最优解包含子问题的最优解。
举个例子:
我们需要求学校中某一年级成绩最好的人。这个问题可以拆解为:求某一年级中每个班级成绩最好的人,然后最后在这些人中求成绩最好的。
这里的子问题就是每个班级。
状态转义方程
还是以斐波那契数列为例,我们直接给出其转义方程
f
(
n
)
=
{
1
if
n
=
1
,
2
f
(
n
−
1
)
+
f
(
n
−
2
)
if
n
>
2
f(n)=\begin{cases} 1 &\text{if } n=1,2 \\ f(n-1)+f(n-2) &\text{if } n>2 \end{cases}
f(n)={1f(n−1)+f(n−2)if n=1,2if n>2
求解
在之前提到穷举类问题存在重叠子问题的现象,动态规划算法有两种解决这一现象的方式:
- 「备忘录」方法
- 「dpTable」方法
备忘录
思路:开辟数组,当某一个数值被计算后,在数组中保存其计算结果,在之后需要再用到这个数值时,去数组中直接获取。
int fib(int N){
if (N < 1) return 0;
int[] memo = new int[N+1];
for(int i = 0; i < N + 1; i++){
memo[i] = 0;
}
return helper(memo, N);
}
int helper(int[] memo, int n){
if(n == 1 || n == 2) return 1;
if(memo[n] != 0) return memo[n];
memo[n] = helper(memo, n-1)+helper(memo, n-2);
return memo[n];
}
代码中,memo数组就是「备忘录」,如果『备忘录中』某一个数值结果已经计算好了,直接取出来用,否则计算,再存入计算结果。
dpTable方法
「DPTable」的思路类似于「备忘录」方法,但是『DPTable』自下而上计算的。
int fib(int N){
if (N < 1) return 0;
int[] memo = new int[N+1];
for(int i = 0; i < N + 1; i++){
memo[i] = 0;
}
memo[1]=memo[2]=1;
for(int i = 3; i < N +1;i++){
memo[i] = memo[i-1] + memo [i-2];
}
return memo[N];
}
在这里还可以继续优化。可以发现在第二个for循环中,memo[i]
只与memo[i-1]
,memo[i-2]
相关,因此可以无需额外开辟数组。
int fib(int N){
if (N < 1) return 0;
int prev, curr, sum;
prev = curr = 1;
for(int i = 3; i < N +1;i++){
sum = prev + curr;
prev = curr;
curr = sum;
}
return sum;
}
例题
凑零钱问题
给你 k 种面值的硬币,面值分别为 c1, c2 … ck ,每种硬币的数量无限,再给一个总金额 amount ,问你最少需要几枚硬币凑出这个 金额,如果不可能凑出,算法返回 -1
- 最优子结构
- 当手里拿着n元硬币,总金额为amount时,此时的子问题的最优解为f(amount-n)+1,就是在(amount-n)为总金额的最优解基础上加1个硬币
- 重叠子问题
- 可以看到当把问题穷举出来后,会有重复现象
- 转移方程
- d p ( n ) = { 0 if n = 0 − 1 if n < 0 m i n ( d p ( n − c o i n ) + 1 , d p ( n ) ) ∣ c o i n ∈ c o i n s if n > 0 dp(n)=\begin{cases} 0 &\text{if } n=0 \\ -1 &\text{if } n<0 \\ min(dp(n-coin)+1, dp(n))|coin\isin coins &\text{if } n>0 \end{cases} dp(n)=⎩⎪⎨⎪⎧0−1min(dp(n−coin)+1,dp(n))∣coin∈coinsif n=0if n<0if n>0
备忘录方法求解
int coinChange(int[] coins, int amount) {
int[] memo = new int[amount + 1];
for (int i = 0; i < amount + 1; i++) {
memo[i] = -1;
}
return dp(coins, amount, memo);
}
int dp(int[] coins, int amount, int[] memo) {
if (amount == 0) return 0;
if (amount < 0) return -1;
if (memo[amount] != -1) return memo[amount];
int res = amount + 1;
for (int coin : coins) {
int subProblem = dp(coins, amount - coin, memo);
if (subProblem == -1) continue;
res = Math.min(res, 1 + subProblem);
}
memo[amount] = res == amount + 1 ? -1 : res;
return memo[amount];
}
dpTable方法求解
int coinChange(int[] coins, int amount) {
int[] memo = new int[amount + 1];
for (int i = 0; i < amount + 1; i++) {
memo[i] = amount + 1;
}
return dp(coins, amount, memo);
}
int dp(int[] coins, int amount, int[] memo) {
memo[0] = 0;
for (int i = 0; i < amount + 1; i++) {
for (int coin : coins) {
if (i - coin < 0) continue;
memo[i] = Math.min(memo[i], 1 + memo[i - coin]);
}
}
return memo[amount] == amount + 1 ? -1 : memo[amount];
}
注意:慎用Integer.MAX_VALUE,容易出现溢出问题。
总结
先想办法穷举,然后列出转移函数,通过备忘录或者dp table的方式解除重叠子问题。
动态规划三要素:重叠子问题,最优子结构,状态转移方程。
dp的遍历顺序需要注意:
- 遍历顺序中,所需的状态必须是已经计算出来的
- 遍历的终点必须是存储结果的那个位置
申明:本博文是看了labuladong的算法小抄之后个人的理解以及总结。