小浩:宜信科技中心攻城獅一枚,熱愛算法,熱愛學習,不拘泥於枯燥編程代碼,更喜歡用輕鬆方式把問題簡單闡述,但願喜歡的小夥伴能夠多多關注!
講解動態規劃的資料不少,官方的定義是指把多階段過程轉化爲一系列單階段問題,利用各階段之間的關係,逐個求解。概念中的各階段之間的關係,其實指的就是狀態轉移方程。不少人以爲DP難(下文統稱動態規劃爲DP),根本緣由是由於DP區別於一些固定形式的算法(好比DFS、二分法、KMP),沒有實際的步驟規定第一步第二步來作什麼,因此準確的說,DP實際上是一種解決問題的思想。算法
這種思想的本質是:一個規模比較大的問題(能夠用兩三個參數表示的問題),能夠經過若干規模較小的問題的結果來獲得的(一般會尋求到一些特殊的計算邏輯,如求最值等)編程
因此咱們通常看到的狀態轉移方程,基本都是這樣:數組
opt :指代特殊的計算邏輯,一般爲max or min。i,j,k 都是在定義DP方程中用到的參數。函數
dp[i] = opt(dp[i-1])+1學習
dpi = w(i,j,k) + opt(dpi-1)優化
dpi = opt(dpi-1 + xi, dpi + yj, ...)spa
每個狀態轉移方程,多少都有一些細微的差異。這個其實很容易理解,世間的關係多了去了,不可能抽象出徹底能夠套用的公式。因此我我的其實不建議去死記硬背各類類型的狀態轉移方程。可是DP的題型真的就徹底沒法掌握,沒法歸類進行分析嗎?我認爲不是的。在本系列中,我將由簡入深爲你們講解動態規劃這個主題。3d
咱們先看上一道最簡單的DP題目,熟悉DP的概念:code
題目:假設你正在爬樓梯。須要 n 階你才能到達樓頂。每次你能夠爬 1 或 2 個臺階。你有多少種不一樣的方法能夠爬到樓頂呢?注意:給定 n 是一個正整數。blog
示例 1:
輸入:2輸出:2解釋:有兩種方法能夠爬到樓頂。
- 1 階 + 1 階
- 2 階
示例 2:
輸入:3輸出:3解釋:有三種方法能夠爬到樓頂。
- 1 階 + 1 階 + 1 階
- 1 階 + 2 階
- 2 階 + 1 階
經過分析咱們能夠明確,該題能夠被分解爲一些包含最優子結構的子問題,即它的最優解能夠從其子問題的最優解來有效地構建。知足「將大問題分解爲若干個規模較小的問題」的條件。因此咱們令 dp[n] 表示能到達第 n 階的方法總數,能夠獲得以下狀態轉移方程:
dp[n]=dp[n-1]+dp[n-2]
根據分析,獲得代碼以下:
func climbStairs(n int) int { if n == 1 { return 1 } dp := make([]int, n+1) dp[1] = 1 dp[2] = 2 for i := 3; i <= n; i++ { dp[i] = dp[i-1] + dp[i-2] } return dp[n] }
題目:給定一個整數數組 nums ,找到一個具備最大和的連續子數組(子數組最少包含一個元素),返回其最大和。
示例 :輸入: [-2,1,-3,4,-1,2,1,-5,4],
輸出: 6
解釋: 連續子數組 [4,-1,2,1] 的和最大,爲 6。
拿到題目請不要看下方題解,先自行思考2-3分鐘....
首先咱們分析題目,一個連續子數組必定要以一個數做爲結尾,那麼咱們能夠將狀態定義成以下:
dp[i]:表示以 nums[i] 結尾的連續子數組的最大和。
那麼爲何這麼定義呢?由於這樣定義實際上是最容易想到的!在上一節中咱們提到,狀態轉移方程實際上是經過1-3個參數的方程來描述小規模問題和大規模問題間的關係。
固然,若是你沒有想到,其實也很是正常!由於 "該問題最先於 1977 年提出,可是直到 1984 年才被發現了線性時間的最優解法。"
根據狀態的定義,咱們繼續進行分析:
若是要獲得dp[i],那麼nums[i]必定會被選取。而且 dp[i] 所表示的連續子序列與 dp[i-1] 所表示的連續子序列極可能就差一個 nums[i] 。即
dp[i] = dp[i-1]+nums[i] , if (dp[i-1] >= 0)
可是這裏咱們遇到一個問題,頗有可能dp[i-1]自己是一個負數。那這種狀況的話,若是dp[i]經過dp[i-1]+nums[i]來推導,那麼結果其實反而變小了,由於咱們dp[i]要求的是最大和。因此在這種狀況下,若是dp[i-1]<0,那麼dp[i]其實就是nums[i]的值。即
dp[i] = nums[i] , if (dp[i-1] < 0)
綜上分析,咱們能夠獲得:
dp[i]=max(nums[i], dp[i−1]+nums[i])
獲得了狀態轉移方程,可是咱們還須要經過一個已有的狀態的進行推導,咱們能夠想到 dp[0] 必定是以 nums[0] 進行結尾,因此
dp[0] = nums[0]
在不少題目中,由於dp[i]自己就定義成了題目中的問題,因此dp[i]最終就是要的答案。可是這裏狀態中的定義,並非題目中要的問題,不能直接返回最後的一個狀態 (這一步常常有初學者會摔跟頭)。因此最終的答案,其實咱們是尋找:
max(dp[0], dp[1], ..., d[i-1], dp[i])
分析完畢,咱們繪製成圖:
假定 nums 爲 [-2,1,-3,4,-1,2,1,-5,4]
根據分析,獲得代碼以下:
func maxSubArray(nums []int) int { if len(nums) < 1 { return 0 } dp := make([]int, len(nums)) //設置初始化值 dp[0] = nums[0] for i := 1; i < len(nums); i++ { //處理 dp[i-1] < 0 的狀況 if dp[i-1] < 0 { dp[i] = nums[i] } else { dp[i] = dp[i-1] + nums[i] } } result := -1 << 31 for _, k := range dp { result = max(result, k) } return result } func max(a, b int) int { if a > b { return a } return b }
咱們能夠進一步精簡代碼爲:
func maxSubArray(nums []int) int { if len(nums) < 1 { return 0 } dp := make([]int, len(nums)) result := nums[0] dp[0] = nums[0] for i := 1; i < len(nums); i++ { dp[i] = max(dp[i-1]+nums[i], nums[i]) result = max(dp[i], result) } return result } func max(a, b int) int { if a > b { return a } return b }
複雜度分析:時間複雜度:O(N)。空間複雜度:O(N)。
題目:給定一個無序的整數數組,找到其中最長上升子序列的長度。
示例:輸入: [10,9,2,5,3,7,101,18]
輸出: 4
解釋: 最長的上升子序列是 [2,3,7,101],它的長度是 4。
說明:可能會有多種最長上升子序列的組合,你只須要輸出對應的長度便可。
本題有必定難度!
若是沒有思路請回顧上一篇的學習內容!
不建議直接看題解!
首先咱們分析題目,要找的是最長上升子序列(Longest Increasing Subsequence,LIS)。由於題目中沒有要求連續,因此LIS多是連續的,也多是非連續的。同時,LIS符合能夠從其子問題的最優解來進行構建的條件。因此咱們能夠嘗試用動態規劃來進行求解。首先咱們定義狀態:
dp[i] :表示以nums[i]結尾的最長上升子序列的長度
咱們假定nums爲[1,9,5,9,3]
咱們分兩種狀況進行討論:
咱們先初步得出上面的結論,可是咱們發現了一些問題。由於dp[i]前面比他小的元素,不必定只有一個!
可能除了nums[j],還包括nums[k],nums[p] 等等等等。因此dp[i]除了可能等於dp[j]+1,還有可能等於dp[k]+1,dp[p]+1 等等等等。因此咱們求dp[i],須要找到dp[j]+1,dp[k]+1,dp[p]+1 等等等等中的最大值。(我在3個等等等等上都進行了加粗,主要是由於初學者很是容易在這裏摔跟斗!這裏強調的目的是但願能記住這道題型!)
即:
dp[i] = max(dp[j]+1,dp[k]+1,dp[p]+1,.....)只要知足:
nums[i] > nums[j]
nums[i] > nums[k]
nums[i] > nums[p]
....
最後,咱們只須要找到dp數組中的最大值,就是咱們要找的答案。
分析完畢,咱們繪製成圖:
根據分析,獲得代碼以下:
func lengthOfLIS(nums []int) int { if len(nums) < 1 { return 0 } dp := make([]int, len(nums)) result := 1 for i := 0; i < len(nums); i++ { dp[i] = 1 for j := 0; j < i; j++ { //這行代碼就是上文中那個 等等等等 if nums[j] < nums[i] { dp[i] = max(dp[j]+1, dp[i]) } } result = max(result, dp[i]) } return result } func max(a, b int) int { if a > b { return a } return b }
前面章節咱們經過題目「最長上升子序列」以及"最大子序和",學習了DP(動態規劃)在線性關係中的分析方法。這種分析方法,也在運籌學中被稱爲「線性動態規劃」,具體指的是 「目標函數爲特定變量的線性函數,約束是這些變量的線性不等式或等式,目的是求目標函數的最大值或最小值」。這點你們做爲了解便可,不須要死記,更不要生搬硬套!
在本節中,咱們將繼續分析一道略微區別於以前的題型,但願能夠由此題與以前的題目進行對比論證,進而順利求解!
題目:給定一個三角形,找出自頂向下的最小路徑和。
示例:每一步只能移動到下一行中相鄰的結點上。
例如,給定三角形:
自頂向下的最小路徑和爲 11(即,2 + 3 + 5 + 1 = 11)。
首先咱們分析題目,要找的是三角形最小路徑和,這是個啥意思呢?假設咱們有一個三角形:[[2], [3,4], [6,5,7], [4,1,8,3]]
那從上到下的最小路徑和就是2-3-5-1,等於11。
因爲咱們是使用數組來定義一個三角形,因此便於咱們分析,咱們將三角形稍微進行改動:
這樣至關於咱們將整個三角形進行了拉伸。這時候,咱們根據題目中給出的條件:每一步只能移動到下一行中相鄰的結點上。其實也就等同於,每一步咱們只能往下移動一格或者右下移動一格。將其轉化成代碼,假如2所在的元素位置爲[0,0],那咱們往下移動就只能移動到[1,0]或者[1,1]的位置上。假如5所在的位置爲[2,1],一樣也只能移動到[3,1]和[3,2]的位置上。以下圖所示:
題目明確了以後,如今咱們開始進行分析。題目很明顯是一個找最優解的問題,而且能夠從子問題的最優解進行構建。因此咱們經過動態規劃進行求解。首先,咱們定義狀態:
dpi : 表示包含第i行j列元素的最小路徑和
咱們很容易想到能夠自頂向下進行分析。而且,不管最後的路徑是哪一條,它必定要通過最頂上的元素,即[0,0]。因此咱們須要對dp0進行初始化。
dp0 = 0位置所在的元素值
繼續分析,若是咱們要求dpi,那麼其必定會從本身頭頂上的兩個元素移動而來。
如5這個位置的最小路徑和,要麼是從2-3-5而來,要麼是從2-4-5而來。而後取兩條路徑和中較小的一個便可。進而咱們獲得狀態轉移方程:
dpi = min(dpi-1,dpi-1) + trianglei
可是,咱們這裏會遇到一個問題!除了最頂上的元素以外,
最左邊的元素只能從本身頭頂而來。(2-3-6-4)
最右邊的元素只能從本身左上角而來。(2-4-7-3)
而後,咱們觀察發現,位於第2行的元素,都是特殊元素(由於都只能從[0,0]的元素走過來)
咱們能夠直接將其特殊處理,獲得:
dp1 = triangle1 + triangle0dp1 = triangle1 + triangle0
最後,咱們只要找到最後一行元素中,路徑和最小的一個,就是咱們的答案。即:
l:dp數組長度result = min(dp[l-1,0],dp[l-1,1],dp[l-1,2]....)
綜上咱們就分析完了,咱們總共進行了4步:
分析完畢,代碼自成:
func minimumTotal(triangle [][]int) int { if len(triangle) < 1 { return 0 } if len(triangle) == 1 { return triangle[0][0] } dp := make([][]int, len(triangle)) for i, arr := range triangle { dp[i] = make([]int, len(arr)) } result := 1<<31 - 1 dp[0][0] = triangle[0][0] dp[1][1] = triangle[1][1] + triangle[0][0] dp[1][0] = triangle[1][0] + triangle[0][0] for i := 2; i < len(triangle); i++ { for j := 0; j < len(triangle[i]); j++ { if j == 0 { dp[i][j] = dp[i-1][j] + triangle[i][j] } else if j == (len(triangle[i]) - 1) { dp[i][j] = dp[i-1][j-1] + triangle[i][j] } else { dp[i][j] = min(dp[i-1][j-1], dp[i-1][j]) + triangle[i][j] } } } for _,k := range dp[len(dp)-1] { result = min(result, k) } return result } func min(a, b int) int { if a > b { return b } return a }
運行上面的代碼,咱們發現使用的內存過大。咱們有沒有什麼辦法能夠壓縮內存呢?經過觀察咱們發現,在咱們自頂向下的過程當中,其實咱們只須要使用到上一層中已經累積計算完畢的數據,而且不會再次訪問以前的元素數據。繪製成圖以下:
優化後的代碼以下:
func minimumTotal(triangle [][]int) int { l := len(triangle) if l < 1 { return 0 } if l == 1 { return triangle[0][0] } result := 1<<31 - 1 triangle[0][0] = triangle[0][0] triangle[1][1] = triangle[1][1] + triangle[0][0] triangle[1][0] = triangle[1][0] + triangle[0][0] for i := 2; i < l; i++ { for j := 0; j < len(triangle[i]); j++ { if j == 0 { triangle[i][j] = triangle[i-1][j] + triangle[i][j] } else if j == (len(triangle[i]) - 1) { triangle[i][j] = triangle[i-1][j-1] + triangle[i][j] } else { triangle[i][j] = min(triangle[i-1][j-1], triangle[i-1][j]) + triangle[i][j] } } } for _,k := range triangle[l-1] { result = min(result, k) } return result } func min(a, b int) int { if a > b { return b } return a }
在上節中,咱們經過分析,順利完成了「三角形最小路徑和」的動態規劃題解。在本節中,咱們繼續看一道類似題型,以求能徹底掌握這種「路徑和」的問題。話很少說,先看題目:
題目:給定一個包含非負整數的 m x n 網格,請找出一條從左上角到右下角的路徑,使得路徑上的數字總和爲最小。說明:每次只能向下或者向右移動一步。
示例:輸入:
[
[1,3,1],
[1,5,1],
[4,2,1]
]
輸出: 7
解釋: 由於路徑 1→3→1→1→1 的總和最小。
首先咱們分析題目,要找的是 最小路徑和,這是個啥意思呢?假設咱們有一個 m*n 的矩形 :[[1,3,1],[1,5,1],[4,2,1]]
那從左上角到右下角的最小路徑和,咱們能夠很容易看出就是1-3-1-1-1,這一條路徑,結果等於7。
題目明確了,咱們繼續進行分析。該題與上一道求三角形最小路徑和同樣,題目明顯符合能夠從子問題的最優解進行構建,因此咱們考慮使用動態規劃進行求解。首先,咱們定義狀態:
dpi : 表示包含第i行j列元素的最小路徑和
一樣,由於任何一條到達右下角的路徑,都會通過[0,0]這個元素。因此咱們須要對dp0進行初始化。
dp0 = 0位置所在的元素值
繼續分析,根據題目給的條件,若是咱們要求dpi,那麼它必定是從本身的上方或者左邊移動而來。以下圖所示:
進而咱們獲得狀態轉移方程:
dpi = min(dpi-1,dpi) + gridi
一樣咱們須要考慮兩種特殊狀況:
最後,由於咱們的目標是從左上角走到右下角,整個網格的最小路徑和其實就是包含右下角元素的最小路徑和。即:
設:dp的長度爲l最終結果就是:dpl-1)-1]
綜上咱們就分析完了,咱們總共進行了4步:
分析完畢,代碼自成:
func minPathSum(grid [][]int) int { l := len(grid) if l < 1 { return 0 } dp := make([][]int, l) for i, arr := range grid { dp[i] = make([]int, len(arr)) } dp[0][0] = grid[0][0] for i := 0; i < l; i++ { for j := 0; j < len(grid[i]); j++ { if i == 0 && j != 0 { dp[i][j] = dp[i][j-1] + grid[i][j] } else if j == 0 && i != 0 { dp[i][j] = dp[i-1][j] + grid[i][j] } else if i != 0 && j != 0 { dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j] } } } return dp[l-1][len(dp[l-1])-1] } func min(a, b int) int { if a > b { return b } return a }
一樣,運行上面的代碼,咱們發現使用的內存過大。有沒有什麼辦法能夠壓縮內存呢?經過觀察咱們發現,在咱們自左上角到右下角計算各個節點的最小路徑和的過程當中,咱們只須要使用到以前已經累積計算完畢的數據,而且不會再次訪問以前的元素數據。繪製成圖以下:(你們看這個過程像不像掃雷,其實若是你們研究掃雷外掛的話,就會發如今掃雷的核心算法中,就有一處頗爲相似這種分析方法,這裏就不深究了)
優化後的代碼以下:
func minPathSum(grid [][]int) int { l := len(grid) if l < 1 { return 0 } for i := 0; i < l; i++ { for j := 0; j < len(grid[i]); j++ { if i == 0 && j != 0 { grid[i][j] = grid[i][j-1] + grid[i][j] } else if j == 0 && i != 0 { grid[i][j] = grid[i-1][j] + grid[i][j] } else if i != 0 && j != 0 { grid[i][j] = min(grid[i-1][j], grid[i][j-1]) + grid[i][j] } } } return grid[l-1][len(grid[l-1])-1] } func min(a, b int) int { if a > b { return b } return a }
本系列全部教程中都不會用到複雜的語言特性,你們不須要擔憂沒有學過go。算法思想最重要,使用go純屬做者愛好。原文首發於公衆號-浩仔講算法