cswork / 模板打开 500 路题单

第 34 站 · 设计与位运算

字典、文件系统与序列化

字典树:结点存孩子和计数 每次操作 O(单词长度)

周赛难度分 难度分 1479–难度分 1479· 中位 难度分 1479(1/6 道题有 zerotrac 周赛分)

打开 500 路题单 · 第 34 站 · 6 题
先学
学完去
没有后续站依赖它。
面试频次
2 家公司考过 · 面经出现 2 次Apple 1、DoorDash 1

先做这道

LC 676实现一个魔法字典中等
卡住再看

设计只允许恰好一次字符修改的字典查询,区分精确匹配与合法近似匹配。

要点

  1. 每个结点代表一个前缀;插入和查询都是顺着字符往下走,耗时只和单词长度有关。
  2. 结点上存计数(有多少单词经过、有多少单词在此结束),前缀计数和删除都能 O(L) 完成。
  3. 序列化二叉树用前序遍历加空结点标记(如 "#"),反序列化时按同样的顺序递归读回来。

易错点

  • 把 search(完整单词)和 startsWith(前缀)混为一谈,忘了检查结束标记。
  • 固定 26 叉数组默认只有小写字母;有大写、数字或 Unicode 时,孩子改用哈希表存。
  • 序列化时值之间不加分隔符,多位数和负数就没法还原。

看动画

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

交互演示 · 逐步观察

字典树 · 插入与查找

插入 cat, car, cart, dog, do,再查找 car, cap, do

换个输入
·
当前处理已发现 / 已计算答案
节点数
1
单词数
0

只有一个空的根节点。每条边代表一个字符,路径拼出前缀。

始终成立从根到任一节点的路径就是一个前缀;节点下写着单词的,才是一个完整单词的结尾。

模板

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

Python
class Trie:
    def __init__(self):
        self.children: dict[str, Trie] = {}
        self.prefix = 0  # words passing through this node
        self.words = 0  # words ending exactly here

    def insert(self, word: str) -> None:
        node = self
        for ch in word:
            node = node.children.setdefault(ch, Trie())
            node.prefix += 1
        node.words += 1

    def _walk(self, s: str):
        node = self
        for ch in s:
            node = node.children.get(ch)
            if node is None:
                return None
        return node

    def count_word(self, word: str) -> int:
        node = self._walk(word)
        return node.words if node else 0

    def count_prefix(self, prefix: str) -> int:
        node = self._walk(prefix)
        return node.prefix if node else 0
C++
// Lowercase words. Nodes live in one vector; child index 0 means "none" (0 is the root).
class Trie {
    struct Node {
        array<int, 26> next{};
        int prefix = 0, words = 0;  // words passing through / ending exactly here
    };
    vector<Node> nodes = vector<Node>(1);

    int walk(const string& s) const {  // node index for s, or -1
        int cur = 0;
        for (char ch : s)
            if (!(cur = nodes[cur].next[ch - 'a'])) return -1;
        return cur;
    }

public:
    void insert(const string& word) {
        int cur = 0;
        for (char ch : word) {
            int c = ch - 'a';
            if (!nodes[cur].next[c]) {
                nodes[cur].next[c] = nodes.size();
                nodes.emplace_back();  // may reallocate: never hold a Node& across this
            }
            cur = nodes[cur].next[c];
            nodes[cur].prefix++;
        }
        nodes[cur].words++;
    }

    int countWord(const string& word) const {
        int i = walk(word);
        return i < 0 ? 0 : nodes[i].words;
    }

    int countPrefix(const string& prefix) const {
        int i = walk(prefix);
        return i < 0 ? 0 : nodes[i].prefix;
    }
};
Java
// Lowercase words.
class Trie {
    private final Trie[] next = new Trie[26];
    private int prefix, words; // words passing through / ending exactly here

    void insert(String word) {
        Trie node = this;
        for (char ch : word.toCharArray()) {
            int c = ch - 'a';
            if (node.next[c] == null) node.next[c] = new Trie();
            node = node.next[c];
            node.prefix++;
        }
        node.words++;
    }

    private Trie walk(String s) {
        Trie node = this;
        for (char ch : s.toCharArray()) {
            node = node.next[ch - 'a'];
            if (node == null) return null;
        }
        return node;
    }

    int countWord(String word) {
        Trie node = walk(word);
        return node == null ? 0 : node.words;
    }

    int countPrefix(String prefix) {
        Trie node = walk(prefix);
        return node == null ? 0 : node.prefix;
    }
}
Go
// Trie stores lowercase words.
type Trie struct {
	next   [26]*Trie
	prefix int // words passing through this node
	words  int // words ending exactly here
}

func (t *Trie) Insert(word string) {
	node := t
	for i := 0; i < len(word); i++ {
		c := word[i] - 'a'
		if node.next[c] == nil {
			node.next[c] = &Trie{}
		}
		node = node.next[c]
		node.prefix++
	}
	node.words++
}

func (t *Trie) walk(s string) *Trie {
	node := t
	for i := 0; i < len(s) && node != nil; i++ {
		node = node.next[s[i]-'a']
	}
	return node
}

func (t *Trie) CountWord(word string) int {
	if node := t.walk(word); node != nil {
		return node.words
	}
	return 0
}

func (t *Trie) CountPrefix(prefix string) int {
	if node := t.walk(prefix); node != nil {
		return node.prefix
	}
	return 0
}

常见变体

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

前序 + 空结点标记的序列化时间 O(n) · 空间 O(n)

把树存成字符串再还原(序列化、比较子树结构、去重子树):前序遍历,空孩子写 #,逗号分隔;还原时按同样的前序顺序一个个读,读到 # 就返回空。只有前序加空标记就能唯一确定一棵树。LC 297 二叉树的序列化与反序列化;BST 可以省掉 #,见 LC 449。

Python
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val, self.left, self.right = val, left, right

def serialize(root) -> str:
    """Preorder with '#' for each missing child, comma-separated (LC 297)."""
    out = []

    def walk(node) -> None:
        if not node:
            out.append("#")
            return
        out.append(str(node.val))
        walk(node.left)
        walk(node.right)

    walk(root)
    return ",".join(out)


def deserialize(data: str):
    """The tree serialize() wrote: the tokens are read back in the same preorder."""
    tokens = iter(data.split(","))

    def build():
        t = next(tokens)
        if t == "#":
            return None
        node = TreeNode(int(t))
        node.left = build()  # left before right, as they were written
        node.right = build()
        return node

    return build()
C++
struct TreeNode {
    int val;
    TreeNode *left, *right;
    TreeNode(int v, TreeNode* l = nullptr, TreeNode* r = nullptr) : val(v), left(l), right(r) {}
};

// Preorder with '#' for each missing child, comma-separated (LC 297).
string serialize(TreeNode* root) {
    if (!root) return "#";
    return to_string(root->val) + "," + serialize(root->left) + "," + serialize(root->right);
}

// The tree serialize() wrote: the tokens are read back in the same preorder.
TreeNode* deserialize(const string& data) {
    size_t pos = 0;
    function<TreeNode*()> build = [&]() -> TreeNode* {
        size_t end = data.find(',', pos);
        if (end == string::npos) end = data.size();
        string t = data.substr(pos, end - pos);
        pos = end + 1;
        if (t == "#") return nullptr;
        TreeNode* node = new TreeNode(stoi(t));
        node->left = build();  // left before right, as they were written
        node->right = build();
        return node;
    };
    return build();
}
Java
class TreeNode {
    int val;
    TreeNode left, right;

    TreeNode(int val) {
        this.val = val;
    }

    TreeNode(int val, TreeNode left, TreeNode right) {
        this.val = val;
        this.left = left;
        this.right = right;
    }
}

class Codec {
    private String[] tokens;
    private int next;

    // Preorder with '#' for each missing child, comma-separated (LC 297).
    String serialize(TreeNode root) {
        if (root == null) return "#";
        return root.val + "," + serialize(root.left) + "," + serialize(root.right);
    }

    // The tree serialize() wrote: the tokens are read back in the same preorder.
    TreeNode deserialize(String data) {
        tokens = data.split(",");
        next = 0;
        return build();
    }

    private TreeNode build() {
        String t = tokens[next++];
        if (t.equals("#")) return null;
        TreeNode node = new TreeNode(Integer.parseInt(t));
        node.left = build(); // left before right, as they were written
        node.right = build();
        return node;
    }
}
Go
import (
	"strconv"
	"strings"
)

type TreeNode struct {
	Val         int
	Left, Right *TreeNode
}

// serialize writes the tree in preorder with "#" for each missing child, comma-separated (LC 297).
func serialize(root *TreeNode) string {
	if root == nil {
		return "#"
	}
	return strconv.Itoa(root.Val) + "," + serialize(root.Left) + "," + serialize(root.Right)
}

// deserialize rebuilds the tree serialize wrote, reading the tokens back in the same preorder.
func deserialize(data string) *TreeNode {
	tokens := strings.Split(data, ",")
	next := 0
	var build func() *TreeNode
	build = func() *TreeNode {
		t := tokens[next]
		next++
		if t == "#" {
			return nil
		}
		v, _ := strconv.Atoi(t)
		node := &TreeNode{Val: v}
		node.Left = build() // left before right, as they were written
		node.Right = build()
		return node
	}
	return build()
}
字典树上 DFS,带着「还能改几个字母」建树 O(总长度) · 查询最坏 O(26 × L²)(LC 211 的 . 通配符可能走遍整棵树)

查询不是精确匹配,而是允许通配符或恰好改一个字母(LC 676 实现一个魔法字典、LC 211 支持 . 的单词查找):在字典树上 DFS,每层按当前字母走对应孩子;字母不同时,只有还没用掉修改机会才能走别的孩子。走到单词结尾时要求刚好改过一次。

Python
class MagicDictionary:
    """Does a word differ from some dictionary word in exactly one letter? (LC 676)"""

    def __init__(self, words: list[str]):
        self.root: dict = {}  # a trie of nested dicts; "$" marks the end of a word
        for w in words:
            node = self.root
            for ch in w:
                node = node.setdefault(ch, {})
            node["$"] = True

    def search(self, word: str) -> bool:
        def dfs(node: dict, i: int, changed: bool) -> bool:
            if i == len(word):
                return changed and "$" in node  # exactly one change, ending on a word
            for ch, child in node.items():
                if ch == "$":
                    continue
                if ch == word[i]:
                    if dfs(child, i + 1, changed):
                        return True
                elif not changed and dfs(child, i + 1, True):  # spend the one change here
                    return True
            return False

        return dfs(self.root, 0, False)
C++
// Does a word differ from some dictionary word in exactly one letter? (LC 676)
class MagicDictionary {
    struct Node {
        Node* next[26] = {};
        bool end = false;  // a dictionary word ends here
    };
    Node root;

    bool dfs(const Node* node, const string& word, size_t i, bool changed) const {
        if (i == word.size()) return changed && node->end;  // exactly one change, ending on a word
        for (int c = 0; c < 26; c++) {
            const Node* child = node->next[c];
            if (!child) continue;
            if (c == word[i] - 'a') {
                if (dfs(child, word, i + 1, changed)) return true;
            } else if (!changed && dfs(child, word, i + 1, true)) {  // spend the one change here
                return true;
            }
        }
        return false;
    }

public:
    explicit MagicDictionary(const vector<string>& words) {
        for (const string& w : words) {
            Node* node = &root;
            for (char ch : w) {
                if (!node->next[ch - 'a']) node->next[ch - 'a'] = new Node();
                node = node->next[ch - 'a'];
            }
            node->end = true;
        }
    }
    bool search(const string& word) const { return dfs(&root, word, 0, false); }
};
Java
// Does a word differ from some dictionary word in exactly one letter? (LC 676)
class MagicDictionary {
    private static class Node {
        final Node[] next = new Node[26];
        boolean end; // a dictionary word ends here
    }

    private final Node root = new Node();

    MagicDictionary(String[] words) {
        for (String w : words) {
            Node node = root;
            for (char ch : w.toCharArray()) {
                if (node.next[ch - 'a'] == null) node.next[ch - 'a'] = new Node();
                node = node.next[ch - 'a'];
            }
            node.end = true;
        }
    }

    boolean search(String word) {
        return dfs(root, word, 0, false);
    }

    private boolean dfs(Node node, String word, int i, boolean changed) {
        if (i == word.length()) return changed && node.end; // exactly one change, ending on a word
        for (int c = 0; c < 26; c++) {
            Node child = node.next[c];
            if (child == null) continue;
            if (c == word.charAt(i) - 'a') {
                if (dfs(child, word, i + 1, changed)) return true;
            } else if (!changed && dfs(child, word, i + 1, true)) { // spend the one change here
                return true;
            }
        }
        return false;
    }
}
Go
type trieNode struct {
	next [26]*trieNode
	end  bool // a dictionary word ends here
}

// MagicDictionary answers whether a word differs from some dictionary word in exactly one letter (LC 676).
type MagicDictionary struct {
	root *trieNode
}

func NewMagicDictionary(words []string) *MagicDictionary {
	root := &trieNode{}
	for _, w := range words {
		node := root
		for i := 0; i < len(w); i++ {
			c := w[i] - 'a'
			if node.next[c] == nil {
				node.next[c] = &trieNode{}
			}
			node = node.next[c]
		}
		node.end = true
	}
	return &MagicDictionary{root}
}

func (d *MagicDictionary) Search(word string) bool {
	var dfs func(node *trieNode, i int, changed bool) bool
	dfs = func(node *trieNode, i int, changed bool) bool {
		if i == len(word) {
			return changed && node.end // exactly one change, ending on a word
		}
		for c, child := range node.next {
			if child == nil {
				continue
			}
			if byte(c) == word[i]-'a' {
				if dfs(child, i+1, changed) {
					return true
				}
			} else if !changed && dfs(child, i+1, true) { // spend the one change here
				return true
			}
		}
		return false
	}
	return dfs(d.root, 0, false)
}

为什么对

  1. 字典树的每个节点唯一对应一个前缀(从根到它的路径),所以查前缀就是沿路径走 L 步,和存了多少个单词无关。
  2. 节点上的计数(经过的单词数、在此结束的单词数)在插入时沿路径各加一,所以以某个前缀开头的单词数就是终点节点上的计数。
  3. 带空标记的先序序列能唯一确定一棵二叉树:反序列化按同样的顺序读,遇到空标记就返回,每个值和每个空位都恰好被读一次。

30 秒口述稿

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

中文
字典树的每个节点代表一个前缀,插入和查找都是一个字符一个字符往下走,复杂度只和单词长度有关。节点上可以存经过的单词数和在此结束的单词数,这样前缀计数和删除都是 O(L)。二叉树序列化我用先序遍历,空节点写成一个特殊标记,比如井号,因为带空标记的先序能唯一确定一棵树。反序列化按同样的顺序读回来,遇到标记就返回空。
English
In a trie each node stands for one prefix, and insert and lookup walk down one character at a time, so the cost depends only on the word length. Nodes can store how many words pass through and how many end there, which makes prefix counts and deletion O(L). To serialize a binary tree I write a preorder walk with a marker such as a hash sign for empty children, because preorder with null markers pins down the tree uniquely. Deserializing reads the tokens back in the same order and returns an empty child at each marker.

自评一下

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

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

小测

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

3 道题
  1. 字典树(Trie)里已经存了 N 个单词,查找一个长度为 L 的单词是否存在,时间是?
  2. 为什么把二叉树序列化成「带空节点标记(如 #)的前序遍历」就足以还原,而普通的前序遍历不行?
  3. 把一组单词建成字典树,时间复杂度是?
    def build(words):
        root = {}
        for w in words:
            node = root
            for ch in w:
                node = node.setdefault(ch, {})
            node['$'] = True
        return root

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

模板练习

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

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

class Trie:
    def __init__(self):
        self.children: dict[str, Trie] = {}
        self.prefix = 0
        self.words = 0

    def insert(self, word: str) -> None:
        node = self
        for ch in word:
        return node.words if node else 0

    def count_prefix(self, prefix: str) -> int:
        node = self._walk(prefix)
        return node.prefix if node else 0

分档练习

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

核心 3

#题目一句话思路难度分
676实现一个魔法字典设计只允许恰好一次字符修改的字典查询,区分精确匹配与合法近似匹配。
1166设计文件系统 🔒实现路径创建和读取,检查父路径存在、重复创建及根目录语义。难度分 1479
297二叉树的序列化与反序列化设计可逆树编码并保留空孩子信息,通过序列化往返验证结构和数值。

进阶 3

#题目一句话思路难度分
745前缀和后缀搜索围绕前缀加后缀查询预建索引,分析Trie或组合键的查询与空间取舍。
588设计内存文件系统 🔒设计目录树与文件内容接口,区分文件和目录并保证目录列表有序。
449序列化和反序列化二叉搜索树利用BST取值边界减少编码冗余,说明恢复结构依赖的有序约束。

Core Skills

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