常见排序算法总结(二)
常见排序算法总结(二)
排序就是将一组对象按照某种逻辑顺序重新排列的过程
本篇文章的程序代码基本结构如下:
import java.util.Scanner;
public class Example {
public static void sort(int[] a) {
//TODO
//编写排序算法
}
private static boolean less(int v, int w) {
return v < w;
}
private static void exch(int[] a, int i, int j) {
int t = a[i];
a[i] = a[j];
a[j] = t;
}
private static boolean isSorted(int[] a) {
//测试数组元素是否有序
for (int i = 1; i < a.length; i++) {
if (less(a[i], a[i-1])) {
return false;
}
}
return true;
}
private static void show(int[] a) {
for (int i = 0; i < a.length; i++) {
System.out.print(a[i] + " ");
}
System.out.println();
}
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int n = in.nextInt();
int[] a = new int[n];
for (int i = 0; i < n; i++) {
a[i] = in.nextInt();
}
in.close();
sort(a);
assert isSorted(a);
show(a);
}
}
快速排序
它可能是应用最广泛的排序算法了
快速排序流行的原因是它实现简单、适用于各种不同的输入数据且在一般应用中比其他排序算法都要快的读。快速排序引人注目的特点包括它是原地排序(只需要一个很小的辅助栈),且将长度为N的数组排序所需的时间和NlogN
成正比。另外,快速排序的内循环比大多数排序算法都要短小,这意味着它无论是在理论上还是实际中都要更快。
它的主要缺点是非常脆弱,在实现时要非常小心才能避免低劣的性能。
基本算法
快速排序是一种分治的排序算法。它将一个数组分成两个子数组,将两部分独立的排序。快速排序和归并排序是互补的:归并排序将数组分为两个子数组分别排序,并将有序的子数组归并以将整个数组排序;而快速排序将数组排序的方式则是当两个子数组都有序时整个数组也就自然有序了。在第一种情况种,递归调用发生在处理整个数组之前;在第二种情况种,递归调用发生在处理整个数组之后。在归并排序中,一个数组被等分为两半;在快速排序中,切分的位置取决于数组的内容。
public static void sort(int[] a) {
sort(a, 0, a.length - 1);
}
private static void sort(int[] a, int low, int high) {
if (high <= low) {
return;
}
int j = partition(a, low, high); //切分
sort(a, low, j -1); //将左半部分a[low..j-1]排序
sort(a, j + 1, high); //将右半部分a[j+1..high]排序
}
快速排序递归地将子数组a[low..high]排序,先用partition()方法将a[j]放到一个合适的位置,然后再用递归调用将其他位置的元素排序。
该方法的关键在于切分,这个过程使得数组满足下面三个条件:
- 对于某个j,a[j]已经排定;
- a[low] 到 a[j-1] 中的所有元素都不大于a[j]
- a[j+1] 到 a[high] 中的所有元素都不小于a[j]
我们就是通过递归地调用切分来排序的。
因为切分过程总能排定一个元素,用归纳法不难证明递归能够正确地将数组排序:如果左子树组和右子数组都是有序的,那么由左子树组(有序且没有任何元素大于切分元素)、切分元素和右子数组(有序且没有任何元素小于切分元素)组成的结果数组也一定是有序的。
要完成这个实现,需要实现切分方法。一般策略是先随意地取a[low]作为切分元素,即那个将会被排定的元素,然后我们从数组的左端开始向右扫描直到找到一个大于等于它的元素,再从数组的右端开始向左扫描直到找到一个小于等于它的元素。这两个元素显然是没有排定的,因此我们交换它们的位置。如此继续,我们就可以保证左指针i的左侧都不大于切分元素,右指针j的右侧元素都不小于切分元素。当两个指针相遇时,我们只需要将切分元素a[low]和左子树最右侧的元素(a[j])交换然后返回j即可。
private static int partition(int[] a, int low, int high) {
//数组切分为 a[low...i-1] a[i] a[i+1...high]
int i = low, j = high + 1; // 左右扫描指针
int v = a[low]; // 切分元素
while (true) {
//扫描左右,检查扫描是否结束并交换元素
while (less(a[++i], v)) {
if (i == high) {
break;
}
}
while (less(v, a[--j])) {
if (j == low) {
break;
}
}
if (i >= j) {
break;
}
exch(a, i, j);
}
exch(a, low, j); // v = a[j]放入正确的位置
return j; // a[low..j-1] <= a[j] <= a[j+1..high]达成
}
这段代码按照a[low]的值v进行切分。当指针i和j相遇时主循环退出。在循环中,a[i]小于v时我们增大i,a[j]大于v时我们减小j,然后交换a[j]和a[j]来保证i左侧的元素都不大于v,j右侧的元素都不小于v。当指针相遇时交换a[low]和a[j],切分结束(这样切分值就留在a[j]中了)。
注意事项
- 原地切分。如果使用一个辅助数组,我们可以很容易实现切分,但将切分后的数组复制回去的开销也许会使我们得不偿失。
- 别越界。如果切分元素是数组中最小或最大的那个元素,我们就要小心别让扫描指针跑出数组的边界。
- 终止循环。正确地检测指针是否越界需要一点技巧,并不像看上去那么容易。一个最常见的错误是没有考虑到数组中可能包含和切分元素的值相同的其他元素。
- 处理切分元素值有重复的情况。如上述算法所示,左侧扫描最好在遇到大于等于元素值的元素时停下,右侧扫描时则是遇到小于等于切分元素值的元素时停下。尽管这样可能会不必要地将一些等值的元素交换,但在某些经典应用中,它能够避免算法的运行时间变为平方级别。
- 终止递归。保证递归总是能够结束也是需要小心的,快速排序也不例外。例如,实现快速排序时最常见的错误就是不能保证将切分元素放入正确的位置,从而导致程序在切分元素正好是子数组的最大或最小元素时陷入了无限的递归循环中。