Word Break
Дана строка s и список слов wordDict. Верните true, если строку s можно разбить на части так, чтобы каждая часть была словом из wordDict, и false в противном случае.
Части сохраняют исходный порядок и вместе используют каждую букву строки s ровно один раз. Любое слово можно использовать любое количество раз, и не обязательно использовать каждое слово.
Функция
- sstring
- строка, которую нужно разделить на слова
- wordDictstring-array
- слова, которые вы можете использовать, сколько угодно раз
- Возвращаетboolean
- true, если s можно разделить на слова из словаря, иначе false
Ограничения
1 ≤ s.length ≤ 3001 ≤ wordDict.length ≤ 10001 ≤ wordDict[i].length ≤ 20sи каждое слово содержат только строчные английские буквы.- Все слова в
wordDictразные.
Примеры
- Ввод
- s = "sunflowerseed"wordDict = ["sun", "flow", "flower", "seed"]
- Вывод
- true
- Пояснение
- Разделите это на
sun,flower,seed. Если взятьflowпослеsun, это ни к чему не приведёт, поскольку ни одно слово не начинается с оставшегосяer, поэтому первое подходящее слово не всегда оказывается правильным.
- Ввод
- s = "bananaban"wordDict = ["ban", "ana"]
- Вывод
- true
- Пояснение
ban+ana+banпокрывает строку и используетbanдважды, что разрешено.
- Ввод
- s = "pineappletart"wordDict = ["pine", "apple", "pineapple", "tar"]
- Вывод
- false
- Пояснение
- Строка начинается с
pine+appleили сpineapple, и в обоих случаях остаётсяtart. Единственное слово, которое сюда подходит, —tar, после чего остаётся одинокаяt, поэтому ни один разрез не подходит.
+21 скрытых тестов при отправке
Дополнительный вопрос
Верните минимальное число слов, которое можно использовать для допустимого разбиения, или -1, если строку s нельзя разбить. Что изменится в таблице и изменится ли время выполнения?
Подсказки
Открывайте по одной. Каждая подсказывает чуть больше.
Первый фрагмент любого разбиения — это слово, которое начинается с
s. Выбрав его, какой вопрос остаётся?Можно ли вырезать буквы от некоторого индекса до конца, зависит только от этого индекса. Таких вопросов всего
n + 1, поэтому запомни каждый ответ, особенно те, на которые ответ —false.Пусть
canEnd[i]показывает, можно ли разбить первыеiбукв, при этомcanEnd[0] = true. ТогдаcanEnd[end]равно true, если для некоторогоcanEnd[start]значение true и буквы отstartдоendобразуют слово. Храни слова в хеш-множестве и проверяй только фрагменты длиной не больше самого длинного слова.
Решение
Жадное разбиение не работает в обоих направлениях: если сначала брать самое короткое слово, sunflowerseed разбивается на sun + flow, а если сначала брать самое длинное, carpetal разбивается на carpet, и al остаётся без пары. Поэтому нужно пробовать варианты, а строку можно разбить экспоненциально многими способами. Решение в том, что возможность разбить остаток строки зависит только от того, где он начинается, поэтому существует всего n + 1 разных вопросов. Ниже n — длина s, m — количество слов, а L — длина самого длинного слова.
Попробуй каждое слово на каждой позиции
Верно, но не успевает на самых больших тестах
Идея
Читайте s слева направо. Каким бы ни был первый фрагмент, это должно быть слово, с которого начинается s. Попробуйте каждое такое слово и для каждого задайте тот же вопрос об оставшихся буквах. Если какое-либо слово приводит к разбиению целиком, ответ — true. Если ни одно не приводит, ответ — false. Когда ничего не остаётся, значит, вы разделили все буквы, и это считается успехом.
Этот алгоритм пробует каждое возможное первое слово, затем каждое возможное второе слово и так далее, поэтому он не может пропустить подходящее разбиение, а каждый возвращаемый им результат true соответствует реальному разбиению.
Он работает медленно, потому что снова и снова проверяет одни и те же остатки. Возьмите 299 букв a, за которыми следует одна b, а также слова a, aa и так далее — вплоть до слова из десяти букв a. Любой способ разбить буквы a на блоки длиной не более десяти доходит до b и завершается неудачей, а таких способов больше, чем 10^89. Рекурсии приходится перебрать их все, прежде чем она сможет вернуть false.
Алгоритм
- Напиши вспомогательную функцию
canSplit(start), которая определяет, можно ли разбить буквы от индексаstartдо конца на слова. - Если
startравен длинеs, верниtrue. - Для каждого слова проверь, содержит ли его
s, начиная с индексаstart. - Если содержит и
canSplit(start + length of the word)равноtrue, верниtrue. - Если ни одно слово не подошло, верни
false. Ответ —canSplit(0).
def wordBreak(s, wordDict):
def can_split(start):
# Can s[start:] be cut into dictionary words?
if start == len(s):
return True # nothing left to cut
for word in wordDict:
if s.startswith(word, start) and can_split(start + len(word)):
return True
return False
return can_split(0)Рекурсия с мемоизацией
Идея
Ответ для остатка зависит только от места, с которого он начинается, а start принимает только n + 1 значений. В примере с буквами a остаток, начинающийся с индекса 20, достигается после двух блоков по десять, после двадцати отдельных букв a и множеством других способов, и каждый раз ответ для него — false. Сохрани ответ для каждого начального места при первом вычислении, а затем считывай его.
Для ячейки мемоизации нужны три состояния: ещё не вычислено, true и false. Важны именно ответы false. Значение true немедленно завершает весь поиск, поэтому повторная работа обычной рекурсии происходит только в ветвях, которые завершаются неудачей.
Каждое начальное место вычисляется один раз и проверяет каждое слово, сравнивая до L букв, поэтому время работы составляет O(n × m × L): здесь не более 300 × 1000 × 20 = 6 × 10^6 проверок букв. Мемоизация и стек вызовов занимают O(n) места, а глубина вложенности вызовов не превышает 300.
Алгоритм
- Создай таблицу с одной ячейкой для каждого индекса и отметь в каждой, что она ещё не вычислена.
- В
canSplit(start)верниtrueв конце строки, а если в ячейке дляstartуже хранится ответ, верни его. - В противном случае попробуй каждое слово, начинающееся с
start, как в обычной рекурсии, и остановись на первом слове, после которого остаток можно разбить. - Сохрани результат в ячейке, в том числе
false, и верни его. - Верни
canSplit(0).
def wordBreak(s, wordDict):
memo = [None] * len(s) # memo[start]: answer for s[start:], None until worked out
def can_split(start):
if start == len(s):
return True
if memo[start] is not None:
return memo[start]
result = False
for word in wordDict:
if s.startswith(word, start) and can_split(start + len(word)):
result = True
break
memo[start] = result
return result
return can_split(0)Снизу вверх по префиксам с использованием хеш-множества
Идея
Перевернём подход и будем работать с префиксами. Пусть canEnd[i] показывает, можно ли разбить первые i букв на слова. Для пустого префикса слова не нужны, поэтому canEnd[0] равно true. Первые end букв можно разбить тогда и только тогда, когда их последняя часть — буквы от start до end — является словом, а буквы перед ней можно разбить, то есть canEnd[start] равно true. Заполняйте таблицу слева направо, и каждое нужное значение canEnd[start] уже будет известно.
Вместо того чтобы сравнивать все m слов в каждой позиции, поместим слова в хеш-множество и будем проверять возможные последние части. Ни одно слово не длиннее L, поэтому совпасть могут только L частей, оканчивающихся на end. Для строки sunflowerseed значение canEnd становится равным true в позициях 0, 3 (sun), 7 (flow), 9 (flower) и 13 (seed после позиции 9), поэтому ответ — true. Позиция 7 никуда не ведёт, потому что ни одно слово не начинается с er, но таблице это не важно.
Есть n позиций, для каждой проверяется не более L частей, а создание части и её хеширование занимают до L шагов. Получаем O(n × L²), то есть не более 300 × 20 × 20 = 1.2 × 10^5 шагов по буквам, независимо от размера словаря. Для построения множества каждое слово читается один раз: O(m × L), поэтому общая сложность составляет O(m × L + n × L²). В множестве хранятся слова — O(m × L) букв, а в таблице — n + 1 флагов. Рекурсии нет.
Алгоритм
- Поместите каждое слово в хеш-множество и запишите длину
Lсамого длинного слова. - Создайте
canEndсn + 1элементами, все со значениемfalse, и установитеcanEnd[0]вtrue. - Для каждого
endот 1 доnпроверьте каждоеlengthот 1 доmin(L, end). - Если
canEnd[end-length]равноtrueи часть такой длины, заканчивающаяся на позицииend, есть в множестве, установитеcanEnd[end]вtrueи прекратите проверять длины. - Верните
canEnd[n].
def wordBreak(s, wordDict):
words = set(wordDict)
longest = max(len(word) for word in wordDict)
n = len(s)
# can_end[i]: the first i letters split into dictionary words
can_end = [False] * (n + 1)
can_end[0] = True # the empty prefix needs no words
for end in range(1, n + 1):
# The last word is s[end-length:end], and no word is longer than longest.
for length in range(1, min(longest, end) + 1):
if can_end[end - length] and s[end - length:end] in words:
can_end[end] = True
break
return can_end[n]
Ловушки и крайние случаи
Большинство неправильных ответов возникают из-за того, что ты слишком рано выбираешь один вариант разбиения или используешь поиск, который не запоминает свои неудачи.
- Жадное разбиение. Если сначала взять самое длинное слово,
carpetalразделится наcarpet, и останетсяal, хотя вариантcar+petalработает. Если сначала взять самое короткое слово, не получится разобратьsunflowerseed. - Проверка только того, что каждая буква из
sвстречается в каком-нибудь слове. Если есть словаaaaaиaa, длина каждого фрагмента будет чётной, поэтомуaaaaaaaиз семи букв нельзя разделить. - Сохранение в мемоизации только ответов
true. Ответtrueи так завершает поиск. Повторная работа выполняется в ветках сfalse, поэтому мемоизация без них не избавит алгоритм от экспоненциальной сложности. - Создание таблицы на один элемент короче нужного.
canEnd[i]относится к первымiбуквам, а допустимы и 0, иn, поэтому в таблице должно бытьn + 1элементов. - Сравнение за пределами
s, когда слово длиннее оставшейся части, например словаabcсab. Перед сравнением букв проверь длины. - В Lua и R позиции символов начинаются с 1: фрагмент длины
k, заканчивающийся на буквеe, начинается с буквыe-k+1.
Частые вопросы4
Какова временная сложность задачи Word Break?
Таблица, заполняемая снизу вверх с использованием хеш-множества, работает за время O(m × L + n × L²), где n — длина s, m — количество слов, а L — длина самого длинного слова. При построении множества каждое слово считывается один раз, а для каждой из n позиций проверяется не более L фрагментов длиной до L букв. Если вместо этого сравнивать каждое слово в каждой позиции, сложность составит O(n × m × L). Обычная рекурсия без мемоизации имеет экспоненциальную сложность.
Почему жадный подход не работает для задачи Word Break?
Жадное правило выбирает одно слово и больше его не пересматривает. При выборе самого длинного слова сначала carpetal разбивается на carpet и al, тогда как вариант car + petal работает. При выборе самого короткого слова сначала sunflowerseed разбивается на sun + flow, и алгоритм застревает на erseed. Динамическое программирование сохраняет каждую позицию, до которой можно добраться при каком-либо разбиении, поэтому нужная позиция не теряется.
Задача Word Break относится к динамическому программированию или к теории графов?
Подходят оба представления. В динамическом программировании canEnd[i] показывает, можно ли разбить первые i букв; это значение строится на основе меньших префиксов. В представлении в виде графа каждый индекс — это узел, а ребро из i в j проводится, если буквы от i до j образуют слово; нужно выяснить, достижим ли узел n из узла 0. Поиск в ширину с множеством посещённых узлов выполняет ту же работу, что и таблица.
Как вывести все предложения вместо возврата значения true или false?
Используй возврат с возвратом: на каждом индексе пробуй каждое подходящее слово и рекурсивно обрабатывай оставшуюся часть, постепенно составляя предложение. Сохраняй список предложений для каждого индекса, чтобы оставшаяся часть решалась только один раз. Сначала заполни таблицу «истина или ложь», чтобы пропустить поиск, если строку нельзя разбить. Количество предложений может расти экспоненциально, поэтому время выполнения зависит от размера выходных данных.
Похожие задачи
Задачи на те же идеи. Решите две или три, и приём запомнится.
Python
def wordBreak(s, wordDict):
# Напишите код здесьСлучай 1
Случай 2
Случай 3
Ввод
s = "sunflowerseed" wordDict = ["sun", "flow", "flower", "seed"]
Ожидается
true