校园选课记录检索与大数据分析系统
校园选课记录检索与大数据分析系统——C语言实现三引擎架构与十万级性能压测实战
写在前面
这篇文章是我数据结构与算法课程设计的完整记录。项目实现了校园选课记录检索与大数据分析系统,纯C语言开发,手写了双向链表、AVL树、哈希表三种底层存储引擎,还做了外部排序、数据生成器、多关键字排序、统计分析等一套完整功能。代码总量7800+行。
本文会毫无保留地分析代码里的Bug——没错,就是那些没写对的地方、没实现完的地方、注释掉一大片的地方,我都会说清楚。希望能帮到正在做课设的同学,也欢迎大佬指正。
一、项目背景——为什么做这个
选课是大学生每学期都经历的事。数万人同时选课、退课、查成绩、看课表——这些操作的背后是一套数据管理系统在支撑。
这个项目的核心问题就一个:选课记录应该用什么数据结构来存?
链表、AVL树、哈希表,哪个最适合?没有标准答案,只有"最合适"——所以我干脆三个都写了,同一份数据同时存在三个引擎里,跑分对比,用数据说话。
二、系统架构概览
项目目录结构:
task/
├── inc/ # 头文件(17个)
│ ├── common.h # 公共定义、引擎枚举
│ ├── model.h # 数据模型 CourseRecord
│ ├── status.h # 状态码
│ ├── statistics.h # 统计结构体
│ ├── utils.h # 工具函数
│ ├── linked_list.h # 链表接口
│ ├── avl_tree.h # AVL树接口
│ ├── hash_table.h # 哈希表接口
│ ├── data_manager.h # 数据管理接口
│ ├── data_generator.h # 数据生成器接口
│ ├── external_sort.h # 外部排序接口
│ ├── menu.h / task_demo.h / ...
├── src/ # 源文件(14个)
│ ├── main.c # 程序入口(165行)
│ ├── data_generator.c # 数据生成器(825行)
│ ├── data_manager.c # 数据管理层(712行)
│ ├── linked_list.c # 双向链表(141行)
│ ├── avl_tree.c # AVL树(322行)
│ ├── hash_table.c # 哈希表(160行)
│ ├── external_sort.c # 外部排序(464行)
│ ├── menu.c # 主菜单(1816行)
│ ├── task_demo.c # 任务演示(2841行)
│ └── advanced_ops.c # 高级操作(346行)
├── build.bat # 编译脚本
├── 100.csv / 1000.csv / 10000.csv / 100000.csv # 示例数据
核心数据结构——选课记录
每条选课记录包含10个字段:
typedef struct {
char student_id[20]; // 学号(12位数字)
char name[50]; // 姓名
char college[50]; // 学院
char course_id[20]; // 课程号(8位,如CS102026)
char course_name[100]; // 课程名
float credit; // 学分
char semester[20]; // 学期(如"2026-01")
char enroll_date[20]; // 选课日期
int score; // 成绩(-1表示缺考/待出分)
int is_elective; // 是否选修(1选修/0必修)
} CourseRecord;
编译与开发环境配置(.vscode + build.bat)
项目的编译系统围绕 VSCode + MinGW-w64 搭建,包含三个关键文件。
.vscode/c_cpp_properties.json——IntelliSense配置
{
"compilerPath": "D:/MSYS2/ucrt64/bin/gcc.exe", // ← 必须改为你自己的gcc路径
"cStandard": "c11",
"cppStandard": "c++17",
"intelliSenseMode": "windows-gcc-x64",
"includePath": ["${workspaceFolder}/**", "${workspaceFolder}/inc"]
}
需要修改的地方:compilerPath必须改成你本地MinGW-w64的gcc路径。如果你用MSYS2,默认安装路径是D:/MSYS2/ucrt64/bin/gcc.exe;如果用mingw-w64,通常是C:/mingw64/bin/gcc.exe。
另外注意defines里定义了UNICODE和_UNICODE——这是给Windows API用的宽字符宏,但实际代码中并没有用到任何Windows宽字符API。这两个宏实际上是多余的,删了也不影响编译。
.vscode/tasks.json——构建任务
{
"label": "Smart Build & Run (自动判断)",
"type": "shell",
"command": "${workspaceFolder}\\build.bat",
"args": ["${file}"],
"group": { "kind": "build", "isDefault": true },
"problemMatcher": ["$gcc"]
}
设计思路:把当前编辑的文件名(${file})传给build.bat作为参数。build.bat根据文件名自动判断要编译主程序还是数据生成器——如果当前打开的是data_generator.c,就编译独立版数据生成器;否则编译主程序。
build.bat——编译脚本(58行)
build.bat是整个项目的编译入口,逻辑如下:
build.bat 流程:
1. 接收参数 %1(当前文件名)
2. 如果文件名是 data_generator.c → 跳转到 BuildGenerator
3. 否则 → 跳转到 BuildMain
:BuildGenerator (独立编译数据生成器)
① ren src\main.c main.c.bak ← 临时移除 main.c(防止冲突)
② gcc -DSTANDALONE ... src\*.c -o data_generator.exe -lm
③ set GCC_RESULT=%errorlevel%
④ ren src\main.c.bak main.c ← 恢复 main.c
⑤ 编译成功 → 启动 data_generator.exe
⑥ 编译失败 → 暂停并显示错误
:BuildMain (编译主程序)
① gcc ... src\*.c -o main.exe -lm
② 编译成功 → 启动 main.exe
③ 编译失败 → 暂停并显示错误
关键设计细节:
-
ren技巧解决源文件冲突:数据生成器有独立的main()入口(被#ifdef STANDALONE包裹),而src/目录下还有一个main.c。如果直接用gcc src/*.c编译,两个main函数会冲突。解决方案是:编译前把main.c重命名为main.c.bak,编译完再改回来。 -
中文编码支持:
chcp 65001将控制台代码页切换为UTF-8,-finput-charset=UTF-8 -fexec-charset=UTF-8告诉gcc源文件和运行时都使用UTF-8。这样才能在控制台正确显示中文。 -
&代替&&:第29行的start cmd /k "chcp 65001 >nul & cd /d "%~dp0" & data_generator.exe & echo ..."使用&而非&&。注释中标明"修复Bug2"——确保程序崩溃时也能暂停显示错误信息,而不是直接关闭窗口。 -
链接数学库:
-lm用于链接数学库,因为数据生成器中使用了sqrt、log、cos、round等数学函数。 -
通配符编译:
src\*.c会自动包含src/下所有.c文件,新增源文件时无需修改编译脚本。
常见问题
Q:VSCode提示"找不到gcc"?
A:修改c_cpp_properties.json中的compilerPath和build.bat中的gcc命令为完整路径。
Q:编译时中文乱码?
A:build.bat已设置UTF-8编码。如果仍有问题,检查源文件是否保存为UTF-8格式(VSCode右下角可切换编码)。
Q:build.bat提示"ren 命令不存在"?
A:ren是Windows CMD的内部命令,在PowerShell中需要使用Rename-Item。但build.bat是在CMD环境下运行的(@echo off开头),所以不会有问题——前提是你直接双击运行或在VSCode中通过tasks.json调用。
系统架构分层
┌─────────────────────────────────────────────┐
│ CLI 交互层 (menu.c / task_demo.c) │
├─────────────────────────────────────────────┤
│ 业务逻辑层 (data_manager.c) │
├──────────┬──────────┬──────────────────────┤
│ 链表引擎 │ AVL树引擎 │ 哈希表引擎 │
│(linked_ │ (avl_ │ (hash_table.c) │
│ list.c) │ tree.c) │ │
├──────────┴──────────┴──────────────────────┤
│ 数据生成器 / 外部排序 / 高级操作模块 │
│ (data_generator / external_sort / advanced) │
└─────────────────────────────────────────────┘
三、三大存储引擎详解
3.1 双向链表(linked_list.c,141行)
标准双向链表,尾插法实现。
typedef struct LinkedListNode {
CourseRecord data;
struct LinkedListNode *prev;
struct LinkedListNode *next;
} LinkedListNode;
// 尾插法核心代码
int List_Insert(LinkedList *l, const CourseRecord *rec) {
LinkedListNode *n = (LinkedListNode *)malloc(sizeof(LinkedListNode));
n->data = *rec;
n->next = NULL;
if (l->size == 0) {
n->prev = NULL;
l->head = l->tail = n;
} else {
n->prev = l->tail;
l->tail->next = n;
l->tail = n;
}
l->size++;
return STATUS_SUCCESS;
}
优点:插入O(1),常数极小,适合小数据量频繁插入。
缺点:查找和删除需要遍历O(n),数据量一大直接拉胯。
3.2 AVL树(avl_tree.c,322行)
手写了LL/RR/LR/RL四种旋转,删除时用中序后继替换法。
// 右旋转——处理LL型不平衡
static AVLTreeNode *RotateRight(AVLTreeNode *y) {
AVLTreeNode *x = y->left;
AVLTreeNode *T2 = x->right;
x->right = y;
y->left = T2;
y->height = Max(GetHeight(y->left), GetHeight(y->right)) + 1;
x->height = Max(GetHeight(x->left), GetHeight(x->right)) + 1;
return x; // 返回新根
}
键值比较策略:先比学号,学号相同再比课程号。
优点:天然有序,O(log n)的查找性能稳定。
缺点:旋转有额外开销,插入和删除比链表慢。
3.3 哈希表(hash_table.c,160行)
DJB2哈希算法 + 拉链法(头插)解决冲突。
static unsigned int HashFunc(const char *s, const char *c) {
unsigned long long h = 5381;
while (*s) { h = ((h << 5) + h) + (*s); s++; }
while (*c) { h = ((h << 5) + h) + (*c); c++; }
return (unsigned int)(h % HASH_TABLE_SIZE);
}
优点:O(1)的平均查找性能,大数据量下的王者。
缺点:无序,不支持范围查询;内存占用略大。
3.4 三引擎全同步架构
这是我自认为最有意思的设计——同一份数据同时存在三个引擎里。插入时三引擎都写,任何一个失败就回滚所有。
int DM_InsertRecord(const CourseRecord *rec) {
int s1 = List_Insert(&g_list, rec);
int s2 = 0;
g_avl_root = AVL_Insert(g_avl_root, rec, &s2);
int s3 = Hash_Insert(&g_hash, rec);
// 任一失败就回滚
if (s2 != STATUS_SUCCESS) {
List_DeleteByKey(&g_list, rec->student_id, rec->course_id);
return s2;
}
if (s3 != STATUS_SUCCESS) {
List_DeleteByKey(&g_list, rec->student_id, rec->course_id);
AVL_Delete(g_avl_root, rec->student_id, rec->course_id, &rollback);
return s3;
}
return STATUS_SUCCESS;
}
还设计了自适应调度策略:
- 数据量 < 5000条:三引擎全同步,数据强一致
- 数据量 ≥ 5000条:自动切换哈希表单引擎,追求极致性能
int DM_AdaptiveInsert(const CourseRecord *rec) {
int current_size = DM_GetDataSize();
if (current_size < ADAPTIVE_THRESHOLD) { // 5000
g_active_engine = ENGINE_ALL_SYNC;
return DM_InsertRecord(rec); // 三引擎全写
} else {
g_active_engine = ENGINE_HASH_TABLE;
return DM_InsertSingle(rec); // 哈希表单引擎
}
}
四、数据生成器——模拟真实教务系统
data_generator.c写了825行,实现了两种生成模式。
模式一:真实教务系统模式(≥50000条)
三种学生画像:
- 学霸(20%):均值92分,波动小,偏好本专业选修课
- 普通(64%):均值78分
- 学渣(16%):均值55分,45%挂科率
轮盘赌选课算法——按动态权重抽取选修课:
// 动态计算权重
float dynamic_total = 0.0f;
for (int i = 0; i < COURSES_COUNT; i++) {
if (COURSES[i].id[3] == '1') { // 选修课
float w = elective_weights[i];
if (student_type == 0) { // 学霸偏好本专业
bool is_major = ...;
if (is_major) w += 8.0f + (float)(rand() % 10);
}
if (student_type == 2 && COURSES[i].credit >= 2.5f)
w += 5.0f; // 学渣偏好高学分课
dynamic_weights[i] = w;
dynamic_total += w;
}
}
// 轮盘赌选择
float r = ((float)rand() / RAND_MAX) * dynamic_total;
float cumulative = 0.0f;
for (int i = 0; i < COURSES_COUNT; i++) {
if (dynamic_weights[i] > 0.0f) {
cumulative += dynamic_weights[i];
if (r <= cumulative) { selected_course_idx = i; break; }
}
}
成绩使用Box-Muller变换生成正态分布:
int GenerateScore(double mean, double stddev, int min_score, int max_score, double fail_rate) {
// 按fail_rate概率直接生成不及格分数
if ((double)rand() / RAND_MAX < fail_rate)
return 40 + (rand() % 20);
// Box-Muller算法
double u1 = (double)rand() / RAND_MAX;
double u2 = (double)rand() / RAND_MAX;
double z0 = sqrt(-2.0 * log(u1)) * cos(2.0 * 3.14159265358979 * u2);
double score = mean + z0 * stddev;
if (score < min_score) score = min_score;
if (score > max_score) score = max_score;
return (int)round(score);
}
必修课挂科后自动生成补考记录(下一学期)。
模式二:高随机性测试模式(<50000条)
完全随机生成,用于压力测试。带有课程容量限制(每门课每学期最多180人)和安全阀机制(连续失败超过50000次自动释放容量限制,防止死循环)。
学号生成规则
12位学号:YYYY(4) + CC(2学院) + MM(2专业) + Cls(2班级) + Seq(2序号)
前5400个名额分配给热门学院(计算机01、电信02、经管07),后续给冷门学院——模拟真实招生分布。
五、🔍 Bug深度分析——我代码里的坑
以下是我仔细审查全部代码后发现的Bug和问题。
Bug 1:按学号排序实际上在按成绩排序(最致命的Bug)
位置:advanced_ops.c 第49行
static int MultiKeyCompare(const void *a, const void *b) {
for (int i = 0; i < g_current_rule_count; i++) {
switch (rule.field) {
case SORT_FIELD_STU_ID:
cmp = rec1->score - rec2->score; // ← Bug!应该是 student_id
break;
case SORT_FIELD_SCORE:
cmp = rec1->score - rec2->score; // 和上面一模一样
break;
case SORT_FIELD_STU_ID下面写的是rec1->score - rec2->score,跟下面SORT_FIELD_SCORE分支的代码完全相同。典型的复制粘贴忘记改。用户选择"按学号排序"时,实际上在按成绩排序。
Bug 2:外部排序(advanced_ops版)吞掉了第一条有效数据
位置:advanced_ops.c 第276行
// 原注释:"Bug修复:原代码此处有 fgets 试图跳过表头,但前面写入临时文件时并未写入表头"
fgets(line, sizeof(line), t_fp); // 跳过"表头"——但临时文件根本没有表头!
这行代码的本意是跳过CSV表头。但临时文件temp_chunk_*.csv写入时根本没写CSV表头(写入代码在第224-230行,直接写了数据行)。所以这个fgets吃掉的是每个临时文件的第一条有效数据。
而且这个"外部排序"本质上是一个假的外部排序——它把临时文件拼接到一起,然后全部读进内存,用qsort排完再写回去。10万条数据全读内存,那还叫"外部排序"吗?
好消息:同目录的
external_sort.c(464行)实现了一个真正的外部排序——分割成1000条一块,用最小堆做K路归并(K=10),这才是正确的做法。
Bug 3:课程容量使用率的分母是200,但容量上限是180
位置:data_manager.c 第492行
stats[i].usage_rate = (stats[i].enroll_count / 200.0f) * 100.0f;
但数据生成器里定义的是 MAX_COURSE_CAPACITY = 180。使用率计算用200做分母,和容量定义对不上。
Bug 4:CompareByField 函数根本不存在
位置:advanced_ops.c 第27行
int cmp = CompareByField(a, b, g_sort_rules[i]); // 函数未定义!
MultiKeyCompare函数里调用了CompareByField,但整个项目搜索不到这个函数的定义。这是一个悬空函数调用——如果这段逻辑被激活,编译直接报错。
Bug 5:unique_courses 字段永远是0
statistics.h中SemesterTrendStat定义了int unique_courses字段,但data_manager.c中DM_GetSemesterTrendStats()函数从未对这个字段赋值。
Bug 6:过期记录清理接口是空壳
位置:data_manager.c 第431-435行
int DM_CleanExpiredRecords(void) {
return 0; // 空壳函数,啥也没干
}
清理逻辑在menu.c和task_demo.c中手动实现了,但对外暴露的这个接口函数完全没有实现。
Bug 7:#if 0注释掉了1251行核心代码
menu.c第444行到1695行——整整1251行代码被#if 0包起来了。这里面有完整的教师端操作台、学生端服务台、筛选排序、统计分析、数据维护、性能测试整套菜单。
问题和后面会详细说。
六、注释代码深度分析——取消后能做什么?
6.1 menu.c 第444-1695行的 #if 0 块
现状(不取消):
主菜单只有两个选项:
[1] 任务书专项演示
[0] 退出系统
所有功能通过task_demo.c中的演示模块提供。
取消后会激活完整的教务管理系统子菜单:
| 功能 | 说明 |
|---|---|
| 教师端操作台 | 录入/修改成绩、新生学籍注册、完整录入选课、自动排课(带时间窗口校验)、查看课程名单 |
| 学生端服务台 | 查看个人课表、选修课程、退选课程(带选课窗口期校验) |
| 筛选与排序 | 多条件筛选 + 多关键字排序 |
| 数据统计分析 | 5大维度报表 |
| 数据维护 | 过期清理、CSV加载/保存、自动排课 |
| 性能对比测试 | 切换引擎、标准测试(100/1000/10000条)、10万条压测 |
| 设置系统时间 | 修改模拟时间测试窗口期业务 |
⚠️ 注意:取消#if 0后还需要同时取消注释以下函数,否则编译报错:
PrintScore()(第424-430行)——格式化打印成绩(-1显示为"待修")GenerateRealisticStudentID()(第432-443行)——学号生成函数
6.2 task_demo.c 第31-36行的宏定义重映射
#define EnableANSI TD_EnableANSI
#define ReadStringWithDefault TD_ReadStringWithDefault
#define ReadStringWithTabPreview TD_ReadStringWithTabPreview
#define WaitEnter TD_WaitEnter
现状(保留):task_demo.c和menu.c各自有同名的静态函数,通过宏重命名防止链接冲突。
取消后:编译时会出现"符号重定义"错误。
6.3 注释掉的工具函数
| 函数 | 位置 | 取消后的效果 |
|---|---|---|
GetHighPrecisionTime() | menu.c:417-422 | 提供纳秒级高精度计时 |
PrintScore() | menu.c:424-430 | 美化成绩显示:-1显示为"待修",0显示为"待修" |
GenerateRealisticStudentID() | menu.c:432-443 | 生成符合学号规则的测试学号 |
6.4 数据生成器中的注释
data_generator.c中的注释主要是行内解释性注释:
- Box-Muller变换的数学原理
- 轮盘赌选课算法的实现步骤
- 课程容量索引计算的说明
这类注释取消不影响任何功能,但代码的可读性会降低,不利于学习和理解。
七、外部排序——我写得最满意的一个模块
external_sort.c(464行)实现了一个真正的外部排序,采用经典的分割+K路归并策略。
阶段一:分割(SplitPhase)
每次读入1000条(MEMORY_LIMIT),在内存中用qsort排序,写入临时文件temp_r0_XXX.csv。10万条数据约生成100个临时文件。
// 内存缓冲区
CourseRecord buffer[MEMORY_LIMIT]; // 1000条
// 读满就排序+写入
if (count == MEMORY_LIMIT) {
qsort(buffer, count, sizeof(CourseRecord), CompareRecordByID);
snprintf(names[file_count], MAX_FILENAME_LEN, "temp_r0_%03d.csv", file_count);
FILE *ft = fopen(names[file_count], "w");
for (int i = 0; i < count; i++)
WriteRecord(ft, &buffer[i]);
fclose(ft);
file_count++;
count = 0;
}
阶段二:K路归并(MergeRound)
使用最小堆同时合并K个有序文件(K=10):
typedef struct {
char student_id[MAX_STU_ID_LEN]; // 比较键
char line[1024]; // 完整CSV行,直接写入
int file_idx; // 来源文件索引
} HeapNode;
// 归并核心循环
while (heap_size > 0) {
HeapNode top = HeapPop(heap, &heap_size);
fputs(top.line, fout); // 直接写入原始CSV行
(*write_ops)++;
int fi = top.file_idx;
HeapNode node;
node.file_idx = fi;
if (fgets(node.line, sizeof(node.line), files[fi])) {
ExtractStudentID(node.line, node.student_id, sizeof(node.student_id));
HeapPush(heap, &heap_size, node);
}
}
巧妙之处:line字段存储的是原始CSV文本,归并输出时直接fputs写入,避免了反复的结构体序列化和反序列化开销。
性能报告输出
################## 排序完成 ##################
┌─────────────┬────────┬────────┬─────────┐
│ 阶段 │ 读取 │ 写入 │ 文件数 │
├─────────────┼────────┼────────┼─────────┤
│ 分割 │ 100000 │ 100000 │ -> 100 │
│ 归并(共3轮) │ 200000 │ 200000 │ -> 1 │
├─────────────┼────────┼────────┼─────────┤
│ 合计 │ 300000 │ 300000 │ │
└─────────────┴────────┴────────┴─────────┘
八、统计分析功能
系统实现了5项统计分析:
4.1 课程人数与容量使用率
按课程分组统计选课人数,使用率 = 人数 / 200 × 100%。
4.2 学生学分统计
按学号分组,统计选课门数和已获得总学分(成绩≥60分的课程才算学分)。
if (all[i].score != -1 && all[i].score >= 60)
stats[j].total_credit += all[i].credit;
4.3 学院分布
各学院选课人数及占比。
4.4 学期趋势
每个学期的选课人次和不重复课程数。这里手动实现了去重逻辑——用字符串拼接的方式记录已统计的key。
4.5 成绩分布
优秀(90+)、良好(80-89)、中等(70-79)、及格(60-69)、不及格(<60)、待修(-1)六档,带百分比。
九、性能对比——实测数据
各引擎的理论时间复杂度:
| 操作 | 链表 | AVL树 | 哈希表 |
|---|---|---|---|
| 插入 | O(1) | O(log n) | O(1) |
| 查找 | O(n) | O(log n) | O(1) |
| 删除 | O(n) | O(log n) | O(1) |
| 内存占用 | 最小 | 中等 | 较大 |
10万条数据下的预期表现:
| 引擎 | 插入10万条 | 查找10万次 | 删除10万次 | 综合 |
|---|---|---|---|---|
| 链表 | ~500ms | 极慢(O(n)) | 极慢(O(n)) | ❌ |
| AVL树 | ~400ms | ~300ms | ~300ms | ⚠️ |
| 哈希表 | ~200ms | ~15ms | ~15ms | ✅ |
这也是为什么自适应策略的阈值设为5000条:小数据量三引擎同步保一致,超过5000条自动切哈希表追性能。
十、项目总结
项目亮点
- 三引擎全同步架构——同一份数据同时存在链表/AVL树/哈希表中,插入时事务性回滚保证一致性
- 自适应调度策略——5000条阈值自动切换引擎,小数据量保一致、大数据量保性能
- 数据生成器仿真度高——正态分布成绩、轮盘赌选课、三种学生画像、补考逻辑
- 真正的外部排序——分割+最小堆K路归并,内存限制1000条也能排10万条数据
- 控制台交互体验——ANSI转义序列、Tab键预览学生/课程、方向键移动光标
最大的教训
一定要一次性想清楚接口再动手写代码。#if 0注释掉1251行旧代码,是因为后来重构了菜单系统但又舍不得删旧代码。结果新旧两套逻辑并存,代码可读性大打折扣。
还有就是变量命名和复制粘贴一定要检查。按学号排序变成按成绩排序这种Bug,如果代码review时仔细一点是能发现的。
需要改进的地方
| 问题 | 位置 | 修复建议 |
|---|---|---|
| 学号排序Bug | advanced_ops.c:49 | 将score改为student_id的strcmp |
| 假外部排序 | advanced_ops.c:259-285 | 删除或重写,改用external_sort.c |
| 使用率分母不一致 | data_manager.c:492 | 统一为180 |
| CompareByField缺失 | advanced_ops.c:27 | 补全函数定义 |
| unique_courses未赋值 | data_manager.c:577-610 | 补充去重逻辑 |
| 过期清理空壳 | data_manager.c:431-435 | 实现实际逻辑 |
| #if 0大段注释 | menu.c:444-1695 | 重构为条件编译 |
项目源码:https://github.com/Asxiangeshennan/Campus-Course-Registration-Record-Retrieval-and-Big-Data-Analytics-System
开发环境:Windows + VSCode + MinGW-w64 (gcc, C11标准)
编译方式:VSCode中按Ctrl+Shift+B,或双击build.bat
数据生成说明:生成大量数据时可能提示"提前结束",这是课程容量限制和安全阀机制触发的正常现象,多跑几次即可。
本文为课程设计记录,代码欢迎fork和star,欢迎在评论区交流讨论。
更多推荐
所有评论(0)