← C++ 教材目录(共 13 章)

第 13 章 数据结构与算法补遗

red wenzi · 2026-09-15 · 编程语言 · C++ · 📖 预计阅读 41 分钟 · 共 4 道练习
🧮 这一章正在拆分:排序、二叉树、图与并查集会在 数据结构与算法 线里逐个重写成语言无关的四语言对照。链表部分已经上线:第 1 章 链表 →
🎯 本章你会学到:四种排序、二叉树、图、并查集与复杂度速查。建议边读边敲代码,每节的练习先自己做,再展开答案对照。

第 10 章写了顺序表和链表,第 11 章讲了十种算法思想。这一章把常见课程作业和面试里最容易考到的部分补齐:**四种排序、二叉树、图、并查集**,最后给两张速查表和一张选型清单。

这些结构不需要背,但要做到:给一个需求,能立刻说出用哪个结构、复杂度是多少、边界在哪里。

13.1 四种必须会写的排序

std::sort 平时够用,但“手写一遍”能让你真正理解复杂度从哪来。四种里前两种必须默写,后两种要能讲清思路。

插入排序:像整理扑克牌

插入排序
for (int i = 1; i < n; ++i) {
    int key = a[i];
    int j = i - 1;
    while (j >= 0 && a[j] > key) { a[j + 1] = a[j]; --j; }
    a[j + 1] = key;
}

已排好序的区间在左边不断长大。数据基本有序时非常快(接近 O(n)),也是很多标准库在小数组上的实际做法。

归并排序:分治 + 合并

归并排序
void mergeSort(std::vector<int>& a, int l, int r, std::vector<int>& buf) {
    if (r - l <= 1) { return; }              // [l, r) 只剩 0/1 个元素
    int mid = l + (r - l) / 2;
    mergeSort(a, l, mid, buf);
    mergeSort(a, mid, r, buf);
    int i = l, j = mid, k = l;               // 合并两个有序区间
    while (i < mid && j < r) { buf[k++] = (a[i] <= a[j]) ? a[i++] : a[j++]; }
    while (i < mid) { buf[k++] = a[i++]; }
    while (j < r) { buf[k++] = a[j++]; }
    for (int t = l; t < r; ++t) { a[t] = buf[t]; }
}

复杂度稳定在 O(n log n),而且是**稳定排序**(相等元素保持原顺序)。代价是需要 O(n) 的额外空间。

快速排序:选基准 + 分区

快速排序
void quickSort(std::vector<int>& a, int l, int r) {   // [l, r]
    if (l >= r) { return; }
    int i = l, j = r;
    int pivot = a[l + (r - l) / 2];                  // 取中间值当基准
    while (i <= j) {
        while (a[i] < pivot) { ++i; }
        while (a[j] > pivot) { --j; }
        if (i <= j) { std::swap(a[i], a[j]); ++i; --j; }
    }
    quickSort(a, l, j);
    quickSort(a, i, r);
}

平均 O(n log n)、常数小、原地排序,是 std::sort 的基础。最坏 O(n²)(每次选到极值当基准),随机化或三数取中可以规避。

堆排序:借优先队列的思路

堆排序(用标准库的堆操作)
std::make_heap(a.begin(), a.end());          // 建大顶堆 O(n)
for (int i = n - 1; i > 0; --i) {
    std::pop_heap(a.begin(), a.begin() + i + 1);   // 最大值换到末尾
}

O(n log n)、原地、不用递归。手写版的关键是 sift_down(下沉):父节点和较大的子节点交换,直到满足堆性质。

算法平均最坏额外空间稳定
插入排序O(n²)O(n²)O(1)
归并排序O(n log n)O(n log n)O(n)
快速排序O(n log n)O(n²)O(log n) 递归栈
堆排序O(n log n)O(n log n)O(1)
⚠️ 易错点 排序函数的区间一律是左闭右开 [l, r)(除了快排那种闭区间写法),递归终止条件写错是最常见的死循环来源。

✍️ 本节练习

13.1.1必做手写归并排序 + 与 std::sort 对拍

读入 n 个整数,用自己写的归并排序升序输出;再用固定种子 12345 做 200 轮随机对拍,每轮把随机数组分别交给你的排序和 std::sort,断言结果完全一致,最后输出 ok

输入 一个整数 n,随后 n 个整数。

输出 第一行升序序列;第二行 ok

样例输入
6
5 2 9 1 5 6
样例输出
1 2 5 5 6 9
ok

💡 提示 对拍固定种子:std::mt19937 gen(12345);比较直接用 == 比较两个 vector。

✅ 查看参考答案与解析
#include <algorithm>
#include <cassert>
#include <cstddef>
#include <iostream>
#include <random>
#include <vector>

void mergeSort(std::vector<int>& a, int l, int r, std::vector<int>& buf) {
    if (r - l <= 1) { return; }
    int mid = l + (r - l) / 2;
    mergeSort(a, l, mid, buf);
    mergeSort(a, mid, r, buf);
    int i = l, j = mid, k = l;
    while (i < mid && j < r) { buf[k++] = (a[i] <= a[j]) ? a[i++] : a[j++]; }
    while (i < mid) { buf[k++] = a[i++]; }
    while (j < r) { buf[k++] = a[j++]; }
    for (int t = l; t < r; ++t) { a[t] = buf[t]; }
}

int main() {
    int n{};
    std::cin >> n;
    std::vector<int> a(static_cast<std::size_t>(n));
    for (int i = 0; i < n; ++i) { std::cin >> a[i]; }

    std::vector<int> buf(a.size());
    mergeSort(a, 0, n, buf);
    for (int i = 0; i < n; ++i) { std::cout << a[i] << (i + 1 == n ? '\n' : ' '); }

    std::mt19937 gen(12345);                 // 固定种子,结果可复现
    std::uniform_int_distribution<int> dist(-1000, 1000);
    for (int round = 0; round < 200; ++round) {
        int len = 1 + round % 50;
        std::vector<int> mine(static_cast<std::size_t>(len)), ref;
        for (int i = 0; i < len; ++i) { mine[i] = dist(gen); }
        ref = mine;
        std::vector<int> tmp(mine.size());
        mergeSort(mine, 0, len, tmp);
        std::sort(ref.begin(), ref.end());
        assert(mine == ref);
    }
    std::cout << "ok\n";
    return 0;
}

解析 对拍是验证自己实现的通用手段:同一份随机输入喂给两个实现,逐元素比较;固定种子保证失败可复现。

13.2 二叉树与二叉搜索树

二叉树是“每个节点最多两个孩子”的结构。二叉搜索树(BST)额外要求:左子树全部小于根,右子树全部大于根——这让查找可以像二分一样折半。

BST 的节点与插入(unique_ptr 版)
struct Node {
    int value{};
    std::unique_ptr<Node> left;
    std::unique_ptr<Node> right;
    explicit Node(int v) : value(v) { }
};

void insert(std::unique_ptr<Node>& root, int v) {
    if (!root) { root = std::make_unique<Node>(v); return; }
    if (v < root->value) { insert(root->left, v); }
    else if (v > root->value) { insert(root->right, v); }
    // 相等就忽略(集合语义)
}

void inorder(const std::unique_ptr<Node>& root) {   // 中序 = 升序
    if (!root) { return; }
    inorder(root->left);
    std::cout << root->value << ' ';
    inorder(root->right);
}

中序遍历输出一定是有序序列,这是 BST 最好用的性质。查找的平均复杂度 O(log n),但**如果插入顺序本身有序,BST 会退化成链表**,复杂度变成 O(n)。

⚠️ 易错点 std::map / std::set 底层用的是红黑树(一种自平衡 BST),所以它敢承诺 O(log n);你自己写的裸 BST 没有平衡机制,性能没有保障。

✍️ 本节练习

13.2.1必做BST:插入 + 中序 + 最值

读入 n 个整数依次插入二叉搜索树(重复值忽略),输出中序遍历结果、最小值、最大值。

输入 第一行整数 n;第二行 n 个整数。

输出 第一行升序的中序序列;第二行 min=;第三行 max=

样例输入
6
5 3 8 1 4 7
样例输出
1 3 4 5 7 8
min=1
max=8

💡 提示 最小值就是一路往左走到底,最大值一路往右走到底。

✅ 查看参考答案与解析
#include <iostream>
#include <memory>

struct Node {
    int value{};
    std::unique_ptr<Node> left;
    std::unique_ptr<Node> right;
    explicit Node(int v) : value(v) { }
};

void insert(std::unique_ptr<Node>& root, int v) {
    if (!root) { root = std::make_unique<Node>(v); return; }
    if (v < root->value) { insert(root->left, v); }
    else if (v > root->value) { insert(root->right, v); }
    // 相等:忽略,保持集合语义
}

void inorder(const std::unique_ptr<Node>& root, bool& first) {
    if (!root) { return; }
    inorder(root->left, first);
    std::cout << (first ? "" : " ") << root->value;
    first = false;
    inorder(root->right, first);
}

const Node* minNode(const std::unique_ptr<Node>& root) {
    const Node* p = root.get();
    while (p && p->left) { p = p->left.get(); }
    return p;
}

const Node* maxNode(const std::unique_ptr<Node>& root) {
    const Node* p = root.get();
    while (p && p->right) { p = p->right.get(); }
    return p;
}

int main() {
    int n{};
    std::cin >> n;
    std::unique_ptr<Node> root;
    for (int i = 0; i < n; ++i) {
        int x{};
        std::cin >> x;
        insert(root, x);
    }
    bool first = true;
    inorder(root, first);
    std::cout << '\n';
    std::cout << "min=" << minNode(root)->value << '\n';
    std::cout << "max=" << maxNode(root)->value << '\n';
    return 0;
}

解析 unique_ptr 让整棵树在 root 析构时自动递归释放,不需要手写析构函数;注意树很深时递归释放同样有爆栈风险。

13.3 图:邻接表、DFS、BFS

图就是“点和边”。存图最常用的方式是**邻接表**:vector<vector<int>>,下标是点,里面存相邻点。点少边多也可以用邻接矩阵。

建图(无向)
int n, m;
std::cin >> n >> m;
std::vector<std::vector<int>> g(n);
for (int i = 0; i < m; ++i) {
    int u, v;
    std::cin >> u >> v;
    g[u].push_back(v);
    g[v].push_back(u);        // 无向图:两个方向都存;有向图去掉这行
}

建好图之后:DFS 用来回答“有哪些点连在一起”(连通块、判环、拓扑序);BFS 用来回答“最少几步能到”(无权图最短路)。两者的边界检查写法不一样,记住这一点就不会写混。

问题用哪个复杂度
有多少个连通块DFS / BFS 都行O(n + m)
从 a 到 b 最少几条边BFSO(n + m)
图里有没有环DFS(记录父节点)O(n + m)
按拓扑序输出DFS 后序反着输出 / 入度 BFSO(n + m)
⚠️ 易错点 点编号从 0 开始时数组要开 n 个;从 1 开始就开 n+1 个。越界是图论题最常见的崩溃原因,先用小数据跑一遍。

✍️ 本节练习

13.3.1挑战连通块数 + 无权图最短路

读入 n 个点、m 条无向边,输出两行:整张图的连通块个数;点 0 到点 n-1 的最短边数(不可达输出 -1)。

输入 第一行 n m;接下来 m 行每行两个整数 u v。

输出 第一行 components=;第二行 dist=

样例输入
6 5
0 1
1 2
2 3
3 4
1 5
样例输出
components=1
dist=2

💡 提示 连通块用 DFS;最短路用 BFS 的 dist 数组,初始 -1 表示未访问。

✅ 查看参考答案与解析
#include <iostream>
#include <queue>
#include <vector>

void dfs(int u, const std::vector<std::vector<int>>& g, std::vector<char>& vis) {
    vis[u] = 1;
    for (int v : g[u]) {
        if (!vis[v]) { dfs(v, g, vis); }
    }
}

int main() {
    int n{}, m{};
    std::cin >> n >> m;
    std::vector<std::vector<int>> g(n);
    for (int i = 0; i < m; ++i) {
        int u{}, v{};
        std::cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);
    }

    // 1) 连通块计数(DFS)
    std::vector<char> vis(n, 0);
    int components = 0;
    for (int i = 0; i < n; ++i) {
        if (!vis[i]) {
            ++components;
            dfs(i, g, vis);
        }
    }
    std::cout << "components=" << components << '\n';

    // 2) 无权最短路(BFS)
    std::vector<int> dist(n, -1);
    std::queue<int> q;
    dist[0] = 0;
    q.push(0);
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        for (int v : g[u]) {
            if (dist[v] == -1) {
                dist[v] = dist[u] + 1;
                q.push(v);
            }
        }
    }
    std::cout << "dist=" << dist[n - 1] << '\n';
    return 0;
}

解析 BFS 里“入队即标记”是保证每个点只入队一次的关键;如果改成出队时标记,同一个点会被重复入队,复杂度退化甚至出错。

13.4 并查集:合并与查询

并查集解决“谁和谁是一伙的”:不断合并两个集合,随时查询两个人是否同一集合。路径压缩 + 按大小合并之后,单次操作接近 O(1)。

并查集模板
struct DSU {
    std::vector<int> parent, size;
    explicit DSU(int n) : parent(n), size(n, 1) {
        for (int i = 0; i < n; ++i) { parent[i] = i; }
    }
    int find(int x) {                       // 路径压缩
        if (parent[x] != x) { parent[x] = find(parent[x]); }
        return parent[x];
    }
    void unite(int a, int b) {              // 按大小合并
        int ra = find(a), rb = find(b);
        if (ra == rb) { return; }
        if (size[ra] < size[rb]) { std::swap(ra, rb); }
        parent[rb] = ra;
        size[ra] += size[rb];
    }
    bool same(int a, int b) { return find(a) == find(b); }
};

递归版 find 写起来短;数据量极大时递归可能爆栈,可以改成 while 循环加两遍扫描的写法。

✍️ 本节练习

13.4.1必做朋友圈:合并 + 查询

读入 n 个人和 m 对朋友关系(朋友的朋友也是朋友),输出朋友圈个数;再读入一对查询 a b,输出他们是否在同一朋友圈。

输入 第一行 n m;接下来 m 行每行两个整数 u v;最后一行两个整数 a b(查询)。

输出 第一行 groups=;第二行同一圈输出 yes,否则输出 no

样例输入
5 3
0 1
2 3
3 4
2 4
样例输出
groups=2
yes

💡 提示 每读一对朋友就 unite(u, v);统计 groups 就是数 find(i) == i 的个数。

✅ 查看参考答案与解析
#include <iostream>
#include <utility>
#include <vector>

struct DSU {
    std::vector<int> parent, sz;
    explicit DSU(int n) : parent(static_cast<std::size_t>(n)), sz(static_cast<std::size_t>(n), 1) {
        for (int i = 0; i < n; ++i) { parent[static_cast<std::size_t>(i)] = i; }
    }
    int find(int x) {
        if (parent[static_cast<std::size_t>(x)] != x) {
            parent[static_cast<std::size_t>(x)] = find(parent[static_cast<std::size_t>(x)]);
        }
        return parent[static_cast<std::size_t>(x)];
    }
    void unite(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) { return; }
        if (sz[static_cast<std::size_t>(ra)] < sz[static_cast<std::size_t>(rb)]) {
            std::swap(ra, rb);
        }
        parent[static_cast<std::size_t>(rb)] = ra;
        sz[static_cast<std::size_t>(ra)] += sz[static_cast<std::size_t>(rb)];
    }
};

int main() {
    int n{}, m{};
    std::cin >> n >> m;
    DSU dsu(n);
    for (int i = 0; i < m; ++i) {
        int u{}, v{};
        std::cin >> u >> v;
        dsu.unite(u, v);
    }
    int groups = 0;
    for (int i = 0; i < n; ++i) {
        if (dsu.find(i) == i) { ++groups; }
    }
    std::cout << "groups=" << groups << '\n';
    int a{}, b{};
    std::cin >> a >> b;
    std::cout << (dsu.find(a) == dsu.find(b) ? "yes" : "no") << '\n';
    return 0;
}

解析 统计集合个数时不要数 parent[i] == i 而不做路径压缩——必须先 find(i) 把路径压平再比较,否则中间节点会被漏算。

13.5 复杂度速查表

写代码时选错容器,往往就是“超时”的根源。这两张表建议抄在笔记本第一页。

容器随机访问尾部插入中部插入/删除查找有序
vectorO(1)平摊 O(1)O(n)O(n)
dequeO(1)O(1)O(n)O(n)
list不支持O(1)O(1)(已有迭代器)O(n)
map / set不支持O(log n)O(log n)
unordered_map / unordered_set不支持平均 O(1)平均 O(1)
priority_queue只能看堆顶O(log n)堆序
任务推荐做法复杂度
频繁按下标读写vectorO(1)
已知规模先开空间vector + reserve
去重且要顺序set / 排序后 uniqueO(n log n)
统计频率unordered_map平均 O(n)
每次取最值priority_queueO(log n)
大量区间求和前缀和预处理 O(n),查询 O(1)
在单调序列里找边界二分 / lower_boundO(log n)
判断连通性、动态合并并查集近 O(1)

13.6 怎么选:一张决策清单

拿到题目先别写代码,按下面的顺序问自己几个问题,答案基本就出来了。

💡 复杂度和数据规模对照:n ≤ 20 可以 O(2ⁿ);n ≤ 500 可以 O(n³);n ≤ 5000 可以 O(n²);n ≤ 10⁶ 只能 O(n log n) 或 O(n)。先看 n 的范围,再选算法——这比“先想算法再担心超时”省时间。

本章小结

📚 本文概念都在知识大全:

排序算法 · 二叉树与 BST · 图的存储与遍历 · 并查集 · 时间复杂度 · 栈与队列