深蓝黑背景品牌封面:左侧大标题「找出被改过的那个数」,副标题「不用排序:和、平方和与异或」;右侧原创装饰——上下两排数字卡片对比,上排为原始序列 3 1 4 1 5,下排中数字 4 被替换成 9 并红色高亮,其余数字打勾一致;左上标注「历史题目复盘 · 数据处理」,右上标注「Day 08 · 10-06」 图 1:本文封面,点明「不改顺序找篡改」的题目主题。

先说明属性:这是一篇历史题目复盘,出自本地历史面试资料,与任何公司的当前招聘无关。题面:一份原始数据在传输或存储后被改掉了一个数,给你原始副本和篡改后的副本,不许排序,找出改的是哪个数、改成了什么。隐含约束是 O(n) 时间、O(1) 空间。

面试现场,面试官禁掉排序,就是在等你说出「聚合特征」这四个字。排序要 O(n log n),逐位比对要先对齐顺序——都不合格。

核心结论:不要逐位比对,对整份数据算「可逆聚合特征」。

原始副本算一份,篡改副本算一份,两个特征值一减,答案自己浮出来。具体用哪种特征,决定了你能解到什么程度。

路径一:求和,先拿到差值。

两份数据分别求和,差值 d 就是「新值 − 旧值」。一遍扫描、一个累加器,O(n) 时间 O(1) 空间。但只知道差还不够——差是 +5,可能是 4 改成 9,也可能是 1 改成 6,你定位不到原来是谁。

路径二:补上平方和,联立解方程。

再算平方和的差:新值² − 旧值² =(新值−旧值)×(新值+旧值)= d × s,其中 s 是两值之和。用 d 除一下就得到 s,于是有 新−旧=d、新+旧=s,二元一次方程直接解出旧值和新值。走查:原始 [3, 1, 4, 1, 5] 被改成 [3, 1, 9, 1, 5],和差 +5、平方和差 +65,则 s=13,解得旧值 4、新值 9。两遍扫描、两个累加器,全程不排序。

路径三:异或,天然消对。

把原始全部与篡改后全部一起异或:没被动过的数两两抵消,最后剩下「旧值 ⊕ 新值」。它不能唯一反推两值(知道异或结果 13,推不出一定是 4 和 9),但它是判断「有没有被改」的最快办法,而且整数域内天然不怕加法溢出。如果题目变体是「缺了一个数」而不是「改了一个数」,异或结果直接就是缺失的那个数——连方程都不用解。

可运行的验证。

下面这份代码把三条路径都实现并走查,用 Node.js 22.15.0 直接运行(node tampered-number.js):

'use strict';

// 和 + 平方和:找出被篡改的那一个数
function findTampered(original, tampered) {
  let sumDiff = 0;
  let sqDiff = 0;
  for (let i = 0; i < original.length; i++) {
    sumDiff += tampered[i] - original[i]; // = 新值 - 旧值
    sqDiff += tampered[i] ** 2 - original[i] ** 2; // = (新-旧)(新+旧)
  }
  const sum = sqDiff / sumDiff; // = 新值 + 旧值
  const newVal = (sumDiff + sum) / 2;
  const oldVal = sum - newVal;
  return { oldVal, newVal };
}

const original = [3, 1, 4, 1, 5];
const tampered = [3, 1, 9, 1, 5];
const hit = findTampered(original, tampered);
console.log('和差', tampered.reduce((a, b) => a + b, 0) - original.reduce((a, b) => a + b, 0),
  ',平方和差', tampered.reduce((a, b) => a + b * b, 0) - original.reduce((a, b) => a + b * b, 0));
console.log('解出:旧值', hit.oldVal, '被改成新值', hit.newVal);

// 异或:同样的数两两抵消,剩下 旧值 ⊕ 新值
let x = 0;
for (const v of original) x ^= v;
for (const v of tampered) x ^= v;
console.log('异或结果(旧值 ⊕ 新值):', x);

// 变体:缺了一个数,异或直接给出答案
function findMissing(full, incomplete) {
  let m = 0;
  for (const v of full) m ^= v;
  for (const v of incomplete) m ^= v;
  return m;
}
console.log('缺失的数:', findMissing([3, 1, 4, 1, 5], [3, 4, 1, 5]));

实测输出:

和差 5 ,平方和差 65
解出:旧值 4 被改成新值 9
异或结果(旧值 ⊕ 新值): 13
缺失的数: 1

这套思路的真正价值在工程里。

数据管道传输后的完整性校验(校验和)、样本集的差异比对、文件分块的指纹,用的都是同一个思想:不比对每一条,而是比对「可逆聚合特征」。一个好特征要满足两个条件:对单点变化敏感(改一个数特征就变)、可差分(两个特征相减能反推变化)。和、平方和、异或、哈希,都是这个家族里不同强度的成员。

解法图:问题是原始数据集被改掉一个数后,如何在 O(n) 时间、O(1) 空间找出改了什么,不许排序。三条路径:路径一「求和」——分别对原始与篡改后的数据求和,差值 d 就是新值减旧值;只知道差还不够,配合路径二「平方和」——平方和的差等于 新值²−旧值² = (新值−旧值)(新值+旧值),联立两式解出旧值与新值,示例:和差 +5、平方和差 +65,则新−旧=5、新+旧=13,得旧值 4、新值 9。路径三「异或」——把原始全部与篡改后全部一起异或,相同的数两两抵消,剩下 旧值⊕新值;若题目是「缺了一个数」变体,异或结果直接就是缺失的那个数。底部对比三种特征:和最简单但只给差值、平方和联立可解出具体两值、异或天然消对且不怕溢出,数据完整性校验与样本比对用的都是这套「可逆聚合特征」思想 图 2:和、平方和与异或三条路径的解法结构。

可能的追问。

「改了两个数怎么办?」一路特征一个方程,解不出两个未知数——再加特征,比如立方和,或者先按异或结果分桶把问题拆开;特征的数量要追上未知量的数量。「数据很大会溢出吗?」平方和会,工程上要么用大数类型,要么换成异或或模运算。「为什么强调不排序?」排序本身 O(n log n) 还会打乱原顺序,聚合特征一遍流式扫描就能算,数据不落盘也做得完。

易错点清单。

一,只用求和就想定位具体数值——差值不是身份。二,平方和除以和差时没考虑差为零(根本没改数)的边界。三,把异或结果当成具体答案——它只给两值的混合信息。四,忽略溢出,大整数场景直接翻车。

回到面试现场,答题节奏建议:先否定排序与逐位比对,给出「聚合特征差分」的框架,再按和、平方和、异或递进补齐定位能力,最后主动谈溢出与多变量的变体。这类题的区分度,全在「你知道一个特征不够,还要知道缺几个、为什么」。

你在数据校验场景里用过哪种指纹?校验和、异或还是哈希?欢迎分享。