diff --git a/127-Word-Ladder/benchmark.py b/127-Word-Ladder/benchmark.py new file mode 100644 index 0000000..8f72292 --- /dev/null +++ b/127-Word-Ladder/benchmark.py @@ -0,0 +1,185 @@ +import importlib.util +import itertools +import random +import timeit +from pathlib import Path + + +BASE_DIR = Path(__file__).parent +STEP1_SOLUTION_FILES = ("step1-1.py", "step1-2.py", "step1-3.py") +STEP2_COMPARE_FILES = ("step1-3.py", "step2.py") +REPEAT = 5 + + +def load_solution(filename: str): + path = BASE_DIR / filename + module_name = filename.removesuffix(".py").replace("-", "_") + spec = importlib.util.spec_from_file_location(module_name, path) + if spec is None or spec.loader is None: + raise RuntimeError(f"failed to load {path}") + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.Solution() + + +def random_words(count: int, word_length: int, seed: int) -> list[str]: + rng = random.Random(seed) + words = set() + alphabet = "abcdefghijklmnopqrstuvwxyz" + while len(words) < count: + words.add("".join(rng.choice(alphabet) for _ in range(word_length))) + return list(words) + + +def sparse_with_short_chain(count: int) -> tuple[str, str, list[str]]: + chain = ["aaaaa", "aaaab", "aaabb", "aabbb", "abbbb", "bbbbb"] + extras = [ + word + for word in random_words(count + 20, word_length=5, seed=count) + if word not in chain + ] + word_list = chain[1:] + extras[: max(0, count - len(chain) + 1)] + return chain[0], chain[-1], word_list + + +def are_adjacent(word1: str, word2: str) -> bool: + return sum(c1 != c2 for c1, c2 in zip(word1, word2)) == 1 + + +def long_induced_chain(count: int, word_length: int = 10) -> tuple[str, str, list[str]]: + rng = random.Random(count) + alphabet = "abcdefghijklmnopqrstuvwxyz" + chain = ["a" * word_length] + used = set(chain) + + while len(chain) < count: + current = chain[-1] + candidates = [] + for i in range(word_length): + for letter in alphabet: + if current[i] == letter: + continue + candidate = current[:i] + letter + current[i + 1 :] + if candidate in used: + continue + if any(are_adjacent(candidate, word) for word in chain[:-1]): + continue + candidates.append(candidate) + + if not candidates: + raise RuntimeError(f"failed to generate long chain: count={count}") + + next_word = rng.choice(candidates) + chain.append(next_word) + used.add(next_word) + + return chain[0], chain[-1], chain[1:] + + +def connected_grid(count: int) -> tuple[str, str, list[str]]: + words = ["".join(chars) for chars in itertools.product("abcde", repeat=5)] + selected = words[:count] + begin_word = selected[0] + end_word = selected[-1] + word_list = [word for word in selected if word != begin_word] + return begin_word, end_word, word_list + + +def bench(solution, begin_word: str, end_word: str, word_list: list[str]) -> float: + def run(): + # step1-1.py appends begin_word, so pass a fresh copy every run. + return solution.ladderLength(begin_word, end_word, word_list.copy()) + + return timeit.timeit(run, number=REPEAT) + + +def main() -> None: + cases = [ + ("classic", "hit", "cog", ["hot", "dot", "dog", "lot", "log", "cog"], 5), + ] + for count in (200, 800, 1600): + begin_word, end_word, word_list = sparse_with_short_chain(count) + cases.append((f"sparse-chain-{count}", begin_word, end_word, word_list, 6)) + for count in (100, 200, 400): + begin_word, end_word, word_list = long_induced_chain(count) + cases.append((f"long-chain-{count}", begin_word, end_word, word_list, count)) + for count in (125, 625, 1500): + begin_word, end_word, word_list = connected_grid(count) + cases.append((f"connected-grid-{count}", begin_word, end_word, word_list, None)) + + step1_solutions = { + filename: load_solution(filename) for filename in STEP1_SOLUTION_FILES + } + + print(f"REPEAT={REPEAT}") + print( + f"{'case':<22} {'expected':>8} {'step1-1 (ms)':>14} " + f"{'step1-2 (ms)':>14} {'step1-3 (ms)':>14} {'winner':>8}" + ) + print("-" * 86) + for name, begin_word, end_word, word_list, expected in cases: + results = {} + durations = {} + for filename, solution in step1_solutions.items(): + result = solution.ladderLength(begin_word, end_word, word_list.copy()) + if expected is not None: + assert result == expected, ( + f"{filename}: got {result}, expected {expected} in {name}" + ) + results[filename] = result + durations[filename] = bench(solution, begin_word, end_word, word_list) * 1000 + + if expected is None: + result_values = set(results.values()) + assert len(result_values) == 1, f"answer mismatch in {name}: {results}" + expected = result_values.pop() + + winner = min(durations, key=durations.get) + print( + f"{name:<22} {expected:>8} " + f"{durations['step1-1.py']:>14.3f} " + f"{durations['step1-2.py']:>14.3f} " + f"{durations['step1-3.py']:>14.3f} " + f"{winner.removesuffix('.py'):>8}" + ) + + step2_solutions = { + filename: load_solution(filename) for filename in STEP2_COMPARE_FILES + } + + print() + print("step1-3.py vs step2.py") + print( + f"{'case':<22} {'expected':>8} {'step1-3 (ms)':>14} " + f"{'step2 (ms)':>12} {'winner':>8}" + ) + print("-" * 70) + for name, begin_word, end_word, word_list, expected in cases: + results = {} + durations = {} + for filename, solution in step2_solutions.items(): + result = solution.ladderLength(begin_word, end_word, word_list.copy()) + if expected is not None: + assert result == expected, ( + f"{filename}: got {result}, expected {expected} in {name}" + ) + results[filename] = result + durations[filename] = bench(solution, begin_word, end_word, word_list) * 1000 + + if expected is None: + result_values = set(results.values()) + assert len(result_values) == 1, f"answer mismatch in {name}: {results}" + expected = result_values.pop() + + winner = min(durations, key=durations.get) + print( + f"{name:<22} {expected:>8} " + f"{durations['step1-3.py']:>14.3f} " + f"{durations['step2.py']:>12.3f} " + f"{winner.removesuffix('.py'):>8}" + ) + + +if __name__ == "__main__": + main() diff --git a/127-Word-Ladder/note.md b/127-Word-Ladder/note.md new file mode 100644 index 0000000..596bc08 --- /dev/null +++ b/127-Word-Ladder/note.md @@ -0,0 +1,100 @@ +# 127. Word Ladder + +https://leetcode.com/problems/word-ladder/ + +## step1(まず通す) + +word をノード、編集距離1の word 同士がエッジで繋がっていると思うと、これは beginWord から endWord までのグラフの最短経路を探す問題になる。 + +隣接判定、隣接リスト作成、最短経路探索の3つの道具があれば答えがわかる、ということで書いたのが最初のコード(step1-1.py)。 + +ただしこれは隣接リスト作成が配列長さ n に対してbegin_word を足してるので n(n + 1)/2 回ループを回すので遅い。 +n <= 5000 とかなので、最大でループが 12500000 回くらい回る。しかもその中で毎回、単語長さの回数だけループしているので、全部で 1億回ループ回してることになる。それは遅いはずだ。append して入力を破壊しているのも気になる。 + +この部分をもう少し効率よくやるとして考えたのは、全ペアについて事前に隣接判定をするのではなくて、必要になった時に必要な分だけ隣接判定をする(= 経路を探索しながら道を見つける)方法。探索時に見る単語数が少ない場合は速そう。逆に、見る単語数が多い(到達可能な単語が多いとか、長い一本道とか)ケースでは結構な回数ループが回るのでそれほど早くなるわけではなさそう。(step1-2.py) + +他に思いつく方法として、隣接リスト作成時、ある単語に隣接する単語を探すのにリストを走査するのではなくて、隣接しうる単語(もとの単語から任意の1文字を別のアルファベットに変更したもの)を候補として、それが word_list (を set にしたもの)に入っているかを見るというのがある。こうすると、各単語につき最大でも `変更箇所 10 文字 * 変更先アルファベット 25 択 = 250` 種類しか候補がないのでだいぶ速そう。単語の長さや文字種が多い時はこれは不利。今回の250 はリスト長さの半分なので、リスト長が長い時は 1-1 から比較すると数倍早くなりそうだが、逆にリスト長さが短い時は逆に遅いだろう。(step1-3.py) + +### 速度テスト + +387 の時と同じように Codex に `benchmark.py` を書かせて計測した。 + +注意: + +- 表示されている時間はすべて REPEAT 回分の合計 +- `step1-1.py` は `word_list.append(begin_word)` で入力を破壊するので、計測時は毎回 `word_list.copy()` を渡している +- `sparse-chain-*` はノイズ単語が多く、短い経路だけがあるケース +- `long-chain-*` はショートカットできない長い一本道を探索するケース +- `connected-grid-*` は到達可能な単語が多く、探索範囲が広がるケース + +```txt +> python benchmark.py +REPEAT=5 +case expected step1-1 (ms) step1-2 (ms) step1-3 (ms) winner +-------------------------------------------------------------------------------------- +classic 5 0.040 0.025 0.256 step1-2 +sparse-chain-200 6 19.670 0.950 12.283 step1-2 +sparse-chain-800 6 311.258 3.772 49.080 step1-2 +sparse-chain-1600 6 1248.045 7.555 92.355 step1-2 +long-chain-100 100 7.071 6.757 12.366 step1-2 +long-chain-200 200 27.633 26.623 26.871 step1-2 +long-chain-400 400 109.059 105.169 50.591 step1-3 +connected-grid-125 4 7.428 2.076 7.907 step1-2 +connected-grid-625 5 187.273 65.378 39.821 step1-3 +connected-grid-1500 6 1084.208 478.642 94.773 step1-3 +``` + +結果を見ると、`step1-1.py` は最初に全ペアの隣接判定をするので、入力が大きくなるとかなり遅い。 + +`step1-2.py` は探索しながら残り単語に対して隣接判定するので、短い経路がすぐ見つかる `sparse-chain-*` では一番速い。逆に、到達可能な単語が多くて探索範囲が広がる `connected-grid-*` や、長い一本道を最後までたどる `long-chain-*` では、未使用単語リストの走査が何度も発生するので `step1-3.py` に負ける。 + +`step1-3.py` は各単語ごとに `単語長 * 25` 個の候補を作って set で存在確認するので、単語リスト全体を毎回なめる必要がない。LeetCode の制約では単語長が最大 10、単語数が最大 5000 なので、全ペア比較よりこちらのほうがスケールしやすいが、単語数が少ない時は遅い。 + +## step2(整形&他の人のコードを読む) + +step1-3.py をベースにする。 + +- インライン関数で書いたせいでだいぶ見づらくなってそうなので独立したメソッドにする + +### 他の人のコードを読む + +- https://github.com/naoto-iwase/leetcode/pull/19/changes + - パターン検索のアイディア + - ある1文字を25種で具体的に置き換えるのではなく、"\*" で抽象化する考え方 + - タプルで持てば、単に切れ目として抽象化することもできる(抽象化したダミー文字が不要なので文字種によらず対応できる) + - 単純計算で25倍くらいは速くなりそう + - 以前他の方のコードをレビューした時にコメントした、「要素をグループ分けする時に、全ペアについて同じグループかを見るよりもグループに『名前』をつけた方が効率がいい」というのが当てはまる + - 今回の「名前」は `(word[:i], word[i + 1:])` というタプル + +計測結果: + +`benchmark.py` に `step2.py` も追加して、`step1-3.py` と比較した。 + +```txt +step1-3.py vs step2.py +case expected step1-3 (ms) step2 (ms) winner +---------------------------------------------------------------------- +classic 5 0.245 0.036 step2 +sparse-chain-200 6 12.367 1.366 step2 +sparse-chain-800 6 49.117 5.354 step2 +sparse-chain-1600 6 92.987 11.063 step2 +long-chain-100 100 12.760 1.458 step2 +long-chain-200 200 26.733 2.892 step2 +long-chain-400 400 50.554 5.853 step2 +connected-grid-125 4 7.737 0.919 step2 +connected-grid-625 5 39.782 4.733 step2 +connected-grid-1500 6 101.432 11.970 step2 +``` + +`step1-3.py` は各単語について `単語長 * 25` 個の候補文字列を作るが、`step2.py` は1文字を抜いたパターンごとに単語をまとめておく。今回の制約では、候補文字列を全部作るよりパターン辞書を使う方がかなり速い。 + +- https://github.com/shining-ai/leetcode/pull/20/files#r1517033572 + - 文字列を2箇所以上で結合するときは、二項演算子+よりf-stringの方がパフォーマンスが優れており、Google Style Guideで推奨されているとのこと。 +- https://discord.com/channels/1084280443945353267/1200089668901937312/1216123084889788486 + - 比較の方法について + - 「頭から半分または尻尾から半分が一致しているはずなので、それでバケットを作ってバケット内でのみ比較すればいいというやりかたもありますね。(編集距離が1であるかの確認に、頭から何文字一致していて、尻尾から何文字一致しているかを足してやればいいという方法をどっかで使ったことあります。)」 + - https://cs.stackexchange.com/questions/93467/data-structure-or-algorithm-for-quickly-finding-differences-between-strings + +## step3(10分以内にさっとかける \* 3回) + +関数に分割しすぎていたので自分でちょうど良いと思えるくらいにした diff --git a/127-Word-Ladder/step1-1.py b/127-Word-Ladder/step1-1.py new file mode 100644 index 0000000..856139a --- /dev/null +++ b/127-Word-Ladder/step1-1.py @@ -0,0 +1,49 @@ +from collections import defaultdict, deque + + +class Solution: + def ladderLength(self, begin_word: str, end_word: str, word_list: list[str]) -> int: + def are_adjacent(word1: str, word2: str) -> bool: + diff_count = 0 + for c1, c2 in zip(word1, word2): + if c1 != c2: + diff_count += 1 + + return diff_count == 1 + + def construct_adjacency_list() -> dict[str, list[str]]: + word_to_adjacency_list = defaultdict(list) + for i in range(len(word_list)): + for j in range(i): + word1 = word_list[i] + word2 = word_list[j] + if are_adjacent(word1, word2): + word_to_adjacency_list[word1].append(word2) + word_to_adjacency_list[word2].append(word1) + + return word_to_adjacency_list + + def find_shortest_transformations_length( + word_to_adjacency_list: dict[str, list[str]], + ) -> int: + words_to_see = deque([(begin_word, 1)]) + seen_words = set([begin_word]) + while words_to_see: + word, distance = words_to_see.popleft() + if word == end_word: + return distance + + for adjacent in word_to_adjacency_list[word]: + if adjacent in seen_words: + continue + seen_words.add(adjacent) + words_to_see.append((adjacent, distance + 1)) + + return 0 + + if end_word not in word_list: + return 0 + + word_list.append(begin_word) + word_to_adjacency_list = construct_adjacency_list() + return find_shortest_transformations_length(word_to_adjacency_list) diff --git a/127-Word-Ladder/step1-2.py b/127-Word-Ladder/step1-2.py new file mode 100644 index 0000000..7e6ff1e --- /dev/null +++ b/127-Word-Ladder/step1-2.py @@ -0,0 +1,33 @@ +from collections import deque + + +class Solution: + def ladderLength(self, begin_word: str, end_word: str, word_list: list[str]) -> int: + words_not_used = set(word_list) + if end_word not in words_not_used: + return 0 + + def are_adjacent(word1: str, word2: str) -> bool: + diff_count = 0 + for c1, c2 in zip(word1, word2): + if c1 != c2: + diff_count += 1 + + return diff_count == 1 + + def find_shortest_transformations_length() -> int: + words_to_see = deque([(begin_word, 1)]) + words_not_used.discard(begin_word) + while words_to_see: + word, distance = words_to_see.popleft() + if word == end_word: + return distance + + for w in list(words_not_used): + if not are_adjacent(word, w): + continue + words_not_used.remove(w) + words_to_see.append((w, distance + 1)) + return 0 + + return find_shortest_transformations_length() diff --git a/127-Word-Ladder/step1-3.py b/127-Word-Ladder/step1-3.py new file mode 100644 index 0000000..f2d3f5e --- /dev/null +++ b/127-Word-Ladder/step1-3.py @@ -0,0 +1,49 @@ +from collections import deque + + +class Solution: + def ladderLength(self, begin_word: str, end_word: str, word_list: list[str]) -> int: + all_letters = "abcdefghijklmnopqrstuvwxyz" + word_set = set(word_list) + word_list_and_begin = word_list + [begin_word] + + def get_adjacent_words(word: str) -> list[str]: + adjacenct_words = [] + for i in range(len(word)): + for letter in all_letters: + if word[i] == letter: + continue + candidate = word[:i] + letter + word[i + 1 :] + if candidate in word_set: + adjacenct_words.append(str(candidate)) + + return adjacenct_words + + def construct_adjacency_list() -> dict[str, list[str]]: + adjacency_list = dict() + for word in word_list_and_begin: + adjacency_list[word] = get_adjacent_words(word) + + return adjacency_list + + def find_shortest_transformations_length( + adjacency_list: dict[str, list[str]], + ) -> int: + words_to_see = deque([(begin_word, 1)]) + seen_words = set([begin_word]) + while words_to_see: + word, distance = words_to_see.popleft() + if word == end_word: + return distance + + for adjacent in adjacency_list[word]: + if adjacent in seen_words: + continue + seen_words.add(adjacent) + words_to_see.append((adjacent, distance + 1)) + + return 0 + + adjacency_list = construct_adjacency_list() + num_min_step = find_shortest_transformations_length(adjacency_list) + return num_min_step diff --git a/127-Word-Ladder/step2.py b/127-Word-Ladder/step2.py new file mode 100644 index 0000000..a70e3d5 --- /dev/null +++ b/127-Word-Ladder/step2.py @@ -0,0 +1,59 @@ +from collections import defaultdict, deque + + +class Solution: + def get_patterns_map( + self, word_list: list[str] + ) -> dict[tuple[str, str], list[str]]: + patterns_to_words = defaultdict(list) + for word in word_list: + for i in range(len(word)): + patterns_to_words[(word[:i], word[i + 1 :])].append(word) + + return patterns_to_words + + def get_adjacent_words( + self, patterns_to_words: dict[tuple[str, str], list[str]], word: str + ) -> list[str]: + adjacenct_words = [] + for i in range(len(word)): + adjacenct_words += patterns_to_words[(word[:i], word[i + 1 :])] + return adjacenct_words + + def construct_adjacency_list( + self, word_list: list[str], begin_word: str + ) -> dict[str, list[str]]: + adjacency_list = dict() + patterns_to_words = self.get_patterns_map(word_list) + adjacency_list[begin_word] = self.get_adjacent_words( + patterns_to_words, begin_word + ) + for word in word_list: + adjacency_list[word] = self.get_adjacent_words(patterns_to_words, word) + + return adjacency_list + + def find_shortest_transformations_length( + self, begin_word: str, end_word: str, adjacency_list: dict[str, list[str]] + ) -> int: + words_to_see = deque([(begin_word, 1)]) + seen_words = set([begin_word]) + while words_to_see: + word, distance = words_to_see.popleft() + if word == end_word: + return distance + + for adjacent in adjacency_list[word]: + if adjacent in seen_words: + continue + seen_words.add(adjacent) + words_to_see.append((adjacent, distance + 1)) + + return 0 + + def ladderLength(self, begin_word: str, end_word: str, word_list: list[str]) -> int: + adjacency_list = self.construct_adjacency_list(word_list, begin_word) + num_min_step = self.find_shortest_transformations_length( + begin_word, end_word, adjacency_list + ) + return num_min_step diff --git a/127-Word-Ladder/step3.py b/127-Word-Ladder/step3.py new file mode 100644 index 0000000..a7099ac --- /dev/null +++ b/127-Word-Ladder/step3.py @@ -0,0 +1,34 @@ +from collections import defaultdict, deque + + +class Solution: + def construct_adjacency_list( + self, begin_word: str, word_list: list[str] + ) -> dict[str, list[str]]: + patterns_to_words = defaultdict(list) + for word in word_list: + for i in range(len(word)): + patterns_to_words[(word[:i], word[i + 1 :])].append(word) + + adjacency_list = defaultdict(list) + for word in word_list + [begin_word]: + for i in range(len(word)): + adjacency_list[word] += patterns_to_words[(word[:i], word[i + 1 :])] + + return adjacency_list + + def ladderLength(self, begin_word: str, end_word: str, word_list: list[str]) -> int: + adjacency_list = self.construct_adjacency_list(begin_word, word_list) + words_to_see = deque([(begin_word, 1)]) + seen_words = set([begin_word]) + while words_to_see: + word, distance = words_to_see.popleft() + if word == end_word: + return distance + for adjacent in adjacency_list[word]: + if adjacent in seen_words: + continue + words_to_see.append((adjacent, distance + 1)) + seen_words.add(adjacent) + + return 0