摘要:

学习用动态规划解决最长公共子序列、最长公共子串和最长递增子序列的问题。

正文:

(主要是《算法图解》(第9章)的python实践)

1、最长公共子序列(Longest Common Subsequence, LCS)

在前一篇《python(二)》中,学习了如何用动态规划的思想解决数组分堆的问题,接下来查阅了《算法图解》,在第9章“动态规划”了解了更多的动态规划的知识。其中有个问题还比较有意思,叫做“最长公共子序列”。

计算两个list之间的公共子序列,指的是求解两个list的元素中“有顺序的共同部分”,如:list_a = [1, 4, 5, 3, 7];list_b = [3, 1, 5, 7, 9]。首先,如果只讨论公共部分,那么就是它们的交集,即[1, 3, 5, 7]。但是元素‘3’的顺序在两个list中是不一致的,按list_a中的顺序的交集可以写为[1, 5, 3, 7],而按list_b中的顺序的交集可以写为[3, 1, 5, 7],显然,所谓“有顺序的共同部分”就是[1, 5, 7]了,它在list_a和list_b中的顺序是一致的。这个[1, 5, 7]便是一个公共子序列,而两个list之间可以有很多公共子序列,其中元素最多的(最长)便是“最长公共子序列”了,这个解可以不止一个。

我们将list_a的每个元素视为一行,list_b的每个元素视为一列,构成一个矩阵,矩阵的每一个值value[i, j]表示的是list_a从0看到i元素、list_b从0看到j元素为止的最大长度。而所求的最大长度即为整个矩阵的最大值,显然,仍然用动态规划的思路求解,其步骤和《python(二)》基本相似。

(1.1)value的递推公式:

value[i, j] = value[i-1, j-1] + 1, 如果list_a[i]==list_b[j]

value[i, j] = max(value[i, j-1], value[i-1, j]), 如果list_a[i]!=list_b[j]

**理解**)仍然是假设前面问题都已经解决了(不同的i/j),如果list_a第i个元素等于list_b第j个元素,那么显然最大长度就是value[i-1, j-1]+1(i和j同时多考虑了1个);如果list_a第i个元素不等于list_b第j个元素,那就保持之前的最大值状态(注意这里是“保持”,意味着并不要求公共部分是连续的——要求连续是最长公共子串问题),而所谓之前,就是[i, j-1]和[i-1, j](只少看一个元素),因此保持之前最大值就是求这两个节点的最大值。总而言之,value[i, j]的取值只来源于它左、上方相邻的三个节点。

(1.2)边界条件:

考虑list_a的第0个元素时,value[0, :] = 0;

考虑list_b的第0个元素时,value[:, 0] = 0

(1.3)is_take的递推公式:

同《python(二)》,用取数编码(is_take矩阵)来取数,递推公式(思路上和value的递推是一致的):

is_take[i, j] = 2^(num-i) + is_take[i-1, j-1], 如果list_a[i]==list_b[j]

is_take[i, j] = is_take[i-1, j], 如果list_a[i]!=list_b[j]且value[i-1, j]>value[i, j-1]

is_take[i, j] = is_take[i, j-1], 如果list_a[i]!=list_b[j]且value[i, j-1]>value[i-1, j]

(1.4)python代码实现

链接如下,代码还包含了后面两个问题的内容:


longest_common_subsequence.pygithub.com

函数LCS(list_a, list_b)的输入可以是字符串、list和np.array,如下是两个测试结果:

test1:




python头歌最长公共子串 python最长公共子序列详解_python 矩阵元素变为整形


'fish'和'fosh'的LCS是['f', 's', 'h'],str输入时,没有被选出来的元素设为空值(输出是一个np.array)

test2:


python头歌最长公共子串 python最长公共子序列详解_python 矩阵元素变为整形_02

list_a和list_b的LCS是[1, 5, 7],list输入时,没有被选出来的元素设为nan(输出是一个np.array)


2、最长公共子串

最长公共子串只是在最长公共子序列的基础上,增加了一个“连续性”的要求,也就是说,公共部分不仅要保持在两个原序列中的顺序,还必须是连续出现的。很显然,前述的test1的解就只能变为['s', 'h']了,而test2甚至只有单元素解(交集元素)。

仍然可以用动态规划的思路求解,只需要稍微修改一下LCS的递推公式:

(2.1)value的递推公式:

value[i, j] = value[i-1, j-1] + 1, 如果list_a[i]==list_b[j]

value[i, j] = 0, 如果list_a[i]!=list_b[j]

**理解**)仍然是假设前面问题都已经解决了(不同的i/j),如果list_a第i个元素等于list_b第j个元素,那么显然最大长度就是value[i-1, j-1]+1,如果list_a第i个元素不等于list_b第j个元素,由于有“连续”条件约束,因此此时value[i, j]为0(意味着重新开始看最大长度)。

(2.2)is_take的递推公式:

is_take[i, j] = 2^(num-i) + is_take[i-1, j-1], 如果list_a[i]==list_b[j]

is_take[i, j] = 0, 如果list_a[i]!=list_b[j]

(2.3)python代码实现:

代码见前述链接。test:


python头歌最长公共子串 python最长公共子序列详解_python求解公共子序列_03

'HISH'和'VISTA'的最大公共子串,是中间的['I', 'S']

3、最长递增子序列

问题描述:求一个序列的最长递增子序列(Longest Increased Subsequence,LIS)之前,先看递增子序列的定义:设输入序列为包含了n个元素的序列list_input=[a1, a2, …, an],它的递增子序列是这样一个子序列IS = [a[k1], a[k2], …, a[km]],其中k1 <k2 <… <km且a[K1] <a[k2] <… <a[km],而最大递增子序列就是这些IS中元素最多(最长)的那个。

乍一看似乎和问题1、2没有太多关系,但实际上可以将问题巧妙地转换一下,首先得到输入序列list_input的从小到大排好顺序的序列list_input_sorted,然后去计算list_input和list_input_sorted的最长公共子序列,这样结果就是LIS了。


python头歌最长公共子串 python最长公共子序列详解_动态规划_04


test:


python头歌最长公共子串 python最长公共子序列详解_python 矩阵元素变为整形_05

输入的list_input,最长递增子序列的长度是5

(**LCS函数的一个bug)

在进行LIS的测试中,发行了LCS函数的一个bug,前面的test的结果是正确的,但是下面这个test的结果是不完整的。对于这个list_input而言,[4, 10, 46]也是一个LIS的解,但是并没有被程序查找出来:


python头歌最长公共子串 python最长公共子序列详解_python n个list如何组成矩阵_06


这个问题出在了is_take矩阵上,当list_a[i]!=list_b[j]且value[i-1, j]==value[i, j-1]时,如果此时is_take[i-1, j]!=is_take[i, j-1],表明is_take[i, j]应该要同时选取这两个值,但是由于这个节点的is_take取值只能是一个,所以总是会舍弃掉一个解。这个情况简单说来,就是list_input里的两个相邻的数在list_input_sorted中也相邻,并且它们在自身节点上都被选择了。暂时还没有想到简单的解决方案……

4、动态规划小结

《算法图解》最后总结了一下动态规划应用的注意点,这里摘抄一下:

(a)需要在给定约束条件下优化某种指标时,动态规划很有用;

(b)问题可分解为独立的离散子问题时,可使用动态规划来解决;

(c)每种动态规划解决方案都涉及网格;

(或者说是矩阵,其行、列都会有对应的实际意义)

(d)单元格中的值通常就是你要优化的值;

(e)每个单元格都是一个子问题,因此你需要考虑如何将问题分解为子问题;

(f)没有放之四海皆准的计算动态规划解决方案的公式。

再加一点自己的学习感想,在已知了一些算法后,如何将问题“转化”为已知算法,是一个要努力学习的技巧。