ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

严蔚敏数据结构C语言实现:从线性表到KMP的完整代码与避坑指南

严蔚敏数据结构C语言实现:从线性表到KMP的完整代码与避坑指南 简介这份资源是严蔚敏《数据结构与算法C语言》教材的配套代码实现包面向计算机专业学生、考研备考者及需要巩固算法基础的编程人员。包内代码紧密对应教材章节覆盖线性表、栈、队列、树、图、排序、查找、递归分治、动态规划、贪心及回溯等核心内容可帮助读者将抽象概念落到可运行的C/C程序上边读边练加深对数据结构和经典算法的理解。资源共416个文件以154个cpp文件、154个c文件及89个h头文件为主体另含少量txt说明、dat测试数据及工程配置文件整个压缩包约494KB轻量易下载且文件命名按章节/题号组织便于按需定位。目前已有1607人学习适合对照严蔚敏教材逐章调试算法逻辑、验证课堂所学尤其适合需要大量代码范例配合理论学习的入门和进阶读者。1. 抄了严蔚敏课本代码仍然跑不通问题出在哪里数据结构这门课普遍选用严蔚敏《数据结构C语言版》当教材。作者在书中用一套带“类C”风格的伪代码描述了线性表、树、图、查找和排序的算法这个写法教学上是清楚的但把它当成一份能直接编译的C源码就错了。很多人照着课本敲到Dev-C或VS里迎面而来一堆语法报错也不知道该从何下手。问题的核心是教材里的“ElemType”、“引用参数”和“算法步骤”是设计层面的抽象代码实现需要你自己补上初始化、边界判断和资源释放。这篇笔记会带着你做一套从编译到测试的完整实现路径覆盖线性表、链表、栈、队列、二叉树、快速排序和KMP每段代码都标出关键参数和最容易翻车的位置。适合本科在读、考研机试备考者以及所有想把课堂理论变成动手能力的人。2. 线性表C语言实现顺序表与链表的完整代码及参数说明线性表是严蔚敏教材第一个重点。顺序表、单链表、双链表、循环链表四类结构层层增加指针复杂性。这一章直接用两个可运行的代码块讲清顺序表和单链表。我的建议是把教材里的ElemType先替换成int把引用参数全改成指针跑通后再回头看教材抽象顺序感和信心都会顺很多。2.1 顺序表从类型定义到插入删除三个容易被忽略的参数严蔚敏的顺序表定义里有三个字段elem、length、listsize。elem是一块连续内存的首地址length是当前元素个数listsize是已分配容量。很多人会漏掉listsize导致插入时不做扩容判断数据一多就写进未分配内存程序崩溃毫无预兆。#include stdio.h #include stdlib.h #define LIST_INIT_SIZE 100 #define LISTINCREMENT 10 #define OK 1 #define ERROR 0 typedef int ElemType; typedef struct { ElemType *elem; int length; int listsize; } SqList; int InitList(SqList *L) { L-elem (ElemType *)malloc(LIST_INIT_SIZE * sizeof(ElemType)); if (!L-elem) return ERROR; L-length 0; L-listsize LIST_INIT_SIZE; return OK; } int ListInsert(SqList *L, int i, ElemType e) { ElemType *newbase, *p, *q; if (i 1 || i L-length 1) return ERROR; if (L-length L-listsize) { newbase (ElemType *)realloc(L-elem, (L-listsize LISTINCREMENT) * sizeof(ElemType)); if (!newbase) return ERROR; L-elem newbase; L-listsize LISTINCREMENT; } q (L-elem[i - 1]); for (p (L-elem[L-length - 1]); p q; --p) *(p 1) *p; *q e; L-length; return OK; } int ListDelete(SqList *L, int i, ElemType *e) { ElemType *p, *q; if (i 1 || i L-length) return ERROR; p (L-elem[i - 1]); *e *p; q L-elem L-length - 1; for (p; p q; p) *(p - 1) *p; --L-length; return OK; }插入函数里最容易看错的就是循环方向。for (p (L-elem[L-length - 1]); p q; --p)从表尾向前移动元素因为目标位置q后面的元素必须先“让位”如果反过来从q向后移动第一个赋值就把下一个元素覆盖了整段数据直接损坏。删除函数则是从被删元素的下一个位置开始把后面每个元素往前挪一格方向是从前向后因为每个位置都在读已完成覆盖的地方。参数i的语义也值得单独强调严蔚敏教材里i代表位序从 1 开始而 C 数组下标从 0 开始。所以所有“第 i 个元素”都对应elem[i-1]。这个差一格很容易在不同函数里来回反复犯。我写代码时会在函数入口用if (i 1 || i L-length 1)挡掉非法输入既保证返回值可控也让后续的指针运算安全。realloc 扩容之后的指针重赋值是另一个易错点。realloc 可能把原有内存搬到新地址旧的elem值变成悬空指针必须重新拿返回值刷新elemtop这类的指针变量也要重新计算偏移。顺序表这里相对简单到了栈里这个坑就特别明显。2.2 单链表头插法建表、插入和删除的指针顺序单链表的代码不在多在于指针指向的顺序。严蔚敏把“带头节点的单链表”作为默认形态头节点不存数据好处是可以统一处理首元节点的插入和删除免去大量if (p head)的分支。#include stdio.h #include stdlib.h #define OK 1 #define ERROR 0 typedef int ElemType; typedef struct LNode { ElemType data; struct LNode *next; } LNode, *LinkList; LinkList CreateListHead(int n) { LinkList L (LinkList)malloc(sizeof(LNode)); LNode *p; if (!L) return NULL; L-next NULL; for (int i 0; i n; i) { p (LNode *)malloc(sizeof(LNode)); if (!p) return NULL; scanf(%d, p-data); p-next L-next; L-next p; } return L; } int ListInsert_L(LinkList L, int i, ElemType e) { LNode *p L; int j 0; while (p j i - 1) { p p-next; j; } if (!p || j i - 1) return ERROR; LNode *s (LNode *)malloc(sizeof(LNode)); if (!s) return ERROR; s-data e; s-next p-next; p-next s; return OK; } int ListDelete_L(LinkList L, int i, ElemType *e) { LNode *p L; int j 0; while (p-next j i - 1) { p p-next; j; } if (!(p-next) || j i - 1) return ERROR; LNode *q p-next; *e q-data; p-next q-next; free(q); return OK; } void PrintList(LinkList L) { LNode *p L-next; while (p) { printf(%d , p-data); p p-next; } printf(\n); }插入节点时s-next p-next与p-next s两步不能交换。写反的后果是p-next先指向s然后s-next又指向p-next也就是s自己链表出现环。这种问题在数据量小的时候看不出数据量略大打印链表会出现死循环。删除节点时循环条件是p-next而不是p。因为我们要找到第i个节点的前驱如果判断p本身可能在已经越过链尾时仍然继续。删除之后必须free(q)这一步是教材里常被省略的资源管理不写就会成为内存泄漏的一部分。建表用头插法时输入顺序是反的。比如你依次输入 1、2、3链表里的顺序是 3、2、1因为每个新节点都插到头部。如果实验题目要求保持输入顺序要么改成尾插法加一个尾指针要么建表之后再把链表反转。很多人在实验报告里写“结果和输入不一致”最后排查到自己写的是头插法。2.3 最小测试主函数没有它谈何代码实现上文给的函数只是模块要验证就要写 main。一个合格的最小测试应该覆盖插入到空表、插入到尾部、删除头节点、删除尾节点、删除越界位置。这一点我用一个 20 行以内的 main 就能覆盖int main(void) { LinkList L CreateListHead(3); ListInsert_L(L, 2, 99); int e; ListDelete_L(L, 1, e); printf(deleted%d\n, e); PrintList(L); LNode *p L; while (p) { LNode *tmp p; p p-next; free(tmp); } return 0; }运行前先准备好固定输入echo 1 2 3 | ./demo。用管道注入测试数据比每次手动按回车强太多机试现场也能保证可复现性。注意我最后一段循环把链表节点逐个释放这一点教材里没有但工程代码必须有。3. 栈和队列C语言实现从教材原型到可运行程序的翻译要点栈和队列是线性表的两个受限版本。严蔚敏教材对它们的定义很简洁但代码实现层面的资源管理和边界处理也都在细节上。这一章给出顺序栈、循环队列的可运行代码并把“教材原型”与“工程可用”之间的差距说到位。3.1 顺序栈Push、Pop 和扩容后 top 重定位很多学生写完栈只在栈顶用一个整数下标而没有真正的base/top指针。这种写法可以跑但应对扩容时会遇到一个麻烦realloc 可能移动内存旧下标还能用吗答案是能用前提是下标和地址的对应关系要重新计算。严蔚敏用两个指针base和top本质上就是在把地址变量显式化。#include stdio.h #include stdlib.h #define STACK_INIT_SIZE 100 #define STACKINCREMENT 10 typedef int ElemType; typedef struct { ElemType *base; ElemType *top; int stacksize; } SqStack; int InitStack(SqStack *S) { S-base (ElemType *)malloc(STACK_INIT_SIZE * sizeof(ElemType)); if (!S-base) return 0; S-top S-base; S-stacksize STACK_INIT_SIZE; return 1; } int Push(SqStack *S, ElemType e) { if (S-top - S-base S-stacksize) { int offset (int)(S-top - S-base); S-base (ElemType *)realloc(S-base, (S-stacksize STACKINCREMENT) * sizeof(ElemType)); if (!S-base) return 0; S-top S-base offset; S-stacksize STACKINCREMENT; } *S-top e; return 1; } int Pop(SqStack *S, ElemType *e) { if (S-top S-base) return 0; *e *--S-top; return 1; }这里我特别加了一行int offset (int)(S-top - S-base);在 realloc 之前先记录偏移量。很多教材版本直接写S-top S-base S-stacksize;这在刚扩容时恰好正确但如果你在扩容前已经 Push 过一些元素旧 top 和 base 的偏移就不是 stacksize。用一个中间变量保存偏移量是更严谨的写法也是我在实际代码里和教材版本差别最大的地方之一。Pop 从栈顶弹出元素严格说只要把top下移并返回旧顶值不需要清空那块内存。清空留下的旧数据在调试时有干扰但不影响正确性。我的习惯是不做多余写入省时也避免掩盖内存错误。3.2 栈应用括号匹配的完整代码与退出路径栈最常见的入门应用是括号匹配。严蔚敏教材在表达式求值章节里提到此过程但没有直接给一个完整程序。补齐一个int bracketMatch(const char *s) { SqStack st; if (!InitStack(st)) return 0; for (const char *p s; *p; p) { if (*p ( || *p [ || *p {) { Push(st, *p); } else if (*p ) || *p ] || *p }) { int c; if (!Pop(st, c)) { free(st.base); return 0; } if ((*p ) c ! () || (*p ] c ! [) || (*p } c ! {)) { free(st.base); return 0; } } } int ok (st.top st.base); free(st.base); return ok; }这段代码在三个 return 路径上都执行了free(st.base)。如果你只写一个 return程序运行正常还好一旦括号不匹配就会大量泄漏。要记住数据结构的函数可以做任何事但申请的内存必须回收这是让代码从教科书范本变成可交付程序的关键一步。3.3 循环队列少用一个存储单元的真正含义队列的顺序存储如果用普通数组front 和 rear 持续增加rear 到尾部时即使 front 前面还有大量空位也没法再插入这是假溢出。循环队列把数组下标做成环形进队和出队都以取模的方式移动。#define MAXQSIZE 100 typedef int QElemType; typedef struct { QElemType *base; int front; int rear; } SqQueue; int InitQueue(SqQueue *Q) { Q-base (QElemType *)malloc(MAXQSIZE * sizeof(QElemType)); if (!Q-base) return 0; Q-front Q-rear 0; return 1; } int QueueLength(SqQueue Q) { return (Q.rear - Q.front MAXQSIZE) % MAXQSIZE; } int EnQueue(SqQueue *Q, QElemType e) { if ((Q-rear 1) % MAXQSIZE Q-front) return 0; Q-base[Q-rear] e; Q-rear (Q-rear 1) % MAXQSIZE; return 1; } int DeQueue(SqQueue *Q, QElemType *e) { if (Q-front Q-rear) return 0; *e Q-base[Q-front]; Q-front (Q-front 1) % MAXQSIZE; return 1; }队满的条件是(Q-rear 1) % MAXQSIZE Q-front也就是 rear 再走一步就追上 front。之所以“少用一个”存储单元是为了让队空front rear和队满不冲突。假如把全部 MAXQSIZE 个单元都允许放元素判断队满和队空就会变成同一个条件需要额外用一个 size 字段才能区分。考试时如果要求你写严蔚敏版本就按“少用一格”来。工程上我更推荐带 size 的版本调试时看 size 一眼就知道当前队列长度但代价是维护成本稍高。想扩展成双端队列只要让 front 和 rear 都能双向移动插入删除前各自判断边界即可核心逻辑还是这套取模运算。4. 二叉树、快速排序与KMP三个高频考点的代码落点从这一章开始算进入考研408和面试手撕代码最常考的“算法重头戏”。严蔚敏教材里二叉树、快速排序、串匹配各有一章这里只挑出三个最容易被代码实现卡住的知识点分别把代码逻辑说透。4.1 二叉树遍历递归极简但深度一大就要换显式栈二叉树遍历是递归的经典入门。前序、中序、后序三行代码都能搞定但这里的“极简”有个前提二叉树深度不能太大。系统调用栈默认也就几兆字节每个递归栈帧占用几十字节深度上万就可能越界。严蔚敏教材没有展开这一点实际机试时却总有人在这里摔跤。#include stdio.h #include stdlib.h typedef char TElemType; typedef struct BiTNode { TElemType data; struct BiTNode *lchild, *rchild; } BiTNode, *BiTree; void PreOrder(BiTree T) { if (T NULL) return; printf(%c , T-data); PreOrder(T-lchild); PreOrder(T-rchild); } void PreOrderIterative(BiTree T) { BiTNode *stack[1000]; int top -1; if (T) stack[top] T; while (top 0) { BiTNode *node stack[top--]; printf(%c , node-data); if (node-rchild) stack[top] node-rchild; if (node-lchild) stack[top] node-lchild; } }迭代版前序遍历中压栈顺序必须是“右孩子先压、左孩子后压”。因为栈是后进先出后压的左孩子会先弹出这样才能维持“根→左→右”的遍历顺序。写反了结果就变了调试时拿一个只有三个节点的满二叉树对照一下立刻能发现。如果还觉得理解不了可以把栈变量换成人先拿起根节点发现右边有个孩子要排到后边左边要排到前边于是把右先放进箱子底部左放进箱子顶部。这个日常生活类比很多学生反映比背代码有用。4.2 快速排序三数取中比教材版本在工程上更稳严蔚敏教材的快速排序用第一个元素作枢轴代码短适合教学。但工程和机试里这个选择会成为性能陷阱如果数组已经有序每次分割都极度不平衡递归深度变成 n时间复杂度退化到 O(n^2)。三数取中median of three可以极大缓解这个问题改动量也很小。void swap(int *a, int *b) { int t *a; *a *b; *b t; } int medianOfThree(int arr[], int low, int high) { int mid low (high - low) / 2; if (arr[mid] arr[low]) swap(arr[mid], arr[low]); if (arr[high] arr[low]) swap(arr[high], arr[low]); if (arr[high] arr[mid]) swap(arr[high], arr[mid]); swap(arr[mid], arr[high - 1]); /* 把枢轴放到high-1方便后面切分 */ return arr[high - 1]; } void quickSort(int arr[], int low, int high) { if (low high) { int pivot medianOfThree(arr, low, high); int i low, j high - 1; while (1) { while (arr[i] pivot); while (j low arr[--j] pivot); if (i j) break; swap(arr[i], arr[j]); } swap(arr[i], arr[high - 1]); quickSort(arr, low, i - 1); quickSort(arr, i 1, high); } }这段代码有几个相互咬合的细节把枢轴存到high-1而不是直接原地分隔是为了减少移动左边arr[i]的前缀 是为了跳过边界位置右边j low的限制保证 j 不会越过左边界。如果只照抄其中一段另外两段特别容易相互矛盾所以我的建议是整块替换不要只改一行。注意机试场景下如果题目明确要求按严蔚敏教材原版快排作答不要在卷面上换三数取中。解法以题目要求为准三数取中更适合不受限的工程场景或实验报告。调试快排时打印一轮分割前后的low、high、pivot和数组当前状态再用一个长度 8 的小数组手动推演是比盯着代码硬想有效得多的方法。先把小数组的每一轮交换在纸上画出来再回到代码里看对应行几乎所有 bug 都是边界条件上的差一错误。4.3 KMP从暴力枚举到 next 数组的优化思路严蔚敏教材的串匹配章节先讲朴素匹配再讲KMP。朴素匹配本质是双重循环暴力枚举所有位置最坏复杂度 O(m×n)KMP 的目标是让主串不回头利用模式串自身的重复结构跳过多余比较。最关键的 next 数组本质是“最长公共前后缀长度”的映射。#include stdio.h #include stdlib.h #include string.h void getNext(const char *pattern, int next[]) { int m (int)strlen(pattern); next[0] -1; int k -1, j 0; while (j m - 1) { if (k -1 || pattern[j] pattern[k]) { j; k; next[j] k; } else { k next[k]; } } } int kmpSearch(const char *text, const char *pattern) { int n (int)strlen(text); int m (int)strlen(pattern); if (m 0) return 0; int *next (int *)malloc(m * sizeof(int)); getNext(pattern, next); int i 0, j 0; while (i n j m) { if (j -1 || text[i] pattern[j]) { i; j; } else { j next[j]; } } free(next); if (j m) return i - j; return -1; }next[0] 为什么是 -1因为 KMP 主循环里用j -1来统一表示“第一个字符就不匹配”的情况这样处理后下一次比较会让i前进、j归零不额外加分支。如果 next[0] 初始化为 0主循环的判断就会复杂不少容易把两个不同情况混在一起。理解 KMP 有一个朴素但管用的思路先写一个暴力枚举版本记录在哪些字符上做了无意义的重复比较然后看模式串的前缀后缀能不能复用最后再抽象成 next 数组。先把暴力枚举跑通再谈优化这是我个人的学习路径建议。5. 严蔚敏数据结构代码实现避坑指南从编译报错到内存泄漏代码实现这件事思路和学理之外最终拼的是踩坑经验。以下是五个出现频率最高的坑每一条都按“现象 → 原因 → 解决”来写。这些坑我本人都踩过也是不同届学生反复问我的问题。希望能帮你一次性躲过去。5.1 教材里的“引用传参”在纯C编译器下直接报错现象把严蔚敏教材代码里的ListDelete(SqList L, int i, ElemType e)粘贴到 GCC 或 Dev-C 里编译报expected )或 token错误整段代码行行标红。原因教材虽然叫“C语言版”但大量使用了 C 风格的引用参数。C 语言只有指针传递没有“引用”这个语法。解决先确认自己是在学 C 还是在学 C。写纯C就把所有e改成*e调用处传e写 C 就把文件后缀改成.cpp保留引用。我推荐前者因为数据结构实验和考研机试默认使用 C 语法的情况更常见用指针反而和数组、地址的概念统一。5.2 顺序表插入删除“结果对不上”位序和数组下标差一格现象在第 3 个位置插入元素打印结果发现新元素出现在下标 3即第 4 个位置或者删除第 2 个元素删掉的是第 3 个。原因严蔚敏教材里所有位序i从 1 开始而 C 数组下标从 0 开始。插入位置写成L.elem[i]而非L.elem[i-1]就会差一位。解决在每个 Insert/Delete 函数头部先做if (i 1 || i L.length 1)的边界检查再把所有“第 i 个位置”统一写成elem i - 1。写完之后用三个用例验证插入头部、中间、尾部打印内容确认。5.3 二叉树递归深度一大就崩溃必须会写非递归版本现象用递归函数对一棵深度上万的二叉树做中序遍历中途程序段错误退出gdb 看到栈溢出。原因递归调用依赖系统栈默认栈空间有限递归深度太深时栈帧累积超过上限直接写坏未授权地址。解决深度可控时用递归代码短可读性好深度未知或机试数据很大时用显式栈。这个转换对前序/中序/后序都成立其中后序的迭代写法最绕可以用“两个栈”的办法绕开一个栈做遍历一个栈存结果最后倒序输出。5.4 快速排序用首元素做枢轴在已排序数据上退化现象对升序数组跑严蔚敏教材版快排运行时间明显变慢甚至递归越深直接爆栈。原因首元素作枢轴时如果数据已经有序每次划分只产生一个元素和剩下 n-1 个元素两个子区间递归深度 O(n)时间退化到 O(n^2)。解决采用三数取中或随机选枢轴。机试里最省事的是在low、mid、high三个位置取中位数代码多五行却能把最坏情况的概率压到很低。实在不想改算法就对有序输入改写归并排序。5.5 程序运行正常但 Valgrind 报内存泄漏现象./demo输出结果完全正确但valgrind --leak-checkfull ./demo报告definitely lost一堆字节。原因教材代码只展示算法不管资源释放而malloc之后的free是工程代码的义务。忽略它每一次建表、建树都在泄漏内存。解决给每个抽象结构补一个Destroy函数链表和二叉树用遍历把所有free执行干净在 main 的每个分支上都调用对应销毁函数。提交前跑一次 valgrind把泄漏清零再收工。这一步消耗的时间不超过十分钟但能让你避免“运行五分钟内存吃满”的尴尬。6. 一个能救命的验证习惯用断言把严蔚敏代码变成可回归测试代码写完怎么证明它对我见过太多人靠“printf 打印一遍肉眼看对不对”完成验证。在小工程里这没问题但一旦结构增加到五个以上肉眼验错成本太高。更好的做法是用assert做最小回归集。6.1 用 assert() 替代 printf 验证核心操作#include assert.h void test_sqlist(void) { SqList L; assert(InitList(L) OK); assert(ListInsert(L, 1, 10) OK); assert(ListInsert(L, 2, 20) OK); assert(ListInsert(L, 2, 15) OK); assert(L.length 3); assert(L.elem[0] 10); assert(L.elem[1] 15); assert(L.elem[2] 20); ElemType e; assert(ListDelete(L, 2, e) OK); assert(e 15); assert(L.length 2); assert(L.elem[1] 20); }assert 在条件为真时静默通过为假时中止并报告行号。用这种方式写测试回归时只需要跑一遍所有 test_ 函数任何错误都会以最短路径暴露而且不需要人长时间盯着输出窗口。6.2 按接口写测试让实现随时可替换另一个习惯是“面向接口写测试”。你在实现顺序表时写一个测试将来换成链表实现测试代码不需要变只要两边函数名一致、签名一致。这样的约束会倒逼你写出更规范的操作集合而严蔚敏教材恰好已经给出了标准接口命名比如 InitList、ListInsert、ListDelete、ListLength。你只需要保持这些名字内部实现随意换。久而久之你的代码库就会变成一套可插拔的数据结构模块机试和面试时可以直接复用。我自己的做法是每学完一个结构把测试代码存成一个纯文本文件按“输入、预期、实际”三列记录失败用例。等到期末复习或机试之前直接翻开这个文件回忆踩过的坑省下的时间比写代码时多得多。希望帮到你。本文还有配套的精品资源点击获取
返回列表