字符串
作為人機交互的途徑,程序或多或少地肯定要需要處理文字信息。如何在計算機中抽象人類語言的信息就成為一個問題。字符串便是這個問題的答案。雖然從形式上來說,字符串可以算是線性表的一種,其數據儲存區存儲的元素是一個個來自於選定字符集的字符,但是字符串由於其作為一個整體才有表達意義的這個特點,顯示出一些特殊性。人們一般關注線性表都會關注其元素和表的關系以及元素之間的關系和操作,而字符串常常需要一些對表整體的關注和操作。
字符串的基本概念如長度,比大小,子字符串等等這些,只要有點編程基礎的人都懂就不多說了。關於字符串的抽象數據類型大概可以擬出這樣一個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模塊的說明中更加合理,就記錄在那邊了。以上。