ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

阿里算法岗笔试真题解析:滑动窗口、并查集与树形DP全拆解

阿里算法岗笔试真题解析:滑动窗口、并查集与树形DP全拆解 3月底投的阿里系列算法岗笔试安排在3月28号这场。这几天后台一直有人问我考了什么、难度怎么样、有没有原题。我先把话说在前头网上传的所谓官方原题基本都不靠谱这场笔试的题我也只能根据同批考生的回忆做还原细节跟原题可能有出入但考点、出题风格、代码实现思路都是实打实的。按这套题目去刷、去复盘至少能把笔试的及格线踩稳运气好一点还能冲个高分。这场笔试整体给我的感觉是不考偏题怪题但非常考验基础功和临场审题能力。四道编程题覆盖了数组滑动窗口、贪心与堆、图论并查集、树形DP这几个主流方向。每道题单拎出来都不是那种一眼秒的送分题但也没有hard到让人完全无从下手。关键在于你能不能快速认出题型找到正确的算法方向并在有限时间内把代码写对、写稳。下面我就按回忆还原的题目逐一拆解每道题都会给思路、代码、复杂度分析和容易踩的坑。1. 笔试概况与考情分析1.1 这场笔试的整体风格与难度定位先聊整体印象。阿里系列的算法岗笔试一般不会像ACM区域赛那样搞一堆恶心人的构造题或数学题它的核心目的其实是筛选两件事第一你有没有扎实的数据结构与算法基本功第二你面对一个中等偏上的业务算法问题时能不能快速抽象成经典的模型。这次3月28日的场次同样延续了这个风格。四道题全部是经典模型换壳没有一道是原创到需要现场发明算法的。比如有一道题看似在描述一个很复杂的业务场景拆到底就是滑动窗口有一道题表面上在问最短完成时间本质是贪心加桶思想还有一道题是网格连通性问题考的是离线并查集。这种出题方式在近两年的大厂笔试里非常典型题干写得很长把业务包装得很满但实际上算法内核都是你刷过的题。难度梯度也比较标准。第一题偏简单给基础选手一个保底分第二题和第三题是中等偏上决定你能不能进下一轮第四题是树形DP属于区分度最明显的一题能完整AC的人不会太多。如果你目标是拿到面试机会前三题要做稳第四题至少要写出暴力版本拿部分分。1.2 核心考点分布与准备方向我把这场笔试的考点拉了一张表方便你对照着查漏补缺题号核心考点难度常见变体第一题前缀和、单调队列/滑动窗口简单~中等和为K的子数组、最长无重复子串第二题贪心、计数、桶思想中等LeetCode 621任务调度器第三题并查集、离线处理、逆向思维中等偏上网格连通块、动态加边第四题树形DP、树上最大权独立集偏难打家劫舍III、树的直径从这张表能看出来准备这类笔试性价比最高的方向其实是把高频的算法原型练到肌肉记忆程度。不要指望考场上去现推公式或者现想思路笔试环境下的时间压力根本不允许。平时刷题的时候要有意识地做题型归纳比如看到相邻连续子数组就想到滑窗和前缀和看到连通块集合合并就想到并查集看到树上的选择问题就立刻往树形DP上靠。思维反射快了AC率自然就上来了。2. 题目一数组与滑动窗口2.1 题目描述与考点定位这道题是整场笔试的第一题题干包装了一个物流调度的场景去掉业务壳子以后核心问题是这样给定一个长度为 n 的整数数组 nums 和一个目标值 k你需要找到一个最长的连续子数组使得该子数组的所有元素之和不超过 k。输出满足条件的最长子数组长度。如果不存在合法子数组输出 0。数据范围1 ≤ n ≤ 10^5-10^9 ≤ nums[i] ≤ 10^9-10^9 ≤ k ≤ 10^9。为什么我说它是滑动窗口模型呢这里有个关键细节nums[i] 可以是负数。如果数组全是正数这道题就是经典的和不超过k的最长子数组直接双指针滑动窗口就能解决因为窗口右边界右移时左边界也只会单调右移不会往后退。但一旦引入了负数窗口和变大变小就失去了单调性双指针就不一定成立了这是这道题真正的陷阱。很多人在第一题就翻车恰恰是因为平时刷的滑动窗口题都是正整数版本考场上看到负数直接默认套模板结果要么超时要么答案错误。这也提醒我们笔试里越是第一题越要仔细读数据范围。2.2 核心解题思路前缀和加单调队列面对连续子数组加和不超过k并且元素可负的情况正确的打开方式是把它转化为前缀和问题。设 prefix[i] 表示前 i 个元素的前缀和那么子数组 [l, r] 的和可以写成 prefix[r] - prefix[l-1]。题目要求 prefix[r] - prefix[l-1] ≤ k也就是对于每个右端点 r需要找最靠左的左端点 l-1满足 prefix[l-1] ≥ prefix[r] - k。如果我们遍历 r 的时候能维护前面所有 prefix 值的一个有序集合每次二分查找第一个大于等于 prefix[r] - k 的位置就能求出以 r 结尾的最长合法子数组长度然后整体取最大值。这里有个更优雅的解法因为我们要找的是最靠左的满足条件的前缀位置所以维护一个下标递增、prefix值也严格递增的单调队列会非常合适。为什么因为如果后面某个位置的 prefix 值比前面某个位置小或者相等那前面那个较旧、前缀和又较大或相等的位置在后续匹配中就永远不可能是最优解——它既不够靠左同一轮中前缀和条件又更紧留着只会浪费计算。这么说可能有点绕我用一个生活化的类比解释想象你在排队买奶茶每个人的前缀和相当于他手里拿的号。如果后面来的人拿的号比前面的人还小那前面那些拿大号的人就永远等不到优先叫号可以直接从队列里离开。单调队列干的就是这个事它始终保持队内号递增从而保证每个位置只进队出队一次整体复杂度 O(n)。2.3 代码实现与细节处理from collections import deque def max_len_subarray(nums, k): n len(nums) prefix [0] * (n 1) for i in range(1, n 1): prefix[i] prefix[i-1] nums[i-1] dq deque() dq.append(0) # 前缀和数组下标0前缀和为0 ans 0 for r in range(1, n 1): # 队头是当前满足 prefix[head] prefix[r] - k 的最靠左位置 while dq and prefix[dq[0]] prefix[r] - k: # 这里实际上是找第一个大于等于 threshold 的位置 # 如果队头的值比 threshold 小说明不满足队头可以弹出观察下一个 break # 注意上面这个循环写法容易绕晕换一种更清晰的二分思路更好 pass上面这段代码我在写的时候自己都嫌绕实话说单调队列配合找第一个满足条件的下标有一个细节需要特别注意队列维护的只是潜在最优前缀的下标序列想直接取队头去判断是否满足 prefix[head] ≥ prefix[r] - k 是可以的但如果队头不满足我们需要的是从队头开始往后的第一个满足者单调队列只能保证数值递增不能保证下标连续所以严谨的写法是配合二分。from bisect import bisect_left def max_len_subarray(nums, k): n len(nums) prefix [0] * (n 1) for i in range(1, n 1): prefix[i] prefix[i-1] nums[i-1] # 维护单调递增的前缀和序列元组存(前缀和值, 最靠左的下标) stack [] ans 0 for r in range(n 1): threshold prefix[r] - k # 二分找第一个前缀和 threshold 的下标 idx bisect_left(stack, (threshold, -1)) if idx len(stack): ans max(ans, r - stack[idx][1]) # 将当前前缀和插入单调栈值递增 if not stack or prefix[r] stack[-1][0]: stack.append((prefix[r], r)) return ans这样写就清楚多了。整个算法的复杂度是 O(n log n)因为每个位置二分一次插入栈恒定 O(1)。实际测试里 n 10^5 时这个复杂度毫无压力。还要提示一个很隐蔽的坑前缀和数组的 prefix[0] 0 一定要在构建单调结构前就放进去。否则处理到 r 时如果合法子数组恰好从头开始你就永远匹配不到下标0结果会漏掉最长情况。我当年第一次写这道题就栽在这里差点怀疑人生。3. 题目二贪心与堆3.1 题目描述与考点定位第二题是一道任务调度题包装成了服务器任务并发执行的场景。去掉业务背景后是经典的 LeetCode 621 任务调度器给定一个字符数组 tasks每个字符代表一种任务相同任务之间必须间隔至少 n 个时间单位才能再次执行。每个单位时间可以执行一个任务也可以选择待机。求执行完所有任务所需的最短时间。数据范围任务总数 m ≤ 10^5任务种类最多 26 种n ≤ 100。这道题考的是贪心核心问题是怎么安排任务顺序才能让总时间最短第一反应可能是用优先队列模拟调度过程——每次选择当前可执行且剩余次数最多的任务。这也是一种解法同样能 AC因为 m ≤ 10^5堆排序的 O(m log 26) 完全够用。但这个解法的问题在于你写起来容易但未必能说服面试官你真的理解问题的本质。更好的方案是拿数学公式直接算。3.2 核心解题思路桶与公式法我们换个角度看问题。把出现次数最多的那个任务作为锚点比如任务 A 出现了 maxCnt 次。因为相同任务之间必须隔 n 个时间单位所以可以把执行过程想象成排布在一个桶里。具体来说我们搭建 (maxCnt - 1) 个完整轮次每个轮次占 (n 1) 个时间单位因为执行一个任务后要等 n 个单位才能再次执行同类型任务。在这个骨架里前 maxCnt-1 轮中每一轮都放一个次数最多的任务剩余的空位可以填入其他任务也可以留空即待机。最后再把剩下那些出现 maxCnt 次的任务各执行一次也就是追加 maxFreqCount 个时间单位。于是最短时间就是total max(len(tasks), (maxCnt - 1) * (n 1) maxFreqCount)这个公式的巧妙之处在于它把填空过程完全抽象化了。只要其他任务的种类足够多、次数足够密集能填满空位那总时间就是任务总数 m如果填不满那就只能待机总时间由最频繁任务主导。这里我想用一个更容易理解的例子假设最频繁的任务 A 出现 4 次n 2那么 A 之间的圈是 3 个单位时间。画出来就是A _ _ A _ _ A _ _ A中间有 3 个空位区每个区 2 个空位总共能插入 6 个其他任务。如果其他任务数量比 6 多证明它们足够稠密甚至可以在同一区连续执行只要不是同类型总时间就是任务总数如果其他任务少于 6说明空位填不满总时间就是 9 4 - 1 12即上面骨架的长度。3.3 代码实现与验证from collections import Counter def least_interval(tasks, n): cnt Counter(tasks) max_cnt max(cnt.values()) max_freq_count sum(1 for v in cnt.values() if v max_cnt) part_count max_cnt - 1 part_length n 1 return max(len(tasks), part_count * part_length max_freq_count)代码很短但不要小看它。考试时写这个公式最需要确认的是 max_freq_count 的计算是否正确。很多人只统计了最大频次的值却忘了统计达到这个频次的任务种类数。想象一下如果 tasks [A,A,A,B,B,B]n 2maxCnt 3maxFreqCount 2公式结果是 (3-1)*(21)2 8。而实际上排布是 A B _ A B _ A B确实需要 8 个单位时间验证无误。如果面试官追问优先队列模拟法我也给你一个建议能用公式就别用堆堆的方案虽然直观但需要处理冷却中任务队列代码量至少翻一倍而且容易在状态维护上出 bug。笔试环境下花 10 分钟写出正确公式远远好过花 25 分钟写一个复杂模拟最后超时。4. 题目三并查集与离线思维4.1 题目描述与考点定位第三题是网格连通性问题也是让我觉得最有大厂风格的一道题有一个 n × m 的网格初始所有格子都是 0。现在依次执行 q 次操作第 i 次操作把某个格子 (x, y) 从 0 变成 1保证每个格子最多被操作一次。每次操作完成后你需要输出当前网格中最大的由相邻上下左右四个方向的 1 组成的连通块大小。数据范围1 ≤ n, m ≤ 10001 ≤ q ≤ n × m。这道题的考点很明确并查集Union-Find。但真正的难点不在并查集本身而在于每次操作后询问全局最大连通块这句话。如果你按照正常的正向思路每次把一个格子变成 1 后去合并相邻的 1并用一个变量维护当前最大连通块的大小看起来没什么问题实际操作中确实也能做。但要注意一个细节并查集维护连通性天然适合添加操作不适合删除操作。这道题每个格子从 0 变成 1 是不可逆的所以正向操作其实是只加不删正好命中并查集的舒适区。如果你遇到的是每次把 1 变成 0的题目那就要立刻想到离线倒序处理把删除转换成添加再用并查集做——这是这类题目最重要的思维模型。4.2 核心解题思路动态维护最大连通块正向遍历操作序列每遇到一个点亮操作就把它插入并查集。插入的时候先把当前格子的连通块大小初始化为 1。然后枚举上下左右四个邻居如果某个邻居也是 1说明它们属于同一个连通块需要合并。合并时要注意并查集常规的 size 更新只在根节点做合并后把两个根节点的 size 加在一起同时更新全局最大值 max_size。这里有一个极容易写错的地方如果你把每个格子都映射成一个一维下标 id (x-1) * m (y-1)那么每次合并完成后一定要让新根节点的 size 等于两个旧根的 size 之和并且把全局 max_size 和新根 size 取 max。有些同学只在 union 的时候更新了数组忘了维护全局最大值导致整道题错得莫名其妙。离线思路的代码会更复杂一些但作为扩展我建议你掌握如果操作是把 1 变成 0就把整个操作序列倒过来看先假设最终所有格子都是 1然后倒序执行把 0 变回 1每步合并后的最大连通块倒序输出就是答案。这个过程和正向版几乎一致只是多了一个预处理最终状态。4.3 并查集的代码骨架class UnionFind: def __init__(self, total): self.parent list(range(total)) self.size [1] * total def find(self, x): while self.parent[x] ! x: self.parent[x] self.parent[self.parent[x]] # 路径压缩 x self.parent[x] return x def union(self, a, b): ra, rb self.find(a), self.find(b) if ra rb: return 0 # 按大小合并小的并入大的 if self.size[ra] self.size[rb]: ra, rb rb, ra self.parent[rb] ra self.size[ra] self.size[rb] return self.size[ra] def solve(n, m, ops): uf UnionFind(n * m) grid [[0] * m for _ in range(n)] max_size 0 directions [(1,0),(-1,0),(0,1),(0,-1)] for x, y in ops: x, y x-1, y-1 grid[x][y] 1 idx x * m y max_size max(max_size, 1) for dx, dy in directions: nx, ny x dx, y dy if 0 nx n and 0 ny m and grid[nx][ny] 1: ni nx * m ny new_root_size uf.union(idx, nidx) max_size max(max_size, uf.size[uf.find(idx)]) print(max_size)我在代码里注释一下union 函数的返回值可能不太好用因为不同实现返回的含义不一样。更稳妥的做法是 union 完之后直接取当前格子的根节点 size 去更新 max_size。这个习惯能避免很多莫名其妙的 bug。真实笔试里这道题还有一个小坑n 和 m 乘积最大是 10^6Python 的递归并查集深度可能会爆栈所以 find 最好写成迭代形式或者使用 sys.setrecursionlimit 但并不可靠。上面给出的 find 是迭代加路径压缩性能足够稳。5. 题目四树形DP5.1 题目描述与考点定位第四题是一棵树的动态规划也是整场笔试区分度最高的一题。题目大意是给定一棵 n 个节点的树每个节点有一个权值 val[i]。现在需要从树中选择一些节点要求任意两个被选中的节点不能有直接的边相连即不能同时选择一条边相连的两个节点目标是让所选节点的权值之和最大。输出最大权值和。数据范围1 ≤ n ≤ 10^5-10^5 ≤ val[i] ≤ 10^5。如果你刷过 LeetCode 337打家劫舍 III看到这题应该会心一笑这就是把二叉树的限制推广到了一般树。核心模型是树上最大权独立集。这里有个值得留意的点节点权值可以是负数。这就意味着并不是所有能选的节点都一定要选。贪心思路比如隔层取显然是错的因为你无法保证取一层不取另一层在带权的时候依然是最优的必须通过树形 DP 精确决策。5.2 核心解题思路状态定义与转移定义两个状态dp[u][0]以 u 为根的子树中不选节点 u 时能获得的最大权值和。dp[u][1]以 u 为根的子树中选择节点 u 时能获得的最大权值和。转移非常直观如果 u 被选择了那么它的所有子节点都不能选所以 dp[u][1] val[u] sum(dp[v][0])。如果 u 没被选择那么每个子节点 v 可以选也可以不选我们取两种情况的较大值所以 dp[u][0] sum(max(dp[v][0], dp[v][1]))。最后答案就是 max(dp[root][0], dp[root][1])。这个 DP 的时间复杂度是 O(n)因为每个节点只被访问一次每条边只被处理一次。难点在于树不一定以 1 为根需要先建图然后 DFS 遍历。笔试环境里如果树的形态不保证为二叉树你需要用邻接表存储并且递归时传入父节点防止往回走。5.3 记忆化搜索与迭代DP实现import sys sys.setrecursionlimit(300000) def dfs(u, parent): dp0 0 # 不选 u dp1 val[u] # 选 u for v in graph[u]: if v parent: continue child_dp0, child_dp1 dfs(v, u) dp0 max(child_dp0, child_dp1) dp1 child_dp0 return dp0, dp1 n int(input()) val [0] list(map(int, input().split())) graph [[] for _ in range(n 1)] for _ in range(n - 1): u, v map(int, input().split()) graph[u].append(v) graph[v].append(u) ans0, ans1 dfs(1, 0) print(max(ans0, ans1))如果你担心递归在极端情况树退化成链导致栈溢出可以改成迭代的拓扑序 DP先 BFS/DFS 一遍求出每个节点的父节点和遍历顺序然后按后序遍历顺序自底向上更新。不过大多数线上笔试环境对 Python 的递归深度限制在 10^5 左右配合 sys.setrecursionlimit 开到 30 万基本够用所以这里直接用递归记忆化搜索也是可行的。这道题我建议大家把扩展也吃透如果题目从不能选相邻节点变成不能选距离不超过2的节点状态数就要变成三四个难度直接上一个台阶。备考时把简单的树形 DP 练熟遇到复杂状态才能举一反三。6. 笔试避坑指南与实战建议6.1 读懂题意的三个关键动作大厂笔试的题干普遍偏长经常包裹业务场景。我自己的经验是读题时不要上来就看故事而是拿着笔在草稿纸上做三件事第一圈出所有数字范围n、q、权值范围这直接决定了要用什么复杂度的算法第二把最后的问题提炼成一句不带业务色彩的纯算法描述比如求最大连通块大小或求最长连续子数组长度第三搞清楚输入输出的格式特别是有没有多组测试用例、要不要排序输出、下标从 0 开始还是 1 开始。这三个动作看起来简单但真的能救你一命。我见过太多人算法思路完全正确就是因为没注意节点下标从 0 开始样例过了提交全挂。审题的时间永远不算浪费。6.2 时间分配与取舍策略算法岗笔试一般给 90 到 120 分钟四道题。我推荐的时间分配是第一题 20 分钟以内第二题 25 分钟以内第三题 30 分钟左右第四题 35 分钟到 40 分钟剩下时间全部用来检查边界和看排在前面但没通过的测试点。如果做到某一题卡了 20 分钟还没有明确思路果断跳过做后面的题。笔试的分数通常按照通过率计算你写出一个暴力的部分分版本拿到 30% 到 50% 的分数比死磕一题最后空着其他题要划算得多。我参加过的多场笔试中先拿稳部分分再回头优化的策略帮我保住了不少分。还有一点要做心理建设第四题如果没 AC不代表笔试一定挂。大部分大厂校招笔试的录取线并不要求满分你前面的题正确率足够高加上第四题的部分分进面希望很大。不要因为最后一题没做出来就心态崩了后面的面试更重要。6.3 常见问题速查表问题表现可能原因解决方案第一题样例过但提交超时用了 O(n^2) 双指针处理负数数组改前缀和加二分复杂度 O(n log n)第二题结果比答案大maxFreqCount 统计错误或忘了取 max(len(tasks), ...)检查最大频次的任务种类数第三题连通块大小不对合并后根节点 size 未更新统一在根节点维护 size合并后取新根 size第三题递归栈溢出并查集 find 写成递归版改成迭代 find 路径压缩第四题答案少了负权节点的情况无脑把所有节点都纳入 DP 决策只按状态转移取 max不要贪心输入数据读取不对忽略了行末空格或空行用 sys.stdin.read 一次性读入这张表是我在复盘同类题目后整理的几乎覆盖了四道题里最容易犯的错。你可以在实战前把这张表过一遍遇到类似报错能立刻定位。最后再分享一个我自己实际笔试中的小习惯交卷前如果还剩 5 分钟不要再改代码逻辑了就检查一件事——把每个循环的边界重新看一遍比如 for i in range(1, n1) 和 range(n) 的区间是否正确、数组有没有越界、取模有没有漏掉。根据我的经验笔试翻车至少有一半是边界写错而不是算法想错。把这一关守住你离面试通知就真的不远了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进