LeetCode 219. 存在重复元素 II
题目描述
题意分析
问的是数组里是否存在两个下标
i和j,满足nums[i] == nums[j]且abs(i - j) <= k。注意问的是存在性,返回布尔值,不需要给出具体是哪一对。两个条件是「值相等」和「下标靠近」的合取,前者天然指向按值分组,后者天然指向对下标做距离约束,所以状态里必须同时携带值和下标两样信息。
数组长度可以到 10^5,元素值域是完整的 int 范围且可以为负,
k可以是 0,也可以远大于数组长度。k = 0时要求i和j距离不超过 0,而题目又要求它们是不同下标,所以此时恒为false;k极大时等价于问「数组里是否有重复元素」。值域跨整个 int 说明不能开值域数组做计数,只能用哈希;长度 10^5 说明 $O(n^2)$ 的两两比较会退化到 10^10 次操作,必须做到线性或近线性。
解法:哈希表记录最近位置
核心思路
暴力做法是双重循环枚举所有下标对
(i, j),检查值是否相等且距离是否不超过k。它显然正确,但在 10^5 长度下要做约 50 亿次比较,瓶颈在于对每一个i,我们都从头把整个数组重扫了一遍,而其中绝大多数下标离i早就超过k了,根本没有资格参与配对。稍微收敛一点的暴力是只往前看
k个位置,代价变成 $O(nk)$,但k本身也可以到 10^5,最坏情况没有改善。真正的浪费在于:对固定的值v,我们反复线性搜索它上一次出现在哪。观察点是这个题的非对称性——如果把
i固定为「当前正在处理的右端点」,那么所有候选的j都在i左边,而在所有满足nums[j] == nums[i]的左侧下标里,只有最大的那个才最有可能满足i - j <= k。换句话说,同一个值的更早出现位置全是冗余的:如果最近的那次都够不着,更早的必然更够不着。由此得到本解法的不变量:扫描到下标
i之前,哈希表里对每个已出现过的值v,恰好存着v在[0, i-1]范围内最后一次出现的下标。维护住这条不变量,判断就退化成一次哈希查询加一次减法。这条不变量也顺带决定了读写顺序:必须先用当前值查表(此时表中还没有
i自己,避免拿自己和自己配对得到距离 0),再把i写回去覆盖旧值(保证下一轮开始时不变量依然成立)。
解题步骤
- 建一个从元素值到下标的哈希表
last,初始为空。之所以映射到下标而不是布尔或次数,是因为距离条件只依赖位置,只记「出现过」会丢掉判断所需的信息。- 从左到右用下标
i遍历数组。之所以必须带着下标遍历而不是只遍历值,是因为距离约束的两端都是下标,值本身无法推出位置。- 每到一个位置,先查
last中是否已有nums[i]。之所以先查后写,是因为写在前面会让当前元素立刻覆盖自己的记录,随后查到的就是i本身,i - i = 0 <= k在k >= 0时恒成立,任何输入都会被误判为true。- 若查到旧下标
prev且i - prev <= k,立刻返回true。之所以能立刻返回,是因为题目只问存在性,找到一对合法配对后无需继续;同时因为prev < i,i - prev必为正数,不必再取绝对值。- 无论是否查到,都把
last[nums[i]] = i写回去。之所以在「查到了但距离超了」的情况下也要覆盖,是因为旧下标已经被证明太远,留着它只会让后续判断更悲观,而i是这个值到目前为止最靠右的出现位置,正是不变量要求保存的那个。- 循环自然结束说明没有任何合法配对,返回
false。以
nums = [1, 2, 3, 1, 2, 3], k = 2走一遍:i = 0,值 1,表中无,写入{1:0}。i = 1,值 2,表中无,写入{1:0, 2:1}。i = 2,值 3,表中无,写入{1:0, 2:1, 3:2}。i = 3,值 1,查到prev = 0,3 - 0 = 3 > 2,不满足,覆盖为{1:3, 2:1, 3:2}。i = 4,值 2,查到prev = 1,4 - 1 = 3 > 2,覆盖为{1:3, 2:4, 3:2}。i = 5,值 3,查到prev = 2,5 - 2 = 3 > 2,覆盖。循环结束返回false,与预期一致。再用
nums = [1, 0, 1, 1], k = 1检验覆盖的必要性:i = 0写入{1:0}。i = 1写入{1:0, 0:1}。i = 2,值 1,查到prev = 0,2 - 0 = 2 > 1,不满足,此时把记录覆盖成{1:2, ...}。i = 3,值 1,查到prev = 2,3 - 2 = 1 <= 1,返回true。如果第i = 2步没有覆盖而是保留了最早的 0,第i = 3步算出的距离会是 3,就会漏掉正确答案。
代码实现
class Solution {
public boolean containsNearbyDuplicate(int[] nums, int k) {
Map<Integer, Integer> last = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
Integer prev = last.get(nums[i]);
if (prev != null && i - prev <= k) {
return true;
}
last.put(nums[i], i);
}
return false;
}
}
func containsNearbyDuplicate(nums []int, k int) bool {
last := make(map[int]int)
for i, v := range nums {
if prev, ok := last[v]; ok && i-prev <= k {
return true
}
last[v] = i
}
return false
}
复杂度分析
- 时间复杂度:$O(n)$,凭据是数组只被从左到右扫描一遍,每个位置上做的是一次哈希查询和一次哈希写入,两者的均摊代价都是 $O(1)$,与
k的大小完全无关。- 空间复杂度:$O(\min(n, m))$,其中 $m$ 是数组中不同元素的个数,凭据是哈希表里每个不同的值只占一条记录,值重复出现时只是覆盖下标而不新增条目,最坏情况(全部互异)退化为 $O(n)$。
关键点总结
- 「值相等 + 下标接近」这类双条件存在性问题,通用套路是把其中一个条件交给哈希表做分组,另一个条件在查表命中后用 $O(1)$ 判断来兜住。
- 固定右端点、只回看左侧的扫描方式,能把对称的两两枚举变成单向的一次遍历,这是从 $O(n^2)$ 降到 $O(n)$ 最常用的视角转换。
- 「同值只需保留最近一次出现」是一条可迁移的剪枝原则:当判据关于距离单调时,更早的候选被更晚的候选严格支配,可以安全丢弃。
- 先查表后写表的顺序不是风格问题而是正确性问题,凡是「当前元素不能和自己配对」的题都必须把写操作放在查操作之后。
- 面试视角:这题面试官真正在看的是你能否说清「为什么覆盖旧下标是安全的」。只写出五行代码而讲不出支配关系,会被追问到答不上来;能主动提出
k = 0和「k 大于数组长度时退化为 217」这两个边界,则加分明显。另一个常被追问的等价写法是维护一个大小为k的滑动窗口集合,空间可降到 $O(\min(n, k))$,可以作为后续优化点主动提出。
易错点总结
- 把
last.put(nums[i], i)写在查询之前:用例nums = [1, 2, 3], k = 1,第 0 位写入后立刻查到prev = 0,0 - 0 = 0 <= 1成立,返回true,而正确答案是false。- 用
Set而不是Map只记「出现过」:用例nums = [1, 2, 3, 1], k = 2,会因为看到重复的 1 就返回true,但实际距离是 3,正确答案是false。- 命中旧值但距离超限时选择
continue而不覆盖记录:用例nums = [1, 0, 1, 1], k = 1,表里的 1 一直停在下标 0,到i = 3算出距离 3,返回false,而正确答案是true。- 用
int prev = last.get(nums[i])而不先判空:用例nums = [1, 2, 3], k = 1,第 0 位取到null后拆箱,抛出NullPointerException。- 判断写成
i - prev < k:用例nums = [1, 1], k = 1,1 - 0 = 1不小于 1,返回false,而正确答案是true,题目给的是闭区间<=。- 用
last.getOrDefault(nums[i], -1)后直接比较i - prev <= k:用例nums = [5], k = 1尚可,但nums = [5, 6], k = 5时i = 0取到-1,0 - (-1) = 1 <= 5成立,返回true,而正确答案是false;默认值必须选成不可能满足条件的极小值如Integer.MIN_VALUE,且要防减法溢出。- 直接对
nums[i]排序后找相邻相等元素:用例nums = [1, 2, 3, 1], k = 3,排序打乱了下标,abs(i - j)已无从计算,得到的答案与真实位置无关。- 用
nums[i] == nums[j]的双重循环并把内层写成for (int j = i + 1; j < nums.length; j++)却不加j - i > k的提前跳出:用例长度 10^5 的全互异数组,会执行约 50 亿次比较而超时。- Go 里写成
if prev := last[v]; v != 0 && i-prev <= k,用值是否为零来判断存不存在:用例nums = [0, 1, 0], k = 2,值 0 永远走不到判断分支,返回false,而正确答案是true;必须用prev, ok := last[v]的双返回值形式。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 217. 存在重复元素 | 简单 | 去掉了距离约束,只需集合判重,是本题在 k 无穷大时的退化形态 |
| 220. 存在重复元素 III | 困难 | 值也放宽成相差不超过 t,哈希查不出来,需要有序结构或桶 |
| 1. 两数之和 | 简单 | 同样是先查后写的哈希配对,但配对条件是求和而非相等 |
| 3. 无重复字符的最长子串 | 中等 | 记录字符最近位置后用来推进左边界,从判存在升级为求最优长度 |
| 438. 找到字符串中所有字母异位词 | 中等 | 窗口大小固定但状态是整张计数表,考察进出窗口时的增量维护 |