从“暴力懵圈”到“优雅秒杀”:两数之和背后的哈希哲学

45 阅读11分钟

每日语录:你敲下的每一行报错代码,都是未来大神成长路上的通关文牒,别怕看不懂,只怕你不动


对应题目:两数之和

题目难度:简单

相关标签:数组 哈希表


原题复现:

给定一个整数数组nums和一个整数目标值targe,请你在该数组中找出和为目标值target的那两个整数,并返回它们的数组下标

你可以假设每种输入只会对应一个答案,并且你不能使用两次相同的元素

你可以按任意顺序返回答案

示例 1:

输入nums = [2,7,11,15], target = 9

输出[0,1]

解释因为 nums[0] + nums[1] == 9 ,返回 [0, 1]

示例 2:

输入nums = [3,2,4], target = 6

输出[1,2]

示例3:

输入nums = [3,3], target = 6

输出[0,1]

提示:

  • 2 <= nums.length <= 10⁴

  • -10⁹ <= nums[i] <= 10⁹

  • -10⁹ <= target <= 10⁹

  • 只会存在一个有效答案

进阶: 你可以想出一个时间复杂度小于 O(n²) 的算法吗


一.审题与界定:别急着写代码,先把题目嚼碎了再说

当你第一次看到这道题时,可能会觉得:“哦,不就是找个两数之和嘛,”但如果你真这么想,很容易踩进一个坑:你以为自己懂了,其实你只懂了表面意思

我们来把题目拆成三个层次:

1.已知条件(我们有什么)

一个整数数组,例如[2, 7, 11, 15]

一个目标值,例如9

2.未知目标(我们要找什么)

找出两个数,它们的和等于目标值9

返回这两个数的下标(注意:不是值本身,是它们在数组里的位置)

3.约束边界(有哪些限制)

每种输入只会有一个答案(这其实是给你的“免死金牌”,意味着你不用担心找到多个结果)

不能使用两次相同的元素(比如[3, 3],你可以用两个3,因为它们是两个不同的元素,虽然值相同,但下标不同)

你可以按任意顺序返回答案([0,1][1,0]都算对)

这一步的作用:就像你拿到一份藏宝图,先不急着跑,先把地图上的标记,边界,危险区域都看清楚,很多新手一上来就写代码,结果越写越乱,就是因为没有先把“什么能做,什么不能做”想清楚


二.问题与建模:把生活场景翻译成计算机语言

现在,我们要把这道题从“人类语言”翻译成“计算机能听懂的语言”

生活化类比

想象你在一个班级点名册里,要找出两个学生,他们的年龄加起来恰好等于某个数,你手里有一张名单(数组),上面写着每个人的年龄(值)和座位号(下标)

你可能会怎么做

方法一:从第一个学生开始,依次问后面每一个同学:“你和我加起来等于目标吗,”这就是暴力法

方法二:你每看到一个学生,就拿出一本小本子记下:“这个年龄的学生坐在几号位”,然后你只需要看看“目标年龄减去当前年龄”这个差值,是否已经在小本子里了,这就是哈希法

数学本质

我们要找的是:

nums[i] + nums[j] = target

这个式子可以变形为:

nums[j] = target - nums[i]

也就是说,“求和问题”被转化为了“差值查找问题”

我们不需要再傻傻地两两相加,而是“遍历到一个数,就去问之前有没有存过它的补数”

这一步的作用:把题目从“生活故事”提炼成“数学模型”,这是从“我大概知道怎么做”到“我能用程序逻辑表达”的关键转折


三.策略与推演:为什么“空间换时间”是神来之笔

第一种思路:暴力法(直觉,但慢)

如果你用两个循环遍历所有组合:

先固定第一个数,然后遍历后面所有数,看有没有相加等于target

时间复杂度是O(n²),当数组长度很大时(比如10⁴个元素),就要计算1亿次组合,电脑会累到“喘气”

第二种思路:哈希法(聪明,又快)

你发现暴力法的本质问题在于:每次查找“补数”都需要重新遍历一遍数组

如果能把之前看过的数存起来,下次直接“查表”,就能瞬间找到

这就是哈希表的威力:

它就像一个“即时贴备忘录”,你每处理一个数,就把它的值和下标记下来

当你看下一个数时,只需要查一下target是否已经在备忘录里,如果在,就直接找到答案了

思考逻辑

为什么可以用哈希表,因为我们需要“快速查找之前是否出现过某个值”

为什么不用数组或集合,因为数组太大浪费内存,集合只存值不存下标,不符合要求

所以,map(键值对)是最佳选择——键存数值,值存下标


四.步骤与流程:用自然语言画出“执行路线图”

我们可以把整个算法分为三个清晰的阶段:

阶段一:准备工具

创建一个空的“备忘录”(哈希表),用来存放“已经看过的数字和它的下标”

阶段二:开始遍历

从数组的第一个元素开始,依次取出每个数nums[i]

计算diff = target - nums[i],即“当前数需要匹配的补数”

阶段三:查表与更新

去备忘录里查一下:diff是否已经存在

如果存在:说明我们已经见过一个数,它和当前数正好能凑成target,直接返回那个数和当前的下标

如果不存在:说明当前数暂时没有匹配,但未来可能有别的数需要它,所以把它记进备忘录里,继续遍历

用流程图表示

开始
  
创建空哈希表
  
遍历数组(i从0到结尾)
  
计算 diff = target - nums[i]
  
diff 在哈希表中吗?
   是  返回 [哈希表[diff], i]
   否  把 nums[i] 和 i 加入哈希表
  
继续下一轮
  
(如果遍历结束还没返回,说明无解,但题目保证有答案)

注意:这里的关键是“先查再记”

如果你先把当前数记进去,再查补数,万一diff和当前数一样(比如target=6, nums[i]=3),你可能会查到“自己”,导致错误地使用同一个元素两次,所以必须先查后记


五.边界与验证:提前排雷,让你的代码更健壮

常见的坑点

1.数组长度正好为2:比如nums = [1,2], target=3,这肯定能找到,但你的代码要能处理这种最小规模

2.负数和零numstarget都可能是负数或零,你的代码应该自然支持,不需要特殊处理(因为只是做加减法)

3.重复元素:比如nums = [3,3], target=6

当你遍历到第一个3时,哈希表还是空的,先记进去

遍历到第二个3时,diff = 3,发现哈希表里有3(下标是0),于是返回[0,1]

这不会出错,因为两个3是不同的元素

4.无解的情况:题目保证有解,所以不必写return nil,但为了代码规范,最好还是有一个默认返回值

测试用例设计

基础用例nums = [2,7,11,15], target=9 ,应返回[0,1]

乱序用例nums = [3,2,4], target=6 ,应返回[1,2]

重复元素用例nums = [3,3], target=6 ,应返回[0,1]

负值用例nums = [-1,-2,-3], target=-4 ,应返回[0,2]

这一步的作用:让你意识到,题目虽然简单,但“简单”不等于“没坑”,提前想好这些情况,写代码时就不会手忙脚乱


六.最终代码:清晰高效,带丰富注释

// twoSum 是解决 LeetCode 第一题(两数之和)的核心函数
// 参数:nums 为整数数组,target 为目标值
// 返回:一个长度为 2 的整数切片,包含两个数的下标
func twoSum(nums []int, target int) []int {
    // 创建一个哈希表(字典),用于存储已遍历过的数值及其对应下标
    // 这是典型的“空间换时间”策略
    memo := make(map[int]int)

    // 遍历数组,i 是当前下标,num 是当前值
    for i, num := range nums {
        // 计算与当前数配对所需的补数: target - num
        diff := target - num

        // 去哈希表中查找补数是否已经存在
        if j, found := memo[diff]; found {
            // 如果存在,说明我们之前已经遇到了一个数与当前数相加等于 target
            // 返回那个数的下标 j 和当前数的下标 i
            return []int{j, i}
        }

        // 如果补数不在哈希表中,就把当前数及其下标存入哈希表
        // 这样后面的数字在查找时就有可能找到它
        memo[num] = i
    }

    // 根据题目约束,必定存在一个答案,这里仅为了编译完整性而返回空切片
    return []int{}
}

七.时间复杂度与空间复杂度分析

时间复杂度:O(n)

理由:我们对数组进行了一次遍历(一个循环),循环次数等于数组长度n,在循环内部,哈希表的查找和插入操作都是O(1)的(平均情况下),因此总的时间复杂度是 O(n)

空间复杂度:O(n)

理由:我们使用了一个哈希表memo,它最多会存储n个键值对(当数组中没有匹配时,所有元素都会被存进去),因此额外的空间开销与数组大小成正比,即O(n)

为什么这是最优的

由于题目要求至少遍历一次数组,所以时间复杂度不可能低于O(n),同时,为了能够在O(1)时间内查找补数,必须使用额外的存储结构(哈希表),因此空间复杂度O(n)是合理的,这已经是在时间与空间之间达到了最佳平衡


八.总结:你迈出的第一步,已经超越了一半人

现在,让我们回过头来看看我们走过的路:

从审题开始,我们没有急着写代码,而是先像侦探一样,把题目里的每一个线索都捋清楚

然后建模,把“找两个数”这个生活场景,翻译成了计算机能理解的“查找补数”问题

接着推演策略,我们对比了“暴力穷举”和“哈希备忘”两种思路,明白了为什么“空间换时间”是一种优雅的取舍

再设计流程,我们画出了清晰的执行步骤,确保每一步都有条不紊

最后验证边界,我们提前发现了那些可能让你掉进去的坑,并一一填平

最终,我们写出了这样一段简洁,高效,带丰富注释的代码,它虽然只有十几行,但背后承载的是“先查后记”,“边遍历边记录”这种在算法世界中反复出现的核心思想

这道题教会我们的三件事

1.不要害怕暴力——暴力是思维的起点,但绝不是终点,有了暴力,你才能找到优化的方向

2.哈希表是“查缺补漏”的神器——当你需要“快速查找之前是否见过某个元素”时,第一时间想到它

3.先思考,再动手——真正的高手,花在思考上的时间比敲代码的时间多得多

你今天的坚持,就是明天所有难题的“补数”

当你遇到更复杂的问题时,不妨问问自己:“我能不能像两数之和那样,用一个备忘录来简化它

现在,你已经不是那个对着第一题发愁的萌新了,你学会了分析问题,选择策略,推导步骤,验证边界,这,就是刷题最宝贵的收获

带着这份收获,勇敢地去挑战下一道题吧


版权声明:本文题解及全部内容(含思路,代码,注释,测试用例)的完整知识产权归作者所有,仅供个人学习参考,未经明确书面授权,严禁任何形式的复制,转载,下载,改编或用于商业用途,文中所含测试用例仅用于辅助理解算法逻辑,请勿直接复制提交至在线评测平台,否则可能因不符合题目隐藏数据或格式要求而导致错误,相关后果由使用者自行承担,尊重原创,从每一次独立思考开始