diff --git a/notes/pics/sort-3way.png b/notes/pics/sort-3way.png new file mode 100644 index 00000000..9f7e11ba Binary files /dev/null and b/notes/pics/sort-3way.png differ diff --git a/notes/pics/sort-partition.png b/notes/pics/sort-partition.png new file mode 100644 index 00000000..5ae79a9f Binary files /dev/null and b/notes/pics/sort-partition.png differ diff --git a/notes/pics/sort-select.png b/notes/pics/sort-select.png new file mode 100644 index 00000000..dd44588d Binary files /dev/null and b/notes/pics/sort-select.png differ diff --git a/notes/算法 - 排序.md b/notes/算法 - 排序.md index 1cefba5d..da871a17 100644 --- a/notes/算法 - 排序.md +++ b/notes/算法 - 排序.md @@ -142,7 +142,7 @@ public class Shell> extends Sort { ``` -希尔排序的运行时间达不到平方级别,使用递增序列 1, 4, 13, 40, ... 的希尔排序所需要的比较次数不会超过 N 的若干倍乘以递增序列的长度。后面介绍的高级排序算法只会比希尔排序快两倍左右。 +希尔排序的运行时间达不到平方级别:使用递增序列 1, 4, 13, 40, ... 时,最坏情况下的比较次数与 N3/2 成正比,随机数组上更少(100 万个随机整数约 6300 万次,JDK 21 实测,见附录 A5)。对于中等规模的数组,后面介绍的高级排序算法只会比希尔排序快两倍左右。 ## 归并排序 @@ -190,7 +190,7 @@ public abstract class MergeSort> extends Sort { 将一个大数组分成两个小数组去求解。 -因为每次都将问题对半分成两个子问题,这种对半分的算法复杂度一般为 O(NlogN)。 +每次都将数组对半分,递归一共有 logN 层,每一层的归并合起来处理 N 个元素,因此复杂度为 O(NlogN)。 ```java public class Up2DownMergeSort> extends MergeSort { @@ -273,7 +273,11 @@ public class QuickSort> extends Sort { ### 2. 切分 -取 a[l] 作为切分元素,然后从数组的左端向右扫描直到找到第一个大于等于它的元素,再从数组的右端向左扫描找到第一个小于等于它的元素,交换这两个元素。不断进行这个过程,就可以保证左指针 i 的左侧元素都不大于切分元素,右指针 j 的右侧元素都不小于切分元素。当两个指针相遇时,将切分元素 a[l] 和 a[j] 交换位置。 +取 a[l] 作为切分元素,然后从数组的左端向右扫描直到找到第一个大于等于它的元素,再从数组的右端向左扫描找到第一个小于等于它的元素,交换这两个元素。不断进行这个过程,就可以保证左指针 i 的左侧元素都不大于切分元素,右指针 j 的右侧元素都不小于切分元素。当两个指针相遇或交叉时,将切分元素 a[l] 和 a[j] 交换位置,它就落在了排序后的最终位置上。 + +

+ +需要注意的是,扫描时遇到与切分元素相等的元素也要停下交换,否则全部相等的数组每次只能切掉一个元素,退化为平方级别(1 万个相同元素约 1 亿次比较,停下时约 12 万次,JDK 21 实测,见附录 A4)。

@@ -299,7 +303,7 @@ private int partition(T[] nums, int l, int h) { 快速排序最好的情况下是每次都正好将数组对半分,这样递归调用次数才是最少的。这种情况下比较次数为 CN=2CN/2+N,复杂度为 O(NlogN)。 -最坏的情况下,第一次从最小的元素切分,第二次从第二小的元素切分,如此这般。因此最坏的情况下需要比较 N2/2。为了防止数组最开始就是有序的,在进行快速排序时需要随机打乱数组。 +最坏的情况下,第一次从最小的元素切分,第二次从第二小的元素切分,如此这般,需要比较 N2/2 次,递归深度也达到 N。取 a[l] 作为切分元素时,已经有序的数组正好是这种情况,因此排序前先随机打乱数组。 ### 4. 算法改进 @@ -315,7 +319,11 @@ private int partition(T[] nums, int l, int h) { 对于有大量重复元素的数组,可以将数组切分为三部分,分别对应小于、等于和大于切分元素。 -三向切分快速排序对于有大量重复元素的随机数组可以在线性时间内完成排序。 +切分过程见下图,与切分元素相等的元素集中到中间,不再参与递归。[荷兰国旗问题](Leetcode%20题解%20-%20排序.md#荷兰国旗问题)用的就是这种切分。 + +

+ +三向切分快速排序对于不同键很少的随机数组可以在线性时间内完成排序,例如 100 万个只取 0、1、2 的随机数约需 167 万~200 万次比较(JDK 21 实测,见附录 A3)。 ```java public class ThreeWayQuickSort> extends QuickSort { @@ -345,11 +353,13 @@ public class ThreeWayQuickSort> extends QuickSort { ### 5. 基于切分的快速选择算法 -快速排序的 partition() 方法,会返回一个整数 j 使得 a[l..j-1] 小于等于 a[j],且 a[j+1..h] 大于等于 a[j],此时 a[j] 就是数组中第 j 小的元素(j 从 0 开始计数)。 +快速排序的 partition() 方法,会返回一个整数 j 使得 a[l..j-1] 小于等于 a[j],且 a[j+1..h] 大于等于 a[j],此时 a[j] 已经在排序后的位置上,即数组中第 j + 1 小的元素。 -可以利用这个特性找出数组的第 k 个元素。 +可以利用这个特性找出数组中排序后下标为 k 的元素:每次切分后只在 k 所在的一侧继续切分。 -该算法是线性级别的,假设每次能将数组二分,那么比较的总次数为 (N+N/2+N/4+..),直到找到第 k 个元素,这个和显然小于 2N。 +

+ +该算法平均是线性级别的,假设每次能将数组二分,那么比较的总次数为 (N+N/2+N/4+..),直到找到第 k 个元素,这个和小于 2N。最坏情况与快速排序一样是 \~N2/2,同样要先打乱数组。求第 k 大元素也可以用大小为 k 的小顶堆,复杂度 NlogK,见 [Leetcode 题解 - 排序](Leetcode%20题解%20-%20排序.md#1-kth-element)。 ```java public T select(T[] nums, int k) { @@ -375,7 +385,7 @@ public T select(T[] nums, int k) { ### 1. 堆 -堆中某个节点的值总是大于等于或小于等于其子节点的值,并且堆是一棵完全二叉树。 +堆中某个节点的值总是大于等于或小于等于其子节点的值,并且堆是一棵完全二叉树。前者称为大顶堆,后者称为小顶堆,下文以大顶堆为例。 堆可以用数组来表示,这是因为堆是完全二叉树,而完全二叉树很容易就存储在数组中。位置 k 的节点的父节点位置为 k/2,而它的两个子节点的位置分别为 2k 和 2k+1。这里不使用数组索引为 0 的位置,是为了更清晰地描述节点的位置关系。 @@ -475,7 +485,7 @@ public T delMax() { ##### 5.1 构建堆 -无序数组建立堆最直接的方法是从左到右遍历数组进行上浮操作。一个更高效的方法是从右至左进行下沉操作,如果一个节点的两个子节点都已经是堆有序,那么进行下沉操作可以使得这个节点为根节点的堆有序。叶子节点不需要进行下沉操作,可以忽略叶子节点的元素,因此只需要遍历一半的元素即可。 +无序数组建立堆最直接的方法是从左到右遍历数组进行上浮操作。一个更高效的方法是从右至左进行下沉操作,如果一个节点的两个子节点都已经是堆有序,那么进行下沉操作可以使得这个节点为根节点的堆有序。叶子节点不需要进行下沉操作,可以忽略叶子节点的元素,因此只需要遍历一半的元素即可。这种方法建堆是线性的:约 1/4 的节点最多下沉 1 层,1/8 的节点最多下沉 2 层……总层数不超过 N,每层比较 2 次,共少于 2N 次比较(10 万个随机元素约 18.8 万次,JDK 21 实测,见附录 A2)。

@@ -537,21 +547,110 @@ public class HeapSort> extends Sort { | 算法 | 稳定性 | 时间复杂度 | 空间复杂度 | 备注 | | :---: | :---: |:---: | :---: | :---: | | 选择排序 | × | N2 | 1 | | -| 冒泡排序 | √ | N2 | 1 | | +| 冒泡排序 | √ | N \~ N2 | 1 | 有序时一轮就退出 | | 插入排序 | √ | N \~ N2 | 1 | 时间复杂度和初始顺序有关 | -| 希尔排序 | × | N 的若干倍乘以递增序列的长度 | 1 | 改进版插入排序 | -| 快速排序 | × | NlogN | logN | | +| 希尔排序 | × | 最坏 N3/2 | 1 | 改进版插入排序,递增序列 1, 4, 13, ... | +| 快速排序 | × | NlogN | logN | 概率保证,最坏 N2 | | 三向切分快速排序 | × | N \~ NlogN | logN | 适用于有大量重复主键| | 归并排序 | √ | NlogN | N | | | 堆排序 | × | NlogN | 1 | 无法利用局部性原理| +稳定性指的是相等的元素排序后保持原来的相对顺序。冒泡、插入排序只交换相邻元素,归并排序相等时取左侧,因此稳定;其它几种的远距离交换会越过相等的元素,例如选择排序对 [5a, 8, 5b, 2] 第一步交换 5a 和 2,最终得到 [2, 5b, 5a, 8](见附录 A1)。 + 快速排序是最快的通用排序算法,它的内循环的指令很少,而且它还能利用缓存,因为它总是顺序地访问数据。它的运行时间近似为 \~cNlogN,这里的 c 比其它线性对数级别的排序算法都要小。 使用三向切分快速排序,实际应用中可能出现的某些分布的输入能够达到线性级别,而其它排序算法仍然需要线性对数时间。 ### 2. Java 的排序算法实现 -Java 主要排序方法为 java.util.Arrays.sort(),对于原始数据类型使用双轴快速排序(Dual-Pivot Quicksort),对于引用类型使用 TimSort,它是归并排序的改进版本。 +Java 主要排序方法为 java.util.Arrays.sort(),对于原始数据类型使用双轴快速排序(Dual-Pivot Quicksort),对于引用类型使用 TimSort,它是归并排序的改进版本。这是因为对象常按某个字段排序,需要稳定性;相等的原始类型值无法区分,可以用更快但不稳定的快速排序。 + +## 附录:实测 + +环境:Temurin JDK 21.0.13,Apple M1 Pro。代码中的 Selection、sink()、ThreeWayQuickSort 与正文相同,另加比较计数。 + +### A1. 选择排序的稳定性 + +```java +record K(int v, String tag) implements Comparable { + public int compareTo(K o) { return Integer.compare(v, o.v); } + public String toString() { return v + tag; } +} + +K[] a = { new K(5, "a"), new K(8, ""), new K(5, "b"), new K(2, "") }; +new Selection().sort(a); +System.out.println(Arrays.toString(a)); +``` + +```text +[2, 5b, 5a, 8] +``` + +### A2. 下沉建堆的比较次数 + +从右至左对 N/2 \~ 1 的节点调用 sink(),统计比较次数(随机整数和升序数组,升序数组对大顶堆是较差的输入): + +```text +heap build N=1000 compares=1867 +heap build ascending N=1000 compares=1982 +heap build N=100000 compares=187762 +heap build ascending N=100000 compares=199978 +``` + +### A3. 三向切分快速排序的比较次数 + +用正文的 ThreeWayQuickSort 对只取 0、1、2 的随机数组排序,统计 compareTo() 的调用次数: + +```java +static long compares = 0; + +record K(int v) implements Comparable { + public int compareTo(K o) { compares++; return Integer.compare(v, o.v); } +} + +Random random = new Random(42); +for (int n : new int[]{100_000, 1_000_000}) { + K[] a = new K[n]; + for (int i = 0; i < n; i++) a[i] = new K(random.nextInt(3)); + compares = 0; + new ThreeWayQuickSort().sort(a); + System.out.println("N=" + n + " compares=" + compares); +} +``` + +输入固定,sort() 中的随机打乱每次不同,运行 10 次的结果(节选 3 次): + +```text +N=100000 compares=199540 N=1000000 compares=1667273 +N=100000 compares=199981 N=1000000 compares=1998566 +N=100000 compares=166522 N=1000000 compares=1667273 +``` + +10 次中 N = 100 万时最少 1667273 次、最多 2001031 次,第一次切分的元素恰好是 1 时比较最少。 + +### A4. 切分时遇到相等元素是否停下 + +用 int 数组改写正文的 partition(),less() 中计数;对照版本把扫描条件中的 less 改为小于等于,即遇到相等元素不停。对全部为 0 的数组排序: + +```text +all-equal N=1000 stop-on-equal compares=8898 +all-equal N=1000 no-stop compares=999999 +all-equal N=10000 stop-on-equal compares=121034 +all-equal N=10000 no-stop compares=99999999 +``` + +同一程序对 [5, 2, 7, 4, 1, 8, 3, 6] 切分一次,输出 [1, 2, 3, 4, 5, 8, 7, 6],与正文切分图的例子一致。 + +### A5. 希尔排序的比较次数 + +按正文的 Shell 对随机 int 数组排序(Random(7)),内层每比较一次计数: + +```text +N=10000 increments=9 compares=235189 +N=100000 increments=11 compares=3877254 +N=1000000 increments=13 compares=63070313 +``` + ## 参考资料