Skip to content

Array 与 Hashing

Array 和 hash table 是编程中两种最基础的数据结构。本文介绍它们的底层工作原理,然后通过难度递增的问题逐步建立关键的解题模式:双指针、滑动窗口、前缀和以及基于 hash 的查找,并在每一步指出常见陷阱。

  • 如果你深刻理解 array 和 hash map,就能解决约 40% 的编程面试题。这两种结构无处不在,因为它们提供了算法最需要的两样东西:快速索引访问(array)和按键快速查找(hash map)。

  • 本文教授模式,而非解法。目标是:当你看到一道新题时,能够识别哪种模式适用以及为什么,而不是试图回忆一个记住的解法。

Array

  • Array 是一块连续的内存,元素存储在固定的偏移位置。访问元素 \(i\) 的代价是 \(O(1)\),因为地址就是 base + i * element_size。这是最快的数据访问方式,也是 array 作为默认选择的原因。

  • 动态 array(Python 的 list、Java 的 ArrayList、C++ 的 vector)在满时自动增长。策略是均摊倍增:当 array 满时,分配一个新的双倍大小的 array 并复制所有内容。复制花费 \(O(n)\),但它发生得很少(每 \(n\) 次插入一次),因此每次插入的均摊代价是 \(O(1)\)

  • Cache 局部性是 array 在实践中快的原因,而不仅仅是理论上。由于元素连续存储,访问一个元素会将附近的元素加载到 CPU cache(第 13 章)。迭代 array 对 cache 友好;跟随 linked list 中的 pointer 则不然。这种常数因子的差异在实践中可达 10-100 倍。

操作 Array 动态 Array
按索引访问 \(O(1)\) \(O(1)\)
追加 不支持 \(O(1)\) 均摊
在位置 \(i\) 插入 \(O(n)\) \(O(n)\)
在位置 \(i\) 删除 \(O(n)\) \(O(n)\)
搜索(未排序) \(O(n)\) \(O(n)\)
  • 陷阱:在 array 中间插入或删除是 \(O(n)\),因为所有后续元素都必须移位。如果需要频繁的中间插入,考虑 linked list 或完全不同的方法。

字符串

  • 字符串是字符的 array。在 Python 中,字符串是不可变的:每次拼接都会创建一个新字符串。在循环中逐字符构建字符串是 \(O(n^2)\),因为每次拼接都会复制到目前为止的整个字符串。
# 差:O(n^2) 字符串拼接
s = ""
for c in characters:
    s += c  # 每次复制整个字符串

# 好:O(n) 使用 list 然后 join
parts = []
for c in characters:
    parts.append(c)
s = "".join(parts)
  • 陷阱:在 Python 的循环中使用 s += c 是最常见的性能 bug 之一。始终先收集到 list 中,然后调用 .join()

  • 编码:ASCII 使用 7 位(128 个字符)。UTF-8 是可变长度的:ASCII 字符使用 1 字节,带重音符的字符使用 2 字节,汉字/日文字符使用 3 字节,emoji 使用 4 字节。当题目说"小写英文字母"时,字母表大小为 26,这意味着你可以使用固定大小的 array 而非 hash map。

Hash Table

  • Hash table\(O(1)\) 的平均情况代价实现按键查找、插入和删除。它通过计算hash function \(h(key)\) 将键转换为 array 索引来工作。

  • Hash function 必须:确定性(相同的键总是产生相同的 hash)、均匀分布(将键均匀分布在各个桶中)、快速计算

  • 碰撞发生在两个不同的键 hash 到同一个索引时。两种主要策略:

    • 链地址法(Chaining):每个桶存储一个键值对的 linked list。发生碰撞时,追加到 list。最坏情况(所有键 hash 到同一个桶):\(O(n)\)。用好的 hash function 平均情况:\(O(1)\)

    • 开放寻址(Open addressing):碰撞时,探测下一个空闲槽。线性探测检查下一个槽,然后再下一个,以此类推。对 cache 友好,但会出现聚集(长串的被占用槽)。Robin Hood hashing 通过替换"距离主位置更近"的条目来减少方差。

  • 负载因子 \(\alpha = n / m\)(元素数/桶数)决定性能。当 \(\alpha\) 超过阈值(通常为 0.75)时,table 重新 hash:分配更大的 table 并重新插入所有元素。代价为 \(O(n)\),但发生频率很低。

  • Hash map(Python 的 dict,Java 的 HashMap)存储键值对。Hash set(Python 的 set,Java 的 HashSet)只存储键(用于快速成员测试)。

操作 平均情况 最坏情况
查找 \(O(1)\) \(O(n)\)
插入 \(O(1)\) \(O(n)\)
删除 \(O(1)\) \(O(n)\)
  • Bloom filter 是空间效率高的概率集合。它们能告诉你"肯定不在集合中"或"可能在集合中"(可调的误报率)。使用 \(k\) 个 hash function 和一个位 array。用于数据库(避免对不存在的键进行磁盘读取)、Web cache 和拼写检查器。

  • 何时使用 hash map:每当你需要在 \(O(1)\) 内回答"我以前见过这个吗?"或"与这个键关联的计数/索引/值是什么?"时。如果你在反复进行线性扫描寻找某个东西,hash map 几乎肯定能让它更快。


模式:Hash Map 查找

  • 最基本的模式:使用 hash map 将 \(O(n)\) 扫描替换为 \(O(1)\) 查找。

简单:Two Sum

  • 问题:给定一个整数 array 和目标值,返回两个数的索引,使其和等于目标值。

  • 暴力法 \(O(n^2)\):检查每对。

  • 模式洞察:对于每个数 num,你需要 target - num 存在于 array 中。与其扫描 array 寻找它,不如将之前见过的数存储在 hash map 中。

def two_sum(nums, target):
    seen = {}  # 值 -> 索引
    for i, num in enumerate(nums):
        complement = target - num
        if complement in seen:
            return [seen[complement], i]
        seen[num] = i
  • 为什么有效:一遍遍历 array。对于每个元素,hash map 查找是 \(O(1)\)。总计:\(O(n)\) 时间,\(O(n)\) 空间。

  • 陷阱:不要在检查补数之前将当前数加入 hash map,否则你可能将一个元素与自身匹配。上面代码中的顺序是正确的:先检查,再插入。

中等:Group Anagrams

  • 问题:给定一个字符串 list,将变位词分组在一起。("eat"、"tea"、"ate")是一组。

  • 模式洞察:变位词具有相同的字符,只是顺序不同。如果你对每个字符串排序,变位词会产生相同的排序后的键。将该排序后的键用作 hash map 的键。

from collections import defaultdict

def group_anagrams(strs):
    groups = defaultdict(list)
    for s in strs:
        key = tuple(sorted(s))  # 或使用字符计数 tuple
        groups[key].append(s)
    return list(groups.values())
  • 优化:对每个字符串排序花费 \(O(k \log k)\),其中 \(k\) 是字符串长度。对于更快的键,计算字符频率并将计数 tuple 用作键:
def group_anagrams_fast(strs):
    groups = defaultdict(list)
    for s in strs:
        count = [0] * 26
        for c in s:
            count[ord(c) - ord('a')] += 1
        groups[tuple(count)].append(s)
    return list(groups.values())
  • 这对每个字符串是 \(O(k)\) 而非 \(O(k \log k)\)。字符计数 tuple 是一种规范形式:对于同一组的所有成员都相同的表示。

  • 陷阱:在 Python 中,list 是不可 hash 的(不能作为 dict 的键)。你必须将其转换为 tuple。当人们尝试 groups[count].append(s) 时会踩到这个坑。

困难:最长连续序列

  • 问题:给定一个未排序的 array,找出最长连续序列的长度(例如 [100, 4, 200, 1, 3, 2] → 4,因为 [1, 2, 3, 4])。

  • 暴力法 \(O(n \log n)\):对 array 排序,然后扫描连续的运行。

  • 模式洞察:将所有数字放入 hash set 以实现 \(O(1)\) 查找。对于每个数字,检查它是否是序列的起点(即 num - 1 不在 set 中)。如果是,统计序列延伸多远。

def longest_consecutive(nums):
    num_set = set(nums)
    best = 0

    for num in num_set:
        # 只从序列起点开始计数
        if num - 1 not in num_set:
            length = 1
            while num + length in num_set:
                length += 1
            best = max(best, length)

    return best
  • 为什么是 \(O(n)\):内层 while 循环在所有迭代中总共最多运行 \(n\) 次(每个数字最多被访问两次:一次在外层循环中,一次在 while 扩展中)。if num - 1 not in num_set 守卫确保我们只从序列起点开始计数。

  • 陷阱:没有 if num - 1 not in num_set 检查,你会从每个元素开始计数,在最坏情况下使其变为 \(O(n^2)\)(例如 [1, 2, 3, ..., n] 会从每个起点扫描整个序列)。


模式:双指针

  • 双指针模式使用两个索引在 array 中移动,通常从两端向中间或从同一端以不同速度移动。它适用于 array 已排序或需要比较对的情况。

  • 何时使用:问题涉及对、子数组或分区,且 array 已排序(或可以排序而不丢失所需信息)。

简单:有效回文

  • 问题:只考虑字母数字字符并忽略大小写,判断字符串是否是回文。

  • 模式:一个指针从开头,一个从末尾。向内移动,比较字符。

def is_palindrome(s):
    left, right = 0, len(s) - 1

    while left < right:
        # 跳过非字母数字字符
        while left < right and not s[left].isalnum():
            left += 1
        while left < right and not s[right].isalnum():
            right -= 1

        if s[left].lower() != s[right].lower():
            return False

        left += 1
        right -= 1

    return True
  • 陷阱:忘记内层 while 循环中的 left < right 检查。没有它,在像 "!!!" 这样全是非字母数字的字符串上,指针会越界。

中等:Three Sum

  • 问题:找出 array 中所有和为零的唯一三元组。

  • 模式:对 array 排序。固定一个元素,然后对剩余部分使用双指针,找到和为固定元素的相反数的对。

def three_sum(nums):
    nums.sort()
    result = []

    for i in range(len(nums) - 2):
        # 跳过重复的固定元素
        if i > 0 and nums[i] == nums[i - 1]:
            continue

        left, right = i + 1, len(nums) - 1
        target = -nums[i]

        while left < right:
            total = nums[left] + nums[right]
            if total < target:
                left += 1
            elif total > target:
                right -= 1
            else:
                result.append([nums[i], nums[left], nums[right]])
                # 跳过重复
                while left < right and nums[left] == nums[left + 1]:
                    left += 1
                while left < right and nums[right] == nums[right - 1]:
                    right -= 1
                left += 1
                right -= 1

    return result
  • 为什么有效:排序是 \(O(n \log n)\)。对于每个固定元素,双指针扫描是 \(O(n)\)。总计:\(O(n^2)\),对于这道题是最优的(你必须考虑所有对)。

  • 陷阱:处理重复是最难的部分。没有去重逻辑(无论是对固定元素还是双指针结果),你会返回重复的三元组。if i > 0 and nums[i] == nums[i-1]: continue 这一行至关重要。

困难:接雨水

  • 问题:给定一个高度图(非负整数 array),计算下雨后它能接住多少水。

  • 模式洞察:对于每个位置,水位由其左侧最大高度和右侧最大高度的最小值减去当前高度决定。从两端的双指针追踪这些运行中的最大值。

def trap(height):
    left, right = 0, len(height) - 1
    left_max, right_max = 0, 0
    water = 0

    while left < right:
        if height[left] < height[right]:
            if height[left] >= left_max:
                left_max = height[left]
            else:
                water += left_max - height[left]
            left += 1
        else:
            if height[right] >= right_max:
                right_max = height[right]
            else:
                water += right_max - height[right]
            right -= 1

    return water
  • 为什么有效:关键洞察是,如果 height[left] < height[right]left 位置的水被 left_max 约束(我们知道右侧有一个更高的 bar,所以右侧不是瓶颈)。我们处理较短的一侧,确保另一侧有更高的 bar。

  • 陷阱:许多人尝试先预计算 left_max[i]right_max[i] array(这可以工作但使用 \(O(n)\) 空间)。双指针方法实现 \(O(1)\) 空间。另外,在最大值更新中将 >=> 混淆会导致差一错误的水量计算。


模式:滑动窗口

  • 滑动窗口模式维护一个在迭代时扩展和收缩的窗口(连续子数组)。它适用于关于满足某些条件的子数组或子字符串的问题。

  • 何时使用:问题要求满足某约束的最长/最短子数组或子字符串,且扩展/收缩窗口是单调的(添加元素只会使约束更难/更容易满足,不会两者兼有)。

  • 模板

def sliding_window(arr):
    left = 0
    state = ...  # 窗口状态(计数、和等)
    best = ...

    for right in range(len(arr)):
        # 扩展:将 arr[right] 加入窗口状态
        update_state(state, arr[right])

        # 收缩:当约束被违反时,从左侧收缩
        while constraint_violated(state):
            remove_from_state(state, arr[left])
            left += 1

        # 更新答案
        best = max(best, right - left + 1)  # 或 min,取决于问题

    return best

简单:买卖股票的最佳时机

  • 问题:给定每日价格,找出一次买入和一次卖出的最大利润(先买后卖)。

  • 模式:追踪到目前为止见到的最低价格(窗口的左边界),并在每一天计算利润。

def max_profit(prices):
    min_price = float('inf')
    max_profit = 0

    for price in prices:
        min_price = min(min_price, price)
        max_profit = max(max_profit, price - min_price)

    return max_profit
  • 这是一个退化的滑动窗口:左指针(最低价格)只有在找到新的最低值时才前进。\(O(n)\) 时间,\(O(1)\) 空间。

中等:最长不含重复字符的子串

  • 问题:找出不含任何重复字符的最长子串的长度。

  • 模式:通过移动 right 扩展窗口。当找到重复字符时,从左侧收缩,直到重复字符被移除。

def length_of_longest_substring(s):
    char_index = {}  # 字符 -> 其最近的索引
    left = 0
    best = 0

    for right, char in enumerate(s):
        if char in char_index and char_index[char] >= left:
            left = char_index[char] + 1  # 跳过重复字符

        char_index[char] = right
        best = max(best, right - left + 1)

    return best
  • 为什么是 char_index[char] >= left:该字符可能在当前窗口开始之前就在 map 中了。没有这个检查,你会错误地收缩一个实际上不在当前窗口中的字符。

  • 陷阱:使用 set 并从左侧逐个移除字符是正确的,但更慢。hash map 方法直接跳到正确位置。

困难:最小覆盖子串

  • 问题:给定字符串 st,找出 s 中包含 t 所有字符的最小窗口。

  • 模式:扩展窗口以包含所有所需字符,然后从左侧收缩以找到最小有效窗口。

from collections import Counter

def min_window(s, t):
    if not t or not s:
        return ""

    need = Counter(t)       # 我们需要的字符及其数量
    have = 0                # 我们有足够数量的唯一字符数
    required = len(need)    # 我们需要的唯一字符数

    left = 0
    best = (float('inf'), 0, 0)  # (长度, 左, 右)

    window_counts = {}

    for right in range(len(s)):
        char = s[right]
        window_counts[char] = window_counts.get(char, 0) + 1

        # 检查这个字符的计数是否现在满足需求
        if char in need and window_counts[char] == need[char]:
            have += 1

        # 当窗口有效时,从左侧收缩
        while have == required:
            # 更新最优答案
            if (right - left + 1) < best[0]:
                best = (right - left + 1, left, right)

            # 移除最左字符
            left_char = s[left]
            window_counts[left_char] -= 1
            if left_char in need and window_counts[left_char] < need[left_char]:
                have -= 1
            left += 1

    length, start, end = best
    return s[start:end + 1] if length != float('inf') else ""
  • 陷阱have 计数器是关键优化。没有它,你需要在每一步比较整个 window_counts dict 与 need,这是每步 \(O(|\text{唯一字符}|)\)have 计数器使有效性检查变为 \(O(1)\)

  • 陷阱:检查 window_counts[char] == need[char](不是 >=)确保我们对每个字符恰好递增 have 一次。如果使用 >=,会过度计数。


模式:前缀和

  • 前缀和 array 存储累积和:prefix[i] = sum(arr[0:i])。在 \(O(n)\) 时间内构建后,任意子数组的和可以在 \(O(1)\) 内计算:sum(arr[l:r]) = prefix[r] - prefix[l]
def build_prefix(arr):
    prefix = [0] * (len(arr) + 1)
    for i in range(len(arr)):
        prefix[i + 1] = prefix[i] + arr[i]
    return prefix

# arr[l:r] 的和(包含 l,不含 r)
def range_sum(prefix, l, r):
    return prefix[r] - prefix[l]
  • 何时使用:问题涉及多个子数组和查询,或找到特定和的子数组。

简单:区间和查询

  • 问题:给定一个 array,回答多个"从索引 \(l\)\(r\) 的和是多少?"的查询。

  • 没有前缀和:每个查询是 \(O(n)\)。有前缀和:\(O(n)\) 预处理,然后每个查询 \(O(1)\)

中等:和为 K 的子数组

  • 问题:计算和为 \(k\) 的连续子数组的数量。

  • 模式洞察:从索引 \(l\)\(r\) 的子数组和等于 prefix[r+1] - prefix[l]。我们希望这等于 \(k\),所以 prefix[l] = prefix[r+1] - k。对于每个位置,使用 hash map 计算有多少早期前缀和等于 current_prefix - k

def subarray_sum(nums, k):
    count = 0
    prefix = 0
    prefix_counts = {0: 1}  # 空前缀和

    for num in nums:
        prefix += num
        # 有多少早期前缀和等于 prefix - k?
        count += prefix_counts.get(prefix - k, 0)
        prefix_counts[prefix] = prefix_counts.get(prefix, 0) + 1

    return count
  • 这结合了前缀和与 hash map 查找:\(O(n)\) 时间,\(O(n)\) 空间。

  • 陷阱:忘记初始化 prefix_counts = {0: 1}。空前缀(任何元素之前)的和为 0。没有这个,你会漏掉从索引 0 开始的子数组。

困难:除自身以外 array 的乘积

  • 问题:给定一个 array,返回一个 array,其中每个元素是所有其他元素的乘积。不能使用除法。

  • 模式:从左侧构建前缀乘积,从右侧构建后缀乘积。每个位置的答案是 left_product * right_product

def product_except_self(nums):
    n = len(nums)
    result = [1] * n

    # 左侧遍历:result[i] = nums[0..i-1] 的乘积
    prefix = 1
    for i in range(n):
        result[i] = prefix
        prefix *= nums[i]

    # 右侧遍历:乘以 nums[i+1..n-1] 的乘积
    suffix = 1
    for i in range(n - 1, -1, -1):
        result[i] *= suffix
        suffix *= nums[i]

    return result
  • \(O(n)\) 时间,\(O(1)\) 额外空间(输出 array 不算)。这使用输出 array 本身存储中间前缀乘积,然后在第二次遍历中乘以后缀乘积。

  • 陷阱:如果 array 包含零,基于除法的方法会失败。这种前缀/后缀方法能正确处理零,因为它从不执行除法。


常见陷阱汇总

陷阱 示例 修复方法
窗口大小差一 right - left vs right - left + 1 画出 2 元素的例子
Python 中的可变默认参数 def f(seen={}) 在调用间共享状态 使用 def f(seen=None)
循环中的字符串拼接 s += c 在 Python 中是 \(O(n^2)\) 使用 list.append + "".join"
在前缀和中忘记 {0: 1} 漏掉从索引 0 开始的子数组 始终用空前缀初始化
检查前先加入 hash map Two Sum:在检查补数之前添加 num 先检查,再插入
不处理重复 Three Sum 返回重复三元组 跳过连续相等的值
整数溢出 C++/Java 中大 array 的和 使用 long 或检查边界

课后练习(NeetCode)

按顺序练习这些题目。每道题都强化本文中的一种模式。

Hash Map 查找

双指针

滑动窗口

前缀和