九章算法系列(#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

 


总结

动态规划是没有打过竞赛的小伙伴们都怕的一个章节,这个章节我总结的很少,是由于有些题目尚未理解的足够深,因此怕误导你们,就不敢放上来了。可是面试仍是须要好好准备一下,记住以前所说的几种可能用到动态规划和不可能用到动态规划的状况便可,我的感受面试过程可以写出多项式级别的复杂度已经算还能够了,若是以后可以进一步到滚动数组或者压缩到一维数组之类的,那就更可以加分了。

相关文章
相关标签/搜索