系統設計題 — 實作範例
範圍 — design.md 背後的「設計一個 X」實作題,每題一張題目 card,依它逼出來的結構搭配(design.md 的模式)歸類。 另見:design.md — 上層文件:這些解法在演練的模式、五步驟方法與挑選表;design_patterns.md — 一致性雜湊、限流器與負載平衡,同一關卡會問但不是 LC 題;ood_design.md — LLD 關卡的類別建模與 SOLID;iterator.md — 單獨談 iterator 的契約;trie.md、heap.md、hash_map.md、linked_list.md — 被組合起來的各個結構。
LeetCode 題目清單
概觀
模式在 design.md:一道設計題就是一個存資料的結構,再加上第二個結構,而它唯一的工作是讓某一個操作變快。這一頁是題目。 每張 card 寫出它歸約到的模式、它多加的那一步,以及一份標準解法。結構本身就是別處核心 card 的設計(LRU 在 linked_list.md、 動態中位數在 heap.md、min stack 在 stack.md)就在那裡解,這裡只列成指標。
索引
| LC | 題目 | 歸約到 | 難度 | 在哪 |
|---|---|---|---|---|
| 146 | LRU Cache | linked_list § Template 7 | Medium | 在 linked_list.md 解 |
| 460 | LFU Cache | linked_list § LFU variant | Hard | 在 linked_list.md 解 |
| 432 | All O(1) Data Structure | linked_list § Template 7 | Hard | 見下方 |
| 703 | Kth Largest Element in a Stream | heap § Template 1 | Easy | 在 heap_examples.md 解 |
| 355 | Design Twitter | heap § Template 3 | Medium | 見下方 |
| 295 | Find Median from Data Stream | heap § Template 4 | Hard | 在 heap.md 解 |
| 211 | Design Add and Search Words Data Structure | trie § Template 3 | Medium | 在 trie.md 解 |
| 642 | Design Search Autocomplete System | trie § Template 4 | Hard | 見下方 |
| 362 | Design Hit Counter | queue § Template 6 | Medium | 見下方 |
| 353 | Design Snake Game | queue § Template 6 | Medium | 見下方 |
| 380 | Insert Delete GetRandom O(1) | design § Pattern 6 | Medium | 在 set_examples.md 解 |
| 155 | Min Stack | stack § Template 4 | Medium | 在 stack.md 解 |
| 1381 | Design a Stack With Increment Operation | stack § Template 4 | Medium | 見下方 |
| 895 | Maximum Frequency Stack | stack § Template 4 | Hard | 見下方 |
| 981 | Time Based Key-Value Store | design § Pattern 8 | Medium | 見下方 |
| 729 | My Calendar I | design § Pattern 8 | Medium | 見下方 |
| 731 | My Calendar II | design § Pattern 8 | Medium | 見下方 |
| 732 | My Calendar III | design § Pattern 8 | Hard | 見下方 |
| 715 | Range Module | design § Pattern 8 | Hard | 見下方 |
| 2034 | Stock Price Fluctuation | heap_advanced § two lazy heaps | Medium | 在 heap_advanced.md 解 |
| 1166 | Design File System | design § Pattern 9 | Medium | 見下方 |
| 635 | Design Log Storage System | design § Pattern 9 | Medium | 見下方 |
| 588 | Design In-Memory File System | design § Pattern 9 | Hard | 見下方 |
| 348 | Design Tic-Tac-Toe | design § Pattern 10 | Medium | 見下方 |
| 1396 | Design Underground System | design § Pattern 10 | Medium | 見下方 |
其他帶 design 標籤、這裡沒有解的題目:
| LC | 題目 | 想法 | 在哪 |
|---|---|---|---|
| 705 / 706 | Design HashSet / HashMap | 桶陣列 + 分離鏈結 | hash_map.md |
| 707 | Design Linked List | dummy head + 一個 size 欄位 | linked_list.md |
| 1472 | Design Browser History | 陣列 + 游標;visit 會截掉往前的歷史 |
linked_list § Template 7 的練習 |
| 1352 | Product of the Last K Numbers | 前綴乘積;add(0) 時清空 list,k 超過長度就回答 0 |
prefix_sum.md |
| 297 / 449 | Serialize and Deserialize Binary Tree / BST | 帶 null 標記的前序;BST 可以省掉標記,用 (lower, upper) 邊界重建 |
tree.md |
| 745 | Prefix and Suffix Search | 把每個 suffix + '{' + word 插進同一棵字典樹,查 suf + '{' + pre |
trie.md |
| 676 / 677 | Implement Magic Dictionary / Map Sum Pairs | 允許恰好一個字元不同的 DFS/每個節點存前綴和 | trie.md |
雜湊表 + 鏈結串列的例題
All O(1) Data Structure — LC 432 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
關鍵 — 串列裡放的是計數桶而不是 key,依計數由小到大排列,所以最小的 key 在
head.next、 最大的在tail.prev。計數每次只變 1,所以 key 只會移到相鄰的桶,而那個桶就在舊桶旁邊以 O(1) 建立。
歸約到:linked_list § Template 7 — 同一條帶哨兵的雙向鏈結串列,只是每個節點是一個「同計數 key 的桶」,也不必維護 min_freq(LFU 的那個整數變成了串列本身的順序)。
# python
# LC 432 - All O`one Data Structure
# IDEA: key -> count map + a doubly linked list of buckets ordered by count;
# inc / dec move a key to the neighbouring bucket, creating it beside the old one
# time = O(1) per call, space = O(number of keys)
class Bucket:
def __init__(self, count):
self.count = count
self.keys = set()
self.prev = None
self.next = None
class AllOne:
def __init__(self):
self.count_of = {} # key -> count
self.bucket_of = {} # count -> Bucket
self.head = Bucket(0) # sentinel before the smallest count
self.tail = Bucket(0) # sentinel after the largest count
self.head.next = self.tail
self.tail.prev = self.head
def _insert_after(self, node, new_node):
new_node.prev = node
new_node.next = node.next
node.next.prev = new_node
node.next = new_node
def _remove_if_empty(self, bucket):
if not bucket.keys:
bucket.prev.next = bucket.next
bucket.next.prev = bucket.prev
del self.bucket_of[bucket.count]
def inc(self, key):
count = self.count_of.get(key, 0)
here = self.bucket_of[count] if count else self.head
if count + 1 not in self.bucket_of: # the next bucket goes right after this one
new_bucket = Bucket(count + 1)
self.bucket_of[count + 1] = new_bucket
self._insert_after(here, new_bucket)
self.bucket_of[count + 1].keys.add(key)
self.count_of[key] = count + 1
if count:
here.keys.remove(key)
self._remove_if_empty(here)
def dec(self, key):
count = self.count_of[key] # the problem guarantees key exists
here = self.bucket_of[count]
if count == 1:
del self.count_of[key]
else:
if count - 1 not in self.bucket_of: # the previous bucket goes right before this one
new_bucket = Bucket(count - 1)
self.bucket_of[count - 1] = new_bucket
self._insert_after(here.prev, new_bucket)
self.bucket_of[count - 1].keys.add(key)
self.count_of[key] = count - 1
here.keys.remove(key)
self._remove_if_empty(here)
def getMaxKey(self):
if self.tail.prev is self.head:
return ""
return next(iter(self.tail.prev.keys))
def getMinKey(self):
if self.head.next is self.tail:
return ""
return next(iter(self.head.next.keys))
陷阱
- 在把新桶插到舊桶旁邊之前就刪掉舊桶:對桶 1 裡唯一的 key 做
inc,桶 1 被拆掉, 桶 2 就沒有可以插在後面的錨點了。 - 掃描找最小/最大的桶:只有「計數 → 桶」的 map 時,
getMinKey是 O(不同計數的個數)。是串列的順序讓它變成 O(1)。 dec到 0 時必須從count_of刪掉這個 key,不然之後的inc會從過期的計數開始。
雜湊表 + 堆積的例題
Design Twitter — LC 355
關鍵 — 每個使用者的推文本來就依時間排好,所以動態牆就是對追蹤對象的清單做 k 路合併, 拿到 10 則就停。全域時鐘讓不同使用者的時間戳可以互相比較; 推進堆積時取負號,最小堆積就會先彈出最新的推文。
歸約到:heap § Template 3 — 合併 k 個有序串列,但是惰性地做:只拉前 10 筆。實體建模(為什麼 Tweet 帶著時間、為什麼時鐘是全域的)在 ood_design.md。
# python
# LC 355 - Design Twitter
# IDEA: per-user tweet lists in post order + a follow set; getNewsFeed = lazy k-way merge, newest first
# time = O(1) post / follow / unfollow, O(k + 10 log k) getNewsFeed for k followees; space = O(users + tweets)
import heapq
from collections import defaultdict
class Twitter:
def __init__(self):
self.clock = 0
self.tweets = defaultdict(list) # user -> [(time, tweetId)], oldest first
self.following = defaultdict(set) # user -> users they follow
def postTweet(self, userId, tweetId):
self.tweets[userId].append((self.clock, tweetId))
self.clock += 1
def getNewsFeed(self, userId):
heap = [] # (-time, tweetId, user, index in that user's list)
for user in self.following[userId] | {userId}: # a user always sees their own tweets
if self.tweets[user]:
i = len(self.tweets[user]) - 1 # newest tweet of this user
time, tweet_id = self.tweets[user][i]
heap.append((-time, tweet_id, user, i))
heapq.heapify(heap)
feed = []
while heap and len(feed) < 10:
_, tweet_id, user, i = heapq.heappop(heap)
feed.append(tweet_id)
if i > 0: # this user's next-newest tweet
time, next_id = self.tweets[user][i - 1]
heapq.heappush(heap, (-time, next_id, user, i - 1))
return feed
def follow(self, followerId, followeeId):
self.following[followerId].add(followeeId)
def unfollow(self, followerId, followeeId):
self.following[followerId].discard(followeeId)
陷阱
- 在
getNewsFeed裡把使用者加進自己的追蹤集合會改到狀態:之後unfollow(u, u)就把使用者自己的推文藏起來了。改成和{userId}取聯集。 unfollow用remove而不是discard,遇到從沒追蹤過的使用者會丟例外。- 把所有推文合併完再取前 10 是 O(推文總數);堆積彈出 10 次就停。
字典樹的例題
| LC | 題目 | 在哪 |
|---|---|---|
| 211 | Design Add and Search Words Data Structure | trie § Template 3 — 那張 card 的錨定題 |
Design Search Autocomplete System — LC 642
關鍵 — 指向字典樹的游標跟著已輸入的前綴移動,所以每按一個鍵就是往子節點走一步, 不是從根重新走一遍。一旦某個字元不在字典樹上,游標就保持
None直到#, 這句話之後的每個按鍵都回答[]。
歸約到:trie § Template 4 — 同樣的前綴走訪,再加上每個句子在執行期間會變的熱度(所以前三名是在查詢時排,不是在插入時快取)。
# python
# LC 642 - Design Search Autocomplete System
# IDEA: each trie node keeps the set of sentences under it; a live cursor follows the typed prefix;
# '#' records the sentence (count + 1) and resets the cursor
# time = O(m log m) per keystroke for the m sentences under the cursor, space = O(total characters)
from collections import defaultdict
class TrieNode:
def __init__(self):
self.children = {}
self.sentences = set()
class AutocompleteSystem:
def __init__(self, sentences, times):
self.root = TrieNode()
self.count = defaultdict(int) # sentence -> hot degree
for sentence, t in zip(sentences, times):
self.count[sentence] += t
self._add(sentence)
self.typed = ""
self.cursor = self.root
def _add(self, sentence):
node = self.root
for ch in sentence:
node = node.children.setdefault(ch, TrieNode())
node.sentences.add(sentence)
def input(self, c):
if c == "#":
self.count[self.typed] += 1
self._add(self.typed)
self.typed = ""
self.cursor = self.root
return []
self.typed += c
if self.cursor is not None:
self.cursor = self.cursor.children.get(c)
if self.cursor is None:
return []
ranked = sorted(self.cursor.sentences, key=lambda s: (-self.count[s], s))
return ranked[:3]
陷阱
- 某個字元找不到時把游標重設回根:在
"i "失配之後輸入"i a", 會從根重新比對,推薦出根本沒有這個前綴的句子。 - 同熱度時依句子的 ASCII 順序決定(
' '排在字母前面),也就是一般的字串比較; 只依熱度排序會在 LC 642 的範例 1 出錯。
串流/佇列的例題
Design Hit Counter — LC 362
關鍵 — 時間戳是非遞減地進來的,所以過期的點擊永遠在前面:
getHits從左邊彈完之後,佇列裡剛好就是最近 300 秒的點擊。
歸約到:queue § Template 6 — 串流上的移動視窗,以時間而不是以筆數為準。
# python
# LC 362 - Design Hit Counter
# IDEA: deque of hit timestamps; evict from the left everything at or before timestamp - 300
# time = O(1) amortised per call, space = O(hits in the last 300 s)
from collections import deque
class HitCounter:
def __init__(self):
self.hits = deque()
def hit(self, timestamp):
self.hits.append(timestamp)
def getHits(self, timestamp):
while self.hits and self.hits[0] <= timestamp - 300:
self.hits.popleft()
return len(self.hits)
追問(每秒有大量點擊):依 timestamp % 300 分桶,不管進來多少點擊,記憶體都是 O(300)。
這就是要給的第二種解法,理由就是這一點。
# python
# LC 362 - Design Hit Counter (fixed 300 buckets)
# IDEA: bucket i holds the hits of the last second that mapped to i; a stale bucket is reset on reuse
# time = O(1) hit, O(300) getHits; space = O(300)
class HitCounterBuckets:
def __init__(self):
self.times = [0] * 300
self.counts = [0] * 300
def hit(self, timestamp):
i = timestamp % 300
if self.times[i] != timestamp: # this bucket still holds an older second
self.times[i] = timestamp
self.counts[i] = 0
self.counts[i] += 1
def getHits(self, timestamp):
total = 0
for i in range(300):
if timestamp - self.times[i] < 300:
total += self.counts[i]
return total
陷阱
- 視窗是
(timestamp - 300, timestamp]:時間 1 的點擊到 301 就不算了。拿< timestamp - 300當淘汰條件,會讓它多留一秒。
Design Snake Game — LC 353
關鍵 — 蛇身是一個雙端佇列(頭在左、尾在右),再加上一個存同樣格子的集合。 在檢查新的頭之前先把尾巴移出集合,因為頭可以合法地走進尾巴正要離開的那一格。
歸約到:queue § Template 6 — 一個隨串流前進而消耗前端的佇列,旁邊再放一個集合做 O(1) 的成員檢查。
# python
# LC 353 - Design Snake Game
# IDEA: deque body + set of occupied cells; pop the tail unless eating, then test the new head
# time = O(1) per move, space = O(width * height)
from collections import deque
class SnakeGame:
def __init__(self, width, height, food):
self.width = width
self.height = height
self.food = deque(food)
self.body = deque([(0, 0)]) # head at index 0
self.occupied = {(0, 0)}
self.score = 0
def move(self, direction):
dr, dc = {"U": (-1, 0), "D": (1, 0), "L": (0, -1), "R": (0, 1)}[direction]
row, col = self.body[0][0] + dr, self.body[0][1] + dc
if not (0 <= row < self.height and 0 <= col < self.width):
return -1
if self.food and [row, col] == self.food[0]:
self.food.popleft() # eat: the tail stays, the snake grows
self.score += 1
else:
tail = self.body.pop()
self.occupied.remove(tail)
if (row, col) in self.occupied:
return -1
self.body.appendleft((row, col))
self.occupied.add((row, col))
return self.score
陷阱
- 先檢查碰撞再移除尾巴:長度 4 的蛇在 2×2 的格子裡繞圈, 頭走進尾巴正要空出來的那格時會被判定死亡。
- 食物一次吃一個、依序吃:只有
food[0]是有效的。
雜湊表 + 陣列的例題
| LC | 題目 | 在哪 |
|---|---|---|
| 380 | Insert Delete GetRandom O(1) | set_examples § 11 — 把要刪的元素和最後一個交換,再 pop |
堆疊 + 輔助狀態的例題
Design a Stack With Increment Operation — LC 1381
關鍵 —
increment(k, val)是加到最底下的 k 個元素,所以只在第 k 個元素上記一次, 當成「底下所有元素待加的增量」;pop在回傳之前把自己的增量交給下一個元素。 於是每個元素看到的,剛好就是涵蓋到它的那些增量。
歸約到:stack § Template 4 — 每一層一個輔助值,跟著那一層一起 push 和 pop;這裡的輔助值是待加的增量,而不是目前的最小值。
# python
# LC 1381 - Design a Stack With Increment Operation
# IDEA: inc[i] = pending amount for stack[0..i]; pop adds inc[top] and passes it down to top - 1
# time = O(1) per push / pop / increment, space = O(maxSize)
class CustomStack:
def __init__(self, maxSize):
self.max_size = maxSize
self.stack = []
self.inc = [] # inc[i] applies to stack[0..i]
def push(self, x):
if len(self.stack) < self.max_size:
self.stack.append(x)
self.inc.append(0)
def pop(self):
if not self.stack:
return -1
add = self.inc.pop()
if self.inc:
self.inc[-1] += add # the element below inherits the pending delta
return self.stack.pop() + add
def increment(self, k, val):
i = min(k, len(self.stack)) - 1
if i >= 0:
self.inc[i] += val
// java
// LC 1381 - Design a Stack With Increment Operation
// IDEA: inc[i] = amount to add to stack[0..i]; on pop, hand the delta down to i - 1
// time = O(1) per push / pop / increment, space = O(maxSize)
class CustomStack {
private final int[] stack;
private final int[] inc;
private int size = 0;
public CustomStack(int maxSize) {
stack = new int[maxSize];
inc = new int[maxSize];
}
public void push(int x) {
if (size < stack.length) {
stack[size++] = x;
}
}
public int pop() {
if (size == 0) {
return -1;
}
size--;
int result = stack[size] + inc[size];
if (size > 0) {
inc[size - 1] += inc[size]; // propagate the pending delta downwards
}
inc[size] = 0; // the slot will be reused by the next push
return result;
}
public void increment(int k, int val) {
int i = Math.min(k, size) - 1; // mark only the k-th element
if (i >= 0) {
inc[i] += val;
}
}
}
陷阱
- 在
increment裡逐一處理底下 k 個元素,每次呼叫是 O(k);改用增量就是 O(1)。 - Java:pop 時忘了
inc[size] = 0,舊的增量會漏到下一個 push 到這格的元素上。
Maximum Frequency Stack — LC 895 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
關鍵 —
group[f]是一個堆疊,依 push 順序存放所有計數曾經達到 f 的值。 push 時把值放進新計數的桶,但不從較低的桶移除,所以 pop 之後, 較早的那份已經在桶f - 1裡等著:不必清理,而且桶是連續的,max_freq每次最多只降 1。
歸約到:stack § Template 4 — 每一層保存狀態;這裡的層是頻率,每一層本身是一個堆疊,所以同頻率時自然由最近的勝出。
# python
# LC 895 - Maximum Frequency Stack
# IDEA: value -> count, count -> stack of values; pop = top of the max-count stack
# time = O(1) per push / pop, space = O(n)
from collections import defaultdict
class FreqStack:
def __init__(self):
self.freq = defaultdict(int) # value -> count
self.group = defaultdict(list) # count -> values that reached it, in push order
self.max_freq = 0
def push(self, val):
self.freq[val] += 1
f = self.freq[val]
self.max_freq = max(self.max_freq, f)
self.group[f].append(val)
def pop(self):
val = self.group[self.max_freq].pop()
self.freq[val] -= 1
if not self.group[self.max_freq]:
self.max_freq -= 1
return val
// java
// LC 895 - Maximum Frequency Stack
// IDEA: bucket values by frequency, each bucket a stack -> pop = top of the maxFreq bucket
// time = O(1) per push / pop, space = O(n)
class FreqStack {
private final Map<Integer, Integer> freq = new HashMap<>(); // value -> count
private final Map<Integer, Deque<Integer>> group = new HashMap<>(); // count -> stack of values
private int maxFreq = 0;
public void push(int val) {
int f = freq.merge(val, 1, Integer::sum);
maxFreq = Math.max(maxFreq, f);
group.computeIfAbsent(f, x -> new ArrayDeque<>()).push(val); // keep copies in 1..f
}
public int pop() {
Deque<Integer> top = group.get(maxFreq);
int val = top.pop();
freq.merge(val, -1, Integer::sum);
if (top.isEmpty()) {
maxFreq--; // buckets are dense: maxFreq only ever drops by 1
}
return val;
}
}
陷阱
- push 時把值從舊桶移走(像 LFU 那樣)會讓 pop 出錯:從桶 2 彈出之後, 這個值必須還留在桶 1、還在它原本的 push 位置上。
- 用
(-freq, -push_index, value)的堆積也行,但每次呼叫是 O(log n) — 先把它當第一個想法講出來, 再給出分桶的解法。
有序 map/時間索引的例題
Time Based Key-Value Store — LC 981
關鍵 —
set被呼叫時時間戳嚴格遞增,所以每個 key 的 list 本來就依時間排好;get就是二分搜尋最後一筆時間 ≤ t 的資料。
歸約到:design § Pattern 8 — 雜湊表對到依時間排好的 list;「floor」查詢就是 bisect_right - 1。
# python
# LC 981 - Time Based Key-Value Store
# IDEA: key -> parallel lists of times and values (appended in time order); get = floor by bisect
# time = O(1) set, O(log n) get; space = O(n)
import bisect
from collections import defaultdict
class TimeMap:
def __init__(self):
self.times = defaultdict(list) # key -> timestamps, increasing
self.values = defaultdict(list) # key -> values, aligned with times
def set(self, key, value, timestamp):
self.times[key].append(timestamp)
self.values[key].append(value)
def get(self, key, timestamp):
i = bisect.bisect_right(self.times[key], timestamp) - 1 # last time <= timestamp
return self.values[key][i] if i >= 0 else ""
// java
// LC 981 - Time Based Key-Value Store
// IDEA: key -> list of (time, value) appended in time order; get = binary search for the last time <= t
// time = O(1) set, O(log n) get; space = O(n)
class TimeMap {
private final Map<String, List<Integer>> times = new HashMap<>();
private final Map<String, List<String>> values = new HashMap<>();
public void set(String key, String value, int timestamp) {
times.computeIfAbsent(key, k -> new ArrayList<>()).add(timestamp);
values.computeIfAbsent(key, k -> new ArrayList<>()).add(value);
}
public String get(String key, int timestamp) {
List<Integer> ts = times.get(key);
if (ts == null) return "";
int lo = 0, hi = ts.size() - 1, found = -1;
while (lo <= hi) { // last index with ts[i] <= timestamp
int mid = lo + (hi - lo) / 2;
if (ts.get(mid) <= timestamp) {
found = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return found == -1 ? "" : values.get(key).get(found);
}
}
陷阱
- 對
(time, value)tuple 的 list 做bisect,第二個欄位需要一個哨兵 ((t, chr(127))),而遇到開頭字元更大的值就會出錯。只對時間做 bisect。 - 比所有
set都早的查詢必須回傳"",而不是values[-1]:要檢查的是i == -1。
My Calendar I — LC 729
關鍵 — 已存的預約兩兩不重疊、以起點為 key,所以新的
[start, end)只可能和其中兩個衝突:起點在start或之前的那個(floor), 以及起點在它之後的那個(ceiling)。
歸約到:design § Pattern 8 — 有序 map 上的 floor/ceiling 查詢。
# python
# LC 729 - My Calendar I
# IDEA: sorted starts with aligned ends; check the floor (spills into us?) and the ceiling (we spill into it?)
# time = O(log n) search + O(n) list insert per book, space = O(n)
import bisect
class MyCalendar:
def __init__(self):
self.starts = []
self.ends = [] # aligned with starts
def book(self, start, end):
i = bisect.bisect_right(self.starts, start) - 1 # floor: last start <= start
if i >= 0 and self.ends[i] > start:
return False
j = i + 1 # ceiling: first start > start
if j < len(self.starts) and self.starts[j] < end:
return False
self.starts.insert(j, start)
self.ends.insert(j, end)
return True
// java
// LC 729 - My Calendar I
// IDEA: TreeMap start -> end; a booking is legal iff it fits between its two neighbours
// time = O(log n) per book, space = O(n)
class MyCalendar {
private final TreeMap<Integer, Integer> booked = new TreeMap<>(); // start -> end
public boolean book(int start, int end) {
Integer prev = booked.floorKey(start); // latest booking starting <= start
if (prev != null && booked.get(prev) > start) {
return false; // prev spills into us
}
Integer next = booked.ceilingKey(start); // earliest booking starting >= start
if (next != null && next < end) {
return false; // we spill into next
}
booked.put(start, end);
return true;
}
}
陷阱
- 區間是左閉右開:先
[10, 20)再[20, 30)是合法的。floor 的檢查寫成>=會把它擋掉。 - Python 沒有
TreeMap:list.insert是 O(n),對 LC 729 的 1,000 次呼叫沒問題;要講出來,或改用sortedcontainers.SortedList。
My Calendar II — LC 731
關鍵 — 每個起點記
+1、每個終點記-1;依 key 排序後的累加和, 就是那一刻正在進行的預約數。先暫時把新預約加進去, 只要累加和一碰到 3 就撤回。
歸約到:design § Pattern 8 — 把有序 map 當成稀疏的差分陣列(就是 scanning_line.md 的掃描線計數)。
# python
# LC 731 - My Calendar II
# IDEA: delta map +1 / -1, sweep in key order; reject and roll back if 3 bookings overlap
# time = O(n log n) per book, space = O(n)
from collections import defaultdict
class MyCalendarTwo:
def __init__(self):
self.delta = defaultdict(int)
def book(self, start, end):
self.delta[start] += 1
self.delta[end] -= 1
active = 0
for t in sorted(self.delta):
active += self.delta[t]
if active >= 3: # triple booking: undo and refuse
self.delta[start] -= 1
self.delta[end] += 1
return False
return True
陷阱
- 先檢查再加入需要掃第二遍;先加入、必要時撤回只要掃一遍。
- 總成本 O(n²) 在 1,000 次呼叫下沒問題;另一種解法(保存被重複預約的區間) 也是同樣的上界。
My Calendar III — LC 732
關鍵 — 和 LC 731 一樣的增量 map,只是永遠不撤回:答案是掃描過程中累加和的最大值。
歸約到:design § Pattern 8 — LC 731 的掃描,回報峰值而不是檢查上限。
# python
# LC 732 - My Calendar III
# IDEA: delta map +1 / -1; the max prefix sum in key order is the max concurrent bookings
# time = O(n log n) per book, space = O(n)
from collections import defaultdict
class MyCalendarThree:
def __init__(self):
self.delta = defaultdict(int)
def book(self, start, end):
self.delta[start] += 1
self.delta[end] -= 1
active = best = 0
for t in sorted(self.delta):
active += self.delta[t]
best = max(best, active)
return best
// java
// LC 732 - My Calendar III
// IDEA: TreeMap as a delta array on a sparse timeline; prefix-sum in key order
// time = O(n) per book (n = distinct endpoints), space = O(n)
class MyCalendarThree {
private final TreeMap<Integer, Integer> delta = new TreeMap<>();
public int book(int start, int end) {
delta.merge(start, 1, Integer::sum);
delta.merge(end, -1, Integer::sum);
int active = 0;
int best = 0;
for (int d : delta.values()) { // TreeMap iterates keys ascending
active += d;
best = Math.max(best, active);
}
return best;
}
}
陷阱
- 同一個 key 上的
-1和+1必須在讀取累加和之前先抵銷:[10, 20)和[20, 30)從不重疊。map 會把它們併成同一個 key,所以這點自動成立。
Range Module — LC 715
關鍵 — 讓追蹤中的區間保持互不重疊、已排序、彼此不相鄰、且非空。每個操作 用兩次二分搜尋找出它碰到的那一段已存區間,再把整段換成最多兩個區間, 在回傳前把不變量恢復。
歸約到:design § Pattern 8 — LC 729 的有序區間 map,改成可變的:寫入會合併鄰居,刪除會把區間切開。線段樹的寫法在 segment_tree.md。
# python
# LC 715 - Range Module
# IDEA: parallel sorted lists of disjoint [start, end); add/remove replace the touched run by slice assignment
# time = O(log n) search + O(n) slice per call, space = O(n)
import bisect
class RangeModule:
def __init__(self):
self.starts = []
self.ends = []
def addRange(self, left, right):
i = bisect.bisect_left(self.ends, left) # first interval ending at or after left
j = bisect.bisect_right(self.starts, right) # first interval starting after right
if i < j: # intervals i..j-1 touch [left, right): absorb them
left = min(left, self.starts[i])
right = max(right, self.ends[j - 1])
self.starts[i:j] = [left]
self.ends[i:j] = [right]
def queryRange(self, left, right):
i = bisect.bisect_right(self.starts, left) - 1 # the interval that could contain left
return i >= 0 and self.ends[i] >= right
def removeRange(self, left, right):
i = bisect.bisect_right(self.ends, left) # first interval ending after left
j = bisect.bisect_left(self.starts, right) # first interval starting at or after right
if i >= j:
return # nothing overlaps [left, right)
new_starts, new_ends = [], []
if self.starts[i] < left: # keep the head piece [start, left)
new_starts.append(self.starts[i])
new_ends.append(left)
if self.ends[j - 1] > right: # keep the tail piece [right, end)
new_starts.append(right)
new_ends.append(self.ends[j - 1])
self.starts[i:j] = new_starts
self.ends[i:j] = new_ends
// java
// LC 715 - Range Module
// IDEA: TreeMap of merged disjoint [start, end); add absorbs neighbours, remove re-inserts the surviving pieces
// time = O(log n) amortised per call, space = O(n)
class RangeModule {
private final TreeMap<Integer, Integer> ranges = new TreeMap<>(); // start -> end
public void addRange(int left, int right) {
Integer s = ranges.floorKey(left);
Integer e = ranges.floorKey(right);
if (s != null && ranges.get(s) >= left) {
left = s; // touches or overlaps on the left: absorb
}
if (e != null && ranges.get(e) > right) {
right = ranges.get(e); // extends past right: absorb
}
ranges.subMap(left, true, right, true).clear(); // drop everything swallowed
ranges.put(left, right);
}
public boolean queryRange(int left, int right) {
Integer s = ranges.floorKey(left);
return s != null && ranges.get(s) >= right; // one merged interval must cover it all
}
public void removeRange(int left, int right) {
Integer s = ranges.floorKey(left);
Integer e = ranges.floorKey(right);
if (e != null && ranges.get(e) > right) {
ranges.put(right, ranges.get(e)); // keep the tail piece [right, oldEnd)
}
if (s != null && ranges.get(s) > left) {
ranges.put(s, left); // keep the head piece [oldStart, left)
}
ranges.subMap(left, true, right, false).clear();
}
}
試跑
add [10, 20) -> [10,20)
remove [14, 16) -> [10,14) [16,20) (one interval split in two)
query [10, 14) -> True
query [13, 15) -> False (14..16 was removed)
query [16, 17) -> True
陷阱
- 相鄰的區間必須合併:先
add [10, 20)再add [20, 30)是同一個區間,否則query [15, 25)會錯誤地失敗。addRange裡的搜尋用的是ends >= left和starts <= right,兩邊都含等號。 removeRange裡頭尾兩段可能來自同一個區間(被切成兩段);要先把兩段都算出來, 再替換那一整段。
路徑階層的例題
Design File System — LC 1166
關鍵 —
createPath只需要知道父路徑存在,而父路徑就是到最後一個/為止的部分。 一個「完整路徑 → 值」的 map 一次查找就能回答; 因為沒有ls,所以不需要樹。
歸約到:design § Pattern 9 — 選擇中「用完整路徑當 key 的 map」那一邊。
# python
# LC 1166 - Design File System
# IDEA: map full path -> value; a path may be created only if it is new and its parent exists
# time = O(L) per call for a path of length L, space = O(total path length)
class FileSystem:
def __init__(self):
self.value_of = {"": -1} # the root's parent string, so "/a" has a parent
def createPath(self, path, value):
if not path or path == "/" or path in self.value_of:
return False
parent = path[:path.rfind("/")]
if parent not in self.value_of:
return False
self.value_of[path] = value
return True
def get(self, path):
if path == "":
return -1
return self.value_of.get(path, -1)
陷阱
- 在
"/a"之前建立"/a/b"必須失敗:檢查父路徑就是整道題。 - 建立已存在的路徑要回傳
False,並保留舊值。
Design Log Storage System — LC 635
關鍵 — 時間戳是固定寬度的
Year:Month:Day:Hour:Minute:Second字串,所以 截到某個粒度就是取前綴,而把兩個前綴當字串比較,就等於把它們當時間比較。 範圍查詢於是變成每筆 log 做兩次字串比較。
歸約到:design § Pattern 9 — 時間戳是由六個片段組成的路徑,粒度決定要比到多深。
# python
# LC 635 - Design Log Storage System
# IDEA: keep (timestamp, id); compare the first k characters, where k is set by the granularity
# time = O(1) put, O(n) retrieve; space = O(n)
class LogSystem:
PREFIX = {"Year": 4, "Month": 7, "Day": 10, "Hour": 13, "Minute": 16, "Second": 19}
def __init__(self):
self.logs = [] # (timestamp, id)
def put(self, id, timestamp):
self.logs.append((timestamp, id))
def retrieve(self, start, end, granularity):
k = self.PREFIX[granularity]
low, high = start[:k], end[:k]
return [log_id for ts, log_id in self.logs if low <= ts[:k] <= high]
陷阱
- 解析成整數、逐欄比較也行,但固定寬度的格式正是這道題設計好的技巧。
- 在選定的粒度下,兩端都包含在內。
Design In-Memory File System — LC 588
關鍵 —
ls、mkdir和寫檔都走同一條路徑,所以結構就是 由路徑片段組成的字典樹,節點是存放子目錄與檔案的目錄。對檔案ls只回傳它的名字;對目錄則回傳排序後的子項名稱。
歸約到:design § Pattern 9 — 選擇中「字典樹」那一邊,因為 ls 要列出某個前綴底下的東西。
# python
# LC 588 - Design In-Memory File System
# IDEA: nested dict trie: each dir is {"dirs": {}, "files": {}}; walk the path's components
# time = O(L) per walk for a path of L components (+ O(k log k) to sort k names in ls), space = O(total content)
class FileSystem:
def __init__(self):
self.root = {"dirs": {}, "files": {}}
def _walk(self, parts):
"""Return the directory at parts, creating missing ones."""
node = self.root
for part in parts:
node = node["dirs"].setdefault(part, {"dirs": {}, "files": {}})
return node
def ls(self, path):
parts = [p for p in path.split("/") if p]
if parts:
parent = self._walk(parts[:-1])
if parts[-1] in parent["files"]: # a file lists only itself
return [parts[-1]]
node = self._walk(parts)
return sorted(list(node["dirs"]) + list(node["files"]))
def mkdir(self, path):
self._walk([p for p in path.split("/") if p])
def addContentToFile(self, filePath, content):
parts = [p for p in filePath.split("/") if p]
directory = self._walk(parts[:-1])
directory["files"][parts[-1]] = directory["files"].get(parts[-1], "") + content
def readContentFromFile(self, filePath):
parts = [p for p in filePath.split("/") if p]
return self._walk(parts[:-1])["files"][parts[-1]]
陷阱
- Python 3 的
dict.keys() + dict.keys()會丟TypeError;要先轉成 list。 ls("/")切開後沒有任何片段;走訪必須回傳根,而不是失敗。
聚合計數的例題
Design Tic-Tac-Toe — LC 348
關鍵 — 某一列、某一行或某條對角線放滿 n 個同一玩家的棋子,該玩家就贏。每條線上 玩家 1 記
+1、玩家 2 記-1;一條線屬於某個玩家,若且唯若它的 總和是±n,而一步棋最多只碰到四條線。
歸約到:design § Pattern 10 — 在寫入時更新聚合值,讓讀取(誰贏了?)是 O(1)。
# python
# LC 348 - Design Tic-Tac-Toe
# IDEA: one running sum per row, column and both diagonals; +1 for player 1, -1 for player 2
# time = O(1) per move, space = O(n)
class TicTacToe:
def __init__(self, n):
self.n = n
self.rows = [0] * n
self.cols = [0] * n
self.diagonal = 0
self.anti_diagonal = 0
def move(self, row, col, player):
value = 1 if player == 1 else -1
self.rows[row] += value
self.cols[col] += value
if row == col:
self.diagonal += value
if row + col == self.n - 1:
self.anti_diagonal += value
if self.n in (abs(self.rows[row]), abs(self.cols[col]), abs(self.diagonal), abs(self.anti_diagonal)):
return player
return 0
陷阱
- 奇數邊長棋盤的中心同時在兩條對角線上:要兩個
if,不是if / elif。 - 每步之後掃描整個棋盤,每步是 O(n²);計數器是 O(1)。
Design Underground System — LC 1396
關鍵 — 查詢的是每條
(start, end)路線的平均,所以為每條路線保存總時間 和行程次數,在出站時更新。乘客進行中的行程是唯一的個人狀態, 行程結束時就刪掉。
歸約到:design § Pattern 10 — 平均值就是在寫入時保存的總和與次數。
# python
# LC 1396 - Design Underground System
# IDEA: open trips id -> (station, time); route (start, end) -> [total time, trips]
# time = O(1) per call, space = O(passengers + routes)
from collections import defaultdict
class UndergroundSystem:
def __init__(self):
self.open_trip = {} # id -> (station, time)
self.route = defaultdict(lambda: [0, 0]) # (start, end) -> [total time, trips]
def checkIn(self, id, stationName, t):
self.open_trip[id] = (stationName, t)
def checkOut(self, id, stationName, t):
start_station, start_time = self.open_trip.pop(id)
totals = self.route[(start_station, stationName)]
totals[0] += t - start_time
totals[1] += 1
def getAverageTime(self, startStation, endStation):
total, trips = self.route[(startStation, endStation)]
return total / trips
陷阱
- 保存每一趟的時間、讀取時再平均,會讓
getAverageTime變成 O(行程數)。 - 路線有方向:
(A, B)和(B, A)是不同的 key。