01 请说明 C 语言中指针和数组的核心区别,以及数组名在哪些场景会退化为指针?
(1)总述
- 数组 :相同类型元素连续存放的集合;数组名是数组类型的不可修改左值 ,标识整块内存,自身不额外占存储,不能赋值、不能
arr++。 - 指针 :存储内存地址的独立变量,自身在 x86 下占 4 字节、x64 下占 8 字节,可随意修改指向。
(2)四个核心区别
sizeof不同 :sizeof(数组名)得到整个数组的字节数(N × sizeof(T));sizeof(指针)只得到指针自身大小。&取地址类型不同 :&arr是指向整个数组的指针T(*)[N],加减 1 移动整个数组长度;&p是二级指针T**。- 可修改性不同 :数组名不可赋值、不可自增;指针可以
p++、p = ...。 - 函数传参不同 :形参
T arr[]会被编译器改写为T* arr,数组传参必然退化,函数内无法用sizeof求原数组大小。
(3)退化规则
数组表达式求值时默认退化为指向首元素的指针,仅四种情况例外:
- 作为
sizeof的操作数; - 作为取地址运算符
&的操作数; - 作为
_Alignof的操作数(C11); - 字符串字面值初始化字符数组 ,如
char s[] = "abc";。
其余场景(表达式求值、算术运算、函数传参)全部退化。注意 char *p = "abc"; 中的字面值会退化,p 指向只读静态区。
int arr[10];
int *p = arr;
sizeof(arr); // 40:整个数组
sizeof(p); // 8(x64):指针自身
&arr; // 类型 int(*)[10]
arr++; // 编译错误:数组名不可修改
p++; // 合法:移动一个 int
02 结构体和联合体的内存布局有什么差异?各自的典型使用场景是什么?
(1)结构体(struct):成员各自独立、按序排列
- 每个成员拥有独立存储空间,按声明顺序排列;
- 成员的有效对齐数 = min(类型自然对齐数, 编译器指定对齐数(
#pragma pack)); - 成员偏移量必须是其有效对齐数的整数倍,不满足则插入填充字节;
- 结构体总大小必须是最大有效对齐数的整数倍,尾部也可能填充。
(2)联合体(union):成员共享内存、同时只有一个有效
-
所有成员共享同一块从偏移 0 开始的内存;
-
写入一个成员会覆盖其他成员,任一时刻只有一个成员有效;
-
总大小 = 最大成员大小按最大对齐要求向上取。
struct S {
char c; // 偏移 0
int i; // 偏移 4(1~3 填充)
char d; // 偏移 8,尾部填充
}; // sizeof(struct S) = 12union U {
char c[9]; // 最大成员 9 字节
int i; // 对齐要求 4
}; // 9 向上取整为 4 的倍数 → 12
(3)典型使用场景
- 结构体:组织需要同时存在的多个属性,如学生信息、网络协议首部、系统调用参数。
- 联合体 :
- 配合枚举实现带标签联合体(tagged union),互斥字段、节省内存、手动多态;
- 协议解析 / 硬件寄存器视图,同一组字节按字节、半字、字访问;
- 类型双关(type punning),如判断大小端、查看浮点数位表示(属实现定义行为,GCC 支持)。
03 快速排序的复杂度、最坏情况与优化
问题:快速排序的平均、最好、最坏时间复杂度分别是多少?什么场景下出现最坏情况?常用优化手段有哪些?
(1)复杂度
- 最好 / 平均:O(n log n)------每次划分接近均分,递归深度 log n,每层比较总量 O(n);
- 最坏:O(n²)------每次划分极度不平衡(0 个与 n−1 个),递归深度退化为 n,比较总量 n + (n−1) + ... + 1。
- 补充:空间复杂度平均 O(log n) 、最坏 O(n) ;快排不稳定。
(2)最坏情况的触发场景
每轮选取的基准值(pivot/key)都是区间极值。
最典型的情况是:对已经有序或完全逆序的数组,固定选取首元素(或尾元素)作为基准。
(3)六种常用优化手段
- 随机化基准值:使最坏情况无法被特定输入稳定触发;
- 三数取中(median-of-three):取首、中、尾的中位数,避免选到极值;
- 三路划分(Dutch National Flag) :分为
<key、=key、>key三段,重复元素多时显著减少递归; - 小区间切换插入排序:分区长度小于阈值(常取 5~20)时改用插入排序,常数因子更小;
- 尾递归优化:只递归小分区、大分区改迭代,栈深压缩到 O(log n);
- Introsort 兜底:递归深度超过 2 log n 时切换堆排序,保证最坏 O(n log n)
04 请说明进程和线程的本质区别,Linux 下常用的进程间通信(IPC)方式有哪些,各自的优缺点是什么?
(1)本质区别
- 进程是资源分配的基本单位:拥有独立地址空间、页表、文件描述符表,进程间相互隔离;
- 线程是 CPU 调度的基本单位 :一个进程至少含一个主线程;线程共享进程的代码、堆、全局变量、fd 表和信号处理函数,仅私有栈、寄存器上下文、线程 ID、
errno、信号掩码和 TLS。
其他差异:
- 切换开销:进程切换要换页表、刷 TLB,开销大;同进程线程切换不换地址空间,只保存 PC、SP 和寄存器,开销小;
- 健壮性:进程间独立,一个崩溃不影响他人;一个线程崩溃通常拖垮整个进程;
- 通信方式:进程间必须用 IPC;线程间直接读写共享变量,但需互斥锁、条件变量同步。
(2)七种 IPC 方式及优缺点
- 匿名管道 pipe :内核环形缓冲区,半双工,靠
fork继承 fd,用于亲缘进程。- 优点:简单、字节流可靠、内核自动同步;
- 缺点:半双工、仅限亲缘进程、数据无格式、缓冲区有限(约 64KB)。
- 命名管道 FIFO :
mkfifo创建管道文件,任意本地进程可打开。- 优点:无亲缘要求、使用简单;
- 缺点:半双工、不能跨主机、打开需读写双方同时在场、有文件创建删除开销。
- 信号 signal :异步事件通知。
- 优点:开销极小、可异步触发;
- 缺点:不能传数据(实时信号可携带少量数据)、会中断系统调用。
- 消息队列(System V / POSIX) :内核维护的消息链表,传递结构化消息。
- 优点:按类型读取、支持优先级、自带同步、无亲缘要求;
- 缺点:有内核拷贝、消息大小和数量受限、仅本地。
- 共享内存(System V / POSIX /
mmap) :多进程映射同一物理页,适合大量数据。- 优点:速度最快、无内核拷贝;
- 缺点:无同步机制,必须配合信号量/锁;仅本地;崩溃易残留脏数据。
- 信号量 semaphore:内核计数器,属同步机制而非数据传输,常与共享内存搭配。
- 套接字 socket :网络套接字可跨主机,
AF_UNIX用于本地。- 优点:全双工、可跨网络、协议标准、Unix 域套接字可传递 fd;
- 缺点:有协议栈开销,本地速度不如共享内存。
选型口诀:大数据量 → 共享内存 + 信号量;结构化小消息 → 消息队列;异步通知 → 信号;跨主机 → socket。
05 什么是文件描述符(fd)?请对比 select、poll、epoll 三种 IO 多路复用机制的实现差异和使用场景。
(1)文件描述符
fd 是每个进程私有的、索引内核 I/O 对象的非负整数句柄。
文件、目录、管道、socket、设备等一切 I/O 对象在内核中统一抽象为 struct file,fd 即进程文件描述符表的下标;它是进程级概念,fork 时被子进程继承。默认 0 / 1 / 2 分别为标准输入、标准输出、标准错误。
(2)select
- 基于
fd_set位图,有 1024(FD_SETSIZE)硬上限; - 每次调用全量拷贝到内核并线性遍历 O(n);
- 返回时
fd_set被内核改写 ,下次调用前必须重新FD_SET;用户态仍需 O(n) 遍历; - 仅支持水平触发 LT。
(3)poll
- 基于
struct pollfd数组,无 1024 上限(受ulimit -n和内存限制); - 每次仍需全量拷贝、线性遍历,O(n);
events/revents输入输出分离,数组可复用,无需每次重设;- 仅支持水平触发 LT。
(4)epoll(Linux 专有)
- 三个接口分工:
epoll_create1()创建实例 →epoll_ctl()增删改 fd →epoll_wait()等待就绪; - 内核用红黑树 管理注册 fd(只注册一次,无需每次全量拷贝),用就绪链表收集就绪 fd,并在设备等待队列上注册回调;
- fd 就绪 → 回调把表项挂入就绪链表并唤醒进程 →
epoll_wait直接返回就绪项,复杂度 O(k)(k 为就绪数),与监听总数无关; - 支持 LT(默认)和 ET ,以及
EPOLLONESHOT;ET 必须搭配非阻塞 fd,并循环读到EAGAIN,否则丢数据。
(5)使用场景
- select:fd 数量少(<1024)、要求高可移植性(含 Windows);
- poll:数量中等、需突破 1024 限制且要求 POSIX 跨平台;
- epoll:Linux 高并发长连接(C10K / C100K),如 Nginx、Redis、高性能网络库------连接总数大、每次活跃数少时优势最大。