LeetCode/DP심화

139. Word Break ★

hyunkookim 2024. 12. 29. 20:03

139. Word Break

 

이 문제는 **Dynamic Programming (동적 프로그래밍)**을 사용해서 효율적으로 풀 수 있습니다.

"Word Break" 문제라고 불리는 전형적인 문제 중 하나입니다. 다음은 문제를 푸는 방법에 대한 자세한 설명입니다.


문제 풀이 방법

  1. 아이디어
    • 문자열 s를 특정 위치에서 쪼개서, 각 부분 문자열이 wordDict에 있는 단어로 구성되어 있는지 확인합니다.
    • dp[i]를 정의: s[0:i]까지의 부분 문자열을 wordDict의 단어들로 분해할 수 있는지를 나타내는 boolean 값.
    • 기본 상태: dp[0] = True (빈 문자열은 항상 분해 가능).
  2. 점화식
    • dp[i] = True가 되려면, 아래 조건이 충족되어야 합니다:
      • 어떤 j(0 ≤ j < i)에 대해, dp[j] == True이고 s[j:i]가 wordDict에 포함되어 있어야 합니다.
    • 즉, dp[i] = dp[j] and s[j:i] in wordDict 조건을 만족하는 j가 존재하면 됩니다.
  3. 구현 순서
    • 문자열 s의 길이를 기준으로, dp 배열을 채워갑니다.
    • 매 위치에서 가능한 모든 분할(j)을 확인하여 dp 값을 갱신합니다.
  4. 복잡도
    • 시간 복잡도: O(n^2), 여기서 n은 문자열 s의 길이.
    • 공간 복잡도: O(n)(dp 배열 사용).
from typing import List

class Solution:
    def wordBreak(self, s: str, wordDict: List[str]) -> bool:
        # wordDict를 set으로 변환해 탐색 속도를 높임 (O(1)로 검색 가능)
        word_set = set(wordDict)
        # 문자열 s의 길이를 n에 저장
        n = len(s)
        
        # dp 배열 초기화: 길이 n+1, 모두 False로 설정
        # dp[i]는 s[0:i]까지의 문자열이 wordDict로 분해 가능한지를 나타냄
        dp = [False] * (n + 1)
        # dp[0] = True: 빈 문자열은 항상 분해 가능하므로 True로 설정
        dp[0] = True
        
        # 문자열의 길이 1부터 n까지 순회하며 dp 배열 채우기
        for i in range(1, n + 1):
            # 현재 i를 기준으로 이전 문자열의 모든 분할점 j를 순회
            for j in range(i):
                # dp[j]가 True이고, s[j:i]가 wordDict에 존재한다면
                # s[0:j]까지 분해 가능하며 s[j:i]가 단어 사전에 포함됨을 의미
                if dp[j] and s[j:i] in word_set:
                    # dp[i]를 True로 갱신: s[0:i]까지의 문자열이 분해 가능하다는 뜻
                    dp[i] = True
                    # 하나라도 True가 확인되면 더 탐색할 필요가 없으므로 루프 종료
                    break
        
        # dp[n]의 값을 반환: 문자열 s 전체가 분해 가능한지 여부
        return dp[n]

 

기본 아이디어

  • dp[i]는 s[0:i] (문자열 s의 처음부터 i까지의 부분 문자열)이 wordDict의 단어들로 쪼개질 수 있는지를 나타냅니다.
  • dp[0] = True로 시작합니다. 빈 문자열은 항상 분해 가능하다는 뜻입니다.

 

 

다른 예제

 

요약

  • j 루프의 역할: 현재 위치 i를 기준으로 모든 이전 위치 j를 확인하여, s[j:i]가 word_set에 있는지 확인합니다.
  • dp[j] == True: j 이전까지의 문자열은 이미 분해 가능하다는 뜻입니다.
  • s[j:i] in word_set: j부터 i까지의 부분 문자열이 단어 사전에 포함되어 있는지 확인합니다.
  • 이를 통해 현재 i까지의 문자열(s[0:i])이 분해 가능한지 판별합니다.

방법 2: DFS + 백트래킹

아이디어

  • 문자열을 재귀적으로 쪼개어 wordDict에 포함된 단어로 나눌 수 있는지 확인합니다.
  • 방문한 인덱스메모이제이션(cache)하여 중복 연산을 방지합니다.
from typing import List

class Solution:
    def wordBreak(self, s: str, wordDict: List[str]) -> bool:
        word_set = set(wordDict)  # 단어를 빠르게 검색하기 위해 set 사용
        n = len(s)
        cache = {}  # 메모이제이션을 위한 딕셔너리
        
        def dfs(start):
            # 문자열의 끝까지 도달하면 True 반환
            if start == n:
                return True
            
            # 이미 계산한 위치라면 결과 반환
            if start in cache:
                return cache[start]
            
            # 현재 위치에서 가능한 모든 끝 위치를 탐색
            for end in range(start + 1, n + 1):
                # s[start:end]가 wordDict에 있고, 나머지 부분도 분해 가능하다면 True
                if s[start:end] in word_set and dfs(end):
                    cache[start] = True
                    return True
            
            # 어떤 분할도 불가능하면 False 저장
            cache[start] = False
            return False
        
        return dfs(0)

 

시간 복잡도

  • 최악의 경우 O(n^2): 모든 가능한 부분 문자열을 탐색.
  • 하지만 메모이제이션으로 중복 연산을 줄이므로 효율적.

 

방법 3: BFS (Breadth-First Search)

아이디어

  • 그래프 탐색처럼 s의 각 위치를 노드로 보고, 가능한 모든 부분 문자열을 큐에 추가하여 탐색합니다.
  • 방문한 노드는 다시 탐색하지 않도록 visited 집합을 사용합니다.
from typing import List
from collections import deque

class Solution:
    def wordBreak(self, s: str, wordDict: List[str]) -> bool:
        word_set = set(wordDict)  # 단어를 빠르게 검색하기 위해 set 사용
        n = len(s)
        queue = deque([0])  # 시작 위치를 큐에 추가
        visited = set()  # 방문한 위치를 저장
        
        while queue:
            start = queue.popleft()
            
            # 이미 방문한 위치는 건너뛰기
            if start in visited:
                continue
            
            # start 위치에서 끝 위치를 탐색
            for end in range(start + 1, n + 1):
                # s[start:end]가 wordDict에 있다면
                if s[start:end] in word_set:
                    # 문자열의 끝에 도달하면 True 반환
                    if end == n:
                        return True
                    # 끝 위치를 큐에 추가
                    queue.append(end)
            
            # 현재 위치를 방문 처리
            visited.add(start)
        
        # 가능한 분해를 찾지 못한 경우 False 반환
        return False

 

시간 복잡도

  • 최악의 경우 O(n^2): 모든 부분 문자열을 탐색.
  • 하지만 visited를 사용하여 중복 탐색을 방지.

 

 

https://youtu.be/Sx9NNgInc3A?si=w-SzGf0ygxUGjpf0

 

class Solution:
    def wordBreak(self, s: str, wordDict: List[str]) -> bool:
        # 결정 트리: decision tree
        # 캐쉬
        # DP
        """
        # bottom up 으로..
        # 그래서 s 문자열의 끝에서부터 ..검색
        # wordDict = ["neet", "leet","code"]
        # s = neetcode 라면
        #     012345678 
        # dp[시작지점]
        # dp[8] 은.. 종료 .. 잘 찾았다면. 잘 종료했을테니 dp[8] = True, 기본 세팅
        # dp[7]: 문자열 'e' 이것은 wordDict에 없고, 
                    wordDict의 어떤 문자열의 길이만큼의 단어도 아님
                    따라서, dp[7] = False
        # dp[6]: 문자열 'de' 이것은 wordDict에 없고, 
                    wordDict의 어떤 문자열의 길이만큼의 단어도 아님
                    따라서, dp[6] = False
        # dp[5]: 문자열 'ode' 이것은 wordDict에 없고, 
                    wordDict의 어떤 문자열의 길이만큼의 단어도 아님
                    따라서, dp[5] = False
        # dp[4]: 문자열 'code' 이것은 wordDict에 있어서 dp[4] = True
        # dp[3]: 문자열 t 부터 시작하는 단어.. 
                    이것은 wordDict의 어떤 문자열의 길이만큼의 단어로 하니, 
                    tcod 인데, wordDict 에 없음
                    따라서, dp[3] = False
        # dp[2]: 문자열 e 부터 시작하는 단어.. 
                    이것은 wordDict의 어떤 문자열의 길이만큼의 단어로 하니, 
                    etco 인데, wordDict 에 없음
                    따라서, dp[2] = False
        # dp[1]: 문자열 e 부터 시작하는 단어.. 
                    이것은 wordDict의 어떤 문자열의 길이만큼의 단어로 하니, 
                    eetc 인데, wordDict 에 없음
                    따라서, dp[1] = False
        # dp[0]: 문자열 n 부터 시작하는 단어.. 
                    이것은 wordDict의 어떤 문자열의 길이만큼의 단어로 하니, 
                    neet 인데, wordDict 에 있음
                    따라서, dp[0] = True
        # 규칙: dp[0] = d[0 + len(w)] = True
        """

        # dp 배열 초기화, dp[i]는 s[i:] 문자열이 wordDict로 분해 가능한지 나타냄
        dp = [False] * (len(s) + 1)
        
        # 문자열의 끝은 항상 True, 빈 문자열은 분해 가능
        dp[len(s)] = True

        # 문자열의 끝에서부터 시작하여 dp 배열을 채움
        for i in range(len(s) - 1, -1, -1):  # i를 len(s)-1에서 0까지 순회
            for w in wordDict:  # wordDict의 각 단어를 순회
                # i에서 시작해 w의 길이만큼 자른 문자열이 wordDict에 있는지 확인
                if (i + len(w)) <= len(s) and s[i: i + len(w)] == w:
                    # dp[i]를 업데이트: dp[i]는 dp[i + len(w)]의 값으로 설정
                    dp[i] = dp[i + len(w)]

                # dp[i]가 True면 더 이상 확인할 필요 없음
                if dp[i]:
                    break

        # dp[0] 반환: s 전체 문자열이 wordDict로 분해 가능한지 나타냄
        return dp[0]

 

 

 

https://youtu.be/-qMVFoVyTqY?si=ZkQmhyRaxq1SvGGl

 

'LeetCode > DP심화' 카테고리의 다른 글

120. Triangle  (0) 2024.12.30
322. Coin Change  (0) 2024.12.29
746. Min Cost Climbing Stairs ★  (0) 2024.11.23
1137. N-th Tribonacci Number  (1) 2024.11.22
714. Best Time to Buy and Sell Stock with Transaction Fee  (2) 2024.11.08