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