cswork / 模板打开 500 路题单

第 02 站 · 线性结构

哈希计数与关系建模

边扫边查的哈希表 时间 O(n) · 空间 O(n)

周赛难度分 难度分 1309–难度分 1738· 中位 难度分 1532(8/19 道题有 zerotrac 周赛分)

打开 500 路题单 · 第 02 站 · 19 题
先学
学完去
面试频次
8 家公司考过 · 面经出现 11 次Amazon 2、MathWorks 2、Meta 2、Aeonea 1、Agoda 1

先做这道

LC 1两数之和简单
卡住再看

边扫描边查询补数,区分先查询再写入以免重复使用同一下标。

要点

  1. 先查补数、再存自己:一遍扫描就能找到「之前出现过」的配对,也不会把同一个元素用两次。
  2. 存什么取决于问什么:要位置就存下标,要个数就存次数,要最早出现的位置就只在第一次出现时存。
  3. 键可以组合出来:排序后的字符串、计数元组、(行, 值) 对,都能把「同一类」放进同一个桶。

易错点

  • 先插入再查询,target 恰好等于 2x 时会把同一个元素和自己配成一对。
  • C++ 的 map[key] 读一个不存在的键会插入默认值;只想判断存在时用 find 或 count。
  • Java 里两个 Integer 用 == 比较,超过 127 时可能不相等;用 equals 或先拆箱成 int。

看动画

一步一步播放这个模式在小例子上的执行过程。可以暂停、单步前进或后退,拖动进度条跳到任意一步。

交互演示 · 逐步观察

哈希表 · 拉链法与扩容

依次插入 12, 7, 4, 15, 9, 20, 3

换个输入
∅[0]
∅[1]
∅[2]
∅[3]
当前处理已发现 / 已计算答案
键数
0
桶数
4
负载因子
0.00
扩容次数
0

4 个空桶。键 k 放进第 k mod 桶数 号桶;同一个桶里的键串成链表(拉链法)。

始终成立键 k 总在第 k mod 桶数 号桶里;负载因子不超过 0.75,链表的平均长度就是常数。

模板

选一种语言展开。四种写法实现的是同一个模板,每一份都编译运行过,并用测试数据核对了输出。看完用它重写热身题 LC 1。

Python
def two_sum(nums: list[int], target: int) -> tuple[int, int]:
    seen: dict[int, int] = {}  # value -> index of an earlier element
    for i, x in enumerate(nums):
        if target - x in seen:  # look up before inserting: no self-pairing
            return seen[target - x], i
        seen[x] = i
    return -1, -1
C++
pair<int, int> twoSum(const vector<int>& nums, int target) {
    unordered_map<int, int> seen;  // value -> index of an earlier element
    for (int i = 0; i < (int)nums.size(); i++) {
        auto it = seen.find(target - nums[i]);  // find() never inserts, unlike []
        if (it != seen.end()) return {it->second, i};
        seen[nums[i]] = i;
    }
    return {-1, -1};
}
Java
class Solution {
    int[] twoSum(int[] nums, int target) {
        Map<Integer, Integer> seen = new HashMap<>(); // value -> index of an earlier element
        for (int i = 0; i < nums.length; i++) {
            Integer j = seen.get(target - nums[i]); // look up before inserting: no self-pairing
            if (j != null) return new int[] {j, i};
            seen.put(nums[i], i);
        }
        return new int[] {-1, -1};
    }
}
Go
func twoSum(nums []int, target int) (int, int) {
	seen := map[int]int{} // value -> index of an earlier element
	for i, x := range nums {
		if j, ok := seen[target-x]; ok { // look up before inserting: no self-pairing
			return j, i
		}
		seen[x] = i
	}
	return -1, -1
}

常见变体

这一站的题还会用到下面 2 种写法。展开看什么时候换用它,代码同样四种语言都跑过测试。

按规范键分组时间 O(n · L log L) · 空间 O(n · L)

要把「本质相同」的元素归到一起(字母异位词、移位字符串、同构):给每个元素算一个规范键,相同键进同一组。键可以是排序后的字母,也可以是 26 个计数拼成的字符串。LC 49 字母异位词分组、LC 249 移位字符串分组。

Python
def group_anagrams(words: list[str]) -> list[list[str]]:
    """Words grouped by anagram class, groups in order of first appearance."""
    index: dict[str, int] = {}  # canonical key -> position of its group
    groups: list[list[str]] = []
    for w in words:
        key = "".join(sorted(w))  # anagrams share their sorted letters
        if key not in index:
            index[key] = len(groups)
            groups.append([])
        groups[index[key]].append(w)
    return groups
C++
// Words grouped by anagram class, groups in order of first appearance.
vector<vector<string>> groupAnagrams(const vector<string>& words) {
    unordered_map<string, int> index;  // canonical key -> position of its group
    vector<vector<string>> groups;
    for (const string& w : words) {
        string key = w;
        sort(key.begin(), key.end());  // anagrams share their sorted letters
        auto [it, fresh] = index.try_emplace(key, (int)groups.size());
        if (fresh) groups.emplace_back();
        groups[it->second].push_back(w);
    }
    return groups;
}
Java
class Solution {
    // Words grouped by anagram class, groups in order of first appearance.
    List<List<String>> groupAnagrams(String[] words) {
        Map<String, List<String>> groups = new LinkedHashMap<>(); // canonical key -> its words, in first-seen order
        for (String w : words) {
            char[] letters = w.toCharArray();
            Arrays.sort(letters); // anagrams share their sorted letters
            groups.computeIfAbsent(new String(letters), k -> new ArrayList<>()).add(w);
        }
        return new ArrayList<>(groups.values());
    }
}
Go
import "sort"

// groupAnagrams returns the words grouped by anagram class, in order of first appearance.
func groupAnagrams(words []string) [][]string {
	index := map[string]int{} // canonical key -> position of its group
	var groups [][]string
	for _, w := range words {
		letters := []byte(w)
		sort.Slice(letters, func(i, j int) bool { return letters[i] < letters[j] })
		key := string(letters) // anagrams share their sorted letters
		at, ok := index[key]
		if !ok {
			at = len(groups)
			index[key] = at
			groups = append(groups, nil)
		}
		groups[at] = append(groups[at], w)
	}
	return groups
}
哈希集合:只从连续段的起点往后数时间 O(n) · 空间 O(n)

要在 O(n) 里找最长的连续整数段(LC 128),不能排序:把数放进集合,只有 x - 1 不在集合里时 x 才是段的起点,从起点往后数。每个数最多被数两次,所以是线性的。

Python
def longest_consecutive(nums: list[int]) -> int:
    """Length of the longest run of consecutive integers, in O(n)."""
    have = set(nums)
    best = 0
    for x in have:
        if x - 1 in have:
            continue  # not a run start: the run is counted from its start
        y = x
        while y + 1 in have:
            y += 1
        best = max(best, y - x + 1)
    return best
C++
// Length of the longest run of consecutive integers, in O(n).
int longestConsecutive(const vector<int>& nums) {
    unordered_set<int> have(nums.begin(), nums.end());
    int best = 0;
    for (int x : have) {
        if (have.count(x - 1)) continue;  // not a run start: the run is counted from its start
        int y = x;
        while (have.count(y + 1)) y++;
        best = max(best, y - x + 1);
    }
    return best;
}
Java
class Solution {
    // Length of the longest run of consecutive integers, in O(n).
    int longestConsecutive(int[] nums) {
        Set<Integer> have = new HashSet<>();
        for (int x : nums) have.add(x);
        int best = 0;
        for (int x : have) {
            if (have.contains(x - 1)) continue; // not a run start: the run is counted from its start
            int y = x;
            while (have.contains(y + 1)) y++;
            best = Math.max(best, y - x + 1);
        }
        return best;
    }
}
Go
// longestConsecutive returns the length of the longest run of consecutive integers, in O(n).
func longestConsecutive(nums []int) int {
	have := map[int]bool{}
	for _, x := range nums {
		have[x] = true
	}
	best := 0
	for x := range have {
		if have[x-1] {
			continue // not a run start: the run is counted from its start
		}
		y := x
		for have[y+1] {
			y++
		}
		best = max(best, y-x+1)
	}
	return best
}

为什么对

  1. 一遍扫描不会漏:任何一对答案 i < j,扫到 j 时 i 已经存进哈希表,所以一定在 j 处被查到;先查后存保证 j 不会和自己配对。
  2. 分组正确的条件是「键相同 ⇔ 属于同一类」。排序后的字符串、字母计数元组都满足这一点,所以每个桶里恰好是一类元素。
  3. 哈希表查找期望 O(1),所以整体 O(n);冲突很多时会退化,面试时可以提一句。

30 秒口述稿

面试官让你讲思路时,按做法、为什么对、复杂度的顺序说。中英文各一版,可以对着读几遍。

中文
我用哈希表把找配对从 O(n²) 降到 O(n)。从左往右扫,每到一个元素先查它需要的另一半是否已经出现过,再把它自己存进去。任意一对答案在扫到右边那个元素时,左边那个已经在表里,所以不会漏;先查后存也保证不会和自己配对。时间 O(n),空间 O(n)。
English
I use a hash map to turn the pair search from O(n²) into O(n). Scanning left to right, I first look up the complement of the current element, then store the element itself. For any valid pair, the left element is already in the map when I reach the right one, so nothing is missed, and looking up before storing prevents pairing an element with itself. That is O(n) time and O(n) space.

自评一下

对照上面的答案给自己打个分。没掌握的题会按 1、3、7、21 天的间隔回来,到期的题在「我的进度 · 到期复习」里。 只存在这台设备上;登录后会合并到你的账号。

对照要点(0/3)
这道题你会了吗?

小测

5 道概念题和复杂度题。答过的题会和 OA 选择题练习一起按间隔回来复习。 去 OA 选择题练习

5 道题
  1. 用链地址法的哈希表里有 n 个键、m 个桶,负载因子 α = n/m。假设哈希函数把键均匀打散,一次查找失败(键不存在)的期望代价是?
  2. 关于哈希表,下面哪些说法正确?(多选)
  3. 下面代码在最坏情况下的时间复杂度是?(n = len(a))
    def count_repeats(a):
        seen = []
        repeats = 0
        for x in a:
            if x in seen:
                repeats += 1
            else:
                seen.append(x)
        return repeats
  4. 下面代码的时间复杂度是?(n = len(a),取最紧的界)
    def has_pair(a, target):
        seen = set()
        for x in a:
            if target - x in seen:
                return True
            seen.add(x)
        return False
  5. 下面代码的时间复杂度是?(n = len(a),取最紧的界)
    def most_common(a):
        best = None
        for x in a:
            if best is None or a.count(x) > a.count(best):
                best = x
        return best

作答记录只保存在这台设备的浏览器里;登录后打开OA 选择题练习页,会存进账号。

模板练习

用上面的模板和变体出题:把打乱的行排回原序,把挖掉的边界条件填回去,或者在空白编辑器里整段默写,跑本站的测试并逐行对比。每次「换一题」都会重新出题。

下面几行被打乱了。点一行选中,再点另一行和它交换,排回模板原来的顺序;缩进是提示。

分档练习

本站在 500 路里的 19 道题,按基础、核心、进阶分三档。每题附一句话思路,先自己想,想不出再看。

基础 5

#题目一句话思路难度分
1两数之和边扫描边查询补数,区分先查询再写入以免重复使用同一下标。
219存在重复元素 II维护值的最近出现位置,用下标差判定局部重复。
409最长回文串用奇偶频次组织可构造回文的长度,区分中心与成对字符。
266回文排列 🔒把回文排列的存在性归结为奇数频次的数量。
1010总持续时间可被 60 整除的歌曲对余数互补分类计数,单独考虑余数零和中点自配对。难度分 1377

核心 13

#题目一句话思路难度分
49字母异位词分组为字符串构造稳定的等价类键,比较排序键与计数字段键。
128最长连续序列只从连续区间的起点向右扩展,证明哈希扫描的总工作量。
454四数相加 II把四数组组合拆成两组配对和,利用计数哈希降低枚举维度。
187重复的DNA序列固定长度子串去重与重复检测,比较直接字符串键和滚动编码。
249移位字符串分组 🔒用相邻字符模差表示移位等价类,正确处理环绕与单字符。
1679K 和数对的最大数目用互补值配对消耗频次,保证每个元素只能被使用一次。难度分 1346
2342数位和相等数对的最大和按数位和分组并维护组内前两大值,避免排序全部候选。难度分 1309
2364统计坏数对的数目把下标差与数值差条件变形成相同键计数,再由总对数取补集。难度分 1622
1814统计一个数组中好对子的数目把涉及反转数值的成对等式化为单元素键,统计相同键组合。难度分 1738
1657确定两个字符串是否接近区分字符集合相同与频次数组可置换两项必要条件。难度分 1530
554砖墙把跨越最少砖块转为内部缝隙位置的最大重合计数。
1930长度为 3 的不同回文子序列按两端字符确定区间,再对中间字符去重统计长度三回文。难度分 1533
2131连接两字母单词得到的最长回文串用反向单词配对并单独处理对称单词作为回文中心。难度分 1557

进阶 1

#题目一句话思路难度分
220存在重复元素 III在受限下标窗口内维护值域邻居,比较有序集合与桶划分。

Core Skills

不调库,亲手实现这一站用到的数据结构或算法,四种语言都能判题。