Skip to content
Open
Show file tree
Hide file tree
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
185 changes: 185 additions & 0 deletions 127-Word-Ladder/benchmark.py
Original file line number Diff line number Diff line change
@@ -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()
100 changes: 100 additions & 0 deletions 127-Word-Ladder/note.md
Original file line number Diff line number Diff line change
@@ -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回)

関数に分割しすぎていたので自分でちょうど良いと思えるくらいにした
49 changes: 49 additions & 0 deletions 127-Word-Ladder/step1-1.py
Original file line number Diff line number Diff line change
@@ -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)])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

word_to_distanceでも良いと思いました。

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

ありがとうございます。現状だと distance 要素がない命名になっているのでおっしゃる通り名前に入れると良さそうですね。

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)
33 changes: 33 additions & 0 deletions 127-Word-Ladder/step1-2.py
Original file line number Diff line number Diff line change
@@ -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()
Loading