刚做完蚂蚁这轮开发岗的笔试,趁热打铁把手写的思路和踩的坑整理出来。这场是 2026.03.15 的场次,岗位方向是后端开发,整体题型、难度、时间安排都还算典型,准备投大厂开发岗的朋友建议认真看一眼,尤其是里面那几道题目的思考路径,比自己闷头刷题要有价值得多。
这次笔试一共 120 分钟,题量大头是算法编程题,外加少量的基础选择题。选择题部分考得比较杂,有 Java 基础、数据库索引、操作系统进程调度,还有一两道网络协议相关的概念题,整体难度中等,不偏不怪,属于“认真复习了就有分”的类型。编程题部分才是真正拉开差距的地方,一共四道,前三道是标准输入输出的 ACM 模式,最后一道偏工程场景建模,考得挺活。
先说一下整体感受:蚂蚁的开发岗笔试风格,和纯竞赛题不太一样,它更看重“能不能把一个实际问题抽象成算法模型”,然后要求代码在边界条件下依然稳得住。所以平时刷题只追求“能跑通样例”远远不够,边界条件和复杂度分析才是拿分关键。
1. 笔试整体结构与时间分配
1.1 试卷构成和分值分布
先说大框架。整张卷子总分 100 分,客观题占了 30 分,编程题 70 分。我个人的做题顺序是先花 20 到 25 分钟解决选择题,剩下时间全部砸在编程题上。不要在一道选择题上卡太久,比如那两道关于数据库索引和 Java 并发包的题,一眼看不出来就凭积累先选,后面如果有时间再回来推敲。
编程题的分值分布大概是 10 分、15 分、20 分、25 分这样往上走的。越往后越难,分值也越高。很多人一上来就死磕最后一道大题,结果前面简单的题没时间写,白白丢分。我的策略是先快速扫一遍四道题,判断一下难易梯度,然后从最简单的开始写,保证基础分先落袋。
还有一点要提醒:蚂蚁笔试用的在线编辑器,虽然支持主流语言,但补全和提示都比较弱,基本就是记事本水平。平时用惯了 IDEA 或者 VS Code 自动补全的人,上了考场写代码速度会明显下降。建议提前两三周用牛客网那种在线编辑器练手感,尤其是快排、滑动窗口、堆这些常用模板,要能不看资料直接默写出来。
1.2 核心考察方向解析
从“开发岗”这个定位出发,这次笔试题考察的方向挺清晰的。选择题覆盖了计算机基础,编程题则明显偏向“数据结构与算法在实际场景中的应用”,四道题分别涉及字符串处理、图论建模、动态规划、以及一个类似任务调度的工程场景问题。
总结下来,蚂蚁开发岗笔试的核心考察点有三个:第一是编码基本功,包括输入输出处理、边界条件判断、常用容器操作;第二是算法思维,尤其是如何把业务问题抽象成经典模型;第三是复杂度意识,同样的题目,暴力解能过一部分数据,但想要全部 AC,必须得拿出 O(n) 或 O(n log n) 级别的解法。
我备考的时候把 LeetCode 题库按企业标签刷过一轮,但真正考试的时候发现,蚂蚁的题比 LeetCode 上标注为“中等”的题要稍微活一点。它不会直接告诉你“这是并查集”,而是给你一个看似复杂的故事背景,让你自己去发现底层模型。这一点在第三题和第四题表现得特别明显。
2. 选择题里的高频考点回顾
2.1 基础概念题避坑指南
选择题部分,我印象比较深的有几道。一道是关于 Java 的HashMap在并发场景下的问题,问的是 JDK 1.7 和 1.8 在底层实现上的区别以及为什么会产生死循环。如果你只是背过八股文,知道“1.7 头插法、1.8 尾插法”,这道题能答上一半,但题目还追问了扩容时链表分裂的条件,这就得真正理解 resize 的过程才能做对。
还有一道 SQL 索引的选择题,给了一个员工表,然后问哪条查询语句可以用到联合索引(department_id, status, created_at)。这种题其实就是在考最左前缀原则,但陷阱在于它把条件里的顺序打乱了,比如WHERE status = 1 AND department_id = 100,很多人一看联合索引就不假思索地选“不能命中”,但实际上数据库优化器会做条件重排,这种场景下依然能走索引。
操作系统那边考了一道关于进程调度算法的题,问的是在时间片轮转调度下,一个进程的平均等待时间怎么算。这道题倒是规规矩矩,只要画得出甘特图就能拿分,但计算量稍大,建议在草稿纸上按时间轴慢慢推进,别心算,容易错。
2.2 这些知识点为什么值得反复看
很多人觉得选择题分少,不值得花大量时间复习。但我的体会是,选择题是最容易拿分的部分,而且复习选择题的过程,本身就是在帮面试做准备。比如 HashMap 的并发问题、索引失效的场景、TCP 四次挥手的状态变化,这些都是面试官喜欢追问的点,笔试之前过一遍,相当于一鱼两吃。
我当时复习选择题是拿“八股文清单”过的,每看到一个知识点,就问自己三个问题:它是什么?它解决了什么问题?它在什么场景下会出问题?能答上来就过,答不上来就记到错题本里。这个习惯帮我省了很多时间,也让我在笔试选择题部分做得比较顺。
3. 第一道编程题:字符串相邻去重变体
3.1 题目大意与思路推导
第一题大概是这么个意思:给定一个只包含小写字母的字符串,要求不断删除相邻且相同的一对字符,直到无法继续删除为止,输出最终字符串。这道题其实就是括号匹配的变体,本质是栈的应用。题目给了 10 分,属于送分题,但送分不代表能丢分,因为它的坑在于删除之后,新相邻的字符如果相同,还要继续删除。
换句话说,如果字符串是abba,你如果只做一遍扫描,删除bb之后得到aa,这还没完,必须继续删除aa,最后得到空字符串。所以关键不是“找到所有相邻重复对然后一次性删掉”,而是“用栈按顺序处理,每进一个字符就和栈顶比较”。
我第一次做的时候用的就是一次性删的思路,样例过了但提交只过了一半测试点,后来才反应过来问题出在“连锁反应”上。这也是笔试里最常见的思维陷阱——只顾着处理当前这一层,忘了操作之后可能产生新的状态。
3.2 高效实现与复杂度分析
正确的解法就是用栈。遍历字符串的每个字符,如果栈不为空且栈顶字符等于当前字符,就弹出栈顶;否则把当前字符压入栈。等到字符串遍历完,栈里剩下的字符就是最终结果,从栈底到栈顶的顺序输出即可。
import java.util.*; public class Main { public static void main(String[] args) { Scanner sc = new Scanner(System.in); String s = sc.nextLine(); Deque<Character> stack = new ArrayDeque<>(); for (char c : s.toCharArray()) { if (!stack.isEmpty() && stack.peek() == c) { stack.pop(); } else { stack.push(c); } } StringBuilder sb = new StringBuilder(); while (!stack.isEmpty()) { sb.append(stack.pollLast()); } System.out.println(sb.toString()); } }这个解法的时间复杂度是 O(n),每个字符最多入栈一次、出栈一次。空间复杂度最坏情况下是 O(n),比如所有字符都不相同的情况。这个题想要满分,还有一个细节:题目如果用的是 BufferedReader 而不是 Scanner,在数据量大到 10^6 级别时能省下不少时间,建议提前准备一个快读模板。
4. 第二道编程题:带权图上的最短路径变种
4.1 题目还原与建模过程
第二题给了一个无向图,节点和边都带权,要求从起点到终点的最短路径。这本来是最短路裸题,但它加了一个条件:你可以把最多 k 条边的权值变为 0。考场上我一眼看过去就知道这是分层图最短路,因为“把 k 条边变成 0”这个操作,本质上就是在原本的图上多开 k 层,每一层代表已经使用了 j 次特权。
建模方式不复杂,把原图复制 k 层,原来的每个节点变成 k+1 层里的不同状态。每对相邻节点之间,除了在同一层里的正常连边之外,还要从第 j 层的节点连一条权重为 0 的边到第 j+1 层的对应节点。这样跑一遍 Dijkstra,答案就是最后一层终点的最短距离。
说实话,这道题模型不算难,15 分很合理,考的是有没有见过分层图这个套路。如果你之前刷过“飞行路线”或者“免费搭车”这类题,应该十分钟之内能建立正确的模型。但我在考场上还是犹豫了一下,因为题面里没有直接说“最多使用 k 次免费机会”,而是用一个叙述性的场景把条件包起来了,差点没看出来是分层图。
4.2 Dijkstra 的扩展与代码实现要点
我实现的版本用了一个dist[i][j]的二维数组,i表示节点编号,j表示已经使用的免费次数。优先队列里存的是三层信息:当前距离、当前节点、已用免费次数。每次从堆里弹出当前距离最小的状态,然后分别尝试“花权重走普通边”和“免费走一条边”两个方向。
import java.util.*; public class Main { static class Edge { int to, w; Edge(int to, int w) { this.to = to; this.w = w; } } static class State implements Comparable<State> { int dist, node, used; State(int dist, int node, int used) { this.dist = dist; this.node = node; this.used = used; } public int compareTo(State other) { return Integer.compare(this.dist, other.dist); } } public static void main(String[] args) { Scanner sc = new Scanner(System.in); int n = sc.nextInt(); int m = sc.nextInt(); int k = sc.nextInt(); List<List<Edge>> graph = new ArrayList<>(); for (int i = 0; i <= n; i++) { graph.add(new ArrayList<>()); } for (int i = 0; i < m; i++) { int u = sc.nextInt(); int v = sc.nextInt(); int w = sc.nextInt(); graph.get(u).add(new Edge(v, w)); graph.get(v).add(new Edge(u, w)); } int start = sc.nextInt(); int end = sc.nextInt(); int[][] dist = new int[n + 1][k + 1]; for (int[] row : dist) { Arrays.fill(row, Integer.MAX_VALUE); } PriorityQueue<State> pq = new PriorityQueue<>(); dist[start][0] = 0; pq.offer(new State(0, start, 0)); while (!pq.isEmpty()) { State cur = pq.poll(); if (cur.dist != dist[cur.node][cur.used]) continue; for (Edge e : graph.get(cur.node)) { int nd = cur.dist + e.w; if (nd < dist[e.to][cur.used]) { dist[e.to][cur.used] = nd; pq.offer(new State(nd, e.to, cur.used)); } if (cur.used < k && cur.dist < dist[e.to][cur.used + 1]) { dist[e.to][cur.used + 1] = cur.dist; pq.offer(new State(cur.dist, e.to, cur.used + 1)); } } } int ans = Integer.MAX_VALUE; for (int i = 0; i <= k; i++) { ans = Math.min(ans, dist[end][i]); } System.out.println(ans); } }有一个细节要说明:当dist[cur.node][cur.used]和堆里取出的cur.dist不一致时,说明这个状态已经被更优的路径更新过,直接跳过,这就是 Dijkstra 的“懒删除”策略。这个优化很关键,不然堆里会积压大量无效状态,导致 TLE。
4.3 复杂度评估与数据范围陷阱
分层图 Dijkstra 的时间复杂度是 O(k(n + m) log(kn)),对于 n 和 m 在 10^5 级别、k 在 10 以内的情况是完全能跑的。但这里有个大坑:如果你把分层图显式地建出来,节点数会变成 n 乘以 (k+1),边数也会跟着膨胀一倍多,内存很容易爆掉。
所以在实际实现里,我采用的是“逻辑分层”而不是“物理分层”——不真的去建一份新图,而是用二维状态数组来表达层数。这样做,图本身还是只有 n 个节点、m 条边,节省了内存,也减少了初始化开销。考场上如果你看到这种题,第一反应应该是“分层图 + 最短路”,但动手前一定想清楚是显式建图还是用状态维度隐式表达。
5. 第三道编程题:动态规划与状态压缩的配合
5.1 完全背包问题还是多重背包?
第三题是一个背包问题,但包装得很生活化。大意是:你有若干种面额不同的代金券,每种代金券有数量限制,问凑出某个目标金额共有多少种组合方式。这道题字面看是多重背包求方案数,但数据范围给了一个关键提示——代金券的种数很少,最多只有 5 种,而目标金额最大到 10^5。
这里就得想一下了,如果真按多重背包来写,把每种代金券拆成 01 背包,复杂度会变成 O(n * 金额 * 数量),大概率过不了全部数据。但种数这么少,意味着可以走生成函数或者状态压缩的思路,甚至可以用母函数来理解:每种代金券的选择情况对应一个多项式,把所有多项式卷起来,目标金额对应的系数就是答案。
真正做题的时候,我选择了把多重背包转成 01 背包时用二进制拆分优化,这个方案稳妥且通用,写起来也不容易出错。二进制拆分的原理很简单:如果某个面额有 x 张,把它拆成若干个 2 的幂次组,就能用这些组表示 0 到 x 之间的任意数量,而不需要一一张展开。
5.2 防溢出的经典处理手法
背包问题求方案数,第一反应是用二维 DP:dp[i][j]表示前 i 种代金券凑出金额 j 的方案数。但实际写的时候可以直接滚成一维数组,只要注意内层循环要倒序遍历,避免同一轮里重复使用已经更新的状态。这是 01 背包的常识,但多重背包经过二进制拆分之后,每一组都是一个标准 01 物品,所以逻辑上完全一致。
还有一个容易坑人的地方是取模。题目里给的模数一般是 10^9 加 7 这种常用大质数,但我在笔试时见过有人因为忘记在dp[j] + dp[j - w]处取模,结果中间过程溢出导致答案全错的案例。也有人在输出答案时取模,但在 DP 更新时没取,等到数值膨胀到超过 long 范围才意识到问题。我的建议是每做一次加法就取一次模,虽然多几行代码,但能避免最恶心的调试过程。
另外,这道题还给了一个小陷阱:目标金额可能是 0。如果是 0,那答案应该是 1,因为“什么都不选”也算一种方案,但忘了初始化dp[0] = 1的话,这里就会直接错。笔试场上这种边界条件没人提醒你,只能靠平时养成的习惯来兜底。
6. 第四道编程题:任务调度的工程建模题
6.1 场景抽象与拓扑排序识别
第四题是整场笔试里最有意思的一道。题目描述了一个类似任务编排系统的场景:有 n 个任务,任务之间存在依赖关系,每个任务有预计耗时,有些任务需要先等前置任务完成才能开始,有些任务可以并行执行。要求计算所有任务完成的最短时间。
这类题一旦看到“依赖关系”“前置条件”“并行执行”这些字眼,基本就是在考拓扑排序。最短完成时间实际上就是关键路径问题,只不过要在拓扑排序的同时,更新每个任务的最早开始时间。具体做法是:按照拓扑序遍历每个任务,当一个任务结束后,用它的完成时间去松弛它的所有后继任务。
我当时读完题先在草稿纸上画了一个 DAG,确定关系表达清楚之后才开始写代码。这个“先画图再写码”的习惯在考场上帮我省了很多调试时间。你只要把任务之间的依赖关系整理成邻接表,再统计每个任务的入度,就能开始拓扑排序了。
6.2 拓扑排序 + 关键路径的完整实现
实现上,我用了一个队列来维护当前所有入度为 0 的任务。初始时把所有没有前置依赖的任务放进去,它们的最早开始时间就是 0。然后每次从队列里取出一个任务,更新它的完成时间,再把它所有后继任务的入度减一,如果某个后继任务的入度变成 0,就把它放入队列。
在这个过程中还要维护每个任务的最早开始时间:一个任务的最早开始时间,等于它的所有前置任务完成时间的最大值。因为要等所有前置任务都完成了,这个任务才能开始。而最终答案,就是所有任务完成时间的最大值。
import java.util.*; public class Main { public static void main(String[] args) { Scanner sc = new Scanner(System.in); int n = sc.nextInt(); int m = sc.nextInt(); int[] cost = new int[n]; for (int i = 0; i < n; i++) { cost[i] = sc.nextInt(); } List<List<Integer>> graph = new ArrayList<>(); for (int i = 0; i < n; i++) { graph.add(new ArrayList<>()); } int[] indegree = new int[n]; for (int i = 0; i < m; i++) { int a = sc.nextInt(); int b = sc.nextInt(); graph.get(a).add(b); indegree[b]++; } int[] earliest = new int[n]; int[] finish = new int[n]; Queue<Integer> queue = new LinkedList<>(); for (int i = 0; i < n; i++) { if (indegree[i] == 0) { queue.offer(i); } } int visited = 0; int answer = 0; while (!queue.isEmpty()) { int task = queue.poll(); visited++; finish[task] = earliest[task] + cost[task]; answer = Math.max(answer, finish[task]); for (int next : graph.get(task)) { earliest[next] = Math.max(earliest[next], finish[task]); indegree[next]--; if (indegree[next] == 0) { queue.offer(next); } } } if (visited != n) { System.out.println(-1); } else { System.out.println(answer); } } }注意这里有一个环检测的妙用:如果把所有能处理的节点都处理完之后,visited的数量不等于 n,说明图里存在环。存在环就说明任务依赖关系有死锁,题目里如果要求这种情况输出 -1,你的代码就能准确接住。这道题在样例里就放了一个有环的例子,目的就是看你能不能想到这一层。
7. 编程题解题顺序与时间管理策略
7.1 先通用技巧还是先冲难题?
编程题的做题顺序确实值得专门讲一下。我自己习惯是“先通读,再排序,先保底,再冲刺”。拿到试卷的第一时间,我会花两三分钟把四道题都浏览一遍,给每道题打一个难度标签。这一轮浏览很重要,它能让你避免陷入“第一题好难,后面其实是水题”的窘境。
具体到这场笔试,第一题的栈应用属于入门难度,第三题的背包属于中等难度,第二题的分层图属于“看到过就会、没看到就不会”的套路题,第四题的拓扑排序属于“思路不难,但实现细节多”的工程题。我的做题顺序是 1 -> 3 -> 2 -> 4,把最有把握的先写掉,把最花时间的放到最后。
总分 70 分的编程题里,拿到 50 分以上的关键在于“不纠结完美解法”。比如第二题如果你没看出分层图,可以用暴力 BFS 配上剪枝拿部分分;第四题如果有环不会输出 -1,把拓扑排序的部分写对也能拿不少分。笔试又不是 ACM 现场赛,部分分积累起来完全能够进入面试轮。
7.2 时间不足时如何合理放弃
这里想聊聊“放弃”这个话题。很多人在笔试时有一个误区,觉得每道题都必须做出来,否则就是失败。但实际上,一场大厂笔试能全 AC 四道题的人是极少数,大多数进入面试的人也就是做出来两题半、三题的水平。你真正要保证的,是会做的题不丢分,而不是把不会做的题硬啃出来。
我的策略是一旦在某道题上卡了 25 分钟还没有清晰的思路,就先停下来,转去做下一道。因为笔试的时长是固定的,多卡一分钟,后面会做的题就少一分钟。这种取舍看起来很亏,但实际上是效率最高的做法。等把能拿的分都拿完之后,再回头啃一开始卡住的那道题,心态和思路都会清爽很多。
8. 考后总结:蚂蚁开发岗笔试的备考方向
8.1 数据结构和算法的复习优先级
结合这次笔试的题目分布,我来给准备蚂蚁开发岗的朋友一个复习方向参考。优先级最高的肯定是各大厂笔试的“标配四件套”:栈与队列、二叉树与图论、动态规划、细节实现能力。其中图论相关的东西尤其要重视,分层图、拓扑排序、并查集、最小生成树这些内容,在蚂蚁的笔试题里出现频率相当高。
站在我的角度,这背后的逻辑也好理解。蚂蚁的核心业务高度依赖分布式系统和任务调度,所以笔试题里天然会带一些“工程抽象”的味道。你以为是在做算法题,实际上它是在通过算法题考察你建模的能力——能不能把一个混乱的现实场景理出清晰的逻辑结构。这就是为什么裸刷 LeetCode 不一定够用,还得有意识地把算法模型往业务场景上套。
8.2 输入输出、编程环境与模拟练习
关于笔试环境,我再多啰嗦两句。蚂蚁笔试用的在线编辑器,不会像本地 IDE 那样帮你自动导包,也不会提示你语法错误。平时用惯了内补全的人,建议提前花一周时间适应。我个人的办法是,在牛客网找几家大厂的往年真题来刷,并且只用网页编辑器写,不切到本地 IDE。
写的时候也要注意输入输出的处理方式。Scanner在数据量小的时候没有问题,但如果数据量到了 10^6 级别,Scanner会比BufferedReader慢好几倍。建议不管题目数据范围写没写,都直接上快速 IO 模板,这个习惯关键时刻能救命。
经常有朋友问我,刷题刷到什么程度才敢去投蚂蚁。说实话,没有绝对的量化标准,但你可以自测一下:给你一道 LeetCode 中等难度的题目,你能不能在三十分钟内给出正确思路,并且写出无语法错误的代码。如果能稳定做到,笔试基本就有戏;如果不能,就继续把基础数据结构刷扎实,不要急着投简历。
这场笔试整体给我的感觉是,题目不偏、不怪、不超纲,每道题背后都对应着一个经典算法模型,但都需要一点“场景翻译”的能力。希望这篇复盘能帮到你,也希望正在准备大厂笔试的朋友,都能拿到满意的成绩单。