dp入門——由分杆問題認識動態規劃

簡介

若是你常刷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 with memoization

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 }

bottom-up with tabulation

相比於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

  • dynamic programming (動態規劃)是一類經常使用來解決優化問題的算法。它的最大特色是使用子問題的信息幫助解決父問題,使解題的難度減少。好比,求第1,000,002個斐波那契數這個問題看起來很複雜,但若是已知了第1,000,000和第1,000,001斐波那契數,事情就簡單多了。
  • 動態規劃問題有一個顯著的特色,就是子問題之間存在相互重疊。動態規劃經過記錄子問題的結果,保證每一個子問題只計算一次,減小了時間浪費。動態規劃的時間複雜度一般是多項式複雜度(即O(nk),k爲非負常數),而不記錄結果的算法因爲重複計算,複雜度一般遠高於動態規劃,達到指數複雜度。
  • dynamic programming這個英文名詞有些讓人難懂。實際上,這裏的programming不是指編程,而是數學上的一種解決優化問題的方法,叫作列表法(tabular method),大體過程是將函數的不一樣變量值在表中列出並對錶進行各類操做來求得結果。若是列表法是靜態(static)的,則動態規劃算法中,表格是慢慢增加的,先解決相對簡單的子問題,而後經過子問題的結合求取父問題,這樣表格好像是「動態」的。這就是dynamic programming的意思。

Memoization vs. Tabulation 簡介

  • 動態規劃一般有兩種解法:top-downbottom-up
  • top-down一般以遞歸形式出現,從父問題開始,遞歸地求解子問題。top-down的求解過程一般與memoization結合,即將計算過的結果緩存在數組或者哈希表等結構中。當進入遞歸求解問題時,先查看緩存中是否已有結果,若是有則直接返回緩存的結果。
  • bottom-up一般以循環形式出現。bottom-up的求解過程一般與tabulation結合,即先解最小的子問題,解決後將結果記錄在表格中(一般是一維或二維數組),解決父問題時直接查表拿到子問題的結果,而後將父問題的結果也填在表中,直到把表格填滿,最後填入的就是起始問題的結果。

參考資料

dynamic programming -- wikipedia

算法導論(英文版)3rd Ed. 15.1

What is dynamic programming? -- Stackoverflow

Tabular Method of Minimisation

數位邏輯之化簡(列表法)

Dynamic programming and memoization: bottom-up vs top-down approaches

 

dp系列下一篇:dp方法論——由矩陣相乘問題學習dp解題思路

相關文章
相關標籤/搜索