時間複雜度與空間複雜度
常用O(1)
或O(n)
表示,其中1
表示一個單位(最簡單的單位,可以是多個或1個,但在時間上總體是較低且連續的),時間通常指的是程式執行時間,空間則是指程式在執行時所佔用的記憶體空間。各個階段的複雜度可用下面的順序比較:
O(1) < O(logn) < O(n) < O(nlogn) < O(n2) ...
注意:
1. O(n2) 表示 O(n的平方)
2. O(logn) 表示時間(或空間)單位相對於n的對數,即:假設時間(或空間)的k次方為n,logn 表示實際複雜度為k
注意遞迴必定具有空間複雜度,因為系統需要開闢空間來記錄每次函式遞迴前的狀態,以便結果返回時呼叫。
遞迴與漢洛塔
遞迴有兩個特點:
- 呼叫自身
- 有明確的終止條件
漢羅塔問題則是一個很好的遞迴例子,透過Python
實現如下:
def hanoi(n, a, b, c):
"""
將 n 個盤子從柱子 a 經過 柱子 b 移動到柱子 c
步驟如下:
首先需要將 n-1 個盤子經過柱子 c 移動到柱子 b
此時柱子 a 只剩一個盤子(最底部的大盤子),柱子 c 為空
然後將柱子 a 的盤子 移動到柱子 c
此時柱子 a 為空
最後將 n-1 個盤子經過柱子 a 移動到柱子 c
此時所有盤子都在柱子 c上
n 為要移動的 n 個盤子
a 柱子 a
b 柱子 b
c 柱子 c
注意三個引數的順序
"""
if n > 0:
hanoi(n-1, a, c, b)
print(f'Moving {a} to {c}')
hanoi(n-1, b, a, c)
查詢
查詢一般來說是在某個物件(列表、集合等)中找到指定的元素所在位置。
順序查詢
順序查詢的時間複雜度為O(n)
,實現如下:
def linear_search(li, val):
"""
遍歷並比較,找到合適的值的索引並返回
"""
for index, value in li:
if value == val:
return index
else:
return None
二分查詢
二分查詢的時間複雜度為O(logn)
,但前提是隻能對經過排序後的列表產生作用,如果拿到的是未經排序的列表,那麼對列表進行排序的時間複雜度為O(n)
。假定拿到的是已經排好序的列表,那麼二分法實現如下:
def binary_search(li, val):
"""
每次取中間值,找到待索引區域
直到找到指定的值或沒有中間值為止
"""
left = 0
right = len(li) - 1
while right >= left:
mid = (left + right) // 2
value = li[mid]
if value == val:
return mid
elif value > val:
right = mid - 1
else:
left = mid + 1
else:
return None
排序
氣泡排序
遍歷列表的每一個值,如果當前值大於當前值的後面一個值,則交換兩個值的位置,使較大的值處於後面。重複遍歷直到排好序為止。
演算法實現:
def bubble_sort(li):
"""氣泡排序
遍歷 len(li)-1 趟 (從0開始)
"""
cycles = len(li) - 1
for i in range(cycles):
is_exchanged = False
for j in range(cycles - 1):
if li[j] > li[j+1]:
li[j], li[j+1] = li[j+1], li[j]
if not is_exchanged:
is_exchanged = True
if not is_exchanged:
break
選擇排序
遍歷列表,找到最小的值,將它放到一個新的位置。如此反覆直到剩下的所有值都轉移到新位置為止。演算法實現:
def select_sort(li):
"""選擇排序
遍歷 len(li) - 1 趟 (從0開始)"""
li_length = len(li)
for i in range(li_length-1):
# 找到並記錄最小元素所在位置
min_loc = i
for j in range(i+1, li_length):
if li[j] < li[min_loc]:
min_loc = j
if min_loc != i:
li[min_loc], li[i] = li[i], li[min_loc]
插入排序
插入排序類似於摸牌,每當摸到一張新牌時,就和手裡面的牌依次進行比較,如果小於被比較的牌,則將被比較的牌往後移一位,直到大於被比較的牌或前面沒有牌可以比較時(即摸到的牌為當前手裡牌最小的牌),將摸到的牌插入到當前位置的後一位。演算法實現:
def insert_sort(li):
"""插入排序
類似於打牌,從列表中依次取一張牌
用獲取的牌與手中的牌依次對比
如果獲取的牌比對比的牌小,則將它插入到對比的牌的前面
"""
for i in range(1, len(li)):
# 假定第一張牌為手中的牌
# i 表示獲取的牌的下標
tmp = li[i]
# j 表示手裡牌的下標
j = i - 1
while j >= 0 and li[j] > tmp:
li[j+1] = li[j]
j -= 1
li[j+1] = tmp
快速排序
從列表中隨機選一個數,並將它重新放到列表中,使得它左邊的數都比它小,它右邊的數都比它大。根據它的位置將列表分為兩部分,再依次對這兩部分執行同樣的操作,最後遞迴直到所有數都按順序排好為止。
快速排序的複雜度是O(nlogn)
,而上面三種(冒泡、選擇、插入)的複雜度為O(n^2)
,因此快速排序實現較快。但仍有極低機率出現最壞的情況,使得快速排序的複雜度為O(n^2)
。演算法實現:
"""
Quick sort
"""
import random
def quick_sort(li, left, right):
"""快速排序演算法"""
if left < right:
mid = partition(li, left, right)
quick_sort(li, left, mid-1)
quick_sort(li, right, mid+1)
def partition(li, left, right):
"""從給定列表的指定範圍隨機抽取一個數 x
找到 x 在列表中的相應位置 pos
使得列表中所有 pos 左邊的數都比 x 小
所有 pos 右邊的數都比 x 大
將 x 放到列表中 pos 所在的位置
並返回 pos
"""
pos = random.randint(left, right)
tmp = li[pos]
while left < right:
# 由於取出了tmp
# 可以視作tmp所在列表中的位置為一個空位
# 先從空位的左邊部分從左往右開始尋找
# 如果出現某個數大於tmp
# 那麼交換兩個數的位置
while left < pos and li[left] <= tmp:
left += 1
li[pos] = li[left]
pos = left
# 同理從右往左找
while pos < right and li[right] >= tmp:
right -= 1
li[pos] = li[right]
pos = right
# 最後將 tmp 放到 pos 所在位置
# 使得: pos 左邊的數都比 tmp 小,
# pos 右邊的數都比 tmp 大
li[pos] = tmp
return pos
if __name__ == '__main__':
li = list(range(10000, 0, -1))
# random.shuffle(li)
print(li)
quick_sort(li, 0, len(li)-1)
print(li)
堆排序
前言 - 樹與堆
- 樹
- 定義
樹是一種資料結構,從一個點出發,分散為多個點,
每一個分散的點又可以分散為多個點,照此依次遞迴直到不再有分散的點為止。
- 節點
其最頂部的節點被稱為樹的根節點,最底部的節點被稱為葉子節點(即不再有延申)。
樹的每個度關聯著父節點和子節點,子節點是從父節點延申出來的。
- 度
每次分散的維度(次數)又被稱為度,樹的度則是取樹中最大的度。
- 二叉樹
- 定義
二叉樹是樹中的一種特殊結構,表示樹的每個節點的子節點不超過兩個。
- 滿二叉樹
二叉樹的每一個節點都有兩個子節點(葉子節點除外,因為它沒有子節點)。
- 完全二叉樹
從樹的頂部往下,有不間斷的子節點,直到葉子節點。
葉子節點右邊可以不完整,但從左往最右的部分不能出現斷裂。
- 完全二叉樹與列表
將完全二叉樹的每個節點,從頂至下,且從左至右依次取出,依次放入一個列表中,如:
9
/ \
8 7
/ \ / \
6 5 4 3
/ \
2 1
轉化為列表:
[9, 8, 7, 6, 5, 4, 3, 2, 1]
下標: 0, 1, 2, 3, 4, 5, 6, 7, 8
那麼此時,給定任意節點在列表中的位置 i ,
可以根據固定的公式找到其父節點和左右子節點所在位置:
左邊子節點 l:
l = 2i + 1
右邊子節點 r:
r = 2i + 2
父節點 f:
f = i - 1 // 2
- 堆
- 定義
堆是一種特殊的完全二叉樹結構,它分為:
a. 大根堆
任一節點比其子節點大
b. 小根堆
任一節點比其子節點小
注意:當根節點的左右子樹都是堆,但其自身不是堆時,可以透過一次向下調整使其變為堆,比如:
0
/ \
8 7
/ \ / \
6 5 4 3
/ \
2 1
||
\/
8
/ \
6 7
/ \ / \
2 5 4 3
/ \
0 1
過程描述:
0首先和8比較,因為小於8所以和8交換位置,
依次繼續和其子節點進行比較,如果小於其子節點則交換位置,
直到不再小於其子節點或沒有子節點為止。
- 構造堆
對任一完全二叉樹,都可以透過下面的方法將其轉化為堆:
假定有完全二叉樹如下:
0
/ \
4 6
/ \ / \
9 1 7 2
/ \
3 5
現將二叉樹拆分為一個個小的子二叉樹,
比如最下面的 5,3,9 為一個子二叉樹,依次是:
4
6 9 1 ...
7 2 3 5
接下來對每一個子二叉樹進行一次向下調整,使其變為一個堆。
比如最後的子二叉樹調整為(因為符合堆的特徵所以沒有調整):
9 9
/ \ => / \
3 5 3 5
2,7,6調整為:
6 7
/ \ => / \
7 2 6 2
5,3,1,9,4 調整為:
4 9
/ \ => / \
9 1 5 1
/ \ / \
3 5 3 4
最後,這個完全二叉樹就會變成一個堆:
9
/ \
5 7
/ \ / \
4 1 6 2
/ \
3 0
實際上就是對每一個子二叉樹進行向下調整,
每當最下面的子二叉樹稻城堆條件時,將其整合到上面一層,
然後再對整合後的二叉樹進行向下調整,如此往復
堆排序
堆排序本質上就是把列表當作一個完全二叉樹,然後從二叉樹的最後一個葉子節點所在的最下子二叉樹開始,一步步的構造一個堆(大根堆或小根堆),構造完堆後,即可根據堆的特性一次次的彈出一個數並儲存到列表的最後,同時縮小堆的規模(去掉最後一個數,這個數是已經排好序的),如此往復直到堆被縮小到0為止,此時的列表就變為了一個有序列表了。
堆排序的時間複雜度為O(nlogn)
。
程式碼實現:
- 向下調整的程式碼實現:
def sift(li, low, high):
"""
對堆進行向下調整
使得在指定範圍內的二叉樹變為堆結構
:param li: 列表
:param low: 二叉樹頂點位置 即根節點位置
:param high 指定範圍內的二叉樹的最後一個元素所在位置
"""
# i 指向堆頂點
# left 指向 i 的左孩子
# tmp 用於儲存頂部變數
i = low
left = i * 2 + 1
tmp = li[i]
while left <= high:
# 從左右節點中找出最大的元素
# 賦值給larger
larger = left
right = left + 1
if right <= high and li[right] > li[left]:
larger = right
# 將左右孩子中最大的元素與頂點進行比較
if li[larger] > tmp:
# 如果大於頂點則將頂點置為最大的孩子元素
# 同時重新對 頂點和頂點的左孩子進行賦值
# 即向下看一層
li[i] = li[larger]
i = larger
left = i * 2 + 1
else:
# 如果不大於則表示本次向下調整完成
# 結束迴圈
break
# 最後將快取的頂點元素放到 i 指向的位置
# 此時 i 如果未調整則是頂點元素
# 如果經過調整則是左右孩子中的任一個孩子所在位置
li[i] = tmp
- 堆排序實現:
def heap_sort(li):
length = len(li)
# 構造堆
# 從下往上 找到每一個子完全二叉樹的頂點
# 頂點位置是相鄰的
# 進行向下調整
left = length - 1
top = (left - 1) // 2
for i in range(top, -1, -1):
sift(li, i, left)
# 從後往前遍歷堆的每一個元素
# 取出堆的根節點(即列表的最大值)
# 與堆的最後一個葉子元素進行交換
# 並將堆的範圍縮減一個元素(即排除最後一個葉子元素,也是列表的最大值)
# 對堆進行一次向下調整
# 重複上面的步驟,即可完成堆排序
for i in range(left, -1, -1):
li[0], li[i] = li[i], li[0]
sift(li, 0, i-1)
堆排序與topk
topk
問題是指從一個列表中找到排名前k
的值,有如下幾種思路用來解決該問題:
- 排序後切片 時間複雜度為 O(nlogn)
- 冒泡/選擇/插入排序(只需要排k次) 時間複雜度為 O(kn)
- 堆排序思路 時間複雜度為 O(klogn)
上面三種思路中,堆排序思路是時間複雜度是最低的。使用堆排序解決topk
問題的大致思路如下:
1. 取列表的前k個值構造一個小根堆,假定堆頂就是目前第k大的數
2. 在列表中從k往後依次取出一個值與小根堆的根節點值進行比較,
如果小於該節點值則忽略該元素,
否則將堆頂更換為該元素,並重新進行一次向下調整
3. 遍歷完成後,前k個值則為反序的列表中最大的k個元素
實現程式碼如下:
def sift(li, low, high):
"""
對堆進行向下調整
使得在指定範圍內的二叉樹變為堆結構
:param li: 列表
:param low: 二叉樹頂點位置 即根節點位置
:param high 指定範圍內的二叉樹的最後一個元素所在位置
注意與上面的sift函式比較,上面sift中的 > 符號在此處全部變為了 <
這表示構造出來的堆是一個小根堆
"""
i = low
left = i * 2 + 1
tmp = li[i]
while left <= high:
larger = left
right = left + 1
if right <= high and li[right] < li[left]:
larger = right
if li[larger] < tmp:
li[i] = li[larger]
i = larger
left = i * 2 + 1
else:
break
li[i] = tmp
def topk(li, k):
"""
找出 li 中最大的 k 個數
但是不改變 li 的結構
返回一個長度為 k 的有序列表
"""
# 對 li 的前 k 個元素構造堆
heap = li[0:k]
left = k - 1
top = (left - 1) // 2
for i in range(top, -1, -1):
sift(heap, i, left)
# 遍歷 li 中 k 後面的值並與堆頂進行比較
end = len(li) - 1
for i in range(k, end):
if li[i] > heap[0]:
heap[0] = li[i]
sift(heap, 0, left)
# 對構造的堆進行排序
for i in range(left, -1, -1):
heap[0], heap[i] = heap[i], heap[0]
sift(heap, 0, i-1)
# 返回包含前 k 個元素的列表
return heap
歸併排序
- 歸併的含義:
對兩個有序的列表進行一定操作,歸併為一個有序的列表。
具體操作如下:
假定有如下兩個有序列表,分別為 li1, li2:
1, 5, 8, 3, 6, 9
此時開闢一個新的臨時列表 tmp
分別依次從 li1, li2 中的第一個元素開始,
假定分別為 x, y,且 x 大於 y
比較兩個元素的大小,將較小的元素(y)放到臨時列表中,
留下較大的元素(x)與 li2 的下一個元素進行比較
重複上面的步驟,最後即可得到一個新的有序列表
- 歸併排序:
歸併排序是將列表分解為兩個部分
在將分解後的兩個部分各分解為兩個部分
以此重複,
直到最後分解後的兩個部分為兩個有序的列表時
也就是兩個列表各自包含的元素為0個或一個時
對兩個列表進行歸併即可
簡單來說,歸併排序與堆排序有些類似,
二者都是知曉某個原理(堆/歸併),
再構造條件運用該原理從而實現排序
- 程式碼實現:
def merge(li, low, mid, high):
"""歸併
對指定範圍內的列表元素進行歸併操作
並用歸併後的結果覆蓋該列表相應範圍內的元素
"""
# 建立一個臨時列表
# 將原列表指定範圍內的資料切分為兩部分
# 並用變數 j 指向第二部分的初始位置
# 用變數 i 指向第一部分的初始位置
tmp = []
i = low
j = mid + 1
# 取出兩部分的第一個元素進行比較
# 將較小的元素放到臨時列表中
# 再往後查詢新的元素進行比較
# 直到有一個部分的元素已全部取出為止
while i <= mid and j <= high:
if li[i] < li[j]:
tmp.append(li[i])
i += 1
else:
tmp.append(li[j])
j += 1
# 分別檢查兩個部分
# 如果還有剩餘的元素
# 則依次新增到臨時列表中
while i <= mid:
tmp.append(li[i])
i += 1
while j <= high:
tmp.append(li[j])
j += 1
# 將臨時列表的資料寫入到原列表對應的位置
# 注意覆蓋範圍需要包含 high
# 所以使用 high+1
li[low:high+1] = tmp
def merge_sort(li, low=0, high=None):
"""歸併排序
不斷將列表分為兩個部分
直到兩個部分都為有序時(包含元素為1個或0個時)
進行歸併
"""
# 第一次遞迴前
if high is None:
high = len(li) - 1
# 至少有兩個元素 遞迴
if low < high:
mid = (low + high) // 2
merge_sort(li, low, mid)
merge_sort(li, mid + 1, high)
merge(li, low, mid, high)
- 歸併排序的時間複雜度與空間複雜度
因為涉及到建立新的列表以及遞迴
所以歸併排序也存在空間複雜度,
它的空間複雜度為 O(n)
時間複雜度為 O(nlogn)
幾種排序對比
注:
1. 快排涉及遞迴,所以存在空間複雜度;
2. 穩定性是指再存在兩個相同元素的情況下,兩個相同元素的相對位置是否變動,
如果相對位置發生了改變,則不穩定,反之即穩定。
一般來說,對列表進行挨次調整的演算法(如氣泡排序、歸併排序等)是穩定的,
而跳著調整的演算法(如插入排序、快速排序)則不穩定。
希爾排序
- 希爾排序是在插入排序的基礎上做的改良。大體思路如下:
假定列表長度為 n
以某個數 d 為基點,這個數需大於 0 小於 n, 假定為 n // 2
從列表的起始位置開始,以 d 為間隔,每隔 d 個單位從列表中去一個值
直到列表末尾
把取出的值視作一個列表,對這個列表進行插入排序
重複上面的步驟直到列表中的值取完為止
此時將 d 設為 d // 2
繼續重複上面的步驟
直到 d 變為 1 時,進行最後一次插入排序
結束後排序完成
- 程式碼實現:
def insert_sort_gap(li, gap):
"""
有間隔的插入排序
"""
for i in range(gap, len(li)):
tmp = li[i]
j = i - gap
while j >= 0 and li[j] > tmp:
li[j+gap] = li[j]
j -= gap
li[j+gap] = tmp
def shell_sort(li):
"""
希爾排序
"""
d = len(li) // 2
while d >= 1:
insert_sort_gap(li, d)
d //= 2
- 複雜度
希爾排序的時間複雜度介於 O(n) 到 O(n^2) 之間
這取決於 d 的取值(不同的取值方法會導致不同的時間複雜度)
目前取值方法中最快的時間複雜度為:
O(nlog2n) 即O(n*logn*logn)
具體可參見維基百科
計數排序
- 概念
假定已知一個未知長度的列表中的元素全都介於 0-100 之間
這個列表為 li
建立一個新的元素全部為 0 且長度為 100 的列表 tmp
遍歷 li 中的每一個元素
用 tmp 統計該元素的出現次數
清空 li
將 tmp 中儲存的元素及出現次數依次寫入到 li 中
排序完成
- 程式碼實現
def count_sort(li, max_count):
"""計數排序
:param li: 需要排序的列表
:param max_count: 列表元素的最大值
該列表中的所有值介於 0 到 max_count 之間
"""
# 建立臨時列表用於計數
tmp = [0 for _ in range(max_count+1)]
for i in li:
tmp[i] += 1
# 將統計好的結果重新寫入到 li 中
li.clear()
for index, val in enumerate(tmp):
for i in range(val):
li.append(index)
- 複雜度
由於計數排序需要建立新的列表,所以存在空間複雜度,最大為 O(n)
其時間複雜度為 O(n)
桶排序
- 概念
桶排序基於計數排序
假定有一個列表 li
li 列表中的數最大值為10000
分 10 個列表(即桶)
分別對應: 0-9999 10000-19999 20000-29999 ... 的數值範圍
遍歷 li
按 0-9999 10000-19999 20000-29999 ... 的範圍區分每個數
並將數放入相應的桶中
放入時可做相應的排序,保持桶內的數有序
完成後將所有的桶重新組成一個列表
此時排序完成
- 程式碼實現
def bucket_sort(li, n=100, max_num=10000):
"""桶排序
:param n: 分為多少個桶
:param max_num: 列表中的最大值
"""
# 建立桶
buckets = [[] for _ in range(n)]
for var in li:
# 找到元素屬於哪個桶
# 注意當 var == max_num 時
# var // (max_num // n) == 10
# 所以需要考慮到這種情況
# 因此使用 min
i = min(var // (max_num // n), n-1)
# 將元素放入相應的桶中
buckets[i].append(var)
# 保持桶內有序(插入排序)
for j in range(len(buckets[i])-1, 0, -1):
if buckets[i][j] < buckets[i][j-1]:
buckets[i][j], buckets[i][j-1] = buckets[i][j-1], buckets[i][j]
else:
break
li.clear()
for bucket in buckets:
li.extend(bucket)
- 複雜度
桶排序的時間複雜度為 O(n+k) ~ O(n2k)
空間複雜度為 O(nk)
桶排序的表現取決於資料的分佈,也就是需要對資料排序時採取不同的分桶策略
基數排序
- 概念
基數排序類似於桶排序,但分桶策略有所不同
首先找到列表中的最大數
並算出這個數的位數 k (比如: 1000 -> 3, 99999 -> 5)
首先遍歷列表,對列表中所有值按個位數排序(分別放入 0-9 10個桶中)
再次遍歷列表,... 按十位數進行排序
...
遍歷 k 次
最後列表即為有序
- 程式碼實現
def radix_sort(li):
"""基數排序"""
# 找到列表中最大的值的位數並遍歷
k = 0
max_num = max(li)
while 10 ** k <= max_num:
# 建立桶
buckets = [[] for _ in range(10)]
# 遍歷列表
for val in li:
# 找到當前 k 對應的位數的值 並放入對應的桶中
# 89 & k=0 -> 9
# 89 & k=5 -> 0
digit = (val // 10 ** k) % 10
buckets[digit].append(val)
# 重置列表
li.clear()
for bucket in buckets:
li.extend(bucket)
k += 1