นากกายต้นฉบับกำลังลากเส้นทางผ่านกระเบื้องตัวอักษรโดยวางและเก็บหมุดย้อนกลับทุกก้าว

LeetCode 79 Word Search: backtracking ที่คืนสถานะกระดานทุกทางออก

พี่โชว์Engineering

โจทย์ Word Search ให้หาคำในตารางด้วยการเดินขึ้นลงซ้ายขวา โดยใช้ช่องเดิมซ้ำในเส้นทางเดียวไม่ได้ครับ รูปแบบนี้คือ backtracking: เลือกช่อง ทำเครื่องหมาย ลองต่อ แล้วคืนสถานะไม่ว่าพบหรือไม่พบ

วิธีที่ 1: visited set

เก็บพิกัดใน set ระหว่าง DFS โค้ดอธิบายง่ายและไม่แก้ board แต่ใช้ memory สูงสุด O(L) ตามความยาวคำ รวม call stack

วิธีที่ 2: in-place marking

เปลี่ยนตัวอักษรช่องที่ใช้อยู่เป็น sentinel ชั่วคราว แล้ว restore ก่อน return ลด visited structure เหลือ call stack O(L) แต่ต้องมั่นใจว่า sentinel ไม่ชน alphabet และคืนค่าทุก branch

วิธีที่ 3: frequency pruning + เลือกปลายคำ

นับอักษรบน board ก่อน หากคำต้องการตัวใดมากเกินให้ตอบ false ทันที แล้วเลือกค้นจากปลายที่พบได้น้อยกว่าเพื่อลด branching นี่ยังเป็น DFS เดิม แต่ลดงานเฉลี่ยโดยไม่เปลี่ยน worst case O(mn·4^L)

from collections import Counter
import unittest


def exist_with_set(board, word):
    if not word:
        return True
    rows, cols = len(board), len(board[0]) if board else 0

    def dfs(row, col, index, used):
        if index == len(word):
            return True
        if not (0 <= row < rows and 0 <= col < cols):
            return False
        if (row, col) in used or board[row][col] != word[index]:
            return False
        used.add((row, col))
        found = any(dfs(row + dr, col + dc, index + 1, used)
                    for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)))
        used.remove((row, col))
        return found

    return any(dfs(row, col, 0, set()) for row in range(rows) for col in range(cols))


def exist_in_place(board, word):
    if not word:
        return True
    if not board or not board[0]:
        return False
    counts = Counter(char for row in board for char in row)
    if Counter(word) - counts:
        return False
    if counts[word[-1]] < counts[word[0]]:
        word = word[::-1]
    rows, cols = len(board), len(board[0])

    def dfs(row, col, index):
        if index == len(word):
            return True
        if not (0 <= row < rows and 0 <= col < cols) or board[row][col] != word[index]:
            return False
        saved = board[row][col]
        board[row][col] = None
        found = any(dfs(row + dr, col + dc, index + 1)
                    for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)))
        board[row][col] = saved
        return found

    return any(dfs(row, col, 0) for row in range(rows) for col in range(cols))


class WordSearchTests(unittest.TestCase):
    def check(self, board, word, expected):
        original = [row[:] for row in board]
        self.assertEqual(exist_with_set(board, word), expected)
        self.assertEqual(exist_in_place(board, word), expected)
        self.assertEqual(board, original)

    def test_paths_and_reuse(self):
        board = [list("ABCE"), list("SFCS"), list("ADEE")]
        self.check(board, "ABCCED", True)
        self.check(board, "SEE", True)
        self.check(board, "ABCB", False)

    def test_empty_and_frequency_pruning(self):
        self.check([], "A", False)
        self.check([["A"]], "", True)
        self.check([["A", "B"]], "AAA", False)


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

Decision table

สถานการณ์Visited setIn-place markFrequency + start heuristic
readabilityดีที่สุดต้องระวัง restoreเพิ่มขั้นก่อน DFS
memoryO(L) set + stackO(L) stackO(alphabet) + stack
ห้ามแก้ input แม้ชั่วคราวเหมาะไม่เหมาะใช้ร่วมกับ set ได้
board ใหญ่ คำหายจากความถี่ยัง DFSยัง DFSตัดได้ทันที
input immutableเหมาะต้อง copyเลือก backend ได้
ต้องคืน path จริงเก็บ parent/path ง่ายเพิ่ม path แยกเป็น optimization เสริม

Best default: visited set สำหรับสัมภาษณ์เพราะ invariant ชัด; ใช้ in-place + frequency pruning เมื่อ memory/throughput สำคัญและ contract อนุญาต mutation ชั่วคราว

Edge cases

  • board ว่างหรือแถวว่าง
  • คำว่างต้องกำหนด contract แม้ constraints อาจไม่ให้มา
  • ห้ามเดินทแยงและห้ามใช้ช่องซ้ำใน path เดียว
  • ต้อง restore board แม้ branch พบคำสำเร็จ
  • recursion depth เท่าความยาวคำ อาจต้อง stack เองนอก constraints ปกติ

Recap

  • mark ก่อนเดิน และ unmark ทุกทางออก
  • frequency pruning ลดงานแต่ไม่เปลี่ยน worst case
  • เลือกปลายคำที่หายากเพื่อลดจุดเริ่ม
  • ทดสอบว่า board หลังเรียกเหมือนเดิม