【数据结构】【学习笔记】哈希表
哈希表
说到哈希表,笔者就会想到字典,一直以为这两个是同一个东西,但发现其实不是。
- 字典(Dictionary)
- 是一个抽象的接口(概念),只关心要做什么;
- 核心功能:按 Key 存取 Value 的功能,Key 不能重复。
- 哈希表(Hash Table)
- 是实现这个概念的具体技术内容,关心的是怎么做;
- 利用哈希函数计算下标,实现 \(O(1)\) 的存取速度。
⚙️定义
哈希表(Hash Table,又称散列表)是一种通过哈希函数(Hash Function)将数据的 Key(键) 直接映射为数组下标(Index),从而实现极速存取的数据结构。
哈希函数如下:
\(Key\):数据的唯一标识(如字符串、数值、对象指针等)。
哈希函数(Hash Function):把任意类型的 Key 转换为固定范围整数的算法。
桶数组(Buckets):真正存储数据(Key-Value 对)的连续内存空间,长度为 \(M\)。
🔎特性
- 搜索的平均时间复杂度 \(O(1)\),但最坏时会退化为 \(O(N)\);
- 空间复杂度 \(O(N)\);
- 无序性 —— 不保证排序;
- 负载因子(Load Factor) —— \(\alpha = \frac{N}{M}\),\(N\) 为元素数,\(M\) 为桶数。
负载因子就是哈希表的“拥挤度”。
通常达到 \(0.75\) 时会自动触发翻倍扩容,即过挤。
🤓实现
实现哈希表时,最关键的技术挑战是处理 哈希冲突(Hash Collision) —— 即不同的 Key 计算出了相同的数组下标。
- 解决冲突的两大方案
- 拉链法(Separate Chaining,最常用)
- 原理:桶数组里存的不是数据本身,而是链表头节点。发生冲突时,将新节点追加到对应链表末尾。
- 优化:Java HashMap 在链表长度大于 8 时会转换为红黑树,防止恶意哈希攻击将查找退化为 \(O(N)\)。
- 应用:C++ (std::unordered_map)、Java、TS/JS (Map) 等各大主流语言默认选择。
- 开放寻址法(Open Addressing)
- 原理:所有元素都紧凑保存在桶数组里。若下标已被占用,则按规则向后寻找下一个空位(如线性探测 \(i+1, i+2\dots\))。
- 优点:内存连续,对 CPU 缓存极为友好(Cache Friendly)。
- 应用:Python 的 dict 内部实现。
- 拉链法(Separate Chaining,最常用)
- 扩容与重哈希(Rehash)当数据量增长导致负载因子过高时,哈希表会自动进行扩容:
- 申请一块容量翻倍的新桶数组(\(M \rightarrow 2M\));
- 遍历旧表中的所有元素,重新计算下标并塞入新表(耗时 \(O(N)\))。
- 💡 工程经验:
如果提前知晓数据量大小 \(N\),初始化时指定预分配空间(如 C++ 的 dict(n) 或 reserve(n)),可以完全避免运行期的 Rehash 开销!
🎨思维导图
❔题目
1. 两数之和
解题思路
一开始思考的方法是:
1. 先使用哈希表,key为数字数值,val为索引数组;
2. 先遍历一遍数组初始化哈希表;
3. 然后再遍历,根据target-cur的值来寻找是否在哈希表可得,并且不是当前索引,返回答案。
但是这个很浪费空间呢。
所以应该使用边查边存的思路:
- 每次查询时检查map中是否有另一半了;
- 假如没有,则将当前数字和索引存入map,继续搜索;
- 可以看出,这样子查,就不用担心数字重复的问题了,因为都会被查到。
实现
function twoSum(nums: number[], target: number): number[] {
const dict = new Map<number, number>();
const n = nums.length;
for(let i = 0; i < n; i++){
let les = target - nums[i];
if(dict.has(les)){
return [dict.get(les)!, i];
}else{
dict.set(nums[i], i);
}
}
return [];
};
public class Solution {
public int[] TwoSum(int[] nums, int target) {
int n = nums.Length;
Dictionary<int, int> dict = new Dictionary<int, int>(n);
for(int i = 0; i < n; i++){
int cur = nums[i];
int les = target - cur;
if(dict.TryGetValue(les, out int j)){
return [j, i];
}else{
dict[cur] = i;
}
}
return [];
}
}
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
int n = nums.size();
std::unordered_map<int, int> dict(n);
for(int i = 0; i < n; i++){
int cur = nums[i];
int les = target - cur;
auto it = dict.find(les);
if(it != dict.end()){
return {it->second, i};
}else{
dict[cur] = i;
}
}
return {};
}
};
复杂度分析
时间复杂度:\(O(N)\) —— 最多遍历一次数组。
空间复杂度:\(O(N)\) —— 哈希表最多存储 \(N\) 个键值对。
2. 随机链表的复制
解题思路
方法一
一开始想着用一次遍历的方法解决,先创建一个新的,并且再做一个哈希表记录每一个节点的random,并且用数组存放所有的节点。
但是发现怎么思考都思考不出结果。
选择使用两次遍历的方法,第一次遍历,一边创建新节点,并将原节点和新节点存放在哈希表中。
结构:{key: 原节点, val: 新节点}
然后二次遍历的时候,将原节点从头往后走,利用哈希表将next和random对应连接上即可。
方法二
原地节点交织(三步法)
- 原地复制,将新节点.next接入旧节点.next
- 这时候,某个节点的random,其next一定就是他的新节点
- 最后把链表拆分
这个方法就不需要额外的空间了。
实现
function copyRandomList(head: _Node | null): _Node | null {
if(head === null) return head;
// 创建一个新的,并且再做一个哈希表将新节点存入
// 哈希表结构[key: 原节点, value: 新节点]
let dict: Map<_Node, _Node> = new Map<_Node, _Node>();
let h = head;
// 先创建完所有的新节点,并且塞入哈希表
while(h != null){
let cur: _Node = new _Node(h.val);
dict.set(h, cur);
h = h.next;
}
// 再次遍历
h = head;
while(h != null){
let cur = dict.get(h)!;
if(h.next){
cur.next = dict.get(h.next) || null;
}
if(h.random){
cur.random = dict.get(h.random) || null;
}
h = h.next;
}
return dict.get(head) || null;
};
public class Solution {
public Node CopyRandomList(Node head) {
var dict = new Dictionary<Node, Node>();
Node h = head;
while(h != null){
dict.Add(h, new Node(h.val));
h = h.next;
}
h = head;
while(h != null){
Node cur = dict[h];
if(h.next != null){
cur.next = dict[h.next];
}
if(h.random != null){
cur.random = dict[h.random];
}
h = h.next;
}
return head == null ? null : dict[head];
}
}
class Solution {
public:
Node* copyRandomList(Node* head) {
std::unordered_map<Node*, Node*> dict;
auto cur = head;
while(cur != nullptr){
dict[cur] = new Node(cur->val);
cur = cur->next;
}
cur = head;
while(cur != nullptr){
auto p = dict[cur];
p->next = cur->next == nullptr ? nullptr : dict[cur->next];
p->random = cur->random == nullptr ? nullptr : dict[cur->random];
cur = cur->next;
}
return head == nullptr ? nullptr : dict[head];
}
};
复杂度分析
- 时间复杂度:\(O(N)\),两次遍历链表,每次遍历 \(N\) 个节点。
- 空间复杂度:
- 方法一(哈希表):\(O(N)\),需要额外的哈希表存储 \(N\) 个原节点到新节点的映射。
- 方法二(原地交织):\(O(1)\),直接在原链表上修改结构,仅需常数级别的指针变量。
3. 缺失的第一个正数
解题思路
看到题目描述我就懵了——空间居然只有常数级别额外空间。那怎么用哈希表呢?
原来还有原地哈希的方法!
因为该问题中,最小正整数,一定不超过数组的长度+1,也就是
然后遍历数组,将数字和索引对应上,超过范围的数字跳过即可,因为索引的范围是 \([0, n-1]\) ,所以放入时索引要对应-1,而查询时,等于的值要对应索引+1。
做好哈希表后,从头检查到尾,假如有一个值与索引无法对应,那么说明答案就是该值,不然就是n+1。
实现
function firstMissingPositive(nums: number[]): number {
const n = nums.length;
// 数字一定在[1, n+1]范围内
// 原地哈希
// 把对应的数字放在对应的idx上
// 由于idx范围在[0, n-1]
// 所以数字要-1
for(let i = 0; i < n; i++){
let cur = nums[i];
// 用while避免交换的被跳过
while(cur > 0 && cur <= n && nums[cur-1] !== cur){
const targetIdx = cur-1;
[nums[i], nums[targetIdx]] = [nums[targetIdx], nums[i]];
cur = nums[i];
}
}
for(let i = 0; i < n; i++){
if(nums[i] !== i+1){
return i+1;
}
}
return n + 1;
};
public class Solution {
public int FirstMissingPositive(int[] nums) {
int n = nums.Length;
for(int i = 0; i < n; i++){
int cur = nums[i];
while(cur > 0 && cur <= n && nums[cur-1] != nums[i]){
int target = cur-1;
(nums[target], nums[i]) = (nums[i], nums[target]);
cur = nums[i];
}
}
for(int i = 0; i < n; i++){
if(nums[i] != i+1){
return i+1;
}
}
return n+1;
}
}
class Solution {
public:
int firstMissingPositive(vector<int>& nums) {
int n = nums.size();
for(int i = 0; i < n; i++){
int cur = nums[i];
while(cur > 0 && cur <= n && nums[cur-1] != nums[i]){
swap(nums[cur-1], nums[i]);
cur = nums[i];
}
}
for(int i = 0; i < n; i++){
if(nums[i] != i+1){
return i+1;
}
}
return n+1;
}
};
复杂度分析
时间复杂度:\(O(N)\) 。
空间复杂度:\(O(1)\) 。
🤯拓展
DRY 原则(Don't Repeat Yourself)
C#在声明变量时,可以使用var时代码简洁,var在C#是静态强类型。
在C++,可以使用auto,
C++的右值
C++ 规定不能对右值取地址。
局部变量是分配在栈(Stack)上的。当函数执行完毕返回时,函数栈帧被销毁,这些局部变量对应的内存全被回收。
外部调用者拿到这个指针去访问时,会直接触发 野指针崩溃 / 内存非法访问(Segmentation Fault)。
在 C++ 中,凡是要在函数结束后继续存在的数据结构(例如新克隆出来的链表节点、树节点),都必须用 new 在堆(Heap)上开辟内存。
注:本文为个人学习与刷题笔记,部分文本结构与排版格式由 AI 辅助整理。

浙公网安备 33010602011771号