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
69 changes: 69 additions & 0 deletions word_ladder/phase1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 単純にBFSをしてtime limit overしたもの, そりゃそうだろうなという感じの時間計算量
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

@SuperHotDogCat SuperHotDogCat Mar 5, 2025

Choose a reason for hiding this comment

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

この解答だとqueue.append((word, next_available_words, sequence_length + 1))で使える単語も保持していて遠回りも含めた全部のルートを考えてしまっているので, 大雑把には最悪の場合n = wordList.lengthとした時n×(n-1)×(n-2)×...×1となってしまい, O(n!)かかります。Pythonは1秒10^6stepほどだった気がするので今回wordList.length <=5000であり, スターリング公式でn!≒n^(n+1/2) / e^nとして考えるとn=5000を代入して1831^5000 × 70 / 10^6≒(2000)^5000×70/10^6=7×10^16495 sec程度かかる気がします。(2^10≒10^3を用いて計算)

Copy link
Owner Author

Choose a reason for hiding this comment

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

あ、いやcalc_word_diffにword.length分かかるので今回word.length<=10なので7×10^16496 secでしょうか

Copy link

@nodchip nodchip Mar 5, 2025

Choose a reason for hiding this comment

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

n×(n-1)×(n-2)×...×1となってしまい

ここの部分に違和感を感じました。単語 S1 → S2 → S3 → ... → Sn と遷移して endWord にたどり着く場合、 endWord に近づく遷移と遠ざかる遷移があります。遷移は n 回行われますので、多めに見積もって 2^n に比例した個数の状態が保持されます。また wordList のコピーを毎回作っていますので、 2^n * n になると思います。 n = 5000 を代入すると、 log_{10}2 = 0.3 として、 2^5000 * 5000 ≒ 10^1500 * 5000 = 5.0 * 10^1503 くらいになると思います。
単語 S1 → S2 → S3 → ... → Sn と遷移して endWord にたどり着くような入力を意図して作れるかは未検討です。

Copy link
Owner Author

Choose a reason for hiding this comment

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

こういう感じでしょうか
IMG_9372

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.

ちょっと実験してみました。
begin: hot, wordList: [lot, got, cot, cog], end: cogで少し樹形図書いてみたら9ルート出てきて確かに2^4のほうが4!よりは抑えられている気がしました。ありがとうございます。

class Solution:
def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
for word in wordList:
if word == endWord: # ここ, if endWord not in wordListで効率化できるな
break
else:
return 0 # もしwordListになければreturnする

def calc_word_diff(s1: str, s2: str) -> int:
word_diff_count = 0
for i in range(len(s1)):
if s1[i] != s2[i]:
word_diff_count += 1
Copy link

Choose a reason for hiding this comment

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

この問題で必要なのって、単語が隣接しているかどうかなので、diff_countについて2以上を数える必要がなくなり、そのようなロジックを組み込めるとそのぶんシンプルにできそうです。

return word_diff_count
queue = deque([(beginWord, wordList, 1)])
while queue:
tail_word, available_words, sequence_length = queue.popleft()
if tail_word == endWord:
return sequence_length
for word in available_words:
if calc_word_diff(tail_word, word) == 1:
Copy link

Choose a reason for hiding this comment

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

ネストが深すぎるので、ここを反転させてearly continueみたいな感じでやれると読みやすくできそうです。

next_available_words = available_words.copy()
next_available_words.remove(word)
queue.append((word, next_available_words, sequence_length + 1))

return 0

# 前もってグラフを作っておいてBFS, seenで管理しているので最大でもO(5000^2)なはず。通りはしたがなんか遅い, 何か間違えている気がする
# seenで管理するのは今回はshortest pathなのでBFSで最初についたルートでseenに追加したとしても問題ないからだと考えている
class Solution:
def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
for word in wordList:
if word == endWord: # ここ, if endWord not in wordListで効率化できるな
break
else:
return 0 # もしwordListになければreturnする

def calc_word_diff(s1: str, s2: str) -> int:
word_diff_count = 0
for i in range(len(s1)):
if s1[i] != s2[i]:
word_diff_count += 1
return word_diff_count

word_graph = defaultdict(list)
Copy link

Choose a reason for hiding this comment

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

グラフの中に入れる要素を単語本体ではなく wordList 内のインデックスとすることで、処理を少し軽くすることができるかもしれません。

for word in wordList:
if calc_word_diff(beginWord, word) == 1:
word_graph[beginWord].append(word)

for i in range(len(wordList) - 1):
for j in range(i + 1, len(wordList)):
if calc_word_diff(wordList[i], wordList[j]) == 1:
word_graph[wordList[i]].append(wordList[j])
word_graph[wordList[j]].append(wordList[i])

seen = set()
queue = deque([(beginWord, 1)])
while queue:
tail_word, sequence_length = queue.popleft()
for next_word in word_graph[tail_word]:
if next_word == endWord:
return sequence_length + 1
if next_word in seen:
continue
seen.add(next_word)
queue.append((next_word, sequence_length + 1))

return 0
100 changes: 100 additions & 0 deletions word_ladder/phase2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# @hardjuice @tohsumi @colorbox @pompom0x0 @takkatao
# https://github.com/olsen-blue/Arai60/pull/20/files
# - 遷移のさせ方、には一工夫必要。ある単語(例:hot)から、変化のさせ方としては、3パターン存在する。
# - 具体的には、「*ot」「h*t」「ho*」の3パターンである。これらをキーとして、対応する単語を、辞書管理すれば良さそう。
# - 「hot」 ->「*ot」「h*t」「ho*」という変換が、必要そう。
# このまとめ方が参考になった, 文字列の結合でなくてtupleをkeyにするというのも確かにと思った
# get_keys_from_word(word)で結局O(n)かかるので自分と同じような実行時間になるのかしら...?
# https://github.com/colorbox/leetcode/pull/34/files
# https://github.com/Hurukawa2121/leetcode/pull/20#pullrequestreview-2569671996
# https://github.com/t0hsumi/leetcode/pull/20
# https://github.com/tshimosake/arai60/pull/11#discussion_r1944112516
# ordを一応確認 https://github.com/t0hsumi/leetcode/pull/12#discussion_r1886759238

# 最初にグラフを作らなくてもよかった
class Solution:
def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
for word in wordList:
if word == endWord: # ここ, if endWord not in wordListで効率化できるな
Copy link

Choose a reason for hiding this comment

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

pythonのリストのin, not inについてはネイティブコードで実装されているので、速度的にも入力文字数という意味でもnot inとした方が良さそうです。
https://github.com/python/cpython/blob/main/Objects/listobject.c#L616

break
else:
return 0 # もしwordListになければreturnする

def calc_word_diff(s1: str, s2: str) -> int:
word_diff_count = 0
for i in range(len(s1)):
if s1[i] != s2[i]:
word_diff_count += 1
return word_diff_count

seen = set()
queue = deque([(beginWord, 1)])
while queue:
tail_word, sequence_length = queue.popleft()
for word in wordList:
if word not in seen and calc_word_diff(tail_word, word) == 1:
if word == endWord:
return sequence_length + 1
queue.append((word, sequence_length + 1))
seen.add(word)
return 0
# 書いてから思ったけど1に等しいかどうかで判断する方が綺麗かも
Copy link

Choose a reason for hiding this comment

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

僕もそう思います

# def is_connectable(s1: str, s2: str) -> bool:
# count = 0
# for i in range(len(s1)):
# if s1[i] != s2[i]:
# count += 1
# return count == 1

# 1文字違う←→1文字だけ入れ替えたものがhitするかで判断, ただまあ早くはなったんですが文字の読みにくくなった気がする。calc_word_diffみたいに関数名にした方が好きかも
# あとはこのコードだと英語小文字以外で対応ができない, 拡張がだるい, 文字コード全部対象だとlower_characters = 'abcdefghijklmnopqrstuvwxyz'のところが長くてつらいなあという感じでしょうか
# このコードがボトルネックで, サービスに影響を与えるなら使うか程度?
class Solution(object):
def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
wordList = set(wordList)
queue = deque([[beginWord, 1]])
lower_characters = 'abcdefghijklmnopqrstuvwxyz'
Copy link

Choose a reason for hiding this comment

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

string.ascii_lowercaseを使うのもありかと思います。字数はそれほど変わりませんが、タイプミスや入れ忘れは減ると思います。

while queue:
word, length = queue.popleft()
if word == endWord:
return length
for i in range(len(word)):
for c in lower_characters:
next_word = word[:i] + c + word[i + 1:] # chr(ord('a') + [0:26]の数)でも良い
if next_word in wordList:
wordList.remove(next_word)
queue.append([next_word, length + 1])
return 0

# key_to_words, word_to_keysを作ってwhile文内部の処理を早くする放法
# key = (beginWord[:i], beginWord[i + 1:])という処理, 早くはなるんだけどやはりコメントを付与してあげないと一見してわからないコードかも?
class Solution(object):
def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
key_to_words = defaultdict(list)
word_to_keys = defaultdict(list)
Comment on lines +73 to +74
Copy link

Choose a reason for hiding this comment

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

両方とも用意するのわかりやすくていいですね

for i in range(len(beginWord)):
key = (beginWord[:i], beginWord[i + 1:]) # 1文字違うことを示す ex. hot -> *ot, h*t, ho*のようにおこなう
key_to_words[key].append(beginWord)
word_to_keys[beginWord].append(key)

for word in wordList:
for i in range(len(word)):
key = (word[:i], word[i + 1:])
key_to_words[key].append(word)
word_to_keys[word].append(key)

Copy link

Choose a reason for hiding this comment

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

私は初期化の部分で関数を分けるかもしれません。

if endWord not in word_to_keys:
return 0

seen = set([beginWord])
queue = deque([(beginWord, 1)])
while queue:
tail_word, sequence_length = queue.popleft()
for key in word_to_keys[tail_word]:
for word in key_to_words[key]:
if word not in seen:
if word == endWord:
return sequence_length + 1
Comment on lines +92 to +97
Copy link

Choose a reason for hiding this comment

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

ここのネストがかなり深くなっていくので、tail_wordを引数として、次のwordを生成する関数があってもいいのではと感じました。以下みたいな感じでしょうか?

def generate_next_word(word: str, seen: set[str]) -> Iterator[str]:
    for key in word_to_keys[word]:
        for next_word in key_to_words[key]:
            if next_word in seen:
                continue
            yield next_word

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.

wordがendWordの時すぐreturnすればネストを一つ消せることに気づきましたが, それでもまだ長い気がするのでyieldを使うことにします。

while queue:
    tail_word, sequence_length = queue.popleft()
    for key in word_to_keys[tail_word]:
        for word in key_to_words[key]:
            if word == endWord:
                return sequence_length + 1
            if word not in seen:
                queue.append((word, sequence_length + 1))
                seen.add(word)

queue.append((word, sequence_length + 1))
seen.add(word)
return 0
23 changes: 23 additions & 0 deletions word_ladder/phase3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
class Solution:
def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
if endWord not in wordList:
return 0

def is_connectable(s1: str, s2: str) -> bool:
count = 0
for i in range(len(s1)):
if s1[i] != s2[i]:
count += 1
return count == 1
Comment on lines +6 to +11
Copy link

Choose a reason for hiding this comment

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

countが1を超えた段階でFalseを返すと、少しだけ早くできそうです。


seen = set([beginWord])
queue = deque([(beginWord, 1)])
while queue:
tail_word, sequence_length = queue.popleft()
for word in wordList:
if word not in seen and is_connectable(word, tail_word):
if word == endWord:
return sequence_length + 1
queue.append((word, sequence_length + 1))
seen.add(word)
return 0