题目描述

✅ 220. 存在重复元素 III

image-20260928231000281

image-20260928231000282

题意分析

判断是否存在两个不同位置,下标差不超过 indexDiff,数值差不超过 valueDiff。下文和代码分别用 k、t 表示这两个限制:按原下标顺序维护最近 k 个位置,再在这些候选中判断值差。

解法一:滑动窗口 + 有序集合

核心思路

[!blue]

处理下标 i 前,只保留下标区间 [max(0, i-k), i) 中的值。它们都与当前位置不同,并且下标差不超过 k;因此剩下的问题是:有序集合中是否存在一个值落在 [x-t, x+t],其中 x = nums[i]。

set.ceiling(x-t) 返回不小于下界的最小值。若不存在,所有候选都小于下界;若它大于 x+t,其他不小于下界的值只会更大。只有它位于上界以内时才存在答案,所以每轮只检查这一个候选即可。

必须先查询,再插入 x,避免把当前位置与自己配对。本轮检查结束后,若 i >= k,删除下标 i-k 对应的旧值:它与当前下标相差 k,本轮仍可使用,但与下一下标相差 k+1,必须在下一轮前移除。

普通集合不保存重复次数也足够:只要有效窗口中出现两个相同值,值差为 0,就已经返回 true。因此执行到插入、删除时,窗口内不会有重复值,删除过期值不会误删另一个仍有效的副本。代码用 long 保存值并计算上下界,删除时也使用相同类型。

解题步骤

  • 将值提升为宽整数。
  • 查询下界的最小后继并比较上界。
  • 未命中才加入当前值。
  • 本轮末尾移除下标 i-k,为下轮维持有效窗口。
  • 全部位置都未命中时,返回 false。

代码实现

class Solution {
    public boolean containsNearbyAlmostDuplicate(int[] nums, int k, int t) {
        if (k <= 0 || t < 0) {
            return false;
        }

        TreeSet<Long> set = new TreeSet<>();

        // 查询时只保存此前至多 k 个位置,不包含当前下标
        for (int i = 0; i < nums.length; i++) {
            long x = nums[i];
            // 只需检查不小于下界的最小候选,它超上界则其他值也无解
            Long ceil = set.ceiling(x - t);

            if (ceil != null && ceil <= x + t) {
                return true;
            }

            // 全部查询未命中后才写入,避免当前项与自己配对
            set.add(x);

            // 本轮末尾删除 i 减 k 的旧位置,为下一轮维护窗口
            if (i >= k) {
                set.remove((long) nums[i - k]);
            }
        }

        return false;
    }
}

复杂度分析

  • 时间复杂度:$O(n\log(\min(n,k)+1))$,n 为数组长度,每项执行常数次有序集合操作。
  • 空间复杂度:$O(\min(n,k)+1)$,含插入后暂时多出的一项。

关键点总结

[!green]

  • 查找范围的最小候选即可,无需扫描整个集合。
  • 位置窗口按原顺序维护,不能排序输入。
  • Java 集合保存宽整数,删除旧值也使用相同类型。

解法二:滑动窗口 + 分桶

核心思路

[!blue]

沿用相同的下标窗口,把其中的值按宽度 w = t+1 分桶。桶号为 b 的桶覆盖整数 [b*w, (b+1)*w-1],桶内最大差是 w-1 = t,所以当前值的桶中只要已有元素,就可以直接返回 true。

若两个桶的编号至少相差 2,即使取最近的两个端点,差也至少为 w+1,已经超过 t。因此只需再检查左右相邻桶;邻桶中的值可能接近,也可能相差很远,必须验证实际差值。左桶中的值小于当前值,右桶中的值大于当前值,所以两侧分别使用 x-val 与 val-x。

桶号必须是 floor(x/w)。Go 的整数除法对负数向零截断,所以 x < 0 时用 (x+1)/w-1 得到向下取整的结果,避免把零两侧的数错误地放入同一个过宽的桶。宽度、桶号和值都使用 int64 计算。

每轮先检查三个桶,未找到答案才把当前值写入本桶,再删除下一轮过期位置所属的桶。同桶出现两个有效元素时早已返回成功,因此继续执行时每个桶最多只有一个值,删除整个过期桶不会丢失其他有效候选。

解题步骤

  • 桶宽使用宽整数计算 t+1。
  • 计算当前值桶号,负数补偿向零截断的除法。
  • 检查同桶与相邻桶,相邻时再验证差值。
  • 写入当前值,并删除下一轮过期位置所属桶。
  • 扫描结束仍无匹配则返回 false;t = 0 时桶宽为 1,只会接受相等的数。

代码实现

func containsNearbyAlmostDuplicate(nums []int, k int, t int) bool {
    if k <= 0 || t < 0 {
        return false
    }

    bucketSize := int64(t) + 1
    buckets := make(map[int64]int64)

    getBucket := func(x int64) int64 {
        if x >= 0 {
            return x / bucketSize
        }
        // 负数向下取整,避免零两侧被截断到同一个过宽桶
        return (x+1)/bucketSize - 1
    }

    // 查询时只保存此前至多 k 个位置,不包含当前下标
    for i, v := range nums {
        x := int64(v)
        id := getBucket(x)

        if _, ok := buckets[id]; ok {
            return true
        }
        // 相邻桶仅可能接近,仍要验证实际差值
        if val, ok := buckets[id-1]; ok && x-val <= int64(t) {
            return true
        }
        if val, ok := buckets[id+1]; ok && val-x <= int64(t) {
            return true
        }

        // 全部查询未命中后才写入,避免当前项与自己配对
        buckets[id] = x
        // 本轮末尾删除 i 减 k 的旧位置,为下一轮维护窗口
        if i >= k {
            old := int64(nums[i-k])
            bid := getBucket(old)
            delete(buckets, bid)
        }
    }

    return false
}

复杂度分析

  • 时间复杂度:期望 $O(n)$,每项最多检查三个哈希桶。
  • 空间复杂度:$O(\min(n,k)+1)$,只保存有效窗口桶。

关键点总结

[!green]

  • 桶宽含端点差,因此取阈值加一。
  • 负数必须使用向下取整的桶号。
  • 邻桶仅表示可能接近,仍需实际差值检查。

易错点总结

[!yellow]

  • 先插入再查询,会把当前元素与自己配对。
  • 过期位置未删除,会接受下标距离超限的值。
  • 邻桶存在就直接成功,会把实际差值过大的配对误计。
  • 负数桶号向零截断,会扩大零附近的桶范围,误收本不接近的值。
  • Java 有序集合存放宽整数,边界运算和删除旧值都要使用相同类型。

相似题目

题目 难度 关联与区别
219. 存在重复元素 II 简单 都限制下标距离,原题只查完全相同的值,本题允许值差,需要有序窗口或桶定位。
480. 滑动窗口中位数 困难 同样维护会插入和过期删除的窗口,原题查询中位数,本题查询邻近数值范围。
217. 存在重复元素 简单 重复元素系列。去掉距离限制并要求两个值相同后,可简化为集合判重。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2020/66367910
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!