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

文章标签: #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 对比:

特性LomutoHoare
交换次数较多(每次发现小元素都交换)较少(只有必要时交换)
基准选择通常最后一个元素任意位置(通常中间)
分区结果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提升幅度
随机数据12ms8ms33%
已排序数据850ms10ms98.8%
逆序数据800ms10ms98.8%
重复数据15ms8ms47%

测试条件: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 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐