算法面试必修课,动态规划基础题型归纳(一)

算法面试必修课,动态规划基础题型归纳(一)

一、前言

动态规划(Dynamic Programming,简称DP),虽然抽象后进行求解的思路并不复杂,但具体的形式千差万别,找出问题的子结构以及通过子结构重新构造最优解的过程很难统一,并不像回溯法具有解决绝大多数问题的银弹。

随着计算机从业与求职者的增加,并伴随大家都是“有备而来”的情况下,一般简单的反转链表之类的题目已经无法再在面试中坚挺了。因此在求职者人数与招聘名额的比例较大的情况下,公司会倾向于出更难的面试问题。而动态规划就是一种比较具有难度,又比较“好出”的面试问题。相比其他的算法与数据结构知识来说,贪心法分治法太难出题了,搜索算法往往需要耗费求职者过长的程序编写时间一般也不倾向于出,二叉树链表等问题题目并没有那么多,而且求职者也都会着重准备这一块。因此动态规划这一类的问题,便越来越多的出现在了面试中。

由于该类题型较难,所以掌握好动态规划,你就是那位被录用的百里挑一的应聘者 !

二、动态规划解题步骤

核心思想是递推,难点在于想起出 状态 dp[i] 代表什么,然后构造状态转移矩阵,利用初始条件递推出最终结果。

判断问题的子结构(也可看作状态),当具有最优子结构时,动态规划可能适用。

  求解重叠子问题。一个递归算法不断地调用同一问题,递归可以转化为查表从而利用子问题的解。分治法则不同,每次递归都产生新的问题。重新构造一个最优解。

备忘录法:

  动态规划的一种变形,使用自顶向下的策略,更像递归算法。

  初始化时表中填入一个特殊值表示待填入,当递归算法第一次遇到一个子问题时,计算并填表;以后每次遇到时只需返回以前填入的值。

  1. 将原问题拆分成子问题
  2. 确认状态
  3. 确认边界状态(初始条件)
  4. 状态转移方程

三、动态规划基本题型总结提纲目录

1.硬币找零,路径规划

2.字符串相似度/编辑距离(edit distance)

3.最长公共子序列(Longest Common Subsequence,lcs)

4.最长递增子序列(Longest Increasing Subsequence,lis)

5.最大子序列积

6.矩阵链乘法

7.0-1背包问题

8.有代价的最短路径

9.瓷砖覆盖(状态压缩DP)

10.工作量划分

11.三路取苹果

参考资料

《算法设计手册》第八章 动态规划 面试题解答

四、例题分析

1..硬币找零表示

题目描述

有数量不限的硬币,币值为25分、10分、5分和1分,请编写代码计算n分有几种表示法。

给定一个int n,请返回n分有几种表示法。保证n小于等于100000,为了防止溢出,请将答案Mod 1000000007。

测试样例输入:6

返回:2

如果有6分的话,可以分为一个5分加1个1分,或者6个1分,所以有两种表示方法,结果是2.

问题解析

首先如果只用一种硬币的话,那么只有一种解法。 接下来,如果我在前一种硬币的基础上又用了一种硬币那么我的解法就变成两种了 ,好了。

我们模拟下

首先我的硬币只有面值1 2 那么我要组合成面值为4, 有多少种解法呢 ?

1.我们只用面值1的硬币 那么就是 1 1 1 1,这里就有1种解了 。

2.接下来我们要用面值为2的硬币在用了面值1硬币的基础上 那么就得在 1 1 后用 2 此时已经有了2种解法了 。

3.那么此时采用了1个面值为2的硬币啊,按道理说我们是可以用两个面值为2的硬币的,所以我们继续向下走 在 1 2 1 1的基础上

我们来到了第三个位置此时我们放入一个面值2的硬币发现此时是用了1个面值1的硬币 和一个面值2的硬币,所以我们总共的总数还是只能有2种

1 2 2 1 那么接下来我们来到了4的位置 我们又用了1个 面值2的硬币,那么我们得到 了1 2 2 3 为什么呢 我的位置四就有3种了呢

好,让我们来分析分析 第一种那就是全部用面值1的硬币达到了4 第二种是 用面值 1 1 2达到了4 第三种是用了2 2 达到了4

由此我们可以用dp[n+1]来存储结果 首先dp[0] = 1; 为什么我的dp[0]要等于1呢,是因为我们第一次使用某一种

硬币面值的时候需要在0面值的基础上增1,所以我们的dp[0] = 1就是为我们这样服务的。 那么我们再来解析下dp[]数组的含义

他代表着我们前面使用的所有硬币的数量以及到达n种数 1.【1 1 1 1】 2.【1 2 2 1】 3.【1 2 2 3】

1.位置二的1代表我们用了一个面值一的硬币以及我们到达位置2用了两个面值1的硬币

2.位置二的2代表了我们在1.的基础上用了面值2的硬币到达了2所以我们此时就有2种方法 下面给出代码

class Coins {
public:
    int countWays(int n) {
        // write code here
        int coins[4]={1,5,10,25};
        int dp[100001] = {0};       
        dp[0] = 1;
        for(int i = 0;i < 4;++i){
            for(int j = coins[i];j <= n;++j){
                dp[j] =(dp[j]+dp[j-coins[i]])%1000000007;               
            }
        }
        return dp[n];
    }
};

1.1取苹果

动态规划之收集苹果

路径经过的最大值(最小值):

题目描述:平面上有N*M个格子,每个格子中放着一定数量的苹果。从左上角的格子开始, 每一步只能向下走或是向右走,每次走到一个格子就把格子里的苹果收集起来, 这样一直走到右下角,问最多能收集到多少个苹果。

不妨用一个表格来表示:

{5, 8, 5, 7, 1, 8},
{1, 3, 2, 8, 7, 9},
{7, 8, 6, 6, 8, 7},
{9, 9, 8, 1, 6, 3},
{2, 4,10, 2, 6, 2},
{5, 5, 2, 1, 8, 8},
左图为题目给定每一格苹果的分布,右图为第一行第一列的累加和

接下来填第2行,首先是第2行第2列的值,应该填写为 MAX(A[1,2], A[2,1])+ A[2,2]对应的苹果数量。也就是说到达第2行第2列能获得的最大苹果数,要看第2行第1列所获得的苹果数(6)和第1行第2列所获得的苹果数(13),这两者哪个更大,谁大就取谁的值,显然第1行第2列所获得的苹果数(13)更大,所以用13加上第2行第2列的苹果数3 = 16,就是到达第2行第2列能获得的最大苹果数。同理,填所在格能获得的最大苹果数就是看它左面一格和上面一格哪个值更大,就取哪个值再加上自己格子里面的苹果数,就是到达此格能获得的最大苹果数。依此填完所有格子,最后得到下图:

所有行列的累加和

所以:到达右下角能够获得的最大苹果数量是76。所经过的路径可以通过倒推的方法得到,从右下角开始看所在格子的左边一格和上面一格哪边大就往哪边走,如果遇到一样大的,任选一条即可。

这样我们可以画出路线图,如下图右边表格:

找出最大苹果个数路线图

这个例子的分析和解决方法大概就是这样了。在前面第一个例子里面我们提到:空间换时间是动态规划的精髓。但是一个问题是否能够用动态规划算法来解决,需要看这个问题是否能被分解为更小的问题(子问题)。而子问题之间是否有包含的关系,是区别动态规划算法和分治法的所在。一般来说,分治法的各个子问题之间是相互独立的,比如折半查找(二分查找)、归并排序等。而动态规划算法的子问题在往下细分为更小的子问题时往往会遇到重复的子问题,我们只处理同一个子问题一次,将子问题的结果保存下来,这就是动态规划的最大特点。

首先,我们要找到这个问题中的“状态”是什么?我们必须注意到的一点是,到达一个格子的方式最多只有两种:从左边来的(除了第一列)和从上边来的(除了第一行)。因此为了求出到达当前格子后最多能收集到多少个苹果,我们就要先去考察那些能到达当前这个格子的格子,到达它们最多能收集到多少个苹果。 (是不是有点绕,但这句话的本质其实是DP的关键:欲求问题的解,先要去求子问题的解)

经过上面的分析,很容易可以得出问题的状态和状态转移方程。状态S[i][j]表示我们走到(i, j)这个格子时,最多能收集到多少个苹果。那么,状态转移方程如下:

S[i][j]=A[i][j] + max(S[i-1][j], if i>0 ; S[i][j-1], if j>0)

其中i代表行,j代表列,下标均从0开始;A[i][j]代表格子(i, j)处的苹果数量。

S[i][j]有两种计算方式:1.对于每一行,从左向右计算,然后从上到下逐行处理;2. 对于每一列,从上到下计算,然后从左向右逐列处理。这样做的目的是为了在计算S[i][j]时,S[i-1][j]和S[i][j-1]都已经计算出来了。

动态规划算法总结起来就是两点:

1 寻找递推(递归)关系,比较专业的说法叫做状态转移方程。

2 保存中间状态,空间换时间。

代码如下:

#include<iostream>
using namespace std;
int a[100][100];
int dp[100][100];
int m,n;
 
int main()
{
	cin>>m>>n;
	for(int i=0;i<m;i++)
	    for(int j=0;j<n;j++)
		    cin>>a[i][j];	
	for(int i=0;i<m;i++)
	    for(int j=0;j<n;j++)
	    {
	        if(i==0&&j==0)
	            dp[i][j]=a[i][j];
	        if(i==0&&j>0)
	            dp[i][j]=a[i][j]+dp[i][j-1];
	        if(i>0&&j==0)
	            dp[i][j]=a[i][j]+dp[i-1][j];
	        if(i>0&&j>0)
	            dp[i][j]=a[i][j]+max(dp[i][j-1],dp[i-1][j]);
	    }
	cout<<dp[m-1][n-1]<<endl;
	return 0;	
}

1.2机器人走方格

题目描述

有一个X*Y的网格,一个机器人只能走格点且只能向右或向下走,要从左上角走到右下角。请设计一个算法,计算机器人有多少种走法。

给定两个正整数int x,int y,请返回机器人的走法数目。保证x+y小于等于12。

测试样例:

2,2

返回:2

题目分析:

题目要求走的是大格子而不是网格线的交点,所以有两种走法。

二维数组用于计算走到当前格子的走法总数,为其上方格子走法总数与其左侧格子走法总数之和

class Robot {
public:
    int countWays(int x, int y) {
        // write code ,here
        int dp[13][13]={0};
        dp[0][0]=0;
        for(int i=1;i<y;i++)//第一行初始化,因为只有横着走一种方法。
             dp[0][i]=1;
         for(int i=1;i<x;i++)//第一列初始化,因为只有竖着一种方法。
            dp[i][0]=1;
         for(int i=1;i<x;i++)//dp[i][j]的方法,等于走到上面一格和走到左边一个方法之和。
              for(int j=1;j<y;j++){
                  dp[i][j]=dp[i-1][j]+dp[i][j-1];
              }
         return dp[x-1][y-1];      
    }
};

变种:机器人障碍走方格

题目描述

有一个X*Y的网格,一个机器人只能走格点且只能向右或向下走,要从左上角走到右下角。请设计一个算法,计算机器人有多少种走法。注意这次的网格中有些障碍点是不能走的。

给定一个int[][] map(C++ 中为vector >),表示网格图,若map[i][j]为1则说明该点不是障碍点,否则则为障碍。另外给定int x,int y,表示网格的大小。请返回机器人从(0,0)走到(x - 1,y - 1)的走法数,为了防止溢出,请将结果Mod 1000000007。保证x和y均小于等于5。

分析:

和上一个题一样,多了障碍。

对map的处理:不能走这个点,说明这个点的可能路径走法==0,经过不能走的点写成0就可以了,总结一下就是:

  1. 不能走,就是方法数d[i][j]=0
  2. 起点,1种走法
  3. 上边沿:只能从左边来
  4. 左边沿:只能从上边来
  5. 其他点:左边+上边
class Robot {
public:
    int countWays(vector<vector<int> > map, int x, int y) {
         if(map.size() != x || map[0].size() != y)
        {
            return 0;
        }
        int dp[100][100]={0};
       dp[0][0] = 1;
         for(int i=0;i<x;i++)//dp[i][j]的方法,等于走到上面一格和走到左边一个方法之和。
              for(int j=0;j<y;j++){
                   if(map[i][j] != 1) 
                  {
                      dp[i][j]=0;
                       continue;
                  }
                  if(i == 0 || j == 0)
                  {
                      if(i == 0 && j != 0)
                      {
                          dp[i][j] = dp[i][j-1];
                      }
                       if(i != 0 && j == 0)
                      {
                          dp[i][j] = dp[i-1][j];
                      }
                      continue;
                  }
                  dp[i][j]=(dp[i-1][j]+dp[i][j-1])%1000000007;

              }
         return dp[x-1][y-1];      

    }
};

下面看第二种题型,

字符串相似度/编辑距离(edit distance)

2.1字符串匹配

KMP算法是一种改进后的字符串匹配算法,由D.E.Knuth与V.R.Pratt和J.H.Morris同时发现,因此人们称它为克努特——莫里斯——普拉特操作(简称KMP算法)。通过一个辅助函数实现跳过扫描不必要的目标串字符,以达到优化效果。

一般匹配字符串时,我们从目标字符串str(假设长度为n)的第一个下标选取和ptr长度(长度为m)一样的子字符串进行比较,如果一样,就返回开始处的下标值,不一样,选取str下一个下标,同样选取长度为n的字符串进行比较,直到str的末尾(实际比较时,下标移动到n-m)。这样的时间复杂度是O(n*m)。

KMP算法:可以实现复杂度为O(m+n)

为何简化了时间复杂度:

充分利用了目标字符串ptr的性质(比如里面部分字符串的重复性,即使不存在重复字段,在比较时,实现最大的移动量)。

上面理不理解无所谓,我说的其实也没有深刻剖析里面的内部原因。

先不说kmp算法,先谈谈朴素的模式匹配算法。朴素的模式匹配算法是一种暴力匹配算法,也就是最蠢的匹配算法。如果失配的话,i,j都得变,i回溯至刚开头字符的下一位,j就置为0。这就造成了浪费,因为i已经回溯到刚才比较过了的字符,又需要再一次被比较,重复比较,造成浪费。

如果i不动,只动j的话是不是可以呢。这就引出了kmp算法的精髓了。kmp算法就是保持i不动,通过修改j的位置,让模式串尽可能的移动到需要的地方。

“前缀”指除了最后一个字符以外,一个字符串的全部头部组合;

“后缀”指除了第一个字符以外,一个字符串的全部尾部组合;

废话少说,举个例子:”ababa”

“部分匹配值”就是”前缀”和”后缀”的最长的共有元素的长度。以”ABABA”为例,

- “A”的前缀和后缀都为空集,共有元素的长度为0;

- “AB”的前缀为[A],后缀为[B],共有元素的长度为0;

- “ABA”的前缀为[A, AB],后缀为[BA, A],共有元素为”A”,共有元素的长度1;

- “ABAB”的前缀为[A, AB, ABA],后缀为[BAB,AB, B],共有元素为”AB”,共有元素的长度为2;

- “ABABA”的前缀为[A, AB, ABA, ABAB],后缀为[BABA, ABA, BA, A],共有元素为”ABA”,长度为3;

该算法具体步骤为:

求得next数组,然后再进行算法的后续步骤。

next数组中储存的是这个字符串前缀和后缀中相同字符串的最长长度。比如abcdefgabc,前缀和后缀相同的是abc,长度是3。

next[i]储存的是string中前i+1位字符串前缀和后缀的最长长度。如abadefg,next[2]存的是aba这个字符串前缀和后缀的最长长度。

也就是说next的值由部分匹配值整体向右移一位,且在第一位赋值-1后,再整体加1而成。

首先看看next数组值的求解方法例如:

模式串 a b a a b c a c

next值 0 1 1 2 2 3 1 2

next数组的求解方法是:第一位的next值为0,第二位的next值为1,后面求解每一位的next值时,根据前一位进行比较。首先将前一位与其next值对应的内容进行比较,如果相等,则该位的next值就是前一位的next值加上1;如果不等,向前继续寻找next值对应的内容来与前一位进行比较,直到找到某个位上内容的next值对应的内容与前一位相等为止,则这个位对应的值加上1即为需求的next值;如果找到第一位都没有找到与前一位相等的内容,那么需求的位上的next值即为1。

看起来很令人费解,利用上面的例子具体运算一遍。

1.前两位必定为0和1。

2.计算第三位的时候,看第二位b的next值,为1,则把b和1对应的a进行比较,不同,则第三位a的next的值为1,因为一直比到最前一位,都没有发生比较相同的现象。

3.计算第四位的时候,看第三位a的next值,为1,则把a和1对应的a进行比较,相同,则第四位a的next的值为第三位a的next值加上1。为2。因为是在第三位实现了其next值对应的值与第三位的值相同。

4.计算第五位的时候,看第四位a的next值,为2,则把a和2对应的b进行比较,不同,则再将b对应的next值1对应的a与第四位的a进行比较,相同,则第五位的next值为第二位b的next值加上1,为2。因为是在第二位实现了其next值对应的值与第四位的值相同。

5.计算第六位的时候,看第五位b的next值,为2,则把b和2对应的b进行比较,相同,则第六位c的next值为第五位b的next值加上1,为3,因为是在第五位实现了其next值对应的值与第五位相同。

6.计算第七位的时候,看第六位c的next值,为3,则把c和3对应的a进行比较,不同,则再把第3位a的next值1对应的a与第六位c比较,仍然不同,则第七位的next值为1。

7.计算第八位的时候,看第七位a的next值,为1,则把a和1对应的a进行比较,相同,则第八位c的next值为第七位a的next值加上1,为2,因为是在第七位和实现了其next值对应的值与第七位相同。

next数组的求解方法是:第一位的next值为0,第二位的next值为1,后面求解每一位的next值时,根据前一位进行比较。首先将前一位与其next值对应的内容进行比较,如果相等,则该位的next值就是前一位的next值加上1;如果不等,向前继续寻找next值对应的内容来与前一位进行比较,直到找到某个位上内容的next值对应的内容与前一位相等为止,则这个位对应的值加上1即为需求的next值;如果找到第一位都没有找到与前一位相等的内容,那么需求的位上的next值即为1。

代码如下:

#include <stdio.h>
#include <string.h>
int next[100];

void getNextVal(char *pattern){
    int k = -1;
    int j = 0;
    int len = strlen(pattern);
    next[j] = k;
    while(j < len){
        if(k == -1 || pattern[j] == pattern[k]){
            k++;
            j++;
            if(pattern[j] == pattern[k])
                next[j] = next[k];
            else 
                next[j] = k;
        }
        else 
            k = next[k];
    }
}

int KMP_Searsh(char *str, char *pattern){
    int i = 0;
    int j = 0;
    int slen = strlen(str);
    int plen = strlen(pattern);
    while(i < slen && j < plen){
        if(j == -1 || str[i] == pattern[j]){
            i++;
            j++;
        }
        else
            j = next[j];
    }
    if(j == plen)
        return i - j;
    return -1;
}

int main()
{
    int i;
    int ans;
    char str[] = {"abababaababacb"};
    char pattern[] = {"ababacb"};
    getNextVal(pattern);
    ans = KMP_Searsh(str, pattern);
    for(i = 0; i < strlen(pattern); i++)
        printf("%d ", next[i]);
    printf("\n");
    if(ans != -1)
        printf("match! begin subscript: %d\n", ans);
    else
        printf("not match!\n");
    return 0;

关于动态规划,后面还有很多,十几个题型我这才讲了两个,有时间我再更新,欢迎大家来一起学习。

看似特别枯燥无聊晦涩难懂,但是学会之后,你就会发现其乐无穷,哈哈哈哈。

编辑于 2019-10-12

文章被以下专栏收录