
刚考完阿里这轮算法岗笔试趁着记忆还热乎我赶紧把题目和复盘思路整理出来。说实话看到卷子那一刻我反而松了口气整体没有特别偏门的题四道题基本都是算法岗笔试里的常客二分答案、TopK、拓扑排序、滑动窗口只是每道题都套了一层业务场景的外壳。这篇复盘我会把题面还原、解题思路、参考代码以及考试时容易踩的坑都写清楚给后面准备大厂算法岗笔试的同学做个参照。如果你也在准备阿里系列或者其他大厂的算法岗这篇内容应该能帮你少走不少弯路。我会尽量把每道题的思考链路讲透不光是给一份能跑的代码更重要的是让你知道考场上看到这种题第一反应应该往哪个方向想。1. 整体题型复盘与考察逻辑1.1 这次笔试题量、时间与题目分布先说下这场笔试的基本盘。总共4道编程题考试时间120分钟使用的是常见的ACM模式也就是你得自己处理输入输出。题目难度梯度我个人体感是第1题中等偏易第2题中等第3题中等偏上第4题看起来难但思路通了以后反而比第3题好写。题型分布大致如下题号核心考点难度建议用时场景包装第一题二分答案 贪心中等偏易15-20分钟任务调度 / 算力分配第二题TopK / 堆 / 哈希中等20-25分钟热门商品 / 高频词统计第三题拓扑排序 / 环检测中等偏上25-30分钟模块依赖构建第四题滑动窗口 / 哈希表中等20-25分钟日志关键字覆盖这个分布其实是比较典型的阿里风格不考特别偏门的算法但会把经典题包装成业务问题考察你是否能把实际问题抽象成已知模型。所以平时刷题如果只记模板不理解原理考场上是比较吃亏的。1.2 算法岗笔试到底在考什么很多同学以为算法岗笔试就是 LeetCode 刷题比赛谁刷得多谁分高。我自己的感受是出题人更想通过这几道题看到你的建模能力和代码落地的严谨度。第一看你能不能把业务描述转化成算法模型。比如“把一组任务分成连续k段每段负载之和的最大值尽量小”本质上就是一个让“最大值最小化”的二分答案题。能不能识别出这个结构决定了你的解题方向。第二看你对复杂度有没有敏感度。数据范围不同选择的算法完全不同。同样是 TopKn到了10^9你还写全排序那基本不可能过。考察的不仅是“会不会做”还有“能不能在限制下做出来”。第三看代码细节。边界条件、整数溢出、输入解析、空值处理这些很容易拉开差距。尤其 ACM 模式下一个输入输出的细节错了整个题直接零分特别冤。后面我会专门把这块的坑整理出来。2. 第一题二分答案 贪心任务连续划分问题2.1 题面复盘与核心模型题目的大意是系统有 n 个任务按顺序排好每个任务有负载值 a[i]现在要把这些任务连续地分成 k 组每组内部的负载求和要求所有组负载最大值尽量小。最终输出这个最小化的最大值。我先说看到这个题的第一反应它长得特别像“把数组分成k段让每段和的最大值最小”这就是经典的二分答案题目。题目里“连续分组”四个字很关键意味着不是任意组合而是在数组上切 k-1 刀。如果想不到二分可能会往 DP 上想比如 dp[i][j] 表示前 i 个元素分成 j 段的最大段和最小值但这个题目数据范围如果 n 到 10^5二维 DP 直接超时超空间。所以看到“最大值最小化”或者“最小值最大化”这类表述基本第一反应就是二分答案。2.2 二分答案的“为什么”和上下界推导二分答案的核心思想是我们去猜一个可能的答案 mid然后验证这个 mid 是否可行。题目要求“最大值尽量小”也就是说存在一个最优值 X当我们的猜测 mid X 时一定可以找到一种分法让每段和都不超过 mid当 mid X 时无论怎么分都不可能做到。这里就有一个很重要的单调性mid 越大限制越松越容易满足mid 越小限制越紧越难满足。所以我们可以在这个单调的区间里二分查找最小的可行值。上界和下界怎么定下界可以取 max(a[i])因为不管怎么分负载最大的那个任务一定会落在某个组里所以组和至少不会小于这个值。再严格一点下界也可以取 max(max(a[i]), ceil(sum/k))其实 max 就够了。上界直接取 sum(a)也就是把所有任务放一组这一组的和不可能超过总负载。这样二分区间是 [max(a[i]), sum(a)]。check 函数就很简单了贪心地从左往右扫尽量把更多任务塞进当前组只要当前组累加和不超过 mid就一直塞如果当前任务加上去会超过 mid就新开一组。如果最后需要的组数小于等于 k说明 mid 是可行的可以尝试更小的值否则就需要增大 mid。2.3 参考代码实现def check(a, k, limit): cnt 1 cur 0 for x in a: if cur x limit: cnt 1 cur x else: cur x return cnt k def solve(): n, k map(int, input().split()) a list(map(int, input().split())) left, right max(a), sum(a) while left right: mid (left right) // 2 if check(a, k, mid): right mid else: left mid 1 print(left) if __name__ __main__: solve()这里二分模板用的是“求最小值”的写法当 check(mid) 为 True 时说明 mid 可行我们把右边界收到 mid否则左边界收到 mid 1。这样最终 left 就是最小可行值。2.4 实战中的易错点和个人心得这题看起来简单考场上照样有很多细节会翻车。第一个坑是初始组数 cnt 应该从 1 开始而不是 0因为哪怕一个任务都不往里塞只要数组非空第一组已经存在了。第二个坑是 cur 的更新方式当 cur x 超过 limit 时新开一组后当前任务 x 要作为新组的初始值而不是把 cur 清零再加 x。这两个地方弄错样例都过不了。还有一个要注意的是二分边界。left 取 max(a) 不是为了好看而是保证任何单任务都不会超过我们允许的段和。如果 left 设成 0check 会在第一个任务上就失败二分也能收敛但会多做很多无意义的迭代。right 取 sum(a) 也是同理保证初始区间一定包含可行解。数据范围大的话中间累加 cur 和 sum(a) 要用 64 位整数。Python 没有溢出问题但如果你用 C 或 Java 写就要小心 int 溢出尤其是 n 到 10^5、a[i] 到 10^9 的时候sum 会到 10^14 级别必须用 long long。这个细节我见过不少人在笔试里栽过。3. 第二题TopK 高频元素堆与快速选择的抉择3.1 场景化题目怎么读第二题换了个贴近电商业务的场景某个时间窗口内有大量商品点击记录每条记录是一个商品 ID要求统计出现次数最多的 K 个商品并按出现次数从高到低输出。如果出现次数相同按商品 ID 从小到大输出。剥掉场景这题就是经典的“前 K 个高频元素”。数据范围我记得是商品 ID 数量 n 很大但 K 相对较小。这题考察的核心有两个一是哈希表统计频率这是基础中的基础二是如何在频率统计完以后高效地取出 TopK。3.2 解法一哈希表 小顶堆最常规、也最适合考场的做法就是哈希表加小顶堆。先用一个字典统计每个商品的出现次数然后遍历统计结果用一个大小为 K 的小顶堆维护当前出现频率最高的 K 个元素。这里的核心点是堆顶是堆中最小的元素。每遍历一个新元素如果堆还没满就直接入堆如果堆满了且当前元素频率大于堆顶就弹出堆顶再把当前元素塞进去。这样遍历完所有元素以后堆里留下的就是全局频率最高的 K 个元素。因为堆只维护 K 个元素插入和删除的复杂度都是 O(log K)整体复杂度 O(n log K)。import heapq def solve(): n, k map(int, input().split()) records list(map(int, input().split())) freq {} for x in records: freq[x] freq.get(x, 0) 1 heap [] for key, cnt in freq.items(): if len(heap) k: heapq.heappush(heap, (cnt, key)) elif (cnt, key) heap[0]: heapq.heapreplace(heap, (cnt, key)) res sorted(heap, keylambda x: (-x[0], x[1])) for cnt, key in res: print(key, cnt) if __name__ __main__: solve()注意这里堆元素用 (cnt, key) 的元组。Python 的 heapq 默认是小顶堆比较元组时会先比较 cnt 再比较 key所以堆顶就是频率最小、ID 也较小的元素。如果题目要求频率相同按 ID 从小到大输出那么只要一个元素的 (cnt, key) 大于堆顶的 (cnt, key)它就应该替换堆顶上面代码里的比较条件就是这么来的。3.3 解法二快速选择 / 基于计数的桶排序如果 K 特别大接近 n用堆的复杂度是 O(n log K)其实也能接受。但如果 n 到 10^6 级别K 也接近 nlogK 那一项会拖慢速度。另一个思路是用快速选择也就是基于快排的 partition 思想在平均 O(n) 时间内找到前 K 大的元素。不过快速选择有个问题它只能找出一组 TopK 元素但题目要求按频率有序输出所以找到以后你还得对这 K 个元素做一次排序。另外快速选择的最坏时间复杂度是 O(n^2)虽然随机化以后很难触发但笔试判题环境不一定给随机种子还是有一点风险。还有一类特殊场景可以用桶排序如果元素频率的最大值 maxFreq 不大可以建一个长度为 maxFreq 1 的数组每个桶存放对应频率的商品列表。这样做一次 O(n) 遍历就能拿到所有频率商品再从高到低扫桶输出。不过这个做法内存开销和 maxFreq 挂钩频率分布特别不均衡时不一定划算。3.4 大数据量下的扩展思路笔试能过的话小顶堆方案已经足够。但如果面试官追问“数据量大到单机内存装不下怎么办”你至少要知道分治和归并的思路把数据按哈希分到多台机器每台机器分别统计本机频率并输出局部 TopK最后把各机器的局部 TopK 汇总再做一次全局 TopK。这也是 MapReduce 里常见的两阶段聚合思想算法岗候选人最好能说出来。我自己的感受是TopK 这题真正拉开差距的往往不是堆还是快速选择而是读取和统计阶段的实现稳定性。比如输入是按行给的商品 ID 还是空格分隔的数组有没有可能同一商品在统计时出现次数非常多等等。先把哈希统计这步写对再谈优化不然堆写得再花哨也是白搭。4. 第三题模块依赖与拓扑排序4.1 题面复盘从工程问题到图论模型第三题是四题里最像“阿里味”的一道。题意大致是项目的 n 个模块之间存在 m 条依赖关系每条依赖关系表示某个模块必须先于另一个模块构建。要求判断这些依赖关系是否存在循环依赖如果存在则输出环上任意一个模块编号如果不存在则输出一种合法的模块构建顺序。这个场景在真实工程里非常常见。我平时做服务端开发时配置中心、构建系统、任务编排全都会遇到依赖图。剥掉工程包装以后本题核心就是判断有向图是否有环如果没有环就输出一个拓扑排序结果。看到“依赖关系”“先后顺序”这些词第一反应就是建图然后跑拓扑排序。拓扑排序的算法逻辑不复杂维护每个节点的入度先把所有入度为 0 的节点入队然后依次从队首取出节点把它加入结果序列并把它所有后继节点的入度减 1如果某个后继入度变为 0就把它也入队。如果最终结果序列的长度等于节点总数说明所有节点都能排进一个合法顺序也就是没有环否则说明图中有环。4.2 构造环检测与拓扑排序的实现from collections import deque def solve(): n, m map(int, input().split()) graph [[] for _ in range(n 1)] indeg [0] * (n 1) for _ in range(m): u, v map(int, input().split()) graph[u].append(v) indeg[v] 1 q deque() for i in range(1, n 1): if indeg[i] 0: q.append(i) order [] while q: u q.popleft() order.append(u) for v in graph[u]: indeg[v] - 1 if indeg[v] 0: q.append(v) if len(order) n: # 存在环找一个入度仍未消掉的节点输出 for i in range(1, n 1): if indeg[i] 0: print(cycle, i) return else: print(ok, .join(map(str, order))) if __name__ __main__: solve()当 len(order) n 时说明有些节点入度永远不能变成 0它们就在环上。考试时其实不用精确输出整个环随便输出一个环上节点就能过样例。我上面的代码就是找到第一个 indeg 不为 0 的节点直接输出简单直接。4.3 深度优先检测环与拓扑排序的区别拓扑排序除了上面这种 Kahn 算法还能用 DFS 做。DFS 的思路是给节点打三个状态标记未访问、访问中、已访问。当 DFS 遍历某个节点的后继时如果遇到一个“访问中”的节点说明找到了环。递归结束以后把节点标记为“已访问”并且把节点插入结果列表头部得到的就是拓扑序。import sys sys.setrecursionlimit(1000000) def solve(): n, m map(int, input().split()) graph [[] for _ in range(n 1)] for _ in range(m): u, v map(int, input().split()) graph[u].append(v) state [0] * (n 1) # 0: unvisited, 1: visiting, 2: visited order [] has_cycle False def dfs(u): nonlocal has_cycle state[u] 1 for v in graph[u]: if state[v] 1: has_cycle True return if state[v] 0: dfs(v) if has_cycle: return state[u] 2 order.append(u) for i in range(1, n 1): if state[i] 0: dfs(i) if has_cycle: print(cycle, i) return print(ok, .join(map(str, reversed(order)))) if __name__ __main__: solve()Kahn 算法和 DFS 各有优劣。Kahn 算法实现直观不用考虑递归深度的问题DFS 的好处是天然能区分“访问中”和“已访问”在找环路径的时候更灵活。如果 n 到了 10^5 甚至更大用 Python 写 DFS 要记得调大递归深度限制不然直接 RecursionError这个坑我在别的笔试里踩过不止一次。考场上我更推荐 Kahn因为它的逻辑不容易漏状态。4.4 图论题的考场判断思路这类题在算法岗笔试里出现频率很高而且经常裹着不同的皮比如编译依赖、镜像构建顺序、数据血缘关系、微服务调用链。识别方法就是盯住几个关键词“依赖”“先后”“前置条件”“能否完成”。读题以后先在纸上画一下样例的数据结构确定节点编号方式、边方向是“先修指向后修”还是“后修指向先修”然后选合适的算法。方向搞反了整个拓扑序列就反了判题直接全错。我在考场上的习惯是先把节点编号和边方向在草稿纸上写清楚再动手写代码这习惯帮我避免过好几次低级失误。5. 第四题最短覆盖子串滑动窗口查日志关键字5.1 题面复盘与滑动窗口模型第四题是典型的滑动窗口题换了个运维监控的场景。题面大意说有几条日志每条日志里有若干关键字现在给定一个包含若干个目标关键字的列表要求从日志序列中找出一个连续区间使得这个区间覆盖列表里所有关键字并且区间长度尽可能短输出最短长度。这个描述我一看就知道是“最小覆盖子串”LeetCode 76 题的亲戚。常规做法是双指针滑动窗口右指针不断向右扩展窗口直到窗口内已经包含所有目标关键字然后尝试收缩左指针在保持窗口仍然包含所有目标关键字的前提下尽量让区间变短。每次右指针移动时更新答案最终得到全局最短长度。滑动窗口的难点不在于双指针本身而在于“如何高效判断当前窗口是否已经覆盖所有目标关键字”。最直观的做法是每次移动指针后都重新数一遍窗口内各关键字的出现次数然后和目标列表比较这样做单次判断 O(m)整体最坏 O(nm)数据一大就超时。正确做法是用一个哈希表记录目标关键字的剩余需求量再用一个变量维护“还有多少个关键字种类未满足”。5.2 代码实现与计数器维护细节def solve(): target input().split() logs input().split() need {} for ch in target: need[ch] need.get(ch, 0) 1 need_cnt len(need) left 0 ans float(inf) window {} for right, ch in enumerate(logs): window[ch] window.get(ch, 0) 1 if ch in need and window[ch] need[ch]: need_cnt - 1 while need_cnt 0: ans min(ans, right - left 1) left_ch logs[left] window[left_ch] - 1 if left_ch in need and window[left_ch] need[left_ch]: need_cnt 1 left 1 print(ans if ans ! float(inf) else -1) if __name__ __main__: solve()这里面最精妙的地方是 need_cnt 的维护。need_cnt 表示“还有多少个关键字种类没有达到目标数量”。当右指针加入一个关键字 ch并且它当前在窗口里的数量恰好等于目标数量时说明 ch 这个种类已经满足了need_cnt 减 1。当左指针要移出 left_ch并且移出后 left_ch 在窗口里的数量已经小于目标数量说明 left_ch 从满足变成不满足了need_cnt 加 1。这样整个算法只需要 O(1) 时间维护状态总复杂度 O(n)。5.3 高频易错点计数时机与空值判断我第一次写这题时最容易错的是把“等于目标数量”写成“大于等于目标数量”。仔细想想如果窗口里 ch 的数量已经远超目标数量这时加入一个 ch 并不改变 ch 是否满足的状态只有从“不够”到“恰好够”的这一刻才应该减 need_cnt。类似地左指针移出时只有从“恰好够”变成“不够”的那一刻才加 need_cnt。写错这个条件结果会差很多。还有个问题是题目如果允许空字符串或者日志序列为空这种情况下应该直接输出 0 或 -1。我在代码里把初始 ans 设为无穷大如果最后没更新就输出 -1这算是个兜底。不过更稳妥的做法是读入后在函数开头先判断一下 target 是否为空为空直接返回 0。虽然笔试数据一般不会这么刁钻但养成判断边界的习惯没坏处。5.4 相似滑动窗口题的迁移方法滑动窗口家族很大除了最小覆盖子串还有无重复字符的最长区间、区间内元素种类不超过 k 类的最长区间、区间和不超过目标值的最长区间。它们的共同框架都是“右指针扩张、左指针收缩、用某种计数器或哈希表维护窗口状态”。我个人总结的经验是凡是看到“连续子数组/子串”“最短/最长”“覆盖/包含”这几个特征词组合大概率就是滑动窗口。做这类题先想清楚窗口的“不变量”是什么最小覆盖子串的不变量是窗口覆盖所有目标关键字然后确定在什么条件下收缩左指针。想清楚这两点代码的框架基本就固定了。6. 手撕代码的避坑清单与备考思路6.1 ACM 模式下的输入输出细节阿里笔试是 ACM 模式意味着你写的代码要自己处理输入和输出。这个要求看着基础但实际操作中特别容易出问题。最常见的坑是数据读取不完整、行尾有空格或换行符没处理干净、多组测试数据之间有空行等。我的建议是开考后先用几分钟把输入模板写好统一用 sys.stdin 或 input()然后立刻想清楚题目给的是单组测试还是多组测试。如果是多组测试循环读取输入时要注意文件结束符的处理别在最外层多加一层 while 导致死循环。输出时如果需要空格分隔多个值用 .join(map(str, res)) 这类方式统一构造避免遍遍历时多打印空格影响格式判断。另外如果本机调试没问题但提交超时可以检查是不是输入解析太慢。Python 读大数据时 input() 比 sys.stdin.readline() 慢不少n 到 10^6 级别时差距很明显。我在正式笔试里通常直接写 sys.stdin.readline省得最后为了这点 IO 性能去改代码。6.2 时间分配与做题顺序策略这次四道题我给自己定的策略是先易后难先拿能稳拿的分。第一题看完题目就确定是二分答案直接开写大概 15 分钟通过样例。第二题哈希加堆属于背过模板的题20 分钟内解决。第三题拓扑排序也顺利但我在输出格式上犹豫了一下多花了点时间确认。第四题滑动窗口写起来很顺手反而比第三题快。我把建议时间分配再整理一下方便你参考题目类型建议用时做题策略二分答案 / 贪心20 分钟以内识别“最大最小化”快速确定上下界TopK / 堆 / 排序25 分钟以内先写哈希统计再定优先队列图论 / 拓扑 / 树25-30 分钟先建图再跑模板注意环判断滑动窗口 / 双指针25 分钟以内固定左右指针框架维护计数器如果碰到一道题想了 15 分钟还没有明确思路先把会做的题做掉回头再啃这道题。笔试分数是按照例点算的部分通过总比留空强。真没思路的时候写一个暴力解把能拿的用例分先拿到也有价值。6.3 算法岗刷题优先级与方向建议根据这次笔试题型再往大了说我的体感是阿里算法岗笔试更偏爱这几类算法二分答案、堆与排序、图论、滑动窗口、动态规划、字符串匹配。如果你想有针对性地准备可以按优先级刷第 1 优先级二分答案、TopK/堆、滑动窗口、拓扑排序。这四个是高频考点也是我这次碰到的原题类别。第 2 优先级背包类 DP、LIS/LCS、区间 DP遇到就学不追求题海。第 3 优先级并查集、字典树、字符串哈希。这类题偶尔出现但出现就能拉开差距。刷题时不要盲目追求数量要把每道经典题吃透。拿一道题来说你至少要能回答这三个问题这道题为什么用这个算法换一种做法为什么不行边界条件有哪些如果只能答出“这题我见过用 XX 算法”那面试官一问原理就露馅了。6.4 最后的小提示我个人实际参加笔试的体会是决定成绩好坏的不一定是刷题量而是考场上能不能快速把业务包装还原成算法模型。这种能力只能靠平时做题时多做一步“翻译训练”每看到一道题先逼自己用一句话概括它的算法模型再动手写。比如“它有向图求拓扑序”“它是二分答案 check 贪心”概括得越准解题路径越清晰。如果你现在离笔试还有一段时间建议把每类经典题的模板代码整理成一个本地文件考试前一天过一遍重点看边界条件和复杂度。考试时遇到同类的题你不需要从零开始思考只需要套框架再针对业务场景微调。这看起来是笨办法但确实是我一次次笔试验证下来最稳的方法。希望这篇复盘能帮到你。