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
93 changes: 93 additions & 0 deletions 387-First-Unique-Character-in-a-String/benchmark.py
Original file line number Diff line number Diff line change
@@ -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()
87 changes: 87 additions & 0 deletions 387-First-Unique-Character-in-a-String/note.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# 387. First Unique Character in a String

<https://leetcode.com/problems/first-unique-character-in-a-string/>

## step1(まず通す)

- 複数回出現するかをみるためには先頭から頭まで全部の要素を見る必要がある
- 重複していないもののうち一番前にあるやつを出力するのでもう一度文字列を見る必要がありそう?

以下のようなことをして一度の操作でできないか考えたけど、全部の要素を見ないと重複しているかが確定しないので難しそう

- まずリスト線形に走査して出現回数を数えて、1 回のものを探すためにもう一度先頭から見ていく
- リストの末尾から見ていって、暫定的に重複していない最後(後ろから見ていくのでリストの先頭側)の文字の index を覚えておく。それを更新していく。

## step2(整形&他の人のコードを読む)

unique_character を作る必要はなくて、単に Counter の値が1かを見ればよかった。

Counter は dict のサブクラスで、dict と同様 3.7 以降は挿入順を維持することが言語仕様で決まったので Counter のキーを順番に見ていって value が 1 ならインデックスを探して即リターンできる。

- <https://docs.python.org/ja/3.13/library/stdtypes.html#dicthttps://docs.python.org/ja/3.13/library/stdtypes.html#dict>
- <https://docs.python.org/ja/3.13/library/collections.html#collections.Counter>

str.index()の挙動は以下なので、該当するインデックスを探す処理が step1 のような python でループを書くより速そう

- unicode_index_impl
- <https://github.com/python/cpython/blob/main/Objects/unicodeobject.c#L11698>
- ↑の実体である any_find_slice
- <https://github.com/python/cpython/blob/main/Objects/unicodeobject.c#L9522>
- any_find_slice では index() の引数の長さが 1 のとき、findchar を呼ぶ
- <https://github.com/python/cpython/blob/main/Objects/unicodeobject.c#L9544>
- findchar は 文字種(何倍と文字か)ごとに処理を分けている
- <https://github.com/python/cpython/blob/main/Objects/unicodeobject.c#L1004>
- 最終的に 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
```

### 他の人のコードを読む

- <https://github.com/naoto-iwase/leetcode/pull/15/changes>
- インデックスも記録しておけばさがしにいかなくていい、というやり方
- <https://github.com/t0hsumi/leetcode/pull/15#discussion_r1930362913>
- 「1度しか出てこない」を「find (左から探索した時に最初に見つかる index) と rfind (右から〃)が一致する」と言い換えたコード
- 計算量としては2乗になるが、find, rfind が c で実装されているので文字列の長さがそれなりに短ければ結構速そう(index が速いのと同じような理由)

これも書いてみる

## step3(10分以内にさっとかける * 3回)
19 changes: 19 additions & 0 deletions 387-First-Unique-Character-in-a-String/step1.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions 387-First-Unique-Character-in-a-String/step2.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions 387-First-Unique-Character-in-a-String/step2_memorize_indices.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions 387-First-Unique-Character-in-a-String/step3.py
Original file line number Diff line number Diff line change
@@ -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