伴鱼春招A卷算法题解析:字符串统计、动规与拓扑排序实战
2026/8/29 3:30:50 网站建设 项目流程

2023年春招季,伴鱼技术岗的A卷在圈子里讨论度不低。原因倒不是题目有多偏多怪,恰恰相反,这套卷子出得相当克制——三道编程题全部围绕字符串处理、动态规划、图论三个经典方向展开,没有故弄玄虚的脑筋急转弯,但每一道题都埋了足够多的细节坑。三位一体考察下来,既能看到候选人的基本功,也能看出写代码的工程习惯。

这份解析我按自己的复盘习惯整理出来了。适合三类人看:一是准备投教育行业后端或算法岗位的应届生,可以拿这套题自测一下水平;二是已经在准备其他公司笔试的同学,很多思路和排查技巧是通用的;三是技术面试官,可以从出题角度反推一下这类A卷到底想筛什么样的人。三道题我都给了完整的参考实现,Java为主,关键思路会补充说明。

1. 试卷结构与考察思路分析

1.1 三道题的整体布局

先看这套A卷的题目分布情况,我整理了一张总览表:

题号考察方向核心数据结构难度系数建议用时
第1题字符串统计与自定义排序HashMap、Comparator偏简单15分钟
第2题加权区间调度(动态规划)排序、二分查找、DP数组中等偏上30分钟
第3题拓扑排序与环检测邻接表、队列、入度数组中等25分钟

三题分布在三个完全不同的算法领域,难度呈阶梯状上升。第一题是送分题但需要写对,第二题是拉开差距的关键题,第三题则考察图的建模能力。整体来看没有超纲内容,全部落在大学数据结构与算法的课程范围内,但做起来绝对不轻松。

1.2 为什么这样设计题目

伴鱼做在线教育业务,后端系统要处理的很多问题天然就是字符串和调度类问题。比如课程内容的分词统计、学习计划的排期,背后都是这几类算法。所以这套题并不是随便从题库里抽的,而是紧扣业务场景的。

第1题考察的是编码基本功。这道题代码量不大,但涉及HashMap的统计、自定义比较器、集合排序等一堆高频API,如果平时写代码依赖IDE自动补全太多,在这道题上就会卡壳。第2题是经典的加权区间调度问题,能区分出候选人是否真正理解了动态规划的适用条件,而不是单纯背模板。第3题考察的是图论建模能力,数据规模不大,但环检测的正确性必须考虑周全,能看出一个候选人的思维是否缜密。

1.3 答题节奏建议

参考我自己的经验,建议时间分配如下:第一题控制在10到15分钟,先做掉拿稳基础分;第二题留足30分钟左右,这是全卷的核心题;第三题如果剩余时间少于20分钟,可以先写思路和核心数据结构的定义,再补BFS拓扑排序的主逻辑。每一道题写完都要留2到3分钟自测边界,尤其是空输入、单元素输入、存在环这三种情况,这是最容易翻车的地方。

2. 第1题:字符串字符统计与重排

2.1 题目描述

给定一个只包含小写字母的字符串s,请将字符串中的字符按出现频率从高到低重新排列后输出。如果两个字符的出现频率相同,则按字母表顺序排列。

输入输出格式要求:

  • 输入一行字符串s,长度$1 \le |s| \le 10^5$,只含小写字母。
  • 输出重排后的字符串。

示例一:

输入: tree 输出: eert

解释:t和r都出现1次,e出现2次,所以e开头,t和r频率相同按字母序r在t前面,结果为eert。

示例二:

输入: aabbcccd 输出: cccaabb d?

这里我验证一下:a两次、b两次、c三次、d一次,按频率从高到低:ccc,然后a和b频率相同按字母序a在b前,最后d,所以结果是cccaabbd。注意aabb中间不需要空格,我写的是cccaabbd。

2.2 解题思路推演

这道题的核心就两个步骤:统计频率、按规则排序。

统计频率是标准的HashMap操作,不展开说。排序这里有两种做法,一种是直接对字符集合排序,另一种是使用桶排序的思路。考虑到字符集只有26个小写字母,其实最优做法是桶排序。先统计每个字符的频率,按频率从高到低遍历,每个字符直接重复输出对应次数。因为字母表顺序天然就可以通过遍历a到z保证,频率从高到低可以通过排序或者手动倒序遍历实现。

这里有一个容易忽略的地方:如果直接用HashMap<Character, Integer>统计后,把keySet转成List再sort,需要自定义Comparator。我第一次写就踩了坑,比较逻辑写成freqB - freqA是降频,但同频情况下需要升序字母序,必须追加a - b。这也就是为什么我建议直接用数组int[26]来统计,避免了装箱拆箱的麻烦,而且最后遍历也更容易保证字典序。

2.3 参考实现

import java.util.*; public class Main { public static void main(String[] args) { Scanner sc = new Scanner(System.in); String s = sc.nextLine(); int[] freq = new int[26]; for (char c : s.toCharArray()) { freq[c - 'a']++; } List<Character> chars = new ArrayList<>(); for (int i = 0; i < 26; i++) { if (freq[i] > 0) { chars.add((char) ('a' + i)); } } chars.sort((a, b) -> { if (freq[a - 'a'] != freq[b - 'a']) { return freq[b - 'a'] - freq[a - 'a']; // 按频率降序 } return a - b; // 同频率按字典序升序 }); StringBuilder sb = new StringBuilder(); for (char c : chars) { for (int i = 0; i < freq[c - 'a']; i++) { sb.append(c); } } System.out.println(sb.toString()); } }

这里排序的字符集合最多24个有值的元素,排序成本极低。当然也可以这么做:定义一个桶数组,下标是频率,每个桶里存一个字符串,从高频率到低频率拼接,就是典型的桶排序思路,复杂度O(n)。笔试现场写HashMap版本更容易出bug,我建议直接用int[26]数组。

2.4 现场容易踩的坑

这道题翻车最多的地方不在算法,而在输出。有些人统计完频率直接往HashMap里put,然后遍历entrySet拼接字符串,忘记先按频率排序。还有的人Comparator写反了,结果频率从小到大排,样例都过不了。建议提交前一定跑三个自测用例:单个字符、全部相同字符、aabbcc这种所有频率相同的情况。

另外一个小技巧是:用StringBuilder而不要用String拼接,在字符长度10^5时,直接+=会频繁创建新对象,虽然这个量级不至于超时,但会显得工程素养不够。

3. 第2题:课程学习计划(加权区间调度)

3.1 题目描述

你是一位在线教育平台的课程顾问。平台上有n门课程,每门课程有开始时间、结束时间、学习收益值。由于精力有限,同一时间只能学习一门课程,且课程之间不能有时间重叠,但可以首尾相接(即上一门课结束时间等于下一门课开始时间)。请选择若干门课程,使得总收益值最大。

输入格式:

  • 第一行一个整数n,表示课程数量。
  • 接下来n行,每行三个整数start_i、end_i、value_i。

输出格式:一个整数,表示最大收益值。

数据范围:$1 \le n \le 10^5$,$0 \le start_i < end_i \le 10^9$,$1 \le value_i \le 10^9$。

示例:

3 1 2 5 2 3 6 1 3 8

输出:

11

解释:选择课程[1,2]和[2,3],总收益为5 + 6 = 11,大于只选[1,3]的8。

3.2 先想贪心,再想DP

我第一次看到这题想到的是活动安排问题的贪心策略,按结束时间排序后尽量选早结束的课程。但题目一旦引入每个课程的收益值不同,贪心就不成立了。比如上面样例里,如果按最早结束优先,第一门课选[1,2]没问题,但后续贪心地选[2,3]恰好是正解;再看一个反例:课程A[1,3]收益100,课程B[1,2]收益1,课程C[2,3]收益1,按结束时间贪心会选B和C收益只有2,但选A收益100。这说明收益值让问题从区间覆盖变成了带权区间调度。

带权区间调度的标准解法就是动态规划。核心状态定义是:把所有课程按结束时间升序排列,设dp[i]表示前i门课程中能够获得的最大收益。对于第i门课,有两种选择:不选它,那么收益就是dp[i-1],选了它,那么收益是value_i加上dp[p],其中p是结束时间小于等于第i门课开始时间的最后一门课的编号。

注意dp[p]这里用的是结束时间做下标映射,所以排序后的课程顺序和二分查找就非常关键。p的查找可以用二分,因为课程已经按结束时间排好序了,要找的是最后一个end_j <= start_i的课程。

3.3 二分查找的细节

这里二分查找是整道题最容易写错的地方。一般写法是维护一个endTimes数组,配合一个t数组存dp值的索引,比如用int[] dp,然后对第i门课,在endTimes数组中二分查找最后一个不大于start_i的位置。由于endTimes可能重复,要用右边界二分。

我直接给出模板:

int p = upperBound(endTimes, start[i]) - 1;

其中upperBound返回第一个大于start[i]的位置,减1恰好就是最后一个不大于start[i]的位置。如果直接用Arrays.binarySearch再处理插入点,很容易边界出错。建议单独封装这个函数。

3.4 参考实现

import java.util.*; public class Main { public static void main(String[] args) { Scanner sc = new Scanner(System.in); int n = sc.nextInt(); int[][] courses = new int[n][3]; for (int i = 0; i < n; i++) { courses[i][0] = sc.nextInt(); courses[i][1] = sc.nextInt(); courses[i][2] = sc.nextInt(); } Arrays.sort(courses, (a, b) -> a[1] - b[1]); long[] dp = new long[n + 1]; int[] endTimes = new int[n]; for (int i = 0; i < n; i++) { endTimes[i] = courses[i][1]; } for (int i = 0; i < n; i++) { int p = upperBound(endTimes, courses[i][0]) - 1; long include = courses[i][2] + dp[p + 1]; long exclude = dp[i]; dp[i + 1] = Math.max(include, exclude); } System.out.println(dp[n]); } private static int upperBound(int[] arr, int target) { int left = 0, right = arr.length; while (left < right) { int mid = (left + right) >>> 1; if (arr[mid] <= target) { left = mid + 1; } else { right = mid; } } return left; } }

几个细节说明一下。第一,收益值和dp数组用long,因为n是10^5,每个value最大10^9,总和完全可能超过int范围,这是这道题隐藏的一个大坑。第二,p的dp下标是p+1,因为dp数组偏移了一位。第三,排序时如果结束时间相同,按开始时间排序不影响正确性,但建议写成稳定的比较器,即if (a[1] == b[1]) return a[0] - b[0],这样二分出来的位置更符合直觉。

3.5 动态规划状态转移的直观理解

为什么dp[p+1]而不是dp[p]?因为dp数组下标从1开始对应第0门课做完后的状态。dp[i]表示前i门课程(即索引0到i-1)能获得的最大收益。第i门课(索引i-1)如果要选,需要找到前p门课程(索引0到p-1)的最优值,即dp[p]。由于我们的p是上界二分减1后的结果,p可能是-1,此时dp[0] = 0,所以统一用dp[p+1]没有问题。

转移方程可以直接写成:

dp[i+1] = max(dp[i], value_i + dp[p+1])

左边的dp[i+1]表示处理完第i门课程(索引i-1)之后的状态,不选的话,就继承dp[i],选的话,就是当前课程收益加上兼容前序课程的最优收益。

这道题做完基本就能把带权区间DP的套路吃透。同类变体还有任务调度、会议室预订、广告排期,都是这个模型。

4. 第3题:课程依赖顺序(拓扑排序与环检测)

4.1 题目描述

某在线教育平台要为学习者们规划课程学习顺序。一共有n门课程,编号从0到n-1。学习某些课程之前需要先完成若干前序课程。给定m个依赖关系,每条关系用[a, b]表示,学习课程a之前必须先学习课程b。请判断是否存在一种合法的学习顺序,使得所有课程都能完成学习。如果存在,输出任意一种顺序;如果不存在,输出-1。

输入格式:

  • 第一行两个整数n和m。
  • 接下来m行,每行两个整数a、b。

输出格式:若存在合法顺序,输出n个整数表示课程编号;否则输出-1。

示例一:

4 3 0 1 1 2 2 3

输出:

0 1 2 3

或者任何合法的顺序均可。

示例二:

2 2 0 1 1 0

输出:

-1

4.2 题目本质是图建模

这道题的模板特征非常明显:课程是节点,依赖关系是有向边,a依赖b表示有一条b指向a的边。判断是否存在一种学习顺序,本质上就是判断整个有向图是否存在拓扑序列,也就是图中是否存在环。

有环的情况下,课程之间的依赖形成死锁,比如课程0依赖课程1,课程1又依赖课程0,那两门课都没法学。所以检测环是这道题的核心。

我建议用Kahn算法做,也就是BFS拓扑排序。它的思路是:统计每个节点的入度,把所有入度为0的节点加入队列,每次出队一个节点,把它加进拓扑序列,同时把以它为前驱的所有节点的入度减1,如果某个节点入度变为0就加入队列。最后如果拓扑序列的元素个数等于n,说明无环;否则存在环。

4.3 参考实现

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(); 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(b).add(a); // b -> a indegree[a]++; } Queue<Integer> queue = new LinkedList<>(); for (int i = 0; i < n; i++) { if (indegree[i] == 0) { queue.offer(i); } } List<Integer> order = new ArrayList<>(); while (!queue.isEmpty()) { int cur = queue.poll(); order.add(cur); for (int next : graph.get(cur)) { indegree[next]--; if (indegree[next] == 0) { queue.offer(next); } } } if (order.size() != n) { System.out.println(-1); } else { for (int i = 0; i < order.size(); i++) { if (i > 0) System.out.print(" "); System.out.print(order.get(i)); } System.out.println(); } } }

4.4 需要注意的边界

这道题的数据范围没说,但常规是n在10^5级别,m在10^5级别,所以用邻接表而不是邻接矩阵。另外一个需要注意的点是输入的依赖关系可能有重复,这不会影响正确性,因为重复边会让入度多增加一次,但处理时也会多减一次,Kahn算法天然兼容重复边。但如果用DFS染色法判断环,重复边则不会产生实际影响,因为对已经访问过的节点不会重复加边。

环检测有两种思路,Kahn算法是其中一种,另一种是DFS三色标记法:白色表示未访问,灰色表示在递归栈中,黑色表示已访问完成。如果在DFS过程中遇到灰色节点,说明有环。这种方法也能输出拓扑序列,实现是递归后入栈再反转。现场如果Kahn算法的队列实现不熟,DFS三色法也可以,但递归深度可能达到10^5,容易栈溢出。所以我更推荐Kahn算法。

4.5 三种情况的测试建议

提交前建议想三组测试用例:第一组是无环图,验证输出顺序的长度必须是n;第二组是存在环,必须输出-1;第三组是有多个入度为0的起始节点的情况,比如0依赖1,2依赖3,输出任意合法顺序都可以。这道题判断输出合法性的逻辑很简单:检查输出的每个节点是否满足所有前驱都在它之前。笔试的判题系统通常会做这个校验。

5. 现场实战经验与常见问题

5.1 时间分配和节奏感

我自己按这套卷子的难度估算,第一题10分钟,第二题35分钟,第三题25分钟,合计70分钟,刚好符合一般笔试90分钟的时长限制。如果你在第二题卡了超过40分钟,建议先跳到第三题,因为第三题的拓扑排序模板相对固定,拿到分的概率更高。很多人在第二题死磕导致第三题没时间写,这个损失太大了。

有一个经验分享给大家:笔试时不要急着写代码,先在纸上把每个题的数据范围和算法复杂度写出来。比如看到n=10^5,排序算法用O(n log n)没问题;看到value_i可达10^9,立刻想到long;看到图题,先想邻接表。这30秒的思考能避免80%的返工。

5.2 输入读取和自测的细节

在线笔试平台多数用标准输入输出,Scanner虽然慢一些,但n=10^5时完全够用。如果嫌Scanner慢,可以用BufferedReader,但要注意读空行、换行符等细节,处理不好反而容易RE。

自测的时候,我一般会额外测几个边界数据。比如第一题测长度1的字符串"a";第二题测n=1,即只有一门课;第三题测n=1, m=0,这种情况订单只有一个节点,入度为0,输出0。这些case虽然简单,但能快速验证程序不会在极端情况上崩溃。

5.3 从出题角度看这类A卷想考察什么

我复盘这套卷子时最大的感受是:它考的不是偏题怪题,而是工程场景中最常见、最底层的算法能力。第一题考验候选人的基础API熟练度和代码整洁度;第二题考验是否真正理解动态规划的建模过程,而不是只会套模板;第三题考验图论建模和边界处理能力。这三者恰好对应了后端开发日常最常打交道的三件事:数据处理、资源调度、依赖管理。

从面试官视角来看,候选人在这套卷子上的表现主要分四档:

  • 第一档:三题全部AC,代码清晰,说明算法功底扎实,可以直接进入综合面。
  • 第二档:第一题全过,第二题部分过或者思路正确但边界没处理好,第三题有思路但没写完,说明基础不错,但临场时间规划需要加强。
  • 第三档:只会做第一题,第二题第三题都没写核心内容,这说明算法训练比较薄弱。
  • 第四档:第一题都写错,这种基本没有后续了。

所以如果你目标是进入这类教育公司的技术岗,日常训练时一定不要只刷简单题,要把经典DP模型和图论模板题练到条件反射的程度。

5.4 两个容易忽略的刷题训练方向

基于这套卷子,我建议大家在准备阶段额外加强两个方向。一是带权区间调度类DP,这类题在Java后端岗位笔试中出现频率极高,相关变体包括最多能预约多少场会议、任务调度最大收益等,核心都是"按结束时间排序+二分找前驱+DP转移"。二是拓扑排序的两种写法都要手写熟练,Kahn算法和DFS三色法都要能在5分钟内无bug写出,因为不同笔试平台对语言和输入输出的处理方式不同。

另外,代码风格也是打分会考虑的因素。变量命名有意义,循环边界不靠硬背,Comparator可读性好,这些都会在面试官review代码的时候给你加分。

最后再分享一个我实际刷题时养成的小习惯:每道DP题写完,都手动把样例的dp数组整个推导一遍,写在草稿纸上。这个习惯能帮你抓住很多写代码时注意不到的细节,比如dp数组下标偏移、二分边界条件等。这次A卷第二题,我就是靠这个习惯提前发现p的取值边界,才没有被卡住。

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

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

立即咨询