算法筆記(IV) 字典

典在計算機中指信息及其索引,也可以理解成Key-Value的關聯數組或者map(本質上是key與value構成的笛卡爾積的子集,不同於數學中的映射,其可以是一對多的關係)。key可以是數字、字符串或更復雜的結構等。實現字典的常用數據結構有:hash表、字典樹(trie)、二叉樹、B樹等,它們的優缺點各異,適用不同的場景,在一些場景中,爲了節省空間開銷或者是加快檢索的速度,甚至可以組合適用。


    “字典”就像函數(映射map)在數學中的地位一樣,在計算機中應用極其廣泛,小到字符串匹配、字典壓縮算法,大到編譯器、數據庫、文件系統、搜索引擎等,都需要建立或者維護字典。高德納用了第三卷的下半部來講解“查找”(並聲稱程序設計的每一重要方面都離不開排序和查找),就涵蓋了以上的幾種數據結構。另外,其上半部花了很大的精力去講解“排序”,是因爲我們的查找本身能夠達到高效,需要排序處理,例如二分查找、在查找字符串時,如果字符串是經過字典序排序的,那麼就極大的方便我們的查找。下面使用現實中的例子來說明使用這些數據結構和查找手段的現實原型。

     當我們打開一本書的時候,我們急需的就是瀏覽索引,掌握這本書的結構。索引有很多種,目錄就是一種最常見的索引,一本書往往以章節展開,首先是分成各章,然後各章再細化成小節,一般的目錄的層次只有章節這兩層,一些內容比較多的書可能會有3-4層之多,但再多就對讀者構成了太多的壓力(有實驗表明人類能夠接受的遞歸層數不超過7層,盜夢空間也不過4層結構;)),也不能做到簡明瞭。這種目錄結構是B樹結構的原型,B樹的優點是層次遞歸結構,並可以將上層節點放置內存,而下層節點放在磁盤中,因相對二叉樹其層數更少,可以降低磁盤IO的次數,並支持一些範圍查詢等功能。 

     在實際使用中,一旦知道頁數,我們並不是逐頁的翻找,而是先估算一下位置(如果我們不知道總頁數或者書的厚度的話,翻一半位置是最保險的辦法),啓發式的翻到一個頁面,並比對頁碼,依次遞歸下去,一般很快就可以找到對應的頁碼。這就是二分查找的原型。

      另外一種索引也非常的常見,比如英文詞典的詞是經過以字典序排過的,非常利於我們的查找,我們在檢索時,順着單詞的前綴不斷的定位目標。這種索引結構對應的是trie樹(字典樹,一種前綴樹)。在中文中,我們常根據拼音去定位字,拼音的字母就利用了字典序(當然和英文字母的順序不同),對於念相同讀音的字又按照筆畫數目多少進行排序方便定位。思想和英文是相通的。另外,利用trie樹或者字典序,我們可以方便的查找出擁有相同前綴的字或者單詞。另外後綴樹和後綴數組可以實現更多的功能,對此瞭解的不是很多,參考[suffix tree]。

      還有一種索引,這種索引定位速度往往更快,比如在中文中,可以利用筆畫數目直接定位字,對於一些場景速度更快。這就是hash表的原型:抽取對象的某一種性質,映射成一個大於0的整數,快速定位數據表。當然這種方案在一些場景並不好,例如筆畫數目在30以上的字總共纔在12個,但筆畫數目爲10的字竟然有1700多,這就是說hash表存在衝突(而且非常的不均勻)。爲了處理這種情況,依然是對這些字依讀音進行排序。另外,我們的字典也採用偏旁部首來對字進行編排,對那些有相同的偏旁部首的字又經過以筆畫數目從小到大排序,以方便我們查找。如果以偏旁部首的筆畫數目以方便減少部首,則衝突就相對小的多了,筆畫數目最多的偏旁部首也不過15劃,衝突最多的數目是4劃的部首,但也不過52個。所以我們先使用筆畫數目定位部首,再在有相同部首中再使用全字的筆畫數目找到相應的字(甚至如果再發現有衝突的話,在以拼音定位),則更方便快速。另外,對於靜態的字典,可以構造出最小完美hash函數,避免衝突並節省內存開銷。

     從上面的討論可以看出,合理的設置索引將方便我們的檢索,而且索引要根據應用場景針對性的設置,沒有任何一種數據結構適用所有的情況。而且,在實際中,往往是多種索引結構組合使用。例如上述先利用筆畫數目定位部首,在在部首中再以全字筆畫數定位字就是一種二層hash結構(two-level hash table,見於nosql數據庫系統),當然這仍不能保證不衝突,一般我們的字典都是靜態的,很少出現增添字這種事情,即使再版我們也只要重新以拼音或者筆畫數再編排一下就可以了。

     在專業詞典和英文詞典中,需要不斷的再版修訂補充,週期短則一年兩年,長則可能幾十年甚至不會再版修訂,而對於計算機維護的資源來說,更新的頻率就快的多了,想想一下大型的網站,如google、amazon等,其讀寫更新數據的實時性要求很高,需要更快速的更新內容以及字典,因此往往採用動態的數據結構維護,二叉樹就是一種優良的動態結構,可以保證動態的添加編輯數據,因此上述的場景中,需要排序的功能而又需要動態維護的功能,均可以採用二叉樹進行實現。例如上述的二層hash結構仍然出現衝突的位置,可以掛接一個二叉樹以某種序進行排序,即方便檢索又方便動態的維護。

     另外,上述講到的trie樹在計算機實現上也有一定的考究。 例如我們發現以z開頭的拼音會非常的多,而以o開頭的拼音不過o、ou兩個,也就是說我們的trie樹的分支是非常的稀疏的(或者不平衡的),有的分支比較多,有的就比較的少,因此如果我們的空間很緊張的話,我們就不需要爲o開頭的拼音設置一個完整的字母表搭配了。所以一般可以使用鏈表實現的稀疏數組(sparse array)、二叉樹等動態數據結構表示trie樹的子節點,以降低空間開銷。相比而言,二叉樹維護序的功能要比鏈表更爲方便。另外,還有一些優化手段,沒有仔細研究,參見[double array trie]。

     在實際應用場景中,要查找的對象往往是存儲在磁盤中的,索引爲了更快速,一般常駐內存,因此爲了節省內存並減少內存換頁帶來的效率問題,所以儘量使得索引的空間開銷較小。考慮以上的原因,我們並不會在索引中直接存儲查找對象的值,因爲對象可能是一個根本就無法放進內存的文件或者大數據塊。作爲一個技巧,我們只會在索引中記錄對象數據的指針或者說是句柄抑或是磁盤號扇區等,這樣的開銷就遠遠小於對象數據了。另外,如果內存仍然緊張,則可以將索引同樣放在磁盤中,利用緩存技術將經常訪問的索引加載到內存中來,並利用緩存替換算法根據使用的頻繁度進行淘汰維護,如Tokyo Cabinet,網絡搜索引擎也採用一些類似的緩存技術加速查詢速度。另外爲了克服隨機寫的問題,google的levelDB採用了LSM tree結構,將頻繁更新的內容放在內存中,並批量的寫入磁盤(細節實現不詳,有待繼續研究)。如果沒有遇到隨機寫這種場景,例如日誌系統或者是磁帶系統,我們永遠是順序的寫數據,而使用場景主要是隨機讀(即檢索查找),一般這種系統採用hash表,又高效而簡單了,如Bitcask

     但問題又來了,我們如何確定索引中放置的指針指向的對象數據就是我們要檢索的目標呢?萬一花了大力氣從磁盤中讀取出來的東西,卻根本不是我們想要的呢?在數據庫系統中,往往採用校驗碼來解決這個問題。對於一些小的數據塊,我們可以使用CRC系列快速的計算出校驗碼(本質也是hash),和目標的校驗碼比較,如果相同,則很可能就是我們要查找的目標(當然也可能存在衝突),而對於大文件可以使用衝突更小的hash,如MD5等。這裏簡單提一提,對於數據同步的場景(例如網絡傳輸場景,需要同步多個網關或服務器上的數據;或者客戶端和服務器端的下載數據,如電驢、迅雷等),可以採用hash樹進行維護,如果發現某個數據不一致,則可以遞歸的定位到更小的不同步數據塊,以降低因不同步而傳輸的數據量。
   
     另外,有一些場景也對磁盤IO(或者網絡傳輸)有嚴格的要求,我們最好能夠預先判斷數據庫中是否存在此數據,以減少不必要的磁盤IO或者網絡傳輸,此時就可以採用布隆濾波器來完成。本質上,布隆濾波器也是一種hash的改進版本。舉個簡單的例子,假設我想檢索一堆字中有沒有我想要的字,我可以預先的建立一個表,這一堆字的筆畫數對應的表值均設置爲1,反之爲0;而這一堆字的偏旁部首的筆畫數對應的表值也設置爲1,反之爲0。所以一個字過來,我只需預先判斷這個字的筆畫數和偏旁部首筆畫數對應的表值是否爲1,如果其中一個不爲1,則我立即得出結論,這個字在這堆字中沒有出現,以節省讀取和比較時間。當然布隆濾波器只是一種啓發式方案,會存在誤判,但是不會存在漏判,這點需要在不同的工業應用仔細考慮,如安全性要求很高的場景,如入侵檢測青睞於更低的漏判率,而一些場景,如垃圾郵件檢測等,我們更傾向於不要誤判。

     現在上述介紹的主要是正排索引(前向索引),就像是矩陣有其轉置(倒排索引更像是對正排索引的轉置,而不是逆矩陣,沒有舉函數與反函數的例子,因不太嚴謹)一樣,有正排索引就有倒排索引(也稱反文件)。假如我們利用正派索引檢索某一個頁面包含的關鍵詞有那些,另一些場景則需要我們根據出現的關鍵詞以及集合來檢索頁面(多見於搜索引擎)。這時就需要倒排索引來保證快速的定位了。倒排索引會記錄出出現了某個關鍵詞的頁面號,如果要查找出現了某幾個關鍵詞的頁面,我們可以取這幾個關鍵詞的頁面號集合的並集,迅速的定位頁面,並直接翻看到對應的頁面。
    
    倒排索引不僅在一些場景可以加快查詢,也可以與正排索引組合使用。類似的應用可見於公交路線查詢和列車路線查詢,使用正排索引和倒排索引可以方便的搜索換乘路線。簡單的提一下,如果用0-1矩陣M表示公交路線或者列車路線中站點和路線的索引,則M*M‘表示一次正排索引和倒排索引組合查詢,意味着兩條路線是否有交叉點。同理M*M’*M*M'則表示再次組合查詢,經過一次換乘的查詢情況,如此類推。

    倒排索引結構在英文的書籍中非常的常見,一般都附在書的尾部,輔助讀者快速定位內容。例如一本書的內容編排無法做到完全的線性,前面的章節可能會引入一些概念,而這些概念的講解主要放在後面章節進行講解。如果遇到這種情況,最好使用倒排索引,利用這些概念的相關關鍵字快速定位出現的頁面號,就可以迅速的把握概念的來龍去脈以及被使用的關係了。

    本文簡單的介紹了字典的現實世界原型以及一些理解體會,字典的設計遠非這麼簡單,就像很少書的索引能夠滿足讀者的需求一樣,需要很多的考究。

這一週杯具的打醬油了,本來想和產品線確定需求的,結果發現需求似乎過高,遲遲不敢確定。做了幾個簡單的實驗,發現要提高真的很難,要再想新的路子了。 
2011-11-21 0:56

最近想了一下hash表的處理,如果一個hash表一旦衝突就將原先的項替換的話,實際上這個hash表是一個緩存實現;
爲什麼容許這種可以替換的hash表呢?這主要是爲了存儲空間使用而考慮,如一些場景要求使用內存可配置(也就是可以固定使用內存量),甚至有些場景存儲空間是硬性的(如CPU的cache,是硬件設計造成的,而且因在CPU晶圓上的面積限制而非常昂貴);

可替換hash表常常用於壓縮算法、檢索、因特網瀏覽器等場景,這些場景並不需要保證數據完全的被記錄下來,只需要記錄最近訪問或者頻繁訪問的數據,因此hash表衝突後拋棄原數據項並不會對系統的運行構成威脅(這似乎有點像操作系統和CPU中的緩存和cache)。據我所知,這種衝突式hash表(也就是緩存)可用於web服務器的優化、數據壓縮、搜索引擎、匹配和搜索算法的優化(例如正則式匹配的優化,如google的re2正則式引擎)等等,
另外,動態規劃算法遇到規模龐大的問題也會用到這種會衝突拋棄的hash表作爲狀態緩存,以節省內存開銷(見  程序設計中常用的解題策略 王建德等)。

2011-12-28 20:53

補充:字典的創建
在搜索引擎技術上,字典的創建可以分爲兩種:(1) 預先設置單詞庫+多模式匹配算法(AC自動機),例如如果我們的單詞庫是{“北京”,“北京人”,“人”,“在”,“紐約”},那麼“北京人在紐約”,就很可能分成“北京人 | 在 | 紐約”;(2)使用n-gram方法,例如我們使用2-gram方案(2個詞作爲一個分詞),則“北京人在紐約”,可以分成:“北京”,“京人”,“人在”,“在紐”,“紐約”;顯然第一種方案的可能會遺漏一些可能匹配的結果,因爲搜索的結果依賴於單詞庫的設計,並牽扯到詞性、句法等問題,第二種方案,不會遺漏任何結果,但是會導致很多錯誤的匹配結果。

這裏簡單提一下,有時候我們並不需要去建立字典,或者顯式地構建一個字典,例如正則式匹配等,我們可以即時的根據用戶輸入的搜索詞,匹配存儲的文檔,當然帶來計算量較大。所以一般的搜索引擎使用的是顯式的構建字典(使用上述的兩種方案),從而得到倒排索引以加快搜索。

在《深入理解搜索引擎》一書中,花了很大的篇幅去講解壓縮算法,以爲確實“壓縮算法”和“搜索引擎”在很多方面是相通的,例如滑動窗口壓縮算法LZ77就採用的就是隱式的構建字典,而LZW就是顯式的構建字典。另外對於“重複數據刪除技術”(本質上是一種字典壓縮算法)的劃分數據塊技術也與上述的兩種分詞構建字典的技術相通,請參考[http://blog.csdn.net/liuben/article/details/5829083]。

最近正在看《大規模web服務開發技術》,其中也談到“壓縮技術”是與“搜索引擎”緊密相關的。其實我不僅覺得兩者的關係不僅僅是“搜索引擎”中不斷使用“壓縮技術”,如文本壓縮、倒排索引的壓縮等,而是兩者在思想上也有很多的相似之處,特別是對於特殊的字典壓縮算法:重複數據刪除來說,其和“搜索引擎”作用和結構太相似了,所以由衷感覺在設計“重複數據刪除”方案時,就像在構建一個搜索引擎一樣。

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