นากแม่น้ำสีมิ้นต์ต้นฉบับกำลังต่อแผ่นคำไร้อักษรหลายขนาดให้ปิดช่องทางเดินพอดีโดยไม่เหลือรอยแยก

LeetCode #139 Word Break: ต่อคำให้เต็มสตริงโดยไม่หลงทางซ้ำ

พี่โชว์Engineering

Word Break ให้สตริงกับพจนานุกรม แล้วถามว่าสามารถแบ่งสตริงทั้งหมดเป็นคำในพจนานุกรมได้หรือไม่ คำเดิมใช้ซ้ำได้ จุดหลอกคือการเลือกคำที่ดูถูกตอนต้นอาจพาไปทางตัน แต่ยังมีการแบ่งแบบอื่นที่สำเร็จครับ

ให้นึกถึงการปูกระเบื้องบนทางเดิน เราไม่ต้องจำลำดับกระเบื้องทุกแบบ แค่จำว่าตำแหน่งไหน “เดินมาถึงได้” แล้วลองวางแผ่นถัดไปจากจุดนั้น

วิธีที่ 1: Bottom-up DP — ค่าเริ่มต้นที่แนะนำ

ให้ dp[i] หมายถึง prefix ความยาว i แบ่งได้ ตั้ง dp[0] = True แล้วจากทุกจุดที่ถึงได้ ลองความยาวคำไม่เกินคำยาวสุด เวลา worst case O(n·L²) เพราะตัด substring และ hash โดย L คือความยาวคำสูงสุด; พื้นที่ O(n + จำนวนอักขระในพจนานุกรม) อ่านง่าย ไม่มี recursion limit และหยุดทันทีเมื่อถึงปลายสตริง

วิธีที่ 2: Memoized DFS

เริ่มที่ index ศูนย์ ลองทุกคำ แล้ว cache ว่า suffix ที่ index นี้ไปถึงปลายได้หรือไม่ State มี O(n) และเวลาคล้าย DP เหมาะเมื่ออธิบาย recursion ง่าย แต่ Python อาจชน recursion depth เมื่อสตริงยาว และ call stack เพิ่ม O(n)

วิธีที่ 3: BFS บนตำแหน่ง

มอง index เป็น node และคำที่ match เป็น edge ใช้ queue เดินไปยังปลายคำ พร้อม visited กันตรวจ suffix ซ้ำ เวลาและพื้นที่ใกล้ DP ข้อดีคือ mental model ชัดเมื่อจะต่อยอดหา “จำนวนคำต่ำสุด” แต่ overhead queue มากกว่า boolean DP เล็กน้อย

วิธีที่ 4: Trie + DP/DFS

สร้าง trie แล้วเดินตัวอักษรจากแต่ละ index โดยไม่สร้าง substring เหมาะเมื่อ dictionary ใหญ่ คำยาว และต้องตอบหลายสตริง แลกกับโค้ดและ memory ของ node ที่ซับซ้อนขึ้น

เงื่อนไขเลือกเหตุผล
input ทั่วไป อ่านง่ายBottom-up DPinvariant ตรงและไม่มี recursion
ต้องคืนเส้นทางแบ่งคำDP + parentย้อนสร้างคำตอบได้
dictionary ใช้ซ้ำหลาย queryTrie + DPamortize ค่าสร้าง trie
memory จำกัดDP แบบ booleanO(n) และไม่เก็บทุกเส้นทาง
input มาเป็น streamต้อง buffer ก่อนคำอาจคร่อม chunk และผลสุดท้ายยังกลับได้
input เรียงแล้วไม่ช่วยโดยตรงลำดับตัวอักษรห้ามเปลี่ยน
แก้ input ได้ไม่จำเป็นalgorithm อ่านอย่างเดียว
import unittest


def word_break(text, words):
    dictionary = set(words)
    max_len = max((len(word) for word in dictionary), default=0)
    reachable = [False] * (len(text) + 1)
    reachable[0] = True

    for start in range(len(text)):
        if not reachable[start]:
            continue
        for end in range(start + 1, min(len(text), start + max_len) + 1):
            if text[start:end] in dictionary:
                reachable[end] = True
        if reachable[-1]:
            return True
    return reachable[-1]


class WordBreakTests(unittest.TestCase):
    def test_reuses_a_word(self):
        self.assertTrue(word_break("applepenapple", ["apple", "pen"]))

    def test_greedy_prefix_is_not_enough(self):
        self.assertTrue(word_break("cars", ["car", "ca", "rs"]))

    def test_impossible_suffix(self):
        self.assertFalse(word_break("catsandog", ["cats", "dog", "sand", "and", "cat"]))

    def test_empty_text(self):
        self.assertTrue(word_break("", []))


if __name__ == "__main__":
    unittest.main()

Edge cases และ trade-off

พจนานุกรมว่างกับสตริงไม่ว่างต้อง false ส่วนสตริงว่างสอดคล้องกับ dp[0] จึง true อย่าใช้ greedy เลือกคำยาวสุด เพราะ prefix เดียวอาจพาไปตัน ควรตัดคำซ้ำใน dictionary ด้วย set และจำกัด loop ด้วยความยาวคำสูงสุด

Recap

  • นิยาม state ว่า prefix ไหนไปถึงได้
  • ใช้ set และ max_len ลดงานที่ไม่จำเป็น
  • เลือก trie เมื่อ dictionary ใหญ่และใช้ซ้ำ
  • ทดสอบทางตัน, คำซ้ำ, dictionary ว่าง และสตริงว่าง
  • ค่าเริ่มต้นภายใต้ constraints ของโจทย์: bottom-up DP