以最长公共子序列问题理解动态规划算法(DP)

一、动态规划(Dynamic Programming)

动态规划方法通常用于求解最优化问题。我们希望找到一个解使其取得最优值,而不是所有最优解,可能有多个解都达到最优值。

二、什么问题适合DP解法

如何判断一个问题是不是DP问题呢?适合DP求解的最优化问题通常具有以下两个特征:

最优子结构

如果一个问题的最优解包含其子问题的最优解,我们就称此问题具有最优子结构性质。

以0-1背包问题(给你一个可装载重量为W的背包和N个物品,每个物品有重量和价值两个属性。其中第i个物品的重量为wt[i],价值为val[i],现在让你用这个背包装物品,最多能装的价值是多少?)为例说明什么是子问题:假设你已经选择了第i个物品,那么问题就变为”如何在可装载重量为W-wt[i]和剩下的N-1个物品的情况下求最多能装的价值“,这就是原问题的一个子问题。

我们可以通过以下步骤来判断一个问题是否具有最优子结构性质:

​ 1.先做出一个选择。做出这次选择会产生一个或多个待解的子问题。

​ 2.假定第1步的选择是最优解的一个选择,确定第1步的选择会产生哪些子问题

​ 3.判断以下结论是否成立:作为构成原问题的最优解的组成部分,每个子问题的解就是它本身的最优解。证明这个结论的最好办法是反证法,还是以上述的0-1背包问题为例:如果存在一个选择方案使得子问题”在可装载重量为W-wt[i]和剩下的N-1个物品的情况下求最多能装的价值“的解大于原来的解,那么用这个选择方案替换原来的选择方案,就存在一个整体选择方案使得原问题”在可装载重量为W和N个物品的情况下求最多能装的价值“解大于最优解,这就与最优解的定义矛盾了,因此结论成立。

重叠子问题

如果算法反复求解相同的子问题,就称最优化问题具有重叠子问题性质。

三、DP问题求解思路

下面以力扣的1143题最长公共子序列为例讲解DP问题的求解思路。

LCS

一个简单暴力的算法是穷举出两个字符串的所有子序列,但是这种方法复杂度太高,显然不可行。

如果用DP的思想,就要寻找递推关系式,只要递推关系式出来了,写出代码就是很简单的事了。

首先我们应该分析问题的最优子结构。最长公共子序列(Longest Common Subsequence, LCS)问题的最优子结构:设有两个字符串A,B,其中A=a1,a2,...,am,有m个字符;B=b1,b2,...,bn,有n个字符。C为字符串A和B的一个LCS,C=c1,c2,...,ck有k个字符。那么,很容易有以下结论:

如果am=bn,则必有am=bn=ck,且Zk−1(表示由Z的前k-1个字符组成的字符串)是Am−1和Bn−1的一个LCS;

如果am≠bn,am≠ck,则表示Z是Am−1和B的一个LCS;

如果am≠bn,bn≠ck,则表示Z是A和Bn−1的一个LCS;

那么,原问题的求解过程就被划分为三种情况讨论,定义函数f(i,j)为由A的前i个字符组成的字符串和由B的前j个字符组成的字符串的LCS长度。基于这三种情况我们可以写出动态规划的递推式:

f(i,j)=⎧⎩⎨0,1+f(i−1,j−1),max(f(i,j−1),f(i−1,j)),若i=0orj=0若ai=bj若ai≠bj

当i=0或者j=0时,意味着字符串为空串,这时返回0;

当ai=bj,也就是最后一个字符相同时,那么这个字符一定在LCS里,LCS长度加1,所以返回值为1+LCS(Ai−1,Bj−1)​;

当ai≠bj,也就是最后一个字符不同时,那么这两个字符至少有一个字符不在LCS里,若ai不在LCS,结果为LCS(Ai−1,Bj);若bj不在LCS,结果为LCS(Ai,Bj−1);若ai,bj均不在LCS,结果为LCS(Ai−1,Bj−1);因为我们不知道是哪种情况,所以我们返回三种情况的最大值。又因为LCS(Ai−1,Bj−1)的结果一定是不大于LCS(Ai−1,Bj)的,毕竟Bj−1比Bj少了一个字符嘛,同样也是不大于LCS(Ai,Bj−1)的,所以就省去了两个字符均不在LCS的情况。

1. 一个递归解法

基于上述的DP递推式,我们写出LCS的一个递归解法:

    def longestCommonSubsequence(text1: str, text2: str) -> int:

        len1,len2 = len(text1),len(text2)


        # 函数返回text1的前i个字符组成的字符串与text2的前j个字符组成的字符串的LCS长度

        def dp_core(i, j):

            if i == 0 or j == 0:

                return 0

            if text1[i-1] == text2[j-1]:

                ret = 1 + dp_core(i-1,j-1)

            else:

                ret = max(dp_core(i, j-1), dp_core(i-1, j))

            return ret


        return dp_core(len1,len2)

2. 算法优化之消除重叠子问题

通过观察上述f(i,j)递推式,可以发现,我们在分别求解f(i,j−1),f(i−1,j)时,有可能都会求f(i−1,j−1)的值,也就是会重复求解子问题。DP问题里,发现了一个重叠子问题,就有非常多个子问题,消除子问题是提高DP算法效率的关键点。

使用备忘录的递归方法

怎么消除子问题呢,我们很容易想到的就是设置一个备忘录数组把计算过的值存起来。每次求解之前都先检查是否计算过,已计算的就直接返回存储的值。

    def longestCommonSubsequence(text1: str, text2: str) -> int:

    # 备忘录,储存已计算的值

        memo = {}

        len1,len2 = len(text1),len(text2)

        # 初始状态

        for i in range(len1+1):

            memo[(i,0)] = 0

        for j in range(len2+1):

            memo[(0,j)] = 0

        def dp_core(i, j):

            if (i,j) in memo:

                return memo[(i,j)]

            if text1[i-1] == text2[j-1]:

                memo[(i,j)] = 1 + dp_core(i-1,j-1)

            else:

                memo[(i,j)] = max(dp_core(i, j-1), dp_core(i-1, j))

            return memo[(i,j)]

        return dp_core(len1,len2)

自底向上法的非递归方法

递归的代码虽然思路清晰,可读性较高,但是递归函数会有额外的调用开销。递归的思想是自顶向下,但是最先返回计算值的子问题却是最下层的子问题,上层问题的解依赖于下层子问题的解。因此,理解了这个关系,我们可以抛弃递归,自底向上地计算子问题。

对于上边LCS的f(i,j)递推式来说,计算f(i,j)的值的时候,我们需要先求出f(i,j−1),f(i−1,j)或者f(i−1,j−1),其依赖于下层几个子问题的解。如果知道了这几个子问题的解,那么就可以推出f(i,j)的解。也就是说,我们可以先计算下层子问题的解。

基于自底向上的思想,我们就可以从i=0,j=0开始计算,一直向上计算到i=len(text1),j=len(text2)时,就是我们要求的最优解了。

# 自底向上版本

    def longestCommonSubsequence(text1: str, text2: str) -> int:

        # 记录最优解的值

        memo = {}

        len1,len2 = len(text1),len(text2)

# 初始状态

        for i in range(len1+1):

            memo[(i,0)] = 0

        for j in range(len2+1):

            memo[(0,j)] = 0

        for i in range(1,len1+1):

            for j in range(1,len2+1):

                if text1[i-1] == text2[j-1]:

                    memo[(i,j)] = 1 + memo[(i-1,j-1)]

                else:

                    memo[(i,j)] = max(memo[(i, j-1)], memo[(i-1, j)])

        return memo[(len1,len2)]

通常情况下,如果每个子问题都需要求解一次,自底向上的动态规划算法会比带备忘录的自顶向下算法快,因为自底向上算法没有递归调用的开销。

对于有些DP问题,还可以使用状态压缩来优化备忘录所占用的空间,有兴趣的可以参看这篇文章,这里略去。

3. 重构最优解

有时候题目不仅让我们求出最优解的值,还需要重构出最优解。对于LCS问题而言,就是不仅要求出LCS的长度,还要求出这个LCS序列。那么,我们就需要另外开辟一个空间来记录我们求解最优解过程中所做的每一个选择。

在自底向上的非递归算法上加上记录选择的代码后为:

# 记录最优解的自底向上版本

def longestCommonSubsequence(text1: str, text2: str) -> int:

    # 记录最优解的值

    memo = {}

    # 记录产生最优解时的选择

    choices = {}

    len1,len2 = len(text1),len(text2)

    # 初始状态

    for i in range(len1+1):

        memo[(i,0)] = 0

    for j in range(len2+1):

        memo[(0,j)] = 0

    for i in range(1,len1+1):

        for j in range(1,len2+1):

            if text1[i-1] == text2[j-1]:

                memo[(i,j)] = 1 + memo[(i-1,j-1)]

                choices[(i,j)] = 'ij--'

            elif memo[(i, j-1)] >= memo[(i-1, j)]:

                memo[(i, j)] = memo[(i, j-1)]

                choices[(i,j)] = 'j--'

            else:

                memo[(i, j)] = memo[(i-1, j)]

                choices[(i, j)] = 'i--'

    return memo[(len1,len2)], choices

上述代码的choices字典就是记录求解每个子问题最优解时所做的选择,对于LCS问题来说,记录的就是每一步字符比较的结果。

我们可以用以下函数来重构并打印出最优解,即最长公共子序列。

'''

choices: 动态规划算法求解最优解时每一步的选择

text1: 原输入字符串

i: 表示字符串text1的前i个字符

j: 表示字符串text2的前j个字符

'''

def print_LCS(choices, text1, i, j):

    if i == 0 or j == 0:

        return

    if choices[(i,j)] == 'ij--':

        print_LCS(choices, text1, i - 1, j - 1)

        print(text1[i-1]) # 因为字符串中第i个字符的索引为i-1

    elif choices[(i,j)] == 'i--':

        print_LCS(choices, text1, i - 1, j)

    else:  # choices[(i, j)] == 'j--'

        print_LCS(choices, text1, i, j - 1)

示例代码

s1 = 'abcdefg'

s2 = 'acf'

max_length, choices = longestCommonSubsequence(s1,s2)

print(max_length)

print_LCS(choices, s1, len(s1), len(s2))

上述代码运行的结果为:

3

a

c

f

亚马逊测评 www.yisuping.com

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

推荐阅读更多精彩内容