算法學習之動態規劃(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的算法小抄之後個人的理解以及總結

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