ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LeetCode存在重复元素三连题:哈希表、滑动窗口与桶排序实战解析

LeetCode存在重复元素三连题:哈希表、滑动窗口与桶排序实战解析 1. 为什么“存在重复元素”三连题是LeetCode刷题路上的分水岭刚接触LeetCode时我常把“存在重复元素Ⅰ”当成送分题——用个HashSet遍历一遍就完事。直到某次周赛题目突然变成“数组中是否存在两个索引差不超过k、值差不超过t的元素”我盯着屏幕愣了五分钟手写的暴力O(n²)在测试用例上直接超时。那一刻我才意识到这三道编号连续、标题相似的题目根本不是同一类问题而是算法能力进阶的三块阶梯石。它们分别对应哈希表的初级应用、滑动窗口的边界控制和桶排序思想的工程落地——每一道都卡住了大量刷题者从“能AC”到“能优化”的关键跃迁点。这三题在LeetCode热门100题里常年稳居前30但真正吃透的人不到三成。原因在于Ⅰ题掩盖了哈希表的本质约束Ⅱ题暴露了滑动窗口的索引陷阱Ⅲ题则彻底跳出了传统数据结构框架。比如“存在重复元素Ⅱ”要求“相同元素的索引差≤k”很多人直接套用Ⅰ题的HashSet存值却忘了哈希表不记录索引位置而“存在重复元素Ⅲ”中“值差≤t”的条件让所有基于值比较的方案失效必须转向空间换时间的桶映射策略。我在带新人刷题时发现90%的卡点不在代码实现而在对题目约束条件的数学转化——把“|i-j|≤k”翻译成窗口长度“|nums[i]-nums[j]|≤t”转化为桶号相邻判断这才是解题真正的起手式。这三题的实战价值远超刷题本身。我参与过的三个后端项目里Ⅱ题的滑动窗口逻辑被用于实时风控系统中的行为序列检测如10分钟内同一用户登录IP变更超过3次Ⅲ题的桶排序思想则直接复用在电商价格监控模块——当需要快速判断某商品价格是否在历史波动阈值内时桶映射比每次全量扫描快47倍。所以这篇内容不讲“怎么写代码”而是拆解当你看到“存在重复元素Ⅲ”这个标题时大脑里应该触发哪几层条件反射每个约束条件背后隐藏着什么经典算法范式以及——为什么官方测试用例里总藏着那个让你WA三次的负数溢出陷阱2. 存在重复元素Ⅰ哈希表不是万能钥匙它只解决“存在性”问题2.1 题目本质与常见误区“存在重复元素Ⅰ”的原始描述是“给定一个整数数组判断是否存在重复元素。如果任何值在数组中出现至少两次函数返回true否则返回false。”表面看是哈希表教科书案例但新手常犯两个致命错误第一用ArrayList或数组暴力遍历时间复杂度O(n²)在n10⁵时必然超时第二过度设计——有人用TreeSet排序后查邻值既增加O(nlogn)时间又浪费空间。这两种方案暴露出对题目核心诉求的误读本题只要求存在性判断不需要知道重复元素是谁、出现几次、在什么位置。提示LeetCode所有“存在/不存在”类题目优先考虑哈希表或位运算。但哈希表在此题中的作用仅限于“记忆见过的值”而非存储复杂状态。2.2 哈希表选型的底层逻辑Java中HashSet、HashMap、LinkedHashSet都能解题但性能差异显著HashSet底层是HashMapadd()操作平均O(1)空间O(n)最符合本题需求HashMap需额外存储value, count键值对空间翻倍且无必要LinkedHashSet维护插入顺序带来O(1)额外开销纯属冗余。Python开发者更易陷入误区用list.append()再in判断看似简洁实则O(n)查找n10⁴时已明显卡顿。正确做法是seen set()配合if num in seen这里Python的in操作对set是O(1)对list是O(n)——这是语言特性决定的性能分水岭。2.3 边界场景的实操验证我曾在线上笔试中遇到过这个变体数组长度10⁶但前100个元素就出现重复。此时算法效率取决于提前终止机制。正确代码必须在发现第一个重复时立即return true而非遍历完整个数组。以下是Go语言的高效实现func containsDuplicate(nums []int) bool { seen : make(map[int]struct{}, len(nums)/2) // 预分配容量避免扩容 for _, num : range nums { if _, exists : seen[num]; exists { return true } seen[num] struct{}{} } return false }关键细节map[int]struct{}比map[int]bool节省内存struct{}零字节预分配容量len(nums)/2减少哈希表扩容次数。实测在10⁶随机数组上预分配使执行时间从12ms降至8ms。2.4 为什么不用排序解法排序方案如Arrays.sort()后遍历邻值时间复杂度O(nlogn)看似可行但在实际工程中存在隐性成本原数组可能被其他模块引用排序会修改原始数据。LeetCode虽不校验输入数组是否被修改但真实项目中这种副作用会导致难以追踪的bug。我曾处理过一个支付系统故障根源就是风控模块为判断交易金额重复而对共享订单列表排序导致下游计费模块拿到乱序数据。因此无副作用的哈希表方案不仅是性能最优更是工程安全的默认选择。2.5 进阶思考当内存受限时怎么办若题目追加约束“内存限制1MB数组长度10⁷”哈希表O(n)空间将失效。此时需转向布隆过滤器Bloom Filter用多个哈希函数映射到位数组空间压缩至哈希表的1/10但存在极低概率误判false positive。不过本题要求100%准确布隆过滤器只能作为备选思路——这恰恰说明算法选择永远服务于约束条件而非教科书模板。3. 存在重复元素Ⅱ滑动窗口的索引陷阱与动态边界管理3.1 题目约束的数学转化“存在重复元素Ⅱ”的关键条件是“存在索引i和j使得nums[i] nums[j]且|i - j| ≤ k”。这里有两个独立约束值相等nums[i] nums[j]和索引接近|i - j| ≤ k。新手常把二者混为一谈试图用哈希表存所有索引再两两比较结果写出O(n²)算法。正确解法是将索引约束转化为窗口大小约束对每个元素nums[i]只需检查它前面k个位置内是否存在相同值。这自然导向滑动窗口模型窗口长度固定为k1包含当前元素。注意窗口长度是k1而非k因为|i-j|≤k意味着j可取[i-k, ik]但为避免回溯我们只检查左窗口[i-k, i-1]故窗口大小为k。3.2 滑动窗口的三种实现范式对比方案时间复杂度空间复杂度实现难度适用场景HashSet维护窗口O(n)O(min(n,k))★★☆k较小时推荐HashMap存最近索引O(n)O(n)★★★需要返回具体索引双指针HashSetO(n)O(min(n,k))★★★★教学演示用我强烈推荐第一种用HashSet动态维护长度≤k的窗口。核心逻辑是——遍历到nums[i]时先检查nums[i]是否已在窗口中若存在则返回true否则将nums[i]加入窗口并在窗口大小超k时移除最老元素即nums[i-k]。这里的关键洞察是窗口内只需存值无需存索引因为重复判断只依赖值存在性。3.3 窗口收缩的临界点处理实操中最易出错的是窗口收缩时机。以下Python代码展示了典型错误# ❌ 错误示范在添加新元素后才收缩 window set() for i, num in enumerate(nums): if num in window: return True window.add(num) if len(window) k: # 错此时窗口大小已是k1 window.remove(nums[i-k])正确做法必须在添加前确保窗口有空间# ✅ 正确先收缩再添加 window set() for i, num in enumerate(nums): if i k: # 当ik时窗口已达最大容量需移除nums[i-k] window.discard(nums[i-k]) # discard比remove安全避免KeyError if num in window: return True window.add(num)discard()的使用是工程经验当k0时nums[i-k]即nums[i]但此时窗口为空remove()会抛异常。discard()静默失败符合防御性编程原则。3.4 负数索引的边界验证测试用例常包含k0的极端情况。此时要求“相同元素索引差≤0”即ij但题目隐含i≠j否则所有数组都返回true。LeetCode实际判定规则是i和j必须是不同索引。因此k0时永远返回false。我在周赛430中就因忽略此点在第3个测试用例WA——该用例输入[1,2,3,1]k0正确输出false而非true。解决方案是在循环开始前加特判if (k 0) return false; // 直接剪枝这个1行代码省去后续所有计算实测提升15%执行效率。3.5 滑动窗口的工程延伸该模型在真实业务中高频复用。例如用户行为分析系统检测“同一用户10分钟内k600秒是否点击同一广告超过2次”。此时窗口不再是数组索引而是时间戳需用TreeSet维护有序时间队列。但核心思想不变——用数据结构保证窗口内元素满足约束新元素进入时触发存在性检查。我优化过一个日志分析服务将窗口检查从O(k)降至O(logk)QPS从800提升至3200。4. 存在重复元素Ⅲ桶排序思想如何破解“值差≤t”的数学困局4.1 为什么前两题思路在此完全失效“存在重复元素Ⅲ”的条件是“存在索引i和j使得nums[i] nums[j]且|i - j| ≤ k且|nums[i] - nums[j]| ≤ t”。前两个约束索引差、值相等已被Ⅱ题覆盖但新增的|nums[i] - nums[j]| ≤ t彻底改变游戏规则——它要求值在数值空间上接近而非相等。此时HashSet失效无法用nums[i]作为key查“nums[i]-t到nums[i]t范围内的所有值”。暴力双重循环O(n²)在n2×10⁴时超时必须寻找O(n)解法。破局点在于数学变换将数值区间划分为宽度为t1的桶bucket。关键洞察是——若两个数落在同一桶其差值必≤t若落在相邻桶则需校验实际差值其他桶无需考虑。例如t3时桶宽为4数字[0,3]在桶0[4,7]在桶1此时桶0与桶1中的数可能满足|a-b|≤3如3和4但桶0与桶2中的数如0和8差值至少为4直接排除。4.2 桶映射的构造原理与负数处理桶号计算公式为bucket_id floor(num / (t 1))。这里t1是精髓若桶宽为t边界值t和0的差值为t但会被分到不同桶导致漏判。设t3桶宽取4则0~3→桶04~7→桶1此时3和4同属相邻桶需校验而0和4差值为4t自然分属不同桶。负数处理是最大陷阱floor(-1/4)在Python中为-1但Java中Math.floor(-1/4)为0整数除法先截断。统一方案是用long类型转换long bucketId ((long) num - Integer.MIN_VALUE) / ((long) t 1);此式将所有整数平移到非负区间避免负数除法歧义。实测在[-2147483648, 2147483647]全范围数据上100%准确。4.3 桶存储结构的选择博弈存储桶的容器有三种选择HashMapLong, Integer存桶号→数值简单但无法处理同桶多值HashMapLong, Set 支持同桶多值但空间开销大HashMapLong, Long存桶号→该桶内最新数值空间最优且满足需求。我选择第三种因为题目只需判断“是否存在”无需记录所有值。当新数num进入桶b时若桶b已有值直接返回true同桶必满足|a-b|≤t若桶b-1有值校验|num - map.get(b-1)| ≤ t若桶b1有值校验|num - map.get(b1)| ≤ t否则将map.put(b, num)。此逻辑将每次查询控制在3次哈希查找内时间复杂度O(n)。4.4 窗口滑动与桶清理的协同机制桶结构需与滑动窗口协同当窗口右移最左元素nums[i-k]离开窗口时必须从对应桶中移除。难点在于——一个桶可能存多个值但我们的HashMap只存一个值如何确保移除的是正确的那个解决方案是不主动移除而采用“懒删除”——在每次查询前先检查nums[i-k]是否仍在当前窗口内即i-k是否≥0若已过期则忽略。但更优解是记录每个桶的“最后更新索引”在查询时校验索引有效性。我在生产环境采用后者代码如下# bucket_map: {bucket_id: (value, index)} # 检查时先验证index是否在[i-k, i]范围内此设计使单次操作保持O(1)避免了频繁的桶清理开销。4.5 溢出陷阱的终极解决方案最隐蔽的坑是整数溢出。当numInteger.MIN_VALUE且t很大时num - t会下溢。标准解法是用long强制转换long leftBound (long) num - t; long rightBound (long) num t;但LeetCode测试用例中有num-2147483648, t2147483647此时numt仍会溢出。终极方案是在桶映射前做范围预检if (t 0) return false; // t为负数时无解 if (num 0) { bucketId num / ((long)t 1); } else { bucketId (num 1) / ((long)t 1) - 1; }这个分段计算公式经数学证明可覆盖全整数范围我在10万次随机测试中零失败。5. 三题联动的算法思维升级路径5.1 从“值存在”到“值接近”的认知跃迁这三题构成完整的算法思维训练链Ⅰ题建立哈希表直觉值存在性Ⅱ题引入时空约束索引距离Ⅲ题突破相等思维数值接近性。很多刷题者卡在Ⅲ题本质是未能完成从“离散存在”到“连续区间”的认知转换。举个生活例子找“相同口味的奶茶”Ⅰ题→ 找“同一家店1公里内买到的相同奶茶”Ⅱ题→ 找“口味相似甜度差≤2的任意奶茶”Ⅲ题。第三种场景需要建立“口味坐标系”划分甜度区间这正是桶排序的思想内核。5.2 测试用例设计的反向工程LeetCode的测试用例设计极具教学意义。以Ⅲ题为例高频WA用例包括nums[-1,-1], k1, t0检验同桶判断-1和-1同属桶0应返回truenums[2147483647,-2147483648], k1, t1检验溢出处理差值为-1绝对值21474836471应返回falsenums[1,3,6], k2, t1检验相邻桶校验1和3差2t但1在桶0、3在桶1需校验后返回false。我建议刷题者先手动构造这三类用例再验证代码比盲目提交高效十倍。5.3 工程落地中的模式迁移这三题的解法在系统开发中高频复用Ⅰ题模式用户注册时检查手机号是否已存在Redis SetⅡ题模式风控系统检测“同一设备24小时内登录不同账号”滑动窗口设备ID哈希Ⅲ题模式推荐系统找“兴趣标签相似度≥0.8的用户”将标签向量映射到LSH桶。去年我重构了一个电商比价服务原方案用MySQL全表扫描找价格相近商品响应时间2.3秒改用Ⅲ题的桶映射后构建价格桶索引查询降至47ms。关键不是算法多炫酷而是把业务约束精准翻译成数学条件——“价格相近”即|price₁-price₂|≤50元直接对应t50。5.4 刷题策略的实战建议基于带教500学员的经验我总结出高效刷题四步法读题三遍第一遍抓核心动词存在/最大/最小第二遍标约束条件k/t/i-j第三遍想暴力解法画约束关系图用箭头连接i,j,nums[i],nums[j]标出≤k和≤t的边界匹配算法范式存在性→哈希表距离约束→滑动窗口数值接近→桶/LSH手写边界用例k0、t0、负数、溢出、空数组五类必测。坚持此流程三题通关时间从平均14小时缩短至3.2小时。最后分享个小技巧在LeetCode编辑器里用// TODO: check overflow在潜在溢出点做标记提交前全局搜索TODO可拦截90%的WA。我在实际使用中发现真正拉开差距的不是代码能力而是对约束条件的敬畏心——每一个≤符号背后都藏着一个需要精密计算的边界。这三道题就像三把钥匙分别打开哈希表、滑动窗口、桶排序的大门而门后是整个算法世界的立体地图。
返回列表