摘要:
学习用动态规划解决最长公共子序列、最长公共子串和最长递增子序列的问题。
正文:
(主要是《算法图解》(第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:
'fish'和'fosh'的LCS是['f', 's', 'h'],str输入时,没有被选出来的元素设为空值(输出是一个np.array)
test2:
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:
'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了。
test:
输入的list_input,最长递增子序列的长度是5
(**LCS函数的一个bug)
在进行LIS的测试中,发行了LCS函数的一个bug,前面的test的结果是正确的,但是下面这个test的结果是不完整的。对于这个list_input而言,[4, 10, 46]也是一个LIS的解,但是并没有被程序查找出来:
这个问题出在了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)没有放之四海皆准的计算动态规划解决方案的公式。
再加一点自己的学习感想,在已知了一些算法后,如何将问题“转化”为已知算法,是一个要努力学习的技巧。