快速排序可能是应用最为广泛的排序算法了。应用广泛的主要原因是因为它实现简单,并且在一般应用中比其他算法要快得多。并且它具有良好的特性,他的时间复杂度是O(nlogn)。空间复杂度是O(1),也就是它可以进行原地排序,他只有递归调用产生的空间。
原理:
快速排序是一种分治的排序算法。他根据一个标杆元素,将一个数组分成两个部分,使得左边的部分的元素小于等于标杆元素,而右边的部分大于标杆元素。然后在左右两边的数组中重复这个步骤。这样递归下去,最终,左边的部分是有序的,右边的部分也是有序的,加上中间的标杆,所以,整个数组就是有序的了。
我们发现,按照各个思路,快速排序算法的标杆的选择非常的重要。如果标杆选择不恰当,选择了当中最小最大或者偏最小偏最大的数,都会导致算法非常浪费时间。如果每次都选到了最小的或者最大值,那么快速排序和选择排序没有什么区别了,都是每次只能固定一个数字的位置。
代码如下:
public static void sort(Comparable[] a) {
// 随机打乱数组
for (int i = 0; i < a.length / 2; i++) {
swap(a, i, (int) (Math.random() * a.length));
}
sort(a, 0, a.length - 1);
}
public static void sort(Comparable[] a, int low, int high) {
if (low >= high)
return;
int partition = partition(a, low, high);
sort(a, low, partition - 1); // 排序标杆的左边数组
sort(a, partition + 1, high); // 排序标杆的右边数组
}
public static int partition(Comparable[] a, int low, int high) {
int i = low;
int j = high + 1;
Comparable compare = a[low];
while (true) {
while (less(a[++i], compare)) // 找到左边数组应该放到右边的元素
if (i == high)
break;
while (less(compare, a[--j])) // 找到右边数组应该放到左边的元素
if (j == low)
break;
if (i >= j)
break;
swap(a, i, j); // 将找到的元素交换位置
}
swap(a, j, low); // 最后将标杆放到中间的部分
return j;
}
这里重点的代码就是如何划分。我们使用两个指针,一个在左边的最左开始,从左到右遍历。一个在右边最右开始,从右到左遍历。当左边找到比标杆大的元素,右边找到比标杆小的元素,就将这两个元素互相交换位置。直到两个指针相遇,就将标杆元素值和左边最右的元素交换位置。
这里需要注意的是,左边最右的元素是右边遍历过来的,当右指针遇到左边最右的元素就会停下来。
而我们在开头为什么需要打乱数组呢?因为我们不知道这个数组的实际情况,如果输入的数组是已排好序的,我们用第一个元素作为标杆的话,会使得快排树全部分散到一边,退化成交换排序,就达到最差的情况了,这并不是我们希望看到的。所以我们会先打乱数组。
我们可以对快排进行一些小小的优化:
1.对于小规模的数组,采取插入排序的方法。
2.采用三采样,选取3个点,可以随机的选取,也可以固定的选取开头,中间,结尾。选择三个点中中间的值作为标杆,可以对数组的拆分有一个不错的提升。
3.采用随机数选取标杆的办法。
代码修改如下:
public static void sort(Comparable[] a) {
sort(a, 0, a.length - 1);
}
public static void sort(Comparable[] a, int low, int high) {
if (high - low <= 7) { // 改进1
InsertSort.sort(a, low, high + 1);
return;
}
int partition = partition2(a, low, high);
sort(a, low, partition - 1);
sort(a, partition + 1, high);
}
public static int partition(Comparable[] a, int low, int high) {
// 改进3
swap(a, low, (int)(Math.random() * (high - low) + low));
int i = low;
int j = high + 1;
Comparable compare = a[low];
while (true) {
while (less(a[++i], compare))
if (i == high)
break;
while (less(compare, a[--j]))
if (j == low)
break;
if (i >= j)
break;
swap(a, i, j);
}
swap(a, j, low);
return j;
}
这上面的代码采用了第一种和第三种改进方法。并且我们注意到,在这里我们并不需要打乱数组了。因为我们的标杆是随机选择的,所以打乱数组其实并没有必要,只有当你标杆获取的位置是固定的时候,才需要打乱数组。因为打乱数组的成本其实并不廉价。而对于改进二和改进三。我个人认为随机选取标杆已经是非常不错的方法了。使用三采样的方法选取的标杆并不一定比随机选取的标杆要好多少。而花费的比较次数却是大大增加了。而对于JAVA来说,Comparable的比较开销是比较大的,如果是内置类型,采取三采样的办法也不错。而对于C来说,就更不是问题了。
我们来比较一下改进前和改进后,已经前篇中比较的改进后的Merge排序所花费的时间:
public static void main(String[] args) {
// Scanner sc = new Scanner(System.in);
// String[] a = sc.nextLine().split(" ");
final int NUM = 10000000;
Integer[] a1 = new Integer[NUM];
Integer[] a2 = new Integer[NUM];
Integer[] a3 = new Integer[NUM];
Integer[] a4 = new Integer[NUM];
for (int i = 0; i < NUM; i++) {
// a1[i] = (int) (Math.random() * NUM);
double temp = Math.random();
if (temp > 0.5) {
a1[i] = i;
} else {
a1[i] = (int) (Math.random() * NUM);
}
a2[i] = a1[i];
a3[i] = a1[i];
a4[i] = a1[i];
}
long startTime;
long endTime;
startTime = System.currentTimeMillis(); // 获取开始时间
QuickSort.sort1(a1);
assert isSorted(a1);
endTime = System.currentTimeMillis();
System.out.println("快速排序cost: " + (endTime - startTime) + " ms");
startTime = System.currentTimeMillis(); // 获取开始时间
QuickSort.sort2(a2);
assert isSorted(a2);
endTime = System.currentTimeMillis();
System.out.println("快速排序改良cost: " + (endTime - startTime) + " ms");
startTime = System.currentTimeMillis(); // 获取开始时间
MergeSort.sort2(a3);
assert isSorted(a3);
endTime = System.currentTimeMillis();
System.out.println("Merge排序改良cost: " + (endTime - startTime) + " ms");
}
这里排序的数组是一部分有序,一部分随机的。如果是全部随机的话,快速排序就更快了。而如果接近有序的情况下,归并排序比快速排序是快很多的。
部分有序,部分随机:
快速排序cost: 5709 ms
快速排序改良cost: 3048 ms
Merge排序改良cost: 4366 ms
完全随机:
快速排序cost: 6168 ms
快速排序改良cost: 4490 ms
Merge排序改良cost: 5544 ms
有序:
快速排序cost: 3826 ms
快速排序改良cost: 634 ms
Merge排序改良cost: 114 ms
对于存在大量重复元素的情况来说,快排和归并排序的速度都不是让人满意的,虽然他们花费的时间并不长,但是相对来说也不是非常快。例如上面完全有序的情况,快排还需要600多ms,这就有了很大的改进空间。我们可以使用三向切分的方式来进行partition操作。
显然,这样就能快速的减小递归的次数,因为递归两端的数组的长度减小了很多。
但是在平常的没有大量重复的元素的应用中,这样做反而会变慢,因为交换的次数多了很多。
这里我们就不展开了。
快排最大的问题就在于标杆的选择,只要能够在很快的时间内将标杆选择好,那么快排的性能是让人惊喜的。