堆是计算机科学中核心的数据结构之一,基于完全二叉树构建,兼具高效的插入、删除和极值查询能力,广泛应用于优先队列、堆排序、TopK问题等场景。
一、堆的定义与核心性质
1.1 本质定义
堆是一种完全二叉树 (Complete Binary Tree),同时满足堆序性(Heap Property)。完全二叉树的定义是:除最后一层外,每一层的节点数均为最大值,且最后一层的节点从左到右连续排列(无空洞)。这种结构决定了堆可以用数组高效存储,无需额外指针开销。
1.2 堆序性规则
堆序性是堆与普通完全二叉树的核心区别,分为两种类型:
- 大根堆(Max Heap) :每个父节点的值 大于等于 其左右子节点的值(
parent.val ≥ left.val && parent.val ≥ right.val),堆顶(根节点)是整个堆的最大值。 - 小根堆(Min Heap) :每个父节点的值 小于等于 其左右子节点的值(
parent.val ≤ left.val && parent.val ≤ right.val),堆顶是整个堆的最小值。
1.3 与二叉搜索树(BST)的区别
堆和BST常被混淆,但核心目标完全不同:
| 特性 | 堆(大根堆/小根堆) | 二叉搜索树(BST) |
|---|---|---|
| 结构要求 | 完全二叉树 | 任意二叉树(通常平衡化) |
| 有序性 | 仅父子节点满足堆序(全局无序) | 左子树 < 根 < 右子树(全局有序) |
| 核心操作效率 | 插入/删除堆顶 O(logn),查极值 O(1) | 插入/删除/查找 O(logn)(平衡BST) |
| 适用场景 | 优先队列、TopK、堆排序 | 动态查找、有序遍历 |
二、堆的存储结构
由于堆是完全二叉树,无空洞节点,因此数组是堆的最优存储方式,无需额外空间存储指针。数组与二叉树节点的映射关系如下:
假设堆的数组为 vector<T> heap,对于索引为 i 的节点(从0开始计数):
- 父节点索引:
parent = (i - 1) / 2(整数除法,自动向下取整) - 左子节点索引:
left = 2 * i + 1 - 右子节点索引:
right = 2 * i + 2
示例 :小根堆 [2, 5, 3, 8, 7, 6] 对应的完全二叉树结构:
2 (i=0)
/ \
5(i=1) 3(i=2)
/ \ /
8(i=3)7(i=4)6(i=5)
验证映射关系:i=1 的父节点是 (1-1)/2=0,i=2 的左子节点是 2*2+1=5,完全符合数组存储逻辑。
三、堆的核心操作(C++模板实现)
堆的所有功能基于两个核心操作:上浮(Sift Up) 和 下沉(Sift Down) 。下面实现一个支持大根堆/小根堆的通用模板类 Heap<T, Compare>,其中 Compare 是比较函数对象(默认大根堆)。
3.1 模板类框架
cpp
#include <vector>
#include <algorithm>
#include <stdexcept>
// 比较函数对象:大根堆(默认)
template <typename T>
struct MaxHeapCompare {
bool operator()(const T& a, const T& b) const {
return a < b; // 父节点需大于子节点,故a<b时需交换
}
};
// 比较函数对象:小根堆
template <typename T>
struct MinHeapCompare {
bool operator()(const T& a, const T& b) const {
return a > b; // 父节点需小于子节点,故a>b时需交换
}
};
// 堆模板类:T为元素类型,Compare为比较规则
template <typename T, typename Compare = MaxHeapCompare<T>>
class Heap {
private:
std::vector<T> data; // 存储堆的数组
Compare cmp; // 比较函数对象
// 上浮操作:从索引i向上调整堆
void siftUp(int i) {
while (i > 0) { // 未到达根节点
int parent = (i - 1) / 2; // 父节点索引
// 若当前节点与父节点满足堆序,无需调整
if (!cmp(data[parent], data[i])) break;
// 不满足堆序,交换父节点与当前节点
std::swap(data[parent], data[i]);
i = parent; // 继续向上调整
}
}
// 下沉操作:从索引i向下调整堆
void siftDown(int i) {
int n = data.size();
while (true) {
int maxChild = i; // 初始化最大子节点为当前节点
int left = 2 * i + 1; // 左子节点索引
int right = 2 * i + 2; // 右子节点索引
// 比较左子节点与当前节点
if (left < n && cmp(data[maxChild], data[left])) {
maxChild = left;
}
// 比较右子节点与当前最大子节点
if (right < n && cmp(data[maxChild], data[right])) {
maxChild = right;
}
// 若当前节点已是最大/最小子节点,无需调整
if (maxChild == i) break;
// 交换当前节点与最大/最小子节点
std::swap(data[i], data[maxChild]);
i = maxChild; // 继续向下调整
}
}
public:
// 构造函数:空堆
Heap() = default;
// 构造函数:从数组建堆(自底向上法)
Heap(const std::vector<T>& arr) : data(arr) {
buildHeap();
}
// 建堆操作:将无序数组转为堆
void buildHeap() {
int n = data.size();
// 从最后一个非叶子节点开始,自底向上堆化
int lastNonLeaf = (n - 2) / 2; // 最后一个节点的父节点
for (int i = lastNonLeaf; i >= 0; --i) {
siftDown(i);
}
}
// 插入元素
void push(const T& val) {
data.push_back(val); // 插入到数组末尾(完全二叉树的最后一个叶子)
siftUp(data.size() - 1); // 上浮调整堆
}
// 删除堆顶元素
void pop() {
if (empty()) {
throw std::runtime_error("Heap is empty, cannot pop!");
}
int n = data.size();
data[0] = data[n - 1]; // 堆顶替换为最后一个元素
data.pop_back(); // 删除最后一个元素
if (!empty()) {
siftDown(0); // 下沉调整堆
}
}
// 获取堆顶元素
const T& top() const {
if (empty()) {
throw std::runtime_error("Heap is empty, cannot get top!");
}
return data[0];
}
// 判断堆是否为空
bool empty() const {
return data.empty();
}
// 获取堆的大小
size_t size() const {
return data.size();
}
};
3.2 核心操作详解
3.2.1 上浮(siftUp)
- 场景:插入新元素后,堆序可能被破坏(新元素在叶子,可能不满足与父节点的堆序)。
- 原理:从新元素的位置(数组末尾)向上遍历,与父节点比较,若不满足堆序则交换,直到到达根节点或满足堆序。
- 时间复杂度:O(logn)(树的高度为logn,最多交换logn次)。
3.2.2 下沉(siftDown)
- 场景:删除堆顶后,堆序被破坏(最后一个元素移到堆顶,可能不满足与子节点的堆序)。
- 原理:从堆顶开始,与左右子节点中"符合堆序的极值节点"(大根堆找最大子节点,小根堆找最小子节点)比较,若不满足堆序则交换,直到到达叶子节点或满足堆序。
- 关键细节:需先判断左右子节点是否存在(避免数组越界),再选择极值子节点。
- 时间复杂度:O(logn)(树的高度为logn)。
3.2.3 建堆(buildHeap)
- 两种方法对比 :
- 自顶向下法:逐个将数组元素插入堆,时间复杂度O(nlogn)(每个元素插入需O(logn))。
- 自底向上法:从最后一个非叶子节点开始,逐个执行下沉操作,时间复杂度O(n)(核心考点)。
- O(n)复杂度推导 :
完全二叉树的第k层(根为第0层)有2^k个节点,每个节点的下沉深度最多为(树高 - k)。树高h = logn,总操作次数为:
∑k=0h−12k×(h−k)=O(n)\sum_{k=0}^{h-1} 2^k \times (h - k) = O(n)k=0∑h−12k×(h−k)=O(n) - 代码逻辑 :最后一个非叶子节点索引为
(n-2)/2(最后一个节点n-1的父节点),从该节点向左遍历至根,依次执行siftDown。
四、堆的经典应用
4.1 堆排序(原地排序)
堆排序是堆的核心应用,利用大根堆(升序)或小根堆(降序)实现排序,时间复杂度O(nlogn),空间复杂度O(1)(原地排序),但不稳定(相同元素可能交换位置)。
原理步骤:
- 对数组执行
buildHeap,构建大根堆(升序排序); - 交换堆顶(最大值)与数组末尾元素,此时末尾元素为有序区;
- 对剩余无序区(0~n-2)执行
siftDown(0),重新构建大根堆; - 重复步骤2~3,直到无序区为空。
C++实现:
cpp
// 原地堆排序(升序,基于大根堆)
template <typename T>
void heapSort(std::vector<T>& arr) {
int n = arr.size();
if (n <= 1) return;
// 步骤1:构建大根堆(自底向上)
Heap<T> maxHeap(arr);
arr = maxHeap.getData(); // 简化:直接复用堆的数组(实际可原地修改)
// 步骤2~4:交换堆顶与末尾,调整堆
for (int i = n - 1; i > 0; --i) {
std::swap(arr[0], arr[i]); // 堆顶(最大值)移到末尾
// 对剩余无序区(0~i-1)执行下沉调整
Heap<T> tempHeap(arr.substr(0, i));
for (int j = 0; j < i; ++j) {
arr[j] = tempHeap.getData()[j];
}
}
// 优化版:原地建堆+调整(无需额外堆对象)
/*
// 原地建大根堆
auto buildMaxHeap = [&](std::vector<T>& a, int size) {
int lastNonLeaf = (size - 2) / 2;
for (int i = lastNonLeaf; i >= 0; --i) {
int parent = i;
while (true) {
int maxChild = parent;
int left = 2 * parent + 1;
int right = 2 * parent + 2;
if (left < size && a[left] > a[maxChild]) maxChild = left;
if (right < size && a[right] > a[maxChild]) maxChild = right;
if (maxChild == parent) break;
std::swap(a[parent], a[maxChild]);
parent = maxChild;
}
}
};
buildMaxHeap(arr, n);
for (int i = n - 1; i > 0; --i) {
std::swap(arr[0], arr[i]);
buildMaxHeap(arr, i); // 调整剩余无序区
}
*/
}
// 测试代码
int main() {
std::vector<int> arr = {5, 3, 8, 4, 2, 7, 1, 6};
heapSort(arr);
std::cout << "堆排序结果:";
for (int num : arr) std::cout << num << " "; // 输出:1 2 3 4 5 6 7 8
return 0;
}
4.2 优先队列(Priority Queue)
优先队列是堆的典型应用,元素按优先级排序,每次出队的是优先级最高的元素。C++ STL提供priority_queue,底层基于堆实现。
4.2.1 STL priority_queue详解
- 默认行为:大根堆(优先级高的元素值大)。
- 核心接口 :
push(val)(插入)、pop()(删除堆顶)、top()(获取堆顶)、empty()、size()。 - 改为小根堆的两种方式 :
- 使用
greater<T>(需包含<functional>); - 自定义比较函数对象。
- 使用
4.2.2 示例:任务调度(按优先级执行)
cpp
#include <queue>
#include <functional>
#include <iostream>
#include <string>
// 任务结构体
struct Task {
std::string name;
int priority; // 优先级:数字越大越优先
Task(std::string n, int p) : name(n), priority(p) {}
};
// 自定义比较函数对象(小根堆:优先级低的在前)
struct TaskCompare {
bool operator()(const Task& a, const Task& b) const {
return a.priority > b.priority; // 与大根堆相反
}
};
int main() {
// 1. 大根堆(默认,优先级高的先执行)
std::priority_queue<Task> maxPq;
maxPq.push(Task("任务A", 3));
maxPq.push(Task("任务B", 5));
maxPq.push(Task("任务C", 2));
std::cout << "大根堆优先队列执行顺序:" << std::endl;
while (!maxPq.empty()) {
auto task = maxPq.top();
maxPq.pop();
std::cout << "执行:" << task.name << "(优先级:" << task.priority << ")" << std::endl;
}
// 输出:任务B(5)→ 任务A(3)→ 任务C(2)
// 2. 小根堆(自定义比较函数)
std::priority_queue<Task, std::vector<Task>, TaskCompare> minPq;
minPq.push(Task("任务A", 3));
minPq.push(Task("任务B", 5));
minPq.push(Task("任务C", 2));
std::cout << "\n小根堆优先队列执行顺序:" << std::endl;
while (!minPq.empty()) {
auto task = minPq.top();
minPq.pop();
std::cout << "执行:" << task.name << "(优先级:" << task.priority << ")" << std::endl;
}
// 输出:任务C(2)→ 任务A(3)→ 任务B(5)
return 0;
}
4.3 TopK问题(海量数据前K大元素)
TopK问题是面试高频题,核心需求是从海量数据中快速找到前K个最大值(或最小值)。使用小根堆可实现O(nlogK)时间复杂度、O(K)空间复杂度。
原理(前K大元素):
- 构建一个大小为K的小根堆;
- 遍历所有数据,若元素大于堆顶(当前K个元素的最小值),则替换堆顶并调整堆;
- 遍历结束后,堆中元素即为前K大元素。
C++实现:
cpp
#include <vector>
#include <queue>
#include <iostream>
#include <random>
// 海量数据前K大元素(小根堆实现)
std::vector<int> topK(std::vector<int>& data, int k) {
if (data.empty() || k <= 0 || k > data.size()) {
throw std::invalid_argument("Invalid input!");
}
// 步骤1:构建大小为K的小根堆(用STL priority_queue,greater<int>)
std::priority_queue<int, std::vector<int>, std::greater<int>> minHeap;
for (int i = 0; i < k; ++i) {
minHeap.push(data[i]);
}
// 步骤2:遍历剩余数据,更新堆
for (int i = k; i < data.size(); ++i) {
if (data[i] > minHeap.top()) { // 比堆顶大,替换
minHeap.pop();
minHeap.push(data[i]);
}
}
// 步骤3:提取堆中元素
std::vector<int> result;
while (!minHeap.empty()) {
result.push_back(minHeap.top());
minHeap.pop();
}
return result;
}
// 测试:生成1000个随机数,找前10大
int main() {
std::vector<int> data;
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> dis(1, 10000);
for (int i = 0; i < 1000; ++i) {
data.push_back(dis(gen));
}
int k = 10;
auto topKResult = topK(data);
std::cout << "前" << k << "大元素:";
for (int num : topKResult) {
std::cout << num << " ";
}
return 0;
}
4.4 中位数问题(动态数据实时获取中位数)
中位数是有序数组中间的元素(奇数长度取中间,偶数长度取平均)。对于动态插入的数据,使用两个堆(大根堆+小根堆)可实现O(logn)插入和O(1)查询。
原理:
- 大根堆(leftHeap):存储左半部分数据(≤中位数),堆顶为左半部分最大值;
- 小根堆(rightHeap):存储右半部分数据(≥中位数),堆顶为右半部分最小值;
- 平衡条件:两堆大小差≤1(奇数长度时,大根堆多1个元素;偶数长度时,两堆大小相等);
- 插入逻辑 :
- 若元素≤大根堆顶,插入大根堆;否则插入小根堆;
- 调整两堆大小,确保平衡条件;
- 查询逻辑 :
- 奇数长度:中位数 = 大根堆顶;
- 偶数长度:中位数 = (大根堆顶 + 小根堆顶) / 2。
C++实现:
cpp
#include <vector>
#include <queue>
#include <iostream>
#include <stdexcept>
class MedianFinder {
private:
std::priority_queue<int> leftHeap; // 大根堆(默认):存储左半部分
std::priority_queue<int, std::vector<int>, std::greater<int>> rightHeap; // 小根堆:存储右半部分
// 平衡两堆大小
void balance() {
// 大根堆比小根堆多2个,移一个到小根堆
if (leftHeap.size() - rightHeap.size() == 2) {
rightHeap.push(leftHeap.top());
leftHeap.pop();
}
// 小根堆比大根堆多1个,移一个到大根堆
else if (rightHeap.size() - leftHeap.size() == 1) {
leftHeap.push(rightHeap.top());
rightHeap.pop();
}
}
public:
MedianFinder() = default;
// 插入元素
void addNum(int num) {
if (leftHeap.empty() || num <= leftHeap.top()) {
leftHeap.push(num);
} else {
rightHeap.push(num);
}
balance(); // 平衡堆大小
}
// 获取中位数
double findMedian() {
if (leftHeap.empty() && rightHeap.empty()) {
throw std::runtime_error("No data!");
}
// 奇数长度:左堆多1个
if (leftHeap.size() > rightHeap.size()) {
return leftHeap.top();
}
// 偶数长度:两堆大小相等
else {
return (leftHeap.top() + rightHeap.top()) / 2.0;
}
}
};
// 测试
int main() {
MedianFinder mf;
mf.addNum(1);
mf.addNum(3);
std::cout << "中位数:" << mf.findMedian() << std::endl; // (1+3)/2=2.0
mf.addNum(2);
std::cout << "中位数:" << mf.findMedian() << std::endl; // 2
mf.addNum(4);
std::cout << "中位数:" << mf.findMedian() << std::endl; // (2+3)/2=2.5
return 0;
}
五、堆的优缺点与适用场景
5.1 优点
- 极值查询(堆顶):O(1) 常数时间;
- 插入/删除堆顶:O(logn) 高效;
- 存储紧凑:数组存储,无额外指针开销;
- 支持动态数据:适合元素频繁插入/删除的场景。
5.2 缺点
- 随机访问低效:需遍历数组,O(n);
- 查找任意元素低效:O(n);
- 堆排序不稳定:相同元素可能交换位置;
- 调整堆开销:插入/删除需维护堆序,比数组直接操作耗时。
5.3 适用场景
- 优先队列(任务调度、事件驱动);
- 排序(堆排序);
- TopK问题(海量数据前K大/小);
- 中位数查询(动态数据);
- 贪心算法(如霍夫曼编码、Prim最小生成树)。
六、常见误区与注意事项
- 建堆时间复杂度:自底向上建堆是O(n),而非O(nlogn),面试高频考点;
- STL priority_queue的坑 :
pop()不返回元素,需先top()再pop();默认大根堆,小根堆需显式指定greater<T>; - 下沉操作的边界:必须判断左右子节点是否存在,避免数组越界;
- 堆与BST的区别:堆是"父子有序,全局无序",BST是"左小右大,全局有序",切勿混淆;
- TopK问题的堆选择:前K大用小根堆,前K小用大根堆,可最小化堆大小(O(K))。
堆是基于完全二叉树的高效数据结构,核心优势在于O(1)极值查询和O(logn)插入/删除。掌握堆的关键在于理解"上浮"和"下沉"的维护逻辑,以及建堆的O(n)复杂度推导,这也是面试中的重点考察内容。在实际开发中,优先使用STL的priority_queue,但自定义堆实现能更深入理解其底层原理,应对复杂场景(如中位数查询、自定义比较规则)。
补充建堆的O(n)复杂度推导
∑k=0h−12k×(h−k)=O(n)\sum_{k=0}^{h-1} 2^k \times (h - k) = O(n)∑k=0h−12k×(h−k)=O(n),核心是两步走 :先算出求和式的精确值 ,再结合 hhh 与 nnn 的内在关系(通常来自二叉树场景),推导其时间复杂度量级。
一、第一步:计算求和式的精确值(错位相减法)
求和式 ∑k=0h−12k×(h−k)\sum_{k=0}^{h-1} 2^k \times (h - k)∑k=0h−12k×(h−k) 是典型的「等差×等比」数列求和(其中 2k2^k2k 是等比数列,(h−k)(h-k)(h−k) 是等差数列),适合用错位相减法求解。
步骤1:展开求和式,定义 SSS
设 S=∑k=0h−12k×(h−k)S = \sum_{k=0}^{h-1} 2^k \times (h - k)S=∑k=0h−12k×(h−k),按 kkk 从 000 到 h−1h-1h−1 展开:
S=h×20+(h−1)×21+(h−2)×22+⋯+1×2h−1(1) S = h \times 2^0 + (h-1) \times 2^1 + (h-2) \times 2^2 + \dots + 1 \times 2^{h-1} \tag{1} S=h×20+(h−1)×21+(h−2)×22+⋯+1×2h−1(1)
(解释:当 k=0k=0k=0 时,项为 h×1h \times 1h×1;k=1k=1k=1 时,项为 (h−1)×2(h-1) \times 2(h−1)×2;...;k=h−1k=h-1k=h−1 时,项为 1×2h−11 \times 2^{h-1}1×2h−1)
步骤2:乘以等比数列的公比(此处公比为2)
将等式(1)两边同时乘以 222,得到 2S2S2S:
2S=h×21+(h−1)×22+(h−2)×23+⋯+1×2h(2) 2S = h \times 2^1 + (h-1) \times 2^2 + (h-2) \times 2^3 + \dots + 1 \times 2^h \tag{2} 2S=h×21+(h−1)×22+(h−2)×23+⋯+1×2h(2)
步骤3:错位相减,消去中间项
用(2)式减去(1)式,对齐同类项(核心是消去中间的 21,22,...,2h−12^1, 2^2, ..., 2^{h-1}21,22,...,2h−1 项):
2S−S=[h×21−h×20]+[(h−1)×22−(h−1)×21]+⋯+[1×2h−1×2h−1] 2S - S = \left[ h \times 2^1 - h \times 2^0 \right] + \left[ (h-1) \times 2^2 - (h-1) \times 2^1 \right] + \dots + \left[ 1 \times 2^h - 1 \times 2^{h-1} \right] 2S−S=[h×21−h×20]+[(h−1)×22−(h−1)×21]+⋯+[1×2h−1×2h−1]
步骤4:化简右边表达式
提取每一项的公因子,中间项会形成等比数列:
S=−h×20+21+22+⋯+2h⏟等比数列求和 S = -h \times 2^0 + \underbrace{2^1 + 2^2 + \dots + 2^h}_{等比数列求和} S=−h×20+等比数列求和 21+22+⋯+2h
- 首项 −h×20=−h-h \times 2^0 = -h−h×20=−h(来自(2)式的首项减(1)式的首项);
- 中间的等比数列:21+22+...+2h2^1 + 2^2 + ... + 2^h21+22+...+2h,共 hhh 项,公比 222,首项 222。
步骤5:计算等比数列的和
根据等比数列求和公式 S等比=a1⋅qm−1q−1S_{等比} = a_1 \cdot \frac{q^m - 1}{q - 1}S等比=a1⋅q−1qm−1(a1a_1a1 为首项,qqq 为公比,mmm 为项数):
21+22+⋯+2h=2⋅2h−12−1=2h+1−2 2^1 + 2^2 + \dots + 2^h = 2 \cdot \frac{2^h - 1}{2 - 1} = 2^{h+1} - 2 21+22+⋯+2h=2⋅2−12h−1=2h+1−2
步骤6:合并得到 SSS 的精确值
将等比数列和代入 SSS 的表达式:
S=−h+(2h+1−2)=2h+1−h−2 S = -h + (2^{h+1} - 2) = 2^{h+1} - h - 2 S=−h+(2h+1−2)=2h+1−h−2
二、第二步:建立 hhh 与 nnn 的关系(关键前提)
求和式 ∑k=0h−12k×(h−k)\sum_{k=0}^{h-1} 2^k \times (h - k)∑k=0h−12k×(h−k) 通常来自二叉树场景 (比如平衡二叉树的路径长度之和、操作次数总和等),此时 hhh 是二叉树的高度 ,nnn 是二叉树的节点数。
对于平衡二叉树(或满二叉树,平衡树的极端情况),节点数 nnn 与高度 hhh 满足:
n=2h−1(满二叉树节点数公式:第1层到第h层,每层节点数为2k−1,总和为2h−1) n = 2^h - 1 \quad (\text{满二叉树节点数公式:第1层到第h层,每层节点数为} 2^{k-1},总和为 2^h - 1) n=2h−1(满二叉树节点数公式:第1层到第h层,每层节点数为2k−1,总和为2h−1)
变形可得:
2h=n+1 ⟹ 2h+1=2(n+1)=2n+2 2^h = n + 1 \implies 2^{h+1} = 2(n + 1) = 2n + 2 2h=n+1⟹2h+1=2(n+1)=2n+2
三、第三步:推导 S=O(n)S = O(n)S=O(n)
将 2h+1=2n+22^{h+1} = 2n + 22h+1=2n+2 代入 SSS 的精确值:
S=(2n+2)−h−2=2n−h S = (2n + 2) - h - 2 = 2n - h S=(2n+2)−h−2=2n−h
关键:分析 hhh 的量级
对于平衡二叉树,高度 hhh 是 对数级别 的:
h=log2(n+1)≈log2n(当n很大时,1可忽略) h = \log_2(n + 1) \approx \log_2 n \quad (\text{当n很大时,1可忽略}) h=log2(n+1)≈log2n(当n很大时,1可忽略)
log2n\log_2 nlog2n 是低阶无穷小 (增长速度远慢于 nnn),例如:
- 当 n=106n=10^6n=106 时,log2n≈20\log_2 n \approx 20log2n≈20,h≈20h \approx 20h≈20;
- 当 n=109n=10^9n=109 时,log2n≈30\log_2 n \approx 30log2n≈30,h≈30h \approx 30h≈30。
用大O符号定义验证
大O符号的核心定义:若存在常数 C>0C>0C>0 和 n0>0n_0>0n0>0,当 n≥n0n \geq n_0n≥n0 时,S≤C⋅nS \leq C \cdot nS≤C⋅n,则 S=O(n)S=O(n)S=O(n)。
对于 S=2n−hS=2n - hS=2n−h:
- 因为 h≥1h \geq 1h≥1(树的高度至少为1),所以 S=2n−h≤2nS = 2n - h \leq 2nS=2n−h≤2n(对所有 n≥1n \geq 1n≥1 成立);
- 取 C=2C=2C=2,n0=1n_0=1n0=1,完全满足大O定义。
因此,S=2n−h=O(n)S = 2n - h = O(n)S=2n−h=O(n)(低阶项 hhh 不影响增长趋势,主导项是 2n2n2n,与 nnn 同量级)。
- 求和计算 :通过错位相减法,得到精确值 S=2h+1−h−2S=2^{h+1} - h - 2S=2h+1−h−2;
- 关联 hhh 与 nnn :平衡二叉树中,节点数 n=2h−1n=2^h -1n=2h−1,故 2h+1=2n+22^{h+1}=2n+22h+1=2n+2;
- 量级分析 :代入后 S=2n−hS=2n - hS=2n−h,其中 h=O(logn)h=O(\log n)h=O(logn)(低阶项),主导项为 2n2n2n;
- 大O验证 :满足 S≤C⋅nS \leq C \cdot nS≤C⋅n,故 S=O(n)S=O(n)S=O(n)。
本质是:求和式的增长速度由 2h2^h2h 主导,而 2h2^h2h 与二叉树节点数 nnn 是线性关系(2h=n+12^h = n+12h=n+1),因此求和式最终是 O(n)O(n)O(n) 量级。