数据结构 三
承接上篇基础排序算法,本文继续深入讲解两种进阶的O(nlogn)级排序算法:堆排序与归并排序。堆排序是完全二叉树结构在排序领域的经典应用,归并排序是分治思想的标准工程实现,二者既是算法学习的核心难点,也是校招面试、考研的高频考点。
本文将逐层拆解堆的核心操作、排序全流程、复杂度数学推导,同时补充递归与双版本代码实现、经典算法衍生题与工程应用场景,形成完整的知识闭环。
一、堆排序
堆排序是八大排序中理解门槛最高的算法之一,它摒弃了普通排序两两比较的线性思路,借助完全二叉树的堆结构,将查找极值的操作从O(n)优化到O(logn),最终实现了稳定的O(nlogn)时间复杂度,且全程为原地排序,空间开销极低。
1. 前置基础:完全二叉树与数组的映射关系
堆的物理载体是数组,逻辑结构是一棵完全二叉树:除最后一层外,其余所有层的节点数都是满的;最后一层的节点从左到右连续排列,没有空缺。
完全二叉树的结构特性,让它可以完全脱离指针,直接用数组下标完成父子节点的定位。对于数组中下标为 i 的节点(数组从0开始计数),存在固定的下标映射公式:
- 左子节点下标:
2 * i + 1 - 右子节点下标:
2 * i + 2 - 父节点下标:
(i - 1) / 2(整数除法,向下取整)
正因为这层完美的映射关系,堆排序不需要真正创建树节点对象、不需要申请额外的树结构内存,直接在原数组上就能完成所有堆操作,空间效率极高。
2. 堆的分类:大顶堆与小顶堆
堆根据节点大小规则分为两类,排序时可根据需求选择:
- 大顶堆(大根堆) :任意父节点的值,都大于等于其左右子节点的值。因此堆顶(根节点)是整个序列的最大值,用于实现升序排序。
- 小顶堆(小根堆) :任意父节点的值,都小于等于其左右子节点的值。因此堆顶是整个序列的最小值,用于实现降序排序,也是Top K问题的核心实现。
无论是大顶堆还是小顶堆,规则都必须对整棵树的所有节点成立,这是堆合法性的核心判定标准。
3. 堆的两大核心基础操作
堆的所有操作都基于两个基础方法:向下调整与向上调整。堆排序主要使用向下调整,向上调整则用于堆的元素插入场景。
3.1 向下调整(siftDown)
向下调整是堆维护的核心方法,作用是:当根节点不满足堆性质时,将其逐层向下交换,直到它落到合法的位置,使整棵子树重新符合堆规则。
执行步骤(以大顶堆为例):
- 定义两个游标:
parent指向待调整的父节点,child指向子节点 - 比较左右两个子节点,让
child指向值更大的子节点 - 比较父节点与最大子节点:
- 父节点值 ≥ 子节点值:符合大顶堆规则,调整结束
- 父节点值 < 子节点值:交换父子节点的值
- 交换后,下层子节点被替换为更小的值,可能破坏下层堆规则,因此
parent下移到child位置,child继续指向新的左右子节点,重复步骤2-3 - 直到子节点超出数组有效范围,或父节点值大于子节点,调整结束
3.2 向上调整(siftUp)
向上调整主要用于堆的元素插入:新元素默认添加到数组末尾,然后逐层向上比较交换,直到符合堆规则。
执行步骤(以大顶堆为例):
- 新元素放在数组末尾,
child指向新元素下标 - 计算父节点下标
parent = (child - 1) / 2 - 比较父子节点:
- 子节点值 ≤ 父节点值:符合堆规则,调整结束
- 子节点值 > 父节点值:交换父子节点的值
child上移到父节点位置,继续计算新的父节点,重复比较- 直到到达根节点,或子节点值不大于父节点,调整结束
4. 堆排序完整执行流程
堆排序分为两大阶段:构建初始大顶堆、迭代交换与调整。
阶段一:构建初始大顶堆
构建堆不需要从叶子节点开始调整------叶子节点没有子节点,天然符合堆规则。我们只需要从最后一个非叶子节点开始,从后往前逐个执行向下调整即可。
- 最后一个非叶子节点下标:
n / 2 - 1(n为数组总长度)
从该下标向前遍历到根节点,每个节点执行一次向下调整;遍历完成后,整个数组就变成了合法的大顶堆。
阶段二:迭代交换与堆调整
初始大顶堆构建完成后,进入排序循环,每一轮完成三件事:
- 交换堆顶与堆尾:将数组第一个元素(堆顶最大值)与当前未排序区的最后一个元素交换,最大值进入末尾已排序区,不再参与后续堆构建。
- 未排序区长度减一:有效堆长度减1,排除掉已归位的最大值。
- 根节点向下调整:交换后只有根节点被替换为小值,其余位置依然符合堆规则,因此只需对根节点执行一次向下调整,就能让剩余元素重新成为合法大顶堆。
不断重复上述三步,直到未排序区只剩一个元素,整个数组就完成了升序排序。
关键优化点:第二轮之后不需要全量重建堆,仅调整根节点即可,这是堆排序效率的核心来源。
5. Java完整代码实现
java
public class HeapSort {
/**
* 堆排序入口方法(升序,大顶堆实现)
* @param arr 待排序数组
*/
public static void heapSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
int n = arr.length;
// 第一阶段:从后往前构建大顶堆
// 最后一个非叶子节点下标 = n/2 - 1
for (int i = n / 2 - 1; i >= 0; i--) {
siftDown(arr, i, n);
}
// 第二阶段:迭代交换堆顶与堆尾,调整剩余元素
for (int i = n - 1; i > 0; i--) {
// 堆顶最大值与当前堆尾交换
swap(arr, 0, i);
// 剩余i个元素,对根节点执行向下调整
siftDown(arr, 0, i);
}
}
/**
* 向下调整:维护以parent为根的大顶堆
* @param arr 数组
* @param parent 待调整的父节点下标
* @param length 堆的有效元素长度
*/
private static void siftDown(int[] arr, int parent, int length) {
int temp = arr[parent]; // 保存父节点的值,避免多次交换
// child 先指向左孩子
for (int child = 2 * parent + 1; child < length; child = 2 * child + 1) {
// 如果右孩子存在且值更大,child指向右孩子
if (child + 1 < length && arr[child] < arr[child + 1]) {
child++;
}
// 父节点大于等于最大子节点,符合堆规则,结束
if (temp >= arr[child]) {
break;
}
// 子节点更大,将子节点值赋给父节点位置
arr[parent] = arr[child];
// parent下移,继续向下检查
parent = child;
}
// 最初的父节点值放到最终合法位置
arr[parent] = temp;
}
/**
* 向上调整(堆插入时使用,排序中不涉及,补充知识点)
* @param arr 数组
* @param child 新插入元素的下标
*/
private static void siftUp(int[] arr, int child) {
int temp = arr[child];
while (child > 0) {
int parent = (child - 1) / 2;
if (temp <= arr[parent]) {
break;
}
arr[child] = arr[parent];
child = parent;
}
arr[child] = temp;
}
// 交换数组中两个下标的元素
private static void swap(int[] arr, int a, int b) {
int temp = arr[a];
arr[a] = arr[b];
arr[b] = temp;
}
// 测试示例
public static void main(String[] args) {
int[] arr = {5, 7, 4, 2, 0, 3, 1, 6};
heapSort(arr);
// 输出:[0, 1, 2, 3, 4, 5, 6, 7]
for (int num : arr) {
System.out.print(num + " ");
}
}
}
6. 时间复杂度深度数学推导
堆排序的总时间由「建堆」和「排序」两部分组成,两部分的复杂度量级不同。
(1)建堆阶段:时间复杂度 O(n)
很多人会误以为建堆是O(nlogn),但严格数学推导可以证明建堆的时间复杂度是线性的。
- 设树的高度为H,根节点在第0层,则第h层最多有
2^h个节点 - 第h层的每个节点,向下调整最多走
H - h步 - 总调整次数 S = Σ(第h层节点数 × 该层最大调整步数)
代入等比数列求和公式化简后可得:S < n。也就是说,建堆的总操作次数是线性级别的,时间复杂度为 O(n)。
(2)排序阶段:时间复杂度 O(nlogn)
排序阶段共执行n-1次交换与调整,每次向下调整的最大步数等于树的高度。n个节点的完全二叉树高度为 log₂n,因此单次调整复杂度为O(logn)。
总操作量为 (n-1) × O(logn),时间复杂度为 O(nlogn)。
(3)整体复杂度
两部分相加后,最高阶项为O(nlogn),因此堆排序整体时间复杂度为 O(nlogn)。且无论数据是否有序、是否逆序,操作次数都基本固定,最好、最坏、平均场景下时间复杂度完全一致,性能极其稳定。
7. 经典扩展应用:Top K 问题
堆排序的思想最经典的工程应用就是Top K问题:在海量数据中,找到最大的前K个元素,不需要对全量数据排序。
解题思路
- 取前K个元素,构建一个小顶堆
- 遍历剩余元素:
- 如果当前元素 > 堆顶元素:替换堆顶,执行一次向下调整
- 如果当前元素 ≤ 堆顶元素:直接跳过
- 遍历结束后,堆中的K个元素就是最大的前K个值
优势
- 空间复杂度仅为O(K),不需要加载全部数据到内存,适合海量数据场景
- 时间复杂度为O(nlogK),远低于全量排序的O(nlogn)
Java代码实现
java
import java.util.Arrays;
public class TopK {
public static int[] getTopK(int[] arr, int k) {
if (k <= 0 || arr == null || arr.length < k) {
return new int[0];
}
// 取前k个元素,构建小顶堆
int[] heap = Arrays.copyOf(arr, k);
for (int i = k / 2 - 1; i >= 0; i--) {
siftDownMin(heap, i, k);
}
// 遍历剩余元素
for (int i = k; i < arr.length; i++) {
if (arr[i] > heap[0]) {
heap[0] = arr[i];
siftDownMin(heap, 0, k);
}
}
return heap;
}
// 小顶堆向下调整
private static void siftDownMin(int[] heap, int parent, int length) {
int temp = heap[parent];
for (int child = 2 * parent + 1; child < length; child = 2 * child + 1) {
if (child + 1 < length && heap[child] > heap[child + 1]) {
child++;
}
if (temp <= heap[child]) {
break;
}
heap[parent] = heap[child];
parent = child;
}
heap[parent] = temp;
}
}
8. 核心特性总结
- 时间复杂度:最好/最坏/平均均为 O(nlogn),性能稳定
- 空间复杂度:O(1),纯原地排序,仅借助少量辅助变量
- 稳定性:不稳定排序,堆顶与堆尾的跨位置交换会打乱相等元素的相对顺序
- 适用场景:内存敏感场景、海量数据Top K问题、优先级队列实现
二、归并排序
归并排序是分治(Divide and Conquer)思想的最典型实现,核心逻辑是「先拆分、后合并」:将大数组不断二分拆分为最小单元,再从最小单元开始逐层合并有序序列,最终得到整体有序的数组。它是性能最稳定的O(nlogn)排序算法,也是外部排序的核心基础。
1. 前置基础:双指针合并两个有序序列
合并有序序列是归并排序的原子操作:给定两个各自有序的短数组,用线性时间将它们合并为一个整体有序的长数组。
双指针法执行步骤
- 定义两个指针
s1、s2,分别指向两个有序数组的起始位置 - 定义临时数组存储结果,
index指向临时数组当前写入位置 - 循环比较
s1和s2指向的元素:- 若
arr[s1] <= arr[s2],将arr[s1]写入临时数组,s1++ - 否则将
arr[s2]写入临时数组,s2++
- 若
- 当其中一个数组遍历完毕后,将另一个数组的剩余元素直接追加到临时数组末尾
整个过程每个元素仅被访问一次,时间复杂度为O(m+n),且相等元素优先保留左区间的,天然保证排序稳定性。
2. 归并排序核心思想:分治
一个无序数组无法直接合并,因此先通过拆分将问题化小,再逐层合并解决。
拆分阶段
- 将当前数组区间从中间位置一分为二,得到左、右两个子区间
- 对左、右子区间递归执行拆分操作
- 递归出口:当区间长度为1(左边界等于右边界)时,停止拆分------单个元素天然就是有序的。
合并阶段
- 从长度为1的最小区间开始,按照拆分的逆序,逐层向上合并
- 每一层合并都使用双指针法,将两个有序子区间合并为一个有序区间
- 合并到最顶层时,整个数组就完成了排序
3. 递归版实现与内存逻辑
归并排序最经典的实现是递归版本,通过左右边界游标标记当前处理的区间,不需要创建多份子数组。
递归执行的内存流程
递归方法的调用遵循「深度优先」,并不是先把整个数组全拆完再合并,而是:
- 优先向左递归拆分,左半部分一直拆到递归出口,方法出栈
- 再向右递归拆分右半部分,拆到递归出口,方法出栈
- 左右都拆分完成后,执行合并方法,合并完成后当前层方法出栈
- 回到上一层,继续处理右半部分,最终回到顶层完成整体排序
整个过程中,原数组始终存放在堆内存中,所有方法操作同一块数组;临时数组在合并时创建,方法结束后随栈帧回收。
Java递归版完整代码
java
public class MergeSort {
/**
* 归并排序入口方法
* @param arr 待排序数组
*/
public static void mergeSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
// 临时数组,全程复用,避免每次合并都申请新数组
int[] temp = new int[arr.length];
mergeSort(arr, 0, arr.length - 1, temp);
}
/**
* 递归拆分与合并
* @param arr 原数组
* @param left 当前区间左边界
* @param right 当前区间右边界
* @param temp 临时数组
*/
private static void mergeSort(int[] arr, int left, int right, int[] temp) {
// 递归出口:区间只有一个元素,天然有序
if (left >= right) {
return;
}
// 计算中间位置,避免整数溢出
int mid = left + (right - left) / 2;
// 递归拆分左半部分
mergeSort(arr, left, mid, temp);
// 递归拆分右半部分
mergeSort(arr, mid + 1, right, temp);
// 左右都有序后,合并两个有序区间
merge(arr, left, mid, right, temp);
}
/**
* 合并两个有序区间 [left, mid] 和 [mid+1, right]
* @param arr 原数组
* @param left 左区间起点
* @param mid 左区间终点
* @param right 右区间终点
* @param temp 临时数组
*/
private static void merge(int[] arr, int left, int mid, int right, int[] temp) {
int s1 = left; // 左区间指针
int s2 = mid + 1; // 右区间指针
int index = left; // 临时数组写入指针
// 双指针比较,小的元素写入临时数组
while (s1 <= mid && s2 <= right) {
if (arr[s1] <= arr[s2]) {
temp[index++] = arr[s1++];
} else {
temp[index++] = arr[s2++];
}
}
// 处理左区间剩余元素
while (s1 <= mid) {
temp[index++] = arr[s1++];
}
// 处理右区间剩余元素
while (s2 <= right) {
temp[index++] = arr[s2++];
}
// 将临时数组的结果写回原数组的对应区间
for (int i = left; i <= right; i++) {
arr[i] = temp[i];
}
}
// 测试示例
public static void main(String[] args) {
int[] arr = {5, 7, 4, 2, 0, 3, 1, 6};
mergeSort(arr);
// 输出:0 1 2 3 4 5 6 7
for (int num : arr) {
System.out.print(num + " ");
}
}
}
4. 迭代版(非递归)归并排序
递归版本依赖方法调用栈,当数据量极大时,递归深度过深可能导致栈溢出。迭代版归并排序从底向上逐层合并,完全规避了递归栈的问题,更适合超大规模数据场景。
核心思路
- 初始子数组长度
gap = 1,每个元素自己就是一个有序子数组 - 按当前gap长度,两两分组合并所有子数组
gap *= 2,子数组长度翻倍,继续下一轮合并- 直到gap >= 数组长度,排序完成
Java迭代版代码
java
public class MergeSortIterative {
public static void mergeSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
int n = arr.length;
int[] temp = new int[n];
// gap 为当前有序子数组的长度,从1开始翻倍
for (int gap = 1; gap < n; gap *= 2) {
// 每一组两个子数组,左子数组起点为left
for (int left = 0; left < n; left += 2 * gap) {
int mid = Math.min(left + gap - 1, n - 1);
int right = Math.min(left + 2 * gap - 1, n - 1);
// 合并两个子区间
merge(arr, left, mid, right, temp);
}
}
}
// merge方法与递归版完全一致
private static void merge(int[] arr, int left, int mid, int right, int[] temp) {
int s1 = left;
int s2 = mid + 1;
int index = left;
while (s1 <= mid && s2 <= right) {
if (arr[s1] <= arr[s2]) {
temp[index++] = arr[s1++];
} else {
temp[index++] = arr[s2++];
}
}
while (s1 <= mid) {
temp[index++] = arr[s1++];
}
while (s2 <= right) {
temp[index++] = arr[s2++];
}
for (int i = left; i <= right; i++) {
arr[i] = temp[i];
}
}
}
5. 经典衍生算法:逆序对计数问题
归并排序的合并过程,可以高效解决数组中逆序对的数量问题,这是算法面试的高频题。
问题描述
在数组中,如果前一个数字大于后一个数字,则这两个数字组成一个逆序对。求数组中逆序对的总数。
解题思路
暴力解法需要O(n²)时间,而归并排序可以在O(nlogn)时间内完成计数:
在合并两个有序区间时,如果右区间的元素小于左区间的元素,说明左区间当前位置到末尾的所有元素,都和该右区间元素构成逆序对。此时累加 mid - s1 + 1 到总计数中,其余逻辑和普通归并排序完全一致。
Java代码实现
java
public class ReversePairs {
private static int count = 0;
public static int reversePairs(int[] arr) {
count = 0;
if (arr == null || arr.length <= 1) {
return 0;
}
int[] temp = new int[arr.length];
mergeSort(arr, 0, arr.length - 1, temp);
return count;
}
private static void mergeSort(int[] arr, int left, int right, int[] temp) {
if (left >= right) {
return;
}
int mid = left + (right - left) / 2;
mergeSort(arr, left, mid, temp);
mergeSort(arr, mid + 1, right, temp);
merge(arr, left, mid, right, temp);
}
private static void merge(int[] arr, int left, int mid, int right, int[] temp) {
int s1 = left;
int s2 = mid + 1;
int index = left;
while (s1 <= mid && s2 <= right) {
if (arr[s1] <= arr[s2]) {
temp[index++] = arr[s1++];
} else {
// 核心:左区间剩余元素都与当前s2构成逆序对
count += mid - s1 + 1;
temp[index++] = arr[s2++];
}
}
while (s1 <= mid) {
temp[index++] = arr[s1++];
}
while (s2 <= right) {
temp[index++] = arr[s2++];
}
for (int i = left; i <= right; i++) {
arr[i] = temp[i];
}
}
}
6. 工程扩展:外部排序与多路归并
当待排序的数据量远超内存容量(例如100GB数据,只有8GB内存),无法一次性加载到内存排序时,就需要用到外部排序,其核心思想就是归并排序的延伸。
外部排序执行步骤
- 分块排序:将100GB数据切分为100个1GB的小块,依次将每个小块加载到内存,用快速排序等内存排序算法排好序,写回磁盘。
- 多路归并:同时打开所有有序小块文件,维护一个小顶堆,每次从所有块的当前头部取出最小值,写入结果文件,对应块的指针后移。
- 重复读取、比较、写入,直到所有数据合并完成。
这种思路也叫多路归并排序,是大数据领域、数据库排序、文件系统排序的标准实现方案,也是归并思想在工程领域最核心的应用。
7. 复杂度分析
- 时间复杂度 :最好/最坏/平均均为 O(nlogn)。每一层合并的总操作量都是O(n),总共有logn层,相乘得到总复杂度。性能完全不受数据有序性影响,是所有排序中最稳定的。
- 空间复杂度 :O(n)。需要额外的临时数组存储合并结果,同时递归版有O(logn)的栈空间开销,整体为线性空间。
- 稳定性 :稳定排序。合并时相等元素优先保留左区间的相对顺序,天然保证稳定性。
8. 核心特性总结
- 优势:性能稳定、排序稳定、支持外部排序、适合海量数据
- 劣势:需要额外的内存空间,内存敏感场景下有局限性
- 适用场景:对排序稳定性有要求的场景、磁盘上的海量数据排序、链表排序
三、进阶排序算法横向对比与高频考点
1. 三种O(nlogn)排序核心指标对比
| 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 | 原地排序 | 核心思想 |
|---|---|---|---|---|---|---|
| 快速排序 | O(nlogn) | O(n²)(优化后极难出现) | O(logn) | 不稳定 | 是 | 分治+分区交换 |
| 堆排序 | O(nlogn) | O(nlogn) | O(1) | 不稳定 | 是 | 完全二叉树堆结构 |
| 归并排序 | O(nlogn) | O(nlogn) | O(n) | 稳定 | 否 | 分治+合并有序序列 |
2. 高频面试考点汇总
-
为什么实际运行中快速排序通常比堆排序快?
- 缓存友好性:快排是顺序访问数组,局部性好,CPU缓存命中率高;堆排序是父子节点跳着访问,缓存不友好。
- 常数因子:快排的分区操作简单,运算量小;堆排序需要多次父子比较与交换,常数因子更大。
-
堆排序建堆的时间复杂度为什么是O(n),不是O(nlogn)?
建堆从最后一个非叶子节点开始,越靠下层的节点调整步数越少,通过等比数列求和可证明总操作量小于n,因此是线性复杂度。只有排序阶段才是O(nlogn)。
-
归并排序的稳定性能带来什么实际价值?
多维度排序场景下,稳定排序可以保留上一次排序的相对顺序。例如电商商品先按销量排序,再按价格排序,稳定排序能保证价格相同的商品依然按销量高低排列。
-
什么场景下优先选择归并排序而不是快速排序?
- 对排序稳定性有严格要求时
- 数据存储在磁盘上、无法全部加载到内存的外部排序场景
- 链表排序场景(归并排序不需要额外空间,链表指针修改即可)