寻找最长回文子序列并占用更少的内存

12
我正在尝试解决Cormem的《算法导论第三版》(第405页)中的动态规划问题,其要求如下:
一个回文串是指某个字母表上的非空字符串,它从前往后读和从后往前读都一样。例如,长度为1的所有字符串、civic、racecar和aibohphobia(对回文串的恐惧)都是回文串。
设计一个高效的算法,找到给定输入字符串中作为子序列的最长回文串。例如,对于输入character,你的算法应该返回carac。
嗯,我可以用两种方法来解决它:
第一种解决方案:
该字符串的最长回文子序列(LPS)只是其本身和其反转的最长公共子序列。 (我在解决另一个相关问题(要求序列的最长递增子序列)后构建了此解决方案)。 由于它只是LCS变体,因此它也需要O(n²)时间和O(n²)内存。
第二个解决方案: 第二个解决方案有点更加详细,但也遵循一般的LCS模板。 它来自以下递归:
lps(s[i..j]) = 
    s[i] + lps(s[i+1]..[j-1]) + s[j], if s[i] == s[j];
    max(lps(s[i+1..j]), lps(s[i..j-1])) otherwise

计算最长回文子序列长度的伪代码如下:
compute-lps(s, n):

    // palindromes with length 1
    for i = 1 to n:
        c[i, i] = 1
    // palindromes with length up to 2
    for i = 1 to n-1:
        c[i, i+1] = (s[i] == s[i+1]) ? 2 : 1

    // palindromes with length up to j+1
    for j = 2 to n-1:
        for i = 1 to n-i:
            if s[i] == s[i+j]:
                c[i, i+j] = 2 + c[i+1, i+j-1]
            else:
                c[i, i+j] = max( c[i+1, i+j] , c[i, i+j-1] )

如果我想有效地构建lps(因为我需要表格上的所有单元格),仍然需要O(n²)的时间和内存。通过分析相关问题,例如LIS,可以使用比LCS类似方法更少的内存来解决(LIS可用O(n)内存解决),我在想是否也可能将其解决为O(n)内存。
LIS通过链接候选子序列实现了这个限制,但对于回文字符串来说,这更难了,因为这里重要的不是子序列中的前一个元素,而是第一个元素。是否有人知道是否可能做到这一点,或者以前的解决方案已经是内存最优的?

1
给定输入的 character,你的算法应该返回 ara,而不是 carac - Ergwun
3
据我理解,回文数可以来自保持原始顺序的任何子集,而不仅仅是连通子集。 - Thies Heidecke
@Ergwun:carac 确实在 c h arac ter 中。 - btilly
啊,我没有意识到“子序列”和“子串”的区别 - 谢谢。 - Ergwun
子序列和子串的区别在于子串的元素必须在原始序列上连续出现,而在子序列中,唯一的要求是它们的原始顺序得以保留。 - Luiz Rodrigo
你怎么循环从 i = 1 到 n - i 呢?我错在哪里了? - AndroidDev
2个回答

6
这里有一个非常内存高效的版本。但我没有证明它始终是 O(n)内存。(通过预处理步骤,它可以比O(n2) CPU更好,尽管O(n2)是最坏情况。)
从最左边的位置开始。对于每个位置,跟踪一个表,记录您可以生成长度为1、2、3等的反射子序列的最远点。(意思是在我们的点左侧的子序列被反射到右侧。)对于每个反射子序列,我们存储指向子序列下一部分的指针。
当我们向右工作时,我们从字符串的RHS开始搜索当前元素的任何出现,并尝试使用这些匹配来改进我们先前的界限。完成后,我们查看最长的镜像子序列,我们可以轻松构造最佳回文。
让我们考虑一下character
1. 我们从字母'c'开始,我们的镜像子序列到达了超出字符串末尾的一对(0,11)。 2. 接下来考虑位置1处的'c'。我们形式为(length,end,start)的最佳镜像子序列现在为[(0,11,0),(1,6,1)]。(我将略去您需要生成的链接列表,以实际找到回文。) 3. 接下来考虑位置2处的h。我们没有改进界限[(0,11,0),(1,6,1)]。 4. 接下来考虑位置3处的a。我们将边界改进为[(0,11,0),(1,6,1),(2,5,3)]。 5. 接下来考虑位置4处的r。我们将边界改进为[(0,11,0),(1,10,4),(2,5,3)]。(这是链表会有用的地方。)
通过剩余的列表,我们没有改进那组界限。
所以我们最终得到的最长镜像列表长度为2。我们将遵循链接列表(我在此描述中未记录)找到它是ac。由于该列表的末尾位于位置(5,3),因此我们可以翻转该列表,插入字符4,然后附加该列表以获得carac
一般来说,所需的最大内存是存储所有最大镜像子序列长度以及存储这些子序列的链表所需的内存。通常这将是一个非常小的内存量。
在经典的内存/CPU权衡中,您可以预处理列表一次,时间复杂度为O(n),生成O(n)大小的哈希数组,用于指示特定序列元素出现的位置。这可以让您扫描“使用此配对改进镜像子序列”,而无需考虑整个字符串,这对于较长的字符串通常会节省大量CPU时间。

如果您只想计算最佳回文的长度,那么您的方法非常节省内存。尽管在最坏情况下需要 O(n) 的内存,但更好的上限是 O(L),其中 L 是最佳回文的长度。这只是一个猜测,但我认为长度为 n 的字符串的预期 L 大约为 O(sqrt(n)) - Luiz Rodrigo
但是我并不是很理解我们如何使用得到的列表重构最佳回文串。 - Luiz Rodrigo
@Luiz Rodrigo:在我的代码中有(length, end, start),你需要加上一个(length, end, start, pointer_to_linked_list_for_subsequence)。那个链接列表编码了子序列,当该子序列的尾部也出现在你的数据结构中时可以便宜地共享其部分。我在描述中遗漏了这个连接列表。你需要将它用于构造回文。 - btilly
哦,现在我明白了,这很简单。它确实需要一些内存,但我认为平均表现会很好(我对那些理论方面不是那么擅长)。 - Luiz Rodrigo
@btilly 抱歉,我有点难以理解。character 是一个9个字母的单词。那么为什么你会得到镜像子序列的(0,11)呢? - Forethinker

3
@Luiz Rodrigo的第一个解决方案是错误的:字符串及其反转的最长公共子序列(LCS)不一定是回文。例如:对于字符串CBACB,CAB是该字符串及其反转的LCS,显然不是回文。但是有一种方法可以使其起作用。在构建字符串及其反转的LCS之后,取其左半部分(包括奇数长度字符串的中间字符),并在右侧补充反转的左半部分(如果字符串长度为奇数,则不包括中间字符)。这显然是一个回文,并且可以轻松证明它将是字符串的子序列。对于上述LCS,以这种方式构建的回文将是CAC。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接