Python 的 timsort 演算法出了名的晦澀難懂。這是情理之中的事,它非常複雜。不過,當你真正對它進行深入研究後,你會發現它“只不過”是歸併排序的一個變種。這些變異,有的非常巧妙,有的則非常直接明瞭,總之令人印象頗為深刻。
我會通過一些例子,讓你從基本的歸併演算法(mergesort)開始,逐步過渡到 timsort。在本文中,我將探討如何實現基本的自適應歸併排序演算法(adaptive mergesort),該演算法是 timsort 的核心。以後的文章會在此基礎上,討論timsort中更多具體的優化。
為了簡單起見,我不打算考慮普遍情況,只考慮整型陣列(很容易類推到其他型別, 也使得程式碼更容易理解)。需要說明的是,我將隱藏一些細節(可能會有一些不太嚴謹的地方)。所以,如果你希望瞭解演算法的具體細節,還是應該參考Tim Peters的說明。
對了,例子是 C 寫的,Sorry。
那麼,我們從最簡單的歸併演算法開始吧。
假定你已經瞭解歸併演算法的原理(如果不知道的話,你就得問下度娘)。一起復習下:長度為1的陣列是有序的;當陣列長度n > 1,則將陣列拆分為2部分(通常從陣列中間拆分);分別對兩部分進行歸併排序;然後執行一次合併操作,遍歷兩個有序陣列,依次將較小的元素插入結果陣列,最終得到一個較大的有序陣列。
上程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
#include "timsort.h" #include <stdlib.h> #include <string.h> //p1、p2分別為長l1, l2的有序陣列,將它們合併到 //一個target陣列,target可能與p1、p2有重疊, //但target必須有足夠的空間儲存整個合併後的陣列。 void merge(int target[], int p1[], int l1, int p2[], int l2); void integer_timsort(int array[], int size) { if (size <= 1) return; int partition = size / 2; integer_timsort(array, partition); integer_timsort(array + partition, size - partition); merge(array, array, partition, array + partition, size - partition); } void merge(int target[], int p1[], int l1, int p2[], int l2) { int * merge_to = malloc(sizeof(int) * (l1 + l2)); //我們從這兩個陣列中讀資料時的當前索引 int i1, i2; i1 = i2 = 0; //歸併時寫入下一個元素的地址。 int * next_merge_element = merge_to; //遍歷這兩個陣列,將當前位置較小的元素寫入merge_to陣列,如果 //有兩個相等元素,為了保持穩定性,我們寫入靠前的元素。 //這對int資料當然沒所謂,領會精神就行 :)。 while (i1 < l1 && i2 < l2) { if (p1[i1] <= p2[i2]) { * next_merge_element = p1[i1]; i1++; } else { * next_merge_element = p2[i2]; i2++; } next_merge_element++; } //如果任意一個陣列排序完成,我們將另一個陣列剩餘的 //資料全部直接copy到結果陣列。 memcpy(next_merge_element, p1 + i1, sizeof(int) * (l1 - i1)); memcpy(next_merge_element, p2 + i2, sizeof(int) * (l2 - i2)); //我們已經將資料合併的新申請的記憶體中。現在我們 //要將這些資料複製到target陣列。 memcpy(target, merge_to, sizeof(int) * (l1 + l2)); free(merge_to); } |
我沒有貼出完整的程式碼,你可以在github中檢視詳情。
到了這一步,如果你是C程式設計師,可能有件讓你非常心驚肉跳的事擺在你眼前:每次呼叫合併函式的時候,都頻繁的申請、釋放用於合併操作的記憶體(我們沒檢查null返回值,這可能也讓你很糾結。除非在demo版中,我會在生產程式碼中檢查返回值的,但願這能讓你寬心)。
我們只要稍稍修改一下原型,再封裝一下就可以解決這個問題。
1 2 3 4 5 6 7 8 |
void merge(int target[], int p1[], int l1, int p2[], int l2, int storage[]); void integer_timsort_with_storage(int array[], int size, int storage[]); void integer_timsort(int array[], int size){ int *storage = malloc(sizeof(int) * size); integer_timsort_with_storage(array, size, storage); free(storage); } |
現在我們得到一個封裝的排序函式,在函式內完成初始化,然後進行遞迴呼叫。在timsort中,我們會經常用到這種模式。但是傳入工作函式的記憶體,最終會比直接使用一整塊記憶體塊的情況複雜的多。
我們終於得到了一個基本的歸併排序。我們得想想:如何優化?
一般來說,我們不可能找到一種萬能的優化方案。歸併排序的效能非常接近最優化的比較排序(comparison sort)。timsort最關鍵特性是,它能充分利用資料中的某些共同規律。如果存這種規律,我們就要充分利用。否則,我們只要達到和普通歸併排序基本一樣的效率就可以了。
如果你看一下歸併排序的實現,會發現實際上最主要的工作是合併操作。那優化地方就基本定位合併操作上。這樣可以總結出3種優化途徑:
能不能提高合併效率?
能不能減少合併次數?
是不是有些情況下,使用其它辦法更好,而非歸併排序。
毫無疑問,這3個問題的答案都是“Yes”,這些都是歸併排序非常基本的優化方法。比如,遞迴呼叫中,可以非常方便地根據陣列大小,切換合適的排序演算法。歸併排序是種很好的通用排序演算法,但是在陣列較小時,常量因素就占主導地位了。通常情況下,陣列小於某個大小的時候(通常是七八個左右),歸併排序和插入排序效能就差不多了。
這並不是準確意義上timsort的工作方式,但是我們很快會用到插入排序,因此,下面我得插一小段題外話。
簡而言之:假定我們有一個長度為n的有序(sorted)陣列,在陣列尾部還有第n+1個元素的空間。我們將向陣列增加一個元素,並且保證最終陣列依然是有序的。我們需要為這個元素找到合適的位置,並且將比該元素大的所有元素後移。最顯而易見的辦法,將新增元素插入到第n+1位置,然後從後向前交換元素,直至新增元素到達正確的位置(當陣列規模較大時,這就不一定是最佳方案了。你應該使用二分查詢,然後將餘下的部分整體後移,從而避免大量比較操作。對於小型陣列,這麼做應該沒什麼意義)。
插入排序這樣工作:你已將前面k個元素排序。然後用上面提到的方法,向這k個有序元素中插入第k+1個元素,就得到了k+1個有序元素。如此執行,直至陣列結尾。
上程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
void insertion_sort(int xs[], int length){ if(length <= 1) return; int i; for(i = 1; i < length; i++){ // The array before i is sorted. Now insert xs[i] into it int x = xs[i]; int j = i - 1; // Move j down until it's either at the beginning or on // something <= x, and everything to the right of it has // been moved up one. while(j >= 0 && xs[j] > x){ xs[j+1], xs[j]; j--; } xs[j+1] = x; } } |
那麼程式碼就會變成這樣:
1 2 3 4 5 |
void integer_timsort_with_storage(int array[], int size, int storage[]){ if(size <= INSERTION_SORT_SIZE){ insertion_sort(array, size); return; } |
你可以在這裡檢視該版本。
好了,不說題外話,我們繼續演算法優化的議題。
我們可能減少合併操作嗎?
當然,一般情況下是不行的。不過,我們來考慮一些常見情況。
假設我們的陣列已經是有序的。我們需要進行多少次合併呢?
原則上來說,是不需要的:陣列已經是有序的。什麼都不用做。顯然,增加一個初始檢查算一種方案,確定陣列是否已經排序,然後儘快退出。
然而,這會對排序增加一系列額外工作。當情況符合這種情況時,可以獲得很大的效能改善(複雜度從最差的O(nlog(n)),降低到O(n));當情況不符合時,就做了很多無用功。那麼我們嘗試一下,有什麼辦法既可以檢查陣列的有序性,當檢查失敗時,還可以將檢查結果充分利用起來。
假設我們有如下陣列:
{5, 6, 7, 8, 9, 10, 1, 2, 3}
(暫時不考慮對較小規模陣列會採用不同的排序演算法。)
我們從哪裡分割陣列,才能獲得最佳的合併效果呢?
顯然,已經有兩個有序子陣列:5~10,還有1~3。直接採用這兩個分組就相當完美。
請允許我提出一個全新的方案:
找到原始資料中最長的增長序列作為第一分組,其它陣列元素作為第二分組。
當陣列可以被分割為若干個有序陣列時,這種方案非常有效(即使算不上什麼偉大的想法),但這個辦法有時候表現卻非常糟。試想,如果陣列是倒序排列的,會發生什麼呢。每次分割的第一個有序子陣列長度都是1。也就是說,每個階段第一個分組都只有一個元素,然後如此遞迴分割剩下的n-1個元素。顯然,時間複雜度是令人失望的O(n^2)。
我們可以人為地將陣列前一半元素分成更小的陣列來解決這個問題。但是這還是很難令人滿意:我們依舊忽略了許多額外工作,而這些都是徒勞無功。
然而,這裡的基本思想非常明確:將有序的子陣列作為合併時的分組基準。
比較難處理的是我們對第二分組的選擇。我們希望合併時兩組資料基本平衡,以避免命中最差情況。
為了瞭解如何解決這些問題,我們後退一步。思考下面略有點特殊的做法,這是標準歸併排序的逆操作。
陣列被分割為長度為1的段。
如果有超過一個分組,依次將相鄰位置分組兩兩合併,然後覆蓋它們原來的位置。
如果我們有陣列 {1, 2, 3, 4},那麼有:
{{1}, {2}, {3}, {4}}
{{1, 2}, {3, 4}}
{{1, 2, 3, 4}}
可以比較清楚地看到這與標準的歸併排序“一樣”:我們只是將遞迴確定下來,用全域性記憶體替代了棧,相當於歸併排序的逆操作。顯然,這種方式更貼切的說明我們使用有序子串的想法:第一步,我們沒有選擇將陣列分割為長度為1的段,轉而將陣列分割為一些已經排序的段落。然後我們像上面描述的那樣,進行合併。
那麼,這就存在一個問題:我們用到了一些額外的無用記憶體。在原來的歸併排序中,空間複雜度為O(log(n))。這裡需要O(n)的記憶體在儲存資料。
那為什麼我們“仿造”的演算法, 記憶體消耗怎麼比原版演算法差這麼大呢?
好吧,答案是我在他們實際上還是有區別的。最大的區別是原始歸併排序的資料分割列表是延遲生成的。我們只生成下一級操作需要的記憶體,完成操作操作後就立刻釋放了這些記憶體。
換而言之,實際上我們是優先進行合併,而非建立所有的分組。
那麼,我們來看看能不能將這個思想轉換為一種演算法。
第一步: 在每一步中,建立一個新的基礎資料分組(在常規的歸併排序中,分組只有一個資料項。 在我們上面提到的版本中,分組內為一個有序的子陣列)。將它新增到一個資料已經準備好分組的棧中。將棧頂兩個分組進行若干次合併,嘗試減少棧的高度。如此重複,直到再也沒有分組可以合併。通過合併使得整個棧高度下降為1。
這兒有點不太清晰的地方:我們沒有明確判定何時進行合併的邏輯。
要想說明這一點,這裡就需要更多文字,現有程式碼也太少了。所以我就給出一個簡單答案:隨機選取時機。在一般的歸併排序中,基本上選取合併操作進行一半時為宜。比如生成的分組有一半已經和之前的合併,比如指定層次有一半合併後的分組已經和之前的合併,等等。這就好比用拋硬幣的方法,決定要不要進行合併。
現在,寫點程式碼。
首先,我們封裝一些常用的狀態。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
//我們使用一個固定大小的棧。棧的大小遠大於發生溢位的可能。 //當然,我們依然會做溢位檢查。 #define STACK_SIZE 1024 typedef struct { int * index; int length; } run; typedef struct { int * storage; //我們用到的棧 run runs[STACK_SIZE]; //棧中第一個未寫元素的偏移量 int stack_height; // 我們需要記錄我們已經分組了多少資料,以便我們知道下一分組開始的位置。 // 原則是:<partioned_up_to 的所有資料都在棧中,>= partioned_up_to的所有資料都在棧外。 // 當partitioned_up_to == length,我們就已經將所有資料入棧。 int * partitioned_up_to; int * array; int length; } sort_state_struct; typedef sort_state_struct * sort_state; |
我們會將 sort_state 指標傳入所有用到的函式。
排序的基本邏輯是這樣的:
1 2 3 4 5 |
while(next_partition(&state)){ while(should_collapse(&state)) merge_collapse(&state); } while(state.stack_height > 1) merge_collapse(&state); |
next_partition 返回1則將一個分組入棧,返回0則沒有資料可以入棧(換言之,我們已經到達陣列結尾)。 然後我們將棧下降一點。最後,當整個陣列都進入分組,我們就將棧下降到一個元素。
這樣我們就得到第一個自適應版本的歸併排序:如果資料中有很多有序的子串,利用它們可以大幅提升效率。如果沒有的話,時間複雜度依然為O(n log(n)) (顯而易見)
這個“顯而易見”的限制是個小小的瑕疵。資料隨機化顯然可以避免我們明確進行合併的條件。
那麼,我們看看有沒有更合適的合併條件。一種顯而易見的辦法是嘗試在棧中保持一些常量條件,然後一直合併,直至滿足這些常量條件。
更重要的,我們希望這些常量條件,可以保證棧最多不超過log(n)個元素。
那麼,我們來考慮下面的恆定條件:每個棧上的元素長度都至少(大於等於)是其棧頂方向下一元素大小的2倍。也就是說棧頂元素最短,下一最短元素是棧底方向下一元素,且其大小至少是棧頂元素的2倍。
這條恆定條件實現了棧元素空間複雜度log(n)的限定。在棧下降的過程中,確實有爆發生成超長runs的趨勢。考慮棧中元素長度如下所示:
64, 32, 16, 8, 4, 2, 1
假設我們push一個長度為1的run入棧。我們將會進行如下一些列的合併。
64, 32, 16, 8, 4, 2, 1, 1
64, 32, 16, 8, 4, 2, 2
64, 32, 16, 8, 4, 4
64, 32, 16, 8, 8
64, 32, 16, 16
64, 32, 32
64, 64
128
接下來,為保證合併更加有效率,這種做法就很明顯不合適(實際上這是因為這種做法依賴於陣列中可能用到的一種資料結構)。現在來看,我們的合併依然非常簡陋,這倒不必太擔心,我們現在就著手處理這個問題。
有一點十分明確:現在我們棧的最大高度達成共識。假如棧頂元素長度為1,下一個則>=2,再下一個>=4,依次類推。所以棧中所有資料長度為2^n – 1。在64位作業系統上,陣列最長可以有 2^64 個元素(這將是一個大得驚人的陣列)。我們知道,棧最多需要65個元素就可以適配這個條件。只要再多增加一個棧元素,也就是我們為棧申請66個元素的空間,就不必擔心溢位問題。
還有一點很清楚,我們只需要檢查棧頂第2個元素大小>= 2 * 棧頂元素大小即可。因為我們只會不斷地將滿足這個限制條件的資料入棧,而合併操作也僅發生在棧頂兩個元素上。
那麼,為了滿足這個限制條件,我們只要將should_collapse 做如下改寫:
1 2 3 4 5 6 7 8 9 10 |
int should_collapse(sort_state state){ if (state->stack_height <= 2) return 0; int h = state->stack_height - 1; int head_length = state->runs[h].length; int next_length = state->runs[h-1].length; return 2 * head_length > next_length; } |
看,我們的自適應合併終於搞定了,好耶~
現在我們再回到之前的那個例子,看看之前棘手的問題現在怎麼樣了。
考慮下面的倒序陣列:
5, 4, 3, 2, 1
用我們的自適應歸併排序演算法來處理會發生什麼呢?
runs棧會像下面這樣:
{5}
{5}, {4}
{4, 5}
{4, 5}, {3}
{4, 5}, {3}, {2}
{4, 5}, {2, 3}
{2, 3, 4, 5}
{2, 3, 4, 5}, {1}
{1, 2, 3, 4, 5}
這就是一個非常清晰的歸併演算法。
你知道對逆序陣列排序最好的辦法是什麼?直接對其進行原址逆序啊,你做到了!
對演算法進行一個很簡單的改進,我們就可以利用這一點。我們已經在查詢升序的run了,當沒有找到升序run的時候,直接查詢降序run,然後對其進行原址逆序,再將其作為一個正序run入棧即可。
我們將查詢下一個run的程式碼改寫如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
if (next_start_index < state - > array + state - > length) { if ( * next_start_index < * start_index) { // 從這裡開始檢測降序 while (next_start_index < state - > array + state - > length) { if ( * next_start_index < * (next_start_index - 1)) next_start_index++; else break; } //原址逆序 reverse(start_index, next_start_index - start_index); } else { //這裡開始檢測升序 while (next_start_index < state - > array + state - > length) { if ( * next_start_index >= * (next_start_index - 1)) next_start_index++; else break; } } } |
類似逆序陣列這樣的基本情況,排序演算法已經可以更好地處理鋸齒狀波動的資料了。比如,對下面的資料排序:
{1, 2, 3, 4, 5, 4, 3, 2, 1}
我們會有如下的合併:
{1, 2, 3, 4, 5}
{1, 2, 3, 4, 5}, {1, 2, 3, 4}
{1, 1, 2, 2, 3, 3, 4, 4, 5}
這樣就比之前的實現好多了!
對run的生成演算法,還有最後一步優化:
在我們提到的歸併排序中,我們有一個陣列大小界限,對那些較小的陣列切換到插入排序。目前我們的自適應排序中沒有類似的功能,這也意味著當沒有什麼資料結構可利用時,我們的版本與常規的歸併排序而言並沒有什麼優勢。
回想一下我們的“逆歸併排序”,對較小的run切換到插入排序的做法,可以這樣理解:與其從大小為1的run開始,我們從大小為INSERTION_SORT_SIZE的run開始,我們用插入排序保證run是有序的。
這就自然讓我們的自適應排序發生變化:當我們找到的run長度小於某個最小值時,就用插入排序將其擴充套件到這個大小。
這就需要我們對next_partition的結尾部分做如下修改:
1 2 3 4 |
if(run_to_add.length < MIN_RUN_SIZE){ boost_run_length(state, &run_to_add); } state->partitioned_up_to = start_index + run_to_add.length; |
(其實可以將這段程式碼寫得再具體一點,可讀性會更好,我們已經知道得到的資料是有序的,所以我想偷個小懶)
當處理隨機資料是,這樣應當對效能有所改善,最差不會低於常規的歸併排序。
我們現在得到了自適應排序演算法,大體上有點timsort“核心”的意思。 Timsort在此基礎上還做了大量的優化,它的成功歸功於其中很多改進。不過自適應歸併排序是這一切的基礎。我希望,也打算在以後的文章中研究其餘部分。