Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add 127. Word Ladder.md #20

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
390 changes: 390 additions & 0 deletions 127. Word Ladder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,390 @@
# step 1
wordに対して、1文字違いをwordListから毎回探すやり方でTLE

find_words_diff_by_one()に時間がかかる。m = len(word), n = len(wordList)として、
find_words_diff_by_one()のtime complexityがO(mn).
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ladderLength() 全体の時間計算量も求めてみてください。そして、時間計算量を求めたあと、制約にあるデータサイズの上限を代入し、どれくらいの数字になるかを確かめてみてください。その数字が言語ごとの処理速度の違いを考慮したうえで、一定の数値以下に入っていれば、現実的な時間で処理が終わると思います。

今回の場合ですと O(m*n^2) とります。制約に 1 <= m <= 10、1 <= n <= 5000 とありますので、 m = 10、n = 5000 を代入します。すると 2.5 * 10^8 となります。 Python は比較的遅い言語で、 1 秒間に 100 万ステップくらいしか計算できません。2.5 * 10^8 / 10^6 = 250 となり、 250 秒くらいかかりそうということが分かります。これは制限時間を大きく超えそうです。

言語ごと速度の差は以下をご覧ください。
https://github.com/niklas-heer/speed-comparison
https://benchmarksgame-team.pages.debian.net/benchmarksgame/box-plot-summary-charts.html

上記の実行時間の概算方法は、 IPC やメモリのアクセスパターンや整数・浮動小数の計算速度の違い等、様々な要因で大きくぶれます。目安程度に考えることをお勧めいたします。


`if next_word in path:`(下から五行目)も遅くなりそう。

```python
from collections import deque


class Solution:
def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
def is_diff_by_one(word1: str, word2: str) -> bool:
if len(word1) != len(word2):
return False
len_word = len(word1)
num_of_diffs = 0
for i in range(len_word):
if word1[i] == word2[i]:
continue
num_of_diffs += 1
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ここで、num_of_diffが2以上だったらループを抜けると無駄な処理を減らせそうです

return num_of_diffs == 1

def find_words_diff_by_one(
wordList: List[str],
keyword: str
) -> List[str]:
diff_words = []
for word in wordList:
if is_diff_by_one(keyword, word):
diff_words.append(word)
return diff_words

next_transformations = deque()
next_transformations.append([beginWord])
while next_transformations:
path = next_transformations.popleft()
last_word = path[-1]
if last_word == endWord:
return len(path)
words_diff_by_one = find_words_diff_by_one(wordList, last_word)
for next_word in words_diff_by_one:
if next_word in path:
continue
next_path = path.copy()
next_path.append(next_word)
next_transformations.append(next_path)
return 0
```

先に1文字違いを調べておく方針。
1文字違いを調べるのに、time complexityがO(n^2 * m)かかり、
これはMemory limit exceededとなった。
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この問題、結構制限が適当に決められているという印象があります。
通らなかったらよくない解答である、通ったらよい解答である、というようには見ないほうがいいです。
いくつか選択があり、偶然にもこの場合はこういう制限がかかっていたので、そのうちのいくつかが偶然にも動かないものがあった。
とはいえ、どういうものが動かないかが分かったあとに、回避策がいくつかでてくるようにしておく事自体は大切です。


```python
from collections import defaultdict
from collections import deque


class Solution:
def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
def is_diff_by_one(word1: str, word2: str) -> bool:
if len(word1) != len(word2):
return False
len_word = len(word1)
num_of_diffs = 0
for i in range(len_word):
if word1[i] == word2[i]:
continue
num_of_diffs += 1
return num_of_diffs == 1

word_to_words_diff_by_one = defaultdict(list)
for word1 in wordList:
for word2 in wordList:
if is_diff_by_one(word1, word2):
word_to_words_diff_by_one[word1].append(word2)
if beginWord not in wordList:
for word in wordList:
if is_diff_by_one(beginWord, word):
word_to_words_diff_by_one[beginWord].append(word)

next_transformations = deque()
next_transformations.append([beginWord])
while next_transformations:
path = next_transformations.popleft()
last_word = path[-1]
if last_word == endWord:
return len(path)
words_diff_by_one = word_to_words_diff_by_one[last_word]
for next_word in words_diff_by_one:
if next_word in path:
continue
next_path = path.copy()
next_path.append(next_word)
next_transformations.append(next_path)
return 0
```

ここで解答を見る。

m <= 10より、ある文字から遷移可能な文字数は高々10 * 26 = 260通りなので、それを全列挙する。
一度訪問済みのwordに関しては、2度目の訪問時はpathの長さが最小ではないので、飛ばす。

こういう時、素直にこういう発想ができないことが多い。一つの方針が立った時、他のものを考えずに
突き進む癖がある。

```python
from collections import deque
import string


class Solution:
def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
len_word = len(beginWord)
words_set = set(wordList)
visited = set()
next_candidates = deque()
next_candidates.append([beginWord, 1])
while next_candidates:
last_word, path_length = next_candidates.popleft()
if last_word == endWord:
return path_length
visited.add(last_word)
for i in range(len_word):
for char in string.ascii_lowercase:
char_list = list(last_word)
char_list[i] = char
next_word = ''.join(char_list)
if next_word not in words_set:
continue
if next_word in visited:
continue
next_candidates.append([next_word, path_length + 1])
return 0
```

上だとテストケースは通るが、かなり遅かった。考えられる原因としては、
- endWordかの確認が遅い
- next_wordの時点で確認可能
- これと合わせて、next_wordを作る時にわざわざリストを使っている
- visitedに追加される前に同じwordをnext_candidatesに加えている

修正版は以下の通り

一番最後の、「visitedに追加される前に同じwordをnext_candidatesに加えている」によって
かなり遅くなっているみたいだった。BFSをするなら、最短経路以外の同じ地点に至るルートはできるだけ
早く探索対象から外した方が実行時間が短くなるようだった。
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LeetCode の実行環境は実行時間の計測の精度が良くないようです。 LeetCode に表示される実行時間はあまり信用しないほうがよいと思います。


```python
from collections import deque
import string


class Solution:
def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
len_word = len(beginWord)
words_set = set(wordList)
next_candidates = deque()
next_candidates.append([beginWord, 1])
while next_candidates:
last_word, path_length = next_candidates.popleft()
for i in range(len_word):
for char in string.ascii_lowercase:
next_word = f"{last_word[:i]}{char}{last_word[i+1:]}"
if next_word not in words_set:
continue
if next_word == endWord:
return path_length + 1
next_candidates.append([next_word, path_length + 1])
words_set.remove(next_word)
return 0
```

BFSのtime complexityはO(V + E)であるが、これはどう考えればいいかわからなかった。
`words_set.remove(next_word)`は、高々n = len(wordList)しか実行されないので、
`next_candidates.append([next_word, path_length + 1])`もn程度で済む。

上の方針で、words_setから探索済みのwordを省いていくと、
wordに対して、1文字違いをwordListから毎回探すやり方でもleetcodeのテストはTLEとならなかった。

# step 2
- https://github.com/olsen-blue/Arai60/pull/20/files
- ある単語からその単語から1文字とった分割へのhash table, 分割からそれを構成しうる単語へのhash tableを用意
- 二つを組み合わせて、ある単語から次に遷移可能な単語を全て列挙していた
- 初めは分割の間に`*`を入れていたが、後半はtupleに変わっており、後者は`*`が文字として
wordListに入ってきても動くようになっている
- 自分のやっている総当たりみたいなやつよりも必要最低限を調べている感じだった
- https://github.com/katataku/leetcode/pull/18/files#r1910141758
- > distance が 2 になったところで return False すると、処理量が少し減ると思います。
- 自分もハミング距離を求める操作をしていたが、同じように最後まで数えていた
- 最悪計算量は変わらないが、今回は特に、最悪のシチュエーションが多いとも考えられない
- dequeの命名がwords_and_lengthsだった。
- next_candidatesよりも叙述的だった。
- https://github.com/TORUS0818/leetcode/pull/22/files
- is_convertible() 問題文はっきり読んでいなくてもわかりやすい
- is_diff_by_oneとしたが、問題文読んでいれば通じるとは思う。
- [zip()](https://docs.python.org/3.3/library/functions.html#zip)
- [itertools.zip_longest()](https://docs.python.org/3/library/itertools.html#itertools.zip_longest)
- 長さが同じ保証があるので、今回はスッキリ書ける。
- dijkstraでも解いていた
- 面接で解くとすると、今回はノード間距離が変わらないからオーバーな気もする。
- https://github.com/TORUS0818/leetcode/pull/22/files#r1667212013
- 二方向BFS

m = len(word), n = len(wordList)とする。
基本方針はBFSで、一度生成できたwordについては2回は探索しないようにする。
あるwordから次のwordへと至るための方針として
- wordに対してその都度、計算
- wordを1文字ずつずらしていって、wordListに入っているか確認
- time complexity: O(n) (wordListがsetになっているとして)
- wordを1文字ずらしたものがm <= 10より、高々260通りある。
- wordListから順に単語をとりだして、もとのwordと比較
- time complexity: O(m * n)
- 事前にword -> adjacent wordsの対応を作っておく
- word -> 可能な分割、分割 -> 同値類
- time complexity: O(m * n)

wordをずらす
```python
from collections import deque
import string


class Solution:
def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
def generate_adjacent_word(
word: str,
word_set: Set[str]
) -> Iterator[str]:
for index in range(len(word)):
for char in string.ascii_lowercase:
next_word = f"{word[:index]}{char}{word[index+1:]}"
if next_word not in word_set:
continue
yield next_word

words_and_lengths = deque([(beginWord, 1)])
generated_words = set([beginWord])
word_set = set(wordList)
while words_and_lengths:
last_word, length = words_and_lengths.popleft()
for next_word in generate_adjacent_word(last_word, word_set):
if next_word in generated_words:
continue
if next_word == endWord:
return length + 1
words_and_lengths.append((next_word, length + 1))
generated_words.add(next_word)
return 0
```

wordListの中から探す(TLE)
```python
class Solution:
def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
def is_adjacent(word1: str, word2: str) -> bool:
if len(word1) != len(word2):
return False
num_of_diffs = 0
for char1, char2 in zip(word1, word2):
if char1 == char2:
continue
num_of_diffs += 1
if num_of_diffs > 1:
return False
return num_of_diffs == 1

def generate_adjacent_word(
word: str,
word_set: Set[str]
) -> Iterator[str]:
for possible_word in word_set:
if is_adjacent(word, possible_word):
yield possible_word

words_and_lengths = deque([(beginWord, 1)])
word_set = set(wordList)
generated_words = set(beginWord)
while words_and_lengths:
word, length = words_and_lengths.popleft()
for next_word in generate_adjacent_word(word, word_set):
if next_word in generated_words:
continue
if next_word == endWord:
return length + 1
generated_words.add(next_word)
words_and_lengths.append((next_word, length + 1))
return 0
```

同値類をまとめる
```python
from collections import defaultdict
from collections import deque


class Solution:
def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
def generate_pattern(word: str) -> Iterator[Tuple[str, str]]:
pattern = []
for index in range(len(word)):
yield (word[:index], word[index + 1:])

pattern_to_words = defaultdict(list)
for word in wordList:
for pattern in generate_pattern(word):
pattern_to_words[pattern].append(word)

generated_words = set([beginWord])
words_and_lengths = deque([(beginWord, 1)])
while words_and_lengths:
word, length = words_and_lengths.popleft()
for pattern in generate_pattern(word):
for next_word in pattern_to_words[pattern]:
if next_word == endWord:
return length + 1
if next_word in generated_words:
continue
generated_words.add(next_word)
words_and_lengths.append((next_word, length + 1))
return 0
```

# step 3
一度,`popleft()`を`pop()`と書いてDFSをしていた。
```python
from collections import deque
from collections import defaultdict


class Solution:
def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
def generate_pattern(word: str) -> Iterator[Tuple[str, str]]:
for index in range(len(word)):
yield word[:index], word[index + 1:]

pattern_to_words = defaultdict(list)
for word in wordList:
for pattern in generate_pattern(word):
pattern_to_words[pattern].append(word)

words_and_lengths = deque([(beginWord, 1)])
generated_words = set([beginWord])
while words_and_lengths:
word, length = words_and_lengths.popleft()
for pattern in generate_pattern(word):
for next_word in pattern_to_words[pattern]:
if next_word == endWord:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

後のステップで直っていますが、ここのネストが深すぎてちょっと読みづらさを感じました。

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

            for pattern in generate_pattern(word):
                for next_word in pattern_to_words[pattern]:

この二行圧縮すべきでしたね、ありがとうございます

return length + 1
if next_word in generated_words:
continue
generated_words.add(next_word)
words_and_lengths.append((next_word, length + 1))
return 0
```

```python
from collections import deque


class Solution:
def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
def generate_adjacent_word(
word: str,
word_set: Set[str]
) -> Iterable[str]:
for index in range(len(word)):
for char in string.ascii_lowercase:
generated_word = f"{word[:index]}{char}{word[index + 1:]}"
if generated_word not in word_set:
continue
yield generated_word

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

373-375 行目は、

if generated_word in word_set:
    yield generated_word

のほうが簡潔ではありませんか?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

個人的には、そこまで簡潔さが変わっている印象はないです。
こうしている理由としては、https://discord.com/channels/1084280443945353267/1192728121644945439/1194203372115464272
これに書いてある気持ちに近いかもしれません。

ただリンク先の問題と異なり、この部分は、条件も、それが成立した時・しなかった時にする処理も1行で表せるので、いただいた提案でも簡潔だと思います。


words_and_lengths = deque([(beginWord, 1)])
generated_words = set([beginWord])
word_set = set(wordList)
while words_and_lengths:
word, length = words_and_lengths.popleft()
for next_word in generate_adjacent_word(word, word_set):
if next_word == endWord:
return length + 1
if next_word in generated_words:
continue
generated_words.add(next_word)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

word_set から取り除くのも手かと思います。

words_and_lengths.append((next_word, length + 1))
return 0
```