Algorithm7---Dynamic

導言

動態規劃得算法思想是通過空間換取時間。這篇文章來源於和程序員小吳一起學算法,清晰解釋了什麼是動態規劃以及解決動態規劃問題得思考步驟

什麼是動態規劃

用一句話解釋動態規劃就是記住你之前做過的事,如果更精確些,其實是記住你之前得到的答案

思考動態規劃問題的四個步驟

一般解決動態規劃問題,分爲四個步驟,分別是

  • 問題拆解,找到問題之間的具體聯繫
  • 狀態定義
  • 遞推方程推導
  • 實現
    eg:
    “1+1+1+1+1+1+1+1” 得出答案是 8,那麼如何快速計算 “1+ 1+1+1+1+1+1+1+1”,我們首先可以對這個大的問題進行拆解,這裏我說的大問題是 9 個 1 相加,這個問題可以拆解成 1 + “8 個 1 相加的答案”,8 個 1 相加繼續拆,可以拆解成 1 + “7 個 1 相加的答案”,… 1 + “0 個 1 相加的答案”,到這裏,第一個步驟 已經完成。

狀態定義 其實是需要思考在解決一個問題的時候我們做了什麼事情,然後得出了什麼樣的答案。對於這個問題,當前問題的答案就是當前的狀態,基於上面的問題拆解,你可以發現兩個相鄰的問題的聯繫其實就是 後一個問題的答案 = 前一個問題的答案 + 1,這裏,狀態的每次變化就是 +1。

定義好了狀態,遞推方程就變得非常簡單,就是 dp[i] = dp[i - 1] + 1,這裏的 dp[i] 記錄的是當前問題的答案,也就是當前的狀態,dp[i - 1] 記錄的是之前相鄰的問題的答案,也就是之前的狀態,它們之間通過 +1 來實現狀態的變更。

最後一步就是實現了,有了狀態表示和遞推方程,實現這一步上需要重點考慮的其實是初始化,就是用什麼樣的數據結構,根據問題的要求需要做那些初始值的設定。

func DpExample(n int64) int64 {
	dp := []int64{}
	dp[0] = 0
	for i := int64(1); i <= n; i++ {
		dp[i] = dp[i-1] + 1
	}
	return dp[n]
}

你可以看到,動態規劃這四個步驟其實是相互遞進的,狀態的定義離不開問題的拆解,地推方程的推導離不開狀態的定義,最後的實現代碼的核心其實就是遞推方程,這其中如果有一個步驟卡殼了則會導致問題無法解決,當問題的複雜程度增加的時候,這裏面的思維複雜程度會上升。

題目實戰

爬樓梯(LeetCode 第 70 號問題)

但凡涉及到動態規劃的題目都離不開一道例題:爬樓梯(LeetCode 第 70 號問題)。

1.題目描述


假設你正在爬樓梯。需要 n 階你才能到達樓頂。

每次你可以爬 1 或 2 個臺階。你有多少種不同的方法可以爬到樓頂呢?

注意:給定 n 是一個正整數。

示例一:
輸入:2
輸出:2
解釋: 有兩種方法可以爬到樓頂。

1. 1 階 + 1 階
2. 2 階
示例二:
輸入:3
輸出:3
解釋: 有三種方法可以爬到樓頂。

1. 1 階 + 1 階 + 1 階
2. 1 階 + 2 階
3. 2 階 + 1 階

2.題目解析


爬樓梯,可以爬一步也可以爬兩步,問有多少種不同的方式到達終點,我們按照上面提到的

四個步驟進行分析:

  • 問題拆解

我們到達第 n 個樓梯可以從第 n - 1 個樓梯和第 n - 2 個樓梯到達,因此第 n 個問題可以拆解成第 n - 1 個問題和第 n - 2 個問題,第 n - 1 個問題和第 n - 2 個問題又可以繼續往下拆,直到第 0 個問題,也就是第 0 個樓梯 (起點)

  • 狀態定義

問題拆解” 中已經提到了,第 n 個樓梯會和第 n - 1 和第 n - 2 個樓梯有關聯,那麼具體的聯繫是什麼呢?你可以這樣思考,第 n - 1 個問題裏面的答案其實是從起點到達第 n - 1 個樓梯的路徑總數,n - 2 同理,從第 n - 1 個樓梯可以到達第 n 個樓梯,從第 n - 2 也可以,並且路徑沒有重複,因此我們可以把第 i 個狀態定義爲 “從起點到達第 i 個樓梯的路徑總數”,狀態之間的聯繫其實是相加的關係。

  • 遞推方程

狀態定義” 中我們已經定義好了狀態,也知道第 i 個狀態可以由第 i - 1 個狀態和第 i - 2 個狀態通過相加得到,因此遞推方程就出來了 dp[i] = dp[i - 1] + dp[i - 2]

  • 實現

你其實可以從遞推方程看到,我們需要有一個初始值來方便我們計算,起始位置不需要移動 dp[0] = 0,第 1 層樓梯只能從起始位置到達,因此 dp[1] = 1,第 2 層樓梯可以從起始位置和第 1 層樓梯到達,因此 dp[2] = 2,有了這些初始值,後面就可以通過這幾個初始值進行遞推得到。

3.參考代碼


func ClimbStairs(n int64) int64 {
	if (n == 1){
		return 1
	}
	dp := [n]int64{}
	dp[0] = 0; dp[1] = 1; dp[2] = 2
	for i := int64(3); i <= n; i++ {
		dp[i] = dp[i-1] + dp[i-2]
	}
	return dp[n]
}

三角形最小路徑和(LeetCode 第120 號問題)

LeetCode 第 120 號問題:三角形最小路徑和。

1.題目描述


給定一個三角形,找出自頂向下的最小路徑和。每一步只能移動到下一行中相鄰的結點上。

例如,給定三角形:

[
     [2],
    [3,4],
   [6,5,7],
  [4,1,8,3]
]

自頂向下的最小路徑和爲 11(即,2 + 3 + 5 + 1 = 11)。

說明:

如果你可以只使用 O(n) 的額外空間(n 爲三角形的總行數)來解決這個問題,那麼你的算法會很加分。

2.題目解析


  • 問題拆解:
    這裏的總問題是求出最小路徑和,路徑是這裏的分析重點,路徑是由一個個元素組成的,和之前爬樓梯拿到題目類似,[i][j] 位置的元素,經過這個元素的路徑也會經過 [i-1][j] 或者 [i-1][j-1], 因此經過一個元素的路徑和可以通過這個元素上面的一個或者兩個元素的路徑和得到。
  • 狀態定義
    狀態的定義一般會和問題需要求解的答案聯繫在一起,這裏其實有兩種方式,一種是考慮路徑從上到下,另一種是考慮路徑從下到上,因爲元素的值是不變的, 所以路徑的方向不同也不會影響最後求得的路徑和,如果是從上到下,你會發現,在考慮下面元素的時候,起始元素的路徑只會從[i-1][j] 獲得,每行當中的最後一個元素的路徑只會從[i-][j-1] 獲得,中間二者都可以,這樣不太好實現,因此這裏考慮從下到上的方式,狀態的定義就變成了 “最後一行元素到當前元素的最小路徑和”,對於[0][0] 這個元素來說,最後狀態表示的就是我們的最終答案。
  • 遞推方程
dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + triangle[i][j]
  • 實現
    這裏初始化時,我們需要將最後一行的元素填入狀態數組中,然後就是按照前面分析的策略,從下到上計算即可

3.參考代碼


func MinimumTotal(n [][]int64) int64 {
	size := len(n)

	dp := [][]int64{}

	for i := 0; i < size; i++{
		dp[size-1][i] = n[size-1][i]
	}

	for i := size-2; i >=0; i-- {
		for j:=0; j < i+1; j++{
			dp[i][j] = int64(math.Min(float64(dp[i+1][j]), float64(dp[i+1][j+1]))) + n[i][j]
		}
	}
	return dp[0][0]
}

最大子序和(LeetCode 第 53號問題)

LeetCode 第 53 號問題:最大子序和。

1.題目描述


給定一個整數數組 nums ,找到一個具有最大和的連續子數組(子數組最少包含一個元素),返回其最大和。

輸入: [-2,1,-3,4,-1,2,1,-5,4],
輸出: 6
解釋: 連續子數組 [4,-1,2,1] 的和最大,爲 6。

進階:
如果你已經實現複雜度爲 O(n) 的解法,嘗試使用更爲精妙的分治法求解。

2.題目解析


求最大子數組和,非常經典的一道題目,這道題目有很多種不同的做法,而且很多算法思想都可以在這道題目上面體現出來,比如動態規劃、貪心、分治,還有一些技巧性的東西,比如前綴和數組,這裏還是使用動態規劃的思想來解題,套路還是之前的四步驟:

  • 問題拆解:
    問題的核心是子數組,子數組可以看作是一段區間,因此可以由起始點和終止點確定一個子數組,兩個點中,我們先確定一個點,然後去找另一個點,比如說,如果我們確定一個子數組的截至元素在i這個位置,之歌時候我們需要思考的問題是 “以 i 結尾的所有子數組中,和最大的是多少?”,然後我們去試着拆解,這裏其實只有兩種情況:

這個位置的元素自成一個子數組
位置的元素的值 + 以 i-1 結尾的所有子數組中的子數組和最大的值

你可以看到,我們把 i 個問題拆成第 i-1 個問題,之間的聯繫也變得清晰

  • 狀態定義
    通過上面的分析,其實狀態已經有了,dp[i] 就是 “以 i 結尾的所有子數組的最大值”
  • 遞推方程
    拆解問題的時候也提到了,有兩種情況,即當前元素自成一個子數組,另外可以考慮前一個狀態的答案,於是就有了
dp[i] = Math.max(dp[i - 1] + array[i], array[i])

化簡一下就成了:

dp[i] = Math.max(dp[i - 1], 0) + array[i]
  • 實現

題目要求子數組不能爲空,因此一開始需要初始化,也就是 dp[0] = array[0],保證最後答案的可靠性,另外我們需要用一個變量記錄最後的答案,因爲子數組有可能以數組中任意一個元素結尾

3.參考代碼


func MaxSubArray(n []int64) int64 {
	if len(n) == 0{
		return 0
	}
	size := len(n)

	dp := n
	//dp[0] = n[0]
	result := dp[0]
	for i := 1; i < size; i++{
		dp[i] = int64(math.Max(float64(dp[i-1]),0)) + n[i]
		result = int64(math.Max(float64(result),float64(dp[i])))
	}

	return result
}

最長子迴文

題目描述

迴文串(palindromic string)是指這個字符串無論從左讀還是從右讀,所讀的順序是一樣的;簡而言之,迴文串是左右對稱的。

題目解析

最容易想到的是窮舉法,窮舉所有子串,找出迴文串的子串,統計出最長的那一個。

窮舉的時間複雜度過高,接下來我們用dp進行優化。對於母串s,我們用dp[i,j]=1表示子串s[i…j]爲迴文子串,那麼就有遞推式,dp[i,j] = dp[i+1,j-1] if s[i] = s[j]。

  • 當s[i] = s[j]時,如果 s[i+1…j-1]是迴文子串,則s[i…j]也是迴文子串;
  • 如果s[i] != s[j] 或 s[i+1…j-1]不是迴文子串,則s[i…j]也不是
    對於只包含單個字符、或兩個字符重複,其均爲迴文串:
  • dp[i,i] = 1
  • d[i,i+1] = 1 if s[i] == s[i+1]

代碼實現

func LongestPalindrome(s string)string{
	length := len(s)
	longest := string(s[0])
	dp := make([][]bool,length)
	for i:= range dp{
		dp[i] = make([]bool, length)
	}
	for gap := 0;gap<length;gap++{
		for i:=0;i<length-gap;i++{
			j := i+gap
			if s[i] == s[j] && (j-i<=2 || dp[i+1][j-1]){
				dp[i][j] = true
				if j+1-i > len(longest){
					longest = s[i:j+1]
				}
			}
		}
	}
	return longest
}

分治法

//分治法
迴文串是左右對稱的,如果從中心軸開始遍歷,會減少一層循環。依次以母串的每一個字符爲中心軸,得到迴文串;然後通過比較得到最長的那一個
func l2rHelper(s string,mid int)string{
	l := mid-1; r := mid+1
	length := len(s)
	for r < length && s[r] == s[mid]{
		r++
	}
	for l >= 0 && r<length && s[l]==s[r]{
		l--
		r++
	}
	return s[l+1:r]
}
func LongestPalindrome(s string)string{
	length := len(s)
	longest := string(s[0])
	for i:=0;i<length-1;i++{
		if len(l2rHelper(s,i)) > len(longest){
			longest = l2rHelper(s,i)
		}
	}
	return longest
}

矩陣類動態規劃問題

矩陣類動態規劃,也可以叫做座標類動態規劃,一般這類問題都會給你一個矩陣,矩陣裏面有着一些信息,然後你需要根據這些信息求解問題。

其實 矩陣可以看作是圖的一種,怎麼說?你可以把整個矩陣當成一個圖,矩陣裏面的每個位置上的元素當成是圖上的節點,然後每個節點的鄰居就是其相鄰的上下左右的位置,我們遍歷矩陣其實就是遍歷圖,在遍歷的過程中會有一些臨時的狀態,也就是子問題的答案,我們記錄這些答案,從而推得我們最後想要的答案。

一般來說,在思考這類動態規劃問題的時候,我們只需要思考當前位置的狀態,然後試着去看當前位置和它鄰居的遞進關係,從而得出我們想要的遞推方程,這一類動態規劃問題,相對來說比較簡單,我們通過幾道例題來熟悉一下。

不同路徑(LeetCode 第 62 號問題)

1.題目描述


一個機器人位於一個 m x n 網格的左上角 (起始點在下圖中標記爲“Start” )。

機器人每次只能向下或者向右移動一步。機器人試圖達到網格的右下角(在下圖中標記爲“Finish”)。

問總共有多少條不同的路徑?
在這裏插入圖片描述
例如,上圖是一個7 x 3 的網格。有多少可能的路徑?

說明: m 和 n 的值均不超過 100。

示例 1:

輸入: m = 3, n = 2
輸出: 3
解釋:
從左上角開始,總共有 3 條路徑可以到達右下角。

1. 向右 -> 向右 -> 向下
2. 向右 -> 向下 -> 向右
3. 向下 -> 向右 -> 向右
輸入: m = 7, n = 3
輸出: 28

2.題目解析


給定一個矩陣,問有多少種不同的方式從起點(0,0) 到終點 (m-1,n-1),並且每次移動只能向右或者向下,我們還是按之前提到的分析動態規劃那四個步驟來思考一下:

  • 問題拆解:
    題目中說了,每次移動只能是向右或者是向下,矩陣類動態規劃需要關注當前位置和其相鄰位置的關係,對於某一個位置來說,經過它的路徑只能從它上面過來,或者從它左邊過來,因此,如果需要求到達當前位置的不同路徑,我們需要知道到達其上方位置的不同路徑,以及到達其左方位置的不同路徑
  • 狀態定義
    矩陣類動態規劃的狀態定義相對來說比較簡單,只需要看當前位置即可,問題拆解中,我們分析了當前位置和其鄰居的關係,提到每個位置其實都可以算做是終點,狀態表示就是 “從起點到達該位置的不同路徑數目
  • 遞推方程
    有了狀態,也知道了問題之間的聯繫,其實遞推方程也出來了,就是
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
  • 實現
    有了這些,這道題還沒完,我們還要考慮狀態數組的初始化問題,對於上邊界和左邊界的點,因爲它們只能從一個方向過來,需要單獨考慮,比如上邊界的點只能從左邊這一個方向過來,左邊界的點只能從上邊這一個方向過來,它們的不同路徑個數其實就只有 1,提前處理就好。

3.參考代碼


func UniquePaths(m int64,n int64) int64 {
	dp := [7][7]int64{}

	for i:=int64(0); i<m; i++{
		dp[i][0] = 1
	}
	for j:=int64(0); j<n; j++{
		dp[0][j] = 1
	}
	for i:=int64(1); i<m; i++{
		for j:=int64(1); j<n; j++{
			dp[i][j] = dp[i-1][j] + dp[i][j-1]
		}
	}
	fmt.Println(dp)

	return dp[m-1][n-1]
}

不同路徑II

1.題目描述


一個機器人位於一個 m x n 網格的左上角 (起始點在下圖中標記爲“Start” )。

機器人每次只能向下或者向右移動一步。機器人試圖達到網格的右下角(在下圖中標記爲“Finish”)。

現在考慮網格中有障礙物。那麼從左上角到右下角將會有多少條不同的路徑?
在這裏插入圖片描述
網格中的障礙物和空位置分別用 1 和 0 來表示。

說明:m 和 n 的值均不超過 100。

示例 1:

輸入:
[
  [0,0,0],
  [0,1,0],
  [0,0,0]
]
輸出: 2
解釋:
3x3 網格的正中間有一個障礙物。
從左上角到右下角一共有 2 條不同的路徑:

1. 向右 -> 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右 -> 向右

2.題目解析


在上面那道題的基礎上,矩陣中增加了障礙物,這裏只需要針對障礙物進行判斷即可,如果當前位置是障礙物的話,狀態數組中當前位置記錄的答案就是 0,也就是沒有任何一條路徑可以到達當前位置,除了這一點外,其餘的分析方法和解題思路和之前 一樣

3.參考代碼


func UniquePathsWithObstacles(obstacleGrid *[7][7]int64) int64 {
	if len(obstacleGrid) == 0 || len(obstacleGrid[0]) == 0{
		return 0
	}
	if (obstacleGrid[0][0]) == 1{
		return 0
	}
	m := len(obstacleGrid)
	n := len(obstacleGrid[0])
	dp := [7][7]int64{}
	dp[0][0] = 1

	for i:=0; i<m; i++{
		dp[i][0] = 1
		if obstacleGrid[i][0] == 1{
			dp[i][0] = 0
		}
	}
	for j:=0; j<n; j++{
		dp[0][j] = 1
		if obstacleGrid[0][j] == 1{
			dp[0][j] = 0
		}
	}
	for i:=1; i<m; i++{
		for j:=1; j<n; j++{
			dp[i][j] = dp[i-1][j] + dp[i][j-1]
			if obstacleGrid[i][j] == 1{
				dp[i][j] = 0
			}
		}
	}
	fmt.Println(dp)

	return dp[m-1][n-1]
}

最小路徑和(LeetCode 第 64 號問題)

1.題目描述


給定一個包含非負整數的 m x n 網格,請找出一條從左上角到右下角的路徑,使得路徑上的數字總和爲最小。

說明:每次只能向下或者向右移動一步。

示例:

輸入:
[
  [1,3,1],
  [1,5,1],
  [4,2,1]
]
輸出: 7
解釋: 因爲路徑 1→3→1→1→1 的總和最小。

2.題目解析


給定一個矩陣,問從起點(0,0) 到終點 (m-1,n-1) 的最小路徑和是多少,並且每次移動只能向右或者向下,按之四個步驟來思考一下:

  • 問題解析:
    拆解問題的方式方法和前兩道題目非常類似,這裏不同的地方只是記錄的答案不同,也就是狀態不同,我們還是可以僅僅考慮當前位置,然後可以看到只有上面的位置和左邊的位置可以到達當前位置,因此當前問題就可以拆解成兩個子問題

  • 狀態定義
    因爲是要求路徑和,因此狀態需要記錄的是 “從起始點到當前位置的最小路徑和

  • 遞推方程
    有了狀態,以及問題之間的聯繫,我們知道了,當前的最短路徑和可以由其上方和其左方的最短路徑和對比得出,遞推方程也可以很快寫出來:

dp[i][j] = Math.min(dp[i - 1][j] + dp[i][j - 1]) + grid[i][j]
  • 實現
    實現上面需要重點考慮的還是狀態數組的初始化,這一步還是和前面兩題類似,這裏就不過多贅述

3.參考代碼

在Go語言中,當多維數組直接作爲函數實參進行參數傳遞的時候,會有很大的限制性,
比如除第一維數組的其他維數需要顯式給出等;此時可以使用多維切片來作爲參數傳遞:
type s1 []int
type s2 []s1

func UniquePaths(grid s2) int64 {
	m := len(grid)
	n := len((grid)[0])
	dp := grid
	dp[0][0] = grid[0][0]
	
	for i:=1; i<m; i++{
		dp[i][0] = dp[i-1][0] + grid[i][0]
	}
	for j:=1; j<n; j++{
		dp[0][j] = dp[0][j-1] + grid[0][j]
	}
	for i:=1; i<m; i++{
		for j:=1; j<n; j++{
			dp[i][j] = int64(math.Max(float64(dp[i-1][j]),float64(dp[i][j-1]))) + grid[i][j]
		}
	}
	fmt.Println(dp)

	return dp[m-1][n-1]
}

最大正方形(LeetCode 第 221 號問題)

1.題目描述


在一個由 0 和 1 組成的二維矩陣內,找到只包含 1 的最大正方形,並返回其面積。

示例:

輸入: 

1 0 1 0 0
1 0 1 1 1
1 1 1 1 1
1 0 0 1 0

輸出: 4

2.題目解析


題目給定一個字符矩陣,字符矩陣中只有兩種字符,分別是 ‘0’ 和 ‘1’,題目要在矩陣中找全爲 ‘1’ 的,面積最大的正方形。

剛拿道這道題,如果不說任何解法的話,其實並不是特別好想,我們先來看看切題的思路是怎麼樣的。

首先一個正方形是由四個頂點構成的,如果說我們在矩陣中隨機找四個點,然後判斷該四個點組成的是不是正方形,如果是正方形,然後看組成正方形的每個位置的元素是不是都是 ‘1’,這種方式也是可行的,但是比較暴力,這麼弄下來,時間複雜度是 O((m*n)^4)。

那我們就會思考,組成一個正方形是不是必須要四個點都找到?如果我們找出其中的三個點,甚至說兩個點,能不能確定這個正方形呢?

你會發現,這裏我們只需要考慮 正方形對角線的兩個點 即可,這兩個點確定了,另外的兩個點也就確定了,因此我們可以把時間複雜度降爲 O((m*n)^2)。

但是這裏還是會有一些重複計算在裏面,我們和之前一樣,本質還是在做暴力枚舉,只是說枚舉的個數變少了,我們能不能記錄我們之前得到過的答案,通過犧牲空間換取時間呢,這裏正是動態規劃所要做的事情!

  • 問題拆解
    我們可以思考,如果我們從左到右,然後從上到下遍歷矩陣,假設我們遍歷到的當前位置是正方形的右下方的點,那其實我們可以看之前我們遍歷過的點有沒有可能和當前點組成符合條件的正方形,除了這個點以外,無非是要找另外三個點,這三個點分別在當前點的上方,左方,以及左上方,也就是從這個點往這三個方向去做延伸,具體延伸的距離是和其相鄰的三個點中的狀態有關
  • 狀態定義
    因爲我們考慮的是正方形的右下方的頂點,因此狀態可以定義成 “當前點爲正方形的右下方的頂點時,正方形的最大面積
  • 遞推方程
    有了狀態,我們再來看看遞推方程如何寫,前面說到我們可以從當前點向三個方向延伸,我們看相鄰的位置的狀態,這裏我們需要取三個方向的狀態的最小值才能確保我們延伸的是全爲 ‘1’ 的正方形,也就是
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1
  • 實現
    在實現上,我們需要單獨考慮兩種情況,就是當前位置是 ‘1’,還有就是當前位置是 ‘0’,如果是 ‘0’ 的話,狀態就是 0,表示不能組成正方形,如果是 ‘1’ 的話,我們也需要考慮位置,如果是第一行的元素,以及第一列的元素,表明該位置無法同時向三個方向延伸,狀態直接給爲 1 即可,其他情況就按我們上面得出的遞推方程來計算當前狀態。

3.參考代碼


type s1 []int64
type s2 []s1

func MaximalSquare(matrix s2) int64 {
	if len(matrix) == 0 || len(matrix[0]) == 0{
		return 0
	}
	m := len(matrix); n := len(matrix[0])
	dp := matrix; maxLength := int64(0)

	for i:=0; i<m; i++{
		for j:=0; j<n; j++ {
			if matrix[i][j] == 1 {
				if (i == 0 || j == 0) {
					dp[i][j] = 1
					if matrix[i][j] == 0 {
						dp[i][j] = 0
					}
				} else {
					dp[i][j] = int64(math.Min(float64(dp[i-1][j]),
						math.Min(float64(dp[i][j-1]), float64(dp[i-1][j-1])),
					)) + 1
				}
				maxLength = int64(math.Max(float64(dp[i][j]),float64(maxLength)))
			}
		}
	}
	fmt.Println(dp)

	return maxLength*maxLength
}

總結

  • 通過這幾個簡單的例子,相信你不難發現,解動態規劃題目其實就是拆解問題,定義狀態的過程,嚴格說來,動態規劃並不是一個具體的算法,而是凌駕於算法之上的一種 思想

  • 這種思想強調的是從局部最優解通過一定的策略推得全局最優解,從子問題的答案一步步推出整個問題的答案,並且利用空間換取時間。從很多算法之中你都可以看到動態規劃的影子,所以,還是那句話 技術都是相通的,找到背後的本質思想是關鍵。

  • 對於矩陣類的動態規劃,相對來說比較簡單,這一類動態規劃也比較好識別,一般輸入的參數就是一個矩陣,解題的時候,我們只需要從當前位置出發考慮狀態即可,通常來說當前位置的狀態的求解僅僅需要藉助其相鄰位置的狀態,通常我們也不需要考慮非常隱蔽的邊界條件,一般需要做的初始化操作都可以從矩陣中,以及題目中的信息得出。

補充

至於爲什麼最終的解法看起來如此精妙,是因爲動態規劃遵循一套固定的流程:遞歸的暴力解法 -> 帶備忘錄的遞歸解法 -> 非遞歸的動態規劃解法。這個過程是層層遞進的解決問題的過程,你如果沒有前面的鋪墊,直接看最終的非遞歸動態規劃解法,當然會覺得牛逼而不可及了。

當然,見的多了,思考多了,是可以一步寫出非遞歸的動態規劃解法的。任何技巧都需要練習,我們先遵循這個流程走,算法設計也就這些套路,除此之外,真的沒啥高深的。

本文會通過兩個個比較簡單的例子:斐波那契和湊零錢問題,揭開動態規劃的神祕面紗,描述上述三個流程。後續還會寫幾篇文章探討如何使用動態規劃技巧解決比較複雜的經典問題。

斐波那契數列

步驟一、暴力的遞歸算法

func Fib(n int64) int64 {
	if n == 1 || n == 2{
		return 1
	}
	return Fib(n-1)+Fib(n-2)
}

PS:但凡遇到需要遞歸的問題,最好都畫出遞歸樹,這對你分析算法的複雜度,尋找算法低效的原因都有巨大幫助。
在這裏插入圖片描述
遞歸算法的時間複雜度怎麼計算?子問題個數乘以解決一個子問題需要的時間。

子問題個數,即遞歸樹中節點的總數。顯然二叉樹節點總數爲指數級別,所以子問題個數爲 O(2^n)。

解決一個子問題的時間,在本算法中,沒有循環,只有 f(n – 1) + f(n – 2) 一個加法操作,時間爲 O(1)。

所以,這個算法的時間複雜度爲 O(2^n),指數級別,爆炸。

觀察遞歸樹,很明顯發現了算法低效的原因:存在大量重複計算,比如 f(18) 被計算了兩次,而且你可以看到,以 f(18) 爲根的這個遞歸樹體量巨大,多算一遍,會耗費巨大的時間。更何況,還不止 f(18) 這一個節點被重複計算,所以這個算法及其低效。

這就是動態規劃問題的第一個性質:重疊子問題。下面,我們想辦法解決這個問題。

步驟二、帶備忘錄的遞歸解法

明確了問題,其實就已經把問題解決了一半。即然耗時的原因是重複計算,那麼我們可以造一個「備忘錄」,每次算出某個子問題的答案後別急着返回,先記到「備忘錄」裏再返回;每次遇到一個子問題先去「備忘錄」裏查一查,如果發現之前已經解決過這個問題了,直接把答案拿出來用,不要再耗時去計算了。

一般使用一個數組充當這個「備忘錄」,當然你也可以使用哈希表(字典),思想都是一樣的。

var memo [22]int64
func Fib(n int64) int64 {
	if n < 1 {
		return 0
	}
	memo[1] = 1; memo[2] = 1
	return Helper(n)
}
func Helper(n int64) int64{
	if n > 0 && memo[n] == 0{
		memo[n] = Helper(n-1) + Helper(n-2)
	}
	return memo[n]
}

現在,畫出遞歸樹,你就知道「備忘錄」到底做了什麼。

在這裏插入圖片描述
實際上,帶「備忘錄」的遞歸算法,把一棵存在巨量冗餘的遞歸樹通過「剪枝」,改造成了一幅不存在冗餘的遞歸圖,極大減少了子問題(即遞歸圖中節點)的個數。

子問題個數,即圖中節點的總數,由於本算法不存在冗餘計算,子問題就是 f(1), f(2), f(3) … f(20),數量和輸入規模 n = 20 成正比,所以子問題個數爲 O(n)。

所以,本算法的時間複雜度是 O(n)。比起暴力算法,是降維打擊

至此,帶備忘錄的遞歸解法的效率已經和動態規劃一樣了。實際上,這種解法和動態規劃的思想已經差不多了,只不過這種方法叫做「自頂向下」,動態規劃叫做「自底向上」。

啥叫「自頂向下」?注意我們剛纔畫的遞歸樹(或者說圖),是從上向下延伸,都是從一個規模較大的原問題比如說 f(20),向下逐漸分解規模,直到 f(1) 和 f(2) 觸底,然後逐層返回答案,這就叫「自頂向下」。

啥叫「自底向上」?反過來,我們直接從最底下,最簡單,問題規模最小的 f(1) 和 f(2) 開始往上推,直到推到我們想要的答案 f(20),這就是動態規劃的思路,這也是爲什麼動態規劃一般都脫離了遞歸,而是由循環迭代完成計算。

步驟三、動態規劃

有了上一步「備忘錄」的啓發,我們可以把這個「備忘錄」獨立出來成爲一張表,就叫做 DP table 吧,在這張表上完成「自底向上」的推算豈不美哉!

var memo [22]int64
func Fib(n int64) int64 {
	if n < 1 {
		return 0
	}
	memo[1] = 1; memo[2] = 1
	for i := int64(3); i < n+1; i++{
		memo[i] = memo[i-1] + memo[i-2]
	}
	return memo[n]
}

在這裏插入圖片描述
這裏,引出「動態轉移方程」這個名詞,實際上就是描述問題結構的數學形式
在這裏插入圖片描述

爲啥叫「狀態轉移方程」?爲了聽起來高端。你把 f(n) 想做一個狀態 n,這個狀態 n 是由狀態 n – 1 和狀態 n – 2 相加轉移而來,這就叫狀態轉移,僅此而已。

你會發現,上面的幾種解法中的所有操作,例如 return f(n – 1) + f(n – 2),dp[i] = dp[i – 1] + dp[i – 2],以及對備忘錄或 DP table 的初始化操作,都是圍繞這個方程式的不同表現形式。可見列出「狀態轉移方程」的重要性,它是解決問題的核心。很容易發現,其實狀態轉移方程直接代表着暴力解法。

千萬不要看不起暴力解,動態規劃問題最困難的就是寫出狀態轉移方程,即這個暴力解。優化方法無非是用備忘錄或者 DP table,再無奧妙可言。

這個例子的最後,講一個細節優化。細心的讀者會發現,根據斐波那契數列的狀態轉移方程,當前狀態只和之前的兩個狀態有關,其實並不需要那麼長的一個 DP table 來存儲所有的狀態,只要想辦法存儲之前的兩個狀態就行了。所以,可以進一步優化,把空間複雜度降爲 O(1):

func Fib(n int64) int64 {
	if n < 1 {
		return 0
	}
	pre := int64(1); cur := int64(1); sum := int64(0)
	for i := int64(1); i < n-1; i++{
		sum = cur + pre
		pre = cur
		cur = sum
	}
	return sum
}

動態規劃的另一個重要特性「最優子結構」,怎麼沒有涉及?下面會涉及。斐波那契數列的例子嚴格來說不算動態規劃,以上旨在演示算法設計螺旋上升的過程。當問題中要求求一個最優解或在代碼中看到循環和 max、min 等函數時,十有八九,需要動態規劃大顯身手。

湊零錢問題

題目:給你 k 種面值的硬幣,面值分別爲 c1, c2 … ck,再給一個總金額 n,問你最少需要幾枚硬幣湊出這個金額,如果不可能湊出,則回答 -1 。

比如說,k = 3,面值分別爲 1,2,5,總金額 n = 11,那麼最少需要 3 枚硬幣,即 11 = 5 + 5 + 1 。下面走流程。

一、暴力解法

首先是最困難的一步,寫出狀態轉移方程,這個問題比較好寫:
在這裏插入圖片描述
其實,這個方程就用到了「最優子結構」性質:原問題的解由子問題的最優解構成。即 f(11) 由 f(10), f(9), f(6) 的最優解轉移而來。
記住,要符合「最優子結構」,子問題間必須互相獨立。啥叫相互獨立?你肯定不想看數學證明,我用一個直觀的例子來講解。

var coins = []int64{1,2,5}
func CoinChange(n int64) int64 {
	ans := n
	for _,i := range coins{
		if (n-int64(i)) < 0{continue}
		subProb := CoinChange(n-int64(i))
		ans = int64(math.Min(float64(ans),float64(subProb+1)))
	}
	return ans
}

畫出遞歸樹:
在這裏插入圖片描述
時間複雜度分析:子問題總數 x 每個子問題的時間。子問題總數爲遞歸樹節點個數,這個比較難看出來,是 O(n^k),總之是指數級別的。每個子問題中含有一個 for 循環,複雜度爲 O(k)。所以總時間複雜度爲 O(k*nk),指數級別。

var coins = []int64{1,2,5}
var memo [13]int64
func CoinChange(amount int64)int64{
	return Helper(amount)
}
func Helper(n int64) int64 {
	if (n == 0) {return 0}
	if (memo[n] != 0) {return memo[n]}
	memo[n] = n
	for _,i := range coins{
		if (n-int64(i)) < 0{continue}
		subProb := Helper(n-int64(i))
		memo[n] = int64(math.Min(float64(memo[n]),float64(subProb+1)))
	}
	return memo[n]
}

不畫圖了,很顯然「備忘錄」大大減小了子問題數目,完全消除了子問題的冗餘,所以子問題總數不會超過金額數 n,即子問題數目爲 O(n)。處理一個子問題的時間不變,仍是 O(k),所以總的時間複雜度是 O(kn)。

三、動態規劃

var coins = []int64{1,2,5}
var dp [12]int64

func CoinChange(n int64) int64 {
	for i := int64(0); i<n+1; i++{
		dp[i] = i
		for _,coin := range coins{
			if (i-int64(coin)) < 0{continue}
			dp[i] = int64(math.Min(float64(dp[i]),float64(1+dp[i-int64(coin)])))
		}
	}

	return dp[n]
}

補充總結

計算機解決問題其實沒有任何奇技淫巧,它唯一的解決辦法就是窮舉,窮舉所有可能性。算法設計無非就是先思考“如何窮舉”,然後再追求“如何聰明地窮舉”。

列出動態轉移方程,就是在解決“如何窮舉”的問題。之所以說它難,一是因爲很多窮舉需要遞歸實現,二是因爲有的問題本身的解空間複雜,不那麼容易窮舉完整。

備忘錄、DP table 就是在追求“如何聰明地窮舉”。用空間換時間的思路,是降低時間複雜度的不二法門,除此之外,試問,還能玩出啥花活?

序列類動態規劃

通常問題的輸入參數會涉及數組或是字符串

輸入數組:[1,2,3,4,5,6,7,8,9]
子數組:[2,3,4], [5,6,7], [6,7,8,9], ...
子序列:[1,5,9], [2,3,6], [1,8,9], [7,8,9], ...
  • 子數組的問題和我們前面提到的矩陣類動態規劃的分析思路很類似,只需要考慮當前位置,以及當前位置和相鄰位置的關係
  • 對於第 i 個位置的狀態分析,它不僅僅需要考慮當前位置的狀態,還需要考慮前面 i – 1 個位置的狀態,思考的方向其實在於 尋找當前狀態和之前所有狀態的關係

最長上升子序列(LeetCode 第 300 號問題)

題目描述

給定一個無序的整數數組,找到其中最長上升子序列的長度

題目解析

給定一個數組,求最長遞增子序列。因爲是子序列,**這樣對於每個位置的元素其實都存在 兩種可能,就是選和不選,**暴力解法,枚舉所有的子序列,判斷他們是不是遞增的,選取最大的遞增序列,這樣做的話,時間複雜度是 O(2^n),顯然不高效。

  • 問題解析
    數組中最長遞增子序列,雖然不是連續的區間,但是它依然有起點和終點。如果我們確定終點位置,然後去看前面 i-1 個位置中,哪一個位置可以和當前位置拼接在一起。
  • 狀態定義
    如果我們要求解第 i 個問題的解,那麼我們必須考慮前 i-1 個問題的解,我們定義dp[i] 表示以位置i 結尾的子序列的最大長度
  • 遞推方程
    對於 i 這個位置,我們需要考慮前 i-1 個位置,看看哪些位置可以拼在 i 位置之前, 如果有多個位置可以拼在 i 之前, 那麼必須選最長的那個:
dp[i] = Math.max(dp[j],...,dp[k]) + 1, 
其中 inputArray[j] < inputArray[i], inputArray[k] < inputArray[i]
  • 實現
    需要考慮狀態數組的初始化,因爲對於每個位置,它本身其實就是一個序列,因此所有位置的狀態都可以初始化爲 1。

參考代碼

// 動態規劃
// 時間複雜度O(n^2)
// 空間複雜度O(n)
func lengthOfLIS(nums []int) int {
	if nums == nil || len(nums) == 0{return 0}
	var dp = make([]int,len(nums))
	max := 1
	for i:=0;i<len(nums);i++{
		dp[i] = 1
		for j:=0;j<i;j++{
			if nums[j] < nums[i]{
				dp[i] = int(math.Max(float64(dp[i]),float64(dp[j]+1)))
			}
		}
		max = int(math.Max(float64(max),float64(dp[i])))
	}
	return max
}
發佈了72 篇原創文章 · 獲贊 13 · 訪問量 4657
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章