首页 / 资讯中心 / 文章详情

数据结构课程设计:哈夫曼编码、跳马与长整数运算算法实现拆解

数据结构课程设计:哈夫曼编码、跳马与长整数运算算法实现拆解 ★ FEATURED ARTICLE
简介《数据结构课程设计》报告PDF涵盖四个经典算法实践专题哈夫曼码编/译码系统、递归替换问题、跳马问题与长整数运算面向计算机专业本专科学生及正在准备课程设计或相关考试的开发者。每个专题均按“数据类型定义—算法设计—函数调用关系图—调试分析—测试结果—带注释源程序”完整组织可作为课程设计报告撰写和算法编码的对照范本。压缩包仅含1个PDF文件大小268KB便于快速下载阅读。目前已有171人学习使用。其中哈夫曼编码部分详解了建树、编码与解码流程递归替换给出模式查找替换的递归策略及边界处理思路跳马问题通过深度优先或广度优先搜索实现棋盘路径遍历长整数运算则以数组或链表存放大数并逐位实现加减乘除。读者既能借鉴整体报告结构也能直接参考各专题的源码实现与调试要点适合用于答辩准备、考前复习和算法实践入门。1. 数据结构课程设计四个经典题里最值得反复拆的算法实现如果你正在头疼数据结构课程设计的选题哈夫曼码的编/译码系统、递归替换问题、跳马问题、长整数运算问题这四道题几乎是绕不开的组合。它们分别对应树、递归、图遍历、线性表四个核心模块恰好把一门数据结构课里最常考的知识点串了一遍。这篇笔记按我实际拆过的课程设计源码来写从数据结构定义、算法设计讲到调试过程把每个题的关键函数和参数都标出来。适合要交课设报告的学生也适合想快速复习哈夫曼编码、DFS 和链表运算的从业者。2. 哈夫曼码的编/译码系统建树、编码、译码三步走附关键函数拆解2.1 数据结构如何定义用 Bnode 结构体把字符、权值和指针包在一起哈夫曼编码的核心不是编码本身而是怎么在代码里表达“树”。课设源码里定义了一个 Bnode 结构体把字符、权值、左右孩子标志、前缀码存储数组和三个指针放在一起这种写法在课设场景里很实用。#define MAX 100 typedef struct { int weight; // 节点权值 char name; // 节点字符 char flag; // 标志左孩子0右孩子1根2 char encod[MAX]; // 编码存储数组 struct Bnode *lchild; // 左孩子指针 struct Bnode *rchild; // 右孩子指针 struct Bnode *parent; // 双亲指针 } Bnode;这个结构体最巧妙的地方是flag字段。它在建树过程中承担了三重身份初始时所有节点都是森林中的独立树flag置为2表示当前还是根节点被选入合并后两个最小节点分别标记为左孩子0和右孩子1。这样在建树循环里判断“还剩几棵树”就特别直接——只要扫描数组中flag2的节点就行不需要额外维护一套森林结构。我在自己重写时习惯把encod单独拆出去因为真正的哈夫曼编码一般在建树完成后用递归从根到叶子走一遍生成而不是在建树过程中动态拼接。不过课设源码里把这个数组放在结构体内是为了让每个叶子节点都自带编码结果后面做文段编码时查表更省事。两种做法都能跑区别只在逻辑复杂度。2.2 构建哈夫曼树search_min 的筛选顺序决定编码质量建树函数creat_Btree的逻辑是典型的“森林合并”每次从当前节点集合里挑出两个权值最小的节点合并成新树新树的权值等于两者之和。这里有个容易被忽略的细节k变量既做循环控制又表示剩余根节点数量源码里开局先做了一次k--这就是为了配合while(k1)的退出条件。Bnode *creat_Btree(int k, Bnode z[MAX], int n, Bnode *head[20]) { Bnode *a, *b, *c; int i 0, j; a b c (Bnode *)malloc(sizeof(Bnode)); k--; // k 相当于循环控制变量减 1 是循环的需要 while (k 1) { a search_min(z, n, k); // 找最小权值节点 a-flag 3; // 标记已被选择 b search_min(z, n, k); // 再找次小权值节点 c-weight a-weight b-weight; a-parent c; // 指针移动节点关系确立 b-parent c; c-lchild a; c-rchild b; a-flag 0; // 左孩子标记 b-flag 1; // 右孩子标记 c-flag 2; // 新根节点标记 n; // z 中元素个数加一 z[n] *c; k--; // 根节点数减一a、b 变成孩子c 成为新根 } if (k 1) { for (i 0; i n; i) if (z[i].flag 2) return (z[i]); } }search_min没有在正文里贴出完整实现但它的筛选逻辑必须满足两个条件未被合并flag不是3且权值最小。很多翻车现场都出在这里如果search_min只比较权值不检查flag会把已经被选走的节点再选一次导致左右孩子指向同一节点树就废了。另一个关键点是c节点的处理。源码里abc(Bnode *)malloc(sizeof(Bnode))三个指针指向同一块内存后续每轮用c-weight、c-lchild更新的是同一块内存然后通过z[n]*c把它复制进数组。这种做法可行但有个隐患如果某轮合并后没有及时重置c的左右孩子指针新树会带着上一轮的脏数据。我的习惯是每轮开始前单独malloc一个新节点避免指针复用带来悬垂引用。2.3 前缀码生成与译码从根到叶子走路径查表还原字符建树完成后哈夫曼编码的生成分两步从根节点开始向左走记0、向右走记1到叶子节点就得到该字符的前缀码。课设源码里用encod数组保存编码但我实际跑的时候发现一个坑如果直接在原结构体上递归生成编码需要在递归进入左子树前拷贝一份当前编码串否则兄弟节点的编码会互相污染。我会用临时字符数组做路径拼接到叶子节点时再拷进encod。译码则简单得多从根节点出发遇到0走左孩子、遇到1走右孩子走到叶子输出name再回到根继续读下一位。这里建议用while循环而不是递归因为实际待译码文本可能很长递归深度太深容易爆栈。写译码模块时要注意文件操作题目要求全程信息用文件保存所以编码结果、字符权值表最好各自落盘。我一般按“一行一个字符和权值”的格式存权值表编码结果直接存二进制串这样译码时先读表重建哈夫曼树再读编码串逐位译码和源程序里分模块设计的思路一致。3. 跳马问题深度优先遍历与栈回溯怎么保证不重复走完 64 格3.1 方向数组与坐标合法性八个方向用两个定长数组表达国际象棋里马的走法是“日”字也就是横向走两格加纵向走一格或者纵向走两格加横向走一格。对棋盘上任意坐标(i,j)一步之内能到达的位置有八个源码用两个定长数组tryx和tryy把八个方向的偏移量存起来这是避免写八个 if 的最优雅方案。#define MAXNUM 8 // 横纵格数最大值 #define INVALIDDIR -1 // 无路可走 #define MAXLEN 64 // 棋盘总格数 #define MAXDIR 8 // 下一步可走的方向 typedef struct { int x; // 横坐标 int y; // 纵坐标 int direction; // 方向编号 } HorsePoint;方向数组的取值顺序很有讲究。源码里tryx[MAXDIR] {1,2,2,1,-1,-2,-2,-1}tryy[MAXDIR] {-2,-1,1,2,2,1,-1,-2}两个数组按下标一一对应。我把这八个方向画在坐标系里检查过它们覆盖了顺时针方向的全部走法右偏上、正右上、正右下、右偏下、左偏上、正左上、正左下、左偏下。这种顺序并不影响最终结果但会影响搜索路径的形状某些课设要求输出指定方向的遍历矩阵时调换顺序会得到不同答案。方向数组是整个跳马程序的地基。我第一次写的时候漏了newpoint.x0这个下界判断导致马跳到负数坐标数组越界后棋盘数据被改写程序进入死循环。后来我把合法性判断收敛成一个函数所有方向都走同一套边界检查问题就消失了。3.2 压栈、出栈与回溯count 控制搜索深度ChessBoard 标记已走位置跳马问题的解法本质是深度优先搜索加回溯。源码用一个结构体数组ChessPath模拟栈count表示当前栈内节点数量ChessBoard二维数组标记棋盘上哪些位置已经走过。入栈和出栈是最核心的两个操作。void PushStack(HorsePoint positon) { ChessBoard[positon.x][positon.y] 1; // 标记已走过 ChessPath[count] positon; count; } HorsePoint PopStack() { HorsePoint positon; count--; positon ChessPath[count]; ChessBoard[positon.x][positon.y] 0; // 回溯时撤销标记 ChessPath[count].direction INVALIDDIR; return positon; }PushStack和PopStack成对出现是回溯算法的典型写法进入新位置时压栈并标记无路可走时出栈并撤销标记。这里最关键的思维转换是ChessBoard标记的撤销必须在PopStack里做而不是在尝试方向失败时做。否则会出现一个位置被标记后又回退但另一个分支还没探索就被错误拦截。搜索主循环CalcPoint的退出条件是count0 || countMAXLEN。count0表示从当前起点出发所有路径都试过仍然没走完countMAXLEN表示 64 格全部走完任务达成。这个条件判断放在while开头比放在循环末尾更保险能避免最后一步导致数组越界。3.3 方向试探与父节点更新GetNewPoint 里最容易写错的 direction 自增GetNewPoint是跳马问题里逻辑最绕的一个函数负责试探当前节点的下一跳。源码里parent-direction parent-direction这行是典型的“先自增再赋值”意味着每次调用都会从下一个方向开始试探而不是固定从方向 0 开始。HorsePoint GetNewPoint(HorsePoint *parent) { int i; HorsePoint newpoint; int tryx[MAXDIR] {1,2,2,1,-1,-2,-2,-1}; int tryy[MAXDIR] {-2,-1,1,2,2,1,-1,-2}; newpoint.direction INVALIDDIR; parent-direction parent-direction; for (i parent-direction; i MAXDIR; i) { newpoint.x parent-x tryx[i]; newpoint.y parent-y tryy[i]; // 判断坐标是否在棋盘范围内且该位置没有被走过 if (newpoint.x MAXNUM newpoint.x 0 newpoint.y MAXNUM newpoint.y 0 ChessBoard[newpoint.x][newpoint.y] 0) { parent-direction i; return newpoint; } } parent-direction INVALIDDIR; return newpoint; }direction字段在HorsePoint里存的是“当前试探到第几个方向”。当GetNewPoint找到合法方向时会把parent-direction更新为i这样下次再试探时从i1开始不会重复尝试已失败的方向。这个机制是整个回溯搜索能跑通的核心。我调试时发现一个很容易翻车的点parent-direction parent-direction在不同编译器下的行为不完全一致VC 6.0 里它是先取旧值再加一并赋值但某些编译器会先自增再返回。建议直接改成parent-direction 1语义更明确。另外CalcPoint里拿到npositon后要先判断ppositon-direction ! INVALIDDIR再决定压栈还是出栈这行判断漏掉的话会把一个无效坐标压进栈里。4. 长整数运算与递归替换问题双向链表存储大数#include 递归展开4.1 长整数的链表结构设计每个结点存一位还是多位长整数运算问题的核心是突破 C 语言整型范围限制。课设要求实现两个任意长整数的加减乘源码采用双向循环链表存储每个结点含一个整型变量。这里有一个设计决策值得重点说每个结点存一位十进制数还是多位如果每个结点只存一位加减运算最简单但空间利用率低乘法时进位处理也更琐碎。如果每个结点存 4 位甚至 9 位乘法效率会高很多但代码里要处理模和进位的边界。课设源码采用每个结点一个整型变量的方案胜在逻辑清晰适合答辩时讲明白。结点结构体可以设计为typedef struct Node { int data; // 当前结点存放的数字 struct Node *prior; // 前驱指针 struct Node *next; // 后继指针 } DNode;我重写时会优先存 4 位一组因为乘法用10000做模和进位计算非常规整输出时用%04d补齐前导零即可。但如果是照着课设源码复现先按一位一结点跑通再优化成多位一组这条路更稳妥。4.2 加减乘的逐位运算进位标记和结果位数怎么控制加法运算从链表尾结点开始逐位相加用一个carry变量记录进位。两个数位数不一样时短链表对应位置补零。减法要处理大数减小数先比较两数长度不够减时向高位借位输出前把结果链表头部多余的零结点删掉。乘法最直接的做法是双重循环第一层遍历被乘数结点第二层遍历乘数结点乘积累加到结果链表对应位置上。// 以加法为例a、b 是存储长整数的双向循环链表头指针 void Add(DNode *a, DNode *b) { DNode *pa a-prior; // 指向最低位 DNode *pb b-prior; int carry 0, sum; while (pa ! a || pb ! b || carry) { sum carry; if (pa ! a) { sum pa-data; pa pa-prior; } if (pb ! b) { sum pb-data; pb pb-prior; } carry sum / 10; InsertNode(sum % 10); // 把当前位插入结果链表头部 } }写长整数乘法时最容易错的不是乘法本身而是进位的叠加。因为a[i] * b[j]的结果可能超过一个结点能容纳的范围如果每位只留一位十进制数内层循环的进位变量要不断累加而不是覆盖。我习惯在算完一整轮后再统一处理进位避免中途调整链表结构。4.3 递归替换问题扩展 #include 指令的编程思路与边界递归替换问题放在长整数运算这一章后面讲是因为它也涉及文件读写和递归两个重难点。题目要求读取 C/C 源文件把形如#include filename的行替换成对应文件的内容并且递归处理被引入文件里的更多#include最终输出一个展开后的完整文件。typedef struct { char data[MAX]; // 数据项 } char1; int print(char ch[MAX], int n) { FILE *fp, *fp1; char s[MAX]; // s 存储 # 后面的 include 关键字 char1 b[MAX]; // b 中存储文件内容 int i 0, k, d 0, j, flag; if ((fp fopen(ch, rb)) NULL) { printf(文件打开失败\n); exit(0); } while (!feof(fp)) { fread(b[i], 1, sizeof(char1), fp); i; if (b[i - 1].data[0] }) // 读到右花括号就停止 break; } k i - 1; fclose(fp); // 后续循环扫描 b 数组遇到 #include 则递归调用 print 处理被引入文件 }这个程序的关键在于flag b[i].data[19] - 0这种“按固定位置取值”的技巧。源码里用文件名中第 20 个字符来区分被引入文件是哪一个这在三个固定文件的课设场景下可行但几乎没有扩展性。如果是自己写,建议用sscanf从行中提取真正的文件名而不是依赖固定下标。递归的终止条件也要想清楚。被引用的文件里还可能再出现#include所以print函数会不断向下展开直到某个文件不再包含#include为止。这里必须防止循环包含比如文件 A include 文件 B文件 B 又 include 文件 A不加访问标记就会无限递归。课设源码没有处理这种情况只能靠文件内容保证不出现循环引用实际工程里要维护一个“已展开文件”集合。5. 避坑笔记数据结构课设里最常见的五类翻车现场5.1 哈夫曼树节点选择错误建出来的树不像树现象编码结果里有字符的编码是另一个字符编码的前缀译码时对不上。原因search_min筛选最小节点时没有排除已经被合并过的节点导致同一个节点被选中两次。解决给节点加flag状态位筛选时只允许flag2的节点参与比较选完后马上把状态改成3。5.2 跳马程序输出矩阵但某些点是零现象程序结束虽没有报错但输出的 8x8 矩阵里有些坐标值是 0。原因PrintChess里用countMAXLEN判断是否全部走完但搜索过程中count在回溯时会减小若有分支没走通就提前结束了。解决在PrintChess里加一个独立计数器遍历ChessPath统计实际路径长度或者把路径输出和栈深度解耦用数组单独记录完整路径。5.3 递归替换处理大文件时突然崩溃现象源文件只有几 MB递归展开到一半程序退出提示栈溢出。原因递归深度过深print函数每次都在栈上分配大数组char1 b[MAX]几层递归下来就把栈耗尽了。解决把char1 b[MAX]改成动态分配的指针或者用显式栈模拟递归这样不受系统栈大小限制。5.4 长整数乘法结果多出前导若干零现象两个大数相乘结果末尾多了几个 0数值错得离谱。原因进位处理时把进位值留在了结果链表里没有参与下一轮运算或者插入结点时把高位零也插进去了。解决乘法内层循环结束后单独检查进位值若大于零则作为新结点插入链表头部输出前从高位开始跳过值为 0 的结点。5.5 VC 6.0 里编译通过运行时中文乱码现象控制台输出中文标题和提示语时乱码文件里读出的中文字符也乱码。原因VC 6.0 默认使用本地代码页而源文件如果是 UTF-8 编码就会显示异常。解决把源文件另存为 GB2312 编码或者直接在程序开头调用system(chcp 936)把控制台代码页切到简体中文。这不是数据结构的问题但课设验收时翻车概率极高。6. 验证思路与报告收尾用函数调用关系图和边界用例说服老师6.1 测试用例怎么设计四个题目分别补什么边界哈夫曼编码要测试权值相同的字符、只有一个字符的输入、所有字符权值都相等等极端情况。跳马问题要分别从棋盘角、棋盘中心、边缘位置出发记录哪些起点无解直观理解马踏棋盘的局部无解现象。长整数运算要测位数不对称的加法、结果为负值的减法、零乘大数。递归替换要准备三层嵌套的 include 文件和包含自引用的文件确认递归的确切行为。6.2 函数调用关系图怎么画按数据流而不是代码调用顺序课设报告里要求画函数调用关系图很多同学直接抄源码里的函数调用顺序画出来的图全是线。我的习惯是先画数据流入口函数读文件或键盘输入经过核心算法函数处理后结果流向输出函数。哈夫曼编码的creat_Btree调用search_min是垂直关系跳马问题的CalcPoint调用PushStack、PopStack、GetNewPoint是水平协作关系两者画法完全不同。最后分享一个个人习惯每次交课设前我都会把四个题目的 main 函数入口统一整理成一个菜单用一个switch分发到四个模块。这样做的好处是验收时能当场演示任意一道题而不是临时重新编译。递归替换模块的文件名也不要写死成 “辅助.c”改成从参数传入这样能直接测试老师给的任意样例。从那以后我每次做课设都会强制走一遍“模块入口统一 输入参数可选”的设计流程省去了很多答辩现场的尴尬。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站