简介:这份数据结构课程设计资料包面向高校计算机相关专业学生及需要巩固C语言数据结构实践的学习者,围绕单链表、栈、队列、二叉树和图五种结构组织内容,通过多级菜单整合创建、插入删除、查找遍历等基本操作及典型应用,可用于课程设计选题参考、实验代码研读与功能扩展示例。压缩包共28个文件,约500KB,主要包含C/C++头文件与源文件、Visual Studio解决方案及工程配置,并附带编译生成的exe、pdb等运行调试产物,结构紧凑,便于直接打开工程查看模块划分与调用关系。已有1714人学习,说明其作为课设参考具有一定认可度。读者可从中梳理五种结构的接口设计、菜单组织方式,以及一元多项式运算、表达式求值、酒店客房分配、二叉排序树与Huffman编码、拓扑排序等应用思路,适合按需拆解复用并对照完善自己的课程设计。
1. 先别急着改代码:一份C语言数据结构课程设计资源到底该先跑哪一步
课程设计验收前半小时,最容易被翻车的不是算法写不出来,而是菜单一按就崩、链表释放报错、文件读回来全是乱码。这份“数据结构课程设计(C语言实现)”资源,走的是另一条路:把线性表、栈队列、二叉树、图、排序查找和文件读写拆成可单独编译的模块,配一个能跑测试输入的入口。它适合两类人:一类是赶课程设计底稿,想先有能演示的完整骨架;另一类是想对照边界条件,看看初始化、扩容、删除和释放到底该写在哪。拿到后先做冷编译和测试入口,不要先改菜单文案,这是我见过最省时间的顺序。
2. 拆包与编译:从Makefile入口到六个模块的职责边界
这套代码最值得先看的不是某个排序函数,而是编译入口。课程设计代码常见的问题是“每个功能一个main”,最后合不起来;这套资源把模块按数据结构拆分,用统一的ds_app入口串起来。先跑通编译,再按模块替换,比从头写菜单稳得多。
2.1 先看模块边界:谁负责存数据,谁负责打印
拿到源码后不要按文件名一个个点开,先按职责分组。线性表只负责容量、长度和元素移动;链表只负责节点连接和释放;栈和队列只暴露入、出、判空;树和图负责结构关系;排序和查找挂在容器上;文件读写只做序列化和反序列化;菜单层只做输入解析和调用。这样分的好处是,改一个结构不会把菜单层拖下水。
常见做法是用一个头文件声明接口,源文件里藏具体结构。例如顺序表的接口只暴露seqlist_init、seqlist_push、seqlist_erase、seqlist_destroy,调用方不需要知道capacity怎么扩。菜单层只拿返回值判断成功失败,不直接碰data指针。这个边界一旦立住,后面排查内存问题会轻松很多。
| 模块 | 主要职责 | 关键参数 | 常见误用 |
|---|---|---|---|
| 顺序表 | 连续存储、随机访问、扩容 | capacity、size、插入位置 | 扩容后直接覆盖原指针 |
| 单链表 | 节点插入删除、动态长度 | 头指针、前驱指针 | 删除头节点后继续用旧指针 |
| 栈/队列 | 受限操作、判空判满 | top、front、rear、容量 | 栈顶指针语义不统一 |
| 二叉树 | 递归建树、遍历、释放 | 根节点、空标记 | 递归深度过大、释放顺序错 |
| 图 | 邻接矩阵/邻接表、遍历 | 顶点数、边数、权值 | 无向边只加一次 |
| 排序查找 | 统一比较接口、哈希冲突 | 比较函数、装载因子 | 比较函数返回值溢出 |
这张表不用背,改代码时对照看就行。真正要动手的是编译入口:先确认make能出可执行文件,再确认make test能对上台架输出。没有测试入口的课程设计,后面每改一次都像开盲盒。
2.2 用Makefile统一编译与调试开关
我一般不让课程设计代码散着编译,至少留一个Makefile。下面这份是够用的最小版本,包含调试开关、测试入口和内存检查入口:
CC ?= gcc CFLAGS ?= -Wall -Wextra -Wpedantic -g -O0 -std=c11 TARGET := ds_app SRC := $(wildcard src/*.c) OBJ := $(SRC:.c=.o) INC := -Isrc .PHONY: all run test clean asan all: $(TARGET) $(TARGET): $(OBJ) $(CC) $(OBJ) -o $@ %.o: %.c $(CC) $(CFLAGS) $(INC) -c $< -o $@ run: all ./$(TARGET) test: all ./$(TARGET) < tests/menu_input.txt > tests/out.txt @diff -u tests/expected.txt tests/out.txt asan: CFLAGS += -fsanitize=address,undefined asan: clean all clean: rm -f $(OBJ) $(TARGET)逻辑说明:wildcard收集src下所有 C 文件,模式规则把每个.c编成.o,最后链接成ds_app。test目标把固定输入喂给程序,再和期望输出做diff,这样菜单改动不会悄悄破坏已有功能。asan目标在编译时加地址和未定义行为检查,适合跑链表释放、数组越界这类问题。
参数说明:-Wall -Wextra -Wpedantic是把警告开足,-g -O0保留调试信息且不优化,方便定位行号;-std=c11固定语言标准,避免不同编译器对变长数组、匿名结构体支持不一致。CFLAGS ?=允许命令行覆盖,例如make CFLAGS="-Wall -Wextra -Werror -g -O0 -std=c11"可以把警告直接当错误,验收前跑一次很值。asan在部分 Windows 编译器上支持不完整,遇到链接失败就退回普通编译加手动检查,不要在这上面耗太久。
注意:
make test里的diff和rm是类 Unix 环境常见命令。Windows 下如果命令不可用,常见做法是装一个带make和diff的编译环境,或者把测试目标改成直接运行程序、人工比对输出。不要为了跨平台把 Makefile 写成一大坨条件判断,课程设计阶段不值得。
2.3 测试数据与文件持久化怎么接
课程设计里文件读写最容易变成“演示时能存,验收时读不回来”。这套资源把持久化放在独立文件里,菜单只调用保存和加载接口。下面是一个顺序表保存函数的写法:
int seqlist_save(const SeqList *list, const char *path) { if (!list || !path) return -1; FILE *fp = fopen(path, "w"); if (!fp) return -2; fprintf(fp, "%zu\n", list->size); for (size_t i = 0; i < list->size; ++i) { fprintf(fp, "%d\n", list->data[i]); } if (fclose(fp) != 0) return -3; return 0; }逻辑说明:第一行写元素个数,后面每个元素一行,加载时先读size再循环读元素。返回值分开:-1表示参数为空,-2表示打开失败,-3表示关闭失败,菜单层可以据此提示“路径不对”还是“写盘失败”。参数里path用相对路径更稳,例如tests/data/seqlist.txt,不要写死绝对路径,换一台机器就找不到。
加载函数要检查三个地方:fscanf的返回值是否等于期望个数、读到的size是否超过最大容量、文件是否提前结束。常见做法是先读到临时数组,确认数量合法后再拷贝进容器,避免读到一半把原数据覆盖。保存时更稳的做法是先写path.tmp,fclose成功后再rename到目标路径;这样写到一半断电不会把旧数据毁掉。课程设计不要求这么细,但验收时老师拔U盘、切目录,这个习惯能当后悔药。
菜单输入不要用裸scanf("%d")接到底。常见翻车是输入字母后缓冲区残留,菜单进入死循环。把输入统一收口到一个read_int,用fgets读整行再strtol解析,后面第5章会给具体写法。文件路径、输入解析、编译入口这三件事先跑通,再去改数据结构的内部实现,顺序不要反。
3. 线性结构落地:顺序表扩容、链表释放与栈队列判空的四个参数
线性结构是课程设计里最容易被低估的部分。顺序表看着简单,扩容时直接realloc覆盖原指针就是内存泄漏;链表删除节点时先free再找前驱就是野指针;栈和队列的判空条件不统一,菜单演示时就会“明明有元素却说出栈失败”。这一章把最容易写错的参数拎出来。
3.1 顺序表的容量与扩容策略
顺序表的核心参数只有两个:size表示当前元素个数,capacity表示已经申请到的容量。插入前判断size == capacity,需要扩容。扩容不要每次加 1,常见做法是翻倍,均摊成本低,课程设计里也够用。关键是realloc的返回值必须先接临时指针:
int seqlist_reserve(SeqList *list, size_t need) { if (!list) return -1; if (need <= list->capacity) return 0; size_t new_cap = list->capacity ? list->capacity * 2 : 8; while (new_cap < need) new_cap *= 2; int *tmp = (int *)realloc(list->data, new_cap * sizeof(int)); if (!tmp) return -2; list->data = tmp; list->capacity = new_cap; return 0; }逻辑说明:need是“这次操作总共需要多大容量”,不是“还要加多少”。如果当前容量够就直接返回,避免无意义重分配。新容量从 8 起步,按 2 倍增长,直到覆盖need。tmp接住realloc的结果,失败时原list->data仍然有效,不会丢指针。
参数说明:new_cap * sizeof(int)是字节数,课程设计里元素类型固定为int时可以这么写;如果后面要存结构体,建议加一层sizeof(*list->data),改类型时不容易漏。realloc失败返回NULL,此时不能写list->data = realloc(...),否则原内存泄漏,后面释放会崩。插入位置合法范围是0 <= pos <= size,删除位置是0 <= pos < size,移动元素用memmove比手写循环稳,重叠区间也不会出错。
扩容后不要忘了把新申请的区域初始化。课程设计演示时打印整个capacity会看到随机值,不是程序错,但容易被误判。只打印size范围内的元素,或者初始化时用memset清零。更稳的做法是调试打印里同时输出size和capacity,看到size没变但capacity变了,就知道扩容生效了。
3.2 单链表的头插尾插与释放
单链表第一个坑是头指针的传递。删除头节点时如果只传Node *head,函数里改的是副本,外面看头指针没变。常见做法是传二级指针Node **head,或者返回新的头指针。释放整条链表同样要传二级指针,最后把头指针置空:
void list_destroy(Node **head) { if (!head) return; Node *cur = *head; while (cur) { Node *next = cur->next; free(cur); cur = next; } *head = NULL; }逻辑说明:先保存next再free(cur),顺序反了就是标准野指针。循环结束后把*head置NULL,防止外部继续访问已释放内存。参数head是头指针的地址,调用时写list_destroy(&list);,不要写list_destroy(list);。
头插和尾插的边界不一样。头插只要新节点分配成功,插到最前面,O(1);尾插要么维护tail指针,要么每次从头遍历到最后一个节点。课程设计里如果频繁尾插,维护tail更合适,但删除尾节点时要重新找前驱,参数会变多。我的建议是:演示用头插,需要保持输入顺序用尾插加tail,不要两种混着写还不注释。
删除节点时要先找到前驱。删除头节点单独处理,删除中间节点让prev->next = cur->next,然后free(cur)。如果删除后还要打印链表,确保打印函数遇到NULL就停,不要用固定次数循环。链表长度用size字段维护时,每次插入删除同步加减,别等打印时再遍历数一遍,否则调试时两个数对不上,排查很费时间。
3.3 栈和队列的判空条件与排序接口
栈和队列的难点不在代码量,在语义统一。栈可以用top表示“下一个空位”,那么空栈是top == 0,入栈写data[top++] = x,出栈写x = data[--top]。也可以用top表示“栈顶元素下标”,空栈是top == -1。两种都行,但全项目只能用一种。混用的典型现象是:压入第一个元素后判空仍然返回真,或者出栈读到了未初始化位置。
循环队列要留一个空位区分队空和队满:队空front == rear,队满(rear + 1) % capacity == front。这样实际能存capacity - 1个元素。如果不想浪费这个位置,就加一个count字段,判空判满都看count。课程设计里加count更直观,菜单打印队列长度也方便。参数上注意rear更新时先取模再赋值,别写出rear = (rear + 1) % capacity之后又用旧下标。
排序可以挂在顺序表上,用统一比较接口,后面换类型不用重写排序:
typedef int (*CmpFn)(const void *, const void *); static int cmp_int(const void *a, const void *b) { int x = *(const int *)a; int y = *(const int *)b; return (x > y) - (x < y); } void seqlist_sort(SeqList *list) { if (list && list->size > 1) { qsort(list->data, list->size, sizeof(int), cmp_int); } }逻辑说明:cmp_int返回负数、0、正数表示小于、等于、大于,写成(x > y) - (x < y)可以避免x - y在极端值下溢出。qsort的参数依次是首地址、元素个数、单元素字节数、比较函数。排序前判断size > 1,空表和一个元素直接跳过。
参数说明:如果后面要按结构体某个字段排序,比较函数里强制转换到对应结构体指针,不要继续用int *。计时可以用clock()包住排序调用,输出(double)(end - start) / CLOCKS_PER_SEC。课程设计演示时不要只报“快排比冒泡快”,把数据规模、随机种子和计时单位一起打印,验收时更经得起问。冒泡、插入、快排可以共用同一组测试数据,排序后用一个is_sorted函数检查非降序,比肉眼盯输出可靠。
4. 树与图:递归建树、邻接表选型和遍历输出的三个验证点
树和图是课程设计里“看起来高级、写起来容易漏释放”的部分。二叉树递归建树如果输入格式没约定好,空指针和叶子节点会混;图如果用邻接矩阵存稀疏图,空间浪费不说,遍历时还会扫一堆不存在的边。这一章把建树、选型和哈希查找放在一起看,因为它们都涉及“结构关系怎么存”和“怎么验证输出”。
4.1 二叉树的建树与三种遍历
二叉树节点定义要先把左右指针置空,不然后面释放时容易访问随机地址。递归建树常见输入格式是“值 左子树 右子树”,用-1表示空。先序建树读一个值,如果是-1返回NULL,否则申请节点,再递归读左右子树。参数里要传文件或输入流,不要每个节点都从stdin读,测试时没法喂固定输入。
typedef struct TreeNode { int value; struct TreeNode *left; struct TreeNode *right; } TreeNode; void tree_preorder(const TreeNode *root) { if (!root) return; printf("%d ", root->value); tree_preorder(root->left); tree_preorder(root->right); }逻辑说明:先序遍历先访问根,再左子树,再右子树;中序和后序只是printf的位置不同。递归终止条件是空指针,不要用value == -1判断,因为-1可能只是普通值。参数root用const修饰,表示遍历不修改节点,调用方传TreeNode *也能兼容。
验证点一:建树后先打印先序和中序,手工推一遍是否和输入一致。验证点二:释放树必须用后序,先释放左右子树再free(root),先释放根再找左右子树就是野指针。验证点三:递归深度。课程设计数据量一般不大,但如果输入退化成一条链,递归深度可能上千,栈空间吃紧。常见做法是限制输入规模,或者在注释里写明“递归深度不超过 N”。非递归遍历可以后面再加,不要一上来就写迭代,先把递归版调通。
4.2 图的邻接矩阵与邻接表怎么选
图的存储选型看两个参数:顶点数n和边数m。邻接矩阵开n * n的二维数组,判断两点是否相邻是O(1),适合稠密图;邻接表只存实际存在的边,空间O(n + m),适合稀疏图。课程设计里如果顶点数不超过几十个,邻接矩阵写起来快;如果边数远小于n^2,邻接表更稳。
| 对比项 | 邻接矩阵 | 邻接表 |
|---|---|---|
| 空间 | O(n^2) | O(n + m) |
| 判断边是否存在 | O(1) | 平均O(degree) |
| 遍历某点所有邻边 | 扫整行O(n) | 只扫实际边 |
| 删除边 | 直接置 0 | 要改链表指针 |
| 适用场景 | 稠密图、顶点少 | 稀疏图、边少 |
邻接表插入边时要先检查顶点下标范围,再申请边节点。无向图要调用两次,分别加u->v和v->u;只加一次的话,遍历时从另一个方向走不到。下面是有向边插入的骨架:
typedef struct EdgeNode { int to; int weight; struct EdgeNode *next; } EdgeNode; typedef struct { EdgeNode **head; int n; } Graph; int graph_add_edge(Graph *g, int u, int v, int w) { if (!g || u < 0 || v < 0 || u >= g->n || v >= g->n) return -1; EdgeNode *e = (EdgeNode *)malloc(sizeof(*e)); if (!e) return -2; e->to = v; e->weight = w; e->next = g->head[u]; g->head[u] = e; return 0; }逻辑说明:新边节点插到head[u]前面,时间复杂度O(1)。参数u是起点,v是终点,w是权值;无向图要在调用处再写一次graph_add_edge(g, v, u, w)。释放图时遍历每个顶点的边链表,先保存next再free,最后释放head数组。
4.3 哈希查找与冲突处理
哈希表放在课程设计里通常用于“快速查找”。核心参数是表长和装载因子。表长取质数能减少聚集,常见做法是HASH_CAP取 97、193 这类质数。冲突处理用线性探测最直观,但删除时不能直接置空,否则后面的探测链断了。下面用used标记状态:0 空、1 占用、2 已删除。
#define HASH_CAP 97 typedef struct { int key; int used; /* 0 空, 1 占用, 2 删除 */ } HashSlot; static int hash_index(int key) { return ((key % HASH_CAP) + HASH_CAP) % HASH_CAP; } int hash_insert(HashSlot table[], int key) { int start = hash_index(key); for (int step = 0; step < HASH_CAP; ++step) { int idx = (start + step) % HASH_CAP; if (table[idx].used != 1) { table[idx].key = key; table[idx].used = 1; return 0; } if (table[idx].key == key) return 1; } return -1; }逻辑说明:线性探测从哈希位置开始逐个往后找,遇到空位或删除标记就插入,遇到相同键返回已存在。参数table是定长数组,调用前要把used全部清零。((key % HASH_CAP) + HASH_CAP) % HASH_CAP是为了处理负数键,C语言里负数取模仍为负,直接当下标会越界。
查找函数和插入类似,遇到used == 0就停,说明不存在;遇到used == 2继续往后找。删除函数把used置 2,不要置 0。装载因子超过 0.7 时探测链会变长,课程设计里可以固定表长,插入前统计占用数,超过阈值就提示“表快满了”。如果要做扩容,必须重新哈希所有占用槽,不能直接拷贝数组。验证时至少跑三组:插入重复键、删除后查找、插入负数键,输出对得上才算过。
5. 避坑与排查:内存泄漏、输入缓冲和边界条件的五条血泪记录
这一章不按功能讲,按翻车现场讲。课程设计代码量不大,但内存、输入和边界这三类问题最容易在验收时集中爆发。下面五条都是我在类似资源里反复见到的现象,每条按“现象→原因→解决”拆开。
5.1 内存类问题:realloc覆盖指针与free后继续用
现象:程序跑一段时间后崩溃,或者保存文件时提示“堆损坏”;用内存检查工具能看到“invalid free”或“double free”。
原因:最常见的是list->data = realloc(list->data, new_cap * sizeof(int));直接覆盖。realloc失败返回NULL,原指针丢了,后面释放时崩。另一个原因是链表删除节点时先free(cur),然后还在用cur->next找下一个,或者释放完整条链表后外部又调用了一次list_destroy,第二次进入循环读到已释放内存。
解决:所有realloc先接临时指针,判断非空再赋值;释放链表时先保存next,最后把头指针置NULL;结构体销毁函数里把内部指针也置空,调用方重复调用时先判断*head == NULL直接返回。验收前用地址检查跑一遍固定输入:
gcc -Wall -Wextra -g -O0 -std=c11 -fsanitize=address,undefined src/*.c -o ds_app_asan ./ds_app_asan < tests/menu_input.txt参数说明:-fsanitize=address检查越界和释放后使用,-fsanitize=undefined检查未定义行为。跑完没有报错再提交。如果编译器不支持,至少把-Wall -Wextra的警告逐条看完,警告里出现“may be used uninitialized”不要放过。
5.2 输入类问题:scanf残留导致菜单死循环
现象:菜单输入一个字母后,程序疯狂打印菜单,停不下来;或者选择“1”之后下一个输入直接被跳过。
原因:scanf("%d", &choice)遇到非数字字符时不消费该字符,缓冲区里一直留着,下一次循环又读到同一个字符,形成死循环。混用scanf和fgets时,前者留下的换行符会被后者当空行读走。
解决:菜单输入统一用fgets读整行,再用strtol解析,失败就清掉这一行继续循环。下面这个函数可以直接抄:
int read_int(const char *prompt, int *out) { char buf[64]; if (prompt) printf("%s", prompt); if (!fgets(buf, sizeof(buf), stdin)) return -1; char *end = NULL; long v = strtol(buf, &end, 10); if (end == buf || (*end != '\n' && *end != '\0')) return -2; *out = (int)v; return 0; }逻辑说明:fgets最多读sizeof(buf)-1个字符,保证不越界;strtol解析后end指向未消费部分,如果等于开头说明没读到数字,如果既不是换行也不是结束符说明后面还有杂字符,都按失败处理。参数out用指针带回结果,返回0成功、-1输入结束、-2格式错误。菜单层拿到非零就continue,不要继续用未初始化的choice。
5.3 边界类问题:删除头节点与空结构判空
现象:链表删除第一个节点后打印乱码;空栈执行出栈后程序崩溃;队列明明有元素却提示队满。
原因:删除头节点没有单独处理,prev初值为NULL,循环里访问prev->next直接崩。栈的top语义混用,空栈判断写错。循环队列没有留空位也没有count,front == rear同时被当成空和满。
解决:删除链表节点先判断head == NULL,再判断待删节点是不是头节点,是就*head = (*head)->next并释放旧头。栈和队列在头文件注释里写死语义:top是下一个空位,空栈top == 0;循环队列用count计数,空count == 0,满count == capacity。所有出栈、出队操作先判空,返回错误码而不是直接读数组。
5.4 文件类问题:路径、换行与读回数量不符
现象:保存成功,加载时读到 0 个元素;或者元素数量对,最后一个值是乱的。
原因:保存和加载用的相对路径基准不同,程序在src目录下跑,文件写到了别处;文本模式换行在不同平台不一致,fscanf读%d后留下换行,后面读字符串会读到空行;加载时没有检查fscanf返回值,文件提前结束也继续循环。
解决:路径统一从程序运行目录写起,测试时先打印fopen的完整路径;加载时先读数量,再循环读并判断fscanf(fp, "%d", &x) == 1,失败立即停;保存用"w",加载用"r",不要混用二进制模式读写文本。更稳的做法是保存到临时文件再重命名,避免写一半损坏旧数据。
5.5 编译类问题:警告当噪音与跨平台差异
现象:本地能编译,换一台机器报“undefined reference”;或者make成功但运行时段错误。
原因:头文件声明和源文件定义不一致,函数参数类型对不上;Makefile里漏了某个源文件,链接阶段才报错;不同编译器对gets、变长数组、//注释的支持不同,用了非标准写法。
解决:编译时把-Wall -Wextra -Wpedantic打开,警告逐条看完;函数声明统一放头文件,源文件#include对应头文件,让编译器帮你对参数;不要用gets,用fgets;数组长度用宏或常量,不要用运行时变量定义栈数组。换环境前先make clean再make,避免旧目标文件干扰。
6. 进阶验收:把课程设计改成可演示的测试工具与三个检查习惯
课程设计最后拼的不是代码量,是“能不能稳定演示”。我一般会在验收前把程序当成一个黑匣子,用固定输入跑三遍:正常流程、边界输入、错误输入。正常流程走一遍增删改查和保存加载;边界输入测空结构、满容量、删除第一个和最后一个;错误输入测字母菜单、不存在的位置、读不存在的文件。三遍都过,再改菜单文案。
6.1 用测试脚本固定验收路径
把常用命令写进一个脚本,避免验收时手忙脚乱:
#!/usr/bin/env bash set -e make clean make CFLAGS="-Wall -Wextra -Werror -g -O0 -std=c11" make test ./ds_app < tests/menu_input.txt > tests/out_manual.txt diff -u tests/expected.txt tests/out_manual.txt逻辑说明:set -e让任何一步失败就停,避免带着编译错误继续跑;-Werror把警告当错误,冷编译能过说明声明和定义基本对齐;make test跑固定台架,最后再人工跑一遍输入做对照。参数上tests/menu_input.txt要覆盖菜单每个分支,expected.txt不要手写太多,先用一次正确输出做基准,后面改代码再比对差异。
6.2 三个检查习惯
| 检查项 | 命令/动作 | 通过标准 |
|---|---|---|
| 冷编译 | make clean && make | 无警告、无链接错误 |
| 内存 | 地址检查跑固定输入 | 无越界、无重复释放 |
| 边界 | 空结构、满容量、非法输入 | 返回错误码,不崩溃 |
从那以后我每次交课程设计前都强制走一遍冷编译、固定输入和内存检查,不再相信“我这边能跑”。这套“数据结构课程设计(C语言实现)”资源最适合当底稿:先跑通make test,再按模块替换成自己的实现,最后用测试脚本锁住行为。需要整包时按项目标题检索即可,拿到后先别改菜单,先把编译和测试入口跑通。希望帮到你。
本文还有配套的精品资源,点击获取