前言
相信同学们对排序算法都不陌生,排序作为基础算法的重要部分,同时也是面试的高频考点。如果你了解过排序算法,那么一定见过下面这张图:
今天我们就从这张图讲起,主要从每一个排序算法的构建思路以及具体实现展开。非线性排序算法中,快排、插排、堆排以及归并排序是面试高频考点,本文会重点讲解。线性排序由于都需要在特定场景下使用,因为不在本文重点关注范围内,仅作粗略的描述。在完成所有常用算法解析后,笔者还会对 Arrays.sort 的源码进行拆解,深入理解工业级的排序算法设计逻辑。
非线性排序
交换排序
-
冒泡排序
主要思想:通过两两比较,如果前一位数较大则交换,逐步交换到最后一位,每一轮至少排定一个最大数。除此之外,还可以在每次遍历过程中设置一次标记,当数组有序时结束排序流程,这样在一些特殊场景上可以大幅提高算法效率。
具体实现:- 首先外循环遍历整个数组(从右至左),内循环则从左至右进行元素交换
- 设置标记点,若数组已全部排定,提前退出循环
public class BubbleSort { /** * 冒泡排序 */ public static int[] sort(int[] nums) { int j = nums.length-1; while (j > 0) { int i = 0; boolean flag = true; for (;i < j;i++) { if (nums[i] > nums[i+1]) { int tmp = nums[i]; nums[i] = nums[i+1]; nums[i+1] = tmp; flag = false; } } if (flag) { break; } j--; } return nums; } /** * 测试 */ public static void main(String[] args) { System.out.println("---------------原数组---------------"); int[] array = UniversalMethod.createArray(Constant.DEFAULT_ARRAY_LENGTH); UniversalMethod.printArray(array); System.out.println("\n"); System.out.println("---------------排序数组---------------"); sort(array); UniversalMethod.printArray(array); } }
注意:UniversalMethod.createArray()仅仅是笔者工程内部一个随机生成数组的工具方法,同学们在自测的时候可以自行选择任意方式进行测试,下同。
-
快速排序
主要思想:快排同样是一种交换排序,但它利用分治思想大大优化了冒泡排序过程中的内循环部分。其核心在于设立一个基准点(pivot),每一轮都查找出基准点左侧大于基准点的值v1,以及基准点右侧小于等于基准点的值v2,然后交换这两个值,若干轮次后数组便排定。最好的情况是每一轮的基准点刚好位于排序段内的中点,那么时间复杂度将为O(NlogN);如果遇到最差的情况,即数组本身有序,每一轮选择出的基准点带有极大的倾斜性,则时间复杂度退化为O(N^2)。
具体实现:- 设置基准点 pivot(这里以最左侧数值作为基准点),设置左右两侧指针i, j,设置左边界left,有边界right;
- 将左指针 i 移动到大于 pivot 的第一个位置,将右指针 j 移动到 小于 pivot 的第一个位置
- 交换左右指针指向的元素
- 交换右指针和基准点所在位置,按照右指针将数组拆分为两段
- 对拆分的数组递归上述过程,直到 左侧边界 大于等于 右侧边界
public class QuickSort { /** * 快速排序 */ public static void quickSort(int[] nums, int left, int right) { if (left >= right) { return; } int pivot = left; int i = left, j = right; while (i < j) { while (i < j && nums[j] > nums[pivot]) { j--; } while (i < j && nums[i] <= nums[pivot]) { i++; } UniversalMethod.swap(nums, i, j); } UniversalMethod.swap(nums, j, pivot); quickSort(nums, left, j-1); quickSort(nums, j+1, right); } /** * 测试 */ public static void main(String[] args) { System.out.println("---------------原数组---------------"); int[] array = UniversalMethod.createArray(Constant.DEFAULT_ARRAY_LENGTH); UniversalMethod.printArray(array); System.out.println("\n"); System.out.println("---------------排序数组---------------"); quickSort(array, 0, array.length-1); UniversalMethod.printArray(array); } }
注意:上面提到过在顺序数组中若使用快排,那么基准点就无法均匀的切割数组,会使得快排效率大大降低,本文中采用的是固定基准点的策略,事实上可以引入 Random 随机选择基准点,这样可以将极端情况发生的概率大大降低。
插入排序
-
简单插入排序
主要思想:简单排入排序,即我们通常所说的插入排序,它并没有采取像冒泡排序一般的暴力比较策略,而是每轮迭代都将当前元素(假设当前元素下标为i)插入到下标在 [0, i] 范围内符合序列要求的位置。不难发现,插排的思想与冒泡排序正好相反,它主要通过确定左侧已排定数组,这样做的好处是在通常情况下,比较次数减少,使得内循环的复杂度线性系数较小。
具体实现:- 设置外循环,遍历整个列表
- 将当前元素同前面所有元素进行比较,选择合适的位置进行插入
- 迭代上述过程,完成排序
public class InsertSort { /** * 插入排序 */ public static void insertSort(int[] nums) { for (int i = 1;i < nums.length;i++) { int j = i-1; while (j >= 0 && nums[j] > nums[j+1]) { UniversalMethod.swap(nums, j, j+1); j--; } } } /** * 测试 */ public static void main(String[] args) { System.out.println("---------------原数组---------------"); int[] array = UniversalMethod.createArray(Constant.DEFAULT_ARRAY_LENGTH); UniversalMethod.printArray(array); System.out.println("\n"); System.out.println("---------------排序数组---------------"); insertSort(array); UniversalMethod.printArray(array); } }
注意:这里的UniversalMethod.swap()同样是一个工具方法,仅用于交换元素,同学们可以自己实现,实现起来也相当简单,此处不再赘述,下同。
-
希尔排序
主要思想:把待排序的数列按照一定的增量分割成多个子数列。但是这个子数列不是连续的,而是通过前面提到的增量,按照一定相隔的增量进行分割的,然后对各个子数列进行插入排序,接着增量逐渐减小,然后仍然对每部分进行插入排序,在减小到1之后直接使用插入排序处理数列。
具体实现:
希尔排序并不作为本文的重点,感兴趣的同学可以关注这篇文章学习。
选择排序
-
简单选择排序
主要思想:选择排序同样是一种利用内循环暴力搜索的算法,它每一轮迭代都会查找出最值至于一侧,外循环迭代完成后自然排序完成。选择排序实现简单,但效率奇差,因此只作理解。
具体实现:- 设置外循环遍历整个数组;
- 内循环查找出最值并记录下标,将最值和未排定数组的最右侧或最左侧元素交换
- 迭代上述过程,完成排序
public class SelectSort { /** * 选择排序 */ public static int[] selectSort(int[] nums) { for (int i = nums.length-1;i >= 0;i--) { int maxValue = nums[i]; int maxIdx = i; for (int j = 0;j <= i;j++) { if (nums[j] > maxValue) { maxValue = nums[j]; maxIdx = j; } } UniversalMethod.swap(nums, maxIdx, i); } return nums; } /** * 测试 */ public static void main(String[] args) { System.out.println("---------------原数组---------------"); int[] array = UniversalMethod.createArray(Constant.DEFAULT_ARRAY_LENGTH); UniversalMethod.printArray(array); System.out.println("\n"); System.out.println("---------------排序数组---------------"); selectSort(array); UniversalMethod.printArray(array); } }
-
堆排序
主要思想:堆排序并没有什么特别之处,它利用二叉堆这一数据结构提高内循环效率,因为堆的元素查找复杂度为O(LogN)。但建堆的过程也会有消耗,因此小规模数据不推荐使用这一方法。
具体实现:- 利用数组构建二叉堆(上浮操作);
- 设置外循环,然后依据当前元素,下沉维护整个堆
public class MinHeap { /** * 上浮 */ public void shiftUp(int[] heap, int endIdx) { if (heap == null) { throw new RuntimeException(Constant.HEAP_IS_NULL); } int endVal = heap[endIdx]; while (endIdx > 0 && heap[(endIdx - 1) / 2] > endVal) { heap[endIdx] = heap[(endIdx - 1) / 2]; endIdx = (endIdx - 1) / 2; } heap[endIdx] = endVal; } /** * 下沉 */ public void shiftDown(int[] heap, int startIdx, int endIdx) { if (heap == null || heap.length == 0) { throw new RuntimeException(Constant.HEAP_IS_NULL_AND_LENGTH_ZERO); } int startVal = heap[startIdx]; while (startIdx * 2 + 1 <= endIdx) { int child = startIdx * 2 + 1; if (child + 1 <= endIdx && heap[child] > heap[child+1]) { child++; } if (heap[child] < startVal) { heap[startIdx] = heap[child]; startIdx = child; }else { break; } } heap[startIdx] = startVal; } /** * 堆排序 */ public int[] heapSort(int[] array) { int[] heap = new int[array.length]; for (int i = 0;i < array.length;i++) { heap[i] = array[i]; shiftUp(heap, i); } int endIdx = heap.length-1; while (endIdx > 0) { int tmp = heap[0]; heap[0] = heap[endIdx]; heap[endIdx] = tmp; shiftDown(heap, 0, --endIdx); } return heap; } /** * 测试 */ public static void main(String[] args) { int[] array = UniversalMethod.createArray(Constant.DEFAULT_ARRAY_LENGTH); MinHeap minHeap = new MinHeap(); System.out.println("---------------原数组---------------"); UniversalMethod.printArray(array); System.out.println("\n"); System.out.println("---------------排序数组---------------"); int[] sortedArray = minHeap.heapSort(array); UniversalMethod.printArray(sortedArray); } }
归并排序
主要思想:归并排序同快排一样也采用了分而治之的思想,但不同的是,归并排序采取硬切割的策略,将数组两两拆分,直到数组元素数量只有一个为止,将拆分的最小单位进行两两对比并交换,然后向上合并每个单元【这是归并的含义】,最终完成排序。
具体实现:
- 初始化临时数组,用于存储每个切割单元比较合并后的结果;
- 二分数组,并开启递归;
- 将切割单元进行内部排序,然后对左右两个切割单元进行合并,保存在临时数组中;
- 左右边界重合或左边界大于有边界时递归结束,完成排序
public class MergeSort {
public int[] template;
/**
* 初始化并排序
*/
public int[] initAndSorted(int[] nums) {
template = new int[nums.length];
mergeSort(nums, 0, nums.length-1);
return template;
}
/**
* 归并排序
*/
public void mergeSort(int[] nums, int left, int right) {
if (left >= right) {
return;
}
int mid = left + (right - left) / 2;
mergeSort(nums, left, mid);
mergeSort(nums, mid+1, right);
int i = left, j = mid+1; // i为左半数组的指针,j为右半数组的指针
int k = 0; // k 表示template数组的指针
while (i <= mid && j <= right) {
if (nums[i] <= nums[j]) {
template[k++] = nums[i++];
}else {
template[k++] = nums[j++];
}
}
// 边界处理
while (i <= mid) {
template[k++] = nums[i++];
}
while (j <= right) {
template[k++] = nums[j++];
}
// 将归并后的子组结果合入nums原数组
for (int n = 0;n < right - left + 1;n++) {
nums[n+left] = template[n];
}
}
/**
* 测试
*/
public static void main(String[] args) {
MergeSort mergeSort = new MergeSort();
System.out.println("---------------原数组---------------");
int[] array = UniversalMethod.createArray(Constant.DEFAULT_ARRAY_LENGTH);
UniversalMethod.printArray(array);
System.out.println("\n");
System.out.println("---------------排序数组---------------");
int[] sortedArray = mergeSort.initAndSorted(array);
UniversalMethod.printArray(sortedArray);
}
}
线性排序
计数排序
主要思想:计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。简单来说,就是先算出数组的最值以确定桶的大小,然后对同中的键位进行填充(频数统计),然后再还原有序数组。
具体实现:
感兴趣的同学可以去看这篇文章进行学习。
桶排序
主要思想:桶排序的核心就在于构建一个函数使得数组所有元素能够尽量均匀的分布在各个桶中,然后在每个桶中进行排序,最后还原有序数组。本质上计数排序就是一种桶排序,只是采用了单值作为分桶的标准。
具体实现:
感兴趣的同学可以去看这篇文章进行学习。
基数排序
主要思想:基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。
具体实现:
感兴趣的同学可以去看这篇文章进行学习。
Arrays.sort()源码解析
讲完了十大排序算法,终于来到文本的核心部分——Arrays.sort(),我相信,在座的Java选手一定用过这个方法,不管是在项目中抑或是在算法练习中,但我也相信,99%的同学应该没有仔细阅读过它的源码,今天我就替大家来读一遍,顺便来翻译翻译,什么叫tm的Arrays.sort()?
惊喜就是进入 Arrays 源码文件,你会看到 Arrays 其实调用了 DualPivotQuicksort 中的 sort 方法进行排序:
public static void sort(int[] a) {
DualPivotQuicksort.sort(a, 0, a.length - 1, null, 0, 0);
}
我们发现这一函数有许多参数构成,依次为待排序数组、待排序部分左侧边界、待排序部分右侧边界以及三个用于归并排序的参数。组略的看过下面这段代码后不难发现这是一种混合排序:即在小样本或者非高度结构化的情况下使用插入排序或快速排序,其他情况则使用归并排序。
注意:高度结构化是根据源码中的注释直接翻译的,笔者认为结构化的含义其实就是是否大体呈升序状态或者降序状态,即凹凸点不能过多【可以理解为把数组按顺序绘制成折线图,每个峰值所在处可以认为是一个凹凸点】
static void sort(int[] a, int left, int right,
int[] work, int workBase, int workLen) {
// -------------------------------------预处理部分-------------------------------------
if (right - left < QUICKSORT_THRESHOLD) {
sort(a, left, right, true); // 内含快速排序及插入排序部分
return;
}
int[] run = new int[MAX_RUN_COUNT + 1];
int count = 0; run[0] = left;
for (int k = left; k < right; run[count] = k) {
if (a[k] < a[k + 1]) {
while (++k <= right && a[k - 1] <= a[k]);
} else if (a[k] > a[k + 1]) {
while (++k <= right && a[k - 1] >= a[k]);
for (int lo = run[count] - 1, hi = k; ++lo < --hi; ) {
int t = a[lo]; a[lo] = a[hi]; a[hi] = t;
}
} else {
for (int m = MAX_RUN_LENGTH; ++k <= right && a[k - 1] == a[k]; ) {
if (--m == 0) {
sort(a, left, right, true);
return;
}
}
}
if (++count == MAX_RUN_COUNT) {
sort(a, left, right, true);
return;
}
}
if (run[count] == right++) {
run[++count] = right;
} else if (count == 1) {
return;
}
// ---------------------------------归并排序部分-------------------------------------
byte odd = 0;
for (int n = 1; (n <<= 1) < count; odd ^= 1);
// Use or create temporary array b for merging
int[] b;
int ao, bo;
int blen = right - left;
if (work == null || workLen < blen || workBase + blen > work.length) {
work = new int[blen];
workBase = 0;
}
if (odd == 0) {
System.arraycopy(a, left, work, workBase, blen);
b = a;
bo = 0;
a = work;
ao = workBase - left;
} else {
b = work;
ao = 0;
bo = workBase - left;
}
for (int last; count > 1; count = last) {
for (int k = (last = 0) + 2; k <= count; k += 2) {
int hi = run[k], mi = run[k - 1];
for (int i = run[k - 2], p = i, q = mi; i < hi; ++i) {
if (q >= hi || p < mi && a[p + ao] <= a[q + ao]) {
b[i + bo] = a[p++ + ao];
} else {
b[i + bo] = a[q++ + ao];
}
}
run[++last] = hi;
}
if ((count & 1) != 0) {
for (int i = right, lo = run[count - 1]; --i >= lo;
b[i + bo] = a[i + ao]
);
run[++last] = right;
}
int[] t = a; a = b; b = t;
int o = ao; ao = bo; bo = o;
}
}
DualPivotQuicksort.sort() 方法在预处理部分主要做了三件事:
- 判断数组长度是否超过快排阈值,如果超过,则进行进一步判断,否则直接调用快排方法。而进入快排的方法中也会有长度判断,如果数组长度小于47,会使用插入排序;
- 创建 运行记录空间(run) 主要用于记录数组的形状,是否大体呈有序状态。如果峰值过多,说明数组趋于乱序状态,依然使用快排
- 如若最后一轮迭代仅剩一个数,那么则在 run 中添加一个哨兵,值为 right + 1;如若数组已然有序,则直接返回
以下是代码详解,不难发现,预处理部分事实上大大提高了代码的健壮性:
// 在小样本条件(默认286)下直接使用快排
if (right - left < QUICKSORT_THRESHOLD) {
sort(a, left, right, true);
return;
}
// run[i]表示第i轮外循环迭代的起始位置
int[] run = new int[MAX_RUN_COUNT + 1];
int count = 0; run[0] = left; // count表示轮数-1, run[0] 初始值为 left
// 检查这个数组是否大体呈有序态
for (int k = left; k < right; run[count] = k) {
if (a[k] < a[k + 1]) { // 升序
while (++k <= right && a[k - 1] <= a[k]);
} else if (a[k] > a[k + 1]) { // 降序
while (++k <= right && a[k - 1] >= a[k]);
// 如果是降序,那么对降序序列进行交换,转换为升序序列
for (int lo = run[count] - 1, hi = k; ++lo < --hi; ) {
int t = a[lo]; a[lo] = a[hi]; a[hi] = t;
}
} else { // 相等
// 统计相等元素【a[k]】的频数,如果超过MAX_RUN_LENGTH(默认33)则使用快排
for (int m = MAX_RUN_LENGTH; ++k <= right && a[k - 1] == a[k]; ) {
if (--m == 0) {
sort(a, left, right, true);
return;
}
}
}
/*
* 如果数组的结构化指标(即凹凸点个数)超过了阈值,其实就是乱序,那么还是选择快速排序
*/
if (++count == MAX_RUN_COUNT) {
sort(a, left, right, true);
return;
}
}
// 检查特殊情况
if (run[count] == right++) {
// 数组长度为1时,设置一个哨兵
run[++count] = right;
} else if (count == 1) { // 数组已呈有序状态
return;
}
这里还需要强调一下哨兵设置的技巧,笔者在看上面整个循环体的时候并没有发现有给 run 数组赋值的语句,仅在循环条件部分看到有 int k = left; k < right; run[count] = k
这样一行代码,但笔者疑惑的是明明k一定小于right,后面却说run[count] == right++
呢?于是不信邪,去写了个demo:
public class demo {
public static void main(String[] args) {
int[] run = new int[10];
for (int i = 0; i < 9; run[i] = 1) {
i += 9;
}
UniversalMethod.printArray(run);
}
}
写完才发现惊喜,之前我以为循环条件不满足的话,分号后面的语句就不会执行,原来理解错了,即使条件不满足,run[i] = 1
依然可以执行。这样就解释了这个设置哨兵的细节:即当最后一轮迭代只有一个元素自成一个子序列,那么就设置一个哨兵。
未完待续…