ARTICLE DETAIL

资讯详情

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

C语言图书管理系统实战:链表实现与txt文件存储详解

C语言图书管理系统实战:链表实现与txt文件存储详解 简介这是一套基于C语言链表实现的完整图书管理系统课程设计级项目面向计算机专业初学者与数据结构实践者解决传统数组式管理在动态增删改查场景下的性能与扩展性瓶颈问题。资源包共35个文件包含9个核心C源码如main.c、bookinfo.c、userRegisterLogin.c、2个关键数据文件books.txt和users.txt用于持久化存储、1个头文件head.h、2个布局文件及可直接运行的图书管理系统.exe辅以程序设计说明书docx/pdf和Makefile.win等构建支持文件总大小2.96MB。已有5547人学习下载体现了其在教学实践中的广泛认可度。读者可获得模块清晰、职责分离的工程化代码结构——管理员与读者双角色功能完备支持导入导出、组合查询、多字段排序与借阅统计并具备良好的可读性与调试基础是理解链表应用、文件I/O与小型系统架构的优质范例。 到期末周了又到了一年一度C语言大作业冲刺的时候。图书管理系统这个题目我估计每个学校都在用链表存储、文件保存、控制台交互三件套齐活。但说实话很多同学交上来的代码老师一眼扫过去就知道是拼出来的还是自己写的——不是代码跑不通而是整体的设计思路、函数划分、错误处理太“散”了。今天我就把这个项目从数据结构设计、函数拆解到文件格式约定、边界处理完整过一遍。你把这篇文章吃透不仅能把作业写完还能在验收的时候从容回答老师的追问。1. 这个项目到底要做什么需求拆解与模块划分图书管理系统的核心价值其实就一句话用C语言模拟一个最简化的图书借阅流程。它跟真实的图书馆管理系统没法比但在数据结构课程设计里它恰好能把链表操作、文件持久化、结构体嵌套、字符串处理这些C语言的核心知识点全部串起来。1.1 从标题能提炼出的隐含需求你仔细看标题——“C语言图书管理系统 链表实现 功能齐全书籍信息以及用户信息保存在附带的txt文件中”里面有三个关键信息链表实现说明数据的动态存储用链表不是定长数组。这要求你处理节点的动态创建、插入、删除、查找、排序、释放。功能齐全这词儿很微妙不同老师对“齐全”的定义不一样。但最基本的底线是——图书的增删改查、用户的注册登录、借书还书这六件事不能少。往上还可以加按书名/作者/ISBN搜索、按出版社统计、按借阅次数排序、逾期罚款计算、图书数量超限校验。txt文件保存程序退出后数据不能丢重启后要能从txt里加载回来。这是文件读写操作的考点后面我会重点讲。1.2 模块划分先画清楚分工再做代码我见过太多同学一上来就写main函数写完两百行发现逻辑乱套。正确的做法是先分模块。以这个项目为例合理的模块划分应该是模块职责涉及的知识点数据层图书/用户结构体定义、链表头节点管理结构体、指针持久层从txt加载数据、保存数据到txt文件读写、字符串解析业务层增删改查、借书还书、排序统计链表操作、排序算法交互层菜单循环、接收用户输入、展示结果printf/scanf、输入校验入口层程序初始化、主循环、退出清理main函数设计建议你写代码的时候把同层代码放同一个文件里拆成函数不要全部堆在main里。哪怕你最终只提交一个.c文件也写在文件里用注释分区分好老师看到有层次感的代码印象分会高很多。2. 链表数据结构怎么定节点设计决定后面的路好不好走2.1 图书节点和用户节点要分开不要图省事共用结构体有些同学为了省事用一个Book结构体把书名、作者、借书人全塞进去。这种做法在应付极简演示时勉强能跑但一旦涉及到“一个用户同时借多本书”或者“同一本书被多个用户借过”这种逻辑就崩了。正确的设计是图书链表和用户链表分开维护它们之间靠“借书信息”建立关联。我推荐的做法是// 图书信息 typedef struct Book { char isbn[20]; // ISBN号唯一标识 char title[100]; // 书名 char author[50]; // 作者 char publisher[50]; // 出版社 int total_count; // 馆藏总数 int available_count; // 可借数量 int borrow_count; // 累计借出次数用于排序 struct Book *next; // 链表指针 } Book; // 用户信息 typedef struct User { char user_id[20]; // 学号/工号唯一标识 char name[50]; // 姓名 char password[20]; // 密码 char borrowed_isbn[10][20]; // 当前借阅的ISBN列表最多10本 int borrowed_num; // 当前借了几本 float fine_due; // 待缴罚款 struct User *next; // 链表指针 } User;你可能会问为什么用户节点里不直接放一个“借阅记录链表”而用一个固定数组存ISBN因为对于大作业这个场景固定数组更简单、更直观而且方便保存到txt文件——你遍历文件时按固定格式读回来就行不用频繁malloc。如果你想把项目做成进阶版可以把borrowed_isbn换成指向BorrowRecord结构体的链表但那样文件格式和加载逻辑都会复杂一倍得分不是特别追求优秀的话固定数组够用了。2.2 链表头节点用不存数据的头节点还是直接指针这是很多新手容易纠结的问题。两种做法我都用过给你说一下实测感受直接用指针指向第一个有效节点实现简单但删除第一个节点时要特殊处理“头指针指向第二个节点”的情况代码里全是if (prev NULL)这类分支容易漏判断。用带数据的头节点删除和新增逻辑统一不用考虑头指针更新的特殊情况写起来更省心但如果你遍历链表打印全部节点时不小心把头节点也打印出来会多一行脏数据。我个人的建议是链表头节点用于存储“总数”这类聚合信息不存储实际业务数据。比如头节点的total_count记录馆藏图书数量方便查询。这样既统一了操作逻辑又多了一个统计信息的存储位置。代价是你需要用head-next表示第一个真正的图书节点遍历时从head-next开始。// 创建带头节点的空链表 Book* create_book_list(void) { Book *head (Book*)malloc(sizeof(Book)); if (!head) { printf(内存分配失败\n); exit(1); } // 头节点不存有效数据total_count记录链表长度 strcpy(head-isbn, ); head-next NULL; head-total_count 0; return head; }2.3 为什么这里选链表而不是数组既然图书信息已经存在txt文件里了为什么不一次性读出全部数据放到结构体数组里原因主要有三点增删操作更自然数组中间删除一个元素后面所有元素都得前移时间复杂度O(n)。链表删除节点改一下前后指针就行。内存按需分配文件里有多少条记录就malloc多少个节点。不会出现“数组开小了装不下开大了浪费内存”的问题。课程考核要求这是最现实的原因。链表操作本来就是C语言课程的重点老师明确要求“链表实现”你交个数组方案上去可能连验收都过不了。3. 文件存储格式与读写txt文件不是简单地读一行写一行文件读写是这个项目里最“阴”的部分。很多同学在内存里操作链表没毛病一涉及保存/加载就各种问题。核心原因就是没有设计好文件格式。3.1 文件格式设计定好分隔符和字段顺序我推荐这样一个格式books.txt#ISBN#书名#作者#出版社#总数量-可借数量#借出次数 978-7-111-54778-3#C程序设计#谭浩强#清华大学出版社#5-3#12 978-7-302-45678-9#数据结构#严蔚敏#清华大学出版社#3-2#8用#做字段分隔符因为书名和作者里不太可能出现#比用空格分隔更安全。总数量和可借数量之间用-连起来一个是5-3表示总馆藏5本可借3本。这样一行一个字段的逻辑清楚解析也方便。borrow_count记录累计借出次数为“热门图书排序”功能做准备。users.txt 的格式类似#用户ID#姓名#密码#已借数量#待缴罚款#已借ISBN列表 20210001#张三#123456#2#0.0#978-7-111-54778-3,978-7-302-45678-9已借ISBN列表用逗号分隔。这里要注意设计这种“一行一个完整记录”的格式就是为了让fgets读取每一行、再用sscanf解析时逻辑简单、不容易出错。3.2 加载txt从文件到链表加载的核心逻辑就是逐行读取、按分隔符切割、malloc节点、尾插法挂到链表上。尾插法意味着新节点总是追加到链表末尾这样可以保持和文件里数据的顺序一致。void load_books(Book *head, const char *filename) { FILE *fp fopen(filename, r); if (!fp) { printf(警告书籍数据文件 %s 不存在已创建空链表。\n, filename); return; } char line[1024]; int load_count 0; while (fgets(line, sizeof(line), fp)) { // 跳过空行和注释行 if (strlen(line) 2 || line[0] #) continue; Book *new_node (Book*)malloc(sizeof(Book)); if (!new_node) { printf(内存分配失败加载中断\n); fclose(fp); return; } memset(new_node, 0, sizeof(Book)); new_node-next NULL; char count_text[50]; // 解析一行从line中按#分隔提取字段 // 注意sscanf需要严格匹配格式 int parsed sscanf(line, #%[^#]#%[^#]#%[^#]#%[^#]#%[^#]#%d, new_node-isbn, new_node-title, new_node-author, new_node-publisher, count_text, new_node-borrow_count); if (parsed ! 6) { printf(行解析失败跳过: %s\n, line); free(new_node); continue; } // 解析总数量-可借数量 int total, available; if (sscanf(count_text, %d-%d, total, available) ! 2) { free(new_node); continue; } new_node-total_count total; new_node-available_count available; // 尾插法挂到链表末尾 Book *tail head; while (tail-next) tail tail-next; tail-next new_node; head-total_count; load_count; } fclose(fp); printf(成功加载 %d 条图书记录。\n, load_count); }这里有几个细节要特别提一下%[^#]是sscanf的字符集匹配格式读取“直到遇到#为止”的所有字符这是解析这种分隔符文件的利器。memset(new_node, 0, sizeof(Book))把节点所有字节清零防止字符串数组里有垃圾值后面打印时出现乱码。解析失败时跳过这一行而不是直接崩溃。这种容错虽然简单但能让你在调试时少很多无头绪的崩溃。3.3 保存txt从链表到文件保存逻辑是加载的逆过程但有个坑要特别注意保存时必须用临时文件保存成功后才替换原文件。如果直接打开原文件写写到一半程序崩溃原文件的数据就全废了。用“先写临时文件再重命名替换”的方式就算崩溃原文件还在最多丢一次未保存的数据。int save_books(Book *head, const char *filename) { char temp_file[256]; snprintf(temp_file, sizeof(temp_file), %s.tmp, filename); FILE *fp fopen(temp_file, w); if (!fp) { printf(保存失败无法创建临时文件\n); return 0; } fprintf(fp, #ISBN#书名#作者#出版社#总数量-可借数量#借出次数\n); Book *p head-next; for (; p ! NULL; p p-next) { fprintf(fp, #%s#%s#%s#%s#%d-%d#%d\n, p-isbn, p-title, p-author, p-publisher, p-total_count, p-available_count, p-borrow_count); } fclose(fp); // 用临时文件替换原文件 if (remove(filename) ! 0 access(filename, F_OK) 0) { printf(原文件删除失败请检查权限\n); return 0; } if (rename(temp_file, filename) ! 0) { printf(文件替换失败数据未完全保存\n); return 0; } printf(数据已保存到 %s。\n, filename); return 1; }3.4 系统退出时统一保存操作时不要频繁写磁盘别在每次借书后都调save_books频繁写磁盘不仅慢而且容易把硬盘写坏虽然现代硬盘读写寿命都很长但没这个必要。正确做法是每次增删改操作时修改内存中的链表退出前统一保存一次。如果你想保险可以加一个“修改标志位”只有发生过修改才在退出时保存。这算是真实项目管理里“脏标记”的简单版。4. 核心功能逐个拆解增删改查与借还书的完整链路4.1 图书管理插入、查找、删除、修改插入分了两种情况新书入库isbn不存在直接尾插和已有图书增加馆藏isbn已存在total_count和available_count加1。插入前先遍历链表查一次isbn避免重复录入。// 新增图书如果ISBN已存在则增加馆藏数量 int add_book(Book *head) { Book new_data; memset(new_data, 0, sizeof(new_data)); printf(请输入ISBN号); scanf(%s, new_data.isbn); printf(请输入书名); scanf(%s, new_data.title); printf(请输入作者); scanf(%s, new_data.author); printf(请输入出版社); scanf(%s, new_data.publisher); printf(请输入馆藏数量); scanf(%d, new_data.total_count); // 检查ISBN是否已存在 for (Book *p head-next; p ! NULL; p p-next) { if (strcmp(p-isbn, new_data.isbn) 0) { p-total_count new_data.total_count; p-available_count new_data.total_count; printf(该图书已存在馆藏数量增加 %d当前总数 %d。\n, new_data.total_count, p-total_count); return 1; } } // 新书尾插 Book *node (Book*)malloc(sizeof(Book)); if (!node) return 0; *node new_data; node-available_count new_data.total_count; node-borrow_count 0; node-next NULL; Book *tail head; while (tail-next) tail tail-next; tail-next node; head-total_count; printf(图书入库成功当前图书总数 %d。\n, head-total_count); return 1; }这里我用了*node new_data;把栈上结构体的值直接拷到堆节点里简单高效不用逐个字段赋值。但前提是结构体里没有指针字段否则就是浅拷贝了。本项目中结构体都是字符串数组所以没问题。查找支持按ISBN精确匹配、按书名模糊匹配strstr还可以拓展为按作者、按出版社。这里的关键是不要把查找逻辑写在菜单函数里而是抽成一个独立的函数。这样处理借书还书时也能复用。// 按ISBN精确查找 Book* find_book_by_isbn(Book *head, const char *isbn) { for (Book *p head-next; p ! NULL; p p-next) { if (strcmp(p-isbn, isbn) 0) return p; } return NULL; } // 按书名模糊查找返回找到了几本 int find_books_by_title(Book *head, const char *title) { int count 0; for (Book *p head-next; p ! NULL; p p-next) { if (strstr(p-title, title)) { printf(%s | %s | %s | 可借 %d/%d\n, p-isbn, p-title, p-author, p-available_count, p-total_count); count; } } return count; }删除的经典操作是“双指针遍历”一个前置指针prev一个当前指针cur。因为要断开链表必须拿到被删除节点的前一个节点。这里如果你用了带头节点的链表删除第一个有效节点时prev就是头节点逻辑统一不用特判。int delete_book(Book *head, const char *isbn) { Book *prev head; Book *cur head-next; while (cur ! NULL) { if (strcmp(cur-isbn, isbn) 0) { prev-next cur-next; free(cur); head-total_count--; printf(删除成功当前图书总数 %d。\n, head-total_count); return 1; } prev cur; cur cur-next; } printf(未找到该ISBN对应的图书。\n); return 0; }删除前最好再确认一下这本图书当前有没有被借出去如果available_count ! total_count说明有用户手里还借着这本书强行删除会导致用户借阅记录里的ISBN变成“幽灵书”。稳妥的处理是提示“该图书尚有X本未归还无法删除”或者联动把所有借了该书的用户的借阅记录也清掉。推荐用前者简单安全。4.2 用户注册与登录链表文件落盘用户管理的核心是注册、登录、展示个人信息含借阅列表。注册要求用户ID唯一输入两次密码一致才创建用户节点尾插到用户链表更新users.txt。登录输入ID和密码跟用户链表比对。密码建议不要明文存但大作业阶段不强制。你可以做个简单的异或加密或哈希提一下是加分项。4.3 借书流程一个完整的增删改组合操作借书的业务逻辑看起来只有“把书借给用户”实际上牵扯到四个数据结构的变化图书链表里对应节点available_count - 1borrow_count 1用户链表里对应用户节点borrowed_num 1在borrowed_isbn数组里写入ISBN判断读者是否已借满10本书是否还有可借数量ISBN是否存在用户是否登录操作完成后设置“数据已修改”标志方便退出时统一保存。借书前做判断的顺序很重要错误提示要明确不能笼统地报“借书失败”。我按顺序列一下校验步骤int borrow_book(Book *bhead, User *uhead, const char *uid, const char *isbn) { User *user find_user_by_id(uhead, uid); if (!user) { printf(用户不存在请先注册。\n); return 0; } Book *book find_book_by_isbn(bhead, isbn); if (!book) { printf(图书不存在。\n); return 0; } if (book-available_count 0) { printf(该图书已全部借出不可借。\n); return 0; } if (user-borrowed_num MAX_BORROW_COUNT) { printf(您已借满 %d 本请先归还部分图书。\n, MAX_BORROW_COUNT); return 0; } // 检查是否已经借过这本书防止重复借同一本 for (int i 0; i user-borrowed_num; i) { if (strcmp(user-borrowed_isbn[i], isbn) 0) { printf(您已借过这本书不能重复借阅。\n); return 0; } } // 更新图书信息 book-available_count--; book-borrow_count; // 更新用户信息 strcpy(user-borrowed_isbn[user-borrowed_num], isbn); user-borrowed_num; printf(借书成功%s 已借出剩余可借 %d 本。\n, book-title, book-available_count); return 1; }这里我加了“不能重复借同一本”的校验这个是你答辩时能说的亮点说明你考虑到了用户和图书之间的唯一性约束。4.4 还书流程链路对称别忘了罚款业务还书跟借书是对称的判断用户是否借了这本书遍历用户的borrowed_isbn数组图书节点available_count 1用户节点把该ISBN从数组里移除数组后面的元素往前移可选功能如果还书日期晚于应还日期计算罚款金额并累加到用户节点的fine_due罚款这块如果做了就要设计好日期判断。简单方案是借书时记录时间戳还书时判断是否超过30天。但大作业阶段如果没做日期判断直接用“读者是否自己报超期天数”的方式也说得过去。我个人建议做到“计算超期天数”这一层能体现你对真实业务的理解。4.5 排行榜与统计遍历链表的典型应用“功能齐全”的加分项里我强烈推荐加一个“热门图书排行榜”按borrow_count降序排列输出TOP10。这个功能的技术点在于排序。排序有两种做法直接对链表节点进行插入排序每次从未排序部分摘下最大节点尾插到已排序链表中。代码稍长但在考查链表操作上有说服力。借助辅助数组把链表节点指针提取到数组里用qsort排序再按序输出。代码短易实现但严格来说是“用数组思维解链表题”答辩时容易被问住。我给你展示一种基于“选择排序”思想的实现直接在链表上比较但不交换节点只交换节点里的数据字段。思路跟冒泡排序一样容易理解但更好写void sort_books_by_borrow_count(Book *head) { for (Book *p head-next; p ! NULL; p p-next) { Book *max p; for (Book *q p-next; q ! NULL; q q-next) { if (q-borrow_count max-borrow_count) { max q; } } if (max ! p) { // 交换节点数据不含next指针 Book temp; strcpy(temp.isbn, p-isbn); strcpy(p-isbn, max-isbn); strcpy(max-isbn, temp.isbn); strcpy(temp.title, p-title); strcpy(p-title, max-title); strcpy(max-title, temp.title); strcpy(temp.author, p-author); strcpy(p-author, max-author); strcpy(max-author, temp.author); strcpy(temp.publisher, p-publisher); strcpy(p-publisher, max-publisher); strcpy(max-publisher, temp.publisher); p-total_count ^ max-total_count; max-total_count ^ p-total_count; p-total_count ^ max-total_count; p-available_count ^ max-available_count; max-available_count ^ p-available_count; p-available_count ^ max-available_count; p-borrow_count ^ max-borrow_count; max-borrow_count ^ p-borrow_count; p-borrow_count ^ max-borrow_count; } } }等下这里的异或交换整型字段虽然能用但如果字段是浮点数或者是数组就不行。而且可读性差。在正式项目里不推荐但作为C语言学习的技巧演示可以提一嘴。更稳妥的交换方式是写一个swap_book_data函数把借出次数这个整型字段直接用一个临时变量交换即可。比如if (max ! p) { char temp_str[100]; strcpy(temp_str, p-title); strcpy(p-title, max-title); strcpy(max-title, temp_str); strcpy(temp_str, p-author); strcpy(p-author, max-author); strcpy(max-author, temp_str); strcpy(temp_str, p-publisher); strcpy(p-publisher, max-publisher); strcpy(max-publisher, temp_str); strcpy(temp_str, p-isbn); strcpy(p-isbn, max-isbn); strcpy(max-isbn, temp_str); int temp p-total_count; p-total_count max-total_count; max-total_count temp; temp p-available_count; p-available_count max-available_count; max-available_count temp; temp p-borrow_count; p-borrow_count max-borrow_count; max-borrow_count temp; }这样写虽然代码长但清晰、不容易错。注意不要在交换时动next指针否则链表会断掉。5. 文件方案踩过的坑编码、路径、乱码、崩溃文件这块儿有一堆只有写多了才会发现的坑我挑几个典型的说。5.1 中文乱码的根源不在于C代码而在于文件编码如果你用VS或Code::Blocks在Windows上跑用fprintf写中文再用记事本打开默认可能是GBK编码但如果你用VSCode写代码源文件是UTF-8编码编译器把字符串常量按UTF-8存进二进制写进文件的就是UTF-8。这两者混在一起就会出现“自己写的文件自己都读不回来”的乱码。解决方案没有一个绝对标准但核心思路是保证控制台输入、源代码里的字符串常量、txt文件三方的编码一致。我的建议是在Windows上用系统自带的记事本/GBK环境跑就把源文件编码也设置成GBK在Linux或macOS上跑就用UTF-8。不要跨平台交叉打开文件。如果实在无法避免就在打开文件时用setlocale设置本地化环境#include locale.h setlocale(LC_ALL, );注意这行代码要在main函数开头调用能解决一部分控制台中文输出乱码的问题但对txt文件编码本身无效。文件编码问题最本质的解法还是“统一编码”。5.2 scanf输入后残留换行符导致fgets读到空行这是一个非常经典的问题。当你在菜单里用scanf(%d, choice)选择功能后回车产生的换行符会残留在输入缓冲区中。紧接着如果你用fgets读取书名它会直接把这个残留的换行符读走导致你的书名变成空字符串。解决方案有几种用完scanf后调用while(getchar() ! \n);清空缓冲区。用scanf( %d, choice)——在%d前加一个空格跳过空白字符。统一用fgets读字符串再sscanf解析。我推荐方案1因为它最直观、最好解释。但如果你的程序里大量需要读字符串方案3才是治本的方案。写一个简单的read_string函数包一层会舒服很多。5.3 打开文件失败时的应对别用exit(1)直接退出很多同学的代码在fopen失败后直接exit(1)退出。但如果文件不存在是因为第一次运行还没有生成txt文件你这样退出就很蠢。更友好的方式是文件不存在时提示“将创建空链表”然后以空的链表继续运行等退出时再创建并写入文件。只在你明确要求读文件、但文件里的格式完全无法解析时才提示“文件损坏”这时再考虑是否退出。6. 项目评估与扩展从六级代码到更完整的系统写到这里这个系统已经具备了图书管理增删改查、排序、用户管理注册、登录、借阅记录、文件持久化读写txt、退出保存三大能力。从课程设计的评分维度看已经能达到良或优的水平。如果再往上走还有几个扩展方向值得尝试你可以根据自己的时间选一两个日期模块借书、还书带时间戳自动计算逾期天数与罚款。管理员/普通用户分权管理员能新增和删除图书普通用户只能查书和借还书。数据校验增强ISBN的格式校验比如判断长度和分隔符、用户ID的纯数字校验、密码强度检查。操作日志每次借书/还书/入库都写一行日志到log.txt这个能当答辩亮点。链表排序的稳定性优化把借阅排序做成真正的链表排序归并排序等而不是简单交换数据。最后给你一个排版建议交代码前在.c文件顶部注释块里写清楚“程序名称、作者、日期、功能介绍、运行环境”在关键函数前写上功能注释。老师看的第一眼通常就是这些这决定了你代码的“第一印象”。我个人做这类课设项目的习惯是先写“能跑通基本流程”的版本跑通后再回头看代码把重复代码抽成函数把边界判断补齐。这一步往往花不了太长时间但代码质量完全是两个等级。本文还有配套的精品资源点击获取
返回列表