☰
数对差1:从暴力到哈希,顺带讲透溢出、去重与高精度
2026/9/29 16:35:48 网站建设 项目流程

刷题群有人甩出来一道题:给你一排整数,数一数里面有多少对数的值刚好相差 1。比如[1, 3, 5, 2],答案是 2 对:(1,2)和(2,3)。这个题目本身不难,但真正动手写的时候,很多人才发现自己在审题、去重、溢出、数据规模估计这些基本功上全都没过关。尤其是有几年经验的开发者,常年写业务代码,一上来就是两层循环,复杂度算也不算,结果数据量一上来直接超时。

这篇文章不只是在讲这一道题。我会从这道题展开,把暴力枚举、排序、哈希表三种主流解法都走一遍,顺带把边界条件、整数溢出、高精度大整数、复杂变体这些问题全部串起来。不管是准备面试、刷竞赛题,还是单纯想把基础算法打牢,这篇文章都能给出一份可以直接抄作业的完整参考。

1. 先把问题说清楚:审题往往比做题更关键

1.1 这个“对”到底怎么算?两种语义先定死

题目写“有多少对整数它们的值正好相差 1”,这里藏着第一个坑:这个“对”是按数组下标算,还是按数值去重后算?

举个例子:数组[1, 1, 2, 2, 3]。

  • 如果按数组元素的下标组合来算,1有两个,2有两个,所有相差 1 的下标组合就是 2×2 + 2×2 = 8 对。
  • 如果按“不同的整数数值”来算,那就是(1,2)和(2,3)两对。

绝大多数刷题场景(尤其是 LeetCode 风格的问题)默认按数值去重后的“值对”来统计,因为题目说的是“有多少对整数”,强调的是数值关系,而不是下标组合。但你做题之前一定要确认好语义,不然写完一版发现答案对不上,还以为是自己写错了。

还有一种更常见的表述是“统计数组中差为 1 的数对个数,每个元素只能使用一次”,这种语义下[1, 2, 2, 3]配合贪心匹配,答案会是 2。不同语义对应的解法完全不一样,所以我建议一上来先把这句“这个‘对’到底怎么算”问清楚,这是所有讨论的地基。

1.2 数据规模决定你做法的上限

很多新手拿到题就直接写双重循环,也不看一眼数据范围。实际刷题平台的约束通常写得很明确,比如:

  • n <= 10^3:O(n^2)随便写,暴力法完全没问题。
  • n <= 10^5:需要O(n log n)排序或O(n)哈希。
  • n <= 10^7甚至更大:基本只能O(n)扫描,而且要考虑内存和 IO。

我见过不少人在n = 10^5的场景下用了两层循环,本地小数据全对,提交直接超时。所以拿到题第一件事不是写代码,而是看数据范围,先估算一下自己能接受的复杂度上限。

对于这道题,O(n)到O(n log n)都是可以接受的。如果你一开始就写哈希法,那连排序都不需要,直接一遍过。这个选择背后的逻辑我会在下一节详细拆。

2. 三种主流通解与实现细节

2.1 暴力法:用来验证答案的“基准测试”

先看最直观的实现。两层循环枚举所有组合,判断绝对差是否等于 1:

def count_pairs_bruteforce(nums): n = len(nums) ans = 0 for i in range(n): for j in range(i + 1, n): if abs(nums[i] - nums[j]) == 1: ans += 1 return ans

这段代码思路没有任何问题,在n <= 10^3的场景下跑得飞快。但它的价值不止于此:暴力法最简单、最容易验证正确性,所以我在写优化版本之前,总是先写一个暴力版当基准测试。后面优化版本运行结果跟它对比,如果答案不一致,说明优化版有 bug。

这个习惯特别重要。很多人直接上手写哈希法,写完拿几个小样例测一下觉得没问题就交了,结果遇到重复数字的情况答案错误。有了暴力版做对照,你一眼就能看出那一步错了。

2.2 排序法:实际刷题中性价比最高的方案

排序法的思路很自然:如果数组有序,那么和当前元素相差 1 的数一定紧挨在它旁边。排序后只要检查相邻元素就行:

def count_pairs_sort(nums): nums.sort() n = len(nums) ans = 0 for i in range(1, n): if nums[i] - nums[i - 1] == 1: ans += 1 return ans

等等,这里有个关键陷阱:如果数组里有重复元素,排序后重复值会连在一起,但重复值本身不会形成差 1 的数对。比如[1, 1, 2],按上面代码跑,检查1和1差为 0,跳过;检查2和1差为 1,答案加一。看起来没问题。

但如果数组是[1, 2, 2, 3]呢?检查2和2跳过,检查3和2差为 1,答案加一。这里就有问题了:1和2这对被漏掉了,因为1的右边紧挨着的是2,但2的下标被前面那个重复的2占位了?

实际跑一遍你就知道:排序后数组[1, 2, 2, 3],循环检查相邻元素:

  • nums[1] - nums[0] = 2 - 1 = 1,ans = 1
  • nums[2] - nums[1] = 2 - 2 = 0,跳过
  • nums[3] - nums[2] = 3 - 2 = 1,ans = 2

结果还是 2 对:(1,2)和(2,3),居然没丢。但这只是运气好,因为我们的判断只看相邻差是否为 1。如果数组里有两个相同的 1:[1, 1, 2, 3],排序后检查:

  • 1 - 1 = 0,跳过
  • 2 - 1 = 1,ans = 1
  • 3 - 2 = 1,ans = 2

结果(1,2)被计数了吗?被计了,因为那个1虽然是重复的第二个1,但它在数组中确实存在,数值1和2的差是 1。

问题出在什么时候?如果数组是[1, 2, 2, 4],照理应有(1,2)一对。跑一下:

  • 2 - 1 = 1,ans = 1
  • 2 - 2 = 0,跳过
  • 4 - 2 = 2,跳过

得 1 对,也没问题。我仔细想了想,当差值恰好为 1 时,重复值确实不太会影响相邻判断,因为差 1 的两个数必然相邻出现,除非中间的重复值数量特别多,把这两个数分隔开。比如[1, 2, 2, 2, 3]:

  • 2 - 1 = 1,ans = 1
  • 2 - 2 = 0
  • 2 - 2 = 0
  • 3 - 2 = 1,ans = 2

(2,3)被计了,(1,2)也被计了,结果正确。

但万一重复值把相邻关系破坏了怎么办?比如[1, 3, 2, 2, 2, 4],排序后[1, 2, 2, 2, 3, 4]:

  • 2 - 1 = 1,ans = 1
  • 2 - 2 = 0
  • 2 - 2 = 0
  • 3 - 2 = 1,ans = 2
  • 4 - 3 = 1,ans = 3

结果是 3 对,实际数值对(1,2)、(2,3)、(3,4),正好 3 对。看起来差值恰好为 1 时,中间无论堆多少重复值,相邻差序列里总会包含一次2-1或3-2。但我不能确保任何情况下都如此,稳妥的做法是先set去重再排序:

def count_pairs_sort(nums): nums = sorted(set(nums)) ans = 0 for i in range(1, len(nums)): if nums[i] - nums[i - 1] == 1: ans += 1 return ans

这样语义最清晰:先拿到所有不同数值,再统计相邻差为 1 的对数。排序复杂度O(n log n),去重后元素个数最多是min(n, 不同值数量),内存可控。这个版本我强烈推荐,因为它把语义和实现统一了,不管你面试时怎么被追问都不慌。

实际上我不确定上面那个不先去重的版本在极端情况下是否一定等价,但正是这种不确定让我养成了“先 set 再排序”的习惯。做题不是猜运气,确定性的解法才是好解法。

2.3 哈希表法:更好玩,也更容易写错

排序法已经很快了,但还有一个更骚的操作:用哈希集合存所有出现的数值,然后遍历每个数,检查x + 1是否在集合里:

def count_pairs_hash(nums): s = set(nums) ans = 0 for x in s: if x + 1 in s: ans += 1 return ans

这个方法的精妙之处在于,把“找配对”变成了“查存在”。你不需要比较任何两个数,只需要对每个数问一句:x+1在不在?如果x和x+1都在集合里,那它们就是一对差 1 的数。

为什么不会重复计数?因为(x, x+1)只会从x这边被统计一次,等遍历到x+1时,检查的是x+2是否存在,跟前面那对没关系。所以答案天然不重不漏。

这个解法的时间复杂度是O(n),空间也是O(n)。在数据规模很大的时候,它是性能和代码简洁度上的最优解之一。

三种方法对比如下:

方法时间复杂度空间复杂度适用场景易错点
暴力法O(n^2)O(1)n <= 10^3 或当基准测试双层循环写错下标
排序法O(n log n)O(1)(或 O(n) 若用 set)n <= 10^5 通用重复值未去重
哈希法O(n)O(n)n 很大且内存够x + 1溢出

3. 最容易翻车的边界:溢出、负数与重复值

3.1 32位有符号整数的边界在哪里?

先说一个实战中特别常见的翻车点:整数溢出。题目如果来自面试或 OJ,通常会限定输入范围,比如“数组元素是 32 位有符号整数”。32 位有符号整数的范围是-2^31到2^31 - 1,也就是-2147483648到2147483647。

这时候哈希法里写if x + 1 in s就可能踩坑:如果x恰好等于2147483647,那么x + 1在 32 位整数里会溢出成-2147483648。Python 因为是任意精度整数,不会溢出,但如果是 Java、C++,这一步直接 bug。

Java 里怎么处理?稳妥做法是用长整型:

public int countPairs(int[] nums) { Set<Long> set = new HashSet<>(); for (int num : nums) { set.add((long) num); } int ans = 0; for (Long x : set) { if (set.contains(x + 1)) { ans++; } } return ans; }

把x转成long再比较,就绕开了 32 位边界问题。C++ 则可以直接用long long。别小看这一步,我见过不止一个候选人写完哈希法,反问一句“如果 x 是 INT_MAX 会怎样”,当场答不上来。

另外,负数的情况也顺便说一下。比如[-1, 0, 1, 2],差 1 的数对有(-1,0)、(0,1)、(1,2)。无论排序法还是哈希法,负数天然参与比较,-1 + 1 = 0,逻辑没问题。唯一要注意的是abs(nums[i] - nums[j]) == 1这种写法在暴力法中容易让人忽略负数,其实没有影响,绝对值就是为负数准备的。

3.2 排序法的去重陷阱

我在 2.2 节强调过“先 set 再排序”。这里再展开说一下为什么。

如果不先去重,排序后的数组可能长这样:[1, 1, 1, 2, 3, 3]。遍历相邻元素,你会发现有 3 次相邻差为 1(两个1与2各相邻一次?其实只有2-1那次)。数值对只有(1,2)和(2,3)两对,但某个错误写法可能会统计出 3 对、4 对甚至更多。

再举一个更直接的例子:[1, 1, 2, 2],如果按“去重后的值对”算,答案应该是 1 对((1,2))。但如果不先去重,只检查相邻差为 1,那么排序后:

  • 1 - 1 = 0
  • 2 - 1 = 1,ans = 1
  • 2 - 2 = 0

答案确实是 1,看起来没问题。但换一种写法,如果用“双指针扫不同值”的方式,或者用“每个数找 x+1”的方式,只要你没有去重,答案可能变成 4。为什么?因为1有两个、2有两个,按下标组合来算就是 4 对。

同一个题目,不同解法跑出不同答案,这就是语义没定死的后果。所以我反复强调:进入写代码阶段之前,先确认题目要的是“值对”还是“下标对”。如果是“下标对”,这种情况只能用哈希计数法,并且要从出现的每个位置分别配对。如果是“值对”,set去重是必须的一步。

3.3 求一个整数有多少位,基础但实用

跟这道题相关的另一个小技能是“求一个整数有多少位”。面试和刷题里经常用到,比如把数组元素落到桶里、取哈希槽位、判断整数位数以决定用什么数据结构。

最稳的写法是用对数:

import math def count_digits(n): if n == 0: return 1 return math.floor(math.log10(abs(n))) + 1

如果怕浮点数精度问题,也可以用循环除10:

def count_digits(n): if n == 0: return 1 n = abs(n) cnt = 0 while n: n //= 10 cnt += 1 return cnt

这个技能在这道题里什么时候用得到?如果你要给元素分组,比如按最后一位数字分类,或者按位数判断可能的最大值,都会用到。它不算什么高深算法,但关键时刻能帮你少写很多 if。另外,它和“整数排序”“逆序输出整数”这类基础题一样,是我们练好基本功的一部分,别嫌小。

4. 经典题不是终点:几个紧密相关的变体扩展

4.1 01序列与整数k的限制间隔问题

热搜词里出现了一个很有意思的变体:“给你一个 01 序列以及一个整数 k,如果所有 1 都至少间隔 k 个元素”。这其实是一个和“数对差 1”同源的统计问题,只是把“数值差”换成了“位置间隔约束”。

给定一个只含 0 和 1 的序列,比如0100101,再给一个整数k,问是否所有1之间都至少间隔k个元素(也就是相邻两个 1 的下标差大于等于k + 1)。这个问题的解法非常直接:

def check_gap(s, k): last_one = -1 for i, ch in enumerate(s): if ch == '1': if last_one != -1 and i - last_one < k + 1: return False last_one = i return True

核心逻辑是记录上一个1的位置,遇到新的1就计算下标差。如果下标差小于等于k,说明不满足“至少间隔 k 个元素”的要求。注意这里的边界:如果k = 1,要求两个1之间至少有一个0,那么下标差至少是 2;如果k = 0,则相邻1都不允许,下标差至少是 1。

跟“数对差 1”那题对比,你会发现它们的共同点是:把问题转化为相邻关系或存在性查询。数对差 1 是问值域上的邻居是否存在,01 序列是问位置序列上相邻1的距离是否达标。会了前者,后者基本上就是换层皮。

4.2 当整数大到需要高精度:Python、Java、Julia 实测

“有多少对整数差 1”这个题本身不太会出现超大整数,但一旦数据范围变成高精度场景,比如让你从超大整数集合里找差 1 的数对,事情就有意思了。

先说 Python,它的整数是任意精度的,10**100 + 1随便算,完全不担心溢出。所以上面哈希法在 Python 里天然免疫 32 位溢出问题。这也是为什么很多人用 Python 刷题,写起来确实省心。

Java 则必须用BigInteger:

import java.math.BigInteger; import java.util.HashSet; import java.util.Set; public int countPairsBig(Set<BigInteger> nums) { Set<BigInteger> set = nums; int ans = 0; for (BigInteger x : set) { if (set.contains(x.add(BigInteger.ONE))) { ans++; } } return ans; }

这里x.add(BigInteger.ONE)就是x + 1,但每一步都是对象运算,性能和内存都比原生 int 差很多。所以高精度场景下,能用原生类型就不要上 BigInteger,这属于性能常识。

Julia 在这方面做得比较巧妙。它的Int默认是机器整数,但如果你在计算中溢出,可以使用BigInt或直接让数值类型自动提升。Julia 的BigInt用起来非常顺手:

x = big(10)^100 + 1 y = x + 1 println(y - x) # 1

我在实测里发现 Julia 的任意精度整数速度比 Python 快不少,和 Java BigInteger 接近,但语法上更接近数学表达。如果你是那种对性能敏感又想用高精度的人,Julia 是个很舒服的选择。

这里也顺带提一句“Julia 高精度浮点数和整数”这个热搜词:Julia 中BigFloat和BigInt是独立类型,互相转换要显式调用,比如BigFloat(1//3)和big(1)//big(3)的行为完全不同。写算法时一定要搞清楚自己用的是哪种,避免出现“整数除法把余数丢掉”这种经典错误。

4.3 大整数加法并行与排序优化的进阶思路

再往深处走,如果数据量特别大,大到单机内存都装不下,就要考虑并行和分治了。热搜词里的“大整数加法 并行”和“整数排序”其实就是这类进阶思路的关键词。

大整数加法的并行思路很直白:把大整数按位切分成多段,每一段独立相加,然后再处理进位。比如一个 1 万位的整数加法,可以切成 4 段,每段 2500 位,四个线程同时算,最后从低位到高位统一进位。

回到“找差 1 的数对”这个问题上,如果数据量大到需要并行,最简单的做法是把排序分摊到多线程:

  1. 把数组切分成若干块,每块独立排序。
  2. 多路归并,得到一个全局有序序列。
  3. 线性扫描一次统计答案。

这就是典型的“并行排序 + 串行扫描”模式。它之所以高效,是因为排序是整个算法中唯一需要耗费大量时间的地方,而扫描是 O(n) 的,扛得住单线程。如果不想排序,也可以用分布式哈希表,但工程复杂度会高出很多。对大部分场景来说,并行排序反而最实用。

如果是用二分分治的思路来做这道题,也可以这样:把数组分成两半,分别统计内部对数,再统计跨过中点的数对。跨中点的那部分,可以先把两边排序,然后用双指针统计。这本质上就是归并排序的副产品,复杂度依旧是 O(n log n)。这种扩展我更建议你了解一下,因为面试官很爱从简单题往下追问,看你有没有能力把它改成归并排序的框架。

4.4 语言细节差异:Java 逆序输出那道题踩过的坑

有热词提到“Java 逆序输出整数”,正好可以顺便聊聊不同语言处理整数时的差异,因为这跟我们前面讨论的溢出问题高度相关。

经典的 Java 逆序输出题是:给一个 32 位有符号整数x = 123,返回321;如果x = -123,返回-321;如果反转后溢出,则返回 0。标准写法:

public int reverse(int x) { int rev = 0; while (x != 0) { int pop = x % 10; x /= 10; if (rev > Integer.MAX_VALUE / 10 || (rev == Integer.MAX_VALUE / 10 && pop > 7)) { return 0; } if (rev < Integer.MIN_VALUE / 10 || (rev == Integer.MIN_VALUE / 10 && pop < -8)) { return 0; } rev = rev * 10 + pop; } return rev; }

这个题和我们前面聊的“差 1 数对”有什么关系?其实是同一个底层能力的两种体现:对整数边界、取模、负数的处理是否够稳。

写逆序输出的常见坑有三个:

  1. x % 10对负数的结果是负数,比如-123 % 10 = -3,这符合 Java 规范,但很多从 Python 转过来的人会搞混(Python 的-123 % 10 = 7)。
  2. 溢出判断放在rev = rev * 10 + pop之前,否则已经溢出了再判断就晚了。
  3. 边界值Integer.MAX_VALUE = 2147483647、Integer.MIN_VALUE = -2147483648,反转后很可能会越过这个范围。

这些经验不是孤立的小技巧,它们跟“32 位有符号整数”这个热搜词直接相关。不管你是刷题还是做真实系统,只要涉及 int 乘法、加法,就要随时警觉溢出问题。“差 1 数对”这道题里的x + 1溢出只是冰山一角,真正的高频风险在交易金额、时间戳换算、ID 拼接这些线上场景里更多。

5. 常见问题排查与工程实践补充

5.1 刷题和工程中常见的坑速查表

我把这道题及相关变体里最常见的坑整理成一个速查表,方便你下次直接对号入座:

问题现象原因解决方案
超时大数据量跑不动用了 O(n^2) 暴力法改为排序或哈希
答案偏大把重复下标也算进去了语义未定成“值对”先 set 去重或明确下标语义
答案偏小部分数对漏统计排序后未处理重复值先去重再排序遍历
溢出哈希查不到 INT_MAX 的配对x + 1超出 32 位范围Java 用 long 或 BigInteger
负数处理错误逆序输出、差分计算不正确对%和/负数语义不熟先验证语言对负数的定义再编码
大整数计算吃内存10^100 级别的统计盲目用高精度对象能用 int 不碰 BigInteger,必须用时控制规模
01 序列边界误判k=0和k=1结果一样没搞清“间隔 k 个元素”的定义明确下标差阈值是k+1还是k

5.2 工程中存整数怎么选类型:以 MySQL 为例

算法题写顺手了,最后落到真实工程里,整数类型的选择也是个高频话题。热搜词里有“mysql 可以存储整数数值的是”,这其实就是工程中一个很实际的问题。

以 MySQL 为例,整数类型主要有这些:

类型字节数有符号范围常见用途
TINYINT1-128 ~ 127状态码、开关量
SMALLINT2-32768 ~ 32767小范围计数
MEDIUMINT3-8388608 ~ 8388607中量级计数
INT4-2147483648 ~ 2147483647常规 ID、计数
BIGINT8-9223372036854775808 ~ 9223372036854775807雪花 ID、大金额

回到我们的“数对差 1”问题,如果你要把所有整数放进 MySQL 去重统计,那字段类型就得按数据范围选:普通业务量用INT,如果可能超过 21 亿就上BIGINT。这里有个很多人忽略的细节:MySQL 的INT在有符号时最大只能到 2147483647,无符号才能到 4294967295。所以建表时除了看范围,还要考虑是否有符号、是否需要无符号,避免将来线上跑着跑着突然溢出报错。

另外,MySQL 的INT展示宽度(比如INT(11))不影响存储范围,它只是显示宽度。很多初学者以为INT(11)能存更大的数,其实不能。这种细节在面试和实际运维中都可能被问倒。

5.3 我个人在实际操作中的一些体会

最后分享一点我在做这类题目和工程落地时的个人经验。

我最开始也是喜欢一上来就写哈希法,觉得 O(n) 很酷。后来做线上性能分析多了,发现很多场景根本不需要 O(n) 的“极致优化”,排序 + 扫描反而更容易维护。因为set虽然快,但它在内存占用和哈希碰撞上的表现会受到数据分布影响,最坏情况下可能退化。而排序算法是高度优化过的,时间可控、行为可预测。在算法竞赛之外的真实系统里,稳定比华丽更重要。

还有一个小技巧:遇到这种“差值固定为 1”的题目,先想想它能不能用“排序后看相邻”解决。如果能,通常意味着问题可以转化为相邻关系,这种转化往往还能进一步迁移到其他类似问题,比如“差为 k 的数对”“差为 k 的子数组”等。你掌握的是同一个思维模型,而不是一道孤立的题。

另外,写代码前把数据范围、语义、边界条件三件事想清楚,比手速快更值钱。我在带新人时就发现,他们不是不会写 sort,而是根本没意识到重复值会改变答案语义。每次我提醒“先 set 再 sort”,他们都恍然大悟。等你踩过几次这个坑,你也会明白为什么我反复强调这句话。

回到标题本身,题目虽然简单,但它的延展性比大多数“难题”都好,可深可浅,从暴力到哈希到并行到高精度再到工程存储,都能串起来。我写这篇东西的目的就是帮你把这条线一次走通,下次不管题目怎么变形,你都能一眼看穿它。

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

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

立即咨询