diff --git a/387-First-Unique-Character-in-a-String/benchmark.py b/387-First-Unique-Character-in-a-String/benchmark.py new file mode 100644 index 0000000..a327e25 --- /dev/null +++ b/387-First-Unique-Character-in-a-String/benchmark.py @@ -0,0 +1,93 @@ +import timeit +from collections import Counter +from step1 import Solution as S1 +from step2 import Solution as S2 + +CASES = [ + ("leetcode", 0), + ("loveleetcode", 2), + ("aabb", -1), + ("z" * 10000 + "a", 10000), # 答えが長い文字列の先頭 + ("a" + "z" * 10000, 0), # 答えが長い文字列の末尾 +] + +REPEAT = 1000 + + +def bench(fn, s): + return timeit.timeit(lambda: fn(s), number=REPEAT) + + +def counter_only(s): + return Counter(s) + + +def build_set(s): + counts = Counter(s) + unique_character = set() + for c in counts: + if counts[c] == 1: + unique_character.add(c) + return unique_character + + +def python_scan(s): + unique_character = build_set(s) + for i in range(len(s)): + if s[i] in unique_character: + return i + return -1 + + +def index_scan(s): + counts = Counter(s) + for c in counts: + if counts[c] == 1: + return s.index(c) + return -1 + + +def main(): + col = 35 + print(f"{'case':<{col}} {'step1 (ms)':>12} {'step2 (ms)':>12} {'winner':>8}") + print("-" * (col + 36)) + for s, expected in CASES: + label = repr(s) if len(s) <= 20 else f"'{s[:15]}…{s[-1]}' (len={len(s)})" + t1 = bench(S1().firstUniqChar, s) * 1000 + t2 = bench(S2().firstUniqChar, s) * 1000 + winner = "step1" if t1 < t2 else "step2" + sol1, sol2 = S1().firstUniqChar(s), S2().firstUniqChar(s) + assert sol1 == expected == sol2, ( + f"answer mismatch: s1={sol1} s2={sol2} expected={expected}" + ) + print(f"{label:<{col}} {t1:>12.3f} {t2:>12.3f} {winner:>8}") + + # ---- コスト分解 (長い文字列2ケースのみ) ---- + print() + print(f"-- cost breakdown (REPEAT={REPEAT}) --") + breakdown_cases = [ + ("z" * 10000 + "a", "unique at end"), + ("a" + "z" * 10000, "unique at start"), + ] + for s, label in breakdown_cases: + t_counter = bench(counter_only, s) * 1000 + t_set = bench(build_set, s) * 1000 + t_pscan = bench(python_scan, s) * 1000 + t_iscan = bench(index_scan, s) * 1000 + scan_py = t_pscan - t_set # step1 のベース: Counter + set + scan_idx = t_iscan - t_counter # step2 のベース: Counter のみ + print(f"\n [{label}]") + print(f" Counter only : {t_counter:>8.3f} ms") + print(f" Counter + set (step1) : {t_set:>8.3f} ms") + print( + f" python loop (step1) : {t_pscan:>8.3f} ms (scan only: {scan_py:>+.3f} ms)" + ) + print( + f" s.index scan (step2) : {t_iscan:>8.3f} ms (scan only: {scan_idx:>+.3f} ms)" + ) + ratio = scan_py / scan_idx if scan_idx > 0 else float("inf") + print(f" scan ratio (py/idx) : {ratio:>8.2f}x") + + +if __name__ == "__main__": + main() diff --git a/387-First-Unique-Character-in-a-String/note.md b/387-First-Unique-Character-in-a-String/note.md new file mode 100644 index 0000000..024a376 --- /dev/null +++ b/387-First-Unique-Character-in-a-String/note.md @@ -0,0 +1,87 @@ +# 387. First Unique Character in a String + + + +## step1(まず通す) + +- 複数回出現するかをみるためには先頭から頭まで全部の要素を見る必要がある +- 重複していないもののうち一番前にあるやつを出力するのでもう一度文字列を見る必要がありそう? + +以下のようなことをして一度の操作でできないか考えたけど、全部の要素を見ないと重複しているかが確定しないので難しそう + +- まずリスト線形に走査して出現回数を数えて、1 回のものを探すためにもう一度先頭から見ていく +- リストの末尾から見ていって、暫定的に重複していない最後(後ろから見ていくのでリストの先頭側)の文字の index を覚えておく。それを更新していく。 + +## step2(整形&他の人のコードを読む) + +unique_character を作る必要はなくて、単に Counter の値が1かを見ればよかった。 + +Counter は dict のサブクラスで、dict と同様 3.7 以降は挿入順を維持することが言語仕様で決まったので Counter のキーを順番に見ていって value が 1 ならインデックスを探して即リターンできる。 + +- +- + +str.index()の挙動は以下なので、該当するインデックスを探す処理が step1 のような python でループを書くより速そう + +- unicode_index_impl + - +- ↑の実体である any_find_slice + - + - any_find_slice では index() の引数の長さが 1 のとき、findchar を呼ぶ + - +- findchar は 文字種(何倍と文字か)ごとに処理を分けている + - +- 最終的に memchr/wmemchr という c の標準ライブラリに行き着く + +例えば、`"aaaaaaa...aaaab"` みたいな、文字種数は少ないが答えが出てくるのが最後の方というケースではかなり速くなるはず。 + +### 実験してみた(benchmark.py) + +こう見ると、 + +- unique_character を得るための set 化は文字列が長い時はほぼ誤差なくらい小さい(英小文字で 26 種と、文字の種類が少ないから) +- インデックスを探す部分が step1 の python のループに比べて str.index() がめちゃくちゃ速い(C で動くから) + +注意: + +- 表示されている時間はすべて REPEAT 回分の合計 +- 実際の1回の呼び出しはインタープリタのオーバーヘッドがあるのでもっとかかる & 実験で見えている差は薄まる + +```txt +> python benchmark.py +case step1 (ms) step2 (ms) winner +----------------------------------------------------------------------- +'leetcode' 0.740 0.519 step2 +'loveleetcode' 0.811 0.600 step2 +'aabb' 0.474 0.441 step2 +'zzzzzzzzzzzzzzz…a' (len=10001) 271.670 155.136 step2 +'azzzzzzzzzzzzzz…z' (len=10001) 158.467 156.757 step2 + +-- cost breakdown (REPEAT=1000) -- + + [unique at end] + Counter only : 156.362 ms + Counter + set (step1) : 155.801 ms + python loop (step1) : 269.456 ms (scan only: +113.656 ms) + s.index scan (step2) : 156.773 ms (scan only: +0.411 ms) + scan ratio (py/idx) : 276.79x + + [unique at start] + Counter only : 154.897 ms + Counter + set (step1) : 156.597 ms + python loop (step1) : 157.018 ms (scan only: +0.421 ms) + s.index scan (step2) : 156.023 ms (scan only: +1.125 ms) + scan ratio (py/idx) : 0.37x +``` + +### 他の人のコードを読む + +- + - インデックスも記録しておけばさがしにいかなくていい、というやり方 +- + - 「1度しか出てこない」を「find (左から探索した時に最初に見つかる index) と rfind (右から〃)が一致する」と言い換えたコード + - 計算量としては2乗になるが、find, rfind が c で実装されているので文字列の長さがそれなりに短ければ結構速そう(index が速いのと同じような理由) + +これも書いてみる + +## step3(10分以内にさっとかける * 3回) diff --git a/387-First-Unique-Character-in-a-String/step1.py b/387-First-Unique-Character-in-a-String/step1.py new file mode 100644 index 0000000..7429a9a --- /dev/null +++ b/387-First-Unique-Character-in-a-String/step1.py @@ -0,0 +1,19 @@ +from collections import Counter + + +class Solution: + def firstUniqChar(self, s: str) -> int: + counts = Counter(s) + unique_character = set() + for c in counts: + if counts[c] == 1: + unique_character.add(c) + + if not unique_character: + return -1 + + for i in range(len(s)): + if s[i] in unique_character: + return i + + return -1 diff --git a/387-First-Unique-Character-in-a-String/step2.py b/387-First-Unique-Character-in-a-String/step2.py new file mode 100644 index 0000000..4196fac --- /dev/null +++ b/387-First-Unique-Character-in-a-String/step2.py @@ -0,0 +1,11 @@ +from collections import Counter + + +class Solution: + def firstUniqChar(self, s: str) -> int: + counts = Counter(s) + for c in counts: + if counts[c] == 1: + return s.index(c) + + return -1 diff --git a/387-First-Unique-Character-in-a-String/step2_memorize_indices.py b/387-First-Unique-Character-in-a-String/step2_memorize_indices.py new file mode 100644 index 0000000..2b4feb7 --- /dev/null +++ b/387-First-Unique-Character-in-a-String/step2_memorize_indices.py @@ -0,0 +1,15 @@ +class Solution: + def firstUniqChar(self, s: str) -> int: + first_indices = dict() + for i, letter in enumerate(s): + if letter not in first_indices: + first_indices[letter] = i + else: + # 参考にしたコードでは index が 0 以上かを見ているが、キーの有無で既出かどうかはわかるのですでに -1 (3回目以降)でも単に代入 + first_indices[letter] = -1 + + for letter in first_indices: + if first_indices[letter] != -1: + return first_indices[letter] + + return -1 diff --git a/387-First-Unique-Character-in-a-String/step3.py b/387-First-Unique-Character-in-a-String/step3.py new file mode 100644 index 0000000..4196fac --- /dev/null +++ b/387-First-Unique-Character-in-a-String/step3.py @@ -0,0 +1,11 @@ +from collections import Counter + + +class Solution: + def firstUniqChar(self, s: str) -> int: + counts = Counter(s) + for c in counts: + if counts[c] == 1: + return s.index(c) + + return -1