贝壳找房2024秋招C++笔试:从编程题到系统设计全复盘

📅 发布时间:2026/9/1 11:15:22
贝壳找房2024秋招C++笔试:从编程题到系统设计全复盘
2024年秋招贝壳找房C工程师第二批笔试我是在九月中旬考的。当时投的是北京总部的C开发岗笔试平台用的牛客一共两个半小时题量和难度都比我预想中要扎实不少。考完之后趁着还记得住我把整套卷子的考点、每道题的思路、以及我踩过的坑都整理了一遍今天一次性写出来给后面准备贝壳或者其他互联网公司C岗位的同学做个参考。1. 贝壳二批笔试的整体情况与考点分布先说整体感觉。贝壳的笔试不像有些公司那样纯堆八股也不像大厂那样一整页全是难题它更偏向“基础扎实程度 典型算法熟练度”的组合考核。整套卷子大概分三块选择题、编程题、以及一道综合设计/分析题每个批次可能不太一样我这场是这样的结构。分值占比上编程题和选择题基本对半开综合题占一小部分。选择题部分考察范围非常典型的C方向内存管理、STL容器底层实现、多线程同步、constexpr和模板、设计模式的应用场景、C11之后的新特性。这些内容说句实话有认真刷过面经和八股的基本没什么问题但有几道题问得很细比如关于vector扩容倍数在不同编译器下的具体表现、shared_ptr循环引用导致内存泄漏的具体场景、以及std::function和函数指针的区别这些属于知道就是知道、不知道只能蒙的题。编程题一共四道经典的牛客风格难度依次递增。第一道是签到题字符串相关正常几分钟能做出来。第二道是贪心排序。第三道是动态规划。第四道压轴题考的是并查集图论建模这个题我当场没完全AC后面复盘时重新推导了一遍。整体来看贝壳的算法题更看重经典算法的灵活变形而不是偏题怪题。综合设计题给了一个场景房产楼栋信息管理系统中需要批量处理大量用户的搜索请求要求设计一个高效的缓存同步方案并考虑线程安全问题。这种题没有标准答案考察的是系统设计思维和C多线程的实际理解程度。2. 编程题复盘从签到题到压轴题的完整解题思路2.1 签到题字符串处理考察边界处理能力第一道题大概是这个意思给定一个字符串s和一个整数k要求把字符串每隔k个字符做一次反转。如果最后一段不足k个字符这一段也需要反转。这种题一看就是考察基本功的但它是整套卷子的第一道题心态影响很大。我当时用了最直观的写法按步长遍历每段调用reverse#include string #include algorithm #include iostream std::string reverseSegments(const std::string s, int k) { std::string result s; int n s.size(); for (int i 0; i n; i k) { int end std::min(i k, n); std::reverse(result.begin() i, result.begin() end); } return result; }这道题唯一的坑在于k可能大于字符串长度或者k为0/负数的非法输入。题目描述里不会专门提醒这些边界情况所以每道题第一件事就是确认数据范围。这道题用O(n)就能过没有性能压力。2.2 贪心排序题题目长核心逻辑短第二道题的场景是贝壳平台的房源推荐排序给定一些房源每个房源有评分和带看次数要求按“评分降序同分则带看次数降序仍然相同则按编号升序”的规则输出前N个房源。这道题本质上就是自定义排序写一个比较函数完事。但我当时在这个题上犹豫了一下因为题目描述很长包装成了一整个业务故事容易让人误以为要写复杂算法。这类题的经验就是跳过故事直接看输入输出只要发现是“给一堆元素按某种规则排个序”那十有八九是排序题。#include vector #include algorithm struct House { int id; int score; int visits; }; std::vectorHouse topNHouses(std::vectorHouse houses, int limit) { std::sort(houses.begin(), houses.end(), [](const House a, const House b) { if (a.score ! b.score) return a.score b.score; if (a.visits ! b.visits) return a.visits b.visits; return a.id b.id; }); if (limit (int)houses.size()) return houses; return std::vectorHouse(houses.begin(), houses.begin() limit); }这道题核心考的就是lambda表达式里比较器的写法以及排序稳定性问题。注意std::sort是不稳定排序如果题目要求相同元素保持原有顺序就必须用std::stable_sort或者像这里一样把编号作为第三判断条件。我见过不少同学直接把比较函数写反升降序搞混这种低级错误非常可惜。2.3 动态规划题状态定义是核心中的核心第三道题是典型的DP题一个经纪人每天可以带看多个客户每个客户有一个“预期收益”和“需要占用的时间段”要求选择一组互不冲突的客户使得总收益最大。这道题本质上就是“加权区间调度”问题。思路是这样的先把所有客户按结束时间排序然后定义dp[i]表示前i个客户中能获得的最大收益。对于第i个客户有两种选择不接dp[i] dp[i-1]接则需要找到“结束时间不晚于当前客户开始时间”的最近一个客户jdp[i] max(dp[i-1], dp[j] value[i])。#include vector #include algorithm struct Client { int start; int end; int profit; }; int maxProfit(std::vectorClient clients) { std::sort(clients.begin(), clients.end(), [](const Client a, const Client b) { return a.end b.end; }); int n clients.size(); std::vectorint dp(n 1, 0); for (int i 1; i n; i) { // 不选当前客户 dp[i] dp[i - 1]; // 查找不冲突的前一个客户 int j i - 1; while (j 1 clients[j - 1].end clients[i - 1].start) { j--; } dp[i] std::max(dp[i], dp[j] clients[i - 1].profit); } return dp[n]; }这里的while线性查找在最坏情况下是O(n^2)如果数据量大会超时笔试时应该用二分查找优化。但我当时为了保险直接用线性查找硬写的结果本地测试能过交上去之后有一个用例卡了超时这题没拿满分。复盘的时候我重写了一遍二分版本大概是这样int lastNonConflict(const std::vectorClient clients, int idx) { int left 0, right idx - 1, ans -1; while (left right) { int mid left (right - left) / 2; if (clients[mid].end clients[idx].start) { ans mid; left mid 1; } else { right mid - 1; } } return ans 1; // 返回的是前缀长度 }如果每道编程题的总时限在2秒左右数据量到了10^5级别线性查找必挂。这提醒我笔试做题时不能只满足于“能跑通”还要想一下极端数据量下的时间复杂度。2.4 压轴题并查集建模图连通问题第四道题是整场笔试里最有区分度的。简化后的题意是一个城市有N个小区M条道路每条道路连接两个小区。现在随机选择k个小区作为“重点保障小区”要求在保障期间任意两个重点保障小区之间都能通过道路连通。问最少需要额外维修多少条道路才能满足这个条件。看到这个题第一反应是把重点小区抽象成节点把道路抽象成边问题转化为给定一个图和图中若干个关键节点最少加多少条边能让所有关键节点连通。最直接的做法是并查集。先把所有已经连通的道路合并然后遍历关键节点把不同连通块里的关键节点依次连接起来需要的边数就是“关键节点所在不同连通块的数量 - 1”。#include vector class UnionFind { private: std::vectorint parent; std::vectorint rank; public: UnionFind(int n) : parent(n), rank(n, 0) { for (int i 0; i n; i) parent[i] i; } int find(int x) { if (parent[x] ! x) parent[x] find(parent[x]); return parent[x]; } void unite(int x, int y) { int rx find(x), ry find(y); if (rx ry) return; if (rank[rx] rank[ry]) parent[rx] ry; else if (rank[rx] rank[ry]) parent[ry] rx; else { parent[ry] rx; rank[rx]; } } }; int minAdditionalRoads(int n, const std::vectorstd::pairint,int roads, const std::vectorint keys) { UnionFind uf(n); for (auto e : roads) { uf.unite(e.first, e.second); } std::vectorint rootIds; for (int k : keys) { int root uf.find(k); bool exists false; for (int val : rootIds) { if (val root) { exists true; break; } } if (!exists) rootIds.push_back(root); } return (int)rootIds.size() - 1; }我当时的做法也是这个思路但在找“关键节点属于哪些连通块”这一步写复杂了用一个vector存root然后每次线性查找是否存在导致整体复杂度多了个O(k^2)。如果k很大这一步是最容易超时的。正确做法是直接用unordered_set去重或者对rootIds排序后再去重。这个题我卡在了一个细节上题目里说“任意两个重点保障小区之间都能通过道路连通”这意味着只需要让所有关键节点落在同一个连通分量里。但在实际图里普通节点和关键节点是混在一起的所以要让所有关键节点连通不一定非要两两之间直接加边——把不同连通块之间的任一节点连起来就能把两个连通块合并。这个思路我一开始没想透绕了弯路浪费了不少时间。3. 选择题中的C核心考点哪些题最容易丢分选择题内容很杂我按回忆整理了大概的考点范围。总体来看贝壳的笔试比较注重C新特性和底层实现机制的结合不是死记硬背能搞定的。3.1 C11/14/17新特性相关constexpr到底是在C哪个版本引入的——这是C11引入的但C14放宽了constexpr函数的限制C17又增加了constexpr if。选择题如果只问“引入版本”答案就是C11如果问“哪个版本开始支持包含循环和局部变量的constexpr函数”答案是C14。这一块很容易混淆我自己也在这里纠结了一会儿。移动语义和右值引用也是必考内容。有一道题给出了这样一个代码片段std::vectorint getVector() { std::vectorint v {1, 2, 3}; return v; } int main() { std::vectorint v2 getVector(); }问main里这个赋值过程调用了几次拷贝构造/移动构造。如果编译器开了C11并且没有关掉RVO/返回值优化实际上一次构造都不需要是直接构造的。但如果加了中间变量或者条件分支RVO失效就可能会调用移动构造。这种题考察的是对语言规范和编译器优化之间关系的理解光背八股不看代码容易栽。lambda表达式也是高频考法。有一道题问下面这个lambda捕获列表中哪些写法是C14才支持的auto f [x 10]() { return x 1; };这个叫“初始化捕获”C14开始支持。C11只能捕获外部变量不能直接定义新变量并初始化。3.2 内存管理和智能指针智能指针的选择题集中在shared_ptr的循环引用问题上。题目大概是两个类A和B各自持有一个shared_ptr指向对方那么在作用域结束时这两个对象会不会被销毁答案是不会。因为A指向BB指向A形成了循环引用引用计数永远不会降到0导致内存泄漏。解法是在其中一个类里改用weak_ptr打破循环。贝壳的题没有就此打住它还问了weak_ptr.lock()返回什么——返回一个shared_ptr如果原对象已经被释放则返回空shared_ptr。还有一道关于unique_ptr的题以下哪个操作是unique_ptr支持而shared_ptr不支持的答案是std::move独占所有权转移。unique_ptr不支持拷贝但支持移动。shared_ptr则反之拷贝和移动都支持。3.3 STL容器底层与性能差异STL容器的选择题比较套路化。vector的扩容机制几乎每年必考vector在内存不足时GCC环境下通常是按2倍扩容MSVC下通常是按1.5倍扩容。扩容过程中会申请新内存、把旧元素移动/拷贝过去、释放旧内存所以频繁扩容会带来性能损耗这就是reserve的意义。map和unordered_map的底层实现也是常考。map是红黑树有序但插入删除O(log n)unordered_map是哈希表无序但平均O(1)。题目会问如果只需要判断元素是否存在且不需要排序应该选哪个答案是unordered_map。vector和list对比的题也会出现。vector在尾部插入是O(1)在中间插入是O(n)list在任意位置插入是O(1)前提是你已经有迭代器。但list每个节点有额外指针开销而且内存不连续缓存命中率低所以多数场景下vector仍然是更优选择。3.4 设计模式与多线程设计模式考察的题比较典型给一个业务场景问该用哪种模式。比如“一个Logger类整个系统只需要一个实例所有模块共享使用”对应单例模式“给某个接口增加新功能但不想修改原有代码”对应装饰器模式“一个操作需要支持撤销/重做”对应命令模式。贝壳的题里还问到了观察者模式场景是“房源价格变化时需要通知多个渠道更新展示”。多线程的题在选择题里出现了两种情况一是问std::thread的可拷贝性——std::thread不可拷贝只能移动二是问ABA问题——这是CAS操作中典型的坑即一个值从A变成B又变回A导致CAS误判为“没有变化”。解决ABA问题的常用办法是加入版本号/标记。贝壳的题就问到了这个ABA问题的产生场景和解决思路说明他们对并发场景下的实际工程问题是有偏好的。4. 综合设计题缓存同步方案的答题思路综合设计题是我印象最深刻的一道题。场景大概是贝壳找房的某个子系统需要为大量用户提供房源信息查询每次查询都直连数据库会导致压力过大所以需要在中间加一层缓存。要求设计一个缓存同步方案并回答以下问题缓存采用什么数据结构缓存什么时候更新如何保证一致性多线程同时读写时如何保证线程安全如果缓存服务崩溃了如何恢复这类题在笔试里不会要求你写完整代码但需要把思路和关键设计讲清楚。我是按这样回答的。缓存数据结构方面我选择了ConcurrentHashMap风格的结构对应C里是std::unordered_map配合细粒度锁或者无锁哈希表。考虑到房源信息是“读多写少”的场景读写锁std::shared_mutex是一个合理的方案。如果允许引入第三方库TBB的concurrent_hash_map也是不错的选择。一致性方面我设计了两种更新策略结合使用一是Cache Aside模式读请求先查缓存缓存未命中则查数据库、把结果写回缓存写请求更新数据库后主动删除对应缓存等下一次读请求再回填。二是通过发布订阅消息队列当数据库中的房源信息发生变化时监听变更事件并主动刷新缓存这种方式时效性更好但实现复杂度更高。线程安全方面要避免在持有锁的情况下做耗时操作。比如更新缓存时不应该在持锁状态下查询数据库而应该先释放锁再查库然后在回填时用double-check的方式避免并发写入覆盖新数据。缓存恢复方面我给了一个两层的方案本地缓存加上分布式缓存Redis。本地缓存如果丢失可以从Redis恢复Redis如果丢失可以重启后通过数据库全量回填。同时可以记录缓存的版本号崩溃后从上次持久化的版本号开始增量恢复。这题拼的不是“标准答案”而是能不能把每一个环节的trade-off说清楚。面试官或者批卷系统想看到的是你面对实际问题时有自己的判断力。5. 应试策略复盘时间分配与做题顺序的真实经验说实话这套卷子如果按顺序死磕最大的风险是第四道题耗掉大量时间导致前面的选择题来不及检查。我当时的做题策略是这样的仅供参考。拿到卷子先花3-5分钟浏览全部题目。这一步很重要先了解“地图”再决定怎么走。选择题一共20来道我计划每道不超过1分钟如果超过就先标记跳过。编程题第一道和第二道加起来控制在30分钟以内第三道和第四道各留25-30分钟。最后剩下15分钟检查选择题和补充编程题的注释/边界情况。实际执行下来第一道签到题花了不到5分钟第二道排序题大约15分钟第三道DP题花了30分钟但因为线性查找超时没拿满第四道压轴题我花了40多分钟在中间那个“关键节点去重”的地方绕了弯路最后勉强写出了并查集版本但没在本地跑通全部测试用例。考完复盘时我意识到如果当时把第四道题的去重逻辑早点用unordered_set做而不是自己手写一个O(k^2)的查找完全来得及跑通。还有一个很重要的经验牛客笔试平台的评测环境可能和你本地编译器的C标准版本不完全一致。贝壳这场我在选择题里遇到了C标准的题目说明平台至少支持C11。编程题提交时最好注明按C11/14编译用auto、lambda这些语法没问题但如果你用了C17的std::optional或者结构化绑定就要确认平台是否支持否则编译不过白费功夫。6. 考后复盘贝壳这道压轴题的完整推导与优化第四道并查集题我笔试时没有完全AC。考完第二天我把它完整重新推导了一遍这里把每一步细节都写出来希望能帮到之后遇到类似题的同学。先说暴力思路如果只有两三个关键节点可以直接枚举所有道路组合看最少加几条边能连通。但关键节点数量k可能很大枚举组合是指数复杂度不可行。所以必须用连通分量来抽象。第一步用并查集把所有道路合并。这里有个前提N和M都可能达到10^5级别所以并查集要做路径压缩否则find会退化成O(n)。第二步遍历关键节点列表为每一个关键节点找到它的根节点。这一步里面每个关键节点只需要取find(k)就够了不需要关心它所在连通块的其他节点。第三步统计去重后的根节点数量。这里直接用unordered_set是最简洁的#include unordered_set // 统计关键节点所在的不同连通块数量 std::unordered_setint rootSet; for (int k : keys) { rootSet.insert(uf.find(k)); } return (int)rootSet.size() - 1;这比我自己笔试时的写法简洁太多也完全避免了O(k^2)的重复查找。第四步答案是rootSet.size() - 1。为什么因为如果有3个互相不连通的连通块分别包含若干关键节点那么把第一个和第二个连通块之间连一条路再把第二个和第三个之间连一条路就只需要2条路刚好让所有关键节点都在同一个连通块里。每增加一个连通块只需要额外加一条边。这个推导过程其实映射了一个通用规律在无向图里让k个连通分量变成1个连通分量至少需要k-1条边。并查集的典型应用包括判断图是否连通、连通分量数量、最小生成树的Kruskal算法等刷题时可以把这些题型放在一起总结。7. 这类笔试的长期备战建议从贝壳这次的考点反推学习重点考完贝壳这次笔试我最大的感受是现在的C校招笔试越来越不满足于“背八股”和“刷模板题”了。它要求你真正理解语言底层机制并且能灵活组合算法和数据结构解决业务问题。如果你还有几个月才参加秋招想按这次笔试的考察内容做针对性准备我建议按这个优先级来第一算法模板必须滚瓜烂熟。二分、排序、双指针、贪心、DP、并查集、单调栈、快速幂、字符串处理这些都是常客。尤其是并查集贝壳这次考了很多其他公司也喜欢考因为它在业务里确实有广泛的应用场景社交网络连通性、物流网络连通性、城市基础设施连通性等。写并查集的时候路径压缩和按秩合并这两个优化必须背下来笔试时直接默写。第二C新特性要追到C17。constexpr、移动语义、lambda、智能指针、std::function、std::optional、结构化绑定这些是区分“会写C”和“懂C”的分水岭。贝壳的选择题明显想筛选出后者。第三多线程和并发是必须掌握的进阶项。ABA问题、CAS、互斥锁、读写锁、条件变量、线程池这些概念不仅笔试会考面试聊项目时提到也会有明显的加分效果。贝壳的笔试至少出现了两道多线程相关题说明这家公司的技术栈里并发场景占比不低。第四系统设计意识要提前培养。贝壳最后那道缓存设计题不会写代码也能答但对没有系统设计经验的同学来说可能会卡在“从哪开始想”。我建议平时看技术博客时遇到“缓存和数据库一致性”“分布式锁”“消息队列应用场景”这类内容多思考一下它解决的到底是什么问题别只收藏不看。做题顺序和时间管理也是可以通过模拟考练出来的。秋招笔试高峰期每周至少有3-5场笔试每场都要认真对待按真实考试节奏走别偷懒只在本地IDE里改来改去不提交。只有多提交才能感受牛客评测系统的提示是什么风格、超时是什么提示、编译错误怎么快速定位这些“软技能”同样决定最终分数。我后来反思贝壳这场笔试最亏的地方不是算法不会而是第三道DP题明明可以做二分优化却偷懒用了线性查找第四道题明明可以用unordered_set却手写了低效的去重。这两道题如果能拿满最后总分会漂亮很多。建议大家在日常刷题时就养成一个习惯写完暴力解法之后多想一步“这里能不能用更优的数据结构优化一下”这个习惯一旦养成笔试时的代码质量会明显提升。