回文

回文半径

文字列 \(S\) が文字 \(s_i\) の前後で回文であるとき、奇数長の回文であれば前後\(r\) 個の文字で \(s_{i+r} = s_{i-r}\) が成り立つ。
\(r\) の最大値を回文半径という。最小半径は1。
言い換えると、回文半径は位置 \(i\) の前後で作れる最長回文の長さを表す。
[1]:
def palindrome_radius(S):
    rad = [1 for _ in S]
    n = len(S)
    for i in range(n):
        r = 0
        while i-r >= 0 and i+r < n and S[i+r] == S[i-r]:
            r += 1
        rad[i] = r
    return rad

例えば、文字列 “abbaaa” に含まれる奇数長回文の回文半径は次の通り。

[2]:
palindrome_radius("abbaaa")
[2]:
[1, 1, 1, 1, 2, 1]
このままでは、“abba” のような偶数回文が検出されない。
これを検出する方法として、もとの文字列の各文字間にダミー文字を挟む方法がある。
[3]:
S = "abbaaa"
T = "$" + "$".join(S) + "$"
print(T)

rad = palindrome_radius(T)
print(rad)
$a$b$b$a$a$a$
[1, 2, 1, 2, 5, 2, 1, 2, 3, 4, 3, 2, 1]
上記コードの \(rad_{2i+1}\) は奇数長回文、\(rad_{2i}\) (両端を除く) は偶数長回文に対応する。つまり、偶奇両方の最長回文を検出可能。
求めた回文半径 \(rad_i\) は、次のようにダミー文字を含む回文半径となる。
奇数長回文 “$a$a$a$”, 回文半径4
偶数長回文 “$a$b$b$a$”, 回文半径5

ダミー文字のない回文を得るには、2文字目から1飛ばしで文字列を取り出せばよい。

回文半径を利用して、文字列の連続部分列で構成される最長の回文を \(\mathcal{O}(|S|^2)\) で求めることができる。
なお、このような最長回文はManacherのアルゴリズムでより高速に求められる。
[4]:
def longest_palindrome(S):
    T = "$" + "$".join(S) + "$"
    rad = [1 for _ in T]
    n = len(T)
    for i in range(n):
        r = 1
        while i-r >= 0 and i+r < n and T[i+r] == T[i-r]:
            r += 1
        rad[i] = r

    mi = 0
    mr = 0
    for i, r in enumerate(rad):
        if mr < r:
            mi = i
            mr = r
    return T[mi-mr+1:mi+mr][1::2]
[5]:
print(longest_palindrome("mississippi"))
print(longest_palindrome("1337"))
ississi
33

Manacher

https://snuke.hatenablog.com/entry/2014/12/02/235837

文字列 \(S\)\(i\) 番目の文字 \(s_i\) に対する回文半径の配列 \(r_1, r_2, \ldots, r_n\) を、計算量 \(\mathcal{O}(|S|)\) で求めるアルゴリズム。

アルゴリズム

奇数長回文を含む文字列 “babacabac” の連続部分列を考える。
左から3文字目で回文 “aba” (半径2)、5文字目で回文 “abacaba” (半径4) が検出される。
このとき回文の対称性より、5文字目の回文判定を終えた直後に以下のことがわかる。
  • 6文字目は 4文字目の結果(回文半径1)が3未満なので、回文半径 1

  • 7文字目は 3文字目の結果(回文半径2)が2以上なので、回文半径 2 以上

  • 8文字目は 2文字目の結果(回文半径2)が1以上なので、回文半径 1 以上

この場合、回文半径が定まる文字をスキップすることができる。
一般化すると次の通り。
  • \(i\) 文字目の回文半径が \(r_i\) に定まったとき、\(k \in [1, r_i]\) について次のことがわかる。

    • \(k+r_{i-k} < r_i\) ならば、\(i+k\) 文字目の回文半径は \(r_{i+k} = r_{i-k}\) で確定する。\((k+r_{i+k} < r_i)\)

    • そうでない場合は \(i+k\) 文字目の回文半径を \(r_{i+k} \geq r_i - k\) とする。

実装

偶数長の回文を考慮して実装する。

\(k+r_{i-k} < r_i\) が偽となった時点で次のステップに進んでいるが、それでも十分回文判定を減らすことができ、計算量 \(\mathcal{O}(|S|)\) で求めることができる。

[6]:
def longest_palindrome(S):
    T = "$" + "$".join(S) + "$"
    rad = [1 for _ in T]
    longest = ""
    n = len(T)
    r = 1
    for i in range(n):
        r = rad[i]
        while i-r >= 0 and i+r < n and T[i+r] == T[i-r]:
            r += 1
        rad[i] = r

        # Manacher
        k = 1
        while i-k >= 0 and k + rad[i-k] < rad[i]:
            rad[i+k] = rad[i-k]
            k += 1
        if i + k < n:
            rad[i+k] = rad[i] - k
        i += k

    mi = 0
    mr = 0
    for i, r in enumerate(rad):
        if mr < r:
            mi = i
            mr = r
    return T[mi-mr+2:mi+mr:2]
[7]:
print(longest_palindrome("mississippi"))
print(longest_palindrome("1337"))
ississi
33