哎,看到“831”这个词,我猜你此刻正对着堆积如山的参考书发呆,或者刚被一道红黑树的后继节点求法虐得体无完肤。别慌,把咖啡放下,咱们今天不整那些虚头巴脑的定义背诵,就来聊聊这831考试里那些让你“既爱又恨”的数据结构。我知道你很累,但结构这东西,一旦你参透了它的“脾气”,做题其实是一种享受。
别再把数组和链表当成亲兄弟了
很多同学在复习线性表这部分时,最容易犯的一个低级错误,就是把数组的逻辑结构和物理结构搞混。咱们先看一个真题里常出现的陷阱。
【真题重现】 假设有一个长度为 \(n\) 的数组 \(A\),如果要实现随机访问任意元素的时间复杂度为 \(O(1)\),那么下列哪种操作在数组中的时间复杂度最差? A. 查找最大值 B. 插入新元素 C. 删除指定位置元素 D. 遍历数组
【避坑解析】 乍一看,好像B和C都对,毕竟插入和删除都要移动元素。但这里有个大坑:题目问的是“最差”情况。
- 插入到第 \(i\) 个位置,平均移动 \(n/2\) 次,最坏(插到第一个)移动 \(n\) 次,复杂度 \(O(n)\)。
- 删除第 \(i\) 个位置,同理,最坏 \(O(n)\)。
- 查找最大值,需要遍历整个数组,也是 \(O(n)\)。
- 遍历数组,\(O(n)\)。
等等,四个都是 \(O(n)\)?别急,这时候要看常数项和具体场景。在831的某些学校真题中,它会考得更细。比如,如果题目问的是“有序数组中查找特定元素”,那答案是二分查找 \(O(\log n)\)。但如果只是普通数组,查找最大值确实是 \(O(n)\)。
这里我要纠正一个常见的误解:很多学生认为数组插入一定比链表慢。这在理论上是成立的,但在实际工程中,如果数据量很小(比如 \(n < 100\)),数组的局部性原理(Cache Locality)会让它的速度可能快过频繁分配内存节点的链表。考研虽然主要考理论,但理解“为什么”比死记“是什么”更重要。
【代码示例:理解数组动态扩容的代价】 为了让你更直观地理解为什么数组插入有时候会“突然慢下来”,我们用代码模拟一下 ArrayList 的扩容机制。
class DynamicArray:
def __init__(self):
self.data = []
self.size = 0
self.capacity = 0
def append(self, item):
# 当容量不足时,触发扩容
if self.size == self.capacity:
# 关键考点:扩容策略通常是翻倍
new_capacity = max(1, self.capacity * 2)
new_data = [None] * new_capacity
for i in range(self.size):
new_data[i] = self.data[i]
self.data = new_data
self.capacity = new_capacity
self.data[self.size] = item
self.size += 1
# 摊销分析:虽然单次插入最坏是O(n),但平摊下来是O(1)
你看,这段代码里的 for 循环看起来是 \(O(n)\),但这只是“偶发”事件。在831的考研题里,如果考到摊销复杂度(Amortized Complexity),一定要记得这种“偶尔的大开销,平摊后均摊到低成本”的思想。
栈与队列:不只是“先进后出”和“先进先出”
栈和队列是基础中的基础,但831的考题越来越喜欢结合括号匹配、表达式求值以及循环队列的边界判断来出题。
【高频考点:循环队列的空与满】 这是每年必考的计算题或选择题。很多同学死记硬背公式,结果考试一紧张就搞反。
假设循环队列用数组 Q[0..M-1] 存储,头指针 front,尾指针 rear。
- 判空条件:
front == rear - 判满条件:这里有两个派系,不同学校教材写法不同,务必确认你报考学校的指定教材!
- 派系A(少用一个元素):
(rear + 1) % M == front - 派系B(附加标志量
tag):front == rear && tag == 1为满,front == rear && tag == 0为空。
- 派系A(少用一个元素):
【真题陷阱】
题目:在一个长度为 \(M\) 的循环队列中,front = 0, rear = M-1,此时队列的状态是?
A. 满
B. 空
C. 无法判断
D. 队头在队尾后面
【深度解析】
这道题选 C. 无法判断。
为什么?因为 front = 0, rear = M-1 这个状态,在空队列(刚初始化)和满队列(存了M-1个元素,或者在派系B中标记为满)时都可能出现,取决于当前队列里到底有多少个元素,或者是否采用了特定的判满策略。如果没有给出“已存储元素个数”或“当前状态标记”,单纯看指针位置,是无法区分的。这就是典型的“信息不全,无法定论”。
树与二叉树:遍历序列的还原艺术
这部分是算法大题的常客。给你前序和中序,让你画树或者写后序;给你层序和中序,让你还原。
【核心逻辑:递归分解】 不要试图用脑子硬想,要建立“区间分割”的思维模型。
- 前序:根 -> 左 -> 右。第一个肯定是根。
- 中序:左 -> 根 -> 右。根的位置把数组分成左子树和右子树。
【实战案例】
前序:[1, 2, 4, 5, 3, 6]
中序:[4, 2, 5, 1, 6, 3]
步骤演示:
- 前序第一个是
1,所以根是1。 - 在中序里找
1,左边[4, 2, 5]是左子树,右边[6, 3]是右子树。 - 左子树有3个节点,回到前序,
1后面的3个[2, 4, 5]就是左子树的前序。 - 重复上述过程:左子树前序
2是根,中序4在2左边,5在2右边… 以此类推。
【易错点:哈夫曼树的带权路径长度】 很多同学会忘记哈夫曼树是严格二叉树(没有度为1的节点),并且在计算 WPL 时容易加错数。
题目:权值集合为 {2, 3, 7, 9, 15},求哈夫曼树的 WPL。
错误做法:直接把所有权值相加。
正确做法:模拟构建过程。
- 选最小的两个:2, 3 -> 合并为 5。集合变为
{5, 7, 9, 15}。 - 选最小的两个:5, 7 -> 合并为 12。集合变为
{9, 12, 15}。 - 选最小的两个:9, 12 -> 合并为 21。集合变为
{15, 21}。 - 选最小的两个:15, 21 -> 合并为 36。
WPL = 所有非叶子节点的权值之和 = \(5 + 12 + 21 + 36 = 74\)。 或者:\(2\times4 + 3\times4 + 7\times3 + 9\times2 + 15\times2 = 8 + 12 + 21 + 18 + 30 = 89\)… 等等,让我重新算一下深度。 叶子节点路径长度:
- 2, 3 在 deepest,路径长 4?不对,让我们画图确认。
- 根36
- 左15 (叶, 深度1)
- 右21
- 左9 (叶, 深度2)
- 右12
- 左5
- 左2 (叶, 深度4)
- 右3 (叶, 深度4)
- 右7 (叶, 深度3) WPL = \(15\times1 + 9\times2 + 7\times3 + 2\times4 + 3\times4 = 15 + 18 + 21 + 8 + 12 = 74\)。 没错,两种算法结果一致。记住:WPL 等于所有新生成的根节点权值之和,这是最快的方法。
- 根36
图论:Dijkstra 与 Prim 的“双胞胎”错觉
这是831里最容易出现概念混淆的地方。Dijkstra(单源最短路径)和 Prim(最小生成树)的算法代码结构极度相似,都是贪心策略,都有一个 lowcost 数组(或 dist 数组)和一个 visited 数组。
【辨析关键】
- Dijkstra:
dist[i]表示从源点到 \(i\) 的最短距离。每次选距离源点最近的未访问点,更新其邻居的距离。目的是路径最短。 - Prim:
lowcost[i]表示从已生成的树到 \(i\) 的最小边权。每次选离树最近的点加入树,更新其邻居的边权。目的是树的总权重最小。
【真题陷阱】 题目:用 Dijkstra 算法求从顶点 V0 到其他顶点的最短路径,当算法结束时,下列哪个性质一定成立? A. 所有边的权值都是正数 B. 从 V0 到任意顶点的最短路径是唯一的 C. 已访问顶点集合中的顶点,其最短路径已经确定,不会再被修改 D. 图中不存在负权边
【深度解析】 选 C。 Dijkstra 的核心就是贪心,一旦一个顶点被标记为“已访问”(即加入了集合 S),它的最短路径值就被认为是最优的,后续不会再改变。
- A 错:算法本身不要求权值为正,但如果有负权边,Dijkstra 会失效(这时候要用 Bellman-Ford)。题目没说图的特点,但算法逻辑本身不依赖权值为正这一前提来保证“已确定”,而是依赖“非负权边”才能保证贪心正确。等等,如果存在负权边,Dijkstra 是错误的。所以 D 也是前提条件,但 C 是对算法执行过程状态的准确描述。通常这类题选 C 更稳妥,因为它是算法过程的直接体现。
- B 错:最短路径可能不唯一(有多条路径长度相同)。
- D 错:这是算法适用的前提,而不是算法结束时的性质。如果图中有负权边,Dijkstra 根本不能正确运行,也就谈不上“算法结束时的性质”。
【代码对比:一眼看穿】
// Dijkstra: 找源点到各点的最短距离
void Dijkstra(Graph G, int v) {
for (int i = 0; i < G.n; i++) {
dist[i] = G.arc[v][i]; // 初始化为源点直接连接的边
if (dist[i] != Infinity) prev[i] = v;
}
S[0] = v; // 源点入集
for (int i = 1; i < G.n; i++) { // 还需选n-1个点
int min = Infinity, u = v;
for (int j = 0; j < G.n; j++)
if (j not in S && dist[j] < min) {
u = j; min = dist[j];
}
S[u] = 1; // u 入集
// 更新:源点到j的距离,是否可以通过u更短?
for (int j = 0; j < G.n; j++)
if (j not in S && dist[u] + G.arc[u][j] < dist[j]) {
dist[j] = dist[u] + G.arc[u][j];
prev[j] = u;
}
}
}
// Prim: 找最小生成树的边权
void Prim(Graph G, int v) {
for (int i = 0; i < G.n; i++) {
lowcost[i] = G.arc[v][i]; // 初始化为源点直接连接的边
if (lowcost[i] != Infinity) adjvex[i] = v;
}
S[0] = v;
for (int i = 1; i < G.n; i++) {
int min = Infinity, u = v;
for (int j = 0; j < G.n; j++)
if (j not in S && lowcost[j] < min) {
u = j; min = lowcost[j];
}
S[u] = 1; // u 入集,加入生成树
// 更新:j到树的距离,是否可以通过u更短?
for (int j = 0; j < G.n; j++)
if (j not in S && G.arc[u][j] < lowcost[j]) {
lowcost[j] = G.arc[u][j];
adjvex[j] = u;
}
}
}
看这两段代码,除了变量名 dist vs lowcost,prev vs adjvex,逻辑几乎一模一样!这就是为什么考试喜欢考辨析,让你指出更新逻辑中的差别:Dijkstra 更新的是 dist[u] + weight,Prim 更新的是 weight(因为 Prim 关心的是连接树的那条边的权值,而不是从源点出发的累积距离)。
排序算法:不仅要看时间,还要看稳定性
831 经常考排序的稳定性和空间复杂度。
| 算法 | 最好 | 平均 | 最坏 | 空间 | 稳定性 | 适用场景 |
|---|---|---|---|---|---|---|
| 冒泡 | \(O(n)\) | \(O(n^2)\) | \(O(n^2)\) | \(O(1)\) | 稳定 | 小规模,基本有序 |
| 快速 | \(O(n\log n)\) | \(O(n\log n)\) | \(O(n^2)\) | \(O(\log n)\) | 不稳定 | 大规模,平均性能最优 |
| 直接插入 | \(O(n)\) | \(O(n^2)\) | \(O(n^2)\) | \(O(1)\) | 稳定 | 小规模,基本有序 |
| 堆排序 | \(O(n\log n)\) | \(O(n\log n)\) | \(O(n\log n)\) | \(O(1)\) | 不稳定 | 大规模,要求最坏情况可控 |
| 归并 | \(O(n\log n)\) | \(O(n\log n)\) | \(O(n\log n)\) | \(O(n)\) | 稳定 | 大规模,外排序基础 |
【避坑指南】
- 快速排序的最坏情况:当序列已经有序或基本有序时,如果每次选择第一个元素作为基准(pivot),快速排序会退化为 \(O(n^2)\)。为了避免这个坑,实际应用中常采用“三者取中”法或随机选取基准。
- 堆排序不是稳定的:很多学生误以为堆排序稳定,其实不然。因为在调整堆的过程中,相同值的元素可能会发生跨距离的交换,破坏稳定性。
- 内排序 vs 外排序:如果数据量太大,内存放不下,就必须用外排序(如多路归并)。这时候,归并排序的思想是核心,而快速、堆排序等内排序算法无法直接使用。
【代码示例:快速排序的分区优化】
def quick_sort(arr, low, high):
if low < high:
# 三者取中法,防止有序数组退化
mid = (low + high) // 2
if arr[low] > arr[mid]:
arr[low], arr[mid] = arr[mid], arr[low]
if arr[low] > arr[high]:
arr[low], arr[high] = arr[high], arr[low]
if arr[mid] > arr[high]:
arr[mid], arr[high] = arr[high], arr[mid]
# 将pivot放到high-1的位置,或者直接用high作为pivot,这里演示经典挖坑法
pivot = arr[high]
i, j = low, high
while i < j:
while i < j and arr[i] <= pivot:
i += 1
arr[j] = arr[i]
while i < j and arr[j] >= pivot:
j -= 1
arr[i] = arr[j]
arr[i] = pivot
quick_sort(arr, low, i - 1)
quick_sort(arr, i + 1, high)
查找:二叉排序树与平衡二叉树
【BST 的删除节点】 这是大题高频点。删除节点有三种情况:
- 叶子节点:直接删除,父节点对应指针置空。
- 只有左子树或只有右子树:将子树直接接到被删节点的父节点上。
- **左右子树都有
