当前位置: 首页 > news >正文

数据结构:堆(Heap)

目录

从根源问题出发

设计新的结构 - 逻辑形态

优化结构 - 物理形态

堆的存储 - 从“树”到“数组”

逐步实现代码(C/C++)


从根源问题出发

我们先忘掉“堆”这个名字,思考一个非常常见的需求:

“我有一堆动态的数据,我需要频繁地、快速地找到这堆数据里的最大值(或者最小值)。”

为了解决这个问题,我们回顾一下已经学过的数据结构,看看它们表现如何:

用普通数组(无序)

  • 优点:插入新数据很快,直接在末尾添加就行,时间复杂度是 O(1)。

  • 缺点:找最大值太慢。你必须从头到尾遍历一遍,时间复杂度是 O(n)。如果数据量很大,每次都遍历是不可接受的。

用有序数组

  • 优点:找最大值超快。它就在数组的头部或尾部,时间复杂度是 O(1)。

  • 缺点:插入新数据太慢。为了维持数组的有序性,你得先找到新元素该放的位置,然后把后面的元素都移动一遍。平均时间复杂度是 O(n)。

数据结构查找最大值插入新元素
无序数组O(n) (慢)O(1) (快)
有序数组O(1) (快)O(n) (慢)

你看,这两种基本结构都有明显的短板,它们没法同时“比较快”地完成查找最大值插入元素这两个操作。我们需要一种新的、更平衡的数据结构。这个需求,就是“堆”诞生的根本原因。


设计新的结构 - 逻辑形态

既然数组这种线性结构不行,我们自然会想到你刚学过的“树”结构。树的结构比数组复杂,也许能提供更优的解决方案。

我们来尝试设计一棵“树”,让它天生就适合找最大值。一个最直观的想法是什么?

“让最大值一眼就能看到,比如,就放在树根(root)的位置!”

这个想法太棒了!如果根节点永远是最大值,那我们找最大值不就永远是 O(1) 了吗?

好,顺着这个思路继续推导:

  • 如果根节点是整个树的最大值,那么对于任何一棵子树来说,这棵子树的根节点也应该是这棵子-树里的最大值。

  • 换句话说,对于树中的任意一个节点,它的值都必须大于或等于它所有孩子节点的值。

我们就得到了这个结构的第一条核心属性,我们称之为 “堆序属性” (Heap Property)

父节点的值 > 子节点的值

(注意:这里没有规定左、右孩子谁大谁小,只规定了父子关系)

这种父节点总是最大的,我们称之为“大顶堆”(Max Heap)。

反之,如果规定父节点总是最小的(父节点 < 子节点),那就是“小顶堆”(Min Heap)。我们下面都以大顶堆为例。

下面这棵树就满足我们刚刚定义的“堆序属性”:

      100/   \70    80/ \   /30 40 60

但是,下面这棵树,虽然根是最大值,但它不满足堆序属性,因为它局部不满足(80的子树里,根不是80,而是90)。

      100/   \70    80/ \     \30 40     90   <-- 这里的 90 比它的父节点 80 大,破坏了规则

优化结构 - 物理形态

现在我们有了“堆序属性”,但这还不够。回忆一下,树的查询、插入等操作的效率,通常和什么有关?—— 树的高度

如果一棵树长得“歪七扭八”,像下面这样,虽然它也满足我们定义的“堆序属性”:

      100/70/60/
30

这棵树几乎退化成了一个链表,它的高度是 O(n)。如果我们基于这样的结构去进行插入和删除操作,效率肯定不会高。

怎样让树的高度尽可能低?—— 让它尽可能地“胖”起来,也就是让节点尽可能地铺满每一层。

你已经学过了,这种“尽可能铺满”的树,有一个专门的名字,叫 “完全二叉树” (Complete Binary Tree)

它的定义是:一棵二叉树,除了最后一层外,其他各层节点数都达到最大,并且最后一层的节点都连续集中在最左边。

所以,为了保证效率,我们给堆加上第二条核心属性,我们称之为 “结构属性” (Shape Property)

堆必须是一棵完全二叉树。

现在,把我们从第一性原理推导出的两条属性结合起来,就得到了“堆”的严格定义:

堆(Heap)是一个完全二叉树,并且满足堆序属性(父节点的值总是不小于其子节点的值)。

这就是堆的本质。它巧妙地结合了两种特性:

  1. 值的顺序性 (堆序属性):保证了根节点一定是最大值。

  2. 结构的平衡性 (完全二叉树):保证了树的高度是 O(logn),为日后高效的插入、删除操作打下了基础。


堆的存储 - 从“树”到“数组”

现在我们逻辑上定义了堆是一棵“完全二叉树”,但在C/C++代码里怎么表示它呢?

用常规的指针方式定义树节点当然可以:

struct TreeNode {int data;TreeNode* left;TreeNode* right;
};

但对于“完全二叉树”这种结构极其规整的树来说,用指针太浪费了!因为它的节点排列没有任何“空隙”,我们可以非常巧妙地用一个数组来存储它,而且只用数组就够了,不需要任何指针

怎么做呢?我们把树的节点按层序遍历(从上到下,从左到右)的顺序,依次放入数组中。

神奇的地方来了:在这种存储方式下,任意一个节点和它的父、子节点在数组中的下标(index)都有着固定的数学关系。

为了公式更简洁,我们先假设数组从下标 1 开始存储。对于数组中下标为 i 的节点:

  • 它的父节点下标是: floor(i / 2) (i除以2向下取整)

  • 它的左孩子下标是: 2 * i

  • 它的右孩子下标是: 2 * i + 1

验证一下:

  • 节点 80 (下标为3):

    • 父节点是 floor(3/2) = 1,对应的值是 100。正确。

  • 节点 70 (下标为2):

    • 左孩子是 2 * 2 = 4,对应的值是 30。正确。

    • 右孩子是 2 * 2 + 1 = 5,对应的值是 40。正确。

这个性质至关重要!它意味着我们可以抛弃复杂的指针操作,只用简单的下标运算就能在树的结构里上下游走。

这不仅代码简单,而且由于数组在内存中是连续存储的,缓存命中率更高,实际运行效率也常常优于指针实现。


逐步实现代码(C/C++)

根据上面的推导,我们现在可以定义出堆的基本结构。暂时不包含任何操作,只定义它的“骨架”。

1. 定义堆的结构体

一个堆需要什么?

  • 一个指向数据的指针(数组的首地址)。

  • 记录当前已经存了多少个元素。

  • 记录这个数组总共能存多少个元素(容量)。

// 为了方便,我们用 C++ 的语法来写,但其思想和 C 语言是完全一致的。
// 你可以把 new/delete 看作是 malloc/free。// 堆的结构定义
struct Heap {int* data;      // 用一个整型指针指向我们的数组,数组里存放堆的数据int size;       // 记录当前堆中有多少个元素int capacity;   // 记录堆的最大容量(数组有多大)
};

这就是堆在内存中的样子,一个非常简单的结构体。

2. 创建一个堆

我们需要一个函数来初始化这个结构体。

#include <iostream> // 只是为了后面可能的输出// 创建并初始化一个堆
Heap* createHeap(int capacity) {// 1. 为 Heap 结构体本身分配内存Heap* h = new Heap;if (!h) {// 内存分配失败return nullptr;}// 2. 为存储数据的数组分配内存// 注意:我们故意多分配一个空间,让数组从下标 1 开始使用,这样公式更简单。// 但在C/C++中,数组通常从0开始,我们后面会统一到0-based。// 这里为了讲解方便,先用 1-based 的思路。// 不过,为了让你平滑过渡到标准实现,我们还是用 0-based 来写代码。h->data = new int[capacity];if (!h->data) {// 内存分配失败delete h;return nullptr;}// 3. 初始化其他成员h->size = 0; // 一开始堆是空的h->capacity = capacity;return h;
}

3. 实现下标计算的辅助函数

现在,我们把前面推导的下标关系用代码实现。在C/C++中,数组下标都是从 0 开始的 (0-based)。公式需要做一点点调整,但原理完全一样。

对于数组中下标为 i 的节点:

  • 它的父节点下标是: (i - 1) / 2 (整数除法自动向下取整)

  • 它的左孩子下标是: 2 * i + 1

  • 它的右孩子下标是: 2 * i + 2

我们把它们写成几个小函数,这样代码会非常清晰:

// 辅助函数,根据一个节点的下标,返回其父节点的下标
// (这是 0-based 数组的公式)
int parent(int i) {return (i - 1) / 2;
}// 返回左孩子的下标
int leftChild(int i) {return 2 * i + 1;
}// 返回右孩子的下标
int rightChild(int i) {return 2 * i + 2;
}

到这里,我们已经完成了对“堆”这个数据结构的静态定义和基础准备。我们从一个实际问题出发,推导出了堆的两个核心属性,并最终选择用数组这种高效的方式来存储它,还准备好了在数组中模拟树形关系的基础代码。

你现在应该对“堆是什么”以及“为什么是这样设计的”有了清晰的认识。下一步,我们就可以在今天搭建的这个骨架之上,去学习如何进行插入、删除等动态操作了。

http://www.dtcms.com/a/353738.html

相关文章:

  • 生成式AI的引擎室:深入剖析LLM内存管理与调度
  • 【解锁Photonics for AI:系统学习光学神经网络与超表面设计,成就下一代光芯片工程师】
  • python - js的引入方式、注释变量、数据类型、强制转换、自动类型转换、js运算符、分支结构、函数
  • Nginx单端口代理多个前后端服务的完整配置指南
  • 【雅思019】Canceling an appointment
  • 数据结构——算法设计的基本思想(穷举、递归、分治等)
  • 【自用】JavaSE--junit单元测试、反射、注解、动态代理
  • FreeRTOS 常见面试题与核心知识点详解
  • Redis数据持久化——RDB快照和Aof日志追加
  • 8.28 模拟
  • 从易用性的角度来看,哪个ETL平台比较好用?
  • MySQL-数据类型
  • Clerk 用户认证系统集成文档
  • 关于virtual camera
  • UE5 PCG 笔记(三) Height To Density 节点
  • UE5 查找组件
  • UE5多人MOBA+GAS 55、基于 Python 协调器与 EOS 的会话编排
  • 嵌入式Linux自学不走弯路!670+讲课程!系统学习路线:入门+应用+ARM+驱动+移植+项目 (STM32MP157开发板)
  • 快速入门PowerDesigner-Database
  • 软件开发整体介绍和Swagger介绍和使用步骤
  • Dubbo加标签方式
  • Ubuntu 22.04 插入光驱后磁盘满启动故障clean, ...files, ...blocks
  • Proxmox VE 中启用 CentOS 虚拟机的串口终端(xterm.js 控制台)
  • MAX系列FPGA型号对比及低功耗特性分析
  • 服务器类型与TCP并发服务器构建(SELECT)
  • 冬天的思念
  • 数模笔记day01(数据预处理、K-means聚类、遗传算法、概率密度分布)
  • SqlHelper类库的使用方法
  • 关于DTO、DO、BO、VO
  • Linux系统性能优化全攻略:从CPU到网络的全方位监控与诊断