Knowledge Point(KP): Timsort && Introsort

排序算法,在嚴蔚敏著作《數據結構(C語言版)》單獨爲一章,在高德納著作《計算機程序設計藝術》,這套聖經裏,單獨爲一卷(PS,真的厚)。

本文並不準備深入每一個角落,而是先簡要的總結,之後分別對Timesort和Introsort做深入的分析。

本文僅作爲個人學習總結,受個人知識上界限制,如有疏漏,恭請指正。


第一部分、各種基本排序策略的簡要總結
(1)插入排序
基本思想:講一個記錄插入到已經排好序的有序表中,進而得到一個新的、記錄數增1的有序表。
方法包括:直接插入排序,折半插入排序,2-路插入排序,希爾排序
(2)交換排序:
基本思想:比較兩個記錄的關鍵字,如果有序則不交換,如果反序則交換
方法包括:冒泡排序,快速排序
(3)選擇排序
基本思想:每一趟在記錄ni+1n-i+1中選取關鍵字最小的記錄,作爲有序序列中第ii個記錄。
方法包括:簡單選擇排序,樹形選擇排序,堆排序
(4)歸併排序
基本思想:將兩個或兩個以上的有序表組合成一個新的有序表
方法包括:歸併排序
(5)基數排序
基本思想:藉助多關鍵字排序的思想對單邏輯關鍵字進行排序
方法包括:多關鍵字的排序,鏈式基數排序

基本性能對比:

排序方法 平均時間 最壞情況 輔助存儲
簡單排序 O(n2)O(n^2) O(n2)O(n^2) O(1)O(1)
快速排序 O(nlogn)O(n\log n) O(n2)O(n^2) O(logn)O(\log n)
堆排序 O(nlogn)O(n \log n) O(nlogn)O(n \log n) O(1)O(1)
歸併排序 O(nlogn)O(n \log n) O(nlogn)O(n \log n) O(n)O(n)
基數排序 O(d(n+rd))O(d(n+rd)) O(d(n+rd))O(d(n+rd)) O(rd)O(rd)

總結概要:
(1)從平均時間性能而言,快速排序最佳,其所需時間最少,但快速排序在最壞情況下的時間性能比如堆排序和歸併排序。而後者相比較的結果是,nn較大時,歸併排序所需時間較堆排序少,但它所需的輔助存儲量最多。
(2)上表中“簡單排序”包括除希爾排序之外的所有插入排序,冒泡排序和簡單選擇排序,其中以直接插入排序爲最簡單,當序列中的記錄“基本有序”或nn值較小時,它是最佳的排序方法,因此常見它和其他的排序方法,如快速排序、歸併排序等結合在一起使用。
(3)基數排序的時間複雜度也可以寫成O(dn˙)O(d \dot n)。因此,它最適合用於nn值很大而關鍵字較小的序列。若關鍵字也很大,而序列中大多數記錄的“最高位關鍵字”均不同,則也可以先按“最高位關鍵字”不同將序列分成若干“小”的子序列,而後進行直接插入排序。
(4)簡單排序和基數排序是穩定的,而快速排序、堆排序和希爾排序等時間性能較好的排序方法都是不穩定的(穩定性由方法本身決定)。


第二部分、Timsort
在第一部分的總結概要的第二條中提到:插入排序常結合快速排序、歸併排序來結合使用,那麼對於Timsort,就是插入排序與歸併排序結合使用。So,它們是怎麼結合的呢?

這裏是Timsort的維基百科。Timsort是一種混合穩定排序算法,來源於歸併排序和插入排序;該算法在很多真實世界中的數據上性能顯著。該方法來自Peter Mcllroy’s “Optimistic Sorting and Information Theoretic Complexity”, in Proceeding of the Fourth Annual ACM-SIAM Symposium on Discrete Algorithms, pp.467-474, January 1993. 在2002年把該算法引入python。

該算法找出數據中存在的有序子序列,再用排序的知識對剩下的數據進行高效排序,即用歸併排序對有序子序列進行合併。

從python 2.3版開始,timsort就作爲python的標準排序算法;也用在Java SE 7, 安卓平臺,GNU Octave和谷歌瀏覽器中。

1、timsort概述

首先來看看性能對比

在這裏插入圖片描述
圖片來源

從上圖來看,Timsort的運行時間與歸併排序相似,由於Timsort是一種經過優化的歸併排序算法,而歸併排序自身已經達到了比較排序算法時間複雜度的下限,因此優化之後的Timsort是目前最快的比較排序算法之一。

基本思想:
現實世界中,大多數數據都是部分有序的,Timsort利用了這一個事實。在Timsort中,稱這些有序的數據塊爲“natural runs”, 除了“natural runs”, 還有“run(s)”,後者是經過一定處理的。在排序時,Timsort迭代數據元素,將其放入不同的run中,同時針對這些run,按規則進行合併只剩下一個,也就是排好序的結果。

在這裏插入圖片描述
大致思想就是:先採用插入排序將非常小的run擴充爲較大的run,然後再採用歸併排序來合併多個run,因此Timsort實際爲歸併排序。那麼具體下來就是,首先定義一個參數minrun,當run長度小於minrun時,就認爲它是非常小的run,否則認爲它是較大的run。
那麼,Timsort的過程爲:

  1. 找到小的run擴充爲較大的run
  2. 按規則合併run

由此,就要對“擴充”和“合併”兩個步驟作深刻理解。

擴充
從左到右處理待排序列,將其劃分爲若干個run。從第一個尚未處理的對象開始,找到一個儘可能長的連續嚴格遞減(嚴格遞減,不取等號)或連續非遞減(升序,可以取等號)序列(連續+序列=子串),如果是連續嚴格遞減序列,則可以通過一個簡單的“翻轉操作”在線性時間內將其變爲嚴格遞增序列。

如果這樣得到的序列長度等於minrun,則視爲一個完整的run,繼續生成下一個run;否則用插入排序將後面的元素添加進來,直至其長度達到minrun爲止。考慮下面的例子:

  • 對於待排序列的前4個數是3,6,7,5,minrun=4,則儘可能長的連續非遞減序列爲3,6,7,長度沒有達到4。於是將後面的5插入進來,得到長度爲4的run:3,5,6,7

合併
在理想的情況下,合併長度儘量相近的runs,這樣可以節約時間。使用霍夫曼樹的歸併策略雖然可行,但不應該花費太多的時間在選擇優先合併的run上。Timsort選擇了一種折中的方法,即要求最右邊的三個run的長度儘量滿足兩個條件。記最右邊的三個run的長度從左到右分別是A,B,C,則滿足:

  1. A>B+CA > B+C
  2. B>CB>C
    這樣,就可以保證合併後的run長度從右往左以指數量級遞增,這樣只需要從右至左依次進行合併就可以使每次合併的兩個run的長度大致相同,實現了平衡。如果AB+CA \le B+C,則合併A,BA, B或者B,CB, C,這取決於哪一種合併方式生成的新run更短,如果A>B+CA>B+C或者$B\leC ,則合併B, C$。

每生成一個新的run都試圖進行合併。在算法結束之後,有可能會出現有剩餘run沒有合併的情況。這是採用強制合併,直至最終僅剩一個run,即排序結果。

minrun的選取方式
如果待排序列長度爲minrun,則我們總共會產生nminrun\lceil \frac{n}{minrun} \rceil個初始run。

  • 如果nminrun\lceil \frac{n}{minrun} \rceil剛好是2的整數次冪,則歸併過程將會非常“完美”,可看做一個滿二叉樹。
  • 如果nminrun\lceil \frac{n}{minrun} \rceil比2的某個整數次冪稍大一點點,則到算法最後階段會出現一個超長run與一個超短run的合併,這就不太好了。

因此,需要選取的minrun,滿足nminrun\lceil \frac{n}{minrun} \rceil剛好是2的整數次冪或比某個2的整數次冪稍小一點的數。

如果數組元素小於64個,則採用二分插入排序(在第一部分的總結部分裏,提到插入排序對於小型數組最爲有效)。

如果數組元素大於64個,則算法按照擴充和合並的方式來完成。首先根據minrun查數組中升序或嚴格降序的部分。當Timsort找到一個run時,如果run的長度小於minrun,就選擇run之後的數字插入排序至run中,使得run的長度達到minrun。然後將這個run壓入棧中,也將run在數組中的起始位置和run的長度放入棧中,之後根據先前壓入棧中的run決定是否該合併run。

當run的數目等於或略小於2的冪時,合併兩個數組最爲有效。Timsort選擇範圍爲[32,64][32, 64]的minrun,使得原始數組的長度除以minrun時,等於或略小於2的冪。

在維基百科中,有這麼一段“ The final algorithm takes the six most significant bits of the size of the array, adds one if any of the remaining bits are set, and uses that result as the minrun.”,第一次看這非常一臉懵。

這裏說明一下,以上翻譯過來就是,選擇數組長度的六個最高標誌位,如果其餘的標誌位被設置,則加1:

  • 211: 1101 0011,取前6個最高標誌位爲110100(52),最後兩位爲11,所以minrun爲52+1,nminrun=4\lceil \frac{n}{minrun} \rceil = 4滿足要求。
  • 976:11 1101 0000,取前6個最高標誌位爲111101(61),同時最後幾位爲0000,所以minrun爲61,nminrun=16\lceil \frac{n}{minrun} \rceil = 16滿足要求。

合併的時候,按照合併中的規則,滿足條件時,合併結束。
Timsort並沒有執行原址(in_place)的歸併,因爲保證原址並穩定的話,需要很大的開銷。

實際上Timsort合併2個相鄰的run需要臨時存儲空間,臨時存儲空間的大小是2個run中較小的run的大小。Timsort算法先將較小的run複製到臨時空間,然後用原先存儲這2個run的空間來存儲合併後的run。

合併算法是用簡單插入排序,一次從左到右或從右到左比較,然後合併2個run。爲了提高效率,timsort用二分插入排序。

Galloping mode
在Galloping mode中,對於兩個run,算法在一個run中搜索另一個run的第一個元素位置。通過該初始元素與另一個run的第2k1135...2k-1(1,3, 5...)個元素進行比較來完成,來獲得初始元素所在的元素範圍。這縮短了二分查找的範圍,從而提高了效率,如果發現Galloping的效率低於二分查找,則退出Galloping mode。
在這裏插入圖片描述
二分查找會找到X中第一個大於Y[0]的元素x,當找到x時,可以在合併時忽略x之前的元素;類似的,在Y中找到第一個大於X[-1]的元素,當找到y時,可以在合併時忽略y之後的元素。這種查找在隨機數中效率不會很高,但在其他情況下有很高的效率。
在這裏插入圖片描述
當算法到達最小閾值min_gallop時,算法切換到Galloping mode,試圖利用數據中的那些可以直接排序的元素。只有當一個run的初始元素不是另一個run的前七個元素之一時,Galloping纔有用。即初始閾值是7。

爲了避免Galloping mode的缺點,合併函數會調整閾值。如果所選元素來自先前返回元素的同一個數組,則min_gallop減1,否則,該值增加1,從而阻止返回到Galloping mode。在隨機數據的情況下,min_gallop的值會變得非常大,以至於Galloping mode永遠不會再次發生。

Galloping並不總是有效。在某些情況下,Galloping mode會有比簡單的線性搜索更多的比較。

本質上Timsort是一個經過大量優化的歸併排序,而歸併排序已經到達了最壞的情況下,比較排序算法時間複雜度的下界,所以在最壞的情況下,Timsort的時間複雜度爲O(nlogn)O(n\log n)。最佳情況下,即輸入已經排好序,它則已線性O(n)O(n)時間運行。

代碼這裏,官方原始代碼這裏


第三部分、Introsort

introsort概述
The Same,在第一部分的總結概要的第二條中提到:插入排序常結合快速排序、歸併排序來結合使用,那麼對於Introsort,就是插入排序,快速排序和堆排序結合使用。So,它們是怎麼結合的呢?

快速排序,平均複雜度爲O(nlogn)O(n \log n),最壞情況下將達到O(N2)O(N^2)。不過Introsort,極類似於median-of-three快速排序,可將最壞情況推進到O(nlogn)O(n \log n)。早期的STL sort算法都採用快速排序,SGI STL採用的是Introsort。

Quick Sort
Quick Sort算法敘述如下:
1,如果S的元素個數爲0或1,結束。
2,取S中的任何一個元素,當做樞軸(pivot)v。
3,將S分割爲L, R兩段,使L內的每個元素都小於或等於v,R內的每個元素都大於或等於v。
4,對L,R遞歸執行Quick Sort。
Quick Sort的精神在於將大區間分割爲小區間的,分段排序。

median-of-three QuickSort
但是任何一個元素都可以被選作樞軸,其合適與否影響着Quick Sort的效率。爲了避免“元素當初輸入時不夠隨機”所帶來的惡化效應,最理想最穩當的方式就是取整個序列的頭,中央,尾三個位置的元素,以三者之間的中值作爲樞軸。這種做法稱爲 median-of-three QuickSort。

小規模序列的case
當面對規模非常小的小型序列,用Quick Sort顯然不合適。在小數據量的情況下,簡單的插入排序的效率都比Quick Sort高,因爲Quick Sort會爲了極小的子序列產生許多的函數遞歸調用。

考慮到以上的情況,溼度評估序列的大小,然後決定採用快速排序或者插入排序是很有必要的。那麼問題是,多小的序列才應該改變排序方式呢!

插入排序的優勢
此外,也注意到,對於“幾乎排序但尚未完成”的序列,採用插入排序,效率一般認爲會比“將所有子序列徹底排序”更好。這是因爲插入排序在面對“幾乎排序但尚未完成”的序列時,有很好的表現。

introsort
鑑於不當的樞軸,導致不當的分割,進而影響整體效率。David R.Musser在1996年提出一種混合式排序算法:Introspective Sorting,簡稱Introsort。其行爲在大部分情況下與media-of-three Quick Sort完全相同。但是當分割行爲有惡化爲二次行爲的傾向時,能夠自我偵測,轉而改爲堆排序,使得效率維持在O(nlogn)O(n \log n),這又比一開始就用堆排序來的好。

在SGI STL sort中,來看下Introsort算法:

template <class RandomAccessIterator>
inline void sort(RandomAccessIterator first, RandomAccessIterator last){	
	if(first != last){
		_introsort_loop(first, last, value_type(first), _lg(last-first)*2);  
		_final_insertion_sort(first, last);   // 最後的插入排序,完成整個序列排序
	}
}

其中,_lg()用來控制分割惡化的情況:

template <class Size>
inline Size _lg(Size n){	
	Size k;
	for(k=0; n>1; n>>1) ++k;
	return k;
}

因此,對於n=7,得k=2;n=20,得k=4。

template <class RandomAccessIterator, class T, class size>
void __introsort_loop(RandomAccessIterator first, RandomAccessIterator last,
			T*, Size depth_limit){
	//_stl_threshold是個全局常數,稍早定義爲const int 16
	while(last - first > _stl_threshold){		// > 16
		if(depth_limit == 0){			// 分割惡化
			partial_sort(first, last, last);   	// 改用堆排序
			return;
		}
	}
	--depth_limit;
	//以下是 media-of-three partition,選擇一個夠好的樞軸並決定分割點
	// 分割點落在迭代器cut上
	RandomAccessIterator cut = _unguarded_partition(first, last, 
		T(_median(*first, *(fist+(last-first)/2), *(last-1))));
	// 對右半段遞歸進行sort
	_introsort_loop(cut, last, value_type(first), depth_limit);
	last = cut;
	//返回到while循環,準備對左半段遞歸進行sort
}
// 這種寫法可讀性就講究吧

通過元素個數檢驗之後,再檢查分割層次。

Introsort,由以上三段代碼可以看出端倪了。


以《三傻大鬧寶萊塢》裏最後一句臺詞來作結:追求卓越,成功就會追着你跑。


參考鏈接
[1] https://sikasjc.github.io/2018/07/25/timsort/
[2] 《STL源碼剖析》
[3] https://en.wikipedia.org/wiki/Timsort
[4] https://hackernoon.com/timsort-the-fastest-sorting-algorithm-youve-never-heard-of-36b28417f399

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章