脑子要烧坏了:使用manache算法查找最长回文子字符串

Posted tyler_download

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了脑子要烧坏了:使用manache算法查找最长回文子字符串相关的知识,希望对你有一定的参考价值。

在面试算法题中,字符串是经常出现的类型。而字符串类型中回文出镜率相当高,在查找回文的问题中出现了一系列相当烧脑但却又精彩纷呈,非常值得研究和欣赏的算法,我们这次研究的mamache算法就是一例。它设计巧妙,而且效率很高,研究它能让人有一种回味无穷的感觉。

所谓回文就是将字符串倒转后字符的排列与原来一样的字符串,例如"aba"。在回文问题中有一个特定类型,那就是从给定字符串中查找最长回文。例如"efabababa"中最长回文子字符串就是从下标为2开始的字符串"abababa",现在问题是给定字符串后,我们如何查找长度最长的回文子串呢。首先我们要做的是判断一个字符串是否为回文,回文的特点是以中心为对称,例如给定回文字符串"ababa",那么它的中心就是下标为2的a,根据对称性以中心为起点,向左挪动一位和向右挪动一位,对应字符必然相同,于是从下标为2的a向左挪动一位那就是下标为1的b,向右挪动一位,那就是下标为3的b,同理向左挪动2位就是下标为0的a,向右挪动2位就会下标为4的a。于是我们得出一个判断回文的方法,以中心点为基础,分别向两边扩展,每扩展一步就判断对应的字符是否相同,一旦字符不同那么停止扩展,当前所得字符串就是回文长度。

有了上面办法后给定字符串我们就能查找最长回文子字符串,那就是我们依次遍历字符串中每个字符,然后以该字符作为中心点,然后利用上面描述方法判断以该点为中心的字符串能形成多长的回文,当遍历完所有字符后就能得到最长回文子字符串。对于给定字符串如果包含n个字符,那么我们就得遍历n次。在选定一个字符作为中心字符后,我们需要向左右两步扩展,扩展的最大长度就是n/2,于是我们当前算法的复杂度就是O(n^2)。我们这个算法存在一个问题,那就是对字符个数为偶数的字符串不管用,例如abba,它本身是回文,但是我们使用任何一个字符最为中心点时都判断不出它是回文,因此处理办法就是我们增加一些辅助字符,使得整个字符串的长度变为奇数,通常情况下我们使用’|'来作为辅助字符,于是字符串变成 |a|b|b|a|,于是中心字符就是下标为4的"|“,那么使用上面算法就能正确查找出字符串”|a|b|b|a|”是回文,然后把辅助字符去掉,剩下的字符串"abba"就是回文。

当前算法的效率不算太好,我们要研究的manache算法它的效率是O(n),比我们当前做法好了一个数量级。对于字符串算法而言,它提升效率的一个关键那就是要利用前面字符比对所得信息来减少后面字符比对的次数,我发现几乎所有涉及到字符串的问题要想提升效率就必须利用这个步骤。我们看一个具体例子,假设给定字符串“abaaba",变换后字符串为"|a|b|a|a|b|a|",下面我们列表统计一下以每个字符为中心时所能得到的回文长度:

|a|b|a|b|a|b|a|
pos01234567891011121314
len010305070503010

在上面表格中,第一行对应字符串字符,第二行对应字符下标,第三行表示以该字符为中心时所得的回文”半径“,例如下标为1的字符对应为a,它下面对应长度为1,也就是说以a为中心,向两边扩展一个字符所对应的字符串”|a|"就能形成回文,我们再看下标为7的字符也就是b,它对应第三行数字为7,也就是说以它为中心向两边扩展7个字符所形成字符串就是回文,由此类推。我们观察一下上面表格中以一个中心点为基础查看两个对应位置的字符,如果以他们为中心所形成的回文长度有什么规律。

从整体上看,整个字符串以pos为7的字符b为中心点形成了一个回文,于是我们也以它为基础分别看左右对称的字符为中心点时,对应回文长度有什么规律。首先我们分别向左和向右挪动一个字符,左边字符对应pos为6,以它为中心对应的回文长度为0,同理右边字符对应pos为8,它对应的回文长度也是0。我们再以b为中心分别向左和向右挪动两个字符,挪动后左边字符是pos等于5的a,以它为中心的回文半径长度为5,同理挪动后右边字符是pos为9的字符a,以它为中心的回文半径长度也是5.

通过以上观察我们是否能得出结论,给定回文的中心字符,那么基于该中心,左右两步对称的字符所对应的回文长度是不是都是一样的?答案时不一定,我们再看一个例子:

|b|a|b|c|b|a|b|c|b|a|c|c|b|a|
pos012345678910111213141516171819202122232425262728
len01030107010901050101012101010

在上面给定的字符串中,我们以下标为7的字符c为中心,以半径为7,那么它会形成一个回文“|b|a|b|c|b|a|b|",我们看以c为中心,分别向左边和右边挪动4个字符,于是左边字符就是下标为3的a,可以看到以a为中心的回文半径长度为3.同时我们看c右边4个字符后的字符a,以它为中心对应的回文长度就是9,两者不一样,那么其中有什么规律可循呢,这里就是manache算法的精髓。

我们定义几个概念:
1,centerPosition, 如果某个字符,以它为中心向左右两步扩展能形成回文字符串的话,那么我们就认为这个字符当前所在位置叫centerPosition,例如上面例子中下标为7的字符c,由于以它为中心向左右分别扩展7个字符所得的字符串能形成回文,因此它所在的位置就能称为centerPosition。
2,centerRightPoistion,以中心字符为基准,向右扩展回文半径的长度后所达到的位置。在上面例子中,以下标为7的字符c为中心,向右边扩展7个字符得到下标为14,它所对应的字符"|“是以c为中心的回文字符串最右边的字符,因此下标14就称为centerRightPosition
3,centerLeftPosition,以中心字符为基准,向左扩展回文半径的长度后所抵达的位置。在上面例子中,下标为7的字符时c,以它为基准向左边挪到7个字符,得到下标为0,它对应的字符时”|",这个字符时以c为中心的回文字符串最左边的字符,因此这个字符所在位置叫centerLeftPosition。
4,currentLeftPosition,如果字符的下标i满足centerLeftPosition < i < centerPosition,那么它就称为currentLeftPosition
5, currentRightPosition, 如果字符下标满足centerPosition < i < centerRightPosition,那么i就称为currentRightPosition。
注意,currentLeftPosition位于中心点左边,currentRightPosition位于中心点右边,而且这两个数值关于中心点对称。也就说如果currentLeftPosition是在centerPosition的基础上向左挪动t个单位,那么currentRightPosition就对应于在中心点基础上向右边挪动t个单位。

现在问题在于,如果我们知道了以cuurentLeftPosition所在字符为中心的回文长度,那么我们如何得知它对称的currentRightPosition为中心的字符回文长度呢。根据manache算法,我们需要分4种情况进行考虑:
1, Len[currentLeftPosition] < centerRightPosition - currentRightPosition,那么Len[currentRightPosition] = Len[currentLeftPosition]。这个条件的意思是如果位于中心点右边的字符,如果它距离回文字符串右边界的距离大于对称左边点为中心的回文长度时,那么根据对称性,那么以右边字符为中心的回文字符串长度跟对称的左边字符为中心的回文字符串长度相同。我们可以仔细想想,回文字符串的特点是对称性,也就是距离中心点长度一样的左右两边的字符相同,假设当前回文字符串的半径为d,也就是以中心点起始,在它左边的d个字符与在它右边的d个字符相同。

假设中心点往左挪动l个单位,然后以对应字符为中心点所得到的回文半径为t,那么根据对称性,在中心点右边l个单位后,以对应字符为中心也会得到半径为t的回文,现在问题在于右边的回文长度肯定不小于对称的左边的回文长度,只是我们要确定右边回文长度不会大于左边。假设右边回文长半径是t+1,那么根据对称性以中心点往右挪动l+t+1个单位后所得字符,跟中心点往左挪动l+t+1个单位后所得字符相同,于是如果右边点为中心的回文半径为t+1,那么以对称左边点为中心的回文其半径也肯定是t+1,这跟我们原来设定左边回文半径为t矛盾。所有条件1满足时一定有Len[currentLeftPosition] = Len[currentRightPosition]。

注意我们这里考虑的是在在大回文字符串里面包含的小回文字符串,根据大回文字符串的对称性,我们能推导包含在它里面的小回文字符串长度特性。

2,Len[currentLeftPosition] = centerRightPosition - currentRightPosition,并且以centerPosition为中心的回文字符串是整个字符串的后缀,那么Len[currentLeftPosition] = Len[currentRightPosition],这点有点不好理解,其实它是条件1的一种特殊情况,根据给定条件如果以currentLeftPosition对应字符为中心能形成半径为t的回文字符串,那么根据对称性,以currentRightPosition为中心也能形成半径为t的字符串。但与1不同,在1中currentRightPosition形成的回文字符串的右边还存在其他字符,但是在2中,以currentRightPosition为中心半径为t的回文字符串已经是整个字符串的后缀,也就是它后面已经没有其他字符了,因此以currentRightPosition为中心的回文字符串其半径只能有t那么长。

3.如果以currentLeftPosition为中心所形成的回文字符串恰好是以centerPosition为中心的回文字符串的前缀,但是以centerPosition为中心的回文字符串不是整个字符串的后缀,那么就有L[currentRightPosition]>=L[currentLeftPosition]。这个条件有点抽象,我们看一个具体例子,在上面字符串对应表格中,以下标7为中心的回文字符串,它的半径长度为7,于是它所形成回文字符串的左边界下标为0,右边界下标为14,注意整个字符串最后一个字符下标为28,所以这个回文字符串不是整个字符串的后缀,我们看从中心点向左挪动4个单位,对象下标为3的字符为a, 以它为中心的回文字符串半径长度为3。同理以中心点向右挪动4个单位,也就是下标为11,对应字符也是a,但是以它为中心的回文字符串半径长度为9。在条件3中之所以有L[currentRightPosition]>=L[currentLeftPosition],是因为以currentRightPosition为中心的字符串可以突破当前以centPosition为中心的回文字符串右边界继续往右扩展,因此以它为中心的回文字符串长度就可能大于它对应左边点,也就是currentLeftPosition为中心的回文字符串的长度。

4,如果以currentLeftPosition为中心点所形成的回文字符串其左边界超过了以centerPosition为中心点所形成的回文字符串左边界,那么就有L[currentRightPosition] >= centerRightPosition - currentRightPosition。我们先看具体例子,依然使用上面的表格,假设中心点的下标为11,它所形成回文的半径长度为9,因此它的左边界下标为2。我们看距离中心点4个单位左右两边字符,从中心点向左4个单位对应字符的下标为7,我们看到以它为中心的回文字符串半径为7,也就是这个回文字符串它的左边界点下标为0,它超出了中心点下标为11的回文字符串的左边界。我们再从下标为11的中心点向右挪动4个单位得到下标为15的字符,可以看到以它为中心点的回文字符串半径长度为5。导致这个结果的原因是,根据对称性,以currentRightPosition为中心,向左或向右挪动centerRightPosition - currentRightPosition个单位所得回文字符串跟以currentLeftPoisition为中心,半径长度为centerRightPosition - currentRightPosition的回文字符串一定是相同的。但是由于以currentLeftPosition为中心的回文字符串,其左边界可以图片以centerPosition为中心的回文字符串的左边界,因此针对currentRightPosition为中心的回文字符串在对称性下至少能跟currentLeftPostion对应上半径为centerRightPosition - currentRightPosition的字符,因此以currentRightPosition为中心的回文字符串其半径长度至少是centerRightPosition - currentRightPosition.

有了上面4个条件后,我们就能减少不必要的字符比对。假设我们已经知道了currentLeftPosition为中心的回文长度,那么在计算currentRightPosition为中心的回文长度时,如果满足条件1,2,那么比对时可以直接越过Len[currentLeftPosition]长度的字符,如果满足条件3,那先越过Len[currentLeftPosition]个字符后再继续进行比对,如果满足条件4,那么越过centerRightPosition-currentRightPosition个字符后再进行比对。

现在还有两个问题需要确定,上面算命步骤中默认Len[currentLeftPosition]已经计算完毕,但是它的值是怎么得来的呢,我们使用开头说过的回文判断办法来进行计算。第二个问题在于centerPosition如何确定,首先我们将centerPosition设置为2,因为以下标为0的字符对应的回文长度一定是0,原因是它左边没有字符所以无法构成回文。下标为1的字符是作为中心时,它的回文长度只能是1,因为它左边只有一个字符"|",所以回文半径不可能超过1.于是currentLeftPosition就能设置为1,currentRightPosition就设置为3,当满足条件3或者4时,我们计算完currentRightPosition对应的回文半径后,将它作为新的centerPosition处理,那么新的currentRightPosition就是当前centerPosition+1,下面我们看相应的实现代码:

import os

def  palindrome_radius(str, center, skip):
    if center < 0 or center - skip - 1 < 0 or center + skip + 1>= len(str):
        return -1

    left = center - skip - 1
    right = center + skip + 1
    radius = 0
    while left >= 0 and right < len(str):
        if str[left] != str[right]:
            break
        radius += 1
        left -= 1
        right += 1

    return radius + skip

def  transform_string(str):
    new_str = "|"
    for i in range(len(str)):
        new_str += str[i]
        new_str += "|"

    return new_str

def find_longest_palindrom_sub_string(text):
    text = transform_string(text)
    Len = [0] * len(text)
    Len[1] = 1
    centerPosition = 1
    centerLeftPosition = 0
    centerRightPosition = 2
    currentLeftPosition = 0
    currentRightPosition = 2

    while currentRightPosition < len(text):
        #分别针对4中情况进行处理
        if Len[currentLeftPosition] < centerRightPosition - currentRightPosition:
            #情况1
            Len[currentRightPosition] = Len[currentLeftPosition]
        elif Len[currentLeftPosition] == centerRightPosition - currentRightPosition and centerRightPosition == len(text) - 1:
            #情况2
            Len[currentRightPosition] = Len[currentLeftPosition]
        elif currentLeftPosition - Len[currentLeftPosition] == centerLeftPosition and centerRightPosition < len(text) - 1:
            '''
            情况3,此时currentRightPosition为中心点的回文长度至少为Len[currentLeftPosition],因此我们越过Len[currentLeftPosition对应
            长度后再进行左右字符匹配来判断是否为回文
            '''
            Len[currentRightPosition] = palindrome_radius(text, currentRightPosition, Len[currentLeftPosition])

        elif currentLeftPosition - Len[currentLeftPosition] < centerLeftPosition:
            Len[currentRightPosition] = palindrome_radius(text, currentRightPosition, centerRightPosition - currentRightPosition)
        else:
            #只能有4中情况,逻辑不可能到这里
            os.abort()

        if currentRightPosition + Len[currentRightPosition] >= centerRightPosition:
            centerPosition = currentRightPosition
            centerLeftPosition = centerPosition - Len[centerPosition]
            centerRightPosition = centerPosition + Len[centerPosition]


        currentRightPosition += 1
        '''
        currentLeft与currentRight要基于centerPosition对称
        currentLeftPosition = centerPosition - (currentRightPosition - centerPosition)
        需要注意的是centerPosition有可能会跟currentRightPosition重合,这种情况出现在Len[centerPosition] == 0的时候
        于是下面的计算使得currentLeftPosition == currentRightPosition == centerPosition
        '''

        currentLeftPosition = 2*centerPosition - currentRightPosition


    return Len

Len = find_longest_palindrom_sub_string("babcbabcbaccba")
print(f"Len: Len")

上面代码运行后输出结果如下:
Len: [0, 1, 0, 3, 0, 1, 0, 7, 0, 1, 0, 9, 0, 1, 0, 5, 0, 1, 0, 1, 0, 1, 2, 1, 0, 1, 0, 1, 0]
可以看到,输出结果跟我们上面分析时给定的数据一致,虽然代码没有直接给出最长回文子字符串,但通过输出结果可以很容易获取,我们只要从上面结构中拿到最大值,同时最大值在数组中的下标就对应回文字符串中心字符所在位置。

manache算法的时间复杂度不好分析,从代码看到它在最外层有一个for循环,在进入情况3,4会执行palindrome_radius函数,而里面有个while循环,两个循环嵌套让人看起来不像是O(n)复杂度。我们进一步分析可以看到,如果进入情况1,2,对应的执行步骤只有O(1),如果进入情况3或4,外层循环就会调用palindrome_radius,注意到这个函数的输入参数center其实对应外层循环的currentRightPosition,而这个变量只增不减,同时每个字符最多出现在palindrome_radius函数中的while循环一次,一旦它出现后就会进入被skip的范围,于是下次执行palindrome_radius函数时,它就不会再次进入while循环,因此即使palindrome_radius嵌套在外层for循环,但它的while循环最多执行n次。

更多精彩内容请在B站搜索Coding迪斯尼,更多干货:http://m.study.163.com/provider/7600199/index.htm?share=2&shareId=7600199

以上是关于脑子要烧坏了:使用manache算法查找最长回文子字符串的主要内容,如果未能解决你的问题,请参考以下文章

manacher算法求最长回文子序列

数据结构--Manacher算法(最长回文子串)

力扣5-最长回文子串-(Manacher算法)

manacher算法处理最长的回文子串

最长回文子串之Manacher算法

查找字符串中的最长回文字符串---Manacher算法