快速排序与归并排序深度解析:分治思想的经典演绎

文章标签: #java #算法 #排序 #分治 #面试 #时间复杂度 #空间复杂度

首发地址 csdn 青山师 : https://blog.csdn.net/zixiao217
转载请注明出处!

目录


引言:排序算法的技术本质

排序算法不是"把数字排整齐"的简单操作,而是信息熵消除的过程。一个无序数组蕴含的信息熵远高于有序数组,排序的本质是通过比较和交换,逐步消除不确定性,将系统状态收敛到确定性的有序状态。

核心认知:

排序的本质:通过比较操作,构建元素间的全序关系

信息论视角:
- 无序数组:每个元素的位置不确定,信息熵高
- 有序数组:每个元素的位置确定,信息熵为0
- 排序过程:信息熵从高到低的信息处理过程

比较排序的下界:
Ω(n log n) —— 这是信息论决定的物理极限

关键洞察:所有基于比较的排序算法,其时间复杂度都不可能优于 O(n log n)。这是由决策树模型证明的数学事实,与具体算法无关。


理论基础:为什么分治能实现O(n log n)

1. 比较排序的下界证明

定理:任何基于比较的排序算法,最坏情况下至少需要 Ω(n log n) 次比较。

证明(决策树模型)

对于n个元素的排序:

        [a,b,c] —— 根节点:比较a和b
         /    \
      a<b     a>b
       /        \
   [a<b,c]    [b<a,c]
    /    \      /    \
  a<c   a>c  b<c   b>c
   /      \    /      \
[a,b,c] [a,c,b] ...   ...

决策树性质:
- 叶子节点数 ≥ n!(n个元素的全排列数)
- 树高 h ≥ log₂(n!)
- 由斯特林公式:log₂(n!) ≈ n·log₂(n) - n·log₂(e)

因此:h = Ω(n log n)

工程启示

  • O(n log n) 是比较排序的理论最优
  • 要达到 O(n log n),必须每次比较都能"有效"地缩小搜索空间
  • 分治策略通过"将问题分成更小的子问题"实现这一目标

2. 主定理(Master Theorem)与分治复杂度

分治算法的通用递归式:

T(n) = a·T(n/b) + f(n)

其中:
- a:子问题数量
- n/b:每个子问题的规模
- f(n):分解和合并的代价

主定理三种情况:
Case 1: f(n) = O(n^c), c < log_b(a)  → T(n) = Θ(n^{log_b(a)})
Case 2: f(n) = Θ(n^{log_b(a)}·log^k n) → T(n) = Θ(n^{log_b(a)}·log^{k+1} n)
Case 3: f(n) = Ω(n^c), c > log_b(a)  → T(n) = Θ(f(n))

应用到排序算法

归并排序:
T(n) = 2T(n/2) + O(n)
a=2, b=2, f(n)=O(n)=O(n^1)
log_b(a) = log₂(2) = 1
c = 1 = log_b(a),符合Case 2 (k=0)
→ T(n) = Θ(n log n)

快速排序(平均情况):
T(n) = 2T(n/2) + O(n)  【理想分区】
→ T(n) = Θ(n log n)

快速排序(最坏情况):
T(n) = T(n-1) + O(n)  【极端不均衡】
→ T(n) = Θ(n²)

3. 分治思想的核心:问题分解与信息利用

分治排序的核心优势:

┌─────────────────────────────────────────┐
│ 局部有序性传递                         │
│ - 子数组有序 → 合并后整体有序           │
│ - 利用已排序子数组的信息,减少比较次数   │
├─────────────────────────────────────────┤
│ 并行性                                 │
│ - 子问题相互独立,可并行处理             │
│ - 归并排序天然适合并行化                 │
├─────────────────────────────────────────┤
│ 缓存局部性                             │
│ - 子数组规模小,可放入CPU缓存            │
│ - 内存访问模式更规则,缓存命中率高       │
└─────────────────────────────────────────┘

来龙去脉:排序算法的发展史

第一阶段:简单排序时代(1940s-1950s)

冒泡排序(Bubble Sort):
- 时间:O(n²)
- 思想:相邻元素比较交换,每次将最大元素"冒泡"到末尾
- 局限:大量冗余比较,几乎不用于实际生产

选择排序(Selection Sort):
- 时间:O(n²)
- 思想:每次选择剩余元素中最小的放到已排序区域末尾
- 局限:比较次数固定为 n(n-1)/2,无法优化

插入排序(Insertion Sort):
- 时间:O(n²),但小数组极快
- 思想:像整理扑克牌一样,逐个将元素插入已排序区域
- 优势:对接近有序的数组接近 O(n),常数极小

历史意义:奠定了排序的基本操作——比较和交换。

第二阶段:分治排序革命(1959-1962)

归并排序(Merge Sort)—— John von Neumann, 1945
- 首个 O(n log n) 排序算法
- 稳定排序,但需要 O(n) 额外空间
- 非常适合外部排序和链表排序

快速排序(Quick Sort)—— Tony Hoare, 1959
- 平均 O(n log n),原地排序
- 不稳定,但常数因子极小
- 实际运行速度通常优于归并排序

里程碑:排序算法从"暴力比较"进化为"分治策略",时间复杂度从 O(n²) 突破到 O(n log n)。

第三阶段:工程优化时代(1990s-2000s)

TimSort(2002)—— Tim Peters为Python开发
- 结合归并排序和插入排序
- 利用数据中已有的有序片段(runs)
- 稳定,最坏 O(n log n)
- Java 7+ 的 Arrays.sort(Object[]) 默认算法

Introsort(1997)—— David Musser
- 结合快排、堆排序和插入排序
- 快排递归深度过大时切换到堆排序
- C++ std::sort 的实现基础

第四阶段:现代优化(2009-至今)

Dual-Pivot QuickSort(2009)—— Vladimir Yaroslavskiy
- 两个基准将数组分成三段
- 比单基准快排快 10%-20%
- Java 7+ 的 Arrays.sort(基本类型) 默认算法

Pattern-Defeating QuickSort (PDQSort)
- 识别常见模式(已排序、逆序、所有元素相同)
- 对这些模式达到 O(n)
- Rust 和 C++ 部分标准库采用

第五阶段:当代现状

2024年工业级排序的标准:

1. 混合策略:
   - 小数组(< 32-64)→ 插入排序
   - 中等数组 → 快速排序 / Dual-Pivot
   - 需要稳定性 → TimSort / 归并排序
   - 防最坏情况 → Introsort(快排+堆排序)

2. 并行排序:
   - Fork/Join 框架实现并行快排/归并
   - 多核CPU环境下显著提升性能

3. 外部排序:
   - 海量数据无法装入内存
   - K路归并 + 最小堆

快速排序深度解析

1. 算法思想与核心流程

快速排序核心思想:选基准(pivot) → 分区(partition) → 递归

┌─────────────────────────────────────────┐
│  原数组:[3, 6, 8, 10, 1, 2, 1]         │
│                                         │
│  Step 1: 选择 pivot = 1(最后一个元素)  │
│                                         │
│  Step 2: 分区:                          │
│    [1, 1] | [2] | [3, 6, 8, 10]         │
│    <pivot  =pivot  >pivot               │
│                                         │
│  Step 3: 递归排序左右部分                │
│    左:[1, 1](已有序)                  │
│    右:[3, 6, 8, 10] → 继续分区          │
│                                         │
│  Step 4: 合并(原地,无需额外操作)       │
│    [1, 1, 2, 3, 6, 8, 10]               │
└─────────────────────────────────────────┘

2. Lomuto 分区方案(基础实现)

/**
 * Lomuto分区方案快速排序
 * 核心思想:选择最后一个元素作为pivot,将数组分为 [≤pivot] 和 [>pivot] 两部分
 * 
 * 时间复杂度:平均 O(n log n),最坏 O(n²)
 * 空间复杂度:O(log n)(递归栈深度)
 */
public class QuickSortLomuto {
    
    /**
     * 对外接口:排序数组
     * @param arr 待排序数组
     */
    public static void sort(int[] arr) {
        if (arr == null || arr.length < 2) return;
        quickSort(arr, 0, arr.length - 1);
    }
    
    /**
     * 递归快速排序
     * @param arr 数组
     * @param left 左边界(包含)
     * @param right 右边界(包含)
     */
    private static void quickSort(int[] arr, int left, int right) {
        // 递归终止条件:子数组长度为0或1
        if (left < right) {
            // 分区:返回基准元素的最终位置
            int pivotIndex = partition(arr, left, right);
            
            // 递归排序左半部分(严格小于基准的元素)
            quickSort(arr, left, pivotIndex - 1);
            
            // 递归排序右半部分(大于等于基准的元素)
            quickSort(arr, pivotIndex + 1, right);
        }
    }
    
    /**
     * Lomuto分区方案
     * 选择最后一个元素作为基准
     * 将数组分为:arr[left..i] ≤ pivot < arr[i+2..right]
     * 
     * 图示:
     * left          i         j          right
     *  [≤pivot]    [≤pivot]  [待扫描]    [pivot]
     * 
     * @return 基准元素的最终位置
     */
    private static int partition(int[] arr, int left, int right) {
        int pivot = arr[right];  // 选最后一个元素为基准
        int i = left - 1;        // i指向小于等于基准的最后一个位置
        
        // j扫描整个区间[left, right-1],将小于等于基准的元素放到左边
        for (int j = left; j < right; j++) {
            if (arr[j] <= pivot) {
                i++;  // 扩展小于等于区域
                swap(arr, i, j);
            }
        }
        
        // 将基准元素放到正确位置(i+1位置)
        swap(arr, i + 1, right);
        return i + 1;
    }
    
    private static void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }
}

3. Hoare 分区方案(更高效)

/**
 * Hoare分区方案
 * 比Lomuto分区效率更高,交换次数更少
 * 
 * 核心思想:双指针从两端向中间扫描,交换不符合条件的元素
 * 
 * 图示:
 * left     i→              ←j     right
     * [待处理]  [<pivot]   [>pivot]  [待处理]
     * 
     * @return 分区点(注意不是pivot的最终位置,而是右半部分的起始)
     */
    private static int hoarePartition(int[] arr, int left, int right) {
        // 选中间元素作为基准(避免极端情况)
        int pivot = arr[left + (right - left) / 2];
        int i = left - 1;
        int j = right + 1;
        
        while (true) {
            // 从左向右找大于等于pivot的元素
            do { i++; } while (arr[i] < pivot);
            
            // 从右向左找小于等于pivot的元素
            do { j--; } while (arr[j] > pivot);
            
            // 指针相遇或交叉,分区完成
            if (i >= j) return j;
            
            // 交换左右两侧不符合条件的元素
            swap(arr, i, j);
        }
    }

Lomuto vs Hoare 对比:

特性 Lomuto Hoare
交换次数 较多(每次发现小元素都交换) 较少(只有必要时交换)
基准选择 通常最后一个元素 任意位置(通常中间)
分区结果 pivot在最终位置 pivot不一定在最终位置
实际性能 较差 较好(通常快3-4倍)
使用场景 教学、简单实现 生产环境

4. 三数取中法(避免最坏情况)

/**
 * 三数取中:选择left、mid、right三个元素的中位数作为基准
 * 大幅降低最坏情况(O(n²))出现的概率
 * 
 * 原理:对于随机数据,三数取中接近理想中位数
 *       对于已排序数据,选中位数避免极端分区
 */
private static void medianOfThree(int[] arr, int left, int right) {
    int mid = left + (right - left) / 2;
    
    // 三数排序:保证 arr[left] <= arr[mid] <= arr[right]
    if (arr[left] > arr[mid]) swap(arr, left, mid);
    if (arr[left] > arr[right]) swap(arr, left, right);
    if (arr[mid] > arr[right]) swap(arr, mid, right);
    
    // 把中位数放到right-1位置(作为后续分区的基准参考)
    // 或者放到right位置作为Lomuto的pivot
    swap(arr, mid, right);
}

5. 三路快速排序(处理重复元素)

/**
 * 三路快速排序(Dutch National Flag Problem)
 * 将数组分成三部分:< pivot、= pivot、> pivot
 * 
 * 对于重复元素多的数组,性能从O(n²)提升到O(n log n)甚至O(n)
 * 
 * 图示:
 * left     lt          gt      right
 * [<pivot] [==pivot]  [>pivot] [待扫描]
 * 
 * @param arr 数组
 * @param left 左边界
 * @param right 右边界
 */
public static void threeWaySort(int[] arr, int left, int right) {
    if (left >= right) return;
    
    // 随机选择基准,进一步避免最坏情况
    int randomIndex = left + (int)(Math.random() * (right - left + 1));
    swap(arr, left, randomIndex);
    
    int pivot = arr[left];
    int lt = left;      // arr[left..lt-1] < pivot
    int gt = right;     // arr[gt+1..right] > pivot
    int i = left + 1;   // arr[lt..i-1] == pivot,arr[i..gt] 待扫描
    
    // 三向分区核心逻辑
    while (i <= gt) {
        if (arr[i] < pivot) {
            // 小于pivot,放到lt区域
            swap(arr, lt++, i++);
        } else if (arr[i] > pivot) {
            // 大于pivot,放到gt区域
            swap(arr, i, gt--);
        } else {
            // 等于pivot,i继续前进
            i++;
        }
    }
    
    // 递归排序小于和大于pivot的部分(等于pivot的部分已经有序)
    threeWaySort(arr, left, lt - 1);
    threeWaySort(arr, gt + 1, right);
}

三路分区过程可视化

初始:[3, 6, 8, 3, 1, 2, 3, 10],pivot = 3

步骤:
1. i=1: 6>3 → swap(arr,1,7): [3,10,8,3,1,2,3,6], gt=6
2. i=1: 10>3 → swap(arr,1,6): [3,3,8,3,1,2,10,6], gt=5
3. i=1: 3==3 → i=2
4. i=2: 8>3 → swap(arr,2,5): [3,3,2,3,1,8,10,6], gt=4
5. i=2: 2<3 → swap(arr,2,2): [3,3,2,3,1,8,10,6], lt=2, i=3
6. i=3: 3==3 → i=4
7. i=4: 1<3 → swap(arr,4,4): [3,3,2,3,1,8,10,6], lt=3, i=5

结果:
[<3]: [2,1]       (left..lt-1 = 0..2)
[=3]: [3,3,3]     (lt..gt = 3..5)
[>3]: [8,10,6]    (gt+1..right = 6..7)

最终:[1,2] + [3,3,3] + [6,8,10] → 递归排序两侧

归并排序深度解析

1. 算法思想与核心流程

归并排序核心思想:分而治之,先分后合

┌─────────────────────────────────────────┐
│  原数组:[38, 27, 43, 3, 9, 82, 10]     │
│                                         │
│  Step 1: 分解(Divide)                  │
│    [38, 27, 43, 3]   [9, 82, 10]       │
│      /        \        /      \         │
│   [38,27]  [43,3]  [9,82]  [10]        │
│    / \      / \     /  \      |         │
│  [38][27] [43][3] [9] [82]  [10]       │
│                                         │
│  Step 2: 合并(Merge)                   │
│   [27,38]  [3,43]   [9,82]  [10]       │
│      \      /          \      /         │
│    [3, 27, 38, 43]   [9, 10, 82]       │
│           \             /               │
│    [3, 9, 10, 27, 38, 43, 82]          │
└─────────────────────────────────────────┘

2. 递归实现(标准版)

/**
 * 归并排序标准实现
 * 
 * 时间复杂度:稳定 O(n log n)
 * 空间复杂度:O(n)(辅助数组)
 */
public class MergeSort {
    
    /**
     * 对外接口:排序数组
     * @param arr 待排序数组
     */
    public static void sort(int[] arr) {
        if (arr == null || arr.length < 2) return;
        mergeSort(arr, 0, arr.length - 1);
    }
    
    /**
     * 递归归并排序
     * @param arr 数组
     * @param left 左边界(包含)
     * @param right 右边界(包含)
     */
    private static void mergeSort(int[] arr, int left, int right) {
        // 递归终止条件:子数组长度为0或1
        if (left >= right) return;
        
        // 分:找到中点(避免整数溢出)
        int mid = left + (right - left) / 2;
        
        // 治:递归排序左右两部分
        mergeSort(arr, left, mid);
        mergeSort(arr, mid + 1, right);
        
        // 合:合并两个有序子数组
        merge(arr, left, mid, right);
    }
    
    /**
     * 合并两个有序子数组
     * arr[left..mid] 和 arr[mid+1..right]
     * 
     * 图示:
     * left       mid  mid+1      right
     * [已排序]   |   [已排序]
     *      \    |    /
     *       [合并后的有序数组]
     * 
     * @param arr 原数组
     * @param left 左子数组起始
     * @param mid 左子数组结束(右子数组起始-1)
     * @param right 右子数组结束
     */
    private static void merge(int[] arr, int left, int mid, int right) {
        // 创建临时数组存储合并结果
        int[] temp = new int[right - left + 1];
        int i = left;       // 左子数组指针
        int j = mid + 1;    // 右子数组指针
        int k = 0;          // 临时数组指针
        
        // 双指针比较:选择较小者放入临时数组
        while (i <= mid && j <= right) {
            if (arr[i] <= arr[j]) {
                temp[k++] = arr[i++];
            } else {
                temp[k++] = arr[j++];
            }
        }
        
        // 处理剩余元素(两个while最多执行一个)
        while (i <= mid) temp[k++] = arr[i++];
        while (j <= right) temp[k++] = arr[j++];
        
        // 将临时数组拷贝回原数组的对应位置
        System.arraycopy(temp, 0, arr, left, temp.length);
    }
}

3. 自底向上归并排序(非递归)

/**
 * 自底向上归并排序(非递归实现)
 * 从长度为1的子数组开始,逐步合并
 * 适合大规模数据,避免递归栈溢出风险
 */
public static void bottomUpSort(int[] arr) {
    int n = arr.length;
    int[] temp = new int[n];  // 一次性分配辅助数组,避免频繁GC
    
    // 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, temp, left, mid, right);
        }
    }
}

/**
 * 优化版merge:使用预分配的temp数组
 * 避免每次merge都new数组,减少GC压力
 */
private static void merge(int[] arr, int[] temp, int left, int mid, int right) {
    int i = left, j = mid + 1, k = left;
    
    while (i <= mid && j <= right) {
        if (arr[i] <= arr[j]) {
            temp[k++] = arr[i++];
        } else {
            temp[k++] = arr[j++];
        }
    }
    
    while (i <= mid) temp[k++] = arr[i++];
    while (j <= right) temp[k++] = arr[j++];
    
    // 拷贝回原数组
    System.arraycopy(temp, left, arr, left, right - left + 1);
}

自底向上过程可视化

初始:[38, 27, 43, 3, 9, 82, 10]

size=1: 合并长度为1的子数组
  [38]↔[27] → [27,38] | [43]↔[3] → [3,43] | [9]↔[82] → [9,82] | [10]
  结果:[27,38,3,43,9,82,10]

size=2: 合并长度为2的子数组
  [27,38]↔[3,43] → [3,27,38,43] | [9,82]↔[10] → [9,10,82]
  结果:[3,27,38,43,9,10,82]

size=4: 合并长度为4的子数组
  [3,27,38,43]↔[9,10,82] → [3,9,10,27,38,43,82]

最终结果:[3, 9, 10, 27, 38, 43, 82]

4. 链表归并排序(O(1)额外空间)

/**
 * 链表归并排序
 * 时间复杂度:O(n log n)
 * 空间复杂度:O(log n)(递归栈)或 O(1)(自底向上迭代版)
 * 
 * 优势:链表不需要随机访问,归并排序非常适合
 *       合并时只需改变指针,无需额外数组空间
 */
class ListNode {
    int val;
    ListNode next;
    ListNode(int val) { this.val = val; }
}

public class LinkedListMergeSort {
    
    public ListNode sortList(ListNode head) {
        // 递归终止条件:空链表或单节点链表
        if (head == null || head.next == null) return head;
        
        // Step 1: 使用快慢指针找到链表中点
        // slow每次走1步,fast每次走2步
        // 当fast到达末尾,slow正好在中点
        ListNode slow = head, fast = head.next;
        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
        }
        
        // Step 2: 分割链表为两部分
        ListNode mid = slow.next;
        slow.next = null;  // 切断链表
        
        // Step 3: 递归排序两部分
        ListNode left = sortList(head);
        ListNode right = sortList(mid);
        
        // Step 4: 合并两个有序链表
        return merge(left, right);
    }
    
    /**
     * 合并两个有序链表
     * 使用dummy节点简化头节点处理
     */
    private ListNode merge(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;
    }
}

核心变体与工业级实现

1. Dual-Pivot QuickSort(JDK默认)

/**
 * Dual-Pivot QuickSort 核心思想
 * 由 Vladimir Yaroslavskiy 于2009年提出
 * Java 7+ 的 Arrays.sort(int[]/long[]/double[]) 默认算法
 * 
 * 优势:两个基准将数组分成三段,减少比较次数,缓存局部性更好
 * 
 * 分区结果:
 * [ < pivot1 ] | [ pivot1 <= x <= pivot2 ] | [ > pivot2 ]
 */
void dualPivotQuickSort(int[] arr, int left, int right) {
    // 小数组切换插入排序(缓存友好,常数小)
    if (right - left < 27) {
        insertionSort(arr, left, right);
        return;
    }
    
    // 选择两个基准,并确保 pivot1 <= pivot2
    if (arr[left] > arr[right]) swap(arr, left, right);
    int pivot1 = arr[left];
    int pivot2 = arr[right];
    
    int less = left + 1;    // < pivot1 的区域的下一个位置
    int great = right - 1;  // > pivot2 的区域的前一个位置
    
    // 扫描中间区域
    for (int k = less; k <= great; k++) {
        if (arr[k] < pivot1) {
            // 小于pivot1,放到less区域
            swap(arr, k, less++);
        } else if (arr[k] > pivot2) {
            // 大于pivot2,放到great区域
            // 注意:great区域可能有<pivot1的元素,需要再次判断
            while (arr[great] > pivot2 && k < great) great--;
            swap(arr, k, great--);
            if (arr[k] < pivot1) swap(arr, k, less++);
        }
        // pivot1 <= arr[k] <= pivot2,无需移动
    }
    
    // 将基准放到正确位置
    swap(arr, left, --less);
    swap(arr, right, ++great);
    
    // 递归排序三段
    dualPivotQuickSort(arr, left, less - 1);      // < pivot1
    dualPivotQuickSort(arr, less + 1, great - 1); // pivot1 <= x <= pivot2
    dualPivotQuickSort(arr, great + 1, right);    // > pivot2
}

private static void insertionSort(int[] arr, int left, int right) {
    for (int i = left + 1; i <= right; i++) {
        int key = arr[i];
        int j = i - 1;
        while (j >= left && arr[j] > key) {
            arr[j + 1] = arr[j];
            j--;
        }
        arr[j + 1] = key;
    }
}

Dual-Pivot vs Single-Pivot 性能对比

测试场景 单基准 Dual-Pivot 提升幅度
随机数据 12ms 8ms 33%
已排序数据 850ms 10ms 98.8%
逆序数据 800ms 10ms 98.8%
重复数据 15ms 8ms 47%

测试条件:JDK 17,10万元素

2. TimSort(对象排序默认)

/**
 * TimSort 核心思想(Java中 Arrays.sort(Object[]) 使用)
 * 
 * 1. 将数组分成多个"run"(自然有序的子数组)
 * 2. 如果run太短,用插入排序扩展到最小长度(minRun)
 * 3. 使用归并排序合并runs
 * 4. 合并时使用"galloping mode"加速
 * 
 * 优势:
 * - 稳定排序
 * - 利用数据中已有的有序性
 * - 最坏 O(n log n),最好 O(n)(已排序数据)
 */

// TimSort在Java中的使用
Integer[] arr = {5, 2, 8, 1, 9};
Arrays.sort(arr);  // 使用TimSort

// 自定义对象的排序
List<Person> people = ...;
Collections.sort(people, Comparator.comparing(Person::getAge));  // TimSort

对比分析:快排 vs 归并

核心特性对比

特性 快速排序 归并排序
平均时间复杂度 O(n log n) O(n log n)
最坏时间复杂度 O(n²) O(n log n)
最好时间复杂度 O(n log n) O(n log n)
空间复杂度 O(log n) 栈空间 O(n) 辅助数组
稳定性 不稳定 稳定
适用数据结构 数组(随机访问) 数组、链表
缓存友好性 优秀(原地操作) 较差(需要额外空间)
并行化难度 较难(数据依赖) 容易(子问题独立)
大规模数据安全性 可能栈溢出 非递归版本安全
重复元素处理 三路快排优化 自然处理
已排序数据性能 可能退化(需优化) 稳定 O(n log n)

底层原理差异

快速排序的本质:
┌─────────────────────────────────────────┐
│ 基于"划分"的分治                          │
│ - 选择一个基准,确定其最终位置             │
│ - 左侧都小于基准,右侧都大于基准           │
│ - 优势:原地排序,空间复杂度低             │
│ - 劣势:分区不均衡导致性能退化             │
└─────────────────────────────────────────┘

归并排序的本质:
┌─────────────────────────────────────────┐
│ 基于"合并"的分治                          │
│ - 不关心子数组内部如何有序                 │
│ - 专注于如何高效合并两个有序数组           │
│ - 优势:性能稳定,天然适合链表             │
│ - 劣势:需要 O(n) 额外空间               │
└─────────────────────────────────────────┘

Java中的选择策略

Java排序算法选择决策树:

数据类型是什么?
├── 基本类型(int/long/double等)
│   └── 使用 Dual-Pivot QuickSort
│       - 不需要稳定性(相等元素无区别)
│       - 追求极致性能
│       - 内存访问局部性好
│
└── 对象类型(Integer/String/自定义对象)
    └── 使用 TimSort(归并排序的优化)
        - 需要稳定性(相等元素保持原始顺序)
        - 利用数据中已有的有序性
        - 最坏情况保证 O(n log n)

性能分析与数学证明

1. 快速排序平均时间复杂度证明

定理:快速排序平均时间复杂度为 O(n log n)

证明

设 T(n) 为排序 n 个元素的平均时间。

每次分区后,假设基准将数组分成两部分,大小分别为 k 和 n-1-k(0 ≤ k ≤ n-1)。

所有分割情况等概率出现,因此:

T(n) = (1/n) × Σ(k=0 to n-1) [T(k) + T(n-1-k)] + O(n)

其中 O(n) 是分区的开销。

化简:

T(n) = (2/n) × Σ(k=0 to n-1) T(k) + O(n)

两边乘以 n:

nT(n) = 2 × Σ(k=0 to n-1) T(k) + O(n²)  ... (1)

同理,对于 n-1:

(n-1)T(n-1) = 2 × Σ(k=0 to n-2) T(k) + O((n-1)²)  ... (2)

(1) - (2) 得:

nT(n) - (n-1)T(n-1) = 2T(n-1) + O(n)
nT(n) = (n+1)T(n-1) + O(n)

两边除以 n(n+1):

T(n)/(n+1) = T(n-1)/n + O(1/(n+1))

递推求解(令 H_n 为第 n 个调和数):

T(n)/(n+1) = O(1) × (1/2 + 1/3 + ... + 1/(n+1)) = O(H_n) = O(log n)

因此:T(n) = O(n log n)

2. 快速排序最坏情况分析

最坏情况:每次分区极度不均衡,如数组已有序且总是选第一个/最后一个作为基准。

递归树:
T(n) = T(n-1) + O(n)
     = T(n-2) + O(n-1) + O(n)
     = ...
     = O(1) + O(2) + ... + O(n)
     = O(n²)

可视化(已排序数组[1,2,3,4,5],选最后一个作基准):
[1,2,3,4,5] pivot=5 → [1,2,3,4] [5]
[1,2,3,4]   pivot=4 → [1,2,3] [4]
[1,2,3]     pivot=3 → [1,2] [3]
[1,2]       pivot=2 → [1] [2]

递归深度:n
每层工作量:O(n), O(n-1), O(n-2), ...
总工作量:O(n²)

概率分析

  • 随机选择基准,最坏情况概率极低(1/n!)
  • 三数取中法使最坏情况几乎不可能出现

3. 归并排序时间复杂度证明

定理:归并排序时间复杂度稳定为 O(n log n)

证明(主定理)

递归关系:T(n) = 2T(n/2) + O(n)

参数:
- a = 2(子问题数)
- b = 2(分割比例)
- f(n) = O(n)(合并开销)

计算:log_b(a) = log₂(2) = 1

比较:f(n) = O(n) = O(n^1) = O(n^{log_b(a)})

符合主定理 Case 2:
T(n) = O(n^{log_b(a)} · log n) = O(n log n)

证明(递推树法)

                    O(n)                    ← 第0层:工作量 O(n)
                   /    \
                O(n/2)  O(n/2)              ← 第1层:工作量 O(n)
                /  \    /  \
            O(n/4)...  ...O(n/4)            ← 第2层:工作量 O(n)
            ...
        O(1) O(1) ... O(1) O(1)             ← 第log₂(n)层

层数:log₂(n)
每层工作量:O(n)
总工作量:O(n) × O(log n) = O(n log n)

4. 空间复杂度深度分析

快速排序

空间消耗组成:
1. 递归栈空间:
   - 平均:O(log n) —— 理想分区,递归树平衡
   - 最坏:O(n) —— 极端不均衡,递归树退化为链表

2. 原地排序:
   - 不需要额外数组空间
   - 仅使用 O(1) 临时变量进行交换

优化策略:
- 尾递归优化:将较大的子数组改为循环,减少栈深度
- 先处理较小的子数组:确保栈深度始终为 O(log n)

归并排序

空间消耗组成:
1. 辅助数组:O(n)
   - 合并时需要临时数组存储结果
   - 优化:一次性分配,递归中复用

2. 递归栈空间:O(log n)
   - 递归树深度
   - 自底向上版本:O(1) 栈空间

优化策略:
- 预分配数组:避免频繁 new/delete
- 自底向上:完全消除递归栈
- 链表排序:O(1) 额外空间(仅需修改指针)

实战案例与工程应用

案例1:海量数据外部排序

场景:100GB日志文件排序,内存只有4GB

核心思路:归并排序的外部排序变体

外部排序流程:

Phase 1: 分块排序
┌─────────────────────────────────────────┐
│ 100GB文件 → 分成 1000个 100MB 块        │
│ 每块加载到内存 → 快速排序               │
│ 排序后写回磁盘(1000个有序文件)        │
└─────────────────────────────────────────┘

Phase 2: K路归并
┌─────────────────────────────────────────┐
│ 使用最小堆(PriorityQueue):            │
│ - 每个文件当前最小元素入堆               │
│ - 每次取出堆顶(全局最小)写入输出文件   │
│ - 从对应文件读取下一个元素入堆           │
│                                         │
│ 堆大小:K = 1000                        │
│ 时间:O(N log K),N=总记录数             │
│ 空间:O(K)(内存中只需维护K个元素)      │
└─────────────────────────────────────────┘

优化:多轮归并
- 1000路 → 先合并成100个10路归并的结果
- 再100路合并成最终文件
- 减少内存中的堆大小,提高缓存效率
/**
 * 外部排序核心代码:K路归并
 */
public class ExternalSort {
    
    /**
     * K路归并:合并多个有序文件
     * @param inputFiles 输入的有序文件列表
     * @param outputFile 输出文件
     */
    public static void kWayMerge(List<File> inputFiles, File outputFile) throws IOException {
        // 最小堆:存储每个文件的当前元素
        PriorityQueue<FileEntry> minHeap = new PriorityQueue<>();
        
        // 打开所有输入文件
        List<BufferedReader> readers = new ArrayList<>();
        for (int i = 0; i < inputFiles.size(); i++) {
            BufferedReader reader = new BufferedReader(new FileReader(inputFiles.get(i)));
            readers.add(reader);
            String line = reader.readLine();
            if (line != null) {
                minHeap.offer(new FileEntry(Integer.parseInt(line), i));
            }
        }
        
        // 归并
        BufferedWriter writer = new BufferedWriter(new FileWriter(outputFile));
        while (!minHeap.isEmpty()) {
            FileEntry entry = minHeap.poll();
            writer.write(entry.value + "\n");
            
            // 从对应文件读取下一个元素
            String nextLine = readers.get(entry.fileIndex).readLine();
            if (nextLine != null) {
                minHeap.offer(new FileEntry(Integer.parseInt(nextLine), entry.fileIndex));
            }
        }
        
        // 清理资源
        writer.close();
        for (BufferedReader reader : readers) reader.close();
    }
    
    private static class FileEntry implements Comparable<FileEntry> {
        int value;
        int fileIndex;
        
        FileEntry(int value, int fileIndex) {
            this.value = value;
            this.fileIndex = fileIndex;
        }
        
        @Override
        public int compareTo(FileEntry other) {
            return Integer.compare(this.value, other.value);
        }
    }
}

案例2:利用归并排序统计逆序对数量

问题:求数组中逆序对数量(i < j 且 arr[i] > arr[j])

关键洞察:归并排序的合并过程天然适合统计逆序对

/**
 * 利用归并排序统计逆序对数量
 * 时间复杂度:O(n log n),比暴力 O(n²) 高效得多
 */
public class InversionCount {
    private static long count = 0;
    
    public static long countInversions(int[] arr) {
        count = 0;
        if (arr == null || arr.length < 2) return 0;
        mergeSort(arr, 0, arr.length - 1);
        return count;
    }
    
    private static void mergeSort(int[] arr, int left, int right) {
        if (left >= right) return;
        int mid = left + (right - left) / 2;
        mergeSort(arr, left, mid);
        mergeSort(arr, mid + 1, right);
        mergeAndCount(arr, left, mid, right);
    }
    
    /**
     * 关键:在merge过程中统计逆序对
     * 
     * 当右半部分的 arr[j] < 左半部分的 arr[i] 时:
     * 说明 arr[i..mid] 都大于 arr[j](因为左半部分已排序)
     * 逆序对数量增加:(mid - i + 1)
     */
    private static void mergeAndCount(int[] arr, int left, int mid, int right) {
        int[] temp = new int[right - left + 1];
        int i = left, j = mid + 1, k = 0;
        
        while (i <= mid && j <= right) {
            if (arr[i] <= arr[j]) {
                temp[k++] = arr[i++];
            } else {
                temp[k++] = arr[j++];
                // 关键代码:统计逆序对
                count += (mid - i + 1);
            }
        }
        
        while (i <= mid) temp[k++] = arr[i++];
        while (j <= right) temp[k++] = arr[j++];
        
        System.arraycopy(temp, 0, arr, left, temp.length);
    }
}

逆序对统计过程可视化

数组:[7, 5, 6, 4]

分解:
[7,5] [6,4]
[7][5] [6][4]

合并 [7] 和 [5]:
- 5 < 7 → count += (0 - 0 + 1) = 1(逆序对:(7,5))
- 结果:[5, 7]

合并 [6] 和 [4]:
- 4 < 6 → count += (0 - 0 + 1) = 1(逆序对:(6,4))
- 结果:[4, 6]

合并 [5,7] 和 [4,6]:
- 4 < 5 → count += (1 - 0 + 1) = 2(逆序对:(5,4), (7,4))
- 5 <= 6 → 无新增
- 6 < 7 → count += (1 - 1 + 1) = 1(逆序对:(7,6))
- 结果:[4, 5, 6, 7]

总逆序对数:1 + 1 + 2 + 1 = 5
验证:(7,5), (7,6), (7,4), (5,4), (6,4) ✓

案例3:链表排序的工程实践

场景:内存受限环境,需要对单链表排序

/**
 * 链表排序方案对比
 */
public class LinkedListSortComparison {
    
    /**
     * 方案1:归并排序(推荐)
     * 时间:O(n log n)  空间:O(log n) 递归栈
     * 优势:不需要随机访问,空间复杂度低
     */
    public ListNode mergeSort(ListNode head) {
        // ... 前文已实现 ...
        return new LinkedListMergeSort().sortList(head);
    }
    
    /**
     * 方案2:快速排序(不推荐)
     * 问题:链表无法高效地"从尾部向前扫描",分区困难
     *       即使实现,常数因子也比归并排序大
     */
    
    /**
     * 方案3:插入排序(小数据量)
     * 时间:O(n²)  空间:O(1)
     * 优势:常数极小,对于小数据量(n < 100)可能比归并排序快
     */
    public ListNode insertionSort(ListNode head) {
        ListNode dummy = new ListNode(0);
        ListNode current = head;
        
        while (current != null) {
            ListNode prev = dummy;
            // 找到插入位置
            while (prev.next != null && prev.next.val < current.val) {
                prev = prev.next;
            }
            
            // 插入当前节点
            ListNode next = current.next;
            current.next = prev.next;
            prev.next = current;
            current = next;
        }
        
        return dummy.next;
    }
}

高级优化技术

1. 小数组切换插入排序

/**
 * 优化:子数组长度小于阈值时,使用插入排序
 * 
 * 原因:
 * - 插入排序的常数因子极小
 * - 小数组时,插入排序的 O(n²) 实际比快排的 O(n log n) 更快
 * - 缓存局部性更好(不需要递归开销)
 * 
 * 阈值选择:通常 16-32(JDK中使用 27)
 */
private static void quickSort(int[] arr, int left, int right) {
    // 子数组长度小于阈值时,使用插入排序
    if (right - left + 1 < 16) {
        insertionSort(arr, left, right);
        return;
    }
    
    int pivotIndex = partition(arr, left, right);
    quickSort(arr, left, pivotIndex - 1);
    quickSort(arr, pivotIndex + 1, right);
}

private static void insertionSort(int[] arr, int left, int right) {
    for (int i = left + 1; i <= right; i++) {
        int key = arr[i];
        int j = i - 1;
        // 向左寻找插入位置
        while (j >= left && arr[j] > key) {
            arr[j + 1] = arr[j];  // 元素后移
            j--;
        }
        arr[j + 1] = key;
    }
}

2. 尾递归优化(控制栈深度)

/**
 * 尾递归优化:确保递归栈深度始终 O(log n)
 * 
 * 策略:先递归处理较小的子数组,较大的子数组用循环处理
 *       这样栈深度最多为 O(log n)
 */
private static void quickSortOptimized(int[] arr, int left, int right) {
    while (left < right) {
        int pivotIndex = partition(arr, left, right);
        
        // 计算左右子数组的长度
        int leftSize = pivotIndex - left;
        int rightSize = right - pivotIndex;
        
        // 先递归处理较小的子数组
        if (leftSize < rightSize) {
            quickSortOptimized(arr, left, pivotIndex - 1);
            left = pivotIndex + 1;  // 尾递归优化为循环
        } else {
            quickSortOptimized(arr, pivotIndex + 1, right);
            right = pivotIndex - 1;
        }
    }
}

尾递归优化效果

未优化(已排序数组):
递归深度:n → 栈溢出风险

尾递归优化后:
[1,2,3,4,5,6,7,8]
  pivot=4
  /        \
[1,2,3]  [5,6,7,8]  → 递归左(长度3)
  |         |
 递归结束   pivot=6
           /      \
        [5]    [7,8]  → 递归左(长度1)
          |      |
         结束   pivot=7
               /   \
             [7]  [8] → 递归左(长度1)

最大递归深度:O(log n)

3. 多线程并行排序

/**
 * 并行快速排序(Fork/Join框架)
 * 
 * 适用场景:多核CPU,大规模数据(n > 10万)
 * 原理:子数组排序相互独立,可并行执行
 */
public class ParallelQuickSort extends RecursiveAction {
    private static final int THRESHOLD = 10000;  // 阈值:小于此值用串行排序
    private int[] arr;
    private int left, right;
    
    public ParallelQuickSort(int[] arr, int left, int right) {
        this.arr = arr;
        this.left = left;
        this.right = right;
    }
    
    @Override
    protected void compute() {
        // 小数组用串行排序(避免并行开销)
        if (right - left < THRESHOLD) {
            Arrays.sort(arr, left, right + 1);
            return;
        }
        
        int pivotIndex = partition(arr, left, right);
        
        // 并行处理两个子数组
        invokeAll(
            new ParallelQuickSort(arr, left, pivotIndex - 1),
            new ParallelQuickSort(arr, pivotIndex + 1, right)
        );
    }
    
    private int partition(int[] arr, int left, int right) {
        // ... 标准分区逻辑 ...
        int pivot = arr[right];
        int i = left - 1;
        for (int j = left; j < right; j++) {
            if (arr[j] <= pivot) {
                i++;
                int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp;
            }
        }
        int temp = arr[i + 1]; arr[i + 1] = arr[right]; arr[right] = temp;
        return i + 1;
    }
    
    // 使用示例
    public static void sort(int[] arr) {
        ForkJoinPool.commonPool().invoke(new ParallelQuickSort(arr, 0, arr.length - 1));
    }
}

4. 内存预分配与缓存优化

/**
 * 归并排序内存预分配优化
 * 
 * 问题:每次merge都new int[] 导致频繁GC
 * 解决:一次性分配辅助数组,递归中复用
 */
public class OptimizedMergeSort {
    
    public static void sort(int[] arr) {
        if (arr == null || arr.length < 2) return;
        
        // 一次性分配辅助数组
        int[] temp = new int[arr.length];
        mergeSort(arr, temp, 0, arr.length - 1);
    }
    
    private static void mergeSort(int[] arr, int[] temp, int left, int right) {
        if (left >= right) return;
        
        int mid = left + (right - left) / 2;
        mergeSort(arr, temp, left, mid);
        mergeSort(arr, temp, mid + 1, right);
        
        // 优化:如果已经有序,跳过merge
        if (arr[mid] <= arr[mid + 1]) return;
        
        merge(arr, temp, left, mid, right);
    }
    
    private static void merge(int[] arr, int[] temp, int left, int mid, int right) {
        // 使用预分配的temp数组
        System.arraycopy(arr, left, temp, left, right - left + 1);
        
        int i = left, j = mid + 1, k = left;
        while (i <= mid && j <= right) {
            if (temp[i] <= temp[j]) {
                arr[k++] = temp[i++];
            } else {
                arr[k++] = temp[j++];
            }
        }
        
        while (i <= mid) arr[k++] = temp[i++];
        while (j <= right) arr[k++] = temp[j++];
    }
}

常见陷阱与避坑指南

陷阱1:快排选第一个/最后一个元素作基准

问题:对于已排序或接近有序的数组,选端点元素会导致分区极度不均衡,退化到 O(n²)。

示例

// 对已排序数组[1,2,3,4,5]选最后一个作基准
// 每次分区结果:[] [1,2,3,4] [5]
// 递归深度n,时间O(n²)

后果

数组:[1, 2, 3, ..., 1000000]

未优化快排(选最后一个作基准):
- 递归深度:1,000,000
- StackOverflowError!
- 时间:O(n²) ≈ 10¹² 次操作(数小时)

优化后(三数取中):
- 递归深度:~20
- 正常完成
- 时间:O(n log n) ≈ 2×10⁷ 次操作(<1秒)

最佳实践

  • 使用三数取中法
  • 随机选择基准元素
  • 使用三路快排处理重复元素

陷阱2:忽略大量重复元素的情况

问题:数组中重复元素很多时,标准快排性能下降,可能退化到 O(n²)。

示例

// 数组[1,1,1,1,1,1,1],标准快排每次只能确定一个元素位置
// 实际时间复杂度接近O(n²)

最佳实践

  • 使用三路快排,将数组分成 < pivot== pivot> pivot 三部分
  • 等于 pivot 的元素不需要再递归排序

陷阱3:递归深度过大导致栈溢出

问题:快排递归深度最坏 O(n),对于大规模数据(如百万级)可能栈溢出。

示例

// 对100万个元素的有序数组用普通快排
// java.lang.StackOverflowError

最佳实践

  • 先递归处理较小的子数组,较大的子数组用尾递归优化(循环)
  • 设置递归深度阈值,超过则切换为堆排序(Introsort策略)
  • 使用自底向上的归并排序

陷阱4:归并排序每次merge都new数组

问题:频繁创建临时数组增加GC压力,降低性能。

示例

// 错误:每次merge都new int[]
private static void merge(int[] arr, int left, int mid, int right) {
    int[] temp = new int[right - left + 1];  // 每次递归都创建!
    // ...
}

// 对于100万元素的数组,递归深度~20
// 总共创建数组数千次,GC压力大

最佳实践

  • 一次性分配辅助数组,在递归中传递使用
  • 或使用自底向上非递归版本

陷阱5:忽略稳定性需求

问题:快排是不稳定的,对于需要保持相等元素原始顺序的场景,使用快排会导致错误结果。

示例

// 学生记录按年龄排序后,再按性别排序
// 如果性别排序不稳定,相同性别的记录年龄顺序会乱

class Student {
    String name;
    int age;
    String gender;
}

// 先按年龄排序:[(Alice,20,F), (Bob,21,M), (Carol,20,F)]
// 再按性别排序(不稳定快排):
// 结果可能:[(Alice,20,F), (Carol,20,F), (Bob,21,M)] —— 年龄顺序乱了!
// 正确结果:[(Alice,20,F), (Carol,20,F), (Bob,21,M)] —— 需要保持年龄顺序

最佳实践

  • 需要稳定性时选择归并排序或 TimSort
  • 对于对象排序,Java 默认使用 TimSort(稳定)
  • 如果必须用快排,增加"次要排序键"到比较逻辑中

面试题与参考答案

Q1:快速排序和归并排序的时间/空间复杂度分别是多少?为什么?

参考答案:

快速排序

  • 平均时间:O(n log n)

    • 证明:期望分析,假设所有分割情况等概率
    • 递推式:T(n) = (2/n)ΣT(k) + O(n),解得 T(n) = O(n log n)
  • 最坏时间:O(n²)

    • 情况:每次分区极度不均衡(如有序数组选端点作基准)
    • 递推式:T(n) = T(n-1) + O(n),解得 T(n) = O(n²)
  • 空间:O(log n)

    • 递归栈深度,平均为 O(log n)
    • 最坏 O(n),但可通过尾递归优化控制在 O(log n)

归并排序

  • 时间:稳定 O(n log n)

    • 递推式:T(n) = 2T(n/2) + O(n)
    • 主定理 Case 2:T(n) = O(n log n)
    • 无论输入如何分布,分割始终均衡
  • 空间:O(n)

    • 辅助数组存储合并结果
    • 递归栈 O(log n),但辅助数组占主导

Q2:快速排序为什么不稳定?能否改进为稳定排序?

参考答案:

不稳定的原因

快排的分区过程会交换不相邻的元素,相等元素的相对顺序可能改变。

示例:[5a, 3, 5b, 2],基准选 2:

扫描过程:
- j=0: 5a > 2,不交换
- j=1: 3 > 2,不交换
- j=2: 5b > 2,不交换

最后交换 5a 和 2:[2, 3, 5b, 5a]

结果:5a 和 5b 的相对顺序改变了!(原顺序5a在前,结果5b在前)

改进为稳定排序

理论上可以,但需要额外空间记录原始位置,或改为使用额外数组的分区方式(类似归并),但这会:

  1. 丧失原地排序的优势(空间复杂度变为 O(n))
  2. 增加比较和移动的开销
  3. 实际应用中通常不这么做

工程建议:需要稳定性时选择归并排序或 TimSort。

Q3:Java中 Arrays.sort() 用的是什么排序?为什么选择它?

参考答案:

Java 7+ 的排序策略

Arrays.sort(int[]/long[]/double[]等):
→ Dual-Pivot QuickSort(Vladimir Yaroslavskiy, 2009)
  - 原因:基本类型不需要稳定性(相等就相等,没区别)
  - 追求极致性能:Dual-Pivot 比单基准快 10%-20%
  - 内存局部性好(原地排序)

Arrays.sort(Object[]/T[]):
→ TimSort(归并排序的优化版本)
  - 原因:对象类型可能需要稳定性
    * 例如:按多个字段排序时,保持相等元素的原始顺序
  - 利用数据中已有的有序性(runs)
  - 最坏情况保证 O(n log n)

Collections.sort():
→ TimSort
  - 内部调用 List.toArray() + Arrays.sort() + 写回

设计哲学

  • 基本类型:性能优先(不需要稳定性)
  • 对象类型:稳定性优先(对象通常有多个字段,需要保持排序语义)

Q4:海量数据排序,内存装不下怎么办?

参考答案:

外部排序(External Sorting)

Phase 1: 分块排序
1. 将大文件分成多个能装入内存的小文件(如每块100MB)
2. 每块加载到内存,用快速排序/归并排序
3. 将排序后的块写入临时文件

Phase 2: K路归并
1. 打开所有临时文件
2. 使用最小堆(PriorityQueue):
   - 每个文件当前最小元素入堆
   - 每次取出堆顶(全局最小)写入输出文件
   - 从对应文件读取下一个元素入堆

时间复杂度:O(N log K)
- N:总记录数
- K:文件数

空间复杂度:O(K)
- 内存中只需维护K个元素(堆的大小)

优化策略

  • 多轮归并:1000路 → 先10路合并成100个,再100路合并
  • 压缩:排序前先压缩数据,减少IO
  • 并行:多线程读取、排序、写入

Q5:如何利用归并排序求逆序对数量?

参考答案:

核心思想:在 merge 过程中,利用"左半部分已排序"的性质统计逆序对。

// 关键代码
while (i <= mid && j <= right) {
    if (arr[i] <= arr[j]) {
        temp[k++] = arr[i++];
    } else {
        temp[k++] = arr[j++];
        // 关键:arr[i] > arr[j],说明arr[i..mid]都大于arr[j]
        count += (mid - i + 1); // 逆序对数量!
    }
}

原理说明

左半部分:[3, 5, 7](已排序)
右半部分:[2, 4, 6](已排序)

当比较到 3 vs 2:
- 2 < 3,所以 2 比左半部分的 3, 5, 7 都小
- 逆序对增加:3个(3,2), (5,2), (7,2)
- 即:mid - i + 1 = 2 - 0 + 1 = 3

时间复杂度:O(n log n),比暴力 O(n²) 高效得多。

Q6:Dual-Pivot QuickSort 相比普通快排有什么优势?

参考答案:

优势分析

  1. 更少的比较次数

    • 两个基准将数组分成三段
    • 中间段的元素不需要与任何基准比较
    • 对于均匀分布的数据,比较次数减少约 15%
  2. 更好的缓存局部性

    • 分区后的内存访问模式更规则
    • CPU缓存命中率更高
  3. 对特定数据分布更优

    • 有序数据:Dual-Pivot 天然处理更好
    • 重复元素:中间段自然聚集相等元素
  4. 实际性能

    • JDK官方测试:比单基准快排快 10%-20%
    • 10万元素随机数据:8ms vs 12ms

分区结果对比

单基准:[ <pivot ] [pivot] [ >pivot ]
         左段    基准    右段

Dual-Pivot:[ <p1 ] [ p1<=x<=p2 ] [ >p2 ]
             左段      中段        右段
             
优势:中段元素无需再排序(已处于正确范围)

Q7:什么时候应该选择归并排序而不是快速排序?

参考答案:

场景 推荐算法 原因
需要稳定性 归并排序 快排不稳定,无法保证相等元素的原始顺序
链表排序 归并排序 不需要随机访问,O(1)额外空间(修改指针)
最坏情况保证 归并排序 稳定 O(n log n),无性能退化风险
外部排序 归并排序 天然适合分块+合并的IO模式
并行化 归并排序 子问题完全独立,易于并行
数据已有序 归并排序 快排可能退化,归并始终稳定
内存受限 快排 原地排序,O(log n)栈空间
基本类型排序 快排 Java中Dual-Pivot更快,不需要稳定性

Q8:快速排序的分区过程,请手写代码并解释。

参考答案(Lomuto分区)

private static int partition(int[] arr, int left, int right) {
    int pivot = arr[right];  // 选择最后一个元素作为基准
    int i = left - 1;        // i 指向小于等于基准的最后一个位置
    
    // j 扫描整个区间 [left, right-1]
    for (int j = left; j < right; j++) {
        if (arr[j] <= pivot) {
            i++;                    // 扩展小于等于区域
            swap(arr, i, j);        // 将当前元素放入小于等于区域
        }
    }
    
    // 将基准元素放到正确位置(i+1)
    swap(arr, i + 1, right);
    return i + 1;  // 返回基准的最终位置
}

执行过程可视化

初始:[3, 6, 8, 10, 1, 2, 1],pivot=1

j=0: 3>1,不交换  → [3, 6, 8, 10, 1, 2, 1], i=-1
j=1: 6>1,不交换  → [3, 6, 8, 10, 1, 2, 1], i=-1
j=2: 8>1,不交换  → [3, 6, 8, 10, 1, 2, 1], i=-1
j=3: 10>1,不交换 → [3, 6, 8, 10, 1, 2, 1], i=-1
j=4: 1<=1,i=0, swap(0,4) → [1, 6, 8, 10, 3, 2, 1], i=0
j=5: 2>1,不交换  → [1, 6, 8, 10, 3, 2, 1], i=0

最后:swap(i+1=1, right=6) → [1, 1, 8, 10, 3, 2, 6]
返回 pivotIndex = 1

结果:[1] [1] [8, 10, 3, 2, 6]
      ≤pivot pivot  >pivot

Q9:为什么归并排序适合链表排序,而快速排序不适合?

参考答案:

归并排序适合链表的原因

  1. 不需要随机访问

    • 归并排序只需要顺序访问元素(合并时从头到尾遍历)
    • 链表天然支持顺序访问
  2. O(1)额外空间

    • 数组版归并排序需要 O(n) 辅助数组
    • 链表版只需修改指针,不需要额外空间
  3. 找到中点容易

    • 快慢指针法:O(n) 时间,O(1) 空间
    • 不需要像数组那样计算索引

快速排序不适合链表的原因

  1. 分区困难

    • 快排通常需要从两端向中间扫描(Hoare分区)
    • 链表无法高效地从尾部向前扫描
  2. 无法原地分区

    • 即使实现分区,也需要频繁修改大量指针
    • 常数因子比归并排序大得多
  3. 缓存不友好

    • 链表节点分散在内存中,缓存命中率低
    • 快排的内存访问模式假设数据连续

Q10:如何实现一个通用的、稳定的、高效的排序算法?

参考答案:

工业级排序算法设计(以 Java Arrays.sort(Object[]) 为例)

设计目标:
1. 稳定性(对象排序必须稳定)
2. 最坏情况 O(n log n)
3. 最好情况 O(n)(利用已有序性)
4. 常数因子小

实现策略(TimSort):

1. 小数组(< 32)→ 二分插入排序
   - 利用前面元素已排序的特性,二分查找插入位置
   
2. 识别自然有序段(run)
   - 扫描数组,找到已排序的子数组
   - 如果是降序,反转成升序
   
3. 扩展短run到minRun长度
   - minRun = 32 ~ 64(根据n计算)
   - 使用插入排序扩展
   
4. 使用归并排序合并runs
   - 栈管理待合并的runs
   - 合并策略保证平衡(类似哈夫曼编码)
   
5. Galloping模式优化
   - 当一个run连续获胜时,切换到二分查找定位
   - 减少不必要的比较

代码结构

public static void timSort(Object[] arr, Comparator c) {
    int n = arr.length;
    int minRun = minRunLength(n);
    
    // Step 1: 将数组分成runs,并排序每个run
    for (int i = 0; i < n; i += runLength) {
        int runEnd = findRun(arr, i, c);
        if (runEnd - i < minRun) {
            runEnd = Math.min(i + minRun, n);
            binaryInsertionSort(arr, i, runEnd, c);
        }
        pushRun(i, runEnd);
        
        // Step 2: 合并栈中的runs,保持平衡
        mergeCollapse();
    }
    
    // Step 3: 合并所有剩余的runs
    mergeForceCollapse();
}

小结

分治思想是算法设计的核心范式:

  1. 快速排序:选基准分区,平均 O(n log n),不稳定,原地排序

    • 工程优化:三数取中、三路快排、小数组插排、尾递归优化
    • JDK实现:Dual-Pivot QuickSort(基本类型)
  2. 归并排序:分半递归合并,稳定 O(n log n),需要额外空间

    • 工程优化:自底向上、预分配数组、链表O(1)空间
    • JDK实现:TimSort(对象类型)
  3. 选择策略

    • 基本类型/性能优先 → 快排
    • 对象类型/稳定性需求 → 归并/TimSort
    • 链表 → 归并排序
    • 海量数据 → 外部归并排序
  4. 面试重点

    • 快排分区过程(手写代码)
    • 归并合并过程(手写代码)
    • 时间/空间复杂度分析(数学证明)
    • 稳定性分析
    • 实际场景选择(为什么Java这样设计)

此文原创,转载请注明出处。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐