1. 程式人生 > 其它 >資料結構與演算法之字串

資料結構與演算法之字串

字串是多個字元連線起來的串,其中的字元是選自特定的字符集,比如ASCII、Unicode等,在Python和Java語言中使用的Unicode字符集,由多個Unicode字元連線成的串即為字串,字串在結構上可以理解成一個順序表,其中的每個字元即為順序表中的一個元素,但是字串與順序表不同在於,字串多涉及到一些整體操作,比如子串的匹配與替換等。

子串

比如主串為abababcc,abab就是其中的子串,可以發現,abab在其中出現了兩次,所以子串是可以多次出現且重疊位置的,如果子串位置有重疊的話,在進行替換的時候,需要指定方向。比如abababcc需要將abab替換為eeee,那麼從左向右替換,得到的結果為eeeeabcc,從右向左替換為abeeeecc,得到的是不同的結果。

字串匹配演算法

樸素匹配演算法

樸素匹配演算法就是用最簡單的邏輯進行匹配運算,比如eeeeabcc進行匹配abcc,那麼從左向右進行匹配時,進行的步驟有:

此種匹配方案思路為兩步:
(1)選定方向匹配每個元素
(2)發現不匹配時,轉去考慮目標串裡的下一個位置是否與模式串匹配

KMP演算法

設pat為模式串(待匹配的串),txt為總串,m為pat的長度,n為txt的總長度。演算法的核心理念:區別於樸素演算法在每次匹配失敗時,都會將txt的下標回溯,KMP演算法永不回溯txt下標,不斷調整pat的下標用以繼續匹配。
對於樸素演算法匹配與KMP演算法匹配的區別這裡不再細講,這裡解釋下核心理念中幾點概念,和它們的原理
1、永不回溯txt下標
下圖是我用processon畫的樸素法與KMP法,txt下標的變化,也就是說在KMP法中txt的下標一直在前進匹配。此外我在KMP中將一個詞標紅了,“合適”這個詞,這也是KMP演算法的關鍵,怎麼在匹配失敗時,怎麼調整pat到合適的下標繼續匹配。

2、不斷調整pat的下標
從上面圖形的描述中,你用肉眼發現,用KMP法時,是不是很聰明,它怎麼就知道將pat的下標移動到b

的位置繼續匹配呢,如果用人的思維來思考,結合匹配過程,我們能獲取到的資訊有這些:
(1)當前txt中,匹配失敗的元素是a
(2)在txt與pat進行匹配過程中,前三個元素已經是匹配成功的,分別是abc
(3)pat第一個元素是a,txt的第二個元素是b,將pat第一個下標與txt下標第二個元素對齊進行匹配(這就是樸素法的方式),這肯定失敗,同理,第三個元素也失敗
(4)pat第一個元素是a,txt第四個元素也是a,將pat下標與txt下標第四個元素下標對齊匹配,將肯定會成功
(5)結合上面的資訊,我們只需要從pat的第二個元素與txt下標的第五個元素進行對比,從這裡開始繼續匹配
這些資訊都是通過人眼,結合匹配過程分析出來的,那麼KMP是怎麼做到分析這些過程的呢?提煉上面的資訊,關鍵需要做到這一步:知道匹配失敗的元素後,找到pat需要移動的下標


上圖是結合整個匹配過程資訊而生成了一個匹配流程,在進行匹配的幾步中,當遇到txt與pat中的元素不相等時,需要比較txt中已匹配串與pat中已匹配串的中有沒有重疊的部分(術語稱為:最長的前後綴子串),而這個比較的關鍵在於txt中當前比較的元素,我將txt中當前用於比較的元素定義為Ti,將pat中當前匹配元素定義為Pj,將pat應該移動的下標稱作K。Ti的出現是隨機的,資料範圍也沒有明確的限制(除了字符集的限制外),這時Ti會有兩種情況:
1、如果Ti這個元素不存在於pat中(比如pat是abcd,而Ti是e),則pat與txt找不到最長的前後綴,這時pat移動到下標為0的位置。
2、如果Ti存在於pat中,需要定義當Ti出現時,pat應該移動的位置。這個結果完全依賴於pat的元素,因為Ti∈{pat}的,於是可以生成一個pat的列表,其中的每個元素Pj對應於每個Ti都有一個k。
以下是字串匹配的程式碼,get_k_dict是獲取k列表的函式。

# 字串匹配
def match_string(txt, pat):
    i, j = 0, 0
    m, n = len(pat), len(txt)
    k_dict = get_k_dict(pat)
    k = 0
    while j < m and i < n:
        k = k + 1
        if txt[i] == pat[j]:
            # 相等,則txt與pat的下標都往後推移,繼續匹配
            j = j + 1
        else:
            if pat.find(txt[i]) == -1:
                # 如果txt當前元素不在pat中,那麼將pat設定為0下標,txt繼續往後匹配
                j = 0
            if pat.find(txt[i]) >= 0:
                # 獲取pat下標移動位置
                j = k_dict[j][txt[i]]
        if j == m:
            return i - m
        i = i + 1
    return -1

問題的關鍵在於怎麼實現get_k_dict,這裡需要涉及一個概念,叫做最長相等前後綴,什麼意思呢,可以參見下圖:

附加:
1、怎麼求一個串的字首與字尾?求字首從第一個元素往右取,不斷拼接為整串,求字尾則從最後一個元素往左取,不斷拼接為整串,舉例說明abacd
abacd(字首):a、ab、aba、abac、abacd
abacd(字尾):d、cd、acd、bacd、abacd
2、為什麼是最長相等前後綴?
舉例:baabaa與abaaba進行取重疊串
baabaa字尾:a、aa、baa、abaa、aabaa、baabaa
abaaba字首:a、ab、abaa、abaab、abaaba
由此可見,相等前後綴有兩個aabaa,按照取最長的話,應該取abaa,為什麼取最長呢?看下圖

經過上圖對比發現,相比於abaa如果取a的話,將會跳過更多的匹配,這可能會導致損失可能的匹配。
弄懂了最長相等前後綴之後,我們開始實現get_k_dict。嘗試分析下get_k_dict函式的過程
(1):當入參Pj與Ti相等時,函式直接返回,返回值為pat的下標加1。很好理解,如果對比的字元都相等,那麼往後繼續對比則可
(2):當入參Pj與Ti不相等時,這時會有多種可能性,根據Ti的值而不同,假設pat的串為abacd...,我們來演示這個過程:


以上是這個過程示意圖表,在圖中,描述了當前pat屬於某種狀態後遇到Ti後應該跳轉到某種狀態。這部分理解可以參考KMP 演算法詳解
既然可以總結出了一個Ti -> K的一個表格,證明這個解是有窮的且唯一的,那麼我們需要做的就是找規律(目的在於找上面連結部落格中提到的影子狀態的由來)。

規律1:在一開始在pat中最長相同前後綴時(或者稱為相同串,可參見:abcabd中的a與第二個a)時,每次匹配時跟第一個元素匹配失敗的回退是相同的,比如abcabd中的b在匹配時,Ti不等於b,if Ti=a,那麼K=1,如果Ti!=a,k=0。再比如c在匹配時,if Ti=a,那麼K=1,如果Ti!=a,k=0
規律2:具有相同串的元素進行匹配時,比如ab_1,與ab_2,1,2是我給定義的編號,決定了他們的位置,在abcabd中,第一個ab為ab_1,第二個ab為ab_2,ab_2的回退跟ab_1的選擇邏輯是一致的。此邏輯也好理解,ab_2中你將pat與txt的前面已經匹配過的元素都擋起來不看,那不就是ab_1在匹配嗎。那麼可以忽略前面已經匹配的元素是因為這個ab_2的串其實就是pat到目前匹配過程為止的最長字首了,只用看可以重疊的地方,其它重疊不了的就可以忽略不看。
在上圖匹配中,按照我說的邏輯進行匹配過程,你會發現在不斷的從txt匹配過程中,都是在生成可用的資訊,我們從匹配最後一個元素d往回看,匹配d就是在匹配第三個元素c,而匹配元素c就是在匹配第一個元素a,此過程可以描述為match(d) = match(c) = match(a),有了此種規律,那麼我們就知道匹配時候當我們需要去找pat的下標K的時候,我們應該一步步回溯狀態,回到最初狀態就會有答案。
規律3:當我們去重新整理kk時,其實也就是在kk的位置跟當前的?去比較,這個結果就是k的新值

實現get_k_dict函式程式碼

def get_k_dict(pat):
    # 構造一個雙重字典,
    # 第一重:key為pat當前的元素,value是一個ti相關字典
    # 第二重:key為ti可能出現的元素,value為當ti出現時,k的值,初始預設為0
    dp = {i: {i: 0 for i in pat} for i in range(len(pat))}
    # 初始化首元素的k,pat首元素只要遇到它本身時,k=1,其它都等於0
    first_ele = pat[0]
    dp[0][first_ele] = 1
    # 設定一個最長相等前後綴的後置位變數,初始時設定為0
    longest_str_after = 0
    # 從pat的第二個元素下標元素開始找k
    m = len(pat)
    for j in range(1, m):
        for ti in pat:
            # 兩種情況,
            # 如果ti正好等於pj,那麼將k值等於 j + 1
            # 如果ti不等於pj,那麼就找longest_str_after去辦
            if ti == pat[j]:
                k = j + 1
                dp[j][ti] = k
            if ti != pat[j]:
                # 交給longest_str_after去辦事
                dp[j][ti] = dp[longest_str_after][ti]
        # 每次迴圈結束都需要檢查下longest_str_after的下標需不需要更新
        # 更新的原理為:
        # 如果pj當前比較的元素跟longest_str_after下標指示的元素相等,那麼最長相同前後綴長度就加1,longest_str_after的下標就要加1;如果pj當前比較的元素跟longest_str_after下標指示的元素不相等,那麼就要回退到之前最長的前後綴的位置
        # longest_str_after的位置,就是longest_str_after遇到pat[j]字元時的值
        longest_str_after = dp[longest_str_after][pat[j]]
    return dp

傳統的KMP匹配步驟

以上是以一個我們人眼去匹配時應該想到的一個過程,然後產出了一個程式碼邏輯,在傳統的KMP演算法邏輯中,並沒有上述過程那麼智慧,可以看下圖

我們直接來看傳統的KMP的程式碼邏輯,通過程式碼來比對與上述的邏輯有何不同?我們來實現一下傳統的KMP(Python)程式碼

def get_table(pat):
    i, k, m = 0, -1, len(pat)
    dp = [-1] * m
    # 思路就是判斷當前pat[i]的元素,設定pat[i+1]位置元素的k
    while i < m - 1:
        if k == -1:
            i = i + 1
            k = k + 1   # k加1設定為0
            if pat[i] == pat[k]:
                # 注意這時的i,k都加1了
                dp[i] = dp[k]
            else:
                dp[i] = k  # 設定i+1的k
        else:
            if pat[i] == pat[k]:
                i = i + 1
                k = k + 1
                if pat[i] == pat[k]:
                    # 注意這時的i,k都加1了,
(備註一)           dp[i] = dp[k]
                else:
                    dp[i] = k   # 設定i+1的k
            else:
                # 回退k
                k = dp[k]
    return dp

下圖是上面程式碼中的備註一處的程式碼的說明

下圖是-1的起始狀態的說明

兩種方案的對比

1、執行結果的對比
定義模式串為aa,KMP非傳統版與KMP傳統版的結果分別如下:
非傳統版:{0: {'a': 1}, 1: {'a': 2}} ——> 代表0下標的元素只有遇到'a'字元時k=1,遇到其它字元k都是0,1下標遇到'a'字元k=1,其它k=0
傳統版:[-1, -1] ——>代表0下標的元素,k=-1(-1的含義是txt下標加1,pat下標歸0),1下標的元素,k=-1
非傳統版的txt的下標是預設往後推進一位,而傳統版是不會的。就aa串的兩種執行結果,匹配效率都是一樣的,在沒有遇到可匹配的字元時,都是將txt下標往後推進一位,且pat下標歸0。
2、匹配邏輯的對比
非傳統版:將pat當前匹配元素Pj遇到的多種Ti(txt當前匹配元素)進行彙總,不同的Ti有不同的k值
傳統版:遍歷pat的元素,pat中的每個元素都對應有一個k,這個k就是pat的另一元素的下標
兩種匹配邏輯的核心都在於:pat與txt中已匹配串中,尋找最長相等前後綴
3、執行效率的對比
非傳統版:pat中可能存在多種字元,受限於字符集的字元個數,但總歸是常量。時間複雜度可記做O((字符集字元個數)M)
傳統版:時間複雜度為O(M)
M為模式串長度,複雜度上面來說,傳統版更優一點。

總結

1、字串匹配演算法有兩種:樸素匹配法,KMP匹配法。
樸素匹配法是暴力破解的方式,通過回溯下標的方式,忽略了已有匹配串提供的資訊。
KMP匹配法,使用上了已有匹配串提供的資訊,不回溯下標,通過調整pat的下標繼續匹配
2、一種非傳統版的KMP演算法
非傳統的KMP演算法,是一種窮盡txt當前匹配元素的可能性,然後彙總這些可能性,而得出的K值,K值就是pat的轉移下標。
與傳統的KMP演算法比較來看,非傳統的KMP邏輯理解更簡單,但是效率偏低,核心的實現理念都是一致的。
3、KMP匹配演算法的關鍵
第一:最長相等前後綴。需要理解為什麼只需要pat的元素就可以構成一個用來作為匹配的參考表。關鍵在於匹配過程中是有一個字首與字尾的一個匹配。
第二:k是什麼?或者說,dp表中裡面的值是什麼?
是pat中當前匹配元素在匹配失敗後,所轉移的位置,這個位置充分利用了已有的匹配資訊而得出。比如dp[2]=1,它的含義就是pat下標為2的匹配元素在匹配失敗後,可以將下標轉移到1的位置繼續匹配。
第三:dp[i] = dp[k]與k=dp[k]
這兩步都是在往前找k的位置,dp[k]就是pat下標為k時匹配失敗後,可以將下標轉移的位置。
dp[i] = dp[k] ——> 當pat下標為i匹配失敗時,可將下標轉移到kk(kk=pat下標為k時匹配失敗後,可以將下標轉移的位置)位置
k=dp[k] ——> 當前k等於kk(kk=pat下標為k時匹配失敗後,可以將下標轉移的位置)
將kk...k...i按照順序進行排列,賦值的過程,其實就是在往前找更合理的k的位置(也就是更合理的pat轉移下標)