Longest Palindrome (最长回文子串)

一个序列(字符串)S=a1a2...an 的倒置 S' 为 anan-1...a1。而 S 的子串定义为 S 中任何连续的一部分,如 aiai+1...a是 S 的一个子串,但aiai+2... 则不是 S 的子串。如果一个序列 S=S',则 S 称为回文序列。本文接下来将研究一个有趣的问题:给定一个序列 S,找出 S 中最长的回文子串。

 

在详细分析各个算法之前,先给出一个概览:简单的暴力算法时间复杂度为 Ɵ(n3)。经过仔细分析,可以通过动态规划把复杂度降至 O(n2)。但是,令人兴奋的是,存在线性复杂度,即 O(n) 的算法!

 

一、暴力算法

 

按照惯例,先分析一下暴力的算法:对 S 的每个子串,先判断其是否为回文,如果是,则跟已知的最长回文子串相比较,并保存其中最长的那个。

set longest_parlindrome to empty string for each substring P of the string S do if P is a palindrome if P.length > longest_parlindrome.length then longest_parlindrome := P end if end if end for output longest_parlindrome 

长度为 n 的字符串一共有 O(n2) 个子串,判断一个子串是否为回文串平均需要 O(n) 时间,因此,该算法的复杂度为 O(n3) 。当然,可以在算法中加入剪枝,例如,当子串 P 的长度比已知最长回文子串的长度小时,可以不必进行检查是否为回文串了。但这个并不能带来时间复杂度上质的变化。

 

二、动态规划

 

仔细分析该暴力算法,可以发现,该算法中存在很多重复计算。例如,当子串 P 不是回文时,xPy 也不可能是回文。再如,子串 xPx 是否为回文当且仅当 P 是回文。这后面的例子有点动态规划的味道了。沿着这个方向,可以比较容易的设计出动态规划的算法。首先,用 LP[i][j] 表示子串 aiai+1...aj 是否为回文。则

 

     LP[i][j] = true,如果 i >= j,即单字符的串和空串都是回文;

     LP[i][j] = true,如果 S[i] = S[j] 并且 LP[i+1][j-1] = true;

     否则,LP[i][j] = false。

 

在填动态规划表时,按子串的长度从小到大进行。

/* Use LP_start and LP_end to record the start and ending positions of the found longest parlindrome. */ Assume S is not empty. If S is empty, just output S and return. LP_start := 1 LP_end := 0 for i from 2 to n do LP[i][i-1] := true end for for m from 1 to n do for i from 1 to n+1-m do j := i+m-1 if i = j or (S[i] = S[j] and LP[i+1][j-1] = true) then LP[i][j] := true if LP_end - LP_start + 1 < m then LP_end := j LP_start := i end if else LP[i][j] := false end if end for end for output S[LP_start ~ LP_end] 

可以看出,这个算法的时间复杂度为 O(n2)。相比较暴力算法,速度的提升来自检查每个子串是否为回文只需要 O(1) 的时间,而这在暴力算法中,最坏时平均是 O(n)。O(n2) 看起来好像是极限了,因为,毕竟搜索空间是 Ɵ(n2),即有 Ɵ(n2) 个子串。如果有线性复杂度的算法,那么将是比较神奇的,因为,这意味着不需要逐一检查每个子串!事实上,这个真的可以有,而已知有两个完全不同的算法达到线性复杂度,从而也是最优的算法:一个基于后缀树 (suffix tree);一个基于回文子串一个性质。

 

三、基于后缀树的线性算法

 

先来看看基于后缀树的算法。首先需要了解一下最长公共扩展:给定两个字符串 S1 和 S2,以及 S1 的一个后缀 t1 和 S2 的一个后缀 t2,求 t1 和 t2 的最长公共前缀。这个问题之所以被称为最长公共扩展,是因为,如果 t1 的第一个字符在 S1 中位于 q1,t2 的第一个字符在 S2 中位于 q2,那么,最长公共扩展问题即为,求从 t1 开始在 S1 中向后扩展,同时从 t2 开始在 S2 中向后扩展,两个扩展中最长的公共部分。当 S1 和 S2 给定后,每个位置对 (q1,q2) 代表一个查询,查询对应着位置 q1 和 q2 的最长公共扩展。 举个例子,令 S1=abcdef,S2=efbcd,q1=2,q2=3,则 (q1,q2) 的最长公共扩展为 bcd。

 

现在,令 S1=S 和 S2=S',即 S 的倒置,n 为 S 的长度。如果有一个长度为 2k 的回文串是最大的,即没有更长的回文串包含它,若假设其中心的两个字符右的那个在 S 中位于 q+1,则中心左边的那个字符在 S' 中位于 n-q+1,并且,(q+1,n-q+1) 的长度为 k。反之,如果 (q+1,n-q+1) 的长度为 k,则在 S 中有一个长度为 2k 的,中心的左边位于 q 的偶数回文串。因此,如果我们遍历每个中心的左边,我们就可以找出每个中心对应的最长偶数回文的长度。用相似的方法可以求得每个中心对应的最长奇数回文串的长度,从而在这所有的 2n-1 个回文子串中找出最长那个。

 

现在,有 2n-1 个中心,因此,一共有 2n-1 个查询。如果算法的复杂度为 O(n),则每个查询须在 O(1) 时间内完成。这事儿看起来很悬,但真的能够做到!这是因为,存在一个神奇的算法,可以在 O(n) 的时间内建立起长度为 n 的字符串的后缀树。而且,一旦预先建立好 S1 和 S2 的后缀树,则对每个查询 (q1,q2),可以在 O(1) 的时间内确定其最长公共扩展。具体的算法参见《Algorithms on Strings, Trees and Sequences》第 198 页。这里,我摘抄了其中计算偶数回文的算法。

 

/* Algorithm to compute all even length maximal parlindrome in S */ 1. Create the reverse string S' from S and preprocess the two strings so that any longest common extension query can be solved in constant time. 2. For each q from 1 to n — 1, solve the longest common extension query for the index pair (q + 1,n — q + 1) in S and S', respectively. If the extension has nonzero length k, then there is a maximal palindrome of radius k left-centered at q.  

 

四、一个简单的线性算法

 

个人认为后缀树是一个比较“重型”的数据结构,功能虽然强大,但线性建立后缀树的算法很是复杂。用它来解决最长回文子串问题,有点像杀鸡用了牛刀。事实上,有个非常简单的算法,可以在线性时间内计算出所有中心的最大回文串,从而求出最长的那个回文串。这个算法是本人在某国外的网站上看到的,链接在这里。因为未在任何中文网站上见到过,本人也不敢独享,于是在因此向大家详细介绍该算法。

 

 

先来说说回文的中心。对于奇数长度的回文,这个中心是惟一的;但对于偶数回文来说,从字符串本身看来,中心有两个相同的字符。当然,也可以理解为偶数回文的中心落在两个相邻的字符之间。为了统一起见和方便叙述,我们想像在 S 的开头、末尾和每两个相邻的字符之间插入一个特殊的字符(空隙),奇数回文的中心落在某个常规字符上,偶数回文的中心落在特殊字符(空隙)上。而且,在索引回文的中心的位置时,把特殊字符也计算在内。例如,假设 S = abcbbe,则把 S 想像成 _a_b_c_b_b_e_,其中下划线代表特殊字符。于是,回文 bcb 的中心为 c,其中位置索引为 6,而不是3,因为当我们索引中心时,把特殊字符也计算在内;另一个回文  bb 的中心为 _,其中心位置索引为 9。这样,所有子串,都有一个惟一的中心索引位置。不过,我们在索引 S 本身的字符位置时,依然只是参照实际上 S,而非想像中的带有特殊字符的 S。例如,当我们说 S[1],就只是指 S 的第一个字符,即 a,而 S[1~3] 则指其子串 abc。

 

在说明回文的一些性质前,先来看个例子:S = abababa。这个例子中,S[1~3],即开头的 aba 是一个回文,同时也是更大的回文,S[1~5] 及 S 本身的子串。对比 S[1~3]=aba 和 S[1~5]=ababa,可以发现,S[3~5] = S[1~3],且其关于 S[1~5] 的中心对称。这不是偶然的。可以证明,如果 X 是 Y 的一个真回文子串(即 X 不等于 Y),则存在它的关于 Y 的中心对称的对偶子串 X'=X。在上例中,若Y=S[1~5],X=S[1~3],则 X' = S[3~5]。精确地说,假设回文 Y 的中心位于 p,X 是 Y 的长度为 k 的真回文子串,其中心位于 p-d,则有一个长度也为 k 的真回文子串,其中心位于 p+d。如果用数组 Len[1~2n+1] 表示各个中心的最大回文串,即 Len[i] 表示中心位于 i 的最长的回文,则上述性质说明,

 

如果 回文 Y 的中心位于 p,且以 p-d (d>0) 为中心的最长回文 X 是 Y 的子串,则 Len[p+d] 至少不小于 Len[p-d],即 Len[p+d] >= Len[p-d]。

 

需要强调的是,X 必须是 Y 的子串,否则个不等式不成立。那么,什么时候等号成立呢?一个充分的条件是,或者 X 不是 Y 的前缀,或者 Y 是 S 的后缀。注意,这个条件只是充分的,而非必要条件。基于这个性质和这个充分条件,就可以设计出一个线性时间复杂度的算法了。

 

1. 把当前中心位置指针 p 指向第一个可能的中心,即 p :=1

2. 只要 1 <= p <= 2n+1,执行下面循环

     a) 以 p 为中心,向两边扩展,直到找到以 p 为中心的最长回文,假设其长度为 k

     b) Len[p] := k

     c) 利用上述的性质和找到的最长回文,计算 Len[p+1], Len[p+2],... 的值,直到当前已有的信息不足以继续确定某个 Len[p+h] 的值

     d) p := p+h

3. 返回 Len 中最大的值及其下标(即中心位置)

这个算法是 O(n) 时间复杂度的,虽然现在似乎不是那么明显,但看了下面更为详尽的伪代码,就很清楚了。
p := 1
i := 1
LP_len := 1  // the length of LP found so far
LP_center := 1 // the center of LP found so far

while p <= 2n+1 do
    j := p-i // the pointer extending to left
    // below search the LP centered at p
    while j >= 1 and S[i]=S[j] do
        i := i+1
        j := j-1
    end while

    /* set the length of LP centered at p */
    Len[p] := 2*i - p - 1

    /* track the LP found so far */
    if Len[p] > LP_len then
        LP_len := Len[p]
        LP_center := p
    end if

    /* use the property discussed above to compute values of other slots of Len as possible as we can */
    current_start := j + 1 // the start position of LP centered at p
    p := p + 1 // the next slot of Len to consider
    q := p - 2 // the center of dual of LP centered at p
    while q >= 1 and p <= 2n+1 do
        q_start := (q-Len[q]+1) / 2
        if q_start > current_start or i > n then
            Len[p] := Len[q]
            p := p+1
            q := q-1
        else
            break
        end if
    end while
end while

return the pair (LP_len,LP_center)

在上述伪代码中,可以看出,在内部的两个 while 中,要么递增 i 的值( S 中下一个待访问的元素的下标),要么递增 p (下一个考虑的可能中心),而每个递增及其附加操作的复杂度都为 O(1),因此,总的时间复杂度为 O(n)。值得指出的时,Len 数组记录每个中心的最长回文长度,从而该算法实际上求出所有的最大回文子串,而不仅仅是最长的那个。上述的伪代码是为了方便理解而写的,具体实现的代码可能比这个伪代码还要简单,例如原文给出的Python代码。

 

你可能感兴趣的:(趣味题目)