C语言图书管理系统课设:折半查找与链表操作全解析
简介这是一份面向高校计算机/物联网专业学生的数据结构课程设计报告围绕图书管理信息系统的设计与实现展开覆盖图书采编、编目、查询以及借还书等核心业务流程。报告从问题描述与基本要求出发给出了以书号为主关键字、以书名/作者/出版社为次关键字的索引文件设计以及基于链表和结构体的图书、读者信息组织方式并设计了buy、SearchByNum、SearchByName、borrow、return等处理函数。资源包为1个Word文档大小119KB整体结构完整包含题设、概要设计、模块分析与关键代码示意能够满足课程设计报告撰写或系统功能参考的需要。内容预览中还呈现了折半查找、单链表、外部变量返回查找位置等具体实现细节便于读者对照理解数据结构在信息管理系统中的实际应用。目前已有1381人学习浏览适合正在完成同类课程设计或需要借鉴图书管理功能模块设计的同学参考使用。1. 图书管理信息系统这份数据结构课设报告到底值不值得照着做如果你是正在准备数据结构课程设计的人大概率见过这类题目做个图书管理信息系统用C语言实现采编、查询、借还书。听起来简单但真动手写起来很多人卡在链表怎么挂、折半查找怎么跟插入配合、借还书时怎么删结点不丢数据。这份《图书管理信息系统的设计与实现》是湖北师范学院2012年的课程设计报告代码是纯C语言写的没有数据库、没有图形界面全部用数组和链表在内存里完成管理。它能解决的核心问题非常明确用最朴素的数据结构——线性表、链表、折半查找——把一套完整的业务流程跑通。适合三类人正在做类似课设想找参考的学生、想复习C语言链表操作的人、需要梳理课设答辩思路的初学者。这篇笔记我会把它的设计思路、每个核心函数的实现逻辑和参数细节、以及我自己复现时踩过的坑全部拆开讲。2. 数据结构选型数组、链表和结构体怎么组织一本书2.1 整体架构为什么图书用数组、借阅者用链表先看这份报告的总体设计。系统里有两类核心数据图书信息和借阅者信息。对于图书报告选择了一个结构体数组来存放定义是ook boo[MAXSIZE]MAXSIZE被定义为 100也就是系统最多支持 100 种书。每种书的结构体包含书号num、书名name、作者auth、出版社pub、总库存TotNum、现库存NowNum以及一个指向借了这本书的读者链表的头指针next。#define MAXSIZE 100 #define LIST_INIT_SIZE 100 typedef struct LNode { char CardNum[20]; // 图书证号 struct LNode *next; // 指向下一个借阅者 } LinkList; typedef struct book { char num[20]; // 书号 char name[20]; // 书名 char auth[20]; // 作者 char pub[20]; // 出版社 int TotNum; // 总库存 int NowNum; // 现库存 LinkList *next; // 借了该书的人链表头 } ook;图书用数组而不是链表这个选择很关键。因为报告里的核心查找函数BinarySearch()用的是折半查找而折半查找的前提是数据存储在连续内存里、按下标随机访问。数组天然满足这个条件时间复杂度是 O(log n)。如果你把图书也换成链表折半查找就没法用了只能顺序遍历查找效率会掉到 O(n)。所以这种选型的逻辑是查询频繁的数据用数组数量动态变化、增删频繁的借阅记录用链表。借阅者信息用了一个叫lend的数组但每个借阅者又挂了一个Bor链表记录他借了哪些书。Bor结构体里存书号BNum、借书日期BorDate、归还日期RetDatenext指向下一条借书记录typedef struct Boro { char BNum[20]; // 所借书的书号 char BorDate[8]; // 借书日期 char RetDate[8]; // 归还日期 struct Boro *next; // 下一条借书记录 } Bor; typedef struct LinkBook { Bor *next; // 该图书证的借书记录链表头 char CNum[20]; // 图书证号 int Total; // 借书的数量 } lend[LIST_INIT_SIZE];这里有个容易看混的点lend不是结构体类型名是一个数组类型名lend Lin声明了一个长度为LIST_INIT_SIZE的结构体数组。Retotal这个全局变量记录当前注册过的借阅证数量新增读者时Retotal删除时Retotal--。2.2 全局变量设计total 和 Retotal 的边界作用报告里用了两个关键全局变量total表示图书种类数Retotal表示借阅者证数。这两个变量贯穿所有函数比如BinarySearch()的查找范围就是low0, hightotal-1。我在复现的时候发现一个隐患如果total初始化为 0而你在 main 函数里先调用了Borrow()而不是先Buy()录入图书那么二分查找的范围就是空区间虽然代码里有用total0来判断“书库没书”但不同函数对这种情况的处理并不一致有的直接返回 false有的会继续走后续逻辑。所以建议在main()里先把图书采编入库函数跑一遍保证total0再开放借书功能。LIST_INIT_SIZE和MAXSIZE都是 100但代表的意义完全不同。前者限制借阅者数量后者限制图书种类数。如果你的测试数据超过 100 种书或者 100 个读者数组会越界程序可能直接崩溃。报告里没做动态扩容这是它的一个明显边界后面避坑章节会细说。2.3 三个索引链头文件主索引、书名索引、作者索引的对应关系这份报告的题目描述里给了三个索引链头文件——书名索引、作者索引、出版社索引每个索引格式是“关键字 → 链头地址 → 长度”。比如书名“数据库”对应链头地址 6、长度 2代表书号为 11021 和 62201 的两本书书名都是“数据库”。但注意正文的完整代码里这个索引文件的设计并没有真正实现。代码里用的是直接在结构体数组上二分查找书号、顺序遍历书名并没有建独立的多级索引文件。也就是说题目描述里的索引设计是“要求”代码实现用的是更朴素的折半查找方案。这一点在答辩时很容易被老师追问你题目里写了索引链头文件代码里为什么没有我的建议是如果你要交这份报告要么在文字说明里承认“实际实现采用折半查找替代多级索引”要么自己补一段索引文件的定义。后面避坑章我会给一个补全方案。3. 查找与插入折半查找和有序插入怎么配合才不出错3.1 BinarySearch 折半查找的实现细节和返回值陷阱查找是这份报告的灵魂。BinarySearch()的代码我完整贴出来并标注每一处的逻辑int mid 0; // 外部变量 mid用来返回查找到的位置 int BinarySearch(ook boo[], char SearchNum[]) { int low 0, high total - 1; int found 0; while (low high) { mid (low high) / 2; // 取中间下标 if (strcmp(boo[mid].num, SearchNum) 0) { // 书号相等 found 1; return 1; // 查找成功 } if (strcmp(boo[mid].num, SearchNum) 0) high mid - 1; // 目标在左半区 else low mid 1; // 目标在右半区 } if (found 0) return 0; // 查找失败 }这里有一个非常典型的 C 语言写法函数只有一个返回值但调用方往往既想知道“有没有找到”又想知道“找到的位置”。报告用了一个外部全局变量mid来解决——查找成功后mid保存的就是目标书号在数组中的下标。这个做法在课设里很常见但它的缺陷也很明显如果两个线程同时调用这个函数mid会被互相覆盖。好在这个系统本来就是单线程控制台程序不会出并发问题答辩时可以解释为“为了简化接口设计用外部变量传递查找位置”。对比strcmp的返回值和二分调整方向逻辑是对的strcmp返回负值表示第一个参数比第二个小正值表示大。但注意原代码里有个笔误第一版实现写的是if(strcmp(boo[mid].num,SearchNum)!0) highmid-1;这会让所有不相等的情况都往左半区找导致查找失败。后面修正成了大于 0 才往左、否则往右。这个细节非常值得在答辩时主动提出来说明你理解了二分法的边界调整逻辑。另外一个细节mid在查找失败返回 0 的时候保留的是最后一次计算的位置。Buy()里插入新书时就是用这个mid作为插入点把后面的元素整体后移。所以你会发现mid这个全局变量在查找失败时反而承载了“插入位置”的语义——这是这份代码设计里最巧妙也最容易被忽略的点。3.2 Buy 采编入库插入点计算和后移的边界问题Buy()函数是报告里最长的函数之一它的核心逻辑分两段如果二分查找找到了书直接把总库存和现库存 1如果没找到就把新书插到mid指向的位置后面并保持数组有序。void Buy(ook boo[], char BuyNum[]) { if (BinarySearch(boo, BuyNum)) { boo[mid].TotNum; boo[mid].NowNum; printf(入库成功。已更改书库中该书的信息。\n); } if (!BinarySearch(boo, BuyNum)) { int i; for (i total; i mid total; i--) boo[i] boo[i - 1]; // 从后往前移动空出 mid1 位置 strcpy(boo[i].num, BuyNum); // 注意这里 i 是循环结束后 mid1 printf(该书在书库中不存在。设立新书目请补全书的详细信息。\n); printf(该书购入的数量是:); scanf(%d, boo[i].NowNum); boo[i].TotNum boo[i].NowNum; printf(该书的名字是:); scanf(%s, boo[i].name); printf(该书的作者是:); scanf(%s, boo[i].auth); printf(该书的出版社是:); scanf(%s, boo[i].pub); boo[i].next NULL; total; printf(已增加该书的信息。入库成功。\n); } }这里有几个值得注意的细节。第一for循环里的条件i mid totaltotal在这里作为布尔值用如果total0就不移动任何元素此时mid还是 0新书插到下标 0 的位置。第二循环结束后i的值是mid1所以strcpy(boo[i].num, BuyNum)实际写入的是mid1位置。第三移动是从total开始、到mid1结束也就是把mid1到total-1的元素整体后移一位腾出mid1这个空位。整个逻辑在total不为 0 的时候是对的。但这里有个隐蔽的翻车点如果mid在查找失败时指向数组末尾附近比如total99、mid98新书要插到下标 99但数组最大容量是MAXSIZE100所以最多只能插到下标 99再插入第 101 种书就会越界。报告里没有任何数组满的判断。我复现时测试到第 100 种书就出问题了。建议补一段判断if (total MAXSIZE) { printf(书库已满无法录入新书。\n); return; }这段代码放在Buy()函数开头两行就能避免最危险的越界错误。3.3 SearchByNum 和 SearchByName两种查询方式各自的适用场景按书号查用二分查找按书名查用线性遍历。这个选择很合理因为数组按书号有序二分查找才能成立而书名没有排序只能遍历。SearchByNum()里有一个细节如果查到书而且这本书被借出去了会遍历boo[mid].next链表把所有借阅者的图书证号打印出来。void SearchByNum(ook boo[], char SeaNum[]) { LinkList *p; if (!BinarySearch(boo, SeaNum)) printf(对不起未找到您想查找的书。\n); else { p boo[mid].next; printf(书号:%s 书名:%s 作者:%s 出版社:%s 现库存:%d 总库存:%d\n, boo[mid].num, boo[mid].name, boo[mid].auth, boo[mid].pub, boo[mid].NowNum, boo[mid].TotNum); if (p ! NULL) { printf(已借该书的图书证号:\n); while (p) { printf(%s , p-CardNum); p p-next; } } printf(\n); } }按照书名查找的函数实现更简单就是遍历所有图书遇到书名相同的就全部打印出来。这个函数有个小问题它没有处理“没找到任何书”的情况如果遍历一遍没有匹配项屏幕上只会显示一行“找到符合该书名的书的详细信息如下”然后什么都没有。建议加一个计数变量遍历结束如果是 0 就提示“抱歉未找到该书名对应的书。”4. 借还书核心流程链表结点的增删有哪些边界要处理4.1 Borrow 借书两条链表同时更新的顺序和条件借书是整个系统里最复杂的操作因为它要同时维护两条链表图书结构体里的借阅者链表boo[mid].next和借阅者结构体里的借书记录链表Lin[i].next。两条链表的更新顺序不能颠倒如果先更新了图书这边的链表再在借阅者表里查找证号万一找不到证号还得回滚图书链表的修改——代码里没有回滚逻辑所以必须把不可逆的修改放在最后做。void Borrow(ook boo[], lend Lin, char BorrowNum[], char CaNum[]) { Bor *p, *q; LinkList *m, *n; int i; if (!BinarySearch(boo, BorrowNum) || total 0) { printf(书库里没这书。\n); return; } if (boo[mid].NowNum 0) { printf(借阅失败。该书现在库存为0。\n); return; } boo[mid].NowNum--; // 现库存减1 // —— 第一步往图书的借阅者链表中追加读者证号 —— if (boo[mid].next NULL) { m (LinkList *)malloc(sizeof(LNode)); boo[mid].next m; strcpy(m-CardNum, CaNum); m-next NULL; } else { m boo[mid].next; while (m-next) m m-next; n (LinkList *)malloc(sizeof(LNode)); m-next n; strcpy(n-CardNum, CaNum); n-next NULL; } // —— 第二步往借阅者的借书链表中追加书目 —— for (i 0; i Retotal; i) { if (strcmp(Lin[i].CNum, CaNum) 0) { // 已有该证信息 p Lin[i].next; while (p-next) p p-next; q (Bor *)malloc(sizeof(Boro)); p-next q; strcpy(q-BNum, BorrowNum); printf(输入归还日期:); scanf(%s, q-RetDate); q-next NULL; printf(借阅成功。\n); break; } } if (i Retotal) { // 没有该证信息新建 strcpy(Lin[i].CNum, CaNum); p (Bor *)malloc(sizeof(Boro)); Lin[i].next p; strcpy(p-BNum, BorrowNum); printf(输入归还日期:); scanf(%s, p-RetDate); p-next NULL; Retotal; printf(借阅成功。\n); } }这段代码里有几个值得展开讲解的边界场景。第一Lin[i].Next可能为空吗在正常流程里不会因为借书时如果读者没有任何历史记录函数会创建一个Bor结点挂上去。但如果你在 main 函数里直接构造了一个只有证号、没有借书记录的读者那pLin[i].next就是NULLwhile(p-next)会直接段错误。这个函数的假设前提是每个注册读者至少借过一本书。如果要在代码里做防御得在取p之前判断Lin[i].nextNULL的情况。第二scanf(%s, q-RetDate)输入归还日期但前面Borrow()根本没让用户输入借书日期BorDate字段是空的。报告里结构体定义了BorDate但代码里从来没有给它赋值。这是我复现时发现的比较明显的逻辑缺口你自己补代码的时候要决定要么录入当前系统日期作为借书日期要么在借书流程里让用户输入。我建议用time()函数自动生成日期避免用户多输一步。第三借书环节在图书链表中只是记录了证号没有记录借书日期和应还日期——所有日期信息都存在借阅者链表的Bor结点里。这意味着如果想要按书查“这本书被谁借走了、什么时候该还”是查不到的只能知道证号。这是这个课设系统的一个功能边界答辩时如果被问到可以如实说是按报告设计做的简化。4.2 Return 还书删除链表结点的三种情况和 free 的位置还书函数Return()的复杂度比借书更高因为链表删除要区分“删除的是第一个结点”和“删除的是中间结点”两种情况每种情况的指针操作都不一样。void Return(ook boo[], lend Lin, char ReturnNum[], char BorrowerNum[]) { Bor *p, *q; LinkList *m, *n; int flag 0; if (!BinarySearch(boo, ReturnNum) || !total) { printf(书库中无此书。\n); return; } // —— 第一部分从图书的借阅者链表中删除该读者 —— m boo[mid].next; if (m NULL) { printf(这本书没有借出记录。\n); return; } if (strcmp(m-CardNum, BorrowerNum) 0) { // 情况1要还书的人正好是链表的第一个结点 boo[mid].NowNum; boo[mid].next m-next; free(m); } else { // 情况2遍历找到目标结点 while (m-next) { if (strcmp(m-next-CardNum, BorrowerNum) 0) { n m-next; m-next n-next; free(n); boo[mid].NowNum; break; } m m-next; } } // —— 第二部分从借阅者的借书链表中删除该书的记录 —— for (int i 0; i Retotal; i) { if (strcmp(Lin[i].CNum, BorrowerNum) 0) { p Lin[i].next; if (p NULL) break; if (strcmp(p-BNum, ReturnNum) 0) { // 还的是借的第一本书 Lin[i].next p-next; free(p); printf(成功归还该书。\n); flag 1; break; } else { // 遍历找对应书号的结点 while (p-next) { if (strcmp(p-next-BNum, ReturnNum) 0) { q p-next; p-next q-next; free(q); printf(成功归还该书。\n); flag 1; break; } p p-next; } } } } // —— 第三部分清理没有借书记录的借阅证 —— for (int k 0; k Retotal; k) { if (Lin[k].next NULL) { int j; for (j k; j Retotal; j) Lin[j] Lin[j 1]; // 数组整体前移 strcpy(Lin[j].CNum, ); Retotal--; break; // 注意break 只删一个空证 } } if (flag 0) printf(无该证信息。\n); }这里第三部分的逻辑需要特别说明它遍历Lin数组找到第一个next NULL的借阅证然后把它后面的所有元素前移一位相当于删掉了这个空证。但问题在于for循环里找到空证后只执行一次删除然后break退出。如果同时有多个空证只清理了第一个。这是个功能缺陷但不影响主要流程——因为一个证只有在它还完所有书之后才会变成空证而这种清理只是为了防止数组里堆积垃圾数据。free()的位置也是个细节。报告里释放借阅者链表的结点用的是free(n)和free(m)释放借书链表的结点用的是free(p)和free(q)。注意在释放之前指针已经指向了下一个结点所以释放的是孤立出来的结点不会影响链表结构。这个删除写法是课设里必考的点如果在答辩时被问“为什么删除后链表不断开”你可以直接画图说明先让前一个结点的next指向目标结点的next再free目标结点。5. 避坑指南复现这份课设报告时的六个翻车点5.1 数组越界MAXSIZE 100 的限制和录入第 101 本书现象连续录入新书到第 100 种时程序还能正常跑录入第 101 种时要么打印乱码要么直接崩溃退出。原因Buy()里插入新书用的是for(itotal; imid total; i--) boo[i]boo[i-1];当total100时移动后写入的boo[100]已经超出数组boo[MAXSIZE]的合法下标 0~99属于典型的数组越界写。解决在Buy()函数开头加判断total MAXSIZE时直接返回并提示书库已满。同时把MAXSIZE改成更大值比如 200 或 1000按你的测试数据规模来。我一般会顺手把MAXSIZE和LIST_INIT_SIZE都改成 1024省得测试数据稍微多一点就踩边界。5.2 scanf 缓冲区残留输入数字后再输入字符串导致读取失败现象在Buy()里先scanf(%d, boo[i].NowNum)输入数量紧接着scanf(%s, boo[i].name)输入书名结果书名没等到输入就被跳过了程序直接往后执行。原因scanf(%d)读取完数字后缓冲区内残留了一个换行符\n下一个scanf(%s)读到这个换行符视为输入结束直接把空字符串赋给了name。解决在每次scanf之后用getchar()吃掉残留的换行符或者在scanf的格式串里加空格比如scanf( %s, boo[i].name)。注意格式串里加空格能跳过任意空白字符比getchar()更稳妥。报告中多处scanf都加了前导空格比如scanf( %d,boo[i].NowNum)这个细节是原作者刻意处理的复现时不要自作主张删掉空格。5.3 BinarySearch 的 mid 全局变量在多处调用时被覆盖现象Buy()里先调用了BinarySearch判断书是否存在然后又调用一次BinarySearch获取mid位置结果两次查找的结果不一致第二次返回的mid不是同一个值。原因mid是全局变量每次调用BinarySearch都会重新计算并覆盖它。在Buy()里第一次调用if(BinarySearch(boo, BuyNum))后mid保存的是查找成功的位置但如果查找失败mid保存的是最后比较的位置。第二次调用if(!BinarySearch(boo, BuyNum))时mid可能已经变化。解决不要连续调用两次BinarySearch而是用一个局部变量保存结果int found; found BinarySearch(boo, BuyNum); if (found) { boo[mid].TotNum; ... } else { // 此时 mid 仍然可用作为插入位置 ... }这一处是报告原代码里最大的效率隐患——每次Buy()都做了两次二分查找数据量小看不出来数据量到几千条时能明显感到卡顿。改成一次查找后逻辑也更清晰。5.4 Return 里的空链表访问借书人链表的 head 为 NULL 时直接崩溃现象运行Return()还一本从没被借出去的书程序在mboo[mid].next;之后直接访问m-CardNum然后段错误。原因如果一本书的现存量和总量相同说明没人借过它boo[mid].next为NULL。但Return()的第一部分没有判断mNULL就直接strcmp(m-CardNum, BorrowerNum)空指针解引用必然崩溃。解决在m boo[mid].next;后面加一行判断if (m NULL) { printf(这本书没有借出记录无法归还。\n); return; }同样的场景也会出现在借阅者链表里如果一个读者没有任何借书记录Lin[i].next是NULLReturn()里pLin[i].next; strcmp(p-BNum, ReturnNum)同样会空指针。需要加if (p NULL) break;防御。5.5 借书日期缺失结构体里有 BorDate 但从来没赋值现象运行程序时借书功能可以正常完成但查看借阅者的借书记录时发现BorDate字段是空的只有归还日期被录入了。原因Borrow()里创建Bor结点时只strcpy了BNum和RetDate跳过了BorDate。结构体定义里有这个字段但代码里没有对应的输入或赋值逻辑。解决在借书时用 C 标准库的time()生成当前日期自动填充#include time.h time_t now; struct tm *tm_now; char dateStr[9]; time(now); tm_now localtime(now); strftime(dateStr, sizeof(dateStr), %Y%m%d, tm_now); strcpy(q-BorDate, dateStr);这样借书日期就是系统当天日期不用用户手动输入也更贴近真实场景。5.6 多个空借阅证只清理第一个Return 里的 break 导致数组留下空洞现象连续让两个读者还完所有书后检查Retotal发现只减少了 1另一个空证还在数组里后续新注册的读者可能覆盖掉它、也可能不覆盖。原因清理空证的for循环里找到第一个Lin[k].next NULL的元素后执行前移和Retotal--然后立刻break后续的空证没有被处理。解决把break去掉让for循环继续遍历。但要注意前移后数组长度变了下标需要重新调整所以最简单的做法是重新遍历删掉一个空证后k回退一步。或者用双循环先统计空证数量再一次性前移。课设级别的代码我推荐一个最直观的写法int k 0; while (k Retotal) { if (Lin[k].next NULL) { for (int j k; j Retotal; j) Lin[j] Lin[j 1]; Retotal--; } else { k; } }这个while循环处理完所有空证且不会因为前移跳过元素。这是我在复现时踩过坑之后改出来的比原报告里的forbreak方案稳得多。6. 验证方法与进阶改造怎么证明这套系统能跑、能答辩拿到这份报告和代码之后第一步不是急着改功能而是先完整跑一遍核心流程确认原始代码在你的编译器环境下能编译、能运行。我写了一个标准验收流程照着走一遍基本能覆盖所有功能分支# 假设代码文件是 book.c用 gcc 编译如果是 Windows 用 Dev-C 或 VS 的 C 语言环境也行 gcc book.c -o book.exe # 运行程序 ./book.exe启动后按这个顺序操作先录入 3~5 本书其中两本故意用相同书名方便测试按书名查找的多条结果展示然后按书号查找一本录入过的书、一本没录入过的书接着借书——第一次借一本书第二次借同一本书给另一个读者第三次再借一本库存已清零的书验证“借阅失败”最后还书——先还第一本书再还第二本书还一本没借出去的书看提示。每一步都记录输出结果。如果在这个过程中某个环节崩了对照上一章的避坑表逐条排查。验证通过后如果想让这份课设看起来“更有东西”有两个性价比很高的改造方向。第一个是给Buy()补充库存上限和数组满检查因为原代码的越界问题是硬伤第二个是给借书流程自动生成借书日期并且把归还日期从“手动输入”改成“借书日期 30 天”自动计算——这两个改动加起来不超过 20 行代码但能让答辩时的功能完整度明显提升。如果你还想再进一步可以把boo数组改成链表结构、把BinarySearch改成按书名建立的哈希索引——但我不建议你这么做。因为这份报告的核心价值在于它是一份“刚好够用”的课设代码每种数据结构都有明确的出场理由折半查找配合有序数组、链表用于动态增删的借阅记录逻辑闭环清晰评委老师不会追问太难的问题。你大改数据结构之后反而要解释为什么不用折半查找了、哈希冲突怎么处理给自己挖坑。我自己复现这个项目的体验是它最大的优点不是代码写得多么优雅而是把所有课设需要讲清楚的概念都集中在了几个关键函数里——二分查找、链表增删、结构体数组、全局变量传参每一个都能在答辩时展开讲三分钟。从那以后我每次做课设复现都强制自己先在纸上画出链表结构图、标清楚每次增删的指针变化再去改代码。这个方法让我少踩了一半的指针坑希望帮到你。本文还有配套的精品资源点击获取