diff --git a/03_step4.py b/03_step4.py new file mode 100644 index 0000000..13767ab --- /dev/null +++ b/03_step4.py @@ -0,0 +1,33 @@ +# time complexity: O(n) nの項の最善は最悪の 1/2倍 +# space complexity: O(n) nの項の最善は最悪の 1/n倍 + +class Solution: + def lengthOfLongestSubstring(self, s: str) -> int: + longest_length = 0 + window_chars = set() + end = 0 # end finally becomes len(s). + for begin in range(len(s)): + while end < len(s) and s[end] not in window_chars: + window_chars.add(s[end]) + end += 1 + longest_length = max(end - begin, longest_length) + window_chars.remove(s[begin]) + return longest_length + +# 変更点 +""" +変数名: +window_chars : 「見たものすべて」を予想させるseen_charsから変更 +begin, end : left, rightから変更 + window_begin, window_endも検討したが、簡潔性を選んだ。 + start, stop も検討したが、substring_stop としないと落ち着かなかったので避けた。(自分の慣れの問題かもしれない) + end が、exclusiveかつ最終的にlen(s)になることをコメントに明示したが、C++では含意されているので読み手によっては冗長と感じるかも。 + +制御: +beginのループをforループに変更: + 変更前は `while begin < len(s): begin += 1` だった。自明な書き換え + 内側の end の while ループはインデックスの制限の条件に加えて追加の条件があり、rangeに書き換えると制御にフラグを導入することになり複雑になったのでwhileループのまま。 + +コメント: +最善と最悪の定数倍はビッグO記法を使わずに記述 +""" diff --git a/3.md b/3.md new file mode 100644 index 0000000..c903cb2 --- /dev/null +++ b/3.md @@ -0,0 +1,193 @@ +# 問題 +題意:与えられた文字列にたいして、異なる文字だけからなる部分文字列の最大の長さはいくらか。 + +# 全探索 step1 +すべての区間に対して文字の出現回数を調べ、同じ文字が出現しない区間の長さを最大長と比較し更新していく。 + +計算量: +- 時間 O(n^3) 二重ループの中で配列の走査を2回呼んでいる O(n^2 * (n + n))。TLE +- 空間 O(n^3) O(n)のdictをn^2個構築している + - 入力の文字列長をnとして、substringの文字数の総和は、1文字がn個、2文字がn-1個、3文字がn-2個、、、n文字が1個。長い文字列ほど少ないので O(n^3) は上限ではない + +```py +from collections import defaultdict + +class Solution: + def count_chars(self, s:str): + char_count_map = defaultdict(int) + for char in s: + char_count_map[char] += 1 + return char_count_map + + def nums_appear_once(self, nums: List[int]): + for num in nums: + if num > 1: + return False + return True + + def lengthOfLongestSubstring(self, s: str) -> int: + max_substring_length = 0 + i = 0 + while i <= len(s) - 1: + j = i + while j <= len(s) - 1: + char_count_map = self.count_chars(s[i : j+1]) + current_substring_length = 0 + current_substring_length = j - i + 1 + max_substring_length = max(max_substring_length, current_substring_length) + j += 1 + i += 1 + return max_substring_length +# 考えるのに5分、全体の実装に8分、閉区間で書き直し&関数名で悩み、42分で動くものが完成 (大きい入力ではTLE) +``` +コードに対する感覚: +- 制御:二重ループの中身がごちゃごちゃしている。辞書を作って、それをさらに走査するのは、やりたいことに対して大げさに感じる。 +- 命名:nums_appear_once()が変。booleanを返すので疑問形になっていてほしい。 +- 制御・初期値:閉区間で書くためにwhileを使ったせいでインデックスの更新が散らばってしまっている。 + + +# 全探索 step2 +```py +class Solution: + def is_valid_substring(self, s: str): + char_count_map = defaultdict(int) + for char in s: + char_count_map[char] += 1 + if char_count_map[char] > 1: + return False + return True + + def lengthOfLongestSubstring(self, s: str) -> int: + max_substring_length = 0 + for i in range(len(s)): + for j in range(i, len(s)): + ## 説明変数 current_substring_lengthを使用 + current_substring_length = 0 + if self.is_valid_substring(s[i : j+1]): + current_substring_length = j - i + 1 + max_substring_length = max(max_substring_length, + current_substring_length) + + ## 上記ブロックの異なる表現 + if self.is_valid_substring(s[i : j+1]): + max_substring_length = max(j - i + 1, max_substring_length) # `j - i + 1` is the length of s[i:j+1] + return max_substring_length +``` +コードに対する感覚: +- 説明変数として current_substring_lengthを使っているが、max()の中で直接 `j-i-1`を使って、コメントで補うほうがスッキリする。 +- 印象として全探索step1より整理されているように感じる。 + + +# setを使う解法 step1 +注目している部分文字列が含む文字をsetで記憶 + +計算量: +- 時間 O(n) left, rightともにsの末尾近くまで進むとき最悪でO(2n)、すべての文字列が異なるとき最善。 +- 空間 O(n) すべての文字列が異なるとき最悪、すべての文字が同じ時最善。 + +```py +def lengthOfLongestSubstring(self, s: str) -> int: + max_substring_length = 0 + seen_chars = set() + left = 0 + right = 0 + while left < len(s): + while right < len(s) and s[right] not in seen_chars: + seen_chars.add(s[right]) + right += 1 + max_substring_length = max(right - left, max_substring_length) + seen_chars.remove(s[left]) + left += 1 + return max_substring_length +``` +アプローチ: +- 全探索ではスライスが更新されるごとに辞書を作り直していたが、right, leftのインクリメントにもとなって端の移動分だけ更新すると「現時点のsubstringに含まれている文字がなにか」の情報を使い回せる。 + +コードに対する感覚: +- 参考にした回答にはfor文でrightをインクリメントする回答が多かった。leftに対するwhile文で一番外側の制御を書くのは自分以外には読みにくいかもしれない。 + - 2重ループっぽい見た目なので O(n^2)っぽい印象を与えるかもしれない + - 「重複要素を見つけた直後にleftを1だけインクリメントする」というのは、ぱっと見では、leftを進め足りないと思ってしまう。 + - 重複が見つかったときのループの動き:`while right < ~`のループに入らずに、seen_charsがs[right]と重複するs[left]を吐き出すまで、leftをインクリメントし続ける。 + - お尻を一歩引きつけるごとに頭を出しては引っ込める尺取り虫 + +変数名の改善: +- left, rightだと、`right - left`の順序で何度も間違える。 +- leftの代替として、substring_start_index, substring_head_index, first_char_indexなど。 + +# setを使う解法 step2 + +やったこと: +- 変数名の変更 `max_substring_leng` -> `max_length` + +```py +class Solution: + def lengthOfLongestSubstring(self, s: str) -> int: + seen_chars = set() + max_length = 0 + left = 0 + right = 0 + while left < len(s): + while right < len(s) and s[right] not in seen_chars: + seen_chars.add(s[right]) + right += 1 + max_length = max(right - left, max_length) + seen_chars.remove(s[left]) + left += 1 + return max_length +``` +コードに対する感覚: +- `max_length`ではなく、問題文の表現を使った `longest_substring_length` のほうがよさそう。 + - 抽象的には、既存の言葉づかいと一貫性した表現にすること。 +- max_lengthは略しすぎになる恐れがありそう。関数名を脳内に保持しておかなければならないので、関数が長くなったらsubstringを省略しないほうがよいと思われる。 + - 関数名を忘れた読み手には、スライスを使っている箇所から max_length は、substring の長さを言っていると予想させることになる。 + - 関数が長くなることに備えるならばsubstringをはじめから省略しないことを選択する +- `max_X = max(current_X, max_X)` では、`max()` の引数は、字面が長いほうを後ろに置くほうが見やすそう。 + +# set step3 +```py +class Solution: + def lengthOfLongestSubstring(self, s: str) -> int: + # 4'15 -> 5'25 -> 4'01 + longest_length = 0 + seen_chars = set() + base_index = 0 + right = 0 + while base_index < len(s): + while right < len(s) and s[right] not in seen_chars: + seen_chars.add(s[right]) + right += 1 + longest_length = max(right - base_index, longest_length) + seen_chars.remove(s[base_index]) + base_index += 1 + return longest_length +``` + +コードに対する感覚: +- base_indexは良い名前ではないと感じる。substring_start_indexがしっくりくる。 + +# 参考 +- https://github.com/thonda28/leetcode/pull/6/files#diff-6b92fcf16b9cd27d1d2d7ff3daeb4a7f48fb0a4fd64c5c68c2a58ae7af4a1837 + - dictの命名は `x_to_y` もよさそう + - inclusive, exclusiveを含意している変数名がある + - 閉区間:start, end, first, last, begin, finish + - 開区間:lower_bound, upper_bound, limit + - forでrightを進めていく実装。重複したら、被った文字の次の文字にleftをセットする。 + - すでに出現した文字に対してそのインデックスを覚えておく。 + - `longest_lengt = max(right, char_to_index[right] + 1)` で max() はleftを逆戻りさせないために必要。"abba"のように重複が挟まれていると、間の重複を飛び越してしまう。 + - こちらも `for right in s:` で文字を走査し、重複のたびにleftを更新する実装 https://github.com/shining-ai/leetcode/pull/48/files#diff-70cb8db03b4879ea63d98354edceeb583ded7f2ca60a22c5d6ad2d53fa853ef5 + +# 調べ物 +辞書関連: +- valueの初期値を設定する方法 + 1. 書く. `if x not in my_dict: my_dict[x] = default_value` で初期化する + 2. `my_dict[key] = setdefault(value, default_value)` を使う。 https://docs.python.org/3/library/stdtypes.html#dict.setdefault + 3. collectonsモジュールの`defaultdict(<型>)`を使う https://docs.python.org/3/library/collections.html#defaultdict-objects + - このときに指定する型を `default_factory` と呼ぶ。setdefaultより早いらしい。 +- 実装 + - dictの実装。8000行あって怯む。https://github.com/python/cpython/blob/main/Objects/dictobject.c + - 概略の解説 https://stackoverflow.com/questions/327311/how-are-pythons-built-in-dictionaries-implemented/44509302#44509302 + - 3.6以前と以後で大きく変わっている。 + - 3.7から仕様として挿入順を保つようになった + - ハッシュの衝突はオープンアドレス法で処理される https://en.wikipedia.org/wiki/Hash_table#Open_addressing + - 要素の数が増えるとスロット数が拡張される + - 衝突を防ぐために要素数より大きい領域が確保されている。