分而治之是计算机领域非常常用的一种思想。在排序中,将数组拆分成不同的组,此为分,每组数据分别在各自组内进行排序,此为治。分治可以很好的利用多处理器的并行计算能力,提高排序效率。今天介绍两种基于分治思想的经典排序算法: 快速排序和 归并排序。
快速排序
快速排序的基本思路是,首先选取一个基准值,然后根据基准值,将数组拆分为左右两部分,使得基准值左侧的元素,都比基准值小,右侧的元素,都比基准值大。随后,对左右两部分数组进行同样的操作:选取基准值,做划分处理。一直分到不能再分,数组就整体有序了。每经过一轮排序,该轮基准元素的位置就会被确定下来。
图解如下
假设准备对如下数组进行快速排序
先随机选取一个元素作为基准值,假设选中的是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;
}
}