1. 线性结构
线性结构的核心定义:数据元素之间仅存在 "一对一" 的前驱 - 后继关系,所有元素按线性顺序排列,可通过顺序存储或链式存储两种方式实现,是数据结构中最基础、最常用的结构类型。
a. 顺序存储
i. 顺序表 ------ 本质就是数组
- 核心定义:以连续的物理内存空间存储数据元素,逻辑顺序与物理顺序完全一致,底层依托数组实现,是顺序存储的核心载体。
- 底层特性:
- 内存连续:所有元素依次排列在一段整块的内存地址中,无间隔、无碎片(初始化时分配);
- 随机访问:支持通过下标(索引)直接定位任意元素,访问时间复杂度为 O (1),效率极高;
- 容量固定:初始化时需指定最大容量,后续扩容需重新申请更大的连续内存,拷贝原有元素,扩容开销大。
- 核心操作(补全缺漏):
- 初始化:分配连续内存,初始化数组、容量、当前元素个数;
- 插入:头部 / 中间插入需平移后续所有元素,时间复杂度 O (n);尾部插入无需平移,时间复杂度 O (1);
- 删除:头部 / 中间删除需平移后续所有元素,时间复杂度 O (n);尾部删除无需平移,时间复杂度 O (1);
- 查找:无序顺序表遍历查找(O (n));有序顺序表可折半查找(O (log₂n));
- 销毁:释放分配的连续内存,避免内存泄漏(动态分配的顺序表必须销毁)。
- 优缺点(补全细节):
- 优点:访问速度快、遍历效率高、CPU 缓存友好(内存连续,可预加载)、空间利用率较高(无指针开销);
- 缺点:插入 / 删除效率低(需平移元素)、容量固定(扩容麻烦)、易产生内部内存碎片(数组未占满时,剩余空间无法利用)。
- 适用场景:查询、遍历操作频繁,插入 / 删除操作较少,数据量固定或波动小的场景(如学生成绩查询、固定长度的数据存储)。
b. 链式存储
链式存储的核心定义:以离散的物理内存空间存储数据元素,每个元素(结点)包含 "数据域" 和 "指针域",通过指针域关联前后元素,逻辑上连续、物理上离散,无需连续内存空间。
i. 单链表(补全细节、实现、常见问题)
- 结构组成:每个结点包含 数据域(存储元素值) + 一个后继指针 next(指向后一个结点),链表头部为头结点(可空),尾部结点的 next 指针为 NULL(非循环单链表)。
- 核心特性:
- 内存离散:无需连续内存,有新元素时动态申请结点,按需分配;
- 遍历限制:只能从前往后遍历(从头结点开始,通过 next 指针依次访问),无法反向回溯;
- 操作效率:插入 / 删除只需修改指针指向,无需移动元素,时间复杂度 O (1)(已知插入 / 删除位置时);查找需从头遍历,时间复杂度 O (n)。
- 核心操作:
- 初始化:创建头结点(可空),初始化头指针、尾指针(可选)、元素个数;
- 插入:头插(在头结点后插入)、尾插(在尾部结点后插入)、中间插(在指定结点后插入);
- 删除:删除指定结点(需找到其前驱结点,修改前驱结点的 next 指针);
- 查找:从头遍历,匹配数据域,找到目标结点;
- 销毁:从头遍历,逐个释放每个结点的内存,避免内存泄漏。
- 常见问题:
- 空链表判断:头指针为 NULL 或头结点的 next 为 NULL;
- 野指针问题:删除结点后,需将指针置为 NULL,避免悬挂指针;
- 遍历终止条件:当指针为 NULL 时,遍历结束。
ii. 双链表
-
结构组成:每个结点包含 数据域 + 前驱指针 prev(指向前一个结点) + 后继指针 next(指向后一个结点),头部有头结点,尾部结点的 next 为 NULL,头部结点的 prev 为 NULL(非循环双链表)。
-
核心特性(补充与单链表的区别):
- 双向遍历:可从头往后遍历(通过 next),也可从后往前遍历(通过 prev),遍历灵活性远超单链表;
- 操作更便捷:插入 / 删除时,无需像单链表那样查找前驱结点(可通过 prev 直接获取),操作更高效;
- 空间开销略大:比单链表多一个 prev 指针,每个结点占用更多内存。
-
核心操作:
- 初始化:创建头结点,头指针指向头结点,头结点的 prev 和 next 均为 NULL;
- 插入:头插、尾插、中间插(需同时修改前驱结点的 next 和后继结点的 prev);
- 删除:删除指定结点(同时修改前驱结点的 next 和后继结点的 prev,无需查找前驱);
- 双向遍历:正向遍历(next)、反向遍历(prev);
- 销毁:与单链表类似,逐个释放结点,注意指针置空。
-
优势对比:双链表比单链表更适合需要频繁反向遍历、插入 / 删除位置不明确的场景(如操作系统中的进程链表、双向循环队列)。
-
补充:单链表与双链表的共性
- 均不支持随机访问,只能线性遍历;
- 均动态分配内存,无固定容量限制;
- 插入 / 删除的核心是修改指针,无需移动元素;
- 均需注意内存释放,避免内存泄漏。
c. 特殊线性结构
特殊线性结构的核心:底层仍是顺序表或链表,只是人为限制了插入、删除的操作位置,形成固定的操作规则(LIFO/FIFO),适配特定场景。
i. 只能在表的同一端插入、删除 ------ 栈
- 核心规则:LIFO(Last In First Out)后进先出
- 核心定义:栈是操作受限的线性表,仅开放一个操作端口(栈顶),所有插入、删除、访问操作均只能在栈顶进行,另一端(栈底)固定不动,无法操作。
- 关键概念(补全):
- 栈顶(top):唯一可操作端,始终指向最后入栈的元素(最新元素),入栈时栈顶 "上移",出栈时栈顶 "下移";
- 栈底(bottom):固定端,是栈的起始位置,无论栈是否为空,栈底位置不变;
- 空栈:栈中无任何元素,此时栈顶与栈底重合;
- 栈满:栈中元素个数达到最大容量(仅顺序栈有栈满,链式栈无栈满限制)。
- 存储方式:
- 顺序栈:底层是数组,栈底固定为数组下标 0,栈顶用索引标记(空栈时 top=-1);
- 链式栈:底层是单链表,链表头部作为栈顶,链表尾部作为栈底,入栈时头插,出栈时头删。
- 核心操作:
- 初始化:创建空栈,初始化栈顶、栈底指针,设置容量(顺序栈);
- 判空:判断栈是否为空(避免空栈出栈);
- 判满:判断栈是否已满(顺序栈,避免栈溢出);
- 入栈(push):将元素添加到栈顶,更新栈顶指针;
- 出栈(pop):删除栈顶元素,取出元素值,更新栈顶指针;
- 取栈顶(gettop):读取栈顶元素,不删除,栈状态不变;
- 销毁:释放栈占用的内存(链式栈需逐个释放结点)。
- 典型应用:
- 函数调用栈:操作系统为每个函数调用分配栈帧,存储局部变量、返回地址,函数执行完出栈;
- 递归实现:递归过程中,每次递归调用的参数、返回地址入栈,递归结束后出栈回溯;
- 括号匹配:遍历表达式,左括号入栈,右括号出栈匹配,判断括号是否成对;
- 表达式求值:将中缀表达式转换为后缀 / 前缀表达式,借助栈实现计算;
- 进制转换:十进制转二进制、八进制、十六进制(除基取余,逆序输出,用栈实现逆序)。
ii. 表的一端插入,另一端删除 ------ 队列
- 核心规则:FIFO(First In First Out)先进先出
- 核心定义:队列是操作受限的线性表,开放两个独立端口,一端(队尾)仅负责插入,另一端(队头)仅负责删除,不允许中间操作,元素按进入顺序依次处理,完全模拟 "排队" 逻辑。
- 关键概念:
- 队尾(rear):插入端,只进不出,入队时队尾 "后移",指向新插入的元素;
- 队头(front):删除端,只出不进,出队时队头 "后移",指向新的队头元素;
- 空队列:队列中无任何元素,此时队头与队尾重合;
- 队满:队列中元素个数达到最大容量(仅顺序队列有队满,链式队列无队满限制)。
- 存储方式:
- 顺序存储:
- 普通顺序队列:底层是数组,队头、队尾初始为 0,入队 rear++,出队 front++,存在 "假溢出" 问题(数组有空闲空间,但 rear 达到最大下标,无法入队);
- 循环队列(补全核心,解决假溢出):将数组头尾相连,形成环形结构,队头、队尾指针移动到数组末尾时,通过取模运算自动回到开头,充分利用内存;
- 判空条件:front == rear;
- 判满条件:(rear + 1) % 最大容量 == front(预留一个空位置,区分队空和队满);
- 链式存储(lqueue):
- 底层是单链表,链表头部作为队头(出队),链表尾部作为队尾(入队);
- 无假溢出问题,动态扩容,无需指定容量;
- 结构定义:队头指针 front(指向链表头部)、队尾指针 rear(指向链表尾部),每个结点包含数据域和 next 指针。
- 顺序存储:
- 核心操作:
- 初始化:创建空队列,初始化队头、队尾指针,设置容量(顺序队列);
- 判空:判断队列是否为空(避免空队列出队);
- 判满:判断队列是否已满(顺序队列,避免队列溢出);
- 入队(enqueue):将元素添加到队尾,更新队尾指针;
- 出队(dequeue):删除队头元素,取出元素值,更新队头指针;
- 取队头(getfront):读取队头元素,不删除,队列状态不变;
- 销毁:释放队列占用的内存(链式队列需逐个释放结点)。
- 典型应用:
- 任务调度:操作系统中,优先级相同的进程按队列排队,CPU 按 FIFO 顺序执行;
- 消息缓冲:网络通信中,接收的消息按顺序入队,处理线程按顺序出队处理;
- 打印机排队:多个打印任务按提交顺序入队,打印机依次执行;
- IO 缓冲:键盘输入、文件读写时,数据按顺序入队,逐步处理,避免数据丢失;
- 线程池任务队列:线程池中的任务按提交顺序排队,空闲线程依次取出任务执行。
2. 作业讲解
结合前面的线性结构(栈、队列、表达式),补全作业核心思路、详细步骤、常见错误、代码框架,确保能直接用于作业实操,避免踩坑。
作业 1:栈的应用 ------ 十进制转二进制(argv [1] 输入十进制,输出二进制)
核心原理
- 转换规则:除 2 取余,逆序输出,利用栈的 LIFO 特性实现 "逆序";
- 特殊情况:输入十进制数为 0 时,直接输出 0(避免空栈或逻辑错误);输入负数时,可提示 "请输入正整数",或补充负数二进制(补码)转换逻辑(可选)。
详细步骤
- 命令行参数处理:判断 argc 是否大于 1(确保用户输入 argv [1]),若未输入,提示 "请输入十进制正整数";
- 数据预处理:将 argv [1](字符串类型)转换为整数类型(int 或 long long,避免数值溢出),判断是否为正整数(排除非数字、负数、小数);
- 初始化栈:创建顺序栈或链式栈(优先顺序栈,实现简单),初始化栈顶、栈底指针;
- 除 2 取余,余数入栈:
- 若输入数为 0,直接将 0 入栈;
- 若输入数为正整数,循环执行:① 计算余数 rem = 输入数 % 2;② 将 rem 入栈;③ 输入数 = 输入数 / 2;④ 直到输入数为 0,停止循环;
- 余数出栈,输出二进制:循环执行出栈操作,将栈中所有余数依次输出,直到栈为空,输出的序列即为二进制数;
- 销毁栈:释放栈占用的内存,避免内存泄漏;
- 异常处理:处理非数字输入、负数、数值过大等情况,给出友好提示。
常见错误
- 未处理 argv [1] 缺失,导致数组越界;
- 未处理输入为 0 的情况,导致空栈,无输出;
- 顺序栈未判满,导致栈溢出;
- 出栈前未判空,导致空栈出栈,程序崩溃;
- 链式栈未释放结点,导致内存泄漏。
代码思路(C 语言框架)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// 顺序栈定义
#define MAX_SIZE 100
typedef struct {
int data[MAX_SIZE];
int top;
} SeqStack;
// 栈初始化
void InitStack(SeqStack *s) {
s->top = -1;
}
// 判空
int IsEmpty(SeqStack *s) {
return s->top == -1;
}
// 判满
int IsFull(SeqStack *s) {
return s->top == MAX_SIZE - 1;
}
// 入栈
int Push(SeqStack *s, int num) {
if (IsFull(s)) return 0;
s->top++;
s->data[s->top] = num;
return 1;
}
// 出栈
int Pop(SeqStack *s, int *num) {
if (IsEmpty(s)) return 0;
*num = s->data[s->top];
s->top--;
return 1;
}
int main(int argc, char *argv[]) {
// 处理命令行参数
if (argc != 2) {
printf("请输入正确的参数:./a.out 十进制数\n");
return 1;
}
// 转换为整数
int n = atoi(argv[1]);
if (n < 0) {
printf("请输入十进制正整数\n");
return 1;
}
SeqStack s;
InitStack(&s);
// 除2取余,入栈
if (n == 0) {
Push(&s, 0);
} else {
while (n != 0) {
int rem = n % 2;
Push(&s, rem);
n = n / 2;
}
}
// 出栈,输出二进制
printf("二进制结果:");
int num;
while (!IsEmpty(&s)) {
Pop(&s, &num);
printf("%d", num);
}
printf("\n");
return 0;
}
作业 2:表达式转换 ------ 中缀表达式转前缀表达式(argv [1] 输入中缀,输出前缀)
核心原理
- 中缀表达式:运算符在操作数中间,需考虑优先级和括号(人类习惯写法);
- 前缀表达式:运算符在操作数前面,无需括号、无需优先级,计算机可直接计算;
- 转换核心:利用栈存储运算符,通过 "逆序处理、优先级判断、括号翻转、二次逆序" 实现转换。
详细步骤(必背)
- 预处理中缀表达式:
- 去除表达式中的所有空格(如 "1 + 2 * 3" → "1+2*3");
- 处理异常情况:表达式为空、括号不匹配(如 "(1+2""1+2)")、运算符位置错误(如 "+1+2""1++2"),提示错误信息;
- 处理多位数(可选):若操作数为多位数(如 10、20),需判断连续数字,作为一个整体处理。
- 中缀表达式逆序:将预处理后的中缀表达式整体逆序,同时将括号翻转("("→")",")"→"("),因为逆序后括号的作用会反转;
- 初始化工具:创建一个空栈(存储运算符),创建一个结果字符串(存储中间结果);
- 遍历逆序后的字符串,逐个处理字符:
- 情况 1:当前字符是操作数(0-9,或多位数):直接追加到结果字符串,多位数需连续读取;
- 情况 2:当前字符是右括号 ")"(原中缀的左括号):直接入栈(括号优先级最高,等待左括号匹配);
- 情况 3:当前字符是左括号 "("(原中缀的右括号):循环出栈,将栈中所有运算符追加到结果字符串,直到遇到右括号 ")",然后将右括号出栈(不追加到结果);
- 情况 4:当前字符是运算符(+、-、*、/):
- 定义优先级:*、/ 优先级为 2,+、- 优先级为 1;
- 循环判断:若栈不为空,且栈顶运算符的优先级 ≥ 当前运算符的优先级,则出栈并追加到结果;
- 直到栈为空,或栈顶运算符优先级 < 当前运算符,将当前运算符入栈;
- 遍历结束后,若栈不为空,将栈中剩余的所有运算符依次出栈,追加到结果字符串;
- 结果字符串逆序:将得到的中间结果再次逆序,即为原中缀表达式对应的前缀表达式;
- 输出前缀表达式:注意操作数之间添加空格,便于阅读(如 "+ 1 * 2 3")。
常见错误
- 未去除表达式中的空格,导致字符处理错误;
- 逆序后未翻转括号,导致括号匹配错误、优先级判断错误;
- 运算符优先级判断错误(如把 +、- 优先级设为 2,*、/ 设为 1);
- 遍历结束后,未将栈中剩余运算符出栈,导致结果缺失;
- 未处理多位数,导致多位数被拆分为单个数字(如 10 被拆分为 1 和 0);
- 括号不匹配,未做异常处理,导致程序崩溃。
代码思路(C 语言框架)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
// 栈定义(存储运算符)
#define MAX_STACK 100
typedef struct {
char data[MAX_STACK];
int top;
} OpStack;
// 初始化栈
void InitOpStack(OpStack *s) {
s->top = -1;
}
// 判空
int OpIsEmpty(OpStack *s) {
return s->top == -1;
}
// 判满
int OpIsFull(OpStack *s) {
return s->top == MAX_STACK - 1;
}
// 入栈
int OpPush(OpStack *s, char op) {
if (OpIsFull(s)) return 0;
s->top++;
s->data[s->top] = op;
return 1;
}
// 出栈
char OpPop(OpStack *s) {
if (OpIsEmpty(s)) return '\0';
return s->data[s->top--];
}
// 取栈顶
char OpGetTop(OpStack *s) {
if (OpIsEmpty(s)) return '\0';
return s->data[s->top];
}
// 运算符优先级判断
int GetPriority(char op) {
if (op == '*' || op == '/') return 2;
if (op == '+' || op == '-') return 1;
return 0; // 括号优先级单独处理
}
// 字符串逆序
void ReverseStr(char *str) {
int len = strlen(str);
for (int i = 0; i < len / 2; i++) {
char temp = str[i];
str[i] = str[len - 1 - i];
str[len - 1 - i] = temp;
}
}
// 中缀转前缀
void InfixToPrefix(char *infix, char *prefix) {
OpStack s;
InitOpStack(&s);
int len = strlen(infix);
int idx = 0;
// 1. 逆序中缀表达式,翻转括号
char reversed[100] = {0};
for (int i = 0; i < len; i++) {
if (infix[i] == '(') reversed[i] = ')';
else if (infix[i] == ')') reversed[i] = '(';
else reversed[i] = infix[i];
}
ReverseStr(reversed);
// 2. 遍历逆序后的字符串
for (int i = 0; i < strlen(reversed); i++) {
char c = reversed[i];
// 情况1:操作数(数字)
if (isdigit(c)) {
// 处理多位数
while (isdigit(reversed[i])) {
prefix[idx++] = reversed[i++];
}
prefix[idx++] = ' '; // 操作数之间加空格
i--; // 回退一位,避免跳过非数字字符
}
// 情况2:右括号(原左括号)
else if (c == ')') {
OpPush(&s, c);
}
// 情况3:左括号(原右括号)
else if (c == '(') {
char op;
while ((op = OpPop(&s)) != ')') {
prefix[idx++] = op;
prefix[idx++] = ' ';
}
}
// 情况4:运算符
else if (c == '+' || c == '-' || c == '*' || c == '/') {
while (!OpIsEmpty(&s) && GetPriority(OpGetTop(&s)) >= GetPriority(c)) {
prefix[idx++] = OpPop(&s);
prefix[idx++] = ' ';
}
OpPush(&s, c);
}
}
// 3. 弹出栈中剩余运算符
while (!OpIsEmpty(&s)) {
prefix[idx++] = OpPop(&s);
prefix[idx++] = ' ';
}
// 4. 逆序结果,得到前缀表达式
prefix[idx - 1] = '\0'; // 去掉最后一个空格
ReverseStr(prefix);
}
int main(int argc, char *argv[]) {
if (argc != 2) {
printf("请输入正确的参数:./a.out 中缀表达式\n");
return 1;
}
char infix[100] = {0};
strcpy(infix, argv[1]);
char prefix[200] = {0};
InfixToPrefix(infix, prefix);
printf("前缀表达式:%s\n", prefix);
return 0;
}
补充作业:队列的应用
- 作业需求:实现循环队列,完成入队、出队、遍历操作;
- 核心思路:基于数组实现循环队列,利用取模运算解决假溢出,实现判空、判满、入队、出队、遍历等操作;
- 代码框架可参考前面的循环队列定义,补全操作函数,此处不再赘述。
3. 动态库和静态库
库(Library)是模块化、可复用的代码集合,将常用功能封装成库,供多个程序调用,减少重复开发、提高编译效率、保护源码,是工程开发中不可或缺的工具。
a. 库 library
i. 定义:将多个具有相关功能的.c 文件(模块)封装成一个独立的文件(库文件),隐藏源码实现细节,只提供调用接口(头文件),供其他程序复用。ii. 库的分类(补全):
- 静态库(.a 后缀,Windows 下为.lib):编译时融入可执行文件,运行时独立;
- 动态库(.so 后缀,Windows 下为.dll):运行时动态加载,编译时不融入可执行文件;
- 核心区别:编译时是否将库代码拷贝到可执行文件中。iii. 作用(补全细节,拓展更多作用):
- 可供复用:多个程序可共用同一个库,无需重复编写相同功能的代码,提高开发效率;
- 可共享:动态库可被多个程序共享加载到内存,节省内存空间(静态库不可共享);
- 减少编译开销:修改库代码后,动态库无需重新编译依赖它的程序,静态库需重新编译;
- 保护源码:只提供库文件和头文件,不提供.c 源码,避免源码泄露;
- 模块化开发:将大型项目拆分为多个模块,每个模块封装成库,便于团队协作、维护和升级;
- 简化部署:多个程序依赖同一个库时,只需部署一次库文件(动态库),减少部署体积。
b. 动态库
i. 核心定义:运行期间动态加载到内存的库,程序编译时,仅记录库的依赖关系,不将库代码拷贝到可执行文件中;程序启动时,系统会将动态库加载到内存,供程序调用,运行结束后,动态库从内存中释放。ii. 核心特性:编译时不嵌入可执行文件,运行时依赖库文件存在,多个程序可共享同一个动态库。iii. 优势(补全细节,拓展更多优势):
-
目标文件(可执行文件)体积小:仅包含自身代码和库的依赖信息,不包含库代码,节省磁盘空间;
-
可无缝升级:库文件更新后,依赖它的程序无需重新编译,直接运行即可使用新功能(只需保证接口不变);
-
内存共享:多个程序同时运行时,只需加载一次动态库到内存,供所有程序共享,节省内存资源;
-
开发效率高:修改库功能后,无需重新编译所有依赖程序,只需重新编译动态库;
-
灵活部署:可单独部署动态库,根据需求更新或替换库文件,不影响主程序。iv. 缺点(补全细节,拓展更多缺点):
-
移植性差:程序移植到其他机器时,必须确保目标机器上存在对应的动态库(版本一致),否则程序无法运行("缺少.so 文件" 错误);
-
运行依赖:运行时必须依赖动态库文件,若库文件丢失、损坏或版本不兼容,程序会崩溃;
-
加载开销:程序启动时,需要动态加载库文件,比静态库多了一次加载过程,启动速度略慢;
-
接口依赖:若库的接口(函数名、参数、返回值)修改,依赖它的程序必须重新编译,否则会调用失败。v. 创建过程(补全细节、参数说明、常见错误):
-
核心命令:
gcc -fPIC --shared -o libxxxx.so llist.c lstack.c -
参数详解:
-fPIC:生成位置无关代码(Position-Independent Code),确保库文件加载到内存的任意地址都能正常运行,避免地址冲突(动态库必须添加此参数);--shared:指定生成动态库(共享库),告诉编译器将多个.c 文件打包成动态库文件;-o libxxxx.so:指定输出的动态库文件名,必须以 "lib" 开头,以 ".so" 结尾(系统默认识别此格式),xxxx 为自定义库名(如 libmystack.so);llist.c lstack.c:需要打包成动态库的.c 文件(多个文件用空格分隔),可包含多个模块。
-
常见错误:
- 忘记加
-fPIC:生成的动态库无法正常加载,提示 "不是位置无关代码"; - 文件名不符合规范(未以 lib 开头、.so 结尾):链接库时无法找到库文件;
- 多个.c 文件未全部写入:库中缺少对应的函数,调用时提示 "未定义的引用"。vi. 链接库(编译主程序时链接动态库,补全细节、参数说明):
- 忘记加
-
核心命令:
gcc -o a.out main.c -lxxxx -Lpath -
参数详解:
-o a.out:指定生成的可执行文件名(自定义,如 main、test 等);main.c:主程序的.c 文件(依赖动态库的程序代码);-lxxxx:指定链接的动态库名,去掉 "lib" 和 ".so"(如链接 libmystack.so,只需写 - lmystack);-Lpath:指定动态库所在的路径(path 为库文件的绝对路径或相对路径);- 若动态库和主程序在同一目录,可写
-L./(./ 表示当前目录); - 若不指定 - L,系统会默认去 /usr/lib、/lib 等系统库路径查找,找不到则链接失败。
- 若动态库和主程序在同一目录,可写
-
常见错误:
-lxxxx写错库名:提示 "无法找到 - lxxxx";- 未指定 - Lpath,库不在系统路径:提示 "无法找到库文件";
- 库文件权限不足:无法读取库文件,修改权限(chmod 755 libxxxx.so)。vii. 查看目标文件链接的所有库(补全细节、用法):
-
核心命令:
ldd a.out -
作用:查看可执行文件 a.out 所依赖的所有动态库,包括系统库和自定义动态库;
-
输出解读:
-
若显示 "libxxxx.so => 库路径":表示找到对应的动态库,可正常加载;
-
若显示 "libxxxx.so => not found":表示未找到动态库,程序无法运行,需检查库路径或环境变量。viii. 指定环境变量(让系统找到自定义动态库,补全两种方式、细节):动态库链接成功后,运行可执行文件时,系统会默认去系统路径查找动态库,自定义动态库需配置环境变量,两种常用方式:方式 1:临时环境变量(仅当前终端有效,重启终端失效)
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:你的动态库所在路径
-
方式 2:永久环境变量(补全细节,推荐)
-
编辑.bashrc 文件:
vim ~/.bashrc -
在文件末尾添加:
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/xxx/mylib # 替换为你的动态库路径 -
保存退出(vim 中按 Esc,输入:wq);ix. 更新环境变量配置(让配置立即生效,补全):
source ~/.bashrc
-
作用:无需重启终端,立即加载新的环境变量配置;
-
若未执行此命令,环境变量配置不会生效,系统仍找不到动态库。x. 加载最新配置,分配地址(补全细节、作用):
sudo ldconfig
-
作用:更新系统动态库缓存,让系统识别新添加的动态库,分配内存地址,避免 "库文件存在但无法加载" 的问题;
-
注意:必须加 sudo(管理员权限),否则无法更新系统缓存;
-
补充:若配置环境变量后,运行程序仍提示 "找不到动态库",执行此命令即可解决。xi. 补充:动态库的运行测试(补全)配置完成后,运行可执行文件,测试是否能正常调用动态库:
./a.out
-
若正常输出结果,说明动态库创建、链接、配置成功;
-
若提示 "缺少.so 文件",检查环境变量、库路径、ldconfig 命令是否执行。
c. 静态库
i. 核心定义:编译时直接将库代码拷贝到目标文件(可执行文件)中的库,程序运行时,不依赖任何外部库文件,完全独立,可直接运行。ii. 核心特性:编译时嵌入可执行文件,运行时与库文件完全分离,不依赖库文件存在。iii. 优势(补全细节,拓展更多优势):
-
移植性更强:可执行文件包含了所有需要的库代码,拿到可执行文件后,无需安装任何库,直接在任意支持的机器上运行;
-
运行速度快:无需在运行时动态加载库文件,减少加载开销,运行效率比动态库略高;
-
无依赖风险:运行时不依赖任何外部库文件,不会出现 "缺少库文件""库版本不兼容" 的问题;
-
接口稳定:库代码直接嵌入可执行文件,即使库文件被修改或删除,也不影响可执行文件的运行;
-
适合小型程序:小型程序使用静态库,可避免动态库的加载开销,简化部署。iv. 缺点(补全细节,拓展更多缺点):
-
目标文件(可执行文件)体积大:包含了自身代码和所有库代码,比动态库链接的可执行文件大很多,占用更多磁盘空间;
-
无法无缝升级:若静态库升级(修改功能、修复 bug),依赖它的所有程序必须重新编译(重新将新的库代码拷贝到可执行文件中),否则无法使用新功能;
-
内存浪费:多个程序使用同一个静态库时,每个程序的可执行文件中都会包含一份库代码,导致多份拷贝,浪费内存资源;
-
编译效率低:修改静态库后,所有依赖它的程序都需要重新编译,大型项目中会大幅降低开发效率。v. 生成步骤(补全细节、参数说明、常见错误):静态库生成分为两步:生成.o 目标文件 → 打包成静态库,步骤固定,必须严格执行。
-
第一步:生成.o 目标文件(将.c 文件编译为二进制目标文件)
gcc -c -o xxx.o xxxx.c- 参数详解:
-
-c:只编译,不链接,生成.o 目标文件(不生成可执行文件); -
-o xxx.o:指定生成的.o 文件名,与.c 文件名对应(如 llist.c → llist.o); -
多个.c 文件需分别生成.o 文件,如:
gcc -c -o llist.o llist.c gcc -c -o lstack.o lstack.c
-
- 参数详解:
-
第二步:将.o 文件打包成静态库
ar -cr libxxxx.a xxx.o yyy.o- 参数详解:
ar:打包命令,用于将多个.o 文件打包成静态库;-c:创建静态库文件(若库文件不存在,自动创建;若存在,覆盖);-r:将.o 文件添加到静态库中,若库中已有同名.o 文件,替换为新的;libxxxx.a:静态库文件名,必须以 "lib" 开头,以 ".a" 结尾(系统默认识别格式),xxxx 为自定义库名;xxx.o yyy.o:需要打包的.o 文件(多个文件用空格分隔)。
- 参数详解:
-
常见错误:
- 忘记执行第一步(生成.o 文件):直接打包,提示 "无.o 文件";
- 文件名不符合规范(未以 lib 开头、.a 结尾):链接时无法找到库文件;
- 遗漏.o 文件:库中缺少对应的函数,调用时提示 "未定义的引用"。vi. 补充:静态库的链接(补全,与动态库对比)静态库的链接命令与动态库类似,无需配置环境变量(因为库代码已嵌入可执行文件):
-
说明:-lxxxx(去掉 lib 和.a)、-Lpath(指定静态库路径)的用法与动态库一致;
-
区别:静态库链接后,可执行文件不依赖任何库文件,直接运行即可,无需配置环境变量、无需执行 ldconfig。
gcc -o a.out main.c -lxxxx -Lpath
-
d. 动态库与静态库的核心对比
| 对比维度 | 动态库(.so) | 静态库(.a) |
|---|---|---|
| 编译方式 | 不拷贝库代码,仅记录依赖 | 拷贝库代码到可执行文件 |
| 运行依赖 | 依赖动态库文件存在 | 不依赖任何库文件 |
| 可执行文件体积 | 小 | 大 |
| 移植性 | 差(需携带库文件) | 强(可直接运行) |
| 升级方式 | 无缝升级,无需重新编译程序 | 需重新编译程序 |
| 内存占用 | 多个程序共享,节省内存 | 多个程序多份拷贝,浪费内存 |
| 运行速度 | 略慢(需动态加载) | 略快(无加载开销) |
| 编译效率 | 高(修改库无需重新编译程序) | 低(修改库需重新编译程序) |
e. 头文件
头文件(.h)是库的 "接口说明文件",包含函数声明、宏定义、结构体定义等,主程序通过包含头文件,才能调用库中的函数,头文件的正确包含是库调用成功的前提。
i. 自己编写的头文件(非系统头文件)如何包含?(补全 3 种方式,细节拉满)
-
方式 1:直接写头文件路径(相对路径 / 绝对路径,最常用、最安全,推荐)
-
语法:
#include "./head.h" // 相对路径,头文件在当前目录 #include "../include/head.h" // 相对路径,头文件在上一级目录的include文件夹 #include "/home/xxx/include/head.h" // 绝对路径,头文件在指定绝对路径 -
优势:无需配置环境变量,直接指定路径,避免与系统头文件冲突;
-
注意事项:路径必须正确,否则编译器无法找到头文件,提示 "没有那个文件或目录"。
-
-
方式 2:使用 #include <头文件名>(系统默认查找路径,补全细节、不推荐原因)
-
语法:
#include <head.h> -
原理:编译器会默认去系统头文件路径查找头文件,系统默认路径包括:
- /usr/include(系统标准头文件路径);
- /usr/local/include(第三方库头文件路径);
-
实现方式:a. 方式一(不鼓励,不推荐):将自己的头文件拷贝到系统头文件路径(如 /usr/include),命令:
sudo cp head.h /usr/include- 弊端:① 容易覆盖系统同名头文件,导致系统程序出错;② 只有 root 用户有权限操作;③ 头文件更新后,需重新拷贝,维护麻烦。b. 方式二(推荐,补全):配置环境变量 C_INCLUDE_PATH,让编译器在指定路径中查找头文件(无需拷贝到系统目录)。
-
-
方式 3:配置环境变量 C_INCLUDE_PATH(推荐,补全细节、步骤)
-
作用:指定头文件的查找路径,编译器会在环境变量指定的路径中,查找 #include <头文件名> 包含的头文件;
-
操作步骤:
-
编辑.bashrc 文件(永久生效):
vim ~/.bashrc -
在文件末尾添加(替换为自己的头文件路径):
export C_INCLUDE_PATH=$C_INCLUDE_PATH:/home/xxx/include- 说明:$C_INCLUDE_PATH 表示保留原有环境变量,冒号后面添加新的头文件路径,多个路径用冒号分隔;
-
保存退出,更新环境变量:
source ~/.bashrc
-
-
优势:无需拷贝头文件到系统目录,不破坏系统文件,头文件更新后无需重新配置,多个头文件路径可灵活添加。
-
ii. 头文件包含的常见错误(补全,避免踩坑)
-
路径错误:#include "./head.h" 中路径写错(如多写 / 少写目录、文件名拼写错误),提示 "没有那个文件或目录";
-
混淆 #include "" 和 #include <>:将自己的头文件用 #include <> 包含,且未配置环境变量,导致编译器无法找到;
-
头文件重复包含:多个.c 文件或头文件中重复包含同一个头文件,导致 "重复定义" 错误;
-
解决方法:在头文件中添加 "防止重复包含" 的宏,如:
#ifndef HEAD_H #define HEAD_H // 头文件内容(函数声明、结构体定义等) #endif
-
-
头文件与库文件不匹配:头文件中的函数声明与库文件中的函数实现不一致(如参数个数、返回值类型不同),导致 "未定义的引用" 或运行错误;
-
环境变量配置错误:配置 C_INCLUDE_PATH 时,路径写错,或未执行 source ~/.bashrc,导致编译器无法找到头文件。
4. Makefile
Makefile 是管理大型项目的自动化编译脚本,通过定义 "目标 - 依赖 - 规则",自动判断哪些文件被修改过,只编译修改的文件,避免重复编译,大幅提高编译效率,是 C/C++ 工程开发的必备工具。
a. make 命令(补全细节、用法)
i. make main:执行 Makefile 中,以 main 为目标的编译规则(若 Makefile 中无 main 目标,会执行第一个目标);ii. make :默认执行 Makefile 中的第一个目标(通常是可执行文件目标,如 a.out、main);iii. make clean:执行 Makefile 中定义的 clean 目标,用于清理编译生成的.o 文件、可执行文件等,避免冗余文件;iv. make -f 文件名:若 Makefile 文件名不是默认名称(如 makefile1、myMakefile),用此命令指定要执行的 Makefile 文件,如:
make -f myMakefile
ii. makefile 的默认规则(补全,必懂)
- 默认规则 1:若未指定目标,make 会自动执行 Makefile 中的第一个目标;
- 默认规则 2:若目标文件不存在,或目标文件的修改时间晚于依赖文件,make 会执行规则命令,重新生成目标;
- 默认规则 3:若依赖文件不存在,make 会自动查找是否有以依赖文件为目标的规则,递归生成依赖文件;
- 默认规则 4:默认的编译命令(若未指定规则命令):对于.c 文件,默认执行
gcc -c -o 目标.o 依赖.c,生成.o 文件;对于.o 文件,默认执行gcc -o 目标 依赖.o,生成可执行文件。
b. Makefile 是什么?
- 定义:Makefile 是一个文本格式的自动化编译脚本,包含一系列 "目标 - 依赖 - 规则",用于描述项目的编译流程、文件依赖关系,告诉 make 命令如何编译、链接项目。
- 核心作用:
- 自动化编译:无需手动输入冗长的 gcc 命令,只需输入 make,即可自动完成编译、链接,生成可执行文件;
- 增量编译:自动判断哪些文件被修改过,只编译修改的文件,未修改的文件不重新编译,节省编译时间(大型项目尤为重要);
- 管理项目结构:将大型项目的多个模块、多个文件的编译规则集中管理,便于维护、修改和团队协作;
- 简化命令:可将复杂的编译命令、清理命令、测试命令等,封装到 Makefile 中,只需输入简单的 make 命令即可执行。
- 优势:
- 高效:增量编译,避免重复编译;
- 灵活:可自定义编译规则、目标,适配不同项目需求;
- 可维护:集中管理编译规则,修改时只需修改 Makefile,无需修改每个编译命令;
- 跨平台:可在 Linux、Unix、Windows 等系统中使用(Windows 需安装 make 工具)。
c. 文件名
i. make 识别的文件名(三种均可):
Makefile:最规范、最常用,推荐使用(首字母大写,区分于普通文件);makefile:小写,make 也能识别,但不规范;MAKEFILE:全大写,make 能识别,但不常用;ii. 注意事项:- 若文件名不是以上三种,make 会提示 "没有规则可制作目标",需用 make -f 文件名指定;
- 一个项目中通常只有一个 Makefile,放在项目根目录下,统一管理所有编译规则。
d. 语法
i. 核心思想:倒推方式(依赖倒推)
- 倒推逻辑:要生成最终的目标文件(如可执行文件 a.out),需要先生成哪些依赖文件(如.o 文件);要生成.o 文件,需要先有对应的.c 文件和头文件;以此类推,直到找到最基础的文件(.c、.h),然后按顺序执行编译命令,生成目标文件。
ii. 核心语法格式:
目标文件: 依赖文件1 依赖文件2 ...
规则命令(必须以Tab键开头,不能用空格)
- 目标文件(target):要生成的文件(如 a.out、main.o、libxxx.a),也可以是 "伪目标"(如 clean,不生成文件,仅执行命令);
- 依赖文件(prerequisites):生成目标文件所需要的文件(如.c、.h、.o 文件),若依赖文件不存在或被修改,会先生成依赖文件;
- 规则命令(commands):生成目标文件的具体命令(如 gcc 编译命令),必须以 Tab 键开头(这是 Makefile 语法的硬性要求,用空格会报错);
- 注释:用 #开头,注释内容不会被 make 执行,用于说明规则、标注注释。
-
规则详解:
- 规则逻辑:如果 "依赖文件的修改时间晚于目标文件",或者 "目标文件不存在",make 会执行规则命令,重新生成目标文件;
- 若依赖文件不存在,make 会自动查找 Makefile 中,以该依赖文件为目标的规则,递归生成依赖文件;
- 一个目标可以有多个依赖文件,一个依赖文件可以被多个目标使用;
- 一个目标可以有多个规则命令,每个命令占一行,均以 Tab 开头,按顺序执行。
-
伪目标:
-
定义:不生成具体文件,仅用于执行特定命令(如清理文件、测试程序)的目标,通常用.PHONY 声明,避免与同名文件冲突;
-
常用伪目标:clean(清理编译产物)、test(测试程序)、all(生成所有目标);
-
示例(clean 伪目标):
.PHONY: clean # 声明clean为伪目标,避免与clean文件冲突 clean:
-