☰
数据结构C/C++代码实现全解析:从线性表到图论算法,附避坑指南
2026/10/10 6:25:05 网站建设 项目流程

简介:面向正在学习数据结构与算法、需要参考代码实现的同学,作者将自己在CSDN问答中反复解答过的内容整理成一份压缩包。包内共35个文件,其中34个为C/C++源码文件,另含1个Markdown说明文档,整包约28KB,轻量易下载。代码覆盖线性表、栈与队列、串、数组/矩阵、广义表、二叉树与线索二叉树、哈夫曼树、图及图算法等主线内容,具体包括顺序表、链表、栈队列的顺序与链式实现,以及BFS、DFS、Dijkstra、Floyd、Prim、Kruskal、拓扑排序、关键路径等经典算法,还涉及邻接表、邻接矩阵、十字链表、邻接多重表等多种图存储方式。对于课程设计、考研复习或日常刷题,均可直接对照源码理解存储结构与算法流程,节省自行调试时间。目前已有369人学习下载,对于入门到进阶阶段的学习者较为实用。

1. 数据结构代码包:为什么说它比刷题库更值得过一遍

期末考前一周,某同学抱着一堆数据结构代码来找我,说他的“哈夫曼树.cpp”一编译就跑飞。我扫了一眼,发现他把顶点数写死在全局数组里,遍历时却按另一个 n 值去跑——这是复现数据结构源码最常见的通病:文件能跑,换一组输入就翻车。这份「数据结构C/C++代码实现」资源,把从线性表到图论最常考的二十多个经典实现收在一个包里,顺序表、链表、栈队列、二叉树、线索二叉树、哈夫曼树、DFS/BFS、Dijkstra、Floyd、Prim、Kruskal、拓扑排序、关键路径全都有。它不是教科书,是一批可以照着改、照着跑的源码,适合期末突击、复试手撕代码,以及做课程设计时不想从零开始的人。

2. 线性表与栈队列:先分清存储结构,再看代码才不晕

拿到压缩包先别急着逐文件编译,这二十多个文件虽然各自独立,但它们背后只有两种存储思路:连续存储和链式存储。理解了这个,后面看图和树都会顺很多。

2.1 顺序表与链表:存储结构决定操作代价

顺序表的核心是数组,逻辑相邻就是物理相邻,按下标访问是 O(1),但插入和删除要搬动后续元素。链表的核心是结点加指针,插入删除只要改指针,但访问第 k 个元素得从头走。代码包里「顺序表.cpp」和「单链表.cpp」刚好是这两种思路的对照。

顺序表插入的经典写法:

#define MAXSIZE 100 typedef struct { int data[MAXSIZE]; int length; } SeqList; int ListInsert(SeqList *L, int i, int e) { if (i < 1 || i > L->length + 1) return 0; // 位置越界 if (L->length >= MAXSIZE) return 0; // 表已满 for (int j = L->length; j >= i; j--) // 从后往前搬 L->data[j] = L->data[j - 1]; L->data[i - 1] = e; // 下标要减 1 L->length++; return 1; }

这段代码里最容易看懵的是for循环下标:逻辑位置 i 从 1 开始,数组下标从 0 开始,所以data[i-1]才是要插入的位置。搬运时j从length开始往前,先把最后一个元素挪到length位置,再依次后移,最后把新元素写进空出来的i-1。参数L是结构体指针,修改length必须用它,否则函数结束就丢。初始化时记得让length = 0,很多翻车都是因为忘了给长度赋初值。

单链表的插入处理的是另一种边界,头插法和尾插法代码差异很大,空表插入和非空表插入的指针操作也不一样。建议把顺序表和单链表两个文件放一起读,一个看搬移的循环,一个看指针的衔接,两边对照能看出「为什么链表插入不需要搬元素」——它只需要改两个指针,代价与表长无关。

2.2 栈与队列:先进后出与先进先出的代码骨架

栈和队列都属于操作受限的线性表,区别只在进出口的数量。栈只允许一端进出,队列一端进另一端出。代码包里「栈.cpp」「链栈.cpp」对应顺序栈和链栈,「队列.cpp」「链队.cpp」对应顺序循环队列和链队列。这里我重点说链栈,因为它揭示了链表做栈时的头插思想。

typedef struct StackNode { int data; struct StackNode *next; } StackNode; StackNode* Push(StackNode *top, int x) { StackNode *s = (StackNode*)malloc(sizeof(StackNode)); s->data = x; s->next = top; // 新结点指向旧栈顶 return s; // 新结点成为栈顶 } StackNode* Pop(StackNode *top, int *x) { if (top == NULL) return NULL; *x = top->data; StackNode *p = top; top = top->next; // 栈顶后移 free(p); return top; }

这段代码和其他链式实现最大的不同是:入栈出栈都要return新栈顶。因为栈顶是局部变量,直接改top指针在函数内部生效,调用方拿不到新值,所以要么返回新指针,要么传二级指针。很多初学者在这段上翻车,就是只调用Push(top, x)却不接收返回值,结果打印出来栈还是空的。初始化时top = NULL,判断空栈就看top == NULL,这个约定在链式结构里是统一的。

顺序队列的代码里要特别注意「循环队列」的写法,入队rear = (rear + 1) % MAXSIZE,出队front = (front + 1) % MAXSIZE,判空条件是front == rear,判满条件是(rear + 1) % MAXSIZE == front。如果不取模直接rear++,数组很快就越界,这也是顺序队列最容易出 bug 的地方。

2.3 读这批源码的通用顺序:先看笔记,再按依赖分组

压缩包里有个「数据结构.md」文件,它其实是这批代码的索引笔记,建议你第一个打开它。我一般会按这条线读:先线性表(顺序表、单链表、双向链表),再栈队列(栈、链栈、队列、链队),然后树(二叉树、线索二叉树、哈夫曼树),最后图(邻接矩阵、邻接表、DFS/BFS、最短路径、最小生成树、拓扑排序、关键路径)。每个文件基本不超过两百行,用到的都是纯 C 的写法,printf、malloc、free占大头,个别文件用了 C++ 的引用或cout。编译时用 g++ 更稳妥,避免.cpp后缀配 gcc 时报链接错。

3. 树与图遍历:递归、回溯与队列的三个分水岭

树和图是这门课里最考验代码转化能力的两块。树的遍历靠递归和栈,图的遍历要靠队列或递归栈,它们的实现骨架其实只有几行,但每一行都卡在「什么时候进、什么时候出」上。

3.1 二叉树:一次递归搞定先序中序后序

二叉树的三序遍历代码结构几乎一样,区别只在于访问结点的时机。先序是「访问—左—右」,中序是「左—访问—右」,后序是「左—右—访问」。代码包里「二叉树.cpp」用的是链式存储,结点结构包含数据域和左右孩子指针:

typedef struct BiTNode { char data; struct BiTNode *lchild, *rchild; } BiTNode; void PreOrder(BiTNode *T) { if (T == NULL) return; // 递归出口 printf("%c ", T->data); // 先访问根 PreOrder(T->lchild); // 再走左子树 PreOrder(T->rchild); // 最后走右子树 }

这段代码看似简单,但递归出口T == NULL是整个函数的灵魂。把它换成T->lchild == NULL之类就会漏子树。中序和后序只要挪动printf那一行的位置即可,访问时机越靠后,根结点的输出就越晚。理解递归时别在脑子里人肉压栈,画一棵三层的小树,用笔标出每个结点的访问序号,比盯着代码想快得多。

3.2 线索二叉树与哈夫曼树:两个容易混的改造点

「线索二叉树.cpp」和「哈夫曼树.cpp」放在一起很容易让人以为都是指针改动,但二者方向完全不同。线索二叉树是把空指针利用起来,指向遍历序列中的前驱和后继;哈夫曼树是从下往上建树,每次选两个权值最小的结点合并。线索二叉树的结点要加ltag和rtag两个标志位,0 表示孩子指针,1 表示线索,这就是它和普通二叉树在结构上唯一的区别。

哈夫曼树建树的起点是叶子结点集合,每次从森林里选两个最小权的树合并,新树的权是二者之和。代码实现里最常见的做法是维护一个权值数组,每次扫描找两个最小值:

void SelectMin(int w[], int n, int *s1, int *s2) { int min1 = INF, min2 = INF; *s1 = *s2 = -1; for (int i = 0; i < n; i++) { if (w[i] < min1 && w[i] != -1) { min2 = min1; *s2 = *s1; min1 = w[i]; *s1 = i; } else if (w[i] < min2 && w[i] != -1) { min2 = w[i]; *s2 = i; } } }

这段代码的坑在于w[i] == -1的结点代表已被合并过,必须跳过。如果初始 n 个叶子建哈夫曼树,最终结点数是2n - 1,数组长度要按这个上限预留,很多跑飞都是数组开小了。另外s1和s2用指针传出,是为了一个函数同时返回两个下标,如果你自己不习惯这种写法,用结构体返回也可以。

3.3 DFS与BFS:图遍历的一纵一横

图的 DFS 和 BFS 是后续拓扑排序、最短路径的基础文件。DFS 走的是「一条路走到底再回头」,BFS 走的是「一圈一圈往外扩」。DFS 常写成递归,BFS 必须配队列。

// DFS 核心,visited 数组需全局初始化 void DFS(int v) { visited[v] = 1; printf("%d ", v); for (int w = FirstNeighbor(v); w >= 0; w = NextNeighbor(v, w)) { if (!visited[w]) { DFS(w); } } }
// BFS 核心,用队列实现 void BFS(int v) { queue<int> q; visited[v] = 1; q.push(v); while (!q.empty()) { int cur = q.front(); q.pop(); printf("%d ", cur); for (int w = FirstNeighbor(cur); w >= 0; w = NextNeighbor(cur, w)) { if (!visited[w]) { visited[w] = 1; q.push(w); } } } }

两段代码里FirstNeighbor和NextNeighbor是图存储结构提供的邻居接口,如果是邻接矩阵实现,这两个函数就是一层for循环扫一行;如果是邻接表实现,就是沿着边表指针走。DFS 的标记时机在递归之前,BFS 的标记时机在入队之前。有个常犯错误是出队时才标记visited,这会导致同一个结点被反复入队,队列膨胀,逻辑上也不再是严格的一层一层扫。选 DFS 还是 BFS,取决于你要什么结果:找路径是否存在、判断连通分量用 DFS 更省空间;求无权图的最短路径、层序遍历用 BFS 更直接。

4. 最短路径与最小生成树:四个经典算法怎么选、怎么看

图论部分是这个包里文件数最多的区域:Dijkstra、Floyd、Prim、Kruskal、拓扑排序、关键路径,还有三个建图文件。每个算法的代码都不短,但如果按「算法思想—数据结构—核心循环」三步拆开看,就能很快抓到主干。

4.1 最短路径:Dijkstra 与 Floyd,单源与全源的分工

Dijkstra 解决单源最短路径,要求边权非负;Floyd 解决任意两点间最短路径,走的是一条动态规划的思路:逐步允许中间结点集合扩大。代码包里「Dijkstra.cpp」通常用邻接矩阵存图,维护dist[]和path[]两个数组,每次从未确定最短路径的结点里选dist最小的那个做松弛。

#define MAXV 100 #define INF 0x3f3f3f3f void Dijkstra(int v, int n, int arcs[MAXV][MAXV], int dist[], int path[]) { bool s[MAXV] = {false}; for (int i = 0; i < n; i++) { dist[i] = arcs[v][i]; if (dist[i] < INF) path[i] = v; else path[i] = -1; } s[v] = true; dist[v] = 0; for (int k = 1; k < n; k++) { int u = -1, min = INF; for (int j = 0; j < n; j++) { if (!s[j] && dist[j] < min) { u = j; min = dist[j]; } } if (u == -1) break; s[u] = true; for (int j = 0; j < n; j++) { if (!s[j] && arcs[u][j] < INF && dist[u] + arcs[u][j] < dist[j]) { dist[j] = dist[u] + arcs[u][j]; path[j] = u; } } } }

参数v是源点编号,n是顶点数。INF用0x3f3f3f3f是因为它足够大且相加不会溢出 int,如果用INT_MAX,松弛时dist[u] + arcs[u][j]一加就可能变成负数,整个比较逻辑就废了。Dijkstra 的外层循环跑n-1次,每次选一个未确定结点,内层第一次扫描找最小,第二次扫描做松弛,这就是它的 O(n²) 来源。Floyd 的三重循环里最外层是中间结点 k,内两层是起点和终点,写成if (dist[i][k] + dist[k][j] < dist[i][j])更新,这个 k 在最外层是它和 Dijkstra 最大的区别,也是考试里最爱问的「为什么 Floyd 的 k 不能放内层」。

两种算法怎么选,参考这张表:

场景推荐算法复杂度限制
单源,边权非负DijkstraO(n²)不能有负权边
单源,可能有负权边Bellman-Ford 或 SPFAO(nm)无负环
全源,点数少FloydO(n³)边权可以为负但不能有负环
全源,点数多对每个点跑 DijkstraO(n·e·logn)非负权

4.2 最小生成树:Prim 与 Kruskal,加点与加边

Prim 从一个起点出发,每次把连接「已选集合」和「未选集合」的最短边拉进来,适合稠密图;Kruskal 把所有边按权排序,从小到大选不构成环的边,适合稀疏图。代码包里「Prim.cpp」的核心是维护lowcost[]数组,每次选最小,一通操作后更新相邻顶点:

void Prim(int v, int n, int arcs[MAXV][MAXV]) { int lowcost[MAXV]; bool used[MAXV] = {false}; for (int i = 0; i < n; i++) lowcost[i] = arcs[v][i]; used[v] = true; for (int k = 1; k < n; k++) { int u = -1, min = INF; for (int j = 0; j < n; j++) { if (!used[j] && lowcost[j] < min) { u = j; min = lowcost[j]; } } if (u == -1) return; used[u] = true; for (int j = 0; j < n; j++) { if (!used[j] && arcs[u][j] < lowcost[j]) { lowcost[j] = arcs[u][j]; } } } }

和 Dijkstra 的代码相比,Prim 的更新条件是arcs[u][j] < lowcost[j],没有dist[u] + ...的累加,区别就在这里:最短路径关心「到源点的累计距离」,最小生成树只关心「到已选集合最小边的当前值」。Kruskal 的实现通常要配合并查集,判断加入一条边是否会形成环。「Kruskal.cpp」里的核心就是把边按权重排序,然后逐个尝试 union,union 失败说明成环,跳过这条边。

4.3 拓扑排序、关键路径与十字链表:从建图到应用

「邻接矩阵创建图.cpp」「邻接表创建图.cpp」「十字链表.cpp」「邻接多重表.cpp」四个文件解决的是同一件事:不同方式存图。邻接矩阵最直观但浪费空间,邻接表省空间但定位边的操作略绕,十字链表同时存入弧和出弧,适合频繁修改或有向图,邻接多重表专为无向图设计,每条边只存一次。你不需要每个文件都精读,先建图,再看 DFS/BFS 怎么在对应结构上取邻居,最后回到应用算法,这样图论部分就串起来了。

关键路径相关文件是这套代码里难度较高的一个。「拓扑排序.cpp」是基础,关键路径要先拓扑排序确定事件的最早发生时间,再逆拓扑序求最晚发生时间,两者相等的活动才是关键活动。有一个很现实的经验:如果拓扑排序跑出来的顶点数小于图中结点数,说明图里有环,这时候做关键路径纯属浪费时间,先回去查建图代码。

5. 避坑手册:从这批代码里最容易踩到的五个坑

源码能跑只是起点,换输入就挂才是常态。下面这些坑是我在带人看这类代码时反复遇到的,每一条都是「现象—原因—解决」的结构,你对照着检查自己的文件即可。

5.1 坑一:递归函数没有出口,程序运行直接炸栈

现象:运行二叉树遍历或 DFS 时,程序卡死或报Segmentation fault,甚至整个窗口崩溃。

原因:递归出口判断写错或漏写。比如把if (T == NULL) return;写成if (T->lchild == NULL) return;,叶子结点带空子树时递归无法停止;或者 DFS 里visited数组忘记初始化,同一结点被反复访问,递归深度呈指数级增长。

解决:每个递归函数先检查出口,再检查业务逻辑。二叉树递归出口必须是T == NULL,图 DFS 的出口本质是「所有邻居都已访问」,靠visited标记来兜底。另外一个直觉判断法:递归函数里如果没有「直接 return」的分支,大概率是错的。

5.2 坑二:数组下标从 1 开始的操作,存进下标从 0 开始的数组

现象:顺序表插入后元素错位,打印出来第一个元素是空的,或者最后一个元素被丢。

原因:序号和下标混用。逻辑位置 i 从 1 开始,数组下标从 0 开始,插入代码里搬移用j >= i,写入却用了data[i]而不是data[i - 1],于是一个元素被写到下一个位置,表尾丢元素。

解决:统一约定,要么所有接口都从 0 开始(更符合 C 的习惯),要么保留 1 起始的语义但在写入处手动减 1。我一般会在代码开头加注释:// 位置从 1 开始,下标从 0 开始,每次改代码都盯着这一行看,能避免 80% 的下标问题。

5.3 坑三:.cpp文件用 C 风格写着,编译却用 gcc 命令

现象:代码明明写得没问题,gcc 编译却报C++语法错误,换 g++ 又报malloc没强转,两边轮流出错。

原因:文件后缀是.cpp,但主体是malloc、printf的 C 写法,gcc 默认按 C 编译,对 C++ 语法不适配;g++ 按 C++ 编译,从void*到int*的隐式转换又不合法。

解决:统一用 g++ 编译,并把malloc的返回值做强转:(StackNode*)malloc(sizeof(StackNode))。命令写g++ 顺序表.cpp -o 顺序表 && ./顺序表,不做两步能避开很多环境问题。代码包里写的虽然是 C 风格,按 C++ 工程处理最省事。

5.4 坑四:图的顶点编号从 0 开始,建图却从 1 开始录入

现象:Dijkstra 或 Floyd 跑出来的路径错乱,某些顶点明明有边却显示不可达。

原因:建图代码按 1 到 n 读入顶点,算法内部按 0 到 n-1 访问,顶点编号整体偏移一位。邻接矩阵的对角线、visited数组的大小全跟着错位,而且这种错误往往只在部分数据上暴露,隐蔽性极强。

解决:读代码第一件事就是确认「顶点编号从 0 还是从 1 开始」。从 0 开始,循环写i < n,建边时u--、v--;从 1 开始,数组开n+1大小,循环写i <= n。代码里通常能通过for (i = 0; i < n; i++)判断,发现混用就把录入处减 1 对齐。

5.5 坑五:链表删除结点后没释放,或者先释放又访问了 next

现象:循环链表或链栈在多次删除后程序报错,用内存检测工具能看到「use after free」。

原因:删除结点时先free(p)又访问p->next,或p->next没有提前存下来,free 之后指针变成悬空指针,下一轮循环取p->next就是读取已释放的内存。

解决:删除前先把下一个结点地址存起来,再释放当前结点。一般的顺序是q = p->next; p->data 搬到 p->next 或建立连接; free(q)。写链表操作时养成一个习惯:任何指针在 free 之后都视为不可用,访问链关系必须在 free 之前完成。

6. 进阶用法:把这批源码改造成你自己的算法模板

代码包里的文件命名直接对应算法名,这本身就是一份很好的检索目录。但「能看懂」和「能上考场手写」之间还差一步:把每个文件重构成不依赖全局变量、能接受任意规模输入的模板。这一步做完,你的收获会比单纯编译通过大得多。

6.1 把源码改造成可复用模板的四个动作

第一,把写死的常量抽成预处理宏,放在文件顶部:

#ifndef MAXV #define MAXV 100 // 顶点数上限,按题目改 #endif #define INF 0x3f3f3f3f

第二,把全局数组改成参数传入函数,或者至少把数组大小与 n 绑定。第三,把测试输入从main里抽取成单独函数,方便换用例。第四,每个文件加一个print 数组/树的辅助函数,调试时直接看输出而不是靠断点。

6.2 五组自测用例,验证你改造完的文件没写坏

  • 空表/空树/空图:顺序表length=0、二叉树T=NULL、图n=1,程序不能崩。
  • 最小规模:单链表只插入一个结点,删除它之后链表为空。
  • 边界下标:顺序表在位置 1 和位置length+1各插入一次。
  • 图不连通:Dijkstra 跑两个互不相连的顶点,输出应保持INF。
  • 重复顶点:BFS 同一个起点跑两次,第二次应直接结束。

每改一个算法文件,就用第 3 组或第 5 组用例过一遍,十次有九次能提前暴露下标或标记问题。从那以后我每次拿到新代码,都强制自己先跑一组最小输入再加数据规模,这个习惯帮我省下的排错时间,比任何调试器的帮助都大。这份资源的价值也正在这里——它不是标准答案,而是你对照练习、亲手改成自己风格模板的素材,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询