diff --git a/200. Number of Islands.md b/200. Number of Islands.md new file mode 100644 index 0000000..96c6f0c --- /dev/null +++ b/200. Number of Islands.md @@ -0,0 +1,340 @@ +https://leetcode.com/problems/number-of-islands/description/ +## STEP 1 +全部スキャンしてみていくのとnumber of islands とdfsを組み合わせる発想がなかった +enqueueした瞬間チェック済みにするの忘れがち +bfsで書いたのでqueueをstackに変えればdfsになる。別にこれはステップ数とか関係ないからどっちでも良い。 + +```python +from collections import deque +class Solution: + def numIslands(self, grid: List[List[str]]) -> int: + direction = [(0,1),(1,0),(0, -1), (-1,0)] + + nr = len(grid) + nc = len(grid[0]) + + island = 0 + for i in range(nr): + for j in range(nc): + if grid[i][j] == '1': + q = deque() + q.append((i,j)) + grid[i][j] = '0' + while q: + cr, cc = q.popleft() + + for dr, dc in direction: + r = cr + dr + c = cc + dc + if 0 <= r < nr and 0 <= c < nc and grid[r][c] == '1': + q.append((r,c)) + grid[r][c] = '0' + island += 1 + return island +``` + + +与えられたgridを破壊したくはないけど、どうせvisitedかどうかだけじゃなくて陸地か海か確かめないといけないしからdeep copyして書き込む形にした。deep copyのコストが大きければvisited をsetで作るのもいいかもしれない。 + +添え字i, jはこの文脈だとわかりにくい?でも毎回row, column と書くのも長いしタイプミスが出そうな気がする。 + +## STEP2 + +ということを組み込んでいくとこんな感じ +あとwhile queueを回すのはif文の中だけでいいから中に入れた。 + +```python +from collections import deque + +class Solution: + def numIslands(self, grid: List[List[str]]) -> int: + visited = set() + num_islands = 0 + num_r = len(grid) + num_c = len(grid[0]) + dirs = [(0,1),(1,0),(0,-1),(-1,0)] + queue = deque() + for r in range(num_r): + for c in range(num_c): + if (r,c) not in visited and grid[r][c] == "1": + queue.append((r,c)) + visited.add((r,c)) + num_islands += 1 + while queue: + curr_r, curr_c = queue.popleft() + for dr, dc in dirs: + new_r = curr_r + dr + new_c = curr_c + dc + if 0 <= new_r < num_r and 0 <= new_c < num_c and (new_r,new_c) not in visited and grid[new_r][new_c] == "1": + visited.add((new_r, new_c)) + queue.append((new_r, new_c)) + return num_islands + +``` + +Q. そう言えばsetってなんでハッシュ可能ものしか入れられないんだっけ? +A. set(や dict のキー)は、内部でハッシュテーブルという仕組みを使って、要素の検索を $O(1)$ の超高速で行っています。もし、set に入れたリストの中身が後から list.append() などで書き換えられてしまうと、ハッシュ値(データの保管場所を決める背番号のようなもの)が変わってしまい、二度とそのデータを見つけ出せなくなってしまいます。 + +| **データ構造** | **順序** | **重複** | **可変性 (中身の変更)** | **要素(またはキー)の制約** | **主な使いどころ** | +| ------------------------------------- | ----------------------------- | --------------------------- | ------------------------------------ | ------------------------------------------- | -------------------------------------------------- | +| **`list`**



`[a, b]` | **あり** | **可能** | **可変**



(Mutable) | なし(何でも入る) | 順序が大事なデータ、要素の追加・削除が多いとき | +| **`tuple`**



`(a, b)` | **あり** | **可能** | **不可変**



(Immutable) | なし(何でも入る) | 座標 `(x, y)` の表現、関数の複数の返り値、`set`の要素や`dict`のキーにしたいとき | +| **`set`**



`{a, b}` | **なし** | **不可** | **可変**



(Mutable) | **ハッシュ可能のみ** | 重複を消したいとき、高速な存在チェック(`in`)、集合演算(積集合や和集合) | +| **`dict`**



`{k: v}` | **あり**



(挿入順) | キーは不可



値は可能 | **可変**



(Mutable) | **キーのみハッシュ可能限定**



(値は何でもOK) | 特定の「キー」に関連付けてデータを高速に検索したいとき | + +Setの実装が気になったのでGeminiと雑談していたら長くなったのでAppendixにまとめた。 + +## STEP 3 +```python +from collections import deque + +class Solution: + def numIslands(self, grid: List[List[str]]) -> int: + num_r = len(grid) + num_c = len(grid[0]) + visited = [[False] * num_c for _ in range(num_r)] + queue = deque() + num_islands = 0 + dirs = [(0,1), (1,0), (0,-1), (-1,0)] + for r in range(num_r): + for c in range(num_c): + if not visited[r][c] and grid[r][c] == "1": + queue.append((r,c)) + visited[r][c] = True + num_islands += 1 + while queue: + curr_r, curr_c = queue.popleft() + for r_dir, c_dir in dirs: + new_r = curr_r + r_dir + new_c = curr_c + c_dir + if 0 <= new_r < num_r and 0 <= new_c < num_c and not visited[new_r][new_c] and grid[new_r][new_c] == "1": + visited[new_r][new_c] = True + queue.append((new_r, new_c)) + return num_islands +``` +二次元配列だと無駄にtupleを作って捨てなくていいし検索もそう遅くはないのでこれでも良いかも?仮に陸地がかなり少ない疎なマップであればSetの方がいいだろうけど。dr,dcだとわかりにくいかもと思ったのでr_dir、c_dirにしてみた + + +## Appendix : Setの実装について + +ドキュメント https://docs.python.org/3/library/stdtypes.html#set-types-set-frozenset +cpython https://github.com/python/cpython/blob/main/Objects/setobject.c + +### 1. 内部のデータ構造(C言語レベル) + +CPythonにおいて、`set` の各要素を格納するスロットは `setentry` というC言語の構造体で定義されています。 + +C + +``` +typedef struct { + PyObject *key; // 格納するオブジェクトへのポインタ + Py_hash_t hash; // キャッシュされたハッシュ値(再計算を防ぐ) +} setentry; +``` + +`set` オブジェクトの実体は、この **`setentry` の連続した配列(テーブル)** です。 + +初期状態では8個のスロットが確保されており、要素が増えるにつれてこの配列が自動的にリサイズ(拡張)されていきます。 + +テーブル内の各スロットは、以下の3つの状態のいずれかをとります。 + +1. **Unused(空):** まだ何も入っていない。(`key == NULL`) + +2. **Active(使用中):** 有効な要素が入っている。 + +3. **Dummy(ダミー):** かつて要素が入っていたが、**削除された**状態。(衝突解決のチェーンを壊さないための特殊なマーカー) + + +### 2. 要素の追加(`add`)と検索のステップ + +例えば、`my_set.add("Tokyo")` を実行したとき、内部では以下のステップが高速で実行されます。 + +#### ステップ1:ハッシュ値の計算 + +まず、オブジェクトの `__hash__()` メソッドが呼ばれ、固有の整数値(ハッシュ値)が計算されます。 + +- 例:`hash("Tokyo")` ➔ `4523984120398`(環境によって異なります) + + +#### ステップ2:インデックスの決定(マスク処理) + +ハッシュテーブルの配列のサイズ(現在のマスク値)を使って、ハッシュ値を配列のインデックスに変換します。 + +- 計算式: `index = hash_value & mask` + +- 配列サイズが8の場合、`mask` は `7`(バイナリで `111`)になり、インデックスは必ず `0〜7` の間に収まります。 + + +#### ステップ3:スロットの確認と「衝突(コリジョン)」の解決 + +決定したインデックスのスロットを見に行きます。 + +- **そこが「Unused(空)」だった場合:** + + ラッキーです。そこに `key` と `hash` を書き込んで処理は終了します。 + +- **そこが「Active」で、すでに同じオブジェクト(または等価なオブジェクト)がある場合:** + + `set` は重複を許さないので、何もせず終了します。 + +- **そこが「Active」だが、別の無関係な要素がすでに座っていた場合(衝突):** + + ここからPythonの強力な**衝突解決アルゴリズム**が動き出します。 + + +### 3. 衝突解決:オープンアドレス法と擬似乱数プローブ + +Pythonは、衝突が起きたときに別の配列を作る(チェイン法)のではなく、同じ配列内の別の空きスロットを探すオープンアドレス法(Open Addressing)を採用しています。 + +単純に「次のスロット(+1)」を見に行くと、データの塊(クラスター)ができてしまい、検索効率が落ちます。そこでCPythonは、ハッシュ値の上位ビットを利用した**擬似乱数的な数式**を使って、次に探すインデックスをジャンプさせます。 + +C + +``` +// CPython内部の次のインデックスを求めるロジックのイメージ +j = (j * 5 + 1 + j_perturb) & mask; +j_perturb >>= PERTURB_SHIFT; // ハッシュ値の上位ビットを少しずつずらして混ぜる +``` + +この複雑なジャンプのおかげで、要素が綺麗に分散し、衝突が起きても平均してわずか数回の試行で空きスロット(または目的の要素)に辿り着けます。これが平均 $O(1)$ の理由です。 + +#### この数式の何が凄いの? + +1. **ハッシュ値の「個性」が活きる:** + + 初期位置 $j$ がたまたま同じ `3` で衝突した2つの異なるデータ(データAとデータB)があったとしても、それぞれの元々のハッシュ値($perturb$ の中身)が違うため、**次にジャンプする先のインデックスは全く別の場所になります。** これにより、データの塊(クラスター)ができるのを完璧に防ぎます。 + +2. **徐々にシンプルなジャンプになる:** + + ループが回る(空き地が見つからない)につれて、$perturb$ は右シフトされて最終的に `0` になります。すると数式は $j = (j \times 5 + 1) \& mask$ というシンプルな形に収束します。 + +3. **すべてのスロットを必ず1回ずつ巡回する:** + + この $j = j \times 5 + 1$ という数式は、数学的に「配列のサイズ(2の累乗)の中にあるすべてのインデックスを、重複することなくユニークな順番で巡回し尽くす」という性質を持っています。そのため、配列に空きがある限り、無限ループにならずに必ずいつかは空き地に辿り着くことが保証されています。 + +### 4. 要素の削除(`remove`)と「Dummy」の重要性 + +要素を削除するとき、スロットを完全に「空(Unused)」に戻してはいけません。なぜなら、**「衝突が起きたから、プローブ(ジャンプ)の先へ進んだ要素」が検索できなくなってしまうから**です。 + +そのため、削除されたスロットには **`Dummy` マーク** を刻みます。 + +- **検索のとき:** `Dummy` を見つけたら、「ここは通り道だったんだな」と判断して、さらに先のジャンプ先を探しにいきます。 + +- **追加(add)のとき:** `Dummy` の場所には、新しい要素を上書きして再利用して良いことになっています。 + + +### 5. テーブルのリサイズ(再ハッシュ) + +要素が増えてテーブルがギチギチになると、衝突の確率が跳ね上がり、パフォーマンスが $O(N)$ に近づいてしまいます。 + +これを防ぐため、CPythonは負荷係数(Load Factor)が約2/3(66.6%)を超えた瞬間(Unusedなスロットが全体の3分の1未満になったとき)、テーブルを拡張します。 + +1. **新しいメモリの確保:** 通常、現在のサイズの **2倍〜4倍** の新しい大きな配列を確保します。 + +2. **再ハッシュ(Rehash):** 古いテーブルにあった要素を、**新しい配列のサイズ(新しいマスク値)でインデックスを計算し直して**、すべて配置し直します。このとき、不要になった `Dummy` スロットはすべて消去され、メモリが綺麗にクリーンアップされます。 + + +このリサイクルの瞬間だけは $O(N)$ の時間がかかりますが、頻繁に起きないようにサイズが設計されているため、均らし計算量(Amortized Time Complexity)としては $O(1)$ が維持されます。 + +### 💡 `dict` との実装上の違い + +- **Python 3.6以前:** `set` と `dict` の内部構造はほぼ双子でした(`dict` は `setentry` に `value` のポインタが追加されただけ)。 + +- **Python 3.6以降:** `dict` は「順序を保証する」「メモリを節約する」ために、インデックス配列とエントリー配列を分ける構造(Compact Dict)に生まれ変わりました。 + +- **現在の `set`:** 一方で `set` は、順序を保証する必要がないため、**昔ながらのシンプルな1つのハッシュテーブル配列**の構造を今も貫いています。そのため、要素を順に取り出したときの並び順は、ハッシュ値とテーブルサイズによって完全に「お任せ(順序保証なし)」になります。 + + + +### 言語ごとのSetでの衝突回避の方法 + +他の言語での実装も気になったので聞いてみた + +### 1. Java・C++の主流:チェイン法(別名:オープンハッシュ) + +Pythonの `set` は「衝突したら別の空き部屋を探す」仕組みでしたが、Javaの `HashSet`(内部は `HashMap`)や、C++の `std::unordered_set` は、「衝突したら同じ部屋に繋げていく」という戦略を取ります。 + +#### 仕組み:配列の先が「連結リスト」になっている + +チェイン法では、配列の各スロットに直接データを入れません。代わりに、**連結リスト(Linked List)の先頭へのポインタ**を置いておきます。 + +1. ハッシュ値からインデックス(例: `[3]`)を決める。 + +2. 部屋 `[3]` を見にいくと、そこには連結リストがある。 + +3. **衝突が起きたら:** リストの末尾(または先頭)に、新しいデータを数珠繋ぎ(チェイン)にして追加していく。 + + +#### 💡 JavaやC++には「Dummy」が必要ない理由 + +ここが先ほどの話と綺麗に繋がります。チェイン法では、**データを削除するときに `Dummy` のようなマーカーを残す必要がありません。** + +なぜなら、データを消すときはリストのノードを1つ「アンリンク(前のノードの次の接続先を、後ろのノードに書き換えるだけ)」すればいいからです。検索するときは、その部屋にあるリストを上から下まで順番になぞるだけなので、途中の要素が消えても検索のルートが途切れる(迷子になる)心配がありません。 + +#### 🚀 Java 8 の狂気的な最適化 + +チェイン法の弱点は、最悪の場合(すべてのデータのハッシュ値が衝突した場合)、1つの部屋に長いリストができてしまい、検索速度が $O(1)$ から $O(N)$ に落ちることです。 + +これを防ぐため、Java 8以降では「1つの部屋に要素が8個以上溜まったら、連結リストを自動的に『赤黒木(Red-Black Tree)』というバランスの取れた二分探索木に変換する」という凄まじい最適化が入っています。これにより、最悪のケースでも検索速度を $O(\log N)$ で耐える構造になっています。 + +### 2. Rustの最新鋭:SwissTable(オープンアドレス法の進化系) + +Pythonと同じ「オープンアドレス法」を採用しつつ、さらに現代のCPUアーキテクチャに特化して爆速にしたのが、**Rust**の `HashSet` です。内部では **SwissTable**(`hashbrown` クレート)というアルゴリズムが使われています。 + +#### 仕組み:CPUの力(SIMD)で16個まとめて覗く + +Pythonは衝突したとき、ジャンプ数式を使って「1室ずつ」空き部屋を確認していました。 + +RustのSwissTableは、データの配列とは別に、「各部屋の状態(空、使用中、削除済み)だけを記録した1バイトのメタデータ配列」**を横に用意しています。 そして、衝突が起きたときは、CPUの**SIMD(単一命令複数データ処理)**というハードウェア機能を使い、**「16部屋分のメタデータを1サイクル(一瞬)で同時にロードして一括チェック」します。 + +一括で「あ、3つ隣の部屋が空いてる!」とミリ秒未満で判別するため、Pythonのように泥臭く何度もジャンプを繰り返す必要がなく、オープンアドレス法の弱点を見事に克服しています。 + +|**言語 / 構造**|**採用している戦略**|**衝突したときどうする?**|**削除するときの処理**|**特徴**| +|---|---|---|---|---| +|**Python**



`set`|**オープンアドレス法**|擬似乱数数式で



別の部屋へジャンプ|**`Dummy` マーカーを置く**|シンプルな1つのハッシュテーブル。順序はバラバラ。| +|**Java**



`HashSet`|**チェイン法**|同じ部屋に



連結リストで繋ぐ|**リストからノードを消すだけ**



(Dummy不要)|8個以上衝突するとリストが「木構造」に進化する。| +|**C++**



`unordered_set`|**チェイン法**|同じ部屋に



連結リストで繋ぐ|**リストからノードを消すだけ**



(Dummy不要)|言語の標準規格(仕様)でチェイン法を使うよう定められている。| +|**Rust**



`HashSet`|**先進的オープンアドレス**



(SwissTable)|メタデータを



16個同時に一括スキャン|コントロールバイトを



「削除済」に書き換える|現代のCPUのハードウェア性能を極限まで引き出す爆速仕様。| + + +### 比較するとC++は遅すぎない? + +#### 1. なぜ速度的に非効率なのか?(キャッシュミスの壁) + +チェイン法が遅い最大の原因は、データの並びがメモリ上で「バラバラ(不連続)」になることです。 + +現代のCPUは、メモリからデータを取ってくるときに、その周辺のデータもまとめて「キャッシュメモリ」という超高速な領域にコピーします(キャッシュローカリティ)。配列のようにデータが綺麗に一列に並んでいれば、隣のデータを一瞬で読み込めます。 + +しかし、C++のチェイン法(連結リスト)は、要素が追加されるたびにメモリ上のあちこち(ヒープ領域)にポインタを飛ばして部屋を確保します。そのため、検索するときに**ポインタを辿って別のメモリ番地へジャンプするたびに「キャッシュミス(お目当てのデータがキャッシュにない状態)」が発生し、低速なメインメモリまでデータを取りに行くハメになります。** これが、実効速度を大きく落とす原因です。 + +#### 2. じゃあ、なぜC++はチェイン法にしているのか? + +理由は、C++の標準規格(STL)が定めている「ポインタ(参照)を絶対に無効化してはいけない」という鉄の掟があるからです。 + +C++はポインタを直接ガシガシ触る言語です。もし、以下のようなコードを書いたとします。 + +C++ + +``` +// ある要素へのポインタ(住所)をキープしておく +int* my_data_ptr = &(*(my_set.find(42))); +``` + +- **オープンアドレス法(Pythonなど)の場合:** 要素が増えて「リサイズ(引っ越し)」が発生すると、テーブル全体のサイズが変わるため、**すべての要素のメモリ上の住所(番地)が強制的に変わってしまいます。** つまり、さっきキープした `my_data_ptr` は、引っ越し前の古い住所を指すことになり、プログラムがクラッシュします(Iterator Invalidation / ポインタの無効化)。 + +- **チェイン法(C++)の場合:** テーブルがリサイズされて配列のサイズが変わっても、**引っ越すのは「連結リストの先頭へのポインタ」だけ**です。各要素が座っているノード自体のメモリ番地は、追加したときから一切動きません。そのため、どれだけ要素が増えたり減ったりしても、キープしたポインタは100%安全に使い続けることができます。 + + +C++の設計者たちは、**「多少の速度低下(効率の悪さ)を犠牲にしてでも、ポインタの安全性を100%保証する(勝手に要素の住所を変えない)」** という道を選んだわけです。 + +#### 3. 速度が必要な時はどうするか? + +例えば、Googleが開発して社内で使い倒しているC++のオープンソースライブラリ(Abseil)には、**`absl::flat_hash_set`** というカスタムデータ構造が用意されています。これはポインタの安定性を潔く諦める代わりに、**内部をRustのSwissTableと同じオープンアドレス法で実装した、爆速の `set`** です。 + +これを使うだけで、標準の `std::unordered_set` に比べて、速度が2倍〜3倍以上、メモリ効率も劇的に改善することが知られています。 + +### 感想 +確かにGoogleでC++書いてた時はabsl::flat_hash_setしか使ってなかったしざっくり速いからの理解だったけどそういうことだったのか。**CPUの**SIMD(単一命令複数データ処理)とかSwissTableまで深掘りすると帰ってくるのが大変そうなので一旦気になることリストに積んでおくだけにする。 + +