近日,本笔者基于 C 语言完成了一个学生信息管理系统的开发。这不仅是对基础知识的一次梳理,更在实践中深化了对数据结构与程序设计的理解。从双向循环链表的设计到终端交互界面的调试,踩了不少坑,也收获了很多。今天就把整个开发过程捋一捋,给同样在学 C 的朋友当个参考。
项目成果展示
一、选择双向循环链表的原因
在系统架构之初,数据存储方式的选择颇费思量。数组虽便于随机访问,但在频繁增删场景下效率欠佳;单链表删节点得从头找前驱,双向链表直接通过prev_p指针就能定位;循环结构则是避免了判空的麻烦,头节点的prev_p指向尾节点,尾节点的next_p指向头节点,遍历的时候不用怕越界,巧妙避免了边界判断的繁琐。
链表节点的设计我分了两层:一层是学生数据结构体,存储学号、姓名等具体信息;另一层是节点结构体,包含数据和前后指针。这样做的好处是后期想扩展成医疗挂号系统(代码里其实留了med_t结构体),改个宏定义就能用,不用大改链表逻辑。
// 1、学生结构体
typedef struct student
{
int id; // 学号
char name[20]; // 姓名
char gender; // 性别(M:男, F:女)
int age; // 年龄
float score; // 分数
}stu_t, *stu_p;
// 节点结构体设计
typedef struct node
{
// 数据域
datatype data; // 数据可以是任意数据
// 指针域
struct node* prev_p; // 指向相邻的上一个节点的指针
struct node* next_p; // 指向相邻的下一个节点的指针
}node_t, *node_p;
二、功能实现
链表的初始化与销毁是基础工作,需特别注意内存管理。在实现DLINK_CIR_LIST_UnInit()
函数时,通过遍历释放所有数据节点,再释放头节点,有效避免了内存泄漏。
1.链表的初始化与销毁
/**
* @brief: 初始化空双向循环链表(初始化头节点)
* @note: None
* @param: None
* @retval: 成功:返回指向这个头节点的指针
* 失败:返回NULL
*/
node_p DLINK_CIR_LIST_InitHeadNode(void)
{
// 1、给头节点申请一个堆内存空间
node_p p = malloc(sizeof(node_t));
bzero(p, sizeof(node_t));
// 2、将头节点的指针域的变量prev_p、next_p指向NULL,数据域不需要赋值
if ( p != NULL)
{
// 数据域
// 指针域
p->prev_p = p;
p->next_p = p;
}
else
{
return NULL;
}
// 3、成功返回指向头节点的指针
return p;
}
/**
* @brief: 销毁链表
* @note: None
* @param: head_node:头节点
* @retval: None
*/
void DLINK_CIR_LIST_UnInit(node_p head_node)
{
// 1、判断链表是否为空,是的话,返回-1
if (DLINK_CIR_LIST_IfEmpty(head_node))
{
free(head_node);
return;
}
// 2、销毁链表中的数据节点
node_p tmp_p = NULL;
node_p tmp2_p = NULL;
for (tmp_p=head_node->next_p; tmp_p!=head_node; tmp_p=tmp2_p)
{
tmp2_p = tmp_p->next_p;
free(tmp_p);
}
// 3、销毁链表中的头节点
free(head_node);
}
2.初始化数据节点、判断空链表
/**
* @brief: 初始化数据节点
* @note: None
* @param: data:要赋值的数据
* @retval: 成功:返回指向这个数据节点的指针
* 失败:返回NULL
*/
node_p DLINK_CIR_LIST_InitDataNode(datatype data)
{
// 1、给数据节点申请一个堆内存空间
node_p p = malloc(sizeof(node_t));
bzero(p, sizeof(node_t));
// 2、将数据节点的指针域变量prev_p、next_p指向NULL,需要对数据域里面的数据进行传参赋值
if ( p!=NULL )
{
// 数据域
p->data = data;
// 指针域
p->prev_p = p;
p->next_p = p;
}
else
{
return NULL;
}
// 3、成功返回指向数据节点的指针
return p;
}
/**
* @brief: 判断链表是否为空
* @note: None
* @param: head_node:头节点
* @retval: 如果链表为空:返回true
* 如果链表非空:返回false
*/
bool DLINK_CIR_LIST_IfEmpty(node_p head_node)
{
return ( (head_node->prev_p == head_node) && (head_node->next_p == head_node) );
}
3.插入数据(头插与尾插)
/**
* @brief: 插入数据(头插法)
* @note: None
* @param: head_node:头节点
* new_node: 要插入的新节点
* @retval: None
*/
void DLINK_CIR_LIST_HeadInsertDataNode(node_p head_node, node_p new_node)
{
// 1、
new_node->prev_p = head_node;
// 2、
new_node->next_p = head_node->next_p;
// 3、
head_node->next_p->prev_p = new_node;
// 4、
head_node->next_p = new_node;
}
/**
* @brief: 插入数据(尾插法)
* @note: None
* @param: head_node:头节点
* new_node: 要插入的新节点
* @retval: None
*/
void DLINK_CIR_LIST_TailInsertDataNode(node_p head_node, node_p new_node)
{
//1、设置一个中间变量指针,将指针指向链表的末尾
node_p tmp_p = NULL;
for (tmp_p = head_node; tmp_p->next_p != head_node; tmp_p = tmp_p->next_p);
//2、让new_node的prev_p指向tmp_p
new_node->prev_p = tmp_p;
//3、让new_node的next_p指向head_node
new_node->next_p = head_node;
//4、让head_node的下一步的上一步指向new_node
head_node->next_p->prev_p = new_node;
//5、让tmp_p的next_p指向new_node
tmp_p->next_p = new_node;
*/
}
4.删除数据
删除和修改都是以学号匹配,这里要注意,删除的时候得先保存前驱和后继节点,不然指针断了就找不到下一个了。
/**
* @brief: 删除数据
* @note: 根据数据来删除
* @param: head_node:头节点
* datatype: 要删除的数据
* @retval: 成功:返回0
* 失败:返回-1
*/
int DLINK_CIR_LIST_DelDataNode(node_p head_node, datatype data)
{
// 1、判断链表是否为空,是的话,返回-1
if (DLINK_CIR_LIST_IfEmpty(head_node))
return -1;
// 2、从头到尾开始遍历链表,找到要删除的节点、并将各个数据节点进行保存
node_p tmp_p = head_node;
node_p del_node = NULL;
node_p last_node = NULL;
node_p next_node = NULL;
int flag = 0;
for (tmp_p=head_node; tmp_p->next_p!=head_node; tmp_p=tmp_p->next_p)
{
#if STU_DATA
// 是否找到要删除的数据
if(STU_FUNC_IfFindDelData(tmp_p, data))
flag = 1;
#elif MED_DATA
#endif
if(flag == 1)
{
// 要要删除的数据节点,和上一个节点,下一个节点进行保存
last_node = tmp_p;
del_node = last_node->next_p;
next_node = del_node->next_p;
flag = 0;
break;
}
}
// 3、将要删除的数据节点删除
last_node->next_p = next_node;
next_node->prev_p = last_node;
// 4、释放掉要删除的数据节点的资源
del_node->prev_p = NULL;
del_node->next_p = NULL;
free(del_node);
// 5、成功返回0
return 0;
}
5.查找数据
/**
* @brief: 遍历链表的数据
* @note: 从头到尾遍历、从尾到头遍历
* @param: head_node:头节点
* @retval: 成功:返回0
* 失败:返回-1
*/
int DLINK_CIR_LIST_ShowListData(node_p head_node)
{
// 1、判断链表是否为空,是的话,返回-1
if (DLINK_CIR_LIST_IfEmpty(head_node))
return -1;
// 2、遍历整个链表,并打印里面的节点的数据
#if STU_DATA
STU_FUNC_ShowList(head_node);
#elif MED_DATA
#endif
// 3、成功返回0、
return 0;
}
6.修改数据
/**
* @brief: 修改数据
* @note: 先找到要修改的数据(根据学生的学号),修改其相应的数据(学生的其它信息)
* @param: head_node: 头节点
* find_data: 要修改的数据
* change_data:修改的数据
* @retval: 成功:返回0
* 失败:返回-1
*/
int DLINK_CIR_LIST_ChangeNodeData(node_p head_node, datatype find_data, datatype change_data)
{
// 1、判断链表是否为空,是的话,返回-1
if (DLINK_CIR_LIST_IfEmpty(head_node))
return -1;
// 2、遍历整个链表,并打印里面的节点的数据
node_p tmp_p = NULL;
int flag = 0;
// a、从头到尾遍历所有的可能
for ( tmp_p = head_node->next_p; tmp_p!=head_node; tmp_p=tmp_p->next_p)
{
#if STU_DATA
if(STU_FUNC_IfFindChangeData(tmp_p, find_data))
flag = 1;
#elif MED_DATA
#endif
// b、找到要修改的数据
if (flag == 1)
{
tmp_p->data = change_data;
flag = 0;
break;
}
}
// 3、成功返回0、
return 0;
}
三、交互界面设计
最开始界面就是纯文字打印,选功能得输入数字,特别麻烦。后来想整个能靠上下键选的菜单,就研究了 ANSI 转义序列 —— 比如\033[44;37m
是改背景色为蓝色、字体为白色,\033[7m
是反显,选中的菜单项用这个效果,用户一眼就能看出来选的是哪个。
键盘输入这块也折腾了好久。一开始用getchar()
,必须按回车才生效,体验感不太好。后来查资料改成了非阻塞模式,用tcgetattr
和tcsetattr
修改终端设置,不用回车,按上下键直接切换选项,按 Enter 确认,无需频繁输入数字,操作流程更为流畅。
/**
* @brief 获取键盘值函数(无堵塞)
* @note 1、设置终端为不堵塞模式
* 2、使用read函数读取输入到终端的1个数据
* @param None
* @retval 返回键盘值
*/
int KEYBOARD_GetVal(void)
{
// 标准输入文件描述符
struct termios old_termios, new_termios;
char ch = 0;
int fd = 0;
int key = 0;
// 获取并保存现有的终端设置
if (tcgetattr(fd, &old_termios) == -1)
{
perror("tcgetattr\n");
return -1;
}
// 设置为非阻塞模式
new_termios = old_termios;
new_termios.c_lflag &= ~ICANON; // 非canonical模式
new_termios.c_cc[VMIN] = 1; // 读取最小字符数
new_termios.c_cc[VTIME] = 0; // 无超时
// 应用新的终端设置
if (tcsetattr(fd, TCSANOW, &new_termios) == -1)
{
perror("tcsetattr\n");
return -2;
}
// 读取数据,不需要回车
while (read(fd, &ch, 1) == 1)
{
printf("%c\n", ch);
switch(ch)
{
case 'A' : key = UP; break;
case 'B' : key = DOWN; break;
case 'C' : key = RIGHT; break;
case 'D' : key = LEFT; break;
case '\n': key = ENTER; break;
case 'y' : key = YES; break;
case 'n' : key = NO; break;
case 'q' : key = EXIT; break;
default : key = UNKNOW; break;
}
return key;
}
// 恢复原来的终端设置
if (tcsetattr(fd, TCSANOW, &old_termios) == -1) {
perror("tcsetattr\r\n");
return -3;
}
}
/**
* @brief printf特效界面显示
* @note 《 学生管理系统 》
* 1、添加学生信息
* 2、删除学生信息
* 3、查看学生信息
* 4、修改学生信息
* 5、其它项
* 退出(q)
* @param 选择界面的变量值
* @retval None
*/
void SCREEN_PrintfSelectUi(int ui_num)
{
switch (ui_num)
{
case 1: // 1、添加学生信息
system("clear"); // 清屏
printf("\r");
printf("\033[44;37m==============================================\033[0m\n");
printf("\033[44;37m * * \033[0m\n");
printf("\033[44;37m * 《学生信息管理系统》 * \033[0m\n");
printf("\033[44;37m * * \033[0m\n");
printf("\033[44;37m\033[1m\033[4m\033[7m * 1、添加学生信息 * \033[0m\n");
printf("\033[44;37m * 2、删除学生信息 * \033[0m\n");
printf("\033[44;37m * 3、查看学生信息 * \033[0m\n");
printf("\033[44;37m * 4、修改学生信息 * \033[0m\n");
printf("\033[44;37m * 5、其它项 * \033[0m\n");
printf("\033[44;37m * 退出(q) * \033[0m\n");
printf("\033[44;37m==============================================\033[0m\n");
break;
.....
.....
.....
}
}
四、扩展功能实现
排序功能采用冒泡算法,通过交换节点数据而非移动节点本身,简化了实现逻辑。
1.按成绩排序(冒泡排序算法)
/**
* @brief 浮点数冒泡排序
*/
int BUBBLE_SORT_FloatSort(float *data_p, int len)
{
if ((data_p == NULL) || (len <= 1))
return -1;
int i = 0;
int j = 0;
for (i = 0; i < len-1; i++)
{
for (j = 0; j < len-i-1; j++)
{
if (data_p[j] > data_p[j+1])
{
float tmp = data_p[j];
data_p[j] = data_p[j+1];
data_p[j+1] = tmp;
}
}
}
return 0;
}
/**
* @brief 整数冒泡排序
*/
int BUBBLE_SORT_IntSort(int *data_p, int len)
{
if ((data_p == NULL) || (len <= 1))
return -1;
int i = 0;
int j = 0;
for (i = 0; i < len-1; i++)
{
for (j = 0; j < len-i-1; j++)
{
if (data_p[j] > data_p[j+1])
{
int tmp = data_p[j];
data_p[j] = data_p[j+1];
data_p[j+1] = tmp;
}
}
}
return 0;
}
2.按学号查找(顺序查找)
查找功能目前采用顺序遍历,按学号查找本来想用二分查找(代码里有bin_search.c),但二分要求数据有序,而学生学号不一定是按顺序存的,所以最后用了顺序查找。如果后期要优化,可以先按学号排序,再用二分,效率能提升不少。
void MAIN_BinarySearchById(node_p head_node)
{
int search_id;
MAIN_ShowSearchStudentInfoScreen(&search_id);
if (DLINK_CIR_LIST_IfEmpty(head_node))
{
MAIN_ShowSuccessScreen("查找学生信息", "链表为空!");
getchar();
return;
}
// 顺序查找
node_p found = NULL;
node_p tmp = head_node->next_p;
while (tmp != head_node)
{
if (tmp->data.id == search_id)
{
found = tmp;
break;
}
tmp = tmp->next_p;
}
MAIN_ShowSearchResult(found, search_id);
getchar();
}
五、开发过程中体悟(可以给大家避避雷)
- 输入缓冲问题:用
scanf
之后要及时清理缓冲区,避免残留字符影响后续输入。我在每个scanf
后面都加了while(getchar() != '\n');
,解决了输入乱码的问题。- 链表空指针:初始化链表的时候,头节点的
prev_p
和next_p
要指向自己,不然判断链表是否为空 (head_node->prev_p == head_node) && (head_node->next_p == head_node)会出错。- 终端设置恢复:修改终端为非阻塞模式后,不管程序正常退出还是异常退出,都要恢复原来的设置。我在
main
函数里加了退出处理,确保终端不会 “变砖”。- 数据一致性:修改学生信息的时候,学号不能改(学号是唯一标识),所以代码里把
change_data.id
设成了find_data.id
,避免用户误改学号导致数据混乱。
六、总结及优化建议
目前实现的功能足够满足基础的学生信息管理需求:添加、删除、查看、修改学生信息,按成绩排序,按学号查找。代码结构清晰,每个模块各司其职 ——
main.c
管交互,dlink_cir_list.c
管理双向循环链表,stu_func.c
管理学生数据处理,后期要加功能,比如导出 Excel、添加课程信息,直接加新的.c 和.h 文件就行,不用动核心逻辑。如果要优化的话,有几个方向:一是加文件存储,现在数据存在内存里,程序一退就没了,用
fwrite
和fread
把数据存到本地文件里;二是加权限管理,比如老师和学生账号,学生只能看自己的信息,老师能改所有信息;三是优化查找效率,按学号排序后用二分查找,或者用哈希表存学号和节点的映射,查的时候直接定位。此次开发实践,不仅巩固了 C 语言与数据结构的基础知识,更深刻体会到程序设计中 "权衡" 的艺术 —— 没有放之四海而皆准的方案,唯有根据具体场景做出最合适的选择。这或许正是编程的魅力所在。