哈希表理论基础

文章目录

哈希表理论基础

1. 哈希表的基本概念

**哈希表(Hash Table)**是一种根据键(Key)直接访问内存存储位置的数据结构,通过哈希函数将键映射到数组中的某个位置,从而实现快速查找、插入和删除操作。

1.1 基本术语

  • 键(Key):要存储或查找的数据
  • 值(Value):与键关联的数据
  • 哈希函数(Hash Function):将键映射到数组索引的函数
  • 哈希值(Hash Value):哈希函数计算出的索引值
  • 哈希碰撞(Hash Collision):不同的键映射到同一个索引位置

1.2 哈希表的特点

  • 快速查找:平均时间复杂度O(1)
  • 空间换时间:需要额外的空间存储数据
  • 无序性unordered_mapunordered_set不保证元素顺序

示例

复制代码
键值对:("apple", 5), ("banana", 3), ("cherry", 8)

哈希函数:h(key) = key[0] - 'a'  (简化示例)

映射结果:
"apple"  → h("apple") = 0  → 索引0
"banana" → h("banana") = 1 → 索引1
"cherry" → h("cherry") = 2 → 索引2

2. 哈希函数

2.1 哈希函数的作用

哈希函数将任意大小的键映射到固定大小的数组索引,理想情况下应该:

  • 均匀分布:将键均匀分布到数组中
  • 快速计算:计算哈希值的时间复杂度为O(1)
  • 确定性:相同的键总是产生相同的哈希值

2.2 常见哈希函数

  • 直接定址法h(key) = keyh(key) = a * key + b
  • 除留余数法h(key) = key % m(m为数组大小)
  • 字符哈希h(key) = key[i] - 'a'(适用于小写字母)

示例

cpp 复制代码
// 字符哈希:将小写字母映射到0-25
int hash(char c) {
    return c - 'a';  // 'a'→0, 'b'→1, ..., 'z'→25
}

// 数字哈希:取模法
int hash(int key, int size) {
    return key % size;
}

3. 哈希碰撞

3.1 什么是哈希碰撞

当两个不同的键通过哈希函数映射到同一个索引位置时,就发生了哈希碰撞。

示例

复制代码
哈希函数:h(key) = key % 10

键:15 和 25
h(15) = 15 % 10 = 5
h(25) = 25 % 10 = 5  ← 碰撞!

3.2 解决哈希碰撞的方法

3.2.1 拉链法(Chaining)

将发生碰撞的元素存储在同一个位置的链表中。

示例

复制代码
索引5:15 → 25 → 35

代码实现

cpp 复制代码
// 简化示例
struct Node {
    int key;
    int value;
    Node* next;
};

class HashTable {
private:
    vector<Node*> table;
    int size;
    
public:
    HashTable(int n) : size(n) {
        table.resize(n, nullptr);
    }
    
    void insert(int key, int value) {
        int index = key % size;
        Node* node = new Node{key, value, table[index]};
        table[index] = node;
    }
};
3.2.2 线性探测法(Linear Probing)

当发生碰撞时,顺序查找下一个空闲位置。

示例

复制代码
数组:[_, _, 15, 25, _, _]
插入25时,索引5已被占用,检查索引6,空闲,插入

代码实现

cpp 复制代码
class HashTable {
private:
    vector<pair<int, int>> table;  // (key, value)
    int size;
    
public:
    HashTable(int n) : size(n) {
        table.resize(n, {-1, -1});  // -1表示空位置
    }
    
    void insert(int key, int value) {
        int index = key % size;
        while (table[index].first != -1) {  // 找到空闲位置
            index = (index + 1) % size;
        }
        table[index] = {key, value};
    }
};

4. C++中的三种哈希结构

4.1 数组(Array)

特点

  • 适用场景:键的范围较小且连续(如0-25的字母)
  • 优点:实现简单,访问速度快,空间效率高
  • 缺点:键的范围大时浪费空间

示例

cpp 复制代码
// 统计26个小写字母的出现次数
int hash[26] = {0};
for (char c : s) {
    hash[c - 'a']++;  // 直接使用字符作为索引
}

4.2 Set(集合)

特点

  • 适用场景:只需要判断元素是否存在,不需要存储额外信息
  • 三种类型
    • unordered_set:无序,查询和增删效率最高(O(1))
    • set:有序(红黑树),查询和增删效率O(log n)
    • multiset:有序,允许重复元素

示例

cpp 复制代码
// 判断元素是否存在
unordered_set<int> uset;
uset.insert(1);
if (uset.find(2) != uset.end()) {
    // 元素存在
}

4.3 Map(映射)

特点

  • 适用场景:需要存储键值对,既要判断元素是否存在,又要记录额外信息(如下标、出现次数等)
  • 三种类型
    • unordered_map:无序,查询和增删效率最高(O(1))
    • map:有序(红黑树),查询和增删效率O(log n)
    • multimap:有序,允许重复键

示例

cpp 复制代码
// 存储元素和下标
unordered_map<int, int> umap;
umap[1] = 0;  // 键:元素值,值:下标
if (umap.find(2) != umap.end()) {
    int index = umap[2];  // 获取下标
}

5. 三种哈希结构的选择

5.1 选择原则

比较项 数组 Set Map
适用场景 键范围小且连续 只需判断存在性 需要键值对
时间复杂度 O(1) O(1) O(1)
空间复杂度 O(范围大小) O(n) O(n)
是否有序 是(按索引) 否(unordered) 否(unordered)
典型应用 字母统计 去重、存在性判断 两数之和、计数

5.2 选择建议

  1. 数组作为哈希表

    • 键的范围小(如0-1000)
    • 键连续或接近连续
    • 需要统计出现次数
    • 典型题目:有效的字母异位词、赎金信
  2. Set作为哈希表

    • 只需要判断元素是否存在
    • 键的范围大或分散
    • 需要去重
    • 典型题目:两个数组的交集、快乐数
  3. Map作为哈希表

    • 需要存储键值对
    • 需要记录额外信息(下标、出现次数等)
    • 典型题目:两数之和、四数相加II

5.3 性能对比

数组 vs Set vs Map

  • 数组:空间效率最高,但受限于键的范围
  • Set:比数组占用更多空间,但不受键范围限制
  • Map:功能最强大,但空间开销最大

示例对比

cpp 复制代码
// 场景:统计0-1000范围内数字的出现次数

// 方法1:数组(推荐)
int arr[1001] = {0};
arr[num]++;  // 快速、简单

// 方法2:Map(不推荐,浪费空间)
unordered_map<int, int> umap;
umap[num]++;  // 功能相同,但空间开销更大

// 场景:统计大范围或分散的数字(如0, 5, 1000000)

// 方法1:数组(不推荐,浪费空间)
int arr[1000001] = {0};  // 大部分空间浪费

// 方法2:Map(推荐)
unordered_map<int, int> umap;
umap[num]++;  // 只存储实际出现的数字

6. 哈希表的时间复杂度

操作 平均时间复杂度 最坏时间复杂度 说明
查找 O(1) O(n) 最坏情况:所有元素碰撞
插入 O(1) O(n) 最坏情况:所有元素碰撞
删除 O(1) O(n) 最坏情况:所有元素碰撞
遍历 O(n) O(n) 需要访问所有元素

注意

  • 平均情况下,哈希表的操作都是O(1)
  • 最坏情况下(所有元素碰撞),退化为O(n)
  • 实际应用中,哈希函数设计良好时,很少出现最坏情况

7. 何时使用哈希表

7.1 使用场景

  1. 快速判断元素是否出现

    • 查找某个元素是否在集合中
    • 判断重复元素
  2. 统计元素出现次数

    • 统计字符、数字的出现频率
    • 计数问题
  3. 存储键值对关系

    • 需要同时存储键和值
    • 需要根据键快速查找值
  4. 去重操作

    • 去除重复元素
    • 保留唯一元素

7.2 判断标准

当遇到以下情况时,考虑使用哈希表

  • 需要快速查找元素
  • 需要判断元素是否存在
  • 需要统计元素出现次数
  • 需要存储键值对关系
  • 需要去重操作

示例

cpp 复制代码
// 问题:判断数组中是否存在两个数之和等于target

// 思路:使用哈希表存储已访问的元素
unordered_map<int, int> umap;  // 键:元素值,值:下标
for (int i = 0; i < nums.size(); i++) {
    int complement = target - nums[i];
    if (umap.find(complement) != umap.end()) {
        return {umap[complement], i};  // 找到答案
    }
    umap[nums[i]] = i;  // 存储当前元素
}

8. 哈希表的优缺点

8.1 优点

  • 查找速度快:平均O(1)时间复杂度
  • 插入删除快:平均O(1)时间复杂度
  • 灵活性高:可以存储任意类型的键值对

8.2 缺点

  • 空间开销:需要额外的空间存储哈希表
  • 无序性unordered_mapunordered_set不保证顺序
  • 哈希碰撞:可能影响性能(最坏情况O(n))
  • 键的限制:某些类型需要自定义哈希函数

9. 常见题型模板

9.1 数组作为哈希表模板

模板1:统计字符出现次数(字母异位词)

适用场景:键的范围小且连续(如26个小写字母)

核心思路

  • 使用数组索引直接映射字符
  • 先统计一个字符串,再减去另一个字符串
  • 最后检查数组是否全为0

模板代码

cpp 复制代码
// LeetCode 242. 有效的字母异位词
class Solution {
public:
    bool isAnagram(string s, string t) {
        int hash[26] = {};  // 初始化为0
        
        // 统计s中字符出现次数
        for(int i = 0; i < s.size(); i++) {
            hash[s[i] - 'a']++;
        }
        
        // 减去t中字符出现次数
        for(int j = 0; j < t.size(); j++) {
            hash[t[j] - 'a']--;
        }
        
        // 检查是否全为0
        for(int k = 0; k < 26; k++) {
            if(hash[k] != 0) {
                return false;
            }
        }
        return true;
    }
};

关键点

  • 字符映射:c - 'a' 将小写字母映射到0-25
  • 数组大小:26(小写字母数量)
  • 时间复杂度:O(n),空间复杂度:O(1)
模板2:判断字符是否足够(赎金信)

适用场景:判断一个字符串的字符是否足够组成另一个字符串

核心思路

  • 先统计可用字符(magazine)
  • 再减去需要的字符(ransomNote)
  • 检查是否有负数(表示不够)

模板代码

cpp 复制代码
// LeetCode 383. 赎金信
class Solution {
public:
    bool canConstruct(string ransomNote, string magazine) {
        int hash[26] = {0};
        
        // 统计magazine中字符出现次数
        for(int i = 0; i < magazine.length(); i++) {
            hash[magazine[i] - 'a']++;
        }
        
        // 减去ransomNote中需要的字符
        for(int j = 0; j < ransomNote.length(); j++) {
            hash[ransomNote[j] - 'a']--;
        }
        
        // 检查是否有负数(表示不够)
        for(int k = 0; k < 26; k++) {
            if(hash[k] < 0) {
                return false;
            }
        }
        return true;
    }
};

关键点

  • 与字母异位词的区别:这里允许magazine有更多字符
  • 判断条件:检查是否有负数,而不是是否全为0
模板3:统计数字出现次数(两个数组的交集II)

适用场景:数字范围较小且连续(如0-1000)

模板代码

cpp 复制代码
// LeetCode 350. 两个数组的交集II
class Solution {
public:
    vector<int> intersect(vector<int>& nums1, vector<int>& nums2) {
        vector<int> result;
        int arr1[1001] = {0};  // 记录nums1中元素的出现次数
        int arr2[1001] = {0};  // 记录nums2中元素的出现次数
        
        // 统计nums1中每个元素出现的次数
        for(int i = 0; i < nums1.size(); i++) {
            arr1[nums1[i]]++;
        }
        
        // 统计nums2中每个元素出现的次数
        for(int j = 0; j < nums2.size(); j++) {
            arr2[nums2[j]]++;
        }
        
        // 找到交集元素,并取较小的出现次数
        for(int k = 0; k <= 1000; k++) {
            if(arr1[k] > 0 && arr2[k] > 0) {
                int minCount = min(arr1[k], arr2[k]);
                for(int l = 0; l < minCount; l++) {
                    result.push_back(k);
                }
            }
        }
        
        return result;
    }
};

9.2 Set作为哈希表模板

模板1:判断元素是否存在(两个数组的交集)

适用场景:只需要判断元素是否存在,不需要存储额外信息

核心思路

  • 将第一个数组存入set
  • 遍历第二个数组,查找是否在set中
  • 使用unordered_set自动去重

模板代码

cpp 复制代码
// LeetCode 349. 两个数组的交集
class Solution {
public:
    vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
        unordered_set<int> result_set;
        unordered_set<int> nums_set(nums1.begin(), nums1.end());
        
        for(int num : nums2) {
            // 如果nums2中的元素在nums_set中出现过
            if(nums_set.find(num) != nums_set.end()) {
                result_set.insert(num);
            }
        }
        
        return vector<int>(result_set.begin(), result_set.end());
    }
};

关键点

  • find() != end():判断元素是否存在
  • unordered_set:自动去重,效率最高
  • 数组方法 vs Set方法:
    • 数组:键范围小且连续时更高效
    • Set:键范围大或分散时更节省空间
模板2:判断循环(快乐数)

适用场景:需要判断是否出现循环,记录已出现的值

核心思路

  • 使用set记录已出现的sum值
  • 如果sum重复出现,说明进入循环,返回false
  • 如果sum等于1,返回true

模板代码

cpp 复制代码
// LeetCode 202. 快乐数
class Solution {
public:
    // 计算各位数字的平方和
    int getSum(int n) {
        int sum = 0;
        while (n) {
            sum += (n % 10) * (n % 10);
            n = n / 10;
        }
        return sum;
    }
    
    bool isHappy(int n) {
        unordered_set<int> result;
        int sum = 0;
        
        while (1) {
            sum = getSum(n);
            if (sum == 1) {
                return true;
            }
            
            // 如果sum重复出现,说明进入循环
            if (result.find(sum) != result.end()) {
                return false;
            } else {
                result.insert(sum);
            }
            n = sum;
        }
    }
};

关键点

  • 循环判断:使用set记录已出现的值
  • 终止条件:sum == 1 或 sum重复出现

9.3 Map作为哈希表模板

模板1:两数之和(需要记录下标)

适用场景:需要存储键值对,既要判断元素是否存在,又要记录额外信息(如下标)

核心思路

  • 遍历数组,对于每个元素,查找target - nums[i]是否在map中
  • 如果找到,返回两个下标
  • 如果没找到,将当前元素和下标存入map

模板代码

cpp 复制代码
// LeetCode 1. 两数之和
class Solution {
public:
    vector<int> twoSum(vector<int>& nums, int target) {
        unordered_map<int, int> umap;  // 键:元素值,值:下标
        
        for(int i = 0; i < nums.size(); i++) {
            // 查找target - nums[i]是否在map中
            auto ptr = umap.find(target - nums[i]);
            if(ptr != umap.end()) {
                return {ptr->second, i};  // 返回两个下标
            }
            
            // 将当前元素和下标存入map
            umap.insert(pair<int, int>(nums[i], i));
            // 或者:umap[nums[i]] = i;
        }
        
        return {};
    }
};

关键点

  • 为什么用Map不用Set:需要记录下标
  • 为什么用Map不用数组:元素值可能很大,数组浪费空间
  • 遍历顺序:边遍历边查找,只需一次遍历
模板2:四数相加II(需要记录出现次数)

适用场景:需要统计某个值出现的次数

核心思路

  • 先遍历前两个数组,统计所有可能的和及其出现次数
  • 再遍历后两个数组,查找相反数是否在map中
  • 累加出现次数

模板代码

cpp 复制代码
// LeetCode 454. 四数相加II
class Solution {
public:
    int fourSumCount(vector<int>& nums1, vector<int>& nums2, 
                     vector<int>& nums3, vector<int>& nums4) {
        unordered_map<int, int> umap;  // 键:a+b的和,值:出现次数
        
        // 统计nums1和nums2中所有可能的和及其出现次数
        for(int a : nums1) {
            for(int b : nums2) {
                umap[a + b]++;  // 自动处理重复键,值累加
            }
        }
        
        int count = 0;
        // 遍历nums3和nums4,查找相反数
        for(int c : nums3) {
            for(int d : nums4) {
                if(umap.find(-(c + d)) != umap.end()) {
                    count += umap[-(c + d)];  // 累加出现次数
                }
            }
        }
        
        return count;
    }
};

关键点

  • umap[key]++:自动处理重复键,值累加
  • 分组思想:将4个数组分成两组,每组两个数组
  • 时间复杂度:O(n²),空间复杂度:O(n²)
模板3:字母异位词分组(需要将数组/字符串作为key)

适用场景:需要将复杂结构(如数组、字符串)作为key进行分组

核心思路

  • 将每个字符串转换为统一的key(如字符频次数组转字符串)
  • 使用map将相同key的字符串分组

模板代码

cpp 复制代码
// LeetCode 49. 字母异位词分组
class Solution {
public:
    vector<vector<string>> groupAnagrams(vector<string>& strs) {
        unordered_map<string, vector<string>> mp;
        
        for (auto& s : strs) {
            // 统计每个字符的出现次数
            int freq[26] = {0};
            for (char c : s) {
                freq[c - 'a']++;
            }
            
            // 把频次数组转成字符串作为key,例如:"2#1#0#0#..."
            string key;
            for (int i = 0; i < 26; i++) {
                key += to_string(freq[i]);
                key.push_back('#');  // 避免歧义,如"12"和"1#2"
            }
            
            mp[key].push_back(s);
        }
        
        // 将map的值转换为vector
        vector<vector<string>> result;
        for (auto& p : mp) {
            result.push_back(p.second);
        }
        
        return result;
    }
};

关键点

  • Key的设计:将字符频次数组转换为字符串
  • 分隔符:使用'#'避免歧义(如"12"和"1#2")
  • Map的值类型:vector,用于存储同一组的字符串

9.4 双指针+去重模板(三数之和、四数之和)

模板1:三数之和

适用场景:需要去重的多数和问题,不能使用哈希表去重(因为要去除重复的三元组)

核心思路

  • 先排序
  • 固定第一个数,使用双指针找后两个数
  • 关键:去重逻辑要正确

模板代码

cpp 复制代码
// LeetCode 15. 三数之和
class Solution {
public:
    vector<vector<int>> threeSum(vector<int>& nums) {
        vector<vector<int>> result;
        sort(nums.begin(), nums.end());
        
        for(int i = 0; i < nums.size(); i++) {
            // 剪枝:如果第一个数大于0,后面不可能和为0
            if (nums[i] > 0) {
                return result;
            }
            
            // 去重a:正确方法,与前面的数比较
            if (i > 0 && nums[i] == nums[i - 1]) {
                continue;
            }
            
            int left = i + 1;
            int right = nums.size() - 1;
            
            while (right > left) {
                if (nums[i] + nums[left] + nums[right] > 0) {
                    right--;
                } else if (nums[i] + nums[left] + nums[right] < 0) {
                    left++;
                } else {
                    result.push_back(vector<int>{nums[i], nums[left], nums[right]});
                    
                    // 去重b和c:找到答案后再去重
                    while (right > left && nums[right] == nums[right - 1]) right--;
                    while (right > left && nums[left] == nums[left + 1]) left++;
                    
                    // 找到答案时,双指针同时收缩
                    right--;
                    left++;
                }
            }
        }
        return result;
    }
};

关键点

  • 去重a:i > 0 && nums[i] == nums[i - 1](与前面的数比较)
  • 去重b和c:找到答案后再去重,避免漏掉如[0,0,0]的情况
  • 剪枝:如果第一个数大于0,直接返回
模板2:四数之和

适用场景:四数之和,需要两层循环+双指针

模板代码

cpp 复制代码
// LeetCode 18. 四数之和
class Solution {
public:
    vector<vector<int>> fourSum(vector<int>& nums, int target) {
        vector<vector<int>> result;
        sort(nums.begin(), nums.end());
        
        for(int k = 0; k < nums.size(); k++) {
            // 一级剪枝
            if (nums[k] > target && nums[k] >= 0) {
                break;
            }
            
            // 去重nums[k]
            if (k > 0 && nums[k] == nums[k - 1]) {
                continue;
            }
            
            for(int i = k + 1; i < nums.size(); i++) {
                // 二级剪枝
                if (nums[k] + nums[i] > target && nums[k] + nums[i] >= 0) {
                    break;
                }
                
                // 去重nums[i]
                if (i > k + 1 && nums[i] == nums[i - 1]) {
                    continue;
                }
                
                int left = i + 1;
                int right = nums.size() - 1;
                
                while (right > left) {
                    // 注意:防止溢出
                    if ((long) nums[k] + nums[i] + nums[left] + nums[right] > target) {
                        right--;
                    } else if ((long) nums[k] + nums[i] + nums[left] + nums[right] < target) {
                        left++;
                    } else {
                        result.push_back(vector<int>{nums[k], nums[i], nums[left], nums[right]});
                        
                        // 去重nums[left]和nums[right]
                        while (right > left && nums[right] == nums[right - 1]) right--;
                        while (right > left && nums[left] == nums[left + 1]) left++;
                        
                        right--;
                        left++;
                    }
                }
            }
        }
        return result;
    }
};

关键点

  • 两层循环:外层固定k,内层固定i
  • 剪枝:一级剪枝和二级剪枝
  • 溢出处理:使用long防止int溢出
  • 去重:对k、i、left、right都要去重

9.5 其他技巧模板

模板:最长连续序列

适用场景:需要找到连续序列,且要求O(n)时间复杂度

核心思路

  • 使用set去重并快速查找
  • 只从序列的起始数字开始查找(即num-1不在set中)
  • 从起始数字向后查找连续序列

模板代码

cpp 复制代码
// LeetCode 128. 最长连续序列
class Solution {
public:
    int longestConsecutive(vector<int>& nums) {
        int result = 0;
        unordered_set<int> uset;
        
        // 将所有数字存入set
        for(const int& num : nums) {
            uset.insert(num);
        }
        
        // 只从序列的起始数字开始查找
        for(const int& num : uset) {
            // 如果num-1不在set中,说明num是序列的起始数字
            if(!uset.count(num - 1)) {
                int currentNum = num;
                int currentResult = 1;
                
                // 从起始数字向后查找连续序列
                while(uset.count(currentNum + 1)) {
                    currentNum += 1;
                    currentResult += 1;
                }
                
                result = max(result, currentResult);
            }
        }
        
        return result;
    }
};

关键点

  • 只从起始数字查找:!uset.count(num - 1)
  • 时间复杂度:O(n),虽然有两层循环,但每个数字最多被访问两次
  • 空间复杂度:O(n)

10. 总结

哈希表是一种空间换时间的数据结构,通过哈希函数将键映射到数组索引,实现快速查找、插入和删除操作。

核心要点

  1. 三种哈希结构:数组、Set、Map,根据场景选择
  2. 哈希函数:将键映射到索引,要求均匀分布
  3. 哈希碰撞:通过拉链法或线性探测法解决
  4. 选择原则:键范围小用数组,只需存在性用Set,需要键值对用Map
  5. 时间复杂度:平均O(1),最坏O(n)

使用建议

  • 优先使用unordered_setunordered_map(效率最高)
  • 需要有序时使用setmap
  • 键范围小且连续时优先考虑数组
  • 需要存储额外信息时使用Map

常见题型总结

  • 数组哈希:字母异位词、赎金信(键范围小且连续)
  • Set哈希:两个数组的交集、快乐数(只需判断存在性)
  • Map哈希:两数之和、四数相加II(需要键值对)
  • 双指针+去重:三数之和、四数之和(需要去重的多数和)
  • 其他技巧:字母异位词分组、最长连续序列
相关推荐
AuroraWanderll2 小时前
C++11(二)核心突破:右值引用与移动语义(上)
c语言·数据结构·c++·算法·stl
CoderYanger2 小时前
第 479 场周赛Q1——3769. 二进制反射排序
java·数据结构·算法·leetcode·职场和发展
广府早茶2 小时前
机器人重量
c++·算法
神仙别闹2 小时前
基于QT(C++)实现宠物小精灵对战游戏
c++·qt·宠物
sin_hielo2 小时前
leetcode 1925
数据结构·算法·leetcode
CoderYanger2 小时前
A.每日一题——1925. 统计平方和三元组的数目
java·开发语言·数据结构·算法·leetcode·哈希算法
XH华2 小时前
数据结构第七章:队列的学习
数据结构
zz0723202 小时前
数据结构 —— 并查集
java·数据结构
CQ_YM2 小时前
数据结构之排序
c语言·数据结构·算法·排序算法