Pattern xuất hiện ở khoảng 40% bài Easy/Medium trên LeetCode và Codility. Đào sâu vào nghệ thuật thiết kế key — sự khác biệt giữa O(n²) và O(n).
Tuần 1 đã giới thiệu mantra "đổi space lấy time". Tuần 3 đào sâu cách áp dụng nó.
set + x in s O(1).dict / Counter O(1) per query.Nếu có, thì câu hỏi tiếp theo là: key của hash sẽ là gì? Đó là phần nghệ thuật.
Hash table = mảng B "bucket" + hàm băm h(key). Vị trí lưu key là h(key) mod B.
Nếu hàm băm phân phối đều và n / B nhỏ (load factor < 1), mỗi bucket chứa ~1 phần tử.
Tìm/thêm/xoá là O(1) trung bình.
Nếu nhiều key băm cùng bucket (collision), chuỗi tại bucket dài lên. Tệ nhất: mọi key cùng bucket → O(n) per operation.
Python dict dùng open addressing + random hash seed; thực tế gần như không bao giờ degrade.
| Cấu trúc | Mục đích | Ví dụ điển hình |
|---|---|---|
set | Kiểm tra "đã thấy chưa" | Detect duplicate, cycle detection |
dict | Map key → value | Two Sum (value → index), counting |
Counter | Đếm tần suất chuyên dụng | Anagram, top-K frequent |
from collections import Counter
c = Counter("aabbbc") # Counter({'b': 3, 'a': 2, 'c': 1})
c.most_common(2) # [('b', 3), ('a', 2)]
c['x'] # 0 — không tồn tại trả 0, không lỗi
c1 = Counter("abc"); c2 = Counter("aab")
c1 + c2 # Counter({'a': 3, 'b': 2, 'c': 1})
c1 & c2 # min(intersection): Counter({'a': 1, 'b': 1})
c1 & c2 = phần chung của hai multiset. Ứng dụng cho bài
Find Common Characters, Intersection of Two Arrays II.
Python yêu cầu key của dict/set phải hashable:
hỗ trợ __hash__ và bất biến.
| Hashable? | Loại | Ví dụ |
|---|---|---|
| Có | int, float, str, bool | 42, "abc", 3.14 |
| Có | tuple (chứa hashable) | (1, 2, "a") |
| Có | frozenset | frozenset([1,2,3]) |
| Không | list, dict, set | [1,2] — TypeError |
# Sai: list không hashable
seen = set()
seen.add([1, 2, 3]) # TypeError
# Đúng: chuyển thành tuple
seen.add(tuple([1, 2, 3])) # OK
# Hoặc frozenset nếu thứ tự không quan trọng
seen.add(frozenset([1, 2, 3])) # OK
cnt = Counter(data) # Pha 1: O(n) — đếm
result = process(cnt) # Pha 2: O(n) hoặc O(k) — xử lý dựa trên tần suất
Đặc điểm chung: thứ tự ban đầu không quan trọng, chỉ quan trọng tần suất. Đó là dấu hiệu Frequency Counting.
Đề (LeetCode #49): Cho mảng string strs. Nhóm các anagram cùng group.
Ví dụ: ["eat","tea","tan","ate","nat","bat"] →
[["bat"],["nat","tan"],["ate","eat","tea"]].
Key phải có tính chất: hai string là anagram \(\iff\) có cùng key.
from collections import defaultdict
def groupAnagrams(strs):
groups = defaultdict(list)
for s in strs:
key = ''.join(sorted(s)) # "tea" → "aet"
groups[key].append(s)
return list(groups.values())
Time O(n × k log k) với k = độ dài chuỗi (sort mỗi chuỗi).
def groupAnagrams(strs):
groups = defaultdict(list)
for s in strs:
cnt = [0] * 26
for ch in s:
cnt[ord(ch) - ord('a')] += 1
key = tuple(cnt) # tuple hashable, list thì không
groups[key].append(s)
return list(groups.values())
Time O(n × k) — không cần sort.
| Approach | Key | Time | Khi nào ưu tiên |
|---|---|---|---|
| Sorted | "aet" | O(n × k log k) | Code ngắn, chuỗi ngắn |
| Tuple đếm | (1,0,0,...,1,...) | O(n × k) | Chuỗi dài, tối ưu |
Đề (LeetCode #347): Mảng nums. Trả về k phần tử xuất hiện nhiều nhất.
Constraints: Phải đạt tốt hơn O(n log n).
from collections import Counter
def topKFrequent(nums, k):
cnt = Counter(nums)
return [x for x, _ in cnt.most_common(k)]
# Time O(n log n), đạt nhưng chưa tốt nhất
import heapq
def topKFrequent(nums, k):
cnt = Counter(nums)
return heapq.nlargest(k, cnt.keys(), key=cnt.get)
# Time O(n log k) — tốt hơn khi k << n
Heap sẽ học chi tiết ở Tuần 9.
Quan sát: tần suất tối đa của một phần tử là n. Tạo n+1 bucket, bucket[f] chứa các phần tử có tần suất f. Duyệt ngược từ bucket lớn nhất.
def topKFrequent(nums, k):
cnt = Counter(nums)
n = len(nums)
buckets = [[] for _ in range(n + 1)]
for x, f in cnt.items():
buckets[f].append(x)
result = []
for f in range(n, 0, -1):
for x in buckets[f]:
result.append(x)
if len(result) == k:
return result
return result
Time O(n), Space O(n).
Đề (LeetCode #387): Cho string s. Trả về index của ký tự đầu tiên
xuất hiện đúng một lần. Nếu không có, trả về -1.
def firstUniqChar(s):
cnt = Counter(s) # Pass 1: đếm — O(n)
for i, ch in enumerate(s): # Pass 2: tìm theo thứ tự — O(n)
if cnt[ch] == 1:
return i
return -1
Time O(n), Space O(k) với k = số ký tự khác nhau (≤ 26 cho chữ thường).
Biến thể: First Unique Character in a Stream phải dùng cấu trúc dữ liệu khác (queue + count).
Đề (LeetCode #1002): Cho mảng các string words. Trả về các ký tự
xuất hiện trong mọi string (kể cả trùng lặp).
Ví dụ: ["bella","label","roller"] → ["e","l","l"] (vì
'l' xuất hiện ≥ 2 trong cả 3 string, 'e' xuất hiện ≥ 1).
from collections import Counter
from functools import reduce
def commonChars(words):
common = reduce(lambda a, b: a & b, map(Counter, words))
return list(common.elements())
Counter(s) & Counter(t) = giao multiset — lấy min(count_s, count_t) cho mỗi ký tự.reduce áp dụng phép giao lần lượt qua các Counter.elements() "duỗi" Counter ra: Counter({'l':2,'e':1}) → ['l','l','e'].Time O(n × k) với n = số string, k = độ dài trung bình.
+, -, &, |. Học thuộc 4 phép này
sẽ giúp viết code ngắn hơn nhiều ở các bài liên quan tần suất.
seen = {} # value → thông tin (index, count, time...)
for i, x in enumerate(arr):
if <điều kiện ghép với cái đã thấy>:
# tìm trong seen O(1)
return ...
seen[x] = i # ghi nhận đã thấy
Đề (LeetCode #219): Mảng nums, số k. Trả True
nếu tồn tại i, j sao cho nums[i] == nums[j] và |i - j| ≤ k.
# O(n × k)
for i in range(len(nums)):
for j in range(i+1, min(i+k+1, len(nums))):
if nums[i] == nums[j]: return True
return False
def containsNearbyDuplicate(nums, k):
seen = {} # value → last index
for i, x in enumerate(nums):
if x in seen and i - seen[x] <= k:
return True
seen[x] = i # cập nhật index mới nhất
return False
Time O(n), Space O(n).
nums = [1, 2, 3, 1, 2, 3], k = 2. Kỳ vọng: False (mọi cặp trùng đều cách xa hơn 2).
| i | x | x trong seen? | khoảng cách | kết quả | seen sau |
|---|---|---|---|---|---|
| 0 | 1 | không | — | — | {1:0} |
| 1 | 2 | không | — | — | {1:0, 2:1} |
| 2 | 3 | không | — | — | {1:0, 2:1, 3:2} |
| 3 | 1 | có (idx 0) | 3 − 0 = 3 | 3 > k → tiếp | {1:3, 2:1, 3:2} |
| 4 | 2 | có (idx 1) | 4 − 1 = 3 | 3 > k → tiếp | {1:3, 2:4, 3:2} |
| 5 | 3 | có (idx 2) | 5 − 2 = 3 | 3 > k → tiếp | {1:3, 2:4, 3:5} |
Trả về False.
Đề (LeetCode #205): Hai string s, t là isomorphic nếu
có thể thay thế ký tự trong s để được t, mỗi ký tự ánh xạ duy nhất sang một ký tự khác,
và khác ký tự nguồn ánh xạ tới khác ký tự đích.
Ví dụ: "egg" ↔ "add" → True (e→a, g→d). "foo" ↔ "bar" → False (o→a và o→r mâu thuẫn).
def isIsomorphic(s, t):
if len(s) != len(t): return False
s2t, t2s = {}, {}
for a, b in zip(s, t):
if a in s2t and s2t[a] != b: return False
if b in t2s and t2s[b] != a: return False
s2t[a] = b
t2s[b] = a
return True
Time O(n), Space O(k) với k = số ký tự khác nhau.
Nhiều sinh viên chỉ dùng 1 map (s → t) và sai trên test "ab" ↔ "aa".
s = "ab", t = "aa"
i=0: 'a' không trong map → map['a'] = 'a'. map = {'a':'a'}
i=1: 'b' không trong map → map['b'] = 'a'. map = {'a':'a', 'b':'a'}
Trả True ✗ — nhưng đáp án thực sự là False.
Lỗi: cả 'a' và 'b' đều ánh xạ sang 'a' — vi phạm tính song ánh (hai ký tự nguồn khác nhau phải ánh xạ tới hai ký tự đích khác nhau).
Map ngược t2s kiểm tra: nếu 'a' đã ánh xạ ngược về 'a', thì 'b' không thể cũng ánh xạ vào 'a'.
Đề (LeetCode #290): Pattern dạng "abba". String "dog cat cat dog". Trả True nếu pattern khớp với chuỗi từ qua một bijection.
Đây chính là Isomorphic Strings nâng lên cấp từ thay vì cấp ký tự.
def wordPattern(pattern, s):
words = s.split()
if len(pattern) != len(words): return False
p2w, w2p = {}, {}
for p, w in zip(pattern, words):
if p in p2w and p2w[p] != w: return False
if w in w2p and w2p[w] != p: return False
p2w[p] = w
w2p[w] = p
return True
pattern = "abba", s = "dog cat cat dog"
('a','dog') → p2w={'a':'dog'}, w2p={'dog':'a'}
('b','cat') → p2w={'a':'dog','b':'cat'}, w2p={'dog':'a','cat':'b'}
('b','cat') → p2w['b'] == 'cat' ✓, w2p['cat'] == 'b' ✓
('a','dog') → khớp ✓
→ True
seen[x] = i?set nhẹ hơn dict — không cần value. Dùng khi chỉ quan tâm "phần tử này có
trong tập đã thấy không?".
s = set()
s.add(x) # O(1)
x in s # O(1)
s.remove(x) # O(1) — lỗi nếu x không có
s.discard(x) # O(1) — không lỗi
a & b # giao (intersection)
a | b # hợp (union)
a - b # hiệu
Đề (LeetCode #128): Mảng nums chưa sort. Tìm độ dài chuỗi liên tiếp dài nhất.
Ví dụ: [100,4,200,1,3,2] → 4 (chuỗi 1,2,3,4). Yêu cầu O(n).
Sort rồi duyệt → O(n log n). Vi phạm yêu cầu.
def longestConsecutive(nums):
s = set(nums)
longest = 0
for x in s:
if x - 1 not in s: # x là đầu chuỗi
length = 1
while x + length in s:
length += 1
longest = max(longest, length)
return longest
Time O(n), Space O(n).
Lo lắng tự nhiên: vòng while x + length in s có thể chạy lâu — sao tổng thời gian là O(n)?
Vòng while chỉ chạy khi x là đầu chuỗi (x - 1 not in s).
Khi đó, vòng đếm chính xác độ dài của chuỗi mà x mở đầu — gọi là L. Vòng tốn O(L).
Mỗi phần tử của nums thuộc đúng một chuỗi liên tiếp duy nhất, nên tổng L
qua tất cả các "đầu chuỗi" = n.
[100, 4, 200, 1, 3, 2]Kết quả: 4.
Đề (LeetCode #202): Số n là "happy" nếu lặp lại quá trình "thay n bằng tổng bình phương các chữ số" cuối cùng cho ra 1. Nếu chu kỳ vô hạn không chứa 1 → False.
Ví dụ: 19 → 1²+9² = 82 → 8²+2² = 68 → 6²+8² = 100 → 1²+0²+0² = 1 → True.
def isHappy(n):
def step(x):
s = 0
while x:
x, d = divmod(x, 10)
s += d * d
return s
seen = set()
while n != 1 and n not in seen:
seen.add(n)
n = step(n)
return n == 1
Time và space phụ thuộc vào số chu kỳ — chứng minh được là O(log n) dựa trên phân tích các giá trị đạt được.
set tốt hơn dict?Đã làm chi tiết ở Tuần 2. Ôn lại nhanh tinh thần kết hợp:
def subarraySum(nums, k):
count, prefix = 0, 0
seen = defaultdict(int)
seen[0] = 1
for x in nums:
prefix += x
count += seen[prefix - k]
seen[prefix] += 1
return count
Đề (LeetCode #3): String s. Tìm độ dài substring dài nhất không có ký tự lặp.
Đây là cầu nối hoàn hảo từ Hash sang Sliding Window (Tuần 4).
def lengthOfLongestSubstring(s):
last_seen = {} # ký tự → index gần nhất
longest = 0
left = 0
for right, ch in enumerate(s):
if ch in last_seen and last_seen[ch] >= left:
left = last_seen[ch] + 1 # đẩy left qua ký tự lặp
last_seen[ch] = right
longest = max(longest, right - left + 1)
return longest
"abcabcbb"| right | ch | left | last_seen sau | length |
|---|---|---|---|---|
| 0 | a | 0 | {a:0} | 1 |
| 1 | b | 0 | {a:0,b:1} | 2 |
| 2 | c | 0 | {a:0,b:1,c:2} | 3 |
| 3 | a | 1 | {a:3,b:1,c:2} | 3 |
| 4 | b | 2 | {a:3,b:4,c:2} | 3 |
| 5 | c | 3 | {a:3,b:4,c:5} | 3 |
| 6 | b | 5 | {a:3,b:6,c:5} | 2 |
| 7 | b | 7 | {a:3,b:7,c:5} | 1 |
| Câu hỏi của đề | Pattern phù hợp | Cấu trúc |
|---|---|---|
| Tìm cặp/bộ thoả tổng/hiệu | Lookup | dict value→index |
| Đếm tần suất, top K | Frequency | Counter |
| Nhóm theo đặc trưng | Key Design | defaultdict(list) |
| Phát hiện trùng / cycle | Set Membership | set |
| Mapping 1-1 (bijection) | Two-way Lookup | 2 dict |
| Subarray có tổng = K | Hash + Prefix | dict prefix→count |
| Substring không lặp / ≤ K loại | Hash + Sliding Window (Tuần 4) | dict + 2 pointer |
dict, set, Counter — và operations multiset của Counter.Sliding Window — Fixed Window và Variable Window
Pattern chuyên dụng cho subarray/substring liên tiếp với điều kiện. Sẽ học cách thiết kế "shrink condition", template chuẩn cho variable window, và 8 worked examples từ Maximum Average Subarray, Minimum Size Subarray Sum, đến Longest Substring with At Most K Distinct Characters. Hash của tuần này sẽ tiếp tục được dùng làm cấu trúc theo dõi cửa sổ.
Nhấn T hoặc Esc để đóng