Notes & software courses · Free to learn
Aph's Blog
On this page

ข้อ 70 · LC208 Implement Trie (Prefix Tree) (สร้าง Trie) 🟡

👋 อ่านฟรีทั้งหมดบน Aph's Blog — เนื้อหาภาษาไทย ทำตามทีละหน้าใน sidebar ได้เลย หากมีข้อเสนอแนะหรืออยากให้เพิ่มหัวข้อไหน บอกได้เสมอ

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 นี้

Example 1
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
Constraints (ข้อจำกัด)
  • 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(ความยาวคำ) ล้วน ๆ

  1. insert: เริ่มที่ root traverse ตัวอักษรทีละตัว ถ้ายังไม่มีเส้นทางก็สร้าง node ใหม่ พอถึงตัวสุดท้ายปัก is_end = True
  2. สร้าง helper function _find(prefix) ที่ traverse ตามตัวอักษร ถ้าหลุดเส้นทาง return None ไม่งั้น return node ปลายทาง
  3. search(word): เรียก _find แล้วต้องได้ node ที่ไม่ None และ node.is_end เป็น True
  4. startsWith(prefix): เรียก _find แล้วแค่เช็คว่าไม่ None ก็พอ
จุดพลาดที่พบบ่อย

ลืมเช็ค is_end ใน search ทำให้ search('app') ตอบ True ทั้งที่ยังไม่เคย insert คำว่า app อีกจุดคือต้องเริ่ม traverse จาก self.root ใหม่ทุกครั้ง อย่าใช้ node ค้างจากการเรียกก่อนหน้า

▶ เฉลยละเอียด (ลองเองก่อนนะ)
เฉลย (Python) — โค้ดนี้รันได้จริงpython
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"))        # True
Output
True
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 กันเลย

💡 สรุป pattern

Trie เหมาะกับงาน search คำ/prefix จำนวนมาก key คือ children เป็น hash map และ is_end แยกคำจริงจากทางผ่าน จำ template insert/_find นี้ไว้ ต่อยอดได้อีกหลายข้อ