【數據結構】 字符串&KMP子串匹配算法

字符串python

  做爲人機交互的途徑,程序或多或少地確定要須要處理文字信息。如何在計算機中抽象人類語言的信息就成爲一個問題。字符串即是這個問題的答案。雖然從形式上來講,字符串能夠算是線性表的一種,其數據儲存區存儲的元素是一個個來自於選定字符集的字符,可是字符串因爲其做爲一個總體纔有表達意義的這個特色,顯示出一些特殊性。人們通常關注線性表都會關注其元素和表的關係以及元素之間的關係和操做,而字符串經常須要一些對錶總體的關注和操做。git

  字符串的基本概念如長度,比大小,子字符串等等這些,只要有點編程基礎的人都懂就很少說了。關於字符串的抽象數據類型大概能夠擬出這樣一個ADT:正則表達式

ADT String:
    String(self,sseq)    #基於字符序列sseq建立字符串
    is_empty(self)    #判斷是不是空串
    len(self)    #返回字串長度
    char(self,index)    #返回指定索引的字符
    substr(self,start,end)    #對字符串進行切片,返回切得的子串
    match(self,string)    #查找串string在本字符串中第一次出現的位置,未出現返回-1
    concat(self,string)    #作出本字符串與另外一字符串string的拼接串
    subst(self,str1,str2)    #作出將本字符串中的子串str1所有替換成str2的字符串

  字符串類基本的操做就是以上這些了,其中大部分操做是比較簡單的,只有match和subst操做可能比較複雜(由於牽扯到子串索引問題,後面會講到這個)。算法

■  字符串的基本實現編程

  由於字符串本質上是一個線性表,根據線性表的分類,很容易想到能夠採用順序表或者鏈表兩種形式來實現字符串。實際上的字符串儲存形式能夠居於二者中間,即將字符序列分段保存在一組儲存塊間而且用連接域把這些儲存塊鏈接起來。在C語言以及其餘一脈相承的語言中,短一點的字符串一般仍是會用順序表的形式來實現。在順序表中咱們也知道,分紅一體式順序表和分離式順序表,一般分離式順序表仍是能夠動態擴容的動態順序表。這個能夠根據須要實現的需求來看。若是想讓字符串是一種建立時就必須指定大小的類型的話,就能夠經過一體式順序表來實現,好比Python中的str類型是不可變類型,因此應該使用一體式順序表實現的。在其餘一些語言中可能要求字符串變量能夠動態地變化內容,這樣子的話就要用動態順序表了。數據結構

  此外,就不可變字符串的實現而言,還牽扯到一個問題就是串在何處終止。咱們有兩種解決方案,一是學順序表在字符串中維護一些額外的信息好比串的長度,二是在字符串尾自動加上一個表明終止的編碼,這個編碼不能當作任何可顯式的字符。C語言以及繼承了C的Python中都是採用了第二種方法。數據結構和算法

  關於字符串的編碼,在比較年輕的語言如Python,Java中都默認使用了Unicode字符集來編碼字符串,而老語言中大多都還在默認使用ASCII和擴充ASCII。函數

■  Python中的字符串工具

  下面從數據結構和算法的角度說明一下python中的字符串。關於字符串的一些具體操做方法和性質我以前也提到過好多,能夠參看其餘文章。搜索引擎

  首先Python中的字符串是一個不可變的類型, 對象的長度和內容在建立的那一刻就固定下來,由於不一樣對象長度和性質可能有所不一樣,Python中對於字符串變量的表示是這樣的:一塊順序表中被大體分紅三個區域,分別盛放該字符串的長度信息,字符串的其餘一些信息(好比提供給解釋器用的,用於管理對象的信息)以及字符儲存區。

  str類的一些操做基本分紅三類,獲取str對象信息(如len,isdigital等等方法)、基於已有對象或憑空創造新的str對象(好比切片,格式化,替換等等)、字串檢索(好比count,endwith,find等等)

  在這些操做中,像len,訪問字符等顯而易見是O(1)操做,而其餘操做基本上都是要掃描整個字符串的,包括檢索,in,not in,isdigital等等都是O(n)的操做。

 

■  字符串匹配

  字符串和其操做的基本實現和線性表是相似的,就很少說了。可是着重須要講一下的,是字符串匹配的問題。

  字符串匹配看起來好像是很簡單的一樁事,但其實是很是有學問在裏頭的。首先是其重要性,在實際應用中用的地方實在是太多了。包括文本處理時的查找,對垃圾郵件的過濾,搜索引擎爬區數以億萬計的網頁,甚至是作DNA檢測時對於四種鹼基序列的匹配(聽說當今世界有一半以上的計算能力都在被用來匹配DNA序列)。由於字符串匹配如此重要,因此對這方面的研究很是多,也有各類各樣的匹配算法紛繁複雜。下面說明兩種匹配算法。

  在介入具體算法以前,先明確幾個概念。目標串是指被匹配的,長度較長的,做爲素材的字符串。模式串是指去匹配的,長度較短的,做爲工具的串。通常目標串長度老是大大大於模式串。

  ●  樸素匹配算法

  樸素匹配顧名思義是很是簡單的算法。它的基本想法很樸素,基於兩點:

  1. 用模式串從左到右依次逐個字符匹配目標串

  2. 發現不匹配時結束此輪匹配而後考慮目標串中當前匹配中字符的下一個字符

  樸素匹配算法實現十分簡單:

def naive_matching(t,p):
    m , n = len(p) , len(t)
    i , j = 0 , 0
    while i < m and j < n:
        if p[i] == t[j]:
            i , j = i+1 , j+1
        else:
            i, j = 0 , j - i + 1
    if i == m:
        return j - i  #這裏return的是模式串在目標串中的位置 return -1

  容易看出,這樣的一個算法其效率比較低。形成低效率的主要緣由是執行過程當中會發生回溯。即不匹配的時候模式串會只移動一個字符,到目標串的下一個字符開始又從下標0開始匹配。最壞的狀況就是每次匹配只有到模式串遍歷到最後一個字符才發現不匹配,而後匹配又發生在整個目標串的最後面時。好比模式串是00001,目標串是00000000000000000000000000001這樣子的狀況。對於長m的模式串和n的目標串,這種最壞狀況須要作n-m+1輪比較,每次比較又須要進行m次操做,總的來看複雜度是m*(n-m+1)爲O(m*n)即平方複雜度。

  樸素匹配算法 的效率低,究其根源在於把每次字符匹配看作是一次獨立的動做,而沒有利用字符串自己是一個總體的特色。在數學上這樣作是認爲目標串和模式串裏的字符都是徹底隨機的量,並且有無窮多種取值的可能,所以兩次模式串對目標串的比較是互相獨立的。爲了改進樸素算法,下面說明一下KMP算法。

  ●  KMP算法

  KMP算法是由Knuth,Pratt和Morris提出的,因此KMP實際上是人名。。

  KMP算法的基本思想是在每輪模式串對目標串的匹配中都獲取到必定信息,根據這些信息來跳過一些輪的匹配從而提高效率。好比看下面這個實例,目標串爲ababccabcacbab,模式串是abcac。

  在完成第一輪匹配以後,其實能夠有這樣一個判斷:在模式串的第2位(注意,說的是下標2,下同)匹配失敗了,而以前的第0位和第1位都是匹配的,這就說明第1位字符是b,而由於第0位和第1位字符不同是a,因此實際上根本不須要把模式串對準目標串的第1位匹配,確定匹配不上。因此左邊的(1)是沒必要要的,正如右邊KMP過程顯示的那樣,模式串直接右移了兩個字符到目標串的第2位匹配。同理,在樸素過程當中的(3),(4)也是不須要的,由於在KMP過程的(1)中,模式串第0位到第三位徹底匹配,到第四位匹配失敗。由於模式串自己後面還有個,爲了避免錯過正確匹配,此次只移動了三個字符到達了右邊的(2)狀況。試想,模式串是abcdc,而目標串是ababcdb...的話這裏就能夠是右移4個字符了。

  概括,抽象一下上面的KMP匹配方法,重點就是要找到前一次匹配中匹配失敗的那個字符所在位置,而後從模式串中分析出一些信息,綜合二者把模式串進行「大跨步」的移動,省去一些無用功。

  那麼就來了一些問題。好比,如何肯定我能夠移動幾個字符?另外所謂「從模式串中分析出一些信息」太抽象了,具體怎麼分析,要獲得哪些信息?

  爲回答這些問題,咱們須要把模式串和目標串抽象化。目標串定義爲"t0t1t2...tj...",模式串定義爲"p0p1...pi..."。首先要明確一點,就是不論目標串是怎麼樣的,對於固定的模式串而言,在模式串中特定的字符上匹配失敗的話,其進一步移動的字符數都是固定的。這聽起來有點懸,可是細想,當pi匹配失敗時就意味着p0到pi-1爲止的全部字符都已經匹配成功。這也就是說咱們已經能夠肯定一部分目標串的內容了。在這部份內容的基礎上肯定模式串能夠後移幾個字符也就不是那麼難以想象了。這也從算法上給出了一個清晰的信號:在某個特定的字符匹配失敗時向後移動幾格,是模式串自身的性質,跟要匹配的目標串無關,因此在正式匹配以前咱們能夠先計算出模式串全部位置匹配失敗時應該移動幾個字符,以這組數據幫助提高正式匹配時的效率。姑且稱這個分析模式串的過程爲模式串預處理吧。預處理應該產出一個長度和模式串同樣長的列表pnext,pnext中的每一項表明着對應位置的字符pi匹配失敗時,從p0到pi-1爲止的子串中最大相同先後綴的長度(最大相同先後綴的概念後面再詳細說)

  模式串預處理時還有可能會遇到一些特殊狀況,好比對於任何模式串的首位,由於首位就匹配失敗的話不存在以前匹配成功的串,也就無從談起先後綴,因此通常規定其值爲-1。

  那麼爲何要這麼構造pnext呢?來看這張書上的圖

  當pi和tj匹配失敗時,因爲模式串第0位到第i-1位和目標串中相同,因此目標串也能夠寫成(1)這種形式,而後把模式串向右移動去進行下一輪匹配的話應該須要找到一個位置k,使得當pk和tj在匹配時,模式串中的第0位到第k-1位能夠和目標串中的pi-k到pi-1位徹底一致。而由於pi-k到pi-1是模式串的一個後綴,p0到pk-1又是模式串的一個前綴(後綴和前綴的概念就是s[n:-1]+s[-1]以及s[0:n],其中n爲[0,len(s))的任何整數)。這樣一來,尋找k的問題就轉化成了肯定這兩個相同先後綴的長度的問題。顯然,當k越小時表示移動的距離越遠,前面也說過爲了避免錯過任何正確匹配,移動應該儘可能屢次,因此當k有多個取值,即模式串有多個最大相同先後綴時應該取儘可能長的(不包括p0到pi-1自己但包括空串,自己表示沒有作任何移動而空串表示沒有找到相關的最大相同先後綴子串而用p0去匹配tj

  ●  如何求pnext

  如今問題轉化爲如何求出pnext或者說如何求出模式串中每一字符匹配失敗時,除去其自己而在其前面全部字符組成序列的最大相同先後綴。

  對於簡單的模式串,好比ababc,咱們能夠手工來算,規定了第0位是-1,第1位是求「a」的最大相同先後綴顯然是0,第2位是求「ab」的最大相同先後綴,也是0;第3位是求「aba」的,由於有相同前綴和後綴「a」,其長度爲1,因此是1;第4位相似的是2。最終咱們獲得的pnext結果是[-1,0,0,1,2]

  若是想要經過函數來獲得pnext,那麼能夠考慮經過數學概括法來解決。即

  1. 當pnext[0]時等於-1

  2. 假設當pnext[i-1]時等於k-1,那麼再爲前綴和模式串自己都再算進各自的下一個字符pk和pi。若pk==pi,則天然是最大相同先後綴增長一個字符因此pnext[i]=k。若不相等,就意味着當前的前綴是不管如何也沒法和後綴相同了。此時就應該退而求其次,試圖在前綴中尋找一個更短的前綴看看可否靠這個短前綴加上一個字符來獲得相同的後綴。這裏須要注意的是,由於i-1長度下模式串的先後綴時相同的,當我取到那個短前綴(也就是前綴的前綴)時應該意識到其應該也是和後綴的後綴(也就是某個短一些的以pi-1結尾的子串)徹底相同的。因此經過這個前綴+一個字符的模式來尋找後綴的後綴+pi的方法是正確的。這一點反映在代碼中有點使人費解,今天想了一個下午+半個晚上纔在一篇論文當中找到答案。

  如此就能夠獲得生成pnext的函數了:

def gen_pnext(p):
    i, k, m = 0, -1, len(p)
    pnext = [-1] * m    #初始化pnext
    while i < m - 1:
        if k == -1 or p[i] == p[k]:
            i, k = i + 1, k + 1
            pnext[i] = k
        else:
            k = pnext[k]    #這裏就是所謂的費解的地方。一開始怎麼也沒想到前綴的前綴和後綴的後綴是相同的這一點,致使糾結爲何在前綴中直接取一個小前綴而不是一點點縮小前綴
    return pnext

  將一個模式串做爲參數傳給這個函數就能夠獲得這個模式串對應的pnext列表,根據這個列表就能夠幫助以後的匹配了。哪裏出現了匹配失敗,查詢pnext列表獲得那個位置字符的k值,而後讓模式串的p[k]號字符對準以前失敗處目標串的那個字符,進行下一輪匹配。

  好比能夠套用上面那個abcac的例子,若是它去匹配目標串ababcabcacbab,第一次失敗在第2位,其k值是0,因此就把第0位的a對準目標串中第2位的a,進行第二次匹配;第二次匹配失敗在第4位的c,k值是1,就把p[1]的b對準目標串的第6位的b進行第三次匹配。第三次匹配得到成功。

  把上述過程抽象化一下,結合pnext的生成函數就能夠獲得完整的KMP算法的表達了:

def matching_KMP(t,p,pnext):
    j,i = 0,0
    n,m = len(t),len(p)
    while j < n and i < m:
        if i == -1 or t[j] == p[i]: #i=-1的狀況只多是第一個字符,而p[i]正是以前所說的,p[k]移動到上一次匹配出錯的地方的那個p[k]
            j,i = j+1,i+1
        else:
            i = pnext[i]
    if i == m:  #若是i=m了,代表所有匹配完成
        return j - i
    return -1

  再來看一下KMP算法的複雜性。首先是生成pnext的函數時間複雜性是O(m),m爲模式串長度。匹配函數結構和生成pnext函數還蠻像的,其時間複雜度是O(n),n是目標串的長度。綜合起來看,整個MSP算法的複雜度就是O(m+n)。由於通常狀況下m<<n,因此近似認爲複雜度就是O(n)。繞了這麼一大圈,終於把樸素匹配的O(n**2)給降到了O(n)。。

  ●  生成pnext函數的改進

  在pnext的生成算法中,設置pnext[i]的那部分還能夠再改進一會兒。由於在匹配失敗的時候必定會有pi != tj,若是此時pi == pk那麼就能夠說明pk和tj不用比較,確定是不一樣的。即分析出最大相同先後綴的前綴的後一個字符和發生匹配失敗的那個字符若是相同,那麼就沒有必要把模式串右移pnext[i]個字符了,反正也是匹配失敗的,而是能夠直接右移pnext[k]個字符。這一修改能夠把模式串移動得更遠,有可能提升效率(雖然我沒看懂。。)。修改後的函數以下:

def gen_pnext2(p):
    i, k, m = 0, -1, len(p)
    pnext = [-1] * m
    while i < m - 1:
        if k == -1 or p[i] == p[k]:
            i, k = i + 1, k + 1
            if p[i] == p[k]:    #多了這個判斷,這裏的p[i]和p[k]不必定是先後綴中相同的內容,前面i和k都已經+=1了。因此當二者相同時有這麼個改進點
                pnext[i] = pnext[k]
            else:
                pnext[i] = k
        else:
            k = pnext[k]
    return pnext

  ●  KMP適合場景以及其餘的算法

  許多場景中須要一個模式串反覆地匹配多個目標串,此時能夠一次性地生成模式串的pnext而後重複使用提升效率。這是最適合KMP算法的場景

  由於執行中不回溯,因此KMP算法也支持一邊讀入一邊匹配,不回頭重讀就不須要保存被匹配的串。在處理從外部得到大量信息的場景也很適合KMP算法。

  另外還有其餘的一些算法,好比BM算法在其餘一些場景中可能會比KMP算法要快不少。總之字符串匹配算法這是個大學問。

 

■  正則表達式

  以上說到的串匹配,其實只是基於固定模式串的簡單匹配。實際問題中的匹配需求可能要遠比其可提供的匹配方式複雜。另外,以前有提到過關於模式匹配的問題,以前所說的子串簡單匹配其實就是模式匹配的一種特殊狀況,而真正的模式匹配每每要經過一個模式串來匹配獲得一組目標串。當目標串不少很長,甚至有無窮多的可能的時候,就須要設計一種有效的匹配方法。

  一種有效的方法就是設計一種模式語言,以一個字符串的形式來表達一種模式,而後用這種模式串來匹配多個目標串。關於模式語言,前人有過不少研究,可是當設計的模式語言愈來愈複雜的時候,匹配的算法可能就只能設計出直屬複雜性的算法,模式匹配問題在這種算法下會成爲一種耗費很高,甚至不可解的問題。也就是說,這種狀況下的模式語言是沒有價值的。實際狀況中,有意義的模式語言是描述能力和處理能力之間獲得的平衡。

  正則表達式就是通過了實踐檢驗,幾乎已經成爲了一種技術規範的模式語言。正則表達式的基本成分也是字符,可是它在設定上把字符分紅了普通字符和有特殊意義的字符。對於普通字符,在正則表達式中指代的就是它自己,對於特殊字符,就有特殊的意義。若是想要把特殊字符變成普通字符就須要在正則表達式中添加轉義符號。正則表達式有基本的性質以下:

  正則表達式中的普通字符只和該字符自己匹配

  若是有正則表達式α和β,那麼他們之間能夠造成組合,"αβ"這個正則式表明順序組合匹配,好比α能匹配字符串s,β能匹配t,那麼這個正則式就能夠匹配s+t

  α和β還有選擇組合"α|β"。這個正則既能夠匹配s也能夠匹配t

  正則表達式有通配符的設定,即用某種符號表明一切可能字符,配合上對於其數量匹配的一些符號能夠匹配任意長度,任意內容的相關字符。好比".*"就是這樣一個正則式

  關於正則表達式的一些特殊字符的具體意義和用法很少說了,能夠參見python的re模塊那篇筆記,這裏給出幾個書上的例子來體驗一下。好比"abc"只能和"abc"匹配,"a(b*)(c*)"能夠匹配全部一個a開頭後後面跟着若干個b而後跟着若干個c的字符串,"a((b|c)*)"能夠匹配任何一個a開頭,後面由任意多個b和c組成的字符串。

  ●  正則表達式的實現算法

  真正的正則式實現算法確定是很是複雜的,這裏給出一種簡化版的正則表達式,包括了一些正則中經常使用的特殊符號而且試圖用python來對這樣一個簡化正則系統進行實現。

  這種簡化版的正則系統中的特殊符號包括:

  .  匹配任意單個字符

  ^  從目標串的開頭開始匹配

  $  匹配到目標串的結尾

  *  在星號前的單個字符能夠匹配從0到任意多個相同字符

  這個正則系統的正則式的實例:"a*b." ; "^ab*c.$" ; "aa*bc.*bca"

  下面考慮一種樸素的正則匹配算法,給出一個函數match,將正則式和目標串做爲參數傳遞進去而後返回匹配到的子串在目標串中的位置。

def match(re,text):
    rlen = len(re)
    tlen = len(text)
    def match_at(re,i,text,j):
        """檢查text[j]開始的正文是否與re[i]開始的模式匹配,
        之因此不設置成默認就從re的頭開始匹配是由於要留出接口來處理星號符
        """
        while True:
            if i == rlen:   #表示模式串匹配到最後爲止一直都是匹配成功
                return True
            if re[i] == "$":    #若是當前處理中字符的下一字符是$,覺得着必須i和j+=1以後兩個字符都來到各自串尾上
                return i+1 == rlen and j == tlen
            if i+1 < rlen and re[i+1] == "*":   #若是模式串下一個字符是星號,就要進行星號符匹配
                return match_star(re[i],re,i+2,text,j)  #能夠看到re[i]是星號前面的那個字符,i+2是星號後面的那個字符的下標
            if j == tlen or (re[i] != '.' and re[i] != text[j]):
                """當j==tlen表示匹配到目標串尾可是模式串仍是有剩餘內容,說明匹配失敗
                當re的第i位是通配符不是通配符.,並且此位置和目標串不匹配就說明本次匹配失敗。須要跳出函數進行下一輪匹配
                """
                return False
            i,j = i+1,j+1

    def match_star(c,re,i,text,j):
        """匹配星號符,即當在text中跳過若干個字符c以後檢查匹配
        """
        for n in range(j,tlen):
            if match_at(re,i,text,n):#每掃描一個目標串中的元素就檢查是否開始和模式串中跳過星號的部分匹配,檢查到目標串結尾仍然都匹配就可return True了。
                return True
            if text[n] != c and c != '.':   #當發現任何一個沒有開始和跳過星號部分匹配,可是卻和給定的c不一樣,c還不是統配符的字符,就代表匹配失敗了
                break
        return False

    if re[0] == "^":
        if match_at(re,1,text,0):   #由於模式串開頭是^,說明從頭開始匹配。反映到函數中來i應該取1,讓模式串從第1位開始匹配目標串
            return 1
    for n in range(tlen):   #這個循環掃描目標串,每次循環體都用模式串從頭匹配一部分目標串,目標串漸漸日後縮小。直到有一個匹配出現就中斷循環return 出來。
        if match_at(re,0,text,n):
            return n
    return -1

  更多的正則表達式的補充,看了一下仍是補充在了re模塊的說明中更加合理,就記錄在那邊了。以上。

相關文章
相關標籤/搜索