数组与哈希 数组(array)和哈希表(hash table)是编程中最基础的两种数据结构。本文件先讲清它们的底层原理,再通过难度递增的题目构建出关键解题模式:双指针、滑动窗口、前缀和以及基于哈希的查找,并在每一步指出常见陷阱。 如果你深刻理解了数组和哈希表,就能解决约 40% 的编程面试题。这两种结构无处不在,因为它们恰好提供了算法最需要的两样东西:快速的按下标访问(数组)和按键快速查找(哈希表)。 本文件教的是模式,而不是解法。目标是当你看到一道新题时,能认出该用哪个模式、为什么用,而不是试图回忆某个背下来的答案。 数组 数组是一块连续的内存,元素按固定偏移量存放。访问第 $i$ 个元素只需 $O(1)$,因为地址就是 。这是数据访问能达到的最快速度,也是数组成为默认选择的原因。
数组(array)和哈希表(hash table)是编程中最基础的两种数据结构。本文件先讲清它们的底层原理,再通过难度递增的题目构建出关键解题模式:双指针、滑动窗口、前缀和以及基于哈希的查找,并在每一步指出常见陷阱。
如果你深刻理解了数组和哈希表,就能解决约 40% 的编程面试题。这两种结构无处不在,因为它们恰好提供了算法最需要的两样东西:快速的按下标访问(数组)和按键快速查找(哈希表)。
本文件教的是模式,而不是解法。目标是当你看到一道新题时,能认出该用哪个模式、为什么用,而不是试图回忆某个背下来的答案。
数组是一块连续的内存,元素按固定偏移量存放。访问第 i 个元素只需 O(1),因为地址就是 base + i * element_size。这是数据访问能达到的最快速度,也是数组成为默认选择的原因。
动态数组(dynamic array)(Python 的 list、Java 的 ArrayList、C++ 的 vector)在满的时候会自动扩容。策略是均摊倍增(amortised doubling):数组满时,分配一个两倍大的新数组,把所有元素拷过去。拷贝本身是 O(n),但它发生得极少(每 n 次插入才一次),所以每次插入的均摊成本是 O(1)。
**缓存局部性(cache locality)**是数组在实践中(而不仅仅是理论上)很快的原因。因为元素连续存放,访问一个元素会把附近的元素一起加载进 CPU 缓存(第 13 章)。遍历数组是缓存友好的,而沿着链表的指针跳来跳去则不是。这个常数因子的差距在实践中可以达到 10-100 倍。
| 操作 | 数组 | 动态数组 |
|---|---|---|
| 按下标访问 | O(1) | O(1) |
| 追加 | 不适用 | O(1) 均摊 |
| 在位置 i 插入 | O(n) | O(n) |
| 在位置 i 删除 | O(n) | O(n) |
| 查找(未排序) | O(n) | O(n) |
# 坏: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,这意味着你可以用一个定长数组代替哈希表。
哈希表把键映射到值,平均情况下查找、插入、删除都是 O(1)。它通过一个哈希函数(hash function) h(key) 把键转成数组下标。
哈希函数必须满足:确定性(同一个键永远得到同一个哈希值)、均匀(把键均匀分散到各个桶里)、计算快。
当两个不同的键哈希到同一个下标时就会发生冲突(collision)。两种主要应对策略:
链地址法(chaining):每个桶存一个键值对的链表。冲突时追加到链表。最坏情况(所有键都哈希到同一个桶):O(n)。哈希函数良好时的平均情况:O(1)。
开放寻址法(open addressing):冲突时探测下一个空位。线性探测(linear probing)依次检查下一个、再下一个……它对缓存友好,但容易产生聚集(clustering)(长串连续占用的位置)。Robin Hood 哈希通过让「离家近」的条目抢占「离家远」的条目来降低方差。
装填因子(load factor) \alpha = n / m(条目数 / 桶数)决定了性能。当 \alpha 超过阈值(通常是 0.75)时,表会重哈希(rehash):分配一张更大的表,把所有元素重新插入。这要花 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 个哈希函数和一个位数组。常用于数据库(避免对不存在的键读盘)、网页缓存和拼写检查。
什么时候该用哈希表:每当你需要在 O(1) 内回答「我之前见过这个吗?」或「这个键对应的计数/下标/值是多少?」时。如果你正在反复做线性扫描找东西,哈希表几乎肯定能让它变快。
题目:给定一个整数数组和目标值,返回两个加起来等于目标值的元素的下标。
暴力 O(n^2):检查每一对。
模式洞见:对每个数 num,你需要 target - num 在数组某处存在。与其扫描数组找它,不如把已经见过的数存进哈希表。
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
为什么有效:只扫一遍数组。对每个元素,哈希表查找是 O(1)。总计:O(n) 时间,O(n) 空间。
陷阱:不要在检查互补数之前就把当前数加进哈希表,否则可能把一个元素和它自己匹配。上面代码里的顺序是对的:先查再插。
题目:给定一组字符串,把字母异位词分到一起。("eat"、"tea"、"ate" 是一组。)
模式洞见:字母异位词含有相同的字符,只是顺序不同。如果你对每个字符串排序,异位词会得到同样的排序结果。用这个排序结果作为哈希表的键。
from collections import defaultdict def group_anagrams(strs): groups = defaultdict(list) for s in strs: key = tuple(sorted(s)) # 或用字符计数元组 groups[key].append(s) return list(groups.values())
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)。字符计数元组是一种规范形式(canonical form):对同一组里的所有成员,这个表示都相同。
陷阱:在 Python 里,list 不可哈希(不能作为 dict 的键)。你必须转成 tuple。很多人在写 groups[count].append(s) 时就栽在这上面。
题目:给定一个未排序数组,找出最长连续序列的长度(例如 [100, 4, 200, 1, 3, 2] → 4,因为 [1, 2, 3, 4])。
暴力 O(n \log n):排序数组,再扫描连续段。
模式洞见:把所有数放进哈希集合以便 O(1) 查找。对每个数,检查它是不是一个序列的起点(即 num - 1 不在集合里)。如果是,就数这个序列能延伸多远。
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] 会从每个起点都扫描整条序列)。
**双指针(two pointers)**模式用两个下标在数组里移动,通常从两端出发或从同一端以不同速度出发。当数组已排序,或需要比较数对时,它很管用。
何时使用:问题涉及数对、子数组或划分,且数组已排序(或排序后不会丢失所需信息)。
题目:判断一个字符串是否是回文(只考虑字母数字字符,忽略大小写)。
模式:一个指针在开头,一个在末尾。向中间靠拢,逐字符比较。
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
left < right 检查。没有它,遇到像 "!!!"(全是非字母数字)这样的字符串,指针会越界。题目:找出数组中所有和为零且互不相同的三元组。
模式:排序数组。固定一个元素,再在剩余部分用双指针找和等于该固定元素相反数的数对。
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 这一行至关重要。
题目:给定一个高度图(非负整数数组),计算下雨后能接住多少水。
模式洞见:对每个位置,水位由「其左侧最大高度」和「右侧最大高度」中的较小值减去当前高度决定。两端的双指针分别追踪这两个不断更新的最大值。
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 限定(我们知道右侧有更高的柱子,所以右侧不会是瓶颈)。我们处理较矮的一侧,保证另一侧有更高的柱子。
陷阱:很多人会先预计算 left_max[i] 和 right_max[i] 数组(可行,但占 O(n) 空间)。双指针做法做到了 O(1) 空间。另外,把更新最大值时的 >= 写成 > 会导致接水量差一。
**滑动窗口(sliding window)**模式维护一个窗口(连续子数组),在遍历时不断扩张和收缩。适用于询问满足某条件的子数组或子串的问题。
何时使用:问题要求满足某约束的最长/最短子数组或子串,且窗口的扩张/收缩是单调的(加元素只会让约束更难/更易满足,而非两者皆有)。
模板:
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
题目:找出不含任何重复字符的最长子串的长度。
模式:移动 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:这个字符可能是在当前窗口开始之前就进了字典。没有这个检查,你会为一个其实不在当前窗口里的字符错误地收缩窗口。
陷阱:用集合并从左边逐个移除字符是对的,但更慢。哈希表做法直接跳到正确位置。
题目:给定字符串 s 和 t,找出 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) # (长度, left, right) 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 字典和 need 比较,每次 O(|\text{不同字符数}|)。have 计数器让合法性检查变成 O(1)。
陷阱:检查 window_counts[char] == need[char](而不是 >=)确保我们对每个字符恰好增加一次 have。如果用 >=,就会多算。
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] 的和(左闭右开) def range_sum(prefix, l, r): return prefix[r] - prefix[l]
题目:给定一个数组,回答多次「下标 l 到 r 的和是多少?」的查询。
没有前缀和:每次查询 O(n)。有前缀和:O(n) 预处理,之后每次查询 O(1)。
题目:统计和为 k 的连续子数组的个数。
模式洞见:从下标 l 到 r 的子数组和等于 prefix[r+1] - prefix[l]。我们希望它等于 k,所以 prefix[l] = prefix[r+1] - k。对每个位置,用哈希表统计有多少个更早的前缀和等于 当前前缀和 - 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
这把前缀和与哈希表查找结合在一起:O(n) 时间,O(n) 空间。
陷阱:忘了初始化 prefix_counts = {0: 1}。空前缀(任何元素之前)的和是 0。没有它,你会漏掉从下标 0 开始的子数组。
题目:给定一个数组,返回一个新数组,其中每个元素等于其余所有元素的乘积。不能使用除法。
模式:从左边构造前缀乘积,从右边构造后缀乘积。每个位置的答案就是 左乘积 * 右乘积。
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) 额外空间(输出数组不计)。它复用输出数组来存中间的前缀乘积,再在第二趟里乘进后缀乘积。
陷阱:如果数组里有零,基于除法的做法会失败。这种前缀/后缀做法能正确处理零,因为它从不做除法。
| 陷阱 | 例子 | 修复 |
|---|---|---|
| 窗口大小差一 | 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 开始的子数组 | 永远用空前缀初始化 |
| 先插哈希表再检查 | 两数之和:先加 num 再查互补数 |
先查再插 |
| 没处理重复 | 三数之和返回重复三元组 | 跳过连续相等的值 |
| 整数溢出 | C++/Java 中大数组的求和 | 用 long 或检查范围 |
按顺序练习以下题目,每道题都巩固了本文件里的一个模式。可在 NeetCode 的题目列表中找到这些题目。