实战为王!用C语言完成学生信息管理系统开发——从链表到交互界面

       近日,本笔者基于 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(),必须按回车才生效,体验感不太好。后来查资料改成了非阻塞模式,用tcgetattrtcsetattr修改终端设置,不用回车,按上下键直接切换选项,按 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();
}

五、开发过程中体悟(可以给大家避避雷)

  1. 输入缓冲问题:用scanf之后要及时清理缓冲区,避免残留字符影响后续输入。我在每个scanf后面都加了while(getchar() != '\n');,解决了输入乱码的问题。
  2. 链表空指针:初始化链表的时候,头节点的prev_pnext_p要指向自己,不然判断链表是否为空 (head_node->prev_p == head_node) && (head_node->next_p == head_node)会出错。
  3. 终端设置恢复:修改终端为非阻塞模式后,不管程序正常退出还是异常退出,都要恢复原来的设置。我在main函数里加了退出处理,确保终端不会 “变砖”。
  4. 数据一致性:修改学生信息的时候,学号不能改(学号是唯一标识),所以代码里把change_data.id设成了find_data.id,避免用户误改学号导致数据混乱。

六、总结及优化建议

       目前实现的功能足够满足基础的学生信息管理需求:添加、删除、查看、修改学生信息,按成绩排序,按学号查找。代码结构清晰,每个模块各司其职 ——main.c管交互,dlink_cir_list.c管理双向循环链表,stu_func.c管理学生数据处理,后期要加功能,比如导出 Excel、添加课程信息,直接加新的.c 和.h 文件就行,不用动核心逻辑。

 

       如果要优化的话,有几个方向:一是加文件存储,现在数据存在内存里,程序一退就没了,用fwritefread把数据存到本地文件里;二是加权限管理,比如老师和学生账号,学生只能看自己的信息,老师能改所有信息;三是优化查找效率,按学号排序后用二分查找,或者用哈希表存学号和节点的映射,查的时候直接定位。

      此次开发实践,不仅巩固了 C 语言与数据结构的基础知识,更深刻体会到程序设计中 "权衡" 的艺术 —— 没有放之四海而皆准的方案,唯有根据具体场景做出最合适的选择。这或许正是编程的魅力所在。

“如鹏教育”是为计算机、信息等IT类专业在校大学生服务的学习社区。IT行业是一个前景广阔的行业,对人才的需求量非常大,但是与此对应的是在校IT类专业大学生却非常迷茫,他们有着各种各样的困惑: (1)IT类专业好找工作吗?待遇怎么样? (2)现在计算机专业学生那么多,我们会不会找不到工作? (3)培训机构几个月就能培养出一个高薪白领软件工程师,我学四年却什么都不会,是不是上大学浪费了? (4)听说我们专业毕业后可以做软件开发、游戏开发、嵌入式开发、网络管理,我应该学哪个方向? (5)有人说“做软件开发就是吃青春饭,干不到35岁”,是不是35岁以后我就失业了? (6)IT行业的技术发展这么快,是不是我学的很快就会被淘汰?学什么不会被淘汰? (7)这么多技术,我该学什么?我该怎么学? (8)看到招聘启事上都写着要会某某工具、某某语言、某某框架,这什么时候能学完? (9)单位招聘都要两年、三年的工作经验,我还没毕业哪里来的经验呀? (10)像微软、google、百度、IBM等这样的大公司招聘的时候看重什么能力呀? (11)考研还是不考研,谁能告诉我? (12)计算机专业是学好C语言就行了吗?C#、 Java那些东西需不需要学? ………… 大部分同学都在被这些问题迷茫着,因此浪费了大量的时间,也走了很多弯路,这样大部分同学毕业后个人能力根本无法满足企业的要求,这就出现了同学们最害怕的“毕业即失业”!“企业里急需大量才人,应届生找不到工作”是业内一个怪圈,“计算机321”认为要从根本上改变这个怪圈就要从同学们的大学生活的每一天抓起。“大一看清IT行业、对这个行业产生兴趣;大二、大三苦练基本技能、实战本领;大四学习求职技巧 ”是我们运营的宗旨。 我们原创的《C语言也能干大事》、《自己动手写网站》、《学校里教的过时了吗》、《一切语言都是纸老虎》等视频教程已经帮助很多同学走出了困境! 学东西不用东奔西走,在宿舍就能学习,在网上就能与老师互动。让“如鹏”与同学们共成长。
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值