多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C++后端面试算法题第十期:五道高频易错题全解析

C++后端面试算法题第十期:五道高频易错题全解析 第十期打卡来了。C后端面试算法题这个系列到了这一期已经攒下不少素材。前九期把链表、二叉树、哈希、动态规划这些方向轮着刷了一遍这期我没打算继续堆难题反而挑了五道“高频、易错、可深挖”的题目合并两个有序链表、无重复字符的最长子串、LRU缓存、二叉树最近公共祖先、买卖股票的最佳时机。它们不是最高深的题却是很多后端候选人在笔试里丢分、在面试问答里被追问到卡壳的题。后端岗位做题为什么绕不开这几道因为后端工作几乎每天都在和数据批量打交道。接口要限流缓存要淘汰有序数据要合并树形结构要做权限或组织映射。面试官挑这些题目不是说只让你背出代码而是想通过短短二十分钟看出几件事能不能把题目条件读清楚能不能把边界情况想完整能不能在压力下稳定写出可运行的C代码。C候选人还要额外回答一个问题你平时用STL顺得很底层原理清不清楚所以第十期按三条标准选材第一大厂后端笔试面试高频第二每种题都能延伸到一个实际工程场景第三适合用C实现能把语言细节展示出来。刷完这五道题建议再按“一题三问”的方式复盘一遍效果会比单纯刷量好很多。1. 第十期选题思路为什么又把这几类题翻出来刷这一期不是随便挑五道题凑数。后端面试的算法题库非常庞大难度也深不见底但高频题永远是那么一批。它们的共同特征是代码量不大边界条件多可以往多个方向追问。恰好适合考察一个C候选人的数据结构功底、编码习惯和沟通能力。我见过不少候选人难题刷了一堆结果在一道简单链表题上栽了跟头。原因往往不是不会而是忽略了输入为空的情况或者while循环里指针移动的次序不对。这类错误在笔试中非常致命因为线上答题没有断点调试错了就是错了。而大厂面试官又尤其喜欢用这种“不难但容易错”的题做筛选因为它能迅速暴露一个人的基本功。1.1 五道题背后的考点分布先给一张总览表方便你判断自己哪块薄弱后面每道题再单独展开。题目核心考点后端场景对应合并两个有序链表链表、哨兵节点、迭代与递归有序队列合并、归并排序思想无重复字符的最长子串滑动窗口、哈希表、数组计数接口限流、日志窗口统计LRU缓存哈希表 双向链表、O(1)操作本地缓存、缓存淘汰策略二叉树最近公共祖先二叉树遍历、递归返回值设计权限树、评论楼层祖先查找买卖股票的最佳时机动态规划、状态压缩收益计算、订单状态机这五题覆盖数组、字符串、链表、二叉树、动态规划五个方向。链表题检查指针操作滑动窗口题检查双指针意识LRU检查数据结构选型和容器能力LCA检查递归拆分股票题检查状态定义。可以说一份稍微全面一点的后端算法考察基本就围绕这些能力展开。为什么强调“基础”因为这五道题的代码量都不大特别适合在面试里手写。面试官能在很短时间里看到你整个思考过程写之前问不问题、写的时候注释清不清楚、写完会不会主动测边界。所谓“大厂必刷”未必是难题海而是这些基础题最能暴露真实水平。1.2 代码环境与约定说明本期代码按 C11/14 标准书写用到的头文件都在代码块里给出了。你在本地编译器上直接建一个空项目把每个函数粘贴进去就能编译。下面几条是我自己长期刷题总结的约定。提示面试刷题环境往往不允许联网所以代码应当只依赖标准库不要依赖在线工具或第三方库。养成纯手写习惯关键时刻才稳。我写代码有三个固定习惯。第一先确认输入是否可能为空链表可能是空指针字符串可能是空串数组可能是空数组。第二写完立刻口头算一遍复杂度尤其是空间复杂度“额外空间”这几个字要常挂嘴边。第三想清楚返回值语义再看看是否需要特殊处理退化输入。这三个习惯能覆盖大多数面试题的失分点。代码中大量使用using namespace std这是为了方便展示。如果你在工程代码里也这样写会让命名空间污染变严重面试官问到这个问题时你可以直接答出来。另外所有接口命名和参数顺序尽量贴近常见在线评判系统的风格方便你提交到OJ时直接复用。2. 五道高频题完整拆解思路、代码与复杂度这一章是整篇博文的正文核心。每道题我按一个统一节奏展开题干、思路、代码、复杂度、面试追问。你可以直接照着练习也可以把某个题单独摘出去做复习卡。2.1 合并两个有序链表别让指针在边界处迷路题目描述给定两个升序链表返回合并后的升序链表。要求原地调整节点的next指针不创建新节点。看起来是链表题里最简单的一道但我见过太多人在这里翻车。最容易出问题的地方有三个递归版本在长链表上栈溢出迭代版本忘了处理某个链表已走完的情况while循环里tail指针的移动位置写错导致死循环或丢失节点。思路用哨兵节点 迭代。哨兵节点是一个不参与实际业务的哑节点作用是不用单独判断“合并后链表为空”的情况。定义一个dummy让tail始终指向当前结果链表的最后一个节点。循环比较两个链表的头节点值谁小就把谁接在tail后面然后移动对应链表的指针最后tail向后走一步。循环退出后最多有一个链表还剩节点。这里很多人会写“再写一个while把剩余节点逐个接上”其实没必要直接让tail-next指向不为空的链表头就可以了。因为原链表本身就是有序的剩余节点整体接到后面依然是升序。代码实现struct ListNode { int val; ListNode* next; ListNode(int x) : val(x), next(nullptr) {} }; ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) { ListNode dummy(0); ListNode* tail dummy; while (l1 ! nullptr l2 ! nullptr) { if (l1-val l2-val) { tail-next l1; l1 l1-next; } else { tail-next l2; l2 l2-next; } tail tail-next; } tail-next (l1 ! nullptr) ? l1 : l2; return dummy.next; }复杂度分析时间 O(n m)n 和 m 分别为两个链表长度空间 O(1)因为只用了常数个辅助指针。如果写递归版时间同样是 O(n m)但递归调用栈会到 O(n m)当链表很长时可能栈溢出。面试官扩展问法把两个链表改成 K 个有序链表。如果逐个两两合并复杂度会变成 O(K²N) 或者 O(KN)取决于合并顺序。建议答案分两层第一层把所有链表头放进最小堆每次取堆顶节点接到结果末尾再把该节点的后驱入堆复杂度 O(N log K)N 为所有链表节点总数第二层也可以两两归并复杂度同样是 O(N log K)但常数和写法不同。这一问能直接看出候选人有没有真正理解归并思想建议认真练一次最小堆版本。2.2 无重复字符的最长子串滑动窗口右边界不能着急往前跑题目描述给定一个字符串 s找到其中不含有重复字符的最长子串的长度。例如s abcabcbb结果是 3最长子串是abc。暴力思路是枚举所有子串再用哈希判断是否有重复复杂度 O(n²)写起来简单面试却丢分。合格的解法是滑动窗口一次遍历搞定。思路用两个下标left和right框住一个窗口窗口内的子串始终保持无重复字符。right每向右扩展一位就检查新字符是否在窗口内已经出现过。如果出现过把left跳到重复字符上次出现位置的下一个下标同时更新该字符的最新位置最后计算窗口长度更新答案。关键是“怎么判断字符出现过”。我推荐用固定长度数组vectorint pos(256, -1)下标是字符的ASCII码值是它最近一次出现的下标。数组访问 O(1)比unordered_map常数小。字符串若包含高位字节字符直接拿char做下标可能变成负数必须通过(unsigned char)转换。代码实现#include string #include vector #include algorithm using namespace std; int lengthOfLongestSubstring(string s) { vectorint pos(256, -1); int left 0; int ans 0; for (int right 0; right static_castint(s.size()); right) { unsigned char ch static_castunsigned char(s[right]); if (pos[ch] left) { left pos[ch] 1; } pos[ch] right; ans max(ans, right - left 1); } return ans; }复杂度分析时间 O(n)left和right各扫描一遍字符总体线性空间 O(1)因为辅助数组大小固定为 256。考题延伸面试官如果让你手推pwwkew可以直接在白板上画。我当时的推法是right 到第二个 w 时发现 w 上一次位置是 1当前 left 是 0所以窗口内已经有 wleft 跳到 2这时窗口变成wke长度更新成 3。手感极其重要纸上多推几遍能避免代码把 left 更新错了。再延伸一步这道题和后端接口限流非常像限流窗口也是用左右边界记录时间区间只是存的值从字符位置变成时间戳。能讲出这个联系面试官会认为你有业务迁移能力。2.3 LRU缓存哈希表与双向链表的一对一配合题目描述设计一个LRU缓存支持get(key)和put(key, value)要求平均时间复杂度都是 O(1)。缓存容量固定满了之后淘汰最近最少使用的键值对。LRU是后端高频题里最有工程味道的一道。缓存系统、进程页面置换、本地缓存淘汰核心全是LRU思想。为什么答案结构是哈希表 双向链表因为get要按key快速查找哈希表 O(1)链表维护“最近使用顺序”方便删除尾部最久未使用节点。但单链表删除任意节点需要从头找前驱做不到 O(1)所以必须用双向链表。思路标准做法是用listpairint, int保存key-value和最近使用顺序链头表示刚访问过的链尾表示最久未使用。另用unordered_mapint, listpairint, int::iterator保存key到链表节点的映射。get命中时把节点用splice移到链头再返回值put时先查是否存在存在则更新值并移到链头不存在则先判断容量满了就把链尾节点删除并从哈希表里删掉对应key最后在链头插入新节点并建立映射。list::splice是本题关键API它能把节点从链表任意位置移动到指定位置并且复杂度 O(1)因为双向链表节点转移不涉及遍历。很多候选人不知道这个接口选择自己手写双链表那也没问题但对指针失效的把握要求更高。代码实现#include list #include unordered_map #include utility using namespace std; class LRUCache { public: LRUCache(int capacity) : cap_(capacity) {} int get(int key) { auto it mp_.find(key); if (it mp_.end()) { return -1; } int value it-second-second; cache_.splice(cache_.begin(), cache_, it-second); return value; } void put(int key, int value) { auto it mp_.find(key); if (it ! mp_.end()) { it-second-second value; cache_.splice(cache_.begin(), cache_, it-second); return; } if (cache_.size() static_castsize_t(cap_)) { int oldKey cache_.back().first; cache_.pop_back(); mp_.erase(oldKey); } cache_.emplace_front(key, value); mp_[key] cache_.begin(); } private: int cap_; listpairint, int cache_; unordered_mapint, listpairint, int::iterator mp_; };为什么不建议用时间戳 排序如果每次get都更新时间戳淘汰时遍历所有key找最老时间戳复杂度 O(n)如果用堆维护时间戳又需要处理节点更新时堆内元素的删除工程上非常麻烦。双向链表的顺序天然就是访问顺序这是数据结构选择的典范。面试深挖方向多线程场景怎么办最简单的回答是给get和put加同一把互斥锁正确性有保障但并发低更好的回答是分段锁或者分片缓存每个分片独立带锁降低竞争还可以说读多写少时考虑读写锁。这些问题没有标准答案关键要表达你能从“正确性”和“吞吐量”两个维度权衡。实际工程里还要考虑链表节点内存分配开销以及哈希表扩容时的停顿能把这些提出来会更加分。一个容易踩的坑删除尾部缓存时只操作链表却忘了mp_.erase(oldKey)。这样get老key依然能命中而且内存不会真正释放表现成“缓存命中率正常但内存持续上涨”。我早期调这个题就踩过排查半天才发现哈希表里残留了陈旧键。这类一致性问题是后端工程师特别应该警惕的。2.4 二叉树最近公共祖先递归返回值设计的艺术题目描述给定一棵二叉树的根节点root和树中的两个节点p、q返回它们的最近公共祖先。一个节点也可以是它自己的祖先。这道题的递归代码极短但要想明白“函数返回什么”才能稳。很多初学者一上来就想找路径把祖先路径存下来再比较。那当然也能解但代码长、时间空间都是 O(n)。这里我讲标准后序递归写法。思路对当前节点root先处理空值如果root为空或者等于p或q直接返回root。如果不是递归去左右子树找答案。拿到left和right两个返回值后分情况判断如果left和right都不为空说明p和q分别落在root的左右两侧root就是最近公共祖先。如果只有一侧非空说明两个节点都在这一侧直接返回这一侧的递归结果。如果两侧都为空这个分支没有目标节点返回空。这种自底向上的处理方法本质上和动态规划的“先解决子问题再合并结果”一致。返回值的含义必须统一表示“子树内是否找到了 p 或 q以及找到时的最近公共祖先候选”这样递归才能一层层把信息传上去。代码实现struct TreeNode { int val; TreeNode* left; TreeNode* right; TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} }; TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) { if (root nullptr || root p || root q) { return root; } TreeNode* left lowestCommonAncestor(root-left, p, q); TreeNode* right lowestCommonAncestor(root-right, p, q); if (left ! nullptr right ! nullptr) { return root; } return left ! nullptr ? left : right; }复杂度分析时间 O(n)每个节点最多访问一次空间主要看递归栈深度最坏退化成链表时 O(n)平衡树时 O(log n)。所以严格说这个解额外空间不是 O(1)如果面试官问“能否 O(1)”需要换迭代法或父指针。面试沟通注意题目默认 p、q 一定在树里但你不妨主动确认。如果 p/q 可能不存在递归返回的语义会变复杂需要用一个计数变量记录找到了几个目标节点。这个主动提问能体现严谨性。我见过面评里专门写“候选人主动澄清条件加分”可见细节真的重要。扩展关联如果把树换成二叉搜索树LCA可以利用val大小关系剪枝。当前节点大于p、q则向左走小于p、q则向右走否则当前节点就是LCA复杂度 O(高度)。这是同一问题在不同数据形态下的最优解建议把两个版本都练一下。2.5 买卖股票的最佳时机从“状态定义”看懂动态规划题目描述给定一个数组pricesprices[i]表示第 i 天的股票价格。最多只能完成一笔交易也就是只能买入一次、卖出一次求最大利润。如果不能获利返回 0。很多人一看到动态规划就紧张但这道题是理解“状态压缩”最好的入门例子。直接背“记录最小值遍历更新答案”这种操作不是不行只是面试官多问两句你就会露怯。我建议用状态语言理解它。思路设第 i 天卖出的最大利润是dp[i]。第 i 天卖出必然是在 0 到 i-1 天中价格最低的那天买入。所以我们只要维护一个min_price表示当前已扫描天里的最低价格然后每天用prices[i] - min_price更新答案。因为答案只与“当前最低买入价”和“当前价格”有关不需要存下全部dp数组一个变量就够这就是状态压缩。为什么叫动态规划而不是贪心因为每一步的最优值都依赖前面保存的最优状态历史最低价并且我们在每次决策时试图构造当前状态的最优解。贪心一般不保留状态这里的min_price其实是一个被压缩的“状态”。代码实现#include vector #include algorithm using namespace std; int maxProfit(vectorint prices) { if (prices.empty()) { return 0; } int min_price prices[0]; int ans 0; for (size_t i 1; i prices.size(); i) { ans max(ans, prices[i] - min_price); min_price min(min_price, prices[i]); } return ans; }复杂度分析时间 O(n)空间 O(1)。这是最优解面试时不用再纠结能降到 O(1) 以下。扩展版本面试官大概率会接着问“如果可以无限交易但任何时候最多持有一股怎么算”这时候要定义两个状态hold表示当前持有股票的最大收益cash表示当前不持有股票的最大收益。每天做一次状态转移最终答案就是 cash。这样你能顺着拓展到带冷却期、带手续费、最多K笔交易。学了状态机再遇到“订单状态如何流转”这类业务题思路会打开很多。还有一个不起眼但重要的点当价格始终递减时答案必须是 0而不是负数。代码初始化ans 0正好解决了这个语义。很多人只测上涨样例忘了递减样例导致把答案算成负数这种测试疏漏在面试里非常扣分。3. 刷题常见的坑边界、迭代器与口误前面是五道题的正解这一章是我从模拟面试和日常调代码里总结出来的常见问题。算法能力差不多的情况下细节处理直接决定面评档次。3.1 边界条件自查清单很多代码在正常用例上跑得很好一到边界就崩。我列了一张表建议每道题写完都按这个测一遍。题目必测边界常见翻车点合并两个有序链表两链表都为空、其中一个为空while里直接解引用空指针无重复字符的最长子串空串、全相同字符、长度1left更新错误导致窗口越界LRU缓存容量为1、重复key、get不存在key删除尾部后忘了同步哈希表二叉树最近公共祖先单节点、p是q祖先返回值处理混乱买卖股票空数组、全下降序列数组越界或返回负数我写代码的方法是提交前先用三条用例跑一遍。第一条是普通用例第二条是空输入第三条是退化输入。比如合并链表测l1 nullptr, l2 单节点滑动窗口测空串LRU测容量1时连续put两个key。这三条跑通基本能避免大量低级失分。3.2 迭代器失效与容器操作陷阱C 和很多语言不同容器修改后迭代器可能失效。LRU 的splice和emplace_front都在改链表结构但list的节点转移不会让其他迭代器失效所以保存的mp_映射依然有效。真正要小心的是unordered_map的 rehash插入元素可能触发扩容使之前的迭代器全部失效。好在put里我们先查在不在查到后修改并移动节点不插入新元素只有新增键时才emplace_front并给mp_赋值这个顺序能避开迭代器失效问题。另一个常见错误是在循环遍历 vector 或 map 时做删除操作。后台处理定时任务时很多人会写“遍历集合找到超时项然后 erase”结果在 for 循环里 erase 迭代器导致后续迭代器失效。正确姿势是先用remove_if或先收集要删除的 key循环结束后统一清理。这个点在后端开发里几乎天天见到能在算法题里顺手点出来面试官会更信任你的工程经验。3.3 面试表达里的三个禁忌第一个禁忌把“时间复杂度 O(1)”挂在嘴边却没解释常数因子。LRU 的 get 确实 O(1)但哈希查找 链表拼接各有代价。第二个禁忌把空间复杂度说成 O(1)递归实现明明要占调用栈。第三个禁忌代码写完不说话。正确做法是写完主动说“我来分析一下复杂度”然后简明讲两句。尤其是递归、滑动窗口这类题目不说思路直接写面试官很可能认为你在背题。我见过一位候选人五道题里三道默写正确但面试官问他“为什么会想到用双向链表”时他沉默了二十秒才挤出“因为这是标准答案”。这就是典型的会背不会讲。所以本博文每一道题都写了“为什么”不只是给代码。4. 怎么把题目变成面试里的实际加分项刷题容易陷入“刷过就忘”因为缺少沉淀。这里分享我自己的复盘方法把每题做成可复述的知识点再和工程场景绑定最后放进一个可复用的答题模板里。4.1 一题多解面试时的选择策略同一道题有多种解法时面试时不必都写完但要能回答“为什么选这个解法”。合并有序链表迭代空间 O(1) 但代码长递归空间 O(n) 但代码短我优先写迭代因为后端线上环境对栈敏感如果题目明确限制递归深度迭代就是唯一选择。滑动窗口里unordered_map更通用数组更高效。我会先写数组方案并说明字符集前提再说可以改 map 支持任意字符集。这样既展示了效率意识又展示了灵活性。买股票那题我会先写扫描版再补一句“它本质是压缩后的动态规划”然后顺势展开状态定义。这套节奏能让面试官觉得你是在讲题不是背答案。4.2 从算法题延伸到后端工程场景后端候选人最容易忽略的是“算法结论如何落地”。LRU 是最典型的可扩展题。你可以说线上本地缓存一般会加锁简单做法是全量互斥锁性能不好高并发下用分片或者引入外部缓存组件再或者允许统计误差时用抽样淘汰。这句话不需要背一堆名词只需要把权衡逻辑讲清楚。滑动窗口那题也能延展接口限流统计一分钟内请求次数本质是滑动窗口计数日志系统统计某个时间段内的独立用户数也是滑动窗口去重。差异只在于窗口里存的是下标还是时间桶。能说出这个抽象比背“滑动窗口模板”有用得多。二叉树 LCA 可以延展到权限树找到两个角色的最近共同上级组织或者评论区的最近共同楼层。只要存储结构是树LCA 的查找思路完全一致。我把这种迁移过程称为“把算法题升华成方案题”。面试官问“你项目里用过哪些数据结构”时你可以直接拿这些场景举例。4.3 如果时间不多按这个顺序练这期五道题如果只想抽半天我的推荐顺序前20分钟默写合并链表两种写法重点练迭代再花30分钟手推滑动窗口状态变化写无重复字符子串然后花1个小时做LRU必须测容量为1的用例接着用画图方式跑二叉树LCA把每个递归返回值标在图上最后把股票题改成“允许无限交易”对比两版状态定义。按我自己的经验单刷一道题收益有限一题三问才是提高效率的关键。所谓三问就是三道自问能不能换一种解法能不能优化复杂度改一个条件后会怎么变LRU问并发滑动窗口问超长字符二叉树问换成二叉搜索树。做20道题不如把这5道题每个方向吃透。5. 综合练习五道题拼出一个后端小系统如果你觉得每道题都单独刷没意思我建议做一个组合练习设计一个简单的“商品报价系统”把五道题全部用上。这不是真实的商业项目却是很好的面试训练场景。5.1 场景设计商品报价与用户访问服务假设有一个商品系统每天接收多个来源的报价数据每个来源的数据都已经按价格升序排列。合并多个有序报价列表就是“合并K个有序链表”的场景。系统还要记录用户最近浏览过的商品容量有限超出就淘汰最久没看的记录这就是LRU。用户访问商品详情页时后端要做限流防止单个用户短时间刷爆接口这里可以用滑动窗口思路统计访问次数。商品分类是一棵分类树两个商品所属分类的最近公共分类就是LCA。最后系统展示某商品历史价格走势给用户计算如果昨天买今天卖最多能赚多少这就是股票买卖问题。把五道题放到同一个业务故事里你会发现自己对题目的理解立刻不一样了。每个数据结构都不是孤立存在的而是为了满足某个需求而被选择。面试官如果真的问“这道题在你的项目里有什么用”你也能直接讲出至少一个完整场景。5.2 综合回答的组织模板我在模拟面试里常用一个三段式模板分享出来供参考。第一步拆需求先说明这个场景里需要哪些核心操作比如“缓存要支持O(1)的读和写需要淘汰最久未使用”。第二步选结构结合需求说清楚为什么选哈希表双向链表而不是别的。第三步谈边界说明容量满了怎么做、get不到key怎么办、并发访问怎么保证一致。这套模板适用于绝大多数算法面试题。你不需要背话术而是要养成“需求—数据结构—边界”的思维惯性。很多候选人答不好题不是因为不会写代码而是因为不知道从哪个角度开始讲。有一个固定框架至少不会语无伦次。5.3 个人复盘记录建议最后建议你维护一个简单的刷题复盘表不用很复杂列出日期、题目、解法、复杂度、面试追问方向、自己的收获即可。我发现每隔两周回头看这些记录会发现自己看问题的角度在变化。最开始关注“代码能不能过”后来变成“为什么这个解法最优”再后来变成“这个结构还能用到哪里”。这种变化恰好对应了从初级到高级的成长曲线。第十期的五道题如果你能让它们从“会写”变成“会讲”这期就算没白刷。
返回列表