【软考算法】软件设计师下午第四题之链表、树与图:指针操作、遍历顺序与邻接矩阵的“基本功“

前面六篇我们把分治、DP、贪心、回溯、递归递推都聊完了。今天回到最底层——链表、树、图。这三大结构在第四题里不考高深理论,考的是**指针操作、遍历顺序、邻接矩阵/表**这些基本功。2022 年下半年第四题就考了二叉树非递归中序遍历,链栈 + 二叉链表,指针飞来飞去 citeweb_search:21#3。

一、第四题里的数据结构:考的是"动手"不是"动嘴"

软件设计师下午第四题的考纲里明确写着:链表、栈、二叉树、图的基本操作程序实现。这类题的特点是:

  • 代码量大:通常会给完整的结构体定义 + 算法框架,挖 3~4 个空;

  • 指针操作多->next->lchild->rchild->link 这些箭头是填空的常客;

  • 遍历顺序必考:中序遍历(左根右)是出现频率最高的,其次是先序、后序、层次;

  • 辅助结构:非递归遍历几乎都要借助栈,图的遍历要借助 visited 数组。

和前面几篇的"算法策略"不同,这一篇没有复杂的递归式,也没有状态转移方程。核心就是把指针指对、把顺序走对、把空填对


二、案例一:二叉树非递归中序遍历(2022 年真题风格)

中序遍历的顺序是左 → 根 → 右。递归写法很简单,但非递归写法需要借助来保存路径,因为代码要自己管理"回溯"的过程。2022 年下半年第四题考的就是这个,而且用的是链栈(不含头节点的单向链表)。

思路拆解

  1. 从根节点出发,一路向左,沿途节点全部入栈

  2. 左子树走到头了,出栈一个节点,访问它(这就是"根");

  3. 然后转向该节点的右子树,重复步骤 1;

  4. 直到栈为空且当前指针也为空,遍历结束。

完整代码

#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 数组防止重复访问。

思路拆解

  1. 从某个顶点 v 出发,标记 visited[v] = 1,访问 v;

  2. 在邻接矩阵的第 v 行中,从左到右找第一个与 v 相邻且未被访问的顶点 w;

  3. 递归(或迭代)对 w 进行 DFS;

  4. 如果 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;头插法
图 DFSif (!visited[w]) DFS(G, w);未访问才递归
邻接矩阵G.arc[v][w] != 0判断 v-w 是否有边

考场上如果看到结构体定义 + 指针操作 + 挖空,先深呼吸,把 -> 箭头两边的东西看清楚:左边是指针变量,右边是结构体成员。只要不把 lchildrchild 写反,不把 linknext 搞混,这 15 分里至少能拿到 7~10 分。


八、系列完结预告

【软考算法】软件设计师下午第四题算法系列完结篇:从"放弃"到"稳拿"的备考心法

七篇算法博客到这里就告一段落了。分治法、动态规划、贪心、回溯、递归递推、链表树图——六大板块全覆盖。最后一篇我想不聊具体算法,聊聊考场上怎么分配时间、怎么猜空、怎么把 45 分及格线稳稳拿住。如果你跟着这个系列一路看到这里,那篇总结值得一看。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值