数据结构 三

数据结构 三

承接上篇基础排序算法,本文继续深入讲解两种进阶的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)

向下调整是堆维护的核心方法,作用是:当根节点不满足堆性质时,将其逐层向下交换,直到它落到合法的位置,使整棵子树重新符合堆规则。

执行步骤(以大顶堆为例)

  1. 定义两个游标:parent 指向待调整的父节点,child 指向子节点
  2. 比较左右两个子节点,让 child 指向值更大的子节点
  3. 比较父节点与最大子节点:
    • 父节点值 ≥ 子节点值:符合大顶堆规则,调整结束
    • 父节点值 < 子节点值:交换父子节点的值
  4. 交换后,下层子节点被替换为更小的值,可能破坏下层堆规则,因此 parent 下移到 child 位置,child 继续指向新的左右子节点,重复步骤2-3
  5. 直到子节点超出数组有效范围,或父节点值大于子节点,调整结束
3.2 向上调整(siftUp)

向上调整主要用于堆的元素插入:新元素默认添加到数组末尾,然后逐层向上比较交换,直到符合堆规则。

执行步骤(以大顶堆为例)

  1. 新元素放在数组末尾,child 指向新元素下标
  2. 计算父节点下标 parent = (child - 1) / 2
  3. 比较父子节点:
    • 子节点值 ≤ 父节点值:符合堆规则,调整结束
    • 子节点值 > 父节点值:交换父子节点的值
  4. child 上移到父节点位置,继续计算新的父节点,重复比较
  5. 直到到达根节点,或子节点值不大于父节点,调整结束

4. 堆排序完整执行流程

堆排序分为两大阶段:构建初始大顶堆、迭代交换与调整。

阶段一:构建初始大顶堆

构建堆不需要从叶子节点开始调整------叶子节点没有子节点,天然符合堆规则。我们只需要从最后一个非叶子节点开始,从后往前逐个执行向下调整即可。

  • 最后一个非叶子节点下标:n / 2 - 1(n为数组总长度)

从该下标向前遍历到根节点,每个节点执行一次向下调整;遍历完成后,整个数组就变成了合法的大顶堆。

阶段二:迭代交换与堆调整

初始大顶堆构建完成后,进入排序循环,每一轮完成三件事:

  1. 交换堆顶与堆尾:将数组第一个元素(堆顶最大值)与当前未排序区的最后一个元素交换,最大值进入末尾已排序区,不再参与后续堆构建。
  2. 未排序区长度减一:有效堆长度减1,排除掉已归位的最大值。
  3. 根节点向下调整:交换后只有根节点被替换为小值,其余位置依然符合堆规则,因此只需对根节点执行一次向下调整,就能让剩余元素重新成为合法大顶堆。

不断重复上述三步,直到未排序区只剩一个元素,整个数组就完成了升序排序。

关键优化点:第二轮之后不需要全量重建堆,仅调整根节点即可,这是堆排序效率的核心来源。

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个元素,不需要对全量数据排序。

解题思路
  1. 取前K个元素,构建一个小顶堆
  2. 遍历剩余元素:
    • 如果当前元素 > 堆顶元素:替换堆顶,执行一次向下调整
    • 如果当前元素 ≤ 堆顶元素:直接跳过
  3. 遍历结束后,堆中的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. 前置基础:双指针合并两个有序序列

合并有序序列是归并排序的原子操作:给定两个各自有序的短数组,用线性时间将它们合并为一个整体有序的长数组。

双指针法执行步骤
  1. 定义两个指针 s1s2,分别指向两个有序数组的起始位置
  2. 定义临时数组存储结果,index 指向临时数组当前写入位置
  3. 循环比较 s1s2 指向的元素:
    • arr[s1] <= arr[s2],将 arr[s1] 写入临时数组,s1++
    • 否则将 arr[s2] 写入临时数组,s2++
  4. 当其中一个数组遍历完毕后,将另一个数组的剩余元素直接追加到临时数组末尾

整个过程每个元素仅被访问一次,时间复杂度为O(m+n),且相等元素优先保留左区间的,天然保证排序稳定性。

2. 归并排序核心思想:分治

一个无序数组无法直接合并,因此先通过拆分将问题化小,再逐层合并解决。

拆分阶段
  1. 将当前数组区间从中间位置一分为二,得到左、右两个子区间
  2. 对左、右子区间递归执行拆分操作
  3. 递归出口:当区间长度为1(左边界等于右边界)时,停止拆分------单个元素天然就是有序的。
合并阶段
  1. 从长度为1的最小区间开始,按照拆分的逆序,逐层向上合并
  2. 每一层合并都使用双指针法,将两个有序子区间合并为一个有序区间
  3. 合并到最顶层时,整个数组就完成了排序

3. 递归版实现与内存逻辑

归并排序最经典的实现是递归版本,通过左右边界游标标记当前处理的区间,不需要创建多份子数组。

递归执行的内存流程

递归方法的调用遵循「深度优先」,并不是先把整个数组全拆完再合并,而是:

  1. 优先向左递归拆分,左半部分一直拆到递归出口,方法出栈
  2. 再向右递归拆分右半部分,拆到递归出口,方法出栈
  3. 左右都拆分完成后,执行合并方法,合并完成后当前层方法出栈
  4. 回到上一层,继续处理右半部分,最终回到顶层完成整体排序

整个过程中,原数组始终存放在堆内存中,所有方法操作同一块数组;临时数组在合并时创建,方法结束后随栈帧回收。

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. 迭代版(非递归)归并排序

递归版本依赖方法调用栈,当数据量极大时,递归深度过深可能导致栈溢出。迭代版归并排序从底向上逐层合并,完全规避了递归栈的问题,更适合超大规模数据场景。

核心思路
  1. 初始子数组长度 gap = 1,每个元素自己就是一个有序子数组
  2. 按当前gap长度,两两分组合并所有子数组
  3. gap *= 2,子数组长度翻倍,继续下一轮合并
  4. 直到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内存),无法一次性加载到内存排序时,就需要用到外部排序,其核心思想就是归并排序的延伸。

外部排序执行步骤
  1. 分块排序:将100GB数据切分为100个1GB的小块,依次将每个小块加载到内存,用快速排序等内存排序算法排好序,写回磁盘。
  2. 多路归并:同时打开所有有序小块文件,维护一个小顶堆,每次从所有块的当前头部取出最小值,写入结果文件,对应块的指针后移。
  3. 重复读取、比较、写入,直到所有数据合并完成。

这种思路也叫多路归并排序,是大数据领域、数据库排序、文件系统排序的标准实现方案,也是归并思想在工程领域最核心的应用。

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. 高频面试考点汇总

  1. 为什么实际运行中快速排序通常比堆排序快?

    • 缓存友好性:快排是顺序访问数组,局部性好,CPU缓存命中率高;堆排序是父子节点跳着访问,缓存不友好。
    • 常数因子:快排的分区操作简单,运算量小;堆排序需要多次父子比较与交换,常数因子更大。
  2. 堆排序建堆的时间复杂度为什么是O(n),不是O(nlogn)?

    建堆从最后一个非叶子节点开始,越靠下层的节点调整步数越少,通过等比数列求和可证明总操作量小于n,因此是线性复杂度。只有排序阶段才是O(nlogn)。

  3. 归并排序的稳定性能带来什么实际价值?

    多维度排序场景下,稳定排序可以保留上一次排序的相对顺序。例如电商商品先按销量排序,再按价格排序,稳定排序能保证价格相同的商品依然按销量高低排列。

  4. 什么场景下优先选择归并排序而不是快速排序?

    • 对排序稳定性有严格要求时
    • 数据存储在磁盘上、无法全部加载到内存的外部排序场景
    • 链表排序场景(归并排序不需要额外空间,链表指针修改即可)