深入理解 timsort 演算法(1):自適應歸併排序

alvendarthy發表於2016-04-18

Python 的 timsort 演算法出了名的晦澀難懂。這是情理之中的事,它非常複雜。不過,當你真正對它進行深入研究後,你會發現它“只不過”是歸併排序的一個變種。這些變異,有的非常巧妙,有的則非常直接明瞭,總之令人印象頗為深刻。

我會通過一些例子,讓你從基本的歸併演算法(mergesort)開始,逐步過渡到 timsort。在本文中,我將探討如何實現基本的自適應歸併排序演算法(adaptive mergesort),該演算法是 timsort 的核心。以後的文章會在此基礎上,討論timsort中更多具體的優化。

為了簡單起見,我不打算考慮普遍情況,只考慮整型陣列(很容易類推到其他型別, 也使得程式碼更容易理解)。需要說明的是,我將隱藏一些細節(可能會有一些不太嚴謹的地方)。所以,如果你希望瞭解演算法的具體細節,還是應該參考Tim Peters的說明

對了,例子是 C 寫的,Sorry。

那麼,我們從最簡單的歸併演算法開始吧。

假定你已經瞭解歸併演算法的原理(如果不知道的話,你就得問下度娘)。一起復習下:長度為1的陣列是有序的;當陣列長度n > 1,則將陣列拆分為2部分(通常從陣列中間拆分);分別對兩部分進行歸併排序;然後執行一次合併操作,遍歷兩個有序陣列,依次將較小的元素插入結果陣列,最終得到一個較大的有序陣列。

上程式碼:

我沒有貼出完整的程式碼,你可以在github中檢視詳情。

到了這一步,如果你是C程式設計師,可能有件讓你非常心驚肉跳的事擺在你眼前:每次呼叫合併函式的時候,都頻繁的申請、釋放用於合併操作的記憶體(我們沒檢查null返回值,這可能也讓你很糾結。除非在demo版中,我會在生產程式碼中檢查返回值的,但願這能讓你寬心)。

我們只要稍稍修改一下原型,再封裝一下就可以解決這個問題。

現在我們得到一個封裝的排序函式,在函式內完成初始化,然後進行遞迴呼叫。在timsort中,我們會經常用到這種模式。但是傳入工作函式的記憶體,最終會比直接使用一整塊記憶體塊的情況複雜的多。

我們終於得到了一個基本的歸併排序。我們得想想:如何優化?

一般來說,我們不可能找到一種萬能的優化方案。歸併排序的效能非常接近最優化的比較排序(comparison sort)。timsort最關鍵特性是,它能充分利用資料中的某些共同規律。如果存這種規律,我們就要充分利用。否則,我們只要達到和普通歸併排序基本一樣的效率就可以了。

如果你看一下歸併排序的實現,會發現實際上最主要的工作是合併操作。那優化地方就基本定位合併操作上。這樣可以總結出3種優化途徑:

能不能提高合併效率?
能不能減少合併次數?
是不是有些情況下,使用其它辦法更好,而非歸併排序。

毫無疑問,這3個問題的答案都是“Yes”,這些都是歸併排序非常基本的優化方法。比如,遞迴呼叫中,可以非常方便地根據陣列大小,切換合適的排序演算法。歸併排序是種很好的通用排序演算法,但是在陣列較小時,常量因素就占主導地位了。通常情況下,陣列小於某個大小的時候(通常是七八個左右),歸併排序和插入排序效能就差不多了。

這並不是準確意義上timsort的工作方式,但是我們很快會用到插入排序,因此,下面我得插一小段題外話。

簡而言之:假定我們有一個長度為n的有序(sorted)陣列,在陣列尾部還有第n+1個元素的空間。我們將向陣列增加一個元素,並且保證最終陣列依然是有序的。我們需要為這個元素找到合適的位置,並且將比該元素大的所有元素後移。最顯而易見的辦法,將新增元素插入到第n+1位置,然後從後向前交換元素,直至新增元素到達正確的位置(當陣列規模較大時,這就不一定是最佳方案了。你應該使用二分查詢,然後將餘下的部分整體後移,從而避免大量比較操作。對於小型陣列,這麼做應該沒什麼意義)。

插入排序這樣工作:你已將前面k個元素排序。然後用上面提到的方法,向這k個有序元素中插入第k+1個元素,就得到了k+1個有序元素。如此執行,直至陣列結尾。
上程式碼:

那麼程式碼就會變成這樣:

你可以在這裡檢視該版本。

好了,不說題外話,我們繼續演算法優化的議題。

我們可能減少合併操作嗎?

當然,一般情況下是不行的。不過,我們來考慮一些常見情況。

假設我們的陣列已經是有序的。我們需要進行多少次合併呢?

原則上來說,是不需要的:陣列已經是有序的。什麼都不用做。顯然,增加一個初始檢查算一種方案,確定陣列是否已經排序,然後儘快退出。

然而,這會對排序增加一系列額外工作。當情況符合這種情況時,可以獲得很大的效能改善(複雜度從最差的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。

這兒有點不太清晰的地方:我們沒有明確判定何時進行合併的邏輯。

要想說明這一點,這裡就需要更多文字,現有程式碼也太少了。所以我就給出一個簡單答案:隨機選取時機。在一般的歸併排序中,基本上選取合併操作進行一半時為宜。比如生成的分組有一半已經和之前的合併,比如指定層次有一半合併後的分組已經和之前的合併,等等。這就好比用拋硬幣的方法,決定要不要進行合併。

現在,寫點程式碼。

首先,我們封裝一些常用的狀態。

我們會將 sort_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 做如下改寫:

看,我們的自適應合併終於搞定了,好耶~

現在我們再回到之前的那個例子,看看之前棘手的問題現在怎麼樣了。

考慮下面的倒序陣列:

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, 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的結尾部分做如下修改:

(其實可以將這段程式碼寫得再具體一點,可讀性會更好,我們已經知道得到的資料是有序的,所以我想偷個小懶)

當處理隨機資料是,這樣應當對效能有所改善,最差不會低於常規的歸併排序。

我們現在得到了自適應排序演算法,大體上有點timsort“核心”的意思。 Timsort在此基礎上還做了大量的優化,它的成功歸功於其中很多改進。不過自適應歸併排序是這一切的基礎。我希望,也打算在以後的文章中研究其餘部分。

相關文章