常用的查找算法(python)

基本概念

查找(Searching)就是根據給定的某個值,在查找表中確定一個其關鍵字等於給定值的數據元素(或記錄)。

查找表(Search Table):由同一類型的數據元素(或記錄)構成的集合
關鍵字(Key):數據元素中某個數據項的值,又稱爲鍵值。
主鍵(Primary Key):可唯一地標識某個數據元素或記錄的關鍵字。
查找表按照操作方式可分爲:

  • 靜態查找表(Static Search Table):只做查找操作的查找表。它的主要操作是:
  • 查詢某個“特定的”數據元素是否在表中
  • 檢索某個“特定的”數據元素和各種屬性
  • 動態查找表(Dynamic Search Table):在查找中同時進行插入或刪除等操作:
  • 查找時插入數據
  • 查找時刪除數據

無序表查找

也就是數據不排序的線性查找,遍歷數據元素。
算法分析:最好情況是在第一個位置就找到了,此爲O(1);最壞情況在最後一個位置才找到,此爲O(n);所以平均查找次數爲(n+1)/2。最終時間複雜度爲O(n)

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


if __name__ == '__main__':
    LIST = [1, 5, 8, 123, 22, 54, 7, 99, 300, 222]
    result = sequential_search(LIST, 123)
    print(result)

有序表查找

查找表中的數據必須按某個主鍵進行某種排序!

  1. 二分查找(Binary Search)
    算法核心:在查找表中不斷取中間元素與查找值進行比較,以二分之一的倍率進行表範圍的縮小。
# 針對有序查找表的二分查找算法
# 時間複雜度O(log(n))

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

if __name__ == '__main__':
    LIST = [1, 5, 7, 8, 22, 54, 99, 123, 200, 222, 444]
    result = binary_search(LIST, 99)
    print(result)
  1. 插值查找
    二分查找法雖然已經很不錯了,但還有可以優化的地方。
    有的時候,對半過濾還不夠狠,要是每次都排除十分之九的數據豈不是更好?選擇這個值就是關鍵問題,插值的意義就是:以更快的速度進行縮減。

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

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

# 插值查找算法
# 時間複雜度O(log(n))

def binary_search(lis, key):
    low = 0
    high = len(lis) - 1
    #time = 0
    while low <= high:
        #time += 1
        # 計算mid值是插值算法的核心代碼
        mid = low + int((high - low) * (key - lis[low])/(lis[high] - lis[low]))
        print("mid=%s, low=%s, high=%s" % (mid, low, high))
        if key < lis[mid]:
            high = mid - 1
        elif key > lis[mid]:
            low = mid + 1
        else:
            # 打印查找的次數
            print("times: %s" % time)
            return mid
    #print("times: %s" % time)
    return False

if __name__ == '__main__':
    LIST = [1, 5, 7, 8, 22, 54, 99, 123, 200, 222, 444]
    result = binary_search(LIST, 444)
    print(result)

插值算法的總體時間複雜度仍然屬於O(log(n))級別的。其優點是,對於表內數據量較大,且關鍵字分佈比較均勻的查找表,使用插值算法的平均性能比二分查找要好得多。反之,對於分佈極端不均勻的數據,則不適合使用插值算法。
3. 斐波那契查找
由插值算法帶來的啓發,發明了斐波那契算法。其核心也是如何優化那個縮減速率,使得查找次數儘量降低。
使用這種算法,前提是已經有一個包含斐波那契數據的列表
F = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144,…]

# 斐波那契查找算法
# 時間複雜度O(log(n))

def fibonacci_search(lis, 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(lis) - 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(lis[high])
        i += 1
    print(lis)
    
    # 算法主邏輯。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 < lis[mid]:
            high = mid - 1
            k -= 1
        elif key > lis[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

if __name__ == '__main__':
    LIST = [1, 5, 7, 8, 22, 54, 99, 123, 200, 222, 444]
    result = fibonacci_search(LIST, 444)
    print(result)

算法分析:斐波那契查找的整體時間複雜度也爲O(log(n))。但就平均性能,要優於二分查找。但是在最壞情況下,比如這裏如果key爲1,則始終處於左側半區查找,此時其效率要低於二分查找。

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

線性索引查找

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

稠密索引

稠密索引指的是在線性索引中,爲數據集合中的每個記錄都建立一個索引項。

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

分塊索引

給大量的無序數據集合進行分塊處理,使得塊內無序,塊與塊之間有序。
這其實是有序查找和無序查找的一種中間狀態或者說妥協狀態。因爲數據量過大,建立完整的稠密索引耗時耗力,佔用資源過多;但如果不做任何排序或者索引,那麼遍歷的查找也無法接受,只能折中,做一定程度的排序或索引。
在這裏插入圖片描述
分塊索引的效率比遍歷查找的O(n)要高一些,但與二分查找的O(logn)還是要差不少。

倒排索引

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

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

五、二叉排序樹

二叉排序樹又稱爲二叉查找樹。它或者是一顆空樹,或者是具有下列性質的二叉樹:

若它的左子樹不爲空,則左子樹上所有節點的值均小於它的根結構的值;
若它的右子樹不爲空,則右子樹上所有節點的值均大於它的根結構的值;
它的左、右子樹也分別爲二叉排序樹。
在這裏插入圖片描述
構造一顆二叉排序樹的目的,往往不是爲了排序,而是爲了提高查找和插入刪除關鍵字的速度。

二叉排序樹的操作:

  • 查找:對比節點的值和關鍵字,相等則表明找到了;小了則往節點的左子樹去找,大了則往右子樹去找,這麼遞歸下去,最後返回布爾值或找到的節點。
  • 插入:從根節點開始逐個與關鍵字進行對比,小了去左邊,大了去右邊,碰到子樹爲空的情況就將新的節點鏈接。
  • 刪除:如果要刪除的節點是葉子,直接刪;如果只有左子樹或只有右子樹,則刪除節點後,將子樹鏈接到父節點即可;如果同時有左右子樹,則可以將二叉排序樹進行中序遍歷,取將要被刪除的節點的前驅或者後繼節點替代這個被刪除的節點的位置。
class BSTNode:
    """
    定義一個二叉樹節點類。
    以討論算法爲主,忽略了一些諸如對數據類型進行判斷的問題。
    """
    def __init__(self, data, left=None, right=None):
        """
        初始化
        :param data: 節點儲存的數據
        :param left: 節點左子樹
        :param right: 節點右子樹
        """
        self.data = data
        self.left = left
        self.right = right


class BinarySortTree:
    """
    基於BSTNode類的二叉排序樹。維護一個根節點的指針。
    """
    def __init__(self):
        self._root = None

    def is_empty(self):
        return self._root is None

    def search(self, key):
        """
        關鍵碼檢索
        :param key: 關鍵碼
        :return: 查詢節點或None
        """
        bt = self._root
        while bt:
            entry = bt.data
            if key < entry:
                bt = bt.left
            elif key > entry:
                bt = bt.right
            else:
                return entry
        return None

    def insert(self, key):
        """
        插入操作
        :param key:關鍵碼 
        :return: 布爾值
        """
        bt = self._root
        if not bt:
            self._root = BSTNode(key)
            return
        while True:
            entry = bt.data
            if key < entry:
                if bt.left is None:
                    bt.left = BSTNode(key)
                    return
                bt = bt.left
            elif key > entry:
                if bt.right is None:
                    bt.right = BSTNode(key)
                    return
                bt = bt.right
            else:
                bt.data = key
                return

    def delete(self, key):
        """
        二叉排序樹最複雜的方法
        :param key: 關鍵碼
        :return: 布爾值
        """
        p, q = None, self._root     # 維持p爲q的父節點,用於後面的鏈接操作
        if not q:
            print("空樹!")
            return
        while q and q.data != key:
            p = q
            if key < q.data:
                q = q.left
            else:
                q = q.right
            if not q:               # 當樹中沒有關鍵碼key時,結束退出。
                return
        # 上面已將找到了要刪除的節點,用q引用。而p則是q的父節點或者None(q爲根節點時)。
        if not q.left:
            if p is None:
                self._root = q.right
            elif q is p.left:
                p.left = q.right
            else:
                p.right = q.right
            return
        # 查找節點q的左子樹的最右節點,將q的右子樹鏈接爲該節點的右子樹
        # 該方法可能會增大樹的深度,效率並不算高。可以設計其它的方法。
        r = q.left
        while r.right:
            r = r.right
        r.right = q.right
        if p is None:
            self._root = q.left
        elif p.left is q:
            p.left = q.left
        else:
            p.right = q.left

    def __iter__(self):
        """
        實現二叉樹的中序遍歷算法,
        展示我們創建的二叉排序樹.
        直接使用python內置的列表作爲一個棧。
        :return: data
        """
        stack = []
        node = self._root
        while node or stack:
            while node:
                stack.append(node)
                node = node.left
            node = stack.pop()
            yield node.data
            node = node.right


if __name__ == '__main__':
    lis = [62, 58, 88, 48, 73, 99, 35, 51, 93, 29, 37, 49, 56, 36, 50]
    bs_tree = BinarySortTree()
    for i in range(len(lis)):
        bs_tree.insert(lis[i])
    # bs_tree.insert(100)
    bs_tree.delete(58)
    for i in bs_tree:
        print(i, end=" ")
    # print("\n", bs_tree.search(4))

二叉排序樹總結:

  • 二叉排序樹以鏈式進行存儲,保持了鏈接結構在插入和刪除操作上的優點。
  • 在極端情況下,查詢次數爲1,但最大操作次數不會超過樹的深度。也就是說,二叉排序樹的查找性能取決於二叉排序樹的形狀,也就引申出了後面的平衡二叉樹。
  • 給定一個元素集合,可以構造不同的二叉排序樹,當它同時是一個完全二叉樹的時候,查找的時間複雜度爲O(log(n)),近似於二分查找
  • 當出現最極端的斜樹時,其時間複雜度爲O(n),等同於順序查找,效果最差。

在這裏插入圖片描述

平衡二叉樹

平衡二叉樹(AVL樹,發明者的姓名縮寫):一種高度平衡的排序二叉樹,其每一個節點的左子樹和右子樹的高度差最多等於1。

平衡二叉樹首先必須是一棵二叉排序樹!

平衡因子(Balance Factor):將二叉樹上節點的左子樹深度減去右子樹深度的值。

對於平衡二叉樹所有包括分支節點和葉節點的平衡因子只可能是-1,0和1,只要有一個節點的因子不在這三個值之內,該二叉樹就是不平衡的。
在這裏插入圖片描述
最小不平衡子樹:距離插入結點最近的,且平衡因子的絕對值大於1的節點爲根的子樹。

平衡二叉樹的構建思想:每當插入一個新結點時,先檢查是否破壞了樹的平衡性,若有,找出最小不平衡子樹。在保持二叉排序樹特性的前提下,調整最小不平衡子樹中各結點之間的連接關係,進行相應的旋轉,成爲新的平衡子樹。

下面是由[1,2,3,4,5,6,7,10,9]構建平衡二叉樹
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

散列表(哈希表)

散列表:所有的元素之間沒有任何關係。元素的存儲位置,是利用元素的關鍵字通過某個函數直接計算出來的。這個一一對應的關係函數稱爲散列函數或Hash函數。
採用散列技術將記錄存儲在一塊連續的存儲空間中,稱爲散列表或哈希表(Hash Table)。關鍵字對應的存儲位置,稱爲散列地址。

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

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

設計出一個簡單、均勻、存儲利用率高的散列函數是散列技術中最關鍵的問題。
但是,一般散列函數都面臨着衝突的問題。
衝突:兩個不同的關鍵字,通過散列函數計算後結果卻相同的現象。collision。

散列函數的構造方法

好的散列函數:計算簡單、散列地址分佈均勻

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)

處理散列衝突

  • 開放定址法

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

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

改進的辦法有二次方探測法和隨機數探測法。

  • 再散列函數法

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

  • 鏈接地址法

碰到衝突時,不更換地址,而是將所有關鍵字爲同義詞的記錄存儲在一個鏈表裏,在散列表中只存儲同義詞子表的頭指針,如下圖:
在這裏插入圖片描述
這樣的好處是,不怕衝突多;缺點是降低了散列結構的隨機存儲性能。本質是用單鏈表結構輔助散列結構的不足。

  • 公共溢出區法

其實就是爲所有的衝突,額外開闢一塊存儲空間。如果相對基本表而言,衝突的數據很少的時候,使用這種方法比較合適。
在這裏插入圖片描述

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),屬於最極端的好了。
但是,現實中衝突可不可避免的,下面三個方面對查找性能影響較大:

  • 散列函數是否均勻
  • 處理衝突的辦法
  • 散列表的裝填因子(表內數據裝滿的程度)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章