设计哈希集合【set】【拉链法】【位运算法】【定长拉链法】 - 哈希表本质深度解析

LeetCode 705 设计哈希集合 - 哈希表本质深度解析

题目描述

设计一个哈希集合(HashSet),不使用任何内建的哈希表库,实现以下操作:

  • add(key): 向哈希集合中插入值 key
  • remove(key): 将给定值 key 从哈希集合中删除
  • contains(key): 返回哈希集合中是否存在这个值 key

数据范围: 0 <= key <= 10^6,最多调用 10^4 次操作

哈希表的本质与设计思想

1. 哈希表的核心思想

哈希表是一种在时间和空间上做权衡的经典数据结构。其核心思想是:

  • 空间换时间: 通过预分配空间,实现O(1)的查找、插入、删除操作
  • 哈希函数: 将任意输入映射到固定范围的数组索引
  • 冲突处理: 解决不同输入映射到同一位置的问题

2. 设计哈希函数的关键考虑

  1. 均匀分布: 哈希函数应该将输入均匀分布到各个桶中
  2. 快速计算: 哈希函数应该计算速度快
  3. 冲突最小化: 减少不同输入映射到同一位置的概率

3. 冲突处理方法

  • 拉链法: 每个桶维护一个链表/数组存储冲突的元素
  • 开放寻址法: 当发生冲突时,寻找下一个可用位置
  • 线性探测: 依次检查下一个位置
  • 二次探测: 使用二次函数计算下一个位置

解法一:超大数组法(空间换时间)

核心思想

直接使用一个足够大的数组,让每个key都有唯一的索引位置,完全避免冲突。

C++实现

class MyHashSet {
private:
    vector<bool> data;
    
public:
    MyHashSet() {
        // 10^6 + 1 大小的数组,key直接作为索引
        data.resize(1000001, false);
    }
    
    void add(int key) {
        data[key] = true;
    }
    
    void remove(int key) {
        data[key] = false;
    }
    
    bool contains(int key) {
        return data[key];
    }
};

Python实现

class MyHashSet:
    def __init__(self):
        # 使用列表模拟超大数组
        self.data = [False] * 1000001
    
    def add(self, key: int) -> None:
        self.data[key] = True
    
    def remove(self, key: int) -> None:
        self.data[key] = False
    
    def contains(self, key: int) -> bool:
        return self.data[key]

优缺点分析

优点:

  • 时间复杂度O(1),性能极佳
  • 实现简单,代码清晰
  • 无冲突问题

缺点:

  • 空间复杂度O(数据范围),空间浪费严重
  • 需要预知数据范围
  • 当数据范围很大时无法使用
  • 存储元素较少时性价比极低

解法二:拉链法(经典实现)

核心思想

使用较小的数组作为桶,每个桶维护一个链表处理冲突。这是大多数编程语言哈希表的实现方式。

C++实现

class MyHashSet {
private:
    // 桶的数量,选择质数有助于均匀分布
    static const int BASE = 1009;
    vector<list<int>> buckets;
    
    // 哈希函数:取模运算
    int hash(int key) {
        return key % BASE;
    }
    
public:
    MyHashSet() {
        buckets.resize(BASE);
    }
    
    void add(int key) {
        int index = hash(key);
        // 检查是否已存在
        for (auto it = buckets[index].begin(); it != buckets[index].end(); ++it) {
            if (*it == key) {
                return; // 已存在,直接返回
            }
        }
        // 不存在则添加
        buckets[index].push_back(key);
    }
    
    void remove(int key) {
        int index = hash(key);
        // 在链表中查找并删除
        for (auto it = buckets[index].begin(); it != buckets[index].end(); ++it) {
            if (*it == key) {
                buckets[index].erase(it);
                return;
            }
        }
    }
    
    bool contains(int key) {
        int index = hash(key);
        // 在链表中查找
        for (auto it = buckets[index].begin(); it != buckets[index].end(); ++it) {
            if (*it == key) {
                return true;
            }
        }
        return false;
    }
};

Python实现

class MyHashSet:
    def __init__(self):
        # 选择质数作为桶数,有助于均匀分布
        self.buckets = 1009
        self.table = [[] for _ in range(self.buckets)]
    
    def hash(self, key: int) -> int:
        return key % self.buckets
    
    def add(self, key: int) -> None:
        hash_key = self.hash(key)
        # 检查是否已存在
        if key not in self.table[hash_key]:
            self.table[hash_key].append(key)
    
    def remove(self, key: int) -> None:
        hash_key = self.hash(key)
        # 如果存在则删除
        if key in self.table[hash_key]:
            self.table[hash_key].remove(key)
    
    def contains(self, key: int) -> bool:
        hash_key = self.hash(key)
        return key in self.table[hash_key]

优缺点分析

优点:

  • 空间利用率高,只占用实际需要的空间
  • 不需要预知数据范围
  • 支持动态扩容
  • 实现相对简单

缺点:

  • 需要多次内存访问,性能略差
  • 最坏情况下时间复杂度O(n)
  • 需要设计合理的哈希函数
  • 链表过长时性能下降

插播迭代器相关

C++ STL迭代器详解 - 从零开始理解

什么是迭代器?

迭代器(Iterator)是C++ STL中的一个重要概念,它提供了一种统一的方式来访问容器中的元素。可以把迭代器想象成一个"指针",它指向容器中的某个元素。

基本概念

1. 容器(Container)

容器是存储数据的结构,比如:

  • vector - 动态数组
  • list - 双向链表
  • set - 集合
  • map - 映射

2. 迭代器(Iterator)

迭代器是访问容器元素的工具,类似于指针。

迭代器的基本操作

1. 获取迭代器

vector<int> vec = {1, 2, 3, 4, 5};

// 获取指向第一个元素的迭代器
auto begin_it = vec.begin();

// 获取指向最后一个元素之后位置的迭代器
auto end_it = vec.end();

2. 迭代器的操作

vector<int> vec = {1, 2, 3, 4, 5};
auto it = vec.begin();

// 解引用:获取迭代器指向的值
int value = *it;  // value = 1

// 移动迭代器
++it;  // 移动到下一个元素
--it;  // 移动到上一个元素(如果支持)

// 比较迭代器
if (it != vec.end()) {
    // 迭代器还没有到达末尾
}

详细解析你的代码

让我们逐步分析这段代码:

void add(int key) {
    int index = hash(key);
    // 检查是否已存在?
    for (auto it = buckets[index].begin(); it != buckets[index].end(); ++it) {
        if (*it == key) return;
    }
    // 不存在, 则直接添加
    buckets[index].push_back(key);
}

1. auto 关键字

auto it = buckets[index].begin();
  • auto 是C++11引入的类型推导关键字
  • 编译器会自动推导出 it 的类型
  • 等价于:list::iterator it = buckets[index].begin();

2. begin()end() 方法

buckets[index].begin()  // 返回指向链表第一个元素的迭代器
buckets[index].end()    // 返回指向链表末尾之后位置的迭代器

3. 迭代器循环

for (auto it = buckets[index].begin(); it != buckets[index].end(); ++it) {
    if (*it == key) return;
}

这个循环可以分解为:

// 1. 初始化:获取指向链表开头的迭代器
auto it = buckets[index].begin();

// 2. 条件检查:如果迭代器还没到达末尾
while (it != buckets[index].end()) {
    // 3. 解引用迭代器,获取当前元素的值
    if (*it == key) {
        return;  // 找到重复元素,直接返回
    }
    
    // 4. 移动到下一个元素
    ++it;
}

图解迭代器

假设我们有一个链表:[1, 2, 3, 4]

链表: [1] -> [2] -> [3] -> [4] -> null
       ↑                   	      ↑
    begin()             	   end()
  • begin() 指向第一个元素 1
  • end() 指向最后一个元素之后的位置(null)

不同容器的迭代器

1. vector的迭代器

vector<int> vec = {1, 2, 3, 4, 5};

// 正向遍历
for (auto it = vec.begin(); it != vec.end(); ++it) {
    cout << *it << " ";  // 输出:1 2 3 4 5
}

// 反向遍历
for (auto it = vec.rbegin(); it != vec.rend(); ++it) {
    cout << *it << " ";  // 输出:5 4 3 2 1
}

2. list的迭代器

list<int> lst = {1, 2, 3, 4, 5};

// 正向遍历
for (auto it = lst.begin(); it != lst.end(); ++it) {
    cout << *it << " ";  // 输出:1 2 3 4 5
}

// 反向遍历
for (auto it = lst.rbegin(); it != lst.rend(); ++it) {
    cout << *it << " ";  // 输出:5 4 3 2 1
}

现代C++的简化写法

1. 范围for循环(C++11)

vector<int> vec = {1, 2, 3, 4, 5};

// 传统迭代器写法
for (auto it = vec.begin(); it != vec.end(); ++it) {
    cout << *it << " ";
}

// 现代范围for循环(推荐)
for (const auto& element : vec) {
    cout << element << " ";
}

2. 你的代码可以改写为

void add(int key) {
    int index = hash(key);
    
    // 使用范围for循环检查是否已存在
    for (const auto& element : buckets[index]) {
        if (element == key) {
            return;  // 已存在,直接返回
        }
    }
    
    // 不存在,则添加
    buckets[index].push_back(key);
}

迭代器的类型

1. 输入迭代器(Input Iterator)

只能读取元素,只能向前移动

// 例如:istream_iterator

2. 输出迭代器(Output Iterator)

只能写入元素,只能向前移动

// 例如:ostream_iterator

3. 前向迭代器(Forward Iterator)

可以读写元素,只能向前移动

// 例如:forward_list的迭代器

4. 双向迭代器(Bidirectional Iterator)

可以读写元素,可以向前和向后移动

// 例如:list的迭代器

5. 随机访问迭代器(Random Access Iterator)

可以读写元素,可以随机访问任意位置

// 例如:vector的迭代器

常见错误和注意事项

1. 迭代器失效

vector<int> vec = {1, 2, 3, 4, 5};
auto it = vec.begin();

// 错误:在遍历过程中修改容器
for (auto it = vec.begin(); it != vec.end(); ++it) {
    if (*it == 2) {
        vec.erase(it);  // 错误!迭代器失效
    }
}

2. 正确的删除方式

vector<int> vec = {1, 2, 3, 4, 5};

// 方法1:使用erase的返回值
for (auto it = vec.begin(); it != vec.end();) {
    if (*it == 2) {
        it = vec.erase(it);  // erase返回下一个有效的迭代器
    } else {
        ++it;
    }
}

// 方法2:使用remove-erase惯用法
vec.erase(remove(vec.begin(), vec.end(), 2), vec.end());

实际应用示例

1. 在哈希表中查找元素

class MyHashSet {
private:
    vector<list<int>> buckets;
    
public:
    bool contains(int key) {
        int index = hash(key);
        
        // 使用迭代器遍历链表
        for (auto it = buckets[index].begin(); it != buckets[index].end(); ++it) {
            if (*it == key) {
                return true;  // 找到元素
            }
        }
        return false;  // 未找到元素
    }
};

2. 删除链表中的元素

void remove(int key) {
    int index = hash(key);
    
    // 使用迭代器查找并删除
    for (auto it = buckets[index].begin(); it != buckets[index].end(); ++it) {
        if (*it == key) {
            buckets[index].erase(it);  // 删除当前元素
            return;
        }
    }
}

总结

  1. 迭代器是访问容器元素的统一接口
  2. begin() 指向第一个元素,end() 指向末尾之后的位置
  3. auto 用于自动类型推导
  4. *it 用于解引用,获取迭代器指向的值
  5. ++it 用于移动到下一个元素
  6. 现代C++推荐使用范围for循环简化代码

理解迭代器是掌握C++ STL的关键,它是连接算法和容器的桥梁!


到此为止

拉链法:

// 拉链法
class MyHashSet {
// 
private:
    static const int BASE = 1009;
    vector<list<int>> buckets;

    // 哈希函数
    int hash(int key) {
        return key % BASE;
    }

public:
    MyHashSet() {
        buckets.resize(BASE);        
    }
    
    void add(int key) {
        int index = hash(key);
        // 检查是否已存在?
        for (auto it = buckets[index].begin(); it != buckets[index].end(); ++it) {
            if (*it == key) return;
        }
        // 不存在, 则直接添加
        buckets[index].push_back(key);
    }
    
    void remove(int key) {
        int index = hash(key);
        for (auto it = buckets[index].begin(); it != buckets[index].end();++it) {
            if (*it == key) {
                buckets[index].erase(it);
                return;
            }
        }
    }
    
    bool contains(int key) {
        int index = hash(key);
        // 现代范围for循环(推荐)
        for (const auto& element : buckets[index]) {
            if (element == key) {
                return true;
            }
        }
        return false;
    }
};


解法三:分桶数组 + 位运算(极致优化)

核心思想

使用位运算实现类似bitmap的数据结构。每个int的32位可以表示32个不同的key,大大节省空间。

C++实现

class MyHashSet {
private:
    vector<int> buckets;
    
    // 设置指定位的值
    void setBit(int bucket, int bit, bool val) {
        if (val) {
            // 设置位为1:使用OR操作
            buckets[bucket] |= (1 << bit);
        } else {
            // 设置位为0:使用AND操作
            buckets[bucket] &= ~(1 << bit);
        }
    }
    
    // 获取指定位的值
    bool getBit(int bucket, int bit) {
        return (buckets[bucket] >> bit) & 1;
    }
    
public:
    MyHashSet() {
        // 10^6 / 32 ≈ 31250,向上取整到32000
        buckets.resize(32000, 0);
    }
    
    void add(int key) {
        int bucket = key / 32;  // 确定桶的位置
        int bit = key % 32;     // 确定位的位置
        setBit(bucket, bit, true);
    }
    
    void remove(int key) {
        int bucket = key / 32;
        int bit = key % 32;
        setBit(bucket, bit, false);
    }
    
    bool contains(int key) {
        int bucket = key / 32;
        int bit = key % 32;
        return getBit(bucket, bit);
    }
};

Python实现

class MyHashSet:
    def __init__(self):
        # 10^6 / 32 ≈ 31250,向上取整到32000
        self.buckets = [0] * 32000
    
    def set_bit(self, bucket: int, bit: int, val: bool) -> None:
        if val:
            # 设置位为1
            self.buckets[bucket] |= (1 << bit)
        else:
            # 设置位为0
            self.buckets[bucket] &= ~(1 << bit)
    
    def get_bit(self, bucket: int, bit: int) -> bool:
        return (self.buckets[bucket] >> bit) & 1
    
    def add(self, key: int) -> None:
        bucket = key // 32
        bit = key % 32
        self.set_bit(bucket, bit, True)
    
    def remove(self, key: int) -> None:
        bucket = key // 32
        bit = key % 32
        self.set_bit(bucket, bit, False)
    
    def contains(self, key: int) -> bool:
        bucket = key // 32
        bit = key % 32
        return self.get_bit(bucket, bit)

位运算详解

核心位运算操作
  1. 设置位为1: x |= (1 << pos)

    • 1 << pos 创建一个只有第pos位为1的掩码
    • |= 操作将该位设置为1,其他位保持不变
  2. 设置位为0: x &= ~(1 << pos)

    • ~(1 << pos) 创建一个除了第pos位为0,其他位都为1的掩码
    • &= 操作将该位设置为0,其他位保持不变
  3. 获取位的值: (x >> pos) & 1

    • >> 右移操作将目标位移到最低位
    • & 1 只保留最低位的值

优缺点分析

优点:

  • 空间效率极高,每个int可以表示32个key
  • 时间复杂度O(1)
  • 位运算性能极佳
  • 无冲突问题

缺点:

  • 实现相对复杂
  • 需要理解位运算
  • 仍然需要预知数据范围

解法四:定长拉链数组(二维数组)

核心思想

将哈希表设计成二维数组,第一维用于哈希分桶,第二维用于存储具体元素。

C++实现

class MyHashSet {
private:
    static const int BUCKETS = 1000;
    static const int ITEMS_PER_BUCKET = 1001;
    vector<vector<int>> table;
    
    int hash(int key) {
        return key % BUCKETS;
    }
    
    int pos(int key) {
        return key / BUCKETS;
    }
    
public:
    MyHashSet() {
        table.resize(BUCKETS);
    }
    
    void add(int key) {
        int hash_key = hash(key);
        // 如果桶为空,初始化
        if (table[hash_key].empty()) {
            table[hash_key].resize(ITEMS_PER_BUCKET, 0);
        }
        table[hash_key][pos(key)] = 1;
    }
    
    void remove(int key) {
        int hash_key = hash(key);
        if (!table[hash_key].empty()) {
            table[hash_key][pos(key)] = 0;
        }
    }
    
    bool contains(int key) {
        int hash_key = hash(key);
        return !table[hash_key].empty() && table[hash_key][pos(key)] == 1;
    }
};

Python实现

class MyHashSet:
    def __init__(self):
        self.buckets = 1000
        self.items_per_bucket = 1001
        self.table = [[] for _ in range(self.buckets)]
    
    def hash(self, key: int) -> int:
        return key % self.buckets
    
    def pos(self, key: int) -> int:
        return key // self.buckets
    
    def add(self, key: int) -> None:
        hash_key = self.hash(key)
        # 如果桶为空,初始化
        if not self.table[hash_key]:
            self.table[hash_key] = [0] * self.items_per_bucket
        self.table[hash_key][self.pos(key)] = 1
    
    def remove(self, key: int) -> None:
        hash_key = self.hash(key)
        if self.table[hash_key]:
            self.table[hash_key][self.pos(key)] = 0
    
    def contains(self, key: int) -> bool:
        hash_key = self.hash(key)
        return (self.table[hash_key] and 
                self.table[hash_key][self.pos(key)] == 1)

性能对比分析

方法 时间复杂度 空间复杂度 实现难度 适用场景
超大数组 O(1) O(数据范围) 简单 数据范围小,性能要求高
拉链法 O(1)平均 O(n) 中等 通用场景,面试首选
位运算 O(1) O(数据范围/32) 困难 空间敏感,位运算熟练
定长拉链 O(1) O(数据范围) 中等 数据范围已知

哈希表设计的关键要点

1. 哈希函数设计原则

  • 均匀性: 输出应该均匀分布
  • 确定性: 相同输入总是产生相同输出
  • 高效性: 计算速度要快
  • 雪崩效应: 输入的微小变化应该导致输出的巨大变化

2. 桶数选择策略

  • 质数: 选择质数作为桶数有助于减少冲突
  • 2的幂: 便于位运算优化
  • 负载因子: 考虑元素数量与桶数的比例

3. 冲突处理策略

  • 拉链法: 适合元素分布不均匀的情况
  • 开放寻址: 适合元素分布相对均匀的情况
  • 再哈希: 使用多个哈希函数

实际应用中的考虑

1. 扩容机制

当负载因子过高时,需要扩容:

// 扩容示例
void resize() {
    vector<list<int>> old_buckets = buckets;
    buckets.resize(buckets.size() * 2);
    // 重新哈希所有元素
    for (const auto& bucket : old_buckets) {
        for (int key : bucket) {
            add(key);
        }
    }
}

2. 内存管理

  • 使用智能指针管理动态内存
  • 考虑内存对齐和缓存友好性
  • 避免频繁的内存分配

3. 线程安全

  • 使用锁保护共享数据
  • 考虑无锁数据结构
  • 读写分离设计

总结

哈希表的设计体现了计算机科学中经典的时空权衡思想:

  1. 超大数组法体现了"空间换时间"的极致
  2. 拉链法体现了平衡设计的智慧
  3. 位运算法体现了底层优化的精妙
  4. 定长拉链法体现了工程实践的考虑

在实际应用中,需要根据具体场景选择合适的设计:

  • 面试场景: 拉链法是最佳选择,体现了对数据结构的深入理解
  • 性能敏感: 位运算法或超大数组法
  • 内存敏感: 拉链法或位运算法
  • 通用场景: 拉链法,平衡了各种需求

哈希表的设计不仅考察了算法基础,更体现了系统设计的能力,是面试中的经典题目。

你可能感兴趣的:(哈希算法,散列表,算法)