若是你学习过算法,那么确定据说过快速排序的大名,可是对于快速排序中用到的 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...