雜湊(Hash Map / Hash Set)是面試最高頻的工具,核心永遠是一句話:把 O(n) 的查找/比對降成 O(1)。難的不是「用 map」,而是想清楚要拿什麼當 key。
這篇用 5 題串起雜湊的幾種用法,並延續上一篇的完整推導流程:
📋 題目 → 🧭 拆解 → 暴力 → 瓶頸 → 洞察 → 最優 → 程式碼
- 計數比對(242)→ 把計數當 key 分組(49)→ 字串編碼設計(271)→ 前綴和 + 雜湊(560)→ 雜湊集合 + 邊界展開(128)
① LC 242 — Valid Anagram
📋 題目:給定兩個字串 s 與 t,判斷 t 是否為 s 的「字母重組(anagram)」——即兩者每個字元出現次數完全相同。
範例:s = "anagram", t = "nagaram" → true;s = "rat", t = "car" → false
🧭 從讀題到解法
- 拆解:anagram ⟺ 兩字串的「字元 → 次數」完全一致。
- 直覺解:把兩字串排序後比較是否相等,O(n log n)。能過,但沒用到雜湊。
- 關鍵洞察:與其排序,不如數每個字元出現幾次——用一個計數表,
s的字元 +1、t的字元 -1,最後全為 0 就是 anagram。 - 最優解:長度不同直接 false;否則一個計數陣列掃一遍。
def isAnagram(s, t):
if len(s) != len(t):
return False
count = {}
for c in s:
count[c] = count.get(c, 0) + 1
for c in t:
if count.get(c, 0) == 0:
return False
count[c] -= 1
return True
bool isAnagram(string s, string t) {
if (s.size() != t.size()) return false;
int count[26] = {0};
for (char c : s) count[c - 'a']++;
for (char c : t) {
if (--count[c - 'a'] < 0) return false;
}
return true;
}
- 複雜度:時間 O(n),空間 O(1)(固定 26 字母)或 O(字元集)。
- 坑:先比長度;若是 Unicode / 含非小寫英文,要用 HashMap 而非 26 格陣列。
② LC 49 — Group Anagrams
📋 題目:給定字串陣列 strs,把互為 anagram 的字串分到同一組,回傳所有分組。
範例:["eat","tea","tan","ate","nat","bat"] → [["eat","tea","ate"],["tan","nat"],["bat"]]
🧭 從讀題到解法
- 拆解:要「分組」——一定有某種「同組共享的標識」。
- 從 242 出發:242 告訴我們 anagram ⟺ 字元計數相同。那麼同一組 anagram 會有相同的「字元計數」——這就是天然的分組 key!
- 關鍵洞察:對每個字串算出一個正規化 key(排序後的字串,或「26 個字母的計數」),把 key 相同的丟進同一個桶。
- 最優解:用 HashMap,key = 正規化結果,value = 該組字串清單。
from collections import defaultdict
def groupAnagrams(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) # char-count as key
return list(groups.values())
vector<vector<string>> groupAnagrams(vector<string>& strs) {
unordered_map<string, vector<string>> groups;
for (auto& s : strs) {
string key = s;
sort(key.begin(), key.end()); // sorted string as key
groups[key].push_back(s);
}
vector<vector<string>> res;
for (auto& [k, v] : groups) res.push_back(v);
return res;
}
- 複雜度:用排序當 key 是 O(n·k log k);用「字元計數」當 key 可降到 O(n·k)(n 個字串、k 為平均長度)。
- 坑:key 的選擇決定效率——計數 key 比排序 key 快,但要注意 key 的可雜湊性(Python 用
tuple、C++ 可把計數轉成字串)。
③ LC 271 — Encode and Decode Strings
📋 題目:設計兩個函式:encode 把一個「字串陣列」序列化成單一字串,decode 再還原回原陣列。字串內可能含任意字元(包括你想用的分隔符)。
範例:["lint","code","love you"] → 編碼成某字串 → 解碼回 ["lint","code","love you"]
🧭 從讀題到解法
- 拆解:這是設計題——重點在「如何序列化才能無歧義還原」。
- 錯誤的直覺:用逗號或某個符號分隔(
"lint,code,...")。問題:字串本身可能就含逗號,還原時會切錯。沒有任何單一字元能安全當分隔符。 - 關鍵洞察:改用長度前綴編碼——每個字串前面記它的長度 + 一個標記符,例如
4#lint4#code8#love you。還原時先讀長度,就知道接下來要精確抓幾個字元,內容含什麼字元都無所謂。 - 最優解:encode 用
len(s) + "#" + s串接;decode 逐段先讀到#、取長度、再依長度切出字串。
def encode(strs):
return ''.join(f'{len(s)}#{s}' for s in strs)
def decode(s):
res, i = [], 0
while i < len(s):
j = i
while s[j] != '#': # read the length digits
j += 1
length = int(s[i:j])
res.append(s[j + 1: j + 1 + length])
i = j + 1 + length
return res
string encode(vector<string>& strs) {
string res;
for (auto& s : strs) res += to_string(s.size()) + "#" + s;
return res;
}
vector<string> decode(string s) {
vector<string> res;
int i = 0, n = s.size();
while (i < n) {
int j = i;
while (s[j] != '#') j++; // read the length digits
int len = stoi(s.substr(i, j - i));
res.push_back(s.substr(j + 1, len));
i = j + 1 + len;
}
return res;
}
- 複雜度:時間 O(總長度),空間 O(總長度)。
- 坑:用「長度 + 標記符」而非單純分隔符。
#出現在內容裡也沒關係——因為我們是靠長度精確抓取,不是靠搜尋分隔符。
④ LC 560 — Subarray Sum Equals K
📋 題目:給定整數陣列 nums 和整數 k,回傳「和恰好為 k」的連續子陣列個數。
範例:nums = [1,1,1], k = 2 → 2(兩個 [1,1]);nums = [1,2,3], k = 3 → 2([1,2] 與 [3])
🧭 從讀題到解法
- 拆解:連續子陣列、求「和為 k」的個數。
- 暴力解:列舉所有 (i, j) 算區間和,O(n²)。
- 想用滑動視窗?不行:
nums可能有負數,視窗和不再單調,滑窗失效。換工具。 - 關鍵洞察(前綴和 + 雜湊):令
prefix[j]為前 j 項的和,則區間(i, j]的和 =prefix[j] - prefix[i]。要它等於 k ⟺prefix[i] = prefix[j] - k。所以邊掃邊問:「之前出現過幾次prefix - k這個前綴和?」——用一個 HashMap 記「每個前綴和出現的次數」。 - 最優解:一邊累加前綴和,一邊用
count[prefix - k]累加答案,再把當前前綴和記進表。
from collections import defaultdict
def subarraySum(nums, k):
count = defaultdict(int)
count[0] = 1 # empty prefix: enables subarrays from index 0
prefix = res = 0
for n in nums:
prefix += n
res += count[prefix - k] # how many earlier prefixes = prefix - k
count[prefix] += 1
return res
int subarraySum(vector<int>& nums, int k) {
unordered_map<int, int> count;
count[0] = 1; // empty prefix
int prefix = 0, res = 0;
for (int n : nums) {
prefix += n;
auto it = count.find(prefix - k);
if (it != count.end()) res += it->second;
count[prefix]++;
}
return res;
}
- 複雜度:時間 O(n),空間 O(n)。
- 坑:①
count[0] = 1是關鍵(讓「從頭開始」的子陣列也被算到);② 要先用count累加答案、再把當前前綴和寫進表,順序不能反;③ 有負數時這是正解,不要用滑動視窗。
⑤ LC 128 — Longest Consecutive Sequence
📋 題目:給定未排序整數陣列 nums,找出最長連續整數序列的長度,要求時間 O(n)。
範例:nums = [100,4,200,1,3,2] → 4(連續序列 1,2,3,4)
🧭 從讀題到解法
- 拆解:要找最長連續段(值連續,不是位置連續),且限制 O(n)。
- 排序解:排序後掃一遍 O(n log n)——但題目要 O(n),所以排序被排除。
- 關鍵洞察:把所有數丟進 HashSet(O(1) 查存在)。對每個數,只有當它是序列的起點(即
n - 1不在 set 裡)時,才開始往上數n+1, n+2, ...。這樣每個數最多被走訪兩次。 - 最優解:遍歷 set,遇到起點就展開計算長度,取最長。
def longestConsecutive(nums):
num_set = set(nums)
longest = 0
for n in num_set:
if n - 1 not in num_set: # n is the start of a sequence
length = 1
while n + length in num_set:
length += 1
longest = max(longest, length)
return longest
int longestConsecutive(vector<int>& nums) {
unordered_set<int> s(nums.begin(), nums.end());
int longest = 0;
for (int n : s) {
if (s.find(n - 1) == s.end()) { // n is a sequence start
int length = 1;
while (s.find(n + length) != s.end()) length++;
longest = max(longest, length);
}
}
return longest;
}
- 複雜度:時間 O(n),空間 O(n)。
- 坑:「只從起點展開」(
n-1不在 set)是維持 O(n) 的關鍵——少了這個判斷,會對同一段序列重複展開,退化成 O(n²)。
一張表記住全部
| 題 | 推導起點 | 雜湊用法 | 時間 | 關鍵 / 坑 |
|---|---|---|---|---|
| LC 242 | 排序 → 改用計數 | 字元計數比對 | O(n) | Unicode 要用 map |
| LC 49 | 從 242 → 計數當 key | 正規化 key 分組 | O(n·k) | key 選計數比排序快 |
| LC 271 | 分隔符會出包 → 長度前綴 | 字串編碼設計 | O(總長) | 靠長度抓、不靠分隔符 |
| LC 560 | 滑窗失效(負數) → 前綴和 | 前綴和次數表 | O(n) | count[0]=1、先查再存 |
| LC 128 | 排序太慢 → HashSet | 集合查存在 + 邊界展開 | O(n) | 只從起點展開 |
面試辨識口訣
- 看到「anagram / 字元次數」→ 計數表。
- 看到「分組 / 歸類」→ 想「同組共享什麼 key」。
- 看到「序列化字串、含任意字元」→ 長度前綴編碼,別用分隔符。
- 看到「連續子陣列求和 = k」且可能有負數 → 前綴和 + 雜湊(不是滑窗)。
- 看到「最長連續、要 O(n)」→ HashSet + 只從起點展開。
雜湊題的精髓不是「用 map」,而是選對 key:計數、前綴和、正規化字串……想清楚 key,題目就解一半了。