← 返回首页

排序算法入门(四):堆排序 —— 用完全二叉树原地排序

2026-09-12·2 次浏览
排序算法入门(四):堆排序 —— 用完全二叉树原地排序

为什么一个数组就能当成一棵完全二叉树?因为完全二叉树逐层从左到右填满,最后一层也只缺右侧若干位置,中间没有空洞。这种形状让它的层序遍历序列能原样放进一段连续内存,父子关系完全由下标算出来,一个左右孩子指针都不用存。

数组与完全二叉树的对应关系

以 0 为起点编号,任意下标 i 的结点满足三条恒等式:

  • 父结点:(i - 1) / 2(Java 整数除法向下取整,i >= 1 时结果非负)
  • 左孩子:2 * i + 1
  • 右孩子:2 * i + 2

由此能推出哪些结点是叶子:下标 i 有左孩子当且仅当 2i + 1 < n,解得 i <= n/2 - 1。所以从下标 n/2 起直到末尾全是叶子,最后一个非叶结点是 n/2 - 1

这也解释了建堆为什么从 n/2 - 1 倒着往前跑。下沉有个前提:被下沉结点的左右子树必须已经是合法的堆。叶子没有子树,天然满足;按 i = n/2-1, ..., 0 的顺序处理时,轮到 i 时两个孩子下标都大于 i,早就处理完了,子树都是堆。

大顶堆与小顶堆

堆只施加一条约束:每个结点的值都不小于(大顶堆)或不大于(小顶堆)它的两个孩子。它不要求整棵树有序,左右孩子之间、同层结点之间都没有大小关系,堆唯一保证的是堆顶为全局最值。所以升序排序用大顶堆,堆顶那个最大值挪到末尾正好落在最终位置上。

下沉(sift down)才是主角

上浮用在插入:新元素追加到末尾,一路向上和父结点比。下沉用在删除堆顶和建堆:把元素放到某个位置,一路向下和较大的孩子比。两者不要混,堆排序里一次上浮都用不到。建堆是原地调整,不涉及插入;主循环把堆顶换到末尾后,被换到根上的元素破坏了堆性质,但它的左右子树完好,从根往下沉就够了。

三个细节:先挑出较大的孩子再比较,直接拿左孩子去比是常见错法;交换后要继续往下走,换下去的元素可能比下一层还小;边界用可控的 size,已经归位的尾部绝不能再碰。

建堆为什么是 O(n) 而不是 O(n log n)

最笨的估计是 n/2 个非叶结点各下沉 log n 层,得 O(n log n),但这个上界太松。结点数量和它能下沉的高度是反着来的:约一半结点是叶子,高度 0;约四分之一最多下沉 1 层;再往上一层只有约 n/8 个结点,最多 2 层。逐层把结点个数乘以最大高度再相加,后面的项衰减得比前面涨得还快,总和只与 n 成正比。

树高虽然是 log n,但站在那个高度上的结点只有 1 个,绝大多数结点贴着地面几乎不动。所以建堆是 O(n)O(n log n) 完全来自排序主体那 n 次换堆顶加下沉。

完整实现

java Copy
public class HeapSort {

    public static void sort(int[] a) {
        if (a == null || a.length < 2) {
            return;                 // 0 或 1 个元素已经有序,也避免后面下标越界
        }
        buildHeap(a);
        // 每轮把堆顶(当前最大值)换到有效区间末尾,之后它永久归位
        for (int end = a.length - 1; end > 0; end--) {
            swap(a, 0, end);
            // end 已归位,堆的有效范围缩到 [0, end),此时只有根被破坏
            siftDown(a, 0, end);
        }
    }

    private static void buildHeap(int[] a) {
        // 下标 >= n/2 的结点全是叶子,本身就是合法的堆,不用处理
        // 从最后一个非叶结点倒着走,保证下沉时左右子树已经是堆
        for (int i = a.length / 2 - 1; i >= 0; i--) {
            siftDown(a, i, a.length);
        }
    }

    /** 让 a[i] 在 [0, size) 这个范围内下沉到合适位置 */
    private static void siftDown(int[] a, int i, int size) {
        while (true) {
            int left = 2 * i + 1;
            if (left >= size) {
                return;                     // 没有孩子,已经落到叶子
            }
            int larger = left;
            int right = left + 1;
            if (right < size && a[right] > a[left]) {
                larger = right;             // 先选出较大的孩子,它才有资格和父结点竞争
            }
            if (a[i] >= a[larger]) {
                return;                     // 父结点不小于较大的孩子,堆性质已满足,可以停
            }
            swap(a, i, larger);
            i = larger;                     // 继续从被换下去的位置往下检查
        }
    }

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

    public static void main(String[] args) {
        int[] data = {4, 1, 7, 3, 9, 2, 8, 0, 5, 6};
        sort(data);
        System.out.println(java.util.Arrays.toString(data));
        // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
    }
}

复杂度与稳定性

堆的形状只由元素个数决定,任何输入都走同一条路径,所以最好、平均、最坏都是 O(n log n),不存在快排那种本来有序反而退化的问题。额外空间只有几个循环变量,O(1),是严格的原地排序。

但它是不稳定的:主循环把堆顶与末尾互换,下沉时父结点又与较远的后代互换,相等元素的相对次序很容易被打乱且无法补救。要稳定性只能改用归并排序。

算法 平均 最坏 额外空间 稳定性 是否原地
堆排序 O(n log n) O(n log n) O(1) 不稳定
快速排序 O(n log n) O(n^2) O(log n) 递归栈 不稳定
归并排序 O(n log n) O(n log n) O(n) 稳定

堆排序的取舍

时间和空间都挑不出毛病,为什么工程里的通用排序几乎都用快排?答案是访存模式。快排在数组上顺序扫描,两个指针贴着走,缓存预取和分支预测都能吃满;堆排序在树里来回跳,访问 2i+12i+2(i-1)/2n 一大,每次跳转都可能踩到没进缓存的 cache line。比较次数同阶,实际耗时通常差出一截。

但堆排序有一条别人替代不了:最坏也是 O(n log n),额外空间 O(1)。快排的 O(n log n) 只是平均,最坏 O(n^2),递归栈还要 O(log n)O(n)。这个差别在对上承诺"任意输入都不超时"时很值钱:C++ 标准库的 std::sort 用 introsort,主体是快排,递归深度一旦超过 2 * log n 就切堆排序兜底。

堆更常见的用途:优先队列与 TopK

业务代码里手写堆排序的机会不多,但堆这套机制天天在用,只是被封装好了。java.util.PriorityQueue 默认是小顶堆,offer 上浮、poll 弹出堆顶后下沉都是 O(log n),而 peek 只看堆顶,是 O(1)

java Copy
PriorityQueue<Integer> minHeap = new PriorityQueue<>();                     // 默认小顶堆
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());

minHeap.offer(5);
minHeap.offer(1);
minHeap.peek();      // 1,O(1):堆顶就是全局最小值
minHeap.poll();      // 1,O(log n):弹出后自动修复堆

调度和定时任务也靠它:ScheduledThreadPoolExecutor 内部是 DelayedWorkQueueDelayQueue 同理,都是小顶堆,堆顶就是最早到期的任务,调度线程看一眼就知道该不该干活:

java Copy
ScheduledExecutorService pool = Executors.newScheduledThreadPool(2);
// 堆顶即最近到期的任务
pool.schedule(() -> System.out.println("run"), 3, TimeUnit.SECONDS);

合并 K 个有序链表是"多路归并 + 小顶堆"。不建堆就得每轮在 K 个链表头里线性找最小,整体 O(NK);堆里只维护 K 个当前候选,每取一个元素 O(log K),总计 O(N log K)

java Copy
class ListNode {
    int val;
    ListNode next;
    ListNode(int val) { this.val = val; }
}

static ListNode mergeKLists(ListNode[] lists) {
    // 堆里只放每条链表"当前最小的那个候选",保证堆顶是全局最小
    PriorityQueue<ListNode> pq = new PriorityQueue<>(Comparator.comparingInt(n -> n.val));
    for (ListNode head : lists) {
        if (head != null) {
            pq.offer(head);            // 空链表不进堆,否则后面取 val 会 NPE
        }
    }
    ListNode dummy = new ListNode(0);
    ListNode tail = dummy;
    while (!pq.isEmpty()) {
        ListNode node = pq.poll();
        tail.next = node;              // 直接接上已有结点,不新建对象
        tail = node;
        if (node.next != null) {
            pq.offer(node.next);       // 该链表的下一个元素成为新候选
        }
    }
    return dummy.next;
}

TopK 是堆出现频率最高的用法。求最大的 K 个数用小顶堆:堆里始终保存目前见过的最大的 K 个,堆顶是其中最小的,也是唯一有资格被淘汰的。新来一个数比堆顶大就替换掉堆顶,否则丢弃。复杂度 O(n log K)n 大而 K 小时比全排序划算得多,流式数据也能用,内存里只需要 K 个元素:

java Copy
import java.util.Arrays;
import java.util.PriorityQueue;

public class TopK {

    /** 返回数组中最大的 k 个数,结果不保证有序 */
    public static int[] topK(int[] nums, int k) {
        if (nums == null || nums.length == 0 || k <= 0) {
            return new int[0];
        }
        k = Math.min(k, nums.length);

        // 求最大的 k 个用小顶堆:堆顶是这 k 个里最小的,充当"守门员"
        // 新元素只有比堆顶大才值得进来,进来就要挤掉堆顶
        PriorityQueue<Integer> heap = new PriorityQueue<>(k);
        for (int v : nums) {
            if (heap.size() < k) {
                heap.offer(v);              // 还没装满,先无条件收下
            } else if (v > heap.peek()) {
                heap.poll();                // 堆顶不够格,淘汰它
                heap.offer(v);
            }
        }

        int[] res = new int[heap.size()];
        for (int i = 0; i < res.length; i++) {
            res[i] = heap.poll();           // 小顶堆依次弹出,结果是升序
        }
        return res;
    }

    public static void main(String[] args) {
        int[] nums = {3, 2, 1, 5, 6, 4};
        System.out.println(Arrays.toString(topK(nums, 2)));   // [5, 6]
    }
}

方向必须记牢:求最大的 K 个用小顶堆,求最小的 K 个用大顶堆。记法是堆顶当守门员,它得是能被淘汰的那个。

小结

下标三条恒等式是一切的起点,n/2 - 1 既是最后一个非叶结点,也是建堆循环的起点;结点数随深度递减而可下沉高度递增,建堆因此收敛到 O(n),那 n 次换堆顶加下沉才是 O(n log n) 的来源。堆排序原地、空间 O(1)、最坏也是 O(n log n),代价是访问跳跃、缓存不友好且不稳定,所以它很少当主力,更像快排身边负责兜底的那一个。

── 完 ──

评论

还没有评论,来说点什么
发表评论
0 / 1000