零基础掌握C语言数据结构与算法实战
简介:数据结构是计算机科学的核心内容,C语言作为底层开发的首选语言,是学习数据结构的理想工具。本教程“零基础学数据结构C语言代码”专为初学者打造,通过理论与代码结合的方式,系统讲解数组、链表、栈、队列、树、图等常见数据结构的C语言实现。配套源码涵盖添加、删除、查找等基础操作,帮助学习者通过动手实践掌握核心概念,为深入算法和系统编程打下坚实基础。
1. 数据结构概述与C语言实现
数据结构是计算机科学的核心基础之一,它决定了数据如何被存储、访问和操作。在实际开发中,选择合适的数据结构能够显著提升程序的效率与可维护性。本章将从基本概念入手,系统介绍数据结构的分类(如线性结构、树形结构、图形结构等),并阐述其在算法设计与系统开发中的关键作用。
为了将理论落地,我们将使用C语言作为实现工具。C语言以其接近底层的特性,为理解数据结构提供了理想的编程环境,尤其在内存管理和指针操作方面具有显著优势。例如,我们可以使用结构体( struct )来定义复杂的数据类型,使用指针来实现动态内存分配与链接结构,为后续链表、树、图等结构的构建打下坚实基础。
下面,我们将重点讲解C语言中与数据结构密切相关的核心语法特性,包括结构体定义、指针操作以及内存分配函数(如 malloc 和 free )的使用方法。这些内容将为读者建立数据结构实现的编程基础,帮助理解其底层原理与实际应用。
2. 数组结构与C语言操作
2.1 数组的基本概念与分类
数组是一种最基础、最常用的数据结构,它将相同类型的数据元素按顺序存储在一块连续的内存空间中。数组在程序设计中广泛用于数据的组织与访问,尤其在系统级编程语言如C语言中,数组的使用更加灵活且性能优异。
2.1.1 一维数组与多维数组定义
在C语言中,一维数组是最简单的数组结构。其声明方式如下:
数据类型 数组名[数组长度];
例如:
int arr[5]; // 声明一个长度为5的整型数组
该数组在内存中连续存储5个整型变量,其索引从 0 到 4 。可以通过索引访问数组元素:
arr[0] = 10; // 给第一个元素赋值
printf("%d\n", arr[0]); // 输出10
多维数组可以理解为数组的数组。二维数组是常见的多维数组形式,其声明方式如下:
数据类型 数组名[第一维长度][第二维长度];
例如:
int matrix[3][3]; // 声明一个3x3的二维数组
访问方式如下:
matrix[0][0] = 1;
printf("%d\n", matrix[0][0]); // 输出1
在内存中,C语言中的二维数组是以行优先(row-major)的方式存储的,即先连续存储第一行的所有元素,接着是第二行,以此类推。
数组的逻辑结构与物理结构对比
| 特性 | 一维数组 | 二维数组 |
|---|---|---|
| 逻辑结构 | 线性结构 | 二维线性结构 |
| 存储方式 | 连续内存 | 连续内存 |
| 访问方式 | 单索引 | 双索引 |
| 内存布局 | 行优先 | 行优先 |
| 应用场景 | 列表、集合等 | 矩阵、图像像素处理等 |
一维数组的逻辑结构图示(mermaid流程图)
graph LR
A[索引0] --> B[索引1]
B --> C[索引2]
C --> D[索引3]
D --> E[索引4]
2.1.2 静态数组与动态数组的区别
在C语言中,数组可以分为静态数组和动态数组两种类型。
静态数组 是在编译时就确定大小的数组,声明方式如:
int arr[10];
这种数组的大小固定,无法在运行时调整。其优点是访问速度快,内存分配简单;缺点是不够灵活,可能造成内存浪费或空间不足。
动态数组 是通过内存动态分配函数(如 malloc 、 calloc )在运行时创建的数组。例如:
int *arr = (int *)malloc(10 * sizeof(int)); // 动态分配10个整型空间
使用完毕后,需手动释放内存:
free(arr);
动态数组的优势在于空间可变,适用于不确定数据量的场景。但其管理复杂,需手动处理内存分配与释放,否则容易造成内存泄漏。
静态数组与动态数组对比表
| 对比维度 | 静态数组 | 动态数组 |
|---|---|---|
| 内存分配时机 | 编译时确定 | 运行时动态分配 |
| 内存释放 | 自动释放(函数结束) | 需手动调用 free 释放 |
| 空间大小 | 固定 | 可变 |
| 使用场景 | 数据量已知 | 数据量不确定或较大 |
| 性能 | 快 | 稍慢(涉及内存分配与释放) |
| 内存安全 | 安全 | 需注意内存泄漏与越界访问 |
动态数组的初始化流程图(mermaid)
graph TD
A[开始] --> B[申请内存空间]
B --> C{申请成功?}
C -- 是 --> D[使用数组]
C -- 否 --> E[报错并退出]
D --> F[操作数组]
F --> G[释放内存]
G --> H[结束]
2.2 数组在C语言中的实现
2.2.1 数组的初始化与访问
数组在C语言中可以采用多种方式进行初始化。最常见的是在声明时直接赋值:
int arr[5] = {1, 2, 3, 4, 5};
也可以在声明时不指定长度,由编译器自动推断:
int arr[] = {1, 2, 3, 4, 5}; // 自动推断长度为5
对于多维数组,初始化方式如下:
int matrix[2][3] = {
{1, 2, 3},
{4, 5, 6}
};
数组的访问通过索引完成,例如:
printf("%d\n", arr[2]); // 输出3
代码示例:数组的初始化与访问
#include <stdio.h>
int main() {
int arr[5] = {10, 20, 30, 40, 50};
for(int i = 0; i < 5; i++) {
printf("arr[%d] = %d\n", i, arr[i]);
}
return 0;
}
代码逻辑分析:
-
int arr[5] = {10, 20, 30, 40, 50};:声明一个长度为5的整型数组,并初始化。 -
for(int i = 0; i < 5; i++):遍历数组索引0到4。 -
printf("arr[%d] = %d\n", i, arr[i]);:输出数组元素。
初始化方式对比表
| 初始化方式 | 语法示例 | 说明 |
|---|---|---|
| 显式初始化 | int arr[5] = {1,2,3,4,5}; | 明确指定每个元素的值 |
| 部分初始化 | int arr[5] = {1,2}; | 未初始化部分自动赋0 |
| 默认初始化 | int arr[5]; | 元素值为未定义(随机值) |
| 动态初始化 | int *arr = malloc(5 * sizeof(int)); | 通过 malloc 动态分配 |
2.2.2 数组的越界问题与规避策略
数组越界是指访问数组时使用了超出其有效索引范围的索引值。例如:
int arr[5];
arr[5] = 10; // 越界访问(索引范围是0~4)
C语言不会对数组越界进行自动检查,因此可能导致程序崩溃或数据被破坏。
常见规避策略:
- 手动边界检查
在访问数组元素前,判断索引是否合法:
c if (index >= 0 && index < length) { // 安全访问 }
-
使用标准库函数
使用memcpy、memmove等函数进行安全的数组复制与操作。 -
封装数组访问函数
将数组操作封装为函数,增加边界判断逻辑:
c void safe_set(int *arr, int length, int index, int value) { if (index >= 0 && index < length) { arr[index] = value; } else { printf("Index out of bounds!\n"); } }
越界访问流程图(mermaid)
graph TD
A[开始] --> B[访问数组索引]
B --> C{索引是否合法?}
C -- 是 --> D[执行访问操作]
C -- 否 --> E[输出越界警告]
D --> F[结束]
E --> F
数组越界示例与分析
#include <stdio.h>
int main() {
int arr[5] = {1, 2, 3, 4, 5};
for(int i = 0; i <= 5; i++) {
printf("arr[%d] = %d\n", i, arr[i]);
}
return 0;
}
问题分析:
- i <= 5 :循环条件导致i最大为5,而数组索引最大为4,造成越界。
- 输出结果中 arr[5] 的值为随机内存数据,可能导致程序崩溃或逻辑错误。
修复建议:
for(int i = 0; i < 5; i++) // 修改为i < 5
2.3 数组的操作与应用
2.3.1 插入、删除与查找操作的实现
数组的插入、删除和查找是最基本的操作,但由于数组的连续存储特性,这些操作通常会带来性能上的开销。
插入操作
插入操作是在数组指定位置插入一个新元素。由于数组的连续性,插入会导致后续元素整体后移。
void insert(int *arr, int *length, int capacity, int index, int value) {
if (*length >= capacity) {
printf("Array is full!\n");
return;
}
if (index < 0 || index > *length) {
printf("Invalid index!\n");
return;
}
for (int i = *length; i > index; i--) {
arr[i] = arr[i - 1];
}
arr[index] = value;
(*length)++;
}
代码逻辑分析:
-
for (int i = *length; i > index; i--):从后往前将元素后移。 -
arr[index] = value:在指定位置插入新值。 -
(*length)++:更新数组当前长度。
删除操作
删除操作是将指定位置的元素移除,并将后续元素前移。
void delete(int *arr, int *length, int index) {
if (index < 0 || index >= *length) {
printf("Invalid index!\n");
return;
}
for (int i = index; i < *length - 1; i++) {
arr[i] = arr[i + 1];
}
(*length)--;
}
代码逻辑分析:
-
for (int i = index; i < *length - 1; i++):从前向后将元素前移。 -
(*length)--:更新数组当前长度。
查找操作
查找操作是遍历数组寻找目标值,返回其索引。
int search(int *arr, int length, int target) {
for (int i = 0; i < length; i++) {
if (arr[i] == target) {
return i;
}
}
return -1;
}
代码逻辑分析:
-
for (int i = 0; i < length; i++):遍历数组。 -
if (arr[i] == target):判断是否匹配目标值。 -
return i:返回索引位置。 -
return -1:未找到目标值。
数组操作对比表
| 操作类型 | 时间复杂度 | 描述 |
|---|---|---|
| 插入 | O(n) | 需要移动元素,效率较低 |
| 删除 | O(n) | 需要移动元素,效率较低 |
| 查找 | O(n) | 顺序查找,效率一般 |
2.3.2 数组排序与查找算法的C语言实现
冒泡排序
冒泡排序是一种基础排序算法,其基本思想是通过相邻元素的比较与交换,将最大(或最小)元素“冒泡”至数组末尾。
void bubble_sort(int *arr, int n) {
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
代码逻辑分析:
- 外层循环控制轮数:
n - 1轮。 - 内层循环控制每轮比较次数:
n - i - 1。 - 如果前一个元素大于后一个元素,则交换。
二分查找
二分查找适用于已排序数组,其效率远高于顺序查找。
int binary_search(int *arr, int left, int right, int target) {
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] == target) {
return mid;
} else if (arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
代码逻辑分析:
-
mid = left + (right - left) / 2:计算中间索引,避免整数溢出。 -
if (arr[mid] == target):找到目标值,返回索引。 -
else if (arr[mid] < target):目标值在右半部分。 -
else:目标值在左半部分。
排序与查找算法对比表
| 算法类型 | 时间复杂度(平均) | 是否稳定排序 | 适用场景 |
|---|---|---|---|
| 冒泡排序 | O(n²) | 是 | 小规模数据、教学演示 |
| 二分查找 | O(log n) | 是 | 已排序数组的高效查找 |
(未完待续,第二章后续章节将深入讲解数组的实际项目应用与完整系统实现)
3. 链表结构设计与C语言实现
链表是一种重要的线性数据结构,相较于数组,它在内存中的存储方式更加灵活,适用于动态数据的频繁插入与删除操作。本章将系统讲解链表的基本原理、结构类型、C语言实现机制及其操作方式,并通过一个学生信息管理系统案例,演示链表在实际项目中的应用。
3.1 链表的基本原理与结构类型
链表由若干个节点组成,每个节点包含两个部分:数据域(data)和指针域(next)。指针域指向下一个节点,从而形成链式结构。与数组不同,链表在内存中不是连续存储的,而是通过指针链接各节点,因此在插入和删除操作时效率更高。
3.1.1 单链表、双链表与循环链表的概念
链表根据节点之间的连接方式可以分为以下几种类型:
| 类型 | 特点描述 |
|---|---|
| 单链表 | 每个节点只含有一个指向下一个节点的指针,只能从头到尾单向遍历。 |
| 双链表 | 每个节点含有两个指针,分别指向前后两个节点,可双向遍历。 |
| 循环链表 | 尾节点的指针指向头节点,形成一个环形结构,适用于周期性任务处理。 |
通过以下 mermaid 流程图,可以更直观地理解这三种链表的结构差异:
graph TD
subgraph 单链表
A1[节点1] --> A2[节点2] --> A3[节点3] --> A4[节点4]
end
subgraph 双链表
B1[节点1] <--> B2[节点2] <--> B3[节点3] <--> B4[节点4]
end
subgraph 循环链表
C1[节点1] --> C2[节点2] --> C3[节点3] --> C4[节点4] --> C1
end
3.1.2 链表与数组的对比分析
| 特性 | 数组 | 链表 |
|---|---|---|
| 存储方式 | 连续内存空间 | 非连续,通过指针连接 |
| 访问效率 | O(1),支持随机访问 | O(n),需逐个遍历 |
| 插入/删除效率 | O(n),需要移动元素 | O(1),只需修改指针 |
| 内存管理 | 固定大小,不易扩展 | 动态分配,灵活高效 |
| 实现复杂度 | 简单 | 相对复杂 |
从上表可以看出,链表的优势在于插入和删除操作效率高,而数组的优势在于随机访问。选择链表还是数组,取决于具体应用场景对操作类型的需求。
3.2 链表的C语言实现机制
链表在C语言中主要通过结构体和指针实现。结构体用于定义节点的数据结构,指针用于连接节点,而动态内存分配(如 malloc 和 free )则用于实现链表的灵活性。
3.2.1 结构体与指针的结合使用
链表节点通常定义如下结构体:
typedef struct Node {
int data; // 数据域
struct Node *next; // 指针域,指向下一个节点
} Node;
该结构体中, data 用于存储节点的数据, next 是指向下一个节点的指针。通过 Node *head 可以定义链表的头指针。
示例代码:创建节点
Node* createNode(int value) {
Node *newNode = (Node*)malloc(sizeof(Node)); // 分配内存
if (newNode == NULL) {
printf("Memory allocation failed.\n");
exit(1);
}
newNode->data = value; // 设置数据
newNode->next = NULL; // 初始指向空
return newNode;
}
代码逻辑分析:
- 使用
malloc动态分配一个Node结构体大小的内存空间。 - 判断是否分配成功,若失败则输出错误信息并退出程序。
- 初始化节点的数据域
data。 - 设置指针域
next为NULL,表示该节点为链表尾部。
3.2.2 内存动态分配(malloc与free)
在链表操作中,动态内存管理至关重要。C语言中常用的内存管理函数包括:
-
malloc:分配指定大小的内存块。 -
calloc:分配并初始化内存块。 -
realloc:调整已分配内存块的大小。 -
free:释放已分配的内存。
注意事项:
- 每次调用
malloc后必须检查返回值是否为NULL,防止内存分配失败。 - 使用完链表后应调用
free释放所有节点占用的内存,避免内存泄漏。
示例代码:释放链表内存
void freeList(Node *head) {
Node *current = head;
Node *nextNode;
while (current != NULL) {
nextNode = current->next; // 保存下一个节点
free(current); // 释放当前节点
current = nextNode; // 移动到下一个节点
}
}
代码逻辑分析:
- 定义两个指针变量
current和nextNode,用于遍历和释放节点。 - 在
while循环中,依次保存下一个节点地址,释放当前节点,然后移动指针继续操作。 - 最终释放整个链表所占用的内存空间。
3.3 链表的基本操作实现
链表的基本操作包括创建、遍历、插入、删除、反转和合并等。下面将逐一讲解这些操作的实现方法。
3.3.1 创建、遍历、插入与删除节点
创建链表
Node* createList(int values[], int size) {
Node *head = NULL;
Node *tail = NULL;
for (int i = 0; i < size; i++) {
Node *newNode = createNode(values[i]);
if (head == NULL) {
head = newNode;
tail = newNode;
} else {
tail->next = newNode;
tail = newNode;
}
}
return head;
}
逻辑分析:
- 通过
createNode函数创建新节点。 - 若链表为空,则将新节点设为头节点和尾节点。
- 否则,将新节点连接到当前尾节点之后,并更新尾指针。
遍历链表
void printList(Node *head) {
Node *current = head;
while (current != NULL) {
printf("%d -> ", current->data);
current = current->next;
}
printf("NULL\n");
}
逻辑分析:
- 使用指针
current从头节点开始遍历。 - 每次输出当前节点的数据,并移动指针到下一个节点。
- 遍历结束时输出
NULL表示链表结束。
插入节点(在指定位置后)
void insertAfter(Node *prevNode, int value) {
if (prevNode == NULL) {
printf("Previous node cannot be NULL.\n");
return;
}
Node *newNode = createNode(value);
newNode->next = prevNode->next;
prevNode->next = newNode;
}
参数说明:
-
prevNode:插入位置的前一个节点。 -
value:要插入的新节点的数据。
逻辑分析:
- 创建新节点。
- 新节点的
next指向原前节点的下一个节点。 - 前节点的
next指向新节点,完成插入操作。
删除节点(按值删除)
void deleteNode(Node **headRef, int key) {
Node *temp = *headRef;
Node *prev = NULL;
if (temp != NULL && temp->data == key) {
*headRef = temp->next; // 删除头节点
free(temp);
return;
}
while (temp != NULL && temp->data != key) {
prev = temp;
temp = temp->next;
}
if (temp == NULL) return; // 未找到
prev->next = temp->next;
free(temp);
}
逻辑分析:
- 若删除的是头节点,直接更新头指针。
- 否则,遍历链表寻找目标节点。
- 找到后修改前节点的
next指针,释放目标节点内存。
3.3.2 链表反转与合并操作
反转链表
Node* reverseList(Node *head) {
Node *prev = NULL;
Node *current = head;
Node *next = NULL;
while (current != NULL) {
next = current->next; // 保存下一个节点
current->next = prev; // 修改当前节点的指针方向
prev = current; // 移动 prev 指针
current = next; // 移动 current 指针
}
return prev;
}
逻辑分析:
- 使用三个指针:
prev、current、next。 - 逐个节点修改指针方向,最终使链表逆序。
合并两个有序链表
Node* mergeLists(Node *a, Node *b) {
Node dummy;
Node *tail = &dummy;
while (a && b) {
if (a->data <= b->data) {
tail->next = a;
a = a->next;
} else {
tail->next = b;
b = b->next;
}
tail = tail->next;
}
tail->next = (a) ? a : b;
return dummy.next;
}
逻辑分析:
- 使用一个虚拟头节点
dummy简化边界处理。 - 比较两个链表当前节点的值,选择较小的节点连接到合并链表中。
- 当一个链表遍历完后,直接连接另一个链表的剩余部分。
3.4 实战案例:基于链表的学生信息管理系统
在实际开发中,链表常用于实现动态数据管理系统。下面通过一个学生信息管理系统的案例,展示链表在项目中的具体应用。
3.4.1 系统需求分析与模块划分
功能需求:
- 添加学生信息(学号、姓名、成绩)
- 删除指定学生信息
- 修改学生信息
- 查询学生信息
- 显示所有学生信息
模块划分:
- 数据结构定义模块
- 链表操作模块(增删改查)
- 用户交互模块(菜单与输入处理)
3.4.2 数据结构设计与功能编码实现
学生信息结构体定义
typedef struct Student {
int id;
char name[50];
float score;
} Student;
typedef struct StudentNode {
Student student;
struct StudentNode *next;
} StudentNode;
添加学生信息
void addStudent(StudentNode **head, Student s) {
StudentNode *newNode = (StudentNode*)malloc(sizeof(StudentNode));
if (newNode == NULL) {
printf("Memory allocation failed.\n");
exit(1);
}
newNode->student = s;
newNode->next = *head;
*head = newNode;
}
逻辑分析:
- 动态分配节点内存。
- 将学生信息复制到新节点。
- 插入链表头部。
显示所有学生信息
void printStudents(StudentNode *head) {
StudentNode *current = head;
while (current != NULL) {
printf("ID: %d, Name: %s, Score: %.2f\n",
current->student.id, current->student.name, current->student.score);
current = current->next;
}
}
该函数通过遍历链表输出每个学生的信息。
删除学生信息(按学号)
void deleteStudent(StudentNode **head, int id) {
StudentNode *temp = *head;
StudentNode *prev = NULL;
if (temp != NULL && temp->student.id == id) {
*head = temp->next;
free(temp);
return;
}
while (temp != NULL && temp->student.id != id) {
prev = temp;
temp = temp->next;
}
if (temp == NULL) return;
prev->next = temp->next;
free(temp);
}
该函数通过遍历查找目标学号的学生节点并删除。
主菜单交互逻辑
int main() {
StudentNode *head = NULL;
int choice;
while (1) {
printf("\nStudent Management System\n");
printf("1. Add Student\n2. Delete Student\n3. Display Students\n4. Exit\n");
printf("Enter your choice: ");
scanf("%d", &choice);
if (choice == 1) {
Student s;
printf("Enter ID: ");
scanf("%d", &s.id);
printf("Enter Name: ");
scanf("%s", s.name);
printf("Enter Score: ");
scanf("%f", &s.score);
addStudent(&head, s);
} else if (choice == 2) {
int id;
printf("Enter ID to delete: ");
scanf("%d", &id);
deleteStudent(&head, id);
} else if (choice == 3) {
printStudents(head);
} else if (choice == 4) {
break;
}
}
// 释放链表内存
StudentNode *current = head;
while (current != NULL) {
StudentNode *next = current->next;
free(current);
current = next;
}
return 0;
}
该程序通过菜单交互方式实现学生信息的增删查操作,并在退出前释放链表内存,防止内存泄漏。
本章系统讲解了链表的基本原理、C语言实现机制、核心操作及其在学生信息管理系统中的实际应用。下一章将进入栈与队列的学习,进一步拓展数据结构的知识体系。
4. 栈与队列的C语言实现
栈和队列是两种非常基础但又极其重要的线性数据结构。它们分别遵循“后进先出”(LIFO)和“先进先出”(FIFO)的操作原则,广泛应用于操作系统、编译器、算法设计、任务调度等多个领域。本章将从理论基础出发,逐步深入到C语言的实现,并通过两个实战项目加深对这两种结构的理解与应用。
4.1 栈结构的理论基础与应用场景
4.1.1 栈的LIFO特性与基本操作
栈(Stack)是一种特殊的线性表,其插入和删除操作都限定在表的一端进行,这一端被称为栈顶(Top),另一端称为栈底(Bottom)。栈的基本操作包括:
- push :将元素压入栈顶;
- pop :将栈顶元素弹出;
- peek/top :查看栈顶元素但不弹出;
- isEmpty :判断栈是否为空;
- isFull :判断栈是否已满(仅适用于静态栈)。
这些操作必须遵循“后进先出”(Last In First Out, LIFO)原则。
栈的实现方式
栈可以通过数组(静态栈)或链表(动态栈)实现。数组实现的栈结构简单,访问速度快,但容量固定;链表实现的栈可以动态扩展,但操作复杂度略高。
栈的抽象数据类型(ADT)定义
| 操作名 | 描述 |
|---|---|
push(x) | 将元素x压入栈顶 |
pop() | 弹出栈顶元素并返回 |
top() | 返回栈顶元素但不弹出 |
isEmpty() | 判断栈是否为空 |
isFull() | 判断栈是否已满 |
示例:用数组实现栈的结构定义(C语言)
#define MAX_SIZE 100
typedef struct {
int data[MAX_SIZE];
int top;
} Stack;
初始化栈:
void initStack(Stack *s) {
s->top = -1;
}
逻辑分析:
-
data[MAX_SIZE]是栈的存储空间; -
top表示栈顶指针,初始化为 -1 表示空栈; -
push操作时,top增加1后插入数据; -
pop操作时,取出data[top]后top减1。
参数说明:
-
MAX_SIZE:定义栈的最大容量; -
top:表示当前栈顶位置; -
data[]:存储栈中的元素。
4.1.2 栈在递归与表达式求值中的应用
栈在递归调用中用于保存函数调用的上下文信息,如局部变量、返回地址等。在表达式求值中,栈常用于中缀表达式转后缀表达式(逆波兰表达式),以及后缀表达式的计算。
示例:中缀表达式转后缀表达式(使用栈)
graph TD
A[开始] --> B{当前字符}
B -->|数字| C[添加到输出列表]
B -->|运算符| D[比较栈顶优先级]
D -->|当前优先级≤栈顶| E[弹出栈顶到输出]
D -->|当前优先级>栈顶| F[压入栈]
B -->|左括号| G[压入栈]
B -->|右括号| H{栈顶不是左括号}
H -->|是| I[弹出栈顶到输出]
H -->|否| J[结束括号处理]
C --> K[继续处理下一个字符]
F --> K
J --> K
K --> L{是否处理完表达式}
L -->|否| A
L -->|是| M[弹出栈中所有运算符到输出]
M --> N[结束]
示例:使用栈实现括号匹配检测
#include <stdio.h>
#include <string.h>
#define MAX_SIZE 100
typedef struct {
char data[MAX_SIZE];
int top;
} Stack;
void initStack(Stack *s) {
s->top = -1;
}
int isEmpty(Stack *s) {
return s->top == -1;
}
void push(Stack *s, char ch) {
if (s->top >= MAX_SIZE - 1) {
printf("Stack overflow\n");
return;
}
s->data[++s->top] = ch;
}
char pop(Stack *s) {
if (isEmpty(s)) {
printf("Stack underflow\n");
return '\0';
}
return s->data[s->top--];
}
int isMatchingPair(char opening, char closing) {
return (opening == '(' && closing == ')') ||
(opening == '{' && closing == '}') ||
(opening == '[' && closing == ']');
}
int areBracketsBalanced(char *expr) {
Stack s;
initStack(&s);
for (int i = 0; i < strlen(expr); i++) {
if (expr[i] == '(' || expr[i] == '{' || expr[i] == '[') {
push(&s, expr[i]);
} else if (expr[i] == ')' || expr[i] == '}' || expr[i] == ']') {
if (isEmpty(&s)) return 0;
char top = pop(&s);
if (!isMatchingPair(top, expr[i])) return 0;
}
}
return isEmpty(&s);
}
int main() {
char expr[] = "{[()]}";
if (areBracketsBalanced(expr))
printf("Brackets are balanced\n");
else
printf("Brackets are not balanced\n");
return 0;
}
逻辑分析:
-
initStack()初始化栈; -
push()将左括号压入栈; -
pop()取出栈顶并与右括号比较; -
isMatchingPair()判断是否为匹配的括号; -
areBracketsBalanced()遍历表达式并执行匹配逻辑。
参数说明:
-
expr[]:输入的表达式字符串; -
s:栈结构,用于存储左括号; - 时间复杂度为 O(n),空间复杂度为 O(n),适用于任意长度的表达式。
4.2 队列结构的理论基础与实现方式
4.2.1 队列的FIFO特性与循环队列设计
队列(Queue)是一种先进先出(FIFO)的线性结构,插入操作在队尾(rear),删除操作在队头(front)。队列常用于任务调度、缓冲处理等场景。
队列的基本操作:
| 操作名 | 描述 |
|---|---|
enqueue(x) | 将元素x插入队尾 |
dequeue() | 删除并返回队头元素 |
front() | 返回队头元素但不删除 |
isEmpty() | 判断队列是否为空 |
isFull() | 判断队列是否已满 |
循环队列的设计
使用数组实现队列时,当 rear 到达数组末尾时,如果队列前面仍有空位,应将其视为循环结构。循环队列通过取模运算实现队列的循环利用。
示例:循环队列的结构定义(C语言)
#define MAX_SIZE 100
typedef struct {
int data[MAX_SIZE];
int front; // 队头指针
int rear; // 队尾指针
int count; // 当前元素个数
} Queue;
初始化队列:
void initQueue(Queue *q) {
q->front = 0;
q->rear = 0;
q->count = 0;
}
逻辑分析:
-
front和rear用于指示队头和队尾位置; -
count记录当前队列中元素个数; -
enqueue()操作时,将元素插入rear位置,然后rear = (rear + 1) % MAX_SIZE; -
dequeue()操作时,取出front位置的元素,然后front = (front + 1) % MAX_SIZE; -
isFull()的判断条件是count == MAX_SIZE; -
isEmpty()的判断条件是count == 0。
4.2.2 队列在任务调度与缓冲处理中的作用
队列在任务调度中用于管理多个任务的执行顺序,如操作系统中的进程调度;在缓冲处理中用于协调生产者与消费者之间的速度差异。
示例:使用队列模拟任务调度系统
#include <stdio.h>
#include <stdlib.h>
#define MAX_TASKS 10
typedef struct {
int tasks[MAX_TASKS];
int front;
int rear;
int count;
} TaskQueue;
void initTaskQueue(TaskQueue *q) {
q->front = 0;
q->rear = 0;
q->count = 0;
}
int isEmpty(TaskQueue *q) {
return q->count == 0;
}
int isFull(TaskQueue *q) {
return q->count == MAX_TASKS;
}
void enqueue(TaskQueue *q, int task) {
if (isFull(q)) {
printf("Task queue is full.\n");
return;
}
q->tasks[q->rear] = task;
q->rear = (q->rear + 1) % MAX_TASKS;
q->count++;
}
int dequeue(TaskQueue *q) {
if (isEmpty(q)) {
printf("Task queue is empty.\n");
return -1;
}
int task = q->tasks[q->front];
q->front = (q->front + 1) % MAX_TASKS;
q->count--;
return task;
}
int main() {
TaskQueue q;
initTaskQueue(&q);
enqueue(&q, 101);
enqueue(&q, 102);
enqueue(&q, 103);
printf("Processing task: %d\n", dequeue(&q));
printf("Processing task: %d\n", dequeue(&q));
enqueue(&q, 104);
while (!isEmpty(&q)) {
printf("Processing task: %d\n", dequeue(&q));
}
return 0;
}
逻辑分析:
-
enqueue()将任务加入队列; -
dequeue()取出并执行任务; - 使用循环队列提高空间利用率;
- 任务调度模拟了先进先出的任务执行逻辑。
参数说明:
-
MAX_TASKS:定义队列的最大任务数; -
front和rear指示队列的头尾位置; -
count用于判断队列是否满或空; - 时间复杂度为 O(1) 的入队和出队操作,适用于高频任务调度。
4.3 栈与队列的C语言实现
4.3.1 基于数组和链表的实现方式对比
| 实现方式 | 优点 | 缺点 |
|---|---|---|
| 数组实现 | 简单直观,访问速度快 | 容量固定,扩展性差 |
| 链表实现 | 动态扩展,空间利用率高 | 实现复杂,内存管理开销大 |
示例:链表实现栈
typedef struct Node {
int data;
struct Node *next;
} StackNode;
typedef struct {
StackNode *top;
} Stack;
void initStack(Stack *s) {
s->top = NULL;
}
int isEmpty(Stack *s) {
return s->top == NULL;
}
void push(Stack *s, int data) {
StackNode *newNode = (StackNode *)malloc(sizeof(StackNode));
if (!newNode) {
printf("Memory allocation failed.\n");
return;
}
newNode->data = data;
newNode->next = s->top;
s->top = newNode;
}
int pop(Stack *s) {
if (isEmpty(s)) {
printf("Stack is empty.\n");
return -1;
}
StackNode *temp = s->top;
int data = temp->data;
s->top = temp->next;
free(temp);
return data;
}
逻辑分析:
- 使用链表实现栈可以动态扩展;
-
push()创建新节点并插入栈顶; -
pop()删除栈顶节点并释放内存; - 更适合处理不确定数据量的场景。
示例:链表实现队列
typedef struct Node {
int data;
struct Node *next;
} QueueNode;
typedef struct {
QueueNode *front;
QueueNode *rear;
} Queue;
void initQueue(Queue *q) {
q->front = q->rear = NULL;
}
int isEmpty(Queue *q) {
return q->front == NULL;
}
void enqueue(Queue *q, int data) {
QueueNode *newNode = (QueueNode *)malloc(sizeof(QueueNode));
if (!newNode) {
printf("Memory allocation failed.\n");
return;
}
newNode->data = data;
newNode->next = NULL;
if (q->rear == NULL) {
q->front = q->rear = newNode;
} else {
q->rear->next = newNode;
q->rear = newNode;
}
}
int dequeue(Queue *q) {
if (isEmpty(q)) {
printf("Queue is empty.\n");
return -1;
}
QueueNode *temp = q->front;
int data = temp->data;
q->front = temp->next;
if (q->front == NULL) {
q->rear = NULL;
}
free(temp);
return data;
}
逻辑分析:
- 使用链表实现队列支持动态扩展;
-
enqueue()在队尾添加节点; -
dequeue()删除队头节点并释放内存; - 链式结构适合任务队列、缓冲池等动态场景。
4.4 实战应用:括号匹配检测与任务调度模拟
4.4.1 使用栈实现括号匹配算法
括号匹配问题广泛存在于编译器、文本编辑器等系统中。使用栈结构可以高效地判断括号是否匹配。
实现代码回顾(见 4.1.2 节)
- 使用栈保存左括号;
- 遇到右括号时与栈顶匹配;
- 最终栈应为空才表示匹配成功。
4.4.2 使用队列模拟任务调度系统
任务调度系统通常用于操作系统、服务器、任务队列等场景,使用队列结构可以保证任务的先进先出处理。
实现代码回顾(见 4.2.2 节)
- 使用循环队列模拟任务队列;
- 支持动态添加和执行任务;
- 保证任务处理顺序符合调度策略。
以上内容完整呈现了栈与队列的基本原理、C语言实现方法以及实战应用。通过本章的学习,读者应能熟练掌握栈和队列的数据结构设计与操作逻辑,并能够灵活运用它们解决实际问题。
5. 树结构与遍历实现
数据结构中的“树”是一种非线性的层次结构,广泛应用于文件系统、数据库索引、编译器语法树、人工智能等领域。树结构通过父子关系组织数据,其高效性体现在查找、插入和删除操作上。本章将深入探讨树的基本概念,重点分析二叉树的存储结构与遍历方式,并通过实际案例展示其在表达式求值中的应用。
5.1 树的基本概念与术语
树结构是一种递归定义的数据结构,由一个称为“根”的节点和若干个互不相交的子树组成。理解树结构,首先需要掌握其基本定义与相关术语。
5.1.1 树的定义与性质
树(Tree)是由n(n≥0)个节点组成的有限集合。当n=0时,称为空树;当n>0时,必存在一个特定的称为“根”(Root)的节点,其余节点被划分为m(m≥0)个互不相交的有限集合T₁, T₂, …, Tₘ,每个集合本身又是一棵树,并称为根的子树。
树的基本性质如下:
- 一个树结构中不存在环路。
- 任意两个节点之间有且仅有一条路径相连。
- 每个节点最多有一个父节点(除了根节点)。
- 树的节点数等于其所有子树节点数之和加1。
| 术语 | 定义 |
|---|---|
| 根节点 | 位于树最顶层的节点 |
| 子节点 | 一个节点所连接的下一层节点 |
| 父节点 | 一个节点的上层节点 |
| 叶子节点 | 没有子节点的节点 |
| 子树 | 某个节点及其后代组成的结构 |
| 层次 | 从根节点到该节点的路径长度(根为第1层) |
| 度 | 该节点拥有的子节点数目 |
5.1.2 二叉树、满二叉树与完全二叉树
二叉树(Binary Tree) 是每个节点最多有两个子节点的树结构,通常称为左子节点和右子节点。
- 满二叉树(Full Binary Tree) :每一层的节点数都达到最大值,即除了叶子节点外,每个节点都有两个子节点。
- 完全二叉树(Complete Binary Tree) :除了最后一层外,其余层的节点数都达到最大,并且最后一层的节点都集中在左侧。
mermaid流程图如下:
graph TD
A[树结构] --> B[二叉树]
A --> C[多叉树]
B --> D[满二叉树]
B --> E[完全二叉树]
B --> F[二叉搜索树]
F --> G[AVL树]
F --> H[红黑树]
完全二叉树因其结构紧凑,适合用数组进行顺序存储,而满二叉树则是完全二叉树的一种特殊情况。在实际编程中,特别是堆结构中,完全二叉树被广泛使用。
5.2 二叉树的存储与遍历方式
二叉树的实现方式主要包括顺序存储和链式存储两种形式。遍历是访问树中所有节点的操作,主要包括先序、中序和后序三种遍历方式,每种方式都可以用递归或非递归的方式实现。
5.2.1 顺序存储与链式存储结构
顺序存储 :使用数组来存储二叉树的节点,适用于完全二叉树。对于任意节点i(从0开始):
- 左子节点索引为 2*i + 1
- 右子节点索引为 2*i + 2
- 父节点索引为 (i-1)/2
链式存储 :每个节点由数据域和两个指针域组成,分别指向左子节点和右子节点。
// 链式存储结构定义
typedef struct BinaryTreeNode {
int data;
struct BinaryTreeNode* left;
struct BinaryTreeNode* right;
} BTNode;
| 存储方式 | 优点 | 缺点 |
|---|---|---|
| 顺序存储 | 实现简单,访问效率高 | 不适合非完全二叉树,空间浪费大 |
| 链式存储 | 灵活,适合任意形状的二叉树 | 指针操作复杂,访问效率略低 |
5.2.2 先序、中序、后序遍历的递归与非递归实现
递归实现 是最直观的遍历方式,通过函数调用栈实现。以先序遍历为例:
void preorderTraversal(BTNode* root) {
if (root == NULL) return;
printf("%d ", root->data); // 访问根节点
preorderTraversal(root->left); // 遍历左子树
preorderTraversal(root->right); // 遍历右子树
}
逻辑分析 :
- 第1行:判断是否为空节点,若为空则返回。
- 第2行:打印当前节点的值(根)。
- 第3行:递归调用函数遍历左子树。
- 第4行:递归调用函数遍历右子树。
非递归实现 需要借助栈来模拟递归调用栈。以中序遍历为例:
void inorderTraversalNonRecursive(BTNode* root) {
Stack* stack = createStack();
BTNode* current = root;
while (current != NULL || !isEmpty(stack)) {
while (current != NULL) {
push(stack, current);
current = current->left;
}
current = pop(stack);
printf("%d ", current->data);
current = current->right;
}
}
逻辑分析 :
- 第3行:初始化栈并设置当前节点为根节点。
- 第5行:循环遍历左子树,直到左子节点为空。
- 第6~8行:将当前节点入栈,并向左子节点移动。
- 第9~11行:弹出栈顶节点,访问节点数据,并转向右子节点。
- 第12行:继续外层循环处理右子树。
| 遍历方式 | 访问顺序 | 用途 |
|---|---|---|
| 先序遍历 | 根→左→右 | 复制树结构、打印表达式 |
| 中序遍历 | 左→根→右 | 二叉搜索树按序输出 |
| 后序遍历 | 左→右→根 | 删除节点、求表达式值 |
5.3 二叉树的操作与应用
在实际应用中,我们常常需要对二叉树进行创建、查找、插入、删除等操作。本节将重点介绍二叉树的基本操作及其在二叉搜索树中的实现。
5.3.1 二叉树的创建与查找
创建二叉树 通常基于前序或后序遍历结果递归构造。例如,给定前序遍历数组和中序遍历数组,可以递归构建二叉树。
BTNode* buildTree(int* preorder, int preStart, int preEnd, int* inorder, int inStart, int inEnd) {
if (preStart > preEnd) return NULL;
BTNode* root = (BTNode*)malloc(sizeof(BTNode));
root->data = preorder[preStart];
int index = 0;
for (int i = inStart; i <= inEnd; i++) {
if (inorder[i] == root->data) {
index = i;
break;
}
}
int leftSize = index - inStart;
root->left = buildTree(preorder, preStart + 1, preStart + leftSize, inorder, inStart, index - 1);
root->right = buildTree(preorder, preStart + leftSize + 1, preEnd, inorder, index + 1, inEnd);
return root;
}
逻辑分析 :
- 第1行:边界判断,如果前序数组为空则返回NULL。
- 第3~4行:创建根节点,赋值为前序数组的第一个元素。
- 第6~10行:在中序数组中找到根节点位置,确定左子树大小。
- 第11~12行:递归构建左子树和右子树。
- 第14行:返回构建好的树根节点。
查找节点 可以通过递归或非递归方式实现。以递归查找为例:
BTNode* searchNode(BTNode* root, int target) {
if (root == NULL || root->data == target) return root;
BTNode* left = searchNode(root->left, target);
if (left != NULL) return left;
return searchNode(root->right, target);
}
逻辑分析 :
- 第1行:若当前节点为空或匹配目标值,返回当前节点。
- 第2行:递归在左子树中查找。
- 第3~4行:若左子树找到,返回该节点;否则在右子树中查找。
5.3.2 二叉搜索树(BST)的基本操作
二叉搜索树 (Binary Search Tree)是一种特殊的二叉树,满足以下性质:
- 若左子树非空,则左子树上所有节点的值均小于根节点的值。
- 若右子树非空,则右子树上所有节点的值均大于根节点的值。
- 左、右子树也分别为二叉搜索树。
插入操作 示例:
BTNode* insertBST(BTNode* root, int value) {
if (root == NULL) {
BTNode* newNode = (BTNode*)malloc(sizeof(BTNode));
newNode->data = value;
newNode->left = newNode->right = NULL;
return newNode;
}
if (value < root->data) {
root->left = insertBST(root->left, value);
} else if (value > root->data) {
root->right = insertBST(root->right, value);
}
return root;
}
逻辑分析 :
- 第1~5行:如果当前节点为空,创建新节点并返回。
- 第7~8行:若值小于当前节点,递归插入左子树。
- 第9~10行:若值大于当前节点,递归插入右子树。
- 第12行:返回根节点。
删除操作 较为复杂,需考虑三种情况:
1. 删除节点为叶子节点:直接删除。
2. 删除节点有一个子节点:用子节点替代。
3. 删除节点有两个子节点:找到右子树的最小节点(或左子树的最大节点)替代,并删除该节点。
5.4 实战项目:表达式树的构建与求值
表达式树是一种二叉树,用于表示数学表达式。操作符作为非叶子节点,操作数作为叶子节点。本节将实现表达式树的构建与求值功能。
5.4.1 表达式树的构造方法
表达式树可以从后缀表达式(逆波兰表达式)构建。例如表达式 3 4 + 5 * 可构建为:
*
/ \
+ 5
/ \
3 4
构建函数如下:
BTNode* buildExpressionTree(char** tokens, int size) {
Stack* stack = createStack();
for (int i = 0; i < size; i++) {
char* token = tokens[i];
BTNode* node = (BTNode*)malloc(sizeof(BTNode));
node->data = atoi(token);
node->left = node->right = NULL;
if (isOperator(token)) {
node->right = pop(stack);
node->left = pop(stack);
}
push(stack, node);
}
return pop(stack);
}
逻辑分析 :
- 第3行:初始化栈。
- 第4~11行:遍历每个token,创建节点。
- 第6~9行:如果是操作符,则从栈中取出两个操作数作为左右子节点。
- 第12行:返回栈顶节点,即表达式树根节点。
5.4.2 后序遍历实现表达式求值
表达式树的求值可通过后序遍历递归实现:
int evaluateExpressionTree(BTNode* root) {
if (root == NULL) return 0;
if (root->left == NULL && root->right == NULL) {
return root->data;
}
int leftVal = evaluateExpressionTree(root->left);
int rightVal = evaluateExpressionTree(root->right);
switch (root->data) {
case '+': return leftVal + rightVal;
case '-': return leftVal - rightVal;
case '*': return leftVal * rightVal;
case '/': return leftVal / rightVal;
default: return 0;
}
}
逻辑分析 :
- 第1~3行:若节点为空或为叶子节点,返回其值。
- 第5~6行:递归计算左右子树的值。
- 第8~12行:根据操作符执行对应运算并返回结果。
表达式树结合后缀表达式和递归遍历,是编译器解析表达式的一种重要手段,也是理解树结构应用的典型实例。通过本章的学习,读者可以掌握树结构的核心原理及其在实际问题中的应用技巧。
6. 自平衡树与堆结构的C语言实现
6.1 自平衡树的核心机制
自平衡树是一类能够在插入或删除节点后自动调整结构以保持高度平衡的二叉搜索树(BST)。常见的自平衡树包括 AVL 树 和 红黑树 。它们通过特定的旋转策略或颜色规则,确保树的高度始终保持在 $ O(\log n) $ 级别,从而提高查找、插入和删除的效率。
6.1.1 AVL树的旋转调整策略
AVL树通过在每个节点维护一个平衡因子(balance factor)来实现自平衡,平衡因子定义为左子树高度减去右子树高度。其绝对值不能超过1,否则需要进行旋转操作。
AVL树有四种基本旋转方式:
- LL旋转(左单旋)
- RR旋转(右单旋)
- LR旋转(左右旋)
- RL旋转(右左旋)
下面是一个LL旋转的C语言实现示例:
typedef struct AVLNode {
int key;
struct AVLNode *left;
struct AVLNode *right;
int height;
} AVLNode;
int height(AVLNode *node) {
return node ? node->height : 0;
}
// 右旋转
AVLNode* rotateRight(AVLNode *y) {
AVLNode *x = y->left;
AVLNode *T2 = x->right;
// 旋转操作
x->right = y;
y->left = T2;
// 更新高度
y->height = 1 + (height(y->left) > height(y->right) ? height(y->left) : height(y->right));
x->height = 1 + (height(x->left) > height(x->right) ? height(x->left) : height(x->right));
return x; // 新根节点
}
上述代码中,我们定义了AVL树的节点结构,并实现了一个右旋转操作。类似地,其他旋转方式也可以通过类似的指针操作实现。
6.1.2 红黑树的平衡特性与插入调整
红黑树是一种二叉查找树,每个节点有一个颜色属性:红色或黑色。它满足以下五个性质:
- 每个节点是红色或黑色。
- 根节点是黑色。
- 所有叶子节点(NULL节点)是黑色。
- 如果一个节点是红色,则它的两个子节点都是黑色。
- 对每个节点,从该节点到其所有后代叶子节点的路径中,黑色节点数目相同。
红黑树的插入操作比AVL树更复杂,因为它需要处理多种颜色冲突的情况。例如:
- 插入节点为红色;
- 插入节点的父节点为红色,则违反规则,需要进行旋转和颜色翻转。
红黑树在实际应用中广泛用于实现关联数组、集合等结构,如Java的 TreeMap 和C++的 map 容器。
6.2 堆结构与优先队列的实现
堆是一种特殊的完全二叉树结构,通常用数组来实现。根据堆的性质,可以分为最大堆(max heap)和最小堆(min heap)。
6.2.1 最大堆与最小堆的结构设计
最大堆的特性是父节点的值总是大于等于其子节点,而最小堆则相反。以下是堆的基本结构定义:
typedef struct {
int *data; // 存储堆元素的数组
int capacity; // 堆的最大容量
int size; // 当前堆的大小
} Heap;
初始化堆的代码如下:
Heap* createHeap(int capacity) {
Heap *heap = (Heap*)malloc(sizeof(Heap));
heap->data = (int*)malloc(capacity * sizeof(int));
heap->capacity = capacity;
heap->size = 0;
return heap;
}
6.2.2 堆的构建、插入与删除操作
堆的核心操作包括插入(push)和删除堆顶元素(pop)。
插入操作(heapifyUp) :
void heapifyUp(Heap *heap, int index) {
int parent = (index - 1) / 2;
if (heap->data[index] > heap->data[parent]) {
// 交换父子节点
int temp = heap->data[index];
heap->data[index] = heap->data[parent];
heap->data[parent] = temp;
heapifyUp(heap, parent); // 继续上浮
}
}
void push(Heap *heap, int value) {
if (heap->size == heap->capacity) {
printf("堆已满\n");
return;
}
heap->data[heap->size] = value;
heapifyUp(heap, heap->size++);
}
删除堆顶元素(heapifyDown) :
void heapifyDown(Heap *heap, int index) {
int largest = index;
int left = 2 * index + 1;
int right = 2 * index + 2;
if (left < heap->size && heap->data[left] > heap->data[largest])
largest = left;
if (right < heap->size && heap->data[right] > heap->data[largest])
largest = right;
if (largest != index) {
// 交换并下沉
int temp = heap->data[index];
heap->data[index] = heap->data[largest];
heap->data[largest] = temp;
heapifyDown(heap, largest);
}
}
int pop(Heap *heap) {
if (heap->size <= 0) {
printf("堆为空\n");
return -1;
}
int root = heap->data[0];
heap->data[0] = heap->data[--heap->size];
heapifyDown(heap, 0);
return root;
}
堆结构广泛应用于优先队列、Dijkstra算法、Top-K问题等场景。下一节我们将介绍图结构的基本实现方式。
简介:数据结构是计算机科学的核心内容,C语言作为底层开发的首选语言,是学习数据结构的理想工具。本教程“零基础学数据结构C语言代码”专为初学者打造,通过理论与代码结合的方式,系统讲解数组、链表、栈、队列、树、图等常见数据结构的C语言实现。配套源码涵盖添加、删除、查找等基础操作,帮助学习者通过动手实践掌握核心概念,为深入算法和系统编程打下坚实基础。
更多推荐
所有评论(0)