为什么堆能 O(log n) 插入?拆解完全二叉树的数组魔法

为什么堆能 O(log n) 插入?拆解完全二叉树的数组魔法

摘要:堆是一种用数组存储的完全二叉树,能在 O(log n) 时间内完成插入和删除。本文从父子下标公式出发,手把手拆解上浮、下沉、建堆三大核心操作,并附完整 C 语言实现和 TopK 实战经验。

📑 目录

  • 堆是什么?数组里的完全二叉树
  • 堆的两种类型:大根堆与小根堆
  • 堆能做什么?应用场景与性能优势
  • 核心操作一:上浮(插入新元素)
  • 核心操作二:下沉(删除堆顶 / 建堆)
  • 建堆的两种方式:逐个插入 vs 原地堆化
  • 完整代码:可运行的 C 语言小根堆
  • 实战经验:TopK 问题的堆解法与 C 语言避坑指南
  • 一点总结
  • 互动讨论

堆是什么?数组里的完全二叉树

堆是一种特殊的完全二叉树 结构,但它在物理上是用数组存储的。

  • 逻辑结构:完全二叉树(除了最后一层,其他层全满,最后一层从左到右填充)。
  • 物理存储:数组(连续内存,通过下标计算父子关系)。

父子下标计算公式(数组下标从 0 开始):

关系 公式
父节点 → 左孩子 left = 2 * i + 1
父节点 → 右孩子 right = 2 * i + 2
孩子节点 → 父节点 parent = (i - 1) / 2

用一个数组就能表示一棵树,不需要指针,不需要节点结构体------这就是堆的"数组魔法"。你能通过下标在树和数组之间自由切换,访问速度极快。

堆的两种类型:大根堆与小根堆

堆分为两种,取决于"父节点和子节点"的大小关系:

类型 规则 根节点
大根堆(Max Heap) 每个父节点都 ≥ 其子节点 最大值
小根堆(Min Heap) 每个父节点都 ≤ 其子节点 最小值

无论哪种,堆只保证父与子之间的大小关系,不保证兄弟节点之间的顺序。

堆能做什么?应用场景与性能优势

主要应用场景

场景 思路
TopK 问题(找最大/最小的 K 个元素) 找最大的 K 个 → 维护大小为 K 的小根堆,堆顶是"门槛",新元素比堆顶大就替换并下沉,遍历完堆里就是答案
优先队列(任务调度、Dijkstra 算法) 堆天然支持按优先级出队------O(log n) 插入,O(log n) 弹出最高(低)优先级元素
堆排序(Heap Sort) 先 O(n) 建堆,然后反复弹出堆顶放入有序区,每次弹出后下沉恢复堆序,总复杂度 O(n log n),原地排序
数据流中的中位数 用两个堆:一个大根堆存左半部分,一个小根堆存右半部分,保持二者大小平衡,堆顶就是中位数
合并 K 个有序链表 把 K 个链表的头节点放入小根堆,每次弹出最小值,然后将该节点的 next 入堆,重复直到所有链表合并完

时间复杂度

操作 时间复杂度 说明
插入(Push) O(log n) 尾部插入,向上调整(上浮)
删除堆顶(Pop) O(log n) 堆顶与尾部交换,向下调整(下沉)
获取堆顶(Top) O(1) 直接返回 arr[0]
建堆(Heapify) O(n) 从最后一个父节点开始下沉

空间复杂度:O(1) 的额外空间(除了存储数组本身)。

核心操作一:上浮(插入新元素)

触发场景:插入新元素时,新元素放在数组尾部。

核心逻辑:新元素放在数组末尾(叶子),不断与自己的父节点比较。如果比父节点"优秀"(小根堆就比父小),就交换位置,继续向上比较,直到到达根节点或不再优于父节点。

代码实现(小根堆)

c

ini 复制代码
void siftUp(int heap[], int pos) {
    int cur = pos;
    while (cur > 0) {
        int parent = (cur - 1) / 2;
        if (heap[cur] < heap[parent]) {
            int tmp = heap[cur];
            heap[cur] = heap[parent];
            heap[parent] = tmp;
            cur = parent;
        } else {
            break;
        }
    }
}

执行过程示例(小根堆,插入 1)

原堆:[5, 8, 9],插入 1 在尾部 → [5, 8, 9, 1]

  1. 1 和父节点 8 比较 → 交换 → [5, 1, 9, 8]
  2. 1 和父节点 5 比较 → 交换 → [1, 5, 9, 8](到达根,停止)

上浮的逻辑就像"新来的员工发现领导不如自己,一路往上爬"------直到爬到根节点或遇到更强的领导。

核心操作二:下沉(删除堆顶 / 建堆)

触发场景

  • 删除堆顶(弹出最大值/最小值)
  • 替换堆顶(TopK 问题中,新元素覆盖根)
  • 批量建堆(Heapify)

核心逻辑:从某个父节点开始,不断与左右孩子比较,选出"最优秀"的孩子(小根堆选最小的)。如果孩子比父节点优秀,就交换位置,指针下移继续比较,直到到达叶子节点或不再需要交换。

代码实现(小根堆)

c

ini 复制代码
void siftDown(int heap[], int size, int index) {
    int cur = index;
    while (1) {
        int left = cur * 2 + 1;
        int right = cur * 2 + 2;
        int smallest = cur;

        if (left < size && heap[left] < heap[smallest]) smallest = left;
        if (right < size && heap[right] < heap[smallest]) smallest = right;

        if (smallest == cur) break;

        int tmp = heap[cur];
        heap[cur] = heap[smallest];
        heap[smallest] = tmp;
        cur = smallest;
    }
}

下沉的逻辑就像"空降的领导发现下属比自己强,一路往下让贤"------直到找到比自己弱的下属或到达叶子节点。

建堆的两种方式:逐个插入 vs 原地堆化

方式一:逐个插入(动态建堆)

场景 :数据是一个一个来的(流式数据)。

算法 :每次在尾部插入,执行上浮。

时间复杂度:O(n log n)。

方式二:原地堆化(给定完整数组)------ 推荐

场景 :给定一个完整的乱序数组,要求在 O(n) 时间内原地变成堆。

算法 :从最后一个非叶子节点(n/2 - 1)开始,倒着往前遍历,对每个节点执行下沉

为什么建堆用下沉而不是上浮?

叶子节点(占一半)不需要动。只有上层父节点需要往下压,省去了处理大量叶子节点的开销,所以达到 O(n)。

c

arduino 复制代码
void buildHeap(int arr[], int n) {
    for (int i = n / 2 - 1; i >= 0; i--) {
        siftDown(arr, n, i);
    }
}

建堆示例

将乱序数组 [5, 3, 8, 4, 1, 7] 建成小根堆:

步骤 操作 数组状态
初始 - [5, 3, 8, 4, 1, 7]
1 下沉 i=2(值 8),孩子是 7,不动 [5, 3, 8, 4, 1, 7]
2 下沉 i=1(值 3),孩子 4 和 1,1 最小,交换 3 和 1 [5, 1, 8, 4, 3, 7]
3 下沉 i=0(值 5),孩子 1 和 8,1 最小,交换 5 和 1 [1, 5, 8, 4, 3, 7]
4 继续下沉 5,孩子 4 和 3,3 最小,交换 [1, 3, 8, 4, 5, 7]

完整代码:可运行的 C 语言小根堆

这是一个完整、可直接运行的通用小根堆,包含动态扩容和所有核心操作。

c

ini 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>

typedef struct {
    int *data;
    int size;
    int capacity;
} MinHeap;

MinHeap* createHeap(int capacity) {
    MinHeap* h = (MinHeap*)malloc(sizeof(MinHeap));
    h->data = (int*)malloc(sizeof(int) * capacity);
    h->size = 0;
    h->capacity = capacity;
    return h;
}

void swap(int *a, int *b) {
    int tmp = *a; *a = *b; *b = tmp;
}

void siftUp(MinHeap* h, int idx) {
    while (idx > 0) {
        int parent = (idx - 1) / 2;
        if (h->data[idx] < h->data[parent]) {
            swap(&h->data[idx], &h->data[parent]);
            idx = parent;
        } else break;
    }
}

void siftDown(MinHeap* h, int idx) {
    int cur = idx;
    while (1) {
        int left = cur * 2 + 1;
        int right = cur * 2 + 2;
        int smallest = cur;
        if (left < h->size && h->data[left] < h->data[smallest]) smallest = left;
        if (right < h->size && h->data[right] < h->data[smallest]) smallest = right;
        if (smallest == cur) break;
        swap(&h->data[cur], &h->data[smallest]);
        cur = smallest;
    }
}

void push(MinHeap* h, int val) {
    if (h->size >= h->capacity) {
        h->capacity *= 2;
        h->data = (int*)realloc(h->data, sizeof(int) * h->capacity);
    }
    h->data[h->size] = val;
    h->size++;
    siftUp(h, h->size - 1);
}

int pop(MinHeap* h) {
    if (h->size == 0) return -999;
    int top = h->data[0];
    h->data[0] = h->data[h->size - 1];
    h->size--;
    siftDown(h, 0);
    return top;
}

int top(MinHeap* h) {
    return h->data[0];
}

void freeHeap(MinHeap* h) {
    free(h->data);
    free(h);
}

int main() {
    MinHeap* heap = createHeap(4);
    push(heap, 5);
    push(heap, 3);
    push(heap, 8);
    push(heap, 1);
    push(heap, 4);
    
    printf("堆顶(最小值): %d\n", top(heap)); // 1
    printf("弹出: %d\n", pop(heap));          // 1
    printf("新的堆顶: %d\n", top(heap));      // 3
    
    freeHeap(heap);
    return 0;
}

实战经验:TopK 问题的堆解法与 C 语言避坑指南

在解决 LeetCode 347(前 K 个高频元素)时,总结出一个极其重要的经验:

问题 策略
找最大的 K 个 维护大小为 K 的小根堆。堆顶是"门槛"(当前最小的候选),新元素比堆顶大就替换根并下沉
找最小的 K 个 维护大小为 K 的大根堆。堆顶是"门槛"(当前最大的候选),新元素比堆顶小就替换根并下沉

C 语言避坑指南

在统计频次时,千万不要存结构体的值拷贝------因为值拷贝不会随后续更新而变。正确的做法是存储哈希表的索引(下标),需要用的时候通过索引去取值,保证数据永远新鲜。

c

ini 复制代码
// ❌ 错误:存值拷贝(数据会过期)
items[distinct++] = hash[idx]; 

// ✅ 正确:存索引(永远拿到最新值)
indices[distinct++] = idx;

这个坑的核心在于:C 语言中结构体赋值是值拷贝。如果后续哈希表中该索引对应的值被修改了,你之前存的副本不会同步更新。存索引则是"引用"原数据,永远拿到最新值。

一点总结

操作 一句话记忆
插入走家谱 上浮找爹,一路向上比较
删除走族谱 下沉找儿,一路向下比较
建堆用下沉 从半山腰开始往下滚,省时又省力(O(n))
TopK 用小根堆 找最大的 K 个,堆顶是门槛,新元素比门槛高就替换下沉

堆的本质是"用数组表达的完全二叉树"------它既有树的逻辑清晰,又有数组的访问速度。理解了父子下标公式,你就掌握了堆的底层密码。

互动讨论

  1. 为什么建堆用下沉(O(n))比用上浮(O(n log n))快? 你能从叶子节点的数量角度解释吗?
  2. 找最大的 K 个元素,为什么用小根堆而不是大根堆? 如果用小根堆,堆顶是什么含义?
  3. C 语言中结构体值拷贝的坑,在其他语言(如 Java、Python)中是否存在? 为什么?
  4. 堆排序的时间复杂度是 O(n log n),但建堆只有 O(n),那 O(n log n) 主要花在哪里?
  5. 如果数据流中不断有新的元素进来,如何用堆实时维护中位数? 需要几个堆?

📌 一点心得:堆用数组实现了树的逻辑,用 O(log n) 的代价换来 O(1) 的取最值。这种"用下标计算父子关系"的设计,是数据结构和算法中"以空间换时间"的经典体现。

相关推荐
留白_3 小时前
【决策树】泰坦尼克号生存预测
算法·决策树·机器学习
hqzing4 小时前
鸿蒙 PC 底层开发技术详解(七):二进制自签名算法的实现
算法·华为·harmonyos
AI科技星4 小时前
超复数全域经济周期场与信息谱场——金融与密码学底层理论重构《0·1·∞三元一体全域超复数统一场论》系列全集(六一字不漏完整合订终版)
人工智能·算法·金融·密码学·拓扑学·乖乖数学·全域数学
AI科技星4 小时前
《01无穷全域信息场论:算子G与宇宙本体高维完备公理大典》
人工智能·python·算法·金融·乖乖数学·全域数学
Tim_104 小时前
【C++】013、空类占多少字节?
算法
Ricky_Theseus4 小时前
并查集:连通性问题
算法
Tairitsu_H5 小时前
[LC优选算法#18] 前缀和 | 除⾃⾝以外数组的乘积 | 和为K的⼦数组 | 和可被K整除的⼦数组
算法·前缀和·哈希算法
凯瑟琳.奥古斯特5 小时前
力扣1008:前序重建BST
开发语言·c++·算法·leetcode·职场和发展
qq_383189626 小时前
07-运放运用电路-程控增益放大器
算法