1. 程式人生 > >Python的十種常見演算法

Python的十種常見演算法

十種排序演算法

1. 常見演算法分類

十種常見排序演算法一般分為以下幾種:

(1)非線性時間比較類排序:

​ a. 交換類排序(快速排序、氣泡排序)

​ b. 插入類排序(簡單插入排序、希爾排序)

​ c. 選擇類排序(簡單選擇排序、堆排序)

​ d. 歸併排序(二路歸併排序、多路歸併排序)

(2)線性時間非比較類排序:

​ a. 技術排序

​ b. 基數排序

​ c. 桶排序

總結:

(1)在比較類排序種,歸併排序號稱最快,其次是快速排序和堆排序,兩者不相伯仲,但是有一點需要注意,資料初始排序狀態對堆排序不會產生太大的影響,而快速排序卻恰恰相反。

(2)線性時間非比較類排序一般要優於非線性時間比較類排序,但前者對待排序元素的要求較為嚴格,比如計數排序要求待待排序數的最大值不能太大,桶排序要求元素按照hash分桶後桶內元素的數量要均勻。線性時間非比計較類排序的典型特點是以空間換時間。

2. 演算法描述於實現

2.1 交換類排序

交換類排序的基本方法是:兩兩比較待排序記錄的排序碼,交換不滿足順序要求的偶對,直到全部滿足位置。常見的氣泡排序和快速排序就屬於交換類排序。

2.1.1 氣泡排序

演算法思想:

從陣列中第一個數開始,依次便利資料組中的每一個數,通過相鄰比較交換,每一輪迴圈下來找出剩餘未排序數終端最大數並“冒泡”至數列的頂端。

演算法步驟:

(1)從陣列中第一個數開始,依次與下一個數比較並次交換比自己小的數,直到最後一個數。如果發生交換,則繼續下面的步驟,如果未發生交換,則陣列有序,排序結束,此時時間複雜度未O(n);

(2)每一輪“冒泡”結束後,最大的數將出現在亂序數列的最後一位。重複步驟1。

穩定性:穩定排序。

時間複雜度:O(n)至O(n^2),平均時間複雜度為O(n^2)。

最好的情況:如果待排序資料列為正序,則一趟排序就可完成排序,排序碼的比較次數為(n-1)次,且沒有移動,時間複雜度為O(n)。

最壞的情況:如果待排序資料序列為逆序,則氣泡排序需要(n-1)趟起泡,每趟進行(n-i)次排序碼的比較和移動,即比較和移動次數均達到最大值:

比較次數:Cmax=∑i=1n−1(n−i)=n(n−1)/2=O(n^2)

移動次數等於比較次數,因此最壞時間複雜度為O(n^2)

例項程式碼:

# 氣泡排序
def bubble_sort(nums):
    for i in range(len(nums)-1):    # 這個迴圈負責氣泡排序進行的次數
        for j in range(len(nums)-i-1): # j為列表下標
            if nums[j] > nums[j+1]:
                nums[j], nums[j+1] = nums[j+1], nums[j]
    return nums

print(bubble_sort([45, 32, 8, 33, 12, 22, 19, 97]))
# 輸出:[8, 12, 19, 22, 32, 33, 45, 97]

2.1.2 快速排序

氣泡排序是在相鄰的兩個記錄進行比較和交換,每次交換隻能上移或下移一個位置,導致總的比較與移動次數較多。快速排序又稱為分割槽交換排序,是對氣泡排序的改進,快速排序採用的思想是分治思想。

演算法原理:

(1)從待排序的n個記錄中任意選取一個記錄(通常選取第一個記錄)為分割槽標準;

(2)把所有小於該排序列的記錄移動到左邊,把所有大於該排序碼的記錄移動到右邊,中間放所選記錄,稱之為第一趟排序;

(3)然後對前後兩個子序列分別重複上述過程,直到所有記錄都排好序。

穩定性:不穩定排序

時間複雜度:O(nlog2n)至O(n^2),平均時間複雜度為O(nlogn)。

最好的情況:每趟排序結束後,每次劃分使兩個子檔案的長度大致相等,時間複雜度為O(nlogn)。

最壞的情況:使待排序記錄已經拍好序,第一趟經過(n-1)次比較後第一個記錄保持位置不變,並等到一個(n-1)個元素的子記錄;第二趟經過(n-2)次比較,將第二個記錄定位在原來的位置上,並得到一個包括(n-2)個記錄的子檔案,依次類推,這樣總的比較次數是:

Cmax=∑i=1n−1(n−i)=n(n−1)/2=O(n2)

例項程式碼:

# 快速排序
def quick_sort(array):
    if len(array) < 2:  # 基線條件(停止遞迴的條件)
        return array
    else:   # 遞迴條件
        base_value = array[0]   # 選擇基準值
        # 由所有小於基準值的元素組成的子陣列
        less = [m for m in array[1:] if m < base_value]
        # 包括基準在內的同時和基準相等的元素
        equal = [w for w in array if w == base_value]
        # 由所有大於基準值的元素組成的子陣列
        greater = [n for n in array[1:] if n > base_value]
    return quick_sort(less) + equal + quick_sort(greater)

# 示例:
array = [2,3,5,7,1,4,6,15,5,2,7,9,10,15,9,17,12]
print(quickSort(array))
# 輸出為[1, 2, 2, 3, 4, 5, 5, 6, 7, 7, 9, 9, 10, 12, 15, 15, 17]

2.2 插入類排序

插入排序的基本方法是:每步將一個待排序的記錄,按其排序碼大小,插到前面已經排序的檔案中的適當位置,直到全部插入完為止。

2.2.1 直接插入排序

原理:從待排序的第n個記錄中的第二個記錄開始,依次與前面的記錄比較並尋找插入的位置,每次外迴圈結束後,將當前的數插入到合適的位置。

穩定性:穩定排序。

時間複雜度:O(n)至O(n^2),平均時間複雜度是O(n^2)。

最好情況:當待排序記錄已經有序,這時需要比較的次數是Cmin=n−1=O(n) 。

最壞情況:如果待排序記錄為逆序,則最多的比較次數為。Cmax=∑i=1n−1(i)=n(n−1)2=O(n2) 。

例項程式碼:

# 直接插入排序
def insert_sort(array):
    n = len(array)
    for i in range(1, n):
        if array[i] < array[i - 1]:
            temp = array[i]
            index = i  # 待插入的下標
            for j in range(i - 1, -1, -1):  # 從i-1迴圈到0(包括0)
                if array[j] > temp:
                    array[j + 1] = array[j]
                    index = j  # 記錄待插入下標
                else:
                    break
            array[index] = temp
    return array


lst = [1, 3, 4, 5, 6, 99, 56, 23, 78, 90]
print(insert_sort(lst))
# [1, 3, 4, 5, 6, 23, 56, 78, 90, 99]

2.2.2 Shell排序

Shell排序又稱縮小增量排序,由D.L.Shell在1959年提出,是對直接插入排序的改進。

原理:Shell排序法是對相鄰指定距離(稱為增量)的元素進行比較,並不斷把增量縮小至1,完成排序。

shell排序開始時增量較大,分組較多,每組的記錄數目較少,故在各組內採用直接插入排序較快,後來增量di逐漸縮小,分組數減少,各組的記錄數增多,但由於已經按(di-1)分組排序,檔案較接近於有序狀態,所以新的一趟排序過程較塊。因此Shell排序在效率上比直接插入排序有較大的改進。

在直接插入排序的基礎上,將直接插入排序中的1全部改變稱增量d即可,因為shell排序最後一輪的增量d就為1.

穩定性:不穩定排序

時間複雜度:O(n^1.3)到O(n^2)。Shell排序演算法的時間複雜度分析比較複雜,實際所需的時間取決於各次排序時增量的個數和增量的取值。研究表明,若增量的取值比較合理,Shell排序演算法的時間複雜度約為O(n^1.3)。

對於增量的選擇,Shell最初建議增量選擇為n/2,並且對增量取半直到1;D.Knuth教授建議di+1=[di-13]序列。

def shellSort(nums):
    # 設定步長
    step = len(nums) // 2
    while step > 0:
        for i in range(step, len(nums)):
            # 類似插入排序, 當前值與指定步長之前的值比較, 符合條件則交換位置
            while i >= step and nums[i - step] > nums[i]:
                nums[i], nums[i - step] = nums[i - step], nums[i]
                i -= step
        step = step // 2
    return nums


if __name__ == '__main__':
    nums = [9, 3, 5, 8, 2, 7, 1]
    print(shellSort(nums))

2.3 選擇類排序

選擇類排序的基本方法是:每步從待排序記錄中選出排序碼最小的記錄,順序放在已排序的記錄序列的後面,直到全部排完。

2.3.1 簡單選擇排序(又稱直接選擇排序)

原理:從所有記錄中選出最小的一個數據元素與第一個位置的記錄交換;然後再剩下的記錄當中再找最小的與第二個位置的記錄交換,迴圈到只剩下最後一個數據元素為止。

穩定性:不穩定排序。

時間複雜度:最壞、最好和平均複雜度均為O(n^2),因此,簡單選擇排序也是常見排序演算法中效能最差的排序演算法。簡單選擇排序的比較次數與檔案的初始狀態沒有關係,在第i趟排序中選出最小排序碼的記錄,需要做(n-i)次比較,因此總的比較次數是:∑i=1n−1(n−i)=n(n−1)/2=O(n2)。

# 簡單選擇排序
def selsectd_sort(array):
    # 獲取list的長度
    length = len(array)
    # 進行比較的輪數
    for i in range(0, length-1):
        smallest = i    # 預設設定最小值的index為當前值
        # 用當先最小index的值分別與後面的值進行比較,以便獲取最小index
        for j in range(i+1, length):
            # 如果找到比當前值小的index,則進行兩值交換
            if array[j] < array[smallest]:
                array[j], array[smallest] = array[smallest], array[j]

lst = [1, 4, 5, 0, 6]
print(selsectd_sort(lst))

2.3.2 堆排序

直接選擇排序中,第一次選擇經過了(n-1)次比較,只是從排序碼序列中選出了一個最小的排序碼,而沒有儲存其他中間比較結果。所以後一趟排序時又要重複許多比較操作,降低了效率。J.Willioms和Floyd在1964年提出了堆排序方法,避免了這一缺點。

堆的性值:

(1)性質:完全二叉樹或者是近似完全二叉樹;

(2)分類:

​ 大頂堆:父節點不小於子節點鍵值。

​ 小頂堆:父節點不大於子節點鍵值,圖展示一個最小堆

(3)左右孩子:沒有大小的順序

(4)堆的儲存:

​ 一般都用陣列來儲存堆,i節點的父節點下標就為(i - 1)/2.。

​ 它的左右子節點下標分別為(2*i+1)和(2*i+2)。

​ 如第0個節點左右子節點下標分別為1和2.

(5)堆的操作

​ a. 建立堆

>以最小堆為例,如果以陣列儲存元素時,一個數組具有對應的樹表現形式,但樹並不滿足堆的條件,需要重新排列元素,可以建立“堆化”的樹。

​ b. 插入堆

>將一個新元素插入到表尾,即陣列末尾時,如果新構成的二叉樹不滿足堆的性質,需要重新排列元素。

​ c. 刪除堆

堆排序中,刪除一個元素總是發生在堆頂,因為堆頂的元素是最小的(小頂堆中)。表中最後一個元素用來填補空缺位置,結果樹被更新以滿足堆條件。

穩定性:不穩定排序

插入程式碼實現:

每次插入都是講新資料放在陣列最後。可以發現從這個新資料的父節點到根節點必然為一個有序的數列,現在的任務是將這個新資料插入到這個有序資料中,這就類似於直接插入排序中將一個數據併入到有序區間中,這是節點“上浮”調整。

(6)堆排序的實現

由於堆也是用陣列來儲存的,故堆陣列進行堆化後,第一次將A[0]與A[n-1]交換,再對A[0...n-2]重新恢復堆。第二次將A[0]與A[n-2]交換,再對A[0...n-3]重新恢復堆,重複這樣的操作直到A[0]與A[1]交換。由於每次都是將最小的資料併入到後面的有序區間,故操作完成後整個陣列就有序了。有點類似於直接選擇排序。

# 堆排序
def sift_down(array, start, end):
    """
    調整成大頂堆,初始堆時,從下往上;交換堆頂與堆尾後,從上往下調整
    :param array: 列表的引用
    :param start: 父結點
    :param end: 結束的下標
    :return: 無
    """
    while True:
        # 當列表第一個是以下標0開始,結點下標為i,左孩子則為2*i+1,右孩子下標則為2*i+2;
        # 若下標以1開始,左孩子則為2*i,右孩子則為2*i+1
        left_child = 2*start + 1  # 左孩子的結點下標
        # 當結點的右孩子存在,且大於結點的左孩子時
        if left_child > end:
            break

        if left_child+1 <= end and array[left_child+1] > array[left_child]:
            left_child += 1
        if array[left_child] > array[start]:  # 當左右孩子的最大值大於父結點時,則交換
            array[left_child], array[start] = swap(array[left_child], array[start])

            start = left_child  # 交換之後以交換子結點為根的堆可能不是大頂堆,需重新調整
        else:  # 若父結點大於左右孩子,則退出迴圈
            break

def heap_sort(array):  # 堆排序
    # 先初始化大頂堆
    first = len(array)//2 - 1  # 最後一個有孩子的節點(//表示取整的意思)
    # 第一個結點的下標為0,很多部落格&課本教材是從下標1開始,無所謂吧,你隨意
    for i in range(first, -1, -1):  # 從最後一個有孩子的節點開始往上調整
        print(array[i])
        sift_down(array, i, len(array)-1)  # 初始化大頂堆

    print("初始化大頂堆結果:", array)
    # 交換堆頂與堆尾
    for head_end in range(len(array)-1, 0, -1):  # start stop step
        array[head_end], array[0] = array[0], array[head_end] # 交換堆頂與堆尾
        sift_down(array, 0, head_end-1)  # 堆長度減一(head_end-1),再從上往下調整成大頂堆


if __name__ == "__main__":
    array = [16, 7, 3, 20, 17, 8]
    print(array)
    heap_sort(array)
    print("堆排序最終結果:", array)

(7)堆排序的效能分析

由於每次重新恢復堆的時間複雜度為O(logN),共(N-1)次堆調整操作,再加上前面建立堆時(N/2)次向下調整,每次調整時間複雜度也為O(logN)。兩次操作時間相加還是O(NlogN)。故堆排序的時間複雜度為O(N logN)。

最壞情況:如果待排序陣列是有序的,仍然需要O(N*logN)複雜度的比較操作,只是少了移動的操作;

最好情況:如果待排序陣列是逆序的,不僅需要O(N*logN)複雜度的比較操作,而且需要O(N*logN)複雜度的交換操作。總的時間複雜度還是O(N*logN)。

因此,堆排序和快速排序再效率上是差不多的,但是堆排序一般優於快速排序的重要一點是,資料的初始分佈情況對堆排序的效率沒有大的影響。

2.4 歸併排序

(1)演算法思想:

歸併排序屬於比較類非線性時間排序,號稱比較類排序中效能最佳者,再資料應用中較廣。

歸併排序是分治法的一個典型應用。將已有序的子序列合併,得到完全有序的序列;即先使每個子序列有序,再使子序列段間有序。若將兩個有序表合併成一個有序表,稱為二路歸併。

(2)穩定性

穩定排序演算法

(3)時間複雜度

最壞,最好和平均時間複雜度都是O(nlogn)

# 歸併排序
def merge(left, right):
    # 從兩個右順序的列表李白你依次取資料比較後放入result
    # 每次我們分別拿出兩個列表中最小的數比較,把較小的放入result
    result = []
    while len(left) > 0 and len(right) > 0:
        # 為了保持穩定性,當遇到相等的時候優先把左側的數放進結果列表
        # 因為left本來也是大數列中比較靠左的
        if left[0] <= right[0]:
            result.append(left.pop(0))
        else:
            result.append(right.pop(0))
        # while迴圈出來之後,說明其中一個數組沒有資料了
        # 我們把另一個數組新增到結果陣列後面
    result += left
    result += right
    return result

def merge_sort(array):
    # 不斷遞迴呼叫自己,一直到拆分成單個元素的時候就返回這個元素,不再拆分了
    if len(array) == 1:
        return array

    # 取拆分的中間位置
    middle = len(array) // 2
    # 拆分過後左側子串
    array_left = array[:middle]
    # 拆分過後右側子串
    array_right = array[middle:]

    # 對拆分過後的左右字串再拆分,一直到只有一個元素為止
    # 最後一次遞迴時候, left和right都會接到一個元素的列表
    # 最後一次遞迴之前的left和right會接收到排好序的子序列
    left = merge_sort(array_left)
    right = merge_sort(array_right)

    # 我們對返回的兩個拆分結果進行排序後合併再返回正確順序的字列表
    # 這裡我們呼叫一個函式幫助我們按順序合併left和rigth
    return merge(left, right)

lst = [5, 4, 3, 2, 1]
print(merge_sort(lst))

2.5 線性時間非比較類排序

2.5.1 計數排序

計數排序使一個非基於比較的排序演算法,該演算法於1954年由Harold H.Seward提出,它的優勢在於對於較小範圍內的整數排序。它的複雜度為O(n+k)(其中K使待排序數的範圍),快於任何比較排序演算法,缺點就是非常消耗空間。很明顯,如果當O(k)>O(n*log(n))的時候其效率反而不如基於比較的排序,比如堆排序和歸併排序和快速排序。

(1)演算法原理

基本思想是對於給定的輸入序列中的每一個元素x,確定該序列中值小於x的元素的個數。一旦有了這個資訊,就可以將x直接存放到最終的輸出序列的正確位置上。例如,如果輸入序列中只有17個元素的值小於x的值,則x可以直接存放在輸出序列的第18個位置上。當然,如果有多個元素具有相同的值時,我們不能將這些元素放在輸出序列的同一個位置上,在程式碼中作適當的修改即可。

(2)演算法步驟:

​ a. 找出待排序的陣列中最大的元素;

​ b. 統計陣列中每個值為i的元素出現的次數,存入陣列c的第i項;

​ c. 對所有的計數累加(從C中的第一個元素開始,每一項和前一項相加);

​ d. 反向填充目標陣列:將每個元素i放在新陣列的第C(i)項,每放一個元素就將 C(i)減去1。

(3)時間複雜度

​ O(n+k)

(4)空間複雜度

​ O(k)

(5)要求

​ 待排序數中最大數值不能太大

(6)穩定性

​ 穩定

(7)程式碼示例

# 計數排序
def counting_sort(a, k):  # k = max(a)
    n = len(a)  # 計算a序列的長度
    b = [0 for i in range(n)]  # 設定輸出序列並初始化為0
    c = [0 for i in range(k + 1)]  # 設定計數序列並初始化為0,
    for j in a:
        c[j] = c[j] + 1
    for i in range(1, len(c)):
        c[i] = c[i] + c[i-1]
    for j in a:
        b[c[j] - 1] = j
        c[j] = c[j] - 1
    return b


print(counting_sort([1, 3, 5, 32, 423, 5, 23, 5, 75], 423))

注意:計數排序是典型的以空間換時間的排序演算法,對待排序的資料有嚴格的要求,比如待排序的數值中包含負數,最大值都有限制,謹慎使用。

2.5.2 基數排序

基數排序屬於“分配式排序”,是非比較類線性時間排序的一種,又稱“桶子法”。顧名思義,它是透過鍵值的部分資訊,將要排序的元素分配至某些“桶”中,已達到排序的作用。

# 基數排序
def radix_sort(list, d=3): # 預設三位數,如果是四位數,則d=4,以此類推
    for i in range(d):  # d輪排序
        s = [[] for k in range(10)]  # 因每一位數字都是0~9,建10個桶
        for j in list:
            s[int(j / (10 ** i)) % 10].append(j)
        re = [a for b in s for a in b]
    return re

print(radix_sort([12, 4, 23, 26, 85, 12, 45], 2))

2.5.3 桶排序

桶排序也是分配排序的一種,但其是基於比較排序的,這也是與基數排序最大的區別所在。

(1)演算法思想

桶排序演算法類似於散列表。首先要假設待排序的元素輸入符合某種均勻分佈,例如資料均勻分佈在[0, 1]區間上,則可將此區間劃分為10個小區間,稱為桶,對散佈到同一個桶中的元素再排序。

(2)要求

待排序數長度一致

(3)排序過程

​ a. 設定一個定量的陣列當作空桶子;

​ b. 尋訪序列,並且把記錄一個一個放到對應的桶子去;

​ c. 對每個不是空的桶子進行排序;

​ d. 從不是空的桶子裡把專案再放回原來的序列中。

>例如待排序列 k = {49, 38, 35, 97, 76, 73, 27, 49}。這些資料全部在1—100之間。因此我們定製10個桶,然後確定對映函式 f(k) = k/10。則第一個關鍵字49將定位到第4個桶中(49/10=4)。依次將所有關鍵字全部堆入桶中,並在每個非空的桶中進行快速排序。

(4)時間複雜度

對N個關鍵字進行桶排序的時間複雜度分為兩個部分:

​ a. 迴圈計算每個關鍵字的桶對映函式,這個時間複雜度是O(N)

​ b. 利用先進的比較排序演算法對每個桶內的所有資料進行排序,對於N個待排資料,M個桶,平均每個桶[N/M]個數據,則桶內排序的時間複雜度為:∑i=1MO(Ni∗logNi)=O(N∗logNM) 。其中Ni為第i個桶的資料量。

因此,平均時間複雜度為線性的O(N+C),C為桶內排序所花費的時間。當每個桶只有一個數,則最好的時間複雜度為: O(N)。

# 桶排序
def bucket_sort(a):
    buckets = [0] * ((max(a) - min(a)) + 1)  # 初始化桶元素為0
    for i in range(len(a)):
        buckets[a[i] - min(a)] += 1  # 遍歷陣列a,在桶的相應位置累加值
    b = []
    for i in range(len(buckets)):
        if buckets[i] != 0:
            b += [i + min(a)] * buckets[i]
    return b


print(bucket_sort([1,3, 4, 53, 23, 534, 23]))