Python 一網打盡<排序演算法>之從希爾排序演算法的分治哲學開始

一枚大果殼發表於2022-04-16

1. 前言

本文將介紹希爾排序歸併排序基數排序(桶排序)堆排序

在所有的排序演算法中,冒泡插入選擇屬於相類似的排序演算法,這類演算法的共同點:通過不停地比較,再使用交換邏輯重新確定資料的位置。

希爾歸併快速排序演算法也可歸為同一類,它們的共同點都是建立在分治思想之上。把大問題分拆成小問題,解決所有小問題後,再合併每一個小問題的結果,最終得到對原始問題的解答。

通俗而言:化整為零,各個擊破。

分治演算法很有哲學蘊味:老祖宗所言 合久必分,分久必合,分開地目的是為了更好的合併。

分治演算法的求解流程:

  • 分解問題:將一個需要解決的、看起很複雜 原始問題 分拆成很多獨立的子問題子問題原始問題有相似性。

    如:一個數列的區域性(小問題)有序,必然會讓數列最終(原始問題)有序。

  • 求解子問題:子問題除了與原始問題具有相似性,也具有獨立性,即所有子問題都可以獨立求解。

  • 合併子問題:合併每一個子問題的求解結果最終可以得到原始問題的解。

下面通過深入瞭解希爾排序演算法,看看分治演算法是如何以哲學之美的方式工作的。

2. 希爾排序

講解希爾之前,先要回顧一下插入排序。插入排序的平均時間複雜度,理論上而言和氣泡排序是一樣的 O(n2),但如果數列是前部分有序,則每一輪只需比較一次,對於 n 個數字的原始數列而言,時間複雜度可以是達到 O(n)

插入排序的時間複雜度為什麼會出現如此有意思的變化?

  • 插入排序演算法的排序思想是儘可能減少數字之間的交換次數
  • 通常情形下,交換處理的時間大約是移動的 3 倍。這便是插入排序的效能有可能要優於氣泡排序的原因。

希爾排序演算法本質就是插入排序,或說是對插入排序的改良。

其演算法理念:讓原始數列不斷趨近於排序,從而降低插入排序的時間複雜度。

希爾排序的實現流程:

  • 把原始數列從邏輯上切割成諸多個子數列。
  • 對每一個子數列使用插入排序演算法排序。
  • 當所有子數列完成後,再對原數列進行最後一次插入演算法排序。

希爾排序演算法的理念:當數列區域性有序時,全域性必然是趨向於有序”

希爾排序的關鍵在於如何切分子數列,切分方式可以有 2 種:

任何時候使用分治理念解決問題時,分拆子問題都是關鍵的也是核心的。

2.1 前後切分

如有原始數列=[3,9,8,1,6,5,7] 採用前後分成 2 個子數列。

在這裡插入圖片描述

前後分算得上是簡單粗暴的切分方案,沒有太多技術含量,這種一根筋的切分方式,對於原始問題的最終效能優化可能起不了太多影響。

在這裡插入圖片描述

如上圖所示,對子數列排序後,如果要實現原始數列中的所有數字從小到大排列有序,則後部分的數字差不多全部要移到時前部分數字的中間,其移動量是非常大的。

後面的 4 個數字中,1 需要移動 3 次,567 需要移動 2 次, 肉眼可見的次數是 9 次。

這種分法很難實現數字區域性有序的正態分佈,因為數字的位置變化不大

如下圖是原始數列=[3,9,8,1,6,5,7] 的原始位置示意圖:
在這裡插入圖片描述

使用前後切分後的數字位置變化如下圖所示,和上圖相比較,數字的位置變化非常有限,而且是限定在一個很窄的範圍內。也就是說子問題的求解結果對最終問題的結果的影響很微小。

在這裡插入圖片描述

2.2 增量切分

增量切分採用間隔切分方案,可能讓數字區域性有序以正態分佈。

增量切分,需要先設定一個增量值。如對原始數列=[3,9,8,1,6,5,7] 設定切分增量為 3 時,整個數列會被切分成 3 個邏輯子數列。增量數也決定最後能切分多少個子數列。

在這裡插入圖片描述

對切分後的 3 個子數列排序後可得到下圖:

在這裡插入圖片描述

在此基礎之上,再進行插入排序的的次數要少很多。

使用增量切分後再排序,原始數列中的數字的位置變化範圍較大。

如數字 9 原始位置是 1,經過增量切分再排序後位置可以到 4。已經很接近 9 的最終位置 6 了。

下圖是增量切分後數字位置的變化圖,可以看出來,幾乎所有的數字都產生了位置變化 ,且位置變化的跨度較大。有整體趨於有序的勢頭。

在這裡插入圖片描述

實現希爾排序演算法時,最佳的方案是先初始化一個增量值,切分排序後再減少增量值,如此反覆直到增量值等於 1 (也就是對原數列整體做插入排序)。

增量值大,數字位置變化的跨度就大,增量值小,數字位置的變化會收緊。

編碼程式碼希爾排序:

# 希爾排序
def shell_sort(nums):
    # 增量
    increment = len(nums) // 2
    # 新數列
    while increment > 0:
        # 增量值是多少,則切分的子數列就有多少
        for start in range(increment):
            insert_sort(nums, start, increment)
        # 修改增量值,直到增量值為 1    
        increment = increment // 2
        
# 插入排序
def insert_sort(nums, start, increment):
    for back_idx in range(start + increment, len(nums), increment):
        for front_idx in range(back_idx, 0, -increment):
            if nums[front_idx] < nums[front_idx - increment]:
                nums[front_idx], nums[front_idx - increment] = nums[front_idx - increment], nums[front_idx]
            else:
                break

nums = [3, 9, 8, 1, 6, 5, 7]
shell_sort(nums)
print(nums)

這裡會有一個讓人疑惑的觀點:難道一次插入排序的時間複雜度會高於多次插入排序時間複雜度?

通過切分方案,經過子數列的微排序(因子數列數字不多,其移動交換量也不會很大),最後一次插入排序的移動次數可以達到最小,只要增量選擇合適,時間複雜度可以控制 在 O(n)O(<sup>2</sup>) 之間。完全是有可能優於單純的使用一次插入排序。

3. 歸併排序

歸併排序演算法也是基於分治思想。和希爾排序一樣,需要對原始數列進行切分,但是切分的方案不一樣。

相比較希爾排序,歸併排序的分解子問題,求解子問題,合併子問題的過程分界線非常清晰。可以說,歸併排序更能完美詮釋什麼是分治思想。

3.1 分解子問題

歸併排序演算法的分解過程採用二分方案。

  • 把原始數列一分為二。

  • 然後在已經切分後的子數列上又進行二分。

  • 如此反覆,直到子數列不能再分為止。

    如下圖所示:

在這裡插入圖片描述

如下程式碼,使用遞迴演算法對原數列進行切分,通過輸出結果觀察切分過程:

# 切分原數列
def split_nums(nums):
    print(nums)
    if len(nums) > 1:
        # 切分線,中間位置
        sp_line = len(nums) // 2
        split_nums(nums[0:sp_line])
        split_nums(nums[sp_line:])

nums = [3, 9, 8, 1, 6, 5, 7]
split_nums(nums)

輸出結果:和上面演示圖的結論一樣。

[3, 9, 8, 1, 6, 5, 7]
[3, 9, 8]
[3]
[9, 8]
[9]
[8]
[1, 6, 5, 7]
[1, 6]
[1]
[6]
[5, 7]
[5]
[7]

3.2 求解子問題

切分後,對每相鄰 2 個子數列進行合併。當對相鄰 2 個數列進行合併時,不是簡單合併,需要保證合併後的數字是排序的。如下圖所示:

在這裡插入圖片描述

3.3 合併排序

如何實現 2 個數字合併後數字有序?

使用子數列中首數字比較演算法進行合併排序。如下圖演示瞭如何合併 nums01=[1,3,8,9]、nums02=[5,6,7] 2 個子數列。

子數列必須是有序的!!

在這裡插入圖片描述

  • 數字 1 和 數字 5 比較,5 大於 1 ,數字 1 先位於合併數列中。

在這裡插入圖片描述

  • 數字 3 與數字 5 比較,數字 3 先進入合併數列中。

在這裡插入圖片描述

  • 數字 8 和數字 5 比較,數字 5 進入合併數列中。

在這裡插入圖片描述

從頭至尾,進行首數字大小比較,最後,可以保證合併後的數列是有序的。

在這裡插入圖片描述

編寫一個合併排序程式碼:

如果僅僅是合併 2 個有序數列,本文提供 2 個方案:

  • 不增加額外的儲存空間:把最終合併排序好的數字全部儲存到其中的一個數列中。
def merge_sort(nums01, nums02):
    # 為 2 個數列建立 2 個指標
    idx_01 = 0
    idx_02 = 0
    while idx_01 < len(nums01) and idx_02 < len(nums02):
        if nums01[idx_01] > nums02[idx_02]:
            # 這裡不額外增加儲存空間,如果數列 2 中的值大於數字 1 的插入到數列 1 中
            nums01.insert(idx_01, nums02[idx_02])
            idx_02 += 1
        # 數列 1 的指標向右移動    
        idx_01 += 1
    # 檢查 nums02 中的數字是否已經全部合併到 nums01 中
    while idx_02 < len(nums02):
        nums01.append(nums02[idx_02])
        idx_02 += 1

nums01 = [1, 2, 8, 9]
nums02 = [5, 6, 7, 12, 15]
merge_sort(nums01, nums02)
# 合併後的數字都儲存到了第一個數列中
print(nums01)
'''
輸出結果:
[1,2,5,6,7,8,9,12,15]
'''
  • 增加一個空數列,用來儲存最終合併的數字。
# 使用附加數列
nums=[]
def merge_sort(nums01, nums02):
    # 為 2 個數列建立 2 個指標
    idx_01 = 0
    idx_02 = 0
    k=0
    while idx_01 < len(nums01) and idx_02 < len(nums02):
        if nums01[idx_01] > nums02[idx_02]:
            nums.append(nums02[idx_02])
            idx_02 += 1
        else:
            nums.append(nums01[idx_01])
            idx_01 += 1
        k+=1
    # 檢查是否全部合併
    while idx_02 < len(nums02):
        nums.append(nums02[idx_02])
        idx_02 += 1
    while idx_01 < len(nums01):
        nums.append(nums01[idx_01])
        idx_01 += 1

nums01 = [1, 2, 8, 9]
nums02 = [5, 6, 7, 12, 15]
merge_sort(nums01, nums02)
print(nums)

前面是分步講解切分和合並邏輯,現在把切分和合並邏輯合二為一,就完成了歸併演算法的實現:

def merge_sort(nums):
    if len(nums) > 1:
        # 切分線,中間位置
        sp_line = len(nums) // 2
        nums01 = nums[:sp_line]
        nums02 = nums[sp_line:]
        merge_sort(nums01)
        merge_sort(nums02)

        # 為 2 個數列建立 2 個指標
        idx_01 = 0
        idx_02 = 0
        k = 0
        while idx_01 < len(nums01) and idx_02 < len(nums02):
            if nums01[idx_01] > nums02[idx_02]:
                # 合併後的數字要儲存到原數列中
                nums[k] = nums02[idx_02]
                idx_02 += 1
            else:
                nums[k] = nums01[idx_01]
                idx_01 += 1
            k += 1
        # 檢查是否全部合併
        while idx_02 < len(nums02):
            nums[k] = nums02[idx_02]
            idx_02 += 1
            k += 1
        while idx_01 < len(nums01):
            nums[k] = nums01[idx_01]
            idx_01 += 1
            k += 1

nums = [3, 9, 8, 1, 6, 5, 7]
merge_sort(nums)
print(nums)

個人覺得,歸併演算法對於理解分治思想有大的幫助。

從歸併演算法上可以完整的體現分治理念的哲學之美。

4. 基數排序

基數排序(radix sort)屬於“分配式排序”(distribution sort),又稱“桶子法”(bucket sort)或 bin sort。

基數排序沒有使用分治理念,放在本文一起講解,是因為基數排序有一個對數字自身切分邏輯。

基數排序的最基本思想:

如對原始數列 nums = [3, 9, 8, 1, 6, 5, 7] 中的數字使用基數排序。

  • 先提供一個長度為 10 的新空數列(本文也稱為排序數列)。

    為什麼新空數列的長度要設定為 10?等排序完畢,相信大家就能找到答案。

在這裡插入圖片描述

。把原數列中的數字轉存到新空數列中,轉存方案:

nums 中的數字 3 儲存在新數列索引號為 3 的位置。

nums 中的數字 9 儲存在新數列索引號為 9 的位置。

nums 中的數字 8 儲存在新數列索引號為 8 的位置。

……

在這裡插入圖片描述

從上圖可知,原數列中的數字所轉存到排序數列中的位置,是數字所代表的索引號所指的位置。顯然,經過轉存後,新數列就是一個排好序的數列。

新空數列的長度定義為多大由原始數列中數字的最大值來決定。

編碼實現:

# 原數列
nums = [3, 9, 8, 1, 6, 5, 7]
# 找到數列中的最大值
sort_nums=[0]*(max(nums)+1)
for i in nums:
    sort_nums[i]=i

print([i for i in sort_nums if i!=0])
'''
輸出結果:
[1,3,5,6,7,8,9]
'''

使用上述方案建立新空資料,如果數字之間的間隔較大時,新數列的空間浪費就非常大。

如對 nums=[1,98,51,2,32,4,99,13,45] 使用上述方案排序,新空數列的長度要達到 99 ,真正需要儲存的數字只有 7 個,如此空間浪費幾乎是令人恐怖的。

所以,有必要使用改良方案。如果在需要排序的數字中出現了 2 位以上的數字,則使用如下法則:

  • 先根據每一個數字個位上的數字進行儲存。個位數是 1 儲存在位置為 1 的位置,是 9 就儲存在位置是 9 的位置。如下圖:

在這裡插入圖片描述

可看到有可能在同一個位置儲存多個數字。這也是基數排序也稱為桶排序的原因。

一個位置就是一個桶,可以存放多個具有相同性質的數字。如上圖:個位上數字相同的數字就在一個桶中。

  • 把存放在排序數列中的數字按順序重新拿出來,這時的數列順序變成 nums=[1,51,2,32,13,4,45,8,99]
  • 把重組後數列中的數字按十位上的數字重新存入排序數列。

在這裡插入圖片描述

可以看到,經過 2 輪轉存後,原數列就已經排好序。

這個道理是很好理解的:

現實生活中,我們在比較 2 個數字 大小時,可以先從個位上的數字相比較,然後再對十位上的數字比較。

基數排序,很有生活的味道!!

編碼實現基數排序:

nums = [1, 98, 51, 2, 32, 4, 99, 13, 45]
# 數列中的最大值
m = max(nums)
# 確定最大位數,用來確定需要轉存多少次
l = len(str(m))

for i in range(l + 1):
    # 排序數列,也是桶
    sort_nums = [[] for _ in range(10)]
    for n in nums:
        # 分解數字個位上的數字
        g_s = (n // 10 ** i) % 10
        # 根據個位上的數字找到轉存位置
        sub_nums = sort_nums[g_s]
        sub_nums.append(n)
    # 合併資料
    nums = []
    for l in sort_nums:
        nums.extend(l)
print(nums)
'''
輸出結果:
[1, 2, 4, 13, 32, 45, 51, 98, 99]
'''

上述轉存過程是由低位到高位,也稱為 LSD ,也可以先高位後低位方案轉存MSD

5. 總結

分治很有哲學味道,當你遇到困難,應該試著找到問題的薄弱點,然後一點點地突破。

當遇到困難時,老師們總會這麼勸解我們。

分治其實和專案開發中的元件設計思想也具有同工異曲之處。

相關文章