高级数据结构 (Advanced Data Structures)

title: ""

章节概述

本章涵盖多种高级数据结构——字典树(Trie)、并查集(Union-Find)、跳表(Skip List)、
B树和线段树。这些数据结构在特定场景下提供极大的性能提升,是现代软件系统的核心组件。

本节侧重底层实现与内存理解,与 CPP 教程的 TrieUnion-Find线段树跳表B树 等对应章节形成互补——CPP教程侧重使用模式和算法优化,本教程深入内存布局和手动实现。

在CPP教程中对应章节侧重各数据结构的 STL 使用和竞赛优化,本节侧重从零实现、理解内存布局、以及各结构的适用场景分析。



第一节: 字典树(Trie / Prefix Tree)


1.1 什么是字典树?

字典树(Trie)是一种树形结构,用于高效存储和检索字符串集合。每个节点表示一个字符,
从根到节点的路径表示一个前缀。Trie 的查找时间与字符串长度成正比 O(L),与数据量无关。

#include <stdlib.h>
#include <stdio.h>
#include <stdbool.h>
#include <string.h>
 
#define ALPHABET_SIZE 26
 
typedef struct TrieNode {
 struct TrieNode *children[ALPHABET_SIZE]; // 26个字母的子节点
 bool is_end; // 标记是否为某个单词的结尾
} TrieNode;
graph TD
 root["root"] --> t["t"]
 root --> a["a"]
 t --> o["o"]
 a --> p["p"]
 p --> p2["p"]
 p2 --> l["l"]
 l --> e["e (end)"]
 o --> space["(end)"]
 t --> e2["e"]
 e2 --> a2["a"]
 a2 --> space2["(end)"]

该 Trie 存储了 "to", "tea", "apple"。共享前缀 "t""te""ap" 等节点。


1.2 Trie 的创建与插入

TrieNode* trie_node_create() {
 TrieNode *node = (TrieNode*)calloc(1, sizeof(TrieNode));
 // calloc 将 children 初始化为 NULL,is_end 初始化为 false
 return node;
}
 
void trie_insert(TrieNode *root, const char *word) {
 TrieNode *cur = root;
 for (int i = 0; word[i] != '\0'; i++) {
 int idx = word[i] - 'a';
 if (idx < 0 || idx >= ALPHABET_SIZE) continue; // 忽略非小写字母
 
 if (cur->children[idx] == NULL) {
 cur->children[idx] = trie_node_create();
 }
 cur = cur->children[idx];
 }
 cur->is_end = true; // 标记单词结尾
}
操作数组实现指针实现
访问子节点O(1) 直接索引O(1) 指针解引用
内存占用26个指针(208B/节点)26个指针 + padding
稀疏节点浪费空间同样浪费

练习 1.2.1: 分析 Trie 的内存开销。如果存储 10 万个英文单词(平均长度 5),大约需要多少节点和内存?


1.3 Trie 的搜索

// 精确搜索
bool trie_search(const TrieNode *root, const char *word) {
 const TrieNode *cur = root;
 for (int i = 0; word[i] != '\0'; i++) {
 int idx = word[i] - 'a';
 if (idx < 0 || idx >= ALPHABET_SIZE) return false;
 if (cur->children[idx] == NULL) return false;
 cur = cur->children[idx];
 }
 return cur->is_end;
}
 
// 前缀搜索
bool trie_starts_with(const TrieNode *root, const char *prefix) {
 const TrieNode *cur = root;
 for (int i = 0; prefix[i] != '\0'; i++) {
 int idx = prefix[i] - 'a';
 if (idx < 0 || idx >= ALPHABET_SIZE) return false;
 if (cur->children[idx] == NULL) return false;
 cur = cur->children[idx];
 }
 return true; // 不需要是完整单词
}
 
// 收集所有以给定前缀开头的单词
void trie_collect_words(const TrieNode *node, char *buffer, int depth,
 char **results, int *count) {
 if (node->is_end) {
 buffer[depth] = '\0';
 results[*count] = strdup(buffer);
 (*count)++;
 }
 for (int i = 0; i < ALPHABET_SIZE; i++) {
 if (node->children[i]) {
 buffer[depth] = 'a' + i;
 trie_collect_words(node->children[i], buffer, depth + 1,
 results, count);
 }
 }
}

练习 1.3.1: 实现 trie_delete(TrieNode *root, const char *word)。删除后如果节点不再被任何单词使用,需要释放该节点。


1.4 Trie 的销毁(后序遍历)

void trie_destroy(TrieNode *root) {
 if (!root) return;
 for (int i = 0; i < ALPHABET_SIZE; i++) {
 trie_destroy(root->children[i]);
 }
 free(root);
}

与二叉树一样,Trie 必须用后序遍历释放——先释放子节点,再释放父节点。


1.5 优化:压缩字典树(Radix Tree / Patricia Trie)

当 Trie 中存在大量”单链”(只有一个子节点的连续节点),可以压缩为一个边:

graph TD
 subgraph "标准 Trie"
 r1["r"] --> o1["o"] --> o2["o"] --> t1["t"]
 r1 --> o3["o"] --> m1["m"] --> a1["a"] --> n1["n"]
 end
 subgraph "压缩 Trie"
 r2["r"] --> oot["oot"]
 r2 --> oman["oman"]
 end

压缩 Trie 减少节点数和指针,但实现复杂度大幅增加。

关于 Trie 的 C++ STL 无法直接使用(没有标准 Trie 容器),CPP 教程中展示了竞赛场景下的实现,参见 CPP: Trie



第二节: 并查集(Union-Find / Disjoint Set)


2.1 什么是并查集?

并查集维护一个不相交集合的集合,支持两个核心操作:

  • Find: 查找元素属于哪个集合(找代表元)
  • Union: 合并两个集合
graph TD
 subgraph "三个不相交集合"
 s1["集合1: {0, 1, 2} → 代表元=0"]
 s2["集合2: {3, 4} → 代表元=3"]
 s3["集合3: {5, 6, 7, 8} → 代表元=5"]
 end

2.2 基础实现 —— 森林表示

typedef struct {
 int *parent; // parent[i] = i 的父节点
 int n; // 元素数量
} UnionFind;
 
UnionFind* uf_create(int n) {
 UnionFind *uf = (UnionFind*)malloc(sizeof(UnionFind));
 uf->parent = (int*)malloc(n * sizeof(int));
 uf->n = n;
 for (int i = 0; i < n; i++) {
 uf->parent[i] = i; // 每个元素初始指向自己
 }
 return uf;
}
 
// 查找(未优化版本)
int uf_find_naive(UnionFind *uf, int x) {
 while (uf->parent[x] != x) {
 x = uf->parent[x];
 }
 return x;
}
 
// 合并(未优化版本)
void uf_union_naive(UnionFind *uf, int x, int y) {
 int root_x = uf_find_naive(uf, x);
 int root_y = uf_find_naive(uf, y);
 if (root_x != root_y) {
 uf->parent[root_x] = root_y; // 简单合并
 }
}

未优化的最坏情况: 退化为链表,查找 O(n)。


2.3 路径压缩(Path Compression)

在查找过程中,将路径上的所有节点直接指向根:

int uf_find(UnionFind *uf, int x) {
 if (uf->parent[x] != x) {
 uf->parent[x] = uf_find(uf, uf->parent[x]); // 递归压缩
 }
 return uf->parent[x];
}
 
// 迭代版本(避免递归栈)
int uf_find_iter(UnionFind *uf, int x) {
 int root = x;
 while (uf->parent[root] != root) {
 root = uf->parent[root];
 }
 // 路径压缩:所有经过的节点直接指向 root
 while (x != root) {
 int next = uf->parent[x];
 uf->parent[x] = root;
 x = next;
 }
 return root;
}
graph TD
 subgraph "路径压缩前"
 p1["1"] --> p2["2"]
 p2 --> p3["3"]
 p3 --> p4["4 (root)"]
 p5["5"] --> p2
 p6["6"] --> p3
 end
 subgraph "Find(1) 后 — 路径压缩"
 c1["1"] --> c4["4 (root)"]
 c2["2"] --> c4
 c3["3"] --> c4
 p5 --> c4
 p6 --> c4
 end

2.4 按秩合并(Union by Rank)

总是将较矮的树合并到较高的树下:

typedef struct {
 int *parent;
 int *rank; // rank[i] = 以 i 为根的树的高度上界
 int n;
} UnionFindOptimized;
 
UnionFindOptimized* ufo_create(int n) {
 UnionFindOptimized *uf = (UnionFindOptimized*)malloc(sizeof(UnionFindOptimized));
 uf->parent = (int*)malloc(n * sizeof(int));
 uf->rank = (int*)calloc(n, sizeof(int));
 uf->n = n;
 for (int i = 0; i < n; i++) uf->parent[i] = i;
 return uf;
}
 
int ufo_find(UnionFindOptimized *uf, int x) {
 if (uf->parent[x] != x) {
 uf->parent[x] = ufo_find(uf, uf->parent[x]);
 }
 return uf->parent[x];
}
 
void ufo_union(UnionFindOptimized *uf, int x, int y) {
 int root_x = ufo_find(uf, x);
 int root_y = ufo_find(uf, y);
 if (root_x == root_y) return;
 
 // 按秩合并
 if (uf->rank[root_x] < uf->rank[root_y]) {
 uf->parent[root_x] = root_y;
 } else if (uf->rank[root_x] > uf->rank[root_y]) {
 uf->parent[root_y] = root_x;
 } else {
 uf->parent[root_y] = root_x;
 uf->rank[root_x]++; // 秩增加
 }
}

复杂度分析: 路径压缩 + 按秩合并使每次操作平均达到 O(α(n)),
其中 α(n) 是反阿克曼函数——对于任何实际输入值,α(n) ≤ 4。近乎 O(1)。

练习 2.4.1: 实现并查集的”按大小合并”(Union by Size),与小树合并到大树等效。对比按秩合并的优缺点。


2.5 并查集应用

// 1. 检测无向图中的环
bool has_cycle_undirected(int n, int edges[][2], int m) {
 UnionFindOptimized *uf = ufo_create(n);
 for (int i = 0; i < m; i++) {
 int u = edges[i][0], v = edges[i][1];
 if (ufo_find(uf, u) == ufo_find(uf, v)) {
 ufo_destroy(uf);
 return true; // 已经在同一集合 → 有环
 }
 ufo_union(uf, u, v);
 }
 ufo_destroy(uf);
 return false;
}
 
// 2. 连通分量计数
int count_connected_components(UnionFindOptimized *uf) {
 int count = 0;
 for (int i = 0; i < uf->n; i++) {
 if (uf->parent[i] == i) count++;
 }
 return count;
}

关于并查集在 Kruskal 最小生成树算法中的应用,参见 CPP: 并查集



第三节: 跳表(Skip List)


3.1 跳表简介

跳表是链表的”加速版”——通过多层索引实现 O(log n) 的查找,同时保持链表的简洁插入删除。
它是 Redis 的 ZSet 和 LevelDB 的 MemTable 的底层实现。

graph LR
 subgraph "跳表 (Skip List)"
 l3["Level 3: head"] --> l3n1["→ 30"] --> l3n2["→ NULL"]
 l2["Level 2: head"] --> l2n1["→ 10"] --> l2n2["→ 30"] --> l2n3["→ NULL"]
 l1["Level 1: head"] --> l1n1["→ 10"] --> l1n2["→ 20"] --> l1n3["→ 30"] --> l1n4["→ 40"] --> l1n5["→ NULL"]
 end

查找 25 的过程:Level 3: head→30 跳过(25<30) → Level 2: head→10→30 跳过 → Level 1: 20→30 找到位置。


3.2 跳表节点的 C 实现

#include <stdlib.h>
#include <time.h>
 
#define SKIPLIST_MAX_LEVEL 16 // 最大层数
 
typedef struct SkipNode {
 int value;
 struct SkipNode **forward; // forward[i] = 第 i 层的下一个节点
} SkipNode;
 
typedef struct {
 SkipNode *header;
 int level; // 当前最大层数
 int size;
} SkipList;
 
SkipNode* skipnode_create(int value, int level) {
 SkipNode *node = (SkipNode*)malloc(sizeof(SkipNode));
 node->value = value;
 node->forward = (SkipNode**)calloc(level, sizeof(SkipNode*));
 return node;
}
 
SkipList* skiplist_create() {
 SkipList *sl = (SkipList*)malloc(sizeof(SkipList));
 // 头节点拥有最大层数,不存数据
 sl->header = skipnode_create(-1, SKIPLIST_MAX_LEVEL);
 sl->level = 1;
 sl->size = 0;
 return sl;
}

3.3 随机层数生成

跳表使用随机化决定新节点的层数(类似抛硬币):

int skiplist_random_level() {
 int level = 1;
 // 每次有 50% 概率升级(p = 0.5)
 while ((rand() % 2) && level < SKIPLIST_MAX_LEVEL) {
 level++;
 }
 return level;
}

p = 0.5 时:50% 节点只有 1 层,25% 有 2 层,12.5% 有 3 层…
这正是几何分布,保证每层节点数是上一层的 1/p。


3.4 跳表的插入

bool skiplist_insert(SkipList *sl, int value) {
 SkipNode *update[SKIPLIST_MAX_LEVEL];
 SkipNode *cur = sl->header;
 
 // 1. 从最高层开始查找插入位置
 for (int i = sl->level - 1; i >= 0; i--) {
 while (cur->forward[i] && cur->forward[i]->value < value) {
 cur = cur->forward[i];
 }
 update[i] = cur; // 记录每层的前驱节点
 }
 
 cur = cur->forward[0];
 if (cur && cur->value == value) return false; // 重复值
 
 // 2. 随机生成新节点的层数
 int new_level = skiplist_random_level();
 if (new_level > sl->level) {
 for (int i = sl->level; i < new_level; i++) {
 update[i] = sl->header; // 新增层从头节点开始
 }
 sl->level = new_level;
 }
 
 // 3. 创建节点并插入每层
 SkipNode *new_node = skipnode_create(value, new_level);
 for (int i = 0; i < new_level; i++) {
 new_node->forward[i] = update[i]->forward[i];
 update[i]->forward[i] = new_node;
 }
 
 sl->size++;
 return true;
}

插入的指针操作图解:

graph LR
 subgraph "插入前"
 before["...更新update数组指向每层的前驱节点..."]
 end
 subgraph "插入后"
 after["new_node->forward[i] = update[i]->forward[i]\nupdate[i]->forward[i] = new_node"]
 end

3.5 跳表的查找与删除

SkipNode* skiplist_search(SkipList *sl, int value) {
 SkipNode *cur = sl->header;
 for (int i = sl->level - 1; i >= 0; i--) {
 while (cur->forward[i] && cur->forward[i]->value < value) {
 cur = cur->forward[i];
 }
 }
 cur = cur->forward[0];
 if (cur && cur->value == value) return cur;
 return NULL;
}
 
bool skiplist_delete(SkipList *sl, int value) {
 SkipNode *update[SKIPLIST_MAX_LEVEL];
 SkipNode *cur = sl->header;
 
 for (int i = sl->level - 1; i >= 0; i--) {
 while (cur->forward[i] && cur->forward[i]->value < value) {
 cur = cur->forward[i];
 }
 update[i] = cur;
 }
 
 cur = cur->forward[0];
 if (!cur || cur->value != value) return false;
 
 // 从每层移除
 for (int i = 0; i < sl->level; i++) {
 if (update[i]->forward[i] != cur) break;
 update[i]->forward[i] = cur->forward[i];
 }
 
 // 如果删除了最高层节点,降低 level
 while (sl->level > 1 && sl->header->forward[sl->level - 1] == NULL) {
 sl->level--;
 }
 
 free(cur->forward);
 free(cur);
 sl->size--;
 return true;
}

练习 3.5.1: 实现跳表的范围查询 skiplist_range_query(sl, low, high)

关于跳表的 C++ 实现(无标准库支持),参见 CPP: 跳表



第四节: B树简介 —— 为什么数据库用 B 树


4.1 B树的结构

B树是一种自平衡的多路搜索树,每个节点可以存储多个键和多个子节点。
B树为磁盘存储而设计——一个节点通常等于一个磁盘块(4KB~16KB)。

graph TD
 subgraph "B树(阶 m=3)"
 root["[15│30]"] --> child1["[5│10]"]
 root --> child2["[20│25]"]
 root --> child3["[35│40│45]"]
 child1 --> leaf1["[1│3]"]
 child1 --> leaf2["[7│9]"]
 child1 --> leaf3["[12│14]"]
 end

4.2 B树 vs 二叉搜索树

特性二叉搜索树B树
每个节点的子节点数≤2可配置(如 100+)
树的高度大(log₂ n)(log_m n)
磁盘读取次数O(log₂ n) 次O(log_m n) 次
缓存友好差(指针追踪)好(节点内数组)
适用场景内存磁盘/SSD

核心原理: 磁盘访问比内存访问慢 10^5 - 10^6 倍。B树的大节点减少了树的层数,
从而减少了磁盘读取次数。


4.3 简化的B树节点结构

#define B_TREE_ORDER 3 // 阶(最大子节点数)
 
typedef struct BTreeNode {
 int keys[B_TREE_ORDER - 1]; // 键(最多 order-1 个)
 struct BTreeNode *children[B_TREE_ORDER]; // 子节点
 int num_keys; // 当前键的数量
 bool is_leaf;
} BTreeNode;

对于一个阶为 m 的 B树:

  • 每个内部节点(非根)至少有 ⌈m/2⌉ 个子节点
  • 每个节点最多有 m 个子节点
  • 所有叶子在同一层

B树完整实现的插入(分裂)和删除(借用/合并)逻辑非常复杂,留作高级学习内容。
在 C++ 中 B树通常通过数据库引擎(如 SQLite)实现,无标准 STL 容器。参见 CPP: B树

练习 4.3.1: 计算对于 100 万条数据,B树(阶 100)的查找需要几次磁盘访问?二叉搜索树需要几次?



第五节: 线段树(Segment Tree)


5.1 线段树简介

线段树是一种二叉树,每个节点存储一段区间的聚合信息(和、最小值、最大值等),
支持 O(log n) 的区间查询和 O(log n) 的单点/区间更新。

graph TD
 root["[0,7] sum=36"] --> left["[0,3] sum=10"]
 root --> right["[4,7] sum=26"]
 left --> ll["[0,1] sum=3"]
 left --> lr["[2,3] sum=7"]
 right --> rl["[4,5] sum=11"]
 right --> rr["[6,7] sum=15"]
 ll --> ll1["[0] = 1"]
 ll --> ll2["[1] = 2"]
 lr --> lr1["[2] = 3"]
 lr --> lr2["[3] = 4"]
 rl --> rl1["[4] = 5"]
 rl --> rl2["[5] = 6"]
 rr --> rr1["[6] = 7"]
 rr --> rr2["[7] = 8"]

5.2 线段树的数组实现

与二叉堆类似,线段树可以用数组紧凑存储:

typedef struct {
 int *tree; // 线段树数组(4n 大小足够)
 int *lazy; // 懒标记数组(用于区间更新)
 int n; // 原数组大小
} SegTree;
 
SegTree* segtree_create(int n) {
 SegTree *st = (SegTree*)malloc(sizeof(SegTree));
 st->tree = (int*)calloc(4 * n, sizeof(int));
 st->lazy = (int*)calloc(4 * n, sizeof(int));
 st->n = n;
 return st;
}

为什么是 4n?

完全二叉树的节点数 < 4n。对于 n 个叶子的线段树,最坏情况下需要 4n 个节点。
实际使用 4n 保证任何情况下都不会溢出。

// 建树(递归)
void segtree_build(SegTree *st, const int *arr, int node,
 int start, int end) {
 if (start == end) {
 st->tree[node] = arr[start]; // 叶子节点
 return;
 }
 int mid = start + (end - start) / 2;
 int left_child = 2 * node + 1;
 int right_child = 2 * node + 2;
 
 segtree_build(st, arr, left_child, start, mid);
 segtree_build(st, arr, right_child, mid + 1, end);
 
 st->tree[node] = st->tree[left_child] + st->tree[right_child];
}

5.3 区间查询

int segtree_query(SegTree *st, int node, int start, int end,
 int ql, int qr) {
 // 完全在查询范围内
 if (ql <= start && end <= qr) {
 return st->tree[node];
 }
 // 完全不在查询范围内
 if (qr < start || end < ql) {
 return 0; // 对于和查询,返回 0
 }
 
 int mid = start + (end - start) / 2;
 int left_sum = segtree_query(st, 2 * node + 1, start, mid, ql, qr);
 int right_sum = segtree_query(st, 2 * node + 2, mid + 1, end, ql, qr);
 return left_sum + right_sum;
}

5.4 单点更新

void segtree_update_point(SegTree *st, int node, int start, int end,
 int idx, int value) {
 if (start == end) {
 st->tree[node] = value;
 return;
 }
 int mid = start + (end - start) / 2;
 if (idx <= mid) {
 segtree_update_point(st, 2 * node + 1, start, mid, idx, value);
 } else {
 segtree_update_point(st, 2 * node + 2, mid + 1, end, idx, value);
 }
 st->tree[node] = st->tree[2 * node + 1] + st->tree[2 * node + 2];
}

5.5 懒标记(Lazy Propagation)—— 区间更新

对于区间更新(如 [l,r] 内所有元素 +val),如果逐个单点更新会 O(n log n)。
懒标记将更新”推迟”到实际需要时才应用:

void segtree_push_down(SegTree *st, int node, int start, int end) {
 if (st->lazy[node] != 0) {
 st->tree[node] += (end - start + 1) * st->lazy[node]; // 应用懒标记
 
 if (start != end) {
 // 向子节点传播懒标记
 st->lazy[2 * node + 1] += st->lazy[node];
 st->lazy[2 * node + 2] += st->lazy[node];
 }
 st->lazy[node] = 0; // 清除当前节点的懒标记
 }
}
 
void segtree_update_range(SegTree *st, int node, int start, int end,
 int ul, int ur, int delta) {
 segtree_push_down(st, node, start, end); // 先处理之前的懒标记
 
 if (ur < start || end < ul) return; // 不在范围内
 if (ul <= start && end <= ur) {
 // 完全在范围内,设置懒标记
 st->lazy[node] += delta;
 segtree_push_down(st, node, start, end);
 return;
 }
 
 int mid = start + (end - start) / 2;
 segtree_update_range(st, 2 * node + 1, start, mid, ul, ur, delta);
 segtree_update_range(st, 2 * node + 2, mid + 1, end, ul, ur, delta);
 st->tree[node] = st->tree[2 * node + 1] + st->tree[2 * node + 2];
}

懒标记原理: 当对一个区间 [2,5] 做 +3 操作时,如果该区间恰好被线段树节点覆盖,
只在该节点的懒标记上记录 +3,不向下更新子节点。只有当后续查询需要访问子节点时,
才将懒标记”下推”(push down)。

sequenceDiagram
 participant update as update_range([2,5], +3)
 participant node as 线段树节点[2,5]
 participant lazy as lazy[node]

 update->>node: 区间完全匹配
 update->>lazy: lazy[node] += 3
 Note over node,lazy: 不更新子节点,O(log n)!

 participant query as query([3,4])
 query->>node: 需要子区间[3,4]的信息
 node->>lazy: push_down: tree[node] += (5-2+1)*3
 Note over lazy: 将 lazy 传给子节点
 node->>lazy: lazy[node] = 0

练习 5.5.1: 实现支持区间最小值查询的线段树(将 + 替换为 min 操作)。


5.6 线段树的使用场景

操作数组前缀和线段树
点查询O(1)O(1)O(log n)
点更新O(1)O(n)O(log n)
区间查询O(n)O(1)O(log n)
区间更新O(n)O(n)O(log n)

线段树在”既需要区间查询又需要区间更新”时不可替代。

关于线段树在竞赛中的更多变体(动态开点、可持久化线段树、树状数组对比),参见 CPP: 线段树CPP: 树状数组



章节测试


判断题(10题)

判断题 1

Trie(字典树)的查找时间复杂度与存储的单词数量成对数关系。

判断题 2

路径压缩和按秩合并使并查集的每次操作接近 O(1)。

判断题 3

跳表的查找、插入、删除操作平均时间复杂度为 O(log n)。

判断题 4

B树每个节点只能存储一个键。

判断题 5

线段树的区间查询时间复杂度是 O(log n)。

判断题 6

Trie 必须用后序遍历销毁(先释放子节点再释放父节点)。

判断题 7

并查集可以检测无向图中是否存在环。

判断题 8

跳表中的节点层数是确定的(根据插入顺序固定)。

判断题 9

线段树的懒标记(Lazy Propagation)用于加速单点查询。

判断题 10

B树的高度比同样数据量的二叉搜索树大。


选择题(10题)

选择题 1

在 Trie 中存储 “cat”, “car”, “dog”,共有几个节点(不含 root)?

  • A. 6

  • B. 8

  • C. 9

  • D. 10

选择题 2

路径压缩后的并查集中,执行 Find(5) 后,5 的 parent 指向?

  • A. 原来的父节点

  • B. 集合的代表元(根)

  • C. NULL

  • D. 自己

选择题 3

Redis 的有序集合(ZSet)底层使用什么数据结构?

  • A. 红黑树

  • B. 跳表 + 哈希表

  • C. B+树

  • D. 二叉堆

选择题 4

线段树数组大小通常取原数组大小的多少倍?

  • A. 2n

  • B. 3n

  • C. 4n

  • D. n²

选择题 5

以下哪个数据结构最适合实现自动补全(autocomplete)功能?

  • A. 哈希表

  • B. BST

  • C. Trie

  • D. 堆

选择题 6

B树设计的核心动机是?

  • A. 简化二叉搜索树的实现

  • B. 减少磁盘 I/O 次数

  • C. 加快内存中的查找速度

  • D. 支持并发操作

选择题 7

并查集的按秩合并中,“秩”的含义是?

  • A. 集合中元素的个数

  • B. 集合的创建时间

  • C. 以该节点为根的树的近似高度

  • D. 节点的查找次数

选择题 8

线段树的懒标记存在的目的是?

  • A. 让代码更简洁

  • B. 将区间更新延迟到实际需要时才执行,提高效率

  • C. 支持多线程并发

  • D. 减少内存使用

选择题 9

跳表中一个节点有 3 层的概率是(假设升级概率 p = 0.5)?

  • A. 1/2

  • B. 1/4

  • C. 1/8

  • D. 1/16

选择题 10

对于 n=100 万的并查集,同时使用路径压缩和按秩合并,单次操作的实际复杂度可达?

  • A. O(log n)

  • B. O(√n)

  • C. O(α(n)) ≈ O(1)

  • D. O(n)


编程大题

编程题 1:实现完整的 Trie 字典系统

要求

  1. 支持 insert、search、startsWith、delete、collectAllWords
  2. 支持大小写不敏感(全部转小写)
  3. 统计总节点数、总单词数、平均共享前缀长度
  4. 实现内存使用统计(trie_memory_usage()
  5. /usr/share/dict/words(或类似词库)做性能测试

提示: delete 操作需要清理不再被使用的节点。

编程题 2:并查集优化 —— 朋友圈问题

要求

  1. 读取 n 个人的朋友关系(m 条朋友对)
  2. 用并查集找出所有朋友圈(连通分量)
  3. 输出最大的朋友圈大小和所有朋友圈的成员列表
  4. 支持动态添加/删除朋友关系
  5. 分析删除朋友关系时并查集的局限性(并查集不支持高效删除)

提示: 并查集天生不支持删除操作。讨论如何通过”时间戳”或”重建”来解决。

编程题 3:线段树实现区间最值+修改的在线评测系统

要求

  1. 实现一个支持以下操作的线段树:
  • add(l, r, val): 区间 [l,r] 所有元素 +val
  • set(l, r, val): 区间 [l,r] 所有元素 = val
  • query(l, r): 查询区间 [l,r] 的最大值
  • query_sum(l, r): 查询区间 [l,r] 的和
  1. 使用懒标记处理两种更新(加法和赋值需要两个懒标记,注意优先级)
  2. 处理大数据量(10^6 个元素,10^5 个操作)
  3. 与暴力方法对比正确性

提示: 当同时有加法懒标记和赋值懒标记时,赋值的优先级最高(会覆盖之前的加法标记)。

推荐练习题(力扣)

知识点题目建议
Trie力扣Trie
并查集力扣并查集
并查集力扣并查集
线段树+懒标记力扣线段树
线段树+复合懒标记力扣线段树
线段树/分块力扣线段树


知识网络