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


前言

經過60多年的發展,科學家和工程師們發明了很多排序算法,有基本的插入算法,也有相對高效的歸並排序算法等,他們各有各的特點,比如歸並排序性能穩定、堆排序空間消耗小等等。但是這些算法也有自己的局限性比如快速排序最壞情況和冒泡算法一樣,歸並排序需要消耗的空間最多,插入排序平均情況的時間復雜度太高。在實際工程應用中,我們希望得到一款綜合性能最好的排序算法,能夠兼具最壞和最好時間復雜度(空間復雜度的優化可以靠后畢竟內存的價格是越來越便宜),於是基於歸並和插入排序的TimSort就誕生了,並且被用作Java和Python的內置排序算法。

簡介

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

當Timsort運行在部分排序好的數組里面的時候,需要的比較次數要遠小於\(nlogn\),也是遠小於相同情況下的歸並排序算法需要的比較次數。但是和其他的歸並排序算法一樣,最壞情況下的時間復雜度是\(O(nlogn)\)的水平。但是在最壞的情況下,Timsort需要的臨時存儲空間只有\(n/2\),在最好的情況下,需要的額外空間是常數級別的。從各個方面都能夠擊敗需要\(O(n)\)空間和穩定\(O(nlogn)\)時間的歸並算法。

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

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

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

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM