常見排序原理及 python 實現

kingron發表於2024-07-03

時間複雜度與空間複雜度

常用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)

堆排序

前言 - 樹與堆
- 定義
    樹是一種資料結構,從一個點出發,分散為多個點,
    每一個分散的點又可以分散為多個點,照此依次遞迴直到不再有分散的點為止。

- 節點
    其最頂部的節點被稱為樹的根節點,最底部的節點被稱為葉子節點(即不再有延申)。
    樹的每個度關聯著父節點和子節點,子節點是從父節點延申出來的。

- 度
    每次分散的維度(次數)又被稱為度,樹的度則是取樹中最大的度。
  1. 二叉樹
- 定義
    二叉樹是樹中的一種特殊結構,表示樹的每個節點的子節點不超過兩個。

- 滿二叉樹
    二叉樹的每一個節點都有兩個子節點(葉子節點除外,因為它沒有子節點)。

- 完全二叉樹
    從樹的頂部往下,有不間斷的子節點,直到葉子節點。
    葉子節點右邊可以不完整,但從左往最右的部分不能出現斷裂。
  1. 完全二叉樹與列表
將完全二叉樹的每個節點,從頂至下,且從左至右依次取出,依次放入一個列表中,如:

                                   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交換位置,
    依次繼續和其子節點進行比較,如果小於其子節點則交換位置,
    直到不再小於其子節點或沒有子節點為止。
  1. 構造堆
對任一完全二叉樹,都可以透過下面的方法將其轉化為堆:


假定有完全二叉樹如下:

           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)

程式碼實現:

  1. 向下調整的程式碼實現:
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
  1. 堆排序實現:
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

歸併排序

  1. 歸併的含義:
    對兩個有序的列表進行一定操作,歸併為一個有序的列表。
    具體操作如下:

    假定有如下兩個有序列表,分別為 li1, li2:

        1, 5, 8,        3, 6, 9 

    此時開闢一個新的臨時列表 tmp
    分別依次從 li1, li2 中的第一個元素開始,
    假定分別為 x, y,且 x 大於 y
    比較兩個元素的大小,將較小的元素(y)放到臨時列表中,
    留下較大的元素(x)與 li2 的下一個元素進行比較
    重複上面的步驟,最後即可得到一個新的有序列表
  1. 歸併排序:
    歸併排序是將列表分解為兩個部分
    在將分解後的兩個部分各分解為兩個部分
    以此重複,
    直到最後分解後的兩個部分為兩個有序的列表時
    也就是兩個列表各自包含的元素為0個或一個時
    對兩個列表進行歸併即可

    簡單來說,歸併排序與堆排序有些類似,
    二者都是知曉某個原理(堆/歸併),
    再構造條件運用該原理從而實現排序
  1. 程式碼實現:
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)

  1. 歸併排序的時間複雜度與空間複雜度
    因為涉及到建立新的列表以及遞迴
    所以歸併排序也存在空間複雜度,
    它的空間複雜度為 O(n)
        時間複雜度為 O(nlogn)

幾種排序對比


注:

1. 快排涉及遞迴,所以存在空間複雜度;
2. 穩定性是指再存在兩個相同元素的情況下,兩個相同元素的相對位置是否變動,
    如果相對位置發生了改變,則不穩定,反之即穩定。
    一般來說,對列表進行挨次調整的演算法(如氣泡排序、歸併排序等)是穩定的,
    而跳著調整的演算法(如插入排序、快速排序)則不穩定。

希爾排序

  1. 希爾排序是在插入排序的基礎上做的改良。大體思路如下:
    假定列表長度為 n
    以某個數 d 為基點,這個數需大於 0 小於 n, 假定為 n // 2
    
    從列表的起始位置開始,以 d 為間隔,每隔 d 個單位從列表中去一個值
    直到列表末尾
    把取出的值視作一個列表,對這個列表進行插入排序
    重複上面的步驟直到列表中的值取完為止

    此時將 d 設為 d // 2
    繼續重複上面的步驟

    直到 d 變為 1 時,進行最後一次插入排序
    結束後排序完成
  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
  1. 複雜度
    希爾排序的時間複雜度介於 O(n) 到 O(n^2) 之間
    這取決於 d 的取值(不同的取值方法會導致不同的時間複雜度)
    目前取值方法中最快的時間複雜度為:
        O(nlog2n)  即O(n*logn*logn)
    具體可參見維基百科

計數排序

  1. 概念
    假定已知一個未知長度的列表中的元素全都介於 0-100 之間
    這個列表為 li
    
    建立一個新的元素全部為 0 且長度為 100 的列表 tmp
    
    遍歷 li 中的每一個元素
    用 tmp 統計該元素的出現次數

    清空 li
    將 tmp 中儲存的元素及出現次數依次寫入到 li 中

    排序完成
  1. 程式碼實現
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)
  1. 複雜度
    由於計數排序需要建立新的列表,所以存在空間複雜度,最大為 O(n)
    其時間複雜度為 O(n)

桶排序

  1. 概念
    桶排序基於計數排序
    
    假定有一個列表 li
    li 列表中的數最大值為10000
    
    分 10 個列表(即桶)
    分別對應: 0-9999 10000-19999 20000-29999 ... 的數值範圍
    遍歷 li 
    按 0-9999 10000-19999 20000-29999 ... 的範圍區分每個數 
    並將數放入相應的桶中

    放入時可做相應的排序,保持桶內的數有序
    完成後將所有的桶重新組成一個列表
    此時排序完成
  1. 程式碼實現
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)
  1. 複雜度
    桶排序的時間複雜度為 O(n+k) ~ O(n2k)
            空間複雜度為 O(nk)

    桶排序的表現取決於資料的分佈,也就是需要對資料排序時採取不同的分桶策略

基數排序

  1. 概念
    基數排序類似於桶排序,但分桶策略有所不同

    首先找到列表中的最大數
    並算出這個數的位數 k (比如: 1000 -> 3,  99999 -> 5)

    首先遍歷列表,對列表中所有值按個位數排序(分別放入 0-9 10個桶中)
    再次遍歷列表,... 按十位數進行排序
    ...
    遍歷 k 次

    最後列表即為有序    
  1. 程式碼實現
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

相關文章