歡迎大家訪問我的個人網站《劉江的博客和教程》:www.liujiangblog.com
主要分享Python 及Django教程以及相關的博客
目錄
一、基本概念
二、無序表查找
三、有序表查找
3.1 二分查找(Binary Search)
3.2 插值查找
3.3 斐波那契查找
四、線性索引查找
4.1 稠密索引
4.2 分塊索引
4.3 倒排索引
五、二叉排序樹
六、 平衡二叉樹
七、多路查找樹(B樹)
7.1 2-3樹
7.2 2-3-4樹
7.3 B樹
7.4 B+樹
八、散列表(哈希表)
8.1 散列函數的構造方法
8.2 處理散列沖突
8.3 散列表查找實現
8.4 散列表查找性能分析
參考書目《大話數據結構》
一、基本概念
查找(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 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)
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 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)還是要差不少。
- 倒排索引
不是由記錄來確定屬性值,而是由屬性值來確定記錄的位置,這種被稱為倒排索引。其中記錄號表存儲具有相同次關鍵字的所有記錄的地址或引用(可以是指向記錄的指針或該記錄的主關鍵字)。
倒排索引是最基礎的搜索引擎索引技術。
五、二叉排序樹
二叉排序樹又稱為二叉查找樹。它或者是一顆空樹,或者是具有下列性質的二叉樹:
- 若它的左子樹不為空,則左子樹上所有節點的值均小於它的根結構的值;
- 若它的右子樹不為空,則右子樹上所有節點的值均大於它的根結構的值;
- 它的左、右子樹也分別為二叉排序樹。
構造一顆二叉排序樹的目的,往往不是為了排序,而是為了提高查找和插入刪除關鍵字的速度。
二叉排序樹的操作:
- 查找:對比節點的值和關鍵字,相等則表明找到了;小了則往節點的左子樹去找,大了則往右子樹去找,這么遞歸下去,最后返回布爾值或找到的節點。
- 插入:從根節點開始逐個與關鍵字進行對比,小了去左邊,大了去右邊,碰到子樹為空的情況就將新的節點鏈接。
- 刪除:如果要刪除的節點是葉子,直接刪;如果只有左子樹或只有右子樹,則刪除節點后,將子樹鏈接到父節點即可;如果同時有左右子樹,則可以將二叉排序樹進行中序遍歷,取將要被刪除的節點的前驅或者后繼節點替代這個被刪除的節點的位置。
#!/usr/bin/env python
# -*- coding:utf-8 -*-
# Author: Liu Jiang
# Python 3.5
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]構建平衡二叉樹
七、多路查找樹(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的字典數據類型。
設計出一個簡單、均勻、存儲利用率高的散列函數是散列技術中最關鍵的問題。
但是,一般散列函數都面臨着沖突的問題。
沖突:兩個不同的關鍵字,通過散列函數計算后結果卻相同的現象。collision。
8.1 散列函數的構造方法
好的散列函數:計算簡單、散列地址分布均勻
- 直接定址法
例如取關鍵字的某個線性函數為散列函數:
f(key) = a*key + b (a,b為常數) - 數字分析法
抽取關鍵字里的數字,根據數字的特點進行地址分配 - 平方取中法
將關鍵字的數字求平方,再截取部分 - 折疊法
將關鍵字的數字分割后分別計算,再合並計算,一種玩弄數字的手段。 - 除留余數法
最為常見的方法之一。
對於表長為m的數據集合,散列公式為:
f(key) = key mod p (p<=m)
mod:取模(求余數)
該方法最關鍵的是p的選擇,而且數據量較大的時候,沖突是必然的。一般會選擇接近m的質數。 - 隨機數法
選擇一個隨機數,取關鍵字的隨機函數值為它的散列地址。
f(key) = random(key)
總結,實際情況下根據不同的數據特性采用不同的散列方法,考慮下面一些主要問題:
- 計算散列地址所需的時間
- 關鍵字的長度
- 散列表的大小
- 關鍵字的分布情況
- 記錄查找的頻率
8.2 處理散列沖突
- 開放定址法
就是一旦發生沖突,就去尋找下一個空的散列地址,只要散列表足夠大,空的散列地址總能找到,並將記錄存入。
公式是:
這種簡單的沖突解決辦法被稱為線性探測,無非就是自家的坑被占了,就逐個拜訪后面的坑,有空的就進,也不管這個坑是不是后面有人預定了的。
線性探測帶來的最大問題就是沖突的堆積,你把別人預定的坑占了,別人也就要像你一樣去找坑。
改進的辦法有二次方探測法和隨機數探測法。
-
再散列函數法
發生沖突時就換一個散列函數計算,總會有一個可以把沖突解決掉,它能夠使得關鍵字不產生聚集,但相應地增加了計算的時間。 -
鏈接地址法
碰到沖突時,不更換地址,而是將所有關鍵字為同義詞的記錄存儲在一個鏈表里,在散列表中只存儲同義詞子表的頭指針,如下圖:
這樣的好處是,不怕沖突多;缺點是降低了散列結構的隨機存儲性能。本質是用單鏈表結構輔助散列結構的不足。
- 公共溢出區法
其實就是為所有的沖突,額外開辟一塊存儲空間。如果相對基本表而言,沖突的數據很少的時候,使用這種方法比較合適。
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 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))
8.4 散列表查找性能分析
如果沒發生沖突,則其查找時間復雜度為O(1),屬於最極端的好了。
但是,現實中沖突可不可避免的,下面三個方面對查找性能影響較大:
-
散列函數是否均勻
-
處理沖突的辦法
-
散列表的裝填因子(表內數據裝滿的程度)