华为OD机考C卷的算法题,十道里至少有五道是这种“名字听着像业务题,拆开就是贪心加模拟”的货色。今天要拆的这道推荐多样性,就是典型代表。它名字听着像推荐系统算法,实际上考的就是多路归并和边界处理。题目本身不算难,但C卷喜欢在输入输出格式上埋坑,很多候选人不是不会写,而是卡在“怎么把多行列表读干净”和“连续计数怎么维护不混乱”上。这篇文章适合两类人:一是马上要考OD机考、想快速过一遍C卷高频题型的;二是Java基础还不太牢、想看看Scanner、List这些常用API在算法题里怎么组合使用的人。我会先把题面掰开揉碎讲清楚,再给出可以直接提交的完整Java实现,最后聊聊几个我在本地跑测试和线上提交时踩过的坑。
1. 先理解这道C卷题在做什么——题面拆解与输入输出
1.1 题面解读:推荐结果为什么要“打散”
推荐多样性这个题目,本质上是让你把多个已经生成好的推荐列表合并成一个输出列表,合并时满足一个约束:同一个列表里的内容,连续出现在最终结果中的次数不能超过k。
举个例子,你面前摆着三个列表,每个列表里都是系统已经排序好的推荐内容。如果不做任何处理直接拼接,用户看到的可能就是“这个类型的内容连续出现十几次”,很容易审美疲劳。所以题目要求你把它们打散穿插,让同一个列表的元素不要扎堆出现。
用大白话说清楚:内容a来自列表1,内容b也来自列表1,如果你输出的序列里连续出现了a和b,这就是“来自同一列表的连续出现”。如果k=1,这种连续就是违例的。所以这题的实质是:给你M个列表,每个列表N个元素,要求你交错输出,使同一个列表的元素相邻出现的次数不超过k个,同时保持每个列表内部的先后顺序不乱。
这个约束其实和食堂打菜很像。番茄炒蛋窗口出了第一勺之后,你得让后面排队的人先去隔壁窗口打一勺,再回来继续打番茄炒蛋。目的就是不让同一个窗口出菜太密集,大家都能吃上饭。
1.2 输入输出格式,以及最容易读错的地方
这道题的输入格式是:
- 第一行两个整数n和k。n表示每个推荐列表的长度,k表示连续出现的上限。
- 接下来是若干行,每行n个整数,代表一个推荐列表,直到输入结束。
输出是一行,把列表全部打散后的结果,用空格分隔。
这里就有C卷最喜欢的坑了:输入的行数是不确定的。题目不会像A卷那样明确告诉你“接下来有m行”,而是让你自己读,读到没有数据为止。代码里如果用固定for循环去读几行,读少了会漏数据,读多了会卡在等待输入上,直接超时或报错。
正确读法是用while (sc.hasNextLine())配合nextLine逐行处理。但这里有个细节:第一行的nextInt读完以后,换行符还留在缓冲区里,如果直接调nextLine,会先读到一个空串。所以第一行读完n和k之后,必须手动调一次sc.nextLine()把换行符消费掉,再开始循环读后续列表。
还有一个常见的本地调试问题:在线OJ输入结束时hasNextLine会自动返回false,代码正常结束。但是本地IDE里你手动敲键盘输入时,Scanner并不知道输入结束了,会一直等。这时候要按Ctrl+D(Windows)或Ctrl+Z(Linux)发送结束符,代码才会继续往下走。很多人在本地跑不出结果,以为是代码问题,其实就是这个原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心思路——为什么“轮询加计数跳过”是最稳的解法
2.1 把问题转化成多路归并
M个列表,每个列表N个元素,本质上就是M条有序队列。我们要设计一个调度器,每次从其中一条队列的头部取一个元素输出,并且保证同一个队列被连续取出的次数不超过k。
这里我推荐一个思路:按列优先+阈值跳过。所谓按列优先,就是先轮流取每个列表的第一个元素,再轮流取每个列表的第二个元素,依次类推。这样在等长列表的场景下,天然就是交错均匀的,根本不会出现同一个列表连续输出的情况。
但纯按列优先有个隐藏问题:如果某个列表短一些提前取完了,或者其他列表在某一轮被跳过,后面的调度就可能出现连续取同一个列表的情况。所以需要在按列优先的基础上加一个“连续计数”机制,当某个列表的连续输出次数已经达到k时,强制跳过,去选其他列表。
两个规则合在一起,就是完整的贪心策略:
- 优先从上一个被选中列表的下一个位置开始轮询,保证不会总是优先取第一个列表。
- 如果一个列表已经连续输出了k个元素,即使轮到了它也要跳过,去找其他还有内容的列表。
2.2 为什么这个贪心是对的
很多读者会问:每次选剩余数量最多的列表不是更好吗?答案是用不上。这道题的约束是“连续不能超过k”,不是“尽可能分散”。只要输出序列里没有超过k个连续来自同一个列表的元素,就是一个合法解。轮询策略天然能保证等长列表下完全不连续,即使在某些边界场景下触发了连续限制,也只需要跳过那个列表,换一个取,不会破坏合法性。
相比之下,“每次选剩余数量最多”的优先级队列解法虽然更通用,但在这种输入规模下杀鸡用牛刀,而且实现复杂度高,写起来容易翻车。机考看的是用例通过率,不是算法复杂度多漂亮。用最简单、最不容易出错的思路先把用例跑通,才是王道。
当然,这个贪心有一个前提:题目保证存在合法解。如果输入只有一个列表,k=1,那无论如何都不可能满足“连续不超过1”。对于这种极端输入,我们只能直接返回原列表顺序,或者依赖代码里的重置逻辑兜底。后面在边界处理章节会专门讲。
2.3 连续计数cnt的维护细节
这个题的代码逻辑本身不难,但连续计数cnt非常容易写错。我见过不少人的思路是对的,结果cnt维护错了,导致输出一串非法序列。
cnt的定义一定要记清楚:它表示当前这段连续输出里,已经连续输出了多少个来自同一个列表的元素。它不是一个列表被取了多少次的总计数,而只是“最近这一段连续序列的长度”。
每次选中一个列表i后,分两种情况更新:
- 如果i和上一个被选中的列表last相同,说明又连续取了一次,cnt加1。
- 如果i和last不同,说明切换了列表,之前的连续段断了,cnt重置为1。
这个重置动作很关键。很多人会忘记在切换列表时把cnt归零,导致误判“这个列表已经连续太多次了”而不能选它。
轮询起点也要注意:每次查找下一个可取列表时,不是从0开始,而是从last+1开始。这样做的目的是让不同列表循环更均匀,避免每次都从第一个列表开始,导致第一个列表被消耗得太快。
3. Java完整实现与逐段拆解
3.1 数据结构选型:List比二维数组更抗造
存储所有列表时,我推荐用List<List
- 输入的行数不确定,List可以动态add,二维数组必须先确定行数。
- 列表长度可能因为输入格式不统一而存在细微差异,List不需要预先固定列数。
- 代码可读性更好,lists.get(i)直接表示第i个列表,语义清晰。
索引数组用int[] idx,长度是列表个数m,记录每个列表当前已经取到第几个元素。初始都是0,取完一个元素就idx[i]++。
还需要一个result列表保存输出结果。总元素数可以提前算出来,这样主循环用while (result.size() < total)来控制,比较直观。
3.2 主循环逻辑拆解
核心代码片段如下:
java复制int total = 0;
for (List<Integer> list : lists) {
total += list.size();
}
List<Integer> result = new ArrayList<>();
int m = lists.size();
int[] idx = new int[m];
int last = -1;
int cnt = 0;
while (result.size() < total) {
int chosen = -1;
int start = (last + 1 + m) % m;
for (int t = 0; t < m; t++) {
int i = (start + t) % m;
if (idx[i] < lists.get(i).size()) {
if (i != last || cnt < k) {
chosen = i;
break;
}
}
}
if (chosen == -1) {
cnt = 0;
for (int t = 0; t < m; t++) {
int i = (start + t) % m;
if (idx[i] < lists.get(i).size()) {
chosen = i;
break;
}
}
}
result.add(lists.get(chosen).get(idx[chosen]));
idx[chosen]++;
if (chosen == last) {
cnt++;
} else {
last = chosen;
cnt = 1;
}
}
这段代码里有几个地方需要仔细看:
第一,循环扫描用取模运算实现循环访问。start=(last+1+m)%m,保证起点是上一个列表的下一个位置,加m是为了防止last+1为负数时取模结果是负数,Java的%对负数会返回负值,这一点特别容易踩。
第二,chosen==-1的分支。当所有候选列表都被连续限制挡住时,说明当前已经没有合法选择,只能打破约束,重置cnt=0,重新找一个还有元素的列表。这个重置逻辑主要用来兜底,在标准等长输入下其实不会触发,但有了它代码才不会陷入死循环。
第三,更新cnt的两个分支。chosen==last时cnt++,chosen!=last时cnt置1。这个更新必须放在取出元素之后,不能提前,否则影响下一次判断。
3.3 完整可提交代码
完整的Main类代码我放在下面,建议直接复制到本地跑一遍,然后用自己的用例验证:
java复制import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
import java.util.StringJoiner;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int k = sc.nextInt();
sc.nextLine();
List<List<Integer>> lists = new ArrayList<>();
while (sc.hasNextLine()) {
String line = sc.nextLine().trim();
if (line.isEmpty()) {
continue;
}
String[] parts = line.split(" ");
List<Integer> list = new ArrayList<>();
for (String part : parts) {
list.add(Integer.parseInt(part));
}
lists.add(list);
}
if (lists.isEmpty()) {
return;
}
if (lists.size() == 1) {
StringJoiner sj = new StringJoiner(" ");
for (int v : lists.get(0)) {
sj.add(String.valueOf(v));
}
System.out.println(sj);
return;
}
int m = lists.size();
int[] idx = new int[m];
int total = 0;
for (List<Integer> list : lists) {
total += list.size();
}
List<Integer> result = new ArrayList<>();
int last = -1;
int cnt = 0;
while (result.size() < total) {
int chosen = -1;
int start = (last + 1 + m) % m;
for (int t = 0; t < m; t++) {
int i = (start + t) % m;
if (idx[i] < lists.get(i).size()) {
if (i != last || cnt < k) {
chosen = i;
break;
}
}
}
if (chosen == -1) {
cnt = 0;
for (int t = 0; t < m; t++) {
int i = (start + t) % m;
if (idx[i] < lists.get(i).size()) {
chosen = i;
break;
}
}
}
result.add(lists.get(chosen).get(idx[chosen]));
idx[chosen]++;
if (chosen == last) {
cnt++;
} else {
last = chosen;
cnt = 1;
}
}
StringJoiner sj = new StringJoiner(" ");
for (int v : result) {
sj.add(String.valueOf(v));
}
System.out.println(sj);
}
}
我在这里加了一个单列表提前返回的判断。如果列表只有一个,任何交错都是不可能的,直接按原顺序输出即可。这个分支不加也能跑,但会频繁触发重置逻辑,白白浪费计算,而且容易让人觉得代码逻辑有bug。
3.4 输出格式别在这里丢分
机考对输出格式的要求非常严格:多一个空格、少一个空格、或者末尾多一个换行,都可能判错。我推荐用StringJoiner拼接,它天然不会在最后一个元素后面补多余空格。
java复制StringJoiner sj = new StringJoiner(" ");
for (int v : result) {
sj.add(String.valueOf(v));
}
System.out.println(sj);
如果你习惯用StringBuilder,记得在循环里判断一下index是不是最后一个,最后再trim一下。这个细节看着小,实际考试里真的会有人因为多了一个空格被扣分。
4. 机考环境下容易踩的坑
4.1 Scanner读取行数的坑
nextInt之后必须调用nextLine消费掉换行符,这是Java机考最经典的坑。
错误写法:
java复制int n = sc.nextInt();
int k = sc.nextInt();
while (sc.hasNextLine()) {
String line = sc.nextLine(); // 第一次读到空串
}
这样第一行列表还没读就被空串顶掉了。正确写法是:
java复制int n = sc.nextInt();
int k = sc.nextInt();
sc.nextLine();
while (sc.hasNextLine()) {
String line = sc.nextLine().trim();
if (line.isEmpty()) continue;
// 处理line
}
trim和空串判断也很重要。有些平台的行尾有回车符,有些平台会在最后一行后加一个空行,如果不处理空行,lists里会被塞进一个空的List
4.2 本地IDE与在线OJ的输入差异
在线OJ的测试用例是文件输入,hasNextLine读到文件末尾自动返回false,代码自然结束。本地IDE手动输入时,Scanner不会知道“人类已经敲完所有数字了”,会一直阻塞等待。
所以本地测试时,输入完所有数据后要按Ctrl+D结束输入。如果发现代码运行后一直没输出,先检查是不是输入结束符没按,再去查代码逻辑。这个问题真的会卡住很多人。
4.3 单列表和空列表的边界处理
如果lists.size()==0,直接return,否则后续取lists.get(0)就数组越界。
如果lists.size()==1,就是我前面说的,直接输出这个列表的所有元素。因为题目已经无解了,最好的做法是不调用轮询逻辑,避免走入死胡同。
有的同学可能会问:那如果m=1但k很大呢?比如k=100,那确实允许连续100个,理论上也可以走通用逻辑。但输入只有一个列表时,轮询永远只能选它,cnt会一直增长直到超过k,然后触发重置,依然能输出完。代码不会死循环,但多了很多无意义的判断。提前返回能让代码更清晰,也说明你考虑到了边界。
4.4 性能考虑:Scanner够不够用
这道题的数据量一般不会很大,每个列表长度n也就是几十到几百,列表数量m也不会特别夸张。用Scanner完全够,不需要为了性能去手写BufferedReader。
但如果你平时刷牛客遇到过超时,可以换一套更快的读取模板:
java复制BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
String[] first = br.readLine().split(" ");
int n = Integer.parseInt(first[0]);
int k = Integer.parseInt(first[1]);
String line;
while ((line = br.readLine()) != null && !line.trim().isEmpty()) {
String[] parts = line.trim().split(" ");
...
}
这个写法比Scanner快一些,但可读性稍差。机考环境里推荐怎么写顺手就怎么写,不用纠结那几毫秒。
4.5 关于K的边界情况
如果k=0,表示不允许同一个列表连续出现两次以上。在标准等长输入下,轮询天然满足,所以没问题。但如果只有一个列表,k=0时无解,只能原样输出,这也是为什么上面做了单列表提前返回。
如果k特别大,比如k大于列表数量,那么约束形同虚设,因为按列轮询的交错程度已经远超过k的要求,代码也不会出错。
面试延伸题可能会问你:如果每个列表长度不相等,怎么保证合法解?这时候才需要升级到优先级队列,每次取剩余数量最多的列表,配合冷却队列记录上次取出的类别。但机考原题在等长条件下,用本文的贪心就足够了。
5. 三组自测用例,验证你的代码不会翻车
5.1 标准场景:三列表交替输出
输入:
text复制3 1
1 2 3
4 5 6
7 8 9
期望输出:
text复制1 4 7 2 5 8 3 6 9
这个用例是基础中的基础。三个列表各取第一个,再各取第二个,完全交错。k=1时序列里没有任何相邻元素是同一个列表的,合法。
5.2 临界场景:两列表且k=1
输入:
text复制3 1
1 2 3
4 5 6
期望输出:
text复制1 4 2 5 3 6
k=1意味着同一列表的元素不能相邻。两个列表时,唯一合法的方式就是严格交错。代码从last+1开始轮询,自然能输出这个结果。
5.3 允许连续两个的场景:k=2
输入:
text复制3 2
1 2 3
4 5 6
期望输出:
text复制1 4 2 5 3 6
注意,k=2表示允许最多连续两个来自同一列表,但按列轮询的输出依然完全交错,这也是合法解。这里要理解:约束是“不能超过k”,不是“必须达到k”,所以完全交错永远合规。
5.4 单列表兜底场景
输入:
text复制3 1
1 2 3
期望输出:
text复制1 2 3
这个用例只有在加了单列表提前返回分支时才会有清晰的结果。没有提前返回的话,代码也能跑出来,但会经过一次重置逻辑,输出结果一致。我建议保留提前返回分支,一方面逻辑清晰,另一方面节省时间。
我用一个表格总结一下这四组用例:
| 用例 | 输入参数 | 关键点 | 预期输出 | 说明 |
|---|---|---|---|---|
| 标准三列表 | n=3, k=1 | 交错均匀 | 1 4 7 2 5 8 3 6 9 | 常规情况必须一次通过 |
| 两列表临界 | n=3, k=1 | 严格交错 | 1 4 2 5 3 6 | k=1时不能有相邻同类 |
| 放宽阈值 | n=3, k=2 | 合法任意解 | 同上 | 交错解依然合法 |
| 单列表兜底 | n=3, k=1 | 无解场景 | 1 2 3 | 直接输出原列表 |
6. 我的OD机考实战建议
6.1 把输入输出模板背下来
华为OD机考C卷的题,最大的时间消耗往往不是算法本身,而是输入输出。像“直到输入结束”“处理多行数据”这类描述,在C卷里出现频率很高。建议考前自己整理一套模板:
- 读固定两个整数+若干行列表,用Scanner加nextLine循环。
- 读一行整数,用split加Integer.parseInt。
- 输出用StringJoiner,不用StringBuilder手拼空格。
把这套模板练熟,考场上看到题目直接套,省下来的时间全用在核心逻辑上。
6.2 不要纠结最优解
推荐多样性这道题,网上能搜到用PriorityQueue实现的最优解法,思路是每次取剩余数量最多的列表,保证全局均匀。但在机考环境下,这属于过度设计。评分标准是测试用例通过率,不是算法复杂度的观赏性。用贪心加轮询,代码短、容易调、不容易出错,覆盖标准输入绰绰有余。
先把简单解法写对,如果还剩很多时间,再考虑优化也不迟。实际上大多数C卷考生连第一版都未必能一次跑通,别给自己加戏。
6.3 双机位考试环境的小提醒
C卷通常是双机位监考,手机架在侧后方,桌面只能留笔和草稿纸,IDE一般用牛客网页版或者本地IDE。几个注意事项很有用:
- 提前把Java环境配好,确保java和javac命令可用,别到考场才发现JDK没装。
- 本地IDE调试时记得输入结束要按Ctrl+D,否则程序一直阻塞。
- 草稿纸上把测试用例和期望输出先写出来,特别是这种多列表轮询的题,手推一遍再写代码,能避免很多逻辑错误。
- 机考评分是看不到中间输出的,只有最终输出会被比对,所以System.out.println别乱打,调试完记得删掉。
我在实际刷这道题的时候,第一次就栽在读取输入上。后来总结出一个习惯:凡是题目里出现“直到输入结束”这种字眼,一律用while (hasNextLine())去读,每读完一行先trim再判断空串,这个习惯帮我避开了C卷很多类似的坑。最后再分享一个小技巧,如果你觉得某个列表已经取空了,不要慌,idx[i] < lists.get(i).size()这个条件天然会跳过它,只要还有列表不为空,主循环就不会死。考试时遇到输出不对,先打印idx数组看看每个列表消耗到哪了,比盯着结果序列发呆高效得多。
