1. 程式人生 > >Python 排序演算法[一]:令你茅塞頓開,卻又匪夷所思

Python 排序演算法[一]:令你茅塞頓開,卻又匪夷所思

閱讀本文可以幫助你解開以下疑惑:演算法是什麼?演算法難不難?怎麼才能夠在短時間內熟悉業內的經典演算法呢?這些演算法用 Python 實現會是什麼樣的?它們的耗時會跟時間複雜度相關嗎?

神馬是演算法?

演算法(Algorithm)是指解題方案的準確而完整的描述,是一系列解決問題的清晰指令,演算法代表著用系統的方法描述解決問題的策略機制。也就是說,能夠對一定規範的輸入,在有限時間內獲得所要求的輸出。如果一個演算法有缺陷,或不適合於某個問題,執行這個演算法將不會解決這個問題。不同的演算法可能用不同的時間、空間或效率來完成同樣的任務。一個演算法的優劣可以用空間複雜度與時間複雜度來衡量。

演算法中的指令描述的是一個計算,當其執行時能從一個初始狀態和(可能為空的)初始輸入開始,經過一系列有限而清晰定義的狀態,最終產生輸出並停止於一個終態。一個狀態到另一個狀態的轉移不一定是確定的。隨機化演算法在內的一些演算法,包含了一些隨機輸入。

演算法的幾大特徵

一個演算法應該具有 “有窮性”、“確切性”、“輸入項”、“輸出項”、“可行性” 等重要的特徵。這些特徵對應的含義如下:

  • 有窮性(Finiteness)-- 演算法的有窮性是指演算法必須能在執行有限個步驟之後終止;
  • 確切性 (Definiteness) -- 演算法的每一步驟必須有確切的定義;
  • 輸入項 (Input) -- 一個演算法有0個或多個輸入,以刻畫運算物件的初始情況,所謂0個輸入是指演算法本身定出了初始條件;
  • 輸出項 (Output) -- 一個演算法有一個或多個輸出,以反映對輸入資料加工後的結果。沒有輸出的演算法是毫無意義的;
  • 可行性 (Effectiveness) -- 演算法中執行的任何計算步驟都是可以被分解為基本的可執行的操作步,即每個計算步都可以在有限時間內完成(也稱之為有效性)。

演算法兩大要素

  • 一,資料物件的運算和操作:計算機可以執行的基本操作是以指令的形式描述的。一個計算機系統能執行的所有指令的集合,成為該計算機系統的指令系統。一個計算機的基本運算和操作有如下四類:

    • 1 算術運算:加減乘除等運算
    • 2 邏輯運算:或、且、非等運算
    • 3 關係運算:大於、小於、等於、不等於等運算
    • 4 資料傳輸:輸入、輸出、賦值等運算 [1]
  • 二,演算法的控制結構:一個演算法的功能結構不僅取決於所選用的操作,而且還與各操作之間的執行順序有關。

演算法的好壞評定

你說這個演算法好、他卻說這個演算法不好,兩人爭論不休。那麼好與不好應該怎麼評定呢?

同一問題可用不同演算法解決,而一個演算法的質量優劣將影響到演算法乃至程式的效率。演算法分析的目的在於選擇合適演算法和改進演算法。一個演算法的評價主要從時間複雜度和空間複雜度來考慮。

  • 時間複雜度 -- 演算法的時間複雜度是指執行演算法所需要的計算工作量。一般來說,計算機演算法是問題規模n 的函式f(n),演算法的時間複雜度也因此記做。 T(n)=Ο(f(n)) 因此,問題的規模n 越大,演算法執行的時間的增長率與f(n) 的增長率正相關,稱作漸進時間複雜度(Asymptotic Time Complexity)。
  • 空間複雜度 -- 演算法的空間複雜度是指演算法需要消耗的記憶體空間。其計算和表示方法與時間複雜度類似,一般都用複雜度的漸近性來表示。同時間複雜度相比,空間複雜度的分析要簡單得多。
  • 正確性 - 演算法的正確性是評價一個演算法優劣的最重要的標準。
  • 可讀性 - 演算法的可讀性是指一個演算法可供人們閱讀的容易程度。
  • 健壯性 - 健壯性是指一個演算法對不合理資料輸入的反應能力和處理能力,也稱為容錯性。

以上的理論知識可以讓我們對演算法有個大致的理解和認知,接下來我們將使用 Python 實現幾個經典的 排序演算法,並在文末對比 Java 的實現。

演算法的內外之分

除了《唐門》弟子之外(斗羅大陸中的唐門),排序演算法也有內外之分。

  • 內部排序指的是在記憶體中進行排序;
  • 外部排序指的是由於資料量較大,無法讀入記憶體而需要在排序過程中訪問外部儲存的情況;

比較經典的排序演算法如下圖所示:

有氣泡排序、歸併排序、插入排序、希爾排序、選擇排序、快速排序等。

它們各自的時間複雜度如下圖所示:

注意:今天先講冒泡、選擇和插入排序

在開始之前,首先要感謝公眾號《五分鐘學演算法》的大佬 “程式設計師小吳” 授權動態圖片和排序思路。

氣泡排序

氣泡排序的過程如上圖所示,對應的演算法步驟為:

根據動態圖和演算法步驟, Python 實現氣泡排序的程式碼如下:

data = [5, 4, 8, 3, 2]
def bubble(data):
    for i in range(len(data)-1):    # 排序次數
        for s in range(len(data)-i-1):  # s為列表下標
            if data[s] > data[s+1]:
                data[s], data[s+1] = data[s+1], data[s]
    return data
print(bubble(data))
複製程式碼

程式執行後輸出結果為:

[2, 3, 4, 5, 8]
複製程式碼

這是一種時間複雜度上限比較高的方法,它的排序時間會隨著列表長度的增加而增加。

選擇排序

選擇排序的過程和步驟如上圖所示,根據動態圖和演算法步驟, Python 實現選擇排序的程式碼如下:

data = [3, 4, 1, 6, 2, 9, 7, 0, 8, 5]
def selections(nums):
    for i in range(len(nums)):
        min_index = min(nums)  # 最小值
        for j in range(len(nums) - i):
            if nums[min_index] < nums[j]:
                min_index = j
        nums[min_index], nums[len(nums) - i - 1] = nums[len(nums) - i - 1], nums[min_index]
    return nums
print(selections(data))
複製程式碼

其中 min() 方法可以獲得列表中的最小值,執行結果為:

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
複製程式碼

既然 min() 有這個特性 (備註:max() 方法可以獲得列表中最大值),我們可以將它利用起來,騷一點的程式碼為:

data = [3, 4, 1, 6, 2, 9, 7, 0, 8, 5]
res = []
for i in range(0, len(data)):
    aps = min(data)
    data.remove(aps)
    res.append(aps)
print(res)

複製程式碼

執行後得到的輸出結果為:

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
複製程式碼

假如將 min() 換成 max() 方法的,得到的輸出結果為:

[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
複製程式碼

這種只選擇列表最大元素或最小元素的行為,是否也能稱為選擇性排序呢?

雖然這種寫法的程式碼比較短,也更容易理解。但是它的時間複雜度是如何的呢?

首先要確認 min 和 max 的時間複雜度。有人給出了 list 各項操作的時間複雜度:

可以看到 min 和 max 都是隨著列表長度而增長,再加上本身需要 for 迴圈一次,所以這種寫法的時間複雜度為

真的是這樣嗎?

程式碼中有一個 remove 操作,將原列表的元素刪除,但是 remove 的時間複雜度也是O(n),這豈不是變成了 O(n*n + n),如何解決這個問題呢。

觀察到 pop 的時間複雜度是 O(1),那麼是否可以利用 pop 來降低時間複雜度呢?list 提供了獲取元素下標的方法,我們嘗試將程式碼改為:

data = [3, 4, 1, 6, 2, 9, 7, 0, 8, 5]
res = []
for i in range(0, len(data)):
    aps = max(data)
    result = data.pop(data.index(aps))
    print(result)
    res.append(aps)
print(res)
複製程式碼

執行後得到的輸出結果為:

9
8
7
6
5
4
3
2
1
0
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
複製程式碼

由此可見確實能夠根據索引刪除掉 list 元素,在刪除元素這裡降低了複雜度。

慢著,上述 pop 的時間複雜度是 O(1),但是 pop(data.index(i)) 這種操作的時間複雜度呢?也是 O(1) 嗎?我們可以做個實驗來驗證一下:

# 崔慶才丨靜覓、韋世東丨奎因 邀請你關注微信公眾號【進擊的Coder】
from datetime import datetime
data = [i for i in range(500000)]

start_time = datetime.now()
for i in range(len(data)):
    data.pop(data.index(i))
print(data)
print(datetime.now() - start_time)
複製程式碼

這是 pop(data.index(i)) 的程式碼,執行結果如下:

[]
0:00:40.151812
複製程式碼

而如果使用 pop()

from datetime import datetime
data = [i for i in range(500000)]

start_time = datetime.now()
for i in range(len(data)):
    data.pop()
print(data)
print(datetime.now() - start_time)

複製程式碼

執行後的結果為:

[]
0:00:00.071441
複製程式碼

結果顯而易見,pop(i) 的時間複雜度依舊是跟元素個數有關,而不是預想中的 O(1)。由於列表元素不斷減少,所以它的時間複雜度也不是 O(n),假設當前列表元素數量為 k,那麼這個部分的時間複雜度則是 O(k)。說明簡短的 min max寫法能夠一定程度的降低時間複雜度。

驗證一下,兩次 for 迴圈的選擇排序寫法和 mix max 的簡短寫法耗時情況如何:

from datetime import datetime
data = [i for i in range(30000)]


def selections(nums):
    for i in range(len(nums)):
        min_index = min(nums)  # 最小值
        for j in range(len(nums) - i):
            if nums[min_index] < nums[j]:
                min_index = j
        nums[min_index], nums[len(nums) - i - 1] = nums[len(nums) - i - 1], nums[min_index]
    return nums


start_time = datetime.now()
selections(data)
print(datetime.now() - start_time)
複製程式碼

這裡以 3 萬個元素為例,兩次 for 迴圈的執行時間為 47 秒左右。而同樣的數量,用 min max 方式排序:

from datetime import datetime
data = [i for i in range(30000)]

start_time = datetime.now()
res = []
for i in range(0, len(data)):
    aps = max(data)
    # del data[data.index(aps)]
    data.pop(data.index(aps))
    res.append(aps)
print(datetime.now() - start_time)
複製程式碼

所花費的時間為 12 秒,程式碼中用 del 和 pop 方法得到的結果一樣。

還……還有這種操作?

選擇排序也是一種時間複雜度上限比較高的方法,它的排序時間同樣會隨著列表長度的增加而增加。

插入排序

插入排序的過程和步驟如上圖所示,根據動態圖和演算法步驟, Python 實現插入排序的程式碼如下:

from datetime import datetime
data = [i for i in range(30000)]
data.insert(60, 5)

# 崔慶才丨靜覓、韋世東丨奎因 邀請你關注微信公眾號【進擊的Coder】
def direct_insert(nums):
    for i in range(1, len(nums)):
        temp = nums[i]  # temp變數指向尚未排好序元素(從第二個開始)
        j = i-1  # j指向前一個元素的下標
        while j >= 0 and temp < nums[j]:
            # temp與前一個元素比較,若temp較小則前一元素後移,j自減,繼續比較
            nums[j+1] = nums[j]
            j = j-1
            nums[j+1] = temp  # temp所指向元素的最終位置
    return nums


start_time = datetime.now()
res = direct_insert(data)
print(datetime.now() - start_time)
print(len(res), res[:10])
複製程式碼

生成列表後在列索引為 60 的地方插入一個值為 5 的元素,現在資料量為 3 萬零 1。程式碼執行得到的輸出結果為:

0:00:00.007398
30001 [0, 1, 2, 3, 4, 5, 5, 6, 7, 8]
複製程式碼

可以看到 3 萬零 1 個元素的列表排序耗時很短,而且通過切片可以看到順序已經經過排列。

然後測試一下選擇,程式碼如下:

from datetime import datetime
data = [i for i in range(30000)]
data.insert(60, 5)


def selections(nums):
    for i in range(len(nums)):
        min_index = min(nums)  # 最小值
        for j in range(len(nums) - i):
            if nums[min_index] < nums[j]:
                min_index = j
        nums[min_index], nums[len(nums) - i - 1] = nums[len(nums) - i - 1], nums[min_index]
    return nums


start_time = datetime.now()
res = selections(data)
print(datetime.now() - start_time)
print(len(res), res[:10])
複製程式碼

程式碼執行後得到的輸出結果為:

0:00:47.895237
30001 [0, 1, 2, 3, 4, 5, 5, 6, 7, 8]

複製程式碼

可以看到 3 萬零 1 個元素的列表排序耗並不短,耗費了 47 秒鐘,通過切片可以看到順序已經經過排列。

接著試一下 max min 型選擇排序的寫法,得到的結果為:

0:00:14.150992
30001 [29999, 29998, 29997, 29996, 29995, 29994, 29993, 29992, 29991, 29990]
複製程式碼

這簡直了,為什麼這種操作就能夠節省這麼多時間呢?

最後測試一下冒泡:

# 崔慶才丨靜覓、韋世東丨奎因 邀請你關注微信公眾號【進擊的Coder】
from datetime import datetime
data = [i for i in range(30000)]
data.insert(60, 5)


def bubble(data):
    for i in range(len(data)-1):    # 排序次數
        for s in range(len(data)-i-1):  # s為列表下標
            if data[s] > data[s+1]:
                data[s], data[s+1] = data[s+1], data[s]
    return data


start_time = datetime.now()
res = bubble(data)
print(datetime.now() - start_time)
print(len(res), res[:10])

複製程式碼

程式碼執行後得到的輸出結果為:

0:00:41.392303
30001 [0, 1, 2, 3, 4, 5, 5, 6, 7, 8]
複製程式碼

可以看到 3 萬零 1 個元素的列表排序耗並不短,耗費了 41 秒鐘,通過切片可以看到順序已經經過排列。

得到的結果為:

  • 氣泡排序 - 41
  • 選擇排序(兩層 for) - 47
  • 選擇排序(max mix) - 14
  • 插入排序 - 0.007398

問題:實在是令人匪夷所思,插入排序的速度居然比其他兩種排序方式耗時少那麼多。這是為什麼呢?

事實上插入排序只用了 1 層 for 迴圈,並非像冒泡和選擇那樣使用 2 層 for 迴圈,是不是由此可以重新整理上圖中對於時間複雜度的介紹呢?

問題:而兩種不同的選擇排序法的結果差異這麼大,這又是為什麼???

請在評論區發表你的看法