1. 程式人生 > >除了氣泡排序,你知道Python內建的排序演算法嗎?

除了氣泡排序,你知道Python內建的排序演算法嗎?

除了氣泡排序,你知道Python內建的排序演算法嗎?

 

對於程式設計演算法,可能很多讀者在學校第一個瞭解的就是氣泡排序,但是你真的知道 Python 內建排序演算法list.sort() 的原理嗎?它使用的是一種快速、穩定的排序演算法Timsort,其時間複雜度為 O(n log n),該演算法的目標在於處理大規模真實資料。

學習Python中有不明白推薦加入交流群
                號:516107834
                群裡有志同道合的小夥伴,互幫互助,
                群裡有不錯的學習教程!

Timsort 是一種對真實資料非常有效的排序演算法。Tim Peters 在 2001 年為 Python 程式語言創造了 Timsort。Timsort 首先分析它要排序的列表,然後基於該分析選擇合理方案。

Timsort 自發明以來,就成為 Python、Java 、Android 平臺和 GNU Octave 的預設排序演算法。

除了氣泡排序,你知道Python內建的排序演算法嗎?

 

圖源: http://bigocheatsheet.com/

Timsort 的排序時間與 Mergesort 相近,快於其他大多數排序演算法。Timsort 實際上借鑑了插入排序和歸併排序的方法,後文將詳細介紹它的具體過程。

Peters 設計 Timsort 是為了利用大量存在於現實資料集中的有序元素,這些有序元素被稱為「natural runs」。總而言之,Timsort 會先遍歷所有資料並找到資料中已經排好序的分割槽,且每一個分割槽可以稱為一個 run,最後再按規則將這些 run 歸併為一個。

陣列中元素少於 64 個

如果排序的陣列中元素少於 64 個,那麼 Timsort 將執行插入排序。插入排序是對小型列表最有效的簡單排序,它在大型列表中速度很慢,但是在小型列表中速度很快。插入排序的思路如下:

  • 逐個檢視元素
  • 通過在正確的位置插入元素來建立排序列表

下面的跟蹤表說明了插入排序如何對列表 [34, 10, 64, 51, 32, 21] 進行排序的:

除了氣泡排序,你知道Python內建的排序演算法嗎?

 

在這個示例中,我們將從左向右開始排序,其中黑體數字表示新的已排序子陣列。在原陣列每一個元素的排序中,它會從右到左對比已排序子陣列,並插入適當的位置。用動圖來說明插入排序:

除了氣泡排序,你知道Python內建的排序演算法嗎?

 

天然有序的區塊:run

如果列表大於 64 個元素,則 Timsort 演算法首先遍歷列表,查詢「嚴格」升序或降序的部分(Run)。如果一個部分遞減,Timsort 將逆轉這個部分。因此,如果 run 遞減,則如下圖所示(run 用粗體表示):

除了氣泡排序,你知道Python內建的排序演算法嗎?

 

如果沒有遞減,則如下圖所示:

除了氣泡排序,你知道Python內建的排序演算法嗎?

 

minrun 的大小是根據陣列大小確定的。Timsort 演算法選擇它是為了使隨機陣列中的大部分 run 變成 minrun。當 run N 的長度等於或略小於 2 的倍數時,歸併 2 個數組更加高效。Timsort 選擇 minrun 是為了確保 minrun 等於或稍微小於 2 的倍數。

該演算法選擇 minrun 的範圍為 32 ~ 64。當除以 minrun 時,使原始陣列的長度等於或略小於 2 的倍數。

如果 run 的長度小於 minrun,則計算 minrun 減去 run 的長度。我們可以將 run 之外的新元素(minrun - run 個)放到 run 的後面,並執行插入排序來建立新的 run,這個新的 run 長度和 minrun 相同。

如果 minrun 是 63,而 run 的長度是 33,那麼可以獲取 63 - 33 = 30 個新元素。然後將這 30 個新元素放到 run 的末尾並作為新的元素,所以 run 的第 34 個元素 run[33] 有 30 個子元素。最後只需要對後面 30 個元素執行一個插入排序就能建立一個長度為 63 的新 run。

在這一部分完成之後,現在應該在一個列表中有一系列已排序的 run。

歸併

Timsort 現在需要執行歸併排序來合併 run,需要確保在歸併排序的同時保持穩定和平衡。為了保持穩定,兩個等值的元素不應該交換,這不僅保持了它們在列表中的原始位置,而且使演算法更快。

當 Timsort 搜尋到 runs 時,它們會被新增到堆疊中。一個簡單的堆疊是這樣的:

除了氣泡排序,你知道Python內建的排序演算法嗎?

 

想象一堆盤子。你不能從底部取盤子,必須從頂部取,堆疊也是如此。

當歸並不同的 run 時,Timsort 試圖平衡兩個相互矛盾的需求。一方面,我們希望儘可能地延遲歸併,以便利用之後可能出現的模式。但我們更希望儘快歸併,以利用剛才發現的在記憶體層級中仍然排名很高的 run。我們也不能「過分」延遲合併,因為它記住未合併的執行需要消耗記憶體,而堆疊的大小是固定的。

為了得到折衷方案,Timsort 追蹤堆疊上最近的三個項,併為這些堆疊項建立了兩個必須保持為 True 的規則:

  1. A > B + C
  2. B > C

其中 A、B 和 C 是堆疊中最近的三個項。

用 Tim Peters 自己的話來說:

一個好的折衷方案是在堆疊項上維護兩個不變數,其中 A、B 和 C 是最右邊三個還未歸併片段的長度。

通常,將不同長度的相鄰 run 歸併在一起是很難的。更困難的是還必須要保持穩定。為了解決這個問題,Timsort 設定了臨時記憶體。它將兩個 run 中較小的(同時呼叫 runA 和 runB)放在這個臨時記憶體中。

GALLOPING(飛奔模式)

當 Timsort 歸併 A 和 B 時,它注意到一個 run 已經連續多次「獲勝」。如果 run A 的數值完全小於 run B,那麼 run A 會回到原始位置。歸併這兩個 run 會耗費巨大工作量,而且還不會取得任何效果。

通常情況下,資料會有一些預設的內部結構。Timsort 假設,如果 run A 中的值大多低於 run B 的值,那麼 A 的值可能就會小於 B。

除了氣泡排序,你知道Python內建的排序演算法嗎?

 

然後 Timsort 將進入飛奔模式。Timsort 不是檢查 A[0] 和 B[0],而是二進位制搜尋 B[0] 在 A[0] 中的合理位置。這樣,Timsort 可以將 A 的整個部分移動到合適的位置。然後,Timsort 在 B 中搜索 A[0] 的適當位置。然後,Timsort 將立即移動整個 B 到合適的位置。

除了氣泡排序,你知道Python內建的排序演算法嗎?

 

Timsort 檢查 B[0](值為 5),並使用二進位制搜尋查詢其 A 中的正確位置。

現在 B[0] 在 A 列表的後面,Timsort 檢查 B 的正確位置是否有 A[0](即 1),所以我們要看 1 的位置。這個數在 B 的前部,現在我們知道 B 在 A 的後邊,A 在 B 的前邊。

如果 B[0] 的位置非常接近 A 的前端(反之亦然),那麼這個操作就沒必要了。Timsort 也會注意到這一點,並通過增加連續獲得 A 或 B 的數量提高進入飛奔模式的門檻。如果飛奔模式合理,Timsort 使它更容易重新進入該模式。

簡而言之,Timsort 做了兩件非常好的事情:

  • 具有預設的內部結構的陣列具有良好的效能
  • 能夠保持穩定的排序

在此之前,為了實現穩定的排序,必須將列表中的項壓縮為整數,並將其排序為元組陣列。

程式碼

下面的原始碼基於我和 Nanda Javarma 的工作。原始碼並不完整,也不是類似於 Python 的官方 sort() 原始碼。這只是我實現的一個簡化的 Timsort,可以對 Timsort 有個整體把握。此外,Python 中的內建 Timsort 演算法是在 C 中正式實現的,因此能獲得更好的效能。

Timsort 的原始原始碼: https://github.com/python/cpython/blob/master/Objects/listobject.c

# based off of this code https://gist.github.com/nandajavarma/a3a6b62f34e74ec4c31674934327bbd3
# Brandon Skerritt
# https://skerritt.tech
def binary_search(the_array, item, start, end):
 if start == end:
 if the_array[start] > item:
 return start
 else:
 return start + 1
 if start > end:
 return start
 mid = round((start + end)/ 2)
 if the_array[mid] < item:
 return binary_search(the_array, item, mid + 1, end)
 elif the_array[mid] > item:
 return binary_search(the_array, item, start, mid - 1)
 else:
 return mid
"""
Insertion sort that timsort uses if the array size is small or if
the size of the "run" is small
"""
def insertion_sort(the_array):
 l = len(the_array)
 for index in range(1, l):
 value = the_array[index]
 pos = binary_search(the_array, value, 0, index - 1)
 the_array = the_array[:pos] + [value] + the_array[pos:index] + the_array[index+1:]
 return the_array
def merge(left, right):
 """Takes two sorted lists and returns a single sorted list by comparing the
 elements one at a time.
 [1, 2, 3, 4, 5, 6]
 """
 if not left:
 return right
 if not right:
 return left
 if left[0] < right[0]:
 return [left[0]] + merge(left[1:], right)
 return [right[0]] + merge(left, right[1:])
def timsort(the_array):
 runs, sorted_runs = [], []
 length = len(the_array)
 new_run = [the_array[0]]
 # for every i in the range of 1 to length of array
 for i in range(1, length):
 # if i is at the end of the list
 if i == length - 1:
 new_run.append(the_array[i])
 runs.append(new_run)
 break
 # if the i'th element of the array is less than the one before it
 if the_array[i] < the_array[i-1]:
 # if new_run is set to None (NULL)
 if not new_run:
 runs.append([the_array[i]])
 new_run.append(the_array[i])
 else:
 runs.append(new_run)
 new_run = []
 # else if its equal to or more than
 else:
 new_run.append(the_array[i])
 # for every item in runs, append it using insertion sort
 for item in runs:
 sorted_runs.append(insertion_sort(item))
 # for every run in sorted_runs, merge them
 sorted_array = []
 for run in sorted_runs:
 sorted_array = merge(sorted_array, run)
 print(sorted_array)
timsort([2, 3, 1, 5, 6, 7])

Timsort 實際上在 Python 中已經內建了,所以這段程式碼只充當概念解釋。要使用 Timsort,只需在 Python 中寫:

list.sort()

或者:

sorted(list)

如果你想掌握 Timsort 的工作方式並對其有所瞭解,我強烈建議你嘗試自己實現它!