今天继续写刷题系列第九篇,这次聊的是力扣第 3371 题,题面里带“Outlier”这个词,中文一般叫异常值,程序函数名是 getLargestOutlier。这题并不是那种一上来就让人眼前一亮的难题,但它很典型:表面看是一道构造背景题,实际上一旦把题目条件提炼成一条等式,解法就变成了“移项变形 + 哈希表查值”,整个过程写下来不超过二十行。如果你最近在准备算法面试,或者正在刷哈希表、计数类题目,这篇值得花十分钟看完,因为里面用到的“枚举右,维护左”思路,在很多题目里都能复用。
先说这题适合什么基础的人。哪怕你只是刚学完哈希表,也可以直接读;如果已经刷过两数之和,那理解起来更快。全文我不会堆一堆没用的题号,重点放在两件事上:一是如何把题目里的隐藏关系转成代数式,二是为什么这种“枚举候选值 + 临时删除当前值”的写法能成立。理解完之后,你自己就能写出一份能通过的代码。
1. 力扣3371题目到底在说什么
1.1 如果不做数学翻译,第一反应会走进哪些死胡同
第一次看这道题的经历比较典型。题面说的是:数组里有一个原始值 original,这个值本身会作为数组里的一个元素出现;除它之外的其他元素里面,有一个异常值 outlier;剩下的所有元素加起来,刚好又等于 original。现在要我们找 outlier 的最大可能值。
很多人读完题面后的第一反应是枚举:先选一个位置当 outlier,再选一个位置当 original,然后检查其他数字是否能分成若干组、组的和等于 original。这个方向一深入就会撞墙,因为“检查其他数字能否相加成某个目标值”本质上是一个子集和问题,指数级复杂度,数组长度稍微大一点就直接不可行。
就算只做两两枚举,只验证“总和满足两倍关系”,也容易踩同一个坑:original 和 outlier 数值相同的时候怎么办?两个角色会不会用的是同一个数组元素?如果脑子里没有一张清晰的执行图,写出来的代码很容易出现“自问自答”的错误。所以这道题真正值得学的,不是暴力搜索,而是怎么把题面里的构造成分压缩成一个可以查表的条件。
1.2 把构造过程翻译成一条等式
先给变量起个名字,让后面的推理清爽一点。
记整个数组的和为 S,候选的异常值为 x,原始值为 o。按照题面的构造方式,数组去掉异常值之后,剩下的部分长这样:
- 一个元素等于
original,也就是o - 若干元素加起来等于
original,它们的总和也是o
所以去掉 x 之后,剩余元素的总和一定是 o + o = 2 * o。于是得到最核心的等式:
S - x = 2 * o
这个等式是整道题的题眼。它告诉我们:不需要真的去检查“剩余元素能否分成某种合法分组”,只需要关心两个量之间的关系。只要能找到某个 x,使得 (S - x) 是偶数,并且 (S - x) / 2 这个值确实存在于数组中,那么在数值关系上,这个 x 就有资格成为异常值。
为什么这么说?因为这个等式的存在已经隐含了其他元素的分布:去掉 x 和一个 o 之后,剩余元素的总和会自动等于 o。它们内部怎么排列组合并不重要,整体和满足条件就够了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 移项变形:从“枚举全部分组”到“查一张哈希表”
2.1 等式变形与奇偶性剪枝
上面的等式 S - x = 2 * o 中,S 是一次遍历就能算出来的定值,x 是我们准备枚举的候选异常值,剩下的未知量只有 o。把 o 单独挪到一边,就得到:
o = (S - x) / 2
这一步就是标题里说的“移项变形”。它的意义不是做算术,而是把一个“枚举分组验证”的问题,变成了一个“哈希表里查某个数是否存在”的问题。
因为 o 是整数,所以 S - x 必须是偶数。这个条件可以顺手用来做剪枝。很多候选值在第一次判定时就会被拦下来,根本不用走后面的哈希查询。
这里补一个小细节:如果数组里包含负数,判断奇偶性依然成立,奇数余数不会是 0。代码里直接写 if (rest % 2 != 0) continue; 就可以,C++ 对负数取模只会得到 0 或 -1 这类非零值,用来判断“不是偶数”是安全的。
2.2 最容易疏忽的坑:original 和 outlier 不能是同一个位置
移项完成后,新手最容易犯的错误是:查到 (S - x) / 2 存在于数组中,就直接认为当前 x 合法。但如果这个查出来的 original 和当前枚举的 x 在数值上相等,而且数组里只有一个这样的值,那么你其实是在用同一个数组元素同时扮演两个角色。
举个例子,极端情况数组只有一个元素 0,S = 0。枚举 x = 0 时,(0 - 0) / 2 = 0,查表也能查到。但一个元素不可能既是异常值又是原始值,显然不合法。
所以在查表前,应该先从哈希表里把当前枚举值临时减掉一次,模拟“这个位置已经被异常值占用”的状态。查完再恢复回去。这样如果 o 真的等于 x,还能通过检查说明数组里至少存在另一个相同的值;如果只有一个,就会因为计数变成 0 而判定失败。
这个处理方式看起来只是代码里的一行,却把“同一元素被使用两次”的隐患直接消除了,比单独写 if (x == o) cnt[x] >= 2 这类特判更通用。
2.3 为什么找到 original 后就不用再关心剩余元素怎么分
还有一个容易让人不安的点:找到合法的 x 和 o 之后,剩余元素真的能保证凑成一个和为 o 的整体吗?
可以反着推。数组总和是 S,我们移除掉一个 x,再移除一个 o,剩下元素的个数可能多种多样,但它们的总和是:
S - x - o = 2 * o - o = o
也就是说,剩下那一堆元素的和天然就是 o。题面不需要你再从它们中间挑出哪些组合成 o,因为从整体和来看,它们已经刚好满足条件。只要原始数组确实符合题目的构造背景,这种“剩下所有元素的和等于 original”就是充要条件。
这个理解很关键。它也是整个做法能成立的根基:我们用代数式锁死了整体性的约束,避免了在局部组合里做指数级搜索。
3. 枚举右维护左的代码落地
3.1 先回忆经典的两数之和模板
要说“枚举一个量,维护另一个量的历史集合”,最经典的例子就是两数之和。假设我们要找两个数,使它们的和等于 target,常见的写法是:
cpp复制unordered_set<int> seen;
for (int x : nums) {
int need = target - x;
if (seen.count(need)) {
// 找到答案
}
seen.insert(x);
}
这里就有“枚举右维护左”的思想:从头到尾扫描,把当前元素当成配对中的右侧元素,用哈希表维护已经扫过的左侧元素。因为当前元素还没有被放进 seen,它不会和自己配对,唯一性问题被自动解决。
这个模板的代价是只覆盖了“两个元素一定一前一后”的情况。当我们需要判断的目标可能出现在当前元素的左边,也可能出现在右边时,就不能只维护左边了,需要维护一个全局的可用候选池。
3.2 本题如何套用“枚举右,维护左”
力的 3371 里,original 可能在数组中的任意位置,不保证在枚举值的左边。所以我的处理方式是:先把整个数组的元素频数统计进一个哈希表,然后开始枚举每个 x,把它看成“当前的右侧元素”。在查 (S - x) / 2 之前,先让 cnt[x]--,这样就表示当前这个位置已经被异常值占用,不能再被当成 original。
此刻哈希表里剩下的所有元素,就相当于两数之和模板里的“左侧维护集合”,只不过它的范围是全局的而非前缀。每次循环结束后再 cnt[x]++,把当前值放回池中,让下一次枚举从完整状态出发。
这种“临时删,查,恢复”三步操作,我后来在不少类似题里都用过。它本质上还是“枚举右,维护左”,只是不再用一个单纯的 set,而是用一个带计数的池子。这样写的好处很直接:少写大量关于位置关系的特判。
