算法-排序算法

1 阅读56分钟

概述

排序是计算机科学中被研究得最透彻的问题之一。从冒泡排序笨拙的交换到快速排序精巧的分治,从基于比较的理论下界到突破限制的线性时间算法,每一种排序算法都是时间、空间、稳定性与实现复杂度之间的精密权衡。在真实业务系统中,排序绝非简单的“调用一个API”,它牵动着数据库索引设计、大数据Shuffle策略、UI分页一致性乃至微服务缓存击穿。本文旨在为资深工程师构建排序算法的完整知识体系:我们将以算法原理为主线,深入每种经典排序的内部机理,再从 TimSort 与 DualPivotQuicksort 等工业级实现中汲取工程智慧,最终帮助你在真实场景中做出最优的排序决策。

  • 评价维度:时间(最好/平均/最坏)、空间(原地/非原地)、稳定性、适应性、基于比较 vs 非比较。理解这些维度是选型的前提。
  • 算法分类:基础 O(n²)(冒泡、选择、插入)→ 高效 O(n log n)(希尔、归并、快排、堆排)→ 线性 O(n)(计数、基数、桶)。学习路径应当由简入繁,再回归本质。
  • 工程实现:Java 对象排序用 TimSort(稳定、自适应),基本类型用 DualPivotQuicksort(高性能、不稳定),并行排序用 parallelSort(ForkJoin 归并)。三者映射了不同的工程哲学。
  • 核心思想:分治(归并、快排)、增量(希尔)、选择(堆)、空间换时间(基数)、利用数据特征(TimSort)。思想是算法的灵魂。
  • 适用场景:数据库 ORDER BY、搜索引擎结果排序、大数据 Shuffle、实时排行榜、多级分页展示等。场景决定了算法的最终选择。

文章组织架构

flowchart TD
    subgraph A[概述与分类]
        A1[排序定义与评价维度]
        A2[算法分类图谱]
        A3[选择决策树]
        A4[反模式]
    end
    subgraph B[基础排序算法]
        B1[冒泡排序]
        B2[选择排序]
        B3[插入排序与希尔排序]
    end
    subgraph C[高效比较排序]
        C1[归并排序]
        C2[快速排序]
        C3[堆排序]
    end
    subgraph D[线性时间排序]
        D1[计数排序]
        D2[基数排序]
        D3[桶排序]
    end
    subgraph E[外部与并行排序]
        E1[外部排序]
        E2[并行排序]
    end
    subgraph F[工业级排序剖析]
        F1[TimSort]
        F2[DualPivotQuicksort]
    end
    subgraph G[工程实践与面试]
        G1[工程避坑]
        G2[面试高频专题]
    end
    A --> B --> C --> D --> E --> F --> G

图表说明:该流程图展示了本文的七大模块及其逻辑递进关系。我们从排序的宏观分类与评价体系开始,建立全局骨架;然后逐步深入到具体算法,先基础后高效,再扩展至外部与并行两大进阶话题;接着聚焦工业级实现的深度剖析,最后落地到工程实践与面试。这一路径遵循“为什么排序 → 有哪些排序 → 怎么选 → 内部如何工作 → 如何极致优化 → 生产环境避坑”的认知规律,理论建立骨架,算法填充血肉,工业实现注入灵魂


模块 1:排序算法概述与分类体系

1.1 排序的定义与形式化

给定一个包含 n 条记录的序列,每条记录包含一个。排序的目标是确定一个排列,使得对于任意两个记录 i < j,有 key[i] ≤ key[j](升序)。如果存在相等键,则排列可以有多种,此时稳定性决定了相等键之间的原始相对顺序是否得以保留。

1.2 评价维度的严格定义

  • 时间复杂度:比较次数、交换次数、递归深度等操作总量的渐近行为。通常区分最好情况最坏情况平均情况。基于比较的排序算法下界为 Ω(n log n)
  • 空间复杂度:额外内存占用。原地排序(in-place)通常指额外空间为 O(1) 或 O(log n)(递归栈)。非原地排序通常需要 O(n) 辅助空间(如归并排序)。
  • 稳定性:若两个记录的键相等,排序后它们的相对顺序不变,则称该排序是稳定的。稳定性对于多级排序至关重要。
  • 适应性:算法能否利用输入数据的部分有序性来提升性能。插入排序和 TimSort 是典型的自适应算法。
  • 基于比较 vs 非比较:基于比较的排序需要通过比较键值来确定顺序,其理论下界为 Ω(n log n);非比较排序利用键的额外性质(如取值范围固定)突破这一界限,可达 O(n)。
  • 并行潜力:算法能否被高效地并行化。归并排序天然适合分治并行,快速排序分区也可并行。

1.3 算法分类图谱

graph LR
    A["排序算法"] --> B{"基于比较"}
    B -->|"是"| C["比较排序 下界 Ω n log n"]
    B -->|"否"| D["非比较排序 可达 O n"]
    C --> E["内排序"]
    C --> F["外排序"]
    E --> E1["交换排序"]
    E --> E2["选择排序"]
    E --> E3["插入排序"]
    E --> E4["归并排序"]
    E --> E5["混合排序"]
    E1 --> E11["冒泡排序"]
    E1 --> E12["快速排序"]
    E2 --> E21["简单选择排序"]
    E2 --> E22["堆排序"]
    E3 --> E31["直接插入排序"]
    E3 --> E32["希尔排序"]
    E4 --> E41["归并排序"]
    E5 --> E51["TimSort 和 DualPivotQuicksort"]
    F --> F1["多路归并"]
    D --> D1["计数排序"]
    D --> D2["基数排序"]
    D --> D3["桶排序"]

图表说明:该分类图谱以 “基于比较” 作为顶层划分依据,这是最本质的分类。比较排序受限于 Ω(n log n) 的理论下界,又分为内排序(内存可容纳全部数据)和外排序(数据在磁盘上)。内排序进一步按操作分为交换、选择、插入、归并、混合五大类,涵盖了从冒泡到 TimSort 的所有典型算法。非比较排序通过空间换时间或利用键的域约束,可达线性时间。图中 Java 中的映射十分清晰:TimSort 属于混合排序,DualPivotQuicksort 属于交换排序,parallelSort 基于归并,计数/基数排序有独立实现。

1.4 排序算法选择决策树

flowchart TD
    Start[排序需求] --> Q1{数据量大小?}
    Q1 -->|n < 50| Small[插入排序]
    Q1 -->|n < 1000| Medium{稳定性/适应性?}
    Q1 -->|海量数据| Big{内存能否容纳?}
    Medium -->|需要稳定| MS[TimSort / 归并排序]
    Medium -->|无需稳定| MQ[快速排序 / 双轴快排]
    Big -->|能| Large{键值范围?}
    Big -->|不能| Ext[外部排序]
    Large -->|范围小| Cnt[计数排序 / 基数排序]
    Large -->|范围大| Lge{稳定性?}
    Lge -->|需要| MS
    Lge -->|不需要| MQ

图表说明:该决策树根据数据规模、稳定性需求、内存限制、键值范围四个维度给出推荐算法。小规模数据(n<50)直接使用插入排序,因为它的常数因子极低且对缓存友好;中等规模及大规模数据视稳定性需求分流到 TimSort/归并或快速排序;海量数据内存无法容纳则走外部排序;键值范围固定且较小时非比较排序是碾压级优势。选型时务必结合数据特征和业务约束,避免盲目套用。

1.5 反模式

编号反模式后果正确做法
1忽略稳定性导致分页异常多字段排序后前序顺序被破坏,分页数据跳动对对象排序使用稳定排序或组合比较器
2小数组使用递归排序递归开销远大于排序本身,性能倒退设定阈值(如47)切换至插入排序
3对任意类型强制使用非比较排序无法定义键的分布,结果错误仅正整数或固定范围数据可用
4频繁创建临时数组大量 GC 压力,吞吐下降预分配数组,复用缓冲区
5过于追求理论最优忽略工程常数小规模数据下复杂算法常数劣势明显按规模选择算法,混合策略
6单线程处理海量数据CPU 单核瓶颈,响应慢评估并行排序收益,达到阈值后启用

模块 2:基础 O(n²) 排序算法及其适应性

2.1 冒泡排序

算法原理

冒泡排序重复遍历数组,比较相邻元素并交换顺序错误者。每趟遍历将剩余未排序部分的最大元素“冒泡”到末尾。优化点在于设置交换标志:若一趟遍历没有发生任何交换,说明数组已经完全有序,可提前终止,将最好情况时间复杂度降为 O(n)

时间复杂度:最好 O(n)(已有序),最坏/平均 O(n²)。空间复杂度:O(1)。稳定性:稳定(相等元素不会交换)。

Java 实现及详细说明

import java.util.Arrays;

public class BubbleSort {

    /**
     * 冒泡排序(带提前终止优化)
     *
     * 算法步骤:
     *  1. 外层循环 i 从 0 到 n-2,每趟确定一个最大值的位置
     *  2. 内层循环 j 从 0 到 n-2-i,比较 arr[j] 与 arr[j+1]
     *  3. 若 arr[j] > arr[j+1],则交换两元素
     *  4. 若一整趟内层循环没有任何交换,说明数组已有序,提前退出外层循环
     *
     * @param arr 待排序的整型数组
     */
    public static void bubbleSort(int[] arr) {
        int n = arr.length;
        for (int i = 0; i < n - 1; i++) {
            // swapped 标记本趟是否发生过交换
            boolean swapped = false;

            // 每趟将 arr[0..n-1-i] 中的最大值移动到 arr[n-1-i]
            for (int j = 0; j < n - 1 - i; j++) {
                if (arr[j] > arr[j + 1]) {
                    // 交换相邻元素
                    int temp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = temp;
                    swapped = true; // 记录发生了交换
                }
            }

            // 如果一趟下来没有交换,说明数组已经完全有序,可以提前终止
            if (!swapped) {
                break;
            }
        }
    }

    public static void main(String[] args) {
        int[] array = {5, 1, 4, 2, 8};
        System.out.println("排序前: " + Arrays.toString(array));
        bubbleSort(array);
        System.out.println("排序后: " + Arrays.toString(array)); // [1, 2, 4, 5, 8]
    }
}

代码逐层解析

  • 外层循环 i:控制排序的趟数。每完成一趟,数组末尾 i 个元素已经是全局有序的最大值,因此内层循环的右边界逐渐左移。
  • 内层循环 j:执行相邻元素的比较和交换。这是冒泡排序的核心操作,每次比较都可能触发交换。
  • swapped 标志:实现了算法的适应性。如果输入数据已经几乎有序,可能在第一趟或前几趟就检测到 swapped=false,然后直接退出,避免无谓的比较。这一优化也让冒泡排序在最好情况下降至 O(n) 时间。
  • 常量空间:整个过程只使用了 tempswapped 等有限变量,是典型的原地排序
  • 稳定性:由于只有 arr[j] > arr[j+1] 时才交换,相等的元素永远相邻而不触发交换,因此原相对顺序得以保持。

过程演示(Mermaid 分步图)

flowchart TD
    subgraph 初始化
        direction LR
        S0["初始数组: [5, 1, 4, 2, 8]\n已排序部分: 无"]
    end

    subgraph 第1趟i0["第1趟 (i=0): 目标将最大值8冒到最后"]
        direction LR
        S1a["比较 j=0: 5 > 1 ? 是 → 交换"] --> S1b["状态: [1, 5, 4, 2, 8]"]
        S1b --> S1c["比较 j=1: 5 > 4 ? 是 → 交换"]
        S1c --> S1d["状态: [1, 4, 5, 2, 8]"]
        S1d --> S1e["比较 j=2: 5 > 2 ? 是 → 交换"]
        S1e --> S1f["状态: [1, 4, 2, 5, 8]"]
        S1f --> S1g["比较 j=3: 5 > 8 ? 否 → 不交换"]
        S1g --> S1h["趟结束, 已排序: [8]"]
    end

    subgraph 第2趟i1["第2趟 (i=1): 目标将5冒到倒数第二"]
        direction LR
        S2a["比较 j=0: 1 > 4 ? 否"] --> S2b["比较 j=1: 4 > 2 ? 是 → 交换"]
        S2b --> S2c["状态: [1, 2, 4, 5, 8]"]
        S2c --> S2d["比较 j=2: 4 > 5 ? 否"]
        S2d --> S2e["趟结束, 已排序: [5, 8]"]
    end

    subgraph 第3趟i2["第3趟 (i=2): 未发生交换 → 提前终止"]
        direction LR
        S3a["比较所有相邻对,均有序,swapped=false"] --> S3b["算法结束"]
    end

    S0 --> 第1趟i0
    第1趟i0 --> 第2趟i1
    第2趟i1 --> 第3趟i2
    第3趟i2 --> Result["最终结果: [1, 2, 4, 5, 8]"]

图表说明:该图模拟了数组 [5,1,4,2,8] 的冒泡排序过程。第一趟中,最大值 8 通过三次交换被推至最右端;第二趟只需将次大值 5 推到倒数第二。每趟结束后,已排序的尾部区域扩大。图中方框为每一步的数组状态,虚线箭头表示内层 j 循环的推进。通过分步展示,可以直观看到交换如何逐步将“大元素”向右“冒泡”。

2.2 选择排序

算法原理

选择排序每轮在未排序部分中扫描找出最小元素,并将其与未排序部分的第一个元素交换。这样,已排序部分逐渐扩大。由于其交换次数恒为 n−1,在所有 O(n²) 算法中具有最少的写操作次数,但算法不稳定,且无论输入是否有序,时间复杂度始终为 Θ(n²)。

时间复杂度:最好/平均/最坏均为 Θ(n²)。空间复杂度:O(1)。稳定性:不稳定。

Java 实现及详细说明

public class SelectionSort {

    /**
     * 选择排序
     * 算法步骤:
     *  1. 外层循环 i:将数组分为 [0, i-1] 已排序 和 [i, n-1] 未排序
     *  2. 在未排序部分中找到最小元素的下标 minIdx
     *  3. 将 arr[minIdx] 与 arr[i] 交换,将其归入已排序部分末尾
     *  4. 重复直到所有元素有序
     *
     * @param arr 整型数组
     */
    public static void selectionSort(int[] arr) {
        int n = arr.length;
        for (int i = 0; i < n - 1; i++) {
            // 假设未排序部分的第一个元素就是最小的
            int minIdx = i;

            // 在 arr[i+1..n-1] 中查找更小元素的下标
            for (int j = i + 1; j < n; j++) {
                if (arr[j] < arr[minIdx]) {
                    minIdx = j;
                }
            }

            // 将最小元素交换到当前位置 i
            // 即使 minIdx == i,也进行交换,但可以加判断避免自负交换
            if (minIdx != i) {
                int temp = arr[minIdx];
                arr[minIdx] = arr[i];
                arr[i] = temp;
            }
        }
    }

    public static void main(String[] args) {
        int[] a = {64, 25, 12, 22, 11};
        selectionSort(a);
        System.out.println(Arrays.toString(a)); // [11, 12, 22, 25, 64]
    }
}

代码逐层解析

  • 外层循环i 是已排序与未排序的分界线。[0, i-1] 已经有序,且其中的元素均小于后面任何未排序元素。
  • 内层循环:只在未排序部分 [i, n-1] 中扫描,仅仅记录最小值的下标 minIdx不做任何写操作。这避免了多余的交换,将交换次数降低到 O(n)。
  • 交换:每轮只做一次交换,将最小元素放到 i 位置。如果最小值恰好就在 i 本身,可以省略交换(优化常量因子)。
  • 不稳定性分析:例如序列 [5A, 8, 5B, 2],第一轮会将最小值 2 与 5A 交换,导致 5A5B 的相对顺序改变。

过程演示

flowchart TD
    Start["初始: [64, 25, 12, 22, 11]\n未排序: 全部"] --> Step1
    subgraph Step1["第1步 i=0"]
        direction LR
        A1["扫描 [64,25,12,22,11] 最小值 11 在索引4"] --> A2["交换 64 和 11"] --> A3["[11, 25, 12, 22, 64]\n已排序: 11"]
    end
    Step1 --> Step2
    subgraph Step2["第2步 i=1"]
        direction LR
        B1["扫描 [25,12,22,64] 最小值 12 在索引2"] --> B2["交换 25 和 12"] --> B3["[11, 12, 25, 22, 64]\n已排序: 11,12"]
    end
    Step2 --> Step3
    subgraph Step3["第3步 i=2"]
        direction LR
        C1["扫描 [25,22,64] 最小值 22 在索引3"] --> C2["交换 25 和 22"] --> C3["[11, 12, 22, 25, 64]\n已排序: 11,12,22"]
    end
    Step3 --> Step4
    subgraph Step4["第4步 i=3"]
        direction LR
        D1["扫描 [25,64] 最小值 25 在索引3"] --> D2["无需交换"] --> D3["[11, 12, 22, 25, 64]\n全部有序"]
    end

图表说明:每一步展示 i、当前未排序子数组及找到的最小值。绿色背景表示已排序区域,橙色表示本轮选中的最小元素。选择排序的每一步都精确确定一个最终位置,具有“选择”的特点。

2.3 插入排序

直接插入排序

插入排序维护一个有序前缀。初始时第一个元素自成有序区。然后依次将后续元素插入到前缀的正确位置,通过不断将大于新元素的已排序元素向右移动。该算法具有极强的适应性:对几乎有序的数据,内层 while 循环几乎不执行,复杂度接近 O(n)。而且它是稳定的,常数因子极小,因此常作为高效排序中小规模数据的基础。

时间复杂度:最好 O(n)(已有序),最坏/平均 O(n²)。空间:O(1)。稳定

Java 实现及详细说明

public class InsertionSort {

    /**
     * 直接插入排序
     *
     * 算法步骤:
     *  1. 从第二个元素 (i=1) 开始,将 arr[i] 插入到 [0..i-1] 有序前缀中
     *  2. 保存当前元素为 key
     *  3. 从 i-1 向前扫描,如果已排序元素大于 key,则将该元素右移一位
     *  4. 找到第一个 <= key 的位置,将 key 插入其后
     *  5. 由于移动操作是顺序后移,且 key 在循环结束后一次性插入,算法是稳定的
     *
     * @param arr 待排序数组
     */
    public static void insertionSort(int[] arr) {
        int n = arr.length;
        // 外层循环:依次将 arr[i] 插入到有序前缀
        for (int i = 1; i < n; i++) {
            int key = arr[i];        // 待插入的元素
            int j = i - 1;

            // 内层循环:将大于 key 的元素向右移动一格
            // 条件 `arr[j] > key` 确保相等时不移动,维持稳定性
            while (j >= 0 && arr[j] > key) {
                arr[j + 1] = arr[j]; // 元素右移
                j--;
            }
            // 此时 j 指向第一个 <= key 的位置,将 key 放在 j+1
            arr[j + 1] = key;
        }
    }

    public static void main(String[] args) {
        int[] a = {12, 11, 13, 5, 6};
        insertionSort(a);
        System.out.println(Arrays.toString(a)); // [5, 6, 11, 12, 13]
    }
}

代码逐层解析

  • 外层循环 i:代表当前待插入元素的下标。每次循环前,arr[0..i-1] 是已排序前缀。
  • 变量 key:暂存待插入的值,避免在移动过程中被覆盖。
  • 内层 while 循环右移操作。当 arr[j] > key 时,将 arr[j] 移动到 j+1,然后 j 向前移动。这一步利用了数组的连续存储,移动速度极快。
  • 稳定性关键:条件 arr[j] > key 而不是 >=,这意味着当遇到相等元素时不再移动,key 会被放到相等元素的后面,从而保持原始顺序。
  • 插入点arr[j + 1] = key。此时 j 可能是 -1(key 比所有前缀元素都小),此时插入到位置 0。
  • 局部性优势:右移操作在连续内存中进行,具有极佳的空间局部性,CPU 缓存命中率非常高。这正是插入排序在小规模数据上击败许多 O(n log n) 算法的原因。

过程演示(分步动画图)

flowchart TD
    subgraph 初始
        D0["数组: [12, 11, 13, 5, 6]\n有序前缀: [12]"]
    end

    subgraph 插入11
        direction LR
        A1["i=1, key=11"] --> A2["比较: 12 > 11 → 右移12"]
        A2 --> A3["插入11到索引0"] --> A4["变为: [11, 12, 13, 5, 6]"]
    end

    subgraph 插入13
        direction LR
        B1["i=2, key=13"] --> B2["比较: 12 ≤ 13 → 不移动"]
        B2 --> B3["直接插入索引2"] --> B4["变为: [11, 12, 13, 5, 6]"]
    end

    subgraph 插入5
        direction LR
        C1["i=3, key=5"] --> C2["比较: 13>5→右移; 12>5→右移; 11>5→右移"]
        C2 --> C3["插入5到索引0"] --> C4["变为: [5, 11, 12, 13, 6]"]
    end

    subgraph 插入6
        direction LR
        E1["i=4, key=6"] --> E2["比较: 13>6→右移; 12>6→右移; 11>6→右移; 5≤6→停止"]
        E2 --> E3["插入6到索引1"] --> E4["最终: [5, 6, 11, 12, 13]"]
    end

    初始 --> 插入11
    插入11 --> 插入13
    插入13 --> 插入5
    插入5 --> 插入6

图表说明:此图详细刻画了数组 [12,11,13,5,6] 的插入排序轨迹。每个 i 对应一次插入操作,框中分别展示有序前缀状态元素移动过程最终插入位置。可以清晰看到:当元素几乎有序(如 i=2)时,内层循环几乎不执行;而逆序元素(如 key=5)会导致大量右移操作。通过分步展示,插入排序的自适应特性一目了然。

折半插入排序

在插入排序中,使用二分查找代替线性查找来确定插入位置,可将比较次数从 O(n) 降至 O(log n)。但由于移动次数不变,总时间复杂度仍为 O(n²)。该优化在键值比较成本很高时(如长字符串比较)才具有实际价值。

Java 实现

public class BinaryInsertionSort {
    public static void binaryInsertionSort(int[] arr) {
        int n = arr.length;
        for (int i = 1; i < n; i++) {
            int key = arr[i];
            // 二分查找插入位置(在 [0, i] 之前有序区间查找)
            int left = 0, right = i;
            while (left < right) {
                int mid = (left + right) >>> 1;
                if (arr[mid] <= key) {
                    left = mid + 1;   // 继续在右半区查找
                } else {
                    right = mid;      // 插入点可能在 mid 或左边
                }
            }
            // 此时 left 即为插入位置,将 [left, i-1] 的元素后移一位
            System.arraycopy(arr, left, arr, left + 1, i - left);
            arr[left] = key;
        }
    }
}

代码解析:二分查找使用左闭右开区间 [left, right),查找后 left 就是第一个大于 key 的位置,所有 >= key 的元素均位于 left 之后。System.arraycopy 一次性移动整段元素,比循环逐个移动更快。虽然比较次数减少,但移动次数不变,适合比较成本高昂的场景


模块 3:希尔排序 —— 插入排序的增量扩展

算法原理

希尔排序是对插入排序的改进,通过引入 增量 (gap) 将数组分成若干个“交织”的子序列,先对这些子序列分别进行插入排序,然后逐步减小 gap 直至 1。初期 gap 较大时,元素能够进行长距离跳跃,快速消除大量逆序;后期 gap 较小时,数组已基本有序,插入排序接近 O(n)。整体性能依赖于增量序列。

常见增量序列:

  • Shell 序列:n/2, n/4, ..., 1(最坏 O(n²))
  • Hibbard 序列:1, 3, 7, ..., 2^k-1(最坏 O(n^(3/2)))
  • Sedgewick 序列:1, 5, 19, 41, 109...(最坏 O(n^(4/3)))

希尔排序不稳定,因为相等的元素可能分布在不同子序列中被交换位置。

Java 实现(Shell 增量)

public class ShellSort {
    /**
     * 希尔排序
     * 算法步骤:
     *  1. 计算初始 gap = n/2
     *  2. 对每个 gap,从 gap 开始到 n-1,对元素执行隔 gap 的插入排序
     *  3. gap 折半,重复直到 gap=0
     *
     * @param arr 整型数组
     */
    public static void shellSort(int[] arr) {
        int n = arr.length;
        // 增量逐步减小
        for (int gap = n / 2; gap > 0; gap /= 2) {
            // 对间隔为 gap 的子序列进行插入排序
            for (int i = gap; i < n; i++) {
                int temp = arr[i];
                int j = i;
                // 在子序列中向前比较和移动
                while (j >= gap && arr[j - gap] > temp) {
                    arr[j] = arr[j - gap];
                    j -= gap;
                }
                arr[j] = temp;
            }
        }
    }

    public static void main(String[] args) {
        int[] a = {12, 34, 54, 2, 3, 7, 21, 4};
        shellSort(a);
        System.out.println(Arrays.toString(a)); // [2, 3, 4, 7, 12, 21, 34, 54]
    }
}

代码逐层解析

  • 外层 gap 循环:使用 Shell 序列 n/2, n/4, ... 逐步细化排序。gap 的选取对性能有决定性影响。
  • 中间 for i 循环igap 开始向右扫描,保证每个子序列的“当前元素”都被处理,相当于多路交织的插入排序。
  • 内层 while 循环:在间隔为 gap 的子序列上进行插入排序。条件 arr[j - gap] > temp 控制稳定性和移动,但因为 gap 可能大于 1,相等元素可能跨子序列移动,所以希尔排序不稳定
  • 整体效果:gap 较大时,子序列短,插入排序极快;gap 减小时,数组近乎有序,插入排序也很快。这种“先粗后细”的策略极大改善了平均性能。

过程演示

graph TD
    A["初始数组 gap等于4 12 34 54 2 3 7 21 4"] --> B1
    subgraph B["gap等于4 排序"]
        B1["子序列1 12 3 排序后 3 12"] --> B2["子序列2 34 7 排序后 7 34"]
        B2 --> B3["子序列3 54 21 排序后 21 54"]
        B3 --> B4["子序列4 2 4 排序后 2 4"]
    end
    B4 --> Bfinal["合并 3 7 21 2 12 34 54 4"]
    Bfinal --> C1
    subgraph C["gap等于2 排序"]
        C1["子序列1 3 21 12 54 排序后 3 12 21 54"] --> C2["子序列2 7 2 34 4 排序后 2 4 7 34"]
    end
    C2 --> Cfinal["合并 3 2 12 4 21 7 54 34"]
    Cfinal --> D1
    subgraph D["gap等于1 排序"]
        D1["常规插入排序"] --> Dfinal["结果 2 3 4 7 12 21 34 54"]
    end

图表说明:此图展示了希尔排序在不同 gap 下的状态。gap=4 时,元素被分为 4 个子序列,分别插入排序;gap=2 时,数据已经部分有序;gap=1 就是一次标准插入排序,但由于前期工作,此时数组已基本有序,插入排序接近 O(n)。整个过程逐步消去长距离逆序,最终局部调整,完美体现了希尔排序的设计哲学。


模块 4:归并排序 —— 稳定性与外部排序的基石

4.1 自顶向下递归归并

算法原理

归并排序采用分治策略:

  1. 分解:将数组从中间分成两个子数组。
  2. 解决:递归地对左右子数组进行归并排序。
  3. 合并:将两个有序的子数组合并成一个有序数组,使用临时数组暂存合并结果。

时间复杂度:始终为 O(n log n)。空间复杂度:O(n)(临时数组)或 O(n + log n) 包含递归栈。稳定性:稳定(合并时相等元素保留左侧优先)。

Java 实现及详细说明

public class MergeSort {
    public static void mergeSort(int[] arr) {
        // 预分配一个全局临时数组,避免递归中反复创建
        int[] tmp = new int[arr.length];
        mergeSort(arr, 0, arr.length - 1, tmp);
    }

    /**
     * 递归归并排序核心
     * @param arr  原数组
     * @param left  左边界(包含)
     * @param right 右边界(包含)
     * @param tmp   临时数组
     */
    private static void mergeSort(int[] arr, int left, int right, int[] tmp) {
        if (left >= right) return;               // 递归终止:区间长度 <= 1
        int mid = (left + right) >>> 1;          // 防溢出计算中点
        mergeSort(arr, left, mid, tmp);          // 递归排序左半
        mergeSort(arr, mid + 1, right, tmp);     // 递归排序右半
        merge(arr, left, mid, right, tmp);       // 合并左右两个有序段
    }

    /**
     * 合并 arr[left..mid] 和 arr[mid+1..right] 到临时数组,再拷贝回原数组
     */
    private static void merge(int[] arr, int left, int mid, int right, int[] tmp) {
        int i = left;      // 左半部分指针
        int j = mid + 1;   // 右半部分指针
        int k = left;      // 临时数组指针

        // 双指针遍历两个有序段,较小者放入 tmp
        while (i <= mid && j <= right) {
            // 注意:使用 <= 保证稳定性,相等时左元素优先
            if (arr[i] <= arr[j]) {
                tmp[k++] = arr[i++];
            } else {
                tmp[k++] = arr[j++];
            }
        }
        // 拷贝左半剩余元素
        while (i <= mid) {
            tmp[k++] = arr[i++];
        }
        // 拷贝右半剩余元素
        while (j <= right) {
            tmp[k++] = arr[j++];
        }
        // 将临时数组合并区间的内容整体复制回原数组
        System.arraycopy(tmp, left, arr, left, right - left + 1);
    }

    public static void main(String[] args) {
        int[] a = {38, 27, 43, 3, 9, 82, 10};
        mergeSort(a);
        System.out.println(Arrays.toString(a)); // [3, 9, 10, 27, 38, 43, 82]
    }
}

代码逐层解析

  • 预分配临时数组:在公开方法中一次性分配 tmp,然后传递给递归函数复用。这样避免了每次合并都新建数组,降低了 GC 压力。
  • 递归终止条件left >= right,即区间长度 ≤ 1,天然有序。
  • 中点计算(left + right) >>> 1 无符号右移,防止整数溢出。
  • 合并过程:采用经典双指针arr[i] <= arr[j] 的条件确保了当左右元素相等时,左半部的元素优先进入临时数组,从而保证稳定性
  • 数组拷贝System.arraycopy 是 native 方法,内存复制效率极高,远优于手动循环。

归并过程示意图

flowchart 
    subgraph Left["左半有序: [27, 38, 43]"]
        L1[27] --> L2[38] --> L3[43]
    end
    subgraph Right["右半有序: [3, 9, 82]"]
        R1[3] --> R2[9] --> R3[82]
    end
    Left --> Merge
    Right --> Merge
    subgraph Merge["合并动态过程"]
        direction LR
        M1["i=0,j=0: 27 vs 3 → 取3, j++"] --> M2["i=0,j=1: 27 vs 9 → 取9, j++"]
        M2 --> M3["i=0,j=2: 27 vs 82 → 取27, i++"]
        M3 --> M4["i=1,j=2: 38 vs 82 → 取38, i++"]
        M4 --> M5["i=2,j=2: 43 vs 82 → 取43, i++"]
        M5 --> M6["左半耗尽,追加右半剩余 82"]
        M6 --> M7["tmp: [3,9,27,38,43,82]"]
    end
    Merge --> Result["拷贝回原数组"]
flowchart TD
    A["[38,27,43,3,9,82,10]"] --> B1["[38,27,43]"] --> B1a["[38]"] & B1b["[27,43]"] --> B1b1["[27]"] & B1b2["[43]"]
    A --> C1["[3,9,82,10]"] --> C1a["[3,9]"] --> C1a1["[3]"] & C1a2["[9]"]
    C1 --> C1b["[82,10]"] --> C1b1["[82]"] & C1b2["[10]"]
    B1a & B1b1 & B1b2 --> M1["合并: [27,38,43]"]
    C1a1 & C1a2 & C1b1 & C1b2 --> M2["合并: [3,9,10,82]"]
    M1 & M2 --> Final["最终合并: [3,9,10,27,38,43,82]"]

图表说明:此图详细刻画了合并操作的双指针推进逻辑。左右两个有序段各有一个索引指针,每次比较两指针所指元素,较小者进入 tmp 并移动指针。相等时左指针优先,这是归并排序稳定性的根源。最终所有元素依次进入 tmp,再拷贝回原数组,完成一次合并。

4.2 自底向上迭代归并

不采用递归,而是直接从长度为 1 的子数组开始,逐步两两合并,将 size 翻倍,直至覆盖整个数组。这种方法避免了递归开销,特别适合链表排序。

Java 迭代版实现

public class MergeSortBottomUp {
    public static void mergeSort(int[] arr) {
        int n = arr.length;
        int[] tmp = new int[n];
        // size 表示当前归并的子数组长度,1, 2, 4, 8 ...
        for (int size = 1; size < n; size *= 2) {
            // left 为左子数组的起始索引
            for (int left = 0; left < n - size; left += 2 * size) {
                int mid = left + size - 1;
                int right = Math.min(left + 2 * size - 1, n - 1);
                merge(arr, left, mid, right, tmp); // 复用自顶向下的 merge 方法
            }
        }
    }
    // merge 方法与前面递归版相同,此处省略
}

代码说明:迭代版并无原理差异,只是控制流程显式化。外层循环 size 倍增,内层循环按 2*size 跨步合并。由于深层递归消除,可应用于栈空间极受限的环境。

4.3 链表归并排序

链表归并排序利用链表 O(1) 时间拆分和合并的特点,能在 O(1) 额外空间 内完成稳定排序,是链表排序的首选。

class ListNode {
    int val;
    ListNode next;
    ListNode(int v) { val = v; }
}

public class LinkedListMergeSort {
    public static ListNode sortList(ListNode head) {
        if (head == null || head.next == null) return head;

        // 快慢指针找到链表中点,并断开成两个链表
        ListNode slow = head, fast = head.next;
        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
        }
        ListNode mid = slow.next;
        slow.next = null; // 切断

        ListNode left = sortList(head);
        ListNode right = sortList(mid);
        return mergeTwoLists(left, right);
    }

    private static ListNode mergeTwoLists(ListNode l1, ListNode l2) {
        ListNode dummy = new ListNode(0);
        ListNode tail = dummy;
        while (l1 != null && l2 != null) {
            if (l1.val <= l2.val) {
                tail.next = l1;
                l1 = l1.next;
            } else {
                tail.next = l2;
                l2 = l2.next;
            }
            tail = tail.next;
        }
        tail.next = (l1 != null) ? l1 : l2;
        return dummy.next;
    }
}

4.4 优化:小数组切换插入排序

在实际工程中(如 JDK TimSort),当子数组长度小于某一阈值(例如 32)时,转而使用插入排序。原因是插入排序在小规模数据上常数因子极低,且归并和递归的额外开销在小数组上性价比不高。我们可以在递归函数中加入如下判断:

if (right - left < 32) {
    insertionSort(arr, left, right);
    return;
}

模块 5:快速排序 —— 最广泛使用的比较排序

5.1 分区策略:Hoare 分区与三数取中

算法原理

快速排序通过选择一个基准元素 (pivot),将数组划分为两部分:左侧元素 ≤ pivot,右侧元素 ≥ pivot,然后递归地对两个子数组排序。分区算法核心是重排元素。

  • Lomuto 分区:单指针扫描,代码简单但交换次数多,效率较低。
  • Hoare 分区:双指针分别从两端向中间扫描,遇到逆序对就交换,直到指针交错。Hoare 分区的交换次数约为 Lomuto 的 1/3,且能更好地利用 CPU 缓存,因此现代工业实现普遍采用。

pivot 选取直接影响性能:

  • 固定选首或尾元素:遇到已排序数据退化为 O(n²)。
  • 随机选取:将最坏概率降至极低,但有随机数开销。
  • 三数取中 (median-of-three):取首、中、尾三个元素的中位数,兼顾了良好划分和极低开销,是工业界最常用的策略。

Java 实现(Hoare 分区 + 三数取中 + 插入排序回退)

public class QuickSort {

    public static void quickSort(int[] arr) {
        quickSort(arr, 0, arr.length - 1);
    }

    private static void quickSort(int[] arr, int low, int high) {
        if (low >= high) return;

        // 优化:小数组使用插入排序,阈值设为 47(JDK 风格)
        if (high - low < 47) {
            insertionSort(arr, low, high);
            return;
        }

        // 获取分区点(pivot 的最终位置)
        int pivotIndex = partition(arr, low, high);
        // 注意:Hoare 分区后左段包含 pivot,递归至 pivotIndex,而非 pivotIndex-1
        quickSort(arr, low, pivotIndex);
        quickSort(arr, pivotIndex + 1, high);
    }

    /**
     * Hoare 分区算法,返回分区点下标 j(左段的结束位置)
     * 最终 arr[low .. j] <= pivot,arr[j+1 .. high] >= pivot
     */
    private static int partition(int[] arr, int low, int high) {
        // 三数取中选择 pivot 值(不交换到别处,纯选取值)
        int mid = (low + high) >>> 1;
        int pivot = medianOfThree(arr[low], arr[mid], arr[high]);

        // 双指针初始化:i 为 low-1,j 为 high+1
        int i = low - 1;
        int j = high + 1;
        while (true) {
            // 从左向右找到第一个 >= pivot 的元素
            do {
                i++;
            } while (arr[i] < pivot);

            // 从右向左找到第一个 <= pivot 的元素
            do {
                j--;
            } while (arr[j] > pivot);

            // 指针交错,分区完成
            if (i >= j) {
                return j;   // j 是左段最后一个元素的下标
            }

            // 交换这两个错位元素
            swap(arr, i, j);
        }
    }

    /**
     * 三数取中,返回中间大小的数值
     */
    private static int medianOfThree(int a, int b, int c) {
        // 异或交换版本或 if 版本,确保三数有序排列后取第二个
        if (a > b) { int t = a; a = b; b = t; }
        if (a > c) { int t = a; a = c; c = t; }
        if (b > c) { int t = b; b = c; c = t; }
        return b;
    }

    private static void swap(int[] arr, int i, int j) {
        int tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    }

    // 针对小数组的插入排序
    private static void insertionSort(int[] arr, int low, int high) {
        for (int i = low + 1; i <= high; i++) {
            int key = arr[i];
            int j = i - 1;
            while (j >= low && arr[j] > key) {
                arr[j + 1] = arr[j];
                j--;
            }
            arr[j + 1] = key;
        }
    }

    public static void main(String[] args) {
        int[] a = {9, -3, 5, 2, 6, 8, -6, 1, 3};
        quickSort(a);
        System.out.println(Arrays.toString(a));
    }
}

代码逐层解析

  • 三数取中函数 medianOfThree:通过对三个数排序,返回中间值。该值作为 pivot 的参与比较,但并不将 pivot 交换到某个固定位置,而是在 Hoare 分区中通过 do-while 首次定位自动找到其位置。
  • Hoare 分区ij 分别从两侧向中间移动,一旦发现 arr[i] >= pivotarr[j] <= pivot 就交换。循环结束后 j 成为左右段的分界点,左段元素 ≤ pivot,右段元素 ≥ pivot
  • 递归调用quickSort(arr, low, pivotIndex); 注意左段包含 pivot,因此递归范围包括 pivotIndex。这是 Hoare 分区的巧妙之处:pivot 不一定恰好在分界点,但保证左段都 ≤ 右段,无需再处理分界元素。
  • 小数组阈值:当数组长度小于 47 时,归并到插入排序,避免递归栈开销和分区复杂度的常数代价。
  • 稳定性:由于交换可能跨越大段距离,快速排序不稳定

分区过程演示(Hoare 分区动画图)

flowchart TD
    subgraph Step1["初始 low=0 high=8, pivot=5 (三数取中后)"]
        A1["i=-1, j=9<br>数组: 9,-3,5,2,6,8,-6,1,3"]
    end
    subgraph Step2["第一轮扫描"]
        B1["i++: i=0(9>=5)"] --> B2["j--: j=8(3<=5)"] --> B3["交换 9 和 3<br>数组: 3,-3,5,2,6,8,-6,1,9"]
    end
    subgraph Step3["继续扫描"]
        C1["i++: i=1(-3<5) i=2(5>=5)"] --> C2["j--: j=7(1<=5)"] --> C3["交换 5 和 1<br>数组: 3,-3,1,2,6,8,-6,5,9"]
    end
    subgraph Step4["继续扫描 i=3(2<5) i=4(6>=5); j=6(-6<=5)"]
        D1["交换 6 和 -6<br>数组: 3,-3,1,2,-6,8,6,5,9"]
    end
    subgraph Step5["i=j=5(8>=5), j=5(8>5) j-- j=4(-6<5)"]
        E1["i=5 >= j=4: 交错,返回 j=4"]
    end
    E1 --> Final["分区结果: 左段 [3,-3,1,2,-6] 右段 [8,6,5,9] 完成"]

图表说明:此系列图详细展示了 Hoare 分区的完整扫描过程。双指针从两端交替移动,每当找到一对“i 位置元素大于等于 pivot 且 j 位置元素小于等于 pivot”时便交换,直到两指针交错。最终所有小于等于 pivot 的元素落到左部,大于等于的落在右部。Hoare 分区的交换次数远少于 Lomuto 分区,效率更高。

5.2 三向切分快速排序

当数组中包含大量重复元素时,三向切分将数组划分为 < pivot, = pivot, > pivot 三个区域,可以将重复元素的处理时间降至接近 O(n)。Java 7 开始对基本类型排序使用的 DualPivotQuicksort 就蕴含了类似三向切分的思想。

Java 实现

public class Quick3Way {
    public static void sort(int[] arr) {
        sort(arr, 0, arr.length - 1);
    }

    private static void sort(int[] arr, int low, int high) {
        if (high <= low) return;
        int lt = low, i = low + 1, gt = high;
        int pivot = arr[low];
        while (i <= gt) {
            int cmp = Integer.compare(arr[i], pivot);
            if (cmp < 0) {
                swap(arr, lt++, i++);
            } else if (cmp > 0) {
                swap(arr, i, gt--);
            } else {
                i++;
            }
        }
        // arr[low..lt-1] < pivot, arr[lt..gt] = pivot, arr[gt+1..high] > pivot
        sort(arr, low, lt - 1);
        sort(arr, gt + 1, high);
    }
    // swap 省略
}

代码说明:三个指针 lt, i, gt 分别维护小于区、等于区、大于区的边界。当 i 遇到大于 pivot 的元素时与 gt 交换,gt 左移;遇到小于的元素与 lt 交换,lt 和 i 都右移;相等时 i 直接右移。这样等于区的元素被“压”到中间,无需参与后续递归。


模块 6:堆排序 —— 原地且最坏 O(n log n)

6.1 算法原理

堆排序使用二叉堆维护一个偏序结构。首先将数组原地构建为最大堆(建堆过程 O(n)),然后反复将堆顶(最大值)与未排序部分末尾交换,并对新的堆顶进行下沉(siftDown)操作以恢复堆性质。如此执行 n-1 次,完成排序。

时间复杂度:建堆 O(n),排序 O(n log n),整体 O(n log n) 且无最坏退化。空间复杂度:O(1),是原地排序。不稳定(交换堆顶可能破坏相等元素的相对次序)。

Java 实现及详细说明

public class HeapSort {

    public static void heapSort(int[] arr) {
        int n = arr.length;

        // 1. 构建最大堆:从最后一个非叶子节点开始向前下沉
        // 最后一个非叶子节点下标为 (n/2 - 1)
        for (int i = n / 2 - 1; i >= 0; i--) {
            siftDown(arr, n, i);
        }

        // 2. 逐一将堆顶(最大值)交换到尾部,并调整堆
        for (int i = n - 1; i > 0; i--) {
            // 交换堆顶与当前堆的最后一个元素
            swap(arr, 0, i);
            // 对新的堆顶(原尾部元素)进行下沉,堆大小减少为 i
            siftDown(arr, i, 0);
        }
    }

    /**
     * 下沉操作:调整以 root 为根的子树,使其满足最大堆性质
     * @param arr 数组表示的堆
     * @param heapSize 当前堆的有效大小
     * @param root 需要下沉调整的根下标
     */
    private static void siftDown(int[] arr, int heapSize, int root) {
        int largest = root;           // 假设根最大
        int left = 2 * root + 1;      // 左子节点索引
        int right = 2 * root + 2;     // 右子节点索引

        // 如果左子存在且大于当前最大,更新 largest
        if (left < heapSize && arr[left] > arr[largest]) {
            largest = left;
        }
        // 如果右子存在且大于当前最大,更新 largest
        if (right < heapSize && arr[right] > arr[largest]) {
            largest = right;
        }

        // 若最大值不是根,则交换并递归下沉
        if (largest != root) {
            swap(arr, root, largest);
            // 递归调整被交换的子树
            siftDown(arr, heapSize, largest);
        }
    }

    private static void swap(int[] arr, int i, int j) {
        int tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    }

    public static void main(String[] args) {
        int[] a = {12, 11, 13, 5, 6, 7};
        heapSort(a);
        System.out.println(Arrays.toString(a)); // [5, 6, 7, 11, 12, 13]
    }
}

代码逐层解析

  • 建堆循环for (int i = n / 2 - 1; i >= 0; i--) 自底向上对每个非叶节点执行 siftDown。为什么时间复杂度是 O(n)?因为大部分节点层级较低,下沉次数很少。数学可以证明总操作次数上限为 Σ ceil(log n) - h_i,结果收敛于 O(n)。
  • 下沉操作 siftDown:比较 root 与其左右子节点,选出最大值。如果最大值不是 root,则交换 root 与最大值对应子节点,并递归调整被交换的子树。这是堆排序的核心原语。
  • 排序循环:每次交换 arr[0](堆顶,当前最大值)与 arr[i](堆尾),然后堆大小缩减为 i,对新的堆顶下沉。经过 n-1 次,数组从左至右升序排列。
  • 不稳定性:例如 [3, 2a, 2b],建堆后可能为 [3,2a,2b],第一次交换 32b,得到 [2b,2a,3],即打乱了两个 2 的顺序。

过程示意图

flowchart
    subgraph 建堆["建堆阶段 (从 i=2 到 0)"]
        direction LR
        H1["初始: [12,11,13,5,6,7]"] --> H2["i=2 (值13) 下沉 → 无交换"]
        H2 --> H3["i=1 (值11) 与右子6比较,11>6无需交换"]
        H3 --> H4["i=0 (值12) 与左13比较,13>12 交换 → [13,11,12,5,6,7],继续下沉12与右7比较不交换"]
        H4 --> H5["最大堆完成: [13,11,12,5,6,7]"]
    end
    subgraph 排序["排序阶段"]
        direction LR
        S1["交换堆顶13与末尾7 → [7,11,12,5,6,13]"] --> S2["对7下沉: 12>7且12>11? 12最大,交换7与12 → [12,11,7,5,6,13],下沉7与6比较不交换"]
        S2 --> S3["交换堆顶12与6 → [6,11,7,5,12,13]"] --> S4["下沉6: 11最大,交换6与11 → [11,6,7,5,12,13]"]
        S4 --> S5["继续..."] --> Final["最终有序: [5,6,7,11,12,13]"]
    end
    建堆 --> 排序

图表说明:图中上半部分为建堆过程,箭头表示从非叶节点向前执行下沉,最终堆顶为全局最大值。下半部分为排序阶段,每次交换堆顶与末尾,并调整堆,最终产生升序序列。


模块 7:线性时间排序 —— 突破 O(n log n) 的边界

7.1 计数排序

算法原理

计数排序不经过元素间的比较,而是利用键值的范围信息,通过统计每个值出现的频率,然后计算累积分布来确定每个元素在最终数组中的位置。必须依赖额外的计数数组,故属于空间换时间策略,且其稳定性通过反向遍历原数组得以保障。

时间复杂度:O(n + k),k 为值域大小(max-min+1)。空间复杂度:O(n + k)。稳定

Java 实现(支持负数)

public class CountingSort {
    /**
     * 计数排序,支持包含负数的数组
     */
    public static void countingSort(int[] arr) {
        if (arr == null || arr.length == 0) return;

        // 找出最大值和最小值,以确定计数数组范围
        int max = arr[0], min = arr[0];
        for (int v : arr) {
            if (v > max) max = v;
            if (v < min) min = v;
        }

        int range = max - min + 1;          // 值域大小
        int[] count = new int[range];       // 计数数组,下标偏移 min

        // 1. 统计频率
        for (int v : arr) {
            count[v - min]++;
        }

        // 2. 累积频率,count[i] 表示值 (i+min) 在结果中的最后一个位置索引+1
        for (int i = 1; i < range; i++) {
            count[i] += count[i - 1];
        }

        // 3. 反向遍历原数组,根据累积位置稳定放入输出数组
        int[] output = new int[arr.length];
        for (int i = arr.length - 1; i >= 0; i--) {
            int val = arr[i];
            // 找到 val 对应的累积位置,减 1 得到在 output 中的索引
            int pos = count[val - min] - 1;
            output[pos] = val;
            count[val - min]--;   // 向前移动该值的写入位置
        }

        // 4. 将输出数组拷贝回原数组
        System.arraycopy(output, 0, arr, 0, arr.length);
    }

    public static void main(String[] args) {
        int[] a = {4, 2, -2, 8, 3, 3, 1};
        countingSort(a);
        System.out.println(Arrays.toString(a)); // [-2, 1, 2, 3, 3, 4, 8]
    }
}

代码逐层解析

  • 范围确定:通过 max - min + 1 计算计数数组长度,并用 v - min 将值映射到非负索引,完美支持负数排序。
  • 频率统计:第一遍遍历,count[idx]++ 统计每个值的出现次数。
  • 累积分布count[i] += count[i-1] 后,count[idx] 变为最后一个该值元素在输出数组中的位置+1。例如值为 val 的元素要放到 output[ count[val-min] - 1 ]
  • 反向遍历保持稳定:之所以从 arr 末尾向前遍历,是因为累积分布给了值的最后一个位置,反向遍历时,后出现的相同值会先放在靠后的位置,然后 count[idx]--,使得前一个相同值放在更前的位置,从而保持原始相对顺序。
  • 输出拷贝:利用 System.arraycopy 高效复制回原数组。

过程示意图

flowchart 
    subgraph Step1["统计频率"]
        A["原数组: 4,2,8,3,3,1"] --> C1["count: [0:1, 1:1, 2:1, 3:2, 4:1, 5:0,6:0,7:0,8:1]"]
    end
    subgraph Step2["累积分布"]
        C1 --> C2["累积后: [0:1, 1:2, 2:3, 3:5, 4:6, ...]"]
    end
    subgraph Step3["反向放置"]
        C2 --> P1["i=5 val=1 count[1]=2 -> output[1]=1"]
        P1 --> P2["i=4 val=3 count[3]=5 -> output[4]=3"]
        P2 --> P3["i=3 val=3 count[3]=4 -> output[3]=3"]
        P3 --> P4["i=2 val=8 count[8]=... -> output[5]=8"]
        P4 --> P5["i=1 val=2 count[2]=3 -> output[2]=2"]
        P5 --> P6["i=0 val=4 count[4]=6 -> output[5]=4"]
    end
    Step3 --> Result["结果: 1,2,3,3,4,8"]

图表说明:详细描绘了计数排序的三大步骤。第一步统计频率;第二步将频率累积为每个值的最终位置;第三步反向遍历原数组并放置元素。注意两个 3 的放置顺序:后遍历到的 3(位置 3)放到了 output[3],前一个 3(位置 4)放到了 output[4],相对顺序不变,稳定性得以保证。

7.2 基数排序(LSD)

算法原理

基数排序将所有键值视为等长的数字序列,从最低有效位(LSD)开始,对每一位使用稳定的计数排序,逐步向高位推进。由于每一趟排序是稳定的,当高位相同时,低位排序的结果得到保留,从而最终整体有序。

时间复杂度:O(d * (n + k)),d 为最大位数,k 为基数(十进制为 10)。空间复杂度:O(n + k)。稳定

Java 实现(LSD,处理正整数)

public class RadixSort {
    public static void radixSort(int[] arr) {
        if (arr == null || arr.length == 0) return;

        // 找出最大值以确定最高位数
        int max = Arrays.stream(arr).max().getAsInt();

        // 对每一位进行计数排序,exp = 1, 10, 100, ...
        for (int exp = 1; max / exp > 0; exp *= 10) {
            countingSortByDigit(arr, exp);
        }
    }

    /**
     * 根据当前位 exp 对数组 arr 进行稳定计数排序
     */
    private static void countingSortByDigit(int[] arr, int exp) {
        int n = arr.length;
        int[] output = new int[n];
        int[] count = new int[10]; // 基数 10

        // 1. 统计当前位数字的频率
        for (int value : arr) {
            int digit = (value / exp) % 10;
            count[digit]++;
        }

        // 2. 累积频率
        for (int i = 1; i < 10; i++) {
            count[i] += count[i - 1];
        }

        // 3. 反向遍历原数组,根据当前位数字放置到 output
        for (int i = n - 1; i >= 0; i--) {
            int digit = (arr[i] / exp) % 10;
            output[count[digit] - 1] = arr[i];
            count[digit]--;
        }

        // 4. 将本轮排序结果拷贝回 arr,供下一位使用
        System.arraycopy(output, 0, arr, 0, n);
    }

    public static void main(String[] args) {
        int[] a = {170, 45, 75, 90, 802, 24, 2, 66};
        radixSort(a);
        System.out.println(Arrays.toString(a)); // [2, 24, 45, 66, 75, 90, 170, 802]
    }
}

代码逐层解析

  • 外层按位循环exp 从 1 开始每次乘 10,直到超过最大值。每一趟处理一个十进制位。
  • 按位计数排序:除了提取 digit = (value / exp) % 10,其余步骤与标准计数排序完全一致,都是稳定的。稳定性传导机制是 LSD 基数排序正确的关键:低位排序的相对顺序会保留到高位排序中。
  • 处理负数:如果是包含负数的数组,可以先按符号分开,或者所有数加上一个偏移量统一转为非负,排序后再减回去。

基数排序过程图

flowchart 
    A["原始: 170,45,75,90,802,24,2,66"] --> B["个位排序 (exp=1)"]
    B --> B1["按个位分配到桶: 0:170,90,802; 2:2; 4:24; 5:45,75; 6:66"]
    B1 --> B2["收集: 170,90,802,2,24,45,75,66"]
    B2 --> C["十位排序 (exp=10)"]
    C --> C1["按十位分桶: 0:802,2; 2:24; 4:45; 6:66; 7:170,75; 9:90"]
    C1 --> C2["收集: 802,2,24,45,66,170,75,90"]
    C2 --> D["百位排序 (exp=100)"]
    D --> D1["按百位分桶: 0:2,24,45,66,75,90; 1:170; 8:802"]
    D1 --> D2["最终: 2,24,45,66,75,90,170,802"]

图表说明:该图完整展示了 LSD 基数排序的分配-收集过程。每次按一位数字分配到 0-9 桶中,然后按桶顺序收集。由于每趟使用的计数排序是稳定的,高位排序不会打乱低位已经排好的顺序。例如,经过个位排序后,个位相同的 170 和 90 的顺序在后续高位排序中仍然保持。

7.3 桶排序

假设输入数据均匀分布,桶排序将元素分配到若干个有序的桶区间中,每个桶内再用插入排序(或其它排序)排序,最后合并。期望时间复杂度 O(n),但如果数据严重不均匀,最坏退化到 O(n²)。

Java 实现示例(前面已有,不再赘述,但此处进行完善):

public class BucketSort {
    public static void bucketSort(int[] arr) {
        int n = arr.length;
        if (n <= 0) return;
        int max = arr[0], min = arr[0];
        for (int v : arr) { max = Math.max(max, v); min = Math.min(min, v); }

        // 根据数据范围与 n 确定桶数量,每个桶大小约为 (max-min)/n
        int bucketCount = (max - min) / n + 1;
        List<List<Integer>> buckets = new ArrayList<>(bucketCount);
        for (int i = 0; i < bucketCount; i++) buckets.add(new ArrayList<>());

        // 分配
        for (int v : arr) {
            int idx = (v - min) / n; // 映射到桶索引
            buckets.get(idx).add(v);
        }

        // 桶内排序并合并
        int idx = 0;
        for (List<Integer> bucket : buckets) {
            Collections.sort(bucket); // 使用 TimSort,适合小数据
            for (int v : bucket) arr[idx++] = v;
        }
    }
}

关键点:桶的个数与数据规模 n 相近,期望每个桶元素很少,这样桶内排序开销极小。前提假设数据分布均匀,否则个别桶会堆积大量元素,退化。


模块 8:外部排序 —— 当数据超越内存

8.1 外部排序模型

当待排序文件远远大于可用内存时,不能一次性将所有数据加载到内存进行排序,必须借助外部存储(磁盘)进行外部排序。经典流程包含两个阶段:

  1. 生成初始归并段:将大文件顺序读入内存(每次读入一个块),在内存中对这一块数据进行内部排序(如快速排序),然后将有序结果写回磁盘,形成一个有序段。如此重复,生成多个有序段文件。
  2. 多路归并:将这些有序段通过 k 路归并算法合并成一个全局有序文件。归并时,每次从 k 个段中读取一块数据放入输入缓冲区,利用最小堆败者树选择当前所有段首部的最小值,输出到最终文件。

磁盘 I/O 成本:外部排序的主要时间花在磁盘读写上。总 I/O 量大约是 2 × 数据总量 × (归并趟数 + 1)。优化目标:减少归并趟数

graph LR
    Disk["大文件"] --> Block1["块1 内部排序"] --> Seg1["有序段1"]
    Disk --> Block2["块2 内部排序"] --> Seg2["有序段2"]
    Disk --> Block3["块3 内部排序"] --> Seg3["有序段3"]
    Seg1 --> Merging["多路归并 堆或败者树"]
    Seg2 --> Merging
    Seg3 --> Merging
    Merging --> Output["输出全局有序文件"]

8.2 置换-选择排序

为了产生更长的初始有序段(减少段的数量,从而减少归并趟数),可以采用置换-选择排序。它利用一个有限大小的优先队列模拟内排序,动态输出最小元素并吸收新元素,可以产生平均长度为内存容量 2 倍的有序段。

8.3 多路归并的 Java 模拟实现

public class ExternalSortSimulation {

    /**
     * 模拟外部排序:将数据分块排序,然后多路归并
     * @param data 全部数据(内存中模拟)
     * @param blockSize 每个块的大小(模拟内存限制)
     * @return 排序后的列表
     */
    public static List<Integer> externalSort(List<Integer> data, int blockSize) {
        // 1. 生成初始有序段(内存中模拟写入磁盘的 sorted runs)
        List<List<Integer>> sortedRuns = new ArrayList<>();
        for (int i = 0; i < data.size(); i += blockSize) {
            int end = Math.min(i + blockSize, data.size());
            List<Integer> block = new ArrayList<>(data.subList(i, end));
            Collections.sort(block); // 内部排序
            sortedRuns.add(block);    // 视为写入磁盘的有序段
        }

        // 2. 多路归并:用一个最小堆维持每个 run 的当前头部元素
        PriorityQueue<RunElement> minHeap = new PriorityQueue<>();
        int k = sortedRuns.size();
        // 每个 run 的当前读取位置索引
        int[] pointers = new int[k];
        for (int i = 0; i < k; i++) {
            List<Integer> run = sortedRuns.get(i);
            if (!run.isEmpty()) {
                minHeap.offer(new RunElement(run.get(0), i));
            }
        }

        List<Integer> result = new ArrayList<>();
        while (!minHeap.isEmpty()) {
            RunElement minElem = minHeap.poll();      // 取出当前最小值
            result.add(minElem.value);
            int runIdx = minElem.runIndex;
            pointers[runIdx]++;
            List<Integer> run = sortedRuns.get(runIdx);
            // 如果该 run 还有元素,将下一个元素加入堆
            if (pointers[runIdx] < run.size()) {
                minHeap.offer(new RunElement(run.get(pointers[runIdx]), runIdx));
            }
        }
        return result;
    }

    static class RunElement implements Comparable<RunElement> {
        int value;
        int runIndex;
        RunElement(int v, int idx) { value = v; runIndex = idx; }
        public int compareTo(RunElement o) { return Integer.compare(value, o.value); }
    }

    public static void main(String[] args) {
        List<Integer> data = Arrays.asList(5,2,9,1,5,6,7,3,8,4,10,0);
        List<Integer> sorted = externalSort(data, 4); // 块大小4
        System.out.println(sorted); // [0,1,2,3,4,5,5,6,7,8,9,10]
    }
}

代码解析externalSort 方法模拟了外部排序的完整流程。第 1 阶段用 blockSize 控制内存块大小,分块内部排序产生有序段;第 2 阶段使用 PriorityQueue(对应实际中的败者树)实现 k 路归并。每个 RunElement 记录元素值及其来源段索引,确保可以正确读取下一个元素。该方法完全模拟了外部排序的逻辑,只不过在内存中完成。

外部排序多路归并示意图

flowchart LR
    S1["有序段1<br>1,4,7,10"] --> M[多路归并<br>最小堆]
    S2["有序段2<br>2,5,8,11"] --> M
    S3["有序段3<br>3,6,9,12"] --> M
    M --> O[输出: 1,2,3,4,5,6,7,8,9,10,11,12]

图表说明:多个有序段的数据像水流一样涌入归并器,归并器通过最小堆不断比较各路当前头部,将最小值弹出并输出,同时从相应有序段读入下一个元素。如此边读边写,最终汇集成全局有序输出。


模块 9:并行排序 —— 利用多核

9.1 Arrays.parallelSort 原理

Java 8 引入的 Arrays.parallelSort() 基于 ForkJoinPool 实现并行归并排序。它将数组拆分成多个子任务,每个子任务在超过阈值(如 8192)时继续拆分,否则调用 Arrays.sort()(双轴快排或 TimSort)完成子数组排序,然后并行地合并有序子数组。框架使用工作窃取算法平衡负载。

9.2 使用示例与注意事项

import java.util.Arrays;

public class ParallelSortDemo {
    public static void main(String[] args) {
        int size = 10_000_000;
        int[] arr = new int[size];
        Arrays.parallelSetAll(arr, i -> ThreadLocalRandom.current().nextInt());

        long start = System.nanoTime();
        Arrays.parallelSort(arr);
        long end = System.nanoTime();
        System.out.printf("并行排序耗时: %.2f ms%n", (end - start) / 1e6);
    }
}

注意事项

  • 并行排序有线程调度开销,适用于大型数组(通常 > 8192),小数据反而更慢。
  • 默认使用公共 ForkJoinPool,可能与系统中其他并行任务争抢资源,大型应用可自定义线程池。
  • 并行排序是 DualPivotQuicksortTimSort 内部排序 + 外部归并的混合,因此对基本类型不稳定,对对象类型稳定。

并行排序序列图

sequenceDiagram
    participant Main as 主线程
    participant FJ as ForkJoinPool
    participant W1 as Worker1
    participant W2 as Worker2
    Main->>FJ: parallelSort(arr, 0, n-1)
    FJ->>W1: 左半部分排序任务
    FJ->>W2: 右半部分排序任务
    W1->>W1: 内部排序 (DualPivotQuicksort/TimSort)
    W2->>W2: 内部排序
    W1-->>FJ: 左半有序
    W2-->>FJ: 右半有序
    FJ->>FJ: 合并两个有序子数组
    FJ-->>Main: 排序完成

图表说明:该序列图描绘了并行排序的整个执行流程。主线程将任务提交到 ForkJoinPool,由两个 Worker 并行执行内部排序,之后合并结果。工作窃取机制允许完成快的 Worker 协助完成未完成的任务,最大化 CPU 利用率。


模块 10:工业级排序深度剖析 —— TimSort 与 DualPivotQuicksort

10.1 TimSort(稳定,对象排序)

设计动机与核心思想

现实世界的数据往往部分有序(例如按时间戳排序的日志、连续插入的数据)。TimSort 充分利用了这一特点,它扫描数组找出自然的连续有序段(称为 run),然后通过归并这些 run 完成排序。如果 run 太短,就用插入排序扩展至最小长度 minrun。最终通过栈合并策略维持 O(n log n) 的最坏时间,并在最好情况(数据已有序)达到 O(n)。

流程详解

  1. 查找 run:扫描数组,若为严格降序则反转成升序,得到一个升序 run。
  2. 扩展 run:如果 run 长度小于 minrun(通过数学公式计算),则用二分插入排序将后续元素加入 run,使其长度达到 minrun,同时保证这一段的稳定性。
  3. 栈管理:将 run 的信息(起始位置、长度)压栈。每次压入后,检查栈顶三个 run 是否满足不等式 X > Z + YY > Z(其中 XYZ 是栈顶向下数的三个 run),若不满足则合并其中某些 run,以维持斐波那契式的合并树,确保总合并开销最小。
  4. 合并优化:合并相邻 run 时,使用临时数组拷贝较小的 run,然后进行归并。归并中若检测到某个 run 的元素连续多次胜出(如 7 次),则切换为 Galloping 模式(指数搜索),跳过连续区域,减少比较次数。

关键特性:稳定、自适应、最坏 O(n log n)。

TimSort run 栈状态图

stateDiagram-v2
    [*] --> 寻找Run
    寻找Run --> 扩展至minrun: 插入排序
    扩展至minrun --> 压入栈
    压入栈 --> 检查栈不变量
    检查栈不变量 --> 归并相邻run: 违反不变量
    归并相邻run --> 检查栈不变量
    检查栈不变量 --> 继续寻找Run: 未处理完
    继续寻找Run --> 寻找Run
    寻找Run --> 强制归并: 所有run已入栈
    强制归并 --> [*]

图表说明:状态图表达了 TimSort 的主要阶段。寻找Run扩展至minrun 利用了数据的局部有序性;检查栈不变量归并相邻run 确保合并开销最小;最终 强制归并 完成所有 run 的合并。这种设计是工程优化与算法理论完美结合的典范。

10.2 DualPivotQuicksort(不稳定,基本类型排序)

设计动机

Java 基本类型数组排序不需要稳定性(基本类型的相等值无法区分),因此可以追求极致性能。DualPivotQuicksort 在 2009 年由 Yaroslavskiy 提出并集成到 JDK 7,相比经典单轴快排,通过两个 pivot 将数组分为三个区域进一步减少递归深度和比较次数,尤其对重复元素多的场景收益显著。

双轴分区步骤(简化)

  1. 选取两个 pivot p1p2,保证 p1 <= p2
  2. 维护三个指针 less(< p1 区的右边界)、great(> p2 区的左边界)、k(扫描指针)。
  3. 从左到右扫描:
    • arr[k] < p1,则交换到 less 区,less++, k++
    • arr[k] > p2,则不断将 great 左移,直到 arr[great] <= p2,交换 arr[k]arr[great]great--;交换后若 arr[k] < p1 再次处理。
    • 否则 arr[k] 在 p1 和 p2 之间,k++
  4. 最后将 p1 放到 less-1p2 放到 great+1,递归排序三个区间。

此外,JDK 实现中还包含大量针对小数组(< 47 用插入排序)、中等数组、以及对特定分布(如已排序数组)的优化处理。

双轴分区示意图

flowchart TD
    subgraph 双轴分区结果
        direction LR
        P1["< p1"] --> P2["p1 ≤ x ≤ p2"] --> P3["> p2"]
    end

图表说明:双轴快排将数组划分为三个区。中间区的元素无需参与后续递归,对于含有大量重复元素的数据,中间区会非常庞大,极大地减少递归开销。JDK 中基本类型排序(int[], long[] 等)使用的正是该算法。


模块 11:工程最佳实践与避坑指南

11.1 API 选用原则

  • Arrays.sort(int[])DualPivotQuicksort,速度快但不稳定,适用于基本类型。
  • Arrays.sort(Object[])TimSort,稳定且自适应,适合对象数组。
  • Arrays.parallelSort()并行归并排序,适合大规模数据,但注意阈值和线程池。
  • Collections.sort(List) → 底层转为 Arrays.sort

11.2 Comparator 设计铁律

Comparator 必须满足自反性、对称性、传递性,且最好与 equals 一致,否则 TimSort 合并时会检测到逻辑矛盾,抛出 Comparison method violates its general contract!。编写 Comparator 时,建议使用 Comparator.comparingthenComparing 链式构建,避免手动犯错。

错误 Comparator 示例(违反传递性)

Comparator<Integer> wrong = (a, b) -> a % 2 - b % 2;
// 例如 a=1,b=2,c=3: compare(1,2)=1>0, compare(2,3)=0, compare(1,3)=0 不满足传递性

该错误 Comparator 会使 TimSort 在运行时抛出异常,因为其内部检查无法满足比较逻辑的一致性。

11.3 稳定排序与 UI 分页

多字段排序必须使用稳定排序或复合比较器。例如先按时间排序,再按 ID 排序。若用不稳定排序,可能打乱已排好的时间顺序,导致分页游标错乱、数据重复或丢失。

11.4 大数据量排序的 GC 调优

排序对象数组可能引起大量对象移动,触发 GC。可考虑:

  • 排序索引数组而非原对象数组。
  • 使用基本类型存储键值,避免装箱。
  • 预先扩大年轻代或使用 G1 等低延迟收集器。

11.5 性能测试与 JMH

正确测量排序性能需避免 JIT 优化死代码,使用 JMH:

@Benchmark
public void sortBenchmark(Blackhole bh) {
    int[] arr = Arrays.copyOf(data, data.length);
    Arrays.sort(arr);
    bh.consume(arr); // 消费结果,避免被优化掉
}

11.6 工程避坑清单

陷阱表现原因解决方案
对象排序不稳定导致分页重复翻页看到相同数据快排打乱相等键顺序使用稳定排序或一次性复合Comparator
比较器违反传递性TimSort抛异常不满足契约使用Comparator.comparing等标准方法构建
依赖基本类型排序稳定性相同值顺序不确定快排不稳定包装为对象使用TimSort
超大对象数组导致Full GC长时间STW排序产生大量写操作和对象移动排序索引列表、扩大堆、使用G1
LinkedList直接调Collections.sort性能极低底层转为数组再排序转为ArrayList或自实现归并
汉字排序期望按拼音结果按Unicode码点未设置Locale使用Collator.getInstance(Locale.CHINA)
parallelSort耗尽公共ForkJoinPool其他并行任务阻塞共用线程池评估并行收益,必要时自建线程池
忽略近似有序数据特性性能浪费未使用适应性算法直接使用TimSort(默认具备适应性)
频繁创建Comparator内存与性能开销每次排序new对象复用Comparator实例或预提取排序键
键提取操作昂贵排序耗时主要在getter比较器内反复计算预计算排序键并缓存

模块 12:面试高频专题

说明:以下题目统一归集,每题包含标准回答、多角度追问、加分回答

1. 手写快速排序,分析最好/最坏情况,如何避免最坏?

标准回答:使用 Hoare 分区或 Lomuto 分区递归实现。最好情况每次 pivot 均分数组,O(n log n);最坏情况数组已有序且 pivot 选首/尾,退化为 O(n²)。避免最坏的手段:随机化 pivot、三数取中、三向切分处理重复元素、递归深度过深切换堆排序(Introsort)。

追问

  • Hoare 与 Lomuto 的常数差异?→ Hoare 交换次数约为 Lomuto 的 1/3。
  • 三数取中如何选择?→ 首、尾、中间三个元素的中位数。
  • 尾递归如何减少栈空间?→ 只对较小的分区递归,较大的用循环迭代。

加分回答:JDK DualPivotQuicksort 用两个 pivot 进一步降低递归深度;C++ std::sort 使用 Introsort(快排+堆排混合),Java 选择双轴快排,体现了不同设计趋向。

2. 手写归并排序,如何用链表实现?分析稳定性与空间复杂度。

标准回答:自顶向下递归归并或自底向上迭代。稳定性源于合并时左指针优先。空间:数组版 O(n),链表版 O(1) 额外空间。

追问:如何避免递归?→ 自底向上迭代。链表归并排序如何 O(1) 空间?→ 利用指针操作拆分和合并。归并排序的适应性如何?→ 较好,可检测若前一段最大 ≤ 后一段最小则直接拼接。

加分回答:归并排序是外部排序基石;Python TimSort 和 JDK TimSort 都基于归并;在 Spark 的 Shuffle Sort 中也使用改进的归并。

3. 堆排序原理,建堆为什么是 O(n)?与快排对比优缺点。

标准回答:建堆从最后一个非叶节点向前下沉,各节点下沉高度总和收敛于 O(n)。排序 O(n log n)。优点:原地、最坏保证;缺点:缓存不友好,实际慢于快排,且不稳定。

追问:怎样证明建堆 O(n)? 数学归纳,层节点数乘以高度。为什么实际慢?缓存跳跃、分支预测失败。堆排序适合什么场景?内存极度有限且需最坏保证的实时系统。

加分回答:Go 语言优先队列使用堆;Java PriorityQueue 使用二叉堆;堆排序可无锁化实现,有一定并行潜力。

4. 比较计数排序与基数排序,为什么它们能达到 O(n)?对数据有什么要求?

标准回答:不基于比较,利用键的整数结构。计数排序 O(n+k),基数 O(d(n+k))。要求数据范围有限且可离散化。

追问:LSD 为什么稳定传导?每趟稳定计数排序。如何处理负数?加偏移量。桶排序何时优于它们?数据连续均匀分布。

加分回答:数据库列存中常用计数排序;MapReduce Shuffle 阶段使用 Counting Sort 分组;基数排序可用于字符串排序。

5. 解释排序算法的稳定性,哪些是稳定的?在真实业务中为什么重要?

标准回答:稳定:归并、插入、冒泡、计数、基数、桶、TimSort;不稳定:快排、堆、选择、希尔。业务多级排序必须稳定,否则前序排序被破坏,分页错乱。

追问:如何将不稳定排序“稳定化”?附加原索引作为二级键。为什么 Java 对象排序用稳定排序?保证多级排序与 API 契约。数据库索引与稳定性关系?索引维持有序,但查询时可能使用不稳定排序,需用 sort 强制稳定。

加分回答:C++ 的 std::stable_sort 提供稳定排序;Python sorted 稳定;Swift 也是稳定排序,这是语言设计哲学。

6. Java 中 Arrays.sort(int[])Arrays.sort(Object[]) 内部用了什么算法?为什么不同?

标准回答int[] 用 DualPivotQuicksort(不稳定),Object[] 用 TimSort(稳定)。基本类型无需稳定性,可放手优化;对象排序需稳定且数据常部分有序,TimSort 更佳。

追问:如果基本类型也想稳定怎么办?包装为对象。对象排序能回退到快排吗?不能,JDK 强制稳定。并行排序呢?parallelSort 对基本类型用并行快排,对对象用并行 TimSort。

加分回答:JDK 7 之前对象排序用归并排序,后引入 TimSort 大幅提升性能。双轴快排由 Yaroslavskiy 提出,JDK 内部针对结构化数据做了大量优化。

7. TimSort 的核心思想是什么?它是如何发现并利用数据中的有序片段的?

标准回答:扫描数组识别自然 run(降序反转),不足 minrun 则插入排序扩展;入栈并维持栈不变量决定合并时机;合并用 Galloping 优化。充分利用现实数据常部分有序的特点。

追问:minrun 怎么计算?取 n/2 接近的 2 的幂次,范围 32~64。Galloping 触发条件?连续 7 次比较来自同一 run。栈不变量具体表达式?X > Z + Y 且 Y > Z。

加分回答:Tim Peters 为 Python 设计;JDK 源码 TimSort.java 实现及其注释非常详尽;TimSort 的稳定性使其适用于 UI 表现。

8. 什么是双轴快排?它比经典快排好在哪里?

标准回答:使用两个 pivot 将数组分为三区,减少递归深度,中间区元素不再参与排序,特别适合重复元素多的场景。比单轴快排提升约 10%。

追问:若 p1 == p2 会发生什么?退化为三向切分。为什么 JDK 双轴快排对小数组回退插入排序?常数因子,避免递归开销。与三向切分有何区别?双轴快排分三个区但 pivot 是两个值,三向切分是一个 pivot 三个区。

加分回答:Yaroslavskiy 的论文包含详细性能基准;JDK 实现含有大量数据分布探测代码,如 already sorted 优化。

9. 外部排序的基本过程是怎样的?如果数据有 500GB 而内存只有 1GB,如何排序?

标准回答:分 500 块每块 1GB 内部排序产生有序段,然后 500 路归并(实际 K 可能受内存限制设为 200,做多趟归并)。使用败者树或堆。总 I/O 约 2×500GB×(趟数+1)。

追问:如何减少归并趟数?用置换-选择排序产生更长的初始段。多路归并如何选择 k?受限于内存能容纳的输入缓冲区数量。归并过程中如何避免频繁磁盘寻道?使用大块缓冲区顺序读写。

加分回答:数据库 ORDER BY 大数据集使用此类算法;Hadoop MapReduce Shuffle 阶段使用多路归并;PostgreSQL 源码 tuplesort.c 值得研读。

10. 在并发环境下如何高效排序?Arrays.parallelSort 的原理和注意事项?

标准回答:基于 ForkJoin 拆分数组,并行内部排序然后归并。注意阈值、线程池共用、小数据开销。

追问:工作窃取如何帮助?平衡负载。为什么非比较排序不提供并行版?计数排序全局累积需要同步,开销大。如何监控并行排序?自定义 FJPool 观察活跃线程。

加分回答parallelSort 是 Java 8 引入,Benchmark 显示 8 核可加速 3-4 倍。但要注意与 Stream.parallel 区分。

11. 给定 10 亿个整数,找出前 1000 大的数,有哪些方法?分析各自复杂度。

标准回答:全排序 O(n log n) 不必要;最小堆 O(n log k) k=1000;快速选择期望 O(n) 最坏 O(n²);范围有限可用计数或基数。

追问:数据无法全部内存呢?分块用堆筛选或外排序。快速选择如何避免最坏?中位数之中位数选 pivot。为什么不直接全排序?时间空间浪费。

加分回答:Spark takeOrdered 使用分布式堆;Elasticsearch 聚合使用 Top-K 堆;数据库 TOP N 查询也基于优先队列。

12. 多字段排序如何实现(如先按年龄降序,再按姓名升序)?如何保证性能?

标准回答:构建复合 Comparator,使用 thenComparing。避免在 Comparator 中做昂贵操作,预计算排序键。

追问:动态排序字段如何处理?反射或预编译。怎样缓存排序键?提取成 byte 数组或预计算 int 值。数据库如何高效?复合索引。

加分回答:JDK Comparator.comparing 支持链式调用;Lucene 索引排序也是预计算排序键。

13. 为什么基本类型排序不使用稳定排序?从性能、内存、必要性角度分析。

标准回答:相等值无法区分,稳定性无意义;稳定排序往往需要额外空间或更大常数;双轴快排原地且常数极低,性能最优。

追问:如果偏要怎么办?包装为对象。C++ 基本类型排序稳定吗?std::sort 不保证,但 std::stable_sort 保证。Swift 为什么统一稳定?简化语义。

加分回答:Go 的 sort.Ints 使用快速排序,不保证稳定。Python 的 list.sort 稳定。Java 这种区分是一种极致的性能权衡。

14. 怎样正确地对中文进行排序?Collator 的作用以及 locale 敏感问题。

标准回答:使用 Collator.getInstance(Locale.CHINA) 按拼音排序;可设置强度。注意不同 Locale 排序结果不同。

追问:性能优化?预计算 CollationKey。数据库怎么做?COLLATE 子句。Elasticsearch 怎么办?ICU 分析器。

加分回答:ICU 库提供更全面的 Unicode 排序支持;Java 9+ 默认使用 Unicode 10.0 排序规则。

15. 什么是比较器的契约?违反传递性会导致什么问题?可以举一个导致 TimSort 崩溃的例子吗?

标准回答:契约:自反性、对称性、传递性。违反传递性会导致 TimSort 在合并 run 时检测到矛盾,抛出 Comparison method violates its general contract!。例如 Comparator<Integer> c = (a,b) -> a%2 - b%2,对于 a=1,b=2,c=3,有 1>2, 2=3, 但 1=3,矛盾。

追问:TimSort 如何检测?合并过程中检查比较关系是否自洽。为什么旧归并排序没检测?TimSort 加入了安全断言。如何调试此类异常?对 Comparator 编写单元测试检验传递性。

加分回答:JDK 源码中 TimSortmergeLomergeHi 方法中包含契约检查的断言;这种做法促使开发者写出正确比较器。

16. (系统设计题一)设计一个大型电商的商品列表服务,要求支持多种排序方式(价格、销量、评分),且分页游标不能错乱,需保证极高并发。请设计缓存、排序、分页策略。

标准回答

  • 服务端:基于 Elasticsearch 存储商品信息,对各种排序字段建立正排索引和预排序(如文档值)。
  • 分页:使用游标分页(search_after),避免跳页。游标 key 为 sort_field + 唯一ID,确保顺序稳定。
  • 缓存:热门排序结果的前几页缓存于 Redis ZSET 中,按分值排序。写入时异步更新。
  • 稳定性:排序规则必须包含唯一二级键(商品ID),即使主排序键相同也不会跳页。
  • 高并发:索引层水平扩展,本地缓存 + 远程 Redis 集群,服务无状态。

追问:价格频繁变动如何实时?秒级延迟,使用增量索引更新。游标分页如何防止游标篡改?对游标签名或加密。数据库分库分表如何排序?中间件归并排序。

加分回答:淘宝、Amazon 均采用类似架构;Elasticsearch 的 search_after 可以有效支持深度分页;如果需求允许,可采用定时刷新榜单缓存的方式减轻实时查询压力。

17. (系统设计题二)设计一个日志分析系统,每天产生 TB 级半结构化日志,需要按时间戳排序后进行关联查询。请设计数据存储、排序管道和索引方案。

标准回答

  • 摄取:Kafka 收集日志,按时间分区(如 5 分钟),保证同一时间分区内有序。
  • 排序管道:使用 Flink 按事件时间分配 Watermark,对微批数据按时间戳排序(内部使用 TimSort),处理乱序。
  • 存储:以 Parquet 格式写入 HDFS,按小时/天分区,每个文件内按时间戳排序,并记录 min/max 统计元数据。
  • 查询:使用 Presto/Spark SQL 读取时利用分区裁剪和文件级统计信息跳过无关文件,关联查询采用 Sort Merge Join。
  • 实时查询:实时层写入 ClickHouse 或 Elasticsearch,支持近实时排序和分析。

追问:如何处理严重乱序日志?设置允许的乱序延迟,超时丢弃或单独处理。为什么用 Parquet?列存 + 有序性提升谓词下推和压缩。Flink 中如何保证排序?利用 KeyedProcessFunction 配合定时器排序。

加分回答:Flink 的 Watermark 机制天然适合时间排序;大规模日志分析通常采用 Lambda 架构,批处理层保证最终一致;Iceberg 等表格式也可利用排序优化查询。


延伸阅读

  1. 《算法导论》(Thomas H. Cormen 等) —— 第 2、6、7、8 章,排序与选择经典理论,建堆线性时间证明等。
  2. 《算法》(Robert Sedgewick) —— 第 2 章排序,讲解清晰,包含 Lomuto/Hoare 分区细节及三向切分。
  3. “TimSort” 原始文档及 Java 源码注释 —— Tim Peters 的原始 Python TimSort 描述,java.util.TimSort 注释。
  4. “Dual-Pivot Quicksort” 论文 (V. Yaroslavskiy) —— 原始双轴快排论文,JDK 实现的奠基之作。
  5. 《深入理解 Java 虚拟机》(周志明)—— 第 11 章有关编译优化与 GC 对排序性能影响。
  6. “External Sorting” 经典综述—— 数据库教科书中的外部排序与置换-选择算法描述。
  7. Arrays.parallelSort 官方使用指南—— Oracle JDK 文档,阈值与 ForkJoinPool 使用说明。
  8. “Engineering a Sort Function” (Bentley & McIlroy)—— C 标准库 qsort 的工程实现,蕴含适应性排序早期思想。

本文从排序的哲学根基出发,历经基础算法、高效比较、线性突破、外部与并行,直至工业级巨匠 TimSort 和 DualPivotQuicksort 的微观手术,构建了一幅完整而深刻的排序知识地图。希望它能助你在面试中侃侃而谈,在系统设计上洞悉本质,在实际编码中避开陷阱。排序的艺术,远不止于“写出一个 API 调用”,而在于理解其内在权衡并做出最优抉择。