华为OD机考精讲:最佳对手题排序相邻配对最优解法
2026/9/17 4:51:41 网站建设 项目流程

上周有个备考华为OD机考的朋友给我发了一道题,说C卷里碰到“最佳对手”,题目名听着像是博弈论,读完题干又觉得像匹配问题,脑子里瞬间冒出匈牙利算法、状压DP这些词,结果一看数据范围,N最大能到10万,当场心态就崩了。我让他把原题截图发过来,仔细读完发现这题的题眼根本不在“对手”两个字上,而在“两两分组”和“实力差总和最小”这两个条件上。解法朴素到你可能不信:排序,然后相邻配对,五分钟就能AC。但想明白为什么能这么做,以及不同语言在机考环境里怎么写不容易翻车,才是这篇文章真正想聊的东西。

下面所有内容都围绕华为OD机考C卷里流传最广的“最佳对手 / 实力差距最小总和”这个版本展开,我会给出Java、Python、JavaScript、C/C++、Go五种语言的完整可运行代码,再把机考里最容易丢分的输入输出、整数溢出、边界条件这些细节全部摊开讲。不管你刚开始刷OD题库,还是已经刷了一两百道在冲刺阶段,这篇都能帮你省下不少踩坑的时间。

1. 先把题面掰开:这题到底要你算什么

1.1 题目原型与样例

不同题库对这道题的翻译略有差异,但逻辑等价。我按最常见的版本描述一下:

一共有N个玩家,每个玩家有一个实力值。现在需要把玩家两两分组,每组两人进行对战。为了保证公平,要求每一组两个玩家的实力差不能超过K。请问能否把全部N个玩家都完成分组?如果能,输出所有分组实力差总和的最小值;如果不能,输出-1。

输入格式是这样的:

第一行:两个整数 N K 第二行:N个整数,代表每个玩家的实力值

输出要求:如果能全部分组,输出最小实力差总和,否则输出-1。

看一眼样例会更直观。假设输入是:

4 3 1 3 2 4

排序后数组变成[1, 2, 3, 4],两两相邻配对是(1,2)(3,4),差值分别是1和1,总和2,所以输出2。如果换成(1,3)(2,4),差值总和是4,比2大;(1,4)(2,3)也是4。可见相邻配对确实是最优。

再看一个无解的例子:

4 0 10 20 30 40

K等于0表示每组两个人的实力值必须完全相同,显然这四个数没有相等的,所以输出-1。

1.2 题面文字背后的三个隐藏条件

我见过很多人栽在这道题上,不是因为不会排序,而是没读懂题面里的隐藏条件。

第一,N为偶数。这个条件直接告诉你:所有人必须被分完,不存在有人轮空的情况。有些变体题目里N可以是奇数,那种情况就要用另一套动态规划,后文我会单独开一节讲。

第二,“把全部N个玩家都完成分组”这句话决定了这是全配对问题。如果题目换成“从中选出尽可能多的两人组”,解法立刻从贪心变成DP。这也是我在评论区看到大家吵架的根源——有人贴的代码是动态规划,有人贴的是贪心,其实都没错,但题目版本可能根本不是同一个。

第三,“实力差不能超过K”中的K不是让你去求的东西,而是一个合法性过滤器。你只需要在配对时判断差值是否超过K,不需要在K上做二分答案,也不需要把K当成背包容量。

1.3 为什么网上有人用DP有人用贪心

全网搜“最佳对手”会出现两种说法:一种要求全配对,另一种要求“配对数量优先,差值总和最小”。这两种题的数据范围也可能不一样,全配对版本通常保证N是偶数且必须完整匹配;后者N可能是奇数,允许有人落单。所以我建议刷题的时候,点开题解之前先确认题目版本,否则抄代码都是白抄。

1.4 双机位考试环境下的实际感受

华为OD机考的双机位监控下,你面前一个摄像头,侧后方还有一个,操作都被记录。这种环境里你基本不可能依赖本地IDE的代码补全,平时写代码用惯了Arrays.sort或者a.sort()自动补全,真到考试编辑器里手写的时候很容易卡壳。所以平时练习建议直接用在线编辑器或者最朴素的文本编辑器敲完整代码,尤其是主类名、包名、输入输出模板,要形成肌肉记忆。

2. 核心思路:排序相邻配对为什么就是最优解

2.1 一个直觉实验

把实力值画在数轴上,比如 1、2、100、101 这四个点。要让两条线段的总长度最小,直觉上就是让相邻的两个点连在一起:1-2 连一条,100-101 连一条,总长度2。如果硬要交叉配对,1-100、2-101,总长度是198;1-101、2-100 也是198。

为什么交叉会这么亏?因为排序之后所有点从左到右排列,一条跨越很多点的长边必然会把无数个点甩在一边,而另一边又有一条同样长的长边,两段长边叠加,自然就远远大于把中间那些点各自相邻连接的距离了。

这背后的数学结论是:对于数轴上一组已经排序的点,在所有两两匹配方案中,相邻匹配的总距离最小。任何交叉匹配都可以通过交换端点变成非交叉匹配,同时总距离不会增加。反复进行这种消除交叉的交换,最终就一定得到相邻配对的形态。

2.2 严谨一点的证明,应对面试追问

如果你担心机考之后的性格面或技术面被追问,可以把这个证明逻辑记一下。

假设存在两个配对 ((a_i, a_j)) 和 ((a_p, a_q)),索引满足 (i < p < j < q),这就是一组交叉匹配。由于数组已经排序,所以有 (a_i \le a_p \le a_j \le a_q)。

拆开这两对的距离和:

[ |a_i-a_j| + |a_p-a_q| = a_j - a_i + a_q - a_p ]

如果把它调整成 ((a_i, a_p)) 和 ((a_j, a_q)) 这两对相邻匹配,距离和是:

[ |a_i-a_p| + |a_j-a_q| = a_p - a_i + a_q - a_j ]

两者做差:

[ (a_j - a_i + a_q - a_p) - (a_p - a_i + a_q - a_j) = 2(a_j - a_p) \ge 0 ]

也就是说,交叉匹配的距离和一定不小于改成相邻匹配后的距离和。所以最优解一定可以写成相邻配对的形式。

2.3 加上K限制之后,相邻配对仍然成立吗

这可能是全题最容易被追问的细节:如果相邻配对里有一对差值刚好超过K,是不是代表一定无解?答案是肯定的。

我们可以先看一个更直观的版本:如果排序后第一对(a[1], a[2])的差都大于K,那么a[1]a[3]a[4]……任何人的差值只会更大,都不满足K的限制,所以a[1]根本找不到配对伙伴,全局无解。

再看中间位置的情况。假设合法方案中存在交叉配对(x1, x3)(x2, x4),即

[ x_3 - x_1 \le K, \quad x_4 - x_2 \le K ]

因为 (x_2 \le x_3),所以 (x_2 - x_1 \le x_3 - x_1 \le K),第一对相邻配对(x1, x2)必然合法。又因为 (x_3 \le x_4),所以 (x_4 - x_3 \le x_4 - x_2 \le K),第二对相邻配对(x3, x4)也必然合法。这说明交叉合法对可以被替换成相邻合法对,替换之后仍然合法,而且距离和不会变大。不断消除交叉,最终得到的就是排序后从前往后按顺序两两配对。

所以结论很干净:判断是否有解,只需要看排序后相邻配对中是否出现差值大于K的对;求最小总和,也只需要把相邻配对的距离和累加出来。

2.4 复杂度与数据范围

整体复杂度是排序O(N log N)加上线性扫描O(N)。N到10万毫无压力,就算N到100万也能轻松扛住。内存上只需要一个长度为N的数组,O(N)。

真正需要警惕的是数据类型。如果实力值上限到10的9次方,K也到10的9次方,那么N/2对差值的总和可能达到5乘以10的13次方,远超32位整数范围。所以Java里要用long,C++用long long,Go用int64,Python因为自带大整数所以不用操心。

3. 五种语言实现:直接可跑的AC模板

这一节给出五种语言的完整代码。注意华为OD机考通常要求提交一个完整可运行的程序,不是只写一个函数,所以每个代码都包含完整的输入输出处理。

3.1 Java版本

Java在机考里非常常见,但有两个点容易踩:主类名必须叫Main,不能带package;数据量大时用Scanner没问题,但如果追求速度可以换BufferedReader。因为这里是按空白符读取,ScannernextIntnextLong都不会受换行影响,代码可读性也更好。

import java.util.*; public class Main { public static void main(String[] args) { Scanner sc = new Scanner(System.in); int n = sc.nextInt(); long k = sc.nextLong(); long[] a = new long[n]; for (int i = 0; i < n; i++) { a[i] = sc.nextLong(); } Arrays.sort(a); long ans = 0; for (int i = 0; i < n; i += 2) { long diff = a[i + 1] - a[i]; if (diff > k) { System.out.println(-1); return; } ans += diff; } System.out.println(ans); } }

两个细节说明一下。第一,实力值用long数组而不是int,防止总和溢出。第二,一旦发现相邻差值大于K,直接输出-1并结束程序,不需要继续扫描。

3.2 Python版本

Python写起来最简洁,但机考环境里Python有个经典坑:输入的第二行可能因为换行被拆成多行,如果你只写一行a = list(map(int, input().split())),遇到数据跨行就会少读。稳妥做法是循环读取,直到数组长度达到N。

import sys def solve(): input = sys.stdin.readline n, k = map(int, input().split()) a = [] while len(a) < n: a.extend(map(int, input().split())) a.sort() ans = 0 for i in range(0, n, 2): diff = a[i + 1] - a[i] if diff > k: print(-1) return ans += diff print(ans) if __name__ == "__main__": solve()

Python本身是动态类型,不需要考虑溢出问题。但要注意while len(a) < n这个循环,如果输入文件末尾有额外空行,input().split()返回空列表,extend空列表不会报错,循环继续;直到遇到真正包含数字的行。这个写法在牛客、华为OD的在线编辑器里都很稳。

3.3 JavaScript版本

JavaScript在OD机考中也开放,但用的是Node.js环境。最容易被坑的是sort():JS默认按字符串排序,直接对数字数组调用a.sort()会得到[1, 10, 2, 20]这种结果。必须传比较函数(x, y) => x - y

const readline = require('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const lines = []; rl.on('line', (line) => { lines.push(line.trim()); }); rl.on('close', () => { const first = lines[0].split(' ').map(Number); const n = first[0]; const k = first[1]; const a = []; for (let i = 1; i < lines.length; i++) { if (lines[i] === '') continue; a.push(...lines[i].split(' ').map(Number)); } a.sort((x, y) => x - y); let ans = 0; for (let i = 0; i < n; i += 2) { const diff = a[i + 1] - a[i]; if (diff > k) { console.log(-1); return; } ans += diff; } console.log(ans); });

这里多写了一个循环把所有输入行都拼到a数组里,就是为了防止第二行数据过长被系统换行。Node.js的readline按行触发,拼完之后再统一处理,逻辑最稳妥。

3.4 C/C++版本

C++在机考里最大的优势是cin天然按空白符读取,不怕换行。不过要记得关同步流,否则数据量大时速度会吃亏。

#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; long long k; cin >> n >> k; vector<long long> a(n); for (int i = 0; i < n; i++) { cin >> a[i]; } sort(a.begin(), a.end()); long long ans = 0; for (int i = 0; i < n; i += 2) { long long diff = a[i + 1] - a[i]; if (diff > k) { cout << -1 << '\n'; return 0; } ans += diff; } cout << ans << '\n'; return 0; }

如果你平时用C语言,直接裸写qsort会稍微麻烦一点。机考建议直接用C++的STL,sort比手写快排稳定,也不容易出错。

3.5 Go版本

Go的输入处理比前几种语言都要啰嗦一点,但逻辑并不复杂。核心是bufio.Scanner配合strings.Fields按空白符读取所有数字。

package main import ( "bufio" "fmt" "os" "sort" "strconv" "strings" ) func main() { scanner := bufio.NewScanner(os.Stdin) scanner.Buffer(make([]byte, 1024*1024), 1024*1024) scanner.Scan() firstLine := strings.Fields(scanner.Text()) n, _ := strconv.Atoi(firstLine[0]) k, _ := strconv.ParseInt(firstLine[1], 10, 64) a := make([]int64, 0, n) for scanner.Scan() { fields := strings.Fields(scanner.Text()) for _, s := range fields { v, _ := strconv.ParseInt(s, 10, 64) a = append(a, v) } } sort.Slice(a, func(i, j int) bool { return a[i] < a[j] }) var ans int64 = 0 for i := 0; i < n; i += 2 { diff := a[i+1] - a[i] if diff > k { fmt.Println(-1) return } ans += diff } fmt.Println(ans) }

这里有个细节:bufio.Scanner默认缓冲区大小只有64KB,如果N到10万,第二行数字长度可能超过这个值,所以用scanner.Buffer把缓冲区调大。这个坑在牛客平台上很容易踩,但在本地测试时因为数据量小往往发现不了。

4. 机考实操:最容易丢分的三个细节

代码本身不复杂,但我看到很多人在机考里翻车并不是因为代码逻辑,而是栽在下面这些看起来很不起眼的细节上。

4.1 “全配对”和“能配就配”是两种题目

如果题目要求全员配对,那么一旦相邻配对中出现差值大于K的情况,就要输出-1。但如果题目只是问“最多能配成多少对”,或者“在配对数最多的前提下最小总和”,就不存在输出-1这个分支,要用DP。考试时先读题最后一句:如果出现“无法完成全部组队时输出-1”这类描述,就用贪心;如果出现“最多能匹配多少对”这类描述,就用DP。

我见过最可惜的翻车案例,是一位读者把全配对版本的代码提交到“最大配对版本”的题目上,结果本该输出一个非负整数,他却输出-1,直接丢了一整道题的分。

4.2 int溢出:K和答案必须用64位

很多人认为实力值不超过10的9次方,int就够用,但忘记答案是所有差值之和。N最大10万,极端情况下答案可以达到5乘以10的13次方,这早就超过int的最大值21亿了。

Java里用long,C++里用long long,Go里用int64,JS里数字本身是双精度浮点,10的13次方远小于2的53次方,所以JS不用特别处理。只有Python完全没有这种顾虑。

4.3 输入读取的多行陷阱

机考平台的数据文件不会像样例那样规规矩矩一行放完。第二行可能因为平台显示或者数据生成器的原因被拆成多行,甚至行尾还有空格。Python里如果只读一次input().split(),数组长度小于N,一运行就数组越界。我在实操中已经养成习惯:不管题目描述怎么说,先写一个能循环读取直到数组长度满足要求的输入模板,这能在各种在线评测平台上省掉无数烦恼。

Java的Scanner和C++的cin天然按空白符读取,不会因为换行出错,但如果你在Java里用了nextLine()去读第二行,就会因为第一行末尾的换行符读到空串,需要格外小心。

5. 如果题目变成“最多配对数优先”,DP怎么写

我在文章开头提到网上题解打架的问题。为了让你碰到变体题时不会发懵,这节把另一个版本的解法也讲透。

5.1 变体长什么样

变体题通常这样描述:

有N个玩家,每个玩家有一个实力值。现在希望从中选出若干对玩家进行对战,每对玩家实力差不能超过K。请问在配对数最多的情况下,所有配对实力差总和的最小值是多少?

注意这里没有“必须用完所有玩家”,N也可能不是偶数,或者N是偶数但允许有人不参与对战。

举个例子:

5 5 1 2 3 10 11

最多能配出2对,一种方案是(1,2)(3,?),但10和11差1可以配对,所以最多两对可以是(1,2)(10,11),总和2。如果硬想配(1,2,?)是不行的,因为三人不可能组成两对。所以输出2。

5.2 DP状态设计

仍然先排序。排序后定义两个数组:

pairCnt[i]表示前i个人(排序后下标0到i-1)能组成的最大对数;minSum[i]表示在前i个人达到最大对数时的最小差值总和。

初始化pairCnt[0] = 0minSum[0] = 0pairCnt[1] = 0minSum[1] = 0,因为只有一个人时无法配对。

从i=2开始转移,第i个人(下标i-1)有两种选择:

第一,不参与配对。那么当前状态直接继承前i-1个人的状态:

pairCnt[i] = pairCnt[i-1] minSum[i] = minSum[i-1]

第二,和第i-1个人(下标i-2)配对。前提是:

a[i-1] - a[i-2] <= K

配对后:

pairCnt[i] = pairCnt[i-2] + 1 candidateSum = minSum[i-2] + (a[i-1] - a[i-2])

关键点在于比较:如果配对后的对数比当前pairCnt[i]更大,就更新;如果对数一样大,但候选总差值更小,也更新。这就是“对数优先,总和次之”的转移逻辑。

5.3 Python核心代码

def solve(): import sys input = sys.stdin.readline n, k = map(int, input().split()) a = [] while len(a) < n: a.extend(map(int, input().split())) a.sort() pair_cnt = [0] * (n + 1) min_sum = [0] * (n + 1) for i in range(2, n + 1): # 默认:第 i 个人不配对 pair_cnt[i] = pair_cnt[i - 1] min_sum[i] = min_sum[i - 1] diff = a[i - 1] - a[i - 2] if diff <= k: cand_cnt = pair_cnt[i - 2] + 1 cand_sum = min_sum[i - 2] + diff if cand_cnt > pair_cnt[i] or (cand_cnt == pair_cnt[i] and cand_sum < min_sum[i]): pair_cnt[i] = cand_cnt min_sum[i] = cand_sum print(min_sum[n]) solve()

这段代码的思路和执行过程都很好理解。Go、Java、C++、JS版本的DP无非是把数组替换成对应语言的语法,核心转移完全一样,考试时直接在之前的模板基础上改就行。

5.4 为什么这种DP不能直接拿去做全配对版本

因为DP允许有人落单。假设输入是[1, 100, 101, 200],K=100。全配对版本里排序后相邻配对是(1,100)(101,200),差值分别是99、99,总和198,合法。但DP版本可能选择让1和200落单,只配(100,101)一对,差值1,因为“配对数最大”不要求全配对。调整到全配对约束下,这个解就不成立了。

所以考试第一步永远是确认题意:要不要全员参与。要,就贪心;不要,就DP。

5.5 两个版本的差异对照

判断维度全配对版本最大配对数版本
N的奇偶性偶数可以为奇数
是否存在输出-1存在不存在
是否允许玩家落单不允许允许
核心算法排序+贪心排序+动态规划
状态设计不需要pairCnt/minSum数组
典型复杂度O(N log N)O(N log N)

6. 实战中的做题节奏与训练建议

机考和平时刷题最大的不同在于:你需要在有限时间内完成读题、编码、自测三道工序。我建议拿到题后按下面几步走。

第一步,先花30秒确认数据范围。看到N到10万,基本可以排除暴力深搜和状态压缩DP。

第二步,确认配对约束。题目里出现“全部玩家都必须分成N/2组”,直接切到贪心;出现“尽可能多”“最多能组成几对”,切到DP。

第三步,确认输出格式。要不要输出-1,答案是不是整数,都影响代码分支。

第四步,写完代码后,在本地或者在线编辑器里至少跑三个测试用例:一个常规样例、一个全无解样例、一个极限数据样例。极限数据不必真生成10万个数字,可以把N改小但把答案规模撑大,比如N=4, K=1000000000, 实力值=1, 1000000000, 2000000000, 3000000000,来验证数据类型的溢出问题。

在平时训练上,我建议多练几道同类经典题巩固这种感觉:“最小化两两配对差值总和”“打家劫舍变体”“会议室安排”这类排序+贪心或者排序+DP的题,刷上十道左右,碰到“最佳对手”这类包装过的题目就会形成条件反射。

多语言的话,不用强求五种都精通。你只要把自己最熟的一门语言写到“不用过脑子就能敲出输入输出模板”的程度,考试就成功了一大半。其余语言能看懂就行,毕竟题解可能是别人用别的语言写的。我自己的习惯是Java和Python双修:Java用来写正式代码,Python用来快速验证思路。如果你用Go或者JS,只要按本文的模板把输入输出背熟,同样可以打得很稳。

最后再分享一个小技巧。双机位考试的时候,在线编辑器一般没有代码格式化功能,也没有自动补全。我建议你在进考场之前,把所有常用模板按语言分类整理到一个备忘录里,提前背到肌肉记忆。考试开始后先花两分钟把输入输出骨架敲出来,再填核心逻辑,这样能显著降低紧张感。

这道题本身不算难,它考的不是你会不会用多高级的算法,而是你能不能在一个看似唬人的题目名字下,快速看穿它本质上只是一个排序问题。把这篇文章里的思路和代码吃透,下次再碰到“最佳对手”,你应该不会再慌了。

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

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

立即咨询