Java程序员必看的动图解析面试常见排序算法(下)
归并排序
归并排序是分治算法的典型应用.所谓归并即是将两个有序的数组归并成一个更大的有序数组.
它有一个主要的缺点就是它需要额外的空间(辅助数组)并且所需的额外空间和N成正比.
合并过程
-
申请空间,使其大小为两个已有序序列之和,该空间用于存放合并后的序列
-
声明两个指针,最初位置分别为两个有序序列的起始位置
-
比较两个指针所指向的元素,选择相对小的元素放入合并空间中,并移动指针到下一个位置
-
重复步骤3直到某一指针到达序列尾部
-
将另一序列剩下的所有元素直接放入合并序列尾
自顶向下的归并排序
自顶向下即是从顶部化整为零地递归解决问题.
例如:要对数组 a[lo..hi] 进行排序,需要先将它切分为a[lo..mid]与a[mid+1..hi]两部分,分别通过递归调用将它们单独排序,最后将有序的子数组归并为最终的排序结果.
// stably merge a[lo .. mid] with a[mid+1 ..hi] using aux[lo .. hi]
private static void merge(Comparable[] a, Comparable[] aux, int lo, int mid, int hi) {
// copy a[] to aux[]
for (int k = lo; k <= hi; k++) {
aux[k] = a[k];
}
// merge back to a[]
int i = lo, j = mid + 1;
for (int k = lo; k <= hi; k++) {
if (i > mid) {
a[k] = aux[j++];
} else if (j > hi) {
a[k] = aux[i++];
} else if (less(aux[j], aux[i])) {
a[k] = aux[j++];
} else {
a[k] = aux[i++];
}
}
}
// mergesort a[lo..hi] using auxiliary array aux[lo..hi]
private static void sort(Comparable[] a, Comparable[] aux, int lo, int hi) {
if (hi <= lo)
return;
int mid = lo + (hi - lo) / 2;
sort(a, aux, lo, mid);
sort(a, aux, mid + 1, hi);
merge(a, aux, lo, mid, hi);
}
自底向上的归并排序
自底向上则是循序渐进地解决问题.
实现思路是先归并那些微型数组,然后再成对归并得到的子数组,直到将整个数组归并在一起.
可以先进行两两归并(每个元素想象成一个大小为1的数组),然后进行四四归并(将两个大小为2的数组归并成一个有四个元素的数组),然后是八八归并…..(一直下去)在每一轮归并中,最后一次归并的第二个子数组可能比第一个子数组要小,如果不是的话所有归并中两个数组大小都应该一致.
//merge函数与自顶向下中的一致
public static void sort(Comparable[] a) {
int N = a.length;
Comparable[] aux = new Comparable[N];
for (int len = 1; len < N; len *= 2) {
for (int lo = 0; lo < N - len; lo += len + len) {
int mid = lo + len - 1;
int hi = Math.min(lo + len + len - 1, N - 1);
merge(a, aux, lo, mid, hi);
}
}
优化
如果数组很小,那么频繁的递归调用效率会很差,所以可以使用插入排序(或选择排序等)来处理小规模的子数组.
private static void merge(Comparable[] src, Comparable[] dst, int lo, int mid, int hi) {
int i = lo, j = mid + 1;
for (int k = lo; k <= hi; k++) {
if (i > mid) {
dst[k] = src[j++];
} else if (j > hi) {
dst[k] = src[i++];
} else if (less(src[j], src[i])) {
dst[k] = src[j++];
} else {
dst[k] = src[i++];
}
}
}
private static void sort(Comparable[] src, Comparable[] dst, int lo, int hi) {
// if (hi <= lo) return;
if (hi <= lo + CUTOFF) {
insertionSort(dst, lo, hi);
return;
}
int mid = lo + (hi - lo) / 2;
sort(dst, src, lo, mid);
sort(dst, src, mid + 1, hi);
// using System.arraycopy() is a bit faster than the above loop
if (!less(src[mid + 1], src[mid])) {
System.arraycopy(src, lo, dst, lo, hi - lo + 1);
return;
}
merge(src, dst, lo, mid, hi);
}
// using insertion sort handle small array
private static void insertionSort(Comparable[] a, int lo, int hi) {
for (int i = lo; i <= hi; i++) {
for (int j = i; j > lo && less(a[j], a[j - 1]); j--) {
exch(a, j, j - 1);
}
}
}
public static void sort(Comparable[] a) {
Comparable[] aux = a.clone();
sort(aux, a, 0, a.length - 1);
}
快速排序
快速排序又称 划分交换排序
,它也是一种分治的排序算法.
快速排序有一个潜在的缺点,在切分不平衡时这个程序可能会极为低效,所以需要在快速排序前将数组随机排序来避免这种情况.
它将一个数组切分成两个子数组,将两部分独立地排序.它与归并排序不同的地方在于:
-
归并排序将数组分成两个子数组分别排序,最终将有序的子数组归并以致整个数组排序.
-
快速排序将数组排序的方式则是当两个子数组都有序时,整个数组也就是有序的了.
-
在归并排序中,递归调用发生在处理整个数组之前;而在快速排序中,递归调用发生在处理整个数组之后.
-
在归并排序中,一个数组会被等分为两半,而在快速排序中,切分的位置取决于数组的内容.
运行过程
-
先从数列中挑选出一个
基准
,可以为a[lo],它是被确认为排定的元素. -
从数组的左端(左指针)开始向右扫描直到找到一个大于等于
基准
的元素. -
从数组的右端(右指针)开始向左扫描直到找到一个小于等于
基准
的元素. -
这两个元素即是没有排定的,交换它们的位置(保证了左指针i的左侧元素都不大于
基准
,右指针j的右侧元素都不小于基准
). -
.当两个指针相遇时,将
基准
和左子数组最右侧的元素(a[j])交换然后返回j即可.
代码实现
// partition the subarray a[lo..hi] so that a[lo..j-1] <= a[j] <= a[j+1..hi]
// and return the index j.
private static int partition(Comparable[] a, int lo, int hi) {
int i = lo; // left point
int j = hi + 1; // right point
Comparable v = a[lo]; // partition element
while (true) {
// scan left point
while (less(a[++i], v)) {
if (i == hi)
break;
}
// scan right point
while (less(v, a[--j])) {
if (j == lo)
break;
}
// check if point cross
if (i >= j)
break;
exch(a, i, j);
}
// put partition element v to a[j]
exch(a, lo, j);
// now a[lo..j-1] <= a[j] <= a[j+1..hi]
return j;
}
private static void sort(Comparable[] a, int lo, int hi) {
if (hi <= lo)
return;
int j = partition(a, lo, hi);
sort(a, lo, j - 1);
sort(a, j + 1, hi);
}
public static void sort(Comparable[] a) {
shuffle(a);
sort(a, 0, a.length - 1);
}
// random sort an array
private static void shuffle(Object[] a) {
if (a == null)
throw new IllegalArgumentException("array is null.");
Random random = new Random();
int N = a.length;
for (int i = 0; i < N; i++) {
int j = i + random.nextInt(N - i);
Object temp = a[i];
a[i] = a[j];
a[j] = temp;
}
}
三向切分的快速排序
当存在大量重复元素的情况下,快速排序的递归性会使元素全部重复的子数组经常出现,这就有很大的改进潜力,将当前快速排序从线性对数级别的性能提升至线性级别.
一个简单的思路是将数组切分为三部分,分别对应小于、等于、大于切分元素的数组元素.
在实现中,维护一个左指针 lt
使得 a[lo..lt-1]
的元素都小于 基准
,右指针 gt
使得 a[gt+1..hi]
中的元素都大于 基准
,一个指针 i
使得 a[lt..i-1]
中的元素都等于 基准
, a[i..gt]
中的元素都还未确定.
-
a[i]
小于基准
,将a[lt]
和a[i]
交换,lt++&i++. -
a[i]
大于基准
,将a[gt]
和a[i]
交换,gt–. -
a[i]
等于基准
,i++.
以上操作都会保证数组元素不变且缩小 gt-i
的值(这样循环才会结束).除非和切分元素相等,其他元素都会被交换.
// quicksort the subarray a[lo .. hi] using 3-way partitioning
private static void sort(Comparable[] a, int lo, int hi) {
if (hi <= lo)
return;
int lt = lo, i = lo + 1, gt = hi;
Comparable v = a[lo]; // partition element
// a[lo..lt-1] < a[lt..gt] < a[gt+1..hi]
while (i <= gt) {
int cmp = a[i].compareTo(v);
if (cmp < 0) {
exch(a, i++, lt++);
} else if (cmp > 0) {
exch(a, i, gt--);
} else {
i++;
}
}
sort(a, lo, lt - 1);
sort(a, gt + 1, hi);
}
堆排序
堆排序是基于堆的优先队列实现的一种排序算法.
优先队列
优先队列是一种支持删除最大(最小)元素和插入元素的数据结构,它的内部是有序的,任意优先队列都可以变成一种排序方法.
堆
堆是一种数据结构,它通常可以被看作为一棵树的数组对象.将根节点作为最大数的叫做最大堆,反之,将根节点作为最小数的叫做最小堆.
堆是一个近似完全二叉树的结构,同时又满足了堆的性质:每个元素都要保证大于(小于)等于它的子节点的元素.
在一个堆中,根据根节点的索引位置不同,计算父节点与子节点位置的算法也不同.
-
当数组起始位置为0时,位置k的节点的父节点为
(k-1)/2
,它的两个子节点为2k+1
,2k+2
. -
当数组起始位置为1时(即不使用索引0),位置k的节点的父节点为
k/2
,它的两个子节点为2k
,2k+1
.
为了保证堆有序,需要支持两个操作用于打破堆的状态,然后再遍历堆并按照要求将堆的状态恢复,这个过程叫做堆的有序化.
- 由下至上的堆有序化(上浮) : 如果堆的有序状态因为某个节点变得比它的父节点更大而被打破时,那么就需要通过交换它和它的父节点来修复堆,将这个节点不断向上移动直到遇到了一个更大的父节点.(如果是最小堆,比较的逻辑相反).
// 在本文中,均不使用数组的0索引
private void swim(int k) {
while (k > 1 && less(k/2, k)) {
exch(a,k, k/2);
k = k/2;
}
}
- 由上至下的堆有序化(下沉) : 如果堆的有序状态因为某个节点变得比它的两个子节点或是其中之一更小了而被打破时,需要通过将它和它的两个子节点中的较大者交换来修复堆,将这个节点向下移动直到它的子节点都比它更小或是到达了堆的底部.(如果是最小堆,比较的逻辑想法)
// n为数组长度
private void sink(int k) {
while (2*k <= n) {
int j = 2*k;
if (j < n && less(j, j+1)) j++;
if (!less(a[k],a[j])) break;
exch(a,k, j);
k = j;
}
}
运行过程
堆排序可以分为两个阶段.
-
堆的构造阶段,将原始数组重新组织安排进一个堆中.从右至左用sink()函数,构造子堆,数组的每个位置都已经是一个子堆的根节点.只需要扫描数组中的一半元素,因为我们可以跳过大小为1的子堆.最后在位置1上调用sink()函数,结束扫描.
-
下沉排序阶段,从堆中按递减顺序取出所有元素并得到排序结果.将堆中的最大元素删除,然后放入堆缩小后数组中空出的位置.
代码实现
public static void sort(Comparable[] a) {
int N = a.length;
// construction max heap
for (int k = N / 2; k >= 1; k--) {
sink(a, k, N);
}
// sink sort
while (N > 1) {
// the biggest element (root) swap smallest element then heap shrink
exch(a, 1, N--);
// new root element sink
sink(a, 1, N);
}
}
private static void sink(Comparable[] pq, int k, int n) {
while (2 * k <= n) {
int j = 2 * k;
if (j < n && less(pq, j, j + 1))
j++;
if (!less(pq, k, j))
break;
exch(pq, k, j);
k = j;
}
}
private static boolean less(Comparable[] pq, int i, int j) {
return pq[i - 1].compareTo(pq[j - 1]) < 0;
}
private static void exch(Object[] pq, int i, int j) {
Object swap = pq[i - 1];
pq[i - 1] = pq[j - 1];
pq[j - 1] = swap;
}
总结
在大多数实际情况中,快速排序是最佳选择.如果稳定性很重要而空间又不是问题的情况下,归并排序可能是最好的,但是在运行时间至关重要的任何排序应用中应该认真地考虑使用快速排序.
在 JDK 中,Arrays.sort() 选择了根据不同的参数类型,来使用不同的排序算法.如果是原始数据类型则使用三向切分的快速排序,对引用类型则使用归并排序.