C 语言重难点系统总结笔记
一、指针全解析
1. 一级指针(基础指针)
-
定义 :指向普通变量的指针(存储变量的内存地址 ,不是值!)。
语法:数据类型 *指针名;
例:int *p; char *str; -
💡 通俗理解 :
把变量a想象成一个放着物品的房间 ,&a是房间的门牌号 ,指针p就是一张写着门牌号的纸条 。p = &a:把a的门牌号抄到p上。*p:根据p上的门牌号,找到房间a,拿出/修改里面的物品。
-
核心操作 (加了我之前搞混的点注释):
cint a = 10; int *p = &a; // p存的是a的地址(门牌号),不是10! *p = 20; // *p是"根据地址找房间",修改a的值,此时a=20 printf("a的值:%d,p存的地址:%p", a, p); // 输出:a的值:20,p存的地址:0x... -
作用:间接访问变量、传递变量地址给函数(实现"引用传递"效果)。
2. 二级指针(指向指针的指针)
-
定义 :存储一级指针地址 的指针(指针的指针,像"套娃")。
语法:数据类型 **二级指针名;
例:int **pp; -
💡 通俗理解 :
一级指针p是"写着房间门牌号的纸条",二级指针pp是"放着这张纸条的文件夹 "。pp = &p:文件夹里放着纸条p。*pp:打开文件夹,拿出纸条p(得到p的内容,即a的地址)。**pp:根据纸条p找房间a,拿出物品(得到a的值)。
-
核心逻辑 :
cint a = 10; int *p = &a; // 一级指针p指向a int **pp = &p; // 二级指针pp指向p // 通过二级指针修改a的值:**pp → *p → a **pp = 30; printf("a的值:%d", a); // 输出:30 -
⚠️ 为什么需要二级指针?
比如在函数里想修改一级指针的指向 (不是修改指针指向的值),就需要二级指针。
举个简单例子:c// 错误示例:不用二级指针,函数里修改p没用 void changePtr(int *p) { int b = 20; p = &b; // 这里的p是局部变量,外面的p不会变 } // 正确示例:用二级指针,修改外面p的指向 void changePtr(int **pp) { int b = 20; *pp = &b; // *pp是外面的p,让p指向b } int main() { int a = 10; int *p = &a; changePtr(&p); // 传p的地址(二级指针) printf("*p = %d", *p); // 输出:20(p现在指向b了) return 0; } -
作用:在函数中修改一级指针的指向(如链表头节点的修改,后面会讲)。
3. 数组指针(指向数组的指针)
-
定义 :专门指向整个数组的指针,不是指向数组元素
-
语法:
数据类型 (*指针名)[数组长度];
例:int (*arr_p)[5];// 指向包含5个int元素的数组 -
💡 易混区分技巧 :
- 数组指针:
(*p)[5]→ 先看括号里的*p,说明p是指针,指向一个数组。 - 指针数组:
*p[5]→ 先看p[5],说明p是数组,里面存了5个指针。
- 数组指针:
-
使用示例 :
cint arr[5] = {1,2,3,4,5}; int (*arr_p)[5] = &arr; // 注意:取整个数组的地址用&arr,不是arr // 虽然arr和&arr的地址值一样,但意义不同: // arr是数组首元素地址(arr+1跳过1个int),&arr是整个数组地址(&arr+1跳过5个int) // 访问数组元素:(*arr_p)[索引] printf("arr[0] = %d", (*arr_p)[0]); // 输出:1 printf("数组首地址:%p,arr_p指向的地址:%p", arr, arr_p); // 地址相同
4. 函数指针(指向函数的指针)
-
定义 :存储函数入口地址 的指针,可通过指针调用函数。
语法:返回值类型 (*指针名)(参数类型1, 参数类型2, ...);
例:int (*func_p)(int, int);// 指向参数为2个int、返回int的函数 -
💡 先看中间的
(*func_p),说明是指针;再看两边:左边int是返回值,右边(int, int)是参数类型 → 合起来就是"指向返回int、参数2个int的函数的指针"。 -
使用示例 :
c// 普通函数 int add(int a, int b) { return a + b; } int main() { int (*func_p)(int, int) = add; // 函数指针func_p指向add(函数名就是地址,可省略&) // 通过函数指针调用函数 int result = func_p(3, 4); // 等价于 (*func_p)(3,4),两种写法都可以 printf("3+4=%d", result); // 输出:7 return 0; } -
作用 :实现函数回调(如排序函数
qsort中自定义比较规则)。
二、数组与指针的区别
| 对比维度 | 数组(如int arr[5]) |
指针(如int *p) |
|---|---|---|
| 内存分配 | 编译时分配连续内存空间,大小固定(5×4=20字节)。 | 编译时仅分配指针变量本身的空间 (通常8字节),指向的内存需手动分配(如malloc)。 |
| 可修改性 | 数组名是常量地址 ,不能被重新赋值! ❌ 错误示例:arr = p;(报错) |
指针是变量,可随时指向不同地址! ✅ 正确示例:p = arr; p = &a; |
| sizeof 计算 | 返回数组总字节数 ! 例:sizeof(arr) = 20。 |
返回指针变量本身的字节数 ! 例:sizeof(p) = 8(64位系统)。 |
| 退化(Decay) | 数组名作为函数参数时,会退化为指向首元素的指针(丢失长度信息)! 💡 比如:void func(int arr[]) { sizeof(arr) = 8; }(这里arr是指针,不是数组了) |
本身就是指针,无退化问题。 |
三、结构体与联合体
1. 结构体(struct)
-
定义 :将不同类型的数据组合成一个整体,各成员独立分配内存 (像"一套房子里的不同房间")。
语法:cstruct 结构体名 { 数据类型 成员1; 数据类型 成员2; ... }; -
💡 内存分配图示(内存对齐) :
结构体遵循"内存对齐"原则(成员地址是自身大小的整数倍,总大小是最大成员大小的整数倍),比如:cstruct Student { char name[10]; // 10字节(占地址0-9) int age; // 4字节(对齐到地址12,补2字节空着,占12-15) float score; // 4字节(占16-19,总大小补到24,因为最大成员是4,24是4的倍数) }; printf("struct大小:%d", sizeof(struct Student)); // 输出:24(画图理解:name[10] → 空2字节 → age → score → 空4字节,总共24)
-
核心作用:自定义复杂数据类型(如链表节点、学生信息)。
2. 联合体(union)
-
定义 :与结构体类似,但所有成员共享同一块内存空间 (像"一个房间,不同时间放不同东西"),大小等于最大成员的大小。
语法:cunion 联合体名 { 数据类型 成员1; 数据类型 成员2; ... }; -
💡 内存分配图示(共享内存) :
所有成员从同一地址开始存储,修改一个成员会覆盖其他成员。比如:cunion Data { int i; // 4字节 float f; // 4字节 char str[20]; // 20字节(最大,所以union大小是20) }; union Data data; data.i = 10; // 把i设为10,内存里存的是10的二进制 printf("data.f = %f", data.f); // 输出乱码(把i的二进制当成float解析了) printf("union大小:%d", sizeof(data)); // 输出:20(最大成员str[20]的大小)(画图理解:i、f、str都从地址0开始,用i时这块内存存int,用f时存float,互相覆盖)
-
核心作用:节省内存(同一时刻只使用一个成员)、数据类型转换(通过不同成员解析同一块内存)。
四、内存四区模型(程序运行时的内存分配)
| 内存区域 | 作用 | 生命周期 | 存储内容示例 |
|---|---|---|---|
| 栈区(Stack) | 由编译器自动分配释放,存储函数参数、局部变量。 💡 生长方向:从高地址向低地址(像"往桶里放东西,从顶端(高地址)往下放")。 | 随函数调用创建,函数结束释放。 | int a=10;(局部变量)、函数形参int x。 |
| 堆区(Heap) | 由程序员手动分配释放(malloc/calloc/realloc分配,free释放)。 💡 生长方向:从低地址向高地址(像"在空地上盖房子,从低地址开始盖")。 |
手动分配后,直到free才释放。 |
int *p = (int*)malloc(4);(p指向堆内存)。 |
| 数据区(静态区) | 存储全局变量、静态变量(static修饰)、常量字符串。 |
程序启动时创建,程序结束释放。 | int g_a=20;(全局变量)、static int s_b=30;(静态局部变量)。 |
| 代码区 | 存储程序的二进制可执行代码(函数体指令)。 | 程序启动时加载,只读。 | main函数、add函数的机器指令。 |
- ⚠️ 常见错误 :
- 栈区变量越界访问(如数组下标越界)。
- 堆区内存泄漏(忘记
free)或重复释放。 - 返回局部变量地址(函数结束后栈区内存释放,地址无效!比如:
int* func() { int a=10; return &a; }错误)。
五、字符串核心函数(自定义实现+底层逻辑)
1. 拷贝函数:my_strcpy
-
功能 :
char *my_strcpy(char *dest, const char *src)
将src指向的字符串(以\0结尾)复制到dest指向的数组。 -
💡 核心逻辑 :逐个复制字符,直到
src遇到\0,必须手动给dest加\0! -
⚠️ 不加
\0的错误示例 :c// 错误版:没加'\0' char* my_strcpy_wrong(char* dest, const char* src) { char* ret = dest; while (*src != '\0') { *dest++ = *src++; } // 这里没加'\0' return ret; } int main() { char dest[10] = "xxxxxx"; // 初始是"xxxxxx" char src[] = "hello"; my_strcpy_wrong(dest, src); printf("dest = %s", dest); // 输出:helloxxx(因为没'\0',会把后面的xxx也打出来) return 0; } -
正确代码实现 (防重叠优化版):
cchar *my_strcpy(char *dest, const char *src) { if (dest == NULL || src == NULL) return NULL; // 判空 char *ret = dest; // 保存起始地址 // 循环复制(包含src的'\0') while ((*dest++ = *src++) != '\0'); return ret; } -
注意 :目标数组
dest必须有足够空间,否则会导致缓冲区溢出。
2. 比较函数:my_strcmp
-
功能 :
int my_strcmp(const char *str1, const char *str2)
按ASCII值比较str1和str2,返回差值。 -
核心逻辑 :逐个比较字符,直到不同或遇到
\0。 -
代码实现 :
cint my_strcmp(const char *str1, const char *str2) { if (str1 == NULL || str2 == NULL) return (int)(str1 - str2); // 判空 while (*str1 != '\0' && *str2 != '\0' && *str1 == *str2) { str1++; str2++; } return (int)(*str1 - *str2); // 返回ASCII差值 } -
返回值示例 :
my_strcmp("abc", "abd")→ 返回'c'-'d' = -1(str1 < str2)。
my_strcmp("abc", "abc")→ 返回0(相等)。
3. 拼接函数:my_strcat
-
功能 :
char *my_strcat(char *dest, const char *src)
将src字符串拼接到dest字符串的末尾(覆盖dest原有的\0,并在拼接后加\0)。 -
💡 核心逻辑图示 :
- 先移到
dest的末尾(找到原来的\0)。 - 把
src的字符逐个复制到dest末尾(覆盖原来的\0)。 - 最后加新的
\0。
- 先移到
-
代码实现 :
cchar *my_strcat(char *dest, const char *src) { if (dest == NULL || src == NULL) return NULL; // 判空 char *ret = dest; // 步骤1:移到dest末尾(找'\0') while (*dest != '\0') { dest++; } // 步骤2:拼接src(包含'\0') while ((*dest++ = *src++) != '\0'); return ret; } -
注意 :
dest必须有足够空间容纳拼接后的字符串。
六、结构体实现链表节点
1. 链表节点的结构体定义
-
核心成员 :
数据域(存储数据) +指针域(指向下一节点)。cstruct Node { int data; // 数据域(可替换为任意类型,如struct Student) struct Node *next; // 指针域(指向同类型节点,像"链条"一样连起来) }; -
💡 链表结构图示 :
node1(data=10, next→node2) → node2(data=20, next→node3) → node3(data=30, next→NULL)
2. 链表的基本操作(代码示例)
(1)创建节点
c
#include <stdlib.h> // 包含malloc
struct Node* createNode(int data) {
struct Node* newNode = (struct Node*)malloc(sizeof(struct Node));
newNode->data = data;
newNode->next = NULL; // 新节点默认指向NULL(链表尾巴)
return newNode;
}
(2)连接节点形成链表
c
int main() {
// 创建3个节点
struct Node* node1 = createNode(10);
struct Node* node2 = createNode(20);
struct Node* node3 = createNode(30);
// 连接节点:node1 → node2 → node3 → NULL
node1->next = node2;
node2->next = node3;
// 遍历链表
struct Node* p = node1; // p为遍历指针,从node1开始
while (p != NULL) {
printf("%d ", p->data); // 输出:10 20 30
p = p->next; // 指针后移,指向下一个节点
}
return 0;
}
(3)通过二级指针修改头节点(如头插法)
-
⚠️ 不用二级指针的错误示例 :
c// 错误版:不用二级指针,函数里修改head没用 void insertAtHead_wrong(struct Node* head, int data) { struct Node* newNode = createNode(data); newNode->next = head; // 新节点指向原头节点 head = newNode; // 这里的head是局部变量,外面的head不会变 } int main() { struct Node* head = NULL; insertAtHead_wrong(head, 10); printf("head = %p", head); // 输出:NULL(外面的head没变化) return 0; } -
✅ 正确版:用二级指针 :
c// 头插法:在链表头部插入新节点(需用二级指针修改头节点指向) void insertAtHead(struct Node** head, int data) { struct Node* newNode = createNode(data); newNode->next = *head; // 新节点指向原头节点(*head是外面的head) *head = newNode; // 更新头节点为新节点(修改外面的head) } // 使用示例: int main() { struct Node* head = NULL; // 初始链表为空 insertAtHead(&head, 30); // 链表:30 → NULL(传head的地址,二级指针) insertAtHead(&head, 20); // 链表:20 → 30 → NULL insertAtHead(&head, 10); // 链表:10 → 20 → 30 → NULL // 遍历验证 struct Node* p = head; while (p != NULL) { printf("%d ", p->data); // 输出:10 20 30 p = p->next; } return 0; }
七、总结与学习建议
- 指针:从一级指针开始,多打印地址和值观察;重点用"括号法"区分数组指针与指针数组,用"套娃/文件夹"理解二级指针。
- 结构体/联合体:通过画图理解"独立分配"与"共享内存"的区别;结构体是链表、树等数据结构的基础。
- 内存四区:记住栈"高→低"、堆"低→高"的生长方向,理解变量的生命周期,避免野指针、内存泄漏。
- 字符串函数 :手动实现并测试,一定要记住
\0的重要性 ;实际开发中优先使用系统函数(如strncpy防溢出)。 - 链表:多画图理解节点连接逻辑,掌握头插、尾插、遍历等基本操作,体会二级指针在修改头节点时的作用。