diff --git a/ja/docs/chapter_appendix/contribution.md b/ja/docs/chapter_appendix/contribution.md new file mode 100644 index 000000000..a791a6f95 --- /dev/null +++ b/ja/docs/chapter_appendix/contribution.md @@ -0,0 +1,53 @@ +--- +comments: true +--- + +# 16.2 コントリビューション + +著者の能力に限りがあるため、本書にはいくつかの省略や誤りが避けられません。ご理解をお願いします。誤字、リンク切れ、内容の欠落、文章の曖昧さ、説明の不明確さ、または不合理な文章構造を発見された場合は、読者により良質な学習リソースを提供するため、修正にご協力ください。 + +すべての[コントリビューター](https://github.com/krahets/hello-algo/graphs/contributors)のGitHub IDは、本書のリポジトリ、ウェブ、PDFバージョンのホームページに表示され、オープンソースコミュニティへの無私の貢献に感謝いたします。 + +!!! success "オープンソースの魅力" + + 紙の本の2つの印刷版の間隔はしばしば長く、内容の更新が非常に不便です。 + + しかし、このオープンソースの本では、内容の更新サイクルは数日、さらには数時間に短縮されます。 + +### 1. 内容の微調整 + +下の図に示すように、各ページの右上角に「編集アイコン」があります。以下の手順に従ってテキストやコードを修正できます。 + +1. 「編集アイコン」をクリックします。「このリポジトリをフォークしますか」と促された場合は、同意してください。 +2. Markdownソースファイルの内容を修正し、内容の正確性を確認し、フォーマットの一貫性を保つようにしてください。 +3. ページの下部で修正説明を記入し、「Propose file change」ボタンをクリックします。ページがリダイレクトされた後、「Create pull request」ボタンをクリックしてプルリクエストを開始します。 + +{ class="animation-figure" } + +
図 16-3 ページ編集ボタン
+ +図は直接修正できないため、新しい[Issue](https://github.com/krahets/hello-algo/issues)を作成するか、問題を説明するコメントが必要です。できるだけ早く図を再描画して置き換えます。 + +### 2. 内容の作成 + +このオープンソースプロジェクトへの参加に興味がある場合、コードを他のプログラミング言語に翻訳したり、記事の内容を拡張したりすることを含めて、以下のプルリクエストワークフローを実装する必要があります。 + +1. GitHubにログインし、本書の[コードリポジトリ](https://github.com/krahets/hello-algo)を個人アカウントにフォークします。 +2. フォークしたリポジトリのウェブページに移動し、`git clone`コマンドを使用してリポジトリをローカルマシンにクローンします。 +3. ローカルで内容を作成し、完全なテストを実行してコードの正確性を検証します。 +4. ローカルで行った変更をコミットし、リモートリポジトリにプッシュします。 +5. リポジトリのウェブページを更新し、「Create pull request」ボタンをクリックしてプルリクエストを開始します。 + +### 3. Dockerデプロイメント + +`hello-algo`ルートディレクトリで、以下のDockerスクリプトを実行して`http://localhost:8000`でプロジェクトにアクセスします: + +```shell +docker-compose up -d +``` + +以下のコマンドを使用してデプロイメントを削除します: + +```shell +docker-compose down +``` diff --git a/ja/docs/chapter_appendix/index.md b/ja/docs/chapter_appendix/index.md new file mode 100644 index 000000000..ab1180eb4 --- /dev/null +++ b/ja/docs/chapter_appendix/index.md @@ -0,0 +1,14 @@ +--- +comments: true +icon: material/help-circle-outline +--- + +# 第 16 章 付録 + +{ class="cover-image" } + +## 章の内容 + +- [16.1 プログラミング環境のインストール](installation.md) +- [16.2 一緒に創作に参加](contribution.md) +- [16.3 用語集](terminology.md) diff --git a/ja/docs/chapter_appendix/installation.md b/ja/docs/chapter_appendix/installation.md new file mode 100644 index 000000000..f063b961b --- /dev/null +++ b/ja/docs/chapter_appendix/installation.md @@ -0,0 +1,76 @@ +--- +comments: true +--- + +# 16.1 インストール + +## 16.1.1 IDEのインストール + +ローカルの統合開発環境(IDE)として、オープンソースで軽量なVS Codeを使用することをお勧めします。[VS Code公式ウェブサイト](https://code.visualstudio.com/)にアクセスし、お使いのオペレーティングシステムに適したVS Codeのバージョンを選択してダウンロードし、インストールしてください。 + +{ class="animation-figure" } + +図 16-1 公式ウェブサイトからVS Codeをダウンロード
+ +VS Codeには強力な拡張機能エコシステムがあり、ほとんどのプログラミング言語の実行とデバッグをサポートしています。例えば、「Python Extension Pack」をインストールした後、Pythonコードをデバッグできます。インストール手順を下の図に示します。 + +{ class="animation-figure" } + +図 16-2 VS Code拡張機能パックのインストール
+ +## 16.1.2 言語環境のインストール + +### 1. Python環境 + +1. [Miniconda3](https://docs.conda.io/en/latest/miniconda.html)をダウンロードしてインストールします。Python 3.10以降が必要です。 +2. VS Code拡張機能マーケットプレイスで`python`を検索し、Python Extension Packをインストールします。 +3. (オプション)コマンドラインで`pip install black`を入力して、コードフォーマッティングツールをインストールします。 + +### 2. C/C++環境 + +1. Windowsシステムでは[MinGW](https://sourceforge.net/projects/mingw-w64/files/)をインストールする必要があります([設定チュートリアル](https://blog.csdn.net/qq_33698226/article/details/129031241))。MacOSにはClangが付属しているため、インストールは不要です。 +2. VS Code拡張機能マーケットプレイスで`c++`を検索し、C/C++ Extension Packをインストールします。 +3. (オプション)設定ページを開き、`Clang_format_fallback Style`コードフォーマッティングオプションを検索し、`{ BasedOnStyle: Microsoft, BreakBeforeBraces: Attach }`に設定します。 + +### 3. Java環境 + +1. [OpenJDK](https://jdk.java.net/18/)をダウンロードしてインストールします(バージョンはJDK 9より新しい必要があります)。 +2. VS Code拡張機能マーケットプレイスで`java`を検索し、Extension Pack for Javaをインストールします。 + +### 4. C#環境 + +1. [.Net 8.0](https://dotnet.microsoft.com/en-us/download)をダウンロードしてインストールします。 +2. VS Code拡張機能マーケットプレイスで`C# Dev Kit`を検索し、C# Dev Kitをインストールします([設定チュートリアル](https://code.visualstudio.com/docs/csharp/get-started))。 +3. Visual Studioを使用することもできます([インストールチュートリアル](https://learn.microsoft.com/zh-cn/visualstudio/install/install-visual-studio?view=vs-2022))。 + +### 5. Go環境 + +1. [go](https://go.dev/dl/)をダウンロードしてインストールします。 +2. VS Code拡張機能マーケットプレイスで`go`を検索し、Goをインストールします。 +3. `Ctrl + Shift + P`を押してコマンドバーを呼び出し、goと入力し、`Go: Install/Update Tools`を選択し、すべてを選択してインストールします。 + +### 6. Swift環境 + +1. [Swift](https://www.swift.org/download/)をダウンロードしてインストールします。 +2. VS Code拡張機能マーケットプレイスで`swift`を検索し、[Swift for Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=sswg.swift-lang)をインストールします。 + +### 7. JavaScript環境 + +1. [Node.js](https://nodejs.org/en/)をダウンロードしてインストールします。 +2. (オプション)VS Code拡張機能マーケットプレイスで`Prettier`を検索し、コードフォーマッティングツールをインストールします。 + +### 8. TypeScript環境 + +1. JavaScript環境と同じインストール手順に従います。 +2. [TypeScript Execute (tsx)](https://github.com/privatenumber/tsx?tab=readme-ov-file#global-installation)をインストールします。 +3. VS Code拡張機能マーケットプレイスで`typescript`を検索し、[Pretty TypeScript Errors](https://marketplace.visualstudio.com/items?itemName=yoavbls.pretty-ts-errors)をインストールします。 + +### 9. Dart環境 + +1. [Dart](https://dart.dev/get-dart)をダウンロードしてインストールします。 +2. VS Code拡張機能マーケットプレイスで`dart`を検索し、[Dart](https://marketplace.visualstudio.com/items?itemName=Dart-Code.dart-code)をインストールします。 + +### 10. Rust環境 + +1. [Rust](https://www.rust-lang.org/tools/install)をダウンロードしてインストールします。 +2. VS Code拡張機能マーケットプレイスで`rust`を検索し、[rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)をインストールします。 diff --git a/ja/docs/chapter_appendix/terminology.md b/ja/docs/chapter_appendix/terminology.md new file mode 100644 index 000000000..a19a814ac --- /dev/null +++ b/ja/docs/chapter_appendix/terminology.md @@ -0,0 +1,145 @@ +--- +comments: true +--- + +# 16.3 用語集 + +下の表は本書に登場する重要な用語をリストアップしており、以下の点に注意する価値があります。 + +- 英語文献を読みやすくするため、用語の英語名を覚えることをお勧めします。 +- 一部の用語は簡体字中国語と繁体字中国語で異なる名前を持ちます。 + +表 16-1 データ構造とアルゴリズムの重要用語
+ +図 4-1 配列の定義と格納方法
+ +## 4.1.1 配列の一般的な操作 + +### 1. 配列の初期化 + +配列は必要に応じて2つの方法で初期化できます:初期値なしまたは指定された初期値付きです。初期値が指定されていない場合、ほとんどのプログラミング言語は配列要素を$0$に設定します: + +=== "Python" + + ```python title="array.py" + # 配列を初期化 + arr: list[int] = [0] * 5 # [ 0, 0, 0, 0, 0 ] + nums: list[int] = [1, 3, 2, 5, 4] + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* 配列を初期化 */ + // スタックに格納 + int arr[5]; + int nums[5] = { 1, 3, 2, 5, 4 }; + // ヒープに格納(手動でのメモリ解放が必要) + int* arr1 = new int[5]; + int* nums1 = new int[5] { 1, 3, 2, 5, 4 }; + ``` + +=== "Java" + + ```java title="array.java" + /* 配列を初期化 */ + int[] arr = new int[5]; // { 0, 0, 0, 0, 0 } + int[] nums = { 1, 3, 2, 5, 4 }; + ``` + +=== "C#" + + ```csharp title="array.cs" + /* 配列を初期化 */ + int[] arr = new int[5]; // [ 0, 0, 0, 0, 0 ] + int[] nums = [1, 3, 2, 5, 4]; + ``` + +=== "Go" + + ```go title="array.go" + /* 配列を初期化 */ + var arr [5]int + // Goでは、長さを指定([5]int)すると配列を示し、指定しない([]int)とスライスを示します。 + // Goの配列はコンパイル時に固定長を持つよう設計されているため、長さの指定には定数のみ使用できます。 + // extend()メソッドの実装の便宜上、ここではSliceを配列として扱います。 + nums := []int{1, 3, 2, 5, 4} + ``` + +=== "Swift" + + ```swift title="array.swift" + /* 配列を初期化 */ + let arr = Array(repeating: 0, count: 5) // [0, 0, 0, 0, 0] + let nums = [1, 3, 2, 5, 4] + ``` + +=== "JS" + + ```javascript title="array.js" + /* 配列を初期化 */ + var arr = new Array(5).fill(0); + var nums = [1, 3, 2, 5, 4]; + ``` + +=== "TS" + + ```typescript title="array.ts" + /* 配列を初期化 */ + let arr: number[] = new Array(5).fill(0); + let nums: number[] = [1, 3, 2, 5, 4]; + ``` + +=== "Dart" + + ```dart title="array.dart" + /* 配列を初期化 */ + List図 4-2 配列要素のメモリアドレス計算
+ +上の図で観察されるように、配列のインデックスは慣例的に$0$から始まります。これは直感に反するように見えるかもしれません。数を数えるのは通常$1$から始まるためですが、アドレス計算公式内では、**インデックスは本質的にメモリアドレスからのオフセット**です。最初の要素のアドレスでは、このオフセットは$0$で、そのインデックスが$0$であることを検証しています。 + +配列内の要素へのアクセスは非常に効率的で、$O(1)$時間で任意の要素にランダムアクセスできます。 + +=== "Python" + + ```python title="array.py" + def random_access(nums: list[int]) -> int: + """要素へのランダムアクセス""" + # 区間 [0, len(nums)-1] から数値をランダムに選択 + random_index = random.randint(0, len(nums) - 1) + # ランダムな要素を取得して返す + random_num = nums[random_index] + return random_num + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* 要素への乱数アクセス */ + int randomAccess(int *nums, int size) { + // [0, size)の範囲で乱数を選択 + int randomIndex = rand() % size; + // 乱数要素を取得して返却 + int randomNum = nums[randomIndex]; + return randomNum; + } + ``` + +=== "Java" + + ```java title="array.java" + /* 要素へのランダムアクセス */ + int randomAccess(int[] nums) { + // 区間 [0, nums.length) からランダムに数を選択 + int randomIndex = ThreadLocalRandom.current().nextInt(0, nums.length); + // ランダム要素を取得して返す + int randomNum = nums[randomIndex]; + return randomNum; + } + ``` + +=== "C#" + + ```csharp title="array.cs" + [class]{array}-[func]{RandomAccess} + ``` + +=== "Go" + + ```go title="array.go" + [class]{}-[func]{randomAccess} + ``` + +=== "Swift" + + ```swift title="array.swift" + [class]{}-[func]{randomAccess} + ``` + +=== "JS" + + ```javascript title="array.js" + [class]{}-[func]{randomAccess} + ``` + +=== "TS" + + ```typescript title="array.ts" + [class]{}-[func]{randomAccess} + ``` + +=== "Dart" + + ```dart title="array.dart" + [class]{}-[func]{randomAccess} + ``` + +=== "Rust" + + ```rust title="array.rs" + [class]{}-[func]{random_access} + ``` + +=== "C" + + ```c title="array.c" + [class]{}-[func]{randomAccess} + ``` + +=== "Kotlin" + + ```kotlin title="array.kt" + [class]{}-[func]{randomAccess} + ``` + +=== "Ruby" + + ```ruby title="array.rb" + [class]{}-[func]{random_access} + ``` + +=== "Zig" + + ```zig title="array.zig" + [class]{}-[func]{randomAccess} + ``` + +### 3. 要素の挿入 + +配列要素はメモリ内で密に詰まっており、それらの間に追加データを収容するための空間はありません。以下の図に示すように、配列の中央に要素を挿入するには、後続のすべての要素を1つずつ後ろにシフトして、新しい要素のための空間を作る必要があります。 + +{ class="animation-figure" } + +図 4-3 配列要素挿入の例
+ +配列の長さが固定されているため、要素を挿入すると必然的に配列の最後の要素が失われることに注意することが重要です。この問題を解決する方法は「リスト」の章で探求されます。 + +=== "Python" + + ```python title="array.py" + def insert(nums: list[int], num: int, index: int): + """インデックス index に要素 num を挿入""" + # インデックス index より後のすべての要素を1つ後ろに移動 + for i in range(len(nums) - 1, index, -1): + nums[i] = nums[i - 1] + # num を index の位置の要素に代入 + nums[index] = num + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* `index`に要素numを挿入 */ + void insert(int *nums, int size, int num, int index) { + // `index`より後のすべての要素を1つ後ろに移動 + for (int i = size - 1; i > index; i--) { + nums[i] = nums[i - 1]; + } + // indexの位置にnumを代入 + nums[index] = num; + } + ``` + +=== "Java" + + ```java title="array.java" + /* `index` に要素 num を挿入 */ + void insert(int[] nums, int num, int index) { + // `index` より後のすべての要素を1つ後ろに移動 + for (int i = nums.length - 1; i > index; i--) { + nums[i] = nums[i - 1]; + } + // index の要素に num を代入 + nums[index] = num; + } + ``` + +=== "C#" + + ```csharp title="array.cs" + [class]{array}-[func]{Insert} + ``` + +=== "Go" + + ```go title="array.go" + [class]{}-[func]{insert} + ``` + +=== "Swift" + + ```swift title="array.swift" + [class]{}-[func]{insert} + ``` + +=== "JS" + + ```javascript title="array.js" + [class]{}-[func]{insert} + ``` + +=== "TS" + + ```typescript title="array.ts" + [class]{}-[func]{insert} + ``` + +=== "Dart" + + ```dart title="array.dart" + [class]{}-[func]{insert} + ``` + +=== "Rust" + + ```rust title="array.rs" + [class]{}-[func]{insert} + ``` + +=== "C" + + ```c title="array.c" + [class]{}-[func]{insert} + ``` + +=== "Kotlin" + + ```kotlin title="array.kt" + [class]{}-[func]{insert} + ``` + +=== "Ruby" + + ```ruby title="array.rb" + [class]{}-[func]{insert} + ``` + +=== "Zig" + + ```zig title="array.zig" + [class]{}-[func]{insert} + ``` + +### 4. 要素の削除 + +同様に、以下の図に示すように、インデックス$i$の要素を削除するには、インデックス$i$に続くすべての要素を1つずつ前に移動する必要があります。 + +{ class="animation-figure" } + +図 4-4 配列要素削除の例
+ +削除後、元の最後の要素は「意味がない」ものになるため、特定の修正は必要ないことに注意してください。 + +=== "Python" + + ```python title="array.py" + def remove(nums: list[int], index: int): + """インデックス index の要素を削除""" + # インデックス index より後のすべての要素を1つ前に移動 + for i in range(index, len(nums) - 1): + nums[i] = nums[i + 1] + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* `index`の要素を削除 */ + void remove(int *nums, int size, int index) { + // `index`より後のすべての要素を1つ前に移動 + for (int i = index; i < size - 1; i++) { + nums[i] = nums[i + 1]; + } + } + ``` + +=== "Java" + + ```java title="array.java" + /* `index` の要素を削除 */ + void remove(int[] nums, int index) { + // `index` より後のすべての要素を1つ前に移動 + for (int i = index; i < nums.length - 1; i++) { + nums[i] = nums[i + 1]; + } + } + ``` + +=== "C#" + + ```csharp title="array.cs" + [class]{array}-[func]{Remove} + ``` + +=== "Go" + + ```go title="array.go" + [class]{}-[func]{remove} + ``` + +=== "Swift" + + ```swift title="array.swift" + [class]{}-[func]{remove} + ``` + +=== "JS" + + ```javascript title="array.js" + [class]{}-[func]{remove} + ``` + +=== "TS" + + ```typescript title="array.ts" + [class]{}-[func]{remove} + ``` + +=== "Dart" + + ```dart title="array.dart" + [class]{}-[func]{remove} + ``` + +=== "Rust" + + ```rust title="array.rs" + [class]{}-[func]{remove} + ``` + +=== "C" + + ```c title="array.c" + [class]{}-[func]{removeItem} + ``` + +=== "Kotlin" + + ```kotlin title="array.kt" + [class]{}-[func]{remove} + ``` + +=== "Ruby" + + ```ruby title="array.rb" + [class]{}-[func]{remove} + ``` + +=== "Zig" + + ```zig title="array.zig" + [class]{}-[func]{remove} + ``` + +要約すると、配列の挿入と削除操作には以下の欠点があります: + +- **高い時間計算量**:配列の挿入と削除の両方の平均時間計算量は$O(n)$で、ここで$n$は配列の長さです。 +- **要素の損失**:配列の長さが固定されているため、挿入時に配列の容量を超える要素は失われます。 +- **メモリの無駄**:より長い配列を初期化して前部分のみを利用すると、挿入時に「意味のない」末尾要素が生じ、メモリ空間の無駄につながります。 + +### 5. 配列の走査 + +ほとんどのプログラミング言語では、インデックスを使用するか、各要素を直接反復することで配列を走査できます: + +=== "Python" + + ```python title="array.py" + def traverse(nums: list[int]): + """配列の走査""" + count = 0 + # インデックスによる配列の走査 + for i in range(len(nums)): + count += nums[i] + # 配列要素の走査 + for num in nums: + count += num + # データのインデックスと要素の両方を走査 + for i, num in enumerate(nums): + count += nums[i] + count += num + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* 配列の走査 */ + void traverse(int *nums, int size) { + int count = 0; + // インデックスによる配列の走査 + for (int i = 0; i < size; i++) { + count += nums[i]; + } + } + ``` + +=== "Java" + + ```java title="array.java" + /* 配列を走査 */ + void traverse(int[] nums) { + int count = 0; + // インデックスによる配列の走査 + for (int i = 0; i < nums.length; i++) { + count += nums[i]; + } + // 配列要素の走査 + for (int num : nums) { + count += num; + } + } + ``` + +=== "C#" + + ```csharp title="array.cs" + [class]{array}-[func]{Traverse} + ``` + +=== "Go" + + ```go title="array.go" + [class]{}-[func]{traverse} + ``` + +=== "Swift" + + ```swift title="array.swift" + [class]{}-[func]{traverse} + ``` + +=== "JS" + + ```javascript title="array.js" + [class]{}-[func]{traverse} + ``` + +=== "TS" + + ```typescript title="array.ts" + [class]{}-[func]{traverse} + ``` + +=== "Dart" + + ```dart title="array.dart" + [class]{}-[func]{traverse} + ``` + +=== "Rust" + + ```rust title="array.rs" + [class]{}-[func]{traverse} + ``` + +=== "C" + + ```c title="array.c" + [class]{}-[func]{traverse} + ``` + +=== "Kotlin" + + ```kotlin title="array.kt" + [class]{}-[func]{traverse} + ``` + +=== "Ruby" + + ```ruby title="array.rb" + [class]{}-[func]{traverse} + ``` + +=== "Zig" + + ```zig title="array.zig" + [class]{}-[func]{traverse} + ``` + +### 6. 要素の検索 + +配列内の特定の要素を見つけることは、配列を反復し、各要素をチェックして目的の値と一致するかどうかを決定することを含みます。 + +配列は線形データ構造であるため、この操作は一般的に「線形探索」と呼ばれます。 + +=== "Python" + + ```python title="array.py" + def find(nums: list[int], target: int) -> int: + """配列内の指定された要素を検索""" + for i in range(len(nums)): + if nums[i] == target: + return i + return -1 + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* 配列内の指定要素を検索 */ + int find(int *nums, int size, int target) { + for (int i = 0; i < size; i++) { + if (nums[i] == target) + return i; + } + return -1; + } + ``` + +=== "Java" + + ```java title="array.java" + /* 配列内で指定された要素を検索 */ + int find(int[] nums, int target) { + for (int i = 0; i < nums.length; i++) { + if (nums[i] == target) + return i; + } + return -1; + } + ``` + +=== "C#" + + ```csharp title="array.cs" + [class]{array}-[func]{Find} + ``` + +=== "Go" + + ```go title="array.go" + [class]{}-[func]{find} + ``` + +=== "Swift" + + ```swift title="array.swift" + [class]{}-[func]{find} + ``` + +=== "JS" + + ```javascript title="array.js" + [class]{}-[func]{find} + ``` + +=== "TS" + + ```typescript title="array.ts" + [class]{}-[func]{find} + ``` + +=== "Dart" + + ```dart title="array.dart" + [class]{}-[func]{find} + ``` + +=== "Rust" + + ```rust title="array.rs" + [class]{}-[func]{find} + ``` + +=== "C" + + ```c title="array.c" + [class]{}-[func]{find} + ``` + +=== "Kotlin" + + ```kotlin title="array.kt" + [class]{}-[func]{find} + ``` + +=== "Ruby" + + ```ruby title="array.rb" + [class]{}-[func]{find} + ``` + +=== "Zig" + + ```zig title="array.zig" + [class]{}-[func]{find} + ``` + +### 7. 配列の拡張 + +複雑なシステム環境では、安全な容量拡張のために配列の後にメモリ空間の可用性を確保することが困難になります。その結果、ほとんどのプログラミング言語では、**配列の長さは不変**です。 + +配列を拡張するには、より大きな配列を作成し、元の配列から要素をコピーする必要があります。この操作の時間計算量は$O(n)$で、大きな配列では時間がかかる可能性があります。コードは以下の通りです: + +=== "Python" + + ```python title="array.py" + def extend(nums: list[int], enlarge: int) -> list[int]: + """配列の長さを拡張""" + # 拡張された長さの配列を初期化 + res = [0] * (len(nums) + enlarge) + # 元の配列のすべての要素を新しい配列にコピー + for i in range(len(nums)): + res[i] = nums[i] + # 拡張後の新しい配列を返す + return res + ``` + +=== "C++" + + ```cpp title="array.cpp" + /* 配列長の拡張 */ + int *extend(int *nums, int size, int enlarge) { + // 拡張された長さの配列を初期化 + int *res = new int[size + enlarge]; + // 元の配列の全要素を新しい配列にコピー + for (int i = 0; i < size; i++) { + res[i] = nums[i]; + } + // メモリを解放 + delete[] nums; + // 拡張後の新しい配列を返却 + return res; + } + ``` + +=== "Java" + + ```java title="array.java" + /* 配列長の拡張 */ + int[] extend(int[] nums, int enlarge) { + // 拡張された長さの配列を初期化 + int[] res = new int[nums.length + enlarge]; + // 元の配列のすべての要素を新しい配列にコピー + for (int i = 0; i < nums.length; i++) { + res[i] = nums[i]; + } + // 拡張後の新しい配列を返す + return res; + } + ``` + +=== "C#" + + ```csharp title="array.cs" + [class]{array}-[func]{Extend} + ``` + +=== "Go" + + ```go title="array.go" + [class]{}-[func]{extend} + ``` + +=== "Swift" + + ```swift title="array.swift" + [class]{}-[func]{extend} + ``` + +=== "JS" + + ```javascript title="array.js" + [class]{}-[func]{extend} + ``` + +=== "TS" + + ```typescript title="array.ts" + [class]{}-[func]{extend} + ``` + +=== "Dart" + + ```dart title="array.dart" + [class]{}-[func]{extend} + ``` + +=== "Rust" + + ```rust title="array.rs" + [class]{}-[func]{extend} + ``` + +=== "C" + + ```c title="array.c" + [class]{}-[func]{extend} + ``` + +=== "Kotlin" + + ```kotlin title="array.kt" + [class]{}-[func]{extend} + ``` + +=== "Ruby" + + ```ruby title="array.rb" + [class]{}-[func]{extend} + ``` + +=== "Zig" + + ```zig title="array.zig" + [class]{}-[func]{extend} + ``` + +## 4.1.2 配列の利点と制限 + +配列は連続したメモリ空間に格納され、同じ型の要素で構成されます。このアプローチは、システムがデータ構造操作の効率を最適化するために活用できる実質的な事前情報を提供します。 + +- **高い空間効率**:配列はデータのための連続したメモリブロックを割り当て、追加の構造的オーバーヘッドの必要性を排除します。 +- **ランダムアクセスのサポート**:配列は任意の要素への$O(1)$時間アクセスを可能にします。 +- **キャッシュ局所性**:配列要素にアクセスするとき、コンピュータはそれらを読み込むだけでなく、周囲のデータもキャッシュし、高速キャッシュを利用して後続の操作速度を向上させます。 + +しかし、連続空間格納は諸刃の剣で、以下の制限があります: + +- **挿入と削除の効率が低い**:配列に多くの要素が蓄積されると、要素の挿入や削除には大量の要素をシフトする必要があります。 +- **固定長**:配列の長さは初期化後に固定されます。配列を拡張するには、すべてのデータを新しい配列にコピーする必要があり、大きなコストがかかります。 +- **空間の無駄**:割り当てられた配列サイズが必要以上に大きい場合、余分な空間が無駄になります。 + +## 4.1.3 配列の典型的な応用 + +配列は基本的で広く使用されるデータ構造です。様々なアルゴリズムで頻繁に応用され、複雑なデータ構造の実装に役立ちます。 + +- **ランダムアクセス**:配列はランダムサンプリングが必要なときのデータ格納に理想的です。インデックスに基づいてランダムシーケンスを生成することで、効率的にランダムサンプリングを実現できます。 +- **ソートと検索**:配列はソートと検索アルゴリズムで最も一般的に使用されるデータ構造です。クイックソート、マージソート、二分探索などの技術は主に配列で動作します。 +- **ルックアップテーブル**:配列は迅速な要素や関係の取得のための効率的なルックアップテーブルとして機能します。例えば、文字をASCIIコードにマッピングすることは、ASCIIコード値をインデックスとして使用し、対応する要素を配列に格納することで簡単になります。 +- **機械学習**:ニューラルネットワークの領域では、配列はベクトル、行列、テンソルを含む重要な線形代数演算の実行において重要な役割を果たします。配列はニューラルネットワークプログラミングにおいて主要かつ最も広範囲に使用されるデータ構造として機能します。 +- **データ構造の実装**:配列は、スタック、キュー、ハッシュ表、ヒープ、グラフなど、様々なデータ構造を実装するための構成要素として機能します。例えば、グラフの隣接行列表現は本質的に二次元配列です。 diff --git a/ja/docs/chapter_array_and_linkedlist/index.md b/ja/docs/chapter_array_and_linkedlist/index.md new file mode 100644 index 000000000..1758a3c79 --- /dev/null +++ b/ja/docs/chapter_array_and_linkedlist/index.md @@ -0,0 +1,22 @@ +--- +comments: true +icon: material/view-list-outline +--- + +# 第 4 章 配列と連結リスト + +{ class="cover-image" } + +!!! abstract + + データ構造の世界は頑丈なレンガの壁に似ています。 + + 配列では、レンガがぴったりと整列し、それぞれが次のものと継ぎ目なく隣り合って、統一された形成を作っている姿を想像してください。一方、連結リストでは、これらのレンガが自由に散らばり、それらの間を優雅に編み込む蔦に抱かれています。 + +## 章の内容 + +- [4.1 配列](array.md) +- [4.2 連結リスト](linked_list.md) +- [4.3 リスト](list.md) +- [4.4 メモリとキャッシュ *](ram_and_cache.md) +- [4.5 まとめ](summary.md) diff --git a/ja/docs/chapter_array_and_linkedlist/linked_list.md b/ja/docs/chapter_array_and_linkedlist/linked_list.md new file mode 100644 index 000000000..c81da9c36 --- /dev/null +++ b/ja/docs/chapter_array_and_linkedlist/linked_list.md @@ -0,0 +1,1111 @@ +--- +comments: true +--- + +# 4.2 連結リスト + +メモリ空間は、すべてのプログラム間で共有されるリソースです。複雑なシステム環境では、使用可能なメモリがメモリ空間全体に分散している可能性があります。配列に割り当てられるメモリは連続している必要があることを理解していますが、非常に大きな配列の場合、十分な大きさの連続メモリ空間を見つけるのは困難な場合があります。ここで、連結リストの柔軟な利点が明らかになります。 + +連結リストは線形データ構造であり、各要素はノードオブジェクトで、ノードは「参照」を通じて相互接続されています。これらの参照は後続ノードのメモリアドレスを保持し、1つのノードから次のノードへのナビゲーションを可能にします。 + +連結リストの設計では、ノードを連続するメモリアドレスを必要とせずに、メモリ位置全体に分散配置することができます。 + +{ class="animation-figure" } + +図 4-5 連結リストの定義と格納方法
+ +上図に示すように、連結リストの基本的な構成要素はノードオブジェクトです。各ノードは2つの主要なコンポーネントで構成されています:ノードの「値」と次のノードへの「参照」です。 + +- 連結リストの最初のノードは「ヘッドノード」、最後のノードは「テールノード」です。 +- テールノードは「null」を指し、Javaでは`null`、C++では`nullptr`、Pythonでは`None`として指定されます。 +- C、C++、Go、Rustなどのポインタをサポートする言語では、この「参照」は通常「ポインタ」として実装されます。 + +以下のコードが示すように、連結リストの`ListNode`は値を保持するだけでなく、追加の参照(またはポインタ)も維持する必要があります。したがって、**連結リストは同じ量のデータを格納する場合、配列よりも多くのメモリ空間を占有します**。 + +=== "Python" + + ```python title="" + class ListNode: + """連結リストノードクラス""" + def __init__(self, val: int): + self.val: int = val # ノード値 + self.next: ListNode | None = None # 次のノードへの参照 + ``` + +=== "C++" + + ```cpp title="" + /* 連結リストノード構造体 */ + struct ListNode { + int val; // ノード値 + ListNode *next; // 次のノードへのポインタ + ListNode(int x) : val(x), next(nullptr) {} // コンストラクタ + }; + ``` + +=== "Java" + + ```java title="" + /* 連結リストノードクラス */ + class ListNode { + int val; // ノード値 + ListNode next; // 次のノードへの参照 + ListNode(int x) { val = x; } // コンストラクタ + } + ``` + +=== "C#" + + ```csharp title="" + /* 連結リストノードクラス */ + class ListNode(int x) { // コンストラクタ + int val = x; // ノード値 + ListNode? next; // 次のノードへの参照 + } + ``` + +=== "Go" + + ```go title="" + /* 連結リストノード構造体 */ + type ListNode struct { + Val int // ノード値 + Next *ListNode // 次のノードへのポインタ + } + + // NewListNode コンストラクタ、新しい連結リストを作成 + func NewListNode(val int) *ListNode { + return &ListNode{ + Val: val, + Next: nil, + } + } + ``` + +=== "Swift" + + ```swift title="" + /* 連結リストノードクラス */ + class ListNode { + var val: Int // ノード値 + var next: ListNode? // 次のノードへの参照 + + init(x: Int) { // コンストラクタ + val = x + } + } + ``` + +=== "JS" + + ```javascript title="" + /* 連結リストノードクラス */ + class ListNode { + constructor(val, next) { + this.val = (val === undefined ? 0 : val); // ノード値 + this.next = (next === undefined ? null : next); // 次のノードへの参照 + } + } + ``` + +=== "TS" + + ```typescript title="" + /* 連結リストノードクラス */ + 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; // 次のノードへの参照 + } + } + ``` + +=== "Dart" + + ```dart title="" + /* 連結リストノードクラス */ + class ListNode { + int val; // ノード値 + ListNode? next; // 次のノードへの参照 + ListNode(this.val, [this.next]); // コンストラクタ + } + ``` + +=== "Rust" + + ```rust title="" + use std::rc::Rc; + use std::cell::RefCell; + /* 連結リストノードクラス */ + #[derive(Debug)] + struct ListNode { + val: i32, // ノード値 + next: Option図 4-6 連結リストノード挿入の例
+ +=== "Python" + + ```python title="linked_list.py" + def insert(n0: ListNode, P: ListNode): + """連結リストのノード n0 の後にノード P を挿入""" + n1 = n0.next + P.next = n1 + n0.next = P + ``` + +=== "C++" + + ```cpp title="linked_list.cpp" + /* 連結リストのノードn0の後にノードPを挿入 */ + void insert(ListNode *n0, ListNode *P) { + ListNode *n1 = n0->next; + P->next = n1; + n0->next = P; + } + ``` + +=== "Java" + + ```java title="linked_list.java" + /* 連結リストでノード n0 の後にノード P を挿入 */ + void insert(ListNode n0, ListNode P) { + ListNode n1 = n0.next; + P.next = n1; + n0.next = P; + } + ``` + +=== "C#" + + ```csharp title="linked_list.cs" + [class]{linked_list}-[func]{Insert} + ``` + +=== "Go" + + ```go title="linked_list.go" + [class]{}-[func]{insertNode} + ``` + +=== "Swift" + + ```swift title="linked_list.swift" + [class]{}-[func]{insert} + ``` + +=== "JS" + + ```javascript title="linked_list.js" + [class]{}-[func]{insert} + ``` + +=== "TS" + + ```typescript title="linked_list.ts" + [class]{}-[func]{insert} + ``` + +=== "Dart" + + ```dart title="linked_list.dart" + [class]{}-[func]{insert} + ``` + +=== "Rust" + + ```rust title="linked_list.rs" + [class]{}-[func]{insert} + ``` + +=== "C" + + ```c title="linked_list.c" + [class]{}-[func]{insert} + ``` + +=== "Kotlin" + + ```kotlin title="linked_list.kt" + [class]{}-[func]{insert} + ``` + +=== "Ruby" + + ```ruby title="linked_list.rb" + [class]{}-[func]{insert} + ``` + +=== "Zig" + + ```zig title="linked_list.zig" + [class]{}-[func]{insert} + ``` + +### 3. ノードの削除 + +下図に示すように、連結リストからノードを削除することも非常に簡単で、**1つのノードの参照(ポインタ)を変更するだけです**。 + +重要な点は、ノード`P`が削除された後も`n1`を指し続けていることですが、連結リストの巡回中にはアクセスできなくなることです。これは事実上、`P`が連結リストの一部ではなくなったことを意味します。 + +{ class="animation-figure" } + +図 4-7 連結リストノードの削除
+ +=== "Python" + + ```python title="linked_list.py" + def remove(n0: ListNode): + """連結リストのノード n0 の後の最初のノードを削除""" + if not n0.next: + return + # n0 -> P -> n1 + P = n0.next + n1 = P.next + n0.next = n1 + ``` + +=== "C++" + + ```cpp title="linked_list.cpp" + /* 連結リストのノードn0の後の最初のノードを削除 */ + void remove(ListNode *n0) { + if (n0->next == nullptr) + return; + // n0 -> P -> n1 + ListNode *P = n0->next; + ListNode *n1 = P->next; + n0->next = n1; + // メモリを解放 + delete P; + } + ``` + +=== "Java" + + ```java title="linked_list.java" + /* 連結リストでノード n0 の後の最初のノードを削除 */ + void remove(ListNode n0) { + if (n0.next == null) + return; + // n0 -> P -> n1 + ListNode P = n0.next; + ListNode n1 = P.next; + n0.next = n1; + } + ``` + +=== "C#" + + ```csharp title="linked_list.cs" + [class]{linked_list}-[func]{Remove} + ``` + +=== "Go" + + ```go title="linked_list.go" + [class]{}-[func]{removeItem} + ``` + +=== "Swift" + + ```swift title="linked_list.swift" + [class]{}-[func]{remove} + ``` + +=== "JS" + + ```javascript title="linked_list.js" + [class]{}-[func]{remove} + ``` + +=== "TS" + + ```typescript title="linked_list.ts" + [class]{}-[func]{remove} + ``` + +=== "Dart" + + ```dart title="linked_list.dart" + [class]{}-[func]{remove} + ``` + +=== "Rust" + + ```rust title="linked_list.rs" + [class]{}-[func]{remove} + ``` + +=== "C" + + ```c title="linked_list.c" + [class]{}-[func]{removeItem} + ``` + +=== "Kotlin" + + ```kotlin title="linked_list.kt" + [class]{}-[func]{remove} + ``` + +=== "Ruby" + + ```ruby title="linked_list.rb" + [class]{}-[func]{remove} + ``` + +=== "Zig" + + ```zig title="linked_list.zig" + [class]{}-[func]{remove} + ``` + +### 4. ノードへのアクセス + +**連結リストでのノードへのアクセスは効率が悪いです**。前述したように、配列の任意の要素には$O(1)$時間でアクセスできます。対照的に、連結リストでは、プログラムはヘッドノードから開始して目的のノードが見つかるまで順次ノードを巡回する必要があります。つまり、連結リストの$i$番目のノードにアクセスするには、プログラムは$i - 1$個のノードを反復処理する必要があり、時間計算量は$O(n)$になります。 + +=== "Python" + + ```python title="linked_list.py" + def access(head: ListNode, index: int) -> ListNode | None: + """連結リストのインデックス index のノードにアクセス""" + for _ in range(index): + if not head: + return None + head = head.next + return head + ``` + +=== "C++" + + ```cpp title="linked_list.cpp" + /* 連結リストの`index`番目のノードにアクセス */ + ListNode *access(ListNode *head, int index) { + for (int i = 0; i < index; i++) { + if (head == nullptr) + return nullptr; + head = head->next; + } + return head; + } + ``` + +=== "Java" + + ```java title="linked_list.java" + /* 連結リストの `index` のノードにアクセス */ + ListNode access(ListNode head, int index) { + for (int i = 0; i < index; i++) { + if (head == null) + return null; + head = head.next; + } + return head; + } + ``` + +=== "C#" + + ```csharp title="linked_list.cs" + [class]{linked_list}-[func]{Access} + ``` + +=== "Go" + + ```go title="linked_list.go" + [class]{}-[func]{access} + ``` + +=== "Swift" + + ```swift title="linked_list.swift" + [class]{}-[func]{access} + ``` + +=== "JS" + + ```javascript title="linked_list.js" + [class]{}-[func]{access} + ``` + +=== "TS" + + ```typescript title="linked_list.ts" + [class]{}-[func]{access} + ``` + +=== "Dart" + + ```dart title="linked_list.dart" + [class]{}-[func]{access} + ``` + +=== "Rust" + + ```rust title="linked_list.rs" + [class]{}-[func]{access} + ``` + +=== "C" + + ```c title="linked_list.c" + [class]{}-[func]{access} + ``` + +=== "Kotlin" + + ```kotlin title="linked_list.kt" + [class]{}-[func]{access} + ``` + +=== "Ruby" + + ```ruby title="linked_list.rb" + [class]{}-[func]{access} + ``` + +=== "Zig" + + ```zig title="linked_list.zig" + [class]{}-[func]{access} + ``` + +### 5. ノードの検索 + +連結リストを巡回して、値が`target`に一致するノードを見つけ、連結リスト内でのそのノードのインデックスを出力します。この手順も線形検索の例です。対応するコードは以下のとおりです: + +=== "Python" + + ```python title="linked_list.py" + def find(head: ListNode, target: int) -> int: + """連結リストで値 target を持つ最初のノードを検索""" + index = 0 + while head: + if head.val == target: + return index + head = head.next + index += 1 + return -1 + ``` + +=== "C++" + + ```cpp title="linked_list.cpp" + /* 連結リストで値がtargetの最初のノードを検索 */ + int find(ListNode *head, int target) { + int index = 0; + while (head != nullptr) { + if (head->val == target) + return index; + head = head->next; + index++; + } + return -1; + } + ``` + +=== "Java" + + ```java title="linked_list.java" + /* 連結リストで値 target を持つ最初のノードを検索 */ + int find(ListNode head, int target) { + int index = 0; + while (head != null) { + if (head.val == target) + return index; + head = head.next; + index++; + } + return -1; + } + ``` + +=== "C#" + + ```csharp title="linked_list.cs" + [class]{linked_list}-[func]{Find} + ``` + +=== "Go" + + ```go title="linked_list.go" + [class]{}-[func]{findNode} + ``` + +=== "Swift" + + ```swift title="linked_list.swift" + [class]{}-[func]{find} + ``` + +=== "JS" + + ```javascript title="linked_list.js" + [class]{}-[func]{find} + ``` + +=== "TS" + + ```typescript title="linked_list.ts" + [class]{}-[func]{find} + ``` + +=== "Dart" + + ```dart title="linked_list.dart" + [class]{}-[func]{find} + ``` + +=== "Rust" + + ```rust title="linked_list.rs" + [class]{}-[func]{find} + ``` + +=== "C" + + ```c title="linked_list.c" + [class]{}-[func]{find} + ``` + +=== "Kotlin" + + ```kotlin title="linked_list.kt" + [class]{}-[func]{find} + ``` + +=== "Ruby" + + ```ruby title="linked_list.rb" + [class]{}-[func]{find} + ``` + +=== "Zig" + + ```zig title="linked_list.zig" + [class]{}-[func]{find} + ``` + +## 4.2.2 配列 vs. 連結リスト + +下表は配列と連結リストの特性をまとめ、様々な操作における効率も比較しています。それぞれが対照的な格納戦略を使用するため、それぞれの特性と操作効率は明確に対比されています。 + +表 4-1 配列と連結リストの効率比較
+ +図 4-8 連結リストの一般的な種類
+ +## 4.2.4 連結リストの典型的な応用 + +単方向連結リストは、スタック、キュー、ハッシュ表、グラフの実装によく使用されます。 + +- **スタックとキュー**:単方向連結リストで、挿入と削除が同じ端で行われる場合、スタック(後入先出)のように動作します。逆に、挿入が一方の端で、削除がもう一方の端で行われる場合、キュー(先入先出)のように機能します。 +- **ハッシュ表**:連結リストは、ハッシュ衝突を解決する人気の方法である連鎖法で使用されます。ここでは、すべての衝突した要素が連結リストにグループ化されます。 +- **グラフ**:グラフ表現の標準的な方法である隣接リストは、各グラフ頂点を連結リストに関連付けます。このリストには、対応する頂点に接続された頂点を表す要素が含まれます。 + +双方向連結リストは、前後の要素への高速アクセスが必要なシナリオに最適です。 + +- **高度なデータ構造**:赤黒木やB木などの構造では、ノードの親へのアクセスが重要です。これは各ノードに親ノードへの参照を組み込むことで実現され、双方向連結リストに似ています。 +- **ブラウザ履歴**:Webブラウザでは、双方向連結リストにより、ユーザーが前進または後退ボタンをクリックしたときの訪問ページの履歴ナビゲーションが容易になります。 +- **LRUアルゴリズム**:双方向連結リストは、最近最少使用(LRU)キャッシュ削除アルゴリズムに適しており、最近最少使用データの迅速な識別と、高速なノード追加・削除を可能にします。 + +循環連結リストは、オペレーティングシステムでのリソーススケジューリングなど、周期的な操作が必要なアプリケーションに最適です。 + +- **ラウンドロビンスケジューリングアルゴリズム**:オペレーティングシステムでは、ラウンドロビンスケジューリングアルゴリズムは一般的なCPUスケジューリング方法であり、プロセスのグループを循環する必要があります。各プロセスにはタイムスライスが割り当てられ、期限切れになるとCPUは次のプロセスに回転します。この循環操作は循環連結リストを使用して効率的に実現でき、すべてのプロセス間で公平かつ時分割システムを可能にします。 +- **データバッファ**:循環連結リストは、オーディオやビデオプレーヤーなどのデータバッファでも使用され、データストリームが複数のバッファブロックに分割され、シームレスな再生のために循環方式で配置されます。 diff --git a/ja/docs/chapter_array_and_linkedlist/list.md b/ja/docs/chapter_array_and_linkedlist/list.md new file mode 100644 index 000000000..a3ea7e1ee --- /dev/null +++ b/ja/docs/chapter_array_and_linkedlist/list.md @@ -0,0 +1,1270 @@ +--- +comments: true +--- + +# 4.3 リスト + +リストは、要素へのアクセス、変更、追加、削除、走査などの操作をサポートする、順序付けられた要素のコレクションを表す抽象的なデータ構造の概念であり、ユーザーが容量制限を考慮する必要がありません。リストは連結リストまたは配列に基づいて実装できます。 + +- 連結リストは本質的にリストとして機能し、要素の追加、削除、検索、変更の操作をサポートし、サイズを動的に調整する柔軟性があります。 +- 配列もこれらの操作をサポートしますが、長さが不変であるため、長さ制限のあるリストと考えることができます。 + +配列を使用してリストを実装する場合、**長さの不変性によりリストの実用性が低下します**。これは、事前に格納するデータ量を予測することが困難な場合が多く、適切なリスト長を選択することが困難であるためです。長さが小さすぎると要件を満たさない可能性があり、大きすぎるとメモリ空間を無駄にする可能性があります。 + +この問題を解決するために、動的配列を使用してリストを実装できます。これは配列の利点を継承し、プログラム実行中に動的に拡張できます。 + +実際、**多くのプログラミング言語の標準ライブラリは動的配列を使用してリストを実装しています**。例えば、Pythonの`list`、Javaの`ArrayList`、C++の`vector`、C#の`List`などです。以下の議論では、「リスト」と「動的配列」を同義の概念として扱います。 + +## 4.3.1 リストの一般的な操作 + +### 1. リストの初期化 + +通常、「初期値なし」と「初期値あり」の2つの初期化方法を使用します。 + +=== "Python" + + ```python title="list.py" + # リストを初期化 + # 初期値なし + nums1: list[int] = [] + # 初期値あり + nums: list[int] = [1, 3, 2, 5, 4] + ``` + +=== "C++" + + ```cpp title="list.cpp" + /* リストを初期化 */ + // 注意: C++では、vectorがここで説明されているnumsに相当します + // 初期値なし + vector表 4-2 コンピュータ記憶装置
+ +図 4-9 コンピュータ記憶システム
+ +!!! tip + + コンピュータの記憶階層は、速度、容量、コストの間の慎重なバランスを反映しています。このタイプのトレードオフは様々な業界で一般的であり、利益と制限の間の最適なバランスを見つけることが重要です。 + +全体的に、**ハードディスクは大量のデータの長期保存を提供し、メモリはプログラム実行中に処理されるデータの一時保存として機能し、キャッシュは頻繁にアクセスされるデータと命令を保存して実行効率を向上させます**。それらは一緒になってコンピュータシステムの効率的な動作を保証します。 + +下図に示すように、プログラム実行中、データはハードディスクからメモリに読み込まれ、CPU計算が行われます。CPUの拡張として機能するキャッシュは、**メモリからインテリジェントにデータを先読み**し、CPUのより高速なデータアクセスを可能にします。これによりプログラム実行効率が大幅に向上し、低速なメモリへの依存が減少します。 + +{ class="animation-figure" } + +図 4-10 ハードディスク、メモリ、キャッシュ間のデータフロー
+ +## 4.4.2 データ構造のメモリ効率 + +メモリ空間利用の観点から、配列と連結リストにはそれぞれ利点と制限があります。 + +一方で、**メモリは限られており、複数のプログラム間で共有できない**ため、データ構造での空間使用の最適化は重要です。配列は要素が密接にパックされており、連結リストのように参照(ポインタ)のための追加メモリを必要としないため、空間効率的です。しかし、配列は連続したメモリブロックを事前に割り当てる必要があり、割り当てられた空間が実際の必要量を超える場合、無駄につながる可能性があります。配列の拡張も追加の時間と空間のオーバーヘッドを伴います。対照的に、連結リストは各ノードに対してメモリを動的に割り当て・解放し、ポインタのための追加メモリのコストでより大きな柔軟性を提供します。 + +一方で、プログラム実行中、**繰り返されるメモリの割り当てと解放はメモリの断片化を増加させ**、メモリ利用効率を低下させます。配列は連続記憶方式により、メモリ断片化を引き起こす可能性が比較的低いです。対照的に、連結リストは要素を非連続の場所に保存し、頻繁な挿入と削除はメモリ断片化を悪化させる可能性があります。 + +## 4.4.3 データ構造のキャッシュ効率 + +キャッシュはメモリよりも空間容量がはるかに小さいですが、はるかに高速で、プログラム実行速度において重要な役割を果たします。限られた容量のため、キャッシュは頻繁にアクセスされるデータのサブセットのみを保存できます。CPUがキャッシュに存在しないデータにアクセスしようとすると、キャッシュミスが発生し、CPUは低速なメモリから必要なデータを取得する必要があり、パフォーマンスに影響を与える可能性があります。 + +明らかに、**キャッシュミスが少ないほど、CPUのデータ読み書き効率が高く**、プログラムパフォーマンスが向上します。CPUがキャッシュからデータを正常に取得する割合はキャッシュヒット率と呼ばれ、キャッシュ効率を測定するためによく使用される指標です。 + +より高い効率を達成するために、キャッシュは以下のデータロードメカニズムを採用します。 + +- **キャッシュライン**:キャッシュは個々のバイトではなく、キャッシュラインと呼ばれる単位でデータを保存・ロードして動作します。このアプローチは、一度により大きなデータブロックを転送することで効率を向上させます。 +- **先読みメカニズム**:プロセッサはデータアクセスパターン(例:連続または固定ストライドアクセス)を予測し、これらのパターンに基づいてデータをキャッシュに先読みして、キャッシュヒット率を向上させます。 +- **空間的局所性**:特定のデータがアクセスされると、近くのデータもまもなくアクセスされる可能性があります。これを活用するために、キャッシュは要求されたデータと一緒に隣接するデータをロードし、ヒット率を向上させます。 +- **時間的局所性**:データがアクセスされた場合、近い将来に再びアクセスされる可能性があります。キャッシュはこの原理を使用して、最近アクセスされたデータを保持してヒット率を向上させます。 + +実際、**配列と連結リストは異なるキャッシュ利用効率を持ち**、これは主に以下の側面に反映されます。 + +- **占有空間**:連結リスト要素は配列要素よりも多くの空間を占有するため、キャッシュに保持される有効データが少なくなります。 +- **キャッシュライン**:連結リストデータはメモリ全体に散在し、キャッシュは「行単位でロード」されるため、ロードされる無効データの割合が高くなります。 +- **先読みメカニズム**:配列のデータアクセスパターンは連結リストよりも「予測可能」で、つまりシステムがこれからロードされるデータを推測しやすいです。 +- **空間的局所性**:配列は連続したメモリ空間に保存されるため、ロードされているデータの近くのデータがまもなくアクセスされる可能性が高くなります。 + +全体的に、**配列はより高いキャッシュヒット率を持ち、一般的に連結リストよりも操作効率が高いです**。これにより、配列に基づくデータ構造はアルゴリズム問題の解決において人気があります。 + +**高いキャッシュ効率が配列が常に連結リストより優れているという意味ではない**ことに注意すべきです。データ構造の選択は特定のアプリケーション要件に依存すべきです。例えば、配列と連結リストの両方が「スタック」データ構造を実装できますが(次章で詳細説明)、それらは異なるシナリオに適しています。 + +- アルゴリズム問題では、より高い操作効率とランダムアクセス機能を提供するため、配列に基づくスタックを選択する傾向があります。唯一のコストは配列に対して一定量のメモリ空間を事前に割り当てる必要があることです。 +- データ量が非常に大きく、高度に動的で、スタックの予想サイズを推定するのが困難な場合、連結リストに基づくスタックがより良い選択です。連結リストは大量のデータをメモリの異なる部分に分散でき、配列拡張の追加オーバーヘッドを回避できます。 diff --git a/ja/docs/chapter_array_and_linkedlist/summary.md b/ja/docs/chapter_array_and_linkedlist/summary.md new file mode 100644 index 000000000..6abecc2b3 --- /dev/null +++ b/ja/docs/chapter_array_and_linkedlist/summary.md @@ -0,0 +1,85 @@ +--- +comments: true +--- + +# 4.5 まとめ + +### 1. 重要な復習 + +- 配列と連結リストは2つの基本的なデータ構造であり、コンピュータメモリにおける2つの格納方法を表しています:連続空間格納と非連続空間格納です。それらの特性は互いに補完し合います。 +- 配列はランダムアクセスをサポートし、使用するメモリが少ない一方で、要素の挿入と削除は非効率的で、初期化後の長さが固定されています。 +- 連結リストは参照(ポインタ)の変更によって効率的なノードの挿入と削除を実装し、長さを柔軟に調整できますが、ノードアクセス効率が低く、より多くのメモリを消費します。 +- 連結リストの一般的な種類には、単方向連結リスト、循環連結リスト、双方向連結リストがあり、それぞれに独自の応用シナリオがあります。 +- リストは要素の順序付けられたコレクションで、追加、削除、変更をサポートし、通常は動的配列に基づいて実装され、配列の利点を保持しながら柔軟な長さ調整を可能にします。 +- リストの出現により配列の実用性が大幅に向上しましたが、一部のメモリ空間の無駄につながる可能性があります。 +- プログラム実行中、データは主にメモリに格納されます。配列はより高いメモリ空間効率を提供し、連結リストはメモリ使用においてより柔軟です。 +- キャッシュは、キャッシュライン、先読み、空間的局所性、時間的局所性などのメカニズムを通じてCPUに高速データアクセスを提供し、プログラム実行効率を大幅に向上させます。 +- より高いキャッシュヒット率により、配列は一般的に連結リストよりも効率的です。データ構造を選択する際は、特定のニーズとシナリオに基づいて適切な選択をすべきです。 + +### 2. Q & A + +**Q**:配列をスタックに格納するかヒープに格納するかは、時間と空間効率に影響しますか? + +スタックとヒープの両方に格納される配列は連続したメモリ空間に格納され、データ操作効率は本質的に同じです。しかし、スタックとヒープには独自の特性があり、以下の違いが生じます。 + +1. 割り当てと解放効率:スタックはより小さなメモリブロックで、コンパイラによって自動的に割り当てられます。ヒープメモリは比較的大きく、コードで動的に割り当てることができ、断片化しやすいです。したがって、ヒープでの割り当てと解放操作は一般的にスタックよりも遅くなります。 +2. サイズ制限:スタックメモリは比較的小さく、ヒープサイズは一般的に利用可能なメモリによって制限されます。したがって、ヒープは大きな配列の格納により適しています。 +3. 柔軟性:スタック上の配列のサイズはコンパイル時に決定される必要がありますが、ヒープ上の配列のサイズは実行時に動的に決定できます。 + +**Q**:なぜ配列は同じ型の要素を必要とし、連結リストは同じ型の要素を強調しないのですか? + +連結リストは参照(ポインタ)によって接続されたノードで構成され、各ノードはint、double、string、objectなど、異なる型のデータを格納できます。 + +対照的に、配列要素は同じ型である必要があり、これにより対応する要素位置にアクセスするためのオフセットを計算できます。例えば、intとlong型の両方を含む配列で、単一要素がそれぞれ4バイトと8バイトを占有する場合、配列に2つの異なる長さの要素が含まれているため、以下の式を使用してオフセットを計算できません。 + +```shell +# 要素メモリアドレス = 配列メモリアドレス + 要素長 * 要素インデックス +``` + +**Q**:ノードを削除した後、`P.next`を`None`に設定する必要がありますか? + +`P.next`を変更しなくても問題ありません。連結リストの観点から、ヘッドノードからテールノードまでの巡回で`P`に遭遇することはもうありません。これは、ノード`P`がリストから効果的に削除されたことを意味し、`P`が指す場所はもはやリストに影響しません。 + +ガベージコレクションの観点から、Java、Python、Goなどの自動ガベージコレクションメカニズムを持つ言語では、ノード`P`が収集されるかどうかは、それを指す参照がまだあるかどうかに依存し、`P.next`の値には依存しません。CやC++などの言語では、ノードのメモリを手動で解放する必要があります。 + +**Q**:連結リストでは、挿入と削除操作の時間計算量は`O(1)`です。しかし、挿入や削除前の要素検索には`O(n)`時間がかかるので、なぜ時間計算量は`O(n)`ではないのですか? + +要素を最初に検索してから削除する場合、時間計算量は確かに`O(n)`です。しかし、連結リストの挿入と削除における`O(1)`の利点は他のアプリケーションで実現できます。例えば、連結リストを使用した両端キューの実装では、常にヘッドとテールノードを指すポインタを維持し、各挿入と削除操作を`O(1)`にします。 + +**Q**:「連結リストの定義と格納方法」の図で、薄青色の格納ノードは単一のメモリアドレスを占有しますか、それともノード値と半分を共有しますか? + +図は単なる定性的な表現であり、定量的分析は特定の状況に依存します。 + +- 異なる型のノード値は異なる量の空間を占有します。例えば、int、long、double、オブジェクトインスタンスです。 +- ポインタ変数によって占有されるメモリ空間は、使用されるオペレーティングシステムとコンパイル環境に依存し、通常8バイトまたは4バイトです。 + +**Q**:リストの末尾への要素追加は常に`O(1)`ですか? + +要素を追加することでリスト長を超える場合、リストは最初に拡張される必要があります。システムは新しいメモリブロックを要求し、元のリストのすべての要素を移動するため、この場合の時間計算量は`O(n)`になります。 + +**Q**:「リストの出現により配列の実用性が大幅に向上しましたが、一部のメモリ空間の無駄につながる可能性があります」という文は、容量、長さ、拡張係数などの追加変数によって占有されるメモリを指していますか? + +ここでの空間の無駄は主に2つの側面を指します:一方で、リストは初期長で設定されますが、常に必要とは限りません。他方で、頻繁な拡張を防ぐため、拡張は通常$\times 1.5$などの係数で乗算されます。これにより多くの空きスロットが生まれ、通常は完全に埋めることができません。 + +**Q**:Pythonで`n = [1, 2, 3]`を初期化した後、これら3つの要素のアドレスは連続していますが、`m = [2, 1, 3]`を初期化すると、各要素の`id`は連続していないが`n`のものと同一です。これらの要素のアドレスが連続していない場合、`m`はまだ配列ですか? + +リスト要素を連結リストノード`n = [n1, n2, n3, n4, n5]`に置き換える場合、これら5つのノードオブジェクトも通常メモリ全体に分散しています。しかし、リストインデックスが与えられれば、`O(1)`時間でノードのメモリアドレスにアクセスでき、対応するノードにアクセスできます。これは、配列がノード自体ではなく、ノードへの参照を格納するためです。 + +多くの言語とは異なり、Pythonでは数値もオブジェクトとしてラップされ、リストは数値自体ではなく、これらの数値への参照を格納します。したがって、2つの配列の同じ数値が同じ`id`を持ち、これらの数値のメモリアドレスは連続である必要がないことがわかります。 + +**Q**:C++ STLの`std::list`はすでに双方向連結リストを実装していますが、一部のアルゴリズム書籍では直接使用していないようです。何か制限がありますか? + +一方で、アルゴリズムを実装する際は配列を使用することを好み、必要な場合のみ連結リストを使用します。主に2つの理由があります。 + +- 空間オーバーヘッド:各要素に2つの追加ポインタ(前の要素用と次の要素用)が必要なため、`std::list`は通常`std::vector`よりも多くの空間を占有します。 +- キャッシュ非友好的:データが連続して格納されていないため、`std::list`はキャッシュ利用率が低くなります。一般的に、`std::vector`の方がパフォーマンスが優れています。 + +他方で、連結リストは主に二分木とグラフに必要です。スタックとキューは、連結リストではなく、プログラミング言語の`stack`と`queue`クラスを使用して実装されることが多いです。 + +**Q**:リスト`res = [0] * self.size()`を初期化すると、`res`の各要素は同じアドレスを参照しますか? + +いいえ。しかし、この問題は二次元配列で発生します。例えば、二次元リスト`res = [[0]] * self.size()`を初期化すると、同じリスト`[0]`を複数回参照することになります。 + +**Q**:ノードを削除する際、その後続ノードへの参照を断つ必要がありますか? + +データ構造とアルゴリズム(問題解決)の観点から、プログラムのロジックが正しい限り、リンクを断たなくても問題ありません。標準ライブラリの観点から、リンクを断つ方が安全で論理的に明確です。リンクを断たず、削除されたノードが適切にリサイクルされない場合、後続ノードのメモリのリサイクルに影響を与える可能性があります。 diff --git a/ja/docs/chapter_backtracking/backtracking_algorithm.md b/ja/docs/chapter_backtracking/backtracking_algorithm.md new file mode 100644 index 000000000..d055b0ddf --- /dev/null +++ b/ja/docs/chapter_backtracking/backtracking_algorithm.md @@ -0,0 +1,1198 @@ +--- +comments: true +--- + +# 13.1 バックトラッキングアルゴリズム + +バックトラッキングアルゴリズムは全数探索によって問題を解決する方法です。その核心概念は、初期状態から開始してすべての可能な解を総当たりで探索することです。アルゴリズムは正しいものを記録し、解が見つかるか、すべての可能な解が試されたが解が見つからないまで続けます。 + +バックトラッキングは通常「深さ優先探索」を使用して解空間を走査します。「二分木」の章で、前順、中順、後順走査はすべて深さ優先探索であることを述べました。次に、前順走査を使用してバックトラッキング問題を解決し、アルゴリズムの動作を段階的に理解していきます。 + +!!! question "例1" + + 二分木が与えられた場合、値が $7$ のすべてのノードを検索して記録し、リストで返してください。 + +この問題を解決するために、この木を前順で走査し、現在のノードの値が $7$ かどうかを確認します。そうであれば、ノードの値を結果リスト `res` に追加します。プロセスは以下の図に示されています: + +=== "Python" + + ```python title="preorder_traversal_i_compact.py" + def pre_order(root: TreeNode): + """前順走査:例一""" + if root is None: + return + if root.val == 7: + # 解を記録 + res.append(root) + pre_order(root.left) + pre_order(root.right) + ``` + +=== "C++" + + ```cpp title="preorder_traversal_i_compact.cpp" + /* 前順走査:例1 */ + void preOrder(TreeNode *root) { + if (root == nullptr) { + return; + } + if (root->val == 7) { + // 解を記録 + res.push_back(root); + } + preOrder(root->left); + preOrder(root->right); + } + ``` + +=== "Java" + + ```java title="preorder_traversal_i_compact.java" + /* 前順走査:例1 */ + void preOrder(TreeNode root) { + if (root == null) { + return; + } + if (root.val == 7) { + // 解を記録 + res.add(root); + } + preOrder(root.left); + preOrder(root.right); + } + ``` + +=== "C#" + + ```csharp title="preorder_traversal_i_compact.cs" + [class]{preorder_traversal_i_compact}-[func]{PreOrder} + ``` + +=== "Go" + + ```go title="preorder_traversal_i_compact.go" + [class]{}-[func]{preOrderI} + ``` + +=== "Swift" + + ```swift title="preorder_traversal_i_compact.swift" + [class]{}-[func]{preOrder} + ``` + +=== "JS" + + ```javascript title="preorder_traversal_i_compact.js" + [class]{}-[func]{preOrder} + ``` + +=== "TS" + + ```typescript title="preorder_traversal_i_compact.ts" + [class]{}-[func]{preOrder} + ``` + +=== "Dart" + + ```dart title="preorder_traversal_i_compact.dart" + [class]{}-[func]{preOrder} + ``` + +=== "Rust" + + ```rust title="preorder_traversal_i_compact.rs" + [class]{}-[func]{pre_order} + ``` + +=== "C" + + ```c title="preorder_traversal_i_compact.c" + [class]{}-[func]{preOrder} + ``` + +=== "Kotlin" + + ```kotlin title="preorder_traversal_i_compact.kt" + [class]{}-[func]{preOrder} + ``` + +=== "Ruby" + + ```ruby title="preorder_traversal_i_compact.rb" + [class]{}-[func]{pre_order} + ``` + +=== "Zig" + + ```zig title="preorder_traversal_i_compact.zig" + [class]{}-[func]{preOrder} + ``` + +{ class="animation-figure" } + +図 13-1 前順走査でのノード検索
+ +## 13.1.1 試行と後退 + +**解空間を探索する際に「試行」と「後退」戦略を使用するため、バックトラッキングアルゴリズムと呼ばれます**。探索中、満足のいく解を得るためにもはや進めない状態に遭遇するたびに、前の選択を取り消して前の状態に戻り、次の試行のために他の可能な選択を選択できるようにします。 + +例1では、各ノードの訪問が「試行」を開始します。そして葉ノードを通過するか、`return` 文で親ノードに戻ることが「後退」を示唆します。 + +**後退は単に関数の戻り値ではないことに注意してください**。例1の問題を少し拡張して、それが何を意味するかを説明します。 + +!!! question "例2" + + 二分木で、値が $7$ のすべてのノードを検索し、すべてのマッチングノードについて、**ルートノードからそのノードまでのパスを返してください**。 + +例1のコードに基づいて、訪問したノードパスを記録するために `path` というリストを使用する必要があります。値が $7$ のノードに到達すると、`path` をコピーして結果リスト `res` に追加します。走査後、`res` にはすべての解が保持されます。コードは以下の通りです: + +=== "Python" + + ```python title="preorder_traversal_ii_compact.py" + def pre_order(root: TreeNode): + """前順走査:例二""" + if root is None: + return + # 試行 + path.append(root) + if root.val == 7: + # 解を記録 + res.append(list(path)) + pre_order(root.left) + pre_order(root.right) + # 撤回 + path.pop() + ``` + +=== "C++" + + ```cpp title="preorder_traversal_ii_compact.cpp" + /* 前順走査:例2 */ + void preOrder(TreeNode *root) { + if (root == nullptr) { + return; + } + // 試行 + path.push_back(root); + if (root->val == 7) { + // 解を記録 + res.push_back(path); + } + preOrder(root->left); + preOrder(root->right); + // 回退 + path.pop_back(); + } + ``` + +=== "Java" + + ```java title="preorder_traversal_ii_compact.java" + /* 前順走査:例2 */ + void preOrder(TreeNode root) { + if (root == null) { + return; + } + // 試行 + path.add(root); + if (root.val == 7) { + // 解を記録 + res.add(new ArrayList<>(path)); + } + preOrder(root.left); + preOrder(root.right); + // 回退 + path.remove(path.size() - 1); + } + ``` + +=== "C#" + + ```csharp title="preorder_traversal_ii_compact.cs" + [class]{preorder_traversal_ii_compact}-[func]{PreOrder} + ``` + +=== "Go" + + ```go title="preorder_traversal_ii_compact.go" + [class]{}-[func]{preOrderII} + ``` + +=== "Swift" + + ```swift title="preorder_traversal_ii_compact.swift" + [class]{}-[func]{preOrder} + ``` + +=== "JS" + + ```javascript title="preorder_traversal_ii_compact.js" + [class]{}-[func]{preOrder} + ``` + +=== "TS" + + ```typescript title="preorder_traversal_ii_compact.ts" + [class]{}-[func]{preOrder} + ``` + +=== "Dart" + + ```dart title="preorder_traversal_ii_compact.dart" + [class]{}-[func]{preOrder} + ``` + +=== "Rust" + + ```rust title="preorder_traversal_ii_compact.rs" + [class]{}-[func]{pre_order} + ``` + +=== "C" + + ```c title="preorder_traversal_ii_compact.c" + [class]{}-[func]{preOrder} + ``` + +=== "Kotlin" + + ```kotlin title="preorder_traversal_ii_compact.kt" + [class]{}-[func]{preOrder} + ``` + +=== "Ruby" + + ```ruby title="preorder_traversal_ii_compact.rb" + [class]{}-[func]{pre_order} + ``` + +=== "Zig" + + ```zig title="preorder_traversal_ii_compact.zig" + [class]{}-[func]{preOrder} + ``` + +各「試行」で、現在のノードを `path` に追加することでパスを記録します。「後退」が必要なときはいつでも、`path` からノードをポップして**この失敗した試行前の状態を復元します**。 + +以下の図に示すプロセスを観察することで、**試行は「前進」のようで、後退は「元に戻す」のようです**。後者のペアは、対応するものに対する逆操作と見なすことができます。 + +=== "<1>" + { class="animation-figure" } + +=== "<2>" + { class="animation-figure" } + +=== "<3>" + { class="animation-figure" } + +=== "<4>" + { class="animation-figure" } + +=== "<5>" + { class="animation-figure" } + +=== "<6>" + { class="animation-figure" } + +=== "<7>" + { class="animation-figure" } + +=== "<8>" + { class="animation-figure" } + +=== "<9>" + { class="animation-figure" } + +=== "<10>" + { class="animation-figure" } + +=== "<11>" + { class="animation-figure" } + +図 13-2 試行と後退
+ +## 13.1.2 剪定 + +複雑なバックトラッキング問題は通常1つ以上の制約を含み、**これらは「剪定」によく使用されます**。 + +!!! question "例3" + + 二分木で、値が $7$ のすべてのノードを検索し、ルートからこれらのノードまでのパスを返してください。**ただし、パスには値が $3$ のノードを含まないという制限があります**。 + +上記の制約を満たすために、**剪定操作を追加する必要があります**:検索プロセス中に、値が $3$ のノードに遭遇した場合、そのパスを通じてさらに検索することを即座に中止します。コードは以下の通りです: + +=== "Python" + + ```python title="preorder_traversal_iii_compact.py" + def pre_order(root: TreeNode): + """前順走査:例三""" + # 枝刈り + if root is None or root.val == 3: + return + # 試行 + path.append(root) + if root.val == 7: + # 解を記録 + res.append(list(path)) + pre_order(root.left) + pre_order(root.right) + # 撤回 + path.pop() + ``` + +=== "C++" + + ```cpp title="preorder_traversal_iii_compact.cpp" + /* 前順走査:例3 */ + void preOrder(TreeNode *root) { + // 剪定 + if (root == nullptr || root->val == 3) { + return; + } + // 試行 + path.push_back(root); + if (root->val == 7) { + // 解を記録 + res.push_back(path); + } + preOrder(root->left); + preOrder(root->right); + // 回退 + path.pop_back(); + } + ``` + +=== "Java" + + ```java title="preorder_traversal_iii_compact.java" + /* 前順走査:例3 */ + void preOrder(TreeNode root) { + // 剪定 + if (root == null || root.val == 3) { + return; + } + // 試行 + path.add(root); + if (root.val == 7) { + // 解を記録 + res.add(new ArrayList<>(path)); + } + preOrder(root.left); + preOrder(root.right); + // 回退 + path.remove(path.size() - 1); + } + ``` + +=== "C#" + + ```csharp title="preorder_traversal_iii_compact.cs" + [class]{preorder_traversal_iii_compact}-[func]{PreOrder} + ``` + +=== "Go" + + ```go title="preorder_traversal_iii_compact.go" + [class]{}-[func]{preOrderIII} + ``` + +=== "Swift" + + ```swift title="preorder_traversal_iii_compact.swift" + [class]{}-[func]{preOrder} + ``` + +=== "JS" + + ```javascript title="preorder_traversal_iii_compact.js" + [class]{}-[func]{preOrder} + ``` + +=== "TS" + + ```typescript title="preorder_traversal_iii_compact.ts" + [class]{}-[func]{preOrder} + ``` + +=== "Dart" + + ```dart title="preorder_traversal_iii_compact.dart" + [class]{}-[func]{preOrder} + ``` + +=== "Rust" + + ```rust title="preorder_traversal_iii_compact.rs" + [class]{}-[func]{pre_order} + ``` + +=== "C" + + ```c title="preorder_traversal_iii_compact.c" + [class]{}-[func]{preOrder} + ``` + +=== "Kotlin" + + ```kotlin title="preorder_traversal_iii_compact.kt" + [class]{}-[func]{preOrder} + ``` + +=== "Ruby" + + ```ruby title="preorder_traversal_iii_compact.rb" + [class]{}-[func]{pre_order} + ``` + +=== "Zig" + + ```zig title="preorder_traversal_iii_compact.zig" + [class]{}-[func]{preOrder} + ``` + +「剪定」は非常に生き生きとした名詞です。以下の図に示すように、検索プロセスで、**制約を満たさない検索分岐を「切り取り」ます**。さらなる不要な試行を避け、検索効率を向上させます。 + +{ class="animation-figure" } + +図 13-3 制約に基づく剪定
+ +## 13.1.3 フレームワークコード + +今度は、バックトラッキングから「試行、後退、剪定」の主要なフレームワークを抽出して、コードの汎用性を向上させてみましょう。 + +以下のフレームワークコードでは、`state` は問題の現在の状態を表し、`choices` は現在の状態で利用可能な選択肢を表します: + +=== "Python" + + ```python title="" + def backtrack(state: State, choices: list[choice], res: list[state]): + """バックトラッキングアルゴリズムフレームワーク""" + # 解かどうかを確認 + if is_solution(state): + # 解を記録 + record_solution(state, res) + # 検索を停止 + return + # すべての選択肢を反復 + for choice in choices: + # 剪定:選択肢が有効かどうかを確認 + if is_valid(state, choice): + # 試行:選択を行い、状態を更新 + make_choice(state, choice) + backtrack(state, choices, res) + # 後退:選択を取り消し、前の状態に戻す + undo_choice(state, choice) + ``` + +=== "C++" + + ```cpp title="" + /* バックトラッキングアルゴリズムフレームワーク */ + void backtrack(State *state, vector図 13-4 returnを保持する場合と削除する場合の探索過程の比較
+ +前順走査に基づくコード実装と比べると、バックトラッキングアルゴリズムのフレームワークに基づく実装はやや冗長に見えますが、汎用性はより高いです。実際、**多くのバックトラッキング問題はこのフレームワークの下で解くことができます**。具体的な問題に応じて `state` と `choices` を定義し、フレームワーク内の各メソッドを実装すればよいのです。 + +## 13.1.4 よく使われる用語 + +アルゴリズム問題をより明確に分析するために、バックトラッキングアルゴリズムでよく使われる用語の意味をまとめ、例題 3 の対応例を以下の表に示します。 + +表 13-1 バックトラッキングアルゴリズムでよく使われる用語
+ +図 13-15 4クイーン問題の解
+ +以下の図は、この問題の3つの制約を示しています:**複数のクイーンは同じ行、列、または対角線を占有できません**。対角線は主対角線 `\` と副対角線 `/` に分かれることに注意することが重要です。 + +{ class="animation-figure" } + +図 13-16 Nクイーン問題の制約
+ +### 1. 行ごとの配置戦略 + +クイーンの数がチェスボードの行数と等しく、どちらも $n$ であるため、**チェスボードの各行には1つのクイーンのみが配置できることが**容易に結論付けられます。 + +これは、行ごとの配置戦略を採用できることを意味します:最初の行から開始して、最後の行に到達するまで行ごとに1つのクイーンを配置します。 + +以下の図は、4クイーン問題の行ごとの配置プロセスを示しています。スペースの制限により、図は最初の行の1つの検索分岐のみを展開し、列と対角線の制約を満たさない配置を剪定します。 + +{ class="animation-figure" } + +図 13-17 行ごとの配置戦略
+ +本質的に、**行ごとの配置戦略は剪定関数として機能し**、同じ行に複数のクイーンを配置するすべての検索分岐を除去します。 + +### 2. 列と対角線の剪定 + +列の制約を満たすために、長さ $n$ のブール配列 `cols` を使用して、各列にクイーンが占有されているかどうかを追跡できます。各配置決定の前に、`cols` を使用してすでにクイーンがある列を剪定し、バックトラッキング中に動的に更新されます。 + +!!! tip + + 行列の原点は左上隅にあり、行インデックスは上から下に増加し、列インデックスは左から右に増加することに注意してください。 + +対角線の制約はどうでしょうか?チェスボード上の特定のセルの行と列のインデックスを $(row, col)$ とします。特定の主対角線を選択することで、その対角線上のすべてのセルで差 $row - col$ が同じであることに気付きます。**つまり、$row - col$ は主対角線上で定数値です**。 + +言い換えると、2つのセルが $row_1 - col_1 = row_2 - col_2$ を満たす場合、それらは確実に同じ主対角線上にあります。このパターンを使用して、以下の図に示す配列 `diags1` を利用して、クイーンが主対角線上にあるかどうかを追跡できます。 + +同様に、**$row + col$ の和は副対角線上のすべてのセルで定数値です**。配列 `diags2` を使用して副対角線の制約も処理できます。 + +{ class="animation-figure" } + +図 13-18 列と対角線の制約の処理
+ +### 3. コード実装 + +$n$ 次元の正方行列では、$row - col$ の範囲は $[-n + 1, n - 1]$ で、$row + col$ の範囲は $[0, 2n - 2]$ であることに注意してください。したがって、主対角線と副対角線の数はどちらも $2n - 1$ で、配列 `diags1` と `diags2` の長さは $2n - 1$ です。 + +=== "Python" + + ```python title="n_queens.py" + def backtrack( + row: int, + n: int, + state: list[list[str]], + res: list[list[list[str]]], + cols: list[bool], + diags1: list[bool], + diags2: list[bool], + ): + """バックトラッキングアルゴリズム:n クイーン""" + # すべての行が配置されたら、解を記録 + if row == n: + res.append([list(row) for row in state]) + return + # すべての列を走査 + for col in range(n): + # セルに対応する主対角線と副対角線を計算 + diag1 = row - col + n - 1 + diag2 = row + col + # 枝刈り:セルの列、主対角線、副対角線にクイーンを配置しない + if not cols[col] and not diags1[diag1] and not diags2[diag2]: + # 試行:セルにクイーンを配置 + state[row][col] = "Q" + cols[col] = diags1[diag1] = diags2[diag2] = True + # 次の行を配置 + backtrack(row + 1, n, state, res, cols, diags1, diags2) + # 撤回:セルを空のスポットに復元 + state[row][col] = "#" + cols[col] = diags1[diag1] = diags2[diag2] = False + + def n_queens(n: int) -> list[list[list[str]]]: + """n クイーンを解く""" + # n*n サイズのチェスボードを初期化、'Q' はクイーンを表し、'#' は空のスポットを表す + state = [["#" for _ in range(n)] for _ in range(n)] + cols = [False] * n # クイーンがある列を記録 + diags1 = [False] * (2 * n - 1) # クイーンがある主対角線を記録 + diags2 = [False] * (2 * n - 1) # クイーンがある副対角線を記録 + res = [] + backtrack(0, n, state, res, cols, diags1, diags2) + + return res + ``` + +=== "C++" + + ```cpp title="n_queens.cpp" + /* バックトラッキングアルゴリズム:n クイーン */ + void backtrack(int row, int n, vector表 13-2 順列の例
+ +図 13-5 順列の再帰木
+ +### 1. 重複選択の剪定 + +各要素が一度だけ選択されることを保証するために、ブール配列 `selected` を導入します。ここで `selected[i]` は `choices[i]` が選択されたかどうかを示します。次に、この配列に基づいて剪定ステップを実行します: + +- `choice[i]` を選択した後、`selected[i]` を $\text{True}$ に設定して選択されたとマークします。 +- `choices` を反復処理する際、選択されたとマークされたすべての要素をスキップします(つまり、それらの分岐を剪定します)。 + +以下の図に示すように、最初のラウンドで1を選択し、2番目のラウンドで3を選択し、最後のラウンドで2を選択するとします。2番目のラウンドで要素1の分岐と、3番目のラウンドで要素1と3の分岐を剪定する必要があります。 + +{ class="animation-figure" } + +図 13-6 順列の剪定例
+ +図から、この剪定プロセスが検索空間を $O(n^n)$ から $O(n!)$ に削減することがわかります。 + +### 2. コード実装 + +この理解により、フレームワークコードの「空欄を埋める」ことができます。全体のコードを簡潔に保つため、フレームワークの各部分を個別に実装せず、代わりに `backtrack()` 関数ですべてを展開します: + +=== "Python" + + ```python title="permutations_i.py" + def backtrack( + state: list[int], choices: list[int], selected: list[bool], res: list[list[int]] + ): + """バックトラッキングアルゴリズム:順列 I""" + # 状態の長さが要素数と等しいとき、解を記録 + if len(state) == len(choices): + res.append(list(state)) + return + # すべての選択肢を走査 + for i, choice in enumerate(choices): + # 枝刈り:要素の重複選択を許可しない + if not selected[i]: + # 試行:選択を行い、状態を更新 + selected[i] = True + state.append(choice) + # 次の選択ラウンドに進む + backtrack(state, choices, selected, res) + # 撤回:選択を取り消し、前の状態に復元 + selected[i] = False + state.pop() + + def permutations_i(nums: list[int]) -> list[list[int]]: + """順列 I""" + res = [] + backtrack(state=[], choices=nums, selected=[False] * len(nums), res=res) + return res + ``` + +=== "C++" + + ```cpp title="permutations_i.cpp" + /* バックトラッキングアルゴリズム:順列 I */ + void backtrack(vector図 13-7 重複順列
+ +では、これらの重複順列をどのように除去できるでしょうか?一つの直接的なアプローチは、すべての順列を生成した後にハッシュセットを使用して重複を除去することです。しかし、これはあまり優雅ではありません。**重複を生成する分岐は本来不要であり、事前に剪定されるべきだからです**、これによりアルゴリズムの効率が向上します。 + +### 1. 等値要素の剪定 + +以下の図を見ると、最初のラウンドで $1$ または $\hat{1}$ を選択すると同じ順列につながるため、$\hat{1}$ を剪定します。 + +同様に、最初のラウンドで $2$ を選択した後、2番目のラウンドで $1$ または $\hat{1}$ を選択しても重複分岐につながるため、その時も $\hat{1}$ を剪定します。 + +本質的に、**私たちの目標は、複数の同一要素が選択の各ラウンドで一度だけ選択されることを保証することです。** + +{ class="animation-figure" } + +図 13-8 重複順列の剪定
+ +### 2. コード実装 + +前の問題のコードに基づいて、各ラウンドでハッシュセット `duplicated` を導入します。このセットは、すでに試行した要素を追跡し、重複を剪定できるようにします: + +=== "Python" + + ```python title="permutations_ii.py" + def backtrack( + state: list[int], choices: list[int], selected: list[bool], res: list[list[int]] + ): + """バックトラッキングアルゴリズム:順列 II""" + # 状態の長さが要素数と等しいとき、解を記録 + if len(state) == len(choices): + res.append(list(state)) + return + # すべての選択肢を走査 + duplicated = set[int]() + for i, choice in enumerate(choices): + # 枝刈り:要素の重複選択を許可せず、等しい要素の重複選択も許可しない + if not selected[i] and choice not in duplicated: + # 試行:選択を行い、状態を更新 + duplicated.add(choice) # 選択された要素値を記録 + selected[i] = True + state.append(choice) + # 次の選択ラウンドに進む + backtrack(state, choices, selected, res) + # 撤回:選択を取り消し、前の状態に復元 + selected[i] = False + state.pop() + + def permutations_ii(nums: list[int]) -> list[list[int]]: + """順列 II""" + res = [] + backtrack(state=[], choices=nums, selected=[False] * len(nums), res=res) + return res + ``` + +=== "C++" + + ```cpp title="permutations_ii.cpp" + /* バックトラッキングアルゴリズム:順列 II */ + void backtrack(vector図 13-9 2つの剪定条件の範囲
diff --git a/ja/docs/chapter_backtracking/subset_sum_problem.md b/ja/docs/chapter_backtracking/subset_sum_problem.md new file mode 100644 index 000000000..de3bc430b --- /dev/null +++ b/ja/docs/chapter_backtracking/subset_sum_problem.md @@ -0,0 +1,703 @@ +--- +comments: true +--- + +# 13.3 部分集合和問題 + +## 13.3.1 重複要素がない場合 + +!!! question + + 正の整数の配列 `nums` とターゲット正整数 `target` が与えられた場合、組み合わせ内の要素の和が `target` に等しくなるようなすべての可能な組み合わせを見つけてください。与えられた配列には重複要素がなく、各要素は複数回選択できます。これらの組み合わせを重複する組み合わせを含まないリストとして返してください。 + +例えば、入力集合 $\{3, 4, 5\}$ とターゲット整数 $9$ の場合、解は $\{3, 3, 3\}, \{4, 5\}$ です。以下の2点に注意してください。 + +- 入力集合の要素は無制限に選択できます。 +- 部分集合は要素の順序を区別しません。例えば $\{4, 5\}$ と $\{5, 4\}$ は同じ部分集合です。 + +### 1. 順列解法の参考 + +順列問題と同様に、部分集合の生成を一連の選択として想像でき、選択プロセス中に「要素和」をリアルタイムで更新できます。要素和が `target` に等しくなったとき、部分集合を結果リストに記録します。 + +順列問題とは異なり、**この問題では要素は無制限に選択できるため**、要素が選択されたかどうかを記録するための `selected` ブール配列を使用する必要がありません。順列コードに軽微な修正を加えて、最初に問題を解決できます: + +=== "Python" + + ```python title="subset_sum_i_naive.py" + def backtrack( + state: list[int], + target: int, + total: int, + choices: list[int], + res: list[list[int]], + ): + """バックトラッキングアルゴリズム:部分集合の和 I""" + # 部分集合の和が target と等しいとき、解を記録 + if total == target: + res.append(list(state)) + return + # すべての選択肢を走査 + for i in range(len(choices)): + # 枝刈り:部分集合の和が target を超える場合、その選択をスキップ + if total + choices[i] > target: + continue + # 試行:選択を行い、要素と total を更新 + state.append(choices[i]) + # 次の選択ラウンドに進む + backtrack(state, target, total + choices[i], choices, res) + # 撤回:選択を取り消し、前の状態に復元 + state.pop() + + def subset_sum_i_naive(nums: list[int], target: int) -> list[list[int]]: + """部分集合の和 I を解く(重複する部分集合を含む)""" + state = [] # 状態(部分集合) + total = 0 # 部分集合の和 + res = [] # 結果リスト(部分集合リスト) + backtrack(state, target, total, nums, res) + return res + ``` + +=== "C++" + + ```cpp title="subset_sum_i_naive.cpp" + /* バックトラッキングアルゴリズム:部分集合和 I */ + void backtrack(vector図 13-10 部分集合の検索と境界外の剪定
+ +重複する部分集合を除去するために、**直接的なアイデアは結果リストを重複除去することです**。しかし、この方法は2つの理由で非常に非効率的です。 + +- 配列要素が多い場合、特に `target` が大きい場合、検索プロセスで大量の重複する部分集合が生成されます。 +- 部分集合(配列)の差異を比較することは非常に時間がかかり、まず配列をソートし、次に配列の各要素の差異を比較する必要があります。 + +### 2. 重複部分集合の剪定 + +**剪定を通じて検索プロセス中に重複除去を検討します**。以下の図を観察すると、異なる順序で配列要素を選択するときに重複する部分集合が生成されます。例えば、以下の状況です。 + +1. 最初のラウンドで $3$ を選択し、2番目のラウンドで $4$ を選択すると、これら2つの要素を含むすべての部分集合が生成され、$[3, 4, \dots]$ と表記されます。 +2. 後で、最初のラウンドで $4$ が選択されたとき、**2番目のラウンドは $3$ をスキップすべきです**。この選択によって生成される部分集合 $[4, 3, \dots]$ はステップ `1.` の部分集合と完全に重複するからです。 + +検索プロセスでは、各層の選択が左から右に一つずつ試行されるため、右側の分岐ほどより多く剪定されます。 + +1. 最初の2ラウンドで $3$ と $5$ を選択し、部分集合 $[3, 5, \dots]$ を生成します。 +2. 最初の2ラウンドで $4$ と $5$ を選択し、部分集合 $[4, 5, \dots]$ を生成します。 +3. 最初のラウンドで $5$ が選択された場合、**2番目のラウンドは $3$ と $4$ をスキップすべきです**。部分集合 $[5, 3, \dots]$ と $[5, 4, \dots]$ はステップ `1.` と `2.` で記述された部分集合と完全に重複するからです。 + +{ class="animation-figure" } + +図 13-11 異なる選択順序による重複部分集合
+ +要約すると、入力配列 $[x_1, x_2, \dots, x_n]$ が与えられた場合、検索プロセスでの選択シーケンスは $[x_{i_1}, x_{i_2}, \dots, x_{i_m}]$ であるべきで、$i_1 \leq i_2 \leq \dots \leq i_m$ を満たす必要があります。**この条件を満たさない選択シーケンスは重複を引き起こし、剪定されるべきです**。 + +### 3. コード実装 + +この剪定を実装するために、変数 `start` を初期化し、これは走査の開始点を示します。**選択 $x_{i}$ を行った後、次のラウンドをインデックス $i$ から開始するように設定します**。これにより、選択シーケンスが $i_1 \leq i_2 \leq \dots \leq i_m$ を満たすことが保証され、部分集合の一意性が保証されます。 + +さらに、コードに以下の2つの最適化を行いました。 + +- 検索を開始する前に、配列 `nums` をソートします。すべての選択の走査で、**部分集合和が `target` を超えたときにループを直接終了します**。後続の要素はより大きく、それらの部分集合和は確実に `target` を超えるからです。 +- 要素和変数 `total` を除去し、**`target` に対して減算を実行して要素和をカウントします**。`target` が $0$ に等しくなったとき、解を記録します。 + +=== "Python" + + ```python title="subset_sum_i.py" + def backtrack( + state: list[int], target: int, choices: list[int], start: int, res: list[list[int]] + ): + """バックトラッキングアルゴリズム:部分集合の和 I""" + # 部分集合の和が target と等しいとき、解を記録 + if target == 0: + res.append(list(state)) + return + # すべての選択肢を走査 + # 枝刈り二:start から走査を開始して重複する部分集合の生成を避ける + for i in range(start, len(choices)): + # 枝刈り一:部分集合の和が target を超える場合、直ちにループを終了 + # これは配列がソートされており、後の要素がより大きいため、部分集合の和は必ず target を超えるため + if target - choices[i] < 0: + break + # 試行:選択を行い、target、start を更新 + state.append(choices[i]) + # 次の選択ラウンドに進む + backtrack(state, target - choices[i], choices, i, res) + # 撤回:選択を取り消し、前の状態に復元 + state.pop() + + def subset_sum_i(nums: list[int], target: int) -> list[list[int]]: + """部分集合の和 I を解く""" + state = [] # 状態(部分集合) + nums.sort() # nums をソート + start = 0 # 走査の開始点 + res = [] # 結果リスト(部分集合リスト) + backtrack(state, target, nums, start, res) + return res + ``` + +=== "C++" + + ```cpp title="subset_sum_i.cpp" + /* バックトラッキングアルゴリズム:部分集合和 I */ + void backtrack(vector図 13-12 部分集合和 I のバックトラッキングプロセス
+ +## 13.3.2 重複要素がある場合を考慮 + +!!! question + + 正の整数の配列 `nums` とターゲット正整数 `target` が与えられた場合、組み合わせ内の要素の和が `target` に等しくなるようなすべての可能な組み合わせを見つけてください。**与えられた配列には重複要素が含まれる可能性があり、各要素は一度だけ選択できます**。これらの組み合わせを重複する組み合わせを含まないリストとして返してください。 + +前の問題と比較して、**この問題の入力配列には重複要素が含まれる可能性があり**、新しい問題が導入されます。例えば、配列 $[4, \hat{4}, 5]$ とターゲット要素 $9$ が与えられた場合、既存のコードの出力結果は $[4, 5], [\hat{4}, 5]$ となり、重複する部分集合が生成されます。 + +**この重複の理由は、特定のラウンドで等しい要素が複数回選択されることです**。以下の図では、最初のラウンドに3つの選択肢があり、そのうち2つが $4$ であり、2つの重複する検索分岐を生成し、重複する部分集合を出力します。同様に、2番目のラウンドの2つの $4$ も重複する部分集合を生成します。 + +{ class="animation-figure" } + +図 13-13 等しい要素による重複部分集合
+ +### 1. 等値要素の剪定 + +この問題を解決するために、**等しい要素がラウンドごとに一度だけ選択されるように制限する必要があります**。実装は非常に巧妙です:配列がソートされているため、等しい要素は隣接しています。これは、特定のラウンドの選択で、現在の要素がその左側の要素と等しい場合、それはすでに選択されていることを意味するため、現在の要素を直接スキップします。 + +同時に、**この問題では各配列要素は一度だけ選択できると規定されています**。幸い、変数 `start` を使用してこの制約も満たすことができます:選択 $x_{i}$ を行った後、次のラウンドをインデックス $i + 1$ から前方に開始するように設定します。これにより、重複する部分集合が除去されるだけでなく、要素の重複選択も回避されます。 + +### 2. コード実装 + +=== "Python" + + ```python title="subset_sum_ii.py" + def backtrack( + state: list[int], target: int, choices: list[int], start: int, res: list[list[int]] + ): + """バックトラッキングアルゴリズム:部分集合の和 II""" + # 部分集合の和が target と等しいとき、解を記録 + if target == 0: + res.append(list(state)) + return + # すべての選択肢を走査 + # 枝刈り二:start から走査を開始して重複する部分集合の生成を避ける + # 枝刈り三:start から走査を開始して同じ要素の重複選択を避ける + for i in range(start, len(choices)): + # 枝刈り一:部分集合の和が target を超える場合、直ちにループを終了 + # これは配列がソートされており、後の要素がより大きいため、部分集合の和は必ず target を超えるため + if target - choices[i] < 0: + break + # 枝刈り四:要素が左の要素と等しい場合、検索分岐が重複していることを示すため、スキップ + if i > start and choices[i] == choices[i - 1]: + continue + # 試行:選択を行い、target、start を更新 + state.append(choices[i]) + # 次の選択ラウンドに進む + backtrack(state, target - choices[i], choices, i + 1, res) + # 撤回:選択を取り消し、前の状態に復元 + state.pop() + + def subset_sum_ii(nums: list[int], target: int) -> list[list[int]]: + """部分集合の和 II を解く""" + state = [] # 状態(部分集合) + nums.sort() # nums をソート + start = 0 # 走査の開始点 + res = [] # 結果リスト(部分集合リスト) + backtrack(state, target, nums, start, res) + return res + ``` + +=== "C++" + + ```cpp title="subset_sum_ii.cpp" + /* バックトラッキングアルゴリズム:部分集合和 II */ + void backtrack(vector図 13-14 部分集合和 II のバックトラッキングプロセス
diff --git a/ja/docs/chapter_backtracking/summary.md b/ja/docs/chapter_backtracking/summary.md new file mode 100644 index 000000000..12f76b317 --- /dev/null +++ b/ja/docs/chapter_backtracking/summary.md @@ -0,0 +1,27 @@ +--- +comments: true +--- + +# 13.5 まとめ + +### 1. 重要な復習 + +- バックトラッキングアルゴリズムの本質は全数探索です。解空間の深さ優先走査を実行することで条件を満たす解を求めます。検索中に満足のいく解が見つかった場合、それを記録し、すべての解が見つかるか走査が完了するまで続けます。 +- バックトラッキングアルゴリズムの検索プロセスには試行と後退が含まれます。深さ優先探索を使用して様々な選択を探索し、選択が制約を満たさない場合、前の選択を取り消します。そして前の状態に戻って他のオプションを試し続けます。試行と後退は反対方向の操作です。 +- バックトラッキング問題には通常複数の制約が含まれます。これらの制約は剪定操作を実行するために使用できます。剪定は不要な検索分岐を事前に終了し、検索効率を大幅に向上させることができます。 +- バックトラッキングアルゴリズムは主に検索問題と制約満足問題を解決するために使用されます。組み合わせ最適化問題はバックトラッキングを使用して解決できますが、多くの場合、より効率的または効果的な解決方法が利用可能です。 +- 順列問題は、与えられた集合の要素のすべての可能な順列を検索することを目的とします。各要素が選択されたかどうかを記録するために配列を使用し、同じ要素の重複選択を避けます。これにより、各要素が一度だけ選択されることが保証されます。 +- 順列問題では、集合に重複要素が含まれている場合、最終結果に重複順列が含まれます。同一要素が各ラウンドで一度だけ選択できるように制限する必要があり、これは通常ハッシュセットを使用して実装されます。 +- 部分集合和問題は、与えられた集合でターゲット値に合計する全ての部分集合を見つけることを目的とします。集合は要素の順序を区別しませんが、検索プロセスでは重複する部分集合が生成される可能性があります。これは、アルゴリズムが異なる要素順序を独特のパスとして探索するために発生します。バックトラッキングの前に、データをソートし、各ラウンドの走査の開始点を示す変数を設定します。これにより、重複する部分集合を生成する検索分岐を剪定できます。 +- 部分集合和問題では、配列内の等しい要素は重複集合を生成する可能性があります。配列がすでにソートされているという前提条件を使用して、隣接する要素が等しいかどうかを判定することで剪定を行います。これにより、等しい要素がラウンドごとに一度だけ選択されることが保証されます。 +- $n$ クイーン問題は、2つのクイーンが互いに攻撃できないように $n \times n$ のチェスボードに $n$ 個のクイーンを配置する方案を見つけることを目的とします。問題の制約には行制約、列制約、および主対角線と副対角線の制約が含まれます。行制約を満たすために、行ごとに1つのクイーンを配置する戦略を採用し、各行に1つのクイーンが配置されることを保証します。 +- 列制約と対角線制約の処理は似ています。列制約については、各列にクイーンがあるかどうかを記録する配列を使用し、選択されたセルが合法かどうかを示します。対角線制約については、2つの配列を使用して主対角線と副対角線にそれぞれクイーンの存在を記録します。課題は、同じ主対角線または副対角線上のセルの行と列のインデックス間の関係を決定することです。 + +### 2. Q & A + +**Q**: バックトラッキングと再帰の関係をどのように理解すればよいですか? + +全体的に、バックトラッキングは「アルゴリズム戦略」であり、再帰はより「ツール」です。 + +- バックトラッキングアルゴリズムは通常再帰に基づいています。しかし、バックトラッキングは再帰の応用シナリオの一つであり、特に検索問題においてです。 +- 再帰の構造は「部分問題分解」の問題解決パラダイムを反映します。分割統治、バックトラッキング、動的プログラミング(メモ化再帰)を含む問題の解決でよく使用されます。 diff --git a/ja/docs/chapter_computational_complexity/index.md b/ja/docs/chapter_computational_complexity/index.md new file mode 100644 index 000000000..838e1ac4b --- /dev/null +++ b/ja/docs/chapter_computational_complexity/index.md @@ -0,0 +1,22 @@ +--- +comments: true +icon: material/timer-sand +--- + +# 第 2 章 複雑度解析 + +{ class="cover-image" } + +!!! abstract + + 複雑度解析は、アルゴリズムの広大な宇宙における時空のナビゲーターのようなものです。 + + 時間と空間の次元をより深く探求し、より優雅な解決策を求めるためのガイドとなります。 + +## 章の内容 + +- [2.1 アルゴリズム効率評価](performance_evaluation.md) +- [2.2 反復と再帰](iteration_and_recursion.md) +- [2.3 時間計算量](time_complexity.md) +- [2.4 空間計算量](space_complexity.md) +- [2.5 まとめ](summary.md) diff --git a/ja/docs/chapter_computational_complexity/iteration_and_recursion.md b/ja/docs/chapter_computational_complexity/iteration_and_recursion.md new file mode 100644 index 000000000..b617d63df --- /dev/null +++ b/ja/docs/chapter_computational_complexity/iteration_and_recursion.md @@ -0,0 +1,1089 @@ +--- +comments: true +--- + +# 2.2 反復と再帰 + +アルゴリズムにおいて、タスクの繰り返し実行は非常に一般的であり、複雑度の分析と密接に関係しています。したがって、時間計算量と空間計算量の概念を詳しく学ぶ前に、まずプログラミングで繰り返しタスクを実装する方法を探究しましょう。これには、2つの基本的なプログラミング制御構造である反復と再帰の理解が含まれます。 + +## 2.2.1 反復 + +反復は、タスクを繰り返し実行するための制御構造です。反復では、プログラムは特定の条件が満たされている限りコードブロックを繰り返し実行し、この条件が満たされなくなるまで続けます。 + +### 1. forループ + +`for`ループは反復の最も一般的な形式の1つであり、**反復回数が事前に分かっている場合に特に適しています**。 + +以下の関数は`for`ループを使用して$1 + 2 + \dots + n$の合計を実行し、合計を変数`res`に格納します。Pythonでは、`range(a, b)`は`a`を含み`b`を除く区間を作成することに注意してください。つまり、$a$から$b−1$までの範囲で反復します。 + +=== "Python" + + ```python title="iteration.py" + def for_loop(n: int) -> int: + """forループ""" + res = 0 + # 1, 2, ..., n-1, n の合計をループ + for i in range(1, n + 1): + res += i + return res + ``` + +=== "C++" + + ```cpp title="iteration.cpp" + /* for ループ */ + int forLoop(int n) { + int res = 0; + // 1, 2, ..., n-1, n の合計をループ計算 + for (int i = 1; i <= n; ++i) { + res += i; + } + return res; + } + ``` + +=== "Java" + + ```java title="iteration.java" + /* for ループ */ + int forLoop(int n) { + int res = 0; + // 1, 2, ..., n-1, n の合計をループ計算 + for (int i = 1; i <= n; i++) { + res += i; + } + return res; + } + ``` + +=== "C#" + + ```csharp title="iteration.cs" + [class]{iteration}-[func]{ForLoop} + ``` + +=== "Go" + + ```go title="iteration.go" + [class]{}-[func]{forLoop} + ``` + +=== "Swift" + + ```swift title="iteration.swift" + [class]{}-[func]{forLoop} + ``` + +=== "JS" + + ```javascript title="iteration.js" + [class]{}-[func]{forLoop} + ``` + +=== "TS" + + ```typescript title="iteration.ts" + [class]{}-[func]{forLoop} + ``` + +=== "Dart" + + ```dart title="iteration.dart" + [class]{}-[func]{forLoop} + ``` + +=== "Rust" + + ```rust title="iteration.rs" + [class]{}-[func]{for_loop} + ``` + +=== "C" + + ```c title="iteration.c" + [class]{}-[func]{forLoop} + ``` + +=== "Kotlin" + + ```kotlin title="iteration.kt" + [class]{}-[func]{forLoop} + ``` + +=== "Ruby" + + ```ruby title="iteration.rb" + [class]{}-[func]{for_loop} + ``` + +=== "Zig" + + ```zig title="iteration.zig" + [class]{}-[func]{forLoop} + ``` + +以下の図はこの合計関数を表しています。 + +{ class="animation-figure" } + +図 2-1 Flowchart of the sum function
+ +この合計関数での操作数は入力データのサイズ$n$に比例する、つまり線形関係があります。**この「線形関係」こそが時間計算量が記述するものです**。このトピックについては次のセクションで詳しく説明します。 + +### 2. whileループ + +`for`ループと同様に、`while`ループは反復を実装するためのもう1つのアプローチです。`while`ループでは、プログラムは各反復の開始時に条件をチェックし、条件が真の場合は実行を継続し、そうでなければループを終了します。 + +以下では`while`ループを使用して合計$1 + 2 + \dots + n$を実装します。 + +=== "Python" + + ```python title="iteration.py" + def while_loop(n: int) -> int: + """whileループ""" + res = 0 + i = 1 # 条件変数を初期化 + # 1, 2, ..., n-1, n の合計をループ + while i <= n: + res += i + i += 1 # 条件変数を更新 + return res + ``` + +=== "C++" + + ```cpp title="iteration.cpp" + /* while ループ */ + int whileLoop(int n) { + int res = 0; + int i = 1; // 条件変数を初期化 + // 1, 2, ..., n-1, n の合計をループ計算 + while (i <= n) { + res += i; + i++; // 条件変数を更新 + } + return res; + } + ``` + +=== "Java" + + ```java title="iteration.java" + /* while ループ */ + int whileLoop(int n) { + int res = 0; + int i = 1; // 条件変数を初期化 + // 1, 2, ..., n-1, n の合計をループ計算 + while (i <= n) { + res += i; + i++; // 条件変数を更新 + } + return res; + } + ``` + +=== "C#" + + ```csharp title="iteration.cs" + [class]{iteration}-[func]{WhileLoop} + ``` + +=== "Go" + + ```go title="iteration.go" + [class]{}-[func]{whileLoop} + ``` + +=== "Swift" + + ```swift title="iteration.swift" + [class]{}-[func]{whileLoop} + ``` + +=== "JS" + + ```javascript title="iteration.js" + [class]{}-[func]{whileLoop} + ``` + +=== "TS" + + ```typescript title="iteration.ts" + [class]{}-[func]{whileLoop} + ``` + +=== "Dart" + + ```dart title="iteration.dart" + [class]{}-[func]{whileLoop} + ``` + +=== "Rust" + + ```rust title="iteration.rs" + [class]{}-[func]{while_loop} + ``` + +=== "C" + + ```c title="iteration.c" + [class]{}-[func]{whileLoop} + ``` + +=== "Kotlin" + + ```kotlin title="iteration.kt" + [class]{}-[func]{whileLoop} + ``` + +=== "Ruby" + + ```ruby title="iteration.rb" + [class]{}-[func]{while_loop} + ``` + +=== "Zig" + + ```zig title="iteration.zig" + [class]{}-[func]{whileLoop} + ``` + +**`while`ループは`for`ループよりも柔軟性を提供します**。特に、条件変数のカスタム初期化と各ステップでの変更が可能です。 + +例えば、以下のコードでは、条件変数$i$が各ラウンドで2回更新されますが、これは`for`ループでは実装が不便です。 + +=== "Python" + + ```python title="iteration.py" + def while_loop_ii(n: int) -> int: + """whileループ(2つの更新)""" + res = 0 + i = 1 # 条件変数を初期化 + # 1, 4, 10, ... の合計をループ + while i <= n: + res += i + # 条件変数を更新 + i += 1 + i *= 2 + return res + ``` + +=== "C++" + + ```cpp title="iteration.cpp" + /* while ループ(2つの更新) */ + int whileLoopII(int n) { + int res = 0; + int i = 1; // 条件変数を初期化 + // 1, 4, 10, ... の合計をループ計算 + while (i <= n) { + res += i; + // 条件変数を更新 + i++; + i *= 2; + } + return res; + } + ``` + +=== "Java" + + ```java title="iteration.java" + /* while ループ(2つの更新) */ + int whileLoopII(int n) { + int res = 0; + int i = 1; // 条件変数を初期化 + // 1, 4, 10, ... の合計をループ計算 + while (i <= n) { + res += i; + // 条件変数を更新 + i++; + i *= 2; + } + return res; + } + ``` + +=== "C#" + + ```csharp title="iteration.cs" + [class]{iteration}-[func]{WhileLoopII} + ``` + +=== "Go" + + ```go title="iteration.go" + [class]{}-[func]{whileLoopII} + ``` + +=== "Swift" + + ```swift title="iteration.swift" + [class]{}-[func]{whileLoopII} + ``` + +=== "JS" + + ```javascript title="iteration.js" + [class]{}-[func]{whileLoopII} + ``` + +=== "TS" + + ```typescript title="iteration.ts" + [class]{}-[func]{whileLoopII} + ``` + +=== "Dart" + + ```dart title="iteration.dart" + [class]{}-[func]{whileLoopII} + ``` + +=== "Rust" + + ```rust title="iteration.rs" + [class]{}-[func]{while_loop_ii} + ``` + +=== "C" + + ```c title="iteration.c" + [class]{}-[func]{whileLoopII} + ``` + +=== "Kotlin" + + ```kotlin title="iteration.kt" + [class]{}-[func]{whileLoopII} + ``` + +=== "Ruby" + + ```ruby title="iteration.rb" + [class]{}-[func]{while_loop_ii} + ``` + +=== "Zig" + + ```zig title="iteration.zig" + [class]{}-[func]{whileLoopII} + ``` + +全体的に、**`for`ループはより簡潔で、`while`ループはより柔軟です**。どちらも反復構造を実装できます。どちらを使用するかは、問題の具体的な要件に基づいて決定する必要があります。 + +### 3. ネストしたループ + +1つのループ構造を別のループ構造内にネストできます。以下は`for`ループを使用した例です: + +=== "Python" + + ```python title="iteration.py" + def nested_for_loop(n: int) -> str: + """二重forループ""" + res = "" + # i = 1, 2, ..., n-1, n をループ + for i in range(1, n + 1): + # j = 1, 2, ..., n-1, n をループ + for j in range(1, n + 1): + res += f"({i}, {j}), " + return res + ``` + +=== "C++" + + ```cpp title="iteration.cpp" + /* 2重 for ループ */ + string nestedForLoop(int n) { + ostringstream res; + // ループ i = 1, 2, ..., n-1, n + for (int i = 1; i <= n; ++i) { + // ループ j = 1, 2, ..., n-1, n + for (int j = 1; j <= n; ++j) { + res << "(" << i << ", " << j << "), "; + } + } + return res.str(); + } + ``` + +=== "Java" + + ```java title="iteration.java" + /* 2重 for ループ */ + String nestedForLoop(int n) { + StringBuilder res = new StringBuilder(); + // ループ i = 1, 2, ..., n-1, n + for (int i = 1; i <= n; i++) { + // ループ j = 1, 2, ..., n-1, n + for (int j = 1; j <= n; j++) { + res.append("(" + i + ", " + j + "), "); + } + } + return res.toString(); + } + ``` + +=== "C#" + + ```csharp title="iteration.cs" + [class]{iteration}-[func]{NestedForLoop} + ``` + +=== "Go" + + ```go title="iteration.go" + [class]{}-[func]{nestedForLoop} + ``` + +=== "Swift" + + ```swift title="iteration.swift" + [class]{}-[func]{nestedForLoop} + ``` + +=== "JS" + + ```javascript title="iteration.js" + [class]{}-[func]{nestedForLoop} + ``` + +=== "TS" + + ```typescript title="iteration.ts" + [class]{}-[func]{nestedForLoop} + ``` + +=== "Dart" + + ```dart title="iteration.dart" + [class]{}-[func]{nestedForLoop} + ``` + +=== "Rust" + + ```rust title="iteration.rs" + [class]{}-[func]{nested_for_loop} + ``` + +=== "C" + + ```c title="iteration.c" + [class]{}-[func]{nestedForLoop} + ``` + +=== "Kotlin" + + ```kotlin title="iteration.kt" + [class]{}-[func]{nestedForLoop} + ``` + +=== "Ruby" + + ```ruby title="iteration.rb" + [class]{}-[func]{nested_for_loop} + ``` + +=== "Zig" + + ```zig title="iteration.zig" + [class]{}-[func]{nestedForLoop} + ``` + +以下の図はこのネストしたループを表しています。 + +{ class="animation-figure" } + +図 2-2 Flowchart of the nested loop
+ +このような場合、関数の操作数は$n^2$に比例します。つまり、アルゴリズムの実行時間と入力データのサイズ$n$には「二次関係」があります。 + +さらにネストしたループを追加することで複雑度を高めることができ、各レベルのネストは事実上「次元を増加」させ、時間計算量を「三次」、「四次」などに引き上げます。 + +## 2.2.2 再帰 + +再帰は、関数が自分自身を呼び出すことで問題を解決するアルゴリズム戦略です。主に2つのフェーズが含まれます: + +1. **呼び出し**: プログラムが自分自身を繰り返し呼び出し、しばしばより小さいまたはより単純な引数で、「終了条件」に向かって進みます。 +2. **返却**: 「終了条件」がトリガーされると、プログラムは最も深い再帰関数から返り始め、各レイヤーの結果を集約します。 + +実装の観点から、再帰コードは主に3つの要素を含みます。 + +1. **終了条件**: 「呼び出し」から「返却」にいつ切り替えるかを決定します。 +2. **再帰呼び出し**: 「呼び出し」に対応し、関数が自分自身を呼び出し、通常はより小さいまたはより単純化されたパラメータで行います。 +3. **結果の返却**: 「返却」に対応し、現在の再帰レベルの結果が前のレイヤーに返されます。 + +以下のコードを観察してください。単純に関数`recur(n)`を呼び出すだけで$1 + 2 + \dots + n$の合計を計算できます: + +=== "Python" + + ```python title="recursion.py" + def recur(n: int) -> int: + """再帰""" + # 終了条件 + if n == 1: + return 1 + # 再帰:再帰呼び出し + res = recur(n - 1) + # 復帰:結果を返す + return n + res + ``` + +=== "C++" + + ```cpp title="recursion.cpp" + /* 再帰 */ + int recur(int n) { + // 終了条件 + if (n == 1) + return 1; + // 再帰:再帰呼び出し + int res = recur(n - 1); + // 戻り値:結果を返す + return n + res; + } + ``` + +=== "Java" + + ```java title="recursion.java" + /* 再帰 */ + int recur(int n) { + // 終了条件 + if (n == 1) + return 1; + // 再帰:再帰呼び出し + int res = recur(n - 1); + // 戻り値:結果を返す + return n + res; + } + ``` + +=== "C#" + + ```csharp title="recursion.cs" + [class]{recursion}-[func]{Recur} + ``` + +=== "Go" + + ```go title="recursion.go" + [class]{}-[func]{recur} + ``` + +=== "Swift" + + ```swift title="recursion.swift" + [class]{}-[func]{recur} + ``` + +=== "JS" + + ```javascript title="recursion.js" + [class]{}-[func]{recur} + ``` + +=== "TS" + + ```typescript title="recursion.ts" + [class]{}-[func]{recur} + ``` + +=== "Dart" + + ```dart title="recursion.dart" + [class]{}-[func]{recur} + ``` + +=== "Rust" + + ```rust title="recursion.rs" + [class]{}-[func]{recur} + ``` + +=== "C" + + ```c title="recursion.c" + [class]{}-[func]{recur} + ``` + +=== "Kotlin" + + ```kotlin title="recursion.kt" + [class]{}-[func]{recur} + ``` + +=== "Ruby" + + ```ruby title="recursion.rb" + [class]{}-[func]{recur} + ``` + +=== "Zig" + + ```zig title="recursion.zig" + [class]{}-[func]{recur} + ``` + +以下の図はこの関数の再帰プロセスを示しています。 + +{ class="animation-figure" } + +図 2-3 Recursive process of the sum function
+ +反復と再帰は計算の観点から同じ結果を達成できますが、**それらは思考と問題解決の全く異なるパラダイムを表します**。 + +- **反復**: 「ボトムアップ」で問題を解決します。最も基本的なステップから始まり、タスクが完了するまでこれらのステップを繰り返し追加または累積します。 +- **再帰**: 「トップダウン」で問題を解決します。元の問題をより小さなサブ問題に分解し、各サブ問題は元の問題と同じ形式を持ちます。これらのサブ問題は、解が分かっているベースケースで停止するまで、さらに小さなサブ問題に分解されます。 + +先ほどの合計関数の例を取ってみましょう。$f(n) = 1 + 2 + \dots + n$として定義されます。 + +- **反復**: このアプローチでは、ループ内で合計プロセスをシミュレートします。$1$から始まり$n$まで横断し、各反復で合計操作を実行して最終的に$f(n)$を計算します。 +- **再帰**: ここでは、問題はサブ問題に分解されます:$f(n) = n + f(n-1)$。この分解は、ベースケースの$f(1) = 1$に到達するまで再帰的に続き、そこで再帰が終了します。 + +### 1. 呼び出しスタック + +再帰関数が自分自身を呼び出すたびに、システムは新しく開始された関数にメモリを割り当てて、ローカル変数、戻りアドレス、その他の関連情報を格納します。これは2つの主要な結果をもたらします。 + +- 関数のコンテキストデータは「スタックフレーム空間」と呼ばれるメモリ領域に格納され、関数が返された後にのみ解放されます。したがって、**再帰は一般的に反復よりも多くのメモリ空間を消費します**。 +- 再帰呼び出しは追加のオーバーヘッドを導入します。**したがって、再帰は通常ループよりも時間効率が劣ります。** + +以下の図に示されているように、終了条件がトリガーされる前に$n$個の未返却の再帰関数があり、**再帰の深さが$n$であることを示しています**。 + +{ class="animation-figure" } + +図 2-4 Recursion call depth
+ +実際には、プログラミング言語で許可される再帰の深さは通常制限されており、過度に深い再帰はスタックオーバーフローエラーを引き起こす可能性があります。 + +### 2. 末尾再帰 + +興味深いことに、**関数が返す直前の最後のステップとして再帰呼び出しを実行する場合**、コンパイラまたはインタープリターによって反復と同じ空間効率になるように最適化できます。このシナリオは末尾再帰として知られています。 + +- **通常の再帰**: 標準的な再帰では、関数が前のレベルに戻ったとき、さらにコードを実行し続けるため、システムは前の呼び出しのコンテキストを保存する必要があります。 +- **末尾再帰**: ここでは、再帰呼び出しは関数が返す前の最終操作です。これは、前のレベルに戻った際に、さらなるアクションが必要ないことを意味するため、システムは前のレベルのコンテキストを保存する必要がありません。 + +例えば、$1 + 2 + \dots + n$の計算では、結果変数`res`を関数のパラメータにすることで、末尾再帰を実現できます: + +=== "Python" + + ```python title="recursion.py" + def tail_recur(n, res): + """末尾再帰""" + # 終了条件 + if n == 0: + return res + # 末尾再帰呼び出し + return tail_recur(n - 1, res + n) + ``` + +=== "C++" + + ```cpp title="recursion.cpp" + /* 末尾再帰 */ + int tailRecur(int n, int res) { + // 終了条件 + if (n == 0) + return res; + // 末尾再帰呼び出し + return tailRecur(n - 1, res + n); + } + ``` + +=== "Java" + + ```java title="recursion.java" + /* 末尾再帰 */ + int tailRecur(int n, int res) { + // 終了条件 + if (n == 0) + return res; + // 末尾再帰呼び出し + return tailRecur(n - 1, res + n); + } + ``` + +=== "C#" + + ```csharp title="recursion.cs" + [class]{recursion}-[func]{TailRecur} + ``` + +=== "Go" + + ```go title="recursion.go" + [class]{}-[func]{tailRecur} + ``` + +=== "Swift" + + ```swift title="recursion.swift" + [class]{}-[func]{tailRecur} + ``` + +=== "JS" + + ```javascript title="recursion.js" + [class]{}-[func]{tailRecur} + ``` + +=== "TS" + + ```typescript title="recursion.ts" + [class]{}-[func]{tailRecur} + ``` + +=== "Dart" + + ```dart title="recursion.dart" + [class]{}-[func]{tailRecur} + ``` + +=== "Rust" + + ```rust title="recursion.rs" + [class]{}-[func]{tail_recur} + ``` + +=== "C" + + ```c title="recursion.c" + [class]{}-[func]{tailRecur} + ``` + +=== "Kotlin" + + ```kotlin title="recursion.kt" + [class]{}-[func]{tailRecur} + ``` + +=== "Ruby" + + ```ruby title="recursion.rb" + [class]{}-[func]{tail_recur} + ``` + +=== "Zig" + + ```zig title="recursion.zig" + [class]{}-[func]{tailRecur} + ``` + +末尾再帰の実行プロセスは以下の図に示されています。通常の再帰と末尾再帰を比較すると、合計操作のポイントが異なります。 + +- **通常の再帰**: 合計操作は「返却」フェーズで発生し、各レイヤーが返った後にもう一度合計が必要です。 +- **末尾再帰**: 合計操作は「呼び出し」フェーズで発生し、「返却」フェーズは各レイヤーを通じて返すだけです。 + +{ class="animation-figure" } + +図 2-5 Tail recursion process
+ +!!! tip + + 多くのコンパイラやインタープリターは末尾再帰最適化をサポートしていないことに注意してください。例えば、Pythonはデフォルトで末尾再帰最適化をサポートしていないため、関数が末尾再帰の形式であっても、スタックオーバーフローの問題に遭遇する可能性があります。 + +### 3. 再帰木 + +「分割統治」に関連するアルゴリズムを扱う際、再帰は反復よりもしばしばより直感的なアプローチとより読みやすいコードを提供します。「フィボナッチ数列」を例に取ってみましょう。 + +!!! question + + フィボナッチ数列$0, 1, 1, 2, 3, 5, 8, 13, \dots$が与えられた場合、数列の$n$番目の数を求めなさい。 + +フィボナッチ数列の$n$番目の数を$f(n)$とすると、2つの結論を簡単に導き出せます: + +- 数列の最初の2つの数は$f(1) = 0$と$f(2) = 1$です。 +- 数列の各数は前の2つの数の合計です。つまり、$f(n) = f(n - 1) + f(n - 2)$です。 + +再帰関係を使用し、最初の2つの数を終了条件として考慮すると、再帰コードを書けます。`fib(n)`を呼び出すとフィボナッチ数列の$n$番目の数が得られます: + +=== "Python" + + ```python title="recursion.py" + def fib(n: int) -> int: + """フィボナッチ数列:再帰""" + # 終了条件 f(1) = 0, f(2) = 1 + if n == 1 or n == 2: + return n - 1 + # 再帰呼び出し f(n) = f(n-1) + f(n-2) + res = fib(n - 1) + fib(n - 2) + # 結果 f(n) を返す + return res + ``` + +=== "C++" + + ```cpp title="recursion.cpp" + /* フィボナッチ数列:再帰 */ + int fib(int n) { + // 終了条件 f(1) = 0, f(2) = 1 + if (n == 1 || n == 2) + return n - 1; + // 再帰呼び出し f(n) = f(n-1) + f(n-2) + int res = fib(n - 1) + fib(n - 2); + // 結果 f(n) を返す + return res; + } + ``` + +=== "Java" + + ```java title="recursion.java" + /* フィボナッチ数列:再帰 */ + int fib(int n) { + // 終了条件 f(1) = 0, f(2) = 1 + if (n == 1 || n == 2) + return n - 1; + // 再帰呼び出し f(n) = f(n-1) + f(n-2) + int res = fib(n - 1) + fib(n - 2); + // 結果 f(n) を返す + return res; + } + ``` + +=== "C#" + + ```csharp title="recursion.cs" + [class]{recursion}-[func]{Fib} + ``` + +=== "Go" + + ```go title="recursion.go" + [class]{}-[func]{fib} + ``` + +=== "Swift" + + ```swift title="recursion.swift" + [class]{}-[func]{fib} + ``` + +=== "JS" + + ```javascript title="recursion.js" + [class]{}-[func]{fib} + ``` + +=== "TS" + + ```typescript title="recursion.ts" + [class]{}-[func]{fib} + ``` + +=== "Dart" + + ```dart title="recursion.dart" + [class]{}-[func]{fib} + ``` + +=== "Rust" + + ```rust title="recursion.rs" + [class]{}-[func]{fib} + ``` + +=== "C" + + ```c title="recursion.c" + [class]{}-[func]{fib} + ``` + +=== "Kotlin" + + ```kotlin title="recursion.kt" + [class]{}-[func]{fib} + ``` + +=== "Ruby" + + ```ruby title="recursion.rb" + [class]{}-[func]{fib} + ``` + +=== "Zig" + + ```zig title="recursion.zig" + [class]{}-[func]{fib} + ``` + +上記のコードを観察すると、それ自体の中で2つの関数を再帰的に呼び出していることがわかります。**つまり、1回の呼び出しで2つの分岐呼び出しが生成されます**。以下の図に示されているように、この継続的な再帰呼び出しは最終的に深さ$n$の再帰木を作成します。 + +{ class="animation-figure" } + +図 2-6 Fibonacci sequence recursion tree
+ +基本的に、再帰は「問題をより小さなサブ問題に分解する」パラダイムを体現しています。この分割統治戦略は重要です。 + +- アルゴリズムの観点から、探索、ソート、バックトラッキング、分割統治、動的プログラミングなどの多くの重要な戦略は、直接的または間接的にこの思考方法を使用しています。 +- データ構造の観点から、再帰は連結リスト、木、グラフを扱うのに自然に適しており、これらは分割統治アプローチを使用した分析に適しているためです。 + +## 2.2.3 比較 + +上記の内容をまとめると、以下の表は実装、性能、適用性の観点から反復と再帰の違いを示しています。 + +表: 反復と再帰の特性の比較
+ +図 2-15 Space types used in algorithms
+ +関連するコードは以下の通りです: + +=== "Python" + + ```python title="" + class Node: + """クラス""" + def __init__(self, x: int): + self.val: int = x # ノード値 + self.next: Node | None = None # 次のノードへの参照 + + def function() -> int: + """関数""" + # 特定の操作を実行... + return 0 + + def algorithm(n) -> int: # 入力データ + A = 0 # 一時データ(定数、通常大文字) + b = 0 # 一時データ(変数) + node = Node(0) # 一時データ(オブジェクト) + c = function() # スタックフレーム空間(関数呼び出し) + return A + b + c # 出力データ + ``` + +=== "C++" + + ```cpp title="" + /* 構造体 */ + struct Node { + int val; + Node *next; + Node(int x) : val(x), next(nullptr) {} + }; + + /* 関数 */ + int func() { + // 特定の操作を実行... + return 0; + } + + int algorithm(int n) { // 入力データ + const int a = 0; // 一時データ(定数) + int b = 0; // 一時データ(変数) + Node* node = new Node(0); // 一時データ(オブジェクト) + int c = func(); // スタックフレーム空間(関数呼び出し) + return a + b + c; // 出力データ + } + ``` + +=== "Java" + + ```java title="" + /* クラス */ + class Node { + int val; + Node next; + Node(int x) { val = x; } + } + + /* 関数 */ + int function() { + // 特定の操作を実行... + return 0; + } + + int algorithm(int n) { // 入力データ + final int a = 0; // 一時データ(定数) + int b = 0; // 一時データ(変数) + Node node = new Node(0); // 一時データ(オブジェクト) + int c = function(); // スタックフレーム空間(関数呼び出し) + return a + b + c; // 出力データ + } + ``` + +=== "C#" + + ```csharp title="" + /* クラス */ + class Node { + int val; + Node next; + Node(int x) { val = x; } + } + + /* 関数 */ + int Function() { + // 特定の操作を実行... + return 0; + } + + int Algorithm(int n) { // 入力データ + const int a = 0; // 一時データ(定数) + int b = 0; // 一時データ(変数) + Node node = new(0); // 一時データ(オブジェクト) + int c = Function(); // スタックフレーム空間(関数呼び出し) + return a + b + c; // 出力データ + } + ``` + +=== "Go" + + ```go title="" + /* 構造体 */ + type node struct { + val int + next *node + } + + /* ノード構造体を作成 */ + func newNode(val int) *node { + return &node{val: val} + } + + /* 関数 */ + func function() int { + // 特定の操作を実行... + return 0 + } + + func algorithm(n int) int { // 入力データ + const a = 0 // 一時データ(定数) + b := 0 // 一時データ(変数) + newNode(0) // 一時データ(オブジェクト) + c := function() // スタックフレーム空間(関数呼び出し) + return a + b + c // 出力データ + } + ``` + +=== "Swift" + + ```swift title="" + /* クラス */ + class Node { + var val: Int + var next: Node? + + init(x: Int) { + val = x + } + } + + /* 関数 */ + func function() -> Int { + // 特定の操作を実行... + return 0 + } + + func algorithm(n: Int) -> Int { // 入力データ + let a = 0 // 一時データ(定数) + var b = 0 // 一時データ(変数) + let node = Node(x: 0) // 一時データ(オブジェクト) + let c = function() // スタックフレーム空間(関数呼び出し) + return a + b + c // 出力データ + } + ``` + +=== "JS" + + ```javascript title="" + /* クラス */ + class Node { + val; + next; + constructor(val) { + this.val = val === undefined ? 0 : val; // ノード値 + this.next = null; // 次のノードへの参照 + } + } + + /* 関数 */ + function constFunc() { + // 特定の操作を実行 + return 0; + } + + function algorithm(n) { // 入力データ + const a = 0; // 一時データ(定数) + let b = 0; // 一時データ(変数) + const node = new Node(0); // 一時データ(オブジェクト) + const c = constFunc(); // スタックフレーム空間(関数呼び出し) + return a + b + c; // 出力データ + } + ``` + +=== "TS" + + ```typescript title="" + /* クラス */ + class Node { + val: number; + next: Node | null; + constructor(val?: number) { + this.val = val === undefined ? 0 : val; // ノード値 + this.next = null; // 次のノードへの参照 + } + } + + /* 関数 */ + function constFunc(): number { + // 特定の操作を実行 + return 0; + } + + function algorithm(n: number): number { // 入力データ + const a = 0; // 一時データ(定数) + let b = 0; // 一時データ(変数) + const node = new Node(0); // 一時データ(オブジェクト) + const c = constFunc(); // スタックフレーム空間(関数呼び出し) + return a + b + c; // 出力データ + } + ``` + +=== "Dart" + + ```dart title="" + /* クラス */ + class Node { + int val; + Node next; + Node(this.val, [this.next]); + } + + /* 関数 */ + int function() { + // 特定の操作を実行... + return 0; + } + + int algorithm(int n) { // 入力データ + const int a = 0; // 一時データ(定数) + int b = 0; // 一時データ(変数) + Node node = Node(0); // 一時データ(オブジェクト) + int c = function(); // スタックフレーム空間(関数呼び出し) + return a + b + c; // 出力データ + } + ``` + +=== "Rust" + + ```rust title="" + use std::rc::Rc; + use std::cell::RefCell; + + /* 構造体 */ + struct Node { + val: i32, + next: Option図 2-16 Common types of space complexity
+ +### 1. 定数オーダー $O(1)$ {data-toc-label="1. 定数オーダー"} + +定数オーダーは、入力データサイズ$n$とは無関係な定数、変数、オブジェクトで一般的です。 + +ループで変数を初期化したり関数を呼び出したりするために占有されるメモリは、次のサイクルに入る際に解放され、空間上で累積されないため、空間計算量は$O(1)$のままです: + +=== "Python" + + ```python title="space_complexity.py" + def function() -> int: + """関数""" + # 何らかの操作を実行 + return 0 + + def constant(n: int): + """定数複雑度""" + # 定数、変数、オブジェクトは O(1) のスペースを占有 + a = 0 + nums = [0] * 10000 + node = ListNode(0) + # ループ内の変数は O(1) のスペースを占有 + for _ in range(n): + c = 0 + # ループ内の関数は O(1) のスペースを占有 + for _ in range(n): + function() + ``` + +=== "C++" + + ```cpp title="space_complexity.cpp" + /* 関数 */ + int func() { + // 何らかの操作を実行 + return 0; + } + + /* 定数計算量 */ + void constant(int n) { + // 定数、変数、オブジェクトは O(1) 空間を占める + const int a = 0; + int b = 0; + vector図 2-17 Recursive function generating linear order space complexity
+ +### 3. 二次オーダー $O(n^2)$ {data-toc-label="3. 二次オーダー"} + +二次オーダーは行列やグラフで一般的で、要素数は$n$の二乗に比例します: + +=== "Python" + + ```python title="space_complexity.py" + def quadratic(n: int): + """平方複雑度""" + # 二次元リストは O(n^2) のスペースを占有 + num_matrix = [[0] * n for _ in range(n)] + ``` + +=== "C++" + + ```cpp title="space_complexity.cpp" + /* 二次計算量 */ + void quadratic(int n) { + // 二次元リストは O(n^2) 空間を占める + vector図 2-18 Recursive function generating quadratic order space complexity
+ +### 4. 指数オーダー $O(2^n)$ {data-toc-label="4. 指数オーダー"} + +指数オーダーは二分木で一般的です。下図を観察すると、$n$レベルの「完全二分木」は$2^n - 1$個のノードを持ち、$O(2^n)$の空間を占有します: + +=== "Python" + + ```python title="space_complexity.py" + def build_tree(n: int) -> TreeNode | None: + """指数複雑度(完全二分木の構築)""" + if n == 0: + return None + root = TreeNode(0) + root.left = build_tree(n - 1) + root.right = build_tree(n - 1) + return root + ``` + +=== "C++" + + ```cpp title="space_complexity.cpp" + /* 指数計算量(完全二分木の構築) */ + TreeNode *buildTree(int n) { + if (n == 0) + return nullptr; + TreeNode *root = new TreeNode(0); + root->left = buildTree(n - 1); + root->right = buildTree(n - 1); + return root; + } + ``` + +=== "Java" + + ```java title="space_complexity.java" + /* 指数計算量(完全二分木の構築) */ + TreeNode buildTree(int n) { + if (n == 0) + return null; + TreeNode root = new TreeNode(0); + root.left = buildTree(n - 1); + root.right = buildTree(n - 1); + return root; + } + ``` + +=== "C#" + + ```csharp title="space_complexity.cs" + [class]{space_complexity}-[func]{BuildTree} + ``` + +=== "Go" + + ```go title="space_complexity.go" + [class]{}-[func]{buildTree} + ``` + +=== "Swift" + + ```swift title="space_complexity.swift" + [class]{}-[func]{buildTree} + ``` + +=== "JS" + + ```javascript title="space_complexity.js" + [class]{}-[func]{buildTree} + ``` + +=== "TS" + + ```typescript title="space_complexity.ts" + [class]{}-[func]{buildTree} + ``` + +=== "Dart" + + ```dart title="space_complexity.dart" + [class]{}-[func]{buildTree} + ``` + +=== "Rust" + + ```rust title="space_complexity.rs" + [class]{}-[func]{build_tree} + ``` + +=== "C" + + ```c title="space_complexity.c" + [class]{}-[func]{buildTree} + ``` + +=== "Kotlin" + + ```kotlin title="space_complexity.kt" + [class]{}-[func]{buildTree} + ``` + +=== "Ruby" + + ```ruby title="space_complexity.rb" + [class]{}-[func]{build_tree} + ``` + +=== "Zig" + + ```zig title="space_complexity.zig" + [class]{}-[func]{buildTree} + ``` + +{ class="animation-figure" } + +図 2-19 Full binary tree generating exponential order space complexity
+ +### 5. 対数オーダー $O(\log n)$ {data-toc-label="5. 対数オーダー"} + +対数オーダーは分割統治アルゴリズムで一般的です。例えば、マージソートでは、長さ$n$の配列が各ラウンドで再帰的に半分に分割され、高さ$\log n$の再帰木を形成し、$O(\log n)$のスタックフレーム空間を使用します。 + +別の例は、数値を文字列に変換することです。正の整数$n$が与えられた場合、その桁数は$\log_{10} n + 1$で、文字列の長さに対応するため、空間計算量は$O(\log_{10} n + 1) = O(\log n)$です。 + +## 2.4.4 時間と空間のバランス + +理想的には、時間計算量と空間計算量の両方が最適であることを目指します。しかし、実際には両方を同時に最適化することはしばしば困難です。 + +**時間計算量を下げることは通常、空間計算量の増加を代償とし、その逆も同様です**。アルゴリズムの速度を向上させるためにメモリ空間を犠牲にするアプローチは「時空トレードオフ」として知られ、その逆は「空時トレードオフ」として知られています。 + +選択は、どちらの側面をより重視するかに依存します。ほとんどの場合、時間は空間よりも貴重であるため、「時空トレードオフ」がより一般的な戦略です。もちろん、大量のデータを扱う際は空間計算量を制御することも非常に重要です。 diff --git a/ja/docs/chapter_computational_complexity/summary.md b/ja/docs/chapter_computational_complexity/summary.md new file mode 100644 index 000000000..6b0b99a7d --- /dev/null +++ b/ja/docs/chapter_computational_complexity/summary.md @@ -0,0 +1,53 @@ +--- +comments: true +--- + +# 2.5 まとめ + +### 1. 重要なレビュー + +**アルゴリズム効率評価** + +- 時間効率と空間効率は、アルゴリズムの優劣を評価する2つの主要な基準です。 +- 実際のテストによってアルゴリズムの効率を評価できますが、テスト環境の影響を排除することは困難で、大量の計算リソースを消費します。 +- 複雑度分析は実際のテストの欠点を克服できます。その結果はすべての動作プラットフォームに適用でき、異なるデータスケールでのアルゴリズムの効率を明らかにできます。 + +**時間計算量** + +- 時間計算量は、データ量の増加に伴うアルゴリズムの実行時間の傾向を測定し、アルゴリズムの効率を効果的に評価します。しかし、入力データ量が少ない場合や時間計算量が同じ場合など、特定のケースでは失敗することがあり、アルゴリズムの効率を正確に比較することが困難になります。 +- 最悪ケース時間計算量はビッグ$O$記法を使用して表記され、漸近上限を表し、$n$が無限大に近づくにつれての操作数$T(n)$の増加レベルを反映します。 +- 時間計算量の計算には2つのステップが含まれます:まず操作数をカウントし、次に漸近上限を決定します。 +- 一般的な時間計算量は、低いものから高いものへと並べると、$O(1)$、$O(\log n)$、$O(n)$、$O(n \log n)$、$O(n^2)$、$O(2^n)$、$O(n!)$などが含まれます。 +- 一部のアルゴリズムの時間計算量は固定されておらず、入力データの分布に依存します。時間計算量は最悪、最良、平均のケースに分けられます。最良ケースは、入力データが最良ケースを達成するために厳格な条件を満たす必要があるため、ほとんど使用されません。 +- 平均時間計算量は、ランダムデータ入力下でのアルゴリズムの効率を反映し、実際のアプリケーションでのアルゴリズムの性能に密接に類似しています。平均時間計算量の計算には、入力データの分布とその後の数学的期待値を考慮する必要があります。 + +**空間計算量** + +- 空間計算量は、時間計算量と同様に、データ量の増加に伴うアルゴリズムが占有するメモリ空間の傾向を測定します。 +- アルゴリズムの実行中に使用される関連メモリ空間は、入力空間、一時空間、出力空間に分けることができます。一般的に、入力空間は空間計算量の計算に含まれません。一時空間は一時データ、スタックフレーム空間、命令空間に分けることができ、スタックフレーム空間は通常、再帰関数でのみ空間計算量に影響します。 +- 通常は最悪ケース空間計算量のみに焦点を当てます。これは、最悪の入力データと操作の最悪の瞬間でのアルゴリズムの空間計算量を計算することを意味します。 +- 一般的な空間計算量は、低いものから高いものへと並べると、$O(1)$、$O(\log n)$、$O(n)$、$O(n^2)$、$O(2^n)$などが含まれます。 + +### 2. Q & A + +**Q**: 末尾再帰の空間計算量は$O(1)$ですか? + +理論的には、末尾再帰関数の空間計算量は$O(1)$に最適化できます。しかし、ほとんどのプログラミング言語(Java、Python、C++、Go、C#など)は末尾再帰の自動最適化をサポートしていないため、一般的に空間計算量は$O(n)$と考えられています。 + +**Q**: 「関数」と「メソッド」という用語の違いは何ですか? + +関数は独立して実行でき、すべてのパラメータが明示的に渡されます。メソッドはオブジェクトに関連付けられ、それを呼び出すオブジェクトに暗黙的に渡され、クラスのインスタンス内に含まれるデータを操作できます。 + +一般的なプログラミング言語からの例をいくつか示します: + +- Cは手続き型プログラミング言語で、オブジェクト指向の概念がないため、関数のみがあります。しかし、構造体(struct)を作成することでオブジェクト指向プログラミングをシミュレートでき、これらの構造体に関連付けられた関数は他のプログラミング言語のメソッドと同等です。 +- JavaとC#はオブジェクト指向プログラミング言語で、コードブロック(メソッド)は通常クラスの一部です。静的メソッドはクラスにバインドされ、特定のインスタンス変数にアクセスできないため、関数のように動作します。 +- C++とPythonは手続き型プログラミング(関数)とオブジェクト指向プログラミング(メソッド)の両方をサポートしています。 + +**Q**: 「空間計算量の一般的な種類」の図は、占有空間の絶対サイズを反映していますか? + +いいえ、図は空間計算量を示しており、これは増加傾向を反映するものであり、占有空間の絶対サイズではありません。 + +$n = 8$を取ると、各曲線の値がその関数に対応していないことに気づくかもしれません。これは、各曲線に定数項が含まれているためで、値の範囲を視覚的に快適な範囲に圧縮することを意図しています。 + +実際には、通常は各メソッドの「定数項」複雑度を知らないため、複雑度のみに基づいて$n = 8$の最良ソリューションを選択することは一般的に不可能です。しかし、$n = 8^5$の場合、増加傾向が支配的になるため、選択がはるかに容易になります。 diff --git a/ja/docs/chapter_computational_complexity/time_complexity.md b/ja/docs/chapter_computational_complexity/time_complexity.md new file mode 100644 index 000000000..533a1ac05 --- /dev/null +++ b/ja/docs/chapter_computational_complexity/time_complexity.md @@ -0,0 +1,2452 @@ +--- +comments: true +--- + +# 2.3 時間計算量 + +実行時間は、アルゴリズムの効率を直感的に評価できます。アルゴリズムの実行時間を正確に推定するにはどうすればよいでしょうか? + +1. **実行プラットフォームの決定**: これには、ハードウェア構成、プログラミング言語、システム環境などが含まれ、これらすべてがコードの実行効率に影響する可能性があります。 +2. **様々な計算操作の実行時間の評価**: 例えば、加算操作`+`は1 ns、乗算操作`*`は10 ns、印刷操作`print()`は5 nsなどかかる可能性があります。 +3. **コード内のすべての計算操作をカウント**: これらすべての操作の実行時間を合計すると、総実行時間が得られます。 + +例えば、入力サイズが$n$の以下のコードを考えてみましょう: + +=== "Python" + + ```python title="" + # 特定の操作プラットフォーム下で + def algorithm(n: int): + a = 2 # 1 ns + a = a + 1 # 1 ns + a = a * 2 # 10 ns + # n回ループ + for _ in range(n): # 1 ns + print(0) # 5 ns + ``` + +=== "C++" + + ```cpp title="" + // 特定の操作プラットフォーム下で + void algorithm(int n) { + int a = 2; // 1 ns + a = a + 1; // 1 ns + a = a * 2; // 10 ns + // n回ループ + for (int i = 0; i < n; i++) { // 1 ns, 毎回i++が実行される + cout << 0 << endl; // 5 ns + } + } + ``` + +=== "Java" + + ```java title="" + // 特定の操作プラットフォーム下で + void algorithm(int n) { + int a = 2; // 1 ns + a = a + 1; // 1 ns + a = a * 2; // 10 ns + // n回ループ + for (int i = 0; i < n; i++) { // 1 ns, 毎回i++が実行される + System.out.println(0); // 5 ns + } + } + ``` + +=== "C#" + + ```csharp title="" + // 特定の操作プラットフォーム下で + void Algorithm(int n) { + int a = 2; // 1 ns + a = a + 1; // 1 ns + a = a * 2; // 10 ns + // n回ループ + for (int i = 0; i < n; i++) { // 1 ns, 毎回i++が実行される + Console.WriteLine(0); // 5 ns + } + } + ``` + +=== "Go" + + ```go title="" + // 特定の操作プラットフォーム下で + func algorithm(n int) { + a := 2 // 1 ns + a = a + 1 // 1 ns + a = a * 2 // 10 ns + // n回ループ + for i := 0; i < n; i++ { // 1 ns + fmt.Println(a) // 5 ns + } + } + ``` + +=== "Swift" + + ```swift title="" + // 特定の操作プラットフォーム下で + func algorithm(n: Int) { + var a = 2 // 1 ns + a = a + 1 // 1 ns + a = a * 2 // 10 ns + // n回ループ + for _ in 0 ..< n { // 1 ns + print(0) // 5 ns + } + } + ``` + +=== "JS" + + ```javascript title="" + // 特定の操作プラットフォーム下で + function algorithm(n) { + var a = 2; // 1 ns + a = a + 1; // 1 ns + a = a * 2; // 10 ns + // n回ループ + for(let i = 0; i < n; i++) { // 1 ns, 毎回i++が実行される + console.log(0); // 5 ns + } + } + ``` + +=== "TS" + + ```typescript title="" + // 特定の操作プラットフォーム下で + function algorithm(n: number): void { + var a: number = 2; // 1 ns + a = a + 1; // 1 ns + a = a * 2; // 10 ns + // n回ループ + for(let i = 0; i < n; i++) { // 1 ns, 毎回i++が実行される + console.log(0); // 5 ns + } + } + ``` + +=== "Dart" + + ```dart title="" + // 特定の操作プラットフォーム下で + void algorithm(int n) { + int a = 2; // 1 ns + a = a + 1; // 1 ns + a = a * 2; // 10 ns + // n回ループ + for (int i = 0; i < n; i++) { // 1 ns, 毎回i++が実行される + print(0); // 5 ns + } + } + ``` + +=== "Rust" + + ```rust title="" + // 特定の操作プラットフォーム下で + fn algorithm(n: i32) { + let mut a = 2; // 1 ns + a = a + 1; // 1 ns + a = a * 2; // 10 ns + // n回ループ + for _ in 0..n { // 毎回i++で1 ns + println!("{}", 0); // 5 ns + } + } + ``` + +=== "C" + + ```c title="" + // 特定の操作プラットフォーム下で + void algorithm(int n) { + int a = 2; // 1 ns + a = a + 1; // 1 ns + a = a * 2; // 10 ns + // n回ループ + for (int i = 0; i < n; i++) { // 1 ns, 毎回i++が実行される + printf("%d", 0); // 5 ns + } + } + ``` + +=== "Kotlin" + + ```kotlin title="" + + ``` + +=== "Zig" + + ```zig title="" + // 特定の操作プラットフォーム下で + fn algorithm(n: usize) void { + var a: i32 = 2; // 1 ns + a += 1; // 1 ns + a *= 2; // 10 ns + // n回ループ + for (0..n) |_| { // 1 ns + std.debug.print("{}\n", .{0}); // 5 ns + } + } + ``` + +上記の方法を使用すると、アルゴリズムの実行時間は$(6n + 12)$ nsとして計算できます: + +$$ +1 + 1 + 10 + (1 + 5) \times n = 6n + 12 +$$ + +しかし、実際には、**アルゴリズムの実行時間をカウントすることは実用的でも合理的でもありません**。第一に、推定時間を実行プラットフォームに結び付けたくありません。アルゴリズムは様々なプラットフォームで実行される必要があるからです。第二に、各種操作の実行時間を知ることは困難であり、推定プロセスを難しくします。 + +## 2.3.1 時間増加傾向の評価 + +時間計算量分析は、アルゴリズムの実行時間をカウントするのではなく、**データ量が増加するにつれての実行時間の増加傾向**を分析します。 + +この「時間増加傾向」の概念を例で理解しましょう。入力データサイズを$n$とし、3つのアルゴリズム`A`、`B`、`C`を考えてみます: + +=== "Python" + + ```python title="" + # アルゴリズムAの時間計算量:定数オーダー + def algorithm_A(n: int): + print(0) + # アルゴリズムBの時間計算量:線形オーダー + def algorithm_B(n: int): + for _ in range(n): + print(0) + # アルゴリズムCの時間計算量:定数オーダー + def algorithm_C(n: int): + for _ in range(1000000): + print(0) + ``` + +=== "C++" + + ```cpp title="" + // アルゴリズムAの時間計算量:定数オーダー + void algorithm_A(int n) { + cout << 0 << endl; + } + // アルゴリズムBの時間計算量:線形オーダー + void algorithm_B(int n) { + for (int i = 0; i < n; i++) { + cout << 0 << endl; + } + } + // アルゴリズムCの時間計算量:定数オーダー + void algorithm_C(int n) { + for (int i = 0; i < 1000000; i++) { + cout << 0 << endl; + } + } + ``` + +=== "Java" + + ```java title="" + // アルゴリズムAの時間計算量:定数オーダー + void algorithm_A(int n) { + System.out.println(0); + } + // アルゴリズムBの時間計算量:線形オーダー + void algorithm_B(int n) { + for (int i = 0; i < n; i++) { + System.out.println(0); + } + } + // アルゴリズムCの時間計算量:定数オーダー + void algorithm_C(int n) { + for (int i = 0; i < 1000000; i++) { + System.out.println(0); + } + } + ``` + +=== "C#" + + ```csharp title="" + // アルゴリズムAの時間計算量:定数オーダー + void AlgorithmA(int n) { + Console.WriteLine(0); + } + // アルゴリズムBの時間計算量:線形オーダー + void AlgorithmB(int n) { + for (int i = 0; i < n; i++) { + Console.WriteLine(0); + } + } + // アルゴリズムCの時間計算量:定数オーダー + void AlgorithmC(int n) { + for (int i = 0; i < 1000000; i++) { + Console.WriteLine(0); + } + } + ``` + +=== "Go" + + ```go title="" + // アルゴリズムAの時間計算量:定数オーダー + func algorithm_A(n int) { + fmt.Println(0) + } + // アルゴリズムBの時間計算量:線形オーダー + func algorithm_B(n int) { + for i := 0; i < n; i++ { + fmt.Println(0) + } + } + // アルゴリズムCの時間計算量:定数オーダー + func algorithm_C(n int) { + for i := 0; i < 1000000; i++ { + fmt.Println(0) + } + } + ``` + +=== "Swift" + + ```swift title="" + // アルゴリズムAの時間計算量:定数オーダー + func algorithmA(n: Int) { + print(0) + } + + // アルゴリズムBの時間計算量:線形オーダー + func algorithmB(n: Int) { + for _ in 0 ..< n { + print(0) + } + } + + // アルゴリズムCの時間計算量:定数オーダー + func algorithmC(n: Int) { + for _ in 0 ..< 1_000_000 { + print(0) + } + } + ``` + +=== "JS" + + ```javascript title="" + // アルゴリズムAの時間計算量:定数オーダー + function algorithm_A(n) { + console.log(0); + } + // アルゴリズムBの時間計算量:線形オーダー + function algorithm_B(n) { + for (let i = 0; i < n; i++) { + console.log(0); + } + } + // アルゴリズムCの時間計算量:定数オーダー + function algorithm_C(n) { + for (let i = 0; i < 1000000; i++) { + console.log(0); + } + } + + ``` + +=== "TS" + + ```typescript title="" + // アルゴリズムAの時間計算量:定数オーダー + function algorithm_A(n: number): void { + console.log(0); + } + // アルゴリズムBの時間計算量:線形オーダー + function algorithm_B(n: number): void { + for (let i = 0; i < n; i++) { + console.log(0); + } + } + // アルゴリズムCの時間計算量:定数オーダー + function algorithm_C(n: number): void { + for (let i = 0; i < 1000000; i++) { + console.log(0); + } + } + ``` + +=== "Dart" + + ```dart title="" + // アルゴリズムAの時間計算量:定数オーダー + void algorithmA(int n) { + print(0); + } + // アルゴリズムBの時間計算量:線形オーダー + void algorithmB(int n) { + for (int i = 0; i < n; i++) { + print(0); + } + } + // アルゴリズムCの時間計算量:定数オーダー + void algorithmC(int n) { + for (int i = 0; i < 1000000; i++) { + print(0); + } + } + ``` + +=== "Rust" + + ```rust title="" + // アルゴリズムAの時間計算量:定数オーダー + fn algorithm_A(n: i32) { + println!("{}", 0); + } + // アルゴリズムBの時間計算量:線形オーダー + fn algorithm_B(n: i32) { + for _ in 0..n { + println!("{}", 0); + } + } + // アルゴリズムCの時間計算量:定数オーダー + fn algorithm_C(n: i32) { + for _ in 0..1000000 { + println!("{}", 0); + } + } + ``` + +=== "C" + + ```c title="" + // アルゴリズムAの時間計算量:定数オーダー + void algorithm_A(int n) { + printf("%d", 0); + } + // アルゴリズムBの時間計算量:線形オーダー + void algorithm_B(int n) { + for (int i = 0; i < n; i++) { + printf("%d", 0); + } + } + // アルゴリズムCの時間計算量:定数オーダー + void algorithm_C(int n) { + for (int i = 0; i < 1000000; i++) { + printf("%d", 0); + } + } + ``` + +=== "Kotlin" + + ```kotlin title="" + + ``` + +=== "Zig" + + ```zig title="" + // アルゴリズムAの時間計算量:定数オーダー + fn algorithm_A(n: usize) void { + _ = n; + std.debug.print("{}\n", .{0}); + } + // アルゴリズムBの時間計算量:線形オーダー + fn algorithm_B(n: i32) void { + for (0..n) |_| { + std.debug.print("{}\n", .{0}); + } + } + // アルゴリズムCの時間計算量:定数オーダー + fn algorithm_C(n: i32) void { + _ = n; + for (0..1000000) |_| { + std.debug.print("{}\n", .{0}); + } + } + ``` + +下図はこれら3つのアルゴリズムの時間計算量を示しています。 + +- アルゴリズム`A`には1つの印刷操作のみがあり、その実行時間は$n$とともに増加しません。その時間計算量は「定数オーダー」と考えられます。 +- アルゴリズム`B`には$n$回ループする印刷操作があり、その実行時間は$n$と線形に増加します。その時間計算量は「線形オーダー」です。 +- アルゴリズム`C`には1,000,000回ループする印刷操作があります。時間はかかりますが、入力データサイズ$n$とは無関係です。したがって、`C`の時間計算量は`A`と同じ「定数オーダー」です。 + +{ class="animation-figure" } + +図 2-7 Time growth trend of algorithms a, b, and c
+ +アルゴリズムの実行時間を直接カウントすることと比較して、時間計算量分析の特徴は何でしょうか? + +- **時間計算量はアルゴリズムの効率を効果的に評価します**。例えば、アルゴリズム`B`は線形に増加する実行時間を持ち、$n > 1$の時はアルゴリズム`A`より遅く、$n > 1,000,000$の時は`C`より遅くなります。実際、入力データサイズ$n$が十分に大きい限り、「定数オーダー」複雑度アルゴリズムは常に「線形オーダー」よりも優れており、時間増加傾向の本質を示しています。 +- **時間計算量分析はより直感的です**。明らかに、実行プラットフォームと計算操作の種類は実行時間増加の傾向とは無関係です。したがって、時間計算量分析では、すべての計算操作の実行時間を同じ「単位時間」として扱うことができ、「計算操作実行時間カウント」を「計算操作カウント」に単純化できます。これにより推定の複雑さが大幅に軽減されます。 +- **時間計算量には制限があります**。例えば、アルゴリズム`A`と`C`は同じ時間計算量を持ちますが、実際の実行時間は大きく異なる場合があります。同様に、アルゴリズム`B`は`C`よりも高い時間計算量を持ちますが、入力データサイズ$n$が小さい場合は明らかに優れています。これらの場合、時間計算量のみに基づいてアルゴリズムの効率を判断することは困難です。しかし、これらの問題にもかかわらず、複雑度分析はアルゴリズムの効率を評価するための最も効果的で一般的に使用される方法です。 + +## 2.3.2 漸近上限 + +入力サイズが$n$の関数を考えてみましょう: + +=== "Python" + + ```python title="" + def algorithm(n: int): + a = 1 # +1 + a = a + 1 # +1 + a = a * 2 # +1 + # n回ループ + for i in range(n): # +1 + print(0) # +1 + ``` + +=== "C++" + + ```cpp title="" + void algorithm(int n) { + int a = 1; // +1 + a = a + 1; // +1 + a = a * 2; // +1 + // n回ループ + for (int i = 0; i < n; i++) { // +1 (毎回i++が実行される) + cout << 0 << endl; // +1 + } + } + ``` + +=== "Java" + + ```java title="" + void algorithm(int n) { + int a = 1; // +1 + a = a + 1; // +1 + a = a * 2; // +1 + // n回ループ + for (int i = 0; i < n; i++) { // +1 (毎回i++が実行される) + System.out.println(0); // +1 + } + } + ``` + +=== "C#" + + ```csharp title="" + void Algorithm(int n) { + int a = 1; // +1 + a = a + 1; // +1 + a = a * 2; // +1 + // n回ループ + for (int i = 0; i < n; i++) { // +1 (毎回i++が実行される) + Console.WriteLine(0); // +1 + } + } + ``` + +=== "Go" + + ```go title="" + func algorithm(n int) { + a := 1 // +1 + a = a + 1 // +1 + a = a * 2 // +1 + // n回ループ + for i := 0; i < n; i++ { // +1 + fmt.Println(a) // +1 + } + } + ``` + +=== "Swift" + + ```swift title="" + func algorithm(n: Int) { + var a = 1 // +1 + a = a + 1 // +1 + a = a * 2 // +1 + // n回ループ + for _ in 0 ..< n { // +1 + print(0) // +1 + } + } + ``` + +=== "JS" + + ```javascript title="" + function algorithm(n) { + var a = 1; // +1 + a += 1; // +1 + a *= 2; // +1 + // n回ループ + for(let i = 0; i < n; i++){ // +1 (毎回i++が実行される) + console.log(0); // +1 + } + } + ``` + +=== "TS" + + ```typescript title="" + function algorithm(n: number): void{ + var a: number = 1; // +1 + a += 1; // +1 + a *= 2; // +1 + // n回ループ + for(let i = 0; i < n; i++){ // +1 (毎回i++が実行される) + console.log(0); // +1 + } + } + ``` + +=== "Dart" + + ```dart title="" + void algorithm(int n) { + int a = 1; // +1 + a = a + 1; // +1 + a = a * 2; // +1 + // n回ループ + for (int i = 0; i < n; i++) { // +1 (毎回i++が実行される) + print(0); // +1 + } + } + ``` + +=== "Rust" + + ```rust title="" + fn algorithm(n: i32) { + let mut a = 1; // +1 + a = a + 1; // +1 + a = a * 2; // +1 + + // n回ループ + for _ in 0..n { // +1 (毎回i++が実行される) + println!("{}", 0); // +1 + } + } + ``` + +=== "C" + + ```c title="" + void algorithm(int n) { + int a = 1; // +1 + a = a + 1; // +1 + a = a * 2; // +1 + // n回ループ + for (int i = 0; i < n; i++) { // +1 (毎回i++が実行される) + printf("%d", 0); // +1 + } + } + ``` + +=== "Kotlin" + + ```kotlin title="" + + ``` + +=== "Zig" + + ```zig title="" + fn algorithm(n: usize) void { + var a: i32 = 1; // +1 + a += 1; // +1 + a *= 2; // +1 + // n回ループ + for (0..n) |_| { // +1 (毎回i++が実行される) + std.debug.print("{}\n", .{0}); // +1 + } + } + ``` + +アルゴリズムの操作数を入力サイズ$n$の関数として表す関数を$T(n)$とすると、以下の例を考えてみましょう: + +$$ +T(n) = 3 + 2n +$$ + +$T(n)$は線形関数であるため、その増加傾向は線形であり、したがって、その時間計算量は線形オーダーで、$O(n)$と表記されます。この数学記法はビッグO記法として知られ、関数$T(n)$の漸近上限を表します。 + +本質的に、時間計算量分析は「操作数$T(n)$」の漸近上限を見つけることです。それには正確な数学的定義があります。 + +!!! note "漸近上限" + + すべての$n > n_0$に対して$T(n) \leq c \cdot f(n)$となるような正の実数$c$と$n_0$が存在する場合、$f(n)$は$T(n)$の漸近上限とみなされ、$T(n) = O(f(n))$と表記されます。 + +下図に示されているように、漸近上限の計算では、$n$が無限大に近づくにつれて、$T(n)$と$f(n)$が同じ増加オーダーを持ち、定数因子$c$のみが異なるような関数$f(n)$を見つけることが含まれます。 + +{ class="animation-figure" } + +図 2-8 Asymptotic upper bound of a function
+ +## 2.3.3 計算方法 + +漸近上限の概念は数学的に濃密に見えるかもしれませんが、今すぐ完全に理解する必要はありません。まず計算方法を理解し、時間をかけて練習し理解しましょう。 + +$f(n)$が決まれば、時間計算量$O(f(n))$が得られます。しかし、漸近上限$f(n)$をどのように決定するのでしょうか?このプロセスには一般的に2つのステップが含まれます:操作数のカウントと漸近上限の決定です。 + +### 1. ステップ1: 操作数のカウント + +このステップでは、コードを行ごとに確認します。しかし、$c \cdot f(n)$の定数$c$の存在により、**$T(n)$のすべての係数と定数項は無視できます**。この原理により、操作をカウントする際の簡略化技法が可能になります。 + +1. **$T(n)$の定数項を無視します**。これらは$n$とは無関係であるため、時間計算量に影響しません。 +2. **すべての係数を省略します**。例えば、$2n$、$5n + 1$回などのループは、$n$の前の係数が時間計算量に影響しないため、$n$回に簡略化できます。 +3. **ネストしたループには乗算を使用します**。総操作数は各ループの操作数の積であり、ポイント1と2の簡略化技法を各ループレベルに適用します。 + +関数が与えられた場合、これらの技法を使用して操作をカウントできます: + +=== "Python" + + ```python title="" + def algorithm(n: int): + a = 1 # +0 (技法1) + a = a + n # +0 (技法1) + # +n (技法2) + for i in range(5 * n + 1): + print(0) + # +n*n (技法3) + for i in range(2 * n): + for j in range(n + 1): + print(0) + ``` + +=== "C++" + + ```cpp title="" + void algorithm(int n) { + int a = 1; // +0 (技法1) + a = a + n; // +0 (技法1) + // +n (技法2) + for (int i = 0; i < 5 * n + 1; i++) { + cout << 0 << endl; + } + // +n*n (技法3) + for (int i = 0; i < 2 * n; i++) { + for (int j = 0; j < n + 1; j++) { + cout << 0 << endl; + } + } + } + ``` + +=== "Java" + + ```java title="" + void algorithm(int n) { + int a = 1; // +0 (技法1) + a = a + n; // +0 (技法1) + // +n (技法2) + for (int i = 0; i < 5 * n + 1; i++) { + System.out.println(0); + } + // +n*n (技法3) + for (int i = 0; i < 2 * n; i++) { + for (int j = 0; j < n + 1; j++) { + System.out.println(0); + } + } + } + ``` + +=== "C#" + + ```csharp title="" + void Algorithm(int n) { + int a = 1; // +0 (技法1) + a = a + n; // +0 (技法1) + // +n (技法2) + for (int i = 0; i < 5 * n + 1; i++) { + Console.WriteLine(0); + } + // +n*n (技法3) + for (int i = 0; i < 2 * n; i++) { + for (int j = 0; j < n + 1; j++) { + Console.WriteLine(0); + } + } + } + ``` + +=== "Go" + + ```go title="" + func algorithm(n int) { + a := 1 // +0 (技法1) + a = a + n // +0 (技法1) + // +n (技法2) + for i := 0; i < 5 * n + 1; i++ { + fmt.Println(0) + } + // +n*n (技法3) + for i := 0; i < 2 * n; i++ { + for j := 0; j < n + 1; j++ { + fmt.Println(0) + } + } + } + ``` + +=== "Swift" + + ```swift title="" + func algorithm(n: Int) { + var a = 1 // +0 (技法1) + a = a + n // +0 (技法1) + // +n (技法2) + for _ in 0 ..< (5 * n + 1) { + print(0) + } + // +n*n (技法3) + for _ in 0 ..< (2 * n) { + for _ in 0 ..< (n + 1) { + print(0) + } + } + } + ``` + +=== "JS" + + ```javascript title="" + function algorithm(n) { + let a = 1; // +0 (技法1) + a = a + n; // +0 (技法1) + // +n (技法2) + for (let i = 0; i < 5 * n + 1; i++) { + console.log(0); + } + // +n*n (技法3) + for (let i = 0; i < 2 * n; i++) { + for (let j = 0; j < n + 1; j++) { + console.log(0); + } + } + } + ``` + +=== "TS" + + ```typescript title="" + function algorithm(n: number): void { + let a = 1; // +0 (技法1) + a = a + n; // +0 (技法1) + // +n (技法2) + for (let i = 0; i < 5 * n + 1; i++) { + console.log(0); + } + // +n*n (技法3) + for (let i = 0; i < 2 * n; i++) { + for (let j = 0; j < n + 1; j++) { + console.log(0); + } + } + } + ``` + +=== "Dart" + + ```dart title="" + void algorithm(int n) { + int a = 1; // +0 (技法1) + a = a + n; // +0 (技法1) + // +n (技法2) + for (int i = 0; i < 5 * n + 1; i++) { + print(0); + } + // +n*n (技法3) + for (int i = 0; i < 2 * n; i++) { + for (int j = 0; j < n + 1; j++) { + print(0); + } + } + } + ``` + +=== "Rust" + + ```rust title="" + fn algorithm(n: i32) { + let mut a = 1; // +0 (技法1) + a = a + n; // +0 (技法1) + + // +n (技法2) + for i in 0..(5 * n + 1) { + println!("{}", 0); + } + + // +n*n (技法3) + for i in 0..(2 * n) { + for j in 0..(n + 1) { + println!("{}", 0); + } + } + } + ``` + +=== "C" + + ```c title="" + void algorithm(int n) { + int a = 1; // +0 (技法1) + a = a + n; // +0 (技法1) + // +n (技法2) + for (int i = 0; i < 5 * n + 1; i++) { + printf("%d", 0); + } + // +n*n (技法3) + for (int i = 0; i < 2 * n; i++) { + for (int j = 0; j < n + 1; j++) { + printf("%d", 0); + } + } + } + ``` + +=== "Kotlin" + + ```kotlin title="" + + ``` + +=== "Zig" + + ```zig title="" + fn algorithm(n: usize) void { + var a: i32 = 1; // +0 (技法1) + a = a + @as(i32, @intCast(n)); // +0 (技法1) + + // +n (技法2) + for(0..(5 * n + 1)) |_| { + std.debug.print("{}\n", .{0}); + } + + // +n*n (技法3) + for(0..(2 * n)) |_| { + for(0..(n + 1)) |_| { + std.debug.print("{}\n", .{0}); + } + } + } + ``` + +以下の式は、簡略化前後のカウント結果を示しており、どちらも$O(n^2)$の時間計算量に導きます: + +$$ +\begin{aligned} +T(n) & = 2n(n + 1) + (5n + 1) + 2 & \text{完全カウント (-.-|||)} \newline +& = 2n^2 + 7n + 3 \newline +T(n) & = n^2 + n & \text{簡略化カウント (o.O)} +\end{aligned} +$$ + +### 2. ステップ2: 漸近上限の決定 + +**時間計算量は$T(n)$の最高次項によって決定されます**。これは、$n$が無限大に近づくにつれて、最高次項が支配的になり、他の項の影響は無視できるようになるためです。 + +以下の表は、異なる操作カウントとそれに対応する時間計算量の例を示しています。係数が増加オーダーを変更できないことを強調するために、誇張された値が使用されています。$n$が非常に大きくなると、これらの定数は重要でなくなります。 + +表: 異なる操作カウントに対する時間計算量
+ +図 2-9 Common types of time complexity
+ +### 1. 定数オーダー $O(1)$ {data-toc-label="1. 定数オーダー"} + +定数オーダーは、操作数が入力データサイズ$n$とは無関係であることを意味します。以下の関数では、操作数`size`が大きい場合でも、$n$とは無関係であるため、時間計算量は$O(1)$のままです: + +=== "Python" + + ```python title="time_complexity.py" + def constant(n: int) -> int: + """定数複雑度""" + count = 0 + size = 100000 + for _ in range(size): + count += 1 + return count + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 定数計算量 */ + int constant(int n) { + int count = 0; + int size = 100000; + for (int i = 0; i < size; i++) + count++; + return count; + } + ``` + +=== "Java" + + ```java title="time_complexity.java" + /* 定数計算量 */ + int constant(int n) { + int count = 0; + int size = 100000; + for (int i = 0; i < size; i++) + count++; + return count; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + [class]{time_complexity}-[func]{Constant} + ``` + +=== "Go" + + ```go title="time_complexity.go" + [class]{}-[func]{constant} + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + [class]{}-[func]{constant} + ``` + +=== "JS" + + ```javascript title="time_complexity.js" + [class]{}-[func]{constant} + ``` + +=== "TS" + + ```typescript title="time_complexity.ts" + [class]{}-[func]{constant} + ``` + +=== "Dart" + + ```dart title="time_complexity.dart" + [class]{}-[func]{constant} + ``` + +=== "Rust" + + ```rust title="time_complexity.rs" + [class]{}-[func]{constant} + ``` + +=== "C" + + ```c title="time_complexity.c" + [class]{}-[func]{constant} + ``` + +=== "Kotlin" + + ```kotlin title="time_complexity.kt" + [class]{}-[func]{constant} + ``` + +=== "Ruby" + + ```ruby title="time_complexity.rb" + [class]{}-[func]{constant} + ``` + +=== "Zig" + + ```zig title="time_complexity.zig" + [class]{}-[func]{constant} + ``` + +### 2. 線形オーダー $O(n)$ {data-toc-label="2. 線形オーダー"} + +線形オーダーは、操作数が入力データサイズ$n$と線形に増加することを示します。線形オーダーは一般的に単一ループ構造で現れます: + +=== "Python" + + ```python title="time_complexity.py" + def linear(n: int) -> int: + """線形複雑度""" + count = 0 + for _ in range(n): + count += 1 + return count + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 線形計算量 */ + int linear(int n) { + int count = 0; + for (int i = 0; i < n; i++) + count++; + return count; + } + ``` + +=== "Java" + + ```java title="time_complexity.java" + /* 線形計算量 */ + int linear(int n) { + int count = 0; + for (int i = 0; i < n; i++) + count++; + return count; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + [class]{time_complexity}-[func]{Linear} + ``` + +=== "Go" + + ```go title="time_complexity.go" + [class]{}-[func]{linear} + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + [class]{}-[func]{linear} + ``` + +=== "JS" + + ```javascript title="time_complexity.js" + [class]{}-[func]{linear} + ``` + +=== "TS" + + ```typescript title="time_complexity.ts" + [class]{}-[func]{linear} + ``` + +=== "Dart" + + ```dart title="time_complexity.dart" + [class]{}-[func]{linear} + ``` + +=== "Rust" + + ```rust title="time_complexity.rs" + [class]{}-[func]{linear} + ``` + +=== "C" + + ```c title="time_complexity.c" + [class]{}-[func]{linear} + ``` + +=== "Kotlin" + + ```kotlin title="time_complexity.kt" + [class]{}-[func]{linear} + ``` + +=== "Ruby" + + ```ruby title="time_complexity.rb" + [class]{}-[func]{linear} + ``` + +=== "Zig" + + ```zig title="time_complexity.zig" + [class]{}-[func]{linear} + ``` + +配列の走査や連結リストの走査などの操作は時間計算量が$O(n)$で、$n$は配列またはリストの長さです: + +=== "Python" + + ```python title="time_complexity.py" + def array_traversal(nums: list[int]) -> int: + """線形複雑度(配列の走査)""" + count = 0 + # ループ回数は配列の長さに比例する + for num in nums: + count += 1 + return count + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 線形計算量(配列の走査) */ + int arrayTraversal(vector図 2-10 Constant, linear, and quadratic order time complexities
+ +例えば、バブルソートでは、外側のループが$n - 1$回実行され、内側のループが$n-1$、$n-2$、...、$2$、$1$回実行され、平均$n / 2$回となり、時間計算量は$O((n - 1) n / 2) = O(n^2)$になります: + +=== "Python" + + ```python title="time_complexity.py" + def bubble_sort(nums: list[int]) -> int: + """二次複雑度(バブルソート)""" + count = 0 # カウンタ + # 外側のループ: 未ソート範囲は [0, i] + for i in range(len(nums) - 1, 0, -1): + # 内側のループ: 未ソート範囲 [0, i] の最大要素を右端にスワップ + for j in range(i): + if nums[j] > nums[j + 1]: + # nums[j] と nums[j + 1] をスワップ + tmp: int = nums[j] + nums[j] = nums[j + 1] + nums[j + 1] = tmp + count += 3 # 要素のスワップは3つの個別操作を含む + return count + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 二次計算量(バブルソート) */ + int bubbleSort(vector図 2-11 Exponential order time complexity
+ +実際には、指数オーダーは再帰関数でよく現れます。例えば、以下のコードでは、再帰的に2つの半分に分割し、$n$回の分割後に停止します: + +=== "Python" + + ```python title="time_complexity.py" + def exp_recur(n: int) -> int: + """指数複雑度(再帰実装)""" + if n == 1: + return 1 + return exp_recur(n - 1) + exp_recur(n - 1) + 1 + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 指数計算量(再帰実装) */ + int expRecur(int n) { + if (n == 1) + return 1; + return expRecur(n - 1) + expRecur(n - 1) + 1; + } + ``` + +=== "Java" + + ```java title="time_complexity.java" + /* 指数計算量(再帰実装) */ + int expRecur(int n) { + if (n == 1) + return 1; + return expRecur(n - 1) + expRecur(n - 1) + 1; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + [class]{time_complexity}-[func]{ExpRecur} + ``` + +=== "Go" + + ```go title="time_complexity.go" + [class]{}-[func]{expRecur} + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + [class]{}-[func]{expRecur} + ``` + +=== "JS" + + ```javascript title="time_complexity.js" + [class]{}-[func]{expRecur} + ``` + +=== "TS" + + ```typescript title="time_complexity.ts" + [class]{}-[func]{expRecur} + ``` + +=== "Dart" + + ```dart title="time_complexity.dart" + [class]{}-[func]{expRecur} + ``` + +=== "Rust" + + ```rust title="time_complexity.rs" + [class]{}-[func]{exp_recur} + ``` + +=== "C" + + ```c title="time_complexity.c" + [class]{}-[func]{expRecur} + ``` + +=== "Kotlin" + + ```kotlin title="time_complexity.kt" + [class]{}-[func]{expRecur} + ``` + +=== "Ruby" + + ```ruby title="time_complexity.rb" + [class]{}-[func]{exp_recur} + ``` + +=== "Zig" + + ```zig title="time_complexity.zig" + [class]{}-[func]{expRecur} + ``` + +指数オーダーの増加は極めて急速で、全数探索法(ブルートフォース、バックトラッキングなど)でよく見られます。大規模問題では、指数オーダーは受け入れられず、しばしば動的プログラミングや貪欲アルゴリズムが解決策として必要になります。 + +### 5. 対数オーダー $O(\log n)$ {data-toc-label="5. 対数オーダー"} + +指数オーダーとは対照的に、対数オーダーは「各ラウンドでサイズが半分になる」状況を反映します。入力データサイズが$n$の場合、各ラウンドでサイズが半分になるため、反復回数は$\log_2 n$で、これは$2^n$の逆関数です。 + +下図とコードは「各ラウンドで半分にする」プロセスをシミュレートし、時間計算量は$O(\log_2 n)$で、一般的に$O(\log n)$と省略されます: + +=== "Python" + + ```python title="time_complexity.py" + def logarithmic(n: int) -> int: + """対数複雑度(ループ実装)""" + count = 0 + while n > 1: + n = n / 2 + count += 1 + return count + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 対数計算量(ループ実装) */ + int logarithmic(int n) { + int count = 0; + while (n > 1) { + n = n / 2; + count++; + } + return count; + } + ``` + +=== "Java" + + ```java title="time_complexity.java" + /* 対数計算量(ループ実装) */ + int logarithmic(int n) { + int count = 0; + while (n > 1) { + n = n / 2; + count++; + } + return count; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + [class]{time_complexity}-[func]{Logarithmic} + ``` + +=== "Go" + + ```go title="time_complexity.go" + [class]{}-[func]{logarithmic} + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + [class]{}-[func]{logarithmic} + ``` + +=== "JS" + + ```javascript title="time_complexity.js" + [class]{}-[func]{logarithmic} + ``` + +=== "TS" + + ```typescript title="time_complexity.ts" + [class]{}-[func]{logarithmic} + ``` + +=== "Dart" + + ```dart title="time_complexity.dart" + [class]{}-[func]{logarithmic} + ``` + +=== "Rust" + + ```rust title="time_complexity.rs" + [class]{}-[func]{logarithmic} + ``` + +=== "C" + + ```c title="time_complexity.c" + [class]{}-[func]{logarithmic} + ``` + +=== "Kotlin" + + ```kotlin title="time_complexity.kt" + [class]{}-[func]{logarithmic} + ``` + +=== "Ruby" + + ```ruby title="time_complexity.rb" + [class]{}-[func]{logarithmic} + ``` + +=== "Zig" + + ```zig title="time_complexity.zig" + [class]{}-[func]{logarithmic} + ``` + +{ class="animation-figure" } + +図 2-12 Logarithmic order time complexity
+ +指数オーダーと同様に、対数オーダーも再帰関数で頻繁に現れます。以下のコードは高さ$\log_2 n$の再帰木を形成します: + +=== "Python" + + ```python title="time_complexity.py" + def log_recur(n: int) -> int: + """対数複雑度(再帰実装)""" + if n <= 1: + return 0 + return log_recur(n / 2) + 1 + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 対数計算量(再帰実装) */ + int logRecur(int n) { + if (n <= 1) + return 0; + return logRecur(n / 2) + 1; + } + ``` + +=== "Java" + + ```java title="time_complexity.java" + /* 対数計算量(再帰実装) */ + int logRecur(int n) { + if (n <= 1) + return 0; + return logRecur(n / 2) + 1; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + [class]{time_complexity}-[func]{LogRecur} + ``` + +=== "Go" + + ```go title="time_complexity.go" + [class]{}-[func]{logRecur} + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + [class]{}-[func]{logRecur} + ``` + +=== "JS" + + ```javascript title="time_complexity.js" + [class]{}-[func]{logRecur} + ``` + +=== "TS" + + ```typescript title="time_complexity.ts" + [class]{}-[func]{logRecur} + ``` + +=== "Dart" + + ```dart title="time_complexity.dart" + [class]{}-[func]{logRecur} + ``` + +=== "Rust" + + ```rust title="time_complexity.rs" + [class]{}-[func]{log_recur} + ``` + +=== "C" + + ```c title="time_complexity.c" + [class]{}-[func]{logRecur} + ``` + +=== "Kotlin" + + ```kotlin title="time_complexity.kt" + [class]{}-[func]{logRecur} + ``` + +=== "Ruby" + + ```ruby title="time_complexity.rb" + [class]{}-[func]{log_recur} + ``` + +=== "Zig" + + ```zig title="time_complexity.zig" + [class]{}-[func]{logRecur} + ``` + +対数オーダーは分割統治戦略に基づくアルゴリズムの典型で、「多くに分割」と「複雑な問題を単純化」するアプローチを体現しています。増加が遅く、定数オーダーの次に最も理想的な時間計算量です。 + +!!! tip "$O(\log n)$の底は何ですか?" + + 技術的には、「$m$に分割」は時間計算量$O(\log_m n)$に対応します。対数の底変更公式を使用すると、異なる対数複雑度を等価にできます: + + $$ + O(\log_m n) = O(\log_k n / \log_k m) = O(\log_k n) + $$ + + これは、底$m$を複雑度に影響を与えることなく変更できることを意味します。したがって、しばしば底$m$を省略し、単に対数オーダーを$O(\log n)$と表記します。 + +### 6. 線形対数オーダー $O(n \log n)$ {data-toc-label="6. 線形対数オーダー"} + +線形対数オーダーはネストしたループでよく現れ、2つのループの複雑度がそれぞれ$O(\log n)$と$O(n)$です。関連するコードは以下の通りです: + +=== "Python" + + ```python title="time_complexity.py" + def linear_log_recur(n: int) -> int: + """線形対数複雑度""" + if n <= 1: + return 1 + count: int = linear_log_recur(n // 2) + linear_log_recur(n // 2) + for _ in range(n): + count += 1 + return count + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 線形対数計算量 */ + int linearLogRecur(int n) { + if (n <= 1) + return 1; + int count = linearLogRecur(n / 2) + linearLogRecur(n / 2); + for (int i = 0; i < n; i++) { + count++; + } + return count; + } + ``` + +=== "Java" + + ```java title="time_complexity.java" + /* 線形対数計算量 */ + int linearLogRecur(int n) { + if (n <= 1) + return 1; + int count = linearLogRecur(n / 2) + linearLogRecur(n / 2); + for (int i = 0; i < n; i++) { + count++; + } + return count; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + [class]{time_complexity}-[func]{LinearLogRecur} + ``` + +=== "Go" + + ```go title="time_complexity.go" + [class]{}-[func]{linearLogRecur} + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + [class]{}-[func]{linearLogRecur} + ``` + +=== "JS" + + ```javascript title="time_complexity.js" + [class]{}-[func]{linearLogRecur} + ``` + +=== "TS" + + ```typescript title="time_complexity.ts" + [class]{}-[func]{linearLogRecur} + ``` + +=== "Dart" + + ```dart title="time_complexity.dart" + [class]{}-[func]{linearLogRecur} + ``` + +=== "Rust" + + ```rust title="time_complexity.rs" + [class]{}-[func]{linear_log_recur} + ``` + +=== "C" + + ```c title="time_complexity.c" + [class]{}-[func]{linearLogRecur} + ``` + +=== "Kotlin" + + ```kotlin title="time_complexity.kt" + [class]{}-[func]{linearLogRecur} + ``` + +=== "Ruby" + + ```ruby title="time_complexity.rb" + [class]{}-[func]{linear_log_recur} + ``` + +=== "Zig" + + ```zig title="time_complexity.zig" + [class]{}-[func]{linearLogRecur} + ``` + +下図は線形対数オーダーがどのように生成されるかを示しています。二分木の各レベルには$n$個の操作があり、木には$\log_2 n + 1$レベルがあり、時間計算量は$O(n \log n)$になります。 + +{ class="animation-figure" } + +図 2-13 Linear-logarithmic order time complexity
+ +主流のソートアルゴリズムは通常$O(n \log n)$の時間計算量を持ち、クイックソート、マージソート、ヒープソートなどがあります。 + +### 7. 階乗オーダー $O(n!)$ {data-toc-label="7. 階乗オーダー"} + +階乗オーダーは「全順列」の数学問題に対応します。$n$個の異なる要素が与えられた場合、可能な順列の総数は: + +$$ +n! = n \times (n - 1) \times (n - 2) \times \dots \times 2 \times 1 +$$ + +階乗は通常再帰を使用して実装されます。以下のコードと図に示されているように、第1レベルは$n$個の分岐に分割され、第2レベルは$n - 1$個の分岐に分割され、第$n$レベル後に停止します: + +=== "Python" + + ```python title="time_complexity.py" + def factorial_recur(n: int) -> int: + """階乗複雑度(再帰実装)""" + if n == 0: + return 1 + count = 0 + # 1つからnに分岐 + for _ in range(n): + count += factorial_recur(n - 1) + return count + ``` + +=== "C++" + + ```cpp title="time_complexity.cpp" + /* 階乗計算量(再帰実装) */ + int factorialRecur(int n) { + if (n == 0) + return 1; + int count = 0; + // 1から n に分裂 + for (int i = 0; i < n; i++) { + count += factorialRecur(n - 1); + } + return count; + } + ``` + +=== "Java" + + ```java title="time_complexity.java" + /* 階乗計算量(再帰実装) */ + int factorialRecur(int n) { + if (n == 0) + return 1; + int count = 0; + // 1から n に分裂 + for (int i = 0; i < n; i++) { + count += factorialRecur(n - 1); + } + return count; + } + ``` + +=== "C#" + + ```csharp title="time_complexity.cs" + [class]{time_complexity}-[func]{FactorialRecur} + ``` + +=== "Go" + + ```go title="time_complexity.go" + [class]{}-[func]{factorialRecur} + ``` + +=== "Swift" + + ```swift title="time_complexity.swift" + [class]{}-[func]{factorialRecur} + ``` + +=== "JS" + + ```javascript title="time_complexity.js" + [class]{}-[func]{factorialRecur} + ``` + +=== "TS" + + ```typescript title="time_complexity.ts" + [class]{}-[func]{factorialRecur} + ``` + +=== "Dart" + + ```dart title="time_complexity.dart" + [class]{}-[func]{factorialRecur} + ``` + +=== "Rust" + + ```rust title="time_complexity.rs" + [class]{}-[func]{factorial_recur} + ``` + +=== "C" + + ```c title="time_complexity.c" + [class]{}-[func]{factorialRecur} + ``` + +=== "Kotlin" + + ```kotlin title="time_complexity.kt" + [class]{}-[func]{factorialRecur} + ``` + +=== "Ruby" + + ```ruby title="time_complexity.rb" + [class]{}-[func]{factorial_recur} + ``` + +=== "Zig" + + ```zig title="time_complexity.zig" + [class]{}-[func]{factorialRecur} + ``` + +{ class="animation-figure" } + +図 2-14 Factorial order time complexity
+ +階乗オーダーは指数オーダーよりもさらに速く増加することに注意してください。より大きな$n$値では受け入れられません。 + +## 2.3.5 最悪、最良、平均時間計算量 + +**アルゴリズムの時間効率は固定されていないことが多く、入力データの分布に依存します**。長さ$n$の配列`nums`があり、$1$から$n$までの数で構成され、それぞれが一度だけ現れますが、ランダムにシャッフルされた順序であるとします。タスクは要素$1$のインデックスを返すことです。以下の結論を導けます: + +- `nums = [?, ?, ..., 1]`の場合、つまり最後の要素が$1$の場合、配列の完全な走査が必要で、**最悪ケース時間計算量$O(n)$を達成します**。 +- `nums = [1, ?, ?, ...]`の場合、つまり最初の要素が$1$の場合、配列の長さに関係なく、さらなる走査は不要で、**最良ケース時間計算量$\Omega(1)$を達成します**。 + +「最悪ケース時間計算量」は漸近上限に対応し、大きな$O$記法で表されます。対応して、「最良ケース時間計算量」は漸近下限に対応し、$\Omega$で表されます: + +=== "Python" + + ```python title="worst_best_time_complexity.py" + def random_numbers(n: int) -> list[int]: + """要素 1, 2, ..., n を含む配列を生成、順序はシャッフル""" + # 配列 nums = 1, 2, 3, ..., n を生成 + nums = [i for i in range(1, n + 1)] + # 配列要素をランダムにシャッフル + random.shuffle(nums) + return nums + + def find_one(nums: list[int]) -> int: + """配列 nums で数値 1 のインデックスを検索""" + for i in range(len(nums)): + # 要素 1 が配列の最初にある場合、最良時間計算量 O(1) を達成 + # 要素 1 が配列の最後にある場合、最悪時間計算量 O(n) を達成 + if nums[i] == 1: + return i + return -1 + ``` + +=== "C++" + + ```cpp title="worst_best_time_complexity.cpp" + /* 要素 {1, 2, ..., n} をランダムにシャッフルした配列を生成 */ + vector表 3-1 基本データ型が占める空間と値の範囲
+ +図 3-6 ASCIIコード
+ +しかし、**ASCIIは英語の文字のみを表現できます**。コンピュータのグローバル化に伴い、より多くの言語を表現するためにEASCIIと呼ばれる文字セットが開発されました。ASCIIの7ビット構造から8ビットに拡張し、256文字の表現を可能にしました。 + +世界的に、様々な地域固有のEASCII文字セットが導入されました。これらのセットの最初の128文字はASCIIと一致していますが、残りの128文字は異なる言語の要件に対応するために異なって定義されています。 + +## 3.4.2 GBK文字セット + +後に、**EASCIIでも多くの言語の文字要件を満たすことができない**ことが判明しました。例えば、中国語には約10万の漢字があり、そのうち数千が定期的に使用されています。1980年、中国標準化委員会は6763の中国語文字を含むGB2312文字セットを発表し、中国語のコンピュータ処理ニーズを本質的に満たしました。 + +しかし、GB2312は一部の稀少文字や繁体字を処理できませんでした。GBK文字セットはGB2312を拡張し、21886の中国語文字を含んでいます。GBKエンコーディングスキームでは、ASCII文字は1バイトで表現され、中国語文字は2バイトを使用します。 + +## 3.4.3 Unicode文字セット + +コンピュータ技術の急速な発展と多数の文字セットおよびエンコーディング標準により、数多くの問題が発生しました。一方では、これらの文字セットは一般的に特定の言語の文字のみを定義し、多言語環境では適切に機能できませんでした。他方では、同じ言語に対する複数の文字セット標準の存在により、異なるエンコーディング標準を使用するコンピュータ間で情報交換を行う際に文字化けが発生しました。 + +当時の研究者たちは考えました:**世界のすべての言語と記号を含む包括的な文字セットが開発されれば、言語横断環境と文字化けに関連する問題を解決できるのではないでしょうか?** このアイデアにインスパイアされて、広範囲な文字セットであるUnicodeが誕生しました。 + +Unicodeは中国語で「统一码」(統一コード)と呼ばれ、理論的に100万文字以上を収容できます。世界中のすべての文字を単一のセットに組み込み、様々な言語の処理と表示のための汎用文字セットを提供し、異なるエンコーディング標準による文字化けの問題を減らすことを目指しています。 + +1991年のリリース以来、Unicodeは新しい言語と文字を含むよう継続的に拡張されています。2022年9月現在、Unicodeには149,186文字が含まれており、様々な言語の文字、記号、さらには絵文字も含まれています。広大なUnicode文字セットでは、一般的に使用される文字は2バイトを占有し、一部の稀少な文字は3バイトまたは4バイトを占有する場合があります。 + +Unicodeは各文字に数値(「コードポイント」と呼ばれる)を割り当てる汎用文字セットですが、**これらの文字コードポイントがコンピュータシステムにどのように格納されるべきかは指定していません**。疑問が生じるかもしれません:システムはテキスト内の異なる長さのUnicodeコードポイントをどのように解釈するのでしょうか?例えば、2バイトのコードが与えられた場合、システムはそれが単一の2バイト文字を表すのか、2つの1バイト文字を表すのかをどのように判断するのでしょうか? + +**この問題に対する簡単な解決策は、すべての文字を等長エンコーディングとして格納することです**。以下の図に示すように、「Hello」の各文字は1バイトを占有し、「算法」(アルゴリズム)の各文字は2バイトを占有します。上位ビットをゼロで埋めることで、「Hello 算法」のすべての文字を2バイトとしてエンコードできます。この方法により、システムは2バイトごとに文字を解釈し、フレーズの内容を復元できます。 + +{ class="animation-figure" } + +図 3-7 Unicodeエンコーディング例
+ +しかし、ASCIIが示したように、英語のエンコーディングには1バイトのみが必要です。上記のアプローチを使用すると、英語テキストが占有する空間がASCIIエンコーディングと比較して2倍になり、メモリ空間の無駄になります。したがって、より効率的なUnicodeエンコーディング方法が必要です。 + +## 3.4.4 UTF-8エンコーディング + +現在、UTF-8は国際的に最も広く使用されているUnicodeエンコーディング方法になっています。**これは可変長エンコーディング**で、文字の複雑さに応じて1〜4バイトを使用して文字を表現します。ASCII文字は1バイトのみが必要で、ラテン文字とギリシャ文字は2バイト、一般的に使用される中国語文字は3バイト、その他の稀少な文字は4バイトが必要です。 + +UTF-8のエンコーディング規則は複雑ではなく、2つのケースに分けることができます: + +- 1バイト文字の場合、最上位ビットを$0$に設定し、残りの7ビットをUnicodeコードポイントに設定します。注目すべきは、ASCII文字がUnicodeセットの最初の128コードポイントを占有することです。これは**UTF-8エンコーディングがASCIIと後方互換性がある**ことを意味します。これは、UTF-8を使用して古いASCIIテキストを解析できることを意味します。 +- 長さ$n$バイトの文字($n > 1$)の場合、最初のバイトの最上位$n$ビットを$1$に設定し、$(n + 1)^{\text{th}}$ビットを$0$に設定します。2番目のバイトから、各バイトの最上位2ビットを$10$に設定します。残りのビットはUnicodeコードポイントを埋めるために使用されます。 + +以下の図は「Hello算法」のUTF-8エンコーディングを示しています。最上位$n$ビットが$1$に設定されているため、システムは最上位ビットで$1$に設定されたビット数を数えることで文字の長さを$n$として決定できることが観察できます。 + +しかし、なぜ残りのバイトの最上位2ビットを$10$に設定するのでしょうか?実際、この$10$は一種のチェックサムとして機能します。システムが間違ったバイトからテキストの解析を開始した場合、バイトの先頭の$10$によりシステムは異常を迅速に検出できます。 + +$10$をチェックサムとして使用する理由は、UTF-8エンコーディング規則の下では、文字の最上位2ビットが$10$になることは不可能だからです。これは矛盾により証明できます:文字の最上位2ビットが$10$の場合、文字の長さが$1$であることを示し、これはASCIIに対応します。しかし、ASCII文字の最上位ビットは$0$であるべきで、これは仮定と矛盾します。 + +{ class="animation-figure" } + +図 3-8 UTF-8エンコーディング例
+ +UTF-8以外にも、他の一般的なエンコーディング方法には以下があります: + +- **UTF-16エンコーディング**:2または4バイトを使用して文字を表現します。すべてのASCII文字と一般的に使用される非英語文字は2バイトで表現され、少数の文字は4バイトが必要です。2バイト文字の場合、UTF-16エンコーディングはUnicodeコードポイントと等しくなります。 +- **UTF-32エンコーディング**:すべての文字が4バイトを使用します。これは、UTF-32がUTF-8やUTF-16よりも多くの空間を占有することを意味し、特にASCII文字の割合が高いテキストでは顕著です。 + +ストレージ空間の観点から、UTF-8を使用して英語文字を表現することは1バイトのみが必要なため非常に効率的です。UTF-16を使用して一部の非英語文字(中国語など)をエンコードすることは、2バイトのみが必要なためより効率的になる場合があります。一方、UTF-8では3バイトが必要になる場合があります。 + +互換性の観点から、UTF-8は最も汎用性があり、多くのツールとライブラリがUTF-8を優先的にサポートしています。 + +## 3.4.5 プログラミング言語における文字エンコーディング + +歴史的に、多くのプログラミング言語はプログラム実行中の文字列処理にUTF-16やUTF-32などの固定長エンコーディングを利用していました。これにより文字列を配列として処理でき、いくつかの利点があります: + +- **ランダムアクセス**:UTF-16でエンコードされた文字列は簡単にランダムアクセスできます。可変長エンコーディングであるUTF-8の場合、$i^{th}$文字の位置を特定するには文字列の開始から$i^{th}$位置まで走査する必要があり、$O(n)$時間がかかります。 +- **文字数カウント**:ランダムアクセスと同様に、UTF-16でエンコードされた文字列の文字数をカウントすることは$O(1)$操作です。しかし、UTF-8でエンコードされた文字列の文字数をカウントするには文字列全体を走査する必要があります。 +- **文字列操作**:分割、連結、挿入、削除などの多くの文字列操作は、UTF-16でエンコードされた文字列で簡単です。これらの操作は一般的に、UTF-8エンコーディングの有効性を確保するためにUTF-8でエンコードされた文字列で追加の計算が必要です。 + +プログラミング言語における文字エンコーディングスキームの設計は、様々な要因を含む興味深いトピックです: + +- Javaの`String`型はUTF-16エンコーディングを使用し、各文字が2バイトを占有します。これは、16ビットがすべての可能な文字を表現するのに十分であるという初期の信念に基づいており、後に間違いであることが証明されました。Unicode標準が16ビットを超えて拡張されると、Javaの文字は「サロゲートペア」として知られる16ビット値のペアで表現される場合があります。 +- JavaScriptとTypeScriptは、Javaと同様の理由でUTF-16エンコーディングを使用します。JavaScriptが1995年にNetscapeによって最初に導入されたとき、Unicodeはまだ初期段階にあり、16ビットエンコーディングはすべてのUnicode文字を表現するのに十分でした。 +- C#はUTF-16エンコーディングを使用し、これは主にMicrosoftによって設計された.NETプラットフォーム、および多くのMicrosoft技術(Windowsオペレーティングシステムを含む)がUTF-16エンコーディングを広範囲に使用しているためです。 + +文字数の過小評価により、これらの言語は16ビットを超えるUnicode文字を表現するために「サロゲートペア」を使用する必要がありました。このアプローチには欠点があります:サロゲートペアを含む文字列は2バイトまたは4バイトを占有する文字を持つ場合があり、固定長エンコーディングの利点を失います。さらに、サロゲートペアの処理はプログラミングに複雑さとデバッグの困難さを追加します。 + +これらの課題に対処するため、一部の言語は代替エンコーディング戦略を採用しています: + +- Pythonの`str`型は、文字のストレージ長が文字列内の最大のUnicodeコードポイントに依存する柔軟な表現でUnicodeエンコーディングを使用します。すべての文字がASCIIの場合、各文字は1バイトを占有し、基本多言語面(BMP)内の文字は2バイト、BMPを超える文字は4バイトを占有します。 +- Goの`string`型は内部的にUTF-8エンコーディングを使用します。Goは個別のUnicodeコードポイントを表現するための`rune`型も提供します。 +- Rustの`str`と`String`型は内部的にUTF-8エンコーディングを使用します。Rustは個別のUnicodeコードポイント用の`char`型も提供します。 + +上記の議論は、プログラミング言語での文字列の格納方法に関するものであり、**ファイルでの文字列の格納方法やネットワーク上での送信方法とは異なる**ことに注意することが重要です。ファイルストレージやネットワーク送信では、文字列は通常、最適な互換性と空間効率のためにUTF-8形式でエンコードされます。 diff --git a/ja/docs/chapter_data_structure/classification_of_data_structure.md b/ja/docs/chapter_data_structure/classification_of_data_structure.md new file mode 100644 index 000000000..9ef7decc4 --- /dev/null +++ b/ja/docs/chapter_data_structure/classification_of_data_structure.md @@ -0,0 +1,58 @@ +--- +comments: true +--- + +# 3.1 データ構造の分類 + +一般的なデータ構造には、配列、連結リスト、スタック、キュー、ハッシュ表、木、ヒープ、グラフがあります。これらは「論理構造」と「物理構造」に分類できます。 + +## 3.1.1 論理構造:線形と非線形 + +**論理構造はデータ要素間の論理的関係を明らかにします**。配列と連結リストでは、データは特定の順序で配置され、データ間の線形関係を示しています。一方、木では、データは上から下へ階層的に配置され、「祖先」と「子孫」間の派生関係を示しています。そして、グラフはノードとエッジから構成され、複雑なネットワーク関係を反映しています。 + +下図に示されているように、論理構造は「線形」と「非線形」の2つの主要カテゴリに分けることができます。線形構造はより直感的で、データが論理関係において線形に配置されていることを示しています。非線形構造は、逆に非線形に配置されています。 + +- **線形データ構造**:配列、連結リスト、スタック、キュー、ハッシュ表。要素が一対一の順次関係を持ちます。 +- **非線形データ構造**:木、ヒープ、グラフ、ハッシュ表。 + +非線形データ構造は、さらに木構造とネットワーク構造に分けることができます。 + +- **木構造**:木、ヒープ、ハッシュ表。要素が一対多の関係を持ちます。 +- **ネットワーク構造**:グラフ。要素が多対多の関係を持ちます。 + +{ class="animation-figure" } + +図 3-1 Linear and non-linear data structures
+ +## 3.1.2 物理構造:連続と分散 + +**アルゴリズムの実行中、処理されるデータはメモリに格納されます**。下図はコンピュータのメモリスティックを示しており、各黒い正方形は物理メモリ空間です。メモリを巨大なExcelスプレッドシートと考えることができ、各セルは一定量のデータを格納できます。 + +**システムはメモリアドレスによって目標位置のデータにアクセスします**。下図に示されているように、コンピュータは特定のルールに従って表の各セルに一意の識別子を割り当て、各メモリ空間が一意のメモリアドレスを持つことを保証します。これらのアドレスにより、プログラムはメモリに格納されたデータにアクセスできます。 + +{ class="animation-figure" } + +図 3-2 Memory stick, memory spaces, memory addresses
+ +!!! tip + + メモリをExcelスプレッドシートに比較することは簡略化された類推であることに注意してください。メモリの実際の動作メカニズムはより複雑で、アドレス空間、メモリ管理、キャッシュメカニズム、仮想メモリ、物理メモリなどの概念が関係しています。 + +メモリはすべてのプログラムの共有リソースです。あるメモリブロックが1つのプログラムによって占有されると、他のプログラムが同時に使用することはできません。**したがって、メモリリソースはデータ構造とアルゴリズムの設計における重要な考慮事項です**。例えば、アルゴリズムのピークメモリ使用量は、システムの残り空きメモリを超えてはいけません。連続したメモリブロックが不足している場合は、非連続メモリブロックに格納できるデータ構造を選択する必要があります。 + +下図に示されているように、**物理構造はコンピュータメモリにおけるデータの格納方法を反映し**、連続空間格納(配列)と非連続空間格納(連結リスト)に分けることができます。2つのタイプの物理構造は、時間効率と空間効率の観点で補完的な特性を示します。 + +{ class="animation-figure" } + +図 3-3 Contiguous space storage and dispersed space storage
+ +**すべてのデータ構造は配列、連結リスト、またはその組み合わせに基づいて実装されていることに注意してください**。例えば、スタックとキューは配列または連結リストのどちらでも実装できます。ハッシュ表の実装には配列と連結リストの両方が関係する場合があります。 + +- **配列ベースの実装**:スタック、キュー、ハッシュ表、木、ヒープ、グラフ、行列、テンソル(次元$\geq 3$の配列)。 +- **連結リストベースの実装**:スタック、キュー、ハッシュ表、木、ヒープ、グラフなど。 + +配列に基づいて実装されたデータ構造は「静的データ構造」とも呼ばれ、初期化後に長さを変更できないことを意味します。逆に、連結リストに基づいたものは「動的データ構造」と呼ばれ、プログラム実行中にサイズを調整できます。 + +!!! tip + + 物理構造を理解するのが困難な場合は、次の章「配列と連結リスト」を読んでから、この節に戻ることをお勧めします。 diff --git a/ja/docs/chapter_data_structure/index.md b/ja/docs/chapter_data_structure/index.md new file mode 100644 index 000000000..068d46c7b --- /dev/null +++ b/ja/docs/chapter_data_structure/index.md @@ -0,0 +1,22 @@ +--- +comments: true +icon: material/shape-outline +--- + +# 第 3 章 データ構造 + +{ class="cover-image" } + +!!! abstract + + データ構造は堅牢で多様なフレームワークとして機能します。 + + データの整然とした組織化のための設計図を提供し、その上でアルゴリズムが生き生きと動き出します。 + +## 章の内容 + +- [3.1 データ構造の分類](classification_of_data_structure.md) +- [3.2 基本データ型](basic_data_types.md) +- [3.3 数値エンコーディング *](number_encoding.md) +- [3.4 文字エンコーディング *](character_encoding.md) +- [3.5 まとめ](summary.md) diff --git a/ja/docs/chapter_data_structure/number_encoding.md b/ja/docs/chapter_data_structure/number_encoding.md new file mode 100644 index 000000000..2391e570b --- /dev/null +++ b/ja/docs/chapter_data_structure/number_encoding.md @@ -0,0 +1,162 @@ +--- +comments: true +--- + +# 3.3 数値エンコーディング * + +!!! tip + + 本書では、アスタリスク「*」が付いた章は任意読書です。時間が不足している場合や難しいと感じる場合は、最初はこれらをスキップして、必須の章を完了した後に戻ることができます。 + +## 3.3.1 整数エンコーディング + +前の節の表で、すべての整数型は正の数よりも1つ多い負の数を表現できることを観察しました。例えば、`byte`の範囲は$[-128, 127]$です。この現象は直感に反するように見え、その根本的な理由には符号絶対値、1の補数、2の補数エンコーディングの知識が関与しています。 + +まず重要なことは、**数値はコンピュータ内で2の補数形式で格納される**ということです。なぜそうなのかを分析する前に、これら3つのエンコーディング方法を定義しましょう: + +- **符号絶対値**:数値の二進表現の最上位ビットを符号ビットとし、$0$は正の数、$1$は負の数を表します。残りのビットは数値の値を表します。 +- **1の補数**:正の数の1の補数は符号絶対値と同じです。負の数の場合、符号ビット以外のすべてのビットを反転して得られます。 +- **2の補数**:正の数の2の補数は符号絶対値と同じです。負の数の場合、その1の補数に$1$を加えて得られます。 + +以下の図は、符号絶対値、1の補数、2の補数間の変換を示しています: + +{ class="animation-figure" } + +図 3-4 符号絶対値、1の補数、2の補数間の変換
+ +符号絶対値は最も直感的ですが、制限があります。一つには、**符号絶対値の負の数は計算で直接使用できません**。例えば、符号絶対値で$1 + (-2)$を計算すると$-3$になり、これは正しくありません。 + +$$ +\begin{aligned} +& 1 + (-2) \newline +& \rightarrow 0000 \; 0001 + 1000 \; 0010 \newline +& = 1000 \; 0011 \newline +& \rightarrow -3 +\end{aligned} +$$ + +この問題に対処するため、コンピュータは1の補数を導入しました。1の補数に変換して$1 + (-2)$を計算し、結果を符号絶対値に戻すと、正しい結果$-1$が得られます。 + +$$ +\begin{aligned} +& 1 + (-2) \newline +& \rightarrow 0000 \; 0001 \; \text{(符号絶対値)} + 1000 \; 0010 \; \text{(符号絶対値)} \newline +& = 0000 \; 0001 \; \text{(1の補数)} + 1111 \; 1101 \; \text{(1の補数)} \newline +& = 1111 \; 1110 \; \text{(1の補数)} \newline +& = 1000 \; 0001 \; \text{(符号絶対値)} \newline +& \rightarrow -1 +\end{aligned} +$$ + +また、**符号絶対値では0に2つの表現があります**:$+0$と$-0$です。これは0に対して2つの異なる二進エンコーディングがあることを意味し、曖昧さを引き起こす可能性があります。例えば、条件チェックで正と負の0を区別しないと、正しくない結果になる可能性があります。この曖昧さに対処するには追加のチェックが必要で、計算効率が低下する可能性があります。 + +$$ +\begin{aligned} ++0 & \rightarrow 0000 \; 0000 \newline +-0 & \rightarrow 1000 \; 0000 +\end{aligned} +$$ + +符号絶対値と同様に、1の補数も正と負の0の曖昧さに悩まされます。そのため、コンピュータはさらに2の補数を導入しました。符号絶対値、1の補数、2の補数における負の0の変換過程を観察してみましょう: + +$$ +\begin{aligned} +-0 \rightarrow \; & 1000 \; 0000 \; \text{(符号絶対値)} \newline += \; & 1111 \; 1111 \; \text{(1の補数)} \newline += 1 \; & 0000 \; 0000 \; \text{(2の補数)} \newline +\end{aligned} +$$ + +負の0の1の補数に$1$を加えると桁上がりが発生しますが、`byte`の長さは8ビットのみのため、9番目のビットへの桁上がり$1$は破棄されます。したがって、**負の0の2の補数は$0000 \; 0000$**で、正の0と同じになり、曖昧さが解決されます。 + +最後の謎は、`byte`の$[-128, 127]$の範囲で、追加の負の数$-128$があることです。$[-127, +127]$の区間では、すべての整数に対応する符号絶対値、1の補数、2の補数があり、相互変換が可能であることを観察します。 + +しかし、**2の補数$1000 \; 0000$は対応する符号絶対値を持たない例外です**。変換方法によると、その符号絶対値は$0000 \; 0000$で、0を示します。これは矛盾を示しています。なぜなら、その2の補数は自分自身を表すべきだからです。コンピュータは、この特別な2の補数$1000 \; 0000$を$-128$を表すものとして指定しています。実際、2の補数での$(-1) + (-127)$の計算結果は$-128$になります。 + +$$ +\begin{aligned} +& (-127) + (-1) \newline +& \rightarrow 1111 \; 1111 \; \text{(符号絶対値)} + 1000 \; 0001 \; \text{(符号絶対値)} \newline +& = 1000 \; 0000 \; \text{(1の補数)} + 1111 \; 1110 \; \text{(1の補数)} \newline +& = 1000 \; 0001 \; \text{(2の補数)} + 1111 \; 1111 \; \text{(2の補数)} \newline +& = 1000 \; 0000 \; \text{(2の補数)} \newline +& \rightarrow -128 +\end{aligned} +$$ + +お気づきかもしれませんが、これらの計算はすべて加算であり、重要な事実を示唆しています:**コンピュータの内部ハードウェア回路は主に加算演算を中心に設計されています**。これは、加算が乗算、除算、減算などの他の演算と比較してハードウェアで実装しやすく、並列化が容易で高速計算が可能だからです。 + +これはコンピュータが加算のみを実行できることを意味するものではありません。**加算と基本的な論理演算を組み合わせることで、コンピュータは様々な他の数学演算を実行できます**。例えば、減算$a - b$は$a + (-b)$に変換でき、乗算と除算は複数の加算または減算に変換できます。 + +コンピュータで2の補数を使用する理由をまとめることができます:2の補数表現により、コンピュータは同じ回路と演算を使用して正と負の数の加算を処理でき、減算用の特別なハードウェア回路の必要性を排除し、正と負の0の曖昧さを回避できます。これによりハードウェア設計が大幅に簡素化され、計算効率が向上します。 + +2の補数の設計は非常に巧妙で、スペースの制約により、ここで停止します。興味のある読者はさらに探求することを奨励します。 + +## 3.3.2 浮動小数点数エンコーディング + +興味深いことに気づいたかもしれません:同じ4バイトの長さにもかかわらず、なぜ`float`は`int`と比較してはるかに大きい値の範囲を持つのでしょうか?これは直感に反するように見えます。`float`は分数を表現する必要があるため、範囲が縮小すると予想されるからです。 + +実際、**これは浮動小数点数(`float`)で使用される異なる表現方法によるものです**。32ビットの二進数を次のように考えてみましょう: + +$$ +b_{31} b_{30} b_{29} \ldots b_2 b_1 b_0 +$$ + +IEEE 754標準によると、32ビットの`float`は次の3つの部分で構成されます: + +- 符号ビット$\mathrm{S}$:1ビットを占有し、$b_{31}$に対応します。 +- 指数ビット$\mathrm{E}$:8ビットを占有し、$b_{30} b_{29} \ldots b_{23}$に対応します。 +- 仮数ビット$\mathrm{N}$:23ビットを占有し、$b_{22} b_{21} \ldots b_0$に対応します。 + +二進`float`数の値は次のように計算されます: + +$$ +\text{val} = (-1)^{b_{31}} \times 2^{\left(b_{30} b_{29} \ldots b_{23}\right)_2 - 127} \times \left(1 . b_{22} b_{21} \ldots b_0\right)_2 +$$ + +十進公式に変換すると、次のようになります: + +$$ +\text{val} = (-1)^{\mathrm{S}} \times 2^{\mathrm{E} - 127} \times (1 + \mathrm{N}) +$$ + +各成分の範囲は: + +$$ +\begin{aligned} +\mathrm{S} \in & \{ 0, 1\}, \quad \mathrm{E} \in \{ 1, 2, \dots, 254 \} \newline +(1 + \mathrm{N}) = & (1 + \sum_{i=1}^{23} b_{23-i} \times 2^{-i}) \subset [1, 2 - 2^{-23}] +\end{aligned} +$$ + +{ class="animation-figure" } + +図 3-5 IEEE 754標準での浮動小数点数の計算例
+ +上の図を観察すると、例のデータ$\mathrm{S} = 0$、$\mathrm{E} = 124$、$\mathrm{N} = 2^{-2} + 2^{-3} = 0.375$が与えられた場合: + +$$ +\text{val} = (-1)^0 \times 2^{124 - 127} \times (1 + 0.375) = 0.171875 +$$ + +これで最初の質問に答えることができます:**`float`の表現には指数ビットが含まれているため、`int`よりもはるかに大きい範囲を持ちます**。上記の計算に基づくと、`float`で表現可能な最大正の数は約$2^{254 - 127} \times (2 - 2^{-23}) \approx 3.4 \times 10^{38}$で、最小負の数は符号ビットを切り替えることで得られます。 + +**しかし、`float`の拡張された範囲のトレードオフは精度の犠牲です**。整数型`int`は32ビットすべてを数値表現に使用し、値は均等に分布していますが、指数ビットのため、`float`の値が大きいほど、隣接する数値間の差が大きくなります。 + +以下の表に示すように、指数ビット$\mathrm{E} = 0$と$\mathrm{E} = 255$は特別な意味を持ち、**0、無限大、$\mathrm{NaN}$などを表現するために使用されます**。 + +表 3-2 指数ビットの意味
+ +図 12-4 二分探索の分割統治過程
+ +実装コードでは、問題 $f(i, j)$ を解決するために再帰関数 `dfs()` を宣言します: + +=== "Python" + + ```python title="binary_search_recur.py" + def dfs(nums: list[int], target: int, i: int, j: int) -> int: + """二分探索:問題 f(i, j)""" + # 区間が空の場合、対象要素がないことを示すため、-1 を返す + if i > j: + return -1 + # 中点インデックス m を計算 + m = (i + j) // 2 + if nums[m] < target: + # 再帰部分問題 f(m+1, j) + return dfs(nums, target, m + 1, j) + elif nums[m] > target: + # 再帰部分問題 f(i, m-1) + return dfs(nums, target, i, m - 1) + else: + # 対象要素を発見したため、そのインデックスを返す + return m + + def binary_search(nums: list[int], target: int) -> int: + """二分探索""" + n = len(nums) + # 問題 f(0, n-1) を解く + return dfs(nums, target, 0, n - 1) + ``` + +=== "C++" + + ```cpp title="binary_search_recur.cpp" + /* 二分探索:問題 f(i, j) */ + int dfs(vector図 12-5 二分木構築のサンプルデータ
+ +### 1. 分割統治問題かどうかの判定 + +`preorder` と `inorder` シーケンスから二分木を構築する元の問題は、典型的な分割統治問題です。 + +- **問題を分解できる**:分割統治の観点から、元の問題を2つの部分問題(左の部分木の構築と右の部分木の構築)とルートノードの初期化という1つの操作に分割できます。各部分木(部分問題)について、同じアプローチを継続的に適用し、より小さな部分木(部分問題)に分割し、最小の部分問題(空の部分木)に到達するまで続けます。 +- **部分問題は独立している**:左と右の部分木は重複しません。左の部分木を構築する際、左の部分木に対応する中順走査と前順走査のセグメントのみが必要です。右の部分木にも同じアプローチが適用されます。 +- **部分問題の解を組み合わせることができる**:左と右の部分木(部分問題の解)を構築したら、それらをルートノードに接続して元の問題の解を取得できます。 + +### 2. 部分木の分割方法 + +上記の分析に基づいて、この問題は分割統治を使用して解決できます。**しかし、前順走査 `preorder` シーケンスと中順走査 `inorder` シーケンスを使用して左と右の部分木をどのように分割すればよいでしょうか?** + +定義により、`preorder` と `inorder` シーケンスの両方を3つの部分に分割できます: + +- 前順走査:`[ ルート | 左の部分木 | 右の部分木 ]`。例えば、図では、木は `[ 3 | 9 | 2 1 7 ]` に対応します。 +- 中順走査:`[ 左の部分木 | ルート | 右の部分木 ]`。例えば、図では、木は `[ 9 | 3 | 1 2 7 ]` に対応します。 + +前の図のデータを使用して、次の図に示すステップに従って分割結果を取得できます: + +1. 前順走査の最初の要素3がルートノードの値です。 +2. `inorder` シーケンス内でルートノード3のインデックスを見つけ、このインデックスを使用して `inorder` を `[ 9 | 3 | 1 2 7 ]` に分割します。 +3. `inorder` シーケンスの分割に従って、左と右の部分木がそれぞれ1個と3個のノードを含むことが簡単に決定できるため、`preorder` シーケンスを `[ 3 | 9 | 2 1 7 ]` に対応して分割できます。 + +{ class="animation-figure" } + +図 12-6 前順走査と中順走査での部分木の分割
+ +### 3. 変数に基づく部分木範囲の記述 + +上記の分割方法に基づいて、**`preorder` と `inorder` シーケンスにおけるルート、左の部分木、右の部分木のインデックス範囲を取得しました**。これらのインデックス範囲を記述するために、いくつかのポインタ変数を使用します。 + +- 現在の木のルートノードの `preorder` シーケンスでのインデックスを $i$ とします。 +- 現在の木のルートノードの `inorder` シーケンスでのインデックスを $m$ とします。 +- 現在の木の `inorder` シーケンスでのインデックス範囲を $[l, r]$ とします。 + +以下の表に示すように、これらの変数は `preorder` シーケンスでのルートノードのインデックスと `inorder` シーケンスでの部分木のインデックス範囲を表します。 + +表 12-1 前順走査と中順走査でのルートノードと部分木のインデックス
+ +図 12-7 ルートノードと左右の部分木のインデックス
+ +### 4. コード実装 + +$m$ の問い合わせの効率を向上させるために、ハッシュテーブル `hmap` を使用して `inorder` シーケンスの要素からそのインデックスへのマッピングを格納します: + +=== "Python" + + ```python title="build_tree.py" + def dfs( + preorder: list[int], + inorder_map: dict[int, int], + i: int, + l: int, + r: int, + ) -> TreeNode | None: + """二分木の構築:分割統治""" + # 部分木の区間が空のとき終了 + if r - l < 0: + return None + # ルートノードを初期化 + root = TreeNode(preorder[i]) + # m をクエリして左部分木と右部分木を分割 + m = inorder_map[preorder[i]] + # 部分問題:左部分木を構築 + root.left = dfs(preorder, inorder_map, i + 1, l, m - 1) + # 部分問題:右部分木を構築 + root.right = dfs(preorder, inorder_map, i + 1 + m - l, m + 1, r) + # ルートノードを返す + return root + + def build_tree(preorder: list[int], inorder: list[int]) -> TreeNode | None: + """二分木を構築""" + # ハッシュテーブルを初期化、中順走査の要素からインデックスへのマッピングを保存 + inorder_map = {val: i for i, val in enumerate(inorder)} + root = dfs(preorder, inorder_map, 0, 0, len(inorder) - 1) + return root + ``` + +=== "C++" + + ```cpp title="build_tree.cpp" + /* 二分木の構築:分割統治 */ + TreeNode *dfs(vector図 12-8 二分木構築の再帰過程
+ +各再帰関数の `preorder` と `inorder` シーケンスの分割は以下の図に示されています。 + +{ class="animation-figure" } + +図 12-9 各再帰関数での分割
+ +二分木が $n$ 個のノードを持つと仮定すると、各ノードの初期化(再帰関数 `dfs()` の呼び出し)には $O(1)$ 時間がかかります。**したがって、全体の時間計算量は $O(n)$ です**。 + +ハッシュテーブルは `inorder` 要素からそのインデックスへのマッピングを格納するため、$O(n)$ スペースが必要です。最悪の場合、二分木が連結リストに退化すると、再帰の深さは $n$ に達し、$O(n)$ のスタックスペースを消費する可能性があります。**したがって、全体の空間計算量は $O(n)$ です**。 diff --git a/ja/docs/chapter_divide_and_conquer/divide_and_conquer.md b/ja/docs/chapter_divide_and_conquer/divide_and_conquer.md new file mode 100644 index 000000000..3152e09f5 --- /dev/null +++ b/ja/docs/chapter_divide_and_conquer/divide_and_conquer.md @@ -0,0 +1,101 @@ +--- +comments: true +--- + +# 12.1 分割統治アルゴリズム + +分割統治は重要で人気のあるアルゴリズム戦略です。名前が示すように、アルゴリズムは通常再帰的に実装され、「分割」と「統治」の2つのステップから構成されます。 + +1. **分割(分割段階)**:元の問題を再帰的に2つ以上の小さな部分問題に分解し、最小の部分問題に到達するまで続けます。 +2. **統治(マージ段階)**:解決方法が既知の最小の部分問題から開始し、部分問題の解をボトムアップ方式でマージして元の問題の解を構築します。 + +以下の図に示すように、「マージソート」は分割統治戦略の典型的な応用の一つです。 + +1. **分割**:元の配列(元の問題)を再帰的に2つの副配列(部分問題)に分割し、副配列が1つの要素のみになるまで(最小の部分問題)続けます。 +2. **統治**:順序付けられた副配列(部分問題の解)をボトムアップでマージして、順序付けられた元の配列(元の問題の解)を取得します。 + +{ class="animation-figure" } + +図 12-1 マージソートの分割統治戦略
+ +## 12.1.1 分割統治問題を特定する方法 + +問題が分割統治解決に適しているかどうかは、通常以下の基準に基づいて決定できます。 + +1. **問題をより小さなものに分解できる**:元の問題をより小さく類似した部分問題に分割でき、そのような過程を同じ方法で再帰的に実行できます。 +2. **部分問題は独立している**:部分問題間に重複がなく、独立しており、個別に解決できます。 +3. **部分問題の解をマージできる**:元の問題の解は、部分問題の解を組み合わせることで導出されます。 + +明らかに、マージソートはこれら3つの基準を満たしています。 + +1. **問題をより小さなものに分解できる**:配列(元の問題)を再帰的に2つの副配列(部分問題)に分割します。 +2. **部分問題は独立している**:各副配列は独立してソートできます(部分問題は独立して解決できます)。 +3. **部分問題の解をマージできる**:2つの順序付けられた副配列(部分問題の解)を1つの順序付けられた配列(元の問題の解)にマージできます。 + +## 12.1.2 分割統治による効率の向上 + +**分割統治戦略はアルゴリズム問題を効果的に解決するだけでなく、しばしば効率を向上させます**。ソートアルゴリズムでは、クイックソート、マージソート、ヒープソートは、分割統治戦略を適用しているため、選択ソート、バブルソート、挿入ソートよりも高速です。 + +私たちの心には疑問があるかもしれません:**なぜ分割統治はアルゴリズムの効率を向上させることができ、その根本的な論理は何ですか?** つまり、問題を部分問題に分解し、それらを解決し、それらの解を組み合わせて元の問題に対処することが、元の問題を直接解決するよりも効率的である理由は何ですか?この質問は2つの側面から分析できます:操作数と並列計算。 + +### 1. 操作数の最適化 + +「バブルソート」を例にとると、長さ $n$ の配列を処理するのに $O(n^2)$ 時間が必要です。以下の図に示すように、配列を中点から2つの副配列に分割するとします。そのような分割には $O(n)$ 時間が必要です。各副配列のソートには $O((n / 2)^2)$ 時間が必要です。そして2つの副配列のマージには $O(n)$ 時間が必要です。したがって、全体の時間計算量は: + +$$ +O(n + (\frac{n}{2})^2 \times 2 + n) = O(\frac{n^2}{2} + 2n) +$$ + +{ class="animation-figure" } + +図 12-2 配列分割前後のバブルソート
+ +以下の不等式を計算してみましょう。左側は分割前の総操作数を表し、右側は分割後の総操作数をそれぞれ表します: + +$$ +\begin{aligned} +n^2 & > \frac{n^2}{2} + 2n \newline +n^2 - \frac{n^2}{2} - 2n & > 0 \newline +n(n - 4) & > 0 +\end{aligned} +$$ + +**これは $n > 4$ の場合、分割後の操作数が少なく、より良いパフォーマンスにつながることを意味します**。分割後の時間計算量は依然として二次 $O(n^2)$ ですが、計算量の定数係数が減少していることに注意してください。 + +さらに進むことができます。**副配列をその中点からさらに2つの副配列に分割し続けて、副配列が1つの要素のみになるまで続けたらどうでしょうか?** このアイデアは実際には「マージソート」で、時間計算量は $O(n \log n)$ です。 + +少し違うことを試してみましょう。**2つではなく、より多くの分割に分割したらどうでしょうか?** 例えば、元の配列を $k$ 個の副配列に均等に分割しますか?このアプローチは「バケットソート」と非常に似ており、大量のデータのソートに非常に適しています。理論的には、時間計算量は $O(n + k)$ に達することができます。 + +### 2. 並列計算による最適化 + +分割統治によって生成される部分問題は互いに独立していることが分かっています。**これは、それらを並列で解決できることを意味します。** その結果、分割統治はアルゴリズムの時間計算量を減らすだけでなく、**現代のオペレーティングシステムによる並列最適化も促進します。** + +並列最適化は、複数のコアやプロセッサを持つ環境で特に効果的です。システムが複数の部分問題を同時に処理できるため、計算リソースを完全に活用し、全体的な実行時間が大幅に短縮されます。 + +例えば、以下の図に示す「バケットソート」では、大量のデータを様々なバケットに均等に分解します。各バケットのソート作業は、利用可能な計算ユニットに割り当てることができます。すべての作業が完了すると、すべてのソートされたバケットがマージされて最終結果が生成されます。 + +{ class="animation-figure" } + +図 12-3 バケットソートの並列計算
+ +## 12.1.3 分割統治の一般的な応用 + +分割統治は多くの古典的なアルゴリズム問題を解決するために使用できます。 + +- **最近点対の発見**:このアルゴリズムは点の集合を2つの半分に分割することで動作します。そして各半分で再帰的に最近点対を見つけます。最後に、2つの半分にまたがるペアを考慮して、全体の最近点対を見つけます。 +- **大整数の乗算**:一つのアルゴリズムはKaratsubaと呼ばれます。大整数の乗算をいくつかの小さな整数の乗算と加算に分解します。 +- **行列の乗算**:一例はStrassenアルゴリズムです。大きな行列の乗算を複数の小さな行列の乗算と加算に分解します。 +- **ハノイの塔問題**:ハノイの塔問題は再帰的に解決でき、分割統治戦略の典型的な応用です。 +- **転倒対の解決**:シーケンスで、前の数が後の数より大きい場合、これら2つの数は転倒対を構成します。転倒対問題の解決は、マージソートの助けを借りて、分割統治のアイデアを利用できます。 + +分割統治はアルゴリズムとデータ構造の設計にも広く応用されています。 + +- **二分探索**:二分探索は、ソート済み配列を中点インデックスから2つの半分に分割します。そして、ターゲット値と中間要素値の比較結果に基づいて、一方の半分が破棄されます。同じプロセスで残りの半分で検索が続行され、ターゲットが見つかるか残りの要素がなくなるまで続きます。 +- **マージソート**:この節の冒頭ですでに紹介したため、さらなる詳述は不要です。 +- **クイックソート**:クイックソートはピボット値を選択して配列を2つの副配列に分割し、一方はピボットより小さい要素、もう一方はピボットより大きい要素を持ちます。このプロセスは、これら2つの副配列のそれぞれに対して、1つの要素のみを保持するまで続きます。 +- **バケットソート**:バケットソートの基本的なアイデアは、データを複数のバケットに分散させることです。各バケット内の要素をソートした後、バケットから順序よく要素を取得して順序付けられた配列を取得します。 +- **木**:例えば、二分探索木、AVL木、赤黒木、B木、B+木など。その操作(検索、挿入、削除)はすべて分割統治戦略の応用と見なすことができます。 +- **ヒープ**:ヒープは特別なタイプの完全二分木です。その様々な操作(挿入、削除、ヒープ化)は、実際に分割統治のアイデアを含意しています。 +- **ハッシュテーブル**:ハッシュテーブルは直接分割統治を適用しませんが、一部のハッシュ衝突解決ソリューションは間接的にこの戦略を適用します。例えば、チェイン法の長いリストは、クエリ効率を向上させるために赤黒木に変換される場合があります。 + +**分割統治は巧妙に浸透するアルゴリズムアイデア**であり、様々なアルゴリズムとデータ構造に組み込まれていることが分かります。 diff --git a/ja/docs/chapter_divide_and_conquer/hanota_problem.md b/ja/docs/chapter_divide_and_conquer/hanota_problem.md new file mode 100644 index 000000000..95680bedd --- /dev/null +++ b/ja/docs/chapter_divide_and_conquer/hanota_problem.md @@ -0,0 +1,318 @@ +--- +comments: true +--- + +# 12.4 ハノイの塔問題 + +マージソートと二分木構築の両方で、元の問題を2つの部分問題に分解し、それぞれが元の問題のサイズの半分でした。しかし、ハノイの塔では、異なる分解戦略を採用します。 + +!!! question + + 3つの柱があり、それぞれ `A`、`B`、`C` と表記されます。最初、柱 `A` には $n$ 枚の円盤があり、上から下に向かって昇順のサイズで配置されています。私たちのタスクは、これらの $n$ 枚の円盤を柱 `C` に移動し、元の順序を維持することです(以下の図に示すように)。移動中には以下のルールが適用されます: + + 1. 円盤は柱の上部からのみ取り除くことができ、別の柱の上部に置く必要があります。 + 2. 一度に移動できるのは1枚の円盤のみです。 + 3. 小さい円盤は常に大きい円盤の上にある必要があります。 + +{ class="animation-figure" } + +図 12-10 ハノイの塔の例
+ +**サイズ $i$ のハノイの塔問題を $f(i)$ と表記します**。例えば、$f(3)$ は3枚の円盤を柱 `A` から柱 `C` に移動することを表します。 + +### 1. 基本ケースを考える + +以下の図に示すように、問題 $f(1)$(円盤が1枚のみ)については、`A` から `C` に直接移動できます。 + +=== "<1>" + { class="animation-figure" } + +=== "<2>" + { class="animation-figure" } + +図 12-11 サイズ1の問題の解
+ +$f(2)$(円盤が2枚)については、**柱 `B` の助けを借りて小さい円盤を大きい円盤の上に保つ**必要があります。以下の図に示すように: + +1. まず、小さい円盤を `A` から `B` に移動します。 +2. 次に、大きい円盤を `A` から `C` に移動します。 +3. 最後に、小さい円盤を `B` から `C` に移動します。 + +=== "<1>" + { class="animation-figure" } + +=== "<2>" + { class="animation-figure" } + +=== "<3>" + { class="animation-figure" } + +=== "<4>" + { class="animation-figure" } + +図 12-12 サイズ2の問題の解
+ +$f(2)$ を解決する過程は次のように要約できます:**`B` の助けを借りて2枚の円盤を `A` から `C` に移動する**。ここで、`C` をターゲット柱、`B` をバッファ柱と呼びます。 + +### 2. 部分問題の分解 + +問題 $f(3)$(つまり、円盤が3枚の場合)については、状況がやや複雑になります。 + +すでに $f(1)$ と $f(2)$ の解が分かっているので、分割統治の観点を採用し、**`A` の上の2枚の円盤を1つの単位として扱い**、以下の図に示すステップを実行できます。これにより、3枚の円盤を `A` から `C` に正常に移動できます。 + +1. `B` をターゲット柱、`C` をバッファ柱として、2枚の円盤を `A` から `B` に移動します。 +2. 残りの円盤を `A` から直接 `C` に移動します。 +3. `C` をターゲット柱、`A` をバッファ柱として、2枚の円盤を `B` から `C` に移動します。 + +=== "<1>" + { class="animation-figure" } + +=== "<2>" + { class="animation-figure" } + +=== "<3>" + { class="animation-figure" } + +=== "<4>" + { class="animation-figure" } + +図 12-13 サイズ3の問題の解
+ +本質的に、**$f(3)$ を2つの $f(2)$ 部分問題と1つの $f(1)$ 部分問題に分解します**。これら3つの部分問題を順次解決することで、元の問題が解決され、部分問題が独立しており、それらの解をマージできることを示しています。 + +ここから、以下の図に示すハノイの塔の分割統治戦略を要約できます。元の問題 $f(n)$ を2つの部分問題 $f(n-1)$ と1つの部分問題 $f(1)$ に分割し、以下の順序でこれら3つの部分問題を解決します: + +1. `C` をバッファとして使用し、$n-1$ 枚の円盤を `A` から `B` に移動します。 +2. 残りの円盤を `A` から直接 `C` に移動します。 +3. `A` をバッファとして使用し、$n-1$ 枚の円盤を `B` から `C` に移動します。 + +各 $f(n-1)$ 部分問題について、**同じ再帰分割を適用でき**、最小の部分問題 $f(1)$ に到達するまで続けます。$f(1)$ は単一の移動のみが必要であることがすでに分かっているため、解決するのは簡単です。 + +{ class="animation-figure" } + +図 12-14 ハノイの塔を解決するための分割統治戦略
+ +### 3. コード実装 + +コードでは、再帰関数 `dfs(i, src, buf, tar)` を定義します。これは柱 `src` から上の $i$ 枚の円盤を柱 `tar` に移動し、柱 `buf` をバッファとして使用します: + +=== "Python" + + ```python title="hanota.py" + def move(src: list[int], tar: list[int]): + """円盤を移動""" + # src の上から円盤を取り出す + pan = src.pop() + # 円盤を tar の上に置く + tar.append(pan) + + def dfs(i: int, src: list[int], buf: list[int], tar: list[int]): + """ハノイの塔問題 f(i) を解く""" + # src に円盤が 1 つだけ残っている場合、それを tar に移動 + if i == 1: + move(src, tar) + return + # 部分問題 f(i-1):tar の助けを借りて src の上の i-1 個の円盤を buf に移動 + dfs(i - 1, src, tar, buf) + # 部分問題 f(1):残りの 1 個の円盤を src から tar に移動 + move(src, tar) + # 部分問題 f(i-1):src の助けを借りて buf の上の i-1 個の円盤を tar に移動 + dfs(i - 1, buf, src, tar) + + def solve_hanota(A: list[int], B: list[int], C: list[int]): + """ハノイの塔問題を解く""" + n = len(A) + # B の助けを借りて A の上の n 個の円盤を C に移動 + dfs(n, A, B, C) + ``` + +=== "C++" + + ```cpp title="hanota.cpp" + /* 円盤を移動 */ + void move(vector図 12-15 ハノイの塔の再帰木
+ +!!! quote + + ハノイの塔は古代の伝説に由来します。古代インドの寺院で、僧侶たちは3本の高いダイヤモンドの柱と、異なるサイズの $64$ 枚の金の円盤を持っていました。彼らは、最後の円盤が正しく置かれたとき、世界が終わると信じていました。 + + しかし、僧侶たちが1秒に1枚の円盤を移動したとしても、約 $2^{64} \approx 1.84×10^{19}$ —約5850億年—かかり、宇宙の年齢の現在の推定をはるかに超えています。したがって、この伝説が真実であれば、世界の終わりについて心配する必要はおそらくないでしょう。 diff --git a/ja/docs/chapter_divide_and_conquer/index.md b/ja/docs/chapter_divide_and_conquer/index.md new file mode 100644 index 000000000..54dd83d0a --- /dev/null +++ b/ja/docs/chapter_divide_and_conquer/index.md @@ -0,0 +1,22 @@ +--- +comments: true +icon: material/set-split +--- + +# 第 12 章 分割統治 + +{ class="cover-image" } + +!!! abstract + + 困難な問題は層を重ねて分解され、各分解によってより単純になります。 + + 分割統治は深い真理を明らかにします:単純さから始めれば、複雑さは解決される。 + +## 章の内容 + +- [12.1 分割統治アルゴリズム](divide_and_conquer.md) +- [12.2 分割統治探索戦略](binary_search_recur.md) +- [12.3 二分木構築問題](build_binary_tree_problem.md) +- [12.4 ハノイの塔問題](hanota_problem.md) +- [12.5 まとめ](summary.md) diff --git a/ja/docs/chapter_divide_and_conquer/summary.md b/ja/docs/chapter_divide_and_conquer/summary.md new file mode 100644 index 000000000..4a3b3b222 --- /dev/null +++ b/ja/docs/chapter_divide_and_conquer/summary.md @@ -0,0 +1,15 @@ +--- +comments: true +--- + +# 12.5 まとめ + +- 分割統治は一般的なアルゴリズム設計戦略で、分割(分割)と統治(マージ)の2つの段階から構成され、一般的に再帰を使用して実装されます。 +- 問題が分割統治アプローチに適しているかどうかを判断するために、問題が分解可能かどうか、部分問題が独立しているかどうか、部分問題をマージできるかどうかを確認します。 +- マージソートは分割統治戦略の典型的な例です。配列を再帰的に2つの等しい長さの副配列に分割し、1つの要素のみが残るまで続け、次にこれらの副配列を層ごとにマージしてソートを完了します。 +- 分割統治戦略の導入は、しばしばアルゴリズムの効率を向上させます。一方では操作数を減らし、他方では分割後のシステムの並列最適化を促進します。 +- 分割統治は多数のアルゴリズム問題に適用でき、データ構造とアルゴリズム設計で広く使用され、多くのシナリオに現れます。 +- 総当たり検索と比較して、適応検索はより効率的です。時間計算量が $O(\log n)$ の検索アルゴリズムは、通常分割統治戦略に基づいています。 +- 二分探索は分割統治戦略のもう一つの古典的な応用です。部分問題の解のマージを含まず、再帰的な分割統治アプローチで実装できます。 +- 二分木構築問題では、木の構築(元の問題)を左の部分木と右の部分木の構築(部分問題)に分割できます。これは前順走査と中順走査のインデックス範囲を分割することで実現できます。 +- ハノイの塔問題では、サイズ $n$ の問題をサイズ $n-1$ の2つの部分問題とサイズ $1$ の1つの部分問題に分解できます。これら3つの部分問題を順次解決することで、元の問題が解決されます。 diff --git a/ja/docs/chapter_dynamic_programming/dp_problem_features.md b/ja/docs/chapter_dynamic_programming/dp_problem_features.md new file mode 100644 index 000000000..990a7b5e5 --- /dev/null +++ b/ja/docs/chapter_dynamic_programming/dp_problem_features.md @@ -0,0 +1,476 @@ +--- +comments: true +--- + +# 14.2 動的プログラミング問題の特徴 + +前のセクションでは、動的プログラミングが問題を部分問題に分解することで元の問題を解決する方法を学びました。実際、部分問題の分解は一般的なアルゴリズムアプローチであり、分割統治法、動的プログラミング、バックトラッキングでは異なる重点があります。 + +- 分割統治法アルゴリズムは元の問題を複数の独立した部分問題に再帰的に分割し、最小の部分問題に到達するまで続け、バックトラッキング時に部分問題の解を組み合わせて最終的に元の問題の解を得ます。 +- 動的プログラミングも問題を再帰的に分解しますが、分割統治法アルゴリズムとの主な違いは、動的プログラミングの部分問題が相互依存的であり、分解プロセス中に多くの重複する部分問題が現れることです。 +- バックトラッキングアルゴリズムは試行錯誤によってすべての可能な解を網羅し、枝刈りによって不必要な探索分岐を避けます。元の問題の解は一連の決定ステップから構成され、各決定ステップ前の各部分シーケンスを部分問題として考えることができます。 + +実際、動的プログラミングは最適化問題を解決するためによく使用され、これらは重複する部分問題を含むだけでなく、他に2つの主要な特徴があります:最適部分構造と無記憶性です。 + +## 14.2.1 最適部分構造 + +階段登り問題を少し修正して、最適部分構造の概念を実証するのにより適したものにします。 + +!!! question "階段登りの最小コスト" + + 階段があり、一度に1段または2段上ることができ、階段の各段にはその段で支払う必要があるコストを表す非負の整数があります。非負の整数配列 $cost$ が与えられ、$cost[i]$ は $i$ 段目で支払う必要があるコストを表し、$cost[0]$ は地面(開始点)です。頂上に到達するために必要な最小コストは何ですか? + +下の図に示すように、1段目、2段目、3段目のコストがそれぞれ $1$、$10$、$1$ の場合、地面から3段目に登る最小コストは $2$ です。 + +{ class="animation-figure" } + +図 14-6 3段目に登る最小コスト
+ +$dp[i]$ を $i$ 段目に登る累積コストとします。$i$ 段目は $i-1$ 段目または $i-2$ 段目からのみ来ることができるため、$dp[i]$ は $dp[i-1] + cost[i]$ または $dp[i-2] + cost[i]$ のいずれかしかありえません。コストを最小化するために、2つのうち小さい方を選択すべきです: + +$$ +dp[i] = \min(dp[i-1], dp[i-2]) + cost[i] +$$ + +これにより最適部分構造の意味がわかります:**元の問題の最適解は部分問題の最適解から構築される**。 + +この問題は明らかに最適部分構造を持っています:2つの部分問題 $dp[i-1]$ と $dp[i-2]$ の最適解からより良い方を選択し、それを使用して元の問題 $dp[i]$ の最適解を構築します。 + +では、前のセクションの階段登り問題は最適部分構造を持っているでしょうか?その目標は解の数を求めることで、これは数え上げ問題のようですが、別の方法で尋ねてみましょう:「解の最大数を求める」。驚くことに、**問題が変わったにもかかわらず、最適部分構造が現れた**ことがわかります:$n$ 段目での解の最大数は、$n-1$ 段目と $n-2$ 段目での解の最大数の和に等しいです。したがって、最適部分構造の解釈は非常に柔軟で、異なる問題では異なる意味を持ちます。 + +状態遷移方程式と初期状態 $dp[1] = cost[1]$ および $dp[2] = cost[2]$ に従って、動的プログラミングコードを得ることができます: + +=== "Python" + + ```python title="min_cost_climbing_stairs_dp.py" + def min_cost_climbing_stairs_dp(cost: list[int]) -> int: + """最小コスト階段登り:動的プログラミング""" + n = len(cost) - 1 + if n == 1 or n == 2: + return cost[n] + # dp テーブルを初期化、部分問題の解を格納するために使用 + dp = [0] * (n + 1) + # 初期状態:最小の部分問題の解を事前設定 + dp[1], dp[2] = cost[1], cost[2] + # 状態遷移:小さい部分問題から大きい部分問題を段階的に解く + for i in range(3, n + 1): + dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i] + return dp[n] + ``` + +=== "C++" + + ```cpp title="min_cost_climbing_stairs_dp.cpp" + /* 最小コスト階段登り:動的プログラミング */ + int minCostClimbingStairsDP(vector図 14-7 階段登りの最小コストの動的プログラミングプロセス
+ +この問題も空間最適化が可能で、1次元を0に圧縮し、空間計算量を $O(n)$ から $O(1)$ に削減できます: + +=== "Python" + + ```python title="min_cost_climbing_stairs_dp.py" + def min_cost_climbing_stairs_dp_comp(cost: list[int]) -> int: + """最小コスト階段登り:空間最適化動的プログラミング""" + n = len(cost) - 1 + if n == 1 or n == 2: + return cost[n] + a, b = cost[1], cost[2] + for i in range(3, n + 1): + a, b = b, min(a, b) + cost[i] + return b + ``` + +=== "C++" + + ```cpp title="min_cost_climbing_stairs_dp.cpp" + /* 最小コスト階段登り:空間最適化動的プログラミング */ + int minCostClimbingStairsDPComp(vector図 14-8 制約付きで3段目に登る実行可能な選択肢の数
+ +この問題では、前回が1段ジャンプだった場合、次回は必ず2段ジャンプでなければなりません。これは**次のステップの選択が現在の状態(現在の階段段数)だけでは独立して決定できず、前の状態(前回の階段段数)にも依存する**ことを意味します。 + +この問題がもはや無記憶性を満たさず、状態遷移方程式 $dp[i] = dp[i-1] + dp[i-2]$ も失敗することは容易にわかります。なぜなら $dp[i-1]$ は今回の1段ジャンプを表しますが、多くの「前回が1段ジャンプだった」選択肢を含んでおり、制約を満たすためにはこれらを直接 $dp[i]$ に含めることはできません。 + +このため、状態定義を拡張する必要があります:**状態 $[i, j]$ は $i$ 段目にいて、前回が $j$ 段ジャンプだったことを表す**。ここで $j \in \{1, 2\}$ です。この状態定義は前回が1段ジャンプだったか2段ジャンプだったかを効果的に区別し、現在の状態がどこから来たかを適切に判断できます。 + +- 前回が1段ジャンプだった場合、前々回は必ず2段ジャンプを選択していたはずです。つまり、$dp[i, 1]$ は $dp[i-1, 2]$ からのみ遷移できます。 +- 前回が2段ジャンプだった場合、前々回は1段ジャンプまたは2段ジャンプを選択できました。つまり、$dp[i, 2]$ は $dp[i-2, 1]$ または $dp[i-2, 2]$ から遷移できます。 + +下の図に示すように、$dp[i, j]$ は状態 $[i, j]$ の解の数を表します。この時点で、状態遷移方程式は次のようになります: + +$$ +\begin{cases} +dp[i, 1] = dp[i-1, 2] \\ +dp[i, 2] = dp[i-2, 1] + dp[i-2, 2] +\end{cases} +$$ + +{ class="animation-figure" } + +図 14-9 制約を考慮した再帰関係
+ +最終的に、$dp[n, 1] + dp[n, 2]$ を返せばよく、この2つの合計が $n$ 段目に登る解の総数を表します: + +=== "Python" + + ```python title="climbing_stairs_constraint_dp.py" + def climbing_stairs_constraint_dp(n: int) -> int: + """制約付き階段登り:動的プログラミング""" + if n == 1 or n == 2: + return 1 + # dp テーブルを初期化、部分問題の解を格納するために使用 + dp = [[0] * 3 for _ in range(n + 1)] + # 初期状態:最小の部分問題の解を事前設定 + dp[1][1], dp[1][2] = 1, 0 + dp[2][1], dp[2][2] = 0, 1 + # 状態遷移:小さい部分問題から大きい部分問題を段階的に解く + for i in range(3, n + 1): + dp[i][1] = dp[i - 1][2] + dp[i][2] = dp[i - 2][1] + dp[i - 2][2] + return dp[n][1] + dp[n][2] + ``` + +=== "C++" + + ```cpp title="climbing_stairs_constraint_dp.cpp" + /* 制約付き階段登り:動的プログラミング */ + int climbingStairsConstraintDP(int n) { + if (n == 1 || n == 2) { + return 1; + } + // DPテーブルを初期化し、部分問題の解を格納するために使用 + vector図 14-10 最小経路和の例データ
+ +**第1ステップ:各ラウンドの決定を考え、状態を定義し、それにより $dp$ テーブルを得る** + +この問題の各ラウンドの決定は、現在のセルから下または右に1ステップ移動することです。現在のセルの行と列のインデックスが $[i, j]$ であると仮定すると、下または右に移動した後、インデックスは $[i+1, j]$ または $[i, j+1]$ になります。したがって、状態には2つの変数が含まれるべきです:行インデックスと列インデックス、$[i, j]$ と表記されます。 + +状態 $[i, j]$ は部分問題に対応します:開始点 $[0, 0]$ から $[i, j]$ への最小経路和、$dp[i, j]$ と表記されます。 + +このようにして、下の図に示す二次元 $dp$ 行列を得ます。そのサイズは入力グリッド $grid$ と同じです。 + +{ class="animation-figure" } + +図 14-11 状態定義とDPテーブル
+ +!!! note + + 動的プログラミングとバックトラッキングは決定のシーケンスとして記述でき、状態はすべての決定変数から構成されます。問題解決の進行を記述するすべての変数を含むべきで、次の状態を導出するのに十分な情報を含んでいる必要があります。 + + 各状態は部分問題に対応し、すべての部分問題の解を保存するための $dp$ テーブルを定義します。状態の各独立変数は $dp$ テーブルの次元です。本質的に、$dp$ テーブルは状態と部分問題の解の間のマッピングです。 + +**第2ステップ:最適部分構造を特定し、状態遷移方程式を導出する** + +状態 $[i, j]$ について、それは上のセル $[i-1, j]$ または左のセル $[i, j-1]$ からのみ導出できます。したがって、最適部分構造は:$[i, j]$ に到達する最小経路和は、$[i, j-1]$ と $[i-1, j]$ の最小経路和の小さい方によって決定されます。 + +上記の分析に基づいて、下の図に示す状態遷移方程式を導出できます: + +$$ +dp[i, j] = \min(dp[i-1, j], dp[i, j-1]) + grid[i, j] +$$ + +{ class="animation-figure" } + +図 14-12 最適部分構造と状態遷移方程式
+ +!!! note + + 定義された $dp$ テーブルに基づいて、元の問題と部分問題の関係を考え、部分問題の最適解から元の問題の最適解をどのように構築するか、つまり最適部分構造を見つけます。 + + 最適部分構造を特定したら、それを使用して状態遷移方程式を構築できます。 + +**第3ステップ:境界条件と状態遷移順序を決定する** + +この問題では、最初の行の状態は左の状態からのみ来ることができ、最初の列の状態は上の状態からのみ来ることができるため、最初の行 $i = 0$ と最初の列 $j = 0$ が境界条件です。 + +下の図に示すように、各セルは左のセルと上のセルから導出されるため、ループを使用して行列を走査し、外側のループは行を反復し、内側のループは列を反復します。 + +{ class="animation-figure" } + +図 14-13 境界条件と状態遷移順序
+ +!!! note + + 境界条件は動的プログラミングで $dp$ テーブルを初期化するために使用され、探索では枝刈りに使用されます。 + + 状態遷移順序の核心は、現在の問題の解を計算するとき、それが依存するすべての小さな部分問題が既に正しく計算されていることを確保することです。 + +上記の分析に基づいて、動的プログラミングコードを直接書くことができます。しかし、部分問題の分解はトップダウンアプローチであるため、「力任せ探索 → メモ化探索 → 動的プログラミング」の順序で実装することが習慣的な思考により適合します。 + +### 1. 方法1:力任せ探索 + +状態 $[i, j]$ から探索を開始し、それを常により小さな状態 $[i-1, j]$ と $[i, j-1]$ に分解します。再帰関数には以下の要素が含まれます。 + +- **再帰パラメータ**:状態 $[i, j]$。 +- **戻り値**:$[0, 0]$ から $[i, j]$ への最小経路和 $dp[i, j]$。 +- **終了条件**:$i = 0$ かつ $j = 0$ のとき、コスト $grid[0, 0]$ を返す。 +- **枝刈り**:$i < 0$ または $j < 0$ でインデックスが範囲外のとき、コスト $+\infty$ を返し、実行不可能性を表す。 + +実装コードは以下の通りです: + +=== "Python" + + ```python title="min_path_sum.py" + def min_path_sum_dfs(grid: list[list[int]], i: int, j: int) -> int: + """最小パス和:ブルートフォース探索""" + # 左上のセルの場合、探索を終了 + if i == 0 and j == 0: + return grid[0][0] + # 行または列のインデックスが範囲外の場合、+∞ コストを返す + if i < 0 or j < 0: + return inf + # 左上から (i-1, j) と (i, j-1) への最小パスコストを計算 + up = min_path_sum_dfs(grid, i - 1, j) + left = min_path_sum_dfs(grid, i, j - 1) + # 左上から (i, j) への最小パスコストを返す + return min(left, up) + grid[i][j] + ``` + +=== "C++" + + ```cpp title="min_path_sum.cpp" + /* 最小パス和:ブルートフォース探索 */ + int minPathSumDFS(vector図 14-14 力任せ探索の再帰木
+ +各状態には下と右の2つの選択があるため、左上隅から右下隅までの総ステップ数は $m + n - 2$ で、最悪時間計算量は $O(2^{m + n})$ です。この計算方法はグリッドエッジ近くの状況を考慮していないことに注意してください。ネットワークエッジに到達したとき、選択肢が1つしか残らないため、実際のパス数はより少なくなります。 + +### 2. 方法2:メモ化探索 + +グリッド `grid` と同じサイズのメモリスト `mem` を導入し、様々な部分問題の解を記録し、重複する部分問題を枝刈りします: + +=== "Python" + + ```python title="min_path_sum.py" + def min_path_sum_dfs_mem( + grid: list[list[int]], mem: list[list[int]], i: int, j: int + ) -> int: + """最小パス和:記憶化探索""" + # 左上のセルの場合、探索を終了 + if i == 0 and j == 0: + return grid[0][0] + # 行または列のインデックスが範囲外の場合、+∞ コストを返す + if i < 0 or j < 0: + return inf + # 記録がある場合、それを返す + if mem[i][j] != -1: + return mem[i][j] + # 左と上のセルからの最小パスコスト + up = min_path_sum_dfs_mem(grid, mem, i - 1, j) + left = min_path_sum_dfs_mem(grid, mem, i, j - 1) + # 左上から (i, j) への最小パスコストを記録して返す + mem[i][j] = min(left, up) + grid[i][j] + return mem[i][j] + ``` + +=== "C++" + + ```cpp title="min_path_sum.cpp" + [class]{}-[func]{minPathSumDFSMem} + ``` + +=== "Java" + + ```java title="min_path_sum.java" + /* 最小パス和:メモ化探索 */ + int minPathSumDFSMem(int[][] grid, int[][] mem, int i, int j) { + // 左上のセルの場合、探索を終了 + if (i == 0 && j == 0) { + return grid[0][0]; + } + // 行または列のインデックスが範囲外の場合、+∞ のコストを返す + if (i < 0 || j < 0) { + return Integer.MAX_VALUE; + } + // 記録がある場合、それを返す + if (mem[i][j] != -1) { + return mem[i][j]; + } + // 左と上のセルからの最小パスコスト + int up = minPathSumDFSMem(grid, mem, i - 1, j); + int left = minPathSumDFSMem(grid, mem, i, j - 1); + // 左上から (i, j) への最小パスコストを記録して返す + mem[i][j] = Math.min(left, up) + grid[i][j]; + return mem[i][j]; + } + ``` + +=== "C#" + + ```csharp title="min_path_sum.cs" + [class]{min_path_sum}-[func]{MinPathSumDFSMem} + ``` + +=== "Go" + + ```go title="min_path_sum.go" + [class]{}-[func]{minPathSumDFSMem} + ``` + +=== "Swift" + + ```swift title="min_path_sum.swift" + [class]{}-[func]{minPathSumDFSMem} + ``` + +=== "JS" + + ```javascript title="min_path_sum.js" + [class]{}-[func]{minPathSumDFSMem} + ``` + +=== "TS" + + ```typescript title="min_path_sum.ts" + [class]{}-[func]{minPathSumDFSMem} + ``` + +=== "Dart" + + ```dart title="min_path_sum.dart" + [class]{}-[func]{minPathSumDFSMem} + ``` + +=== "Rust" + + ```rust title="min_path_sum.rs" + [class]{}-[func]{min_path_sum_dfs_mem} + ``` + +=== "C" + + ```c title="min_path_sum.c" + [class]{}-[func]{minPathSumDFSMem} + ``` + +=== "Kotlin" + + ```kotlin title="min_path_sum.kt" + [class]{}-[func]{minPathSumDFSMem} + ``` + +=== "Ruby" + + ```ruby title="min_path_sum.rb" + [class]{}-[func]{min_path_sum_dfs_mem} + ``` + +=== "Zig" + + ```zig title="min_path_sum.zig" + [class]{}-[func]{minPathSumDFSMem} + ``` + +下の図に示すように、メモ化を導入した後、すべての部分問題の解は一度だけ計算される必要があるため、時間計算量は状態の総数、つまりグリッドサイズ $O(nm)$ に依存します。 + +{ class="animation-figure" } + +図 14-15 メモ化探索の再帰木
+ +### 3. 方法3:動的プログラミング + +動的プログラミング解法を反復的に実装します。コードは以下の通りです: + +=== "Python" + + ```python title="min_path_sum.py" + def min_path_sum_dp(grid: list[list[int]]) -> int: + """最小パス和:動的プログラミング""" + n, m = len(grid), len(grid[0]) + # dp テーブルを初期化 + dp = [[0] * m for _ in range(n)] + dp[0][0] = grid[0][0] + # 状態遷移:最初の行 + for j in range(1, m): + dp[0][j] = dp[0][j - 1] + grid[0][j] + # 状態遷移:最初の列 + for i in range(1, n): + dp[i][0] = dp[i - 1][0] + grid[i][0] + # 状態遷移:残りの行と列 + for i in range(1, n): + for j in range(1, m): + dp[i][j] = min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j] + return dp[n - 1][m - 1] + ``` + +=== "C++" + + ```cpp title="min_path_sum.cpp" + /* 最小パス和:動的プログラミング */ + int minPathSumDP(vector図 14-16 最小経路和の動的プログラミングプロセス
+ +### 4. 空間最適化 + +各セルは左と上のセルのみに関連するため、単一行配列を使用して $dp$ テーブルを実装できます。 + +配列 `dp` は1行の状態のみを表現できるため、最初の列の状態を事前に初期化できず、各行を走査するときに更新することに注意してください: + +=== "Python" + + ```python title="min_path_sum.py" + def min_path_sum_dp_comp(grid: list[list[int]]) -> int: + """最小パス和:空間最適化動的プログラミング""" + n, m = len(grid), len(grid[0]) + # dp テーブルを初期化 + dp = [0] * m + # 状態遷移:最初の行 + dp[0] = grid[0][0] + for j in range(1, m): + dp[j] = dp[j - 1] + grid[0][j] + # 状態遷移:残りの行 + for i in range(1, n): + # 状態遷移:最初の列 + dp[0] = dp[0] + grid[i][0] + # 状態遷移:残りの列 + for j in range(1, m): + dp[j] = min(dp[j - 1], dp[j]) + grid[i][j] + return dp[m - 1] + ``` + +=== "C++" + + ```cpp title="min_path_sum.cpp" + [class]{}-[func]{minPathSumDPComp} + ``` + +=== "Java" + + ```java title="min_path_sum.java" + /* 最小パス和:空間最適化動的プログラミング */ + int minPathSumDPComp(int[][] grid) { + int n = grid.length, m = grid[0].length; + // DPテーブルを初期化 + int[] dp = new int[m]; + // 状態遷移:最初の行 + dp[0] = grid[0][0]; + for (int j = 1; j < m; j++) { + dp[j] = dp[j - 1] + grid[0][j]; + } + // 状態遷移:残りの行 + for (int i = 1; i < n; i++) { + // 状態遷移:最初の列 + dp[0] = dp[0] + grid[i][0]; + // 状態遷移:残りの列 + for (int j = 1; j < m; j++) { + dp[j] = Math.min(dp[j - 1], dp[j]) + grid[i][j]; + } + } + return dp[m - 1]; + } + ``` + +=== "C#" + + ```csharp title="min_path_sum.cs" + [class]{min_path_sum}-[func]{MinPathSumDPComp} + ``` + +=== "Go" + + ```go title="min_path_sum.go" + [class]{}-[func]{minPathSumDPComp} + ``` + +=== "Swift" + + ```swift title="min_path_sum.swift" + [class]{}-[func]{minPathSumDPComp} + ``` + +=== "JS" + + ```javascript title="min_path_sum.js" + [class]{}-[func]{minPathSumDPComp} + ``` + +=== "TS" + + ```typescript title="min_path_sum.ts" + [class]{}-[func]{minPathSumDPComp} + ``` + +=== "Dart" + + ```dart title="min_path_sum.dart" + [class]{}-[func]{minPathSumDPComp} + ``` + +=== "Rust" + + ```rust title="min_path_sum.rs" + [class]{}-[func]{min_path_sum_dp_comp} + ``` + +=== "C" + + ```c title="min_path_sum.c" + [class]{}-[func]{minPathSumDPComp} + ``` + +=== "Kotlin" + + ```kotlin title="min_path_sum.kt" + [class]{}-[func]{minPathSumDPComp} + ``` + +=== "Ruby" + + ```ruby title="min_path_sum.rb" + [class]{}-[func]{min_path_sum_dp_comp} + ``` + +=== "Zig" + + ```zig title="min_path_sum.zig" + [class]{}-[func]{minPathSumDPComp} + ``` diff --git a/ja/docs/chapter_dynamic_programming/edit_distance_problem.md b/ja/docs/chapter_dynamic_programming/edit_distance_problem.md new file mode 100644 index 000000000..bb1b59c28 --- /dev/null +++ b/ja/docs/chapter_dynamic_programming/edit_distance_problem.md @@ -0,0 +1,416 @@ +--- +comments: true +--- + +# 14.6 編集距離問題 + +編集距離は、レーベンシュタイン距離とも呼ばれ、一つの文字列を別の文字列に変換するために必要な最小修正回数を指し、情報検索や自然言語処理で2つのシーケンス間の類似度を測定するためによく使用されます。 + +!!! question + + 2つの文字列 $s$ と $t$ が与えられたとき、$s$ を $t$ に変換するために必要な最小編集回数を返してください。 + + 文字列に対して3種類の編集を実行できます:文字の挿入、文字の削除、または文字を他の任意の文字に置換。 + +下の図に示すように、`kitten` を `sitting` に変換するには3回の編集が必要で、2回の置換と1回の挿入を含みます。`hello` を `algo` に変換するには3ステップが必要で、2回の置換と1回の削除を含みます。 + +{ class="animation-figure" } + +図 14-27 編集距離の例データ
+ +**編集距離問題は決定木モデルで自然に説明できます**。文字列は木のノードに対応し、1ラウンドの決定(編集操作)は木のエッジに対応します。 + +下の図に示すように、操作に制限がない場合、各ノードは多くのエッジを導出でき、それぞれが1つの操作に対応するため、`hello` を `algo` に変換する可能な経路は多数あります。 + +決定木の観点から、この問題の目標は、ノード `hello` とノード `algo` の間の最短経路を見つけることです。 + +{ class="animation-figure" } + +図 14-28 決定木モデルに基づいて表現された編集距離問題
+ +### 1. 動的プログラミングアプローチ + +**ステップ1:各ラウンドの決定を考え、状態を定義し、それにより $dp$ テーブルを得る** + +各ラウンドの決定は、文字列 $s$ に対して1つの編集操作を実行することを含みます。 + +編集プロセス中に問題のサイズを段階的に縮小することを目指し、これにより部分問題を構築できます。文字列 $s$ と $t$ の長さをそれぞれ $n$ と $m$ とします。まず、両方の文字列の末尾文字 $s[n-1]$ と $t[m-1]$ を考慮します。 + +- $s[n-1]$ と $t[m-1]$ が同じ場合、それらをスキップして直接 $s[n-2]$ と $t[m-2]$ を考慮できます。 +- $s[n-1]$ と $t[m-1]$ が異なる場合、$s$ に対して1つの編集(挿入、削除、置換)を実行して、2つの文字列の末尾文字を一致させ、それらをスキップしてより小規模な問題を考慮できるようにする必要があります。 + +したがって、文字列 $s$ での各ラウンドの決定(編集操作)は、$s$ と $t$ でマッチされる残りの文字を変更します。したがって、状態は $s$ と $t$ で現在考慮されている $i$ 番目と $j$ 番目の文字であり、$[i, j]$ と表記されます。 + +状態 $[i, j]$ は部分問題に対応します:**$s$ の最初の $i$ 文字を $t$ の最初の $j$ 文字に変更するために必要な最小編集回数**。 + +これから、サイズ $(i+1) \times (j+1)$ の二次元 $dp$ テーブルを得ます。 + +**ステップ2:最適部分構造を特定し、状態遷移方程式を導出する** + +部分問題 $dp[i, j]$ を考慮すると、これに対応する2つの文字列の末尾文字は $s[i-1]$ と $t[j-1]$ であり、下の図に示すように3つのシナリオに分けることができます。 + +1. $s[i-1]$ の後に $t[j-1]$ を追加すると、残りの部分問題は $dp[i, j-1]$ です。 +2. $s[i-1]$ を削除すると、残りの部分問題は $dp[i-1, j]$ です。 +3. $s[i-1]$ を $t[j-1]$ に置換すると、残りの部分問題は $dp[i-1, j-1]$ です。 + +{ class="animation-figure" } + +図 14-29 編集距離の状態遷移
+ +上記の分析に基づいて、最適部分構造を決定できます:$dp[i, j]$ の最小編集回数は、$dp[i, j-1]$、$dp[i-1, j]$、$dp[i-1, j-1]$ の中の最小値に編集ステップ $1$ を加えたものです。対応する状態遷移方程式は: + +$$ +dp[i, j] = \min(dp[i, j-1], dp[i-1, j], dp[i-1, j-1]) + 1 +$$ + +注意してください。**$s[i-1]$ と $t[j-1]$ が同じ場合、現在の文字に対して編集は必要ありません**。この場合、状態遷移方程式は: + +$$ +dp[i, j] = dp[i-1, j-1] +$$ + +**ステップ3:境界条件と状態遷移の順序を決定する** + +両方の文字列が空の場合、編集回数は $0$ です。つまり、$dp[0, 0] = 0$ です。$s$ が空で $t$ が空でない場合、最小編集回数は $t$ の長さに等しく、つまり最初の行 $dp[0, j] = j$ です。$s$ が空でなく $t$ が空の場合、最小編集回数は $s$ の長さに等しく、つまり最初の列 $dp[i, 0] = i$ です。 + +状態遷移方程式を観察すると、$dp[i, j]$ の解決は左、上、左上の解に依存するため、二重ループを使用して正しい順序で $dp$ テーブル全体を走査できます。 + +### 2. コード実装 + +=== "Python" + + ```python title="edit_distance.py" + def edit_distance_dp(s: str, t: str) -> int: + """編集距離:動的プログラミング""" + n, m = len(s), len(t) + dp = [[0] * (m + 1) for _ in range(n + 1)] + # 状態遷移:最初の行と最初の列 + for i in range(1, n + 1): + dp[i][0] = i + for j in range(1, m + 1): + dp[0][j] = j + # 状態遷移:残りの行と列 + for i in range(1, n + 1): + for j in range(1, m + 1): + if s[i - 1] == t[j - 1]: + # 2 つの文字が等しい場合、これら 2 つの文字をスキップ + dp[i][j] = dp[i - 1][j - 1] + else: + # 最小編集数 = 3 つの操作(挿入、削除、置換)からの最小編集数 + 1 + dp[i][j] = min(dp[i][j - 1], dp[i - 1][j], dp[i - 1][j - 1]) + 1 + return dp[n][m] + ``` + +=== "C++" + + ```cpp title="edit_distance.cpp" + /* 編集距離:動的プログラミング */ + int editDistanceDP(string s, string t) { + int n = s.length(), m = t.length(); + vector図 14-30 編集距離の動的プログラミングプロセス
+ +### 3. 空間最適化 + +$dp[i, j]$ は上の $dp[i-1, j]$、左の $dp[i, j-1]$、左上の $dp[i-1, j-1]$ の解から導出され、直接走査では左上の解 $dp[i-1, j-1]$ が失われ、逆走査では事前に $dp[i, j-1]$ を構築できないため、どちらの走査順序も実行可能ではありません。 + +この理由で、変数 `leftup` を使用して左上の $dp[i-1, j-1]$ からの解を一時的に保存し、左と上の解のみを考慮すればよくなります。この状況は無制限ナップサック問題と似ており、直接走査が可能です。コードは以下の通りです: + +=== "Python" + + ```python title="edit_distance.py" + def edit_distance_dp_comp(s: str, t: str) -> int: + """編集距離:空間最適化動的プログラミング""" + n, m = len(s), len(t) + dp = [0] * (m + 1) + # 状態遷移:最初の行 + for j in range(1, m + 1): + dp[j] = j + # 状態遷移:残りの行 + for i in range(1, n + 1): + # 状態遷移:最初の列 + leftup = dp[0] # dp[i-1, j-1] を一時的に保存 + dp[0] += 1 + # 状態遷移:残りの列 + for j in range(1, m + 1): + temp = dp[j] + if s[i - 1] == t[j - 1]: + # 2 つの文字が等しい場合、これら 2 つの文字をスキップ + dp[j] = leftup + else: + # 最小編集数 = 3 つの操作(挿入、削除、置換)からの最小編集数 + 1 + dp[j] = min(dp[j - 1], dp[j], leftup) + 1 + leftup = temp # 次の dp[i-1, j-1] のために更新 + return dp[m] + ``` + +=== "C++" + + ```cpp title="edit_distance.cpp" + [class]{}-[func]{editDistanceDPComp} + ``` + +=== "Java" + + ```java title="edit_distance.java" + /* 編集距離:空間最適化動的プログラミング */ + int editDistanceDPComp(String s, String t) { + int n = s.length(), m = t.length(); + int[] dp = new int[m + 1]; + // 状態遷移:最初の行 + for (int j = 1; j <= m; j++) { + dp[j] = j; + } + // 状態遷移:残りの行 + for (int i = 1; i <= n; i++) { + // 状態遷移:最初の列 + int leftup = dp[0]; // dp[i-1, j-1] を一時的に格納 + dp[0] = i; + // 状態遷移:残りの列 + for (int j = 1; j <= m; j++) { + int temp = dp[j]; + if (s.charAt(i - 1) == t.charAt(j - 1)) { + // 2つの文字が等しい場合、これら2つの文字をスキップ + dp[j] = leftup; + } else { + // 最小編集数 = 3つの操作(挿入、削除、置換)からの最小編集数 + 1 + dp[j] = Math.min(Math.min(dp[j - 1], dp[j]), leftup) + 1; + } + leftup = temp; // 次のラウンドの dp[i-1, j-1] のために更新 + } + } + return dp[m]; + } + ``` + +=== "C#" + + ```csharp title="edit_distance.cs" + [class]{edit_distance}-[func]{EditDistanceDPComp} + ``` + +=== "Go" + + ```go title="edit_distance.go" + [class]{}-[func]{editDistanceDPComp} + ``` + +=== "Swift" + + ```swift title="edit_distance.swift" + [class]{}-[func]{editDistanceDPComp} + ``` + +=== "JS" + + ```javascript title="edit_distance.js" + [class]{}-[func]{editDistanceDPComp} + ``` + +=== "TS" + + ```typescript title="edit_distance.ts" + [class]{}-[func]{editDistanceDPComp} + ``` + +=== "Dart" + + ```dart title="edit_distance.dart" + [class]{}-[func]{editDistanceDPComp} + ``` + +=== "Rust" + + ```rust title="edit_distance.rs" + [class]{}-[func]{edit_distance_dp_comp} + ``` + +=== "C" + + ```c title="edit_distance.c" + [class]{}-[func]{editDistanceDPComp} + ``` + +=== "Kotlin" + + ```kotlin title="edit_distance.kt" + [class]{}-[func]{editDistanceDPComp} + ``` + +=== "Ruby" + + ```ruby title="edit_distance.rb" + [class]{}-[func]{edit_distance_dp_comp} + ``` + +=== "Zig" + + ```zig title="edit_distance.zig" + [class]{}-[func]{editDistanceDPComp} + ``` diff --git a/ja/docs/chapter_dynamic_programming/index.md b/ja/docs/chapter_dynamic_programming/index.md new file mode 100644 index 000000000..87eb91db5 --- /dev/null +++ b/ja/docs/chapter_dynamic_programming/index.md @@ -0,0 +1,24 @@ +--- +comments: true +icon: material/table-pivot +--- + +# 第 14 章 動的プログラミング + +{ class="cover-image" } + +!!! abstract + + 川が流れて海に注ぐように、 + + 動的プログラミングは小さな問題の解を織り合わせて、より大きな問題の解へと導きます。一歩一歩進んで、最終的な答えが待つ彼岸へと向かいます。 + +## 章の内容 + +- [14.1 動的プログラミング入門](intro_to_dynamic_programming.md) +- [14.2 DP問題の特性](dp_problem_features.md) +- [14.3 DP問題解決アプローチ](dp_solution_pipeline.md) +- [14.4 0-1ナップサック問題](knapsack_problem.md) +- [14.5 無制限ナップサック問題](unbounded_knapsack_problem.md) +- [14.6 編集距離問題](edit_distance_problem.md) +- [14.7 まとめ](summary.md) diff --git a/ja/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md b/ja/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md new file mode 100644 index 000000000..ff4bdbe18 --- /dev/null +++ b/ja/docs/chapter_dynamic_programming/intro_to_dynamic_programming.md @@ -0,0 +1,821 @@ +--- +comments: true +--- + +# 14.1 動的プログラミングの紹介 + +動的プログラミングは重要なアルゴリズムパラダイムであり、問題を一連の小さな部分問題に分解し、これらの部分問題の解を保存することで冗長な計算を避け、時間効率を大幅に向上させます。 + +このセクションでは、古典的な問題から始めて、まず力任せの探索法による解法を提示し、重複する部分問題を特定してから、より効率的な動的プログラミング解法を段階的に導出します。 + +!!! question "階段登り" + + $n$ 段の階段があり、一度に $1$ 段または $2$ 段上ることができます。頂上に到達する方法は何通りありますか? + +下の図に示すように、$3$ 段の階段の頂上に到達する方法は $3$ 通りあります。 + +{ class="animation-figure" } + +図 14-1 3段目に到達する方法の数
+ +この問題は**バックトラッキングを用いてすべての可能性を網羅**することで方法の数を計算することを目的としています。具体的には、階段登りの問題を多段階選択プロセスとして考えます:地面から始めて、毎回 $1$ 段または $2$ 段上るかを選択し、階段の頂上に到達したら方法の数をカウントし、頂上を超えた場合はプルーニング(枝刈り)を行います。コードは以下の通りです: + +=== "Python" + + ```python title="climbing_stairs_backtrack.py" + def backtrack(choices: list[int], state: int, n: int, res: list[int]) -> int: + """バックトラッキング""" + # n 段目に登ったとき、解の数に 1 を加える + if state == n: + res[0] += 1 + # すべての選択肢を走査 + for choice in choices: + # 枝刈り:n 段を超えて登ることを許可しない + if state + choice > n: + continue + # 試行:選択を行い、状態を更新 + backtrack(choices, state + choice, n, res) + # 撤回 + + def climbing_stairs_backtrack(n: int) -> int: + """階段登り:バックトラッキング""" + choices = [1, 2] # 1 段または 2 段登ることを選択可能 + state = 0 # 0 段目から登り始める + res = [0] # res[0] を使用して解の数を記録 + backtrack(choices, state, n, res) + return res[0] + ``` + +=== "C++" + + ```cpp title="climbing_stairs_backtrack.cpp" + /* バックトラッキング */ + void backtrack(vector図 14-2 解の数の再帰関係
+ +再帰式に従って力任せ探索解法を得ることができます。$dp[n]$ から始めて、**より大きな問題を再帰的に2つの小さな部分問題の和に分解**し、解が既知の最小の部分問題 $dp[1]$ と $dp[2]$ に到達するまで続けます。$dp[1] = 1$ と $dp[2] = 2$ で、それぞれ1段目と2段目に登る方法が $1$ 通りと $2$ 通りあることを表します。 + +以下のコードを観察すると、標準的なバックトラッキングコードと同様に深さ優先探索に属しますが、より簡潔です: + +=== "Python" + + ```python title="climbing_stairs_dfs.py" + def dfs(i: int) -> int: + """探索""" + # 既知の dp[1] と dp[2] は、それらを返す + if i == 1 or i == 2: + return i + # dp[i] = dp[i-1] + dp[i-2] + count = dfs(i - 1) + dfs(i - 2) + return count + + def climbing_stairs_dfs(n: int) -> int: + """階段登り:探索""" + return dfs(n) + ``` + +=== "C++" + + ```cpp title="climbing_stairs_dfs.cpp" + /* 探索 */ + int dfs(int i) { + // 既知の dp[1] と dp[2] を返す + if (i == 1 || i == 2) + return i; + // dp[i] = dp[i-1] + dp[i-2] + int count = dfs(i - 1) + dfs(i - 2); + return count; + } + + /* 階段登り:探索 */ + int climbingStairsDFS(int n) { + return dfs(n); + } + ``` + +=== "Java" + + ```java title="climbing_stairs_dfs.java" + /* 探索 */ + int dfs(int i) { + // 既知の dp[1] と dp[2] を返す + if (i == 1 || i == 2) + return i; + // dp[i] = dp[i-1] + dp[i-2] + int count = dfs(i - 1) + dfs(i - 2); + return count; + } + + /* 階段登り:探索 */ + int climbingStairsDFS(int n) { + return dfs(n); + } + ``` + +=== "C#" + + ```csharp title="climbing_stairs_dfs.cs" + [class]{climbing_stairs_dfs}-[func]{DFS} + + [class]{climbing_stairs_dfs}-[func]{ClimbingStairsDFS} + ``` + +=== "Go" + + ```go title="climbing_stairs_dfs.go" + [class]{}-[func]{dfs} + + [class]{}-[func]{climbingStairsDFS} + ``` + +=== "Swift" + + ```swift title="climbing_stairs_dfs.swift" + [class]{}-[func]{dfs} + + [class]{}-[func]{climbingStairsDFS} + ``` + +=== "JS" + + ```javascript title="climbing_stairs_dfs.js" + [class]{}-[func]{dfs} + + [class]{}-[func]{climbingStairsDFS} + ``` + +=== "TS" + + ```typescript title="climbing_stairs_dfs.ts" + [class]{}-[func]{dfs} + + [class]{}-[func]{climbingStairsDFS} + ``` + +=== "Dart" + + ```dart title="climbing_stairs_dfs.dart" + [class]{}-[func]{dfs} + + [class]{}-[func]{climbingStairsDFS} + ``` + +=== "Rust" + + ```rust title="climbing_stairs_dfs.rs" + [class]{}-[func]{dfs} + + [class]{}-[func]{climbing_stairs_dfs} + ``` + +=== "C" + + ```c title="climbing_stairs_dfs.c" + [class]{}-[func]{dfs} + + [class]{}-[func]{climbingStairsDFS} + ``` + +=== "Kotlin" + + ```kotlin title="climbing_stairs_dfs.kt" + [class]{}-[func]{dfs} + + [class]{}-[func]{climbingStairsDFS} + ``` + +=== "Ruby" + + ```ruby title="climbing_stairs_dfs.rb" + [class]{}-[func]{dfs} + + [class]{}-[func]{climbing_stairs_dfs} + ``` + +=== "Zig" + + ```zig title="climbing_stairs_dfs.zig" + [class]{}-[func]{dfs} + + [class]{}-[func]{climbingStairsDFS} + ``` + +下の図は力任せ探索によって形成される再帰木を示しています。問題 $dp[n]$ について、その再帰木の深さは $n$ で、時間計算量は $O(2^n)$ です。この指数的増加により、$n$ が大きいとプログラムの実行がはるかに遅くなり、長い待機時間が生じます。 + +{ class="animation-figure" } + +図 14-3 階段登りの再帰木
+ +上の図を観察すると、**指数時間計算量は「重複する部分問題」によって引き起こされる**ことがわかります。例えば、$dp[9]$ は $dp[8]$ と $dp[7]$ に分解され、$dp[8]$ はさらに $dp[7]$ と $dp[6]$ に分解され、両方とも部分問題 $dp[7]$ を含んでいます。 + +このように、部分問題にはさらに小さな重複する部分問題が含まれ、これは無限に続きます。計算リソースの大部分がこれらの重複する部分問題に浪費されています。 + +## 14.1.2 方法2:メモ化探索 + +アルゴリズムの効率を向上させるため、**すべての重複する部分問題を一度だけ計算したい**と考えます。この目的のため、各部分問題の解を記録する配列 `mem` を宣言し、探索プロセス中に重複する部分問題を枝刈りします。 + +1. $dp[i]$ が初めて計算されるとき、後で使用するために `mem[i]` に記録します。 +2. $dp[i]$ を再度計算する必要があるとき、`mem[i]` から直接結果を取得でき、その部分問題の冗長な計算を避けられます。 + +コードは以下の通りです: + +=== "Python" + + ```python title="climbing_stairs_dfs_mem.py" + def dfs(i: int, mem: list[int]) -> int: + """記憶化探索""" + # 既知の dp[1] と dp[2] は、それらを返す + if i == 1 or i == 2: + return i + # dp[i] の記録がある場合、それを返す + if mem[i] != -1: + return mem[i] + # dp[i] = dp[i-1] + dp[i-2] + count = dfs(i - 1, mem) + dfs(i - 2, mem) + # dp[i] を記録 + mem[i] = count + return count + + def climbing_stairs_dfs_mem(n: int) -> int: + """階段登り:記憶化探索""" + # mem[i] は i 段目に登る解の総数を記録、-1 は記録なしを意味する + mem = [-1] * (n + 1) + return dfs(n, mem) + ``` + +=== "C++" + + ```cpp title="climbing_stairs_dfs_mem.cpp" + /* メモ化探索 */ + int dfs(int i, vector図 14-4 メモ化探索による再帰木
+ +## 14.1.3 方法3:動的プログラミング + +**メモ化探索は「トップダウン」方式**です:元の問題(根ノード)から始めて、より大きな部分問題をより小さなものに再帰的に分解し、最小の既知の部分問題(葉ノード)の解に到達するまで続けます。その後、バックトラッキングにより部分問題の解を収集し、元の問題の解を構築します。 + +一方、**動的プログラミングは「ボトムアップ」方式**です:最小の部分問題の解から始めて、元の問題が解決されるまで、より大きな部分問題の解を反復的に構築します。 + +動的プログラミングはバックトラッキングを必要としないため、ループを使った反復のみが必要で、再帰は不要です。以下のコードでは、配列 `dp` を初期化して部分問題の解を保存し、メモ化探索の配列 `mem` と同じ記録機能を果たします: + +=== "Python" + + ```python title="climbing_stairs_dp.py" + def climbing_stairs_dp(n: int) -> int: + """階段登り:動的プログラミング""" + if n == 1 or n == 2: + return n + # dp テーブルを初期化、部分問題の解を格納するため使用 + dp = [0] * (n + 1) + # 初期状態:最小の部分問題の解を事前設定 + dp[1], dp[2] = 1, 2 + # 状態遷移:小さい部分問題から大きい部分問題を段階的に解く + for i in range(3, n + 1): + dp[i] = dp[i - 1] + dp[i - 2] + return dp[n] + ``` + +=== "C++" + + ```cpp title="climbing_stairs_dp.cpp" + /* 階段登り:動的プログラミング */ + int climbingStairsDP(int n) { + if (n == 1 || n == 2) + return n; + // DPテーブルを初期化し、部分問題の解を格納するために使用 + vector図 14-5 階段登りの動的プログラミングプロセス
+ +バックトラッキングアルゴリズムと同様に、動的プログラミングも「状態」の概念を使用して問題解決の特定の段階を表現し、各状態は部分問題とその局所最適解に対応します。例えば、階段登り問題の状態は現在のステップ番号 $i$ として定義されます。 + +上記の内容に基づいて、動的プログラミングでよく使用される用語をまとめることができます。 + +- 配列 `dp` はDPテーブルと呼ばれ、$dp[i]$ は状態 $i$ に対応する部分問題の解を表します。 +- 最小の部分問題(ステップ $1$ と $2$)に対応する状態は初期状態と呼ばれます。 +- 再帰式 $dp[i] = dp[i-1] + dp[i-2]$ は状態遷移方程式と呼ばれます。 + +## 14.1.4 空間最適化 + +注意深い読者は**$dp[i]$ は $dp[i-1]$ と $dp[i-2]$ のみに関連するため、すべての部分問題の解を保存するために配列 `dp` を使用する必要がない**ことに気づくでしょう。単に2つの変数を使って反復的に進めることができます。コードは以下の通りです: + +=== "Python" + + ```python title="climbing_stairs_dp.py" + def climbing_stairs_dp_comp(n: int) -> int: + """階段登り:空間最適化動的プログラミング""" + if n == 1 or n == 2: + return n + a, b = 1, 2 + for _ in range(3, n + 1): + a, b = b, a + b + return b + ``` + +=== "C++" + + ```cpp title="climbing_stairs_dp.cpp" + /* 階段登り:空間最適化動的プログラミング */ + int climbingStairsDPComp(int n) { + if (n == 1 || n == 2) + return n; + int a = 1, b = 2; + for (int i = 3; i <= n; i++) { + int tmp = b; + b = a + b; + a = tmp; + } + return b; + } + ``` + +=== "Java" + + ```java title="climbing_stairs_dp.java" + /* 階段登り:空間最適化動的プログラミング */ + int climbingStairsDPComp(int n) { + if (n == 1 || n == 2) + return n; + int a = 1, b = 2; + for (int i = 3; i <= n; i++) { + int tmp = b; + b = a + b; + a = tmp; + } + return b; + } + ``` + +=== "C#" + + ```csharp title="climbing_stairs_dp.cs" + [class]{climbing_stairs_dp}-[func]{ClimbingStairsDPComp} + ``` + +=== "Go" + + ```go title="climbing_stairs_dp.go" + [class]{}-[func]{climbingStairsDPComp} + ``` + +=== "Swift" + + ```swift title="climbing_stairs_dp.swift" + [class]{}-[func]{climbingStairsDPComp} + ``` + +=== "JS" + + ```javascript title="climbing_stairs_dp.js" + [class]{}-[func]{climbingStairsDPComp} + ``` + +=== "TS" + + ```typescript title="climbing_stairs_dp.ts" + [class]{}-[func]{climbingStairsDPComp} + ``` + +=== "Dart" + + ```dart title="climbing_stairs_dp.dart" + [class]{}-[func]{climbingStairsDPComp} + ``` + +=== "Rust" + + ```rust title="climbing_stairs_dp.rs" + [class]{}-[func]{climbing_stairs_dp_comp} + ``` + +=== "C" + + ```c title="climbing_stairs_dp.c" + [class]{}-[func]{climbingStairsDPComp} + ``` + +=== "Kotlin" + + ```kotlin title="climbing_stairs_dp.kt" + [class]{}-[func]{climbingStairsDPComp} + ``` + +=== "Ruby" + + ```ruby title="climbing_stairs_dp.rb" + [class]{}-[func]{climbing_stairs_dp_comp} + ``` + +=== "Zig" + + ```zig title="climbing_stairs_dp.zig" + [class]{}-[func]{climbingStairsDPComp} + ``` + +上記のコードを観察すると、配列 `dp` が占有していた空間が削除されるため、空間計算量は $O(n)$ から $O(1)$ に削減されます。 + +多くの動的プログラミング問題では、現在の状態は限られた数の前の状態のみに依存するため、必要な状態のみを保持し、「次元削減」によってメモリ空間を節約できます。**この空間最適化技術は「ローリング変数」または「ローリング配列」として知られています**。 diff --git a/ja/docs/chapter_dynamic_programming/knapsack_problem.md b/ja/docs/chapter_dynamic_programming/knapsack_problem.md new file mode 100644 index 000000000..e1d05370b --- /dev/null +++ b/ja/docs/chapter_dynamic_programming/knapsack_problem.md @@ -0,0 +1,679 @@ +--- +comments: true +--- + +# 14.4 0-1ナップサック問題 + +ナップサック問題は動的プログラミングの優れた入門問題であり、動的プログラミングで最も一般的な問題タイプです。0-1ナップサック問題、無制限ナップサック問題、複数ナップサック問題など、多くの変種があります。 + +このセクションでは、まず最も一般的な0-1ナップサック問題を解決します。 + +!!! question + + $n$ 個のアイテムが与えられ、$i$ 番目のアイテムの重量は $wgt[i-1]$ で値は $val[i-1]$ です。容量が $cap$ のナップサックがあります。各アイテムは1回のみ選択できます。容量制限下でナップサックに入れることができるアイテムの最大値は何ですか? + +下の図を観察すると、アイテム番号 $i$ は1から数え始め、配列インデックスは0から始まるため、アイテム $i$ の重量は $wgt[i-1]$ に対応し、値は $val[i-1]$ に対応します。 + +{ class="animation-figure" } + +図 14-17 0-1ナップサックの例データ
+ +0-1ナップサック問題を $n$ ラウンドの決定から構成されるプロセスとして考えることができます。各アイテムについて入れない、または入れるという2つの決定があり、したがって問題は決定木モデルに適合します。 + +この問題の目的は「限られた容量の下でナップサックに入れることができるアイテムの値を最大化する」ことであり、動的プログラミング問題である可能性が高いです。 + +**第1ステップ:各ラウンドの決定を考え、状態を定義し、それにより $dp$ テーブルを得る** + +各アイテムについて、ナップサックに入れなければ容量は変わらず、入れれば容量は減少します。これから状態定義を得ることができます:現在のアイテム番号 $i$ とナップサック容量 $c$、$[i, c]$ と表記されます。 + +状態 $[i, c]$ は部分問題に対応します:**容量 $c$ のナップサックでの最初の $i$ 個のアイテムの最大値**、$dp[i, c]$ と表記されます。 + +探している解は $dp[n, cap]$ であるため、サイズ $(n+1) \times (cap+1)$ の二次元 $dp$ テーブルが必要です。 + +**第2ステップ:最適部分構造を特定し、状態遷移方程式を導出する** + +アイテム $i$ の決定を行った後、残るのは最初の $i-1$ 個のアイテムの決定の部分問題であり、これは2つのケースに分けることができます。 + +- **アイテム $i$ を入れない**:ナップサック容量は変わらず、状態は $[i-1, c]$ に変わります。 +- **アイテム $i$ を入れる**:ナップサック容量は $wgt[i-1]$ だけ減少し、値は $val[i-1]$ だけ増加し、状態は $[i-1, c-wgt[i-1]]$ に変わります。 + +上記の分析により、この問題の最適部分構造が明らかになります:**最大値 $dp[i, c]$ は、アイテム $i$ を入れない方案とアイテム $i$ を入れる方案の2つのうち、より大きな値に等しい**。これから状態遷移方程式を導出できます: + +$$ +dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1]) +$$ + +現在のアイテムの重量 $wgt[i - 1]$ が残りのナップサック容量 $c$ を超える場合、唯一の選択肢はナップサックに入れないことであることに注意することが重要です。 + +**第3ステップ:境界条件と状態遷移の順序を決定する** + +アイテムがない場合またはナップサック容量が $0$ の場合、最大値は $0$ です。つまり、最初の列 $dp[i, 0]$ と最初の行 $dp[0, c]$ はどちらも $0$ に等しいです。 + +現在の状態 $[i, c]$ は直接上の状態 $[i-1, c]$ と左上の状態 $[i-1, c-wgt[i-1]]$ から遷移するため、2層のループを通じて $dp$ テーブル全体を順序通りに走査します。 + +上記の分析に従って、次に力任せ探索、メモ化探索、動的プログラミングの順序で解法を実装します。 + +### 1. 方法1:力任せ探索 + +探索コードには以下の要素が含まれます。 + +- **再帰パラメータ**:状態 $[i, c]$。 +- **戻り値**:部分問題 $dp[i, c]$ の解。 +- **終了条件**:アイテム番号が範囲外 $i = 0$ またはナップサックの残り容量が $0$ のとき、再帰を終了し値 $0$ を返す。 +- **枝刈り**:現在のアイテムの重量がナップサックの残り容量を超える場合、唯一の選択肢はナップサックに入れないことです。 + +=== "Python" + + ```python title="knapsack.py" + def knapsack_dfs(wgt: list[int], val: list[int], i: int, c: int) -> int: + """0-1 ナップサック:ブルートフォース探索""" + # すべてのアイテムが選択されたかナップサックに残り容量がない場合、値 0 を返す + if i == 0 or c == 0: + return 0 + # ナップサック容量を超える場合、ナップサックに入れないことしか選択できない + if wgt[i - 1] > c: + return knapsack_dfs(wgt, val, i - 1, c) + # アイテム i を入れないのと入れるのとの最大値を計算 + no = knapsack_dfs(wgt, val, i - 1, c) + yes = knapsack_dfs(wgt, val, i - 1, c - wgt[i - 1]) + val[i - 1] + # 2 つの選択肢のうち大きい値を返す + return max(no, yes) + ``` + +=== "C++" + + ```cpp title="knapsack.cpp" + /* 0-1 ナップサック:ブルートフォース探索 */ + int knapsackDFS(vector図 14-18 0-1ナップサック問題の力任せ探索再帰木
+ +### 2. 方法2:メモ化探索 + +重複する部分問題が一度だけ計算されることを確保するために、部分問題の解を記録するメモ化リスト `mem` を使用します。ここで `mem[i][c]` は $dp[i, c]$ に対応します。 + +メモ化を導入した後、**時間計算量は部分問題の数に依存**し、$O(n \times cap)$ になります。実装コードは以下の通りです: + +=== "Python" + + ```python title="knapsack.py" + def knapsack_dfs_mem( + wgt: list[int], val: list[int], mem: list[list[int]], i: int, c: int + ) -> int: + """0-1 ナップサック:記憶化探索""" + # すべてのアイテムが選択されたかナップサックに残り容量がない場合、値 0 を返す + if i == 0 or c == 0: + return 0 + # 記録がある場合、それを返す + if mem[i][c] != -1: + return mem[i][c] + # ナップサック容量を超える場合、ナップサックに入れないことしか選択できない + if wgt[i - 1] > c: + return knapsack_dfs_mem(wgt, val, mem, i - 1, c) + # アイテム i を入れないのと入れるのとの最大値を計算 + no = knapsack_dfs_mem(wgt, val, mem, i - 1, c) + yes = knapsack_dfs_mem(wgt, val, mem, i - 1, c - wgt[i - 1]) + val[i - 1] + # 2 つの選択肢のうち大きい値を記録して返す + mem[i][c] = max(no, yes) + return mem[i][c] + ``` + +=== "C++" + + ```cpp title="knapsack.cpp" + [class]{}-[func]{knapsackDFSMem} + ``` + +=== "Java" + + ```java title="knapsack.java" + /* 0-1 ナップサック:メモ化探索 */ + int knapsackDFSMem(int[] wgt, int[] val, int[][] mem, int i, int c) { + // すべてのアイテムが選択されたか、ナップサックに残り容量がない場合、値 0 を返す + if (i == 0 || c == 0) { + return 0; + } + // 記録がある場合、それを返す + if (mem[i][c] != -1) { + return mem[i][c]; + } + // ナップサックの容量を超える場合、ナップサックに入れないことしか選択できない + if (wgt[i - 1] > c) { + return knapsackDFSMem(wgt, val, mem, i - 1, c); + } + // アイテム i を入れない場合と入れる場合の最大値を計算 + int no = knapsackDFSMem(wgt, val, mem, i - 1, c); + int yes = knapsackDFSMem(wgt, val, mem, i - 1, c - wgt[i - 1]) + val[i - 1]; + // 2つの選択肢のより大きい値を記録して返す + mem[i][c] = Math.max(no, yes); + return mem[i][c]; + } + ``` + +=== "C#" + + ```csharp title="knapsack.cs" + [class]{knapsack}-[func]{KnapsackDFSMem} + ``` + +=== "Go" + + ```go title="knapsack.go" + [class]{}-[func]{knapsackDFSMem} + ``` + +=== "Swift" + + ```swift title="knapsack.swift" + [class]{}-[func]{knapsackDFSMem} + ``` + +=== "JS" + + ```javascript title="knapsack.js" + [class]{}-[func]{knapsackDFSMem} + ``` + +=== "TS" + + ```typescript title="knapsack.ts" + [class]{}-[func]{knapsackDFSMem} + ``` + +=== "Dart" + + ```dart title="knapsack.dart" + [class]{}-[func]{knapsackDFSMem} + ``` + +=== "Rust" + + ```rust title="knapsack.rs" + [class]{}-[func]{knapsack_dfs_mem} + ``` + +=== "C" + + ```c title="knapsack.c" + [class]{}-[func]{knapsackDFSMem} + ``` + +=== "Kotlin" + + ```kotlin title="knapsack.kt" + [class]{}-[func]{knapsackDFSMem} + ``` + +=== "Ruby" + + ```ruby title="knapsack.rb" + [class]{}-[func]{knapsack_dfs_mem} + ``` + +=== "Zig" + + ```zig title="knapsack.zig" + [class]{}-[func]{knapsackDFSMem} + ``` + +下の図はメモ化探索で枝刈りされる探索分岐を示しています。 + +{ class="animation-figure" } + +図 14-19 0-1ナップサック問題のメモ化探索再帰木
+ +### 3. 方法3:動的プログラミング + +動的プログラミングは本質的に状態遷移中に $dp$ テーブルを埋めることを含みます。コードは下の図に示されています: + +=== "Python" + + ```python title="knapsack.py" + def knapsack_dp(wgt: list[int], val: list[int], cap: int) -> int: + """0-1 ナップサック:動的プログラミング""" + n = len(wgt) + # dp テーブルを初期化 + dp = [[0] * (cap + 1) for _ in range(n + 1)] + # 状態遷移 + for i in range(1, n + 1): + for c in range(1, cap + 1): + if wgt[i - 1] > c: + # ナップサック容量を超える場合、アイテム i を選択しない + dp[i][c] = dp[i - 1][c] + else: + # アイテム i を選択しないのと選択するのとで大きい値 + dp[i][c] = max(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1]) + return dp[n][cap] + ``` + +=== "C++" + + ```cpp title="knapsack.cpp" + /* 0-1 ナップサック:動的プログラミング */ + int knapsackDP(vector図 14-20 0-1ナップサック問題の動的プログラミングプロセス
+ +### 4. 空間最適化 + +各状態は上の行の状態のみに関連するため、2つの配列を使用してローリング前進させ、空間計算量を $O(n^2)$ から $O(n)$ に削減できます。 + +さらに考えてみると、1つの配列だけで空間最適化を達成できるでしょうか?各状態が直接上のセルまたは左上のセルから遷移することが観察できます。配列が1つしかない場合、$i$ 行目の走査を開始するとき、その配列はまだ $i-1$ 行目の状態を保存しています。 + +- 通常の順序で走査する場合、$dp[i, j]$ に走査したとき、左上の $dp[i-1, 1]$ ~ $dp[i-1, j-1]$ の値がすでに上書きされている可能性があり、正しい状態遷移結果を得ることができません。 +- 逆順で走査する場合、上書き問題はなく、状態遷移を正しく実行できます。 + +下の図は単一配列での $i = 1$ 行目から $i = 2$ 行目への遷移プロセスを示しています。通常順序走査と逆順走査の違いについて考えてみてください。 + +=== "<1>" + { class="animation-figure" } + +=== "<2>" + { class="animation-figure" } + +=== "<3>" + { class="animation-figure" } + +=== "<4>" + { class="animation-figure" } + +=== "<5>" + { class="animation-figure" } + +=== "<6>" + { class="animation-figure" } + +図 14-21 0-1ナップサックの空間最適化動的プログラミングプロセス
+ +コード実装では、配列 `dp` の最初の次元 $i$ を削除し、内側のループを逆走査に変更するだけです: + +=== "Python" + + ```python title="knapsack.py" + def knapsack_dp_comp(wgt: list[int], val: list[int], cap: int) -> int: + """0-1 ナップサック:空間最適化動的プログラミング""" + n = len(wgt) + # dp テーブルを初期化 + dp = [0] * (cap + 1) + # 状態遷移 + for i in range(1, n + 1): + # 逆順で走査 + for c in range(cap, 0, -1): + if wgt[i - 1] > c: + # ナップサック容量を超える場合、アイテム i を選択しない + dp[c] = dp[c] + else: + # アイテム i を選択しないのと選択するのとで大きい値 + dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]) + return dp[cap] + ``` + +=== "C++" + + ```cpp title="knapsack.cpp" + /* 0-1 ナップサック:空間最適化動的プログラミング */ + int knapsackDPComp(vector図 14-22 無制限ナップサック問題の例データ
+ +### 1. 動的プログラミングアプローチ + +無制限ナップサック問題は0-1ナップサック問題と非常に似ており、**唯一の違いはアイテムを選択できる回数に制限がないことです**。 + +- 0-1ナップサック問題では、各アイテムは1つしかないため、アイテム $i$ をバックパックに入れた後は、前の $i-1$ 個のアイテムからのみ選択できます。 +- 無制限ナップサック問題では、各アイテムの数量は無制限であるため、アイテム $i$ をバックパックに入れた後も、**前の $i$ 個のアイテムから引き続き選択できます**。 + +無制限ナップサック問題のルールの下で、状態 $[i, c]$ は2つの方法で変化できます。 + +- **アイテム $i$ を入れない**:0-1ナップサック問題と同様に、$[i-1, c]$ に遷移します。 +- **アイテム $i$ を入れる**:0-1ナップサック問題とは異なり、$[i, c-wgt[i-1]]$ に遷移します。 + +したがって、状態遷移方程式は次のようになります: + +$$ +dp[i, c] = \max(dp[i-1, c], dp[i, c - wgt[i-1]] + val[i-1]) +$$ + +### 2. コード実装 + +2つの問題のコードを比較すると、状態遷移が $i-1$ から $i$ に変わり、残りは完全に同一です: + +=== "Python" + + ```python title="unbounded_knapsack.py" + def unbounded_knapsack_dp(wgt: list[int], val: list[int], cap: int) -> int: + """完全ナップサック:動的プログラミング""" + n = len(wgt) + # dp テーブルを初期化 + dp = [[0] * (cap + 1) for _ in range(n + 1)] + # 状態遷移 + for i in range(1, n + 1): + for c in range(1, cap + 1): + if wgt[i - 1] > c: + # ナップサック容量を超える場合、アイテム i を選択しない + dp[i][c] = dp[i - 1][c] + else: + # アイテム i を選択しないのと選択するのとで大きい値 + dp[i][c] = max(dp[i - 1][c], dp[i][c - wgt[i - 1]] + val[i - 1]) + return dp[n][cap] + ``` + +=== "C++" + + ```cpp title="unbounded_knapsack.cpp" + /* 完全ナップサック:動的プログラミング */ + int unboundedKnapsackDP(vector図 14-23 空間最適化後の無制限ナップサック問題の動的プログラミングプロセス
+ +コード実装は非常に簡単で、配列 `dp` の最初の次元を削除するだけです: + +=== "Python" + + ```python title="unbounded_knapsack.py" + def unbounded_knapsack_dp_comp(wgt: list[int], val: list[int], cap: int) -> int: + """完全ナップサック:空間最適化動的プログラミング""" + n = len(wgt) + # dp テーブルを初期化 + dp = [0] * (cap + 1) + # 状態遷移 + for i in range(1, n + 1): + # 順序で走査 + for c in range(1, cap + 1): + if wgt[i - 1] > c: + # ナップサック容量を超える場合、アイテム i を選択しない + dp[c] = dp[c] + else: + # アイテム i を選択しないのと選択するのとで大きい値 + dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]) + return dp[cap] + ``` + +=== "C++" + + ```cpp title="unbounded_knapsack.cpp" + /* 完全ナップサック:空間最適化動的プログラミング */ + int unboundedKnapsackDPComp(vector図 14-24 コイン交換問題の例データ
+ +### 1. 動的プログラミングアプローチ + +**コイン交換は無制限ナップサック問題の特殊ケースと見なすことができ**、以下の類似点と相違点を共有しています。 + +- 2つの問題は互いに変換できます:「アイテム」は「コイン」に対応し、「アイテムの重量」は「コインの額面」に対応し、「バックパックの容量」は「目標金額」に対応します。 +- 最適化目標は逆です:無制限ナップサック問題はアイテムの値を最大化することを目的とし、コイン交換問題はコインの数を最小化することを目的とします。 +- 無制限ナップサック問題はバックパック容量を「超えない」解を求め、コイン交換は目標金額を「正確に」構成する解を求めます。 + +**第1ステップ:各ラウンドの意思決定を考え、状態を定義し、それにより $dp$ テーブルを導出する** + +状態 $[i, a]$ は部分問題に対応します:**最初の $i$ 種類のコインを使用して金額 $a$ を構成できる最小コイン数**、$dp[i, a]$ と表記されます。 + +二次元 $dp$ テーブルのサイズは $(n+1) \times (amt+1)$ です。 + +**第2ステップ:最適部分構造を特定し、状態遷移方程式を導出する** + +この問題は状態遷移方程式の2つの側面で無制限ナップサック問題と異なります。 + +- この問題は最小値を求めるため、演算子 $\max()$ を $\min()$ に変更する必要があります。 +- 最適化はコインの数に焦点を当てているため、コインが選択されたときに単純に $+1$ を追加します。 + +$$ +dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1) +$$ + +**第3ステップ:境界条件と状態遷移順序を定義する** + +目標金額が $0$ の場合、それを構成するのに必要な最小コイン数は $0$ であるため、最初の列のすべての $dp[i, 0]$ は $0$ です。 + +コインがない場合、**任意の金額 >0 を構成することは不可能**であり、これは無効な解です。状態遷移方程式の $\min()$ 関数が無効な解を認識してフィルタリングできるように、$+\infty$ を使用してそれらを表現することを検討し、つまり最初の行のすべての $dp[0, a]$ を $+\infty$ に設定します。 + +### 2. コード実装 + +ほとんどのプログラミング言語は $+\infty$ 変数を提供しておらず、整数 `int` の最大値のみを代替として使用できます。これによりオーバーフローが発生する可能性があります:状態遷移方程式の $+1$ 演算がオーバーフローする可能性があります。 + +この理由で、数値 $amt + 1$ を使用して無効な解を表します。なぜなら、$amt$ を構成するのに必要な最大コイン数は最大でも $amt$ だからです。結果を返す前に、$dp[n, amt]$ が $amt + 1$ に等しいかどうかを確認し、そうであれば $-1$ を返し、目標金額を構成できないことを示します。コードは以下の通りです: + +=== "Python" + + ```python title="coin_change.py" + def coin_change_dp(coins: list[int], amt: int) -> int: + """硬貨交換:動的プログラミング""" + n = len(coins) + MAX = amt + 1 + # dp テーブルを初期化 + dp = [[0] * (amt + 1) for _ in range(n + 1)] + # 状態遷移:最初の行と最初の列 + for a in range(1, amt + 1): + dp[0][a] = MAX + # 状態遷移:残りの行と列 + for i in range(1, n + 1): + for a in range(1, amt + 1): + if coins[i - 1] > a: + # 目標金額を超える場合、硬貨 i を選択しない + dp[i][a] = dp[i - 1][a] + else: + # 硬貨 i を選択しないのと選択するのとで小さい値 + dp[i][a] = min(dp[i - 1][a], dp[i][a - coins[i - 1]] + 1) + return dp[n][amt] if dp[n][amt] != MAX else -1 + ``` + +=== "C++" + + ```cpp title="coin_change.cpp" + /* 硬貨両替:動的プログラミング */ + int coinChangeDP(vector図 14-25 コイン交換問題の動的プログラミングプロセス
+ +### 3. 空間最適化 + +コイン交換問題の空間最適化は無制限ナップサック問題と同じ方法で処理されます: + +=== "Python" + + ```python title="coin_change.py" + def coin_change_dp_comp(coins: list[int], amt: int) -> int: + """硬貨交換:空間最適化動的プログラミング""" + n = len(coins) + MAX = amt + 1 + # dp テーブルを初期化 + dp = [MAX] * (amt + 1) + dp[0] = 0 + # 状態遷移 + for i in range(1, n + 1): + # 順序で走査 + for a in range(1, amt + 1): + if coins[i - 1] > a: + # 目標金額を超える場合、硬貨 i を選択しない + dp[a] = dp[a] + else: + # 硬貨 i を選択しないのと選択するのとで小さい値 + dp[a] = min(dp[a], dp[a - coins[i - 1]] + 1) + return dp[amt] if dp[amt] != MAX else -1 + ``` + +=== "C++" + + ```cpp title="coin_change.cpp" + /* 硬貨両替:空間最適化動的プログラミング */ + int coinChangeDPComp(vector図 14-26 コイン交換問題IIの例データ
+ +### 1. 動的プログラミングアプローチ + +前の問題と比較して、この問題の目標は組み合わせの数を決定することであるため、部分問題は次のようになります:**最初の $i$ 種類のコインを使用して金額 $a$ を構成できる組み合わせの数**。$dp$ テーブルはサイズ $(n+1) \times (amt + 1)$ の二次元行列のまま残ります。 + +現在の状態の組み合わせ数は、現在のコインを選択しない組み合わせと現在のコインを選択する組み合わせの合計です。状態遷移方程式は: + +$$ +dp[i, a] = dp[i-1, a] + dp[i, a - coins[i-1]] +$$ + +目標金額が $0$ の場合、目標金額を構成するのにコインは必要ないため、最初の列のすべての $dp[i, 0]$ は $1$ に初期化されるべきです。コインがない場合、任意の金額 >0 を構成することは不可能であるため、最初の行のすべての $dp[0, a]$ は $0$ に設定されるべきです。 + +### 2. コード実装 + +=== "Python" + + ```python title="coin_change_ii.py" + def coin_change_ii_dp(coins: list[int], amt: int) -> int: + """硬貨交換 II:動的プログラミング""" + n = len(coins) + # dp テーブルを初期化 + dp = [[0] * (amt + 1) for _ in range(n + 1)] + # 最初の列を初期化 + for i in range(n + 1): + dp[i][0] = 1 + # 状態遷移 + for i in range(1, n + 1): + for a in range(1, amt + 1): + if coins[i - 1] > a: + # 目標金額を超える場合、硬貨 i を選択しない + dp[i][a] = dp[i - 1][a] + else: + # 硬貨 i を選択しないのと選択するのとの両方の選択肢の和 + dp[i][a] = dp[i - 1][a] + dp[i][a - coins[i - 1]] + return dp[n][amt] + ``` + +=== "C++" + + ```cpp title="coin_change_ii.cpp" + /* 硬貨両替 II:動的プログラミング */ + int coinChangeIIDP(vector図 9-1 連結リスト、木、グラフの関係
+ +## 9.1.1 グラフの一般的な種類と用語 + +グラフは、辺に方向があるかどうかによって無向グラフと有向グラフに分けることができます(下図参照)。 + +- 無向グラフでは、辺は2つの頂点間の「双方向」接続を表します。例えば、Facebookの「友達」関係です。 +- 有向グラフでは、辺に方向性があります。つまり、辺$A \rightarrow B$と$A \leftarrow B$は互いに独立しています。例えば、InstagramやTikTokの「フォロー」と「フォロワー」の関係です。 + +{ class="animation-figure" } + +図 9-2 有向グラフと無向グラフ
+ +すべての頂点が接続されているかどうかによって、グラフは連結グラフと非連結グラフに分けることができます(下図参照)。 + +- 連結グラフでは、任意の頂点から開始して他の任意の頂点に到達することが可能です。 +- 非連結グラフでは、任意の開始頂点から到達できない頂点が少なくとも1つ存在します。 + +{ class="animation-figure" } + +図 9-3 連結グラフと非連結グラフ
+ +辺に重み変数を追加することもでき、その結果として重み付きグラフが生まれます(下図参照)。例えば、Instagramでは、システムがあなたと他のユーザーとの間の相互作用レベル(いいね、閲覧、コメントなど)によってフォロワーとフォロー中のリストをソートします。このような相互作用ネットワークは重み付きグラフで表現できます。 + +{ class="animation-figure" } + +図 9-4 重み付きグラフと重みなしグラフ
+ +グラフデータ構造には、以下のような一般的に使用される用語があります。 + +- 隣接:2つの頂点を接続する辺がある場合、これら2つの頂点は「隣接」していると言われます。上図では、頂点1の隣接頂点は頂点2、3、5です。 +- パス:頂点Aから頂点Bまでに通過する辺のシーケンスを、AからBへのパスと呼びます。上図では、辺のシーケンス1-5-2-4は頂点1から頂点4へのパスです。 +- 次数:頂点が持つ辺の数です。有向グラフの場合、入次数はその頂点を指す辺の数、出次数はその頂点から出る辺の数を指します。 + +## 9.1.2 グラフの表現 + +グラフの一般的な表現には「隣接行列」と「隣接リスト」があります。以下の例では無向グラフを使用します。 + +### 1. 隣接行列 + +グラフの頂点数を$n$とすると、隣接行列は$n \times n$の行列を使用してグラフを表現します。各行(列)は頂点を表し、行列要素は辺を表し、2つの頂点間に辺があるかどうかを$1$または$0$で示します。 + +下図に示すように、隣接行列を$M$、頂点のリストを$V$とすると、行列要素$M[i, j] = 1$は頂点$V[i]$と頂点$V[j]$の間に辺があることを示し、逆に$M[i, j] = 0$は2つの頂点間に辺がないことを示します。 + +{ class="animation-figure" } + +図 9-5 隣接行列によるグラフの表現
+ +隣接行列には以下の特性があります。 + +- 頂点は自分自身に接続することはできないため、隣接行列の主対角線上の要素は意味がありません。 +- 無向グラフの場合、両方向の辺は等価であるため、隣接行列は主対角線に関して対称です。 +- 隣接行列の要素を$1$と$0$から重みに置き換えることで、重み付きグラフを表現できます。 + +隣接行列でグラフを表現する場合、行列要素に直接アクセスして辺を取得できるため、追加、削除、検索、変更の操作が効率的で、すべて時間計算量$O(1)$です。ただし、行列の空間計算量は$O(n^2)$で、より多くのメモリを消費します。 + +### 2. 隣接リスト + +隣接リストは$n$個の連結リストを使用してグラフを表現し、各連結リストノードは頂点を表します。$i$番目の連結リストは頂点$i$に対応し、すべての隣接頂点(その頂点に接続された頂点)を含みます。下図は隣接リストを使用して格納されたグラフの例を示しています。 + +{ class="animation-figure" } + +図 9-6 隣接リストによるグラフの表現
+ +隣接リストは実際の辺のみを格納し、辺の総数は$n^2$よりもはるかに少ないことが多く、より空間効率的です。ただし、隣接リストで辺を見つけるには連結リストを走査する必要があるため、その時間効率は隣接行列ほど良くありません。 + +上図を観察すると、**隣接リストの構造はハッシュテーブルの「チェイン法」と非常に似ているため、同様の方法を使用して効率を最適化できます**。例えば、連結リストが長い場合、それをAVL木や赤黒木に変換して、時間効率を$O(n)$から$O(\log n)$に最適化できます。連結リストをハッシュテーブルに変換することで、時間計算量を$O(1)$に削減することもできます。 + +## 9.1.3 グラフの一般的な応用 + +下表に示すように、多くの現実世界のシステムはグラフでモデル化でき、対応する問題はグラフ計算問題に削減できます。 + +表 9-1 現実生活の一般的なグラフ
+ +図 9-7 隣接行列での初期化、辺の追加と削除、頂点の追加と削除
+ +以下は隣接行列を使用して表現されたグラフの実装コードです: + +=== "Python" + + ```python title="graph_adjacency_matrix.py" + class GraphAdjMat: + """隣接行列に基づく無向グラフクラス""" + + def __init__(self, vertices: list[int], edges: list[list[int]]): + """コンストラクタ""" + # 頂点リスト、要素は「頂点値」を表し、インデックスは「頂点インデックス」を表す + self.vertices: list[int] = [] + # 隣接行列、行と列のインデックスは「頂点インデックス」に対応 + self.adj_mat: list[list[int]] = [] + # 頂点を追加 + for val in vertices: + self.add_vertex(val) + # 辺を追加 + # edges要素は頂点インデックスを表す + for e in edges: + self.add_edge(e[0], e[1]) + + def size(self) -> int: + """頂点数を取得""" + return len(self.vertices) + + def add_vertex(self, val: int): + """頂点を追加""" + n = self.size() + # 頂点リストに新しい頂点値を追加 + self.vertices.append(val) + # 隣接行列に行を追加 + new_row = [0] * n + self.adj_mat.append(new_row) + # 隣接行列に列を追加 + for row in self.adj_mat: + row.append(0) + + def remove_vertex(self, index: int): + """頂点を削除""" + if index >= self.size(): + raise IndexError() + # 頂点リストから`index`の頂点を削除 + self.vertices.pop(index) + # 隣接行列から`index`の行を削除 + self.adj_mat.pop(index) + # 隣接行列から`index`の列を削除 + for row in self.adj_mat: + row.pop(index) + + def add_edge(self, i: int, j: int): + """辺を追加""" + # パラメータi、jは頂点要素のインデックスに対応 + # インデックスの範囲外と等価性を処理 + if i < 0 or j < 0 or i >= self.size() or j >= self.size() or i == j: + raise IndexError() + # 無向グラフでは、隣接行列は主対角線について対称、すなわち (i, j) == (j, i) を満たす + self.adj_mat[i][j] = 1 + self.adj_mat[j][i] = 1 + + def remove_edge(self, i: int, j: int): + """辺を削除""" + # パラメータi、jは頂点要素のインデックスに対応 + # インデックスの範囲外と等価性を処理 + if i < 0 or j < 0 or i >= self.size() or j >= self.size() or i == j: + raise IndexError() + self.adj_mat[i][j] = 0 + self.adj_mat[j][i] = 0 + + def print(self): + """隣接行列を出力""" + print("頂点リスト =", self.vertices) + print("隣接行列 =") + print_matrix(self.adj_mat) + ``` + +=== "C++" + + ```cpp title="graph_adjacency_matrix.cpp" + /* 隣接行列に基づく無向グラフクラス */ + class GraphAdjMat { + vector