树是一种非线性的数据结构,它由
个有限节点组成一个具有层次关系的集合
如图,树是一棵倒挂的树,根朝上,叶朝下

注意:树形结构中,子树之间不能有交集,换句话说,不能成环,否则不是树形结构



棵不相交的树构成的集合
一棵二叉树是节点的一个有限集合,该集合满足两种情况(或的关系):
如图:

从上图可以看出:
注意:任意的二叉树都是由以下情况复合而成的:

,节点总数是
,这棵树为满二叉树


堆(heap)是一种满足特定条件的完全二叉树,主要分为两种类型:
如图:

我们将二叉树的根节点称为“堆顶”,将底层最靠右的节点称为“堆底”
i,左子节点索引为2*i+1,右子节点索引为2*i+2j,则父节点索引为(j-1)/2(整数除法,会自动取整)

如何将逻辑结构抽象成物理结构(逐层放数据),如何将物理结构转换成逻辑结构(想象细胞分裂),如图:

堆的所有接口(插入、删除堆顶)都依赖向上调整和向下调整,这两个操作能保证堆的结构不被破坏,始终满足两种堆类型的严格规则
场景: 插入元素时,先把元素放到数组末尾(完全二叉树的最有一个叶节点),再通过向上调整把该元素移到正确位置,保证堆的性质。
步骤(以大根堆为例):
child,父节点的索引为(child-1)/2_arr[child]和_arr[parent]:若子>父,交换二者child = parent和parent = (child - 1) / 2,重复步骤2,直到child == 0(到根节点)或子 <= 父**例子:**往堆[10,8,9,5,3,7]插入11
[10,8,9,5,3,7,11]child = 6 , parent = (child - 1) / 2 == 5 / 2 == 2(值9)
child = 2, parent = (2 - 1) / 2 = 0(值10)
child == 0,调整结束
图示:

代码实现:
// 向上调整算法
void AdjustUp(size_t child_idx) {
// 循环判断 维持堆的结构
while (child_idx > 0) {
// 找父节点索引
size_t parent_idx = (child_idx - 1) / 2;
if (_comp(_arr[parent_idx], _arr[child_idx])) {
std::swap(_arr[parent_idx], _arr[child_idx]);
child_idx = parent_idx;
}
else {
break;
}
}
}场景: 删除堆顶元素(0)时,先把堆顶和最后一个元素(_size - 1)交换,再删除最后一个元素(--_size),最后通过向下调整把新堆顶移到正确位置。
步骤(以大根堆为例):
parent,左子节点索引left = 2*parent + 1 ,右子节点索引right = 2*parent + 2parent、left、right中最大的值,记为max_idx(注意:若子节点索引>=_size,说明没有这个子节点)max_idx != parent(最大值不是父节点),交换_arr[parent]和_arr[max_idx]parent = max_idx,重复步骤2-3,直到left >= _size(无左子节点,到了叶节点)例子: 删除堆顶11[11,8,10,5,3,7,9]:
[9,8,10,5,3,7,11],--_size后变为[9,8,10,5,3,7],parent = 0left = 1(值8),right = 2(值10),max_idx = 2,交换:数组变为[10,8,9,5,3,7],parent = 2left = 5 (值7),right = 6(超出 _size = 6),max_idx = 5,调整结束图示:

代码实现:
// 向下调整算法
void AdjustDown(size_t parent) {
// 假设左孩子是最大的值 先找到左孩子索引
size_t max_idx = 2 * parent + 1;
// 循环判断 维持堆的正确结构
while (max_idx < _size) {
// 验证假设是否成立
if (max_idx + 1 < _size && _comp(_arr[max_idx], _arr[max_idx + 1])) {
++max_idx;// 更新索引 右孩子才是更大的值
}
// 现在已经找出两个孩子中最大的一个了 和父节点比较
if (_comp(_arr[parent], _arr[max_idx])) {
// 交换二者位置
std::swap(_arr[max_idx], _arr[parent]);
// 更新索引
parent = max_idx; // 父节点索引下移到孩子节点
max_idx = parent * 2 + 1; // 还是先假设左孩子的值更大
}
else {
break;
}
}
}对于逻辑结构图和物理结构图,我们很容易找到两个孩子中值大的那个,但是计算机不知道怎么寻找,所以我们先假设左孩子的值更大,然后验证,选择出两个孩子中值大的那个
在某些情况下,我们希望使用一个列表的所有元素来构建一个堆,这个过程被称为“建堆操作”
我们首先创建一个空堆,然后遍历列表,依次对每个元素执行“入堆操作”,即先将元素添加至堆的尾部,再对该元素执行“从底至顶”堆化。每当一个元素入堆,堆的长度就加一。由于节点是从顶到底依次被添加进二叉树的,因此堆的生长顺序是“自上而下”构建的。设元素数量为
,每个元素的入堆操作使用
时间,因此该建堆方法的时间复杂度为
每当调整一个节点后,以该节点为根节点的子树就形成一个合法的子堆。而由于是倒序遍历,所以堆的生长顺序是“自下而上”构建的
选择倒序遍历,是能够保证当前节点之下的子树已经是合法的子堆,这样堆化前节点才是有效的
由于叶子节点没有子节点,因此天然就是合法的子堆,无需堆化,最后一个非叶子节点是最后一个节点的父节点,我们从它开始倒序遍历执行堆化:
将无序数组 [3, 1, 4, 1, 5, 9, 2, 6] 转化为大根堆:

代码实现:
// 构造函数:通过数组初始化堆(核心建堆操作)
max_heap(const std::vector<T>& arr) {
_arr = arr;
_size = arr.size();
if (_size <= 1) return; // 空堆或单个元素无需调整
// 从最后一个非叶子节点开始,向前依次调整每个节点
// 最后一个非叶子节点 = (最后一个节点索引 - 1) / 2
for (int i = (_size - 2) / 2; i >= 0; --i) {
AdjustDown(i); // 对每个非叶子节点执行向下调整
}
}有着前面数组链表的经验,我们很容易实现一个大顶堆,包含插入、删除堆顶、取堆顶、建堆等核心接口,并且支持扩容
#include <iostream>
#include <cassert>
#include <vector>
#include <functional>
namespace Vect {
template <class T,class Compare = std::less<T>>
class heap {
private:
std::vector<T> _arr; // 底层是动态数组
size_t _size; // 有效数据个数
size_t _capacity; // 容量
Compare _comp; // 决定大顶堆还是小顶堆
// 向上调整算法
void AdjustUp(size_t child_idx) {
// 循环判断 维持堆的结构
while (child_idx > 0) {
// 找父节点索引
size_t parent_idx = (child_idx - 1) / 2;
if (_comp(_arr[parent_idx], _arr[child_idx])) {
std::swap(_arr[parent_idx], _arr[child_idx]);
child_idx = parent_idx;
}
else {
break;
}
}
}
// 向下调整算法
void AdjustDown(size_t parent) {
// 假设左孩子是最大的值 先找到左孩子索引
size_t max_idx = 2 * parent + 1;
// 循环判断 维持堆的正确结构
while (max_idx < _size) {
// 验证假设是否成立
if (max_idx + 1 < _size && _comp(_arr[max_idx], _arr[max_idx + 1])) {
++max_idx;// 更新索引 右孩子才是更大的值
}
// 现在已经找出两个孩子中最大的一个了 和父节点比较
if (_comp(_arr[parent], _arr[max_idx])) {
// 交换二者位置
std::swap(_arr[max_idx], _arr[parent]);
// 更新索引
parent = max_idx; // 父节点索引下移到孩子节点
max_idx = parent * 2 + 1; // 还是先假设左孩子的值更大
}
else {
break;
}
}
}
// 扩容
void resize() {
if (_size >= _capacity) {
size_t new_cap = _capacity == 0 ? 4 : 2 * _capacity;
_arr.resize(new_cap); // 直接调用vector的扩容接口 不用自己手动实现
_capacity = new_cap;
}
}
public:
// 默认构造
heap():_size(0),_capacity(0){}
// 构造函数:通过数组初始化堆(核心建堆操作)
heap(const std::vector<T>& arr) {
_arr = arr;
_size = arr.size();
if (_size <= 1) return; // 空堆或单个元素无需调整
// 从最后一个非叶子节点开始,向前依次调整每个节点
// 最后一个非叶子节点 = (最后一个节点索引 - 1) / 2
for (int i = (_size - 2) / 2; i >= 0; --i) {
AdjustDown(i); // 对每个非叶子节点执行向下调整
}
}
// 拷贝构造 vector自带深拷贝
heap(const heap& other)
:_size(other._size)
,_capacity(other._capacity)
,_arr(other._arr)
{ }
// 析构 vector自带
~heap(){ }
/*==================核心接口=========================*/
// 插入元素(向上调整)
void push(const T& val) {
// 先扩容
resize();
_arr[_size] = val;
// 向上调整新元素到正确位置
AdjustUp(_size);
++_size;
}
// 删除堆顶元素(向下调整)
void pop() {
// 确保不为空才能pop
assert(!empty());
// 交换堆顶和堆底元素
std::swap(_arr[0], _arr[_size - 1]);
// 删除堆底元素
--_size;
// 向下调整
AdjustDown(0);
}
// 获取堆顶元素
const T& top()const { assert(!empty()); return _arr[0]; }
// 判空
const bool empty() const { return _size == 0; }
// 获取元素个数
const size_t size() const { return _size; }
// 清空堆
void clear() { _size = 0; }
// 打印堆
void PrintHeap() {
if (empty()) {
std::cout << "堆为空" << std::endl;
return;
}
for (size_t i = 0; i < _size; i++)
{
std::cout << _arr[i] << " " ;
}
std::cout << "\n";
}
};
/*=================== 测试代码 =========================*/
void TestAPI() {
std::cout << "===== 测试大根堆(int类型) =====" << std::endl;
// 测试默认构造 + push + top
heap<int> heap1;
heap1.push(5);
heap1.push(3);
heap1.push(8);
heap1.push(10);
heap1.push(7);
heap1.PrintHeap(); // 输出:堆的元素(数组存储):10 8 5 3 7 (堆顶:10)
// 测试pop(删除堆顶)
heap1.pop();
heap1.PrintHeap(); // 输出:堆的元素(数组存储):8 7 5 3 (堆顶:8)
// 测试string类型堆
std::cout << "\n===== 测试string类型堆 =====" << std::endl;
heap<std::string> heap3;
heap3.push("apple");
heap3.push("banana");
heap3.push("cherry");
heap3.push("date");
heap3.PrintHeap(); // 输出:堆的元素(数组存储):date cherry banana apple (堆顶:date)
heap3.pop();
std::cout << "Pop后堆顶:" << heap3.top() << std::endl; // 输出:cherry
}
}堆是完全二叉树,设元素个数为
,堆的高度是
(从根到叶子的层数,根为第一层)满足:
而时间复杂度我们考虑的是最坏的情况,即是满二叉树的情况,我们要将堆底元素移到堆顶的情况,此时
,两边同时取对数得到:

向上调整的次数等于新元素从叶子节点到最终位置的路径长度,最长为堆的高度
,并且满足:
;
次调整,因此时间复杂度为
向下调整的次数等于新堆顶从根节点到最终位置的路径长度,最长为堆的高度
,并且满足:
;
次调整,因此时间复杂度为
。
,叶节点的数量为
,需要堆化的节点数量为

节点从堆顶到堆底的最大迭代次数等于该节点到叶节点的距离,这个距离正是节点高度,所以我们可以对各层的
求和,得到所有节点的堆化迭代次数总和
将
乘以2,得到:
下式减去上式得到:
利用等比数列求和公式可以得到:
高度为h的满二叉树节点数量 n = 2 h − 1 n = 2^h - 1 n=2h−1,所以时间复杂度为: O(2^h)=O(n)
TopK 问题是指从海量数据中高效找出前 K 个最大(或最小)的元素。其核心挑战是在数据量大(甚至无法全部加载到内存) 或 效率要求高的场景下,避免全量排序的高成本,用更优的时间和空间复杂度解决问题。
TopK 问题的关键是 “筛选” 而非 “全量排序”。全量排序(如快排)的时间复杂度是
,但当n极大(如 10 亿)时,不仅耗时,还可能因内存不足无法执行。而堆的特性(高效维护最值)能完美适配这一场景,核心原理如下:
为什么用小顶堆?
小顶堆的堆顶是最小值,意味着:
,远低于全量排序的
逻辑与找前 K 大对称:
数据:[5,3,8,10,7,1,9,2,6,4],K=3:
[5,3,8],构建小顶堆。建堆后堆顶为3,堆结构:[3,5,8][10,7,1,9,2,6,4][5,10,8](堆顶 5);[7,10,8](堆顶 7);[8,10,9](堆顶 8);[8,10,9]即为前 3 大元素(排序后为[7,8,10],注意堆的存储顺序不直接是排序结果,需额外处理)。TopK 问题在工程中应用广泛,核心场景是 “海量数据 + 筛选最值”:
这些场景的共性:数据量大(n极大)、K 较小(通常远小于n),需要高效(时间
)且低内存(空间
)的解决方案。
// TopK问题
// 找前K大 小顶堆维护
static std::vector<int> TopK_max(const std::vector<int>& data, size_t K) {
// 边界处理 K=0或者K>=数据量直接返回该数组
if (K == 0 || K >= data.size()) {
return data;
}
// 小顶堆(greater 父 < 子)
heap<T, std::greater<T>> minHeap;
// 遍历数组
for (size_t i = 0; i < data.size(); i++)
{
if (minHeap.size() < K) {
// 堆中不足K个元素 直接放进去
minHeap.push(data[i]);
}
else if (data[i] > minHeap.top()) {
// 当前元素比堆顶大
// 说明堆顶的最小值不够资格进前K 要淘汰
minHeap.pop(); // 淘汰堆顶
minHeap.push(data[i]); // 插入新元素
}
// 否则 data[i] <= 堆顶 直接丢弃 连第K大都排不上
}
// 此时堆中正好保存了无序的前K大元素
std::vector<T> ret_nums;
while (!minHeap.empty()) {
ret_nums.push_back(minHeap.top());
minHeap.pop();
}
return ret_nums;
}
// 找前K小 用大顶堆维护
static std::vector<T> TopK_min(const std::vector<T>& data, size_t K) {
if (K == 0 || K >= data.size()) return data;
// 大顶堆(less 父 > 子)
heap<T, std::less<T>> maxHeap;
// 遍历数组
for (size_t i = 0; i < data.size(); i++)
{
if(maxHeap.size() < K){
// 堆中不足K个元素 直接放
maxHeap.push(data[i]);
}
else if (data[i] < maxHeap.top()) {
// 如果当前元素比堆顶还小
// 说明堆顶最大值不够资格进前K小 淘汰
maxHeap.pop(); // 淘汰堆顶
maxHeap.push(data[i]); // 插入新元素
}
// 否则 如果data[i] >= 堆顶 直接丢弃 连第K小都排不上
}
// 此时堆中正好保存了无序的前K小元素
std::vector<T> ret_nums;
while (!maxHeap.empty()) {
ret_nums.push_back(maxHeap.top());
maxHeap.pop();
}
return ret_nums;
}测试代码:
void TestTopK() {
std::vector<int> nums = { 5, 3, 8, 10, 7, 2, 9 ,100,2058,60,102,5461};
auto top3max = heap<int>::TopK_max(nums, 3);
std::cout << "前3大:";
for (auto e : top3max) std::cout << e << " ";
std::cout << "\n";
auto top3min = heap<int>::TopK_min(nums, 3);
std::cout << "前3小:";
for (auto e : top3min) std::cout << e << " ";
std::cout << "\n";
}到这里,我们其实已经把堆这一块的核心内容拆解得差不多了。从二叉树的铺垫,到堆的定义与调整操作,再到复杂度分析,最后结合 TopK 问题做了实战,其实就是一条“从理论到应用”的学习闭环。
回过头看:
一点小感悟:数据结构和算法,其实都像是在训练我们“换个角度思考”的能力。堆教会我们的,不只是怎么写一个优先队列,更是如何在“局部”维护“全局最优”。当你能把这种思维迁移到工程里,就会发现很多看似复杂的问题,思路一下就清晰了。