【数据结构】【学习笔记】哈希表

哈希表

说到哈希表,笔者就会想到字典,一直以为这两个是同一个东西,但发现其实不是。

  • 字典(Dictionary)
    • 是一个抽象的接口(概念),只关心要做什么;
    • 核心功能:按 Key 存取 Value 的功能,Key 不能重复。
  • 哈希表(Hash Table)
    • 是实现这个概念的具体技术内容,关心的是怎么做;
    • 利用哈希函数计算下标,实现 \(O(1)\) 的存取速度。

⚙️定义

哈希表(Hash Table,又称散列表)是一种通过哈希函数(Hash Function)将数据的 Key(键) 直接映射为数组下标(Index),从而实现极速存取的数据结构。

哈希函数如下:

\[\text{Index} = f(\text{Key}) \pmod M \]

\(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 计算出了相同的数组下标。

  1. 解决冲突的两大方案
    • 拉链法(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 内部实现。
  2. 扩容与重哈希(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对应连接上即可。

方法二

原地节点交织(三步法)

  1. 原地复制,将新节点.next接入旧节点.next
  2. 这时候,某个节点的random,其next一定就是他的新节点
  3. 最后把链表拆分

这个方法就不需要额外的空间了。

实现

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,也就是

\[res \in [1, n+1]\tag{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 辅助整理。

posted @ 2026-07-23 23:44  SEHOD  阅读(28)  评论(0)    收藏  举报