给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 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 <= 104-109 <= nums[i] <= 109-109 <= target <= 109- 只会存在一个有效答案
进阶: 你可以想出一个时间复杂度小于 O(n2) 的算法吗?
从零开始详解哈希表(Hash Table)
哈希表(也叫散列表 )是编程中最常用的高效数据结构之一,核心作用是通过「键(Key)」直接快速访问「值(Value)」 ,能把查找、插入、删除的平均时间复杂度做到O(1)(常数时间)。我们从零开始,用最通俗的例子和步骤拆解哈希表的所有核心知识点。
一、先思考:为什么需要哈希表?
在哈希表出现之前,我们常用数组 和链表存储数据,但它们的查找效率有明显短板:
- 数组:如果知道下标,访问是 O (1);但如果要找「某个值对应的下标」(比如找数组中值为 90 的元素位置),需要遍历数组,时间复杂度 O (n)。而且数组的下标是连续整数,若要存储「学号 1001、1005、1010 对应的成绩」,直接用学号当下标会浪费大量空间(下标 0~1000 大部分空着)。
- 链表:查找任意元素需要从头遍历,时间复杂度 O (n),效率更低。
我们需要一种数据结构:既能像数组一样快速访问,又能灵活映射非连续的键(比如学号、字符串)到存储位置------ 哈希表就是为解决这个问题而生的。
二、哈希表的核心思想:「键→哈希值→存储位置」的映射
哈希表的本质是 **「数组 + 映射规则」,核心是把 任意类型的键(Key)通过一个函数转换成数组的索引(下标)**,然后把「键值对」存在这个索引位置。
举个生活中的例子:
- 假设你有 10 个抽屉(对应数组,长度为 10),要存放同学们的成绩单(键 = 学号,值 = 成绩)。
- 你定了一个规则:学号的最后一位数字就是抽屉编号(这就是「映射规则」,即哈希函数)。
- 比如学号
1001→最后一位 1→放在抽屉 1;学号1005→最后一位 5→放在抽屉 5;学号1010→最后一位 0→放在抽屉 0。 - 当你想查
1001的成绩时,直接看抽屉 1 就行,不用翻所有抽屉 ------ 这就是哈希表的高效性。
三、哈希表的核心组件 1:哈希函数(Hash Function)
上面的「取学号最后一位」就是哈希函数,它是哈希表的灵魂。
1. 哈希函数的定义
哈希函数是一个数学函数,接收任意类型的键(Key) ,输出一个固定范围的整数(哈希值 / 散列值),这个整数会作为数组的索引。
2. 哈希函数的三大要求(设计准则)
一个好的哈希函数必须满足以下条件,否则哈希表的效率会大打折扣:
| 要求 | 解释 |
|---|---|
| 确定性 | 同一个键,每次通过哈希函数计算的哈希值必须相同(比如学号 1001 永远对应 1)。 |
| 均匀性 | 哈希值要均匀分布在数组的索引范围内,避免集中在某个区域(减少冲突)。 |
| 高效性 | 计算哈希值的过程要快,时间复杂度 O (1)(不能是复杂的循环 / 递归)。 |
3. 常见的哈希函数(入门级)
实际开发中哈希函数的实现很复杂,但入门只需了解最常用的几种:
- 取余法 (最常用):
h(key) = key % 数组长度(比如 key 是学号 1001,数组长度 10,h (key)=1)。适用于键是整数的场景。 - 直接定址法 :
h(key) = a * key + b(线性映射)。适用于键的范围比较连续的场景(比如键是 1~100 的整数)。 - 字符串哈希 :把字符串转换成整数再取余(比如
"abc"→a的ASCII码*26² + b的ASCII码*26 + c的ASCII码,再取余)。
四、哈希表的核心问题:哈希冲突(Collision)
1. 什么是哈希冲突?
当两个不同的键 通过哈希函数计算出相同的哈希值(即对应数组的同一个索引),就发生了哈希冲突。
比如:学号1001和1011,用「取最后一位」的哈希函数,都得到 1→两个学号要放在同一个抽屉里,这就是冲突。
注意 :哈希冲突是无法避免的 (比如数组长度是 10,却有 11 个不同的学号,必然有冲突),我们能做的是解决冲突。
2. 哈希冲突的两大解决方法(主流)
方法 1:拉链法(链地址法)------Python 字典 / Java HashMap 的核心实现
这是最常用的冲突解决方法,原理是:数组的每个索引位置,不是存储单个键值对,而是存储一个链表(或红黑树);当冲突发生时,把新的键值对添加到链表中。
还是用抽屉的例子:
- 抽屉 1 原本放了学号 1001 的成绩单,现在来了 1011 的成绩单,就在抽屉 1 里挂一个 "小袋子"(链表),把两个成绩单都放进袋子里。
- 当查找 1011 的成绩时,先通过哈希函数找到抽屉 1,再遍历抽屉 1 里的链表,找到 1011 对应的成绩。
拉链法的优化 :当链表的长度超过一定阈值(比如 8),会把链表转换成红黑树(一种平衡二叉树),把查找时间从 O (k)(k 是链表长度)降到 O (logk),进一步提升效率。
方法 2:开放寻址法 ------Redis 的哈希表用到的方法
原理是:当某个索引位置被占用时,按照某种规则「探测」下一个空的索引位置,把键值对存进去。常见的探测规则有:
- 线性探测:冲突时,依次查看下一个索引(index+1→index+2→...),直到找到空位置。
- 二次探测:冲突时,查看 index+1²、index-1²、index+2²、index-2²... 的位置(避免线性探测的 "扎堆" 问题)。
开放寻址法的缺点是:数组满了之后需要扩容,而且删除元素时要标记为 "已删除"(不能直接清空,否则会破坏探测路径);优点是不需要额外的链表空间,内存利用率更高。
五、哈希表的完整存储流程(以拉链法为例)
我们用「存储学号 - 成绩」的例子,梳理哈希表的存储步骤:
- 初始化:创建一个长度为 10 的数组(哈希表的底层数组,也叫「桶(Bucket)」),每个位置初始化为空链表。
- 存储键值对(1001,90) :
- 计算哈希值:
h(1001) = 1001 % 10 = 1。 - 数组索引 1 的位置是空链表,直接把(1001,90)加入链表。
- 计算哈希值:
- 存储键值对(1011,85) :
- 计算哈希值:
h(1011) = 1011 % 10 = 1(冲突)。 - 把(1011,85)添加到数组索引 1 的链表末尾。
- 计算哈希值:
- 存储键值对(1005,95) :
- 计算哈希值:
h(1005) = 1005 % 10 = 5。 - 数组索引 5 的位置是空链表,直接加入。
- 计算哈希值:
六、哈希表的时间复杂度
哈希表的效率取决于哈希函数的均匀性 和冲突的多少:
- 平均情况 :查找、插入、删除的时间复杂度都是O(1)(直接通过哈希值找到索引,链表长度很短甚至只有一个元素)。
- 最坏情况 :如果所有键都哈希到同一个索引(比如哈希函数设计得很差,所有 key%10 都等于 1),链表会变成一个长链,此时时间复杂度退化为O(n)(需要遍历链表)。
如何避免最坏情况?
- 设计均匀的哈希函数。
- 当哈希表的负载因子 超过阈值时,进行扩容。
python
from typing import List
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
num_map = {} # 定义哈希表(字典),存储{数字: 索引}
for index, num in enumerate(nums): # 遍历数组,O(n)
complement = target - num # 计算补数
if complement in num_map: # 查找补数:O(1)(平均)
return [num_map[complement], index] # 返回结果
num_map[num] = index # 插入当前数字和索引:O(1)(平均)
return []