深入理解快速排序和STL的sort算法

1.寫在前面

週六了...依然跳票...沒有新文章產出...因爲很忙...是的...

爲了證明筆者沒有放棄這塊陣地,整合三篇去年的文章,今天一起來學習一下:快速排序及其優化 和 STL的sort算法

通過本文你將瞭解到以下內容:

  • 快速排序的基本思想

  • 快速排序的遞歸實現和迭代實現

  • 快速排序的最壞情況

  • 快速排序和歸併排序對比

  • 快速排序的多角度優化

  • 內省式排序基本原理

  • STL的sort算法基本原理

2. 那年初識快排

2.1 看似青銅實則王者

常見不等同於簡單。

很多人提起快排和二分都覺得很容易的樣子,但是讓現場Code很多就翻車了,就算可以寫出個遞歸版本的代碼,但是對其中的複雜度分析、邊界條件的考慮、非遞歸改造、代碼優化等就無從下手,填鴨背誦基本上分分鐘就被面試官擺平了。

快速排序Quicksort又稱劃分交換排序partition-exchange sort,簡稱快排,一種排序算法。最早由C. A. R. Hoare教授在1960年左右提出,在平均狀況下,排序n個項目要O(nlogn)次比較。

在最壞狀況下則需要O(n^2)次比較,但這種狀況並不常見。事實上,快速排序通常明顯比其他算法更快,因爲它的內部循環可以在大部分的架構上很有效率地達成。

快排的提出者是大名鼎鼎的人物,go語言使用的併發模型CSP也是這個大神提出的,一起膜拜下。

查爾斯·安東尼·理查德·霍爾爵士(Sir Charles Antony Richard Hoare縮寫爲C. A. R. Hoare,1934年1月11日-),暱稱爲東尼·霍爾(Tony Hoare),生於大英帝國錫蘭可倫坡(今斯里蘭卡),英國計算機科學家,圖靈獎得主。

他設計了快速排序算法、霍爾邏輯、交談循序程式。在操作系統中,他提出哲學家就餐問題,併發明用來作爲同步程序的監視器(Monitors)以解決這個問題。他同時證明了監視器與信號標(Semaphore)在邏輯上是等價的。

1980年獲頒圖靈獎、1982年成爲英國皇家學會院士、2000年因爲他在計算機科學與教育方面的傑出貢獻,獲得英國王室頒贈爵士頭銜、2011年獲頒約翰·馮諾依曼獎,現爲牛津大學榮譽教授,並在劍橋微軟研究院擔任研究員。

2.2 快速排序的基本思想和過程

2.2.1 D&C分治思想

在計算機科學中,分治法(Divide&Conquer)是建基於多項分支遞歸的一種很重要的算法範式,快速排序是分治思想在排序問題上的典型應用。

所謂分治思想D&C就是把一個較大規模的問題拆分爲若干小規模且相似的問題。再對小規模問題進行求解,最終合併所有小問題的解,從而形成原來大規模問題的解。

字面上的解釋是"分而治之",這個技巧是很多高效算法的基礎,如排序算法(歸併排序、快速排序)、傅立葉變換(快速傅立葉變換)。

分治法中最重要的部分是循環遞歸的過程,每一層遞歸有三個具體步驟:

  • 分解:將原問題分解爲若干個規模較小,相對獨立,與原問題形式相同的子問題。

  • 解決:若子問題規模較小且易於解決時,則直接解。否則,遞歸地解決各子問題。

  • 合併:將各子問題的解合併爲原問題的解。

2.2.2 基本過程

快速排序使用分治法來把一個序列分爲小於基準值和大於基準值的兩個子序列。遞歸地排序兩個子序列,直至最小的子序列長度爲0或者1,整個遞歸過程結束,詳細步驟爲:

  • 挑選基準值: 從數列中挑出一個元素稱爲基準pivot,選取基準值有數種具體方法,此選取方法對排序的時間性能有決定性影響。

  • 基準值分割: 重新排序數列,所有比基準值小的元素擺放在基準前面,所有比基準值大的元素擺在基準後面,與基準值相等的數可以到任何一邊,在這個分割結束之後,對基準值的排序就已經完成。

  • 遞歸子序列: 遞歸地將小於基準值元素的子序列和大於基準值元素的子序列排序,步驟同上兩步驟,遞歸終止條件是序列大小是0或1,因爲此時該數列顯然已經有序。

3. 快速的遞歸實現和迭代實現

快速排序一般大家寫遞歸的時候更多,但是遞歸往往也會寫出不同風格的版本,所以我們一起來看下多個風格的遞歸版本和迭代版本的實現,多種代碼對比會讓我們理解更深刻。

3.1 遞歸實現代碼

C語言遞歸版本一:

C++遞歸版本二:

兩個版本均可正確運行,但代碼有一點差異:

  • 版本一 使用雙指針交替從左(右)兩邊分別開始尋找大於基準值(小於基準值),然後與基準值交換,直到最後左右指針相遇。

  • 版本二 使用雙指針向中間集合,左指針遇到大於基準值時則停止,等待右指針,右指針遇到小於基準值時則停止,與左指針指向的元素交換,最後基準值放到合適位置。

3.2 遞歸實現過程演示

過程說起來比較抽象,穩住別慌!靈魂畫手畫圖來演示這兩個過程。

3.2.1 C版本一過程演示

第一次遞歸循環爲例:

步驟1: 選擇第一個元素爲基準值pivot=a[left]=5,right指針指向尾部元素,此時先由right自右向左掃描直至遇到<5的元素,恰好right起步元素4<5,因此需要將4與5互換位置;

步驟2: 4與5互換位置之後,輪到left指針從左向右掃描,注意一下left的起步指針指向了由步驟1交換而來的4,新元素4不滿足停止條件,因此left由綠色虛箭頭4位置遊走到元素9的位置,此時left找到9>5,因此將此時left和right指向的元素互換,也就是元素5和元素9互換位置;

步驟3: 互換之後right指針繼續向左掃描,從藍色虛箭頭9位置遊走到3的位置,此時right發現3<5,因此將此時left和right指向的元素互換,也就是元素3和元素5互換位置;

步驟4: 互換之後left指針繼續向右掃描,從綠色虛箭頭3位置遊走到6的位置,此時left發現6>5,因此將此時left和right指向的元素互換,也就是元素6和元素5互換位置;

步驟5: 互換之後right指針繼續向左掃描,從藍色虛箭頭6位置一直遊走到與left指針相遇,此時二者均停留在了pivot=5的新位置上,且左右兩邊分成了兩個相對於pivot值的子序列;

循環結束:至此出現了以5爲基準值的左右子序列,接下來就是對兩個子序列實施同樣的遞歸步驟。

第二次和第三次左子序列遞歸循環爲例:

步驟1-1:選擇第一個元素爲基準值pivot=a[left]=4,right指針指向尾部元素,此時先由right指針向左掃描,恰好起步元素3<4,因此將3和4互換;

步驟1-2:互換之後left指針從元素3開始向右掃描,一直遊走到與right指針相遇,此時本次循環停止,特別注意這種情況下可以看到基準值4只有左子序列,無右子序列,這種情況是一種退化,就像冒泡排序每次循環都將基準值放置到最後,因此效率將退化爲冒泡的O(n^2);

步驟1-3:選擇第一個元素爲基準值pivot=a[left]=3,right指針指向尾部元素,此時先由right指針向左掃描,恰好起步元素1<3,因此將1和3互換;

步驟1-4:互換之後left指針從1開始向右掃描直到與right指針相遇,此時注意到pivot=3無右子序列且左子序列len=1,達到了遞歸循環的終止條件,此時可以認爲由第一次循環產生的左子序列已經全部有序。

循環結束:至此左子序列已經排序完成,接下來對右子序列實施同樣的遞歸步驟,就不再演示了,聰明的你一定get到了。

特別注意:

以上過程中left和right指針在某個元素相遇,這種情況在代碼中是不會出現的,因爲外層限制了i!=j,圖中之所以放到一起是爲了直觀表達終止條件。

3.2.2 C++版本二過程演示

分析一下:

個人覺得這個版本雖然同樣使用D&C思想但是更加簡潔,從動畫可以看到選擇pivot=a[end],然後左右指針分別從index=0和index=end-1向中間靠攏。

過程中掃描目標值並左右交換,再繼續向中間靠攏,直到相遇,此時再根據a[left]和a[right]以及pivot的值來進行合理置換,最終實現基於pivot的左右子序列形式。

腦補場景:

上述過程讓我覺得很像統帥命令左右兩路軍隊從兩翼會和,並且在會和過程中消滅敵人有生力量(認爲是交換元素),直到兩路大軍會師。

此時再將統帥王座擺到正確的位置,此過程中沒有統帥王座的反覆變換,只有最終會師的位置,以王座中心形成了左翼子序列和右翼子序列,再重複相同的過程,直至完成大一統。

腦補不過癮 於是湊圖一張:

3.3 多種遞歸版本說明

雖然快排的遞歸版本是基於D&C實現的,但是由於pivot值的選擇不同、交換方式不同等諸多因素,造成了多種版本的遞歸代碼。

並且內層while循環裏面判斷>=還是>(即是否等於的問題),外層循環判斷本序列循環終止條件等寫法都會不同,因此在寫快排時切忌死記硬背,要不然邊界條件判斷不清楚很容易就死循環了。

看下上述我貼的兩個版本的代碼核心部分:

另外在網上很多大神的博客裏面還進行了多種模式的快排:單軸模式、雙向切分、三項切分、多基準值等新花樣,感興趣可以參考快速排序算法的多種實現。

其實無論哪種寫法都需要明確知道自己是交換、還是覆蓋、基準值選取位置、>=和<=的等號問題、循環終止條件等,這樣才能寫出BugFree的快速排序算法。

網上很多代碼的核心部分是這樣寫的:

覆蓋or交換

代碼中首先將pivot的值引入局部變量保存下來,這樣就認爲A[L]這個位置是個坑,可以被其他元素覆蓋,最終再將pivot的值填到最後的坑裏。

這種做法也沒有問題,因爲你只要畫圖就可以看到,每次坑的位置是有相同元素的位置,也就是被備份了的元素。

個人感覺 與其叫坑不如叫備份,但是如果你代碼使用的是基於指針或者引用的swap,那麼就沒有坑的概念了。

這就是覆蓋和交換的區別,本文的例子都是swap實現的,因此沒有坑位被最後覆蓋一次的過程。

3.4 迭代版本實現

所謂迭代實現就是非遞歸實現一般使用循環來實現,我們都知道遞歸的實現主要是藉助系統內的棧來實現的。

如果調用層級過深需要保存的臨時結果和關係會非常多,進而造成StackOverflow棧溢出。

Stack一般是系統分配空間有限內存連續速度很快,每個系統架構默認的棧大小不一樣,筆者在x86-CentOS7.x版本使用ulimit -s查看是8192Byte。

避免棧溢出的一種辦法是使用循環,以下爲筆者驗證的使用STL的stack來實現的循環版本,代碼如下:

4. 快速排序的優化

快速排序是圖領獎得主發明的算法,被譽爲20世紀最重要的十大算法之一,快速排序爲了可以在多種數據集都有出色的表現,進行了非常多的優化,因此對我們來說要深入理解一種算法的最有效的手段就是不斷優化提高性能。

4.1 快速排序vs歸併排序

快速排序和歸併排序採用的基本思想都是分治思想Divide&Conquer,從D&C思想來看最主要的部分就是分割和合並,兩種算法在使用D&C時側重點有一些差異:

歸併排序在分割時處理很簡單,在合併時處理比較多,重點在合併。

快速排序在分割時處理比較複雜,由於交換的存在遞歸結束時就相當於合併完成了,重點在分割。

歸併排序分治示意圖:

快速排序分治示意圖:

注:快排的過程就不寫具體的數字了 僅爲達意 點到即可。

可以明顯看出來,快速排序在選擇基準值時對整個分治過程影響很大,因爲下一個環節的分治是基於前一環節的分割結果進行的。

4.2 分區不均勻和最壞複雜度

4.2.1 極端分區

考慮一種極端的情況下,如果基準值選取的不合理,比如是最大的或者最小的,那麼將導致只有一邊有數據,對於已經排序或者近乎有序的數據集合來說就可能出現這種極端情況,還是來畫個圖看下:

圖中展示了每次分治都選擇第一個元素作爲基準值,但是每次的基準值都是最小值,導致每次基準值左側沒有子序列,除了基準值之外全部元素都在右子序列。

4.2.2 最壞情況概率和複雜度計算

每次分割排序之後,只能在有序序列中增加1個元素遞歸樹變成了單支樹並且遞歸深度變大,極端情況的出現概率和最壞複雜度計算如下:

極端情況概率就是每次在剩餘所有元素中挑出最小的,這樣每次的概率都是1/(n-i),所以組合起來就是1/(n!),所以隨機數據集合出現最差情況的概率非常低,但是有序數據下固定基準值選擇就可能造成極端情況的出現。

最壞複雜度相當於每次從n-i個元素中只找到1個數據,將所有情況累加也就達到了O(n^2)級別,並不是遞歸過程全都挑選了最值作爲基準值纔會出現O(n^2)的複雜度,複雜度是一個概率化的期望值,具體的係數不同影響也很大。

4.3 基準值選取優化

分割越均勻速度越快 

從上面的幾張圖可以清晰看到基準值的不同對於D&C過程的分割會產生很大的影響,爲了保證快速排序的在通用數據集的效率,因此我們需要在基準值的選取上做一些決策,換句話說就是讓選取的基準值每次都可以儘可能均勻地分割數據集,這樣的效率是最高的。

隨機選取基準值 

網上有很多選擇方法比如固定選取第一個、固定選取最後一個、固定選擇中間值、三值平均選取等,不過個人覺得每一次都隨機選取某個位置的數據作爲基準值,然後與第一個值互換,這樣就相當於每次的基準值都是隨機選擇的,就將固定index帶來的問題,大大降低了。

隨機vs固定對比試驗 

接下來做一組對比試驗,生成一個0-100000的有序數組,代碼增加了很多選擇項和時間測量代碼,測試代碼如下:

筆者使用相同的數據集在fix和random模式下,後者的耗時明顯低於前者,所以某些場景下隨機化帶來的性能提升很明顯,是一個慣用的優化方法。

4.4 三分區模式優化

前面的路子都是以基準值爲準分爲小於子序列和大於子序列,考慮一種特殊的數據集,數據集中有大量重複元素,這種情況下使用兩分區遞歸會對大量重複元素進行處理。

一個優化的方向就是使用三分區模式:小於區間、等於區間、大於區間,這樣在後續的處理中則只需要處理小於區和大於區,降低了等於基準值區間元素的重複處理,加速排序過程。

4.4.1 三分區原理

如圖爲三分區模式中某個時刻的快照,其中展示了幾個關鍵點和區間,包括基準值、小於區、等於區、處理值、待處理區、大於區

在實際過程中根據處理值與基準值的大小關係,進行相應分區合併和交換,再進行下標移動就可以了,實際中分三種情況,這也是寫代碼的依據:

  1. 處理值e==p,將e合併到等於區,i++;

  2. 處理值e<p,將e與(lt+1)位置的數據交換,擴展小於區lt++,等於區長度不變,相當於整體平移;

  3. 處理值e>p,將e與(gt-1)位置的數據交換,擴展大於區gt--,此時i不變,交換後的值是之前待處理區的尾部數據;

  • e==p的合併

  • e<p的合併

  • e>p的合併

  • 分區最終調整

處理完待處理區的全部數據之後的調整也非常重要,主要是將基準值P與lt位置的數據交換,從而實現最終的三分區,如圖所示:

從最終的分區可以看到,我們下一次的循環可以不處理等於區的數據而只處理兩端分區數據,這樣在大量重複場景下優化效果會非常明顯。

4.4.2 三分區實驗

筆者使用相同的數據集在二分區模式下測試10w數據規模耗時大約是1800ms,數據集減少10倍耗時卻增大了幾十倍,或許二分區代碼還是存在優化空間,不過這個對比可以看到存在大量重複元素時三分區性能還是很不錯的。

4.5 快排優化小結

對快速排序的優化主要體現在基準值選取、數據集分割、遞歸子序列選取、其他排序算法混合等方面,換句話說就是讓每次的分區儘量均勻且沒有重複被處理的元素,這樣才能保證每次遞歸都是高效簡潔的。

5. STL的sort算法

在瞭解sort算法的實現之前先來看一個概念:內省式排序,說實話筆者的語文水平確實一般,對於這個詞語用在排序算法上總覺得不通透,那就研究一下吧!

5.1 內省思想

內省式排序英文是Introspective Sort,其中單詞introspective是內省型的意思,還是不太明白,繼續搜索,看一下百度百科對這個詞條的解釋:

內省(Introspection )在心理學中,它是心理學基本研究方法之一。內省法又稱自我觀察法。它是發生在內部的,我們自己能夠意識到的主觀現象。也可以說是對於自己的主觀經驗及其變化的觀察。

正因爲它的主觀性,內省法自古以來就成爲心理學界長期的爭論。另外內省也可看作自我反省,也是儒家強調的自我思考。從這個角度說可以應用於計算機領域,如Java內省機制和cocoa內省機制。

From 百度百科-內省-科普中國審覈通過詞條

原來內省是個心理學名詞,到這裏筆者有些感覺了,內省就是自省、自我思考、根據自己的主觀經驗來觀察變化做出調整,而不是把希望寄託於外界,而是自己的經驗和能力。

通俗點說,內省算法不挑數據集,儘量針對每種數據集都能給定對應的處理方法,讓排序都能有時間保證。寫到這裏,讓筆者腦海浮現了《倚天屠龍記》裏面張無忌光明頂大戰六大門派的場景,無論敵人多麼強悍或者羸弱,我都按照自己的路子應對。

原來內省是個心理學名詞,到這裏筆者有些感覺了,內省就是自省、自我思考、根據自己的主觀經驗來觀察變化做出調整,而不是把希望寄託於外界,而是自己的經驗和能力。

通俗點說,內省算法不挑數據集,儘量針對每種數據集都能給定對應的處理方法,讓排序都能有時間保證。寫到這裏,讓筆者腦海浮現了《倚天屠龍記》裏面張無忌光明頂大戰六大門派的場景,無論敵人多麼強悍或者羸弱,我都按照自己的路子應對。

5.2 內省排序概況

俗話說俠者講究刀、槍、劍、戟、斧、鉞、鉤、叉等諸多兵器,這也告訴我們一個道理沒有哪種兵器是無敵的,只有在某些場景下的明顯優勢,這跟軟件工程沒有銀彈是一樣的。

回到我們的排序算法上,排序算法也可謂是百花齊放:冒泡排序、選擇排序、插入排序、快速排序、堆排序、桶排序等等。

雖然一批老一輩的排序算法是O(n^2)的,優秀的算法可以到達O(nlogn),但是即使都是nlogn的快速排序和堆排序都有各自的長短之處,插入排序在數據幾乎有序的場景下性能可以到達O(n),有時候我們應該做的不是衝突對比而是融合創新

內省排序是由David Musser在1997年設計的排序算法。這個排序算法首先從快速排序開始,當遞歸深度超過一定深度(深度爲排序元素數量的對數值)後轉爲堆排序,David Musser大牛是STL領域響噹噹的人物。

拋開語境一味地對比孰好孰壞其實都沒有意義,內省式排序就是集大成者,爲了能讓排序算法達到一個綜合的優異性能,內省式排序算法結合了快速排序、堆排序、插入排序,並根據當前數據集的特點來選擇使用哪種排序算法,讓每種算法都展示自己的長處,這種思想確實挺啓發人的。

5.3 內省排序排兵佈陣

前面提到了內省式排序主要結合了快速排序、堆排序、插入排序,那麼不禁要問,這三種排序是怎麼排兵佈陣的呢?知己知彼百戰不殆,所以先看下三種排序的優缺點吧!

  • 快速排序 在大量數據時無論是有序還是重複,使用優化後的算法大多可以到達O(nlogn),雖然堆排序也是O(nlogn)但是由於某些原因快速排序會更快一些,當遞歸過深分割嚴重不均勻情況出現時會退化爲O(n^2)的複雜度,這時性能會打折扣,這也就是快速排序的短處了。

  • 堆排序 堆排序是快速排序的有力競爭者,最大的特點是可以到達O(nlogn)並且複雜度很穩定,並不會像快速排序一樣可能退化爲O(n^2),但是堆排序過程中涉及大量堆化調整,並且元素比較是跳着來的對Cache的局部性特徵利用不好,以及一些其他的原因導致堆排序比快速排序更慢一點,但是大O複雜度仍然是一個級別的。

  • 插入排序 插入排序的一個特點是就像我們玩紙牌,在梳理手中的牌時,如果已經比較有序了,那麼只需要做非常少的調整即可,因此插入排序在數據量不大且近乎有序的情況下複雜度可以降低到O(n),這一點值得被應用。

優缺點也大致清楚了,所以可以猜想一下內省式排序在實際中是如何調度使這三種排序算法的:

  • 啓動階段 面對大量的待排序元素,首先使用快速排序進行大刀闊斧排序,複雜度可以在O(nlogn)運行

  • 深入階段 在快速排序使用遞歸過程中,涉及棧幀保存切換等諸多遞歸的操作,如果分區切割不當遞歸過深可能造成棧溢出程序終止,因此如果快速排序過程中退化爲O(n^2),此時會自動檢測切換爲堆排序,因爲堆排序沒有惡化情況,都可以穩定在O(nlogn)

  • 收尾階段 在經過快排和堆排的處理之後,數據分片的待排序元素數量小於某個經驗設定值(可以認爲是遞歸即將結束的前幾步調用)時,數據其實已經幾乎有序,此時就可以使用插入排序來提高效率,將複雜度進一步降低爲O(n)


5.4 sort算法細節

本文介紹的sort算法是基於SGI STL版本的,並且主要是以侯捷老師的《STL源碼剖析》一書爲藍本來進行展開的,因此使用了不帶仿函數的版本。

SGI STL中的sort的參數是兩個隨機存取迭代器RandomAccessIterator,sort的模板也是基於此種迭代器的,因此如果容器不是隨機存取迭代器,那麼可能無法使用通用的sort函數。

  • 關聯容器 map和set底層是基於RB-Tree,本身就已經自帶順序了,因此不需要使用sort算法

  • 序列容器 list是雙向迭代器並不是隨機存取迭代器,vector和deque是隨機存取迭代器適用於sort算法

  • 容器適配器 stack、queue和priority-queue屬於限制元素順序的容器,因此不適用sort算法。

綜上我們可以知道,sort算法可以很好的適用於vector和deque這兩種容器。

前面介紹了內省式排序,所以看下sort是怎麼一步步來使用introsort的,上一段入口代碼:

從代碼來看sort使用了first和last兩個隨機存取迭代器,作爲待排序序列的開始和終止,進一步調用了__introsort_loop和__final_insertion_sort兩個函數,從字面上看前者是內省排序循環,後者是插入排序。其中注意到__introsort_loop的第三個參數__lg(last - first)*2,憑藉我們的經驗來猜(蒙)一下吧,應該遞歸深度的限制,不急看下代碼實現:

這段代碼的意思就是n=last-first,2^k<=n的最大整數k值。

所以整體看當假設last-first=20時,k=4,最大分割深度depth_max=4*2=8,從而我們就可以根據first和last來確定遞歸的最大深度了。

快速排序和堆排序的配合

__introsort_loop函數中主要封裝了快速排序和堆排序,來看看這個函數的實現細節:

各位先不要暈更不要蒙圈,一點點分析肯定可以拿下的

  • 先看參數兩個隨機存取迭代器first和last,第三個參數是__lg計算得到的分割深度;

  • 這時候我們進入了while判斷了last-first的區間大小,__stl_threshold爲16,侯捷大大特別指出__stl_threshold的大小可以是5~20,具體大小可以自己設置,如果大於__stl_threshold那就纔會繼續執行,否則跳出;

  • 假如現在區間大小大於__stl_threshold,判斷第三個參數depth_limit是否爲0,也就是是否出現了分割過深的情況,相當於給了一個初始最大值,然後每分割一次就減1,直到depth_limit=0,這時候調用partial_sort,從《stl源碼剖析》的其他章節可以知道,partial_sort就是對堆排序的封裝,看到這裏有點意思了主角之一的heapsort出現了;

  • 繼續往下看,depth_limit>0 尚有分割餘額,那就燥起來吧!這樣來到了__unguarded_partition,這個函數從字眼看是快速排序的partiton過程,返回了cut隨機存取迭代器,__unguarded_partition的第三個參數__median使用的是三點中值法來獲取的基準值Pivot,至此快速排序的partition的三個元素集齊了,最後返回新的切割點位置;

  • 繼續看馬上搞定啦,__introsort_loop出現了,果然遞歸了,特別注意一下這裏只有一個遞歸,並且傳入的是cut和last,相當於右子序列,那左子序列怎麼辦啊?別急往下看,last=cut峯迴路轉cut變成了左子序列的右邊界,這樣就開始了左子序列的處理;

快速排序的實現對比

前面提到了在sort中快速排序的寫法和我們之前見到的有一些區別,看了一下《STL源碼剖析》對快排左序列的處理,侯捷老師是這麼寫的:"寫法可讀性較差,效率並沒有比較好",看到這裏更蒙圈了,不過也試着分析一下吧!

圖爲:STL源碼剖析中侯捷老師對該種寫法的註釋

常見寫法:

SGI STL中的寫法:

網上有一些大佬的文章說sgi stl中快排的寫法左序列的調用藉助了while循環節省了一半的遞歸調用,是典型的尾遞歸優化思路.

這裏我暫時還沒有寫測試代碼做對比,先佔坑後續寫個對比試驗,再來評論吧,不過這種sgi的這種寫法可以看看哈。

堆排序的細節

//注:這個是帶自定義比較函數的堆排序版本
//堆化和堆頂操作
template <class RandomAccessIterator, class T, class Compare>
void __partial_sort(RandomAccessIterator first, RandomAccessIterator middle,
                    RandomAccessIterator last, T*, Compare comp) {
    make_heap(first, middle, comp);
    for (RandomAccessIterator i = middle; i < last; ++i)
        if (comp(*i, *first))
            __pop_heap(first, middle, i, T(*i), comp, distance_type(first));
    sort_heap(first, middle, comp);
}
//堆排序的入口
template <class RandomAccessIterator, class Compare>
inline void partial_sort(RandomAccessIterator first,
                         RandomAccessIterator middle,
                         RandomAccessIterator last, Compare comp) {
    __partial_sort(first, middle, last, value_type(first), comp);
}

插入排序上場了

__introsort_loop達到__stl_threshold閾值之後,可以認爲數據集近乎有序了,此時就可以通過插入排序來進一步提高排序速度了,這樣也避免了遞歸帶來的系統消耗,看下__final_insertion_sort的具體實現:

template <class RandomAccessIterator>
void __final_insertion_sort(RandomAccessIterator first, 
                            RandomAccessIterator last) {
    if (last - first > __stl_threshold) {
        __insertion_sort(first, first + __stl_threshold);
        __unguarded_insertion_sort(first + __stl_threshold, last);
    }
    else
        __insertion_sort(first, last);
}

來分析一下__final_insertion_sort的實現細節吧!

  • 引入參數隨機存取迭代器first和last

  • 如果last-first > __stl_threshold不成立就調用__insertion_sort,這個相當於元素數比較少了可以直接調用,不用做特殊處理;

  • 如果last-first > __stl_threshold成立就進一步再分割成兩部分,分別調用__insertion_sort和__unguarded_insertion_sort,兩部分的分割點是__stl_threshold,不免要問這倆函數有啥區別呀?

__insertion_sort的實現
//逆序對的調整
template <class RandomAccessIterator, class T>
void __unguarded_linear_insert(RandomAccessIterator last, T value) {
    RandomAccessIterator next = last;
    --next;
    while (value < *next) {
        *last = *next;
        last = next;
        --next;
    }
    *last = value;
}

template <class RandomAccessIterator, class T>
inline void __linear_insert(RandomAccessIterator first, 
                            RandomAccessIterator last, T*) {
    T value = *last;
    if (value < *first) {
        copy_backward(first, last, last + 1);//區間移動
        *first = value;
    }
    else
        __unguarded_linear_insert(last, value);
}

//__insertion_sort入口
template <class RandomAccessIterator>
void __insertion_sort(RandomAccessIterator first, RandomAccessIterator last) {
    if (first == last) return; 
    for (RandomAccessIterator i = first + 1; i != last; ++i)
        __linear_insert(first, i, value_type(first));
}

在插入函數中同樣出現了__unguarded_xxx這種形式的函數,unguarded單詞的意思是無防備的,無保護的,侯捷大大提到這種函數形式是特定條件下免去邊界檢驗條件也能正確運行的函數。

copy_backward也是一種整體移動的優化,避免了one by one的調整移動,底層調用memmove來高效實現。

__unguarded_insertion_sort的實現
template <class RandomAccessIterator, class T>
void __unguarded_insertion_sort_aux(RandomAccessIterator first, 
                                    RandomAccessIterator last, T*) {
    for (RandomAccessIterator i = first; i != last; ++i)
        __unguarded_linear_insert(i, T(*i));
}

template <class RandomAccessIterator>
inline void __unguarded_insertion_sort(RandomAccessIterator first, 
                                RandomAccessIterator last) {
    __unguarded_insertion_sort_aux(first, last, value_type(first));
}

關於插入排序的這兩個函數的實現和目的用途,展開起來會很細緻,所以後面想着單獨在寫插入排序時單獨拿出了詳細學習一下。

6.寫在最後

忙碌的日子裏自己的時間就變得很少,然後開始思考未來,其實這樣並不好。

不多說了,感謝各位讀者的傾情閱讀,筆芯你們。

巨人的肩膀

  • http://feihu.me/blog/2014/sgi-std-sort/

  • https://liam.page/2018/09/18/std-sort-in-STL/

  • https://zhuanlan.zhihu.com/p/36274119

  • 侯捷《STL源碼剖析》第六章

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