通用高效字符串匹配--Sunday算法


字符串匹配(查找)算法是一類重要的字符串算法(String Algorithm)。有兩個字符串, 長度為m的haystack(查找串)和長度為n的needle(模式串), 它們構造自同一個有限的字母表(Alphabet)。如果在haystack中存在一個與needle相等的子串,返回子串的起始下標,否則返回-1。C/C++、PHP中的strstr函數實現的就是這一功能。LeetCode上也有類似的題目,比如#28#187.

這個問題已經被研究了n多年,出現了很多高效的算法,比較著名的有,Knuth-Morris-Pratt 算法 (KMP)、Boyer-Moore搜索算法、Rabin-Karp算法、Sunday算法等。

Sunday算法由Daniel M.Sunday在1990年提出,它的思想跟BM算法很相似, 其效率在匹配隨機的字符串時不僅比其它匹配算法更快,而且 Sunday 算法 的實現比 KMP、BM 的實現容易很多!

只不過Sunday算法是從前往后匹配,在匹配失敗時關注的是主串中參加匹配的最末位字符的下一位字符。

如果該字符沒有在模式串中出現則直接跳過,即移動位數 = 模式串長度 + 1;
否則,其移動位數 = 模式串長度 - 該字符最右出現的位置(以0開始) = 模式串中該字符最右出現的位置到尾部的距離 + 1。

先說暴力法:兩個串左端對其,然后從needle的最左邊字符往右逐一匹配,如果出現失配,則將needle往右移動一位,繼續從needle左端開始匹配...如此,直到找到一串完整的匹配,或者haystack結束。時間復雜度是O(mn),看起來不算太糟。入下圖所示:
圖中紅色標記的字母表示第一個發生失配的位置,綠色標記的是完整匹配的位置。

重復這個匹配、右移的過程,每次只將needle右移一個位置

直到找到這么個完整匹配的子串。

限制這個算法效率的因素在於,有很多重復的不必要的匹配嘗試。因此想辦法減少不必要的匹配,就能提高效率咯。很多高效的字符串匹配算法,它們的核心思想都是一樣樣的,想辦法利用部分匹配的信息,減少不必要的嘗試。


Sunday算法利用的是發生失配時查找串中的下一個位置的字母。還是用圖來說明:

上圖的查找中,在haystack[1]和needle[1]的位置發生失配,接下來要做的事情,就是把needle右移。在右移之前我們先把注意力haystack[3]=d這個位置上。如果needle右移一位,needle[2]=c跟haystack[3]對應,如果右移兩位,needle[1]=b跟haystack[3]對應,如果移三位,needle[0]=a跟haystack[3]對應。然后無論以上情況中的哪一種,在haystack[3]這個位置上都會失配(當然在這個位置前面也可能失配),因為haystack[3]=d這個字母根本就不存在於needle中。因此更明智的做法應該是直接移四位,變成這樣:

然后我們發現在needle[0]=a,haystack[4]=b位置又失配了,於是沿用上一步的思路,看看haystack[7]=b。這次我們發現字母b是在needle中存在的,那它就有可能形成一個完整的匹配,因為我們完全直接跳過,而應該跳到haystack[7]與needle[1]對應的位置,如下圖:

這一次,我們差點就找到了一個完整匹配,可惜needle[0]的位置失配了。不要氣餒,再往后,看haystack[9]=z的位置,它不存在於needle中,於是跳到z的下一個位置,然后...:

於是我們順利地找到了一個匹配!
然后試着從上面的過程中總結出一個算法來。

輸入: haystack, needle
Init: i=0, j=0 while i<=len(haystack)-len(needle): j=0 while j<len(needle) and haystack[i+j] equals needle[j]: j=j+1 if j equals len(needle): return i else increase i...

這里有一個問題,發生失配時,i應該增加多少。如果haystack[i+j]位置的字母不存在於needle中,我們知道可以跳到i+j+1的位置。而如果chr=haystack[i+j]存在於needle,我們說可以跳到使chr對應needle中的同一個字母的位置。但問題是,needle中可能有不止一個的字母等於chr。這種情況下,應該跳到哪一個位置呢?為了不遺漏可能的匹配,應該是跳到使得needle中最右一個chr與haystack[i+j]對應,這樣跳過的距離最小,且是安全的。
於是我們知道,在開始查找之前,應該做一項准備工作,收集Alphabet中的字母在needle中最右一次出現的位置。我們建立一個O(k)這么大的數組,k是Alphabet的大小,這個數組記錄了每一個字母在needle中最右出現的位置。遍歷needle,更新對應字母的位置,如果一個字母出現了兩次,前一個位置就會被后一個覆蓋,另外我們用-1表示根本不在needle中出現。
用occ表示這個位置數組,求occ的過程如下:

輸入: needle
Init: occ is a integer array whose size equals len(needle) fill occ with -1 i=0 while i<len(needle): occ[needle[i]]=i return occ

還有一點需要注意的是,Sunday算法並不限制對needle串的匹配順序,可以從左往右掃描needle,可以從右往左,甚至任何自定義的順序。
接下來嘗試具體實現一下這個算法,以下是Java程序,這里假設Alphabet就是ASCII字符集。


算法的時間復雜度主要依賴兩個因素,一是i每次能跳過的位置有多少;二是在內部循環嘗試匹配時,多快能確定是失配了還是完整匹配了。在最好的情況下,每次失配,occ[haystack[i+j]]都是-1,於是每次i都跳過n+1個位置;並且當在內部循環嘗試匹配,總能在第一個字符位置就確定失配了,這樣得到時間O(m/n)。比如下圖這種情況:

最壞情況下,每次i都只能移動一位,且總是幾乎要到needle的末尾才發現失配了。時間復雜度是O(m*n)並不比Brut-force的解法好。比如像這樣:

 

 

使用Alphabet解法:

class Solution {
 
    public int strStr(String haystack, String needle) {
        int m=haystack.length(), n=needle.length();
        int[] occ=getOCC(needle);
        int jump=0;
        for(int i=0;i<=m-n; i+=jump){
            int j=0;
            while(j<n&&haystack.charAt(i+j)==needle.charAt(j))
                j++;
            if(j==n)
                return i;
            jump=i+n<m ? n-occ[haystack.charAt(i+n)] : 1;
        }
        return -1;
    }

    public int[] getOCC(String p){
        int[] occ=new int[128];
        for(int i=0;i<occ.length;i++)
            occ[i]=-1;
        for(int i=0;i<p.length();i++)
            occ[p.charAt(i)]=i;
        return occ;
    }
}
不用Alphabet的解法 by Golang:
package main

import "fmt"

func strStr(haystack string, needle string) int {
    if haystack == needle {
        return 0
    }
    nl:=len(needle)
    if nl==0{
        return 0
    }
    hl:=len(haystack)
    if hl==0{
        return -1
    }
    nm:=map[byte]int{}
    for i:=0;i<nl;i++{
        nm[needle[i]]=i
    }
    fmt.Printf("%+#v %v %v ",nm, haystack, needle)
    for i :=0; i <hl;{
        j:=0
        tmp:=i
        for ;j<nl && tmp<hl;j++{
            if haystack[tmp] != needle[j] {
                break
            }
            tmp++
        }
        fmt.Printf("i %v, j %v  ", i,j)
        if j == nl {
            return i
        }else if i+nl<hl   {
             hit,exists := nm[haystack[i+nl]]
             fmt.Printf("alpha %v hit %v exst %v ",string(haystack[i+nl]),hit, exists)
             if exists {
                i+=nl-hit
             }else{
                i+=nl+1
             }
        }else {
            return -1
        }
    }
    return -1
}
func main(){
    fmt.Println(strStr("a","a"))
    fmt.Println(strStr("abcdeabc","abcab"))
    fmt.Println(strStr("abcdeabc","abcabe"))
    fmt.Println(strStr("abcebc","abc"))
    fmt.Println(strStr("nnabcd e aebc","abc"))
    fmt.Println(strStr("mississippi","issi"))
    fmt.Println(strStr("mississippi","issip"))
}

  

前面提到Sunday算法對needle的掃描順序是沒有限制的。為了提高在最壞情況下的算法效率,可以對needle中的字符按照其出現的概率從小到大的順序掃描,這樣能盡早地確定失配與否。
Sunday算法實際上是對Boyer-Moore算法的優化,並且它更簡單易實現。其論文中提出了三種不同的算法策略,結果都優於Boyer-Moore算法。

Reference:
1] [D.M. Sunday: A Very Fast Substring Search Algorithm. Communications of the ACM, 33, 8, 132-142 (1990)
2] [Fachhochschule Flensburg


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM