若是你常刷leetcode,會發現許多問題帶有Dynamic Programming的標籤。事實上帶有dp標籤的題目有115道,大部分爲中等和難題,佔全部題目的12.8%(2018年9月),是佔比例第二大的問題。html
若是能系統地對dp這個topic進行學習,相信會極大地提升解題速度,對從此解決實際問題也有思路上的幫助。算法
本文以分杆問題爲切入點,介紹動態規劃的算法動機、核心思想和經常使用的兩種實現方法。編程
The rod-cutting problem(分杆問題)是動態規劃問題的一個典例。數組
給出一根長度爲n(n爲整數)的杆,能夠將杆切割爲任意份長短不一樣的杆(其中包含徹底不進行切割的狀況),每一份的長度都爲整數。給出一個整數數組p[],p[i]表示長度爲i的杆的價格,問如何對杆進行切割可使利潤最大。緩存
p[]數組的一個示例以下:app
在長度爲n的杆上進行整數切割,共有2n-1種狀況,由於有n-1個點能夠選擇是否切割。函數
將這些能夠切割的點編號爲1,2,3, ..., n-1,若是先試着在1處切割,則杆變成了長度爲1和n-1的兩段;若是試着在2處切割,則杆變爲了長度爲2和n-2的兩段,以此類推,共有n種切法(包含徹底不做切割)。這樣,咱們邁出了遞歸的第一步,即把長爲n的杆的最優切割分紅兩個子問題:長爲i的杆的最優切割和長爲n-i的杆的最優切割(i = 1,2,...,n)。最終的利潤爲兩個子杆的利潤和。post
若是用fn表示長度爲n的杆切割後能獲得的最大利潤,通過以上分析,咱們求取兩個子杆的利潤和的最大值便可。即學習
fn = max(pn, f1 + fn-1, f2 + fn-2, ..., fn-1 + f1).優化
這種思路是正確的,但不是太好,有心人能夠注意到子問題之間有較大的重疊之處,好比計算fn-1時會須要查看f1 + fn-2,即f1 + fn-1這個子問題須要查看f1 + f1 + fn-2這個切法;而計算f2時又須要查看f1 + f1,即f2 + fn-2這個子問題也會查看到f1 + f1 + fn-2這個切法,至關於把一些可能性重複查看了多遍。
一個更簡潔合理的思路是:設定左邊這個長爲i的杆不可再切割,只有右邊長爲n-i的杆能夠再切割。則問題變爲
fn = max(pi + fn-i), i = 1,2,...,n
按照上面的分析,能夠初步作一個遞歸實現以下:
1 int cutRod(int n, int[] p){ 2 if(n == 0) 3 return 0; 4 int max = Integer.MIN_VALUE; 5 for(int i = 1; i <= n; i++) 6 max = Math.max(max, p[i] + cutRod(n - i, p)); 7 return max; 8 }
在節點n,算法的時間複雜度爲
Tn = 1 + ∑ Ti (i = 0,1, ..., n-1)
(其中的1是在節點處作加法和max運算的常數複雜度)
這個式子很好推算,只要將Ti的值以此從後往前代入便可:
Tn = 1+T0+T1+ ... +Tn-1 = 1+T0+T1+ ... +Tn-2+(1+T0+T1+ ... +Tn-2)
= 2 (1+T0+T1+ ... +Tn-2) = 2 (1+T0+T1+ ... +Tn-3+(1+T0+T1+ ... +Tn-3))
= 22 (1+T0+T1+ ... +Tn-3) = ... (總結規律) = 2n-1 (1 + T0)
= 2n
即傳統遞歸算法的時間複雜度爲O(2n),爲指數級別。
上一節中說到切割的可能性共有2n-1種,也就是說遞歸算法會將每種可能性都遍歷到。是否還有優化的可能性呢?
以n = 4爲例,畫出遞歸樹結構(節點包含的數字爲n的值)
本圖摘自算法導論(英文版)3rd Ed. 15.1 P346
能夠看到子樹之間存在重疊狀況。最明顯的是n = 2的子問題和n = 3的子問題調用的子樹徹底相同,進行了兩遍一樣的計算。而這個子樹中又包含n = 1的子樹,也就是說浪費的幅度是相乘的。
一個優化思路是將每一個子問題的計算結果記錄下來,下一次再遇到一樣的問題時直接使用記錄值,這就是動態規劃的核心思想。
如上節所述的,動態規劃是一種「以空間換時間」的思想,適用於子問題之間存在重疊狀況的優化問題。它的基本思想是將計算過的子問題的答案記錄下來,從而達到每一個子問題只計算一次的目的。
動態規劃的實現方法分爲top-down和bottom-up兩種,能夠理解爲前者從遞歸樹的根節點向下遞歸調用,然後者從樹的葉結點開始不停地向上循環。
top-down方法比較容易理解,就是在傳統遞歸的基礎上加入memoization(注意與memorization的區別。memoization來自memo,有備忘的意思),即用數組或表等結構緩存計算結果。在每次遞歸運算時,先判斷想要的結果是否在緩存中,若是沒有才進行運算並存入緩存。
1 int cutRod(int n, int[] p){ 2 int[] memo = new int[n + 1]; 3 for(int i = 0; i < memo.length; i++) 4 memo[i] = Integer.MIN_VALUE; //initialization 5 return cutRod(n, p, memo); 6 } 7 8 int cutRod(int n, int[] p, int[] memo){ 9 if(memo[n] != Integer.MIN_VALUE) 10 return memo[n]; //return value directly if memoized 11 if(n == 0) 12 return 0; 13 int max = Integer.MIN_VALUE; 14 for(int i = 1; i <= n; i++) 15 max = Math.max(max, p[i] + cutRod(n - i, p, memo)); 16 memo[n] = max; //memoize it 17 return max; 18 }
相比於top-down,bottom-up的特色是使用循環而非遞歸,先解決子問題,再利用子問題的答案解決父問題。tabulation也很好理解,即用一個表格存放子問題的答案,而後查表得到父問題須要的全部信息去解決父問題,解決後也填在表中,直至把表填滿。
事實上,dynamic programming這個使人費解的名字即來源於此。programming在數學界有「列表法」(tabular method)的意思,指的是爲了求某函數的最大/最小值,將函數的全部變量的全部可能值列在表中並對錶進行某些操做來得到結果。在這裏,表格是「靜態」的,每一個格子中的信息是獨立的;而在動態規劃中,表格是「動態」的,一些格子中的信息依賴於另外一些格子中的計算答案。因此,dynamic programming也能夠理解爲「動態列表法」,也即此處的tabulation。
top-down的實現以下:
1 int cutRod(int n, int[] p){ 2 int[] table = new int[n + 1]; 3 for(int j = 1; j <= n; j++){ //fill table from j = 1 to n 4 int max = Integer.MIN_VALUE; 5 for(int i = 1; i <= j; i++) 6 max = Math.max(max, p[i] + table[j - i]); //calculate f(j) 7 table[j] = max; 8 } 9 return table[n]; 10 }
在bottom-up解法中,咱們從1至n填入表格,在填入table[j]時,須要查詢table[j-1]到table[0]的全部元素,即要作j次查詢。則填滿表格共要作1+2+3+...+n = O(n2)次查詢。則bottom-up解法的時間複雜度爲O(n2)。
在top-down解法中,能夠這樣分析複雜度:首先因爲緩存機制,每一個子問題只會被計算一次;爲了解決大小爲n的問題,咱們須要計算大小爲0,1,2,...,n-1的問題(第15行);計算大小爲n的問題又須要n次計算(第14行),所以top-down解法的複雜度也爲O(n2)。
實際上,動態規劃將前文圖中的遞歸樹作了簡化,將互相重疊的子樹合併,獲得了一個子問題樹。子問題樹中的邊和節點都減小了,意味着時間複雜度獲得了優化。
->
看完例子,咱們來總結一下動態規劃算法的相關概念。
dynamic programming -- wikipedia
What is dynamic programming? -- Stackoverflow
Tabular Method of Minimisation
Dynamic programming and memoization: bottom-up vs top-down approaches
dp系列下一篇:dp方法論——由矩陣相乘問題學習dp解題思路