Dynamic Programming: From novice to advanced

栏目: 编程工具 · 发布时间: 5年前

内容简介:本文翻译自 TopCoder 上的一篇文章:我们遇到的问题中,有很大一部分可以用动态规划 (简称 DP) 来解。 解决这类问题可以很大地提升你的能力与技巧,我会试着帮助你理解如何使用 DP 来解题。 这篇文章是基于实例展开来讲的,因为干巴巴的理论实在不好理解。注意:如果你对于其中某一节已经了解并且不想阅读它,没关系,直接跳过它即可。

本文翻译自 TopCoder 上的一篇文章: Dynamic Programming: From novice to advanced ,并非严格逐字逐句翻译,其中加入了自己的一些理解。水平有限,还望指摘。

前言_

我们遇到的问题中,有很大一部分可以用动态规划 (简称 DP) 来解。 解决这类问题可以很大地提升你的能力与技巧,我会试着帮助你理解如何使用 DP 来解题。 这篇文章是基于实例展开来讲的,因为干巴巴的理论实在不好理解。

注意:如果你对于其中某一节已经了解并且不想阅读它,没关系,直接跳过它即可。

简介 (入门)

什么是动态规划,我们要如何描述它?

动态规划算法通常基于一个递推公式及一个或多个初始状态。 当前子问题的解将由上一次子问题的解推出。使用动态规划来解题只需要多项式时间复杂度, 因此它比回溯法、暴力法等要快许多。

现在让我们通过一个例子来了解一下 DP 的基本原理。

首先,我们要找到某个状态的最优解,然后在它的帮助下,找到下一个状态的最优解。

“状态” 代表什么及如何找到它?

“状态” 用来描述该问题的子问题的解。原文中有两段作者阐述得不太清楚,跳过直接上例子。

如果我们有面值为 1 元、3 元和 5 元的硬币若干枚,如何用最少的硬币凑够 11 元? (表面上这道题可以用贪心算法,但贪心算法无法保证可以求出解,比如 1 元换成 2 元的时候)

首先我们思考一个问题,如何用最少的硬币凑够 i 元 (i<11)?为什么要这么问呢? 两个原因:1. 当我们遇到一个大问题时,总是习惯把问题的规模变小,这样便于分析讨论。 2. 这个规模变小后的问题和原来的问题是同质的,除了规模变小,其它的都是一样的, 本质上它还是同一个问题 (规模变小后的问题其实是原问题的子问题)。

好了,让我们从最小的 i 开始吧。当 i=0,即我们需要多少个硬币来凑够 0 元。 由于 1,3,5 都大于 0,即没有比 0 小的币值,因此凑够 0 元我们最少需要 0 个硬币。 (这个分析很傻是不是?别着急,这个思路有利于我们理清动态规划究竟在做些什么。) 这时候我们发现用一个标记来表示这句 “凑够 0 元我们最少需要 0 个硬币。” 会比较方便, 如果一直用纯文字来表述,不出一会儿你就会觉得很绕了。那么, 我们用 d(i)=j 来表示凑够 i 元最少需要 j 个硬币。于是我们已经得到了 d(0)=0, 表示凑够 0 元最小需要 0 个硬币。当 i=1 时,只有面值为 1 元的硬币可用, 因此我们拿起一个面值为 1 的硬币,接下来只需要凑够 0 元即可,而这个是已经知道答案的, 即 d(0)=0。所以,d(1)=d(1-1)+1=d(0)+1=0+1=1。当 i=2 时, 仍然只有面值为 1 的硬币可用,于是我拿起一个面值为 1 的硬币, 接下来我只需要再凑够 2-1=1 元即可(记得要用最小的硬币数量),而这个答案也已经知道了。 所以 d(2)=d(2-1)+1=d(1)+1=1+1=2。一直到这里,你都可能会觉得,好无聊, 感觉像做小学生的题目似的。因为我们一直都只能操作面值为 1 的硬币!耐心点, 让我们看看 i=3 时的情况。当 i=3 时,我们能用的硬币就有两种了:1 元的和 3 元的( 5 元的仍然没用,因为你需要凑的数目是 3 元!5 元太多了亲)。 既然能用的硬币有两种,我就有两种方案。如果我拿了一个 1 元的硬币,我的目标就变为了: 凑够 3-1=2 元需要的最少硬币数量。即 d(3)=d(3-1)+1=d(2)+1=2+1=3。 这个方案说的是,我拿 3 个 1 元的硬币;第二种方案是我拿起一个 3 元的硬币, 我的目标就变成:凑够 3-3=0 元需要的最少硬币数量。即 d(3)=d(3-3)+1=d(0)+1=0+1=1. 这个方案说的是,我拿 1 个 3 元的硬币。好了,这两种方案哪种更优呢? 记得我们可是要用最少的硬币数量来凑够 3 元的。所以, 选择 d(3)=1,怎么来的呢?具体是这样得到的:d(3)=min{d(3-1)+1, d(3-3)+1}。

OK,码了这么多字讲具体的东西,让我们来点抽象的。从以上的文字中, 我们要抽出动态规划里非常重要的两个概念:状态和状态转移方程。

上文中 d(i) 表示凑够 i 元需要的最少硬币数量,我们将它定义为该问题的” 状态”, 这个状态是怎么找出来的呢?我在另一篇文章 动态规划之背包问题 (一) 中写过: 根据子问题定义状态。你找到子问题,状态也就浮出水面了。 最终我们要求解的问题,可以用这个状态来表示:d(11),即凑够 11 元最少需要多少个硬币。 那状态转移方程是什么呢?既然我们用 d(i) 表示状态,那么状态转移方程自然包含 d(i), 上文中包含状态 d(i) 的方程是:d(3)=min{d(3-1)+1, d(3-3)+1}。没错, 它就是状态转移方程,描述状态之间是如何转移的。当然,我们要对它抽象一下,

d(i)=min{ d(i-vj)+1 },其中 i-vj >=0,vj 表示第 j 个硬币的面值;

有了状态和状态转移方程,这个问题基本上也就解决了。当然了,Talk is cheap,show me the code!

伪代码如下:

Dynamic Programming: From novice to advanced

下图是当 i 从 0 到 11 时的解:

Dynamic Programming: From novice to advanced

从上图可以得出,要凑够 11 元至少需要 3 枚硬币。

此外,通过追踪我们是如何从前一个状态值得到当前状态值的, 可以找到每一次我们用的是什么面值的硬币。比如,从上面的图我们可以看出, 最终结果 d(11)=d(10)+1(面值为 1),而 d(10)=d(5)+1(面值为 5),最后 d(5)=d(0)+1 (面值为 5)。所以我们凑够 11 元最少需要的 3 枚硬币是:1 元、5 元、5 元。

注意:原文中这里本来还有一段的,但我反反复复读了几遍, 大概的意思我已经在上文从 i=0 到 i=3 的分析中有所体现了。作者本来想讲的通俗一些, 结果没写好,反而更不好懂,所以这段不翻译了。

初级

上面讨论了一个非常简单的例子。现在让我们来看看对于更复杂的问题, 如何找到状态之间的转移方式 (即找到状态转移方程)。 为此我们要引入一个新词叫递推关系来将状态联系起来 (说的还是状态转移方程)

OK,上例子,看看它是如何工作的。

一个序列有 N 个数:A[1],A[2],…,A[N],求出最长非降子序列的长度。 (讲 DP 基本都会讲到的一个问题 LIS:longest increasing subsequence)

正如上面我们讲的,面对这样一个问题,我们首先要定义一个 “状态” 来代表它的子问题, 并且找到它的解。注意,大部分情况下,某个状态只与它前面出现的状态有关, 而独立于后面的状态。

让我们沿用 “入门” 一节里那道简单题的思路来一步步找到 “状态” 和“状态转移方程”。 假如我们考虑求 A[1],A[2],…,A[i]的最长非降子序列的长度,其中 i<N, 那么上面的问题变成了原问题的一个子问题(问题规模变小了,你可以让 i=1,2,3 等来分析) 然后我们定义 d(i),表示前 i 个数中以 A[i]结尾的最长非降子序列的长度。OK, 对照 “入门” 中的简单题,你应该可以估计到这个 d(i)就是我们要找的状态。 如果我们把 d(1)到 d(N)都计算出来,那么最终我们要找的答案就是这里面最大的那个。 状态找到了,下一步找出状态转移方程。

为了方便理解我们是如何找到状态转移方程的,我先把下面的例子提到前面来讲。 如果我们要求的这 N 个数的序列是:

5,3,4,8,6,7
复制代码

根据上面找到的状态,我们可以得到:(下文的最长非降子序列都用 LIS 表示)

  • 前 1 个数的 LIS 长度 d(1)=1(序列:5)
  • 前 2 个数的 LIS 长度 d(2)=1(序列:3;3 前面没有比 3 小的)
  • 前 3 个数的 LIS 长度 d(3)=2(序列:3,4;4 前面有个比它小的 3,所以 d(3)=d(2)+1)
  • 前 4 个数的 LIS 长度 d(4)=3(序列:3,4,8;8 前面比它小的有 3 个数,所以 d(4)=max{d(1),d(2),d(3)}+1=3)

OK,分析到这,我觉得状态转移方程已经很明显了,如果我们已经求出了 d(1) 到 d(i-1), 那么 d(i) 可以用下面的状态转移方程得到:

d(i) = max{1, d(j)+1},其中j<i,A[j]<=A[i]
复制代码

用大白话解释就是,想要求 d(i),就把 i 前面的各个子序列中, 最后一个数不大于 A[i] 的序列长度加 1,然后取出最大的长度即为 d(i)。 当然了,有可能 i 前面的各个子序列中最后一个数都大于 A[i],那么 d(i)=1, 即它自身成为一个长度为 1 的子序列。

分析完了,上图:(第二列表示前 i 个数中 LIS 的长度, 第三列表示,LIS 中到达当前这个数的上一个数的下标,根据这个可以求出 LIS 序列)

Dynamic Programming: From novice to advanced

Talk is cheap, show me the code:

#include <iostream>
using namespace std;

int lis(int A[], int n){
    int *d = new int[n];
    int len = 1;
    for(int i=0; i<n; ++i){
        d[i] = 1;
        for(int j=0; j<i; ++j)
            if(A[j]<=A[i] && d[j]+1>d[i])
                d[i] = d[j] + 1;
        if(d[i]>len) len = d[i];
    }
    delete[] d;
    return len;
}
int main(){
    int A[] = {
        5, 3, 4, 8, 6, 7
    };
    cout<<lis(A, 6)<<endl;
    return 0;
}
复制代码

该算法的时间复杂度是 O(n^2),并不是最优的解法。 还有一种很巧妙的算法可以将时间复杂度降到 O(nlogn),网上已经有各种文章介绍它, 这里就不再赘述。传送门:LIS 的 O(nlogn) 解法。 此题还可以用 “排序 + LCS” 来解,感兴趣的话可自行 Google。

练习题

无向图 G 有 N 个结点 (1<N<=1000) 及一些边,每一条边上带有正的权重值。 找到结点 1 到结点 N 的最短路径,或者输出不存在这样的路径。

提示:在每一步中,对于那些没有计算过的结点, 及那些已经计算出从结点 1 到它的最短路径的结点,如果它们间有边, 则计算从结点 1 到未计算结点的最短路径。

尝试解决以下来自 topcoder 竞赛的问题:

  • ZigZag - 2003 TCCC Semifinals 3
  • BadNeighbors - 2004 TCCC Round 4
  • FlowerGarden - 2004 TCCC Round 1

中级

接下来,让我们来看看如何解决二维的 DP 问题。

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

解这个问题与解其它的 DP 问题几乎没有什么两样。第一步找到问题的 “状态”, 第二步找到 “状态转移方程”,然后基本上问题就解决了。

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

伪代码如下:

Dynamic Programming: From novice to advanced

以下两道题来自 topcoder,练习用的。

  • AvoidRoads - 2003 TCO Semifinals 4
  • ChessMetric - 2003 TCCC Round 4

中高级

这一节要讨论的是带有额外条件的 DP 问题。

以下的这个问题是个很好的例子。

无向图 G 有 N 个结点,它的边上带有正的权重值。

你从结点 1 开始走,并且一开始的时候你身上带有 M 元钱。如果你经过结点 i, 那么你就要花掉 S[i] 元 (可以把这想象为收过路费)。如果你没有足够的钱, 就不能从那个结点经过。在这样的限制条件下,找到从结点 1 到结点 N 的最短路径。 或者输出该路径不存在。如果存在多条最短路径,那么输出花钱数量最少的那条。 限制:1<N<=100 ; 0<=M<=100 ; 对于每个 i,0<=S[i]<=100;正如我们所看到的, 如果没有额外的限制条件 (在结点处要收费,费用不足还不给过),那么, 这个问题就和经典的迪杰斯特拉问题一样了 (找到两结点间的最短路径)。 在经典的迪杰斯特拉问题中, 我们使用一个一维数组来保存从开始结点到每个结点的最短路径的长度, 即 M[i] 表示从开始结点到结点 i 的最短路径的长度。然而在这个问题中, 我们还要保存我们身上剩余多少钱这个信息。因此,很自然的, 我们将一维数组扩展为二维数组。M[i][j] 表示从开始结点到结点 i 的最短路径长度, 且剩余 j 元。通过这种方式,我们将这个问题规约到原始的路径寻找问题。 在每一步中,对于已经找到的最短路径,我们找到它所能到达的下一个未标记状态 (i,j), 将它标记为已访问 (之后不再访问这个结点),并且在能到达这个结点的各个最短路径中, 找到加上当前边权重值后最小值对应的路径,即为该结点的最短路径。 (写起来真是绕,建议画个图就会明了很多)。不断重复上面的步骤, 直到所有的结点都访问到为止 (这里的访问并不是要求我们要经过它, 比如有个结点收费很高,你没有足够的钱去经过它,但你已经访问过它) 最后 Min[N-1][j] 中的最小值即是问题的答案 (如果有多个最小值, 即有多条最短路径,那么选择 j 最大的那条路径,即,使你剩余钱数最多的最短路径)。

伪代码:

Dynamic Programming: From novice to advanced

下面有几道 topcoder 上的题以供练习:

  • Jewelry - 2003 TCO Online Round 4
  • StripePainter - SRM 150 Div 1
  • QuickSums - SRM 197 Div 2
  • ShortPalindromes - SRM 165 Div 2

高级

以下问题需要仔细的揣摩才能将其规约为可用 DP 解的问题。

问题:StarAdventure - SRM 208 Div 1:

给定一个 M 行 N 列的矩阵 (M*N 个格子),每个格子中放着一定数量的苹果。 你从左上角的格子开始,只能向下或向右走,目的地是右下角的格子。 你每走过一个格子,就把格子上的苹果都收集起来。然后你从右下角走回左上角的格子, 每次只能向左或是向上走,同样的,走过一个格子就把里面的苹果都收集起来。 最后,你再一次从左上角走到右下角,每过一个格子同样要收集起里面的苹果 (如果格子里的苹果数为 0,就不用收集)。求你最多能收集到多少苹果。

注意:当你经过一个格子时,你要一次性把格子里的苹果都拿走。

限制条件:1 <N, M <= 50;每个格子里的苹果数量是 0 到 1000(包含 0 和 1000)。

如果我们只需要从左上角的格子走到右下角的格子一次,并且收集最大数量的苹果, 那么问题就退化为 “中级” 一节里的那个问题。将这里的问题规约为 “中级” 里的简单题, 这样一来会比较好解。让我们来分析一下这个问题,要如何规约或是修改才能用上 DP。 首先,对于第二次从右下角走到左上角得出的这条路径, 我们可以将它视为从左上角走到右下角得出的路径,没有任何的差别。 (即从 B 走到 A 的最优路径和从 A 走到 B 的最优路径是一样的)通过这种方式, 我们得到了三条从顶走到底的路径。对于这一点的理解可以稍微减小问题的难度。 于是,我们可以将这 3 条路径记为左,中,右路径。对于两条相交路径(如下图):

Dynamic Programming: From novice to advanced

在不影响结果的情况下,我们可以将它们视为两条不相交的路径:

Dynamic Programming: From novice to advanced

这样一来,我们将得到左,中,右 3 条路径。此外,如果我们要得到最优解, 路径之间不能相交(除了左上角和右下角必然会相交的格子)。因此对于每一行 y( 除了第一行和最后一行),三条路径对应的 x 坐标要满足:x1[y] < x2[y] < x3[y]。 经过这一步的分析,问题的 DP 解法就进一步地清晰了。让我们考虑行 y, 对于每一个 x1[y-1],x2[y-1]和 x3[y-1],我们已经找到了能收集到最多苹果数量的路径。 根据它们,我们能求出行 y 的最优解。现在我们要做的就是找到从一行移动到下一行的方式。 令 Max[i][j][k]表示到第 y-1 行为止收集到苹果的最大数量, 其中 3 条路径分别止于第 i,j,k 列。对于下一行 y,对每个 Max[i][j][k] 都加上格子 (y,i),(y,j) 和(y,k)内的苹果数量。因此,每一步我们都向下移动。 我们做了这一步移动之后,还要考虑到,一条路径是有可能向右移动的。 (对于每一个格子,我们有可能是从它上面向下移动到它, 也可能是从它左边向右移动到它)。为了保证 3 条路径互不相交, 我们首先要考虑左边的路径向右移动的情况,然后是中间,最后是右边的路径。 为了更好的理解,让我们来考虑左边的路径向右移动的情况,对于每一个可能的 j,k 对(j<k), 对每个 i(i<j),考虑从位置 (i-1,j,k) 移动到位置(i,j,k)。处理完左边的路径, 再处理中间的路径,最后处理右边的路径。方法都差不多。

用于练习的 topcoder 题目:

  • MiniPaint - SRM 178 Div 1

其它

当阅读一个题目并且开始尝试解决它时,首先看一下它的限制。 如果要求在多项式时间内解决,那么该问题就很可能要用 DP 来解。遇到这种情况, 最重要的就是找到问题的 “状态” 和“状态转移方程”。(状态不是随便定义的, 一般定义完状态,你要找到当前状态是如何从前面的状态得到的, 即找到状态转移方程)如果看起来是个 DP 问题,但你却无法定义出状态, 那么试着将问题规约到一个已知的 DP 问题 (正如“高级” 一节中的例子一样)。


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Convergence Culture

Convergence Culture

Henry Jenkins / NYU Press / 2006-08-01 / USD 30.00

"Convergence Culture" maps a new territory: where old and new media intersect, where grassroots and corporate media collide, where the power of the media producer, and the power of the consumer intera......一起来看看 《Convergence Culture》 这本书的介绍吧!

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码

正则表达式在线测试
正则表达式在线测试

正则表达式在线测试