算法学习之动态规划(java版)

算法学习之动态规划(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(2n)O(2^n),每个子问题的计算量为O(1)O(1),因此总的时间复杂度为O(2n)O(2^n)
整棵树中存在多个重叠子问题,因此是可以优化的。

最优子结构

最优子结构指的是,问题的最优解包含子问题的最优解。

举个例子:
我们需要求学校中某一年级成绩最好的人。这个问题可以拆解为:求某一年级中每个班级成绩最好的人,然后最后在这些人中求成绩最好的。
这里的子问题就是每个班级。

状态转义方程

还是以斐波那契数列为例,我们直接给出其转义方程
f(n)={1if n=1,2f(n1)+f(n2)if n>2f(n)=\begin{cases} 1 &\text{if } n=1,2 \\ f(n-1)+f(n-2) &\text{if } n>2 \end{cases}

求解

在之前提到穷举类问题存在重叠子问题的现象,动态规划算法有两种解决这一现象的方式:

  • 「备忘录」方法
  • 「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个硬币
  • 重叠子问题
    • 在这里插入图片描述 可以看到当把问题穷举出来后,会有重复现象
  • 转移方程
    • dp(n)={0if n=01if n<0mid(dp(ncoin)+1coincoinsif n>0dp(n)=\begin{cases} 0 &\text{if } n=0 \\ -1 &\text{if } n<0 \\ mid(dp(n-coin)+1|coin\isin coins &\text{if } n>0 \end{cases}

备忘录方法求解

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的算法小抄之后个人的理解以及总结

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章