阿里算法岗笔试复盘:二分答案、TopK、拓扑排序与滑动窗口
2026/9/10 19:04:51 网站建设 项目流程

刚考完阿里这轮算法岗笔试,趁着记忆还热乎,我赶紧把题目和复盘思路整理出来。说实话,看到卷子那一刻我反而松了口气,整体没有特别偏门的题,四道题基本都是算法岗笔试里的常客:二分答案、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段,每段负载之和的最大值尽量小”,本质上就是一个让“最大值最小化”的二分答案题。能不能识别出这个结构,决定了你的解题方向。

第二,看你对复杂度有没有敏感度。数据范围不同,选择的算法完全不同。同样是 TopK,n到了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 设成 0,check 会在第一个任务上就失败,二分也能收敛,但会多做很多无意义的迭代。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, key=lambda 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 也接近 n,logK 那一项会拖慢速度。另一个思路是用快速选择,也就是基于快排的 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 贪心”,概括得越准,解题路径越清晰。

如果你现在离笔试还有一段时间,建议把每类经典题的模板代码整理成一个本地文件,考试前一天过一遍,重点看边界条件和复杂度。考试时遇到同类的题,你不需要从零开始思考,只需要套框架再针对业务场景微调。这看起来是笨办法,但确实是我一次次笔试验证下来最稳的方法。希望这篇复盘能帮到你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询