不少人提起快排和二分都以为很容易的样子,可是让现场Code不少就翻车了,就算能够写出个递归版本的代码,可是对其中的复杂度分析、边界条件的考虑、非递归改造、代码优化等就无从下手,填鸭背诵基本上分分钟就被面试官摆平了。ios
快速排序Quicksort又称划分交换排序partition-exchange sort,简称快排,一种排序算法。最先由东尼·霍尔(C. A. R. Hoare)教授在1960年左右提出,在平均情况下,排序n个项目要O(nlogn)次比较。
在最坏情况下则须要O(n^2)次比较,但这种情况并不常见。事实上,快速排序一般明显比其余算法更快,由于它的内部循环能够在大部分的架构上颇有效率地达成。
在计算机科学中,分治法(Divide&Conquer)是建基于多项分支递归的一种很重要的算法范式,快速排序是分治思想在排序问题上的典型应用。git
所谓分治思想D&C就是把一个较大规模的问题拆分为若干小规模且类似的问题。再对小规模问题进行求解,最终合并全部小问题的解,从而造成原来大规模问题的解。github
字面上的解释是"分而治之",这个技巧是不少高效算法的基础,如排序算法(归并排序、快速排序)、傅立叶变换(快速傅立叶变换)。面试
分治法中最重要的部分是循环递归的过程,每一层递归有三个具体步骤:算法
查尔斯·安东尼·理查德·霍尔爵士(Sir Charles Antony Richard Hoare缩写为C. A. R. Hoare,1934年1月11日-),昵称为东尼·霍尔(Tony Hoare),生于大英帝国锡兰可伦坡(今斯里兰卡),英国计算机科学家,图灵奖得主。
他设计了快速排序算法、霍尔逻辑、交谈循序程式。在操做系统中,他提出哲学家就餐问题,并发明用来做为同步程序的监视器(Monitors)以解决这个问题。他同时证实了监视器与信号标(Semaphore)在逻辑上是等价的。
1980年获颁图灵奖、1982年成为英国皇家学会院士、2000年由于他在计算机科学与教育方面的杰出贡献,得到英国王室颁赠爵士头衔、2011年获颁约翰·冯诺依曼奖,现为牛津大学荣誉教授,并在剑桥微软研究院担任研究员。
快速排序使用分治法来把一个序列分为小于基准值和大于基准值的两个子序列。数组
递归地排序两个子序列,直至最小的子序列长度为0或者1,整个递归过程结束,详细步骤为:架构
#include<stdio.h> int a[9]={5,1,9,6,7,11,3,8,4}; void exchange(int *p,int *q){ int temp=*p; *p=*q; *q=temp; } int quicksort(int left,int right){ if(left>=right){ return 0; } int i,j,temp; temp=a[left]; i=left; j=right; while(i!=j){ while(i<j&&a[j]>=temp){ j--; } exchange(&a[i],&a[j]); while(i<j&&a[i]<=temp){ i++; } exchange(&a[i],&a[j]); } quicksort(i+1,right); quicksort(left,i-1); } int main(){ quicksort(0,8); for(int i=0;i<=8;i++){ printf("%d ",a[i]); } }
1 #include<iostream> 2 using namespace std; 3 4 template <typename T> 5 void quick_sort_recursive(T arr[], int start, int end) { 6 if (start >= end) 7 return; 8 T mid = arr[end]; 9 int left = start, right = end - 1; 10 //整个范围内搜寻比枢纽值小或大的元素,而后左侧元素与右侧元素交换 11 while (left < right) { 12 //试图在左侧找到一个比枢纽元更大的元素 13 while (arr[left] < mid && left < right) 14 left++; 15 //试图在右侧找到一个比枢纽元更小的元素 16 while (arr[right] >= mid && left < right) 17 right--; 18 //交换元素 19 std::swap(arr[left], arr[right]); 20 } 21 //这一步很关键 22 if (arr[left] >= arr[end]) 23 std::swap(arr[left], arr[end]); 24 else 25 left++; 26 quick_sort_recursive(arr, start, left - 1); 27 quick_sort_recursive(arr, left + 1, end); 28 } 29 30 //模板化 31 template <typename T> 32 void quick_sort(T arr[], int len) { 33 quick_sort_recursive(arr, 0, len - 1); 34 } 35 36 int main() 37 { 38 int a[9]={5,1,9,6,7,11,3,8,4}; 39 int len = sizeof(a)/sizeof(int); 40 quick_sort(a,len-1); 41 for(int i=0;i<len-1;i++) 42 cout<<a[i]<<endl; 43 }
两个版本都可正确运行,但代码有一点差别:并发
过程提及来比较抽象,稳住别慌!灵魂画手大白会画图来演示这两个过程。dom
以第一次
递归循环为例:ide
步骤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,图中之因此放到一块儿是为了直观表达终止条件。
分析一下:
我的以为这个版本虽然一样使用D&C思想可是更加简洁,从动画能够看到选择pivot=a[end],而后左右指针分别从index=0和index=end-1向中间靠拢。
过程当中扫描目标值并左右交换,再继续向中间靠拢,直到相遇,此时再根据a[left]和a[right]以及pivot的值来进行合理置换,最终实现基于pivot的左右子序列形式。
脑补场景:
上述过程让我以为很像统帅命令左右两路军队从两翼会和,而且在会和过程当中消灭敌人有生力量(认为是交换元素),直到两路大军会师。
此时再将统帅王座摆到正确的位置,此过程当中没有统帅王座的反复变换,只有最终会师的位置,以王座位中心造成了左翼子序列和右翼子序列。
再重复相同的过程,直至完成大一统。
脑补不过瘾 因而凑图一张:
吃瓜时间:
印象中2017年初换工做的时候去CBD一家公司面试手写快排,我就使用C++模板化的版本二实现的,可是面试官质疑说这不是快排,争辩之下让咱们彼此都以为对方很Low,因而很快就把我送出门SayGoodBye了^_^。
我想表达的意思是,虽然快排的递归版本是基于D&C实现的,可是因为pivot值的选择不一样、交换方式不一样等诸多因素,形成了多种版本的递归代码。
而且内层while循环里面判断>=仍是>(便是否等于的问题),外层循环判断本序列循环终止条件等写法都会不一样,所以在写快排时切忌死记硬背,要否则边界条件判断不清楚很容易就死循环了。
看下上述我贴的两个版本的代码核心部分:
//版本一写法 while(i!=j){ while(i<j&&a[j]>=temp){ j--; } exchange(&a[i],&a[j]); while(i<j&&a[i]<=temp){ i++; } exchange(&a[i],&a[j]); } //版本二写法 while (left < right) { while (arr[left] < mid && left < right) left++; while (arr[right] >= mid && left < right) right--; std::swap(arr[left], arr[right]); }
覆盖or交换:
代码中首先将pivot的值引入局部变量保存下来,这样就认为A[L]这个位置是个坑,能够被其余元素覆盖,最终再将pivot的值填到最后的坑里。
这种作法也没有问题,由于你只要画图就能够看到,每次坑的位置是有相同元素的位置,也就是被备份了的元素。
我的感受 与其叫坑不如叫备份,可是若是你代码使用的是基于指针或者引用的swap,那么就没有坑的概念了。
这就是覆盖和交换的区别,本文的例子都是swap实现的,所以没有坑位被最后覆盖一次的过程。
所谓迭代实现就是非递归实现通常使用循环来实现,咱们都知道递归的实现主要是借助系统内的栈来实现的。
若是调用层级过深须要保存的临时结果和关系会很是多,进而形成StackOverflow栈溢出。
Stack通常是系统分配空间有限内存连续速度很快,每一个系统架构默认的栈大小不同,笔者在x86-CentOS7.x版本使用ulimit -s查看是8192Byte。
避免栈溢出的一种办法是使用循环,如下为笔者验证的使用STL的stack来实现的循环版本,代码以下:
#include <stack> #include <iostream> using namespace std; template<typename T> void qsort(T lst[], int length) { std::stack<std::pair<int, int> > mystack; //将数组的首尾下标存储 至关于第一轮循环 mystack.push(make_pair(0, length - 1)); while (!mystack.empty()) { //使用栈顶元素然后弹出 std::pair<int,int> top = mystack.top(); mystack.pop(); //获取当前须要处理的子序列的左右下标 int i = top.first; int j = top.second; //选取基准值 T pivot = lst[i]; //使用覆盖填坑法 而不是交换哦 while (i < j) { while (i < j and lst[j] >= pivot) j--; lst[i] = lst[j]; while (i < j and lst[i] <= pivot) i++; lst[j] = lst[i]; } //注意这个基准值回填过程 lst[i] = pivot; //向下一个子序列进发 if (i > top.first) mystack.push(make_pair(top.first, i - 1)); if (j < top.second) mystack.push(make_pair(j + 1, top.second)); } } int main() { int a[9]={5,1,9,6,7,11,3,8,4}; int len = sizeof(a)/sizeof(int); qsort(a,len); for(int i=0;i<len-1;i++) cout<<a[i]<<endl; }
因为篇幅缘由,目前文章已经近6000字,所以笔者决定将快排算法的优化放到另一篇文章中,不过能够提早预告一下会有哪些内容: