ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

两数之和:从暴力破解到哈希表优化的完整进阶指南

两数之和:从暴力破解到哈希表优化的完整进阶指南 打开 LeetCode 的第一道题大概率就是“两数之和”。很多人觉得它太简单了闭着眼睛都能写三分钟 AC然后马上冲下一题。但我面试过不少候选人能把这道题真正讲清楚、讲透彻的其实不多。一个很现实的情况是你顺手写出来的解法和你能在面试官面前讲明白的方案中间差着一整层思考。今天我就把自己对这道“LeetCode 热题 1”的完整拆解整理出来包括暴力的坑、哈希表为什么是正解、一遍和两遍哈希表的区别以及面试官最爱的那些延伸追问。当然后面还会聊到我刷题这几年从它身上延伸出来的几条路线希望能帮你把这道题的剩余价值榨干。1. 看完题目别急着写代码先明白它在考什么1.1 从题目描述里抽丝剥茧原题的文字很短我经常让候选人先念一遍题再说说有几个隐藏条件需要注意。题目大概是这样的给定一个整数数组nums和一个整数目标值target请你在该数组中找出和为目标值target的那两个整数并返回它们的数组下标。你可以假设每种输入只会对应一个答案。但是数组中同一个元素在答案里不能重复出现。你可以按任意顺序返回答案。这几句话里面有三个关键信息很多人一眼扫过去就丢了。第一是“返回它们的数组下标”。这句话决定了我们不能先排序再原样输出因为排序会打乱下标位置。如果你动手先给数组排了个序后面还得想办法记录原始下标绕了一大圈不如直接用哈希表。第二是“每种输入只会对应一个答案”。这是一个很强的条件意味着不用考虑多个答案的情况也不需要去重。很多变形题里这个条件会被去掉那时难度会瞬间上升我们在第 4 节会专门聊。第三是“同一个元素在答案里不能重复出现”。这句话是在堵一个漏洞如果你用元素本身去重而不是用下标去重在nums [3, 3]target 6这种输入下就会出问题。两个 3 虽然是重复值但是它们分属两个不同的下标完全合法。做算法题最忌讳的就是上来就写代码。先把这三个条件在脑子里过一遍你就知道这道题大概需要什么数据结构了要快速查找显然哈希表是首选。1.2 暴力解法为什么只能算“保底”所有的算法爱好者都经历过从暴力到优化的过程两数之和恰好是这个过程最好的样板。先说说暴力解。暴力解的思路非常朴素枚举数组中的每一个数nums[i]再看它后面的每一个数nums[j]如果nums[i] nums[j] target直接返回[i, j]。这是 O(n²) 的时间复杂度代码也很短def two_sum_brutal(nums, target): n len(nums) for i in range(n): for j in range(i 1, n): if nums[i] nums[j] target: return [i, j] return []问题在于这个方案在数据量稍微大一点的时候就顶不住了。假设n 10000最坏情况下内层循环要跑大约 5000 万次。虽然现代 CPU 处理起来可能也就几百毫秒但一旦n来到几十万甚至上百万O(n²) 就变成灾难了。我在记忆里很清楚地记得第一次跑 LeetCode 的极端用例时的感受暴力解在大数组上直接超时那一刻才真正切身体会到“复杂度”不是纸面上画画曲线就完事的。所以暴力解法在面试里可以作为思考起点但不能作为最终方案。面试官一般会点头示意你继续说然后追问一句“能不能优化一下”。这就是哈希表登场的时候了。2. 哈希表解法两数之和真正的考点2.1 哈希表到底是什么一个储物柜的故事哈希表这个名字听起来有点劝退新人但它的核心思想特别简单就是一个带编号的储物柜。你去游泳馆前台给你一个手环手环上印着柜子编号。你想存东西的时候不用一个柜子一个柜子地打开找空位直接看手环去对应的柜子就行。哈希表就是这样一个结构通过一个键key直接算出它对应的存储位置然后以 O(1) 的时间去访问。在“两数之和”这道题里我们需要的查找是给定一个数x快速判断target - x在不在数组里如果在的话它的下标是多少。这正是哈希表的强项——我用值做 key用下标做 value查找一个值是否存在及其下标平均只需要 O(1)。这背后的思想叫“空间换时间”。我们不满足于暴力解法的 O(1) 空间所以额外开了一张哈希表把遍历过的信息记下来换来了 O(n) 时间。这里有一个非常关键的“逆向思维”不是拿当前的数去找另一个数而是“我每走到一个位置都在问谁和我凑成 target我已经见过它了吗”这种“边遍历边查表”的思维在后面的前缀和、滑动窗口、以及很多更难的题目里会反复出现。2.2 两遍哈希表的实现与复杂度分析我们先写一个最直观的哈希表版本遍历数组两遍。第一遍把所有的值, 下标存入哈希表第二遍遍历数组对每个nums[i]去查target - nums[i]是否在表中如果在且不是同一个下标直接返回。def two_sum_two_pass(nums, target): mapping {} for i, num in enumerate(nums): mapping[num] i for i, num in enumerate(nums): complement target - num if complement in mapping and mapping[complement] ! i: return [i, mapping[complement]] return []这个解法的时间复杂度是 O(n)空间复杂度 O(n)。为什么需要判断mapping[complement] ! i因为题目不允许同一个元素用两次如果nums [1, 3, 3, 5]target 6第二遍循环走到第二个 3 的时候查表能查到第一个 3下标不同合法但如果数组只有一个 3 时查到的下标就是自己必须跳过去。不过两遍哈希表有一个隐患当数组中出现重复元素时第一遍存表会发生后一个下标覆盖前一个下标的情况。比如nums [3, 3]遍历完mapping {3: 1}。好在第二遍按数组索引依次遍历i 0时查到mapping[3] 1返回[0, 1]结果依然正确。这是这道题的条件设计得巧妙重复值不会导致错误答案但如果你在别的题里也这样写可能就被覆盖坑了。所以更加推荐的是下面这种“一遍哈希表”的写法。3. 一遍哈希表从“查”到“边走边记”3.1 先查表再存表顺序不能反一遍哈希表的核心思路是只遍历一次数组每处理一个元素先查表找答案如果找到了直接返回找不到就把当前这个元素存进表里。因为当前元素还没存入表所以在查表时永远不会查到自己。这天然就满足了“同一个元素不能重复使用”的条件也避免了两遍哈希表的下标覆盖问题。def two_sum(nums, target): mapping {} for i, num in enumerate(nums): complement target - num if complement in mapping: return [mapping[complement], i] mapping[num] i return []很多初学者最容易犯的一个错误就是“先存后查”。如果把mapping[num] i写在if complement in mapping之前在nums [3, 3]、target 6的时候i 0存进去i 1时查到complement 3查到的下标是0结果还是对的。但换个输入就不同了nums [2, 0, 2]target 4i 0先存了 2i 2时查 complement 2查到下标 0返回[0, 2]依然正确。那错误到底错在哪问题出在另一种场景nums [6]target 12这种单元素无解的情况。如果先存后查程序会查到自己在表里的记录返回[0, 0]而正确答案应该是无解。虽然 LeetCode 原题保证了有解但写工程代码时你不可能依赖这个保证。所以记住这个顺序先查表再存表这是这道题唯一的规范动作。拿一个具体例子走一遍完整流程。假设nums [2, 7, 11, 15],target 9i 0num 2complement 7表里没有 7把 2 存进去mapping {2: 0}i 1num 7complement 2表里有 2且下标是 0返回[0, 1]看到了吗一次循环就解决。后面的元素根本不需要遍历。3.2 越界、重复值与语言差异实现时的坑虽然一遍哈希表代码看起来只有几行但落到不同语言里还是有几个细节值得注意。第一是整型溢出的问题。在 C 和 Java 里target - num可能超出 int 范围如果题目数据范围很大建议用long类型去接收。Python 因为支持大整数完全不需要担心。LeetCode 原题给出的数据范围一般不会真的溢出但这是一个很好的加分项面试时主动提一句面试官会认为你有工程意识。第二是哈希表的 API 选择。Java 里HashMapInteger, Integer的get方法返回Integer如果 key 不存在会得到null所以一般这样写class Solution { public int[] twoSum(int[] nums, int target) { MapInteger, Integer map new HashMap(); for (int i 0; i nums.length; i) { int complement target - nums[i]; if (map.containsKey(complement)) { return new int[] { map.get(complement), i }; } map.put(nums[i], i); } return new int[0]; } }而 Python 里dict用in判断就非常顺手。Go 的map[int]int有一个“取不到值返回零值”的问题所以需要借助ok模式来判断 key 是否存在否则遇到 value 为 0 的情况会出错。下面是 Go 的写法感受一下差别func twoSum(nums []int, target int) []int { m : make(map[int]int) for i, num : range nums { if j, ok : m[target-num]; ok { return []int{j, i} } m[num] i } return nil }第三是哈希表初始容量的优化。如果你知道数组规模大概是多少可以在创建 map 的时候指定初始容量减少扩容带来的开销。Java 的HashMap可以给初始容量Go 的make(map[int]int, len(nums))也是常规操作。这种优化对这道题可能看不出来但对于追求极致性能的人来说是一个习惯。我自己的经验是这道题用一遍哈希表已经是最优解了时间 O(n)空间 O(n)。数组在极端情况下都是不排序的所以不存在 O(n log n) 排序加双指针的最优说法后面会讲为什么双指针不是第一选择。4. 面试官真正想问的东西从这道题延伸出去的追问4.1 有序数组怎么办双指针对撞两数之和如果是基于有序数组LeetCode 专门开了一道题叫“两数之和 II - 输入有序数组”题号 167。这种场景下哈希表已经不算是最优解了因为数组有序之后我们有更聪明的办法双指针。双指针的思路是左指针指向数组开头右指针指向数组结尾计算当前两个指针位置的和。如果和比 target 大说明右边太大了右指针左移如果和比 target 小说明左边太小了左指针右移如果相等直接返回。循环条件是left right。def two_sum_sorted(numbers, target): left, right 0, len(numbers) - 1 while left right: current_sum numbers[left] numbers[right] if current_sum target: return [left 1, right 1] elif current_sum target: left 1 else: right - 1 return []注意这个写法里返回下标时加了 1因为这道题的下标是从 1 开始的而 Python 数组是 0 开头的。很多人在这一点上栽过跟头我也曾经因为惯性直接返回 0 开头的下标提交后报错才反应过来。双指针的时间复杂度是 O(n)空间复杂度是 O(1)比哈希表省了额外空间。这就是算法题有趣的地方同一种问题约束条件一变化最优方案就变了。所以当面试官追问“如果数组有序你还能优化吗”你要能立刻切换到双指针模式。这不仅仅是炫技而是体现了你“根据场景选算法”的能力。4.2 多个答案与重复元素两道高频变形题原题说“每种输入只会对应一个答案”所以不需要去重。但如果把条件改一改变成“找出所有不重复的组合”题目难度就上来了。这里我建议你把三数之和LeetCode 15和四数之和LeetCode 18一起刷了因为它们本质上是两数之和的升级版核心套路都是排序 双指针 去重。去重是有讲究的。暴力做法是找到所有组合后用 Set 去重但这样效率太低大概率超时。正确做法是在移动指针的时候跳过重复值比如固定第一个数nums[i]之后双指针在右边区间找target - nums[i]的两个数时如果left移动后和移动前值相同就继续移动直到值变化为止。我在刚开始刷三数之和的时候因为去重逻辑写得不对一直收获 Wrong Answer。那个卡了我一晚上的 bug 就是不去重会漏解去重太狠会把合法解也去掉。具体来说去重必须发生在找到一个合法组合之后而不是在找组合之前否则像[-2, 0, 0, 2, 2]这种用例就会丢解。还有一道延伸题叫“和为 K 的子数组”题号 560。它用到了前缀和加哈希表本质思想也是两数之和的变体维护一个前缀和变量prefix_sum每到一个位置都查一下prefix_sum - k在不在哈希表里如果在就说明存在一条从之前某个位置到当前位置的子数组和为 K。这个思路当年我想了很久才转过弯其实就是“两数之和”换了一层皮但一旦想通很多子数组问题都能用类似方法解。如果你是一个正在准备面试的人我强烈建议把“两数之和 - 两数之和 II - 三数之和 - 四数之和 - 和为 K 的子数组”这条链子串起来刷每一道都只比前一道多一点变化。这样刷完你对这类题的体系化理解会超过大多数人。5. 刷题路线与个人心得这道题教会我的那些事5.1 在 LeetCode 体系里的位置为什么它总是第一题做过 LeetCode 热门 100 题的人应该都有印象“两数之和”几乎出现在每一个热门题单的榜首。剑指 Offer 里有它的变体LCR 系列里也有它的影子周赛里偶尔也会出现像是“两数之和”套壳的 T1 签到题。为什么偏偏是它我的理解是这道题把“暴力求解”和“哈希表优化”的对比展现得淋漓尽致。你不需要懂复杂的数学知识不需要掌握二叉树、图论这些高级内容只需要明白“空间换时间”这一个点就能写出最优解。它是一个完美的“数据结构入门课”从思考路径上它逼着你从双层循环的惯性里跳出来从数据结构上它展示了哈希表最典型的使用场景从面试角度它考察了边界条件的敏感程度从工程角度它本身就是“查找表 互补关系”这个通用思路的源头。这也是为什么我在带新人刷题时总要求他们把这道题背得滚瓜烂熟闭着眼睛能把讲解写出来。因为从它身上延伸出来的思维能力比记十道题都有用。5.2 踩过的坑与刷题节奏建议讲几个我在这道题上真实踩过的坑以及新手常见的误操作希望能帮你节省试错时间。第一是变量名和下标别搞混。返回的是[mapping[complement], i]不是[i, mapping[complement]]。虽然顺序无所谓但有些读者会根据顺序来理解代码写反了容易在复盘时把自己绕晕。我建议统一按“先小下标、后大下标”的顺序写看着更舒服也方便和别人讨论。第二是数组可能为空或只有一个元素。LeetCode 原题保证有解但很多人在写本地测试用例时会不小心传入空数组导致 TypeError。防御性思维很重要哪怕是刷题也尽量处理一下len(nums) 2的情况。第三是极端用例nums [0, 4, 3, 0]target 0。这时i 0时 complement 0表里没有 0存入i 3时 complement 0能查到下标 0返回[0, 3]。看似没问题但如果你把“存表”操作放在循环体最后而“查表”放在循环体开头顺序上的逻辑要非常清晰。必要时可以把每一步打印出来一目了然。刷题节奏上我的个人建议是一道题至少尝试两种解法暴力 优化并且亲手画一画例子而不是直接看题解。因为看题解就像看菜谱看懂容易动手做又是另一回事。“两数之和”太经典网上的题解一抓一大把但它作为你第一道独立完成优化的题目值得你多花一个小时去反复推导。最后再分享一个实操小技巧在本地用多种语言各写一遍这道题。我在 Python、Java、Go、C 四种语言里都实现过两数之和做完之后你会对每种语言的哈希表 API 差异、下标系统、类型转换习惯都有非常直观的体会。这也是从“会刷题”走向“会写工程代码”的第一步。LeetCode 的第一题没有理由不好好吃透。它就像算法世界里的“Hello World”写一遍太浅写十遍太腻但真正把它背后的思路消化掉你会在后面遇到成百上千道题时感受到这份基础带来的红利。
返回列表