最近复盘了几道很典型的面试题。它们表面上分属数据结构、操作系统、C++ 基础,但本质上考的是同一件事:你能不能把“概念、实现、边界、场景”串成一条完整链路讲清楚。
我发现,很多时候不是不会,而是回答太散。
所以这篇我不只是写“答案”,而是按“面试官想听什么 → 我应该怎么回答 → 容易被追问什么”的顺序,把这几题整理成一份可复用的复盘稿。
1. LRU Cache 怎么实现?
这题面试官想考什么
这题表面上在考缓存淘汰策略,实际上主要考三件事:
第一,你知不知道 LRU 的语义:当容量满了以后,淘汰“最近最少使用”的元素。
第二,你能不能把复杂度从朴素实现优化到 get / put 都尽量 O(1) 。
第三,你会不会选对容器,而不是只会背“哈希表 + 链表”这六个字。经典设计之所以成立,是因为 std::unordered_map 支持平均常数时间查找,而 std::list 支持任意位置常数时间插入删除,并且除被删元素外不会使其他迭代器失效。(cppreference)
面试时最好怎么答
我一般会这样答:
LRU 的核心目标是:
在容量有限的情况下,优先保留最近访问过的数据,淘汰最久没被访问的数据。
如果要做到get和put都接近 O(1),经典做法是 哈希表 + 双向链表。
哈希表负责 O(1) 定位 key,双向链表负责 O(1) 地把某个节点移动到“最近使用”的位置,以及 O(1) 删除最久未使用的尾节点。
在 C++ 里常见实现就是unordered_map + list。unordered_map查找平均 O(1),list支持 O(1) 插入删除,而且链表节点的迭代器很稳定,所以非常适合做这个题。(cppreference)
更完整一点的展开回答
如果 key 存在:
-
get(key):返回值,同时把这个节点移动到链表头部,表示“刚刚被访问过” -
put(key, value):-
如果 key 已存在,就更新值,并移动到头部
-
如果 key 不存在:
- 容量没满:直接插入头部
- 容量已满:删除尾部节点,再插入新节点到头部
-
这里链表头表示“最近使用”,链表尾表示“最久未使用”。
为什么不是只用数组、vector、或者单链表?
因为你不仅要“找到它”,还要“快速移动它”。
- 只用数组 /
vector:找到元素后,移动位置要搬运很多元素,不是 O(1) - 只用链表:能快删快插,但找 key 要 O(n)
- 单链表:删除任意节点通常还得找到前驱,不方便
- 所以最合适的是:哈希表定位 + 双向链表移动
可以直接背的精简版回答
LRU Cache 我会用
unordered_map + doubly linked list实现。
unordered_map负责 key 到节点的 O(1) 定位,双向链表维护访问顺序,头部是最近使用,尾部是最久未使用。
每次get或put命中都把节点挪到头部;容量满了就淘汰尾部。
这样get和put平均都能做到 O(1)。(cppreference)
C++ 版本示意代码
#include <list>
#include <unordered_map>
#include <utility>
class LRUCache {
public:
explicit LRUCache(int capacity) : capacity_(capacity) {}
int get(int key) {
auto it = pos_.find(key);
if (it == pos_.end()) {
return -1;
}
move_to_front(it->second);
return it->second->second;
}
void put(int key, int value) {
auto it = pos_.find(key);
if (it != pos_.end()) {
it->second->second = value;
move_to_front(it->second);
return;
}
if (static_cast<int>(cache_.size()) == capacity_) {
int old_key = cache_.back().first;
pos_.erase(old_key);
cache_.pop_back();
}
cache_.push_front({key, value});
pos_[key] = cache_.begin();
}
private:
using Node = std::pair<int, int>;
using ListIt = std::list<Node>::iterator;
void move_to_front(ListIt it) {
cache_.splice(cache_.begin(), cache_, it);
}
int capacity_;
std::list<Node> cache_;
std::unordered_map<int, ListIt> pos_;
};
这题常见追问
追问 1:为什么 map 里存的是链表迭代器?
因为我要先通过 key 快速找到节点,然后再 O(1) 移动节点。
如果 map 里不存链表位置,而只存 value,那你还是得去链表里找那个节点,就退化了。
追问 2:为什么链表要用双向,而不是单向?
因为 LRU 淘汰尾节点、移动命中节点到头部时,都希望 O(1)。
单链表删除任意节点通常需要前驱,双向链表更自然。
追问 3:复杂度是多少?
get:平均 O(1)put:平均 O(1)- 空间复杂度:O(capacity)
这题最容易答崩的点
最容易崩的地方有三个:
- 只会说“哈希表+链表”,但说不清各自负责什么
- 忘了说明“访问命中也要更新最近使用顺序”
- 代码里删尾节点后,忘记同步删除 map 里的 key
2. 判断一棵二叉树是否平衡,怎么做?
这里我先提醒一句:
如果面试官原话真的是“证明二叉树完全平衡”,你最好先确认一下他到底指的是:
- 完全二叉树
还是 - 平衡二叉树 / 高度平衡树
因为这是两个不同概念。
通常面试里更高频的是第二个:判断一棵树是不是高度平衡。AVL 的平衡条件就是:对每个节点,左右子树高度差的绝对值不超过 1。
面试时最好怎么答
如果题目是“判断一棵二叉树是否为平衡树”,我会用 后序遍历。
因为一个节点是否平衡,取决于它左右子树是否平衡,以及左右子树高度差是否不超过 1。
所以最自然的方式是先算左右子树,再回到当前节点做判断。
我会让递归函数返回“当前子树高度”;一旦发现某个子树不平衡,就返回一个特殊值,比如-1,一路向上传播。
这样每个节点只访问一次,时间复杂度 O(n)。AVL 的平衡定义本身就是每个节点左右子树高度差绝对值至多为 1。
为什么后序遍历最合适?
因为你判断当前节点是否平衡,需要先知道:
- 左子树高多少
- 右子树高多少
- 左右子树本身是不是已经失衡
这就是典型的“子问题先解决,再处理当前节点”,所以是后序。
两种思路对比
思路 1:朴素写法
对每个节点:
- 分别求左子树高度
- 分别求右子树高度
- 检查差值是否超过 1
- 递归检查左右子树
问题在于:高度会被重复计算很多次。
最坏情况下会接近 O(n²)。
思路 2:优化写法
递归函数同时承担两件事:
- 如果子树平衡,返回它的高度
- 如果子树不平衡,直接返回
-1
这样每个节点只处理一次,复杂度就是 O(n)。
C++ 版本示意代码
struct TreeNode {
int val;
TreeNode* left;
TreeNode* right;
};
class Solution {
public:
bool IsBalanced(TreeNode* root) {
return HeightOrUnbalanced(root) != -1;
}
private:
int HeightOrUnbalanced(TreeNode* node) {
if (node == nullptr) {
return 0;
}
int left_height = HeightOrUnbalanced(node->left);
if (left_height == -1) {
return -1;
}
int right_height = HeightOrUnbalanced(node->right);
if (right_height == -1) {
return -1;
}
if (left_height - right_height > 1 || right_height - left_height > 1) {
return -1;
}
return (left_height > right_height ? left_height : right_height) + 1;
}
};
面试时可以顺带补一句“为什么这个算法是对的”
这个地方很多人会卡住。其实你不用讲形式化证明,讲 正确性思路 就够了:
这个递归函数的语义很清楚:
对任意子树,它要么返回真实高度,要么返回-1表示该子树已经失衡。
对叶子和空树,结论显然成立;
对任意非空节点,只要左右子树都平衡,并且高度差不超过 1,那么当前子树就平衡,高度等于max(left, right) + 1;
否则当前子树不平衡,返回-1。
所以这个递归定义和“平衡树”的定义是一一对应的。
这就是很标准、很稳的“证明思路”。
精简背诵版
我会用后序遍历。
因为判断当前节点是否平衡,依赖左右子树的高度和它们本身是否平衡。
所以递归函数设计成:
平衡就返回高度,不平衡就返回-1。
这样每个节点访问一次,时间复杂度 O(n),空间复杂度是递归栈 O(h)。AVL 的平衡条件就是每个节点左右子树高度差绝对值不超过 1。
这题常见追问
追问 1:为什么不用前序遍历?
因为前序先访问当前节点,但当前节点的平衡性依赖左右子树高度,信息还没准备好。
追问 2:空间复杂度是多少?
递归栈深度是树高 O(h)。
最坏退化链表是 O(n),平衡树时是 O(log n)。
追问 3:如果面试官问“完全二叉树”怎么办?
那就是另一题了。
完全二叉树常见做法是层序遍历:一旦遇到空节点,后面就不应该再出现非空节点。
3. 操作系统里,系统调用的机制是什么?
这题面试官真正想听什么
这题不是让你背“用户态切内核态”这七个字,而是要看你能不能把链路说完整:
用户代码 → libc 封装 → 触发陷入 → CPU 切到内核态 → 内核根据系统调用号分发 → 执行内核服务 → 返回用户态
Linux 手册把 system call 定义为应用程序与内核之间的基本接口,并指出程序通常不是直接调用系统调用,而是通过 glibc 的 wrapper function 来完成;syscall() 这个库函数则允许你按系统调用号直接发起调用。x86-64 上系统调用使用 syscall 指令,系统调用号和参数分别放在约定寄存器里。(man7.org)
面试时最好怎么答
系统调用本质上是用户程序向内核申请服务的一种受控入口。
因为很多操作,比如文件 IO、进程管理、内存映射、网络通信,都涉及受保护资源,用户态代码不能直接做,必须通过系统调用进入内核。
在 Linux 里,应用程序通常先调用 glibc 的封装函数,比如read()、write();这些封装函数会把系统调用号和参数放到指定寄存器里,然后执行特殊指令触发陷入。
进入内核后,CPU 会切到内核态,走内核的系统调用入口代码;内核再根据系统调用号找到对应处理函数,执行完成后把返回值放回寄存器,再返回用户态。
glibc 封装层还会把内核返回的负错误码转换成用户态熟悉的-1 + errno形式。(man7.org)
精简背诵版
系统调用是用户程序访问内核服务的受控入口。
Linux 下通常不是直接发系统调用,而是先调 glibc 包装函数;包装函数把系统调用号和参数放到约定寄存器,执行syscall指令进入内核。
内核根据系统调用号分发到对应处理函数,执行完成后把结果返回;glibc 再把错误码转成errno这种用户态接口。x86-64 下参数寄存器一般是rdi, rsi, rdx, r10, r8, r9,返回值在rax。(man7.org)
这题常见追问
追问 1:为什么不能直接在用户态执行这些操作?
因为像访问磁盘、修改页表、调度进程、操作网卡这类行为属于特权操作,必须由内核统一管理。
追问 2:系统调用和普通函数调用有什么区别?
普通函数调用仍然在用户态,不切权限级;
系统调用会通过特殊机制切到内核态,由内核执行受保护服务。
追问 3:为什么系统调用比普通函数慢?
因为它涉及权限级切换、入口/返回处理、寄存器和上下文管理,成本比普通函数调用大得多。
4. 指针和引用有什么区别?分别适合什么场景?
这题最容易答成“背书题”
很多人一上来就说:
- 指针可以为空,引用不能为空
- 指针可以改指向,引用不能改绑定
这两句没错,但太像书本答案。
面试里更好的答法是:先讲语义差异,再讲工程使用场景。
C++ 参考资料把 reference 定义为“对已存在对象或函数的别名”;而 pointer 具有独立的指针值语义,可以是空指针,也可能是无效指针。空指针是指针类型的正式一等状态,常被用来表示“没有对象”或“可选对象不存在”。(cppreference)
面试时最好怎么答
我会把指针和引用的差异分成“语义层”和“使用层”两部分。
语义上,引用更像某个对象的别名,定义时就必须绑定到一个对象;而指针本身是一个独立值,保存的是地址,所以它可以为空,也可以 later 指向别的对象。
使用上,如果一个参数语义上“必须存在”,我更倾向用引用;如果它是“可选的”“可能没有”“需要表达空值状态”,我更倾向用指针。cppreference 对 reference 的描述就是已有对象/函数的别名,而 pointer 明确支持 null pointer value。(cppreference)
可以这样继续展开
1)可空性不同
- 指针可以是
nullptr - 引用一般不拿来表达“空”
所以:
- “这个对象可能不存在” → 用指针更自然
- “这个参数一定要有” → 用引用更自然
2)是否可重新绑定
- 指针可以改指向
- 引用一旦绑定,之后就始终是那个对象的别名
例如:
int a = 1;
int b = 2;
int* p = &a;
p = &b; // 可以
int& r = a;
// r = b; // 这不是改绑定,而是把 b 的值赋给 a
3)语法使用体验不同
- 指针访问对象常要
*、-> - 引用用起来像对象本身
所以引用更适合函数参数,让接口更自然。
具体场景怎么选?
这是面试里最加分的部分。
场景 1:函数参数必传,而且不想拷贝
用引用,尤其是 const T&
void Print(const std::string& s);
原因:调用方语义清晰,函数内部也不用判空。
场景 2:参数可能为空,是可选对象
用指针
void Print(const std::string* s) {
if (s == nullptr) {
return;
}
}
因为这里“没有字符串”本身就是一个合法状态。
场景 3:需要修改实参
两者都能做到,但如果“必须有值”,优先引用:
void Increment(int& x) {
++x;
}
如果“可能不传”,就用指针:
void Increment(int* x) {
if (x != nullptr) {
++(*x);
}
}
场景 4:表达所有权、动态对象、资源管理
现代 C++ 里通常不会直接用裸指针表达所有权,而是用智能指针:
std::unique_ptrstd::shared_ptr
裸指针更多用于:
- 非 owning 观察者
- 可空参数
- 与 C 接口交互
最稳的回答模板
引用更偏“别名语义”,适合表示一个一定存在的对象;
指针更偏“地址值语义”,适合表示可空、可重新指向、或者需要更灵活间接层的场景。
所以函数参数里,如果对象必然存在,我优先用引用;如果对象可能不存在,我用指针;如果涉及所有权管理,我会优先考虑智能指针,而不是裸指针。reference 是已有对象的别名,pointer 支持 null pointer value,这两个语义差异正好对应这类工程选择。(cppreference)
这题常见追问
追问 1:引用底层是不是指针实现的?
可以说:很多实现上会借助指针机制,但语言语义上引用不是指针。
面试里要分清“实现层”和“语言层”。
追问 2:引用真的绝对不能空吗?
从语言设计和正常使用语义上,引用不用于表达空值;
你不应该把它当成“可空句柄”来设计接口。
追问 3:什么时候绝不能乱用引用?
当“参数可能不存在”时,不要硬用引用;
当“所有权关系不清楚”时,也不要拿裸引用糊弄设计。
最后:这几题真正应该怎么复盘
我现在越来越觉得,面试题不能只按“知识点”复盘,而要按“回答结构”复盘。
因为面试官真正听的是你的表达组织能力。
这几题其实都可以套一个统一框架:
第一层:先说定义 / 目标
比如:
- LRU:淘汰最近最少使用
- 平衡树:每个节点左右高度差不超过 1
- 系统调用:用户态访问内核服务的入口
- 引用 vs 指针:别名语义 vs 地址值语义
第二层:再说核心实现 / 核心机制
比如:
- LRU:哈希表 + 双向链表
- 平衡树:后序遍历 + 高度 /
-1哨兵 - 系统调用:wrapper →
syscall→ 内核分发 → 返回 - 指针引用:可空性、重绑定能力、接口语义
第三层:最后说边界 / 场景 / 易错点
比如:
- LRU:命中后也要更新顺序
- 平衡树:别写成重复算高度的 O(n²)
- 系统调用:别只会说“用户态切内核态”
- 指针引用:别只背八股,要落到接口设计场景
我给自己的最终背诵版
LRU Cache
用
unordered_map + doubly linked list。
map 做 O(1) 定位,链表维护最近使用顺序;命中就移到头部,容量满了淘汰尾部,所以get/put平均 O(1)。(cppreference)
平衡二叉树判断
用后序遍历。
递归函数返回子树高度;如果某棵子树不平衡就返回-1。
当前节点只有在左右子树都平衡、且高度差不超过 1 时才平衡,所以每个节点只访问一次,复杂度 O(n)。AVL 平衡条件就是每个节点左右子树高度差绝对值不超过 1。
系统调用机制
系统调用是用户程序访问内核服务的受控入口。
Linux 下通常先走 glibc 包装函数,再把系统调用号和参数放到寄存器里,执行syscall指令进入内核;内核按系统调用号分发处理,返回后 glibc 再整理成用户态接口。x86-64 下参数常走rdi, rsi, rdx, r10, r8, r9,返回值在rax。(man7.org)
指针和引用
引用是已有对象的别名,适合表示“对象一定存在”;
指针是地址值,能表达空值和重绑定,适合“对象可选”或更灵活的间接访问;
如果涉及所有权,现代 C++ 更优先考虑智能指针。(cppreference)