快速排序是一个比较重要的排序算法,本文中主要讲解快排的递归非递归写法,,两种partition的方法和对快速排序的部分优化
快速排序是对冒泡排序的一种改进,通过一趟排序将排序的数据分割成独立的两个部分,其中一部分的所有数据比另一部分的所有数据都要小,在按这种方法对这两部分数据分别进行快速排序,整个排序的过程是递归进行的,使得整个数据变成有序序列。
其实快排就是一个用基准数,将一个无序序列变成一个两部分整体有序,也就是在基准数之前的都小于基准数,后面的都大于基准数,所以整个算法的关键之处在于将那个基准数找的自己的位置,我这通常称这个过程为一次partition,一次partition后基准数的位置就已经被确定了,我们只需要再次递归的将基准数前的序列快速排序一次,基准数后的序列快速排序一次,这样我们就可以完成快速排序了。
根据上面所说的,快速排序其实很简单,我们用递归写一下,就是找到基准数,对于的基准数我们一般找第一个数,这里确实基准数的选择关系中快速排序的速度问题下面会说这个事情,然后我们partition一下,最后对前序列快速排序,对后序列快速排序即可。看下面代码是不是很容易理解
/** * * @param a 需要排序的数组 * @param low 数组的起点 * @param high 数组的终点 */ public void quickSort(int []a,int low,int high){ int p = partition2(a,low,high); //通过partition找到基准数的位置 if(p > low+1) { quickSort(a, low, p - 1);//对前面的序列快排 } if(p< high -1){ quickSort(a,p+1,high); //对后面的序列快排 } }所以这里的关键就是这个partition函数怎么写了,这里分享两种partition的写法。
一种是严蔚敏课本上的一种解法可能也是大家比较熟悉的一种:
public int partition2(int a[],int low,int high){ int temp = a[low]; //找到基准数 while (low<high){ while (low<high && a[high]>=temp) --high; //向前找一个小于基准数的数 if(low<high){ a[low] = a[high]; //将其放在基准数的前面 } while (low<high && a[low] <= temp) ++low; //向后找一个大于基准数的数 if (low<high){ a[high] = a[low]; //将其放在基准数的后面 } } a[low] = temp; //将基准数放在确定的位置 return low; }实际这是一个什么过程呢?请看下面的图:
第二种的partition操作是一个比较巧妙的操作,只要我们明白partition是做什么的了,直接看代码:
public int partition(int []a,int low,int high){ int i = low; int j = high+1; int x = a[low]; //基准数 int temp = 0; while (true){ while (a[++i]<=x && i<high); //从前向后找大于x数的下标 while (a[--j]>x); //从后向前找小于x的下标 if(i>=j) break; //扫描完一遍。退出循环 //交换找到的两个数 temp = a[i]; a[i] = a[j]; a[j] = temp; } //基准数与a[j]交换 a[low] = a[j]; a[j] = x; return j; }同样我们来看图理解一下:
这两种partition算法都可以的,第二种算法还是比较巧妙的最后都是将基准数把序列划分出两部分。
其实递归的基本算法就是刚开始看见的那几行代码是不是很简单,我们这里要实现一下他的非递归算法:
其实非递归算法就是利用栈来代替掉系统栈所做的操作,整个非递归的思路就是依次将low p-1 p+1 high 这四个关键的位置一直入栈(其中p就是基准数的位置),实现递归中排序的过程。
/** * 快排非递归 * @param a 待排的序列 * @param low 数组的起点 * @param high 数组的终点 */ public void quickSortNonrecusion(int []a,int low ,int high){ int []stack = new int[a.length]; //定义栈 int top = -1; int par = partition(a,low,high); //找到基准数的位置 if (par > low+1){ //让low与par-1入栈 stack[++top] = low; stack[++top] = par-1; } if (par < high-1){ //让par+1与high入栈 stack[++top] = par+1; stack[++top] = high; } while (top!=-1){ //开始循环的解决栈中的四个特殊点 high = stack[top--]; low = stack[top--]; par = partition(a,low,high); if (par > low+1){ stack[++top] = low; stack[++top] = par-1; } if (par < high-1){ stack[++top] = par+1; stack[++top] = high; } } }我们也可以画张图来理解一下这个非递归的算法:
这是刚开始初始化的情况
然后我们依次的将栈顶的两个元素作为新的high和新的low,再次求解partition,直到栈空为止,这样就实现了快速排序的非递归算法了。
对于递归的快速排序我们可以很容易的写出他的递归表达式,在最好的情况下,每次划分所取的基准都恰好为中值,每次划分都产生大小两个为n/2的区域,此时的T(n)为: T ( n ) = { O ( 1 ) n < 1 2 T ( n 2 ) + O ( n ) n ≥ 1 T(n) = \left\{\begin{matrix} O(1) \qquad n<1\\ 2T(\frac{n}{2})+O(n) \qquad n\ge 1 \end{matrix}\right. T(n)={O(1)n<12T(2n)+O(n)n≥1 知道递归表达式我们就是通过主定理法来分析他们时间复杂度,很容易我们可以得知这个的时间复杂度为 O ( n log n ) O(n\log_{}{n} ) O(nlogn)
上面我们仅仅是分析了对于快速排序最好的情况,对于最坏的情况下发生划分过程产生的两个区域分别包含n-1个元素和1个元素的时候,由于函数Partition的时间复杂度为 O ( n ) O(n) O(n),所以如果算法中partition的每一步都出现这种不对称划分,则其计算时间复杂度的T(n)为: T ( n ) = { O ( 1 ) n < 1 T ( n − 1 ) + O ( n ) n ≥ 1 T(n) = \left\{\begin{matrix} O(1) \qquad n<1\\ T(n-1)+O(n) \qquad n\ge 1 \end{matrix}\right. T(n)={O(1)n<1T(n−1)+O(n)n≥1 对于这个表达式,我们可以通过递推的方式求解出他的时间复杂度为 O ( n 2 ) O(n^{2}) O(n2)
由此我们可以基本判断在平均时间复杂度为 O ( n log n ) O(n\log_{}{n} ) O(nlogn),但是在不同的序列中还是可以会出现不对称的划分,所以我们该怎么解决这个问题呢?
其实我们发现导致快速排序不好的因为他的划分不对称,那如何才能将划分对称呢?关键点就是我们基准数的选择上,如果我们每一次都选择的是整个待排序列中中间位置的数。那么对于快排来说,每一次都是对称的划分。下面介绍三种基准划分的方法:
这个其实我们已经很熟悉了,因为上面写的所有代码都是利用这一个来进行划分的,也就是我们选择待排序列的头一个元素或者是最后一个元素。
对于上面的取法我们无法解决掉划分的问题,那么我们可以采用随机找基准的办法,但是在随机情况下未知性太高了。
由于随机基准选取的随机性,使得他并不能适应所有的情况,有可能同一个序列会出现不同的情况,目前最好的方法就是三数取中法。他的做法是:选择数组开头,中间和最后三个元素,通过比较,选择中间的值作为快排的基准值
public int NumberOfThree(int []a,int low,int high){ int mid = (low+high)/2; int temp = 0; if(a[mid] > a[high]){ temp = a[mid]; a[mid] = a[high]; a[high] = temp; } if(a[low] > a[high]){ temp = a[low]; a[low] = a[high]; a[high] = temp; } if (a[mid] > a[low]){ temp = a[mid]; a[mid] = a[low]; a[low] = temp; } return a[low]; }