世界上最快的排序算法——Timsort

前言

通過60多年的發展,科學家和工程師們發明了不少排序算法,有基本的插入算法,也有相對高效的歸併排序算法等,他們各有各的特色,好比歸併排序性能穩定、堆排序空間消耗小等等。可是這些算法也有本身的侷限性好比快速排序最壞狀況和冒泡算法同樣,歸併排序須要消耗的空間最多,插入排序平均狀況的時間複雜度過高。在實際工程應用中,咱們但願獲得一款綜合性能最好的排序算法,可以兼具最壞和最好時間複雜度(空間複雜度的優化能夠靠後畢竟內存的價格是愈來愈便宜),因而基於歸併和插入排序的TimSort就誕生了,而且被用做Java和Python的內置排序算法。html

簡介

Timsort是一個自適應的、混合的、穩定的排序算法,融合了歸併算法和二分插入排序算法的精髓,在現實世界的數據中有着特別優秀的表現。它是由Tim Peter於2002年發明的,用在Python這個編程語言裏面。這個算法之因此快,是由於它充分利用了現實世界的待排序數據裏面,有不少子串是已經排好序的不須要再從新排序,利用這個特性而且加上合適的合併規則能夠更加高效的排序剩下的待排序序列。java

當Timsort運行在部分排序好的數組裏面的時候,須要的比較次數要遠小於\(nlogn\),也是遠小於相同狀況下的歸併排序算法須要的比較次數。可是和其餘的歸併排序算法同樣,最壞狀況下的時間複雜度是\(O(nlogn)\)的水平。可是在最壞的狀況下,Timsort須要的臨時存儲空間只有\(n/2\),在最好的狀況下,須要的額外空間是常數級別的。從各個方面都可以擊敗須要\(O(n)\)空間和穩定\(O(nlogn)\)時間的歸併算法。python

amazing

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就是一個連續上升(此處的上升包括兩個元素相等的狀況)或者降低(嚴格遞減)的子串。svg

好比對於序列[1,2,3,4,3,2,4,7,8],其中有三個run,第一個是[1,2,3,4],第二個是[3,2],第三個是[4,7,8],這三個run都是單調的,在實際程序中對於單調遞減的run會被反轉成遞增的序列svn

create-run

在合併序列的時候,若是run的數量等於或者略小於2的冪次方的時候,效率是最高的;若是略大於2的冪次方,效率就會特別低。因此爲了提升合併時候的效率,須要儘可能控制每一個run的長度,定義一個minrun表示每一個run的最小長度,若是長度過短,就用二分插入排序把run後面的元素插入到前面的run裏面。對於上面的例子,若是minrun=5,那麼第一個run是不符合要求的,就會把後面的3插入到第一個run裏面,變成[1,2,3,3,4]性能

insert-run

在執行排序算法以前,會計算出這個minrun的值(因此說這個算法是自適應的,會根據數據的特色來進行自我調整),minrun會從32到64(包括)選擇一個數字,使得數組的長度除以minrun等於或者略小於2的冪次方。好比長度是65,那麼minrun的值就是33;若是長度是165minrun就是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

run-in-stack

合併條件

爲了保證Timsort的合併平衡性,Tim制定一個合併規則,對於在棧頂的三個run,用XYZ分別表示他們的長度,其中X在棧頂,必須始終維持一下的兩個規則:

\[Z > Y + X \]

\[Y > X \]

一旦有其中的一個條件不被知足,Y這個子序列就會和XZ中較小的元素合併造成一個新run,而後會再次檢查棧頂的三個run看看是否仍然知足條件。若是不知足則會繼續進行合併,直至棧頂的三個元素(若是隻有兩個run就只須要知足第二個條件)知足這兩個條件。

stack

圖片來自這裏

所謂的合併的平衡性就是爲了讓合併的兩個數組的大小盡可能接近,提升合併的效率。因此在合併的過程當中須要儘可能保留這些run用於發現後來的模式,可是咱們又想盡可能快的合併內存層級比較高的run,而且棧的空間是有限的,不能浪費太多的棧空間。經過以上的兩個限制,能夠將整個棧從底部到頂部的run的大小變成嚴格遞減的,而且收斂速度和斐波那契數列同樣,這樣就能夠應用斐波那契數列和的公式根據數組的長度計算出須要的棧的大小,必定是比\(log_{1.618}N\)要小的,其中N是數組的長度。

在最理想的狀況下,這個棧從底部到頂部的數字應該是1286432168422,這樣從棧頂合併到棧底,每次合併的兩個run的長度都是相等的,都是完美的合併。

若是遇到不完美的狀況好比5004001000,那麼根據規則就會合並變成9001000,再次檢查規則以後發現仍是不知足,因而合併變成了1900

合併內存消耗

不使用額外的內存合併兩個run是很困難的,有這種原地合併算法,可是效率過低,做爲trade-off,可使用少許的內存空間來達到合併的目的。

好比有兩個相鄰的run一前一後分別是AB,若是A的長度比較小,那麼就把A複製到臨時內存裏面,而後從小到大開始合併排序放入AB原來的空間裏面不影響原來的數據的使用。若是B的長度比較小,B就會被放到臨時內存裏面,而後從大到小開始合併。

另外還有一個優化的點在於能夠用二分法找到B[0]A中應該插入的位置i以及A[A.length-1]B中應該插入的位置j,這樣在i以前j以後的數據均可以放在原地不須要變化,進一步減少了AB的大小,同時也是縮減了臨時空間的大小。

merge-memory

加速合併

在歸併排序算法中合併兩個數組就是一一比較每一個元素,把較小的放到相應的位置,而後比較下一個,這樣有一個缺點就是若是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,把Bi0以前的元素直接放入合併的空間中,而後再在A中找到B[i0]所在的位置j0,把Aj0以前的元素直接放入合併空間中,如此循環直至在AB中每次找到的新的位置和原位置的差值是小於MIN_GALLOP的,這才中止而後繼續進行一對一的比較。

gallop-mod

GALLOP模式

GALLOP搜索元素分爲兩個步驟,好比咱們想找到A中的元素xB中的位置

第一步是在B中找到合適的索引區間\((2^k-1,2^{k+1}-1)\)使得x在這個元素的範圍內

第二步是在第一步找到的範圍內經過二分搜索來找到對應的位置。

經過這種搜索方式搜索序列B最多須要\(2lgB\)次的比較,相比於直接進行二分搜索的\(lg(B+1)\)次比較,在數組長度比較短或者重複元素比較多的時候,這種搜索方式更加有優點。

這個搜索算法又叫作指數搜索(exponential search),在Peter McIlroy於1993年發明的一種樂觀排序算法中首次提出的。

總結

總結一下上面的排序的過程:

  1. 若是長度小於64直接進行插入排序
  2. 首先遍歷數組收集每一個元素根據特定的條件組成一個run
  3. 獲得一個run以後會把他放入棧中
  4. 若是棧頂部幾個的run符合合併條件,就會觸發合併操做合併相鄰的兩個run留下一個run
  5. 合併操做會使用盡可能小的內存空間和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

更多精彩內容請看個人我的博客

相關文章
相關標籤/搜索