哈希表

键值存储与 O(1) 查找。 哈希表(hash table)通过哈希函数把键(key)映射到数组下标,从而在平均情况下以 O(1) 时间完成查找、插入、删除。它是「字典」「集合」的底层实现之一,计数、去重、两数之和、分组等题目都离不开它。这一章把哈希函数、冲突处理、负载因子讲清楚,并配图与例题代码。

一、什么是哈希表

哈希表是一种键值对(key-value)存储结构:给定键 key,可以快速找到对应的值 value(或判断 key 是否存在)。理想情况下,通过哈希函数把 key 映射到数组的某个(bucket)下标,直接按下标访问,因此查找、插入、删除的平均复杂度为 O(1)。

与数组按「下标」访问不同,哈希表按「键」访问:键可以是整数、字符串或其它可哈希类型,键的取值空间往往很大,而桶的个数有限,所以多个键可能映射到同一桶,产生冲突(collision),需要冲突处理策略。

哈希表:key 经 hash(key) 得到桶下标,桶内用链表存冲突的键值对

二、哈希函数

哈希函数(hash function)把任意键映射到一个固定范围的整数(通常再对桶数取模得到下标)。好的哈希函数应满足:

常见做法:整数可直接取模 key % n(n 为桶数);字符串常用「加权求和再取模」或现成算法(如 Python 的 hash())。键的类型必须不可变且可比较相等(Python 中需实现 __hash____eq__),这样才适合做哈希表的键。

哈希函数:key → hash(key) → 对桶数 n 取模得到桶下标

三、冲突处理

当两个不同的 key 映射到同一桶时发生冲突。常用两种策略:

3.1 链地址法(拉链法)

每个桶维护一条链表(或其它顺序结构),所有映射到该桶的键值对都挂在这条链上。查找时先定位桶,再在链上按 key 比较。插入:在对应桶的链上插入或覆盖。删除:在链上删除对应节点。若哈希函数均匀、负载因子不太大,每条链长度可视为常数,单次操作平均 O(1)。

链地址法:冲突的键值对挂在同一桶的链上,沿链查找/插入/删除

3.2 开放定址法

桶数组只存键值对,不挂链表。发生冲突时,按某种规则找「下一个」空位,例如线性探测:若位置 i 被占,则尝试 i+1、i+2……直到找到空位或找到 key。查找时同样按该规则顺延。删除通常用「惰性删除」(标记为已删)或后续 rehash,否则会破坏探测链。负载因子较高时性能下降,需要扩容 rehash。

开放定址(线性探测):若目标桶被占则试下一个,直到空位或找到 key

Python 的 dict 在 CPython 中采用开放定址(类似线性探测的变体);很多教科书和面试实现采用链地址法,便于理解。

四、负载因子与扩容

负载因子(load factor)α = 已存键值对数 / 桶数。α 越大,冲突越多,查找越慢。通常设定阈值(如 0.75):当 α 超过阈值时扩容(rehash):申请更大的桶数组,把原有键值对重新哈希到新桶中。扩容后 α 降低,平均性能恢复。均摊下来,插入仍可视为 O(1)。

五、Python 中的 dict 与 set

Python 内置的 dict(字典)即哈希表:键唯一,支持 d[key]d.get(key)d[key]=valdel d[key]key in dset(集合)是「只有键没有值」的哈希表,用于去重、判存在。两者都要求键不可变(如 int、str、tuple 不可变元素);list、dict 不能作为键。

下面用链地址法手写一个简易哈希表(只存整数 key→value),帮助理解「桶 + 链表」:

from typing import List, Optional, Tuple

class SimpleHashTable:
    """链地址法:桶数组 + 每桶存 (key, value) 列表;
    负载因子超阈值时扩容 rehash。"""
    LOAD_FACTOR: float = 0.75  # 负载因子阈值,超过则扩容

    def __init__(self, capacity: int = 16) -> None:
        self.cap: int = capacity
        self.buckets: List[List[Tuple[int, int]]] = [[] for _ in range(capacity)]  # 每桶一条链(用 list 表示)
        self._n: int = 0  # 当前键值对个数,用于 O(1) 的 len 与负载因子

    def _index(self, key: int) -> int:
        """由 key 取模得到桶下标。"""
        return key % self.cap

    def _load_factor(self) -> float:
        """负载因子 = 键值对数 / 桶数。"""
        return self._n / self.cap if self.cap > 0 else 0.0

    def _rehash(self) -> None:
        """扩容:桶数翻倍(至少 16),把旧桶中所有 (k,v)
        按新 cap 重新散列到新桶。"""
        old_buckets: List[List[Tuple[int, int]]] = self.buckets
        self.cap = max(16, self.cap * 2)
        self.buckets = [[] for _ in range(self.cap)]
        for chain in old_buckets:
            for k, v in chain:
                i: int = self._index(k)
                self.buckets[i].append((k, v))

    def get(self, key: int) -> Optional[int]:
        """查找:先定位桶,再在桶内链上顺序找 key,
        找到返回 value,否则 None。"""
        i: int = self._index(key)
        for k, v in self.buckets[i]:
            if k == key:
                return v
        return None

    def put(self, key: int, value: int) -> None:
        """插入或更新:桶内已有 key 则覆盖;否则在桶链末尾追加。
        插入后若负载因子超阈值则 rehash。"""
        i: int = self._index(key)
        for j, (k, v) in enumerate(self.buckets[i]):
            if k == key:
                self.buckets[i][j] = (key, value)
                return
        self.buckets[i].append((key, value))
        self._n += 1
        if self._load_factor() > self.LOAD_FACTOR:
            self._rehash()

    def remove(self, key: int) -> None:
        """删除:在桶内链上找到 key 并移除;不存在则 KeyError。"""
        i: int = self._index(key)
        for j, (k, v) in enumerate(self.buckets[i]):
            if k == key:
                self.buckets[i].pop(j)
                self._n -= 1
                return
        raise KeyError(key)

    def __contains__(self, key: int) -> bool:
        """支持 key in table。"""
        return self.get(key) is not None

    def __len__(self) -> int:
        """当前键值对个数。"""
        return self._n

    def clear(self) -> None:
        """清空所有桶链并置 _n 为 0。"""
        for i in range(self.cap):
            self.buckets[i].clear()
        self._n = 0

负载因子与扩容_load_factor() 返回 _n / capput 在插入新键后若负载因子超过 LOAD_FACTOR(默认 0.75),则调用 _rehash():新桶数为原来的 2 倍(至少 16),将旧桶中所有键值对按新桶数重新 _index 放入新桶,从而降低负载因子、减少冲突。

下面用开放定址法(线性探测)再实现一版,支持同样操作;删除时用惰性删除(标记为已删,查找时跳过),扩容 rehash 时再清理。

from typing import List, Optional, Tuple, Any

# 惰性删除占位:删除时只把槽置为此标记,查找时跳过,rehash 时自然清理
_DELETED: Any = object()

class OpenAddressingHashTable:
    """开放定址(线性探测):单数组存槽位,
    None=空、_DELETED=已删、(k,v)=占用;负载因子超阈值则 rehash。"""
    LOAD_FACTOR: float = 0.75

    def __init__(self, capacity: int = 16) -> None:
        self.cap: int = capacity
        self.slots: List[Optional[Tuple[int, int]]] = [None] * capacity  # 每槽 None / _DELETED / (key, value)
        self._n: int = 0

    def _index(self, key: int) -> int:
        """由 key 取模得到起始探测下标。"""
        return key % self.cap

    def _load_factor(self) -> float:
        """负载因子 = 键值对数 / 槽数。"""
        return self._n / self.cap if self.cap > 0 else 0.0

    def _probe_find(self, key: int) -> int:
        """线性探测查找 key:从 _index(key) 起顺延,
        遇 key 返回下标,遇 None 返回 -1,_DELETED 继续。"""
        i: int = self._index(key)
        for _ in range(self.cap):
            cell: Optional[Tuple[int, int]] = self.slots[i]
            if cell is None:
                return -1
            if cell is not _DELETED and cell[0] == key:
                return i
            i = (i + 1) % self.cap
        return -1

    def _probe_slot(self, key: int) -> int:
        """为 put 找写入位置:若已有同 key 返回其下标(更新);
        否则返回第一个可写槽(优先复用 _DELETED,再 None);表满返回 -1。"""
        i: int = self._index(key)
        first_deleted: int = -1
        for _ in range(self.cap):
            cell = self.slots[i]
            if cell is None:
                return first_deleted if first_deleted >= 0 else i
            if cell is _DELETED and first_deleted < 0:
                first_deleted = i
            elif cell is not _DELETED and cell[0] == key:
                return i
            i = (i + 1) % self.cap
        return first_deleted if first_deleted >= 0 else -1

    def _rehash(self) -> None:
        """扩容:槽数翻倍(至少 16),仅把当前有效键值对
        重新 put 进新表,_DELETED 自然被丢弃。"""
        old_slots: List[Optional[Tuple[int, int]]] = self.slots
        old_cap: int = self.cap
        self.cap = max(16, self.cap * 2)
        self.slots = [None] * self.cap
        self._n = 0
        for i in range(old_cap):
            cell = old_slots[i]
            if cell is not None and cell is not _DELETED:
                self.put(cell[0], cell[1])

    def get(self, key: int) -> Optional[int]:
        """查找:线性探测找到 key 则返回 value,否则 None。"""
        pos: int = self._probe_find(key)
        if pos < 0:
            return None
        return self.slots[pos][1]

    def put(self, key: int, value: int) -> None:
        """插入或更新:_probe_slot 找位置;表满则先 rehash 再递归 put;
        同 key 则覆盖,否则写入并检查负载因子。"""
        pos: int = self._probe_slot(key)
        if pos < 0:
            self._rehash()
            self.put(key, value)
            return
        if self.slots[pos] is not None and self.slots[pos] is not _DELETED and self.slots[pos][0] == key:
            self.slots[pos] = (key, value)
            return
        self.slots[pos] = (key, value)
        self._n += 1
        if self._load_factor() > self.LOAD_FACTOR:
            self._rehash()

    def remove(self, key: int) -> None:
        """删除:线性探测找到 key 后把槽置为 _DELETED(惰性删除),
        不移动后续元素以保持探测链;不存在则 KeyError。"""
        pos: int = self._probe_find(key)
        if pos < 0:
            raise KeyError(key)
        self.slots[pos] = _DELETED
        self._n -= 1

    def __contains__(self, key: int) -> bool:
        """支持 key in table。"""
        return self.get(key) is not None

    def __len__(self) -> int:
        """当前键值对个数。"""
        return self._n

    def clear(self) -> None:
        """清空所有槽为 None 并置 _n 为 0。"""
        for i in range(self.cap):
            self.slots[i] = None
        self._n = 0

说明:槽位为 None(空)、_DELETED(已删占位)或 (key, value)_probe_find_index(key) 起线性探测,遇空返回 -1,遇 key 返回下标。_probe_slot 为 put 找可写位置:优先复用第一个遇到的 _DELETED,否则用第一个 None;若找到同 key 则返回该下标以便覆盖。remove 只把槽位置为 _DELETED,不移动后续元素,保证探测链不断。_rehash 时新建更大数组,仅把当前有效键值对重新 put 进去,自然去掉所有 _DELETED

内置用法示例:

from typing import Dict, Set, List, Optional

def dict_set_demo() -> None:
    d: Dict[str, int] = {}
    d["a"] = 1
    d["b"] = 2
    v: Optional[int] = d.get("a")      # 1
    has: bool = "c" in d               # False
    keys: List[str] = list(d.keys())

    s: Set[int] = set()
    s.add(3)
    s.add(7)
    s.discard(3)
    in_set: bool = 7 in s              # True

六、哈希表操作复杂度

操作平均说明
查找(key 是否存在 / 取值)O(1)哈希 + 冲突链或探测
插入 / 更新O(1) 均摊含扩容 rehash
删除O(1) 均摊同上
遍历所有键值对O(n)n 为键值对数量

七、常见应用延伸

哈希表适合:计数(key 为元素,value 为出现次数)、去重/判存在(set 或 dict 的 key)、两数之和(扫一遍,用 dict 记「已见过的数」及下标)、分组(key 为分组依据,value 为列表)、缓存(key 为请求,value 为结果)。下面三道例题覆盖「查找互补、分组、连续序列」。

八、例题与代码详解

以下三道题给出完整题目描述Python3 解法(带类型标注)详细讲解

例题 1:两数之和(无序数组)

题目描述:给定整数数组 nums 和整数 target,在数组中找两个不同下标 i、j,使 nums[i] + nums[j] == target。返回这两个下标组成的列表;保证有且仅有一组解。

输入nums 长度 ≥ 2;有且一组解。
输出[i, j],i < j。

from typing import List, Dict

def two_sum(nums: List[int], target: int) -> List[int]:
    """一遍扫描:用哈希表记录「值 → 下标」,对当前 x 查 target-x 是否已出现。"""
    seen: Dict[int, int] = {}
    for i, x in enumerate(nums):
        need: int = target - x
        if need in seen:
            return [seen[need], i]
        seen[x] = i
    return []

讲解:对每个位置 i、值 x,若存在 j < i 使得 nums[j] == target - x,则 (j, i) 即为答案。用哈希表 seen 记录「已遍历过的值 → 其下标」,遍历到 x 时查 target - x 是否在 seen 中,若有则返回 [seen[need], i],否则将 x → i 加入 seen。单遍扫描,时间 O(n),空间 O(n)。

例题 2:字母异位词分组

题目描述:给定字符串数组 strs,将其中所有字母异位词(所含字母相同、仅顺序不同)分到同一组。返回分组后的列表,每组内为若干字符串。

输入strs 非空,仅小写字母。
输出List[List[str]],每组为字母异位词列表。

from typing import List, Dict, Tuple
from collections import defaultdict

def group_anagrams(strs: List[str]) -> List[List[str]]:
    """分组键:将单词按字符排序后的元组(或计数元组),同键的为异位词。"""
    groups: Dict[Tuple[str, ...], List[str]] = defaultdict(list)
    for s in strs:
        key: Tuple[str, ...] = tuple(sorted(s))
        groups[key].append(s)
    return list(groups.values())

讲解:两个字符串是字母异位词当且仅当它们排序后相同。因此用「排序后的元组」作为分组键:key = tuple(sorted(s)),同一 key 下的所有字符串即一组异位词。用 defaultdict(list) 方便往组里 append。最后返回 groups.values() 转成的列表。设单词最大长度为 k,n 个单词,时间 O(n·k log k),空间 O(n·k)。

例题 3:最长连续序列

题目描述:给定未排序的整数数组 nums,求其中数字连续的最长序列的长度。连续指如 [1,2,3,4] 这样相邻差为 1,不要求在原数组中连续出现。

输入nums 可能含重复;长度 ≥ 0。
输出:最长连续序列的长度(整数)。

from typing import List, Set

def longest_consecutive(nums: List[int]) -> int:
    """用 set 存所有数;只从「某段连续序列的最小值」开始向后扩,避免重复统计。"""
    s: Set[int] = set(nums)
    best: int = 0
    for x in s:
        if x - 1 in s:
            continue
        cur: int = 0
        while x + cur in s:
            cur += 1
        best = max(best, cur)
    return best

讲解:若从每个数都往后扩,会重复统计同一段。优化:只从「某段连续序列的最小值」开始扩——即当 x-1 不在集合中时,说明 x 可能是某段的最小值,从 x 开始用 while x+cur in s 向后数长度。用 set 使单次查找 O(1),每个数最多被「起点」或「被数到」各一次,时间 O(n),空间 O(n)。

小贴士: 哈希表题常考「一遍扫描 + 查表」或「先建表再枚举」。先想清楚「键是什么、值存什么」,再写循环。注意键的类型必须可哈希(不可变)。

九、小结

哈希表通过哈希函数将键映射到桶下标,实现平均 O(1) 的查找、插入、删除。哈希函数需确定性、均匀、高效;冲突处理常用链地址法或开放定址法;负载因子过高时需扩容 rehash。Python 的 dictset 即哈希表,键须不可变。典型应用:计数、去重、两数之和、分组、缓存。下一章我们会学树与二叉树——层次结构与递归遍历。