解構公共子串及K階馬爾可夫隨機文本

搜索是一個常談常新的話題,不僅有對數值型數據的搜索,更多的時候還有對字符類型的數據進行搜索的要求。比如常用的各大搜索引擎都是通過獲取用戶所輸入的字符型數據,之後根據引擎自身所採用的算法將輸入與網頁數據庫中存儲的信息匹配,最後將這些信息對應的網頁鏈接返回給用戶,本文介紹內容之一的公共子串匹配算法就屬於文本匹配算法之一。另外計算機科學在分子生物學中一個重要的應用領域即爲比對(alignment),打個不太恰當的比方,可以把氨基酸看成是字母,蛋白質是一個長句,而結構域就像單詞,即具有特別意義、頻繁出現在不同句子中的一串字母,結構域之所以令人感興趣,是因爲它們表徵了序列內部(蛋白質是氨基酸序列)的結構或功能要素,通過採用公共子串匹配算法我們可以找出最頻繁出現的一類結構域,推動分子生物學的研究。而馬爾科夫隨機文本算法則偏向於人工智能研究領域,在現實生活中的語音識別和數據壓縮中隨處可見他們的蹤影。之所以將這兩種算法寫在一起,是因爲考慮到它們的實現都利用了同一種數據結構,這裏暫且留個懸念,我會放在後文揭祕這種神奇的數據結構。另外筆者在思考這篇文章的時候,根據馬爾科夫隨機文本算法的思想,初步構思出了一個能夠實現人機對話的智能機器人系統,如果讀者有興趣,看完本文後不妨自己實現一下 :-)。

解構公共子串
我們首先從單個序列的簡單例子入手,建立起一個比較直觀的概念:
    設序列S爲"government of the people, by the people, for the people"    ——引自《亞伯拉罕•林肯文集》
我們一眼便能分辨出多次出現(即至少出現兩次)的最長公共子串爲“ the people,”。然而如果某個序列很長,那麼串匹配問題根本不可能通過人工完成,因此我們需要從上述實例中提取出一種方法,使得串匹配的過程能夠通過機器自動完成。
以序列S爲例放大上述“分辨”過程,我們得到一種最樸素的方法,如下所述:

  1. 定義序列的首字符g爲當前字符
  2. 從當前字符的下一字符開始順序尋找與當前字符相同的字符
  3. 一旦找到,我們定義該位置處的元素爲匹配字符,隨後將匹配字符與當前字符之後的元素項一一進行匹配直至某一對元素不等,我們記錄匹配字符的位置爲location,以及匹配子串的長度記爲length,接着從匹配字符的下一字符繼續順序尋找與當前字符相同的字符
  4. 重複過程3直至序列的最後一個字符,在每一次得到新的匹配子串的長度length'後,判斷length'是否大於length,以確定是否需要更新location以及length的值
  5. 將當前字符更新爲下一個字符,重複執行過程2、3、4,直至當前字符已被更新爲序列中的最後一個字符

根據上述描述,得到如下查找最長公共子串的過程:

getLongestSubString(s)  /*the substring appears more than once in the sequence 's'*/
location←0  /*the start of the substring*/
length←0  /*the length of the substring*/
for current←0 to length[s]-2  /*iterate until to the precursor of the last character*/
  do for match←current+1 to length[s]-1
       do if s[match]=s[current]
            then temp←0
                 repeat temp←temp+1
                   /*pay attention that must detect whether overstep the boundary of the string or not*/
                   until s[match+temp]≠NULL && s[match+temp]=s[current+temp]
                 if temp>length  /*update the location and length of the substring*/
                   then length←temp
                        location←match
return length & location

然而由於每次從當前字符開始查找子串時都需要遍歷一次餘下的整個序列,這使得串匹配過程的運行時間與n2漸進等同。而之所以將其放在前面介紹,是因爲遇到上述字符串匹配問題時,這是符合我們直覺的解決方案。
對其優化之前,讓我們更深入的思考一下上述方法:
    設序列S=A0A1A2A3A4A5A6A7A8A9,其中A0=A3=A7,A2=A6,A5=A9
上述串匹配過程可以使用下圖形象化的表示


圖1

由上圖,我們發現字符的比對過程只在4個地方產生了暫停,然而我們將當前字符與餘下序列中的字符的比對次數總共達到了(9+8+…+1)=45次。是否存在一種產生較低開銷的預處理方法,使得我們可以立刻知道,所有與當前字符相同的字符各自在序列中的位置。
一種比較容易想到的方法預先是準備一組空“桶”,而後遍歷整個字符串序列,最終所有相同字符在原序列中的下標以鏈表的形式鏈接在同一個“桶”中,但這種方式存在一個問題,即爲序列中的當前字符尋找合適的“桶”時,期望的比對次數往往與已裝入字符的桶的個數成正比,造成這種現象的原因是序列中的字符與待存入的桶並未形成一一對應的關係,而是基於一種先到先服務(First Come First Serve, FCFS)的原則。這個問題本來很容易解決,因爲我們只需要像前文中所介紹的散列一樣定義字符與下標之間的關係即可,一種可行的簡單定義方式如下:
    設序列中的各個不同元素與“桶”的下標的對應關係分別爲
    <(A0, 0), (A1, 1), (A2, 2), (A4, 3), (A5, 4), (A8, 5)>

根據上述定義方式有:


圖2

預處理完成之後,我們找出某個“桶”中的所有節點,其後在原序列中進行匹配,最後得到最長子串的長度及起始位置。這種方法帶來的好處是減少了不必要的一級比對過程,但因爲常用字符通常佔整個的序列絕大部分,這一事實決定了字符的分配必然不太均衡,從而有些桶中的鏈表很長,而很多桶中的節點寥寥無幾甚至根本沒有節點,因此這種做法不僅浪費了內存空間,並且在比對過程中造成了額外的運行時間開銷,那麼是否存在更好的解決方案?

如果更仔細思考上述方案,我們發現將所有相同字符鏈接在一起的做法相當於“聚集”的過程,然而如果“聚集”的過程不是以裝”桶“的方式進行,而是以排序的方式進行呢?這是一種很有趣的思維跳躍,因爲其實原先的裝”桶“過程我們可以看成是區間排序,雖然已經形成基本有序,但仍不夠精確,如果我們使用更加精確的排序方式來完成”聚集“過程,會有什麼意想不到的效果呢?仍從一個簡單的例子入手:
    設序列S爲banana,該序列的索引分別爲index[0]~index[5]
該序列最終得到如下形成字典序的結果:

  1. index[5]: a
  2. index[3]: ana
  3. index[1]: anana
  4. index[0]: banana
  5. index[4]: na
  6. index[2]: nana

分析上述結果,查找最長子串的方法爲:從index[5]開始直至index[2]順序比較相鄰之間的元素,根據當前比較所得結果判斷是否更新最長子串的起始位置及長度。比如將元素index[5]與index[3]比較,得到最長公共子串“a”,長度爲1,起始位置爲5或3,之後將index[3]與index[1]比較,得到最長公共子串“ana”,長度爲3,起始位置爲3或1,由於當前子串的長度大於先前的子串,因此執行更新操作。依次執行上述操作,最終得到整個序列的最長公共子串即爲“ana”。有意思的是,我們並不需要像先前的裝“桶”策略一樣,將index[5]與index[1]也執行比較操作,因爲形成字典序這一精確的“聚集”過程替我們完成了隱式的比較操作

然而有人可能會發現,這種策略其實仍然存在額外的不必要的比較操作,比如index[1]與index[0]以及index[0]與index[4]之間的比較,我們是否可以通過將已形成字典序的子串對應的索引按序裝入桶中以形成有序鏈表?其實完全沒有必要再這樣做,因爲這種做法佔用了更多的內存空間,同時使得整個結構更加複雜,更重要的是,一個序列的絕大多數元素都是常用字符,按照這種做法最後的結果只是將原先的數組“切”成了相對較短的鏈表而已。基於上述分析最終的實現如下:

getLongestSubString'(s)  /*by forming dictionary order*/
/*create the array where elements are the indexes of sequence*/
/*to which we give the name 'suffix'*/
for i←0 to length[s]-1
  do suffix[i]←i
sort the 'suffix' by dictionary order
length←0
location←suffix[0]
for i←0 to length[suffix]-2
  do temp←-1
     repeat temp←temp+1
       until s[suffix[i]+temp]≠NULL && s[suffix[i]+temp]=s[suffix[i+1]+temp]
     if temp>length
       then length←temp
            location←suffix[i]
return length & location
與相比最初的實現,我們藉助一個額外的數組以及排序操作將運行時間從O(n2)降低至O(n㏒n+n)。其中所使用的數組即爲上文所提到的“神奇的數據結構”,它有一個專用的名稱——“後綴數組(Suffix Array)”。該術語於上世紀90年代提出,但早在70年代就已經被使用了。

上文討論了多次出現的最長公共子串,但對於“多次”實際上要求得相當寬鬆,以至我們的實現僅僅只是考慮了序列中重複出現兩次的最長公共子串。但有時會有更加苛刻的要求,比如找出一個序列中重複出現超過N次的最長公共子串,如果我們以最原始的方法來實現恐怕將使複雜度以幾何倍數上升,並且隨着序列的增長,運行時間將越來越難以忍受,然而如果使用形成了字典序的後綴數組,那麼這種複雜度將直接被該數據結構的序性質所規避。要理解這句話的含義,我們仍以上述序列“banana”爲例,其形成字典序後的結果如下:

  1. index[5]: a
  2. index[3]: ana
  3. index[1]: anana
  4. index[0]: banana
  5. index[4]: na
  6. index[2]: nana

假定需要查找重複出現超過2次的最長子串,那麼最直觀的方法如下:
首先將index[5]與index[3]比較,我們得到重複出現兩次的子串爲“a”,該子串做爲一個臨時對象temp,繼續與index[1]進行比較,我們得到重複出現3次的子串爲“a”,接着我們從index[3]繼續重複執行上述操作,直至index[0]處爲止,依次執行上述過程最終得到出現超過2次的最長子串爲“a”。仔細思考上述過程,我們發現,出現超過N次的公共子串實際就是原字典序中N+1個連續元素的交集,求解的過程可以形式化的表示爲( ( (index[0] ∩ index[1]) ∩ index[2]) …∩ index[N])。
如果深入的觀察一下,就會發現字典序其實使原序列形成了“梯度”有序,可以抽象的表示爲下圖:


圖3

正如圖中的旁註所示,形成字典序後,要查找出現超過N次的子串只需將索引1與索引N+1處的元素比較即可,更加嚴謹的證明過程如下:

  • 設索引1~N+1所對應的序列分別爲S1,S2,S3,…SN+1,其中SN=AN1AN2AN3……
    因爲Sx<Sy<Sz表示形成字典序,Sx與Sy必然在某個對應元素上產生不等,假定該元素的位置爲i,同樣的,Sy與Sz產生不等的位置假定爲j
    因此我們得到Ax1Ax2…Ax(i-1)=Ay1Ay2…Ay(i-1)且Ay1Ay2…Ay(j-1)=Az1Az2…Az(j-1),Axi<Ayi,Ayj<Azj
    分兩種情況討論:①i≤j;②i>j
    --->① 當i≤j時
    設Sx=αAxiβAxjγ,S2=α'Ayiβ'Ayjγ',S3=α''Aziβ''Azjγ'',其中α=α',α'Ayiβ'=α''Aziβ'',因此α=α'=α'',Axi<Ayi=Azi
    則((Sx∩Sy)∩Sz) = ((αAxiβAxjγ∩α'Ayiβ'Ayjγ')∩α''Aziβ''Azjγ'') = α∩α''Aziβ''Azjγ'' = α
    而Sx∩Sz = (αAxiβAxjγ∩α''Aziβ''Azjγ'') = α
    所以((Sx∩Sy)∩Sz)=Sx∩Sz 得證
    --->② 當i>j時 同理可證
    反覆運用上述方法遞歸求解如下表達式可得
      ((((S1∩S2)∩S3)∩S4)……∩SN+1)
    =(((S1∩S3)∩S4)……∩SN+1)
    =((S1∩S4)……∩SN+1)
    =((S1∩SN)∩SN+1)
    =S1∩SN+1             <--------等式(*),注意後文的多個序列中將用到該等式
    因此我們只需將索引1與N+1處的元素比較即可得到重複出現超過N次的公共子串,結論得證

根據上述分析,最終出現超過N次的最長公共子串的查找實現如下:

getLSSMultiOccur(s, N)  /*occure more than N times*/
/*assume that we have created the 'suffix' array and formed the dictionary order*/
length←0
location←suffix[0]
for i←0 to length[s]-N
  do temp←-1
     repeat temp←temp+1
       until s[suffix[i]+temp]≠NULL && s[suffix[i]+temp]=s[suffix[i+N]+temp]
     if temp>length
       then length←temp
            location←suffix[i]
return length & location
我們發現上述實現僅僅只是改變了過程getLongestSubString'(s)中的某一處,即由原先的s[suffix[i]+temp]=s[suffix[i+1]+temp]變爲s[suffix[i]+temp]=s[suffix[i+N]+temp]。這也就是我在上文中所說的,“查找重複出現超過N次的子串所帶來的複雜度直接被該數據結構的序性質所規避”的原因所在。

上文都是從單個序列入手,查找出現超過一定次數的最長子串,然而更多時候我們還有在多個序列中查找最長公共子串的要求。
其實在多個序列中查找最長公共子串的解決方案與單個序列相同,因爲如果我們將多個序列順序拼接成單個序列即從邏輯上將該問題轉換爲了前文所描述的情況。接着我們便可通過建立後綴數組並對其進行排序以找出最長公共子串。然而這樣做可能存在的問題是:有可能找出的最長公共子串只是在單個序列或部分序列中重複出現。一種可行的辦法首先在每個序列的尾部建立一個標記,使得將後綴數組中的元素與每個標記比較即可知道該元素所對應的序列,更形象的表示如下:


圖4

從邏輯上構建完該序列之後,在實際的實現過程中,我們並不將“邏輯”序列中的標記放入後綴數組中,而是另外創建一個獨立的“標記”數組。在後綴數組中的元素能夠通過標記知道自身所在的原序列之後,以上問題就已經解決了一半,剩下的問題是如何在已經形成字典序的後綴數組中查找符合要求的最長公共子串。

因爲在排序操作後,連續的索引可能僅僅只對應單個或部分序列。但考慮到沒法形式化的定義這種序列,因此不失一般性地,可以選擇一種比較簡單的情況來找出一種能夠得到最長公共子串的策略,並且這種策略對於任意其他的情況也是成立的。
    設可能存在的索引順序爲 R1R2…RiS1S2…SjT1T2…Tk ,其中 i, j, k=1, 2, 3,……

  • 形成字典序後,由上文所得等式(*),((S1∩S2)…∩SN+1)=(S1∩SN+1) 包含於 (SN∩SN+1)
    因此 (((R1∩R2)…∩Ri)∩S1)=(R1∩S1) 包含於 (Ri∩S1) ,同理 (((S1∩S2)…∩Sj)T1)=(S1∩T1) 包含於 (Sj∩T1)
    又因爲部分索引順序 R1R2…RiS1 所對應的序列R和S的任意公共子串爲 (Rx∩S1)=(((Rx∩Rx+1)∩Rx+2)…∩S1) 包含於 (Ri∩S1)   --->結論(1)
    同理 SjT1T2…Tk 的任意公共子串 (Sj∩Tz) 包含於 (Sj∩T1)   --->結論(2)
    設上述索引順序 R1R2…RiS1S2…SjT1T2…Tk 包含的任意公共子串爲 ((Rx∩Sy)∩Tz),則
      ((Rx∩Sy)∩Tz)
    =(Rx∩Tz)     <---注意由字典序性質可得
    =(((Rx∩Tz)∩S1)∩Sj)     <---將集合擴展
    =((Rx∩S1)∩(Sj∩Tz))
    因爲由結論(1)和結論(2)可知,上述索引順序 R1R2…RiS1S2…SjT1T2…Tk 的任意公共子串 ((Rx∩Sy)∩Tz) 均包含於 ((Ri∩S1)∩(Sj∩T1))=(Ri∩T1)
    因此由上述分析,我們即可得到查找N個原序列的公共子串的策略爲:
        在後綴數組中尋找一個最小區間[x, y],使得該區間中至少存在一個任意原序列的索引,之後將Suffix[x]與Suffix[y]比較即可得到當前公共子串,依次順序執行上述操作,並根據新的公共子串判斷是否執行更新,最終即得到N個原序列中出現的最長公共子串。

由於在後綴數組中尋找最小區間的策略相對複雜,所以在下面的實現中使用中文註釋詳細講述我的實現方法:

getLssAmongMultiSeq(s[0], s[1], … s[n-1])  /*get the longest common substring among s[0]~s[n-1]*/
/*first calculate the sum of each string's length*/
/*then we merge each string to generate a whole string named 'S'*/
/*create the suffix array named 'suffix' which contains elements as many as the sum*/
/*create the array named 'sign' containing n elements which is used to store the sign*/
/*initialize all the elements in the array 'sign' with zeor*/
/*create a boolean array containing n elements and all of them were initialized with false*/
/*the boolean array was named 'isFind'*/
/*create an array containing n elements which was named 'record'*/
/*首先初始化後綴數組suffix以及用於存放標記的數組sign*/
currlength←0
for i←0 to n-1
  do for j←0 to length[s[i]]-1
       do suffix[currlength+j]←currlength+j+i  /*the index of sign needn't to put into the 'suffix'*/
     currlength←currlength+length[s[i]]
     sign[i]←currlength
/*將後綴數組排序最終形成字典序*/
sort the array 'suffix' with dictionary order
ix←0  /*current element in 'suffix'*/
length←0
location←0
cnt←0
while TRUE
  do for i←ix to length[suffix]-1
       do k←-1  /*the kth sequence*/
          repeat k←k+1
            until suffix[ix]>sign[k]  /*根據標記判斷當前後綴數組中的元素屬於第幾個序列*/
          if isFind[k]=false  /*說明當前查找過程中還未出現第k個序列的索引*/
            then isFind[k]←true
                 cnt←cnt+1  /*記錄當前探查區間中對應的原序列的個數*/
          record[k]←suffix[ix]  /*不斷更新某個原序列對應的索引,從而使得區間最小化*/
          if cnt=n  /*當探查區間中已有n個原序列時,則退出循環,完成區間的查找*/
            then break
     if i=length[suffix]  /*在最後一次查找區間時,有可能是因爲所有元素均已查找完,因此我們直接退出外層循環*/
       then break
     /*排序是爲了查找最小元素及最大元素,從而進行比較,另外重新查找新區間時也需要將最大元素的下一個元素賦值給ix*/
     sort the array 'record'
     k←-1
     repeat k←k+1
       until record[0]>sign[k]  /*找出最小元素所屬序列*/
     isFind[k]←false  /*將該位清零,表示在新的區間中不存在該序列的索引*/
     cnt←cnt-1  /*isFind[k]清零的同時將表示序列個數的cnt減1*/
     temp←-1
     repeat temp←temp+1
       until S[record[0]+temp]≠NULL && S[record[0]+temp]=S[record[n-1]+temp]
     if temp>length
       then length←temp
            location←record[0]
     /*將最大元素的下一個元素賦值給區間起始點,以重新開始查找新的滿足條件的最小區間*/
     ix←record[n-1]+1  /*find new window from next index*/
     record[0]←record[1]  /*the start of new window*/
return length & location

可以發現在上述實現中,需要執行兩種不同的排序,一次是對字符串的排序操作,另一次則是對整型數組的排序操作,一種比較偷懶的做法是直接調用庫函數,然而在之前的文章中可以發現,庫函數的運行時間並不理想,因此我在本文中爲之前所寫的健壯的快速排序設計了與庫函數相同的通用接口,使其能夠運用於不同類型的排序,並且效率更高,以下是通用快速排序的C語言實現:

typedef int (_cdecl *comp)(const void *, const void *);
extern void QuickSort(void *, size_t, size_t, comp);
static void RandomDoubDireSort(void *, int, int, size_t, comp);
static size_t RandomDoubDirePart(void *, size_t, size_t, size_t, comp);
static void InsertionSort(void *, size_t, size_t, comp);
static size_t GeneFrom(size_t, size_t); /*return the value belongs to [left, right]*/
static void swap(char *, char *, size_t);

void QuickSort(void *src, size_t NumOfElem, size_t NumOfBytes, comp f)
{
    if(!src || NumOfBytes == 0)
        return ;
    RandomDoubDireSort(src, 0,NumOfElem-1, NumOfBytes, f);
    InsertionSort(src, NumOfElem, NumOfBytes, f);
}

static void RandomDoubDireSort(
    void *src, 
    int low,  /*pay attention the type must be integer*/
    int high,/*otherwise the arithmetic (high-low) may be overflow*/
    size_t NumOfBytes,
    comp f)
{
    while(high-low >= 49)
    {
        size_t m_Sepa = RandomDoubDirePart(src, low, high, NumOfBytes, f);
        RandomDoubDireSort(src, low, m_Sepa, NumOfBytes, f);
        low = m_Sepa+1;
    }
}

static size_t RandomDoubDirePart(
    void *src, 
    size_t low, 
    size_t high,
    size_t NumOfBytes,
    comp f)
{
    size_t temp = GeneFrom(low, high);
    char *pivot = (char *)src+temp*NumOfBytes;
    int left = low-1, right = high+1;
    while(true)
    {
        do left++;
        while(f((char *)src+left*NumOfBytes, pivot) < 0);
        do right--;
        while(f((char *)src+right*NumOfBytes, pivot) > 0);
        if(right > left)
            swap((char *)src+left*NumOfBytes, 
            (char *)src+right*NumOfBytes, NumOfBytes);
        else
            return right;
    }
}

static void InsertionSort(
    void *src, 
    size_t len,
    size_t NumOfBytes,
    comp f)
{
    char *buf = (char *)malloc(NumOfBytes);
    for(size_t index = 1; index != len; index++)
    {
        memcpy(buf, (char *)src+index*NumOfBytes, NumOfBytes);
        int nestix = index-1;
        for(; nestix != -1; nestix--)
        {
            if(f((char *)src+nestix*NumOfBytes, buf) > 0)
                memcpy((char *)src+(nestix+1)*NumOfBytes, 
                (char *)src+nestix*NumOfBytes, NumOfBytes);
            else
                break;
        }
        memcpy((char *)src+(nestix+1)*NumOfBytes,
            buf, NumOfBytes);
    }
    free(buf);
}

static void swap(char *str1, char *str2, size_t NumOfBytes)
{
    char temp;
    if(str1 == str2)
        return;
    while(NumOfBytes--) {
        temp = *str1;
        *str1++ = *str2;
        *str2++ = temp;
    }
}

static size_t GeneFrom(size_t left, size_t right)
{
    return (rand()%(right-left+1))+left;
}

因爲考慮到查找多個序列的公共子串算法比較繁瑣,因此直接給出使用C的可變形參函數實現的版本,以方便複用。由於在具體實現的時候,往往需要藉助語言本身提供的特性,因此可能和上述僞碼有些區別,不過思想是一樣。

/*注意可變形參需要包含 <stdarg.h> 頭文件*/
/*該結構體包含指向一個字符串的指針,及其長度*/
typedef struct {
    char *LocaOfStr;
    int LengthOfLss;
} logStr;

/*該結構包含拼接形成的整個序列ls,即後綴數組suffix,標記數組sign等*/
/*NumOfSeq表示原序列的個數*/
typedef struct StrucForMultiSeq{
    logStr ls;
    char **suffix;
    char **sign;
    size_t *record;
    bool *isFind;
    size_t NumOfSeq;
} *pStrucForMultiSeq;

/*獲得公共子串的主過程,該過程返回一個存儲在堆上的字符串*/
/*因此需要在主調函數中釋放堆內存*/
extern char *getLssAmongMultiSeq(size_t, ... );
/*將多個原序列拼接爲整個序列的子過程*/
static logStr MergeMultiSeq(va_list *, size_t);
/*在拼接以及形成字典序後查找多個序列中的公共子串*/
static logStr FindLssAfterMergeAndSort(pStrucForMultiSeq);
/*分配資源及初始化操作*/
static void AllocResAndInit(pStrucForMultiSeq);
/*釋放資源*/
static void FreeRes(pStrucForMultiSeq);

/*CALLBACK function*/
int _cdecl s_comp(const void *a, const void *b)  /*sort for C-Style string*/
{ return strcmp(*(char **)a, *(char **)b); }
int _cdecl i_comp(const void *a, const void *b)  /*sort for integer type*/
{ return *(int *)a-*(int *)b; }

/*類似於庫函數strdup,返回的指針指向一個堆內存,
因此注意需要手動釋放內存資源以免發生泄漏*/
char *getLssAmongMultiSeq(size_t num, ...)
{
    StrucForMultiSeq  s_FMS;
    logStr ls;
    char *ret;
    s_FMS.NumOfSeq = num;
    va_list ap;
    va_start(ap, num);
    s_FMS.ls = MergeMultiSeq(&ap, num);
    AllocResAndInit(&s_FMS);
    QuickSort(s_FMS.suffix, s_FMS.ls.LengthOfLss-s_FMS.NumOfSeq,
        sizeof(s_FMS.suffix[0]), s_comp);
    ls = FindLssAfterMergeAndSort(&s_FMS);
    ret = (char *)malloc(ls.LengthOfLss+1);
    memcpy(ret, ls.LocaOfStr, ls.LengthOfLss);
    ret[ls.LengthOfLss] = NULL;
    va_end(ap);
    FreeRes(&s_FMS);
    return ret;
}

static void AllocResAndInit(pStrucForMultiSeq ps)
{
    ps->isFind = (bool *)malloc(ps->NumOfSeq*sizeof(bool));
    ps->record = (size_t *)malloc(ps->NumOfSeq*sizeof(size_t));
    ps->sign = (char **)malloc(ps->NumOfSeq*sizeof(char *));
    ps->suffix = (char **)malloc((ps->ls.LengthOfLss-ps->NumOfSeq)*sizeof(char *));
    if(!(ps->isFind && ps->record && ps->sign && ps->suffix)) {
        printf("Fail to Allocate Memory");
        system("pause"); exit(0);
    }
    size_t currlength = 0, LenOfStr;
    char *pStr = ps->ls.LocaOfStr;
    for(size_t index = 0; index != ps->NumOfSeq; index++)
    {
        LenOfStr = strlen(pStr);
        for(size_t strix = 0; strix != LenOfStr; strix++)
            ps->suffix[currlength+strix] = pStr++;
        currlength += LenOfStr;
        ps->sign[index] = pStr++;
    }
    memset(ps->isFind, 0, ps->NumOfSeq);
}

static void FreeRes(pStrucForMultiSeq ps)
{
    free(ps->isFind);
    free(ps->ls.LocaOfStr);
    free(ps->record);
    free(ps->sign);
    free(ps->suffix);
}

static logStr MergeMultiSeq(va_list *ap, size_t num)
{
    enum { INITSIZE = 1024, TIMES = 2 };  /*argument for dynamic array*/
    struct { size_t current, max; char *str; } da;
    char *pVar, *pda;
    size_t szLength = 0;
    logStr ls;
    da.current = 0, da.max = INITSIZE, da.str = (char *)malloc(da.max);
    for(size_t index = 0; index != num; index++)
    {
        pVar = va_arg(*ap, char*);
        szLength = strlen(pVar);
        while(szLength+1>da.max-da.current) {
            pda = (char *)realloc(da.str, da.max*TIMES);
            if(pda == NULL) {
                printf("Fail to Allocate Memory!");
                system("pause"); exit(0);
            }
            da.str = pda, da.max *= TIMES;
        }
        strcpy(&da.str[da.current], pVar);
        da.current += szLength+1;
        da.str[da.current-1] = NULL;
    }
    ls.LocaOfStr = da.str;
    ls.LengthOfLss = da.current;
    return ls;
}

static logStr FindLssAfterMergeAndSort(pStrucForMultiSeq ps)
{
    size_t ix, cnt = ix = 0, index;        /*ix is the index of current element in 'suffix'*/
    size_t len = ps->ls.LengthOfLss-ps->NumOfSeq;
    int k, temp;                /*the kth original sequence*/
    logStr ls = {0, 0};
    for( ; ; )
    {
        for(index = ix; index != len; index++)
        {
            k = -1;
            do k++;
            while(ps->suffix[index] > ps->sign[k]);
            if(ps->isFind[k] == false) {
                ps->isFind[k] = true; cnt++;
            }
            ps->record[k] = index;
            if(cnt == ps->NumOfSeq) break;
        }
        if(index == len) break;
        QuickSort(ps->record, ps->NumOfSeq, sizeof(ps->record[0]), i_comp);
        k = -1;
        do k++;
        while(ps->suffix[ps->record[0]] > ps->sign[k]);
        ps->isFind[k] = false;
        cnt--;
        temp = -1;
        do temp++;
        while(ps->suffix[ps->record[0]][temp] != NULL &&
            ps->suffix[ps->record[0]][temp] == ps->suffix[ps->record[ps->NumOfSeq-1]][temp]);
        if(temp > ls.LengthOfLss) {
            ls.LengthOfLss = temp; ls.LocaOfStr = ps->suffix[ps->record[0]];
        }
        ps->record[0] = ps->record[1];
        ix = ps->record[ps->NumOfSeq-1]+1;
    }
    return ls;
}

以上是在單個/多個序列中查找最長公共子串的算法,該算法提供了一種文本匹配的思想,具有借鑑意義。並且在有些情況下,排序是高效檢索的前提,所以一個健壯而高效的通用排序算法也具有現實意義。


K階馬爾可夫隨機文本
用於生成隨機文本的一種比較簡單的做法是根據所要生成的文本的長度,每次從26個英文字母及1個空格符中隨機選出一個字符,最終構成一個序列。這樣帶來的問題是,文本的生成確實滿足隨機性的要求,然而似乎並不是給人看的,也就是說生成的文本不存在任何意義。那麼如何生成有意義的隨機文本呢?其實在回答這個問題之前,我們首先應該注意到的是現實世界中發生的任何事件所具有的意義均在於其上下文環境之中。比如甲問乙:你今年多大了?那麼乙的回答應該是1~100之間的任意一個數,如果乙回答了1000,那麼該答案顯然不合上下文語境的要求。對於隨機文本也是一樣,某個字符的生成應當依賴於其上下文語境,這樣至少是可以讓人看懂的。最終的結果是,我們可以把隨機文本的生成抽象成一個參數化的馬爾可夫鏈,即
    在一個系統中,其在任意時刻均處於N個離散狀態中的一個:S1,S2,…,SN,時刻t的狀態標記爲Qt,t=1,2,…
    設在某個離散時刻,根據之前的狀態值轉移到某個狀態的概率爲P(Qt+1=Sj | Qt=Si,Qt-1=Sk,…)

    對於一階馬爾可夫模型來說,系統在時刻t+1的狀態僅僅依賴於其在時刻t的狀態,因此轉移概率爲P(Qt+1=Sj | Qt=Si,Qt-1=Sk,…)=P(Qt+1=Sj | Qt=Si),下圖是一階馬爾可夫鏈更形象的表示:


圖5

其中aij=P(Qt+1=Sj | Qt=Si),即當前狀態Si以概率aij轉移至狀態Sj,這一概率在任何時刻t均相同且有aij≥0,∑aij=1(其中i=1,2,…),πi表示整個系統的首個狀態爲i的概率。

上述馬爾可夫模型可以看成一個“隨機自動機”(stochastic automation),選擇一個初始狀態並規定所要生成的序列長度,最終的結果則完全由系統自身決定,因此滿足文本生成的隨機性要求。並且由於系統的當前狀態由前一時刻的狀態值決定,因此我們可以保證該狀態在局部上下文中存在意義,然而如果我們嚴格要求系統的某個狀態由先前的所有狀態決定,那麼最終的生成結果將是完全預定義的,這種將”上下文”進行泛化所形成的模型我們稱之爲“K階馬爾可夫鏈”。有關該文本更詳細的介紹參見信息論之父香農的著名論文[A Mathematical Theory of Communication]。

問題的關鍵在於:整個系統中的所有狀態及其轉移概率如何確定?這可以通過選擇一個已有文本,根據選定文本的內在結構構建出每個狀態的統計模型,舉例來說:
    設選定的文本爲“an apple”,則有

原狀態 轉移狀態
a n、p
n [blank]
[blank] a
p p、l
l e
e /
根據以上統計模型(其中[blank]表示空格),我們知道如果時刻t的狀態爲'a',那麼下一時刻的狀態可以爲'n'也可以爲'p',若所選擇的狀態爲'p',那麼接下來可選擇的的狀態爲'p'或'l',依此類推,直至系統已經構造完所需長度的序列,或由某一狀態無法再進行轉移爲止。一般來說,所選擇的文本的內在結構越豐富,那麼最終的隨機文本也就更加有趣。另外要說明的一點是,關於統計模型的構造雖然是以字符爲例,但實際生成英文隨機文本時都將整個單詞作爲一個狀態,然而如果要生成的是中文文本,那麼只能以單個漢字作爲一個狀態來構造統計模型,注意這裏不考慮對句子進行分詞。好在無論以什麼作爲狀態,最終生成隨機文本的原理都是相同的。

由之前的分析可知一旦構造完整個統計模型,那麼在生成隨機文本的過程中將不再發生改變。並且在生成隨機文本時將根據現有狀態隨機選擇下一個狀態,因此將產生大量的檢索操作。所以可以考慮在讀取完整個文本之後,建立後綴數組並對其進行排序,執行完這些預處理之後每次都通過二分搜索來定位,那麼文本的生成效率更高。這裏我們不考慮使用鏈接形式的散列結構,雖然它能提供高效的搜索操作,然而其致命的缺點是我們無法保證文本的任意兩個狀態之間都不會產生碰撞。在最後的統計模型中,每個原狀態所對應的轉移狀態的個數均不相同,並且對文本的統計通過機器自動完成,我們不可能事先預知一個特定狀態的所有轉移狀態,因此帶來的問題是如何在未知個數的轉移狀態中提供無差別的隨機選擇?給出以下方法:

RandSelFrmUnkState
i←1
state←0
while CurrentState is still in the set of Translation States
  do if rand(0,i)=0  /*generate integer in the interval [0,i]*/
       then state←i
     i←i+1
return state

其基本思想是總是以1的概率選擇第一個轉移狀態,以1/2的概率選擇第二個,以1/3的概率選擇第三個,依此類推。在這一過程結束之後,我們即能保證在未知個數的轉移狀態中選擇任意一個狀態的概率均相同。簡單證明爲,假設在n個狀態中(n未知)選擇第k個狀態,因此選擇該狀態時的概率必爲1/k,接下來必不選擇第k+1個狀態,此時的概率爲k/(k+1),類推直至最終的結果爲P{state=k}=1/k×k/(k+1)×…×(n-1)/n=1/n,結論得證。


根據以上分析,簡易的隨機文本生成程序如下:

#include <stdio.h>
#include <string.h>
#include <time.h>
#include <stdarg.h>
#include <Windows.h>

/*binary search*/
extern int binasearch(const void *,const void *, size_t, size_t, comp);

#define BYTESOFCNCHAR 2
#define TEXTLENGTH 160
#define ORDER 3
static size_t k_order = 1;
static char *inputFile = ".\\origtext.txt";
static char *outputFile = ".\\markovtext.txt";
static char *getFileContext(const char *);
static void geneMarkovText();
static void getFirstState(char *,char *, size_t, size_t);
static int getNextState(char *, char **, size_t, size_t);
static int wordNComp(const void *, const void *);
static size_t RandSelFrmUnkState(char *, char **, size_t);

int main(int argc, char *argv[])
{
    srand((unsigned int)time(NULL));
    geneMarkovText();
    system("pause");
    return EXIT_SUCCESS;
}

static void geneMarkovText()
{
    char *textBuf = getFileContext(inputFile);
    size_t szBuf = strlen(textBuf)/2;
    char MarkovText[4096], *pMark = MarkovText;
    memset(MarkovText, 0, 4096);
    /*pay attention that a chinese character take over 2 bytes*/
    char **SuffixArray = (char **)malloc(sizeof(char *)*szBuf);
    FILE *fp = fopen(outputFile, "w");
    for(size_t index = 0; index != szBuf; index++)
        SuffixArray[index] = textBuf+index*BYTESOFCNCHAR;
    QuickSort(SuffixArray, szBuf, sizeof(SuffixArray[0]), s_comp);
    for(size_t k = 0; k != ORDER; k++)
    {
        sprintf(pMark, "\n%d-階\n", k_order);
        pMark = MarkovText+strlen(MarkovText);
        getFirstState(textBuf, pMark, szBuf, k_order);
        for(size_t index = 0; index != TEXTLENGTH; index++, pMark += BYTESOFCNCHAR)
            if(!getNextState(pMark, SuffixArray, szBuf, k_order))
                break;
        k_order++;
    }
    printf("%s\n", MarkovText);
    fprintf(fp, MarkovText);
    free(textBuf);
    free(SuffixArray);
    fclose(fp);
}

static void getFirstState(char *src, char *dst, size_t len, size_t k)
{
    size_t Loca = GeneFrom(0, len-k);
    memcpy(dst, src+Loca*BYTESOFCNCHAR, k*BYTESOFCNCHAR);
}

static int getNextState(char *buf, char **SuffixArray, size_t len, size_t k)
{
    short *dst = (short *)buf, *src;
    size_t choice = 1;
    int location = binasearch(buf, SuffixArray, len, sizeof(char *), wordNComp);
    if(location == -1)
        return 0;
    while(location != len && !wordNComp(SuffixArray+location, buf))
    {
        if(GeneFrom(1, choice++) == 1)
            src = (short *)*(SuffixArray+location);
        location ++;
    }
    *(dst+k) = *(src+k);
    return 1;
}

static int wordNComp(const void *p, const void *q)
{
    char *p1 = *(char **)p, *p2 = (char *)q;
    size_t left = k_order*BYTESOFCNCHAR, index;
    for(index = 0; index != left; index++, p1++, p2++)
        if(*p1 != *p2) break;
    if(index == left) return 0;
    else return (unsigned char)*p1-(unsigned char)*p2;
}

static char *getFileContext(const char *fileName)
{
    FILE *fp = fopen(fileName, "r");
    size_t szFile, result;
    char *buf;
    if(fp == NULL) { printf("Wrong : file not exist!\n"); exit(1); }
    /*obtain file size*/
    fseek(fp, 0, SEEK_END);
    szFile = ftell(fp);
    rewind(fp);
    /*allocate memory and read file*/
    buf = (char *)malloc(sizeof(char)*szFile+1);
    if(buf == NULL) { printf("Fail to Allocate Memory!\n"); exit(1); }
    result = fread(buf, 1, szFile, fp);
    if(result != szFile) { printf("Reading error!\n"); exit(1); }
    buf[szFile] = NULL;
    fclose(fp);
    return buf;
}

extern int binasearch(
    const void *elem,
    const void *src,
    size_t len,
    size_t NumOfBytes, 
    comp f)
{
    int low = -1, high = len-1, mid;
    while(low+1 != high)
    {
        mid = (low+high)/2;
        if(f((char *)src+NumOfBytes*mid, elem) < 0)
            low = mid;
        else
            high = mid;
    }
    if(f((char *)src+NumOfBytes*high, elem) != 0)
        high = -1;
    return high;
}

下面我選了06年春晚的一段繞口令來測試上述程序,原文本如下:

  • 且南來了個啞巴,腰裏彆着個喇叭;且北來了個喇嘛,手裏提溜着五斤塌目;別喇叭的啞巴要拿喇叭去換提溜塌目喇嘛的塌目;提溜塌目喇嘛就不拿塌目換別喇叭啞巴的喇叭;別喇叭啞巴要拿喇叭打提溜塌目喇嘛一喇叭;提溜塌目喇嘛要拿塌目打別喇叭啞巴一塌目;不知道是提溜塌目喇嘛拿塌目打了別喇叭啞巴一塌目;還是別喇叭啞巴拿喇叭打了提溜塌目喇嘛一喇叭;啞巴吹喇叭,那個喇嘛回家,他就燉塌目。



以下是生成的隨機文本
  • 1-階
    提溜塌目;不拿喇嘛的喇嘛,那個喇叭啞巴一塌目喇嘛的啞巴一喇叭啞巴一塌目;且南來了個喇叭;提溜塌目;提溜塌目打了彆着五斤塌目喇嘛一塌目;不拿塌目;提溜塌目;別喇嘛,手裏提溜塌目;啞巴的喇嘛一塌目;還是提溜塌目打了提溜塌目打彆着五斤塌目喇叭啞巴要拿喇嘛拿喇叭啞巴拿喇嘛就不拿喇嘛一塌目打別喇叭啞巴吹喇叭;提溜塌目喇嘛回家,手


  • 2-階
    嘛就不拿塌目;別喇叭;提溜塌目;不知道是提溜塌目喇嘛回家,他就燉塌目喇嘛的塌目換別喇叭啞巴,腰裏彆着個喇嘛一喇叭的啞巴要拿喇叭啞巴的喇叭,那個喇嘛要拿塌目打別喇叭;啞巴一塌目;還是別喇叭,那個喇叭;提溜塌目;別喇叭,那個喇叭;提溜塌目打了別喇叭;啞巴要拿喇叭;別喇叭啞巴吹喇叭啞巴一塌目。


  • 3-階
    溜塌目喇嘛就不拿塌目打了別喇叭啞巴的喇叭;且北來了個喇嘛,手裏提溜着五斤塌目;提溜塌目喇嘛拿塌目換別喇叭啞巴拿喇叭打了提溜塌目喇嘛一喇叭;且北來了個啞巴,腰裏彆着個喇叭;啞巴吹喇叭,那個喇嘛,手裏提溜着五斤塌目;提溜塌目喇嘛的塌目;不知道是提溜塌目喇嘛要拿塌目打了別喇叭啞巴的喇叭;提溜塌目喇嘛拿塌目換別喇叭啞巴一塌目;別喇叭


按理說,如果下一個狀態所依賴的當前狀態越短,則可供選擇的狀態越多,因而就更加缺乏內聚力,然而如果所依賴的狀態越長,則趨向於原始輸入的逐字拷貝。一般來講,用連續的兩個字符(即2-階)選擇下一個狀態是一個較好的選擇,因爲此時既能重現原文本的味道,又伴隨隨機性帶來的“惡搞”趣味。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章