你所不知道的快速排序(js實現)

嗶譜嗶譜發表於2017-07-13

注:本文是在看了兩篇大牛的部落格後,通過整理供自己學習快速排序所做筆記,分享出來方便大家學習。如需進一步瞭解可以檢視文中部落格連結。

一. 快速排序是什麼

快速排序是圖靈獎得主C. A. R. Hoare(1934--)於1960時提出來的。

快速排序是對氣泡排序的一種改進。它的基本思想是:通過一趟排序將要排序的資料分割成獨立的兩部分,其中一部分的所有資料都比另外一不部分的所有資料都要小,然後再按此方法對這兩部分資料分別進行快速排序,整個排序過程可以遞迴進行,以此達到整個資料變成有序序列。

整個排序過程只需要三步:

(1)在資料集之中,選擇一個元素作為"基準"(pivot)。
(2)所有小於"基準"的元素,都移到"基準"的左邊;所有大於"基準"的元素,都移到"基準"的右邊。
(3)對"基準"左邊和右邊的兩個子集,不斷重複第一步和第二步,直到所有子集只剩下一個元素為止。

二. 快速排序的時間複雜度

日本程式設計師norahiko,寫了一個排序演算法的動畫演示,可以直觀地將快排和其他排序方法的速度進行對比。

三. 快速排序為什麼那麼快

根據劉偉鵬的數學之美番外篇:快排為什麼那樣快一文所述,快排的本質上是一種通過問問題來縮小結果的可能性區間的策略

下面我們將從最基本的猜數字和稱小球講起,總結其中蘊含的規律,然後一步步引申到排序問題上去。

1. 猜數字

假設有一個1~64之間的數,你來猜(你只能問答案是“是”或“否”的問題)。為了保證不論在什麼情況下都能以儘量少的次數猜中,你應該採取什麼策略呢?很顯然,二分。先是猜是不是位於1~32之間,排除掉一半可能性,然後對區間繼續二分。這種策略能夠保證無論數字怎麼跟你捉迷藏,都能在log_2{n}次以內猜中。用演算法的術語來說就是它的下界是最好的

為什麼這種策略具有最優下界?答案也很簡單,這個策略是平衡的。反之如果策略不是平衡的,比如問是不是在1~10之間,那麼一旦發現不是在1~10之間的話就會剩下比N/2更多的可能性需要去考察了。

這種策略的本質可以概括成“讓未知世界無機可乘”。它是沒有“弱點的”,答案的任何一個分支都是等概率的。反之,一旦某個分支蘊含的可能性更多,當情況落到那個分支上的時候你就鬱悶了。比如猜數字遊戲最糟糕的策略就是一個一個的猜:是1嗎?是2嗎?… 因為這種猜法最差的情況下需要64次才能猜對,下界非常糟糕。二分搜尋為什麼好,就是因為它每次都將可能性排除一半並且無論如何都能排除一半(它是最糟情況下表現最好的)。

2. 稱小球

12個小球,其中有一個是壞球。有一架天平。需要你用最少的稱次數來確定哪個小球是壞的並且它到底是輕還是重。這個問題是一道流傳已久的智力題

我們先回顧一下猜數字遊戲。為了保證任何情況下以最少次數猜中,我們的策略是每次都排除恰好一半的可能性。類比到稱球問題上:壞球可能是12個球中的任意一個,這就是12種可能性;而其中每種可能性下壞球可能輕也可能重。於是“壞球是哪個球,是輕是重”這個問題的答案就有12×2=24種可能性。現在我們用天平來稱球,就等同於對這24種可能性發問,由於天平的輸出結果有三種“平衡、左傾、右傾”,這就相當於我們的問題有三個答案,即可以將所有的可能性切成三份,根據猜數字遊戲的啟發,我們應當儘量讓這三個分支概率均等,即平均切分所有的可能性為三等份。如此一來的話一次稱量就可以將答案的可能性縮減為原來的1/3,三次就能縮減為1/27。而總共才有24種可能性,所以理論上是完全可以3次稱出來的。

如何稱的指導原則有了,構造一個稱的策略就不是什麼太困難的事情了。首先不妨解釋一下為什麼最直觀的稱法不是最優的——6、6稱:在6、6稱的時候,天平平衡的可能性是0,也就是說我們得到的資訊量為零。剛才說了,最優策略應該使得天平三種狀態的概率均等,這樣才能三等分答案的所有可能性。

只要記著這樣一個指導思想——你選擇的稱法必須使得當天平平衡的時候答案剩下的可能性和天平左傾(右傾)的時候答案剩下的可能性一樣多。實際上,這等同於你得選擇一種稱法,使得天平輸出三種結果的概率是均等的,因為天平輸出某個結果的概率就等同於所有支援這個結果(左傾、右傾、平衡)的答案可能性的和,並且答案的每個可能性都是等概率的。

MacKay在他的書《Information Theory: Inference and Learning Algorithms》(作者開放免費電子書)裡面4.1節專門講了這個稱球問題,還畫了一張不錯的圖:

圖中“1+”是指“1號小球為重”這一可能性。一開始一共有24種可能性。4、4稱了之後不管哪種情況(分支),剩下來的可能性總是4種。這是一個完美的三分。然後對每個分支構造第二次稱法,這裡你只要稍加演算就可以發現,分支1上的第二次稱法,即“1、2、6對3、4、5”這種稱法,天平輸出三種結果的可能性是均等的(嚴格來說是幾乎均等)。這就是為什麼這個稱法能夠在最壞的情況下也能表現最好的原因,沒有哪個分支是它的弱點,它必然能將情況縮小到原來的1/3。

3. 排序(理想情況)

用前面的看問題視角,排序的本質可以這樣來表述:一組未排序的N個數字,它們一共有N!種重排,其中只有一種排列是滿足題意的(譬如從大到小排列)。換句話說,排序問題的可能性一共有N!種。任何基於比較的排序的基本操作單元都是“比較a和b”,這就相當於猜數字遊戲裡面的一個問句,顯然這個問句的答案只能是“是”或“否”,一個只有兩種輸出的問題最多隻能將可能性空間切成兩半,根據上面的思路,最佳切法就是切成1/2和1/2。也就是說,我們希望在比較了a和b的大小關係之後,如果發現ab也是剩下N!/2種可能性。由於假設每種排列的概率是均等的,所以這也就意味著支援ab的也是N!/2個,換言之,ab的概率。

我們希望每次在比較a和b的時候,ab的概率是均等的,這樣我們就能保證無論如何都能將可能性縮小為原來的一半了!最優下界。

一個直接的推論是,如果每次都像上面這樣的完美比較,那麼N個元素的N!種可能排列只需要log_2{N!}就排查玩了,而log_2{N!}近似於NlogN。這正是快排的複雜度。

4. 排序(實際情況)

我們考慮快排的過程:隨機選擇一個元素做“軸元素”,將所有大於軸元素的移到左邊,其餘移到右邊。根據這個過程,快排的第一次比較就是將一個元素和軸元素比較,這個時候顯而易見的是,“大於”和“小於”的可能性各佔一半。這是一次漂亮的比較。

然而,快排的第二次比較就不那麼高明瞭:我們不妨令軸元素為pivot,第一次比較結果是a1pivot的話,那麼a1,a2,pivot這三個元素之間的關係就完全確定了——a1<pivot<a2,剩下來的元素排列的可能性我們不妨記為P(不需要具體算出來)。而如果a2<pivot呢?那麼a1和a2的關係就仍然是不確定的,也就是說,這個分支裡面含有兩種情況:a1<a2<pivot,以及a2<a1<pivot。對於其中任一種情況,剩下的元素排列的可能性都是P,於是這個分支裡面剩下的排列可能性就是2P。所以當a2<pivot的時候,還剩下2/3的可能性需要排查。

再進一步,如果第二步比較果真發現a2<pivot的話,第三步比較就更不妙了,模仿上面的推理,a3<pivot的概率將會是3/4!

這就是快排也不那麼快的原因,因為它也沒有做到每次比較都能將剩下的可能性砍掉一半。

5. 小節

將排序問題看成和猜數字一樣,是通過問問題來縮小/排除(narrow down)結果的可能性區間,這樣一來,就會發現,“最好的問題”就是那些能夠均分所有可能性的問題,因為那樣的話不管問題的答案如何,都能排除掉k-1/k(k為問題的答案有多少種輸出——猜數字裡面是2,稱球裡面是3)種可能性,而不均衡的問題總會有一個或一些答案分支排除掉的可能性要小於k-1/k。於是策略的下界就被拖累了

四. 快速排序的具體實現

知道了快排的思想與原理,下面我們就用javascript來具體實現一下快排。

首先,定義一個quickSort函式,它的引數是一個陣列。

var quickSort = function(arr) {
};複製程式碼

然後,檢查陣列的元素個數,如果小於等於1,就返回。

var quickSort = function(arr) {
  if (arr.length <= 1) { return arr; }
};複製程式碼

接著,選擇"基準"(pivot),並將其與原陣列分離,再定義兩個空陣列,用來存放一左一右的兩個子集。

var quickSort = function(arr) {
  if (arr.length <= 1) { return arr; }
  var pivotIndex = Math.floor(arr.length / 2) ;
  var pivot = arr.splice(pivotIndex, 1)[0];
  var left = [];
  var right = [];
};複製程式碼

然後,開始遍歷陣列,小於"基準"的元素放入左邊的子集,大於基準的元素放入右邊的子集。

var quickSort = function(arr) {
  if (arr.length <= 1) { return arr; }
  var pivotIndex = Math.floor(arr.length / 2) ;
  var pivot = arr.splice(pivotIndex, 1)[0];
  var left = [];
  var right = [];
  for (var i = 0; i < arr.length; i++){
    if (arr[i] < pivot) {
      left.push(arr[i]);
    } else {
      right.push(arr[i]);
    }
  }
};複製程式碼

最後,使用遞迴不斷重複這個過程,就可以得到排序後的陣列。

var quickSort = function(arr) {
  if (arr.length <= 1) { return arr; }
  var pivotIndex = Math.floor(arr.length / 2);
  var pivot = arr.splice(pivotIndex, 1)[0];
  var left = [];
  var right = [];
  for (var i = 0; i < arr.length; i++){
    if (arr[i] < pivot) {
      left.push(arr[i]);
    } else {
      right.push(arr[i]);
    }
  }
  return quickSort(left).concat([pivot], quickSort(right));
};複製程式碼

使用的時候,直接呼叫quickSort()就行了。

參考來源:阮一峰、劉未鵬

相關文章