深入浅出 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[] 数组排序

每种数据类型都有几乎相同的算法实现,包括:

  1. 双轴快速排序主算法
  2. 单轴分区和双轴分区方法
  3. 混合插入排序
  4. 普通插入排序
  5. 堆排序
  6. 归并排序相关方法

选择int数组的排序实现进行详细分析,因为它是最典型的实现。

整体流程:

  1. 初始判断:根据数组大小和并行度决定是否启用并行
  2. 递归分治:通过Sorter类实现并行的分治过程
  3. 自适应策略
    • 小数组 → 插入排序
    • 近似有序 → 运行段归并
    • 深度过深 → 堆排序
    • 正常情况 → 双轴快排
  4. 并行合并:通过Merger和RunMerger实现高效的并行归并

这是并行排序的入口,会根据数组大小和并行度决定是否使用并行排序。

java 复制代码
    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个元素的最左部分,使用普通插入排序
java 复制代码
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) {
    // 普通插入排序:最左部分且小于44
    sort(int.class, a, Unsafe.ARRAY_INT_BASE_OFFSET, low, high, DualPivotQuicksort::insertionSort);
    return;
}

步骤2:近似有序数组优化

  • 尝试识别和合并已排序的运行段(runs)
  • 如果数组基本有序,直接使用归并策略
java 复制代码
if ((bits == 0 || size > MIN_TRY_MERGE_SIZE && (bits & 1) > 0)
                    && tryMergeRuns(sorter, a, low, size)) {
                return;
            }

步骤3:防止退化处理

  • 当递归深度过深时,切换到堆排序避免O(n²)时间复杂度
java 复制代码
if ((bits += DELTA) > MAX_RECURSION_DEPTH) {
                heapSort(a, low, high);
                return;
            }

步骤4:轴点选择

使用黄金比例近似值选择5个样本元素:

java 复制代码
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 普通插入排序

普通插入排序

  • 标准的插入排序实现
  • 逐个元素向前比较并插入到正确位置
  • 适用于最左侧部分,因为没有哨兵元素

混合插入排序

混合插入排序结合了三种技术:

  1. 简单插入排序 :处理微小数组(当end == high时,其实就是 长度不超过32 的 数组)

    java 复制代码
         * @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);
  2. 针式插入排序

    • 选择一个"pin"元素作为基准
    • 将大于pin的元素预先移到数组末尾
    • 避免大元素在整个数组中的昂贵移动
  3. 配对插入排序

    • 每次处理两个元素
    • 先插入较大元素,再插入较小元素(从较大元素插入的下标开始向前),提高插入效率

为什么最左部分不能用混合插入

关键差异在于混合插入排序利用了双轴快排的特性:左部分的轴点元素充当哨兵,避免了边界检查。

混合插入排序依赖pivot作为哨兵的核心原因是避免边界检查,提升性能。下面来深入分析这个机制:

在双轴快速排序的递归过程中,当对非最左部分进行排序时,左侧的pivot元素自然成为哨兵

java 复制代码
// 在双轴快速排序中,分区后的结构如下:
// [小于pivot1的元素] [pivot1] [中间元素] [pivot2] [大于pivot2的元素]
//                   ↑
//                 哨兵元素

传统插入排序需要进行边界检查:

java 复制代码
// 传统插入排序(需要边界检查)
while (--i >= low && ai < a[i]) {  // 必须检查 i >= low
    a[i + 1] = a[i];
}

混合插入排序的优化机制

简单插入排序部分的优化

java 复制代码
// 混合插入排序中的简单插入排序
while (ai < a[--i]) {  // 无需边界检查!
    a[i + 1] = a[i];
}

为什么可以省略边界检查?

  • 因为左侧存在pivot元素作为哨兵
  • pivot < 当前排序区间的所有元素
  • ai向左查找插入位置时,最终会遇到pivot元素
  • 此时ai >= pivot,循环自然终止,不会越界

配对插入排序部分的优化

java 复制代码
// 配对插入排序中的查找循环
while (a1 < a[--i]) {  // 同样无需边界检查
    a[i + 2] = a[i];
}

while (a2 < a[--i]) {  // 同样无需边界检查
    a[i + 1] = a[i];
}

性能提升的量化分析

传统方式的开销

  • 每次比较需要执行两个条件判断:i >= lowai < a[i]
  • 对于n个元素,平均需要O(n²)次边界检查

哨兵优化的收益

  • 每次比较只需要一个条件判断:ai < a[i]
  • 减少了50%的条件判断操作
  • 消除了边界检查的CPU分支预测失败成本

堆排序切换机制

当递归深度过深时的堆排序实现:

  1. 建堆阶段:从中间位置开始,自底向上调用pushDown建立最大堆
  2. 排序阶段:重复提取最大元素到数组末尾
  3. pushDown方法:维护堆性质的关键操作

堆排序保证了O(n log n)的最坏时间复杂度,作为快排退化时的保底策略。

这里先通过自底向上的 pushDown建立最大堆,然后常规的,一步步缩小堆大小,完成排序。

java 复制代码
    /**
     * 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

java 复制代码
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 类型识别

java 复制代码
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 检查:

java 复制代码
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;
}

结构化程度检查:

java 复制代码
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 数组容量管理

java 复制代码
run = new int[((size >> 10) | 0x7F) & 0x3FF];

容量计算逻辑:

  • size >> 10:数组大小除以1024
  • | 0x7F:确保至少127个位置
  • & 0x3FF:限制最大1023个位置

动态扩容:

java 复制代码
if (count == run.length) {
    run = Arrays.copyOf(run, count << 1);
}

3. 归并执行阶段

如果检测到多个runs(count > 1),开始归并:

java 复制代码
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. 递归分治策略

java 复制代码
// 找到中间分割点
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. 智能缓冲区切换

java 复制代码
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)

三区间划分

java 复制代码
左部分        中间部分           右部分
< pivot1   pivot1 <= x <= pivot2   > pivot2

划分过程

  1. 将pivot保存到临时位置
  2. 从两端向中间扫描,跳过已经在正确区域的元素
  3. 使用反向三区间划分策略,从右向左处理未知区域
  4. 最后将pivot放回最终位置

单pivot划分(partitionSinglePivot)

当采样元素中有相等值时,使用传统的荷兰国旗三路划分:

pivot选择:使用e3位置的元素作为pivot(中位数近似)

三区间划分

html 复制代码
左部分      中间部分      右部分
< pivot    == pivot     > pivot

划分过程

  1. 采用经典的三路快排策略
  2. 小于pivot的元素移到左侧
  3. 大于pivot的元素移到右侧
  4. 等于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()动态增加待完成任务数
  • 自动跟踪子任务完成状态,当所有子任务完成时触发回调
  1. 完成事件处理
  • tryComplete():尝试完成当前任务,如果计数为0则触发完成逻辑
  • onCompletion():当任务及其所有子任务完成时的回调方法
  • propagateCompletion():类似于 tryComplete(),但 ​不会触发onCompletion( CountedCompleter ) 回调​
java 复制代码
CountedCompleter.java
    public 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.java
    public 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管理合并的并行化

  • 父子任务关系清晰,避免了手动同步的复杂性

  1. 负载均衡

通过fork-join框架的工作窃取机制:

  • 空闲线程可以窃取其他线程队列中的任务

  • 动态调整并行度,充分利用CPU资源

  1. 内存效率
  • 通过depth参数控制递归深度

  • 在合适的时机在原数组和辅助数组之间切换

  • 避免不必要的数组复制

  1. 异常处理和完成保证
  • CountedCompleter确保即使在异常情况下也能正确处理任务完成
  • 通过计数机制保证所有子任务完成后才执行父任务的完成逻辑

性能优化策略

  1. 阈值控制

通过MIN_PARALLEL_SORT_SIZE等常量控制何时使用并行排序,避免小数组的并行开销。

  1. 深度限制

使用depth参数防止过度分解,在合适的粒度上进行并行处理。

  1. 类型特化

为不同的基本类型提供专门的实现,避免装箱拆箱的性能损失。

CountedCompleter在这里提供了一个优雅的并行任务协调框架,使得复杂的双轴快速排序算法能够有效地利用多核处理器的并行能力,同时保持代码的清晰性和正确性。

相关推荐
异常君10 分钟前
Spring 中的 FactoryBean 与 BeanFactory:核心概念深度解析
java·spring·面试
weixin_4612594123 分钟前
[C]C语言日志系统宏技巧解析
java·服务器·c语言
cacyiol_Z26 分钟前
在SpringBoot中使用AWS SDK实现邮箱验证码服务
java·spring boot·spring
竹言笙熙38 分钟前
Polarctf2025夏季赛 web java ez_check
java·学习·web安全
OpenCSG1 小时前
电子行业AI赋能软件开发经典案例——某金融软件公司
人工智能·算法·金融·开源
写bug写bug1 小时前
手把手教你使用JConsole
java·后端·程序员
异常君1 小时前
Java 中 try-catch 的性能真相:全面分析与最佳实践
java·面试·代码规范
witton1 小时前
美化显示LLDB调试的数据结构
数据结构·python·lldb·美化·debugger·mupdf·pretty printer
chao_7891 小时前
链表题解——环形链表 II【LeetCode】
数据结构·leetcode·链表