快速排序优化-双路快速排序法 Quick Sort 2 Ways

当用快速排序算法对有大量重复数据的数组进行排序的时,快速排序算法的效率会非常低。

这里我们来做一个测试,用快速快速排序和归并排序,对一个100万大小的有大量重复数据的数组(数组中所有的元素都是0到10的数据)进行排序。测试结果如下:

QuickSort: 1000000 true 31678ms

MergeSort: 1000000 true 134ms

从上面的测试结果可以看出,快速排序的效率比归并排序的效率低特别多。

似乎此时快速排序的算法又退化到了O(n^2)的级别,这是为什么呢,我们来分析一下这个问题。

下面这张图是现在Partition过程中,面对每一个元素"e"要执行的操作,我们是要看元素“e”是大于"v"还是小于"v",然后将它分别放到不同的位置,这样将整个数组分成两个部分,然后再对每一部分递归下去,进行快速排序。

但是要注意,上面的Partition过程中,我们没有讨论如果“e”等于“v”会怎么样。这里可以回想一下上面实现Partition的代码,代码中是如果"e<v"的话把"e"放到橙色部分中,否则的话把"e"放到紫色部分中。相当于代码中隐藏的是如果"e=v"的话,也会被放到紫色的部分中,相当于紫色的部分包含大于等于"v"的元素。

当然了也可以非常轻松的修改代码中的判断条件,把等于"v"的元素放到橙色部分中,相当于橙色的部分包含小于等于"v"的元素。

这里可以想到,不管是把小于等于"v"的元素放到橙色部分,还是把大于等于"v"的元素放到橙色部分,当我们整个数组中包含有大量重复键值的时候,Partition过程都非常有可能把整个数组分成极度不平衡的两个部分,这是因为对于每个键值来说重复的元素太多了,我们选的键值稍微有一点不平衡的话,两部分的差距就会特别的大。即使"v"选在了一个平衡的位置上,但是由于等于"v"的元素也非常多,一样会导致整个数组被分成了两个极其不平衡的部分。那么在这种情况下我们的快速排序就会退化成O(n^2)级别的算法。

如何解决这个问题呢?这里提出一个解决方案,我们换一个思路来实现Partition的过程。

之前我们将"<v"和">v"两个部分都放在数组的左边,"i"从左到右直至遍历完整个数组。新的思路是我们将"<v"和">v"两个部分放在数组的两端,这样就需要一个新的索引“j”,来记录">v"部分下一个要扫描的元素位置。

比如说我们扫描到下面这个状态,接下来要干什么呢?

首先我们从"i"这个 位置开始向后扫描,当扫描到的元素仍然是小余"v"的话,继续向后扫描,直到碰到一个元素"e"大于等于"v","i"索引停止扫描。对于索引"j"也是同样的操作,我们从"j"这个位置开始向前扫描,当扫描到的元素大于"v"的话,继续向前扫描,直到碰到一个元素"e"小余等于"v","j"索引停止扫描。得到如下图的状态。

上图中两个绿色的部分,分别合并到橙色和紫色的部分。此时数组处于如下的状态。

这时候"i"和"j"所指的元素,只需要交换一下位置,就可以了。

此时橙色的部分都是小于"v"的元素,紫色部分都是大于"v"的元素。然后"i"索引向后移动到下一个待查看的元素,"j"索引向前移动到下一个待查看的元素。直到"i"和"j"两个索引重合

分析上面的流程,会发现上面这张图是有问题的,黄色的部分其实是"<=v"的,紫色的部分其实是">=v"的。

这种Partition的方式所分成的两部分,和之前最大的区别就是把等于"v"的元素,分散到了左右两部分。如果"i"和"j"两个索引指向的元素都等于"v"的,在上面的逻辑里两个元素仍然要交换位置,这样就不会存在大量等于"v"的元素,集中在橙色部分或者是集中在紫色部分。正因为如此,这样的Partition算法,当面临大量重复键值的情况,也能非常好的将它们近乎平分开来。

代码实现如下:

package com.zeng.sort;

public class QuickSortTwo {
	
	public void quickSort2(int[] arr){
		quickSort2(arr, 0, arr.length - 1);
	}
	
	private void quickSort2(int[] arr, int left, int right){
		if(right - left <= 15){
			insertionSort(arr, left, right);
			return;
		}
		int p = partition2(arr, left, right);
		quickSort2(arr, left, p - 1);
		quickSort2(arr, p + 1, right);
	}
	
    /**
     * 双路快速排序的partition
     * @param arr
     * @param left
     * @param right
     * @return 返回值p,使得两个arr[left...p-1]<=arr[p],arr[p+1...right]>=arr[p]
     */
	private int partition2(int[] arr, int left, int right){
		//随机取一个元素和第一个元素交换位置,优化快速排序
		swap(arr, left, (int)(Math.random() * (right - left + 1)) + left);
		int v = arr[left];
		//定义两个索引i和j,分别区分两个部分[left+1,i)>=v,(j,right]>=v
		//给两个索引赋值,得到两个空的部分
		int i = left + 1, j = right;
		while(true){
			//这里判断arr[i] < v,而不是arr[i] <= v,是为了让重复的元素分布的更均匀一些
			while(i <= right && arr[i] < v) i++;
			//这里判断arr[j] > v,而不是arr[j] >= v,是为了让重复的元素分布的更均匀一些
			while(j >= left + 1 && arr[j] > v) j--;
			if(i > j) break;
			swap(arr, i, j);
			i++;
			j--;
		}
		swap(arr, left, j);
		return j;
	}
	
	/**
	 * 插入排序算法,对数组中子数组[left, right]进行排序.
	 * @param arr
	 * @param left
	 * @param right
	 */
	private void insertionSort(int[] arr, int left, int right){
		for(int i = left + 1; i <= right; i ++){
			int e = arr[i];
			int j = i;
			for(; j > left && arr[j - 1] > e; j --){
				arr[j] = arr[j - 1];
			}
			arr[j] = e;
		}
	}
	
	/**
	 * 交换数组中两个元素的值
	 * @param arr
	 * @param i
	 * @param j
	 */
	private void swap(int[] arr, int i, int j){
		int temp = arr[i];
		arr[i] = arr[j];
		arr[j] = temp;
	}
}

优化后的测试结果如下:

对一百万条随机的数据进行排序

MergeSort: 1000000 true 196ms

QuickSort: 1000000 true 100ms

QuickSort2: 1000000 true 105ms

 

对一百万条近乎有序的数据进行排序

MergeSort: 1000000 true 49ms

QuickSort: 1000000 true 38ms

QuickSort2: 1000000 true 33ms

 

对一百万条拥有大量重复元素的数据进行排序

MergeSort: 1000000 true 151ms

QuickSort: 1000000 true 31010ms

QuickSort2: 1000000 true 46ms

 

从上面的测试结果可以看出,普通的快速排序法对拥有大量重复数据的数组进行排序时,效率非常低下,时间复杂度近乎是O(n^2)。但是使用双路快速排序实现的话,效率得到了极大的优化,甚至比归并排序的效率还要高很多。

这里我们就想,如果我们选中的标识元素"v"有大量和它相等的元素,这些元素是不是都能不参与下一轮的快速排序呢?答案是可以的,下一遍我们将介绍 三路快速排序法 Quick Sort 3 Ways。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值