算法Day02-算法研習指導之動態規劃算法框架

動態規劃算法

  • 動態規劃問題的一般形式就是求最值:
    • 動態規劃是運籌學的一種最優化方法
  • 動態規劃的應用場景:
    • 求最長遞增子序列
    • 求最小編輯距離
  • 動態規劃的核心問題:
    • 窮舉
    • 因爲要求最值,肯定要將所有可行的答案窮舉出來,然後在其中找最值
  • 動態規劃的窮舉很特殊:
    • 存在重疊子問題:
      • 如果暴力窮舉效率低下
      • 需要使用備忘錄或者DP Table來優化窮舉過程,避免不必要的計算
    • 具備最優子結構: 這樣才能通過子問題的最值找到原問題的最值
    • 列出正確的狀態轉移方程才能正確地窮舉: 因爲窮舉出所有可行解並不是一件容易的事
  • 動態規劃三要素:
    • 重疊子問題
    • 最優子結構
    • 狀態轉移方程
  • 狀態轉移方程思維框架:
    • 明確狀態
    • 定義DP數組或者函數的含義
    • 明確選擇
    • 明確base case

斐波那契數列

直接遞歸

  • 斐波那契數列數列的數學形式就是遞歸的,代碼如下:
int fib(int N) {
	if (N == 1 || N == 2) {
		return 1;
	} 
	return fib(N-1) + fib(N-2);
}

這是最簡單易懂的直接遞歸的方法,同時也是效率最低的方法,可以通過畫出遞歸樹看出

  • 遞歸的問題分析,最好都畫出遞歸樹,方便分析算法的複雜度,尋找算法低效的原因
  • 遞歸算法的時間複雜度 = 子問題個數 * 解決一個子問題需要的時間
  • 當N=20時,斐波那契數列遞歸樹分析:
    • 要計算f(20),就要計算出f(19)和f(18),然後要計算f(19),就要計算出f(18)和f(17),以此類推.最後到f(2)和f(1)的時候,結果已知.此時遞歸樹結束
    • 根據遞歸算法時間複雜度等於子問題個數乘以解決一個子問題需要的時間:
      • 子問題個數: 也就是遞歸樹中節點的總數.因爲二叉樹的節點總數爲指數級別,所有子問題的個數爲O(2N)
      • 解決一個子問題需要的時間: 因爲這個算法中沒有循環,只有f(n-1)+f(n-2)一個加法操作,所有時間爲O(1)
      • 直接遞歸算法時間複雜度: O(2n),爲指數級別.相當複雜
    • 觀察遞歸樹,分析出算法低效的原因:
      • 存在大量的重複計算
      • f(18)被計算了兩次,以f(18)爲根的遞歸樹體量巨大,多算一遍,就會耗費大量時間
      • 而且,不止f(18)這一個節點會被重複計算,進而導致算法低下
      • 這個問題就是動態規劃的第一個性質 : 重疊子問題

帶備忘錄的遞歸算法

  • 重疊子問題解決:
    • 因爲耗時的原因是因爲重複計算
    • 那麼可以創建一個備忘錄:
      • 每次算出某個子問題的答案先記錄到備忘錄中再返回
      • 每次遇到一個子問題先去備忘錄中查詢
      • 如果發現備忘錄中已經存在這個解決過的問題,直接獲取答案,不需要再耗時計算了
  • 通常使用數組作爲備忘錄, 也可以使用Hash表作爲備忘錄:
int fib(int N) {
	if (N < 1) {
		return 0;
	}
	// 備忘錄初始化爲0 
	vector<int> memo(N+1, 0);
	// 初始化最簡情況
	return helper(memo, N);	
}

int helper(vector<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];
}
  • 帶備忘錄的遞歸算法的遞歸樹分析:
    • 備忘錄的遞歸樹算法是將一棵存在巨量冗餘的遞歸樹通過剪枝, 改造成了一幅不存在冗餘的遞歸圖,極大減少了子問題,即遞歸樹中節點的個數
    • 根據遞歸算法時間複雜度等於子問題個數乘以解決一個子問題需要的時間:
      • 子問題個數: 即遞歸樹中節點的總數.由於不存在冗餘計算,子問題就是f(1),f(2)…f(20),數量和輸入的規模n成正比,所以子問題的個數就是O(n)
      • 解決一個子問題需要的時間: 因爲這個算法中沒有沒有循環,只有加法操作,所以時間爲O(1)
      • 帶備忘錄的遞歸算法時間複雜度: O(n).比直接遞歸算法高效得多
  • 帶備忘錄的遞歸解法的效率和迭代的動態規劃解法一樣:
    • 帶備忘錄的遞歸解法: 自頂向下
      • 從上向下延伸
      • 從一個規模較大的源問題,向下逐漸分解問題規模,直到觸底.然後逐層返回答案
    • 迭代的動態規劃解法: 自底向上
      • 直接從問題規模最小的最底層開始往上推,直到推導出需要的答案.這就是動態規劃的思路
      • 動態規劃一般都脫離了遞歸,去採用循環迭代完成計算

DP數組的迭代算法

  • 備忘錄的基礎上,將備忘錄獨立出來成爲一張表,叫作DP Table
  • DP Table上完成自底向上的推算:
int fib(int N) {
	vector<int> dp(N+1, 0);
	dp[1] = dp[2] = 1;
	for (int i = 3; i <= N; i++) {
		dp[i] = dp[i - 1] + dp[i - 2];
	}
	return dp[N];
}

DP Table和備忘錄的遞歸樹剪枝後的結構很相似,只不過是反過來計算而已.實際上,帶備忘錄的遞歸解法中的備忘錄, 最終完成的就是這個DP Table. 所以帶備忘錄的遞歸算法和DP數組的迭代算法在大部分情況下的算法效率是相同的

  • 狀態轉移方程: 一種描述問題結構的數學形式
    • 狀態轉移: 比如想要將f(n)做成一個狀態n,這個狀態n是由狀態n-1和狀態n-2相加轉移而來
    • 狀態轉義方程是解決問題的核心
    • 狀態轉移方程直接代表着暴力解法
  • 動態規劃最困難的就是寫出狀態轉移方程

斐波那契數列解法補充

  • 根據斐波那契數列的狀態轉移方程:
    • 當前狀態只和之前的兩個狀態有關
    • 所以並不需要一個很長的DP Table來存儲所有狀態
    • 只要存儲之間的兩個狀態就可以
    • 可以將空間複雜度優化爲O(1)
int fib(int n) {
	if (n == 1 || n ==2) {
		return 1;
	}
	int prev = 1, curr = 1;
	for (int i = 3; i <= n; i++) {
		int sum = prev + curr;
		prev = curr;
		curr = sum;
	}
	return curr;
}

湊零錢問題

  • 題目:
    • k種面值的硬幣,硬幣面值分別爲c1, c2,…ck. 每種硬幣的數量無限,需要湊夠總金額amount. 最少需要幾枚硬幣湊出這個金額,如果不能湊出則返回 -1
  • 對於計算機來說,解決這個問題的方法就是將所有可能的湊硬幣方法都窮舉出來,然後找找看最少需要多少枚硬幣

直接遞歸

  • 要符合最優子結構,子問題之間必須互相獨立
  • 湊零錢問題分析:
    • 這個問題是動態規劃問題,因爲具有最優子結構
      • 原問題: 比如想要求amount=11時的最少硬幣數
      • 子問題: 如果知道湊出amount=10的最少硬幣數
      • 只需要將子問題的答案加1. 再選一枚面值爲1的硬幣後就是原問題的答案
      • 因爲硬幣的數量是沒有限制的,子問題之間沒有限制,是相互獨立的
  • 確定是動態規劃問題後,就要思考如何列出狀態轉移方程:
    • 先確定狀態:
      • 狀態就是原問題和子問題中變化的數量
      • 由於硬幣數量是無限的,所以唯一的狀態就是目標金額amount
    • 然後確定DP函數的定義:
      • 當前的目標金額是n,至少需要dp(n)個硬幣湊出該金額
    • 然後確定選擇並擇優:
      • 也就是對於每個狀態,可以做出什麼選擇改變當前狀態
      • 無論當前的目標金額是多少,選擇就是從面額列表coins中選出一個硬幣,然後目標金額就會減少
      • 僞碼如下:
         def coinChange(coins: List[int], amount: int):
         	# 定義: 要湊出金額n,至少需要dp(n)個硬幣
         	def dep(n):
         		# 做選擇: 選擇需要硬幣最少的那個結果
         		for coin in coins:
         			res = min(res, 1 + dp(n - coin))
         		return res;
         	# 計算出要求的問題dp(amount)
         	return dp(amount)
        
    • 最後明確base case:
      • 明確初始的已知狀態
      • 目標金額爲0時,所需硬幣數量爲0.當目標金額小於0時,無解.返回-1
def coinChange(coins: List[int], amount: int):
	def dp(n):
		if n==0: return 0
		if n < 0: return -1
		# 求最小值,所以初始化爲正無窮
		res = float('INF')
		for coin in coins:
			subproblem = dp(n - coins)
			if subproblem == -1: continue
			res = min(res, 1 + subproblem)
		return res if res != float('INF') else -1
	return dp(amount);
  • 以上代碼的數學形式就是通過直接遞歸獲取到的狀態轉移方程
  • 消除重疊子問題:
    • 直接遞歸算法的遞歸樹分析:
      • 根據時間複雜度等於子問題總數乘以每個子問題的時間:
        • 子問題總數: 這個問題的子問題具體個數比較難看出,可以分析爲O(nk),爲指數級別的個數
        • 每個子問題的時間: 每個子問題含有一個for循環,所以時間複雜度爲O(k)
        • 算法時間複雜度: O(k * nk).是指數級別

帶備忘錄的遞歸算法

  • 可以通過備忘錄消除重疊子問題:
def coinChanges(coins: List[int], amount: int):
	# 備忘錄
	memo = dict()
	def dp(n):
		# 查看備忘錄,避免重複計算
		if n in memo: return memo[n]
		# 如果備忘錄中不存在,則進行計算
		if n = 0: return 0
		if n < 0: return -1
		# 求最小值,所以初始化爲正無窮
		res = float('INF')
		for coin in coins:
			subproblem = dep(n - coin)
			if subproblem == -1: continue
			res = min(res, 1 + subproblem)
		# 將計算結果記入備忘錄
		memo[n] = res if res != float('INF') else -1
		return memo[n]
	return dep(amount) 
  • 帶備忘錄的遞歸算法的遞歸樹分析:
    • 根據算法時間複雜度等於子問題總數乘以每個子問題的時間
      • 子問題總數: 由於引用了備忘錄,大大減少了子問題數目,完全消除了子問題的冗餘,所以子問題的總數不會超過n,即子問題的數目爲O(n)
      • 每個子問題的時間: 由於存在for循環,每個子問題的處理時間爲O(k)
      • 算法時間複雜度: O(kn).大大提高了算法效率

DP數組的迭代算法

  • 自底向上使用DP Table來消除重疊子問題
int coinChanges(vector<int> coins, int amount) {
	// 數組大小爲amount+1,初始值也爲amount+1
	vector<int> dp(amount + 1, amount + 1);
	/*
	 * dp[i] = x:
	 * 	當目標金額爲i時,至少需要x枚硬幣	
	 */
	 for (int i = 0; i < dp.size(); i++) {
	 	// 內層for求所有子問題+1的最小值
	 	for (coin in coin) {
	 		// 如果子問題無解,則跳過循環
	 		if (i - coin < 0) {
	 			continue;
	 		}
	 		dp[i] = min(dp[i], 1 + dp[i - coin])
	 	}
	 }
	 return (dp[amount] == dp[amount + 1]) ? -1 : dp[amount];
}
  • 數組之所以初始化爲amount+1. 是因爲湊成amount金額的硬幣數最多隻可能等於amount, 全用面值爲1元面值的硬幣時
  • 初始化爲amount+1就相當於初始化爲正無窮, 以便後續取最小值

總結

  • 斐波那契問題:
    • 解釋瞭如何通過備忘錄或者DP Table的方法來優化遞歸樹
    • 明確了這兩種方法本質上是一樣的,只是自頂向下(備忘錄)自底向上(DP Table) 的不同
  • 湊零錢問題:
    • 展示瞭如何流程化確定狀態轉移方程
    • 只要通過狀態轉義方程寫出直接遞歸的算法,然後就可以通過優化遞歸樹,消除重疊子問題
  • 計算機解決問題的唯一的解決辦法就是窮舉:
    • 窮舉所有可能性
    • 算法設計就是先思考如何窮舉
    • 然後再追求優化窮舉的方式
  • 如何窮舉: 列出動態轉移方程
    • 列出動態轉移方程就是在解決如何窮舉的問題.之所以列出動態轉移方程困難是因爲:
    • 很多窮舉需要遞歸實現
    • 有的問題本身的解空間複雜,不容易窮舉完整
  • 優化窮舉的方式: 備忘錄和DP Table
    • 備忘錄和DP Table就是在追求優化窮舉的方式
    • 運用的是用空間換時間的思路來降低算法時間複雜度
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章