九章算法系列(#3 Dynamic Programming)-课堂笔记

前言

时隔这么久才发了这篇早在三周前就应该发出来的课堂笔记,由于懒癌犯了,加上各种原因,实在是应该反思。好多课堂上老师说的重要的东西可能细节上有一些急记不住了,但是幸好做了一些笔记,还能够让自己回想起来。动态规划算是我的一道大坎了,本科的时候就基本没有学过,研一的时候老师上课也是吃力的跟上了老师的步伐,其实那个时候老师总结的还是挺好的:把动态规划的题目都分成了一维动规、二维遍历、二维不遍历等一系列的问题。这次听了老师的课程,觉得还是需要更加集中的去把各种题进行一个分类吧,然后有针对的去准备,虽然据说这一块在面试中也不容易考到,但是毕竟是难点,还是需要好好准备一下的。因为在dp这个方面,我算是一个比较新手的新手,所以大家可以当作一起入门内容来看这篇博客。

Outline:

  • 了解动态规划

    • Triangle
  • 动态规划的适用范围
  • 坐标型动态规划
    • Minimum Path Sum
    • Climbing Stairs
    • Jump Game
    • Longest Increasing Subsequence
  • 单序列动态规划
    • Word Break
  • 双序列动态规划
    • Longest Common Subsequence
  • 总结

课堂笔记



1.了解动态规划

就不过多的做解释了,直接来一个经典的题目。

给定一个数字三角形,找到从顶部到底部的最小路径和。每一步可以移动到下面一行的相邻数字上。

样例

比如,给出下列数字三角形:

[

[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只能移动到3、4,3只能移动到6、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;
    }

这种算是最暴力的方法了,显然时间复杂度是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);
    }

这个方法比起直接做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());
    }

这个应该算最基本的动态规划了,其中用到的一个想法就是:打小抄。用一个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];
    }

不得不提一句,其实这里可以使用滚动数组,不断更新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];
    }

接下来再来一题:

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];
    }

方法很简单,就是用一个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());
    }



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];
    }

这个题如果不用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];
    }



总结

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

时间: 2024-10-08 23:15:58

九章算法系列(#3 Dynamic Programming)-课堂笔记的相关文章

九章算法系列(#5 Linked List)-课堂笔记

前言 又是很长时间才回来发一篇博客,前一个月确实因为杂七杂八的事情影响了很多,现在还是到了大火燃眉毛的时候了,也应该开始继续整理一下算法的思路了.Linked List大家应该是特别熟悉不过的了,因为这个算是数据结构了里面基本上最开始讲的结构吧.这块内容也没有太多需要琢磨的技巧,可以考量的东西也不多,所以考的就是一些小的trick来完成,面试中链表考得特别多,算是面试官对面试者的基础的考查,所以我建议大家在Linked List这一章,一定要实现Bug Free.这个也是我练的比较多的,有些想法

九章算法系列(#2 Binary Search)-课堂笔记

前言 先说一些题外的东西吧.受到春跃大神的影响和启发,推荐了这个算法公开课给我,晚上睡觉前点开一看发现课还有两天要开始,本着要好好系统地学习一下算法,于是就爬起来拉上两个小伙伴组团报名了.今天听了第一节课,说真的很实用,特别是对于我这种算法不扎实,并且又想找工作,提高自己的情况. 那就不多说废话了,以后每周都写个总结吧,就趁着这一个月好好把算法提高一下.具体就从:课堂笔记.leetcode和lintcode相关习题.hdu和poj相关习题三个方面来写吧.希望自己能够坚持下来,给大家分享一些好的东

九章算法 基础算法 强化算法 系统设计 大数据 安卓 leetcode 高清视频

leetcode 直播视频讲座录像 九章算法视频录像,PPT 算法班,算法强化班,Java入门与基础算法班,big data项目实战班,Andriod项目实战班 九章算法下载 九章算法面试 九章算法leetcode 九章算法答案 九章算法mitbbs 九章算法班 九章算法ppt 九章算法录像 九章算法培训 九章算法微博 leetcode 视频 九章算法偷录 算法培训 算法班课程大纲: 1 从strStr谈面试技巧与Coding Style(免费试听) 2 二分搜索与旋转排序数组 Binary S

九章算法 课程 视频 录制 免费下载 cs3k.com

cs3k.com [ 九章算法强化班 ] 课程 完整 视频 录制 免费下载 最新一期 << 九章算法强化班 >> 课程完整视频录制, 课件打包下载, 百度云盘 地址 ! 本站长期提供九章算法免费课程视频 与 课件打包下载 Big Table 教学TodoList 探讨System Design 分析Rate Limiter 视频Binary Tree 研究Dribbble 设计Big Data 研究PageRank 面试Google Suggestion 设计Location Ba

CS3K.com 九章算法 课程 视频 录制 免费下载

[ 九章算法班 ] 课程 完整 视频 录制 免费下载最新一期 << 九章算法班 >> 课程完整视频录制, 课件打包下载, 百度云盘 地址 ! 本站长期提供九章算法免费课程视频 与 课件打包下载 Dribbble 视频System Design 探讨Distributed File System 研究MiniLinkedin 探讨Map Reduce 分析Array 视频Design Tiny Url 教学Android 设计Divide Conquer 研究PageRank 视频

九章算法

九章算法 前言 第一天的算法都还没有缓过来,直接就进入了第二天的算法学习.前一天一直在整理Binary Search的笔记,也没有提前预习一下,好在Binary Tree算是自己最熟的地方了吧(LeetCode上面Binary Tree的题刷了4遍,目前95%以上能够Bug Free)所以还能跟得上,今天听了一下,觉得学习到最多的,就是把Traverse和Divide Conquer分开来讨论,觉得开启了一片新的天地!今天写这个博客我就尽量把两种方式都写一写吧. Outline: 二叉树的遍历

分治习题--九章算法培训课第三章笔记

1.Maximum Depth of Binary Tree 这是道简单的分治习题了 分: 左子树最大深度 右子树最大深度 治: 最大深度等于max(左子树,右子树)+1 public class Solution { public int maxDepth(TreeNode root) { if (root == null) { return 0; } int left = maxDepth(root.left); int right = maxDepth(root.right); retur

【九章算法免费公开课】从 strStr 谈面试技巧与 Coding Style

刷题到底刷到什么程度才够? 题目不会直接说不会么? 为什么题目都做出来还是老是挂? 觉得面试官总在为难你? 从来就搞不懂动态规划是什么? 如何正确的骑驴找马? 什么是正确的Coding Style? 面试中该与面试官如何沟通? 本周日,九章算法<算法班>金牌讲师-段誉 为您倾情奉献,同时提供实时在线问答环节,解答您最想知道的面试"内幕"! 讲座时间: 北京时间6月14日 09:30-11:30 (周日) 美西时间6月13日 18:30-20:30(周六) 报名链接: htt

九章算法--分配抄书员

思路: (1)最常见的思路就是dp:状态表示为dp[i][j],表示前j个人抄i本书最少时间:dp[i][j] = min(max(dp[k][j-1],sum(k+1,i))) (j<=k<i): 解释一下min,max操作,由于抄书是并行,所以就是取所有人时间里的最大值 (2)二分+贪心:我自己一开始的思路是总和除以人数,然后贪心,但是这样可能不是最优解.但是我们可以计算每个人最大和最小抄书的时间,然后二分搜索这个时间即可. 下面代码是DP:但是没有考虑边界情况只是写来熟悉算法流程. 1