简介:一份基于B树实现的图书管理系统完整课程设计资源,面向学习数据结构与算法的C语言初学者,以及需要完成类似课程设计的在校生。B树是自平衡多路搜索树,节点可拥有多个子节点,树高较低,在查找、插入、删除上具备较高效率,项目围绕其核心操作展开。压缩包共219个文件,大小约190MB,包含C++源码(.cpp)与头文件(.h)、Visual Studio工程配置(.sln/.vcxproj)、编译中间文件(.obj/.pdb)以及可直接运行的exe程序,同时带有资源脚本(.rc/.ico)与示例数据,目录结构清晰,便于定位学习。项目覆盖图书信息增删查改、B树操作接口(插入、删除、查找、遍历)及文件持久化存储,并配合命令行交互界面,帮助理解数据结构在实际系统中的应用。目前已有548人学习下载,适合用于课程设计参考、算法复现和课后巩固,内附完整VS工程与样例数据,可对照调试运行。 数据结构课程设计里,“B树+图书管理系统”这个组合几乎是每年都会出现的经典题目。我拿到的是广东工业大学2019年的课程设计任务:用C语言实现一个基于B树和B+树的图书管理系统。刚看到题目时觉得无非就是写个增删改查,真正动手才发现,这套题的难点根本不在“图书管理”,而在“如何用B树和B+树在文件里维护一套可持久化的索引结构”——普通线性表一拉就能跑,B树却要求你在内存和磁盘之间维护一棵多路搜索树,节点要分裂、合并,索引要落盘,查询要按路径逐层下沉。这篇文章就围绕这个zip里的核心代码,把B树+B+树索引怎么落地、文件怎么设计、哪些坑值得绕开,全部讲透,适合正在做同类课程设计的学生,也适合想复习B树工程实现的开发者参考。
1. 题目拆解与总体方案设计
1.1 课程设计真正要考的能力
接到这种题目,第一反应通常是“图书信息管理”:书名、作者、ISBN、价格、库存、借阅状态,再配上录入、删除、查询、修改、统计几个菜单项。如果只是做到这一步,用一个结构体数组就够了,根本用不上B树。课程设计之所以指定B树和B+树,核心目的其实是考察三项能力。
第一是文件持久化能力。程序退出后图书数据不能丢,所以必须把数据写到文件里,结构体数组的直接存档当然也行,但配合B树索引后,文件里就不能只是简单的记录顺序排列了,还需要维护一棵可定位的索引树。第二是指针的工程化应用能力。B树节点之间靠指针连接,但落盘之后没有真正的指针,全变成文件偏移量,这个转换过程非常考验对C语言指针和文件IO的理解。第三是算法实现能力。B树的插入分裂、删除合并、节点借位这些操作,比普通二叉搜索树复杂了一个量级,是真正能拉开分数的地方。
所以我当时定的方案很明确:功能界面尽量简单朴素,把时间精力全部砸在B树和B+树的实现上。项目里最终是B树管图书的ISBN主索引,B+树管书名辅助索引,两种树各司其职,这让设计报告很好写,答辩时也讲得出东西。
1.2 为什么是B树和B+树:索引选型的取舍
把B树和普通数据结构对比一下,就能理解课程设计为什么偏爱这个选题。数组和链表查找是O(n),数据量超过几千条之后明显卡顿;哈希表查询虽然O(1),但没法做范围查询,也没法按顺序遍历;普通二叉搜索树在数据有序插入时会退化成链表,树高直接变成n。
而B树是多路搜索树,每个节点能存多个关键字,树高只有log_m(n)。以5阶B树为例,存5000条记录时树高只有5到6层,查询一次最多五六次磁盘IO。B+树更进一步,内部节点只存索引键,不存数据,叶子节点才存真正的记录地址,并且叶子节点之间用链表串联。这样做的好处有两个:内部节点能塞下更多键,树更矮;范围查询时只要找到起点,沿着叶子链表往后扫就行,不用反复回溯父节点。
图书系统里“按ISBN查一本书”是单值查询,走B树正合适;“按书名查某类书”往往是模糊或范围匹配,走B+树的叶子链表非常舒服。这就是题目里“B树+B树”组合的意义——两种结构面对不同查询场景各展所长。
1.3 zip包里到底该有什么
这类课程设计下载下来,zip里一般包含源码文件、可执行程序、课程设计报告和截图素材。源码文件基本上就是main.c、btree.c、btree.h、bplus.c、bplus.h、file_manager.c这几个模块,外加一个图书数据文件或数据导入文件。拿到手先不要急着编译,建议把每个文件的功能边界弄清楚,特别是头文件里定义的常量——阶数、节点大小、文件块大小,这些参数直接决定整个系统能存多少数据、性能怎么样。
一个常见的问题是,很多同学拿到zip后第一件事就是改界面、改菜单,把精力花在花哨的格式化输出上。但课程设计评审的重点永远是核心数据结构实现,界面再漂亮,B树操作漏洞百出也是白搭。我的建议是先把索引模块跑通,再回头处理交互层。
2. 核心数据结构与文件存储实现
2.1 B树节点结构与阶数选择
B树的阶数M是影响性能的关键参数。理论上M越大,树越矮,磁盘IO越少,但每个节点的内存占用也越大,节点内做二分查找的时间也会增加。实际操作中,阶数选择要结合文件块大小来定。我当时的代码里用的是M=5,也就是每个节点最多5个孩子、4个键,既保证节点大小不超过1KB,又能让树高控制在合理范围内。
节点结构体是这个样子的:
#define M 5 #define MAX_KEYS (M - 1) typedef struct BTreeNode { int keyNum; // 当前节点中关键字的个数 int keys[MAX_KEYS]; // 关键字数组,这里是ISBN的整型映射 long childPtr[M]; // 孩子节点在文件中的偏移量,-1表示空 long dataOffset[MAX_KEYS]; // 每个关键字对应的图书记录在数据文件中的偏移量 bool isLeaf; // 是否为叶子节点 } BTreeNode;注意这里有个非常关键的工程点:节点里的“指针”不是真正的内存指针,而是long类型的文件偏移量。因为在文件存储的B树里,每个节点在磁盘上都有固定位置,孩子关系靠文件偏移来维护。读取孩子时,用fseek定位到对应偏移量,再fread读入内存;写回时同理。
还有个容易踩的坑:结构体里的bool类型在C语言里要包含stdbool.h,或者直接用int类型代替。有些老编译器不支持bool,写成int会省很多麻烦。另外结构体字段顺序也影响了文件读写时的对齐问题,建议字段都定义成long或int,避免不同平台字节对齐不一致导致文件格式不兼容。
2.2 B+树与B树的差异处理
B+树和B树长得像,但实现起来有几处关键区别。B树每个节点都存数据(或数据地址),中间节点也可能命中目标;B+树则不同,内部节点只放索引键,不存数据地址,所有数据地址都放在叶子节点中。设计B+树节点时,我把结构体分成了内部节点和叶子节点两种形态:
typedef struct BPlusNode { int keyNum; int keys[MAX_KEYS]; // 内部节点存孩子分隔键,叶子节点存真实键 long childPtr[M]; // 内部节点使用 long dataOffset[MAX_KEYS]; // 叶子节点使用,指向数据文件中的记录 long nextLeaf; // 叶子节点指向下一个叶子节点的文件偏移量 bool isLeaf; } BPlusNode;插入操作时,B+树永远只在叶子节点插入,叶子满了就分裂成两个叶子节点,再把分裂处的键提升到父节点。如果父节点也满了,继续递归向上分裂;而B树插入时,如果中间节点满了,直接把中间键提上去,自己裂成左右两半。两者的分裂策略不同,B+树内部节点的键其实是从左孩子复制上来的,不是移动上来的,这样能保证查找时的一致性。
删除时差异更明显。B树删除中间节点的键,需要找前驱或后继替换,然后处理下溢;B+树删除则老老实实只删叶子节点里的键,内部节点的键可以留着作为索引分隔符,不一定要立即删除。这个特性让B+树的删除实现比B树简单不少,也减少了很多合并操作。我当时先实现B树再实现B+树,明显感觉B+树写起来更顺手。
2.3 文件持久化:节点落盘与读盘
这个系统的核心文件操作函数就四个:readNode、writeNode、readRecord、writeRecord。readNode负责根据文件偏移量读入一个B树节点,writeNode负责把节点写回指定偏移量。数据记录的文件操作同理。每个节点在文件中占据固定大小的空间,这意味着删除节点后,那个位置的文件块会变成空洞。我当时没有实现复杂的空闲块回收机制,只是设计了一个空闲偏移量队列,删除节点时把偏移量入队,插入新节点时优先从队列里取,这样避免了文件无限膨胀。
整个系统的文件结构分三层:索引文件(btree.idx)、数据文件(books.dat)和元信息文件(meta.dat)。元信息文件保存B树的根节点偏移量、当前总节点数等信息,每次程序启动时读入,退出时写回。这样程序重启后才能恢复之前的B树状态,而不是一切从零开始。
有个细节要注意:文件操作后的fflush和fclose。如果频繁打开关闭文件,性能会很难看;我采用的是文件句柄常驻方案,main函数里统一打开三个文件,程序结束时统一关闭。但每次写节点后必须fflush,否则fread读到的可能是内存缓冲区里的旧数据。这个坑我调了很久才发现,加了一行fflush之后,所有“神秘失踪”的节点全部恢复了。
3. 图书管理功能模块的实操走读
3.1 添加图书:先写数据文件还是先写索引
录一本新书,正确顺序是先往数据文件里追加一条记录,拿到这条记录的偏移量,再把这个偏移量作为数据地址,插入到B树索引中。顺序不能颠倒,否则插入索引时如果拿不到数据偏移量,这个索引就废了。
int addBook(FILE* dataFile, FILE* idxFile, BookInfo* book) { long dataPos = appendRecord(dataFile, book); // 1. 数据文件追加 int key = book->isbn; // 2. 用ISBN做B树键 int result = btreeInsert(idxFile, key, dataPos); // 3. 插入B树索引 if (result != SUCCESS) { // 插入失败需要回滚,数据文件里那条记录可以通过数据偏移量覆盖删除 rollbackAddRecord(dataFile, dataPos); return FAIL; } return SUCCESS; }插入B树的完整流程分两步。第一步是搜索,从根节点开始,在当前节点里找到第一个比目标键大的位置,顺着对应的孩子指针下沉,直到叶子节点,这一步本质上是在寻找“这个键应该插入到哪个叶子”。第二步是插入,把键和dataOffset塞进叶子节点的keys数组里,保持有序;如果keyNum等于MAX_KEYS说明节点满了,触发分裂操作。分裂的代码比较长,我贴一下核心逻辑:
void splitChild(BTreeNode* parent, int childIndex) { BTreeNode* child = readNode(parent->childPtr[childIndex]); BTreeNode* newChild = createEmptyNode(); // 左半部分留在原节点,右半部分放到新节点 int mid = M / 2; newChild->keyNum = child->keyNum - mid - 1; child->keyNum = mid; // 将原节点的后半段键和孩子指针复制到新节点 for (int i = 0; i < newChild->keyNum; i++) { newChild->keys[i] = child->keys[i + mid + 1]; newChild->dataOffset[i] = child->dataOffset[i + mid + 1]; newChild->childPtr[i] = child->childPtr[i + mid + 1]; } newChild->childPtr[newChild->keyNum] = child->childPtr[child->keyNum]; newChild->isLeaf = child->isLeaf; newChild->nextLeaf = child->nextLeaf; // 把中间键提升到父节点 for (int j = parent->keyNum; j > childIndex; j--) { parent->keys[j] = parent->keys[j - 1]; parent->childPtr[j + 1] = parent->childPtr[j]; } parent->keys[childIndex] = child->keys[mid]; parent->childPtr[childIndex + 1] = writeNode(newChild); parent->keyNum++; }这个中间键提升的位置是child->keys[mid],也就是中间位置索引。当M=5时mid=2,左孩子保留2个键,右孩子保留2个键,中间第3个键上提,整体看起来非常对称。
3.2 删除、借还与节点合并的细节
删除图书是整个系统里最容易出bug的地方。B树删除时,如果当前节点是叶子节点且删除后键数不小于ceil(M/2)-1,直接删就行;如果删除后键数太少,就得先从兄弟节点借一个键,借不到就合并兄弟节点。如果删除的是中间节点的键,需要先找后继键(通常是右子树最小键)替换,然后在右子树里递归删除这个后继键。
借位操作的实现里容易犯一个糊涂,就是兄弟节点的选择。我当时写了一个辅助函数getSibling,根据当前节点在父节点中的下标,先判断右兄弟是否存在并且可借,如果不可借再判断左兄弟。借位本质上是父节点的键下沉补给被删节点,兄弟节点的键上移补充父节点,三个节点的键重新分布。这个过程千万别写错顺序,否则树的结构直接混乱。
借书和还书功能绕开了这个难题——它们不是删除索引节点,只是更新图书记录里的status字段。我当时把这个逻辑单独封装成updateBookStatus函数,内部通过B树索引找到dataOffset,再fseek到数据文件里修改那一条记录的状态标志。课程设计答辩时老师问“为什么借书不涉及B树删除”,把这个区别讲清楚,算是加分项。
3.3 精确查询和范围查询的落地
按ISBN精确查询走的是B树查找,从根节点开始,当前节点内做二分查找,找到对应键就返回dataOffset,找不到就顺着childPtr往下走。B+树按书名查询则分成两步:先在B+树内部节点找到第一个大于等于目标书名的叶子节点,然后在叶子链表上顺序扫描。
范围查询是B+树最擅长的事。比如查“所有书名以‘C语言’开头的书”,先定位到第一个“C语言”前缀所在的叶子,然后沿着nextLeaf一路往后读,直到遇到第一个不是该前缀的键为止。这个过程不需要回溯父节点,叶子链表天然支持顺序访问,效率比B树高很多。我当时统计过,一万条数据下,按书名范围查询,B+树的叶子链表扫描比B树逐节点中序遍历快一个数量级。
查询函数的参数里还有一个很细的点:keys数组里存的是键的整型映射,因为B树的比较运算直接用int比较最快,而真实ISBN是字符串格式的。为了统一,我在程序启动时把每个ISBN计算成一个唯一的整数ID存到临时映射表里,B树索引全部基于这个ID建立。检索出结果后再通过ID反查ISBN字符串。这个设计让B树整体操作简单了很多,不需要写字符串比较的低效逻辑。
4. 调试实录与典型问题排查
4.1 内存泄漏与文件缓冲的连环坑
这类C语言项目最常见的故障不是算法错误,而是内存泄漏。B树每个节点的读写都涉及malloc和free,如果readNode之后用完不free,程序运行一小时就能吃掉几百兆内存。我当时用valgrind跑一遍,瞬间报出几十处内存泄漏,源头几乎全在读入节点后没有free。排查思路很简单:每调用一次readNode,就检查一次是否在后续所有路径上都执行了freeNode。
文件缓冲导致的问题更隐蔽。fwrite之后不调用fflush,接着马上fread,读到的往往是fopen时加载到缓冲区里的旧数据。这一度让我怀疑B树插入代码写错了,后来把writeNode函数里的fflush加上,整个系统立刻恢复正常。工程上如果频繁读写文件,建议在写操作后统一调用fflush,或者在fseek之后调用fsetpos强制刷新,不要嫌性能损耗大,正确性优先。
4.2 删除后的文件空洞与节点错乱
删除B树节点后,文件里那些被删除的节点占用的块并没有清空。如果不做空闲块管理,下次插入新节点时就会追加到文件末尾,文件越来越臃肿,还可能出现“插入后查不到”的怪现象——因为链表或树里指向的偏移量指向的是旧数据块。我的解法是维护一个arrayList类型的空闲偏移量集合,删除节点时把偏移量放进去,插入新节点时优先pop出来复用。代码不多,但解决了文件膨胀的大问题。
节点错乱的另一个来源是fseek定位失败。文件偏移量是long类型,在32位和64位系统下大小不同,如果编译时架构不一致,文件里保存的偏移量就会损坏。我在设计文件头时专门加了一个magic number校验,启动时先检查文件的版本号,不一致就提示重新初始化,避免把崩溃的节点当成正常数据继续处理。
4.3 性能观测与优化空间
课程设计报告里最好放一张性能观测表,当时我的测试环境是普通笔记本,数据量5000条,M=5。
| 操作类型 | 数据结构 | 平均耗时 | 最大IO次数 |
|---|---|---|---|
| 按ISBN精确查询 | B树 | 约0.3ms | 5次节点读取 |
| 按书名范围查询 | B+树 | 约1.2ms | 12个叶节点扫描 |
| 全部图书顺序遍历 | B+树 | 约2.5ms | 按链表逐叶读取 |
实测下来B树的树高在5000条数据下只有5层,也就是说一次查询最多读5个节点文件,速度确实远快于线性扫描。如果还要继续优化,可以考虑把节点大小调整为操作系统磁盘块大小对齐(一般是4KB),减少IO次数;每个节点内做二分查找时用更高效的movable算法,不过这就超出课程设计的要求范围了。
自己做这个课程设计时,最大的体会是:B树和B+树的实现不能只看书上的伪代码,一定要亲手写一遍文件存储和分裂合并。伪代码里节点之间用的是内存指针,一落地到文件就全是偏移量和数组拷贝,难度完全不是一回事。如果时间充裕,建议先写内存版B树跑通逻辑,再改写成文件版,这样排查问题时能明确区分是算法错误还是文件IO错误。最后分享一个答辩小技巧:把每一层节点分裂前后的状态打印出来,截图放报告里,比任何文字说明都有说服力。
本文还有配套的精品资源,点击获取