阅读 198

排序算法——基于分治思想的排序

分而治之是计算机领域非常常用的一种思想。在排序中,将数组拆分成不同的组,此为分,每组数据分别在各自组内进行排序,此为治。分治可以很好的利用多处理器的并行计算能力,提高排序效率。今天介绍两种基于分治思想的经典排序算法:快速排序归并排序

快速排序

快速排序的基本思路是,首先选取一个基准值,然后根据基准值,将数组拆分为左右两部分,使得基准值左侧的元素,都比基准值小,右侧的元素,都比基准值大。随后,对左右两部分数组进行同样的操作:选取基准值,做划分处理。一直分到不能再分,数组就整体有序了。每经过一轮排序,该轮基准元素的位置就会被确定下来。

图解如下

假设准备对如下数组进行快速排序

先随机选取一个元素作为基准值,假设选中的是5

那么把数组分成大于5的部分,和小于5的部分,结果如下

橙色部分表示已被确定位置的基准值元素

随后对5左侧,5右侧的部分,做同样的操作

假设被标记为蓝色的元素是被选中的基准值

根据基准值,将各自部分的元素,分成左右两部分,结果如下

右半边,由于没有比基准值6小的元素,故只存在右半部分

继续拆分,假设蓝色的元素为被选中的基准值

进行拆分,得到

已经拆到不能再分,整个数组有序

用大白话来讲,就是每轮选取一个基准值,用基准值将数组切成两半,左半边是较小的元素,右半边是较大的元素,一直切分到左右半边不能再切,就完成排序。即,一分为二,二分为四,四分为八。一分为二之后,左右两部分的排序互不影响,故可以利用多处理器的资源,进行并行运算,二分为四同理。此为分而治之。所以快排可以很好的利用多处理器资源,利用并行计算来提高排序效率。

当然,基准值的选取对快排非常重要,若选择了糟糕的基准值。无法将数组较好地平均分为左右两部分,则快排的效率会急剧下降。

最坏的情况是,每轮排序都选取了当前数组部分的最值,使得划分的结果,只存在右半部分或左半部分,此时的快速排序便退化成了冒泡排序。

常见的基准选取方式包括:

  • 取第一个元素
  • 随机选取
  • 三数取中

若数组已经有序,并且每次是选取第一个元素作为基准,那么此时快排的效率是极其糟糕的。故一般会随机选取一个元素作为基准。而三数取中法,则是取左端,中间,右端三个数,对这三个数进行排序后,取中间位置的数作为基准,以期待能够较为平均地将数组分为左右两部分。

来看一看快排的实现思路,以最简单的基准选取方式——取第一个元素作为基准,来进行讲解

取首元素为基准

首先选取当前数组第一个元素作为基准

随后,根据基准来切分数组。切分数组主要还是基于比较和交换,这里的切分思路有两种,一种是双边循环,即使用左右两个指针,从左右两端往中间走,交替与基准值进行比较并交换;还有一种是单边循环,单向地从左往右逐渐扩大左侧部分的边界。单边循环的实现更易理解,并且代码更简洁。故采用单边循环的方式来进行图示讲解。

定义一个标志变量mark,用于标记左侧部分的边界,mark的初始位置为基准元素的位置,另外定义一个指针pos,用于从左往右进行遍历,这个指针的初始位置在mark的后一个位置,如图所示

接着,比较pos所指向的元素与基准值的大小,若其比基准值大,则将pos右移一位,继续下一轮比较;若其比基准值小,则先将mark加1,并交换pos和mark指向的两个元素,后将pos右移一位,继续下一轮比较。

用大白话讲,就是,pos指针从左依次往右移动,遍历整个数组,只要发现了比基准值小的,就将其交换到最左侧,并扩大左侧部分的边界(mark值增大),相当于不断地把小于基准的元素交换并堆积到左侧。当pos遍历完整个数组后,将基准值和mark指向的元素进行交换,即完成数组的切分。

各个步骤的图解如下:

第一轮,pos指向2,发现2小于基准值4,则先将mark+1,后交换mark和pos所指向的元素

然后pos往后移动一位,继续处理下一个元素

第二轮,pos指向8,8大于基准值4,则该轮直接结束。继续右移pos

第三轮,pos指向5,5大于基准值4,继续右移pos

第四轮,pos指向7,7大于基准值4,继续右移pos

第五轮,pos指向1,1小于基准值4,则mark+1,并交换pos和mark

继续右移pos

第六轮,pos指向3,3小于基准值4,则mark+1,交换mark和pos

继续右移pos

第七轮,pos指向9,9大于基准值4,继续右移pos

第八轮,pos指向6,6大于基准值4,继续右移pos。

遍历完毕,最后交换mark和基准值4

第一趟排序完毕,根据基准值4成功将数组切分成了左侧小于4的部分,和右侧大于4的部分。

接下来只需要将左右两部分数组看成独立的数组,重复上面的过程,直到数组被切分到不能再分,即完成排序,可以考虑用递归来实现,代码如下

	public void quickSort(int[] array) {
		quickSortInternal(array, 0, array.length - 1);
	}

	/**
	 * 对指定部分的数组进行快速排序
	 * **/
	private void quickSortInternal(int[] array,int left, int right) {
		/* 递归退出条件 */
		if (left >= right) {
			return ;
		}
		//取第一个位置作为基准
		int pivot = array[left];
		int mark = left;
		int pos = mark + 1;
		/*  */
		while (pos <= right) {
			if (array[pos] < pivot) {
				swap(array, ++mark, pos);
			}
			pos++;
		}
		swap(array, left, mark);
		/* 该轮排序结束, 对左右部分进行相同操作, 采用递归方式 */
		quickSortInternal(array, left, mark - 1);
		quickSortInternal(array, mark + 1, right);
	}
复制代码

随机选取基准

由于选取第一个元素作为基准,很容易造成数组切分不均匀,故进行优化,改用随机选取一个元素,代码和上面大同小异

	public void quickSort(int[] array) {
		quickSortInternal(array, 0, array.length - 1);
	}

	/**
	 * 对指定部分的数组进行快速排序
	 * **/
	private void quickSortInternal(int[] array,int left, int right) {
		/* 递归退出条件 */
		if (left >= right) {
			return ;
		}
		//随机选取一个元素作为基准,并和第一个元素交换
        
        //从left到right之间,随机选取一个位置
        int pivotPos = (new Random()).nextInt(right - left) + left + 1;
        //将被选中的基准元素换到第一个位置
        swap(array, pivotPos, left);
        
		int pivot = array[left];
		int mark = left;
		int pos = mark + 1;
		/*  */
		while (pos <= right) {
			if (array[pos] < pivot) {
				swap(array, ++mark, pos);
			}
			pos++;
		}
		swap(array, left, mark);
		/* 该轮排序结束, 对左右部分进行相同操作, 采用递归方式 */
		quickSortInternal(array, left, mark - 1);
		quickSortInternal(array, mark + 1, right);
	}
复制代码

三数取中法

随机选取基准元素的方式仍然不够好,我们考虑用三数取中的方法来进行优化,选取基准元素时,先取左端,中间,右端3个元素,取大小排在3个元素的中间的那一个,作为基准,将其交换到数组的首位,后续步骤与前面所述完全一致,来看一下代码实现

	public void quickSort(int[] array) {
		quickSortInternal(array, 0, array.length - 1);
	}

	/**
	 * 对指定部分的数组进行快速排序
	 * **/
	private void quickSortInternal(int[] array,int left, int right) {
		/* 递归退出条件 */
		if (left >= right) {
			return ;
		}
		//用三数取中法选取一个元素作为基准,并和第一个元素交换
        
        //根据三数取中,获取基准元素的下标
        int pivotPos = getMidPos(array, left, right);
        //将被选中的基准元素换到第一个位置
        swap(array, pivotPos, left);
        
		int pivot = array[left];
		int mark = left;
		int pos = mark + 1;
		/*  */
		while (pos <= right) {
			if (array[pos] < pivot) {
				swap(array, ++mark, pos);
			}
			pos++;
		}
		swap(array, left, mark);
		/* 该轮排序结束, 对左右部分进行相同操作, 采用递归方式 */
		quickSortInternal(array, left, mark - 1);
		quickSortInternal(array, mark + 1, right);
	}
	/**
	 * 对左中右,三个数,进行排序,并返回中间位置的数的下标
	 * **/
	private int getMidPos(int[] array, int left, int right) {
		int mid = (left + right) >>> 1;
		int[] pos = {left, mid, right};
		for (int i = 1; i < pos.length; i++) {
			int j = i;
			while (j - 1 >= 0 && array[pos[j]] < array[pos[j - 1]]) {
				swap(array, pos[j], pos[j - 1]);
				j--;
			}
		}
		return mid;
	}
复制代码

性能测试

当测试数组的规模很大时,采用递归实现的快速排序,会出现StackOverflow异常,故改用非递归实现,进行测试,非递归实现的思路需要引入一个栈,用其来存储每一轮排序的起始位置和终止位置,下面给出选取首元素为基准的非递归代码实现,其余基准选取方式类似

	public void quickSort(int[] array) {
		quickSortInternal(array, 0, array.length - 1);
	}

	/**
	 * 对指定部分的数组进行快速排序
	 * **/
	private void quickSortInternal(int[] array,int left, int right) {
        //用Deque来模拟栈
		Deque<Pair<Integer,Integer>> stack = new LinkedList<>();
		stack.push(new Pair<>(left, right));
        //当栈非空时,执行循环
		while (!stack.isEmpty()) {
            //弹出栈顶元素,获取该轮排序的起止位置
			Pair<Integer, Integer> pop = stack.pop();
			int l = pop.getLeft();
			int r = pop.getRight();
			if (l >= r) {
				continue;
			}
			//取第一个位置作为基准
			int pivot = array[l];
			int mark = l;
			int pos = mark + 1;
			/*  */
			while (pos <= r) {
				if (array[pos] < pivot) {
					swap(array, ++mark, pos);
				}
				pos++;
			}
			swap(array, l, mark);
			/* 该轮排序结束, 对左右部分进行相同操作, 采用非递归方式 */
			stack.push(new Pair<>(l, mark - 1));
			stack.push(new Pair<>(mark + 1, r));
		}
	}

//其中用了一个自定义的类 Pair
public class Pair<L,R> {

		private L left;

		private R right;

		public Pair(L left, R right) {
			this.left = left;
			this.right = right;
		}

		public L getLeft() {
			return left;
		}

		public R getRight() {
			return right;
		}
}
复制代码

性能测试的结果如下面折线图所示

可见选取首元素为基准的,性能要明显差于随机选取法,和三数取中法。上图由于选取首元素的耗时太长,无法看出随机选取法和三数取中法的性能差异,故单独对二者进行性能测试,绘制折线图如下

可见三数取中法的性能要略优于随机选取法

归并排序

归并排序是一种建立在归并操作上的排序算法,它是典型的分而治之思想的体现。其基本思路是,先对待排序数组进行分区,比如一个大小为8的数组,先对它进行分区,一分为二,二分为四,四分为八

当已经分到不能再分(分区只有一个元素),我们可以认为这个分区是有序的。随后便可以对分区进行两两合并

随后,每个分区变为了2个元素,且各个分区内部都是有序的,我们便可以对相邻分区再次进行两两合并

随后,每个分区变为了4个元素,且各个分区内部都是有序的,再次对相邻分区进行合并

只有1个分区,排序结束,数组整体有序

归并排序的代码实现有2种方式:递归实现,非递归实现

先来说代码逻辑较为简单的递归实现:先将数组平均分为左右两个部分,然后对左右两部分数组,递归调用归并排序算法,逻辑上可简单理解为,先把左右两个分区排好序,然后再对左右两个有序分区进行合并操作。关于分区合并的图解说明如下

左半分区的数组下标范围是[left, mid],右半分区的下标范围是[mid + 1, right]

先定义2个指针,分别指向2个分区的第一个位置

比较posL和posR指向的元素,将较小者拿出来,放到另一个辅助数组中,第一轮被拿出来的是posL指向的1

随后posL右移一位,继续比较posL和posR

发现2小于5,则将2拿出来,放到下面数组,posL继续右移

发现4小于5,将4放到下面数组,posL继续右移

发现5小于7,将5放到下面数组,posR右移

发现6小于7,将6放到下面数组,继续右移posR

发现7小于8,将7放到下面数组,右移posL

发现posL已经超出左半部分的边界mid,即左半分区元素已经全部插入完毕,则只需要将右半分区的剩余元素,挨个插入到下方数组即可,posR当前指向8,则挨个插入8和9

如此,便完成一趟归并,成功将2个有序分区合并成1个有序分区

递归实现

代码如下

	public void mergeSort(int[] array) {
		int[] arrayCopy = new int[array.length];
		mergeSort(array, 0, array.length - 1, arrayCopy);
	}

	private void mergeSortInternal(int[] array, int left, int right, int[] arrayCopy) {
		//递归退出条件
        if (left >= right) {
			return ;
		}
        //取中间位置
		int mid = (left + right) >>> 1;
		//递归调用,逻辑上立即为,先让左右分区的元素排好序
		mergeSortInternal(array, left, mid, arrayCopy);
		mergeSortInternal(array, mid + 1, right, arrayCopy);
		//开始对左右两个有序分区进行合并
		int posL = left, posR = mid + 1;
		int i = left;
		while (posL <= mid && posR <= right) {
			if (array[posL] < array[posR]) {
				arrayCopy[i++] = array[posL++];
			} else {
				arrayCopy[i++] = array[posR++];
			}
		}
		//若右半部分元素用完了,则左半部分依次插入
		while (posL <= mid) {
			arrayCopy[i++] = array[posL++];
		}
        //若左半部分元素用完了,则右半部分依次插入
		while (posR <= right) {
			arrayCopy[i++] = array[posR++];
		}
		//将该次归并的结果,写回到原数组
		for (int j = left; j <= right; j++) {
			array[j] = arrayCopy[j];
		}
	}
复制代码

注意到归并排序,在对分区进行合并时,需要一个额外的数组来暂存合并的结果

递归实现的思想比较简单,注意到,递归调用到最深处,也是从大小为1的分区开始两两合并,2个大小为1的分区,合并为1个大小为2的分区,2个大小为2的分区,合并为1个大小为4的分区.....

非递归实现

下面介绍非递归的实现

我们可以发现,归并总是从大小为1的分区开始进行的,则可以这样考虑。我们用分区大小作为循环变量,先从分区大小为1开始,执行相邻分区两两合并,再将分区大小变为2,执行合并,再将分区大小变为4,执行合并....写成代码如下

	public void mergeSortNonRecursive(int[] array) {
		int[] temp = new int[array.length];
		for (int blockSize = 1; blockSize <= array.length; blockSize *= 2) {
			for (int i = 0; i < array.length; i += 2 * blockSize) {
				//对当前区块进行合并
				int left = i;
				int mid = (i + blockSize) < array.length ? (i + blockSize) : array.length;
				int right = (i + 2 * blockSize) < array.length ? (i + 2 * blockSize) : array.length;
				int posL = left, posR = mid;
				int k = left;
				while (posL < mid && posR < right) {
					if (array[posL] < array[posR]) {
						temp[k++] = array[posL++];
					} else {
						temp[k++] = array[posR++];
					}
				}
				while (posL < mid) {
					temp[k++] = array[posL++];
				}
				while (posR < right) {
					temp[k++] = array[posR++];
				}
			}
			//一轮结束,整个数组进行了一趟归并
            //准备扩大区块的大小,进行下一趟归并
			//交换2个数组,进行下一轮排序
			int[] p = array;
			array = temp;
			temp = p;
		}
	}
复制代码