系統設計題 — 實作範例

設計與模擬優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值 更新於 Nov 12, 2026
章節優先度優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值優先度 3/5 — 值得會 — 多半是必備模式的變形值得會優先度 2/5 — 冷門 — 讀過一次即可,除非目標公司已知會問冷門只標在真正需要的章節上 —— 沒標的是背景/參考資料。

範圍 — 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
# 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
# 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
# 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
# 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
# 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
# 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
# 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
// 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
# 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
// 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
# 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
// 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
# 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
// 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
# 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
# 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
// 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
# 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
// 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();
    }
}

試跑

text
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
# 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
# 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
# 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
# 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
# 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。