若是你學習過算法,那麼確定據說過快速排序的大名,可是對於快速排序中用到的 partition 算法,你瞭解的夠多嗎?或許是快速排序太過於光芒四射,使得咱們每每會忽視掉一樣重要的 partition 算法。c++
Partition 可不僅用在快速排序中,還能夠用於 Selection algorithm(在無序數組中尋找第K大的值)中。甚至有可能正是這種經過一趟掃描來進行分類的思想激發 Edsger Dijkstra 想出了 Three-way Partitioning,高效地解決了 Dutch national flag problem 問題。接下來咱們一塊兒來探索 partition 算法。git
快速排序中用到的 partition 算法思想很簡單,首先從無序數組中選出樞軸點 pivot,而後經過一趟掃描,以 pivot 爲分界線將數組中其餘元素分爲兩部分,使得左邊部分的數小於等於樞軸,右邊部分的數大於等於樞軸(左部分或者右部分均可能爲空),最後返回樞軸在新的數組中的位置。github
Partition 的一個直觀簡單實現以下(這裏取數組的第一個元素爲pivot):算法
// Do partition in arr[begin, end), with the first element as the pivot. int partition(vector<int>&arr, int begin, int end){ int pivot = arr[begin]; // Last position where puts the no_larger element. int pos = begin; for(int i=begin+1; i!=end; i++){ if(arr[i] <= pivot){ pos++; if(i!=pos){ swap(arr[pos], arr[i]); } } } swap(arr[begin], arr[pos]); return pos; }
若是原始數組爲[5,9,2,1,4,7,5,8,3,6],那麼整個處理的過程以下圖所示:數組
這種實現思路比較直觀,可是其實並不高效。從直觀上來分析一下,每一個小於pivot的值基本上(除非到如今爲止尚未碰見大於pivot的值)都須要一次交換,大於pivot的值(例如上圖中的數字9)有可能須要被交換屢次才能到達最終的位置。less
若是咱們考慮用 Two Pointers 的思想,保持頭尾兩個指針向中間掃描,每次在頭部找到大於pivot的值,同時在尾部找到小於pivot的值,而後將它們作一個交換,就能夠一次把這兩個數字放到最終的位置。一種比較明智的寫法以下:函數
int partition(vector<int>&arr, int begin, int end) { int pivot = arr[begin]; while(begin < end) { while(begin < end && arr[--end] >= pivot); arr[begin] = arr[end]; while(begin < end && arr[++begin] <= pivot); arr[end] = arr[begin]; } arr[begin] = pivot; return begin; }
若是是第一次看到上面的代碼,那麼停下來,好好品味一下。這裏沒有用到 swap 函數,但其實也至關於作了 swap 操做。之前面的數組爲例,看看以這種方法來作的話,整個處理的流程。學習
直觀上來看,賦值操做的次數很少,比前面單向掃描的swap次數都少,效率應該會更高。這裏從理論上對這兩種方法進行了分析,有興趣能夠看看。ui
咱們都知道經典的快速排序就是首先用 partition 將數組分爲兩部分,而後分別對左右兩部分遞歸進行快速排序,過程以下:spa
void quick_sort(vector<int> &arr, int begin, int end){ if(begin >= end - 1){ return; } int pos = partition(arr, begin, end); quick_sort(arr, begin, pos); quick_sort(arr, pos+1, end); }
雖然快排用到了經典的分而治之的思想,可是快排實現的前提仍是在於 partition 函數。正是有了 partition 的存在,才使得能夠將整個大問題進行劃分,進而分別進行處理。
除了用來進行快速排序,partition 還能夠用 O(N) 的平均時間複雜度從無序數組中尋找第K大的值。和快排同樣,這裏也用到了分而治之的思想。首先用 partition 將數組分爲兩部分,獲得分界點下標 pos,而後分三種狀況:
pos == k-1,則找到第 K 大的值,arr[pos];
pos > k-1,則第 K 大的值在左邊部分的數組。
pos < k-1,則第 K 大的值在右邊部分的數組。
下面給出基於迭代的實現:
int find_kth_number(vector<int> &arr, int k){ int begin = 0, end = arr.size(); assert(k>0 && k<=end); int target_num = 0; while (begin < end){ int pos = partition(arr, begin, end); if(pos == k-1){ target_num = arr[pos]; break; } else if(pos > k-1){ end = pos; } else{ begin = pos + 1; } } return target_num; }
該算法的時間複雜度是多少呢?考慮最壞狀況下,每次 partition 將數組分爲長度爲 N-1 和 1 的兩部分,而後在長的一邊繼續尋找第 K 大,此時時間複雜度爲 O(N^2 )。不過若是在開始以前將數組進行隨機打亂,那麼能夠儘可能避免最壞狀況的出現。而在最好狀況下,每次將數組均分爲長度相同的兩半,運行時間 T(N) = N + T(N/2),時間複雜度是 O(N)。
接下來先考慮這樣一個問題,給定紅、白、藍三種顏色的小球若干個,將其排成一列,使相同顏色的小球相鄰,三種顏色前後順序爲紅,白,藍。這就是經典的 Dutch national flag problem。
咱們能夠針對紅,藍,白三種顏色的球分別計數,而後根據計數結果來從新放球。不過若是咱們將問題進一步抽象,也就是說將一個數組按照某個target值分爲三部分,使得左邊部分的值小於 target,中間部分等於 target,右邊部分大於 target,這樣就不能再用簡單的計數來肯定排序後的結果。這時候,就能夠用到另外一種 partition 算法:three-way-partition。它的思路稍微複雜一點,用三個指針將數組分爲四個部分,經過一次掃描最終將數組分爲 <,=,> 的三部分,以下圖所示:
能夠結合下面代碼來理解具體的邏輯:
// Assume target is in the arr. void three_way_partition(vector<int> &arr, int target){ int next_less_pos = 0, next_bigger_pos = arr.size()-1; int next_scan_pos = 0; while (next_scan_pos <= next_bigger_pos){ if(arr[next_scan_pos] < target){ swap(arr[next_scan_pos++], arr[next_less_pos++]); } else if(arr[next_scan_pos] > target){ swap(arr[next_scan_pos], arr[next_bigger_pos--]); } else{ next_scan_pos++; } } }
這裏的主要思想就是在一遍掃描中,經過交換不一樣位置的數字,使得數組最終能夠維持必定的順序,和前面快排中用到的 partition 思想一致。區別在於快排按照 pivot 將數組分爲兩部分,左右部分中的值均可能等於 pivot,而 three-way-partition 將數組分爲 <, =, >的三部分。
Algorithms 4.0: DemoPartitioning
Dutch national flag problem
Time complexity of quick-sort in detail
Quicksort Partitioning: Hoare vs. Lomuto
數學之美番外篇:快排爲何那樣快
本文由selfboot 發表於我的博客,採用署名-非商業性使用-相同方式共享 3.0 中國大陸許可協議。
非商業轉載請註明做者及出處。商業轉載請聯繫做者本人
本文標題爲:被忽視的 partition 算法
本文連接爲:http://selfboot.cn/2016/09/01...