数组、字符串与哈希结构数组、字符串与哈希结构
统一理解连续存储、映射关系与常见预处理策略。统一理解连续存储、映射关系与常见预处理策略。
专题导读
数组提供连续、可随机访问的存储;字符串可视为受限的字符数组;哈希表则用额外空间换取快速映射。面试中的大量题目,本质都是选择合适的下标含义、预处理方式或键值映射。数组提供连续、可随机访问的存储;字符串可视为受限的字符数组;哈希表则用额外空间换取快速映射。实践中的大量题目,本质都是选择合适的下标含义、预处理方式或键值映射。
学完本章,你应该能够
- 理解数组、字符串和哈希表的操作成本与适用边界理解数组、字符串和哈希表的操作成本与适用边界
- 熟练识别前缀和、差分、计数与索引映射模式熟练识别前缀和、差分、计数与索引映射模式
- 正确设计哈希键并处理重复、冲突和缺失状态正确设计哈希键并处理重复、冲突和缺失状态
- 在原地修改与额外空间之间做出明确权衡在原地修改与额外空间之间做出明确权衡
数据结构模型与操作成本数据结构模型与操作成本
数组擅长按位置访问,哈希表擅长按键定位。选择的关键不是哪个更快,而是问题中的关系更接近“位置”还是“映射”。数组擅长按位置访问,哈希表擅长按键定位。选择的关键不是哪个更快,而是问题中的关系更接近“位置”还是“映射”。
| 操作操作 | 数组 / 字符串数组 / 字符串 | 哈希表哈希表 | 说明说明 |
|---|---|---|---|
| 按位置访问按位置访问 | O(1)O(1) | 不适用不适用 | 数组地址可由基址和下标计算数组地址可由基址和下标计算 |
| 按值查找按值查找 | O(n)O(n) | 平均 O(1)平均 O(1) | 哈希表需额外 O(n) 空间哈希表需额外 O(n) 空间 |
| 末尾追加末尾追加 | 均摊 O(1)均摊 O(1) | 平均 O(1)平均 O(1) | 动态数组可能触发扩容动态数组可能触发扩容 |
| 中间插入/删除中间插入/删除 | O(n)O(n) | 平均 O(1)平均 O(1) | 数组需要移动后续元素数组需要移动后续元素 |
| 保持顺序遍历保持顺序遍历 | 天然支持天然支持 | 取决于实现取决于实现 | 不要默认所有 Map 都无序或有序不要默认所有 Map 都无序或有序 |
面试判断实践判断
需要保留顺序、按下标处理时优先数组;需要判断存在性、统计频次、建立值到位置映射时优先 Set / Map。需要保留顺序、按下标处理时优先数组;需要判断存在性、统计频次、建立值到位置映射时优先 Set / Map。
四类高频预处理模式四类高频预处理模式
预处理的本质是把重复计算提前完成,用 O(n) 空间换取后续查询的 O(1) 或近似 O(1)。预处理的本质是把重复计算提前完成,用 O(n) 空间换取后续查询的 O(1) 或近似 O(1)。
前缀和前缀和
prefix[i] 表示前 i 个元素的和,区间 [l, r] 的和为 prefix[r+1] - prefix[l]。适合静态数组的高频区间查询。prefix[i] 表示前 i 个元素的和,区间 [l, r] 的和为 prefix[r+1] - prefix[l]。适合静态数组的高频区间查询。
差分数组差分数组
对区间 [l, r] 增加 x,只修改 diff[l] += x 与 diff[r+1] -= x,最后做一次前缀和还原。对区间 [l, r] 增加 x,只修改 diff[l] += x 与 diff[r+1] -= x,最后做一次前缀和还原。
计数数组计数数组
当值域较小且明确时,用数组下标代替哈希键,常数更小且顺序稳定。当值域较小且明确时,用数组下标代替哈希键,常数更小且顺序稳定。
哈希映射哈希映射
建立值→频次、值→下标或状态→首次位置的映射,把查找从 O(n) 降到平均 O(1)。建立值→频次、值→下标或状态→首次位置的映射,把查找从 O(n) 降到平均 O(1)。
public final class RangeSum {
private final long[] prefix;
public RangeSum(int[] nums) {
this.prefix = new long[nums.length + 1];
for (int i = 0; i < nums.length; i++) {
prefix[i + 1] = prefix[i] + nums[i];
}
}
public long query(int left, int right) {
if (left < 0 || right >= prefix.length - 1 || left > right) {
throw new IllegalArgumentException("invalid range");
}
// [left, right] = 前 right+1 个 - 前 left 个
return prefix[right + 1] - prefix[left];
}
}
// 预处理 O(n),单次查询 O(1),空间 O(n)额外设置 prefix[0] = 0 可以统一 left = 0 的边界,避免在查询函数中写分支。
哈希键与状态设计哈希键与状态设计
哈希题真正困难的部分通常不是使用 Map,而是决定“什么状态值得作为键”。哈希题真正困难的部分通常不是使用 Map,而是决定“什么状态值得作为键”。
两数之和使用“值 → 下标”,因为遍历到 x 时要查询 target - x 是否出现;最长和为 k 的子数组使用“前缀和 → 最早下标”,因为需要最大化区间长度;字母异位词可使用排序结果或频次数组作为规范化键。两数之和使用“值 → 下标”,因为遍历到 x 时要查询 target - x 是否出现;最长和为 k 的子数组使用“前缀和 → 最早下标”,因为需要最大化区间长度;字母异位词可使用排序结果或频次数组作为规范化键。
存在性存在性
只关心是否出现时用 Set<T>,语义比 Map<T, Boolean> 更直接。只关心是否出现时用 Set<T>,语义比 Map<T, Boolean> 更直接。
频次频次
Map<T, Integer> 记录出现次数,优先使用 merge(key, 1, Integer::sum) 或 getOrDefault。Map<T, Integer> 记录出现次数,优先使用 merge(key, 1, Integer::sum) 或 getOrDefault。
位置位置
保存首次位置还是最近位置取决于目标:最长区间通常保留最早位置,最短区间通常更新最近位置。保存首次位置还是最近位置取决于目标:最长区间通常保留最早位置,最短区间通常更新最近位置。
复合状态复合状态
Java 数组默认使用引用语义,不适合直接作为 HashMap 键;优先定义不可变 record,或使用稳定编码和嵌套 Map。Java 数组默认使用引用语义,不适合直接作为 HashMap 键;优先定义不可变 record,或使用稳定编码和嵌套 Map。
import java.util.HashMap;
import java.util.Map;
public final class TwoSum {
private TwoSum() {
}
public static int[] solve(int[] nums, int target) {
Map<Integer, Integer> indexByValue = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
int complement = target - nums[i];
Integer matchedIndex = indexByValue.get(complement);
if (matchedIndex != null) {
return new int[]{matchedIndex, i};
}
// 先查询再写入,避免同一元素被使用两次
indexByValue.put(nums[i], i);
}
return new int[0];
}
}HashMap.get 在键不存在时返回 null,因此这里使用 Integer 接收并显式判空;如果 null 本身可能是合法值,则应改用 containsKey。
原地修改与读写指针原地修改与读写指针
原地数组题通常维护一个尚未处理区间,并用写指针表示下一个有效位置。关键是先定义每个区间的语义。原地数组题通常维护一个尚未处理区间,并用写指针表示下一个有效位置。关键是先定义每个区间的语义。
循环不变量循环不变量
例如 [0, write) 始终是已整理好的有效元素,[read, n) 是尚未扫描区域。例如 [0, write) 始终是已整理好的有效元素,[read, n) 是尚未扫描区域。
覆盖安全覆盖安全
写指针不应超过读指针,否则可能覆盖尚未读取的数据。写指针不应超过读指针,否则可能覆盖尚未读取的数据。
返回口径返回口径
题目可能只要求返回新长度,尾部残留值无需清空;先确认接口约定。题目可能只要求返回新长度,尾部残留值无需清空;先确认接口约定。
常见误区
前缀和下标错一位前缀和下标错一位
推荐定义 prefix 长度为 n+1,prefix[i] 表示前 i 个元素,闭区间 [l,r] 统一用 prefix[r+1]-prefix[l]。推荐定义 prefix 长度为 n+1,prefix[i] 表示前 i 个元素,闭区间 [l,r] 统一用 prefix[r+1]-prefix[l]。
混淆 HashMap.get 返回的 null混淆 HashMap.get 返回的 null
get 返回 null 可能表示键不存在,也可能表示键映射到 null;需要区分时使用 containsKey,计数场景使用 getOrDefault。get 返回 null 可能表示键不存在,也可能表示键映射到 null;需要区分时使用 containsKey,计数场景使用 getOrDefault。
数组直接作为 HashMap 键数组直接作为 HashMap 键
Java 数组没有基于内容的 equals/hashCode;应包装为不可变值对象、使用 List,或生成稳定编码。Java 数组没有基于内容的 equals/hashCode;应包装为不可变值对象、使用 List,或生成稳定编码。
忽略装箱与内存成本忽略装箱与内存成本
HashMap<Integer, Integer> 会产生装箱对象和较高常数开销;值域较小时优先考虑 int[] 计数。HashMap<Integer, Integer> 会产生装箱对象和较高常数开销;值域较小时优先考虑 int[] 计数。
面试追问延伸思考
数组和链表的核心差异是什么?数组和链表的核心差异是什么?
数组连续存储,可 O(1) 随机访问,但中间插删需移动元素;链表节点分散,通过指针连接,已知节点时插删 O(1),但按下标访问需 O(n),且缓存局部性更差。数组连续存储,可 O(1) 随机访问,但中间插删需移动元素;链表节点分散,通过指针连接,已知节点时插删 O(1),但按下标访问需 O(n),且缓存局部性更差。
什么时候用前缀和,什么时候用滑动窗口?什么时候用前缀和,什么时候用滑动窗口?
前缀和适合静态数组上的多次任意区间查询,也能结合哈希处理包含负数的区间和;滑动窗口要求左右边界能单调移动,通常依赖元素非负或窗口条件具有单调性。前缀和适合静态数组上的多次任意区间查询,也能结合哈希处理包含负数的区间和;滑动窗口要求左右边界能单调移动,通常依赖元素非负或窗口条件具有单调性。
哈希表为什么平均是 O(1)?哈希表为什么平均是 O(1)?
哈希函数把键映射到桶,负载因子受控且分布均匀时,每个桶期望元素数为常数;冲突严重或遭遇恶意输入时仍可能退化。哈希函数把键映射到桶,负载因子受控且分布均匀时,每个桶期望元素数为常数;冲突严重或遭遇恶意输入时仍可能退化。