九章算法系列(#4 Dynamic Programming)-課堂筆記

前言面試

時隔這麼久才發了這篇早在三週前就應該發出來的課堂筆記,因爲懶癌犯了,加上各類緣由,實在是應該反思。好多課堂上老師說的重要的東西可能細節上有一些急記不住了,可是幸虧作了一些筆記,還可以讓本身回想起來。動態規劃算是個人一道大坎了,本科的時候就基本沒有學過,研一的時候老師上課也是吃力的跟上了老師的步伐,其實那個時候老師總結的仍是挺好的:把動態規劃的題目都分紅了一維動規、二維遍歷、二維不遍歷等一系列的問題。此次聽了老師的課程,以爲仍是須要更加集中的去把各類題進行一個分類吧,而後有針對的去準備,雖然聽說這一塊在面試中也不容易考到,可是畢竟是難點,仍是須要好好準備一下的。由於在dp這個方面,我算是一個比較新手的新手,因此你們能夠看成一塊兒入門內容來看這篇博客。算法

 

Outline:數組

  • 瞭解動態規劃
    • Triangle
  • 動態規劃的適用範圍
  • 座標型動態規劃
    • Minimum Path Sum
    • Climbing Stairs
    • Jump Game
    • Longest Increasing Subsequence
  • 單序列動態規劃ide

    • Word Break
  • 雙序列動態規劃
    • Longest Common Subsequence
  • 總結

 

課堂筆記優化

 


1.瞭解動態規劃spa

就不過多的作解釋了,直接來一個經典的題目。code

給定一個數字三角形,找到從頂部到底部的最小路徑和。每一步能夠移動到下面一行的相鄰數字上。blog

樣例遞歸

好比,給出下列數字三角形:ip

[

     [2],

    [3,4],

   [6,5,7],

  [4,1,8,3]

]

從頂到底部的最小路徑和爲11 ( 2 + 3 + 5 + 1 = 11)。

拿到這個題目,若是不知道動態規劃的話,想必你們第一反應就是遍歷所有的路徑,而後求出最小的值就能夠。這個想法的話,跟二叉樹的遍歷有一點相似,可是大致仍是不同的,由於二叉樹在分岔之後就各自保留子樹,而這個題的不能考慮爲二叉樹的狀況,這個結構能夠畫成以下的狀況比較直觀:

 [2],

 [3,4],

 [6,5,7],

 [4,1,8,3]

其中,2只能移動到三、4,3只能移動到六、5,同理,5只能移動到1,8……因此總結下來就是:當前的元素只能移動到下方和右下方的元素,即(i,j)只能移動到(i+1,j)或(i+1,j+1)。這樣的話,DFS來作搜索就行了。

    int bestans = INT_MAX;
    void travers(int i, int j, int sum, vector<vector<int> > &triangle) {
        if (i == triangle.size()) {
            // 遍歷到最底層
            bestans = bestans > sum ? sum : bestans;
            return;
        }
        travers(i + 1, j, sum + triangle[i][j], triangle);
        travers(i + 1, j + 1, sum + triangle[i][j], triangle);
    }
    int minimumTotal(vector<vector<int> > &triangle) {
        // write your code here
        travers(0, 0, 0, triangle);
        return bestans;
    }
View Code

這種算是最暴力的方法了,顯然時間複雜度是0(2^n)的,由於每層的每一個元素都有兩個選擇。我就沒有在lintcode上提升了,顯然是LTE的。這時候就須要回顧咱們以前學過的分治法了,也能夠用分治的方法分別求出下方和右下方兩種選擇的和,而後來求出最小的。直接把代碼貼出來吧(Bug Free):

    int DivideConquer(int i, int j, vector<vector<int> > &triangle) {
        if (i == triangle.size()) {
            return 0;
        }

        return triangle[i][j] + min(
            DivideConquer(i + 1, j, triangle),
            DivideConquer(i + 1, j+ 1, triangle));
    }
    int minimumTotal(vector<vector<int> > &triangle) {
        // write your code here
        return DivideConquer(0, 0, triangle);
    }
View Code

這個方法比起直接作travers來的更加容易思考一些,回顧了一下上節課講的東西,可是複雜度仍是同樣的。到這裏你們應該可以想到了,由於和都是由上面的節點累加起來的,咱們能夠只遍歷一次,把前面獲得的結果記錄下來,這樣就不須要從頭去作遍歷了。因此能夠對分治法進行改進,代碼以下(Bug Free):

    int minimumTotal(vector<vector<int> > &triangle) {
        // write your code here
        int n = triangle.size();
        int m = triangle[n-1].size();
        vector<vector<int> > dp(n, vector<int>(m));
        
        // 初始化原點
        dp[0][0] = triangle[0][0];
        
        // 初始化三角形的邊緣
        for (int i = 1; i < n; ++i) {
            dp[i][0] = dp[i - 1][0] + triangle[i][0];
            dp[i][i] = dp[i - 1][i - 1] + triangle[i][i];
        }
        
        for (int i = 1; i < n; ++i) {
            for (int j = 1; j < i; ++j) {
                dp[i][j] = min(dp[i - 1][j], dp[i - 1][j - 1]) + triangle[i][j];
            }
        }
        
        return *min_element(dp[n - 1].begin(),dp[n - 1].end());
    }
View Code

這個應該算最基本的動態規劃了,其中用到的一個想法就是:打小抄。用一個dp二維數組來存儲以前的路徑的和,可以很大程度減少搜索的次數。這裏又須要談一下以前說過的二叉樹的問題了,若是這個問題是一個二叉樹的話,就不須要用動態規劃的方法來作了,由於二叉樹沒有重複計算的部分,左子樹不會有到右子樹的部分,這樣就沒有打小抄的必要了。這裏也就引出了動態規劃和分治法的根本區別:動態規劃存在重複計算的部分,而分治法是沒有的,也就是說,由全局的問題分紅子問題的時候,分治法的子問題是徹底獨立的,相互之間沒有交集,而動態規劃的方法是有交叉部分的。

 


2.動態規劃的適用範圍

這個內容我我的認爲對於面試是很是重要的,由於以前有面試官給我出過一個求出全部可行解的問題,當時我就是用dp來考慮,顯然最後就用一個三維動態規劃來解決了,這種就給了本身很大的麻煩。因此動態規劃在必定程度上很容易和DFS這樣的場景混淆。

知足下面三個條件之一:

  • 求最大值最小值
  • 判斷是否可行
  • 統計方案個數

則極有多是使用動態規劃的方法來求解的。以前求全部解的話,確定是要去遍歷而後求出知足狀況的解的方法,而不是動態規劃這樣的模式。

如下狀況是不使用動態規劃的狀況:

  • 求出全部具體的方案
  • 輸入數據是一個集合而不是序列
  • 暴力算法的複雜度已是多項式級別
    • 動態規劃擅長於優化指數級別的複雜度到多項式級別

動態規劃就是四個重要的要素:

  • 狀態
  • 方程
  • 初始化
  • 答案

 


3. 座標型動態規劃

這種類型的題目在面試中出現的機率大概是15%,好比第1部分的那個題目就是一個座標型動態規劃的題。它的四要素以下:

  • state:f[x]表示從起點走到座標x
  • function:研究走到x,y這個點以前的一步
  • initiaize:起點
  • answer:終點

這樣的題目主要就是在座標上來進行一個處理。

先上一個極度簡單的題目:

Minimum Path Sum

(http://www.lintcode.com/zh-cn/problem/minimum-path-sum/)

給定一個只含非負整數的m*n網格,找到一條從左上角到右下角的可使數字和最小的路徑。

這裏就不須要多說了,跟咱們上面那個題目其實就是同樣的道理,這裏不過是從上方或者左方兩個方向到達該點,直接用這個方法來計算就行了。直接上代碼(Bug Free):

    int minPathSum(vector<vector<int> > &grid) {
        // write your code here
        int m = grid.size();
        int n = grid[0].size();
        vector<vector<int> > dp(m + 1, vector<int>(n + 1));
        
        // initialize
        dp[0][0] = grid[0][0];
        
        for (int i = 1; i < m; ++i) {
            dp[i][0] = dp[i - 1][0] + grid[i][0];
        }
        for (int j = 1; j < n; ++j) {
            dp[0][j] = dp[0][j - 1] + grid[0][j]; 
        }
        
        // state and function
        for (int i = 1; i < m; ++i) {
            for (int j = 1; j < n; ++j) {
                dp[i][j] = grid[i][j] + min(dp[i - 1][j], dp[i][j - 1]);
            }
        }
        
        // answer
        return dp[m - 1][n - 1];
    }
View Code

不得不提一句,其實這裏可使用滾動數組,不斷更新dp的值,就不須要開闢m*n那麼大的空間,具體的滾動數組的方法我會在以後的進階篇裏面寫到。

而後就是一個比較簡單的題目,Climbing Stairs,題目以下:

Climbing Stairs

(http://www.lintcode.com/zh-cn/problem/climbing-stairs/)

假設你正在爬樓梯,須要n步你才能到達頂部。但每次你只能爬一步或者兩步,你能有多少種不一樣的方法爬到樓頂部?

樣例

好比n=3,1+1+1=1+2=2+1=3,共有3中不一樣的方法

返回 3

這個題目對我本人來講仍是有淵源的,我記得第一次面試的時候問的算法題就是這個題,當時我是真的算法渣,徹底沒有考慮到該怎麼作,就連斐波那契爾數列都沒有想到,因此就用暴力求解的方法作出來了,如今回想一下,當年大三的時候真是太low了。

其實這個題就是一個斐波那契爾數列,由於一次能夠走兩步或者一步,也就是說第i步的前一步多是i-2,也多是i-1,因此就跟上一題走方格是同樣的問題,而後把前面兩種狀況加起來就能夠,這個題也能夠用遞歸來作,複雜度是n^2,用動態規劃的狀況複雜度是n。代碼以下(Bug Free):

    int climbStairs(int n) {
        // write your code here
        vector<int> dp(n + 1);
        dp[0] = 1;
        dp[1] = 1;
        dp[2] = 2;
        
        for(int i = 3; i <= n; ++i) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        
        return dp[n];
    }
View Code

接下來再來一題:

Jump Game

(http://www.lintcode.com/zh-cn/problem/jump-game/)

給出一個非負整數數組,你最初定位在數組的第一個位置。

數組中的每一個元素表明你在那個位置能夠跳躍的最大長度。   

你的目標是使用最少的跳躍次數到達數組的最後一個位置。

樣例

給出數組A = [2,3,1,1,4],最少到達數組最後一個位置的跳躍次數是2(從數組下標0跳一步到數組下標1,而後跳3步到數組的最後一個位置,一共跳躍2次)

這個題是動態規劃裏面的典型題目,不過仍是須要用到一些小trick。直接上代碼吧:

    int jump(vector<int> A) {
        // wirte your code here
        int n = A.size();
        vector<int> dp(n + 1);
        
        dp[0] = 0;
        for (int i = 1; i < n; ++i) {
            dp[i] = INT_MAX;
            for (int j = 0; j < i; ++j) {
                if (dp[j] != INT_MAX && A[j] + j >= i) {
                    dp[i] = dp[j] + 1;
                    break;
                }
            }
        }
        return dp[n - 1];
    }
View Code

方法很簡單,就是用一個dp數組存儲當前第i步須要多少步可以到達,有一個關鍵的地方就是:每次在判斷當前位置i的時候,須要賦值爲最大值,這裏就能夠用這個INT_MAX來做爲判斷第j個點是否可以到達,若是能夠的話,就把i從j的位置+1,用這種方法來求出當前i的點須要的步數,而後直接break就能夠了。

說到座標型動態規劃的表明題,那必定就是(LIS)這個題目了。雖說這個是求最長遞增自序列,看上去像是一個序列的問題,可是它更多的是去解決一個座標跳轉的問題。

Longest Increasing Subsequence

(http://www.lintcode.com/problem/longest-increasing-subsequence/)

給定一個整數序列,找到最長上升子序列(LIS),返回LIS的長度。

說明

最長上升子序列的定義:

最長上升子序列問題是在一個無序的給定序列中找到一個儘量長的由低到高排列的子序列,這種子序列不必定是連續的或者惟一的。

https://en.wikipedia.org/wiki/Longest_increasing_subsequence

樣例

給出 [5,4,1,2,3],LIS 是 [1,2,3],返回 3

給出 [4,2,4,5,3,7],LIS 是 [2,4,5,7],返回 4

這個題目我認爲是須要你們背下來的,可以在2分鐘以內不暇思索就要寫出來的題目,其實就是考慮第i個元素,是否加上前面的某個元素j,平且判斷當前的個數是否大於加上j之後的個數。而後在全部的dp數組裏面找到最大的那個值就是最長子序列的長度。直接上代碼吧(Bug Free):

    int longestIncreasingSubsequence(vector<int> nums) {
        // write your code here
        int n = nums.size();
        if (n == 0) {
            return 0;
        }
        vector<int> dp(n + 1, 1);
        
        for (int i = 1; i < n; ++i) {
            for (int j = 0; j < i; ++j) {
                if (nums[j] < nums[i]) {
                    dp[i] = max(dp[i], dp[j] + 1);
                }
            }
        }
        return *max_element(dp.begin(), dp.end());
    }
View Code

 


4. 單序列動態規劃

這種類型的動態規劃通常在面試中出現的機率是30%,它的四要素表示以下:

  • state:f[i]表示前i個位置/數字/字符,第i個...
  • function: f[i]=f[j]...j是i以前的一個位置
  • initialize: f[0]
  • answer: f[n]..
  • 通常answer是f(n)而不是f(n-1)
    • 由於對於n個字符,包含前0個字符(空串),前1個字符......前n個字符。

其中有一個小技巧:

通常有N個數字/字符,就開N+1個位置的數組,第0個位置單獨留出來做初始化.(跟座標相關的動態規劃除外)

那就直接來作一個題目吧,引出這個章節:

Word Break

(http://www.lintcode.com/problem/word-break/)

給出一個字符串s和一個詞典,判斷字符串s是否能夠被空格切分紅一個或多個出如今字典中的單詞。

樣例

給出

s = "lintcode"

dict = ["lint","code"]

返回 true 由於"lintcode"能夠被空格切分紅"lint code"

這個就是一個典型的序列的問題,用i表示當前位置,j表示字符串的長度,在這以前能夠先遍歷整個dict,求出其中最長的字符串MaxLength,而後保證j小於這個數便可。代碼以下:

    int getMaxLength(unordered_set<string> &dict) {
        int maxLength = 0; // 試試看中文 
        for (unordered_set<string>::iterator it = dict.begin(); it != dict.end(); ++it) { 
            maxLength = maxLength > (*it).length() ? maxLength : (*it).length();
        }
        return maxLength;
    }
    
    bool wordBreak(string s, unordered_set<string> &dict) {
        // write your code here
        int n = s.length();
        vector<bool> dp(n + 1, false);
        
        dp[0] = true;
        
        int MaxLength = getMaxLength(dict);
        
        for (int i = 1; i <= n; ++i) {
            for (int j = 1; j <= MaxLength && j <= i; ++j) {
                string tmp = s.substr(i - j, j);
                if (dp[i - j] && dict.find(tmp) != dict.end()) {
                    dp[i] = true;
                    break;
                }
            }
        }
        return dp[n];
    }
View Code

這個題若是不用MaxLength來控制j的範圍的話,會超時。

 


5. 雙序列動態規劃

 這種題目我我的的理解就是字符串的對應關係,分別用i和j去表示兩個字符串,而後經過操做來計算相應的問題。

Longest Common Subsequence

(http://www.lintcode.com/zh-cn/problem/longest-common-subsequence/)

給出兩個字符串,找到最長公共子序列(LCS),返回LCS的長度。

說明

最長公共子序列的定義:

  • 最長公共子序列問題是在一組序列(一般2個)中找到最長公共子序列(注意:不一樣於子串,LCS不須要是連續的子串)。該問題是典型的計算機科學問題,是文件差別比較程序的基礎,在生物信息學中也有所應用。
  • https://en.wikipedia.org/wiki/Longest_common_subsequence_problem

樣例

給出"ABCD""EDCA",這個LCS是 "A" (或 D或C),返回1

給出 "ABCD""EACB",這個LCS是"AC"返回 2

這個題目其實考察的地方就在於狀態轉移方程。若是字符串A的第i個位置與字符串B的第j個位置相等,那麼當前狀態自動從(i-1,j-1)狀態+1便可;若是不相等,那麼從(i-1,j)或者(i,j-1)中取得最大值來做爲當前的狀態的最大值。代碼以下(Bug Free):

    int longestCommonSubsequence(string A, string B) {
        // write your code here
        int n = A.size();
        int m = B.size();
        vector<vector<int> > dp(n + 1, vector<int>(m + 1));
        for (int i = 1; i <= n; ++i) {
            for (int j = 1; j <= m; ++j) {
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
                if (A[i - 1] == B[j - 1]) {
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                }
            }
        }
        return dp[n][m];
    }
View Code

 


總結

動態規劃是沒有打過競賽的小夥伴們都怕的一個章節,這個章節我總結的很少,是由於有些題目尚未理解的足夠深,因此怕誤導你們,就不敢放上來了。可是面試仍是須要好好準備一下,記住以前所說的幾種可能用到動態規劃和不可能用到動態規劃的狀況便可,我的感受面試過程可以寫出多項式級別的複雜度已經算還能夠了,若是以後可以進一步到滾動數組或者壓縮到一維數組之類的,那就更可以加分了。

相關文章
相關標籤/搜索