前面六篇我们把分治、DP、贪心、回溯、递归递推都聊完了。今天回到最底层——链表、树、图。这三大结构在第四题里不考高深理论,考的是**指针操作、遍历顺序、邻接矩阵/表**这些基本功。2022 年下半年第四题就考了二叉树非递归中序遍历,链栈 + 二叉链表,指针飞来飞去 citeweb_search:21#3。
一、第四题里的数据结构:考的是"动手"不是"动嘴"
软件设计师下午第四题的考纲里明确写着:链表、栈、二叉树、图的基本操作程序实现。这类题的特点是:
-
代码量大:通常会给完整的结构体定义 + 算法框架,挖 3~4 个空;
-
指针操作多:
->next、->lchild、->rchild、->link这些箭头是填空的常客; -
遍历顺序必考:中序遍历(左根右)是出现频率最高的,其次是先序、后序、层次;
-
辅助结构:非递归遍历几乎都要借助栈,图的遍历要借助
visited数组。
和前面几篇的"算法策略"不同,这一篇没有复杂的递归式,也没有状态转移方程。核心就是把指针指对、把顺序走对、把空填对。
二、案例一:二叉树非递归中序遍历(2022 年真题风格)
中序遍历的顺序是左 → 根 → 右。递归写法很简单,但非递归写法需要借助栈来保存路径,因为代码要自己管理"回溯"的过程。2022 年下半年第四题考的就是这个,而且用的是链栈(不含头节点的单向链表)。
思路拆解
-
从根节点出发,一路向左,沿途节点全部入栈;
-
左子树走到头了,出栈一个节点,访问它(这就是"根");
-
然后转向该节点的右子树,重复步骤 1;
-
直到栈为空且当前指针也为空,遍历结束。
完整代码
#include <stdio.h>
#include <stdlib.h>
/* 二叉树节点(二叉链表) */
typedef struct BtNode {
char data;
struct BtNode *lchild, *rchild;
} BtNode, *BTree;
/* 链栈节点(不含头节点) */
typedef struct StNode {
BTree elem; /* 存的是二叉树节点的指针 */
struct StNode *link; /* 指向下一个栈节点 */
} StNode;
/* 访问节点 */
void visit(BTree p) {
printf("%c ", p->data);
}
/* 非递归中序遍历 */
int InOrder(BTree root) {
BTree ptr; /* 当前遍历指针 */
StNode *q; /* 临时栈节点指针 */
StNode *stacktop = NULL; /* 栈顶指针,NULL 表示空栈 */
ptr = root;
/* 循环继续条件:当前节点非空 或 栈非空 */
while (ptr != NULL || stacktop != NULL) {
/* 一路向左,沿途入栈 */
while (ptr != NULL) {
q = (StNode *)malloc(sizeof(StNode));
q->elem = ptr; /* 当前节点地址入栈 */
q->link = stacktop; /* 新节点指向原栈顶 */
stacktop = q; /* 更新栈顶 */
ptr = ptr->lchild; /* 进入左子树 */
}
/* 左子树走到头,出栈访问 */
q = stacktop; /* q 指向栈顶 */
stacktop = stacktop->link; /* 栈顶下移(出栈) */
ptr = q->elem; /* 取出二叉树节点 */
visit(ptr); /* 访问(中序:左根右,此时根出栈) */
ptr = ptr->rchild; /* 进入右子树 */
}
return 0;
}
/* 辅助函数:创建节点 */
BTree createNode(char data) {
BTree p = (BTree)malloc(sizeof(BtNode));
p->data = data;
p->lchild = p->rchild = NULL;
return p;
}
int main() {
/* 手动构建一棵简单的二叉树
A
/ \
B C
/ \
D E
*/
BTree root = createNode('A');
root->lchild = createNode('B');
root->rchild = createNode('C');
root->lchild->lchild = createNode('D');
root->lchild->rchild = createNode('E');
printf("中序遍历: ");
InOrder(root); /* 输出: D B E A C */
return 0;
}
关键点解析
1. 链栈的操作
-
入栈:
q->link = stacktop; stacktop = q;这是头插法,新节点成为新栈顶; -
出栈:
q = stacktop; stacktop = stacktop->link;栈顶下移,原栈顶由 q 保存。
2. 循环条件 while (ptr != NULL || stacktop != NULL) 表示:只要当前还有节点没处理(ptr 非空),或者栈里还有备份(栈非空),循环就继续。两者都空时才结束。
3. 中序的"左根右"怎么体现?
-
ptr->lchild是"左":一直入栈、往左走; -
visit(ptr)是"根":出栈时访问; -
ptr->rchild是"右":访问完根后转向右子树。
4. 时间复杂度 每个节点入栈一次、出栈一次,时间复杂度 O(n),空间复杂度 O(h),h 为树高(递归栈/链栈的最大深度)。
三、案例二:图的深度优先搜索(邻接矩阵版)
图的遍历在软考里也是常客,上午题考遍历序列,下午题可能给邻接矩阵让你补全 DFS/BFS 代码。DFS 的核心是"一条路走到黑,走不通再回头",和回溯法有点像,但图有 visited 数组防止重复访问。
思路拆解
-
从某个顶点 v 出发,标记
visited[v] = 1,访问 v; -
在邻接矩阵的第 v 行中,从左到右找第一个与 v 相邻且未被访问的顶点 w;
-
递归(或迭代)对 w 进行 DFS;
-
如果 v 的所有邻接点都被访问过了,就回溯到上一个顶点。
完整代码
#include <stdio.h>
#define MAXV 100
#define INF 0 /* 0 表示不连通,非0值表示边权 */
typedef struct {
int vexs[MAXV]; /* 顶点表 */
int arc[MAXV][MAXV]; /* 邻接矩阵 */
int vexnum, arcnum; /* 顶点数、边数 */
} MGraph;
int visited[MAXV]; /* 访问标记数组 */
/* 从顶点 v 出发,深度优先遍历图 G */
void DFS(MGraph G, int v) {
int w;
printf("%d ", G.vexs[v]); /* 访问顶点 v */
visited[v] = 1; /* 标记已访问 */
/* 遍历 v 的所有邻接点 */
for (w = 0; w < G.vexnum; w++) {
/* 如果 v 和 w 之间有边,且 w 未被访问 */
if (G.arc[v][w] != INF && !visited[w]) {
DFS(G, w); /* 递归访问 w */
}
}
}
/* 对非连通图,需要遍历所有顶点 */
void DFSTraverse(MGraph G) {
int v;
for (v = 0; v < G.vexnum; v++) {
visited[v] = 0; /* 初始化所有顶点未访问 */
}
for (v = 0; v < G.vexnum; v++) {
if (!visited[v]) {
DFS(G, v); /* 对每个未访问的顶点调用 DFS */
}
}
}
int main() {
MGraph G = {
{1, 2, 3, 4, 5}, /* 顶点编号 */
{ /* 邻接矩阵 */
{0, 1, 1, 0, 0},
{1, 0, 0, 1, 1},
{1, 0, 0, 0, 0},
{0, 1, 0, 0, 0},
{0, 1, 0, 0, 0}
},
5, 4
};
printf("DFS 遍历: ");
DFSTraverse(G); /* 输出: 1 2 4 5 3(取决于顶点编号和邻接顺序) */
return 0;
}
关键点解析
1. 邻接矩阵的判断 G.arc[v][w] != INF 表示顶点 v 和 w 之间有边。如果图是带权图,INF 可以换成一个不可能的最小值(如 0 或 -1),具体看题目定义。
2. visited 数组是灵魂 没有 visited 数组,DFS 会在环里无限转圈。每次递归前必须检查 !visited[w]。
3. 非连通图的处理 DFSTraverse 里外层循环的作用:如果图不是连通的,从一个顶点出发的 DFS 只能访问到它所在的连通分量。外层循环保证每个连通分量都被遍历到。
4. 复杂度
-
时间复杂度:O(V²),V 为顶点数。因为邻接矩阵中找邻接点需要遍历一整行;
-
空间复杂度:O(V),
visited数组 + 递归栈。
如果图用邻接表存储,找邻接点的时间降为 O(E),总时间复杂度 O(V + E)。软考里两种存储方式都要能看懂代码。
四、模拟题一(二叉树非递归中序遍历)
【说明】 对二叉树进行非递归中序遍历,借助链栈实现。设二叉树采用二叉链表存储,节点类型和链栈类型定义如下。
【C代码】
typedef struct BtNode {
char data;
struct BtNode *lchild, *rchild;
} BtNode, *BTree;
typedef struct StNode {
BTree elem;
struct StNode *link;
} StNode;
void visit(BTree p) {
printf("%c ", p->data);
}
int InOrder(BTree root) {
BTree ptr;
StNode *q;
StNode *stacktop = NULL;
ptr = root;
while ((1)) {
while (ptr != NULL) {
q = (StNode *)malloc(sizeof(StNode));
q->elem = ptr;
q->link = stacktop;
stacktop = q;
(2);
}
if (stacktop != NULL) {
q = stacktop;
(3);
ptr = q->elem;
visit(ptr);
(4);
} else {
break;
}
}
return 0;
}
【问题1】(8分) 根据说明和 C 代码,补齐 (1)~(4) 处的空缺。
【问题2】(4分) 该算法的时间复杂度为(5)(用 O 符号表示),空间复杂度为(6)(用 O 符号表示)。
【问题3】(3分) 若将上述非递归算法改为递归算法,则递归版本的空间复杂度主要取决于(7)。
五、模拟题二(单链表就地逆置)
【说明】 将带头节点的单链表就地逆置。要求不额外申请大量节点空间,仅通过修改指针完成。链表节点定义如下。
【C代码】
typedef struct LNode {
int data;
struct LNode *next;
} LNode, *LinkList;
void Reverse(LinkList L) {
LNode *p, *q;
p = L->next; /* p 指向第一个数据节点 */
(1); /* 头节点先指向 NULL */
while (p != NULL) {
q = (2); /* q 保存 p 的后继 */
(3); /* p 插入到头节点之后 */
L->next = p;
p = q;
}
}
【问题1】(6分) 根据说明和 C 代码,补齐 (1)~(3) 处的空缺。
【问题2】(3分) 该算法的时间复杂度为(4)(用 O 符号表示),空间复杂度为(5)(用 O 符号表示)。
【问题3】(3分) 若单链表长度为 n,则上述算法中 while 循环执行次数为(6)次。
【问题4】(3分) 该算法采用的策略是(7)(填"头插法"或"尾插法")。
六、参考答案
模拟题一
【问题1】
-
(1) ptr != NULL || stacktop != NULL(或
ptr != NULL || stacktop != NULL,两者顺序可换) -
(2) ptr = ptr->lchild(或
ptr = ptr->lchild;) -
(3) stacktop = stacktop->link(或
stacktop = q->link;,出栈:栈顶指针下移) -
(4) ptr = ptr->rchild(或
ptr = ptr->rchild;)
【问题2】
-
(5) O(n)(n 为节点数)
-
(6) O(h)(h 为树高,或 O(n) 最坏情况)
【问题3】
-
(7) 树的高度(或递归深度、递归栈深度)
模拟题二
【问题1】
-
(1) L->next = NULL
-
(2) p->next(或
(*p).next) -
(3) p->next = L->next(或
p->next = L->next;,头插法核心:新节点指向原第一个节点)
【问题2】
-
(4) O(n)
-
(5) O(1)(只用了 p、q 两个指针,没有额外申请与 n 相关的空间)
【问题3】
-
(6) n
【问题4】
-
(7) 头插法
七、小结
链表、树、图这三大结构在第四题里,考法相对"实在"——不像动态规划那样需要理解状态转移,也不像回溯法那样需要理解递归框架。核心就是指针指对、顺序走对、空填对。
记住这几组高频填空:
| 场景 | 常见填空 | 含义 |
|---|---|---|
| 二叉树中序 | ptr = ptr->lchild / ptr = ptr->rchild | 向左/向右走 |
| 链栈入栈 | q->link = stacktop; stacktop = q; | 头插法入栈 |
| 链栈出栈 | stacktop = stacktop->link; | 栈顶下移 |
| 链表逆置 | p->next = L->next; L->next = p; | 头插法 |
| 图 DFS | if (!visited[w]) DFS(G, w); | 未访问才递归 |
| 邻接矩阵 | G.arc[v][w] != 0 | 判断 v-w 是否有边 |
考场上如果看到结构体定义 + 指针操作 + 挖空,先深呼吸,把 -> 箭头两边的东西看清楚:左边是指针变量,右边是结构体成员。只要不把 lchild 和 rchild 写反,不把 link 和 next 搞混,这 15 分里至少能拿到 7~10 分。
八、系列完结预告
【软考算法】软件设计师下午第四题算法系列完结篇:从"放弃"到"稳拿"的备考心法
七篇算法博客到这里就告一段落了。分治法、动态规划、贪心、回溯、递归递推、链表树图——六大板块全覆盖。最后一篇我想不聊具体算法,聊聊考场上怎么分配时间、怎么猜空、怎么把 45 分及格线稳稳拿住。如果你跟着这个系列一路看到这里,那篇总结值得一看。

343

被折叠的 条评论
为什么被折叠?



