排序黄金双雄——归并排序&快速排序
序
上一篇讲到了冒泡&选择&插入排序,从有序的定理角度分析了三种排序算法的原理。基本思想就是遍历所有元素,让所有元素满足有序的定理。 所以冒泡&选择&插入排序逃不过双重循环,第一层循环遍历每一个元素,第二层循环和其他元素比较遍历,渐近时间复杂度都为O(n^2)。本篇介绍将介绍归并排序&快速排序,依旧从有序的定理出发解析这两个算法的原理及实现。本文的重点在小结部分,本文的重点在小结部分,本文的重点在小结部分,重要的事情说三遍!!! 文章较长建议收藏起来慢慢看,会让你所有收获。
1、 归并排序
1.1、原理
归并排序(Merge Sort)所遵循的有序定理是命题1(任何一个元素不小于其存在的左边元素。)。核心思想是将数组分为两大部分A&B,A中的所有元素总小于B中的元素,AB两部分满足命题1,再将AB两部分分别再以同样的方式分A’&B’,重复该操作直至A&B两部分中都仅存一个元素 。这样每一个元素都满足命题1,整个数组有序。
Merge操作是归并的核心操作,目的在于将数组分为两大部分A&B,A中的所有元素总小于B中的元素。下面是Merge的代码实现:
private static void merge(Comparable<?>[] a, int lo, int mid ,int hi) {
int i = lo, j = mid+1;
for(int k = lo; k <= hi; k++) aux[k] = a[k];
//注意:++,先赋值计算后自加!!!
for(int k = lo; k <= hi; k++)
if (i > mid) a[k] = aux[j++]; //左半边用尽,取右半边元素
else if (j > hi) a[k] = aux[i++]; //右半边用尽,取左半边元素
else if (less(aux[j], aux[i])) a[k] = aux[j++]; //左半边元素小于右半边元素,取左半边元素
else a[k] = aux[i++]; //右半边元素小于左半边元素,取右半边元素
}
- 自顶向下的归并排序:
首先介绍自顶向下的归并,假设存在如下图的长度为10的乱序数组。首先分为两大部分AB,并交换其中的元素使A中的元素总小于B中的元素,称该操作为Merge。
执行第一次Merge有如下效果:
蓝色区域A中的任意元素均小于红色区B的元素。接着对蓝色A区域的部分Merge,分半向下取整。对红色B区域的部分同理Merge。
两部分Merge后的整体效果如下:
第三次Merge已经可以到单独元素了,原理同上不做赘述。
最后一次Merge得到有序的数组。
至此自顶向下的归并排序算法结束。为什么叫自顶向下呢?注意第一个分AB的Merge操作,对象是整个数组,然后重复操作直至单个元素。 - 自底向上的归并排序:
自底向上的归并排序和自顶向下想法相反,先从每一个元素开始Merge。假设一个长度为10的乱序数组如下:
两两配对并Merge,得到如下的效果:
接着以1×2个元素作为最小单元两两Merge,得到如下的效果:
然后以2×2个元素作为最小单元两两Merge,得到如下的效果:
最后以2×4作为最小单元Merge,结束归并排序:
1.2 Java实现
package sort;
public class MergeSort extends AbstractSort{
/*
* -归并算法-
* 一种分治的思想,由多化少,步骤繁琐但单一步骤极简。
* 需要开辟数组,并不是原地交换式。
* 时间复杂度:O(NlgN)
*/
public MergeSort() {
}
private static Comparable<?>[] aux;
//最小递归构成(核心)
private static void merge(Comparable<?>[] a, int lo, int mid ,int hi) {
int i = lo, j = mid+1;
for(int k = lo; k <= hi; k++) aux[k] = a[k];
//注意:++,先赋值计算后自加!!!
for(int k = lo; k <= hi; k++)
if (i > mid) a[k] = aux[j++]; //左半边用尽,取右半边元素
else if (j > hi) a[k] = aux[i++]; //右半边用尽,取左半边元素
else if (less(aux[j], aux[i])) a[k] = aux[j++]; //左半边元素小于右半边元素,取左半边元素
else a[k] = aux[i++]; //右半边元素小于左半边元素,取右半边元素
}
//自顶向下递归,递归从顶部向下解开去分治
public static void sort(Comparable<?>[] a, int lo, int hi) {
if (hi <= lo) return;
int mid = lo+(hi-lo)/2;
sort(a, lo, mid);
sort(a, mid+1, hi);
merge(a, lo, mid, hi);
}
//自低向上非递归,从1开始,倍数合并,最后凑出完整数组
public static void sort(Comparable<?>[] a) {
int N = a.length;
aux = new Comparable[N];
for (int sz = 1; sz <N; sz = sz+sz) {
for (int lo=0; lo<N-sz; lo += sz+sz) {
merge(a, lo, lo+sz-1, Math.min(lo+sz+sz-1, N-1));
}
}
}
public static void main(String[] args) {
Integer[] a = {2,4,5,8,53,3,14,24,35,23,45};
// aux = new Comparable[a.length];
// MergeSort.sort(a, 0, a.length-1);
MergeSort.sort(a);
show(a);
System.out.println(isSort(a));
}
}
1.3 性能分析
对于长度为N的任意数组,归并排序需要1/2NlgN~NlgN次比较,渐近时间复杂度为O(NlgN),空间复杂度N,不属于原地排序。
2、 快速排序
2.1、原理
快速排序(Quick Sort)所遵循的有序定理是命题3(有序数组其任何一个元素不小于其左边元素且不大于右边元素。)。核心思想是Partition操作:将数组由一个元素a切分为两部分AB,a大于A中的任意元素且小于B中的任意元素,选取第二个元素a在A部分中,再将a’以同样方式插入A的A’&B’两部分,对B执行相同操作;重复该操作直至A&B两部分中都仅存一个元素 。这样每一个元素都满足命题3,整个数组有序。
Partition操作是归并的核心操作,目的在于把数组的第一个元素a插入切分好的两大部分A&B,A中的所有元素总小于a并且B中的元素总大与a。下面是Partition的代码实现:
//寻找和第一个元素互换的位置
public static int partition(Comparable<?>[] a, int lo, int hi) {
int i = lo, j = hi + 1;
Comparable<?> v = a[lo];
while(true) {
//寻找左边比v大的那一位,记录到i,停止
while(less(a[++i], v)) if(i == hi) break;
//寻找右边比v小的那一位,记录到j,停止
while(less(v, a[--j])) if(j == lo) break;
if (i>=j) break;
//a[i]>v>a[j],所以要交换i和j的元素
exch(a, i, j);
}
exch(a, lo, j);
return j;
}
下面以图示方式讲解Partition的流程。以如下数组为例,选取第一个元素a(红色),从左至右找违背小于a的元素然后停止并标记,从右至左寻找违背大于a的元素然后标记并停止,我把它称作“冲突”。
交换两个违背的绿色元素,继续寻找直至下一次“冲突”,然后交换“冲突”的两个元素。
继续寻找冲突,发现两个箭头相遇了,这个时候停止寻找,把蓝色箭头的和元素a交换。
这个时候以红色元素a所在的位置将数组切分为两部分,前一部分A的任意元素小于a并且后一部分B的元素大于a,对a来说满足有序的命题3。
理解Partition操作后再来看快速排序,快速排序类似于归并都运用了分治的思想,不停的拆分整体让小部分满足有序,最后拆分到小部分是单个元素时就意味着每个元素都满足有序。那么接着上面的数组继续以图示说明,对A与B部分再次Partiton:
这里额外说一点,可以看到A部分中的第一个元素是插入到了最后,这种情况是不是越来越像冒泡了?对!这个隐患会导致快排退化成冒泡。 后面在性能上继续介绍。接着继续递归深入Partition。中间过程不再展示了(懒),最后就排序完成了。
2.2 Java实现
package sort;
public class QuickSort extends AbstractSort{
/*
* -快速排序-
* 是一种分治算法,类似于归并排序,但不需要开辟数组!!
*
* 根据排序问题的命题3:
* 有序数组其任何一个元素不小于其左边元素且不大于右边元素。
* 由顶自下,让一个元素满足命题3,递归下去,则所有元素满足命题3
*/
public QuickSort() {
}
//寻找和第一个元素互换的位置
public static int partition(Comparable<?>[] a, int lo, int hi) {
int i = lo, j = hi + 1;
Comparable<?> v = a[lo];
while(true) {
//寻找左边比v大的那一位,记录到i,停止
while(less(a[++i], v)) if(i == hi) break;
//寻找右边比v小的那一位,记录到j,停止
while(less(v, a[--j])) if(j == lo) break;
if (i>=j) break;
//a[i]>v>a[j],所以要交换i和j的元素
exch(a, i, j);
}
exch(a, lo, j);
return j;
}
public static void sort(Comparable<?>[] a) {
sort(a, 0, a.length-1);
}
public static void sort(Comparable<?>[] a, int lo, int hi) {
if (hi<=lo) return;
int p = partition(a, lo, hi);
sort(a, lo, p-1);
sort(a, p+1, hi);
}
public static void main(String[] args) {
Integer[] a = {12,23,24,34,21,23,57,34,67,84,52,78,52,58,83};
QuickSort.sort(a);
show(a);
System.out.println(isSort(a));
}
}
2.3 性能分析
对于长度为N的任意数组,快速排序平均需要2NlgN次比较,渐近时间复杂度为O(NlgN),空间复杂度lgN,属于原地排序。 之前的隐患也看到了,快排在极端情况下有可能退化成冒泡排序,也就是说快排最多需要N*N/2次比较,所以快排前随机打乱数组是最廉价的预防手段。
小结
归并&快排都是效果不错的排序方法,其中快排是各大API中常用sort方法,确实快排有很多的优势,不仅实现简单思路也清晰。理解这两个算法思路是关键,更重要的理解由数学命题作为切入点而设计算法的思想。这两个算法的思想抽象出来就是:让子数组满足有序,然后分解到更小的子数组,递归下去直至子数组的长度为1,即每个元素都满足了有序。 归并排序选择的命题切入点是命题1——任何一个元素不小于其存在的左边元素。(效果上和命题2——有序数组其任何一个元素不大于其右边的元素,一致),快速排序选择的命题切入点是命题3——有序数组其任何一个元素不小于其左边元素且不大于右边元素。相比较之前的冒泡&选择&插入排序三兄弟,归并和冒泡&选择排序选择了一样的切入点(命题1和命题2),快速排序和插入排序一样选择了命题3作为切入点。归并和快排在分治思想的加持下性能有了显著提升。所以思想是一个算法的灵魂!也决定了算法的高度!