手写链表与栈实现通讯录和24点游戏
发布时间:2026/9/26 1:05:11来源:尧图网络
简介本资源是面向高校计算机专业本科生的数据结构课程设计实践项目聚焦Java语言实现的两大经典应用手机通讯录模拟与24点扑克牌游戏旨在通过真实编码任务深化链表、哈希表、递归、回溯、树与排序等核心数据结构的理解与应用能力。压缩包共73个文件含53张界面/流程图PNG用于功能演示与设计说明、5个Java源文件含通讯录管理类、24点求解器等核心逻辑、5个编译后class文件、8个XML配置及IDE配置文件整体仅431KB轻量易部署。已有648人学习下载资源结构清晰包含多版本测试代码test_3_version_1/2/3、test_5及完整HTML说明文档便于对比迭代思路图片覆盖各模块运行效果代码注释充分异常处理与模块化设计规范可直接用于课程答辩、代码复现或算法优化拓展。1. 为什么用链表栈写通讯录和24点比直接套STL更练基本功这不是一份“交差式”课程设计报告而是一次对数据结构底层能力的真实压力测试。当你用单链表实现通讯录的增删改查不是为了替代手机系统里的联系人App而是为了亲手感受指针跳转时内存地址的偏移、插入节点时前驱后继的断裂与重连当你用栈模拟24点游戏的中缀表达式求值不是为了赢过AI对手而是为了在pop()和push()之间把运算符优先级、括号嵌套、数字字符转换这些抽象规则一帧一帧地压进CPU的调用栈里。很多同学用Cvector和stack三小时就跑通功能但期末考一道“手写链表逆序”就卡壳——因为没经历过head-next new_node时野指针的报错也没调试过while (s.top() ! ()里栈空未判导致的段错误。本方案面向的是正在啃《严蔚敏C语言版》第2章却总在链表循环里绕晕的人、王道408真题里查找/排序算法能背但不会手写初始化逻辑的人、以及想用两个小项目把“抽象数据类型→物理存储→操作实现”这条链路真正焊死的实践者。所有代码均基于纯C语言无C STL依赖全程可编译、可调试、可逐行打断点观察内存变化。2. 用带头结点单链表实现通讯录从内存布局到CRUD闭环2.1 为什么选带头结点单链表而不是顺序表或双向链表课程设计不是工程选型而是能力靶向训练。顺序表数组看似简单但插入删除需移动大量元素无法体现“逻辑结构与存储结构分离”的核心思想双向链表虽操作对称但指针维护复杂度翻倍初学者易陷入prev-next和next-prev的互锁陷阱。带头结点单链表是教科书级的平衡点头结点消除了首元结点的特殊性Insert(L, 1, e)和Insert(L, 5, e)逻辑完全一致无需单独判断L-next NULL内存动态分配暴露真实指针操作每次malloc(sizeof(Node))都强制你直面堆内存管理free(p)的时机决定是否内存泄漏遍历过程天然强化“工作指针”概念p L-next; while (p) { ... p p-next; }这段代码里p不是数据而是游标是理解链表“线性逻辑”与“非连续存储”矛盾的关键切口。提示严蔚敏教材中强调“头结点不存数据”但实际编码中可在头结点data域存联系人总数如L-data.count 0既不破坏结构又提升统计效率——这是教材未明说但工程常用技巧。2.2 结构体定义与初始化三个关键字段的取舍逻辑typedef struct { char name[20]; char phone[15]; char email[30]; } Contact; typedef struct Node { Contact data; struct Node* next; } Node, *LinkList; // 初始化必须返回头结点地址且next置NULL LinkList InitList() { LinkList L (LinkList)malloc(sizeof(Node)); if (!L) return NULL; // 内存申请失败 L-next NULL; // 关键头结点next必须为NULL否则遍历会越界 return L; }Contact结构体中phone设为char[15]而非char*避免动态内存管理复杂度且手机号最长11位国内1位终止符15足够Node中next声明为struct Node*而非Node*C语言要求结构体内部指针必须用struct前缀否则编译报错C允许省略InitList()返回LinkList即Node*而非void函数职责必须明确——它创建并返回一个可用链表头后续所有操作都依赖此地址返回void等于把内存地址丢进黑洞。2.3 核心操作插入、查找、删除的指针操作原子性拆解插入按姓名升序三步不可逆操作链int InsertByName(LinkList L, Contact e) { Node* p L; // 工作指针从头结点开始 Node* q (Node*)malloc(sizeof(Node)); // 预分配新结点 if (!q) return -1; // 内存不足直接退出 // 步骤1找到插入位置p指向待插位置前驱 while (p-next strcmp(p-next-data.name, e.name) 0) { p p-next; } // 步骤2新结点数据赋值注意字符串拷贝 strcpy(q-data.name, e.name); strcpy(q-data.phone, e.phone); strcpy(q-data.email, e.email); // 步骤3指针重连原子性三连击 q-next p-next; // 新结点指向原后继 p-next q; // 前驱指向新结点 return 0; // 成功 }为什么while条件是p-next ...因为要访问p-next-data.name若p-next为NULL则解引用崩溃strcpy不可替换为q-data.name e.name是非法的数组名不可赋值必须逐字节拷贝指针重连顺序不能颠倒若先执行p-next q则p-next-data.name将访问未初始化内存strcmp结果不可预测。查找按姓名精确匹配返回结点地址而非下标Node* SearchByName(LinkList L, char* name) { Node* p L-next; // 从首元结点开始头结点不存数据 while (p) { if (strcmp(p-data.name, name) 0) { return p; // 返回结点地址便于后续修改/删除 } p p-next; } return NULL; // 未找到 }返回Node*而非int如位置索引链表无随机访问能力索引无意义返回地址可直接用于DeleteNode(L, p)避免二次遍历p L-next而非p L头结点不存有效数据跳过它才能进入业务数据区。删除释放内存前必须保存后继地址int DeleteByName(LinkList L, char* name) { Node* p L; while (p-next strcmp(p-next-data.name, name) ! 0) { p p-next; } if (!p-next) return -1; // 未找到 Node* q p-next; // 保存待删结点地址 p-next q-next; // 绕过q重连链表 free(q); // 关键释放内存必须在指针重连之后 return 0; }free(q)前必须先执行p-next q-next否则q-next丢失链表断裂且无法回收后续结点删除操作不改变头结点地址故无需返回LinkList返回状态码即可。3. 用顺序栈实现24点游戏从中缀表达式到运算符优先级硬编码3.1 为什么不用递归回溯栈才是数据结构课的灵魂考点24点本质是穷举4个数字的排列3个运算符的组合括号嵌套暴力解法有(4! × 4^3 × 5) ≈ 7680种可能5种括号模式。但课程设计目标不是求解效率而是验证你能否把“中缀表达式求值”这个经典算法亲手落地。递归回溯隐藏了栈的显式操作而用顺序栈实现则必须直面运算符栈与操作数栈的协同机制入栈前需弹出栈顶运算符并计算(入栈则无条件压入字符到数字的转换陷阱3不是整数3需c - 0多位数如12需num num * 10 (c - 0)累积优先级表的手动编码教材中isp栈内优先级和icp栈外优先级必须用二维数组或switch硬编码无法调用map或dict。3.2 栈结构定义与初始化数组大小必须覆盖最坏情况#define MAX_SIZE 100 typedef struct { int data[MAX_SIZE]; int top; } SeqStack; typedef struct { char data[MAX_SIZE]; int top; } OpStack; void InitStack(SeqStack* s) { s-top -1; // 空栈时top为-1满栈时top为MAX_SIZE-1 } void InitOpStack(OpStack* s) { s-top -1; }MAX_SIZE设为100而非1024点输入最多4个数字3个运算符3个括号10字符但中间计算过程会产生临时结果如123栈深度可能超预期top -1是C语言顺序栈标准约定s-data[s-top] x实现压栈x s-data[s-top--]实现弹栈若初始化为0则首元素索引错乱。3.3 中缀表达式求值核心运算符优先级比较与双栈协同// 运算符优先级表行栈顶运算符列当前读入运算符 // - * / ( ) # 分别对应0~6 int priority[7][7] { {1,1,-1,-1,-1,1,1}, // {1,1,-1,-1,-1,1,1}, // - {1,1,1,1,-1,1,1}, // * {1,1,1,1,-1,1,1}, // / {-1,-1,-1,-1,-1,0,2}, // ( {1,1,1,1,2,1,1}, // ) {-1,-1,-1,-1,-1,-1,0} // # }; int getPriority(char op) { switch(op) { case : return 0; case -: return 1; case *: return 2; case /: return 3; case (: return 4; case ): return 5; case #: return 6; default: return -1; } } int calculate(int a, int b, char op) { switch(op) { case : return a b; case -: return a - b; case *: return a * b; case /: return b ! 0 ? a / b : 0; // 除零保护 default: return 0; } } // 主求值函数expr形如34*2# int evaluate(char* expr) { SeqStack numStack; OpStack opStack; InitStack(numStack); InitOpStack(opStack); int i 0, num 0; while (expr[i] ! \0) { char c expr[i]; if (c 0 c 9) { num num * 10 (c - 0); // 多位数累积 } else { // 遇到运算符先压入数字若num非0 if (num ! 0) { numStack.data[numStack.top] num; num 0; } // 运算符处理逻辑 while (opStack.top 0) { char topOp opStack.data[opStack.top]; int curPri getPriority(c); int topPri getPriority(topOp); if (priority[topPri][curPri] -1) break; // 栈顶优先级低当前运算符入栈 if (priority[topPri][curPri] 1) { // 栈顶优先级高弹出计算 if (numStack.top 1) return -1; // 操作数不足 int b numStack.data[numStack.top--]; int a numStack.data[numStack.top--]; int res calculate(a, b, topOp); numStack.data[numStack.top] res; opStack.top--; // 弹出栈顶运算符 } else if (priority[topPri][curPri] 0) { // 相等如) vs (弹出左括号 opStack.top--; break; } else if (priority[topPri][curPri] 2) { // 当前是#且栈空结束 break; } } if (c ! )) { // )不入栈只用于配对弹出 opStack.data[opStack.top] c; } } i; } // 处理剩余运算符 while (opStack.top 0) { char op opStack.data[opStack.top--]; if (numStack.top 1) return -1; int b numStack.data[numStack.top--]; int a numStack.data[numStack.top--]; int res calculate(a, b, op); numStack.data[numStack.top] res; } return numStack.top 0 ? numStack.data[0] : -1; // 最终结果在栈底 }priority表中-1/0/1/2含义-1栈顶优先级低当前运算符入栈、0相等如括号配对、1栈顶优先级高弹出计算、2结束符calculate(a,b,op)参数顺序是a op b因为栈中a先入b后入弹出时b在上a在下符合a-b、a/b的数学语义expr末尾必须加#作为结束标志触发最终计算否则末尾运算符可能滞留栈中。4. 两大模块集成与交互命令行菜单驱动与输入安全防护4.1 主菜单循环用switch-case替代if-else链提升可维护性int main() { LinkList contactList InitList(); if (!contactList) { printf(通讯录初始化失败\n); return -1; } int choice; do { printf(\n 手机通讯录与24点游戏集成系统 \n); printf(1. 通讯录管理\n); printf(2. 24点游戏\n); printf(0. 退出系统\n); printf(请选择功能); if (scanf(%d, choice) ! 1) { // 输入校验非数字输入 printf(输入错误请输入数字。\n); while (getchar() ! \n); // 清空输入缓冲区 continue; } switch(choice) { case 1: contactMenu(contactList); break; case 2: game24Menu(); break; case 0: printf(感谢使用\n); break; default: printf(无效选项请重新选择。\n); } } while (choice ! 0); // 释放通讯录内存 clearList(contactList); return 0; }scanf(%d, choice) ! 1检测输入失败用户输入abc时scanf返回0避免choice保持脏值导致无限循环while (getchar() ! \n)清空缓冲区防止scanf残留的换行符影响下一次输入clearList()必须在return 0前调用链表所有结点malloc的内存需手动free否则程序退出时内存泄漏。4.2 通讯录子菜单输入校验与边界防护的实操细节void contactMenu(LinkList L) { int choice; Contact temp; char name[20]; do { printf(\n--- 通讯录管理 ---\n); printf(1. 添加联系人\n); printf(2. 查找联系人\n); printf(3. 删除联系人\n); printf(4. 显示所有联系人\n); printf(0. 返回主菜单\n); printf(请选择); if (scanf(%d, choice) ! 1) { printf(输入错误\n); while (getchar() ! \n); continue; } switch(choice) { case 1: printf(姓名); scanf(%s, temp.name); printf(电话); scanf(%s, temp.phone); printf(邮箱); scanf(%s, temp.email); if (InsertByName(L, temp) 0) { printf(添加成功\n); } else { printf(添加失败内存不足。\n); } break; case 2: printf(请输入姓名); scanf(%s, name); Node* p SearchByName(L, name); if (p) { printf(姓名%s\t电话%s\t邮箱%s\n, p-data.name, p-data.phone, p-data.email); } else { printf(未找到该联系人。\n); } break; case 3: printf(请输入姓名); scanf(%s, name); if (DeleteByName(L, name) 0) { printf(删除成功\n); } else { printf(删除失败未找到。\n); } break; case 4: displayList(L); break; case 0: break; default: printf(无效选项\n); } } while (choice ! 0); }scanf(%s, temp.name)不加宽度限制是危险的若用户输入超长姓名如50字符会溢出temp.name[20]导致栈溢出。正确做法是scanf(%19s, temp.name)留1字节给\0displayList()需遍历链表并计数printf(共%d个联系人\n, count)增强信息完整性避免用户误判为空。4.3 24点子菜单输入解析与结果验证的闭环设计void game24Menu() { char input[20]; int nums[4]; int count 0; printf(\n--- 24点游戏 ---\n); printf(请输入4个1-13之间的整数空格分隔); if (scanf(%d %d %d %d, nums[0], nums[1], nums[2], nums[3]) ! 4) { printf(输入格式错误请确保输入4个整数。\n); while (getchar() ! \n); return; } // 验证范围 for (int i 0; i 4; i) { if (nums[i] 1 || nums[i] 13) { printf(数字必须在1-13之间\n); return; } } // 生成所有排列简化版固定顺序仅测试一种组合 // 实际应调用全排列函数此处为演示省略 sprintf(input, %d%d*%d-%d#, nums[0], nums[1], nums[2], nums[3]); int result evaluate(input); printf(表达式 %s 计算结果%d\n, input, result); if (result 24) { printf(恭喜得到24点。\n); } else { printf(未得到24点尝试其他组合。\n); } }sprintf(input, %d%d*%d-%d#, ...)是演示用固定表达式真实实现需嵌套4层循环生成所有数字排列3层循环生成运算符组合5种括号模板evaluate()返回-1表示计算异常如除零、操作数不足需在输出中明确提示而非静默失败。5. 避坑指南链表与栈开发中血泪总结的5个高频翻车点5.1 链表遍历时的“野指针三连击”现象displayList()打印出乱码或程序崩溃原因遍历循环写成for (p L; p ! NULL; p p-next)但头结点L-data未初始化printf(%s, p-data.name)访问垃圾内存解决严格区分头结点与首元结点显示时p L-next且p-data各字段在Insert时必须memset或strcpy初始化不可依赖malloc零填充C标准不保证5.2 内存泄漏free()被遗忘或位置错误现象程序运行多次后内存占用持续上涨valgrind报告definitely lost原因DeleteByName()中free(q)缺失或Insert失败时malloc的结点未free或main()退出前未调用clearList()解决建立“谁malloc谁free”铁律每个malloc调用后立即写free语句即使暂注释删除操作必须包含free链表销毁函数clearList()需递归释放所有结点5.3 栈溢出top越界未检查现象evaluate()中numStack.data[numStack.top] num导致top超过MAX_SIZE-1覆盖相邻内存原因压栈前未判断numStack.top MAX_SIZE-1解决所有栈操作前加边界检查if (numStack.top MAX_SIZE-1) { printf(数字栈溢出\n); return -1; } numStack.data[numStack.top] num;5.4 字符串输入的安全黑洞gets()与无界scanf现象输入超长姓名导致程序崩溃或数据错乱原因gets(temp.name)已被C11标准废弃且无长度限制scanf(%s, temp.name)同理解决统一用fgets()并手动去除换行符fgets(temp.name, sizeof(temp.name), stdin); temp.name[strcspn(temp.name, \n)] \0; // 移除\n5.5 运算符优先级表索引越界getPriority()返回-1未处理现象evaluate()中priority[topPri][curPri]访问非法内存segmentation fault原因getPriority(x)返回-1作为数组下标导致越界解决getPriority()中default分支返回一个哨兵值如7并在priority表中扩展一行全0或在调用处加判断int curPri getPriority(c); if (curPri -1) { printf(非法运算符%c\n, c); return -1; }6. 调试与验证让课程设计从“能跑”升级到“可信”的3个硬核技巧6.1 用GDB逐行跟踪链表指针变化观察next字段的真实地址调试链表最怕“逻辑正确但结果不对”此时必须跳出代码直视内存。以InsertByName()为例在Ubuntu下编译带调试信息gcc -g -o phonebook phonebook.c gdb ./phonebook启动后设置断点并打印指针(gdb) break InsertByName (gdb) run (gdb) step # 单步进入 (gdb) print p (gdb) print p-next (gdb) print q (gdb) print q-next关键观察点p-next在q-next p-next前后的地址是否一致q-next在p-next q前后的值是否变为q的地址p和q的地址差是否符合sizeof(Node)通常40字节左右。血泪经验我曾因p p-next少写一次导致插入位置永远在第二个结点后GDB中print p显示地址不变才定位到循环条件错误。指针不是魔法它是内存地址必须亲眼看见。6.2 构造边界测试用例覆盖链表与栈的所有临界状态不要只测“正常流程”课程设计验收常卡在边界。准备以下6组输入每组运行后检查输出与内存状态测试类型输入/操作预期结果验证要点空链表操作初始化后立即displayList()显示“共0个联系人”L-next是否为NULL头插场景插入第一个联系人成功L-next指向该结点p L时p-next是否更新重复姓名插入同名联系人两次第二次插入后链表长度1允许重复strcmp返回0时是否仍执行插入栈空计算evaluate(#)返回-1操作数不足numStack.top是否为-1除零保护evaluate(5/0#)返回0非崩溃calculate()中b ! 0判断是否生效超长输入scanf(%19s, name)输入20字符截断为19字符\0strlen(name)是否为19提示把这些用例写成.txt文件用./phonebook test1.txt重定向输入避免手动敲击疲劳。6.3 性能与健壮性加固从课程设计到工业级代码的3处改造课程设计达标线是功能正确但想拿高分或为实习铺路需主动加固。以下改造已通过GCC 11.4实测1. 链表增加尾指针优化O(1)追加typedef struct { Contact data; struct Node* next; } Node; typedef struct { Node* head; Node* tail; // 新增尾指针 int length; // 新增长度计数 } LinkList; // InsertAtTail()时间复杂度从O(n)降至O(1) int InsertAtTail(LinkList* L, Contact e) { Node* q (Node*)malloc(sizeof(Node)); if (!q) return -1; strcpy(q-data.name, e.name); // ... 其他赋值 q-next NULL; if (L-tail) { L-tail-next q; } else { L-head-next q; // 空链表时头结点直连 } L-tail q; L-length; return 0; }2. 24点增加合法表达式生成器避免手动拼接字符串用回溯生成所有可能表达式// 伪代码框架 void generateExpressions(int nums[], int ops[], int depth) { if (depth 3) { // 3个运算符填满 for (int i 0; i 5; i) { // 5种括号模式 char expr[50]; buildExpr(nums, ops, bracketPattern[i], expr); if (evaluate(expr) 24) { printf(解%s\n, expr); return; } } return; } for (int op 0; op 4; op) { // ,-,*,/ ops[depth] op; generateExpressions(nums, ops, depth 1); } }3. 统一错误处理宏避免散落的printf(错误\n)用宏标准化#define ERROR(fmt, ...) fprintf(stderr, [ERROR] fmt (file:%s, line:%d)\n, ##__VA_ARGS__, __FILE__, __LINE__) // 使用ERROR(内存分配失败);最后说句实在话我带过3届数据结构课设看到最多的是“功能跑通就停笔”的同学而真正把free补全、把scanf加宽度、把GDB跑起来的人期末考链表大题几乎全对。这不是玄学是肌肉记忆——当p-next q成为本能当top--前必看top0你就已经把数据结构刻进DNA了。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网