翻译自英文原版 maths-cs-ai-compendium,共 20 章全部完成。 第01章 向量 | 第02章 矩阵 | 第03章 微积分 第04章 统计学 | 第05章 概率论 | 第06章 机器学习 第07章 计算语言学 | 第08章 计算机视觉 | 第09章 音频与语音 第10章 多模态学习 | 第11章 自主系统 | 第12章 图神经网络 第13章 计算与操作系统 | 第14章 数据结构与算法 第15章 生产级软件工程 | 第16章 SIMD与GPU编程 第17章 AI推理 | 第18章 ML系统设计 第19章 应用人工智能 | 第20章 前沿人工智能 翻译说明: - 所有数学公式 $...$ / $$...$$、代码块、图片引用完整保留 - mkdocs.yml 配置中文导航 + language: zh - README.md 已翻译为中文(兼 docs/index.md) - docs/ 目录包含指向各章文件的 symlink - 约 29,000 行中文内容,排除 .cache/ 构建缓存
22 KiB
数组与哈希
数组和哈希表是编程中最基础的两种数据结构。本文件涵盖它们底层的运行机制,然后构建关键的问题解决模式:双指针、滑动窗口、前缀和以及基于哈希的查找,通过逐步增加难度的题目,并在每一步指出常见陷阱。
-
如果你深入理解数组和哈希表,你可以解决约40%的编码面试题。这两种结构无处不在,因为它们提供了算法最需要的两样东西:快速索引访问(数组)和按键快速查找(哈希表)。
-
本文件教授的是模式,而非解法。目标是当你看到一个新问题时,你能识别出适用哪个模式以及为什么,而不是试图回忆一个背下来的解法。
数组
-
数组是一片连续的内存块,元素以固定偏移量存储。访问元素
i的成本是 $O(1)$,因为地址就是base + i * element_size。这是最快的数据访问方式,也是数组成为默认选择的原因。 -
动态数组(Python 的
list、Java 的ArrayList、C++ 的vector)在满时自动增长。其策略是平摊加倍:当数组满时,分配一个两倍大小的新数组并将所有元素复制过去。复制成本为 $O(n)$,但这种情况很少发生(每n次插入一次),所以每次插入的平摊成本是 $O(1)$。 -
缓存局部性是数组在实践中很快的原因,而不仅仅是理论上。因为元素是连续存储的,访问一个元素会将其邻近元素加载到 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)$,因为所有后续元素都必须移动。如果你需要频繁在中间插入,考虑使用链表或其他方法。
字符串
- 字符串是一个字符数组。在 Python 中,字符串是不可变的:每次拼接都会创建一个新的字符串。在循环中逐字符构建字符串是 $O(n^2)$,因为每次拼接都会复制到目前为止的整个字符串。
# 不好:O(n^2) 字符串拼接
s = ""
for c in characters:
s += c # 每次复制整个字符串
# 好:O(n) 使用列表然后 join
parts = []
for c in characters:
parts.append(c)
s = "".join(parts)
-
陷阱:在 Python 中,循环内的
s += c是最常见的性能 bug 之一。始终先收集到列表中再.join()。 -
编码:ASCII 使用 7 位(128 个字符)。UTF-8 是可变长度的:ASCII 字符使用 1 字节,带重音字符使用 2 字节,中文/日文字符使用 3 字节,表情符号使用 4 字节。当问题说"小写英文字母"时,字母表大小为 26,这意味着你可以使用固定大小的数组而不是哈希表。
哈希表
-
哈希表将键映射到值,平均情况下的查找、插入和删除都是 $O(1)$。它通过计算一个哈希函数
h(key)将键转换为数组索引来实现。 -
哈希函数必须:确定性的(相同键总是得到相同哈希值)、均匀的(将键均匀分布到各个桶中)且计算速度快。
-
冲突发生在两个不同的键哈希到相同的索引时。有两种主要策略:
-
链地址法:每个桶存储一个键值对链表。发生冲突时,追加到链表。最坏情况(所有键哈希到同一个桶):$O(n)$。使用好的哈希函数时的平均情况:$O(1)$。
-
开放地址法:发生冲突时,探测下一个空槽。线性探测检查下一个槽位,然后再下一个,以此类推。它缓存友好,但会遭受聚集问题(长串的已占用槽位)。罗宾汉哈希通过将"离家较近"的条目移位来减少方差。
-
-
负载因子 $\alpha = n / m$(元素数 / 桶数)决定了性能。当
\alpha超过阈值(通常为 0.75)时,表会重新哈希:分配一个更大的表并重新插入所有元素。这需要O(n)时间,但不常发生。 -
哈希映射(Python 中的
dict、Java 中的HashMap)存储键值对。哈希集合(Python 中的set、Java 中的HashSet)只存储键(用于快速成员测试)。
| 操作 | 平均 | 最坏情况 |
|---|---|---|
| 查找 | O(1) |
O(n) |
| 插入 | O(1) |
O(n) |
| 删除 | O(1) |
O(n) |
-
布隆过滤器是空间高效的概率性集合。它可以告诉你"肯定不在集合中"或"可能在集合中"(具有可调的假阳性率)。它使用
k个哈希函数和一个位数组。用于数据库(避免对不存在的键进行磁盘读取)、Web 缓存和拼写检查器。 -
何时使用哈希表:每当你需要用
O(1)的时间回答"我之前见过这个吗?"或"与这个键关联的计数/索引/值是什么?"时。如果你正在反复进行线性扫描寻找某物,哈希表几乎总能使其更快。
模式:哈希表查找
- 最基本的模式:使用哈希表将
O(n)扫描替换为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())
- 优化:对每个字符串排序需要 $O(k \log k)$,其中
k是字符串长度。为了更快的键,统计字符频率并使用计数元组作为键:
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)$。字符计数元组是一种规范形式:对组内所有成员都相同的表示。 -
陷阱:在 Python 中,列表不可哈希(不能用作字典键)。你必须转换为元组。当人们尝试
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] 会从每个起点扫描整个序列)。
模式:双指针
-
双指针模式使用两个索引在数组中移动,通常从两端向中间或从同端以不同速度移动。它在数组已排序或需要比较成对元素时有效。
-
何时使用:问题涉及成对、子数组或分区,并且数组已排序(或可在不丢失所需信息的情况下排序)。
简单:验证回文串
-
问题:判断一个字符串是否是回文串,只考虑字母数字字符并忽略大小写。
-
模式:一个指针在开头,一个在结尾。向中间移动,比较字符。
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检查。没有它,在像 "!!!"(全部非字母数字)这样的字符串上指针可能越界。
中等:三数之和
-
问题:找出数组中所有唯一的三元组,使其和为零。
-
模式:对数组排序。固定一个元素,然后在剩余部分使用双指针找到和为固定元素相反数的对。
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)空间。另外,在最大值更新中混淆>=和>会导致差一错误的水量计算。
模式:滑动窗口
-
滑动窗口模式维护一个窗口(连续子数组),随着迭代扩展和收缩。它适用于询问满足某个条件的子数组或子串的问题。
-
何时使用:问题要求满足约束条件的最长/最短子数组或子串,且扩展/收缩窗口是单调的(添加元素只能使约束更难/更容易满足,而不是两者兼有)。
-
模板:
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:该字符可能来自当前窗口开始之前的映射。没有这个检查,你会错误地为当前窗口中实际不存在的字符收缩窗口。 -
陷阱:使用集合并从左逐个删除字符是正确的但较慢。哈希表方法直接跳到正确的位置。
困难:最小覆盖子串
-
问题:给定字符串
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) # (长度, 左, 右)
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{unique chars}|)$。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] 的和(包含 l,不包含 r)
def range_sum(prefix, l, r):
return prefix[r] - prefix[l]
- 何时使用:问题涉及多个子数组和查询,或寻找具有特定和的子数组。
简单:区间求和查询
-
问题:给定一个数组,回答多个"从索引
l到r的和是多少?"的查询。 -
没有前缀和:每个查询是 $O(n)$。有前缀和:
O(n)预计算,然后每个查询 $O(1)$。
中等:和为 K 的子数组
-
问题:统计有多少个连续子数组的和等于 $k$。
-
模式洞察:从索引
l到r的子数组和等于prefix[r+1] - prefix[l]。我们希望这个值等于 $k$,所以prefix[l] = prefix[r+1] - k。对于每个位置,使用哈希表统计多少个更早的前缀和等于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
-
这结合了前缀和与哈希表查找:
O(n)时间,O(n)空间。 -
陷阱:忘记初始化
prefix_counts = {0: 1}。空前缀(在任何元素之前)的和为 0。没有它,你会漏掉从索引 0 开始的子数组。
困难:除自身以外数组的乘积
-
问题:给定一个数组,返回一个数组,其中每个元素是所有其他元素的乘积。你不能使用除法。
-
模式:从左侧构建前缀乘积,从右侧构建后缀乘积。每个位置的答案是
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)额外空间(输出数组不计入)。它使用输出数组本身来存储中间前缀乘积,然后在第二遍遍历中乘入后缀乘积。 -
陷阱:如果数组包含零,基于除法的方法会失败。这种前缀/后缀方法正确处理零,因为它从不做除法。
常见陷阱总结
| 陷阱 | 示例 | 修复 |
|---|---|---|
| 窗口大小的差一错误 | 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)
按顺序练习。每道题强化本文件中的一个模式。
哈希表查找
- Contains Duplicate — 热身:哈希集判断是否见过
- Two Sum — 补数查找
- Group Anagrams — 规范形式作为键
- Top K Frequent Elements — 哈希表 + 桶排序
- Longest Consecutive Sequence — 哈希集配合序列起点技巧
- Encode and Decode Strings — 设计序列化方案
双指针
- Valid Palindrome — 向内指针
- Two Sum II (sorted) — 排序数组上的双指针
- Three Sum — 固定 + 双指针 + 去重
- Container With Most Water — 贪心双指针
- Trapping Rain Water — 带运行最大值的双指针
滑动窗口
- Best Time to Buy and Sell Stock — 退化窗口
- Longest Substring Without Repeating Characters — 扩展/收缩配合哈希表
- Longest Repeating Character Replacement — 窗口 + 最大频率技巧
- Minimum Window Substring — 扩展到有效,收缩到最小
前缀和
- Product of Array Except Self — 前缀/后缀乘积