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

字符串匹配(查找)算法是一类重要的字符串算法(String Algorithm)。有两个字符串, 长度为m的haystack(查找串)和长度为n的needle(模式串), 它们构造自同一个有限的字母表(Alphabet)。若是在haystack中存在一个与needle相等的子串,返回子串的起始下标,不然返回-1。C/C++、PHP中的strstr函数实现的就是这一功能。LeetCode上也有相似的题目,好比#28#187.java

这个问题已经被研究了n多年,出现了不少高效的算法,比较著名的有,Knuth-Morris-Pratt 算法 (KMP)、Boyer-Moore搜索算法、Rabin-Karp算法、Sunday算法等。算法

Sunday算法由Daniel M.Sunday在1990年提出,它的思想跟BM算法很类似, 其效率在匹配随机的字符串时不只比其它匹配算法更快,并且 Sunday 算法 的实现比 KMP、BM 的实现容易不少!segmentfault

只不过Sunday算法是从前日后匹配,在匹配失败时关注的是主串中参加匹配的最末位字符的下一位字符。数组

若是该字符没有在模式串中出现则直接跳过,即移动位数 = 模式串长度 + 1;
不然,其移动位数 = 模式串长度 - 该字符最右出现的位置(以0开始) = 模式串中该字符最右出现的位置到尾部的距离 + 1。

安全

先说暴力法:两个串左端对其,而后从needle的最左边字符往右逐一匹配,若是出现失配,则将needle往右移动一位,继续从needle左端开始匹配...如此,直到找到一串完整的匹配,或者haystack结束。时间复杂度是O(mn),看起来不算太糟。入下图所示:
图中红色标记的字母表示第一个发生失配的位置,绿色标记的是完整匹配的位置。

重复这个匹配、右移的过程,每次只将needle右移一个位置

直到找到这么个完整匹配的子串。
函数

限制这个算法效率的因素在于,有不少重复的没必要要的匹配尝试。所以想办法减小没必要要的匹配,就能提升效率咯。不少高效的字符串匹配算法,它们的核心思想都是同样样的,想办法利用部分匹配的信息,减小没必要要的尝试。post


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的过程以下:ui

输入: 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字符集。lua


算法的时间复杂度主要依赖两个因素,一是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

相关文章
相关标签/搜索