深入浅出 Arrays.sort(DualPivotQuicksort):如何结合快排、归并、堆排序和插入排序
Arrays.sort 直接调用 DualPivotQuicksort。
DualPivotQuicksort类中定义了多个重要的阈值常量:
MAX_MIXED_INSERTION_SORT_SIZE = 65
:混合插入排序的最大数组大小MAX_INSERTION_SORT_SIZE = 44
:插入排序的最大数组大小MIN_PARALLEL_SORT_SIZE = 4 << 10
:并行排序的最小数组大小MAX_RECURSION_DEPTH = 64 * DELTA
:最大递归深度
这个类为不同的基本数据类型实现了相同的排序算法:
int[]
数组排序long[]
数组排序float[]
数组排序double[]
数组排序byte[]
、char[]
、short[]
数组排序
每种数据类型都有几乎相同的算法实现,包括:
- 双轴快速排序主算法
- 单轴分区和双轴分区方法
- 混合插入排序
- 普通插入排序
- 堆排序
- 归并排序相关方法
选择int数组的排序实现进行详细分析,因为它是最典型的实现。
整体流程:
- 初始判断:根据数组大小和并行度决定是否启用并行
- 递归分治:通过Sorter类实现并行的分治过程
- 自适应策略:
- 小数组 → 插入排序
- 近似有序 → 运行段归并
- 深度过深 → 堆排序
- 正常情况 → 双轴快排
- 并行合并:通过Merger和RunMerger实现高效的并行归并
这是并行排序的入口,会根据数组大小和并行度决定是否使用并行排序。
static void sort(int[] a, int parallelism, int low, int high) {int size = high - low;if (parallelism > 1 && size > MIN_PARALLEL_SORT_SIZE) {int depth = getDepth(parallelism, size >> 12);int[] b = depth == 0 ? null : new int[size];new Sorter(null, a, b, low, size, low, depth).invoke();} else {sort(null, a, 0, low, high);}}
主要的排序逻辑在 sort(Sorter sorter, int[] a, int bits, int low, int high)
方法中,这是一个复杂的while循环实现。
bits变量:递归深度和位标志的组合,其中最右边的位“0”表示数组是最左侧部分。
其中MAX_RECURSION_DEPTH = 64 * DELTA = 64 * 6 = 384,bits每次加 DELTA(也就是6)。
算法流程
步骤1:小数组优化处理
- 对于小于65个元素的非最左部分,使用混合插入排序
- 对于小于44个元素的最左部分,使用普通插入排序
if (size < MAX_MIXED_INSERTION_SORT_SIZE + bits && (bits & 1) > 0) {// 混合插入排序:非最左部分且小于65+递归深度sort(int.class, a, Unsafe.ARRAY_INT_BASE_OFFSET, low, high, DualPivotQuicksort::mixedInsertionSort);return;
}
if (size < MAX_INSERTION_SORT_SIZE) {// 普通插入排序:最左部分且小于44sort(int.class, a, Unsafe.ARRAY_INT_BASE_OFFSET, low, high, DualPivotQuicksort::insertionSort);return;
}
步骤2:近似有序数组优化
- 尝试识别和合并已排序的运行段(runs)
- 如果数组基本有序,直接使用归并策略
if ((bits == 0 || size > MIN_TRY_MERGE_SIZE && (bits & 1) > 0)&& tryMergeRuns(sorter, a, low, size)) {return;}
步骤3:防止退化处理
- 当递归深度过深时,切换到堆排序避免O(n²)时间复杂度
if ((bits += DELTA) > MAX_RECURSION_DEPTH) {heapSort(a, low, high);return;}
步骤4:轴点选择
使用黄金比例近似值选择5个样本元素:
int step = (size >> 3) * 3 + 3;
int e1 = low + step;
int e5 = end - step;
int e3 = (e1 + e5) >>> 1;
int e2 = (e1 + e3) >>> 1;
int e4 = (e3 + e5) >>> 1;
步骤5:样本元素排序
使用4元素排序网络和插入排序的组合对5个样本元素进行排序。
分区策略
根据5个样本元素的分布情况,算法采用两种分区策略:
双轴分区(元素各不相同时)
当5个样本严格递增时,使用第1和第5个元素作为两个轴点进行三分区:
- 左部分:小于pivot1
- 中部分:pivot1 ≤ 元素 ≤ pivot2
- 右部分:大于pivot2
单轴分区(存在重复元素时)
使用第3个元素作为单个轴点,采用荷兰国旗三色分区法。
并行处理支持
类中还包含了复杂的并行处理逻辑:
Sorter
类:实现并行排序的分治Merger
类:实现并行归并RunMerger
类:实现运行段的并行归并
插入排序的两种实现差异
混合插入排序 vs 普通插入排序
普通插入排序
- 标准的插入排序实现
- 逐个元素向前比较并插入到正确位置
- 适用于最左侧部分,因为没有哨兵元素
混合插入排序
混合插入排序结合了三种技术:
- 简单插入排序:处理微小数组(当end == high时,其实就是 长度不超过32 的 数组)
* @param low the index of the first element, inclusive, to be sorted* @param high the index of the last element, exclusive, to be sorted*/private static void mixedInsertionSort(int[] a, int low, int high) {int size = high - low;int end = high - 3 * ((size >> 5) << 3);
- 针式插入排序:
- 选择一个"pin"元素作为基准
- 将大于pin的元素预先移到数组末尾
- 避免大元素在整个数组中的昂贵移动
- 配对插入排序:
- 每次处理两个元素
- 先插入较大元素,再插入较小元素(从较大元素插入的下标开始向前),提高插入效率
为什么最左部分不能用混合插入
关键差异在于混合插入排序利用了双轴快排的特性:左部分的轴点元素充当哨兵,避免了边界检查。
混合插入排序依赖pivot作为哨兵的核心原因是避免边界检查,提升性能。下面来深入分析这个机制:
在双轴快速排序的递归过程中,当对非最左部分进行排序时,左侧的pivot元素自然成为哨兵:
// 在双轴快速排序中,分区后的结构如下:
// [小于pivot1的元素] [pivot1] [中间元素] [pivot2] [大于pivot2的元素]
// ↑
// 哨兵元素
传统插入排序需要进行边界检查:
// 传统插入排序(需要边界检查)
while (--i >= low && ai < a[i]) { // 必须检查 i >= lowa[i + 1] = a[i];
}
混合插入排序的优化机制
简单插入排序部分的优化
// 混合插入排序中的简单插入排序
while (ai < a[--i]) { // 无需边界检查!a[i + 1] = a[i];
}
为什么可以省略边界检查?
- 因为左侧存在pivot元素作为哨兵
- pivot < 当前排序区间的所有元素
- 当
ai
向左查找插入位置时,最终会遇到pivot元素 - 此时
ai >= pivot
,循环自然终止,不会越界
配对插入排序部分的优化
// 配对插入排序中的查找循环
while (a1 < a[--i]) { // 同样无需边界检查a[i + 2] = a[i];
}while (a2 < a[--i]) { // 同样无需边界检查a[i + 1] = a[i];
}
性能提升的量化分析
传统方式的开销
- 每次比较需要执行两个条件判断:
i >= low
和ai < a[i]
- 对于n个元素,平均需要O(n²)次边界检查
哨兵优化的收益
- 每次比较只需要一个条件判断:
ai < a[i]
- 减少了50%的条件判断操作
- 消除了边界检查的CPU分支预测失败成本
堆排序切换机制
当递归深度过深时的堆排序实现:
- 建堆阶段:从中间位置开始,自底向上调用pushDown建立最大堆
- 排序阶段:重复提取最大元素到数组末尾
- pushDown方法:维护堆性质的关键操作
堆排序保证了O(n log n)的最坏时间复杂度,作为快排退化时的保底策略。
这里先通过自底向上的 pushDown建立最大堆,然后常规的,一步步缩小堆大小,完成排序。
/*** Sorts the specified range of the array using heap sort.** @param a the array to be sorted* @param low the index of the first element, inclusive, to be sorted* @param high the index of the last element, exclusive, to be sorted*/private static void heapSort(int[] a, int low, int high) {for (int k = (low + high) >>> 1; k > low; ) {pushDown(a, --k, a[k], low, high);}while (--high > low) {int max = a[low];pushDown(a, low, a[high], low, high);a[high] = max;}}
tryMergeRuns
深入分析
tryMergeRuns
是 Java DualPivotQuicksort 中的一个关键优化技术,它检测数组中的天然有序序列(runs),如果数组具有高度结构化特征,就使用归并排序而不是快速排序,从而实现更高效的排序。
在 sort
方法中,以下情况会调用 tryMergeRuns
:
if ((bits == 0 || size > MIN_TRY_MERGE_SIZE && (bits & 1) > 0)&& tryMergeRuns(sorter, a, low, size)) {return;
}
调用条件:
bits == 0
:表示这是最左边的部分(完整数组)- 或者
size > MIN_TRY_MERGE_SIZE && (bits & 1) > 0
:非左侧部分且足够大(MIN_TRY_MERGE_SIZE = 4096
)
核心实现机制
1. Run 识别阶段
1.1 三种 Run 类型识别
for (int k = low + 1; k < high; ) {if (a[k - 1] < a[k]) {// 识别递增序列while (++k < high && a[k - 1] <= a[k]);} else if (a[k - 1] > a[k]) {// 识别递减序列while (++k < high && a[k - 1] >= a[k]);// 反转为递增序列for (int i = last - 1, j = k; ++i < --j && a[i] > a[j]; ) {int ai = a[i]; a[i] = a[j]; a[j] = ai;}} else {// 识别相等序列for (int ak = a[k]; ++k < high && ak == a[k]; );if (k < high) continue;}// 省略后面的循环代码
}
1.2 关键决策点
第一个 Run 检查:
if (run == null) {if (k == high) {// 整个数组是单调序列,已经有序return true;}if (k - low < MIN_FIRST_RUN_SIZE) {// 第一个run太小(MIN_FIRST_RUN_SIZE = 16),放弃return false;}// 初始化run数组run = new int[((size >> 10) | 0x7F) & 0x3FF];run[0] = low;
}
结构化程度检查:
else if (a[last - 1] > a[last]) {if (count > (k - low) >> MIN_FIRST_RUNS_FACTOR) {// run数量过多,结构化程度不够(MIN_FIRST_RUNS_FACTOR = 7)return false;}if (++count == MAX_RUN_CAPACITY) {// 超过最大run容量(MAX_RUN_CAPACITY = 5120),放弃return false;}
}
2. Run 数组容量管理
run = new int[((size >> 10) | 0x7F) & 0x3FF];
容量计算逻辑:
size >> 10
:数组大小除以1024| 0x7F
:确保至少127个位置& 0x3FF
:限制最大1023个位置
动态扩容:
if (count == run.length) {run = Arrays.copyOf(run, count << 1);
}
3. 归并执行阶段
如果检测到多个runs(count > 1
),开始归并:
if (count > 1) {int[] b; int offset = low;if (sorter == null || (b = (int[]) sorter.b) == null) {b = new int[size]; // 创建临时缓冲区} else {offset = sorter.offset; // 并行情况下复用缓冲区}mergeRuns(a, b, offset, 1, sorter != null, run, 0, count);
}
mergeRuns
实现机制
1. 递归分治策略
// 找到中间分割点
int mi = lo, rmi = (run[lo] + run[hi]) >>> 1;
while (run[++mi + 1] <= rmi);// 分别处理左右两部分
if (parallel && hi - lo > MIN_RUN_COUNT) {// 并行归并RunMerger merger = new RunMerger(a, b, offset, 0, run, mi, hi).forkMe();a1 = mergeRuns(a, b, offset, -aim, true, run, lo, mi);a2 = (int[]) merger.getDestination();
} else {// 串行归并a1 = mergeRuns(a, b, offset, -aim, false, run, lo, mi);a2 = mergeRuns(a, b, offset, 0, false, run, mi, hi);
}
2. 智能缓冲区切换
int[] dst = a1 == a ? b : a; // 选择目标数组// 计算各部分的索引
int k = a1 == a ? run[lo] - offset : run[lo];
int lo1 = a1 == b ? run[lo] - offset : run[lo];
int hi1 = a1 == b ? run[mi] - offset : run[mi];
int lo2 = a2 == b ? run[mi] - offset : run[mi];
int hi2 = a2 == b ? run[hi] - offset : run[hi];
关键常量作用
常量 | 值 | 作用 |
---|---|---|
MIN_FIRST_RUN_SIZE | 16 | 第一个run的最小长度,确保有意义的结构化 |
MIN_FIRST_RUNS_FACTOR | 7 | 控制run密度,防止过度碎片化 |
MAX_RUN_CAPACITY | 5120 | 最大run数量,避免过度内存消耗 |
MIN_TRY_MERGE_SIZE | 4096 | 尝试归并的最小数组大小 |
这个机制是现代排序算法的典型代表,体现了自适应算法设计的精髓:根据数据特征动态选择最优策略,在保证最坏情况性能的同时,大幅提升常见情况下的效率。
pivot选择策略分析
1. 五元素采样策略
该算法使用了一种基于黄金比例近似的五元素采样策略来选择pivot:
步长计算:int step = (size >> 3) * 3 + 3
- 这个公式确保了在不同大小的数组中都能得到合理的采样间隔
- 相当于
step = (size / 8) * 3 + 3
,提供了一个与数组大小成比例的采样距离
五个采样点的选择:
e1 = low + step
(第一个采样点)e5 = end - step
(第五个采样点)e3 = (e1 + e5) >>> 1
(中心点)e2 = (e1 + e3) >>> 1
(左中点)e4 = (e3 + e5) >>> 1
(右中点)
这种不等距的采样分布经过实验验证,在各种输入数据上都表现良好。
2. 四元素排序网络
代码中使用了一个高效的四元素排序网络来对五个采样元素进行部分排序:
5 ------o-----------o------------| |
4 ------|-----o-----o-----o------| | |
2 ------o-----|-----o-----o------| |
1 ------------o-----o------------
这个网络通过固定的比较-交换序列,确保e1、e2、e4、e5四个元素有序,然后通过插入排序将e3插入到正确位置。
双pivot划分(partitionDualPivot)
当五个采样元素完全有序时(a[e1] < a[e2] < a[e3] < a[e4] < a[e5]),使用双pivot策略:
pivot选择:使用e1和e5位置的元素作为pivot1和pivot2
- pivot1 = a[e1](较小的pivot)
- pivot2 = a[e5](较大的pivot)
三区间划分:
左部分 中间部分 右部分
< pivot1 pivot1 <= x <= pivot2 > pivot2
划分过程:
- 将pivot保存到临时位置
- 从两端向中间扫描,跳过已经在正确区域的元素
- 使用反向三区间划分策略,从右向左处理未知区域
- 最后将pivot放回最终位置
单pivot划分(partitionSinglePivot)
当采样元素中有相等值时,使用传统的荷兰国旗三路划分:
pivot选择:使用e3位置的元素作为pivot(中位数近似)
三区间划分:
左部分 中间部分 右部分
< pivot == pivot > pivot
划分过程:
- 采用经典的三路快排策略
- 小于pivot的元素移到左侧
- 大于pivot的元素移到右侧
- 等于pivot的元素保持在中间
并行处理架构
Sorter类 - 并行排序核心
Sorter继承CountedCompleter,实现Fork/Join框架:
- compute方法:根据depth参数决定是继续分割还是执行排序
- onCompletion方法:处理子任务完成后的合并工作
- forkSorter方法:创建并行子任务
Merger类 - 并行归并
负责并行归并两个已排序的部分:
- 根据数据类型分发到对应的mergeParts方法
- 利用Fork/Join框架实现并行合并
RunMerger类 - 运行段并行归并
专门处理自然运行段的并行归并:
- 继承RecursiveTask返回合并结果
- 支持多种数据类型的运行段合并
- 通过forkMe和getDestination方法协调并行执行
CountedCompleter提供的核心能力
关于CountedCompleter的讨论见:Fork/Join框架:CountedCompleter与RecursiveTask深度对比-CSDN博客
CountedCompleter是Java并发框架中的一个抽象类,继承自ForkJoinTask,专门用于管理具有层次结构的并行任务。它提供了以下关键能力:
1. 任务计数管理
- 通过
setPendingCount()
设置待完成的子任务数量 - 通过
addToPendingCount()
动态增加待完成任务数 - 自动跟踪子任务完成状态,当所有子任务完成时触发回调
2. 完成事件处理
tryComplete()
:尝试完成当前任务,如果计数为0则触发完成逻辑onCompletion()
:当任务及其所有子任务完成时的回调方法propagateCompletion()
:类似于tryComplete()
,但 不会触发onCompletion( CountedCompleter )
回调。
CountedCompleter.javapublic final void propagateCompletion() {CountedCompleter<?> a = this, s;for (int c;;) {if ((c = a.pending) == 0) {if ((a = (s = a).completer) == null) {s.quietlyComplete();return;}}else if (a.weakCompareAndSetPendingCount(c, c - 1))return;}}FutureTask.javapublic final void quietlyComplete() {setDone();}/*** Sets DONE status and wakes up threads waiting to join this task.*/private void setDone() {getAndBitwiseOrStatus(DONE);signalWaiters();}
Sorter类的使用分析
在Sorter的compute()
方法中,根据depth值采用不同策略:
并行合并阶段(depth < 0):
- 使用
setPendingCount(2)
设置两个子任务 - 将数组分为两半,创建两个子Sorter任务
- 一个通过
fork()
异步执行,另一个同步执行
排序阶段(depth >= 0):
- 直接调用相应的排序算法(针对int、long、float、double数组)
完成时的合并操作
在onCompletion()
方法中:
-
当depth < 0时,表示需要进行合并操作
-
创建Merger任务来合并已排序的两个部分
-
通过位运算确定源数组和目标数组的位置
动态任务创建
forkSorter()
方法展示了动态任务管理:
- 使用
addToPendingCount(1)
增加待完成任务计数 - 创建新的Sorter任务并fork执行
Merger类的使用分析
Merger类专门负责合并操作,具有以下特点:
类型适应性:
- 在
compute()
方法中根据数组类型(int、long、float、double)调用相应的mergeParts方法 - 使用泛型Object来统一处理不同类型的数组
递归分解:
forkMerger()
方法可以进一步分解合并任务- 使用
addToPendingCount(1)
管理子任务 - 通过
propagateCompletion()
传播完成状态
深层架构优势
1. 分层任务管理
CountedCompleter的计数机制确保了复杂的并行排序任务能够正确协调:
-
Sorter管理排序的分解和递归
-
Merger管理合并的并行化
-
父子任务关系清晰,避免了手动同步的复杂性
2. 负载均衡
通过fork-join框架的工作窃取机制:
-
空闲线程可以窃取其他线程队列中的任务
-
动态调整并行度,充分利用CPU资源
3. 内存效率
-
通过depth参数控制递归深度
-
在合适的时机在原数组和辅助数组之间切换
-
避免不必要的数组复制
4. 异常处理和完成保证
- CountedCompleter确保即使在异常情况下也能正确处理任务完成
- 通过计数机制保证所有子任务完成后才执行父任务的完成逻辑
性能优化策略
1. 阈值控制
通过MIN_PARALLEL_SORT_SIZE
等常量控制何时使用并行排序,避免小数组的并行开销。
2. 深度限制
使用depth参数防止过度分解,在合适的粒度上进行并行处理。
3. 类型特化
为不同的基本类型提供专门的实现,避免装箱拆箱的性能损失。
CountedCompleter在这里提供了一个优雅的并行任务协调框架,使得复杂的双轴快速排序算法能够有效地利用多核处理器的并行能力,同时保持代码的清晰性和正确性。