动态规划 - 从头为你梳理DP

动态规划,应该是在常用的算法思想中最难的一个了。它之所以难,主要有两点:一是不知道怎样的问题可以使用动态规划解决,二是不知道一个问题怎样具体地使用动态规划分析。
其实,这两者都有一定的套路可寻,下面我就为你详细分析这两个问题,并且会告诉你为什么动态规划的效率比回溯算法高:

什么样的问题可以使用动态规划解决

首先,需要明确一件事,一个算法只能在满足条件的情况下才能使用,如果条件不符,则无法使用。

对于动态规划来说,它能解决的问题主要要满足四个特点,我们称之为:一个模型,三个特征。一个模型,是指这个问题符合多阶段最优解模型,三个特点是指,这个问题具有无后向性、重复子问题和最优子结构

下面,我们会用一个很经典的算法题目,来为你展示这四个特点。

题目是这样的:

假设我们有一个 n 乘以 n 的矩阵 w[n][n]。矩阵存储的都是正整数。棋子起始位置在左上角,终止位置在右下角。我们将棋子从左上角移动到右下角。每次只能向右或者向下移动一位。从左上角到右下角,会有很多不同的路径可以走。我们把每条路径经过的数字加起来看作路径的长度。那从左上角移动到右下角的最短路径长度是多少呢?

适用条件-案例.jpg

多阶段最优解模型

这个特点是说,一个问题可以分成多个阶段进行决策,不同的决策可能都可以解决问题,但是只有特定的某一个(或几个)解是最优的解决方案

以这个题目为例,你会发现,在一个 n * n 的矩阵中,一共要移动 2(n - 1) 次,也就是说,我们需要移动 6 次才能到达终点,这里的 6 次,就是多阶段决策。在这 6 次移动中,你都可以选择向右或者向下移动,而只有某一个移动线路可以让路径最短。

案例-一个模型.jpg

无后向性

无后向性有两个含义:

  • 第一个含义是,在推导下一个阶段的状态的时候,我们只关心前面有限个状态的值,而不必追究前面这个状态是如何得到的。
  • 第二个含义是,一个阶段的状态一旦确定,就不再受之后阶段决策的影响。

无后向性是一个非常宽松的条件,基本上满足多阶段最优解模型的问题,都会满足无后向性。对于这个特性,你要记住的是,不要尝试用你的大脑记住所有的决策阶段。你只要分析有限的阶段即可。

重复子问题

这个特性是指,使用不同的决策序列,可能会达到同一个状态。

以上面的例子来说,就是我们到达某一点会有不同的路径可供选择:


案例-重复子问题.jpg

最优子结构

最优子结构是说,一个问题的最优解包含子问题的最优解。从两个方面来说,一是我们可以从子问题的最优解,推出问题的最优解。二是我们可以由最终问题倒推子问题。

可以说,正是因为具有这四个特征,才构成了我们使用动态规划的条件,也是因为这几个特征,才让动态规划会有比回溯算法更高的效率。那么在讲具体的解题思路之前,我们有必要了解一下为什么动态规划会如此高效。

动态规划为什么高效

聊到动态规划,我们常常拿回溯算法和它做比较。所以要理解动态规划的高效,我们首先要理解回溯算法为什么低效,动态规划是如何借助上面四个特点提高效率的。

回溯算法:暴力遍历

回溯算法是一个比动态规划更加通用的算法思想,它通过不断地尝试和回溯来获得问题的解。通常,回溯算法会穷尽问题的所有解,并进一步获得问题的最优解。依然以上面的问题为例,让我们看一下回溯算法的执行过程:

下面使用一个 3 x 3 的矩阵为例,展示了在回溯算法中探索出的两个解:


回溯执行

而对于这个问题来说,一共有 6 种执行路径,他们的递归树如下:


回溯递归树

在回溯算法中,计算机会遍历所有可能的解,这也是为什么我们说回溯算法是一种暴力算法。实际上,回溯算法的时间复杂度为 O(2n),这个复杂度是难以接受的,你可以感受一下 4 x 4 的递归过程:

案例-递归树.jpg

那么,面对同样的问题,动态规划是如何解决的呢?

动态规划:高效剪枝和递推

剪枝和递推是我们在编程中会经常使用到的方法,一个最常见的例子就是求解斐波那契数列,使用剪枝和递推,可以让程序的复杂度飞速下降。

还记得我们之前说过的,使用动态规划的必备条件吗?在动态规划中剪枝的依据来源于重复子问题,递推的依据来源于最优子结构。接下来,我们就来看一下它们是如何发挥作用的。

剪枝和重复子问题

相信你已经察觉到了,回溯算法的递归树做了非常多的冗余操作,例如下面的这个例子:


重复子问题

在这个图片中,是计算机走到 (1,1) 的两个路径,你会发现,同样是走到这个这个位置,却付出了不同的代价:4 和 5,依据我们要求最短路径的需求,我们可以很自然地将代价大的一个树枝“剪掉”:

剪枝

你会发现,这种优化的效果是立竿见影的。

最优子结构和递推

写出递推公式是动态规划中最重要的步骤,我们可以通过分析一个问题的最优子结构得出其递推公式。

以本题为例,棋子只能向下和向右,所以反过来想,如果一个棋子的上方和左边的状态已经确定,我们可以很自然地得到这个棋子的状态。

例如,当前棋子的位置为 ( i , j ),那么这个位置的状态只能从 ( i - 1 , j ) 和 ( i , j - 1 ) 两个状态中得出。
将这个规律总结成递推公式,就是:

min_dist(i, j) = w[i][j] + min(min_dist(i, j-1), min_dist(i-1, j))

接下来的事情就非常简单了,我们可以根据这个公式依次求得棋子在每一个位置的最优状态:


案例-重复子问题-状态转移1.jpg
案例-重复子问题-状态转移2.jpg

有了上面的讲解,你已经了解了动态规划的所有知识。本题在LeetCode上可以找到,你可以点击这里

对这个题目的代码,有三种解法:

  • 回溯(复杂度过高,无法通过提交):
class Solution:
    def minPathSum(self, grid: List[List[int]]) -> int:
        self.grid = grid
        self.m = len(grid)
        self.n = len(grid[0])
        self.res = 10000

        self.dg(0, 0, 0)  # 从出发点开始尝试遍历
        return self.res

    def dg(self, i, j, s):
        if i == self.m - 1 and j == self.n - 1:  # 如果到达了终点,则将结果尝试保存
            if self.res > s + self.grid[i][j]:
                self.res = s + self.grid[i][j]

        if i < self.m and j < self.n:
            self.dg( i + 1, j, s + self.grid[i][j])
            self.dg( i, j + 1, s + self.grid[i][j])
  • 动态规划(正推):
class Solution:
    def minPathSum(self, grid: List[List[int]]) -> int:
        matrix = [ [ None for j in i ] for i in grid ]  # 状态数组

        temp = 0
        for i, _i in enumerate(grid):  # 初始化 y 轴
            temp += grid[i][0]
            matrix[i][0] = temp

        temp = 0
        for j, _j in enumerate(grid[0]):  # 初始化 x 轴
            temp += grid[0][j]
            matrix[0][j] = temp

        for i, _i in enumerate(grid):  # 使用递推公式求状态数组
            for j, _j in enumerate(_i):
                if i == 0 or j == 0 :
                    continue
                # 递推公式
                matrix[i][j] = min(matrix[i - 1][j], matrix[i][j-1]) + grid[i][j]

        return matrix[-1][-1]
  • 递归(倒推):
class Solution:
    def minPathSum(self, grid: List[List[int]]) -> int:
        self.grid = grid
        self.mat = [ [None for j in i] for i in grid ]  # 使用一个数组暂存结果
        m = len(grid)
        n = len(grid[0])

        return self.dg(m - 1, n - 1)  # 获得终点的值

    def dg(self, i, j):  # 会返回 i,j 的最小值
        if i == 0 and j == 0:  # 递归终点:到达 0,0 点
            return self.grid[i][j]

        if self.mat[i][j] is not None:  # 如果这个结果已经有了,就直接返回
            return self.mat[i][j]

        up_min = 10000
        left_min = 10000
        if j > 0 :
            up_min = self.dg(i, j - 1)
        if i > 0 :
            left_min = self.dg(i - 1, j)
        
        cur_min = min(up_min, left_min) + self.grid[i][j]  # 递推公式
        self.mat[i][j] = cur_min  # 暂存

        return cur_min

例题分析

例题1:按摩师

这道题来自LeetCode

一个有名的按摩师会收到源源不断的预约请求,每个预约都可以选择接或不接。在每次预约服务之间要有休息时间,因此她不能接受相邻的预约。给定一个预约请求序列,替按摩师找到最优的预约集合(总预约时间最长),返回总的分钟数。

示例:

输入: [1,2,3,1]
输出: 4
解释: 选择 1 号预约和 3 号预约,总时长 = 1 + 3 = 4。

题目分析:

  • 多阶段最优解模型:在示例中,一共要处理 4 次预约,不同的处理方式会有不同的结果,而我们要找的是最优解。

  • 重复子问题:在不同的情况下,会出现不同的子问题。例如:对于第三天的按摩,我选择去或者不去,会根据之前第一天和第二天的选择有所不同,这就有重复的子问题了。

  • 最优子结构:在不同的情况下,不同的子问题会有最优解。例如:对于第三天的按摩,不同的选择策略会有不同的按摩分钟数,其中会有最优决策。

  • 递推公式:我们在这里需要根据题目要求给出不同的递推公式:
    在这个问题中,我们需要设置状态数组 status[ i ][ j ] ,其中 i 表示按摩师对第 i 天进行决策,j 表示是否接受预约( 0 表示不接受,1 表示接受)。
    根据题目,我们可以得到递推公式:

status[ i ][ 0 ] = max( status[ i-1][ 0 ], status[ i-1 ][ 1 ] )
status[ i ][ 1 ] = status[ i-1 ][ 0 ] + nums[ i ]

OK,这样我们就可以写出代码了:

class Solution:
    def massage(self, nums: List[int]) -> int:
        if len(nums) == 0:
            return 0
            
        sta = [ [None, None] for i in nums ]

        sta[0][0] = 0
        sta[0][1] = nums[0]

        i = 1
        while i < len(nums):
            sta[i][0] = max(sta[i - 1][0], sta[i - 1][1])
            sta[i][1] = sta[i - 1][0] + nums[i]
            
            i += 1

        return max(sta[-1])

例题2:编辑距离

这道题来自LeetCode 第 72 题

给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。(即编辑距离)

你可以对一个单词进行如下三种操作:

  • 插入一个字符
  • 删除一个字符
  • 替换一个字符

输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')

这个题目比较有实际的应用价值,最常见的应用就是拼写纠错:


拼写纠错

image.png

在这些例子中,系统会自动识别输入的内容,并且会将这些内容和一些常用的字符串进行匹配,如果两个字符串的编辑距离比较小,则系统判断你可能是手误打错了内容,从而进一步进行正确的推荐。

分析

在这个题目中,允许三种编辑操作:插入、删除 和 替换。
对于这三种情况,让我们看一在实际的匹配中是如何处理的:

假设我们匹配两个字符串 a 和 b ,现在在 a 字符串中匹配到第 i 个字符,b 中匹配到第 j 个字符。

  • 如果 a[ i ] == b[ j ],我们则可以进入下一次匹配:a[i + 1] 和 b[j + 1]
  • 如果 a[ i ] != b[ j ],我们则需要进行一次编辑操作:插入、删除 或者替换(注意,进行下面的操作会导致编辑距离 +1):
  1. 可以删除 a[i],然后考察 a[i+1]和 b[j];
  2. 可以删除 b[j],然后考察 a[i]和 b[j+1];
  3. 可以在 a[i]前面添加一个跟 b[j]相同的字符,然后考察 a[i]和 b[j+1];
  4. 可以在 b[j]前面添加一个跟 a[i]相同的字符,然后考察 a[i+1]和 b[j];
  5. 可以将 a[i]替换成 b[j],或者将 b[j]替换成 a[i],然后考察 a[i+1]和 b[j+1]。

你可以通过下面的图大致了解一下它的执行过程:


编辑距离.jpg

走到这里,你应该可以发现:

  • 这个问题是一个多阶段最优解模型
  • 这个问题中存在重复子问题
  • 在重复的子问题中,存在最优解

没错,有了这些发现,你应该可以想到使用动态规划来解决这个问题。对于动态规划来说,最重要的是递推公式。

通过对题目的分析,我们已经知道,递推需要区分两种情况:a[ i ] == b[ j ] 和 a[ i ] != b[ j ],它们的递推公式如下:

如果:a[i]!=b[j],那么:min_edist(i, j)就等于:
min(min_edist(i-1,j)+1, min_edist(i,j-1)+1, min_edist(i-1,j-1)+1)

如果:a[i]==b[j],那么:min_edist(i, j)就等于:
min(min_edist(i-1,j)+1, min_edist(i,j-1)+1,min_edist(i-1,j-1))

其中,min表示求三数中的最小值。

有了递推公式,我们就可以很容易地填充状态数组了:


递推.jpg

它的代码如下:

public int lwstDP(char[] a, int n, char[] b, int m) {
  int[][] minDist = new int[n][m];
  for (int j = 0; j < m; ++j) { // 初始化第0行:a[0..0]与b[0..j]的编辑距离
    if (a[0] == b[j]) minDist[0][j] = j;
    else if (j != 0) minDist[0][j] = minDist[0][j-1]+1;
    else minDist[0][j] = 1;
  }
  for (int i = 0; i < n; ++i) { // 初始化第0列:a[0..i]与b[0..0]的编辑距离
    if (a[i] == b[0]) minDist[i][0] = i;
    else if (i != 0) minDist[i][0] = minDist[i-1][0]+1;
    else minDist[i][0] = 1;
  }
  for (int i = 1; i < n; ++i) { // 按行填表
    for (int j = 1; j < m; ++j) {
      if (a[i] == b[j]) minDist[i][j] = min(
          minDist[i-1][j]+1, minDist[i][j-1]+1, minDist[i-1][j-1]);
      else minDist[i][j] = min(
          minDist[i-1][j]+1, minDist[i][j-1]+1, minDist[i-1][j-1]+1);
    }
  }
  return minDist[n-1][m-1];
}

private int min(int x, int y, int z) {
  int minv = Integer.MAX_VALUE;
  if (x < minv) minv = x;
  if (y < minv) minv = y;
  if (z < minv) minv = z;
  return minv;
}

上面的代码是专栏作者的代码,下面是我自己的代码。注意,我的代码为了方便初始化,在矩阵中多使用了一行和一列:

class Solution:
    def minDistance(self, word1: str, word2: str) -> int:
        dp = [ [  None for j in range(len(word2) + 1) ] for i in range(len(word1) + 1) ]  # 多使用一行和一列

        # 初始化数组
        for i, v in enumerate(dp[0]):
            dp[0][i] = i
        
        for i, v in enumerate(dp):
            dp[i][0] = i
        
        i = 1
        while i <= len(word1):
            j = 1
            while j <= len(word2):
                if word1[i - 1] == word2[j - 1]:  # 递推
                    dp[i][j] = min(dp[i - 1][j - 1], dp[ i ][j - 1] + 1, dp[i - 1][ j ] + 1)
                    
                if word1[i - 1] != word2[j - 1]:  # 递推
                    dp[i][j] = min(dp[i - 1][j - 1], dp[ i ][j - 1], dp[i - 1][j]) + 1

                j += 1
            i += 1

        return dp[-1][-1]

示例:

  • 输入:"horse" "ros"
  • 输出:3
  • 状态数组:
    [0, 1, 2, 3]
    [1, 1, 2, 3]
    [2, 2, 1, 2]
    [3, 2, 2, 2]
    [4, 3, 3, 2]
    [5, 4, 4, 3]

如果不理解添加一行一列的原因,你可以看一下官方题解

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,843评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,538评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,187评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,264评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,289评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,231评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,116评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,945评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,367评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,581评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,754评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,458评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,068评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,692评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,842评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,797评论 2 369
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,654评论 2 354