行至图深处,自有路标出
六月中旬了,时间快得让人心慌。
成都的夏天从来不跟你商量,说热就热。前几天天气还是比较阴,有时候还在下雨(伦敦的天气未必有成都阴郁😎),甚至感觉有点凉爽,结果今天就飙到28°。中午从食堂走回图书馆,大理石路面都被烤得发光,直晃眼睛,热浪直往脸上扑。回想这一个多月,过得像开了倍速 —— 写不完的思想汇报,翻来覆去改材料,一轮轮谈话、外调……直到前几天在党旗下宣誓,正式成为预备党员的那一刻,心里才踏实下来。那段忙碌总算有了着落。
等缓过神来,日历已经翻到六月。看看桌上的 408 复习资料,心里咯噔一下:一轮还没收尾,数学题堆成山,《数据结构》刚磕到第六章 —— 图。
身边有的研友早就开始二轮复习并刷真题了,说不慌是假的。每次闭馆往回走,脑子里都在盘算进度,越算越焦虑。但急也没用,落下的只能熬夜补。这几天总算静下来了,重新坐回那个固定位置,摸到熟悉的键盘和那支硬笔钢笔,状态慢慢回来了。
今天复习到图的最短路径、拓扑排序以及关键路径,这块是整个章节里最硬的部分。写篇笔记,把逻辑捋清楚,也给重新启航的自己扎个锚。
图论里最经典的问题之一:在一张带权图里,怎么找到从一个点到另一个点的最短路径?根据问题规模不同,分三种情况讨论 —— 单源无权图用 BFS,单源带权图用 Dijkstra,多源带权图用 Floyd。说到 Dijkstra,就不得不提它的发明者 Edsger W. Dijkstra,这位大佬不仅在最短路径上留了一笔,操作系统里那个经典的银行家算法也是他搞出来的。膜拜大佬🙌。
BFS 求最短路径(无权图)
如果图上每条边都没有权重(或者说权重都相等,默认为 1),那问题就简单了 —— BFS 天生就能找到最短路径。
BFS 是一层一层往外扩的,先被访问到的顶点,一定离起点更近。所以从起点开始跑一遍 BFS,记录每个顶点的”层数”,这层数就是最短路径长度。
#include <stdio.h>
#include <stdbool.h>
#define MAX_VERTEX_NUM 100
/**
* @brief 使用广度优先搜索 (BFS) 计算无权图的单源最短路径
* * @param graph 邻接矩阵表示的图,graph[v][w] != 0 表示 v 到 w 有边
* @param n 图中顶点的总数
* @param start 起始顶点的索引
* @param dist 输出数组:存放起点到各顶点的最短距离(-1 表示不可达)
* @param path 输出数组:存放最短路径上每个顶点的前驱节点(用于回溯路径,-1 表示无前驱)
*/
void BFS_ShortestPath(int graph[MAX_VERTEX_NUM][MAX_VERTEX_NUM], int n, int start, int dist[], int path[]) {
// 1. 初始化访问标记数组,false 表示未访问,true 表示已访问
bool visited[MAX_VERTEX_NUM] = {false};
// 2. 初始化队列及头尾指针(采用顺序队列模拟)
int queue[MAX_VERTEX_NUM];
int front = 0, rear = 0;
// 3. 初始化距离数组和路径数组
for (int i = 0; i < n; i++) {
dist[i] = -1; // 默认所有顶点不可达
path[i] = -1; // 默认没有前驱节点
}
// 4. 起点入队处理
visited[start] = true; // 标记起点已访问
dist[start] = 0; // 起点到自己的距离为 0
queue[rear++] = start; // 起点入队
// 5. 队列不为空时循环遍历
while (front < rear) {
int v = queue[front++]; // 出队当前顶点 v
// 遍历所有可能的邻接点 w
for (int w = 0; w < n; w++) {
// 如果 v 和 w 之间有边,且 w 尚未被访问过
if (graph[v][w] != 0 && !visited[w]) {
visited[w] = true; // 标记 w 已访问
dist[w] = dist[v] + 1; // w 的最短距离等于 v 的距离加 1
path[w] = v; // 记录 w 的前驱节点是 v
queue[rear++] = w; // 将 w 入队,以便后续扩展
}
}
}
}BFS 的写法很干净,没什么坑。唯一的限制:只能处理无权图或每条边权值相等的情况。如果边权不一样,BFS 就不管用了 —— 它只看层数,不认权重。这时候就得请出 Dijkstra。
Dijkstra 算法(单源带权图)
最常见的问题:从一个起点出发,怎么走到图中任意顶点的距离最短?
Dijkstra 的解法有种一步一个脚印的稳健感。核心思想是贪心 —— 把顶点分成两个阵营:已经找到最短路径的“确定区”集合 ,和还在等待的“未知区”集合 。
每一步,在未知区里找一个离起点最近的顶点,拉进确定区。然后以这个新加入的点为跳板,刷新它周围所有邻接点的距离。这个不断“拉人进组、刷新认知”的过程,跟一轮复习挺像的 —— 先把最笃定的知识点啃下来,再以此为基础去摸索更远的领域。
408 选择题极其喜欢考辅助数组的变化,用 C 语言手写一遍 Dijkstra 的底层逻辑是逃不掉的。
#include <stdio.h>
#include <stdbool.h>
#define INFINITY 65535
#define MAX_VERTEX_NUM 100
/**
* @brief Dijkstra 算法计算带权图的单源最短路径
* * @param matrix 邻接矩阵,matrix[i][j] 表示 i 到 j 的边权值,不可达则为 INFINITY
* @param vex_num 顶点总数
* @param v0 源点(起点)的索引
* @param dist 输出数组:存放 v0 到各个顶点的当前已知最短距离
* @param path 输出数组:存放最短路径上每个顶点的前驱节点索引
*/
void Dijkstra(int matrix[MAX_VERTEX_NUM][MAX_VERTEX_NUM], int vex_num, int v0, int dist[], int path[]) {
// final[i] 为 true 表示已求得 v0 到顶点 i 的最短路径(即 i 已加入确定集合)
bool final[MAX_VERTEX_NUM];
// ==========================================
// 阶段一:初始化阶段
// ==========================================
for (int i = 0; i < vex_num; i++) {
final[i] = false; // 初始时所有顶点都未确定最短路径
dist[i] = matrix[v0][i]; // 将与源点 v0 直连的边权值赋给 dist
// 如果 v0 与顶点 i 之间有直连边
if (dist[i] < INFINITY) {
path[i] = v0; // i 的前驱节点暂时设为 v0
} else {
path[i] = -1; // 不直连则暂无前驱
}
}
dist[v0] = 0; // 源点到自身的距离显然为 0
final[v0] = true; // 源点直接纳入已确定集合
path[v0] = -1; // 源点没有前驱节点
// ==========================================
// 阶段二:主循环,每次找出一个距离最近的顶点纳入集合
// ==========================================
// 只需要循环 vex_num - 1 次(剩余未确定的顶点数)
for (int i = 1; i < vex_num; i++) {
int min = INFINITY; // 记录当前未确定顶点中的最小距离
int v = v0; // 记录最小距离顶点的下标
// 核心步骤 1:贪心选择
// 在所有未确定的顶点中,找出距离源点 v0 最近的顶点 v
for (int w = 0; w < vex_num; w++) {
if (!final[w] && dist[w] < min) {
v = w;
min = dist[w];
}
}
// 如果最小距离依然是 INFINITY,说明剩下的未确定顶点与源点完全不连通,直接退出
if (min == INFINITY) break;
// 将选出的顶点 v 标记为已确定
final[v] = true;
// 核心步骤 2:松弛操作 (Relaxation)
// 借助刚刚新加入的顶点 v,尝试更新源点到其他未确定顶点 w 的最短距离
for (int w = 0; w < vex_num; w++) {
// 条件:w 未确定 && v 与 w 之间有边 && (通过 v 中转的距离 < 之前的已知距离)
if (!final[w] && matrix[v][w] < INFINITY && (min + matrix[v][w] < dist[w])) {
dist[w] = min + matrix[v][w]; // 更新更小的距离
path[w] = v; // 修改 w 的前驱节点为 v
}
}
}
}408 踩坑点: 做选择题手算模拟时,死死记住一条 —— 每一次刷新数组,都是且仅是基于当前最新加入 final 集合的那一个顶点的延伸。另外,Dijkstra 不能处理负权边,这算是一个经典面试题了:为什么不能有负权?因为一旦某条边是负的,之前已经确定“最短”的路径,后面可能还能通过绕路变得更短,贪心的前提就崩了。
再用 C# 写一遍,换个视角看算法骨架:
using System;
public class GraphSolver
{
// 定义正无穷大为整型的最大值
private const int Infinity = int.MaxValue;
/// <summary>
/// 使用 Dijkstra 算法计算带权图的单源最短路径(C# 改进版)
/// </summary>
/// <param name="adjMatrix">邻接矩阵,adjMatrix[i, j] 表示边权值,不可达则为 int.MaxValue</param>
/// <param name="vertexCount">顶点总数</param>
/// <param name="source">源点(起点)索引</param>
/// <param name="dist">输出参数:从源点到各个顶点的最短距离数组</param>
/// <param name="path">输出参数:各个最短路径上节点的父节点(前驱)数组</param>
public static void DijkstraSolve(int[,] adjMatrix, int vertexCount, int source, out int[] dist, out int[] path)
{
// 进行安全校验
if (adjMatrix == null || vertexCount <= 0 || source < 0 || source >= vertexCount || adjMatrix.GetLength(0) < vertexCount || adjMatrix.GetLength(1) < vertexCount)
{
dist = Array.Empty<int>();
path = Array.Empty<int>();
return false;
}
// 1. 分配输出数组与状态数组的空间
dist = new int[vertexCount];
path = new int[vertexCount];
bool[] isVisited = new bool[vertexCount];
// 2. 初始化数据
for (int i = 0; i < vertexCount; i++)
{
dist[i] = adjMatrix[source, i]; // 初始距离为源点直连的边权
isVisited[i] = false; // 初始所有节点均未访问
// 如果源点与 i 直连且 i 不是源点本身,前驱设为 source,否则设为 -1
path[i] = (dist[i] != Infinity && i != source) ? source : -1;
}
dist[source] = 0; // 源点到自身的距离为 0
isVisited[source] = true; // 标记源点已访问并确定最短路径
// 3. 主循环:对其余的 vertexCount - 1 个节点进行贪心扩展
for (int count = 1; count < vertexCount; count++)
{
int u = -1;
int minDistance = Infinity;
// 核心步骤 1:寻找当前未访问节点中,距离源点最近的节点 u
for (int v = 0; v < vertexCount; v++)
{
if (!isVisited[v] && dist[v] < minDistance)
{
minDistance = dist[v];
u = v;
}
}
// 如果找不到可达的节点(剩余节点与源点均不连通),提前跳出循环
if (u == -1) break;
// 将节点 u 标记为已访问(加入确定集合)
isVisited[u] = true;
// 核心步骤 2:松弛操作 (Relaxation)
// 利用新加入的节点 u,尝试更新其所有邻接点 v 的最短路径
for (int v = 0; v < vertexCount; v++)
{
// 条件:v 未访问 && u 可达 && u 到 v 有边直连
if (!isVisited[v] && dist[u] != Infinity && adjMatrix[u, v] != Infinity)
{
// 使用减法代替加法进行比较 (dist[u] < dist[v] - adjMatrix[u, v])
// 避免 dist[u] + adjMatrix[u, v] 导致的整型溢出回绕
if (dist[u] < dist[v] - adjMatrix[u, v])
{
dist[v] = dist[u] + adjMatrix[u, v]; // 更新更短的距离
path[v] = u; // 更新前驱节点
}
}
}
}
}
}Floyd 算法(多源带权图)
Dijkstra 一次只能求一个起点到其他点的最短路径。如果需要知道任意两个顶点之间的最短路径,就要用 Floyd。
Floyd 不贪心,而是动态规划。核心思路是“借路”:对于每一对顶点 ,依次尝试让顶点 0、1、2……作为中转节点 ,看看 是否比 更短。这个做法有个好听的名字叫“多阶段决策”,跟求传递闭包的 Warshall 算法思路很像,对比着记效率更高。
#include <stdio.h>
#include <stdbool.h>
#define INFINITY 65535
#define MAX_VERTEX_NUM 100
/**
* @brief Floyd-Warshall 算法计算图中任意两点间的最短路径(多源最短路径)
* * @param matrix 输入数组:原始图的邻接矩阵
* @param n 顶点总数
* @param dist 输出数组:dist[i][j] 存放顶点 i 到顶点 j 的当前最短距离
* @param path 输出数组:path[i][j] 存放 i 到 j 的最短路径上,j 的前驱节点
*/
void Floyd(int matrix[MAX_VERTEX_NUM][MAX_VERTEX_NUM], int n, int dist[MAX_VERTEX_NUM][MAX_VERTEX_NUM], int path[MAX_VERTEX_NUM][MAX_VERTEX_NUM]) {
// ==========================================
// 阶段一:初始化阶段
// ==========================================
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
// 初始时,两点间的最短距离就是它们直连边的权值
dist[i][j] = matrix[i][j];
// 初始化前驱路径:
// 如果 i 和 j 不是同一个点,且有边直连(距离小于无穷大),则 j 的前驱暂时设为 i;
// 否则(自己到自己,或者不直连),前驱设为 -1
path[i][j] = (i != j && matrix[i][j] < INFINITY) ? i : -1;
}
}
// ==========================================
// 阶段二:动态规划核心三层循环
// ==========================================
// 外层循环 k:代表当前允许中转的中间顶点索引(从 0 到 n-1)
// 算法的核心思想是:依次允许节点 0, 1, 2...k 作为中转点,逐步刷新所有点对的最短距离
for (int k = 0; k < n; k++) {
// 中层循环 i:路径的起点
for (int i = 0; i < n; i++) {
// 内层循环 j:路径的终点
for (int j = 0; j < n; j++) {
// 核心状态转移方程判断:
// 如果从 i 经过中转点 k 到达 j 的距离,比当前已知的 i 直达 j 的距离更短
if (dist[i][k] + dist[k][j] < dist[i][j]) {
// 更新最优距离:i 到 j 的距离缩短为两段相加
dist[i][j] = dist[i][k] + dist[k][j];
// 更新前驱节点:
// 因为现在从 i 到 j 的后半段是“从 k 到 j”,
// 所以 j 的前驱节点应当变成“从 k 到 j”这条路径上 j 的前驱,即 path[k][j]
path[i][j] = path[k][j];
}
}
}
}
}408 手算技巧: 初始矩阵就是邻接矩阵,无边填 。依次用顶点 0、1、2……作为 ,每一轮扫描整个矩阵看 是否更短。考场上问“这一步 等于几”,就看被更新的路径都经过了哪个公共顶点。Floyd 还有一个好处 —— 它能处理负权边,但不能有负环(判断方法:跑完后看对角线上有没有出现负值)。
| 算法 | 适用场景 | 核心思想 | 负权边限制 | 时间复杂度 |
|---|---|---|---|---|
| BFS | 无权图(等权边)单源最短路 | 广度优先搜索 | 无负权概念,不涉及 | 或 |
| Dijkstra | 带权图单源最短路 | 贪心 | 不支持负权边 | (普通版) |
| Floyd | 带权图多源最短路 | 动态规划 | 支持负权,但不可存在负权环 |
拓扑排序
拓扑排序处理的是有向无环图(DAG)。它要回答的问题是:如果一堆任务之间有先后依赖关系,应该按什么顺序执行?
这种场景太常见了 —— 比如大学选课,先修课没修就不能选后续课。408 里也经常出这种应用题,给一张 AOV 网(用顶点表示活动),让你排个合理的执行顺序。
核心操作:每次选一个入度为 0 的顶点输出,然后删掉它出发的所有边,重复直到所有顶点都输出完。如果最后还有顶点没输出,说明图里有环,拓扑排序失败。
#include <stdbool.h>
#include <stdio.h>
#define MAX_VERTEX_NUM 100
/**
* @brief 拓扑排序(基于 Kahn 算法,引入安全检查并改用队列实现 BFS 层次序)
* * @param graph 输入参数:二维数组邻接矩阵。graph[i][j] != 0 表示有一条从顶点 i 指向顶点 j 的有向边
* @param n 输入参数:图中顶点的实际有效数量
* @param result 输出参数:一维数组,用于顺序存放拓扑排序成功后的顶点序列
* * @return true 排序成功:图是一个有向无环图 (DAG),result 中包含完整的拓扑序列
* @return false 排序失败:图中存在有向环(回路),无法完成拓扑排序
*/
bool TopologicalSort(int graph[MAX_VERTEX_NUM][MAX_VERTEX_NUM], int n, int result[]) {
// ==========================================
// 1. 安全边界校验
// ==========================================
// 防御性编程:防止外部传入空指针、不合法的节点数,或者超出数组预设最大值的范围,从而避免运行时崩溃
if (n <= 0 || n > MAX_VERTEX_NUM || graph == NULL || result == NULL) {
return false;
}
// ==========================================
// 2. 统计所有顶点的初始入度
// ==========================================
// indegree[i] 存储顶点 i 的入度(即有多少条有向边直接指向顶点 i)
int indegree[MAX_VERTEX_NUM] = {0};
// 遍历整个邻接矩阵
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
// 如果 graph[i][j] 不为 0,说明存在一条 i -> j 的有向边
if (graph[i][j] != 0) {
indegree[j]++; // 顶点 j 的依赖项(入度)增加 1
}
}
}
// ==========================================
// 3. 初始化顺序队列模拟 BFS
// ==========================================
// 用普通的数组和头尾指针来模拟一个标准队列
int queue[MAX_VERTEX_NUM];
int front = 0; // 队头指针,用于出队操作
int rear = 0; // 队尾指针,用于入队操作
// ==========================================
// 4. 初始入度为 0 的顶点入队
// ==========================================
// 寻找图中所有不需要任何先修条件(入度为 0)的孤立点或起始点
for (int i = 0; i < n; i++) {
if (indegree[i] == 0) {
queue[rear++] = i; // 将顶点 i 推入队尾,同时队尾指针后移
}
}
// count 计数器:专门用来统计最终成功加入拓扑序列的顶点总数
int count = 0;
// ==========================================
// 5. 核心循环:队列不为空时持续迭代
// ==========================================
// 当 front == rear 时说明队列已空,循环结束
while (front < rear) {
// 出队操作:遵从先进先出(FIFO)原则,从队头弹出一个当前无依赖的顶点 v
int v = queue[front++];
// 记录结果:将该顶点放入最终的拓扑序列数组中
result[count++] = v;
// 消除依赖:遍历顶点 v 指向的所有邻接点 w
for (int w = 0; w < n; w++) {
if (graph[v][w] != 0) { // 发现一条从 v 指向 w 的有向边
indegree[w]--; // 因为顶点 v 已经“被解决”,所以它的下游顶点 w 的入度减 1
// 动态检查:如果在消去 v 的影响后,w 的入度也变成了 0
// 说明 w 的所有前置依赖全部被清除,它已经具备了执行条件
if (indegree[w] == 0) {
queue[rear++] = w; // 将 w 送入队尾,等待后续处理
}
}
}
}
// ==========================================
// 6. 判断拓扑排序结果并返回
// ==========================================
// 如果 count == n,说明所有的顶点都成功出过队并加入了拓扑序,图是无环的 (DAG)
// 如果 count < n,说明图中有部分节点构成了环路(有向环上的节点入度永远不可能减到 0,因此无法入队)
return count == n;
}考点: DFS 也能做拓扑排序 —— 在 DFS 回溯时把顶点压入栈,最后出栈顺序就是逆拓扑序。其实这背后有个直觉:DFS 越深的点,依赖链越长,回溯时越晚被访问完,自然应该排在前面(逆序输出)。考场上遇到手算题,记住先找入度为 0 的,一步步删就行。如果某一步同时有多个入度为 0 的顶点,拓扑序列就不唯一 —— 这也可能会出现在选择题的选项辨析里。
关键路径
关键路径是在拓扑排序的基础上,解决“整个工程最短需要多长时间完成”的问题。它找出项目里那些绝对不能拖延的任务 —— 这些任务组成的路径就是关键路径。注意区分,拓扑排序用的是 AOV 网(顶点表示活动),而关键路径用的是 AOE 网(边表示活动,顶点表示事件),别搞混了。
需要计算四个量:
- ve(v):事件 v 的最早发生时间(正着推,取 max)
- vl(v):事件 v 的最迟发生时间(倒着推,取 min)
- e(i):活动 i 的最早开始时间 = ve(弧尾)
- l(i):活动 i 的最迟开始时间 = vl(弧头) - 活动耗时
当 时,活动 就在关键路径上。用大白话说:这个活动一点都不能拖,晚一天整个项目就晚一天。
#include <stdbool.h>
#include <stdio.h>
#define MAX_VERTEX_NUM 100
bool TopologicalSort_Improved(int graph[MAX_VERTEX_NUM][MAX_VERTEX_NUM], int n, int result[]);
/**
* @brief 关键路径算法
*/
bool CriticalPath_Improved(int graph[MAX_VERTEX_NUM][MAX_VERTEX_NUM], int n) {
int ve[MAX_VERTEX_NUM] = {0};
int vl[MAX_VERTEX_NUM];
int topo[MAX_VERTEX_NUM];
// 1. 拓扑排序校验
if (!TopologicalSort_Improved(graph, n, topo)) {
return false; // 有环图直接退出
}
// 2. 正推计算 ve (Earliest Event Time)
for (int i = 0; i < n; i++) {
int v = topo[i];
for (int w = 0; w < n; w++) {
if (graph[v][w] != 0) { // 存在弧 <v, w>
if (ve[v] + graph[v][w] > ve[w]) {
ve[w] = ve[v] + graph[v][w]; // 取最大值
}
}
}
}
// 获取整个工程的终点事件和总工期
int end_vertex = topo[n - 1];
int total_project_time = ve[end_vertex];
// 3. 初始化 vl 数组(全部置为总工期)
for (int i = 0; i < n; i++) {
vl[i] = total_project_time;
}
// 4. 逆推计算 vl (Latest Event Time)
// 从拓扑序列的倒数第二个节点开始向前逆推(终点的 vl 已经等于总工期,无需计算)
for (int i = n - 2; i >= 0; i--) {
int v = topo[i];
for (int w = 0; w < n; w++) {
if (graph[v][w] != 0) { // 找到 v 的后置事件 w
// 事件 v 的最迟发生时间,必须满足其所有后置活动的要求,故取最小值
if (vl[w] - graph[v][w] < vl[v]) {
vl[v] = vl[w] - graph[v][w];
}
}
}
}
// 5. 遍历所有边,计算活动的最早开始时间 e 和最迟开始时间 l,找出关键活动
printf("--- 关键路径分析结果 ---\n");
for (int v = 0; v < n; v++) {
for (int w = 0; w < n; w++) {
if (graph[v][w] != 0) {
int e = ve[v]; // 活动 <v, w> 的最早开始时间等于起点事件的最早发生时间
int l = vl[w] - graph[v][w]; // 活动 <v, w> 的最迟开始时间等于终点事件最迟发生时间减去活动持续时间
if (e == l) {
printf("关键活动: 顶点 %d -> 顶点 %d (持续时间: %d)\n", v, w, graph[v][w]);
}
}
}
}
printf("------------------------\n");
return true;
}手算速记: 拓扑排序→正推 ve(取 max)→逆推 vl(取 min)→算每条边的 e 和 l→相等的就是关键活动。整个工程的最短工期就是终点的 值。408 偶尔还会问“缩短某个活动能不能缩短总工期”,答案就一句:只有缩短关键路径上的活动才有效,而且缩短之后要重新检查关键路径有没有发生变化。
核心逻辑口诀小结:
- 事件最早来():顺着拓扑序正推,遇到多条支路汇合,取最大值(
MAX)。 - 事件最迟走():逆着拓扑序逆推,遇到多条支路分叉,取最小值(
MIN)。 - 活动不磨叽():活动的最早开始时间和最迟开始时间完全相等 —— 余量为零,就是关键路径。
这一个多月落下不少进度。别人在一轮末尾查漏补缺,我还在跟邻接表、十字链表死磕。有时候也挺烦躁,尤其是数学证明题做不出来(我真的不喜欢证明题与概念题,写这两种题型时,半天‘憋’不出一个思路,真的很难受!哎,但恨归恨,考场上这两类题也会考,后续还得多练,硬着头皮也得把套路摸熟。)、专业课的代码又写出一堆 bug 的时候。
六月过了三分之二,上海交大还是个挺远的目标。其实想想,人生也像一张图,到处都是节点和带权值的边。前段时间正式被接收为预备党员,算是给这条路标了个最重要的节点。接下来就是一步步走,像 Dijkstra 那样,不去看整张图有多大、别人走得多快,只管每一步踩在当前最踏实的路径上。
图论这章硬骨头啃得差不多了,明天开始刷配套的习题,后续就开始下一章 —— 查找。保持节奏,继续走。