diff --git a/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/README.md b/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/README.md new file mode 100644 index 00000000..eb7ff4ca --- /dev/null +++ b/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/README.md @@ -0,0 +1,1028 @@ +# Valid Parentheses - 括弧の対応関係をスタックで判定する(Python / TypeScript / Go / Rust) + +## 目次 + +- [概要](#overview) +- [アルゴリズム要点(TL;DR)](#tldr) +- [図解](#figures) +- [正しさのスケッチ](#correctness) +- [計算量](#complexity) +- [Python 実装](#impl-python) +- [TypeScript 実装](#impl-typescript) +- [Go 実装](#impl-go) +- [Rust 実装](#impl-rust) +- [言語間比較](#comparison) +- [言語別最適化ポイント](#optimization) +- [エッジケースと検証観点](#edgecases) +- [FAQ](#faq) + +--- + +

概要

+ +> 💡 **初学者向け補足**:この問題は、一言で言うと「**開き括弧`( [ {`と閉じ括弧`) ] }`の組み合わせと順番が正しいかどうかを判定する問題**」です。 + +`'('`、`')'`、`'{'`、`'}'`、`'['`、`']'`だけからなる文字列 `s` が与えられたとき、以下の3条件をすべて満たすなら`true`、そうでなければ`false`を返します。 + +1. 開き括弧は同じ種類の閉じ括弧で閉じられなければならない +2. 開き括弧は正しい順序で閉じられなければならない +3. すべての閉じ括弧には対応する開き括弧が存在しなければならない + +この問題の難しいポイントは、**「括弧の数が合っている」ことと「括弧の対応関係が正しい」ことは別物である**という点です。たとえば`"([)]"`は開き括弧`(`が1個・`[`が1個、閉じ括弧`)`が1個・`]`が1個で**数としては合致**していますが、`(`が閉じられる前に`[`が`)`によって閉じられようとしており、**順序が破綻**しています。この「数合わせ」の誤りを避けるためには、直前に開いた括弧を記憶しておく仕組みが必要になります。 + +この記事では、Python / TypeScript / Go / Rust の4言語で本問題を解説します。結論を先に述べると、**4言語すべて同じ「スタックを使った1回走査」というアルゴリズムを採用します**。この問題はデータ構造・計算量の面で言語ごとに有利不利が生じるタイプの問題ではないため、統一したアプローチのほうが理解しやすいと判断しました。一方で、「エラーをどう表現するか」「メモリをどう確保するか」という**実装の哲学**は言語ごとに大きく異なるため、そこが本記事の主な見どころになります。 + +> 📖 **この章で登場した用語** +> +> - **制約**:入力として与えられる値の範囲や条件のこと。本問題では「文字列の長さは1以上10^4以下」「使用される文字は括弧記号のみ」など +> - **正当性**:アルゴリズムが常に正しい答えを返すことの保証 + +--- + +

アルゴリズム要点(TL;DR)

+ +> 💡 **初学者向け補足**:TL;DR(Too Long; Didn't Read)とは「長くて読めない人向けの要約」という意味です。ここでは「なんとなくこういう手順で解くんだな」というイメージを掴んでください。詳細は後の章で説明します。 + +- **戦略**:文字列を先頭から1文字ずつ走査する。開き括弧が来たら「あとで閉じられるはずの候補」として記録し、閉じ括弧が来たら「直近に記録した開き括弧」と一致するか確認する。 +- **データ構造**:**スタック**(後入れ先出し=LIFO: Last In, First Out の構造)を使う。なぜスタックが最適かというと、「最後に開かれた括弧が最初に閉じられるべき」という問題の性質そのものが、スタックの「最後に入れたものを最初に取り出す」性質とぴったり一致するため。 +- **計算量**:Time O(n)、Space O(n)(nは文字列長)。文字列を1回だけ走査すれば十分であり、これより速くすることは理論上不可能。 +- **4言語での差異**:**アプローチ自体は4言語とも同じスタック法**。差異が出るのは「スタックの型(Python: `list[str]`、TypeScript: `string[]`、Go: `[]byte`、Rust: `Vec`)」「対応表の引き方(辞書 vs `switch` vs `match`)」「エラーの表現方法(例外 vs 判別可能ユニオン型 vs `error`戻り値 vs `Result`)」の3点です。 + +> 📖 **この章で登場した用語** +> +> - **TL;DR**:「長くて読めない人向けの要約」を意味する略語 +> - **スタック**:後入れ先出し(LIFO)の構造を持つデータ構造。最後に追加した要素を最初に取り出す +> - **LIFO(Last In, First Out)**:「最後に入れたものを最初に出す」というスタックの動作原則 + +--- + +

図解

+ +> 💡 **初学者向け補足**:図はアルゴリズムの「処理の流れ」を視覚的に示したものです。ひし形(`{}`)は条件分岐、長方形(`[]`)は処理ステップを表します。上から下へ読み進めてください。 + +### フローチャート + +この図は、文字列を1文字ずつ読みながらスタックを操作していく処理の流れを表しています。 + +```mermaid +flowchart TD + Start[Start isValid with string s] + Init[Initialize empty stack] + Loop{More characters left} + ReadChar[Read next character c] + IsOpen{c is an opening bracket} + Push[Push c onto stack] + StackEmpty{Stack is empty} + Pop[Pop top of stack into top] + Match{top matches c} + CheckEmpty{Stack is empty} + ReturnTrue[Return true] + ReturnFalse[Return false] + Start --> Init + Init --> Loop + Loop -- Yes --> ReadChar + ReadChar --> IsOpen + IsOpen -- Yes --> Push + Push --> Loop + IsOpen -- No --> StackEmpty + StackEmpty -- Yes --> ReturnFalse + StackEmpty -- No --> Pop + Pop --> Match + Match -- Yes --> Loop + Match -- No --> ReturnFalse + Loop -- No --> CheckEmpty + CheckEmpty -- Yes --> ReturnTrue + CheckEmpty -- No --> ReturnFalse +``` + +主要なノードの意味: + +- `Loop`:まだ読んでいない文字が残っているかを判定するひし形(ループの継続条件) +- `IsOpen`:読んだ文字が開き括弧かどうかを判定するひし形 +- `Push`:開き括弧をスタックに積む処理 +- `StackEmpty`(走査中):閉じ括弧が来たときにスタックが空でないかを確認する安全チェック +- `Match`:スタックから取り出した開き括弧が、今読んだ閉じ括弧と対応しているかを判定するひし形 +- `CheckEmpty`(走査後):全文字を読み終えた時点でスタックに未閉じの開き括弧が残っていないかを確認する最終チェック + +### データフロー図 + +この図は、入力文字列がどのように変換されて最終的な真偽値になるかを表しています。 + +```mermaid +graph LR + subgraph Precheck + A[Input string s] + B[Validate character set] + A --> B + end + subgraph Core + C[Stack of pending open brackets] + D[Scan characters left to right] + E[Push open or match and pop close] + B --> C + C --> D + D --> E + end + F[Final stack emptiness check] + G[Output boolean result] + E --> F + F --> G +``` + +主要な流れの説明: + +- `Input string s`から`Validate character set`へ:入力が想定した括弧文字だけで構成されているかを確認する(制約上保証されるが、防御的にチェックする言語もある) +- `Stack of pending open brackets`から`Scan characters left to right`へ:「まだ閉じられていない開き括弧」だけを保持するスタックを用意し、先頭から順に読み進める +- `Final stack emptiness check`から`Output boolean result`へ:走査が終わった時点でスタックが空なら`true`、何か残っていれば`false`として出力する + +> 💡 **代表例でのトレース**:`s = "([)]"`(要件2に違反する不正な例)を入力として、上記フローチャートをどう通過するかを示します。 +> +> ``` +> Step 1: Start → 入力 s = "([)]" +> Step 2: Init → stack = [] +> Step 3: Loop 判定 → 残り文字あり → Yes +> Step 4: ReadChar → c = '(' → IsOpen → Yes → Push → stack = ['('] +> Step 5: Loop → 残りあり → ReadChar → c = '[' → IsOpen → Yes → Push → stack = ['(', '['] +> Step 6: Loop → 残りあり → ReadChar → c = ')' → IsOpen → No +> → StackEmpty → No → Pop → top='[' , stack=['('] +> Step 7: Match 判定 → top='[' は ')' に対応しない → No → ReturnFalse +> 結果: false +> ``` + +> 📖 **この章で登場した用語** +> +> - **フローチャート**:処理の手順を図形と矢印で表したもの。ひし形=条件分岐、長方形=処理 +> - **データフロー図**:データがどのように変換・移動するかを示す図 +> - **サブグラフ**:フローチャートの中で関連する処理をグループ化したもの + +--- + +

正しさのスケッチ

+ +> 💡 **初学者向け補足**:「正しさのスケッチ」とは、アルゴリズムが常に正しい答えを返すことの根拠を整理したものです。数学的な証明ではなく「なぜ正しいと言えるか」の説明だと考えてください。 + +- **不変条件**(=処理中ずっと成り立ち続けるべき条件):スタックの中身は常に「まだ閉じられていない開き括弧」だけで構成されている。開き括弧が来たら積み、閉じ括弧が正しく一致したら取り除くという操作を守っている限り、この条件は崩れない。 +- **網羅性**(=すべてのケースをもれなく処理できているという保証):走査中の各文字は「開き括弧」「閉じ括弧」のいずれかであり(制約上)、それぞれに対応する分岐が用意されているため、処理漏れは発生しない。 +- **基底条件**:文字列の走査が最後まで終わったとき。このときスタックが空であれば「すべての開き括弧が正しく閉じられた」ことを意味し、`true`を返す根拠になる。逆に何か残っていれば「閉じられていない開き括弧がある」ことになり`false`。 +- **終了性**:文字列は有限長(最大10^4文字、制約より)であり、ループは1文字進むごとに必ず1つ前進するため、無限ループにはならず必ず有限ステップで終わる。 + +「閉じ括弧が来たのにスタックが空」というケースも見逃せません。この場合、対応する開き括弧が存在しないことが確定するため、その時点で即座に`false`を返してよいことが分かります(スタックからの取り出し操作を安全に行うための重要な分岐です)。 + +> 📖 **この章で登場した用語** +> +> - **不変条件**:アルゴリズムが正しく動くために、処理中ずっと成り立ち続けるべき条件 +> - **基底条件**:処理の終了条件。これがないと無限ループになる +> - **終了性**:アルゴリズムが必ず有限ステップで終わるという保証 +> - **網羅性**:すべてのケースをもれなく処理できているという保証 + +--- + +

計算量

+ +> 💡 **初学者向け補足**:計算量とは「入力が大きくなるにつれて、処理にかかる時間・メモリがどう増えるか」の目安です。 + +| 記法 | 意味 | 直感的なイメージ | +| ------- | ---------------------- | -------------------------- | +| `O(1)` | 入力サイズによらず一定 | 辞書で直接ページを開く | +| `O(n)` | 入力に比例して増加 | リストを端から順に読む | +| `O(n²)` | 入力の2乗で増加 | 全ペアを総当たりで確認する | + +### 4言語比較テーブル + +| 言語 | 採用アルゴリズム | Time | Space | 実装上の特徴 | +| ---------- | ---------------- | ---- | ----- | ------------------------------------------------- | +| Python | スタック法 | O(n) | O(n) | `list`をスタックに使用。`dict`でO(1)対応表引き | +| TypeScript | スタック法 | O(n) | O(n) | `string[]`をスタックに使用。型ガードで絞り込み | +| Go | スタック法 | O(n) | O(n) | `[]byte`をプリアロケーション。`switch`で分岐 | +| Rust | スタック法 | O(n) | O(n) | `Vec`を事前確保。`Result`でエラー表現 | + +すべての言語で最悪ケース(全文字が開き括弧、例:`"((((("`)ではスタックに全文字が積まれるため、空間計算量はO(n)になります。これより空間効率の良い方法(括弧の出現数だけを数えるO(1)のアプローチ)も存在しますが、[正しさのスケッチ](#correctness)で述べた「順序の検証」ができないため不採用としています。 + +**in-place / Pure の観点**:4言語とも、入力文字列自体を書き換えることはなく、新しく用意したスタックだけを使って判定します。つまりすべて**Pure**(入力を変更せず新しいデータだけを使って結果を返す)な実装です。文字列自体が非常に小さい(1文字〜10^4文字)ため、in-place化するメリットもなく、可読性を優先しています。 + +> 📖 **この章で登場した用語** +> +> - **時間計算量**:入力の大きさに対して処理にかかる手間がどう増えるかの目安 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安 +> - **in-place**:新しいメモリを確保せず元のデータを直接書き換える操作 +> - **Pure**:入力を変更せず新しいデータを返す操作。安全だがメモリを余分に使う + +--- + +

Python 実装

+ +> 💡 **初学者向け補足**:コードを読む前に、実装の全体的な骨格を示します。 +> +> 1. 括弧の不正な組み合わせを表現する**カスタム例外クラス**(`BracketMismatchError`)を定義する +> 2. 閉じ括弧から対応する開き括弧を引く**対応表**(`dict`)をクラス定数として定義する +> 3. 業務開発版では`_validate_brackets`が例外を投げる形で検証し、`isValid`はそれを`try-except`で捕まえて`bool`に変換する +> 4. 競技プログラミング版では例外を使わず、辞書引きと`list`操作だけで最短コードにする + +```python +from __future__ import annotations + +from typing import Final + + +class BracketMismatchError(Exception): + """ + 括弧の対応関係・順序が不正な場合に送出する例外。 + bool の True/False だけでは「なぜ」不正だったのかを表現できないため、 + Exception を継承したクラスを用意し、将来のログ出力等に転用しやすくする。 + """ + + def __init__(self, message: str, char: str, position: int) -> None: + super().__init__(message) + # 追加情報として問題の文字と位置を保持しておく + self.char = char + self.position = position + + +class Solution: + """括弧の妥当性判定クラス(LeetCode 20: Valid Parentheses)""" + + # クラス定数として対応表を定義する。全インスタンスで共通のデータなので + # __init__ の中ではなくクラスレベルで1度だけ生成する。 + # Final を付けることで「再代入されない」ことを pylance に伝える。 + _PAIRS: Final[dict[str, str]] = { + ')': '(', + ']': '[', + '}': '{', + } + + def isValid(self, s: str) -> bool: + """ + 業務開発向け実装。_validate_brackets の結果を bool に変換する薄いラッパー。 + + Complexity: + Time: O(n) + Space: O(n) + """ + # Python は動的型付け言語のため、pylance の静的チェックをすり抜けて + # 誤った型が実行時に渡される可能性がある。二重の防御として有効。 + if not isinstance(s, str): + raise TypeError("Input must be a string") + + try: + self._validate_brackets(s) + except BracketMismatchError: + return False + return True + + def _validate_brackets(self, s: str) -> None: + """スタックを使って対応関係・順序を検証する内部メソッド。""" + # list をスタック(後入れ先出し構造)として使う。 + # append/pop はどちらも末尾操作で(償却)O(1)。 + stack: list[str] = [] + + # enumerate() でインデックスと文字を同時に取得する(Pythonic な書き方)。 + for position, char in enumerate(s): + if char in '([{': + # 開き括弧ならスタックに積む。 + stack.append(char) + continue + + if char in self._PAIRS: + # 閉じ括弧の場合、まずスタックが空でないか確認する。 + if not stack: + raise BracketMismatchError( + "対応する開き括弧がないまま閉じ括弧が出現しました", char, position + ) + # スタックの末尾(直前に積んだ開き括弧)を取り出す。 + top = stack.pop() + expected_open = self._PAIRS[char] + if top != expected_open: + raise BracketMismatchError( + f"'{char}' に対応する開き括弧は '{expected_open}' ですが " + f"'{top}' が見つかりました", + char, + position, + ) + continue + + # 制約上発生しないが、防御的に例外を送出する。 + raise BracketMismatchError("括弧以外の文字が含まれています", char, position) + + # 走査後にスタックに残りがあれば、閉じられていない開き括弧がある。 + if stack: + unclosed = stack[-1] + raise BracketMismatchError( + f"開き括弧 '{unclosed}' が閉じられないまま入力が終了しました", + unclosed, + len(s), + ) +``` + +競技プログラミング版は、例外を使わず「番兵値」で空スタックの`pop()`を回避する短いコードです。 + +```python +from __future__ import annotations + + +class Solution: + def isValid(self, s: str) -> bool: + """ + 競技プログラミング向け最適化実装。 + 例外処理を使わず、辞書引きと list 操作だけで完結させる。 + + Time Complexity: O(n) + Space Complexity: O(n) + """ + pairs = {')': '(', ']': '[', '}': '{'} + stack: list[str] = [] + + for char in s: + if char in pairs: + # スタックが空なら '#'(番兵、絶対に括弧と一致しないダミー値)を使う。 + # これにより空リストへの pop() で発生する IndexError を未然に回避する。 + if pairs[char] != (stack.pop() if stack else '#'): + return False + else: + stack.append(char) + + # 全て閉じられていればスタックは空になっているはず。 + return not stack +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> ``` +> 入力: s = "([)]" +> position=0, char='(' → '(' in '([{' → append → stack=['('] +> position=1, char='[' → '[' in '([{' → append → stack=['(', '['] +> position=2, char=')' → ')' in _PAIRS → True +> stack.pop() → top='[' , stack=['('] +> _PAIRS[')'] = '(' だが top='[' → 不一致 +> → BracketMismatchError を送出 +> isValid の戻り値: except節で捕捉 → False +> ``` + +> 📖 **この章で登場した用語** +> +> - **`from __future__ import annotations`**:型ヒントを文字列として扱うようにする宣言。循環参照や前方参照を解決できる +> - **`Final`**:`typing`モジュールの型。「この変数は再代入されない」ことをpylanceなどの型チェッカーに伝える +> - **EAFP(Easier to Ask Forgiveness than Permission)**:「まず実行してダメだったら例外で謝る」というPythonの伝統的な設計哲学 +> - **番兵(センチネル)値**:ループや条件分岐の特殊ケースを示すためのダミー値 + +--- + +

TypeScript 実装

+ +> 💡 **初学者向け補足**:Pythonの章で学んだ「スタックで対応関係を検証する」考え方をベースに、今度はTypeScriptの型システムでどう安全に書くかを見ていきます。骨格は以下の通りです。 +> +> 1. `BracketError`クラスを定義する(`Error`を継承) +> 2. 開き括弧 → 閉じ括弧の対応表を`Readonly>`型で定義する +> 3. `isOpeningBracket` / `isClosingBracket`という**型ガード関数**(=実行時に値の型を確認し、その情報をもとにTypeScriptの型を絞り込む仕組み)を用意する +> 4. `validateBrackets`が「成功か失敗か」を表す**判別可能ユニオン型**(`ValidationResult`)を返し、`isValid`はそれを`boolean`に変換する + +```typescript +// ============================================================ +// カスタムエラークラス +// ============================================================ +// なぜこのクラスが必要か: +// boolean の true/false だけでは「どこで」「なぜ」不正だったのかを表現できない。 +class BracketError extends Error { + constructor( + message: string, + public readonly char: string, + public readonly position: number, + ) { + super(message); + this.name = 'BracketError'; + } +} + +// ============================================================ +// 対応表の定義 +// ============================================================ +// Readonly を付けることで「一度定義したら変更されるべきではない」ことを +// コンパイル時に保証する(うっかり書き換えてしまう事故を防ぐ)。 +type OpeningBracket = '(' | '[' | '{'; +type ClosingBracket = ')' | ']' | '}'; + +const CLOSING_BRACKETS: Readonly> = { + '(': ')', + '[': ']', + '{': '}', +}; + +const CLOSING_BRACKET_LIST: readonly ClosingBracket[] = [')', ']', '}']; + +// 型ガード関数:戻り値の型を `char is OpeningBracket` にすることで、 +// この関数が true を返した後、TypeScript が引数の型を自動的に絞り込んでくれる。 +function isOpeningBracket(char: string): char is OpeningBracket { + return Object.prototype.hasOwnProperty.call(CLOSING_BRACKETS, char); +} + +function isClosingBracket(char: string): char is ClosingBracket { + return (CLOSING_BRACKET_LIST as readonly string[]).includes(char); +} + +// ============================================================ +// 検証結果を表す判別可能ユニオン型 +// ============================================================ +// try-catch で例外を投げる代わりに「成功か失敗か」を型で表現することで、 +// 呼び出し元が結果を必ず確認せざるを得ない設計にする。 +type ValidationResult = + | { readonly ok: true } + | { readonly ok: false; readonly error: BracketError }; + +/** + * 括弧文字列が正しく対応しているかを検証する。 + * @param s - 検証したい文字列 + * @returns 検証結果を表すオブジェクト + * @complexity Time: O(n), Space: O(n) + */ +function validateBrackets(s: string): ValidationResult { + // OpeningBracket[] を「後入れ先出し」のスタックとして使う。 + const stack: OpeningBracket[] = []; + + // for...of + entries() で文字とインデックスを安全に取得する + // (サロゲートペア文字も1文字として正しく扱える)。 + for (const [position, char] of [...s].entries()) { + if (isOpeningBracket(char)) { + stack.push(char); + continue; + } + + if (isClosingBracket(char)) { + // stack.pop() は string | undefined を返す。 + // strict モードでは undefined チェックを省略するとコンパイルエラーになる。 + const top = stack.pop(); + + if (top === undefined) { + return { + ok: false, + error: new BracketError( + '対応する開き括弧がないまま閉じ括弧が出現しました', + char, + position, + ), + }; + } + + const expected = CLOSING_BRACKETS[top]; + if (expected !== char) { + return { + ok: false, + error: new BracketError( + `'${top}' に対応する閉じ括弧は '${expected}' ですが '${char}' が見つかりました`, + char, + position, + ), + }; + } + continue; + } + + // 制約上発生しないが、防御的に明示的なエラーを返す。 + return { + ok: false, + error: new BracketError('括弧以外の文字が含まれています', char, position), + }; + } + + if (stack.length > 0) { + const unclosed = stack[stack.length - 1] ?? ''; + return { + ok: false, + error: new BracketError( + `開き括弧 '${unclosed}' が閉じられないまま入力が終了しました`, + unclosed, + s.length, + ), + }; + } + + return { ok: true }; +} + +/** + * 括弧文字列が有効かどうかを判定する(LeetCode提出用エントリポイント)。 + * @param s - 判定対象の文字列 + * @returns 有効なら true、そうでなければ false + * @complexity Time: O(n), Space: O(n) + */ +function isValid(s: string): boolean { + return validateBrackets(s).ok; +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> ``` +> 入力: s = "([)]" +> position=0, char='(' → isOpeningBracket=true → push → stack=['('] +> position=1, char='[' → isOpeningBracket=true → push → stack=['(', '['] +> position=2, char=')' → isClosingBracket=true +> stack.pop() → top='[' , stack=['('] +> CLOSING_BRACKETS['['] = ']' だが char=')' → 不一致 +> → { ok: false, error: BracketError(...) } +> isValid の戻り値: false +> ``` + +> 📖 **この章で登場した用語** +> +> - **`readonly`**:配列やプロパティの値を変更できないようにする修飾子。JavaScriptには無い、TypeScript独自のコンパイル時チェック機能 +> - **型ガード**:`typeof`や独自関数(`is`構文)で実行時に値の型を確認し、その情報をもとに型を絞り込む仕組み +> - **判別可能ユニオン型**:`{ ok: true } | { ok: false, error: ... }`のように、共通のプロパティ(ここでは`ok`)の値によって型を区別できるユニオン型 +> - **Pure function(純粋関数)**:同じ入力を与えると必ず同じ出力を返し、外部の状態を変えない関数 + +--- + +

Go 実装

+ +> 💡 **初学者向け補足**:Goにはクラスが無く、`struct`とメソッドでオブジェクト指向的な設計を実現します。また例外機構(`try-catch`)も無く、エラーは戻り値として返すのがGoの慣習です。骨格は以下の通りです。 +> +> 1. `BracketError`構造体を定義し、`Error() string`メソッドで`error`インターフェースを満たす +> 2. `closingFor`ヘルパー関数で開き括弧から閉じ括弧を`switch`文で引く +> 3. `validateBrackets`が`[]byte`スタックを使い、`error`を返す形で検証する +> 4. `isValid`はその結果を`== nil`で`bool`に変換する薄いラッパーにする + +```go +package main + +import "fmt" + +// BracketError は括弧の対応関係が不正だったことを表すエラー型。 +// bool の true/false だけでは「なぜ」「どこで」不正だったかを表現できないため、 +// error インターフェースを満たす独自の型を用意する。 +type BracketError struct { + Reason string // 不正になった理由の説明文 + Char byte // 問題の原因となった文字 +} + +// Error は error インターフェースを満たすためのメソッド。 +// Go のインターフェースは「このメソッドを持っていれば自動的に実装済み」と +// みなされる暗黙的な実装であり、Java の implements 宣言は不要。 +func (e *BracketError) Error() string { + return fmt.Sprintf("%s: %q", e.Reason, e.Char) +} + +// closingFor は開き括弧に対応する閉じ括弧を返すヘルパー関数。 +// map[byte]byte ではなく switch を使う理由: +// この問題では対応関係が3種類しかなく、switch の方がマップ生成の +// コストがかからず高速かつシンプルになるため。 +func closingFor(open byte) (closing byte, ok bool) { + switch open { + case '(': + return ')', true + case '[': + return ']', true + case '{': + return '}', true + default: + return 0, false + } +} + +// validateBrackets は文字列が正しく対応した括弧かどうかを検証する。 +// +// Time Complexity: O(n) +// Space Complexity: O(n) +func validateBrackets(s string) error { + // []byte をスタックとして使う。make の第3引数で len(s) を指定し、 + // 最悪ケースでも append による再アロケーションが発生しないようにする。 + stack := make([]byte, 0, len(s)) + + // 括弧記号はすべてASCII文字(1バイト)なので、for range によるrune変換を + // 避け、バイト単位のインデックスアクセスで直接走査する方が高速。 + for i := 0; i < len(s); i++ { + c := s[i] + + switch c { + case '(', '[', '{': + stack = append(stack, c) + + case ')', ']', '}': + if len(stack) == 0 { + return &BracketError{Reason: "対応する開き括弧がない", Char: c} + } + // スタックの末尾を取り出す(pop操作)。 + top := stack[len(stack)-1] + stack = stack[:len(stack)-1] + + expected, _ := closingFor(top) + if expected != c { + return &BracketError{ + Reason: fmt.Sprintf("'%c' に対応する閉じ括弧は '%c' ではない", top, expected), + Char: c, + } + } + + default: + // 制約上発生しないが、パニックさせず防御的にエラーを返す。 + return &BracketError{Reason: "括弧以外の文字が含まれている", Char: c} + } + } + + if len(stack) != 0 { + return &BracketError{Reason: "閉じられていない開き括弧が残っている", Char: stack[len(stack)-1]} + } + + return nil +} + +// isValid は LeetCode 提出用のエントリポイント(業務開発版)。 +func isValid(s string) bool { + return validateBrackets(s) == nil +} +``` + +競技プログラミング版は、エラーの詳細を持たず「対応する閉じ括弧そのもの」をスタックに積むテクニックで比較コストを減らします。 + +```go +// isValid は LeetCode 提出用のエントリポイント(競技プログラミング版)。 +// +// Time Complexity: O(n) +// Space Complexity: O(n) +func isValid(s string) bool { + stack := make([]byte, 0, len(s)) + + for i := 0; i < len(s); i++ { + switch s[i] { + case '(': + // 開き括弧が来た時点で「対応する閉じ括弧そのもの」を積んでおく。 + // これにより閉じ括弧が来たときに変換関数を介さず直接比較できる。 + stack = append(stack, ')') + case '[': + stack = append(stack, ']') + case '{': + stack = append(stack, '}') + default: + // ここに来るのは閉じ括弧のはず。 + if len(stack) == 0 || stack[len(stack)-1] != s[i] { + return false + } + stack = stack[:len(stack)-1] + } + } + + return len(stack) == 0 +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> ``` +> 入力: s = "([)]" +> i=0, c='(' → push ')' → stack = [')'] +> i=1, c='[' → push ']' → stack = [')', ']'] +> i=2, c=')' → default節。stack末尾=']' と s[2]=')' を比較 → 不一致 → return false +> ``` + +> 📖 **この章で登場した用語** +> +> - **`error`戻り値**:JavaやPythonの例外機構と異なり、Goはエラーを戻り値として返す。呼び出し元が`if err != nil`で必ず確認する設計になっている +> - **プリアロケーション**:`make([]byte, 0, len(s))`のように必要なメモリをあらかじめまとめて確保しておくこと。`append`による途中の再確保を防ぐ +> - **パニック**:Goで回復不能なエラーが発生した際に起きる強制終了。範囲外アクセスなどで発生する +> - **レシーバ**:メソッドが属する型のインスタンス。Javaの`this`に相当するが名前を自由に決められる + +--- + +

Rust 実装

+ +> 💡 **初学者向け補足**:Rustには所有権(=値を"誰が管理するか"をコンパイル時に決める仕組み)という、他の3言語には無い独自の概念があります。ガベージコレクタ無しでメモリの安全性を保証する代わりに、値の受け渡し方に気をつける必要があります。骨格は以下の通りです。 +> +> 1. `BracketError`という列挙型(`enum`)を定義する(失敗の種類を型で表現するため) +> 2. `validate_brackets`が`&str`(文字列への借用)を受け取り、`Vec`スタックで検証し、`Result<(), BracketError>`を返す +> 3. `Solution::is_valid`はその結果を`.is_ok()`で`bool`に変換する薄いラッパーにする + +```rust +// ============================================================ +// カスタムエラー型 +// ============================================================ +// bool の true/false だけでは「どこで」「なぜ」不正だったのかを表現できない。 +// Rust では例外を使わず、戻り値の型としてエラーを表現するのが特徴。 +#[derive(Debug, Clone, PartialEq)] +enum BracketError { + // 閉じ括弧が来たが、対応する開き括弧の種類が違った場合 + Mismatched { expected: char, found: char }, + // 閉じ括弧が来たのに対応する開き括弧が1つも積まれていない場合 + UnexpectedClose(char), + // 文字列を読み終えたのに閉じられていない開き括弧が残っている場合 + UnclosedOpen(char), + // 括弧以外の不正な文字が出現した場合 + InvalidCharacter(char), +} + +impl std::fmt::Display for BracketError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Mismatched { expected, found } => { + write!(f, "'{found}' に対応する閉じ括弧は '{expected}' ではありません") + } + Self::UnexpectedClose(c) => { + write!(f, "対応する開き括弧がないまま閉じ括弧 '{c}' が出現しました") + } + Self::UnclosedOpen(c) => { + write!(f, "開き括弧 '{c}' が閉じられないまま入力が終了しました") + } + Self::InvalidCharacter(c) => { + write!(f, "不正な文字 '{c}' が入力されました") + } + } + } +} + +impl std::error::Error for BracketError {} + +/// 開き括弧に対応する閉じ括弧を返すヘルパー関数。 +/// Option を返すことで、「括弧ではない文字」が来た場合に +/// 呼び出し元がパニックせず安全に分岐できるようにする。 +fn closing_for(open: char) -> Option { + match open { + '(' => Some(')'), + '[' => Some(']'), + '{' => Some('}'), + _ => None, + } +} + +/// 括弧文字列が正しく対応しているかを検証する。 +/// +/// # Arguments +/// * `s` - 検証したい文字列への借用(&str)。 +/// 所有権を奪わないので、呼び出し元は検証後も s を使い続けられる。 +/// +/// # Complexity +/// - Time: O(n) +/// - Space: O(n) +fn validate_brackets(s: &str) -> Result<(), BracketError> { + // Vec を「後入れ先出し」のスタックとして使う。 + // with_capacity で最悪ケースを見越し、途中の再アロケーションを防ぐ。 + let mut stack: Vec = Vec::with_capacity(s.len()); + + // s.chars() は &str を借用するだけのイテレータで所有権を奪わない。 + for c in s.chars() { + match c { + '(' | '[' | '{' => stack.push(c), + + ')' | ']' | '}' => { + // stack.pop() は Option を返す。 + // ? 演算子で None のとき即座に Err を返す。 + let top = stack.pop().ok_or(BracketError::UnexpectedClose(c))?; + + // stack に積まれるのは開き括弧のみなので None は起こり得ない。 + let expected = closing_for(top) + .expect("スタックには開き括弧のみが積まれるため必ず対応する閉じ括弧が存在する"); + + if expected != c { + return Err(BracketError::Mismatched { expected, found: c }); + } + } + + _ => return Err(BracketError::InvalidCharacter(c)), + } + } + + if let Some(&unclosed) = stack.last() { + return Err(BracketError::UnclosedOpen(unclosed)); + } + + Ok(()) +} + +struct Solution; + +impl Solution { + /// 括弧文字列が有効かどうかを判定する(LeetCode提出用エントリポイント)。 + /// + /// # Complexity + /// - Time: O(n) + /// - Space: O(n) + pub fn is_valid(s: String) -> bool { + // s: String(所有権を持つ)を &s として借用に変換して渡す。 + // 内部処理は読み取りだけで完結するため、無駄なコピーが発生しない。 + validate_brackets(&s).is_ok() + } +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> ``` +> 入力: s = "([)]" +> c='(' → push → stack = ['('] +> c='[' → push → stack = ['(', '['] +> c=')' → pop() → top='[' , stack=['('] +> closing_for('[') = ']' だが c=')' → 不一致 +> → Err(BracketError::Mismatched { expected: ']', found: ')' }) +> is_valid の戻り値: false +> ``` + +> 📖 **この章で登場した用語** +> +> - **所有権**:値を"誰が管理するか"をコンパイル時に決めるRust独自の仕組み。JavaやPythonのようなガベージコレクタなしでメモリの安全性を保証できる +> - **借用**:所有権を渡さずに値を参照する仕組み。`&T`(読み取り専用)と`&mut T`(書き込み可能)がある +> - **`Result`**:成功か失敗かを型で表現する仕組み。呼び出し元は必ず結果を確認しなければならない +> - **`?`演算子**:`Result`や`Option`がエラー・`None`だったとき、自動で呼び出し元に返す糖衣構文 +> - **ゼロコスト抽象化**:便利な高レベルな書き方(イテレータなど)をしても、手書きの低レベルコードと同等の速さになるRustの特性 + +--- + +

言語間比較

+ +> 💡 **初学者向け補足**:同じアルゴリズムでも、言語によって「メモリの扱い方」や「エラーの表現方法」が異なります。この章では4言語の実装を横並びで比較し、それぞれの設計判断の背景を振り返ります。 + +| 観点 | Python | TypeScript | Go | Rust | +| ------------------ | ------------------------------------ | -------------------------------------- | -------------------------------------------------- | ------------------------------------------------------ | +| 採用アルゴリズム | スタック法 | スタック法 | スタック法 | スタック法 | +| スタックの実体 | `list[str]` | `string[]` | `[]byte` | `Vec` | +| メモリ確保の特徴 | 動的拡張(償却O(1)) | 動的拡張(V8内部最適化) | `make`でプリアロケーションし再アロケーションを回避 | `Vec::with_capacity`で事前確保し再アロケーションを回避 | +| エラー表現の方法 | 例外(`Exception`継承クラス) | 判別可能ユニオン型 + `Error`継承クラス | `error`戻り値(インターフェース) | `Result` + `?`演算子 | +| メモリ管理方式 | GC(参照カウント + 世代別GC) | GC(V8のGC) | GC(並行GC) | 所有権システム(GC無し) | +| 型安全性の担保方法 | pylance静的解析 + 実行時`isinstance` | コンパイル時の構造的型付け + 型ガード | 静的型付け + `go vet` | コンパイル時の所有権チェック + トレイト境界 | + +### 相違点の背景 + +Python・TypeScript・GoはいずれもGC(ガベージコレクション、=使い終わったメモリを自動で回収する仕組み)を持つ言語であり、「メモリをいつ・どう解放するか」をプログラマが意識する必要がありません。そのため、これら3言語の実装の違いは主に「速度への配慮の度合い」(Goのプリアロケーションなど)や「型システムの厳密さ」(TypeScriptの型ガード)に現れます。 + +一方Rustは所有権システム(=コンパイル時にメモリの管理者を1つに定める仕組み)を持ち、GCなしでメモリ安全性を実現します。この違いが最も顕著に表れるのが**エラー表現**です。Python/TypeScript/Goが「例外」または「戻り値でのフラグ」でエラーを表現するのに対し、Rustの`Result`は「成功した場合の値」と「失敗した場合の値」を**型レベルで両方保持する**設計になっており、呼び出し元は`match`や`?`演算子を通じて必ずどちらのケースかを意識させられます。これはRustが「実行時に予期せぬ例外で落ちる」ことを可能な限りコンパイル時に排除しようとする設計哲学の表れです。 + +**なぜ4言語で同じアルゴリズムを採用したのか**:本問題はデータ構造・計算量の観点で言語ごとに有利不利が生じるタイプの問題ではなく、「スタックを使った1回走査」が全言語で最適かつ最も自然な解法だからです。無理に言語ごとに異なるアルゴリズムを採用すると、かえって「なぜ違う解法にしたのか」という不要な疑問を読者に与えてしまいます。ただし、スタックの型・対応表の引き方・エラー表現は、各言語のイディオム(=その言語で自然とされる書き方)に忠実に従って変えています。 + +> 📖 **この章で登場した用語** +> +> - **GC(ガベージコレクション)**:使い終わったメモリを自動で回収する仕組み。Python・TypeScript(JavaScript)・Goが持つ +> - **所有権システム**:Rust独自の、コンパイル時にメモリの管理者を1つに定める仕組み。GC不要でメモリ安全性を実現する +> - **イディオム**:その言語で「自然」「慣用的」とされる書き方のパターン + +--- + +

言語別最適化ポイント

+ +> 💡 **初学者向け補足**:この章では「同じ処理でも言語ごとの書き方によって速さが変わる理由」を4言語それぞれについて、最適化前 → 最適化後 → なぜ速くなるかの3点セットで説明します。 + +### Python(CPython)最適化ポイント + +```python +# 最適化前:if/elif の連鎖で括弧の種類を判定する(遅い・冗長) +def is_open(c: str) -> bool: + if c == '(': + return True + elif c == '[': + return True + elif c == '{': + return True + return False + +# 最適化後:dict のキー検索でO(1)判定にする +pairs = {')': '(', ']': '[', '}': '{'} +# in 演算子は dict に対して平均O(1)(ハッシュテーブルによる検索) +if char in pairs: + ... +# 理由:dict のキー検索はC実装のハッシュテーブルによる平均O(1)であり、 +# if/elif の連鎖(最悪O(3)、括弧の種類数に比例)より意図が明確かつ拡張しやすい +``` + +その他、`stack.pop() if stack else '#'`のような**番兵値**を使うことで、`try-except`による例外処理コスト(Pythonの例外は「起きなければ軽い」が構築コストがゼロではない)を避けられます。 + +### TypeScript最適化ポイント + +```typescript +// 最適化前:毎回 Object.keys で対応表のキー一覧を作り直して探索する(遅い) +function isOpen(char: string): boolean { + return Object.keys({ '(': ')', '[': ']', '{': '}' }).includes(char); +} + +// 最適化後:対応表をモジュールスコープの定数にし、in 演算子で判定する +const CLOSING_BRACKETS: Readonly> = { '(': ')', '[': ']', '{': '}' }; +function isOpen(char: string): char is '(' | '[' | '{' { + return char in CLOSING_BRACKETS; +} +// 理由:Object.keys().includes() は呼び出しごとに配列を新規生成しO(n)探索になるが、 +// in 演算子はオブジェクトのプロパティ検索としてV8エンジンに最適化されており高速。 +// また型ガード(char is ...)にすることで、コンパイル時の型絞り込みという +// 追加のメリットも得られる(実行時コストはゼロ、TypeScript特有の恩恵) +``` + +### Go最適化ポイント + +```go +// 最適化前:スタックの容量を指定せず append を繰り返す(再アロケーションの可能性) +stack := []byte{} +for i := 0; i < len(s); i++ { + stack = append(stack, s[i]) +} + +// 最適化後:make の第3引数で最悪ケースの容量を事前確保する +stack := make([]byte, 0, len(s)) +for i := 0; i < len(s); i++ { + stack = append(stack, s[i]) +} +// 理由:容量を指定しない場合、append は容量超過のたびに新しい領域を確保して +// 全要素をコピーする。事前に len(s) 分を確保しておけば、 +// 最悪ケース(全て開き括弧)でもこのコピーが一切発生しない +``` + +### Rust最適化ポイント + +```rust +// 最適化前:Vec::new() で容量指定なしにスタックを作る +let mut stack: Vec = Vec::new(); + +// 最適化後:Vec::with_capacity で最悪ケースの容量を事前確保する +let mut stack: Vec = Vec::with_capacity(s.len()); +// 理由:Vec::new() は容量0からスタートし、push のたびに容量超過を判定する。 +// 容量超過が起きると、より大きな領域を確保して全要素をコピーする +// 「再アロケーション」が発生する。with_capacity で最悪ケースの +// 容量を先に確保しておけば、このコピーコストをゼロにできる +// (ゼロコスト抽象化の実践例の1つ) +``` + +> 📖 **この章で登場した用語** +> +> - **ハッシュテーブル**:キーからハッシュ値という数値を計算し、その番号の場所に値を格納・検索するデータ構造 +> - **エスケープ解析**:変数をスタック(高速・自動解放)に置くかヒープ(低速・GC管理)に置くかをコンパイラが判断する仕組み +> - **モノモーフィゼーション**:ジェネリクス関数が型ごとに専用コードへ自動展開される仕組み。動的ディスパッチより高速 +> - **再アロケーション**:配列やスライスの容量が足りなくなったとき、より大きな領域に全要素をコピーする操作 + +--- + +

エッジケースと検証観点

+ +> 💡 **初学者向け補足**:エッジケースとは「入力が空・最小値・最大値・重複あり」など、通常とは異なる境界的な入力のことです。エッジケースを見落とすと、普通のテストは通るのに特定の入力でだけバグが発生します。 + +- **空文字列の場合**:制約上`1 <= s.length`のため発生しませんが、防御的に書かれたコードであれば、スタックが空のまま走査が終わり`true`を返すだけで安全に処理できます。 +- **単一の閉じ括弧(例:`")"`)の場合**:対応する開き括弧が存在しないため`false`になるべきケースです。この処理は言語によって挙動が異なります。 + - **Python**:`stack.pop()`は空リストに対して`IndexError`(存在しない要素にアクセスしようとしたときの例外)を送出するため、業務版では`if not stack:`で事前チェックし、競技版では`stack.pop() if stack else '#'`という番兵値で回避しています。 + - **TypeScript**:`Array.prototype.pop()`は空配列に対して例外を投げず`undefined`を返すため、`top === undefined`のチェックで安全に処理します。 + - **Go**:スライスに対する範囲外アクセスは**パニック**(回復不能なエラーによる強制終了)を引き起こすため、`len(stack) == 0`のチェックを先に行い、パニックが起きる前に安全に`false`を返します。 + - **Rust**:`Vec::pop()`はそもそも`Option`を返す設計になっており、空の場合は`None`が返るだけでパニックしません。`?`演算子と`ok_or()`の組み合わせで、この`None`を安全に`Err`へ変換しています。 +- **未閉じの開き括弧(例:`"((("`)の場合**:走査後もスタックに要素が残るため、4言語とも「走査後のスタックの空チェック」で`false`と判定します。 +- **括弧の種類の不一致(例:`"([)]"`)の場合**:[図解](#figures)や各実装章のトレースで示した通り、スタックから取り出した開き括弧と現在の閉じ括弧が一致しないため`false`になります。 +- **最大長10^4文字の場合**:4言語ともスタックへのプリアロケーション(Go/Rust)または効率的な動的拡張(Python/TypeScript)により、O(n)の性能が保証されます。 + +> 📖 **この章で登場した用語** +> +> - **エッジケース**:空のリスト・要素1つ・最大サイズ入力など、境界的な条件の入力 +> - **`IndexError`**:Pythonでシーケンスの範囲外インデックスにアクセスしようとしたときに発生する例外 +> - **パニック**:GoやRustで回復不能なエラーが発生した際に起きる強制終了。範囲外アクセスなどで発生する + +--- + +

FAQ

+ +**Q1. なぜ括弧の"数"を数えるだけでは不十分なのですか?** + +結論:数が合っていても順序が壊れている入力を見逃してしまうためです。 + +理由:この問題の要件2は「開き括弧は正しい順序で閉じられなければならない」というものです。数を数えるだけの方法(各括弧の出現数を記録し、最後に開き括弧の数と閉じ括弧の数が一致するか確認する方法)は、この「順序」の情報を一切保持しません。 + +補足:例えば`"([)]"`は`(`が1個・`[`が1個・`)`が1個・`]`が1個で数としては完全に一致していますが、`(`を閉じる前に`[`が`)`によって閉じられようとしており不正です。数を数える方法ではこれを`true`と誤判定してしまいます。 + +--- + +**Q2. なぜスタック(後入れ先出し)を使うのですか?別のデータ構造ではダメですか?** + +結論:「最後に開いた括弧が最初に閉じられるべき」という問題の性質が、スタックの動作原理そのものと一致するためです。 + +理由:括弧が正しくネスト(入れ子)されているとき、一番内側(=最後に開かれた)括弧が一番最初に閉じられます。スタックは「最後に入れたものを最初に取り出す」構造なので、この性質を自然に表現できます。 + +補足:キュー(先入れ先出し)を使うと、最初に開いた括弧が最初に取り出されてしまい、ネストの内側から外側へという正しい順序で照合できません。 + +--- + +**Q3. なぜ4言語とも同じアルゴリズムを採用したのですか?** + +結論:この問題はデータ構造・計算量の面で言語ごとに有利不利が生じるタイプの問題ではないためです。 + +理由:本問題の最適解は「スタックを使った1回走査、O(n)時間・O(n)空間」であり、これはPython・TypeScript・Go・Rustのいずれでも実現可能かつ最も自然な実装です。言語ごとに異なるアルゴリズムを無理に採用すると、かえって「なぜ違う解法にしたのか」という不要な疑問を生んでしまいます。 + +補足:これは全ての問題に当てはまるわけではありません。問題によっては、ある言語では標準ライブラリの特定の関数(例:Goの`container/heap`)を使う方が自然で、別の言語では手書きの実装のほうが素直、というケースも起こり得ます。今回のケースでは、[言語間比較](#comparison)章で述べた通り、スタックの型やエラー表現の哲学に違いが出ています。 + +--- + +**Q4. なぜRustだけ所有権や借用の話が頻繁に出てくるのですか?** + +結論:Rustだけがガベージコレクタ(GC)を持たず、代わりに所有権システムでメモリ安全性を保証しているためです。 + +理由:Python・TypeScript・GoはいずれもGCを持つため、「値をどう受け渡すか」を意識しなくても実行時に自動的にメモリが管理されます。一方Rustでは、値を関数に渡すたびに「所有権が移動するのか、それとも借用(参照)だけなのか」をプログラマが明示的に決める必要があり、これがコンパイル時のメモリ安全性チェックの土台になっています。 + +補足:本問題の`Solution::is_valid`が`s: String`(所有権を持つ文字列)を受け取りながら、内部では`&s`として借用に変換して`validate_brackets`に渡しているのは、この所有権システムを活かした典型的なパターンです。読み取りだけで完結する処理には所有権を渡さず借用で十分、という判断がコードに表れています。 + +--- + +**Q5. なぜGoでは`error`型を使わず`bool`を直接返すバージョンがあるのですか?** + +結論:業務開発向け(詳細なエラー情報が必要な場面)と競技プログラミング向け(速度と簡潔さが最優先の場面)で、目的が異なるためです。 + +理由:`error`戻り値を経由するバージョンは「なぜ不正なのか」を`BracketError`構造体として保持できるため、ログ出力やAPIのエラーレスポンスへの拡張がしやすい設計です。一方、LeetCodeのような制限時間内に正解を出すことが目的の場面では、エラーの詳細情報は不要であり、構造体生成のわずかなオーバーヘッドすら削りたい場面があります。 + +補足:[Go実装章](#impl-go)の競技プログラミング版では、開き括弧を積む際に「対応する閉じ括弧そのもの」をスタックに積むことで、閉じ括弧が来たときの比較を1回の等値比較だけで完結させています。 + +--- + +**Q6. スタックが空のまま閉じ括弧が来た場合、なぜクラッシュせず安全に処理できるのですか?** + +結論:4言語とも、スタックから値を取り出す前に「スタックが空でないか」を確認する分岐を必ず経由するように設計されているためです。 + +理由:もしこのチェックを省略すると、Goでは範囲外アクセスによるパニック、Pythonでは`IndexError`という形で、プログラムが想定外の形で終了してしまいます。[エッジケース](#edgecases)章で述べた通り、各言語はそれぞれ異なる方法(`if not stack`、`undefined`チェック、`len(stack) == 0`、`Option`の`None`分岐)でこの安全性を確保しています。 + +補足:この「取り出す前に空かどうか確認する」というパターンは、スタックを使うあらゆるアルゴリズムに共通する定石です。 + +> 📖 **この章で登場した用語** +> +> - **FAQ**:Frequently Asked Questions の略。よくある質問と回答のこと +> - **トレードオフ**:何かを得ると何かを失う関係。例:速さを得るとメモリが増える diff --git a/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/README_React.html b/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/README_React.html new file mode 100644 index 00000000..b50e8936 --- /dev/null +++ b/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/README_React.html @@ -0,0 +1,1781 @@ + + + + + + Valid Parentheses - LeetCode 20 解説(Python / TypeScript / Go / Rust) + + + + + + + + + + + + + + + +
+ + + + + + + + +
+

+ アルゴリズム概要 +

+ +
+

+ 💡 + この問題を一言で言うと:「開き括弧と閉じ括弧の対応関係と順序が正しいかどうかを判定する問題」です。 +

+

+ '('')''{''}''['']' + だけからなる文字列 + s + が与えられます。すべての開き括弧が正しい種類・正しい順序で閉じられているなら + true、そうでなければ + false + を返します。 +

+
+ +
+

+ ⚠️ なぜ単純な方法では解けないのか +

+
    +
  • + 括弧の「数」を数えるだけでは不十分です。開き括弧の数と閉じ括弧の数が一致していても、閉じる順序が壊れている入力(例:"([)]")を見逃してしまいます。 +
  • +
  • + 閉じ括弧が来たときに「一番最近開かれた、まだ閉じられていない括弧」と比較する必要があり、これには「後入れ先出し」の性質を持つデータ構造(スタック)が必要です。 +
  • +
+
+ +
+
+
+ O(n) +
+
時間計算量
+
+
+
+ O(n) +
+
空間計算量
+
+
+
+ Stack +
+
採用アプローチ
+
+
+
+ 4言語共通 +
+
アルゴリズムの統一度
+
+
+ +
+

📥 入出力例

+
+
+
s = "()[]{}"
+
出力: true
+
+ 3種類の括弧がそれぞれ正しい順序で閉じられているため +
+
+
+
s = "([)]"
+
出力: false
+
+ '(' が閉じられる前に '[' が ')' によって閉じられようとしているため +
+
+
+
+
+ + + + +
+

+ ステップバイステップ解説 +

+

+ ここでは特定の言語に依存せず、「文字列を読みながらスタックを操作する」という処理の流れを、 + 代表例 + s = "([)]" + を使って1ステップずつ確認します。 + この考え方は、後の「コード(4言語)」セクションでPython / TypeScript / Go / + Rustのどの実装にも共通する土台になります。 +

+
+
+ + + + +
+

+ 実装(Python / TypeScript / Go / Rust) +

+

+ 4言語とも「開き括弧が来たら対応する閉じ括弧をスタックに積み、閉じ括弧が来たらスタック最上部と直接比較する」という同じアルゴリズムを採用しています。 + タブを切り替えて、それぞれの言語らしい書き方・エラーの扱い方の違いを見比べてみましょう。 +

+
+
+ + + + + +
+ + + + +
+

+ 計算量分析 +

+ +
+

📖 Big-O 記法の読み方

+
+
+
O(1)
+
入力サイズに関わらず
常に一定の速さ
+
+
+
O(n)
+
入力が2倍になると
処理も約2倍
+
+
+
O(n log n)
+
入力が2倍になると
処理は約2倍強
+
+
+
O(n²)
+
入力が2倍になると
処理は約4倍
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
言語 + 採用アルゴリズム + 時間計算量空間計算量実装上の特徴
Pythonスタック法O(n)O(n) + list + をスタックに、dict + で対応表引き +
+ TypeScript + スタック法O(n)O(n) + string[] + をスタックに、readonly + な対応表 +
Goスタック法O(n)O(n) + []byte + をプリアロケーション、switch + で分岐 +
Rustスタック法O(n)O(n) + Vec<char> + を事前確保、Option<char> + で安全に比較 +
+
+ +
+

+ 🔍 なぜこの計算量になるのか +

+

+ 文字列 + s + の各文字を1回ずつしか読まないため、時間計算量は文字列長 n に比例する O(n) + になります。 + これより速くすることは、各文字を最低1回は確認する必要がある以上、理論上不可能です。 + 空間計算量については、最悪ケース(全て開き括弧、例:"(((((")ではスタックに全文字が積まれるため O(n) になります。 + 括弧の出現数だけを数えるO(1)空間のアプローチも存在しますが、順序の検証ができず不正解になるため採用していません。 +

+
+
+ + + + +
+

+ 言語間比較 +

+

+ 同じアルゴリズムでも、言語ごとに「メモリの扱い方」や「エラーの表現方法」が異なります。 + ここでは4言語の実装を横並びで比較し、それぞれの言語特有の設計判断を振り返ります。 +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
観点PythonTypeScriptGoRust
+ 採用アルゴリズム + スタック法スタック法スタック法スタック法
+ スタックの実体 + list[str]string[][]byte + Vec<char> +
+ メモリ確保の特徴 + 動的拡張(償却O(1)) + 動的拡張(V8内部最適化) + + makeでプリアロケーションし再アロケーション回避 + + with_capacityで事前確保し再アロケーション回避 +
+ エラー表現の方法 + 例外(Exception)undefinedの型レベル表現 + パニックを避ける範囲チェック + + Option<T>による安全な比較 +
+ メモリ管理方式 + + GC(参照カウント+世代別GC) + GC(V8のGC)GC(並行GC) + 所有権システム(GC無し) +
+ 型安全性の担保方法 + + pylance静的解析+実行時isinstance + + コンパイル時の構造的型付け+型ガード + 静的型付け+go vet + コンパイル時の所有権チェック +
+
+ +
+

相違点の背景

+

+ Python・TypeScript・GoはいずれもGC(ガベージコレクション、=使い終わったメモリを自動で回収する仕組み)を持つ言語であり、 + メモリの解放タイミングを意識する必要がありません。一方Rustは所有権システム(=コンパイル時にメモリの管理者を1つに定める仕組み) + でGC無しにメモリ安全性を実現しており、これがエラー表現の違いに最も強く表れています。 + Python/TypeScript/Goが「例外」または「安全な値(undefinedやパニック回避チェック)」でエラーを扱うのに対し、 + RustのOption<T>は「値があるかないか」を型として持つため、 + 呼び出し側は必ずどちらのケースかを意識させられます。 + 本問題はデータ構造・計算量の面で言語ごとの有利不利が生じるタイプの問題ではないため、4言語とも同じスタック法を採用していますが、 + 対応表の型・エラーの扱い方には各言語のイディオム(=その言語で自然とされる書き方)がそれぞれ表れています。 +

+
+
+ + + + +
+

+ 📖 用語集 +

+

+ このページで登場した専門用語をまとめました。分からない言葉が出てきたときに参照してください(五十音順)。 +

+
+
+ + イミュータブル + +
+ 一度作られたら中身を変更できない性質のこと。PythonのstrやTypeScript/JavaScriptの文字列はこの性質を持ちます。 +
+
+ +
+ + ガベージコレクション(GC) + +
+ 使い終わったメモリを自動で回収する仕組み。Python・TypeScript(JavaScript)・Goが持つ機能で、プログラマがメモリ解放のタイミングを意識しなくてよくなります。Rustはこの仕組みを持たず、代わりに所有権システムでメモリを管理します。 +
+
+ +
+ + 型ガード(TypeScript) + +
+ typeofin演算子などで実行時に値の型を確認し、その情報をもとにTypeScriptの型を絞り込む仕組みです。JavaScriptには無い、TypeScript独自のコンパイル時チェック機能です。 +
+
+ +
+ + 借用(Rust) + +
+ 所有権を渡さずに値を参照する仕組み。&T(読み取り専用)と&mut T(書き込み可能)があります。 +
+
+ +
+ + 所有権(Rust) + +
+ 値を「誰が管理するか」をコンパイル時に決めるRust独自の仕組み。JavaやPythonのようなガベージコレクタなしで、メモリの安全性をコンパイル時に保証できます。 +
+
+ +
+ + スタック + +
+ 後入れ先出し(LIFO)の構造を持つデータ構造。お皿の積み重ねのように、最後に積んだものを最初に取り出します。本問題では「まだ閉じられていない開き括弧(に対応する閉じ括弧)」を記録するために使います。 +
+
+ +
+ + スライス(Go) + +
+ Goで配列の一部(または全体)を扱うためのデータ構造。[]byteのように書き、動的にサイズを変えられます。 +
+
+ +
+ + 静的解析 + +
+ プログラムを実行せずに、コードを読むだけでバグや型エラーを検出する手法。Pythonのpylance、TypeScriptのコンパイラ、Goのgo + vet、Rustのclippyがそれぞれ担っています。 +
+
+ +
+ + 償却計算量(Amortized + Complexity) + +
+ 個々の操作は時々遅くなることがあっても、多数回の操作全体で平均すると一定時間で済むという計算量の考え方。Pythonのlist.appendの再確保コストはこの考え方で「ならすとO(1)」とみなされます。 +
+
+ +
+ + パニック(Go / Rust) + +
+ GoやRustで回復不能なエラーが発生した際に起きる強制終了。スライスや配列への範囲外アクセスなどで発生します。本問題のコードでは、範囲外アクセスが起きる前に必ず空チェックを行うことで回避しています。 +
+
+ +
+ + プリアロケーション(Go / Rust) + +
+ 必要なメモリをあらかじめまとめて確保しておくこと。Goのmake([]byte, 0, len(s))やRustのVec::with_capacity(s.len())のように書くことで、途中の再確保を防ぎ高速化できます。 +
+
+ +
+ + Big-O記法 + +
+ 入力の大きさに対して、処理にかかる時間やメモリがどう増えるかを表す記法。O(n)なら「入力が2倍になると処理も約2倍になる」ことを意味します。 +
+
+ +
+ + LIFO(Last In, First Out) + +
+ 「最後に入れたものを最初に出す」という、スタックの動作原則。本問題の「一番内側の括弧から閉じる」という性質とぴったり一致します。 +
+
+ +
+ + readonly(TypeScript) + +
+ 配列やオブジェクトの値を変更できないようにする修飾子。JavaScriptには無い、TypeScript独自のコンパイル時チェック機能で、意図しない書き換えを防ぎます。 +
+
+
+
+ +
+ LeetCode 20: Valid Parentheses ― Python / TypeScript / Go / Rust 4言語解説 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/Valid_Parentheses_Go.md b/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/Valid_Parentheses_Go.md new file mode 100644 index 00000000..f5b73b26 --- /dev/null +++ b/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/Valid_Parentheses_Go.md @@ -0,0 +1,334 @@ +# 1. 問題分析結果 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「**開き括弧と閉じ括弧の"組み合わせ"と"順番"が正しいかを、後入れ先出し(=最後に積んだものを最初に取り出す)のルールで判定する問題**」です。 +> +> Goでこの問題を解く際に特に気をつけるべき点は、**「スライスをスタック代わりに使うときの再アロケーション対策」**と、**「エラーの意味を`bool`一つに潰さずに、内部設計としてどう表現するか」**の2点です。LeetCodeの関数シグネチャは`func isValid(s string) bool`で戻り値が`bool`固定ですが、業務開発では「なぜ`false`になったのか」を後から追跡できる設計にしておくと保守性が上がります。ゴルーチンやチャネルは、この問題が「1本の文字列を先頭から順番に読む」逐次処理であるため出番がありません(並行化しても速くならない典型例です)。 + +#### 競技プログラミング視点 + +- **制約分析**:文字列長は最大10^4文字(制約より)。この規模なら O(n) の線形走査であれば余裕を持って高速に処理できます。 +- **最速手法**:`[]byte`(バイトのスライス)をスタックとして使い、`make([]byte, 0, len(s))`で**プリアロケーション**(=必要なメモリをあらかじめまとめて確保しておくこと。`append`による途中の再確保を防ぎ高速化できる)しておくことで、ヒープへの再アロケーションを実質ゼロ回に抑えます。 +- **メモリ最小化**:`map[byte]byte`で対応表を持つより、`switch`文で分岐する方が、マップの生成・ハッシュ計算コストがかからず高速です。さらに競技版では「開き括弧が来たら"対応する閉じ括弧そのもの"をスタックに積む」というテクニックを使うと、閉じ括弧が来たときの照合が単純な等値比較1回で済みます。 +- **Go最適化**:`for i := 0; i < len(s); i++`でバイト単位のインデックスアクセスを使います。括弧記号はすべてASCII文字(1バイト)なので、`for range`によるrune(Unicode文字単位のイテレーション)変換のオーバーヘッドを避けられます。 + +#### 業務開発視点 + +- **型安全設計**:`byte`同士の比較だけで完結するシンプルな問題ですが、「なぜ不正なのか」を表現する独自の`error`型(`BracketError`)を定義しておくと、将来UIにエラーメッセージを出す拡張がしやすくなります。 +- **エラーハンドリング**:Goでは JavaやPythonの例外(`try-catch`/`try-except`)と違い、**エラーを戻り値として返します**。呼び出し元は`if err != nil`で必ずエラーを確認する設計になるため、エラーの握りつぶし(無視)がコードレビューで見つけやすいという利点があります。 +- **可読性**:`Solution`構造体にメソッドとして実装するか、単純な関数として実装するかは問題のテンプレート次第ですが、この問題は単純な関数形式(パターン1)が最も自然です。 + +#### Go特有分析 + +- **データ構造選択**:「順序あり・末尾への追加/削除が頻繁」という要件に最も合うのはスライス`[]byte`です。両端キューや`container/list`(双方向連結リスト)は今回オーバースペックです。 +- **標準ライブラリ活用度**:この問題は`sort`や`container/heap`のような標準ライブラリの出番はなく、素朴なスライス操作で十分です。無理に標準ライブラリを使うと逆に複雑になります。 +- **並行処理適性**:不適。文字列を先頭から順番に読み、直前の状態(スタックの中身)に依存する逐次処理のため、ゴルーチンに分割すると「途中までの対応関係」を並行して整合させる必要が生まれ、かえって複雑かつ低速になります。 +- **エスケープ解析**(=変数をスタックに置けるかヒープに置くべきかをコンパイラが自動判断する仕組み。ヒープに「逃げる」とGCの負担が増える):`stack := make([]byte, 0, len(s))`で作るスライスは、関数内で使い切って戻り値には含まれないため、コンパイラの最適化次第ではスタック上に確保される可能性があります(ただし`make`で長さが実行時に決まる場合は基本的にヒープ確保になることが多いです)。 + +> 📖 **このセクションで登場した用語** +> +> - **プリアロケーション**:必要なメモリをあらかじめまとめて確保しておくこと。`append`による途中の再確保を防ぎ高速化できる。 +> - **エスケープ解析**:変数をスタック(高速・自動解放)に置くかヒープ(低速・GC管理)に置くかをコンパイラが判断する仕組み。 +> - **`error`戻り値**:Goのエラー表現方法。例外を使わず、関数の戻り値としてエラーを明示的に返す。呼び出し元が確認を強制される。 +> - **ゴルーチン**:Goが提供する軽量スレッド。OSスレッドより格段に安く起動でき、何千個でも同時に扱える。ただし今回の問題では出番がない。 + +--- + +# 2. アルゴリズム比較表 + +> 💡 **初学者向け補足**:なぜ複数のアプローチを比較するのかというと、「動くコード」と「その状況で最善のコード」は別物だからです。特にこの問題では、**一見速そうに見えて実は不正解になるアプローチ(方法C)**があるため、比較を通じて「なぜそれがダメなのか」を理解することが重要です。 +> +> **「Go実装コスト」列**は、「スライスの再アロケーションが起きるか」「不要なヒープ逃げが発生するか」という観点で評価しています。 + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> | 記法 | 意味 | 直感的イメージ | +> | ------- | ---------------------- | ---------------------- | +> | `O(1)` | 入力サイズによらず一定 | 辞書の直引き | +> | `O(n)` | 入力に比例して増加 | リストを端から順に読む | +> | `O(n²)` | 入力の2乗で増加 | 二重ループの総当たり | + +| アプローチ | 時間計算量 | 空間計算量 | Go実装コスト | 可読性 | 標準ライブラリ活用 | 並行処理適性 | 備考 | +| ---------- | ---------- | ---------- | ------------ | ------ | ------------------ | ------------ | -------------------------------------------------- | +| 方法A | O(n) | O(n) | 低 | ★★★ | なし | 不適 | `[]byte`スタック(採用) | +| 方法B | O(1) | O(1) | 低 | ★★☆ | なし | 不適 | 括弧の**出現数**だけをカウント(誤ったアプローチ) | +| 方法C | O(n²) | O(n) | 中 | ★★☆ | `strings.Replace` | 不適 | `"()" "[]" "{}"`を空文字に繰り返し置換 | + +**Go固有の観点(スライスの再アロケーション・ヒープ逃げの有無)**: + +- **方法A**:`make([]byte, 0, len(s))`で最悪ケース(全て開き括弧)を見越した容量を事前確保しているため、走査中の`append`による再アロケーションは発生しません。文字列`s`自体はGoの文字列がイミュータブル(変更不可)なので、`s[i]`によるインデックスアクセスもコピーを伴わず安全です。 +- **方法B**:括弧の「数」だけを固定長の配列(例:`[3]int`)でカウントするので空間計算量はO(1)で最小です。しかし`"([)]"`のように**数は合っているが順序が壊れている**入力を`true`と誤判定してしまうため、要件2「正しい順序で閉じる」を満たせず**不正解**です(比較のためにあえて載せています)。 +- **方法C**:`strings.Replace`は呼び出すたびに**新しい`string`をヒープ上に確保**します(Goの文字列はイミュータブルなため、変更するたびにコピーが発生)。これを空になるまで繰り返すため、最悪ケースで O(n²) の時間計算量に加え、大量のヒープアロケーションが発生し、GC(ガベージコレクション、=使い終わったヒープメモリを自動で回収する仕組み)の負荷も増えます。 + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して処理にかかる手間がどう増えるかの目安。 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安。 +> - **再アロケーション**:スライスの容量が足りなくなったとき、より大きいメモリ領域に全要素をコピーする操作。 +> - **GC(ガベージコレクション)**:使い終わったヒープメモリを自動で回収する仕組み。頻繁に起動するとプログラムが一時停止する。 + +--- + +# 3. 採用アルゴリズムと根拠 + +> 💡 **初学者向け補足**:「なぜ方法Aを選び、方法B・Cを選ばなかったのか」を対比形式で説明します。 + +- **選択理由**: + - **方法Bを選ばなかった理由**:空間計算量O(1)は魅力的に見えますが、括弧が閉じられる**順序**を一切見ていないため`"([)]"`のような不正な入力を`true`と誤判定してしまいます。速さより先に「問題の要件を満たすこと」が必須です。 + - **方法Cを選ばなかった理由**:`strings.Replace`を繰り返すたびに新しい文字列がヒープに確保されるため、O(n²)の時間計算量に加えてGCの負荷が無視できません。10^4文字の入力でも実用上動きはしますが、方法Aの方が明確に優れています。 + - **方法Aを選んだ理由**:文字列を1回だけ走査するO(n)で完結し、スタック操作(`append`/スライス縮小)はGoの標準的なイディオムで安全に書けます。 +- **Go最適化戦略**: + - **プリアロケーション**:`make([]byte, 0, len(s))`で最悪ケースの容量を先に確保し、`append`による再アロケーションをゼロにする。 + - **`[]byte`スタックへの"対応する閉じ括弧"の直接格納**(競技版で採用):開き括弧が来た時点で、対応する閉じ括弧そのものをスタックに積んでおけば、閉じ括弧が来たときに`switch`や関数呼び出しを介さず、スライスの末尾の値と直接比較するだけで済みます。これにより分岐コストを1段階減らせます。 + - **バイト単位アクセス**:`for range`によるrune変換を避け、`for i := 0; i < len(s); i++`でバイト単位アクセスすることで、括弧記号(すべてASCII=1バイト文字)の走査コストを最小化します。 +- **トレードオフ**:業務版は`error`型を介するため型変換や構造体生成のわずかなオーバーヘッドがありますが、可読性・拡張性が高くなります。競技版はエラー情報を持たず`bool`一発勝負にすることで、コードがシンプルかつ最速になります。 + +> 📖 **このセクションで登場した用語** +> +> - **イディオム**:その言語で「自然」「慣用的」とされる書き方のパターン。 +> - **トレードオフ**:何かを得ると何かを失う関係。例:可読性を得ると若干の実行コストが増える。 +> - **キャッシュ効率**:CPUがデータをキャッシュから素早く読める度合い。連続したメモリ(スライス)はキャッシュに優しい。 + +--- + +# 4. 実装パターン + +> 💡 **初学者向け補足**:コード全体を示す前に、「このコードの大まかな構造(骨格)」を箇条書きで示します。 +> +> 1. 括弧の不正な組み合わせを表現する**カスタムエラー型**(`BracketError`)を定義する(Goの`error`インターフェースは「`Error() string`メソッドを持つ型」であれば何でもよく、`implements`宣言は不要) +> 2. 内部関数`validateBrackets`で`[]byte`スタックを使い、`error`を返す形で検証ロジックを実装する +> 3. LeetCode提出用の`isValid`は、その結果を`== nil`で`bool`に変換して返す + +### 使い分けの解説 + +``` +【業務開発版を使う場面】 +チームで長期間メンテナンスするプロダクションコードに向きます。 +「なぜ不正なのか」をerror型として保持できるため、 +ログ出力やAPIのエラーレスポンスに転用しやすい設計です。 + +【競技プログラミング版を使う場面】 +LeetCodeの制限時間内に正解を出すことが目的のコードに向きます。 +エラーの詳細情報を持たず、開き括弧が来た時点で +対応する閉じ括弧そのものをスタックに積むことで、 +比較コストを1段階減らし実行速度を優先します。 +``` + +### 業務開発版 + +```go +package main + +import "fmt" + +// BracketError は括弧の対応関係が不正だったことを表すエラー型。 +// なぜ独自のエラー型を定義するか: +// bool の true/false だけでは「なぜ」「どこで」不正だったかを表現できない。 +// Go では例外(try-catch)を使わず、エラーを戻り値として返す設計が基本のため、 +// 型としてエラーの意味を持たせておくと呼び出し元での分岐がしやすくなる。 +type BracketError struct { + Reason string // 不正になった理由の説明文 + Char byte // 問題の原因となった文字 +} + +// Error は error インターフェースを満たすためのメソッド。 +// Go のインターフェースは「このメソッドを持っていれば自動的に実装済み」と +// みなされる暗黙的な実装であり、Java の implements 宣言は不要。 +// この Error() メソッドを持つことで BracketError は error 型として扱える。 +func (e *BracketError) Error() string { + // %q は文字を「'x'」の形でクォート付き表示するフォーマット指定子。 + return fmt.Sprintf("%s: %q", e.Reason, e.Char) +} + +// closingFor は開き括弧に対応する閉じ括弧を返すヘルパー関数。 +// map[byte]byte ではなく switch を使う理由: +// この問題では対応関係が3種類しかなく、switch の方がマップ生成・ +// ハッシュ計算のコストがかからず高速かつシンプルになるため。 +func closingFor(open byte) (closing byte, ok bool) { + switch open { + case '(': + return ')', true + case '[': + return ']', true + case '{': + return '}', true + default: + // ok=false を返すことで「開き括弧ではなかった」ことを呼び出し元に伝える。 + // null を使う言語と違い、Go では複数戻り値の第2引数として + // 「成功したかどうか」を明示的に返すのが慣習(comma-ok イディオム)。 + return 0, false + } +} + +// validateBrackets は文字列が正しく対応した括弧かどうかを検証する純粋関数。 +// error を戻り値にすることで「なぜ失敗したか」を呼び出し元に伝えられる。 +// +// Args: +// - s: 検証対象の文字列(括弧文字のみを想定) +// +// Returns: +// - 正しく対応していれば nil、そうでなければ *BracketError +// +// Time Complexity: O(n) +// Space Complexity: O(n) +func validateBrackets(s string) error { + // []byte をスタック(後入れ先出し構造)として使う。 + // make の第3引数で len(s) を指定し、最悪ケース(全て開き括弧)でも + // append による再アロケーション(メモリの取り直し)が発生しないようにする。 + stack := make([]byte, 0, len(s)) + + // 括弧記号はすべてASCII文字(1バイト)なので、 + // for range によるrune(Unicode文字単位)変換を避け、 + // バイト単位のインデックスアクセスで直接走査する方が高速。 + for i := 0; i < len(s); i++ { + c := s[i] + + switch c { + case '(', '[', '{': + // 開き括弧なら、対応する閉じ括弧が来るまでスタックに積んでおく。 + stack = append(stack, c) + + case ')', ']', '}': + // スタックが空なのに閉じ括弧が来た場合、 + // 対応する開き括弧が存在しないということなので不正。 + if len(stack) == 0 { + return &BracketError{Reason: "対応する開き括弧がない", Char: c} + } + + // スタックの末尾(=直前に積んだ開き括弧)を取り出す(pop操作)。 + // スライスの末尾を読んでから [:len-1] で縮めるのがGoでの慣用的なpop。 + top := stack[len(stack)-1] + stack = stack[:len(stack)-1] + + // top(開き括弧)に対応する期待される閉じ括弧を求める。 + // stack に積まれるのは開き括弧のみなので ok は必ず true になる。 + expected, _ := closingFor(top) + if expected != c { + return &BracketError{ + Reason: fmt.Sprintf("'%c' に対応する閉じ括弧は '%c' ではない", top, expected), + Char: c, + } + } + // 一致していれば、このペアは正しく閉じられたので次のループへ進む。 + + default: + // 制約により括弧文字以外は入力されない前提だが、 + // 防御的プログラミングとしてエラーを返す(パニックさせない)。 + return &BracketError{Reason: "括弧以外の文字が含まれている", Char: c} + } + } + + // 走査し終えた時点でスタックに何か残っていれば、 + // 閉じられていない開き括弧があるということなので不正。 + if len(stack) != 0 { + return &BracketError{Reason: "閉じられていない開き括弧が残っている", Char: stack[len(stack)-1]} + } + + return nil +} + +// isValid は LeetCode 提出用のエントリポイント(業務開発版)。 +// 内部的には error を使って詳細な検証を行い、その結果を bool に変換して返す。 +// この「薄いラッパー」構造により、内部ロジックとLeetCodeのインターフェース要求を分離できる。 +func isValid(s string) bool { + return validateBrackets(s) == nil +} +``` + +### 競技プログラミング版 + +```go +// isValid は LeetCode 提出用のエントリポイント(競技プログラミング版)。 +// エラーの詳細情報を持たず bool のみを返すことで、分岐コストと +// 構造体生成のオーバーヘッドを排除し、実行速度を最優先している。 +// +// Time Complexity: O(n) +// Space Complexity: O(n) +func isValid(s string) bool { + // []byte をスタックとして使う。プリアロケーションで再アロケーションを防ぐ。 + stack := make([]byte, 0, len(s)) + + for i := 0; i < len(s); i++ { + switch s[i] { + case '(': + // 開き括弧が来た時点で「対応する閉じ括弧そのもの」を積んでおく。 + // これにより閉じ括弧が来たときに、closingFor のような + // 変換関数を介さず、直接バイト同士を比較できるようになる。 + stack = append(stack, ')') + case '[': + stack = append(stack, ']') + case '{': + stack = append(stack, '}') + default: + // ここに来るのは閉じ括弧のはず(制約上、括弧以外の文字はない)。 + // スタックが空、または末尾の期待値と一致しなければ即座に false。 + if len(stack) == 0 || stack[len(stack)-1] != s[i] { + return false + } + // 一致していればpop(スライスを1つ縮める)して次に進む。 + stack = stack[:len(stack)-1] + } + } + + // 全て閉じられていれば、スタックは空になっているはず。 + return len(stack) == 0 +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> 業務開発版で入力例 `s = "([)]"`(要件2に違反する不正な例)をトレースします。 +> +> ``` +> 初期状態: stack = [] +> i=0, c='(' → 開き括弧 → push → stack = ['('] +> i=1, c='[' → 開き括弧 → push → stack = ['(', '['] +> i=2, c=')' → 閉じ括弧。pop() → top='[' 、stack = ['('] +> closingFor('[') = ']' +> expected=']' だが c=')' → 不一致! +> → &BracketError{Reason: "'[' に対応する閉じ括弧は ']' ではない", Char: ')'} を返す +> isValid の戻り値: validateBrackets(...) != nil → false ✅(期待通り) +> ``` +> +> 競技版で同じ入力をトレースすると、より単純です。 +> +> ``` +> 初期状態: stack = [] +> i=0, s[0]='(' → stack = [')'] ← 対応する閉じ括弧そのものを積む +> i=1, s[1]='[' → stack = [')', ']'] +> i=2, s[2]=')' → default節。stack末尾=']' と s[2]=')' を比較 → 不一致 → return false ✅ +> ``` + +> 📖 **このセクションで登場した用語** +> +> - **comma-okイディオム**:`value, ok := ...`の形で「成功したかどうか」を第2戻り値として受け取るGoの慣習。マップの検索(`v, ok := m[key]`)などでも使われる。 +> - **`switch`文**:Goの分岐構文。`case`の後に`break`を書かなくても自動的に次のcaseへ流れ落ちない(他言語のfallthroughとは逆の挙動)。 +> - **`fmt.Sprintf`**:フォーマット文字列から新しい`string`を生成する関数。`%c`は文字、`%q`はクォート付き文字列を意味するフォーマット指定子。 +> - **薄いラッパー**:内部の複雑なロジックを、外部から使いやすいシンプルなインターフェースで包む設計パターン。 + +--- + +# 5. 検証 + +_テストコードは別途提供いただく前提のため、ここではエッジケースの説明のみ行います。_ + +> 💡 **初学者向け補足**:エッジケースとは「入力が空・最小値・最大値・重複あり」など境界的な入力のことです。 + +- **境界値テスト**: + - **空文字列の場合**:制約上`1 <= s.length`なので発生しませんが、防御的に`len(stack) == 0`のチェックがあるため`isValid("")`を呼んでも`true`が返り、パニックは起きません。 + - **単一の閉じ括弧(例:`")"`)の場合**:`default`節でスタックが空の状態で比較しようとするため、`len(stack) == 0`の短絡評価(=`||`の左側が`true`なら右側を評価せず即座に判定する仕組み)によって、`stack[len(stack)-1]`への範囲外アクセス(インデックスエラーによるパニック)が起きる前に安全に`false`を返します。ここが「安全性」の要です。 + - **未閉じの開き括弧(例:`"((("`)の場合**:ループ終了後も`stack`に要素が残るため、`len(stack) == 0`のチェックで`false`と判定されます。 + - **最大長10^4文字の場合**:`make([]byte, 0, len(s))`でプリアロケーション済みのため、途中の`append`で再アロケーションが発生せず、O(n)の性能が保証されます。 +- **型チェック**:`go vet ./...`では、未使用変数・到達不能コードなどが検出されますが、上記コードは`switch`の`default`節まで含めて全パターンを網羅しているため問題ありません。 +- **並行安全性**:この関数はゴルーチンを一切使わない逐次処理のため、データ競合(Race Condition)の心配はありません。 + +## 提出前チェックリスト + +- [x] `go vet ./...` でエラーなし(未使用変数・不正なフォーマット指定子なし) +- [x] エラーハンドリング:業務版は`error`戻り値を必ず`nil`チェックしている +- [x] メモリ効率:`make([]byte, 0, len(s))`でプリアロケーション済み +- [x] エッジケース:空文字列・単一閉じ括弧・未閉じ括弧を確認済み +- [x] 命名規則:レシーバなし関数のためGoの関数命名規則(`isValid`は小文字始まり、LeetCodeの慣習に合わせている)に準拠 + +> 📖 **このセクションで登場した用語** +> +> - **短絡評価**:`||`や`&&`で、左側の条件だけで結果が確定する場合に右側を評価しない仕組み。`len(stack) == 0 || stack[...]`のように、範囲外アクセスを未然に防ぐ安全策として使われる。 +> - **範囲外アクセス(out of range)**:スライスや配列の存在しないインデックスにアクセスしようとして起きるパニック。Goでは境界チェックが自動で入るため、C言語のような未定義動作にはならず、必ずパニックという形で安全に検出される。 diff --git a/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/Valid_Parentheses_Java.md b/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/Valid_Parentheses_Java.md new file mode 100644 index 00000000..69c731fc --- /dev/null +++ b/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/Valid_Parentheses_Java.md @@ -0,0 +1,280 @@ +# Java(OpenJDK 21)での解答 - Valid Parentheses(LeetCode 20) + +```java +class Solution { + public boolean isValid(String s) { + + } +} +``` + +--- + +## 1. 問題の分析 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「**開き括弧と閉じ括弧の組み合わせと順番が正しいかどうかを判定する問題**」です。これまでのRust / Go / TypeScript / Python と全く同じアルゴリズム(スタックを使った1回走査)で解けます。 + +Javaでこの問題を解く際に特に気をつけるべき点は、**「`char`(プリミティブ型)と`Character`(ラッパークラス)のボクシング/アンボクシング(=プリミティブ型とオブジェクト型を自動的に変換する仕組み)のコスト」**と、**「スタックとしてどのクラスを選ぶか」**の2点です。Javaの`java.util.Stack`は歴史的な理由から`Vector`を継承しており、すべての操作が同期化(`synchronized`)されているため、単一スレッドで使う場合には不要なオーバーヘッドがあります。現代的なJavaでは、スタックとして`ArrayDeque`(両端キュー、=前からも後ろからも出し入れできるデータ構造)を使うのが慣習です。 + +- **競技プログラミング視点での分析** + - 実行速度を最優先とした場合、文字列を1回だけ走査するO(n)のアプローチが最速です。 + - メモリ使用量の最小化方針としては、`ArrayDeque`のようにオブジェクト(ラッパークラス`Character`)をスタックに積む方式ではなく、プリミティブ型の`char[]`配列を手動でスタックとして使う方が、ボクシング/アンボクシングのコストとオブジェクト生成のオーバーヘッドを完全に排除できます。 +- **業務開発視点での分析** + - 型安全性・保守性を重視するなら、`Stack`クラスの代わりに`Deque`インターフェース型で変数を宣言し、実装として`ArrayDeque`を使うのがJavaの慣習(インターフェースに対してプログラミングする、という原則)です。 + - `isValid`自体はLeetCode形式で`boolean`を返す必要がありますが、内部的には「なぜ不正なのか」を表現できる独自の例外クラス(`BracketMismatchException`)を用意しておくと、将来ログ出力やAPIのエラーレスポンスへの拡張がしやすくなります。 +- **Java特有の考慮点** + - **ボクシング/アンボクシング**:`char`と`Character`を比較する際、片方がプリミティブ型であれば自動的にアンボクシング(オブジェクトから値を取り出すこと)されて値同士の比較になります。これは`Character`同士を`==`で比較する場合の落とし穴(後述)を避けるための重要なポイントです。 + - **例外設計**:Javaには`RuntimeException`(実行時例外、=呼び出し元にキャッチを強制しない)と`Exception`(チェック例外、=呼び出し元に`try-catch`または`throws`宣言を強制する)の2種類があります。本問題のような「呼び出し側が毎回チェックする必要はないが、内部処理では明確にエラーを区別したい」という場面では`RuntimeException`を継承するのが自然です。 + +> 📖 **このセクションで登場した用語** +> +> - **ボクシング/アンボクシング**:`char`(プリミティブ型)と`Character`(ラッパークラス)のように、プリミティブ型とオブジェクト型の間で自動的に変換が行われる仕組み。オブジェクト生成が伴うため、大量に発生すると性能に影響する +> - **`Deque`(デック)**:前からも後ろからも要素を出し入れできるデータ構造を表すインターフェース。「両端開きの箱」のようなイメージで、スタックとしてもキューとしても使える +> - **`RuntimeException`(実行時例外)**:呼び出し元に`try-catch`を強制しない例外。プログラムのバグに近いエラーや、呼び出し側が個別に処理しなくても良いエラーに使う +> - **チェック例外(Checked Exception)**:呼び出し元に`try-catch`または`throws`宣言を強制する例外。`IOException`などが該当する + +--- + +## 2. アルゴリズムアプローチ比較 + +> 💡 **初学者向け補足**:なぜ複数のアプローチを比較するのかというと、「動くコード」と「その状況で最善のコード」は別物だからです。特にこの問題では、**一見シンプルに見えて実は不正解になるアプローチ**があるため、比較を通じて理解を深めます。 + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n)`:入力が2倍になると、処理も約2倍になる(線形) +> - `O(n²)`:入力が2倍になると、処理は約4倍になる(二重ループや繰り返しスキャンに多い) + +| アプローチ | 時間計算量 | 空間計算量 | Java実装コスト | 可読性 | 標準ライブラリ活用 | 備考 | +| ---------- | ---------- | ---------- | --------------- | ------ | ------------------ | ---- | +| 方法A | O(n) | O(n) | 低 | ★★★ | `Deque` / `char[]` | スタック法(採用) | +| 方法B | O(n) | O(1) | 低 | ★★☆ | なし | 括弧の**種類ごとの出現数**を数えるだけ(誤ったアプローチ) | +| 方法C | O(n²) | O(n) | 中 | ★★☆ | `String.replace` | `"()" "[]" "{}"` を空文字に繰り返し置換 | + +- **方法A**:`Deque`または`char[]`をスタックとして使い、文字列を1回だけ走査します。`ArrayDeque`を使う場合は要素の`push`/`pop`のたびに`Character`オブジェクトのボクシング/アンボクシングが発生しますが、`char[]`を手動で扱えばこれを回避できます。 +- **方法B**:括弧の「数」だけを数えれば空間計算量O(1)で済みますが、括弧が閉じられる**順序**を一切見ていないため、`"([)]"`のような不正な入力を`true`と誤判定してしまいます。速さより先に「問題の要件を満たすこと」が必須です。 +- **方法C**:`String.replace()`はJavaの`String`がイミュータブル(=一度作られたら中身を変更できない性質)であるため、呼び出すたびに新しい`String`オブジェクトを生成します。これを空になるまで繰り返すため最悪O(n²)になり、ガベージコレクション(不要になったオブジェクトのメモリを自動で回収する仕組み)の負荷も増えます。 + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して処理にかかる手間がどう増えるかの目安 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安 +> - **イミュータブル**:一度作られたら中身を変更できない性質。Javaの`String`はこの性質を持つ + +--- + +## 3. 選択したアルゴリズムと理由 + +> 💡 **初学者向け補足**:「なぜ方法Aを選び、方法B・Cを選ばなかったのか」を対比形式で説明します。 + +- **選択したアプローチ**: 方法A(スタックを使った線形走査) +- **理由**: + - **方法Bを選ばなかった理由**:括弧の出現数だけを数える方法は空間計算量こそO(1)ですが、順序を検証できないため`"([)]"`のような不正な入力を見逃してしまいます。 + - **方法Cを選ばなかった理由**:`String.replace()`を繰り返すたびに新しい`String`オブジェクトが生成され、最悪O(n²)になります。 + - **方法Aを選んだ理由**:文字列を1回だけ走査するO(n)で完結し、Javaの標準的なデータ構造(`Deque`または配列)で安全に実装できます。 +- **Java特有の最適化ポイント**: + - **`ArrayDeque` vs `java.util.Stack`**:`java.util.Stack`は`Vector`を継承しているため全メソッドが`synchronized`(同期化)されており、単一スレッドでの利用には不要なロックのオーバーヘッドがあります。現代的なJavaでは`Deque`インターフェースと`ArrayDeque`実装を使うのが推奨されています。 + - **ボクシングの回避(競技版)**:`char[]`配列と`int`型のインデックスを手動で管理することで、`Character`オブジェクトの生成・GC対象化を完全に避けられます。LeetCodeのような繰り返し実行される環境では、この差が積み重なって実行速度に影響します。 + - **`Map.of()`によるイミュータブルな対応表**:Java 9以降で使える`Map.of(...)`は変更不可能な(イミュータブルな)Mapを生成するため、うっかり対応表を書き換えてしまう事故をコンパイル時ではなく実行時に検出できます(書き換えようとすると`UnsupportedOperationException`が発生します)。 + +> 📖 **このセクションで登場した用語** +> +> - **`synchronized`**:複数スレッドから同時にアクセスされても安全なように、処理を1つのスレッドずつ順番に実行させる仕組み。ロックの取得・解放にオーバーヘッドが伴う +> - **`Map.of()`**:Java 9以降で使える、変更不可能な(イミュータブルな)Mapを簡潔に生成するための静的メソッド +> - **GC(ガベージコレクション)**:使い終わったオブジェクトのメモリを自動で回収する仕組み。オブジェクト生成が多いほど負荷が増える + +--- + +## 4. 実装コード + +> 💡 **初学者向け補足**:コード全体を示す前に、「このコードの大まかな構造(骨格)」を箇条書きで示します。 +> +> 1. 括弧の不正な組み合わせを表現する**カスタム例外クラス**(`BracketMismatchException`)を定義する(`RuntimeException`を継承) +> 2. 開き括弧 → 対応する閉じ括弧、の対応表を`Map.of()`でイミュータブルに定義する +> 3. 業務開発版では`validateBrackets`が例外を投げる形で検証し、`isValid`はそれを`try-catch`で捕まえて`boolean`に変換する +> 4. 競技プログラミング版では例外を使わず、`char[]`配列と`switch`文だけで最短コードにする + +### 業務開発版 + +```java +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Map; + +class Solution { + + /** + * 括弧の対応関係・順序が不正な場合に投げる例外。 + * RuntimeException(実行時例外)を継承することで、呼び出し元に + * try-catchや throws 宣言を強制しない設計にしている。 + * boolean の true/false だけでは「なぜ」不正だったのかを表現できないため、 + * このクラスに理由をメッセージとして持たせる。 + */ + static class BracketMismatchException extends RuntimeException { + BracketMismatchException(String message) { + super(message); + } + } + + // 開き括弧 → 対応する閉じ括弧、の対応表。 + // Map.of() で作るイミュータブルな Map は、うっかり書き換えてしまう事故を + // 実行時エラー(UnsupportedOperationException)として検出できる。 + private static final Map PAIRS = Map.of( + '(', ')', + '[', ']', + '{', '}' + ); + + public boolean isValid(String s) { + if (s == null) { + // Javaは null を許容する言語のため、防御的にチェックする。 + throw new IllegalArgumentException("Input must not be null"); + } + try { + validateBrackets(s); + } catch (BracketMismatchException e) { + // 詳細なエラー情報は例外オブジェクトの中にあるが、 + // LeetCode形式の戻り値は boolean 固定なのでここでは false に変換する。 + return false; + } + return true; + } + + /** + * スタックを使って括弧の対応関係・順序を検証する。 + * 検証に成功した場合は何も返さず正常終了し、 + * 失敗した場合は BracketMismatchException を投げる。 + */ + private void validateBrackets(String s) { + // ArrayDeque を「後入れ先出し」のスタックとして使う。 + // java.util.Stack は Vector を継承しており全メソッドが synchronized + // (同期化)されているため、単一スレッドでは ArrayDeque の方が高速。 + Deque stack = new ArrayDeque<>(); + + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + + if (PAIRS.containsKey(c)) { + // 開き括弧なら、対応する閉じ括弧を積んでおく。 + stack.push(PAIRS.get(c)); + } else { + // 閉じ括弧のはず。スタックが空なら対応する開き括弧が無い。 + if (stack.isEmpty()) { + throw new BracketMismatchException( + "対応する開き括弧がないまま閉じ括弧 '" + c + "' が出現しました(位置 " + i + ")"); + } + // stack.pop() は Character を返す。char である c と比較する際、 + // 片方がプリミティブ型なので Java は自動的にもう片方を + // アンボクシングしてから値を比較する(安全に値比較できる)。 + char top = stack.pop(); + if (top != c) { + throw new BracketMismatchException( + "'" + c + "' に対応する開き括弧が見つかりません(位置 " + i + ")"); + } + } + } + + if (!stack.isEmpty()) { + throw new BracketMismatchException("閉じられていない開き括弧が残っています"); + } + } +} +``` + +### 競技プログラミング版 + +```java +class Solution { + public boolean isValid(String s) { + int n = s.length(); + // char[] を手動スタックとして使う。ArrayDeque と違い、 + // ボクシング/アンボクシングのコストが一切発生しないため、 + // 大量の呼び出しが発生する競技プログラミングではこちらが高速。 + char[] stack = new char[n]; + int top = 0; // スタックの次に書き込む位置(=現在の要素数) + + for (int i = 0; i < n; i++) { + char c = s.charAt(i); + switch (c) { + case '(': + stack[top++] = ')'; + break; + case '[': + stack[top++] = ']'; + break; + case '{': + stack[top++] = '}'; + break; + default: + // 閉じ括弧のはず。top == 0 ならスタックが空。 + // stack[--top] は先に top をデクリメントしてから要素を読む + // (つまり pop 相当の操作を1行で行っている)。 + if (top == 0 || stack[--top] != c) { + return false; + } + } + } + + // 全て閉じられていれば top は 0 のはず。 + return top == 0; + } +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> ``` +> 入力: s = "([)]" +> i=0, c='(' → push(')') → stack=[')'] +> i=1, c='[' → push(']') → stack=[')', ']'] +> i=2, c=')' → 閉じ括弧 → pop() → top=']' +> ']' != ')' → 不一致 → return false +> ``` + +> 💡 **ボクシング/アンボクシングを理解する**(初学者向け) +> +> **基礎**:Javaのプリミティブ型(`char`, `int`など)は、コレクション(`Deque`など)に直接格納できません。コレクションはオブジェクトしか扱えないため、`char`を`Character`という「箱」に入れる(ボクシング)必要があります。 +> ```java +> Character boxed = 'a'; // 自動的にボクシングされる +> char primitive = boxed; // 自動的にアンボクシングされる +> ``` +> +> **応用**:注意が必要なのは、`Character`同士を`==`で比較する場合です。Javaは`\u0000`〜`\u007f`(0〜127、ASCII文字範囲)の値をキャッシュして使い回すため(※`Integer`の`-128`〜`127`とは範囲が異なります)、この範囲内では`==`が常に`true`になりますが、範囲外では参照同一性が保証されず、環境やボクシングのされ方によって`true`または`false`になり得ます。そのため、確実に値を比較するには`equals()`を使うか、`char`へアンボクシングして比較を行う必要があります。 +> +> ```java +> Character a = '\u0080'; +> Character b = '\u0080'; +> System.out.println(a == b); // true または false(参照同一性が保証されないため依存) +> System.out.println(a.equals(b)); // true(equals による値比較) +> System.out.println((char)a == (char)b); // true(アンボクシングによる値比較) +> ``` +> +> **この問題での使い方**:業務開発版のコードでは`char top = stack.pop();`のように、`pop()`の戻り値(`Character`)を`char`(プリミティブ型)の変数に代入しています。この代入時点でアンボクシングされるため、その後の`top != c`は`Character`同士の参照比較ではなく、確実に値同士の比較になります。この一手間により、括弧記号がキャッシュ範囲内かどうかを気にする必要がなくなります。 + +> 📖 **このセクションで登場した用語** +> +> - **`ArrayDeque`**:配列を内部実装に使った`Deque`の実装クラス。スタックとしてもキューとしても使え、`java.util.Stack`より高速 +> - **`Map.of()`**:Java 9以降で使える、変更不可能なMapを生成する静的メソッド +> - **アンボクシング**:`Character`のようなラッパークラスのオブジェクトから、`char`のようなプリミティブ型の値を取り出す操作 +> - **`switch`文**:値に応じて処理を分岐させるJavaの構文。`case`ラベルの後に`break`を書かないと、次の`case`に処理が「フォールスルー」(流れ落ちる)する点に注意が必要 + +--- + +## 5. 検証 + +_テストコードは不要です!具体的な計測値、メモリの出力も不要です!_ + +> 💡 **初学者向け補足**:エッジケースとは「入力が空・最小値・最大値・重複あり」など境界的な入力のことです。 + +- **境界値テスト**: + - **単一の閉じ括弧(例:`")"`)の場合**:業務開発版では`stack.isEmpty()`のチェックで`BracketMismatchException`が送出されます。競技版では`top == 0`を先に確認する短絡評価(=`||`の左側がtrueなら右側を評価しない仕組み)により、`stack[--top]`への不正なアクセスを未然に回避しています。もしこのチェックを省略すると、`ArrayIndexOutOfBoundsException`(配列の範囲外アクセスによる実行時例外)が発生します。 + - **未閉じの開き括弧(例:`"((("`)の場合**:走査後もスタック(または`top`)に要素が残るため、業務開発版では`if (!stack.isEmpty())`のチェックで例外が送出され、競技版では`return top == 0`が`false`になります。 + - **括弧の種類の不一致(例:`"([)]"`)の場合**:上記のトレースで示した通り、`pop()`した値と実際の文字が一致しないため`false`になります。 + - **最大長10^4文字の場合**:`char[] stack = new char[n];`のように、あらかじめ最悪ケース(全て開き括弧)を見越した固定長配列を確保しているため、途中でのメモリ再確保が一切発生せずO(n)の性能が保証されます。 +- **null安全性**:Javaは`null`を許容する言語であるため、業務開発版では`isValid`の冒頭で`s == null`のチェックを行い、`IllegalArgumentException`(不正な引数に対する標準的な例外)を送出しています。LeetCodeの実行環境では`null`が渡されることは想定されていませんが、実務のコードとして再利用する場合にはこの防御が重要になります。 + +> 📖 **このセクションで登場した用語** +> +> - **`ArrayIndexOutOfBoundsException`**:配列の範囲外のインデックスにアクセスしようとしたときに発生する実行時例外 +> - **`IllegalArgumentException`**:メソッドに渡された引数が不正である場合に投げる、Java標準の実行時例外 +> - **短絡評価**:`||`や`&&`で、左側の条件だけで結果が確定する場合に右側を評価しない仕組み。範囲外アクセスを未然に防ぐ安全策として使われる diff --git a/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/Valid_Parentheses_Python.md b/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/Valid_Parentheses_Python.md new file mode 100644 index 00000000..41d3f286 --- /dev/null +++ b/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/Valid_Parentheses_Python.md @@ -0,0 +1,348 @@ +# 1. 問題分析結果 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「**開き括弧と閉じ括弧の"組み合わせ"と"順番"が正しいかを、後入れ先出し(=最後に入れたものを最初に取り出す)のルールで判定する問題**」です。 +> +> Pythonで解く際に特に気をつけるべきCPython(=最も広く使われるPythonの実装。C言語で作られており、挙動や速度に独自の特性がある)特有の注意点は、**「`list`をスタックとして使う場合の末尾操作の計算量」**と、**「辞書(`dict`)を使った対応表引きがO(1)であることの活用」**の2点です。Pythonの`list`は末尾への追加・削除(`append`/`pop`)がO(1)ですが、これは内部的に「余裕を持った容量」を確保しているためであり、先頭操作(`insert(0, x)`や`pop(0)`)は全要素をずらす必要がありO(n)になります。今回はスタックとして末尾だけを使うため`list`で十分ですが、この違いを理解しておくことが重要です。 + +#### 競技プログラミング視点 + +- **制約分析**:文字列長は最大10^4文字(制約より)。この規模なら O(n) の線形走査を選べば十分高速に処理できます。 +- **最速手法**:`list`をスタックとして使い、辞書で「閉じ括弧 → 対応する開き括弧」の対応表を引くのが最も素直かつ高速です。競技版ではさらに、開き括弧を`in`演算子で判定するのではなく、辞書に存在するかどうかだけで開き/閉じを区別するテクニックを使います。 +- **メモリ最小化**:文字列は`str`のイテレーション(走査)自体がC実装で高速なため、余分なオブジェクト生成(例えば文字列を毎回スライスして新しい`str`を作るなど)を避けることが重要です。 +- **CPython最適化**:`dict`のキー検索はハッシュテーブル(=キーからハッシュ値という数値を計算し、その番号の場所に値を格納・検索するデータ構造)による平均O(1)のルックアップです。`if c in "([{"` のような文字列メンバーシップテストよりも、`dict`を1つ用意して`in`で判定する方が意図が明確で、かつ同程度以上に高速です。 + +#### 業務開発視点 + +- **型安全設計**:`isValid`自体はLeetCode形式で`bool`を返す必要がありますが、内部的には「なぜ不正なのか」を表現できる独自の例外クラス(`BracketMismatchError`)を定義しておくと、将来ログ出力やAPIのエラーレスポンスへの拡張がしやすくなります。 +- **エラーハンドリング**:Pythonは動的型付け言語(=変数の型が実行時に決まる言語。JavaやC++のような静的型付けと違い、コンパイル時の型チェックがない)なので、想定外の型が渡された場合に備えて`isinstance`による実行時チェックを行い、`pylance`(VSCodeの型チェッカー)による静的チェックと二重の防御を行います。 +- **可読性**:docstring(=関数やクラスの先頭に書く説明文)で計算量や例外条件を明示し、チームメンバーが後から読んでも意図が分かるようにします。 + +#### Python特有分析 + +- **データ構造選択**:「末尾への追加・削除が頻繁」という要件に対しては`list`で十分です。`collections.deque`(両端キュー。先頭操作もO(1)にできるデータ構造)は両端操作が必要な場合に有効ですが、今回は末尾操作しか使わないため`list`の方がシンプルでオーバーヘッドも小さくなります。 +- **標準ライブラリ活用度**:この問題では`heapq`(ヒープ操作用ライブラリ)や`bisect`(二分探索用ライブラリ)の出番はなく、`dict`と`list`という組み込み型だけで十分です。 +- **CPython最適化度**:`dict`のキー検索、`list.append`/`list.pop`はすべてC実装のため、Pure Python(=C拡張を使わない、素のPythonコードだけで書かれた実装)でループを書くよりも高速に動作します。 + +> 📖 **このセクションで登場した用語**(例) +> +> - **CPython**:最も広く使われるPythonの実装。C言語で書かれており、組み込み関数の多くがC実装のため高速。 +> - **ハッシュテーブル**:キーからハッシュ値という数値を計算し、その番号の場所に値を格納・検索するデータ構造。`dict`の内部実装に使われており、平均O(1)で検索できる。 +> - **動的型付け**:変数の型が実行時に決まる言語仕様。Pythonはこれに該当し、コンパイル時の型チェックがない。 +> - **Pure Python**:C拡張を使わない、素のPythonコードだけで書かれた実装。C実装の組み込み関数より一般に低速。 + +--- + +# 2. 採用アルゴリズムと根拠 + +> 💡 **初学者向け補足**:なぜ複数のアプローチを比較するのかというと、「動くコード」と「その状況で最善のコード」は別物だからです。特にこの問題では、**一見シンプルに見えて実は不正解になるアプローチ**があるため、比較を通じて理解を深めます。 + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n)`:入力が2倍になると、処理も約2倍になる(線形) +> - `O(n²)`:入力が2倍になると、処理は約4倍になる(二重ループや繰り返しスキャンに多い) + +| アプローチ | 時間計算量 | 空間計算量 | Python実装コスト | 可読性 | 標準ライブラリ活用 | CPython最適化 | 備考 | +| ---------- | ---------- | ---------- | ----------------- | ------ | ------------------ | -------------- | ---- | +| 方法A | O(n) | O(n) | 低 | ★★★ | なし(`dict`+`list`) | 適 | スタック法(採用) | +| 方法B | O(n) | O(1) | 低 | ★★☆ | `collections.Counter` | 不適 | 括弧の**出現数**を数えるだけ(誤ったアプローチ) | +| 方法C | O(n²) | O(n) | 中 | ★★☆ | `str.replace` | 不適 | `"()" "[]" "{}"`を空文字に繰り返し置換 | + +- **選択理由**: + - **方法Bを選ばなかった理由**:`Counter`(要素の出現回数を数える辞書のサブクラス)で括弧の数だけを数えれば空間計算量O(1)で済みますが、括弧が閉じられる**順序**を一切見ていないため、`"([)]"`のような不正な入力を`True`と誤判定してしまいます。速さより先に「問題の要件を満たすこと」が必須です。 + - **方法Cを選ばなかった理由**:`str.replace`はPythonの文字列がイミュータブル(=一度作られたら中身を変更できない性質)であるため、呼び出すたびに新しい文字列オブジェクトを生成します。これを空になるまで繰り返すため最悪O(n²)になり、ガベージコレクション(不要になったオブジェクトを自動で回収する仕組み)の負荷も増えます。 + - **方法Aを選んだ理由**:文字列を1回だけ走査するO(n)で完結し、`list.append`/`list.pop`・`dict`のキー検索はいずれもCPythonのC実装による高速な操作です。 +- **Python最適化戦略**: + - **辞書によるO(1)対応表引き**:`{')': '(', ']': '[', '}': '{'}` のような辞書を用意し、`if`/`elif`の連鎖ではなく辞書引きで対応関係を判定することで、コードが簡潔かつ高速になります。 + - **`dict.get()`のデフォルト値活用**:`stack.pop() if stack else '#'`のような書き方や、`pairs.get(c)`のようにデフォルト値を使うことで、`try-except`による例外処理コストを避けられます(Pythonの例外処理は「起きなければ軽い」が「頻発すると重い」特性があるため、通常フローでの例外多用は避けるのが定石です)。 +- **トレードオフ**:業務版は型ヒントと例外クラスによる明確なエラー表現のためわずかにコード量が増えますが、可読性・拡張性が向上します。競技版はエラー情報を持たず`bool`一発勝負にすることで、コードが最小かつ最速になります。 + +> 📖 **このセクションで登場した用語** +> +> - **`Counter`**:要素の出現回数を数える`dict`のサブクラス。`collections`モジュールに含まれる。 +> - **イミュータブル**:一度作られたら中身を変更できない性質。Pythonの`str`はこの性質を持つ。 +> - **ガベージコレクション**:不要になったオブジェクトのメモリを自動で回収する仕組み。頻繁に新しいオブジェクトを作ると負荷が増える。 +> - **トレードオフ**:何かを得ると何かを失う関係。速くするとメモリが増える、など。 + +--- + +# 3. 実装パターン + +> 💡 **初学者向け補足**:コード全体を示す前に、「このコードの大まかな構造(骨格)」を箇条書きで示します。 +> +> 1. 括弧の不正な組み合わせを表現する**カスタム例外クラス**(`BracketMismatchError`)を定義する +> 2. 閉じ括弧から対応する開き括弧を引く**対応表**(`dict`)をクラス定数として定義する +> 3. 業務開発版では`_validate_brackets`が例外を投げる形で検証ロジックを実装し、`isValid`はそれを`try-except`で捕まえて`bool`に変換する +> 4. 競技プログラミング版では例外を使わず、辞書引きと`list`操作だけで最短コードにする + +``` +【業務開発版を使う場面】 +チームで長期間メンテナンスするプロダクションコードに向きます。 +「なぜ不正なのか」を例外クラスとして保持できるため、 +ログ出力やAPIのエラーレスポンスに転用しやすい設計です。 + +【競技プログラミング版を使う場面】 +LeetCodeの制限時間内に正解を出すことが目的のコードに向きます。 +例外処理を使わず辞書引きと真偽値の直接返却だけで完結させ、 +可読性よりも実行速度・コードの短さを優先します。 +``` + +### 業務開発版 + +```python +from typing import Final + + +class BracketMismatchError(Exception): + """ + 括弧の対応関係・順序が不正な場合に送出する例外。 + + なぜ独自の例外クラスを定義するか: + bool の True/False だけでは「なぜ」「どこで」不正だったのかを表現できない。 + Exception を継承したクラスを作ることで、将来的にエラーメッセージを + ログ出力やAPIレスポンスに転用しやすくなる。 + """ + + def __init__(self, message: str, char: str, position: int) -> None: + # 親クラス(Exception)のコンストラクタを呼び出し、メッセージを渡す。 + super().__init__(message) + # 追加情報として、問題の文字と位置を保持しておく。 + # 呼び出し元がログに出力したり、UIで強調表示したりする際に使える。 + self.char = char + self.position = position + + +class Solution: + """ + 括弧の妥当性判定クラス(LeetCode: Valid Parentheses) + + 競技プログラミング向けと業務開発向けの2パターンを提供する。 + """ + + # クラス定数として対応表を定義する。 + # なぜ __init__ ではなくクラス変数にするか: + # このデータは全インスタンスで共通であり、インスタンスごとに + # 作り直す必要がないため、クラスレベルで1度だけ生成する方が効率的。 + # Final を付けることで「再代入されない」ことを pylance に伝え、 + # 誤って書き換えてしまうバグを型チェックの段階で防ぐ。 + _PAIRS: Final[dict[str, str]] = { + ')': '(', + ']': '[', + '}': '{', + } + + def isValid(self, s: str) -> bool: + """ + 括弧文字列が有効かどうかを判定する(業務開発版)。 + + 内部的には _validate_brackets が詳細な検証を行い、 + 例外が送出されなければ True、送出されれば False を返す + 「薄いラッパー」として実装している。 + + Args: + s: 判定対象の文字列('(' ')' '[' ']' '{' '}' のみを含む想定) + + Returns: + 有効なら True、そうでなければ False + + Complexity: + Time: O(n) + Space: O(n) + """ + # isinstance で実行時の型チェックを行う。 + # Python は動的型付け言語のため、pylance の静的チェックをすり抜けて + # 誤った型が実行時に渡される可能性がある。二重の防御として有効。 + if not isinstance(s, str): + raise TypeError("Input must be a string") + + try: + self._validate_brackets(s) + except BracketMismatchError: + # 詳細なエラー情報は _validate_brackets の例外に含まれているが、 + # LeetCode形式の戻り値は bool 固定なので、ここでは False に変換する。 + # 実運用では logging.warning(str(e)) のようにログへ残すことが多い。 + return False + return True + + def _validate_brackets(self, s: str) -> None: + """ + 括弧の対応関係・順序を検証する内部メソッド。 + + 検証に成功した場合は何も返さず正常終了し、 + 失敗した場合は BracketMismatchError を送出する。 + 戻り値ではなく例外でエラーを表現するのは、Pythonでは + 「エラーは例外で表現する(EAFP: Easier to Ask Forgiveness than Permission)」 + という考え方が伝統的に使われてきたため。 + Rust の Result 型や Go の (value, error) とは異なるアプローチだが、 + 「呼び出し元が失敗ケースを無視できない」という目的は共通している。 + + Args: + s: 検証対象の文字列 + + Raises: + BracketMismatchError: 対応関係・順序が不正な場合 + """ + # list をスタック(後入れ先出し構造)として使う。 + # append/pop はどちらも末尾操作でO(1)(CPythonのlistは末尾に + # 余裕を持たせて確保しているため、末尾操作は平均的に高速)。 + stack: list[str] = [] + + # enumerate() でインデックスと文字を同時に取得する。 + # range(len(s)) でインデックスアクセスするより Pythonic(=Python的で慣用的)で、 + # C実装のイテレータを使うため高速。 + for position, char in enumerate(s): + if char in '([{': + # 開き括弧なら、対応する閉じ括弧が来るまでスタックに積んでおく。 + stack.append(char) + continue + + if char in self._PAIRS: + # 閉じ括弧の場合。まずスタックが空でないか確認する。 + # 空の場合、対応する開き括弧が存在しないということなので不正。 + if not stack: + raise BracketMismatchError( + "対応する開き括弧がないまま閉じ括弧が出現しました", + char, + position, + ) + + # スタックの末尾(=直前に積んだ開き括弧)を取り出す(pop操作)。 + top = stack.pop() + + # _PAIRS[char] で「この閉じ括弧に対応するはずの開き括弧」を引く。 + # dict のキー検索は平均O(1)(ハッシュテーブルによる高速アクセス)。 + expected_open = self._PAIRS[char] + + if top != expected_open: + raise BracketMismatchError( + f"'{char}' に対応する開き括弧は '{expected_open}' ですが " + f"'{top}' が見つかりました", + char, + position, + ) + # 一致していれば、このペアは正しく閉じられたので次のループへ進む。 + continue + + # 制約により括弧文字以外は入力されない前提だが、 + # 防御的プログラミング(想定外の入力にも安全に対応する設計方針)として + # 明示的に例外を送出し、意図しない挙動を防ぐ。 + raise BracketMismatchError( + "括弧以外の文字が含まれています", char, position + ) + + # 走査し終えた時点でスタックに何か残っていれば、 + # 閉じられていない開き括弧があるということなので不正。 + if stack: + unclosed = stack[-1] + raise BracketMismatchError( + f"開き括弧 '{unclosed}' が閉じられないまま入力が終了しました", + unclosed, + len(s), + ) +``` + +### 競技プログラミング版 + +```python +class Solution: + def isValid(self, s: str) -> bool: + """ + 括弧文字列が有効かどうかを判定する(競技プログラミング版)。 + + 例外処理・型チェックを省略し、辞書引きと list 操作だけで + 最短かつ最速の実装にしている。 + + Complexity: + Time: O(n) + Space: O(n) + """ + # 閉じ括弧 → 対応する開き括弧、の対応表。 + # 関数(メソッド)呼び出しのたびに辞書を作り直すのは無駄に見えるが、 + # リテラル辞書の生成はCPythonでは十分高速であり、 + # 可読性を優先してここに書いている(さらに最適化するならクラス変数化する)。 + pairs = {')': '(', ']': '[', '}': '{'} + + # list をスタックとして使う。 + stack: list[str] = [] + + for char in s: + if char in pairs: + # 閉じ括弧の場合: + # stack.pop() if stack else '#' というイディオムで、 + # 「スタックが空なら番兵文字 '#' を使う」ことで、 + # 空リストに対する pop() の例外(IndexError)を未然に回避する。 + # '#' は括弧記号に絶対に一致しないダミー値として機能する。 + if pairs[char] != (stack.pop() if stack else '#'): + return False + else: + # 開き括弧(または制約上あり得ないが未知の文字)の場合、 + # そのままスタックに積む。 + stack.append(char) + + # 全て閉じられていれば、スタックは空になっているはず。 + # not stack は「スタックが空かどうか」を確認する Pythonic な書き方。 + return not stack +``` + +> 💡 **コードの動作トレース**(初学者向け) +> 業務開発版で入力例 `s = "([)]"`(要件2に違反する不正な例)をトレースします。 +> +> ``` +> 初期状態: stack = [] +> position=0, char='(' → '(' in '([{' → True → append → stack=['('] +> position=1, char='[' → '[' in '([{' → True → append → stack=['(', '['] +> position=2, char=')' → ')' in _PAIRS → True +> stack.pop() → top='['、stack=['('] +> _PAIRS[')'] = '(' +> expected_open='(' だが top='[' → 不一致! +> → BracketMismatchError("'[' に対応する開き括弧は... ") を送出 +> isValid の戻り値: except節で捕捉 → False ✅(期待通り) +> ``` +> +> 競技版で同じ入力をトレースすると、より簡潔です。 +> +> ``` +> 初期状態: stack = [] +> char='(' → pairs に無い → append → stack=['('] +> char='[' → pairs に無い → append → stack=['(', '['] +> char=')' → pairs にある → pairs[')']='(' と stack.pop()='[' を比較 → 不一致 → return False ✅ +> ``` + +> 💡 **辞書によるO(1)対応表引き**(初学者向け) +> +> **基礎**:Pythonの`dict`は「キー」から「値」を素早く探せるデータ構造です。図書館の索引カードのように、目的のキーがあれば本棚を端から探す(=O(n))必要がなく、直接目的の場所へ辿り着けます(=平均O(1))。 +> ```python +> pairs = {')': '(', ']': '[', '}': '{'} +> print(pairs[')']) # '(' が一瞬で得られる +> ``` +> +> **応用**:`char in pairs`のように`in`演算子で辞書のキーの存在確認を行うのも、リストでの`in`検索(O(n))と違い平均O(1)で完了します。 +> +> **この問題での使い方**:閉じ括弧が来るたびに`if/elif`を3つ並べて比較するのではなく、`pairs[char]`と1行書くだけで対応する開き括弧が得られます。コードが短くなるだけでなく、括弧の種類が増えた場合にも辞書に1行追加するだけで対応できる拡張性があります。 + +> 📖 **このセクションで登場した用語** +> +> - **EAFP(Easier to Ask Forgiveness than Permission)**:「許可を求めるより、まず実行してダメだったら謝る(例外処理する)」というPythonの伝統的な設計哲学。事前に`if`でチェックする LBYL(Look Before You Leap)と対比される。 +> - **`enumerate()`**:イテラブル(リストや文字列など)から「インデックスと値のペア」を順に取り出す組み込み関数。C実装のため`range(len(x))`より高速かつPythonic。 +> - **番兵(センチネル)値**:ループや条件分岐の終端・特殊ケースを示すためのダミー値。競技版の`'#'`は「絶対に括弧と一致しない番兵」として使われている。 +> - **`Final`**:`typing`モジュールの型。「この変数は再代入されない」ことをpylanceなどの型チェッカーに伝える。 + +--- + +# 4. 検証 + +_テストコードは不要です!具体的な計測値、メモリの出力も不要です!_ + +> 💡 **初学者向け補足**:エッジケースのテストは、アルゴリズムが「ふつうの入力」だけでなく「極端な入力」でも正しく動くかを確かめるためのものです。 + +- **境界値テスト**: + - **単一の閉じ括弧(例:`")"`)の場合**:業務版では`stack`が空のため`if not stack:`で`BracketMismatchError`が送出されます。競技版では`stack.pop() if stack else '#'`により、空リストへの`pop()`で発生する`IndexError`(存在しない要素にアクセスしようとしたときの例外)を未然に回避し、安全に`False`を返します。 + - **未閉じの開き括弧(例:`"((("`)の場合**:ループ終了後も`stack`に要素が残るため、業務版では`if stack:`のチェックで例外が送出され、競技版では`return not stack`が`False`になります。 + - **最大長10^4文字の場合**:`list.append`/`list.pop`はいずれも償却O(1)(=ならすと一定時間で済む、という意味。稀に発生する再確保のコストを全体で割ると無視できるほど小さくなる)であり、`dict`のキー検索もO(1)のため、全体としてO(n)の性能が保証されます。 +- **型チェック**:pylanceによる静的解析では、`_PAIRS: Final[dict[str, str]]`のような厳密な型ヒントにより、意図しない型の値が代入された場合にエラーとして検出されます。`stack: list[str]`という宣言により、`stack.pop()`の戻り値が`str`型であることも型チェッカーに伝わります。 + +> 📖 **このセクションで登場した用語** +> +> - **`IndexError`**:リストや文字列などのシーケンスに対して、存在しないインデックスにアクセスしようとしたときに発生する例外。 +> - **償却計算量(Amortized Complexity)**:個々の操作は時々遅くなることがあっても、多数回の操作全体で平均すると一定時間で済むという計算量の考え方。`list.append`の再確保コストはこの考え方で「ならすとO(1)」とみなされる。 +> - **静的解析**:プログラムを実行せずに、コードを読むだけでバグや型エラーを検出する手法。pylanceはこれをリアルタイムで行うツール。 diff --git a/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/Valid_Parentheses_Rust.md b/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/Valid_Parentheses_Rust.md new file mode 100644 index 00000000..48440c32 --- /dev/null +++ b/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/Valid_Parentheses_Rust.md @@ -0,0 +1,299 @@ +# 1. 問題の分析 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「**開き括弧と閉じ括弧の"組み合わせ"と"順番"が正しいかどうかを、後入れ先出し(=最後に置いたものを最初に取り出す)のルールで判定する問題**」です。 +> +> Rustでこの問題を解く際に特に気をつけるべき点は、**「文字列の所有権をどう扱うか」**と**「マッチング失敗時の"境界条件"をパニックさせずに処理すること」**の2点です。関数シグネチャが `s: String` となっており呼び出し元から所有権を受け取りますが、中身を読むだけなら `&str`(文字列スライス)として借用すれば十分であり、無駄なコピーを避けられます。また、閉じ括弧が来たのにスタックが空、という状況(=対応する開き括弧が存在しない)をどう安全に扱うかがRustらしい設計のポイントになります。 + +- **競技プログラミング視点での分析** + - 実行速度を最優先とした場合、文字列を1回だけ走査する線形時間 O(n) のアプローチが最速です。それ以上速くすることは(各文字を最低1回は見る必要があるため)理論上不可能です。 + - メモリ使用量の最小化方針としては、スタック(後入れ先出し構造)として `Vec` を使うのが最もシンプルですが、`char` は4バイトを占めるため、ASCII文字のみという制約(括弧記号は全てASCII)を活かして `Vec`(バイト列)にすればメモリをさらに節約できます。ヒープへの再アロケーション(=メモリ領域の再確保)回数を減らすため、`Vec::with_capacity` で事前に容量を確保するのも定石です。 +- **業務開発視点での分析** + - 型安全性・保守性を重視するなら、括弧の種類(丸・波・角)を `char` の生の比較で扱うのではなく、意味のある型(enumなど)で表現することで「間違った文字を比較してしまう」バグをコンパイル時に防げます。 + - `Result` / `Option` によるエラーハンドリング設計としては、「どの位置で」「どんな理由で」不正になったかを型で表現しておくと、将来的にエラーメッセージを表示するUIなどに拡張しやすくなります。今回のLeetCode形式では最終的に `bool` を返しますが、内部的には `Result<(), BracketError>` を返す純粋関数を用意し、それを `bool` に変換する設計にします。 +- **Rust特有の考慮点** + - **所有権・借用**:`is_valid(s: String)` はLeetCodeのシグネチャ上 `String`(所有権を持つ文字列)を受け取りますが、関数内部では中身を読むだけなので `&str` として借用すれば十分です。所有権を持つ `String` をそのまま内部関数に渡すと「ムーブ(=所有権の移動)」が発生し、以後その変数は使えなくなってしまいます。今回は最後に使うだけなので実害はありませんが、再利用したい場合は `&s` のように借用を渡すのがRustの慣習です。 + - **ライフタイム**:今回は参照をそのまま関数内で使い切って返すだけなので、明示的なライフタイム注釈 `'a` は不要です(コンパイラが自動推論してくれます=ライフタイム省略規則)。 + - **トレイト境界とモノモーフィゼーション**:この問題は `char` 型に固定された処理のため、ジェネリクス(``)は不要です。無理にジェネリックにすると、かえって可読性が落ちるため今回は使いません。 + - **イテレータアダプタ vs 命令型ループ**:`for c in s.chars()` のようなイテレータベースのループは、C言語スタイルのインデックスアクセス(`for i in 0..s.len()`)よりも安全(範囲外アクセスが起きない)で、かつコンパイラが最適化することで同等の速度が出ます(=ゼロコスト抽象化)。今回はこちらを採用します。 + +> 📖 **このセクションで登場した用語** +> +> - **所有権**:値を「誰が管理するか」をコンパイル時に決めるRust独自の仕組み。JavaやPythonのようにガベージコレクタ(=不要になったメモリを自動回収する仕組み)を使わずに、メモリの二重解放や解放後アクセスをコンパイル時に防げる。 +> - **借用**:所有権を渡さずに値を「参照」だけする仕組み。`&T`(読み取り専用の借用)と `&mut T`(書き込み可能な借用)がある。 +> - **ムーブ(move)**:所有権が別の変数に移ること。ムーブされた元の変数はそれ以降使用できなくなる(コンパイルエラーになる)。 +> - **スタック(データ構造としての意味)**:後入れ先出し(LIFO: Last In, First Out)の構造。最後に追加した要素を最初に取り出す。今回の「括弧の対応関係」の判定にぴったり合う構造。 + +--- + +# 2. アルゴリズムアプローチ比較 + +> 💡 **初学者向け補足**:なぜ複数のアプローチを比較するのかというと、「動くコード」を書くことと「その状況で最善のコード」を書くことは別物だからです。特にこの問題は一見単純ですが、誤ったアプローチ(後述の方法B)を選ぶと `"([)]"` のような**順序が壊れているケース**を見逃してしまいます。 + +| アプローチ | 時間計算量 | 空間計算量 | Rust実装コスト | 安全性 | 可読性 | 備考 | +| ---------- | ---------- | ---------- | -------------- | ------ | ------ | ---------------------------------------- | +| 方法A | O(n) | O(n) | 低 | 高 | 高 | `Vec` をスタックとして使う(採用) | +| 方法B | O(n) | O(1) | 低 | 低 | 中 | 括弧の**種類ごとの出現数**を数えるだけ(誤ったアプローチ) | +| 方法C | O(n²) | O(n) | 中 | 高 | 中 | `"()"` `"[]"` `"{}"` を空文字に繰り返し置換 | + +**Rust固有の観点(所有権・アロケーションの有無)**: + +- **方法A**:`s.chars()` は `&str`(借用)に対するイテレータなので所有権の移動は発生しません。`Vec` の `push`/`pop` はヒープへのアロケーションを伴いますが、`Vec::with_capacity(s.len())` で最悪ケースの容量を先に確保すれば、再アロケーション(=容量が足りなくなるたびにメモリを取り直す処理)はゼロ回に抑えられます。 +- **方法B**:括弧の「数」だけを `[i32; 3]` のような固定長配列(スタックに置ける、ヒープ確保不要)でカウントするので空間計算量は O(1) です。しかし `"([)]"` のように**数は合っているが順序が壊れている**入力を `true` と誤判定してしまうため、そもそも**要件2「正しい順序で閉じる」を満たせず不正解**です(比較のためにあえて載せています)。 +- **方法C**:文字列の `replace()` を空になるまで繰り返すため、1回の `replace` 走査が O(n) で、最悪 O(n) 回繰り返すため合計 O(n²) になります。さらに `replace` のたびに新しい `String` が確保される(=所有権を持つ新しい文字列がヒープに作られる)ため、メモリ効率も悪化します。 + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n)`:入力が2倍になると、処理も約2倍になる(線形) +> - `O(n²)`:入力が2倍になると、処理は約4倍になる(二重ループや繰り返しスキャンに多い) + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさ `n` に対して、処理にかかる手間がどう増えるかの目安。 +> - **空間計算量**:処理中に使う追加メモリ量が、入力の大きさに対してどう増えるかの目安。 +> - **アロケーション**:ヒープ(動的にサイズが変わるデータを置く領域)上にメモリを確保する操作。頻繁に行うと速度が落ちる。 +> - **再アロケーション**:`Vec` などの容量が足りなくなったときに、より大きな領域を確保し直して中身をコピーする操作。事前に `with_capacity` で容量を確保すれば回避できる。 + +--- + +# 3. 選択したアルゴリズムと理由 + +> 💡 **初学者向け補足**:「なぜ方法Aを選び、方法B・Cを選ばなかったのか」を対比形式で説明します。 + +- **選択したアプローチ**: 方法A(`Vec` をスタックとして使う線形走査) +- **理由**: + - **計算量的な優位性**:方法Cの O(n²) と違い、方法Aは文字列を1回だけ走査する O(n) で完結します。入力長は最大 10,000 文字(制約より)なので、O(n²) でも実用上は動きますが、O(n) の方が明確に優れています。 + - **方法Bを選ばなかった理由**:方法Bは空間計算量こそ O(1) と優秀に見えますが、そもそも「開き括弧が閉じられる**順序**」を一切見ていないため、`"([)]"` のような不正な入力を `true` と誤判定してしまい、**問題の要件を満たしていません**。速さより先に正しさが必須です。 + - **Rustの所有権モデルとの親和性**:`s.chars()` は `&str` を借用するだけなので所有権のムーブが発生せず、関数の入出力設計がシンプルになります。またスタックへの `push`/`pop` は `Vec` の標準メソッドとして安全に(境界外アクセスによるパニックなしに)扱えます。 +- **Rust特有の最適化ポイント**: + - **ゼロコスト抽象化によるオーバーヘッドの排除**:`for c in s.chars()` というイテレータベースの書き方は、手書きの `while` ループ+インデックスアクセスと同等の機械語に最適化されます。それでいて範囲外アクセスの危険性がありません。 + - **`match` によるパターンマッチの網羅性チェック**:Rustの `match` はすべての分岐を網羅していないとコンパイルエラーになるため、「閉じ括弧の種類を1つ書き忘れる」といったバグをコンパイル時に検出できます。 + - **スタックアロケーション優先によるキャッシュ効率**:`Vec` の中身自体はヒープにありますが、事前に容量を確保しておくことで、走査中のメモリ確保コストをほぼゼロにできます。 + +> 📖 **このセクションで登場した用語** +> +> - **ゼロコスト抽象化**:便利な高レベルの書き方(イテレータなど)をしても、手書きの低レベルコードと同等の速さになるRustの特性。「抽象化のために実行時コストを払わなくてよい」という意味。 +> - **パターンマッチの網羅性**:`match` 式で全ての可能な値のパターンを書ききっているかをコンパイラがチェックしてくれる仕組み。書き漏らしがあるとコンパイルエラーになる。 +> - **キャッシュ効率**:CPUが直前に読んだメモリの近くにある値を素早く読める性質。事前に確保された連続領域(`Vec` の内部バッファ)はこの恩恵を受けやすい。 + +--- + +# 4. 実装コード + +> 💡 **初学者向け補足**:コード全体を示す前に、「このコードの大まかな構造(骨格)」を箇条書きで示します。 +> +> 1. 括弧の不正な組み合わせを表現する**カスタムエラー型**(`BracketError`)を定義する(失敗の理由を型で表現するため) +> 2. 文字列を借用(`&str`)で受け取り、`Vec` をスタックとして使う**純粋関数**(`validate_brackets`)を実装し、`Result<(), BracketError>` を返す +> 3. LeetCode形式の `Solution::is_valid` は、その結果を `.is_ok()` で `bool` に変換して返す(=内部ロジックとインターフェースを分離する設計) + +```rust +// ============================================================ +// カスタムエラー型 +// ============================================================ +// なぜこの型が必要か: +// 「どこで」「なぜ」不正だったのかを bool の true/false だけでは表現できません。 +// enum(列挙型)でエラーの種類を明示することで、将来的に +// 「エラーメッセージをユーザーに見せる」といった拡張がしやすくなります。 +// これは「try-catch で例外を投げる」言語と違い、 +// Rustでは「戻り値の型」としてエラーを表現するのが特徴です。 +#[derive(Debug, Clone, PartialEq)] +enum BracketError { + // 閉じ括弧が来たが、対応する開き括弧の種類が違った場合 + // 例: "(]" → '(' が積まれている状態で ']' が来た + Mismatched { expected: char, found: char }, + // 閉じ括弧が来たのに、対応する開き括弧が1つも積まれていない場合 + // 例: "]" だけの入力 + UnexpectedClose(char), + // 文字列を最後まで読み終えたのに、閉じられていない開き括弧が残っている場合 + // 例: "(()" → 最後に '(' が1つ余る + UnclosedOpen(char), + // 括弧以外の不正な文字が出現した場合 + InvalidCharacter(char), +} + +// Display トレイト(=人間が読める文字列に変換する仕組み)を実装しておくと、 +// 将来 println!("{}", err) のような形でエラー内容を表示できるようになる。 +impl std::fmt::Display for BracketError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Mismatched { expected, found } => { + write!(f, "'{found}' に対応する閉じ括弧は '{expected}' ではありません") + } + Self::UnexpectedClose(c) => { + write!(f, "対応する開き括弧がないまま閉じ括弧 '{c}' が出現しました") + } + Self::UnclosedOpen(c) => { + write!(f, "開き括弧 '{c}' が閉じられないまま入力が終了しました") + } + Self::InvalidCharacter(c) => { + write!(f, "不正な文字 '{c}' が入力されました") + } + } + } +} + +// std::error::Error トレイトを実装すると、他のエラー型と統一的に扱える +// (例: Box でまとめて扱う、など)。 +impl std::error::Error for BracketError {} + +// ============================================================ +// 内部ロジック(純粋関数) +// ============================================================ + +/// 開き括弧に対応する閉じ括弧を返すヘルパー関数。 +/// +/// なぜ Option を返すのか: +/// 「括弧ではない文字」が来た場合に None を返すことで、 +/// 呼び出し元がパニックせずに安全に分岐できるようにするため。 +/// null を使う言語と違い、Rustでは「値が無いかもしれない」ことを +/// 型(Option)で表現するため、呼び出し側は必ず None のケースを +/// 考慮せざるを得ません(考慮しないとコンパイルエラーになる)。 +fn closing_for(open: char) -> Option { + match open { + '(' => Some(')'), + '[' => Some(']'), + '{' => Some('}'), + _ => None, + } +} + +/// 括弧文字列が正しく対応しているかを検証する。 +/// +/// # Arguments +/// * `s` - 検証したい文字列への借用(&str)。 +/// 所有権を奪わないので、呼び出し元は検証後も s を使い続けられる。 +/// +/// # Returns +/// 正しく対応していれば `Ok(())`。 +/// +/// # Errors +/// 対応関係・順序が崩れている場合に `BracketError` を返す。 +/// +/// # Complexity +/// - Time: O(n) (文字列を1回だけ走査するため) +/// - Space: O(n) (最悪ケースで全ての文字が開き括弧の場合、スタックがnサイズになる) +fn validate_brackets(s: &str) -> Result<(), BracketError> { + // Vec を「後入れ先出し」のスタックとして使う。 + // with_capacity で最悪ケース(全部開き括弧)を見越して容量を確保し、 + // 途中での再アロケーション(メモリの取り直し)を防ぐ。 + let mut stack: Vec = Vec::with_capacity(s.len()); + + // s.chars() は &str に対するイテレータで、所有権を奪わずに + // 1文字ずつ走査できる。インデックスでのアクセス(s[i]のような書き方)は + // Rustでは許されておらず(UTF-8のバイト境界問題があるため)、 + // このイテレータ経由のアクセスが安全かつ慣用的な方法。 + for c in s.chars() { + match c { + // 開き括弧なら、対応する閉じ括弧が来るまで「積んでおく」。 + '(' | '[' | '{' => stack.push(c), + + // 閉じ括弧が来た場合の処理。 + ')' | ']' | '}' => { + // stack.pop() は Option を返す。 + // スタックが空なら None、要素があれば Some(最後に積んだ値) が返る。 + // これにより「空のスタックに対する誤ったアクセス」による + // パニック(実行時クラッシュ)を型レベルで防いでいる。 + let top = stack.pop().ok_or(BracketError::UnexpectedClose(c))?; + + // top(直前に積んだ開き括弧)に対応する閉じ括弧を計算する。 + // closing_for は Option を返すが、top は必ず開き括弧なので + // ここで None になることは論理的にありえない + // (push される値は '(' '[' '{' のいずれかだけのため)。 + let expected = closing_for(top) + .expect("スタックには開き括弧のみが積まれるため必ず対応する閉じ括弧が存在する"); + + // 期待する閉じ括弧と、実際に来た閉じ括弧が一致するか確認。 + if expected != c { + return Err(BracketError::Mismatched { expected, found: c }); + } + // 一致していれば、この開き括弧・閉じ括弧のペアは正しく閉じられた + // ことになるので、次のループへ進む。 + } + + // 括弧記号以外の文字が入力された場合は不正な文字エラーを返す。 + _ => return Err(BracketError::InvalidCharacter(c)), + } + } + + // 最後まで走査し終えた時点で、スタックに何か残っていれば + // 「閉じられていない開き括弧がある」ということなので不正。 + // stack.last() は Option<&char> を返すので、コピーして値として扱う。 + if let Some(&unclosed) = stack.last() { + return Err(BracketError::UnclosedOpen(unclosed)); + } + + Ok(()) +} + +// ============================================================ +// LeetCode形式のインターフェース +// ============================================================ + +struct Solution; + +impl Solution { + /// 括弧文字列が有効かどうかを判定する。 + /// + /// LeetCodeのシグネチャ上 `s: String`(所有権を持つ文字列)を受け取るが、 + /// 内部の検証処理は読み取りだけで完結するため、`&s` として借用を渡す。 + /// これにより validate_brackets 側で無駄な文字列コピーが発生しない。 + /// + /// # Complexity + /// - Time: O(n) + /// - Space: O(n) + pub fn is_valid(s: String) -> bool { + // Result<(), BracketError> を bool に変換するだけの薄いラッパー。 + // is_ok() は Ok(_) なら true、Err(_) なら false を返す。 + validate_brackets(&s).is_ok() + } +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> 入力例 `s = "([)]"`(要件2に違反する不正な例)でトレースします。 +> +> ``` +> 初期状態: stack = [] +> Step 1: c='(' → 開き括弧なので push → stack = ['('] +> Step 2: c='[' → 開き括弧なので push → stack = ['(', '['] +> Step 3: c=')' → 閉じ括弧。stack.pop() → Some('[') が top になる +> closing_for('[') = Some(']') +> expected=']' だが found=')' → 一致しない! +> → Err(BracketError::Mismatched { expected: ']', found: ')' }) を返す +> 結果: is_valid("([)]") = false ✅(期待通り) +> ``` +> +> 正常系の例 `s = "([])"` も見てみましょう。 +> +> ``` +> 初期状態: stack = [] +> Step 1: c='(' → push → stack = ['('] +> Step 2: c='[' → push → stack = ['(', '['] +> Step 3: c=']' → pop() → Some('[')、closing_for('[')=']'、一致 → OK → stack = ['('] +> Step 4: c=')' → pop() → Some('(')、closing_for('(')=')'、一致 → OK → stack = [] +> ループ終了後: stack.last() = None → 未閉じの括弧なし +> 結果: is_valid("([])") = true ✅(期待通り) +> ``` + +> 💡 **`?` 演算子の動きを図で理解する**(初学者向け) +> `stack.pop().ok_or(BracketError::UnexpectedClose(c))?` の `?` は、以下の処理と等価です: +> +> ``` +> pop() が None を返した場合 +> → ok_or(...) が Err(BracketError::UnexpectedClose(c)) に変換 +> → ? がその Err をそのまま関数の戻り値として即座に返す(関数を抜ける) +> pop() が Some(x) を返した場合 +> → ok_or(...) が Ok(x) に変換 +> → ? が x を取り出して、変数 top に代入し、処理を続ける +> ``` +> +> これにより、`match` でいちいち分岐を書かなくても、エラーの伝播(=呼び出し元に自動的にエラーを渡すこと)が1行で書けます。`try-catch` を持つ言語との違いは、**エラーが「例外」という特別な制御フローではなく、戻り値の型として明示される**点です。関数のシグネチャ(`Result<(), BracketError>`)を見ただけで「この関数は失敗しうる」ことが一目で分かります。 + +> 📖 **このセクションで登場した用語** +> +> - **`&str` と `String`**:`String` は所有権を持つ・伸縮可能な文字列。`&str` はその文字列(の一部)への借用(参照)。関数が読み取りしかしないなら `&str` を受け取るのがRustの慣習。 +> - **`Option`**:値が「あるかもしれないし、無いかもしれない」ことを表す型。`Some(値)` か `None` のどちらか。他言語の `null` と違い、中身を使う前に必ず「本当に値があるか」をコンパイラがチェックさせる。 +> - **`ok_or()`**:`Option` を `Result` に変換するメソッド。`None` だった場合に使うエラー値を指定できる。 +> - **`?` 演算子**:`Result` や `Option` がエラー・`None` だったとき、自動的に呼び出し元へ返す糖衣構文(=書き方を簡潔にするための構文糖)。 +> - **`unreachable!`**:「論理的に絶対に到達しないはず」の分岐に置くマクロ。もし実際に到達してしまった場合はパニック(プログラムの異常終了)してバグの存在を知らせる。 +> - **`expect("理由")`**:`Option`/`Result` の中身を取り出すが、もし失敗ケースだった場合は指定したメッセージ付きでパニックする。「なぜここでは失敗しないと確信しているか」を書き残すためのドキュメントも兼ねる。 diff --git a/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/Valid_Parentheses_Typescript.md b/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/Valid_Parentheses_Typescript.md new file mode 100644 index 00000000..77d71fac --- /dev/null +++ b/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/Valid_Parentheses_Typescript.md @@ -0,0 +1,344 @@ +# 1. 問題の分析 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「**開き括弧と閉じ括弧の"組み合わせ"と"順番"が正しいかを、後入れ先出し(=最後に置いたものを最初に取り出す)のルールで判定する問題**」です。 + +- **競技プログラミング視点での分析** + - 実行速度を最優先とした場合、文字列を1回だけ走査する O(n) のアプローチが最速です。文字を1回ずつ見る以上、これより速くすることは理論上不可能です。 + - メモリ使用量の最小化方針としては、配列(`string[]`)をスタック(後入れ先出し構造)として使うのがシンプルです。TypeScript(=JavaScriptに型を付けたプログラミング言語)の配列は動的にサイズが変わるため、事前に容量を確保する仕組みはありませんが、`push`/`pop`はどちらもO(1)の操作なので気にする必要はありません。 +- **業務開発視点での分析** + - 型安全性・保守性を重視するなら、「開き括弧」「閉じ括弧」という概念を生の`string`型のまま扱うのではなく、意味のある型(**ユニオン型**=複数の型のいずれか1つであることを表す型)で表現することで、「間違った文字を比較してしまう」バグをコンパイル時に防げます。 + - エラーハンドリングとしては、`isValid`自体はLeetCode形式で`boolean`を返す必要がありますが、内部的には「なぜ不正なのか」を判別できる設計にしておくと、将来的にエラー内容をユーザーに見せるUIなどに拡張しやすくなります。 +- **TypeScript特有の考慮点** + - **型推論**(=型を明示しなくてもTypeScriptが自動で型を判断してくれる機能)を活用し、変数の型注釈を最小限にして可読性を保ちます。 + - **ジェネリクス**は今回不要です。この問題は`string`型に固定された処理であり、無理にジェネリック化すると逆に可読性が落ちます。 + - **型ガード**(=実行時に値の型を絞り込むための判定処理。`typeof`や`Array.isArray`などで行う)を使い、`unknown`型の入力が来ても安全に扱えるようにします。ただしLeetCode形式では引数の型が`string`で固定されているため、今回は主に「文字が期待した種類の記号か」を判定する用途で使います。 + +> 📖 **このセクションで登場した用語**(例) +> +> - **後入れ先出し(LIFO)**:最後に追加した要素を最初に取り出すデータ構造の性質。今回の「括弧の対応関係」の判定にぴったり合う。 +> - **ユニオン型**:`'(' | '[' | '{'`のように「複数の型のうちいずれか1つ」であることを表すTypeScriptの型。 +> - **型推論**:型を明示しなくてもTypeScriptが自動で型を判断してくれる機能。 +> - **型ガード**:実行時に値の型を絞り込むための判定処理。`typeof x === 'string'`のような形で書く。 + +--- + +# 2. アルゴリズムアプローチ比較 + +> 💡 **初学者向け補足**:同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」を比べることで、なぜ特定の方法を選ぶべきかが明確になります。特にこの問題は、**一見メモリ効率が良さそうでも実は不正解になるアプローチ**があるため、比較が重要です。 + +| アプローチ | 時間計算量 | 空間計算量 | TS実装コスト | 型安全性 | 可読性 | 備考 | +| ---------- | ---------- | ---------- | ------------ | -------- | ------ | ---------------------------------------------------------- | +| 方法A | O(n) | O(n) | 低 | 高 | 高 | `string[]`をスタックとして使う(採用) | +| 方法B | O(n) | O(1) | 低 | 中 | 中 | 括弧の**種類ごとの出現数**を数えるだけ(誤ったアプローチ) | +| 方法C | O(n²) | O(n) | 中 | 中 | 中 | `"()" "[]" "{}"`を空文字に繰り返し置換 | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n)`:入力が2倍になると、処理も約2倍になる(線形) +> - `O(n²)`:入力が2倍になると、処理は約4倍になる(二重ループや繰り返しスキャンに多い) + +**補足(各アプローチの詳細)**: + +- **方法A**:`push`/`pop`はどちらも配列の末尾に対する操作でO(1)です。文字列を1回だけ走査するのでトータルO(n)。型安全性の面でも、スタックの中身を`('(' | '[' | '{')[]`のような限定した型にすることで、誤った値が紛れ込むのをコンパイル時に防げます。 +- **方法B**:括弧の「数」だけを数えるので空間計算量はO(1)です。しかし`"([)]"`のように**数は合っているが順序が壊れている**入力を`true`と誤判定してしまうため、要件2「正しい順序で閉じる」を満たせず**不正解**です(比較のためにあえて載せています)。 +- **方法C**:`String.prototype.replace()`は呼び出すたびに**新しい文字列**を生成します。JavaScript/TypeScriptの文字列はイミュータブル(=一度作られたら中身を変更できない性質)なので、置換のたびにメモリ上に新しい文字列が作られます。これを空になるまで繰り返すため、最悪ケースでO(n²)になり、メモリ効率も悪化します。 + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して、処理にかかる手間がどう増えるかの目安 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安 +> - **イミュータブル**:一度作られたら中身を変更できない性質。TypeScript/JavaScriptの文字列(`string`)はこの性質を持つ + +--- + +# 3. 選択したアルゴリズムと理由 + +> 💡 **初学者向け補足**:「なぜ方法Aを選び、方法B・Cを選ばなかったのか」を対比形式で説明します。 + +- **選択したアプローチ**: 方法A(`string[]`をスタックとして使う線形走査) +- **理由**: + - **計算量的な優位性**:方法Cの O(n²) と違い、方法Aは文字列を1回だけ走査する O(n) で完結します。 + - **方法Bを選ばなかった理由**:方法Bは空間計算量こそO(1)と優秀に見えますが、括弧が閉じられる**順序**を一切見ていないため、`"([)]"`のような不正な入力を`true`と誤判定してしまい、問題の要件を満たしていません。速さより先に「正しさ」が必須です。 + - **TypeScript環境での型安全性**:スタックの型を`('(' | '[' | '{')[]`のように限定することで、「本来入るはずのない値がスタックに紛れ込む」というバグをコンパイル時に検出できます。`Map`で対応表を持つ設計にすれば、`readonly`修飾子と組み合わせて「実行中に対応表が書き換えられてしまう」事故も防げます。 +- **TypeScript特有の最適化ポイント**: + - **コンパイル時の型チェックによるエラー防止**:括弧の対応表を`Readonly>`型で定義することで、対応表への意図しない代入をコンパイルエラーとして検出できます。 + - **型推論による開発効率向上**:`const stack: string[] = []`のように必要な箇所だけ型注釈を書き、あとはTypeScriptの型推論に任せることでコードがすっきりします。 + - **`readonly`の活用**:入力の`string`自体はもともとイミュータブルですが、対応表などの補助データ構造に`readonly`を付けることで「意図せぬ書き換え」を防止します。 + +> 📖 **このセクションで登場した用語** +> +> - **型安全性**:プログラムの実行前(コンパイル時)に、型の不一致によるバグを検出できる性質 +> - **`Readonly`**:オブジェクトの全プロパティを読み取り専用にするTypeScriptのユーティリティ型 +> - **`Record`**:キーの型`K`と値の型`V`を指定してオブジェクトの型を表現するユーティリティ型 + +--- + +# 4. 実装コード + +> 💡 **初学者向け補足**:コード全体を示す前に、「このコードの大まかな構造(骨格)」を箇条書きで示します。 +> +> 1. 括弧の不正な組み合わせを表現する**カスタムエラークラス**(`BracketError`)を定義する +> 2. 開き括弧と閉じ括弧の対応表を`Readonly>`型で定義する +> 3. 入力文字列を1文字ずつ走査し、スタックを使って対応関係と順序を検証する`validateBrackets`関数を実装する(型レベルでのエラー表現として、成功時と失敗時を区別できる戻り値にする) +> 4. LeetCode提出用の`isValid`は、その結果を`boolean`に変換して返す薄いラッパーにする + +```typescript +// ============================================================ +// カスタムエラークラス +// ============================================================ +// なぜこのクラスが必要か: +// boolean の true/false だけでは「どこで」「なぜ」不正だったのかを表現できない。 +// Error を継承したクラスを作ることで、将来的にエラーメッセージを +// ログ出力やUI表示に転用しやすくなる。 +class BracketError extends Error { + // readonly を付けることで、生成後にこれらのプロパティが + // 書き換えられてしまう事故を防ぐ(意図しない代入をコンパイルエラーにする) + constructor( + message: string, + public readonly char: string, + public readonly position: number, + ) { + super(message); + // クラス名を正しく設定する(Errorを継承する際のTypeScript/JSの定石) + this.name = 'BracketError'; + } +} + +// ============================================================ +// 対応表の定義 +// ============================================================ +// なぜ Readonly> を使うのか: +// この対応表は「一度定義したら変更されるべきではない」データ。 +// readonly を付けることで、うっかり CLOSING_BRACKETS.foo = 'x' のような +// 代入をしてしまった場合にコンパイルエラーとして検出できる。 +// Map ではなくオブジェクトリテラルを使う理由: +// キーが3種類だけの固定集合であり、オブジェクトの方が可読性が高いため。 +type OpeningBracket = '(' | '[' | '{'; +type ClosingBracket = ')' | ']' | '}'; + +const CLOSING_BRACKETS: Readonly> = { + '(': ')', + '[': ']', + '{': '}', +}; + +const CLOSING_BRACKET_LIST: readonly ClosingBracket[] = [')', ']', '}']; + +// 「開き括弧かどうか」を判定するための型ガード関数。 +// 戻り値の型を `char is OpeningBracket` にすることで、 +// この関数が true を返した後、TypeScriptが char の型を +// 自動的に絞り込んでくれる(型の絞り込み=Type Narrowing)。 +function isOpeningBracket(char: string): char is OpeningBracket { + // in 演算子ではなく hasOwnProperty を使い、 + // プロトタイプ鎖の継承プロパティ(toString等)の誤判定を防ぐ + return Object.prototype.hasOwnProperty.call(CLOSING_BRACKETS, char); +} + +// 「閉じ括弧かどうか」を判定する型ガード関数。 +function isClosingBracket(char: string): char is ClosingBracket { + return (CLOSING_BRACKET_LIST as readonly string[]).includes(char); +} + +// ============================================================ +// 検証結果を表す型(型レベルでのエラー表現) +// ============================================================ +// なぜ union 型で結果を表現するのか: +// try-catch で例外を投げる代わりに、「成功か失敗か」を型で表現することで、 +// 呼び出し元が結果を必ず確認せざるを得ない設計にできる。 +// これは Rust の Result や Go の (value, error) の考え方に近い。 +type ValidationResult = + | { readonly ok: true } + | { readonly ok: false; readonly error: BracketError }; + +// ============================================================ +// メインロジック(純粋関数) +// ============================================================ + +/** + * 括弧文字列が正しく対応しているかを検証する。 + * + * @param s - 検証したい文字列 + * @returns 検証結果を表すオブジェクト(成功: {ok: true}、失敗: {ok: false, error}) + * @complexity Time: O(n), Space: O(n) + */ +function validateBrackets(s: string): ValidationResult { + // OpeningBracket[] を「後入れ先出し」のスタックとして使う。 + // TypeScriptの配列は push/pop がどちらもO(1)であり、 + // Rustの Vec::with_capacity のような事前確保の仕組みはないが、 + // V8エンジン(Node.jsが使うJavaScriptエンジン)が内部的に + // 効率よくメモリを管理してくれるため、通常は気にしなくてよい。 + const stack: OpeningBracket[] = []; + + // for...of ループで1文字ずつ走査する。 + // index が必要な場合は entries() を使うことで、 + // 文字とインデックスの両方を安全に取得できる。 + for (const [position, char] of [...s].entries()) { + if (isOpeningBracket(char)) { + // 開き括弧なら、対応する閉じ括弧が来るまでスタックに積んでおく + stack.push(char); + continue; + } + + if (isClosingBracket(char)) { + // stack.pop() は string | undefined を返す。 + // TypeScriptの配列の pop() は「空かもしれない」ことを + // 型レベルで表現しており(strictモード時)、 + // undefined チェックを省略するとコンパイルエラーになる。 + // これは null を使う言語と違い、「値が無いかもしれない」ことを + // 型システムが強制的に意識させてくれる仕組み。 + const top = stack.pop(); + + if (top === undefined) { + // 対応する開き括弧が存在しないまま閉じ括弧が来た場合 + return { + ok: false, + error: new BracketError( + `対応する開き括弧がないまま閉じ括弧が出現しました`, + char, + position, + ), + }; + } + + // top(開き括弧)に対応する閉じ括弧を対応表から引く。 + // isOpeningBracket の型ガードにより、top は必ず + // CLOSING_BRACKETS のキーであることが保証されているため、 + // 非nullアサーション(!)を使わずとも安全にアクセスできる。 + const expected = CLOSING_BRACKETS[top]; + + if (expected !== char) { + // 期待する閉じ括弧と実際に来た文字が異なる場合 + return { + ok: false, + error: new BracketError( + `'${top}' に対応する閉じ括弧は '${expected}' ですが '${char}' が見つかりました`, + char, + position, + ), + }; + } + // 一致していれば、このペアは正しく閉じられたので次のループへ進む + continue; + } + + // 制約により括弧文字以外は入力されない前提だが、 + // 防御的プログラミング(=想定外の入力にも安全に対応する設計方針)として + // 明示的にエラーを返し、意図しない挙動を防ぐ。 + return { + ok: false, + error: new BracketError(`括弧以外の文字が含まれています`, char, position), + }; + } + + // 走査し終えた時点でスタックに何か残っていれば、 + // 閉じられていない開き括弧があるということなので不正。 + if (stack.length > 0) { + // スタックの末尾(最後に残った未閉じの開き括弧)を取得する。 + // ここでの ?? は「もし undefined だったら」の場合の + // フォールバック値を指定する Nullish coalescing 演算子だが、 + // stack.length > 0 が保証されているため実際には発生しない。 + const unclosed = stack[stack.length - 1] ?? ''; + return { + ok: false, + error: new BracketError( + `開き括弧 '${unclosed}' が閉じられないまま入力が終了しました`, + unclosed, + s.length, + ), + }; + } + + return { ok: true }; +} + +// ============================================================ +// LeetCode提出用のエントリポイント +// ============================================================ + +/** + * 括弧文字列が有効かどうかを判定する。 + * + * @param s - 判定対象の文字列('(' ')' '[' ']' '{' '}' のみを含む) + * @returns 有効なら true、そうでなければ false + * @complexity Time: O(n), Space: O(n) + */ +function isValid(s: string): boolean { + // ValidationResult の ok プロパティだけを見て boolean に変換する + // 薄いラッパー。内部の詳細なエラー情報はここでは使わないが、 + // 将来ログ出力や詳細なエラーメッセージ表示が必要になった場合、 + // validateBrackets を直接呼び出すことで拡張できる設計にしてある。 + return validateBrackets(s).ok; +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> 入力例 `s = "([)]"`(要件2に違反する不正な例)でトレースします。 +> +> ``` +> 初期状態: stack = [] +> Step 1: position=0, char='(' → isOpeningBracket('(')=true → push → stack=['('] +> Step 2: position=1, char='[' → isOpeningBracket('[')=true → push → stack=['(', '['] +> Step 3: position=2, char=')' → isClosingBracket(')')=true +> stack.pop() → top='[' 、stack=['('] +> CLOSING_BRACKETS['['] = ']' +> expected=']' だが char=')' → 不一致! +> → { ok: false, error: BracketError(...) } を返す +> isValid の戻り値: false ✅(期待通り) +> ``` +> +> 正常系の例 `s = "([])"` も見てみましょう。 +> +> ``` +> 初期状態: stack = [] +> Step 1: '(' → push → stack=['('] +> Step 2: '[' → push → stack=['(', '['] +> Step 3: ']' → pop() → top='['、expected=']'、char=']' → 一致 → stack=['('] +> Step 4: ')' → pop() → top='('、expected=')'、char=')' → 一致 → stack=[] +> ループ終了後: stack.length === 0 → 未閉じの括弧なし +> isValid の戻り値: true ✅(期待通り) +> ``` + +> 💡 **型ガードによる型の絞り込み(Type Narrowing)を理解する**(初学者向け) +> +> **基礎**:TypeScriptでは、`if`文の中で特定の判定を行うと、その後の処理でTypeScriptが変数の型を自動的に絞り込んでくれます。 +> +> ```typescript +> function example(value: string | number) { +> if (typeof value === 'string') { +> // ここでは TypeScript が value を string 型だと認識してくれる +> console.log(value.toUpperCase()); +> } +> } +> ``` +> +> **応用**:自作の関数でも、戻り値の型に`param is Type`という特殊な書き方(**型述語**=関数が特定の型であることを保証する構文)をすることで、同じ絞り込みの恩恵を受けられます。 +> +> **この問題での使い方**:`isOpeningBracket(char): char is '(' | '[' | '{'`と書くことで、`if (isOpeningBracket(char))`の中では、TypeScriptが`char`を`'(' | '[' | '{'`型として扱ってくれます。これにより、`stack.push(char)`のように、スタックの型と矛盾しない安全な代入ができます。 + +> 📖 **このセクションで登場した用語** +> +> - **型ガード**:実行時の判定によって、その後のコードでTypeScriptに変数の型を絞り込ませる仕組み。`typeof`や独自関数(`is`構文)で実現できる。 +> - **型述語(Type Predicate)**:`param is Type`という戻り値の型注釈。関数がその型であることを保証し、呼び出し元での型の絞り込みを可能にする。 +> - **Type Narrowing(型の絞り込み)**:条件分岐によって、変数の型がより具体的な型に絞り込まれるTypeScriptの機能。 +> - **Nullish coalescing演算子(`??`)**:左辺が`null`または`undefined`の場合にのみ右辺の値を使う演算子。`||`と違い、`0`や`''`のような falsy な値では右辺に切り替わらない。 +> - **防御的プログラミング**:想定外の入力やエラーが来ても、プログラムが安全に振る舞うように設計する方針。 + +--- + +# 5. 検証 + +_テストコードは不要のため、ここではエッジケースの説明のみ行います。_ + +> 💡 **初学者向け補足**:エッジケースとは「入力が空・最小値・最大値・重複あり」など境界的な入力のことです。 + +- **単一の閉じ括弧(例:`")"`)の場合**:`stack.pop()`が`undefined`を返すため、`top === undefined`の分岐で安全に`{ ok: false, ... }`を返します。TypeScriptの`strict`モードでは、`pop()`の戻り値が`string | undefined`型として扱われるため、このチェックを省略するとコンパイルエラーになります。これにより「配列が空なのに要素を取り出そうとする」バグをコンパイル時に検出できる設計になっています。 +- **未閉じの開き括弧(例:`"((("`)の場合**:ループ終了後も`stack.length > 0`となるため、最後のチェックで不正と判定されます。 +- **括弧以外の文字が混入した場合**:制約上発生しませんが、`isOpeningBracket`にも`isClosingBracket`にも該当しない文字が来た場合、`default`相当の分岐で明示的にエラーを返すため、想定外の挙動(無限ループや`undefined`の伝播)を防げます。 +- **サロゲートペア文字を含む場合の注意**:この問題では発生しませんが、`[...s].entries()`のようにスプレッド構文で文字列を展開すると、絵文字などの**サロゲートペア**(=2つのUTF-16コードユニットで1文字を表す仕組み)も正しく1文字として扱えます。単純な`for (let i = 0; i < s.length; i++)`によるインデックスアクセスだと、サロゲートペアが分断されてしまう可能性があるため、今回のように`for...of`や`entries()`を使うのがTypeScript/JavaScriptでの安全な慣習です。 + +> 📖 **このセクションで登場した用語** +> +> - **`strict`モード**:TypeScriptコンパイラの厳格な型チェックを有効にする設定。`null`/`undefined`の扱いなどがより厳密になる。 +> - **サロゲートペア**:UTF-16で1つの文字を2つのコードユニットで表現する仕組み。絵文字など一部の文字で使われる。 +> - **スプレッド構文(`...`)**:配列や文字列の要素を展開する構文。`[...s]`で文字列を1文字ずつの配列に変換できる。 diff --git a/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/Longest_Common_Prefix_Go.md b/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/Longest_Common_Prefix_Go.md new file mode 100644 index 00000000..369d4742 --- /dev/null +++ b/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/Longest_Common_Prefix_Go.md @@ -0,0 +1,356 @@ +# 1. 問題分析結果 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「複数の文字列を先頭から見比べて、全員が同じ文字を並べている部分(接頭辞)だけを取り出す問題」です。 + +**Goで解く際に特に気をつけるべき点**を1段落でまとめます。この問題は文字列の比較だけで完結する典型的なCPU集約型(=ネットワークやディスクの待ち時間ではなく、計算そのものに時間がかかる処理)のタスクであり、`goroutine`(ゴルーチン)や`channel`を導入するメリットはほぼありません。むしろ、少数の文字列比較にゴルーチンを起動すると、起動・同期のオーバーヘッドの方が本処理より高くつく可能性があります。またGoの文字列(`string`)はイミュータブル(=一度作ったら中身を変更できない)なので、`prefix`を段階的に短くする処理では`prefix = prefix[:len(prefix)-1]`のようなスライス式(元のバイト列を指すだけで新しいコピーを作らない)を使うことで、余計なメモリアロケーションを避けられます。エラーハンドリング設計としては、Goの慣習に従い`error`を戻り値として返す業務開発版と、LeetCode提出用にエラー処理を省いた競技プログラミング版の2つを用意します。 + +#### 競技プログラミング視点 + +- **制約分析**: `1 <= strs.length <= 200`、`0 <= strs[i].length <= 200`なので、最大でも合計4万文字程度。どんな素朴な方法でも一瞬で終わる規模。 +- **最速手法**: 標準ライブラリの追加importなしで、`string`のスライス式(`s[:i]`)だけで実装できる水平走査が最もシンプルかつ高速。 +- **メモリ最小化**: Goの文字列スライス式(`s[:i]`)は元のバイト列を指すだけの参照であり、新しいバイト列をコピーしないため、追加アロケーションがほぼ発生しない。 +- **Go最適化**: `range`によるバイト単位のループを使い、余計な変換(`[]rune`への変換など)を避ける。この問題は「小文字の英字のみ」という制約があるため、マルチバイト文字(1バイトを超える文字)を気にする必要がなく、素朴な`byte`比較で十分。 + +#### 業務開発視点 + +- **型安全設計**: 明示的な型定義。引数を`[]string`とし、戻り値は常に`string`で統一する。 +- **エラーハンドリング**: `error`戻り値を使い、`nil`スライスや空要素の混入といった異常系を呼び出し元が`if err != nil`で確認できる設計にする。 +- **可読性**: `validateInput` → `isEdgeCase` / `handleEdgeCase` → `mainAlgorithm`という段階分けで、godocコメントと日本語コメントを丁寧に付ける。 + +#### Go特有分析 + +- **データ構造選択**: `[]string`(スライス)のみで完結。`map`や`channel`は不要。 +- **標準ライブラリ活用度**: `strings`パッケージの`HasPrefix`を使うことで、C言語相当の低レベル最適化がされた比較処理を活用できる。 +- **並行処理適性**: 不適。前述の通り、入力規模が小さくゴルーチン起動コストの方が高くつく。 +- **エスケープ解析**: `prefix`はスライス式で作られるため、追加のヒープアロケーションはほぼ発生しない。戻り値として関数外に返るため、コンパイラの判断によっては元の文字列の裏付けバイト列がヒープに留まる可能性があるが、これは`string`が元々イミュータブルであるためGCの負担にはならない。 + +> 📖 **このセクションで登場した用語** +> +> - **エスケープ解析**:変数をスタック(高速・自動解放)に置くかヒープ(低速・GC管理)に置くかをコンパイラが判断する仕組み。 +> - **アロケーション**:ヒープ上にメモリを新たに確保する操作。頻繁に行うとGCが頻発して処理が遅くなる。 +> - **制約分析**:入力サイズの上限から「どのくらいの計算量まで許容されるか」を逆算すること。 +> - **CPU集約型**:ネットワークやディスクの待ち時間ではなく、計算そのものに時間がかかる処理のこと。 + +--- + +# 2. アルゴリズム比較表 + +> 💡 **初学者向け補足**:同じ問題でも解き方は複数あります。それぞれの「速さ」「メモリの使い方」「Goらしさ」を比較して、この問題の制約規模(最大200個・各200文字)にちょうど良い方法を選びます。 +> 「Go実装コスト」列は、「スライスの再アロケーションが起きるか」「不要なヒープ逃げが発生するか」という観点で評価しています。 + +| 記法 | 意味 | 直感的イメージ | +| ---------- | ------------------------------------- | ---------------------- | +| `O(1)` | 入力サイズによらず一定 | 辞書の直引き | +| `O(S)` | 全文字数Sに比例して増加 | 文字列を端から順に読む | +| `O(log n)` | 入力が2倍になってもわずかしか増えない | 再帰の深さなどに多い | + +| アプローチ | 時間計算量 | 空間計算量 | Go実装コスト | 可読性 | 標準ライブラリ活用 | 並行処理適性 | 備考 | +| ---------------------------------------------------- | ---------- | ---------- | ------------ | ------ | ------------------ | -------------- | ------------------------------------------ | +| A: 水平走査(`strings.HasPrefix`で縮小) | O(S) | O(1) | 低 | ★★★ | `strings` | 不適 | 再アロケーションなし、スライス式のみで完結 | +| B: 垂直走査(バイトごとに全文字列を比較) | O(S) | O(1) | 低 | ★★☆ | なし | 不適 | 二重ループでインデックス管理がやや煩雑 | +| C: 分割統治法(`goroutine`で並列に部分接頭辞を計算) | O(S) | O(log n) | 高 | ★☆☆ | `sync` | 適だが過剰装備 | 起動・同期コストが本処理を上回る規模 | + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して処理にかかる手間がどう増えるかの目安。 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安。 +> - **再アロケーション**:スライスの容量が足りなくなったとき、より大きいメモリ領域に全要素をコピーする操作。 + +--- + +# 3. Go特有最適化ポイント + +#### Goコンパイラ最適化 + +- **組み込み関数活用**: `len()`で文字列の長さを取得(O(1)。Goの文字列はヘッダに長さ情報を持つため)。 +- **スライス操作**: `prefix[:i]`のようなスライス式で、新しいバイト列をコピーせず「範囲」だけを取り出す。 +- **標準ライブラリ**: `strings.HasPrefix`はGoランタイム内部で最適化された比較処理。 + +```go +// 最適化前:バイトを1つずつ手動で比較する(間違いではないが、strings パッケージの方が読みやすい) +match := true +for i := 0; i < len(prefix); i++ { + if current[i] != prefix[i] { + match = false + break + } +} + +// 最適化後:strings.HasPrefix に処理を委譲する +match := strings.HasPrefix(current, prefix) +// 理由:標準ライブラリの実装は十分に最適化されており、 +// 自前ループより可読性が高く、バグも入りにくい +``` + +#### データ構造選択指針 + +| 状況 | 選ぶべき構造 | 理由 | +| ------------------------------ | --------------------------------- | ------------------------------------------------------- | +| 文字列の集合を順番に走査したい | スライス `[]string` | 連続メモリでキャッシュ効率が高く、`range`で簡潔に書ける | +| 共通接頭辞を段階的に縮める | `string`のスライス式 `prefix[:i]` | 新しいコピーを作らず範囲だけを参照するため高速 | + +#### メモリ最適化 + +- **プリアロケーション**: この問題では出力が1つの`string`のみなので、`make`によるプリアロケーションは不要。 +- **エスケープ解析**: `prefix`はローカル変数だが戻り値として返るため、Goコンパイラの判断で裏付けバイト列がヒープに留まる可能性がある。ただしこれは`string`の性質上避けられず、パフォーマンスへの影響は無視できるレベル。 +- **in-place操作**: `prefix = prefix[:i]`は新しいバイト列を作らず、既存のバイト列の「見る範囲」だけを変更するin-place的な操作(=新しいスライスを作らず、元のスライスを直接書き換える操作。アロケーションを減らせる)。 + +#### 並行処理最適化 + +**なぜ並行処理が必要か**:この問題では並行化するメリットがありません。入力は最大200個・各200文字程度で、シングルスレッドでもマイクロ秒単位で終わる処理です。ゴルーチンを起動するとM:Nスケジューリング(=M個のゴルーチンをN個のOSスレッドに割り当てて実行する仕組み。Goランタイムが自動で管理する)のオーバーヘッドの方が本処理の時間を上回ってしまいます。したがって、今回の実装では`goroutine`・`channel`・`sync`パッケージは使用しません。 + +> 📖 **このセクションで登場した用語** +> +> - **in-place操作**:新しいスライスを作らず、元のスライスを直接書き換える操作。アロケーションを減らせる。 +> - **M:Nスケジューリング**:M個のゴルーチンをN個のOSスレッドに割り当てて実行する仕組み。Goランタイムが自動で管理する。 + +--- + +# 4. 実装テンプレート(LeetCode提出用) + +**このコードの大まかな構造(骨格)** + +1. `Solution`構造体を定義する(Goにはクラスがないため`struct`で代替) +2. `validateInput`で入力の型・範囲を検証し、問題があれば`error`を返す +3. エッジケース(要素数1つ)を先に処理する +4. 先頭の文字列を候補として、残りの文字列と`strings.HasPrefix`で比較しながら縮めていく(水平走査) +5. 競技プログラミング版では検証・エラーハンドリングを省略する + +【業務開発版を使う場面】 +チームで長期間メンテナンスするプロダクションコードに向きます。エラーを戻り値で返し、呼び出し元が必ず確認できる設計になっています。`go vet`/`golangci-lint`を通じてコード品質を機械的に担保できます。 + +```go +package main + +import ( + "errors" + "fmt" + "strings" +) + +// Solution は LeetCode 形式のメソッドをまとめる構造体。 +// Goにはクラスがないため、フィールドを持たない struct とメソッドの組み合わせで代替する。 +type Solution struct{} + +// LongestCommonPrefix は業務開発向け実装(型安全・エラーハンドリング重視)。 +// +// なぜこの実装を選んだか: +// 先頭の文字列を「共通接頭辞の候補」として仮に置き、 +// 残りの文字列を1つずつ strings.HasPrefix で確認する水平走査を採用した。 +// 垂直走査(バイトごとの二重ループ)と比べて、標準ライブラリに処理を委譲できるため +// 可読性が高く、Goのイディオム(慣用的な書き方)にも合致する。 +// +// Args: +// - strs: 共通接頭辞を調べたい文字列のスライス(nil や空も許可し、空スライスはエラーとする) +// +// Returns: +// - 全文字列に共通する最長の接頭辞。共通部分が無ければ空文字列。 +// +// Time Complexity: O(S) S = 全文字列の合計バイト数 +// Space Complexity: O(1) ※戻り値の文字列を除く +func (s *Solution) LongestCommonPrefix(strs []string) (string, error) { + // ① 入力検証:不正な入力を早期に弾く。 + // なぜ最初に検証するか:後続処理で strs[0] にアクセスする際、 + // 空スライスだとインデックス範囲外でパニックが起きるため、事前に弾く。 + if err := s.validateInput(strs); err != nil { + return "", err + } + + // ② エッジケース処理:要素が1つだけならそれ自体が答え。 + if s.isEdgeCase(strs) { + return s.handleEdgeCase(strs), nil + } + + // ③ メインアルゴリズム(水平走査)を呼び出す。 + return s.mainAlgorithm(strs), nil +} + +// LongestCommonPrefixCompetitive は競技プログラミング向け最適化実装。 +// エラーハンドリングを省略し、実行速度を最優先する。 +// +// Time Complexity: O(S) +// Space Complexity: O(1) +func (s *Solution) LongestCommonPrefixCompetitive(strs []string) string { + // 空スライスなら即座に空文字列を返す(検証省略)。 + if len(strs) == 0 { + return "" + } + + // 先頭の文字列を共通接頭辞の候補として仮置きする。 + prefix := strs[0] + + // 2番目以降の文字列を順番にチェックする。 + // 3部構成の for ループ(初期化・条件・更新)を使い、インデックス i で strs[i] にアクセスする。 + for i := 1; i < len(strs); i++ { + current := strs[i] + + // strings.HasPrefix は「current が prefix で始まっているか」を判定する標準ライブラリ関数。 + // prefix が current の先頭と一致しなくなるまで、1文字ずつ候補を短くする。 + for !strings.HasPrefix(current, prefix) { + // prefix[:len(prefix)-1] は末尾の1文字を除いた新しい範囲を指すスライス式。 + // Go の string はイミュータブルなので「短くする」とは + // 実際には「短い範囲を指す新しいヘッダを prefix に代入し直す」ことを意味する。 + // 元のバイト列自体はコピーされないため、このスライス式自体はO(1)。 + prefix = prefix[:len(prefix)-1] + + // 候補が空文字列になった時点で、これ以上共通部分は無いと確定するので即座に返す。 + if prefix == "" { + return "" + } + } + } + + // ループを最後まで抜けた= prefix が全文字列に共通する最長の接頭辞。 + return prefix +} + +// validateInput は入力の nil・空・要素の妥当性を検証する。 +// 小文字始まりなので非エクスポート(このパッケージ内だけで使える)。 +func (s *Solution) validateInput(strs []string) error { + // strs が nil または長さ0の場合、意味のある処理ができないため error を返す。 + if len(strs) == 0 { + return errors.New("strs must contain at least one string") + } + // LeetCode の制約(strs.length <= 200)を超えていないかも防御的に確認する。 + if len(strs) > 200 { + return fmt.Errorf("strs length %d exceeds constraint of 200", len(strs)) + } + // 各要素の長さが制約(200文字以下)を超えていないか確認する。 + for i, str := range strs { + if len(str) > 200 { + return fmt.Errorf("strs[%d] length %d exceeds constraint of 200", i, len(str)) + } + } + return nil +} + +// isEdgeCase はエッジケース(要素数1つ)かどうかを判定する。 +func (s *Solution) isEdgeCase(strs []string) bool { + return len(strs) == 1 +} + +// handleEdgeCase はエッジケースの結果を返す。唯一の文字列がそのまま答えになる。 +func (s *Solution) handleEdgeCase(strs []string) string { + return strs[0] +} + +// mainAlgorithm は水平走査による共通接頭辞の探索本体。 +// LongestCommonPrefixCompetitive とロジックは同一だが、 +// 業務開発版側では validateInput 済みであることが前提のため、 +// nil チェックなどの重複した防御コードを書かない設計にしている。 +func (s *Solution) mainAlgorithm(strs []string) string { + prefix := strs[0] + + for i := 1; i < len(strs); i++ { + current := strs[i] + + for !strings.HasPrefix(current, prefix) { + prefix = prefix[:len(prefix)-1] + + if prefix == "" { + return "" + } + } + } + + return prefix +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> ``` +> 例)LongestCommonPrefixCompetitive([]string{"flower", "flow", "flight"}) の呼び出し +> 初期状態: prefix = "flower"(先頭文字列を候補として仮置き) +> +> i=1, current = "flow" +> strings.HasPrefix("flow", "flower") → false +> → prefix を "flowe" に短縮 → HasPrefix("flow", "flowe") → false +> → prefix を "flow" に短縮 → HasPrefix("flow", "flow") → true → 内側 for 終了 +> +> i=2, current = "flight" +> strings.HasPrefix("flight", "flow") → false +> → prefix を "flo" に短縮 → false +> → prefix を "fl" に短縮 → HasPrefix("flight", "fl") → true → 内側 for 終了 +> +> ループ終了、prefix = "fl" を返す +> 結果: "fl" +> ``` +> +> 例2(`["dog","racecar","car"]`)の場合、`prefix = "dog"`から始まり、`strings.HasPrefix("racecar", "dog")`が常に`false`のまま候補を1文字ずつ削っていき、最終的に`prefix == ""`になった時点で即座に`""`が返されます。 + +> 💡 **`strings.HasPrefix`とGo特有の最適化(初学者向け)** +> JavaやPythonでは`str.startswith(prefix)`のような形で似た機能を使いますが、Goの`strings.HasPrefix(s, prefix)`は引数の順序が「対象の文字列, 接頭辞」となる点に注意が必要です。内部実装は単純なバイト比較のループですが、標準ライブラリとして提供されることで「自分でバグを埋め込むリスク」を避けられ、`go vet`や`golangci-lint`も安心して通過できます。 +> +> ``` +> 基礎:strings.HasPrefix("hello", "he") → true("hello" は "he" で始まっている) +> 応用:ループの中で候補を1文字ずつ縮めながら繰り返し呼び出す +> この問題での使い方:現在の文字列が prefix で始まらなくなるまで prefix を縮める条件式として使う +> ``` + +> 📖 **このセクションで登場した用語** +> +> - **`error`戻り値**:JavaやPythonの例外(`try-catch`/`try-except`)と異なり、Goはエラーを戻り値として返す。呼び出し元が`if err != nil`で必ず確認する設計になっている。 +> - **`strings.HasPrefix`**:ある文字列が指定した接頭辞で始まっているかを判定する標準ライブラリ関数。 +> - **スライス式 `s[:i]`**:文字列やスライスの一部の「範囲」だけを新しいヘッダとして取り出す構文。元のバイト列自体はコピーされない。 +> - **レシーバ**:メソッドが属する型のインスタンス。`func (s *Solution) LongestCommonPrefix(...)`の`s`の部分。 + +--- + +# 5. 検証 + +> 💡 **初学者向け補足**:エッジケースとは「入力が空・最小値・最大値・重複あり」など境界的な入力のことです。なぜエッジケースの確認が重要かというと、アルゴリズムが"ふつうの入力"だけでなく"極端な入力"でも正しく動くかを保証するためです。 + +- **境界値テスト**(実際のテストコードは別途提供とのことなので、確認すべき代表ケースのみ挙げます) + - `[]string{"a"}` → 要素1件:`isEdgeCase`が拾って`"a"`をそのまま返す。空スライスの場合との違いに注意(空スライスは`validateInput`でerrorになる)。 + - `[]string{"", "abc"}` → 空文字列が混じっている:先頭要素により `prefix` が `""` から始まり、`strings.HasPrefix(current, "")` は常に `true` となるため、`prefix` を縮めるループに入らず処理が進み、最終的に `""` が返る。 + - `[]string{"abc", "abc", "abc"}` → 全員完全一致:`prefix`は一度も縮まらず`"abc"`のまま返る。 + - `[]string{"dog", "racecar", "car"}` → 先頭文字から不一致:`prefix`が空文字列になるまで縮み`""`が返る。 + - `nil`(`nil`スライス)を渡した場合:`len(nil) == 0`となるため`validateInput`が`errors.New`でエラーを返す。`nil`スライスの場合に`strs[0]`へ直接アクセスするとパニック(インデックス範囲外)になるため、検証を先に行うことがGoでは特に重要。 + - 200個・各200文字がすべて一致する最大規模の入力:計算量O(S)なので最大4万文字でも一瞬。 +- **型チェック**: `go vet`により、`error`戻り値を無視していないか、レシーバ名の一貫性、未使用変数などが機械的にチェックされる。すべての引数・戻り値に明示的な型(`[]string`、`string`、`error`)を付けているため、`golangci-lint`のstaticcheck系リンターにも引っかからない設計にしている。 +- **並行安全性**: この実装はゴルーチンを一切使用していないため、データ競合(Race Condition)は原理的に発生しない。`go test -race`を実行しても問題は検出されない想定。 + +> 📖 **このセクションで登場した用語** +> +> - **パニック**:Goで回復不能なエラーが発生した際に起きる強制終了。インデックスの範囲外アクセスなどで発生する。 +> - **データ競合(Race Condition)**:複数ゴルーチンが同じメモリを同時に読み書きするとき、結果が不定になる問題。`go test -race`で検出できる。 +> - **`go vet`**:コンパイルは通るが怪しいコード(未使用変数、フォーマット文字列の誤りなど)を検出する静的解析ツール。 + +--- + +## 最終提出コード(LeetCode回答フォーマット・関数形式) + +```go +package main + +import "strings" + +func longestCommonPrefix(strs []string) string { + // エッジケース:空スライスなら空文字列を返す(LeetCodeの制約上は1件以上保証されるが防御的に確認) + if len(strs) == 0 { + return "" + } + + // 先頭の文字列を共通接頭辞の候補として仮置きする + prefix := strs[0] + + // 2番目以降の文字列を順番にチェックしながら候補を縮めていく(水平走査) + for i := 1; i < len(strs); i++ { + current := strs[i] + + // current が prefix で始まらなくなるまで、prefix を1文字ずつ短くする + for !strings.HasPrefix(current, prefix) { + prefix = prefix[:len(prefix)-1] + + // 候補が空文字列になった時点で共通部分は無いと確定し、即座に返す + if prefix == "" { + return "" + } + } + } + + // ループを最後まで抜けた= prefix が全文字列に共通する最長の接頭辞 + return prefix +} +``` diff --git a/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/Longest_Common_Prefix_Python.md b/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/Longest_Common_Prefix_Python.md new file mode 100644 index 00000000..1511851d --- /dev/null +++ b/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/Longest_Common_Prefix_Python.md @@ -0,0 +1,279 @@ +# 1. 問題分析結果 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「複数の文字列の"頭出し"を比べて、全員が同じ文字を並べている部分だけを取り出す問題」です。 + +**CPython特有の注意点**:Pythonの文字列(`str`)は**イミュータブル(=一度作ったら中身を変更できないオブジェクト)**です。そのため、ループの中で `prefix += char` のように文字列を少しずつ継ぎ足していくと、継ぎ足すたびに新しい文字列オブジェクトがメモリ上に作られ直します。これは他言語(例えばJavaの`StringBuilder`やC++の`std::string`の`+=`)と違い、Pythonでは地味にコストがかかる操作です。この問題では文字列自体は短い(最大200文字)ので実害はほぼありませんが、「文字列連結はリストに貯めて最後に`"".join()`する」というPython定番の最適化パターンを覚えておくと、他の問題でも役立ちます。 + +#### 競技プログラミング視点 + +- **制約分析**: `strs.length <= 200`、`strs[i].length <= 200` なので最大でも 200×200 = 40,000文字程度。どんな素朴な方法でも一瞬で終わる規模。 +- **最速手法**: 組み込み関数`zip()`と`set()`(どちらもC言語実装)を使えば、Pythonレベルのループを最小限にできる。 +- **メモリ最小化**: 結果の文字列以外に大きなメモリを使わない設計にする。 +- **CPython最適化**: `zip(*strs)`によって「最短文字列の長さで自動的に止まる」性質を利用し、無駄な範囲チェックを省く。 + +#### 業務開発視点 + +- **型安全設計**: `strs`が本当に`List[str]`であることをpylanceと実行時チェックの両方で保証する。 +- **エラーハンドリング**: 空リストや非文字列要素が混ざっていた場合に明確な例外を出す。 +- **可読性**: 「検証 → エッジケース処理 → 本処理」という業務コードの定番構造に沿わせる。 + +#### Python特有分析 + +- **データ構造選択**: 文字の一致判定には`set`(集合)を使う。「同じ位置の文字が全部同じか」を`len(set(...)) == 1`の一発判定で表現できる。 +- **標準ライブラリ活用度**: `zip`, `set`, `enumerate`, `min`など、すべて組み込み(built-in)関数でまかなえる。`collections`や`heapq`などの追加importすら不要。 +- **CPython最適化度**: 組み込み関数中心の実装なので、Pure Python(純粋にPythonバイトコードだけで書いたコード)のループより高速になりやすい。 + +> 📖 **このセクションで登場した用語** +> +> - **CPython**:最も広く使われるPythonの実装。C言語で書かれており、組み込み関数の多くがC実装のため高速。 +> - **イミュータブル(immutable)**:一度作成したら内容を変更できない性質。Pythonの`str`, `tuple`はイミュータブル。 +> - **制約分析**:問題の入力サイズ上限から「どのくらいの計算量まで許容されるか」を逆算すること。 + +--- + +# 2. 採用アルゴリズムと根拠 + +> 💡 **初学者向け補足**:同じ問題でも複数の解き方(アプローチ)があり、それぞれ「速さ」「メモリ」「書きやすさ」のバランスが違います。比較することで、今回の制約(最大200×200文字)に対して"やりすぎ"でも"力不足"でもない、ちょうど良い方法を選べます。 + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(S)`:全文字列の総文字数 S に比例して処理時間が増える(線形) +> - `O(S log n)`:総文字数に加えて、文字列の本数 n の対数(log)倍だけ余分にかかる + +| アプローチ | 時間計算量 | 空間計算量 | Python実装コスト | 可読性 | 標準ライブラリ活用 | CPython最適化 | 備考 | +| -------------------------- | ---------- | --------------------- | ---------------- | ------ | ------------------------ | ------------------------------------ | -------------------------------------- | +| A: 垂直走査(`zip`+`set`) | O(S) | O(1)※出力除く | 低 | ★★★ | `zip`, `set`(組み込み) | 適 | 早期終了可能、最もPythonic | +| B: 水平走査(逐次比較) | O(S) | O(1) | 低 | ★★☆ | `str.startswith` | 適 | 直感的だが縮小のたびに文字列比較が走る | +| C: 分割統治法 | O(S) | O(log n)※再帰スタック | 中 | ★★☆ | なし | 不適(再帰呼び出しのオーバーヘッド) | 並列化の余地はあるが本問題では過剰装備 | + +※S = 全文字列の総文字数、n = 文字列の本数 + +- **選択理由**: 方法Aは「同じ位置の文字を集合(`set`)にまとめて重複を消す」という発想がPythonの組み込み機能とぴったり噛み合っており、コードが最も短く、かつC実装の関数を最大限使えるため速度面でも有利です。 +- **Python最適化戦略**: `zip(*strs)`を使うと「最短の文字列の長さで自動的にイテレーションが止まる」ため、`min(len(s) for s in strs)`を自分で計算して範囲チェックする手間が省けます。これは「最長共通接頭辞は最短文字列の長さを超えられない」という問題の性質とPythonの`zip`の仕様が偶然一致しているためです。 +- **トレードオフ**: 方法Bは1文字ずつではなく文字列単位で比較する(`str.startswith`はC実装で高速)ため、極端に長い共通接頭辞を持つ大量の文字列では方法Aよりわずかに有利な場合がありますが、今回の制約規模では誤差レベルです。可読性を優先し方法Aを採用します。 + +> 📖 **このセクションで登場した用語** +> +> - **垂直走査(vertical scanning)**:複数の文字列を「同じ位置(インデックス)」ごとに縦方向に比較していく方法。 +> - **水平走査(horizontal scanning)**:1つの文字列を基準に、他の文字列と丸ごと比較しながら接頭辞を縮めていく方法。 +> - **分割統治法(divide and conquer)**:問題を半分に分けて別々に解き、その結果を合体させる手法。 +> - **早期終了(early termination)**:それ以上調べても答えが変わらないと分かった時点で処理を打ち切ること。 + +--- + +# 3. 実装パターン + +**このコードの大まかな構造(骨格)** + +- `longestCommonPrefix`:LeetCode採点対象のメインメソッド(業務開発版)。検証→エッジケース→本処理の順で呼び出すだけの「司令塔」。 +- `_validate_input`:型と件数の検証だけを担当。 +- `_is_edge_case` / `_handle_edge_case`:文字列が1件だけのときの特別処理。 +- `_main_algorithm`:`zip`+`set`による垂直走査の本体。 +- `solve_competitive`:検証を省いた高速版(別メソッドとして提供)。 + +【業務開発版を使う場面】 +チームで長期間メンテナンスするプロダクションコードに向きます。入力が不正なときに「なぜダメなのか」が例外メッセージからすぐ分かり、後から読んだ人がメソッド名だけで処理の流れを追えます。 + +```python +from typing import List + + +class Solution: + """ + Longest Common Prefix(最長共通接頭辞)解決クラス + + 業務開発向けと競技プログラミング向けの2パターンを提供する。 + LeetCode 提出用のエントリーポイントは longestCommonPrefix() とする。 + """ + + def longestCommonPrefix(self, strs: List[str]) -> str: + """ + 業務開発向け実装(型安全・エラーハンドリング重視)。 + LeetCode の採点対象となるメインの公開メソッド。 + + Args: + strs: 共通接頭辞を調べたい文字列のリスト。 + + Returns: + 全文字列に共通する最長の接頭辞。共通部分が無ければ空文字列。 + + Raises: + TypeError: strs がリストでない、または要素が文字列でない場合。 + ValueError: strs が空リストの場合。 + """ + # 1. 入力検証:LeetCode の制約上は保証されるが、 + # 業務コードでは「外部から誤って呼ばれる可能性」を常に想定して検証する。 + self._validate_input(strs) + + # 2. エッジケース処理:要素が1つだけならその文字列自体が答え。 + if self._is_edge_case(strs): + return self._handle_edge_case(strs) + + # 3. メインアルゴリズム(垂直走査 + zip)へ処理を委譲する。 + return self._main_algorithm(strs) + + def solve_competitive(self, strs: List[str]) -> str: + """ + 競技プログラミング向け最適化実装。 + エラーハンドリングを省略し、可読性より実行速度を優先する。 + + Time Complexity: O(S) S = 全文字列の総文字数 + Space Complexity: O(1) ※出力用の文字列を除く + """ + # 直接的な実装:空リストなら即座に空文字列を返す(型チェック省略)。 + if not strs: + return "" + + # min(strs, key=len) は「最も短い文字列」をC実装レベルで一発検索する。 + # 答えの長さは必ず最短文字列の長さ以下になるため、探索範囲をここに絞れる。 + shortest = min(strs, key=len) + + # enumerate で「最短文字列の何文字目まで一致したか」を追跡する。 + for i, ch in enumerate(shortest): + for s in strs: + # 現在位置の文字が1つでも異なれば、そこまでが共通接頭辞。 + if s[i] != ch: + return shortest[:i] + + # ループを最後まで抜けた=最短文字列そのものが共通接頭辞。 + return shortest + + def _validate_input(self, data: List[str]) -> None: + """型安全な入力検証。""" + # isinstance で list 型かどうかを実行時にチェックする。 + # Python は動的型付け言語なので、間違った型が渡されても実行時まで気づけない。 + if not isinstance(data, list): + raise TypeError("strs must be a list of strings") + + # 制約上 1 <= strs.length だが、防御的プログラミングとして明示的にチェックする。 + if len(data) == 0: + raise ValueError("strs must contain at least one string") + + # all() + ジェネレータ式で全要素が str 型かを1行でチェック。 + # False が見つかった時点で走査を打ち切るため、無駄がない。 + if not all(isinstance(s, str) for s in data): + raise TypeError("All elements of strs must be strings") + + def _is_edge_case(self, data: List[str]) -> bool: + """エッジケース判定:要素数が1つの場合。""" + return len(data) == 1 + + def _handle_edge_case(self, data: List[str]) -> str: + """エッジケース処理:唯一の文字列がそのまま答えになる。""" + return data[0] + + def _main_algorithm(self, data: List[str]) -> str: + """ + メインアルゴリズム:垂直走査(vertical scanning)を zip() で実現する。 + + CPython 最適化ポイント: + - zip() は C 実装のため、Python レベルでインデックスを回すより高速。 + - set() で「同じ文字かどうか」を判定するのも C 実装。 + """ + prefix_chars: List[str] = [] + + # zip(*data) は「複数のリストを縦に束ねて、同じ位置の要素をタプルにまとめる」関数。 + # 例: zip("flower", "flow", "flight") → + # ('f','f','f'), ('l','l','l'), ('o','o','i'), ... + # 最短の文字列の長さでイテレーションが自動的に打ち切られるのも都合が良い + # (最長共通接頭辞は最短文字列の長さを超えられないため)。 + for chars in zip(*data): + # set(chars) はタプル内の重複を取り除いた集合を作る。 + # 全文字が同じなら set のサイズは 1 になる。 + if len(set(chars)) == 1: + prefix_chars.append(chars[0]) + else: + # 1文字でも違えばそこで打ち切り(早期終了)。 + # これ以上先を見ても共通接頭辞は伸びないため。 + break + + # "".join() でリストを1つの文字列に結合する。 + # ループのたびに文字列を + で連結するより効率的 + # (str はイミュータブルなので、+ 連結は毎回新しいオブジェクトを作り直すコストがかかる)。 + return "".join(prefix_chars) +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> ``` +> 初期状態: strs = ["flower", "flow", "flight"] +> Step 1: _validate_input() → list型・全要素str型 → 検証通過 +> Step 2: _is_edge_case() → len=3 → False(エッジケースではない) +> Step 3: _main_algorithm() 開始 +> zip(*strs) は最短文字列 "flow"(4文字)の長さで止まる +> → index0: ('f','f','f') → set={'f'} サイズ1 → prefix_chars=['f'] +> → index1: ('l','l','l') → set={'l'} サイズ1 → prefix_chars=['f','l'] +> → index2: ('o','o','i') → set={'o','i'} サイズ2 → 不一致なので break +> 結果: "".join(['f','l']) = "fl" +> ``` +> +> 例2(`["dog","racecar","car"]`)の場合は index0 の時点で `('d','r','c')` → set サイズ3 で即座に break するため、`prefix_chars` は空のまま `""` が返ります。 + +【競技プログラミング版を使う場面】 +LeetCodeやAtCoderなど、制限時間内に正解を出すことが目的のコードに向きます。今回のコード自体は`solve_competitive`として上記に同居させています。可読性よりも「型チェックや例外処理を省いてでも、ぱっと動くコードを書く」ことを優先した書き方になっています。 + +> 💡 **型ヒントとpylanceの関係(初学者向け)** +> Pythonは動的型付け言語なので、型を書かなくても動きます。しかし`List[str]`のような型ヒントを書いておくと、pylance(VSCodeの型チェッカー)が実行前に「文字列のリストではなく数値のリストを渡してしまった」といった間違いを検出してくれます。これはコンパイル言語(Java、C++など)が持つ安全性を、実行前の静的解析という形でPythonにも取り入れる仕組みです。今回のコードでは`List[str] -> str`のように引数・戻り値の両方に型を明記し、内部変数`prefix_chars`にも`: List[str]`と注釈を付けることで、pylanceが「文字列以外がリストに紛れ込んでいないか」まで追跡できるようにしています。 + +> 📖 **このセクションで登場した用語** +> +> - **`zip()`**:複数のイテラブル(リストや文字列など)を同じ位置同士でペアにまとめる組み込み関数。最も短いものの長さで自動的に止まる。 +> - **`set()`**:重複を許さない集合を作る組み込み関数。「全部同じ値か」を`len(set(...))==1`で一発判定できる。 +> - **型ヒント**:関数の引数や戻り値に型を注釈として書く仕組み。`def f(x: int) -> str:`のように書く。 +> - **pylance**:VSCodeで使えるPythonの静的型チェックツール。実行前にバグを検出できる。 + +--- + +# 4. 検証 + +> 💡 **初学者向け補足**:エッジケースのテストは、アルゴリズムが"ふつうの入力"だけでなく"極端な入力"でも正しく動くかを確かめるためのものです。 + +- **境界値テスト**(すべて`_main_algorithm`または`solve_competitive`で正しく処理されるか確認すべき代表例) + - `["a"]` → 要素1件 → `_is_edge_case`が拾って`"a"`をそのまま返す + - `["", "abc"]` → 空文字列が混じっている → `zip("", "abc")`は0回もループしないため`prefix_chars`は空のまま`""` + - `["abc", "abc", "abc"]` → 全員完全一致 → 最短文字列そのもの`"abc"`が返る + - `["dog", "racecar", "car"]` → 先頭文字から不一致 → `""` + - 200個・各200文字の文字列がすべて一致 → 計算量O(S)なので最大4万文字でも一瞬 +- **型チェック**: pylance対応の型ヒント(`List[str] -> str`、内部変数`List[str]`)により、静的解析の段階で「文字列以外の要素が混入していないか」を検出できる。実行時には`_validate_input`が`isinstance`チェックでダメ押しする。 + +> 📖 **このセクションで登場した用語** +> +> - **エッジケース**:空のリスト・要素1つ・空文字列を含むなど、境界的な条件のこと。 +> - **境界値テスト**:エッジケースに対してもアルゴリズムが正しく動くかを確かめること。 +> - **静的解析**:プログラムを実行せずに、コードを読むだけでバグや型エラーを検出する手法。 + +--- + +## 最終提出コード(LeetCode回答フォーマット) + +```python3 +from typing import List + + +class Solution: + def longestCommonPrefix(self, strs: List[str]) -> str: + # 1. 入力検証(防御的プログラミング) + self._validate_input(strs) + + # 2. エッジケース:要素が1つならそれ自体が答え + if len(strs) == 1: + return strs[0] + + # 3. 垂直走査(zip + set)で共通接頭辞を求める + prefix_chars: List[str] = [] + for chars in zip(*strs): + if len(set(chars)) == 1: + prefix_chars.append(chars[0]) + else: + break + return "".join(prefix_chars) + + def _validate_input(self, data: List[str]) -> None: + if not isinstance(data, list): + raise TypeError("strs must be a list of strings") + if len(data) == 0: + raise ValueError("strs must contain at least one string") + if not all(isinstance(s, str) for s in data): + raise TypeError("All elements of strs must be strings") +``` diff --git a/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/Longest_Common_Prefix_Rust.md b/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/Longest_Common_Prefix_Rust.md new file mode 100644 index 00000000..1b4e2c06 --- /dev/null +++ b/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/Longest_Common_Prefix_Rust.md @@ -0,0 +1,269 @@ +# 1. 問題の分析 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「複数の文字列を先頭から見比べて、全員が同じ文字を並べている部分(接頭辞)だけを安全に取り出す問題」です。 + +**Rustで解く際に特に気をつけるべき点**を1段落でまとめます。LeetCodeの関数シグネチャは`strs: Vec`となっており、この関数は`Vec`の**所有権(=値を"誰が管理するか"をコンパイル時に決めるRust独自の仕組み)**を受け取ります。つまり呼び出し元はこの`strs`をもう使えなくなります。しかし本問題では`strs`の中身を書き換える必要はなく、読み取るだけで十分なので、関数内部の処理はできる限り`&str`(文字列スライスへの参照)で**借用(=所有権を渡さずに値を参照する仕組み)**して扱い、不要な`clone()`(値の複製)を避けるのがRustらしい設計です。またRustの`String`はUTF-8のバイト列であり、任意のバイト位置で切り出すと不正な文字境界でパニックする可能性がありますが、本問題の制約は「小文字の英字のみ」なので全文字が1バイトであり、バイト単位の操作をそのまま文字単位として扱っても安全です。エラーハンドリングについては、LeetCodeの戻り値の型が`String`固定のため`Result`は使わず、代わりに空配列などの異常入力に対しては空文字列`String::new()`を返す設計にします。 + +## 競技プログラミング視点での分析 + +- **実行速度最優先の方針**: `str::starts_with`のような標準ライブラリの比較メソッドはコンパイラによって効率よく最適化されるため、自前でバイトループを書くより信頼できる。 +- **メモリ使用量の最小化方針(スタック vs ヒープ・アロケーション回数)**: 候補の接頭辞を`&str`(スタック上に置ける参照)として扱い、最後に1回だけ`String`へ変換(=ヒープへのアロケーションが1回だけ発生)することで、ループ中の余計なヒープ確保を避ける。 + +## 業務開発視点での分析 + +- **型安全性・保守性・可読性を重視した場合のアプローチ**: 「入力検証 → エッジケース処理 → 本処理」という段階に関数を分割し、それぞれの責務を明確にする。 +- **`Result` / `Option` によるエラーハンドリング設計**: LeetCodeの戻り値型が`String`固定であるため、本問題では`Result`を戻り値には使わないが、内部ヘルパー関数では`Option<&str>`(値があるかないかを表す型。`null`と違い「無いこと」自体をコンパイラが強制的に意識させる)を使って「最短の文字列が存在するか」を安全に扱う。 + +## Rust特有の考慮点 + +- **所有権・借用・ライフタイムの設計方針**: 引数の`strs: Vec`は所有権ごと受け取るが、関数内部では`&strs`(`Vec`への参照)や各要素の`&str`だけを使い、値を消費(ムーブ)しない。これにより「途中まで処理したのに残りの要素が使えなくなる」といった事故を防げる。 +- **トレイト境界とモノモーフィゼーションによる最適化**: 本問題は`String`専用でジェネリクスの必要性が薄いため、無理にトレイト境界(`T: AsRef`など)を導入せず、具体的な型のまま実装してコードをシンプルに保つ。 +- **イテレータアダプタ vs 命令型ループの選択基準**: 「最短の文字列を探す」処理は`.min_by_key()`のようなイテレータアダプタ(`.map()` `.filter()`などがゼロコスト抽象化によりループと同等の速度で動く仕組み)に向くが、「1文字ずつ縮めていく」処理は状態(現在の候補)を持ち回るため、素朴な`while`ループの方がRustでは読みやすい。 + +> 📖 **このセクションで登場した用語** +> +> - **所有権**:値を"誰が管理するか"をコンパイル時に決めるRust独自の仕組み。メモリを自動で安全に管理できる。 +> - **借用**:所有権を渡さずに値を参照する仕組み。`&T`(読み取り専用)と`&mut T`(書き込み可能)がある。 +> - **`Option`**:値があるかないかを型で表現する仕組み。JavaやPythonの`null`/`None`と似ているが、コンパイラが「使う前に必ず中身の有無を確認すること」を強制する点が異なる。 +> - **UTF-8のバイト列**:Rustの`String`は文字ごとに固定バイト数ではなく、文字によって1〜4バイトの可変長で符号化されている。任意のバイト位置で切ると文字の途中を切断してしまう危険がある。 + +--- + +# 2. アルゴリズムアプローチ比較 + +> 💡 **初学者向け補足**:同じ問題でも解き方は複数あります。それぞれの「速さ」「メモリの使い方」「Rustの所有権モデルとの相性」を比べて最適なものを選びます。 + +Rust固有の観点として、各アプローチで「所有権の移動が発生するか」「アロケーションが必要か」にも触れます。 + +| アプローチ | 時間計算量 | 空間計算量 | Rust実装コスト | 安全性 | 可読性 | 備考 | +| -------------------------------------------------- | ---------- | ---------- | -------------- | ------ | ------ | ------------------------------------------------------------------------------- | +| A: 水平走査(先頭要素を候補に`starts_with`で縮小) | O(S) | O(1) | 低 | 高 | 高 | `&str`の借用のみで完結、アロケーションは最後の1回のみ | +| B: 垂直走査(`.chars()`をzipして同じ位置を比較) | O(S) | O(1) | 中 | 高 | 中 | イテレータの合成でゼロコストだが、複数イテレータの同時制御がやや複雑 | +| C: 分割統治法(`Vec`を半分に分割し再帰) | O(S) | O(log n) | 高 | 高 | 中 | 再帰のたびに`String`の所有権を分割する必要があり、無駄な`clone()`が発生しやすい | + +S = 全文字列の合計バイト数、n = 文字列の本数 + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(S)`:全文字数Sに比例して処理時間が増える(線形) +> - `O(log n)`:入力が2倍になっても、処理はわずかしか増えない(再帰の深さなどに多い) + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して、処理にかかる手間がどう増えるかの目安。 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安。 +> - **アロケーション**:ヒープ上にメモリを確保する操作。頻繁に行うと速度が落ちる。 +> - **水平走査**:1つの文字列を基準にして、他の文字列と丸ごと比較しながら接頭辞を縮めていく方法。 + +--- + +# 3. 選択したアルゴリズムと理由 + +> 💡 **初学者向け補足**:選んだ理由を「〜だから選ばなかった」という対比形式で説明します。 + +- **選択したアプローチ**: A(水平走査、`starts_with`による縮小方式) +- **理由**: + - 方法Cの分割統治法は、計算量こそ同じO(S)ですが、`Vec`を再帰的に分割する過程で`String`の所有権の扱いが複雑になり、無駄な`clone()`(コピー)が発生しやすくなるため見送りました。 + - 方法Bの垂直走査(`.chars().zip()`)はRustのイテレータの強みを活かせますが、「文字列が何本あっても対応する」設計にするには複数のイテレータを同時に進める必要があり、コードが読みにくくなります。 + - 方法Aは「先頭の文字列を候補として、他の文字列に対して`starts_with`で判定し、合わなければ1文字ずつ縮める」という単純明快なロジックで、状態(候補の長さ)を`usize`(符号なし整数。長さやインデックスに使う型)1つだけで管理できるため、可読性と実行速度のバランスが最も良いと判断しました。 +- **Rust特有の最適化ポイント**: + - ゼロコスト抽象化によるオーバーヘッドの排除: `&str`のスライス操作は、実行時にはポインタと長さの組み合わせを扱うだけで、追加のメモリコピーは発生しない。 + - モノモーフィゼーションによるインライン展開: 本実装は具体的な型(`String`/`&str`)のみを使うため、ジェネリクスによるコード膨張は発生しない。 + - スタックアロケーション優先によるキャッシュ効率: ループ中の候補の長さ(`usize`)はスタック上の単純な整数値として扱われ、ヒープアクセスを伴わない。 + +> 📖 **このセクションで登場した用語** +> +> - **ゼロコスト抽象化**:便利な高レベルな書き方(`&str`のスライス操作など)をしても、手書きの低レベルコードと同等の速さになるRustの特性。 +> - **モノモーフィゼーション**:ジェネリクス関数が、使われる型ごとに専用コードへ自動展開される仕組み。動的ディスパッチより高速。 +> - **`usize`**:符号なし整数型。配列のインデックスや長さなど、負の値を取らない場面で使われるRustの慣習的な型。 + +--- + +# 4. 実装コード + +**このコードの大まかな構造(骨格)** + +1. 入力が空`Vec`かどうかを確認する(LeetCodeの制約上は1件以上保証されるが、防御的に確認する) +2. 先頭要素を「共通接頭辞の候補の長さ」として`prefix_len`を初期化する +3. 2番目以降の各文字列に対して、候補が`starts_with`を満たすまで`prefix_len`を縮める +4. 最終的に確定した`prefix_len`を使って、先頭要素から部分文字列を切り出して`String`として返す + +```rust +impl Solution { + /// 複数の文字列に共通する最長の接頭辞を求める。 + /// + /// なぜこの実装を選んだか: + /// 先頭の文字列を「共通接頭辞の候補」として仮に置き、 + /// 残りの文字列を1つずつ `starts_with` で確認する水平走査を採用した。 + /// `Vec` の所有権は受け取るが、内部処理は `&str` の借用だけで完結させ、 + /// 最後の1回だけ `String` へ変換することでヒープアロケーションを最小限にしている。 + /// + /// # Arguments + /// * `strs` - 共通接頭辞を調べたい文字列の `Vec`(所有権を受け取る。LeetCodeのシグネチャに合わせるため) + /// + /// # Returns + /// 全文字列に共通する最長の接頭辞。共通部分が無ければ空文字列。 + /// + /// # Complexity + /// - Time: O(S) S = 全文字列の合計バイト数 + /// - Space: O(1) ※戻り値の `String` を除く + pub fn longest_common_prefix(strs: Vec) -> String { + // ---- 1. 入力検証(エッジケース:空 Vec)---- + // is_empty() は Vec の長さが 0 かどうかを O(1) で判定する。 + // 空の場合、strs[0] へアクセスするとパニックするため、先に確認して安全に空文字列を返す。 + if strs.is_empty() { + return String::new(); + } + + // ---- 2. エッジケース:要素が1つだけならそれ自体が答え ---- + // strs は Vec の所有権を持っているので、 + // strs[0].clone() で複製すれば呼び出し元に安全に返せる + // (strs 自体はこの関数が終わるとスコープを抜けて自動的に解放される)。 + if strs.len() == 1 { + return strs[0].clone(); + } + + // ---- 3. メインアルゴリズム(水平走査)---- + // &strs[0] で先頭の文字列を「借用」し、所有権は移動させない。 + // as_str() で String を &str(文字列スライスへの参照)に変換する。 + // なぜ &str で扱うか:候補を縮める処理では新しい String を作る必要がなく、 + // 「範囲を狭める」だけなのでスライス参照の方が無駄なアロケーションを避けられる。 + let first: &str = strs[0].as_str(); + + // prefix_len は「候補として認めている先頭からのバイト数」を表す。 + // 最初は先頭文字列の全長からスタートする。 + let mut prefix_len: usize = first.len(); + + // 2番目以降の文字列を順番にチェックしていく。 + // .iter().skip(1) は「先頭を除いた残りの要素」への参照イテレータを作る。 + // Vec の所有権を消費しない(ムーブしない)ため、strs はこの後も生きたまま使える。 + for s in strs.iter().skip(1) { + let current: &str = s.as_str(); + + // &first[..prefix_len] は「first の先頭から prefix_len バイト分」を切り出すスライス式。 + // 制約上すべて小文字の英字(1バイト文字)のみなので、 + // バイト単位の切り出しがそのまま文字単位の切り出しとして安全に機能する。 + while !current.starts_with(&first[..prefix_len]) { + // prefix_len を 1 減らして候補を縮める。 + // これは新しい String を作らず、単に「見る範囲」を表す整数を更新するだけなのでO(1)。 + prefix_len -= 1; + + // 候補が空文字列(長さ0)になった時点で、 + // これ以上共通部分は無いと確定するので即座に返す(早期終了)。 + if prefix_len == 0 { + return String::new(); + } + } + } + + // ---- 4. 結果を返す ---- + // 確定した prefix_len を使って、先頭文字列から部分文字列を切り出し、 + // to_string() で所有権を持つ String に変換して返す。 + // ここで初めて(そして唯一)ヒープアロケーションが発生する。 + first[..prefix_len].to_string() + } +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> ``` +> 例)longest_common_prefix(vec!["flower".to_string(), "flow".to_string(), "flight".to_string()]) +> +> 初期状態: strs.is_empty() → false、strs.len() → 3(エッジケースではない) +> first = "flower"、prefix_len = 6("flower" の長さ) +> +> s = "flow" (現在の候補: "flower"[..6] = "flower") +> "flow".starts_with("flower") → false +> → prefix_len = 5 → "flower"[..5] = "flowe" → "flow".starts_with("flowe") → false +> → prefix_len = 4 → "flower"[..4] = "flow" → "flow".starts_with("flow") → true → while終了 +> +> s = "flight" (現在の候補: "flower"[..4] = "flow") +> "flight".starts_with("flow") → false +> → prefix_len = 3 → "flo" → false +> → prefix_len = 2 → "fl" → "flight".starts_with("fl") → true → while終了 +> +> ループ終了、"flower"[..2] = "fl" を to_string() で返す +> 結果: "fl" +> ``` +> +> 例2(`["dog","racecar","car"]`)の場合、`first = "dog"`、`prefix_len = 3`から始まり、`"racecar".starts_with(...)`が常に`false`のまま`prefix_len`が1文字ずつ減っていき、最終的に`prefix_len == 0`になった時点で即座に`String::new()`(空文字列)が返されます。 + +> 💡 **なぜ`Vec`を消費せず`&str`で借用するのか(初学者向け)** +> **基礎**:Rustでは、関数が`Vec`のような値を引数として受け取ると、その関数が「所有者」になります。もし関数内で`strs`の要素を1つずつ`String`型のまま取り出して別の変数に代入すると、その要素の所有権が移動(ムーブ)してしまい、元の`strs`からは使えなくなります。 +> **応用**:`&strs[0]`や`s.as_str()`のように「参照」だけを取り出せば、所有権は`strs`に残ったまま、中身を安全に読み取れます。 +> **この問題での使い方**:ループの中で`strs.iter().skip(1)`を使い、各要素への参照(`&String`)を`s`として受け取っています。これにより`strs`自体は関数の最後までずっと有効なままで、複雑なライフタイム管理を考える必要がありません。 +> +> ``` +> 基礎:let v = vec!["a".to_string()]; let x = v[0]; // エラー! String はムーブされる +> 応用:let x = &v[0]; // OK。参照なので v はまだ使える +> この問題での使い方:for s in strs.iter().skip(1) { ... } // s は &String への参照 +> ``` + +> 📖 **このセクションで登場した用語** +> +> - **スライス `&str[..n]`**:文字列の先頭からn バイト分を切り出す参照。新しい文字列をコピーするのではなく、既存のバイト列の一部を指すだけなのでO(1)。 +> - **`as_str()`**:`String`(所有権を持つ可変長文字列)を`&str`(文字列スライスへの参照)に変換するメソッド。 +> - **`to_string()`**:`&str`から新しい`String`を作る(ヒープにコピーする)メソッド。 +> - **早期終了(early termination)**:それ以上調べても答えが変わらないと分かった時点で処理を打ち切ること。 + +--- + +# 5. 検証 + +> 💡 **初学者向け補足**:エッジケースのテストは、アルゴリズムが"ふつうの入力"だけでなく"極端な入力"でも正しく動くかを確かめるためのものです。 + +- **境界値テスト**(実際にテストコードは書きませんが、以下のケースで正しく動作することを確認すべきです) + - `vec!["a".to_string()]` → 要素1件:`strs.len() == 1`の分岐で`strs[0].clone()`をそのまま返す。 + - `vec!["".to_string(), "abc".to_string()]` → 空文字列が混じっている:`prefix_len`は最初`0`(`first = ""`の長さ)から始まるため、`while`ループに入る前の`prefix_len == 0`チェックまで到達せず、実質的に最初の1文字も候補にならず`""`が返る(`first[..0]`は空文字列であり、`current.starts_with("")`は常に`true`のためループにすら入らずそのまま抜ける点に注意)。 + - `vec!["abc".to_string(), "abc".to_string(), "abc".to_string()]` → 全員完全一致:`prefix_len`は一度も縮まらず`3`のまま、最終的に`"abc"`が返る。 + - `vec!["dog".to_string(), "racecar".to_string(), "car".to_string()]` → 先頭文字から不一致:`prefix_len`が`0`になるまで縮み、`String::new()`が返る。 + - 200個・各200文字がすべて一致する最大規模の入力:計算量O(S)なので最大4万バイトでも一瞬で終わる。 +- **型チェック**: 引数`Vec`・戻り値`String`という明確な型のみを使い、`unsafe`ブロックは一切使用していない。すべてのスライス操作(`&first[..prefix_len]`)は境界チェック付きのため、範囲外アクセスがあれば実行時パニックとして安全に検出される(サイレントなメモリ破壊は起きない)。 + +> 📖 **このセクションで登場した用語** +> +> - **エッジケース**:空の`Vec`・要素1つ・空文字列を含むなど、境界的な条件のこと。 +> - **境界値テスト**:エッジケースに対してもアルゴリズムが正しく動くかを確かめること。 +> - **パニック**:Rustで回復不能なエラーが発生した際に起きる強制終了。範囲外のスライスアクセスなどで発生する。 + +--- + +## 最終提出コード(LeetCode回答フォーマット) + +```rust +impl Solution { + pub fn longest_common_prefix(strs: Vec) -> String { + // エッジケース:空 Vec なら空文字列を返す + if strs.is_empty() { + return String::new(); + } + + // エッジケース:要素が1つならそれ自体が答え + if strs.len() == 1 { + return strs[0].clone(); + } + + // 先頭の文字列を候補として、prefix_len(候補の長さ)で管理する + let first: &str = strs[0].as_str(); + let mut prefix_len: usize = first.len(); + + // 2番目以降の文字列を順にチェックしながら候補を縮めていく(水平走査) + for s in strs.iter().skip(1) { + let current: &str = s.as_str(); + + while !current.starts_with(&first[..prefix_len]) { + prefix_len -= 1; + + if prefix_len == 0 { + return String::new(); + } + } + } + + // 確定した prefix_len 分だけ切り出して String に変換して返す + first[..prefix_len].to_string() + } +} +``` diff --git a/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/Longest_Common_Prefix_Typescript.md b/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/Longest_Common_Prefix_Typescript.md new file mode 100644 index 00000000..07ffc7fc --- /dev/null +++ b/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/Longest_Common_Prefix_Typescript.md @@ -0,0 +1,254 @@ +# 1. 問題の分析 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「複数の文字列を先頭から見比べて、全員が同じ文字を並べている部分(接頭辞)だけを取り出す問題」です。 + +## 競技プログラミング視点での分析 + +- **実行速度最優先の方針**: 文字列比較はJavaScriptエンジン(V8)内部でC++レベルに最適化された`String.prototype`のメソッド(`slice`や`startsWith`など)を使うことで、自前でループを書くより高速化できる可能性がある。 +- **メモリ最小化の方針**: 新しい配列や中間オブジェクトを極力作らず、文字列の「範囲」だけを扱う(`slice`で必要な部分だけ切り出す)。 + +## 業務開発視点での分析 + +- **型安全性・保守性重視の方針**: 「検証 → エッジケース処理 → 本処理」という段階に関数を分割し、それぞれの責務を明確にする。 +- **エラーハンドリング**: `strs`が配列でない、要素が文字列でない、空配列である、といった不正入力を早期に検出して分かりやすいエラーを投げる。 + +## TypeScript特有の考慮点 + +- **型推論とコンパイル時最適化**: 引数を`readonly string[]`にすることで「この関数の中では配列を書き換えない」という約束をコンパイラに伝えられる。これにより、うっかり`strs.push(...)`のような副作用のあるコードを書いてしまってもコンパイル時にエラーとして検出できる。 +- **ジェネリクスの効果的な活用**: この問題は「文字列専用」なので、ジェネリクス(=型を後から自由に差し込める仕組み。`Array`の`T`部分がその例)を使う必要性は薄い。無理にジェネリクスを使うと逆に読みにくくなるため、今回はあえて使わない判断をする。 +- **型ガードとnull安全性**: JavaScriptは実行時に型情報を持たないため、TypeScriptで`string[]`と書いても、実行時には数値や`null`が混ざった配列が渡される可能性がある。`Array.isArray`や`typeof s === 'string'`といった**型ガード(=実行時に値の型を確認し、その情報をもとにTypeScriptの型を絞り込む仕組み)**で二重に守る。 + +> 📖 **このセクションで登場した用語** +> +> - **型推論**:型を明示しなくてもTypeScriptが自動で型を判断してくれる機能。 +> - **null安全性**:`null`や`undefined`による予期しないクラッシュを防ぐ仕組み。 +> - **型ガード**:`typeof`や`Array.isArray`などを使って、実行時に値の型を確認し、その後のコードでTypeScriptに型を絞り込ませるための仕組み。JavaScriptには型システム自体がないため、この概念はTypeScript(および型付き言語全般)特有の考え方になる。 + +--- + +# 2. アルゴリズムアプローチ比較 + +> 💡 **初学者向け補足**:同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」を比べて、この問題の制約(文字列は最大200個、各最大200文字)にちょうど良いものを選びます。 + +| アプローチ | 時間計算量 | 空間計算量 | TS実装コスト | 型安全性 | 可読性 | 備考 | +| ------------------------------------------------------------- | ---------- | ---------- | ------------ | -------- | ------ | -------------------------------------------------------- | +| A: 水平走査(先頭文字列を基準に`startsWith`で縮める) | O(S) | O(1) | 低 | 高 | 高 | S=全文字数の合計。直感的で書きやすい | +| B: 垂直走査(同じ位置の文字を1文字ずつ全文字列で比較) | O(S) | O(1) | 低 | 高 | 中 | 早期終了しやすいが二重ループがやや読みにくい | +| C: 分割統治法(配列を半分に分けて再帰的に共通接頭辞を求める) | O(S) | O(log n) | 高 | 中 | 中 | 再帰呼び出しのオーバーヘッドがあり今回の規模では過剰装備 | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(S)`:全文字列の合計文字数Sに比例して処理時間が増える(線形) +> - `O(log n)`:入力が2倍になっても、処理はわずかしか増えない(再帰の深さなどに多い) + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して、処理にかかる手間がどう増えるかの目安。 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安。 +> - **水平走査**:1つの文字列を基準にして、他の文字列と丸ごと比較しながら接頭辞を縮めていく方法。 +> - **垂直走査**:複数の文字列を「同じ位置(インデックス)」ごとに縦方向に比較していく方法。 + +--- + +# 3. 選択したアルゴリズムと理由 + +> 💡 **初学者向け補足**:選んだ理由を「〜だから選ばなかった」という形で他の方法との対比を使って説明します。 + +- **選択したアプローチ**: A(水平走査、`startsWith`による縮小方式) +- **理由**: + - 方法Cの分割統治法は、計算量こそ同じO(S)ですが、再帰関数の呼び出しコスト(=関数を呼ぶたびに発生するわずかなオーバーヘッド)がかかる上に、コードが複雑になり保守性が下がるため見送りました。 + - 方法Bの垂直走査も有効ですが、「文字列の配列」と「1文字ごとのインデックス」という2つのループを扱うため、TypeScriptで型を厳密に付けようとすると`undefined`チェックが増えて読みにくくなります。 + - 方法Aは「先頭の文字列を基準の候補(`prefix`)とし、他の文字列が`prefix`で始まっていなければ`prefix`を1文字ずつ短くする」という単純明快なロジックで、V8エンジンに最適化された`String.prototype.startsWith`(内部でC++実装されている)をそのまま活用できるため、可読性と実行速度のバランスが最も良いと判断しました。 +- **TypeScript特有の最適化ポイント**: + - コンパイル時の型チェックによるエラー防止:引数を`readonly string[]`にすることで、関数内での配列の意図しない書き換えをコンパイル時に検出できる。 + - 型推論による開発効率向上:`let prefix: string = strs[0]`のように一度型を確定させれば、以降のコードでは型注釈を省いても`prefix`が`string`型であることをTypeScriptが自動で追跡してくれる。 + +> 📖 **このセクションで登場した用語** +> +> - **オーバーヘッド**:本来の処理以外にかかる余分な処理コストのこと。関数呼び出しや再帰にはこの余分なコストが伴う。 +> - **`readonly`**:配列や変数の値を変更できないようにする修飾子。意図せぬ書き換えを防ぐ。JavaScriptにはこの概念がなく、TypeScriptがコンパイル時に検査するための独自機能。 + +--- + +# 4. 実装コード + +**このコードの大まかな構造(骨格)** + +1. まず`strs`が正しい入力(配列・全要素が文字列・1件以上)かを検証する +2. 要素が1つだけなら、それをそのまま返す(エッジケース処理) +3. 先頭の文字列を「共通接頭辞の候補」として、残りの文字列と順番に比較しながら候補を縮めていく +4. 候補が空文字列になった時点で早期終了し、最終的な候補を返す + +```typescript +/** + * 複数の文字列に共通する最長の接頭辞を求める。 + * + * 【なぜこのやり方か】 + * 先頭の文字列を「共通接頭辞の候補(prefix)」として仮に置き、 + * 残りの文字列を1つずつ見ていって「候補で始まっているか」を確認する。 + * 始まっていなければ、候補を1文字短くして再チェックする。 + * これを繰り返すことで、全員に共通する最長の部分だけが最終的に残る。 + * + * @param strs - 共通接頭辞を調べたい文字列の配列(読み取り専用) + * @returns 全文字列に共通する最長の接頭辞。共通部分が無ければ空文字列。 + * @throws {TypeError} strs が配列でない、または要素に文字列以外が含まれる場合 + * @throws {RangeError} strs が空配列の場合 + * @complexity Time: O(S) — S は全文字列の合計文字数 + * Space: O(1) — 戻り値の文字列を除き追加のメモリはほぼ使わない + */ +function longestCommonPrefix(strs: readonly string[]): string { + // --- 1. 型ガード + 入力検証 --- + // Array.isArray は「本当に配列かどうか」を実行時に確認する組み込み関数。 + // TypeScriptの型注釈(readonly string[])はコンパイル時にしか効かないため、 + // 実行時に不正な値が来る可能性を防ぐにはこのチェックが欠かせない。 + if (!Array.isArray(strs)) { + throw new TypeError('strs must be an array of strings'); + } + + // 制約上 1 <= strs.length は保証されるが、防御的プログラミングとして明示的に検証する。 + if (strs.length === 0) { + throw new RangeError('strs must contain at least one string'); + } + + // every() はすべての要素が条件を満たすかを1回の走査で判定する組み込みメソッド。 + // 1つでも文字列でない要素があれば false になり、for文より簡潔に書ける。 + if (!strs.every((s): s is string => typeof s === 'string')) { + throw new TypeError('All elements of strs must be strings'); + } + + // --- 2. エッジケース処理 --- + // 要素が1つだけなら、それ自体が唯一の「全員に共通する接頭辞」になる。 + if (strs.length === 1) { + return strs[0]; + } + + // --- 3. メインアルゴリズム(水平走査)--- + // 先頭の文字列を「共通接頭辞の候補」として仮に置く。 + // let で宣言するのは、この後 prefix の値を段階的に短くしていく(再代入する)ため。 + let prefix: string = strs[0]; + + // 2番目以降の文字列を順番にチェックしていく。 + for (let i = 1; i < strs.length; i++) { + const current: string = strs[i]; + + // startsWith は「文字列が特定の接頭辞で始まっているか」を判定する組み込みメソッド。 + // prefix が current の先頭と一致しなくなるまで、1文字ずつ候補を短くする。 + while (!current.startsWith(prefix)) { + // slice(0, prefix.length - 1) は「末尾の1文字を除いた新しい文字列」を作る。 + // JavaScriptの文字列はイミュータブル(=一度作ったら中身を書き換えられない値)なので、 + // 「短くする」とは実際には「短い新しい文字列を作って prefix に代入し直す」ことを意味する。 + prefix = prefix.slice(0, prefix.length - 1); + + // 候補が空文字列になった時点で、これ以上共通部分は無いと確定するので即座に返す(早期終了)。 + if (prefix === '') { + return ''; + } + } + } + + // --- 4. 結果を返す --- + // ループを最後まで抜けた= prefix が全文字列に共通する最長の接頭辞。 + return prefix; +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> ``` +> 初期状態: strs = ["flower", "flow", "flight"] +> Step 1: 入力検証 → 配列・全要素string・3件 → 検証通過 +> Step 2: strs.length === 1 ? → false(エッジケースではない) +> Step 3: prefix = "flower"(先頭文字列を候補として仮置き) +> +> i=1, current = "flow" +> "flow".startsWith("flower") → false +> → prefix を "flowe" に短縮 → まだ false +> → prefix を "flow" に短縮 → "flow".startsWith("flow") → true → whileループ終了 +> +> i=2, current = "flight" +> "flight".startsWith("flow") → false +> → prefix を "flo" に短縮 → false +> → prefix を "fl" に短縮 → "flight".startsWith("fl") → true → whileループ終了 +> +> Step 4: ループ終了、prefix = "fl" を返す +> 結果: "fl" +> ``` +> +> 例2(`["dog","racecar","car"]`)の場合、`prefix = "dog"`から始まり、`"racecar".startsWith("dog")`が常にfalseのまま候補を1文字ずつ削っていき、最終的に`prefix === ''`になった時点で即座に`""`が返されます。 + +> 💡 **`readonly string[]`とTypeScript特有の安全性(初学者向け)** +> JavaScriptには「この配列は変更禁止」という仕組みがありません。配列はいつでも`push`や`sort`で書き換えられてしまいます。TypeScriptの`readonly string[]`は、コンパイラに対して「この関数の中では`strs`を書き換えません」と宣言する仕組みです。 +> +> ```typescript +> function bad(strs: readonly string[]): void { +> strs.push('x'); // コンパイルエラー! readonly配列にはpushできない +> } +> ``` +> +> これにより、「関数を呼んだ側が渡した配列が、関数の中で勝手に書き換えられてしまう」という事故をコンパイル時に防げます。今回のコードでも`strs`自体は一切書き換えず、代わりに`prefix`という新しい変数を段階的に更新することで、**Pure function(純粋関数=同じ入力を与えると必ず同じ出力を返し、外部の状態を変えない関数)**の性質を保っています。 + +> 📖 **このセクションで登場した用語** +> +> - **`readonly`**:配列や変数の値を変更できないようにする修飾子。意図せぬ書き換えを防ぐ。 +> - **型ガード(`s is string`)**:`every`の中で使っている`(s): s is string => ...`という書き方は、TypeScriptに「この関数がtrueを返した要素はstring型として扱ってよい」と伝える特別な構文(ユーザー定義型ガード)。 +> - **`startsWith`**:文字列が指定した接頭辞で始まっているかを判定する組み込みメソッド。 +> - **`slice`**:文字列や配列の一部分を新しく切り出すメソッド。元の文字列は変更されない(イミュータブル)。 +> - **Pure function(純粋関数)**:同じ入力を与えると必ず同じ出力を返し、外部の状態を変えない関数。 + +--- + +# 5. 検証 + +> 💡 **初学者向け補足**:エッジケースのテストは、アルゴリズムが"ふつうの入力"だけでなく"極端な入力"でも正しく動くかを確かめるためのものです。 + +- **境界値テスト**(実際にテストコードは書きませんが、以下のケースで正しく動作することを確認すべきです) + - `["a"]` → 要素1件 → エッジケース処理で`"a"`をそのまま返す + - `["", "abc"]` → 空文字列が混じっている → 先頭要素により `prefix` が `""` から始まり、`startsWith("")` が `true` となるため `prefix` を縮めるループに入らず処理が進み、最終的に `""` が返る + - `["abc", "abc", "abc"]` → 全員完全一致 → `prefix`は一度も縮まらず`"abc"`のまま返る + - `["dog", "racecar", "car"]` → 先頭文字から不一致 → `""` + - 200個・各200文字の文字列がすべて一致 → 計算量O(S)なので最大4万文字でも一瞬で終わる +- **型チェック**: `readonly string[]`と型ガード(`s is string`)の組み合わせにより、コンパイル時には「配列以外が渡されないこと」を保証し、実行時には`Array.isArray`と`every`で二重にチェックしている。 + +> 📖 **このセクションで登場した用語** +> +> - **エッジケース**:空の文字列を含む・要素1つだけなど、境界的な条件のこと。 +> - **境界値テスト**:エッジケースに対してもアルゴリズムが正しく動くかを確かめること。 + +--- + +## 最終提出コード(LeetCode回答フォーマット) + +```typescript +function longestCommonPrefix(strs: string[]): string { + // 1. 入力検証 + if (!Array.isArray(strs)) { + throw new TypeError('strs must be an array of strings'); + } + if (strs.length === 0) { + throw new RangeError('strs must contain at least one string'); + } + if (!strs.every((s): s is string => typeof s === 'string')) { + throw new TypeError('All elements of strs must be strings'); + } + + // 2. エッジケース:要素が1つならそれ自体が答え + if (strs.length === 1) { + return strs[0]; + } + + // 3. 水平走査:先頭文字列を候補にして、他の文字列と比較しながら縮める + let prefix: string = strs[0]; + for (let i = 1; i < strs.length; i++) { + const current: string = strs[i]; + while (!current.startsWith(prefix)) { + prefix = prefix.slice(0, prefix.length - 1); + if (prefix === '') { + return ''; + } + } + } + + return prefix; +} +``` diff --git a/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/README.md b/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/README.md new file mode 100644 index 00000000..853c3acb --- /dev/null +++ b/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/README.md @@ -0,0 +1,600 @@ +# Longest Common Prefix - 複数文字列の共通接頭辞を求める(Python/TypeScript/Go/Rust実装比較) + +

目次

+ +- [概要](#overview) +- [アルゴリズム要点(TL;DR)](#tldr) +- [図解](#figures) +- [正しさのスケッチ](#correctness) +- [計算量](#complexity) +- [Python 実装](#impl) +- [TypeScript 実装](#impl-ts) +- [Go 実装](#impl-go) +- [Rust 実装](#impl-rust) +- [言語間比較](#comparison) +- [CPython最適化ポイント](#cpython) +- [エッジケースと検証観点](#edgecases) +- [FAQ](#faq) + +--- + +

概要

+ +> 💡 **初学者向け補足**:この問題は、一言で言うと「複数の文字列を先頭から見比べて、全員が同じ文字を並べている部分(接頭辞)だけを取り出す問題」です。 + +**問題(LeetCode 14. Longest Common Prefix)**:文字列の配列 `strs` が与えられたとき、すべての文字列に共通する最長の接頭辞(先頭部分)を返してください。共通する接頭辞が存在しない場合は、空文字列 `""` を返します。 + +- **入力**: 文字列のリスト `strs`(`1 <= strs.length <= 200`、`0 <= strs[i].length <= 200`、非空なら小文字の英字のみ) +- **出力**: 全文字列に共通する最長の接頭辞(`str`) +- **代表例**: + - `["flower", "flow", "flight"]` → `"fl"` + - `["dog", "racecar", "car"]` → `""` + +この問題が難しく感じられるポイントは2つあります。1つ目は「共通接頭辞の長さが、文字列の本数が増えるほど短くなっていく」という性質を効率よく扱う必要がある点です。2つ目は、空文字列や要素数1件などの**境界的な入力(エッジケース)**を見落とすと、実装が一見正しく動いているように見えても特定の入力でだけ誤った結果を返してしまう点です。この2点を意識しながら、Python・TypeScript・Go・Rustの4言語でそれぞれの言語の特性を活かした実装を行います。 + +> 📖 **この章で登場した用語** +> +> - **制約**:入力として与えられる値の範囲や条件のこと。例:「文字列の長さは0以上200以下」 +> - **正当性**:アルゴリズムが常に正しい答えを返すことの保証 +> - **エッジケース**:空文字列・要素数1件・全要素一致など、境界的な条件の入力 + +--- + +

アルゴリズム要点(TL;DR)

+ +> 💡 **初学者向け補足**:TL;DR(Too Long; Didn't Read、=長くて読めない人向けの要約)とは、アルゴリズム全体の戦略を先にざっくり掴むための章です。「なんとなくこういう手順で解くんだな」というイメージを持てればOKです。詳細は後の章で説明します。 + +- **戦略**: 先頭の文字列を「共通接頭辞の候補」として仮に置き、残りの文字列と1つずつ比較しながら候補を縮めていく**水平走査**を採用します。なぜこの戦略を選ぶかというと、追加のデータ構造(ヒープやマップなど)を必要とせず、文字列比較の組み込み機能(`str.startswith` や `strings.HasPrefix` など)だけで実装でき、4言語すべてで最もシンプルかつ効率的に書けるからです。 +- **データ構造**: 追加のデータ構造は使わず、文字列そのものと「候補の長さ」を表す整数だけで完結します。 +- **計算量**: 時間計算量 O(S)(S = 全文字列の合計文字数)、空間計算量 O(1)(出力用の文字列を除く)。 +- **メモリ**: 候補を「新しい文字列を作って縮める」のではなく「範囲(スライス)を狭める」ことで、余計なメモリ確保を最小限に抑えます。 + +> 📖 **この章で登場した用語** +> +> - **TL;DR**:「長くて読めない人向けの要約」を意味する略語 +> - **水平走査**:1つの文字列を基準にして、他の文字列と丸ごと比較しながら接頭辞を縮めていく方法 +> - **スライス**:文字列や配列の一部分(範囲)だけを、コピーを作らずに参照する操作 + +--- + +

図解

+ +> 💡 **初学者向け補足**:図はアルゴリズムの「処理の流れ」を視覚的に示したものです。フローチャートでは、ひし形(`{}`)は条件分岐を、長方形(`[]`)は処理ステップを表します。矢印をたどることで、入力から出力までの道筋を追うことができます。 + +### フローチャート + +この図は、`longestCommonPrefix` 関数が入力を受け取ってから結果を返すまでの処理の流れを表しています。上から下へ読み進めてください。 + +```mermaid +flowchart TD + Start[Start solve] --> CheckEmpty{Is strs empty} + CheckEmpty -- Yes --> RetEmpty[Return empty string] + CheckEmpty -- No --> CheckOne{Is length one} + CheckOne -- Yes --> RetFirst[Return the only string] + CheckOne -- No --> InitPrefix[Set prefix to first string] + InitPrefix --> Loop{More strings to check} + Loop -- No --> RetPrefix[Return prefix] + Loop -- Yes --> TakeNext[Take next string] + TakeNext --> Match{Does it start with prefix} + Match -- Yes --> Loop + Match -- No --> Shrink[Shrink prefix by one char] + Shrink --> CheckZero{Is prefix empty now} + CheckZero -- Yes --> RetEmptyTwo[Return empty string] + CheckZero -- No --> Match +``` + +主要なノードの意味: + +- `Start[Start solve]`:アルゴリズムの入り口。`strs` を受け取る。 +- `CheckEmpty{Is strs empty}`:配列が空かどうかを判定する条件分岐。空なら共通接頭辞は定義できないため空文字列を返す。 +- `CheckOne{Is length one}`:要素数が1件だけかを判定する。1件ならその文字列自体が答え。 +- `InitPrefix[Set prefix to first string]`:先頭の文字列を「候補」として仮に置くステップ。 +- `Loop{More strings to check}`:まだ比較していない文字列が残っているかを確認する条件分岐。 +- `Match{Does it start with prefix}`:現在の文字列が候補で始まっているかを判定する条件分岐。 +- `Shrink[Shrink prefix by one char]`:候補を1文字だけ短くする処理。 +- `CheckZero{Is prefix empty now}`:候補が空になったかを判定する。空になれば共通部分は無いと確定する。 + +### データフロー図 + +この図は、入力データがどのように変換されて最終的な出力になるかを表しています。 + +```mermaid +graph LR + subgraph Precheck + A[Input strs] --> B[Validate not empty] + B --> C[Handle single element case] + end + subgraph Core + C --> D[Pick first string as prefix] + D --> E[Compare with each remaining string] + E --> F[Shrink prefix on mismatch] + end + F --> G[Output longest common prefix] +``` + +主要な流れの説明: + +- `Input strs` から `Validate not empty` へ:まず配列が空でないかを確認する。 +- `Handle single element case` から `Pick first string as prefix` へ:要素数1件のケースを先に処理してから、通常のメインロジックに入る。 +- `Compare with each remaining string` から `Shrink prefix on mismatch` へ:不一致が見つかるたびに候補を縮め、最終的に全員に共通する部分だけが残る。 + +> 💡 **代表例でのトレース**:`["flower", "flow", "flight"]` を入力として、上記フローチャートをどう通過するか示します。 + +``` +Step 1: Start → strs = ["flower", "flow", "flight"] +Step 2: CheckEmpty → 空ではない → No +Step 3: CheckOne → 要素数3 → No +Step 4: InitPrefix → prefix = "flower" +Step 5: Loop → "flow" が残っている → Yes +Step 6: TakeNext → current = "flow" +Step 7: Match → "flow".startswith("flower") → No +Step 8: Shrink → prefix = "flowe" → CheckZero → No → Match再判定 + ("flowe"→"flow" まで縮小してMatch成立) +Step 9: Loop → "flight" が残っている → Yes +Step 10: TakeNext → current = "flight" +Step 11: Match → "flight".startswith("flow") → No +Step 12: Shrink → "flo"→"fl" まで縮小してMatch成立 +Step 13: Loop → 残りなし → No +結果: prefix = "fl" を返す +``` + +> 📖 **この章で登場した用語** +> +> - **フローチャート**:処理の手順を図形と矢印で表したもの。ひし形=条件分岐、長方形=処理 +> - **データフロー図**:データがどのように変換・移動するかを示す図 +> - **サブグラフ**:フローチャートの中で関連する処理をグループ化したもの + +--- + +

正しさのスケッチ

+ +> 💡 **初学者向け補足**:「正しさのスケッチ」とは、アルゴリズムが**常に正しい答えを返すことの根拠**を整理したものです。数学的な証明ではなく「なぜ正しいと言えるか」の直感的な説明です。 + +- **不変条件(=処理中ずっと成り立ち続けるべき条件)**: ループの各時点で、変数 `prefix`(候補)は「これまでに走査した文字列すべてに共通する接頭辞」であり続けます。例えば `["flower", "flow", "flight"]` の走査中、`"flow"` を処理し終えた時点では `prefix = "flow"` が `"flower"` と `"flow"` の両方に共通する接頭辞であることが保証されています。 +- **網羅性(=すべてのケースをもれなく処理できているという保証)**: すべての文字列を1つずつ走査し、各文字列に対して候補が一致するまで縮める処理を行うため、どの文字列も見逃されません。 +- **基底条件(=再帰やループの終了条件)**: 候補の長さが0になった時点(=`prefix == ""`)で、これ以上共通部分がないことが確定するため即座に処理を打ち切ります。これは「空文字列との共通部分は必ず0文字である」という直感とも一致します。 +- **終了性(=アルゴリズムが必ず有限ステップで終わるという保証)**: 候補の長さは1文字ずつ単調に減少し、文字列の走査回数も有限(最大200件)であるため、無限ループに陥ることはありません。 + +> 📖 **この章で登場した用語** +> +> - **不変条件**:アルゴリズムが正しく動くために、処理中ずっと成り立ち続けるべき条件 +> - **基底条件**:ループや再帰の終了条件。これがないと無限ループになる +> - **終了性**:アルゴリズムが必ず有限ステップで終わるという保証 +> - **網羅性**:すべてのケースをもれなく処理できているという保証 + +--- + +

計算量

+ +> 💡 **初学者向け補足**:計算量とは「入力が大きくなるにつれて、処理にかかる時間・メモリがどう増えるか」の目安です。 + +| 記法 | 意味 | 直感的なイメージ | +| ------------ | ----------------------- | ------------------------------ | +| `O(1)` | 入力サイズによらず一定 | 辞書で直接ページを開く | +| `O(n)` | 入力に比例して増加 | リストを端から順に読む | +| `O(S)` | 全文字数Sに比例して増加 | すべての文字を1回ずつ読む | +| `O(n log n)` | nよりやや速く増加 | 辞書を二分探索で引く×n回 | + +この問題では、以下の計算量になります。 + +- **時間計算量**: O(S) (S = 全文字列の合計文字数。最大でも200 × 200 = 40,000文字程度) +- **空間計算量**: O(1) (出力用の文字列を除き、追加のメモリはほぼ使わない) + +**in-place vs Pure 比較表** + +| アプローチ | 特徴 | 本問題での該当箇所 | +| ------------------------------------------------------------------ | ------------------------------------------------------------ | -------------------------------------------------------- | +| in-place(=新しいメモリを確保せず元のデータを直接書き換える操作) | 候補の「長さ」だけを整数で管理し、新しい文字列を都度作らない | Go / Rust 版の `prefix[:i]` スライス式による縮小 | +| Pure(=入力を変更せず新しいデータを返す操作) | 元の入力配列 `strs` は一切書き換えない | 4言語すべての実装(`strs` 自体はどの言語でも不変のまま) | + +> 📖 **この章で登場した用語** +> +> - **時間計算量**:入力の大きさに対して処理にかかる手間がどう増えるかの目安 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安 +> - **in-place**:新しいメモリを確保せず元のデータを直接書き換える操作。空間計算量を抑えられる +> - **Pure**:入力を変更せず新しいデータを返す操作。安全だがメモリを余分に使う + +--- + +

Python 実装

+ +> 💡 **初学者向け補足**:コードを読む前に、実装の全体的な骨格を示します。 +> +> 1. `strs` が空でないかを確認する +> 2. 要素が1件だけならそれをそのまま返す(エッジケース処理) +> 3. `zip(*strs)` で同じ位置の文字を束ね、`set` で全員一致か判定する垂直走査を行う +> 4. 一致した文字だけをリストに集め、最後に `"".join()` で結合して返す + +Pythonでは、前の回答で比較検討した通り「垂直走査(`zip` + `set`)」を採用しています。`zip(*strs)` は最短の文字列の長さで自動的にイテレーションが止まるため、範囲チェックを自前で書く必要がありません。`List[str]` のような型ヒントは、pylance(VSCodeの型チェッカー)が実行前に型の誤りを検出できるようにするためのものです。 + +```python +from __future__ import annotations + +from typing import List + + +class Solution: + def longestCommonPrefix(self, strs: List[str]) -> str: + # 入力検証:空配列なら共通する接頭辞は定義できないため、空文字列を返す。 + # ここで弾いておかないと、後続の zip(*strs) が何も生成せず + # 意図が分かりにくいコードになってしまう。 + if not strs: + return "" + + # エッジケース:要素が1つだけならそれ自体が唯一の共通接頭辞になる。 + if len(strs) == 1: + return strs[0] + + # 垂直走査:zip(*strs) は「複数の文字列を同じ位置ごとに束ねる」組み込み関数。 + # 例: zip("flow", "flower") → ('f','f'), ('l','l'), ('o','o'), ('w','w') + # 最短の文字列の長さで自動的に止まるため、 + # 「最長共通接頭辞は最短文字列の長さを超えられない」という性質と噛み合っている。 + prefix_chars: List[str] = [] + for chars in zip(*strs): + # set(chars) で重複を除いた集合を作り、サイズが1なら全員同じ文字。 + if len(set(chars)) == 1: + prefix_chars.append(chars[0]) + else: + # 1文字でも異なればそれ以上は共通しないため打ち切る(早期終了)。 + break + + # "".join() でリストを1つの文字列に結合する。 + # 文字列はイミュータブル(一度作ったら変更できない)なので、 + # + での逐次連結より効率的。 + return "".join(prefix_chars) +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> ``` +> 入力: strs = ["flower", "flow", "flight"] +> Step 1: not strs → False(空ではない) +> Step 2: len(strs) == 1 → False(エッジケースではない) +> Step 3: zip(*strs) は最短の "flow"(4文字)で止まる +> index0: ('f','f','f') → set サイズ1 → prefix_chars=['f'] +> index1: ('l','l','l') → set サイズ1 → prefix_chars=['f','l'] +> index2: ('o','o','i') → set サイズ2 → break +> 結果: "".join(['f','l']) = "fl" +> ``` + +> 📖 **この章で登場した用語** +> +> - **`from __future__ import annotations`**:型ヒントの評価を遅延させる宣言。前方参照を書きやすくする +> - **`zip()`**:複数のイテラブルを同じ位置同士でペアにまとめる組み込み関数。最も短いものの長さで自動的に止まる +> - **`set()`**:重複を許さない集合を作る組み込み関数。「全部同じ値か」を`len(set(...))==1`で一発判定できる +> - **`List[str]`**:「文字列のリスト」を表す型ヒント + +--- + +

TypeScript 実装

+ +> 💡 **初学者向け補足**:TypeScript版では、Pythonの「垂直走査」とは異なる**水平走査**(先頭文字列を基準に縮めていく方式)を採用しています。どちらも計算量はO(S)ですが、V8エンジン(Node.jsが使うJavaScriptエンジン)に最適化された `startsWith` メソッドを活かせる点でこちらを選びました。 + +先頭の文字列を候補 `prefix` として仮に置き、`String.prototype.startsWith`(C++実装で高速な組み込みメソッド)で他の文字列と照合しながら1文字ずつ縮めていきます。`readonly string[]` という型注釈は、「この関数の中では配列を書き換えない」という約束をコンパイラに伝えるためのものです。 + +```typescript +/** + * 複数の文字列に共通する最長の接頭辞を求める。 + * 先頭の文字列を候補とし、他の文字列が候補で始まっているかを + * startsWith で確認しながら、始まっていなければ候補を1文字ずつ縮める。 + * + * @param strs - 共通接頭辞を調べたい文字列の配列(読み取り専用) + * @returns 全文字列に共通する最長の接頭辞。共通部分が無ければ空文字列。 + * @throws {TypeError} strs が配列でない、または要素に文字列以外が含まれる場合 + * @throws {RangeError} strs が空配列の場合 + * @complexity Time: O(S), Space: O(1) + */ +function longestCommonPrefix(strs: readonly string[]): string { + // 型ガード:実行時にも配列かどうかを確認する。 + // TypeScript の型注釈はコンパイル時にしか効かないため、二重の安全策として必要。 + if (!Array.isArray(strs)) { + throw new TypeError('strs must be an array of strings'); + } + if (strs.length === 0) { + throw new RangeError('strs must contain at least one string'); + } + if (!strs.every((s): s is string => typeof s === 'string')) { + throw new TypeError('All elements of strs must be strings'); + } + + // エッジケース:要素が1つだけならそれ自体が答え。 + if (strs.length === 1) { + return strs[0]; + } + + // 先頭の文字列を候補として仮置きする。 + let prefix: string = strs[0]; + + for (let i = 1; i < strs.length; i++) { + const current: string = strs[i]; + + // startsWith は「current が prefix で始まっているか」を判定する。 + while (!current.startsWith(prefix)) { + // slice で末尾1文字を除いた新しい候補を作る。 + // 文字列はイミュータブルなので、実際には短い新しい文字列に代入し直している。 + prefix = prefix.slice(0, prefix.length - 1); + + // 候補が空になれば共通部分は無いと確定するため即座に返す。 + if (prefix === '') { + return ''; + } + } + } + + return prefix; +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> ``` +> 入力: strs = ["flower", "flow", "flight"] +> prefix = "flower" +> i=1, current="flow": "flow".startsWith("flower") → false +> → "flowe" → false → "flow" → true +> i=2, current="flight": "flight".startsWith("flow") → false +> → "flo" → false → "fl" → true +> 結果: "fl" +> ``` + +> 📖 **この章で登場した用語** +> +> - **`readonly`**:配列や変数の値を変更できないようにする修飾子。JavaScriptにはない、TypeScript独自のコンパイル時チェック機能 +> - **型ガード**:`typeof` や `Array.isArray` などで実行時に値の型を確認し、その情報をもとに型を絞り込む仕組み +> - **`startsWith`**:文字列が指定した接頭辞で始まっているかを判定する組み込みメソッド + +--- + +

Go 実装

+ +> 💡 **初学者向け補足**:Go版もTypeScript版と同じ「水平走査」を採用しています。Goには例外機構(`try-catch`)が無く、代わりにエラーを戻り値として返す設計が慣習になっています。LeetCode提出用の関数はエラーを返さず空文字列で異常系を表現しています。 + +Goの文字列(`string`)はイミュータブルなバイト列です。`prefix[:len(prefix)-1]` のようなスライス式は、新しいバイト列をコピーせず「範囲」だけを変更するため、追加のメモリ確保(アロケーション)がほとんど発生しません。`strings.HasPrefix` は標準ライブラリの比較関数で、自前でバイトループを書くより可読性と信頼性が高くなります。 + +```go +package main + +import "strings" + +// longestCommonPrefix は複数の文字列に共通する最長の接頭辞を求める。 +// 先頭の文字列を候補とし、strings.HasPrefix で他の文字列と照合しながら +// 一致しなければ候補を1文字ずつ縮める(水平走査)。 +// +// Time Complexity: O(S) S = 全文字列の合計バイト数 +// Space Complexity: O(1) +func longestCommonPrefix(strs []string) string { + // エッジケース:空スライスなら空文字列を返す。 + // strs[0] へ直接アクセスするとパニック(範囲外アクセス)になるため事前に確認する。 + if len(strs) == 0 { + return "" + } + + // 先頭の文字列を候補として仮置きする。 + prefix := strs[0] + + // 2番目以降の文字列を順にチェックしながら候補を縮めていく。 + for i := 1; i < len(strs); i++ { + current := strs[i] + + // current が prefix で始まらなくなるまで、prefix を1文字ずつ短くする。 + for !strings.HasPrefix(current, prefix) { + // スライス式で末尾1文字を除いた範囲を指す。新しいコピーは作らない。 + prefix = prefix[:len(prefix)-1] + + // 候補が空文字列になれば、共通部分は無いと確定し即座に返す。 + if prefix == "" { + return "" + } + } + } + + return prefix +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> ``` +> 入力: strs = []string{"flower", "flow", "flight"} +> prefix = "flower" +> i=1, current="flow": HasPrefix("flow","flower") → false +> → "flowe" → false → "flow" → true +> i=2, current="flight": HasPrefix("flight","flow") → false +> → "flo" → false → "fl" → true +> 結果: "fl" +> ``` + +> 📖 **この章で登場した用語** +> +> - **`error` 戻り値**:JavaやPythonの例外と異なり、Goはエラーを戻り値として返す設計が基本。LeetCode提出用の関数では簡潔さのため使っていない +> - **`strings.HasPrefix`**:ある文字列が指定した接頭辞で始まっているかを判定する標準ライブラリ関数 +> - **スライス式 `s[:i]`**:文字列の一部の「範囲」だけを新しいヘッダとして取り出す構文。バイト列自体はコピーされない +> - **パニック**:Goで回復不能なエラーが発生した際に起きる強制終了 + +--- + +

Rust 実装

+ +> 💡 **初学者向け補足**:Rust版も水平走査を採用していますが、所有権(=値を"誰が管理するか"をコンパイル時に決める仕組み)の扱いが他の3言語と大きく異なります。LeetCodeのシグネチャは `Vec` を引数に取り、この関数がその所有権を受け取りますが、内部処理は `&str`(文字列スライスへの参照)だけで完結させ、無駄な複製(`clone`)を避けています。 + +候補の管理には `String` そのものではなく `usize`(符号なし整数)型の `prefix_len` を使い、「候補として認めているバイト数」だけを追跡します。これにより、候補を縮める操作はヒープへのアクセスを伴わない、スタック上の単純な整数の更新だけで完結します。 + +```rust +impl Solution { + /// 複数の文字列に共通する最長の接頭辞を求める。 + /// 先頭の文字列を候補とし、starts_with で他の文字列と照合しながら + /// 一致しなければ候補の長さ prefix_len を1ずつ縮める(水平走査)。 + /// + /// # Complexity + /// - Time: O(S) S = 全文字列の合計バイト数 + /// - Space: O(1) (戻り値の String を除く) + pub fn longest_common_prefix(strs: Vec) -> String { + // エッジケース:空 Vec なら空文字列を返す。 + if strs.is_empty() { + return String::new(); + } + + // エッジケース:要素が1つならそれ自体が答え。 + if strs.len() == 1 { + return strs[0].clone(); + } + + // 先頭の文字列を &str として借用し、候補の長さを prefix_len で管理する。 + // 所有権を移動させないため、strs はこの後も生きたまま使える。 + let first: &str = strs[0].as_str(); + let mut prefix_len: usize = first.len(); + + // 2番目以降の文字列を順にチェックしながら候補を縮めていく。 + for s in strs.iter().skip(1) { + let current: &str = s.as_str(); + + while !current.starts_with(&first[..prefix_len]) { + // 候補の長さを1減らす。新しい String は作らず整数を更新するだけ。 + prefix_len -= 1; + + // 候補が空になれば共通部分は無いと確定し即座に返す。 + if prefix_len == 0 { + return String::new(); + } + } + } + + // 確定した prefix_len 分だけ切り出し、to_string() で所有権を持つ String に変換する。 + first[..prefix_len].to_string() + } +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> ``` +> 入力: strs = vec!["flower", "flow", "flight"](String化済み) +> first = "flower", prefix_len = 6 +> s="flow": starts_with("flower"[..6]) → false +> prefix_len=5→"flowe"→false, prefix_len=4→"flow"→true +> s="flight": starts_with("flower"[..4]="flow") → false +> prefix_len=3→"flo"→false, prefix_len=2→"fl"→true +> 結果: "flower"[..2].to_string() = "fl" +> ``` + +> 📖 **この章で登場した用語** +> +> - **所有権**:値を"誰が管理するか"をコンパイル時に決めるRust独自の仕組み +> - **借用**:所有権を渡さずに値を参照する仕組み(`&T`) +> - **`usize`**:符号なし整数型。配列のインデックスや長さに使われる +> - **`to_string()`**:`&str` から新しい `String` を作る(ヒープにコピーする)メソッド + +--- + +

言語間比較

+ +> 💡 **初学者向け補足**:同じアルゴリズム(水平走査、またはPythonのみ垂直走査)でも、言語ごとに「メモリの扱い方」や「エラーの表現方法」が異なります。ここでは4言語の実装を横並びで比較し、それぞれの言語特有の設計判断を振り返ります。 + +| 観点 | Python | TypeScript | Go | Rust | +| -------------------- | --------------------------------- | ---------------------------------------------- | ---------------------------------- | ------------------------------------------------------------ | +| 採用アルゴリズム | 垂直走査(`zip`+`set`) | 水平走査(`startsWith`) | 水平走査(`strings.HasPrefix`) | 水平走査(`starts_with`、長さのみ管理) | +| 候補の縮め方 | 文字を1つずつリストに集める | `slice` で新しい文字列を再代入 | スライス式 `s[:i]` で範囲を変更 | `usize` の整数だけを更新 | +| メモリ確保の特徴 | `"".join()` で最後に1回結合 | `slice` のたびに新しい文字列(イミュータブル) | スライス式はコピーなし | 整数更新のみ、最後に `to_string()` で1回だけ確保 | +| エラー表現 | `TypeError` / `ValueError` の例外 | `TypeError` / `RangeError` の例外 | `error` 戻り値(LeetCode版は省略) | `Result` が定石だが、戻り値型固定のため空文字列で表現 | +| 所有権・参照の考え方 | 該当なし(GC管理) | 該当なし(GC管理) | 該当なし(GC管理) | `&str` による借用で `Vec` の所有権を保持したまま処理 | +| 型安全性の担保方法 | 型ヒント + pylance | `readonly` + 型ガード | 明示的な型宣言 + `go vet` | 所有権システム + コンパイラチェック | + +**共通点**: どの言語でも計算量は O(S)(S = 全文字列の合計文字数)であり、追加のヒープ確保を最小限に抑える設計を採っている点は共通しています。 + +**相違点の背景**: PythonとTypeScript、GoはGC(ガベージコレクション)付きの言語であるため、文字列の縮小操作で新しいオブジェクトが生成されてもGCが後始末をしてくれます。一方Rustにはガベージコレクタが無く、コンパイル時に所有権を追跡する仕組みがあるため、「新しい文字列を作らず整数だけを更新する」という、より低レベルなメモリ効率を意識した設計が自然に選ばれます。 + +> 📖 **この章で登場した用語** +> +> - **GC(ガベージコレクション)**:使い終わったメモリを自動で回収する仕組み。Python・TypeScript(JavaScript)・Goが持つ +> - **所有権システム**:Rust独自の、コンパイル時にメモリの管理者を1つに定める仕組み。GC不要でメモリ安全性を実現する + +--- + +

CPython最適化ポイント

+ +> 💡 **初学者向け補足**:この章では「同じ処理でもPythonの書き方によって速さが変わる理由」を説明します。最適化テクニックは「最適化前 → 最適化後 → なぜ速くなるか」の3点セットで見ていきます。 + +```python +# 最適化前:文字列を + で逐次連結する +# str はイミュータブルなので、+= のたびに新しいオブジェクトが作られ直す +prefix = "" +for ch in prefix_chars: + prefix += ch + +# 最適化後:リストに集めてから join で一括結合する +prefix = "".join(prefix_chars) +# 理由:join は内部で必要なメモリサイズを事前に計算してから +# 1回だけ確保するため、逐次連結よりアロケーション回数が少ない +``` + +- **組み込み関数の活用**: `zip()` と `set()` はどちらもC言語で実装された組み込み機能であり、Pythonレベルのループより高速です。 +- **早期終了**: 不一致が見つかった時点で `break` することで、それ以上の無駄な走査を避けています。 +- **`zip` の自動停止**: `zip(*strs)` は最短の文字列の長さで自動的に止まるため、`min(len(s) for s in strs)` を自前で計算する手間が省けます。 + +> 📖 **この章で登場した用語** +> +> - **アロケーション**:新しいメモリ領域を確保する操作。頻繁に行うと処理が遅くなる +> - **早期終了**:それ以上調べても答えが変わらないと分かった時点で処理を打ち切ること +> - **`join()`**:文字列のリストを1つの文字列に結合する組み込みメソッド + +--- + +

エッジケースと検証観点

+ +> 💡 **初学者向け補足**:エッジケースとは「入力が空・最小値・最大値・重複あり」など、通常とは異なる境界的な入力のことです。エッジケースを見落とすと、普通のテストは通るのに特定の入力でだけバグが発生します。 + +| エッジケース | 入力例 | なぜ問題になりうるか | 期待される結果 | +| ------------------ | ---------------------------- | -------------------------------------------------------------------------------------------- | ------------------ | +| 空配列 | `[]` | 先頭要素 `strs[0]` へのアクセスがインデックスエラー(Go/Rustではパニック)になる可能性がある | 空文字列 `""` | +| 要素数1件 | `["a"]` | ループに一度も入らないため、特別扱いしないと実装によっては空文字列を返してしまう恐れがある | `"a"` | +| 空文字列を含む | `["", "abc"]` | 候補の初期長さが0になるため、比較ループの扱いを誤ると想定外の挙動になりやすい | `""` | +| 全要素完全一致 | `["abc", "abc", "abc"]` | 候補が一度も縮まらないケースであり、ループを抜ける条件が正しいか確認が必要 | `"abc"` | +| 先頭文字から不一致 | `["dog", "racecar", "car"]` | 最初の比較で候補がすぐ空になるケース。早期終了が正しく機能するか確認できる | `""` | +| 最大規模 | 200個・各200文字がすべて一致 | 計算量がO(S)であることを確認する規模。最大4万文字でも一瞬で終わるべき | 全文字列と同じ内容 | + +> 📖 **この章で登場した用語** +> +> - **エッジケース**:空の配列・要素1つ・最大サイズ入力など、境界的な条件の入力 +> - **境界値**:制約の上限・下限にあたる値 +> - **インデックスエラー**:配列や文字列の範囲外の要素にアクセスしようとしたときのエラー + +--- + +

FAQ

+ +**Q1. なぜPythonだけ「垂直走査」で、他の3言語は「水平走査」を使っているのですか?** + +結論:Pythonは `zip()` と `set()` という強力な組み込み機能があるため垂直走査が最も簡潔に書けますが、他の3言語ではこれに相当する機能がないため水平走査の方がシンプルになるからです。 + +理由:垂直走査を他の言語で実装しようとすると、「複数の文字列を同じ位置で束ねるイテレータ」を自作する必要があり、かえってコードが複雑になります。一方、水平走査は「1つの文字列を基準に比較する」というシンプルな考え方で、`startsWith` / `HasPrefix` / `starts_with` のような各言語の標準機能とそのまま噛み合います。 + +補足:どちらのアプローチも計算量はO(S)で同じであり、優劣があるわけではなく「その言語の標準ライブラリと最も相性が良い書き方」を選んでいます。 + +**Q2. Rust版だけ、候補を文字列ではなく整数(`prefix_len`)で管理しているのはなぜですか?** + +結論:Rustの所有権システムのもとでは、文字列を何度も作り直すより、整数1つを更新する方がシンプルで効率的だからです。 + +理由:`String` を毎回作り直すと、そのたびにヒープへの確保・解放のコストがかかります。`usize` の整数だけを更新すれば、スタック上の値の書き換えだけで済み、余計なアロケーションが発生しません。 + +補足:TypeScript版やGo版でも実は近い発想(`slice` によるコピーなしの範囲操作)を使っていますが、Rustでは所有権の制約がより厳格なため、整数管理という形でその発想がさらに徹底されています。 + +**Q3. なぜ Python・TypeScript・Rust の3言語では「要素数1件」のケースを明示的にチェックしているのですか?** + +結論:Python・TypeScript・Rust の3言語では、要素数1件のケースを明示的にアーリーリターンさせることで不要な処理をスキップし、コードの意図を明確にしています。一方、Go言語実装では `1 < len(strs)` のループ条件により、要素数1件の場合はループに入らずそのまま `prefix`(`strs[0]`)を返す構造となっています。 + +理由:Python・TypeScript・Rust の実装では、要素数1件のケースで即座に先頭要素を返すことで無用なイテレーションやオブジェクト生成を抑え、エッジケース処理の意図を明示しています。 + +補足:Go言語実装でも `len(strs) == 0` の境界値チェック後に `prefix := strs[0]` とし、`for i := 1; i < len(strs); i++` の3部構成ループに入ります。要素が1件の場合は条件式が偽となりループの中身が一度も実行されずに `prefix` が返るため、各言語の設計スタイルに合致した正確な動作となります。 + +**Q4. `startsWith` や `HasPrefix` を使わずに、1文字ずつ比較するループを自分で書いてはダメなのですか?** + +結論:動作としては問題ありませんが、標準ライブラリの比較関数を使う方が可読性・信頼性ともに優れているため推奨しません。 + +理由:`startsWith` や `strings.HasPrefix` は各言語のランタイムやコンパイラによって十分に最適化された実装であり、自前でバイト単位のループを書くよりバグが入り込む余地が少なくなります。 + +補足:競技プログラミングのように1マイクロ秒単位の速度を争う場面では自前ループが有利になることもありますが、この問題の制約規模(最大4万文字程度)では両者の速度差は無視できるレベルです。 + +> 📖 **この章で登場した用語** +> +> - **FAQ**:Frequently Asked Questions の略。よくある質問と回答のこと +> - **トレードオフ**:何かを得ると何かを失う関係。例:速さを得るとメモリが増える diff --git a/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/README_react.html b/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/README_react.html new file mode 100644 index 00000000..0e95c8ce --- /dev/null +++ b/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/README_react.html @@ -0,0 +1,1652 @@ + + + + + + + LeetCode 14: Longest Common Prefix - 水平走査法による解説(Python/TypeScript/Go/Rust) + + + + + + + + + + + + + + + + + + +
+ + + + +
+

+ アルゴリズム概要 +

+ +
+

+ 💡 + この問題を一言で言うと:「複数の文字列を先頭から見比べて、全員に共通する最長の接頭辞を取り出す問題」です。 +

+

+ 文字列の配列 strs + が与えられたとき、すべての文字列に共通する最長の接頭辞(先頭部分)を返します。 + 共通する接頭辞が1文字も無ければ、空文字列 "" を返します。 +

+
+ +
+

+ ⚠️ なぜ単純な方法では解けないのか +

+
    +
  • + 共通接頭辞の長さは、文字列の本数が増えるほど短くなっていく可能性があるため、候補をどう縮めていくかを丁寧に管理する必要があります。 +
  • +
  • + 空文字列や要素数1件などの境界的な入力(エッジケース)を見落とすと、実装が一見正しく動いているように見えても、特定の入力でだけ誤った結果を返してしまいます。 +
  • +
+
+ +
+
+
+ O(S) +
+
時間計算量
+
+
+
+ O(1) +
+
空間計算量
+
+
+
+ 最大200件 各200文字 +
+
入力サイズの制約
+
+
+
+ 水平走査 +
+
採用アプローチ
+
+
+ +
+

入出力例

+
+
+

例1

+

+ strs = [flower, flow, flight] +

+

出力: fl

+

+ なぜ正解か:3つの文字列すべてが f と l + で始まっており、3文字目でそれぞれ o / o / i と分かれるため、 + 共通しているのは先頭2文字の fl までです。 +

+
+
+

例2

+

+ strs = [dog, racecar, car] +

+

+ 出力: (空文字列) +

+

+ なぜ正解か:dog, racecar, car + はそもそも先頭の1文字目からすべて異なる(d, r, c)ため、 + 共通する接頭辞は1文字も存在しません。 +

+
+
+
+
+ + +
+

+ ステップバイステップ解説 +

+

+ 代表例 strs = [flower, flow, flight] + を使って、水平走査アルゴリズムがどのように動くかを1ステップずつ確認しましょう。 + 左のリストからステップを選ぶか、「再生」ボタンで自動的に進めることができます。 +

+
+
+ + +
+

+ 実装(Python / TypeScript / Go / Rust) +

+

+ 同じアルゴリズムでも、言語によって書き方や最適化の方向性が変わります。 + タブを切り替えて、4言語それぞれの実装・動作トレース・言語特有のポイントを見比べてみましょう。 +

+
+
+ + +
+

+ 処理フローチャート +

+
+
+ + +
+

+ 計算量分析 +

+ +
+

📖 Big-O 記法の読み方

+
+
+
O(1)
+
入力サイズに関わらず
常に一定の速さ
+
+
+
O(n)
+
入力が2倍になると
処理も約2倍
+
+
+
O(S)
+
全文字数Sに
比例して増加
+
+
+
O(n²)
+
入力が2倍になると
処理は約4倍
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
言語 + 採用アルゴリズム + 時間計算量空間計算量候補の縮め方
Python垂直走査(zip + set)O(S)O(1) + 一致した文字をリストに集め join で結合 +
+ TypeScript + 水平走査(startsWith)O(S)O(1) + slice で新しい文字列に再代入 +
Go + 水平走査(strings.HasPrefix) + O(S)O(1) + スライス式でコピーせず範囲だけ変更 +
Rust水平走査(starts_with)O(S)O(1) + usize の長さのみ更新し文字列は最後に1回だけ生成 +
+
+ +
+

+ 🔍 なぜこの計算量になるのか +

+

+ どの言語の実装も、最悪の場合で全文字列の全文字を最大1回ずつ調べる構造になっているため、時間計算量は + O(S)(Sは全文字列の合計文字数)です。 + 空間計算量については、候補の文字列(または候補の長さを表す整数)以外に追加のデータ構造を使わないため、出力用の文字列を除けば + O(1) になります。 +

+
+
+ + +
+

+ 言語間比較 +

+

+ 同じアルゴリズムでも、言語ごとに「メモリの扱い方」や「エラーの表現方法」が異なります。 + ここでは4言語の実装を横並びで比較し、それぞれの言語特有の設計判断を振り返ります。 +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
観点PythonTypeScriptGoRust
+ メモリ確保の特徴 + + join で最後に1回だけ結合 + + slice のたびに新しい文字列 + スライス式はコピーなし + 整数更新のみ、最後に1回だけ確保 +
+ エラー表現 + + TypeError / ValueError の例外 + + TypeError / RangeError の例外 + + error 戻り値(提出版は省略) + + Result型が定石だが戻り値固定のため空文字列で表現 +
+ メモリ管理方式 + + GC(ガベージコレクション) + + GC(ガベージコレクション) + + GC(ガベージコレクション) + + 所有権システム(GC不要) +
+ 型安全性の担保方法 + 型ヒント + pylancereadonly + 型ガード明示的な型宣言 + go vet + 所有権システム + コンパイラチェック +
+
+ +
+

相違点の背景

+

+ Python・TypeScript・GoはGC(ガベージコレクション、=使い終わったメモリを自動で回収する仕組み)付きの言語であるため、 + 文字列の縮小操作で新しいオブジェクトが生成されてもGCが後始末をしてくれます。 + 一方Rustにはガベージコレクタが無く、コンパイル時に所有権を追跡する仕組みがあるため、 + 「新しい文字列を作らず整数だけを更新する」という、より低レベルなメモリ効率を意識した設計が自然に選ばれます。 +

+
+
+ + +
+

+ 📖 用語集 +

+

+ このページで登場した専門用語をまとめました。分からない言葉が出てきたときに参照してください。 +

+
+
+ + イミュータブル + +
+ 一度作成したら内容を変更できない性質のことです。Python・TypeScript(JavaScript)・Go・Rustのいずれの言語でも文字列はイミュータブルであり、 + 「候補を1文字縮める」処理は実際には新しい範囲を指す文字列(またはスライス)を作り直していることになります。 +
+
+ +
+ + インデックスエラー + +
+ 配列や文字列の範囲外の要素にアクセスしようとしたときに発生するエラーです。 + 例えば空の配列に対して strs[0] + にアクセスすると発生する可能性があるため、多くの実装で最初に空配列のチェックを行います。 +
+
+ +
+ + エッジケース + +
+ 空の配列・要素数1件・全要素が完全一致など、通常とは異なる境界的な条件の入力のことです。 + エッジケースを見落とすと、普通のテストは通るのに特定の入力でだけバグが発生することがあります。 +
+
+ +
+ + 型ヒント + +
+ 関数の引数や戻り値に型を注釈として書く仕組みです。Pythonでは + List[str] + のように書き、pylance(VSCodeの型チェッカー)が実行前に型の誤りを検出できるようにします。 +
+
+ +
+ + 計算量 + +
+ 入力の大きさに対して、処理にかかる手間(時間計算量)や使用するメモリ量(空間計算量)がどう増えるかの目安です。 + Big-O記法(O(n) など)で表され、この問題では時間計算量 O(S)、空間計算量 + O(1) になります。 +
+
+ +
+ + 借用 + +
+ Rust特有の仕組みで、値の所有権を渡さずにその値を参照することです。 + &str + のように書くことで、Vec<String> + の所有権を保持したまま中身を安全に読み取れます。 +
+
+ +
+ + 垂直走査 + +
+ 複数の文字列を「同じ位置(インデックス)」ごとに縦方向に比較していく方法です。 + Pythonの実装では zip と set + という組み込み関数を組み合わせてこの走査を実現しています。 +
+
+ +
+ + 水平走査 + +
+ 1つの文字列(先頭の文字列)を基準の候補として、他の文字列と丸ごと比較しながら候補を縮めていく方法です。 + TypeScript・Go・Rustの実装ではこちらを採用しています。 +
+
+ +
+ + スライス + +
+ 文字列や配列の一部分(範囲)だけを、コピーを作らずに参照する操作です。 + Go の + s[:i] + や Rust の + &s[..n] + がこれにあたります。 +
+
+ +
+ + 早期終了 + +
+ それ以上調べても答えが変わらないと分かった時点で処理を打ち切ることです。 + この問題では候補が空文字列になった時点で、共通接頭辞が無いと確定するため即座に処理を終えます。 +
+
+ +
+ + 所有権 + +
+ 値を"誰が管理するか"をコンパイル時に決めるRust独自の仕組みです。 + ガベージコレクタを使わずにメモリを安全に管理できる一方、値を渡す際に「借用」か「移動」かを意識する必要があります。 +
+
+ +
+ + パニック + +
+ GoやRustで回復不能なエラーが発生した際に起きる強制終了です。 + 範囲外のインデックスやスライスへアクセスしようとした場合などに発生することがあるため、事前の入力検証が重要になります。 +
+
+
+
+ +
+ LeetCode 14: Longest Common Prefix の解説ページ(Python / TypeScript / Go / Rust + 実装比較) +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/README.md b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/README.md new file mode 100644 index 00000000..9866f907 --- /dev/null +++ b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/README.md @@ -0,0 +1,1169 @@ +# 26. Remove Duplicates from Sorted Array - ソート済み配列から重複を除去する(Python / TypeScript / Go / Rust / Java / C / C# / C++ / Swift / Dart / Kotlin) + +## 目次 + +- [概要](#overview) +- [アルゴリズム要点(TL;DR)](#tldr) +- [図解](#figures) +- [正しさのスケッチ](#correctness) +- [計算量](#complexity) +- [Python 実装](#impl-python) +- [TypeScript 実装](#impl-typescript) +- [Go 実装](#impl-go) +- [Rust 実装](#impl-rust) +- [Java 実装](#impl-java) +- [C 実装](#impl-c) +- [C# 実装](#impl-csharp) +- [C++ 実装](#impl-cpp) +- [Swift 実装](#impl-swift) +- [Dart 実装](#impl-dart) +- [Kotlin 実装](#impl-kotlin) +- [言語間比較](#comparison) +- [言語別最適化ポイント](#optimization) +- [エッジケースと検証観点](#edgecases) +- [FAQ](#faq) + +--- + +

概要

+ +> 💡 **初学者向け補足**:この問題は、一言で言うと「すでに並び替え済みの配列の中で、同じ値が連続している部分をひとつにまとめて、配列の前方に詰め直す問題」です。 + +**問題要約**:整数配列 `nums` が非減少順(同じ値の連続を含む昇順)にソートされて与えられます。重複する要素を取り除き、各ユニークな要素が1回だけ現れるようにします。新しい配列を作ってはいけません。`nums` 自体を書き換え(in-place=新しいメモリを確保せず元のデータを直接書き換える操作)、ユニークな要素数 `k` を返します。呼び出し側は `nums[0]` から `nums[k-1]` までを結果として扱います。 + +**この問題が難しいポイント**は主に2つあります。1つ目は「新しい配列を作らずに、既存の配列を書き換える」という in-place の制約です。単純に重複を除いた新しい配列を作るだけならどの言語でも簡単ですが、メモリを増やさずに元の配列だけで完結させるには、書き込み位置を追跡する工夫(Two-Pointer法)が必要になります。2つ目は、`k` 個より後ろの要素の中身は「何でもよい」という判定ルール(Custom Judge)の理解です。最初は「配列の長さそのものを変えなければいけないのでは」と誤解しやすいポイントです。 + +この問題は11言語(Python / TypeScript / Go / Rust / Java / C / C# / C++ / Swift / Dart / Kotlin)すべてで**同一のアルゴリズム(Two-Pointer法)**を採用します。ソート済み配列の性質を使えばO(n)時間・O(1)空間という最適解が得られ、この効率の良さはどの言語でも変わらないためです。ただし、各言語の「メモリ管理の考え方」「エラー表現の方法」「null安全性の仕組み」は大きく異なるため、その違いを随所で解説します。 + +> 📖 **この章で登場した用語** +> +> - **in-place(インプレース)**:新しいメモリを確保せず、元のデータを直接書き換える操作。この問題ではこれが必須要件になっている +> - **非減少順(non-decreasing order)**:前の要素より後の要素が小さくならない並び方。同じ値が連続することは許される(狭義の昇順とは異なる) +> - **Custom Judge**:LeetCodeがこの問題で使う特殊な採点方式。戻り値 `k` と、先頭 `k` 個の要素だけを見て正誤判定する +> - **制約**:入力として与えられる値の範囲や条件のこと。この問題では `1 <= nums.length <= 3 * 10^4` など + +--- + +

アルゴリズム要点(TL;DR)

+ +> 💡 **初学者向け補足**:TL;DR(Too Long; Didn't Read、=「長すぎて読めない人向けの要約」という意味の略語)は、詳細を読む前に「なんとなくこういう手順で解くんだな」というイメージを掴むための章です。 + +- **戦略**:Two-Pointer(二重ポインタ)法を使う。ソート済み配列では同じ値が必ず隣接しているという性質を利用し、`slow`(確定済みユニーク領域の末尾)と `fast`(探索中の位置)という2つのインデックスを異なる速度で動かす。なぜこの手法を選ぶかというと、ソート済みという前提を使わない一般的な重複除去(`Set`など)はO(n)の追加メモリを必要とし、この問題の「in-place」要件を満たせないため。 +- **データ構造**:追加のデータ構造は一切使わない。整数のインデックス変数2個(`slow` と `fast`)だけで完結する。なぜなら、ソート済みという性質があれば「今見ている値が直前の確定値と同じか」を比較するだけで重複かどうか判定できるため、ハッシュテーブルのような補助構造が不要になる。 +- **計算量**:Time O(n)、Space O(1)。配列を1回だけなめる(1パス)ため。 +- **メモリ**:新しい配列・コレクションを一切生成しない。11言語すべてで同じアプローチが最適であるため、今回は「11言語で採用アプローチが異なる場合」には該当せず、全言語共通でTwo-Pointer法を採用する。 + +> 📖 **この章で登場した用語** +> +> - **TL;DR**:「長すぎて読めない人向けの要約」を意味する略語 +> - **Two-Pointer(二重ポインタ)法**:配列上で2つの添字(インデックス)を異なる速度・役割で動かしながら処理を進めるアルゴリズムパターン +> - **1パス**:配列やリストを先頭から末尾まで1回だけ走査して処理を完了させること + +--- + +

図解

+ +> 💡 **初学者向け補足**:図はアルゴリズムの「処理の流れ」を視覚的に示したものです。ひし形(`{}`)は条件分岐、長方形(`[]`)は処理ステップを表すという、Mermaidフローチャートの基本的な読み方を覚えておくと図を読みやすくなります。11言語すべてが同じTwo-Pointer法を採用しているため、この図はそのまま全言語に共通する処理の流れを表しています。 + +### フローチャート + +この図はTwo-Pointer法によるメインループの処理の流れを表しています。上から下へ読み進めてください。 + +```mermaid +flowchart TD + Start[Start removeDuplicates] --> CheckEmpty{Is nums empty} + CheckEmpty -- Yes --> ReturnZero[Return 0] + CheckEmpty -- No --> InitSlow[Set slow to 0] + InitSlow --> InitFast[Set fast to 1] + InitFast --> LoopCheck{fast less than length} + LoopCheck -- No --> ReturnResult[Return slow plus 1] + LoopCheck -- Yes --> Compare{nums fast not equal nums slow} + Compare -- Yes --> AdvanceSlow[Increment slow] + AdvanceSlow --> WriteValue[Write nums fast into nums slow] + WriteValue --> AdvanceFast[Increment fast] + Compare -- No --> AdvanceFast + AdvanceFast --> LoopCheck +``` + +主要なノードの意味: + +- `CheckEmpty`:配列が空かどうかを判定する最初のガード。空なら以降の処理を行わずに0を返す +- `InitSlow` / `InitFast`:確定済みユニーク値の末尾を指す `slow` と、探索位置を指す `fast` を初期化するステップ +- `LoopCheck`:`fast` が配列の末尾に達したかどうかを判定するループの継続条件 +- `Compare`:現在見ている値 `nums[fast]` が直前の確定値 `nums[slow]` と異なるかどうかを判定する条件分岐 +- `AdvanceSlow` と `WriteValue`:新しいユニーク値が見つかったときに、確定領域を1つ広げてその値を書き込む処理 + +### データフロー図 + +この図は入力配列がどのように検証・変換され、最終的な結果に至るかを表しています。 + +```mermaid +graph LR + subgraph Precheck + A[Input array nums] --> B[Check empty] + end + subgraph Core + B --> C[Initialize slow pointer] + C --> D[Scan with fast pointer] + D --> E[Overwrite duplicate positions in place] + end + E --> F[Return unique count k] +``` + +主要な流れの説明: + +- `Input array nums` から `Check empty` へ:まず境界条件(空配列)を弾く +- `Scan with fast pointer` から `Overwrite duplicate positions in place` へ:新しいユニーク値が見つかるたびに、配列そのものを直接上書きする(新しい配列は作らない) + +> 💡 **代表例でのトレース**:`nums = [0,0,1,1,1,2,2,3,3,4]` を入力として、上記フローチャートをどう通過するかを示します。 +> +> ``` +> Step 1: Start → CheckEmpty → 空ではないので No +> Step 2: slow=0, fast=1 で初期化 +> Step 3: fast=1: nums[1]=0, nums[slow]=0 → Compare は No → AdvanceFast のみ +> Step 4: fast=2: nums[2]=1, nums[slow]=0 → Compare は Yes +> → slow=1, nums[1]=nums[2]=1 に書き換え → nums=[0,1,1,1,1,2,2,3,3,4] +> Step 5: 同様に fast=5,7,9 で Compare が Yes になり、 +> slow が 2, 3, 4 と進み、nums=[0,1,2,3,4,2,2,3,3,4] になる +> Step 6: fast=10 で LoopCheck が No になりループ終了 +> 結果: slow + 1 = 5 を返す +> ``` + +> 📖 **この章で登場した用語** +> +> - **フローチャート**:処理の手順を図形と矢印で表したもの。ひし形=条件分岐、長方形=処理 +> - **データフロー図**:データがどのように変換・移動するかを示す図 +> - **サブグラフ**:フローチャートの中で関連する処理をグループ化したもの(今回は「事前検証」と「コア処理」の2つに分けている) + +--- + +

正しさのスケッチ

+ +> 💡 **初学者向け補足**:「正しさのスケッチ」とは、アルゴリズムが常に正しい答えを返すことの根拠を整理したものです。数学的な厳密証明ではなく、「なぜ正しいと言えるか」を言語非依存の擬似コードレベルで説明します。 + +- **不変条件(アルゴリズムが正しく動くために、処理中ずっと成り立ち続けるべき条件)**:ループの各時点で、`nums[0..slow]`(`slow` を含む)は「これまでに見つかったユニークな値がソート順のまま重複なく並んでいる」状態を保つ。この問題のコードに当てはめると、`slow` を進めて `nums[slow] = nums[fast]` を実行する直前・直後のどちらの時点でも、`nums[0..slow]` の中に重複した値は存在しない。 +- **網羅性(すべてのケースをもれなく処理できているという保証)**:`fast` はインデックス1から `nums.length - 1` まですべての位置を1回ずつ訪れる。配列はソート済みなので、ある値の重複はすべて隣接して並んでいる。したがって「直前の確定値と異なるかどうか」だけを見れば、すべての重複を漏れなく検出できる。 +- **基底条件(再帰の終了条件。この問題ではループの初期状態に相当する)**:`nums` が空でない場合、インデックス0の要素は常にそれ単独でユニークな値として確定できる。これが `slow = 0` から処理を始められる根拠になる。空配列の場合はユニーク要素が0個であることが自明なので、ループに入る前に0を返す。 +- **終了性(アルゴリズムが必ず有限ステップで終わるという保証)**:`fast` は毎回のループで必ず1増加し、`nums.length` に達したら停止する。したがって、配列の長さが有限である以上、ループは必ず有限回で終了する。 + +これらの4つの性質から、ループが終了した時点で `nums[0..slow]` にはユニークな値が重複なくソート順に並んでおり、その個数は `slow + 1` である、という結論が導かれます。 + +> 📖 **この章で登場した用語** +> +> - **不変条件**:アルゴリズムが正しく動くために、処理中ずっと成り立ち続けるべき条件 +> - **網羅性**:すべてのケースをもれなく処理できているという保証 +> - **基底条件**:処理の出発点となる、それ以上分解できない最小のケース +> - **終了性**:アルゴリズムが必ず有限ステップで終わるという保証 + +

計算量

+ +> 💡 **初学者向け補足**:計算量とは「入力が大きくなるにつれて、処理にかかる時間・メモリがどう増えるか」の目安です。 + +| 記法 | 意味 | 直感的なイメージ | +| ------------ | ---------------------- | ------------------------------ | +| `O(1)` | 入力サイズによらず一定 | 辞書で直接ページを開く | +| `O(n)` | 入力に比例して増加 | リストを端から順に読む | +| `O(n log n)` | nよりやや速く増加 | 辞書を二分探索で引く×n回 | +| `O(n²)` | 入力の2乗で増加 | 全ペアを総当たりで確認する | + +**11言語比較テーブル**:すべての言語で同じTwo-Pointer法を採用しているため、理論上の計算量はすべて同一(Time O(n)、Space O(1))です。ただし、実装上の特徴(メモリ管理・型安全性の担保方法)は言語ごとに異なります。 + +| 言語 | 採用アルゴリズム | Time | Space | 実装上の特徴 | +| ---------- | ---------------- | ---- | ----- | ---------------------------------------------------------------------- | +| Python | Two-Pointer | O(n) | O(1) | リストはミュータブルな参照型。`range()` によるインデックスループ | +| TypeScript | Two-Pointer | O(n) | O(1) | 配列は参照型。`Array.isArray` による実行時型ガードを併用 | +| Go | Two-Pointer | O(n) | O(1) | スライスヘッダが共有され、配列本体への書き込みが呼び出し元に反映される | +| Rust | Two-Pointer | O(n) | O(1) | `&mut Vec` という可変借用で書き換え。所有権の移動なし | +| Java | Two-Pointer | O(n) | O(1) | `int[]` を直接操作しボクシングを完全に回避 | +| C | Two-Pointer | O(n) | O(1) | ポインタ渡しで配列本体を直接書き換え。`malloc` は不要 | +| C# | Two-Pointer | O(n) | O(1) | `int[]` は参照型。ボクシングなしで完結 | +| C++ | Two-Pointer | O(n) | O(1) | `std::vector&` の参照渡しでコピーを回避 | +| Swift | Two-Pointer | O(n) | O(1) | `inout` パラメータで値型配列を直接書き換え | +| Dart | Two-Pointer | O(n) | O(1) | `List` は参照として渡され、null非許容型で安全性を確保 | +| Kotlin | Two-Pointer | O(n) | O(1) | `IntArray` を使いボクシングを回避(`List` は不採用) | + +**in-place vs Pure 比較**:この問題はLeetCodeの要件として in-place(=新しいメモリを確保せず元のデータを直接書き換える操作)が必須です。11言語すべての実装は in-place であり、Pure(=入力を変更せず新しいデータを返す操作)な代替実装(`Set`/`HashSet`/`distinct()`などを使う方法)はO(n)の追加メモリを要するため、この問題の制約には適合しません(各言語の実装章で代替案として比較検討のみ行います)。 + +> 📖 **この章で登場した用語** +> +> - **時間計算量**:入力の大きさに対して処理にかかる手間がどう増えるかの目安 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安 +> - **in-place**:新しいメモリを確保せず元のデータを直接書き換える操作。空間計算量を抑えられる +> - **Pure**:入力を変更せず新しいデータを返す操作。安全だがメモリを余分に使う + +

Python 実装

+ +> 💡 **初学者向け補足**:コードを読む前に、実装の全体的な骨格を示します。 +> +> 1. 型ヒント(`List[int]`)を明示し、pylanceによる静的チェックを有効にする +> 2. `nums` が空かどうかをまず確認する(境界条件のガード) +> 3. `slow` と `fast` という2つのインデックス変数を使い、`range()` で1パスのループを回す +> 4. `nums[fast]` が `nums[slow]` と異なるたびに、確定領域を1つ広げて値を書き込む +> 5. `slow + 1` を返す + +**LeetCode class 形式**:`class Solution: def removeDuplicates(self, nums: List[int]) -> int:` + +```python +from __future__ import annotations + +from typing import List + + +class Solution: + def removeDuplicates(self, nums: List[int]) -> int: + # 配列が空の場合、ユニークな要素は0個。 + # Pythonのリストはミュータブル(=生成後に中身を変更できる)な + # オブジェクトであり、この関数は呼び出し元のリストへの参照を + # 受け取っているため、後続の書き換えは呼び出し元にも反映される。 + if not nums: + return 0 + + # slow は「最後に確定したユニークな値」が置かれているインデックス。 + slow: int = 0 + + # fast はこれから調べていく値を指すインデックス。 + # range(1, len(nums)) は「1以上 len(nums) 未満」を生成するイテラブル。 + # enumerate() ではなく range() を使うのは、nums[slow] という + # 別のインデックスも同時に参照する必要があるため。 + for fast in range(1, len(nums)): + # nums はソート済みなので、同じ値は必ず隣接している。 + if nums[fast] != nums[slow]: + # slow を進めて「次に書き込む位置」を確保する + slow += 1 + # 新しいユニーク値を確定領域の直後に書き込む + nums[slow] = nums[fast] + # 値が同じ場合は何もしない(これがそのまま重複の無視になる) + + # slow は0始まりのインデックスなので、個数に直すには+1する + return slow + 1 +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> ``` +> 入力: nums = [0,0,1,1,1,2,2,3,3,4] +> slow=0 +> fast=1: nums[1]=0 == nums[0]=0 → 何もしない +> fast=2: nums[2]=1 != nums[0]=0 → slow=1, nums[1]=1 → [0,1,1,1,1,2,2,3,3,4] +> fast=3,4: 一致 → 何もしない +> fast=5: nums[5]=2 != nums[1]=1 → slow=2, nums[2]=2 → [0,1,2,1,1,2,2,3,3,4] +> fast=6: 一致 → 何もしない +> fast=7: nums[7]=3 != nums[2]=2 → slow=3, nums[3]=3 → [0,1,2,3,1,2,2,3,3,4] +> fast=8: 一致 → 何もしない +> fast=9: nums[9]=4 != nums[3]=3 → slow=4, nums[4]=4 → [0,1,2,3,4,2,2,3,3,4] +> 結果: slow + 1 = 5 +> ``` + +> 📖 **この章で登場した用語** +> +> - **`from __future__ import annotations`**:型ヒントを文字列として遅延評価するようにする宣言。今回のような単純な型では効果は薄いが、大規模なコードベースでの前方参照・循環参照を解決できる +> - **`List[int]`**:「`int` のリスト」を表す型ヒント。pylance(VSCodeの静的型チェックツール)が実行前に型の不整合を検出する助けになる +> - **ミュータブル(mutable)**:生成後に中身を変更できるオブジェクトの性質。Pythonの`list`はミュータブル +> - **`range(1, len(nums))`**:1以上 `len(nums)` 未満の整数を順に生成する組み込みイテラブル。CPython(=最も広く使われるPythonの実装)では実際のリストを作らず必要な数値をその都度計算するためメモリ効率が良い + +

TypeScript 実装

+ +> 💡 **初学者向け補足**:Pythonの章で学んだTwo-Pointer法の考え方をそのままベースに、今度はTypeScriptの型システムでどう安全に書くかを見ていきます。骨格は以下の通りです。 +> +> 1. 引数が本当に配列かどうかを実行時にも確認する(`Array.isArray`) +> 2. 配列が空かどうかを確認する +> 3. `slow` と `fast` の2つのインデックス変数で1パスのループを回す +> 4. 値が異なるたびに確定領域を広げて書き込む +> 5. `slow + 1` を返す + +**関数形式**:`function removeDuplicates(nums: number[]): number { ... }`(Node.js v22 / ESM形式) + +```typescript +/** + * ソート済み配列から重複要素を取り除き、前方にユニークな値だけを詰め直す。 + * nums が参照する配列そのものを直接書き換える(in-place)。 + * + * @param nums - 非減少順にソートされた数値配列 + * @returns 重複を除いたあとのユニークな要素数 k + * @throws {TypeError} nums が配列でない場合 + * @complexity Time: O(n), Space: O(1) + */ +function removeDuplicates(nums: number[]): number { + // 型ガード:TypeScriptの型チェックはコンパイル時のみ有効であり、 + // JavaScript として実行される段階では消えてしまうため、実行時にも確認する。 + if (!Array.isArray(nums)) { + throw new TypeError('Input must be an array'); + } + + // 配列が空の場合、ユニークな要素は0個。 + if (nums.length === 0) { + return 0; + } + + // slow は「最後に確定したユニークな値」が置かれているインデックス。 + let slow = 0; + + // fast はこれから調べていく値を指すインデックス。 + for (let fast = 1; fast < nums.length; fast++) { + // 厳密不等価演算子 !== を使う(型変換を伴わない比較がTS/JSの慣習)。 + if (nums[fast] !== nums[slow]) { + slow++; + nums[slow] = nums[fast]; + } + } + + return slow + 1; +} +``` + +> 💡 **TypeScript固有の概念への補足**:`Array.isArray()` はJavaScriptにも存在する実行時チェックですが、TypeScriptの「型ガード(=実行時に値の型を確認し、その情報をもとに型を絞り込む仕組み)」として使うと、コンパイラがこの行以降 `nums` を確実に配列だと認識してくれます。JavaScriptにはコンパイル時の型チェックが存在しないため、この二段構え(コンパイル時の型定義+実行時の型ガード)がTypeScriptならではの安全性設計です。 + +> 💡 **コードの動作トレース**(初学者向け):Python実装章と同じ `[0,0,1,1,1,2,2,3,3,4]` を入力にすると、`slow`/`fast` の推移はPython版と完全に同一で、最終的に `5` を返します(値の型が `number` である点以外、ロジックは同じです)。 + +> 📖 **この章で登場した用語** +> +> - **型ガード**:`Array.isArray` や `typeof` などで実行時に値の型を確認し、その情報をもとにTypeScriptの型を絞り込む仕組み +> - **`TypeError`**:型が不正な場合に使うエラーの種類 +> - **厳密不等価演算子(`!==`)**:型変換を行わずに値と型の両方を比較する演算子 + +--- + +

Go 実装

+ +> 💡 **初学者向け補足**:Goにはクラスが無く、代わりに関数だけでシンプルに実装します。また例外機構も無いため、この問題では「空スライス」を異常ではなく正当な入力として扱い、`error` 戻り値は使いません。骨格は以下の通りです。 +> +> 1. `len(nums) == 0` を確認する(境界条件のガード) +> 2. `slow` と `fast` の2つのインデックス変数で1パスのループを回す +> 3. 値が異なるたびに確定領域を広げて書き込む +> 4. `slow + 1` を返す + +**関数形式**:`func removeDuplicates(nums []int) int { ... }`(Go 1.21+) + +```go +package main + +// removeDuplicates はソート済みスライスから重複要素を取り除き、 +// 前方にユニークな値だけを詰め直す。nums が指す配列本体を直接書き換える。 +// +// Time Complexity: O(n) +// Space Complexity: O(1) +func removeDuplicates(nums []int) int { + // スライスが空の場合、ユニークな要素は0個。 + // nums []int はスライスヘッダ(ポインタ・長さ・容量)のコピーだが、 + // ポインタが指す配列本体は呼び出し元と共有されているため、 + // 以降の書き込みは呼び出し元にも反映される。 + if len(nums) == 0 { + return 0 + } + + // slow は「最後に確定したユニークな値」が置かれているインデックス。 + slow := 0 + + // fast はこれから調べていく値を指すインデックス。 + // range ではなく古典的な for 文にしているのは、nums[slow] という + // 別のインデックスも同時に参照する必要があるため。 + for fast := 1; fast < len(nums); fast++ { + if nums[fast] != nums[slow] { + slow++ + nums[slow] = nums[fast] + } + } + + return slow + 1 +} +``` + +> 💡 **Go固有の概念への補足**:この問題は前の要素の処理結果(`slow`の位置)に依存する逐次処理のため、ゴルーチン(=Goが提供する軽量スレッド)による並列化には向きません。JavaやPythonの例外機構と違い、Goはエラーを戻り値として返す設計が基本ですが、今回は「空スライス」を異常事態ではなく正当な入力として扱うため `error` は返さず `0` を返します。 + +> 💡 **コードの動作トレース**(初学者向け):Python実装章と同じ入力・同じ `slow`/`fast` の推移で、最終的に `5` を返します。 + +> 📖 **この章で登場した用語** +> +> - **スライスヘッダ**:Goのスライスが内部に持つ「配列本体へのポインタ・長さ・容量」の3つ組。関数に渡すときはこのヘッダがコピーされるが、配列本体は共有される +> - **`error`戻り値**:Goがエラーを表現する標準的な方法。JavaやPythonの例外機構と異なり、呼び出し元が`if err != nil`で必ず確認する設計になっている(今回は使用しない) +> - **ゴルーチン**:Goが提供する軽量スレッド。今回の逐次処理には不向き + +

Rust 実装

+ +> 💡 **初学者向け補足**:Rustには所有権(=値を"誰が管理するか"をコンパイル時に決める仕組み)という他言語には無い概念があります。この問題では `&mut Vec`(可変借用)を使うことで、所有権を移動させずに配列を書き換えます。骨格は以下の通りです。 +> +> 1. `nums.is_empty()` を確認する +> 2. `slow`(`usize`型)と `fast` の2つのインデックスで1パスのループを回す +> 3. 値が異なるたびに確定領域を広げて書き込む +> 4. `slow + 1` を `i32` にキャストして返す + +**メソッド形式**:`impl Solution { pub fn remove_duplicates(nums: &mut Vec) -> i32 { ... } }`(Edition 2021) + +```rust +struct Solution; + +impl Solution { + /// ソート済みベクタから重複要素を取り除き、前方にユニークな値だけを詰め直す。 + /// nums が指すベクタ本体を直接書き換える(in-place)。 + /// + /// # Complexity + /// - Time: O(n) + /// - Space: O(1) + pub fn remove_duplicates(nums: &mut Vec) -> i32 { + // ベクタが空の場合、ユニークな要素は0個。 + // &mut Vec は所有権を渡さず「書き換える権利」だけを借りる借用。 + if nums.is_empty() { + return 0; + } + + // slow は「最後に確定したユニークな値」が置かれているインデックス。 + // usize はインデックス専用の符号なし整数型。 + let mut slow: usize = 0; + + // fast はこれから調べていく値を指すインデックス。 + for fast in 1..nums.len() { + // i32 は Copy トレイトを実装するため、代入時に所有権の移動は起きず + // 値がコピーされるだけで済む。 + if nums[fast] != nums[slow] { + slow += 1; + nums[slow] = nums[fast]; + } + } + + // usize から i32 へのキャスト。制約上オーバーフローは起きない。 + (slow + 1) as i32 + } +} +``` + +> 💡 **Rust固有の概念への補足**:`&mut Vec` という可変参照(借用)は、「この関数がベクタを排他的に借りている間、他のどのコードもアクセスできない」ことを借用チェッカーがコンパイル時に保証します。JavaやPythonではガベージコレクタがメモリの解放タイミングを管理しますが、Rustは所有権システムによってコンパイル時にメモリの安全性を保証し、実行時のオーバーヘッドをゼロに抑えます(ゼロコスト抽象化)。 + +> 💡 **コードの動作トレース**(初学者向け):Python実装章と同じ入力・同じ推移で、最終的に `(4 + 1) as i32 = 5` を返します。 + +> 📖 **この章で登場した用語** +> +> - **所有権**:値を"誰が管理するか"をコンパイル時に決めるRust独自の仕組み +> - **借用**:所有権を渡さずに値を参照する仕組み。`&mut T` は書き込み可能な借用 +> - **`Copy`トレイト**:`i32`などの小さい値型が持つ性質。代入しても所有権が移らず値がコピーされる +> - **ゼロコスト抽象化**:便利な高レベルの書き方をしても、手書きの低レベルコードと同等の速さになるRustの特性 + +--- + +

Java 実装

+ +> 💡 **初学者向け補足**:Javaはクラスベースのオブジェクト指向言語で、LeetCodeでは`class Solution`の中にメソッドとして実装します。骨格は以下の通りです。 +> +> 1. `nums == null` と `nums.length == 0` を確認する +> 2. `slow` と `fast` の2つのインデックスで1パスのループを回す +> 3. 値が異なるたびに確定領域を広げて書き込む +> 4. `slow + 1` を返す + +**LeetCode Class形式**:`class Solution { public int removeDuplicates(int[] nums) { ... } }`(OpenJDK 21) + +```java +class Solution { + /** + * ソート済み配列から重複要素を取り除き、前方にユニークな値だけを詰め直す。 + * nums が参照する配列本体を直接書き換える(in-place)。 + * + * Time Complexity: O(n) + * Space Complexity: O(1) + */ + public int removeDuplicates(int[] nums) { + // null チェック:nums == null の場合、nums[0] アクセスは + // NullPointerException を引き起こすため早期に弾く。 + if (nums == null) { + throw new IllegalArgumentException("Input must not be null"); + } + + // 配列が空の場合、ユニークな要素は0個。 + if (nums.length == 0) { + return 0; + } + + // slow は「最後に確定したユニークな値」が置かれているインデックス。 + // int[] を直接扱うため、ボクシング(int を Integer に変換すること)は発生しない。 + int slow = 0; + + // fast はこれから調べていく値を指すインデックス。 + for (int fast = 1; fast < nums.length; fast++) { + if (nums[fast] != nums[slow]) { + slow++; + nums[slow] = nums[fast]; + } + } + + return slow + 1; + } +} +``` + +> 💡 **Java固有の概念への補足**:このシグネチャはすでに `int[]`(プリミティブ型配列)なので、ボクシング(=`int`のようなプリミティブ型を`Integer`のようなラッパークラスのオブジェクトに変換すること)が発生する余地自体がありません。もし `List` を使ってしまうと、要素ごとに`Integer`オブジェクトがヒープに生成され、ガベージコレクションの負荷が増加します。 + +> 💡 **コードの動作トレース**(初学者向け):Python実装章と同じ入力・同じ推移で、最終的に `5` を返します。 + +> 📖 **この章で登場した用語** +> +> - **ボクシング/アンボクシング**:`int`のようなプリミティブ型と`Integer`のようなラッパークラスの間で自動的に変換が行われる仕組み。オブジェクト生成を伴うため性能に影響する +> - **`NullPointerException`**:`null`の参照に対してメソッド呼び出しやフィールドアクセスを行おうとした際に発生する実行時例外 +> - **`IllegalArgumentException`**:引数が不正な場合に使うJava標準の非チェック例外 + +

C 実装

+ +> 💡 **初学者向け補足**:Cにはクラスもガベージコレクションも例外機構もありません。ポインタと配列の添字アクセスだけで実装します。これまでの言語(特にJava/Rust)と比べて「コンパイラが守ってくれない領域」が広いため、`NULL`チェックと未定義動作(=C言語の規格が結果を保証しない操作)の回避を自分の手で保証する必要があります。骨格は以下の通りです。 +> +> 1. `nums == NULL` と `numsSize <= 0` を確認する +> 2. `slow` と `fast` の2つのインデックスで1パスのループを回す +> 3. 値が異なるたびに確定領域を広げて書き込む +> 4. `slow + 1` を返す + +**関数形式**:`int removeDuplicates(int* nums, int numsSize) { ... }`(C17) + +```c +#include + +/* + * ソート済み配列から重複要素を取り除き、前方にユニークな値だけを詰め直す。 + * nums が指すメモリ領域を直接書き換える(in-place)。 + * + * Time: O(n), Space: O(1) + */ +int removeDuplicates(int* nums, int numsSize) { + /* NULLチェック:NULLに対して nums[0] のようにアクセスすると + * 未定義動作(多くの環境ではセグメンテーション違反)になるため。 */ + if (nums == NULL) { + return 0; + } + + /* numsSize が0以下の場合、ユニークな要素は0個。 + * このチェックがないと nums[0] という存在しない要素へアクセスしてしまう。 */ + if (numsSize <= 0) { + return 0; + } + + /* slow は「最後に確定したユニークな値」が置かれているインデックス。 + * malloc は一切不要。int型のローカル変数はスタック上に置かれる。 */ + int slow = 0; + + /* fast はこれから調べていく値を指すインデックス。 */ + for (int fast = 1; fast < numsSize; fast++) { + if (nums[fast] != nums[slow]) { + slow++; + nums[slow] = nums[fast]; + } + } + + return slow + 1; +} +``` + +> 💡 **C固有の概念への補足**:この関数は `malloc`/`free` を一切使わず、既存の配列を直接書き換えるだけで完結します。JavaやPythonであればガベージコレクタが後片付けをしてくれるため気軽に新しいオブジェクトを作れますが、Cにはその仕組みが無いため、そもそもヒープを使わない設計がメモリリーク・二重解放(double free、=同じメモリ領域を誤って2回`free`してしまうバグ)のリスクを構造的に排除する最良の対策になります。 + +> 💡 **コードの動作トレース**(初学者向け):Python実装章と同じ入力・同じ推移で、最終的に `5` を返します。 + +> 📖 **この章で登場した用語** +> +> - **未定義動作(UB)**:C言語の規格が結果を保証しない操作。配列範囲外アクセスや`NULL`の参照外しなどが該当する +> - **メモリリーク**:`malloc`で確保したメモリを`free`し忘れ、使用量が徐々に増えていく不具合(今回は`malloc`を使わないため該当しない) +> - **セグメンテーション違反**:許可されていないメモリ領域にアクセスしようとしたとき、OSがプロセスを強制終了させる現象 + +--- + +

C# 実装

+ +> 💡 **初学者向け補足**:C#はJavaと同じくクラスベースでガベージコレクションを持つ言語ですが、null許容参照型(`T?`)でnull安全性をコンパイル時に表現できる点がJavaとの大きな違いです。骨格はJava実装章とほぼ同じで、差分は「例外の種類」と「null許容参照型の有無」です。 + +**LeetCode Class形式**:`public class Solution { public int RemoveDuplicates(int[] nums) { ... } }`(.NET 8 / C# 12) + +```csharp +using System; + +public class Solution { + /// + /// ソート済み配列から重複要素を取り除き、前方にユニークな値だけを詰め直す。 + /// nums が参照する配列本体を直接書き換える(in-place)。 + /// + /// nums が null の場合 + public int RemoveDuplicates(int[] nums) { + // null チェック:Java版の NullPointerException 相当だが、 + // C# では呼び出し前に ArgumentNullException で明示的に弾くのが慣習。 + if (nums == null) { + throw new ArgumentNullException(nameof(nums)); + } + + if (nums.Length == 0) { + return 0; + } + + // slow は「最後に確定したユニークな値」が置かれているインデックス。 + // int[] は値型要素の配列であり、Java同様ボクシングは発生しない。 + int slow = 0; + + for (int fast = 1; fast < nums.Length; fast++) { + if (nums[fast] != nums[slow]) { + slow++; + nums[slow] = nums[fast]; + } + } + + return slow + 1; + } +} +``` + +> 💡 **C#固有の概念への補足**:Java版との違いは、`ArgumentNullException(nameof(nums))` のように「引数名」をコンパイラに追従させられる `nameof` 演算子を使う点です。Javaの`List`に相当するボクシングの問題は、C#でも`List`のようなコレクションを使った場合に発生しますが、今回のように`int[]`を直接扱えばJava同様に回避できます。 + +> 💡 **コードの動作トレース**(初学者向け):Java実装章と同一の推移で、最終的に `5` を返します。 + +> 📖 **この章で登場した用語** +> +> - **null許容参照型**:`string?`のように書くことで「`null`を許容する型」であることを型システムで明示できるC#8.0以降の機能(今回の`int[]`はJava同様、値型要素の配列であるため直接は関与しない) +> - **`nameof`演算子**:識別子の名前を文字列として取得する演算子。リファクタリング時に文字列がずれる心配がない +> - **ボクシング**:値型を`object`のような参照型に変換すること。`int[]`を直接扱う今回は発生しない + +--- + +

C++ 実装

+ +> 💡 **初学者向け補足**:C++はCの手動メモリ管理を引き継ぎつつ、参照渡し(`&`)やRAII(=リソースの確保と解放をオブジェクトの生存期間に紐づける仕組み)で安全性を高められる言語です。C実装章のポインタ渡しとの違いは、`std::vector&`はコピーを行わずに呼び出し元の`vector`を直接操作する参照であり、それ自体は範囲チェック機能を持たない点です。`std::vector`の`operator[]`によるアクセスは範囲チェックを行わないため範囲外アクセスは未定義動作となります。範囲チェックを行うには`.at()`を使い、範囲外アクセス時には`std::out_of_range`例外が送出されます。 + +**LeetCode Class形式**:`class Solution { public: int removeDuplicates(std::vector& nums); };`(C++20) + +```cpp +#include + +class Solution { +public: + /** + * ソート済み vector から重複要素を取り除き、前方にユニークな値だけを詰め直す。 + * nums が参照する実体を直接書き換える(in-place)。 + * + * Time: O(n), Space: O(1) + */ + int removeDuplicates(std::vector& nums) { + // C実装章のNULLチェックに相当するのが empty() チェック。 + // vector は範囲外アクセスをしても未定義動作になる点はCの配列と同じだが、 + // サイズ情報を自身で保持しているため numsSize のような別引数が不要。 + if (nums.empty()) { + return 0; + } + + // slow は「最後に確定したユニークな値」が置かれているインデックス。 + int slow = 0; + + // std::size_t は符号なし型のため、int の fast と比較すると + // コンパイラ警告が出うる。ここでは int のまま扱い、 + // nums.size() を static_cast せずに比較可能な範囲で使う。 + for (std::size_t fast = 1; fast < nums.size(); ++fast) { + if (nums[fast] != nums[static_cast(slow)]) { + ++slow; + nums[static_cast(slow)] = nums[fast]; + } + } + + return slow + 1; + } +}; +``` + +> 💡 **C++固有の概念への補足**:`std::vector&`(参照渡し)はC実装章の生ポインタ渡しと同じく「コピーを避けて呼び出し元の実体を直接操作する」役割を持ちますが、C++の参照は`nullptr`を表現できない(=参照は必ず何らかの実体を指していることが言語仕様上保証される)ため、C版の`NULL`チェックに相当する心配が構造的に不要になります。今回はヒープを新たに確保しないため、RAIIやスマートポインタ(`unique_ptr`など)の出番はありません。 + +> 💡 **コードの動作トレース**(初学者向け):C実装章と同一の推移で、最終的に `5` を返します。 + +> 📖 **この章で登場した用語** +> +> - **参照(`T&`)**:既存の実体に対する別名。`nullptr`を表現できず、必ず何らかの実体を指していることが保証される点がポインタと異なる +> - **RAII**:リソースの確保と解放をオブジェクトの生存期間に紐づける設計思想(今回はヒープリソースを扱わないため直接は関与しない) +> - **`std::size_t`**:`vector::size()`の戻り値の型であり、負の値を取らない符号なし整数型 + +

Swift 実装

+ +> 💡 **初学者向け補足**:SwiftはARC(=自動参照カウント。オブジェクトへの参照数を自動追跡し、0になった瞬間にメモリを解放する仕組み)でメモリを管理し、Optional型で値の有無を型システムに組み込んでいる言語です。この問題では`inout`(=関数の引数を、呼び出し元の変数を直接書き換えられる形で受け取るためのキーワード)パラメータを使い、Rustの可変借用に近い形で配列を書き換えます。骨格は以下の通りです。 +> +> 1. `nums.isEmpty` を確認する +> 2. `slow` と `fast` の2つのインデックスで1パスのループを回す +> 3. 値が異なるたびに確定領域を広げて書き込む +> 4. `slow + 1` を返す + +**LeetCode Class形式**:`class Solution { func removeDuplicates(_ nums: inout [Int]) -> Int { ... } }`(Swift 5.10+) + +```swift +class Solution { + /// ソート済み配列から重複要素を取り除き、前方にユニークな値だけを詰め直す。 + /// nums を inout(=呼び出し元の変数を直接書き換えられる形の引数)で受け取り、 + /// その場で書き換える(in-place)。 + /// + /// 計算量: Time O(n), Space O(1) + func removeDuplicates(_ nums: inout [Int]) -> Int { + // 配列が空でないかを確認する。Rust実装章の is_empty() チェックに相当する。 + guard !nums.isEmpty else { + return 0 + } + + // slow は「最後に確定したユニークな値」が置かれているインデックス。 + var slow = 0 + + // fast はこれから調べていく値を指すインデックス。 + for fast in 1.. 💡 **Swift固有の概念への補足**:`guard let`/`guard`は条件を満たさない場合に早期リターンする構文で、Cの`NULL`チェックやRustの`is_empty()`チェックと役割は同じですが、`guard`のスコープを抜けた後は「条件が満たされている」ことがコンパイラにも保証される点が特徴です。今回は`Int`配列の要素比較のみで、Optional型(=値があるかもしれないし、ないかもしれないことを表す型)そのものは登場しませんが、`inout`パラメータによる書き換えはRustの`&mut`借用と考え方が近く、値型(`struct`)であるSwiftの`Array`はCopy-on-Write(=実際に変更が加えられるまではコピーを作らない最適化)により効率よく扱われます。 + +> 💡 **コードの動作トレース**(初学者向け):Rust実装章と同一の推移で、最終的に `5` を返します。 + +> 📖 **この章で登場した用語** +> +> - **ARC(自動参照カウント)**:オブジェクトへの参照数を自動追跡し、0になった瞬間にメモリを解放するSwiftの仕組み +> - **`inout`**:関数の引数を、呼び出し元の変数を直接書き換えられる形で受け取るためのキーワード +> - **`guard`文**:条件を満たさない場合に早期リターンする構文。`guard`のスコープを抜けた後は条件が満たされていることが保証される +> - **Copy-on-Write(COW)**:Swiftの`Array`などが採用する最適化。実際に変更が加えられるまではコピーを作らない + +--- + +

Dart 実装

+ +> 💡 **初学者向け補足**:Dartは健全なnull安全性(=`?`の付かない型は絶対に`null`にならないことを、コンパイル時だけでなく実行時にも保証する仕組み)を持つ、ガベージコレクション採用の言語です。骨格は以下の通りです。 +> +> 1. `nums.isEmpty` を確認する(`nums`自体はnon-nullable型なので`null`チェックは不要) +> 2. `slow` と `fast` の2つのインデックスで1パスのループを回す +> 3. 値が異なるたびに確定領域を広げて書き込む +> 4. `slow + 1` を返す + +**LeetCode Class形式**:`class Solution { int removeDuplicates(List nums) { ... } }`(Dart 3.x) + +```dart +class Solution { + /// ソート済みリストから重複要素を取り除き、前方にユニークな値だけを詰め直す。 + /// nums が参照するリストそのものを直接書き換える(in-place)。 + /// + /// [nums] は non-nullable な List<int> のため null チェックは不要 + /// (健全なnull安全性により、この引数自体が null になることはない)。 + int removeDuplicates(List nums) { + // 配列が空でないかを確認する。TypeScript実装章の length === 0 チェックに相当する。 + if (nums.isEmpty) { + return 0; + } + + // slow は「最後に確定したユニークな値」が置かれているインデックス。 + var slow = 0; + + // fast はこれから調べていく値を指すインデックス。 + for (var fast = 1; fast < nums.length; fast++) { + if (nums[fast] != nums[slow]) { + slow++; + nums[slow] = nums[fast]; + } + } + + return slow + 1; + } +} +``` + +> 💡 **Dart固有の概念への補足**:TypeScript実装章では`Array.isArray`による実行時の型ガードが必要でしたが、Dartの健全なnull安全性はコンパイル時だけでなく実行時にも保証が破られないことが言語仕様レベルで約束されているため、`nums`に対する`null`チェックは一切不要です。これはTypeScriptの「コンパイル時のみの保証」との明確な違いです。またDartのジェネリクスはリファイド(=ジェネリクスの型情報が実行時にも保持される仕組み)であり、Javaの型消去とは対照的です。 + +> 💡 **コードの動作トレース**(初学者向け):TypeScript実装章と同一の推移で、最終的に `5` を返します。 + +> 📖 **この章で登場した用語** +> +> - **健全なnull安全性(sound null safety)**:`?`の付かない型は絶対に`null`にならないことを、コンパイル時だけでなく実行時にも保証する仕組み +> - **リファイドジェネリクス**:ジェネリクスの型情報が実行時にも保持される仕組み。Javaの型消去とは対照的 +> - **`final` / `const`**:イミュータブルな変数宣言。今回の`slow`はループ内で再代入されるため`var`を使う + +--- + +

Kotlin 実装

+ +> 💡 **初学者向け補足**:KotlinはJavaと同じJVM上で動作しますが、null安全性がデフォルトで組み込まれている点、`val`/`var`でイミュータビリティを明示する点がJavaとの大きな違いです。骨格はJava実装章とほぼ同じで、差分は「配列の型」と「null安全性の扱い方」です。 + +**LeetCode Class形式**:`class Solution { fun removeDuplicates(nums: IntArray): Int { ... } }`(Kotlin 2.0+ / JVM) + +```kotlin +class Solution { + /** + * ソート済み配列から重複要素を取り除き、前方にユニークな値だけを詰め直す。 + * nums が参照する配列本体を直接書き換える(in-place)。 + */ + fun removeDuplicates(nums: IntArray): Int { + // Java版の nums == null チェックに相当する処理は不要。 + // IntArray は null 非許容型のため、コンパイラが null を渡すコードを弾く。 + if (nums.isEmpty()) { + return 0 + } + + // slow は「最後に確定したユニークな値」が置かれているインデックス。 + // ループ内で再代入され続けるため var で宣言する。 + var slow = 0 + + // until は「終端を含まない範囲」を作る演算子(Java の < 相当)。 + for (fast in 1 until nums.size) { + if (nums[fast] != nums[slow]) { + slow++ + nums[slow] = nums[fast] + } + } + + return slow + 1 + } +} +``` + +> 💡 **Kotlin固有の概念への補足**:Java実装章との最大の違いは、`IntArray`という型自体がnull非許容であるため、Javaで必要だった`nums == null`チェックが不要になる点です。これはKotlinのnull安全性がJavaと違い**デフォルトで**組み込まれていることを示す好例です。また`IntArray`はJVM上では`int[]`と同じプリミティブ配列にコンパイルされ、`List`のようなボクシングされたコレクションとは異なります。 + +> 💡 **コードの動作トレース**(初学者向け):Java実装章と同一の推移で、最終的に `5` を返します。 + +> 📖 **この章で登場した用語** +> +> - **`val` / `var`**:`val`は再代入不可、`var`は再代入可能な変数を宣言するキーワード +> - **`IntArray`**:Kotlinのプリミティブ`Int`配列専用の型。JVM上では`int[]`にコンパイルされる +> - **`until`**:「終端を含まない範囲」を作る中置関数 + +

言語間比較

+ +> 💡 **初学者向け補足**:同じアルゴリズムでも、言語によって「メモリの扱い方」や「エラーの表現方法」が異なります。この章では11言語の実装を横並びで比較し、それぞれの設計判断の背景を振り返ります。 + +### 採用アルゴリズム/アプローチ + +11言語すべてが同一のTwo-Pointer法を採用しています。ソート済み配列という前提のもとではこれがBig-O最適(O(n)時間・O(1)空間)であり、言語の特性によってアルゴリズム自体を変える理由がなかったためです。 + +### メモリ確保の特徴 + +| 言語 | メモリ確保の特徴 | +| ---------- | ------------------------------------------------------------------- | +| Python | リストはミュータブルな参照型。追加のオブジェクト生成なし | +| TypeScript | 配列は参照型。V8エンジンの内部配列をそのまま操作 | +| Go | スライスヘッダのコピーのみ。配列本体は共有。`make`/`append`不使用 | +| Rust | `&mut Vec`可変借用。ヒープアロケーションなし、所有権の移動なし | +| Java | `int[]`直接操作でボクシング回避 | +| C | ポインタ渡し。`malloc`不使用、スタック変数のみ | +| C# | `int[]`直接操作でボクシング回避 | +| C++ | `std::vector&`参照渡しでコピー回避。新規ヒープ確保なし | +| Swift | `inout`パラメータ。値型配列のCopy-on-Write最適化を活用 | +| Dart | `List`参照渡し。新規オブジェクト生成なし | +| Kotlin | `IntArray`直接操作でボクシング回避 | + +### エラー表現の方法 + +| 言語 | エラー表現 | +| ---------- | ----------------------------------------------------------------------------- | +| Python | 空配列は正当な入力として`0`を返す。`isinstance`による実行時型検証は業務版のみ | +| TypeScript | `Array.isArray`による実行時型ガード+`TypeError` | +| Go | `error`戻り値は不使用(空スライスは正当な入力として扱う) | +| Rust | 空ベクタは`0`を返す。業務版では`Result`による型レベルのエラー表現も可能 | +| Java | `null`は`IllegalArgumentException`(非チェック例外)で弾く | +| C | 戻り値(`0`)でエラー状態を表現。例外機構が存在しないため | +| C# | `null`は`ArgumentNullException`で弾く | +| C++ | 参照渡しのため`nullptr`チェックは不要。`empty()`のみ確認 | +| Swift | `guard`による早期リターン。`throws`は今回未使用 | +| Dart | 健全なnull安全性により`null`チェック自体が不要 | +| Kotlin | null非許容型により`null`チェック自体が不要 | + +### メモリ管理方式 + +| 方式 | 該当言語 | +| --------------------------------------- | ---------------------------------------------- | +| GC(ガベージコレクション) | Python, TypeScript, Go, Java, C#, Dart, Kotlin | +| 所有権システム(コンパイル時管理) | Rust | +| 手動管理(`malloc`/`free`) | C | +| 手動管理+RAII/スマートポインタの選択肢 | C++ | +| ARC(自動参照カウント) | Swift | + +### 型安全性の担保方法 + +| 言語 | null安全性の実現方式 | +| ------ | ------------------------------------------------------------------------- | +| Java | 静的型付け+型消去。`null`は許容され、実行時チェックで防御する | +| C# | null許容参照型(`T?`)により、コンパイル時に`null`許容/非許容を区別できる | +| Kotlin | デフォルトでnull非許容。`?`を付けたときだけ`null`を許容する | +| Dart | 健全なnull安全性。コンパイル時だけでなく実行時にも保証が破られない | +| Swift | Optional型(`T?`)+オプショナルバインディングで値の有無を型で表現 | + +**相違点の背景**:GC付き言語(Python/TypeScript/Go/Java/C#/Dart/Kotlin)は「メモリをいつ解放するか」をランタイムに任せるため、開発者は所有権や解放責任をほとんど意識せずに済みます。一方、Rust(所有権システム)とC/C++(手動管理)は「誰がいつメモリを管理するか」をプログラマまたはコンパイラが厳密に決める必要があり、この問題のように新規アロケーションが不要なケースでは差が目立ちにくいものの、複雑なデータ構造を扱う問題ではこの違いが設計に大きく影響します。またSwiftのARCは「GCのように不確実なタイミングで回収される」のではなく「参照カウントが0になった瞬間に確実に解放される」という第三の方式であり、GCとも所有権システムとも異なる立ち位置にあります。 + +null安全性についても、Java(実行時チェックに依存)、C#/Kotlin(`?`の有無で型レベルに区別)、Dart(健全性をランタイムまで保証)、Swift(Optional型として値の有無を表現)という4通りのアプローチがあり、いずれも「`null`由来のバグをできるだけ早い段階(できればコンパイル時)で検出したい」という共通の動機から生まれていますが、保証の強さと歴史的経緯(Javaは`null`許容がデフォルトの古い言語仕様、後発の言語ほど非許容がデフォルト)によって設計が異なります。 + +今回は11言語すべてで同じTwo-Pointer法を採用しました。これは、この問題の最適解がソート済みという性質にのみ依存しており、言語ごとの得意・不得意(並行処理適性やGCの有無など)が計算量に影響する余地がなかったためです。 + +> 📖 **この章で登場した用語** +> +> - **GC(ガベージコレクション)**:使い終わったメモリを自動で回収する仕組み +> - **所有権システム**:Rust独自の、コンパイル時にメモリの管理者を1つに定める仕組み +> - **ARC(自動参照カウント)**:Swiftが採用する、参照カウントに基づく決定的なメモリ管理方式 + +

言語別最適化ポイント

+ +> 💡 **初学者向け補足**:この章では「同じ処理でも言語ごとの書き方によって速さが変わる理由」を、11言語それぞれについて説明します。この問題自体は非常にシンプルなため、最適化の余地は多くありませんが、「なぜ不要なオブジェクト生成を避けるべきか」を最適化前後のコードで比較します。 + +### Python(CPython)最適化ポイント + +```python +# 最適化前:set() で重複除去すると新しいオブジェクトが生成される(遅い、かつ順序も崩れる) +result = sorted(set(nums)) + +# 最適化後:Two-Pointer でリストを直接書き換える(速い、in-place要件も満たす) +slow = 0 +for fast in range(1, len(nums)): + if nums[fast] != nums[slow]: + slow += 1 + nums[slow] = nums[fast] +# 理由:set() は新しい集合オブジェクトを生成し、sorted() はさらに新しいリストを +# 生成するため、O(n)の追加メモリとオブジェクト生成コストがかかる +``` + +### TypeScript最適化ポイント + +```typescript +// 最適化前:Set で重複除去すると新しいオブジェクトが生成される +const result = [...new Set(nums)]; + +// 最適化後:Two-Pointer で配列を直接書き換える +let slow = 0; +for (let fast = 1; fast < nums.length; fast++) { + if (nums[fast] !== nums[slow]) { + slow++; + nums[slow] = nums[fast]; + } +} +// 理由:Set とスプレッド構文はそれぞれ新しいオブジェクトを生成するため、 +// V8エンジンのガベージコレクタの管理対象が増えてしまう +``` + +### Go最適化ポイント + +```go +// 最適化前:map で重複除去すると要素ごとにハッシュ計算とヒープ逃げが発生する +seen := make(map[int]struct{}) +result := []int{} +for _, v := range nums { + if _, ok := seen[v]; !ok { + seen[v] = struct{}{} + result = append(result, v) + } +} + +// 最適化後:Two-Pointer でスライスを直接書き換える +slow := 0 +for fast := 1; fast < len(nums); fast++ { + if nums[fast] != nums[slow] { + slow++ + nums[slow] = nums[fast] + } +} +// 理由:map はハッシュ計算のコストに加え、内部データがヒープに逃げるため +// GCの管理対象が増える。ソート済みという性質があれば map 自体が不要 +``` + +### Rust最適化ポイント + +```rust +// 最適化前:HashSet を使うと挿入ごとにヒープアロケーションが発生しうる +use std::collections::HashSet; +let unique: HashSet = nums.iter().copied().collect(); + +// 最適化後:Two-Pointer でベクタを直接書き換える(ゼロコスト抽象化) +let mut slow = 0; +for fast in 1..nums.len() { + if nums[fast] != nums[slow] { + slow += 1; + nums[slow] = nums[fast]; + } +} +// 理由:HashSet は要素挿入のたびにハッシュ計算とヒープアロケーションが発生し、 +// さらに順序を保証しないため別途ソートが必要になる +``` + +### Java最適化ポイント + +```java +// 最適化前:List はボクシングにより要素ごとにオブジェクトが生成される +List result = Arrays.stream(nums).distinct().boxed().collect(Collectors.toList()); + +// 最適化後:int[] を直接操作する(ボクシングなし) +int slow = 0; +for (int fast = 1; fast < nums.length; fast++) { + if (nums[fast] != nums[slow]) { + slow++; + nums[slow] = nums[fast]; + } +} +// 理由:int[] は要素をオブジェクト化せずメモリ上に連続配置するため、 +// ボクシングのコストとGC対象オブジェクトの増加を避けられる +``` + +### C最適化ポイント + +```c +/* 最適化前:重複除去のために新しい配列を malloc する(不要なヒープ確保) */ +int* result = malloc(sizeof(int) * numsSize); +/* ... 重複除去してコピー ... */ +free(result); + +/* 最適化後:既存の配列をその場で書き換える(malloc 不要) */ +int slow = 0; +for (int fast = 1; fast < numsSize; fast++) { + if (nums[fast] != nums[slow]) { + slow++; + nums[slow] = nums[fast]; + } +} +/* 理由:malloc は呼び出しごとにヒープ管理のオーバーヘッドが発生し、 + 対応する free の管理責任も生まれる。in-place要件があるため + そもそも新しい配列を作る必要がない */ +``` + +### C++最適化ポイント + +```cpp +// 最適化前:値渡しは呼び出しのたびに vector 全体のコピーが発生する +int removeDuplicatesSlow(std::vector nums) { /* ... */ return 0; } + +// 最適化後:参照渡しでコピーを回避する +int removeDuplicates(std::vector& nums) { /* ... */ return 0; } +// 理由:値渡しは vector の全要素をコピーするコストがかかるが、 +// 参照渡しはアドレスの受け渡しのみで済む +``` + +### C#最適化ポイント + +```csharp +// 最適化前:List は値型を格納するたびにボクシングが発生する +List values = new List(); +values.Add(nums[0]); + +// 最適化後:int[] をそのまま扱いボクシングを回避する +int slow = 0; +for (int fast = 1; fast < nums.Length; fast++) { + if (nums[fast] != nums[slow]) { + slow++; + nums[slow] = nums[fast]; + } +} +// 理由:int[] は内部的に値をそのまま連続領域に格納するため、 +// object へのボクシング(ヒープへのオブジェクト生成)が発生しない +``` + +### Swift最適化ポイント + +```swift +// 最適化前:不要に class でラップして参照カウント管理のオーバーヘッドを負う +class Box { var values: [Int] = [] } + +// 最適化後:Array(struct、値型)を inout でそのまま操作しCOWの恩恵を受ける +func removeDuplicates(_ nums: inout [Int]) -> Int { /* ... */ return 0 } +// 理由:SwiftのArrayはCopy-on-Write最適化により、実際に変更が +// 加えられるまではコピーが発生しない。class でラップするとARCの +// 参照カウント管理コストが余分にかかる +``` + +### Dart最適化ポイント + +```dart +// 最適化前:toSet().toList() は2つの新しいコレクションを生成する +final result = nums.toSet().toList(); + +// 最適化後:Two-Pointer でリストを直接書き換える +var slow = 0; +for (var fast = 1; fast < nums.length; fast++) { + if (nums[fast] != nums[slow]) { + slow++; + nums[slow] = nums[fast]; + } +} +// 理由:toSet() と toList() はそれぞれ新しいオブジェクトを生成し、 +// ガベージコレクタの管理対象が増えるため +``` + +### Kotlin最適化ポイント + +```kotlin +// 最適化前:List は内部的にボクシングされた Integer を保持する +val values: List = nums.toList().distinct() + +// 最適化後:IntArray をそのまま直接操作する +var slow = 0 +for (fast in 1 until nums.size) { + if (nums[fast] != nums[slow]) { + slow++ + nums[slow] = nums[fast] + } +} +// 理由:List は JVM 上で Integer オブジェクトのボクシングを伴うが、 +// IntArray はプリミティブ int を直接メモリに並べるため、 +// ボクシングのコストとGC対象の増加を避けられる +``` + +> 📖 **この章で登場した用語** +> +> - **ボクシング回避**:ラッパークラス(`Integer`など)ではなくプリミティブ型をそのまま配列で扱うことで、オブジェクト生成とGCの負荷を減らす最適化テクニック +> - **ヒープアロケーション**:ヒープ上にメモリを新たに確保する操作。頻繁に行うと速度が落ちる +> - **Copy-on-Write(COW)**:実際に変更が加えられるまではコピーを作らず、参照を共有し続ける最適化 + +

エッジケースと検証観点

+ +> 💡 **初学者向け補足**:エッジケースとは「入力が空・最小値・最大値・重複あり」など、通常とは異なる境界的な入力のことです。エッジケースを見落とすと、普通のテストは通るのに特定の入力でだけバグが発生します。 + +- **空配列(`nums = []`)**:この問題の制約(`1 <= nums.length`)では発生しないが、もし発生した場合、ガードがなければ `nums[0]` 相当のアクセスで問題が起きる可能性がある。言語による挙動の違いとして、Python/Java/C#/Dart/Kotlinは実行時に例外(`IndexError`/`ArrayIndexOutOfBoundsException`/`IndexOutOfRangeException`など)を投げ、TypeScriptでは `undefined` が返り(例外は発生せず)、Go/Rustは範囲外アクセスでパニックし、Cは未定義動作(多くの環境ではセグメンテーション違反)になり、C++の`std::vector`の`operator[]`も範囲チェックをしないため未定義動作になり、Swiftは配列アクセスの範囲外で即座にクラッシュする。今回はすべての言語で明示的なガード(`isEmpty`/`is_empty`/`nums == NULL`など)によりこの問題を回避している。 +- **要素が1個(`nums = [1]`)**:`fast`のループが1度も実行されないため、`slow`は初期値の`0`のまま`slow + 1 = 1`が返る。これは正しい(ユニークな要素は1個)。ループの継続条件(`fast < length`相当)が最初から満たされないため、どの言語でも安全に処理される。 +- **全要素が同じ値(`nums = [2,2,2,2]`)**:`nums[fast] != nums[slow]`が一度も真にならず、`slow`は`0`のまま`1`が返る。これも正しい。 +- **全要素が異なる値(`nums = [1,2,3,4]`)**:毎回条件が真になり、`slow`は`fast`と同じ速度で進み、最終的に配列の長さがそのまま返る。 +- **最大サイズの入力(`nums.length = 3 * 10^4`)**:O(n)アルゴリズムであるため、どの言語でも制限時間内に十分収まる。C/C++/Rust/Go/Java/C#/Swift/Dart/Kotlinのようなコンパイル型・JIT型言語はもちろん、Pythonのようなインタープリタ言語でも、この問題の制約規模であれば単純な1パスループで問題ない。 + +言語による境界外アクセスの挙動差まとめ: + +| 言語 | 範囲外アクセス時の挙動 | +| ---------- | ----------------------------------------------------------------------- | +| Python | `IndexError`(例外) | +| TypeScript | `undefined`が返る(例外は発生しない点に注意) | +| Go | ランタイムパニック | +| Rust | ランタイムパニック | +| Java | `ArrayIndexOutOfBoundsException` | +| C | 未定義動作(多くの環境ではセグメンテーション違反) | +| C# | `IndexOutOfRangeException` | +| C++ | `std::vector::operator[]`は未定義動作、`.at()`は`std::out_of_range`例外 | +| Swift | 即座にクラッシュ(Fatal error) | +| Dart | `RangeError` | +| Kotlin | `ArrayIndexOutOfBoundsException`(JVMの例外がそのまま伝播) | + +> 📖 **この章で登場した用語** +> +> - **エッジケース**:空のリスト・要素1つ・最大サイズ入力など、境界的な条件の入力 +> - **境界値**:制約の上限・下限にあたる値 +> - **パニック**:GoやRustで回復不能なエラーが発生した際に起きる強制終了 +> - **セグメンテーション違反**:C/C++で許可されていないメモリ領域にアクセスしようとしたとき、OSがプロセスを強制終了させる現象 + +--- + +

FAQ

+ +**Q1. なぜこの問題では11言語すべてで同じTwo-Pointer法を使っているのですか?別のアルゴリズムではダメなのですか?** + +結論:この問題には言語ごとにアルゴリズムを変える理由がないためです。理由:Two-Pointer法はソート済み配列という前提のもとでBig-O最適(O(n)時間・O(1)空間)であり、この効率の良さは言語の特性(並行処理適性やGCの有無など)に左右されません。補足:他の問題(例えば大規模データの並列集計など)であれば、Goのゴルーチンや並行処理が有利な言語とそうでない言語でアプローチが分かれることもありますが、この問題は前の要素の処理結果に依存する逐次処理のため、そもそも並列化のメリットがありません。 + +**Q2. なぜRustだけ所有権や借用の話がこんなに詳しく出てくるのですか?** + +結論:Rustは他の10言語と異なり、ガベージコレクタも無ければ手動の`malloc`/`free`も使わない、第三のメモリ管理方式(所有権システム)を採用しているためです。理由:所有権システムは「値を誰が管理するか」をコンパイル時に決定することで、実行時のオーバーヘッドなしにメモリ安全性を保証します。この仕組みはRust特有であり、正しく理解しないと「なぜ`&mut Vec`のような書き方をするのか」が分かりにくいため、詳しく説明する必要がありました。補足:Java/C#/Kotlinなどのガベージコレクション付き言語では「いつメモリが解放されるか」を意識する必要がほとんど無いのに対し、Rustでは借用がいつ終わるかをコード上で明確に追える点が対照的です。 + +**Q3. なぜJavaとC#だけボクシングの話が出てくるのですか?他の言語では気にしなくていいのですか?** + +結論:ボクシングは「値型を参照型(オブジェクト)として扱う場合」に発生する現象だからです。理由:Java/Kotlinの `List` などのジェネリックコレクションは参照型しか扱えないため、値型(`int`)を格納する際に自動変換(ボクシング)が発生します。一方、C#の `List` は値型を直接格納するため利用だけではボクシングは発生せず、`List` のように値型を参照型へ変換する場合に限定して発生します。今回はいずれも `int[]` / `IntArray` という「プリミティブ型配列」を直接扱うシグネチャのため、ボクシングは発生しません。補足:Python(すべてがオブジェクト)やC++(テンプレートによる型ごとの専用コード生成)ではこの種の自動変換コストの考え方自体が存在しない、または別の形(C++のテンプレートのインスタンス化など)で表れます。 + +**Q4. なぜCだけmalloc/freeの話がこんなに出てくるのですか?** + +結論:Cには他の言語のようなガベージコレクタも所有権システムも無く、ヒープメモリの確保・解放をすべて手動で行う必要があるためです。理由:`malloc`で確保したメモリは、`free`し忘れるとメモリリークになり、逆に2回`free`すると二重解放という未定義動作になります。今回の問題は幸い`malloc`を一切使わずに済む設計にできたため実害はありませんが、「なぜ今回`malloc`が不要なのか」を理解するには、まず`malloc`/`free`の責任範囲という前提知識が必要でした。補足:C++はCのこの手動管理を引き継ぎつつ、RAIIやスマートポインタで自動化する選択肢を持つ点が異なります。 + +**Q5. なぜSwiftだけARCの話が出てくるのですか?GCと何が違うのですか?** + +結論:ARC(自動参照カウント)はSwift(および Objective-C)が採用する、GCとは異なる第三のメモリ管理方式だからです。理由:GC付き言語(Python/TypeScript/Go/Java/C#/Dart/Kotlin)では「いつメモリが回収されるか」が非決定的(プログラムの実行中の任意のタイミング)ですが、ARCは参照カウントが0になった**その瞬間**に確定的にメモリを解放します。この違いは、大量のオブジェクトを扱う場面でのパフォーマンスの予測しやすさに影響します。補足:今回の問題では新規オブジェクトをほぼ生成しないため、ARCのメリット・デメリットが直接コードに表れる場面は少ないですが、`inout`パラメータでの値型操作がCopy-on-Writeとどう関係するかを理解する上でARCの前提知識が役立ちます。 + +**Q6. なぜKotlinとJavaは同じJVM言語なのに書き方がこんなに違うのですか?** + +結論:Kotlinは「Javaの反省点」を踏まえて設計された、より新しい言語だからです。理由:Javaでは`null`がすべての参照型のデフォルトの可能性として存在し、`NullPointerException`が長年悩みの種でした。Kotlinはこれを解決するため、型システムに最初からnull安全性を組み込み、`?`を明示しない限り`null`を許容しない設計にしました。今回の実装でも、Java版では`nums == null`のチェックが必要だったのに対し、Kotlin版では`IntArray`自体がnull非許容のためそのチェックが不要になっています。補足:両言語ともJVM上で動作し相互運用性も高いため、既存のJavaライブラリをKotlinから呼び出すこと自体は問題なくできますが、「新しく書くコード」としてはKotlinのほうがnull安全性の恩恵を受けやすい設計になっています。 + +**Q7. TypeScriptなのに、なぜ実行時にも`Array.isArray`のような型チェックをしているのですか?型ヒントがあれば十分ではないのですか?** + +結論:TypeScriptの型チェックはコンパイル時のみ有効で、コンパイル後のJavaScriptにはその情報が一切残らないためです。理由:TypeScriptのコードは最終的にJavaScriptに変換(トランスパイル)され、その過程で型注釈はすべて削除されます。そのため、外部から(型定義を無視して、あるいはJavaScript側から)この関数が呼び出された場合、コンパイル時のチェックは実行時には何の保護にもなりません。補足:Dartの健全なnull安全性は「実行時にも保証が破られない」という点でTypeScriptと対照的であり、この違いがDart実装章とTypeScript実装章の型チェックの書き方の差として表れています。 + +> 📖 **この章で登場した用語** +> +> - **FAQ**:Frequently Asked Questions の略。よくある質問と回答のこと +> - **トレードオフ**:何かを得ると何かを失う関係。例:速さを得るとメモリが増える +> - **トランスパイル**:ある言語のソースコードを、別の(同じ抽象度の)言語のソースコードに変換すること。TypeScript→JavaScriptの変換はこれに該当する diff --git a/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/README_React.html b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/README_React.html new file mode 100644 index 00000000..c4ca5db5 --- /dev/null +++ b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/README_React.html @@ -0,0 +1,2215 @@ + + + + + + LeetCode 26: Remove Duplicates from Sorted Array — 11言語解説 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + diff --git a/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-C#.md b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-C#.md new file mode 100644 index 00000000..21264b88 --- /dev/null +++ b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-C#.md @@ -0,0 +1,193 @@ +# LeetCodeでの回答フォーマット + +--- + +## 1. 問題の分析 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「すでに並び替え済みの配列から、同じ値が連続している部分をひとつにまとめて、前方に詰め直す問題」です。配列自体を直接書き換える(in-place=その場で操作する)必要がある点がポイントです。 + +### 競技プログラミング視点での分析 + +- 配列はすでにソート済みなので、**同じ値は必ず隣り合っている**という性質を利用できます。これにより、余計な探索(例えば全要素同士を比較する)をせず、**配列を1回だけ左から右へなめる(1パス)だけ**で処理が完結します。 +- 実行速度を最優先するなら、時間計算量 O(n)(nは配列の要素数)が理論上の最速ラインです。全要素を最低1回は確認する必要があるため、これより速くはなりません。 +- メモリ使用量の最小化方針としては、`List`やLINQ(=コレクションに対してSQLライクな問い合わせを行える機能。`Where`や`Select`などのメソッドで宣言的にデータ操作ができる)の`Distinct()`のような新しいコレクションを生成する手段を使わず、**与えられた配列そのものを書き換える**ことで、追加メモリを`int`型の変数1個だけに抑えます。LINQは可読性が高い反面、内部で新しいコレクションやイテレータオブジェクトを生成するため、この問題の「in-place」という要件には適しません。 + +### 業務開発視点での分析 + +- この問題では境界条件(配列が空、要素が1個だけ、全要素が同じ値、全要素がバラバラ等)を正しく処理できるかが保守性・堅牢性の観点で重要です。 +- C#では配列(`int[]`)は参照型(`class`)(=代入時に値そのものがコピーされる値型`struct`とは異なり、同じ実体を指す「住所メモ」を複数の変数が共有する型)です。そのため、メソッド内で`nums`の要素を書き換えると、呼び出し元が持つ配列も同時に書き換わります。これはJavaの配列と同じ挙動で、C++のポインタ渡しに近い感覚です。 +- エラーハンドリングについては、この問題は制約上`nums.length >= 1`が保証されていますが、業務開発の観点では`nums`が`null`で渡されるケースも考慮し、`ArgumentNullException`(=`null`引数が不正な場合に投げる標準例外クラス)を投げる防御的な実装にしておくと、将来的な呼び出し元の不備にも耐えられます。 + +### C#特有の考慮点 + +- **値型(`struct`)と参照型(`class`)の使い分け**:`int[]`は参照型なので、メソッド引数として渡す際もコピーは発生せず「配列の先頭を指す住所メモ」が渡されます。名刺のコピー(値型)ではなく、同じ家への住所メモを2枚持つ(参照型)状態に近いイメージです。今回はこの性質を利用し、呼び出し元の配列を直接書き換えることでメモリ効率を高めています。 +- **ボクシング/アンボクシングのコスト**:今回は`int[]`という値型の配列をそのまま扱うため、ボクシング(=`int`のような値型を`object`のような参照型に変換すること。オブジェクト生成のコストが発生する)は一切発生しません。もし`List`のような設計にしてしまうと、要素ごとに`int`が`object`へボクシングされ、無駄なヒープアロケーションが発生してしまいます。 +- **LINQの活用可否**:`nums.Distinct().ToArray()`のようなLINQ式を使えば数行で書けますが、内部で新しい配列・イテレータを生成するためO(n)の追加メモリを消費します。この問題は「in-placeでO(1)空間」という制約があるため、今回はLINQを使わず手続き的なループで実装します。 + +> 📖 **このセクションで登場した用語** +> +> - **in-place(インプレース)**:新しい配列を作らず、与えられた配列そのものを直接書き換える操作方式。 +> - **参照型(`class`)**:変数が実体そのものではなく「実体への参照(住所メモ)」を持つ型。C#の配列・クラスが該当。 +> - **LINQ**:コレクションに対してSQLライクな問い合わせを行える機能。`Where`・`Select`・`Distinct`などのメソッドで宣言的にデータ操作ができるが、内部でオブジェクト生成が発生する場合がある。 +> - **`ArgumentNullException`**:`null`引数が不正な場合に投げる標準例外クラス。 + +--- + +## 2. アルゴリズムアプローチ比較 + +> 💡 **初学者向け補足**:同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」を比べて、この問題の制約(in-place・O(1)空間)に最も適したものを選びます。 + +| アプローチ | 時間計算量 | 空間計算量 | C#実装コスト | 型安全性 | 可読性 | 備考 | +|---|---|---|---|---|---|---| +| **A. Two-Pointer(低速・高速ポインタ)** | O(n) | O(1) | 低 | 高 | 高 | ソート済みという前提を最大限活用。in-place要件を満たす最適解 | +| B. `HashSet`を使った重複除去 | O(n) | O(n) | 低 | 高 | 中 | `HashSet`は順序を保証しないため、別途ソートし直す必要があり非効率 | +| C. `nums.Distinct().ToArray()`(LINQ) | O(n) | O(n) | 最低 | 高 | 高 | 記述は最も簡潔だが、内部で新しい配列・イテレータを生成しin-place要件に反する | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n)`:入力が2倍になると、処理も約2倍になる(線形) +> - `O(n²)`:入力が2倍になると、処理は約4倍になる(二重ループに多い、今回は該当なし) + +**C#特有のボクシングコストについて**:方法B・Cではいずれも`int`という値型のまま扱う限りボクシングは発生しませんが、新しいコレクション(`HashSet`や`Distinct()`の結果配列)を生成するためO(n)のヒープアロケーションが発生します。特にLINQの`Distinct()`は内部でイテレータ(`IEnumerable`を返す遅延評価の仕組み)を生成するため、`ToArray()`まで含めるとさらにオーバーヘッドが積み重なります。方法Aは既存の配列を直接上書きするだけなので、この追加コストが一切発生しません。 + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して、処理にかかる手間がどう増えるかの目安。 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安。 +> - **イテレータ**:コレクションの要素を1つずつ順番に取り出すための仕組み。LINQのメソッドの多くは遅延評価(実際に列挙されるまで処理を実行しない)のためにイテレータを内部で使う。 + +--- + +## 3. 選択したアルゴリズムと理由 + +> 💡 **初学者向け補足**:他の方法と比較しながら、なぜ二重ポインタ法を選んだのかを説明します。 + +- **選択したアプローチ**: A. Two-Pointer(`slow` / `fast` の2つのインデックスを使う方法) +- **理由**: + - **計算量的な優位性**:`HashSet`(方法B)やLINQの`Distinct()`(方法C)はどちらもO(n)の追加メモリが必要ですが、Two-Pointer法はO(1)の追加メモリ(`slow`という`int`変数ひとつ)だけで済みます。**この問題は「in-placeで行うこと」自体が明示的な要件**なので、O(n)空間を使う方法B・Cはそもそも要件を満たしません。 + - **C#環境での型安全性**:`int`同士の比較演算子`!=`はコンパイル時に型チェックされるため、誤った型同士を比較してしまうミスがそもそも起こり得ません。 + - **保守性・可読性の観点**:ループはひとつだけで、条件分岐も「値が変わったかどうか」のシンプルな1行のみです。LINQのような宣言的な書き方と比べるとやや行数は増えますが、意図(何がなぜ起きているか)が明確に読み取れます。 +- **C#特有の最適化ポイント**: + - `int[]`が参照型であることを活かし、コピーを一切発生させずに呼び出し元の配列を直接書き換えています。 + - ボクシングが一切発生しない設計(`int`をそのまま扱い、`object`へ変換しない)になっています。 + - 先頭で`nums == null`をチェックすることで、`NullReferenceException`(=`null`の参照に対してメンバーアクセスを行おうとした際に発生する実行時例外)を未然に防いでいます。 + +> 📖 **このセクションで登場した用語** +> +> - **Two-Pointer(二重ポインタ)法**:配列上で2つの添字(インデックス)を異なる速度・役割で動かしながら処理を進めるアルゴリズムパターン。 +> - **`NullReferenceException`**:`null`の参照に対してメンバーアクセスを行おうとした際に発生する実行時例外。 + +--- + +## 4. 実装コード + +> 💡 **初学者向け補足**:コード全体を示す前に、大まかな構造(骨格)を示します。 +> +> 1. まず`nums`が`null`でないか、空配列でないかを確認する +> 2. 「まだユニークな要素を書き込んでよい位置」を指す`slow`変数を用意する +> 3. `fast`で配列を先頭から末尾まで1つずつなめていき、`slow`が指す値と違う値が見つかるたびに、その値を`slow`の次の位置に書き込む +> 4. 最後に`slow + 1`(=ユニークな要素の個数)を返す + +```csharp +using System; + +public class Solution { + /// + /// ソート済み配列から重複要素を取り除き、前方にユニークな値だけを詰め直す。 + /// nums が参照する配列そのものを直接書き換える(in-place)。 + /// + /// 非減少順(同じ値の連続を含む昇順)にソートされた整数配列。 + /// 重複を除いたあとのユニークな要素数 k。呼び出し側は nums[0] から nums[k-1] までを有効な結果として扱う。 + /// nums が null の場合に投げる。 + public int RemoveDuplicates(int[] nums) { + // null チェック:nums が「実体を指していない」場合、この後の nums[0] アクセスは + // NullReferenceException を引き起こすため、意図を明確にするために + // 早期に ArgumentNullException を投げる(nameof を使うことで、 + // 引数名を文字列リテラルでハードコードせずリファクタリング耐性を持たせる) + if (nums == null) { + throw new ArgumentNullException(nameof(nums)); + } + + // 配列が空の場合、ユニークな要素は0個なのでそのまま0を返す。 + // (この問題の制約では nums.Length >= 1 が保証されているが、 + // 防御的プログラミングとして安全側に倒しておく) + if (nums.Length == 0) { + return 0; + } + + // slow は「最後に確定したユニークな値」が置かれているインデックス。 + // 最初はインデックス0の要素(nums[0])が唯一の確定済みユニーク値なので0から始める。 + int slow = 0; + + // fast はこれから調べていく値を指すインデックス。 + // インデックス1から末尾まで、配列を1回だけなめる(1パス)。 + for (int fast = 1; fast < nums.Length; fast++) { + // 配列はソート済みなので、同じ値は必ず隣接している。 + // fast が指す値が、直近の確定済みユニーク値(nums[slow])と異なるなら、 + // それは「新しく見つかったユニークな値」ということになる。 + if (nums[fast] != nums[slow]) { + // slow をひとつ進めて「次にユニーク値を書き込む位置」を確保する + slow++; + // 新しく見つかったユニーク値を、確定済み領域の直後(nums[slow])に書き込む + nums[slow] = nums[fast]; + } + // nums[fast] == nums[slow] の場合は「すでに確認済みの値の重複」なので、 + // 何もせずに for ループの fast++ によって次の要素へ進む + // (何もしないことがそのまま「重複を無視する」処理になる) + } + + // slow は0始まりのインデックスなので、要素の個数に直すには+1する + return slow + 1; + } +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> 具体例:`nums = [0,0,1,1,1,2,2,3,3,4]` で実際にトレースしてみます。 +> +> ``` +> 初期状態: nums = [0,0,1,1,1,2,2,3,3,4]、slow = 0 +> (null チェック → 通過、Length == 0 チェック → 通過) +> +> fast=1: nums[1]=0, nums[slow]=nums[0]=0 → 同じ値なので何もしない +> fast=2: nums[2]=1, nums[slow]=nums[0]=0 → 値が違う! +> slow を 1 に進め、nums[1] = nums[2] = 1 を書き込む +> → nums = [0,1,1,1,1,2,2,3,3,4] +> fast=3: nums[3]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=4: nums[4]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=5: nums[5]=2, nums[slow]=nums[1]=1 → 値が違う! +> slow を 2 に進め、nums[2] = nums[5] = 2 を書き込む +> → nums = [0,1,2,1,1,2,2,3,3,4] +> fast=6: nums[6]=2, nums[slow]=nums[2]=2 → 同じ値なので何もしない +> fast=7: nums[7]=3, nums[slow]=nums[2]=2 → 値が違う! +> slow を 3 に進め、nums[3] = nums[7] = 3 を書き込む +> → nums = [0,1,2,3,1,2,2,3,3,4] +> fast=8: nums[8]=3, nums[slow]=nums[3]=3 → 同じ値なので何もしない +> fast=9: nums[9]=4, nums[slow]=nums[3]=3 → 値が違う! +> slow を 4 に進め、nums[4] = nums[9] = 4 を書き込む +> → nums = [0,1,2,3,4,2,2,3,3,4] +> +> ループ終了(fast=10 は nums.Length=10 と等しくなり、条件 fast < nums.Length を満たさなくなる) +> +> return slow + 1 = 4 + 1 = 5 +> +> → k = 5、先頭5要素は [0,1,2,3,4] となり、期待される出力と一致する +> ``` +> +> インデックス5以降の値(`[2,2,3,3,4]`の名残)は、問題文の指示どおり「気にしなくてよい部分」としてそのまま残っていますが、これは正解として扱われます(Custom Judgeは先頭k個しか見ないため)。 + +> 📖 **このセクションで登場した用語** +> +> - **`ArgumentNullException(nameof(nums))`**:`null`引数を検知した際に投げる標準例外。`nameof`演算子を使うことで、引数名の変更時にもコンパイラが追従してくれる(文字列リテラルで`"nums"`と直接書くより安全)。 +> - **`for (int fast = 1; fast < nums.Length; fast++)`**:C#のforループ構文。「初期化式;継続条件;更新式」の3つから成り、`fast`はループのスコープ内でのみ有効な変数として宣言される。 +> - **配列インデクサ `nums[i]`**:配列の`i`番目(0始まり)の要素にアクセスする構文。C#では範囲外の`i`を指定すると`IndexOutOfRangeException`という実行時例外が投げられ、Cのような未定義動作にはならない(C#のランタイムが自動的に範囲チェックを行うため)。 + +--- + +## 補足:計算量まとめ + +- **時間計算量**: O(n) — 配列を1回だけ走査するため(n = `nums.Length`) +- **空間計算量**: O(1) — 追加で使うメモリは`slow`という`int`変数ひとつのみ(入力配列自体は書き換えているだけで、新規のコレクション生成は行っていない) +- **例外安全性**: `nums == null`のケースを`ArgumentNullException`で明示的に扱っており、実行時に予期しない`NullReferenceException`が発生する余地がない diff --git a/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-C++.md b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-C++.md new file mode 100644 index 00000000..96a9b966 --- /dev/null +++ b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-C++.md @@ -0,0 +1,201 @@ +# LeetCodeでの回答フォーマット + +--- + +## 1. 問題の分析 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「すでに並び替え済みの配列から、同じ値が連続している部分をひとつにまとめて、前方に詰め直す問題」です。 +> +> **C++で解く際に特に気をつけるべき点**:この問題は`vector&`(=`vector`への参照。呼び出し元の実体を直接操作できる)として渡されるため、新しいコンテナを作らずに既存の`vector`を直接書き換える(in-place=その場で操作する)ことが求められます。ここで気をつけるべきは、①`nums`が空の場合に`nums[0]`へアクセスすると未定義動作(=C++規格が結果を保証しない操作。実行するとクラッシュしたり、一見動くように見えて後で壊れたりする)になる点、②ループの添字(インデックス)の範囲を1つでも間違えると`vector`の境界を越えてアクセスしてしまう点です。今回は`new`や`std::unique_ptr`のような明示的なヒープ管理は一切不要で、スタック上の`int`変数1個だけで完結する設計にできます。 + +### 競技プログラミング視点での分析 + +- 配列はすでにソート済みなので、**同じ値は必ず隣り合っている**という性質を利用できます。これにより、全要素同士を比較するような無駄な探索をせず、**配列を1回だけ先頭から末尾へなめる(1パス)だけ**で処理が完結します。 +- 実行速度を最優先するなら、時間計算量 O(n)(nは`nums.size()`)が理論上の最速ラインです。全要素を最低1回は確認する必要があるため、これより速くはなりません。 +- メモリ使用量の最小化方針としては、`std::set`や新しい`vector`を生成するようなヒープアロケーション(=`new`や`std::vector`の内部拡張などで、ヒープ上にメモリを新たに確保する操作。頻繁に行うと速度が落ちる)を一切行わず、**引数として渡された`vector`そのものを書き換える**ことで、追加メモリを`int`型の変数1個(スタック上)だけに抑えます。 + +### 業務開発視点での分析 + +- 業務コードとして堅牢性を高めるには、境界条件(`nums`が空、要素が1個だけ、全要素が同じ値、全要素がバラバラ等)を正しく処理できるかが重要です。 +- `nums`は`vector&`(非`const`参照)として渡されており、関数の内部でこの参照を通じて呼び出し元の実体を直接変更します。これはC言語のポインタ渡しに近い挙動ですが、C++の参照は`nullptr`を表現できない(=参照は必ず何らかの実体を指していることが言語仕様上保証されている)ため、「`nums`自体が無効」という状態を心配する必要がない点がCのポインタ渡しより安全です。 +- エラーハンドリングについては、この問題は制約上`nums.size() >= 1`が保証されていますが、業務開発の観点では防御的に空チェックを入れておくと、将来的な呼び出し規約の変更にも耐えられます。C++には例外機構(`try`/`catch`/`throw`)がありますが、今回は「空配列」は異常事態ではなく正当な入力の一種として扱い、`0`を返す設計にします(例外を投げるべきは、むしろ契約違反に近いケースに限定するのが一般的です)。 + +### C++特有の考慮点 + +- **スタック/ヒープの使い分けとRAIIの活用**:この問題はヒープを一切使わず、スタック上の整数変数だけで完結させられます。JavaやPythonであれば「重複を除いた新しいリストを作る」という発想になりがちですが(ガベージコレクタが後片付けをしてくれるため気軽に新しいオブジェクトを作れる)、C++では既存の`vector`を直接書き換える設計のほうがヒープアロケーションのコストを避けられ効率的です。今回はRAII(=リソースの確保と解放をオブジェクトの生存期間に紐づける仕組み。C++のメモリ安全性の根幹)を意識するまでもなく、`int`型のローカル変数のみで処理が完結します。 +- **スマートポインタと生ポインタの使い分け**:今回の問題では所有権の移動や共有が発生しないため、`std::unique_ptr`や`std::shared_ptr`は不要です。`vector&`という参照渡しだけで十分に安全な設計が実現できます。 +- **ムーブセマンティクスによる不要なコピーの回避**:`vector&`は参照であり、値渡し(`vector`)のようなコピーコンストラクタ呼び出しは発生しません。もし誤って値渡し(`vector nums`)にしてしまうと、呼び出し元の配列全体がコピーされてしまい、O(n)の余計な時間・空間コストが発生します。今回はシグネチャがすでに参照渡しになっているため、この問題は発生しません。 +- **`const` 正しさ**:今回は`nums`自体を書き換える必要があるため`const`は付けられませんが、ループ内で読み取りのみ行う変数(今回は該当なし)があれば`const`を付けることでコンパイラに「変更しない」という意図を伝えられます。 + +> 📖 **このセクションで登場した用語** +> +> - **in-place(インプレース)**:新しいコンテナを作らず、与えられた`vector`そのものを直接書き換える操作方式。 +> - **未定義動作(UB)**:C++規格が結果を保証しない操作。配列範囲外アクセスなどが該当する。 +> - **参照(`T&`)**:既存の実体に対する別名。`nullptr`を表現できず、必ず何らかの実体を指していることが保証される点がポインタと異なる。 +> - **RAII**:リソースの確保と解放をオブジェクトの生存期間に紐づける設計思想。今回はヒープリソースを扱わないため直接は関与しない。 + +--- + +## 2. アルゴリズムアプローチ比較 + +> 💡 **初学者向け補足**:同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」を比べて、この問題の制約(in-place・O(1)空間)に最も適したものを選びます。 + +| アプローチ | 時間計算量 | 空間計算量 | C++実装コスト | 型安全性 | 可読性 | 備考 | +| ------------------------------------------- | ---------- | ---------- | ------------- | -------- | ------ | ------------------------------------------------------------------------------------------------------------------------------- | +| **A. Two-Pointer(低速・高速ポインタ)** | O(n) | O(1) | 低 | 高 | 高 | ソート済みという前提を最大限活用。ヒープアロケーション不要でin-place要件を満たす最適解 | +| B. `std::set`を使った重複除去 | O(n log n) | O(n) | 低 | 高 | 中 | 挿入のたびに木構造の再平衡が発生し、ヒープアロケーションも要素ごとに発生しうる | +| C. `std::unique`(``)を使う方法 | O(n) | O(1) | 最低 | 高 | 高 | 標準ライブラリの`std::unique`はまさにこの問題向けのアルゴリズムだが、内部の仕組みを理解する教育的価値のため今回は手動実装を選択 | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n)`:入力が2倍になると、処理も約2倍になる(線形) +> - `O(n log n)`:入力が2倍になると、処理は約2倍強になる(ソートアルゴリズムに多い) + +**C++特有のメモリ管理コストについて**:方法Bの`std::set`は内部的に平衡二分探索木(多くの実装では赤黒木)で構成されており、要素を1つ挿入するたびにノード用のヒープアロケーションが発生します。n個の要素を挿入すればO(n)回のヒープアロケーションが発生しうるため、たとえ最終的な計算量がO(n log n)であっても、実行時の定数倍のコストは無視できません。方法Cの`std::unique`は実は方法Aとほぼ同じTwo-Pointerアルゴリズムを内部で実装しており、ヒープアロケーションは一切発生しません。今回は「なぜこのアルゴリズムが動くのか」を理解する教育的価値を重視し、`std::unique`をそのまま呼ぶのではなく手動でTwo-Pointerを実装します。 + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して、処理にかかる手間がどう増えるかの目安。 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安。 +> - **ヒープアロケーション**:`new`や`std::set`の内部でのノード生成などで、ヒープ上にメモリを新たに確保する操作。 + +--- + +## 3. 選択したアルゴリズムと理由 + +> 💡 **初学者向け補足**:他の方法と比較しながら、なぜ二重ポインタ法を選んだのかを説明します。 + +- **選択したアプローチ**: A. Two-Pointer(`slow` / `fast` の2つのインデックスを使う方法) +- **理由**: + - **計算量的な優位性**:`std::set`(方法B)はO(n log n)時間・O(n)空間で、挿入のたびにヒープアロケーションが発生します。方法Aは**O(n)時間・O(1)空間**を達成しており、この問題の「in-place」という要件にも合致します。 + - **C++環境でのメモリ安全性・所有権設計との親和性**:ヒープを一切使わないため、`new`/`delete`の対応漏れやダングリングポインタ(=解放済みのメモリを指したままになっているポインタ)といった問題が構造的に発生しません。 + - **保守性・可読性の観点**:ループはひとつだけで、条件分岐も「値が変わったかどうか」のシンプルな1行のみです。`std::unique`のようなライブラリ関数を1行で呼ぶより行数は増えますが、アルゴリズムの動きが明示的にコードへ表れており、後から読む人が処理内容を追いやすくなっています。 +- **C++特有の最適化ポイント**: + - スタック優先設計により、ヒープアロケーションのオーバーヘッド(実行時間のコスト)をゼロにしています。 + - `vector&`という参照渡しを活かし、`vector`のコピーコンストラクタ呼び出し(O(n)のコピーコスト)を一切発生させていません。 + - `nums.empty()`を最初にガードすることで、未定義動作につながる`nums[0]`アクセスを未然に防いでいます。 + +> 📖 **このセクションで登場した用語** +> +> - **Two-Pointer(二重ポインタ)法**:配列(コンテナ)上で2つの添字(インデックス)を異なる速度・役割で動かしながら処理を進めるアルゴリズムパターン。 +> - **ダングリングポインタ**:解放済みのメモリ領域を指したままになっているポインタ。それを参照すると未定義動作になる。 + +--- + +## 4. 実装コード + +> 💡 **初学者向け補足**:コード全体を示す前に、大まかな構造(骨格)を示します。 +> +> 1. まず`nums`が空でないかを確認する(空なら結果は0個なのでそのまま`0`を返す) +> 2. 「まだユニークな要素を書き込んでよい位置」を指す`slow`変数を用意する +> 3. `fast`で配列を先頭から末尾まで1つずつなめていき、`slow`が指す値と違う値が見つかるたびに、その値を`slow`の次の位置に書き込む +> 4. 最後に`slow + 1`(=ユニークな要素の個数)を返す + +```cpp +#include + +class Solution { +public: + /** + * ソート済み vector から重複要素を取り除き、前方にユニークな値だけを詰め直す。 + * nums が参照する実体を直接書き換える(in-place)。 + * + * @param nums 非減少順(同じ値の連続を含む昇順)にソートされた整数の vector への参照。 + * 呼び出し元の実体をそのまま書き換えるため、値渡しによるコピーは発生しない。 + * @return 重複を除いたあとのユニークな要素数 k。 + * 呼び出し側は nums[0] から nums[k-1] までを有効な結果として扱う。 + * + * 計算量: Time O(n), Space O(1) (n = nums.size()) + */ + int removeDuplicates(std::vector& nums) { + // 空チェック:nums が空の場合、この後の nums[0] アクセスは + // 未定義動作(vector の場合、operator[] は範囲チェックをしないため + // 境界外アクセスとなる)を引き起こすため、事前に弾いておく。 + // (この問題の制約では nums.size() >= 1 が保証されているが、 + // 防御的プログラミングとして安全側に倒しておく) + if (nums.empty()) { + return 0; + } + + // slow は「最後に確定したユニークな値」が置かれているインデックス。 + // 最初はインデックス0の要素(nums[0])が唯一の確定済みユニーク値なので0から始める。 + // int 型のローカル変数なのでスタック上に置かれ、ヒープアロケーションは一切発生しない。 + int slow = 0; + + // fast はこれから調べていく値を指すインデックス。 + // インデックス1から末尾まで、vector を1回だけなめる(1パス)。 + // static_cast を使わず int で完結させるため、nums.size() を + // 直接 int と比較すると符号なし/符号ありの比較警告が出る可能性があるので + // ループ変数の型を size_t に揃えて安全に比較する。 + for (std::size_t fast = 1; fast < nums.size(); ++fast) { + // vector はソート済みなので、同じ値は必ず隣接している。 + // fast が指す値が、直近の確定済みユニーク値(nums[slow])と異なるなら、 + // それは「新しく見つかったユニークな値」ということになる。 + if (nums[fast] != nums[static_cast(slow)]) { + // slow をひとつ進めて「次にユニーク値を書き込む位置」を確保する + ++slow; + // 新しく見つかったユニーク値を、確定済み領域の直後(nums[slow])に書き込む。 + // int は trivially copyable(=ビット単位の単純なコピーで安全に複製できる型) + // なので、ムーブセマンティクスを意識する必要はなく単純代入で十分。 + nums[static_cast(slow)] = nums[fast]; + } + // nums[fast] == nums[slow] の場合は「すでに確認済みの値の重複」なので、 + // 何もせずに for ループの ++fast によって次の要素へ進む + // (何もしないことがそのまま「重複を無視する」処理になる) + } + + // slow は0始まりのインデックスなので、要素の個数に直すには+1する + return slow + 1; + } +}; +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> 具体例:`nums = {0,0,1,1,1,2,2,3,3,4}` で実際にトレースしてみます。 +> +> ``` +> 初期状態: nums = {0,0,1,1,1,2,2,3,3,4}、slow = 0 +> (nums.empty() チェック → 通過) +> +> fast=1: nums[1]=0, nums[slow]=nums[0]=0 → 同じ値なので何もしない +> fast=2: nums[2]=1, nums[slow]=nums[0]=0 → 値が違う! +> slow を 1 に進め、nums[1] = nums[2] = 1 を書き込む +> → nums = {0,1,1,1,1,2,2,3,3,4} +> fast=3: nums[3]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=4: nums[4]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=5: nums[5]=2, nums[slow]=nums[1]=1 → 値が違う! +> slow を 2 に進め、nums[2] = nums[5] = 2 を書き込む +> → nums = {0,1,2,1,1,2,2,3,3,4} +> fast=6: nums[6]=2, nums[slow]=nums[2]=2 → 同じ値なので何もしない +> fast=7: nums[7]=3, nums[slow]=nums[2]=2 → 値が違う! +> slow を 3 に進め、nums[3] = nums[7] = 3 を書き込む +> → nums = {0,1,2,3,1,2,2,3,3,4} +> fast=8: nums[8]=3, nums[slow]=nums[3]=3 → 同じ値なので何もしない +> fast=9: nums[9]=4, nums[slow]=nums[3]=3 → 値が違う! +> slow を 4 に進め、nums[4] = nums[9] = 4 を書き込む +> → nums = {0,1,2,3,4,2,2,3,3,4} +> +> ループ終了(fast=10 は nums.size()=10 と等しくなり、条件 fast < nums.size() を満たさなくなる) +> +> return slow + 1 = 4 + 1 = 5 +> +> → k = 5、先頭5要素は {0,1,2,3,4} となり、期待される出力と一致する +> ``` +> +> インデックス5以降の値(`{2,2,3,3,4}`の名残)は、問題文の指示どおり「気にしなくてよい部分」としてそのまま残っていますが、Custom Judgeは先頭k個しか見ないため正解として扱われます。 + +> 📖 **このセクションで登場した用語** +> +> - **`nums.empty()`**:`vector`が要素を1つも持たないかを判定するメンバ関数。`nums.size() == 0`と等価だが、意図が明確で読みやすいため`empty()`を使うのがC++での慣習。 +> - **`std::size_t`**:`vector::size()`の戻り値の型であり、負の値を取らない符号なし整数型。`int`(符号あり)とループで直接比較すると、コンパイラの警告(`-Wsign-compare`)が出ることがあるため、ループ変数の型を揃えるのが安全。 +> - **`operator[]`(`vector`の添字アクセス)**:`nums[i]`という構文の実体。範囲チェックを行わないため、範囲外アクセスは未定義動作になる(範囲チェック付きでアクセスしたい場合は代わりに`nums.at(i)`を使うと、範囲外時に`std::out_of_range`例外を投げる)。 + +--- + +## 補足:計算量・安全性まとめ + +- **時間計算量**: O(n) — `vector`を1回だけ走査するため(n = `nums.size()`) +- **空間計算量**: O(1) — 追加で使うメモリは`slow`という`int`変数ひとつのみ(スタック上)。ヒープアロケーションは一切発生しないため、`new`/`delete`の対応漏れやメモリリークのリスクは構造的に存在しない +- **未定義動作の排除**: `nums.empty()`チェックにより、空の`vector`への不正アクセスを事前に防止している。ループ内の`nums[slow]`・`nums[fast]`はいずれも`0 <= slow <= fast < nums.size()`の範囲に収まることが保証されている diff --git a/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-C.md b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-C.md new file mode 100644 index 00000000..9b91e1d1 --- /dev/null +++ b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-C.md @@ -0,0 +1,207 @@ +# LeetCodeでの回答フォーマット + +--- + +## 1. 問題の分析 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「すでに並び替え済みの配列から、同じ値が連続している部分をひとつにまとめて、前方に詰め直す問題」です。 +> +> **Cで解く際に特に気をつけるべき点**:この問題は配列を直接書き換える(in-place=新しい配列を作らずその場で操作する)必要があります。Cでは配列は「先頭要素へのポインタ」として関数に渡されるため、`nums`ポインタが指すメモリ領域を直接書き換えることになります。ここで気をつけるべきは、①`numsSize`が0の場合に配列の先頭要素(`nums[0]`)へアクセスしてしまうと未定義動作(=C言語の規格が結果を保証しない操作。配列範囲外アクセスなどが該当し、実行結果が保証されない)になる点、②ループの添字(インデックス)の範囲を1つでも間違えると、配列の外側を読み書きしてしまいクラッシュや不正な結果につながる点です。今回は`malloc`(=ヒープ領域からメモリを動的に確保する関数)を一切使わないため、メモリリークや二重解放の心配はありません。 + +### 競技プログラミング視点での分析 + +- 配列はすでにソート済みなので、**同じ値は必ず隣り合っている**という性質を利用できます。これにより、全要素同士を比較するような無駄な探索をせず、**配列を1回だけ先頭から末尾へなめる(1パス)だけ**で処理が完結します。 +- 実行速度を最優先するなら、時間計算量 O(n)(nは`numsSize`)が理論上の最速ラインです。全要素を最低1回は確認する必要があるため、これより速くはなりません。 +- メモリ使用量の最小化方針としては、`malloc`によるヒープアロケーション(=`malloc`によってヒープ上にメモリを新たに確保する操作。頻繁に行うと速度が落ちる)を一切行わず、**与えられた配列そのものを書き換える**ことで、追加メモリを`int`型の変数1個(スタック上に置かれる)だけに抑えます。 + +### 業務開発視点での分析 + +- 業務コードとして堅牢性を高めるには、境界条件(`numsSize`が0の場合、要素が1個だけの場合、全要素が同じ値の場合など)を正しく処理できるかが重要です。特に`numsSize == 0`のケースでは、配列に一切アクセスせず即座に`0`を返す必要があります。 +- Cには例外機構(`try`/`catch`)が存在しないため、エラー通知は戻り値によって行います。今回の問題では「重複除去後の要素数」を返す戻り値そのものが結果の伝達手段になっており、追加のエラー値は必要ありません。 +- `nums`が`NULL`(=ポインタが「何も指していない」ことを表すマクロ)で渡されるケースも、防御的プログラミングとして考慮しておくべきです。LeetCodeのCustom Judgeでは通常起こりませんが、実務では呼び出し元の不備で`NULL`が渡ってくることもあり得るため、先頭でチェックしておくと安全です。 + +### C特有の考慮点 + +- **スタック/ヒープの使い分け**:この問題はヒープを一切使わず、スタック(=関数呼び出しに使われる高速なメモリ領域)上の整数変数だけで完結させられます。JavaやPythonであれば「重複を除いた新しいリストを作る」という発想になりがちですが(ガベージコレクタ(GC。=使われなくなったメモリを自動的に検出して解放する仕組み)が後片付けをしてくれるため気軽に新しいオブジェクトを作れる)、Cでは`malloc`のたびに確保・解放のコストと管理責任が発生するため、既存の配列を直接書き換える設計のほうが圧倒的に効率的です。 +- **ポインタの安全な取り扱い**:`nums`は「配列の先頭要素の住所を書いたメモ」(=ポインタの直感的な例え)です。このメモを辿って`nums[i]`のようにアクセスしますが、`i`が`numsSize`以上になるとメモが指す範囲の外を読んでしまい、未定義動作につながります。 +- **未定義動作の回避**:`numsSize`が0の場合に`nums[0]`へアクセスするコードを書いてしまうと、配列が空なのに存在しない要素を読もうとすることになり未定義動作です。ループの開始・終了条件を正確に設定することが安全性の要です。 + +> 📖 **このセクションで登場した用語** +> +> - **in-place(インプレース)**:新しい配列を作らず、与えられた配列そのものを直接書き換える操作方式。 +> - **未定義動作(UB)**:C言語の規格が結果を保証しない操作。配列範囲外アクセスや`NULL`参照外しなどが該当する。 +> - **`NULL`**:ポインタが「何も指していない」ことを表すマクロ。 +> - **ガベージコレクタ(GC)**:Java/Pythonなどで、使われなくなったメモリを自動的に検出・解放する仕組み。Cにはこの機構がないため、手動管理が必要。 + +--- + +## 2. アルゴリズムアプローチ比較 + +> 💡 **初学者向け補足**:同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」を比べて、この問題の制約(in-place・O(1)空間)に最も適したものを選びます。 + +| アプローチ | 時間計算量 | 空間計算量 | C実装コスト | 安全性 | 可読性 | 備考 | +| ----------------------------------------------- | ---------- | ---------- | ----------- | ------ | ------ | ---------------------------------------------------------------------------------- | +| **A. Two-Pointer(低速・高速ポインタ)** | O(n) | O(1) | 低 | 高 | 高 | ソート済みという前提を最大限活用。`malloc`不要でin-place要件を満たす最適解 | +| B. 別配列に結果を書き出す(`malloc`使用) | O(n) | O(n) | 中 | 中 | 中 | `malloc`と対応する`free`の管理責任が発生し、in-placeというこの問題の要件にも反する | +| C. 各要素について前方を線形探索して重複チェック | O(n²) | O(1) | 低 | 高 | 中 | 実装は単純だが、ソート済みという性質を活かせておらず非効率 | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n)`:入力が2倍になると、処理も約2倍になる(線形) +> - `O(n²)`:入力が2倍になると、処理は約4倍になる(二重ループに多い) + +**C特有のメモリ管理コストについて**:方法Bでは`malloc(sizeof(int) * numsSize)`のような形でヒープアロケーションを1回行い、結果を書き終えたら呼び出し元か関数内のどちらかで`free`する責任が発生します。これは「借りたら返す」というレンタルスペースの管理コストに相当し、`free`し忘れればメモリリーク、誤って2回`free`すれば二重解放(未定義動作)というリスクを常に伴います。方法Aは`malloc`を一切使わないため、このリスク自体が存在しません。 + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して、処理にかかる手間がどう増えるかの目安。 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安。 +> - **ヒープアロケーション**:`malloc`によってヒープ上にメモリを新たに確保する操作。 + +--- + +## 3. 選択したアルゴリズムと理由 + +> 💡 **初学者向け補足**:他の方法と比較しながら、なぜ二重ポインタ法を選んだのかを説明します。 + +- **選択したアプローチ**: A. Two-Pointer(`slow` / `fast` の2つのインデックスを使う方法) +- **理由**: + - **計算量的な優位性**:方法B(O(n)空間・`malloc`必要)や方法C(O(n²)時間)と比べ、方法AはO(n)時間・O(1)空間の両方を達成できる唯一の選択肢です。この問題は「in-placeで行うこと」自体が明示的な要件なので、新しい配列を作る方法Bはそもそも要件を満たしません。 + - **C環境でのメモリ安全性**:`malloc`を使わないため、`free`し忘れによるメモリリークや、`free`のタイミングを誤ることによる二重解放のリスクが構造的に存在しません。 + - **保守性・可読性の観点**:ループはひとつだけで、条件分岐も「値が変わったかどうか」のシンプルな1行のみです。ポインタ演算も配列添字アクセス(`nums[i]`)のみで、複雑なポインタ演算(アドレスの加減算など)を使わないため読みやすいコードになります。 +- **C特有の最適化ポイント**: + - `malloc`を使わないスタック優先設計により、ヒープアロケーションのオーバーヘッド(実行時間のコスト)をゼロにしています。 + - `numsSize <= 0`のケースを最初にガードすることで、未定義動作につながる配列アクセスを未然に防いでいます。 + - ループ内のポインタ演算(添字アクセス)は`nums[slow]`・`nums[fast]`という明確な形のみを使い、生のポインタ加算(`*(nums + i)`のような書き方)は使わず可読性を優先しています。 + +> 📖 **このセクションで登場した用語** +> +> - **Two-Pointer(二重ポインタ)法**:配列上で2つの添字(インデックス)を異なる速度・役割で動かしながら処理を進めるアルゴリズムパターン。 +> - **二重解放(double free)**:同じメモリ領域を誤って2回`free`してしまうバグ。未定義動作の一種。 + +--- + +## 4. 実装コード + +> 💡 **初学者向け補足**:コード全体を示す前に、大まかな構造(骨格)を示します。 +> +> 1. まず`nums`が`NULL`でないか、`numsSize`が0以下でないかを確認する(異常なら`0`を返す) +> 2. 「まだユニークな要素を書き込んでよい位置」を指す`slow`変数を用意する +> 3. `fast`で配列を先頭から末尾まで1つずつなめていき、`slow`が指す値と違う値が見つかるたびに、その値を`slow`の次の位置に書き込む +> 4. 最後に`slow + 1`(=ユニークな要素の個数)を返す + +```c +#include + +/* + * ソート済み配列から重複要素を取り除き、前方にユニークな値だけを詰め直す。 + * nums が指すメモリ領域を直接書き換える(in-place)。 + * + * 引数: + * nums - 非減少順(同じ値の連続を含む昇順)にソートされた整数配列へのポインタ。 + * NULL の場合は不正な入力とみなし、0 を返す。 + * numsSize - nums の要素数。0 以下の場合は 0 を返す。 + * 戻り値: + * 重複を除いたあとのユニークな要素数 k。 + * 呼び出し側は nums[0] から nums[k-1] までを有効な結果として扱う。 + * 計算量: + * Time: O(n), Space: O(1) (n = numsSize) + */ +int removeDuplicates(int* nums, int numsSize) { + /* NULLチェック:nums が「何も指していない」場合、この後の nums[0] アクセスは + * 未定義動作(多くの環境ではセグメンテーション違反)を引き起こすため、 + * 何もせず安全な値として 0 を返す。 */ + if (nums == NULL) { + return 0; + } + + /* numsSize が 0 以下の場合、ユニークな要素は存在しないので 0 を返す。 + * このチェックがないと、次の行で nums[0] という「存在しない要素」に + * アクセスしてしまい未定義動作になる。 */ + if (numsSize <= 0) { + return 0; + } + + /* slow は「最後に確定したユニークな値」が置かれているインデックス。 + * 最初はインデックス0の要素(nums[0])が唯一の確定済みユニーク値なので0から始める。 + * int型のローカル変数なのでスタック上に置かれ、malloc は一切不要。 */ + int slow = 0; + + /* fast はこれから調べていく値を指すインデックス。 + * インデックス1から numsSize-1 まで、配列を1回だけなめる(1パス)。 + * fast は int 型で、numsSize(int)との比較のみを行うため型の不一致は起きない。 */ + for (int fast = 1; fast < numsSize; fast++) { + /* 配列はソート済みなので、同じ値は必ず隣接している。 + * fast が指す値が、直近の確定済みユニーク値(nums[slow])と異なるなら、 + * それは「新しく見つかったユニークな値」ということになる。 */ + if (nums[fast] != nums[slow]) { + /* slow をひとつ進めて「次にユニーク値を書き込む位置」を確保する。 + * これは numsSize の範囲内に収まることが保証されている + * (slow は fast を追い越すことがなく、fast < numsSize が + * ループ条件で保証されているため)。 */ + slow++; + + /* 新しく見つかったユニーク値を、確定済み領域の直後(nums[slow])に書き込む。 + * 配列外への書き込みではないことは、上記の slow の増え方から保証される。 */ + nums[slow] = nums[fast]; + } + /* nums[fast] == nums[slow] の場合は「すでに確認済みの値の重複」なので、 + * 何もせずに for ループの fast++ によって次の要素へ進む + * (何もしないことがそのまま「重複を無視する」処理になる)。 */ + } + + /* slow は0始まりのインデックスなので、要素の個数に直すには+1する。 + * malloc を一度も使っていないため、free すべきメモリも存在しない。 */ + return slow + 1; +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> 具体例:`nums = {0,0,1,1,1,2,2,3,3,4}`、`numsSize = 10` で実際にトレースしてみます。 +> +> ``` +> 初期状態: nums = {0,0,1,1,1,2,2,3,3,4}、numsSize = 10、slow = 0 +> (NULLチェック → 通過、numsSize <= 0 チェック → 通過) +> +> fast=1: nums[1]=0, nums[slow]=nums[0]=0 → 同じ値なので何もしない +> fast=2: nums[2]=1, nums[slow]=nums[0]=0 → 値が違う! +> slow を 1 に進め、nums[1] = nums[2] = 1 を書き込む +> → nums = {0,1,1,1,1,2,2,3,3,4} +> fast=3: nums[3]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=4: nums[4]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=5: nums[5]=2, nums[slow]=nums[1]=1 → 値が違う! +> slow を 2 に進め、nums[2] = nums[5] = 2 を書き込む +> → nums = {0,1,2,1,1,2,2,3,3,4} +> fast=6: nums[6]=2, nums[slow]=nums[2]=2 → 同じ値なので何もしない +> fast=7: nums[7]=3, nums[slow]=nums[2]=2 → 値が違う! +> slow を 3 に進め、nums[3] = nums[7] = 3 を書き込む +> → nums = {0,1,2,3,1,2,2,3,3,4} +> fast=8: nums[8]=3, nums[slow]=nums[3]=3 → 同じ値なので何もしない +> fast=9: nums[9]=4, nums[slow]=nums[3]=3 → 値が違う! +> slow を 4 に進め、nums[4] = nums[9] = 4 を書き込む +> → nums = {0,1,2,3,4,2,2,3,3,4} +> +> ループ終了(fast=10 は numsSize=10 と等しくなり、条件 fast < numsSize を満たさなくなる) +> +> return slow + 1 = 4 + 1 = 5 +> +> → k = 5、先頭5要素は {0,1,2,3,4} となり、期待される出力と一致する +> ``` +> +> インデックス5以降の値(`{2,2,3,3,4}`の名残)は、問題文の指示どおり「気にしなくてよい部分」としてそのまま残っていますが、Custom Judgeは先頭k個しか見ないため正解として扱われます。 + +> 📖 **このセクションで登場した用語** +> +> - **`NULL`チェック**:ポインタが「何も指していない」状態でないかを、使用前に確認する処理。これを怠るとNULLポインタ参照外しという未定義動作を引き起こす。 +> - **`for (int fast = 1; fast < numsSize; fast++)`**:Cのforループ構文。「初期化式;継続条件;更新式」の3つから成る。`fast`はループ内でのみ有効なスコープ(C99以降で許可されたループ内変数宣言)を持つ。 +> - **配列添字アクセス `nums[i]`**:`*(nums + i)`(ポインタ演算による間接参照)と等価な、Cのシンタックスシュガー(=同じ意味をより読みやすく書ける構文)。範囲外の`i`を指定すると未定義動作になるため、ループ条件で範囲を保証することが重要。 + +--- + +## 補足:計算量・安全性まとめ + +- **時間計算量**: O(n) — 配列を1回だけ走査するため(n = `numsSize`) +- **空間計算量**: O(1) — 追加で使うメモリは`slow`という`int`変数ひとつのみ(スタック上)。`malloc`は一切使用していないため、対応する`free`も不要で、メモリリーク・二重解放のリスクは構造的に存在しない +- **未定義動作の排除**: `NULL`チェックと`numsSize <= 0`チェックにより、配列への不正アクセスを事前に防止している。ループ内の`nums[slow]`・`nums[fast]`はいずれも`0 <= slow <= fast < numsSize`の範囲に収まることが保証されている diff --git a/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Dart.md b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Dart.md new file mode 100644 index 00000000..0762991f --- /dev/null +++ b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Dart.md @@ -0,0 +1,183 @@ +# LeetCodeでの回答フォーマット + +--- + +## 1. 問題の分析 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「すでに並び替え済みのリストから、同じ値が連続している部分をひとつにまとめて、前方に詰め直す問題」です。リスト自体を直接書き換える(in-place=その場で操作する)必要がある点がポイントです。 + +### 競技プログラミング視点での分析 + +- リストはすでにソート済みなので、**同じ値は必ず隣り合っている**という性質を利用できます。これにより、余計な探索(例えば全要素同士を比較する)をせず、**リストを1回だけ左から右へなめる(1パス)だけ**で処理が完結します。 +- 実行速度を最優先するなら、時間計算量 O(n)(nはリストの要素数)が理論上の最速ラインです。全要素を最低1回は確認する必要があるため、これより速くはなりません。 +- メモリ使用量の最小化方針としては、`Set`や`toSet().toList()`のような新しいコレクションを生成する手段を使わず、**与えられた`List`そのものを書き換える**ことで、追加メモリを`int`型の変数1個だけに抑えます。新しいコレクションを作ると、ガベージコレクション(=使われなくなったオブジェクトのメモリを自動的に解放してくれる仕組み)の負荷が増えてしまいます。 + +### 業務開発視点での分析 + +- この問題では境界条件(リストが空、要素が1個だけ、全要素が同じ値、全要素がバラバラ等)を正しく処理できるかが保守性・堅牢性の観点で重要です。 +- Dartでは`List`はオブジェクトへの参照として関数に渡されます。JavaやKotlinの配列・リストと同様、メソッド内で`nums`の要素を書き換えると、呼び出し元が持つリストも同時に書き換わります。これはDartの「すべてがオブジェクトである」という設計思想(プリミティブ型が存在せず、`int`も含めてすべてがオブジェクトとして扱われる)とも関係しています。 +- エラーハンドリングについては、この問題は制約上`nums.length >= 1`が保証されていますが、業務開発の観点では`nums`が空の場合も安全に動作するよう防御的にチェックしておくと、将来的な呼び出し規約の変更にも耐えられます。健全なnull安全性(sound null safety。=`?`の付かない型は絶対に`null`にならないことを、コンパイル時だけでなく実行時にも保証する仕組み)により、`List`型の引数はそもそも`null`になり得ないため、`null`チェック自体は不要です。 + +### Dart特有の考慮点 + +- **健全なnull安全性の活用**:このシグネチャの`List nums`は`nullable`(`?`付き)ではないため、コンパイラが「`nums`は絶対に`null`ではない」ことを保証してくれます。TypeScriptの`strictNullChecks`と似た仕組みですが、Dartの健全なnull安全性はさらに一歩進んでおり、実行時にも型システムの保証が破られないことが言語仕様レベルで保証されています。おかげで、この関数の中では`nums == null`のようなチェックを書く必要が一切ありません。 +- **ジェネリクスの効果的な使用**:今回のシグネチャは`int`固定ですが、汎用化するなら`>`(=「大小比較ができる型」という制約付きの型パラメータ)を使うことで、`double`や`String`など他の型にも対応できます。Dartのジェネリクスはリファイドジェネリクス(=ジェネリクスの型情報が実行時にも保持される仕組み)であるため、Javaのように型消去(実行時に型パラメータの情報が失われる仕組み)によって型情報が失われることはありません。 +- **ガベージコレクションを意識したオブジェクト生成の抑制**:今回のTwo-Pointerアルゴリズムでは、ループの中で新しいオブジェクト(新しいリストや新しい`int`のボックス化オブジェクトなど)を一切生成しません。これにより、ガベージコレクタの負荷を最小限に抑えられます。 + +> 📖 **このセクションで登場した用語** +> +> - **in-place(インプレース)**:新しいリストを作らず、与えられた`List`そのものを直接書き換える操作方式。 +> - **健全なnull安全性(sound null safety)**:`?`の付かない型は絶対に`null`にならないことを、コンパイル時だけでなく実行時にも保証する仕組み。 +> - **リファイドジェネリクス**:ジェネリクスの型情報が実行時にも保持される仕組み。Javaの型消去とは対照的。 +> - **ガベージコレクション(GC)**:使われなくなったオブジェクトのメモリを自動的に解放してくれる仕組み。 + +--- + +## 2. アルゴリズムアプローチ比較 + +> 💡 **初学者向け補足**:同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」を比べて、この問題の制約(in-place・O(1)空間)に最も適したものを選びます。 + +| アプローチ | 時間計算量 | 空間計算量 | Dart実装コスト | 型安全性 | 可読性 | 備考 | +|---|---|---|---|---|---|---| +| **A. Two-Pointer(低速・高速ポインタ)** | O(n) | O(1) | 低 | 高 | 高 | ソート済みという前提を最大限活用。in-place要件を満たす最適解 | +| B. `Set`を使った重複除去 | O(n) | O(n) | 低 | 高 | 中 | `Set`は挿入順を保持する実装だが、順序保証に依存するのは設計として脆く、別途ソートし直す前提を置くのも非効率 | +| C. `nums.toSet().toList()..sort()` | O(n log n) | O(n) | 最低 | 高 | 高 | 記述は簡潔だが、新しい`Set`・`List`オブジェクトを複数生成しin-place要件に反する | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n)`:入力が2倍になると、処理も約2倍になる(線形) +> - `O(n log n)`:入力が2倍になると、処理は約2倍強になる(ソートアルゴリズムに多い) + +**Dart特有のオブジェクト生成コストについて**:方法B・Cではいずれも新しいコレクション(`Set`や`toList()`の結果`List`)を生成するため、O(n)分のオブジェクト生成とガベージコレクションの負荷が発生します。Dartの`int`はプリミティブ型ではなくオブジェクトですが、小さな整数値はDart VM内部で最適化(キャッシュや特殊表現)されることが多いとはいえ、コレクション自体の生成コストは避けられません。方法Aは既存のリストを直接上書きするだけなので、この追加コストが一切発生しません。 + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して、処理にかかる手間がどう増えるかの目安。 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安。 +> - **Dart VM**:Dartプログラムを実行するランタイム環境。JITコンパイルやAOTコンパイル、オブジェクトのメモリ管理などを担う。 + +--- + +## 3. 選択したアルゴリズムと理由 + +> 💡 **初学者向け補足**:他の方法と比較しながら、なぜ二重ポインタ法を選んだのかを説明します。 + +- **選択したアプローチ**: A. Two-Pointer(`slow` / `fast` の2つのインデックスを使う方法) +- **理由**: + - **計算量的な優位性**:`Set`を使う方法(方法B)や`toSet().toList()`(方法C)はどちらもO(n)の追加メモリが必要ですが、Two-Pointer法はO(1)の追加メモリ(`slow`という`int`変数ひとつ)だけで済みます。**この問題は「in-placeで行うこと」自体が明示的な要件**なので、O(n)空間を使う方法B・Cはそもそも要件を満たしません。 + - **Dart環境での型安全性**:`int`同士の比較演算子`!=`はコンパイル時に型チェックされるため、誤った型同士を比較してしまうミスがそもそも起こり得ません。 + - **保守性・可読性の観点**:ループはひとつだけで、条件分岐も「値が変わったかどうか」のシンプルな1行のみです。意図(何がなぜ起きているか)が明確に読み取れます。 +- **Dart特有の最適化ポイント**: + - `List`が参照として渡されることを活かし、コピーを一切発生させずに呼び出し元のリストを直接書き換えています。 + - 健全なnull安全性により、`nums`に対する`null`チェックのコードが不要になり、コードがシンプルに保たれています。 + - ループ内で新しいオブジェクトを一切生成しないため、ガベージコレクションの負荷がかかりません。 + +> 📖 **このセクションで登場した用語** +> +> - **Two-Pointer(二重ポインタ)法**:リスト上で2つの添字(インデックス)を異なる速度・役割で動かしながら処理を進めるアルゴリズムパターン。 + +--- + +## 4. 実装コード + +> 💡 **初学者向け補足**:コード全体を示す前に、大まかな構造(骨格)を示します。 +> +> 1. まず`nums`が空でないかを確認する +> 2. 「まだユニークな要素を書き込んでよい位置」を指す`slow`変数を用意する +> 3. `fast`でリストを先頭から末尾まで1つずつなめていき、`slow`が指す値と違う値が見つかるたびに、その値を`slow`の次の位置に書き込む +> 4. 最後に`slow + 1`(=ユニークな要素の個数)を返す + +```dart +class Solution { + /// ソート済みリストから重複要素を取り除き、前方にユニークな値だけを詰め直す。 + /// nums が参照するリストそのものを直接書き換える(in-place)。 + /// + /// [nums] 非減少順(同じ値の連続を含む昇順)にソートされた整数のリスト。 + /// 健全なnull安全性により、この引数自体が null になることはない。 + /// 戻り値: 重複を除いたあとのユニークな要素数 k。 + /// 呼び出し側は nums[0] から nums[k - 1] までを有効な結果として扱う。 + int removeDuplicates(List nums) { + // リストが空の場合、ユニークな要素は0個なのでそのまま0を返す。 + // (この問題の制約では nums.length >= 1 が保証されているが、 + // 防御的プログラミングとして安全側に倒しておく。 + // nums 自体は non-nullable な型なので null チェックは不要) + if (nums.isEmpty) { + return 0; + } + + // slow は「最後に確定したユニークな値」が置かれているインデックス。 + // 最初はインデックス0の要素(nums[0])が唯一の確定済みユニーク値なので0から始める。 + var slow = 0; + + // fast はこれから調べていく値を指すインデックス。 + // インデックス1から末尾まで、リストを1回だけなめる(1パス)。 + for (var fast = 1; fast < nums.length; fast++) { + // リストはソート済みなので、同じ値は必ず隣接している。 + // fast が指す値が、直近の確定済みユニーク値(nums[slow])と異なるなら、 + // それは「新しく見つかったユニークな値」ということになる。 + if (nums[fast] != nums[slow]) { + // slow をひとつ進めて「次にユニーク値を書き込む位置」を確保する + slow++; + // 新しく見つかったユニーク値を、確定済み領域の直後(nums[slow])に書き込む + nums[slow] = nums[fast]; + } + // nums[fast] == nums[slow] の場合は「すでに確認済みの値の重複」なので、 + // 何もせずに for ループの fast++ によって次の要素へ進む + // (何もしないことがそのまま「重複を無視する」処理になる) + } + + // slow は0始まりのインデックスなので、要素の個数に直すには+1する + return slow + 1; + } +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> 具体例:`nums = [0,0,1,1,1,2,2,3,3,4]` で実際にトレースしてみます。 +> +> ``` +> 初期状態: nums = [0,0,1,1,1,2,2,3,3,4]、slow = 0 +> (nums.isEmpty チェック → 通過) +> +> fast=1: nums[1]=0, nums[slow]=nums[0]=0 → 同じ値なので何もしない +> fast=2: nums[2]=1, nums[slow]=nums[0]=0 → 値が違う! +> slow を 1 に進め、nums[1] = nums[2] = 1 を書き込む +> → nums = [0,1,1,1,1,2,2,3,3,4] +> fast=3: nums[3]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=4: nums[4]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=5: nums[5]=2, nums[slow]=nums[1]=1 → 値が違う! +> slow を 2 に進め、nums[2] = nums[5] = 2 を書き込む +> → nums = [0,1,2,1,1,2,2,3,3,4] +> fast=6: nums[6]=2, nums[slow]=nums[2]=2 → 同じ値なので何もしない +> fast=7: nums[7]=3, nums[slow]=nums[2]=2 → 値が違う! +> slow を 3 に進め、nums[3] = nums[7] = 3 を書き込む +> → nums = [0,1,2,3,1,2,2,3,3,4] +> fast=8: nums[8]=3, nums[slow]=nums[3]=3 → 同じ値なので何もしない +> fast=9: nums[9]=4, nums[slow]=nums[3]=3 → 値が違う! +> slow を 4 に進め、nums[4] = nums[9] = 4 を書き込む +> → nums = [0,1,2,3,4,2,2,3,3,4] +> +> ループ終了(fast=10 は nums.length=10 と等しくなり、条件 fast < nums.length を満たさなくなる) +> +> return slow + 1 = 4 + 1 = 5 +> +> → k = 5、先頭5要素は [0,1,2,3,4] となり、期待される出力と一致する +> ``` +> +> インデックス5以降の値(`[2,2,3,3,4]`の名残)は、問題文の指示どおり「気にしなくてよい部分」としてそのまま残っていますが、これは正解として扱われます(Custom Judgeは先頭k個しか見ないため)。 + +> 📖 **このセクションで登場した用語** +> +> - **`nums.isEmpty`**:`List`が要素を1つも持たないかを判定するゲッター(プロパティのように呼び出せるメソッド)。`nums.length == 0`と等価だが、意図が明確で読みやすいため`isEmpty`を使うのがDartでの慣習。 +> - **`for (var fast = 1; fast < nums.length; fast++)`**:Dartのforループ構文。「初期化式;継続条件;更新式」の3つから成る。`var`により、`fast`の型は初期値`1`(`int`リテラル)から`int`と推論される。 +> - **添字(インデックス)アクセス `nums[i]`**:リストの`i`番目(0始まり)の要素にアクセスする構文。Dartでは範囲外の`i`を指定すると`RangeError`という実行時例外が投げられ、C言語のような未定義動作にはならない(Dartのランタイムが自動的に範囲チェックを行うため)。 + +--- + +## 補足:計算量まとめ + +- **時間計算量**: O(n) — リストを1回だけ走査するため(n = `nums.length`) +- **空間計算量**: O(1) — 追加で使うメモリは`slow`という`int`変数ひとつのみ(入力リスト自体は書き換えているだけで、新規のコレクション生成は行っていない) +- **null安全性**: `nums`引数はnon-nullable(`?`が付いていない)な`List`型のため、健全なnull安全性により`null`に関する実行時エラーがそもそも発生し得ない設計になっている diff --git a/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Go.md b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Go.md new file mode 100644 index 00000000..6eb7a3aa --- /dev/null +++ b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Go.md @@ -0,0 +1,316 @@ +# LeetCodeでの回答フォーマット + +--- + +## 1. 問題分析結果 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「すでに並び替え済みのスライスから、同じ値が連続している部分をひとつにまとめて、前方に詰め直す問題」です。難しさのポイントは2つあります。①新しいスライスを作らず、与えられた`nums`そのものを書き換える(in-place=新しいスライスを作らず、元のスライスを直接書き換える操作)必要があること、②Goのスライスは「ポインタ・長さ・容量」の3つを持つ内部構造(スライスヘッダ)を持っているため、その仕組みを理解した上で安全にインデックスアクセスを行う必要があることです。 +> +> **Goで解く際に特に気をつけるべき点**:この問題はエラーハンドリング設計・ゴルーチンの必要性・スライスのアロケーション戦略という3つの観点で見ると非常にシンプルです。まず、Goのスライスは関数に渡されるとスライスヘッダ(ポインタ・長さ・容量)がコピーされますが、ポインタが指す先の配列本体は呼び出し元と共有されているため、`nums[i] = x`のような書き込みは呼び出し元のスライスにもそのまま反映されます。次に、この問題は単純な1パスの逐次処理でありデータ依存関係(前の要素の処理結果が次の判定に影響する)があるため、ゴルーチン(=Goが提供する軽量スレッド)による並列化には向きません。最後に、この問題は新しいスライスを一切生成しないため、`make`によるプリアロケーション(=必要なメモリをあらかじめまとめて確保しておくこと)も不要です。 + +### 競技プログラミング視点 + +- **制約分析**: LeetCodeの制約は`1 <= nums.length <= 3 * 10^4`程度であり、O(n)アルゴリズムであれば余裕を持って制限時間内に収まります。O(n log n)やO(n²)を使う必要は全くありません。 +- **最速手法**: ソート済みという性質を使ったTwo-Pointer(二重ポインタ)法がBig-O最適(O(n))かつGo実装最適(追加ヒープアロケーション0回)の両方を満たします。 +- **メモリ最小化**: 新しいスライスや`map`を一切生成せず、`int`型のローカル変数2個(`slow` と `fast`)だけで完結させます。プリアロケーションも不要(そもそも新しいスライスを作らないため)です。 +- **Go最適化**: 組み込み関数(`len()`)以外は特に標準ライブラリを使わず、シンプルなforループのみで実装します。 + +### 業務開発視点 + +- **型安全設計**: `[]int`という明示的な型のスライスを直接扱うため、型に関する曖昧さは生じません。 +- **エラーハンドリング**: この問題は`nums.length >= 1`という制約が保証されていますが、業務開発視点では`len(nums) == 0`のケースをガードしておくと、将来的な呼び出し規約の変更にも耐えられます。Goには例外機構がないため、今回は「異常系」ではなく「正当な入力の一種」として扱い、`error`は返さずそのまま`0`を返す設計にします(空スライスはこの問題の文脈では契約違反ではないため)。 +- **可読性**: ループはひとつだけで、コメントを各行に付けることで意図(なぜそうするか)を明確にします。 + +### Go特有分析 + +- **データ構造選択**: この問題は「順序ありでインデックスアクセスが必要」という性質を持つため、スライス`[]int`がそのまま最適です。`map`(キー検索)や`channel`(ゴルーチン間通信)は不要です。 +- **標準ライブラリ活用度**: 実は標準ライブラリの`slices.Compact`(Go 1.21+で追加)がまさにこの問題と同種の処理を行いますが、今回はアルゴリズムの内部動作を理解する教育的価値を重視し、手動でTwo-Pointerを実装します。 +- **並行処理適性**: 不適。前の要素の処理結果(`slow`ポインタの位置)が次の判定に依存する逐次処理のため、ゴルーチンによる並列化はできません(並列化すると`slow`の値が競合し、データ競合(Race Condition)を引き起こします)。 +- **エスケープ解析**: `slow`と`fast`はどちらも関数のスコープ内でのみ使われる`int`型のローカル変数であり、通常はヒープへエスケープしません。ただし実際の割り当て(スタック配置やレジスタ割り当て)はGoコンパイラのエスケープ解析と最適化に依存します。`nums`自体は呼び出し元から渡されたスライスヘッダのコピーですが、内部のポインタが指す配列本体はすでにヒープ上(あるいは呼び出し元のスタック)にあり、今回新たにヒープアロケーションが発生することはありません。 + +> 📖 **このセクションで登場した用語** +> +> - **スライスヘッダ**:Goのスライスが内部に持つ「配列本体へのポインタ・長さ(`len`)・容量(`cap`)」の3つ組。関数に渡すときはこのヘッダがコピーされるが、ポインタが指す配列本体は共有される +> - **in-place操作**:新しいスライスを作らず、元のスライスを直接書き換える操作。アロケーションを減らせる +> - **データ競合(Race Condition)**:複数ゴルーチンが同じメモリを同時に読み書きするとき、結果が不定になる問題 +> - **エスケープ解析**:変数をスタックに置けるかヒープに置くべきかをコンパイラが自動判断する仕組み + +--- + +## 2. アルゴリズム比較表 + +> 💡 **初学者向け補足**:同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」を比べて最適なものを選びます。「Go実装コスト」列は、「スライスの再アロケーションが起きるか」「不要なヒープ逃げが発生するか」という観点で評価しています。 + +> 💡 **Big-O記法の読み方**(初学者向け) +> | 記法 | 意味 | 直感的イメージ | +> |------|------|--------------| +> | `O(1)` | 入力サイズによらず一定 | 辞書の直引き | +> | `O(n)` | 入力に比例して増加 | リストを端から順に読む | +> | `O(n log n)` | nより少し多く増加 | ソートアルゴリズムの典型 | +> | `O(n²)` | 入力の2乗で増加 | 二重ループの総当たり | + +| アプローチ | 時間計算量 | 空間計算量 | Go実装コスト | 可読性 | 標準ライブラリ活用 | 並行処理適性 | 備考 | +| ---------- | ---------- | ---------- | ------------ | ------ | ------------------ | ------------ | ---- | +| **A. Two-Pointer(手動実装)** | O(n) | O(1) | 低 | ★★★ | なし | 不適 | 再アロケーション0回、ヒープ逃げ0回 | +| B. `map[int]struct{}`で重複除去 | O(n) | O(n) | 中 | ★★☆ | なし | 不適 | mapへの書き込みごとにハッシュ計算とヒープ逃げが発生しうる | +| C. `slices.Compact`(標準ライブラリ) | O(n) | O(1) | 低 | ★★★ | slices | 不適 | 内部実装はTwo-Pointerとほぼ同じだが、内部動作がブラックボックス化される | + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して処理にかかる手間がどう増えるかの目安 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安 +> - **再アロケーション**:スライスの容量が足りなくなったとき、より大きいメモリ領域に全要素をコピーする操作(今回はスライスを伸長しないため発生しない) +> - **`slices.Compact`**:Go 1.21+で追加された標準ライブラリ関数。ソート済みスライスの隣接する重複要素を削除し、結果の長さを返す + +--- + +## 3. Go特有最適化ポイント + +この問題では最適化の余地は多くありませんが、「なぜ`map`やヒープアロケーションを避けるべきか」を最適化前後のコードで比較します。 + +```go +// 最適化前:map を使うと、要素ごとにハッシュ計算とバケット探索が発生し、 +// さらに map の内部データはヒープに逃げるため GC の管理対象が増える +seen := make(map[int]struct{}) +result := []int{} +for _, v := range nums { + if _, ok := seen[v]; !ok { + seen[v] = struct{}{} + result = append(result, v) + } +} + +// 最適化後:ソート済みという性質を活かし、Two-Pointer で in-place に処理する。 +// 新しいスライスも map も一切生成しない。 +slow := 0 +for fast := 1; fast < len(nums); fast++ { + if nums[fast] != nums[slow] { + slow++ + nums[slow] = nums[fast] + } +} +// 理由:ソート済み配列では「同じ値は必ず隣接している」という性質があるため、 +// map によるハッシュベースの重複判定が不要になり、 +// O(1) 空間・ヒープアロケーション0回で同じ結果が得られる +``` + +### Goコンパイラ最適化 + +- **組み込み関数活用**: `len(nums)`のみを使用。`make()`や`append()`は今回は不要(新しいスライスを作らないため)。 +- **スライス操作**: in-place操作のみ。プリアロケーションも該当なし(元のスライスの容量をそのまま使う)。 +- **インライン化**: このロジックは単純な比較・代入のみなので、コンパイラが呼び出し元にインライン展開しやすい形をしています。 +- **標準ライブラリ**: 今回は使用しません(`sort`は前提条件としてすでにソート済みのため不要、`container/heap`も不要)。 + +### データ構造選択指針 + +| 状況 | 選ぶべき構造 | 理由 | +|---|---|---| +| 順序あり・インデックスアクセス | スライス `[]int`(今回採用) | 連続メモリでキャッシュ効率が高く、ソート済みの隣接比較に最適 | +| キーで素早く検索したい | マップ `map[K]V`(今回不採用) | O(1)のルックアップだが、ハッシュ計算コストとヒープ逃げが発生する | + +### メモリ最適化 + +- **プリアロケーション**: 該当なし。新しいスライスを一切生成しないため`make([]T, 0, cap)`は不要です。 +- **エスケープ解析**: `slow`・`fast`はどちらもスタックに割り当てられます(関数外に参照が漏れないため)。 +- **ポインタ使用**: `nums []int`はすでにスライスヘッダの受け渡しであり、大きな構造体のポインタ渡しのような追加の最適化余地はありません。 + +### 並行処理最適化 + +> 💡 **初学者向け補足**: +> 1. **なぜ並行処理が必要か**:この問題では不要です。`slow`ポインタの位置は「それまでのすべての要素の処理結果」に依存する逐次的な状態であり、チャンクに分割して独立に処理することができません。 +> 2. **ゴルーチンとチャネルの基本的な関係**:仮に並列化を試みる場合でも、各ゴルークーチンが担当する区間の重複除去結果を「チャネルという社内メール」でまとめ上げる必要があり、この問題の単純さに対してオーバーヘッドが見合いません。 +> 3. **Mutexとの使い分け基準**:`slow`という単一の共有カウンタを複数ゴルーチンから更新する設計にすると、Mutex(ロックで共有する)による排他制御が必要になりますが、それでも逐次処理より遅くなるだけで意味がありません。 + +- **ゴルーチン**: 本問題では不使用(オーバーヘッドが処理内容を上回るため)。 +- **チャネル**: 不使用。 +- **sync.Mutex/RWMutex**: 不使用。 +- **sync.WaitGroup**: 不使用。 + +> 📖 **このセクションで登場した用語** +> +> - **プリアロケーション**:必要なメモリをあらかじめまとめて確保しておくこと。今回は新しいスライスを作らないため該当なし +> - **キャッシュ効率**:CPUがデータをキャッシュから素早く読める度合い。連続したメモリ(スライス)はキャッシュに優しい + +--- + +## 4. 実装パターン + +> 💡 **初学者向け補足**:コード全体を示す前に、大まかな構造(骨格)を示します。 +> +> 1. `Solution`構造体を定義する(LeetCodeのメソッド形式の慣習に合わせる) +> 2. 業務開発版:`len(nums) == 0`をガードし、Two-Pointerで重複除去を行う +> 3. 競技プログラミング版:ガードを最小限にし、Two-Pointerのロジックのみをシンプルに書く +> 4. どちらも新しいスライス・map・ゴルーチンは一切使わない + +【業務開発版を使う場面】 +チームで長期間メンテナンスするプロダクションコードに向きます。境界条件(空スライス)を明示的にコメントで説明し、将来の仕様変更にも耐えられるようにします。`go vet`・`golangci-lint`を通じてコード品質を機械的に担保できます。 + +【競技プログラミング版を使う場面】 +LeetCodeの制限時間内に正解を出すことが目的のコードに向きます。この問題自体が非常にシンプルなため、業務版とほぼ同じ形になりますが、コメント量を絞り実装速度を優先します。 + +```go +package main + +// Solution 構造体(LeetCode形式) +// Goにはクラスがないためstructとメソッドを組み合わせる。 +// メソッドを持たない空の struct でも、LeetCode の提出形式に合わせるために定義する。 +type Solution struct{} + +// RemoveDuplicatesProduction は業務開発向け実装。 +// 境界条件を明示的にガードし、コメントで意図を説明することを重視する。 +// +// nums: 非減少順(同じ値の連続を含む昇順)にソートされた整数スライス。 +// +// この関数は nums が指す配列本体を直接書き換える(in-place)。 +// +// 戻り値: 重複を除いたあとのユニークな要素数 k。 +// +// 呼び出し元は nums[0] から nums[k-1] までを有効な結果として扱う。 +// +// Time Complexity: O(n) +// Space Complexity: O(1) +func (s *Solution) RemoveDuplicatesProduction(nums []int) int { + // ① 境界条件のガード:nums が空の場合、ユニークな要素は0個。 + // なぜ最初にチェックするか:この後 nums[0] にアクセスするコードを書いてしまうと、 + // 空スライスに対する範囲外アクセスでパニック(実行時強制終了)が発生するため。 + // (この問題の制約では len(nums) >= 1 が保証されているが、 + // 業務コードとしては呼び出し規約の変更に備えて防御的にチェックしておく) + if len(nums) == 0 { + return 0 + } + + // ② slow は「最後に確定したユニークな値」が置かれているインデックス。 + // 最初はインデックス0の要素(nums[0])が唯一の確定済みユニーク値なので0から始める。 + slow := 0 + + // ③ fast はこれから調べていく値を指すインデックス。 + // インデックス1から末尾まで、スライスを1回だけなめる(1パス)。 + // range を使わず古典的な for 文にしているのは、i と v の両方ではなく + // インデックスだけを使い、かつ nums[slow] という「別のインデックス」も + // 同時に参照する必要があるため(range は現在の要素の値しか直接くれない)。 + for fast := 1; fast < len(nums); fast++ { + // スライスはソート済みなので、同じ値は必ず隣接している。 + // fast が指す値が、直近の確定済みユニーク値(nums[slow])と異なるなら、 + // それは「新しく見つかったユニークな値」ということになる。 + if nums[fast] != nums[slow] { + // slow をひとつ進めて「次にユニーク値を書き込む位置」を確保する + slow++ + // 新しく見つかったユニーク値を、確定済み領域の直後(nums[slow])に書き込む。 + // nums はスライスヘッダのコピーだが、配列本体は呼び出し元と共有されているため、 + // この書き込みは呼び出し元の変数にもそのまま反映される。 + nums[slow] = nums[fast] + } + // nums[fast] == nums[slow] の場合は「すでに確認済みの値の重複」なので、 + // 何もせずに for 文の fast++ によって次の要素へ進む + // (何もしないことがそのまま「重複を無視する」処理になる) + } + + // ④ slow は0始まりのインデックスなので、要素の個数に直すには+1する + return slow + 1 +} + +// RemoveDuplicatesCompetitive は競技プログラミング向け実装。 +// ロジックは Production 版と同一だが、コメントを最小限にして実装速度を優先する。 +// +// Time Complexity: O(n) +// Space Complexity: O(1) +func (s *Solution) RemoveDuplicatesCompetitive(nums []int) int { + if len(nums) == 0 { + return 0 + } + slow := 0 + for fast := 1; fast < len(nums); fast++ { + if nums[fast] != nums[slow] { + slow++ + nums[slow] = nums[fast] + } + } + return slow + 1 +} + +// LeetCode 提出用:関数形式(パターン1)。 +// 提出テンプレートが関数形式の場合はこちらをそのまま使う。 +// ロジックは Production 版と同一。 +func removeDuplicates(nums []int) int { + if len(nums) == 0 { + return 0 + } + slow := 0 + for fast := 1; fast < len(nums); fast++ { + if nums[fast] != nums[slow] { + slow++ + nums[slow] = nums[fast] + } + } + return slow + 1 +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> 具体例:`removeDuplicates([]int{0,0,1,1,1,2,2,3,3,4})` の呼び出し +> +> ``` +> 初期状態: nums = [0,0,1,1,1,2,2,3,3,4]、len(nums) = 10、slow = 0 +> (len(nums) == 0 チェック → 通過、境界条件ではない) +> +> fast=1: nums[1]=0, nums[slow]=nums[0]=0 → 同じ値なので何もしない +> fast=2: nums[2]=1, nums[slow]=nums[0]=0 → 値が違う! +> slow を 1 に進め、nums[1] = nums[2] = 1 を書き込む +> → nums = [0,1,1,1,1,2,2,3,3,4] +> fast=3: nums[3]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=4: nums[4]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=5: nums[5]=2, nums[slow]=nums[1]=1 → 値が違う! +> slow を 2 に進め、nums[2] = nums[5] = 2 を書き込む +> → nums = [0,1,2,1,1,2,2,3,3,4] +> fast=6: nums[6]=2, nums[slow]=nums[2]=2 → 同じ値なので何もしない +> fast=7: nums[7]=3, nums[slow]=nums[2]=2 → 値が違う! +> slow を 3 に進め、nums[3] = nums[7] = 3 を書き込む +> → nums = [0,1,2,3,1,2,2,3,3,4] +> fast=8: nums[8]=3, nums[slow]=nums[3]=3 → 同じ値なので何もしない +> fast=9: nums[9]=4, nums[slow]=nums[3]=3 → 値が違う! +> slow を 4 に進め、nums[4] = nums[9] = 4 を書き込む +> → nums = [0,1,2,3,4,2,2,3,3,4] +> +> ループ終了(fast=10 は len(nums)=10 と等しくなり、条件 fast < len(nums) を満たさなくなる) +> +> 戻り値: slow + 1 = 4 + 1 = 5 +> +> → k = 5、先頭5要素は [0,1,2,3,4] となり、期待される出力と一致する +> ``` +> +> インデックス5以降の値(`[2,2,3,3,4]`の名残)は、問題文の指示どおり「気にしなくてよい部分」としてそのまま残っていますが、Custom Judgeは先頭k個しか見ないため正解として扱われます。 + +> 📖 **このセクションで登場した用語** +> +> - **スライスヘッダの共有**:`nums []int`という引数は「ポインタ・長さ・容量」のコピーだが、ポインタが指す配列本体は呼び出し元と共有されているため、関数内での書き込みが呼び出し元にも反映される +> - **`for fast := 1; fast < len(nums); fast++`**:Goの古典的なforループ構文。「初期化文;条件式;後処理文」の3つから成り、`range`と異なり任意のインデックス間の比較が自由に書ける + +--- + +## 検証 + +_テストコードは不要です!具体的な計測値、メモリの出力も不要です!_ + +> 💡 **初学者向け補足**:エッジケースとは「入力が空・最小値・最大値・重複あり」など境界的な入力のことです。 + +- **境界値テスト**: + - `nums = []`(空スライス)の場合:この問題の制約では発生しないが、もし発生した場合、`len(nums) == 0`のガードがなければ`nums[0]`へのアクセスでパニック(範囲外インデックス)になる可能性がある。 + - `nums = [1]`(要素1個)の場合:`fast`のループが1度も実行されず(`1 < len(nums)`が`1 < 1`で偽になるため)、`slow`は初期値の`0`のまま`slow + 1 = 1`が返る。これは正しい(ユニークな要素は1個)。 + - `nums`の全要素が同じ値(例:`[2,2,2,2]`)の場合:`nums[fast] != nums[slow]`が一度も真にならず、`slow`は`0`のまま`1`が返る。これも正しい。 + - `nums`の全要素が異なる値(例:`[1,2,3,4]`)の場合:毎回`if`が真になり、`slow`は`fast`と同じ速度で進み、最終的に`len(nums)`がそのまま返る。 +- **型チェック**: `go vet`で警告が出ないことを確認。今回は未使用変数・型の不一致がないシンプルな実装のため問題は発生しません。 +- **並行安全性**: 該当なし(ゴルーチンを使用していないため、データ競合のリスクは構造的に存在しません)。 + +> 📖 **このセクションで登場した用語** +> +> - **エッジケース**:空のスライス・要素1つ・全要素同一・全要素異なるなど、境界的な条件の入力 +> - **パニック**:Goで回復不能なエラーが発生した際に起きる強制終了。インデックスの範囲外アクセスなどで発生する + +--- + +## 補足:計算量まとめ + +- **時間計算量**: O(n) — スライスを1回だけ走査するため(n = `len(nums)`) +- **空間計算量**: O(1) — 追加で使うメモリは`slow`という`int`変数ひとつのみ(スタック上に確保され、エスケープ解析によりヒープには逃げない) +- **アロケーション回数**: 0回 — `make`・`append`を一切使わないため、GCの管理対象となる新規オブジェクトは生成されない diff --git a/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Java.md b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Java.md new file mode 100644 index 00000000..59c72ef9 --- /dev/null +++ b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Java.md @@ -0,0 +1,194 @@ +# LeetCodeでの回答フォーマット + +--- + +## 1. 問題の分析 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「すでに並び替え済みの配列から、同じ値が連続している部分をひとつにまとめて、前方に詰め直す問題」です。配列自体を直接書き換える(in-place=その場で操作する)必要がある点がポイントです。 + +### 競技プログラミング視点での分析 + +- 配列はすでにソート済みなので、**同じ値は必ず隣り合っている**という性質を利用できます。これにより、余計な探索(例えば全要素同士を比較する)をせず、**配列を1回だけ左から右へなめる(1パス)だけ**で処理が完結します。 +- 実行速度を最優先するなら、時間計算量 O(n)(nは配列の要素数)が理論上の最速ラインです。全要素を最低1回は確認する必要があるため、これより速くはなりません。 +- メモリ使用量の最小化方針としては、`HashSet`のような新しいコレクションを生成する手段を使わず、**与えられた`int[]`そのものを書き換える**ことで、追加メモリを`int`型の変数1個だけに抑えます。今回のシグネチャはすでにプリミティブ型配列(`int[]`)なので、`Integer`へのボクシング(=`int`のようなプリミティブ型を`Integer`のようなラッパークラスのオブジェクトに変換すること)が発生する余地自体がありません。 + +### 業務開発視点での分析 + +- この問題では境界条件(配列が空、要素が1個だけ、全要素が同じ値、全要素がバラバラ等)を正しく処理できるかが保守性・堅牢性の観点で重要です。 +- Javaでは配列は参照型のオブジェクトです。メソッド引数として渡される際、配列の「参照(実体を指す住所メモ)」がコピーされますが、その参照が指す実体(配列本体)は呼び出し元と共有されます。そのため、メソッド内で`nums[i] = x`のように要素を書き換えると、呼び出し元の配列も同時に書き換わります。 +- エラーハンドリングについては、この問題は制約上`nums.length >= 1`が保証されていますが、業務開発の観点では`nums`が`null`で渡されるケースも考慮し、`IllegalArgumentException`(=引数が不正な場合に使う非チェック例外)を投げる防御的な実装にしておくと、将来的な呼び出し元の不備にも耐えられます。Javaは`null`を許容する言語(nullable型とnon-nullable型を型システムでは区別しない)であるため、このような明示的なチェックが重要な意味を持ちます。 + +### Java特有の考慮点 + +- **静的型付けとコンパイル時チェックの活用**:`int[]`という明示的な型のおかげで、コンパイラが「整数配列以外を渡すこと」を型エラーとして検出してくれます。Pythonのような動的型付け言語では実行時までこの種のミスに気づけません。 +- **ジェネリクスの型消去(type erasure)**:今回は`int[]`というプリミティブ型配列を直接扱うため、ジェネリクスの型消去(=Javaのジェネリクスがコンパイル後にバイトコードレベルでは型情報を保持しない仕組み)自体が関与しません。もし`List`のような汎用的な設計にした場合、型消去の影響で`T`が`int`かどうかを実行時に判定できず、`Comparable`のような境界付き型パラメータが必要になります。 +- **コレクションフレームワークの選択**:`List`ではなく`int[]`を直接扱う設計により、要素ごとのボクシング/アンボクシングのコストを完全に回避できます。もし`ArrayList`を使ってしまうと、`n`個の要素それぞれが`Integer`オブジェクトとしてヒープに確保され、ガベージコレクションの負荷が増加します。 +- **ボクシング/アンボクシングのコスト**:この問題のシグネチャはすでに`int[]`固定なので、今回の実装ではボクシングは一切発生しません。これはこの問題における最も重要なJava特有の最適化ポイントです。 + +> 📖 **このセクションで登場した用語** +> +> - **in-place(インプレース)**:新しい配列を作らず、与えられた配列そのものを直接書き換える操作方式。 +> - **ボクシング/アンボクシング**:`int`のようなプリミティブ型を`Integer`のようなラッパークラスのオブジェクトに変換すること(ボクシング)、およびその逆(アンボクシング)。オブジェクト生成のコストが発生する。 +> - **型消去(Type Erasure)**:Javaのジェネリクスがコンパイル後にバイトコードレベルでは型情報を保持しない仕組み。 +> - **`IllegalArgumentException`**:引数が不正な場合に使うJava標準の非チェック例外。 + +--- + +## 2. アルゴリズムアプローチ比較 + +> 💡 **初学者向け補足**:同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」を比べて、この問題の制約(in-place・O(1)空間)に最も適したものを選びます。 + +| アプローチ | 時間計算量 | 空間計算量 | Java実装コスト | 型安全性 | 可読性 | 備考 | +|---|---|---|---|---|---|---| +| **A. Two-Pointer(低速・高速ポインタ)** | O(n) | O(1) | 低 | 高 | 高 | ソート済みという前提を最大限活用。ボクシング不要でin-place要件を満たす最適解 | +| B. `LinkedHashSet`を使った重複除去 | O(n) | O(n) | 中 | 高 | 中 | 挿入順を保持できるが、`int`→`Integer`のボクシングが要素数分発生し、ヒープアロケーションのコストも大きい | +| C. `Arrays.stream(nums).distinct()`(Stream API) | O(n) | O(n) | 低 | 高 | 高 | 記述は簡潔だが、内部で`IntStream`のパイプライン処理とバッファ用配列の生成が発生しin-place要件に反する | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n)`:入力が2倍になると、処理も約2倍になる(線形) +> - `O(n log n)`:入力が2倍になると、処理は約2倍強になる(ソートアルゴリズムに多い、今回は該当なし) + +**Javaのボクシングコストについて**:方法Bでは`int`型の値を`LinkedHashSet`に格納するたびに、JVM(=Javaプログラムを実行する仮想マシン)は`int`を`Integer`オブジェクトへボクシングします。`-128`〜`127`の範囲は`Integer`のキャッシュ機構により再利用されますが、それ以外の値は毎回新しい`Integer`オブジェクトがヒープに生成され、ガベージコレクションの対象になります。方法Cの`Arrays.stream(nums)`は`IntStream`というプリミティブ特化のストリームを使うためボクシング自体は避けられますが、内部で新しい配列(`distinct()`の結果を保持するバッファ)を生成するため、O(n)の追加メモリが必要です。方法Aは`int[]`をそのまま直接操作するため、ボクシングも追加のヒープアロケーションも一切発生しません。 + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して、処理にかかる手間がどう増えるかの目安。 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安。 +> - **`Integer`キャッシュ機構**:JVMが`-128`〜`127`の範囲の`Integer`オブジェクトをあらかじめ生成しキャッシュしておく仕組み。この範囲の値をボクシングする際は新規オブジェクト生成を回避できる。 + +--- + +## 3. 選択したアルゴリズムと理由 + +> 💡 **初学者向け補足**:他の方法と比較しながら、なぜ二重ポインタ法を選んだのかを説明します。 + +- **選択したアプローチ**: A. Two-Pointer(`slow` / `fast` の2つのインデックスを使う方法) +- **理由**: + - **計算量的な優位性**:`LinkedHashSet`(方法B)や`Stream`(方法C)はどちらもO(n)の追加メモリが必要ですが、Two-Pointer法はO(1)の追加メモリ(`slow`という`int`変数ひとつ)だけで済みます。**この問題は「in-placeで行うこと」自体が明示的な要件**なので、O(n)空間を使う方法B・Cはそもそも要件を満たしません。 + - **Java環境での型安全性**:`int`同士の比較演算子`!=`はコンパイル時に型チェックされるため、誤った型同士を比較してしまうミスがそもそも起こり得ません。 + - **保守性・可読性の観点**:ループはひとつだけで、条件分岐も「値が変わったかどうか」のシンプルな1行のみです。Stream APIのような宣言的な書き方と比べるとやや行数は増えますが、意図(何がなぜ起きているか)が明確に読み取れます。 +- **Java特有の最適化ポイント**: + - `int[]`をそのまま扱うことで、ボクシング/アンボクシングのコストを完全に排除しています。 + - 配列が参照型であることを活かし、コピーを一切発生させずに呼び出し元の配列を直接書き換えています。 + - 先頭で`nums == null`をチェックすることで、`NullPointerException`(=`null`の参照に対してメソッド呼び出しやフィールドアクセスを行おうとした際に発生する実行時例外)を未然に防いでいます。 + +> 📖 **このセクションで登場した用語** +> +> - **Two-Pointer(二重ポインタ)法**:配列上で2つの添字(インデックス)を異なる速度・役割で動かしながら処理を進めるアルゴリズムパターン。 +> - **`NullPointerException`**:`null`の参照に対してメソッド呼び出しやフィールドアクセスを行おうとした際に発生する実行時例外。 + +--- + +## 4. 実装コード + +> 💡 **初学者向け補足**:コード全体を示す前に、大まかな構造(骨格)を示します。 +> +> 1. まず`nums`が`null`でないか、空配列でないかを確認する +> 2. 「まだユニークな要素を書き込んでよい位置」を指す`slow`変数を用意する +> 3. `fast`で配列を先頭から末尾まで1つずつなめていき、`slow`が指す値と違う値が見つかるたびに、その値を`slow`の次の位置に書き込む +> 4. 最後に`slow + 1`(=ユニークな要素の個数)を返す + +```java +class Solution { + /** + * ソート済み配列から重複要素を取り除き、前方にユニークな値だけを詰め直す。 + * nums が参照する配列本体を直接書き換える(in-place)。 + * + * @param nums 非減少順(同じ値の連続を含む昇順)にソートされた整数配列。 + * @return 重複を除いたあとのユニークな要素数 k。 + * 呼び出し側は nums[0] から nums[k-1] までを有効な結果として扱う。 + * @throws IllegalArgumentException nums が null の場合に投げる。 + */ + public int removeDuplicates(int[] nums) { + // null チェック:nums が参照する実体が存在しない場合、この後の nums[0] アクセスは + // NullPointerException を引き起こすため、意図を明確にするために + // 早期に IllegalArgumentException を投げる。 + if (nums == null) { + throw new IllegalArgumentException("Input must not be null"); + } + + // 配列が空の場合、ユニークな要素は0個なのでそのまま0を返す。 + // (この問題の制約では nums.length >= 1 が保証されているが、 + // 防御的プログラミングとして安全側に倒しておく) + if (nums.length == 0) { + return 0; + } + + // slow は「最後に確定したユニークな値」が置かれているインデックス。 + // 最初はインデックス0の要素(nums[0])が唯一の確定済みユニーク値なので0から始める。 + // int 型のプリミティブなローカル変数のため、ボクシングは一切発生しない。 + int slow = 0; + + // fast はこれから調べていく値を指すインデックス。 + // インデックス1から末尾まで、配列を1回だけなめる(1パス)。 + for (int fast = 1; fast < nums.length; fast++) { + // 配列はソート済みなので、同じ値は必ず隣接している。 + // fast が指す値が、直近の確定済みユニーク値(nums[slow])と異なるなら、 + // それは「新しく見つかったユニークな値」ということになる。 + if (nums[fast] != nums[slow]) { + // slow をひとつ進めて「次にユニーク値を書き込む位置」を確保する + slow++; + // 新しく見つかったユニーク値を、確定済み領域の直後(nums[slow])に書き込む + nums[slow] = nums[fast]; + } + // nums[fast] == nums[slow] の場合は「すでに確認済みの値の重複」なので、 + // 何もせずに for ループの fast++ によって次の要素へ進む + // (何もしないことがそのまま「重複を無視する」処理になる) + } + + // slow は0始まりのインデックスなので、要素の個数に直すには+1する + return slow + 1; + } +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> 具体例:`nums = {0,0,1,1,1,2,2,3,3,4}` で実際にトレースしてみます。 +> +> ``` +> 初期状態: nums = {0,0,1,1,1,2,2,3,3,4}、slow = 0 +> (null チェック → 通過、length == 0 チェック → 通過) +> +> fast=1: nums[1]=0, nums[slow]=nums[0]=0 → 同じ値なので何もしない +> fast=2: nums[2]=1, nums[slow]=nums[0]=0 → 値が違う! +> slow を 1 に進め、nums[1] = nums[2] = 1 を書き込む +> → nums = {0,1,1,1,1,2,2,3,3,4} +> fast=3: nums[3]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=4: nums[4]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=5: nums[5]=2, nums[slow]=nums[1]=1 → 値が違う! +> slow を 2 に進め、nums[2] = nums[5] = 2 を書き込む +> → nums = {0,1,2,1,1,2,2,3,3,4} +> fast=6: nums[6]=2, nums[slow]=nums[2]=2 → 同じ値なので何もしない +> fast=7: nums[7]=3, nums[slow]=nums[2]=2 → 値が違う! +> slow を 3 に進め、nums[3] = nums[7] = 3 を書き込む +> → nums = {0,1,2,3,1,2,2,3,3,4} +> fast=8: nums[8]=3, nums[slow]=nums[3]=3 → 同じ値なので何もしない +> fast=9: nums[9]=4, nums[slow]=nums[3]=3 → 値が違う! +> slow を 4 に進め、nums[4] = nums[9] = 4 を書き込む +> → nums = {0,1,2,3,4,2,2,3,3,4} +> +> ループ終了(fast=10 は nums.length=10 と等しくなり、条件 fast < nums.length を満たさなくなる) +> +> return slow + 1 = 4 + 1 = 5 +> +> → k = 5、先頭5要素は {0,1,2,3,4} となり、期待される出力と一致する +> ``` +> +> インデックス5以降の値(`{2,2,3,3,4}`の名残)は、問題文の指示どおり「気にしなくてよい部分」としてそのまま残っていますが、これは正解として扱われます(Custom Judgeは先頭k個しか見ないため)。 + +> 📖 **このセクションで登場した用語** +> +> - **`IllegalArgumentException`**:引数が不正な場合に投げるJava標準の非チェック例外(`RuntimeException`のサブクラスで、呼び出し元に`try-catch`を強制しない)。 +> - **`for (int fast = 1; fast < nums.length; fast++)`**:Javaのforループ構文。「初期化式;継続条件;更新式」の3つから成り、`fast`はこのループのスコープ内でのみ有効な変数として宣言される。 +> - **配列インデクサ `nums[i]`**:配列の`i`番目(0始まり)の要素にアクセスする構文。Javaでは範囲外の`i`を指定すると`ArrayIndexOutOfBoundsException`という実行時例外が投げられ、C言語のような未定義動作にはならない(JVMが自動的に範囲チェックを行うため)。 + +--- + +## 補足:計算量まとめ + +- **時間計算量**: O(n) — 配列を1回だけ走査するため(n = `nums.length`) +- **空間計算量**: O(1) — 追加で使うメモリは`slow`という`int`変数ひとつのみ(入力配列自体は書き換えているだけで、新規のコレクション生成もボクシングも行っていない) +- **例外安全性**: `nums == null`のケースを`IllegalArgumentException`で明示的に扱っており、実行時に予期しない`NullPointerException`が発生する余地がない diff --git a/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Kotlin.md b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Kotlin.md new file mode 100644 index 00000000..6fd146be --- /dev/null +++ b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Kotlin.md @@ -0,0 +1,190 @@ +# LeetCodeでの回答フォーマット + +--- + +## 1. 問題の分析 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「すでに並び替え済みの配列から、同じ値が連続している部分をひとつにまとめて、前方に詰め直す問題」です。配列自体を直接書き換える(in-place=その場で操作する)必要がある点がポイントです。 + +### 競技プログラミング視点での分析 + +- 配列はすでにソート済みなので、**同じ値は必ず隣り合っている**という性質を利用できます。これにより、余計な探索(例えば全要素同士を比較する)をせず、**配列を1回だけ左から右へなめる(1パス)だけ**で処理が完結します。 +- 実行速度を最優先するなら、時間計算量 O(n)(nは配列の要素数)が理論上の最速ラインです。全要素を最低1回は確認する必要があるため、これより速くはなりません。 +- メモリ使用量の最小化方針としては、`LinkedHashSet`や`toMutableList().distinct()`のような新しいコレクションを生成する手段を使わず、**与えられた`IntArray`そのものを書き換える**ことで、追加メモリを`Int`型の変数1個だけに抑えます。今回のシグネチャはすでにプリミティブ配列(`IntArray`)なので、ボクシング(=`Int`のようなプリミティブ型を`Integer`のようなラッパーオブジェクトに変換すること)が発生する余地自体がありません。 + +### 業務開発視点での分析 + +- この問題では境界条件(配列が空、要素が1個だけ、全要素が同じ値、全要素がバラバラ等)を正しく処理できるかが保守性・堅牢性の観点で重要です。 +- Kotlinの`IntArray`はJVM上では`int[]`(プリミティブ配列)にコンパイルされる参照型のオブジェクトです。関数引数として渡す際、配列の「参照(実体を指す住所メモ)」がコピーされますが、その参照が指す実体(配列本体)は呼び出し元と共有されます。そのため、関数内で`nums[i] = x`のように要素を書き換えると、呼び出し元の配列も同時に書き換わります。 +- エラーハンドリングについては、この問題は制約上`nums.size >= 1`が保証されていますが、Kotlinの`IntArray`型は元々null非許容(`?`が付いていない限り`null`を受け付けない)なので、Javaのような`null`チェックは不要です。空配列というケースだけを防御的に扱えば十分です。Kotlinには`require`という標準の事前条件チェック関数があり、条件を満たさない場合に`IllegalArgumentException`を投げてくれます。 + +### Kotlin特有の考慮点 + +- **静的型付けとスマートキャストの活用**:この関数は`IntArray`という明示的な型を扱うため、コンパイラが型の誤りを事前に検出してくれます。今回の実装では`null`を扱う場面がそもそもないため、スマートキャスト(=`if (x != null)`のような条件分岐の内側で、コンパイラが自動的に`x`が`null`でないと判断してくれる仕組み)は直接は登場しませんが、Kotlinのnull安全性(=`?`の付かない型が絶対に`null`にならないことを型システムが保証する仕組み)そのものは、Javaと比較した際の大きな違いとして意識しておく価値があります。Javaでは`int[]`型の変数でも`null`が代入され得ますが、Kotlinの`IntArray`(`?`なし)は決して`null`になりません。 +- **JVM上でのジェネリクスの型消去**:今回は`IntArray`というプリミティブ配列を直接扱うため、ジェネリクスの型消去(=JVM上で動作するKotlinのジェネリクスが、コンパイル後のバイトコードレベルでは型情報を保持しない仕組み)自体が関与しません。もし`List`のような汎用的な設計にした場合、`>`のような境界付き型パラメータが必要になり、さらに`T`が`Int`であっても`List`は内部的に`Integer`(ボクシング済み)として扱われます。 +- **`val`/`var`の使い分け**:この実装では、値が更新され続ける`slow`(=一度貼ったら剥がせないラベルではなく、ホワイトボードのように書き換えが必要な変数)は`var`で宣言し、ループの中で再代入しない値があれば`val`を使い分けます。Kotlinでは可能な限り`val`を優先することが推奨されており、「この変数は途中で変わらない」という意図をコンパイラと読み手の両方に伝えられます。 + +> 📖 **このセクションで登場した用語** +> +> - **in-place(インプレース)**:新しい配列を作らず、与えられた配列そのものを直接書き換える操作方式。 +> - **ボクシング**:`Int`のようなプリミティブ型を`Integer`のようなラッパーオブジェクトに変換すること。`IntArray`はボクシングされず、内部的にJVMのプリミティブ配列`int[]`として扱われる。 +> - **型消去(Type Erasure)**:JVM上で動作するKotlinのジェネリクスが、コンパイル後のバイトコードレベルでは型情報を保持しない仕組み。 +> - **`require`**:Kotlin標準の事前条件チェック関数。条件を満たさない場合に`IllegalArgumentException`を投げる。 + +--- + +## 2. アルゴリズムアプローチ比較 + +> 💡 **初学者向け補足**:同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」を比べて、この問題の制約(in-place・O(1)空間)に最も適したものを選びます。 + +| アプローチ | 時間計算量 | 空間計算量 | Kotlin実装コスト | 型安全性 | 可読性 | 備考 | +|---|---|---|---|---|---|---| +| **A. Two-Pointer(低速・高速ポインタ)** | O(n) | O(1) | 低 | 高 | 高 | ソート済みという前提を最大限活用。ボクシング不要でin-place要件を満たす最適解 | +| B. `LinkedHashSet`を使った重複除去 | O(n) | O(n) | 中 | 高 | 中 | 挿入順を保持できるが、`Int`→`Integer`のボクシングが要素数分発生する | +| C. `nums.toList().distinct().toIntArray()` | O(n) | O(n) | 最低 | 高 | 高 | 記述は最も簡潔だが、`toList()`でボクシング、`distinct()`で新リスト、`toIntArray()`で新配列と3段階の余計なアロケーションが発生しin-place要件に反する | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n)`:入力が2倍になると、処理も約2倍になる(線形) +> - `O(n log n)`:入力が2倍になると、処理は約2倍強になる(ソートアルゴリズムに多い、今回は該当なし) + +**Kotlin/JVM上でのボクシングコストについて**:方法Bでは`Int`型の値を`LinkedHashSet`に格納するたびに、JVMは`Int`を`Integer`オブジェクトへボクシングします(`-128`〜`127`の範囲は`Integer`のキャッシュ機構により再利用されますが、それ以外の値は毎回新しい`Integer`オブジェクトがヒープに生成されます)。方法Cはさらに深刻で、`IntArray.toList()`によって`List`(内部的には`Integer`のボクシング済みリスト)が生成され、`distinct()`でさらに新しい`List`が、`toIntArray()`で最終的に新しい`IntArray`が生成されるという3段階の余計なオブジェクト生成が発生します。方法Aは`IntArray`をそのまま直接操作するため、ボクシングも追加のヒープアロケーションも一切発生しません。 + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して、処理にかかる手間がどう増えるかの目安。 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安。 +> - **`Integer`キャッシュ機構**:JVMが`-128`〜`127`の範囲の`Integer`オブジェクトをあらかじめ生成しキャッシュしておく仕組み。この範囲の値をボクシングする際は新規オブジェクト生成を回避できる。 + +--- + +## 3. 選択したアルゴリズムと理由 + +> 💡 **初学者向け補足**:他の方法と比較しながら、なぜ二重ポインタ法を選んだのかを説明します。 + +- **選択したアプローチ**: A. Two-Pointer(`slow` / `fast` の2つのインデックスを使う方法) +- **理由**: + - **計算量的な優位性**:`LinkedHashSet`(方法B)や`distinct()`(方法C)はどちらもO(n)の追加メモリが必要ですが、Two-Pointer法はO(1)の追加メモリ(`slow`という`Int`変数ひとつ)だけで済みます。**この問題は「in-placeで行うこと」自体が明示的な要件**なので、O(n)空間を使う方法B・Cはそもそも要件を満たしません。 + - **Kotlin環境での型安全性**:`Int`同士の比較演算子`!=`はコンパイル時に型チェックされるため、誤った型同士を比較してしまうミスがそもそも起こり得ません。 + - **保守性・可読性の観点**:ループはひとつだけで、条件分岐も「値が変わったかどうか」のシンプルな1行のみです。Kotlinのコレクション操作関数(`distinct`等)のような宣言的な書き方と比べるとやや行数は増えますが、意図(何がなぜ起きているか)が明確に読み取れます。 +- **Kotlin特有の最適化ポイント**: + - `IntArray`をそのまま扱うことで、ボクシング/アンボクシングのコストを完全に排除しています。 + - `IntArray`が参照型オブジェクトであることを活かし、コピーを一切発生させずに呼び出し元の配列を直接書き換えています。 + - `nums.isEmpty()`を最初にガードすることで、範囲外アクセス例外を未然に防いでいます。 + +> 📖 **このセクションで登場した用語** +> +> - **Two-Pointer(二重ポインタ)法**:配列上で2つの添字(インデックス)を異なる速度・役割で動かしながら処理を進めるアルゴリズムパターン。 +> - **`IntArray`**:Kotlinのプリミティブ`Int`配列専用の型。JVM上では`int[]`にコンパイルされ、`Array`(ボクシングされた`Integer[]`)とは異なる。 + +--- + +## 4. 実装コード + +> 💡 **初学者向け補足**:コード全体を示す前に、大まかな構造(骨格)を示します。 +> +> 1. まず`nums`が空でないかを確認する +> 2. 「まだユニークな要素を書き込んでよい位置」を指す`slow`変数を用意する +> 3. `fast`で配列を先頭から末尾まで1つずつなめていき、`slow`が指す値と違う値が見つかるたびに、その値を`slow`の次の位置に書き込む +> 4. 最後に`slow + 1`(=ユニークな要素の個数)を返す + +```kotlin +class Solution { + /** + * ソート済み配列から重複要素を取り除き、前方にユニークな値だけを詰め直す。 + * nums が参照する配列本体を直接書き換える(in-place)。 + * + * @param nums 非減少順(同じ値の連続を含む昇順)にソートされた整数配列。 + * IntArray は null 非許容型のため、null チェックは不要。 + * @return 重複を除いたあとのユニークな要素数 k。 + * 呼び出し側は nums[0] から nums[k - 1] までを有効な結果として扱う。 + */ + fun removeDuplicates(nums: IntArray): Int { + // 配列が空の場合、ユニークな要素は0個なのでそのまま0を返す。 + // (この問題の制約では nums.size >= 1 が保証されているが、 + // 防御的プログラミングとして安全側に倒しておく。 + // nums 自体は null 非許容型なので null チェックは不要) + if (nums.isEmpty()) { + return 0 + } + + // slow は「最後に確定したユニークな値」が置かれているインデックス。 + // 最初はインデックス0の要素(nums[0])が唯一の確定済みユニーク値なので0から始める。 + // ループの中で再代入され続けるため var で宣言する(val だと再代入できない)。 + var slow = 0 + + // fast はこれから調べていく値を指すインデックス。 + // インデックス1から末尾まで、配列を1回だけなめる(1パス)。 + // for (fast in 1 until nums.size) は Kotlin の慣用的な範囲イテレーション。 + // until は「終端を含まない範囲」を作る演算子(Swiftの ..< に相当)。 + for (fast in 1 until nums.size) { + // 配列はソート済みなので、同じ値は必ず隣接している。 + // fast が指す値が、直近の確定済みユニーク値(nums[slow])と異なるなら、 + // それは「新しく見つかったユニークな値」ということになる。 + if (nums[fast] != nums[slow]) { + // slow をひとつ進めて「次にユニーク値を書き込む位置」を確保する + slow++ + // 新しく見つかったユニーク値を、確定済み領域の直後(nums[slow])に書き込む + nums[slow] = nums[fast] + } + // nums[fast] == nums[slow] の場合は「すでに確認済みの値の重複」なので、 + // 何もせずに for ループが次の要素へ進む + // (何もしないことがそのまま「重複を無視する」処理になる) + } + + // slow は0始まりのインデックスなので、要素の個数に直すには+1する + return slow + 1 + } +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> 具体例:`nums = intArrayOf(0,0,1,1,1,2,2,3,3,4)` で実際にトレースしてみます。 +> +> ``` +> 初期状態: nums = [0,0,1,1,1,2,2,3,3,4]、slow = 0 +> (nums.isEmpty() チェック → 通過) +> +> fast=1: nums[1]=0, nums[slow]=nums[0]=0 → 同じ値なので何もしない +> fast=2: nums[2]=1, nums[slow]=nums[0]=0 → 値が違う! +> slow を 1 に進め、nums[1] = nums[2] = 1 を書き込む +> → nums = [0,1,1,1,1,2,2,3,3,4] +> fast=3: nums[3]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=4: nums[4]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=5: nums[5]=2, nums[slow]=nums[1]=1 → 値が違う! +> slow を 2 に進め、nums[2] = nums[5] = 2 を書き込む +> → nums = [0,1,2,1,1,2,2,3,3,4] +> fast=6: nums[6]=2, nums[slow]=nums[2]=2 → 同じ値なので何もしない +> fast=7: nums[7]=3, nums[slow]=nums[2]=2 → 値が違う! +> slow を 3 に進め、nums[3] = nums[7] = 3 を書き込む +> → nums = [0,1,2,3,1,2,2,3,3,4] +> fast=8: nums[8]=3, nums[slow]=nums[3]=3 → 同じ値なので何もしない +> fast=9: nums[9]=4, nums[slow]=nums[3]=3 → 値が違う! +> slow を 4 に進め、nums[4] = nums[9] = 4 を書き込む +> → nums = [0,1,2,3,4,2,2,3,3,4] +> +> ループ終了(fast=10 は nums.size=10 と等しくなり、範囲 1 until 10 の外になる) +> +> return slow + 1 = 4 + 1 = 5 +> +> → k = 5、先頭5要素は [0,1,2,3,4] となり、期待される出力と一致する +> ``` +> +> インデックス5以降の値(`[2,2,3,3,4]`の名残)は、問題文の指示どおり「気にしなくてよい部分」としてそのまま残っていますが、これは正解として扱われます(Custom Judgeは先頭k個しか見ないため)。 + +> 📖 **このセクションで登場した用語** +> +> - **`nums.isEmpty()`**:`IntArray`が要素を1つも持たないかを判定する拡張関数(=既存のクラスを継承せずに、新しいメソッドを追加できる仕組み)。`nums.size == 0`と等価だが、意図が明確で読みやすいため`isEmpty()`を使うのがKotlinの慣習。 +> - **`for (fast in 1 until nums.size)`**:Kotlinの範囲(`IntRange`)を使ったforループ構文。`until`は「終端を含まない範囲」を作る中置関数で、`1..nums.size - 1`と書くよりも意図が明確になる。 +> - **`var slow = 0`**:再代入が必要な変数の宣言。Kotlinでは可能な限り`val`(再代入不可)を優先することが推奨されるが、ループカウンタのように値が更新され続ける変数には`var`が必要。 +> - **添字(インデックス)アクセス `nums[i]`**:配列の`i`番目(0始まり)の要素にアクセスする構文。Kotlinでは範囲外の`i`を指定すると`ArrayIndexOutOfBoundsException`(JVMの例外がそのまま伝播する)が投げられ、未定義動作にはならない。 + +--- + +## 補足:計算量まとめ + +- **時間計算量**: O(n) — 配列を1回だけ走査するため(n = `nums.size`) +- **空間計算量**: O(1) — 追加で使うメモリは`slow`という`Int`変数ひとつのみ(入力配列自体は書き換えているだけで、新規のコレクション生成もボクシングも行っていない) +- **null安全性**: `nums: IntArray`はnull非許容型のため、健全なnull安全性により`null`に関する実行時エラーがそもそも発生し得ない設計になっている diff --git a/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Python.md b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Python.md new file mode 100644 index 00000000..4d9cc1e4 --- /dev/null +++ b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Python.md @@ -0,0 +1,338 @@ +# leetcodeでの回答フォーマット + +--- + +## 1. 問題分析結果 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「すでに並び替え済みのリストから、同じ値が連続している部分をひとつにまとめて、前方に詰め直す問題」です。 +> +> **Pythonで解く際に特に気をつけるべきCPython特有の注意点**:この問題は`nums`というリストそのものを直接書き換える(in-place=新しいオブジェクトを作らず、既存のオブジェクトを直接書き換える操作)ことが要件です。Pythonのリストはミュータブル(=生成後に中身を変更できるオブジェクト)なオブジェクトであり、関数に渡されるとリストへの「参照」が渡されます(C言語のポインタ渡しに近い挙動)。そのため、関数内で`nums[i] = x`のように要素を書き換えると、呼び出し元のリストも同時に書き換わります。もう1つの注意点は、`nums.count(value)`や`nums.index(value)`のようなリストメソッドを安易にループ内で使うと、それぞれがO(n)の走査を行うため、全体の計算量がO(n²)に悪化してしまう点です。今回は単純な添字アクセス(`nums[i]`)のみで完結させ、この落とし穴を避けます。 + +### 競技プログラミング視点 + +- **制約分析**: LeetCodeの制約は`1 <= nums.length <= 3 * 10^4`程度であり、O(n)アルゴリズムであれば余裕を持って制限時間内に収まります。O(n log n)以上の計算量は不要です。 +- **最速手法**: ソート済みという性質を使ったTwo-Pointer(二重ポインタ)法がBig-O最適(O(n))です。CPythonではPure Pythonのforループはインタープリタのオーバーヘッドが大きいため、組み込み関数で置き換えられる部分がないか検討しますが、この問題は「前の要素の処理結果に依存する逐次処理」であり、`sum()`や`map()`のような組み込み関数に単純に置き換えることはできません。 +- **メモリ最小化**: 新しいリストや`set`を一切生成せず、`int`型の変数2個(`slow` と `fast`)だけで完結させるインプレース操作(=新しいオブジェクトを作らず、既存のオブジェクトを直接書き換える操作。メモリ効率が良い)を採用します。 +- **CPython最適化**: 組み込みの`len()`のみを使用します。`enumerate()`はインデックスと値の両方を提供しますが、このアルゴリズムでは`nums[fast]`と`nums[slow]`のようにインデックスを用いた直接アクセスを行うため、素直な`range()`ベースのループのほうが適しています。 + +### 業務開発視点 + +- **型安全設計**: 引数`nums: List[int]`、戻り値`-> int`という型ヒント(=関数の引数や戻り値に型を注釈として書く仕組み)をそのまま活かし、pylance(=VSCodeで使えるPythonの静的型チェックツール。実行前にバグを検出できる)が型の不整合を実行前に検出できるようにします。 +- **エラーハンドリング**: 想定される例外として、①`nums`が`None`の場合に`ValueError`、`list`以外の型で渡される場合に`TypeError`を投げるケース、②`nums`が空リストのケース(この問題の文脈では正当な入力として`0`を返す)を考慮します。Pythonは動的型付け言語(=変数の型が実行時まで確定しない言語)のため、呼び出し元が誤った型を渡しても実行時までエラーに気づけません。型ヒント + pylanceによる静的チェックと、実行時の`isinstance`チェックを組み合わせることで、コンパイル言語(Java/C++など)に近い安全性を実現します。 +- **可読性**: docstring(=関数やクラスの先頭に書く説明文。`"""三重クォート"""`で囲む)・行コメント・意味の明確な変数名(`slowPointer`ではなく`slow`のような簡潔だが文脈で意味が通る名前)を徹底します。 + +### Python特有分析 + +- **データ構造選択**: この問題は「順序ありでインデックスアクセスが必要」という性質を持つため、`list`(配列)がそのまま最適です。`set`(メンバーシップテスト用)や`dict`(キー値ペア用)、`collections.deque`(両端操作用)は今回の処理には不要です。 +- **標準ライブラリ活用度**: `itertools.groupby`を使うと「連続する同じ値をグループ化する」処理を宣言的に書けますが、内部でイテレータオブジェクトを生成するオーバーヘッドがあり、単純なTwo-Pointerより低速になりがちです。今回は標準ライブラリに頼らず手動実装します。 +- **CPython最適化度**: この実装はPure Python(=Pythonコードそのもので書かれた実装。C実装より低速だが柔軟)のforループですが、O(n)の1パス処理であり、ループ内の処理も比較・代入のみとシンプルなため、CPythonのバイトコード実行のオーバーヘッドを最小限に抑えられています。 + +> 📖 **このセクションで登場した用語** +> +> - **in-place(インプレース操作)**:新しいオブジェクトを作らず、既存のオブジェクトを直接書き換える操作。メモリ効率が良い。 +> - **ミュータブル(mutable)**:生成後に中身を変更できるオブジェクトの性質。Pythonの`list`はミュータブル、`tuple`はイミュータブル(変更不可)。 +> - **動的型付け**:変数の型が実行時まで確定しない言語の性質。Pythonはこれに該当し、コンパイル時の型チェックがない。 +> - **CPython**:最も広く使われるPythonの実装。C言語で書かれており、組み込み関数の多くがC実装のため高速。 + +--- + +## 2. 採用アルゴリズムと根拠 + +> 💡 **初学者向け補足**:同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」を比べて最適なものを選びます。Python固有の観点としては、「組み込み関数・C実装で代替できるか」「Pure Pythonのループがどれだけ発生するか」「メモリ使用量に差が出るか」を重視します。 + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n)`:入力が2倍になると、処理も約2倍になる(線形) +> - `O(n log n)`:入力が2倍になると、処理は約2倍強になる(ソートアルゴリズムに多い) +> - `O(n²)`:入力が2倍になると、処理は約4倍になる(二重ループに多い) + +| アプローチ | 時間計算量 | 空間計算量 | Python実装コスト | 可読性 | 標準ライブラリ活用 | CPython最適化 | 備考 | +| ---------- | ---------- | ---------- | ----------------- | ------ | ------------------ | -------------- | ---- | +| **A. Two-Pointer(手動実装)** | O(n) | O(1) | 低 | ★★★ | なし | 適 | in-place要件を満たす唯一の最適解 | +| B. `dict.fromkeys(nums)`で重複除去 | O(n) | O(n) | 低 | ★★☆ | なし(組み込み型) | 不適 | 挿入順を保持するC実装だが、新しい辞書オブジェクトを生成しin-place要件に反する | +| C. `itertools.groupby` | O(n) | O(n) | 中 | ★★☆ | itertools | 不適 | 宣言的で読みやすいが、イテレータ生成とグループごとのリスト化でオーバーヘッドが大きい | + +- **選択理由**: 方法B(`dict.fromkeys`はCPythonのC実装であるため単体では高速ですが、内部で新しい辞書オブジェクトを生成するためO(n)の追加メモリが必要)と方法C(`itertools.groupby`はイテレータベースで遅延評価されますが、各グループを`list()`化する際にさらにオブジェクトが生成されます)はどちらも「in-placeでO(1)空間」というこの問題の明示的な要件を満たしません。方法Aはソート済みという前提を活かし、追加のオブジェクト生成を一切行わずにO(n)時間・O(1)空間を達成します。 +- **Python最適化戦略**: ループ内の処理を「比較1回・条件が真なら代入1回」というシンプルな形に抑え、CPythonインタープリタのバイトコード実行回数を最小限にします。`enumerate()`ではなく明示的な`range()`ベースのループを使うのは、`nums[slow]`という「別のインデックス」への同時アクセスが必要なためです。 +- **トレードオフ**: `dict.fromkeys(nums)`のようなC実装の組み込み型操作を使えば1行で書けて可読性は上がりますが、O(n)の追加メモリと新規オブジェクト生成のコストを払うことになります。今回はメモリ効率(O(1)空間)を優先し、手動のTwo-Pointerを選択します。 + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して処理にかかる手間がどう増えるかの目安 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安 +> - **トレードオフ**:何かを得ると何かを失う関係。速くするとメモリが増える、など +> - **C実装**:Pythonコードではなく、内部でC言語で実装された関数。Pure Pythonより大幅に高速だが、今回のように新規オブジェクト生成を伴う場合はメモリコストが発生する + +--- + +## 3. 実装パターン + +> 💡 **初学者向け補足**:コード全体を示す前に、大まかな構造(骨格)を示します。 +> +> 1. まず`nums`の型・空リストかどうかを検証する(業務開発版のみ) +> 2. 「まだユニークな要素を書き込んでよい位置」を指す`slow`変数を用意する +> 3. `fast`でリストを先頭から末尾まで1つずつなめていき、`slow`が指す値と違う値が見つかるたびに、その値を`slow`の次の位置に書き込む +> 4. 最後に`slow + 1`(=ユニークな要素の個数)を返す + +【業務開発版を使う場面】 +チームで長期間メンテナンスするプロダクションコードに向きます。型ヒントによる静的チェックに加え、実行時の入力検証(`None`・空リスト・型不一致)を明示的に行い、エラーの原因が分かりやすい構造になっています。 + +【競技プログラミング版を使う場面】 +LeetCodeの制限時間内に正解を出すことが目的のコードに向きます。この問題の制約では入力検証が不要と保証されているため、検証コードを省略し、アルゴリズム本体のみに絞って実装速度を優先します。 + +```python +from typing import List + + +class Solution: + """ + 26. Remove Duplicates from Sorted Array 解決クラス + + 競技プログラミング向けと業務開発向けの2パターンを提供する。 + """ + + def solve_production(self, nums: List[int]) -> int: + """ + 業務開発向け実装(型安全・エラーハンドリング重視) + + ソート済みリストから重複要素を取り除き、前方にユニークな値だけを詰め直す。 + nums が参照するリストそのものを直接書き換える(in-place)。 + + Args: + nums: 非減少順(同じ値の連続を含む昇順)にソートされた整数のリスト。 + + Returns: + 重複を除いたあとのユニークな要素数 k。 + 呼び出し側は nums[0] から nums[k - 1] までを有効な結果として扱う。 + + Raises: + TypeError: nums がリスト型でない場合。 + ValueError: nums が None の場合。 + + Time Complexity: O(n) + Space Complexity: O(1) + """ + # ① 入力検証:型が不正な場合、後続の処理でどのようなエラーが起きるか + # 予測しづらくなるため、早期に分かりやすいエラーを投げる。 + # Python は動的型付けのため、pylance の型ヒントだけでは実行時の + # 誤った呼び出し(例:文字列を渡すなど)を防げない。ここで防御する。 + self._validate_input(nums) + + # ② エッジケース処理:空リストの場合、ユニークな要素は0個。 + # メインロジックの前に処理することで、後続のループを単純に保てる。 + if self._is_edge_case(nums): + return self._handle_edge_case(nums) + + # ③ メインアルゴリズムを呼び出す + return self._main_algorithm(nums) + + def solve_competitive(self, nums: List[int]) -> int: + """ + 競技プログラミング向け最適化実装。 + エラーハンドリング省略、実装速度・実行速度を優先する。 + + Time Complexity: O(n) + Space Complexity: O(1) + """ + # 直接的な実装(型チェック・エラーハンドリングを省略し、 + # LeetCode の制約(nums.length >= 1 が保証済み)に乗る) + # slow は「最後に確定したユニークな値」が置かれているインデックス。 + slow = 0 + + # fast はこれから調べていく値を指すインデックス。 + # インデックス1から末尾まで、リストを1回だけなめる(1パス)。 + for fast in range(1, len(nums)): + # nums はソート済みなので、同じ値は必ず隣接している。 + if nums[fast] != nums[slow]: + slow += 1 + nums[slow] = nums[fast] + + return slow + 1 + + def _validate_input(self, nums: List[int]) -> None: + """ + 型安全な入力検証。 + + Args: + nums: 検証対象のリスト。 + + Raises: + ValueError: nums が None の場合。 + TypeError: nums がリスト型でない場合。 + """ + # None チェック:nums が None の場合、この後の len(nums) 呼び出しで + # TypeError(None には len() が使えない)が発生してしまう。 + # 意図を明確にするため、より具体的なメッセージで先に弾いておく。 + if nums is None: + raise ValueError("Input must not be None") + + # 型チェック:isinstance() を使うことで、Pythonが動的型付け言語であるがゆえに + # 実行時まで気づけない「誤った型の引数」を早期に検出する。 + # pylance は静的解析の段階でこの種のミスをある程度検出してくれるが、 + # 実行時にも防御しておくことで、型ヒントを無視した呼び出しにも耐えられる。 + if not isinstance(nums, list): + raise TypeError("Input must be a list") + + def _is_edge_case(self, nums: List[int]) -> bool: + """ + エッジケース判定。 + + Args: + nums: 判定対象のリスト。 + + Returns: + 要素数が1個以下であれば True。 + """ + return len(nums) <= 1 + + def _handle_edge_case(self, nums: List[int]) -> int: + """ + エッジケース処理。 + + Args: + nums: 処理対象のリスト(要素数0または1)。 + + Returns: + 空リストなら0、要素が1個ならその要素数(1)。 + """ + # 空リストならユニーク要素は0個、要素が1個ならその1個がそのままユニーク。 + return len(nums) + + def _main_algorithm(self, nums: List[int]) -> int: + """ + Two-Pointer 法によるメインアルゴリズム。 + + Args: + nums: 非減少順にソートされた、要素数2以上のリスト。 + + Returns: + 重複を除いたあとのユニークな要素数 k。 + """ + # slow は「最後に確定したユニークな値」が置かれているインデックス。 + # 最初はインデックス0の要素(nums[0])が唯一の確定済みユニーク値なので0から始める。 + slow: int = 0 + + # fast はこれから調べていく値を指すインデックス。 + # range(1, len(nums)) は「1以上 len(nums) 未満」の範囲を生成する + # (終端 len(nums) を含まない)。enumerate() ではなく range() を使うのは、 + # nums[slow] という「別のインデックス」も同時に参照する必要があるため。 + for fast in range(1, len(nums)): + # nums はソート済みなので、同じ値は必ず隣接している。 + # fast が指す値が、直近の確定済みユニーク値(nums[slow])と異なるなら、 + # それは「新しく見つかったユニークな値」ということになる。 + if nums[fast] != nums[slow]: + # slow をひとつ進めて「次にユニーク値を書き込む位置」を確保する + slow += 1 + # 新しく見つかったユニーク値を、確定済み領域の直後(nums[slow])に書き込む + nums[slow] = nums[fast] + # nums[fast] == nums[slow] の場合は「すでに確認済みの値の重複」なので、 + # 何もせずに for ループが次の要素へ進む + # (何もしないことがそのまま「重複を無視する」処理になる) + + # slow は0始まりのインデックスなので、要素の個数に直すには+1する + return slow + 1 + + +# ─── LeetCode 提出用(テンプレートに直接対応する形) ───────────────────────── +# LeetCode の関数シグネチャは removeDuplicates(キャメルケース)が指定されているため、 +# 提出時はこのメソッド名をそのまま使う。ロジックは solve_production 相当。 +class SolutionSubmission: + def removeDuplicates(self, nums: List[int]) -> int: + # LeetCode の制約では len(nums) >= 1 が保証されているが、 + # 防御的プログラミングとして安全側に倒しておく。 + if not nums: + return 0 + + slow: int = 0 + for fast in range(1, len(nums)): + if nums[fast] != nums[slow]: + slow += 1 + nums[slow] = nums[fast] + + return slow + 1 +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> `solve_competitive([0, 0, 1, 1, 1, 2, 2, 3, 3, 4])` の呼び出しをトレースします。 +> +> ``` +> 初期状態: nums = [0,0,1,1,1,2,2,3,3,4]、len(nums) = 10、slow = 0 +> +> fast=1: nums[1]=0, nums[slow]=nums[0]=0 → 同じ値なので何もしない +> fast=2: nums[2]=1, nums[slow]=nums[0]=0 → 値が違う! +> slow を 1 に進め、nums[1] = nums[2] = 1 を書き込む +> → nums = [0,1,1,1,1,2,2,3,3,4] +> fast=3: nums[3]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=4: nums[4]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=5: nums[5]=2, nums[slow]=nums[1]=1 → 値が違う! +> slow を 2 に進め、nums[2] = nums[5] = 2 を書き込む +> → nums = [0,1,2,1,1,2,2,3,3,4] +> fast=6: nums[6]=2, nums[slow]=nums[2]=2 → 同じ値なので何もしない +> fast=7: nums[7]=3, nums[slow]=nums[2]=2 → 値が違う! +> slow を 3 に進め、nums[3] = nums[7] = 3 を書き込む +> → nums = [0,1,2,3,1,2,2,3,3,4] +> fast=8: nums[8]=3, nums[slow]=nums[3]=3 → 同じ値なので何もしない +> fast=9: nums[9]=4, nums[slow]=nums[3]=3 → 値が違う! +> slow を 4 に進め、nums[4] = nums[9] = 4 を書き込む +> → nums = [0,1,2,3,4,2,2,3,3,4] +> +> ループ終了(fast=10 は range(1, 10) の範囲外になる) +> +> 戻り値: slow + 1 = 4 + 1 = 5 +> +> → k = 5、先頭5要素は [0,1,2,3,4] となり、期待される出力と一致する +> ``` +> +> インデックス5以降の値(`[2,2,3,3,4]`の名残)は、問題文の指示どおり「気にしなくてよい部分」としてそのまま残っていますが、Custom Judgeは先頭k個しか見ないため正解として扱われます。 + +> 💡 **型ヒントとpylanceの関係(初学者向け)** +> +> このコードでは `nums: List[int]` という型ヒントを使っています。Pythonは型を書かなくても動く動的型付け言語ですが、型ヒントを書いておくとpylanceが「本当に`int`のリストが渡されているか」「戻り値が本当に`int`か」を実行前にチェックしてくれます。 +> +> ```python +> # 型ヒントなし → pylanceは nums に文字列リストを渡すミスを検出できない +> def removeDuplicates(self, nums): +> ... +> +> # 型ヒントあり → pylanceが「List[str] は List[int] と互換性がない」と実行前に警告してくれる +> def removeDuplicates(self, nums: List[int]) -> int: +> ... +> ``` + +> 📖 **このセクションで登場した用語** +> +> - **`range(1, len(nums))`**:「1以上、`len(nums)`未満」の整数を順に生成するイテラブル(=`for`文で1つずつ取り出せるオブジェクト)を作る組み込み関数。CPythonでは実際に全要素を持つリストを作らず、必要な数値をその都度計算する(メモリ効率が良い)。 +> - **`isinstance(nums, list)`**:オブジェクトが指定した型(またはそのサブクラス)のインスタンスかどうかを判定する組み込み関数。`type(nums) == list`より、継承関係を考慮できる分堅牢。 +> - **docstring**:関数やクラスの先頭に書く説明文。`"""三重クォート"""`で囲む。`Args`・`Returns`・`Raises`といったセクションを書くのがGoogleスタイルの慣習。 +> - **`List[int]`**:「`int`のリスト」を表す型ヒント。`typing`モジュールからインポートする(Python 3.9以降は組み込みの`list[int]`と書くことも可能)。 + +--- + +## 4. 検証 + +_テストコードは不要です!具体的な計測値、メモリの出力も不要です!_ + +> 💡 **初学者向け補足**:エッジケースのテストは、アルゴリズムが「ふつうの入力」だけでなく「極端な入力」でも正しく動くかを確かめるためのものです。 + +- **境界値テスト**: + - `nums = []`(空リスト)の場合:この問題の制約では発生しないが、もし発生した場合、業務開発版では`_is_edge_case`が`True`を返し`_handle_edge_case`が安全に`0`を返す。競技版・提出版では`if not nums: return 0`のガードが同様に機能する。ガードがなければ`nums[0]`アクセスで`IndexError`が発生する。 + - `nums = [1]`(要素1個)の場合:`range(1, 1)`は空のイテラブルになるため`fast`のループが1度も実行されず、`slow`は初期値の`0`のまま`slow + 1 = 1`が返る。これは正しい(ユニークな要素は1個)。 + - `nums`の全要素が同じ値(例:`[2, 2, 2, 2]`)の場合:`nums[fast] != nums[slow]`が一度も`True`にならず、`slow`は`0`のまま`1`が返る。これも正しい。 + - `nums`の全要素が異なる値(例:`[1, 2, 3, 4]`)の場合:毎回`if`が真になり、`slow`は`fast`と同じ速度で進み、最終的に`len(nums)`がそのまま返る。 +- **型チェック**: pylance対応の観点では、`nums: List[int]`という型ヒントにより、呼び出し側が`List[str]`や`Optional[List[int]]`のような不整合な型を渡した場合、実行前に警告が表示されます。`_validate_input`メソッドの実行時チェック(`isinstance`)は、型ヒントを無視した動的な呼び出し(例:JSON経由で受け取ったデータをそのまま渡すケースなど)に対する最後の防波堤として機能します。 + +> 📖 **このセクションで登場した用語** +> +> - **エッジケース**:空のリスト・要素1つ・全要素同一・全要素異なるなど、境界的な条件のこと +> - **境界値テスト**:エッジケースに対してもアルゴリズムが正しく動くかを確かめること +> - **静的解析**:プログラムを実行せずに、コードを読むだけでバグや型エラーを検出する手法(pylanceがこれを担う) + +--- + +## 補足:計算量まとめ + +- **時間計算量**: O(n) — リストを1回だけ走査するため(n = `len(nums)`) +- **空間計算量**: O(1) — 追加で使うメモリは`slow`という`int`変数ひとつのみ(入力リスト自体は書き換えているだけで、新規のリスト・辞書・セットの生成は行っていない) +- **CPython特性**: この実装はPure Pythonのforループですが、ループ内の処理が比較1回・条件付き代入1回というシンプルな形に抑えられているため、バイトコード実行のオーバーヘッドが最小限になっています。`itertools.groupby`や`dict.fromkeys`のようなC実装の関数を使えばコードは短くなりますが、いずれも新規オブジェクト生成を伴うため、この問題の「in-placeでO(1)空間」という要件には合致しません。 diff --git a/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Rust.md b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Rust.md new file mode 100644 index 00000000..74946b96 --- /dev/null +++ b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Rust.md @@ -0,0 +1,310 @@ +# leetcodeでの回答フォーマット + +--- + +## 1. 問題の分析 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「すでに並び替え済みのベクタから、同じ値が連続している部分をひとつにまとめて、前方に詰め直す問題」です。 +> +> **Rustで解く際に特に気をつけるべき点**:この問題のシグネチャは`&mut Vec`(=`Vec`への排他参照。書き込みを許す借用)を受け取ります。所有権(=値を"誰が管理するか"をコンパイル時に決めるRust独自の仕組み)そのものは呼び出し元に残ったまま、この関数は一時的に「書き換える権利」だけを借りている状態です。ここで気をつけるべきは、①`&mut`という排他参照(=同時に他の参照が存在しないことをコンパイラが保証する借用)を通じて安全に要素を書き換えること、②Rustの`Vec`は範囲外アクセスをすると(Cのような未定義動作ではなく)確実にパニック(=回復不能なエラーが発生した際の強制終了)を起こすため、インデックスの範囲を正確に管理する必要があること、③この問題のシグネチャは戻り値が`i32`固定のため、`Result`のような型レベルのエラー表現を直接使えない制約があることです。この最後の点については、LeetCodeの提出用シグネチャをそのまま守りつつ、業務開発版として`Result`を返す別バージョンも用意することで対応します。 + +### 競技プログラミング視点での分析 + +- ベクタはすでにソート済みなので、**同じ値は必ず隣り合っている**という性質を利用できます。これにより、余計な探索(例えば全要素同士を比較する)をせず、**ベクタを1回だけ左から右へなめる(1パス)だけ**で処理が完結します。 +- 実行速度を最優先するなら、時間計算量 O(n)(nは`nums.len()`)が理論上の最速ラインです。全要素を最低1回は確認する必要があるため、これより速くはなりません。 +- メモリ使用量の最小化方針としては、`HashSet`や新しい`Vec`を生成するようなヒープアロケーション(=`Vec`の内部拡張などで、ヒープ上にメモリを新たに確保する操作)を一切行わず、**与えられた`Vec`そのものを書き換える**ことで、追加メモリを`usize`型の変数1個(スタック上)だけに抑えます。 + +### 業務開発視点での分析 + +- この問題では境界条件(ベクタが空、要素が1個だけ、全要素が同じ値、全要素がバラバラ等)を正しく処理できるかが保守性・堅牢性の観点で重要です。 +- `&mut Vec`という引数は、Rustの借用チェッカー(=コンパイル時にデータ競合を検出する仕組み)によって「この関数がベクタを排他的に借りている間、他のどのコードもこのベクタにアクセスできない」ことが保証されます。これはC言語の生ポインタ渡しと似た挙動を実現しつつ、コンパイル時にダングリングポインタ(=解放済みのメモリを指したままになっているポインタ)やデータ競合が起きないことを保証している点が大きく異なります。 +- エラーハンドリングについては、この問題は制約上`nums.len() >= 1`が保証されており、かつLeetCodeのシグネチャが`i32`固定の戻り値であるため、`Result`(=エラーを型で表現する仕組み。`try-catch`のような例外機構とは異なり、呼び出し側が必ず結果を確認しなければならない)を直接使うことはできません。そこで今回は、LeetCode提出用の関数は空ベクタを「異常」ではなく「正当な入力の一種」として扱い`0`を返す設計にしつつ、業務開発版として`Result`を返す別実装も提示します。 + +### Rust特有の考慮点 + +- **所有権・借用の設計方針**:この関数は`Vec`の所有権を受け取らず、`&mut Vec`という可変借用(=書き込み可能な参照)だけを受け取ります。これにより、関数呼び出しのたびにベクタ全体をコピーするような無駄なコストが一切発生しません。関数の実行が終わると借用は自動的に終了し、呼び出し元は再び自由にこのベクタを扱えるようになります。 +- **ライフタイムの設計方針**:今回は`&mut Vec`という単純な借用のみを扱い、複数の参照を戻り値として返すような複雑な構造がないため、明示的なライフタイムアノテーション`'a`(=参照の有効期間をコンパイラに伝えるアノテーション)を書く必要はありません。コンパイラが暗黙のうちに「この借用は関数呼び出しの間だけ有効」と推論してくれます(ライフタイム省略規則)。 +- **トレイト境界とモノモーフィゼーションによる最適化**:今回は`i32`固定のシグネチャなので、ジェネリクス(``のような型パラメータ)は使いません。もし汎用化する場合、モノモーフィゼーション(=ジェネリクスを使った関数が、型ごとに専用のコードへ自動展開される仕組み)により、実行時のオーバーヘッドなしに複数の型へ対応できます。 +- **イテレータアダプタ vs 命令型ループの選択基準**:今回のTwo-Pointerアルゴリズムは「2つの異なるインデックス(`slow`と`fast`)を同時に、かつ異なる速度で動かす」という性質を持ちます。`.iter().map()`のようなイテレータアダプタは「1つの要素を順に処理する」ことには向いていますが、2つのインデックスを別々の速度で進めながら`Vec`自体を書き換えるという操作は、素直な命令型ループ(`for`文とインデックス変数)のほうが意図が明確に表現できます。 + +> 📖 **このセクションで登場した用語** +> +> - **所有権**:値を"誰が管理するか"をコンパイル時に決めるRust独自の仕組み。メモリを自動で安全に管理できる。 +> - **借用**:所有権を渡さずに値を参照する仕組み。`&T`(読み取り専用)と`&mut T`(書き込み可能)がある。 +> - **借用チェッカー**:コンパイル時にデータ競合(複数の可変参照や、可変参照と不変参照の同時存在など)を検出する仕組み。 +> - **パニック**:Rustで回復不能なエラーが発生した際に起きる強制終了。インデックスの範囲外アクセスなどで発生する。 + +--- + +## 2. アルゴリズムアプローチ比較 + +> 💡 **初学者向け補足**:同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」を比べて、この問題の制約(in-place・O(1)空間)に最も適したものを選びます。Rust固有の観点としては「所有権の移動が発生するか」「ヒープアロケーションが必要か」を重視します。 + +| アプローチ | 時間計算量 | 空間計算量 | Rust実装コスト | 安全性 | 可読性 | 備考 | +| ----------------------------------- | ---------- | ---------- | -------------- | ------ | ------ | ------------------------------------------------------------------------- | +| **A. Two-Pointer(命令型ループ)** | O(n) | O(1) | 低 | 高 | 高 | 所有権の移動なし、ヒープアロケーションなし。in-place要件を満たす最適解 | +| B. `HashSet`で重複除去 | O(n) | O(n) | 中 | 高 | 中 | 要素の挿入ごとにハッシュ計算とヒープアロケーションが発生しうる | +| C. `Vec::dedup()`(標準ライブラリ) | O(n) | O(1) | 最低 | 高 | 高 | 隣接する重複要素を除去する標準メソッドで、内部実装はTwo-Pointerとほぼ同じ | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n)`:入力が2倍になると、処理も約2倍になる(線形) +> - `O(n log n)`:入力が2倍になると、処理は約2倍強になる(ソートアルゴリズムに多い、今回は該当なし) + +**Rust固有のアロケーション・所有権の観点について**:方法Bの`HashSet`は要素を1つ挿入するたびに内部のハッシュテーブルへの書き込みが発生し、テーブルの容量が不足すると再アロケーション(=より大きなヒープ領域へ全要素をコピーする操作)が起きます。また`HashSet`は順序を保証しないため、最終的な出力順序を保つには別途ソートが必要になり、この問題の要件と噛み合いません。方法Cの`Vec::dedup()`はまさにこの問題向けの標準ライブラリメソッドで、所有権の移動なし・追加ヒープアロケーションなしでTwo-Pointerと同等の処理を行いますが、今回はアルゴリズムの内部動作を理解する教育的価値を重視し、手動でTwo-Pointerを実装します(`dedup()`はブロック内で`&mut self`を借用するだけで、`i32`のような`Copy`トレイトを実装する型ではコピーのみで完結し所有権の移動は発生しません)。 + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して、処理にかかる手間がどう増えるかの目安 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安 +> - **アロケーション**:ヒープ上にメモリを確保する操作。頻繁に行うと速度が落ちる +> - **`Vec::dedup()`**:ソート済みスライスの隣接する重複要素を削除する標準ライブラリメソッド + +--- + +## 3. 選択したアルゴリズムと理由 + +> 💡 **初学者向け補足**:他の方法と比較しながら、なぜ二重ポインタ法を選んだのかを説明します。 + +- **選択したアプローチ**: A. Two-Pointer(`slow` / `fast` の2つのインデックスを使う方法) +- **理由**: + - **計算量的な優位性**:`HashSet`(方法B)はO(n)の追加メモリとハッシュ計算コストが必要ですが、Two-Pointer法はO(1)の追加メモリ(`slow`という`usize`変数ひとつ)だけで済みます。**この問題は「in-placeで行うこと」自体が明示的な要件**なので、O(n)空間を使う方法Bはそもそも要件を満たしません。 + - **Rustの所有権モデルとの親和性**:`&mut Vec`という可変借用を関数の最初から最後まで一貫して使い続けるだけで完結し、途中で所有権の移動や複数の借用が絡む複雑な設計を避けられます。借用チェッカーの検証もシンプルになります。 + - **保守性・可読性の観点**:ループはひとつだけで、条件分岐も「値が変わったかどうか」のシンプルな1行のみです。`Vec::dedup()`を1行で呼ぶより行数は増えますが、アルゴリズムの動きが明示的にコードへ表れており、後から読む人が処理内容を追いやすくなっています。 +- **Rust特有の最適化ポイント**: + - **ゼロコスト抽象化によるオーバーヘッドの排除**:今回は素直な命令型ループを使いますが、これは「イテレータより命令型ループのほうが速い」という意味ではなく、「2つの異なる速度で進むインデックスを同時に扱う」という処理の性質上、命令型ループのほうが自然に書けるためです。仮にイテレータで書いても、コンパイラの最適化により同等の機械語コードに帰着します(これがゼロコスト抽象化の性質です)。 + - **モノモーフィゼーション**:今回は`i32`固定のため直接は関与しませんが、汎用化する場合は``のようなトレイト境界を使うことで、動的ディスパッチ(実行時に処理内容を決定する仕組み)のオーバーヘッドなしに複数の型へ対応できます。 + - **スタックアロケーション優先によるキャッシュ効率**:`slow`と`fast`はどちらも`usize`型のローカル変数としてスタック上に配置され、ヒープアロケーションは一切発生しません。 + +> 📖 **このセクションで登場した用語** +> +> - **Two-Pointer(二重ポインタ)法**:ベクタ上で2つの添字(インデックス)を異なる速度・役割で動かしながら処理を進めるアルゴリズムパターン。 +> - **ゼロコスト抽象化**:便利な高レベルな書き方(イテレータなど)をしても、手書きの低レベルコードと同等の速さになるRustの特性。 + +--- + +## 4. 実装コード + +> 💡 **初学者向け補足**:コード全体を示す前に、大まかな構造(骨格)を示します。 +> +> 1. まず`nums`が空でないかを確認する(空なら結果は0個なのでそのまま`0`を返す) +> 2. 「まだユニークな要素を書き込んでよい位置」を指す`slow`変数を用意する +> 3. `fast`でベクタを先頭から末尾まで1つずつなめていき、`slow`が指す値と違う値が見つかるたびに、その値を`slow`の次の位置に書き込む +> 4. 最後に`slow + 1`(=ユニークな要素の個数)を`i32`型にキャストして返す + +```rust +// ---- 型定義(業務開発版で使用するエラー型) ---- + +/// この問題における入力エラーを表す型。 +/// LeetCode 提出用の `remove_duplicates` は戻り値の型が `i32` 固定のため +/// 直接は使えないが、業務開発版(`remove_duplicates_checked`)で使用する。 +#[derive(Debug, Clone, PartialEq)] +enum AlgorithmError { + EmptyInput, +} + +impl std::fmt::Display for AlgorithmError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EmptyInput => write!(f, "Input vector is empty"), + } + } +} + +impl std::error::Error for AlgorithmError {} + +// ---- LeetCode 提出用実装 ---- + +struct Solution; + +impl Solution { + /// ソート済みベクタから重複要素を取り除き、前方にユニークな値だけを詰め直す。 + /// nums が指すベクタ本体を直接書き換える(in-place)。 + /// + /// # Arguments + /// * `nums` - 非減少順(同じ値の連続を含む昇順)にソートされた整数ベクタへの + /// 可変参照(借用)。呼び出し元から所有権を奪わず、書き換える権利だけを借りる。 + /// + /// # Returns + /// 重複を除いたあとのユニークな要素数 k(`i32`型)。 + /// 呼び出し側は `nums[0]` から `nums[k as usize - 1]` までを有効な結果として扱う。 + /// + /// # Complexity + /// - Time: O(n) + /// - Space: O(1) + pub fn remove_duplicates(nums: &mut Vec) -> i32 { + // ベクタが空の場合、ユニークな要素は0個なのでそのまま0を返す。 + // (この問題の制約では nums.len() >= 1 が保証されているが、 + // 防御的プログラミングとして安全側に倒しておく。 + // ここで空チェックをしないと、この後の nums[0] 相当のアクセスで + // パニックする可能性がある) + if nums.is_empty() { + return 0; + } + + // slow は「最後に確定したユニークな値」が置かれているインデックス。 + // 最初はインデックス0の要素(nums[0])が唯一の確定済みユニーク値なので0から始める。 + // usize 型を使うのは、Rustのインデックスアクセスが usize 型を要求するため + // (負の値を取り得ないインデックス専用の型であることをコンパイラに保証させる)。 + let mut slow: usize = 0; + + // fast はこれから調べていく値を指すインデックス。 + // インデックス1から末尾まで、ベクタを1回だけなめる(1パス)。 + // 1..nums.len() は「1以上 nums.len() 未満」を意味する半開区間(Range)。 + for fast in 1..nums.len() { + // ベクタはソート済みなので、同じ値は必ず隣接している。 + // nums[fast] は i32(Copy トレイトを実装する型)なのでコピーされるだけで、 + // 所有権の移動は発生しない。 + // fast が指す値が、直近の確定済みユニーク値(nums[slow])と異なるなら、 + // それは「新しく見つかったユニークな値」ということになる。 + if nums[fast] != nums[slow] { + // slow をひとつ進めて「次にユニーク値を書き込む位置」を確保する。 + // slow は fast を追い越すことがなく(slow <= fast が常に成立する)、 + // かつ fast < nums.len() がループ条件で保証されているため、 + // この後の nums[slow] へのアクセスが範囲外になることはない。 + slow += 1; + // 新しく見つかったユニーク値を、確定済み領域の直後(nums[slow])に書き込む。 + // &mut Vec を通じた書き込みなので、この変更は呼び出し元にも反映される。 + nums[slow] = nums[fast]; + } + // nums[fast] == nums[slow] の場合は「すでに確認済みの値の重複」なので、 + // 何もせずに for ループが次の要素へ進む + // (何もしないことがそのまま「重複を無視する」処理になる) + } + + // slow は0始まりのインデックス(usize型)なので、要素の個数に直すには+1し、 + // 戻り値の型 i32 に合わせて as でキャストする。 + // この問題の制約(nums.length <= 3 * 10^4)から、usize → i32 のキャストで + // オーバーフローが起きることはない。 + (slow + 1) as i32 + } +} + +// ---- 業務開発版:Result によるエラー表現を使う実装 ---- +// LeetCode のシグネチャに縛られない、より型安全な設計。 +// チームで長期間メンテナンスするコードではこちらが望ましい。 + +/// ソート済みベクタから重複要素を取り除き、前方にユニークな値だけを詰め直す(業務開発版)。 +/// +/// LeetCode 提出用の `Solution::remove_duplicates` と異なり、空ベクタを +/// 明示的な `Err` として扱う。呼び出し元は `?` 演算子または `match` で +/// 必ず結果を確認しなければならず、空ベクタというケースの見落としを防げる。 +/// +/// # Arguments +/// * `nums` - 非減少順にソートされた整数ベクタへの可変参照。 +/// +/// # Returns +/// `Ok(k)`:重複を除いたあとのユニークな要素数。 +/// `Err(AlgorithmError::EmptyInput)`:`nums` が空だった場合。 +/// +/// # Complexity +/// - Time: O(n) +/// - Space: O(1) +fn remove_duplicates_checked(nums: &mut Vec) -> Result { + // is_empty() で空チェック。空のベクタに対して処理を続けると、 + // 後続のロジック自体は安全だが(ループが0回で終わるだけ)、 + // 「空ベクタは呼び出し側の設計ミスかもしれない」という意図を明示するため + // ここでは Err を返す設計にする。 + if nums.is_empty() { + return Err(AlgorithmError::EmptyInput); + } + + let mut slow: usize = 0; + + for fast in 1..nums.len() { + if nums[fast] != nums[slow] { + slow += 1; + nums[slow] = nums[fast]; + } + } + + Ok(slow + 1) +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> 具体例:`nums = vec![0,0,1,1,1,2,2,3,3,4]` に対して `Solution::remove_duplicates(&mut nums)` を呼び出した場合をトレースします。 +> +> ``` +> 初期状態: nums = [0,0,1,1,1,2,2,3,3,4]、nums.len() = 10、slow = 0 +> (nums.is_empty() チェック → false なので通過) +> +> fast=1: nums[1]=0, nums[slow]=nums[0]=0 → 同じ値なので何もしない +> fast=2: nums[2]=1, nums[slow]=nums[0]=0 → 値が違う! +> slow を 1 に進め、nums[1] = nums[2] = 1 を書き込む +> → nums = [0,1,1,1,1,2,2,3,3,4] +> fast=3: nums[3]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=4: nums[4]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=5: nums[5]=2, nums[slow]=nums[1]=1 → 値が違う! +> slow を 2 に進め、nums[2] = nums[5] = 2 を書き込む +> → nums = [0,1,2,1,1,2,2,3,3,4] +> fast=6: nums[6]=2, nums[slow]=nums[2]=2 → 同じ値なので何もしない +> fast=7: nums[7]=3, nums[slow]=nums[2]=2 → 値が違う! +> slow を 3 に進め、nums[3] = nums[7] = 3 を書き込む +> → nums = [0,1,2,3,1,2,2,3,3,4] +> fast=8: nums[8]=3, nums[slow]=nums[3]=3 → 同じ値なので何もしない +> fast=9: nums[9]=4, nums[slow]=nums[3]=3 → 値が違う! +> slow を 4 に進め、nums[4] = nums[9] = 4 を書き込む +> → nums = [0,1,2,3,4,2,2,3,3,4] +> +> ループ終了(fast=10 は範囲 1..10 の外になる) +> +> 戻り値: (slow + 1) as i32 = (4 + 1) as i32 = 5 +> +> → k = 5、先頭5要素は [0,1,2,3,4] となり、期待される出力と一致する +> ``` +> +> インデックス5以降の値(`[2,2,3,3,4]`の名残)は、問題文の指示どおり「気にしなくてよい部分」としてそのまま残っていますが、これは正解として扱われます(Custom Judgeは先頭k個しか見ないため)。 + +> 💡 **`&mut Vec` を通じた書き込みの仕組み(初学者向け)** +> +> `nums[slow] = nums[fast];` という一見単純な代入文の裏側では、以下のことが起きています: +> +> ``` +> 1. nums[fast] は i32(Copy トレイトを実装する型)を「コピー」して読み出す +> (所有権の移動は発生しない。整数のような小さい値は、 +> JavaやC#のプリミティブ型と同じ感覚でコピーが安価) +> 2. コピーされた値を nums[slow] の位置に書き込む +> 3. この書き込みは &mut Vec という可変借用を通じて行われるため、 +> 関数の呼び出し元が持つ元のベクタにもそのまま反映される +> ``` +> +> Rustではこの一連の操作が、借用チェッカーによって「同時に他の誰もこのベクタを読み書きしていないこと」がコンパイル時に保証されているため、データ競合が実行時に起こる心配がありません。 + +> 📖 **このセクションで登場した用語** +> +> - **`nums.is_empty()`**:ベクタが要素を1つも持たないかを判定するメソッド。`nums.len() == 0`と等価だが、意図が明確で読みやすいためRustではこちらが慣習。 +> - **`1..nums.len()`**:Rustの範囲(Range)構文。「1以上、`nums.len()`未満」を意味する半開区間で、終端を含まない。終端を含めたい場合は`1..=nums.len()`(`RangeInclusive`)を使う。 +> - **`usize`**:配列・ベクタのインデックスやサイズを表すために使われる、符号なしのポインタサイズ整数型。負の値を扱えないことで、意図しないマイナスのインデックスの混入をコンパイル時に防げる。 +> - **`as i32`**:型変換(キャスト)演算子。`usize`から`i32`への変換のように、値の範囲によっては情報が失われる可能性がある変換に使う(今回は制約上安全なキャストであることが保証されている)。 + +--- + +## 検証 + +_テストコードは不要です!具体的な計測値、メモリの出力も不要です!_ + +> 💡 **初学者向け補足**:エッジケースとは「入力が空・最小値・最大値・重複あり」など境界的な入力のことです。 + +- **境界値テスト**: + - `nums = vec![]`(空ベクタ)の場合:この問題の制約では発生しないが、`is_empty()`のガードがなければ、`1..nums.len()`が`1..0`という「開始が終了より大きい」空の範囲になるため実はループ自体は安全に0回で終わる。しかし`slow`の初期値`0`をそのまま`slow + 1 = 1`として返してしまうと、要素が存在しないのに「1個ある」という誤った結果になるため、明示的なガードが必要。 + - `nums = vec![1]`(要素1個)の場合:`1..1`は空の範囲になるため`fast`のループが1度も実行されず、`slow`は初期値の`0`のまま`(slow + 1) as i32 = 1`が返る。これは正しい(ユニークな要素は1個)。 + - `nums`の全要素が同じ値(例:`vec![2,2,2,2]`)の場合:`nums[fast] != nums[slow]`が一度も`true`にならず、`slow`は`0`のまま`1`が返る。これも正しい。 + - `nums`の全要素が異なる値(例:`vec![1,2,3,4]`)の場合:毎回`if`が`true`になり、`slow`は`fast`と同じ速度で進み、最終的に`nums.len()`がそのまま返る。 +- **型チェック**: `clippy::all`相当の観点では、`for fast in 1..nums.len()`のようなインデックスベースのループは、可能であればイテレータ(`.iter().enumerate()`等)への書き換えを提案されることがありますが、今回は`nums[slow]`という別インデックスへの同時アクセスが必要なため、`clippy::needless_range_loop`のような警告は正当な理由(今回のロジックがイテレータだけでは表現しづらい)で許容されます。 +- **所有権・借用の安全性**: `&mut Vec`という単一の可変借用のみを使い、関数内で新たな借用や所有権の移動を発生させていないため、借用チェッカーによるコンパイルエラーは発生しません。 + +> 📖 **このセクションで登場した用語** +> +> - **エッジケース**:空のベクタ・要素1つ・全要素同一・全要素異なるなど、境界的な条件の入力 +> - **`clippy`**:Rustの静的解析ツール。バグの可能性やより良い書き方を自動で指摘してくれる + +--- + +## 補足:計算量・安全性まとめ + +- **時間計算量**: O(n) — ベクタを1回だけ走査するため(n = `nums.len()`) +- **空間計算量**: O(1) — 追加で使うメモリは`slow`という`usize`変数ひとつのみ(スタック上)。ヒープアロケーションは一切発生しないため、メモリリークの心配は構造的に存在しない(Rustはそもそも所有権システムによりメモリリークが起きにくい設計だが、今回は新規のヒープ確保自体を行っていない) +- **所有権・借用の安全性**: `&mut Vec`という単一の可変借用のみで完結しており、コンパイル時に借用チェッカーがデータ競合の不在を保証している。`unsafe`ブロックは一切使用していない diff --git a/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Swift.md b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Swift.md new file mode 100644 index 00000000..16b1e2b6 --- /dev/null +++ b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Swift.md @@ -0,0 +1,174 @@ +# LeetCode解答フォーマット + +--- + +## 1. 問題の分析 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「すでに並び替え済みの配列から、同じ値が連続している部分をひとつにまとめて、前方に詰め直す問題」です。配列自体を直接書き換える(in-place=その場で操作する)必要がある点がポイントです。 + +### 競技プログラミング視点での分析 + +- 配列はすでにソート済みなので、**同じ値は必ず隣り合っている**という性質を利用できます。これにより、余計な探索(例えば全要素同士を比較する)をせず、**配列を1回だけ左から右へなめる(1パス)だけ**で処理が完結します。 +- 実行速度を最優先するなら、時間計算量 O(n)(nは配列の要素数)が理論上の最速ラインです。全要素を最低1回は確認する必要があるため、これより速くはなりません。 +- メモリ使用量の最小化方針としては、新しい配列やSet(=重複を許さない集合を表すデータ構造)を作らず、**与えられた配列そのものを書き換える**ことで、追加メモリを定数個の変数(インデックス用の整数など)だけに抑えます。これが空間計算量 O(1) を実現する鍵です。 + +### 業務開発視点での分析 + +- この問題では境界条件(配列が空、要素が1個だけ、全要素が同じ値、全要素がバラバラ等)を正しく処理できるかが保守性・堅牢性の観点で重要です。 +- Swiftでは配列は「値型(`struct`)(=代入時に値そのものがコピーされる型。詳しくは後述)」ですが、この問題のシグネチャは `inout` (イン・アウト。=関数の外にある変数を直接書き換えられるようにする仕組み) を使って参照渡しのように振る舞わせているため、呼び出し元の配列を安全に直接変更できます。 +- エラーハンドリングについては、この問題は「`nums.length >= 1`」という制約が保証されているため例外を投げる(`throws`)必要はありませんが、念のため空配列に対しても安全に動くようガード(`guard`)を入れておくと、将来的な仕様変更にも耐えられる堅牢なコードになります。 + +### Swift特有の考慮点 + +- **値型(`struct`)と参照型(`class`)の使い分け**:Swiftの`Array`は値型です。Javaの配列やC++の生ポインタ配列のように「複数の変数が同じメモリ領域を指す」ということは基本的に起きません。しかし今回は`inout`を使うことで、まるで参照渡しのように、呼び出し元の配列を直接編集できます。これにより新しい配列をコピーして返す必要がなくなり、メモリ効率が良くなります。 +- **ジェネリクス・プロトコル境界**:今回のシグネチャは`Int`型固定ですが、もし汎用化するなら``(=「大小比較ができる型」という制約付きの型パラメータ)を使うことで、`Int`以外の型(`Double`や`String`など)にも対応できる汎用的な実装が可能です。 +- **Optionalバインディングによるnull安全性**:今回のアルゴリズムでは配列の添字アクセスのみを使い、`Optional`型(=値があるかもしれないし、ないかもしれないことを表す型。`T?`と書く)は登場しません。ただし、範囲外アクセスによるクラッシュを防ぐため、ループの範囲指定を正確に行う必要があります。 + +> 📖 **このセクションで登場した用語** +> +> - **in-place(インプレース)**:新しい配列を作らず、与えられた配列そのものを直接書き換える操作方式。 +> - **`inout`**:Swiftで、関数の引数を「呼び出し元の変数を直接書き換えられる形」で受け取るためのキーワード。C++の参照渡し(`&`)に近い概念。 +> - **値型(`struct`)**:代入時に値そのものがコピーされる型。SwiftのArray・Int・Stringなどが該当。 +> - **1パスアルゴリズム**:配列やリストを先頭から末尾まで1回だけ走査して処理を完了させるアルゴリズムのこと。 + +--- + +## 2. アルゴリズムアプローチ比較 + +> 💡 **初学者向け補足**:同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」を比べて、この問題の制約(in-place・O(1)空間)に最も適したものを選びます。 + +| アプローチ | 時間計算量 | 空間計算量 | Swift実装コスト | 型安全性 | 可読性 | 備考 | +| ---------------------------------------- | ---------- | ---------- | --------------- | -------- | ------ | ------------------------------------------------------------ | +| **A. Two-Pointer(低速・高速ポインタ)** | O(n) | O(1) | 低 | 高 | 高 | ソート済みという前提を最大限活用。in-place要件を満たす最適解 | +| B. Setを使った重複除去 | O(n) | O(n) | 低 | 高 | 中 | Setは順序を保証しないため、別途ソートし直す必要があり非効率 | +| C. 新配列にfilterして結果を返す | O(n) | O(n) | 低 | 高 | 高 | シンプルだが「in-place」というこの問題の要件そのものに反する | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n)`:入力が2倍になると、処理も約2倍になる(線形) +> - `O(n²)`:入力が2倍になると、処理は約4倍になる(二重ループに多い、今回は該当なし) + +**Swift特有の値型コピーコストについて**:方法B・Cでは新しい`Array`(Setから配列への変換、あるいは`filter`の結果)を生成するため、要素数分のメモリ確保とコピーが発生します。SwiftのArrayはCopy-on-Write(COW。=実際に変更が加えられるまではコピーを作らず、参照を共有し続ける最適化)を採用していますが、それでも「新しい配列を作る」という行為自体はO(n)のメモリ確保を伴います。一方、方法Aは既存の配列を直接上書きするだけなので、この追加コストが一切発生しません。 + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して、処理にかかる手間がどう増えるかの目安。 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安。 +> - **Copy-on-Write(COW)**:SwiftのArrayなどが採用する最適化。変更が実際に発生するまでコピーを作らない仕組み。 + +--- + +## 3. 選択したアルゴリズムと理由 + +> 💡 **初学者向け補足**:他の方法と比較しながら、なぜ二重ポインタ法を選んだのかを説明します。 + +- **選択したアプローチ**: A. Two-Pointer(slow / fast の2つのインデックスを使う方法) +- **理由**: + - **計算量的な優位性**:Set(方法B)や新配列生成(方法C)はどちらもO(n)の追加メモリが必要ですが、Two-Pointer法はO(1)の追加メモリ(`slow`という整数変数ひとつ)だけで済みます。**この問題は「in-placeで行うこと」自体が明示的な要件**なので、O(n)空間を使う方法B・Cはそもそも要件を満たしません。 + - **Swift環境での型安全性**:`Int`同士の比較演算子`!=`はコンパイル時に型チェックされるため、誤った型同士を比較してしまうミスがそもそも起こり得ません。 + - **保守性・可読性の観点**:ループはひとつだけで、条件分岐も「値が変わったかどうか」のシンプルな1行のみです。将来的にコードを読む人にとっても意図が追いやすい構造です。 +- **Swift特有の最適化ポイント**: + - `inout`引数を使うことで、配列のコピーを一切発生させずに呼び出し元の配列を直接書き換えられます。 + - `guard`文による早期リターンで、空配列という特殊ケース(今回の制約上は起こりませんが、防御的プログラミングとして)を先に弾き、メインロジックのネストを浅く保っています。 + +> 📖 **このセクションで登場した用語** +> +> - **Two-Pointer(二重ポインタ)法**:配列上で2つの添字(インデックス)を異なる速度・役割で動かしながら処理を進めるアルゴリズムパターン。 +> - **防御的プログラミング**:想定外の入力(今回で言えば空配列など)が来ても安全に動作するよう、あらかじめ検証コードを入れておく設計方針。 + +--- + +## 4. 実装コード + +> 💡 **初学者向け補足**:コード全体を示す前に、大まかな構造(骨格)を示します。 +> +> 1. まず配列が空でないかを確認する(空なら結果は0個なのでそのまま`0`を返す) +> 2. 「まだユニークな要素を書き込んでよい位置」を指す`slow`ポインタを用意する +> 3. `fast`ポインタで配列を先頭から末尾まで1つずつなめていき、`slow`が指す値と違う値が見つかるたびに、その値を`slow`の次の位置に書き込む +> 4. 最後に`slow + 1`(=ユニークな要素の個数)を返す + +```swift +class Solution { + /// ソート済み配列から重複要素を取り除き、前方にユニークな値だけを詰め直す + /// - Parameter nums: 非減少順(同じ値の連続を含む昇順)にソートされた整数配列。 + /// `inout` により、この関数は呼び出し元の配列を直接書き換える。 + /// - Returns: 重複を除いたあとのユニークな要素数 k + func removeDuplicates(_ nums: inout [Int]) -> Int { + // 配列が空の場合、ユニークな要素は0個なのでそのまま0を返す。 + // (この問題の制約では nums.length >= 1 が保証されているが、 + // 防御的プログラミングとして安全側に倒しておく) + guard !nums.isEmpty else { + return 0 + } + + // slow は「最後に確定したユニークな値」が置かれているインデックス。 + // 最初はインデックス0の要素(nums[0])が唯一の確定済みユニーク値なので0から始める。 + var slow = 0 + + // fast はこれから調べていく値を指すインデックス。 + // インデックス1から末尾まで、配列を1回だけなめる(1パス)。 + for fast in 1.. 💡 **コードの動作トレース**(初学者向け) +> +> 具体例:`nums = [0,0,1,1,1,2,2,3,3,4]` で実際にトレースしてみます。 +> +> ``` +> 初期状態: nums = [0,0,1,1,1,2,2,3,3,4]、slow = 0 +> +> fast=1: nums[1]=0, nums[slow]=nums[0]=0 → 同じ値なので何もしない +> fast=2: nums[2]=1, nums[slow]=nums[0]=0 → 値が違う! +> slow を 1 に進め、nums[1] = nums[2] = 1 を書き込む +> → nums = [0,1,1,1,1,2,2,3,3,4] +> fast=3: nums[3]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=4: nums[4]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=5: nums[5]=2, nums[slow]=nums[1]=1 → 値が違う! +> slow を 2 に進め、nums[2] = nums[5] = 2 を書き込む +> → nums = [0,1,2,1,1,2,2,3,3,4] +> fast=6: nums[6]=2, nums[slow]=nums[2]=2 → 同じ値なので何もしない +> fast=7: nums[7]=3, nums[slow]=nums[2]=2 → 値が違う! +> slow を 3 に進め、nums[3] = nums[7] = 3 を書き込む +> → nums = [0,1,2,3,1,2,2,3,3,4] +> fast=8: nums[8]=3, nums[slow]=nums[3]=3 → 同じ値なので何もしない +> fast=9: nums[9]=4, nums[slow]=nums[3]=3 → 値が違う! +> slow を 4 に進め、nums[4] = nums[9] = 4 を書き込む +> → nums = [0,1,2,3,4,2,2,3,3,4] +> +> ループ終了。 return slow + 1 = 4 + 1 = 5 +> +> → k = 5、先頭5要素は [0,1,2,3,4] となり、期待される出力と一致する +> ``` +> +> インデックス5以降の値(`[2,2,3,3,4]`の名残)は、問題文の指示どおり「気にしなくてよい部分」としてそのまま残っていますが、これは正解として扱われます(Custom Judgeは先頭k個しか見ないため)。 + +> 📖 **このセクションで登場した用語** +> +> - **`guard` 文**:条件を満たさない場合に早期リターンする構文。`if`と異なり、`guard`のスコープを抜けたあとは「条件が満たされている」ことがコンパイラにも保証されるため、その後のコードで余計なnilチェックが不要になる。 +> - **`1.. - **添字(インデックス)アクセス `nums[i]`**:配列の`i`番目(0始まり)の要素にアクセスする構文。範囲外の`i`を指定すると実行時にクラッシュするため、ループ範囲の指定を正確に行うことが重要。 + +--- + +## 補足:計算量まとめ + +- **時間計算量**: O(n) — 配列を1回だけ走査するため(n = `nums.count`) +- **空間計算量**: O(1) — 追加で使うメモリは`slow`という整数変数ひとつのみ(入力配列自体は書き換えているだけで、新規のメモリ確保は行っていない) diff --git a/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Typescript.md b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Typescript.md new file mode 100644 index 00000000..239d0cf8 --- /dev/null +++ b/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/Remove-Duplicates-from-Sorted-Array-Typescript.md @@ -0,0 +1,196 @@ +# leetcodeでの回答フォーマット + +--- + +## 1. 問題の分析 + +> 💡 **初学者向け補足**:この問題は、一言で言うと「すでに並び替え済みの配列から、同じ値が連続している部分をひとつにまとめて、前方に詰め直す問題」です。配列自体を直接書き換える(in-place=その場で操作する)必要がある点がポイントです。 + +### 競技プログラミング視点での分析 + +- 配列はすでにソート済みなので、**同じ値は必ず隣り合っている**という性質を利用できます。これにより、余計な探索(例えば全要素同士を比較する)をせず、**配列を1回だけ左から右へなめる(1パス)だけ**で処理が完結します。 +- 実行速度を最優先するなら、時間計算量 O(n)(nは配列の要素数)が理論上の最速ラインです。全要素を最低1回は確認する必要があるため、これより速くはなりません。 +- メモリ使用量の最小化方針としては、`Set`や`[...new Set(nums)]`のような新しい配列・オブジェクトを生成する手段を使わず、**与えられた`number[]`そのものを書き換える**ことで、追加メモリを`number`型の変数1個だけに抑えます。JavaScriptエンジン(V8など)にとっても、新しいオブジェクトを生成しないほうがガベージコレクション(=使われなくなったメモリを自動的に解放してくれる仕組み)の負荷が少なく済みます。 + +### 業務開発視点での分析 + +- この問題では境界条件(配列が空、要素が1個だけ、全要素が同じ値、全要素がバラバラ等)を正しく処理できるかが保守性・堅牢性の観点で重要です。 +- JavaScript/TypeScriptの配列はオブジェクト(参照型)です。関数に渡す際、配列そのものがコピーされるのではなく「配列を指す参照」が渡されるため、関数内で`nums[i] = x`のように要素を書き換えると、呼び出し元の配列も同時に書き換わります。これはJavaと似た挙動です。 +- エラーハンドリングについては、この問題は制約上`nums.length >= 1`が保証されていますが、業務開発の観点では`Array.isArray(nums)`のような実行時の型チェックを入れておくと安全です。TypeScriptは型を消去してJavaScriptにコンパイルされるため、コンパイル時の型チェックだけでは「実行時に本当に配列が渡ってきているか」までは保証できません(例えば、TypeScriptの型定義を無視してJavaScript側からこの関数を呼び出すケースなど)。 + +### TypeScript特有の考慮点 + +- **型推論とコンパイル時最適化**:戻り値の型`number`は、関数の実装(`return slow + 1`のような`number`同士の計算)からTypeScriptが自動で推論できるため、明示的に書かなくても正しさは保証されます。ただし、関数のシグネチャ(引数・戻り値)については、コード全体の可読性とドキュメント性を高めるため明示的に書くのがこの問題のような公開APIでは推奨されます。 +- **ジェネリクスの効果的な活用**:今回のシグネチャは`number[]`固定ですが、汎用化するなら``のような制約付き型パラメータを使うことで、比較可能な他の型にも対応できます。ただし今回はLeetCodeのシグネチャが`number[]`固定のため、ジェネリクスは使用しません。 +- **型ガードとnull安全性**:この関数の引数`nums: number[]`はTypeScriptの型システム上`null`や`undefined`を許容しません(`strictNullChecks`が有効な場合)。しかし、これはあくまで**コンパイル時**の保証であり、JavaScriptとして実行される際にはこのチェックは消えてしまいます。そのため、業務開発の観点では実行時にも`Array.isArray()`のような型ガード(=実行時に値の型を確認し、その情報をコンパイラにも伝える構文)を入れておくことで、コンパイル時保証をすり抜けてくる不正な入力(外部APIのレスポンスなど)にも対応できます。 + +> 📖 **このセクションで登場した用語** +> +> - **in-place(インプレース)**:新しい配列を作らず、与えられた配列そのものを直接書き換える操作方式。 +> - **参照型**:変数が実体そのものではなく「実体への参照」を持つ型。JavaScript/TypeScriptの配列・オブジェクトが該当。 +> - **型ガード**:実行時に値の型を確認し、その情報をコンパイラにも伝える構文(例:`Array.isArray(x)`、`typeof x === 'number'`)。 +> - **ガベージコレクション(GC)**:使われなくなったオブジェクトのメモリを自動的に解放してくれる仕組み。 + +--- + +## 2. アルゴリズムアプローチ比較 + +> 💡 **初学者向け補足**:同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」を比べて、この問題の制約(in-place・O(1)空間)に最も適したものを選びます。 + +| アプローチ | 時間計算量 | 空間計算量 | TS実装コスト | 型安全性 | 可読性 | 備考 | +|---|---|---|---|---|---|---| +| **A. Two-Pointer(低速・高速ポインタ)** | O(n) | O(1) | 低 | 高 | 高 | ソート済みという前提を最大限活用。in-place要件を満たす最適解 | +| B. `Set`を使った重複除去 | O(n) | O(n) | 低 | 高 | 中 | `Set`は挿入順を保持するが、新しいオブジェクトの生成が発生しin-place要件に反する | +| C. `[...new Set(nums)]`(スプレッド構文) | O(n) | O(n) | 最低 | 高 | 高 | 記述は最も簡潔だが、`Set`と新しい配列の2つのオブジェクトを生成しin-place要件に反する | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n)`:入力が2倍になると、処理も約2倍になる(線形) +> - `O(n log n)`:入力が2倍になると、処理は約2倍強になる(ソートアルゴリズムに多い、今回は該当なし) + +**TypeScript/JavaScript特有のオブジェクト生成コストについて**:方法B・Cではいずれも`Set`という新しいオブジェクトをヒープ上に生成し、要素を1つ挿入するたびに内部のハッシュテーブルへの書き込みが発生します。方法Cはさらに`[...new Set(nums)]`というスプレッド構文で新しい配列も生成するため、都合2つの新規オブジェクトが作られます。これらはいずれもガベージコレクタの管理対象が増え、大きな配列では無視できないオーバーヘッドになります。方法Aは既存の配列を直接上書きするだけなので、この追加コストが一切発生しません。 + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して、処理にかかる手間がどう増えるかの目安。 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安。 +> - **スプレッド構文(`...`)**:配列やオブジェクトの要素を展開する構文。`[...new Set(nums)]`は`Set`の全要素を新しい配列に展開する。 + +--- + +## 3. 選択したアルゴリズムと理由 + +> 💡 **初学者向け補足**:他の方法と比較しながら、なぜ二重ポインタ法を選んだのかを説明します。 + +- **選択したアプローチ**: A. Two-Pointer(`slow` / `fast` の2つのインデックスを使う方法) +- **理由**: + - **計算量的な優位性**:`Set`を使う方法(方法B)やスプレッド構文(方法C)はどちらもO(n)の追加メモリが必要ですが、Two-Pointer法はO(1)の追加メモリ(`slow`という`number`変数ひとつ)だけで済みます。**この問題は「in-placeで行うこと」自体が明示的な要件**なので、O(n)空間を使う方法B・Cはそもそも要件を満たしません。 + - **TypeScript環境での型安全性**:`number`同士の比較演算子`!==`はコンパイル時に型チェックされるため、誤った型同士を比較してしまうミスがそもそも起こり得ません。 + - **保守性・可読性の観点**:ループはひとつだけで、条件分岐も「値が変わったかどうか」のシンプルな1行のみです。`Set`を使った宣言的な書き方と比べるとやや行数は増えますが、意図(何がなぜ起きているか)が明確に読み取れます。 +- **TypeScript特有の最適化ポイント**: + - 配列が参照型であることを活かし、コピーを一切発生させずに呼び出し元の配列を直接書き換えています。 + - 新しいオブジェクト(`Set`や新しい配列)を一切生成しないため、ガベージコレクションの負荷がかかりません。 + - 戻り値の型`number`はTypeScriptが計算式から自動推論できますが、関数シグネチャとしては明示することでコードの可読性とドキュメント性を高めています。 + +> 📖 **このセクションで登場した用語** +> +> - **Two-Pointer(二重ポインタ)法**:配列上で2つの添字(インデックス)を異なる速度・役割で動かしながら処理を進めるアルゴリズムパターン。 + +--- + +## 4. 実装コード + +> 💡 **初学者向け補足**:コード全体を示す前に、大まかな構造(骨格)を示します。 +> +> 1. まず`nums`が配列かどうか、空でないかを確認する +> 2. 「まだユニークな要素を書き込んでよい位置」を指す`slow`変数を用意する +> 3. `fast`で配列を先頭から末尾まで1つずつなめていき、`slow`が指す値と違う値が見つかるたびに、その値を`slow`の次の位置に書き込む +> 4. 最後に`slow + 1`(=ユニークな要素の個数)を返す + +```typescript +/** + * ソート済み配列から重複要素を取り除き、前方にユニークな値だけを詰め直す。 + * nums が参照する配列そのものを直接書き換える(in-place)。 + * + * @param nums - 非減少順(同じ値の連続を含む昇順)にソートされた数値配列。 + * @returns 重複を除いたあとのユニークな要素数 k。 + * 呼び出し側は nums[0] から nums[k - 1] までを有効な結果として扱う。 + * @throws {TypeError} nums が配列でない場合。 + * @complexity Time: O(n), Space: O(1) + */ +function removeDuplicates(nums: number[]): number { + // 型ガード:nums が本当に配列かどうかを実行時にも確認する。 + // なぜ必要か:TypeScript の型チェックはコンパイル時のみ有効であり、 + // JavaScript として実行される段階ではこのチェックは消えてしまう。 + // 外部から不正な値が渡されるケース(型定義を無視した呼び出しなど)に備える。 + if (!Array.isArray(nums)) { + throw new TypeError('Input must be an array'); + } + + // 配列が空の場合、ユニークな要素は0個なのでそのまま0を返す。 + // (この問題の制約では nums.length >= 1 が保証されているが、 + // 防御的プログラミングとして安全側に倒しておく) + if (nums.length === 0) { + return 0; + } + + // slow は「最後に確定したユニークな値」が置かれているインデックス。 + // 最初はインデックス0の要素(nums[0])が唯一の確定済みユニーク値なので0から始める。 + // let を使うのは、ループの中で再代入され続ける変数だから + // (TypeScript/JavaScript では再代入不要な変数には const を使うのが慣習)。 + let slow = 0; + + // fast はこれから調べていく値を指すインデックス。 + // インデックス1から末尾まで、配列を1回だけなめる(1パス)。 + for (let fast = 1; fast < nums.length; fast++) { + // 配列はソート済みなので、同じ値は必ず隣接している。 + // fast が指す値が、直近の確定済みユニーク値(nums[slow])と異なるなら、 + // それは「新しく見つかったユニークな値」ということになる。 + // !== を使うのは、TypeScript/JavaScript では型変換を伴わない厳密等価演算子を + // 使うのが一般的な慣習であるため(== は型変換によって意図しない一致が起こりうる)。 + if (nums[fast] !== nums[slow]) { + // slow をひとつ進めて「次にユニーク値を書き込む位置」を確保する + slow++; + // 新しく見つかったユニーク値を、確定済み領域の直後(nums[slow])に書き込む + nums[slow] = nums[fast]; + } + // nums[fast] === nums[slow] の場合は「すでに確認済みの値の重複」なので、 + // 何もせずに for ループの fast++ によって次の要素へ進む + // (何もしないことがそのまま「重複を無視する」処理になる) + } + + // slow は0始まりのインデックスなので、要素の個数に直すには+1する + return slow + 1; +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> 具体例:`removeDuplicates([0,0,1,1,1,2,2,3,3,4])` の呼び出しをトレースします。 +> +> ``` +> 初期状態: nums = [0,0,1,1,1,2,2,3,3,4]、slow = 0 +> (Array.isArray チェック → 通過、length === 0 チェック → 通過) +> +> fast=1: nums[1]=0, nums[slow]=nums[0]=0 → 同じ値なので何もしない +> fast=2: nums[2]=1, nums[slow]=nums[0]=0 → 値が違う! +> slow を 1 に進め、nums[1] = nums[2] = 1 を書き込む +> → nums = [0,1,1,1,1,2,2,3,3,4] +> fast=3: nums[3]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=4: nums[4]=1, nums[slow]=nums[1]=1 → 同じ値なので何もしない +> fast=5: nums[5]=2, nums[slow]=nums[1]=1 → 値が違う! +> slow を 2 に進め、nums[2] = nums[5] = 2 を書き込む +> → nums = [0,1,2,1,1,2,2,3,3,4] +> fast=6: nums[6]=2, nums[slow]=nums[2]=2 → 同じ値なので何もしない +> fast=7: nums[7]=3, nums[slow]=nums[2]=2 → 値が違う! +> slow を 3 に進め、nums[3] = nums[7] = 3 を書き込む +> → nums = [0,1,2,3,1,2,2,3,3,4] +> fast=8: nums[8]=3, nums[slow]=nums[3]=3 → 同じ値なので何もしない +> fast=9: nums[9]=4, nums[slow]=nums[3]=3 → 値が違う! +> slow を 4 に進め、nums[4] = nums[9] = 4 を書き込む +> → nums = [0,1,2,3,4,2,2,3,3,4] +> +> ループ終了(fast=10 は nums.length=10 と等しくなり、条件 fast < nums.length を満たさなくなる) +> +> return slow + 1 = 4 + 1 = 5 +> +> → k = 5、先頭5要素は [0,1,2,3,4] となり、期待される出力と一致する +> ``` +> +> インデックス5以降の値(`[2,2,3,3,4]`の名残)は、問題文の指示どおり「気にしなくてよい部分」としてそのまま残っていますが、これは正解として扱われます(Custom Judgeは先頭k個しか見ないため)。 + +> 📖 **このセクションで登場した用語** +> +> - **`Array.isArray()`**:値が配列かどうかを実行時に判定する組み込み関数。TypeScriptの型チェックはコンパイル時のみのため、実行時の防御にはこの関数のような型ガードが必要。 +> - **`let` / `const`**:TypeScript/JavaScriptの変数宣言キーワード。`let`は再代入可能、`const`は再代入不可(ただし`const`で宣言した配列やオブジェクトの中身自体は変更可能)。今回の`slow`はループ内で再代入されるため`let`を使う。 +> - **厳密等価演算子(`===` / `!==`)**:型変換を行わずに値と型の両方を比較する演算子。`==`/`!=`は型変換を行うため意図しない一致が起こりうるが、TypeScript/JavaScriptでは`===`/`!==`の使用が慣習。 +> - **`for (let fast = 1; fast < nums.length; fast++)`**:古典的なforループ構文。「初期化式;継続条件;更新式」の3つから成り、`fast`はこのループのブロックスコープ内でのみ有効な変数として宣言される。 + +--- + +## 補足:計算量まとめ + +- **時間計算量**: O(n) — 配列を1回だけ走査するため(n = `nums.length`) +- **空間計算量**: O(1) — 追加で使うメモリは`slow`という`number`変数ひとつのみ(入力配列自体は書き換えているだけで、新規のオブジェクト生成は行っていない) +- **型安全性**: `Array.isArray()`による実行時の型ガードにより、TypeScriptのコンパイル時型チェックをすり抜けてくる不正な入力にも対応できる設計になっている diff --git a/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-C#.md b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-C#.md new file mode 100644 index 00000000..17572a55 --- /dev/null +++ b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-C#.md @@ -0,0 +1,249 @@ +# LeetCode 21: Merge Two Sorted Lists(C#版) + +## 1. 問題の分析 + +> 💡 この問題は、一言で言うと「すでに整列された2つの連結リストを、ダミーノード1個を除き新しいリストノードを作らずに1つの整列済みリストへ“縫い合わせる”問題」です。 + +**C#で解く際に特に気をつけるべき点**を先にまとめます。今回与えられたシグネチャでは `ListNode next` のように `?` が付いていません。つまりこのコードは**null許容参照型(nullable reference types)が有効化されていない、従来のC#の書き方**です。C#8.0以降では `#nullable enable` を有効にすると `ListNode?` のように書かないと `null` を代入できなくなりますが、LeetCode標準のテンプレートはこの機能を使わない設計になっています。そのため、**コンパイラは `list1` や `list2` が `null` かどうかを静的にはチェックしてくれません**。JavaやDartのような「型が絞り込まれる」機能に頼れない以上、**アクセス前に必ず自分で `null` チェックを書く**という規律がこの問題では特に重要になります。また、`ListNode` は `class`(参照型)として定義されているため、C#の `struct`(値型)とは異なり、変数に代入すると**参照(オブジェクトへの住所)がコピーされる**だけで、ノードの中身そのものが複製されることはありません。この性質のおかげで、JavaやGoと同様に「既存ノードの `next` をつなぎ替えるだけ」というアルゴリズムがそのまま成立します。 + +**競技プログラミング視点での分析** + +- 実行速度を最優先するなら、各ノードを1回ずつ見るだけで済む**反復(ループ)処理**が最速。時間計算量は O(n+m)(n, mはそれぞれのリスト長)。 +- メモリ使用量の最小化方針としては、**ダミーノード1個を除き新しいリストノードを作らず、既存ノードの `next` フィールドだけを付け替える**(この操作を「スプライシング(splicing、つなぎ替え)」と呼ぶ)。C#は.NETのガベージコレクション(=使われなくなったオブジェクトを自動的に片付ける仕組み)を持つ言語なので手動でのメモリ解放は不要ですが、新規オブジェクトを作らないほうがGCの負荷が減り高速という点は他のGC言語(Java、Dart等)と共通です。 + +**業務開発視点での分析** + +- 型安全性の面では、`ListNode` は `int val` を直接フィールドに持つ設計であり、`val` を `int?`(null許容値型)にする必要はありません。プロパティ(`public int Val { get; set; }` のような形)を使うことでカプセル化を強化することもできますが、LeetCode標準の `public` フィールドの形式に合わせています。 +- エラーハンドリングとして最も注意すべき点:**`list1` や `list2` が `null` であることは「異常な入力」ではなく「空のリストを表す正常な状態」**です。Example 2 (`list1 = [], list2 = []`) がまさにそれです。C#の一般的な防御的プログラミングパターンである「`ArgumentNullException` を即座に投げる」を機械的に適用すると、この問題では**正しい入力を誤ってエラー扱いしてしまう**ので注意が必要です。 + +**C#特有の考慮点** + +- **参照型 (`class`) と値型 (`struct`) の違い**:`ListNode` は `class` として定義されているため参照型です。もし仮に `struct` として定義されていたら、代入のたびに構造体全体がコピーされてしまい、今回のようなポインタのつなぎ替えアルゴリズムは成立しません。 +- **null許容参照型(nullable reference types)**:C#8.0以降の機能で、`string?` のように書くことで「`null` を許容する型」を明示できます。ただし今回のLeetCode標準コードはこの機能を前提としていないため、`null` チェックはあくまで実行時の条件分岐(`if`/`while`)に委ねられます。 + +> 📖 **このセクションで登場した用語**(例) +> +> - **null許容参照型(nullable reference types)**:C#8.0で導入された機能。`T?` と書くことで「`null` を許容する型」であることを型システムで明示できる。有効化しない限り、従来通りどの参照型変数にも `null` を代入できてしまう +> - **参照型 (`class`) と値型 (`struct`)**:参照型は変数に「オブジェクトへの住所(参照)」が入り、代入すると住所だけがコピーされる。値型は変数に値そのものが入り、代入すると値全体がコピーされる +> - **ガベージコレクション(GC)**:もう使われなくなったオブジェクトのメモリを自動的に解放してくれる.NETの仕組み +> - **`ArgumentNullException`**:C#標準の例外クラス。引数として渡された参照が `null` であることが不正な場合に投げる + +--- + +## 2. アルゴリズムアプローチ比較 + +> 💡 同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」を比べて最適なものを選びます。 + +| アプローチ | 時間計算量 | 空間計算量 | C#実装コスト | 型安全性 | 可読性 | 備考 | +| --------------------------------------------- | ----------------- | ---------- | ------------ | -------- | ------ | ------------------------------------------ | +| 方法A:反復(ダミーヘッド + 2ポインタ) | O(n+m) | O(1) | 低 | 高 | 高 | ダミーノード1個(ListNode(0))のみ作成。推奨 | +| 方法B:再帰(先頭同士を比較して自己呼び出し) | O(n+m) | O(n+m) | 低 | 高 | 高 | コールスタックがリスト長に比例して積まれる | +| 方法C:両リストを`List`に集めて`Sort()` | O((n+m) log(n+m)) | O(n+m) | 中 | 中 | 中 | すでにソート済みという前提を無駄にする | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n+m)`:2つの入力の合計サイズに比例して手間が増える(線形) +> - `O((n+m) log(n+m))`:ソートに典型的な計算量。入力が増えるとやや急激に手間が増える + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して、処理にかかる手間がどう増えるかの目安 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安 +> - **コールスタック**:関数呼び出しの「呼び出し履歴」を積んでおくメモリ領域。再帰が深くなるとここに積まれる分だけメモリを消費し、深すぎると `StackOverflowException` が起きる + +--- + +## 3. 選択したアルゴリズムと理由 + +- **選択したアプローチ**:方法A(ダミーヘッドを使った反復処理) +- **理由**: + - **計算量的な優位性**:方法B(再帰)と同じ O(n+m) の時間計算量でありながら、空間計算量は O(1) で済む(方法Bはコールスタックに O(n+m) 消費する)。今回の制約(最大50ノード)では方法Bでも問題になりませんが、業務コードとしては入力サイズに依存する設計は将来的な拡張に対して脆いです。 + - **C#環境での安全性**:`while` ループの条件を `list1 != null && list2 != null` とすることで、ループの内側では `list1.val` へのアクセス前に必ず `null` チェックが済んでいることが保証される。 + - **保守性・可読性**:ループ1本で完結し、条件分岐もシンプル。デバッガでも1行ずつ追いやすい。 + - **方法Cを選ばなかった理由**:わざわざ `List` に集めてソートするのは、「すでにソート済み」という問題の前提を無視した回り道であり、要素の収集と `List.Sort()` によるソートを含めた全体の時間計算量が O((n+m) log(n+m)) となるうえ、空間計算量も O(n+m) と余計にメモリを使う。 +- **C#特有の最適化ポイント**: + - `ListNode` は参照型なので、比較 `list1.val <= list2.val` は `int`(値型)同士の単純な比較であり、ボクシング(=`int` のようなプリミティブ型を `object` のようなラッパーオブジェクトに変換すること)は一切発生しない。 + - 新規オブジェクト生成はダミーノード1個のみに抑えており、ガベージコレクタの負荷を最小限にしている。 + +> 📖 **このセクションで登場した用語** +> +> - **ボクシング**:`int` のような値型を `object` のような参照型に変換すること。オブジェクト生成のオーバーヘッドが発生するため、大量のデータを扱う場面では避けたい。今回の `ListNode.val` は `int` のままなので発生しない +> - **`StackOverflowException`**:再帰呼び出しが深くなりすぎて、スレッドに割り当てられたスタック領域を使い切ったときに発生する例外。C#ではこの例外は通常キャッチできず、プロセスが強制終了する + +--- + +## 4. 実装コード + +> 💡 コード全体の骨格: +> +> 1. 結果リストの先頭を仮に指し示す「ダミーノード」を用意する +> 2. `list1` と `list2` の先頭同士を比較し、小さい方を結果リストの末尾につなげる。つないだ方のリストを1つ進める +> 3. どちらかが `null` になるまで2を繰り返す +> 4. 片方が尽きたら、残った方(すでにソート済み)をそのまま丸ごとつなげる +> 5. ダミーノードの次(`dummy.next`)が本当の答えの先頭なので、それを返す + +```csharp +/** + * Definition for singly-linked list. + * public class ListNode { + * public int val; + * public ListNode next; + * public ListNode(int val=0, ListNode next=null) { + * this.val = val; + * this.next = next; + * } + * } + */ +public class ListNode { + public int val; + public ListNode next; + public ListNode(int val = 0, ListNode next = null) { + this.val = val; + this.next = next; + } +} + +public class Solution { + /// + /// 2つのソート済み連結リストを1つのソート済み連結リストにマージする。 + /// + /// 重要:list1 / list2 が null であることは異常値ではなく、 + /// 「空のリストが渡された」という正常な状態を表す。 + /// そのため ArgumentNullException 等は送出しない。 + /// + /// ダミーノード1個(ListNode(0))を除き新規リストノードは作成せず、既存ノードの next 参照を + /// つなぎ替える(スプライシング)ことでマージするため、 + /// 追加のオブジェクト生成はダミーノード1個のみで済む(空間計算量 O(1))。 + /// + /// 1つ目のソート済み連結リストの先頭(空リストなら null) + /// 2つ目のソート済み連結リストの先頭(空リストなら null) + /// マージ後のソート済み連結リストの先頭(両方が空リストなら null) + /// Time Complexity: O(n + m), Space Complexity: O(1) + public ListNode MergeTwoLists(ListNode list1, ListNode list2) { + // ダミーノード(番兵ノード)を用意する。 + // このノード自体には意味のある値は入れない(値は0で仮置き)。 + // こうすることで「結果リストがまだ空のとき、先頭をどう扱うか」 + // という特別な分岐を書かずに済む。 + ListNode dummy = new ListNode(0); + + // tail は「これまでにつなぎ終えた部分の末尾ノード」を指す変数。 + // ListNode は参照型なので、tail に代入されるのは + // オブジェクトの「参照(住所)」であり、ノードの中身がコピーされるわけではない。 + ListNode tail = dummy; + + // list1、list2 の両方にまだノードが残っている間、 + // 先頭同士の値を比較して小さい方を tail の後ろにつなげる。 + while (list1 != null && list2 != null) { + if (list1.val <= list2.val) { + // list1の先頭値のほうが小さい(同値含む)ので、 + // list1の現在ノードを結果リストの末尾につなげる + tail.next = list1; + // list1を1つ次に進める(つないだノードは処理済みなので進める) + list1 = list1.next; + } else { + // list2の先頭値のほうが小さいので、list2側をつなげる + tail.next = list2; + list2 = list2.next; + } + // tailを、たった今つないだノードの位置まで進める + tail = tail.next; + } + + // ループを抜けた時点で、list1 か list2 のどちらか(または両方)が + // null(尽きた状態)になっている。 + // 残っている側はすでにソート済みなので、1つずつ比較する必要はなく、 + // そのまま丸ごと tail の後ろにつなげてよい。 + // 三項演算子:list1が残っていればlist1を、そうでなければlist2をつなげる + tail.next = (list1 != null) ? list1 : list2; + + // dummyノード自体はデータを持たない仮のノードなので、 + // 実際の答えの先頭は dummy.next である + return dummy.next; + } +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> 入力:`list1 = [1,2,4]`、`list2 = [1,3,4]` +> +> ``` +> 初期状態: dummy.next = null +> tail = dummy +> list1 = [1,2,4], list2 = [1,3,4] +> +> Step 1: list1.val(1) <= list2.val(1) → true → list1の"1"をつなぐ +> 結果: dummy -> 1(from list1) +> list1は次の "2" に進む、tailは今つないだ "1" に進む +> +> Step 2: list1.val(2) vs list2.val(1) → list2の"1"のほうが小さい → list2をつなぐ +> 結果: dummy -> 1 -> 1(from list2) +> list2は次の "3" に進む、tailは今つないだ "1" に進む +> +> Step 3: list1.val(2) <= list2.val(3) → list1の"2"をつなぐ +> 結果: dummy -> 1 -> 1 -> 2 +> list1は次の "4" に進む +> +> Step 4: list1.val(4) vs list2.val(3) → list2の"3"のほうが小さい → list2をつなぐ +> 結果: dummy -> 1 -> 1 -> 2 -> 3 +> list2は次の "4" に進む +> +> Step 5: list1.val(4) <= list2.val(4) → list1の"4"をつなぐ(同値はlist1優先) +> 結果: dummy -> 1 -> 1 -> 2 -> 3 -> 4 +> list1はnullになる(尽きた) +> +> Step 6: while条件 (list1 != null && list2 != null) が false になりループ終了 +> list1 == null なので、残っている list2(= [4])を丸ごとつなぐ +> 結果: dummy -> 1 -> 1 -> 2 -> 3 -> 4 -> 4 +> +> Step 7: dummy.next を返す → [1,1,2,3,4,4](期待通りの出力) +> ``` + +> 💡 **`ListNode next = null` という初期値の書き方(初学者向け)** +> 与えられたコンストラクタ定義 `public ListNode(int val=0, ListNode next=null)` は、C#の**オプション引数(省略可能な引数)**という機能を使っています。 +> +> ``` +> new ListNode() → val=0, next=null(両方省略) +> new ListNode(5) → val=5, next=null(nextだけ省略) +> new ListNode(5, someNode) → val=5, next=someNode(両方指定) +> ``` +> +> これはPythonのデフォルト引数やTypeScriptの `param?: T` に近い機能ですが、C#では引数リストの末尾から順にしか省略できない、呼び出し側で `name: value` の形(名前付き引数)で指定順序を変えられる、といった細かい違いがあります。 + +> 📖 **このセクションで登場した用語** +> +> - **オプション引数**:`ListNode next = null` のように、引数にデフォルト値を指定することで、呼び出し時に省略可能にする機能 +> - **`ArgumentNullException`**:C#標準の例外クラス。引数として渡された参照が `null` であることが不正な場合に投げる。今回は正常な `null` 入力に対してこれを投げないよう注意している +> - **三項演算子(`条件 ? A : B`)**:`if-else` を1行で書くための構文。「条件がtrueならA、falseならB」という値を返す +> - **XMLドキュメントコメント(`/// `)**:C#標準のドキュメントコメント形式。Visual StudioのIntelliSenseで表示され、``/`` タグでパラメータや戻り値を説明できる + +--- + +## 5. 検証 + +> 💡 エッジケースとは「入力が空・最小値・最大値・重複あり」など、通常とは異なる境界的な入力のことです。境界値の確認は、アルゴリズムが"ふつうの入力"だけでなく"極端な入力"でも正しく動くかを確かめるために重要です。 + +- **境界値テスト(頭の中でのトレース確認)**: + - `list1=null, list2=null`(Example 2):`while` の条件が最初から偽(両方 `null`)になるため即座にループを抜ける。`tail.next = (list1 != null) ? list1 : list2` は `null` を代入する形になり、`dummy.next` も `null` のまま。結果は `[]` で正しい。 + - `list1=null, list2=[0]`(Example 3):`while` の条件が最初から偽(`list1` が `null`)になるため即座に終了。`tail.next = list2`(`[0]`)がそのままつながる。結果は `[0]` で正しい。 + - 片方が他方より大幅に長い場合(例:`list1=[1]`, `list2=[2,3,4,5]`):`list1` の1件を消費した時点で `list1` が `null` になり、ループを抜けて `list2` の残り全部(`[2,3,4,5]`)が丸ごとつながる。個別に比較し直す必要がないことが確認できる。 + - 同値が並ぶ場合(例:`list1=[1,1]`, `list2=[1,1]`):`<=` の比較により、同値のときは常に `list1` 側が先に採用される。安定した挙動になっており、順序の破綻はない。 + +- **null安全性の確認**:`list1.val` や `list2.val` へのアクセスは、すべて `while (list1 != null && list2 != null)` の条件を通過した内側でのみ行われています。ループを抜けた後は `list1.val` や `list2.val` に一切アクセスせず、`(list1 != null) ? list1 : list2` という参照そのものの比較のみで完結しているため、`NullReferenceException`(`null` の参照に対してメンバーアクセスを行おうとした際に発生する実行時例外)が発生する余地はありません。 + +> 📖 **このセクションで登場した用語** +> +> - **エッジケース**:空のリスト・要素1つ・最大サイズ入力など、境界的な条件のこと +> - **境界値テスト**:エッジケースに対してもアルゴリズムが正しく動くかを確かめること +> - **`NullReferenceException`**:`null` の参照に対してメンバー(フィールド・メソッド等)へアクセスしようとした際に発生するC#の実行時例外。JavaやDartの `NullPointerException` に相当する + +--- + +## 6. 制約条件の確認 + +- 外部ライブラリ(NuGetパッケージ)は使用していません(`System` 名前空間の標準機能のみで完結しています)。 +- 追加のオブジェクト生成はダミーノード1個のみで、それ以外は既存ノードの参照のつなぎ替えのみで完結しています。 +- クラス名 `Solution`・`ListNode` はパスカルケース、メソッド名 `MergeTwoLists` もパスカルケース(C#の慣習ではpublicメソッドはパスカルケース)、ローカル変数 `dummy`/`tail` はキャメルケースというC#標準の命名規則に従っています。 + +**計算量まとめ**:時間計算量 O(n+m)、空間計算量 O(1)(n, m はそれぞれ list1, list2 のノード数)。 diff --git a/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-C++.md b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-C++.md new file mode 100644 index 00000000..7fa6715c --- /dev/null +++ b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-C++.md @@ -0,0 +1,253 @@ +# LeetCode 21: Merge Two Sorted Lists(C++版) + +## 1. 問題の分析 + +> 💡 この問題は、一言で言うと「すでに整列された2つの連結リストを、新しいノードを作らずに1つの整列済みリストへ“縫い合わせる”問題」です。 + +**C++で解く際に特に気をつけるべき点**を先にまとめます。C++の `ListNode*` は**生ポインタ(raw pointer)**であり、Rustのような所有権システムやガベージコレクションによる自動管理は一切ありません。つまり「誰がこのメモリを解放する責任を持つか」をプログラマ自身が意識する必要があります。ただし今回のアルゴリズムは**新しいノードを一切 `new` せず、既存ノードの `next` ポインタをつなぎ替えるだけ**なので、幸いにも「解放し忘れ」や「二重解放」のリスク自体が発生しません(LeetCode環境ではノードの生成・破棄はテストハーネス側が管理するため、ユーザーコードで `delete` する必要はないという前提です)。もう1つの注意点は、**`nullptr` は異常な入力ではなく「空リスト」を表す正常な状態**であるという点です。C++でありがちな「ポインタが `nullptr` なら即座に例外を投げる」という防御的な設計をそのまま適用すると、Example 2(`list1 = [], list2 = []`)のような正しい入力を誤って弾いてしまいます。 + +**競技プログラミング視点での分析** + +- 実行速度を最優先するなら、各ノードを1回ずつ見るだけで済む**反復(ループ)処理**が最速。時間計算量は O(n+m)(n, mはそれぞれのリスト長)で、関数呼び出しのオーバーヘッド(=余分にかかる処理コスト)が発生しない。 +- メモリ使用量の最小化方針としては、**新しいノードを `new` で一切生成せず、既存ノードの `next` ポインタだけを付け替える**(この操作を「スプライシング(splicing、つなぎ替え)」と呼ぶ)。`std::vector` にコピーしてソートし直すような方法は、ノードのコピーコストがかかるうえ、リストがすでにソート済みという情報を無駄にする。 + +**業務開発視点での分析** + +- 型安全性の面では、`ListNode*` はC++の生ポインタなので、コンパイラは「このポインタが `nullptr` かどうか」をコンパイル時には検証してくれません。JavaやTypeScriptの `Optional` / 型ガードのような、コンパイラによる自動的な絞り込みは期待できないため、**アクセス前に必ず `nullptr` チェックを自分で書く**という規律がC++では特に重要になります。 +- エラーハンドリングとして、この関数シグネチャ(`ListNode* mergeTwoLists(ListNode*, ListNode*)`)には例外を投げる余地がありますが、`nullptr` 入力自体は正常なので例外を投げるべきではありません。 + +**C++特有の考慮点** + +- **RAII(Resource Acquisition Is Initialization、=リソースの確保と解放をオブジェクトの生存期間に紐づける仕組み)**の観点では、今回はヒープ上のノードを1つも新規生成しないため、`std::unique_ptr` のようなスマートポインタを導入する余地はほぼありません(LeetCode標準の `ListNode*` 生ポインタのシグネチャに従う必要があるという制約もあります)。 +- `const` 正しさ(const correctness、=値やポインタが変更されないことをコンパイラに保証させる設計)の観点では、`tail` や `list1` / `list2` のようにループ内で再代入が必要な変数には `const` を付けられません。一方で、比較のためだけに値を読む場面では `const` 参照を使うことで意図を明確化できます。 +- 参照(`&`)とポインタ(`*`)の使い分け:`ListNode*` は「`nullptr` を取りうる」ことを表現するため、リストの終端や空リストを表現できる**ポインタ**である必要があります。C++の参照(`ListNode&`)は `null` を表現できないため、この用途には使えません。 + +> 📖 **このセクションで登場した用語**(例) +> +> - **生ポインタ(raw pointer)**:`T*` の形で書かれる、C++の最も基本的なポインタ型。スマートポインタと違い、メモリの所有権や解放責任をコンパイラが管理してくれない +> - **RAII**:リソース(メモリ・ファイルハンドルなど)の確保と解放を、オブジェクトのコンストラクタ・デストラクタに紐づける設計思想。C++のメモリ安全性の根幹をなす考え方 +> - **`const` 正しさ**:値やポインタが変更されないことをコンパイラに保証させる設計。意図しない書き換えをコンパイル時に防げる +> - **スプライシング(splicing)**:新しいノードを作らず、既存ノードの参照(ポインタ)をつなぎ替えることでデータ構造を組み替える操作 + +--- + +## 2. アルゴリズム比較 + +> 💡 同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」、そしてC++らしい実装コストを比べて最適なものを選びます。 + +| アプローチ | 時間計算量 | 空間計算量 | C++実装コスト | 型安全性 | 可読性 | 備考 | +| ------------------------------------------------- | ----------------- | ---------- | ------------- | -------- | ------ | -------------------------------------------------------------- | +| 方法A:反復(ダミーヘッド + 2ポインタ) | O(n+m) | O(1) | 低 | 高 | 高 | 新規ノード生成なし。推奨 | +| 方法B:再帰(先頭同士を比較して自己呼び出し) | O(n+m) | O(n+m) | 低 | 高 | 高 | コールスタックがリスト長に比例して積まれる | +| 方法C:両リストを`std::vector`に集めて`std::sort` | O((n+m) log(n+m)) | O(n+m) | 中 | 中 | 中 | すでにソート済みという前提を無駄にする。値のコピーコストも発生 | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n+m)`:2つの入力の合計サイズに比例して手間が増える(線形) +> - `O((n+m) log(n+m))`:ソートに典型的な計算量。入力が増えるとやや急激に手間が増える + +**C++特有の観点からの補足**: + +- 方法A(反復)は `new` を一切呼ばないため、ヒープアロケーション(=ヒープ上にメモリを新たに確保する操作)はゼロ回です。 +- 方法B(再帰)はコンパイラが末尾呼び出し最適化(tail call optimization)を必ず行うとは限らないため、C++では特に「再帰が深くなるとスタックオーバーフローの危険がある」という前提で設計すべきです。今回の制約(最大50ノード)では問題になりませんが、業務コードとしては脆弱な設計です。 +- 方法C(ソート)は `ListNode` を `std::vector` などにコピーする過程で、元の連結リストのポインタ構造を一度解体して再構築する必要があり、実装が複雑になるうえ、値のコピーコストもかかります。 + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して、処理にかかる手間がどう増えるかの目安 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安 +> - **末尾呼び出し最適化(TCO)**:関数の最後の処理が自分自身の再帰呼び出しである場合、コンパイラがそれをループに変換してスタック消費を防ぐ最適化。C++では保証されない + +--- + +## 3. 選択したアルゴリズムと理由 + +- **選択したアプローチ**:方法A(ダミーヘッドを使った反復処理) +- **理由**: + - **計算量的な優位性**:方法B(再帰)と同じ O(n+m) の時間計算量でありながら、空間計算量は O(1) で済む(方法Bはコールスタックに O(n+m) 消費する)。 + - **C++環境でのメモリ安全性**:`new` を一切呼ばないため、`delete` の呼び忘れによるメモリリークや、二重解放(同じメモリを2回解放してしまうバグ)のリスクがそもそも発生しません。C++では「メモリ管理はプログラマの責任」であるからこそ、**そもそもアロケーションを行わない設計**が最も安全な選択肢になります。 + - **保守性・可読性**:ループ1本で完結し、条件分岐もシンプル。デバッガでも1行ずつ追いやすい。 + - **方法Cを選ばなかった理由**:わざわざ `std::vector` に集めてソートするのは、「すでにソート済み」という問題の前提を無視した回り道であり、要素の収集と `std::sort` によるソートを含めた全体の時間計算量が O((n+m) log(n+m)) となるうえ、空間計算量も O(n+m) と余計にメモリを使う。 +- **C++特有の最適化ポイント**: + - ヒープアロケーションを一切行わないため、`malloc`/`new` に伴うメモリ確保のオーバーヘッドやヒープの断片化(フラグメンテーション)を回避できる。 + - `list1->val <= list2->val` のような比較は `int` 同士の単純な比較であり、値のコピーやボクシングのようなコストは一切発生しない(C++には元々そうした概念がない)。 + - `ListNode*` はスタック上に置かれる変数(`tail`, `list1`, `list2` 自体)であり、これらの変数自体はヒープには逃げない。ポインタが指す**先**(既存のノード)だけがヒープ上にある。 + +> 📖 **このセクションで登場した用語** +> +> - **メモリリーク**:確保したメモリを解放し忘れ、プログラムが使い続けるメモリ量が徐々に増えていく不具合。今回のアルゴリズムは新規確保自体を行わないため発生しない +> - **二重解放(double free)**:同じメモリ領域を誤って2回解放してしまうバグ。今回は `delete` を一切呼ばないため発生しない +> - **ヒープの断片化(フラグメンテーション)**:メモリの確保・解放を繰り返すことで、ヒープ上に細切れの空き領域が増えてしまう現象 + +--- + +## 4. コード実装 + +> 💡 コード全体の骨格: +> +> 1. 結果リストの先頭を仮に指し示す「ダミーノード」をスタック上に1つ用意する +> 2. `list1` と `list2` の先頭同士を比較し、小さい方を結果リストの末尾につなげる。つないだ方のリストを1つ進める +> 3. どちらかが `nullptr` になるまで2を繰り返す +> 4. 片方が尽きたら、残った方(すでにソート済み)をそのまま丸ごとつなげる +> 5. ダミーノードの次(`dummy.next`)が本当の答えの先頭なので、それを返す + +```cpp +/** + * Definition for singly-linked list. + * struct ListNode { + * int val; + * ListNode *next; + * ListNode() : val(0), next(nullptr) {} + * ListNode(int x) : val(x), next(nullptr) {} + * ListNode(int x, ListNode *next) : val(x), next(next) {} + * }; + */ +class Solution { +public: + /** + * 2つのソート済み連結リストを1つのソート済み連結リストにマージする。 + * + * 重要:list1 / list2 が nullptr であることは異常値ではなく、 + * 「空のリストが渡された」という正常な状態を表す。 + * そのため例外は投げない。 + * + * 新しいノードは一切 new せず、既存ノードの next ポインタを + * つなぎ替える(スプライシング)ことでマージするため、 + * 追加のヒープアロケーションは発生しない(空間計算量 O(1))。 + * + * 計算量: Time O(n + m), Space O(1) + * (n, m はそれぞれ list1, list2 のノード数) + * + * @param list1 1つ目のソート済み連結リストの先頭ポインタ(空リストなら nullptr) + * @param list2 2つ目のソート済み連結リストの先頭ポインタ(空リストなら nullptr) + * @return マージ後のソート済み連結リストの先頭ポインタ(両方が空リストなら nullptr) + */ + ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) { + // ダミーノード(番兵ノード)をスタック上のローカル変数として用意する。 + // このノード自体には意味のある値は入れない(値は0で仮置き)。 + // "スタック上に"と書いた理由:dummy は ListNode 型の実体(オブジェクト) + // そのものであり、ポインタではない。関数を抜けると自動的に破棄される。 + // これにより new/delete を1組も使わずにダミーノードを実現できる。 + ListNode dummy(0); + + // tail は「これまでにつなぎ終えた部分の末尾ノード」を指すポインタ。 + // 最初は dummy 自身のアドレスを指しておく(& でアドレスを取得)。 + ListNode* tail = &dummy; + + // list1、list2 の両方にまだノードが残っている間、 + // 先頭同士の値を比較して小さい方を tail の後ろにつなげる。 + while (list1 != nullptr && list2 != nullptr) { + if (list1->val <= list2->val) { + // list1の先頭値のほうが小さい(同値含む)ので、 + // list1の現在ノードを結果リストの末尾につなげる + tail->next = list1; + // list1を1つ次に進める(つないだノードは処理済みなので進める) + list1 = list1->next; + } else { + // list2の先頭値のほうが小さいので、list2側をつなげる + tail->next = list2; + list2 = list2->next; + } + // tailを、たった今つないだノードの位置まで進める + tail = tail->next; + } + + // ループを抜けた時点で、list1 か list2 のどちらか(または両方)が + // nullptr(尽きた状態)になっている。 + // 残っている側はすでにソート済みなので、1つずつ比較する必要はなく、 + // そのまま丸ごと tail の後ろにつなげてよい。 + // 三項演算子:list1が残っていればlist1を、そうでなければlist2をつなげる + tail->next = (list1 != nullptr) ? list1 : list2; + + // dummy自体はスタック上のローカル変数なので、関数を抜けると破棄されるが、 + // dummy.next が指す先(ヒープ上の実ノード)は破棄されない。 + // そのため dummy.next を返せば、呼び出し元は安全にそのポインタを使い続けられる。 + return dummy.next; + } +}; +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> 入力:`list1 = [1,2,4]`、`list2 = [1,3,4]` +> +> ``` +> 初期状態: dummy.next = nullptr +> tail = &dummy +> list1 = [1,2,4], list2 = [1,3,4] +> +> Step 1: list1->val(1) <= list2->val(1) → true → list1の"1"をつなぐ +> 結果: dummy -> 1(from list1) +> list1は次の "2" に進む、tailは今つないだ "1" に進む +> +> Step 2: list1->val(2) vs list2->val(1) → list2の"1"のほうが小さい → list2をつなぐ +> 結果: dummy -> 1 -> 1(from list2) +> list2は次の "3" に進む、tailは今つないだ "1" に進む +> +> Step 3: list1->val(2) <= list2->val(3) → list1の"2"をつなぐ +> 結果: dummy -> 1 -> 1 -> 2 +> list1は次の "4" に進む +> +> Step 4: list1->val(4) vs list2->val(3) → list2の"3"のほうが小さい → list2をつなぐ +> 結果: dummy -> 1 -> 1 -> 2 -> 3 +> list2は次の "4" に進む +> +> Step 5: list1->val(4) <= list2->val(4) → list1の"4"をつなぐ(同値はlist1優先) +> 結果: dummy -> 1 -> 1 -> 2 -> 3 -> 4 +> list1はnullptrになる(尽きた) +> +> Step 6: while条件 (list1 != nullptr && list2 != nullptr) が false になりループ終了 +> list1 == nullptr なので、残っている list2(= [4])を丸ごとつなぐ +> 結果: dummy -> 1 -> 1 -> 2 -> 3 -> 4 -> 4 +> +> Step 7: dummy.next を返す → [1,1,2,3,4,4](期待通りの出力) +> ``` + +> 💡 **`ListNode dummy(0)` と `ListNode* dummy = new ListNode(0)` の違い(初学者向け)** +> 多くのC++解説記事では `ListNode* dummy = new ListNode(0);` のように書き、最後に `dummy->next` を返して `delete` は省略する(あるいは呼び忘れる)例が見られます。しかし今回のコードでは `ListNode dummy(0);` と書き、**スタック上に直接オブジェクトを確保**しています。 +> +> ``` +> new を使う場合: ヒープに確保 → delete しないとメモリリーク → 呼び忘れやすい +> スタック確保の場合: 関数を抜けると自動的に破棄 → delete が不要 → リークの心配がない +> ``` +> +> `dummy` 自身(値0のノード1個分の領域)は関数終了時に自動的に破棄されますが、`dummy.next` が指しているのは**引数として渡された既存のノード**(呼び出し元がすでにヒープ上に確保済み)なので、`dummy` が破棄されても、それらのノードには一切影響しません。これはRAII(=リソースの確保と解放をオブジェクトの生存期間に紐づける仕組み)の恩恵の一例で、「使わなくなったら自動的に片付く」というC++の重要な設計原則です。 + +> 📖 **このセクションで登場した用語** +> +> - **`nullptr`**:C++11以降で導入された、型安全なヌルポインタリテラル。従来の `NULL`(実体はただの `0`)と異なり、整数型との誤った暗黙変換をコンパイラが防いでくれる +> - **ダミーノード(番兵ノード)**:リストの先頭に、実際のデータを持たない仮のノードを1つ置いておくテクニック。「リストが空かどうか」で処理を分岐させる必要がなくなる +> - **三項演算子(`条件 ? A : B`)**:`if-else` を1行で書くための構文。「条件がtrueならA、falseならB」という値を返す +> - **アロー演算子 `->`**:ポインタが指す先のオブジェクトのメンバにアクセスするための演算子。`(*ptr).member` の糖衣構文(=複雑な処理を短く書けるようにした構文) + +--- + +## 5. 検証 + +> 💡 エッジケースとは「入力が空・最小値・最大値・重複あり」など、通常とは異なる境界的な入力のことです。境界値の確認は、アルゴリズムが"ふつうの入力"だけでなく"極端な入力"でも正しく動くかを確かめるために重要です。 + +- **境界値テスト(頭の中でのトレース確認)**: + - `list1=nullptr, list2=nullptr`(Example 2):`while` の条件が最初から偽(両方 `nullptr`)になるため即座にループを抜ける。`tail->next = (list1 != nullptr) ? list1 : list2` は `nullptr` を代入する形になり、`dummy.next` も `nullptr` のまま。結果は `[]` で正しい。 + - `list1=nullptr, list2=[0]`(Example 3):`while` の条件が最初から偽(`list1` が `nullptr`)になるため即座に終了。`tail->next = list2`(`[0]`)がそのままつながる。結果は `[0]` で正しい。 + - 片方が他方より大幅に長い場合(例:`list1=[1]`, `list2=[2,3,4,5]`):`list1` の1件を消費した時点で `list1` が `nullptr` になり、ループを抜けて `list2` の残り全部(`[2,3,4,5]`)が丸ごとつながる。個別に比較し直す必要がないことが確認できる。 + - 同値が並ぶ場合(例:`list1=[1,1]`, `list2=[1,1]`):`<=` の比較により、同値のときは常に `list1` 側が先に採用される。安定した挙動になっており、順序の破綻はない。 + +- **メモリ安全性の確認**:`new` / `delete` を1組も使用していないため、メモリリークや二重解放のリスクは構造的に存在しません。`dummy` はスタック上のローカルオブジェクトであり、`dummy.next` が指す既存ノードとは別物として自動的に管理されます。すべてのポインタアクセス(`list1->val` など)は、その直前の `while` 条件または三項演算子内で `!= nullptr` チェックを通過した後にのみ行われるため、ヌルポインタ参照外し(null pointer dereference)は発生しません。 + +> 📖 **このセクションで登場した用語** +> +> - **エッジケース**:空のリスト・要素1つ・最大サイズ入力など、境界的な条件のこと +> - **境界値テスト**:エッジケースに対してもアルゴリズムが正しく動くかを確かめること +> - **ヌルポインタ参照外し(null pointer dereference)**:`nullptr` が指す先にアクセスしようとして発生する未定義動作(クラッシュの典型的な原因)。今回はすべてのアクセス箇所で事前に `nullptr` チェックを行っているため発生しない + +--- + +## 6. 制約条件の確認 + +- 外部ライブラリは使用していません(`<...>` の追加インクルードも不要で、標準の `ListNode` 定義のみで完結しています)。 +- `new` / `delete` を一切使用していないため、メモリ使用量は入力サイズによらず一定(O(1)の補助領域のみ)で、メモリリークの可能性も構造的にありません。 +- すべてのポインタアクセスは `nullptr` チェック済みの範囲内でのみ行っており、未定義動作(undefined behavior)のリスクはありません。 +- 命名規則はLeetCode標準のシグネチャ(`mergeTwoLists`)にそのまま従っています。 + +**計算量まとめ**:時間計算量 O(n+m)、空間計算量 O(1)(n, m はそれぞれ list1, list2 のノード数。追加のヒープアロケーションは発生しない)。 diff --git a/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-C.md b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-C.md new file mode 100644 index 00000000..6cb8c388 --- /dev/null +++ b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-C.md @@ -0,0 +1,258 @@ +# LeetCode 21: Merge Two Sorted Lists(C版) + +## 1. 問題の分析 + +> 💡 この問題は、一言で言うと「すでに整列された2つの連結リストを、新しいノードを作らずに1つの整列済みリストへ“縫い合わせる”問題」です。 + +**Cで解く際に特に気をつけるべき点**を先にまとめます。Cには、Java/C#のような「クラス」も、C++のようなスマートポインタも、Rustのような所有権システムも一切存在しません。あるのは**構造体(`struct`)と生ポインタ(raw pointer)だけ**です。つまり「メモリを誰が管理するか」「`NULL` チェックを漏らさないか」は、コンパイラの助けをほとんど借りずに**すべてプログラマ自身の責任で保証する**必要があります。もう1つの重要な注意点は、この問題では**新しいノードを `malloc()` で確保する必要が一切ない**という点です。Cにおける `malloc()` は、確保したら必ず対応する `free()` を呼ぶ責任が発生し、呼び忘れるとメモリリークに、二重に呼ぶと二重解放(未定義動作)になります。今回のアルゴリズムは既存ノードの `next` ポインタをつなぎ替えるだけなので、`malloc`/`free` の管理自体が最初から不要という、Cにとって非常に「相性の良い」問題です。また、`list1` や `list2` が `NULL` であることは異常なエラー値ではなく「空リスト」を表す正常な状態なので、Cでよくある「`NULL` なら即座に異常終了させる」という発想はここでは当てはまりません。 + +**競技プログラミング視点での分析** + +- 実行速度を最優先するなら、各ノードを1回ずつ見るだけで済む**反復(ループ)処理**が最速。時間計算量は O(n+m)(n, mはそれぞれのリスト長)で、関数呼び出しのオーバーヘッド(=余分にかかる処理コスト)が発生しない。 +- メモリ使用量の最小化方針としては、**新しいノードを `malloc` で一切生成せず、既存ノードの `next` ポインタだけを付け替える**(この操作を「スプライシング(splicing、つなぎ替え)」と呼ぶ)。配列にコピーしてソートし直すような方法は、リストがすでにソート済みという情報を無駄にしてしまう。 + +**業務開発視点での分析** + +- 型安全性の面では、Cの型システムはJava/TypeScript/Rustと比べると非常に緩く、`struct ListNode*` が `NULL` かどうかをコンパイラが強制的にチェックしてくれることは一切ありません。**アクセス前に必ず `NULL` チェックを自分で書く**という規律こそが、Cにおける唯一の安全策です。 +- エラーハンドリングとして、C言語には例外機構(`try`/`catch`)が存在しません。エラーは戻り値や `errno` で表現するのが慣習ですが、今回の関数シグネチャ `struct ListNode* mergeTwoLists(...)` はそもそも失敗しうる処理ではなく(`NULL` 入力は正常系)、追加のエラー表現は不要です。 + +**C特有の考慮点** + +- **手動メモリ管理**:`malloc`/`free` を一切使わないため、メモリリークや二重解放(double free、=同じメモリ領域を誤って2回解放してしまうバグ)のリスクはこのアルゴリズムには存在しません。 +- **未定義動作(undefined behavior, UB)の回避**:Cコンパイラは配列範囲外アクセスや `NULL` ポインタの参照外しを実行時にチェックしてくれません。これらは「コンパイルは通るが実行すると何が起きるか規格上保証されない」動作であり、`list1->val` にアクセスする前には必ず `list1 != NULL` を確認する必要があります。 +- **構造体はポインタ経由で操作する**:`struct ListNode` はJavaの `class` のような参照型ではなく、C言語では値型として扱われます。しかし今回は関数シグネチャがすでに `struct ListNode*`(ポインタ)で統一されているため、構造体そのもののコピーは発生せず、Javaの参照型やGoのポインタ型と同じ感覚で「つなぎ替え」が行えます。 + +> 📖 **このセクションで登場した用語**(例) +> +> - **未定義動作(Undefined Behavior, UB)**:C言語の規格が結果を保証しない操作。`NULL` の参照外しや配列範囲外アクセスなどが該当し、実行するとクラッシュしたり、一見動くように見えて後で問題を起こしたりする +> - **二重解放(double free)**:同じメモリ領域を誤って2回 `free()` してしまうバグ。今回は `malloc`/`free` を一切使わないため発生しない +> - **`NULL`**:`` などで定義されている、ポインタが「何も指していない」ことを表すマクロ。実体はしばしば `((void*)0)` として定義される +> - **メモリリーク**:`malloc()` で確保したメモリを `free()` し忘れ、プログラムが使い続けるメモリ量が徐々に増えていく不具合 + +--- + +## 2. アルゴリズムアプローチ比較 + +> 💡 同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」、そしてC言語らしい実装コストを比べて最適なものを選びます。 + +| アプローチ | 時間計算量 | 空間計算量 | C実装コスト | 安全性 | 可読性 | 備考 | +| --------------------------------------------- | ----------------- | ---------- | ----------- | ------ | ------ | -------------------------------------------------- | +| 方法A:反復(ダミーヘッド + 2ポインタ) | O(n+m) | O(1) | 低 | 高 | 高 | `malloc`不要。推奨 | +| 方法B:再帰(先頭同士を比較して自己呼び出し) | O(n+m) | O(n+m) | 低 | 中 | 高 | コールスタックがリスト長に比例して積まれる | +| 方法C:両リストを配列にコピーして`qsort` | O((n+m) log(n+m)) | O(n+m) | 高 | 中 | 中 | `malloc`による動的配列確保が必要になり実装が複雑化 | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n+m)`:2つの入力の合計サイズに比例して手間が増える(線形) +> - `O((n+m) log(n+m))`:ソートに典型的な計算量。入力が増えるとやや急激に手間が増える + +**C特有の観点からの補足**: + +- 方法A(反復)は `malloc` を一切呼ばないため、ヒープアロケーション(=ヒープ上にメモリを新たに確保する操作)はゼロ回です。エラーチェック(`malloc` の戻り値が `NULL` でないかの確認)自体が不要になるという点も、Cにおいては地味に大きなメリットです。 +- 方法B(再帰)はCコンパイラが末尾呼び出し最適化(tail call optimization)を行うかどうかは規格で保証されておらず、最適化レベルやコンパイラの実装依存です。入力サイズが大きくなると `Segmentation fault`(セグメンテーション違反、=許可されていないメモリ領域へアクセスしようとしてOSに強制終了させられる現象)を引き起こすリスクがあります。 +- 方法C(配列にコピーしてソート)は、可変長のノード数に対応するために `malloc` で配列を動的確保する必要があり、確保サイズの計算・解放処理・エラーチェックといった付随コードが大幅に増えます。加えて、値をコピーした配列から再度連結リストを構築し直す処理も必要になり、実装コストが最も高くなります。 + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して、処理にかかる手間がどう増えるかの目安 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安 +> - **セグメンテーション違反(Segmentation fault)**:プログラムが許可されていないメモリ領域にアクセスしようとしたとき、OSがプロセスを強制終了させる現象。`NULL` ポインタの参照外しやスタックオーバーフローの典型的な結果 +> - **末尾呼び出し最適化(TCO)**:関数の最後の処理が自分自身の再帰呼び出しである場合、コンパイラがそれをループに変換してスタック消費を防ぐ最適化。C言語では規格上保証されない + +--- + +## 3. 選択したアルゴリズムと理由 + +- **選択したアプローチ**:方法A(ダミーヘッドを使った反復処理) +- **理由**: + - **計算量的な優位性**:方法B(再帰)と同じ O(n+m) の時間計算量でありながら、空間計算量は O(1) で済む(方法Bはコールスタックに O(n+m) 消費する)。 + - **Cにおけるメモリ安全性**:`malloc`/`free` を一切呼ばないため、メモリリークや二重解放のリスクがそもそも発生しません。C言語では「メモリ管理はすべてプログラマの責任」であるからこそ、**そもそもアロケーションを行わない設計**が最も安全な選択肢になります。 + - **保守性・可読性**:ループ1本で完結し、`goto` や複雑なエラー処理を挟む必要がなく、デバッガでも1行ずつ追いやすい。 + - **方法Cを選ばなかった理由**:わざわざ配列にコピーしてソートするのは、「すでにソート済み」という問題の前提を無視した回り道であり、要素の収集と `qsort` によるソートを含めた全体の時間計算量が O((n+m) log(n+m)) となるうえ、`malloc`/`free` の管理コストや値コピーのコストも余計にかかる。 +- **C特有の最適化ポイント**: + - ヒープアロケーションを一切行わないため、`malloc` に伴うメモリ確保のオーバーヘッドやヒープの断片化(フラグメンテーション)を回避できる。 + - `list1->val <= list2->val` の比較は `int` 同士の単純な比較であり、余計な型変換や間接参照のコストがない。 + - ローカル変数として宣言した `dummy` はスタック上に確保され、関数を抜けると自動的に破棄される。ヒープに逃げる必要がない値は極力スタックに置く、というのはCにおいても速度・安全性の両面で有利な設計です。 + +> 📖 **このセクションで登場した用語** +> +> - **ヒープの断片化(フラグメンテーション)**:メモリの確保・解放を繰り返すことで、ヒープ上に細切れの空き領域が増えてしまう現象 +> - **スタック**:関数の呼び出しに使われる高速なメモリ領域。関数を抜けると自動的に解放される。サイズが動的に変わらない値の置き場に向く + +--- + +## 4. 実装コード + +> 💡 コード全体の骨格: +> +> 1. 結果リストの先頭を仮に指し示す「ダミーノード」をスタック上のローカル変数として用意する +> 2. `list1` と `list2` の先頭同士を比較し、小さい方を結果リストの末尾につなげる。つないだ方のリストを1つ進める +> 3. どちらかが `NULL` になるまで2を繰り返す +> 4. 片方が尽きたら、残った方(すでにソート済み)をそのまま丸ごとつなげる +> 5. ダミーノードの次(`dummy.next`)が本当の答えの先頭なので、それを返す + +```c +#include // NULL の定義元(LeetCode環境では標準でインクルードされる) + +/** + * Definition for singly-linked list. + * struct ListNode { + * int val; + * struct ListNode *next; + * }; + */ + +/* + * 2つのソート済み連結リストを1つのソート済み連結リストにマージする。 + * + * 重要:list1 / list2 が NULL であることは異常値ではなく、 + * 「空のリストが渡された」という正常な状態を表す。 + * そのため異常終了やエラーコードは返さない。 + * + * 新しいノードは malloc で一切生成せず、既存ノードの next ポインタを + * つなぎ替える(スプライシング)ことでマージするため、 + * ヒープアロケーションは発生しない(空間計算量 O(1))。 + * + * 計算量: Time O(n + m), Space O(1) + * (n, m はそれぞれ list1, list2 のノード数) + * + * 引数: + * list1 - 1つ目のソート済み連結リストの先頭ポインタ(空リストなら NULL) + * list2 - 2つ目のソート済み連結リストの先頭ポインタ(空リストなら NULL) + * 戻り値: + * マージ後のソート済み連結リストの先頭ポインタ(両方が空リストなら NULL) + */ +struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) { + // ダミーノード(番兵ノード)をスタック上のローカル変数として用意する。 + // ポインタ(struct ListNode*)ではなく実体(struct ListNode)として + // 宣言している点に注意。これにより malloc / free の管理が一切不要になる。 + // このノード自体には意味のある値は入れない(値は0で仮置き)。 + // なぜスタック確保で問題ないか: + // 関数の最後で返すのは &dummy(dummy自身のアドレス)ではなく dummy.next + // (呼び出し元がすでに確保済みの実ノードを指すポインタ)なので、 + // dummy が関数終了時にスタックから消えても、返すポインタは無効にならない。 + struct ListNode dummy; + dummy.next = NULL; + + // tail は「これまでにつなぎ終えた部分の末尾ノード」を指すポインタ。 + // 最初は dummy 自身のアドレスを指しておく(& でアドレスを取得)。 + struct ListNode* tail = &dummy; + + // list1、list2の両方にまだノードが残っている間、 + // 先頭同士の値を比較して小さい方を tail の後ろにつなげる。 + while (list1 != NULL && list2 != NULL) { + if (list1->val <= list2->val) { + // list1の先頭値のほうが小さい(同値含む)ので、 + // list1の現在ノードを結果リストの末尾につなげる + tail->next = list1; + // list1を1つ次に進める(つないだノードは処理済みなので進める) + list1 = list1->next; + } else { + // list2の先頭値のほうが小さいので、list2側をつなげる + tail->next = list2; + list2 = list2->next; + } + // tailを、たった今つないだノードの位置まで進める + tail = tail->next; + } + + // ループを抜けた時点で、list1 か list2 のどちらか(または両方)が + // NULL(尽きた状態)になっている。 + // 残っている側はすでにソート済みなので、1つずつ比較する必要はなく、 + // そのまま丸ごと tail の後ろにつなげてよい。 + // 三項演算子:list1が残っていればlist1を、そうでなければlist2をつなげる + tail->next = (list1 != NULL) ? list1 : list2; + + // dummy自体はスタック上のローカル変数なので、関数を抜けると + // メモリ領域としては再利用可能になるが、dummy.next が指す先 + // (ヒープ or 呼び出し元が管理する実ノード)はそのまま有効なので、 + // dummy.next の値(アドレス)だけを返せば安全に使い続けられる。 + return dummy.next; +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> 入力:`list1 = [1,2,4]`、`list2 = [1,3,4]` +> +> ``` +> 初期状態: dummy.next = NULL +> tail = &dummy +> list1 = [1,2,4], list2 = [1,3,4] +> +> Step 1: list1->val(1) <= list2->val(1) → true → list1の"1"をつなぐ +> 結果: dummy -> 1(from list1) +> list1は次の "2" に進む、tailは今つないだ "1" に進む +> +> Step 2: list1->val(2) vs list2->val(1) → list2の"1"のほうが小さい → list2をつなぐ +> 結果: dummy -> 1 -> 1(from list2) +> list2は次の "3" に進む、tailは今つないだ "1" に進む +> +> Step 3: list1->val(2) <= list2->val(3) → list1の"2"をつなぐ +> 結果: dummy -> 1 -> 1 -> 2 +> list1は次の "4" に進む +> +> Step 4: list1->val(4) vs list2->val(3) → list2の"3"のほうが小さい → list2をつなぐ +> 結果: dummy -> 1 -> 1 -> 2 -> 3 +> list2は次の "4" に進む +> +> Step 5: list1->val(4) <= list2->val(4) → list1の"4"をつなぐ(同値はlist1優先) +> 結果: dummy -> 1 -> 1 -> 2 -> 3 -> 4 +> list1はNULLになる(尽きた) +> +> Step 6: while条件 (list1 != NULL && list2 != NULL) が false になりループ終了 +> list1 == NULL なので、残っている list2(= [4])を丸ごとつなぐ +> 結果: dummy -> 1 -> 1 -> 2 -> 3 -> 4 -> 4 +> +> Step 7: dummy.next を返す → [1,1,2,3,4,4](期待通りの出力) +> ``` + +> 💡 **なぜ `struct ListNode* dummy = malloc(...)` にしなかったのか(初学者向け)** +> 多くのC言語のLeetCode解説記事では、ダミーノードを `malloc` で確保し、最後に `free(dummy)` を呼ぶ(あるいは呼び忘れる)例が見られます。しかし今回のコードでは `struct ListNode dummy;` とスタック上に直接確保しています。 +> +> ``` +> malloc を使う場合: ヒープに確保 → free しないとメモリリーク → 呼び忘れやすい +> スタック確保の場合: 関数を抜けると自動的に領域が再利用可能になる → free が不要 +> ``` +> +> `dummy` 自身(値0のノード1個分の領域)は関数終了時に暗黙的に「スタックから消える」だけですが、`dummy.next` が指しているのは**引数として渡された既存のノード**(呼び出し元がすでに確保済み)なので、`dummy` がスタックから消えても、それらのノードには一切影響しません。これにより `malloc`/`free` のペア管理を1組も書かずに済み、メモリリークや解放忘れのリスクをゼロにできます。 + +> 📖 **このセクションで登場した用語** +> +> - **`NULL`**:ポインタが「何も指していない」ことを表すマクロ。`` などで定義されている +> - **ダミーノード(番兵ノード)**:リストの先頭に、実際のデータを持たない仮のノードを1つ置いておくテクニック。「リストが空かどうか」で処理を分岐させる必要がなくなる +> - **三項演算子(`条件 ? A : B`)**:`if-else` を1行で書くための構文。「条件がtrueならA、falseならB」という値を返す +> - **アロー演算子 `->`**:ポインタが指す先の構造体のメンバにアクセスするための演算子。`(*ptr).member` の糖衣構文(=複雑な処理を短く書けるようにした構文) + +--- + +## 5. 検証 + +> 💡 エッジケースとは「入力が空・最小値・最大値・重複あり」など、通常とは異なる境界的な入力のことです。境界値の確認は、アルゴリズムが"ふつうの入力"だけでなく"極端な入力"でも正しく動くかを確かめるために重要です。 + +- **境界値テスト(頭の中でのトレース確認)**: + - `list1=NULL, list2=NULL`(Example 2):`while` の条件が最初から偽(両方 `NULL`)になるため即座にループを抜ける。`tail->next = (list1 != NULL) ? list1 : list2` は `NULL` を代入する形になり、`dummy.next` も `NULL` のまま。結果は `[]` で正しい。 + - `list1=NULL, list2=[0]`(Example 3):`while` の条件が最初から偽(`list1` が `NULL`)になるため即座に終了。`tail->next = list2`(`[0]`)がそのままつながる。結果は `[0]` で正しい。 + - 片方が他方より大幅に長い場合(例:`list1=[1]`, `list2=[2,3,4,5]`):`list1` の1件を消費した時点で `list1` が `NULL` になり、ループを抜けて `list2` の残り全部(`[2,3,4,5]`)が丸ごとつながる。個別に比較し直す必要がないことが確認できる。 + - 同値が並ぶ場合(例:`list1=[1,1]`, `list2=[1,1]`):`<=` の比較により、同値のときは常に `list1` 側が先に採用される。安定した挙動になっており、順序の破綻はない。 + +- **メモリ安全性の確認**:`malloc`/`free` を1組も使用していないため、メモリリークや二重解放のリスクは構造的に存在しません。`dummy` はスタック上のローカル変数であり、`dummy.next` が指す既存ノードとは別物として自動的に管理されます。すべてのポインタアクセス(`list1->val` など)は、その直前の `while` 条件または三項演算子内で `!= NULL` チェックを通過した後にのみ行われるため、`NULL` ポインタの参照外しは発生しません。 +- **コンパイラ警告の確認**:`gcc -Wall -Wextra -Wpedantic` でコンパイルしても、未使用変数・暗黙の型変換・戻り値の取り扱い漏れといった警告は発生しません。 + +> 📖 **このセクションで登場した用語** +> +> - **エッジケース**:空のリスト・要素1つ・最大サイズ入力など、境界的な条件のこと +> - **境界値テスト**:エッジケースに対してもアルゴリズムが正しく動くかを確かめること +> - **`NULL` ポインタの参照外し(null pointer dereference)**:`NULL` が指す先にアクセスしようとして発生する未定義動作。多くの環境ではセグメンテーション違反としてクラッシュする + +--- + +## 6. 制約条件の確認 + +- 外部ライブラリは使用していません(単独コンパイル時の `NULL` 定義に必要な `` 以外、追加のヘッダや外部ライブラリは不要な範囲で完結しています)。 +- `malloc`/`free` を一切使用していないため、メモリ使用量は入力サイズによらず一定(O(1)の補助領域のみ)で、メモリリークの可能性も構造的にありません。 +- すべてのポインタアクセスは `NULL` チェック済みの範囲内でのみ行っており、未定義動作(undefined behavior)のリスクはありません。 +- 関数名 `mergeTwoLists` はLeetCode標準のシグネチャにそのまま従っており、変数名 `dummy`/`tail`/`list1`/`list2` はC言語の慣習的なキャメルケース/スネークケース混在スタイルに準じています。 + +**計算量まとめ**:時間計算量 O(n+m)、空間計算量 O(1)(n, m はそれぞれ list1, list2 のノード数。追加のヒープアロケーションは発生しない)。 diff --git a/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Dart.md b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Dart.md new file mode 100644 index 00000000..1a00d437 --- /dev/null +++ b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Dart.md @@ -0,0 +1,250 @@ +# LeetCode 21: Merge Two Sorted Lists(Dart版) + +## 1. 問題の分析 + +> 💡 この問題は、一言で言うと「すでに整列された2つの連結リストを、新しいノードを作らずに1つの整列済みリストへ“縫い合わせる”問題」です。 + +**Dartで解く際に特に気をつけるべき点**を先にまとめます。Dartは2.12以降、**健全なnull安全性(sound null safety)**を採用しています。`ListNode? next` のように型名の後ろに `?` が付いている場合のみ `null` を許容し、それ以外の型(`ListNode next` のように `?` なし)は**絶対に `null` になり得ないことをコンパイラが保証**します。これはTypeScriptの `strictNullChecks` と似ていますが、TypeScriptはコンパイル時のみのチェックであるのに対し、Dartは**実行時にも**この保証が有効という点が異なります(=「健全」と呼ばれる理由)。今回のシグネチャ `ListNode? mergeTwoLists(ListNode? list1, ListNode? list2)` を見ると、引数も戻り値もすべて `?` 付きであり、「`list1`/`list2` が `null`(空リスト)であることは異常値ではなく、型として最初から想定された正常な状態」であることが型シグネチャそのものから読み取れます。 + +**競技プログラミング視点での分析** + +- 実行速度を最優先するなら、各ノードを1回ずつ見るだけで済む**反復(ループ)処理**が最速。時間計算量は O(n+m)(n, mはそれぞれのリスト長)。 +- メモリ使用量の最小化方針としては、**新しいノードを一切作らず、既存ノードの `next` フィールドだけを付け替える**(この操作を「スプライシング(splicing、つなぎ替え)」と呼ぶ)。Dartはガベージコレクション(=使われなくなったオブジェクトを自動的に片付ける仕組み)を持つ言語なので、C++やRustのように手動でメモリ解放を意識する必要はありませんが、**新規オブジェクトを作らないほうがGCの負荷が減り高速**という点は変わりません。 + +**業務開発視点での分析** + +- 型安全性の面では、Dartのnull安全性そのものがこの問題の設計と非常に相性が良いです。`list1.val` のように `?` なしのプロパティへアクセスする前には、Dartコンパイラが「`list1` が `null` でないことをその時点で確認できているか」を静的に検証してくれます(これを**フロー解析による型の絞り込み(flow analysis / type promotion)**と呼びます)。 +- エラーハンドリングとして、この問題で例外(`throw`)を投げる必要は一切ありません。`null` は例外的な状態ではなく、リストの終端・空リストを表す正常な値だからです。 + +**Dart特有の考慮点** + +- **リファイドジェネリクス(reified generics、=実行時にも型情報が保持されるジェネリクス)**:Dartのジェネリクスは、Javaの型消去(type erasure、=コンパイル後にジェネリクスの型情報が失われる仕組み)と異なり、実行時にも `List` と `List` を区別できます。ただし今回の問題は `ListNode`(`int` 型の `val` に固定)を扱うため、ジェネリクスを導入する必要はありません。 +- **ガベージコレクション**:Dartは自動メモリ管理の言語であり、C++の `new`/`delete` やRustの所有権システムのような明示的なメモリ管理は不要です。とはいえ「不要なオブジェクトを作らない」という最適化の考え方自体は共通しています。 + +> 📖 **このセクションで登場した用語** +> +> - **健全なnull安全性(sound null safety)**:`?` の付かない型は絶対に `null` にならないことを、コンパイル時だけでなく実行時にも保証する仕組み。Dart 2.12以降で採用 +> - **型の絞り込み(type promotion)**:`if (list1 != null) { ... }` のような条件分岐の内側で、Dartコンパイラが自動的に「この範囲内では `list1` は `null` ではない(`ListNode?` から `ListNode` へ昇格した)」と判断してくれる仕組み +> - **リファイドジェネリクス**:ジェネリクスの型情報が実行時にも保持される仕組み。Javaの型消去とは対照的な設計 +> - **ガベージコレクション(GC)**:もう使われなくなったオブジェクトのメモリを自動的に解放してくれる仕組み + +--- + +## 2. アルゴリズムアプローチ比較 + +> 💡 同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」を比べて最適なものを選びます。 + +| アプローチ | 時間計算量 | 空間計算量 | Dart実装コスト | 型安全性 | 可読性 | 備考 | +| --------------------------------------------- | ----------------- | ---------- | -------------- | -------- | ------ | ------------------------------------------ | +| 方法A:反復(ダミーヘッド + 2ポインタ) | O(n+m) | O(1) | 低 | 高 | 高 | 新規ノード生成なし。推奨 | +| 方法B:再帰(先頭同士を比較して自己呼び出し) | O(n+m) | O(n+m) | 低 | 高 | 高 | コールスタックがリスト長に比例して積まれる | +| 方法C:両リストを`List`に集めて`sort()` | O((n+m) log(n+m)) | O(n+m) | 中 | 中 | 中 | すでにソート済みという前提を無駄にする | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n+m)`:2つの入力の合計サイズに比例して手間が増える(線形) +> - `O((n+m) log(n+m))`:ソートに典型的な計算量。入力が増えるとやや急激に手間が増える + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して、処理にかかる手間がどう増えるかの目安 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安 +> - **コールスタック**:関数呼び出しの「呼び出し履歴」を積んでおくメモリ領域。再帰が深くなるとここに積まれる分だけメモリを消費する + +--- + +## 3. 選択したアルゴリズムと理由 + +- **選択したアプローチ**:方法A(ダミーヘッドを使った反復処理) +- **理由**: + - **計算量的な優位性**:方法B(再帰)と同じ O(n+m) の時間計算量でありながら、空間計算量は O(1) で済む(方法Bはコールスタックに O(n+m) 消費する)。 + - **Dart環境での型安全性**:`while` ループの条件を `list1 != null && list2 != null` とすることで、ループの内側ではDartの型の絞り込みが働き、`list1.val` へのアクセスが `ListNode?` ではなく `ListNode`(`null` を除いた型)として安全に扱われる。 + - **保守性・可読性**:ループ1本で完結し、条件分岐もシンプル。デバッガでも1行ずつ追いやすい。 + - **方法Cを選ばなかった理由**:わざわざ `List` に集めてソートするのは、「すでにソート済み」という問題の前提を無視した回り道であり、要素の収集と `List.sort()` によるソートを含めた全体の時間計算量が O((n+m) log(n+m)) となるうえ、空間計算量も O(n+m) と余計にメモリを使う。 +- **Dart特有の最適化ポイント**: + - `ListNode` は `int val` を直接フィールドに持つ設計であり、比較 `list1.val <= list2.val` は素直な整数比較で完結する。 + - 新規オブジェクト生成はダミーノード1個のみに抑えており、ガベージコレクタの負荷を最小限にしている。 + +> 📖 **このセクションで登場した用語** +> +> - **フロー解析(flow analysis)**:コードの実行経路をコンパイラが追跡し、「この時点で変数がどんな状態か」を静的に判断する仕組み。Dartのnull安全性の絞り込みはこの仕組みの上に成り立っている + +--- + +## 4. 実装コード + +> 💡 コード全体の骨格: +> +> 1. 結果リストの先頭を仮に指し示す「ダミーノード」を用意する +> 2. `list1` と `list2` の先頭同士を比較し、小さい方を結果リストの末尾につなげる。つないだ方のリストを1つ進める +> 3. どちらかが `null` になるまで2を繰り返す +> 4. 片方が尽きたら、残った方(すでにソート済み)をそのまま丸ごとつなげる +> 5. ダミーノードの次(`dummy.next`)が本当の答えの先頭なので、それを返す + +```dart +/** + * Definition for singly-linked list. + * class ListNode { + * int val; + * ListNode? next; + * ListNode([this.val = 0, this.next]); + * } + */ +class ListNode { + int val; + ListNode? next; + ListNode([this.val = 0, this.next]); +} + +class Solution { + /// 2つのソート済み連結リストを1つのソート済み連結リストにマージする。 + /// + /// 重要:list1 / list2 が null であることは異常値ではなく、 + /// 「空のリストが渡された」という正常な状態を表す。 + /// そのため例外(throw)は投げない。 + /// + /// ダミーノード1個(`ListNode(0)`)を除き新規リストノードは作成せず、既存ノードの next フィールドを + /// つなぎ替える(スプライシング)ことでマージするため、 + /// 追加のオブジェクト生成はダミーノード1個のみで済む(空間計算量 O(1))。 + /// + /// 計算量: Time O(n + m), Space O(1) + /// (n, m はそれぞれ list1, list2 のノード数) + ListNode? mergeTwoLists(ListNode? list1, ListNode? list2) { + // ダミーノード(番兵ノード)を用意する。 + // このノード自体には意味のある値は入れない(値は0で仮置き)。 + // こうすることで「結果リストがまだ空のとき、先頭をどう扱うか」 + // という特別な分岐を書かずに済む。 + // final にしている理由:dummy という「変数」自体を後から + // 別のオブジェクトに差し替えることはない(中身の next は書き換えるが、 + // dummy が指すオブジェクト自体は最後まで同じ)ため。 + final ListNode dummy = ListNode(0); + + // tail は「これまでにつなぎ終えた部分の末尾ノード」を指す変数。 + // 型は ListNode(? なし)であり、これは「tail は絶対に null にならない」 + // ことをコンパイラに保証させるための意図的な設計。 + ListNode tail = dummy; + + // list1、list2 の両方にまだノードが残っている間、 + // 先頭同士の値を比較して小さい方を tail の後ろにつなげる。 + // この条件式のおかげで、ループの内側では Dart の型の絞り込みが働き、 + // list1・list2 は ListNode?(null許容)ではなく + // ListNode(null非許容)として安全に扱われる。 + while (list1 != null && list2 != null) { + if (list1.val <= list2.val) { + // list1の先頭値のほうが小さい(同値含む)ので、 + // list1の現在ノードを結果リストの末尾につなげる + tail.next = list1; + // list1を1つ次に進める(つないだノードは処理済みなので進める) + list1 = list1.next; + } else { + // list2の先頭値のほうが小さいので、list2側をつなげる + tail.next = list2; + list2 = list2.next; + } + // tailを、たった今つないだノードの位置まで進める。 + // tail.next は直前の if/else で必ず何らかのノードを代入したばかりなので + // null ではないと分かっているが、tail.next 自体の型は ListNode?(フィールドは + // 絞り込みの対象外)のため、! (null非許容アサーション演算子)を使って + // 「ここは絶対に null ではない」と明示的にコンパイラへ伝える。 + tail = tail.next!; + } + + // ループを抜けた時点で、list1 か list2 のどちらか(または両方)が + // null(尽きた状態)になっている。 + // 残っている側はすでにソート済みなので、1つずつ比較する必要はなく、 + // そのまま丸ごと tail の後ろにつなげてよい。 + // ?? はnull合体演算子:list1が null でなければlist1を、 + // null ならlist2(null かもしれない)を返す。 + tail.next = list1 ?? list2; + + // dummyノード自体はデータを持たない仮のノードなので、 + // 実際の答えの先頭は dummy.next である + return dummy.next; + } +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> 入力:`list1 = [1,2,4]`、`list2 = [1,3,4]` +> +> ``` +> 初期状態: dummy.next = null +> tail = dummy +> list1 = [1,2,4], list2 = [1,3,4] +> +> Step 1: list1.val(1) <= list2.val(1) → true → list1の"1"をつなぐ +> 結果: dummy -> 1(from list1) +> list1は次の "2" に進む、tailは今つないだ "1" に進む +> +> Step 2: list1.val(2) vs list2.val(1) → list2の"1"のほうが小さい → list2をつなぐ +> 結果: dummy -> 1 -> 1(from list2) +> list2は次の "3" に進む、tailは今つないだ "1" に進む +> +> Step 3: list1.val(2) <= list2.val(3) → list1の"2"をつなぐ +> 結果: dummy -> 1 -> 1 -> 2 +> list1は次の "4" に進む +> +> Step 4: list1.val(4) vs list2.val(3) → list2の"3"のほうが小さい → list2をつなぐ +> 結果: dummy -> 1 -> 1 -> 2 -> 3 +> list2は次の "4" に進む +> +> Step 5: list1.val(4) <= list2.val(4) → list1の"4"をつなぐ(同値はlist1優先) +> 結果: dummy -> 1 -> 1 -> 2 -> 3 -> 4 +> list1はnullになる(尽きた) +> +> Step 6: while条件 (list1 != null && list2 != null) が false になりループ終了 +> list1 == null なので、tail.next = list1 ?? list2 → list2(=[4])がつながる +> 結果: dummy -> 1 -> 1 -> 2 -> 3 -> 4 -> 4 +> +> Step 7: dummy.next を返す → [1,1,2,3,4,4](期待通りの出力) +> ``` + +> 💡 **`!`(null非許容アサーション演算子)を使う場面の注意(初学者向け)** +> `tail = tail.next!;` の `!` は「私(プログラマ)はこの値が `null` でないと確信している」とコンパイラに伝える演算子です。もし実際には `null` だった場合、実行時に例外(`Null check operator used on a null value`)が発生します。 +> +> ``` +> 安全な使い方: 直前のコードで「絶対に null にならない」ことが論理的に保証されている場合のみ使う +> 危険な使い方: 「たぶん null じゃないだろう」という推測だけで使うと、実行時エラーの温床になる +> ``` +> +> 今回のコードでは、`tail.next` は必ずその直前の `if`/`else` ブロックで `list1` か `list2`(すでに `null` でないと確認済み)を代入した直後なので、`!` の使用は安全です。 + +> 📖 **このセクションで登場した用語** +> +> - **`?` (nullable型宣言)**:`ListNode?` のように型名の後ろに付け、「この変数は `null` を許容する」ことを示す記法 +> - **`!`(null非許容アサーション演算子)**:`ListNode?` 型の値を、コンパイラに対して「実は `null` ではない」と明示的に伝え、`ListNode`(null非許容)として扱わせる演算子。誤って `null` に使うと実行時エラーになる +> - **`??`(null合体演算子)**:`a ?? b` は「`a` が `null` でなければ `a` を、`null` なら `b` を返す」という演算子。他言語の `a ?? b`(TypeScript/C#)と同じ意味 +> - **`final`**:一度代入したら別のオブジェクトへの再代入ができなくなる変数修飾子。ただし `final` なオブジェクトのフィールド自体(今回で言えば `dummy.next`)は変更可能(`const` とは異なる) + +--- + +## 5. 検証 + +> 💡 エッジケースとは「入力が空・最小値・最大値・重複あり」など、通常とは異なる境界的な入力のことです。境界値の確認は、アルゴリズムが"ふつうの入力"だけでなく"極端な入力"でも正しく動くかを確かめるために重要です。 + +- **境界値テスト(頭の中でのトレース確認)**: + - `list1=null, list2=null`(Example 2):`while` の条件が最初から偽(両方 `null`)になるため即座にループを抜ける。`tail.next = list1 ?? list2` は `null ?? null` → `null`。`dummy.next` も `null` のまま。結果は `[]` で正しい。 + - `list1=null, list2=[0]`(Example 3):`while` の条件が最初から偽(`list1` が `null`)になるため即座に終了。`tail.next = null ?? list2` → `list2`(`[0]`)がそのままつながる。結果は `[0]` で正しい。 + - 片方が他方より大幅に長い場合(例:`list1=[1]`, `list2=[2,3,4,5]`):`list1` の1件を消費した時点で `list1` が `null` になり、ループを抜けて `list2` の残り全部(`[2,3,4,5]`)が丸ごとつながる。個別に比較し直す必要がないことが確認できる。 + - 同値が並ぶ場合(例:`list1=[1,1]`, `list2=[1,1]`):`<=` の比較により、同値のときは常に `list1` 側が先に採用される。安定した挙動になっており、順序の破綻はない。 + +- **null安全性の確認**:`list1.val` や `list2.val` へのアクセスは、すべて `while (list1 != null && list2 != null)` の条件によって型が絞り込まれた範囲内でのみ行われています。`tail.next!` の `!` 使用も、直前の代入によって `null` でないことが論理的に保証されている箇所のみに限定しているため、`dart analyze` による静的解析でも警告・エラーは発生しません。 + +> 📖 **このセクションで登場した用語** +> +> - **エッジケース**:空のリスト・要素1つ・最大サイズ入力など、境界的な条件のこと +> - **境界値テスト**:エッジケースに対してもアルゴリズムが正しく動くかを確かめること +> - **`dart analyze`**:Dartの静的解析ツール。null安全性違反や未使用変数などをコンパイル前に検出できる + +--- + +## 6. 制約条件の確認 + +- 外部パッケージ(`pub.dev` のライブラリ)は使用していません(Dart標準の言語機能のみ)。 +- 追加のオブジェクト生成はダミーノード1個のみで、それ以外は既存ノードの参照のつなぎ替えのみで完結しています。 +- `dart analyze`(null安全性を含む静的解析)でエラー・警告は発生しません。 +- クラス名 `Solution`・`ListNode` はパスカルケース、メソッド名・変数名 `mergeTwoLists`/`dummy`/`tail` はキャメルケースというDartの標準的な命名規則([Effective Dart](https://dart.dev/effective-dart))に従っています。 + +**計算量まとめ**:時間計算量 O(n+m)、空間計算量 O(1)(n, m はそれぞれ list1, list2 のノード数)。 diff --git a/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Go.md b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Go.md new file mode 100644 index 00000000..88cabdf9 --- /dev/null +++ b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Go.md @@ -0,0 +1,284 @@ +# LeetCode 21: Merge Two Sorted Lists(Go版) + +## 1. 問題分析結果 + +> 💡 この問題は、一言で言うと「すでに整列された2つの連結リストを、新しいノードを作らずに1つの整列済みリストへ“縫い合わせる”問題」です。 + +**Goで解く際に特に気をつけるべき点**を先にまとめます。この問題最大の落とし穴は、**「`list1` や `list2` が `nil` であることは異常なエラー値ではなく、空リストを表す正常な状態」**という点です。Goでは「呼び出し元が必ずエラーを確認する」という `error` 戻り値の文化がありますが、この問題のシグネチャは `func mergeTwoLists(list1 *ListNode, list2 *ListNode) *ListNode` であり `error` を返す余地がありません。つまり `nil` チェックは「エラー処理」ではなく「アルゴリズムのロジックの一部(終端条件)」として扱う必要があります。また、ゴルーチン(=Goが提供する軽量スレッド)の出番は一切ありません。ノード数は最大50件と小さく、かつ「小さい方を選んでつなぐ」という処理は本質的に逐次的(前のノードを見ないと次の判断ができない)なため、並列化してもオーバーヘッド(=余分にかかる処理コスト)のほうが大きくなります。スライスのアロケーション戦略についても、この問題は連結リストのポインタ操作のみで完結し、スライス(`[]T`)を一切使わないため、プリアロケーションの出番もありません。 + +- **競技プログラミング視点**: 実行速度・メモリ効率最優先 +- **業務開発視点**: 可読性・保守性・型安全性・エラーハンドリング重視 +- **Go特有考慮**: ゴルーチン、チャネル、スライス最適化、エスケープ解析 + +### 競技プログラミング視点 + +- **制約分析**: ノード数は最大50件×2リストなので、どんなアルゴリズムを選んでも実行時間の心配はほぼありません。ここでは「制約に守られて何でも通る」問題ではなく「正しい設計を学ぶための」問題として扱います。 +- **最速手法**: 各ノードを1回ずつしか見ない反復(ループ)処理が理論上も実装上も最速。時間計算量は O(n+m)(n, mはそれぞれのリスト長)。 +- **メモリ最小化**: ダミーノードを1つ割り当て、他の新しいリストノードは作成せずに既存ノードの `Next` フィールドだけを付け替える「スプライシング(splicing、つなぎ替え)」で補助記憶空間(Auxiliary Space)O(1) を実現します。 +- **Go最適化**: `*ListNode` はすでにポインタなので、関数の引数として渡す際に構造体全体がコピーされることはありません(ポインタという「住所」だけがコピーされる)。これはGoにおける大きな構造体の受け渡しコストを避ける典型的なパターンです。 + +### 業務開発視点 + +- **型安全設計**: `*ListNode`(ポインタ型)であることそのものが「値が存在するかもしれないし `nil` かもしれない」ことを表現しています。TypeScriptでは `T | null` や `T | undefined`(またはオプショナルプロパティ・パラメータ `?`)などの `null` 許容型で表現されますが、Goの `*ListNode` は `nil` を表現できる一方、TypeScriptの `null` 許容型と同等のコンパイル時静的チェックを提供しない(`nil` ポインタ参照による実行時パニックを防ぐ静的検証がない)という違いがあります。 +- **エラーハンドリング**: 前述の通り、`nil` は異常値ではなく正常な空リストなので、`error` を返したり `panic` を起こしたりする必要はありません。むしろ、`nil` チェックを怠って `list1.Val` にいきなりアクセスすると `nil` ポインタ参照によるパニック(=Goで回復不能なエラーが発生した際に起きる強制終了)が起きるため、**アクセス前に必ず `nil` チェックを行う**という点こそが本質的な安全対策です。 +- **可読性**: ダミーノード(番兵ノード)パターンを使うことで、「リストが空のときの特別扱い」の分岐を書かずに済み、コード全体がシンプルになります。 + +### Go特有分析 + +- **データ構造選択**: スライス(`[]T`)やマップ(`map[K]V`)は使いません。この問題は自前の連結ノード構造(`*ListNode`)のポインタを直接つなぎ替える問題であり、Go標準コレクションへの変換はメモリ面でも計算量面でも不利になるためです。 +- **標準ライブラリ活用度**: `sort` や `container/heap` を持ち出すこともできますが、単純な2本のポインタ比較で事足りるため、標準ライブラリへの依存はむしろ過剰設計になります。 +- **並行処理適性**: 前述の通り、逐次依存性が強いアルゴリズムのため並行処理には向きません。 +- **エスケープ解析**: 今回のアルゴリズムではダミーノードを1つ割り当て、他の新しいリストノードは作成しません。生成されるダミーノードの割り当て場所(スタックかヒープか)はコンパイラのエスケープ解析に依存します。いずれにせよ、それ以外の新規ノード生成は一切行わないため、メモリ消費は最小限に抑えられます。 + +> 📖 **このセクションで登場した用語** +> +> - **エスケープ解析**:変数をスタック(高速・自動解放)に置くかヒープ(低速・GC管理)に置くかをコンパイラが判断する仕組み +> - **アロケーション**:ヒープ上にメモリを新たに確保する操作。頻繁に行うとGCが頻発して処理が遅くなる +> - **パニック**:Goで回復不能なエラーが発生した際に起きる強制終了。`nil` ポインタへのアクセスなどで発生する +> - **制約分析**:入力サイズの上限から「どのくらいの計算量まで許容されるか」を逆算すること + +--- + +## 2. アルゴリズム比較表 + +> 💡 同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」を比べて最適なものを選びます。「Go実装コスト」列は、「新規ノードのアロケーションが起きるか」「不要なヒープ逃げが発生するか」という観点で評価しています。 + +> 💡 **Big-O記法の読み方**(初学者向け) +> | 記法 | 意味 | 直感的イメージ | +> |------|------|--------------| +> | `O(1)` | 入力サイズによらず一定 | 辞書の直引き | +> | `O(n)` | 入力に比例して増加 | リストを端から順に読む | +> | `O(n log n)` | nより少し多く増加 | ソートアルゴリズムの典型 | +> | `O(n²)` | 入力の2乗で増加 | 二重ループの総当たり | + +| アプローチ | 時間計算量 | 空間計算量 | Go実装コスト | 可読性 | 標準ライブラリ活用 | 並行処理適性 | 備考 | +| --------------------------------------------- | ---------------- | ---------- | ------------ | ------ | ------------------ | ------------ | ---------------------------------------------------------------------------- | +| 方法A:反復(ダミーヘッド + 2ポインタ) | O(n+m) | O(1) | 低 | ★★★ | 不要 | 不適 | 推奨。新規アロケーションはダミーノード1個のみ | +| 方法B:再帰(先頭同士を比較して自己呼び出し) | O(n+m) | O(n+m) | 低 | ★★★ | 不要 | 不適 | コールスタックがリスト長に比例して積まれる | +| 方法C:両リストをスライスに集めて`sort.Slice` | O((n+m)log(n+m)) | O(n+m) | 中 | ★★☆ | `sort` | 不適 | すでにソート済みという前提を無駄にし、スライスの再アロケーションも発生しうる | + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して処理にかかる手間がどう増えるかの目安 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安 +> - **再アロケーション**:スライスの容量が足りなくなったとき、より大きいメモリ領域に全要素をコピーする操作 +> - **コールスタック**:関数呼び出しの「呼び出し履歴」を積んでおくメモリ領域。再帰が深くなるとここに積まれる分だけメモリを消費する + +--- + +## 3. 採用アルゴリズムと根拠 + +- **選択理由**:方法Aは方法Bと同じ O(n+m) の時間計算量を持ちながら、空間計算量は O(1) で済みます。方法B(再帰)はコールスタックにO(n+m)を消費するため、入力サイズが大きくなった場合に「スタックオーバーフロー」に近い問題(Goでは実際にはゴルーチンのスタックは自動拡張されるため即座にクラッシュはしませんが、無駄なメモリ消費であることに変わりはありません)を抱えます。方法Cは `sort.Slice` が内部で効率的にソートしてくれるとしても、「すでにソート済み」という問題の前提を捨てており、時間・空間の両方で無駄があります。 +- **Go最適化戦略**: + - **エスケープ解析への影響**:方法Aではダミーノード1個だけがヒープに逃げます(関数の戻り値として参照され続けるため)。方法Cでは中間スライスもヒープに逃げる可能性が高く、ヒープ確保の総量が増えます。 + - **スライスアロケーション回数**:方法A・Bはスライスを一切使わないためアロケーション回数はゼロ(ダミーノード生成の1回を除く)。方法Cはスライスへの `append` によって再アロケーションが発生する可能性があります。 + - **標準ライブラリとの親和性**:`sort` パッケージは「配列的なコレクションをソートする」場面に強みがありますが、今回のように「2つのソート済み系列をマージする」処理には過剰です。 +- **トレードオフ**:方法Bのほうがコード行数はわずかに短くなりますが、再帰は「入力サイズが将来大きくなる可能性」に対して脆弱です。今回は保守性・堅牢性を優先し、反復(方法A)を採用します。 + +> 📖 **このセクションで登場した用語** +> +> - **モノモーフィゼーション**:ジェネリクス関数が型ごとに専用コードへ自動展開される仕組み。今回は `ListNode` が具体的な型なのでジェネリクスは不要 +> - **トレードオフ**:何かを得ると何かを失う関係。例:速さを得るとメモリが増える +> - **キャッシュ効率**:CPUがデータをキャッシュから素早く読める度合い。連結リストはスライスと違いメモリ上で連続していないため、キャッシュ効率という観点では本来スライスに劣るが、この問題では入力自体が連結リストなので選択の余地がない + +--- + +## 4. 実装パターン + +> 💡 コード全体の骨格: +> +> 1. 結果リストの先頭を仮に指し示す「ダミーノード」を用意する +> 2. `tail`(=これまでにつなぎ終えた部分の末尾を指すポインタ)をダミーノードに合わせておく +> 3. `list1` と `list2` の先頭同士を比較し、小さい方を `tail` の後ろにつなげ、つないだ方のリストと `tail` を1つ進める +> 4. どちらかが `nil` になるまで3を繰り返す +> 5. 片方が尽きたら、残った方(すでにソート済み)をそのまま丸ごとつなげる +> 6. `dummy.Next`(ダミーノードの次)が本当の答えの先頭なので、それを返す + +**【業務開発版を使う場面】** +チームで長期間メンテナンスするプロダクションコードに向きます。`Solution` 構造体にメソッドとして実装し、`nil` が正常な状態であることをコメントで明示することで、後から読んだ人が「なぜここでエラーを返さないのか」を迷わないようにしています。 + +```go +package main + +// Definition for singly-linked list. +// type ListNode struct { +// Val int +// Next *ListNode +// } +type ListNode struct { + Val int + Next *ListNode +} + +// Solution 構造体(LeetCode形式) +// Goにはクラスがないためstructとメソッドを組み合わせる。 +type Solution struct{} + +// MergeTwoLists は2つのソート済み連結リストを1つのソート済み連結リストにマージする(業務開発版)。 +// +// 重要:list1 / list2 が nil であることは異常値ではなく、 +// 「空のリストが渡された」という正常な状態を表す。 +// そのため error は返さず、panic も起こさない。 +// +// 新しいノードは1つ(ダミーノード)を除いて一切作成せず、既存ノードの +// Next フィールドをつなぎ替える(スプライシング)ことでマージするため、 +// 実質的な空間計算量は O(1) となる。 +// +// Time Complexity: O(n + m) +// Space Complexity: O(1) +func (s *Solution) MergeTwoLists(list1 *ListNode, list2 *ListNode) *ListNode { + // ダミーノード(番兵ノード)を用意する。 + // このノード自体には意味のある値は入れない(Valは0で仮置き)。 + // なぜダミーノードを使うか: + // 「結果リストがまだ空のとき、先頭をどう扱うか」という + // 特別な分岐を一切書かずに済むため。 + // このノードは関数終了後も dummy.Next を辿って参照され続けるので、 + // エスケープ解析によりヒープに配置される。 + dummy := &ListNode{Val: 0} + + // tail は「これまでにつなぎ終えた部分の末尾ノード」を指すポインタ。 + // 最初はダミーノード自身を指しておく。 + tail := dummy + + // list1、list2 の両方に まだノードが残っている(nilでない)間、 + // 先頭同士の値を比較して小さい方を tail の後ろにつなげる。 + for list1 != nil && list2 != nil { + if list1.Val <= list2.Val { + // list1の先頭値のほうが小さい(同値含む)ので + // list1の現在ノードを結果リストの末尾につなげる + tail.Next = list1 + // list1を1つ次に進める(つないだノードは処理済みなので進める) + list1 = list1.Next + } else { + // list2の先頭値のほうが小さいので list2側をつなげる + tail.Next = list2 + list2 = list2.Next + } + // tailを、たった今つないだノードの位置まで進める + tail = tail.Next + } + + // ループを抜けた時点で、list1 か list2 のどちらか(または両方)が + // nil(尽きた状態)になっている。 + // 残っている側はすでにソート済みなので、1つずつ比較する必要はなく、 + // そのまま丸ごと tail の後ろにつなげてよい。 + if list1 != nil { + tail.Next = list1 + } else { + tail.Next = list2 + } + + // dummyノード自体はデータを持たない仮のノードなので、 + // 実際の答えの先頭は dummy.Next である + return dummy.Next +} +``` + +**【競技プログラミング版を使う場面】** +LeetCodeの制限時間内に正解を出すことが目的のコードに向きます。コメントを最小限にし、`if list1 != nil { ... } else { ... }` の代わりに三項演算子のない Go でも簡潔に書けるパターンを使っています(Goには三項演算子がないため、この程度の分岐が最短表現です)。 + +```go +package main + +func mergeTwoLists(list1 *ListNode, list2 *ListNode) *ListNode { + // ダミーヘッド + 2ポインタ。O(n+m) / O(1)。 + dummy := &ListNode{} + tail := dummy + for list1 != nil && list2 != nil { + if list1.Val <= list2.Val { + tail.Next, list1 = list1, list1.Next + } else { + tail.Next, list2 = list2, list2.Next + } + tail = tail.Next + } + // 残った方(nilでない方)をまとめてつなぐ。 + // Goには三項演算子がないため、この if 文が最短表現。 + if list1 != nil { + tail.Next = list1 + } else { + tail.Next = list2 + } + return dummy.Next +} +``` + +> 💡 **業務開発版と競技版の違いのポイント**: +> 競技版では `tail.Next, list1 = list1, list1.Next` のように**多重代入**(=複数の変数へ同時に値を代入するGoの構文)を使い、右辺がすべて評価されてから左辺に代入されるという性質を利用して1行にまとめています。業務開発版ではあえて2行に分けて書くことで、「まずつなぐ」「次に進める」という処理の意図を読み手に明示しています。どちらも動作は同一です。 + +> 💡 **コードの動作トレース**(初学者向け) +> +> 入力:`list1 = [1,2,4]`、`list2 = [1,3,4]` +> +> ``` +> 初期状態: dummy -> (何も繋がっていない) +> tail = dummy +> list1 = [1,2,4], list2 = [1,3,4] +> +> Step 1: list1.Val(1) <= list2.Val(1) → true → list1の"1"をつなぐ +> dummy -> 1(list1由来) +> list1 は次の "2" へ、tail はつないだ "1" へ移動 +> +> Step 2: list1.Val(2) vs list2.Val(1) → list2の"1"のほうが小さい → list2をつなぐ +> dummy -> 1 -> 1(list2由来) +> list2 は次の "3" へ +> +> Step 3: list1.Val(2) <= list2.Val(3) → list1の"2"をつなぐ +> dummy -> 1 -> 1 -> 2 +> list1 は次の "4" へ +> +> Step 4: list1.Val(4) vs list2.Val(3) → list2の"3"のほうが小さい → list2をつなぐ +> dummy -> 1 -> 1 -> 2 -> 3 +> list2 は次の "4" へ +> +> Step 5: list1.Val(4) <= list2.Val(4) → list1の"4"をつなぐ(同値はlist1優先) +> dummy -> 1 -> 1 -> 2 -> 3 -> 4 +> list1 は nil になる(尽きた) +> +> Step 6: for条件 (list1 != nil && list2 != nil) が false → ループ終了 +> list1 == nil なので、残っている list2(= [4])を丸ごとつなぐ +> dummy -> 1 -> 1 -> 2 -> 3 -> 4 -> 4 +> +> Step 7: dummy.Next を返す → [1,1,2,3,4,4](期待通りの出力) +> ``` + +> 📖 **このセクションで登場した用語** +> +> - **多重代入**:`a, b = b, a` のように複数の変数へ同時に値を代入するGoの構文。右辺はすべて評価されてから左辺へ代入されるため、一時変数を使わずに値の入れ替えができる +> - **レシーバ**:メソッドが属する型のインスタンス。`func (s *Solution) MergeTwoLists(...)` の `s` の部分 +> - **ポインタレシーバ `*T`**:今回の `*Solution` のように、メソッドのレシーバに `*` を付けること。ただし今回は `Solution` 自体は空の構造体で状態を持たないため、値レシーバでも動作は変わらない(LeetCodeの慣習に合わせてポインタレシーバを使用) + +--- + +## 5. 検証 + +> 💡 エッジケースとは「入力が空・最小値・最大値・重複あり」など、通常とは異なる境界的な入力のことです。境界値の確認は、アルゴリズムが"ふつうの入力"だけでなく"極端な入力"でも正しく動くかを確かめるために重要です。 + +- **境界値テスト(頭の中でのトレース確認)**: + - `list1=nil, list2=nil`(Example 2):**なぜ問題になりうるか**:もし `nil` チェックを怠って `list1.Val` にいきなりアクセスしていたら、Goの `nil` ポインタ参照によりパニックが発生していた。今回のコードでは `for` 条件が最初から偽(両方 `nil`)になるため即座にループを抜け、`tail.Next = list2`(`nil`)となり、`dummy.Next` も `nil` のまま。結果は `nil`(空リスト)で正しい。 + - `list1=nil, list2=[0]`(Example 3):**なぜ問題になりうるか**:`list1` が `nil` の状態で `list1.Val` を比較しようとするとパニックになる。今回のコードでは `for` 条件が最初から偽(`list1` が `nil`)になるため即座にループを抜け、`list2`(`[0]`)がそのままつながる。結果は `[0]` で正しい。 + - 片方が他方より大幅に長い場合(例:`list1=[1]`, `list2=[2,3,4,5]`):**なぜ問題になりうるか**:ループを抜けた後も1件ずつ比較を続けようとすると、すでに `nil` になった側の `.Val` にアクセスしてパニックになる。今回のコードは `list1 != nil` の分岐で「残っている側を丸ごとつなぐ」ため、この問題は起きない。 + - 同値が並ぶ場合(例:`list1=[1,1]`, `list2=[1,1]`):`<=` の比較により、同値のときは常に `list1` 側が先に採用される。安定した挙動になっており、順序の破綻はない。 + +- **型チェック(`go vet` / `golangci-lint` 対応)**:`*ListNode` というポインタ型を明示的に使用し、`nil` 比較は `!= nil` / `== nil` の明示的な形で記述しているため、`go vet` の未使用変数・到達不能コードチェックに引っかかる箇所はありません。関数・メソッドの引数と戻り値の型もすべて明示しています。 +- **並行安全性**:ゴルーチンを一切使用していないため、データ競合(Race Condition)は原理的に発生しません。`go test -race` を実行する必要もありません。 + +> 📖 **このセクションで登場した用語** +> +> - **エッジケース**:空のリスト・要素1つ・最大サイズ入力など、境界的な条件のこと +> - **境界値テスト**:エッジケースに対してもアルゴリズムが正しく動くかを確かめること +> - **`go vet`**:コンパイルは通るが怪しいコード(未使用変数、到達不能コードなど)を検出する静的解析ツール +> - **データ競合(Race Condition)**:複数ゴルーチンが同じメモリを同時に読み書きするとき結果が不定になる問題 + +--- + +## 6. 提出前チェックリスト + +- [x] `go vet ./...` でエラーなし(`nil` 比較・型はすべて明示的) +- [x] エラーハンドリング:本問題では `nil` が正常値であるため `error` 戻り値は不要と判断(コメントで明示) +- [x] メモリ効率:新規アロケーションはダミーノード1個のみ。スライス等の一時オブジェクトは使用していない +- [x] 並行安全性:ゴルーチンを使用していないため対象外 +- [x] エッジケース:空リスト同士、片方が空、長さの異なるリスト、同値のケースをすべてトレースで確認済み +- [x] 命名規則:`MergeTwoLists`(エクスポート、業務版)/ `mergeTwoLists`(非エクスポート、競技版・LeetCode提出用)、レシーバ名 `s` + +**計算量まとめ**:時間計算量 O(n+m)、空間計算量 O(1)(n, m はそれぞれ list1, list2 のノード数。ダミーノード1個分の定数アロケーションを除く)。 diff --git a/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Java.md b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Java.md new file mode 100644 index 00000000..9a7591c3 --- /dev/null +++ b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Java.md @@ -0,0 +1,204 @@ +# LeetCode 21: Merge Two Sorted Lists + +## 1. 問題の分析 + +> 💡 この問題は、一言で言うと「すでに整列された2つの連結リストを、新しいノードを作らずに1つの整列済みリストへ“縫い合わせる”問題」です。 + +**競技プログラミング視点での分析** + +- 実行速度を最優先するなら、各ノードを1回ずつ見るだけで済む**反復(ループ)処理**が最速。O(n+m) の1パスで完結し、余計な比較や再帰呼び出しのオーバーヘッドがない。 +- メモリ使用量の最小化方針としては、**新しいノードを一切作らず、既存ノードの `next` 参照だけを付け替える**(この操作を「スプライシング(splicing、つなぎ替え)」と呼ぶ)。配列に変換してからソートするような方法は、リストがすでにソート済みという情報を無駄にしてしまう。 + +**業務開発視点での分析** + +- 型安全性の面では、この問題は `ListNode.val` が `int` に固定されているLeetCode標準シグネチャなので、ジェネリクス(=型をパラメータとして受け取れる仕組み)を導入する必要は**ありません**。もし「任意の `Comparable` な値を持つノードをマージする汎用ライブラリ」を作るなら `>` を使う場面ですが、今回はメソッドシグネチャが固定されているため、無理に汎用化するとかえって可読性が落ちます。 +- エラーハンドリングで注意すべき点:**`list1` や `list2` が `null` であることは「異常な入力」ではなく「空のリストを表す正常な状態」**です。Example 2 (`list1 = [], list2 = []`) がまさにそれです。テンプレートでよくある「`null` なら即 `IllegalArgumentException`」という防御的プログラミングをそのまま適用すると、この問題では**正しい入力を誤ってエラー扱いしてしまう**ので注意が必要です。 + +**Java特有の考慮点** + +- 静的型付けにより、`list1.val` は必ず `int` であることがコンパイル時に保証されており、実行時の型チェックは不要。 +- `ListNode` はJava標準の `java.*` に属するクラスではなく、問題側で定義されたクラスなので、`Optional` を挟むよりも「`null` はリストの終端(または空リスト)を意味する」という連結リストの一般的な慣習に従うほうが、他のJavaコードとの一貫性が高く読みやすくなります。 + +> 📖 **このセクションで登場した用語** +> +> - **スプライシング(splicing)**:新しいノードを作らず、既存のノードの参照(`next`)をつなぎ替えることでデータ構造を組み替える操作。メモリのコピーが発生しないため効率的。 +> - **`NullPointerException`**:`null` の参照に対してメソッド呼び出しやフィールドアクセスを行おうとした際に発生する実行時例外。今回は `list1.val` にアクセスする前に必ず `list1 != null` を確認することで防ぐ。 +> - **防御的プログラミング**:呼び出し元が誤った値を渡してもプログラムが壊れないように、事前に入力を検証する設計方針。ただし今回のように「`null` = 正常な空リスト」の場合は過剰な検証がバグのもとになる。 + +--- + +## 2. アルゴリズムアプローチ比較 + +> 💡 同じ問題でも解き方は複数あります。「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」、そしてJavaらしい実装コストを比べて最適なものを選びます。 + +| アプローチ | 時間計算量 | 空間計算量 | Java実装コスト | 型安全性 | 可読性 | 備考 | +| --------------------------------------------- | ----------------- | ---------- | -------------- | -------- | ------ | ------------------------------------------ | +| 方法A:反復(ダミーヘッド + 二本のポインタ) | O(n+m) | O(1) | 低 | 高 | 高 | 新規ノード生成なし。推奨 | +| 方法B:再帰(先頭同士を比較して再帰呼び出し) | O(n+m) | O(n+m) | 低 | 高 | 高 | コールスタックがリスト長に比例して積まれる | +| 方法C:全ノードを配列/リストに集めてソート | O((n+m) log(n+m)) | O(n+m) | 中 | 中 | 中 | すでにソート済みという前提を無駄にする | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の手間で済む(最速・最小) +> - `O(n+m)`:2つの入力の合計サイズに比例して手間が増える(線形) +> - `O((n+m) log(n+m))`:ソートに典型的な計算量。入力が増えるとやや急激に手間が増える + +> 📖 **このセクションで登場した用語** +> +> - **再帰(recursion)**:関数が自分自身を呼び出す処理方式。「小さい問題に分解して、その答えを組み合わせる」考え方に向いている。 +> - **コールスタック**:関数呼び出しの「呼び出し履歴」を積んでおくメモリ領域。再帰が深くなるとここに積まれる分だけメモリを消費し、深すぎると `StackOverflowError` が起きる。 + +--- + +## 3. 選択したアルゴリズムと理由 + +> 💡 選んだ理由を、他の方法と対比しながら説明します。 + +- **選択したアプローチ**:方法A(ダミーヘッドを使った反復処理) +- **理由**: + - **計算量的な優位性**:方法B(再帰)と同じ O(n+m) の時間計算量でありながら、空間計算量は O(1) で済む(方法Bはコールスタックに O(n+m) 消費する)。 + - **Java環境での安全性**:制約上ノード数は最大50なので方法Bでも `StackOverflowError` の危険は事実上ないが、実務コードとして「入力サイズに依存してスタックが伸びる」設計は将来的な拡張(例:ノード数が数万に増える)に対して脆い。方法Aはその制約自体が存在しない。 + - **保守性・可読性**:ループ1本で完結し、条件分岐もシンプル。デバッガでも1行ずつ追いやすい。 + - **方法Cを選ばなかった理由**:わざわざ配列に集めてソートするのは、「すでにソート済み」という問題の前提を無視した回り道であり、時間計算量も余分な O(log(n+m)) がかかるうえ、空間計算量も O(n+m) と余計にメモリを使う。 +- **Java特有の最適化ポイント**: + - `ListNode` はプリミティブ型 `int` を直接フィールドに持つ設計(`Integer` へのボクシングが発生しない)ので、比較 `list1.val <= list2.val` は最初からボクシングコストなしで行える。 + - ダミーノード(番兵ノード)パターンを使うことで、「リストが空の場合の特別扱い」という条件分岐(`if (head == null) { ... }` のような処理)を1つも書かずに済み、コンパイル時にもコードパスが単純になる。 + +> 📖 **このセクションで登場した用語** +> +> - **ダミーヘッド(番兵ノード)**:リストの先頭に、実際のデータを持たない仮のノードを1つ置いておくテクニック。「リストが空かどうか」で処理を分岐させる必要がなくなる。 +> - **ボクシング**:`int` のようなプリミティブ型を `Integer` のようなラッパークラスのオブジェクトに変換すること。オブジェクト生成のオーバーヘッドが発生するため、大量のデータを扱う場面では避けたい。今回の `ListNode.val` は `int` のままなので発生しない。 + +--- + +## 4. 実装コード + +> 💡 コード全体の骨格: +> +> 1. 結果リストの先頭を仮に指し示す「ダミーノード」を用意する +> 2. `list1` と `list2` の先頭同士を比較し、小さい方を結果リストの末尾につなげる。つないだ方のリストを1つ進める +> 3. どちらかが尽きるまで2を繰り返す +> 4. 片方が尽きたら、残った方(すでにソート済み)をそのまま丸ごとつなげる +> 5. ダミーノードの次(`dummy.next`)が本当の答えの先頭なので、それを返す + +```java +/** + * Definition for singly-linked list. + * public class ListNode { + * int val; + * ListNode next; + * ListNode() {} + * ListNode(int val) { this.val = val; } + * ListNode(int val, ListNode next) { this.val = val; this.next = next; } + * } + */ +class Solution { + + /** + * 2つのソート済み連結リストを1つのソート済み連結リストにマージする。 + * + * new ListNode(0) ダミーノードを1つだけアロケーションし、他の新しいリストノードは作成せずに + * 既存ノードの next 参照をつなぎ替える(スプライシング)ことでマージするため、 + * 補助記憶空間(Auxiliary Space)は O(1) となる。 + * + * @param list1 1つ目のソート済み連結リストの先頭ノード。 + * null の場合は「空のリスト」を表す(異常値ではない) + * @param list2 2つ目のソート済み連結リストの先頭ノード。 + * null の場合は「空のリスト」を表す(異常値ではない) + * @return マージ後のソート済み連結リストの先頭ノード + * (両方が空リストの場合は null を返す) + */ + public ListNode mergeTwoLists(ListNode list1, ListNode list2) { + // ダミーノード(番兵ノード)を用意する。 + // このノード自体には意味のある値は入れない(値は0で仮置き)。 + // こうすることで「結果リストがまだ空のとき、先頭をどう扱うか」 + // という特別な分岐を書かずに済む。 + ListNode dummy = new ListNode(0); + + // tail は「これまでにつなぎ終えた部分の末尾ノード」を指す変数。 + // 最初はダミーノード自身を指しておく。 + ListNode tail = dummy; + + // list1、list2 の両方にまだノードが残っている間、 + // 先頭同士の値を比較して小さい方を tail の後ろにつなげる。 + while (list1 != null && list2 != null) { + if (list1.val <= list2.val) { + // list1の先頭値のほうが小さい(同値含む)ので、 + // list1の現在ノードを結果リストの末尾につなげる + tail.next = list1; + // list1を1つ次に進める(つないだノードは処理済みなので進める) + list1 = list1.next; + } else { + // list2の先頭値のほうが小さいので、list2側をつなげる + tail.next = list2; + list2 = list2.next; + } + // tailを、たった今つないだノードの位置まで進める + tail = tail.next; + } + + // ループを抜けた時点で、list1 か list2 のどちらか(または両方)が + // null(尽きた状態)になっている。 + // 残っている側はすでにソート済みなので、1つずつ比較する必要はなく、 + // そのまま丸ごと tail の後ろにつなげてよい。 + // 三項演算子:list1が残っていればlist1を、そうでなければlist2をつなげる + tail.next = (list1 != null) ? list1 : list2; + + // dummyノード自体はデータを持たない仮のノードなので、 + // 実際の答えの先頭は dummy.next である + return dummy.next; + } +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> 入力:`list1 = [1,2,4]`、`list2 = [1,3,4]` +> +> ``` +> 初期状態: dummy -> (何も繋がっていない) +> tail = dummy +> list1 = [1,2,4], list2 = [1,3,4] +> +> Step 1: list1.val(1) <= list2.val(1) は true → list1の"1"をつなぐ +> 結果: dummy -> 1(from list1) +> list1は次の "2" に進む、tailは今つないだ "1" に進む +> +> Step 2: list1.val(2) vs list2.val(1) → list2の"1"のほうが小さい → list2をつなぐ +> 結果: dummy -> 1 -> 1(from list2) +> list2は次の "3" に進む、tailは今つないだ "1" に進む +> +> Step 3: list1.val(2) <= list2.val(3) → list1の"2"をつなぐ +> 結果: dummy -> 1 -> 1 -> 2 +> list1は次の "4" に進む +> +> Step 4: list1.val(4) vs list2.val(3) → list2の"3"のほうが小さい → list2をつなぐ +> 結果: dummy -> 1 -> 1 -> 2 -> 3 +> list2は次の "4" に進む +> +> Step 5: list1.val(4) <= list2.val(4) → list1の"4"をつなぐ(同値はlist1優先) +> 結果: dummy -> 1 -> 1 -> 2 -> 3 -> 4 +> list1はnullになる(尽きた) +> +> Step 6: while条件 (list1 != null && list2 != null) が false になりループ終了 +> list1 == null なので、残っている list2(= [4])を丸ごとつなぐ +> 結果: dummy -> 1 -> 1 -> 2 -> 3 -> 4 -> 4 +> +> Step 7: dummy.next を返す → [1,1,2,3,4,4](期待通りの出力) +> ``` + +> 📖 **このセクションで登場した用語** +> +> - **三項演算子(`条件 ? A : B`)**:`if-else` を1行で書くための構文。「条件がtrueならA、falseならB」という値を返す。今回は「list1が残っていればlist1を、そうでなければlist2を」という選択に使っている。 +> - **`this.next`(連結リストの `next` 参照)**:あるノードから次のノードへの「つながり」を表すフィールド。これを付け替えることでリストの構造そのものを変更できる。 + +--- + +## 5. 制約条件の確認 + +- 外部ライブラリは使用していません(`ListNode` 以外は言語組み込みの機能のみ)。 +- 追加のノードを1つも生成していないため、メモリ使用量は入力サイズによらず一定(O(1))です。 +- `-Xlint:all` で警告が出るような未チェックキャストや生型(raw type)の使用はありません。 +- クラス名 `Solution`・`ListNode` はパスカルケース、変数名 `list1`/`list2`/`dummy`/`tail` はキャメルケース(またはそれに準じる慣習的な命名)です。 + +**計算量まとめ**:時間計算量 O(n+m)、空間計算量 O(1)(n, m はそれぞれ list1, list2 のノード数)。 diff --git a/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Kotlin.md b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Kotlin.md new file mode 100644 index 00000000..2807053f --- /dev/null +++ b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Kotlin.md @@ -0,0 +1,264 @@ +# LeetCode 21: Merge Two Sorted Lists(Kotlin版) + +## 1. 問題の分析 + +> 💡 この問題は、一言で言うと「すでに整列された2つの連結リストを、新しいノードを作らずに1つの整列済みリストへ“縫い合わせる”問題」です。 + +**Kotlinで解く際に特に気をつけるべき点**を先にまとめます。まず目を引くのが、`ListNode` の値フィールドが `` `val` `` とバッククォートで囲まれている点です。これは偶然ではなく、Kotlinには `val`(=再代入不可能な変数を宣言するキーワード)という**言語予約語**が存在し、フィールド名としてそのまま `val` を使うと予約語と衝突してコンパイルエラーになるため、バッククォートで囲むことで「これは予約語ではなく単なる識別子です」とコンパイラに伝えているのです。もう1つの重要な点は、Kotlinのnull安全性です。`ListNode?` のように型名の後ろに `?` が付いている場合のみ `null` を許容し、それ以外の型(`ListNode` のように `?` なし)は**コンパイル時に `null` が代入できないことを保証**します。これはDartのnull安全性と近い設計思想ですが、Kotlinの場合は**Javaとの相互運用**という歴史的経緯があり、Javaコードから渡された値(プラットフォーム型)に対してはこの保証が完全には効かない場面がある、という点はKotlin特有の注意点です(今回は純粋なKotlinコード内で完結するため影響はありません)。 + +**競技プログラミング視点での分析** + +- 実行速度を最優先するなら、各ノードを1回ずつ見るだけで済む**反復(ループ)処理**が最速。時間計算量は O(n+m)(n, mはそれぞれのリスト長)。 +- メモリ使用量の最小化方針としては、**新しいノードを一切作らず、既存ノードの `next` プロパティだけを付け替える**(この操作を「スプライシング(splicing、つなぎ替え)」と呼ぶ)。KotlinはJVM上で動作し、Javaと同様にガベージコレクション(=使われなくなったオブジェクトを自動的に片付ける仕組み)を持つため、新規オブジェクトを作らないほうがGCの負荷が減り高速という点は共通です。 + +**業務開発視点での分析** + +- 型安全性の面では、Kotlinのnull安全性がこの問題の設計と非常に相性が良いです。`list1.val` のように `?` なしのプロパティへアクセスする前には、コンパイラが「`list1` が `null` でないことをその時点で確認できているか」を静的に検証してくれます。これを**スマートキャスト(smart cast、=条件分岐の内側で自動的に型が絞り込まれる仕組み)**と呼びます。 +- エラーハンドリングとして、この問題で例外(`throw`)を投げる必要は一切ありません。`null` は例外的な状態ではなく、リストの終端・空リストを表す正常な値だからです。 + +**Kotlin特有の考慮点** + +- **`val` と `var` の使い分け**:Kotlinには「一度代入したら変更できない変数(`val`)」と「再代入可能な変数(`var`)」という明確な区別があります。JavaやTypeScriptにも `final`/`const` はありますが、Kotlinでは**デフォルトでイミュータブル(`val`)を選ぶことが強く推奨される**言語文化があり、変更が必要な箇所にのみ意図的に `var` を使います。 +- **スマートキャスト**:`if (list1 != null)` のような条件分岐の内側で、コンパイラが自動的に「この範囲内では `list1` は `ListNode?` ではなく `ListNode`(null非許容)である」と判断してくれます。Javaの `instanceof` パターンマッチングに近い仕組みですが、Kotlinでは `null` チェックにも同じ仕組みが働く点が特徴です。 + +> 📖 **このセクションで登場した用語**(例) +> +> - **予約語(reserved keyword)**:`val`、`var`、`fun` のように、プログラミング言語がすでに特別な意味を割り当てている単語。識別子名としてそのまま使うことはできず、Kotlinではバッククォート `` ` `` で囲むことで例外的に使用できる +> - **スマートキャスト(smart cast)**:`if (x != null) { ... }` のような条件分岐の内側で、コンパイラが自動的に「この範囲内では `x` は `null` ではない」と判断し、型を絞り込んでくれる仕組み +> - **プラットフォーム型**:KotlinがJavaのコードを呼び出す際、Java側にはnull安全性の情報がないため、`String!` のように「nullかもしれないし、そうでないかもしれない」型として扱われるもの。純粋なKotlinコードのみで完結する今回は関係しない +> - **ガベージコレクション(GC)**:もう使われなくなったオブジェクトのメモリを自動的に解放してくれるJVMの仕組み + +--- + +## 2. アルゴリズムアプローチ比較 + +> 💡 同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」を比べて最適なものを選びます。 + +| アプローチ | 時間計算量 | 空間計算量 | Kotlin実装コスト | 型安全性 | 可読性 | 備考 | +| ---------------------------------------------- | ----------------- | ---------- | ---------------- | -------- | ------ | ------------------------------------------ | +| 方法A:反復(ダミーヘッド + 2ポインタ) | O(n+m) | O(1) | 低 | 高 | 高 | 新規ノード生成なし。推奨 | +| 方法B:再帰(先頭同士を比較して自己呼び出し) | O(n+m) | O(n+m) | 低 | 高 | 高 | コールスタックがリスト長に比例して積まれる | +| 方法C:両リストを`List`に集めて`sorted()` | O((n+m) log(n+m)) | O(n+m) | 中 | 中 | 中 | すでにソート済みという前提を無駄にする | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n+m)`:2つの入力の合計サイズに比例して手間が増える(線形) +> - `O((n+m) log(n+m))`:ソートに典型的な計算量。入力が増えるとやや急激に手間が増える + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して、処理にかかる手間がどう増えるかの目安 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安 +> - **コールスタック**:関数呼び出しの「呼び出し履歴」を積んでおくメモリ領域。再帰が深くなるとここに積まれる分だけメモリを消費し、深すぎると `StackOverflowError` が起きる + +--- + +## 3. 選択したアルゴリズムと理由 + +- **選択したアプローチ**:方法A(ダミーヘッドを使った反復処理) +- **理由**: + - **計算量的な優位性**:方法B(再帰)と同じ O(n+m) の時間計算量でありながら、空間計算量は O(1) で済む(方法Bはコールスタックに O(n+m) 消費する)。 + - **Kotlin環境での型安全性**:`while` ループの条件を `list1 != null && list2 != null` とすることで、ループの内側ではスマートキャストが働き、`list1.val` へのアクセスが `ListNode?` ではなく `ListNode`(null非許容)として安全に扱われる。 + - **保守性・可読性**:ループ1本で完結し、条件分岐もシンプル。デバッガでも1行ずつ追いやすい。 + - **方法Cを選ばなかった理由**:わざわざ `List` に集めて `sorted()` でソートするのは、「すでにソート済み」という問題の前提を無視した回り道であり、要素の収集と `sorted()` によるソートを含めた全体の時間計算量が O((n+m) log(n+m)) となるうえ、空間計算量も O(n+m) と余計にメモリを使う。 +- **Kotlin特有の最適化ポイント**: + - `` `val` `` は `Int` を直接プロパティとして持つ設計であり、比較 `list1.\`val\` <= list2.\`val\`` は素直な整数比較で完結する(JVM上でボクシングが最適化される場合が多い)。 + - `tail` を `var`(再代入可能)として宣言し、それ以外の不変な部分(ダミーノードそのものへの参照など)は極力 `val` にすることで、意図しない再代入をコンパイラに防いでもらう。 + +> 📖 **このセクションで登場した用語** +> +> - **`val` / `var`**:`val` は一度代入したら再代入できない変数、`var` は再代入可能な変数を宣言するキーワード。Kotlinでは可能な限り `val` を使うことが推奨される + +--- + +## 4. 実装コード + +> 💡 コード全体の骨格: +> +> 1. 結果リストの先頭を仮に指し示す「ダミーノード」を用意する +> 2. `list1` と `list2` の先頭同士を比較し、小さい方を結果リストの末尾につなげる。つないだ方のリストを1つ進める +> 3. どちらかが `null` になるまで2を繰り返す +> 4. 片方が尽きたら、残った方(すでにソート済み)をそのまま丸ごとつなげる +> 5. ダミーノードの次(`dummy.next`)が本当の答えの先頭なので、それを返す + +```kotlin +/** + * Example: + * var li = ListNode(5) + * var v = li.`val` + * Definition for singly-linked list. + * class ListNode(var `val`: Int) { + * var next: ListNode? = null + * } + */ +class ListNode(var `val`: Int) { + var next: ListNode? = null +} + +class Solution { + /** + * 2つのソート済み連結リストを1つのソート済み連結リストにマージする。 + * + * 重要:list1 / list2 が null であることは異常値ではなく、 + * 「空のリストが渡された」という正常な状態を表す。 + * そのため例外(throw)は投げない。 + * + * ダミーノード1個(`ListNode(0)`)を除き新規リストノードは作成せず、既存ノードの next プロパティを + * つなぎ替える(スプライシング)ことでマージするため、 + * 追加のオブジェクト生成はダミーノード1個のみで済む(空間計算量 O(1))。 + * + * 計算量: Time O(n + m), Space O(1) + * (n, m はそれぞれ list1, list2 のノード数) + * + * @param list1 1つ目のソート済み連結リストの先頭(空リストなら null) + * @param list2 2つ目のソート済み連結リストの先頭(空リストなら null) + * @return マージ後のソート済み連結リストの先頭(両方が空リストなら null) + */ + fun mergeTwoLists(list1: ListNode?, list2: ListNode?): ListNode? { + // ダミーノード(番兵ノード)を用意する。 + // このノード自体には意味のある値は入れない(値は0で仮置き)。 + // こうすることで「結果リストがまだ空のとき、先頭をどう扱うか」 + // という特別な分岐を書かずに済む。 + // val にしている理由:dummy という「変数」自体を後から + // 別のオブジェクトに差し替えることはない(中身の next プロパティは + // 書き換えるが、dummy が指すオブジェクト自体は最後まで同じ)ため。 + val dummy = ListNode(0) + + // tail は「これまでにつなぎ終えた部分の末尾ノード」を指す変数。 + // ループのたびに別のノードを指すよう再代入するため var で宣言する。 + // 型は ListNode(? なし)であり、これは「tail は絶対に null にならない」 + // ことをコンパイラに保証させるための意図的な設計。 + var tail = dummy + + // 引数の list1・list2 は val パラメータであり Kotlin では + // 関数の仮引数は暗黙的に val 扱い(再代入不可)のため、 + // ローカルの var(currentList1・currentList2)に保持し、 + // 後続処理(while ループ内)でポインタを更新できるようにする。 + var currentList1 = list1 + var currentList2 = list2 + + // currentList1、currentList2の両方にまだノードが残っている間、 + // 先頭同士の値を比較して小さい方を tail の後ろにつなげる。 + // この条件式のおかげで、ループの内側ではスマートキャストが働き、 + // currentList1・currentList2 は ListNode?(null許容)ではなく + // ListNode(null非許容)として安全に扱われる。 + while (currentList1 != null && currentList2 != null) { + if (currentList1.`val` <= currentList2.`val`) { + // currentList1の先頭値のほうが小さい(同値含む)ので、 + // currentList1の現在ノードを結果リストの末尾につなげる + tail.next = currentList1 + // currentList1を1つ次に進める(つないだノードは処理済みなので進める) + currentList1 = currentList1.next + } else { + // currentList2の先頭値のほうが小さいので、currentList2側をつなげる + tail.next = currentList2 + currentList2 = currentList2.next + } + // tailを、たった今つないだノードの位置まで進める。 + // tail.next は直前の if/else で必ず何らかのノードを代入したばかりなので + // null ではないと分かっているが、tail.next 自体の型は ListNode?(プロパティは + // スマートキャストの対象外になりやすい)のため、!!(非null表明演算子)で + // 「ここは絶対に null ではない」と明示的にコンパイラへ伝える。 + tail = tail.next!! + } + + // ループを抜けた時点で、currentList1 か currentList2 のどちらか(または両方)が + // null(尽きた状態)になっている。 + // 残っている側はすでにソート済みなので、1つずつ比較する必要はなく、 + // そのまま丸ごと tail の後ろにつなげてよい。 + // ?: はエルビス演算子:currentList1が null でなければcurrentList1を、 + // null ならcurrentList2(null かもしれない)を返す。 + tail.next = currentList1 ?: currentList2 + + // dummyノード自体はデータを持たない仮のノードなので、 + // 実際の答えの先頭は dummy.next である + return dummy.next + } +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> 入力:`list1 = [1,2,4]`、`list2 = [1,3,4]` +> +> ``` +> 初期状態: dummy.next = null +> tail = dummy +> currentList1 = [1,2,4], currentList2 = [1,3,4] +> +> Step 1: currentList1.val(1) <= currentList2.val(1) → true → currentList1の"1"をつなぐ +> 結果: dummy -> 1(from list1) +> currentList1は次の "2" に進む、tailは今つないだ "1" に進む +> +> Step 2: currentList1.val(2) vs currentList2.val(1) → currentList2の"1"のほうが小さい → currentList2をつなぐ +> 結果: dummy -> 1 -> 1(from list2) +> currentList2は次の "3" に進む、tailは今つないだ "1" に進む +> +> Step 3: currentList1.val(2) <= currentList2.val(3) → currentList1の"2"をつなぐ +> 結果: dummy -> 1 -> 1 -> 2 +> currentList1は次の "4" に進む +> +> Step 4: currentList1.val(4) vs currentList2.val(3) → currentList2の"3"のほうが小さい → currentList2をつなぐ +> 結果: dummy -> 1 -> 1 -> 2 -> 3 +> currentList2は次の "4" に進む +> +> Step 5: currentList1.val(4) <= currentList2.val(4) → currentList1の"4"をつなぐ(同値はlist1優先) +> 結果: dummy -> 1 -> 1 -> 2 -> 3 -> 4 +> currentList1はnullになる(尽きた) +> +> Step 6: while条件 (currentList1 != null && currentList2 != null) が false になりループ終了 +> currentList1 == null なので、tail.next = currentList1 ?: currentList2 → currentList2(=[4])がつながる +> 結果: dummy -> 1 -> 1 -> 2 -> 3 -> 4 -> 4 +> +> Step 7: dummy.next を返す → [1,1,2,3,4,4](期待通りの出力) +> ``` + +> 💡 **なぜ引数をそのまま使わず `currentList1`/`currentList2` に持ち替えたのか(初学者向け)** +> Kotlinの関数パラメータ(今回で言う `list1`、`list2`)は、Javaの `final` 引数と同様に**暗黙的に再代入不可**です。 +> +> ```kotlin +> fun mergeTwoLists(list1: ListNode?, list2: ListNode?): ListNode? { +> list1 = list1?.next // コンパイルエラー! list1 は val 扱いで再代入できない +> } +> ``` +> +> このアルゴリズムは「リストを1つずつ進めながら辿る」処理なので、途中で再代入が必要です。そのため `var currentList1 = list1` という**別の `var` 変数**を用意し、そちらを進めていく必要があります。JavaやC#では引数自体が再代入可能なのでこのような書き換えは不要でしたが、Kotlinでは「関数の引数は変更しない」という設計思想がデフォルトで強制される点が特徴的な違いです。 + +> 📖 **このセクションで登場した用語** +> +> - **`?` (nullable型宣言)**:`ListNode?` のように型名の後ろに付け、「この変数は `null` を許容する」ことを示す記法 +> - **`!!`(非null表明演算子)**:`ListNode?` 型の値を、コンパイラに対して「実は `null` ではない」と明示的に伝え、`ListNode`(null非許容)として扱わせる演算子。誤って `null` に使うと `NullPointerException` が発生する +> - **`?:`(エルビス演算子)**:`a ?: b` は「`a` が `null` でなければ `a` を、`null` なら `b` を返す」という演算子。名前の由来は `?:` を横に倒すとエルビス・プレスリーの髪型に見えることから +> - **関数パラメータの暗黙的immutability**:Kotlinの関数引数はデフォルトで再代入不可(`val` 相当)という設計。再代入が必要な場合は別の `var` 変数を用意する必要がある + +--- + +## 5. 検証 + +> 💡 エッジケースとは「入力が空・最小値・最大値・重複あり」など、通常とは異なる境界的な入力のことです。境界値の確認は、アルゴリズムが"ふつうの入力"だけでなく"極端な入力"でも正しく動くかを確かめるために重要です。 + +- **境界値テスト(頭の中でのトレース確認)**: + - `list1=null, list2=null`(Example 2):`while` の条件が最初から偽(両方 `null`)になるため即座にループを抜ける。`tail.next = currentList1 ?: currentList2` は `null ?: null` → `null`。`dummy.next` も `null` のまま。結果は `[]` で正しい。 + - `list1=null, list2=[0]`(Example 3):`while` の条件が最初から偽(`currentList1` が `null`)になるため即座に終了。`tail.next = null ?: currentList2` → `currentList2`(`[0]`)がそのままつながる。結果は `[0]` で正しい。 + - 片方が他方より大幅に長い場合(例:`list1=[1]`, `list2=[2,3,4,5]`):`currentList1` の1件を消費した時点で `currentList1` が `null` になり、ループを抜けて `currentList2` の残り全部(`[2,3,4,5]`)が丸ごとつながる。個別に比較し直す必要がないことが確認できる。 + - 同値が並ぶ場合(例:`list1=[1,1]`, `list2=[1,1]`):`<=` の比較により、同値のときは常に `list1` 側が先に採用される。安定した挙動になっており、順序の破綻はない。 + +- **null安全性の確認**:``currentList1.`val```や``currentList2.`val```へのアクセスは、すべて`while (currentList1 != null && currentList2 != null)` の条件によってスマートキャストが効いた範囲内でのみ行われています。`tail.next!!`の`!!`使用も、直前の代入によって`null` でないことが論理的に保証されている箇所のみに限定しているため、Kotlinコンパイラの警告は発生しません。 + +> 📖 **このセクションで登場した用語** +> +> - **エッジケース**:空のリスト・要素1つ・最大サイズ入力など、境界的な条件のこと +> - **境界値テスト**:エッジケースに対してもアルゴリズムが正しく動くかを確かめること +> - **`NullPointerException`**:`null` の参照に対してメンバーアクセスを行おうとした際に発生する実行時例外。`!!` の誤用時にKotlinでも発生しうる(JVM上で動作するため) + +--- + +## 6. 制約条件の確認 + +- 外部ライブラリは使用していません(Kotlin標準ライブラリのみで完結しています)。 +- 追加のオブジェクト生成はダミーノード1個のみで、それ以外は既存ノードの参照のつなぎ替えのみで完結しています。 +- クラス名 `Solution`・`ListNode` はパスカルケース、関数名 `mergeTwoLists`/変数名 `dummy`/`tail`/`currentList1`/`currentList2` はキャメルケースというKotlinの標準的な命名規則([Kotlin Coding Conventions](https://kotlinlang.org/docs/coding-conventions.html))に従っています。 +- 予約語と衝突する `` `val` `` プロパティ名は、問題側の定義に合わせてそのままバッククォート付きで使用しています。 + +**計算量まとめ**:時間計算量 O(n+m)、空間計算量 O(1)(n, m はそれぞれ list1, list2 のノード数)。 diff --git a/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Python.md b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Python.md new file mode 100644 index 00000000..23b71dcf --- /dev/null +++ b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Python.md @@ -0,0 +1,252 @@ +# LeetCode 21: Merge Two Sorted Lists(Python版) + +## 1. 問題分析結果 + +> 💡 この問題は、一言で言うと「すでに整列された2つの連結リストを、新しいノードを作らずに1つの整列済みリストへ“縫い合わせる”問題」です。 + +**CPython特有の注意点を先にまとめます**:この問題は再帰で書くと非常に短くコンパクトになりますが、CPythonの再帰にはデフォルトで約1000回という呼び出し回数の上限(`sys.getrecursionlimit()`)があります。今回は制約上ノード数が最大50なので問題になりませんが、「短く書けるから」という理由だけで安易に再帰を選ぶと、入力サイズが変わったときに `RecursionError`(=再帰の呼び出し回数が上限を超えたときに発生する例外)を引き起こすリスクがある、という点は覚えておくべきです。 + +### 競技プログラミング視点 + +- **制約分析**:ノード数は最大50件×2リストなので、どんなアルゴリズムを選んでも実行時間の心配はほぼありません。ここでは「制約に守られて何でも通る」問題ではなく「正しい設計を学ぶための」問題として扱います。 +- **最速手法**:各ノードを1回ずつしか見ない反復(ループ)処理が理論上も実装上も最速です。時間計算量は O(n+m)(n, mはそれぞれのリストの長さ)。 +- **メモリ最小化**:新しいノードを一切生成せず、既存ノードの `next` 参照だけを付け替える「スプライシング(splicing、つなぎ替え)」で空間計算量 O(1) を実現します。 +- **CPython最適化**:この問題はリストや辞書のような組み込みコレクションを扱うわけではなく、自前の連結ノード構造を1つずつ処理する問題なので、`sum()` や内包表記のようなC実装の恩恵をそのまま活かせる場面は限定的です。最大の最適化は「余計なオブジェクトを作らないこと」そのものです。 + +#### 業務開発視点 + +- **型安全設計**:`Optional[ListNode]`(= `ListNode` または `None` のどちらかであることを表す型ヒント)を使い、「リストが空である」という状態を型レベルで明示します。 +- **エラーハンドリング**:この問題における最大の注意点は、**「`list1` や `list2` が `None` であることは異常値ではなく、空リストを表す正常な状態」**という点です。テンプレートによくある「入力が空ならエラー」というパターンをそのまま当てはめると、Example 2(`list1=[], list2=[]`)のような正しい入力を誤って拒否してしまいます。 +- **可読性**:ダミーノード(番兵ノード)パターンを使うことで、「リストが空のときの特別扱い」の分岐を書かずに済み、コード全体がシンプルになります。 + +#### Python特有分析 + +- **データ構造選択**:`list`(Python標準のリスト型)や `deque`(両端キュー)は使いません。この問題は自前の連結リスト構造(`ListNode`)を直接操作する問題であり、Python標準コレクションへの変換はメモリ面でも計算量面でも不利になるためです。 +- **標準ライブラリ活用度**:`heapq` や `collections` を持ち出すこともできますが、単純な2本のポインタ比較で事足りるため、標準ライブラリへの依存はむしろ過剰設計(オーバーエンジニアリング)になります。 +- **CPython最適化度**:この問題の最適化の本質は「アルゴリズム自体を O(n+m) に保つこと」であり、組み込み関数の活用余地は小さいです。 + +> 📖 **このセクションで登場した用語** +> +> - **CPython**:最も広く使われるPythonの実装。C言語で書かれており、組み込み関数の多くがC実装のため高速 +> - **`RecursionError`**:再帰関数の呼び出しが深くなりすぎて、Pythonの再帰呼び出し上限(デフォルト約1000回)を超えたときに発生する例外 +> - **スプライシング(splicing)**:新しいノードを作らず、既存ノードの参照をつなぎ替えることでデータ構造を組み替える操作 +> - **`Optional[ListNode]`**:`ListNode` または `None` のどちらかであることを表す型ヒント。`Union[ListNode, None]` の短縮形 + +--- + +## 2. 採用アルゴリズムと根拠 + +> 💡 同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」、そしてPythonらしい実装コストを比べて最適なものを選びます。 + +| アプローチ | 時間計算量 | 空間計算量 | Python実装コスト | 可読性 | 標準ライブラリ活用 | CPython最適化 | 備考 | +| ------------------------------------------------- | ---------------- | ---------- | ---------------- | ------ | ------------------ | ------------- | ------------------------------------------ | +| 方法A:反復(ダミーヘッド + 2ポインタ) | O(n+m) | O(1) | 低 | ★★★ | 不要 | 適 | 推奨。再帰呼び出しのオーバーヘッドがない | +| 方法B:再帰(先頭同士を比較して自己呼び出し) | O(n+m) | O(n+m) | 低 | ★★★ | 不要 | 不適 | コードは短いがコールスタックを消費 | +| 方法C:両リストを`list`に集めて`sorted()`でソート | O((n+m)log(n+m)) | O(n+m) | 中 | ★★☆ | `sorted()` | 適(C実装) | すでにソート済みという前提を無視した回り道 | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n+m)`:2つの入力の合計サイズに比例して処理が増える(線形) +> - `O((n+m) log(n+m))`:ソートに典型的な計算量。入力が増えるとやや急激に手間が増える + +- **選択理由**:方法Aは方法Bと同じ O(n+m) の時間計算量を持ちながら、空間計算量は O(1) で済みます(方法Bはコールスタック=関数呼び出しの履歴を積んでおくメモリ領域にO(n+m)を消費します)。方法Cは `sorted()` がC実装で高速だとしても、「すでにソート済み」という問題の前提を捨ててしまっており、時間・空間の両方で無駄があります。 +- **Python最適化戦略**:ダミーヘッド(番兵ノード)を使い、`while` ループ中で `<=` による単純比較のみを行います。オブジェクト生成を一切行わないため、ガベージコレクション(=使われなくなったオブジェクトを自動的に片付ける仕組み)の負荷も最小限に抑えられます。 +- **トレードオフ**:方法Bのほうがコード行数はわずかに短くなりますが、再帰は「入力サイズが将来大きくなる可能性」に対して脆弱です。今回は保守性・堅牢性を優先し、反復(方法A)を採用します。 + +> 📖 **このセクションで登場した用語** +> +> - **コールスタック**:関数呼び出しの「呼び出し履歴」を積んでおくメモリ領域。再帰が深くなるとここに積まれる分だけメモリを消費する +> - **ガベージコレクション(GC)**:もう使われなくなったオブジェクトのメモリを自動的に解放してくれるPythonの仕組み。オブジェクト生成を減らすほどGCの負荷も減る +> - **ダミーヘッド(番兵ノード)**:リストの先頭に、実データを持たない仮のノードを1つ置いておくテクニック。「リストが空かどうか」で処理を分岐させる必要がなくなる + +--- + +## 3. 実装パターン + +> 💡 コード全体の骨格: +> +> 1. 結果リストの先頭を仮に指し示す「ダミーノード」を用意する +> 2. `tail`(=これまでにつなぎ終えた部分の末尾を指す変数)をダミーノードに合わせておく +> 3. `list1` と `list2` の先頭同士を比較し、小さい方を `tail` の後ろにつなげ、つないだ方のリストと `tail` を1つ進める +> 4. どちらかが尽きるまで3を繰り返す +> 5. 片方が尽きたら、残った方(すでにソート済み)をそのまま丸ごとつなげる +> 6. `dummy.next`(ダミーノードの次)が本当の答えの先頭なので、それを返す + +**【業務開発版を使う場面】** +チームで長期間メンテナンスするプロダクションコードに向きます。入力が本当に `None`(空リスト)であっても正しく動作することをコメントで明示し、後から読んだ人が「なぜここで `None` チェックをしていないのか/しているのか」を迷わないようにしています。 + +```python3 +from typing import Optional + + +# Definition for singly-linked list. +# class ListNode: +# def __init__(self, val=0, next=None): +# self.val = val +# self.next = next +class ListNode: + """LeetCode標準の連結リストノード定義(コメントアウトされたものと同一)。""" + + def __init__(self, val: int = 0, next: "Optional[ListNode]" = None) -> None: + self.val: int = val + self.next: Optional["ListNode"] = next + + +class Solution: + """LeetCode 21: Merge Two Sorted Lists 解決クラス""" + + def mergeTwoLists( + self, list1: Optional[ListNode], list2: Optional[ListNode] + ) -> Optional[ListNode]: + """ + 2つのソート済み連結リストを1つのソート済み連結リストにマージする(業務開発版)。 + + 重要:list1 / list2 が None であることは異常値ではなく、 + 「空のリストが渡された」という正常な状態を表す。 + そのため ValueError 等は送出しない。 + + ListNode() ダミーノードを1つだけアロケーションし、他の新しいリストノードは作成せずに + 既存ノードの next 参照をつなぎ替える(スプライシング)ことでマージするため、 + 補助記憶空間(Auxiliary Space)は O(1) となる。 + + Args: + list1: 1つ目のソート済み連結リストの先頭ノード(空リストなら None) + list2: 2つ目のソート済み連結リストの先頭ノード(空リストなら None) + + Returns: + マージ後のソート済み連結リストの先頭ノード + (両方が空リストの場合は None) + """ + # ダミーノード(番兵ノード)を用意する。 + # 実データとしての意味は持たせず、値は0で仮置きする。 + # これにより「結果リストがまだ空のときの特別扱い」を + # 一切書かずに済む。 + dummy: ListNode = ListNode() + + # tail は「これまでにつなぎ終えた部分の末尾ノード」を指す変数。 + # 最初はダミーノード自身を指しておく。 + tail: ListNode = dummy + + # list1、list2 の両方にまだノードが残っている間、 + # 先頭同士の値を比較して小さい方を tail の後ろにつなげる。 + # Python は動的型付け言語だが、型ヒントを付けているため + # pylance が list1 / list2 の None チェック漏れを検出してくれる。 + while list1 is not None and list2 is not None: + if list1.val <= list2.val: + # list1の先頭値のほうが小さい(同値含む)ので + # list1の現在ノードを結果リストの末尾につなげる + tail.next = list1 + # list1を1つ次に進める + list1 = list1.next + else: + # list2の先頭値のほうが小さいので list2側をつなげる + tail.next = list2 + list2 = list2.next + # tailを、たった今つないだノードの位置まで進める + tail = tail.next + + # ループを抜けた時点で、list1 か list2 のどちらか(または両方)が + # None(尽きた状態)になっている。 + # 残っている側はすでにソート済みなので、そのまま丸ごと + # tail の後ろにつなげてよい(一方が None なら None がそのままつながるだけ)。 + tail.next = list1 if list1 is not None else list2 + + # dummyノード自体はデータを持たない仮のノードなので、 + # 実際の答えの先頭は dummy.next である + return dummy.next +``` + +**【競技プログラミング版を使う場面】** +LeetCodeやAtCoderなど、制限時間内に正解を出すことが目的のコードに向きます。docstringやコメントを最小限にし、変数名も短くして、書く手間と読む手間の両方を削っています。 + +```python3 +from typing import Optional + + +class Solution: + def mergeTwoLists( + self, list1: Optional[ListNode], list2: Optional[ListNode] + ) -> Optional[ListNode]: + # ダミーヘッド + 2ポインタ。O(n+m) / O(1)。 + dummy = tail = ListNode() + while list1 and list2: + if list1.val <= list2.val: + tail.next, list1 = list1, list1.next + else: + tail.next, list2 = list2, list2.next + tail = tail.next + tail.next = list1 or list2 + return dummy.next +``` + +> 💡 **業務開発版と競技版の違いのポイント**: +> 競技版では `tail.next, list1 = list1, list1.next` のようにタプル代入(=複数の変数へ同時に値を代入する書き方)を使い、代入を1行にまとめています。また `list1 and list2` は `list1 is not None and list2 is not None` の短縮形として使っていますが、これは `ListNode` に `__bool__` や `__len__` が定義されていないため、`None` かどうかだけで真偽が決まる(=空でないオブジェクトは常に真)という前提に依存しています。業務開発版では意図を明確にするためあえて `is not None` と書いています。 + +> 💡 **コードの動作トレース**(初学者向け) +> +> 入力:`list1 = [1,2,4]`、`list2 = [1,3,4]` +> +> ``` +> 初期状態: dummy -> (何も繋がっていない) +> tail = dummy +> list1 = [1,2,4], list2 = [1,3,4] +> +> Step 1: list1.val(1) <= list2.val(1) → True → list1の"1"をつなぐ +> dummy -> 1(list1由来) +> list1 は次の "2" へ、tail はつないだ "1" へ移動 +> +> Step 2: list1.val(2) vs list2.val(1) → list2の"1"のほうが小さい → list2をつなぐ +> dummy -> 1 -> 1(list2由来) +> list2 は次の "3" へ +> +> Step 3: list1.val(2) <= list2.val(3) → list1の"2"をつなぐ +> dummy -> 1 -> 1 -> 2 +> list1 は次の "4" へ +> +> Step 4: list1.val(4) vs list2.val(3) → list2の"3"のほうが小さい → list2をつなぐ +> dummy -> 1 -> 1 -> 2 -> 3 +> list2 は次の "4" へ +> +> Step 5: list1.val(4) <= list2.val(4) → list1の"4"をつなぐ(同値はlist1優先) +> dummy -> 1 -> 1 -> 2 -> 3 -> 4 +> list1 は None になる(尽きた) +> +> Step 6: while条件 (list1 is not None and list2 is not None) が False → ループ終了 +> list1 が None なので、残っている list2(= [4])を丸ごとつなぐ +> dummy -> 1 -> 1 -> 2 -> 3 -> 4 -> 4 +> +> Step 7: dummy.next を返す → [1,1,2,3,4,4](期待通りの出力) +> ``` + +> 💡 **型ヒントとpylanceの関係(初学者向け)** +> `Optional[ListNode]` という型ヒントを書いておくことで、pylance(VSCodeの型チェッカー)が「`list1.val` にアクセスする前に `None` チェックをしていない」といったミスを実行前に警告してくれます。今回のコードでは `while list1 is not None and list2 is not None:` というガード(=安全確認のための条件分岐)の内側でのみ `list1.val` にアクセスしているため、pylanceは「ここでは `list1` が `None` ではないと保証されている」と型を絞り込み(=型の絞り込み。英語で narrowing)、エラーを出しません。 + +> 📖 **このセクションで登場した用語** +> +> - **タプル代入**:`a, b = b, a` のように複数の変数へ同時に値を代入するPythonの書き方。一時変数を使わずに値の入れ替えができる +> - **`__bool__` / 真偽値判定**:Pythonのオブジェクトが `if obj:` のように書かれたときに真か偽かを判定する仕組み。`__bool__` が定義されていない場合、`None` 以外は基本的に真として扱われる +> - **型の絞り込み(narrowing)**:`if x is not None:` のような条件分岐の内側で、型チェッカーが「この範囲内では `x` は `None` ではない」と自動的に判断してくれる仕組み +> - **docstring**:関数やクラスの先頭に書く説明文。`"""三重クォート"""` で囲む + +--- + +## 4. 検証 + +> 💡 エッジケースとは「入力が空・最小値・最大値・重複あり」など、通常とは異なる境界的な入力のことです。エッジケースの確認は、アルゴリズムが"ふつうの入力"だけでなく"極端な入力"でも正しく動くかを確かめるために重要です。 + +- **境界値テスト(頭の中でのトレース確認)**: + - `list1=[], list2=[]`(Example 2):`dummy` は最初から誰ともつながらず、`while` ループの条件が最初から偽(両方 `None`)になるため即座に終了。`tail.next = list1 if list1 is not None else list2` は `None if None is not None else None` → `None` となり、`dummy.next` も `None` のまま。結果は `[]` で正しい。 + - `list1=[], list2=[0]`(Example 3):`while` ループの条件が最初から偽(`list1` が `None`)になるため即座に終了。`tail.next = list2`(`[0]`)がそのままつながる。結果は `[0]` で正しい。 + - 片方が他方より大幅に長い場合(例:`list1=[1]`, `list2=[2,3,4,5]`):`list1` の1件を消費した時点で `list1` が `None` になり、ループを抜けて `list2` の残り全部(`[2,3,4,5]`)が丸ごとつながる。個別に比較し直す必要がないことが確認できる。 + - 同値が並ぶ場合(例:`list1=[1,1]`, `list2=[1,1]`):`<=` の比較により、同値のときは常に `list1` 側が先に採用される。安定した挙動になっており、順序の破綻はない。 + +- **型チェック(pylance対応)**:全ての引数・戻り値・ローカル変数に型ヒント(`Optional[ListNode]`、`ListNode`)を付与しており、`None` の可能性がある箇所は `is not None` ガードの内側でのみアクセスしているため、pylanceの厳格モード(strict mode)でも警告は発生しません。 + +> 📖 **このセクションで登場した用語** +> +> - **エッジケース**:空のリスト・要素1つ・最大サイズ入力など、境界的な条件のこと +> - **境界値テスト**:エッジケースに対してもアルゴリズムが正しく動くかを確かめること +> - **strict mode(厳格モード)**:pylanceの型チェックをより厳しく行う設定。未使用変数や `Any` 型の暗黙的な使用なども検出対象になる + +**計算量まとめ**:時間計算量 O(n+m)、空間計算量 O(1)(n, m はそれぞれ list1, list2 のノード数)。 diff --git a/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Rust.md b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Rust.md new file mode 100644 index 00000000..31f6e9f3 --- /dev/null +++ b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Rust.md @@ -0,0 +1,294 @@ +# LeetCode 21: Merge Two Sorted Lists(Rust版) + +## 1. 問題の分析 + +> 💡 この問題は、一言で言うと「すでに整列された2つの連結リストを、新しいノードを作らずに1つの整列済みリストへ“縫い合わせる”問題」です。 + +**Rustで解く際に特に気をつけるべき点**を先にまとめます。Rustの `ListNode` は `next: Option>` という形で、次のノードを**所有(owns)**しています。JavaやTypeScript、Goでは「ポインタ/参照をつなぎ替える」だけで済みましたが、Rustでは**1つの値は同時に1箇所からしか所有できない**という所有権(=値を"誰が管理するか"をコンパイル時に決めるRust独自の仕組み)のルールがあるため、「`list1` の次のノードを取り出して `tail` につなぐ」という操作は、実は**値の"移動"(move)**そのものです。単純に `tail.next = list1.next`(参照のコピー)のようなことはできず、`Option::take()` という「箱の中身を取り出して、元の箱には `None` を残す」操作が中心的な役割を果たします。また、この問題は `Result` を使うようなエラーが発生する余地がなく、`None` は「空リスト」を表す正常な値であるため、エラーハンドリングの主眼は「パニックさせないこと」よりもむしろ「**所有権を正しく受け渡すこと**」そのものにあります。 + +**競技プログラミング視点での分析** + +- 実行速度を最優先するなら、各ノードを1回ずつ見るだけで済む処理が最速。時間計算量は O(n+m)。 +- メモリ使用量の最小化方針としては、ダミーの `Box` を1つ割り当て(ヒープアロケーションはこれ1回のみ)、他の新しいノードの作成やヒープ割り当ては一切行わず、既存の `Box` の値を移動(move)させるだけでマージを完了させます。補助記憶空間(Auxiliary Space)は O(1) に保たれます。 + +**業務開発視点での分析** + +- 型安全性の面では、`Option>` という型そのものが「ノードが存在するか(`Some`)、リストが終端/空か(`None`)」をコンパイル時に表現しています。これはJavaやTypeScriptの `null` チェック忘れによる実行時エラーを、コンパイラが構造的に防いでくれる仕組みです。 +- エラーハンドリングとして `Result` を使う必要はありません。関数シグネチャ自体が `Option>` を返すよう固定されており、`None` は正常な戻り値の一部だからです。 + +**Rust特有の考慮点** + +- 所有権・借用・ライフタイムの設計方針:この問題では引数の `list1` / `list2` を**借用(`&`)ではなく所有権ごと受け取っています**(`Option>` であり `&Option>` ではない)。これは「渡された2つのリストを解体して、新しい1つのリストに再構成する」という処理の性質上、元のリストをそのまま残しておく必要がなく、むしろ**所有権を完全にもらって自由に組み替えたほうが効率的**だからです。 +- トレイト境界とモノモーフィゼーションによる最適化:今回は `ListNode` という具体的な型を扱うため、ジェネリクスやトレイト境界(=ジェネリクス型に「この機能が必要」と制約をつける仕組み)は不要です。 +- イテレータアダプタ vs 命令型ループの選択基準:`.map()` や `.fold()` のようなイテレータアダプタは「連続したコレクションを順に処理する」場面に強みがありますが、連結リストの所有権を1ノードずつ移動させながら別の構造に組み替える今回の処理は、`while let` を使った明示的なループ(または再帰)のほうが所有権の流れを追いやすく適しています。 + +> 📖 **このセクションで登場した用語**(例) +> +> - **所有権**:値を"誰が管理するか"をコンパイル時に決めるRust独自の仕組み。1つの値は同時に1箇所からしか所有できない +> - **移動(move)**:ある変数が持つ値の所有権を、別の変数へ渡す操作。移動元の変数はその後使えなくなる +> - **借用**:所有権を渡さずに値を参照する仕組み。`&T`(読み取り専用)と`&mut T`(書き込み可能)がある +> - **`Option`**:値があるかないかを型で表現する列挙型。`Some(値)` か `None` のどちらか。他言語の `null` と違い、コンパイラが「中身を確認せずに使うこと」を許さない +> - **ヒープアロケーション**:`Box::new(...)` のように、ヒープ上にメモリを新たに確保する操作。今回のアルゴリズムではダミーノード用の1回を除き新規のアロケーションを行わない + +--- + +## 2. アルゴリズムアプローチ比較 + +> 💡 同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」、そしてRust固有の観点(所有権の移動が発生するか・アロケーションが必要か)を比べて最適なものを選びます。 + +| アプローチ | 時間計算量 | 空間計算量 | Rust実装コスト | 安全性 | 可読性 | 備考 | +| ------------------------------------------ | ---------------- | ------------------------ | -------------- | ------ | ------ | --------------------------------------------------------- | +| 方法A:反復(`take()` + 可変参照の再借用) | O(n+m) | O(1)(補助領域) | 中 | 高 | 中 | `unsafe`不要。所有権の流れがやや複雑 | +| 方法B:再帰(`match`でパターンマッチ) | O(n+m) | O(n+m)(コールスタック) | 低 | 高 | 高 | コードは短く直感的。所有権の移動が自然に書ける | +| 方法C:両方を`Vec`に集めてソート | O((n+m)log(n+m)) | O(n+m) | 中 | 高 | 中 | `Box`の中身を都度取り出す必要がありアロケーションも増える | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n+m)`:2つの入力の合計サイズに比例して手間が増える(線形) +> - `O((n+m) log(n+m))`:ソートに典型的な計算量。入力が増えるとやや急激に手間が増える + +**Rust固有の観点からの補足**: + +- 方法A(反復)は、`list1.take()` によって `list1` から中身を"奪い"、その所有権を新しい構造につなぎ込みます。新規の `Box` はダミーノード用の1つを除いて作らないため、ヒープアロケーションは1回のみです。ただし「今つないだノードの次に、また次をつなげる」ために `tail` という**可変参照(`&mut`)の再借用**を毎回行う必要があり、借用チェッカー(=`&T`と`&mut T`の使い方をコンパイル時にチェックする仕組み)との付き合い方にやや習熟が必要です。 +- 方法B(再帰)は、`match (list1, list2) { ... }` のパターンマッチで所有権の移動が非常に自然に書けます。ただし、再帰1回につきコールスタック(=関数呼び出しの履歴を積んでおくメモリ領域)を1段消費するため、空間計算量は O(n+m) になります。 +- 方法C(ソート)は、リストの中身を一度 `Vec` に取り出す際に `Box` の中身を1つずつ移動させる必要があり、実装がかえって複雑になるうえ、「すでにソート済み」という前提を活かせません。 + +> 📖 **このセクションで登場した用語** +> +> - **借用チェッカー**:`&T`(共有参照)と`&mut T`(排他参照)が同時に競合しないかをコンパイル時にチェックするRustコンパイラの仕組み。データ競合をコンパイル時に排除できる +> - **再借用(reborrow)**:すでに借用しているものから、さらに一時的に借用し直す操作。今回は `tail` を毎ループ更新する際に発生する +> - **コールスタック**:関数呼び出しの「呼び出し履歴」を積んでおくメモリ領域。再帰が深くなるとここに積まれる分だけメモリを消費する + +--- + +## 3. 選択したアルゴリズムと理由 + +- **選択したアプローチ**:方法A(反復・`take()`による所有権移動 + 可変参照の再借用) +- **理由**: + - **計算量的な優位性**:方法Bと同じ O(n+m) の時間計算量を持ちながら、空間計算量は補助領域 O(1) で済みます(方法Bはコールスタックに O(n+m) を消費します)。今回の制約(ノード数最大50)では方法Bでもスタックオーバーフローの危険はまずありませんが、業務コードとして「入力サイズに比例してスタックが伸びる」設計は将来の拡張性に対して脆いという判断です。 + - **Rustの所有権モデルとの親和性**:`Option::take()` は「箱の中身を取り出し、元の箱には `None` を残す」というRust特有の操作で、所有権を安全に移動させながら**二重解放(同じメモリを2回解放してしまうバグ)やダングリングポインタ(=すでに無効になったメモリを指し続けるポインタ)を、コンパイラが構造的に防いでくれます**。これはC++で同様のコードを書く場合と比べた、所有権モデルの大きな恩恵です。 + - **保守性・可読性**:反復1本で完結し、`unsafe` ブロックを一切使わないため、コンパイラの安全性保証をフルに活かせます。 +- **Rust特有の最適化ポイント**: + - ゼロコスト抽象化(=便利な書き方をしても、手書きの低レベルコードと同じ速さになる性質)の観点から、`Option>` の `match` や `take()` はいずれもコンパイル後には単純なポインタ操作とヌルチェックに近いコードへ最適化されます。 + - スタックアロケーション優先の観点では、今回のアルゴリズムはダミーノード1つを除くヒープアロケーションを行わないため、「新しいノードのために `Box::new` を呼ぶ」というコストが繰り返し発生することはありません。 + +> 📖 **このセクションで登場した用語** +> +> - **ゼロコスト抽象化**:便利な高レベルな書き方(`Option`や`match`など)をしても、手書きの低レベルコードと同等の速さになるRustの特性 +> - **二重解放(double free)**:同じメモリ領域を誤って2回解放してしまうバグ。C/C++では手動で気をつける必要があるが、Rustは所有権システムでコンパイル時に防ぐ +> - **ダングリングポインタ**:すでに解放された(無効になった)メモリを指し続けているポインタ。Rustの借用チェッカーがコンパイル時に防ぐ + +--- + +## 4. 実装コード + +> 💡 コード全体の骨格: +> +> 1. 結果リストの先頭を仮に指し示す「ダミーノード」を `Box` として1つだけ確保する +> 2. `tail` という可変参照を使って「今つなぎ終えた部分の末尾」を追いかける +> 3. `list1` と `list2` の先頭同士を比較し、値が小さいほうを `take()` で取り出して `tail.next` につなぐ。取り出したノードの `next` を新しい `list1`(または `list2`)とする +> 4. どちらかが `None` になるまで3を繰り返す +> 5. 残った方(すでにソート済み)を丸ごと `tail.next` につなぐ +> 6. `dummy.next` が本当の答えの先頭なので、それを `Option` ごと返す + +```rust +// Definition for singly-linked list. +// #[derive(PartialEq, Eq, Clone, Debug)] +// pub struct ListNode { +// pub val: i32, +// pub next: Option> +// } +// +// impl ListNode { +// #[inline] +// fn new(val: i32) -> Self { +// ListNode { +// next: None, +// val +// } +// } +// } +struct Solution; + +impl Solution { + /// 2つのソート済み連結リストを1つのソート済み連結リストにマージする。 + /// + /// 新しい `Box`(ヒープ上のノード)は「ダミーノード」1つを除いて + /// 一切生成しない。既存ノードの所有権を `Option::take()` によって + /// 移動させ、そのままつなぎ替える(スプライシング)ことでマージする。 + /// + /// 重要:`list1` / `list2` が `None` であることはエラーではなく、 + /// 「空のリストが渡された」という正常な状態を表す。 + /// そのため `Result` は使わず、`panic!` も発生しない。 + /// + /// # Arguments + /// * `list1` - 1つ目のソート済み連結リストの先頭(所有権ごと受け取る) + /// * `list2` - 2つ目のソート済み連結リストの先頭(所有権ごと受け取る) + /// + /// # Returns + /// マージ後のソート済み連結リストの先頭(両方が空リストなら `None`) + /// + /// # Complexity + /// - Time: O(n + m) + /// - Space: O(1)(補助領域のみ。新規ヒープ確保はダミーノード1個分) + pub fn merge_two_lists( + mut list1: Option>, + mut list2: Option>, + ) -> Option> { + // ダミーノード(番兵ノード)を1つだけヒープに確保する。 + // このノード自体には意味のある値は入れない(値は0で仮置き)。 + // なぜダミーノードを使うか: + // 「結果リストがまだ空のとき、先頭をどう扱うか」という + // 特別な分岐を一切書かずに済むため。 + let mut dummy = Box::new(ListNode::new(0)); + + // tail は「これまでにつなぎ終えた部分の末尾ノード」への + // 可変参照(&mut)。所有権は dummy 側に残したまま、 + // "今どこまでつなぎ終えたか"を指し示すためだけに使う。 + // + // なぜ Box そのものを持ち回さず &mut にするか: + // dummy の所有権自体は最後に dummy.next として返す必要があるため、 + // ループの中では「借用」だけを使って末尾を指し示す。 + let mut tail = &mut dummy; + + // list1、list2 の両方にまだノードが残っている(Someである)間、 + // 先頭同士の値を比較して小さい方を tail の後ろにつなげる。 + while list1.is_some() && list2.is_some() { + // as_ref().unwrap() で中身を「覗き見る」だけ(所有権は奪わない)。 + // is_some() で存在を確認済みなので unwrap() が失敗することはない。 + let take_from_list1 = list1.as_ref().unwrap().val <= list2.as_ref().unwrap().val; + + if take_from_list1 { + // list1.take() で list1 の中身(Box)を"奪う"。 + // 奪った後、元の list1 には None が自動的に残る。 + // これが Rust における「移動」の安全な実現方法。 + let mut node = list1.take().unwrap(); + + // node の next フィールドも take() で奪い、 + // それを新しい list1(=「まだつないでいない残りの部分」)とする。 + // node.next には None が残るので、後で tail につなぐときに + // 誤って残りのリストごと持って行ってしまう事故が起きない。 + list1 = node.next.take(); + + // node(中身を空にした1ノード分の Box)を tail の後ろにつなぐ。 + // ここで node の所有権は tail.next(=dummy構造の内部)へ移動する。 + tail.next = Some(node); + } else { + // list2 側でも同様の操作を行う + let mut node = list2.take().unwrap(); + list2 = node.next.take(); + tail.next = Some(node); + } + + // tail を、たった今つないだノードの位置まで進める。 + // as_mut() で Option> から &mut Box を取り出し、 + // unwrap() で中身にアクセスする(直前で Some を代入したばかりなので安全)。 + // これは「再借用(reborrow)」と呼ばれる操作:tail という借用から、 + // さらに一段先の借用を作り直している。 + tail = tail.next.as_mut().unwrap(); + } + + // ループを抜けた時点で、list1 か list2 のどちらか(または両方)が + // None(尽きた状態)になっている。 + // 残っている側はすでにソート済みなので、1つずつ比較する必要はなく、 + // そのまま丸ごと tail.next につなげてよい。 + // Option::or() は「自分が None なら相手を返す」というメソッド。 + // list1 が Some ならそれを、None なら list2(Some でも None でも)を返す。 + tail.next = list1.or(list2); + + // dummy はローカル変数だが、その中身(dummy.next 以下のチェーン)を + // 丸ごと戻り値として返す。dummy 自身(値0のノード)は + // ここで破棄され、dummy.next 以降の所有権だけが呼び出し元に渡る。 + dummy.next + } +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> 入力:`list1 = [1,2,4]`、`list2 = [1,3,4]` +> +> ``` +> 初期状態: dummy = Box(val:0, next:None) +> tail = &mut dummy +> list1 = Some([1,2,4]), list2 = Some([1,3,4]) +> +> Step 1: list1.val(1) <= list2.val(1) → true +> node = list1.take().unwrap() → node=[1] (元のlist1由来)、list1は一旦None +> list1 = node.next.take() → list1 = Some([2,4])、nodeのnextはNone +> tail.next = Some(node) → dummy -> 1 +> tail = tail.next.as_mut().unwrap() → tailは今つないだ"1"を指す +> +> Step 2: list1.val(2) vs list2.val(1) → list2の"1"のほうが小さい +> node = list2.take().unwrap() → node=[1] (list2由来) +> list2 = node.next.take() → list2 = Some([3,4]) +> tail.next = Some(node) → dummy -> 1 -> 1 +> tail は今つないだ"1(list2由来)"を指す +> +> Step 3: list1.val(2) <= list2.val(3) → list1の"2"をつなぐ +> list1 = Some([4]) +> dummy -> 1 -> 1 -> 2 +> +> Step 4: list1.val(4) vs list2.val(3) → list2の"3"のほうが小さい +> list2 = Some([4]) +> dummy -> 1 -> 1 -> 2 -> 3 +> +> Step 5: list1.val(4) <= list2.val(4) → list1の"4"をつなぐ(同値はlist1優先) +> list1 = None(nodeのnextがNoneだったため) +> dummy -> 1 -> 1 -> 2 -> 3 -> 4 +> +> Step 6: while条件 (list1.is_some() && list2.is_some()) が false → ループ終了 +> list1 = None なので、tail.next = list1.or(list2) → list2(=Some([4]))がつながる +> dummy -> 1 -> 1 -> 2 -> 3 -> 4 -> 4 +> +> Step 7: dummy.next を返す → [1,1,2,3,4,4](期待通りの出力) +> ``` + +> 💡 **`take()` の動きを図で理解する**(初学者向け) +> `Option::take()` は以下の処理と等価です: +> +> ``` +> 実行前: list1 = Some(Box(値)) +> take() 実行 → 戻り値: Some(Box(値)) +> list1 の中身: None (空の箱が残る) +> ``` +> +> これにより、「値を取り出しつつ、元の変数を安全に空にする」という操作を、コンパイラのチェックを通しながら行えます。`std::mem::replace(&mut list1, None)` と本質的に同じ処理ですが、`take()` はそれを読みやすく糖衣構文化(=複雑な処理を短く書けるようにした構文)したものです。 + +> 📖 **このセクションで登場した用語** +> +> - **`Option::take()`**:`Option` の中身を取り出しつつ、元の変数には `None` を残すメソッド。所有権を安全に"奪う"ための標準的な手段 +> - **`Option::or()`**:自分が `None` のとき、代わりに引数の値を返すメソッド。`list1.or(list2)` は「`list1`があればそれを、なければ`list2`を」という意味 +> - **再借用(reborrow)**:すでに借用している `&mut` 参照から、さらに一段先の `&mut` 参照を作り直す操作。`tail = tail.next.as_mut().unwrap()` がその例 +> - **`as_ref()` / `as_mut()`**:`Option` の中身を「奪わずに」参照として取り出すメソッド。それぞれ `Option<&T>` / `Option<&mut T>` を返す + +--- + +## 5. 検証 + +> 💡 エッジケースとは「入力が空・最小値・最大値・重複あり」など、通常とは異なる境界的な入力のことです。境界値の確認は、アルゴリズムが"ふつうの入力"だけでなく"極端な入力"でも正しく動くかを確かめるために重要です。 + +- **境界値テスト(頭の中でのトレース確認)**: + - `list1=None, list2=None`(Example 2):`while` の条件 `list1.is_some() && list2.is_some()` が最初から偽になるため即座にループを抜ける。`tail.next = list1.or(list2)` は `None.or(None)` → `None`。`dummy.next` も `None` のまま。結果は `None`(空リスト)で正しい。 + - `list1=None, list2=Some([0])`(Example 3):ループの条件が最初から偽(`list1` が `None`)になるため即座に終了。`tail.next = None.or(Some([0]))` → `Some([0])` がそのままつながる。結果は `[0]` で正しい。 + - 片方が他方より大幅に長い場合(例:`list1=[1]`, `list2=[2,3,4,5]`):`list1` の1件を消費した時点で `list1` が `None` になり、ループを抜けて `list2` の残り全部が `or()` によって丸ごとつながる。個別に取り出し直す必要がないことが確認できる。 + - 同値が並ぶ場合(例:`list1=[1,1]`, `list2=[1,1]`):`<=` の比較により、同値のときは常に `list1` 側が先に採用される。所有権の移動順序も含めて挙動は安定している。 + +- **所有権・借用に関するチェック**:このコードは `unsafe` ブロックを1つも使用していません。`take()` による所有権の移動と、`as_mut()` による再借用のみで安全にリストを組み替えており、コンパイラの借用チェッカーが二重解放やダングリング参照の可能性をコンパイル時に排除しています。`#![deny(clippy::all)]` 相当のコードクオリティとしても、不要な `clone()` や `unwrap()` の濫用(`is_some()` で存在確認済みの `unwrap()` のみ使用)を避けています。 + +> 📖 **このセクションで登場した用語** +> +> - **エッジケース**:空のリスト・要素1つ・最大サイズ入力など、境界的な条件のこと +> - **境界値テスト**:エッジケースに対してもアルゴリズムが正しく動くかを確かめること +> - **`clippy`**:Rustの静的解析ツール。非効率なコードパターンやより良い書き方を提案してくれる + +--- + +## 6. 制約条件の確認 + +- 外部クレートは使用していません(`std` の `Box` と `Option` のみ)。 +- `unsafe` ブロックは1つも使用していません。 +- 新規のヒープアロケーションはダミーノード1個分のみで、それ以外は既存ノードの所有権移動(`take()`)のみで完結しています。 + +**計算量まとめ**:時間計算量 O(n+m)、空間計算量 O(1)(補助領域のみ。n, m はそれぞれ list1, list2 のノード数)。 diff --git a/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Swift.md b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Swift.md new file mode 100644 index 00000000..45532711 --- /dev/null +++ b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Swift.md @@ -0,0 +1,273 @@ +# LeetCode 21: Merge Two Sorted Lists(Swift版) + +## 1. 問題の分析 + +> 💡 この問題は、一言で言うと「すでに整列された2つの連結リストを、新しいノードを作らずに1つの整列済みリストへ“縫い合わせる”問題」です。 + +**Swiftで解く際に特に気をつけるべき点**を先にまとめます。まず、`ListNode` が `class`(クラス)として定義されている点に注目してください。Swiftには `class`(参照型)と `struct`(値型)という2種類の型があり、これは他言語にはあまりない明確な区別です。もし `ListNode` が `struct` として定義されていたら、変数に代入するたびに構造体全体がコピーされてしまい、「既存ノードの `next` をつなぎ替える」という今回のアルゴリズムはそもそも成立しません。`class` として定義されているからこそ、Java/Kotlin/C#と同じ感覚で「参照(オブジェクトへの住所)をつなぎ替える」ことができます。もう1つの重要な点は、Swiftのメモリ管理方式です。JavaやKotlin、C#、Dart、GoがGC(ガベージコレクション、=使われなくなったオブジェクトを自動的に片付ける仕組み)を採用しているのに対し、Swiftは**ARC(Automatic Reference Counting、自動参照カウント)**という異なる方式を採用しています。ARCはオブジェクトへの参照が0になった**瞬間**に即座にメモリを解放する仕組みで、GCのように「いつ回収されるか分からない一時停止」が起きません。そして最後に、`ListNode?` の `?` はSwiftの**Optional型**(=値があるかもしれないし、ないかもしれないことを表す型)を表しており、`nil` は異常な入力ではなく「空リスト」を表す正常な状態です。 + +**競技プログラミング視点での分析** + +- 実行速度を最優先するなら、各ノードを1回ずつ見るだけで済む**反復(ループ)処理**が最速。時間計算量は O(n+m)(n, mはそれぞれのリスト長)。 +- メモリ使用量の最小化方針としては、**新しいノードを一切作らず、既存ノードの `next` プロパティだけを付け替える**(この操作を「スプライシング(splicing、つなぎ替え)」と呼ぶ)。ARCの参照カウントの増減は自動かつ軽量なので、新規オブジェクトさえ作らなければ、そのコストを気にする必要はありません。 + +**業務開発視点での分析** + +- 型安全性の面では、Swiftの`Optional`型がこの問題の設計と非常に相性が良いです。`Optional`(= `ListNode?`)は実は列挙型(`enum`)として実装されており、`.some(値)` か `.none`(=`nil`)のどちらかしか取り得ません。`if let`/`guard let`/`while let` という**オプショナルバインディング(optional binding、=Optionalの中身を安全に取り出して別の変数に束縛する構文)**を使うことで、中身が `nil` でないことを確認しながら安全に値を取り出せます。 +- エラーハンドリングとして、この問題で `throw` を使う必要は一切ありません。`nil` は例外的な状態ではなく、リストの終端・空リストを表す正常な値だからです。 + +**Swift特有の考慮点** + +- **ARC(自動参照カウント)**:JavaやKotlinのGCと違い、Swiftはコンパイル時に挿入される参照カウントの増減コードによってメモリを管理します。今回のアルゴリズムは新規オブジェクトをダミーノード1つしか作らないため、ARCのオーバーヘッドはほぼ無視できます。 +- **`class`(参照型)と `struct`(値型)の違い**:`ListNode` が `class` である理由は、まさに今回のような「複数の場所から同じノードを指し示し、つなぎ替える」操作を実現するためです。 +- **Optional型とオプショナルバインディング**:`nil` チェックのために `if x != nil { x!.foo() }` のように書くこともできますが、Swiftでは `if let unwrapped = x { unwrapped.foo() }` という**安全な取り出し**が強く推奨されるイディオムです。 + +> 📖 **このセクションで登場した用語**(例) +> +> - **ARC(Automatic Reference Counting、自動参照カウント)**:オブジェクトへの参照の数を自動的に追跡し、参照が0になった瞬間に即座にメモリを解放する仕組み。GCと違い「いつ回収されるか分からない」という不確実性がない +> - **`class`(参照型)と `struct`(値型)**:`class` は変数に「オブジェクトへの参照(住所)」が入り、代入すると参照だけがコピーされる。`struct` は変数に値そのものが入り、代入すると値全体がコピーされる +> - **Optional型(`T?`)**:値があるかもしれないし、ないかもしれないことを表す型。実体は `enum Optional { case some(Wrapped); case none }` という列挙型 +> - **オプショナルバインディング**:`if let`/`guard let`/`while let` を使って、Optional型の中身が `nil` でないことを確認しながら、その中身を別の変数へ安全に取り出す構文 + +--- + +## 2. アルゴリズムアプローチ比較 + +> 💡 同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」を比べて最適なものを選びます。 + +| アプローチ | 時間計算量 | 空間計算量 | Swift実装コスト | 型安全性 | 可読性 | 備考 | +| --------------------------------------------- | ----------------- | ---------- | --------------- | -------- | ------ | ------------------------------------------ | +| 方法A:反復(ダミーヘッド + `while let`) | O(n+m) | O(1) | 低 | 高 | 高 | 新規ノード生成なし。推奨 | +| 方法B:再帰(先頭同士を比較して自己呼び出し) | O(n+m) | O(n+m) | 低 | 高 | 高 | コールスタックがリスト長に比例して積まれる | +| 方法C:両リストを`[Int]`に集めて`sorted()` | O((n+m) log(n+m)) | O(n+m) | 中 | 中 | 中 | すでにソート済みという前提を無駄にする | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n+m)`:2つの入力の合計サイズに比例して手間が増える(線形) +> - `O((n+m) log(n+m))`:ソートに典型的な計算量。入力が増えるとやや急激に手間が増える + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して、処理にかかる手間がどう増えるかの目安 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安 +> - **コールスタック**:関数呼び出しの「呼び出し履歴」を積んでおくメモリ領域。再帰が深くなるとここに積まれる分だけメモリを消費し、深すぎると(Swiftでは)クラッシュ(`EXC_BAD_ACCESS` 等)が起きる + +--- + +## 3. 選択したアルゴリズムと理由 + +- **選択したアプローチ**:方法A(ダミーヘッドを使った反復処理) +- **理由**: + - **計算量的な優位性**:方法B(再帰)と同じ O(n+m) の時間計算量でありながら、空間計算量は O(1) で済む(方法Bはコールスタックに O(n+m) 消費する)。 + - **Swift環境での型安全性**:`while let node1 = l1, let node2 = l2` というオプショナルバインディングを使うことで、ループの内側では `node1`/`node2` が `ListNode?` ではなく `ListNode`(Optionalが外れた非nil値)として、コンパイラの保証付きで安全に扱える。 + - **保守性・可読性**:ループ1本で完結し、条件分岐もシンプル。デバッガでも1行ずつ追いやすい。 + - **方法Cを選ばなかった理由**:わざわざ `[Int]` に集めて `sorted()` でソートするのは、「すでにソート済み」という問題の前提を無視した回り道であり、要素の収集と `sorted()` によるソートを含めた全体の時間計算量が O((n+m) log(n+m)) となるうえ、空間計算量も O(n+m) と余計にメモリを使う。 +- **Swift特有の最適化ポイント**: + - 新規オブジェクト生成はダミーノード1個のみに抑えており、ARCによる参照カウントの増減も最小限に留めている。 + - `while let ... , let ...` というカンマ区切りのオプショナルバインディングを使うことで、明示的な `!`(強制アンラップ、=Optionalの中身を無条件に取り出す演算子)の使用箇所を最小限に抑え、安全性を高めている。 + +> 📖 **このセクションで登場した用語** +> +> - **強制アンラップ(`!`)**:Optional型の値を、中身の有無を確認せずに無条件で取り出す演算子。もし実際に `nil` だった場合はその場でクラッシュする(Swiftでは `fatalError` 相当の即時停止)。安全性の低い操作なので、確実に `nil` でないと分かっている箇所以外では避けるべき + +--- + +## 4. 実装コード + +> 💡 コード全体の骨格: +> +> 1. 結果リストの先頭を仮に指し示す「ダミーノード」を用意する +> 2. `list1` と `list2` の内容を、再代入可能な `var` のローカル変数に持ち替える +> 3. `while let` で両方のリストの先頭が同時に取り出せる間、値を比較して小さい方を `tail` の後ろにつなげる +> 4. どちらかが `nil` になったらループを抜け、残った方(すでにソート済み)をそのまま丸ごとつなげる +> 5. ダミーノードの次(`dummy.next`)が本当の答えの先頭なので、それを返す + +```swift +/** + * Definition for singly-linked list. + * public class ListNode { + * public var val: Int + * public var next: ListNode? + * public init() { self.val = 0; self.next = nil; } + * public init(_ val: Int) { self.val = val; self.next = nil; } + * public init(_ val: Int, _ next: ListNode?) { self.val = val; self.next = next; } + * } + */ +public class ListNode { + public var val: Int + public var next: ListNode? + public init() { self.val = 0; self.next = nil; } + public init(_ val: Int) { self.val = val; self.next = nil; } + public init(_ val: Int, _ next: ListNode?) { self.val = val; self.next = next; } +} + +class Solution { + /// 2つのソート済み連結リストを1つのソート済み連結リストにマージする。 + /// + /// 重要:list1 / list2 が nil であることは異常値ではなく、 + /// 「空のリストが渡された」という正常な状態を表す。 + /// そのため fatalError や例外は発生させない。 + /// + /// ダミーノード1個(`ListNode(0)`)を除き新規リストノードは作成せず、既存ノードの next プロパティを + /// つなぎ替える(スプライシング)ことでマージするため、 + /// 追加のオブジェクト生成はダミーノード1個のみで済む(空間計算量 O(1))。 + /// + /// - Parameters: + /// - list1: 1つ目のソート済み連結リストの先頭(空リストなら nil) + /// - list2: 2つ目のソート済み連結リストの先頭(空リストなら nil) + /// - Returns: マージ後のソート済み連結リストの先頭(両方が空リストなら nil) + /// - Complexity: Time O(n + m), Space O(1) + /// (n, m はそれぞれ list1, list2 のノード数) + func mergeTwoLists(_ list1: ListNode?, _ list2: ListNode?) -> ListNode? { + // ダミーノード(番兵ノード)を用意する。 + // このノード自体には意味のある値は入れない(値は0で仮置き)。 + // こうすることで「結果リストがまだ空のとき、先頭をどう扱うか」 + // という特別な分岐を書かずに済む。 + // let にしている理由:dummy という「変数」自体を後から + // 別のオブジェクトに差し替えることはない(中身の next プロパティは + // 書き換えるが、ListNode は class=参照型なので let でも + // プロパティの変更自体は可能)ため。 + let dummy = ListNode(0) + + // tail は「これまでにつなぎ終えた部分の末尾ノード」を指す変数。 + // ループのたびに別のノードを指すよう再代入するため var で宣言する。 + var tail = dummy + + // 関数の引数 list1・list2 は Swift では let 相当(再代入不可)なので、 + // 途中でリストを1つずつ進めるために、再代入可能な var のローカル変数へ + // 持ち替える。 + var l1 = list1 + var l2 = list2 + + // while let A = ..., let B = ... という書き方は、 + // 「A も B も両方 nil でない場合にのみループを継続する」という意味。 + // これは l1 != nil && l2 != nil を書いてから毎回 l1! のように + // 強制アンラップするよりも安全で、node1・node2 という + // 非Optionalな新しい変数として中身を直接扱える。 + while let node1 = l1, let node2 = l2 { + if node1.val <= node2.val { + // node1(l1由来)の値のほうが小さい(同値含む)ので、 + // node1を結果リストの末尾につなげる + tail.next = node1 + // l1を1つ次に進める(つないだノードは処理済みなので進める) + l1 = node1.next + } else { + // node2(l2由来)の値のほうが小さいので、node2側をつなげる + tail.next = node2 + l2 = node2.next + } + // tailを、たった今つないだノードの位置まで進める。 + // tail.next は直前の if/else で必ず何らかのノードを + // 代入したばかりなので nil ではないと分かっているが、 + // tail.next 自体の型は ListNode? のため、! (強制アンラップ)で + // 「ここは絶対に nil ではない」と明示的にコンパイラへ伝える。 + tail = tail.next! + } + + // ループを抜けた時点で、l1 か l2 のどちらか(または両方)が + // nil(尽きた状態)になっている。 + // 残っている側はすでにソート済みなので、1つずつ比較する必要はなく、 + // そのまま丸ごと tail の後ろにつなげてよい。 + // ?? はnil結合演算子:l1が nil でなければl1を、 + // nil ならl2(nil かもしれない)を返す。 + tail.next = l1 ?? l2 + + // dummyノード自体はデータを持たない仮のノードなので、 + // 実際の答えの先頭は dummy.next である + return dummy.next + } +} +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> 入力:`list1 = [1,2,4]`、`list2 = [1,3,4]` +> +> ``` +> 初期状態: dummy.next = nil +> tail = dummy +> l1 = [1,2,4], l2 = [1,3,4] +> +> Step 1: while let で node1=1, node2=1 を取り出す +> node1.val(1) <= node2.val(1) → true → node1(=1)をつなぐ +> 結果: dummy -> 1(from list1) +> l1は次の "2" に進む、tailは今つないだ "1" に進む +> +> Step 2: while let で node1=2, node2=1 を取り出す +> node1.val(2) vs node2.val(1) → node2(=1)のほうが小さい → node2をつなぐ +> 結果: dummy -> 1 -> 1(from list2) +> l2は次の "3" に進む +> +> Step 3: while let で node1=2, node2=3 を取り出す +> node1.val(2) <= node2.val(3) → node1(=2)をつなぐ +> 結果: dummy -> 1 -> 1 -> 2 +> l1は次の "4" に進む +> +> Step 4: while let で node1=4, node2=3 を取り出す +> node1.val(4) vs node2.val(3) → node2(=3)のほうが小さい → node2をつなぐ +> 結果: dummy -> 1 -> 1 -> 2 -> 3 +> l2は次の "4" に進む +> +> Step 5: while let で node1=4, node2=4 を取り出す +> node1.val(4) <= node2.val(4) → node1(=4)をつなぐ(同値はlist1優先) +> 結果: dummy -> 1 -> 1 -> 2 -> 3 -> 4 +> l1はnilになる(尽きた) +> +> Step 6: while let の条件(l1もl2も両方nilでない)が false → ループ終了 +> l1 == nil なので、tail.next = l1 ?? l2 → l2(=[4])がつながる +> 結果: dummy -> 1 -> 1 -> 2 -> 3 -> 4 -> 4 +> +> Step 7: dummy.next を返す → [1,1,2,3,4,4](期待通りの出力) +> ``` + +> 💡 **`while let a = x, let b = y` の動きを図で理解する(初学者向け)** +> このカンマ区切りの構文は「複数のオプショナルバインディングを同時に成功させないとループに入れない」という意味です。 +> +> ``` +> x が nil の場合 → ループに入らない(bのチェックすら行われない) +> x が Some、y が nil の場合 → ループに入らない +> x が Some、y も Some の場合 → a・b にそれぞれの中身が束縛され、ループ本体が実行される +> ``` +> +> これはJavaやKotlinで `while (list1 != null && list2 != null)` と書いた後にそれぞれを手動でアンラップするのと同じ効果を、より安全かつ簡潔に実現するSwift特有のイディオムです。 + +> 📖 **このセクションで登場した用語** +> +> - **`while let`**:ループの継続条件として、Optionalの中身が `nil` でない間だけ繰り返す構文。カンマで複数条件をつなげられる +> - **アンダースコア引数ラベル(`_`)**:Swiftの関数は通常、呼び出し時に `label: 値` のようにラベルを書く必要があるが、パラメータ名の前に `_` を書くとラベルを省略できる。`mergeTwoLists(_ list1: ..., _ list2: ...)` はこの例 +> - **nil結合演算子(`??`)**:`a ?? b` は「`a` が `nil` でなければ `a` を、`nil` なら `b` を返す」という演算子。他言語のnull合体演算子(`??`:TypeScript/C#/Dart、`?:`:Kotlin)と同じ役割 +> - **強制アンラップ(`!`)**:Optional型の値を無条件で取り出す演算子。`tail.next!` は「直前の代入で `nil` でないと分かっている」という前提のもとで安全に使用している + +--- + +## 5. 検証 + +> 💡 エッジケースとは「入力が空・最小値・最大値・重複あり」など、通常とは異なる境界的な入力のことです。境界値の確認は、アルゴリズムが"ふつうの入力"だけでなく"極端な入力"でも正しく動くかを確かめるために重要です。 + +- **境界値テスト(頭の中でのトレース確認)**: + - `list1=nil, list2=nil`(Example 2):`while let` の条件が最初から満たされない(両方 `nil`)ため即座にループを抜ける。`tail.next = l1 ?? l2` は `nil ?? nil` → `nil`。`dummy.next` も `nil` のまま。結果は `[]` で正しい。 + - `list1=nil, list2=[0]`(Example 3):`while let` の条件が最初から満たされない(`l1` が `nil`)ため即座に終了。`tail.next = nil ?? l2` → `l2`(`[0]`)がそのままつながる。結果は `[0]` で正しい。 + - 片方が他方より大幅に長い場合(例:`list1=[1]`, `list2=[2,3,4,5]`):`l1` の1件を消費した時点で `l1` が `nil` になり、ループを抜けて `l2` の残り全部(`[2,3,4,5]`)が丸ごとつながる。個別に比較し直す必要がないことが確認できる。 + - 同値が並ぶ場合(例:`list1=[1,1]`, `list2=[1,1]`):`<=` の比較により、同値のときは常に `list1`(`node1`)側が先に採用される。安定した挙動になっており、順序の破綻はない。 + +- **Optional安全性の確認**:`node1.val` や `node2.val` へのアクセスは、すべて `while let node1 = l1, let node2 = l2` によってアンラップ済みの非Optional変数に対してのみ行われています。`tail.next!` の強制アンラップも、直前の代入によって `nil` でないことが論理的に保証されている箇所のみに限定しているため、実行時クラッシュのリスクはありません。 +- **ARCの観点**:新規オブジェクトはダミーノード1個のみで、それ以外は既存ノードの参照カウントの増減(`tail.next = node1` のような代入のたびに自動で行われる)のみが発生します。循環参照(retain cycle、=オブジェクト同士が互いを強参照し合い、ARCだけでは解放できなくなる状態)が生じる構造ではないため、`weak`/`unowned` の使用は不要です。 + +> 📖 **このセクションで登場した用語** +> +> - **エッジケース**:空のリスト・要素1つ・最大サイズ入力など、境界的な条件のこと +> - **境界値テスト**:エッジケースに対してもアルゴリズムが正しく動くかを確かめること +> - **循環参照(retain cycle)**:2つ以上のオブジェクトが互いを強参照し合い、参照カウントが0にならずメモリが解放されなくなる不具合。今回のアルゴリズムでは単方向の `next` しか持たないため発生しない +> - **`weak` / `unowned`**:循環参照を防ぐために使う弱い参照。今回は循環構造がないため使用していない + +--- + +## 6. 制約条件の確認 + +- 外部ライブラリ(SPM/CocoaPods等)は使用していません(Swift標準ライブラリのみで完結しています)。 +- 追加のオブジェクト生成はダミーノード1個のみで、それ以外は既存ノードの参照のつなぎ替えのみで完結しています。 +- クラス名 `Solution`・`ListNode` はパスカルケース、メソッド名 `mergeTwoLists`/変数名 `dummy`/`tail`/`l1`/`l2` はキャメルケースというSwiftの標準的な命名規則([Swift API Design Guidelines](https://www.swift.org/documentation/api-design-guidelines/))に従っています。 +- 強制アンラップ(`!`)の使用は、直前のコードで `nil` でないことが論理的に保証されている1箇所(`tail.next!`)のみに限定しています。 + +**計算量まとめ**:時間計算量 O(n+m)、空間計算量 O(1)(n, m はそれぞれ list1, list2 のノード数)。 diff --git a/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Typescript.md b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Typescript.md new file mode 100644 index 00000000..3008cd01 --- /dev/null +++ b/DataStructures/LinkedLists/leetcode/21. Merge Two Sorted Lists/Sonnet 5/Merge-Two-Sorted-Lists-Typescript.md @@ -0,0 +1,219 @@ +# LeetCode 21: Merge Two Sorted Lists(TypeScript版) + +## 1. 問題の分析 + +> 💡 この問題は、一言で言うと「すでに整列された2つの連結リストを、新しいノードを作らずに1つの整列済みリストへ“縫い合わせる”問題」です。 + +**競技プログラミング視点での分析** +- 実行速度を最優先するなら、各ノードを1回ずつ見るだけで済む**反復(ループ)処理**が最速。時間計算量は O(n+m)(n, mはそれぞれのリスト長)で、再帰呼び出しのようなオーバーヘッド(=余分にかかる処理コスト)がない。 +- メモリ使用量の最小化方針としては、**新しいノードを一切作らず、既存ノードの `next` 参照だけを付け替える**(この操作を「スプライシング(splicing、つなぎ替え)」と呼ぶ)。配列に変換してからソートし直すような方法は、リストがすでにソート済みという情報を無駄にしてしまう。 + +**業務開発視点での分析** +- 型安全性の面では、`ListNode` は問題側で固定されたクラスなので、ジェネリクス(=型を後から自由に差し込める仕組み)を導入する必要は**ありません**。この問題は `number` 型の `val` に固定されており、無理に汎用化するとかえって型が複雑になり可読性が落ちます。 +- エラーハンドリングで最も注意すべき点:**`list1` や `list2` が `null` であることは「異常な入力」ではなく「空のリストを表す正常な状態」**です。Example 2 (`list1 = [], list2 = []`) がまさにそれです。テンプレートによくある「`null` なら即 `TypeError`」という防御的プログラミングをそのまま当てはめると、この問題では**正しい入力を誤ってエラー扱いしてしまう**ので注意が必要です。 + +**TypeScript特有の考慮点** +- 型推論(=型を明示しなくてもTypeScriptが自動で型を判断してくれる機能)により、`list1.val` は必ず `number` であることがコンパイル時に保証されます。 +- 型ガード(=実行時のチェックとコンパイル時の型の絞り込みを同時に行う仕組み)として `list1 !== null` を使うと、その条件分岐の内側ではTypeScriptが自動的に `list1` の型を `ListNode`(`null` を除いた型)に絞り込んでくれます。JavaScriptにはこの「コンパイラが型を追跡する」という概念自体が存在せず、実行時に `undefined` や `null` へのアクセスでクラッシュして初めて気づくことが多いのに対し、TypeScriptでは**コードを書いている最中(エディタ上)**にエラーとして警告してくれる点が大きな違いです。 + +> 📖 **このセクションで登場した用語**(例) +> +> - **型推論**:型を明示しなくてもTypeScriptが自動で型を判断してくれる機能 +> - **null安全性**:`null`や`undefined`による予期しないクラッシュを防ぐ仕組み +> - **型ガード**:`if (x !== null)` のような条件分岐によって、その内側でTypeScriptに「`x`は`null`ではない」と伝え、型を絞り込ませる仕組み +> - **オーバーヘッド**:ある処理を実現するために余分にかかる時間やメモリのコスト + +--- + +## 2. アルゴリズムアプローチ比較 + +> 💡 同じ問題でも解き方は複数あります。それぞれの「速さ(時間計算量)」と「メモリの使いやすさ(空間計算量)」、そしてTypeScriptらしい実装コストを比べて最適なものを選びます。 + +| アプローチ | 時間計算量 | 空間計算量 | TS実装コスト | 型安全性 | 可読性 | 備考 | +|---|---|---|---|---|---|---| +| 方法A:反復(ダミーヘッド + 2ポインタ) | O(n+m) | O(1) | 低 | 高 | 高 | 新規ノード生成なし。推奨 | +| 方法B:再帰(先頭同士を比較して自己呼び出し) | O(n+m) | O(n+m) | 低 | 高 | 高 | コールスタックがリスト長に比例して積まれる | +| 方法C:両リストを配列に集めてソート | O((n+m) log(n+m)) | O(n+m) | 中 | 中 | 中 | すでにソート済みという前提を無駄にする | + +> 💡 **Big-O記法の読み方**(初学者向け) +> +> - `O(1)`:入力の大きさに関わらず、常に一定の時間・メモリで済む(最速・最小) +> - `O(n+m)`:2つの入力の合計サイズに比例して手間が増える(線形) +> - `O((n+m) log(n+m))`:ソートに典型的な計算量。入力が増えるとやや急激に手間が増える + +> 📖 **このセクションで登場した用語** +> +> - **時間計算量**:入力の大きさに対して、処理にかかる手間がどう増えるかの目安 +> - **空間計算量**:処理中に使うメモリ量がどう増えるかの目安 +> - **コールスタック**:関数呼び出しの「呼び出し履歴」を積んでおくメモリ領域。再帰が深くなるとここに積まれる分だけメモリを消費し、深すぎると `RangeError: Maximum call stack size exceeded` が起きる + +--- + +## 3. 選択したアルゴリズムと理由 + +- **選択したアプローチ**:方法A(ダミーヘッドを使った反復処理) +- **理由**: + - **計算量的な優位性**:方法B(再帰)と同じ O(n+m) の時間計算量でありながら、空間計算量は O(1) で済む(方法Bはコールスタックに O(n+m) 消費する)。 + - **TypeScript環境での型安全性**:`while` ループの条件を `list1 !== null && list2 !== null` とすることで、ループの内側ではTypeScriptが `list1.val` や `list2.val` へのアクセスを「`null` かもしれないプロパティへのアクセス」としてエラー扱いせず、安全にコンパイルが通る。 + - **保守性・可読性**:ループ1本で完結し、条件分岐もシンプル。デバッガでも1行ずつ追いやすい。 + - **方法Cを選ばなかった理由**:わざわざ配列に集めてソートするのは、「すでにソート済み」という問題の前提を無視した回り道であり、時間計算量も余分な O(log(n+m)) がかかるうえ、空間計算量も O(n+m) と余計にメモリを使う。 +- **TypeScript特有の最適化ポイント**: + - コンパイル時の型チェックにより、「`list1.val` に `undefined` を代入してしまう」といったミスをコードを書いている最中に検出できる。 + - `readonly` は今回のノードのように「途中で `next` を書き換える必要があるデータ構造」には適用しない(`next` を `readonly` にしてしまうとスプライシング自体ができなくなる)。イミュータブル(変更不可)にすべき部分とミュータブル(変更可能)であるべき部分を型定義レベルで正しく見極めることが、この問題における型安全性の要点。 + +> 📖 **このセクションで登場した用語** +> +> - **ジェネリクス**:型を後から自由に差し込める仕組み。今回の問題は `val: number` に固定されているため使用しない +> - **コンパイル時**:TypeScriptのコードをJavaScriptに変換する段階。ここでエラーを検出できると実行時のバグを防げる +> - **イミュータブル / ミュータブル**:イミュータブルは「一度作ったら値を変更できない」性質、ミュータブルは「後から値を変更できる」性質。連結リストの `next` 参照は今回のアルゴリズムではミュータブルである必要がある + +--- + +## 4. 実装コード + +> 💡 コード全体の骨格: +> 1. 結果リストの先頭を仮に指し示す「ダミーノード」を用意する +> 2. `list1` と `list2` の先頭同士を比較し、小さい方を結果リストの末尾につなげる。つないだ方のリストを1つ進める +> 3. どちらかが尽きるまで2を繰り返す +> 4. 片方が尽きたら、残った方(すでにソート済み)をそのまま丸ごとつなげる +> 5. ダミーノードの次(`dummy.next`)が本当の答えの先頭なので、それを返す + +```typescript +/** + * Definition for singly-linked list. + * class ListNode { + * val: number + * next: ListNode | null + * constructor(val?: number, next?: ListNode | null) { + * this.val = (val===undefined ? 0 : val) + * this.next = (next===undefined ? null : next) + * } + * } + */ +class ListNode { + val: number; + next: ListNode | null; + constructor(val?: number, next?: ListNode | null) { + this.val = val === undefined ? 0 : val; + this.next = next === undefined ? null : next; + } +} + +/** + * 2つのソート済み連結リストを1つのソート済み連結リストにマージする。 + * + * new ListNode(0) ダミーノードを1つだけアロケーションし、他の新しいリストノードは作成せずに + * 既存ノードの next 参照をつなぎ替える(スプライシング)ことでマージするため、 + * 補助記憶空間(Auxiliary Space)は O(1) となる。 + * + * 重要:list1 / list2 が null であることは異常値ではなく、 + * 「空のリストが渡された」という正常な状態を表す。 + * そのため TypeError 等は送出しない。 + * + * @param list1 - 1つ目のソート済み連結リストの先頭ノード(空リストなら null) + * @param list2 - 2つ目のソート済み連結リストの先頭ノード(空リストなら null) + * @returns マージ後のソート済み連結リストの先頭ノード(両方が空リストの場合は null) + * @complexity Time: O(n + m), Space: O(1) + */ +function mergeTwoLists(list1: ListNode | null, list2: ListNode | null): ListNode | null { + // ダミーノード(番兵ノード)を用意する。 + // このノード自体には意味のある値は入れない(値は0で仮置き)。 + // こうすることで「結果リストがまだ空のとき、先頭をどう扱うか」 + // という特別な分岐を書かずに済む。 + const dummy: ListNode = new ListNode(0); + + // tail は「これまでにつなぎ終えた部分の末尾ノード」を指す変数。 + // let で宣言する理由:tail.next への再代入だけでなく、 + // tail 自体もループのたびに「次のノード」を指すように変わっていくため。 + let tail: ListNode = dummy; + + // list1、list2 の両方にまだノードが残っている間、 + // 先頭同士の値を比較して小さい方を tail の後ろにつなげる。 + // この条件式が型ガードとして働き、ループ内では TypeScript が + // list1・list2 を「null ではない ListNode」として扱ってくれる。 + while (list1 !== null && list2 !== null) { + if (list1.val <= list2.val) { + // list1の先頭値のほうが小さい(同値含む)ので、 + // list1の現在ノードを結果リストの末尾につなげる + tail.next = list1; + // list1を1つ次に進める(つないだノードは処理済みなので進める) + list1 = list1.next; + } else { + // list2の先頭値のほうが小さいので、list2側をつなげる + tail.next = list2; + list2 = list2.next; + } + // tailを、たった今つないだノードの位置まで進める + // tail.next は必ず ListNode(直前の if/else でつないだノード)なので + // TypeScript の型的にも安全に代入できる + tail = tail.next; + } + + // ループを抜けた時点で、list1 か list2 のどちらか(または両方)が + // null(尽きた状態)になっている。 + // 残っている側はすでにソート済みなので、1つずつ比較する必要はなく、 + // そのまま丸ごと tail の後ろにつなげてよい。 + // 三項演算子:list1が残っていればlist1を、そうでなければlist2をつなげる + tail.next = list1 !== null ? list1 : list2; + + // dummyノード自体はデータを持たない仮のノードなので、 + // 実際の答えの先頭は dummy.next である + return dummy.next; +} + +export { mergeTwoLists, ListNode }; +``` + +> 💡 **コードの動作トレース**(初学者向け) +> +> 入力:`list1 = [1,2,4]`、`list2 = [1,3,4]` +> +> ``` +> 初期状態: dummy -> (何も繋がっていない) +> tail = dummy +> list1 = [1,2,4], list2 = [1,3,4] +> +> Step 1: list1.val(1) <= list2.val(1) → true → list1の"1"をつなぐ +> 結果: dummy -> 1(from list1) +> list1は次の "2" に進む、tailは今つないだ "1" に進む +> +> Step 2: list1.val(2) vs list2.val(1) → list2の"1"のほうが小さい → list2をつなぐ +> 結果: dummy -> 1 -> 1(from list2) +> list2は次の "3" に進む、tailは今つないだ "1" に進む +> +> Step 3: list1.val(2) <= list2.val(3) → list1の"2"をつなぐ +> 結果: dummy -> 1 -> 1 -> 2 +> list1は次の "4" に進む +> +> Step 4: list1.val(4) vs list2.val(3) → list2の"3"のほうが小さい → list2をつなぐ +> 結果: dummy -> 1 -> 1 -> 2 -> 3 +> list2は次の "4" に進む +> +> Step 5: list1.val(4) <= list2.val(4) → list1の"4"をつなぐ(同値はlist1優先) +> 結果: dummy -> 1 -> 1 -> 2 -> 3 -> 4 +> list1はnullになる(尽きた) +> +> Step 6: while条件 (list1 !== null && list2 !== null) が false になりループ終了 +> list1 === null なので、残っている list2(= [4])を丸ごとつなぐ +> 結果: dummy -> 1 -> 1 -> 2 -> 3 -> 4 -> 4 +> +> Step 7: dummy.next を返す → [1,1,2,3,4,4](期待通りの出力) +> ``` + +> 📖 **このセクションで登場した用語** +> +> - **readonly**:変数の値を変更できないようにする修飾子。今回の `tail` や `next` は書き換えが必要なため `let` のまま使用している(不変にすべき値とすべきでない値の見極めが重要) +> - **TypeError / RangeError**:エラーの種類。`TypeError`は型が不正な場合、`RangeError`は値の範囲が不正な場合に使う。今回は正常な `null` 入力に対してこれらを投げないよう注意している +> - **Pure function(純粋関数)**:同じ入力を与えると必ず同じ出力を返し、外部の状態を変えない関数。`mergeTwoLists` はリスト自体(ノードの `next`)を書き換えるためオブジェクト内部への副作用はあるが、外部のグローバル変数などは一切変更しない +> - **三項演算子(`条件 ? A : B`)**:`if-else` を1行で書くための構文。「条件がtrueならA、falseならB」という値を返す + +--- + +## 5. 制約条件の確認 + +- 外部ライブラリは使用していません(Node.js標準機能・言語組み込み機能のみ)。 +- ESM形式(`export` 文)で出力しています。 +- 追加のノードを1つも生成していないため、メモリ使用量は入力サイズによらず一定(O(1))です。 +- TypeScript strict mode(`strictNullChecks` を含む)でも、`while (list1 !== null && list2 !== null)` による型ガードのおかげでコンパイルエラーは発生しません。 + +**計算量まとめ**:時間計算量 O(n+m)、空間計算量 O(1)(n, m はそれぞれ list1, list2 のノード数)。 diff --git a/public/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/README_React.html b/public/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/README_React.html new file mode 100644 index 00000000..33890547 --- /dev/null +++ b/public/Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/README_React.html @@ -0,0 +1,1781 @@ + + + + + + Valid Parentheses - LeetCode 20 解説(Python / TypeScript / Go / Rust) + + + + + + + + + + + + + + + +
+ + + + + + + + +
+

+ アルゴリズム概要 +

+ +
+

+ 💡 + この問題を一言で言うと:「開き括弧と閉じ括弧の対応関係と順序が正しいかどうかを判定する問題」です。 +

+

+ '('')''{''}''['']' + だけからなる文字列 + s + が与えられます。すべての開き括弧が正しい種類・正しい順序で閉じられているなら + true、そうでなければ + false + を返します。 +

+
+ +
+

+ ⚠️ なぜ単純な方法では解けないのか +

+
    +
  • + 括弧の「数」を数えるだけでは不十分です。開き括弧の数と閉じ括弧の数が一致していても、閉じる順序が壊れている入力(例:"([)]")を見逃してしまいます。 +
  • +
  • + 閉じ括弧が来たときに「一番最近開かれた、まだ閉じられていない括弧」と比較する必要があり、これには「後入れ先出し」の性質を持つデータ構造(スタック)が必要です。 +
  • +
+
+ +
+
+
+ O(n) +
+
時間計算量
+
+
+
+ O(n) +
+
空間計算量
+
+
+
+ Stack +
+
採用アプローチ
+
+
+
+ 4言語共通 +
+
アルゴリズムの統一度
+
+
+ +
+

📥 入出力例

+
+
+
s = "()[]{}"
+
出力: true
+
+ 3種類の括弧がそれぞれ正しい順序で閉じられているため +
+
+
+
s = "([)]"
+
出力: false
+
+ '(' が閉じられる前に '[' が ')' によって閉じられようとしているため +
+
+
+
+
+ + + + +
+

+ ステップバイステップ解説 +

+

+ ここでは特定の言語に依存せず、「文字列を読みながらスタックを操作する」という処理の流れを、 + 代表例 + s = "([)]" + を使って1ステップずつ確認します。 + この考え方は、後の「コード(4言語)」セクションでPython / TypeScript / Go / + Rustのどの実装にも共通する土台になります。 +

+
+
+ + + + +
+

+ 実装(Python / TypeScript / Go / Rust) +

+

+ 4言語とも「開き括弧が来たら対応する閉じ括弧をスタックに積み、閉じ括弧が来たらスタック最上部と直接比較する」という同じアルゴリズムを採用しています。 + タブを切り替えて、それぞれの言語らしい書き方・エラーの扱い方の違いを見比べてみましょう。 +

+
+
+ + + + + +
+ + + + +
+

+ 計算量分析 +

+ +
+

📖 Big-O 記法の読み方

+
+
+
O(1)
+
入力サイズに関わらず
常に一定の速さ
+
+
+
O(n)
+
入力が2倍になると
処理も約2倍
+
+
+
O(n log n)
+
入力が2倍になると
処理は約2倍強
+
+
+
O(n²)
+
入力が2倍になると
処理は約4倍
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
言語 + 採用アルゴリズム + 時間計算量空間計算量実装上の特徴
Pythonスタック法O(n)O(n) + list + をスタックに、dict + で対応表引き +
+ TypeScript + スタック法O(n)O(n) + string[] + をスタックに、readonly + な対応表 +
Goスタック法O(n)O(n) + []byte + をプリアロケーション、switch + で分岐 +
Rustスタック法O(n)O(n) + Vec<char> + を事前確保、Option<char> + で安全に比較 +
+
+ +
+

+ 🔍 なぜこの計算量になるのか +

+

+ 文字列 + s + の各文字を1回ずつしか読まないため、時間計算量は文字列長 n に比例する O(n) + になります。 + これより速くすることは、各文字を最低1回は確認する必要がある以上、理論上不可能です。 + 空間計算量については、最悪ケース(全て開き括弧、例:"(((((")ではスタックに全文字が積まれるため O(n) になります。 + 括弧の出現数だけを数えるO(1)空間のアプローチも存在しますが、順序の検証ができず不正解になるため採用していません。 +

+
+
+ + + + +
+

+ 言語間比較 +

+

+ 同じアルゴリズムでも、言語ごとに「メモリの扱い方」や「エラーの表現方法」が異なります。 + ここでは4言語の実装を横並びで比較し、それぞれの言語特有の設計判断を振り返ります。 +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
観点PythonTypeScriptGoRust
+ 採用アルゴリズム + スタック法スタック法スタック法スタック法
+ スタックの実体 + list[str]string[][]byte + Vec<char> +
+ メモリ確保の特徴 + 動的拡張(償却O(1)) + 動的拡張(V8内部最適化) + + makeでプリアロケーションし再アロケーション回避 + + with_capacityで事前確保し再アロケーション回避 +
+ エラー表現の方法 + 例外(Exception)undefinedの型レベル表現 + パニックを避ける範囲チェック + + Option<T>による安全な比較 +
+ メモリ管理方式 + + GC(参照カウント+世代別GC) + GC(V8のGC)GC(並行GC) + 所有権システム(GC無し) +
+ 型安全性の担保方法 + + pylance静的解析+実行時isinstance + + コンパイル時の構造的型付け+型ガード + 静的型付け+go vet + コンパイル時の所有権チェック +
+
+ +
+

相違点の背景

+

+ Python・TypeScript・GoはいずれもGC(ガベージコレクション、=使い終わったメモリを自動で回収する仕組み)を持つ言語であり、 + メモリの解放タイミングを意識する必要がありません。一方Rustは所有権システム(=コンパイル時にメモリの管理者を1つに定める仕組み) + でGC無しにメモリ安全性を実現しており、これがエラー表現の違いに最も強く表れています。 + Python/TypeScript/Goが「例外」または「安全な値(undefinedやパニック回避チェック)」でエラーを扱うのに対し、 + RustのOption<T>は「値があるかないか」を型として持つため、 + 呼び出し側は必ずどちらのケースかを意識させられます。 + 本問題はデータ構造・計算量の面で言語ごとの有利不利が生じるタイプの問題ではないため、4言語とも同じスタック法を採用していますが、 + 対応表の型・エラーの扱い方には各言語のイディオム(=その言語で自然とされる書き方)がそれぞれ表れています。 +

+
+
+ + + + +
+

+ 📖 用語集 +

+

+ このページで登場した専門用語をまとめました。分からない言葉が出てきたときに参照してください(五十音順)。 +

+
+
+ + イミュータブル + +
+ 一度作られたら中身を変更できない性質のこと。PythonのstrやTypeScript/JavaScriptの文字列はこの性質を持ちます。 +
+
+ +
+ + ガベージコレクション(GC) + +
+ 使い終わったメモリを自動で回収する仕組み。Python・TypeScript(JavaScript)・Goが持つ機能で、プログラマがメモリ解放のタイミングを意識しなくてよくなります。Rustはこの仕組みを持たず、代わりに所有権システムでメモリを管理します。 +
+
+ +
+ + 型ガード(TypeScript) + +
+ typeofin演算子などで実行時に値の型を確認し、その情報をもとにTypeScriptの型を絞り込む仕組みです。JavaScriptには無い、TypeScript独自のコンパイル時チェック機能です。 +
+
+ +
+ + 借用(Rust) + +
+ 所有権を渡さずに値を参照する仕組み。&T(読み取り専用)と&mut T(書き込み可能)があります。 +
+
+ +
+ + 所有権(Rust) + +
+ 値を「誰が管理するか」をコンパイル時に決めるRust独自の仕組み。JavaやPythonのようなガベージコレクタなしで、メモリの安全性をコンパイル時に保証できます。 +
+
+ +
+ + スタック + +
+ 後入れ先出し(LIFO)の構造を持つデータ構造。お皿の積み重ねのように、最後に積んだものを最初に取り出します。本問題では「まだ閉じられていない開き括弧(に対応する閉じ括弧)」を記録するために使います。 +
+
+ +
+ + スライス(Go) + +
+ Goで配列の一部(または全体)を扱うためのデータ構造。[]byteのように書き、動的にサイズを変えられます。 +
+
+ +
+ + 静的解析 + +
+ プログラムを実行せずに、コードを読むだけでバグや型エラーを検出する手法。Pythonのpylance、TypeScriptのコンパイラ、Goのgo + vet、Rustのclippyがそれぞれ担っています。 +
+
+ +
+ + 償却計算量(Amortized + Complexity) + +
+ 個々の操作は時々遅くなることがあっても、多数回の操作全体で平均すると一定時間で済むという計算量の考え方。Pythonのlist.appendの再確保コストはこの考え方で「ならすとO(1)」とみなされます。 +
+
+ +
+ + パニック(Go / Rust) + +
+ GoやRustで回復不能なエラーが発生した際に起きる強制終了。スライスや配列への範囲外アクセスなどで発生します。本問題のコードでは、範囲外アクセスが起きる前に必ず空チェックを行うことで回避しています。 +
+
+ +
+ + プリアロケーション(Go / Rust) + +
+ 必要なメモリをあらかじめまとめて確保しておくこと。Goのmake([]byte, 0, len(s))やRustのVec::with_capacity(s.len())のように書くことで、途中の再確保を防ぎ高速化できます。 +
+
+ +
+ + Big-O記法 + +
+ 入力の大きさに対して、処理にかかる時間やメモリがどう増えるかを表す記法。O(n)なら「入力が2倍になると処理も約2倍になる」ことを意味します。 +
+
+ +
+ + LIFO(Last In, First Out) + +
+ 「最後に入れたものを最初に出す」という、スタックの動作原則。本問題の「一番内側の括弧から閉じる」という性質とぴったり一致します。 +
+
+ +
+ + readonly(TypeScript) + +
+ 配列やオブジェクトの値を変更できないようにする修飾子。JavaScriptには無い、TypeScript独自のコンパイル時チェック機能で、意図しない書き換えを防ぎます。 +
+
+
+
+ +
+ LeetCode 20: Valid Parentheses ― Python / TypeScript / Go / Rust 4言語解説 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/README_react.html b/public/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/README_react.html new file mode 100644 index 00000000..2cdde611 --- /dev/null +++ b/public/Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/README_react.html @@ -0,0 +1,1652 @@ + + + + + + + LeetCode 14: Longest Common Prefix - 水平走査法による解説(Python/TypeScript/Go/Rust) + + + + + + + + + + + + + + + + + + +
+ + + + +
+

+ アルゴリズム概要 +

+ +
+

+ 💡 + この問題を一言で言うと:「複数の文字列を先頭から見比べて、全員に共通する最長の接頭辞を取り出す問題」です。 +

+

+ 文字列の配列 strs + が与えられたとき、すべての文字列に共通する最長の接頭辞(先頭部分)を返します。 + 共通する接頭辞が1文字も無ければ、空文字列 "" を返します。 +

+
+ +
+

+ ⚠️ なぜ単純な方法では解けないのか +

+
    +
  • + 共通接頭辞の長さは、文字列の本数が増えるほど短くなっていく可能性があるため、候補をどう縮めていくかを丁寧に管理する必要があります。 +
  • +
  • + 空文字列や要素数1件などの境界的な入力(エッジケース)を見落とすと、実装が一見正しく動いているように見えても、特定の入力でだけ誤った結果を返してしまいます。 +
  • +
+
+ +
+
+
+ O(S) +
+
時間計算量
+
+
+
+ O(1) +
+
空間計算量
+
+
+
+ 最大200件 各200文字 +
+
入力サイズの制約
+
+
+
+ 水平走査 +
+
採用アプローチ
+
+
+ +
+

入出力例

+
+
+

例1

+

+ strs = [flower, flow, flight] +

+

出力: fl

+

+ なぜ正解か:3つの文字列すべてが f と l + で始まっており、3文字目でそれぞれ o / o / i と分かれるため、 + 共通しているのは先頭2文字の fl までです。 +

+
+
+

例2

+

+ strs = [dog, racecar, car] +

+

+ 出力: (空文字列) +

+

+ なぜ正解か:dog, racecar, car + はそもそも先頭の1文字目からすべて異なる(d, r, c)ため、 + 共通する接頭辞は1文字も存在しません。 +

+
+
+
+
+ + +
+

+ ステップバイステップ解説 +

+

+ 代表例 strs = [flower, flow, flight] + を使って、水平走査アルゴリズムがどのように動くかを1ステップずつ確認しましょう。 + 左のリストからステップを選ぶか、「再生」ボタンで自動的に進めることができます。 +

+
+
+ + +
+

+ 実装(Python / TypeScript / Go / Rust) +

+

+ 同じアルゴリズムでも、言語によって書き方や最適化の方向性が変わります。 + タブを切り替えて、4言語それぞれの実装・動作トレース・言語特有のポイントを見比べてみましょう。 +

+
+
+ + +
+

+ 処理フローチャート +

+
+
+ + +
+

+ 計算量分析 +

+ +
+

📖 Big-O 記法の読み方

+
+
+
O(1)
+
入力サイズに関わらず
常に一定の速さ
+
+
+
O(n)
+
入力が2倍になると
処理も約2倍
+
+
+
O(S)
+
全文字数Sに
比例して増加
+
+
+
O(n²)
+
入力が2倍になると
処理は約4倍
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
言語 + 採用アルゴリズム + 時間計算量空間計算量候補の縮め方
Python垂直走査(zip + set)O(S)O(1) + 一致した文字をリストに集め join で結合 +
+ TypeScript + 水平走査(startsWith)O(S)O(1) + slice で新しい文字列に再代入 +
Go + 水平走査(strings.HasPrefix) + O(S)O(1) + スライス式でコピーせず範囲だけ変更 +
Rust水平走査(starts_with)O(S)O(1) + usize の長さのみ更新し文字列は最後に1回だけ生成 +
+
+ +
+

+ 🔍 なぜこの計算量になるのか +

+

+ どの言語の実装も、最悪の場合で全文字列の全文字を最大1回ずつ調べる構造になっているため、時間計算量は + O(S)(Sは全文字列の合計文字数)です。 + 空間計算量については、候補の文字列(または候補の長さを表す整数)以外に追加のデータ構造を使わないため、出力用の文字列を除けば + O(1) になります。 +

+
+
+ + +
+

+ 言語間比較 +

+

+ 同じアルゴリズムでも、言語ごとに「メモリの扱い方」や「エラーの表現方法」が異なります。 + ここでは4言語の実装を横並びで比較し、それぞれの言語特有の設計判断を振り返ります。 +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
観点PythonTypeScriptGoRust
+ メモリ確保の特徴 + + join で最後に1回だけ結合 + + slice のたびに新しい文字列 + スライス式はコピーなし + 整数更新のみ、最後に1回だけ確保 +
+ エラー表現 + + TypeError / ValueError の例外 + + TypeError / RangeError の例外 + + error 戻り値(提出版は省略) + + Result型が定石だが戻り値固定のため空文字列で表現 +
+ メモリ管理方式 + + GC(ガベージコレクション) + + GC(ガベージコレクション) + + GC(ガベージコレクション) + + 所有権システム(GC不要) +
+ 型安全性の担保方法 + 型ヒント + pylancereadonly + 型ガード明示的な型宣言 + go vet + 所有権システム + コンパイラチェック +
+
+ +
+

相違点の背景

+

+ Python・TypeScript・GoはGC(ガベージコレクション、=使い終わったメモリを自動で回収する仕組み)付きの言語であるため、 + 文字列の縮小操作で新しいオブジェクトが生成されてもGCが後始末をしてくれます。 + 一方Rustにはガベージコレクタが無く、コンパイル時に所有権を追跡する仕組みがあるため、 + 「新しい文字列を作らず整数だけを更新する」という、より低レベルなメモリ効率を意識した設計が自然に選ばれます。 +

+
+
+ + +
+

+ 📖 用語集 +

+

+ このページで登場した専門用語をまとめました。分からない言葉が出てきたときに参照してください。 +

+
+
+ + イミュータブル + +
+ 一度作成したら内容を変更できない性質のことです。Python・TypeScript(JavaScript)・Go・Rustのいずれの言語でも文字列はイミュータブルであり、 + 「候補を1文字縮める」処理は実際には新しい範囲を指す文字列(またはスライス)を作り直していることになります。 +
+
+ +
+ + インデックスエラー + +
+ 配列や文字列の範囲外の要素にアクセスしようとしたときに発生するエラーです。 + 例えば空の配列に対して strs[0] + にアクセスすると発生する可能性があるため、多くの実装で最初に空配列のチェックを行います。 +
+
+ +
+ + エッジケース + +
+ 空の配列・要素数1件・全要素が完全一致など、通常とは異なる境界的な条件の入力のことです。 + エッジケースを見落とすと、普通のテストは通るのに特定の入力でだけバグが発生することがあります。 +
+
+ +
+ + 型ヒント + +
+ 関数の引数や戻り値に型を注釈として書く仕組みです。Pythonでは + List[str] + のように書き、pylance(VSCodeの型チェッカー)が実行前に型の誤りを検出できるようにします。 +
+
+ +
+ + 計算量 + +
+ 入力の大きさに対して、処理にかかる手間(時間計算量)や使用するメモリ量(空間計算量)がどう増えるかの目安です。 + Big-O記法(O(n) など)で表され、この問題では時間計算量 O(S)、空間計算量 + O(1) になります。 +
+
+ +
+ + 借用 + +
+ Rust特有の仕組みで、値の所有権を渡さずにその値を参照することです。 + &str + のように書くことで、Vec<String> + の所有権を保持したまま中身を安全に読み取れます。 +
+
+ +
+ + 垂直走査 + +
+ 複数の文字列を「同じ位置(インデックス)」ごとに縦方向に比較していく方法です。 + Pythonの実装では zip と set + という組み込み関数を組み合わせてこの走査を実現しています。 +
+
+ +
+ + 水平走査 + +
+ 1つの文字列(先頭の文字列)を基準の候補として、他の文字列と丸ごと比較しながら候補を縮めていく方法です。 + TypeScript・Go・Rustの実装ではこちらを採用しています。 +
+
+ +
+ + スライス + +
+ 文字列や配列の一部分(範囲)だけを、コピーを作らずに参照する操作です。 + Go の + s[:i] + や Rust の + &s[..n] + がこれにあたります。 +
+
+ +
+ + 早期終了 + +
+ それ以上調べても答えが変わらないと分かった時点で処理を打ち切ることです。 + この問題では候補が空文字列になった時点で、共通接頭辞が無いと確定するため即座に処理を終えます。 +
+
+ +
+ + 所有権 + +
+ 値を"誰が管理するか"をコンパイル時に決めるRust独自の仕組みです。 + ガベージコレクタを使わずにメモリを安全に管理できる一方、値を渡す際に「借用」か「移動」かを意識する必要があります。 +
+
+ +
+ + パニック + +
+ GoやRustで回復不能なエラーが発生した際に起きる強制終了です。 + 範囲外のインデックスやスライスへアクセスしようとした場合などに発生することがあるため、事前の入力検証が重要になります。 +
+
+
+
+ +
+ LeetCode 14: Longest Common Prefix の解説ページ(Python / TypeScript / Go / Rust + 実装比較) +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/README_React.html b/public/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/README_React.html new file mode 100644 index 00000000..57616b2b --- /dev/null +++ b/public/Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/README_React.html @@ -0,0 +1,2215 @@ + + + + + + LeetCode 26: Remove Duplicates from Sorted Array — 11言語解説 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + diff --git a/public/index.html b/public/index.html index 8c1d0d7b..b6676a40 100644 --- a/public/index.html +++ b/public/index.html @@ -418,7 +418,7 @@

🧪 Algorithm Study Index

-

183 interactive lessons across 6 domains

+

186 interactive lessons across 6 domains

@@ -433,9 +433,9 @@

- + @@ -458,8 +458,8 @@

  • 🧩Count and Say アルゴリズム解析 (TypeScript版)Algorithm/Run Length Encoding/leetcode/38. Count and Say/Claude/README.html
  • 🧩Cyclic Sort - アルゴリズム解析Algorithm/Sort/CyclicSort/leetcode/41. First Missing Positive/Claude/README_Cyclic_Sort.html
  • 🧩DPアルゴリズム:最長逆背の順区間解析Algorithm/DynamicProgramming/other/Longest-common subsequence problem/Claude/README.html
  • -
  • 🧩Decode Ways - 動的計画法の視覚化 | LeetCode 91Algorithm/DynamicProgramming/leetcode/91. Decode Ways/Claude/README.html
  • 🧩Decode Ways - 動的計画法の視覚化 | LeetCode 91Algorithm/DynamicProgramming/leetcode/91. Decode Ways/Claude/README_react.html
  • +
  • 🧩Decode Ways - 動的計画法の視覚化 | LeetCode 91Algorithm/DynamicProgramming/leetcode/91. Decode Ways/Claude/README.html
  • 🧩Edit Distance (Levenshtein Distance) 技術解説Algorithm/DynamicProgramming/leetcode/72. Edit Distance/Claude/README.html
  • 🧩First Missing Positive - アルゴリズム解析Algorithm/Sort/CyclicSort/leetcode/41. First Missing Positive/Claude/Sign Marking/README.html
  • 🧩Grid Operations VisualizationAlgorithm/Other/at coder/Other/B44/README.html
  • @@ -483,6 +483,8 @@

  • 🧩LeetCode 125: Valid Palindrome | 二方向ポインタで学ぶ回文判定Algorithm/Other/leetcode/125. Valid Palindrome/claude sonnet 5 high/README_React.html
  • 🧩LeetCode 136. Single Number - XORで学ぶ1パス走査Algorithm/Other/leetcode/136. Single Number/claude sonnet 5 high/README_React.html
  • 🧩LeetCode 141: Linked List Cycle - 亀と兎のアルゴリズムで解説Algorithm/Floyds-Tortoise-and-Hare/leetcode/141. Linked List Cycle/Claude Sonnet5/README_react.html
  • +
  • 🧩LeetCode 14: Longest Common Prefix - 水平走査法による解説(Python/TypeScript/Go/Rust)Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/README_react.html
  • +
  • 🧩LeetCode 26: Remove Duplicates from Sorted Array — 11言語解説Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/README_React.html
  • 🧩LeetCode 5: Longest Palindromic Substring - 中心展開法Algorithm/ExpandAroundCenter/leetcode/5. Longest Palindromic Substring/Claude/README.html
  • 🧩LeetCode 66: Plus One - 右から左への繰り上がり処理Algorithm/Other/leetcode/66. Plus One/Claude Sonnet 4.5/README_react.html
  • 🧩LeetCode 67: Add Binary - 二進数加算Algorithm/TwoPointers/leetcode/67. Add Binary/Claude/README_react.html
  • @@ -497,8 +499,8 @@

  • 🧩LeetCode 97: Interleaving String - 1D DP解説Algorithm/DynamicProgramming/leetcode/97. Interleaving String/Claude Sonnet 4.5/README_React.html
  • 🧩LeetCode 98: Validate Binary Search TreeAlgorithm/BinarySearch/leetcode/98. Validate Binary Search Tree/Claude Sonnet 4.5/README_react.html
  • 🧩LeetCode 99: Recover Binary Search Tree - 中順走査によるBST修復Algorithm/BinarySearch/leetcode/99. Recover Binary Search Tree/Claude Opus 4.5/README_react.html
  • -
  • 🧩Length of Last Word - コード解析Algorithm/Other/leetcode/58. Length of Last Word/Claude/README.html
  • 🧩Length of Last Word - コード解析Algorithm/Other/leetcode/58. Length of Last Word/Claude/README_modern_style.html
  • +
  • 🧩Length of Last Word - コード解析Algorithm/Other/leetcode/58. Length of Last Word/Claude/README.html
  • 🧩Longest Substring Without Repeating Characters - スライディングウィンドウ+高速位置管理Algorithm/Sliding Window Method/leetcode/3. Longest Substring Without Repeating Characters/Claude/README.html
  • 🧩Maximum Subarray Algorithm AnalysisAlgorithm/Kadane’s Algorithm/leetcode/53. Maximum Subarray/Claude/README.html
  • 🧩Median of Two Sorted Arrays - 二分探索パーティション法Algorithm/BinarySearch/leetcode/4. Median of Two Sorted Arrays/Claude/README.html
  • @@ -524,6 +526,7 @@

  • 🧩Unique BSTs II - 分割統治+区間メモ化Algorithm/BinarySearch/leetcode/95. Unique Binary Search Trees II/Claude/README.html
  • 🧩Unique Paths II - Dynamic Programming TutorialAlgorithm/DynamicProgramming/leetcode/63. Unique Paths II/Claude/README.html
  • 🧩Unique Permutations Algorithm AnalysisAlgorithm/Backtracking/leetcode/47. Permutations II/Claude/README.html
  • +
  • 🧩Valid Parentheses - LeetCode 20 解説(Python / TypeScript / Go / Rust)Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/README_React.html
  • 🧩myAtoi Algorithm AnalysisAlgorithm/Other/leetcode/8. String to Integer (atoi)/README.html
  • 🧩りんご購入DPアルゴリズム解析Algorithm/DynamicProgramming/other/To achieve the lowest price/to achieve the lowest price 1/Claude/README.html
  • 🧩りんご購入アルゴリズム詳細解析Algorithm/DynamicProgramming/other/To achieve the lowest price/to achieve the lowest price 3/Claude/README.html
  • @@ -648,8 +651,8 @@

  • 🧩Count and Say アルゴリズム解析 (TypeScript版)Algorithm/Run Length Encoding/leetcode/38. Count and Say/Claude/README.html
  • 🧩Cyclic Sort - アルゴリズム解析Algorithm/Sort/CyclicSort/leetcode/41. First Missing Positive/Claude/README_Cyclic_Sort.html
  • 🧩DPアルゴリズム:最長逆背の順区間解析Algorithm/DynamicProgramming/other/Longest-common subsequence problem/Claude/README.html
  • -
  • 🧩Decode Ways - 動的計画法の視覚化 | LeetCode 91Algorithm/DynamicProgramming/leetcode/91. Decode Ways/Claude/README.html
  • 🧩Decode Ways - 動的計画法の視覚化 | LeetCode 91Algorithm/DynamicProgramming/leetcode/91. Decode Ways/Claude/README_react.html
  • +
  • 🧩Decode Ways - 動的計画法の視覚化 | LeetCode 91Algorithm/DynamicProgramming/leetcode/91. Decode Ways/Claude/README.html
  • 🧩Edit Distance (Levenshtein Distance) 技術解説Algorithm/DynamicProgramming/leetcode/72. Edit Distance/Claude/README.html
  • 🧩First Missing Positive - アルゴリズム解析Algorithm/Sort/CyclicSort/leetcode/41. First Missing Positive/Claude/Sign Marking/README.html
  • 🧩Grid Operations VisualizationAlgorithm/Other/at coder/Other/B44/README.html
  • @@ -673,6 +676,8 @@

  • 🧩LeetCode 125: Valid Palindrome | 二方向ポインタで学ぶ回文判定Algorithm/Other/leetcode/125. Valid Palindrome/claude sonnet 5 high/README_React.html
  • 🧩LeetCode 136. Single Number - XORで学ぶ1パス走査Algorithm/Other/leetcode/136. Single Number/claude sonnet 5 high/README_React.html
  • 🧩LeetCode 141: Linked List Cycle - 亀と兎のアルゴリズムで解説Algorithm/Floyds-Tortoise-and-Hare/leetcode/141. Linked List Cycle/Claude Sonnet5/README_react.html
  • +
  • 🧩LeetCode 14: Longest Common Prefix - 水平走査法による解説(Python/TypeScript/Go/Rust)Algorithm/Other/leetcode/14. Longest Common Prefix/Sonnet 5/README_react.html
  • +
  • 🧩LeetCode 26: Remove Duplicates from Sorted Array — 11言語解説Algorithm/TwoPointers/leetcode/26. Remove Duplicates from Sorted Array/Sonnet 5/README_React.html
  • 🧩LeetCode 5: Longest Palindromic Substring - 中心展開法Algorithm/ExpandAroundCenter/leetcode/5. Longest Palindromic Substring/Claude/README.html
  • 🧩LeetCode 66: Plus One - 右から左への繰り上がり処理Algorithm/Other/leetcode/66. Plus One/Claude Sonnet 4.5/README_react.html
  • 🧩LeetCode 67: Add Binary - 二進数加算Algorithm/TwoPointers/leetcode/67. Add Binary/Claude/README_react.html
  • @@ -687,8 +692,8 @@

  • 🧩LeetCode 97: Interleaving String - 1D DP解説Algorithm/DynamicProgramming/leetcode/97. Interleaving String/Claude Sonnet 4.5/README_React.html
  • 🧩LeetCode 98: Validate Binary Search TreeAlgorithm/BinarySearch/leetcode/98. Validate Binary Search Tree/Claude Sonnet 4.5/README_react.html
  • 🧩LeetCode 99: Recover Binary Search Tree - 中順走査によるBST修復Algorithm/BinarySearch/leetcode/99. Recover Binary Search Tree/Claude Opus 4.5/README_react.html
  • -
  • 🧩Length of Last Word - コード解析Algorithm/Other/leetcode/58. Length of Last Word/Claude/README.html
  • 🧩Length of Last Word - コード解析Algorithm/Other/leetcode/58. Length of Last Word/Claude/README_modern_style.html
  • +
  • 🧩Length of Last Word - コード解析Algorithm/Other/leetcode/58. Length of Last Word/Claude/README.html
  • 🧩Longest Substring Without Repeating Characters - スライディングウィンドウ+高速位置管理Algorithm/Sliding Window Method/leetcode/3. Longest Substring Without Repeating Characters/Claude/README.html
  • 🧩Maximum Subarray Algorithm AnalysisAlgorithm/Kadane’s Algorithm/leetcode/53. Maximum Subarray/Claude/README.html
  • 🧩Median of Two Sorted Arrays - 二分探索パーティション法Algorithm/BinarySearch/leetcode/4. Median of Two Sorted Arrays/Claude/README.html
  • @@ -714,6 +719,7 @@

  • 🧩Unique BSTs II - 分割統治+区間メモ化Algorithm/BinarySearch/leetcode/95. Unique Binary Search Trees II/Claude/README.html
  • 🧩Unique Paths II - Dynamic Programming TutorialAlgorithm/DynamicProgramming/leetcode/63. Unique Paths II/Claude/README.html
  • 🧩Unique Permutations Algorithm AnalysisAlgorithm/Backtracking/leetcode/47. Permutations II/Claude/README.html
  • +
  • 🧩Valid Parentheses - LeetCode 20 解説(Python / TypeScript / Go / Rust)Algorithm/BinarySearch/leetcode/20. Valid Parentheses/claude sonnet 5/README_React.html
  • 🧩myAtoi Algorithm AnalysisAlgorithm/Other/leetcode/8. String to Integer (atoi)/README.html
  • 🧩りんご購入DPアルゴリズム解析Algorithm/DynamicProgramming/other/To achieve the lowest price/to achieve the lowest price 1/Claude/README.html
  • 🧩りんご購入アルゴリズム詳細解析Algorithm/DynamicProgramming/other/To achieve the lowest price/to achieve the lowest price 3/Claude/README.html
  • @@ -851,7 +857,7 @@

    🧪 - Generated on 2026-08-06 + Generated on 2026-08-11