备战蓝桥杯----C/C++组 (一)数据结构与STL讲解(中):树、二叉树与堆——从层次结构到优先队列的进阶之路

个人主页:
wengqidaifeng

✨ 永远在路上,永远向前走

个人专栏:
数据结构
C语言
嵌入式小白启动!
重要OJ算法题详解

文章目录


如果说线性结构是程序世界的"一维空间",那么树形结构就是通往"多维世界"的第一扇门。

(前言)写在前面

在上一篇文章中,我们学习了顺序表、链表、栈和队列------这些线性结构如同一根笔直的线条,数据之间只有"前驱"和"后继"两种关系。然而,现实世界中的数据关系远比这复杂得多。

想一想:

  • 一个公司的组织架构,CEO下面有多个部门总监,每个总监下面又有多个经理...
  • 一个文件夹系统,根目录下有多个子文件夹,每个子文件夹又可以包含更多文件...
  • 一场比赛的对阵表,从总决赛不断向下拆分到各个小组...

这些场景中,数据之间呈现的是一对多 的层次关系------这正是树形结构所要解决的问题。

如果说线性结构是程序世界的"基础工具",那么树形结构就是构建复杂系统的"高级框架"。从文件系统的目录树,到数据库的索引结构,再到人工智能中的决策树,树形结构无处不在。

而在这些树形结构中,最基础、最重要的当属二叉树------每个结点最多有两个孩子的特殊树。它不仅是许多高级数据结构(如堆、二叉搜索树、AVL树、红黑树)的基石,更是算法竞赛中"分治思想"的最佳载体。

本文将带你走进树的世界,从最基础的树的存储与遍历,到二叉树的三种深度优先遍历,再到堆与优先级队列的实现与应用。通过手写代码+STL使用的双重讲解,让你不仅"会用",更能"懂其所以然"。


一、树:从线性到层次的跨越

1.1 什么是树?

树(Tree) 是一种非线性的数据结构,它由 n(n≥0)个结点组成,具有以下特点:

  • 有一个特殊的结点称为根结点(root),它没有前驱
  • 除根结点外,其余结点被分成 (m) 个互不相交的集合,每个集合又是一棵树,称为根结点的子树(subtree)

这个定义是递归 的------树由子树构成,子树又由更小的子树构成。递归,正是理解和操作树的核心思想。

1.2 树的基本术语

术语 含义 示例
结点的度 结点拥有的子树个数 一个结点有2个孩子,则度为2
树的度 树中所有结点度的最大值 树中最大结点度为3,则树的度为3
叶子结点 度为0的结点 没有孩子的结点
分支结点 度大于0的结点 有孩子的结点
孩子/双亲 结点的子树的根称为该结点的孩子,该结点称为孩子的双亲
兄弟 同一双亲的孩子之间互称兄弟
深度/高度 树中结点的最大层次数 根为第1层,最深为第4层,则深度为4
路径 从结点(u)到结点(v)经过的结点序列 路径长度为经过的边数

1.3 有序树与无序树

  • 有序树:结点的子树有从左到右的顺序,不可互换(如二叉树)
  • 无序树:结点的子树之间没有顺序关系

这个区分在二叉树中尤为重要------左右孩子是不能颠倒的,就像人的左手和右手,位置固定。

1.4 有根树与无根树

  • 有根树:根节点是确定的,父子关系明确
  • 无根树:根节点不确定,任意结点都可以作为根

在处理无根树时,我们通常需要存储双向的边关系(即(u)连接(v),(v)也连接(u)),然后指定一个根进行遍历。


二、树的存储:孩子表示法

在算法竞赛中,树最常见的存储方式是孩子表示法------每个结点维护一个列表,记录它的所有孩子(或所有相邻结点)。

2.1 用vector数组存储

cpp 复制代码
#include <iostream>
#include <vector>
using namespace std;

const int N = 1e5 + 10;
vector<int> edges[N];  // edges[i] 存储与结点i相邻的所有结点
bool st[N];            // 标记是否访问过

int main() {
    int n;
    cin >> n;
    
    // 读入n-1条边(树有n-1条边)
    for(int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        edges[u].push_back(v);
        edges[v].push_back(u);  // 无根树需要双向存储
    }
    
    // 现在就可以从任意结点开始遍历整棵树了
    return 0;
}

优点 :代码简单直观,容易理解
缺点:动态数组有轻微的性能开销(但在竞赛中通常不会卡常数)

2.2 用链式前向星存储

链式前向星本质是用数组模拟链表,是静态实现的,效率更高。

cpp 复制代码
#include <iostream>
using namespace std;

const int N = 1e5 + 10;

int h[N], e[N * 2], ne[N * 2], idx;  // idx是当前分配的结点编号
bool st[N];

// 添加一条从a到b的边(头插法)
void add(int a, int b) {
    idx++;
    e[idx] = b;      // 存储目标结点
    ne[idx] = h[a];  // 指向a原来的第一个邻居
    h[a] = idx;      // 更新a的第一个邻居
}

int main() {
    int n;
    cin >> n;
    
    for(int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        add(u, v);
        add(v, u);  // 无根树双向存储
    }
    
    // 遍历结点u的所有邻居
    // for(int i = h[u]; i; i = ne[i]) {
    //     int v = e[i];
    //     // 处理v...
    // }
    
    return 0;
}

为什么用N * 2 因为每条边要存储两次(双向),所以数组大小需要开两倍。

2.3 两种方式的选择

方式 优点 缺点 适用场景
vector数组 代码简洁,易调试 动态内存,略慢 一般题目,追求代码简洁
链式前向星 静态内存,速度快 代码稍复杂,易出错 大数据量,追求极致效率

建议:初学阶段两种都掌握,做题时选择自己熟悉的一种。绝大多数题目,vector数组完全够用。


三、树的遍历:深度优先与广度优先

树的遍历是许多算法的基础。遍历的核心是不重不漏地访问每个结点一次。

3.1 深度优先遍历(DFS)

深度优先遍历的思路是:一条路走到黑。从根结点出发,沿着一条路径一直往下走,直到无法继续,再回溯到上一个结点,走另一条路径。

用递归实现DFS非常自然:

cpp 复制代码
// DFS遍历以u为根的子树
void dfs(int u) {
    cout << u << " ";  // 访问当前结点
    st[u] = true;      // 标记已访问
    
    // 遍历所有邻居
    for(auto v : edges[u]) {
        if(!st[v]) {
            dfs(v);    // 递归访问子树
        }
    }
}

时间复杂度 :(O(N)),每个结点访问一次,每条边遍历两次
空间复杂度:(O(N)),最坏情况(链状树)递归深度为(N)

3.2 广度优先遍历(BFS)

广度优先遍历的思路是:一层一层地扫。从根结点出发,先访问所有距离为1的结点,再访问距离为2的结点,以此类推。

用队列实现BFS:

cpp 复制代码
#include <queue>

void bfs(int root) {
    queue<int> q;
    q.push(root);
    st[root] = true;
    
    while(!q.empty()) {
        int u = q.front();
        q.pop();
        cout << u << " ";
        
        for(auto v : edges[u]) {
            if(!st[v]) {
                q.push(v);
                st[v] = true;
            }
        }
    }
}

时间复杂度 :(O(N))
空间复杂度:(O(N)),最坏情况所有结点在同一层,队列大小可达(N)

3.3 DFS vs BFS:如何选择?

特性 DFS BFS
实现方式 递归/栈 队列
空间消耗 递归深度(可能栈溢出) 队列大小(可能很大)
适用场景 探索深度优先的问题、回溯、树形DP 最短路径、层次遍历

四、二叉树:每个结点最多两个孩子

4.1 二叉树的定义

二叉树(Binary Tree) 是每个结点最多有两个子树的树结构。两个子树分别称为左子树右子树,顺序固定,不可颠倒。

二叉树的特点:

  • 每个结点最多有两棵子树
  • 左右子树有顺序之分
  • 可以是空树(没有任何结点)

4.2 特殊的二叉树

满二叉树

所有非叶子结点都有左右孩子,且所有叶子结点在同一层。

性质:深度为(k)的满二叉树,结点数为(2^k - 1)。

完全二叉树

在满二叉树的基础上,从最后一层的右侧开始连续删除若干个结点,剩下的就是完全二叉树。

性质

  • 叶子结点只可能在最后两层
  • 可以用数组顺序存储,父结点与子结点的下标关系固定

4.3 二叉树的存储

顺序存储(适用于完全二叉树)

对于完全二叉树,我们可以用数组存储,下标关系如下:

复制代码
结点编号从1开始:
- 根结点:下标1
- 结点i的左孩子:下标 2*i
- 结点i的右孩子:下标 2*i+1
- 结点i的父结点:下标 i/2
cpp 复制代码
const int N = 1e6 + 10;
int heap[N];  // 存储完全二叉树

// 访问结点i的左孩子
int left_child = heap[2 * i];
// 访问结点i的右孩子
int right_child = heap[2 * i + 1];
// 访问结点i的父结点
int parent = heap[i / 2];

注意:普通二叉树用顺序存储会造成大量空间浪费,不适合。

链式存储(适用于一般二叉树)

用数组模拟链式存储,两个数组分别记录左右孩子:

cpp 复制代码
const int N = 1e6 + 10;
int l[N], r[N];  // l[i]表示结点i的左孩子编号,r[i]表示右孩子编号
// 如果孩子不存在,则值为0

int main() {
    int n;
    cin >> n;
    for(int i = 1; i <= n; i++) {
        cin >> l[i] >> r[i];  // 读入每个结点的左右孩子
    }
    return 0;
}

五、二叉树的遍历:三种深搜顺序

二叉树最核心的遍历方式有三种:先序遍历中序遍历后序遍历 。它们的区别在于访问根结点的时机

5.1 先序遍历(根左右)

顺序:根结点 → 左子树 → 右子树

cpp 复制代码
void preorder(int root) {
    if(root == 0) return;  // 空结点
    cout << root << " ";   // 访问根
    preorder(l[root]);     // 遍历左子树
    preorder(r[root]);     // 遍历右子树
}

5.2 中序遍历(左根右)

顺序:左子树 → 根结点 → 右子树

cpp 复制代码
void inorder(int root) {
    if(root == 0) return;
    inorder(l[root]);      // 遍历左子树
    cout << root << " ";   // 访问根
    inorder(r[root]);      // 遍历右子树
}

5.3 后序遍历(左右根)

顺序:左子树 → 右子树 → 根结点

cpp 复制代码
void postorder(int root) {
    if(root == 0) return;
    postorder(l[root]);    // 遍历左子树
    postorder(r[root]);    // 遍历右子树
    cout << root << " ";   // 访问根
}

5.4 层序遍历(BFS)

层序遍历就是广度优先遍历,按层从上到下、从左到右访问所有结点:

cpp 复制代码
#include <queue>

void levelorder(int root) {
    queue<int> q;
    q.push(root);
    
    while(!q.empty()) {
        int u = q.front();
        q.pop();
        cout << u << " ";
        
        if(l[u]) q.push(l[u]);
        if(r[u]) q.push(r[u]);
    }
}

5.5 由两种遍历序列还原二叉树

这是一个经典问题:已知一棵二叉树的中序+前序(或中序+后序),可以唯一确定这棵树。

核心思路

  • 前序序列的第一个元素是根
  • 后序序列的最后一个元素是根
  • 在中序序列中找到根,左边是左子树,右边是右子树
  • 递归处理左右子树

例题 :已知中序BADC,后序BDCA,求前序。

复制代码
后序最后一个A是根
中序中A左边是B,右边是DC → 左子树有B,右子树有D、C
后序中左子树部分BD,右子树部分C
递归处理...
最终前序:ABCD
cpp 复制代码
// 已知中序a和后续b,求先序
void dfs(int l1, int r1, int l2, int r2) {
    if(l1 > r1) return;
    
    char root = b[r2];           // 后序最后一个为根
    cout << root;                 // 先输出根
    
    int p = l1;
    while(a[p] != root) p++;      // 在中序中找到根的位置
    
    // 左子树:中序[l1, p-1],后序[l2, l2+p-l1-1]
    dfs(l1, p-1, l2, l2+p-l1-1);
    // 右子树:中序[p+1, r1],后序[l2+p-l1, r2-1]
    dfs(p+1, r1, l2+p-l1, r2-1);
}

六、堆:优先级队列的底层实现

6.1 什么是堆?

堆(Heap) 是一种特殊的完全二叉树,它满足以下性质:

  • 大根堆 :每个结点的值都大于等于其左右孩子的值
  • 小根堆 :每个结点的值都小于等于其左右孩子的值

堆常用来实现优先级队列------每次取出的都是最大(或最小)的元素。

6.2 堆的存储

由于堆是完全二叉树,我们可以用数组顺序存储,下标关系与完全二叉树相同:

cpp 复制代码
const int N = 1e6 + 10;
int heap[N];  // 堆数组,从下标1开始存储
int sz;       // 当前堆中元素个数

6.3 堆的核心操作

向上调整(up)

当在堆的末尾插入一个新元素时,需要向上调整,维持堆的性质。

cpp 复制代码
void up(int child) {
    int parent = child / 2;
    while(parent >= 1 && heap[child] > heap[parent]) {  // 大根堆
        swap(heap[child], heap[parent]);
        child = parent;
        parent = child / 2;
    }
}
向下调整(down)

当删除堆顶元素后,将最后一个元素放到堆顶,需要向下调整。

cpp 复制代码
void down(int parent) {
    int child = parent * 2;
    while(child <= sz) {
        // 找出左右孩子中较大的(大根堆)
        if(child + 1 <= sz && heap[child + 1] > heap[child]) {
            child++;
        }
        if(heap[parent] >= heap[child]) break;
        swap(heap[parent], heap[child]);
        parent = child;
        child = parent * 2;
    }
}

6.4 堆的基本操作

cpp 复制代码
// 插入元素
void push(int x) {
    heap[++sz] = x;
    up(sz);
}

// 删除堆顶
void pop() {
    heap[1] = heap[sz--];
    down(1);
}

// 获取堆顶
int top() {
    return heap[1];
}

// 堆的大小
int size() {
    return sz;
}

// 判空
bool empty() {
    return sz == 0;
}

时间复杂度:所有操作均为(O(\log N))。

6.5 建堆:从无序数组构建堆

方法一:逐个插入,时间复杂度(O(N \log N))

方法二:从最后一个非叶子结点开始,依次向下调整,时间复杂度(O(N))

cpp 复制代码
void buildHeap(int arr[], int n) {
    // 先将数组复制到堆中
    for(int i = 1; i <= n; i++) {
        heap[i] = arr[i-1];
    }
    sz = n;
    
    // 从最后一个非叶子结点开始向下调整
    for(int i = n / 2; i >= 1; i--) {
        down(i);
    }
}

七、STL中的堆:priority_queue

7.1 基本使用

priority_queue是C++标准库提供的优先级队列,底层就是用堆实现的。

cpp 复制代码
#include <iostream>
#include <queue>  // priority_queue的头文件
using namespace std;

int main() {
    // 默认是大根堆
    priority_queue<int> heap;
    
    // 插入元素
    heap.push(3);
    heap.push(1);
    heap.push(4);
    heap.push(2);
    
    // 获取堆顶(最大值)
    cout << heap.top() << endl;  // 输出: 4
    
    // 删除堆顶
    heap.pop();
    
    // 遍历(边取边删)
    while(!heap.empty()) {
        cout << heap.top() << " ";
        heap.pop();
    }
    // 输出: 3 2 1
    
    return 0;
}

7.2 创建小根堆

cpp 复制代码
// 方法一:使用greater
priority_queue<int, vector<int>, greater<int>> minHeap;

// 方法二:存入负数(取巧)
priority_queue<int> minHeap;  // 本质是大根堆
minHeap.push(-x);  // 存入负数,取出来时再取反

7.3 存储自定义类型

当堆中存储结构体时,需要重载<运算符。

cpp 复制代码
struct Node {
    int value;
    int id;
    
    // 重载<运算符,定义比较规则
    bool operator<(const Node& other) const {
        return value > other.value;  // 小根堆:大的下沉
    }
};

priority_queue<Node> heap;

7.4 常用操作总结

操作 代码 时间复杂度
插入元素 heap.push(x); (O(\log N))
删除堆顶 heap.pop(); (O(\log N))
获取堆顶 heap.top(); (O(1))
获取大小 heap.size(); (O(1))
判空 heap.empty(); (O(1))

八、算法题实战

8.1 二叉树深度

题目:给定一棵二叉树,求其深度(从根到叶子经过的最大结点数)。

思路:递归求解,深度 = 1 + max(左子树深度, 右子树深度)。

cpp 复制代码
#include <iostream>
using namespace std;

const int N = 1e6 + 10;
int l[N], r[N];

int dfs(int root) {
    if(root == 0) return 0;
    return max(dfs(l[root]), dfs(r[root])) + 1;
}

int main() {
    int n;
    cin >> n;
    for(int i = 1; i <= n; i++) {
        cin >> l[i] >> r[i];
    }
    cout << dfs(1) << endl;
    return 0;
}

8.2 堆模板题

题目:实现一个堆,支持插入、获取最小值、删除最小值。

思路 :手写小根堆或直接用priority_queue

cpp 复制代码
#include <iostream>
#include <queue>
using namespace std;

int main() {
    priority_queue<int, vector<int>, greater<int>> heap;
    int n;
    cin >> n;
    
    while(n--) {
        int op, x;
        cin >> op;
        if(op == 1) {
            cin >> x;
            heap.push(x);
        } else if(op == 2) {
            cout << heap.top() << endl;
        } else {
            heap.pop();
        }
    }
    
    return 0;
}

8.3 第k小

题目:动态维护一个序列,支持插入和查询第k小的数。

思路:维护一个大根堆,始终保持堆的大小为k,堆顶就是第k小的数。

cpp 复制代码
#include <iostream>
#include <queue>
using namespace std;

int main() {
    int n, m, k;
    cin >> n >> m >> k;
    
    priority_queue<int> heap;  // 大根堆
    
    // 先插入n个初始元素
    for(int i = 0; i < n; i++) {
        int x;
        cin >> x;
        heap.push(x);
        if(heap.size() > k) heap.pop();
    }
    
    while(m--) {
        int op;
        cin >> op;
        if(op == 1) {
            int x;
            cin >> x;
            heap.push(x);
            if(heap.size() > k) heap.pop();
        } else {
            if(heap.size() == k) {
                cout << heap.top() << endl;
            } else {
                cout << -1 << endl;
            }
        }
    }
    
    return 0;
}

九、总结与对比

9.1 树与线性结构的对比

特性 线性结构 树形结构
关系类型 一对一 一对多
遍历方式 简单顺序 DFS/BFS
应用场景 列表、队列、栈 层次关系、分治问题

9.2 二叉树 vs 堆

特性 普通二叉树
形态 任意形状 完全二叉树
性质 无特殊要求 父结点 >= 子结点(大根堆)
存储 链式为主 顺序存储
用途 表达层次关系 优先级队列

9.3 选择建议

  • 需要表达层次关系(如组织架构、文件系统)→ 树
  • 需要快速获取最大/最小值 → 堆
  • 需要快速查找、插入、删除 → 二叉搜索树(后续文章)
  • 需要处理优先级队列 → priority_queue

写在最后

树形结构是算法竞赛中最重要的数据结构之一。从简单的树的遍历,到复杂的二叉搜索树、线段树、树状数组,再到树形DP,都离不开对树的理解。

本文我们学习了:

  • 树的定义与基本术语
  • 树的存储方式(vector数组、链式前向星)
  • 树的遍历(DFS、BFS)
  • 二叉树的三种深度优先遍历
  • 堆的定义与实现
  • STL中的priority_queue

下一篇预告:我们将深入探讨二叉搜索树与平衡树------如何让树在动态变化中保持高效查找。

练习建议

  1. 用DFS和BFS分别遍历一棵树,理解两种方式的区别
  2. 手写二叉树的三种遍历,理解递归的本质
  3. 手写堆的所有操作,理解up和down的作用
  4. 用priority_queue完成洛谷P3378【模板】堆

树是递归思想的最佳载体。当你真正理解了树的递归遍历,你就打开了通往高级算法的大门。


祝你在蓝桥杯的备赛路上越走越远!🚀

相关推荐
承渊政道2 小时前
C++学习之旅【IO库相关内容介绍】
c语言·开发语言·c++·学习·macos·visual studio
小年糕是糕手2 小时前
【35天从0开始备战蓝桥杯 -- Day5】
数据结构·数据库·c++·算法·蓝桥杯
炸膛坦客2 小时前
单片机/C/C++八股:(十七)C++ 中指针和引用的区别
c语言·开发语言·c++
草莓熊Lotso3 小时前
Linux IPC 进阶:System V 消息队列与信号量(含内核管理深度解析)
linux·运维·服务器·数据库·c++·人工智能·mysql
AI+程序员在路上10 小时前
CANopen 协议:介绍、调试命令与应用
linux·c语言·开发语言·网络
2401_8318249610 小时前
基于C++的区块链实现
开发语言·c++·算法
爱编码的小八嘎11 小时前
C语言完美演绎4-4
c语言
汉克老师11 小时前
GESP5级C++考试语法知识(六、链表(一)单链表)
c++·链表·单链表·快慢指针·进阶·gesp5级·gesp五级
m0_5180194811 小时前
C++与机器学习框架
开发语言·c++·算法