1. 程式人生 > >經典查詢演算法及其Python實現

經典查詢演算法及其Python實現

寫在前面

上一篇介紹了幾大排序演算法,從基本原理解釋到Python程式碼實現,平時有空的話還需要經常翻出來複習複習。今天就主要來看看另外一大類演算法:經典查詢演算法。

本篇相關python程式碼已上傳至Github:使勁兒點!

1.基本概念

查詢就是根據給定的某個值,在查詢表中確定一個其關鍵字等於給定值的資料元素。

查詢表:由同一型別的資料元素構成的集合

關鍵字:資料元素中某個資料項的值,又稱為鍵值

主鍵:可唯一的標識某個資料元素或記錄的關鍵字

查詢表按照操作方式可分為:

1.靜態查詢表(Static Search Table):只做查詢操作的查詢表。它的主要操作是:

  • 查詢某個“特定的”資料元素是否在表中
  • 檢索某個“特定的”資料元素和各種屬性
2.動態查詢表(Dynamic Search Table):在查詢中同時進行插入或刪除等操作:
  • 查詢時插入資料
  • 查詢時刪除資料

2.無序表查詢

也就是資料不排序的線性查詢,遍歷資料元素。

演算法分析:最好情況是在第一個位置就找到了,此為O(1);最壞情況在最後一個位置才找到,此為O(n);所以平均查詢次數為(n+1)/2。最終時間複雜度為O(n)

下面是基於遍歷元素的無序表查詢程式碼實現

def unorder_search(lis, key):
    length = len(lis)
    for i in range(length):
        if lis[i] == key:
            return i
        else:
            return False

3.有序表查詢

有序表查詢也就是表中資料必須按某個主鍵的某種排序方式進行。

3.1 二分查詢(Binary Search)

演算法核心:在查詢表中不斷取中間元素與查詢值進行比較,以二分之一的倍率進行表範圍的縮小。

其時間複雜度為O(log(n))

def binary_search(lists, key):
    low = 0
    high = len(lists) - 1
    time = 0
    while low <= high:
        time += 1
        mid = int((low + high) / 2)
        if key < lists[mid]:
            high = mid - 1
        elif key > lists[mid]:
            low = mid + 1
        else:
            # 列印折半的次數
            print("times: %s" % time)
            return mid
    print("times: %s" % time)
    return False

3.2 插值查詢

二分查詢法雖然已經很不錯了,但還有可以優化的地方。

有的時候,對半過濾還不夠狠,要是每次都排除十分之九的資料豈不是更好?選擇這個值就是關鍵問題,插值的意義就是:以更快的速度進行縮減。

插值的核心就是使用公式:
value = (key - list[low])/(list[high] - list[low])

用這個value來代替二分查詢中的1/2。上面二分查詢的程式碼可以直接使用,只需要改一句。

其時間複雜度仍為O(log(n))

def binary_search2(lists, key):
    low = 0
    high = len(lists) - 1
    time = 0
    while low <= high:
        time += 1
        # 計算mid值是插值演算法的核心程式碼
        mid = low + int((high - low) * (key - lists[low])/(lists[high] - lists[low]))
        print("mid=%s, low=%s, high=%s" % (mid, low, high))
        if key < lists[mid]:
            high = mid - 1
        elif key > lists[mid]:
            low = mid + 1
        else:
            # 列印查詢的次數
            print("times: %s" % time)
            return mid
    print("times: %s" % time)
    return False
插值演算法的總體時間複雜度仍然屬於O(log(n))級別的。其優點是,對於表內資料量較大,且關鍵字分佈比較均勻的查詢表,使用插值演算法的平均效能比二分查詢要好得多。反之,對於分佈極端不均勻的資料,則不適合使用插值演算法。

3.3斐波那契查詢

由插值演算法帶來的啟發,發明了斐波那契演算法。其核心也是如何優化那個縮減速率,使得查詢次數儘量降低。
使用這種演算法,前提是已經有一個包含斐波那契資料的列表

F = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144,...]

其時間複雜度為O(log(n))

def fibonacci_search(lists, key):
    # 需要一個現成的斐波那契列表。其最大元素的值必須超過查詢表中元素個數的數值。
    F = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144,
         233, 377, 610, 987, 1597, 2584, 4181, 6765,
         10946, 17711, 28657, 46368]
    low = 0
    high = len(lists) - 1    
    # 為了使得查詢表滿足斐波那契特性,在表的最後新增幾個同樣的值
    # 這個值是原查詢表的最後那個元素的值
    # 新增的個數由F[k]-1-high決定
    k = 0
    while high > F[k]-1:
        k += 1
    print(k)
    i = high
    while F[k]-1 > i:
        lis.append(lists[high])
        i += 1
    print(lists)
    
    # 演算法主邏輯。time用於展示迴圈的次數。
    time = 0
    while low <= high:
        time += 1
        # 為了防止F列表下標溢位,設定if和else
        if k < 2:
            mid = low
        else:
            mid = low + F[k-1]-1
        
        print("low=%s, mid=%s, high=%s" % (low, mid, high))
        if key < lists[mid]:
            high = mid - 1
            k -= 1
        elif key > lists[mid]:
            low = mid + 1
            k -= 2
        else:
            if mid <= high:
                # 列印查詢的次數
                print("times: %s" % time)
                return mid
            else:
                print("times: %s" % time)
                return high
    print("times: %s" % time)
    return False
演算法分析:斐波那契查詢的整體時間複雜度也為O(log(n))。但就平均效能,要優於二分查詢。但是在最壞情況下,比如這裡如果key為1,則始終處於左側半區查詢,此時其效率要低於二分查詢。

總結:二分查詢的mid運算是加法與除法,插值查詢則是複雜的四則運算,而斐波那契查詢只是最簡單的加減運算。在海量資料的查詢中,這種細微的差別可能會影響最終的查詢效率。因此,三種有序表的查詢方法本質上是分割點的選擇不同,各有優劣,應根據實際情況進行選擇。

4.線性索引查詢

對於海量的無序資料,為了提高查詢速度,一般會為其構造索引表。
索引就是把一個關鍵字與它相對應的記錄進行關聯的過程。
一個索引由若干個索引項構成,每個索引項至少包含關鍵字和其對應的記錄在儲存器中的位置等資訊。
索引按照結構可以分為:線性索引、樹形索引和多級索引。
線性索引:將索引項的集合通過線性結構來組織,也叫索引表。
線性索引可分為:稠密索引、分塊索引和倒排索引

4.1稠密索引

稠密索引指的是線上性索引中,為資料集合中的每個記錄都建立一個索引項。

這其實就相當於給無序的集合,建立了一張有序的線性表。其索引項一定是按照關鍵碼進行有序的排列。
這也相當於把查詢過程中需要的排序工作給提前做了。

4.2分塊索引

給大量的無序資料集合進行分塊處理,使得塊內無序,塊與塊之間有序。

這其實是有序查詢和無序查詢的一種中間狀態或者說妥協狀態。因為資料量過大,建立完整的稠密索引耗時耗力,佔用資源過多;但如果不做任何排序或者索引,那麼遍歷的查詢也無法接受,只能折中,做一定程度的排序或索引。

分塊索引的效率比遍歷查詢的O(n)要高一些,但與二分查詢的O(logn)還是要差不少。


4.3 倒排索引

不是由記錄來確定屬性值,而是由屬性值來確定記錄的位置,這種被稱為倒排索引。其中記錄號表儲存具有相同次關鍵字的所有記錄的地址或引用(可以是指向記錄的指標或該記錄的主關鍵字)。

倒排索引是最基礎的搜尋引擎索引技術。

5.二叉查詢樹(BST)和平衡二叉樹(AVL)

這兩種查詢演算法原理及程式碼實現已經在前面資料結構中具體分析過,這裡就不再贅述

6. 多路查詢樹(B樹)                                       

多路查詢樹(muitl-way search tree):其每一個節點的孩子可以多於兩個,且每一個結點處可以儲存多個元素。對於多路查詢樹,每個節點可以儲存多少個元素,以及它的孩子數的多少是關鍵,常用的有這4種形式:2-3樹、2-3-4樹、B樹和B+樹。

6.1   2-3樹

2-3樹:每個結點都具有2個孩子,或者3個孩子,或者沒有孩子。

一個2結點包含一個元素和兩個孩子(或者沒有孩子,不能只有一個孩子)。與二叉排序樹類似,其左子樹包含的元素都小於該元素,右子樹包含的元素都大於該元素。
一個3結點包含兩個元素和三個孩子(或者沒有孩子,不能只有一個或兩個孩子)。
2-3樹中所有的葉子都必須在同一層次上。


6.1.1 查詢

要判斷一個鍵是否在樹中,先將它和根結點中的鍵比較。如果它和其中任意一個相等,查詢命中;否則我們就根據比較的結果找到指向相應區間的連結,並在其指向的子樹中遞迴地繼續查詢。如果這是個空連結,則查詢未命中。


6.1.2 插入


插入一共有四種不同的情況:

①向2-結點中插入新鍵:只要把這個2-結點換為一個3-結點,將要插入的鍵儲存在其中即可。


②向只含一個3-結點的樹中插入新鍵:先臨時將新鍵存入該結點中形成一個4-結點。然後將其轉換為一顆由3個2-結點組成的2-3樹,其中一個結點(根)含有中鍵,一個結點含有三個鍵中的最小者(和根結點左連結相連),一個結點含有三個鍵中的最大者(右相連)。如下所示:


③向一個父節點為2-結點的3-結點中插入新鍵:首先將3-結點替換為包換新鍵的4-結點,其次將2-結點替換為含有中鍵的新3-結點,最後將4-結點分解為兩個2-結點並將中鍵移動至父節點中,如下所示:


④向一個父結點為3-結點的3-結點中插入新鍵:處理方式與③類似,一直向上不斷分解,直至遇到一個2-結點。


6.1.3 刪除



6.2  B樹

B樹是一種平衡的多路查詢樹。節點最大的孩子數目稱為B樹的階(order)。2-3樹是3階B樹,2-3-4是4階B樹。
B樹的資料結構主要用在記憶體和外部儲存器的資料互動中。




 B+樹

為了解決B樹的所有元素遍歷等基本問題,在原有的結構基礎上,加入新的元素組織方式後,形成了B+樹。
B+樹是應檔案系統所需而出現的一種B樹的變形樹,嚴格意義上將,它已經不是最基本的樹了。
B+樹中,出現在分支節點中的元素會被當做他們在該分支節點位置的中序後繼者(葉子節點)中再次列出。另外,每一個葉子節點都會儲存一個指向後一葉子節點的指標。


所有的葉子節點包含全部的關鍵字的資訊,及相關指標,葉子節點本身依關鍵字的大小自小到大順序連結
B+樹的結構特別適合帶有範圍的查詢。比如查詢年齡在20~30歲之間的人。

7. 散列表(雜湊表)

散列表:所有的元素之間沒有任何關係。元素的儲存位置,是利用元素的關鍵字通過某個函式直接計算出來的。這個一一對應的關係函式稱為雜湊函式或Hash函式。

採用雜湊技術將記錄儲存在一塊連續的儲存空間中,稱為散列表或雜湊表(Hash Table)。關鍵字對應的儲存位置,稱為雜湊地址。

散列表是一種面向查詢的儲存結構。它最適合求解的問題是查詢與給定值相等的記錄。但是對於某個關鍵字能對應很多記錄的情況就不適用,比如查詢所有的“男”性。也不適合範圍查詢,比如查詢年齡20~30之間的人。排序、最大、最小等也不合適。

因此,散列表通常用於關鍵字不重複的資料結構。比如python的字典資料型別。

設計出一個簡單、均勻、儲存利用率高的雜湊函式是雜湊技術中最關鍵的問題。
但是,一般雜湊函式都面臨著衝突的問題。

衝突:兩個不同的關鍵字,通過雜湊函式計算後結果卻相同的現象。collision。

使用雜湊的查詢演算法分為兩步:第一步是用雜湊函式將被查詢的鍵轉化為陣列的一個索引。第二步就是一個處理碰撞衝突的過程

7.1 雜湊函式的構造

一個好的雜湊函式:計算簡單、雜湊地址分佈均勻

1. 直接定址法
例如取關鍵字的某個線性函式為雜湊函式:

f(key) = a*key + b (a,b為常數)

2. 數字分析法

抽取關鍵字裡的數字,根據數字的特點進行地址分配

3. 平方取中法

將關鍵字的數字求平方,再擷取部分

4. 摺疊法

將關鍵字的數字分割後分別計算,再合併計算,一種玩弄數字的手段。

5. 除留餘數法
最為常見的方法之一。
對於表長為m的資料集合,雜湊公式為:
f(key) = key mod p (p<=m)
mod:取模(求餘數)

該方法最關鍵的是p的選擇,而且資料量較大的時候,衝突是必然的。一般會選擇接近m的質數。

6. 隨機數法
選擇一個隨機數,取關鍵字的隨機函式值為它的雜湊地址。

f(key) = random(key)

總結,實際情況下根據不同的資料特性採用不同的雜湊方法,考慮下面一些主要問題:
  • 計算雜湊地址所需的時間
  • 關鍵字的長度
  • 散列表的大小
  • 關鍵字的分佈情況
  • 記錄查詢的頻率

7.2 處理雜湊衝突

1. 開放定址法

就是一旦發生衝突,就去尋找下一個空的雜湊地址,只要散列表足夠大,空的雜湊地址總能找到,並將記錄存入。公式如下:


這種簡單的衝突解決辦法被稱為線性探測,無非就是自家的坑被佔了,就逐個拜訪後面的坑,有空的就進,也不管這個坑是不是後面有人預定了的。
線性探測帶來的最大問題就是衝突的堆積,你把別人預定的坑佔了,別人也就要像你一樣去找坑。

2. 再雜湊函式法

發生衝突時就換一個雜湊函式計算,總會有一個可以把衝突解決掉,它能夠使得關鍵字不產生聚集,但相應地增加了計算的時間。

3. 連結地址法 

碰到衝突時,不更換地址,而是將所有關鍵字為同義詞的記錄儲存在一個連結串列裡,在散列表中只儲存同義詞子表的頭指標,如下圖:


這樣的好處是,不怕衝突多;缺點是降低了雜湊結構的隨機儲存效能。本質是用單鏈表結構輔助雜湊結構的不足。

4. 公共溢位區法

其實就是為所有的衝突,額外開闢一塊儲存空間。如果相對基本表而言,衝突的資料很少的時候,使用這種方法比較合適。


class HashTable:
    def __init__(self, size):
        self.elem = [None for i in range(size)]  # 使用list資料結構作為雜湊表元素儲存方法
        self.count = size  # 最大表長

    def hash(self, key):
        return key % self.count  # 雜湊函式採用除留餘數法

    def insert_hash(self, key):
        """插入關鍵字到雜湊表內"""
        address = self.hash(key)  # 求雜湊地址
        while self.elem[address]:  # 當前位置已經有資料了,發生衝突。
            address = (address+1) % self.count  # 線性探測下一地址是否可用
        self.elem[address] = key  # 沒有衝突則直接儲存。

    def search_hash(self, key):
        """查詢關鍵字,返回布林值"""
        star = address = self.hash(key)
        while self.elem[address] != key:
            address = (address + 1) % self.count
            if not self.elem[address] or address == star:  # 說明沒找到或者迴圈到了開始的位置
                return False
        return True


if __name__ == '__main__':
    list_a = [12, 67, 56, 16, 25, 37, 22, 29, 15, 47, 48, 34]
    hash_table = HashTable(12)
    for i in list_a:
        hash_table.insert_hash(i)

    for i in hash_table.elem:
        if i:
            print((i, hash_table.elem.index(i)), end=" ")
    print("\n")

    print(hash_table.search_hash(15))
    print(hash_table.search_hash(33))

 如果沒發生衝突,則其查詢時間複雜度為O(1),屬於最極端的好了。
但是,現實中衝突可不可避免的,下面三個方面對查詢效能影響較大:

  • 雜湊函式是否均勻
  • 處理衝突的辦法
  • 散列表的裝填因子(表內資料裝滿的程度)

小結

對以上幾種經典的查詢演算法進行對比

以上~

2018.05.02