Manacher(馬車)アルゴリズムの明確で詳細なコード分析-文字列内のすべての回文部分文字列を検索します

アルゴリズムの問​​題の紹介

Manacherアルゴリズムは、LeetCode647を実行したときに見たものです。このアルゴリズムの目的は、文字列内のすべての回文部分文字列をより高速に検索することです。
インターネットでたくさんの記事やビデオを読んだのですが、わかりにくいので、ここでブログを書いて、アルゴリズムを説明するだけでなく、理解を深めて、後でレビューすることにしました。レビュー。
以下では、このアルゴリズムを段階的に説明します。

まず、回文文字列とは何かを明確にする必要があります。
Baidu百科事典によると、「回文文字列」は、「レベル」や「正午」など、回文文字列である同じ正と負の読みを持つ文字列です。これらの2つの単語について、回文は対称性によって特徴付けられることがわかります。レベルでは、lはlに対応し、eはeに対応し、vはそれ自体に対応し(中心点です)、正午はnがnに対応し、 oはoに対応します。
これらの2つの例は、回文ストリングの2つのケースのほぼすべて、つまり奇数と偶数の長さのケースをカバーしています。

次に、回文の部分文字列についてです。回文文字列レベルの場合、その部分文字列eveも回文文字列であり、その部分文字列vも回文文字列であることは明らかです。したがって、レベルには、合計3つの回文部分文字列があります(それ自体を考慮)。半径の概念を使用して、回文文字列を記述することができます。たとえば、レベル、左右の境界に対する中心点は3単位です(それ自体を考慮)。 )、したがって、半径は3であり、正午、半径は2です。したがって、回文文字列の場合、回文部分文字列の数はその半径に等しくなります。

私たちの問題は、文字列が与えられた場合、その中のすべての回文部分文字列の数を見つけなければならないということです。
次に、良いアイデア(中央拡散)が出てきました。
文字列の各文字をトラバースし、各文字を中心点として使用し、両側に広げます。そのたびに、両側の数値を比較するたびに、それらが等しい場合、結果は+1(新しいパリンドローム部分文字列が見つかることを意味します)であり、トラバーサルはそれらが等しくなくなるまで続行されます。
このようにして、すべての回文部分文字列を見つけることができます。ここには2つの問題があります。
問題1はパリティです。奇数の回文部分文字列の中心点は1つです。中心点を囲み、中心点の両側の文字が同じかどうかを比較するだけでよいので、奇数がお気に入りです。シンプルさ。偶数の長さは、1つが中心点である正午など、より厄介です。もちろん、偶数の場合は2つのポインターを作成できます。1つは前のoを指し、もう1つは次のo、そしてそれが奇数の場合、すべてがレベルのvを指します。このように、パリティは毎回議論することができますが、それはより面倒であり、複雑さもより高くなります。
問題2は、全体的な複雑さが比較的高く、最悪の場合O(n ^ 2)であり、これはお気に入りではありません。

したがって、現時点では、マナチャーアルゴリズムが助けになります。それは、これら2つの問題を解決することです。
簡単に言えば、いわゆるマナチャーアルゴリズムは、上記の価格比較暴力の中心的な拡散方法の改善であり、いくつかの判断条件が追加されているため、多くのステップを再計算する必要がないため、時間の複雑さがある程度軽減されます。(dpと同様に、二重計算を防ぐために計算値を記録します)。

Manacherアルゴリズムのコア

上記の2つの問題を解決するために。
マナッチャーアルゴリズムは、それぞれ次の操作を実行します。

1つ目は、回文のパリティについてです。アルゴリズムはそのような操作を行いました。文字列を変更します。例:文字列に「#」を挿入します(#は単なる補助記号であり、元の文字列への情報干渉は発生しません)。
ここに画像の説明を挿入
このようにして、奇数と偶数の問題を考慮する必要はありません。処理する必要のある文字列は、最初は奇数か偶数かを確認できますが、今では1が奇数になっています。これが私たちが見たいものです。
次に、挿入した#の数を見てみましょう。長さnの文字列の場合、最初に中央にn-1を挿入し、次に両側に2を挿入するため、合計n + 1を挿入するため、最終的に処理される文字列の長さは2 * n +1になります。

次に、2番目の質問があります。それは、時間の複雑さを軽減するためどのよう繰り返すかです
実際、最初にこの複製を見てみましょう。この複製を読んだ後、おそらくマナチャーアルゴリズム全体のコアを理解することができます。

前に述べたように、マナチャーアルゴリズムは中央拡散のアイデアを改善したものです。ここで、処理された文字列をトラバースし、引き続き中心拡散を使用して、各ポイントを中心とする回文を見つけます。
回文文字列が見つかり、その右マージンが最大であると仮定します。
ここに画像の説明を挿入
上の図に示すように、見つかった回文の中心はiMax、右の境界はrMax、左の境界はme、以下はlMaxという名前です。
次に、現在のトラバーサルの中心点はiであり、テキスト文字列を取得するためにiで中心拡散を実行する必要がありますが、そのような質問の場合、拡散比較を1つずつ実行する必要がありますか?
ここに画像の説明を挿入

上図のように、回文弦の対称性をしっかりと把握する必要があります。上の図では、s1とs2はiMaxに関して2つの対称であり、区間[lMax、rMax]に含まれています(lMaxは一時的に私が取得したものであり、すべて理解しています)。したがって、それらの2つは対称で等しくなければなりません。s1が回文である場合、s2も回文である必要があります。

したがって、明らかに、私の現在のiはiMaxよりも大きくなければなりません(iMaxは前のトラバーサルと中心拡散によって検出されるため)。また、s1が回文(仮説)であるとすでに判断していることも明らかです。 、s2は対称であるため、回文であるかどうかを再度判断する必要はありません。

このようにして、マナチャーアルゴリズムが時間の複雑さを軽減できる理由をほぼ理解できました。その理由は、左から右にトラバースしたためです。左側はすでに判定されているのに、なぜ右側で再度判定するのか。対称性のために、同じことが終わりました。(対称関数の単調性を見つけるのとよく似ていますか?)

もちろん、これはそれほど単純なことではありません。それを実装するには、補助的なデータ構造と複数の状況判断が必要です。

Manacherアルゴリズムの論理分析

まず、s1セグメントで何が起こっているのかをどうやって知ることができますか?言い換えれば、回文文字列s1の長さはどれくらいですか?中心点は誰ですか?半径はいくつですか?
ここでは、配列fがアルゴリズムで使用され、fの長さは2 * n + 1です(処理された文字列と同じ、対応します)。
f [i]は、現在の点を中心点として形成できる最長の回文の半径を格納します。

次に、点mに移動するときに、iMaxに関して対称なn点のf [n]を見るだけでよいと仮定すると、f [m]がf [n]以上であることがわかります(中心点としてのm)パリンドロームストリングの半径は少なくともf [n]であり、その後中心が広がり、比較はm + f [n] +1から直接開始されるため、f [n]は不要です。 -m比較)。

もちろん、状況はそれほど単純ではなく、さまざまなカテゴリーで議論されるべき多くの状況があります。

ケース1:iがrMaxよりも小さい場合
、対称性があるため、iMaxに関してiと対称である2 * iMax-i点を介してf [i]を取得できます。
しかし、2つの状況があります。
ケース1小さなケースA
ここに画像の説明を挿入

iの対称点によって検出された最大の回文が[lMax、rMax]にもある場合、当然のことながら、f [i] = f [2 * rMax-i]です。
問題ない。
ただし、ケース1の小さなケースB
ここに画像の説明を挿入
明らかに、ここでのs1の範囲は[lMax、rMax]を超え、超過部分は対称特性を満たさないため、ここでのf [i]の割り当ては私たちだけが決定できます。最大値はrMax-i + 1です。

状況2
これは理解しやすいです。i> rMax。
ここに画像の説明を挿入
明らかに、現時点では、区間[lMax、rMax]内でiMaxに関してiに対称な点を見つけることができず、これを使用してf [i]に値を割り当てることはできませんが、文字も回文素数であることがわかっています。その半径は1(それ自体を考慮)であるため、この時点でf [i] = 1に設定します。

上記は、すべての反復的な状況とそれらに対処する方法を考慮する必要があるということです。

f [i]が決定された後、iを中心とする最長の回文が見つかったという意味ではありません。また、f [i]に基づいて中心拡散を続けて、最長のものを見つける必要があります。
同時に、新しい境界がrMaxを超えると、iMaxとrMaxを更新する必要があります。

この時点で、マナチャーアルゴリズムのロジックは終了です。以下のコードを見てみましょう。次のコードはjavaです。

マナチャーコードの実装

class Solution {
    
    
    public int countSubstrings(String s) {
    
    
        int n =s.length();
        StringBuffer t = new StringBuffer("$#");//这里左右多给了字符$!是为了保证数组从1开始,与rMax初始值避开
        //构建新的字符串,用#来填充
        for(int i =0; i<n; i++){
    
    
            t.append(s.charAt(i));
            t.append('#');
        }
        n = t.length();
        t.append('!');

        //f[i]
        int[] f = new int[n];
        int iMax= 0, rMax = 0, ans = 0; //iMax为边界最大的那个的中心,rMax为最大的右边界
        for(int i=1;i<n;i++){
    
    
            //初始化f[i] 
            f[i] = i<rMax? Math.min(rMax-i+1,f[2*iMax-i]) : 1;//情况1(min是小情况A与B的判断) 与情况2的判断
            //中心扩展  暴力扩展
            while(t.charAt(i+f[i])==t.charAt(i-f[i])){
    
    
                f[i]++;
            }
            //对iMax和rMax进行更新
            if(i+f[i]-1>rMax){
    
    
                iMax = i;
                rMax=i+f[i]-1;
            }
            //统计答案,当前贡献为f[i]-1/2向上取整  有多少个子串,同时因为多加入了#,要排除这个干扰
            ans += f[i]/2;
        }
        return ans;

    }
}

おすすめ

転載: blog.csdn.net/qq_34687559/article/details/109560059