Skip to content

127. Word Ladder#42

Open
hayashi-ay wants to merge 7 commits intomainfrom
hayashi-ay-patch-31
Open

127. Word Ladder#42
hayashi-ay wants to merge 7 commits intomainfrom
hayashi-ay-patch-31

Conversation

@hayashi-ay
Copy link
Copy Markdown
Owner

@@ -0,0 +1,127 @@
グラフの問題として解ける。beginWordからendWordまでの最短経路を求めれば良いので、BFSが良い。
hitの次の候補は"\*it", "h\*t", "hi\*"になる。\*には任意のアルファベットが入る。
事前に"\*it"などの一文字を任意にした状態のワードの一覧を作ってあげれば次のノードを見つけるのが楽になる。
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

考え方がやや複雑に感じました。 1 <= beginWord.length <= 10、 1 <= wordList.length <= 5000 のため、あらかじめ全単語対の異なる文字数が 1 文字かどうかを愚直に調べ、グラフ構造に落とし込んだほうが、シンプルになるように思いました。

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.

確かにそうですね。一度解いたことがあり、解法を覚えていたのでこちらで解きましたが、素直に隣接リストを作るのが自然ですね。

(メモ)
wordの長さをM, wordListをNとしたときに、一文字を任意にした一覧を作る方法だとO(MN)で単語ごとに比較して隣接リスト方法だとO(MN^2)だが、Nが5000程度なら隣接リストを作成する方法も許容できる。

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.

解きました。こちらの方がシンプルで良いですね。
ただLeetCodeだとM=10, N=5000のケースがあり、TLEの基準の10s前後の実行時間になりました。

class Solution:
    def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
        def hamming_distance(word1, word2):
            distance = 0
            for i in range(len(word1)):
                if word1[i] != word2[i]:
                    distance += 1
                # LeetCodeのTLEを回避するために早めに打ち切る
                # 結構ギリギリでTLEを超える場合もある
                if distance > 1:
                    return distance
            return distance

        adj = defaultdict(list)
        for i in range(len(wordList) - 1):
            for j in range(i + 1, len(wordList)):
                word1, word2 = wordList[i], wordList[j]
                if hamming_distance(word1, word2) == 1:
                    adj[word1].append(word2)
                    adj[word2].append(word1)
        if beginWord not in wordList:
            for word in wordList:
                if hamming_distance(beginWord, word) == 1:
                    adj[beginWord].append(word)
                    adj[word].append(beginWord)

        words = [beginWord]
        seen = set(words)
        num_of_words = 1
        while words:
            next_words = []
            num_of_words += 1
            for word in words:
                for next_word in adj[word]:
                    if next_word in seen:
                        continue
                    if next_word == endWord:
                        return num_of_words
                    seen.add(next_word)
                    next_words.append(next_word)
            words = next_words
        return 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

C++ だと 329ms だったのでうっかりしていました。
言語によって速度がまちまちです。主要な言語については、どの程度の速度差があるか覚えておくとよいと思います。
https://github.com/niklas-heer/speed-comparison

Copy link
Copy Markdown
Owner Author

@hayashi-ay hayashi-ay Mar 7, 2024

Choose a reason for hiding this comment

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

PythonはC++に比べて50倍くらいを目安にしていましたが、添付の結果だと100倍弱ありそうですね。

同じスクリプト言語のJavaScriptが3から4倍ほどと早いのはJITの影響でしょうか?Python3.13からJITが入るそうなので、Pythonの速度差も今後縮まるかもですね。
https://tonybaloney.github.io/posts/python-gets-a-jit.html

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

JavaScript が速い具体的な原因は分かりません。ただ、ブラウザの中の人がかなり頑張って最適化しているという話は聞いたことがあります。

Python に関しては、 3.11 から高速化に注力しているとの事ですので、少しずつ速くなっていくと思います。

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.

V8 EngineにGoogleの人材が投下されてて色々な高速化がされて複合的に早いという感じなんですね。

groups = defaultdict(list)
for word in wordList:
for i in range(len(word)):
key = f"{word[:i]}*{word[i+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.

キーを作成するコードが複数回登場するので、関数に切り出したほうが読みやすくかつ、書き間違えにくくなると思います。

seen = set()
while words:
num_of_words += 1
next_words = []
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

next_words に同じ単語が重複して含まれる場合があるように思います。処理結果は正しいと思うのですが、無駄な処理になっていると思います。


num_of_words = 0
words = [beginWord]
seen = set()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

このタイミングで seen に beginWord を入れてしまうのはいかがでしょうか?

Copy link
Copy Markdown
Owner Author

@hayashi-ay hayashi-ay Mar 7, 2024

Choose a reason for hiding this comment

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

wordsをこれから訪れる一覧ではなく、すでに訪れた一覧と考えるということですね。効率は先にseenに入れてしまう方が良さそうですね。

key = f"{word[:i]}*{word[i+1:]}"
for next_word in groups[key]:
if next_word not in seen:
next_words.append(next_word)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

このタイミングで seen に next_word を入れれば、 next_words に同じ単語が重複してはいることは亡くなると思います。
また、その場合、
if next_word in seen:
と書いたほうが、ソースコードが読みやすくなると思います。

for word in words:
if word == endWord:
return num_of_words
if word in seen:
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

26~28 行目の処理は省略し、最内周ループで seen の処理を行うとよいと思います。

return 0
```

両側から。
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

入力データのサイズや傾向を考えると、両側探索は指し過ぎのように思います。

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.

一応LeetCodeの解法にあったのでこちらでも解いてみました。
LeetCodeのテストの通過が120msくらいが90msと確かに早くなったのですが、書いていて難しかったので実際はシンプルな1stのアプローチが良さそうですね。

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.

beginWordの長さが多くなると横に広がるので両側探索のメリットがよりあるかもですね。

Comment on lines +90 to +91
patterns = make_patterns(word)
for pattern in patterns:
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ここだけ他と記述方法が異なっています。

for next_word in pattern_to_words[pattern]:

個人的には一度変数に格納する今の書き方のほうが読みやすいです。

seen_from_end = set([endWord])
num_of_words = 2
while words_from_begin and words_from_end:
words, seen, other_seen = words_from_begin, seen_from_begin, seen_from_end
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

3変数に同時に代入は読むのがつらいと感じました。
swapでないなら、1行ずつ代入でいいのではないかと思いました。

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.

確かに変数名も長いですし、1行だと読みづらいですね。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants