前言
經過60多年的發展,科學家和工程師們發明了很多排序算法,有基本的插入算法,也有相對高效的歸並排序算法等,他們各有各的特點,比如歸並排序性能穩定、堆排序空間消耗小等等。但是這些算法也有自己的局限性比如快速排序最壞情況和冒泡算法一樣,歸並排序需要消耗的空間最多,插入排序平均情況的時間復雜度太高。在實際工程應用中,我們希望得到一款綜合性能最好的排序算法,能夠兼具最壞和最好時間復雜度(空間復雜度的優化可以靠后畢竟內存的價格是越來越便宜),於是基於歸並和插入排序的TimSort就誕生了,並且被用作Java和Python的內置排序算法。
簡介
Timsort是一個自適應的、混合的、穩定的排序算法,融合了歸並算法和二分插入排序算法的精髓,在現實世界的數據中有着特別優秀的表現。它是由Tim Peter於2002年發明的,用在Python這個編程語言里面。這個算法之所以快,是因為它充分利用了現實世界的待排序數據里面,有很多子串是已經排好序的不需要再重新排序,利用這個特性並且加上合適的合並規則可以更加高效的排序剩下的待排序序列。
當Timsort運行在部分排序好的數組里面的時候,需要的比較次數要遠小於\(nlogn\),也是遠小於相同情況下的歸並排序算法需要的比較次數。但是和其他的歸並排序算法一樣,最壞情況下的時間復雜度是\(O(nlogn)\)的水平。但是在最壞的情況下,Timsort需要的臨時存儲空間只有\(n/2\),在最好的情況下,需要的額外空間是常數級別的。從各個方面都能夠擊敗需要\(O(n)\)空間和穩定\(O(nlogn)\)時間的歸並算法。
OK!結合精心制作的動圖,讓我們來看看這個牛皮的Timsort到底是怎么回事。
算法
限制
在最初的Tim實現的版本中,對於長度小於64
數組直接進行二分插入排序,不會進行復雜的歸並排序,因為在小數組中插入排序的性能已經足夠好。在Java中有略微的改變,這個閾值被修改成了32
,據說在實際中32
這個閾值能夠得到更好的性能。
二分插入排序
插入排序的邏輯是將排好序的數組之后的一個元素不停的向前移動交換元素直到找到合適的位置,如果這個新元素比前面的序列的最小的元素還要小,就要和前面的每個元素進行比較,浪費大量的時間在比較上面。采用二分搜索的方法直接找到這個元素應該插入的位置,就可以減少很多次的比較。雖然仍然是需要移動相同數量的元素,但是復制數組的時間消耗要小於元素間的一一互換。
比如對於[2,3,4,5,6,1]
,想把1
插入到前面,如果使用直接的插入排序,需要5次比較,但是使用二分插入排序,只需要2次比較就直到插入的位置,然后直接把2,3,4,5,6
全部向后移動一位,把1
放入第一位就完成了插入操作。
Run
首先介紹其中最重要的一個概念,英文叫做run
,翻譯能力宕機的我就在這篇文章中用英文名字吧( ╯□╰ )。所謂的run
就是一個連續上升(此處的上升包括兩個元素相等的情況)或者下降(嚴格遞減)的子串。
比如對於序列[1,2,3,4,3,2,4,7,8]
,其中有三個run
,第一個是[1,2,3,4]
,第二個是[3,2]
,第三個是[4,7,8]
,這三個run
都是單調的,在實際程序中對於單調遞減的run
會被反轉成遞增的序列。
在合並序列的時候,如果run
的數量等於或者略小於2
的冪次方的時候,效率是最高的;如果略大於2
的冪次方,效率就會特別低。所以為了提高合並時候的效率,需要盡量控制每個run
的長度,定義一個minrun
表示每個run
的最小長度,如果長度太短,就用二分插入排序把run
后面的元素插入到前面的run
里面。對於上面的例子,如果minrun=5
,那么第一個run
是不符合要求的,就會把后面的3
插入到第一個run
里面,變成[1,2,3,3,4]
。
在執行排序算法之前,會計算出這個minrun
的值(所以說這個算法是自適應的,會根據數據的特點來進行自我調整),minrun
會從32到64(包括)選擇一個數字,使得數組的長度除以minrun
等於或者略小於2
的冪次方。比如長度是65
,那么minrun
的值就是33
;如果長度是165
,minrun
就是42
(注意這里的Java的minrun
的取值會在16到32之間)。
這里用Java源碼做示范:
private static int minRunLength(int n) {
assert n >= 0;
int r = 0; // 如果低位任何一位是1,就會變成1
while (n >= 64) { // 改成了64
r |= (n & 1);
n >>= 1;
}
return n + r;
}
合並
在歸並算法中合並是兩兩分別合並,第一個和第二個合並,第三個和第四個合並,然后再合並這兩個已經合並的序列。但是在Timsort中,合並是連續的,每次計算出了一個run
之后都有可能導致一次合並,這樣的合並順序能夠在合並的同時保證算法的穩定性。
在Timsort中用一個棧來保存每個run
,比如對於上面的[1,2,3,4,3,2,4,7,8]
這個例子,棧底是[1,2,3,4]
,中間是[3,2]
,棧頂是[4,7,8]
,每次合並僅限於棧里面相鄰的兩個run
。
合並條件
為了保證Timsort的合並平衡性,Tim制定一個合並規則,對於在棧頂的三個run
,用X
、Y
和Z
分別表示他們的長度,其中X
在棧頂,必須始終維持一下的兩個規則:
一旦有其中的一個條件不被滿足,Y
這個子序列就會和X
於Z
中較小的元素合並形成一個新run
,然后會再次檢查棧頂的三個run
看看是否仍然滿足條件。如果不滿足則會繼續進行合並,直至棧頂的三個元素(如果只有兩個run
就只需要滿足第二個條件)滿足這兩個條件。
圖片來自這里
所謂的合並的平衡性就是為了讓合並的兩個數組的大小盡量接近,提高合並的效率。所以在合並的過程中需要盡量保留這些run
用於發現后來的模式,但是我們又想盡量快的合並內存層級比較高的run
,並且棧的空間是有限的,不能浪費太多的棧空間。通過以上的兩個限制,可以將整個棧從底部到頂部的run
的大小變成嚴格遞減的,並且收斂速度和斐波那契數列一樣,這樣就可以應用斐波那契數列和的公式根據數組的長度計算出需要的棧的大小,一定是比\(log_{1.618}N\)要小的,其中N
是數組的長度。
在最理想的情況下,這個棧從底部到頂部的數字應該是128
、64
、32
、16
、8
、4
、2
、2
,這樣從棧頂合並到棧底,每次合並的兩個run
的長度都是相等的,都是完美的合並。
如果遇到不完美的情況比如500
、400
、1000
,那么根據規則就會合並變成900
、1000
,再次檢查規則之后發現還是不滿足,於是合並變成了1900
。
合並內存消耗
不使用額外的內存合並兩個run
是很困難的,有這種原地合並算法,但是效率太低,作為trade-off,可以使用少量的內存空間來達到合並的目的。
比如有兩個相鄰的run
一前一后分別是A
和B
,如果A
的長度比較小,那么就把A
復制到臨時內存里面,然后從小到大開始合並排序放入A
和B
原來的空間里面不影響原來的數據的使用。如果B
的長度比較小,B
就會被放到臨時內存里面,然后從大到小開始合並。
另外還有一個優化的點在於可以用二分法找到B[0]
在A
中應該插入的位置i
以及A[A.length-1]
在B
中應該插入的位置j
,這樣在i
之前和j
之后的數據都可以放在原地不需要變化,進一步減小了A
和B
的大小,同時也是縮減了臨時空間的大小。
加速合並
在歸並排序算法中合並兩個數組就是一一比較每個元素,把較小的放到相應的位置,然后比較下一個,這樣有一個缺點就是如果A
中如果有大量的元素A[i...j]
是小於B
中某一個元素B[k]
的,程序仍然會持續的比較A[i...j]
中的每一個元素和B[k]
,增加合並過程中的時間消耗。
為了優化合並的過程,Tim設定了一個閾值MIN_GALLOP
,如果A
中連續MIN_GALLOP
個元素比B
中某一個元素要小,那么就進入GALLOP
模式,反之亦然。默認的MIN_GALLOP
值是7。
在GALLOP
模式中,首先通過二分搜索找到A[0]
在B
中的位置i0
,把B
中i0
之前的元素直接放入合並的空間中,然后再在A
中找到B[i0]
所在的位置j0
,把A
中j0
之前的元素直接放入合並空間中,如此循環直至在A
和B
中每次找到的新的位置和原位置的差值是小於MIN_GALLOP
的,這才停止然后繼續進行一對一的比較。
GALLOP模式
GALLOP搜索元素分為兩個步驟,比如我們想找到A
中的元素x
在B
中的位置
第一步是在B
中找到合適的索引區間\((2^k-1,2^{k+1}-1)\)使得x
在這個元素的范圍內
第二步是在第一步找到的范圍內通過二分搜索來找到對應的位置。
通過這種搜索方式搜索序列B
最多需要\(2lgB\)次的比較,相比於直接進行二分搜索的\(lg(B+1)\)次比較,在數組長度比較短或者重復元素比較多的時候,這種搜索方式更加有優勢。
這個搜索算法又叫做指數搜索(exponential search),在Peter McIlroy於1993年發明的一種樂觀排序算法中首次提出的。
總結
總結一下上面的排序的過程:
- 如果長度小於
64
直接進行插入排序 - 首先遍歷數組收集每個元素根據特定的條件組成一個
run
- 得到一個
run
之后會把他放入棧中 - 如果棧頂部幾個的
run
符合合並條件,就會觸發合並操作合並相鄰的兩個run
留下一個run
- 合並操作會使用盡量小的內存空間和GALLOP模式來加速合並
參考
Comparison between timsort and quicksort
This is the fastest sorting algorithm ever
TimSort
Timsort: The Fastest sorting algorithm for real-world problems
[Python-Dev] Sorting
Intro
TimSort
更多精彩內容請看我的個人博客