詳解常用尋找資料結構及演算法(Python實現),資料結構python
一、基本概念
尋找(Searching)就是根據給定的某個值,在尋找表中確定一個其關鍵字等於給定值的資料元素(或記錄)。
尋找表(Search Table):由同一類型的資料元素(或記錄)構成的集合
關鍵字(Key):資料元素中某個資料項目的值,又稱為索引值。
主鍵(Primary Key):可唯一地標識某個資料元素或記錄的關鍵字。
尋找表按照操作方式可分為:
- 靜態尋找表(Static Search Table):只做尋找操作的尋找表。它的主要操作是:
- 查詢某個“特定的”資料元素是否在表中
- 檢索某個“特定的”資料元素和各種屬性
- 動態尋找表(Dynamic Search Table):在尋找中同時進行插入或刪除等操作:
- 尋找時插入資料
- 尋找時刪除資料
二、無序表尋找
也就是資料不排序的線性尋找,遍曆資料元素。
演算法分析:最好情況是在第一個位置就找到了,此為O(1);最壞情況在最後一個位置才找到,此為O(n);所以平均尋找次數為(n+1)/2。最終時間複雜度為O(n)
# 最基礎的遍曆無序列表的尋找演算法# 時間複雜度O(n)def sequential_search(lis, key): length = len(lis) for i in range(length): if lis[i] == key: return i else: return Falseif __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 Falseif __name__ == '__main__': LIST = [1, 5, 7, 8, 22, 54, 99, 123, 200, 222, 444] result = binary_search(LIST, 99) print(result)
2. 插值尋找
二分尋找法雖然已經很不錯了,但還有可以最佳化的地方。
有的時候,對半過濾還不夠狠,要是每次都排除十分之九的資料豈不是更好?選擇這個值就是關鍵問題,插值的意義就是:以更快的速度進行縮減。
插值的核心就是使用公式:
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 Falseif __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 Falseif __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運算是加法與除法,插值尋找則是複雜的四則運算,而斐波那契尋找只是最簡單的加減運算。在海量資料的尋找中,這種細微的差別可能會影響最終的尋找效率。因此,三種有序表的尋找方法本質上是分割點的選擇不同,各有優劣,應根據實際情況進行選擇。
四、線性索引尋找
對于海量的無序資料,為了提高尋找速度,一般會為其構造索引表。
索引就是把一個關鍵字與它相對應的記錄進行關聯的過程。
一個索引由若干個索引項目構成,每個索引項目至少包含關鍵字和其對應的記錄在儲存空間中的位置等資訊。
索引按照結構可以分為:線性索引、樹形索引和多級索引。
線性索引:將索引項目的集合通過線性結構來組織,也叫索引表。
線性索引可分為:稠密索引、分塊索引和倒排索引
1.稠密索引
稠密索引指的是線上性索引中,為資料集合中的每個記錄都建立一個索引項目。
這其實就相當於給無序的集合,建立了一張有序的線性表。其索引項目一定是按照關鍵碼進行有序的排列。
這也相當於把尋找過程中需要的排序工作給提前做了。
1.分塊索引
給大量的無序資料集合進行分塊處理,使得塊內無序,塊與塊之間有序。
這其實是有序尋找和無序尋找的一種中間狀態或者說妥協狀態。因為資料量過大,建立完整的稠密索引耗時耗力,佔用資源過多;但如果不做任何排序或者索引,那麼遍曆的尋找也無法接受,只能折中,做一定程度的排序或索引。
分塊索引的效率比遍曆尋找的O(n)要高一些,但與二分尋找的O(logn)還是要差不少。
1.倒排索引
不是由記錄來確定屬性值,而是由屬性值來確定記錄的位置,這種被稱為倒排索引。其中記錄號表格儲存體具有相同次關鍵字的所有記錄的地址或引用(可以是指向記錄的指標或該記錄的主關鍵字)。
倒排索引是最基礎的搜尋引擎索引技術。
五、二叉排序樹
二叉排序樹又稱為二叉尋找樹。它或者是一顆空樹,或者是具有下列性質的二叉樹:
- 若它的左子樹不為空白,則左子樹上所有節點的值均小於它的根結構的值;
- 若它的右子樹不為空白,則右子樹上所有節點的值均大於它的根結構的值;
- 它的左、右子樹也分別為二叉排序樹。
構造一顆二叉排序樹的目的,往往不是為了排序,而是為了提高尋找和插入刪除關鍵字的速度。
二叉排序樹的操作:
1.尋找:對比節點的值和關鍵字,相等則表明找到了;小了則往節點的左子樹去找,大了則往右子樹去找,這麼遞迴下去,最後返回布爾值或找到的節點。
2.插入:從根節點開始逐個與關鍵字進行對比,小了去左邊,大了去右邊,碰到子樹為空白的情況就將新的節點連結。
3.刪除:如果要刪除的節點是葉子,直接刪;如果只有左子樹或只有右子樹,則刪除節點後,將子樹連結到父節點即可;如果同時有左右子樹,則可以將二叉排序樹進行中序遍曆,取將要被刪除的節點的前驅或者後繼節點替代這個被刪除的節點的位置。
#!/usr/bin/env python# -*- coding:utf-8 -*-# Author: Liu Jiang# Python 3.5class BSTNode: """ 定義一個二叉樹節點類。 以討論演算法為主,忽略了一些諸如對資料類型進行判斷的問題。 """ def __init__(self, data, left=None, right=None): """ 初始化 :param data: 節點儲存的資料 :param left: 節點左子樹 :param right: 節點右子樹 """ self.data = data self.left = left self.right = rightclass 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.rightif __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]構建平衡二叉樹
七、多路尋找樹(B樹)
多路尋找樹(muitl-way search tree):其每一個節點的孩子可以多於兩個,且每一個結點處可以儲存多個元素。
對於多路尋找樹,每個節點可以儲存多少個元素,以及它的孩子數的多少是關鍵,常用的有這4種形式:2-3樹、2-3-4樹、B樹和B+樹。
2-3樹
2-3樹:每個結點都具有2個孩子,或者3個孩子,或者沒有孩子。
一個2結點包含一個元素和兩個孩子(或者沒有孩子,不能只有一個孩子)。與二叉排序樹類似,其左子樹包含的元素都小於該元素,右子樹包含的元素都大於該元素。
一個3結點包含兩個元素和三個孩子(或者沒有孩子,不能只有一個或兩個孩子)。
2-3樹中所有的葉子都必須在同一層次上。
其插入操作如下:
其刪除操作如下:
2-3-4樹
其實就是2-3樹的擴充,包括了4結點的使用。一個4結點包含小中大三個元素和四個孩子(或沒有孩子)。
其插入操作:
其刪除操作:
B樹
B樹是一種平衡的多路尋找樹。節點最大的孩子數目稱為B樹的階(order)。2-3樹是3階B樹,2-3-4是4階B樹。
B樹的資料結構主要用在記憶體和外部儲存空間的資料互動中。
B+樹
為瞭解決B樹的所有元素遍曆等基本問題,在原有的結構基礎上,加入新的元素組織方式後,形成了B+樹。
B+樹是應檔案系統所需而出現的一種B樹的變形樹,嚴格意義上將,它已經不是最基本的樹了。
B+樹中,出現在分支節點中的元素會被當做他們在該分支節點位置的中序後繼者(葉子節點)中再次列出。另外,每一個葉子節點都會儲存一個指向後一葉子節點的指標。
所有的葉子節點包含全部的關鍵字的資訊,及相關指標,葉子節點本身依關鍵字的大小自小到大順序連結
B+樹的結構特別適合帶有範圍的尋找。比如尋找年齡在20~30歲之間的人。
八、散列表(雜湊表)
散列表:所有的元素之間沒有任何關係。元素的儲存位置,是利用元素的關鍵字通過某個函數直接計算出來的。這個一一對應的關係函數稱為散列函數或Hash函數。
採用散列技術將記錄儲存在一塊連續的儲存空間中,稱為散列表或雜湊表(Hash Table)。關鍵字對應的儲存位置,稱為散列地址。
散列表是一種面向尋找的儲存結構。它最適合求解的問題是尋找與給定值相等的記錄。但是對於某個關鍵字能對應很多記錄的情況就不適用,比如尋找所有的“男”性。也不適合範圍尋找,比如尋找年齡20~30之間的人。排序、最大、最小等也不合適。
因此,散列表通常用於關鍵字不重複的資料結構。比如python的字典資料類型。
設計出一個簡單、均勻、儲存利用率高的散列函數是散列技術中最關鍵的問題。
但是,一般散列函數都面臨著衝突的問題。
衝突:兩個不同的關鍵字,通過散列Function Compute後結果卻相同的現象。collision。
8.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)
總結,實際情況下根據不同的資料特性採用不同的散列方法,考慮下面一些主要問題:
- 計算散列地址所需的時間
- 關鍵字的長度
- 散列表的大小
- 關鍵字的分布情況
- 記錄尋找的頻率
8.2 處理散列衝突
1、開放定址法
就是一旦發生衝突,就去尋找下一個空的散列地址,只要散列表足夠大,空的散列地址總能找到,並將記錄存入。
公式是:
這種簡單的衝突解決辦法被稱為線性探測,無非就是自家的坑被佔了,就逐個拜訪後面的坑,有空的就進,也不管這個坑是不是後面有人預定了的。
線性探測帶來的最大問題就是衝突的堆積,你把別人預定的坑佔了,別人也就要像你一樣去找坑。
改進的辦法有二次方探測法和隨機數探測法。
2、再散列函數法
發生衝突時就換一個散列Function Compute,總會有一個可以把衝突解決掉,它能夠使得關鍵字不產生聚集,但相應地增加了計算的時間。
3、連結地址法
碰到衝突時,不更換地址,而是將所有關鍵字為同義字的記錄儲存在一個鏈表裡,在散列表中只儲存同義字子表的頭指標,如:
這樣的好處是,不怕衝突多;缺點是降低了散列結構的隨機儲存效能。本質是用單鏈表結構輔助散列結構的不足。
4、公用溢出區法
其實就是為所有的衝突,額外開闢一Block Storage空間。如果相對基本表而言,衝突的資料很少的時候,使用這種方法比較合適。
8.3 散列表尋找實現
下面是一段簡單的實現代碼:
#!/usr/bin/env python# -*- coding:utf-8 -*-# Author: Liu Jiang# Python 3.5# 忽略了對資料類型,元素溢出等問題的判斷。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 Trueif __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))
8.4 散列表尋找效能分析
如果沒發生衝突,則其尋找時間複雜度為O(1),屬於最極端的好了。
但是,現實中衝突可不可避免的,下面三個方面對尋找效能影響較大:
- 散列函數是否均勻
- 處理衝突的辦法
- 散列表的裝填因子(表內資料裝滿的程度)
以上就是本文的全部內容,希望對大家的學習有所協助,也希望大家多多支援幫客之家。