言鏈表學(xué)生成績(jī)管理系統(tǒng):從結(jié)構(gòu)設(shè)計(jì)到內(nèi)存管理與文件持久化)
簡(jiǎn)介這份資源是面向計(jì)算機(jī)相關(guān)專業(yè)學(xué)生的C語(yǔ)言課程設(shè)計(jì)參考包圍繞「學(xué)生成績(jī)管理系統(tǒng)」提供完整課設(shè)報(bào)告與可運(yùn)行源碼適合正在完成數(shù)據(jù)結(jié)構(gòu)或程序設(shè)計(jì)大作業(yè)、需要一份高完成度范例的學(xué)習(xí)者。系統(tǒng)以鏈表為核心數(shù)據(jù)結(jié)構(gòu)涵蓋密碼登錄、菜單交互、學(xué)生信息錄入與增刪改查、文件保存與讀取以及單科排名、個(gè)人成績(jī)查詢、不及格超兩科名單統(tǒng)計(jì)等功能報(bào)告目錄從需求分析、概要設(shè)計(jì)、詳細(xì)設(shè)計(jì)到測(cè)試與總結(jié)層層展開(kāi)模塊劃分與函數(shù)調(diào)用關(guān)系梳理清晰。壓縮包共27個(gè)文件約837KB包含10個(gè)c源文件與10個(gè)o目標(biāo)文件、頭文件、工程配置及可執(zhí)行文件另附課設(shè)報(bào)告docx與測(cè)試數(shù)據(jù)txt多文件結(jié)構(gòu)便于對(duì)照閱讀與二次修改。目前已有963人學(xué)習(xí)作者稱該作業(yè)成績(jī)95分以上可作為課程設(shè)計(jì)模板與排錯(cuò)參考。1. 從一份課設(shè)說(shuō)起鏈表版學(xué)生成績(jī)管理系統(tǒng)到底在練什么每年學(xué)期末總有一批同學(xué)被同一道題卡住用 C 語(yǔ)言寫一個(gè)學(xué)生成績(jī)管理系統(tǒng)要求增刪改查、排序、統(tǒng)計(jì)、文件讀寫還要求用鏈表而不是數(shù)組。很多人第一反應(yīng)是去搜一份能跑的代碼抄下來(lái)交差結(jié)果答辯時(shí)被問(wèn)一句「你這個(gè)刪除節(jié)點(diǎn)之后內(nèi)存怎么處理的」就當(dāng)場(chǎng)翻車。這個(gè)標(biāo)題真正在練的不是「管理系統(tǒng)」這四個(gè)字而是用鏈表把動(dòng)態(tài)數(shù)據(jù)結(jié)構(gòu)、指針操作、內(nèi)存管理和文件持久化串成一條完整的工程鏈路。它適合兩類人一類是剛學(xué)完指針、結(jié)構(gòu)體、文件操作需要一個(gè)綜合項(xiàng)目把知識(shí)焊死的初學(xué)者另一類是想回頭補(bǔ)一補(bǔ) C 語(yǔ)言底層手感的人。鏈表在這里不是炫技而是剛需——學(xué)生人數(shù)事先不知道用數(shù)組就得開(kāi)一個(gè)「夠大」的固定空間開(kāi)小了溢出開(kāi)大了浪費(fèi)而鏈表可以隨加隨分配。把這件事做扎實(shí)你會(huì)順帶搞懂 malloc/free 的配對(duì)、二級(jí)指針改頭節(jié)點(diǎn)、文件里怎么存變長(zhǎng)記錄這些真問(wèn)題。下面按「先立結(jié)構(gòu)、再落功能、最后避坑」的順序講透。2. 結(jié)構(gòu)體與鏈表節(jié)點(diǎn)怎么設(shè)計(jì)先把地基打?qū)?.1 一個(gè)節(jié)點(diǎn)該存什么不該存什么鏈表節(jié)點(diǎn)的字段設(shè)計(jì)直接決定后面所有功能的寫法。常見(jiàn)做法是把「學(xué)生數(shù)據(jù)」和「鏈表指針」放在同一個(gè)結(jié)構(gòu)體里數(shù)據(jù)部分用獨(dú)立的結(jié)構(gòu)體包一層指針單獨(dú)一個(gè)字段。這樣做的理由是數(shù)據(jù)字段以后要擴(kuò)展加電話、加班級(jí)而指針邏輯不變分層之后改動(dòng)面小。#include stdio.h #include stdlib.h #include string.h /* 學(xué)生數(shù)據(jù)本體和鏈表指針解耦 */ typedef struct { char id[16]; /* 學(xué)號(hào)用字符串存避免前導(dǎo)零丟失 */ char name[32]; /* 姓名 */ float score; /* 成績(jī) */ } Student; /* 鏈表節(jié)點(diǎn)數(shù)據(jù) 指向下一個(gè)節(jié)點(diǎn)的指針 */ typedef struct Node { Student data; struct Node *next; } Node;這里有個(gè)新手最容易忽略的點(diǎn)struct Node *next里的struct Node不能省成Node因?yàn)樵诮Y(jié)構(gòu)體定義還沒(méi)結(jié)束時(shí)Node這個(gè) typedef 名字尚未生效。寫成Node *next在部分編譯器下會(huì)直接報(bào)錯(cuò)。學(xué)號(hào)用char數(shù)組而不是int是因?yàn)檎鎸?shí)學(xué)號(hào)常有前導(dǎo)零或字母用整型存會(huì)在打印時(shí)丟格式這個(gè)坑我在幫人看代碼時(shí)見(jiàn)過(guò)太多次。2.2 頭節(jié)點(diǎn)用不用「啞節(jié)點(diǎn)」先想清楚鏈表實(shí)現(xiàn)有兩種流派一種頭指針直接指向第一個(gè)真實(shí)節(jié)點(diǎn)另一種額外搞一個(gè)不存數(shù)據(jù)的「啞節(jié)點(diǎn)」dummy head。對(duì)課設(shè)這種增刪頻繁的場(chǎng)景我一般推薦啞節(jié)點(diǎn)原因是插入和刪除時(shí)不用單獨(dú)判斷「是不是在頭部操作」代碼能少一半if。/* 創(chuàng)建帶啞節(jié)點(diǎn)的空鏈表返回啞節(jié)點(diǎn)指針 */ Node *list_create(void) { Node *head (Node *)malloc(sizeof(Node)); if (head NULL) { return NULL; /* 分配失敗交給調(diào)用方處理 */ } head-next NULL; /* 啞節(jié)點(diǎn)不存數(shù)據(jù)只做錨點(diǎn) */ return head; }參數(shù)說(shuō)明函數(shù)無(wú)入?yún)⒎祷貑」?jié)點(diǎn)指針失敗返回NULL。邏輯說(shuō)明啞節(jié)點(diǎn)的data字段永遠(yuǎn)不用所有真實(shí)數(shù)據(jù)都掛在head-next之后。這樣后面寫插入時(shí)無(wú)論插到哪個(gè)位置都是「找到前驅(qū)節(jié)點(diǎn)改兩個(gè)指針」不需要特判頭插。代價(jià)是多花一個(gè)節(jié)點(diǎn)的內(nèi)存對(duì)課設(shè)規(guī)模完全可以接受。2.3 插入頭插、尾插、按學(xué)號(hào)有序插三種插入方式對(duì)應(yīng)三種需求。頭插最快但順序亂尾插保持錄入順序有序插讓鏈表天然按學(xué)號(hào)排好后面查重和打印都省事。課設(shè)里最實(shí)用的是有序插入。/* 按學(xué)號(hào)升序插入學(xué)號(hào)重復(fù)則拒絕插入返回 1 成功 0 失敗 */ int list_insert_sorted(Node *head, Student s) { Node *pre head; Node *cur head-next; /* 找到第一個(gè)學(xué)號(hào)大于等于 s 的節(jié)點(diǎn)pre 停在它前面 */ while (cur ! NULL strcmp(cur-data.id, s.id) 0) { pre cur; cur cur-next; } if (cur ! NULL strcmp(cur-data.id, s.id) 0) { return 0; /* 學(xué)號(hào)已存在拒絕重復(fù) */ } Node *node (Node *)malloc(sizeof(Node)); if (node NULL) { return 0; } node-data s; /* 結(jié)構(gòu)體整體賦值比逐字段拷貝省事 */ node-next cur; /* 新節(jié)點(diǎn)接管 cur */ pre-next node; /* 前驅(qū)指向新節(jié)點(diǎn) */ return 1; }邏輯說(shuō)明pre和cur一前一后同步推進(jìn)循環(huán)結(jié)束時(shí)pre是插入位置的前驅(qū)。strcmp返回負(fù)數(shù)表示前者小所以cur-data.id s.id時(shí)繼續(xù)往后走。參數(shù)上head是啞節(jié)點(diǎn)s按值傳遞結(jié)構(gòu)體對(duì)小結(jié)構(gòu)體開(kāi)銷可忽略。注意node-data s這種整體賦值只在結(jié)構(gòu)體不含指針成員時(shí)安全本例的id、name都是定長(zhǎng)數(shù)組可以直接賦值。3. 增刪改查與統(tǒng)計(jì)把功能一個(gè)個(gè)焊死3.1 刪除節(jié)點(diǎn)free 之前先想好誰(shuí)指向誰(shuí)刪除是鏈表里最容易內(nèi)存泄漏的操作。正確順序是先讓前驅(qū)跳過(guò)待刪節(jié)點(diǎn)再free待刪節(jié)點(diǎn)。順序反了free之后你就找不到前驅(qū)了。/* 按學(xué)號(hào)刪除返回 1 成功 0 未找到 */ int list_delete(Node *head, const char *id) { Node *pre head; Node *cur head-next; while (cur ! NULL strcmp(cur-data.id, id) ! 0) { pre cur; cur cur-next; } if (cur NULL) { return 0; /* 沒(méi)找到 */ } pre-next cur-next; /* 前驅(qū)跳過(guò)待刪節(jié)點(diǎn) */ free(cur); /* 此時(shí) cur 已脫離鏈表安全釋放 */ return 1; }參數(shù)說(shuō)明id用const char *表明函數(shù)不修改傳入字符串。邏輯說(shuō)明pre-next cur-next這一步完成后cur就成了沒(méi)人引用的孤兒節(jié)點(diǎn)free它不會(huì)影響鏈表結(jié)構(gòu)。這里如果先f(wàn)ree(cur)再訪問(wèn)cur-next就是典型的 use-after-free程序可能當(dāng)場(chǎng)崩也可能「看起來(lái)正常」——這種玄學(xué) bug 最難查。3.2 查找與修改返回節(jié)點(diǎn)指針比返回下標(biāo)有用查找函數(shù)最好返回節(jié)點(diǎn)指針這樣調(diào)用方拿到之后既能讀也能改不用再遍歷一次。/* 按學(xué)號(hào)查找返回節(jié)點(diǎn)指針未找到返回 NULL */ Node *list_find(Node *head, const char *id) { Node *cur head-next; while (cur ! NULL) { if (strcmp(cur-data.id, id) 0) { return cur; } cur cur-next; } return NULL; } /* 修改成績(jī)成功返回 1 */ int list_update_score(Node *head, const char *id, float new_score) { Node *node list_find(head, id); if (node NULL) { return 0; } node-data.score new_score; return 1; }邏輯說(shuō)明list_find是只讀遍歷不改變鏈表所以參數(shù)用Node *head即可。list_update_score復(fù)用查找結(jié)果避免重復(fù)遍歷。參數(shù)上new_score用float如果課設(shè)要求成績(jī)保留兩位小數(shù)打印時(shí)用%.2f控制即可存儲(chǔ)精度夠用。3.3 統(tǒng)計(jì)與排序平均分、最高分、按成績(jī)排序統(tǒng)計(jì)類功能遍歷一遍就能全算出來(lái)沒(méi)必要多次遍歷。排序則建議用「鏈表版冒泡」或「把數(shù)據(jù)拷進(jìn)數(shù)組排序再寫回」后者代碼更短。/* 一次遍歷算出人數(shù)、總分、最高分、最低分 */ void list_stats(Node *head, int *count, float *avg, float *max, float *min) { int n 0; float sum 0.0f; float mx -1.0f, mn 101.0f; Node *cur head-next; while (cur ! NULL) { float s cur-data.score; sum s; if (s mx) mx s; if (s mn) mn s; n; cur cur-next; } *count n; *avg (n 0) ? sum / n : 0.0f; *max (n 0) ? mx : 0.0f; *min (n 0) ? mn : 0.0f; }參數(shù)說(shuō)明count、avg、max、min都是輸出參數(shù)用指針回傳。邏輯說(shuō)明mx初值取 -1、mn初值取 101是因?yàn)槌煽?jī)合理范圍是 0 到 100這樣即使鏈表為空也不會(huì)算出錯(cuò)誤極值??真湵頃r(shí)avg直接給 0避免除零。排序如果要求不高冒泡交換節(jié)點(diǎn)里的data字段即可不用動(dòng)指針代碼量小且不易出錯(cuò)。4. 文件讀寫與內(nèi)存釋放讓數(shù)據(jù)活過(guò)下一次運(yùn)行4.1 存文件一行一條記錄用分隔符切字段課設(shè)一般要求數(shù)據(jù)能存盤、下次啟動(dòng)能讀回來(lái)。最省事的格式是每行一條記錄字段之間用空格或逗號(hào)分隔。用空格分隔時(shí)姓名不能含空格這點(diǎn)要在錄入時(shí)限制。/* 保存到文件成功返回 1 */ int list_save(Node *head, const char *path) { FILE *fp fopen(path, w); if (fp NULL) { return 0; } Node *cur head-next; while (cur ! NULL) { fprintf(fp, %s %s %.2f\n, cur-data.id, cur-data.name, cur-data.score); cur cur-next; } fclose(fp); return 1; } /* 從文件加載追加到鏈表尾部 */ int list_load(Node *head, const char *path) { FILE *fp fopen(path, r); if (fp NULL) { return 0; /* 文件不存在不算錯(cuò)誤首次運(yùn)行正常 */ } Student s; while (fscanf(fp, %15s %31s %f, s.id, s.name, s.score) 3) { list_insert_sorted(head, s); } fclose(fp); return 1; }參數(shù)說(shuō)明%15s和%31s里的數(shù)字是寬度限制防止讀入超長(zhǎng)字符串撐爆數(shù)組這是fscanf讀字符串必須養(yǎng)成的習(xí)慣。邏輯說(shuō)明fscanf返回成功賦值的項(xiàng)數(shù)等于 3 說(shuō)明三個(gè)字段都讀到了循環(huán)繼續(xù)文件讀完返回 EOF循環(huán)自然結(jié)束。加載時(shí)復(fù)用有序插入保證讀回來(lái)的數(shù)據(jù)也是排好序的。4.2 釋放整條鏈表別只 free 頭節(jié)點(diǎn)程序退出前要把所有節(jié)點(diǎn)釋放掉。只free(head)會(huì)漏掉后面所有節(jié)點(diǎn)雖然進(jìn)程退出后操作系統(tǒng)會(huì)回收但課設(shè)答辯時(shí)老師很可能專門看這一段。/* 釋放整條鏈表包括啞節(jié)點(diǎn) */ void list_destroy(Node *head) { Node *cur head; while (cur ! NULL) { Node *next cur-next; /* 先存下一個(gè)再釋放當(dāng)前 */ free(cur); cur next; } }邏輯說(shuō)明循環(huán)里必須先保存cur-next因?yàn)閒ree(cur)之后cur指向的內(nèi)存已失效再讀cur-next就是訪問(wèn)已釋放內(nèi)存。這個(gè)「先存后放」的順序是鏈表釋放的固定套路寫反了就是血淚經(jīng)驗(yàn)。4.3 主函數(shù)怎么把功能串起來(lái)主函數(shù)用一個(gè)循環(huán)菜單驅(qū)動(dòng)每個(gè)分支調(diào)用對(duì)應(yīng)函數(shù)。關(guān)鍵是輸入處理要健壯scanf讀數(shù)字失敗時(shí)要清空緩沖區(qū)否則會(huì)死循環(huán)。int main(void) { Node *head list_create(); if (head NULL) { printf(內(nèi)存分配失敗\n); return 1; } list_load(head, students.txt); /* 啟動(dòng)時(shí)嘗試加載 */ int choice; while (1) { printf(\n1.錄入 2.刪除 3.修改 4.查詢 5.統(tǒng)計(jì) 6.保存 0.退出\n); if (scanf(%d, choice) ! 1) { while (getchar() ! \n); /* 清空非法輸入 */ continue; } if (choice 0) break; /* 各分支調(diào)用對(duì)應(yīng)函數(shù)此處省略具體錄入交互 */ } list_save(head, students.txt); list_destroy(head); return 0; }邏輯說(shuō)明scanf返回 1 表示成功讀到一個(gè)整數(shù)否則說(shuō)明用戶輸入了非數(shù)字此時(shí)用getchar循環(huán)吃掉緩沖區(qū)里的殘留字符避免下一輪又讀到同樣的垃圾。退出前先保存再釋放順序不能反。5. 避坑與排查課設(shè)答辯最容易被問(wèn)倒的 5 個(gè)點(diǎn)5.1 現(xiàn)象程序運(yùn)行到刪除就崩潰原因先f(wàn)ree了節(jié)點(diǎn)之后又訪問(wèn)了它的next字段屬于 use-after-free。解決嚴(yán)格按「前驅(qū)改指向 → 再 free」的順序?qū)慺ree 之前把需要的指針都存好。5.2 現(xiàn)象插入后打印新數(shù)據(jù)不見(jiàn)了原因頭插時(shí)忘了更新頭指針或者用值傳遞Node *head卻在函數(shù)里改了head本身。解決如果操作會(huì)改變頭指針函數(shù)參數(shù)要用Node **head二級(jí)指針用啞節(jié)點(diǎn)則不存在這個(gè)問(wèn)題因?yàn)轭^指針永遠(yuǎn)不變。5.3 現(xiàn)象讀文件后數(shù)據(jù)重復(fù)或亂序原因加載時(shí)用了頭插導(dǎo)致文件里正序的數(shù)據(jù)讀進(jìn)來(lái)變成倒序或者沒(méi)做學(xué)號(hào)查重同一份文件加載兩次就翻倍。解決加載用有序插入并做查重或者加載前先清空鏈表。5.4 現(xiàn)象輸入姓名帶空格后面字段全錯(cuò)位原因fscanf的%s遇到空格就停姓名里的空格被當(dāng)成了下一個(gè)字段的分隔符。解決錄入時(shí)限制姓名不含空格或者改用fgets整行讀入再自己切分后者更穩(wěn)但代碼稍長(zhǎng)。5.5 現(xiàn)象程序跑完內(nèi)存占用一直漲原因刪除節(jié)點(diǎn)只改了指針沒(méi)free或者退出時(shí)只釋放了頭節(jié)點(diǎn)。解決所有malloc都要有對(duì)應(yīng)的free退出前調(diào)用list_destroy遍歷釋放??梢杂霉ぞ吲芤槐榭从袥](méi)有泄漏養(yǎng)成習(xí)慣比事后補(bǔ)救強(qiáng)。6. 進(jìn)階技巧把課設(shè)代碼改造成能拿得出手的樣子如果只滿足于「能跑」上面幾章就夠了。但如果你想讓這份課設(shè)真正成為簡(jiǎn)歷上能講的東西有幾個(gè)投入產(chǎn)出比很高的改造方向。第一個(gè)是把數(shù)據(jù)層和界面層分開(kāi)?,F(xiàn)在所有printf和scanf都散在功能函數(shù)里耦合很重??梢园唁浫?、打印這些交互抽到單獨(dú)的函數(shù)鏈表操作只負(fù)責(zé)數(shù)據(jù)這樣以后想換成圖形界面或者命令行參數(shù)模式數(shù)據(jù)層一行不用改。做法很簡(jiǎn)單讓list_insert_sorted這類函數(shù)只接收Student結(jié)構(gòu)體不碰任何輸入輸出。第二個(gè)是加一層簡(jiǎn)單的錯(cuò)誤碼。現(xiàn)在函數(shù)返回 0/1信息量太少??梢远x一組枚舉比如OK、ERR_DUP、ERR_NO_MEM、ERR_NOT_FOUND調(diào)用方根據(jù)錯(cuò)誤碼打印不同提示。這樣調(diào)試時(shí)一眼能看出是學(xué)號(hào)重復(fù)還是內(nèi)存不夠比統(tǒng)一打印「操作失敗」有用得多。第三個(gè)是用數(shù)組排序替代鏈表排序。鏈表冒泡寫起來(lái)指針繞來(lái)繞去容易出錯(cuò)。更穩(wěn)的做法是統(tǒng)計(jì)時(shí)把節(jié)點(diǎn)指針拷進(jìn)一個(gè)指針數(shù)組用qsort按成績(jī)排序再按順序打印。這樣排序邏輯和鏈表結(jié)構(gòu)解耦qsort的比較函數(shù)單獨(dú)寫代碼短且不容易翻車。/* qsort 比較函數(shù)按成績(jī)降序 */ int cmp_by_score_desc(const void *a, const void *b) { const Node *na *(const Node *const *)a; const Node *nb *(const Node *const *)b; if (na-data.score nb-data.score) return 1; if (na-data.score nb-data.score) return -1; return 0; } /* 把鏈表節(jié)點(diǎn)指針收集到數(shù)組后排序打印 */ void list_print_sorted_by_score(Node *head, int n) { Node **arr (Node **)malloc(sizeof(Node *) * n); if (arr NULL) return; Node *cur head-next; int i 0; while (cur ! NULL) { arr[i] cur; cur cur-next; } qsort(arr, n, sizeof(Node *), cmp_by_score_desc); for (i 0; i n; i) { printf(%s %s %.2f\n, arr[i]-data.id, arr[i]-data.name, arr[i]-data.score); } free(arr); /* 數(shù)組本身要釋放節(jié)點(diǎn)不釋放 */ }邏輯說(shuō)明arr里存的是節(jié)點(diǎn)指針排序只動(dòng)指針順序不動(dòng)鏈表結(jié)構(gòu)所以打印完不需要重建鏈表。cmp_by_score_desc里先把void *轉(zhuǎn)成Node **再解引用這是qsort比較函數(shù)處理指針數(shù)組的標(biāo)準(zhǔn)寫法。注意free(arr)只釋放指針數(shù)組節(jié)點(diǎn)本身仍歸鏈表管別在這里把節(jié)點(diǎn)也釋放了。第四個(gè)是給關(guān)鍵操作加日志。在插入、刪除、保存這些函數(shù)里用fprintf(stderr, ...)打一行簡(jiǎn)短記錄比如「insert id1001 ok」。程序出問(wèn)題時(shí)日志能幫你快速定位是哪一步開(kāi)始不對(duì)。這個(gè)習(xí)慣我從寫課設(shè)一直保留到現(xiàn)在排查線上問(wèn)題時(shí)省下的時(shí)間遠(yuǎn)超當(dāng)初加日志的幾分鐘。最后說(shuō)個(gè)我自己的教訓(xùn)當(dāng)年第一次寫這個(gè)課設(shè)我把所有代碼塞進(jìn)一個(gè)main.c功能全用全局變量傳結(jié)果改一個(gè)功能就崩另一個(gè)。后來(lái)重寫時(shí)強(qiáng)迫自己每個(gè)函數(shù)只做一件事參數(shù)該傳就傳雖然一開(kāi)始覺(jué)得麻煩但調(diào)試時(shí)間直接砍半。鏈表這東西指針指錯(cuò)一個(gè)地方現(xiàn)象可能出現(xiàn)在十萬(wàn)八千里外模塊化不是為了好看是為了讓你出問(wèn)題時(shí)能縮小排查范圍。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取