On this page
ข้อ 70 · LC208 Implement Trie (Prefix Tree) (สร้าง Trie) 🟡
implement class Trie ที่มี insert, search, startsWith โดยแยกคำจริงออกจาก prefix ด้วย is_end
โจทย์ (LC208): ให้ implement class Trie ที่จำลอง data structure Prefix Tree ประกอบด้วย constructor Trie() และสาม method คือ insert(word) เพิ่ม string word เข้า trie, search(word) return true ถ้า word เคยถูก insert ไว้ (ตรงกันทั้งคำ) และ startsWith(prefix) return true ถ้ามีคำที่เคย insert ไว้ขึ้นต้นด้วย prefix นี้
- Input:
- Trie(); insert("apple"); search("apple"); search("app"); startsWith("app"); insert("app"); search("app")
- Output:
- null, null, true, false, true, null, true
- Explanation:
- หลัง insert("apple") ตัว search("apple") ตรงทั้งคำจึงได้ true แต่ search("app") ได้ false เพราะยังไม่เคย insert คำว่า app ทั้งคำ ส่วน startsWith("app") ได้ true เพราะ apple ขึ้นต้นด้วย app พอ insert("app") เพิ่มเข้าไป search("app") จึงกลายเป็น true
- 1 <= word.length, prefix.length <= 2000
- ตัวอักษรอังกฤษพิมพ์เล็กเท่านั้น
- เรียก insert, search, startsWith รวมกันได้มากสุด 3 × 10^4 ครั้ง
แนวทาง — ต้องใช้อะไร & คิดยังไง
ข้อนี้คือการนำ trie มา wrap เป็น class ใช้ TrieNode (โหนด) ที่มี children (hash map ตัวอักษร -> node) และ is_end (flag บอกว่ามีคำจบที่นี่) หัวใจคือแยกความต่างระหว่างมีคำนี้จริงกับมีคำที่ขึ้นต้นด้วยสิ่งนี้ให้ออก
ถ้าเก็บคำเป็น array (ลิสต์) ธรรมดาแล้ว search ด้วยการ iterate ทุกคำ แต่ละครั้งจะช้า O(จำนวนคำ × ความยาว) การใช้ trie ทำให้ทุก operation เหลือ O(ความยาวคำ) ล้วน ๆ
- insert: เริ่มที่ root traverse ตัวอักษรทีละตัว ถ้ายังไม่มีเส้นทางก็สร้าง node ใหม่ พอถึงตัวสุดท้ายปัก is_end = True
- สร้าง helper function _find(prefix) ที่ traverse ตามตัวอักษร ถ้าหลุดเส้นทาง return None ไม่งั้น return node ปลายทาง
- search(word): เรียก _find แล้วต้องได้ node ที่ไม่ None และ node.is_end เป็น True
- startsWith(prefix): เรียก _find แล้วแค่เช็คว่าไม่ None ก็พอ
ลืมเช็ค is_end ใน search ทำให้ search('app') ตอบ True ทั้งที่ยังไม่เคย insert คำว่า app อีกจุดคือต้องเริ่ม traverse จาก self.root ใหม่ทุกครั้ง อย่าใช้ node ค้างจากการเรียกก่อนหน้า
▶ เฉลยละเอียด (ลองเองก่อนนะ)
class TrieNode:
def __init__(self):
self.children = {} # ตัวอักษร -> TrieNode
self.is_end = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
for ch in word:
if ch not in node.children:
node.children[ch] = TrieNode() # ยังไม่มีเส้นทาง สร้างใหม่
node = node.children[ch]
node.is_end = True # ปักธงว่าคำจบที่นี่
def _find(self, prefix):
# เดินตามตัวอักษร ถ้าหลุดเส้นทางเมื่อไรคืน None
node = self.root
for ch in prefix:
if ch not in node.children:
return None
node = node.children[ch]
return node
def search(self, word):
node = self._find(word)
# ต้องเดินถึง และมีคำจบตรงนั้นจริง
return node is not None and node.is_end
def startsWith(self, prefix):
# แค่เดินถึงได้ก็พอ ไม่ต้องสน is_end
return self._find(prefix) is not None
trie = Trie()
trie.insert("apple")
print(trie.search("apple")) # True
print(trie.search("app")) # False
print(trie.startsWith("app")) # True
trie.insert("app")
print(trie.search("app")) # TrueTrue
False
True
Trueหัวใจของข้อนี้คือแยกความต่างระหว่างมีคำนี้จริงกับมีคำที่ขึ้นต้นด้วยสิ่งนี้ให้ออก เราจึงดึงส่วนที่ซ้ำกัน (การ traverse ตามตัวอักษรจนสุด prefix) ออกมาเป็น function _find ที่ return node ปลายทางหรือ None แล้ว search เพิ่มเงื่อนไขเช็ค is_end ส่วน startsWith แค่ดูว่าไม่ None
ถ้าไม่แยก _find ก็เขียนได้เหมือนกันแต่โค้ดจะซ้ำสองรอบ การดึงออกมาช่วยให้ search กับ startsWith ต่างกันแค่บรรทัดสุดท้าย อ่านง่ายและลดโอกาสพลาด
Time O(L) ต่อการเรียกหนึ่งครั้ง เมื่อ L คือความยาวคำหรือ prefix · Space O(จำนวนตัวอักษรทั้งหมดที่เก็บ) กรณีแย่สุดคือทุกคำไม่ share prefix กันเลย
Trie เหมาะกับงาน search คำ/prefix จำนวนมาก key คือ children เป็น hash map และ is_end แยกคำจริงจากทางผ่าน จำ template insert/_find นี้ไว้ ต่อยอดได้อีกหลายข้อ