数据结构-二叉树(三):堆复杂度证明与 Top-K 问题|错位相减推导 + 海量数据内存优化

写在前面

上一篇我们已经完成了二叉堆的完整实现,从完全二叉树的数组存储出发,学习了 HeapAdjustUpHeapAdjustDownHeapPushHeapPop,并进一步实现了建堆与堆排序。

数据结构‑二叉树(二):二叉堆从零实现|Heap结构设计 + 建堆优化 + 堆排序深度解析-CSDN博客

当时我们直接得到了两个很重要的结论: 向上调整建堆=O(N logN)向下调整建堆=O(N);

但这里其实很容易产生一个疑问:

HeapAdjustUpHeapAdjustDown 单次最坏不都是沿完全二叉树走一条路径,复杂度都是 O(log N) 吗?为什么拿来建堆以后,一个是 O(N logN),另一个却变成了 O(N)?

所以这一篇先不急着继续增加堆接口,而是把这两个复杂度真正推导一遍,使用等比数列和错位相减理解两种建堆方式为什么会产生差距。

证明完复杂度以后,再把堆放到一个更实际的问题里:

如果有 1 亿个整数,只需要找出最大的 10 个,而且内存非常有限,甚至无法一次性存放全部数据,该怎么办?

这也是堆相比"完整排序"更能体现价值的地方。

代码仓库

本篇全部测试代码已上传至 Gitee 仓库。项目复用之前实现的二叉堆模块,不改动原有 Heap.hHeap.c,新增独立测试文件 Heap_TopK_Test.c

文件中包含测试数据集生成函数 GenerateData,可以输出二进制文件模拟海量数据;TestTopK 实现流式读取,基于小根堆完成 Top‑K 查找,同时内置自动化结果校验,复现文中内存受限场景下的算法逻辑。

数据结构/Heap_TopK_Test · Luminous/Code_2026 - 码云 - 开源中国


一、先明确:单次调整为什么是 O(log N)

堆在逻辑上是一棵完全二叉树。

对于包含 (N) 个节点的完全二叉树,它的高度大约为:

向上调整的过程,是从当前节点沿父节点不断向根靠近:

复制代码
当前节点
   ↑
 父节点
   ↑
 父节点
   ↑
  根

向下调整则正好相反:

复制代码
  根
  ↓
孩子节点
  ↓
孩子节点
  ↓
叶子

最坏情况下,两种调整都只会经过一条根到叶子的路径,因此:HeapAdjustUp=O(log N) HeapAdjustDown=O(log N)

但这里需要区分:

单次调整的复杂度利用调整函数完成整个建堆过程的复杂度并不是一回事。

真正决定两种建堆方式差异的,是不同层节点的数量以及这些节点最多需要移动多少层。


二、向上调整建堆模拟不断插入元素

上一篇代码中保留了这种建堆方式:

cpp 复制代码
for (int i = 1; i < n; i++)
{
    HeapAdjustUp(a, i);
}

它的思想和连续执行 HeapPush 基本一致:

复制代码
前面的元素已经组成一个堆
          ↓
把下一个元素看成新插入节点
          ↓
       向上调整
          ↓
继续插入下一个元素

例如数组:

复制代码
12 45 7 89 23 56

先把 12 看成一个只有根节点的堆,再依次把 45、7、89、23、56 加进来,每加入一个元素,都对新节点执行一次 HeapAdjustUp

问题就在于:

越靠近底层的节点数量越多,而这些节点理论上能够向上移动的距离也越长。


三、向上建堆的复杂度证明

为了方便推导,假设是一棵高度为 h 的满二叉树,并把根所在深度记为 0。

节点总数为: N=2^{h-1}

各层节点数量和最多向上移动的距离如下:

深度 节点数量 最多向上调整
0 1 0
1 2 1
2 2^2 2
3 2^3 3
... ... ...
h-1 2^{h-1} h-1

最坏情况下,总调整次数可以写成:

即:

3.1 使用错位相减

原式:

两边乘 2:

用 (2S-S) 做错位相减:

括号中是等比数列:

代入:

化简:

因为:

所以:

同时:

代入后,最高阶项就是:

因此最终得到:


四、为什么向上建堆=O(N logN)比较"吃亏"

从树形结构来看会更加直观:

复制代码
                 ●
             /       \
           ●           ●
         /   \       /   \
        ●     ●     ●     ●
      /  \   / \   / \   / \
     ● ● ● ● ● ● ● ● ● ● ● ●

越往下:

  • 节点数量越多;
  • 这些节点距离根越远;
  • 最坏情况下需要执行的交换次数越多。

也就是说:

节点最多的位置,恰好也是向上调整成本最高的位置。

因此所有调整成本累积起来,最终达到 O(N logN)。

这也是为什么,当完整数组已经摆在面前时,一般不会优先选择(向上建堆=O(N logN)。


五、向下调整建堆从最后一个非叶子节点开始

上一篇实际使用的是:

cpp 复制代码
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
    HeapAdjustDown(a, n, i);
}

和(向上建堆=O(N logN))最大的不同是:

叶子节点根本不需要调整。

因为叶子没有孩子,无论它是多少,都天然满足"父子大小关系"。

所以只需要从:

复制代码
最后一个非叶子节点
          ↓
一直调整到根节点

即可。

例如:

复制代码
          12
       /      \
     45        7
    /  \      /
  89   23   56

最底层的:

复制代码
89 23 56

都是叶子,直接跳过。

真正需要处理的只有:

复制代码
7
45
12

六、向下建堆复杂度证明

仍然假设高度为 (h) 的满二叉树。

这一次从底部向上统计。

距离叶子的高度 节点数量 最多向下调整
1 2^{h-2} 1
2 2^{h-3} 2
3 2^{h-4} 3
... ... ...
h-1 1 h-1

因此最坏情况下:

写成:

6.1 再用一次错位相减

两边乘 2:

然后计算 (2S-S),得到:

其中:

因此:

化简:

因为:

所以:

忽略低阶项:


七、为什么向下建堆反而更快

观察节点分布:

复制代码
                 ●         ← 能向下很多层,但只有1个
             /       \
           ●           ●   ← 调整较多,但节点很少
         /   \       /   \
        ●     ●     ●     ● ← 调整很短
      /  \   / \   / \   / \
     ● ● ● ● ● ● ● ● ● ● ● ● ← 数量最多,但叶子不调整

向下建堆正好和向上建堆反过来了:

  • 数量最多的叶子节点调整次数为 0;
  • 靠近底部的非叶子节点数量很多,但通常只向下走 1 层;
  • 真正可能走很多层的节点靠近根部,而这些节点数量非常少。

因此所有节点的调整成本加起来只有 (O(N))。

所以完整数组建堆时:

优先使用向下调整。


八、两种建堆方式对比

对比 向上调整建堆 向下调整建堆
思想 模拟不断 Push 从最后非叶子节点开始
调整方向 子 → 父 父 → 子
单次调整 O(log N) O(log N)
整体建堆 O(N logN) O(N)
适用场景 元素逐个到达 已有完整数组

因此可以简单记成:

动态插入用向上调整,完整数组建堆用向下调整。

但比记结论更重要的是理解:

两者差距来自完全二叉树不同层节点数量与调整距离的分布。


九、问题升级:1 亿个数据只找最大的 10 个

前面堆排序解决的是:

把所有数据完整排序。

例如:

复制代码
HeapSort(arr, n);

如果排序完成后只取:

复制代码
arr[0] ~ arr[9]

当然可以得到前 10 大。

但如果数据量变成:

复制代码
100000000

也就是 1 亿个整数呢?

假设一个 int 占 4 字节,仅原始数组就需要:100000000*4=400000000Bytes

约为: 381MiB

如果可用内存只有 1MB,那么连这些数据都无法一次性读进内存。

这时候第一反应可能是:

把数据分组,每组排序,取每组最大的几个,再继续筛选。

这种方法能做,但仔细想一下:

最终只要 10 个最大值,有必要把每一组数据全部排好吗?

答案显然是否定的。

所以可以进一步优化为:

从始至终只维护 10 个候选元素。

这就是经典的 Top-K 问题。


十、找前 K 大为什么使用小根堆

假设:

复制代码
K = 10

先读取前 10 个数据,建立一个小根堆

这里很容易记反:

找最大的数据,为什么反而用小根堆?

因为小根堆的堆顶,是当前 10 个候选值中最小的一个

它刚好就是 Top-10 的准入门槛。

假设当前堆顶为:

复制代码
72

新读到:

复制代码
60

因为:

复制代码
60 <= 72

说明 60 连当前第 10 大都比不过,直接丢弃。

如果新读到:

复制代码
100

因为:

复制代码
100 > 72

它就应该进入 Top-10。

直接:

cpp 复制代码
topK[0] = 100;
HeapAdjustDown(topK, 10, 0);

替换掉当前最小候选,再执行一次向下调整恢复小根堆。

因此整个过程只有:

复制代码
读取前K个
   ↓
建立小根堆
   ↓
后续每个数据和堆顶比较
   ↓
更大 → 替换堆顶 → 向下调整
   ↓
否则直接忽略

十一、Top-K复杂度

前 (K) 个元素建堆: O(K)

后面的 (N-K) 个元素,每个至少比较一次。

如果进入 Top-K,则执行一次 O(log K) 向下调整。

最坏情况下: O(N\log K)

空间只需要: O(K)

当:

复制代码
K = 10

是固定常数时,log K也可以看作常数,因此整个过程基本就是 O(N) 的一次扫描。

如果是前 10 大:

复制代码
前10大 → 小根堆

如果是前 10 小:

复制代码
前10小 → 大根堆

本质都是让:

堆顶成为当前候选集合中最应该被淘汰的元素。


十二、简单测试:随机生成 10 万个数据

理论理解后,用 10 万个数据简单验证即可,没有必要真的生成 1 亿个数据。

测试分成两个函数:

cpp 复制代码
GenerateData();
TestTopK();

生成和测试分别运行,在 main 中注释掉其中一个即可。

为了让每次生成的数据不同,随机数生成前加入:

cpp 复制代码
srand((unsigned int)time(NULL));

使用当前时间作为随机种子。

普通数据写成:

cpp 复制代码
rand() % 1000000 + i

这里加上循环变量 i 只是一个很常见的小处理,让不同位置的数据再多一点变化,一定程度上减少重复值即可,并不保证所有数据完全不同,也没有必要保证。

普通数据最大约为: 999999+99999<1100000

然后在数据中随机放入 10 个明显更大的值:

复制代码
10000000
20000000
...
100000000

这样最终 Top-10 的正确答案就是确定的。


十三、生成测试数据

cpp 复制代码
#include "Heap.h"
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#define DATA_COUNT 100000
#define TOP_K 10
#define RANDOM_RANGE 1000000
#define SPECIAL_STEP 10000000

void GenerateData(void)
{
    FILE* pf = fopen("data.bin", "wb");
    if (pf == NULL)
    {
        perror("fopen fail");
        return;
    }

    srand((unsigned int)time(NULL));

    int specialPos[TOP_K];

    /*
    10万数据分成10段,
    每段随机选一个位置存特殊值。
    这样位置随机,同时天然不会重复。
    */
    for (int i = 0; i < TOP_K; i++)
    {
        specialPos[i] =
            i * (DATA_COUNT / TOP_K)
            + rand() % (DATA_COUNT / TOP_K);
    }

    for (int i = 0; i < DATA_COUNT; i++)
    {
        /*
        加上i只是让不同位置多一点变化,
        减少重复概率,不保证完全无重复。
        */
        int value = rand() % RANDOM_RANGE + i;

        for (int j = 0; j < TOP_K; j++)
        {
            if (i == specialPos[j])
            {
                value = (j + 1) * SPECIAL_STEP;
                break;
            }
        }

        fwrite(&value, sizeof(int), 1, pf);
    }

    fclose(pf);

    printf("已生成%d个测试数据\n", DATA_COUNT);
}

这里的特殊值最小为:

复制代码
10000000

而普通值最大不到:

复制代码
1100000

所以可以确定这 10 个特殊值就是全局最大的 10 个。


十四、流式读取并测试 Top-10

测试阶段不把 10 万个整数重新装进数组,而是直接从文件逐个读取。

这也更符合前面讨论的:

数据量大时不一次性全部加载到内存。

cpp 复制代码
int CompareDesc(const void* p1, const void* p2)
{
    int x = *(const int*)p1;
    int y = *(const int*)p2;

    if (x < y)
        return 1;
    else if (x > y)
        return -1;

    return 0;
}

void TestTopK(void)
{
    FILE* pf = fopen("data.bin", "rb");
    if (pf == NULL)
    {
        perror("fopen fail");
        return;
    }

    HPDataType topK[TOP_K];

    // 先读前10个作为初始候选
    if (fread(topK,
              sizeof(HPDataType),
              TOP_K,
              pf) != TOP_K)
    {
        printf("测试数据不足\n");
        fclose(pf);
        return;
    }

    // 前10个数据建立小根堆
    for (int i = (TOP_K - 2) / 2; i >= 0; i--)
    {
        HeapAdjustDown(topK, TOP_K, i);
    }

    HPDataType x;

    // 后续数据逐个读取
    while (fread(&x, sizeof(HPDataType), 1, pf) == 1)
    {
        if (x > topK[0])
        {
            topK[0] = x;

            HeapAdjustDown(topK, TOP_K, 0);
        }
    }

    fclose(pf);

    /*
    此时topK中已经保存正确的前10大,
    但它只是一个小根堆,并没有整体有序。

    为了方便查看,只对这10个结果排序。
    */
    qsort(
        topK,
        TOP_K,
        sizeof(HPDataType),
        CompareDesc
    );

    printf("Top-%d:\n", TOP_K);

    for (int i = 0; i < TOP_K; i++)
    {
        printf("%d ", topK[i]);
    }

    printf("\n");

    // 简单验证结果
    int pass = 1;

    for (int i = 0; i < TOP_K; i++)
    {
        int expected =
            (TOP_K - i) * SPECIAL_STEP;

        if (topK[i] != expected)
        {
            pass = 0;
            break;
        }
    }

    if (pass)
        printf("TEST PASS\n");
    else
        printf("TEST FAIL\n");
}

十五、main 中生成与测试分别运行

第一次运行,先生成数据:

cpp 复制代码
int main(void)
{
    GenerateData();

    //TestTopK();

    return 0;
}

生成:

复制代码
data.bin

之后改为:

cpp 复制代码
int main(void)
{
    //GenerateData();

    TestTopK();

    return 0;
}

进行测试。

正确结果应该是:

复制代码
100000000
90000000
80000000
70000000
60000000
50000000
40000000
30000000
20000000
10000000

最后输出:

复制代码
TEST PASS

就说明这 10 个事先放入随机位置的大数都被正确找了出来。


十六、完整排序和 Top-K 的区别

假设有:N=100000000个数据,只需要:K=10个最大值。

完整排序解决的是:

所有数据之间的顺序关系。

时间复杂度: O(N logN)

而 Top-K 只关心:

哪些元素属于最大的 K 个。

因此: O(N logK)

空间只需要:O(K)

时,差距会非常明显。

尤其数据来自:

  • 文件;
  • 网络;
  • 日志;
  • 数据流;

这些场景时,可以边读边处理,完全没有必要把所有数据同时放进内存。


写在最后

上一篇主要解决的是:

堆怎么写、向上调整和向下调整怎么实现。

这一篇则继续追问:

为什么两种建堆方式复杂度会不同?

通过节点数量、调整距离以及错位相减可以看到:向上建堆=O(N logN), 而:向下建堆=O(N);

两者虽然都使用单次 O(log N) 的调整函数,但不同层节点数量和实际调整距离不同,最终导致了完全不同的整体复杂度。

再往后看 Top-K,会更明显地发现:

堆真正有价值的地方,并不是一定要把所有数据排序。

如果 1 亿个数据只需要最大的 10 个,那么剩下 99999990 个数据之间谁大谁小,其实根本不重要。

这时候只维护一个 10 个元素的小根堆,就可以把空间从 O(N) 降到 O(K),同时避免完整排序的大量无效工作。

从完整排序到 Top-K,也是对数据结构作用的一次更直观理解:优化不只是让原来的步骤执行得更快,有时候更重要的是先判断哪些步骤根本没有必要执行。

相关推荐
GlueNa2SiO31 小时前
04-Flask数据库操作SQLAlchemy
笔记·python·flask
浔溺2 小时前
al+大数据每日学习笔记28
笔记·学习
FakeOccupational4 小时前
【github 有趣项目】OpenPLC: 支持通用硬件的开源 PLC 软件平台‌
笔记
1000世界小札7 小时前
《大话数据结构》第9章精读:归并排序与快速排序完整 C++ 实现
数据结构·c++·算法
xian_wwq10 小时前
【学习笔记】-深度认知系列-第2讲-大模型到底是什么?——拆解“参数、训练、推理”
笔记·学习·深度认知
Oll Correct12 小时前
Adobe illustrator 案例九:百宝箱图标绘制
笔记·ui·adobe·illustrator
M78佐菲13 小时前
Linux学习笔记:进程
linux·笔记·学习·算法
乐橙开放平台16 小时前
养老 SaaS 笔记:setMessageCallback 事件桥接 + msgType 映射 P0/P1,先 ACK 再分流值班流
笔记·物联网·音视频·智能家居
晓梦林17 小时前
Tools2靶场学习笔记
笔记·学习