0Pricing
DSA Interview Prep · บทเรียน

การแบ่งคำและการแบ่งสตริงเป็นส่วน

ใช้ตาราง DP หนึ่งมิติตรวจสอบว่าสตริงแบ่งเป็นคำในพจนานุกรมได้หรือไม่ วิเคราะห์เวลา O(n²) และเหตุผลที่ทรีช่วยเร่งความเร็ว

การแบ่งคำและการแบ่งสตริงเป็นส่วน เป็นบทเรียน DSA Interview Prep ฟรีบน CoddyKit นี่คือบทเรียนที่ 3 จากทั้งหมด 4 บทเรียน คุณสามารถอ่านบทเรียนทั้งหมดด้านล่างฟรี — จากนั้นลองปฏิบัติด้วยตัวคุณเองในเบราว์เซอร์พร้อมตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7 บทเรียนนี้เป็นส่วนหนึ่งของเส้นทางการเรียน DSA Interview Prep และความก้าวหน้าของคุณจะซิงค์ข้ามเว็บและแอป CoddyKit คอร์ส DSA Interview Prep มีบทเรียนทั้งหมด 4 บทเรียน

ปัญหาการแบ่งคำ

การแบ่งคำ (LeetCode 139) ถามว่า เมื่อกำหนดสตริง s และพจนานุกรมคำศัพท์แล้ว s สามารถแบ่งเป็นลำดับคำในพจนานุกรมตั้งแต่หนึ่งคำขึ้นไป โดยคั่นด้วยช่องว่างได้หรือไม่ ตัวอย่างเช่น เมื่อ s = 'leetcode' และ wordDict = ['leet', 'code'] คำตอบคือ True เพราะ 'leet' + 'code' = 'leetcode' นี่เป็นโจทย์ DP หนึ่งมิติแบบคลาสสิก

s = 'leetcode'
word_set = {'leet', 'code'}
# Can we split 'leetcode' into words from word_set?
# 'leet' in set → yes, 'code' in set → yes
# So: 'leetcode' = 'leet' + 'code' → True

s2 = 'catsandog'
word_set2 = {'cats', 'dog', 'sand', 'and', 'cat'}
# No matter how we split, last part 'og' not in dict
print('Expected: True, False')

การกำหนดสูตรและสถานะของ DP

กำหนดให้ dp[i] เป็น True หากสตริงย่อย s[:i] สามารถแบ่งโดยใช้คำในพจนานุกรมได้ กรณีฐานคือ dp[0] = True (สตริงว่างสามารถแบ่งได้เสมอ) สำหรับแต่ละตำแหน่ง i ให้ตรวจสอบทุกตำแหน่ง j < i: หาก dp[j] เป็นจริงและ s[j:i] อยู่ในพจนานุกรม ให้กำหนด dp[i] = True คำตอบสุดท้ายคือ dp[len(s)]

def word_break(s, word_dict):
    word_set = set(word_dict)
    n = len(s)
    dp = [False] * (n + 1)
    dp[0] = True  # empty string
    
    for i in range(1, n + 1):
        for j in range(i):
            # If s[:j] is segmentable AND s[j:i] is a word
            if dp[j] and s[j:i] in word_set:
                dp[i] = True
                break  # no need to check other j values
    return dp[n]

print(word_break('leetcode', ['leet', 'code']))        # True
print(word_break('catsandog', ['cats','dog','sand','and','cat']))  # False

การติดตามตาราง DP

สำหรับ s = 'leetcode' และพจนานุกรม {'leet', 'code'}: dp[0]=T ที่ i=4: j=0, dp[0]=T และ s[0:4]='leet' อยู่ในพจนานุกรม → dp[4]=T ที่ i=8: j=4, dp[4]=T และ s[4:8]='code' อยู่ในพจนานุกรม → dp[8]=T ตำแหน่งอื่นทั้งหมดที่ไม่มีคำลงท้ายจะยังคงเป็นเท็จ คำตอบ dp[8]=True ยืนยันได้ว่าสตริงนี้สามารถแบ่งได้

def word_break_trace(s, word_dict):
    word_set = set(word_dict)
    n = len(s)
    dp = [False] * (n + 1)
    dp[0] = True
    for i in range(1, n + 1):
        for j in range(i):
            if dp[j] and s[j:i] in word_set:
                dp[i] = True
                print(f'dp[{i}]=True via s[{j}:{i}]={repr(s[j:i])}')
                break
    print('dp table:', dp)
    return dp[n]

word_break_trace('leetcode', ['leet', 'code'])

การวิเคราะห์ความซับซ้อนด้านเวลา

DP แบบพื้นฐานใช้เวลา O(n²): มีการวนรอบภายนอก n ครั้ง และแต่ละครั้งมีการวนรอบภายในมากที่สุด n ครั้ง อย่างไรก็ตาม การตัดสตริงด้วย s[j:i] ก็ใช้เวลา O(n) เช่นกัน ทำให้ความซับซ้อนจริงในภาษาไพทอนเป็น O(n³) วิธีปรับปรุงอย่างหนึ่งคือวนผ่านคำในพจนานุกรม และตรวจสอบว่าแต่ละคำลงท้ายที่ตำแหน่ง i หรือไม่ ทำให้ได้ O(n × W × L) โดย W คือขนาดพจนานุกรม และ L คือความยาวคำโดยเฉลี่ย สำหรับข้อมูลนำเข้าส่วนใหญ่ในการสัมภาษณ์ O(n²) หรือ O(n³) ถือว่ายอมรับได้

# Slightly faster: iterate over words rather than all j positions
def word_break_v2(s, word_dict):
    word_set = set(word_dict)
    n = len(s)
    dp = [False] * (n + 1)
    dp[0] = True
    for i in range(1, n + 1):
        for word in word_set:
            wl = len(word)
            # Does 'word' end exactly at position i?
            if i >= wl and dp[i - wl] and s[i - wl:i] == word:
                dp[i] = True
                break
    return dp[n]

print(word_break_v2('applepenapple', ['apple', 'pen']))  # True

ทางเลือกด้วยการเรียกซ้ำและการจดจำผลลัพธ์

โจทย์เดียวกันนี้แก้ได้จากบนลงล่างด้วยการจดจำผลลัพธ์ กำหนดฟังก์ชันเรียกซ้ำ can_break(start) ที่คืนค่า True หาก s[start:] สามารถแบ่งได้ ลองใช้แต่ละคำเป็นคำนำหน้าของ s[start:] แล้วเรียกซ้ำกับส่วนที่เหลือ เก็บผลลัพธ์ไว้เพื่อหลีกเลี่ยงการสำรวจดัชนีเริ่มต้นเดิมซ้ำหลายครั้ง วิธีนี้เทียบเท่ากับ DP จากล่างขึ้นบน แต่อาจทำงานเร็วกว่าในทางปฏิบัติหากมีการตัดหลายตำแหน่งทิ้งตั้งแต่ต้น

from functools import lru_cache

def word_break_memo(s, word_dict):
    word_set = set(word_dict)
    
    @lru_cache(maxsize=None)
    def can_break(start):
        if start == len(s): return True
        for end in range(start + 1, len(s) + 1):
            if s[start:end] in word_set and can_break(end):
                return True
        return False
    
    return can_break(0)

print(word_break_memo('leetcode', ['leet', 'code']))  # True
print(word_break_memo('catsandog', ['cats','dog','sand','and','cat']))  # False

การคืนค่าการแบ่งที่ถูกต้องทั้งหมด

การแบ่งคำ II (LeetCode 140) ขอให้คืนค่าการแบ่งที่เป็นไปได้ทั้งหมด แนวทางคือการย้อนรอยร่วมกับการจดจำผลลัพธ์: เรียกซ้ำจากแต่ละตำแหน่ง และเมื่อพบคำที่ตรงกัน ให้เรียกซ้ำกับส่วนที่เหลือ เก็บผลลัพธ์บางส่วนทั้งหมดเป็นรายการสตริง เพื่อหลีกเลี่ยง TLE ให้จดจำรายการประโยคที่เป็นไปได้จากแต่ละดัชนีเริ่มต้น จำนวนประโยคอาจเพิ่มขึ้นแบบเอ็กซ์โพเนนเชียลในกรณีเลวร้ายที่สุด แต่การจดจำผลลัพธ์จะกำจัดการคำนวณซ้ำที่ไม่จำเป็น

from functools import lru_cache

def word_break_ii(s, word_dict):
    word_set = set(word_dict)
    
    @lru_cache(maxsize=None)
    def break_from(start):
        if start == len(s): return ['']
        results = []
        for end in range(start + 1, len(s) + 1):
            word = s[start:end]
            if word in word_set:
                for rest in break_from(end):
                    results.append(word if not rest else word + ' ' + rest)
        return results
    
    return break_from(0)

print(word_break_ii('catsanddog', ['cat','cats','and','sand','dog']))
# ['cat sand dog', 'cats and dog']

การปรับปรุงประสิทธิภาพด้วยต้นไม้คำนำหน้า

เมื่อพจนานุกรมมีขนาดใหญ่หรือคำมีความยาวมาก การตรวจสอบ s[j:i] in word_set สำหรับทุกค่า j จะช้าเนื่องจากการแฮชสตริงของภาษาไพทอน โครงสร้างต้นไม้คำนำหน้า ช่วยให้คุณเดินผ่านตัวอักษรทีละตัวและตัดเส้นทางที่เป็นไปไม่ได้ทิ้งตั้งแต่ต้น แทนที่จะตรวจสอบตำแหน่งเริ่มต้นทั้งหมด O(n) คุณจะติดตามเฉพาะเส้นทางที่มีอยู่ในโครงสร้างต้นไม้เท่านั้น วิธีนี้ลดเวลาในการทำงานจริงได้อย่างมากเมื่อมีคำนำหน้าเพียงไม่กี่แบบที่นำไปสู่คำที่ถูกต้อง

class TrieNode:
    def __init__(self):
        self.children = {}
        self.is_end = False

def build_trie(words):
    root = TrieNode()
    for word in words:
        node = root
        for ch in word:
            node = node.children.setdefault(ch, TrieNode())
        node.is_end = True
    return root

def word_break_trie(s, word_dict):
    root = build_trie(word_dict)
    n = len(s)
    dp = [False] * (n + 1)
    dp[0] = True
    for i in range(n):
        if not dp[i]: continue
        node = root
        for j in range(i, n):
            ch = s[j]
            if ch not in node.children: break
            node = node.children[ch]
            if node.is_end:
                dp[j + 1] = True
    return dp[n]

print(word_break_trie('leetcode', ['leet', 'code']))  # True

กรณีขอบและข้อจำกัด

กรณีขอบที่สำคัญ: (1) สตริงว่าง: คืนค่า True (สตริงว่างสามารถแบ่งได้โดยไม่ต้องดำเนินการใด ๆ) (2) คำที่ไม่มีในพจนานุกรม: dp จะไม่กำหนดให้ตำแหน่งที่เกี่ยวข้องเป็น True และคืนค่า False ได้อย่างถูกต้อง (3) คำที่ซ้อนทับกัน: เช่น 'a' และ 'aa' ในพจนานุกรม โดยมี s='aaa' — DP จัดการกรณีนี้ได้โดยธรรมชาติด้วยการตรวจสอบค่าของ j ทั้งหมด (4) ตัวอักษรซ้ำกัน: s='aaaaab' โดยมี dict=['a','aa','aaa'] — มีเส้นทางแบบเอ็กซ์โพเนนเชียล แต่การจดจำผลลัพธ์จะจำกัดความซับซ้อนไว้ที่ O(n²)

def word_break(s, word_dict):
    word_set = set(word_dict)
    dp = [False] * (len(s) + 1)
    dp[0] = True
    for i in range(1, len(s) + 1):
        for j in range(i):
            if dp[j] and s[j:i] in word_set:
                dp[i] = True
                break
    return dp[len(s)]

# Edge cases
print(word_break('', ['hello']))          # True (empty string)
print(word_break('a', ['b']))             # False
print(word_break('aaa', ['a', 'aa']))     # True (many ways)

การทำให้การแบ่งสตริงเป็นกรณีทั่วไป

การแบ่งคำสามารถประยุกต์ใช้กับปัญหา การแบ่งสตริง ทั่วไปได้: เราสามารถแบ่งสตริง s ตามกฎบางอย่างได้หรือไม่ ให้แทนที่การค้นหาในพจนานุกรมด้วยการตรวจสอบใด ๆ ที่ใช้เวลา O(1) หรือ O(L) ตัวอย่างเช่น เราสามารถแบ่ง s เป็นพาลินโดรมได้หรือไม่ ให้ใช้ตารางพาลินโดรมที่คำนวณไว้ล่วงหน้าแทนเซตคำ โครงสร้าง DP ยังคงเหมือนเดิม — มีเพียงการตรวจสอบความถูกต้องที่เปลี่ยนไป

def palindrome_partition_possible(s):
    '''Can s be partitioned into palindromes? (Always yes — single chars are palindromes)'''
    n = len(s)
    # Precompute palindrome table
    is_pal = [[False]*n for _ in range(n)]
    for i in range(n): is_pal[i][i] = True
    for i in range(n-1): is_pal[i][i+1] = (s[i]==s[i+1])
    for length in range(3, n+1):
        for i in range(n-length+1):
            j = i + length - 1
            is_pal[i][j] = s[i]==s[j] and is_pal[i+1][j-1]
    # DP similar to word break
    dp = [False] * (n + 1)
    dp[0] = True
    for i in range(1, n + 1):
        for j in range(i):
            if dp[j] and is_pal[j][i-1]:
                dp[i] = True
                break
    return dp[n]

print(palindrome_partition_possible('aab'))  # True (a,a,b or aa,b)

แนวทาง DP เทียบกับ BFS

การแบ่งคำสามารถมองเป็นปัญหา BFS ของเส้นทางสั้นที่สุด ได้เช่นกัน: แต่ละตำแหน่งในสตริงเป็นโหนด และมีเส้นเชื่อมจาก j ไปยัง i หาก s[j:i] อยู่ในพจนานุกรม การทำ BFS จากโหนด 0 จะถามว่าโหนด n สามารถเข้าถึงได้หรือไม่ BFS มีความซับซ้อนเท่ากันคือ O(n² × L) แต่อาจเข้าใจได้ง่ายกว่า หากคุณจำลองโจทย์นี้เป็นปัญหากราฟระหว่างการสัมภาษณ์

from collections import deque

def word_break_bfs(s, word_dict):
    word_set = set(word_dict)
    n = len(s)
    visited = set()
    queue = deque([0])
    while queue:
        start = queue.popleft()
        if start == n: return True
        for end in range(start + 1, n + 1):
            if end not in visited and s[start:end] in word_set:
                visited.add(end)
                queue.append(end)
    return False

print(word_break_bfs('leetcode', ['leet', 'code']))    # True
print(word_break_bfs('catsandog', ['cats','dog','and','sand','cat']))  # False

กลยุทธ์การสื่อสารในการสัมภาษณ์

ในการสัมภาษณ์ ให้ไล่อธิบายกระบวนการคิดดังนี้: (1) สังเกตว่าตัวเลือกในแต่ละตำแหน่งขึ้นอยู่กับสิ่งที่เข้าถึงได้ก่อนหน้านั้น ซึ่งเป็นสัญญาณว่าโจทย์นี้ใช้ DP (2) กำหนดสถานะ: dp[i] = เราสามารถแบ่ง s[:i] ได้หรือไม่ (3) ระบุความสัมพันธ์เวียนเกิดและกรณีฐานก่อนเขียนโค้ด (4) เขียนวิธีแก้ O(n²) ก่อน แล้วกล่าวถึงการปรับปรุงประสิทธิภาพด้วยโครงสร้างต้นไม้คำนำหน้าเป็นประเด็นต่อยอด (5) อภิปรายกรณีขอบ: สตริงว่าง อักขระเดียว และคำที่ไม่มีในพจนานุกรม

# Clean final solution to present in interview
def word_break(s, word_dict):
    '''O(n^2 * L) time, O(n + W) space where W = total word length in dict'''
    word_set = set(word_dict)   # O(W) space
    n = len(s)
    dp = [False] * (n + 1)     # O(n) space
    dp[0] = True
    for i in range(1, n + 1):
        for j in range(i):     # try all split points
            if dp[j] and s[j:i] in word_set:
                dp[i] = True
                break
    return dp[n]

# Time: O(n^2 * L) - n^2 pairs, each dict lookup is O(L)
# Space: O(n) for dp array, O(W) for word_set
print(word_break('applepenapple', ['apple', 'pen']))  # True

ตรวจสอบความเข้าใจ

ทดสอบความเข้าใจแนวคิดโครงสร้างข้อมูล & อัลกอริทึม — การเตรียมตัวสัมภาษณ์การเขียนโปรแกรมจากบทเรียนนี้

สรุปบทเรียน

ในบทเรียนนี้ คุณได้เรียนรู้: dp[i] แทนว่าสามารถแบ่ง s[:i] เป็นคำในพจนานุกรมได้หรือไม่, ความสัมพันธ์เวียนเกิด O(n²) ตรวจสอบจุดแบ่ง j ทั้งหมดที่ dp[j]=True และ s[j:i] อยู่ในเซตคำ และ โครงสร้างต้นไม้คำนำหน้าสามารถเร่งลูปภายในได้ด้วยการตัดคำนำหน้าที่ไม่มีอยู่ทิ้งตั้งแต่ต้น ต่อไปเราจะศึกษาเรื่องวิธีการถอดรหัสและการนับเส้นทาง ซึ่งเป็นรูปแบบ DP หนึ่งมิติที่คล้ายฟีโบนัชชีอีกแบบหนึ่ง

คำถามที่พบบ่อย

บทเรียน “การแบ่งคำและการแบ่งสตริงเป็นส่วน” ฟรีหรือไม่

ใช่ — ข้อความเต็มของ “การแบ่งคำและการแบ่งสตริงเป็นส่วน” ฟรีให้อ่านที่นี่บนเว็บ เพื่อปฏิบัติแบบโต้ตอบ (ตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7) และปลดล็อคส่วนที่เหลือของคอร์ส DSA Interview Prep ให้อัปเกรดเป็น CoddyKit PRO คอร์ส DSA Interview Prep มีบทเรียนทั้งหมด 4 บทเรียน

คุณจะเรียนรู้อะไรในบทเรียน “การแบ่งคำและการแบ่งสตริงเป็นส่วน”

ใช้ตาราง DP หนึ่งมิติตรวจสอบว่าสตริงแบ่งเป็นคำในพจนานุกรมได้หรือไม่ วิเคราะห์เวลา O(n²) และเหตุผลที่ทรีช่วยเร่งความเร็ว คุณปฏิบัติ DSA Interview Prep ด้วยโค้ดที่ใช้งานได้จริงที่คุณเรียกใช้โดยตรงในเบราว์เซอร์ และติวเตอร์ AI ตลอด 24/7 ตอบคำถามของคุณขณะที่คุณไปผ่านบทเรียน

คุณต้องมีประสบการณ์ก่อนที่จะเริ่มเรียน DSA Interview Prep หรือไม่

ไม่จำเป็นต้องมีประสบการณ์มาก่อน DSA Interview Prep บน CoddyKit ออกแบบมาสำหรับผู้เริ่มต้นไปจนถึงผู้เรียนขั้นสูง คุณสามารถเริ่มต้นที่นี่หรือเริ่มจากตัวแรกและเรียนด้วยความเร็วของคุณเอง นี่คือบทเรียนที่ 3 จากทั้งหมด 4 บทเรียน

บทเรียน “การแบ่งคำและการแบ่งสตริงเป็นส่วน” ใช้เวลานานแค่ไหน

บทเรียน CoddyKit ส่วนใหญ่ใช้เวลาประมาณ 5–10 นาที แต่ละบทเรียนจึงสั้นและเป็นแบบโต้ตอบ คุณสามารถก้าวหน้าอย่างต่อเนื่องและกลับมาเรียนต่อจากตรงที่เพิ่งหยุดบนเว็บและแอปได้เลย

ฉันเขียนและรันโค้ดในบทเรียน DSA Interview Prep นี้ได้ไหม

ได้ บทเรียน DSA Interview Prep ทุกบทมีตัวแก้ไขโค้ดในตัว คุณจึงเขียนและรันโค้ดจริงได้เลยในเบราว์เซอร์ และได้รับข้อเสนอแนะจาก AI ในทันที — ไม่ต้องติดตั้งในเครื่องของคุณ

บทเรียนทั้งหมดในหลักสูตรนี้

  1. โจรปล้นบ้าน: ความสัมพันธ์เวียนเกิดแบบเลือกหรือข้าม
  2. ช่วงย่อยผลรวมสูงสุดและช่วงย่อยผลคูณสูงสุด
  3. การแบ่งคำและการแบ่งสตริงเป็นส่วน
  4. ถอดรหัสวิธีและการนับเส้นทาง
← กลับไปที่ DSA Interview Prep