2026.3.19 面试题复盘|LRU 缓存、平衡二叉树、系统调用机制、指针与引用

4 阅读16分钟

最近复盘了几道很典型的面试题。它们表面上分属数据结构、操作系统、C++ 基础,但本质上考的是同一件事:你能不能把“概念、实现、边界、场景”串成一条完整链路讲清楚。

我发现,很多时候不是不会,而是回答太散。
所以这篇我不只是写“答案”,而是按“面试官想听什么我应该怎么回答容易被追问什么”的顺序,把这几题整理成一份可复用的复盘稿。


1. LRU Cache 怎么实现?

这题面试官想考什么

这题表面上在考缓存淘汰策略,实际上主要考三件事:

第一,你知不知道 LRU 的语义:当容量满了以后,淘汰“最近最少使用”的元素。
第二,你能不能把复杂度从朴素实现优化到 get / put 都尽量 O(1)
第三,你会不会选对容器,而不是只会背“哈希表 + 链表”这六个字。经典设计之所以成立,是因为 std::unordered_map 支持平均常数时间查找,而 std::list 支持任意位置常数时间插入删除,并且除被删元素外不会使其他迭代器失效。(cppreference)

面试时最好怎么答

我一般会这样答:

LRU 的核心目标是:
在容量有限的情况下,优先保留最近访问过的数据,淘汰最久没被访问的数据。
如果要做到 getput 都接近 O(1),经典做法是 哈希表 + 双向链表
哈希表负责 O(1) 定位 key,双向链表负责 O(1) 地把某个节点移动到“最近使用”的位置,以及 O(1) 删除最久未使用的尾节点。
在 C++ 里常见实现就是 unordered_map + listunordered_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) 定位,双向链表维护访问顺序,头部是最近使用,尾部是最久未使用。
每次 getput 命中都把节点挪到头部;容量满了就淘汰尾部。
这样 getput 平均都能做到 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)

这题最容易答崩的点

最容易崩的地方有三个:

  1. 只会说“哈希表+链表”,但说不清各自负责什么
  2. 忘了说明“访问命中也要更新最近使用顺序”
  3. 代码里删尾节点后,忘记同步删除 map 里的 key

2. 判断一棵二叉树是否平衡,怎么做?

这里我先提醒一句:
如果面试官原话真的是“证明二叉树完全平衡”,你最好先确认一下他到底指的是:

  • 完全二叉树
    还是
  • 平衡二叉树 / 高度平衡树

因为这是两个不同概念。
通常面试里更高频的是第二个:判断一棵树是不是高度平衡。AVL 的平衡条件就是:对每个节点,左右子树高度差的绝对值不超过 1。

面试时最好怎么答

如果题目是“判断一棵二叉树是否为平衡树”,我会用 后序遍历
因为一个节点是否平衡,取决于它左右子树是否平衡,以及左右子树高度差是否不超过 1。
所以最自然的方式是先算左右子树,再回到当前节点做判断。
我会让递归函数返回“当前子树高度”;一旦发现某个子树不平衡,就返回一个特殊值,比如 -1,一路向上传播。
这样每个节点只访问一次,时间复杂度 O(n)。AVL 的平衡定义本身就是每个节点左右子树高度差绝对值至多为 1。

为什么后序遍历最合适?

因为你判断当前节点是否平衡,需要先知道:

  • 左子树高多少
  • 右子树高多少
  • 左右子树本身是不是已经失衡

这就是典型的“子问题先解决,再处理当前节点”,所以是后序。

两种思路对比

思路 1:朴素写法

对每个节点:

  1. 分别求左子树高度
  2. 分别求右子树高度
  3. 检查差值是否超过 1
  4. 递归检查左右子树

问题在于:高度会被重复计算很多次
最坏情况下会接近 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_ptr
  • std::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)