BWA
Burrows-Wheeler Aligner作為一個十分出名的alignment軟件,在各個生信領域的比對中都發揮了重要的作用。但是如果只是會使用這個軟件而不能理解其中的原理的話,那就十分的遺憾了。
今天這一篇主要講后綴樹的相關知識,網上的文章很多,我除了使用自己的理解以外,也會參考別的文章的內容,相互佐證以增加可讀性。但是網上的文章中使用到python進行后綴樹實現的并不多,一來是應用的實際作用不大,二是本就有不少的后綴樹相關的python模塊,相信直接使用那些模塊會比自己寫好很多。但是由于自己從零實現對算法的理解幫助很大,我最后還是自己從零開始寫了一個簡單的后綴樹基礎模塊。
后綴樹的前前身---Trie樹
以下這張圖就是Trie樹。由{bear, bell, bid, bull, buy, sell, stock, stop}幾個單詞生成的一個Trie樹,Trie樹本身就具有十分顯著的優點,當在一個構建好的樹中進行查詢的時候,可以看出僅需要線性復雜度的時間。當然缺點也是十分明顯的,空間復雜度是其比較大的問題。構建Trie樹并不會消耗太多的時間。
特點
- 根節點無字符
- 從根節點到各個葉節點的唯一路徑的組合,即構建時的字符串
- 任意節點的子節點所包含的字符都不相同。
- 每一個節點只包含一個字符 (該特點并不強制,看后續壓縮Trie樹可知)
可以想象得到,如何在構建好的Trie樹中的搜索,也可以直觀的從肉眼看出規律。
Trie的搜索過程
- 從根節點出發。(定義父節點為Node1,搜尋字符串為Sstr)
- 尋找搜尋字符第一個字符對應的子節點 (尋找Node1的子節點中與Sstr[0]相等的節點,并將其賦值給Node1)
- 從該子節點繼續往下,搜尋字符第二個字符對應的子節點的子節點(繼續尋找Node1的子節點與Sstr[1]相等的節點...并...)
- 重復至找不到對應節點或者到達Sstr的末尾。
后綴樹的前身---壓縮Trie樹
從Trie樹繼續靠近后綴樹的過程中,還存在一個壓縮Trie樹。顧名思義,壓縮Trie樹是在Trie樹的基礎上進行壓縮。那么何為壓縮,并且壓縮的意義在哪?
Trie樹的壓縮
除根節點外,若任意節點的父節點只有其一個子節點。(即 若該節點為獨生子女),那兒我們將其和父節點壓縮為一個節點
壓縮的過程是一個精簡Trie樹的分支的過程,但也由此帶來了一些代碼上實現的困難。例如任意一個節點不一定為單個字符,即任意一個節點均可能含有多個字符了(此處強調的是節點含多字符,父節點含單字符,父父節點仍可以是多字符。)
后綴樹的最后一步---什么是后綴
后綴其實指的是一個字符的后綴集合。
例如,
對于abcabxabcd,則可以生成這樣的后綴集合
abcabxabcd
bcabxabcd
cabxabcd
abxabcd
bxabcd
xabcd
abcd
bcd
cd
d
若將這些后綴集合左對齊的寫在每一行,就可以看到這樣的一個倒三角的結構。每次刪掉第一個字符,從而生成一個含等同于 字符串長度 數量的字符的后綴集合(即含有10行,該字符串長度為10)
后綴樹的定義
在看完上面的幾個前要后,我們可以來提出后綴樹的基本定義了。
后綴樹是一個由單個字符串的后綴集合所構建的壓縮Trie樹。
后綴樹的構建
后綴樹本身與壓縮Trie樹是沒什么大的區別的,完全可以使用一個(壓縮后綴樹的構建算法+生成后綴集合)的算法去生成一個后綴樹,但這樣的話,時間復雜度太高。
后來,在1995年,Ukkenon發表了論文《On-line construction of suffix trees》。這樣,一下子將構建后綴樹的時間復雜度降低到了線性的尺度上。
接下來我們就重點在描述Ukkenon的后綴樹構建算法,并且在后文提供Python的代碼實現。
Ukkenon算法
由于其中部分與我python代碼實現相關的地方,可能與原論文的部分表述有所不同。請倍加注意。
一開始為了構建Suffix Tree,我們先初始化一些具有重要作用的存儲對象。
active_point = (root, '', 0) # 分別稱為 (父節點,指示子節點,位置索引)
remainder = 0 # 稱為 剩余后綴
現在無法理解很正常,之后再講述算法的過程中會漸漸講解其意義和作用。
我們接下來從構建字符串abcabxabcd
的后綴樹的實際過程中講解這個算法
高層次的來看構建過程
- 從左到右,每次只向后綴樹加入一個字符。 (注意:用的是加入,不是插入。) (加入的是一個字符,但實際上操作的是每一個以該字符開頭的后綴。)
- 加入樹前,會先確認 該字符是否在某已有字符串的 前綴中 (前綴與后綴類似,均為一個對應字符串的集合,確認的目的是,若在,則說明該字符開頭的后綴需要與別的后綴共享一些節點。)
第一個字符,a
由于本身這個是個空的后綴樹,那么直接在根節點后插入一個節點,a即可。
active_point = (root, '', 0),remainder = 0
- 在插入前,
remainder= remainder + 1
,表明,我們現在準備要插入一個后綴。- 插入a節點到父節點root上。
- 插入后,由于我們新增了一個葉節點,且完成,所以
remainder= remainder - 1
active_point = (root, '', 0),remainder = 0
第二個字符,b
- 在插入前,
remainder= remainder + 1
,表明,我們現在準備要插入一個后綴。- 擴展每一個葉節點,所以a節點-->ab節點
- 查詢父節點下,(所有已插入字符串的前綴中不包含b),所以,b需要獨立建立新的一個節點,所以向父節點插入b節點 ,注意的是,父節點下查詢時只有ab節點,但ab節點的前綴為
[a,ab]
。- 插入后,由于我們新增了一個葉節點,且完成,所以
remainder= remainder - 1
active_point = (root, '', 0),remainder = 0
第三個字符,c
- 在插入前,
remainder= remainder + 1
,表明,我們現在準備要插入一個后綴。- 擴展每一個葉節點,所以ab節點-->abc節點,b節點-->bc節點。
- 查詢父節點下,(所有已插入字符串的前綴中不包含c),所以,c需要獨立建立新的一個節點,所以向父節點插入c節點
- 插入后,由于我們新增了一個葉節點,且完成,所以
remainder= remainder - 1
active_point = (root, '', 0),remainder = 0
第四個字符,a
- 在插入前,
remainder= remainder + 1
,表明,我們現在準備要插入一個后綴。- 擴展每一個葉節點,所以abc節點-->abca節點,bc節點-->bca節點,c節點-->ca節點。
- 查詢父節點下,所有已插入字符串的前綴中是否包含a?發現的確是有的,且“最后一個”節點為,就是abca節點。
特殊操作1
active_point
,由于該節點abca的父節點還是root,所以第一個不變。
active_point[1] = 'a'
,指示子節點則等于,當前字符a。
active_point[2] = '1'
,位置索引,因為a在abca的第一個位置,所以等于1。
- 插入后,由于我們無新增了一個葉節點,且完成,所以
remainder
保持不變。
active_point = (root, 'a', 1),remainder = 1
為什么我們這里要這樣操作?
因為一旦我們發現了已插入字符串的前綴就是我們要插入的后綴,那么我們要找到“最后一個”節點。(為啥要最后一個?因為當字符串更長時,會出現你找到節點有很多。這時我們需要最后一個,可見第九個字符步驟)。那么說明這個將要插入的后綴與這“最后一個”節點的是共享一些字符串的。那么就會產生一次分叉(這分叉可能已經存在),那么這個分叉還不能直接分叉,因為還不能確定是在這個地方分叉,一定要到不一樣的地方才是分叉點,如果一直都一樣,就是完全一個重復的后綴。
所以這個地方我們插入了我們準備插入的后綴嗎?
沒有,現在整個后綴樹仍然只有后綴集合里的三個后綴。
第五個字符,b
- 在插入前,
remainder= remainder + 1
,表明,我們現在準備要插入一個后綴。- 擴展每一個葉節點,所以abca節點-->abcab節點,bca節點-->bcab節點,ca節點-->cab節點。
- 查詢父節點下,所有已插入字符串的前綴中是否包含ab?發現的確是有的,且“最后一個”節點為,就是abcab節點。(由于上面那個后綴我們沒插入,現在它也被擴展為ab了。)
特殊操作1
active_point
,由于該節點abcab的父節點還是root,所以第一個不變。
active_point[1] = 'ab'
,指示子節點則等于,擴展后的字符ab。
active_point[2] = '2'
,位置索引,因為b在abcab的第一個位置,所以等于1。
- 插入后,由于我們無新增了一個葉節點,且完成,所以
remainder
保持不變。
active_point = (root, 'ab', 2),remainder = 2
第六個字符,x
- 在插入前,
remainder= remainder + 1
,表明,我們現在準備要插入一個后綴。- 擴展每一個葉節點,所以abcab節點-->abcabx節點,bcab節點-->bcabx節點,cab節點-->cabx節點。
- 查詢父節點下,所有已插入字符串的前綴中是否包含abx?發現沒有。所以這是一個分叉點(分叉點的解釋可見第四個字符的解釋文字。)
特殊操作2
active_point[0]
,從父節點開始找,其子節點們。
active_point[1] = 'abx'
,在子節點們中找其前綴中含abx的節點。(其實就是第五個字符時找到的abcab,但現在為abcabx)
active_point[2] = '2'
,使用位置索引來尋找到將分叉的位置,即上圖中綠色箭頭所指向的位置
則我們插入abx這個節點,即如下圖
- 由于新增了一個葉節點,所以
remainder= remainder - 1
,但是由于remainder還>1,則我們繼續。- 插入bx這個節點(為什么還要插入bx?,因為我們中間漏了bx的后綴,所以得補上。)
一樣的,也是從active_point的父節點開始尋找,active_point[1]=bx的節點,且active_point[2]由于父節點不變,active_point[1]變化了。所以我們得-1。同樣為新增了葉節點,所以
remainder= remainder - 1
- 插入x這個節點,此時,
active_point = (root, 'x', 0),remainder = 1
此時,由于active_point[2]已經為0,說明,我們要在父節點的右側直接插入一個節點。插入后,同樣為新增了葉節點,所以
remainder= remainder - 1
。
自此,遇到分叉點的情況已經解決。
active_point = (root, '', 0),remainder = 0
Q:新增的兩個分叉點有關系嗎?
A:即ab和b節點,創建時就是因為存在abx到bx的前后關系。即如果我們再遇到abk之類的,我們插入完abk后,必然也要插入bk節點,所以一定會從ab節點到b節點。如果我們每次都要重新再找一次b節點,會使速度變慢。
Q:能利用其關系來加速嗎?
A:能。加入后綴鏈接,即圖中的綠色虛線箭頭(有向)
第七個字符,a
同第四個字符。
active_point = (root, 'a', 1),remainder = 1
第八個字符,b
同第五個字符
active_point = (root, 'ab', 2),remainder = 2
第九個字符,c
此時遇到一個比較神奇的事情,我們需要尋找已插入字符串時,發現abc存在。但“最后一個”節點不能cover整個字符串。那我們先不管,繼續下面的步驟。
- 在插入前,
remainder= remainder + 1
,表明,我們現在準備要插入一個后綴。- 擴展每一個葉節點。
- 查詢abc,找到“最后一個”節點是c。
特殊操作1
active_point
,由于該節點c的父節點是ab,所以active_point[0] = 節點ab
active_point[1] = 'abc'
,指示子節點則等于,擴展后的字符abc。
active_point[2] = '1'
,位置索引,因為c在cabxabc的第一個位置,所以等于1。
- 插入后,由于我們無新增了一個葉節點,且完成,所以
remainder
保持不變。
active_point = (節點ab, 'abc', 1),remainder = 3
第十個字符,d
那么就到了最后一個字符了,這個字符中也會使用到前文說的后綴連接。
- 在插入前,
remainder= remainder + 1
,表明,我們現在準備要插入一個后綴。- 擴展每一個葉節點
- 查詢父節點下,所有已插入字符串的前綴中是否包含abcd?發現沒有。所以這是一個分叉點(分叉點的解釋可見第四個字符的解釋文字。)
特殊操作2
active_point[0]
,從父節點開始找,即節點ab,找其子節點們。
active_point[1] = 'abcd'
,在子節點們對應字符串找其前綴中含abc的節點。(其實就是第九個字符時找到的cabxabc,但現在為cabxabcd)(雖然cabxabc自己不含有abc的前綴,但你要考慮到其對應字符串為abcabxabc。)
active_point[2] = '1'
,使用位置索引來尋找到將分叉的位置,即上圖中綠色箭頭所指向的位置
- 則我們插入abcd這個節點,即分裂cabxabcd,分成c -->[abxabcd,d]
- 由于新增了一個葉節點,所以
remainder= remainder - 1
,但是由于remainder還>1,則我們繼續。- 由于
active_point[0]
存在后綴連接,所以我們要沿著后綴連接的方向改變,active_point[0] = 節點b
,active_point[1] ='bcd'
,但由于父節點的變化,不涉及根節點,所以active_point[2]
不變- 插入bcd這個節點
一樣的,也是從active_point的父節點,即節點b,開始尋找,active_point[1]=bcd的“最后一個”節點。所以找到了cabxabcd,所以分裂cabxabcd,,分成c -->[abxabcd,d]。同樣新增了葉節點,所以
remainder= remainder - 1
- 因為此時的
active_point[0]
無后綴連接,所以我們把active_point[0]
重置為根節點,active_point[1] = cd
,active_point[2]-=1
。由于父節點重置為根節點,所以-1。- 插入cd這個節點
一樣的,也是從active_point的父節點,即節點b,開始尋找,active_point[1]=bcd的“最后一個”節點。所以找到了cabxabcd,所以分裂cabxabcd,,分成c -->[abxabcd,d]。同樣新增了葉節點,所以
remainder= remainder - 1
- 插入d這個節點,此時,
active_point = (root, 'd', 0),remainder = 1
此時,由于active_point[2]已經為0,說明,我們要在父節點的右側直接插入一個節點。插入后,同樣為新增了葉節點,所以
remainder= remainder - 1
。
最后的后綴樹長這樣。
回頭總結
- active_point的變化規律
active_point[0],只有當遇到分叉點時,我們把最后能找到的那個節點的父節點作為active_point[0]。
active_point[1],原作者跟我在這個地方有點差異,我會選擇在這個位置儲存 已存在的字符的。
active_point[2],原則上等于最后一個字符(非分叉點)在能找到的那個節點上的位置。
active_point[2]的變化,當active_point[0]為根節點時,每次插入完要-1,當active_point[0]不是根節點,且其沒有后綴連接時,每次-1
- remainder則是觀察有沒有葉節點的變化,因為每個葉節點代表一個字符串,而且是唯一的一個字符串。若葉節點增加了,則代表加入了一個新的字符串。則
remainder-=1
全文結束。。。。。。
寫完大概是個廢魚了。。。對了。。。還有代碼。。。還有些注意事項
注意事項
- 后綴樹的查找時存在一種情況,若上述的后綴樹不以d結尾,會發生什么情況呢?
會使得很多的分支都不會出現在圖上,也就是abc,bc,c這幾個后綴,變成了一個隱式的字符串在后綴樹中。(因為我們一直存著,但是一直遇不到分叉點。)那么這種情況只要簡單的加一個結尾即可,有時會加入特殊字符。
代碼在這...
algorithms_in_python/suffix_tree/trie_tree_Ukkonen_al.py
其中,另一個trie_tree是trie的實現,并且構建樹的方法也完全不一樣。而trie_tree_Ukkonen_al才是使用Ukkonen構建的后綴樹。
里面如果有些奇怪的注釋之類大家可以忽略。。。
還有一些奇怪的命名大家也可以忽略。。。
寫完大概是個廢魚了,繼續寫點別的好了。。。
如果有人問這個東西能做啥的話。
1.查找字符串O是否在字符串S中。
方案:用S構造后綴樹,按在trie中搜索字串的方法搜索O即可。
原理:若O在S中,則O必然是S的某個后綴的前綴。
例如:leconte,查找O:con是否在S中,則O(con)必然是S(leconte)的前綴。
2.指定字符串T在字符串S中的重復次數。
方案:用S+’$’構造后綴樹,搜索T節點下的葉子節點數目即為重復次數
原理:如果T在S中重復了兩次,則S應有兩個后綴以T為前綴,重復次數自然統計出來了。
3.字符串S中的最長重復子串
方案:原理同2,具體做法是找到最深的非葉子節點。
這個深指從root所經歷過的字符個數,最深非葉子節點所經歷的字符串起來就是最長重復子串。為什么非要是葉子節點呢?因為既然是要重復的,當然葉子節點個數要>=2
4.兩個字符串S1,S2的最長公共子串(而非以前所說的最長公共子序列,因為子序列是不連續的,而子串是連續的。)
方案:將S1#S2$作為字符串壓入后綴樹,找到最深的非葉子節點,且該節點的葉子節點既有#也有$.
5.最長回文子串
以上是我copy and paste來的。。。
。。。