mirror of
https://github.com/krahets/hello-algo.git
synced 2026-07-16 16:56:06 +00:00
Re-translate the Japanese version (#1871)
* Retranslate Japanese docs with GPT-5.4 * Retranslate Japanese code with GPT-5.4
This commit is contained in:
@@ -1,46 +1,46 @@
|
||||
# コントリビューション
|
||||
# 一緒に制作に参加しましょう
|
||||
|
||||
著者の能力に限りがあるため、本書にはいくつかの省略や誤りが避けられません。ご理解をお願いします。誤字、リンク切れ、内容の欠落、文章の曖昧さ、説明の不明確さ、または不合理な文章構造を発見された場合は、読者により良質な学習リソースを提供するため、修正にご協力ください。
|
||||
著者の力には限りがあるため、本書にはどうしても一部の漏れや誤りが含まれる可能性があります。ご了承ください。誤字、リンク切れ、内容の欠落、表現の曖昧さ、説明の不明瞭さ、文章構成の不適切さなどの問題を見つけた場合は、ぜひ修正にご協力ください。読者により良い学習リソースを提供できます。
|
||||
|
||||
すべての[コントリビューター](https://github.com/krahets/hello-algo/graphs/contributors)のGitHub IDは、本書のリポジトリ、ウェブ、PDFバージョンのホームページに表示され、オープンソースコミュニティへの無私の貢献に感謝いたします。
|
||||
すべての[寄稿者](https://github.com/krahets/hello-algo/graphs/contributors)の GitHub ID は、本書のリポジトリ、Web 版、PDF 版のホームページに掲載され、オープンソースコミュニティへの惜しみない貢献に感謝を表します。
|
||||
|
||||
!!! success "オープンソースの魅力"
|
||||
|
||||
紙の本の2つの印刷版の間隔はしばしば長く、内容の更新が非常に不便です。
|
||||
|
||||
しかし、このオープンソースの本では、内容の更新サイクルは数日、さらには数時間に短縮されます。
|
||||
紙の書籍では、2 回の増刷の間隔が長くなりがちで、内容更新は非常に不便です。
|
||||
|
||||
一方、このオープンソース書籍では、内容更新のサイクルは数日、場合によっては数時間にまで短縮されています。
|
||||
|
||||
### 内容の微調整
|
||||
|
||||
下の図に示すように、各ページの右上角に「編集アイコン」があります。以下の手順に従ってテキストやコードを修正できます。
|
||||
以下の図のように、各ページの右上には「編集アイコン」があります。次の手順で本文やコードを修正できます。
|
||||
|
||||
1. 「編集アイコン」をクリックします。「このリポジトリをフォークしますか」と促された場合は、同意してください。
|
||||
2. Markdownソースファイルの内容を修正し、内容の正確性を確認し、フォーマットの一貫性を保つようにしてください。
|
||||
3. ページの下部で修正説明を記入し、「Propose file change」ボタンをクリックします。ページがリダイレクトされた後、「Create pull request」ボタンをクリックしてプルリクエストを開始します。
|
||||
1. 「編集アイコン」をクリックし、「このリポジトリを Fork する必要があります」と表示された場合は、その操作を承認してください。
|
||||
2. Markdown のソースファイルを修正し、内容が正しいことを確認したうえで、できるだけ書式の統一を保ってください。
|
||||
3. ページ下部に修正内容の説明を入力し、その後「Propose file change」ボタンをクリックします。ページ遷移後、「Create pull request」ボタンをクリックするとプルリクエストを作成できます。
|
||||
|
||||

|
||||
|
||||
図は直接修正できないため、新しい[Issue](https://github.com/krahets/hello-algo/issues)を作成するか、問題を説明するコメントが必要です。できるだけ早く図を再描画して置き換えます。
|
||||
画像は直接修正できないため、新しい [Issue](https://github.com/krahets/hello-algo/issues) を作成するかコメントで問題を説明してください。できるだけ早く描き直して差し替えます。
|
||||
|
||||
### 内容の作成
|
||||
### コンテンツ制作
|
||||
|
||||
このオープンソースプロジェクトへの参加に興味がある場合、コードを他のプログラミング言語に翻訳したり、記事の内容を拡張したりすることを含めて、以下のプルリクエストワークフローを実装する必要があります。
|
||||
コードを他のプログラミング言語へ翻訳することや、記事内容を拡充することなど、このオープンソースプロジェクトへの参加に興味がある場合は、以下の Pull Request ワークフローに従ってください。
|
||||
|
||||
1. GitHubにログインし、本書の[コードリポジトリ](https://github.com/krahets/hello-algo)を個人アカウントにフォークします。
|
||||
2. フォークしたリポジトリのウェブページに移動し、`git clone`コマンドを使用してリポジトリをローカルマシンにクローンします。
|
||||
3. ローカルで内容を作成し、完全なテストを実行してコードの正確性を検証します。
|
||||
4. ローカルで行った変更をコミットし、リモートリポジトリにプッシュします。
|
||||
5. リポジトリのウェブページを更新し、「Create pull request」ボタンをクリックしてプルリクエストを開始します。
|
||||
1. GitHub にログインし、本書の[コードリポジトリ](https://github.com/krahets/hello-algo)を個人アカウントに Fork します。
|
||||
2. Fork したリポジトリのページに入り、`git clone` コマンドを使ってリポジトリをローカルにクローンします。
|
||||
3. ローカルでコンテンツを作成し、完全なテストを行ってコードの正しさを確認します。
|
||||
4. ローカルで行った変更を Commit し、その後リモートリポジトリへ Push します。
|
||||
5. リポジトリのページを更新し、「Create pull request」ボタンをクリックするとプルリクエストを作成できます。
|
||||
|
||||
### Dockerデプロイメント
|
||||
### Docker デプロイ
|
||||
|
||||
`hello-algo`ルートディレクトリで、以下のDockerスクリプトを実行して`http://localhost:8000`でプロジェクトにアクセスします:
|
||||
`hello-algo` のルートディレクトリで以下の Docker スクリプトを実行すると、`http://localhost:8000` で本プロジェクトにアクセスできます。
|
||||
|
||||
```shell
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
以下のコマンドを使用してデプロイメントを削除します:
|
||||
以下のコマンドでデプロイを削除できます。
|
||||
|
||||
```shell
|
||||
docker-compose down
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
# インストール
|
||||
# プログラミング環境のインストール
|
||||
|
||||
## IDEのインストール
|
||||
## IDE のインストール
|
||||
|
||||
ローカルの統合開発環境(IDE)として、オープンソースで軽量なVS Codeを使用することをお勧めします。[VS Code公式ウェブサイト](https://code.visualstudio.com/)にアクセスし、お使いのオペレーティングシステムに適したVS Codeのバージョンを選択してダウンロードし、インストールしてください。
|
||||
オープンソースで軽量な VS Code をローカルの統合開発環境(IDE)として使用することを推奨します。[VS Code 公式サイト](https://code.visualstudio.com/) にアクセスし、使用している OS に応じたバージョンの VS Code をダウンロードしてインストールしてください。
|
||||
|
||||

|
||||

|
||||
|
||||
VS Codeには強力な拡張機能エコシステムがあり、ほとんどのプログラミング言語の実行とデバッグをサポートしています。例えば、「Python Extension Pack」をインストールした後、Pythonコードをデバッグできます。インストール手順を下の図に示します。
|
||||
VS Code には強力な拡張機能のエコシステムがあり、ほとんどのプログラミング言語の実行とデバッグをサポートしています。Python を例にすると、「Python Extension Pack」拡張機能をインストールした後、Python コードをデバッグできるようになります。インストール手順を以下に示します。
|
||||
|
||||

|
||||

|
||||
|
||||
## 言語環境のインストール
|
||||
|
||||
### Python環境
|
||||
### 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`を入力して、コードフォーマッティングツールをインストールします。
|
||||
1. [Miniconda3](https://docs.conda.io/en/latest/miniconda.html) をダウンロードしてインストールします。Python 3.10 以降が必要です。
|
||||
2. VS Code の拡張機能マーケットプレイスで `python` を検索し、Python Extension Pack をインストールします。
|
||||
3. (任意)コマンドラインで `pip install black` を入力し、コード整形ツールをインストールします。
|
||||
|
||||
### C/C++環境
|
||||
### 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 }`に設定します。
|
||||
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. (任意)Settings ページを開き、コード整形オプション `Clang_format_fallback Style` を検索して、`{ BasedOnStyle: Microsoft, BreakBeforeBraces: Attach }` に設定します。
|
||||
|
||||
### Java環境
|
||||
### Java 環境
|
||||
|
||||
1. [OpenJDK](https://jdk.java.net/18/)をダウンロードしてインストールします(バージョンはJDK 9より新しい必要があります)。
|
||||
2. VS Code拡張機能マーケットプレイスで`java`を検索し、Extension Pack for Javaをインストールします。
|
||||
1. [OpenJDK](https://jdk.java.net/18/) をダウンロードしてインストールします(バージョンは JDK 9 より新しい必要があります)。
|
||||
2. VS Code の拡張機能マーケットプレイスで `java` を検索し、Extension Pack for Java をインストールします。
|
||||
|
||||
### C#環境
|
||||
### 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))。
|
||||
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))。
|
||||
|
||||
### Go環境
|
||||
### Go 環境
|
||||
|
||||
1. [go](https://go.dev/dl/)をダウンロードしてインストールします。
|
||||
2. VS Code拡張機能マーケットプレイスで`go`を検索し、Goをインストールします。
|
||||
3. `Ctrl + Shift + P`を押してコマンドバーを呼び出し、goと入力し、`Go: Install/Update Tools`を選択し、すべてを選択してインストールします。
|
||||
1. [go](https://go.dev/dl/) をダウンロードしてインストールします。
|
||||
2. VS Code の拡張機能マーケットプレイスで `go` を検索し、Go をインストールします。
|
||||
3. ショートカットキー `Ctrl + Shift + P` を押してコマンドパレットを開き、go と入力して `Go: Install/Update Tools` を選択し、すべてにチェックを入れてインストールします。
|
||||
|
||||
### Swift環境
|
||||
### 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)をインストールします。
|
||||
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) をインストールします。
|
||||
|
||||
### JavaScript環境
|
||||
### JavaScript 環境
|
||||
|
||||
1. [Node.js](https://nodejs.org/en/)をダウンロードしてインストールします。
|
||||
2. (オプション)VS Code拡張機能マーケットプレイスで`Prettier`を検索し、コードフォーマッティングツールをインストールします。
|
||||
1. [Node.js](https://nodejs.org/en/) をダウンロードしてインストールします。
|
||||
2. (任意)VS Code の拡張機能マーケットプレイスで `Prettier` を検索し、コード整形ツールをインストールします。
|
||||
|
||||
### TypeScript環境
|
||||
### 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)をインストールします。
|
||||
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) をインストールします。
|
||||
|
||||
### Dart環境
|
||||
### Dart 環境
|
||||
|
||||
1. [Dart](https://dart.dev/get-dart)をダウンロードしてインストールします。
|
||||
2. VS Code拡張機能マーケットプレイスで`dart`を検索し、[Dart](https://marketplace.visualstudio.com/items?itemName=Dart-Code.dart-code)をインストールします。
|
||||
1. [Dart](https://dart.dev/get-dart) をダウンロードしてインストールします。
|
||||
2. VS Code の拡張機能マーケットプレイスで `dart` を検索し、[Dart](https://marketplace.visualstudio.com/items?itemName=Dart-Code.dart-code) をインストールします。
|
||||
|
||||
### Rust環境
|
||||
### 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)をインストールします。
|
||||
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) をインストールします。
|
||||
|
||||
@@ -1,137 +1,137 @@
|
||||
# 用語集
|
||||
|
||||
下の表は本書に登場する重要な用語をリストアップしており、以下の点に注意する価値があります。
|
||||
以下の表は、本書に登場する重要な用語を一覧にしたものです。特に次の点に注意してください。
|
||||
|
||||
- 英語文献を読みやすくするため、用語の英語名を覚えることをお勧めします。
|
||||
- 一部の用語は簡体字中国語と繁体字中国語で異なる名前を持ちます。
|
||||
- 名詞の英語表現も覚えておくと、英語文献を読む際に役立ちます。
|
||||
- 一部の名詞は、簡体字中国語と繁体字中国語で呼び方が異なります。
|
||||
|
||||
<p align="center"> 表 <id> データ構造とアルゴリズムの重要用語 </p>
|
||||
|
||||
| English | 日本語 | 简体中文 | 繁体中文 |
|
||||
| ------------------------------ | ---------------------- | -------------- | -------------- |
|
||||
| algorithm | アルゴリズム | 算法 | 演算法 |
|
||||
| data structure | データ構造 | 数据结构 | 資料結構 |
|
||||
| code | コード | 代码 | 程式碼 |
|
||||
| file | ファイル | 文件 | 檔案 |
|
||||
| function | 関数 | 函数 | 函式 |
|
||||
| method | メソッド | 方法 | 方法 |
|
||||
| variable | 変数 | 变量 | 變數 |
|
||||
| asymptotic complexity analysis | 漸近計算量解析 | 渐近复杂度分析 | 漸近複雜度分析 |
|
||||
| time complexity | 時間計算量 | 时间复杂度 | 時間複雜度 |
|
||||
| space complexity | 空間計算量 | 空间复杂度 | 空間複雜度 |
|
||||
| loop | ループ | 循环 | 迴圈 |
|
||||
| iteration | 反復 | 迭代 | 迭代 |
|
||||
| recursion | 再帰 | 递归 | 遞迴 |
|
||||
| tail recursion | 末尾再帰 | 尾递归 | 尾遞迴 |
|
||||
| recursion tree | 再帰木 | 递归树 | 遞迴樹 |
|
||||
| big-$O$ notation | ビッグO記法 | 大 $O$ 记号 | 大 $O$ 記號 |
|
||||
| asymptotic upper bound | 漸近上界 | 渐近上界 | 漸近上界 |
|
||||
| sign-magnitude | 符号と絶対値 | 原码 | 原碼 |
|
||||
| 1's complement | 1の補数 | 反码 | 一補數 |
|
||||
| 2's complement | 2の補数 | 补码 | 二補數 |
|
||||
| array | 配列 | 数组 | 陣列 |
|
||||
| index | インデックス | 索引 | 索引 |
|
||||
| linked list | 連結リスト | 链表 | 鏈結串列 |
|
||||
| linked list node, list node | 連結リストノード | 链表节点 | 鏈結串列節點 |
|
||||
| head node | 先頭ノード | 头节点 | 頭節點 |
|
||||
| tail node | 末尾ノード | 尾节点 | 尾節點 |
|
||||
| list | リスト | 列表 | 串列 |
|
||||
| dynamic array | 動的配列 | 动态数组 | 動態陣列 |
|
||||
| hard disk | ハードディスク | 硬盘 | 硬碟 |
|
||||
| random-access memory (RAM) | メモリ | 内存 | 記憶體 |
|
||||
| cache memory | キャッシュメモリ | 缓存 | 快取 |
|
||||
| cache miss | キャッシュミス | 缓存未命中 | 快取未命中 |
|
||||
| cache hit rate | キャッシュヒット率 | 缓存命中率 | 快取命中率 |
|
||||
| stack | スタック | 栈 | 堆疊 |
|
||||
| top of the stack | スタックトップ | 栈顶 | 堆疊頂 |
|
||||
| bottom of the stack | スタックボトム | 栈底 | 堆疊底 |
|
||||
| queue | キュー | 队列 | 佇列 |
|
||||
| double-ended queue | 両端キュー | 双向队列 | 雙向佇列 |
|
||||
| front of the queue | キューの先頭 | 队首 | 佇列首 |
|
||||
| rear of the queue | キューの末尾 | 队尾 | 佇列尾 |
|
||||
| hash table | ハッシュテーブル | 哈希表 | 雜湊表 |
|
||||
| hash set | ハッシュセット | 哈希集合 | 雜湊集合 |
|
||||
| bucket | バケット | 桶 | 桶 |
|
||||
| hash function | ハッシュ関数 | 哈希函数 | 雜湊函式 |
|
||||
| hash collision | ハッシュ衝突 | 哈希冲突 | 雜湊衝突 |
|
||||
| load factor | 負荷率 | 负载因子 | 負載因子 |
|
||||
| separate chaining | チェイン法 | 链式地址 | 鏈結位址 |
|
||||
| open addressing | オープンアドレス法 | 开放寻址 | 開放定址 |
|
||||
| linear probing | 線形プローブ法 | 线性探测 | 線性探查 |
|
||||
| lazy deletion | 遅延削除 | 懒删除 | 懶刪除 |
|
||||
| binary tree | 二分木 | 二叉树 | 二元樹 |
|
||||
| tree node | 木のノード | 树节点 | 樹節點 |
|
||||
| left-child node | 左の子ノード | 左子节点 | 左子節點 |
|
||||
| right-child node | 右の子ノード | 右子节点 | 右子節點 |
|
||||
| parent node | 親ノード | 父节点 | 父節點 |
|
||||
| left subtree | 左の部分木 | 左子树 | 左子樹 |
|
||||
| right subtree | 右の部分木 | 右子树 | 右子樹 |
|
||||
| root node | ルートノード | 根节点 | 根節點 |
|
||||
| leaf node | 葉ノード | 叶节点 | 葉節點 |
|
||||
| edge | エッジ | 边 | 邊 |
|
||||
| level | レベル | 层 | 層 |
|
||||
| degree | 次数 | 度 | 度 |
|
||||
| height | 高さ | 高度 | 高度 |
|
||||
| depth | 深さ | 深度 | 深度 |
|
||||
| perfect binary tree | 完全二分木 | 完美二叉树 | 完美二元樹 |
|
||||
| complete binary tree | 完全二分木 | 完全二叉树 | 完全二元樹 |
|
||||
| full binary tree | 満二分木 | 完满二叉树 | 完滿二元樹 |
|
||||
| balanced binary tree | 平衡二分木 | 平衡二叉树 | 平衡二元樹 |
|
||||
| binary search tree | 二分探索木 | 二叉搜索树 | 二元搜尋樹 |
|
||||
| AVL tree | AVL木 | AVL 树 | AVL 樹 |
|
||||
| red-black tree | 赤黒木 | 红黑树 | 紅黑樹 |
|
||||
| level-order traversal | レベル順走査 | 层序遍历 | 層序走訪 |
|
||||
| breadth-first traversal | 幅優先走査 | 广度优先遍历 | 廣度優先走訪 |
|
||||
| depth-first traversal | 深さ優先走査 | 深度优先遍历 | 深度優先走訪 |
|
||||
| binary search tree | 二分探索木 | 二叉搜索树 | 二元搜尋樹 |
|
||||
| balanced binary search tree | 平衡二分探索木 | 平衡二叉搜索树 | 平衡二元搜尋樹 |
|
||||
| balance factor | 平衡因子 | 平衡因子 | 平衡因子 |
|
||||
| heap | ヒープ | 堆 | 堆積 |
|
||||
| max heap | 最大ヒープ | 大顶堆 | 大頂堆積 |
|
||||
| min heap | 最小ヒープ | 小顶堆 | 小頂堆積 |
|
||||
| priority queue | 優先度キュー | 优先队列 | 優先佇列 |
|
||||
| heapify | ヒープ化 | 堆化 | 堆積化 |
|
||||
| top-$k$ problem | Top-$k$ 問題 | Top-$k$ 问题 | Top-$k$ 問題 |
|
||||
| graph | グラフ | 图 | 圖 |
|
||||
| vertex | 頂点 | 顶点 | 頂點 |
|
||||
| undirected graph | 無向グラフ | 无向图 | 無向圖 |
|
||||
| directed graph | 有向グラフ | 有向图 | 有向圖 |
|
||||
| connected graph | 連結グラフ | 连通图 | 連通圖 |
|
||||
| disconnected graph | 非連結グラフ | 非连通图 | 非連通圖 |
|
||||
| weighted graph | 重み付きグラフ | 有权图 | 有權圖 |
|
||||
| adjacency | 隣接 | 邻接 | 鄰接 |
|
||||
| path | パス | 路径 | 路徑 |
|
||||
| in-degree | 入次数 | 入度 | 入度 |
|
||||
| out-degree | 出次数 | 出度 | 出度 |
|
||||
| adjacency matrix | 隣接行列 | 邻接矩阵 | 鄰接矩陣 |
|
||||
| adjacency list | 隣接リスト | 邻接表 | 鄰接表 |
|
||||
| breadth-first search | 幅優先探索 | 广度优先搜索 | 廣度優先搜尋 |
|
||||
| depth-first search | 深さ優先探索 | 深度优先搜索 | 深度優先搜尋 |
|
||||
| binary search | 二分探索 | 二分查找 | 二分搜尋 |
|
||||
| searching algorithm | 探索アルゴリズム | 搜索算法 | 搜尋演算法 |
|
||||
| sorting algorithm | ソートアルゴリズム | 排序算法 | 排序演算法 |
|
||||
| selection sort | 選択ソート | 选择排序 | 選擇排序 |
|
||||
| bubble sort | バブルソート | 冒泡排序 | 泡沫排序 |
|
||||
| insertion sort | 挿入ソート | 插入排序 | 插入排序 |
|
||||
| quick sort | クイックソート | 快速排序 | 快速排序 |
|
||||
| merge sort | マージソート | 归并排序 | 合併排序 |
|
||||
| heap sort | ヒープソート | 堆排序 | 堆積排序 |
|
||||
| bucket sort | バケットソート | 桶排序 | 桶排序 |
|
||||
| counting sort | 計数ソート | 计数排序 | 計數排序 |
|
||||
| radix sort | 基数ソート | 基数排序 | 基數排序 |
|
||||
| divide and conquer | 分割統治法 | 分治 | 分治 |
|
||||
| hanota problem | ハノイの塔問題 | 汉诺塔问题 | 河內塔問題 |
|
||||
| backtracking algorithm | バックトラッキング | 回溯算法 | 回溯演算法 |
|
||||
| constraint | 制約 | 约束 | 約束 |
|
||||
| solution | 解 | 解 | 解 |
|
||||
| state | 状態 | 状态 | 狀態 |
|
||||
| pruning | 枝刈り | 剪枝 | 剪枝 |
|
||||
| permutations problem | 順列問題 | 全排列问题 | 全排列問題 |
|
||||
| subset-sum problem | 部分集合和問題 | 子集和问题 | 子集合問題 |
|
||||
| $n$-queens problem | $n$ クイーン問題 | $n$ 皇后问题 | $n$ 皇后問題 |
|
||||
| dynamic programming | 動的プログラミング | 动态规划 | 動態規劃 |
|
||||
| initial state | 初期状態 | 初始状态 | 初始狀態 |
|
||||
| state-transition equation | 状態遷移方程式 | 状态转移方程 | 狀態轉移方程 |
|
||||
| knapsack problem | ナップサック問題 | 背包问题 | 背包問題 |
|
||||
| edit distance problem | 編集距離問題 | 编辑距离问题 | 編輯距離問題 |
|
||||
| greedy algorithm | 貪欲アルゴリズム | 贪心算法 | 貪婪演算法 |
|
||||
| English | 日本語 | 日本語 |
|
||||
| ------------------------------ | -------------- | -------------- |
|
||||
| algorithm | アルゴリズム | アルゴリズム |
|
||||
| data structure | データ構造 | データ構造 |
|
||||
| code | コード | コード |
|
||||
| file | ファイル | ファイル |
|
||||
| function | 関数 | 関数 |
|
||||
| method | メソッド | メソッド |
|
||||
| variable | 変数 | 変数 |
|
||||
| asymptotic complexity analysis | 漸近計算量解析 | 漸近計算量解析 |
|
||||
| time complexity | 時間計算量 | 時間計算量 |
|
||||
| space complexity | 空間計算量 | 空間計算量 |
|
||||
| loop | ループ | ループ |
|
||||
| iteration | 反復 | 反復 |
|
||||
| recursion | 再帰 | 再帰 |
|
||||
| tail recursion | 末尾再帰 | 末尾再帰 |
|
||||
| recursion tree | 再帰木 | 再帰木 |
|
||||
| big-$O$ notation | ビッグオー記法 | ビッグオー記法 |
|
||||
| asymptotic upper bound | 漸近上界 | 漸近上界 |
|
||||
| sign-magnitude | 符号絶対値表現 | 符号絶対値表現 |
|
||||
| 1’s complement | 1の補数 | 1の補数 |
|
||||
| 2’s complement | 2の補数 | 2の補数 |
|
||||
| array | 配列 | 配列 |
|
||||
| index | インデックス | インデックス |
|
||||
| linked list | 連結リスト | 連結リスト |
|
||||
| linked list node, list node | 連結リストノード | 連結リストノード |
|
||||
| head node | 先頭ノード | 先頭ノード |
|
||||
| tail node | 末尾ノード | 末尾ノード |
|
||||
| list | リスト | リスト |
|
||||
| dynamic array | 動的配列 | 動的配列 |
|
||||
| hard disk | ハードディスク | ハードディスク |
|
||||
| random-access memory (RAM) | メモリ | メモリ |
|
||||
| cache memory | キャッシュ | キャッシュ |
|
||||
| cache miss | キャッシュミス | キャッシュミス |
|
||||
| cache hit rate | キャッシュヒット率 | キャッシュヒット率 |
|
||||
| stack | スタック | スタック |
|
||||
| top of the stack | スタックトップ | スタックトップ |
|
||||
| bottom of the stack | スタックボトム | スタックボトム |
|
||||
| queue | キュー | キュー |
|
||||
| double-ended queue | 両端キュー | 両端キュー |
|
||||
| front of the queue | キュー先頭 | キュー先頭 |
|
||||
| rear of the queue | キュー末尾 | キュー末尾 |
|
||||
| hash table | ハッシュテーブル | ハッシュテーブル |
|
||||
| hash set | ハッシュ集合 | ハッシュ集合 |
|
||||
| bucket | バケット | バケット |
|
||||
| hash function | ハッシュ関数 | ハッシュ関数 |
|
||||
| hash collision | ハッシュ衝突 | ハッシュ衝突 |
|
||||
| load factor | 負荷率 | 負荷率 |
|
||||
| separate chaining | 連鎖アドレス法 | 連鎖アドレス法 |
|
||||
| open addressing | オープンアドレス法 | オープンアドレス法 |
|
||||
| linear probing | 線形探索 | 線形探索 |
|
||||
| lazy deletion | 遅延削除 | 遅延削除 |
|
||||
| binary tree | 二分木 | 二分木 |
|
||||
| tree node | ノード | ノード |
|
||||
| left-child node | 左子ノード | 左子ノード |
|
||||
| right-child node | 右子ノード | 右子ノード |
|
||||
| parent node | 親ノード | 親ノード |
|
||||
| left subtree | 左部分木 | 左部分木 |
|
||||
| right subtree | 右部分木 | 右部分木 |
|
||||
| root node | 根ノード | 根ノード |
|
||||
| leaf node | 葉ノード | 葉ノード |
|
||||
| edge | 辺 | 辺 |
|
||||
| level | レベル | レベル |
|
||||
| degree | 次数 | 次数 |
|
||||
| height | 高さ | 高さ |
|
||||
| depth | 深さ | 深さ |
|
||||
| perfect binary tree | 完備二分木 | 完備二分木 |
|
||||
| complete binary tree | 完全二分木 | 完全二分木 |
|
||||
| full binary tree | 満二分木 | 満二分木 |
|
||||
| balanced binary tree | 平衡二分木 | 平衡二分木 |
|
||||
| binary search tree | 二分探索木 | 二分探索木 |
|
||||
| AVL tree | AVL 木 | AVL 木 |
|
||||
| red-black tree | 赤黒木 | 赤黒木 |
|
||||
| level-order traversal | レベル順走査 | レベル順走査 |
|
||||
| breadth-first traversal | 幅優先走査 | 幅優先走査 |
|
||||
| depth-first traversal | 深さ優先走査 | 深さ優先走査 |
|
||||
| binary search tree | 二分探索木 | 二分探索木 |
|
||||
| balanced binary search tree | 平衡二分探索木 | 平衡二分探索木 |
|
||||
| balance factor | 平衡係数 | 平衡係数 |
|
||||
| heap | ヒープ | ヒープ |
|
||||
| max heap | 最大ヒープ | 最大ヒープ |
|
||||
| min heap | 最小ヒープ | 最小ヒープ |
|
||||
| priority queue | 優先度付きキュー | 優先度付きキュー |
|
||||
| heapify | ヒープ化 | ヒープ化 |
|
||||
| top-$k$ problem | Top-$k$ 問題 | Top-$k$ 問題 |
|
||||
| graph | グラフ | グラフ |
|
||||
| vertex | 頂点 | 頂点 |
|
||||
| undirected graph | 無向グラフ | 無向グラフ |
|
||||
| directed graph | 有向グラフ | 有向グラフ |
|
||||
| connected graph | 連結グラフ | 連結グラフ |
|
||||
| disconnected graph | 非連結グラフ | 非連結グラフ |
|
||||
| weighted graph | 重み付きグラフ | 重み付きグラフ |
|
||||
| adjacency | 隣接 | 隣接 |
|
||||
| path | 経路 | 経路 |
|
||||
| in-degree | 入次数 | 入次数 |
|
||||
| out-degree | 出次数 | 出次数 |
|
||||
| adjacency matrix | 隣接行列 | 隣接行列 |
|
||||
| adjacency list | 隣接リスト | 隣接リスト |
|
||||
| breadth-first search | 幅優先探索 | 幅優先探索 |
|
||||
| depth-first search | 深さ優先探索 | 深さ優先探索 |
|
||||
| binary search | 二分探索 | 二分探索 |
|
||||
| searching algorithm | 探索アルゴリズム | 探索アルゴリズム |
|
||||
| sorting algorithm | ソートアルゴリズム | ソートアルゴリズム |
|
||||
| selection sort | 選択ソート | 選択ソート |
|
||||
| bubble sort | バブルソート | バブルソート |
|
||||
| insertion sort | 挿入ソート | 挿入ソート |
|
||||
| quick sort | クイックソート | クイックソート |
|
||||
| merge sort | マージソート | マージソート |
|
||||
| heap sort | ヒープソート | ヒープソート |
|
||||
| bucket sort | バケットソート | バケットソート |
|
||||
| counting sort | 計数ソート | 計数ソート |
|
||||
| radix sort | 基数ソート | 基数ソート |
|
||||
| divide and conquer | 分割統治 | 分割統治 |
|
||||
| hanota problem | ハノイの塔問題 | ハノイの塔問題 |
|
||||
| backtracking algorithm | バックトラッキングアルゴリズム | バックトラッキングアルゴリズム |
|
||||
| constraint | 制約 | 制約 |
|
||||
| solution | 解 | 解 |
|
||||
| state | 状態 | 状態 |
|
||||
| pruning | 枝刈り | 枝刈り |
|
||||
| permutations problem | 全順列問題 | 全順列問題 |
|
||||
| subset-sum problem | 部分和問題 | 部分和問題 |
|
||||
| $n$-queens problem | $n$ クイーン問題 | $n$ クイーン問題 |
|
||||
| dynamic programming | 動的計画法 | 動的計画法 |
|
||||
| initial state | 初期状態 | 初期状態 |
|
||||
| state-transition equation | 状態遷移方程式 | 状態遷移方程式 |
|
||||
| knapsack problem | ナップサック問題 | ナップサック問題 |
|
||||
| edit distance problem | 編集距離問題 | 編集距離問題 |
|
||||
| greedy algorithm | 貪欲法 | 貪欲法 |
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
# 配列
|
||||
|
||||
<u>配列</u>は線形データ構造で、同じような項目が並んでいるようなもので、コンピュータのメモリ内の連続した空間に一緒に格納されます。これは整理された格納を維持するシーケンスのようなものです。この並びの各項目には、<u>インデックス</u>として知られる独自の「位置」があります。以下の図を参照して、配列の動作を観察し、これらの重要な用語を理解してください。
|
||||
<u>配列(array)</u>は線形データ構造の一種であり、同じ型の要素を連続したメモリ領域に格納します。要素が配列内にある位置を、その要素の<u>インデックス(index)</u>と呼びます。下図は、配列の主要な概念と格納方式を示しています。
|
||||
|
||||

|
||||

|
||||
|
||||
## 配列の一般的な操作
|
||||
|
||||
### 配列の初期化
|
||||
|
||||
配列は必要に応じて2つの方法で初期化できます:初期値なしまたは指定された初期値付きです。初期値が指定されていない場合、ほとんどのプログラミング言語は配列要素を$0$に設定します:
|
||||
必要に応じて、配列の初期化方法として初期値なしと初期値ありの 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]
|
||||
```
|
||||
@@ -21,11 +21,11 @@
|
||||
=== "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 };
|
||||
```
|
||||
@@ -33,7 +33,7 @@
|
||||
=== "Java"
|
||||
|
||||
```java title="array.java"
|
||||
/* 配列を初期化 */
|
||||
/* 配列を初期化する */
|
||||
int[] arr = new int[5]; // { 0, 0, 0, 0, 0 }
|
||||
int[] nums = { 1, 3, 2, 5, 4 };
|
||||
```
|
||||
@@ -41,7 +41,7 @@
|
||||
=== "C#"
|
||||
|
||||
```csharp title="array.cs"
|
||||
/* 配列を初期化 */
|
||||
/* 配列を初期化する */
|
||||
int[] arr = new int[5]; // [ 0, 0, 0, 0, 0 ]
|
||||
int[] nums = [1, 3, 2, 5, 4];
|
||||
```
|
||||
@@ -49,18 +49,18 @@
|
||||
=== "Go"
|
||||
|
||||
```go title="array.go"
|
||||
/* 配列を初期化 */
|
||||
/* 配列を初期化する */
|
||||
var arr [5]int
|
||||
// Goでは、長さを指定([5]int)すると配列を示し、指定しない([]int)とスライスを示します。
|
||||
// Goの配列はコンパイル時に固定長を持つよう設計されているため、長さの指定には定数のみ使用できます。
|
||||
// extend()メソッドの実装の便宜上、ここではSliceを配列として扱います。
|
||||
// Go では、長さを指定する場合([5]int)は配列であり、長さを指定しない場合([]int)はスライス
|
||||
// Go の配列はコンパイル時に長さが確定するよう設計されているため、長さの指定には定数しか使用できない
|
||||
// 拡張 extend() メソッドを実装しやすくするため、以下ではスライス(Slice)を配列(Array)として扱う
|
||||
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]
|
||||
```
|
||||
@@ -68,7 +68,7 @@
|
||||
=== "JS"
|
||||
|
||||
```javascript title="array.js"
|
||||
/* 配列を初期化 */
|
||||
/* 配列を初期化する */
|
||||
var arr = new Array(5).fill(0);
|
||||
var nums = [1, 3, 2, 5, 4];
|
||||
```
|
||||
@@ -76,7 +76,7 @@
|
||||
=== "TS"
|
||||
|
||||
```typescript title="array.ts"
|
||||
/* 配列を初期化 */
|
||||
/* 配列を初期化する */
|
||||
let arr: number[] = new Array(5).fill(0);
|
||||
let nums: number[] = [1, 3, 2, 5, 4];
|
||||
```
|
||||
@@ -84,7 +84,7 @@
|
||||
=== "Dart"
|
||||
|
||||
```dart title="array.dart"
|
||||
/* 配列を初期化 */
|
||||
/* 配列を初期化する */
|
||||
List<int> arr = List.filled(5, 0); // [0, 0, 0, 0, 0]
|
||||
List<int> nums = [1, 3, 2, 5, 4];
|
||||
```
|
||||
@@ -92,20 +92,20 @@
|
||||
=== "Rust"
|
||||
|
||||
```rust title="array.rs"
|
||||
/* 配列を初期化 */
|
||||
/* 配列を初期化する */
|
||||
let arr: [i32; 5] = [0; 5]; // [0, 0, 0, 0, 0]
|
||||
let slice: &[i32] = &[0; 5];
|
||||
// Rustでは、長さを指定([i32; 5])すると配列を示し、指定しない(&[i32])とスライスを示します。
|
||||
// Rustの配列はコンパイル時に固定長を持つよう設計されているため、長さの指定には定数のみ使用できます。
|
||||
// 一般的にRustでは動的配列としてVectorが使用されます。
|
||||
// extend()メソッドの実装の便宜上、ここではベクターを配列として扱います。
|
||||
// Rust では、長さを指定する場合([i32; 5])は配列であり、長さを指定しない場合(&[i32])はスライス
|
||||
// Rust の配列はコンパイル時に長さが確定するよう設計されているため、長さの指定には定数しか使用できない
|
||||
// Vector は Rust で一般に動的配列として使われる型
|
||||
// 拡張 extend() メソッドを実装しやすくするため、以下では vector を配列(array)として扱う
|
||||
let nums: Vec<i32> = vec![1, 3, 2, 5, 4];
|
||||
```
|
||||
|
||||
=== "C"
|
||||
|
||||
```c title="array.c"
|
||||
/* 配列を初期化 */
|
||||
/* 配列を初期化する */
|
||||
int arr[5] = { 0 }; // { 0, 0, 0, 0, 0 }
|
||||
int nums[5] = { 1, 3, 2, 5, 4 };
|
||||
```
|
||||
@@ -113,18 +113,32 @@
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title="array.kt"
|
||||
|
||||
/* 配列を初期化する */
|
||||
var arr = IntArray(5) // { 0, 0, 0, 0, 0 }
|
||||
var nums = intArrayOf(1, 3, 2, 5, 4)
|
||||
```
|
||||
|
||||
=== "Ruby"
|
||||
|
||||
```ruby title="array.rb"
|
||||
# 配列を初期化する
|
||||
arr = Array.new(5, 0)
|
||||
nums = [1, 3, 2, 5, 4]
|
||||
```
|
||||
|
||||
??? pythontutor "実行の可視化"
|
||||
|
||||
https://pythontutor.com/render.html#code=%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E6%95%B0%E7%BB%84%0Aarr%20%3D%20%5B0%5D%20*%205%20%20%23%20%5B%200,%200,%200,%200,%200%20%5D%0Anums%20%3D%20%5B1,%203,%202,%205,%204%5D&cumulative=false&curInstr=0&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
|
||||
|
||||
### 要素へのアクセス
|
||||
|
||||
配列内の要素は連続したメモリ空間に格納されるため、各要素のメモリアドレスを計算することが簡単になります。以下の図に示されている公式は、配列のメモリアドレス(特に、最初の要素のアドレス)と要素のインデックスを利用して、要素のメモリアドレスを決定するのに役立ちます。この計算により、目的の要素への直接アクセスが合理化されます。
|
||||
配列要素は連続したメモリ領域に格納されるため、要素のメモリアドレスの計算は非常に容易です。配列のメモリアドレス(先頭要素のメモリアドレス)とある要素のインデックスが与えられれば、下図の式を使ってその要素のメモリアドレスを計算でき、直接その要素にアクセスできます。
|
||||
|
||||

|
||||

|
||||
|
||||
上の図で観察されるように、配列のインデックスは慣例的に$0$から始まります。これは直感に反するように見えるかもしれません。数を数えるのは通常$1$から始まるためですが、アドレス計算公式内では、**インデックスは本質的にメモリアドレスからのオフセット**です。最初の要素のアドレスでは、このオフセットは$0$で、そのインデックスが$0$であることを検証しています。
|
||||
上図を見ると、配列の最初の要素のインデックスは $0$ であり、これは少し直感に反するように思えます。というのも、$1$ から数え始めるほうが自然だからです。しかし、アドレス計算式の観点では、**インデックスの本質はメモリアドレスのオフセット**です。先頭要素のアドレスのオフセットは $0$ であるため、そのインデックスが $0$ なのは妥当です。
|
||||
|
||||
配列内の要素へのアクセスは非常に効率的で、$O(1)$時間で任意の要素にランダムアクセスできます。
|
||||
配列では要素へのアクセスは非常に効率的であり、$O(1)$ 時間で任意の要素にランダムアクセスできます。
|
||||
|
||||
```src
|
||||
[file]{array}-[class]{}-[func]{random_access}
|
||||
@@ -132,11 +146,11 @@
|
||||
|
||||
### 要素の挿入
|
||||
|
||||
配列要素はメモリ内で密に詰まっており、それらの間に追加データを収容するための空間はありません。以下の図に示すように、配列の中央に要素を挿入するには、後続のすべての要素を1つずつ後ろにシフトして、新しい要素のための空間を作る必要があります。
|
||||
配列要素はメモリ内で「ぴったり隣接して」おり、その間にほかのデータを格納する余地はありません。下図のように、配列の途中に要素を挿入したい場合は、その要素より後ろにあるすべての要素を 1 つずつ後ろへずらし、その後でそのインデックスに要素を代入する必要があります。
|
||||
|
||||

|
||||

|
||||
|
||||
配列の長さが固定されているため、要素を挿入すると必然的に配列の最後の要素が失われることに注意することが重要です。この問題を解決する方法は「リスト」の章で探求されます。
|
||||
注意すべき点として、配列の長さは固定であるため、要素を 1 つ挿入すると配列末尾の要素が必ず「失われ」ます。この問題の解決策は「リスト」の章で扱います。
|
||||
|
||||
```src
|
||||
[file]{array}-[class]{}-[func]{insert}
|
||||
@@ -144,25 +158,25 @@
|
||||
|
||||
### 要素の削除
|
||||
|
||||
同様に、以下の図に示すように、インデックス$i$の要素を削除するには、インデックス$i$に続くすべての要素を1つずつ前に移動する必要があります。
|
||||
同様に、下図のように、インデックス $i$ の要素を削除したい場合は、インデックス $i$ より後ろの要素をすべて 1 つずつ前へずらす必要があります。
|
||||
|
||||

|
||||

|
||||
|
||||
削除後、元の最後の要素は「意味がない」ものになるため、特定の修正は必要ないことに注意してください。
|
||||
注意してください。要素の削除が完了すると、もともとの末尾要素は「意味を持たない」状態になるため、わざわざ変更する必要はありません。
|
||||
|
||||
```src
|
||||
[file]{array}-[class]{}-[func]{remove}
|
||||
```
|
||||
|
||||
要約すると、配列の挿入と削除操作には以下の欠点があります:
|
||||
全体として見ると、配列の挿入と削除には次の欠点があります。
|
||||
|
||||
- **高い時間計算量**:配列の挿入と削除の両方の平均時間計算量は$O(n)$で、ここで$n$は配列の長さです。
|
||||
- **要素の損失**:配列の長さが固定されているため、挿入時に配列の容量を超える要素は失われます。
|
||||
- **メモリの無駄**:より長い配列を初期化して前部分のみを利用すると、挿入時に「意味のない」末尾要素が生じ、メモリ空間の無駄につながります。
|
||||
- **時間計算量が高い**:配列の挿入と削除の平均時間計算量はいずれも $O(n)$ であり、ここで $n$ は配列長です。
|
||||
- **要素が失われる**:配列の長さは不変であるため、要素を挿入すると配列長の範囲を超えた要素は失われます。
|
||||
- **メモリの浪費**:やや長めの配列を初期化して先頭部分だけを使うこともでき、この場合データ挿入時に失われる末尾要素はすべて「無意味」ですが、その代わり一部のメモリ領域が無駄になります。
|
||||
|
||||
### 配列の走査
|
||||
|
||||
ほとんどのプログラミング言語では、インデックスを使用するか、各要素を直接反復することで配列を走査できます:
|
||||
ほとんどのプログラミング言語では、インデックスを使って配列を走査することも、各要素を直接取り出しながら走査することもできます。
|
||||
|
||||
```src
|
||||
[file]{array}-[class]{}-[func]{traverse}
|
||||
@@ -170,9 +184,9 @@
|
||||
|
||||
### 要素の検索
|
||||
|
||||
配列内の特定の要素を見つけることは、配列を反復し、各要素をチェックして目的の値と一致するかどうかを決定することを含みます。
|
||||
配列内で指定した要素を探すには、配列を走査し、各反復で要素値が一致するかを判定し、一致したら対応するインデックスを出力します。
|
||||
|
||||
配列は線形データ構造であるため、この操作は一般的に「線形探索」と呼ばれます。
|
||||
配列は線形データ構造であるため、上記の検索操作は「線形探索」と呼ばれます。
|
||||
|
||||
```src
|
||||
[file]{array}-[class]{}-[func]{find}
|
||||
@@ -180,34 +194,34 @@
|
||||
|
||||
### 配列の拡張
|
||||
|
||||
複雑なシステム環境では、安全な容量拡張のために配列の後にメモリ空間の可用性を確保することが困難になります。その結果、ほとんどのプログラミング言語では、**配列の長さは不変**です。
|
||||
複雑なシステム環境では、配列の後方にあるメモリ領域が利用可能であることをプログラム側で保証できず、そのため安全に配列容量を拡張できません。したがって、ほとんどのプログラミング言語では、**配列の長さは不変です**。
|
||||
|
||||
配列を拡張するには、より大きな配列を作成し、元の配列から要素をコピーする必要があります。この操作の時間計算量は$O(n)$で、大きな配列では時間がかかる可能性があります。コードは以下の通りです:
|
||||
配列を拡張したい場合は、より大きな新しい配列を作り、元の配列の要素を順に新配列へコピーする必要があります。これは $O(n)$ の操作であり、配列が大きい場合は非常に時間がかかります。コードは次のとおりです。
|
||||
|
||||
```src
|
||||
[file]{array}-[class]{}-[func]{extend}
|
||||
```
|
||||
|
||||
## 配列の利点と制限
|
||||
## 配列の利点と限界
|
||||
|
||||
配列は連続したメモリ空間に格納され、同じ型の要素で構成されます。このアプローチは、システムがデータ構造操作の効率を最適化するために活用できる実質的な事前情報を提供します。
|
||||
配列は連続したメモリ領域に格納され、要素の型も同一です。この方法には豊富な事前情報が含まれており、システムはそれらを利用してデータ構造の操作効率を最適化できます。
|
||||
|
||||
- **高い空間効率**:配列はデータのための連続したメモリブロックを割り当て、追加の構造的オーバーヘッドの必要性を排除します。
|
||||
- **ランダムアクセスのサポート**:配列は任意の要素への$O(1)$時間アクセスを可能にします。
|
||||
- **キャッシュ局所性**:配列要素にアクセスするとき、コンピュータはそれらを読み込むだけでなく、周囲のデータもキャッシュし、高速キャッシュを利用して後続の操作速度を向上させます。
|
||||
- **空間効率が高い**:配列はデータに連続したメモリブロックを割り当てるため、追加の構造オーバーヘッドが不要です。
|
||||
- **ランダムアクセスをサポートする**:配列では任意の要素に $O(1)$ 時間でアクセスできます。
|
||||
- **キャッシュ局所性**:配列要素にアクセスする際、コンピュータはその要素だけでなく周囲のデータもキャッシュするため、高速キャッシュを利用して後続操作の実行速度を高められます。
|
||||
|
||||
しかし、連続空間格納は諸刃の剣で、以下の制限があります:
|
||||
連続領域への格納は諸刃の剣であり、次のような制約があります。
|
||||
|
||||
- **挿入と削除の効率が低い**:配列に多くの要素が蓄積されると、要素の挿入や削除には大量の要素をシフトする必要があります。
|
||||
- **固定長**:配列の長さは初期化後に固定されます。配列を拡張するには、すべてのデータを新しい配列にコピーする必要があり、大きなコストがかかります。
|
||||
- **空間の無駄**:割り当てられた配列サイズが必要以上に大きい場合、余分な空間が無駄になります。
|
||||
- **挿入と削除の効率が低い**:配列内の要素が多い場合、挿入や削除では大量の要素を移動する必要があります。
|
||||
- **長さが不変**:配列は初期化後に長さが固定され、拡張するにはすべてのデータを新しい配列へコピーする必要があり、コストが大きくなります。
|
||||
- **空間の浪費**:配列に割り当てたサイズが実際の必要量を上回る場合、余分な領域は無駄になります。
|
||||
|
||||
## 配列の典型的な応用
|
||||
|
||||
配列は基本的で広く使用されるデータ構造です。様々なアルゴリズムで頻繁に応用され、複雑なデータ構造の実装に役立ちます。
|
||||
配列は基礎的で一般的なデータ構造であり、さまざまなアルゴリズムで頻繁に使われるだけでなく、多様な複雑データ構造の実装にも利用できます。
|
||||
|
||||
- **ランダムアクセス**:配列はランダムサンプリングが必要なときのデータ格納に理想的です。インデックスに基づいてランダムシーケンスを生成することで、効率的にランダムサンプリングを実現できます。
|
||||
- **ソートと検索**:配列はソートと検索アルゴリズムで最も一般的に使用されるデータ構造です。クイックソート、マージソート、二分探索などの技術は主に配列で動作します。
|
||||
- **ルックアップテーブル**:配列は迅速な要素や関係の取得のための効率的なルックアップテーブルとして機能します。例えば、文字をASCIIコードにマッピングすることは、ASCIIコード値をインデックスとして使用し、対応する要素を配列に格納することで簡単になります。
|
||||
- **機械学習**:ニューラルネットワークの領域では、配列はベクトル、行列、テンソルを含む重要な線形代数演算の実行において重要な役割を果たします。配列はニューラルネットワークプログラミングにおいて主要かつ最も広範囲に使用されるデータ構造として機能します。
|
||||
- **データ構造の実装**:配列は、スタック、キュー、ハッシュ表、ヒープ、グラフなど、様々なデータ構造を実装するための構成要素として機能します。例えば、グラフの隣接行列表現は本質的に二次元配列です。
|
||||
- **ランダムアクセス**:いくつかのサンプルをランダムに抽出したい場合、配列に格納してランダムな系列を生成し、インデックスに基づいてランダムサンプリングを行えます。
|
||||
- **ソートと探索**:配列はソートアルゴリズムと探索アルゴリズムで最もよく使われるデータ構造です。クイックソート、マージソート、二分探索などは主に配列上で行われます。
|
||||
- **ルックアップテーブル**:ある要素やその対応関係を高速に調べる必要がある場合、配列をルックアップテーブルとして使えます。たとえば文字から ASCII コードへの対応を実装したいなら、文字の ASCII コード値をインデックスとし、対応する要素を配列の対応位置に格納できます。
|
||||
- **機械学習**:ニューラルネットワークでは、ベクトル、行列、テンソル間の線形代数演算が大量に使われ、これらのデータはいずれも配列の形で構築されます。配列はニューラルネットワークプログラミングで最もよく使われるデータ構造です。
|
||||
- **データ構造の実装**:配列はスタック、キュー、ハッシュテーブル、ヒープ、グラフなどのデータ構造の実装に利用できます。たとえば、グラフの隣接行列表現は実際には 2 次元配列です。
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
|
||||
!!! abstract
|
||||
|
||||
データ構造の世界は頑丈なレンガの壁に似ています。
|
||||
データ構造の世界は、まるで重厚なれんがの壁のようです。
|
||||
|
||||
配列では、レンガがぴったりと整列し、それぞれが次のものと継ぎ目なく隣り合って、統一された形成を作っている姿を想像してください。一方、連結リストでは、これらのレンガが自由に散らばり、それらの間を優雅に編み込む蔦に抱かれています。
|
||||
配列のれんがは整然と並び、一つひとつがぴったりと接しています。連結リストのれんがはあちこちに分散し、それらをつなぐつるがれんがのすき間を自由に行き交います。
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# 連結リスト
|
||||
|
||||
メモリ空間は、すべてのプログラム間で共有されるリソースです。複雑なシステム環境では、使用可能なメモリがメモリ空間全体に分散している可能性があります。配列に割り当てられるメモリは連続している必要があることを理解していますが、非常に大きな配列の場合、十分な大きさの連続メモリ空間を見つけるのは困難な場合があります。ここで、連結リストの柔軟な利点が明らかになります。
|
||||
メモリ空間はすべてのプログラムに共通の資源であり、複雑なシステム実行環境では、空きメモリがメモリの各所に散在している可能性があります。配列を格納するメモリ空間は連続していなければなりませんが、配列が非常に大きい場合、メモリはそのような大きな連続領域を提供できないことがあります。このとき、連結リストの柔軟性という利点が現れます。
|
||||
|
||||
<u>連結リスト</u>は線形データ構造であり、各要素はノードオブジェクトで、ノードは「参照」を通じて相互接続されています。これらの参照は後続ノードのメモリアドレスを保持し、1つのノードから次のノードへのナビゲーションを可能にします。
|
||||
<u>連結リスト(linked list)</u>は線形データ構造の一種であり、各要素はノードオブジェクトです。各ノードは「参照」によって接続されます。参照には次のノードのメモリアドレスが記録されており、これによって現在のノードから次のノードへアクセスできます。
|
||||
|
||||
連結リストの設計では、ノードを連続するメモリアドレスを必要とせずに、メモリ位置全体に分散配置することができます。
|
||||
連結リストの設計では、各ノードをメモリの各所に分散して格納でき、それらのメモリアドレスは連続している必要がありません。
|
||||
|
||||

|
||||

|
||||
|
||||
上図に示すように、連結リストの基本的な構成要素は<u>ノード</u>オブジェクトです。各ノードは2つの主要なコンポーネントで構成されています:ノードの「値」と次のノードへの「参照」です。
|
||||
上図を見ると、連結リストの構成単位は<u>ノード(node)</u>オブジェクトです。各ノードは 2 つのデータ、すなわちノードの「値」と次のノードを指す「参照」を含みます。
|
||||
|
||||
- 連結リストの最初のノードは「ヘッドノード」、最後のノードは「テールノード」です。
|
||||
- テールノードは「null」を指し、Javaでは`null`、C++では`nullptr`、Pythonでは`None`として指定されます。
|
||||
- C、C++、Go、Rustなどのポインタをサポートする言語では、この「参照」は通常「ポインタ」として実装されます。
|
||||
- 連結リストの最初のノードを「先頭ノード」、最後のノードを「末尾ノード」と呼びます。
|
||||
- 末尾ノードが指す先は「空」であり、Java、C++、Python ではそれぞれ `null`、`nullptr`、`None` と表記します。
|
||||
- C、C++、Go、Rust などポインタをサポートする言語では、上記の「参照」は「ポインタ」に置き換えるべきです。
|
||||
|
||||
以下のコードが示すように、連結リストの`ListNode`は値を保持するだけでなく、追加の参照(またはポインタ)も維持する必要があります。したがって、**連結リストは同じ量のデータを格納する場合、配列よりも多くのメモリ空間を占有します**。
|
||||
以下のコードが示すように、連結リストノード `ListNode` は値のほかに、追加で 1 つの参照(ポインタ)を保持する必要があります。そのため、同じデータ量であれば、**連結リストは配列より多くのメモリ空間を消費します**。
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
class ListNode:
|
||||
"""連結リストノードクラス"""
|
||||
def __init__(self, val: int):
|
||||
self.val: int = val # ノード値
|
||||
self.val: int = val # ノードの値
|
||||
self.next: ListNode | None = None # 次のノードへの参照
|
||||
```
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
```cpp title=""
|
||||
/* 連結リストノード構造体 */
|
||||
struct ListNode {
|
||||
int val; // ノード値
|
||||
int val; // ノードの値
|
||||
ListNode *next; // 次のノードへのポインタ
|
||||
ListNode(int x) : val(x), next(nullptr) {} // コンストラクタ
|
||||
};
|
||||
@@ -42,7 +42,7 @@
|
||||
```java title=""
|
||||
/* 連結リストノードクラス */
|
||||
class ListNode {
|
||||
int val; // ノード値
|
||||
int val; // ノードの値
|
||||
ListNode next; // 次のノードへの参照
|
||||
ListNode(int x) { val = x; } // コンストラクタ
|
||||
}
|
||||
@@ -52,8 +52,8 @@
|
||||
|
||||
```csharp title=""
|
||||
/* 連結リストノードクラス */
|
||||
class ListNode(int x) { // コンストラクタ
|
||||
int val = x; // ノード値
|
||||
class ListNode(int x) { //コンストラクタ
|
||||
int val = x; // ノードの値
|
||||
ListNode? next; // 次のノードへの参照
|
||||
}
|
||||
```
|
||||
@@ -63,11 +63,11 @@
|
||||
```go title=""
|
||||
/* 連結リストノード構造体 */
|
||||
type ListNode struct {
|
||||
Val int // ノード値
|
||||
Val int // ノードの値
|
||||
Next *ListNode // 次のノードへのポインタ
|
||||
}
|
||||
|
||||
// NewListNode コンストラクタ、新しい連結リストを作成
|
||||
// NewListNode コンストラクタ。新しい連結リストを作成する
|
||||
func NewListNode(val int) *ListNode {
|
||||
return &ListNode{
|
||||
Val: val,
|
||||
@@ -81,7 +81,7 @@
|
||||
```swift title=""
|
||||
/* 連結リストノードクラス */
|
||||
class ListNode {
|
||||
var val: Int // ノード値
|
||||
var val: Int // ノードの値
|
||||
var next: ListNode? // 次のノードへの参照
|
||||
|
||||
init(x: Int) { // コンストラクタ
|
||||
@@ -96,7 +96,7 @@
|
||||
/* 連結リストノードクラス */
|
||||
class ListNode {
|
||||
constructor(val, next) {
|
||||
this.val = (val === undefined ? 0 : val); // ノード値
|
||||
this.val = (val === undefined ? 0 : val); // ノードの値
|
||||
this.next = (next === undefined ? null : next); // 次のノードへの参照
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@
|
||||
val: number;
|
||||
next: ListNode | null;
|
||||
constructor(val?: number, next?: ListNode | null) {
|
||||
this.val = val === undefined ? 0 : val; // ノード値
|
||||
this.val = val === undefined ? 0 : val; // ノードの値
|
||||
this.next = next === undefined ? null : next; // 次のノードへの参照
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@
|
||||
```dart title=""
|
||||
/* 連結リストノードクラス */
|
||||
class ListNode {
|
||||
int val; // ノード値
|
||||
int val; // ノードの値
|
||||
ListNode? next; // 次のノードへの参照
|
||||
ListNode(this.val, [this.next]); // コンストラクタ
|
||||
}
|
||||
@@ -135,7 +135,7 @@
|
||||
/* 連結リストノードクラス */
|
||||
#[derive(Debug)]
|
||||
struct ListNode {
|
||||
val: i32, // ノード値
|
||||
val: i32, // ノードの値
|
||||
next: Option<Rc<RefCell<ListNode>>>, // 次のノードへのポインタ
|
||||
}
|
||||
```
|
||||
@@ -145,7 +145,7 @@
|
||||
```c title=""
|
||||
/* 連結リストノード構造体 */
|
||||
typedef struct ListNode {
|
||||
int val; // ノード値
|
||||
int val; // ノードの値
|
||||
struct ListNode *next; // 次のノードへのポインタ
|
||||
} ListNode;
|
||||
|
||||
@@ -162,19 +162,39 @@
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title=""
|
||||
|
||||
/* 連結リストノードクラス */
|
||||
// コンストラクタ
|
||||
class ListNode(x: Int) {
|
||||
val _val: Int = x // ノードの値
|
||||
val next: ListNode? = null // 次のノードへの参照
|
||||
}
|
||||
```
|
||||
|
||||
## 連結リストの一般的な操作
|
||||
=== "Ruby"
|
||||
|
||||
```ruby title=""
|
||||
# 連結リストノードクラス
|
||||
class ListNode
|
||||
attr_accessor :val # ノードの値
|
||||
attr_accessor :next # 次のノードへの参照
|
||||
|
||||
def initialize(val=0, next_node=nil)
|
||||
@val = val
|
||||
@next = next_node
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## 連結リストの基本操作
|
||||
|
||||
### 連結リストの初期化
|
||||
|
||||
連結リストの構築は2段階のプロセスです:まず各ノードオブジェクトを初期化し、次にノード間の参照リンクを形成します。初期化後、ヘッドノードから`next`参照をたどってすべてのノードを順次巡回できます。
|
||||
連結リストの構築は 2 つの手順に分かれます。第 1 に各ノードオブジェクトを初期化し、第 2 にノード間の参照関係を構築します。初期化が完了したら、連結リストの先頭ノードから出発し、参照で `next` をたどってすべてのノードに順にアクセスできます。
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="linked_list.py"
|
||||
# 連結リストを初期化: 1 -> 3 -> 2 -> 5 -> 4
|
||||
# 連結リスト 1 -> 3 -> 2 -> 5 -> 4 を初期化
|
||||
# 各ノードを初期化
|
||||
n0 = ListNode(1)
|
||||
n1 = ListNode(3)
|
||||
@@ -191,7 +211,7 @@
|
||||
=== "C++"
|
||||
|
||||
```cpp title="linked_list.cpp"
|
||||
/* 連結リストを初期化: 1 -> 3 -> 2 -> 5 -> 4 */
|
||||
/* 連結リスト 1 -> 3 -> 2 -> 5 -> 4 を初期化 */
|
||||
// 各ノードを初期化
|
||||
ListNode* n0 = new ListNode(1);
|
||||
ListNode* n1 = new ListNode(3);
|
||||
@@ -208,7 +228,7 @@
|
||||
=== "Java"
|
||||
|
||||
```java title="linked_list.java"
|
||||
/* 連結リストを初期化: 1 -> 3 -> 2 -> 5 -> 4 */
|
||||
/* 連結リスト 1 -> 3 -> 2 -> 5 -> 4 を初期化 */
|
||||
// 各ノードを初期化
|
||||
ListNode n0 = new ListNode(1);
|
||||
ListNode n1 = new ListNode(3);
|
||||
@@ -225,7 +245,7 @@
|
||||
=== "C#"
|
||||
|
||||
```csharp title="linked_list.cs"
|
||||
/* 連結リストを初期化: 1 -> 3 -> 2 -> 5 -> 4 */
|
||||
/* 連結リスト 1 -> 3 -> 2 -> 5 -> 4 を初期化 */
|
||||
// 各ノードを初期化
|
||||
ListNode n0 = new(1);
|
||||
ListNode n1 = new(3);
|
||||
@@ -242,7 +262,7 @@
|
||||
=== "Go"
|
||||
|
||||
```go title="linked_list.go"
|
||||
/* 連結リストを初期化: 1 -> 3 -> 2 -> 5 -> 4 */
|
||||
/* 連結リスト 1 -> 3 -> 2 -> 5 -> 4 を初期化 */
|
||||
// 各ノードを初期化
|
||||
n0 := NewListNode(1)
|
||||
n1 := NewListNode(3)
|
||||
@@ -259,7 +279,7 @@
|
||||
=== "Swift"
|
||||
|
||||
```swift title="linked_list.swift"
|
||||
/* 連結リストを初期化: 1 -> 3 -> 2 -> 5 -> 4 */
|
||||
/* 連結リスト 1 -> 3 -> 2 -> 5 -> 4 を初期化 */
|
||||
// 各ノードを初期化
|
||||
let n0 = ListNode(x: 1)
|
||||
let n1 = ListNode(x: 3)
|
||||
@@ -276,7 +296,7 @@
|
||||
=== "JS"
|
||||
|
||||
```javascript title="linked_list.js"
|
||||
/* 連結リストを初期化: 1 -> 3 -> 2 -> 5 -> 4 */
|
||||
/* 連結リスト 1 -> 3 -> 2 -> 5 -> 4 を初期化 */
|
||||
// 各ノードを初期化
|
||||
const n0 = new ListNode(1);
|
||||
const n1 = new ListNode(3);
|
||||
@@ -293,7 +313,7 @@
|
||||
=== "TS"
|
||||
|
||||
```typescript title="linked_list.ts"
|
||||
/* 連結リストを初期化: 1 -> 3 -> 2 -> 5 -> 4 */
|
||||
/* 連結リスト 1 -> 3 -> 2 -> 5 -> 4 を初期化 */
|
||||
// 各ノードを初期化
|
||||
const n0 = new ListNode(1);
|
||||
const n1 = new ListNode(3);
|
||||
@@ -310,7 +330,7 @@
|
||||
=== "Dart"
|
||||
|
||||
```dart title="linked_list.dart"
|
||||
/* 連結リストを初期化: 1 -> 3 -> 2 -> 5 -> 4 */
|
||||
/* 連結リスト 1 -> 3 -> 2 -> 5 -> 4 を初期化 */\
|
||||
// 各ノードを初期化
|
||||
ListNode n0 = ListNode(1);
|
||||
ListNode n1 = ListNode(3);
|
||||
@@ -327,7 +347,7 @@
|
||||
=== "Rust"
|
||||
|
||||
```rust title="linked_list.rs"
|
||||
/* 連結リストを初期化: 1 -> 3 -> 2 -> 5 -> 4 */
|
||||
/* 連結リスト 1 -> 3 -> 2 -> 5 -> 4 を初期化 */
|
||||
// 各ノードを初期化
|
||||
let n0 = Rc::new(RefCell::new(ListNode { val: 1, next: None }));
|
||||
let n1 = Rc::new(RefCell::new(ListNode { val: 3, next: None }));
|
||||
@@ -345,7 +365,7 @@
|
||||
=== "C"
|
||||
|
||||
```c title="linked_list.c"
|
||||
/* 連結リストを初期化: 1 -> 3 -> 2 -> 5 -> 4 */
|
||||
/* 連結リスト 1 -> 3 -> 2 -> 5 -> 4 を初期化 */
|
||||
// 各ノードを初期化
|
||||
ListNode* n0 = newListNode(1);
|
||||
ListNode* n1 = newListNode(3);
|
||||
@@ -362,18 +382,50 @@
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title="linked_list.kt"
|
||||
|
||||
/* 連結リスト 1 -> 3 -> 2 -> 5 -> 4 を初期化 */
|
||||
// 各ノードを初期化
|
||||
val n0 = ListNode(1)
|
||||
val n1 = ListNode(3)
|
||||
val n2 = ListNode(2)
|
||||
val n3 = ListNode(5)
|
||||
val n4 = ListNode(4)
|
||||
// ノード間の参照を構築
|
||||
n0.next = n1;
|
||||
n1.next = n2;
|
||||
n2.next = n3;
|
||||
n3.next = n4;
|
||||
```
|
||||
|
||||
配列全体は1つの変数です。例えば、配列`nums`には`nums[0]`、`nums[1]`などの要素が含まれますが、連結リストは複数の異なるノードオブジェクトで構成されています。**通常、連結リストはそのヘッドノードで参照されます**。例えば、前のコードスニペットの連結リストは`n0`として参照されます。
|
||||
=== "Ruby"
|
||||
|
||||
```ruby title="linked_list.rb"
|
||||
# 連結リスト 1 -> 3 -> 2 -> 5 -> 4 を初期化
|
||||
# 各ノードを初期化
|
||||
n0 = ListNode.new(1)
|
||||
n1 = ListNode.new(3)
|
||||
n2 = ListNode.new(2)
|
||||
n3 = ListNode.new(5)
|
||||
n4 = ListNode.new(4)
|
||||
# ノード間の参照を構築
|
||||
n0.next = n1
|
||||
n1.next = n2
|
||||
n2.next = n3
|
||||
n3.next = n4
|
||||
```
|
||||
|
||||
??? pythontutor "可視化実行"
|
||||
|
||||
https://pythontutor.com/render.html#code=class%20ListNode%3A%0A%20%20%20%20%22%22%22%E9%93%BE%E8%A1%A8%E8%8A%82%E7%82%B9%E7%B1%BB%22%22%22%0A%20%20%20%20def%20__init__%28self,%20val%3A%20int%29%3A%0A%20%20%20%20%20%20%20%20self.val%3A%20int%20%3D%20val%20%20%23%20%E8%8A%82%E7%82%B9%E5%80%BC%0A%20%20%20%20%20%20%20%20self.next%3A%20ListNode%20%7C%20None%20%3D%20None%20%20%23%20%E5%90%8E%E7%BB%A7%E8%8A%82%E7%82%B9%E5%BC%95%E7%94%A8%0A%0A%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E9%93%BE%E8%A1%A8%201%20-%3E%203%20-%3E%202%20-%3E%205%20-%3E%204%0A%20%20%20%20%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E5%90%84%E4%B8%AA%E8%8A%82%E7%82%B9%0A%20%20%20%20n0%20%3D%20ListNode%281%29%0A%20%20%20%20n1%20%3D%20ListNode%283%29%0A%20%20%20%20n2%20%3D%20ListNode%282%29%0A%20%20%20%20n3%20%3D%20ListNode%285%29%0A%20%20%20%20n4%20%3D%20ListNode%284%29%0A%20%20%20%20%23%20%E6%9E%84%E5%BB%BA%E8%8A%82%E7%82%B9%E4%B9%8B%E9%97%B4%E7%9A%84%E5%BC%95%E7%94%A8%0A%20%20%20%20n0.next%20%3D%20n1%0A%20%20%20%20n1.next%20%3D%20n2%0A%20%20%20%20n2.next%20%3D%20n3%0A%20%20%20%20n3.next%20%3D%20n4&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
|
||||
|
||||
配列全体は 1 つの変数であり、たとえば配列 `nums` には `nums[0]` や `nums[1]` などの要素が含まれます。一方、連結リストは複数の独立したノードオブジェクトで構成されます。**通常、先頭ノードを連結リストの代名詞として扱います**。たとえば上記のコードの連結リストは `n0` と表せます。
|
||||
|
||||
### ノードの挿入
|
||||
|
||||
連結リストにノードを挿入するのは非常に簡単です。下図に示すように、隣接する2つのノード`n0`と`n1`の間に新しいノード`P`を挿入することを目指すとします。**これは2つのノード参照(ポインタ)を変更するだけで実現でき**、時間計算量は$O(1)$です。
|
||||
連結リストへのノード挿入は非常に簡単です。下図に示すように、隣り合う 2 つのノード `n0` と `n1` の間に新しいノード `P` を挿入したいとします。**このとき 2 つのノードの参照(ポインタ)を変更するだけでよく**、時間計算量は $O(1)$ です。
|
||||
|
||||
比較すると、配列に要素を挿入する時間計算量は$O(n)$であり、大量のデータを扱う場合には効率が悪くなります。
|
||||
これに対して、配列に要素を挿入する時間計算量は $O(n)$ であり、データ量が大きい場合の効率は低くなります。
|
||||
|
||||

|
||||

|
||||
|
||||
```src
|
||||
[file]{linked_list}-[class]{}-[func]{insert}
|
||||
@@ -381,11 +433,11 @@
|
||||
|
||||
### ノードの削除
|
||||
|
||||
下図に示すように、連結リストからノードを削除することも非常に簡単で、**1つのノードの参照(ポインタ)を変更するだけです**。
|
||||
下図に示すように、連結リストでのノード削除も非常に簡単で、**1 つのノードの参照(ポインタ)を変更するだけで済みます**。
|
||||
|
||||
重要な点は、ノード`P`が削除された後も`n1`を指し続けていることですが、連結リストの巡回中にはアクセスできなくなることです。これは事実上、`P`が連結リストの一部ではなくなったことを意味します。
|
||||
なお、削除操作が完了した後もノード `P` は依然として `n1` を指していますが、実際にはこの連結リストをたどっても `P` へはアクセスできません。つまり、`P` はすでにこの連結リストには属していません。
|
||||
|
||||

|
||||

|
||||
|
||||
```src
|
||||
[file]{linked_list}-[class]{}-[func]{remove}
|
||||
@@ -393,15 +445,15 @@
|
||||
|
||||
### ノードへのアクセス
|
||||
|
||||
**連結リストでのノードへのアクセスは効率が悪いです**。前述したように、配列の任意の要素には$O(1)$時間でアクセスできます。対照的に、連結リストでは、プログラムはヘッドノードから開始して目的のノードが見つかるまで順次ノードを巡回する必要があります。つまり、連結リストの$i$番目のノードにアクセスするには、プログラムは$i - 1$個のノードを反復処理する必要があり、時間計算量は$O(n)$になります。
|
||||
**連結リストでノードにアクセスする効率は低い**です。前節で述べたように、配列では任意の要素へ $O(1)$ 時間でアクセスできます。これに対して連結リストでは、プログラムは先頭ノードから出発し、1 つずつ後ろへたどって目的のノードを見つける必要があります。つまり、連結リストの第 $i$ ノードにアクセスするには $i - 1$ 回のループが必要であり、時間計算量は $O(n)$ です。
|
||||
|
||||
```src
|
||||
[file]{linked_list}-[class]{}-[func]{access}
|
||||
```
|
||||
|
||||
### ノードの検索
|
||||
### ノードの探索
|
||||
|
||||
連結リストを巡回して、値が`target`に一致するノードを見つけ、連結リスト内でのそのノードのインデックスを出力します。この手順も線形検索の例です。対応するコードは以下のとおりです:
|
||||
連結リストを走査し、その中から値が `target` のノードを探し、そのノードの連結リスト内でのインデックスを出力します。この処理も線形探索に属します。コードは次のとおりです。
|
||||
|
||||
```src
|
||||
[file]{linked_list}-[class]{}-[func]{find}
|
||||
@@ -409,26 +461,26 @@
|
||||
|
||||
## 配列 vs. 連結リスト
|
||||
|
||||
下表は配列と連結リストの特性をまとめ、様々な操作における効率も比較しています。それぞれが対照的な格納戦略を使用するため、それぞれの特性と操作効率は明確に対比されています。
|
||||
次の表は、配列と連結リストの各種特徴と操作効率をまとめたものです。両者は互いに逆の格納戦略を採用しているため、各種性質や操作効率にも対照的な特徴が現れます。
|
||||
|
||||
<p align="center"> 表 <id> 配列と連結リストの効率比較 </p>
|
||||
|
||||
| | 配列 | 連結リスト |
|
||||
| ------------------ | ------------------------------------------------ | ----------------------- |
|
||||
| 格納方式 | 連続メモリ空間 | 分散メモリ空間 |
|
||||
| 容量拡張 | 固定長 | 柔軟な拡張 |
|
||||
| メモリ効率 | 要素あたりのメモリ少、潜在的な空間の無駄 | 要素あたりのメモリ多 |
|
||||
| 要素へのアクセス | $O(1)$ | $O(n)$ |
|
||||
| 要素の追加 | $O(n)$ | $O(1)$ |
|
||||
| 要素の削除 | $O(n)$ | $O(1)$ |
|
||||
| | 配列 | 連結リスト |
|
||||
| -------- | ------------------------------ | -------------- |
|
||||
| 格納方式 | 連続したメモリ空間 | 分散したメモリ空間 |
|
||||
| 容量拡張 | 長さは不変 | 柔軟に拡張可能 |
|
||||
| メモリ効率 | 要素のメモリ消費は少ないが、空間を無駄にする可能性がある | 要素のメモリ消費が多い |
|
||||
| 要素へのアクセス | $O(1)$ | $O(n)$ |
|
||||
| 要素の追加 | $O(n)$ | $O(1)$ |
|
||||
| 要素の削除 | $O(n)$ | $O(1)$ |
|
||||
|
||||
## 連結リストの一般的な種類
|
||||
## 一般的な連結リストの種類
|
||||
|
||||
下図に示すように、連結リストには3つの一般的な種類があります。
|
||||
下図に示すように、一般的な連結リストの種類は 3 つあります。
|
||||
|
||||
- **単方向連結リスト**:これは前述した標準的な連結リストです。単方向連結リストのノードには値と次のノードへの参照が含まれます。最初のノードはヘッドノードと呼ばれ、null(`None`)を指す最後のノードはテールノードです。
|
||||
- **循環連結リスト**:これは単方向連結リストのテールノードがヘッドノードを指してループを作ることで形成されます。循環連結リストでは、任意のノードがヘッドノードとして機能できます。
|
||||
- **双方向連結リスト**:単方向連結リストとは対照的に、双方向連結リストは2つの方向で参照を維持します。各ノードには後続者(次のノード)と前任者(前のノード)の両方への参照(ポインタ)が含まれます。双方向連結リストはどちらの方向にも巡回できるより多くの柔軟性を提供しますが、より多くのメモリ空間も消費します。
|
||||
- **単方向連結リスト**:前述した通常の連結リストのことです。単方向連結リストのノードは、値と次のノードを指す参照の 2 つのデータを含みます。最初のノードを先頭ノード、最後のノードを末尾ノードと呼び、末尾ノードは空 `None` を指します。
|
||||
- **循環連結リスト**:単方向連結リストの末尾ノードを先頭ノードへ向けると(先頭と末尾をつなぐと)、循環連結リストが得られます。循環連結リストでは、任意のノードを先頭ノードとみなせます。
|
||||
- **双方向連結リスト**:単方向連結リストと比べて、双方向連結リストは 2 方向の参照を記録します。双方向連結リストのノード定義には、後続ノード(次のノード)と前駆ノード(前のノード)を指す参照(ポインタ)が含まれます。単方向連結リストより柔軟で、2 方向に連結リストを走査できますが、そのぶん多くのメモリ空間を必要とします。
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -436,9 +488,9 @@
|
||||
class ListNode:
|
||||
"""双方向連結リストノードクラス"""
|
||||
def __init__(self, val: int):
|
||||
self.val: int = val # ノード値
|
||||
self.val: int = val # ノードの値
|
||||
self.next: ListNode | None = None # 後続ノードへの参照
|
||||
self.prev: ListNode | None = None # 前任ノードへの参照
|
||||
self.prev: ListNode | None = None # 前駆ノードへの参照
|
||||
```
|
||||
|
||||
=== "C++"
|
||||
@@ -446,9 +498,9 @@
|
||||
```cpp title=""
|
||||
/* 双方向連結リストノード構造体 */
|
||||
struct ListNode {
|
||||
int val; // ノード値
|
||||
int val; // ノードの値
|
||||
ListNode *next; // 後続ノードへのポインタ
|
||||
ListNode *prev; // 前任ノードへのポインタ
|
||||
ListNode *prev; // 前駆ノードへのポインタ
|
||||
ListNode(int x) : val(x), next(nullptr), prev(nullptr) {} // コンストラクタ
|
||||
};
|
||||
```
|
||||
@@ -458,9 +510,9 @@
|
||||
```java title=""
|
||||
/* 双方向連結リストノードクラス */
|
||||
class ListNode {
|
||||
int val; // ノード値
|
||||
ListNode next; // 次のノードへの参照
|
||||
ListNode prev; // 前任ノードへの参照
|
||||
int val; // ノードの値
|
||||
ListNode next; // 後続ノードへの参照
|
||||
ListNode prev; // 前駆ノードへの参照
|
||||
ListNode(int x) { val = x; } // コンストラクタ
|
||||
}
|
||||
```
|
||||
@@ -470,9 +522,9 @@
|
||||
```csharp title=""
|
||||
/* 双方向連結リストノードクラス */
|
||||
class ListNode(int x) { // コンストラクタ
|
||||
int val = x; // ノード値
|
||||
ListNode next; // 次のノードへの参照
|
||||
ListNode prev; // 前任ノードへの参照
|
||||
int val = x; // ノードの値
|
||||
ListNode next; // 後続ノードへの参照
|
||||
ListNode prev; // 前駆ノードへの参照
|
||||
}
|
||||
```
|
||||
|
||||
@@ -481,12 +533,12 @@
|
||||
```go title=""
|
||||
/* 双方向連結リストノード構造体 */
|
||||
type DoublyListNode struct {
|
||||
Val int // ノード値
|
||||
Val int // ノードの値
|
||||
Next *DoublyListNode // 後続ノードへのポインタ
|
||||
Prev *DoublyListNode // 前任ノードへのポインタ
|
||||
Prev *DoublyListNode // 前駆ノードへのポインタ
|
||||
}
|
||||
|
||||
// NewDoublyListNode 初期化
|
||||
// NewDoublyListNode の初期化
|
||||
func NewDoublyListNode(val int) *DoublyListNode {
|
||||
return &DoublyListNode{
|
||||
Val: val,
|
||||
@@ -501,9 +553,9 @@
|
||||
```swift title=""
|
||||
/* 双方向連結リストノードクラス */
|
||||
class ListNode {
|
||||
var val: Int // ノード値
|
||||
var next: ListNode? // 次のノードへの参照
|
||||
var prev: ListNode? // 前任ノードへの参照
|
||||
var val: Int // ノードの値
|
||||
var next: ListNode? // 後続ノードへの参照
|
||||
var prev: ListNode? // 前駆ノードへの参照
|
||||
|
||||
init(x: Int) { // コンストラクタ
|
||||
val = x
|
||||
@@ -517,9 +569,9 @@
|
||||
/* 双方向連結リストノードクラス */
|
||||
class ListNode {
|
||||
constructor(val, next, prev) {
|
||||
this.val = val === undefined ? 0 : val; // ノード値
|
||||
this.val = val === undefined ? 0 : val; // ノードの値
|
||||
this.next = next === undefined ? null : next; // 後続ノードへの参照
|
||||
this.prev = prev === undefined ? null : prev; // 前任ノードへの参照
|
||||
this.prev = prev === undefined ? null : prev; // 前駆ノードへの参照
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -533,9 +585,9 @@
|
||||
next: ListNode | null;
|
||||
prev: ListNode | null;
|
||||
constructor(val?: number, next?: ListNode | null, prev?: ListNode | null) {
|
||||
this.val = val === undefined ? 0 : val; // ノード値
|
||||
this.val = val === undefined ? 0 : val; // ノードの値
|
||||
this.next = next === undefined ? null : next; // 後続ノードへの参照
|
||||
this.prev = prev === undefined ? null : prev; // 前任ノードへの参照
|
||||
this.prev = prev === undefined ? null : prev; // 前駆ノードへの参照
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -545,9 +597,9 @@
|
||||
```dart title=""
|
||||
/* 双方向連結リストノードクラス */
|
||||
class ListNode {
|
||||
int val; // ノード値
|
||||
ListNode next; // 次のノードへの参照
|
||||
ListNode prev; // 前任ノードへの参照
|
||||
int val; // ノードの値
|
||||
ListNode? next; // 後続ノードへの参照
|
||||
ListNode? prev; // 前駆ノードへの参照
|
||||
ListNode(this.val, [this.next, this.prev]); // コンストラクタ
|
||||
}
|
||||
```
|
||||
@@ -561,9 +613,9 @@
|
||||
/* 双方向連結リストノード型 */
|
||||
#[derive(Debug)]
|
||||
struct ListNode {
|
||||
val: i32, // ノード値
|
||||
val: i32, // ノードの値
|
||||
next: Option<Rc<RefCell<ListNode>>>, // 後続ノードへのポインタ
|
||||
prev: Option<Rc<RefCell<ListNode>>>, // 前任ノードへのポインタ
|
||||
prev: Option<Rc<RefCell<ListNode>>>, // 前駆ノードへのポインタ
|
||||
}
|
||||
|
||||
/* コンストラクタ */
|
||||
@@ -583,14 +635,14 @@
|
||||
```c title=""
|
||||
/* 双方向連結リストノード構造体 */
|
||||
typedef struct ListNode {
|
||||
int val; // ノード値
|
||||
int val; // ノードの値
|
||||
struct ListNode *next; // 後続ノードへのポインタ
|
||||
struct ListNode *prev; // 前任ノードへのポインタ
|
||||
struct ListNode *prev; // 前駆ノードへのポインタ
|
||||
} ListNode;
|
||||
|
||||
/* コンストラクタ */
|
||||
ListNode *newListNode(int val) {
|
||||
ListNode *node, *next;
|
||||
ListNode *node;
|
||||
node = (ListNode *) malloc(sizeof(ListNode));
|
||||
node->val = val;
|
||||
node->next = NULL;
|
||||
@@ -602,26 +654,49 @@
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title=""
|
||||
|
||||
/* 双方向連結リストノードクラス */
|
||||
// コンストラクタ
|
||||
class ListNode(x: Int) {
|
||||
val _val: Int = x // ノードの値
|
||||
val next: ListNode? = null // 後続ノードへの参照
|
||||
val prev: ListNode? = null // 前駆ノードへの参照
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
=== "Ruby"
|
||||
|
||||
```ruby title=""
|
||||
# 双方向連結リストノードクラス
|
||||
class ListNode
|
||||
attr_accessor :val # ノードの値
|
||||
attr_accessor :next # 後続ノードへの参照
|
||||
attr_accessor :prev # 前駆ノードへの参照
|
||||
|
||||
def initialize(val=0, next_node=nil, prev_node=nil)
|
||||
@val = val
|
||||
@next = next_node
|
||||
@prev = prev_node
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 連結リストの典型的な応用
|
||||
|
||||
単方向連結リストは、スタック、キュー、ハッシュ表、グラフの実装によく使用されます。
|
||||
単方向連結リストは、スタック、キュー、ハッシュテーブル、グラフなどのデータ構造の実装によく用いられます。
|
||||
|
||||
- **スタックとキュー**:単方向連結リストで、挿入と削除が同じ端で行われる場合、スタック(後入先出)のように動作します。逆に、挿入が一方の端で、削除がもう一方の端で行われる場合、キュー(先入先出)のように機能します。
|
||||
- **ハッシュ表**:連結リストは、ハッシュ衝突を解決する人気の方法である連鎖法で使用されます。ここでは、すべての衝突した要素が連結リストにグループ化されます。
|
||||
- **グラフ**:グラフ表現の標準的な方法である隣接リストは、各グラフ頂点を連結リストに関連付けます。このリストには、対応する頂点に接続された頂点を表す要素が含まれます。
|
||||
- **スタックとキュー**:挿入と削除の両方の操作を連結リストの一端で行うと、その性質は後入れ先出しとなり、スタックに対応します。挿入を連結リストの一端で行い、削除をもう一端で行うと、その性質は先入れ先出しとなり、キューに対応します。
|
||||
- **ハッシュテーブル**:連鎖アドレス法はハッシュ衝突を解決する主流の方式の 1 つであり、この方式では、衝突したすべての要素が 1 つの連結リストに格納されます。
|
||||
- **グラフ**:隣接リストはグラフを表現する一般的な方法の 1 つであり、グラフの各頂点は 1 つの連結リストに関連付けられます。連結リスト内の各要素は、その頂点に接続されたほかの頂点を表します。
|
||||
|
||||
双方向連結リストは、前後の要素への高速アクセスが必要なシナリオに最適です。
|
||||
双方向連結リストは、前後の要素をすばやく見つける必要がある場面でよく用いられます。
|
||||
|
||||
- **高度なデータ構造**:赤黒木やB木などの構造では、ノードの親へのアクセスが重要です。これは各ノードに親ノードへの参照を組み込むことで実現され、双方向連結リストに似ています。
|
||||
- **ブラウザ履歴**:Webブラウザでは、双方向連結リストにより、ユーザーが前進または後退ボタンをクリックしたときの訪問ページの履歴ナビゲーションが容易になります。
|
||||
- **LRUアルゴリズム**:双方向連結リストは、最近最少使用(LRU)キャッシュ削除アルゴリズムに適しており、最近最少使用データの迅速な識別と、高速なノード追加・削除を可能にします。
|
||||
- **高度なデータ構造**:たとえば赤黒木や B 木では、ノードの親ノードへアクセスする必要があります。これは、ノード内に親ノードを指す参照を保持することで実現でき、双方向連結リストに似ています。
|
||||
- **ブラウザ履歴**:Web ブラウザでユーザーが進むボタンや戻るボタンをクリックしたとき、ブラウザはユーザーが訪れた前後のページを知る必要があります。双方向連結リストの性質によって、この操作は簡単になります。
|
||||
- **LRU アルゴリズム**:キャッシュ淘汰(LRU)アルゴリズムでは、最近最も使用されていないデータをすばやく見つける必要があり、さらにノードの高速な追加と削除も必要です。そのため、双方向連結リストが非常に適しています。
|
||||
|
||||
循環連結リストは、オペレーティングシステムでのリソーススケジューリングなど、周期的な操作が必要なアプリケーションに最適です。
|
||||
循環連結リストは、オペレーティングシステムのリソーススケジューリングのように、周期的な操作が必要な場面でよく用いられます。
|
||||
|
||||
- **ラウンドロビンスケジューリングアルゴリズム**:オペレーティングシステムでは、ラウンドロビンスケジューリングアルゴリズムは一般的なCPUスケジューリング方法であり、プロセスのグループを循環する必要があります。各プロセスにはタイムスライスが割り当てられ、期限切れになるとCPUは次のプロセスに回転します。この循環操作は循環連結リストを使用して効率的に実現でき、すべてのプロセス間で公平かつ時分割システムを可能にします。
|
||||
- **データバッファ**:循環連結リストは、オーディオやビデオプレーヤーなどのデータバッファでも使用され、データストリームが複数のバッファブロックに分割され、シームレスな再生のために循環方式で配置されます。
|
||||
- **ラウンドロビン時間片スケジューリングアルゴリズム**:オペレーティングシステムにおいて、ラウンドロビン時間片スケジューリングは一般的な CPU スケジューリングアルゴリズムであり、一連のプロセスを循環的に処理する必要があります。各プロセスには 1 つの時間片が割り当てられ、その時間片を使い切ると、CPU は次のプロセスへ切り替わります。この循環操作は、循環連結リストで実現できます。
|
||||
- **データバッファ**:一部のデータバッファ実装でも、循環連結リストが使われることがあります。たとえば音声・動画プレーヤーでは、データストリームを複数のバッファブロックに分割して循環連結リストへ格納し、シームレス再生を実現できます。
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
# リスト
|
||||
|
||||
<u>リスト</u>は、要素へのアクセス、変更、追加、削除、走査などの操作をサポートする、順序付けられた要素のコレクションを表す抽象的なデータ構造の概念であり、ユーザーが容量制限を考慮する必要がありません。リストは連結リストまたは配列に基づいて実装できます。
|
||||
<u>リスト(list)</u>は抽象的なデータ構造の概念であり、要素の順序付き集合を表す。要素のアクセス、更新、追加、削除、走査などの操作をサポートし、利用者は容量制限の問題を考慮する必要がない。リストは連結リストまたは配列に基づいて実装できる。
|
||||
|
||||
- 連結リストは本質的にリストとして機能し、要素の追加、削除、検索、変更の操作をサポートし、サイズを動的に調整する柔軟性があります。
|
||||
- 配列もこれらの操作をサポートしますが、長さが不変であるため、長さ制限のあるリストと考えることができます。
|
||||
- 連結リストは本質的にリストと見なすことができ、要素の追加・削除・参照・更新をサポートし、柔軟に動的拡張できる。
|
||||
- 配列も要素の追加・削除・参照・更新をサポートするが、長さが不変であるため、長さ制限のあるリストとしか見なせない。
|
||||
|
||||
配列を使用してリストを実装する場合、**長さの不変性によりリストの実用性が低下します**。これは、事前に格納するデータ量を予測することが困難な場合が多く、適切なリスト長を選択することが困難であるためです。長さが小さすぎると要件を満たさない可能性があり、大きすぎるとメモリ空間を無駄にする可能性があります。
|
||||
配列でリストを実装する場合、**長さが不変である性質によってリストの実用性が低下する**。これは、通常は事前にどれだけのデータを格納する必要があるかを決められず、適切なリスト長を選びにくいためである。長さが小さすぎると利用要件を満たせない可能性が高く、大きすぎるとメモリ空間の浪費を招く。
|
||||
|
||||
この問題を解決するために、<u>動的配列</u>を使用してリストを実装できます。これは配列の利点を継承し、プログラム実行中に動的に拡張できます。
|
||||
この問題を解決するために、<u>動的配列(dynamic array)</u>を用いてリストを実装できる。これは配列の各種利点を引き継ぎつつ、プログラム実行中に動的な拡張を行える。
|
||||
|
||||
実際、**多くのプログラミング言語の標準ライブラリは動的配列を使用してリストを実装しています**。例えば、Pythonの`list`、Javaの`ArrayList`、C++の`vector`、C#の`List`などです。以下の議論では、「リスト」と「動的配列」を同義の概念として扱います。
|
||||
実際には、**多くのプログラミング言語の標準ライブラリが提供するリストは動的配列に基づいて実装されている**。たとえば、Python の `list` 、Java の `ArrayList` 、C++ の `vector` 、C# の `List` などである。以降の議論では、「リスト」と「動的配列」を同じ概念として扱う。
|
||||
|
||||
## リストの一般的な操作
|
||||
## リストの基本操作
|
||||
|
||||
### リストの初期化
|
||||
|
||||
通常、「初期値なし」と「初期値あり」の2つの初期化方法を使用します。
|
||||
通常は「初期値なし」と「初期値あり」の 2 つの初期化方法を用いる。
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
```cpp title="list.cpp"
|
||||
/* リストを初期化 */
|
||||
// 注意: C++では、vectorがここで説明されているnumsに相当します
|
||||
// なお、C++ では vector が本稿でいう nums に相当する
|
||||
// 初期値なし
|
||||
vector<int> nums1;
|
||||
// 初期値あり
|
||||
@@ -44,7 +44,7 @@
|
||||
/* リストを初期化 */
|
||||
// 初期値なし
|
||||
List<Integer> nums1 = new ArrayList<>();
|
||||
// 初期値あり(要素型はint[]のラッパークラスInteger[]である必要があります)
|
||||
// 初期値あり(配列の要素型は int[] のラッパークラスである Integer[] である必要があることに注意)
|
||||
Integer[] numbers = new Integer[] { 1, 3, 2, 5, 4 };
|
||||
List<Integer> nums = new ArrayList<>(Arrays.asList(numbers));
|
||||
```
|
||||
@@ -123,133 +123,168 @@
|
||||
=== "C"
|
||||
|
||||
```c title="list.c"
|
||||
// Cは組み込みの動的配列を提供していません
|
||||
// C には組み込みの動的配列がない
|
||||
```
|
||||
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title="list.kt"
|
||||
|
||||
/* リストを初期化 */
|
||||
// 初期値なし
|
||||
var nums1 = listOf<Int>()
|
||||
// 初期値あり
|
||||
var numbers = arrayOf(1, 3, 2, 5, 4)
|
||||
var nums = numbers.toMutableList()
|
||||
```
|
||||
|
||||
=== "Ruby"
|
||||
|
||||
```ruby title="list.rb"
|
||||
# リストを初期化
|
||||
# 初期値なし
|
||||
nums1 = []
|
||||
# 初期値あり
|
||||
nums = [1, 3, 2, 5, 4]
|
||||
```
|
||||
|
||||
??? pythontutor "可視化実行"
|
||||
|
||||
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E5%88%97%E8%A1%A8%0A%20%20%20%20%23%20%E6%97%A0%E5%88%9D%E5%A7%8B%E5%80%BC%0A%20%20%20%20nums1%20%3D%20%5B%5D%0A%20%20%20%20%23%20%E6%9C%89%E5%88%9D%E5%A7%8B%E5%80%BC%0A%20%20%20%20nums%20%3D%20%5B1,%203,%202,%205,%204%5D&cumulative=false&curInstr=4&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
|
||||
|
||||
### 要素へのアクセス
|
||||
|
||||
リストは本質的に配列であるため、$O(1)$時間で要素にアクセスし更新することができ、非常に効率的です。
|
||||
リストの本質は配列であるため、要素へのアクセスと更新は $O(1)$ 時間で行え、効率が高い。
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="list.py"
|
||||
# 要素にアクセス
|
||||
num: int = nums[1] # インデックス1の要素にアクセス
|
||||
num: int = nums[1] # インデックス 1 の要素にアクセス
|
||||
|
||||
# 要素を更新
|
||||
nums[1] = 0 # インデックス1の要素を0に更新
|
||||
nums[1] = 0 # インデックス 1 の要素を 0 に更新
|
||||
```
|
||||
|
||||
=== "C++"
|
||||
|
||||
```cpp title="list.cpp"
|
||||
/* 要素にアクセス */
|
||||
int num = nums[1]; // インデックス1の要素にアクセス
|
||||
int num = nums[1]; // インデックス 1 の要素にアクセス
|
||||
|
||||
/* 要素を更新 */
|
||||
nums[1] = 0; // インデックス1の要素を0に更新
|
||||
nums[1] = 0; // インデックス 1 の要素を 0 に更新
|
||||
```
|
||||
|
||||
=== "Java"
|
||||
|
||||
```java title="list.java"
|
||||
/* 要素にアクセス */
|
||||
int num = nums.get(1); // インデックス1の要素にアクセス
|
||||
int num = nums.get(1); // インデックス 1 の要素にアクセス
|
||||
|
||||
/* 要素を更新 */
|
||||
nums.set(1, 0); // インデックス1の要素を0に更新
|
||||
nums.set(1, 0); // インデックス 1 の要素を 0 に更新
|
||||
```
|
||||
|
||||
=== "C#"
|
||||
|
||||
```csharp title="list.cs"
|
||||
/* 要素にアクセス */
|
||||
int num = nums[1]; // インデックス1の要素にアクセス
|
||||
int num = nums[1]; // インデックス 1 の要素にアクセス
|
||||
|
||||
/* 要素を更新 */
|
||||
nums[1] = 0; // インデックス1の要素を0に更新
|
||||
nums[1] = 0; // インデックス 1 の要素を 0 に更新
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
||||
```go title="list_test.go"
|
||||
/* 要素にアクセス */
|
||||
num := nums[1] // インデックス1の要素にアクセス
|
||||
num := nums[1] // インデックス 1 の要素にアクセス
|
||||
|
||||
/* 要素を更新 */
|
||||
nums[1] = 0 // インデックス1の要素を0に更新
|
||||
nums[1] = 0 // インデックス 1 の要素を 0 に更新
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
|
||||
```swift title="list.swift"
|
||||
/* 要素にアクセス */
|
||||
let num = nums[1] // インデックス1の要素にアクセス
|
||||
let num = nums[1] // インデックス 1 の要素にアクセス
|
||||
|
||||
/* 要素を更新 */
|
||||
nums[1] = 0 // インデックス1の要素を0に更新
|
||||
nums[1] = 0 // インデックス 1 の要素を 0 に更新
|
||||
```
|
||||
|
||||
=== "JS"
|
||||
|
||||
```javascript title="list.js"
|
||||
/* 要素にアクセス */
|
||||
const num = nums[1]; // インデックス1の要素にアクセス
|
||||
const num = nums[1]; // インデックス 1 の要素にアクセス
|
||||
|
||||
/* 要素を更新 */
|
||||
nums[1] = 0; // インデックス1の要素を0に更新
|
||||
nums[1] = 0; // インデックス 1 の要素を 0 に更新
|
||||
```
|
||||
|
||||
=== "TS"
|
||||
|
||||
```typescript title="list.ts"
|
||||
/* 要素にアクセス */
|
||||
const num: number = nums[1]; // インデックス1の要素にアクセス
|
||||
const num: number = nums[1]; // インデックス 1 の要素にアクセス
|
||||
|
||||
/* 要素を更新 */
|
||||
nums[1] = 0; // インデックス1の要素を0に更新
|
||||
nums[1] = 0; // インデックス 1 の要素を 0 に更新
|
||||
```
|
||||
|
||||
=== "Dart"
|
||||
|
||||
```dart title="list.dart"
|
||||
/* 要素にアクセス */
|
||||
int num = nums[1]; // インデックス1の要素にアクセス
|
||||
int num = nums[1]; // インデックス 1 の要素にアクセス
|
||||
|
||||
/* 要素を更新 */
|
||||
nums[1] = 0; // インデックス1の要素を0に更新
|
||||
nums[1] = 0; // インデックス 1 の要素を 0 に更新
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="list.rs"
|
||||
/* 要素にアクセス */
|
||||
let num: i32 = nums[1]; // インデックス1の要素にアクセス
|
||||
let num: i32 = nums[1]; // インデックス 1 の要素にアクセス
|
||||
/* 要素を更新 */
|
||||
nums[1] = 0; // インデックス1の要素を0に更新
|
||||
nums[1] = 0; // インデックス 1 の要素を 0 に更新
|
||||
```
|
||||
|
||||
=== "C"
|
||||
|
||||
```c title="list.c"
|
||||
// Cは組み込みの動的配列を提供していません
|
||||
// C には組み込みの動的配列がない
|
||||
```
|
||||
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title="list.kt"
|
||||
|
||||
/* 要素にアクセス */
|
||||
val num = nums[1] // インデックス 1 の要素にアクセス
|
||||
/* 要素を更新 */
|
||||
nums[1] = 0 // インデックス 1 の要素を 0 に更新
|
||||
```
|
||||
|
||||
=== "Ruby"
|
||||
|
||||
```ruby title="list.rb"
|
||||
# 要素にアクセス
|
||||
num = nums[1] # インデックス 1 の要素にアクセス
|
||||
# 要素を更新
|
||||
nums[1] = 0 # インデックス 1 の要素を 0 に更新
|
||||
```
|
||||
|
||||
??? pythontutor "可視化実行"
|
||||
|
||||
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E5%88%97%E8%A1%A8%0A%20%20%20%20nums%20%3D%20%5B1,%203,%202,%205,%204%5D%0A%0A%20%20%20%20%23%20%E8%AE%BF%E9%97%AE%E5%85%83%E7%B4%A0%0A%20%20%20%20num%20%3D%20nums%5B1%5D%20%20%23%20%E8%AE%BF%E9%97%AE%E7%B4%A2%E5%BC%95%201%20%E5%A4%84%E7%9A%84%E5%85%83%E7%B4%A0%0A%0A%20%20%20%20%23%20%E6%9B%B4%E6%96%B0%E5%85%83%E7%B4%A0%0A%20%20%20%20nums%5B1%5D%20%3D%200%20%20%20%20%23%20%E5%B0%86%E7%B4%A2%E5%BC%95%201%20%E5%A4%84%E7%9A%84%E5%85%83%E7%B4%A0%E6%9B%B4%E6%96%B0%E4%B8%BA%200&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
|
||||
|
||||
### 要素の挿入と削除
|
||||
|
||||
配列と比較して、リストは要素の追加と削除においてより柔軟性を提供します。リストの末尾への要素追加は$O(1)$操作ですが、リストの他の場所での要素の挿入と削除の効率は配列と同じままで、時間計算量は$O(n)$です。
|
||||
配列と比べて、リストでは要素を自由に追加・削除できる。リスト末尾への要素追加の時間計算量は $O(1)$ だが、要素の挿入と削除の効率は依然として配列と同じで、時間計算量は $O(n)$ である。
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -264,11 +299,11 @@
|
||||
nums.append(5)
|
||||
nums.append(4)
|
||||
|
||||
# 中間に要素を挿入
|
||||
nums.insert(3, 6) # インデックス3に数値6を挿入
|
||||
# 途中に要素を挿入
|
||||
nums.insert(3, 6) # インデックス 3 に数値 6 を挿入
|
||||
|
||||
# 要素を削除
|
||||
nums.pop(3) # インデックス3の要素を削除
|
||||
nums.pop(3) # インデックス 3 の要素を削除
|
||||
```
|
||||
|
||||
=== "C++"
|
||||
@@ -284,11 +319,11 @@
|
||||
nums.push_back(5);
|
||||
nums.push_back(4);
|
||||
|
||||
/* 中間に要素を挿入 */
|
||||
nums.insert(nums.begin() + 3, 6); // インデックス3に数値6を挿入
|
||||
/* 途中に要素を挿入 */
|
||||
nums.insert(nums.begin() + 3, 6); // インデックス 3 に数値 6 を挿入
|
||||
|
||||
/* 要素を削除 */
|
||||
nums.erase(nums.begin() + 3); // インデックス3の要素を削除
|
||||
nums.erase(nums.begin() + 3); // インデックス 3 の要素を削除
|
||||
```
|
||||
|
||||
=== "Java"
|
||||
@@ -304,11 +339,11 @@
|
||||
nums.add(5);
|
||||
nums.add(4);
|
||||
|
||||
/* 中間に要素を挿入 */
|
||||
nums.add(3, 6); // インデックス3に数値6を挿入
|
||||
/* 途中に要素を挿入 */
|
||||
nums.add(3, 6); // インデックス 3 に数値 6 を挿入
|
||||
|
||||
/* 要素を削除 */
|
||||
nums.remove(3); // インデックス3の要素を削除
|
||||
nums.remove(3); // インデックス 3 の要素を削除
|
||||
```
|
||||
|
||||
=== "C#"
|
||||
@@ -324,11 +359,11 @@
|
||||
nums.Add(5);
|
||||
nums.Add(4);
|
||||
|
||||
/* 中間に要素を挿入 */
|
||||
nums.Insert(3, 6);
|
||||
/* 途中に要素を挿入 */
|
||||
nums.Insert(3, 6); // インデックス 3 に数値 6 を挿入
|
||||
|
||||
/* 要素を削除 */
|
||||
nums.RemoveAt(3);
|
||||
nums.RemoveAt(3); // インデックス 3 の要素を削除
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
@@ -344,11 +379,11 @@
|
||||
nums = append(nums, 5)
|
||||
nums = append(nums, 4)
|
||||
|
||||
/* 中間に要素を挿入 */
|
||||
nums = append(nums[:3], append([]int{6}, nums[3:]...)...) // インデックス3に数値6を挿入
|
||||
/* 途中に要素を挿入 */
|
||||
nums = append(nums[:3], append([]int{6}, nums[3:]...)...) // インデックス 3 に数値 6 を挿入
|
||||
|
||||
/* 要素を削除 */
|
||||
nums = append(nums[:3], nums[4:]...) // インデックス3の要素を削除
|
||||
nums = append(nums[:3], nums[4:]...) // インデックス 3 の要素を削除
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
@@ -364,11 +399,11 @@
|
||||
nums.append(5)
|
||||
nums.append(4)
|
||||
|
||||
/* 中間に要素を挿入 */
|
||||
nums.insert(6, at: 3) // インデックス3に数値6を挿入
|
||||
/* 途中に要素を挿入 */
|
||||
nums.insert(6, at: 3) // インデックス 3 に数値 6 を挿入
|
||||
|
||||
/* 要素を削除 */
|
||||
nums.remove(at: 3) // インデックス3の要素を削除
|
||||
nums.remove(at: 3) // インデックス 3 の要素を削除
|
||||
```
|
||||
|
||||
=== "JS"
|
||||
@@ -384,11 +419,11 @@
|
||||
nums.push(5);
|
||||
nums.push(4);
|
||||
|
||||
/* 中間に要素を挿入 */
|
||||
nums.splice(3, 0, 6);
|
||||
/* 途中に要素を挿入 */
|
||||
nums.splice(3, 0, 6); // インデックス 3 に数値 6 を挿入
|
||||
|
||||
/* 要素を削除 */
|
||||
nums.splice(3, 1);
|
||||
nums.splice(3, 1); // インデックス 3 の要素を削除
|
||||
```
|
||||
|
||||
=== "TS"
|
||||
@@ -404,11 +439,11 @@
|
||||
nums.push(5);
|
||||
nums.push(4);
|
||||
|
||||
/* 中間に要素を挿入 */
|
||||
nums.splice(3, 0, 6);
|
||||
/* 途中に要素を挿入 */
|
||||
nums.splice(3, 0, 6); // インデックス 3 に数値 6 を挿入
|
||||
|
||||
/* 要素を削除 */
|
||||
nums.splice(3, 1);
|
||||
nums.splice(3, 1); // インデックス 3 の要素を削除
|
||||
```
|
||||
|
||||
=== "Dart"
|
||||
@@ -424,11 +459,11 @@
|
||||
nums.add(5);
|
||||
nums.add(4);
|
||||
|
||||
/* 中間に要素を挿入 */
|
||||
nums.insert(3, 6); // インデックス3に数値6を挿入
|
||||
/* 途中に要素を挿入 */
|
||||
nums.insert(3, 6); // インデックス 3 に数値 6 を挿入
|
||||
|
||||
/* 要素を削除 */
|
||||
nums.removeAt(3); // インデックス3の要素を削除
|
||||
nums.removeAt(3); // インデックス 3 の要素を削除
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
@@ -444,38 +479,76 @@
|
||||
nums.push(5);
|
||||
nums.push(4);
|
||||
|
||||
/* 中間に要素を挿入 */
|
||||
nums.insert(3, 6); // インデックス3に数値6を挿入
|
||||
/* 途中に要素を挿入 */
|
||||
nums.insert(3, 6); // インデックス 3 に数値 6 を挿入
|
||||
|
||||
/* 要素を削除 */
|
||||
nums.remove(3); // インデックス3の要素を削除
|
||||
nums.remove(3); // インデックス 3 の要素を削除
|
||||
```
|
||||
|
||||
=== "C"
|
||||
|
||||
```c title="list.c"
|
||||
// Cは組み込みの動的配列を提供していません
|
||||
// C には組み込みの動的配列がない
|
||||
```
|
||||
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title="list.kt"
|
||||
/* リストをクリア */
|
||||
nums.clear();
|
||||
|
||||
/* 末尾に要素を追加 */
|
||||
nums.add(1);
|
||||
nums.add(3);
|
||||
nums.add(2);
|
||||
nums.add(5);
|
||||
nums.add(4);
|
||||
|
||||
/* 途中に要素を挿入 */
|
||||
nums.add(3, 6); // インデックス 3 に数値 6 を挿入
|
||||
|
||||
/* 要素を削除 */
|
||||
nums.remove(3); // インデックス 3 の要素を削除
|
||||
```
|
||||
|
||||
### リストの反復
|
||||
=== "Ruby"
|
||||
|
||||
配列と同様に、リストはインデックスを使用して反復することも、各要素を直接反復することもできます。
|
||||
```ruby title="list.rb"
|
||||
# リストをクリア
|
||||
nums.clear
|
||||
|
||||
# 末尾に要素を追加
|
||||
nums << 1
|
||||
nums << 3
|
||||
nums << 2
|
||||
nums << 5
|
||||
nums << 4
|
||||
|
||||
# 途中に要素を挿入
|
||||
nums.insert(3, 6) # インデックス 3 に数値 6 を挿入
|
||||
|
||||
# 要素を削除
|
||||
nums.delete_at(3) # インデックス 3 の要素を削除
|
||||
```
|
||||
|
||||
??? pythontutor "可視化実行"
|
||||
|
||||
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E6%9C%89%E5%88%9D%E5%A7%8B%E5%80%BC%0A%20%20%20%20nums%20%3D%20%5B1,%203,%202,%205,%204%5D%0A%20%20%20%20%0A%20%20%20%20%23%20%E6%B8%85%E7%A9%BA%E5%88%97%E8%A1%A8%0A%20%20%20%20nums.clear%28%29%0A%20%20%20%20%0A%20%20%20%20%23%20%E5%9C%A8%E5%B0%BE%E9%83%A8%E6%B7%BB%E5%8A%A0%E5%85%83%E7%B4%A0%0A%20%20%20%20nums.append%281%29%0A%20%20%20%20nums.append%283%29%0A%20%20%20%20nums.append%282%29%0A%20%20%20%20nums.append%285%29%0A%20%20%20%20nums.append%284%29%0A%20%20%20%20%0A%20%20%20%20%23%20%E5%9C%A8%E4%B8%AD%E9%97%B4%E6%8F%92%E5%85%A5%E5%85%83%E7%B4%A0%0A%20%20%20%20nums.insert%283,%206%29%20%20%23%20%E5%9C%A8%E7%B4%A2%E5%BC%95%203%20%E5%A4%84%E6%8F%92%E5%85%A5%E6%95%B0%E5%AD%97%206%0A%20%20%20%20%0A%20%20%20%20%23%20%E5%88%A0%E9%99%A4%E5%85%83%E7%B4%A0%0A%20%20%20%20nums.pop%283%29%20%20%20%20%20%20%20%20%23%20%E5%88%A0%E9%99%A4%E7%B4%A2%E5%BC%95%203%20%E5%A4%84%E7%9A%84%E5%85%83%E7%B4%A0&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
|
||||
|
||||
### リストの走査
|
||||
|
||||
配列と同様に、リストもインデックスに基づいて走査することも、各要素を直接走査することもできる。
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="list.py"
|
||||
# インデックスでリストを反復
|
||||
# インデックスでリストを走査
|
||||
count = 0
|
||||
for i in range(len(nums)):
|
||||
count += nums[i]
|
||||
|
||||
# リスト要素を直接反復
|
||||
# リストの要素を直接走査
|
||||
for num in nums:
|
||||
count += num
|
||||
```
|
||||
@@ -483,13 +556,13 @@
|
||||
=== "C++"
|
||||
|
||||
```cpp title="list.cpp"
|
||||
/* インデックスでリストを反復 */
|
||||
/* インデックスでリストを走査 */
|
||||
int count = 0;
|
||||
for (int i = 0; i < nums.size(); i++) {
|
||||
count += nums[i];
|
||||
}
|
||||
|
||||
/* リスト要素を直接反復 */
|
||||
/* リストの要素を直接走査 */
|
||||
count = 0;
|
||||
for (int num : nums) {
|
||||
count += num;
|
||||
@@ -499,13 +572,13 @@
|
||||
=== "Java"
|
||||
|
||||
```java title="list.java"
|
||||
/* インデックスでリストを反復 */
|
||||
/* インデックスでリストを走査 */
|
||||
int count = 0;
|
||||
for (int i = 0; i < nums.size(); i++) {
|
||||
count += nums.get(i);
|
||||
}
|
||||
|
||||
/* リスト要素を直接反復 */
|
||||
/* リストの要素を直接走査 */
|
||||
for (int num : nums) {
|
||||
count += num;
|
||||
}
|
||||
@@ -514,13 +587,13 @@
|
||||
=== "C#"
|
||||
|
||||
```csharp title="list.cs"
|
||||
/* インデックスでリストを反復 */
|
||||
/* インデックスでリストを走査 */
|
||||
int count = 0;
|
||||
for (int i = 0; i < nums.Count; i++) {
|
||||
count += nums[i];
|
||||
}
|
||||
|
||||
/* リスト要素を直接反復 */
|
||||
/* リストの要素を直接走査 */
|
||||
count = 0;
|
||||
foreach (int num in nums) {
|
||||
count += num;
|
||||
@@ -530,13 +603,13 @@
|
||||
=== "Go"
|
||||
|
||||
```go title="list_test.go"
|
||||
/* インデックスでリストを反復 */
|
||||
/* インデックスでリストを走査 */
|
||||
count := 0
|
||||
for i := 0; i < len(nums); i++ {
|
||||
count += nums[i]
|
||||
}
|
||||
|
||||
/* リスト要素を直接反復 */
|
||||
/* リストの要素を直接走査 */
|
||||
count = 0
|
||||
for _, num := range nums {
|
||||
count += num
|
||||
@@ -546,13 +619,13 @@
|
||||
=== "Swift"
|
||||
|
||||
```swift title="list.swift"
|
||||
/* インデックスでリストを反復 */
|
||||
/* インデックスでリストを走査 */
|
||||
var count = 0
|
||||
for i in nums.indices {
|
||||
count += nums[i]
|
||||
}
|
||||
|
||||
/* リスト要素を直接反復 */
|
||||
/* リストの要素を直接走査 */
|
||||
count = 0
|
||||
for num in nums {
|
||||
count += num
|
||||
@@ -562,13 +635,13 @@
|
||||
=== "JS"
|
||||
|
||||
```javascript title="list.js"
|
||||
/* インデックスでリストを反復 */
|
||||
/* インデックスでリストを走査 */
|
||||
let count = 0;
|
||||
for (let i = 0; i < nums.length; i++) {
|
||||
count += nums[i];
|
||||
}
|
||||
|
||||
/* リスト要素を直接反復 */
|
||||
/* リストの要素を直接走査 */
|
||||
count = 0;
|
||||
for (const num of nums) {
|
||||
count += num;
|
||||
@@ -578,13 +651,13 @@
|
||||
=== "TS"
|
||||
|
||||
```typescript title="list.ts"
|
||||
/* インデックスでリストを反復 */
|
||||
/* インデックスでリストを走査 */
|
||||
let count = 0;
|
||||
for (let i = 0; i < nums.length; i++) {
|
||||
count += nums[i];
|
||||
}
|
||||
|
||||
/* リスト要素を直接反復 */
|
||||
/* リストの要素を直接走査 */
|
||||
count = 0;
|
||||
for (const num of nums) {
|
||||
count += num;
|
||||
@@ -594,13 +667,13 @@
|
||||
=== "Dart"
|
||||
|
||||
```dart title="list.dart"
|
||||
/* インデックスでリストを反復 */
|
||||
/* インデックスでリストを走査 */
|
||||
int count = 0;
|
||||
for (var i = 0; i < nums.length; i++) {
|
||||
count += nums[i];
|
||||
}
|
||||
|
||||
/* リスト要素を直接反復 */
|
||||
/* リストの要素を直接走査 */
|
||||
count = 0;
|
||||
for (var num in nums) {
|
||||
count += num;
|
||||
@@ -610,13 +683,13 @@
|
||||
=== "Rust"
|
||||
|
||||
```rust title="list.rs"
|
||||
// インデックスでリストを反復
|
||||
// インデックスでリストを走査
|
||||
let mut _count = 0;
|
||||
for i in 0..nums.len() {
|
||||
_count += nums[i];
|
||||
}
|
||||
|
||||
// リスト要素を直接反復
|
||||
// リストの要素を直接走査
|
||||
_count = 0;
|
||||
for num in &nums {
|
||||
_count += num;
|
||||
@@ -626,96 +699,125 @@
|
||||
=== "C"
|
||||
|
||||
```c title="list.c"
|
||||
// Cは組み込みの動的配列を提供していません
|
||||
// C には組み込みの動的配列がない
|
||||
```
|
||||
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title="list.kt"
|
||||
/* インデックスでリストを走査 */
|
||||
var count = 0
|
||||
for (i in nums.indices) {
|
||||
count += nums[i]
|
||||
}
|
||||
|
||||
/* リストの要素を直接走査 */
|
||||
for (num in nums) {
|
||||
count += num
|
||||
}
|
||||
```
|
||||
|
||||
=== "Ruby"
|
||||
|
||||
```ruby title="list.rb"
|
||||
# インデックスでリストを走査
|
||||
count = 0
|
||||
for i in 0...nums.length
|
||||
count += nums[i]
|
||||
end
|
||||
|
||||
# リストの要素を直接走査
|
||||
count = 0
|
||||
for num in nums
|
||||
count += num
|
||||
end
|
||||
```
|
||||
|
||||
??? pythontutor "可視化実行"
|
||||
|
||||
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E5%88%97%E8%A1%A8%0A%20%20%20%20nums%20%3D%20%5B1,%203,%202,%205,%204%5D%0A%20%20%20%20%0A%20%20%20%20%23%20%E9%80%9A%E8%BF%87%E7%B4%A2%E5%BC%95%E9%81%8D%E5%8E%86%E5%88%97%E8%A1%A8%0A%20%20%20%20count%20%3D%200%0A%20%20%20%20for%20i%20in%20range%28len%28nums%29%29%3A%0A%20%20%20%20%20%20%20%20count%20%2B%3D%20nums%5Bi%5D%0A%0A%20%20%20%20%23%20%E7%9B%B4%E6%8E%A5%E9%81%8D%E5%8E%86%E5%88%97%E8%A1%A8%E5%85%83%E7%B4%A0%0A%20%20%20%20for%20num%20in%20nums%3A%0A%20%20%20%20%20%20%20%20count%20%2B%3D%20num&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
|
||||
|
||||
### リストの連結
|
||||
|
||||
新しいリスト`nums1`が与えられたとき、それを元のリストの末尾に追加できます。
|
||||
新しいリスト `nums1` が与えられたとき、それを元のリストの末尾に連結できる。
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="list.py"
|
||||
# 2つのリストを連結
|
||||
# 2 つのリストを連結
|
||||
nums1: list[int] = [6, 8, 7, 10, 9]
|
||||
nums += nums1 # nums1をnumsの末尾に連結
|
||||
nums += nums1 # リスト nums1 を nums の後ろに連結
|
||||
```
|
||||
|
||||
=== "C++"
|
||||
|
||||
```cpp title="list.cpp"
|
||||
/* 2つのリストを連結 */
|
||||
/* 2 つのリストを連結 */
|
||||
vector<int> nums1 = { 6, 8, 7, 10, 9 };
|
||||
// nums1をnumsの末尾に連結
|
||||
// リスト nums1 を nums の後ろに連結
|
||||
nums.insert(nums.end(), nums1.begin(), nums1.end());
|
||||
```
|
||||
|
||||
=== "Java"
|
||||
|
||||
```java title="list.java"
|
||||
/* 2つのリストを連結 */
|
||||
/* 2 つのリストを連結 */
|
||||
List<Integer> nums1 = new ArrayList<>(Arrays.asList(new Integer[] { 6, 8, 7, 10, 9 }));
|
||||
nums.addAll(nums1); // nums1をnumsの末尾に連結
|
||||
nums.addAll(nums1); // リスト nums1 を nums の後ろに連結
|
||||
```
|
||||
|
||||
=== "C#"
|
||||
|
||||
```csharp title="list.cs"
|
||||
/* 2つのリストを連結 */
|
||||
/* 2 つのリストを連結 */
|
||||
List<int> nums1 = [6, 8, 7, 10, 9];
|
||||
nums.AddRange(nums1); // nums1をnumsの末尾に連結
|
||||
nums.AddRange(nums1); // リスト nums1 を nums の後ろに連結
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
||||
```go title="list_test.go"
|
||||
/* 2つのリストを連結 */
|
||||
/* 2 つのリストを連結 */
|
||||
nums1 := []int{6, 8, 7, 10, 9}
|
||||
nums = append(nums, nums1...) // nums1をnumsの末尾に連結
|
||||
nums = append(nums, nums1...) // リスト nums1 を nums の後ろに連結
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
|
||||
```swift title="list.swift"
|
||||
/* 2つのリストを連結 */
|
||||
/* 2 つのリストを連結 */
|
||||
let nums1 = [6, 8, 7, 10, 9]
|
||||
nums.append(contentsOf: nums1) // nums1をnumsの末尾に連結
|
||||
nums.append(contentsOf: nums1) // リスト nums1 を nums の後ろに連結
|
||||
```
|
||||
|
||||
=== "JS"
|
||||
|
||||
```javascript title="list.js"
|
||||
/* 2つのリストを連結 */
|
||||
/* 2 つのリストを連結 */
|
||||
const nums1 = [6, 8, 7, 10, 9];
|
||||
nums.push(...nums1); // nums1をnumsの末尾に連結
|
||||
nums.push(...nums1); // リスト nums1 を nums の後ろに連結
|
||||
```
|
||||
|
||||
=== "TS"
|
||||
|
||||
```typescript title="list.ts"
|
||||
/* 2つのリストを連結 */
|
||||
/* 2 つのリストを連結 */
|
||||
const nums1: number[] = [6, 8, 7, 10, 9];
|
||||
nums.push(...nums1); // nums1をnumsの末尾に連結
|
||||
nums.push(...nums1); // リスト nums1 を nums の後ろに連結
|
||||
```
|
||||
|
||||
=== "Dart"
|
||||
|
||||
```dart title="list.dart"
|
||||
/* 2つのリストを連結 */
|
||||
/* 2 つのリストを連結 */
|
||||
List<int> nums1 = [6, 8, 7, 10, 9];
|
||||
nums.addAll(nums1); // nums1をnumsの末尾に連結
|
||||
nums.addAll(nums1); // リスト nums1 を nums の後ろに連結
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="list.rs"
|
||||
/* 2つのリストを連結 */
|
||||
/* 2 つのリストを連結 */
|
||||
let nums1: Vec<i32> = vec![6, 8, 7, 10, 9];
|
||||
nums.extend(nums1);
|
||||
```
|
||||
@@ -723,110 +825,136 @@
|
||||
=== "C"
|
||||
|
||||
```c title="list.c"
|
||||
// Cは組み込みの動的配列を提供していません
|
||||
// C には組み込みの動的配列がない
|
||||
```
|
||||
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title="list.kt"
|
||||
|
||||
/* 2 つのリストを連結 */
|
||||
val nums1 = intArrayOf(6, 8, 7, 10, 9).toMutableList()
|
||||
nums.addAll(nums1) // リスト nums1 を nums の後ろに連結
|
||||
```
|
||||
|
||||
### リストのソート
|
||||
=== "Ruby"
|
||||
|
||||
リストがソートされると、「二分探索」や「双ポインタ」アルゴリズムなど、配列関連のアルゴリズム問題でよく使用されるアルゴリズムを使用できます。
|
||||
```ruby title="list.rb"
|
||||
# 2 つのリストを連結
|
||||
nums1 = [6, 8, 7, 10, 9]
|
||||
nums += nums1
|
||||
```
|
||||
|
||||
??? pythontutor "可視化実行"
|
||||
|
||||
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E5%88%97%E8%A1%A8%0A%20%20%20%20nums%20%3D%20%5B1,%203,%202,%205,%204%5D%0A%20%20%20%20%0A%20%20%20%20%23%20%E6%8B%BC%E6%8E%A5%E4%B8%A4%E4%B8%AA%E5%88%97%E8%A1%A8%0A%20%20%20%20nums1%20%3D%20%5B6,%208,%207,%2010,%209%5D%0A%20%20%20%20nums%20%2B%3D%20nums1%20%20%23%20%E5%B0%86%E5%88%97%E8%A1%A8%20nums1%20%E6%8B%BC%E6%8E%A5%E5%88%B0%20nums%20%E4%B9%8B%E5%90%8E&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
|
||||
|
||||
### リストをソート
|
||||
|
||||
リストのソートが完了すると、配列系アルゴリズム問題でよく問われる「二分探索」や「両ポインタ」アルゴリズムを使えるようになる。
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="list.py"
|
||||
# リストをソート
|
||||
nums.sort() # ソート後、リスト要素は昇順になります
|
||||
nums.sort() # ソート後、リスト要素は小さい順に並ぶ
|
||||
```
|
||||
|
||||
=== "C++"
|
||||
|
||||
```cpp title="list.cpp"
|
||||
/* リストをソート */
|
||||
sort(nums.begin(), nums.end()); // ソート後、リスト要素は昇順になります
|
||||
sort(nums.begin(), nums.end()); // ソート後、リスト要素は小さい順に並ぶ
|
||||
```
|
||||
|
||||
=== "Java"
|
||||
|
||||
```java title="list.java"
|
||||
/* リストをソート */
|
||||
Collections.sort(nums); // ソート後、リスト要素は昇順になります
|
||||
Collections.sort(nums); // ソート後、リスト要素は小さい順に並ぶ
|
||||
```
|
||||
|
||||
=== "C#"
|
||||
|
||||
```csharp title="list.cs"
|
||||
/* リストをソート */
|
||||
nums.Sort(); // ソート後、リスト要素は昇順になります
|
||||
nums.Sort(); // ソート後、リスト要素は小さい順に並ぶ
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
||||
```go title="list_test.go"
|
||||
/* リストをソート */
|
||||
sort.Ints(nums) // ソート後、リスト要素は昇順になります
|
||||
sort.Ints(nums) // ソート後、リスト要素は小さい順に並ぶ
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
|
||||
```swift title="list.swift"
|
||||
/* リストをソート */
|
||||
nums.sort() // ソート後、リスト要素は昇順になります
|
||||
nums.sort() // ソート後、リスト要素は小さい順に並ぶ
|
||||
```
|
||||
|
||||
=== "JS"
|
||||
|
||||
```javascript title="list.js"
|
||||
/* リストをソート */
|
||||
nums.sort((a, b) => a - b); // ソート後、リスト要素は昇順になります
|
||||
nums.sort((a, b) => a - b); // ソート後、リスト要素は小さい順に並ぶ
|
||||
```
|
||||
|
||||
=== "TS"
|
||||
|
||||
```typescript title="list.ts"
|
||||
/* リストをソート */
|
||||
nums.sort((a, b) => a - b); // ソート後、リスト要素は昇順になります
|
||||
nums.sort((a, b) => a - b); // ソート後、リスト要素は小さい順に並ぶ
|
||||
```
|
||||
|
||||
=== "Dart"
|
||||
|
||||
```dart title="list.dart"
|
||||
/* リストをソート */
|
||||
nums.sort(); // ソート後、リスト要素は昇順になります
|
||||
nums.sort(); // ソート後、リスト要素は小さい順に並ぶ
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="list.rs"
|
||||
/* リストをソート */
|
||||
nums.sort(); // ソート後、リスト要素は昇順になります
|
||||
nums.sort(); // ソート後、リスト要素は小さい順に並ぶ
|
||||
```
|
||||
|
||||
=== "C"
|
||||
|
||||
```c title="list.c"
|
||||
// Cは組み込みの動的配列を提供していません
|
||||
// C には組み込みの動的配列がない
|
||||
```
|
||||
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title="list.kt"
|
||||
|
||||
/* リストをソート */
|
||||
nums.sort() // ソート後、リスト要素は小さい順に並ぶ
|
||||
```
|
||||
|
||||
=== "Ruby"
|
||||
|
||||
```ruby title="list.rb"
|
||||
# リストをソート
|
||||
nums = nums.sort { |a, b| a <=> b } # ソート後、リスト要素は小さい順に並ぶ
|
||||
```
|
||||
|
||||
??? pythontutor "可視化実行"
|
||||
|
||||
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E5%88%97%E8%A1%A8%0A%20%20%20%20nums%20%3D%20%5B1,%203,%202,%205,%204%5D%0A%20%20%20%20%0A%20%20%20%20%23%20%E6%8E%92%E5%BA%8F%E5%88%97%E8%A1%A8%0A%20%20%20%20nums.sort%28%29%20%20%23%20%E6%8E%92%E5%BA%8F%E5%90%8E%EF%BC%8C%E5%88%97%E8%A1%A8%E5%85%83%E7%B4%A0%E4%BB%8E%E5%B0%8F%E5%88%B0%E5%A4%A7%E6%8E%92%E5%88%97&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
|
||||
|
||||
## リストの実装
|
||||
|
||||
多くのプログラミング言語には、Java、C++、Pythonなどを含む組み込みリストが付属しています。それらの実装は、初期容量や拡張係数などの様々なパラメータを慎重に考慮した設定で、複雑になりがちです。興味のある読者は、さらなる学習のためにソースコードを調べることができます。
|
||||
多くのプログラミング言語にはリストが組み込まれており、たとえば Java、C++、Python などがある。それらの実装は比較的複雑で、初期容量や拡張倍率など各種パラメータの設定もよく考えられている。興味があればソースコードを参照して学べる。
|
||||
|
||||
リストがどのように動作するかの理解を深めるために、3つの重要な設計側面に焦点を当てて、簡略化されたリストの実装を試みます:
|
||||
リストの動作原理への理解を深めるため、ここでは簡易版のリストを実装し、以下の 3 つの設計ポイントを含める。
|
||||
|
||||
- **初期容量**:配列に合理的な初期容量を選択します。この例では、初期容量として10を選択します。
|
||||
- **サイズ記録**:リスト内の現在の要素数を記録する変数`size`を宣言し、要素の挿入と削除でリアルタイムに更新します。この変数により、リストの末尾を特定し、拡張が必要かどうかを判断できます。
|
||||
- **拡張メカニズム**:要素挿入時にリストが満杯に達した場合、拡張プロセスが必要です。これには拡張係数に基づいてより大きな配列を作成し、現在の配列からすべての要素を新しい配列に転送することが含まれます。この例では、拡張のたびに配列サイズを2倍にすることを規定します。
|
||||
- **初期容量**:妥当な配列の初期容量を選ぶ。この例では 10 を初期容量として選ぶ。
|
||||
- **要素数の記録**:`size` という変数を宣言して、現在のリスト要素数を記録し、要素の挿入と削除に応じてリアルタイムに更新する。この変数により、リスト末尾の位置を特定し、拡張が必要かどうかを判断できる。
|
||||
- **拡張機構**:要素を挿入する時点でリスト容量がいっぱいなら、拡張が必要になる。まず拡張倍率に応じてより大きな配列を作成し、次に現在の配列の全要素を順に新しい配列へ移す。この例では、配列を毎回以前の 2 倍に拡張する。
|
||||
|
||||
```src
|
||||
[file]{my_list}-[class]{my_list}-[func]{}
|
||||
|
||||
@@ -1,71 +1,71 @@
|
||||
# メモリとキャッシュ *
|
||||
|
||||
この章の最初の2つのセクションでは、「連続格納」と「分散格納」をそれぞれ表現する2つの基本的なデータ構造である配列と連結リストを探究しました。
|
||||
本章の前二節では、配列と連結リストという二つの基礎的かつ重要なデータ構造を扱いました。これらはそれぞれ「連続格納」と「分散格納」という二つの物理構造を表しています。
|
||||
|
||||
実際、**物理構造はプログラムがメモリとキャッシュをどの程度効率的に利用するかを大きく決定し**、これがアルゴリズムの全体的なパフォーマンスに影響を与えます。
|
||||
実際には、**物理構造はプログラムにおけるメモリとキャッシュの利用効率を大きく左右し**、ひいてはアルゴリズムプログラム全体の性能に影響します。
|
||||
|
||||
## コンピュータ記憶装置
|
||||
## コンピュータの記憶装置
|
||||
|
||||
コンピュータには3種類の記憶装置があります:<u>ハードディスク</u>、<u>ランダムアクセスメモリ(RAM)</u>、および<u>キャッシュメモリ</u>です。以下の表は、コンピュータシステムにおけるそれぞれの役割とパフォーマンス特性を示しています。
|
||||
コンピュータには三種類の記憶装置があります。<u>ハードディスク(hard disk)</u>、<u>メモリ(random-access memory, RAM)</u>、<u>キャッシュ(cache memory)</u>です。以下の表は、これらがコンピュータシステムで担う役割と性能上の特徴を示しています。
|
||||
|
||||
<p align="center"> 表 <id> コンピュータ記憶装置 </p>
|
||||
<p align="center"> 表 <id> コンピュータの記憶装置 </p>
|
||||
|
||||
| | ハードディスク | メモリ | キャッシュ |
|
||||
| ----------- | -------------------------------------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
|
||||
| 用途 | OS、プログラム、ファイルなどのデータの長期保存 | 現在実行中のプログラムと処理中のデータの一時保存 | 頻繁にアクセスされるデータと命令を保存し、CPUのメモリへのアクセス数を削減 |
|
||||
| 揮発性 | 電源オフ後もデータは失われない | 電源オフ後にデータは失われる | 電源オフ後にデータは失われる |
|
||||
| 容量 | より大きい、TBレベル | より小さい、GBレベル | 非常に小さい、MBレベル |
|
||||
| 速度 | より遅い、数百から数千MB/s | より高速、数十GB/s | 非常に高速、数十から数百GB/s |
|
||||
| 価格(USD) | より安価、数セント/GB | より高価、数ドル/GB | 非常に高価、CPUと一緒に価格設定 |
|
||||
| | ハードディスク | メモリ | キャッシュ |
|
||||
| -------------- | ---------------------------------------- | -------------------------------------- | ------------------------------------------------- |
|
||||
| 用途 | OS、プログラム、ファイルなどを長期保存 | 実行中のプログラムや処理中のデータを一時保存 | 頻繁にアクセスされるデータや命令を保存し、CPU のメモリアクセス回数を減らす |
|
||||
| 揮発性 | 電源断後もデータは失われない | 電源断後にデータは失われる | 電源断後にデータは失われる |
|
||||
| 容量 | 大きい、TB 級 | 小さい、GB 級 | 非常に小さい、MB 級 |
|
||||
| 速度 | 遅い、数百〜数千 MB/s | 速い、数十 GB/s | 非常に速い、数十〜数百 GB/s |
|
||||
| 価格(人民元) | 比較的安価、数角〜数元 / GB | 比較的高価、数十〜数百元 / GB | 非常に高価、CPU と一体で価格設定される |
|
||||
|
||||
コンピュータ記憶システムは、下図に示すようにピラミッドとして視覚化できます。ピラミッドの上部にある記憶装置ほど高速で、容量が小さく、より高価です。このマルチレベル設計は偶然ではなく、コンピュータ科学者とエンジニアによる慎重な検討の結果です。
|
||||
コンピュータの記憶システムは、下図のようなピラミッド構造として捉えられます。ピラミッドの頂点に近い記憶装置ほど速度は速く、容量は小さく、コストは高くなります。この多層構造は偶然ではなく、コンピュータ科学者やエンジニアによる熟慮の末の設計です。
|
||||
|
||||
- **ハードディスクをメモリに置き換えるのは困難です**。第一に、メモリ内のデータは電源オフ後に失われるため、長期データ保存には適していません。第二に、メモリはハードディスクよりも大幅に高価で、消費者市場での広範囲な使用の実現可能性を制限しています。
|
||||
- **キャッシュは大容量と高速のトレードオフに直面しています**。L1、L2、L3キャッシュの容量が増加するにつれて、その物理サイズが大きくなり、CPUコアからの距離が増加します。これによりデータ転送時間が長くなり、アクセス遅延が高くなります。現在の技術では、マルチレベルキャッシュ構造が容量、速度、コストの間の最適なバランスを提供します。
|
||||
- **ハードディスクはメモリで置き換えにくい**。まず、メモリ内のデータは電源断後に失われるため、長期保存には向きません。次に、メモリのコストはハードディスクの数十倍であり、消費者市場で広く普及しにくいという問題があります。
|
||||
- **キャッシュは大容量と高速性を両立しにくい**。L1、L2、L3 キャッシュの容量が段階的に増えるにつれて、物理サイズは大きくなり、CPU コアとの物理的距離も遠くなります。その結果、データ転送時間が増え、要素アクセスの遅延も大きくなります。現在の技術では、多層キャッシュ構造が容量、速度、コストの最適なバランスです。
|
||||
|
||||

|
||||

|
||||
|
||||
!!! tip
|
||||
|
||||
コンピュータの記憶階層は、速度、容量、コストの間の慎重なバランスを反映しています。このタイプのトレードオフは様々な業界で一般的であり、利益と制限の間の最適なバランスを見つけることが重要です。
|
||||
コンピュータの記憶階層は、速度、容量、コストの三者間にある巧妙なバランスを体現しています。実際、このようなトレードオフはあらゆる工業分野に広く存在しており、異なる利点と制約のあいだで最適な均衡点を見つけることが求められます。
|
||||
|
||||
全体的に、**ハードディスクは大量のデータの長期保存を提供し、メモリはプログラム実行中に処理されるデータの一時保存として機能し、キャッシュは頻繁にアクセスされるデータと命令を保存して実行効率を向上させます**。それらは一緒になってコンピュータシステムの効率的な動作を保証します。
|
||||
要するに、**ハードディスクは大量データの長期保存に、メモリはプログラム実行中に処理しているデータの一時保存に、キャッシュは頻繁にアクセスされるデータや命令の保存に用いられ**、プログラム実行効率を高めます。三者は協調して動作し、コンピュータシステムの高効率な運用を支えています。
|
||||
|
||||
下図に示すように、プログラム実行中、データはハードディスクからメモリに読み込まれ、CPU計算が行われます。CPUの拡張として機能するキャッシュは、**メモリからインテリジェントにデータを先読み**し、CPUのより高速なデータアクセスを可能にします。これによりプログラム実行効率が大幅に向上し、低速なメモリへの依存が減少します。
|
||||
次の図に示すように、プログラム実行時にはデータがハードディスクからメモリへ読み込まれ、CPU の計算に使われます。キャッシュは CPU の一部と見なせ、**メモリからデータを賢く読み込むことで**、CPU に高速なデータ読み出しを提供し、プログラムの実行効率を大きく高め、低速なメモリへの依存を減らします。
|
||||
|
||||

|
||||

|
||||
|
||||
## データ構造のメモリ効率
|
||||
|
||||
メモリ空間利用の観点から、配列と連結リストにはそれぞれ利点と制限があります。
|
||||
メモリ空間の利用という観点では、配列と連結リストにはそれぞれ利点と制約があります。
|
||||
|
||||
一方で、**メモリは限られており、複数のプログラム間で共有できない**ため、データ構造での空間使用の最適化は重要です。配列は要素が密接にパックされており、連結リストのように参照(ポインタ)のための追加メモリを必要としないため、空間効率的です。しかし、配列は連続したメモリブロックを事前に割り当てる必要があり、割り当てられた空間が実際の必要量を超える場合、無駄につながる可能性があります。配列の拡張も追加の時間と空間のオーバーヘッドを伴います。対照的に、連結リストは各ノードに対してメモリを動的に割り当て・解放し、ポインタのための追加メモリのコストでより大きな柔軟性を提供します。
|
||||
一方で、**メモリは有限であり、同じメモリ領域を複数のプログラムで共有することはできません**。そのため、データ構造にはできるだけ効率よく空間を使うことが求められます。配列の要素は密に並んでおり、連結リストのノード間参照(ポインタ)を保持する追加領域が不要なため、空間効率は高くなります。しかし、配列は十分な連続メモリを一度に確保する必要があり、メモリ浪費を招くことがありますし、拡張時にも追加の時間と空間コストがかかります。これに対して連結リストは「ノード」単位で動的にメモリを割り当て・解放でき、より高い柔軟性を備えています。
|
||||
|
||||
一方で、プログラム実行中、**繰り返されるメモリの割り当てと解放はメモリの断片化を増加させ**、メモリ利用効率を低下させます。配列は連続記憶方式により、メモリ断片化を引き起こす可能性が比較的低いです。対照的に、連結リストは要素を非連続の場所に保存し、頻繁な挿入と削除はメモリ断片化を悪化させる可能性があります。
|
||||
他方で、プログラムの実行中には、**メモリの確保と解放を繰り返すにつれて、空きメモリの断片化はますます進み**、メモリ利用効率の低下を招きます。配列は連続した格納方式を取るため、比較的メモリ断片化を起こしにくい構造です。反対に、連結リストの要素は分散して格納されるため、頻繁な挿入や削除を行うと、より断片化を招きやすくなります。
|
||||
|
||||
## データ構造のキャッシュ効率
|
||||
|
||||
キャッシュはメモリよりも空間容量がはるかに小さいですが、はるかに高速で、プログラム実行速度において重要な役割を果たします。限られた容量のため、キャッシュは頻繁にアクセスされるデータのサブセットのみを保存できます。CPUがキャッシュに存在しないデータにアクセスしようとすると、<u>キャッシュミス</u>が発生し、CPUは低速なメモリから必要なデータを取得する必要があり、パフォーマンスに影響を与える可能性があります。
|
||||
キャッシュは容量こそメモリよりはるかに小さいものの、速度はメモリよりずっと速く、プログラム実行速度において極めて重要な役割を果たします。キャッシュ容量には限りがあり、頻繁にアクセスされる一部のデータしか保持できません。そのため、CPU がアクセスしようとするデータがキャッシュ内に存在しない場合、<u>キャッシュミス(cache miss)</u>が発生し、CPU は低速なメモリから必要なデータを読み込まなければなりません。
|
||||
|
||||
明らかに、**キャッシュミスが少ないほど、CPUのデータ読み書き効率が高く**、プログラムパフォーマンスが向上します。CPUがキャッシュからデータを正常に取得する割合は<u>キャッシュヒット率</u>と呼ばれ、キャッシュ効率を測定するためによく使用される指標です。
|
||||
当然ながら、**「キャッシュミス」が少ないほど、CPU のデータ読み書き効率は高くなり**、プログラム性能も向上します。CPU がキャッシュからデータを正常に取得できた割合を<u>キャッシュヒット率(cache hit rate)</u>と呼び、この指標は通常、キャッシュ効率の評価に用いられます。
|
||||
|
||||
より高い効率を達成するために、キャッシュは以下のデータロードメカニズムを採用します。
|
||||
できるだけ高い効率を実現するため、キャッシュは次のようなデータ読み込みの仕組みを採用しています。
|
||||
|
||||
- **キャッシュライン**:キャッシュは個々のバイトではなく、キャッシュラインと呼ばれる単位でデータを保存・ロードして動作します。このアプローチは、一度により大きなデータブロックを転送することで効率を向上させます。
|
||||
- **先読みメカニズム**:プロセッサはデータアクセスパターン(例:連続または固定ストライドアクセス)を予測し、これらのパターンに基づいてデータをキャッシュに先読みして、キャッシュヒット率を向上させます。
|
||||
- **空間的局所性**:特定のデータがアクセスされると、近くのデータもまもなくアクセスされる可能性があります。これを活用するために、キャッシュは要求されたデータと一緒に隣接するデータをロードし、ヒット率を向上させます。
|
||||
- **時間的局所性**:データがアクセスされた場合、近い将来に再びアクセスされる可能性があります。キャッシュはこの原理を使用して、最近アクセスされたデータを保持してヒット率を向上させます。
|
||||
- **キャッシュライン**:キャッシュはデータを 1 バイト単位で保存・読み込みするのではなく、キャッシュライン単位で扱います。1 バイト単位の転送と比べて、キャッシュライン単位のほうが効率的です。
|
||||
- **プリフェッチ機構**:プロセッサはデータアクセスのパターン(たとえば順次アクセス、一定ステップ幅のスキップアクセスなど)を予測し、そのパターンに応じてデータをキャッシュへ読み込むことで、ヒット率を高めます。
|
||||
- **空間的局所性**:あるデータがアクセスされた場合、その近傍のデータも近いうちにアクセスされる可能性があります。そのため、キャッシュはあるデータを読み込む際に、その周辺のデータもあわせて読み込み、ヒット率を高めます。
|
||||
- **時間的局所性**:あるデータがアクセスされた場合、そのデータは近い将来に再びアクセスされる可能性が高いです。キャッシュはこの性質を利用し、最近アクセスしたデータを保持することでヒット率を高めます。
|
||||
|
||||
実際、**配列と連結リストは異なるキャッシュ利用効率を持ち**、これは主に以下の側面に反映されます。
|
||||
実際には、**配列と連結リストではキャッシュの利用効率が異なり**、主に次の点に表れます。
|
||||
|
||||
- **占有空間**:連結リスト要素は配列要素よりも多くの空間を占有するため、キャッシュに保持される有効データが少なくなります。
|
||||
- **キャッシュライン**:連結リストデータはメモリ全体に散在し、キャッシュは「行単位でロード」されるため、ロードされる無効データの割合が高くなります。
|
||||
- **先読みメカニズム**:配列のデータアクセスパターンは連結リストよりも「予測可能」で、つまりシステムがこれからロードされるデータを推測しやすいです。
|
||||
- **空間的局所性**:配列は連続したメモリ空間に保存されるため、ロードされているデータの近くのデータがまもなくアクセスされる可能性が高くなります。
|
||||
- **使用空間**:連結リストの要素は配列要素より多くの空間を占めるため、キャッシュに収まる有効データ量は少なくなります。
|
||||
- **キャッシュライン**:連結リストのデータはメモリの各所に分散しており、キャッシュは「ライン単位で読み込む」ため、無効データまで読み込む割合が高くなります。
|
||||
- **プリフェッチ機構**:配列のほうが連結リストよりもデータアクセスのパターンを「予測しやすく」、システムが次に読み込まれるデータを推測しやすくなります。
|
||||
- **空間的局所性**:配列はまとまったメモリ空間に格納されるため、読み込まれたデータの近くにあるデータも、まもなくアクセスされる可能性が高くなります。
|
||||
|
||||
全体的に、**配列はより高いキャッシュヒット率を持ち、一般的に連結リストよりも操作効率が高いです**。これにより、配列に基づくデータ構造はアルゴリズム問題の解決において人気があります。
|
||||
全体として、**配列はより高いキャッシュヒット率を持つため、操作効率では通常、連結リストより優れています**。このため、アルゴリズム問題を解く際には、配列ベースで実装されたデータ構造のほうが好まれることが多くなります。
|
||||
|
||||
**高いキャッシュ効率が配列が常に連結リストより優れているという意味ではない**ことに注意すべきです。データ構造の選択は特定のアプリケーション要件に依存すべきです。例えば、配列と連結リストの両方が「スタック」データ構造を実装できますが(次章で詳細説明)、それらは異なるシナリオに適しています。
|
||||
注意すべきなのは、**キャッシュ効率が高いからといって、配列があらゆる状況で連結リストより優れているとは限らない**という点です。実際にどのデータ構造を選ぶかは、具体的な要件に応じて決めるべきです。たとえば、配列と連結リストはいずれも「スタック」データ構造を実装できますが(次章で詳しく説明します)、適した場面は異なります。
|
||||
|
||||
- アルゴリズム問題では、より高い操作効率とランダムアクセス機能を提供するため、配列に基づくスタックを選択する傾向があります。唯一のコストは配列に対して一定量のメモリ空間を事前に割り当てる必要があることです。
|
||||
- データ量が非常に大きく、高度に動的で、スタックの予想サイズを推定するのが困難な場合、連結リストに基づくスタックがより良い選択です。連結リストは大量のデータをメモリの異なる部分に分散でき、配列拡張の追加オーバーヘッドを回避できます。
|
||||
- アルゴリズム問題に取り組むときは、一般に配列ベースのスタックを選ぶ傾向があります。より高い操作効率とランダムアクセス能力を備えており、その代償は配列用に一定量のメモリを事前確保することだけです。
|
||||
- データ量が非常に大きく、動的性が高く、スタックの想定サイズを見積もりにくい場合は、連結リストベースのスタックのほうが適しています。連結リストなら大量のデータをメモリの異なる場所に分散して保存でき、配列拡張による追加コストも回避できます。
|
||||
|
||||
@@ -1,81 +1,86 @@
|
||||
# まとめ
|
||||
|
||||
### 重要な復習
|
||||
### 要点の振り返り
|
||||
|
||||
- 配列と連結リストは2つの基本的なデータ構造であり、コンピュータメモリにおける2つの格納方法を表しています:連続空間格納と非連続空間格納です。それらの特性は互いに補完し合います。
|
||||
- 配列はランダムアクセスをサポートし、使用するメモリが少ない一方で、要素の挿入と削除は非効率的で、初期化後の長さが固定されています。
|
||||
- 連結リストは参照(ポインタ)の変更によって効率的なノードの挿入と削除を実装し、長さを柔軟に調整できますが、ノードアクセス効率が低く、より多くのメモリを消費します。
|
||||
- 連結リストの一般的な種類には、単方向連結リスト、循環連結リスト、双方向連結リストがあり、それぞれに独自の応用シナリオがあります。
|
||||
- リストは要素の順序付けられたコレクションで、追加、削除、変更をサポートし、通常は動的配列に基づいて実装され、配列の利点を保持しながら柔軟な長さ調整を可能にします。
|
||||
- リストの出現により配列の実用性が大幅に向上しましたが、一部のメモリ空間の無駄につながる可能性があります。
|
||||
- プログラム実行中、データは主にメモリに格納されます。配列はより高いメモリ空間効率を提供し、連結リストはメモリ使用においてより柔軟です。
|
||||
- キャッシュは、キャッシュライン、先読み、空間的局所性、時間的局所性などのメカニズムを通じてCPUに高速データアクセスを提供し、プログラム実行効率を大幅に向上させます。
|
||||
- より高いキャッシュヒット率により、配列は一般的に連結リストよりも効率的です。データ構造を選択する際は、特定のニーズとシナリオに基づいて適切な選択をすべきです。
|
||||
- 配列と連結リストは 2 種類の基本的なデータ構造であり、それぞれコンピュータメモリにおけるデータの 2 つの格納方式、すなわち連続領域への格納と分散領域への格納を表す。両者の特徴は相互補完的である。
|
||||
- 配列はランダムアクセスをサポートし、使用メモリも少ない。一方で、要素の挿入と削除の効率は低く、初期化後に長さを変更できない。
|
||||
- 連結リストは参照(ポインタ)を変更することでノードの挿入と削除を効率的に行え、長さも柔軟に調整できる。一方で、ノードへのアクセス効率は低く、メモリ使用量も多い。一般的な連結リストには単方向連結リスト、循環連結リスト、双方向連結リストがある。
|
||||
- リストは、追加・削除・検索・更新をサポートする順序付き要素集合であり、通常は動的配列に基づいて実装される。配列の利点を保ちながら、長さを柔軟に調整できる。
|
||||
- リストの登場により配列の実用性は大幅に高まったが、一部のメモリ領域が無駄になる可能性がある。
|
||||
- プログラムの実行時、データは主にメモリに格納される。配列はより高いメモリ空間効率を提供でき、連結リストはメモリ利用の面でより柔軟である。
|
||||
- キャッシュは、キャッシュライン、プリフェッチ機構、空間局所性と時間局所性といったデータ読み込み機構を通じて CPU に高速なデータアクセスを提供し、プログラムの実行効率を大きく向上させる。
|
||||
- 配列はキャッシュヒット率が高いため、通常は連結リストよりも高効率である。データ構造を選択する際は、具体的な要件や場面に応じて適切に選ぶべきである。
|
||||
|
||||
### Q & A
|
||||
|
||||
**Q**:配列をスタックに格納するかヒープに格納するかは、時間と空間効率に影響しますか?
|
||||
**Q**:配列をスタックに格納する場合とヒープに格納する場合では、時間効率と空間効率に影響がありますか?
|
||||
|
||||
スタックとヒープの両方に格納される配列は連続したメモリ空間に格納され、データ操作効率は本質的に同じです。しかし、スタックとヒープには独自の特性があり、以下の違いが生じます。
|
||||
スタック上とヒープ上の配列はいずれも連続したメモリ領域に格納されるため、データ操作の効率は基本的に同じである。ただし、スタックとヒープにはそれぞれ特徴があり、以下の違いが生じる。
|
||||
|
||||
1. 割り当てと解放効率:スタックはより小さなメモリブロックで、コンパイラによって自動的に割り当てられます。ヒープメモリは比較的大きく、コードで動的に割り当てることができ、断片化しやすいです。したがって、ヒープでの割り当てと解放操作は一般的にスタックよりも遅くなります。
|
||||
2. サイズ制限:スタックメモリは比較的小さく、ヒープサイズは一般的に利用可能なメモリによって制限されます。したがって、ヒープは大きな配列の格納により適しています。
|
||||
3. 柔軟性:スタック上の配列のサイズはコンパイル時に決定される必要がありますが、ヒープ上の配列のサイズは実行時に動的に決定できます。
|
||||
1. 確保と解放の効率:スタックは比較的小さなメモリ領域で、確保はコンパイラによって自動的に行われる。一方、ヒープメモリは相対的に大きく、コード内で動的に確保できる反面、断片化しやすい。そのため、ヒープ上での確保と解放は通常スタック上より遅い。
|
||||
2. サイズ制限:スタックメモリは比較的小さく、ヒープのサイズは一般に利用可能メモリに制限される。そのため、ヒープは大きな配列の格納により適している。
|
||||
3. 柔軟性:スタック上の配列サイズはコンパイル時に確定している必要があるが、ヒープ上の配列サイズは実行時に動的に決定できる。
|
||||
|
||||
**Q**:なぜ配列は同じ型の要素を必要とし、連結リストは同じ型の要素を強調しないのですか?
|
||||
**Q**:なぜ配列では同じ型の要素が求められるのに、連結リストでは同じ型であることが強調されないのですか?
|
||||
|
||||
連結リストは参照(ポインタ)によって接続されたノードで構成され、各ノードはint、double、string、objectなど、異なる型のデータを格納できます。
|
||||
連結リストはノードで構成され、ノード同士は参照(ポインタ)で接続されている。各ノードには `int`、`double`、`string`、`object` など、異なる型のデータを格納できる。
|
||||
|
||||
対照的に、配列要素は同じ型である必要があり、これにより対応する要素位置にアクセスするためのオフセットを計算できます。例えば、intとlong型の両方を含む配列で、単一要素がそれぞれ4バイトと8バイトを占有する場合、配列に2つの異なる長さの要素が含まれているため、以下の式を使用してオフセットを計算できません。
|
||||
これに対して、配列要素は同じ型でなければならない。そうでなければ、オフセットを計算して対応する要素位置を取得できないからである。たとえば、配列に `int` と `long` の 2 種類が同時に含まれていて、各要素がそれぞれ 4 バイトと 8 バイトを占める場合、配列内に 2 種類の「要素長」が存在するため、次の式ではオフセットを計算できない。
|
||||
|
||||
```shell
|
||||
# 要素メモリアドレス = 配列メモリアドレス + 要素長 * 要素インデックス
|
||||
# 要素のメモリアドレス = 配列のメモリアドレス(先頭要素のメモリアドレス) + 要素長 * 要素インデックス
|
||||
```
|
||||
|
||||
**Q**:ノードを削除した後、`P.next`を`None`に設定する必要がありますか?
|
||||
**Q**:ノード `P` を削除した後、`P.next` を `None` に設定する必要はありますか?
|
||||
|
||||
`P.next`を変更しなくても問題ありません。連結リストの観点から、ヘッドノードからテールノードまでの巡回で`P`に遭遇することはもうありません。これは、ノード`P`がリストから効果的に削除されたことを意味し、`P`が指す場所はもはやリストに影響しません。
|
||||
`P.next` を変更しなくてもよい。この連結リストの観点では、先頭ノードから末尾ノードまでたどっても、もはや `P` に出会うことはない。つまり、ノード `P` はすでに連結リストから削除されており、この時点で `P` がどこを指していても、この連結リストには影響しない。
|
||||
|
||||
ガベージコレクションの観点から、Java、Python、Goなどの自動ガベージコレクションメカニズムを持つ言語では、ノード`P`が収集されるかどうかは、それを指す参照がまだあるかどうかに依存し、`P.next`の値には依存しません。CやC++などの言語では、ノードのメモリを手動で解放する必要があります。
|
||||
データ構造とアルゴリズム(問題を解くとき)の観点では、切り離さなくても問題はなく、プログラムのロジックが正しいことを保証すればよい。標準ライブラリの観点では、切り離したほうがより安全で、ロジックも明確である。切り離さない場合、削除されたノードが適切に回収されなかったとすると、後続ノードのメモリ回収に影響する可能性がある。
|
||||
|
||||
**Q**:連結リストでは、挿入と削除操作の時間計算量は`O(1)`です。しかし、挿入や削除前の要素検索には`O(n)`時間がかかるので、なぜ時間計算量は`O(n)`ではないのですか?
|
||||
**Q**:連結リストでの挿入と削除の時間計算量は $O(1)$ です。しかし、追加や削除の前には要素を探すのに $O(n)$ の時間が必要です。では、なぜ時間計算量は $O(n)$ ではないのですか?
|
||||
|
||||
要素を最初に検索してから削除する場合、時間計算量は確かに`O(n)`です。しかし、連結リストの挿入と削除における`O(1)`の利点は他のアプリケーションで実現できます。例えば、連結リストを使用した両端キューの実装では、常にヘッドとテールノードを指すポインタを維持し、各挿入と削除操作を`O(1)`にします。
|
||||
要素を先に探してから削除するのであれば、時間計算量が $O(n)$ であるのは確かである。しかし、連結リストの $O(1)$ での追加・削除という利点は、ほかの用途で生かせる。たとえば、両端キューは連結リストで実装するのに適しており、先頭ノードと末尾ノードを常に指すポインタ変数を維持すれば、各挿入・削除操作はどれも $O(1)$ になる。
|
||||
|
||||
**Q**:「連結リストの定義と格納方法」の図で、薄青色の格納ノードは単一のメモリアドレスを占有しますか、それともノード値と半分を共有しますか?
|
||||
**Q**:図「連結リストの定義と格納方式」で、薄青色のノードポインタ部分は 1 つのメモリアドレスを占めているのですか? それともノード値と半分ずつなのでしょうか?
|
||||
|
||||
図は単なる定性的な表現であり、定量的分析は特定の状況に依存します。
|
||||
この模式図は定性的な表現にすぎず、定量的な表現は具体的な状況に応じて分析する必要がある。
|
||||
|
||||
- 異なる型のノード値は異なる量の空間を占有します。例えば、int、long、double、オブジェクトインスタンスです。
|
||||
- ポインタ変数によって占有されるメモリ空間は、使用されるオペレーティングシステムとコンパイル環境に依存し、通常8バイトまたは4バイトです。
|
||||
- ノード値が占める領域は型によって異なり、たとえば `int`、`long`、`double`、インスタンスオブジェクトなどがある。
|
||||
- ポインタ変数が占めるメモリ空間の大きさは、使用する OS やコンパイル環境によって異なり、多くは 8 バイトまたは 4 バイトである。
|
||||
|
||||
**Q**:リストの末尾への要素追加は常に`O(1)`ですか?
|
||||
**Q**:リストの末尾への要素追加は常に $O(1)$ ですか?
|
||||
|
||||
要素を追加することでリスト長を超える場合、リストは最初に拡張される必要があります。システムは新しいメモリブロックを要求し、元のリストのすべての要素を移動するため、この場合の時間計算量は`O(n)`になります。
|
||||
要素を追加する際にリスト長を超える場合は、先にリストを拡張してから追加する必要がある。システムは新しいメモリ領域を確保し、元のリストの全要素をそこへ移動するため、このとき時間計算量は $O(n)$ になる。
|
||||
|
||||
**Q**:「リストの出現により配列の実用性が大幅に向上しましたが、一部のメモリ空間の無駄につながる可能性があります」という文は、容量、長さ、拡張係数などの追加変数によって占有されるメモリを指していますか?
|
||||
**Q**:「リストの登場により配列の実用性は大きく向上したが、一部のメモリ空間が無駄になる可能性がある」というのは、容量、長さ、拡張倍率のような追加変数が占めるメモリのことですか?
|
||||
|
||||
ここでの空間の無駄は主に2つの側面を指します:一方で、リストは初期長で設定されますが、常に必要とは限りません。他方で、頻繁な拡張を防ぐため、拡張は通常$\times 1.5$などの係数で乗算されます。これにより多くの空きスロットが生まれ、通常は完全に埋めることができません。
|
||||
ここでいう空間の無駄には主に 2 つの意味がある。一方では、リストには初期長が設定されるが、必ずしもそれだけ必要とは限らない。もう一方では、頻繁な拡張を防ぐため、拡張時には通常ある係数、たとえば $\times 1.5$ を掛ける。このため、多くの空きスロットが生じ、通常それらを完全に埋めることはできない。
|
||||
|
||||
**Q**:Pythonで`n = [1, 2, 3]`を初期化した後、これら3つの要素のアドレスは連続していますが、`m = [2, 1, 3]`を初期化すると、各要素の`id`は連続していないが`n`のものと同一です。これらの要素のアドレスが連続していない場合、`m`はまだ配列ですか?
|
||||
**Q**:Python で `n = [1, 2, 3]` を初期化した後、この 3 つの要素のアドレスは連続しています。しかし `m = [2, 1, 3]` を初期化すると、各要素の id は連続しておらず、それぞれ `n` 内の同じ値と一致していることがわかります。これらの要素のアドレスが連続していないなら、`m` も配列なのですか?
|
||||
|
||||
リスト要素を連結リストノード`n = [n1, n2, n3, n4, n5]`に置き換える場合、これら5つのノードオブジェクトも通常メモリ全体に分散しています。しかし、リストインデックスが与えられれば、`O(1)`時間でノードのメモリアドレスにアクセスでき、対応するノードにアクセスできます。これは、配列がノード自体ではなく、ノードへの参照を格納するためです。
|
||||
仮にリスト要素を連結リストのノード `n = [n1, n2, n3, n4, n5]` に置き換えたとしても、通常この 5 つのノードオブジェクトもメモリ上の各所に分散して格納される。それでも、与えられたリストインデックスに対して、私たちは依然として $O(1)$ 時間でノードのメモリアドレスを取得し、対応するノードにアクセスできる。これは、配列に格納されているのがノードそのものではなく、ノードへの参照だからである。
|
||||
|
||||
多くの言語とは異なり、Pythonでは数値もオブジェクトとしてラップされ、リストは数値自体ではなく、これらの数値への参照を格納します。したがって、2つの配列の同じ数値が同じ`id`を持ち、これらの数値のメモリアドレスは連続である必要がないことがわかります。
|
||||
多くの言語と異なり、Python では数値もオブジェクトとしてラップされており、リストに格納されているのは数値そのものではなく、数値への参照である。そのため、2 つの配列内の同じ数値が同一の id を持つことがあり、しかもそれらの数値のメモリアドレスは連続している必要がない。
|
||||
|
||||
**Q**:C++ STLの`std::list`はすでに双方向連結リストを実装していますが、一部のアルゴリズム書籍では直接使用していないようです。何か制限がありますか?
|
||||
**Q**:C++ STL の `std::list` はすでに双方向連結リストを実装していますが、アルゴリズム本ではあまり直接使われないようです。何か制約があるのでしょうか?
|
||||
|
||||
一方で、アルゴリズムを実装する際は配列を使用することを好み、必要な場合のみ連結リストを使用します。主に2つの理由があります。
|
||||
一方では、私たちは多くの場合、アルゴリズムの実装に配列を好み、必要なときにだけ連結リストを使う。その主な理由は 2 つある。
|
||||
|
||||
- 空間オーバーヘッド:各要素に2つの追加ポインタ(前の要素用と次の要素用)が必要なため、`std::list`は通常`std::vector`よりも多くの空間を占有します。
|
||||
- キャッシュ非友好的:データが連続して格納されていないため、`std::list`はキャッシュ利用率が低くなります。一般的に、`std::vector`の方がパフォーマンスが優れています。
|
||||
- 空間オーバーヘッド:各要素には 2 つの追加ポインタ(前の要素用と次の要素用)が必要なため、`std::list` は通常 `std::vector` より多くの空間を消費する。
|
||||
- キャッシュ非効率:データが連続して格納されていないため、`std::list` はキャッシュの利用効率が低い。一般には、`std::vector` のほうが性能がよい。
|
||||
|
||||
他方で、連結リストは主に二分木とグラフに必要です。スタックとキューは、連結リストではなく、プログラミング言語の`stack`と`queue`クラスを使用して実装されることが多いです。
|
||||
もう一方では、連結リストを使う必要がある代表的な場面は主に二分木とグラフである。スタックやキューについては、連結リストではなく、たいてい言語が提供する `stack` と `queue` を使う。
|
||||
|
||||
**Q**:リスト`res = [0] * self.size()`を初期化すると、`res`の各要素は同じアドレスを参照しますか?
|
||||
**Q**:`res = [[0]] * n` という操作で 2 次元リストを生成した場合、それぞれの `[0]` は独立していますか?
|
||||
|
||||
いいえ。しかし、この問題は二次元配列で発生します。例えば、二次元リスト`res = [[0]] * self.size()`を初期化すると、同じリスト`[0]`を複数回参照することになります。
|
||||
独立していない。この 2 次元リストでは、すべての `[0]` は実際には同一オブジェクトへの参照である。そのうちの 1 つを変更すると、対応するすべての要素が一緒に変化することがわかる。
|
||||
|
||||
**Q**:ノードを削除する際、その後続ノードへの参照を断つ必要がありますか?
|
||||
2 次元リスト内の各 `[0]` を独立させたい場合は、`res = [[0] for _ in range(n)]` を使って実現できる。この方式の原理は、独立した `[0]` リストオブジェクトを $n$ 個初期化していることにある。
|
||||
|
||||
データ構造とアルゴリズム(問題解決)の観点から、プログラムのロジックが正しい限り、リンクを断たなくても問題ありません。標準ライブラリの観点から、リンクを断つ方が安全で論理的に明確です。リンクを断たず、削除されたノードが適切にリサイクルされない場合、後続ノードのメモリのリサイクルに影響を与える可能性があります。
|
||||
**Q**:`res = [0] * n` という操作で生成されたリストでは、それぞれの整数 0 は独立していますか?
|
||||
|
||||
このリストでは、すべての整数 0 が同一オブジェクトへの参照である。これは、Python が小さな整数(通常は -5 から 256)に対してキャッシュプール機構を採用し、オブジェクトの再利用を最大化して性能を向上させているためである。
|
||||
|
||||
それらは同じオブジェクトを指しているが、それでもリスト内の各要素は独立して変更できる。これは、Python の整数が「イミュータブルオブジェクト」だからである。ある要素を変更するとき、実際には別のオブジェクトへの参照に切り替わるのであって、元のオブジェクトそのものを変更しているわけではない。
|
||||
|
||||
しかし、リスト要素が「ミュータブルオブジェクト」(たとえばリスト、辞書、クラスインスタンスなど)である場合は、ある要素を変更するとそのオブジェクト自体が直接変更され、そのオブジェクトを参照しているすべての要素に同じ変化が生じる。
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
# バックトラッキングアルゴリズム
|
||||
|
||||
<u>バックトラッキングアルゴリズム</u>は全数探索によって問題を解決する方法です。その核心概念は、初期状態から開始してすべての可能な解を総当たりで探索することです。アルゴリズムは正しいものを記録し、解が見つかるか、すべての可能な解が試されたが解が見つからないまで続けます。
|
||||
<u>バックトラッキングアルゴリズム(backtracking algorithm)</u>は、総当たりによって問題を解く手法です。その中核となる考え方は、初期状態から出発し、あり得るすべての解を力任せに探索し、正しい解に到達したらそれを記録し、解を見つけるか、考えられるすべての選択を試しても解が見つからなくなるまで続ける、というものです。
|
||||
|
||||
バックトラッキングは通常「深さ優先探索」を使用して解空間を走査します。「二分木」の章で、前順、中順、後順走査はすべて深さ優先探索であることを述べました。次に、前順走査を使用してバックトラッキング問題を解決し、アルゴリズムの動作を段階的に理解していきます。
|
||||
バックトラッキングアルゴリズムでは、通常「深さ優先探索」を用いて解空間をたどります。「二分木」の章で述べたように、前順・中順・後順走査はいずれも深さ優先探索に属します。ここでは前順走査を使ってバックトラッキング問題を構成し、その仕組みを段階的に理解していきます。
|
||||
|
||||
!!! question "例1"
|
||||
!!! question "例題1"
|
||||
|
||||
二分木が与えられた場合、値が $7$ のすべてのノードを検索して記録し、リストで返してください。
|
||||
1 本の二分木が与えられたとき、値が $7$ のノードをすべて探索して記録し、そのノードのリストを返してください。
|
||||
|
||||
この問題を解決するために、この木を前順で走査し、現在のノードの値が $7$ かどうかを確認します。そうであれば、ノードの値を結果リスト `res` に追加します。プロセスは以下の図に示されています:
|
||||
この問題では、この木を前順走査し、現在のノードの値が $7$ かどうかを判定します。該当する場合は、そのノードの値を結果リスト `res` に追加します。関連する処理は下図と次のコードのとおりです。
|
||||
|
||||
```src
|
||||
[file]{preorder_traversal_i_compact}-[class]{}-[func]{pre_order}
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
## 試行と後退
|
||||
## 試行と戻る
|
||||
|
||||
**解空間を探索する際に「試行」と「後退」戦略を使用するため、バックトラッキングアルゴリズムと呼ばれます**。探索中、満足のいく解を得るためにもはや進めない状態に遭遇するたびに、前の選択を取り消して前の状態に戻り、次の試行のために他の可能な選択を選択できるようにします。
|
||||
**バックトラッキングアルゴリズムと呼ばれるのは、解空間を探索する際に「試行」と「戻る」という戦略を取るためです**。探索中に、ある状態から先へ進めない、または条件を満たす解を得られないと分かった場合、アルゴリズムは直前の選択を取り消して前の状態へ戻り、別の選択肢を試します。
|
||||
|
||||
例1では、各ノードの訪問が「試行」を開始します。そして葉ノードを通過するか、`return` 文で親ノードに戻ることが「後退」を示唆します。
|
||||
例題1では、各ノードへの訪問が 1 回の「試行」に対応し、葉ノードを越えるか親ノードへ戻る `return` は「戻る」を表します。
|
||||
|
||||
**後退は単に関数の戻り値ではないことに注意してください**。例1の問題を少し拡張して、それが何を意味するかを説明します。
|
||||
ここで強調しておきたいのは、**戻るとは関数の return だけを指すわけではない**という点です。これを説明するために、例題1を少し拡張します。
|
||||
|
||||
!!! question "例2"
|
||||
!!! question "例題2"
|
||||
|
||||
二分木で、値が $7$ のすべてのノードを検索し、すべてのマッチングノードについて、**ルートノードからそのノードまでのパスを返してください**。
|
||||
二分木の中で値が $7$ のノードをすべて探索し、**根ノードからそれらのノードまでの経路を返してください**。
|
||||
|
||||
例1のコードに基づいて、訪問したノードパスを記録するために `path` というリストを使用する必要があります。値が $7$ のノードに到達すると、`path` をコピーして結果リスト `res` に追加します。走査後、`res` にはすべての解が保持されます。コードは以下の通りです:
|
||||
例題1のコードを土台に、訪問済みノードの経路を記録するためのリスト `path` を導入します。値が $7$ のノードに到達したら、`path` をコピーして結果リスト `res` に追加します。走査が完了すると、`res` にはすべての解が保存されています。コードは次のとおりです。
|
||||
|
||||
```src
|
||||
[file]{preorder_traversal_ii_compact}-[class]{}-[func]{pre_order}
|
||||
```
|
||||
|
||||
各「試行」で、現在のノードを `path` に追加することでパスを記録します。「後退」が必要なときはいつでも、`path` からノードをポップして**この失敗した試行前の状態を復元します**。
|
||||
各「試行」で現在のノードを `path` に追加して経路を記録し、「戻る」前にはそのノードを `path` から取り除き、**今回の試行前の状態を復元する**必要があります。
|
||||
|
||||
以下の図に示すプロセスを観察することで、**試行は「前進」のようで、後退は「元に戻す」のようです**。後者のペアは、対応するものに対する逆操作と見なすことができます。
|
||||
次の図に示す過程を見ると、**試行と戻るは「前進」と「取り消し」として理解できます**。この 2 つの操作は互いに逆向きです。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -71,72 +71,72 @@
|
||||
=== "<11>"
|
||||

|
||||
|
||||
## 剪定
|
||||
## 枝刈り
|
||||
|
||||
複雑なバックトラッキング問題は通常1つ以上の制約を含み、**これらは「剪定」によく使用されます**。
|
||||
複雑なバックトラッキング問題には、通常 1 つ以上の制約条件が含まれます。**制約条件は多くの場合「枝刈り」に利用できます**。
|
||||
|
||||
!!! question "例3"
|
||||
!!! question "例題3"
|
||||
|
||||
二分木で、値が $7$ のすべてのノードを検索し、ルートからこれらのノードまでのパスを返してください。**ただし、パスには値が $3$ のノードを含まないという制限があります**。
|
||||
二分木の中で値が $7$ のノードをすべて探索し、根ノードからそれらのノードまでの経路を返してください。**ただし、経路には値が $3$ のノードを含めてはいけません**。
|
||||
|
||||
上記の制約を満たすために、**剪定操作を追加する必要があります**:検索プロセス中に、値が $3$ のノードに遭遇した場合、そのパスを通じてさらに検索することを即座に中止します。コードは以下の通りです:
|
||||
上の制約条件を満たすために、**枝刈り操作を追加する必要があります**。探索中に値が $3$ のノードに出会った場合は、そこで早めに return し、それ以上探索を続けません。コードは次のとおりです。
|
||||
|
||||
```src
|
||||
[file]{preorder_traversal_iii_compact}-[class]{}-[func]{pre_order}
|
||||
```
|
||||
|
||||
「剪定」は非常に生き生きとした名詞です。以下の図に示すように、検索プロセスで、**制約を満たさない検索分岐を「切り取り」ます**。さらなる不要な試行を避け、検索効率を向上させます。
|
||||
「枝刈り」は非常にイメージしやすい名称です。次の図のように、探索中に**制約条件を満たさない探索分岐を切り落とす**ことで、多くの無意味な試行を避け、探索効率を高められます。
|
||||
|
||||

|
||||

|
||||
|
||||
## フレームワークコード
|
||||
|
||||
今度は、バックトラッキングから「試行、後退、剪定」の主要なフレームワークを抽出して、コードの汎用性を向上させてみましょう。
|
||||
次に、バックトラッキングにおける「試行・戻る・枝刈り」の本体部分を抽出し、汎用性の高いコードフレームワークへまとめてみます。
|
||||
|
||||
以下のフレームワークコードでは、`state` は問題の現在の状態を表し、`choices` は現在の状態で利用可能な選択肢を表します:
|
||||
以下のフレームワークコードでは、`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<Choice *> &choices, vector<State *> &res) {
|
||||
// 解かどうかを確認
|
||||
// 解かどうかを判定
|
||||
if (isSolution(state)) {
|
||||
// 解を記録
|
||||
recordSolution(state, res);
|
||||
// 検索を停止
|
||||
// これ以上探索しない
|
||||
return;
|
||||
}
|
||||
// すべての選択肢を反復
|
||||
// すべての選択肢を走査
|
||||
for (Choice choice : choices) {
|
||||
// 剪定:選択肢が有効かどうかを確認
|
||||
// 枝刈り: 選択が妥当かを判定
|
||||
if (isValid(state, choice)) {
|
||||
// 試行:選択を行い、状態を更新
|
||||
// 試行: 選択を行い、状態を更新
|
||||
makeChoice(state, choice);
|
||||
backtrack(state, choices, res);
|
||||
// 後退:選択を取り消し、前の状態に戻す
|
||||
// 戻る: 選択を取り消し、前の状態に戻す
|
||||
undoChoice(state, choice);
|
||||
}
|
||||
}
|
||||
@@ -146,23 +146,23 @@
|
||||
=== "Java"
|
||||
|
||||
```java title=""
|
||||
/* バックトラッキングアルゴリズムフレームワーク */
|
||||
/* バックトラッキングアルゴリズムのフレームワーク */
|
||||
void backtrack(State state, List<Choice> choices, List<State> res) {
|
||||
// 解かどうかを確認
|
||||
// 解かどうかを判定
|
||||
if (isSolution(state)) {
|
||||
// 解を記録
|
||||
recordSolution(state, res);
|
||||
// 検索を停止
|
||||
// これ以上探索しない
|
||||
return;
|
||||
}
|
||||
// すべての選択肢を反復
|
||||
// すべての選択肢を走査
|
||||
for (Choice choice : choices) {
|
||||
// 剪定:選択肢が有効かどうかを確認
|
||||
// 枝刈り: 選択が妥当かを判定
|
||||
if (isValid(state, choice)) {
|
||||
// 試行:選択を行い、状態を更新
|
||||
// 試行: 選択を行い、状態を更新
|
||||
makeChoice(state, choice);
|
||||
backtrack(state, choices, res);
|
||||
// 後退:選択を取り消し、前の状態に戻す
|
||||
// 戻る: 選択を取り消し、前の状態に戻す
|
||||
undoChoice(state, choice);
|
||||
}
|
||||
}
|
||||
@@ -172,23 +172,23 @@
|
||||
=== "C#"
|
||||
|
||||
```csharp title=""
|
||||
/* バックトラッキングアルゴリズムフレームワーク */
|
||||
/* バックトラッキングアルゴリズムのフレームワーク */
|
||||
void Backtrack(State state, List<Choice> choices, List<State> res) {
|
||||
// 解かどうかを確認
|
||||
// 解かどうかを判定
|
||||
if (IsSolution(state)) {
|
||||
// 解を記録
|
||||
RecordSolution(state, res);
|
||||
// 検索を停止
|
||||
// これ以上探索しない
|
||||
return;
|
||||
}
|
||||
// すべての選択肢を反復
|
||||
// すべての選択肢を走査
|
||||
foreach (Choice choice in choices) {
|
||||
// 剪定:選択肢が有効かどうかを確認
|
||||
// 枝刈り: 選択が妥当かを判定
|
||||
if (IsValid(state, choice)) {
|
||||
// 試行:選択を行い、状態を更新
|
||||
// 試行: 選択を行い、状態を更新
|
||||
MakeChoice(state, choice);
|
||||
Backtrack(state, choices, res);
|
||||
// 後退:選択を取り消し、前の状態に戻す
|
||||
// 戻る: 選択を取り消し、前の状態に戻す
|
||||
UndoChoice(state, choice);
|
||||
}
|
||||
}
|
||||
@@ -198,23 +198,23 @@
|
||||
=== "Go"
|
||||
|
||||
```go title=""
|
||||
/* バックトラッキングアルゴリズムフレームワーク */
|
||||
/* バックトラッキングアルゴリズムのフレームワーク */
|
||||
func backtrack(state *State, choices []Choice, res *[]State) {
|
||||
// 解かどうかを確認
|
||||
// 解かどうかを判定
|
||||
if isSolution(state) {
|
||||
// 解を記録
|
||||
recordSolution(state, res)
|
||||
// 検索を停止
|
||||
// これ以上探索しない
|
||||
return
|
||||
}
|
||||
// すべての選択肢を反復
|
||||
// すべての選択肢を走査
|
||||
for _, choice := range choices {
|
||||
// 剪定:選択肢が有効かどうかを確認
|
||||
// 枝刈り: 選択が妥当かを判定
|
||||
if isValid(state, choice) {
|
||||
// 試行:選択を行い、状態を更新
|
||||
// 試行: 選択を行い、状態を更新
|
||||
makeChoice(state, choice)
|
||||
backtrack(state, choices, res)
|
||||
// 後退:選択を取り消し、前の状態に戻す
|
||||
// 戻る: 選択を取り消し、前の状態に戻す
|
||||
undoChoice(state, choice)
|
||||
}
|
||||
}
|
||||
@@ -224,23 +224,23 @@
|
||||
=== "Swift"
|
||||
|
||||
```swift title=""
|
||||
/* バックトラッキングアルゴリズムフレームワーク */
|
||||
/* バックトラッキングアルゴリズムのフレームワーク */
|
||||
func backtrack(state: inout State, choices: [Choice], res: inout [State]) {
|
||||
// 解かどうかを確認
|
||||
// 解かどうかを判定
|
||||
if isSolution(state: state) {
|
||||
// 解を記録
|
||||
recordSolution(state: state, res: &res)
|
||||
// 検索を停止
|
||||
// これ以上探索しない
|
||||
return
|
||||
}
|
||||
// すべての選択肢を反復
|
||||
// すべての選択肢を走査
|
||||
for choice in choices {
|
||||
// 剪定:選択肢が有効かどうかを確認
|
||||
// 枝刈り: 選択が妥当かを判定
|
||||
if isValid(state: state, choice: choice) {
|
||||
// 試行:選択を行い、状態を更新
|
||||
// 試行: 選択を行い、状態を更新
|
||||
makeChoice(state: &state, choice: choice)
|
||||
backtrack(state: &state, choices: choices, res: &res)
|
||||
// 後退:選択を取り消し、前の状態に戻す
|
||||
// 戻る: 選択を取り消し、前の状態に戻す
|
||||
undoChoice(state: &state, choice: choice)
|
||||
}
|
||||
}
|
||||
@@ -250,23 +250,23 @@
|
||||
=== "JS"
|
||||
|
||||
```javascript title=""
|
||||
/* バックトラッキングアルゴリズムフレームワーク */
|
||||
/* バックトラッキングアルゴリズムのフレームワーク */
|
||||
function backtrack(state, choices, res) {
|
||||
// 解かどうかを確認
|
||||
// 解かどうかを判定
|
||||
if (isSolution(state)) {
|
||||
// 解を記録
|
||||
recordSolution(state, res);
|
||||
// 検索を停止
|
||||
// これ以上探索しない
|
||||
return;
|
||||
}
|
||||
// すべての選択肢を反復
|
||||
// すべての選択肢を走査
|
||||
for (let choice of choices) {
|
||||
// 剪定:選択肢が有効かどうかを確認
|
||||
// 枝刈り: 選択が妥当かを判定
|
||||
if (isValid(state, choice)) {
|
||||
// 試行:選択を行い、状態を更新
|
||||
// 試行: 選択を行い、状態を更新
|
||||
makeChoice(state, choice);
|
||||
backtrack(state, choices, res);
|
||||
// 後退:選択を取り消し、前の状態に戻す
|
||||
// 戻る: 選択を取り消し、前の状態に戻す
|
||||
undoChoice(state, choice);
|
||||
}
|
||||
}
|
||||
@@ -276,23 +276,23 @@
|
||||
=== "TS"
|
||||
|
||||
```typescript title=""
|
||||
/* バックトラッキングアルゴリズムフレームワーク */
|
||||
/* バックトラッキングアルゴリズムのフレームワーク */
|
||||
function backtrack(state: State, choices: Choice[], res: State[]): void {
|
||||
// 解かどうかを確認
|
||||
// 解かどうかを判定
|
||||
if (isSolution(state)) {
|
||||
// 解を記録
|
||||
recordSolution(state, res);
|
||||
// 検索を停止
|
||||
// これ以上探索しない
|
||||
return;
|
||||
}
|
||||
// すべての選択肢を反復
|
||||
// すべての選択肢を走査
|
||||
for (let choice of choices) {
|
||||
// 剪定:選択肢が有効かどうかを確認
|
||||
// 枝刈り: 選択が妥当かを判定
|
||||
if (isValid(state, choice)) {
|
||||
// 試行:選択を行い、状態を更新
|
||||
// 試行: 選択を行い、状態を更新
|
||||
makeChoice(state, choice);
|
||||
backtrack(state, choices, res);
|
||||
// 後退:選択を取り消し、前の状態に戻す
|
||||
// 戻る: 選択を取り消し、前の状態に戻す
|
||||
undoChoice(state, choice);
|
||||
}
|
||||
}
|
||||
@@ -302,23 +302,23 @@
|
||||
=== "Dart"
|
||||
|
||||
```dart title=""
|
||||
/* バックトラッキングアルゴリズムフレームワーク */
|
||||
/* バックトラッキングアルゴリズムのフレームワーク */
|
||||
void backtrack(State state, List<Choice>, List<State> res) {
|
||||
// 解かどうかを確認
|
||||
// 解かどうかを判定
|
||||
if (isSolution(state)) {
|
||||
// 解を記録
|
||||
recordSolution(state, res);
|
||||
// 検索を停止
|
||||
// これ以上探索しない
|
||||
return;
|
||||
}
|
||||
// すべての選択肢を反復
|
||||
// すべての選択肢を走査
|
||||
for (Choice choice in choices) {
|
||||
// 剪定:選択肢が有効かどうかを確認
|
||||
// 枝刈り: 選択が妥当かを判定
|
||||
if (isValid(state, choice)) {
|
||||
// 試行:選択を行い、状態を更新
|
||||
// 試行: 選択を行い、状態を更新
|
||||
makeChoice(state, choice);
|
||||
backtrack(state, choices, res);
|
||||
// 後退:選択を取り消し、前の状態に戻す
|
||||
// 戻る: 選択を取り消し、前の状態に戻す
|
||||
undoChoice(state, choice);
|
||||
}
|
||||
}
|
||||
@@ -328,23 +328,23 @@
|
||||
=== "Rust"
|
||||
|
||||
```rust title=""
|
||||
/* バックトラッキングアルゴリズムフレームワーク */
|
||||
/* バックトラッキングアルゴリズムのフレームワーク */
|
||||
fn backtrack(state: &mut State, choices: &Vec<Choice>, res: &mut Vec<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);
|
||||
}
|
||||
}
|
||||
@@ -354,23 +354,23 @@
|
||||
=== "C"
|
||||
|
||||
```c title=""
|
||||
/* バックトラッキングアルゴリズムフレームワーク */
|
||||
/* バックトラッキングアルゴリズムのフレームワーク */
|
||||
void backtrack(State *state, Choice *choices, int numChoices, State *res, int numRes) {
|
||||
// 解かどうかを確認
|
||||
// 解かどうかを判定
|
||||
if (isSolution(state)) {
|
||||
// 解を記録
|
||||
recordSolution(state, res, numRes);
|
||||
// 検索を停止
|
||||
// これ以上探索しない
|
||||
return;
|
||||
}
|
||||
// すべての選択肢を反復
|
||||
// すべての選択肢を走査
|
||||
for (int i = 0; i < numChoices; i++) {
|
||||
// 剪定:選択肢が有効かどうかを確認
|
||||
// 枝刈り: 選択が妥当かを判定
|
||||
if (isValid(state, &choices[i])) {
|
||||
// 試行:選択を行い、状態を更新
|
||||
// 試行: 選択を行い、状態を更新
|
||||
makeChoice(state, &choices[i]);
|
||||
backtrack(state, choices, numChoices, res, numRes);
|
||||
// 後退:選択を取り消し、前の状態に戻す
|
||||
// 戻る: 選択を取り消し、前の状態に戻す
|
||||
undoChoice(state, &choices[i]);
|
||||
}
|
||||
}
|
||||
@@ -380,23 +380,23 @@
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title=""
|
||||
/* バックトラッキングアルゴリズムフレームワーク */
|
||||
/* バックトラッキングアルゴリズムのフレームワーク */
|
||||
fun backtrack(state: State?, choices: List<Choice?>, res: List<State?>?) {
|
||||
// 解かどうかを確認
|
||||
// 解かどうかを判定
|
||||
if (isSolution(state)) {
|
||||
// 解を記録
|
||||
recordSolution(state, res)
|
||||
// 検索を停止
|
||||
// これ以上探索しない
|
||||
return
|
||||
}
|
||||
// すべての選択肢を反復
|
||||
// すべての選択肢を走査
|
||||
for (choice in choices) {
|
||||
// 剪定:選択肢が有効かどうかを確認
|
||||
// 枝刈り: 選択が妥当かを判定
|
||||
if (isValid(state, choice)) {
|
||||
// 試行:選択を行い、状態を更新
|
||||
// 試行: 選択を行い、状態を更新
|
||||
makeChoice(state, choice)
|
||||
backtrack(state, choices, res)
|
||||
// 後退:選択を取り消し、前の状態に戻す
|
||||
// 戻る: 選択を取り消し、前の状態に戻す
|
||||
undoChoice(state, choice)
|
||||
}
|
||||
}
|
||||
@@ -406,98 +406,98 @@
|
||||
=== "Ruby"
|
||||
|
||||
```ruby title=""
|
||||
### バックトラッキングアルゴリズムフレームワーク ###
|
||||
### バックトラッキングアルゴリズムのフレームワーク ###
|
||||
def backtrack(state, choices, res)
|
||||
# 解かどうかを確認
|
||||
# 解かどうかを判定
|
||||
if is_solution?(state)
|
||||
# 解を記録
|
||||
record_solution(state, res)
|
||||
return
|
||||
end
|
||||
|
||||
# すべての選択肢を反復
|
||||
# すべての選択肢を走査
|
||||
for choice in choices
|
||||
# 剪定:選択肢が有効かどうかを確認
|
||||
# 枝刈り: 選択が妥当かを判定
|
||||
if is_valid?(state, choice)
|
||||
# 試行:選択を行い、状態を更新
|
||||
# 試行: 選択を行い、状態を更新
|
||||
make_choice(state, choice)
|
||||
backtrack(state, choices, res)
|
||||
# 後退:選択を取り消し、前の状態に戻す
|
||||
# 戻る: 選択を取り消し、前の状態に戻す
|
||||
undo_choice(state, choice)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
次に、フレームワークコードに基づいて例題 3 を解きます。状態 `state` はノードの走査経路を表し、選択肢 `choices` は現在ノードの左子ノードと右子ノード、結果 `res` は経路リストです:
|
||||
次に、このフレームワークコードを用いて例題3を解きます。状態 `state` はノードの走査経路、選択肢 `choices` は現在のノードの左子ノードと右子ノード、結果 `res` は経路のリストです。
|
||||
|
||||
```src
|
||||
[file]{preorder_traversal_iii_template}-[class]{}-[func]{backtrack}
|
||||
```
|
||||
|
||||
問題文の意味に従い、値が $7$ のノードを見つけた後も探索を続ける必要があります。**したがって、解を記録した後の `return` 文を削除する必要があります**。次の図は、`return` 文を保持する場合と削除する場合の探索過程の比較です。
|
||||
問題の条件より、値が $7$ のノードを見つけた後も探索を続ける必要があります。**そのため、解を記録した後の `return` 文は削除しなければなりません**。次の図は、`return` 文を残す場合と削除する場合の探索過程を比較したものです。
|
||||
|
||||

|
||||

|
||||
|
||||
前順走査に基づくコード実装と比べると、バックトラッキングアルゴリズムのフレームワークに基づく実装はやや冗長に見えますが、汎用性はより高いです。実際、**多くのバックトラッキング問題はこのフレームワークの下で解くことができます**。具体的な問題に応じて `state` と `choices` を定義し、フレームワーク内の各メソッドを実装すればよいのです。
|
||||
前順走査にもとづく実装と比べると、バックトラッキングアルゴリズムのフレームワークにもとづく実装はやや冗長に見えますが、汎用性に優れています。実際、**多くのバックトラッキング問題はこのフレームワークで解けます**。具体的な問題に応じて `state` と `choices` を定義し、各メソッドを実装すれば十分です。
|
||||
|
||||
## よく使われる用語
|
||||
|
||||
アルゴリズム問題をより明確に分析するために、バックトラッキングアルゴリズムでよく使われる用語の意味をまとめ、例題 3 の対応例を以下の表に示します。
|
||||
アルゴリズム問題をより明確に分析するために、バックトラッキングでよく使われる用語の意味を整理し、例題3に対応する例を次の表にまとめます。
|
||||
|
||||
<p align="center"> 表 <id> バックトラッキングアルゴリズムでよく使われる用語 </p>
|
||||
<p align="center"> 表 <id> よく使われるバックトラッキング用語 </p>
|
||||
|
||||
| 名称 | 定義 | 例題 3 |
|
||||
| ------------------------------ | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
|
||||
| 解(solution) | 解は問題の特定条件を満たす答えであり、1 つまたは複数存在する可能性がある | 根ノードからノード $7$ までの制約条件を満たすすべての経路 |
|
||||
| 制約条件(constraint) | 制約条件は、解の実現可能性を制限する条件であり、通常は枝刈りに使用される | 経路にノード $3$ を含まない |
|
||||
| 状態(state) | 状態は、ある時点での問題の状況を表し、これまでに行った選択を含む | 現在訪問したノード経路、すなわち `path` ノードリスト |
|
||||
| 試行(attempt) | 試行は、利用可能な選択肢に基づいて解空間を探索する過程であり、選択を行い、状態を更新し、解かどうかを確認する | 左(右)子ノードを再帰的に訪問し、ノードを `path` に追加し、ノードの値が $7$ かを確認する |
|
||||
| バックトラック(backtracking) | 制約条件を満たさない状態に遭遇した場合、以前の選択を取り消して前の状態に戻ること | 葉ノードを越えたとき、探索終了、値が $3$ のノードに遭遇したとき探索を終了し、関数が戻る |
|
||||
| 枝刈り(pruning) | 問題の特性や制約条件に基づき、無意味な探索経路を避ける方法であり、探索効率を向上させる | 値が $3$ のノードに遭遇した場合、それ以上探索しない |
|
||||
| 用語 | 定義 | 例題3 |
|
||||
| ---------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| 解(solution) | 問題の特定の条件を満たす答えであり、1 つまたは複数存在し得る | 根ノードからノード $7$ までの、制約条件を満たすすべての経路 |
|
||||
| 制約条件(constraint) | 解の実現可能性を制限する条件であり、通常は枝刈りに用いられる | 経路にノード $3$ を含まないこと |
|
||||
| 状態(state) | ある時点における問題の状況を表し、すでに行った選択を含む | 現在までに訪問したノードの経路、すなわち `path` ノードリスト |
|
||||
| 試行(attempt) | 利用可能な選択肢にもとづいて解空間を探索する過程であり、選択、状態更新、解判定を含む | 左右の子ノードを再帰的に訪問し、ノードを `path` に追加し、値が $7$ か判定する |
|
||||
| 戻る(backtracking) | 制約条件を満たさない状態に出会ったとき、それまでの選択を取り消して前の状態へ戻ること | 葉ノードを越えたとき、ノード訪問を終えたとき、値が $3$ のノードに出会ったときに探索を終了し、関数から戻る |
|
||||
| 枝刈り(pruning) | 問題の性質や制約条件にもとづき、無意味な探索経路を避ける方法であり、探索効率を高める | 値が $3$ のノードに出会ったら、それ以上探索しない |
|
||||
|
||||
!!! tip
|
||||
|
||||
問題、解、状態などの概念は一般的なものであり、分割統治、バックトラッキング、動的計画法、貪欲法などのアルゴリズムにも関係します。
|
||||
問題、解、状態などの概念は汎用的であり、分割統治、バックトラッキング、動的計画法、貪欲法などのアルゴリズムにも共通して現れます。
|
||||
|
||||
## 長所と限界
|
||||
## 利点と限界
|
||||
|
||||
バックトラッキングアルゴリズムは本質的に深さ優先探索(DFS)アルゴリズムの一種であり、条件を満たす解を見つけるまであらゆる可能な解を試みます。この方法の利点は、すべての可能な解を見つけられる点であり、適切な枝刈りを行えば効率が高いことです。
|
||||
バックトラッキングアルゴリズムの本質は深さ優先探索です。条件を満たす解を見つけるまで、あり得るすべての解を試します。この方法の利点は、考えられるすべての解を見つけられることであり、適切な枝刈りを行えば高い効率を発揮します。
|
||||
|
||||
しかし、大規模または複雑な問題を扱う場合、**バックトラッキングアルゴリズムの実行効率は許容できないほど低下する可能性があります**。
|
||||
しかし、大規模または複雑な問題を扱う場合、**バックトラッキングアルゴリズムの実行効率は受け入れがたいことがあります**。
|
||||
|
||||
- **時間**:バックトラッキングアルゴリズムは通常、状態空間のすべての可能性を探索する必要があり、時間計算量は指数オーダーまたは階乗オーダーに達する可能性があります。
|
||||
- **空間**:再帰呼び出し中に現在の状態(例:経路、枝刈り用の補助変数など)を保存する必要があり、深さが大きい場合、空間の使用量が増加します。
|
||||
- **時間**:バックトラッキングアルゴリズムでは通常、状態空間のすべての可能性をたどる必要があり、時間計算量は指数時間や階乗時間に達することがあります。
|
||||
- **空間**:再帰呼び出しの過程では現在の状態(たとえば経路や枝刈り用の補助変数など)を保持する必要があり、深さが大きいと空間使用量も大きくなります。
|
||||
|
||||
それでもなお、**バックトラッキングアルゴリズムは特定の探索問題や制約満足問題の最良の解法であることが多いです**。これらの問題では、どの選択が有効な解を生成するかを予測できないため、すべての可能な選択を試す必要があります。このような場合、**効率の最適化が鍵**となります。一般的な最適化手法は次の 2 つです。
|
||||
それでもなお、**バックトラッキングアルゴリズムは一部の探索問題や制約充足問題に対する最良の解法です**。この種の問題では、どの選択が有効な解を生むかを事前に予測できないため、可能な選択肢をすべてたどる必要があります。このときの鍵は**いかに効率を最適化するか**であり、代表的な方法は 2 つあります。
|
||||
|
||||
- **枝刈り**:解を生成しないことが確実な経路を避けることで、時間と空間を節約します。
|
||||
- **ヒューリスティック探索**:探索中に戦略や評価値を導入し、有効な解を生成する可能性が高い経路を優先的に探索します。
|
||||
- **枝刈り**:解が生じないことが確実な経路を探索しないことで、時間と空間を節約する。
|
||||
- **ヒューリスティック探索**:探索中に何らかの戦略や推定値を導入し、有効な解を生みやすい経路を優先的に探索する。
|
||||
|
||||
## バックトラッキングの典型的な例題
|
||||
## バックトラッキングの典型例題
|
||||
|
||||
バックトラッキングアルゴリズムは、多くの探索問題、制約満足問題、組合せ最適化問題を解くのに使用できます。
|
||||
バックトラッキングアルゴリズムは、多くの探索問題、制約充足問題、組合せ最適化問題の解決に利用できます。
|
||||
|
||||
**探索問題**:この種の問題の目標は、特定の条件を満たす解を見つけることです。
|
||||
|
||||
- 全順列問題:与えられた集合のすべての可能な順列を求める。
|
||||
- 部分和問題:与えられた集合と目標和に対して、和が目標値になるすべての部分集合を求める。
|
||||
- ハノイの塔:3 本の柱と異なるサイズの円盤があり、すべての円盤を 1 本の柱から別の柱に移す。1 回に 1 枚しか動かせず、大きな円盤を小さい円盤の上に置くことはできない。
|
||||
- 全順列問題:ある集合が与えられたとき、考えられるすべての順列を求める。
|
||||
- 部分和問題:ある集合と目標和が与えられたとき、和が目標値となるすべての部分集合を見つける。
|
||||
- ハノイの塔問題:3 本の柱と大きさの異なる複数の円盤が与えられたとき、すべての円盤を 1 本の柱から別の柱へ移動する。ただし 1 回に 1 枚しか動かせず、大きい円盤を小さい円盤の上に置いてはならない。
|
||||
|
||||
**制約満足問題**:この種の問題の目標は、すべての制約条件を満たす解を見つけることです。
|
||||
**制約充足問題**:この種の問題の目標は、すべての制約条件を満たす解を見つけることです。
|
||||
|
||||
- $n$ クイーン問題:$n imes n$ のチェス盤に $n$ 個のクイーンを配置し、互いに攻撃しないようにする。
|
||||
- 数独:$9 imes 9$ のグリッドに数字 $1$ \~ $9$ を入力し、各行、列、$3 imes 3$ のサブグリッドに重複がないようにする。
|
||||
- グラフ彩色問題:与えられた無向グラフに対し、隣接頂点が異なる色になるように最小限の色で彩色する。
|
||||
- $n$ クイーン問題:$n \times n$ の盤面に $n$ 個のクイーンを配置し、互いに攻撃し合わないようにする。
|
||||
- 数独:$9 \times 9$ のグリッドに数字 $1$ ~ $9$ を入れ、各行・各列・各 $3 \times 3$ の小区画で数字が重複しないようにする。
|
||||
- グラフ彩色問題:無向グラフが与えられたとき、隣接する頂点が同じ色にならないように、できるだけ少ない色で各頂点を彩色する。
|
||||
|
||||
**組合せ最適化問題**:この種の問題の目標は、組合せ空間内で特定の条件を満たす最適解を見つけることです。
|
||||
**組合せ最適化問題**:この種の問題の目標は、組合せ空間の中で条件を満たす最適解を見つけることです。
|
||||
|
||||
- 0-1 ナップサック問題:与えられた物品群とバックパックがあり、各物品には価値と重さが設定されている。バックパックの容量制限内で、総価値を最大化する物品の選択を求める。
|
||||
- 旅行セールスマン問題:グラフ上で、1 つの点から出発し、すべての他の点を 1 回ずつ訪問して出発点に戻る最短経路を求める。
|
||||
- 最大クリーク問題:与えられた無向グラフの中で、任意の 2 頂点間に辺が存在する最大の完全部分グラフを見つける。
|
||||
- 0-1 ナップサック問題:複数の品物とナップサックが与えられ、各品物には価値と重さがある。ナップサック容量の範囲内で総価値が最大になるように品物を選ぶ。
|
||||
- 巡回セールスマン問題:グラフ内のある頂点から出発し、他のすべての頂点をちょうど 1 回ずつ訪れて出発点へ戻るときの最短経路を求める。
|
||||
- 最大クリーク問題:無向グラフが与えられたとき、任意の 2 頂点間に辺が存在する最大の完全部分グラフを見つける。
|
||||
|
||||
注意すべきは、多くの組合せ最適化問題に対して、バックトラッキングが最適解法ではないということです。
|
||||
多くの組合せ最適化問題では、バックトラッキングは最適な解法ではない点に注意してください。
|
||||
|
||||
- 0-1 ナップサック問題は、時間効率を高めるために動的計画法がよく使用されます。
|
||||
- 旅行セールスマン問題は有名な NP-Hard 問題であり、遺伝的アルゴリズムやアントコロニーアルゴリズムなどの手法がよく使われます。
|
||||
- 最大クリーク問題はグラフ理論の古典的な問題であり、貪欲法などのヒューリスティックアルゴリズムで解くことができます。
|
||||
- 0-1 ナップサック問題は通常、より高い時間効率を得るために動的計画法で解く。
|
||||
- 巡回セールスマン問題は著名な NP-Hard 問題であり、よく用いられる解法には遺伝的アルゴリズムや蟻コロニー最適化などがある。
|
||||
- 最大クリーク問題はグラフ理論における古典的問題であり、貪欲法などのヒューリスティックで解ける。
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
|
||||
!!! abstract
|
||||
|
||||
迷路の探検家のように、私たちは前進する道で障害に遭遇することがあります。
|
||||
|
||||
バックトラッキングの力は、私たちに新しく始めること、試し続けること、そして最終的に光への出口を見つけることを可能にします。
|
||||
私たちは迷宮の探検者のように、前へ進む道で困難に出会うことがあります。
|
||||
|
||||
バックトラッキングの力によってやり直しができ、試行を重ね、最後には光へ通じる出口を見つけられます。
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
# Nクイーン問題
|
||||
# n クイーン問題
|
||||
|
||||
!!! question
|
||||
|
||||
チェスのルールによると、クイーンは同じ行、列、または対角線上の駒を攻撃できます。$n$ 個のクイーンと $n \times n$ のチェスボードが与えられた場合、2つのクイーンが互いに攻撃できない配置を見つけてください。
|
||||
チェスのルールによれば、クイーンは同じ行、同じ列、または同じ斜線上にある駒を攻撃できます。$n$ 個のクイーンと $n \times n$ サイズの盤面が与えられたとき、すべてのクイーンが互いに攻撃し合わない配置を求めます。
|
||||
|
||||
以下の図に示すように、$n = 4$ の場合、2つの解があります。バックトラッキングアルゴリズムの観点から、$n \times n$ のチェスボードには $n^2$ 個のマスがあり、すべての可能な選択肢 `choices` を示しています。チェスボードの状態 `state` は、各クイーンが配置されるにつれて継続的に変化します。
|
||||
下図に示すように、$n = 4$ のとき、2 つの解を見つけることができます。バックトラッキングの観点から見ると、$n \times n$ サイズの盤面には合計 $n^2$ 個のマスがあり、これがすべての選択肢 `choices` を与えます。クイーンを 1 つずつ配置していく過程で、盤面の状態は絶えず変化し、その各時点の盤面が状態 `state` です。
|
||||
|
||||

|
||||

|
||||
|
||||
以下の図は、この問題の3つの制約を示しています:**複数のクイーンは同じ行、列、または対角線を占有できません**。対角線は主対角線 `\` と副対角線 `/` に分かれることに注意することが重要です。
|
||||
下図は本問題の 3 つの制約条件を示しています。**複数のクイーンは同じ行、同じ列、同じ対角線上に置けません**。なお、対角線には主対角線 `\` と副対角線 `/` の 2 種類があります。
|
||||
|
||||

|
||||

|
||||
|
||||
### 行ごとの配置戦略
|
||||
|
||||
クイーンの数がチェスボードの行数と等しく、どちらも $n$ であるため、**チェスボードの各行には1つのクイーンのみが配置できることが**容易に結論付けられます。
|
||||
クイーンの数と盤面の行数はいずれも $n$ なので、次の推論を容易に得られます:**盤面の各行にはクイーンを 1 つだけ配置できます**。
|
||||
|
||||
これは、行ごとの配置戦略を採用できることを意味します:最初の行から開始して、最後の行に到達するまで行ごとに1つのクイーンを配置します。
|
||||
つまり、行ごとの配置戦略を採用できます:最初の行から始めて、各行に 1 つのクイーンを配置し、最後の行まで進みます。
|
||||
|
||||
以下の図は、4クイーン問題の行ごとの配置プロセスを示しています。スペースの制限により、図は最初の行の1つの検索分岐のみを展開し、列と対角線の制約を満たさない配置を剪定します。
|
||||
下図は 4 クイーン問題における行ごとの配置過程を示しています。図の大きさの都合上、下図では 1 行目における検索分岐の 1 つだけを展開し、列制約と対角線制約を満たさない案はすべて枝刈りしています。
|
||||
|
||||

|
||||
|
||||
本質的に、**行ごとの配置戦略は剪定関数として機能し**、同じ行に複数のクイーンを配置するすべての検索分岐を除去します。
|
||||
本質的には、**行ごとの配置戦略は枝刈りとして機能します**。これにより、同じ行に複数のクイーンが現れるすべての探索分岐を回避できます。
|
||||
|
||||
### 列と対角線の剪定
|
||||
### 列と対角線の枝刈り
|
||||
|
||||
列の制約を満たすために、長さ $n$ のブール配列 `cols` を使用して、各列にクイーンが占有されているかどうかを追跡できます。各配置決定の前に、`cols` を使用してすでにクイーンがある列を剪定し、バックトラッキング中に動的に更新されます。
|
||||
列制約を満たすために、長さ $n$ のブール配列 `cols` を用いて、各列にクイーンがあるかどうかを記録できます。配置を決めるたびに、`cols` を使って既存のクイーンがある列を枝刈りし、バックトラッキングの中で `cols` の状態を動的に更新します。
|
||||
|
||||
!!! tip
|
||||
|
||||
行列の原点は左上隅にあり、行インデックスは上から下に増加し、列インデックスは左から右に増加することに注意してください。
|
||||
注意として、行列の原点は左上にあり、行インデックスは上から下へ、列インデックスは左から右へ増加します。
|
||||
|
||||
対角線の制約はどうでしょうか?チェスボード上の特定のセルの行と列のインデックスを $(row, col)$ とします。特定の主対角線を選択することで、その対角線上のすべてのセルで差 $row - col$ が同じであることに気付きます。**つまり、$row - col$ は主対角線上で定数値です**。
|
||||
では、対角線制約はどのように扱えばよいのでしょうか。盤面上のあるマスの行列インデックスを $(row, col)$ とし、行列内のある主対角線を選ぶと、その対角線上のすべてのマスで行インデックスから列インデックスを引いた値が等しいことが分かります。**つまり、主対角線上のすべてのマスでは $row - col$ が一定値になります**。
|
||||
|
||||
言い換えると、2つのセルが $row_1 - col_1 = row_2 - col_2$ を満たす場合、それらは確実に同じ主対角線上にあります。このパターンを使用して、以下の図に示す配列 `diags1` を利用して、クイーンが主対角線上にあるかどうかを追跡できます。
|
||||
つまり、2 つのマスが $row_1 - col_1 = row_2 - col_2$ を満たすなら、それらは必ず同じ主対角線上にあります。この性質を利用して、下図の配列 `diags1` により、各主対角線にクイーンがあるかどうかを記録できます。
|
||||
|
||||
同様に、**$row + col$ の和は副対角線上のすべてのセルで定数値です**。配列 `diags2` を使用して副対角線の制約も処理できます。
|
||||
同様に、**副対角線上のすべてのマスでは $row + col$ が一定値です**。副対角線制約も配列 `diags2` を使って処理できます。
|
||||
|
||||

|
||||

|
||||
|
||||
### コード実装
|
||||
|
||||
$n$ 次元の正方行列では、$row - col$ の範囲は $[-n + 1, n - 1]$ で、$row + col$ の範囲は $[0, 2n - 2]$ であることに注意してください。したがって、主対角線と副対角線の数はどちらも $2n - 1$ で、配列 `diags1` と `diags2` の長さは $2n - 1$ です。
|
||||
注意として、$n$ 次正方行列では $row - col$ の範囲は $[-n + 1, n - 1]$ 、$row + col$ の範囲は $[0, 2n - 2]$ です。したがって、主対角線と副対角線の本数はいずれも $2n - 1$ であり、配列 `diags1` と `diags2` の長さもともに $2n - 1$ です。
|
||||
|
||||
```src
|
||||
[file]{n_queens}-[class]{}-[func]{n_queens}
|
||||
```
|
||||
|
||||
$n$ 個のクイーンを行ごとに配置し、列の制約を考慮して、最初の行から最後の行まで、$n$、$n-1$、$\dots$、$2$、$1$ の選択肢があり、$O(n!)$ 時間を使用します。解を記録する際、行列 `state` をコピーして `res` に追加する必要があり、コピー操作は $O(n^2)$ 時間を使用します。したがって、**全体の時間計算量は $O(n! \cdot n^2)$ です**。実際には、対角線制約に基づく剪定により検索空間を大幅に削減できるため、多くの場合、検索効率は上記の時間計算量よりも優れています。
|
||||
行ごとに $n$ 回配置し、列制約を考慮すると、1 行目から最終行までの選択肢はそれぞれ $n$、$n-1$、$\dots$、$2$、$1$ 個となるため、時間計算量は $O(n!)$ です。解を記録する際には、行列 `state` をコピーして `res` に追加する必要があり、このコピー操作には $O(n^2)$ 時間を要します。したがって、**全体の時間計算量は $O(n! \cdot n^2)$** です。実際には、対角線制約による枝刈りも探索空間を大きく縮小できるため、探索効率はしばしば上記の時間計算量より良くなります。
|
||||
|
||||
配列 `state` は $O(n^2)$ 空間を使用し、配列 `cols`、`diags1`、`diags2` はそれぞれ $O(n)$ 空間を使用します。最大再帰深度は $n$ で、$O(n)$ のスタックフレーム空間を使用します。したがって、**空間計算量は $O(n^2)$ です**。
|
||||
配列 `state` は $O(n^2)$ の空間を使用し、配列 `cols`、`diags1`、`diags2` はいずれも $O(n)$ の空間を使用します。最大再帰深さは $n$ で、スタックフレーム空間として $O(n)$ を使用します。したがって、**空間計算量は $O(n^2)$** です。
|
||||
|
||||
@@ -1,95 +1,95 @@
|
||||
# 順列問題
|
||||
# 全順列問題
|
||||
|
||||
順列問題は、バックトラッキングアルゴリズムの典型的な応用です。これは、配列や文字列などの与えられた集合から要素のすべての可能な配置(順列)を見つけることを含みます。
|
||||
全順列問題はバックトラッキングアルゴリズムの典型的な応用例です。これは、ある集合(配列や文字列など)が与えられたとき、その要素のあり得るすべての順列を求める問題です。
|
||||
|
||||
以下の表は、入力配列とその対応する順列を含むいくつかの例を示しています。
|
||||
下表に、入力配列とそれに対応するすべての順列から成る例をいくつか示します。
|
||||
|
||||
<p align="center"> 表 <id> 順列の例 </p>
|
||||
<p align="center"> 表 <id> 全順列の例 </p>
|
||||
|
||||
| 入力配列 | 順列 |
|
||||
| :----------- | :----------------------------------------------------------------- |
|
||||
| $[1]$ | $[1]$ |
|
||||
| $[1, 2]$ | $[1, 2], [2, 1]$ |
|
||||
| $[1, 2, 3]$ | $[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]$ |
|
||||
| 入力配列 | すべての順列 |
|
||||
| :---------- | :----------------------------------------------------------------- |
|
||||
| $[1]$ | $[1]$ |
|
||||
| $[1, 2]$ | $[1, 2], [2, 1]$ |
|
||||
| $[1, 2, 3]$ | $[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]$ |
|
||||
|
||||
## 重複要素がない場合
|
||||
## 等しい要素がない場合
|
||||
|
||||
!!! question
|
||||
|
||||
重複要素のない整数配列が与えられた場合、すべての可能な順列を返してください。
|
||||
重複要素を含まない整数配列を入力として受け取り、あり得るすべての順列を返します。
|
||||
|
||||
バックトラッキングの観点から、**順列を生成するプロセスを一連の選択として見ることができます。** 入力配列が $[1, 2, 3]$ だとします。最初に $1$ を選択し、次に $3$、最後に $2$ を選択すると、順列 $[1, 3, 2]$ が得られます。「バックトラッキング」は前の選択を取り消して、代替オプションを探索することを意味します。
|
||||
バックトラッキングアルゴリズムの観点から見ると、**順列生成の過程は一連の選択の結果として捉えられます**。入力配列が $[1, 2, 3]$ だとすると、最初に $1$ を選び、次に $3$ を選び、最後に $2$ を選べば、順列 $[1, 3, 2]$ が得られます。戻る操作は 1 つの選択を取り消し、その後で別の選択を試し続けることを表します。
|
||||
|
||||
コーディングの観点から、候補集合 `choices` は入力配列のすべての要素で構成され、`state` はこれまでに選択された要素を保持します。各要素は一度だけ選択できるため、**`state` のすべての要素は一意である必要があります**。
|
||||
バックトラッキングコードの観点では、候補集合 `choices` は入力配列中のすべての要素であり、状態 `state` は現時点までに選ばれた要素です。各要素は 1 回しか選べないことに注意してください。**したがって `state` 内の要素はすべて一意でなければなりません**。
|
||||
|
||||
以下の図に示すように、検索プロセスを再帰木に展開できます。各ノードは現在の `state` を表します。ルートノードから開始して、3回の選択の後、葉ノードに到達します—それぞれが順列に対応します。
|
||||
下図のように、探索過程は再帰木として展開できます。木の各ノードは現在の状態 `state` を表します。根ノードから始めて 3 ラウンドの選択を経て葉ノードに到達し、各葉ノードが 1 つの順列に対応します。
|
||||
|
||||

|
||||

|
||||
|
||||
### 重複選択の剪定
|
||||
### 重複選択の枝刈り
|
||||
|
||||
各要素が一度だけ選択されることを保証するために、ブール配列 `selected` を導入します。ここで `selected[i]` は `choices[i]` が選択されたかどうかを示します。次に、この配列に基づいて剪定ステップを実行します:
|
||||
各要素が 1 回しか選ばれないようにするため、ブール配列 `selected` の導入を考えます。ここで `selected[i]` は `choices[i]` がすでに選ばれているかどうかを表し、これに基づいて次の枝刈りを行います。
|
||||
|
||||
- `choice[i]` を選択した後、`selected[i]` を $\text{True}$ に設定して選択されたとマークします。
|
||||
- `choices` を反復処理する際、選択されたとマークされたすべての要素をスキップします(つまり、それらの分岐を剪定します)。
|
||||
- 選択 `choice[i]` を行った後、`selected[i]` を $\text{True}$ に設定し、その要素が選択済みであることを表します。
|
||||
- 選択肢リスト `choices` を走査するとき、すでに選ばれたノードはすべてスキップします。これが枝刈りです。
|
||||
|
||||
以下の図に示すように、最初のラウンドで1を選択し、2番目のラウンドで3を選択し、最後のラウンドで2を選択するとします。2番目のラウンドで要素1の分岐と、3番目のラウンドで要素1と3の分岐を剪定する必要があります。
|
||||
下図のように、1 回目に 1、2 回目に 3、3 回目に 2 を選ぶとします。このとき 2 回目では要素 1 の分岐を、3 回目では要素 1 と要素 3 の分岐を刈り取る必要があります。
|
||||
|
||||

|
||||

|
||||
|
||||
図から、この剪定プロセスが検索空間を $O(n^n)$ から $O(n!)$ に削減することがわかります。
|
||||
上図から、この枝刈りにより探索空間の大きさは $O(n^n)$ から $O(n!)$ へ削減されることがわかります。
|
||||
|
||||
### コード実装
|
||||
|
||||
この理解により、フレームワークコードの「空欄を埋める」ことができます。全体のコードを簡潔に保つため、フレームワークの各部分を個別に実装せず、代わりに `backtrack()` 関数ですべてを展開します:
|
||||
以上を整理できれば、フレームワークコードの「穴埋め」を行えます。全体のコードを短くするため、フレームワークコード中の各関数を個別には実装せず、これらを `backtrack()` 関数内に展開します。
|
||||
|
||||
```src
|
||||
[file]{permutations_i}-[class]{}-[func]{permutations_i}
|
||||
```
|
||||
|
||||
## 重複要素を考慮する場合
|
||||
## 等しい要素を考慮する場合
|
||||
|
||||
!!! question
|
||||
|
||||
**重複要素を含む可能性のある**整数配列が与えられた場合、すべての一意の順列を返してください。
|
||||
整数配列を入力として受け取り、**配列には重複要素が含まれる場合があります**。重複しない順列をすべて返します。
|
||||
|
||||
入力配列が $[1, 1, 2]$ だとします。2つの同一要素 $1$ を区別するために、2番目を $\hat{1}$ とラベル付けします。
|
||||
入力配列が $[1, 1, 2]$ だと仮定します。2 つの重複する要素 $1$ を区別しやすくするため、2 つ目の $1$ を $\hat{1}$ と記します。
|
||||
|
||||
以下の図に示すように、この方法で生成される順列の半分は重複です:
|
||||
下図のように、上述の方法で生成される順列の半分は重複しています。
|
||||
|
||||

|
||||

|
||||
|
||||
では、これらの重複順列をどのように除去できるでしょうか?一つの直接的なアプローチは、すべての順列を生成した後にハッシュセットを使用して重複を除去することです。しかし、これはあまり優雅ではありません。**重複を生成する分岐は本来不要であり、事前に剪定されるべきだからです**、これによりアルゴリズムの効率が向上します。
|
||||
では、重複した順列をどのように取り除けばよいのでしょうか。最も直接的なのは、ハッシュ集合を用いて順列結果をそのまま重複排除する方法です。しかしこのやり方は十分に洗練されていません。**なぜなら、重複順列を生成する探索分岐はそもそも不要であり、事前に見つけて枝刈りすべきだからです**。そうすることで、アルゴリズム効率をさらに高められます。
|
||||
|
||||
### 等値要素の剪定
|
||||
### 等しい要素の枝刈り
|
||||
|
||||
以下の図を見ると、最初のラウンドで $1$ または $\hat{1}$ を選択すると同じ順列につながるため、$\hat{1}$ を剪定します。
|
||||
下図を見ると、1 回目のラウンドでは $1$ を選ぶことと $\hat{1}$ を選ぶことは等価であり、これら 2 つの選択の下で生成される順列はすべて重複します。したがって $\hat{1}$ を枝刈りすべきです。
|
||||
|
||||
同様に、最初のラウンドで $2$ を選択した後、2番目のラウンドで $1$ または $\hat{1}$ を選択しても重複分岐につながるため、その時も $\hat{1}$ を剪定します。
|
||||
同様に、1 回目で $2$ を選んだ後では、2 回目のラウンドにおける $1$ と $\hat{1}$ も重複分岐を生むため、2 回目の $\hat{1}$ も枝刈りすべきです。
|
||||
|
||||
本質的に、**私たちの目標は、複数の同一要素が選択の各ラウンドで一度だけ選択されることを保証することです。**
|
||||
本質的には、**各ラウンドの選択において、等しい複数の要素が 1 回しか選ばれないようにすることが目標です**。
|
||||
|
||||

|
||||

|
||||
|
||||
### コード実装
|
||||
|
||||
前の問題のコードに基づいて、各ラウンドでハッシュセット `duplicated` を導入します。このセットは、すでに試行した要素を追跡し、重複を剪定できるようにします:
|
||||
前問のコードを土台として、各ラウンドの選択でハッシュ集合 `duplicated` を 1 つ用意し、そのラウンドですでに試した要素を記録して、重複要素を枝刈りすることを考えます。
|
||||
|
||||
```src
|
||||
[file]{permutations_ii}-[class]{}-[func]{permutations_ii}
|
||||
```
|
||||
|
||||
すべての要素が異なると仮定すると、$n$ 個の要素の順列は $n!$ (階乗)個あります。各結果を記録するには長さ $n$ のリストをコピーする必要があり、これには $O(n)$ 時間がかかります。**したがって、総時間計算量は $O(n!n)$ です。**
|
||||
要素どうしがすべて互いに異なると仮定すると、$n$ 個の要素には全部で $n!$ 通りの順列(階乗)があります。結果を記録する際には、長さ $n$ のリストをコピーする必要があり、これに $O(n)$ 時間を要します。**したがって時間計算量は $O(n!n)$** です。
|
||||
|
||||
最大再帰深度は $n$ で、$O(n)$ のスタック空間を使用します。`selected` 配列も $O(n)$ 空間が必要です。一度に最大 $n$ 個の個別の `duplicated` セットが存在する可能性があるため、それらは集合的に $O(n^2)$ 空間を占有します。**したがって、空間計算量は $O(n^2)$ です。**
|
||||
再帰の最大深さは $n$ であり、$O(n)$ のスタックフレーム空間を使います。`selected` は $O(n)$ 空間を使用します。同時刻に存在する `duplicated` は最大で $n$ 個であり、$O(n^2)$ 空間を要します。**したがって空間計算量は $O(n^2)$** です。
|
||||
|
||||
### 2つの剪定方法の比較
|
||||
### 2 種類の枝刈りの比較
|
||||
|
||||
`selected` と `duplicated` はどちらも剪定メカニズムとして機能しますが、異なる問題をターゲットにしています:
|
||||
`selected` と `duplicated` はどちらも枝刈りに用いられますが、目的は異なる点に注意してください。
|
||||
|
||||
- **重複選択の剪定**(`selected` 経由):検索全体に単一の `selected` 配列があり、現在の状態にすでにある要素を示します。これにより、同じ要素が `state` に複数回現れることを防ぎます。
|
||||
- **等値要素の剪定**(`duplicated` 経由):`backtrack` 関数の各呼び出しは独自の `duplicated` セットを使用し、その特定の反復(`for` ループ)ですでに選択された要素を記録します。これにより、等しい要素が選択の各ラウンドで一度だけ選択されることを保証します。
|
||||
- **重複選択の枝刈り**:探索全体を通して `selected` は 1 つだけです。これは現在の状態にどの要素が含まれているかを記録し、ある要素が `state` に重複して現れるのを防ぎます。
|
||||
- **等しい要素の枝刈り**:各ラウンドの選択、すなわち各回の `backtrack` 呼び出しには `duplicated` が含まれます。これはそのラウンドの走査(`for` ループ)でどの要素がすでに選ばれたかを記録し、等しい要素が 1 回しか選ばれないことを保証します。
|
||||
|
||||
以下の図は、これら2つの剪定戦略の範囲を示しています。木の各ノードは選択を表します。ルートから任意の葉への経路は、1つの完全な順列に対応します。
|
||||
下図は、2 つの枝刈り条件が有効になる範囲を示しています。木の各ノードは 1 つの選択を表し、根ノードから葉ノードまでの経路上の各ノードが 1 つの順列を構成することに注意してください。
|
||||
|
||||

|
||||

|
||||
|
||||
@@ -1,88 +1,88 @@
|
||||
# 部分集合和問題
|
||||
# 部分和問題
|
||||
|
||||
## 重複要素がない場合
|
||||
## 重複しない要素の場合
|
||||
|
||||
!!! question
|
||||
|
||||
正の整数の配列 `nums` とターゲット正整数 `target` が与えられた場合、組み合わせ内の要素の和が `target` に等しくなるようなすべての可能な組み合わせを見つけてください。与えられた配列には重複要素がなく、各要素は複数回選択できます。これらの組み合わせを重複する組み合わせを含まないリストとして返してください。
|
||||
正整数配列 `nums` と目標の正整数 `target` が与えられたとき、要素の和が `target` に等しくなるすべての組合せを見つけてください。配列に重複要素はなく、各要素は複数回選択できます。これらの組合せをリスト形式で返してください。リストに重複する組合せを含めてはなりません。
|
||||
|
||||
例えば、入力集合 $\{3, 4, 5\}$ とターゲット整数 $9$ の場合、解は $\{3, 3, 3\}, \{4, 5\}$ です。以下の2点に注意してください。
|
||||
例えば、入力集合 $\{3, 4, 5\}$ と目標整数 $9$ に対する解は $\{3, 3, 3\}, \{4, 5\}$ です。次の 2 点に注意してください。
|
||||
|
||||
- 入力集合の要素は無制限に選択できます。
|
||||
- 部分集合は要素の順序を区別しません。例えば $\{4, 5\}$ と $\{5, 4\}$ は同じ部分集合です。
|
||||
- 入力集合内の要素は何度でも繰り返し選択できます。
|
||||
- 部分集合では要素の順序を区別しません。例えば $\{4, 5\}$ と $\{5, 4\}$ は同じ部分集合です。
|
||||
|
||||
### 順列解法の参考
|
||||
### 全順列の解法を参考にする
|
||||
|
||||
順列問題と同様に、部分集合の生成を一連の選択として想像でき、選択プロセス中に「要素和」をリアルタイムで更新できます。要素和が `target` に等しくなったとき、部分集合を結果リストに記録します。
|
||||
全順列問題と同様に、部分集合の生成過程を一連の選択結果として捉え、選択の過程で「要素の和」を逐次更新できます。そして要素の和が `target` に等しくなった時点で、その部分集合を結果リストに記録します。
|
||||
|
||||
順列問題とは異なり、**この問題では要素は無制限に選択できるため**、要素が選択されたかどうかを記録するための `selected` ブール配列を使用する必要がありません。順列コードに軽微な修正を加えて、最初に問題を解決できます:
|
||||
ただし全順列問題と異なるのは、**この問題では集合内の要素を無制限に選択できる**点です。そのため、要素がすでに選択されたかどうかを記録する `selected` ブール配列は不要です。全順列のコードに少し修正を加えると、まず次の解法コードが得られます。
|
||||
|
||||
```src
|
||||
[file]{subset_sum_i_naive}-[class]{}-[func]{subset_sum_i_naive}
|
||||
```
|
||||
|
||||
配列 $[3, 4, 5]$ とターゲット要素 $9$ を上記のコードに入力すると、結果 $[3, 3, 3], [4, 5], [5, 4]$ が得られます。**和が $9$ のすべての部分集合を正常に見つけましたが、重複する部分集合 $[4, 5]$ と $[5, 4]$ が含まれています**。
|
||||
上のコードに配列 $[3, 4, 5]$ と目標値 $9$ を入力すると、出力は $[3, 3, 3], [4, 5], [5, 4]$ となります。**和が $9$ となる部分集合はすべて見つかっていますが、重複する部分集合 $[4, 5]$ と $[5, 4]$ が含まれています**。
|
||||
|
||||
これは、検索プロセスが選択の順序を区別するためですが、部分集合は選択順序を区別しません。以下の図に示すように、$5$ の前に $4$ を選択することと $4$ の前に $5$ を選択することは異なる分岐ですが、同じ部分集合に対応します。
|
||||
これは、探索過程では選択順を区別する一方で、部分集合では選択順を区別しないためです。次の図のように、先に $4$ を選んでから $5$ を選ぶ場合と、先に $5$ を選んでから $4$ を選ぶ場合は別の分岐ですが、対応する部分集合は同じです。
|
||||
|
||||

|
||||

|
||||
|
||||
重複する部分集合を除去するために、**直接的なアイデアは結果リストを重複除去することです**。しかし、この方法は2つの理由で非常に非効率的です。
|
||||
重複する部分集合を取り除くために、**直接的な方法として結果リストの重複を除去する**ことが考えられます。しかし、この方法は効率が低く、その理由は次の 2 点です。
|
||||
|
||||
- 配列要素が多い場合、特に `target` が大きい場合、検索プロセスで大量の重複する部分集合が生成されます。
|
||||
- 部分集合(配列)の差異を比較することは非常に時間がかかり、まず配列をソートし、次に配列の各要素の差異を比較する必要があります。
|
||||
- 配列要素が多い場合、特に `target` が大きい場合には、探索過程で大量の重複部分集合が生成されます。
|
||||
- 部分集合(配列)同士の違いを比較するのは非常に時間がかかり、まず配列をソートし、その後に各要素を比較する必要があります。
|
||||
|
||||
### 重複部分集合の剪定
|
||||
### 重複部分集合の枝刈り
|
||||
|
||||
**剪定を通じて検索プロセス中に重複除去を検討します**。以下の図を観察すると、異なる順序で配列要素を選択するときに重複する部分集合が生成されます。例えば、以下の状況です。
|
||||
**探索過程で枝刈りを行って重複を除去する**ことを考えます。次の図を観察すると、重複部分集合は配列要素を異なる順序で選択したときに生じます。例えば次のような状況です。
|
||||
|
||||
1. 最初のラウンドで $3$ を選択し、2番目のラウンドで $4$ を選択すると、これら2つの要素を含むすべての部分集合が生成され、$[3, 4, \dots]$ と表記されます。
|
||||
2. 後で、最初のラウンドで $4$ が選択されたとき、**2番目のラウンドは $3$ をスキップすべきです**。この選択によって生成される部分集合 $[4, 3, \dots]$ はステップ `1.` の部分集合と完全に重複するからです。
|
||||
1. 1 回目と 2 回目でそれぞれ $3$ と $4$ を選ぶと、これら 2 要素を含むすべての部分集合、すなわち $[3, 4, \dots]$ が生成されます。
|
||||
2. その後、1 回目で $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.` で記述された部分集合と完全に重複するからです。
|
||||
1. 最初の 2 回で $3$ と $5$ を選ぶと、部分集合 $[3, 5, \dots]$ が生成されます。
|
||||
2. 最初の 2 回で $4$ と $5$ を選ぶと、部分集合 $[4, 5, \dots]$ が生成されます。
|
||||
3. もし 1 回目で $5$ を選ぶなら、**2 回目では $3$ と $4$ をスキップすべき**です。なぜなら、部分集合 $[5, 3, \dots]$ と $[5, 4, \dots]$ は、手順 `1.` と手順 `2.` で述べた部分集合と完全に重複するからです。
|
||||
|
||||

|
||||

|
||||
|
||||
要約すると、入力配列 $[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$ を満たす必要があります。**この条件を満たさない選択シーケンスは重複を引き起こし、剪定されるべきです**。
|
||||
まとめると、入力配列 $[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$ を満たす必要があります。**この条件を満たさない選択列は重複を生むため、枝刈りすべきです**。
|
||||
|
||||
### コード実装
|
||||
|
||||
この剪定を実装するために、変数 `start` を初期化し、これは走査の開始点を示します。**選択 $x_{i}$ を行った後、次のラウンドをインデックス $i$ から開始するように設定します**。これにより、選択シーケンスが $i_1 \leq i_2 \leq \dots \leq i_m$ を満たすことが保証され、部分集合の一意性が保証されます。
|
||||
この枝刈りを実現するために、走査の開始位置を示す変数 `start` を初期化します。**選択 $x_{i}$ を行った後、次のラウンドはインデックス $i$ から走査を開始する**ように設定します。これにより、選択列が $i_1 \leq i_2 \leq \dots \leq i_m$ を満たし、部分集合の一意性が保証されます。
|
||||
|
||||
さらに、コードに以下の2つの最適化を行いました。
|
||||
これに加えて、コードには次の 2 つの最適化も施しています。
|
||||
|
||||
- 検索を開始する前に、配列 `nums` をソートします。すべての選択の走査で、**部分集合和が `target` を超えたときにループを直接終了します**。後続の要素はより大きく、それらの部分集合和は確実に `target` を超えるからです。
|
||||
- 要素和変数 `total` を除去し、**`target` に対して減算を実行して要素和をカウントします**。`target` が $0$ に等しくなったとき、解を記録します。
|
||||
- 探索を始める前に、まず配列 `nums` をソートします。すべての選択肢を走査するとき、**部分集合の和が `target` を超えたら直ちにループを終了**します。後続の要素はさらに大きいため、その和も必ず `target` を超えるからです。
|
||||
- 要素和を保持する変数 `total` は省略し、**`target` から減算することで要素和を管理**します。`target` が $0$ になったときに解を記録します。
|
||||
|
||||
```src
|
||||
[file]{subset_sum_i}-[class]{}-[func]{subset_sum_i}
|
||||
```
|
||||
|
||||
以下の図は、配列 $[3, 4, 5]$ とターゲット要素 $9$ を上記のコードに入力した後の全体的なバックトラッキングプロセスを示しています。
|
||||
次の図は、配列 $[3, 4, 5]$ と目標値 $9$ を上のコードに入力したときの、全体のバックトラッキング過程を示しています。
|
||||
|
||||

|
||||

|
||||
|
||||
## 重複要素がある場合を考慮
|
||||
## 重複要素を考慮する場合
|
||||
|
||||
!!! question
|
||||
|
||||
正の整数の配列 `nums` とターゲット正整数 `target` が与えられた場合、組み合わせ内の要素の和が `target` に等しくなるようなすべての可能な組み合わせを見つけてください。**与えられた配列には重複要素が含まれる可能性があり、各要素は一度だけ選択できます**。これらの組み合わせを重複する組み合わせを含まないリストとして返してください。
|
||||
正整数配列 `nums` と目標の正整数 `target` が与えられたとき、要素の和が `target` に等しくなるすべての組合せを見つけてください。**与えられた配列には重複要素が含まれる可能性があり、各要素は 1 回しか選択できません**。これらの組合せをリスト形式で返してください。リストに重複する組合せを含めてはなりません。
|
||||
|
||||
前の問題と比較して、**この問題の入力配列には重複要素が含まれる可能性があり**、新しい問題が導入されます。例えば、配列 $[4, \hat{4}, 5]$ とターゲット要素 $9$ が与えられた場合、既存のコードの出力結果は $[4, 5], [\hat{4}, 5]$ となり、重複する部分集合が生成されます。
|
||||
前問と比べると、**この問題の入力配列には重複要素が含まれる可能性があります**。そのため、新たな問題が生じます。例えば、配列 $[4, \hat{4}, 5]$ と目標値 $9$ が与えられると、既存コードの出力は $[4, 5], [\hat{4}, 5]$ となり、重複部分集合が現れます。
|
||||
|
||||
**この重複の理由は、特定のラウンドで等しい要素が複数回選択されることです**。以下の図では、最初のラウンドに3つの選択肢があり、そのうち2つが $4$ であり、2つの重複する検索分岐を生成し、重複する部分集合を出力します。同様に、2番目のラウンドの2つの $4$ も重複する部分集合を生成します。
|
||||
**この重複が生じる原因は、同じ値の要素があるラウンドで複数回選ばれてしまうことにあります**。次の図では、1 回目には 3 つの選択肢があり、そのうち 2 つはどちらも $4$ です。これにより 2 本の重複した探索分岐が生じ、重複部分集合が出力されます。同様に、2 回目の 2 つの $4$ も重複部分集合を生みます。
|
||||
|
||||

|
||||

|
||||
|
||||
### 等値要素の剪定
|
||||
### 等しい要素の枝刈り
|
||||
|
||||
この問題を解決するために、**等しい要素がラウンドごとに一度だけ選択されるように制限する必要があります**。実装は非常に巧妙です:配列がソートされているため、等しい要素は隣接しています。これは、特定のラウンドの選択で、現在の要素がその左側の要素と等しい場合、それはすでに選択されていることを意味するため、現在の要素を直接スキップします。
|
||||
この問題を解決するには、**各ラウンドで等しい要素が 1 回しか選ばれないように制限する必要があります**。実装方法は巧妙です。配列はすでにソートされているため、等しい要素は必ず隣り合っています。したがって、あるラウンドの選択で現在の要素が左隣の要素と等しいなら、それはすでに選ばれたことを意味するので、その要素を直接スキップします。
|
||||
|
||||
同時に、**この問題では各配列要素は一度だけ選択できると規定されています**。幸い、変数 `start` を使用してこの制約も満たすことができます:選択 $x_{i}$ を行った後、次のラウンドをインデックス $i + 1$ から前方に開始するように設定します。これにより、重複する部分集合が除去されるだけでなく、要素の重複選択も回避されます。
|
||||
同時に、**この問題では各配列要素を 1 回しか選択できない**という制約もあります。幸い、この制約も変数 `start` を使って満たせます。すなわち、選択 $x_{i}$ を行った後、次のラウンドはインデックス $i + 1$ から後ろへ走査するよう設定します。これにより、重複部分集合を除去できるだけでなく、同じ要素を繰り返し選ぶことも防げます。
|
||||
|
||||
### コード実装
|
||||
|
||||
@@ -90,6 +90,6 @@
|
||||
[file]{subset_sum_ii}-[class]{}-[func]{subset_sum_ii}
|
||||
```
|
||||
|
||||
以下の図は、配列 $[4, 4, 5]$ とターゲット要素 $9$ のバックトラッキングプロセスを示し、4種類の剪定操作が含まれています。図とコードのコメントを組み合わせて、検索プロセス全体と各種類の剪定操作の動作を理解してください。
|
||||
次の図は、配列 $[4, 4, 5]$ と目標値 $9$ に対するバックトラッキング過程を示しており、全部で 4 種類の枝刈り操作が含まれています。図とコードコメントを対応させながら、探索全体の流れと、各枝刈り操作がどのように機能するかを理解してください。
|
||||
|
||||

|
||||

|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# まとめ
|
||||
|
||||
### 重要な復習
|
||||
### 重要なポイントの振り返り
|
||||
|
||||
- バックトラッキングアルゴリズムの本質は全数探索です。解空間の深さ優先走査を実行することで条件を満たす解を求めます。検索中に満足のいく解が見つかった場合、それを記録し、すべての解が見つかるか走査が完了するまで続けます。
|
||||
- バックトラッキングアルゴリズムの検索プロセスには試行と後退が含まれます。深さ優先探索を使用して様々な選択を探索し、選択が制約を満たさない場合、前の選択を取り消します。そして前の状態に戻って他のオプションを試し続けます。試行と後退は反対方向の操作です。
|
||||
- バックトラッキング問題には通常複数の制約が含まれます。これらの制約は剪定操作を実行するために使用できます。剪定は不要な検索分岐を事前に終了し、検索効率を大幅に向上させることができます。
|
||||
- バックトラッキングアルゴリズムは主に検索問題と制約満足問題を解決するために使用されます。組み合わせ最適化問題はバックトラッキングを使用して解決できますが、多くの場合、より効率的または効果的な解決方法が利用可能です。
|
||||
- 順列問題は、与えられた集合の要素のすべての可能な順列を検索することを目的とします。各要素が選択されたかどうかを記録するために配列を使用し、同じ要素の重複選択を避けます。これにより、各要素が一度だけ選択されることが保証されます。
|
||||
- 順列問題では、集合に重複要素が含まれている場合、最終結果に重複順列が含まれます。同一要素が各ラウンドで一度だけ選択できるように制限する必要があり、これは通常ハッシュセットを使用して実装されます。
|
||||
- 部分集合和問題は、与えられた集合でターゲット値に合計する全ての部分集合を見つけることを目的とします。集合は要素の順序を区別しませんが、検索プロセスでは重複する部分集合が生成される可能性があります。これは、アルゴリズムが異なる要素順序を独特のパスとして探索するために発生します。バックトラッキングの前に、データをソートし、各ラウンドの走査の開始点を示す変数を設定します。これにより、重複する部分集合を生成する検索分岐を剪定できます。
|
||||
- 部分集合和問題では、配列内の等しい要素は重複集合を生成する可能性があります。配列がすでにソートされているという前提条件を使用して、隣接する要素が等しいかどうかを判定することで剪定を行います。これにより、等しい要素がラウンドごとに一度だけ選択されることが保証されます。
|
||||
- $n$ クイーン問題は、2つのクイーンが互いに攻撃できないように $n \times n$ のチェスボードに $n$ 個のクイーンを配置する方案を見つけることを目的とします。問題の制約には行制約、列制約、および主対角線と副対角線の制約が含まれます。行制約を満たすために、行ごとに1つのクイーンを配置する戦略を採用し、各行に1つのクイーンが配置されることを保証します。
|
||||
- 列制約と対角線制約の処理は似ています。列制約については、各列にクイーンがあるかどうかを記録する配列を使用し、選択されたセルが合法かどうかを示します。対角線制約については、2つの配列を使用して主対角線と副対角線にそれぞれクイーンの存在を記録します。課題は、同じ主対角線または副対角線上のセルの行と列のインデックス間の関係を決定することです。
|
||||
- バックトラッキングアルゴリズムの本質は全探索法であり、解空間を深さ優先で走査することで条件を満たす解を探索します。探索の過程で条件を満たす解に出会ったら記録し、すべての解を見つけるか探索が完了するまで続けます。
|
||||
- バックトラッキングアルゴリズムの探索過程は、試行と戻るという 2 つの部分から成ります。深さ優先探索によってさまざまな選択を試し、制約条件を満たさない状況に遭遇した場合は直前の選択を取り消して前の状態に戻り、ほかの選択を引き続き試します。試行と戻るは互いに逆方向の操作です。
|
||||
- バックトラッキング問題には通常複数の制約条件が含まれており、それらを枝刈りに利用できます。枝刈りによって不要な探索分岐を早期に打ち切り、探索効率を大幅に高められます。
|
||||
- バックトラッキングアルゴリズムは主に探索問題と制約充足問題の解決に用いられます。組合せ最適化問題もバックトラッキングで解けますが、より高効率またはより適した解法が存在することが少なくありません。
|
||||
- 全順列問題の目的は、与えられた集合要素のすべての可能な並べ方を探索することです。各要素が選択済みかどうかを配列で記録し、同じ要素を重複して選ぶ探索分岐を刈り取ることで、各要素が 1 度だけ選ばれるようにします。
|
||||
- 全順列問題では、集合内に重複要素があると最終結果にも重複した順列が現れます。各ラウンドで等しい要素は 1 回しか選べないように制約する必要があり、通常はハッシュ集合を用いて実現します。
|
||||
- 部分和問題の目標は、与えられた集合の中から和が目標値となるすべての部分集合を見つけることです。集合では要素順序を区別しませんが、探索過程では順序違いの結果も出力されるため、重複部分集合が生じます。そこで、バックトラッキング前にデータをソートし、各ラウンドの走査開始位置を示す変数を設定することで、重複部分集合を生成する探索分岐を枝刈りします。
|
||||
- 部分和問題では、配列中の等しい要素が重複集合を生みます。配列がソート済みであるという前提を利用し、隣接要素が等しいかどうかを判定して枝刈りすることで、等しい要素が各ラウンドで 1 回しか選ばれないようにします。
|
||||
- $n$ クイーン問題の目的は、$n \times n$ の盤面に $n$ 個のクイーンを配置する方法を見つけることであり、どの 2 つのクイーンも互いに攻撃できないことが条件です。この問題の制約には行制約、列制約、主対角線制約、副対角線制約があります。行制約を満たすため、行ごとに配置する戦略を採用し、各行に 1 個のクイーンを置くことを保証します。
|
||||
- 列制約と対角線制約の扱い方は似ています。列制約については、各列にクイーンが存在するかどうかを配列で記録し、選択したマスが有効かどうかを判定します。対角線制約については、主対角線と副対角線それぞれにクイーンが存在するかを 2 つの配列で記録します。難点は、同じ主対角線または副対角線上にあるマスが満たす行列インデックスの規則を見つけることにあります。
|
||||
|
||||
### Q & A
|
||||
|
||||
**Q**: バックトラッキングと再帰の関係をどのように理解すればよいですか?
|
||||
**Q**:バックトラッキングと再帰の関係はどのように理解すればよいですか?
|
||||
|
||||
全体的に、バックトラッキングは「アルゴリズム戦略」であり、再帰はより「ツール」です。
|
||||
全体として見ると、バックトラッキングは「アルゴリズム戦略」の一種であり、再帰はむしろ「道具」に近いものです。
|
||||
|
||||
- バックトラッキングアルゴリズムは通常再帰に基づいています。しかし、バックトラッキングは再帰の応用シナリオの一つであり、特に検索問題においてです。
|
||||
- 再帰の構造は「部分問題分解」の問題解決パラダイムを反映します。分割統治、バックトラッキング、動的プログラミング(メモ化再帰)を含む問題の解決でよく使用されます。
|
||||
- バックトラッキングアルゴリズムは通常、再帰に基づいて実装されます。ただし、バックトラッキングは再帰の応用場面の 1 つであり、探索問題における再帰の応用です。
|
||||
- 再帰の構造は「部分問題への分解」という問題解決パラダイムを表しており、分割統治、バックトラッキング、動的計画法(メモ化再帰)などの問題によく用いられます。
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# 複雑度解析
|
||||
# 計算量解析
|
||||
|
||||

|
||||

|
||||
|
||||
!!! abstract
|
||||
|
||||
複雑度解析は、アルゴリズムの広大な宇宙における時空のナビゲーターのようなものです。
|
||||
|
||||
時間と空間の次元をより深く探求し、より優雅な解決策を求めるためのガイドとなります。
|
||||
計算量解析は、広大なアルゴリズム宇宙における時空の案内人のようなものです。
|
||||
|
||||
それは、時間と空間という二つの次元で私たちをより深く探求へ導き、より洗練された解決策を見つけ出します。
|
||||
|
||||
@@ -1,194 +1,194 @@
|
||||
# 反復と再帰
|
||||
|
||||
アルゴリズムにおいて、タスクの繰り返し実行は非常に一般的であり、複雑度の分析と密接に関係しています。したがって、時間計算量と空間計算量の概念を詳しく学ぶ前に、まずプログラミングで繰り返しタスクを実装する方法を探究しましょう。これには、2つの基本的なプログラミング制御構造である反復と再帰の理解が含まれます。
|
||||
アルゴリズムでは、ある処理を繰り返し実行することがよくあり、これは複雑度解析と密接に関係しています。そのため、時間計算量と空間計算量を紹介する前に、まずプログラム内で反復実行を実現する方法、つまり 2 つの基本的な制御構造である反復と再帰について見ていきます。
|
||||
|
||||
## 反復
|
||||
|
||||
<u>反復</u>は、タスクを繰り返し実行するための制御構造です。反復では、プログラムは特定の条件が満たされている限りコードブロックを繰り返し実行し、この条件が満たされなくなるまで続けます。
|
||||
<u>反復(iteration)</u>は、ある処理を繰り返し実行するための制御構造です。反復では、プログラムは一定の条件を満たす間、あるコード片を繰り返し実行し、その条件を満たさなくなるまで続けます。
|
||||
|
||||
### forループ
|
||||
### for ループ
|
||||
|
||||
`for`ループは反復の最も一般的な形式の1つであり、**反復回数が事前に分かっている場合に特に適しています**。
|
||||
`for` ループは最も一般的な反復形式の 1 つで、**反復回数があらかじめ分かっている場合に適しています**。
|
||||
|
||||
以下の関数は`for`ループを使用して$1 + 2 + \dots + n$の合計を実行し、合計を変数`res`に格納します。Pythonでは、`range(a, b)`は`a`を含み`b`を除く区間を作成することに注意してください。つまり、$a$から$b−1$までの範囲で反復します。
|
||||
次の関数は `for` ループを用いて $1 + 2 + \dots + n$ の総和を計算しており、その結果は変数 `res` に記録されます。なお、Python の `range(a, b)` に対応する区間は「左閉右開」であり、走査範囲は $a, a + 1, \dots, b-1$ です。
|
||||
|
||||
```src
|
||||
[file]{iteration}-[class]{}-[func]{for_loop}
|
||||
```
|
||||
|
||||
以下の図はこの合計関数を表しています。
|
||||
次の図は、この総和関数のフローチャートです。
|
||||
|
||||

|
||||

|
||||
|
||||
この合計関数での操作数は入力データのサイズ$n$に比例する、つまり線形関係があります。**この「線形関係」こそが時間計算量が記述するものです**。このトピックについては次のセクションで詳しく説明します。
|
||||
この総和関数の操作回数は入力データサイズ $n$ に比例し、言い換えれば「線形関係」にあります。実際、**時間計算量が記述するのはこの「線形関係」そのものです**。関連内容は次節で詳しく説明します。
|
||||
|
||||
### whileループ
|
||||
### while ループ
|
||||
|
||||
`for`ループと同様に、`while`ループは反復を実装するためのもう1つのアプローチです。`while`ループでは、プログラムは各反復の開始時に条件をチェックし、条件が真の場合は実行を継続し、そうでなければループを終了します。
|
||||
`for` ループと同様に、`while` ループも反復を実現する方法の 1 つです。`while` ループでは、各反復のたびにまず条件を確認し、条件が真であれば実行を続け、そうでなければループを終了します。
|
||||
|
||||
以下では`while`ループを使用して合計$1 + 2 + \dots + n$を実装します。
|
||||
次に、`while` ループを使って $1 + 2 + \dots + n$ の総和を求めてみましょう。
|
||||
|
||||
```src
|
||||
[file]{iteration}-[class]{}-[func]{while_loop}
|
||||
```
|
||||
|
||||
**`while`ループは`for`ループよりも柔軟性を提供します**。特に、条件変数のカスタム初期化と各ステップでの変更が可能です。
|
||||
**`while` ループは `for` ループより自由度が高い**です。`while` ループでは、条件変数の初期化や更新手順を柔軟に設計できます。
|
||||
|
||||
例えば、以下のコードでは、条件変数$i$が各ラウンドで2回更新されますが、これは`for`ループでは実装が不便です。
|
||||
たとえば次のコードでは、条件変数 $i$ が各反復で 2 回更新されており、このようなケースは `for` ループではあまり扱いやすくありません。
|
||||
|
||||
```src
|
||||
[file]{iteration}-[class]{}-[func]{while_loop_ii}
|
||||
```
|
||||
|
||||
全体的に、**`for`ループはより簡潔で、`while`ループはより柔軟です**。どちらも反復構造を実装できます。どちらを使用するかは、問題の具体的な要件に基づいて決定する必要があります。
|
||||
総じて、**`for` ループのコードはより簡潔で、`while` ループはより柔軟**です。どちらも反復構造を実現できますが、どちらを使うかは問題ごとの要件に応じて決めるべきです。
|
||||
|
||||
### ネストしたループ
|
||||
|
||||
1つのループ構造を別のループ構造内にネストできます。以下は`for`ループを使用した例です:
|
||||
1 つのループ構造の中に別のループ構造を入れ子にできます。以下では `for` ループを例にします。
|
||||
|
||||
```src
|
||||
[file]{iteration}-[class]{}-[func]{nested_for_loop}
|
||||
```
|
||||
|
||||
以下の図はこのネストしたループを表しています。
|
||||
次の図は、このネストしたループのフローチャートです。
|
||||
|
||||

|
||||

|
||||
|
||||
このような場合、関数の操作数は$n^2$に比例します。つまり、アルゴリズムの実行時間と入力データのサイズ$n$には「二次関係」があります。
|
||||
この場合、関数の操作回数は $n^2$ に比例し、言い換えればアルゴリズムの実行時間は入力データサイズ $n$ と「二次関係」にあります。
|
||||
|
||||
さらにネストしたループを追加することで複雑度を高めることができ、各レベルのネストは事実上「次元を増加」させ、時間計算量を「三次」、「四次」などに引き上げます。
|
||||
さらにネストしたループを追加することもできます。ネストが 1 段増えるたびに「次元が 1 つ上がる」ことになり、時間計算量は「三次関係」「四次関係」へと高くなっていきます。
|
||||
|
||||
## 再帰
|
||||
|
||||
<u>再帰</u>は、関数が自分自身を呼び出すことで問題を解決するアルゴリズム戦略です。主に2つのフェーズが含まれます:
|
||||
<u>再帰(recursion)</u>は、関数が自分自身を呼び出すことで問題を解決するアルゴリズム戦略です。主に 2 つの段階から成ります。
|
||||
|
||||
1. **呼び出し**: プログラムが自分自身を繰り返し呼び出し、しばしばより小さいまたはより単純な引数で、「終了条件」に向かって進みます。
|
||||
2. **返却**: 「終了条件」がトリガーされると、プログラムは最も深い再帰関数から返り始め、各レイヤーの結果を集約します。
|
||||
1. **再帰呼び出し**:プログラムは自分自身をより深く呼び出し続け、通常はより小さい、またはより単純化された引数を渡し、「終了条件」に達するまで進みます。
|
||||
2. **復帰**: 「終了条件」が満たされると、プログラムは最も深い再帰関数から 1 層ずつ戻り、各層の結果をまとめていきます。
|
||||
|
||||
実装の観点から、再帰コードは主に3つの要素を含みます。
|
||||
実装の観点から見ると、再帰コードは主に 3 つの要素から成ります。
|
||||
|
||||
1. **終了条件**: 「呼び出し」から「返却」にいつ切り替えるかを決定します。
|
||||
2. **再帰呼び出し**: 「呼び出し」に対応し、関数が自分自身を呼び出し、通常はより小さいまたはより単純化されたパラメータで行います。
|
||||
3. **結果の返却**: 「返却」に対応し、現在の再帰レベルの結果が前のレイヤーに返されます。
|
||||
1. **終了条件**:いつ再帰呼び出しから復帰へ切り替わるかを決めます。
|
||||
2. **再帰呼び出し**:再帰呼び出しに対応し、関数が自分自身を呼び出します。通常はより小さい、またはより単純化された引数を入力します。
|
||||
3. **結果の返却**:復帰に対応し、現在の再帰レベルの結果を 1 つ上の層へ返します。
|
||||
|
||||
以下のコードを観察してください。単純に関数`recur(n)`を呼び出すだけで$1 + 2 + \dots + n$の合計を計算できます:
|
||||
次のコードを見ると、関数 `recur(n)` を呼び出すだけで $1 + 2 + \dots + n$ を計算できます。
|
||||
|
||||
```src
|
||||
[file]{recursion}-[class]{}-[func]{recur}
|
||||
```
|
||||
|
||||
以下の図はこの関数の再帰プロセスを示しています。
|
||||
次の図は、この関数の再帰過程を示しています。
|
||||
|
||||

|
||||

|
||||
|
||||
反復と再帰は計算の観点から同じ結果を達成できますが、**それらは思考と問題解決の全く異なるパラダイムを表します**。
|
||||
計算の観点では、反復と再帰は同じ結果を得られますが、**それらは問題を考え解決するためのまったく異なる 2 つのパラダイムを表しています**。
|
||||
|
||||
- **反復**: 「ボトムアップ」で問題を解決します。最も基本的なステップから始まり、タスクが完了するまでこれらのステップを繰り返し追加または累積します。
|
||||
- **再帰**: 「トップダウン」で問題を解決します。元の問題をより小さなサブ問題に分解し、各サブ問題は元の問題と同じ形式を持ちます。これらのサブ問題は、解が分かっているベースケースで停止するまで、さらに小さなサブ問題に分解されます。
|
||||
- **反復**:「ボトムアップ」で問題を解決します。最も基本的な手順から始め、それらを繰り返したり積み上げたりして、処理が完了するまで進めます。
|
||||
- **再帰**:「トップダウン」で問題を解決します。元の問題をより小さな部分問題に分解し、それらの部分問題は元の問題と同じ形を持ちます。さらに部分問題をより小さな部分問題へと分解し、基本ケースに達したところで停止します(基本ケースの解は既知です)。
|
||||
|
||||
先ほどの合計関数の例を取ってみましょう。$f(n) = 1 + 2 + \dots + n$として定義されます。
|
||||
前述の総和関数を例に、問題を $f(n) = 1 + 2 + \dots + n$ とします。
|
||||
|
||||
- **反復**: このアプローチでは、ループ内で合計プロセスをシミュレートします。$1$から始まり$n$まで横断し、各反復で合計操作を実行して最終的に$f(n)$を計算します。
|
||||
- **再帰**: ここでは、問題はサブ問題に分解されます:$f(n) = n + f(n-1)$。この分解は、ベースケースの$f(1) = 1$に到達するまで再帰的に続き、そこで再帰が終了します。
|
||||
- **反復**:ループ内で総和の過程を模擬し、$1$ から $n$ まで走査して、各反復で加算を行えば $f(n)$ を求められます。
|
||||
- **再帰**:問題を部分問題 $f(n) = n + f(n-1)$ に分解し、これを再帰的に分解し続け、基本ケース $f(1) = 1$ に達したところで終了します。
|
||||
|
||||
### 呼び出しスタック
|
||||
|
||||
再帰関数が自分自身を呼び出すたびに、システムは新しく開始された関数にメモリを割り当てて、ローカル変数、戻りアドレス、その他の関連情報を格納します。これは2つの主要な結果をもたらします。
|
||||
再帰関数が自分自身を呼び出すたびに、システムは新たに開始された関数のためにメモリを割り当て、局所変数、呼び出し先アドレス、その他の情報を保存します。これにより 2 つの結果が生じます。
|
||||
|
||||
- 関数のコンテキストデータは「スタックフレーム空間」と呼ばれるメモリ領域に格納され、関数が返された後にのみ解放されます。したがって、**再帰は一般的に反復よりも多くのメモリ空間を消費します**。
|
||||
- 再帰呼び出しは追加のオーバーヘッドを導入します。**したがって、再帰は通常ループよりも時間効率が劣ります。**
|
||||
- 関数のコンテキストデータは「スタックフレーム領域」と呼ばれるメモリ領域に保存され、関数が戻るまで解放されません。したがって、**再帰は通常、反復より多くのメモリ空間を消費します**。
|
||||
- 再帰による関数呼び出しには追加のオーバーヘッドが発生します。**そのため再帰は通常、ループより時間効率が低くなります**。
|
||||
|
||||
以下の図に示されているように、終了条件がトリガーされる前に$n$個の未返却の再帰関数があり、**再帰の深さが$n$であることを示しています**。
|
||||
次の図のように、終了条件が発動する前には、まだ戻っていない再帰関数が同時に $n$ 個存在し、**再帰の深さは $n$** になります。
|
||||
|
||||

|
||||

|
||||
|
||||
実際には、プログラミング言語で許可される再帰の深さは通常制限されており、過度に深い再帰はスタックオーバーフローエラーを引き起こす可能性があります。
|
||||
実際には、プログラミング言語が許容する再帰の深さには通常上限があり、深すぎる再帰はスタックオーバーフローを引き起こす可能性があります。
|
||||
|
||||
### 末尾再帰
|
||||
|
||||
興味深いことに、**関数が返す直前の最後のステップとして再帰呼び出しを実行する場合**、コンパイラまたはインタープリターによって反復と同じ空間効率になるように最適化できます。このシナリオは<u>末尾再帰</u>として知られています。
|
||||
興味深いことに、**関数が返る直前の最後の処理で再帰呼び出しを行う場合**、その関数はコンパイラやインタプリタによって最適化され、空間効率が反復と同程度になることがあります。これを<u>末尾再帰(tail recursion)</u>と呼びます。
|
||||
|
||||
- **通常の再帰**: 標準的な再帰では、関数が前のレベルに戻ったとき、さらにコードを実行し続けるため、システムは前の呼び出しのコンテキストを保存する必要があります。
|
||||
- **末尾再帰**: ここでは、再帰呼び出しは関数が返す前の最終操作です。これは、前のレベルに戻った際に、さらなるアクションが必要ないことを意味するため、システムは前のレベルのコンテキストを保存する必要がありません。
|
||||
- **通常の再帰**:関数が 1 つ上の階層の関数へ戻った後も、引き続きコードを実行する必要があるため、システムは 1 つ上の呼び出しのコンテキストを保存しておく必要があります。
|
||||
- **末尾再帰**:再帰呼び出しが関数の返却前の最後の操作であるため、1 つ上の階層へ戻った後に他の処理を続ける必要がなく、システムは 1 つ上の関数のコンテキストを保存する必要がありません。
|
||||
|
||||
例えば、$1 + 2 + \dots + n$の計算では、結果変数`res`を関数のパラメータにすることで、末尾再帰を実現できます:
|
||||
$1 + 2 + \dots + n$ の計算を例にすると、結果変数 `res` を関数の引数にすることで、末尾再帰を実現できます。
|
||||
|
||||
```src
|
||||
[file]{recursion}-[class]{}-[func]{tail_recur}
|
||||
```
|
||||
|
||||
末尾再帰の実行プロセスは以下の図に示されています。通常の再帰と末尾再帰を比較すると、合計操作のポイントが異なります。
|
||||
末尾再帰の実行過程を次の図に示します。通常の再帰と末尾再帰を比べると、加算処理が実行されるタイミングが異なります。
|
||||
|
||||
- **通常の再帰**: 合計操作は「返却」フェーズで発生し、各レイヤーが返った後にもう一度合計が必要です。
|
||||
- **末尾再帰**: 合計操作は「呼び出し」フェーズで発生し、「返却」フェーズは各レイヤーを通じて返すだけです。
|
||||
- **通常の再帰**:加算処理は復帰の過程で実行され、各層が戻るたびにもう一度加算を行います。
|
||||
- **末尾再帰**:加算処理は再帰呼び出しの過程で実行され、復帰の過程では各層が戻るだけで済みます。
|
||||
|
||||

|
||||

|
||||
|
||||
!!! tip
|
||||
|
||||
多くのコンパイラやインタープリターは末尾再帰最適化をサポートしていないことに注意してください。例えば、Pythonはデフォルトで末尾再帰最適化をサポートしていないため、関数が末尾再帰の形式であっても、スタックオーバーフローの問題に遭遇する可能性があります。
|
||||
多くのコンパイラやインタプリタは末尾再帰最適化をサポートしていない点に注意してください。たとえば、Python はデフォルトで末尾再帰最適化をサポートしていないため、関数が末尾再帰の形であっても、スタックオーバーフローが発生する可能性があります。
|
||||
|
||||
### 再帰木
|
||||
|
||||
「分割統治」に関連するアルゴリズムを扱う際、再帰は反復よりもしばしばより直感的なアプローチとより読みやすいコードを提供します。「フィボナッチ数列」を例に取ってみましょう。
|
||||
「分割統治」に関連するアルゴリズム問題を扱う際、再帰は反復よりも発想が直感的で、コードも読みやすいことがよくあります。「フィボナッチ数列」を例に見てみましょう。
|
||||
|
||||
!!! question
|
||||
|
||||
フィボナッチ数列$0, 1, 1, 2, 3, 5, 8, 13, \dots$が与えられた場合、数列の$n$番目の数を求めなさい。
|
||||
フィボナッチ数列 $0, 1, 1, 2, 3, 5, 8, 13, \dots$ が与えられたとき、この数列の第 $n$ 項を求めてください。
|
||||
|
||||
フィボナッチ数列の$n$番目の数を$f(n)$とすると、2つの結論を簡単に導き出せます:
|
||||
フィボナッチ数列の第 $n$ 項を $f(n)$ とすると、次の 2 つが容易に分かります。
|
||||
|
||||
- 数列の最初の2つの数は$f(1) = 0$と$f(2) = 1$です。
|
||||
- 数列の各数は前の2つの数の合計です。つまり、$f(n) = f(n - 1) + f(n - 2)$です。
|
||||
- 数列の最初の 2 項は $f(1) = 0$ と $f(2) = 1$ です。
|
||||
- 数列中の各項は直前の 2 項の和であり、すなわち $f(n) = f(n - 1) + f(n - 2)$ です。
|
||||
|
||||
再帰関係を使用し、最初の2つの数を終了条件として考慮すると、再帰コードを書けます。`fib(n)`を呼び出すとフィボナッチ数列の$n$番目の数が得られます:
|
||||
漸化式に従って再帰呼び出しを行い、最初の 2 項を終了条件とすれば、再帰コードを書けます。`fib(n)` を呼び出すことでフィボナッチ数列の第 $n$ 項を得られます。
|
||||
|
||||
```src
|
||||
[file]{recursion}-[class]{}-[func]{fib}
|
||||
```
|
||||
|
||||
上記のコードを観察すると、それ自体の中で2つの関数を再帰的に呼び出していることがわかります。**つまり、1回の呼び出しで2つの分岐呼び出しが生成されます**。以下の図に示されているように、この継続的な再帰呼び出しは最終的に深さ$n$の<u>再帰木</u>を作成します。
|
||||
上のコードを見ると、関数内で 2 回の再帰呼び出しを行っています。**これは 1 回の呼び出しから 2 つの呼び出し分岐が生じることを意味します**。次の図のように、この再帰呼び出しを繰り返していくと、最終的に深さ $n$ の<u>再帰木(recursion tree)</u>が生成されます。
|
||||
|
||||

|
||||

|
||||
|
||||
基本的に、再帰は「問題をより小さなサブ問題に分解する」パラダイムを体現しています。この分割統治戦略は重要です。
|
||||
本質的に見ると、再帰は「問題をより小さな部分問題へ分解する」という思考パラダイムを体現しており、この分割統治の戦略は非常に重要です。
|
||||
|
||||
- アルゴリズムの観点から、探索、ソート、バックトラッキング、分割統治、動的プログラミングなどの多くの重要な戦略は、直接的または間接的にこの思考方法を使用しています。
|
||||
- データ構造の観点から、再帰は連結リスト、木、グラフを扱うのに自然に適しており、これらは分割統治アプローチを使用した分析に適しているためです。
|
||||
- アルゴリズムの観点では、探索、ソート、バックトラッキング、分割統治、動的計画法など、多くの重要な戦略が直接または間接にこの考え方を用いています。
|
||||
- データ構造の観点では、再帰は連結リスト、木、グラフに関する問題の処理に本質的に適しており、これらは分割統治の考え方で分析しやすいからです。
|
||||
|
||||
## 比較
|
||||
## 両者の比較
|
||||
|
||||
上記の内容をまとめると、以下の表は実装、性能、適用性の観点から反復と再帰の違いを示しています。
|
||||
以上をまとめると、次の表のように、反復と再帰は実装、性能、適用性の面で違いがあります。
|
||||
|
||||
<p align="center"> 表: 反復と再帰の特性の比較 </p>
|
||||
<p align="center"> 表 <id> 反復と再帰の特徴の比較 </p>
|
||||
|
||||
| | 反復 | 再帰 |
|
||||
| ----------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------- |
|
||||
| アプローチ | ループ構造 | 関数が自分自身を呼び出す |
|
||||
| 時間効率 | 一般的により高い効率、関数呼び出しのオーバーヘッドなし | 各関数呼び出しがオーバーヘッドを生成 |
|
||||
| メモリ使用量 | 通常は固定サイズのメモリ空間を使用 | 累積的な関数呼び出しが大量のスタックフレーム空間を使用する可能性 |
|
||||
| 適用可能な問題 | 単純なループタスクに適している、直感的で読みやすいコード | 問題の分解に適している(木、グラフ、分割統治、バックトラッキングなど)、簡潔で明確なコード構造 |
|
||||
| | 反復 | 再帰 |
|
||||
| -------- | -------------------------------------- | ------------------------------------------------------------ |
|
||||
| 実装方法 | ループ構造 | 関数が自分自身を呼び出す |
|
||||
| 時間効率 | 通常は効率が高く、関数呼び出しの負荷がない | 関数呼び出しのたびにオーバーヘッドが発生する |
|
||||
| メモリ使用 | 通常は固定サイズのメモリ空間を使う | 関数呼び出しの蓄積により大量のスタックフレーム領域を使う可能性がある |
|
||||
| 適用対象 | 単純な反復処理に適し、コードが直感的で読みやすい | 木、グラフ、分割統治、バックトラッキングなどの部分問題分解に適し、コード構造が簡潔で明快 |
|
||||
|
||||
!!! tip
|
||||
|
||||
以下の内容が理解しにくい場合は、「スタック」の章を読んだ後に再び訪れることを検討してください。
|
||||
以下の内容が難しいと感じる場合は、「スタック」の章を読み終えた後に改めて復習してください。
|
||||
|
||||
それでは、反復と再帰の本質的な関連は何でしょうか?上記の再帰関数を例に取ると、合計操作は再帰の「返却」フェーズで発生します。これは、最初に呼び出された関数が最後に合計操作を完了することを意味し、**スタックの「後入れ先出し」原理を反映しています**。
|
||||
では、反復と再帰にはどのような内在的な関係があるのでしょうか。前述の再帰関数を例にすると、加算処理は再帰の復帰段階で行われます。これは、最初に呼び出された関数が実際には最後に加算を完了することを意味しており、**この動作の仕組みはスタックの「後入れ先出し」の原則とよく似ています**。
|
||||
|
||||
「呼び出しスタック」や「スタックフレーム空間」などの再帰用語は、再帰とスタックの密接な関係を示しています。
|
||||
実際、「呼び出しスタック」や「スタックフレーム領域」といった再帰の用語自体が、再帰とスタックの密接な関係を示唆しています。
|
||||
|
||||
1. **呼び出し**: 関数が呼び出されると、システムは「呼び出しスタック」上にその関数用の新しいスタックフレームを割り当て、ローカル変数、パラメータ、戻りアドレス、その他のデータを格納します。
|
||||
2. **返却**: 関数が実行を完了して返ると、対応するスタックフレームが「呼び出しスタック」から削除され、前の関数の実行環境が復元されます。
|
||||
1. **再帰呼び出し**:関数が呼び出されると、システムは「呼び出しスタック」上にその関数のための新しいスタックフレームを割り当て、局所変数、引数、返却先アドレスなどのデータを保存します。
|
||||
2. **復帰**:関数の実行が完了して戻ると、対応するスタックフレームは「呼び出しスタック」から取り除かれ、前の関数の実行環境が復元されます。
|
||||
|
||||
したがって、**明示的なスタックを使用して呼び出しスタックの動作をシミュレートできます**。これにより再帰を反復形式に変換できます:
|
||||
したがって、**明示的なスタックを使って呼び出しスタックの振る舞いを模擬することができ**、その結果として再帰を反復形式へ変換できます。
|
||||
|
||||
```src
|
||||
[file]{recursion}-[class]{}-[func]{for_loop_recur}
|
||||
```
|
||||
|
||||
上記のコードを観察すると、再帰が反復に変換されたとき、コードはより複雑になります。反復と再帰はしばしば相互に変換できますが、2つの理由でそうすることが常に推奨されるわけではありません:
|
||||
上のコードを見ると、再帰を反復へ変換すると、コードはより複雑になります。反復と再帰は多くの場合に相互変換できますが、常にそうする価値があるとは限りません。理由は次の 2 点です。
|
||||
|
||||
- 変換されたコードは理解がより困難になり、読みにくくなる可能性があります。
|
||||
- 一部の複雑な問題では、システムの呼び出しスタックの動作をシミュレートすることは非常に困難です。
|
||||
- 変換後のコードは理解しにくくなり、可読性が下がる可能性があります。
|
||||
- 複雑な問題によっては、システムの呼び出しスタックの振る舞いを模擬すること自体が非常に難しい場合があります。
|
||||
|
||||
結論として、**反復または再帰を選択するかは問題の具体的な性質によります**。プログラミングの実践では、両方の長所と短所を比較検討し、手元の状況に最も適したアプローチを選択することが重要です。
|
||||
要するに、**反復を選ぶか再帰を選ぶかは、対象となる問題の性質によって決まります**。実際のプログラミングでは、両者の長所と短所を見極め、状況に応じて適切な方法を選ぶことが重要です。
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
# アルゴリズムの効率評価
|
||||
# アルゴリズム効率の評価
|
||||
|
||||
アルゴリズム設計において、私たちは順序に従って以下の2つの目標を追求します。
|
||||
アルゴリズム設計では、次の 2 つのレベルの目標を順に追求します。
|
||||
|
||||
1. **問題の解決策を見つける**: アルゴリズムは、指定された入力範囲内で確実に正しい解を見つけることができるべきです。
|
||||
2. **最適解を求める**: 同じ問題に対して複数の解決策が存在する場合があり、私たちは可能な限り最も効率的なアルゴリズムを見つけることを目指します。
|
||||
1. **問題の解法を見つける**:アルゴリズムは、定められた入力範囲内で問題の正しい解を確実に求められる必要があります。
|
||||
2. **最適な解法を追求する**:同じ問題に対して複数の解法が存在する場合があり、私たちはできるだけ効率的なアルゴリズムを見つけたいと考えます。
|
||||
|
||||
つまり、問題を解決できることを前提として、アルゴリズムの効率がアルゴリズムを評価する主要な基準となっており、これには以下の2つの次元が含まれます。
|
||||
つまり、問題を解けることを前提として、アルゴリズム効率はその良し悪しを測る主要な評価指標となっており、次の 2 つの観点を含みます。
|
||||
|
||||
- **時間効率**: アルゴリズムが実行される速度。
|
||||
- **空間効率**: アルゴリズムが占有するメモリ空間のサイズ。
|
||||
- **時間効率**:アルゴリズムの実行時間の長さ。
|
||||
- **空間効率**:アルゴリズムが使用するメモリ空間の大きさ。
|
||||
|
||||
要するに、**私たちの目標は、高速でメモリ効率の良いデータ構造とアルゴリズムを設計することです**。アルゴリズムの効率を効果的に評価することは重要です。なぜなら、そうすることで初めて様々なアルゴリズムを比較し、アルゴリズムの設計と最適化プロセスを導くことができるからです。
|
||||
簡単に言えば、**私たちの目標は「高速で省メモリ」なデータ構造とアルゴリズムを設計すること**です。そして、アルゴリズム効率を効果的に評価することは非常に重要です。そうすることで初めて、さまざまなアルゴリズムを比較し、さらにアルゴリズム設計と最適化の過程を導けるからです。
|
||||
|
||||
効率評価には主に2つの方法があります:実際のテストと理論的推定です。
|
||||
効率の評価方法は主に 2 種類に分けられます。実測と理論的な見積もりです。
|
||||
|
||||
## 実際のテスト
|
||||
## 実測
|
||||
|
||||
アルゴリズム`A`と`B`があり、どちらも同じ問題を解決でき、それらの効率を比較する必要があるとします。最も直接的な方法は、コンピュータを使用してこれら2つのアルゴリズムを実行し、実行時間とメモリ使用量を監視・記録することです。この評価方法は実際の状況を反映しますが、大きな制限があります。
|
||||
いまアルゴリズム `A` とアルゴリズム `B` があり、どちらも同じ問題を解けるとします。この 2 つのアルゴリズムの効率を比較する必要がある場合、最も直接的な方法は 1 台のコンピュータで両者を実行し、その実行時間とメモリ使用量を監視して記録することです。この評価方法は実際の状況を反映できますが、大きな制約もあります。
|
||||
|
||||
一方で、**テスト環境からの干渉を排除することは困難です**。ハードウェア構成はアルゴリズムの性能に影響を与える可能性があります。例えば、並列度の高いアルゴリズムはマルチコアCPUでの実行により適していますし、集約的なメモリ操作を含むアルゴリズムは高性能メモリでより良い性能を発揮します。アルゴリズムのテスト結果は、異なるマシン間で変わる可能性があります。これは、平均効率を計算するために複数のマシンでテストすることが実用的でないことを意味します。
|
||||
一方では、**テスト環境による干渉要因を排除しにくい**という問題があります。ハードウェア構成はアルゴリズムの性能に影響します。たとえば、並列度の高いアルゴリズムはマルチコア CPU での実行により適しており、メモリアクセスが集中的なアルゴリズムは高性能メモリ上でより良い性能を示します。つまり、異なるマシンでのテスト結果は一致しない可能性があります。これは、さまざまなマシンでテストして平均効率を統計的に求める必要があることを意味しますが、それは現実的ではありません。
|
||||
|
||||
一方で、**完全なテストを実施することは非常にリソース集約的です**。アルゴリズムの効率は入力データサイズによって変わります。例えば、データ量が少ない場合はアルゴリズム`A`が`B`より速く実行される可能性がありますが、データ量が多い場合はテスト結果が逆になる可能性があります。したがって、説得力のある結論を導くためには、幅広い入力データサイズをテストする必要があり、これには過度な計算リソースが必要になります。
|
||||
他方では、**完全なテストを実施するには非常に多くの資源が必要**です。入力データ量が変化すると、アルゴリズムは異なる効率を示します。たとえば、入力データ量が小さいときはアルゴリズム `A` の実行時間がアルゴリズム `B` より短くても、入力データ量が大きいときには結果がちょうど逆になるかもしれません。そのため、説得力のある結論を得るには、さまざまな規模の入力データでテストする必要があり、それには大量の計算資源を要します。
|
||||
|
||||
## 理論的推定
|
||||
## 理論的な見積もり
|
||||
|
||||
実際のテストの大きな制限により、計算のみでアルゴリズムの効率を評価することを検討できます。この推定方法は<u>漸近的複雑度解析</u>、または単に<u>複雑度解析</u>として知られています。
|
||||
実測には大きな制約があるため、いくつかの計算だけによってアルゴリズムの効率を評価することを考えられます。この見積もり方法は<u>漸近計算量解析(asymptotic complexity analysis)</u>と呼ばれ、略して<u>計算量解析</u>といいます。
|
||||
|
||||
複雑度解析は、アルゴリズムの実行に必要な時間と空間リソースと入力データのサイズとの関係を反映します。**これは、入力データのサイズが増加するにつれて、アルゴリズムに必要な時間と空間の増加傾向を記述します**。この定義は複雑に聞こえるかもしれませんが、より良く理解するために3つの重要なポイントに分解できます。
|
||||
計算量解析は、アルゴリズムの実行に必要な時間資源と空間資源が入力データ規模とどのような関係にあるかを表します。**これは、入力データ規模が増加するにつれて、アルゴリズムの実行に必要な時間と空間がどのように増加するかという傾向を記述するものです**。この定義はややわかりにくいので、次の 3 つのポイントに分けて理解できます。
|
||||
|
||||
- 「時間と空間リソース」は、それぞれ<u>時間計算量</u>と<u>空間計算量</u>に対応します。
|
||||
- 「入力データのサイズが増加するにつれて」は、複雑度がアルゴリズムの効率と入力データ量との関係を反映することを意味します。
|
||||
- 「時間と空間の増加傾向」は、複雑度解析が実行時間や占有空間の具体的な値ではなく、時間や空間が増加する「率」に焦点を当てることを示します。
|
||||
- 「時間資源と空間資源」は、それぞれ<u>時間計算量(time complexity)</u>と<u>空間計算量(space complexity)</u>に対応します。
|
||||
- 「入力データ規模が増加するにつれて」とは、計算量がアルゴリズムの実行効率と入力データ規模との関係を反映していることを意味します。
|
||||
- 「時間と空間の増加傾向」とは、計算量解析が注目するのは実行時間や使用空間の具体的な値ではなく、時間や空間の増加の「速さ」であることを示します。
|
||||
|
||||
**複雑度解析は実際のテスト方法の欠点を克服します**。これは以下の側面で反映されます:
|
||||
**計算量解析は実測という方法の欠点を克服しています**。その点は次のように表れます。
|
||||
|
||||
- 実際にコードを実行する必要がないため、より環境に優しく、エネルギー効率が良いです。
|
||||
- テスト環境に依存せず、すべての動作プラットフォームに適用できます。
|
||||
- 異なるデータ量でのアルゴリズムの効率を反映でき、特に大量データでのアルゴリズムの性能を示します。
|
||||
- 実際にコードを動かす必要がなく、より環境にやさしく省エネルギーです。
|
||||
- テスト環境から独立しており、解析結果はすべての実行プラットフォームに適用できます。
|
||||
- 異なるデータ量におけるアルゴリズム効率を表せ、とくに大規模データ量での性能を反映できます。
|
||||
|
||||
!!! tip
|
||||
|
||||
複雑度の概念についてまだ混乱している場合でも、心配しないでください。以降の章で詳しく取り上げます。
|
||||
それでも計算量の概念がまだわかりにくくても、心配はいりません。後続の章で詳しく説明します。
|
||||
|
||||
複雑度解析は、アルゴリズムの効率を評価する「ものさし」を提供し、実行に必要な時間と空間リソースを測定し、異なるアルゴリズムの効率を比較することを可能にします。
|
||||
計算量解析は、アルゴリズム効率を評価するための「物差し」を私たちに与えてくれます。これにより、あるアルゴリズムの実行に必要な時間資源と空間資源を測り、異なるアルゴリズム同士の効率を比較できます。
|
||||
|
||||
複雑度は数学的概念であり、初心者には抽象的で困難かもしれません。この観点から、複雑度解析は最初に紹介するのに最も適したトピックではないかもしれません。しかし、特定のデータ構造やアルゴリズムの特性について議論するとき、その速度と空間使用量を分析することを避けるのは困難です。
|
||||
計算量は数学的な概念であり、初学者にとってはやや抽象的で、学習の難度も比較的高いかもしれません。この観点から見ると、計算量解析は最初に紹介する内容としてはあまり適していない可能性があります。しかし、あるデータ構造やアルゴリズムの特徴を議論する際には、その実行速度や空間使用状況の分析を避けることはできません。
|
||||
|
||||
要約すると、データ構造とアルゴリズムに深く入る前に複雑度解析の基本的な理解を身につけることをお勧めします。**これにより、簡単なアルゴリズムで複雑度解析を実行できるようになります**。
|
||||
以上を踏まえると、データ構造とアルゴリズムを深く学ぶ前に、**まず計算量解析について初歩的な理解を持ち、簡単なアルゴリズムの計算量解析ができるようにしておくこと**を勧めます。
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
# 空間計算量
|
||||
|
||||
<u>空間計算量</u>は、データ量が増加するにつれてアルゴリズムが占有するメモリ空間の増加傾向を測定するために使用されます。この概念は時間計算量と非常に似ていますが、「実行時間」が「占有メモリ空間」に置き換えられています。
|
||||
<u>空間計算量(space complexity)</u>は、アルゴリズムが占有するメモリ空間がデータ量の増加に伴ってどのように増えるかを測る指標です。この概念は時間計算量と非常によく似ており、「実行時間」を「占有メモリ空間」に置き換えるだけです。
|
||||
|
||||
## アルゴリズムに関連する空間
|
||||
|
||||
アルゴリズムが実行中に使用するメモリ空間には、主に以下の種類があります。
|
||||
アルゴリズムが実行中に使用するメモリ空間には、主に次の種類があります。
|
||||
|
||||
- **入力空間**: アルゴリズムの入力データを格納するために使用されます。
|
||||
- **一時空間**: アルゴリズムの実行中に変数、オブジェクト、関数コンテキスト、その他のデータを格納するために使用されます。
|
||||
- **出力空間**: アルゴリズムの出力データを格納するために使用されます。
|
||||
- **入力空間**:アルゴリズムの入力データを格納するための空間。
|
||||
- **一時空間**:アルゴリズムの実行中に使用する変数、オブジェクト、関数コンテキストなどのデータを格納するための空間。
|
||||
- **出力空間**:アルゴリズムの出力データを格納するための空間。
|
||||
|
||||
一般的に、空間計算量の統計範囲には「一時空間」と「出力空間」の両方が含まれます。
|
||||
一般に、空間計算量の集計範囲は「一時空間」と「出力空間」を合わせたものです。
|
||||
|
||||
一時空間はさらに3つの部分に分けることができます。
|
||||
一時空間はさらに三つに分けられます。
|
||||
|
||||
- **一時データ**: アルゴリズムの実行中に様々な定数、変数、オブジェクトなどを保存するために使用されます。
|
||||
- **スタックフレーム空間**: 呼び出された関数のコンテキストデータを保存するために使用されます。システムは関数が呼び出されるたびにスタックの頂上にスタックフレームを作成し、関数が返された後にスタックフレーム空間を解放します。
|
||||
- **命令空間**: コンパイル済みプログラム命令を格納するために使用され、実際の統計では通常無視できます。
|
||||
- **一時データ**:アルゴリズム実行中の各種定数、変数、オブジェクトなどを保存するための空間。
|
||||
- **スタックフレーム空間**:呼び出された関数のコンテキストデータを保存するための空間。システムは関数を呼び出すたびにスタックの先頭にスタックフレームを作成し、関数が戻るとその空間を解放します。
|
||||
- **命令空間**:コンパイル後のプログラム命令を保存するための空間で、実際の集計では通常無視されます。
|
||||
|
||||
プログラムの空間計算量を分析する際、**通常は一時データ、スタックフレーム空間、出力データをカウントします**。以下の図に示されています。
|
||||
プログラムの空間計算量を分析する際には、**通常、一時データ、スタックフレーム空間、出力データの三つを数えます**。以下の図に示すとおりです。
|
||||
|
||||

|
||||

|
||||
|
||||
関連するコードは以下の通りです:
|
||||
関連するコードを以下に示します。
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -30,20 +30,20 @@
|
||||
class Node:
|
||||
"""クラス"""
|
||||
def __init__(self, x: int):
|
||||
self.val: int = x # ノード値
|
||||
self.next: Node | None = None # 次のノードへの参照
|
||||
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 # 出力データ
|
||||
def algorithm(n) -> int: # 入力データ
|
||||
A = 0 # 一時データ(定数。一般に大文字で表す)
|
||||
b = 0 # 一時データ(変数)
|
||||
node = Node(0) # 一時データ(オブジェクト)
|
||||
c = function() # スタックフレーム空間(関数呼び出し)
|
||||
return A + b + c # 出力データ
|
||||
```
|
||||
|
||||
=== "C++"
|
||||
@@ -58,16 +58,16 @@
|
||||
|
||||
/* 関数 */
|
||||
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; // 出力データ
|
||||
int algorithm(int n) { // 入力データ
|
||||
const int a = 0; // 一時データ(定数)
|
||||
int b = 0; // 一時データ(変数)
|
||||
Node* node = new Node(0); // 一時データ(オブジェクト)
|
||||
int c = func(); // スタックフレーム空間(関数呼び出し)
|
||||
return a + b + c; // 出力データ
|
||||
}
|
||||
```
|
||||
|
||||
@@ -80,19 +80,19 @@
|
||||
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; // 出力データ
|
||||
|
||||
int algorithm(int n) { // 入力データ
|
||||
final int a = 0; // 一時データ(定数)
|
||||
int b = 0; // 一時データ(変数)
|
||||
Node node = new Node(0); // 一時データ(オブジェクト)
|
||||
int c = function(); // スタックフレーム空間(関数呼び出し)
|
||||
return a + b + c; // 出力データ
|
||||
}
|
||||
```
|
||||
|
||||
@@ -100,24 +100,23 @@
|
||||
|
||||
```csharp title=""
|
||||
/* クラス */
|
||||
class Node {
|
||||
int val;
|
||||
class Node(int x) {
|
||||
int val = x;
|
||||
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; // 出力データ
|
||||
int Algorithm(int n) { // 入力データ
|
||||
const int a = 0; // 一時データ(定数)
|
||||
int b = 0; // 一時データ(変数)
|
||||
Node node = new(0); // 一時データ(オブジェクト)
|
||||
int c = Function(); // スタックフレーム空間(関数呼び出し)
|
||||
return a + b + c; // 出力データ
|
||||
}
|
||||
```
|
||||
|
||||
@@ -130,14 +129,14 @@
|
||||
next *node
|
||||
}
|
||||
|
||||
/* ノード構造体を作成 */
|
||||
/* node 構造体を作成 */
|
||||
func newNode(val int) *node {
|
||||
return &node{val: val}
|
||||
}
|
||||
|
||||
|
||||
/* 関数 */
|
||||
func function() int {
|
||||
// 特定の操作を実行...
|
||||
// いくつかの処理を実行...
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -165,16 +164,16 @@
|
||||
|
||||
/* 関数 */
|
||||
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 // 出力データ
|
||||
let a = 0 // 一時データ(定数)
|
||||
var b = 0 // 一時データ(変数)
|
||||
let node = Node(x: 0) // 一時データ(オブジェクト)
|
||||
let c = function() // スタックフレーム空間(関数呼び出し)
|
||||
return a + b + c // 出力データ
|
||||
}
|
||||
```
|
||||
|
||||
@@ -186,23 +185,23 @@
|
||||
val;
|
||||
next;
|
||||
constructor(val) {
|
||||
this.val = val === undefined ? 0 : 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; // 出力データ
|
||||
function algorithm(n) { // 入力データ
|
||||
const a = 0; // 一時データ(定数)
|
||||
let b = 0; // 一時データ(変数)
|
||||
const node = new Node(0); // 一時データ(オブジェクト)
|
||||
const c = constFunc(); // スタックフレーム空間(関数呼び出し)
|
||||
return a + b + c; // 出力データ
|
||||
}
|
||||
```
|
||||
|
||||
@@ -214,14 +213,14 @@
|
||||
val: number;
|
||||
next: Node | null;
|
||||
constructor(val?: number) {
|
||||
this.val = val === undefined ? 0 : val; // ノード値
|
||||
this.val = val === undefined ? 0 : val; // ノードの値
|
||||
this.next = null; // 次のノードへの参照
|
||||
}
|
||||
}
|
||||
|
||||
/* 関数 */
|
||||
function constFunc(): number {
|
||||
// 特定の操作を実行
|
||||
// いくつかの処理を実行
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -246,7 +245,7 @@
|
||||
|
||||
/* 関数 */
|
||||
int function() {
|
||||
// 特定の操作を実行...
|
||||
// いくつかの処理を実行...
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -264,14 +263,14 @@
|
||||
```rust title=""
|
||||
use std::rc::Rc;
|
||||
use std::cell::RefCell;
|
||||
|
||||
|
||||
/* 構造体 */
|
||||
struct Node {
|
||||
val: i32,
|
||||
next: Option<Rc<RefCell<Node>>>,
|
||||
}
|
||||
|
||||
/* コンストラクタ */
|
||||
/* Node 構造体を作成 */
|
||||
impl Node {
|
||||
fn new(val: i32) -> Self {
|
||||
Self { val: val, next: None }
|
||||
@@ -279,17 +278,17 @@
|
||||
}
|
||||
|
||||
/* 関数 */
|
||||
fn function() -> i32 {
|
||||
// 特定の操作を実行...
|
||||
fn function() -> i32 {
|
||||
// いくつかの処理を実行...
|
||||
return 0;
|
||||
}
|
||||
|
||||
fn algorithm(n: i32) -> i32 { // 入力データ
|
||||
const a: i32 = 0; // 一時データ(定数)
|
||||
let mut b = 0; // 一時データ(変数)
|
||||
let node = Node::new(0); // 一時データ(オブジェクト)
|
||||
let c = function(); // スタックフレーム空間(関数呼び出し)
|
||||
return a + b + c; // 出力データ
|
||||
fn algorithm(n: i32) -> i32 { // 入力データ
|
||||
const a: i32 = 0; // 一時データ(定数)
|
||||
let mut b = 0; // 一時データ(変数)
|
||||
let node = Node::new(0); // 一時データ(オブジェクト)
|
||||
let c = function(); // スタックフレーム空間(関数呼び出し)
|
||||
return a + b + c; // 出力データ
|
||||
}
|
||||
```
|
||||
|
||||
@@ -298,34 +297,80 @@
|
||||
```c title=""
|
||||
/* 関数 */
|
||||
int func() {
|
||||
// 特定の操作を実行...
|
||||
// いくつかの処理を実行...
|
||||
return 0;
|
||||
}
|
||||
|
||||
int algorithm(int n) { // 入力データ
|
||||
const int a = 0; // 一時データ(定数)
|
||||
int b = 0; // 一時データ(変数)
|
||||
int c = func(); // スタックフレーム空間(関数呼び出し)
|
||||
return a + b + c; // 出力データ
|
||||
int algorithm(int n) { // 入力データ
|
||||
const int a = 0; // 一時データ(定数)
|
||||
int b = 0; // 一時データ(変数)
|
||||
int c = func(); // スタックフレーム空間(関数呼び出し)
|
||||
return a + b + c; // 出力データ
|
||||
}
|
||||
```
|
||||
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title=""
|
||||
/* クラス */
|
||||
class Node(var _val: Int) {
|
||||
var next: Node? = null
|
||||
}
|
||||
|
||||
/* 関数 */
|
||||
fun function(): Int {
|
||||
// いくつかの処理を実行...
|
||||
return 0
|
||||
}
|
||||
|
||||
fun algorithm(n: Int): Int { // 入力データ
|
||||
val a = 0 // 一時データ(定数)
|
||||
var b = 0 // 一時データ(変数)
|
||||
val node = Node(0) // 一時データ(オブジェクト)
|
||||
val c = function() // スタックフレーム空間(関数呼び出し)
|
||||
return a + b + c // 出力データ
|
||||
}
|
||||
```
|
||||
|
||||
## 計算方法
|
||||
=== "Ruby"
|
||||
|
||||
空間計算量を計算する方法は時間計算量とほぼ同様で、統計対象を「操作数」から「使用空間のサイズ」に変更するだけです。
|
||||
```ruby title=""
|
||||
### クラス ###
|
||||
class Node
|
||||
attr_accessor :val # ノードの値
|
||||
attr_accessor :next # 次のノードへの参照
|
||||
|
||||
しかし、時間計算量とは異なり、**通常は最悪ケース空間計算量のみに焦点を当てます**。これは、メモリ空間がハード要件であり、すべての入力データの下で十分なメモリ空間が確保されていることを保証する必要があるためです。
|
||||
def initialize(x)
|
||||
@val = x
|
||||
end
|
||||
end
|
||||
|
||||
以下のコードを考えてみましょう。最悪ケース空間計算量の「最悪ケース」という用語には2つの意味があります。
|
||||
### 関数 ###
|
||||
def function
|
||||
# いくつかの処理を実行...
|
||||
0
|
||||
end
|
||||
|
||||
1. **最悪の入力データに基づく**: $n < 10$の場合、空間計算量は$O(1)$ですが、$n > 10$の場合、初期化された配列`nums`が$O(n)$の空間を占有するため、最悪ケース空間計算量は$O(n)$です。
|
||||
2. **アルゴリズムの実行中に使用されるピークメモリに基づく**: 例えば、最後の行を実行する前、プログラムは$O(1)$の空間を占有します。配列`nums`を初期化する際、プログラムは$O(n)$の空間を占有するため、最悪ケース空間計算量は$O(n)$です。
|
||||
### アルゴリズム ###
|
||||
def algorithm(n) # 入力データ
|
||||
a = 0 # 一時データ(定数)
|
||||
b = 0 # 一時データ(変数)
|
||||
node = Node.new(0) # 一時データ(オブジェクト)
|
||||
c = function # スタックフレーム空間(関数呼び出し)
|
||||
a + b + c # 出力データ
|
||||
end
|
||||
```
|
||||
|
||||
## 推定方法
|
||||
|
||||
空間計算量の推定方法は時間計算量とおおむね同じで、数える対象を「操作回数」から「使用空間の大きさ」に変えるだけです。
|
||||
|
||||
ただし時間計算量と異なり、**通常は最悪空間計算量だけに注目します**。メモリ空間は厳格な要件であり、どの入力データに対しても十分なメモリを確保できることを保証しなければならないからです。
|
||||
|
||||
以下のコードを見ると、最悪空間計算量における「最悪」には二つの意味があります。
|
||||
|
||||
1. **最悪の入力データを基準にする**:$n < 10$ のとき空間計算量は $O(1)$ ですが、$n > 10$ のとき初期化される配列 `nums` が $O(n)$ の空間を占有するため、最悪空間計算量は $O(n)$ です。
|
||||
2. **アルゴリズム実行中のメモリ使用量のピークを基準にする**:例えば、プログラムは最後の行を実行する前までは $O(1)$ の空間しか使いませんが、配列 `nums` を初期化するときには $O(n)$ の空間を占有するため、最悪空間計算量は $O(n)$ です。
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -437,10 +482,10 @@
|
||||
|
||||
```rust title=""
|
||||
fn algorithm(n: i32) {
|
||||
let a = 0; // O(1)
|
||||
let b = [0; 10000]; // O(1)
|
||||
let a = 0; // O(1)
|
||||
let b = [0; 10000]; // O(1)
|
||||
if n > 10 {
|
||||
let nums = vec![0; n as usize]; // O(n)
|
||||
let nums = vec![0; n as usize]; // O(n)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -459,25 +504,41 @@
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title=""
|
||||
|
||||
fun algorithm(n: Int) {
|
||||
val a = 0 // O(1)
|
||||
val b = IntArray(10000) // O(1)
|
||||
if (n > 10) {
|
||||
val nums = IntArray(n) // O(n)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**再帰関数では、スタックフレーム空間を考慮に入れる必要があります**。以下のコードを考えてみましょう:
|
||||
=== "Ruby"
|
||||
|
||||
```ruby title=""
|
||||
def algorithm(n)
|
||||
a = 0 # O(1)
|
||||
b = Array.new(10000) # O(1)
|
||||
nums = Array.new(n) if n > 10 # O(n)
|
||||
end
|
||||
```
|
||||
|
||||
**再帰関数では、スタックフレーム空間の集計に注意が必要です**。以下のコードを見てみましょう。
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title=""
|
||||
def function() -> int:
|
||||
# 特定の操作を実行
|
||||
# いくつかの処理を実行
|
||||
return 0
|
||||
|
||||
def loop(n: int):
|
||||
"""ループ O(1)"""
|
||||
"""ループの空間計算量は O(1)"""
|
||||
for _ in range(n):
|
||||
function()
|
||||
|
||||
def recur(n: int):
|
||||
"""再帰 O(n)"""
|
||||
"""再帰の空間計算量は O(n)"""
|
||||
if n == 1:
|
||||
return
|
||||
return recur(n - 1)
|
||||
@@ -487,16 +548,16 @@
|
||||
|
||||
```cpp title=""
|
||||
int func() {
|
||||
// 特定の操作を実行
|
||||
// いくつかの処理を実行
|
||||
return 0;
|
||||
}
|
||||
/* サイクル O(1) */
|
||||
/* ループの空間計算量は O(1) */
|
||||
void loop(int n) {
|
||||
for (int i = 0; i < n; i++) {
|
||||
func();
|
||||
}
|
||||
}
|
||||
/* 再帰 O(n) */
|
||||
/* 再帰の空間計算量は O(n) */
|
||||
void recur(int n) {
|
||||
if (n == 1) return;
|
||||
recur(n - 1);
|
||||
@@ -507,16 +568,16 @@
|
||||
|
||||
```java title=""
|
||||
int function() {
|
||||
// 特定の操作を実行
|
||||
// いくつかの処理を実行
|
||||
return 0;
|
||||
}
|
||||
/* サイクル O(1) */
|
||||
/* ループの空間計算量は O(1) */
|
||||
void loop(int n) {
|
||||
for (int i = 0; i < n; i++) {
|
||||
function();
|
||||
}
|
||||
}
|
||||
/* 再帰 O(n) */
|
||||
/* 再帰の空間計算量は O(n) */
|
||||
void recur(int n) {
|
||||
if (n == 1) return;
|
||||
recur(n - 1);
|
||||
@@ -527,16 +588,16 @@
|
||||
|
||||
```csharp title=""
|
||||
int Function() {
|
||||
// 特定の操作を実行
|
||||
// いくつかの処理を実行
|
||||
return 0;
|
||||
}
|
||||
/* サイクル O(1) */
|
||||
/* ループの空間計算量は O(1) */
|
||||
void Loop(int n) {
|
||||
for (int i = 0; i < n; i++) {
|
||||
Function();
|
||||
}
|
||||
}
|
||||
/* 再帰 O(n) */
|
||||
/* 再帰の空間計算量は O(n) */
|
||||
int Recur(int n) {
|
||||
if (n == 1) return 1;
|
||||
return Recur(n - 1);
|
||||
@@ -547,18 +608,18 @@
|
||||
|
||||
```go title=""
|
||||
func function() int {
|
||||
// 特定の操作を実行
|
||||
// いくつかの処理を実行
|
||||
return 0
|
||||
}
|
||||
|
||||
/* サイクル O(1) */
|
||||
|
||||
/* ループの空間計算量は O(1) */
|
||||
func loop(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
function()
|
||||
}
|
||||
}
|
||||
|
||||
/* 再帰 O(n) */
|
||||
|
||||
/* 再帰の空間計算量は O(n) */
|
||||
func recur(n int) {
|
||||
if n == 1 {
|
||||
return
|
||||
@@ -572,18 +633,18 @@
|
||||
```swift title=""
|
||||
@discardableResult
|
||||
func function() -> Int {
|
||||
// 特定の操作を実行
|
||||
// いくつかの処理を実行
|
||||
return 0
|
||||
}
|
||||
|
||||
/* サイクル O(1) */
|
||||
/* ループの空間計算量は O(1) */
|
||||
func loop(n: Int) {
|
||||
for _ in 0 ..< n {
|
||||
function()
|
||||
}
|
||||
}
|
||||
|
||||
/* 再帰 O(n) */
|
||||
/* 再帰の空間計算量は O(n) */
|
||||
func recur(n: Int) {
|
||||
if n == 1 {
|
||||
return
|
||||
@@ -596,16 +657,16 @@
|
||||
|
||||
```javascript title=""
|
||||
function constFunc() {
|
||||
// 特定の操作を実行
|
||||
// いくつかの処理を実行
|
||||
return 0;
|
||||
}
|
||||
/* サイクル O(1) */
|
||||
/* ループの空間計算量は O(1) */
|
||||
function loop(n) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
constFunc();
|
||||
}
|
||||
}
|
||||
/* 再帰 O(n) */
|
||||
/* 再帰の空間計算量は O(n) */
|
||||
function recur(n) {
|
||||
if (n === 1) return;
|
||||
return recur(n - 1);
|
||||
@@ -616,16 +677,16 @@
|
||||
|
||||
```typescript title=""
|
||||
function constFunc(): number {
|
||||
// 特定の操作を実行
|
||||
// いくつかの処理を実行
|
||||
return 0;
|
||||
}
|
||||
/* サイクル O(1) */
|
||||
/* ループの空間計算量は O(1) */
|
||||
function loop(n: number): void {
|
||||
for (let i = 0; i < n; i++) {
|
||||
constFunc();
|
||||
}
|
||||
}
|
||||
/* 再帰 O(n) */
|
||||
/* 再帰の空間計算量は O(n) */
|
||||
function recur(n: number): void {
|
||||
if (n === 1) return;
|
||||
return recur(n - 1);
|
||||
@@ -636,16 +697,16 @@
|
||||
|
||||
```dart title=""
|
||||
int function() {
|
||||
// 特定の操作を実行
|
||||
// いくつかの処理を実行
|
||||
return 0;
|
||||
}
|
||||
/* サイクル O(1) */
|
||||
/* ループの空間計算量は O(1) */
|
||||
void loop(int n) {
|
||||
for (int i = 0; i < n; i++) {
|
||||
function();
|
||||
}
|
||||
}
|
||||
/* 再帰 O(n) */
|
||||
/* 再帰の空間計算量は O(n) */
|
||||
void recur(int n) {
|
||||
if (n == 1) return;
|
||||
recur(n - 1);
|
||||
@@ -656,17 +717,17 @@
|
||||
|
||||
```rust title=""
|
||||
fn function() -> i32 {
|
||||
// 特定の操作を実行
|
||||
// いくつかの処理を実行
|
||||
return 0;
|
||||
}
|
||||
/* サイクル O(1) */
|
||||
/* ループの空間計算量は O(1) */
|
||||
fn loop(n: i32) {
|
||||
for i in 0..n {
|
||||
function();
|
||||
}
|
||||
}
|
||||
/* 再帰 O(n) */
|
||||
void recur(n: i32) {
|
||||
/* 再帰の空間計算量は O(n) */
|
||||
fn recur(n: i32) {
|
||||
if n == 1 {
|
||||
return;
|
||||
}
|
||||
@@ -678,16 +739,16 @@
|
||||
|
||||
```c title=""
|
||||
int func() {
|
||||
// 特定の操作を実行
|
||||
// いくつかの処理を実行
|
||||
return 0;
|
||||
}
|
||||
/* サイクル O(1) */
|
||||
/* ループの空間計算量は O(1) */
|
||||
void loop(int n) {
|
||||
for (int i = 0; i < n; i++) {
|
||||
func();
|
||||
}
|
||||
}
|
||||
/* 再帰 O(n) */
|
||||
/* 再帰の空間計算量は O(n) */
|
||||
void recur(int n) {
|
||||
if (n == 1) return;
|
||||
recur(n - 1);
|
||||
@@ -697,89 +758,123 @@
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title=""
|
||||
|
||||
fun function(): Int {
|
||||
// いくつかの処理を実行
|
||||
return 0
|
||||
}
|
||||
/* ループの空間計算量は O(1) */
|
||||
fun loop(n: Int) {
|
||||
for (i in 0..<n) {
|
||||
function()
|
||||
}
|
||||
}
|
||||
/* 再帰の空間計算量は O(n) */
|
||||
fun recur(n: Int) {
|
||||
if (n == 1) return
|
||||
return recur(n - 1)
|
||||
}
|
||||
```
|
||||
|
||||
`loop()`関数と`recur()`関数の時間計算量は両方とも$O(n)$ですが、それらの空間計算量は異なります。
|
||||
=== "Ruby"
|
||||
|
||||
- `loop()`関数はループ内で`function()`を$n$回呼び出し、各反復の`function()`は返ってそのスタックフレーム空間を解放するため、空間計算量は$O(1)$のままです。
|
||||
- 再帰関数`recur()`は実行中に$n$個の未返却の`recur()`インスタンスが同時に存在するため、$O(n)$のスタックフレーム空間を占有します。
|
||||
```ruby title=""
|
||||
def function
|
||||
# いくつかの処理を実行
|
||||
0
|
||||
end
|
||||
|
||||
## 一般的な種類
|
||||
### ループの空間計算量は O(1) ###
|
||||
def loop(n)
|
||||
(0...n).each { function }
|
||||
end
|
||||
|
||||
入力データのサイズを$n$とすると、下図は一般的な空間計算量の種類を示しています(低いものから高いものへと並べられています)。
|
||||
### 再帰の空間計算量は O(n) ###
|
||||
def recur(n)
|
||||
return if n == 1
|
||||
recur(n - 1)
|
||||
end
|
||||
```
|
||||
|
||||
関数 `loop()` と `recur()` はどちらも時間計算量は $O(n)$ ですが、空間計算量は異なります。
|
||||
|
||||
- 関数 `loop()` はループの中で `function()` を $n$ 回呼び出しますが、各反復での `function()` は戻るたびにスタックフレーム空間が解放されるため、空間計算量は依然として $O(1)$ です。
|
||||
- 再帰関数 `recur()` は実行中に未返却の `recur()` が同時に $n$ 個存在するため、$O(n)$ のスタックフレーム空間を占有します。
|
||||
|
||||
## よくある型
|
||||
|
||||
入力データサイズを $n$ とすると、以下の図はよくある空間計算量の型を低い順から高い順に示しています。
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
& O(1) < O(\log n) < O(n) < O(n^2) < O(2^n) \newline
|
||||
& \text{定数} < \text{対数} < \text{線形} < \text{二次} < \text{指数}
|
||||
O(1) < O(\log n) < O(n) < O(n^2) < O(2^n) \newline
|
||||
\text{定数階} < \text{対数階} < \text{線形階} < \text{平方階} < \text{指数階}
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||

|
||||

|
||||
|
||||
### 定数オーダー $O(1)$
|
||||
### 定数階 $O(1)$
|
||||
|
||||
定数オーダーは、入力データサイズ$n$とは無関係な定数、変数、オブジェクトで一般的です。
|
||||
定数階は、個数が入力データサイズ $n$ に依存しない定数、変数、オブジェクトなどによく現れます。
|
||||
|
||||
ループで変数を初期化したり関数を呼び出したりするために占有されるメモリは、次のサイクルに入る際に解放され、空間上で累積されないため、空間計算量は$O(1)$のままです:
|
||||
注意すべき点として、ループ内で変数を初期化したり関数を呼び出したりして使用されたメモリは、次の反復に入ると解放されるため、空間の占有は累積せず、空間計算量は依然として $O(1)$ です。
|
||||
|
||||
```src
|
||||
[file]{space_complexity}-[class]{}-[func]{constant}
|
||||
```
|
||||
|
||||
### 線形オーダー $O(n)$
|
||||
### 線形階 $O(n)$
|
||||
|
||||
線形オーダーは配列、連結リスト、スタック、キューなどで一般的で、要素数は$n$に比例します:
|
||||
線形階は、要素数が $n$ に比例する配列、連結リスト、スタック、キューなどによく現れます。
|
||||
|
||||
```src
|
||||
[file]{space_complexity}-[class]{}-[func]{linear}
|
||||
```
|
||||
|
||||
下図に示されているように、この関数の再帰深度は$n$で、$n$個の未返却の`linear_recur()`関数インスタンスがあり、$O(n)$サイズのスタックフレーム空間を使用します:
|
||||
以下の図に示すように、この関数の再帰の深さは $n$ であり、同時に $n$ 個の未返却 `linear_recur()` 関数が存在するため、$O(n)$ のスタックフレーム空間を使用します。
|
||||
|
||||
```src
|
||||
[file]{space_complexity}-[class]{}-[func]{linear_recur}
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
### 二次オーダー $O(n^2)$
|
||||
### 平方階 $O(n^2)$
|
||||
|
||||
二次オーダーは行列やグラフで一般的で、要素数は$n$の二乗に比例します:
|
||||
平方階は、要素数が $n$ の二乗に比例する行列やグラフによく現れます。
|
||||
|
||||
```src
|
||||
[file]{space_complexity}-[class]{}-[func]{quadratic}
|
||||
```
|
||||
|
||||
下図に示されているように、この関数の再帰深度は$n$で、各再帰呼び出しで長さ$n$、$n-1$、$\dots$、$2$、$1$の配列が初期化され、平均$n/2$となり、全体として$O(n^2)$の空間を占有します:
|
||||
以下の図に示すように、この関数の再帰の深さは $n$ であり、各再帰関数の中で長さがそれぞれ $n$、$n-1$、$\dots$、$2$、$1$ の配列を初期化しています。平均長は $n / 2$ なので、全体では $O(n^2)$ の空間を占有します。
|
||||
|
||||
```src
|
||||
[file]{space_complexity}-[class]{}-[func]{quadratic_recur}
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
### 指数オーダー $O(2^n)$
|
||||
### 指数階 $O(2^n)$
|
||||
|
||||
指数オーダーは二分木で一般的です。下図を観察すると、$n$レベルの「完全二分木」は$2^n - 1$個のノードを持ち、$O(2^n)$の空間を占有します:
|
||||
指数階は二分木によく現れます。以下の図を見ると、高さが $n$ の「満二分木」のノード数は $2^n - 1$ であり、$O(2^n)$ の空間を占有します。
|
||||
|
||||
```src
|
||||
[file]{space_complexity}-[class]{}-[func]{build_tree}
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
### 対数オーダー $O(\log n)$
|
||||
### 対数階 $O(\log n)$
|
||||
|
||||
対数オーダーは分割統治アルゴリズムで一般的です。例えば、マージソートでは、長さ$n$の配列が各ラウンドで再帰的に半分に分割され、高さ$\log n$の再帰木を形成し、$O(\log n)$のスタックフレーム空間を使用します。
|
||||
対数階は分割統治アルゴリズムによく現れます。例えばマージソートでは、長さ $n$ の配列を入力として、各再帰で配列を中央から二つに分割するため、高さ $\log n$ の再帰木が形成され、$O(\log n)$ のスタックフレーム空間を使用します。
|
||||
|
||||
別の例は、数値を文字列に変換することです。正の整数$n$が与えられた場合、その桁数は$\log_{10} n + 1$で、文字列の長さに対応するため、空間計算量は$O(\log_{10} n + 1) = O(\log n)$です。
|
||||
また、数値を文字列に変換する場合を考えると、正の整数 $n$ の桁数は $\lfloor \log_{10} n \rfloor + 1$ であり、対応する文字列長も $\lfloor \log_{10} n \rfloor + 1$ です。したがって空間計算量は $O(\log_{10} n + 1) = O(\log n)$ となります。
|
||||
|
||||
## 時間と空間のバランス
|
||||
## 時間と空間のトレードオフ
|
||||
|
||||
理想的には、時間計算量と空間計算量の両方が最適であることを目指します。しかし、実際には両方を同時に最適化することはしばしば困難です。
|
||||
理想的には、アルゴリズムの時間計算量と空間計算量の両方を最適にしたいところです。しかし実際には、この二つを同時に最適化するのは通常きわめて困難です。
|
||||
|
||||
**時間計算量を下げることは通常、空間計算量の増加を代償とし、その逆も同様です**。アルゴリズムの速度を向上させるためにメモリ空間を犠牲にするアプローチは「時空トレードオフ」として知られ、その逆は「空時トレードオフ」として知られています。
|
||||
**時間計算量を下げるには、通常、空間計算量を増やす代償が必要であり、その逆も同様です**。メモリ空間を犠牲にして実行速度を上げる考え方を「空間を時間と引き換えにする」と呼び、その逆を「時間を空間と引き換えにする」と呼びます。
|
||||
|
||||
選択は、どちらの側面をより重視するかに依存します。ほとんどの場合、時間は空間よりも貴重であるため、「時空トレードオフ」がより一般的な戦略です。もちろん、大量のデータを扱う際は空間計算量を制御することも非常に重要です。
|
||||
どちらの考え方を選ぶかは、何をより重視するかによって決まります。多くの場合、空間より時間のほうが貴重なので、「空間を時間と引き換えにする」戦略のほうが一般的です。もちろん、データ量が非常に大きい場合には、空間計算量を抑えることも同じくらい重要です。
|
||||
|
||||
@@ -1,49 +1,55 @@
|
||||
# まとめ
|
||||
|
||||
### 重要なレビュー
|
||||
### 要点の振り返り
|
||||
|
||||
**アルゴリズム効率評価**
|
||||
**アルゴリズム効率の評価**
|
||||
|
||||
- 時間効率と空間効率は、アルゴリズムの優劣を評価する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$ で表され、関数の漸近上界に対応し、$n$ が正の無限大に近づくときの操作回数 $T(n)$ の増加の度合いを表します。
|
||||
- 時間計算量の推定は二段階に分かれ、まず操作回数を数え、次に漸近上界を判断します。
|
||||
- 一般的な時間計算量を低い順から並べると、$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)$などが含まれます。
|
||||
- 空間計算量の役割は時間計算量に似ており、アルゴリズムが使用するメモリ空間がデータ量の増加に伴ってどう変化するかを測ります。
|
||||
- アルゴリズム実行中に関係するメモリ空間は、入力空間、一時空間、出力空間に分けられます。通常、入力空間は空間計算量の計算に含めません。一時空間は一時データ、スタックフレーム空間、命令空間に分けられ、このうちスタックフレーム空間は通常、再帰関数でのみ空間計算量に影響します。
|
||||
- 私たちは通常、最悪空間計算量のみに注目し、最悪の入力データと最悪の実行時点における空間計算量を数えます。
|
||||
- 一般的な空間計算量を低い順から並べると、$O(1)$、$O(\log n)$、$O(n)$、$O(n^2)$、$O(2^n)$ などがあります。
|
||||
|
||||
### Q & A
|
||||
|
||||
**Q**: 末尾再帰の空間計算量は$O(1)$ですか?
|
||||
**Q**:尾再帰の空間計算量は $O(1)$ ですか?
|
||||
|
||||
理論的には、末尾再帰関数の空間計算量は$O(1)$に最適化できます。しかし、ほとんどのプログラミング言語(Java、Python、C++、Go、C#など)は末尾再帰の自動最適化をサポートしていないため、一般的に空間計算量は$O(n)$と考えられています。
|
||||
理論上、尾再帰関数の空間計算量は $O(1)$ まで最適化できます。ただし、ほとんどのプログラミング言語(Java、Python、C++、Go、C# など)は尾再帰の自動最適化をサポートしていないため、通常は空間計算量を $O(n)$ と見なします。
|
||||
|
||||
**Q**: 「関数」と「メソッド」という用語の違いは何ですか?
|
||||
**Q**:関数とメソッドという二つの用語の違いは何ですか?
|
||||
|
||||
<u>関数</u>は独立して実行でき、すべてのパラメータが明示的に渡されます。<u>メソッド</u>はオブジェクトに関連付けられ、それを呼び出すオブジェクトに暗黙的に渡され、クラスのインスタンス内に含まれるデータを操作できます。
|
||||
<u>関数(function)</u>は独立して実行でき、すべての引数は明示的に渡されます。<u>メソッド(method)</u>はオブジェクトに関連付けられ、それを呼び出すオブジェクトが暗黙的に渡され、クラスのインスタンスに含まれるデータを操作できます。
|
||||
|
||||
一般的なプログラミング言語からの例をいくつか示します:
|
||||
以下では、いくつかの一般的なプログラミング言語を例に説明します。
|
||||
|
||||
- Cは手続き型プログラミング言語で、オブジェクト指向の概念がないため、関数のみがあります。しかし、構造体(struct)を作成することでオブジェクト指向プログラミングをシミュレートでき、これらの構造体に関連付けられた関数は他のプログラミング言語のメソッドと同等です。
|
||||
- JavaとC#はオブジェクト指向プログラミング言語で、コードブロック(メソッド)は通常クラスの一部です。静的メソッドはクラスにバインドされ、特定のインスタンス変数にアクセスできないため、関数のように動作します。
|
||||
- C++とPythonは手続き型プログラミング(関数)とオブジェクト指向プログラミング(メソッド)の両方をサポートしています。
|
||||
- C 言語は手続き型プログラミング言語であり、オブジェクト指向の概念がないため、関数しかありません。ただし、構造体(struct)を作成してオブジェクト指向プログラミングを模倣でき、構造体に関連付けられた関数は、他のプログラミング言語におけるメソッドに相当します。
|
||||
- Java と C# はオブジェクト指向のプログラミング言語であり、コードブロック(メソッド)は通常あるクラスの一部です。静的メソッドの振る舞いは関数に似ており、クラスに束縛され、特定のインスタンス変数にはアクセスできません。
|
||||
- C++ と Python は、手続き型プログラミング(関数)にもオブジェクト指向プログラミング(メソッド)にも対応しています。
|
||||
|
||||
**Q**: 「空間計算量の一般的な種類」の図は、占有空間の絶対サイズを反映していますか?
|
||||
**Q**:「一般的な空間計算量の種類」の図が表しているのは、使用空間の絶対量ですか?
|
||||
|
||||
いいえ、図は空間計算量を示しており、これは増加傾向を反映するものであり、占有空間の絶対サイズではありません。
|
||||
いいえ。この図が示しているのは空間計算量であり、表しているのは増加傾向であって、使用空間の絶対量ではありません。
|
||||
|
||||
$n = 8$を取ると、各曲線の値がその関数に対応していないことに気づくかもしれません。これは、各曲線に定数項が含まれているためで、値の範囲を視覚的に快適な範囲に圧縮することを意図しています。
|
||||
$n = 8$ と仮定すると、各曲線の値が対応する関数と一致していないように見えるかもしれません。これは、各曲線に定数項が含まれており、値の範囲を視覚的に見やすい範囲へ圧縮しているためです。
|
||||
|
||||
実際には、通常は各メソッドの「定数項」複雑度を知らないため、複雑度のみに基づいて$n = 8$の最良ソリューションを選択することは一般的に不可能です。しかし、$n = 8^5$の場合、増加傾向が支配的になるため、選択がはるかに容易になります。
|
||||
実際には、各手法の「定数項」の複雑度がどれほどか通常は分からないため、一般に複雑度だけを根拠に $n = 8$ 以下で最適解を選ぶことはできません。ただし、$n = 8^5$ であれば選びやすく、このときは増加傾向がすでに支配的になっています。
|
||||
|
||||
**Q** 実際の利用場面に応じて、時間(または空間)を犠牲にしてアルゴリズムを設計することはありますか?
|
||||
|
||||
実際の応用では、多くの場合、空間を犠牲にして時間を得る選択をします。たとえばデータベースのインデックスでは、通常 B+ 木やハッシュインデックスを構築し、大量のメモリ空間を使う代わりに、$O(\log n)$ あるいは $O(1)$ の高速な検索を実現します。
|
||||
|
||||
空間資源が貴重な場面では、時間を犠牲にして空間を得ることもあります。たとえば組み込み開発では、デバイスのメモリが非常に貴重なため、エンジニアはハッシュテーブルの使用をやめ、配列による順次探索を選んでメモリ使用量を節約することがあります。その代償として探索は遅くなります。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,66 +1,66 @@
|
||||
# 基本データ型
|
||||
|
||||
コンピュータ内のデータについて考える際、テキスト、画像、動画、音声、3Dモデルなど、様々な形式が思い浮かびます。これらの組織的な形式は異なりますが、すべて様々な基本データ型から構成されています。
|
||||
コンピュータ内のデータについて考えるとき、テキスト、画像、動画、音声、3D モデルなど、さまざまな形態を思い浮かべます。これらのデータの構成形式はそれぞれ異なりますが、いずれも各種の基本データ型によって成り立っています。
|
||||
|
||||
**基本データ型とは、CPUが直接操作できるもの**であり、アルゴリズムで直接使用されます。主に以下が含まれます。
|
||||
**基本データ型は CPU が直接演算できる型**であり、アルゴリズムの中で直接使われます。主なものは次のとおりです。
|
||||
|
||||
- 整数型:`byte`、`short`、`int`、`long`
|
||||
- 浮動小数点型:`float`、`double`、小数を表現するために使用
|
||||
- 文字型:`char`、様々な言語の文字、句読点、さらには絵文字を表現するために使用
|
||||
- ブール型:`bool`、「はい」または「いいえ」の判断を表現するために使用
|
||||
- 整数型 `byte`、`short`、`int`、`long` 。
|
||||
- 浮動小数点数型 `float`、`double` ,小数を表すために使います。
|
||||
- 文字型 `char` ,各言語の文字、句読点、さらには絵文字などを表すために使います。
|
||||
- 真偽値型 `bool` ,真か偽かの判定を表すために使います。
|
||||
|
||||
**基本データ型は、コンピュータ内で二進形式で格納されます**。1つの二進桁は1ビットです。ほとんどの現代的なオペレーティングシステムでは、1バイトは8ビットで構成されています。
|
||||
**基本データ型はコンピュータ内で 2 進数の形で格納されます**。1 つの二進桁は $1$ ビットです。現代のほとんどのオペレーティングシステムでは、$1$ バイト(byte)は $8$ ビット(bit)で構成されます。
|
||||
|
||||
基本データ型の値の範囲は、それらが占める空間のサイズに依存します。以下では、Javaを例に説明します。
|
||||
基本データ型の値域は、その型が占める領域の大きさによって決まります。以下では Java を例に取ります。
|
||||
|
||||
- 整数型`byte`は1バイト = 8ビットを占め、$2^8$個の数値を表現できます。
|
||||
- 整数型`int`は4バイト = 32ビットを占め、$2^{32}$個の数値を表現できます。
|
||||
- 整数型 `byte` は $1$ バイト = $8$ ビットを占め、$2^{8}$ 個の数を表せます。
|
||||
- 整数型 `int` は $4$ バイト = $32$ ビットを占め、$2^{32}$ 個の数を表せます。
|
||||
|
||||
以下の表は、Javaにおける様々な基本データ型が占める空間、値の範囲、デフォルト値を示しています。この表を暗記する必要はありませんが、一般的な理解を持ち、必要時に参照することをお勧めします。
|
||||
下表は、Java における各種基本データ型の使用領域、値域、デフォルト値を示したものです。この表を丸暗記する必要はなく、大まかに理解しておけば十分であり、必要になったときに参照すればかまいません。
|
||||
|
||||
<p align="center"> 表 <id> 基本データ型が占める空間と値の範囲 </p>
|
||||
<p align="center"> 表 <id> 基本データ型の使用領域と値域 </p>
|
||||
|
||||
| 型 | シンボル | 占有空間 | 最小値 | 最大値 | デフォルト値 |
|
||||
| ------- | -------- | -------- | ------------------------ | ----------------------- | -------------- |
|
||||
| 整数 | `byte` | 1バイト | $-2^7$ ($-128$) | $2^7 - 1$ ($127$) | 0 |
|
||||
| | `short` | 2バイト | $-2^{15}$ | $2^{15} - 1$ | 0 |
|
||||
| | `int` | 4バイト | $-2^{31}$ | $2^{31} - 1$ | 0 |
|
||||
| | `long` | 8バイト | $-2^{63}$ | $2^{63} - 1$ | 0 |
|
||||
| 浮動小数点 | `float` | 4バイト | $1.175 \times 10^{-38}$ | $3.403 \times 10^{38}$ | $0.0\text{f}$ |
|
||||
| | `double` | 8バイト | $2.225 \times 10^{-308}$ | $1.798 \times 10^{308}$ | 0.0 |
|
||||
| 文字 | `char` | 2バイト | 0 | $2^{16} - 1$ | 0 |
|
||||
| ブール | `bool` | 1バイト | $\text{false}$ | $\text{true}$ | $\text{false}$ |
|
||||
| 型 | 記号 | 使用領域 | 最小値 | 最大値 | デフォルト値 |
|
||||
| ------ | -------- | -------- | ------------------------ | ----------------------- | -------------- |
|
||||
| 整数 | `byte` | 1 バイト | $-2^7$ ($-128$) | $2^7 - 1$ ($127$) | $0$ |
|
||||
| | `short` | 2 バイト | $-2^{15}$ | $2^{15} - 1$ | $0$ |
|
||||
| | `int` | 4 バイト | $-2^{31}$ | $2^{31} - 1$ | $0$ |
|
||||
| | `long` | 8 バイト | $-2^{63}$ | $2^{63} - 1$ | $0$ |
|
||||
| 浮動小数点数 | `float` | 4 バイト | $1.175 \times 10^{-38}$ | $3.403 \times 10^{38}$ | $0.0\text{f}$ |
|
||||
| | `double` | 8 バイト | $2.225 \times 10^{-308}$ | $1.798 \times 10^{308}$ | $0.0$ |
|
||||
| 文字 | `char` | 2 バイト | $0$ | $2^{16} - 1$ | $0$ |
|
||||
| 真偽値 | `bool` | 1 バイト | $\text{false}$ | $\text{true}$ | $\text{false}$ |
|
||||
|
||||
上記の表はJavaの基本データ型に特有であることにご注意ください。すべてのプログラミング言語には独自のデータ型定義があり、占有空間、値の範囲、デフォルト値が異なる場合があります。
|
||||
注意してください。上表は Java の基本データ型に対するものです。各プログラミング言語にはそれぞれ独自のデータ型定義があり、使用領域、値域、デフォルト値は異なる場合があります。
|
||||
|
||||
- Pythonでは、整数型`int`は任意のサイズになることができ、利用可能なメモリによってのみ制限されます。浮動小数点`float`は倍精度64ビットです。`char`型は存在せず、単一文字は実際には長さ1の文字列`str`です。
|
||||
- CおよびC++では基本データ型のサイズが指定されておらず、実装とプラットフォームによって異なります。上記の表はLP64[データモデル](https://en.cppreference.com/w/cpp/language/types#Properties)に従っており、LinuxやmacOSを含むUnix 64ビットオペレーティングシステムで使用されています。
|
||||
- CおよびC++における`char`のサイズは1バイトですが、ほとんどのプログラミング言語では、特定の文字エンコーディング方法に依存し、詳細は「文字エンコーディング」の章で説明されています。
|
||||
- ブール値の表現には1ビット(0または1)のみが必要ですが、通常はメモリ内に1バイトとして格納されます。これは、現代のコンピュータCPUが通常1バイトを最小のアドレス可能なメモリ単位として使用するためです。
|
||||
- Python では、整数型 `int` は利用可能なメモリに制限されるだけで任意の大きさを取れます。浮動小数点数 `float` は倍精度 64 ビットです。`char` 型はなく、1 文字は実際には長さ 1 の文字列 `str` です。
|
||||
- C と C++ では基本データ型の大きさは明確に規定されておらず、実装やプラットフォームによって異なります。上表は LP64 [データモデル](https://en.cppreference.com/w/cpp/language/types#Properties) に従っており、Linux や macOS を含む Unix 系 64 ビット OS で用いられています。
|
||||
- `char` の大きさは C と C++ では 1 バイトですが、多くのプログラミング言語では採用する文字エンコーディング方式によって決まります。詳しくは「文字エンコーディング」の章を参照してください。
|
||||
- 真偽値を表すのに必要なのは 1 ビット($0$ または $1$)だけですが、メモリ上では通常 1 バイトとして格納されます。これは、現代のコンピュータ CPU が通常 1 バイトを最小のアドレス指定可能なメモリ単位としているためです。
|
||||
|
||||
では、基本データ型とデータ構造の関係は何でしょうか?データ構造とは、コンピュータ内でデータを組織化し格納する方法であることを知っています。ここでの焦点は「データ」ではなく「構造」です。
|
||||
では、基本データ型とデータ構造の間にはどのような関係があるのでしょうか。データ構造とは、コンピュータ内でデータを組織し格納する方法のことです。この言葉で主役なのは「データ」ではなく「構造」です。
|
||||
|
||||
「数値の列」を表現したい場合、自然に配列の使用を考えます。これは、配列の線形構造が数値の隣接性と順序性を表現できるためですが、格納される内容が整数`int`、小数`float`、文字`char`のいずれであっても、「データ構造」とは無関係です。
|
||||
「数字の並び」を表したいなら、自然に配列の使用を思い浮かべるでしょう。これは、配列の線形構造が数字どうしの隣接関係や順序関係を表せるからです。しかし、格納する内容が整数 `int` なのか、小数 `float` なのか、文字 `char` なのかは、「データ構造」とは関係ありません。
|
||||
|
||||
言い換えると、**基本データ型はデータの「内容型」を提供し、データ構造はデータの「組織化方法」を提供します**。例えば、以下のコードでは、同じデータ構造(配列)を使用して、`int`、`float`、`char`、`bool`などの異なる基本データ型を格納し表現しています。
|
||||
言い換えると、**基本データ型はデータの「内容の型」を提供し、データ構造はデータの「組織方法」を提供します**。たとえば次のコードでは、同じデータ構造(配列)を使って `int`、`float`、`char`、`bool` など異なる基本データ型を格納・表現しています。
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title=""
|
||||
# 様々な基本データ型を使用して配列を初期化
|
||||
# さまざまな基本データ型で配列を初期化する
|
||||
numbers: list[int] = [0] * 5
|
||||
decimals: list[float] = [0.0] * 5
|
||||
# Pythonの文字は実際には長さ1の文字列
|
||||
# Python の文字は実際には長さ 1 の文字列
|
||||
characters: list[str] = ['0'] * 5
|
||||
bools: list[bool] = [False] * 5
|
||||
# Pythonのリストは様々な基本データ型とオブジェクト参照を自由に格納可能
|
||||
# Python のリストはさまざまな基本データ型とオブジェクト参照を自由に格納できる
|
||||
data = [0, 0.0, 'a', False, ListNode(0)]
|
||||
```
|
||||
|
||||
=== "C++"
|
||||
|
||||
```cpp title=""
|
||||
// 様々な基本データ型を使用して配列を初期化
|
||||
// さまざまな基本データ型で配列を初期化する
|
||||
int numbers[5];
|
||||
float decimals[5];
|
||||
char characters[5];
|
||||
@@ -70,7 +70,7 @@
|
||||
=== "Java"
|
||||
|
||||
```java title=""
|
||||
// 様々な基本データ型を使用して配列を初期化
|
||||
// さまざまな基本データ型で配列を初期化する
|
||||
int[] numbers = new int[5];
|
||||
float[] decimals = new float[5];
|
||||
char[] characters = new char[5];
|
||||
@@ -80,7 +80,7 @@
|
||||
=== "C#"
|
||||
|
||||
```csharp title=""
|
||||
// 様々な基本データ型を使用して配列を初期化
|
||||
// さまざまな基本データ型で配列を初期化する
|
||||
int[] numbers = new int[5];
|
||||
float[] decimals = new float[5];
|
||||
char[] characters = new char[5];
|
||||
@@ -90,7 +90,7 @@
|
||||
=== "Go"
|
||||
|
||||
```go title=""
|
||||
// 様々な基本データ型を使用して配列を初期化
|
||||
// さまざまな基本データ型で配列を初期化する
|
||||
var numbers = [5]int{}
|
||||
var decimals = [5]float64{}
|
||||
var characters = [5]byte{}
|
||||
@@ -100,7 +100,7 @@
|
||||
=== "Swift"
|
||||
|
||||
```swift title=""
|
||||
// 様々な基本データ型を使用して配列を初期化
|
||||
// さまざまな基本データ型で配列を初期化する
|
||||
let numbers = Array(repeating: 0, count: 5)
|
||||
let decimals = Array(repeating: 0.0, count: 5)
|
||||
let characters: [Character] = Array(repeating: "a", count: 5)
|
||||
@@ -110,14 +110,14 @@
|
||||
=== "JS"
|
||||
|
||||
```javascript title=""
|
||||
// JavaScriptの配列は様々な基本データ型とオブジェクトを自由に格納可能
|
||||
// JavaScript の配列はさまざまな基本データ型とオブジェクトを自由に格納できる
|
||||
const array = [0, 0.0, 'a', false];
|
||||
```
|
||||
|
||||
=== "TS"
|
||||
|
||||
```typescript title=""
|
||||
// 様々な基本データ型を使用して配列を初期化
|
||||
// さまざまな基本データ型で配列を初期化する
|
||||
const numbers: number[] = [];
|
||||
const characters: string[] = [];
|
||||
const bools: boolean[] = [];
|
||||
@@ -126,7 +126,7 @@
|
||||
=== "Dart"
|
||||
|
||||
```dart title=""
|
||||
// 様々な基本データ型を使用して配列を初期化
|
||||
// さまざまな基本データ型で配列を初期化する
|
||||
List<int> numbers = List.filled(5, 0);
|
||||
List<double> decimals = List.filled(5, 0.0);
|
||||
List<String> characters = List.filled(5, 'a');
|
||||
@@ -136,9 +136,9 @@
|
||||
=== "Rust"
|
||||
|
||||
```rust title=""
|
||||
// 様々な基本データ型を使用して配列を初期化
|
||||
// さまざまな基本データ型で配列を初期化する
|
||||
let numbers: Vec<i32> = vec![0; 5];
|
||||
let decimals: Vec<f32> = vec![0.0, 5];
|
||||
let decimals: Vec<f32> = vec![0.0; 5];
|
||||
let characters: Vec<char> = vec!['0'; 5];
|
||||
let bools: Vec<bool> = vec![false; 5];
|
||||
```
|
||||
@@ -146,7 +146,7 @@
|
||||
=== "C"
|
||||
|
||||
```c title=""
|
||||
// 様々な基本データ型を使用して配列を初期化
|
||||
// さまざまな基本データ型で配列を初期化する
|
||||
int numbers[10];
|
||||
float decimals[10];
|
||||
char characters[10];
|
||||
@@ -156,6 +156,20 @@
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title=""
|
||||
|
||||
// さまざまな基本データ型で配列を初期化する
|
||||
val numbers = IntArray(5)
|
||||
val decinals = FloatArray(5)
|
||||
val characters = CharArray(5)
|
||||
val bools = BooleanArray(5)
|
||||
```
|
||||
|
||||
=== "Ruby"
|
||||
|
||||
```ruby title=""
|
||||
# Ruby のリストはさまざまな基本データ型とオブジェクト参照を自由に格納できる
|
||||
data = [0, 0.0, 'a', false, ListNode(0)]
|
||||
```
|
||||
|
||||
??? pythontutor "実行の可視化"
|
||||
|
||||
https://pythontutor.com/render.html#code=class%20ListNode%3A%0A%20%20%20%20%22%22%22%E9%93%BE%E8%A1%A8%E8%8A%82%E7%82%B9%E7%B1%BB%22%22%22%0A%20%20%20%20def%20__init__%28self,%20val%3A%20int%29%3A%0A%20%20%20%20%20%20%20%20self.val%3A%20int%20%3D%20val%20%20%23%20%E8%8A%82%E7%82%B9%E5%80%BC%0A%20%20%20%20%20%20%20%20self.next%3A%20ListNode%20%7C%20None%20%3D%20None%20%20%23%20%E5%90%8E%E7%BB%A7%E8%8A%82%E7%82%B9%E5%BC%95%E7%94%A8%0A%0A%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E4%BD%BF%E7%94%A8%E5%A4%9A%E7%A7%8D%E5%9F%BA%E6%9C%AC%E6%95%B0%E6%8D%AE%E7%B1%BB%E5%9E%8B%E6%9D%A5%E5%88%9D%E5%A7%8B%E5%8C%96%E6%95%B0%E7%BB%84%0A%20%20%20%20numbers%20%3D%20%5B0%5D%20*%205%0A%20%20%20%20decimals%20%3D%20%5B0.0%5D%20*%205%0A%20%20%20%20%23%20Python%20%E7%9A%84%E5%AD%97%E7%AC%A6%E5%AE%9E%E9%99%85%E4%B8%8A%E6%98%AF%E9%95%BF%E5%BA%A6%E4%B8%BA%201%20%E7%9A%84%E5%AD%97%E7%AC%A6%E4%B8%B2%0A%20%20%20%20characters%20%3D%20%5B'0'%5D%20*%205%0A%20%20%20%20bools%20%3D%20%5BFalse%5D%20*%205%0A%20%20%20%20%23%20Python%20%E7%9A%84%E5%88%97%E8%A1%A8%E5%8F%AF%E4%BB%A5%E8%87%AA%E7%94%B1%E5%AD%98%E5%82%A8%E5%90%84%E7%A7%8D%E5%9F%BA%E6%9C%AC%E6%95%B0%E6%8D%AE%E7%B1%BB%E5%9E%8B%E5%92%8C%E5%AF%B9%E8%B1%A1%E5%BC%95%E7%94%A8%0A%20%20%20%20data%20%3D%20%5B0,%200.0,%20'a',%20False,%20ListNode%280%29%5D&cumulative=false&curInstr=12&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
|
||||
|
||||
@@ -1,87 +1,87 @@
|
||||
# 文字エンコーディング *
|
||||
|
||||
コンピュータシステムでは、すべてのデータが二進形式で格納され、`char`も例外ではありません。文字を表現するために、各文字と二進数の一対一のマッピングを定義する「文字セット」を開発する必要があります。文字セットがあれば、コンピュータは表を参照して二進数を文字に変換できます。
|
||||
コンピュータでは、すべてのデータは二進数の形で保存されており、文字 `char` も例外ではありません。文字を表すためには、「文字セット」を定義し、各文字と二進数の間の一対一の対応関係を定める必要があります。文字セットがあれば、コンピュータは対応表を参照して二進数から文字への変換を行えます。
|
||||
|
||||
## ASCII文字セット
|
||||
## ASCII 文字セット
|
||||
|
||||
<u>ASCIIコード</u>は最も初期の文字セットの一つで、正式にはAmerican Standard Code for Information Interchangeとして知られています。7つの二進桁(1バイトの下位7ビット)を使用して文字を表現し、最大128種類の異なる文字を表現できます。以下の図に示すように、ASCIIには英語の大文字と小文字、0〜9の数字、様々な句読点、特定の制御文字(改行やタブなど)が含まれています。
|
||||
<u>ASCII コード</u>は最も早く登場した文字セットで、その正式名称は American Standard Code for Information Interchange(米国標準情報交換コード)です。これは 7 ビットの二進数(1 バイトの下位 7 ビット)で 1 文字を表し、最大で 128 種類の異なる文字を表現できます。下図のように、ASCII コードには英字の大文字と小文字、数字 0 ~ 9、いくつかの句読点、そしていくつかの制御文字(改行やタブなど)が含まれます。
|
||||
|
||||

|
||||

|
||||
|
||||
しかし、**ASCIIは英語の文字のみを表現できます**。コンピュータのグローバル化に伴い、より多くの言語を表現するために<u>EASCII</u>と呼ばれる文字セットが開発されました。ASCIIの7ビット構造から8ビットに拡張し、256文字の表現を可能にしました。
|
||||
しかし、**ASCII コードで表現できるのは英語だけです**。コンピュータのグローバル化に伴い、より多くの言語を表せる <u>EASCII</u> 文字セットが生まれました。これは ASCII の 7 ビットを 8 ビットへ拡張したもので、256 種類の異なる文字を表現できます。
|
||||
|
||||
世界的に、様々な地域固有のEASCII文字セットが導入されました。これらのセットの最初の128文字はASCIIと一致していますが、残りの128文字は異なる言語の要件に対応するために異なって定義されています。
|
||||
世界では、さまざまな地域に適した EASCII 文字セットが次々に登場しました。これらの文字セットでは、前半の 128 文字は ASCII コードで統一され、後半の 128 文字は各言語の要件に合わせて個別に定義されています。
|
||||
|
||||
## GBK文字セット
|
||||
## GBK 文字セット
|
||||
|
||||
後に、**EASCIIでも多くの言語の文字要件を満たすことができない**ことが判明しました。例えば、中国語には約10万の漢字があり、そのうち数千が定期的に使用されています。1980年、中国標準化委員会は6763の中国語文字を含む<u>GB2312</u>文字セットを発表し、中国語のコンピュータ処理ニーズを本質的に満たしました。
|
||||
その後、人々は**EASCII コードでも多くの言語に必要な文字数を満たせない**ことに気づきました。たとえば漢字は 10 万字近くあり、日常的に使うものだけでも数千字あります。中国国家標準総局は 1980 年に <u>GB2312</u> 文字セットを公開し、6763 字の漢字を収録して、漢字のコンピュータ処理の基本的な需要を満たしました。
|
||||
|
||||
しかし、GB2312は一部の稀少文字や繁体字を処理できませんでした。<u>GBK</u>文字セットはGB2312を拡張し、21886の中国語文字を含んでいます。GBKエンコーディングスキームでは、ASCII文字は1バイトで表現され、中国語文字は2バイトを使用します。
|
||||
しかし、GB2312 では一部の珍しい字や繁体字を扱えません。<u>GBK</u> 文字セットは GB2312 を基に拡張されたもので、合計 21886 字の漢字を収録しています。GBK のエンコーディング方式では、ASCII 文字は 1 バイト、漢字は 2 バイトで表されます。
|
||||
|
||||
## Unicode文字セット
|
||||
## Unicode 文字セット
|
||||
|
||||
コンピュータ技術の急速な発展と多数の文字セットおよびエンコーディング標準により、数多くの問題が発生しました。一方では、これらの文字セットは一般的に特定の言語の文字のみを定義し、多言語環境では適切に機能できませんでした。他方では、同じ言語に対する複数の文字セット標準の存在により、異なるエンコーディング標準を使用するコンピュータ間で情報交換を行う際に文字化けが発生しました。
|
||||
コンピュータ技術が急速に発展するにつれて、文字セットと符号化規格は百花繚乱の状態となり、それに伴って多くの問題も生じました。一方では、これらの文字セットは通常、特定の言語の文字しか定義しておらず、多言語環境では正常に動作できませんでした。もう一方では、同じ言語にも複数の文字セット規格が存在し、2 台のコンピュータが異なる符号化規格を使っていると、情報伝達の際に文字化けが発生しました。
|
||||
|
||||
当時の研究者たちは考えました:**世界のすべての言語と記号を含む包括的な文字セットが開発されれば、言語横断環境と文字化けに関連する問題を解決できるのではないでしょうか?** このアイデアにインスパイアされて、広範囲な文字セットであるUnicodeが誕生しました。
|
||||
当時の研究者たちはこう考えました。**十分に完全な文字セットを打ち出して、世界中のあらゆる言語と記号をそこに収録すれば、多言語環境や文字化けの問題を解決できるのではないか**。この発想に後押しされて、大規模で包括的な文字セット Unicode が誕生しました。
|
||||
|
||||
<u>Unicode</u>は中国語で「统一码」(統一コード)と呼ばれ、理論的に100万文字以上を収容できます。世界中のすべての文字を単一のセットに組み込み、様々な言語の処理と表示のための汎用文字セットを提供し、異なるエンコーディング標準による文字化けの問題を減らすことを目指しています。
|
||||
<u>Unicode</u> の中国語名は「統一コード」であり、理論上は 100 万を超える文字を収容できます。Unicode は世界中の文字を 1 つの文字セットに統合することを目指し、さまざまな言語の文字を処理・表示できる汎用文字セットを提供することで、符号化規格の違いによる文字化けを減らそうとしています。
|
||||
|
||||
1991年のリリース以来、Unicodeは新しい言語と文字を含むよう継続的に拡張されています。2022年9月現在、Unicodeには149,186文字が含まれており、様々な言語の文字、記号、さらには絵文字も含まれています。広大なUnicode文字セットでは、一般的に使用される文字は2バイトを占有し、一部の稀少な文字は3バイトまたは4バイトを占有する場合があります。
|
||||
1991 年の公開以来、Unicode は新しい言語と文字を継続的に拡充してきました。2022 年 9 月時点で、Unicode にはすでに 149186 文字が含まれており、各種言語の文字、記号、さらには絵文字まで収録されています。巨大な Unicode 文字セットでは、よく使われる文字は 2 バイトを占め、一部の珍しい文字は 3 バイト、さらには 4 バイトを占めます。
|
||||
|
||||
Unicodeは各文字に数値(「コードポイント」と呼ばれる)を割り当てる汎用文字セットですが、**これらの文字コードポイントがコンピュータシステムにどのように格納されるべきかは指定していません**。疑問が生じるかもしれません:システムはテキスト内の異なる長さのUnicodeコードポイントをどのように解釈するのでしょうか?例えば、2バイトのコードが与えられた場合、システムはそれが単一の2バイト文字を表すのか、2つの1バイト文字を表すのかをどのように判断するのでしょうか?
|
||||
Unicode は汎用文字セットであり、本質的には各文字に番号(「コードポイント」)を割り当てるものですが、**それらのコードポイントをコンピュータ内でどのように保存するかまでは規定していません**。ここで疑問が生じます。長さの異なる Unicode コードポイントが同じテキストに現れたとき、システムはどのように文字を解析するのでしょうか。たとえば長さ 2 バイトの符号が与えられたとき、それが 2 バイトの 1 文字なのか、1 バイトの 2 文字なのかをどう判定するのでしょうか。
|
||||
|
||||
**この問題に対する簡単な解決策は、すべての文字を等長エンコーディングとして格納することです**。以下の図に示すように、「Hello」の各文字は1バイトを占有し、「算法」(アルゴリズム)の各文字は2バイトを占有します。上位ビットをゼロで埋めることで、「Hello 算法」のすべての文字を2バイトとしてエンコードできます。この方法により、システムは2バイトごとに文字を解釈し、フレーズの内容を復元できます。
|
||||
この問題に対して、**すべての文字を固定長の符号として保存する**という直接的な解決策があります。下図のように、「Hello」の各文字は 1 バイト、「アルゴリズム」の各文字は 2 バイトを占めます。上位ビットを 0 で埋めることで、「Hello アルゴリズム」のすべての文字を 2 バイト長にエンコードできます。こうすれば、システムは 2 バイトごとに 1 文字を解析して、この語句の内容を復元できます。
|
||||
|
||||

|
||||

|
||||
|
||||
しかし、ASCIIが示したように、英語のエンコーディングには1バイトのみが必要です。上記のアプローチを使用すると、英語テキストが占有する空間がASCIIエンコーディングと比較して2倍になり、メモリ空間の無駄になります。したがって、より効率的なUnicodeエンコーディング方法が必要です。
|
||||
しかし ASCII コードはすでに、英語の符号化には 1 バイトで十分であることを示しています。上記の方式を採用すると、英語のテキストが占める空間は ASCII エンコーディング時の 2 倍になり、メモリ空間の浪費が大きくなります。そのため、より効率的な Unicode エンコーディング方式が必要です。
|
||||
|
||||
## UTF-8エンコーディング
|
||||
## UTF-8 エンコーディング
|
||||
|
||||
現在、UTF-8は国際的に最も広く使用されているUnicodeエンコーディング方法になっています。**これは可変長エンコーディング**で、文字の複雑さに応じて1〜4バイトを使用して文字を表現します。ASCII文字は1バイトのみが必要で、ラテン文字とギリシャ文字は2バイト、一般的に使用される中国語文字は3バイト、その他の稀少な文字は4バイトが必要です。
|
||||
現在、UTF-8 は国際的に最も広く使われている Unicode エンコーディング方式になっています。**これは可変長のエンコーディング**であり、1 文字を 1 〜 4 バイトで表し、文字の複雑さに応じて長さが変わります。ASCII 文字は 1 バイト、ラテン文字とギリシャ文字は 2 バイト、一般的な漢字は 3 バイト、そのほかの一部の珍しい文字は 4 バイト必要です。
|
||||
|
||||
UTF-8のエンコーディング規則は複雑ではなく、2つのケースに分けることができます:
|
||||
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コードポイントを埋めるために使用されます。
|
||||
- 長さ 1 バイトの文字では、最上位ビットを $0$ にし、残りの 7 ビットを Unicode コードポイントに設定します。ここで注意すべきなのは、ASCII 文字が Unicode 文字セットの先頭 128 個のコードポイントを占めていることです。つまり、**UTF-8 エンコーディングは ASCII コードと下位互換性があります**。このため、UTF-8 を使って古い ASCII コードのテキストを解析できます。
|
||||
- 長さ $n$ バイトの文字(ただし $n > 1$)では、先頭バイトの上位 $n$ ビットをすべて $1$ にし、第 $n + 1$ ビットを $0$ に設定します。2 バイト目以降では、各バイトの上位 2 ビットをいずれも $10$ にし、残りのすべてのビットで文字の Unicode コードポイントを埋めます。
|
||||
|
||||
以下の図は「Hello算法」のUTF-8エンコーディングを示しています。最上位$n$ビットが$1$に設定されているため、システムは最上位ビットで$1$に設定されたビット数を数えることで文字の長さを$n$として決定できることが観察できます。
|
||||
下図は「Helloアルゴリズム」に対応する UTF-8 エンコーディングを示しています。観察すると、上位 $n$ ビットがすべて $1$ に設定されているため、システムは先頭から連続する $1$ の個数を読むことで、その文字の長さが $n$ であると解析できます。
|
||||
|
||||
しかし、なぜ残りのバイトの最上位2ビットを$10$に設定するのでしょうか?実際、この$10$は一種のチェックサムとして機能します。システムが間違ったバイトからテキストの解析を開始した場合、バイトの先頭の$10$によりシステムは異常を迅速に検出できます。
|
||||
では、なぜ残りのすべてのバイトの上位 2 ビットを $10$ にするのでしょうか。実は、この $10$ は検査用の印として機能します。システムが誤ったバイト位置からテキストを解析し始めたとしても、バイト先頭の $10$ によって異常を素早く判定できます。
|
||||
|
||||
$10$をチェックサムとして使用する理由は、UTF-8エンコーディング規則の下では、文字の最上位2ビットが$10$になることは不可能だからです。これは矛盾により証明できます:文字の最上位2ビットが$10$の場合、文字の長さが$1$であることを示し、これはASCIIに対応します。しかし、ASCII文字の最上位ビットは$0$であるべきで、これは仮定と矛盾します。
|
||||
この $10$ を検査用の印とする理由は、UTF-8 の符号化規則では上位 2 ビットが $10$ になる文字は存在しないからです。この結論は背理法で証明できます。ある文字の上位 2 ビットが $10$ だと仮定すると、その文字の長さは $1$ であり、ASCII コードに対応することになります。しかし ASCII コードの最上位ビットは $0$ であるはずなので、仮定と矛盾します。
|
||||
|
||||

|
||||

|
||||
|
||||
UTF-8以外にも、他の一般的なエンコーディング方法には以下があります:
|
||||
UTF-8 以外にも、一般的なエンコーディング方式として次の 2 つがあります。
|
||||
|
||||
- **UTF-16エンコーディング**:2または4バイトを使用して文字を表現します。すべてのASCII文字と一般的に使用される非英語文字は2バイトで表現され、少数の文字は4バイトが必要です。2バイト文字の場合、UTF-16エンコーディングはUnicodeコードポイントと等しくなります。
|
||||
- **UTF-32エンコーディング**:すべての文字が4バイトを使用します。これは、UTF-32がUTF-8やUTF-16よりも多くの空間を占有することを意味し、特にASCII文字の割合が高いテキストでは顕著です。
|
||||
- **UTF-16 エンコーディング**:1 文字を 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 は英語文字の表現に非常に効率的で、必要なのは 1 バイトだけです。一方、UTF-16 は一部の非英語文字(たとえば中国語の文字)の符号化でより効率的になることがあり、必要なのは 2 バイトだけで、UTF-8 では 3 バイト必要になる場合があります。
|
||||
|
||||
互換性の観点から、UTF-8は最も汎用性があり、多くのツールとライブラリがUTF-8を優先的にサポートしています。
|
||||
互換性という観点では、UTF-8 の汎用性が最も高く、多くのツールやライブラリが UTF-8 を優先的にサポートしています。
|
||||
|
||||
## プログラミング言語における文字エンコーディング
|
||||
## プログラミング言語の文字エンコーディング
|
||||
|
||||
歴史的に、多くのプログラミング言語はプログラム実行中の文字列処理にUTF-16やUTF-32などの固定長エンコーディングを利用していました。これにより文字列を配列として処理でき、いくつかの利点があります:
|
||||
従来の多くのプログラミング言語では、実行中の文字列に 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でエンコードされた文字列で追加の計算が必要です。
|
||||
- **ランダムアクセス**:UTF-16 で符号化された文字列はランダムアクセスが容易です。UTF-8 は可変長エンコーディングなので、第 $i$ 文字を見つけるには文字列の先頭から第 $i$ 文字まで走査する必要があり、$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エンコーディングを広範囲に使用しているためです。
|
||||
- Java の `String` 型は UTF-16 エンコーディングを使用し、各文字は 2 バイトを占めます。これは Java 言語の設計当初、人々が 16 ビットあればあらゆる文字を表現するのに十分だと考えていたためです。しかし、これは誤った判断でした。その後 Unicode 規格は 16 ビットを超える範囲へ拡張されたため、現在の Java では 1 文字が 16 ビット値の組(「サロゲートペア」)で表されることがあります。
|
||||
- JavaScript と TypeScript の文字列が UTF-16 エンコーディングを使う理由も Java と似ています。1995 年に Netscape 社が初めて JavaScript 言語を公開した当時、Unicode はまだ発展初期にあり、16 ビットの符号化で十分すべての Unicode 文字を表せると考えられていました。
|
||||
- C# が UTF-16 エンコーディングを使う主な理由は、.NET プラットフォームが Microsoft によって設計され、Microsoft の多くの技術(Windows オペレーティングシステムを含む)で UTF-16 エンコーディングが広く使われているためです。
|
||||
|
||||
文字数の過小評価により、これらの言語は16ビットを超えるUnicode文字を表現するために「サロゲートペア」を使用する必要がありました。このアプローチには欠点があります:サロゲートペアを含む文字列は2バイトまたは4バイトを占有する文字を持つ場合があり、固定長エンコーディングの利点を失います。さらに、サロゲートペアの処理はプログラミングに複雑さとデバッグの困難さを追加します。
|
||||
以上のプログラミング言語は文字数を過小評価していたため、16 ビットを超える長さの Unicode 文字を表すために「サロゲートペア」を採用せざるを得ませんでした。これはやむを得ない妥協策です。一方では、サロゲートペアを含む文字列では、1 文字が 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`型も提供します。
|
||||
- Python の `str` は Unicode エンコーディングを使用し、柔軟な文字列表現を採用しています。保存される文字の長さは、その文字列中で最大の Unicode コードポイントに依存します。文字列がすべて ASCII 文字であれば各文字は 1 バイト、ASCII の範囲を超える文字があってもすべてが基本多言語面(BMP)内であれば各文字は 2 バイト、BMP を超える文字があれば各文字は 4 バイトを占めます。
|
||||
- Go 言語の `string` 型は内部で UTF-8 エンコーディングを使用します。Go 言語には単一の Unicode コードポイントを表す `rune` 型も用意されています。
|
||||
- Rust 言語の `str` と `String` 型は内部で UTF-8 エンコーディングを使用します。Rust にも単一の Unicode コードポイントを表す `char` 型があります。
|
||||
|
||||
上記の議論は、プログラミング言語での文字列の格納方法に関するものであり、**ファイルでの文字列の格納方法やネットワーク上での送信方法とは異なる**ことに注意することが重要です。ファイルストレージやネットワーク送信では、文字列は通常、最適な互換性と空間効率のためにUTF-8形式でエンコードされます。
|
||||
注意すべきなのは、ここまでの議論はすべて、プログラミング言語内での文字列の保存方法についてであり、**文字列をファイルに保存したりネットワークで転送したりする方法とは別の問題である**ということです。ファイル保存やネットワーク転送では、通常、互換性と空間効率を最適化するために文字列を UTF-8 形式にエンコードします。
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
# データ構造の分類
|
||||
|
||||
一般的なデータ構造には、配列、連結リスト、スタック、キュー、ハッシュ表、木、ヒープ、グラフがあります。これらは「論理構造」と「物理構造」に分類できます。
|
||||
代表的なデータ構造には、配列、連結リスト、スタック、キュー、ハッシュテーブル、木、ヒープ、グラフがあり、これらは「論理構造」と「物理構造」の 2 つの観点から分類できます。
|
||||
|
||||
## 論理構造:線形と非線形
|
||||
|
||||
**論理構造はデータ要素間の論理的関係を明らかにします**。配列と連結リストでは、データは特定の順序で配置され、データ間の線形関係を示しています。一方、木では、データは上から下へ階層的に配置され、「祖先」と「子孫」間の派生関係を示しています。そして、グラフはノードとエッジから構成され、複雑なネットワーク関係を反映しています。
|
||||
**論理構造はデータ要素間の論理的な関係を示します**。配列と連結リストでは、データは一定の順序で並び、データ間の線形関係を表します。一方、木ではデータは上から下へ階層的に並び、「祖先」と「子孫」の派生関係を示します。グラフはノードと辺で構成され、複雑なネットワーク関係を反映します。
|
||||
|
||||
下図に示されているように、論理構造は「線形」と「非線形」の2つの主要カテゴリに分けることができます。線形構造はより直感的で、データが論理関係において線形に配置されていることを示しています。非線形構造は、逆に非線形に配置されています。
|
||||
以下の図に示すように、論理構造は「線形」と「非線形」の 2 つに大別できます。線形構造は比較的直感的で、データが論理関係において線形に並ぶことを指します。非線形構造はその逆で、非線形に配置されます。
|
||||
|
||||
- **線形データ構造**:配列、連結リスト、スタック、キュー、ハッシュ表。要素が一対一の順次関係を持ちます。
|
||||
- **非線形データ構造**:木、ヒープ、グラフ、ハッシュ表。
|
||||
- **線形データ構造**:配列、連結リスト、スタック、キュー、ハッシュテーブルであり、要素間は 1 対 1 の順序関係です。
|
||||
- **非線形データ構造**:木、ヒープ、グラフ、ハッシュテーブル。
|
||||
|
||||
非線形データ構造は、さらに木構造とネットワーク構造に分けることができます。
|
||||
非線形データ構造は、さらに木構造と網状構造に分けられます。
|
||||
|
||||
- **木構造**:木、ヒープ、ハッシュ表。要素が一対多の関係を持ちます。
|
||||
- **ネットワーク構造**:グラフ。要素が多対多の関係を持ちます。
|
||||
- **木構造**:木、ヒープ、ハッシュテーブルであり、要素間は 1 対多の関係です。
|
||||
- **網状構造**:グラフであり、要素間は多対多の関係です。
|
||||
|
||||

|
||||

|
||||
|
||||
## 物理構造:連続と分散
|
||||
|
||||
**アルゴリズムの実行中、処理されるデータはメモリに格納されます**。下図はコンピュータのメモリスティックを示しており、各黒い正方形は物理メモリ空間です。メモリを巨大なExcelスプレッドシートと考えることができ、各セルは一定量のデータを格納できます。
|
||||
**アルゴリズムのプログラムが実行されるとき、処理中のデータは主にメモリに格納されます**。下図はコンピュータのメモリモジュールを示しており、各黒い四角はそれぞれ 1 つのメモリ空間を表しています。メモリは巨大な Excel の表のようなものだと考えることができ、各セルには一定量のデータを格納できます。
|
||||
|
||||
**システムはメモリアドレスによって目標位置のデータにアクセスします**。下図に示されているように、コンピュータは特定のルールに従って表の各セルに一意の識別子を割り当て、各メモリ空間が一意のメモリアドレスを持つことを保証します。これらのアドレスにより、プログラムはメモリに格納されたデータにアクセスできます。
|
||||
**システムはメモリアドレスを通じて目的の位置にあるデータへアクセスします**。下図に示すように、コンピュータは特定の規則に従って表内の各セルに番号を割り当て、各メモリ空間が一意のメモリアドレスを持つようにします。これらのアドレスがあれば、プログラムはメモリ内のデータにアクセスできます。
|
||||
|
||||

|
||||

|
||||
|
||||
!!! tip
|
||||
|
||||
メモリをExcelスプレッドシートに比較することは簡略化された類推であることに注意してください。メモリの実際の動作メカニズムはより複雑で、アドレス空間、メモリ管理、キャッシュメカニズム、仮想メモリ、物理メモリなどの概念が関係しています。
|
||||
補足すると、メモリを Excel の表にたとえるのは単純化した比喩であり、実際のメモリの動作機構はより複雑で、アドレス空間、メモリ管理、キャッシュ機構、仮想メモリ、物理メモリなどの概念が関わります。
|
||||
|
||||
メモリはすべてのプログラムの共有リソースです。あるメモリブロックが1つのプログラムによって占有されると、他のプログラムが同時に使用することはできません。**したがって、メモリリソースはデータ構造とアルゴリズムの設計における重要な考慮事項です**。例えば、アルゴリズムのピークメモリ使用量は、システムの残り空きメモリを超えてはいけません。連続したメモリブロックが不足している場合は、非連続メモリブロックに格納できるデータ構造を選択する必要があります。
|
||||
メモリはすべてのプログラムで共有される資源であり、あるメモリ領域が 1 つのプログラムに占有されると、通常は他のプログラムが同時に利用できません。**したがって、データ構造とアルゴリズムの設計では、メモリ資源は重要な考慮要素です**。たとえば、アルゴリズムが使用するメモリ使用量のピークは、システムに残っている空きメモリを超えてはなりません。大きな連続メモリ領域が不足している場合、選択するデータ構造は分散したメモリ空間に格納できる必要があります。
|
||||
|
||||
下図に示されているように、**物理構造はコンピュータメモリにおけるデータの格納方法を反映し**、連続空間格納(配列)と非連続空間格納(連結リスト)に分けることができます。2つのタイプの物理構造は、時間効率と空間効率の観点で補完的な特性を示します。
|
||||
下図に示すように、**物理構造はデータがコンピュータメモリ内にどのように格納されるかを表します**。これは連続空間への格納(配列)と分散空間への格納(連結リスト)に分けられます。物理構造は低レベルでデータのアクセス、更新、追加、削除などの操作方法を決定し、2 種類の物理構造は時間効率と空間効率の面で相補的な特徴を持ちます。
|
||||
|
||||

|
||||

|
||||
|
||||
**すべてのデータ構造は配列、連結リスト、またはその組み合わせに基づいて実装されていることに注意してください**。例えば、スタックとキューは配列または連結リストのどちらでも実装できます。ハッシュ表の実装には配列と連結リストの両方が関係する場合があります。
|
||||
補足すると、**すべてのデータ構造は配列、連結リスト、またはその両者の組み合わせに基づいて実装されます**。たとえば、スタックとキューは配列でも連結リストでも実装できます。一方、ハッシュテーブルの実装には配列と連結リストの両方が含まれる場合があります。
|
||||
|
||||
- **配列ベースの実装**:スタック、キュー、ハッシュ表、木、ヒープ、グラフ、行列、テンソル(次元$\geq 3$の配列)。
|
||||
- **連結リストベースの実装**:スタック、キュー、ハッシュ表、木、ヒープ、グラフなど。
|
||||
- **配列に基づいて実装可能**:スタック、キュー、ハッシュテーブル、木、ヒープ、グラフ、行列、テンソル(次元 $\geq 3$ の配列)など。
|
||||
- **連結リストに基づいて実装可能**:スタック、キュー、ハッシュテーブル、木、ヒープ、グラフなど。
|
||||
|
||||
配列に基づいて実装されたデータ構造は「静的データ構造」とも呼ばれ、初期化後に長さを変更できないことを意味します。逆に、連結リストに基づいたものは「動的データ構造」と呼ばれ、プログラム実行中にサイズを調整できます。
|
||||
連結リストは初期化後も、プログラムの実行中に長さを調整できるため、「動的データ構造」とも呼ばれます。配列は初期化後に長さを変更できないため、「静的データ構造」とも呼ばれます。なお、配列もメモリを再割り当てすることで長さを変更でき、ある程度の「動的性」を持たせることができます。
|
||||
|
||||
!!! tip
|
||||
|
||||
物理構造を理解するのが困難な場合は、次の章「配列と連結リスト」を読んでから、この節に戻ることをお勧めします。
|
||||
物理構造の理解が難しいと感じる場合は、先に次の章を読んでから本節を振り返ることを勧めます。
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# データ構造
|
||||
|
||||

|
||||

|
||||
|
||||
!!! abstract
|
||||
|
||||
データ構造は堅牢で多様なフレームワークとして機能します。
|
||||
|
||||
データの整然とした組織化のための設計図を提供し、その上でアルゴリズムが生き生きと動き出します。
|
||||
データ構造は、堅固で多様な枠組みのようなものである。
|
||||
|
||||
それはデータを秩序立てて組織するための青写真を示し、アルゴリズムはその上で生き生きと動き出す。
|
||||
|
||||
@@ -2,23 +2,23 @@
|
||||
|
||||
!!! tip
|
||||
|
||||
本書では、アスタリスク「*」が付いた章は任意読書です。時間が不足している場合や難しいと感じる場合は、最初はこれらをスキップして、必須の章を完了した後に戻ることができます。
|
||||
本書では、タイトルに * 記号が付いている章は選読です。時間が限られている場合や理解が難しいと感じる場合は、いったん読み飛ばし、必読章を終えてから個別に取り組んでください。
|
||||
|
||||
## 整数エンコーディング
|
||||
## 符号付き絶対値表現、1 の補数、2 の補数
|
||||
|
||||
前の節の表で、すべての整数型は正の数よりも1つ多い負の数を表現できることを観察しました。例えば、`byte`の範囲は$[-128, 127]$です。この現象は直感に反するように見え、その根本的な理由には符号絶対値、1の補数、2の補数エンコーディングの知識が関与しています。
|
||||
前節の表を見ると、すべての整数型で表せる負数の個数は正数より 1 つ多く、たとえば `byte` の値域は $[-128, 127]$ です。この現象は直感に反するように見えますが、その背景には符号付き絶対値表現、1 の補数、2 の補数に関する知識があります。
|
||||
|
||||
まず重要なことは、**数値はコンピュータ内で2の補数形式で格納される**ということです。なぜそうなのかを分析する前に、これら3つのエンコーディング方法を定義しましょう:
|
||||
まず押さえておくべきなのは、**数値はコンピュータ内で「2 の補数」の形で保存される**ということです。その理由を説明する前に、まずはこの 3 つの定義を示します。
|
||||
|
||||
- **符号絶対値**:数値の二進表現の最上位ビットを符号ビットとし、$0$は正の数、$1$は負の数を表します。残りのビットは数値の値を表します。
|
||||
- **1の補数**:正の数の1の補数は符号絶対値と同じです。負の数の場合、符号ビット以外のすべてのビットを反転して得られます。
|
||||
- **2の補数**:正の数の2の補数は符号絶対値と同じです。負の数の場合、その1の補数に$1$を加えて得られます。
|
||||
- **符号付き絶対値表現**:数値の二進表現の最上位ビットを符号ビットとみなし、$0$ は正数、$1$ は負数を表し、残りのビットが数値の値を表します。
|
||||
- **1 の補数**:正数の 1 の補数は符号付き絶対値表現と同じで、負数の 1 の補数は符号ビットを除くすべてのビットを反転したものです。
|
||||
- **2 の補数**:正数の 2 の補数は符号付き絶対値表現と同じで、負数の 2 の補数は 1 の補数に $1$ を加えたものです。
|
||||
|
||||
以下の図は、符号絶対値、1の補数、2の補数間の変換を示しています:
|
||||
下図は、符号付き絶対値表現、1 の補数、2 の補数の変換方法を示しています。
|
||||
|
||||

|
||||

|
||||
|
||||
<u>符号絶対値</u>は最も直感的ですが、制限があります。一つには、**符号絶対値の負の数は計算で直接使用できません**。例えば、符号絶対値で$1 + (-2)$を計算すると$-3$になり、これは正しくありません。
|
||||
<u>符号付き絶対値表現(sign-magnitude)</u>は最も直感的ですが、いくつかの制約があります。まず、**負数の符号付き絶対値表現はそのまま演算に使えません**。たとえば符号付き絶対値表現で $1 + (-2)$ を計算すると、結果は $-3$ になってしまい、これは明らかに誤りです。
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
@@ -29,20 +29,20 @@ $$
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
この問題に対処するため、コンピュータは<u>1の補数</u>を導入しました。1の補数に変換して$1 + (-2)$を計算し、結果を符号絶対値に戻すと、正しい結果$-1$が得られます。
|
||||
この問題を解決するために、コンピュータには<u>1 の補数(1's complement)</u>が導入されました。まず符号付き絶対値表現を 1 の補数に変換し、1 の補数で $1 + (-2)$ を計算してから、結果を 1 の補数から符号付き絶対値表現へ戻すと、正しい結果 $-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 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を区別しないと、正しくない結果になる可能性があります。この曖昧さに対処するには追加のチェックが必要で、計算効率が低下する可能性があります。
|
||||
一方、**数値 0 の符号付き絶対値表現には $+0$ と $-0$ の 2 つの表し方があります**。つまり、数値 0 に対して異なる 2 つの二進コードが対応しており、これは曖昧さの原因になります。たとえば条件判定で正のゼロと負のゼロを区別しないと、誤った判定結果になる可能性があります。また、この曖昧さを解消しようとすると追加の判定処理が必要になり、計算効率が下がるおそれがあります。
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
@@ -51,100 +51,100 @@ $$
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
符号絶対値と同様に、1の補数も正と負の0の曖昧さに悩まされます。そのため、コンピュータはさらに<u>2の補数</u>を導入しました。符号絶対値、1の補数、2の補数における負の0の変換過程を観察してみましょう:
|
||||
符号付き絶対値表現と同様に、1 の補数にも正負のゼロの曖昧さがあります。そこでコンピュータはさらに<u>2 の補数(2's complement)</u>を導入しました。まずは負のゼロについて、符号付き絶対値表現、1 の補数、2 の補数の変換を見てみましょう。
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
-0 \rightarrow \; & 1000 \; 0000 \; \text{(符号絶対値)} \newline
|
||||
= \; & 1111 \; 1111 \; \text{(1の補数)} \newline
|
||||
= 1 \; & 0000 \; 0000 \; \text{(2の補数)} \newline
|
||||
-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と同じになり、曖昧さが解決されます。
|
||||
負のゼロの 1 の補数に $1$ を加えると桁上がりが発生しますが、`byte` 型の長さは 8 ビットしかないため、第 9 ビットへあふれた $1$ は捨てられます。つまり、**負のゼロの 2 の補数は $0000 \; 0000$ であり、正のゼロの 2 の補数と同じです**。そのため、2 の補数表現ではゼロは 1 つしか存在せず、正負のゼロの曖昧さは解消されます。
|
||||
|
||||
最後の謎は、`byte`の$[-128, 127]$の範囲で、追加の負の数$-128$があることです。$[-127, +127]$の区間では、すべての整数に対応する符号絶対値、1の補数、2の補数があり、相互変換が可能であることを観察します。
|
||||
最後にもう 1 つ疑問が残ります。`byte` 型の値域は $[-128, 127]$ ですが、余分にある負数 $-128$ はどのように得られるのでしょうか。区間 $[-127, +127]$ にあるすべての整数には、それぞれ対応する符号付き絶対値表現、1 の補数、2 の補数があり、符号付き絶対値表現と 2 の補数の間は相互に変換できます。
|
||||
|
||||
しかし、**2の補数$1000 \; 0000$は対応する符号絶対値を持たない例外です**。変換方法によると、その符号絶対値は$0000 \; 0000$で、0を示します。これは矛盾を示しています。なぜなら、その2の補数は自分自身を表すべきだからです。コンピュータは、この特別な2の補数$1000 \; 0000$を$-128$を表すものとして指定しています。実際、2の補数での$(-1) + (-127)$の計算結果は$-128$になります。
|
||||
しかし、**2 の補数 $1000 \; 0000$ だけは例外で、対応する符号付き絶対値表現を持ちません**。変換規則に従うと、この 2 の補数に対応する符号付き絶対値表現は $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 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)$に変換でき、乗算と除算は複数の加算または減算に変換できます。
|
||||
ただし、これはコンピュータが加算しかできないという意味ではありません。**加算といくつかの基本的な論理演算を組み合わせることで、コンピュータはさまざまな数学演算を実現できます**。たとえば減算 $a - b$ は加算 $a + (-b)$ に変換できますし、乗算や除算も繰り返しの加算または減算に変換できます。
|
||||
|
||||
コンピュータで2の補数を使用する理由をまとめることができます:2の補数表現により、コンピュータは同じ回路と演算を使用して正と負の数の加算を処理でき、減算用の特別なハードウェア回路の必要性を排除し、正と負の0の曖昧さを回避できます。これによりハードウェア設計が大幅に簡素化され、計算効率が向上します。
|
||||
これで、コンピュータが 2 の補数を使う理由をまとめられます。2 の補数表現に基づけば、コンピュータは同じ回路と操作で正数と負数の加算を扱うことができ、減算専用の特別なハードウェア回路を設計する必要がなく、正負のゼロの曖昧さも特別に処理しなくて済みます。これにより、ハードウェア設計は大幅に簡略化され、演算効率も向上します。
|
||||
|
||||
2の補数の設計は非常に巧妙で、スペースの制約により、ここで停止します。興味のある読者はさらに探求することを奨励します。
|
||||
2 の補数の設計は非常に巧妙ですが、紙幅の都合上ここまでにします。興味のある読者は、さらに深く調べてみてください。
|
||||
|
||||
## 浮動小数点数エンコーディング
|
||||
## 浮動小数点数のエンコーディング
|
||||
|
||||
興味深いことに気づいたかもしれません:同じ4バイトの長さにもかかわらず、なぜ`float`は`int`と比較してはるかに大きい値の範囲を持つのでしょうか?これは直感に反するように見えます。`float`は分数を表現する必要があるため、範囲が縮小すると予想されるからです。
|
||||
注意深い人なら気づくかもしれません。`int` と `float` はどちらも長さが 4 バイトで同じなのに、なぜ `float` の値域は `int` よりはるかに広いのでしょうか。これはかなり直感に反します。というのも、`float` は小数を表す必要があるので、本来なら値域は狭くなるはずだからです。
|
||||
|
||||
実際、**これは浮動小数点数(`float`)で使用される異なる表現方法によるものです**。32ビットの二進数を次のように考えてみましょう:
|
||||
実際には、**これは浮動小数点数 `float` が異なる表現方法を採用しているためです**。32 ビット長の二進数を次のように表します。
|
||||
|
||||
$$
|
||||
b_{31} b_{30} b_{29} \ldots b_2 b_1 b_0
|
||||
$$
|
||||
|
||||
IEEE 754標準によると、32ビットの`float`は次の3つの部分で構成されます:
|
||||
IEEE 754 標準によれば、32-bit 長の `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$に対応します。
|
||||
- 符号部 $\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`数の値は次のように計算されます:
|
||||
二進数 `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)^{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})
|
||||
\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}]
|
||||
(1 + \mathrm{N}) = & (1 + \sum_{i=1}^{23} b_{23-i} 2^{-i}) \subset [1, 2 - 2^{-23}]
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||

|
||||

|
||||
|
||||
上の図を観察すると、例のデータ$\mathrm{S} = 0$、$\mathrm{E} = 124$、$\mathrm{N} = 2^{-2} + 2^{-3} = 0.375$が与えられた場合:
|
||||
上図を見ると、例として $\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
|
||||
\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` よりはるかに広い**のです。上の計算より、`float` が表せる最大の正数は $2^{254 - 127} \times (2 - 2^{-23}) \approx 3.4 \times 10^{38}$ であり、符号ビットを切り替えれば最小の負数が得られます。
|
||||
|
||||
**しかし、`float`の拡張された範囲のトレードオフは精度の犠牲です**。整数型`int`は32ビットすべてを数値表現に使用し、値は均等に分布していますが、指数ビットのため、`float`の値が大きいほど、隣接する数値間の差が大きくなります。
|
||||
**浮動小数点数 `float` は値域を広げる一方で、その代償として精度を犠牲にしています**。整数型 `int` は 32 ビットすべてを数値の表現に使うため、数値は一様に分布します。しかし指数部があるため、浮動小数点数 `float` は値が大きくなるほど、隣り合う 2 つの数の差も大きくなる傾向があります。
|
||||
|
||||
以下の表に示すように、指数ビット$\mathrm{E} = 0$と$\mathrm{E} = 255$は特別な意味を持ち、**0、無限大、$\mathrm{NaN}$などを表現するために使用されます**。
|
||||
次の表のとおり、指数部 $\mathrm{E} = 0$ と $\mathrm{E} = 255$ には特別な意味があり、**ゼロ、無限大、$\mathrm{NaN}$ などを表すために使われます**。
|
||||
|
||||
<p align="center"> 表 <id> 指数ビットの意味 </p>
|
||||
<p align="center"> 表 <id> 指数部の意味 </p>
|
||||
|
||||
| 指数ビットE | 仮数ビット$\mathrm{N} = 0$ | 仮数ビット$\mathrm{N} \ne 0$ | 計算公式 |
|
||||
| ------------------ | ----------------------------- | ------------------------------- | ---------------------------------------------------------------------- |
|
||||
| $0$ | $\pm 0$ | 非正規化数 | $(-1)^{\mathrm{S}} \times 2^{-126} \times (0.\mathrm{N})$ |
|
||||
| $1, 2, \dots, 254$ | 正規化数 | 正規化数 | $(-1)^{\mathrm{S}} \times 2^{(\mathrm{E} -127)} \times (1.\mathrm{N})$ |
|
||||
| $255$ | $\pm \infty$ | $\mathrm{NaN}$ | |
|
||||
| 指数部 E | 仮数部 $\mathrm{N} = 0$ | 仮数部 $\mathrm{N} \ne 0$ | 計算式 |
|
||||
| ------------------ | ----------------------- | ------------------------- | ---------------------------------------------------------------------- |
|
||||
| $0$ | $\pm 0$ | 非正規化数 | $(-1)^{\mathrm{S}} \times 2^{-126} \times (0.\mathrm{N})$ |
|
||||
| $1, 2, \dots, 254$ | 正規化数 | 正規化数 | $(-1)^{\mathrm{S}} \times 2^{(\mathrm{E} -127)} \times (1.\mathrm{N})$ |
|
||||
| $255$ | $\pm \infty$ | $\mathrm{NaN}$ | |
|
||||
|
||||
非正規化数は浮動小数点数の精度を大幅に向上させることは注目に値します。最小の正の正規化数は$2^{-126}$で、最小の正の非正規化数は$2^{-126} \times 2^{-23}$です。
|
||||
なお、非正規化数によって浮動小数点数の精度は大きく向上します。最小の正の正規化数は $2^{-126}$ であり、最小の正の非正規化数は $2^{-126} \times 2^{-23}$ です。
|
||||
|
||||
倍精度`double`も`float`と同様の表現方法を使用しますが、簡潔さのためここでは詳述しません。
|
||||
倍精度 `double` も `float` と同様の表現方法を採用しているため、ここでは詳述しません。
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
# まとめ
|
||||
|
||||
### 重要なポイント
|
||||
### 重要ポイントの振り返り
|
||||
|
||||
- データ構造は論理構造と物理構造の2つの観点から分類できます。論理構造はデータ間の論理的関係を記述し、物理構造はデータがメモリにどのように格納されるかを記述します。
|
||||
- よく使用される論理構造には、線形構造、木、ネットワークがあります。通常、論理構造に基づいてデータ構造を線形(配列、連結リスト、スタック、キュー)と非線形(木、グラフ、ヒープ)に分けます。ハッシュ表の実装は線形と非線形の両方のデータ構造を含む場合があります。
|
||||
- プログラムが実行中の際、データはメモリに格納されます。各メモリ空間には対応するアドレスがあり、プログラムはこれらのアドレスを通じてデータにアクセスします。
|
||||
- 物理構造は連続空間格納(配列)と離散空間格納(連結リスト)に分けることができます。すべてのデータ構造は配列、連結リスト、またはその両方の組み合わせを使用して実装されます。
|
||||
- コンピュータの基本データ型には、整数(`byte`、`short`、`int`、`long`)、浮動小数点数(`float`、`double`)、文字(`char`)、ブール値(`bool`)が含まれます。データ型の値の範囲は、そのサイズと表現に依存します。
|
||||
- 符号絶対値、1の補数、2の補数は、コンピュータで整数をエンコードする3つの方法であり、相互に変換することができます。符号絶対値の最上位ビットは符号ビットで、残りのビットは数値の値を表します。
|
||||
- 整数はコンピュータで2の補数によってエンコードされます。この表現の利点には、(i)コンピュータが正と負の整数の加算を統一できる、(ii)減算用の特別なハードウェア回路を設計する必要がない、(iii)正と負の0の曖昧さがない、があります。
|
||||
- 浮動小数点数のエンコーディングは、1つの符号ビット、8つの指数ビット、23の仮数ビットで構成されます。指数ビットのため、浮動小数点数の範囲は整数よりもはるかに大きくなりますが、精度を犠牲にします。
|
||||
- ASCIIは最初期の英語文字セットで、1バイトの長さで計127文字です。GBKは人気のある中国語文字セットで、2万文字以上の中国語文字を含みます。Unicodeは世界の様々な言語の文字を含む完全な文字セット標準を提供することを目的とし、文字エンコーディング方法の不一致による文字化け問題を解決します。
|
||||
- UTF-8は最も人気があり一般的なUnicodeエンコーディング方法です。これは可変長エンコーディング方法で、優れた拡張性と空間効率を持ちます。UTF-16とUTF-32は固定長エンコーディング方法です。中国語文字をエンコードする際、UTF-16はUTF-8よりも少ない空間を使用します。JavaやC#などのプログラミング言語はデフォルトでUTF-16エンコーディングを使用します。
|
||||
- データ構造は、論理構造と物理構造という 2 つの観点から分類できます。論理構造はデータ要素間の論理的関係を記述し、物理構造はデータのコンピュータメモリ上での格納方法を記述します。
|
||||
- 代表的な論理構造には、線形、木構造、網状構造などがあります。通常、論理構造に基づいてデータ構造を線形(配列、連結リスト、スタック、キュー)と非線形(木、グラフ、ヒープ)の 2 種類に分類します。ハッシュテーブルの実装には、線形データ構造と非線形データ構造が同時に含まれる場合があります。
|
||||
- プログラムの実行時、データはコンピュータメモリに格納されます。各メモリ空間には対応するメモリアドレスがあり、プログラムはそれらのメモリアドレスを通じてデータにアクセスします。
|
||||
- 物理構造は主に連続領域への格納(配列)と分散領域への格納(連結リスト)に分けられます。すべてのデータ構造は、配列、連結リスト、またはその両方の組み合わせによって実装されます。
|
||||
- コンピュータにおける基本データ型には、整数 `byte`、`short`、`int`、`long`、浮動小数点数 `float`、`double`、文字 `char`、真偽値 `bool` があります。これらの値域は、使用する記憶領域の大きさと表現方式によって決まります。
|
||||
- 符号付き絶対値表現、1 の補数、2 の補数は、コンピュータで数値を符号化する 3 つの方法であり、相互に変換できます。整数の符号付き絶対値表現では最上位ビットが符号ビットで、残りのビットが数値の値です。
|
||||
- 整数はコンピュータ内では 2 の補数の形式で格納されます。2 の補数表現では、コンピュータは正数と負数の加算を同じように扱うことができ、減算のために特別なハードウェア回路を別途設計する必要がなく、さらに正負のゼロが重複する問題もありません。
|
||||
- 浮動小数点数の符号化は、1 ビットの符号部、8 ビットの指数部、23 ビットの仮数部で構成されます。指数部があるため、浮動小数点数の値域は整数よりはるかに広くなりますが、その代償として精度が犠牲になります。
|
||||
- ASCII コードは最も早く登場した英字文字集合で、長さは 1 バイト、収録文字数は 127 です。GBK 文字集合はよく使われる中国語文字集合で、2 万字以上の漢字を収録しています。Unicode は完全な文字集合標準を提供することを目指しており、世界中のさまざまな言語の文字を収録することで、文字コード方式の不一致によって生じる文字化けの問題を解決します。
|
||||
- UTF-8 は最も広く使われている Unicode の符号化方式で、汎用性が非常に高いです。可変長の符号化方式であり、拡張性に優れ、記憶領域の利用効率を効果的に高めます。UTF-16 と UTF-32 は固定長の符号化方式です。中国語を符号化する場合、UTF-16 は UTF-8 よりも使用領域が小さくなります。Java や C# などのプログラミング言語は、デフォルトで UTF-16 を使用します。
|
||||
|
||||
### Q & A
|
||||
|
||||
**Q**: なぜハッシュ表は線形と非線形の両方のデータ構造を含むのですか?
|
||||
**Q**:なぜハッシュテーブルには線形データ構造と非線形データ構造が同時に含まれるのですか?
|
||||
|
||||
ハッシュ表の基礎構造は配列です。ハッシュ衝突を解決するために、「チェイン法」を使用する場合があります(後の節「ハッシュ衝突」で説明):配列の各バケットは連結リストを指し、その長さが特定の閾値より大きくなると木(通常は赤黒木)に変換される可能性があります。
|
||||
格納の観点から、ハッシュ表の基礎構造は配列で、各バケットには値、連結リスト、または木が含まれる場合があります。したがって、ハッシュ表は線形データ構造(配列、連結リスト)と非線形データ構造(木)の両方を含む場合があります。
|
||||
ハッシュテーブルの基盤は配列であり、ハッシュ衝突を解決するために「チェイン法」(後続の「ハッシュ衝突」の章で説明します)を使うことがあります。配列内の各バケットは 1 つの連結リストを指し、その連結リストの長さがある閾値を超えると、木(通常は赤黒木)に変換されることもあります。
|
||||
|
||||
**Q**: `char`型の長さは1バイトですか?
|
||||
格納の観点から見ると、ハッシュテーブルの基盤は配列であり、各バケットスロットには値が入ることもあれば、連結リストや木が入ることもあります。したがって、ハッシュテーブルには線形データ構造(配列、連結リスト)と非線形データ構造(木)が同時に含まれる場合があります。
|
||||
|
||||
`char`型の長さは、プログラミング言語のエンコーディング方法によって決まります。例えば、Java、JavaScript、TypeScript、C#はすべてUTF-16エンコーディング(Unicodeコードポイントを保存するため)を使用するため、`char`型の長さは2バイトです。
|
||||
**Q**:`char` 型の長さは 1 バイトですか?
|
||||
|
||||
**Q**: 配列ベースのデータ構造を「静的データ構造」と呼ぶことに曖昧さはありませんか?スタックもプッシュやポップなどの「動的」操作を実行できます。
|
||||
`char` 型の長さは、プログラミング言語が採用する符号化方式によって決まります。たとえば、Java、JavaScript、TypeScript、C# はいずれも UTF-16 符号化(Unicode コードポイントを保持)を採用しているため、`char` 型の長さは 2 バイトです。
|
||||
|
||||
スタックは動的なデータ操作を実装できますが、データ構造は依然として「静的」です(長さが固定)。配列ベースのデータ構造は動的に要素を追加または削除できますが、その容量は固定されています。スタックサイズが事前に割り当てられたサイズを超える場合、古い配列は新しく作成されたより大きな配列にコピーされます。
|
||||
**Q**:配列ベースで実装されたデータ構造を「静的データ構造」と呼ぶのは曖昧ではありませんか? スタックも push や pop などの操作ができ、これらの操作はどれも「動的」です。
|
||||
|
||||
**Q**: スタック(キュー)を構築する際、そのサイズが指定されていないのに、なぜ「静的データ構造」なのですか?
|
||||
スタックは確かに動的なデータ操作を実現できますが、データ構造自体は依然として「静的」(長さが不変)です。配列ベースのデータ構造でも要素を動的に追加または削除できますが、その容量は固定です。データ量が事前に確保した大きさを超えた場合は、より大きな新しい配列を作成し、古い配列の内容を新しい配列にコピーする必要があります。
|
||||
|
||||
高級プログラミング言語では、スタック(キュー)の初期容量を手動で指定する必要はありません。このタスクはクラス内で自動的に完了されます。例えば、Javaの`ArrayList`の初期容量は通常10です。さらに、拡張操作も自動的に完了されます。詳細については、後続の「リスト」の章を参照してください。
|
||||
**Q**:スタック(キュー)を構築するときにサイズを指定していないのに、なぜそれらは「静的データ構造」なのですか?
|
||||
|
||||
**Q**: 符号絶対値を2の補数に変換する方法は「最初に否定してから1を加える」ですので、2の補数を符号絶対値に変換することはその逆操作「最初に1を減算してから否定する」であるべきです。
|
||||
しかし、2の補数も「最初に否定してから1を加える」を通じて符号絶対値に変換できます。なぜですか?
|
||||
高水準プログラミング言語では、スタック(キュー)の初期容量を人手で指定する必要はなく、この作業はクラス内部で自動的に行われます。たとえば、Java の `ArrayList` の初期容量は通常 10 です。また、容量拡張も自動的に実装されています。詳しくは後続の「リスト」の章を参照してください。
|
||||
|
||||
**A**: これは、符号絶対値と2の補数間の相互変換が「補数」の計算と等価だからです。まず補数を定義します:$a + b = c$と仮定すると、$a$は$b$の$c$に対する補数と言い、逆に$b$は$a$の$c$に対する補数と言います。
|
||||
**Q**:符号付き絶対値表現から 2 の補数への変換方法は「先にビット反転してから 1 を加える」ですが、2 の補数から符号付き絶対値表現への変換は逆演算である「先に 1 を引いてからビット反転する」べきなのに、同じく「先にビット反転してから 1 を加える」でも求められます。これはなぜですか?
|
||||
|
||||
長さ$n = 4$の二進数$0010$が与えられた場合、この数が符号絶対値(符号ビットを無視)の場合、その2の補数は「最初に否定してから1を加える」ことで得られます:
|
||||
これは、符号付き絶対値表現と 2 の補数の相互変換が、実際には「補数」を計算する過程だからです。まず補数の定義を示します。$a + b = c$ とすると、$a$ を $b$ から $c$ への補数と呼び、逆に $b$ も $a$ から $c$ への補数と呼びます。
|
||||
|
||||
長さ $n = 4$ ビットの 2 進数 $0010$ が与えられたとします。この数を符号付き絶対値表現(符号ビットは考慮しない)とみなすと、その 2 の補数は「先にビット反転してから 1 を加える」ことで得られます。
|
||||
|
||||
$$
|
||||
0010 \rightarrow 1101 \rightarrow 1110
|
||||
$$
|
||||
|
||||
符号絶対値と2の補数の和が$0010 + 1110 = 10000$であることを観察します。つまり、2の補数$1110$は符号絶対値$0010$の$10000$に対する「補数」です。**これは、上記の「最初に否定してから1を加える」が$10000$に対する補数の計算と等価であることを意味します**。
|
||||
ここで、符号付き絶対値表現と 2 の補数の和は $0010 + 1110 = 10000$ となります。つまり、2 の補数 $1110$ は符号付き絶対値表現 $0010$ から $10000$ への「補数」です。**これは、上記の「先にビット反転してから 1 を加える」が、実際には $10000$ への補数を計算する過程であることを意味します。**
|
||||
|
||||
では、$1110$の$10000$に対する「補数」は何でしょうか?「最初に否定してから1を加える」ことで計算できます:
|
||||
では、2 の補数 $1110$ から $10000$ への「補数」はいくつでしょうか。これもやはり「先にビット反転してから 1 を加える」ことで求められます。
|
||||
|
||||
$$
|
||||
1110 \rightarrow 0001 \rightarrow 0010
|
||||
$$
|
||||
|
||||
言い換えると、符号絶対値と2の補数は互いに$10000$に対する「補数」であるため、「符号絶対値から2の補数」と「2の補数から符号絶対値」は同じ操作(最初に否定してから1を加える)で実装できます。
|
||||
言い換えると、符号付き絶対値表現と 2 の補数は互いに相手から $10000$ への「補数」なので、「符号付き絶対値表現から 2 の補数への変換」と「2 の補数から符号付き絶対値表現への変換」は同じ操作(先にビット反転してから 1 を加える)で実現できます。
|
||||
|
||||
もちろん、「最初に否定してから1を加える」の逆操作を使用して2の補数$1110$の符号絶対値を求めることもできます。つまり、「最初に1を減算してから否定する」:
|
||||
もちろん、逆演算を用いて 2 の補数 $1110$ の符号付き絶対値表現を求めることもでき、その場合は「先に 1 を引いてからビット反転する」ことになります。
|
||||
|
||||
$$
|
||||
1110 \rightarrow 1101 \rightarrow 0010
|
||||
$$
|
||||
|
||||
要約すると、「最初に否定してから1を加える」と「最初に1を減算してから否定する」は両方とも$10000$に対する補数を計算しており、等価です。
|
||||
まとめると、「先にビット反転してから 1 を加える」と「先に 1 を引いてからビット反転する」の 2 つの演算は、どちらも $10000$ への補数を計算しており、等価です。
|
||||
|
||||
本質的に、「否定」操作は実際には$1111$に対する補数を求めることです(`符号絶対値 + 1の補数 = 1111`が常に成り立つため)。そして1の補数に1を加えることは$10000$に対する2の補数と等しくなります。
|
||||
本質的には、「ビット反転」という操作は実際には $1111$ への補数を求めています(常に `符号付き絶対値表現 + 1 の補数 = 1111` が成り立つため)。そして、1 の補数にさらに 1 を加えて得られる 2 の補数が、$10000$ への補数です。
|
||||
|
||||
上記では$n = 4$を例に取りましたが、任意の桁数の任意の二進数に一般化できます。
|
||||
上記では $n = 4$ を例にしましたが、この考え方は任意のビット長の 2 進数に一般化できます。
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
# 分割統治検索戦略
|
||||
# 分割統治探索戦略
|
||||
|
||||
私たちは検索アルゴリズムが主に2つのカテゴリに分類されることを学びました。
|
||||
私たちはすでに学んだように、探索アルゴリズムは大きく二つに分けられる。
|
||||
|
||||
- **総当たり検索**:データ構造を走査することで実装され、時間計算量は $O(n)$ です。
|
||||
- **適応検索**:独特なデータ組織形式や事前情報を利用し、時間計算量は $O(\log n)$ または $O(1)$ に達することができます。
|
||||
- **力ずく探索**:データ構造を走査することで実現され、時間計算量は $O(n)$ である。
|
||||
- **適応的探索**:固有のデータ構造や事前情報を利用し、時間計算量は $O(\log n)$ 、さらには $O(1)$ に達しうる。
|
||||
|
||||
実際、**時間計算量が $O(\log n)$ の検索アルゴリズムは通常分割統治戦略に基づいています**。例えば、二分探索や木などです。
|
||||
実際、**時間計算量が $O(\log n)$ の探索アルゴリズムは通常、分割統治戦略に基づいて実装される**。たとえば二分探索や木構造である。
|
||||
|
||||
- 二分探索の各ステップは、問題(配列内でターゲット要素を検索する)をより小さな問題(配列の半分でターゲット要素を検索する)に分割し、配列が空になるかターゲット要素が見つかるまで続けます。
|
||||
- 木は分割統治のアイデアを表現し、二分探索木、AVL木、ヒープなどのデータ構造では、様々な操作の時間計算量は $O(\log n)$ です。
|
||||
- 二分探索の各ステップでは、問題(配列内で目標要素を探索すること)を小さな問題(配列の半分で目標要素を探索すること)に分解し、この過程は配列が空になるか目標要素が見つかるまで続く。
|
||||
- 木構造は分割統治の考え方を代表するものであり、二分探索木、AVL 木、ヒープなどのデータ構造では、さまざまな操作の時間計算量はいずれも $O(\log n)$ である。
|
||||
|
||||
二分探索の分割統治戦略は以下の通りです。
|
||||
二分探索の分割統治戦略は以下のとおりである。
|
||||
|
||||
- **問題を分割できる**:二分探索は元の問題(配列内での検索)を部分問題(配列の半分での検索)に再帰的に分割し、中間要素とターゲット要素を比較することで実現されます。
|
||||
- **部分問題は独立している**:二分探索では、各ラウンドで一つの部分問題を処理し、他の部分問題に影響されません。
|
||||
- **部分問題の解をマージする必要がない**:二分探索は特定の要素を見つけることを目的としているため、部分問題の解をマージする必要がありません。部分問題が解決されると、元の問題も解決されます。
|
||||
- **問題は分解できる**:二分探索は、元の問題(配列内で探索すること)を部分問題(配列の半分で探索すること)へ再帰的に分解する。これは中央要素と目標要素を比較することで実現される。
|
||||
- **部分問題は独立している**:二分探索では、各ラウンドで一つの部分問題だけを処理し、ほかの部分問題の影響を受けない。
|
||||
- **部分問題の解を統合する必要はない**:二分探索は特定の要素を探すことを目的としているため、部分問題の解を統合する必要がない。部分問題が解決されると、元の問題も同時に解決される。
|
||||
|
||||
分割統治は検索効率を向上させることができます。なぜなら、総当たり検索はラウンドごとに1つの選択肢しか除去できませんが、**分割統治は選択肢の半分を除去できるからです**。
|
||||
分割統治が探索効率を高められる本質的な理由は、力ずく探索では各ラウンドで一つの候補しか除外できないのに対し、**分割統治による探索では各ラウンドで候補の半分を除外できる**からである。
|
||||
|
||||
### 分割統治に基づく二分探索の実装
|
||||
### 分割統治に基づく二分探索
|
||||
|
||||
前の章では、二分探索は反復に基づいて実装されました。今度は、分割統治(再帰)に基づいて実装します。
|
||||
前の章では、二分探索を漸化式(反復)に基づいて実装した。ここでは分割統治(再帰)に基づいてこれを実装する。
|
||||
|
||||
!!! question
|
||||
|
||||
長さ $n$ の順序付けられた配列 `nums` が与えられ、すべての要素が一意である場合、要素 `target` を見つけてください。
|
||||
長さ $n$ の昇順配列 `nums` が与えられ、そのすべての要素は一意である。要素 `target` を探索せよ。
|
||||
|
||||
分割統治の観点から、検索区間 $[i, j]$ に対応する部分問題を $f(i, j)$ と表します。
|
||||
分割統治の観点から、探索区間 $[i, j]$ に対応する部分問題を $f(i, j)$ と記す。
|
||||
|
||||
元の問題 $f(0, n-1)$ から開始して、以下のステップで二分探索を実行します。
|
||||
元の問題 $f(0, n-1)$ を出発点として、次の手順で二分探索を行う。
|
||||
|
||||
1. 検索区間 $[i, j]$ の中点 $m$ を計算し、それを使用して検索区間の半分を除去します。
|
||||
2. 半分のサイズに縮小された部分問題を再帰的に解決します。これは $f(i, m-1)$ または $f(m+1, j)$ になる可能性があります。
|
||||
3. `target` が見つかるか区間が空になってリターンするまで、ステップ `1.` と `2.` を繰り返します。
|
||||
1. 探索区間 $[i, j]$ の中点 $m$ を計算し、それに基づいて探索区間の半分を除外する。
|
||||
2. 規模が半分に縮小された部分問題を再帰的に解く。候補は $f(i, m-1)$ または $f(m+1, j)$ である。
|
||||
3. `1.` と `2.` の手順を繰り返し、`target` が見つかるか区間が空になったら返す。
|
||||
|
||||
以下の図は、配列内で要素 $6$ を探す二分探索の分割統治過程を示しています。
|
||||
次の図は、配列内で要素 $6$ を二分探索する分割統治の過程を示している。
|
||||
|
||||

|
||||

|
||||
|
||||
実装コードでは、問題 $f(i, j)$ を解決するために再帰関数 `dfs()` を宣言します:
|
||||
実装コードでは、再帰関数 `dfs()` を宣言して問題 $f(i, j)$ を解く。
|
||||
|
||||
```src
|
||||
[file]{binary_search_recur}-[class]{}-[func]{binary_search}
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
# 二分木構築問題
|
||||
# 二分木の構築問題
|
||||
|
||||
!!! question
|
||||
|
||||
二分木の前順走査 `preorder` シーケンスと中順走査 `inorder` シーケンスが与えられた場合、二分木を構築してそのルートノードを返してください。二分木に重複するノード値がないと仮定します(以下の図に示すように)。
|
||||
二分木の前順走査 `preorder` と中順走査 `inorder` が与えられたとき、これらから二分木を構築し、その根ノードを返してください。二分木には値が重複するノードが存在しないものとします(下図のとおり)。
|
||||
|
||||

|
||||

|
||||
|
||||
### 分割統治問題かどうかの判定
|
||||
### 分割統治問題かどうかを判断する
|
||||
|
||||
`preorder` と `inorder` シーケンスから二分木を構築する元の問題は、典型的な分割統治問題です。
|
||||
元の問題は `preorder` と `inorder` から二分木を構築することであり、典型的な分割統治問題です。
|
||||
|
||||
- **問題を分解できる**:分割統治の観点から、元の問題を2つの部分問題(左の部分木の構築と右の部分木の構築)とルートノードの初期化という1つの操作に分割できます。各部分木(部分問題)について、同じアプローチを継続的に適用し、より小さな部分木(部分問題)に分割し、最小の部分問題(空の部分木)に到達するまで続けます。
|
||||
- **部分問題は独立している**:左と右の部分木は重複しません。左の部分木を構築する際、左の部分木に対応する中順走査と前順走査のセグメントのみが必要です。右の部分木にも同じアプローチが適用されます。
|
||||
- **部分問題の解を組み合わせることができる**:左と右の部分木(部分問題の解)を構築したら、それらをルートノードに接続して元の問題の解を取得できます。
|
||||
- **問題は分解できる**:分割統治の観点から見ると、元の問題は 2 つの部分問題、すなわち左部分木の構築と右部分木の構築に分けられ、さらに根ノードを初期化する 1 ステップが加わります。各部分木(部分問題)に対しても、同じ分割方法を再利用してより小さな部分木(部分問題)へと分けていき、最小の部分問題(空部分木)に達した時点で終了します。
|
||||
- **部分問題は独立している**:左部分木と右部分木は互いに独立しており、両者の間に重なりはありません。左部分木を構築するときは、中順走査と前順走査のうち左部分木に対応する部分だけを見れば十分です。右部分木も同様です。
|
||||
- **部分問題の解は統合できる**:左部分木と右部分木(部分問題の解)が得られたら、それらを根ノードに接続することで元の問題の解を得られます。
|
||||
|
||||
### 部分木の分割方法
|
||||
### 部分木をどのように分割するか
|
||||
|
||||
上記の分析に基づいて、この問題は分割統治を使用して解決できます。**しかし、前順走査 `preorder` シーケンスと中順走査 `inorder` シーケンスを使用して左と右の部分木をどのように分割すればよいでしょうか?**
|
||||
以上の分析より、この問題は分割統治で解けます。**では、前順走査 `preorder` と中順走査 `inorder` を使って左部分木と右部分木をどのように分割すればよいのでしょうか**?
|
||||
|
||||
定義により、`preorder` と `inorder` シーケンスの両方を3つの部分に分割できます:
|
||||
定義に従うと、`preorder` と `inorder` はいずれも 3 つの部分に分けられます。
|
||||
|
||||
- 前順走査:`[ ルート | 左の部分木 | 右の部分木 ]`。例えば、図では、木は `[ 3 | 9 | 2 1 7 ]` に対応します。
|
||||
- 中順走査:`[ 左の部分木 | ルート | 右の部分木 ]`。例えば、図では、木は `[ 9 | 3 | 1 2 7 ]` に対応します。
|
||||
- 前順走査:`[ 根ノード | 左部分木 | 右部分木 ]` ,例えば上図の木は `[ 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 ]` に対応して分割できます。
|
||||
1. 前順走査の先頭要素 3 が根ノードの値です。
|
||||
2. 根ノード 3 の `inorder` におけるインデックスを探すと、そのインデックスを用いて `inorder` を `[ 9 | 3 | 1 2 7 ]` に分割できます。
|
||||
3. `inorder` の分割結果から、左部分木と右部分木のノード数はそれぞれ 1 と 3 であることがわかり、したがって `preorder` を `[ 3 | 9 | 2 1 7 ]` に分割できます。
|
||||
|
||||

|
||||

|
||||
|
||||
### 変数に基づく部分木範囲の記述
|
||||
### 変数を用いて部分木区間を記述する
|
||||
|
||||
上記の分割方法に基づいて、**`preorder` と `inorder` シーケンスにおけるルート、左の部分木、右の部分木のインデックス範囲を取得しました**。これらのインデックス範囲を記述するために、いくつかのポインタ変数を使用します。
|
||||
以上の分割方法により、**根ノード、左部分木、右部分木が `preorder` と `inorder` の中で占めるインデックス区間**が得られました。これらのインデックス区間を表すために、いくつかのポインタ変数を導入します。
|
||||
|
||||
- 現在の木のルートノードの `preorder` シーケンスでのインデックスを $i$ とします。
|
||||
- 現在の木のルートノードの `inorder` シーケンスでのインデックスを $m$ とします。
|
||||
- 現在の木の `inorder` シーケンスでのインデックス範囲を $[l, r]$ とします。
|
||||
- 現在の木の根ノードが `preorder` に現れるインデックスを $i$ とします。
|
||||
- 現在の木の根ノードが `inorder` に現れるインデックスを $m$ とします。
|
||||
- 現在の木が `inorder` において占めるインデックス区間を $[l, r]$ とします。
|
||||
|
||||
以下の表に示すように、これらの変数は `preorder` シーケンスでのルートノードのインデックスと `inorder` シーケンスでの部分木のインデックス範囲を表します。
|
||||
次の表のように、これらの変数を用いれば根ノードの `preorder` におけるインデックスと、部分木の `inorder` におけるインデックス区間を表せます。
|
||||
|
||||
<p align="center"> 表 <id> 前順走査と中順走査でのルートノードと部分木のインデックス </p>
|
||||
<p align="center"> 表 <id> 根ノードと部分木の前順走査・中順走査におけるインデックス </p>
|
||||
|
||||
| | `preorder` でのルートノードインデックス | `inorder` での部分木インデックス範囲 |
|
||||
| ------------- | ------------------------------------- | ----------------------------------- |
|
||||
| 現在の木 | $i$ | $[l, r]$ |
|
||||
| 左の部分木 | $i + 1$ | $[l, m-1]$ |
|
||||
| 右の部分木 | $i + 1 + (m - l)$ | $[m+1, r]$ |
|
||||
| | 根ノードの `preorder` におけるインデックス | 部分木の `inorder` におけるインデックス区間 |
|
||||
| ------ | ---------------------------- | ----------------------------- |
|
||||
| 現在の木 | $i$ | $[l, r]$ |
|
||||
| 左部分木 | $i + 1$ | $[l, m-1]$ |
|
||||
| 右部分木 | $i + 1 + (m - l)$ | $[m+1, r]$ |
|
||||
|
||||
右の部分木のルートインデックスの $(m-l)$ は「左の部分木のノード数」を表すことに注意してください。より明確な理解のために、以下の図を参照することが役立つ場合があります。
|
||||
右部分木の根ノードのインデックスにある $(m-l)$ は「左部分木のノード数」を意味します。下図と合わせて理解することを勧めます。
|
||||
|
||||

|
||||

|
||||
|
||||
### コード実装
|
||||
### コードの実装
|
||||
|
||||
$m$ の問い合わせの効率を向上させるために、ハッシュテーブル `hmap` を使用して `inorder` シーケンスの要素からそのインデックスへのマッピングを格納します:
|
||||
$m$ の検索効率を高めるために、ハッシュテーブル `hmap` を用いて配列 `inorder` の要素からインデックスへの対応を保存します。
|
||||
|
||||
```src
|
||||
[file]{build_tree}-[class]{}-[func]{build_tree}
|
||||
```
|
||||
|
||||
以下の図は、二分木を構築する再帰過程を示しています。各ノードは再帰の「下降」段階で作成され、各エッジ(参照)は「上昇」段階で形成されます。
|
||||
下図は二分木を構築する再帰過程を示しています。各ノードは下向きに「再帰していく」過程で生成され、各辺(参照)は上向きに「戻る」過程で張られます。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -90,10 +90,10 @@ $m$ の問い合わせの効率を向上させるために、ハッシュテー
|
||||
=== "<9>"
|
||||

|
||||
|
||||
各再帰関数の `preorder` と `inorder` シーケンスの分割は以下の図に示されています。
|
||||
各再帰関数における前順走査 `preorder` と中順走査 `inorder` の分割結果を下図に示します。
|
||||
|
||||

|
||||

|
||||
|
||||
二分木が $n$ 個のノードを持つと仮定すると、各ノードの初期化(再帰関数 `dfs()` の呼び出し)には $O(1)$ 時間がかかります。**したがって、全体の時間計算量は $O(n)$ です**。
|
||||
木のノード数を $n$ とすると、各ノードの初期化(再帰関数 `dfs()` の 1 回の実行)には $O(1)$ 時間かかります。**したがって、全体の時間計算量は $O(n)$** です。
|
||||
|
||||
ハッシュテーブルは `inorder` 要素からそのインデックスへのマッピングを格納するため、$O(n)$ スペースが必要です。最悪の場合、二分木が連結リストに退化すると、再帰の深さは $n$ に達し、$O(n)$ のスタックスペースを消費する可能性があります。**したがって、全体の空間計算量は $O(n)$ です**。
|
||||
ハッシュテーブルには `inorder` の要素からインデックスへの対応を保存するため、空間計算量は $O(n)$ です。最悪の場合、すなわち二分木が連結リストに退化すると、再帰の深さは $n$ に達し、$O(n)$ のスタックフレーム空間を使用します。**したがって、全体の空間計算量は $O(n)$** です。
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
# 分割統治アルゴリズム
|
||||
# 分割統治法
|
||||
|
||||
<u>分割統治</u>は重要で人気のあるアルゴリズム戦略です。名前が示すように、アルゴリズムは通常再帰的に実装され、「分割」と「統治」の2つのステップから構成されます。
|
||||
<u>分割統治法(divide and conquer)</u>は、問題を分けて統べるという意味であり、非常に重要で一般的なアルゴリズム戦略です。分割統治法は通常、再帰に基づいて実装され、「分」と「治」の 2 つのステップから構成されます。
|
||||
|
||||
1. **分割(分割段階)**:元の問題を再帰的に2つ以上の小さな部分問題に分解し、最小の部分問題に到達するまで続けます。
|
||||
2. **統治(マージ段階)**:解決方法が既知の最小の部分問題から開始し、部分問題の解をボトムアップ方式でマージして元の問題の解を構築します。
|
||||
1. **分(分割段階)**:元の問題を 2 つ以上の部分問題へ再帰的に分解し、最小の部分問題に到達した時点で停止します。
|
||||
2. **治(統合段階)**:解が既知である最小の部分問題から始めて、部分問題の解を下から上へ統合し、元の問題の解を構築します。
|
||||
|
||||
以下の図に示すように、「マージソート」は分割統治戦略の典型的な応用の一つです。
|
||||
|
||||
1. **分割**:元の配列(元の問題)を再帰的に2つの副配列(部分問題)に分割し、副配列が1つの要素のみになるまで(最小の部分問題)続けます。
|
||||
2. **統治**:順序付けられた副配列(部分問題の解)をボトムアップでマージして、順序付けられた元の配列(元の問題の解)を取得します。
|
||||
1. **分**:元の配列(元の問題)を 2 つの部分配列(部分問題)へ再帰的に分割し、部分配列に要素が 1 つだけ残るまで続けます。
|
||||
2. **治**:整列済みの部分配列(部分問題の解)を下から上へ統合し、整列済みの元の配列(元の問題の解)を得ます。
|
||||
|
||||

|
||||
|
||||
## 分割統治問題を特定する方法
|
||||
## 分割統治法の問題を見極めるには
|
||||
|
||||
問題が分割統治解決に適しているかどうかは、通常以下の基準に基づいて決定できます。
|
||||
ある問題が分割統治法で解くのに適しているかどうかは、通常、次の判断基準を参考にできます。
|
||||
|
||||
1. **問題をより小さなものに分解できる**:元の問題をより小さく類似した部分問題に分割でき、そのような過程を同じ方法で再帰的に実行できます。
|
||||
2. **部分問題は独立している**:部分問題間に重複がなく、独立しており、個別に解決できます。
|
||||
3. **部分問題の解をマージできる**:元の問題の解は、部分問題の解を組み合わせることで導出されます。
|
||||
1. **問題は分解できる**:元の問題は、より小さく類似した部分問題に分解でき、同じ方法で再帰的に分割できます。
|
||||
2. **部分問題は独立している**:部分問題同士に重複がなく、相互依存もないため、独立して解決できます。
|
||||
3. **部分問題の解は統合できる**:元の問題の解は、部分問題の解を統合することで得られます。
|
||||
|
||||
明らかに、マージソートはこれら3つの基準を満たしています。
|
||||
明らかに、マージソートは以上の 3 つの判断基準を満たしています。
|
||||
|
||||
1. **問題をより小さなものに分解できる**:配列(元の問題)を再帰的に2つの副配列(部分問題)に分割します。
|
||||
2. **部分問題は独立している**:各副配列は独立してソートできます(部分問題は独立して解決できます)。
|
||||
3. **部分問題の解をマージできる**:2つの順序付けられた副配列(部分問題の解)を1つの順序付けられた配列(元の問題の解)にマージできます。
|
||||
1. **問題は分解できる**:配列(元の問題)を 2 つの部分配列(部分問題)へ再帰的に分割します。
|
||||
2. **部分問題は独立している**:各部分配列は独立にソートできます(部分問題は独立に解けます)。
|
||||
3. **部分問題の解は統合できる**:2 つの整列済み部分配列(部分問題の解)は、1 つの整列済み配列(元の問題の解)に統合できます。
|
||||
|
||||
## 分割統治による効率の向上
|
||||
## 分割統治法で効率を高める
|
||||
|
||||
**分割統治戦略はアルゴリズム問題を効果的に解決するだけでなく、しばしば効率を向上させます**。ソートアルゴリズムでは、クイックソート、マージソート、ヒープソートは、分割統治戦略を適用しているため、選択ソート、バブルソート、挿入ソートよりも高速です。
|
||||
**分割統治法はアルゴリズムの問題を効果的に解けるだけでなく、多くの場合アルゴリズムの効率も高められます**。ソートアルゴリズムでは、クイックソート、マージソート、ヒープソートが選択ソート、バブルソート、挿入ソートより高速ですが、これは分割統治戦略を適用しているためです。
|
||||
|
||||
私たちの心には疑問があるかもしれません:**なぜ分割統治はアルゴリズムの効率を向上させることができ、その根本的な論理は何ですか?** つまり、問題を部分問題に分解し、それらを解決し、それらの解を組み合わせて元の問題に対処することが、元の問題を直接解決するよりも効率的である理由は何ですか?この質問は2つの側面から分析できます:操作数と並列計算。
|
||||
ここで次の疑問が生じます。**なぜ分割統治法はアルゴリズム効率を高められるのでしょうか。その根本的な仕組みは何でしょうか**?言い換えると、大きな問題を複数の部分問題に分解し、部分問題を解き、それらの解を統合して元の問題の解にするという手順は、なぜ元の問題を直接解くより効率的なのでしょうか。この問題は、操作回数と並列計算の 2 つの観点から議論できます。
|
||||
|
||||
### 操作数の最適化
|
||||
### 操作回数の最適化
|
||||
|
||||
「バブルソート」を例にとると、長さ $n$ の配列を処理するのに $O(n^2)$ 時間が必要です。以下の図に示すように、配列を中点から2つの副配列に分割するとします。そのような分割には $O(n)$ 時間が必要です。各副配列のソートには $O((n / 2)^2)$ 時間が必要です。そして2つの副配列のマージには $O(n)$ 時間が必要です。したがって、全体の時間計算量は:
|
||||
「バブルソート」を例に取ると、長さ $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)
|
||||
@@ -42,7 +42,7 @@ $$
|
||||
|
||||

|
||||
|
||||
以下の不等式を計算してみましょう。左側は分割前の総操作数を表し、右側は分割後の総操作数をそれぞれ表します:
|
||||
次に、以下の不等式を計算します。左辺と右辺はそれぞれ、分割前と分割後の操作総数です:
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
@@ -52,40 +52,40 @@ n(n - 4) & > 0
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
**これは $n > 4$ の場合、分割後の操作数が少なく、より良いパフォーマンスにつながることを意味します**。分割後の時間計算量は依然として二次 $O(n^2)$ ですが、計算量の定数係数が減少していることに注意してください。
|
||||
**これは、$n > 4$ のときに分割後の操作回数の方が少なくなり、ソート効率が高くなることを意味します**。ただし、分割後の時間計算量は依然として 2 次の $O(n^2)$ であり、計算量の定数項が小さくなっただけです。
|
||||
|
||||
さらに進むことができます。**副配列をその中点からさらに2つの副配列に分割し続けて、副配列が1つの要素のみになるまで続けたらどうでしょうか?** このアイデアは実際には「マージソート」で、時間計算量は $O(n \log n)$ です。
|
||||
さらに考えると、**部分配列を中央からさらに 2 つの部分配列へと分割し続け**、部分配列に要素が 1 つだけ残るまで分割を止めないとしたらどうでしょうか。この考え方がまさに「マージソート」であり、時間計算量は $O(n \log n)$ です。
|
||||
|
||||
少し違うことを試してみましょう。**2つではなく、より多くの分割に分割したらどうでしょうか?** 例えば、元の配列を $k$ 個の副配列に均等に分割しますか?このアプローチは「バケットソート」と非常に似ており、大量のデータのソートに非常に適しています。理論的には、時間計算量は $O(n + k)$ に達することができます。
|
||||
さらに、**分割点をいくつか増やして**、元の配列を平均的に $k$ 個の部分配列に分けるとしたらどうでしょうか。この状況は「バケットソート」と非常によく似ており、大量データのソートに非常に適しています。理論上の時間計算量は $O(n + k)$ に達します。
|
||||
|
||||
### 並列計算による最適化
|
||||
### 並列計算の最適化
|
||||
|
||||
分割統治によって生成される部分問題は互いに独立していることが分かっています。**これは、それらを並列で解決できることを意味します。** その結果、分割統治はアルゴリズムの時間計算量を減らすだけでなく、**現代のオペレーティングシステムによる並列最適化も促進します。**
|
||||
分割統治法で生成される部分問題は互いに独立しているため、**通常は並列に解くことができます**。つまり、分割統治法はアルゴリズムの時間計算量を下げられるだけでなく、**オペレーティングシステムの並列最適化にも有利です**。
|
||||
|
||||
並列最適化は、複数のコアやプロセッサを持つ環境で特に効果的です。システムが複数の部分問題を同時に処理できるため、計算リソースを完全に活用し、全体的な実行時間が大幅に短縮されます。
|
||||
並列最適化は、マルチコアまたはマルチプロセッサ環境で特に有効です。システムが複数の部分問題を同時に処理でき、計算資源をより十分に活用できるため、全体の実行時間を大幅に短縮できます。
|
||||
|
||||
例えば、以下の図に示す「バケットソート」では、大量のデータを様々なバケットに均等に分解します。各バケットのソート作業は、利用可能な計算ユニットに割り当てることができます。すべての作業が完了すると、すべてのソートされたバケットがマージされて最終結果が生成されます。
|
||||
たとえば、以下の図に示す「バケットソート」では、大量のデータを各バケットに均等に割り当てることで、すべてのバケットのソート処理を各計算ユニットに分散し、完了後に結果を統合できます。
|
||||
|
||||

|
||||
|
||||
## 分割統治の一般的な応用
|
||||
## 分割統治法の代表的な応用
|
||||
|
||||
分割統治は多くの古典的なアルゴリズム問題を解決するために使用できます。
|
||||
一方では、分割統治法は多くの古典的なアルゴリズム問題を解くのに使えます。
|
||||
|
||||
- **最近点対の発見**:このアルゴリズムは点の集合を2つの半分に分割することで動作します。そして各半分で再帰的に最近点対を見つけます。最後に、2つの半分にまたがるペアを考慮して、全体の最近点対を見つけます。
|
||||
- **大整数の乗算**:一つのアルゴリズムはKaratsubaと呼ばれます。大整数の乗算をいくつかの小さな整数の乗算と加算に分解します。
|
||||
- **行列の乗算**:一例はStrassenアルゴリズムです。大きな行列の乗算を複数の小さな行列の乗算と加算に分解します。
|
||||
- **ハノイの塔問題**:ハノイの塔問題は再帰的に解決でき、分割統治戦略の典型的な応用です。
|
||||
- **転倒対の解決**:シーケンスで、前の数が後の数より大きい場合、これら2つの数は転倒対を構成します。転倒対問題の解決は、マージソートの助けを借りて、分割統治のアイデアを利用できます。
|
||||
- **最近点対探索**:このアルゴリズムは、まず点集合を 2 つに分け、それぞれの部分における最近点対を求め、最後に 2 つの部分をまたぐ最近点対を求めます。
|
||||
- **大整数乗算**:たとえば Karatsuba 法では、大整数の乗算を、より小さな整数どうしのいくつかの乗算と加算に分解します。
|
||||
- **行列乗算**:たとえば Strassen 法では、大きな行列の乗算を、複数の小さな行列の乗算と加算に分解します。
|
||||
- **ハノイの塔問題**:ハノイの塔問題は再帰によって解くことができ、これは典型的な分割統治戦略の応用です。
|
||||
- **反転対の計算**:ある数列で前の数が後ろの数より大きい場合、その 2 つの数は反転対を構成します。反転対の問題は、分割統治の考え方を利用し、マージソートを用いて解けます。
|
||||
|
||||
分割統治はアルゴリズムとデータ構造の設計にも広く応用されています。
|
||||
他方で、分割統治法はアルゴリズムとデータ構造の設計にも非常に広く応用されています。
|
||||
|
||||
- **二分探索**:二分探索は、ソート済み配列を中点インデックスから2つの半分に分割します。そして、ターゲット値と中間要素値の比較結果に基づいて、一方の半分が破棄されます。同じプロセスで残りの半分で検索が続行され、ターゲットが見つかるか残りの要素がなくなるまで続きます。
|
||||
- **マージソート**:この節の冒頭ですでに紹介したため、さらなる詳述は不要です。
|
||||
- **クイックソート**:クイックソートはピボット値を選択して配列を2つの副配列に分割し、一方はピボットより小さい要素、もう一方はピボットより大きい要素を持ちます。このプロセスは、これら2つの副配列のそれぞれに対して、1つの要素のみを保持するまで続きます。
|
||||
- **バケットソート**:バケットソートの基本的なアイデアは、データを複数のバケットに分散させることです。各バケット内の要素をソートした後、バケットから順序よく要素を取得して順序付けられた配列を取得します。
|
||||
- **木**:例えば、二分探索木、AVL木、赤黒木、B木、B+木など。その操作(検索、挿入、削除)はすべて分割統治戦略の応用と見なすことができます。
|
||||
- **ヒープ**:ヒープは特別なタイプの完全二分木です。その様々な操作(挿入、削除、ヒープ化)は、実際に分割統治のアイデアを含意しています。
|
||||
- **ハッシュテーブル**:ハッシュテーブルは直接分割統治を適用しませんが、一部のハッシュ衝突解決ソリューションは間接的にこの戦略を適用します。例えば、チェイン法の長いリストは、クエリ効率を向上させるために赤黒木に変換される場合があります。
|
||||
- **二分探索**:二分探索では、整列済み配列を中央のインデックスで 2 つに分け、目標値と中央要素の比較結果に基づいてどちらの半区間を除外するかを決め、残った区間で同じ二分操作を行います。
|
||||
- **マージソート**:本節の冒頭で紹介したため、ここでは繰り返しません。
|
||||
- **クイックソート**:クイックソートは基準値を 1 つ選び、配列を、基準値より小さい要素の部分配列と、基準値より大きい要素の部分配列に分け、その後それぞれに対して同じ分割操作を行い、部分配列に要素が 1 つだけ残るまで続けます。
|
||||
- **バケットソート**:バケットソートの基本的な考え方は、データを複数のバケットに分散し、各バケット内の要素をソートしたうえで、各バケットの要素を順に取り出して整列済み配列を得ることです。
|
||||
- **木構造**:たとえば二分探索木、AVL 木、赤黒木、B 木、B+ 木などでは、探索・挿入・削除などの操作をいずれも分割統治戦略の応用とみなせます。
|
||||
- **ヒープ**:ヒープは特殊な完全二分木であり、挿入、削除、ヒープ化などの各種操作には、実際には分割統治の考え方が含まれています。
|
||||
- **ハッシュテーブル**:ハッシュテーブル自体は分割統治を直接適用しているわけではありませんが、いくつかのハッシュ衝突解決法では間接的に分割統治戦略が使われています。たとえば、連鎖アドレス法における長い連結リストは、検索効率を高めるために赤黒木へ変換されます。
|
||||
|
||||
**分割統治は巧妙に浸透するアルゴリズムアイデア**であり、様々なアルゴリズムとデータ構造に組み込まれていることが分かります。
|
||||
このように、**分割統治法は「静かに物を潤す」ようなアルゴリズム思想**であり、さまざまなアルゴリズムやデータ構造の中に潜んでいます。
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
# ハノイの塔問題
|
||||
# ハノイの塔の問題
|
||||
|
||||
マージソートと二分木構築の両方で、元の問題を2つの部分問題に分解し、それぞれが元の問題のサイズの半分でした。しかし、ハノイの塔では、異なる分解戦略を採用します。
|
||||
マージソートや二分木の構築では、いずれも元の問題を元問題の半分の規模をもつ 2 つの部分問題に分解していました。しかし、ハノイの塔の問題では、異なる分解戦略を採用します。
|
||||
|
||||
!!! question
|
||||
|
||||
3つの柱があり、それぞれ `A`、`B`、`C` と表記されます。最初、柱 `A` には $n$ 枚の円盤があり、上から下に向かって昇順のサイズで配置されています。私たちのタスクは、これらの $n$ 枚の円盤を柱 `C` に移動し、元の順序を維持することです(以下の図に示すように)。移動中には以下のルールが適用されます:
|
||||
3 本の柱があり、それぞれを `A`、`B`、`C` とします。初期状態では、柱 `A` に $n$ 枚の円盤が通されており、上から下へ小さい順に並んでいます。私たちの課題は、この $n$ 枚の円盤を柱 `C` に移し、元の順序を保つことです(以下の図のとおり)。円盤を移動する際には、次のルールに従う必要があります。
|
||||
|
||||
1. 円盤は 1 本の柱の頂上から取り出し、別の柱の頂上に置くことしかできません。
|
||||
2. 1 回に移動できる円盤は 1 枚だけです。
|
||||
3. 小さい円盤は常に大きい円盤の上になければなりません。
|
||||
|
||||
1. 円盤は柱の上部からのみ取り除くことができ、別の柱の上部に置く必要があります。
|
||||
2. 一度に移動できるのは1枚の円盤のみです。
|
||||
3. 小さい円盤は常に大きい円盤の上にある必要があります。
|
||||

|
||||
|
||||

|
||||
|
||||
**サイズ $i$ のハノイの塔問題を $f(i)$ と表記します**。例えば、$f(3)$ は3枚の円盤を柱 `A` から柱 `C` に移動することを表します。
|
||||
**規模が $i$ のハノイの塔の問題を $f(i)$ と表します** 。たとえば $f(3)$ は、$3$ 枚の円盤を `A` から `C` へ移動するハノイの塔の問題を表します。
|
||||
|
||||
### 基本ケースを考える
|
||||
|
||||
以下の図に示すように、問題 $f(1)$(円盤が1枚のみ)については、`A` から `C` に直接移動できます。
|
||||
以下の図に示すように、問題 $f(1)$ 、すなわち円盤が 1 枚だけの場合は、それを `A` から `C` へ直接移動すれば済みます。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
|
||||
$f(2)$(円盤が2枚)については、**柱 `B` の助けを借りて小さい円盤を大きい円盤の上に保つ**必要があります。以下の図に示すように:
|
||||
以下の図に示すように、問題 $f(2)$ 、すなわち円盤が 2 枚ある場合は、**小さい円盤が常に大きい円盤の上にある条件を満たすため、`B` を借りて移動を行う必要があります**。
|
||||
|
||||
1. まず、小さい円盤を `A` から `B` に移動します。
|
||||
2. 次に、大きい円盤を `A` から `C` に移動します。
|
||||
3. 最後に、小さい円盤を `B` から `C` に移動します。
|
||||
1. まず上の小さい円盤を `A` から `B` へ移します。
|
||||
2. 次に大きい円盤を `A` から `C` へ移します。
|
||||
3. 最後に小さい円盤を `B` から `C` へ移します。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -42,20 +42,20 @@ $f(2)$(円盤が2枚)については、**柱 `B` の助けを借りて小さ
|
||||
=== "<4>"
|
||||

|
||||
|
||||
$f(2)$ を解決する過程は次のように要約できます:**`B` の助けを借りて2枚の円盤を `A` から `C` に移動する**。ここで、`C` をターゲット柱、`B` をバッファ柱と呼びます。
|
||||
問題 $f(2)$ を解く過程は、**2 枚の円盤を `B` を介して `A` から `C` へ移す**と要約できます。このとき、`C` を目標の柱、`B` を補助の柱と呼びます。
|
||||
|
||||
### 部分問題の分解
|
||||
### 部分問題への分解
|
||||
|
||||
問題 $f(3)$(つまり、円盤が3枚の場合)については、状況がやや複雑になります。
|
||||
問題 $f(3)$ 、すなわち円盤が 3 枚ある場合になると、状況はやや複雑になります。
|
||||
|
||||
すでに $f(1)$ と $f(2)$ の解が分かっているので、分割統治の観点を採用し、**`A` の上の2枚の円盤を1つの単位として扱い**、以下の図に示すステップを実行できます。これにより、3枚の円盤を `A` から `C` に正常に移動できます。
|
||||
$f(1)$ と $f(2)$ の解が既知なので、分割統治の観点から、**`A` の上部にある 2 枚の円盤をひとまとまりとみなして**、次の図の手順を実行できます。こうして 3 枚の円盤を `A` から `C` へ順調に移動できます。
|
||||
|
||||
1. `B` をターゲット柱、`C` をバッファ柱として、2枚の円盤を `A` から `B` に移動します。
|
||||
2. 残りの円盤を `A` から直接 `C` に移動します。
|
||||
3. `C` をターゲット柱、`A` をバッファ柱として、2枚の円盤を `B` から `C` に移動します。
|
||||
1. `B` を目標の柱、`C` を補助の柱として、2 枚の円盤を `A` から `B` へ移します。
|
||||
2. `A` に残った 1 枚の円盤を `A` から `C` へ直接移動します。
|
||||
3. `C` を目標の柱、`A` を補助の柱として、2 枚の円盤を `B` から `C` へ移します。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -66,32 +66,32 @@ $f(2)$ を解決する過程は次のように要約できます:**`B` の助
|
||||
=== "<4>"
|
||||

|
||||
|
||||
本質的に、**$f(3)$ を2つの $f(2)$ 部分問題と1つの $f(1)$ 部分問題に分解します**。これら3つの部分問題を順次解決することで、元の問題が解決され、部分問題が独立しており、それらの解をマージできることを示しています。
|
||||
本質的には、**問題 $f(3)$ を 2 つの部分問題 $f(2)$ と 1 つの部分問題 $f(1)$ に分けています** 。この 3 つの部分問題を順に解けば、元の問題も解決されます。これは、部分問題が独立しており、解を組み合わせられることを示しています。
|
||||
|
||||
ここから、以下の図に示すハノイの塔の分割統治戦略を要約できます。元の問題 $f(n)$ を2つの部分問題 $f(n-1)$ と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` に移動します。
|
||||
1. $n-1$ 枚の円盤を `C` を介して `A` から `B` へ移します。
|
||||
2. 残り $1$ 枚の円盤を `A` から `C` へ直接移します。
|
||||
3. $n-1$ 枚の円盤を `A` を介して `B` から `C` へ移します。
|
||||
|
||||
各 $f(n-1)$ 部分問題について、**同じ再帰分割を適用でき**、最小の部分問題 $f(1)$ に到達するまで続けます。$f(1)$ は単一の移動のみが必要であることがすでに分かっているため、解決するのは簡単です。
|
||||
この 2 つの部分問題 $f(n-1)$ は、**同じ方法で再帰的に分割できます**。最小の部分問題 $f(1)$ に到達するまでこれを続けます。一方、$f(1)$ の解は既知であり、1 回の移動操作だけで済みます。
|
||||
|
||||

|
||||

|
||||
|
||||
### コード実装
|
||||
### コードの実装
|
||||
|
||||
コードでは、再帰関数 `dfs(i, src, buf, tar)` を定義します。これは柱 `src` から上の $i$ 枚の円盤を柱 `tar` に移動し、柱 `buf` をバッファとして使用します:
|
||||
コードでは、再帰関数 `dfs(i, src, buf, tar)` を定義します。その役割は、柱 `src` の上部にある $i$ 枚の円盤を、補助の柱 `buf` を使って目標の柱 `tar` へ移動することです:
|
||||
|
||||
```src
|
||||
[file]{hanota}-[class]{}-[func]{solve_hanota}
|
||||
```
|
||||
|
||||
以下の図に示すように、ハノイの塔問題は高さ $n$ の再帰木として視覚化できます。各ノードは部分問題を表し、`dfs()` の呼び出しに対応します。**したがって、時間計算量は $O(2^n)$、空間計算量は $O(n)$ です。**
|
||||
以下の図に示すように、ハノイの塔の問題は高さ $n$ の再帰木を形成し、各ノードは 1 つの部分問題、すなわち 1 つ起動された `dfs()` 関数に対応します。**したがって時間計算量は $O(2^n)$ 、空間計算量は $O(n)$** です。
|
||||
|
||||

|
||||

|
||||
|
||||
!!! quote
|
||||
|
||||
ハノイの塔は古代の伝説に由来します。古代インドの寺院で、僧侶たちは3本の高いダイヤモンドの柱と、異なるサイズの $64$ 枚の金の円盤を持っていました。彼らは、最後の円盤が正しく置かれたとき、世界が終わると信じていました。
|
||||
ハノイの塔の問題は古い伝説に由来します。古代インドのある寺院で、僧侶たちは 3 本の高いダイヤモンドの柱と、$64$ 枚の大きさの異なる金の円盤を持っていました。僧侶たちは絶えず円盤を動かし、最後の 1 枚が正しく置かれた瞬間に世界が終わると信じていました。
|
||||
|
||||
しかし、僧侶たちが1秒に1枚の円盤を移動したとしても、約 $2^{64} \approx 1.84×10^{19}$ —約5850億年—かかり、宇宙の年齢の現在の推定をはるかに超えています。したがって、この伝説が真実であれば、世界の終わりについて心配する必要はおそらくないでしょう。
|
||||
しかし、たとえ僧侶たちが 1 秒に 1 回移動するとしても、合計でおよそ $2^{64} \approx 1.84×10^{19}$ 秒、約 $5850$ 億年が必要で、現在推定されている宇宙の年齢をはるかに上回ります。したがって、この伝説が本当だったとしても、世界の終わりを心配する必要はなさそうです。
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
|
||||
!!! abstract
|
||||
|
||||
困難な問題は層を重ねて分解され、各分解によってより単純になります。
|
||||
|
||||
分割統治は深い真理を明らかにします:単純さから始めれば、複雑さは解決される。
|
||||
難題は段階的に分解され、そのたびにより単純になっていく。
|
||||
|
||||
分割統治は一つの重要な事実を示している。単純なことから始めれば、すべてはもはや複雑ではない。
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# まとめ
|
||||
|
||||
- 分割統治は一般的なアルゴリズム設計戦略で、分割(分割)と統治(マージ)の2つの段階から構成され、一般的に再帰を使用して実装されます。
|
||||
- 問題が分割統治アプローチに適しているかどうかを判断するために、問題が分解可能かどうか、部分問題が独立しているかどうか、部分問題をマージできるかどうかを確認します。
|
||||
- マージソートは分割統治戦略の典型的な例です。配列を再帰的に2つの等しい長さの副配列に分割し、1つの要素のみが残るまで続け、次にこれらの副配列を層ごとにマージしてソートを完了します。
|
||||
- 分割統治戦略の導入は、しばしばアルゴリズムの効率を向上させます。一方では操作数を減らし、他方では分割後のシステムの並列最適化を促進します。
|
||||
- 分割統治は多数のアルゴリズム問題に適用でき、データ構造とアルゴリズム設計で広く使用され、多くのシナリオに現れます。
|
||||
- 総当たり検索と比較して、適応検索はより効率的です。時間計算量が $O(\log n)$ の検索アルゴリズムは、通常分割統治戦略に基づいています。
|
||||
- 二分探索は分割統治戦略のもう一つの古典的な応用です。部分問題の解のマージを含まず、再帰的な分割統治アプローチで実装できます。
|
||||
- 二分木構築問題では、木の構築(元の問題)を左の部分木と右の部分木の構築(部分問題)に分割できます。これは前順走査と中順走査のインデックス範囲を分割することで実現できます。
|
||||
- ハノイの塔問題では、サイズ $n$ の問題をサイズ $n-1$ の2つの部分問題とサイズ $1$ の1つの部分問題に分解できます。これら3つの部分問題を順次解決することで、元の問題が解決されます。
|
||||
### 要点の振り返り
|
||||
|
||||
- 分割統治法は一般的なアルゴリズム設計戦略であり、分(分割)と治(統合)の 2 つの段階からなり、通常は再帰に基づいて実装されます。
|
||||
- それが分割統治法の問題かどうかを判断する基準には、問題を分解できるか、部分問題が独立しているか、部分問題を統合できるかが含まれます。
|
||||
- マージソートは分割統治法の典型的な応用であり、配列を再帰的に同じ長さの 2 つの部分配列に分割し、要素が 1 つだけになるまで続け、その後で各層を順に統合してソートを完了します。
|
||||
- 分割統治法を導入すると、多くの場合アルゴリズムの効率を高められます。一方では操作回数が減り、他方では分割後にシステムの並列最適化を行いやすくなります。
|
||||
- 分割統治法は多くのアルゴリズム問題を解決できるだけでなく、データ構造やアルゴリズム設計にも広く応用されており、至る所でその姿を見ることができます。
|
||||
- 総当たり探索と比べて、適応的な探索のほうが効率的です。時間計算量が $O(\log n)$ の探索アルゴリズムは、通常は分割統治法に基づいて実装されます。
|
||||
- 二分探索は分割統治法のもう 1 つの典型的な応用であり、部分問題の解を統合する手順を含みません。再帰的な分割統治によって二分探索を実現できます。
|
||||
- 二分木を構築する問題では、木の構築(元の問題)を左部分木と右部分木の構築(部分問題)に分けられます。これは、先行順序走査と中間順序走査のインデックス区間を分割することで実現できます。
|
||||
- ハノイの塔の問題では、規模 $n$ の問題を、規模 $n-1$ の 2 つの部分問題と規模 $1$ の 1 つの部分問題に分けられます。これら 3 つの部分問題を順に解くと、元の問題も解決されます。
|
||||
|
||||
@@ -1,79 +1,79 @@
|
||||
# 動的プログラミング問題の特徴
|
||||
# 動的計画法の問題特性
|
||||
|
||||
前のセクションでは、動的プログラミングが問題を部分問題に分解することで元の問題を解決する方法を学びました。実際、部分問題の分解は一般的なアルゴリズムアプローチであり、分割統治法、動的プログラミング、バックトラッキングでは異なる重点があります。
|
||||
前節では、動的計画法が部分問題への分解によってどのように元の問題を解くのかを学びました。実際、部分問題への分解は汎用的なアルゴリズムの考え方であり、分割統治法、動的計画法、バックトラッキングでは重視点が異なります。
|
||||
|
||||
- 分割統治法アルゴリズムは元の問題を複数の独立した部分問題に再帰的に分割し、最小の部分問題に到達するまで続け、バックトラッキング時に部分問題の解を組み合わせて最終的に元の問題の解を得ます。
|
||||
- 動的プログラミングも問題を再帰的に分解しますが、分割統治法アルゴリズムとの主な違いは、動的プログラミングの部分問題が相互依存的であり、分解プロセス中に多くの重複する部分問題が現れることです。
|
||||
- バックトラッキングアルゴリズムは試行錯誤によってすべての可能な解を網羅し、枝刈りによって不必要な探索分岐を避けます。元の問題の解は一連の決定ステップから構成され、各決定ステップ前の各部分シーケンスを部分問題として考えることができます。
|
||||
- 分割統治法は、元の問題を再帰的に複数の互いに独立した部分問題へ分割し、最小の部分問題に至るまで分解したうえで、バックトラック時に部分問題の解を統合し、最終的に元の問題の解を得ます。
|
||||
- 動的計画法も問題を再帰的に分解しますが、分割統治法との主な違いは、動的計画法における部分問題が相互依存しており、分解の過程で多数の重複部分問題が現れることです。
|
||||
- バックトラッキング法は、試行と巻き戻しの中ですべての可能な解を列挙し、枝刈りによって不要な探索分岐を避けます。元の問題の解は一連の意思決定ステップから構成されるため、各決定ステップ以前の部分系列を一つの部分問題と見なせます。
|
||||
|
||||
実際、動的プログラミングは最適化問題を解決するためによく使用され、これらは重複する部分問題を含むだけでなく、他に2つの主要な特徴があります:最適部分構造と無記憶性です。
|
||||
実際、動的計画法は最適化問題を解くためによく用いられます。これらは重複部分問題を含むだけでなく、さらに二つの大きな特性、すなわち最適部分構造と無後効性を備えています。
|
||||
|
||||
## 最適部分構造
|
||||
|
||||
階段登り問題を少し修正して、最適部分構造の概念を実証するのにより適したものにします。
|
||||
階段昇り問題を少し変更し、最適部分構造の概念をより示しやすくします。
|
||||
|
||||
!!! question "階段登りの最小コスト"
|
||||
!!! question "階段昇りの最小コスト"
|
||||
|
||||
階段があり、一度に1段または2段上ることができ、階段の各段にはその段で支払う必要があるコストを表す非負の整数があります。非負の整数配列 $cost$ が与えられ、$cost[i]$ は $i$ 段目で支払う必要があるコストを表し、$cost[0]$ は地面(開始点)です。頂上に到達するために必要な最小コストは何ですか?
|
||||
階段が与えられ、各ステップで $1$ 段または $2$ 段上ることができます。各段には非負整数が貼られており、その段に到達するために支払う必要があるコストを表します。非負整数配列 $cost$ が与えられ、$cost[i]$ は第 $i$ 段で支払うコストを表し、$cost[0]$ は地面(開始地点)です。頂上に到達するために必要な最小コストを求めてください。
|
||||
|
||||
下の図に示すように、1段目、2段目、3段目のコストがそれぞれ $1$、$10$、$1$ の場合、地面から3段目に登る最小コストは $2$ です。
|
||||
下図に示すように、第 $1$、$2$、$3$ 段のコストがそれぞれ $1$、$10$、$1$ である場合、地面から第 $3$ 段まで上る最小コストは $2$ です。
|
||||
|
||||

|
||||

|
||||
|
||||
$dp[i]$ を $i$ 段目に登る累積コストとします。$i$ 段目は $i-1$ 段目または $i-2$ 段目からのみ来ることができるため、$dp[i]$ は $dp[i-1] + cost[i]$ または $dp[i-2] + cost[i]$ のいずれかしかありえません。コストを最小化するために、2つのうち小さい方を選択すべきです:
|
||||
$dp[i]$ を第 $i$ 段まで上るのに累積して支払ったコストとします。第 $i$ 段には $i - 1$ 段または $i - 2$ 段からしか到達できないため、$dp[i]$ は $dp[i - 1] + cost[i]$ または $dp[i - 2] + cost[i]$ のいずれかになります。コストをできるだけ小さくするには、この二つのうち小さいほうを選べばよいです。
|
||||
|
||||
$$
|
||||
dp[i] = \min(dp[i-1], dp[i-2]) + cost[i]
|
||||
$$
|
||||
|
||||
これにより最適部分構造の意味がわかります:**元の問題の最適解は部分問題の最適解から構築される**。
|
||||
ここから最適部分構造の意味を導けます。**元の問題の最適解は、部分問題の最適解から構築される**ということです。
|
||||
|
||||
この問題は明らかに最適部分構造を持っています:2つの部分問題 $dp[i-1]$ と $dp[i-2]$ の最適解からより良い方を選択し、それを使用して元の問題 $dp[i]$ の最適解を構築します。
|
||||
この問題が最適部分構造を持つことは明らかです。二つの部分問題の最適解 $dp[i-1]$ と $dp[i-2]$ からより良いほうを選び、それを用いて元の問題 $dp[i]$ の最適解を構築しています。
|
||||
|
||||
では、前のセクションの階段登り問題は最適部分構造を持っているでしょうか?その目標は解の数を求めることで、これは数え上げ問題のようですが、別の方法で尋ねてみましょう:「解の最大数を求める」。驚くことに、**問題が変わったにもかかわらず、最適部分構造が現れた**ことがわかります:$n$ 段目での解の最大数は、$n-1$ 段目と $n-2$ 段目での解の最大数の和に等しいです。したがって、最適部分構造の解釈は非常に柔軟で、異なる問題では異なる意味を持ちます。
|
||||
では、前節の階段昇り問題には最適部分構造があるのでしょうか。その目的は方法数を求めることで、一見すると計数問題です。しかし問い方を変えて「最大の方法数を求める」とすると、意外にも、**問題の変更前後は等価であるにもかかわらず、最適部分構造が現れます**。すなわち、第 $n$ 段の最大方法数は第 $n-1$ 段と第 $n-2$ 段の最大方法数の和に等しいのです。このように、最適部分構造の解釈は比較的柔軟であり、問題によって意味合いが異なります。
|
||||
|
||||
状態遷移方程式と初期状態 $dp[1] = cost[1]$ および $dp[2] = cost[2]$ に従って、動的プログラミングコードを得ることができます:
|
||||
状態遷移方程式と初期状態 $dp[1] = cost[1]$ および $dp[2] = cost[2]$ に基づいて、次の動的計画法コードが得られます。
|
||||
|
||||
```src
|
||||
[file]{min_cost_climbing_stairs_dp}-[class]{}-[func]{min_cost_climbing_stairs_dp}
|
||||
```
|
||||
|
||||
下の図は上記コードの動的プログラミングプロセスを示しています。
|
||||
下図は上記コードの動的計画法の過程を示しています。
|
||||
|
||||

|
||||

|
||||
|
||||
この問題も空間最適化が可能で、1次元を0に圧縮し、空間計算量を $O(n)$ から $O(1)$ に削減できます:
|
||||
この問題では空間最適化も可能であり、一次元をゼロ次元まで圧縮することで、空間計算量を $O(n)$ から $O(1)$ に削減できます。
|
||||
|
||||
```src
|
||||
[file]{min_cost_climbing_stairs_dp}-[class]{}-[func]{min_cost_climbing_stairs_dp_comp}
|
||||
```
|
||||
|
||||
## 無記憶性
|
||||
## 無後効性
|
||||
|
||||
無記憶性は動的プログラミングが問題解決に効果的であることを可能にする重要な特徴の1つです。その定義は:**特定の状態が与えられたとき、その将来の発展は現在の状態のみに関連し、過去に経験したすべての状態とは無関係である**。
|
||||
無後効性は、動的計画法が問題を効率よく解ける重要な特性の一つであり、その定義は次のとおりです。**ある確定した状態が与えられたとき、その後の発展は現在の状態のみに依存し、過去に経たすべての状態には依存しない**。
|
||||
|
||||
階段登り問題を例に取ると、状態 $i$ が与えられたとき、それは状態 $i+1$ と $i+2$ に発展し、それぞれ1段ジャンプと2段ジャンプに対応します。これら2つの選択をするとき、状態 $i$ より前の状態を考慮する必要はありません。なぜなら、それらは状態 $i$ の将来に影響しないからです。
|
||||
階段昇り問題を例にすると、状態 $i$ が与えられたとき、そこから状態 $i+1$ と状態 $i+2$ へ発展し、それぞれ $1$ 段進む場合と $2$ 段進む場合に対応します。この二つの選択を行う際、状態 $i$ より前の状態を考慮する必要はなく、それらは状態 $i$ の将来に影響を与えません。
|
||||
|
||||
しかし、階段登り問題に制約を追加すると、状況が変わります。
|
||||
しかし、階段昇り問題に制約を一つ追加すると、状況は変わります。
|
||||
|
||||
!!! question "制約付き階段登り"
|
||||
!!! question "制約付き階段昇り"
|
||||
|
||||
$n$ 段の階段があり、毎回1段または2段上ることができますが、**1段を2回連続でジャンプすることはできません**。頂上に登る方法は何通りありますか?
|
||||
全部で $n$ 段ある階段が与えられ、各ステップで $1$ 段または $2$ 段上ることができます。**ただし、連続する 2 回で $1$ 段ずつ上ることはできません**。頂上まで上る方法は何通りあるでしょうか。
|
||||
|
||||
下の図に示すように、3段目に登る実行可能な選択肢は2つだけで、1段を3回連続でジャンプする選択肢は制約条件を満たさないため破棄されます。
|
||||
下図に示すように、第 $3$ 段まで上る実行可能な方法は $2$ 通りしか残りません。そのうち、$1$ 段ずつ 3 回連続で上る方法は制約を満たさないため除外されます。
|
||||
|
||||

|
||||

|
||||
|
||||
この問題では、前回が1段ジャンプだった場合、次回は必ず2段ジャンプでなければなりません。これは**次のステップの選択が現在の状態(現在の階段段数)だけでは独立して決定できず、前の状態(前回の階段段数)にも依存する**ことを意味します。
|
||||
この問題では、前回が $1$ 段上りだった場合、次回は必ず $2$ 段上らなければなりません。これは、**次の一手が現在の状態(現在いる階段の段数)だけでは独立に決まらず、一つ前の状態(前回いた段数)にも関係する**ことを意味します。
|
||||
|
||||
この問題がもはや無記憶性を満たさず、状態遷移方程式 $dp[i] = dp[i-1] + dp[i-2]$ も失敗することは容易にわかります。なぜなら $dp[i-1]$ は今回の1段ジャンプを表しますが、多くの「前回が1段ジャンプだった」選択肢を含んでおり、制約を満たすためにはこれらを直接 $dp[i]$ に含めることはできません。
|
||||
容易に分かるように、この問題はもはや無後効性を満たしておらず、状態遷移方程式 $dp[i] = dp[i-1] + dp[i-2]$ も成立しません。というのも、$dp[i-1]$ は今回 $1$ 段上る場合を表しますが、その中には「前回も $1$ 段上ってきた」方法が多数含まれており、制約を満たすためには $dp[i-1]$ をそのまま $dp[i]$ に加えることができないからです。
|
||||
|
||||
このため、状態定義を拡張する必要があります:**状態 $[i, j]$ は $i$ 段目にいて、前回が $j$ 段ジャンプだったことを表す**。ここで $j \in \{1, 2\}$ です。この状態定義は前回が1段ジャンプだったか2段ジャンプだったかを効果的に区別し、現在の状態がどこから来たかを適切に判断できます。
|
||||
このため、状態定義を拡張する必要があります。**状態 $[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]$ から遷移できます。
|
||||
- 前回に $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]$ の解の数を表します。この時点で、状態遷移方程式は次のようになります:
|
||||
下図に示すように、この定義のもとでは $dp[i, j]$ は状態 $[i, j]$ に対応する方法数を表します。このとき状態遷移方程式は次のようになります。
|
||||
|
||||
$$
|
||||
\begin{cases}
|
||||
@@ -82,20 +82,20 @@ dp[i, 2] = dp[i-2, 1] + dp[i-2, 2]
|
||||
\end{cases}
|
||||
$$
|
||||
|
||||

|
||||

|
||||
|
||||
最終的に、$dp[n, 1] + dp[n, 2]$ を返せばよく、この2つの合計が $n$ 段目に登る解の総数を表します:
|
||||
最終的に、$dp[n, 1] + dp[n, 2]$ を返せば十分であり、その和が第 $n$ 段まで上る方法の総数を表します。
|
||||
|
||||
```src
|
||||
[file]{climbing_stairs_constraint_dp}-[class]{}-[func]{climbing_stairs_constraint_dp}
|
||||
```
|
||||
|
||||
上記のケースでは、前の状態のみを考慮すればよいため、状態定義を拡張することで依然として無記憶性を満たすことができます。しかし、一部の問題では非常に深刻な「状態効果」があります。
|
||||
上の例では、追加で考慮すべきなのは一つ前の状態だけであるため、状態定義を拡張することで問題を再び無後効性に適合させることができます。しかし、問題によっては非常に強い「後効性」があります。
|
||||
|
||||
!!! question "障害物生成付き階段登り"
|
||||
!!! question "階段昇りと障害物生成"
|
||||
|
||||
$n$ 段の階段があり、毎回1段または2段上ることができます。**$i$ 段目に登ったとき、システムが自動的に $2i$ 段目に障害物を置き、その後のすべてのラウンドで $2i$ 段目にジャンプすることが禁止される**と規定されています。例えば、最初の2ラウンドで2段目と3段目にジャンプした場合、その後は4段目と6段目にジャンプできません。頂上に登る方法は何通りありますか?
|
||||
全部で $n$ 段ある階段が与えられ、各ステップで $1$ 段または $2$ 段上ることができます。**第 $i$ 段に到達すると、システムは自動的に第 $2i$ 段に障害物を置き、それ以降はどの回でも第 $2i$ 段へ跳ぶことができない**とします。例えば、最初の 2 回でそれぞれ第 $2$ 段、第 $3$ 段に到達した場合、その後は第 $4$ 段と第 $6$ 段に跳ぶことはできません。頂上まで上る方法は何通りあるでしょうか。
|
||||
|
||||
この問題では、次のジャンプはすべての過去の状態に依存します。各ジャンプがより高い段に障害物を置き、将来のジャンプに影響するからです。このような問題では、動的プログラミングはしばしば解決に苦労します。
|
||||
この問題では、次の跳躍が過去のすべての状態に依存します。なぜなら、各跳躍がより高い段に障害物を設置し、将来の跳躍に影響するからです。この種の問題は、動的計画法では解きにくいことが多いです。
|
||||
|
||||
実際、多くの複雑な組み合わせ最適化問題(巡回セールスマン問題など)は無記憶性を満たしません。このような問題に対しては、通常、ヒューリスティック探索、遺伝的アルゴリズム、強化学習などの他の方法を選択して、限られた時間内に使用可能な局所最適解を得ます。
|
||||
実際、多くの複雑な組合せ最適化問題(例えば巡回セールスマン問題)は無後効性を満たしません。このような問題に対しては、通常、ヒューリスティック探索、遺伝的アルゴリズム、強化学習などの他の方法を用いて、限られた時間内に実用的な局所最適解を得ます。
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
# 動的プログラミング問題解決アプローチ
|
||||
# 動的計画法の問題解決の考え方
|
||||
|
||||
前の2つのセクションでは、動的プログラミング問題の主要な特徴を紹介しました。次に、より実用的な2つの問題を一緒に探索しましょう。
|
||||
前の 2 節では動的計画法の問題の主要な特徴を紹介しました。ここからは、さらに実用的な 2 つの問題を一緒に考えていきます。
|
||||
|
||||
1. 問題が動的プログラミング問題かどうかをどのように判断するか?
|
||||
2. 動的プログラミング問題を解決する完全なステップは何か?
|
||||
1. ある問題が動的計画法の問題かどうかを、どのように判断すればよいでしょうか?
|
||||
2. 動的計画法の問題を解くには、どこから着手し、完全な手順はどのようなものでしょうか?
|
||||
|
||||
## 問題の判定
|
||||
|
||||
一般的に言えば、問題が重複する部分問題、最適部分構造を含み、無記憶性を示す場合、通常動的プログラミング解法に適しています。しかし、問題の説明から直接これらの特徴を抽出することはしばしば困難です。したがって、通常は条件を緩和し、**まず問題がバックトラッキング(全探索)を使用した解決に適しているかどうかを観察**します。
|
||||
一般に、ある問題が重複部分問題と最適部分構造を含み、さらに無後效性を満たしているなら、通常は動的計画法で解くのに適しています。しかし、問題文からこれらの性質を直接読み取るのは簡単ではありません。そのため通常は条件を少し緩めて、**まずその問題がバックトラッキング(全探索)で解くのに適しているか**を観察します。
|
||||
|
||||
**バックトラッキングに適した問題は通常「決定木モデル」に適合**し、これは木構造を使用して記述でき、各ノードは決定を表し、各パスは決定のシーケンスを表します。
|
||||
**バックトラッキングで解くのに適した問題は、通常「決定木モデル」を満たします**。この種の問題は木構造で表現でき、各ノードは 1 つの決定を表し、各経路は 1 つの決定列を表します。
|
||||
|
||||
言い換えると、問題が明示的な決定概念を含み、解が一連の決定を通じて生成される場合、それは決定木モデルに適合し、通常バックトラッキングを使用して解決できます。
|
||||
言い換えると、問題に明確な決定の概念が含まれており、解が一連の決定によって生成されるなら、その問題は決定木モデルを満たし、通常はバックトラッキングで解くことができます。
|
||||
|
||||
この基礎の上で、動的プログラミング問題を判定するための「ボーナスポイント」があります。
|
||||
これに加えて、動的計画法の問題には判定のための「加点要素」もあります。
|
||||
|
||||
- 問題に最大化(最小化)または最も(最も少ない)最適な解を見つけるという記述が含まれている。
|
||||
- 問題の状態がリスト、多次元行列、または木を使用して表現でき、状態がその周囲の状態と再帰関係を持っている。
|
||||
- 問題文に最大(最小)や最多(最少)などの最適化に関する記述がある。
|
||||
- 問題の状態が配列、多次元行列、または木で表現でき、ある状態とその周辺の状態の間に漸化的な関係がある。
|
||||
|
||||
対応して、「ペナルティポイント」もあります。
|
||||
反対に、「減点要素」もあります。
|
||||
|
||||
- 問題の目標は最適解だけでなく、すべての可能な解を見つけることである。
|
||||
- 問題の説明に順列と組み合わせの明らかな特徴があり、特定の複数の解を返す必要がある。
|
||||
- 問題の目的が最適解を求めることではなく、あり得るすべての解を列挙することである。
|
||||
- 問題文に明確な順列・組合せの特徴があり、具体的な複数の解を返す必要がある。
|
||||
|
||||
問題が決定木モデルに適合し、比較的明らかな「ボーナスポイント」を持つ場合、それが動的プログラミング問題であると仮定し、解決プロセス中に検証できます。
|
||||
ある問題が決定木モデルを満たし、さらに比較的明確な「加点要素」を備えているなら、その問題は動的計画法の問題であると仮定し、解く過程でそれを検証できます。
|
||||
|
||||
## 問題解決ステップ
|
||||
## 問題を解く手順
|
||||
|
||||
動的プログラミング問題解決プロセスは問題の性質と難易度によって異なりますが、一般的に次のステップに従います:決定の記述、状態の定義、$dp$ テーブルの確立、状態遷移方程式の導出、境界条件の決定など。
|
||||
動的計画法の解法の流れは問題の性質や難易度によって異なりますが、通常は次の手順に従います。すなわち、決定を記述し、状態を定義し、$dp$ テーブルを構築し、状態遷移方程式を導出し、境界条件を定めます。
|
||||
|
||||
問題解決ステップをより具体的に説明するために、古典的な問題「最小経路和」を例として使用します。
|
||||
解法の手順をより具体的に示すために、ここでは古典的な問題である「最小経路和」を例にします。
|
||||
|
||||
!!! question
|
||||
|
||||
$n \times m$ の二次元グリッド `grid` が与えられ、グリッドの各セルには負でない整数が含まれ、そのセルのコストを表します。ロボットは左上のセルから始まり、各ステップで下または右にのみ移動でき、右下のセルに到達するまで続けます。左上から右下への最小経路和を返してください。
|
||||
$n \times m$ の 2 次元グリッド `grid` が与えられます。グリッドの各セルには非負整数が格納されており、そのセルのコストを表します。ロボットは左上のセルを始点とし、毎回下または右に 1 マスだけ移動して、右下のセルまで進みます。左上から右下までの最小経路和を返してください。
|
||||
|
||||
下の図は例を示しており、与えられたグリッドの最小経路和は $13$ です。
|
||||
次の図は 1 つの例を示しており、このグリッドの最小経路和は $13$ です。
|
||||
|
||||

|
||||

|
||||
|
||||
**第1ステップ:各ラウンドの決定を考え、状態を定義し、それにより $dp$ テーブルを得る**
|
||||
**ステップ 1:各ラウンドの決定を考え、状態を定義して、$dp$ テーブルを得る**
|
||||
|
||||
この問題の各ラウンドの決定は、現在のセルから下または右に1ステップ移動することです。現在のセルの行と列のインデックスが $[i, j]$ であると仮定すると、下または右に移動した後、インデックスは $[i+1, j]$ または $[i, j+1]$ になります。したがって、状態には2つの変数が含まれるべきです:行インデックスと列インデックス、$[i, j]$ と表記されます。
|
||||
この問題における各ラウンドの決定は、現在のマスから下または右へ 1 マス進むことです。現在のマスの行・列インデックスを $[i, j]$ とすると、下または右へ 1 マス進んだ後のインデックスは $[i+1, j]$ または $[i, j+1]$ になります。したがって、状態には行インデックスと列インデックスの 2 つの変数を含め、$[i, j]$ と表します。
|
||||
|
||||
状態 $[i, j]$ は部分問題に対応します:開始点 $[0, 0]$ から $[i, j]$ への最小経路和、$dp[i, j]$ と表記されます。
|
||||
状態 $[i, j]$ に対応する部分問題は、始点 $[0, 0]$ から $[i, j]$ まで進む最小経路和であり、その解を $dp[i, j]$ と記します。
|
||||
|
||||
このようにして、下の図に示す二次元 $dp$ 行列を得ます。そのサイズは入力グリッド $grid$ と同じです。
|
||||
これで、次の図に示す 2 次元の $dp$ 行列が得られます。そのサイズは入力グリッド $grid$ と同じです。
|
||||
|
||||

|
||||

|
||||
|
||||
!!! note
|
||||
|
||||
動的プログラミングとバックトラッキングは決定のシーケンスとして記述でき、状態はすべての決定変数から構成されます。問題解決の進行を記述するすべての変数を含むべきで、次の状態を導出するのに十分な情報を含んでいる必要があります。
|
||||
動的計画法とバックトラッキングの過程は、いずれも 1 つの決定列として記述できます。そして状態は、すべての決定変数から構成されます。状態には解法の進行状況を表すすべての変数が含まれているべきであり、次の状態を導くのに十分な情報を持っている必要があります。
|
||||
|
||||
各状態は 1 つの部分問題に対応しており、すべての部分問題の解を保存するために $dp$ テーブルを定義します。状態の各独立変数は、$dp$ テーブルの 1 つの次元に対応します。本質的に、$dp$ テーブルは状態と部分問題の解との対応関係です。
|
||||
|
||||
各状態は部分問題に対応し、すべての部分問題の解を保存するための $dp$ テーブルを定義します。状態の各独立変数は $dp$ テーブルの次元です。本質的に、$dp$ テーブルは状態と部分問題の解の間のマッピングです。
|
||||
**ステップ 2:最適部分構造を見つけ、状態遷移方程式を導出する**
|
||||
|
||||
**第2ステップ:最適部分構造を特定し、状態遷移方程式を導出する**
|
||||
状態 $[i, j]$ は、上のマス $[i-1, j]$ または左のマス $[i, j-1]$ からしか遷移してきません。したがって最適部分構造は、$[i, j]$ に到達する最小経路和が、$[i, j-1]$ の最小経路和と $[i-1, j]$ の最小経路和のうち小さい方によって決まる、ということです。
|
||||
|
||||
状態 $[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]
|
||||
@@ -69,75 +69,75 @@ $$
|
||||
|
||||
!!! note
|
||||
|
||||
定義された $dp$ テーブルに基づいて、元の問題と部分問題の関係を考え、部分問題の最適解から元の問題の最適解をどのように構築するか、つまり最適部分構造を見つけます。
|
||||
定義済みの $dp$ テーブルに基づいて、元の問題と部分問題の関係を考え、部分問題の最適解から元の問題の最適解を構成する方法、すなわち最適部分構造を見つけます。
|
||||
|
||||
最適部分構造を特定したら、それを使用して状態遷移方程式を構築できます。
|
||||
ひとたび最適部分構造が見つかれば、それを使って状態遷移方程式を構築できます。
|
||||
|
||||
**第3ステップ:境界条件と状態遷移順序を決定する**
|
||||
**ステップ 3:境界条件と状態遷移の順序を決める**
|
||||
|
||||
この問題では、最初の行の状態は左の状態からのみ来ることができ、最初の列の状態は上の状態からのみ来ることができるため、最初の行 $i = 0$ と最初の列 $j = 0$ が境界条件です。
|
||||
この問題では、先頭行にある状態は左の状態からしか得られず、先頭列にある状態は上の状態からしか得られません。したがって、先頭行 $i = 0$ と先頭列 $j = 0$ が境界条件になります。
|
||||
|
||||
下の図に示すように、各セルは左のセルと上のセルから導出されるため、ループを使用して行列を走査し、外側のループは行を反復し、内側のループは列を反復します。
|
||||
次の図に示すように、各マスは左のマスと上のマスから遷移してくるため、ループを用いて行列を走査します。外側のループで各行を、内側のループで各列を走査します。
|
||||
|
||||

|
||||

|
||||
|
||||
!!! note
|
||||
|
||||
境界条件は動的プログラミングで $dp$ テーブルを初期化するために使用され、探索では枝刈りに使用されます。
|
||||
境界条件は、動的計画法では $dp$ テーブルの初期化に使われ、探索では枝刈りに使われます。
|
||||
|
||||
状態遷移の順序で重要なのは、現在の問題の解を計算するときに、それが依存するより小さな部分問題の解がすべてすでに正しく計算済みであることを保証する点です。
|
||||
|
||||
状態遷移順序の核心は、現在の問題の解を計算するとき、それが依存するすべての小さな部分問題が既に正しく計算されていることを確保することです。
|
||||
以上の分析により、すでに動的計画法のコードを直接書くことができます。しかし、部分問題への分解はトップダウンの考え方であるため、「力任せ探索 $\rightarrow$ メモ化探索 $\rightarrow$ 動的計画法」の順に実装するほうが、思考の流れにはより自然です。
|
||||
|
||||
上記の分析に基づいて、動的プログラミングコードを直接書くことができます。しかし、部分問題の分解はトップダウンアプローチであるため、「力任せ探索 → メモ化探索 → 動的プログラミング」の順序で実装することが習慣的な思考により適合します。
|
||||
### 方法 1:力任せ探索
|
||||
|
||||
### 方法1:力任せ探索
|
||||
状態 $[i, j]$ から探索を開始し、より小さな状態 $[i-1, j]$ と $[i, j-1]$ へと分解していきます。再帰関数には次の要素が含まれます。
|
||||
|
||||
状態 $[i, j]$ から探索を開始し、それを常により小さな状態 $[i-1, j]$ と $[i, j-1]$ に分解します。再帰関数には以下の要素が含まれます。
|
||||
|
||||
- **再帰パラメータ**:状態 $[i, j]$。
|
||||
- **戻り値**:$[0, 0]$ から $[i, j]$ への最小経路和 $dp[i, j]$。
|
||||
- **再帰引数**:状態 $[i, j]$ 。
|
||||
- **戻り値**:$[0, 0]$ から $[i, j]$ までの最小経路和 $dp[i, j]$ 。
|
||||
- **終了条件**:$i = 0$ かつ $j = 0$ のとき、コスト $grid[0, 0]$ を返す。
|
||||
- **枝刈り**:$i < 0$ または $j < 0$ でインデックスが範囲外のとき、コスト $+\infty$ を返し、実行不可能性を表す。
|
||||
- **枝刈り**:$i < 0$ または $j < 0$ でインデックスが範囲外になった場合、コスト $+\infty$ を返し、実行不可能であることを表す。
|
||||
|
||||
実装コードは以下の通りです:
|
||||
実装コードは次のとおりです。
|
||||
|
||||
```src
|
||||
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dfs}
|
||||
```
|
||||
|
||||
下の図は $dp[2, 1]$ を根とする再帰木を示しており、いくつかの重複する部分問題を含み、その数はグリッド `grid` のサイズが増加すると急激に増加します。
|
||||
次の図は、$dp[2, 1]$ を根ノードとする再帰木を示しています。この中にはいくつかの重複部分問題が含まれており、その数はグリッド `grid` のサイズが大きくなるにつれて急激に増加します。
|
||||
|
||||
本質的に、重複する部分問題の理由は:**左上隅から特定のセルに到達する複数のパスが存在する**ことです。
|
||||
本質的に、重複部分問題が生じる理由は、**左上からあるセルへ到達する経路が複数存在すること**にあります。
|
||||
|
||||

|
||||
|
||||
各状態には下と右の2つの選択があるため、左上隅から右下隅までの総ステップ数は $m + n - 2$ で、最悪時間計算量は $O(2^{m + n})$ です。この計算方法はグリッドエッジ近くの状況を考慮していないことに注意してください。ネットワークエッジに到達したとき、選択肢が1つしか残らないため、実際のパス数はより少なくなります。
|
||||
各状態には下と右の 2 通りの選択肢があり、左上から右下まで進むには合計で $m + n - 2$ 歩必要です。したがって最悪時間計算量は $O(2^{m + n})$ です。ここで、$n$ と $m$ はそれぞれグリッドの行数と列数を表します。なお、この見積もりではグリッド境界付近の状況を考慮していません。境界に達すると選択肢は 1 つだけになるため、実際の経路数はこれより少なくなります。
|
||||
|
||||
### 方法2:メモ化探索
|
||||
### 方法 2:メモ化探索
|
||||
|
||||
グリッド `grid` と同じサイズのメモリスト `mem` を導入し、様々な部分問題の解を記録し、重複する部分問題を枝刈りします:
|
||||
グリッド `grid` と同じサイズのメモ配列 `mem` を導入し、各部分問題の解を記録して、重複部分問題を枝刈りします。
|
||||
|
||||
```src
|
||||
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dfs_mem}
|
||||
```
|
||||
|
||||
下の図に示すように、メモ化を導入した後、すべての部分問題の解は一度だけ計算される必要があるため、時間計算量は状態の総数、つまりグリッドサイズ $O(nm)$ に依存します。
|
||||
次の図に示すように、メモ化を導入すると、すべての部分問題の解は 1 回だけ計算すればよくなります。したがって時間計算量は状態総数、すなわちグリッドサイズの $O(nm)$ に依存します。
|
||||
|
||||

|
||||
|
||||
### 方法3:動的プログラミング
|
||||
### 方法 3:動的計画法
|
||||
|
||||
動的プログラミング解法を反復的に実装します。コードは以下の通りです:
|
||||
反復に基づいて動的計画法の解法を実装すると、コードは次のようになります。
|
||||
|
||||
```src
|
||||
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dp}
|
||||
```
|
||||
|
||||
下の図は最小経路和の状態遷移プロセスを示し、グリッド全体を走査するため、**時間計算量は $O(nm)$** です。
|
||||
次の図は最小経路和の状態遷移の過程を示しています。グリッド全体を走査するため、**時間計算量は $O(nm)$** です。
|
||||
|
||||
配列 `dp` のサイズは $n \times m$ であるため、**空間計算量は $O(nm)$** です。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -174,9 +174,9 @@ $$
|
||||
|
||||
### 空間最適化
|
||||
|
||||
各セルは左と上のセルのみに関連するため、単一行配列を使用して $dp$ テーブルを実装できます。
|
||||
各マスは左のマスと上のマスにのみ関係するため、1 行の配列だけを使って $dp$ テーブルを実装できます。
|
||||
|
||||
配列 `dp` は1行の状態のみを表現できるため、最初の列の状態を事前に初期化できず、各行を走査するときに更新することに注意してください:
|
||||
ただし、配列 `dp` は 1 行分の状態しか表せないため、先頭列の状態を事前に初期化することはできず、各行を走査するときに更新する必要があります。
|
||||
|
||||
```src
|
||||
[file]{min_path_sum}-[class]{}-[func]{min_path_sum_dp_comp}
|
||||
|
||||
@@ -1,80 +1,80 @@
|
||||
# 編集距離問題
|
||||
|
||||
編集距離は、レーベンシュタイン距離とも呼ばれ、一つの文字列を別の文字列に変換するために必要な最小修正回数を指し、情報検索や自然言語処理で2つのシーケンス間の類似度を測定するためによく使用されます。
|
||||
編集距離は、Levenshtein 距離とも呼ばれ、2つの文字列の相互変換に必要な最小の編集回数を指し、通常は情報検索や自然言語処理において2つの系列の類似度を測るために用いられます。
|
||||
|
||||
!!! question
|
||||
|
||||
2つの文字列 $s$ と $t$ が与えられたとき、$s$ を $t$ に変換するために必要な最小編集回数を返してください。
|
||||
2つの文字列 $s$ と $t$ を入力し、$s$ を $t$ に変換するのに必要な最小編集回数を返してください。
|
||||
|
||||
1つの文字列に対して3種類の編集操作を行えます。1文字の挿入、1文字の削除、任意の文字への置換です。
|
||||
|
||||
文字列に対して3種類の編集を実行できます:文字の挿入、文字の削除、または文字を他の任意の文字に置換。
|
||||
下図に示すように、`kitten` を `sitting` に変換するには 3 回の編集が必要で、内訳は 2 回の置換と 1 回の挿入です。`hello` を `algo` に変換する場合も 3 回必要で、内訳は 2 回の置換と 1 回の削除です。
|
||||
|
||||
下の図に示すように、`kitten` を `sitting` に変換するには3回の編集が必要で、2回の置換と1回の挿入を含みます。`hello` を `algo` に変換するには3ステップが必要で、2回の置換と1回の削除を含みます。
|
||||

|
||||
|
||||

|
||||
**編集距離問題は決定木モデルで自然に説明できます**。文字列が木のノードに対応し、1回の決定(1回の編集操作)が木の1本の辺に対応します。
|
||||
|
||||
**編集距離問題は決定木モデルで自然に説明できます**。文字列は木のノードに対応し、1ラウンドの決定(編集操作)は木のエッジに対応します。
|
||||
下図に示すように、操作に制限がない場合、各ノードからは多くの辺を派生でき、それぞれの辺が1種類の操作に対応します。これは `hello` から `algo` への変換に多くの経路があり得ることを意味します。
|
||||
|
||||
下の図に示すように、操作に制限がない場合、各ノードは多くのエッジを導出でき、それぞれが1つの操作に対応するため、`hello` を `algo` に変換する可能な経路は多数あります。
|
||||
決定木の観点から見ると、本問の目標はノード `hello` とノード `algo` の間の最短経路を求めることです。
|
||||
|
||||
決定木の観点から、この問題の目標は、ノード `hello` とノード `algo` の間の最短経路を見つけることです。
|
||||

|
||||
|
||||

|
||||
### 動的計画法の考え方
|
||||
|
||||
### 動的プログラミングアプローチ
|
||||
**第1ステップ:各ラウンドの決定を考え、状態を定義して、$dp$ テーブルを得る**
|
||||
|
||||
**ステップ1:各ラウンドの決定を考え、状態を定義し、それにより $dp$ テーブルを得る**
|
||||
各ラウンドの決定は、文字列 $s$ に対して1回の編集操作を行うことです。
|
||||
|
||||
各ラウンドの決定は、文字列 $s$ に対して1つの編集操作を実行することを含みます。
|
||||
編集操作の過程で問題の規模が徐々に小さくなることを期待します。そうして初めて部分問題を構築できます。文字列 $s$ と $t$ の長さをそれぞれ $n$ と $m$ とし、まず両文字列の末尾の文字 $s[n-1]$ と $t[m-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回の編集(挿入、削除、置換)を行い、両文字列の末尾の文字を同じにします。そうすることでそれらをスキップし、より小さい問題を考えられます。
|
||||
|
||||
- $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]$ と記します。
|
||||
|
||||
したがって、文字列 $s$ での各ラウンドの決定(編集操作)は、$s$ と $t$ でマッチされる残りの文字を変更します。したがって、状態は $s$ と $t$ で現在考慮されている $i$ 番目と $j$ 番目の文字であり、$[i, j]$ と表記されます。
|
||||
状態 $[i, j]$ に対応する部分問題は、**$s$ の先頭 $i$ 文字を $t$ の先頭 $j$ 文字に変換するのに必要な最小編集回数**です。
|
||||
|
||||
状態 $[i, j]$ は部分問題に対応します:**$s$ の最初の $i$ 文字を $t$ の最初の $j$ 文字に変更するために必要な最小編集回数**。
|
||||
これにより、サイズが $(i+1) \times (j+1)$ の2次元 $dp$ テーブルが得られます。
|
||||
|
||||
これから、サイズ $(i+1) \times (j+1)$ の二次元 $dp$ テーブルを得ます。
|
||||
**第2ステップ:最適部分構造を見つけ、状態遷移方程式を導く**
|
||||
|
||||
**ステップ2:最適部分構造を特定し、状態遷移方程式を導出する**
|
||||
部分問題 $dp[i, j]$ を考えます。これに対応する2つの文字列の末尾文字は $s[i-1]$ と $t[j-1]$ であり、編集操作の違いに応じて下図の3つの場合に分けられます。
|
||||
|
||||
部分問題 $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]$ です。
|
||||
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]$ です。
|
||||
|
||||

|
||||
|
||||
上記の分析に基づいて、最適部分構造を決定できます:$dp[i, j]$ の最小編集回数は、$dp[i, j-1]$、$dp[i-1, j]$、$dp[i-1, j-1]$ の中の最小値に編集ステップ $1$ を加えたものです。対応する状態遷移方程式は:
|
||||
以上の分析から、最適部分構造は次のように得られます。$dp[i, j]$ の最小編集回数は、$dp[i, j-1]$、$dp[i-1, j]$、$dp[i-1, j-1]$ の3つのうち最小の編集回数に、今回の編集回数 $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]$ が同じ場合、現在の文字に対して編集は必要ありません**。この場合、状態遷移方程式は:
|
||||
注意すべき点として、**$s[i-1]$ と $t[j-1]$ が同じ場合、現在の文字を編集する必要はありません**。この場合の状態遷移方程式は次のとおりです:
|
||||
|
||||
$$
|
||||
dp[i, j] = dp[i-1, j-1]
|
||||
$$
|
||||
|
||||
**ステップ3:境界条件と状態遷移の順序を決定する**
|
||||
**第3ステップ:境界条件と状態遷移の順序を決める**
|
||||
|
||||
両方の文字列が空の場合、編集回数は $0$ です。つまり、$dp[0, 0] = 0$ です。$s$ が空で $t$ が空でない場合、最小編集回数は $t$ の長さに等しく、つまり最初の行 $dp[0, j] = j$ です。$s$ が空でなく $t$ が空の場合、最小編集回数は $s$ の長さに等しく、つまり最初の列 $dp[i, 0] = i$ です。
|
||||
2つの文字列がともに空のとき、編集回数は $0$、すなわち $dp[0, 0] = 0$ です。$s$ が空で $t$ が空でないとき、最小編集回数は $t$ の長さに等しいため、先頭行は $dp[0, j] = j$ です。$s$ が空でなく $t$ が空のとき、最小編集回数は $s$ の長さに等しいため、先頭列は $dp[i, 0] = i$ です。
|
||||
|
||||
状態遷移方程式を観察すると、$dp[i, j]$ の解決は左、上、左上の解に依存するため、二重ループを使用して正しい順序で $dp$ テーブル全体を走査できます。
|
||||
状態遷移方程式を観察すると、$dp[i, j]$ の解は左、上、左上の解に依存します。そのため、2重ループで $dp$ テーブル全体を順方向に走査すれば十分です。
|
||||
|
||||
### コード実装
|
||||
### コードの実装
|
||||
|
||||
```src
|
||||
[file]{edit_distance}-[class]{}-[func]{edit_distance_dp}
|
||||
```
|
||||
|
||||
下の図に示すように、編集距離問題の状態遷移プロセスはナップサック問題と非常に似ており、二次元グリッドを埋めることと見なすことができます。
|
||||
下図に示すように、編集距離問題の状態遷移の過程はナップサック問題と非常によく似ており、どちらも2次元グリッドを埋めていく過程とみなせます。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -120,9 +120,9 @@ $$
|
||||
|
||||
### 空間最適化
|
||||
|
||||
$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]$ を構築できないため、どちらの走査順序も実行可能ではありません。
|
||||
$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]$ からの解を一時的に保存し、左と上の解のみを考慮すればよくなります。この状況は無制限ナップサック問題と似ており、直接走査が可能です。コードは以下の通りです:
|
||||
そのため、変数 `leftup` を用いて左上の解 $dp[i-1, j-1]$ を一時保存し、左と上の解だけを考えればよくなります。このときの状況は完全ナップサック問題と同じであり、順方向走査を用いることができます。コードは次のとおりです:
|
||||
|
||||
```src
|
||||
[file]{edit_distance}-[class]{}-[func]{edit_distance_dp_comp}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# 動的プログラミング
|
||||
# 動的計画法
|
||||
|
||||

|
||||

|
||||
|
||||
!!! abstract
|
||||
|
||||
川が流れて海に注ぐように、
|
||||
|
||||
動的プログラミングは小さな問題の解を織り合わせて、より大きな問題の解へと導きます。一歩一歩進んで、最終的な答えが待つ彼岸へと向かいます。
|
||||
小川は川へと注ぎ、河川は大海へと注ぐ。
|
||||
|
||||
動的計画法は小さな問題の解を集めて大きな問題の答えとし、一歩ずつ私たちを問題解決の彼岸へと導く。
|
||||
|
||||
@@ -1,110 +1,110 @@
|
||||
# 動的プログラミングの紹介
|
||||
# 動的計画法入門
|
||||
|
||||
<u>動的プログラミング</u>は重要なアルゴリズムパラダイムであり、問題を一連の小さな部分問題に分解し、これらの部分問題の解を保存することで冗長な計算を避け、時間効率を大幅に向上させます。
|
||||
<u>動的計画法(dynamic programming)</u>は重要なアルゴリズムパラダイムであり、問題をより小さな部分問題の列に分解し、それらの解を保存して重複計算を避けることで、時間効率を大幅に向上させます。
|
||||
|
||||
このセクションでは、古典的な問題から始めて、まず力任せの探索法による解法を提示し、重複する部分問題を特定してから、より効率的な動的プログラミング解法を段階的に導出します。
|
||||
本節では、古典的な例題から始めて、まずその力任せのバックトラッキング解法を示し、そこに含まれる重複部分問題を観察したうえで、より効率的な動的計画法の解法を段階的に導きます。
|
||||
|
||||
!!! question "階段登り"
|
||||
!!! question "階段を上る"
|
||||
|
||||
$n$ 段の階段があり、一度に $1$ 段または $2$ 段上ることができます。頂上に到達する方法は何通りありますか?
|
||||
全体で $n$ 段ある階段が与えられ、各ステップで $1$ 段または $2$ 段上ることができます。頂上まで到達する方法は何通りあるでしょうか?
|
||||
|
||||
下の図に示すように、$3$ 段の階段の頂上に到達する方法は $3$ 通りあります。
|
||||
次の図に示すように、$3$ 段の階段では、頂上まで到達する方法は全部で $3$ 通りあります。
|
||||
|
||||

|
||||

|
||||
|
||||
この問題は**バックトラッキングを用いてすべての可能性を網羅**することで方法の数を計算することを目的としています。具体的には、階段登りの問題を多段階選択プロセスとして考えます:地面から始めて、毎回 $1$ 段または $2$ 段上るかを選択し、階段の頂上に到達したら方法の数をカウントし、頂上を超えた場合はプルーニング(枝刈り)を行います。コードは以下の通りです:
|
||||
この問題の目的は方法の総数を求めることです。**考えられるすべての可能性をバックトラッキングで総当たりすることができます**。具体的には、階段を上ることを複数ラウンドの選択過程とみなし、地面から出発して各ラウンドで $1$ 段または $2$ 段上ります。階段の頂上に到達するたびに方法数を $1$ 増やし、頂上を越えた場合は枝刈りします。コードは次のとおりです:
|
||||
|
||||
```src
|
||||
[file]{climbing_stairs_backtrack}-[class]{}-[func]{climbing_stairs_backtrack}
|
||||
```
|
||||
|
||||
## 方法1:力任せ探索
|
||||
## 方法 1:総当たり探索
|
||||
|
||||
バックトラッキングアルゴリズムは問題を明示的に部分問題に分解しません。代わりに、問題を一連の決定ステップとして扱い、試行と枝刈りを通じてすべての可能性を探索します。
|
||||
バックトラッキング法は通常、問題を明示的に分解するのではなく、問題解決を一連の意思決定ステップとみなし、試行と枝刈りによってあらゆる可能な解を探索します。
|
||||
|
||||
この問題を分解アプローチを使って分析できます。$dp[i]$ を $i$ 段目に到達する方法の数とします。この場合、$dp[i]$ が元の問題であり、その部分問題は次のようになります:
|
||||
この問題を問題分解の観点から分析してみましょう。$i$ 段目まで上る方法が全部で $dp[i]$ 通りあるとすると、$dp[i]$ が元の問題であり、その部分問題には次が含まれます:
|
||||
|
||||
$$
|
||||
dp[i-1], dp[i-2], \dots, dp[2], dp[1]
|
||||
$$
|
||||
|
||||
各移動は $1$ 段または $2$ 段しか進めないため、$i$ 段目に立っているとき、前のステップは $i-1$ 段目または $i-2$ 段目のいずれかにいたはずです。つまり、$i$ 段目には $i-1$ 段目または $i-2$ 段目からしか到達できません。
|
||||
各ラウンドでは $1$ 段または $2$ 段しか上れないため、$i$ 段目の階段に立っているとき、直前のラウンドでは $i - 1$ 段目または $i - 2$ 段目にしか立てません。言い換えると、$i -1$ 段目または $i - 2$ 段目からしか $i$ 段目へ進めません。
|
||||
|
||||
これにより重要な結論が得られます:**$i-1$ 段目に到達する方法の数に $i-2$ 段目に到達する方法の数を加えたものが、$i$ 段目に到達する方法の数に等しい**。式は以下の通りです:
|
||||
ここから重要な帰結が得られます。**$i - 1$ 段目まで上る方法数と $i - 2$ 段目まで上る方法数の和が、$i$ 段目まで上る方法数に等しい**のです。式は次のとおりです:
|
||||
|
||||
$$
|
||||
dp[i] = dp[i-1] + dp[i-2]
|
||||
$$
|
||||
|
||||
これは、階段登り問題において部分問題間に再帰関係があることを意味し、**元の問題の解は部分問題の解から構築できます**。下の図はこの再帰関係を示しています。
|
||||
これは、階段を上る問題では各部分問題の間に漸化関係があり、**元の問題の解は部分問題の解から構築できる**ことを意味します。次の図はこの漸化関係を示しています。
|
||||
|
||||

|
||||

|
||||
|
||||
再帰式に従って力任せ探索解法を得ることができます。$dp[n]$ から始めて、**より大きな問題を再帰的に2つの小さな部分問題の和に分解**し、解が既知の最小の部分問題 $dp[1]$ と $dp[2]$ に到達するまで続けます。$dp[1] = 1$ と $dp[2] = 2$ で、それぞれ1段目と2段目に登る方法が $1$ 通りと $2$ 通りあることを表します。
|
||||
漸化式に基づいて総当たり探索の解法を得ることができます。$dp[n]$ を出発点とし、**より大きな問題を再帰的に 2 つのより小さな問題の和へ分解**していき、最小部分問題 $dp[1]$ と $dp[2]$ に到達したら返します。ここで最小部分問題の解は既知であり、$dp[1] = 1$、$dp[2] = 2$ です。これは、第 $1$ 段目と第 $2$ 段目まで上る方法がそれぞれ $1$ 通り、$2$ 通りであることを表します。
|
||||
|
||||
以下のコードを観察すると、標準的なバックトラッキングコードと同様に深さ優先探索に属しますが、より簡潔です:
|
||||
次のコードを見ると、標準的なバックトラッキングコードと同じく深さ優先探索に属しますが、より簡潔です:
|
||||
|
||||
```src
|
||||
[file]{climbing_stairs_dfs}-[class]{}-[func]{climbing_stairs_dfs}
|
||||
```
|
||||
|
||||
下の図は力任せ探索によって形成される再帰木を示しています。問題 $dp[n]$ について、その再帰木の深さは $n$ で、時間計算量は $O(2^n)$ です。この指数的増加により、$n$ が大きいとプログラムの実行がはるかに遅くなり、長い待機時間が生じます。
|
||||
次の図は総当たり探索によって形成される再帰木を示しています。問題 $dp[n]$ に対して、その再帰木の深さは $n$、時間計算量は $O(2^n)$ です。指数オーダーは爆発的に増加するため、比較的大きな $n$ を入力すると長時間待たされることになります。
|
||||
|
||||

|
||||

|
||||
|
||||
上の図を観察すると、**指数時間計算量は「重複する部分問題」によって引き起こされる**ことがわかります。例えば、$dp[9]$ は $dp[8]$ と $dp[7]$ に分解され、$dp[8]$ はさらに $dp[7]$ と $dp[6]$ に分解され、両方とも部分問題 $dp[7]$ を含んでいます。
|
||||
上の図を見ると、**指数オーダーの時間計算量は「重複部分問題」によって生じています**。たとえば $dp[9]$ は $dp[8]$ と $dp[7]$ に分解され、$dp[8]$ は $dp[7]$ と $dp[6]$ に分解されるため、どちらにも部分問題 $dp[7]$ が含まれています。
|
||||
|
||||
このように、部分問題にはさらに小さな重複する部分問題が含まれ、これは無限に続きます。計算リソースの大部分がこれらの重複する部分問題に浪費されています。
|
||||
このように、部分問題の中にはさらに小さな重複部分問題が含まれ、それが際限なく続いていきます。計算資源の大部分は、こうした重複部分問題に浪費されています。
|
||||
|
||||
## 方法2:メモ化探索
|
||||
## 方法 2:メモ化探索
|
||||
|
||||
アルゴリズムの効率を向上させるため、**すべての重複する部分問題を一度だけ計算したい**と考えます。この目的のため、各部分問題の解を記録する配列 `mem` を宣言し、探索プロセス中に重複する部分問題を枝刈りします。
|
||||
アルゴリズム効率を高めるため、**すべての重複部分問題を 1 回だけ計算したい**と考えます。そのために、各部分問題の解を記録する配列 `mem` を宣言し、探索の過程で重複部分問題を枝刈りします。
|
||||
|
||||
1. $dp[i]$ が初めて計算されるとき、後で使用するために `mem[i]` に記録します。
|
||||
2. $dp[i]$ を再度計算する必要があるとき、`mem[i]` から直接結果を取得でき、その部分問題の冗長な計算を避けられます。
|
||||
1. $dp[i]$ を初めて計算したとき、その結果を `mem[i]` に記録して後で使えるようにします。
|
||||
2. 再び $dp[i]$ を計算する必要が生じたときは、`mem[i]` から直接結果を取得し、その部分問題の重複計算を避けます。
|
||||
|
||||
コードは以下の通りです:
|
||||
コードは次のとおりです:
|
||||
|
||||
```src
|
||||
[file]{climbing_stairs_dfs_mem}-[class]{}-[func]{climbing_stairs_dfs_mem}
|
||||
```
|
||||
|
||||
下の図を観察すると、**メモ化後、すべての重複する部分問題は一度だけ計算される必要があり、時間計算量を $O(n)$ に最適化**します。これは大幅な改善です。
|
||||
次の図を見ると、**メモ化を行うことで、すべての重複部分問題は 1 回だけ計算すればよくなり、時間計算量は $O(n)$ まで改善されます**。これは大きな飛躍です。
|
||||
|
||||

|
||||

|
||||
|
||||
## 方法3:動的プログラミング
|
||||
## 方法 3:動的計画法
|
||||
|
||||
**メモ化探索は「トップダウン」方式**です:元の問題(根ノード)から始めて、より大きな部分問題をより小さなものに再帰的に分解し、最小の既知の部分問題(葉ノード)の解に到達するまで続けます。その後、バックトラッキングにより部分問題の解を収集し、元の問題の解を構築します。
|
||||
**メモ化探索は「トップダウン」の方法**です。元の問題(根ノード)から始めて、より大きな部分問題を再帰的により小さな部分問題へ分解し、解が既知である最小部分問題(葉ノード)に至ります。その後、バックトラックしながら各層で部分問題の解を集め、元の問題の解を構築します。
|
||||
|
||||
一方、**動的プログラミングは「ボトムアップ」方式**です:最小の部分問題の解から始めて、元の問題が解決されるまで、より大きな部分問題の解を反復的に構築します。
|
||||
これとは対照的に、**動的計画法は「ボトムアップ」の方法**です。最小部分問題の解から始めて、より大きな部分問題の解を反復的に構築し、最終的に元の問題の解を得ます。
|
||||
|
||||
動的プログラミングはバックトラッキングを必要としないため、ループを使った反復のみが必要で、再帰は不要です。以下のコードでは、配列 `dp` を初期化して部分問題の解を保存し、メモ化探索の配列 `mem` と同じ記録機能を果たします:
|
||||
動的計画法にはバックトラックの過程が含まれないため、再帰を使う必要はなく、ループによる反復だけで実装できます。次のコードでは、部分問題の解を保存する配列 `dp` を初期化しており、これはメモ化探索における配列 `mem` と同じ記録の役割を果たします:
|
||||
|
||||
```src
|
||||
[file]{climbing_stairs_dp}-[class]{}-[func]{climbing_stairs_dp}
|
||||
```
|
||||
|
||||
下の図は上記コードの実行プロセスをシミュレートしています。
|
||||
次の図は、以上のコードの実行過程をシミュレートしたものです。
|
||||
|
||||

|
||||

|
||||
|
||||
バックトラッキングアルゴリズムと同様に、動的プログラミングも「状態」の概念を使用して問題解決の特定の段階を表現し、各状態は部分問題とその局所最適解に対応します。例えば、階段登り問題の状態は現在のステップ番号 $i$ として定義されます。
|
||||
バックトラッキング法と同様に、動的計画法でも問題解決の特定段階を表すために「状態」という概念を用います。各状態は 1 つの部分問題と、それに対応する局所最適解に対応します。たとえば、階段を上る問題では、状態は現在いる階段の段数 $i$ と定義されます。
|
||||
|
||||
上記の内容に基づいて、動的プログラミングでよく使用される用語をまとめることができます。
|
||||
以上を踏まえると、動的計画法のよく使われる用語を次のようにまとめられます。
|
||||
|
||||
- 配列 `dp` は<u>DPテーブル</u>と呼ばれ、$dp[i]$ は状態 $i$ に対応する部分問題の解を表します。
|
||||
- 最小の部分問題(ステップ $1$ と $2$)に対応する状態は<u>初期状態</u>と呼ばれます。
|
||||
- 再帰式 $dp[i] = dp[i-1] + dp[i-2]$ は<u>状態遷移方程式</u>と呼ばれます。
|
||||
- 配列 `dp` を <u>dp テーブル</u> と呼び、$dp[i]$ は状態 $i$ に対応する部分問題の解を表します。
|
||||
- 最小部分問題に対応する状態(第 $1$ 段目と第 $2$ 段目の階段)を<u>初期状態</u>と呼びます。
|
||||
- 漸化式 $dp[i] = dp[i-1] + dp[i-2]$ を<u>状態遷移方程式</u>と呼びます。
|
||||
|
||||
## 空間最適化
|
||||
|
||||
注意深い読者は**$dp[i]$ は $dp[i-1]$ と $dp[i-2]$ のみに関連するため、すべての部分問題の解を保存するために配列 `dp` を使用する必要がない**ことに気づくでしょう。単に2つの変数を使って反復的に進めることができます。コードは以下の通りです:
|
||||
注意深い読者は気づいたかもしれません。**$dp[i]$ は $dp[i-1]$ と $dp[i-2]$ にしか依存しないため、すべての部分問題の解を保存するために配列 `dp` を使う必要はありません**。2 つの変数を順に更新していくだけで十分です。コードは次のとおりです:
|
||||
|
||||
```src
|
||||
[file]{climbing_stairs_dp}-[class]{}-[func]{climbing_stairs_dp_comp}
|
||||
```
|
||||
|
||||
上記のコードを観察すると、配列 `dp` が占有していた空間が削除されるため、空間計算量は $O(n)$ から $O(1)$ に削減されます。
|
||||
上のコードを見ると、配列 `dp` が占めていた領域を省けるため、空間計算量は $O(n)$ から $O(1)$ へと下がります。
|
||||
|
||||
多くの動的プログラミング問題では、現在の状態は限られた数の前の状態のみに依存するため、必要な状態のみを保持し、「次元削減」によってメモリ空間を節約できます。**この空間最適化技術は「ローリング変数」または「ローリング配列」として知られています**。
|
||||
動的計画法の問題では、現在の状態はしばしば直前の限られた個数の状態にしか関係しません。このような場合は、必要な状態だけを保持し、「次元削減」によってメモリ空間を節約できます。**この空間最適化の技巧は「ローリング変数」または「ローリング配列」と呼ばれます**。
|
||||
|
||||
@@ -1,97 +1,97 @@
|
||||
# 0-1ナップサック問題
|
||||
# 0-1 ナップサック問題
|
||||
|
||||
ナップサック問題は動的プログラミングの優れた入門問題であり、動的プログラミングで最も一般的な問題タイプです。0-1ナップサック問題、無制限ナップサック問題、複数ナップサック問題など、多くの変種があります。
|
||||
ナップサック問題は、動的計画法の入門として非常に適した問題であり、動的計画法で最もよく見られる問題形式の1つです。これには 0-1 ナップサック問題、完全ナップサック問題、多重ナップサック問題など、多くの派生があります。
|
||||
|
||||
このセクションでは、まず最も一般的な0-1ナップサック問題を解決します。
|
||||
本節では、まず最も一般的な 0-1 ナップサック問題を解いていきます。
|
||||
|
||||
!!! question
|
||||
|
||||
$n$ 個のアイテムが与えられ、$i$ 番目のアイテムの重量は $wgt[i-1]$ で値は $val[i-1]$ です。容量が $cap$ のナップサックがあります。各アイテムは1回のみ選択できます。容量制限下でナップサックに入れることができるアイテムの最大値は何ですか?
|
||||
$n$ 個の品物が与えられ、$i$ 番目の品物の重さは $wgt[i-1]$、価値は $val[i-1]$ であり、容量 $cap$ のナップサックがあります。各品物は1回しか選べないとき、ナップサック容量の制約下で入れられる品物の最大価値を求めてください。
|
||||
|
||||
下の図を観察すると、アイテム番号 $i$ は1から数え始め、配列インデックスは0から始まるため、アイテム $i$ の重量は $wgt[i-1]$ に対応し、値は $val[i-1]$ に対応します。
|
||||
以下の図を見てみましょう。品物番号 $i$ は $1$ から始まり、配列のインデックスは $0$ から始まるため、品物 $i$ は重さ $wgt[i-1]$、価値 $val[i-1]$ に対応します。
|
||||
|
||||

|
||||

|
||||
|
||||
0-1ナップサック問題を $n$ ラウンドの決定から構成されるプロセスとして考えることができます。各アイテムについて入れない、または入れるという2つの決定があり、したがって問題は決定木モデルに適合します。
|
||||
0-1 ナップサック問題は、$n$ 回の意思決定からなる過程とみなせます。各品物について「入れない」「入れる」という2つの選択肢があるため、この問題は決定木モデルを満たします。
|
||||
|
||||
この問題の目的は「限られた容量の下でナップサックに入れることができるアイテムの値を最大化する」ことであり、動的プログラミング問題である可能性が高いです。
|
||||
この問題の目的は「ナップサック容量の制約下で入れられる品物の最大価値」を求めることなので、動的計画法の問題である可能性が高いです。
|
||||
|
||||
**第1ステップ:各ラウンドの決定を考え、状態を定義し、それにより $dp$ テーブルを得る**
|
||||
**ステップ1:各ラウンドの選択を考え、状態を定義して、$dp$ テーブルを得る**
|
||||
|
||||
各アイテムについて、ナップサックに入れなければ容量は変わらず、入れれば容量は減少します。これから状態定義を得ることができます:現在のアイテム番号 $i$ とナップサック容量 $c$、$[i, c]$ と表記されます。
|
||||
各品物について、ナップサックに入れなければ容量は変わらず、入れれば容量は減少します。ここから状態を、現在の品物番号 $i$ とナップサック容量 $c$ として定義し、$[i, c]$ と表せます。
|
||||
|
||||
状態 $[i, c]$ は部分問題に対応します:**容量 $c$ のナップサックでの最初の $i$ 個のアイテムの最大値**、$dp[i, c]$ と表記されます。
|
||||
状態 $[i, c]$ に対応する部分問題は、**先頭 $i$ 個の品物を容量 $c$ のナップサックに入れるときの最大価値** であり、これを $dp[i, c]$ と記します。
|
||||
|
||||
探している解は $dp[n, cap]$ であるため、サイズ $(n+1) \times (cap+1)$ の二次元 $dp$ テーブルが必要です。
|
||||
求めるべきものは $dp[n, cap]$ なので、サイズ $(n+1) \times (cap+1)$ の2次元 $dp$ テーブルが必要です。
|
||||
|
||||
**第2ステップ:最適部分構造を特定し、状態遷移方程式を導出する**
|
||||
**ステップ2:最適部分構造を見つけ、状態遷移方程式を導く**
|
||||
|
||||
アイテム $i$ の決定を行った後、残るのは最初の $i-1$ 個のアイテムの決定の部分問題であり、これは2つのケースに分けることができます。
|
||||
品物 $i$ に対する選択を行った後に残るのは、先頭 $i-1$ 個の品物に対する部分問題であり、次の2つのケースに分けられます。
|
||||
|
||||
- **アイテム $i$ を入れない**:ナップサック容量は変わらず、状態は $[i-1, c]$ に変わります。
|
||||
- **アイテム $i$ を入れる**:ナップサック容量は $wgt[i-1]$ だけ減少し、値は $val[i-1]$ だけ増加し、状態は $[i-1, c-wgt[i-1]]$ に変わります。
|
||||
- **品物 $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]$ は、品物 $i$ を入れない場合と入れる場合のうち、より価値の大きい方に等しい** ということです。これにより、次の状態遷移方程式を導けます。
|
||||
|
||||
$$
|
||||
dp[i, c] = \max(dp[i-1, c], dp[i-1, c - wgt[i-1]] + val[i-1])
|
||||
$$
|
||||
|
||||
現在のアイテムの重量 $wgt[i - 1]$ が残りのナップサック容量 $c$ を超える場合、唯一の選択肢はナップサックに入れないことであることに注意することが重要です。
|
||||
注意すべき点として、現在の品物の重さ $wgt[i - 1]$ が残りのナップサック容量 $c$ を超える場合は、入れない選択しかできません。
|
||||
|
||||
**第3ステップ:境界条件と状態遷移の順序を決定する**
|
||||
**ステップ3:境界条件と状態遷移の順序を決める**
|
||||
|
||||
アイテムがない場合またはナップサック容量が $0$ の場合、最大値は $0$ です。つまり、最初の列 $dp[i, 0]$ と最初の行 $dp[0, c]$ はどちらも $0$ に等しいです。
|
||||
品物がない場合、またはナップサック容量が $0$ の場合、最大価値は $0$ です。すなわち、先頭列 $dp[i, 0]$ と先頭行 $dp[0, c]$ はいずれも $0$ になります。
|
||||
|
||||
現在の状態 $[i, c]$ は直接上の状態 $[i-1, c]$ と左上の状態 $[i-1, c-wgt[i-1]]$ から遷移するため、2層のループを通じて $dp$ テーブル全体を順序通りに走査します。
|
||||
現在の状態 $[i, c]$ は、上側の状態 $[i-1, c]$ と左上の状態 $[i-1, c-wgt[i-1]]$ から遷移してくるため、2重ループで $dp$ テーブル全体を順方向に走査すれば十分です。
|
||||
|
||||
上記の分析に従って、次に力任せ探索、メモ化探索、動的プログラミングの順序で解法を実装します。
|
||||
以上の分析に基づき、次に全探索、メモ化探索、動的計画法の順で実装していきます。
|
||||
|
||||
### 方法1:力任せ探索
|
||||
### 方法1:全探索
|
||||
|
||||
探索コードには以下の要素が含まれます。
|
||||
探索コードには次の要素が含まれます。
|
||||
|
||||
- **再帰パラメータ**:状態 $[i, c]$。
|
||||
- **戻り値**:部分問題 $dp[i, c]$ の解。
|
||||
- **終了条件**:アイテム番号が範囲外 $i = 0$ またはナップサックの残り容量が $0$ のとき、再帰を終了し値 $0$ を返す。
|
||||
- **枝刈り**:現在のアイテムの重量がナップサックの残り容量を超える場合、唯一の選択肢はナップサックに入れないことです。
|
||||
- **再帰引数**:状態 $[i, c]$ です。
|
||||
- **戻り値**:部分問題の解 $dp[i, c]$ です。
|
||||
- **終了条件**:品物番号が範囲外である $i = 0$、またはナップサックの残り容量が $0$ のとき、再帰を終了して価値 $0$ を返します。
|
||||
- **枝刈り**:現在の品物の重さがナップサックの残り容量を超える場合、入れない選択しかできません。
|
||||
|
||||
```src
|
||||
[file]{knapsack}-[class]{}-[func]{knapsack_dfs}
|
||||
```
|
||||
|
||||
下の図に示すように、各アイテムは選択しないと選択するという2つの探索分岐を生成するため、時間計算量は $O(2^n)$ です。
|
||||
以下の図のように、各品物ごとに「選ばない」「選ぶ」の2つの探索分岐が生じるため、時間計算量は $O(2^n)$ です。
|
||||
|
||||
再帰木を観察すると、$dp[1, 10]$ などの重複する部分問題があることが容易にわかります。アイテムが多く、ナップサック容量が大きい場合、特に同じ重量のアイテムが多い場合、重複する部分問題の数は大幅に増加します。
|
||||
再帰木を観察すると、$dp[1, 10]$ などの重複部分問題が存在することが分かります。品物数が多く、ナップサック容量が大きく、特に同じ重さの品物が多い場合には、重複部分問題の数は大幅に増加します。
|
||||
|
||||

|
||||

|
||||
|
||||
### 方法2:メモ化探索
|
||||
|
||||
重複する部分問題が一度だけ計算されることを確保するために、部分問題の解を記録するメモ化リスト `mem` を使用します。ここで `mem[i][c]` は $dp[i, c]$ に対応します。
|
||||
重複部分問題が一度だけ計算されるようにするため、メモ配列 `mem` を用いて部分問題の解を記録します。ここで `mem[i][c]` は $dp[i, c]$ に対応します。
|
||||
|
||||
メモ化を導入した後、**時間計算量は部分問題の数に依存**し、$O(n \times cap)$ になります。実装コードは以下の通りです:
|
||||
メモ化を導入すると、**時間計算量は部分問題の数に依存し**、すなわち $O(n \times cap)$ になります。実装コードは次のとおりです。
|
||||
|
||||
```src
|
||||
[file]{knapsack}-[class]{}-[func]{knapsack_dfs_mem}
|
||||
```
|
||||
|
||||
下の図はメモ化探索で枝刈りされる探索分岐を示しています。
|
||||
次の図は、メモ化探索で剪定された探索分岐を示しています。
|
||||
|
||||

|
||||

|
||||
|
||||
### 方法3:動的プログラミング
|
||||
### 方法3:動的計画法
|
||||
|
||||
動的プログラミングは本質的に状態遷移中に $dp$ テーブルを埋めることを含みます。コードは下の図に示されています:
|
||||
動的計画法の本質は、状態遷移に従って $dp$ テーブルを埋めていく過程です。コードは次のようになります。
|
||||
|
||||
```src
|
||||
[file]{knapsack}-[class]{}-[func]{knapsack_dp}
|
||||
```
|
||||
|
||||
下の図に示すように、時間計算量と空間計算量の両方が配列 `dp` のサイズ、つまり $O(n \times cap)$ によって決定されます。
|
||||
以下の図のように、時間計算量と空間計算量はいずれも配列 `dp` のサイズによって決まり、$O(n \times cap)$ です。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -134,17 +134,17 @@ $$
|
||||
|
||||
### 空間最適化
|
||||
|
||||
各状態は上の行の状態のみに関連するため、2つの配列を使用してローリング前進させ、空間計算量を $O(n^2)$ から $O(n)$ に削減できます。
|
||||
各状態は直前の行の状態にしか依存しないため、2つの配列をローテーションして用いることで、空間計算量を $O(n^2)$ から $O(n)$ に削減できます。
|
||||
|
||||
さらに考えてみると、1つの配列だけで空間最適化を達成できるでしょうか?各状態が直接上のセルまたは左上のセルから遷移することが観察できます。配列が1つしかない場合、$i$ 行目の走査を開始するとき、その配列はまだ $i-1$ 行目の状態を保存しています。
|
||||
さらに考えると、1つの配列だけで空間最適化を実現できるでしょうか。観察すると、各状態は真上または左上のマスから遷移してきます。配列が1つしかないと仮定すると、$i$ 行目の走査を開始した時点では、その配列にはまだ $i-1$ 行目の状態が格納されています。
|
||||
|
||||
- 通常の順序で走査する場合、$dp[i, j]$ に走査したとき、左上の $dp[i-1, 1]$ ~ $dp[i-1, j-1]$ の値がすでに上書きされている可能性があり、正しい状態遷移結果を得ることができません。
|
||||
- 逆順で走査する場合、上書き問題はなく、状態遷移を正しく実行できます。
|
||||
- 順方向に走査すると、$dp[i, j]$ に到達した時点で、左上にある $dp[i-1, 1]$ ~ $dp[i-1, j-1]$ の値がすでに上書きされている可能性があり、正しい状態遷移結果を得られません。
|
||||
- 逆方向に走査すれば、上書きの問題は発生せず、状態遷移を正しく行えます。
|
||||
|
||||
下の図は単一配列での $i = 1$ 行目から $i = 2$ 行目への遷移プロセスを示しています。通常順序走査と逆順走査の違いについて考えてみてください。
|
||||
次の図は、単一配列のもとで $i = 1$ 行目から $i = 2$ 行目へ変換する過程を示しています。順方向走査と逆方向走査の違いを考えてみてください。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -161,7 +161,7 @@ $$
|
||||
=== "<6>"
|
||||

|
||||
|
||||
コード実装では、配列 `dp` の最初の次元 $i$ を削除し、内側のループを逆走査に変更するだけです:
|
||||
コード実装では、配列 `dp` の第1次元 $i$ をそのまま削除し、内側のループを逆方向走査に変更するだけで済みます。
|
||||
|
||||
```src
|
||||
[file]{knapsack}-[class]{}-[func]{knapsack_dp_comp}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
# まとめ
|
||||
|
||||
- 動的プログラミングは問題を分解し、部分問題の解を保存することで冗長な計算を避け、計算効率を向上させます。
|
||||
- 時間を考慮しなければ、すべての動的プログラミング問題はバックトラッキング(力任せ探索)を使用して解決できますが、再帰木には多くの重複する部分問題があり、効率が非常に低くなります。記憶化リストを導入することで、計算されたすべての部分問題の解を保存し、重複する部分問題が一度だけ計算されることを保証できます。
|
||||
- 記憶化探索はトップダウンの再帰解法であり、動的プログラミングはボトムアップの反復アプローチに対応し、「表を埋める」ことに似ています。現在の状態は特定の局所状態のみに依存するため、dpテーブルの1次元を削除して空間計算量を削減できます。
|
||||
- 部分問題の分解は汎用的なアルゴリズムアプローチであり、分割統治法、動的プログラミング、バックトラッキングで特徴が異なります。
|
||||
- 動的プログラミング問題には3つの主要な特徴があります:重複する部分問題、最適部分構造、無記憶性。
|
||||
- 元の問題の最適解がその部分問題の最適解から構築できる場合、最適部分構造を持ちます。
|
||||
- 無記憶性とは、状態の将来の発展が現在の状態のみに依存し、過去に経験したすべての状態に依存しないことを意味します。多くの組み合わせ最適化問題にはこの特性がなく、動的プログラミングを使用して迅速に解決することはできません。
|
||||
### 要点の振り返り
|
||||
|
||||
- 動的計画法は問題を分解し、部分問題の解を保存することで重複計算を避け、計算効率を高めます。
|
||||
- 時間を考慮しなければ、すべての動的計画法の問題はバックトラッキング(総当たり探索)で解けますが、再帰木には大量の重複部分問題が存在するため、効率はきわめて低くなります。メモ化配列を導入すると、計算済みのすべての部分問題の解を保存でき、重複部分問題が 1 回だけ計算されることを保証できます。
|
||||
- メモ化探索はトップダウンの再帰的解法であり、それに対応する動的計画法はボトムアップの漸化式による解法で、ちょうど「表を埋める」ようなものです。現在の状態は一部の局所状態にのみ依存するため、$dp$ 表の 1 次元を削減して空間計算量を下げることができます。
|
||||
- 部分問題への分解は汎用的なアルゴリズムの考え方であり、分割統治、動的計画法、バックトラッキングではそれぞれ異なる性質を持ちます。
|
||||
- 動的計画法の問題には 3 つの大きな特徴があります。重複部分問題、最適部分構造、無後効性です。
|
||||
- 元の問題の最適解が部分問題の最適解から構築できるなら、その問題は最適部分構造を持ちます。
|
||||
- 無後効性とは、ある状態の将来の発展がその状態のみに関係し、過去に経たすべての状態とは無関係であることを指します。多くの組合せ最適化問題は無後効性を持たず、動的計画法で高速に解くことはできません。
|
||||
|
||||
**ナップサック問題**
|
||||
|
||||
- ナップサック問題は最も典型的な動的プログラミング問題の1つで、0-1ナップサック、無制限ナップサック、複数ナップサックなどの変種があります。
|
||||
- 0-1ナップサックの状態定義は、最初の $i$ 個のアイテムを含む容量 $c$ のナップサックでの最大値です。アイテムをナップサックに入れないまたは入れるという決定に基づいて、最適部分構造を特定し、状態遷移方程式を構築できます。空間最適化では、各状態が直接上と左上の状態に依存するため、左上の状態の上書きを避けるためにリストを逆順で走査する必要があります。
|
||||
- 無制限ナップサック問題では、各種類のアイテムを選択できる数に制限がないため、アイテムを含める状態遷移は0-1ナップサックと異なります。状態が直接上と左の状態に依存するため、空間最適化では前方走査を含める必要があります。
|
||||
- コイン交換問題は無制限ナップサック問題の変種で、「最大」値を求めることから「最小」コイン数を求めることに変わり、状態遷移方程式は $\max()$ を $\min()$ に変更する必要があります。ナップサックの容量を「超えない」ことを追求することから、正確に目標金額を求めることに変わり、「目標金額を構成できない」無効解を表すために $amt + 1$ を使用します。
|
||||
- コイン交換問題IIは「最小コイン数」を求めることから「コインの組み合わせ数」を求めることに変わり、状態遷移方程式を $\min()$ から和算演算子に変更します。
|
||||
- ナップサック問題は最も典型的な動的計画法の問題の 1 つであり、0-1 ナップサック、完全ナップサック、多重ナップサックなどの派生があります。
|
||||
- 0-1 ナップサックの状態は、容量 $c$ のナップサックに対して、前 $i$ 個の品物で得られる最大価値として定義されます。ナップサックに入れない場合と入れる場合の 2 つの判断から最適部分構造を得て、状態遷移方程式を構築できます。空間最適化では、各状態が真上と左上の状態に依存するため、左上の状態が上書きされるのを避けるために配列を逆順に走査する必要があります。
|
||||
- 完全ナップサック問題では各品物の選択数に制限がないため、品物を入れる場合の状態遷移は 0-1 ナップサック問題とは異なります。状態は真上と真左の状態に依存するので、空間最適化では順方向に走査するべきです。
|
||||
- コイン両替問題は完全ナップサック問題の変種です。「最大」価値を求める問題から「最小」の硬貨枚数を求める問題へ変わるため、状態遷移方程式の $\max()$ は $\min()$ に置き換える必要があります。また、ナップサック容量を「超えない」ことを目指すのではなく、目標金額を「ちょうど」作ることを目指すため、$amt + 1$ を「目標金額を作れない」無効解の表現として用います。
|
||||
- コイン両替問題 II では、「最少硬貨枚数」を求める問題から「硬貨の組合せ数」を求める問題へ変わるため、状態遷移方程式も $\min()$ から総和演算子へ対応して変わります。
|
||||
|
||||
**編集距離問題**
|
||||
|
||||
- 編集距離(レーベンシュタイン距離)は2つの文字列間の類似度を測定し、一つの文字列を別の文字列に変更するために必要な最小編集ステップ数として定義され、編集操作には追加、削除、置換が含まれます。
|
||||
- 編集距離問題の状態定義は、$s$ の最初の $i$ 文字を $t$ の最初の $j$ 文字に変更するために必要な最小編集ステップ数です。$s[i] \ne t[j]$ の場合、追加、削除、置換の3つの決定があり、それぞれに対応する残余部分問題があります。これから最適部分構造を特定し、状態遷移方程式を構築できます。$s[i] = t[j]$ の場合、現在の文字の編集は必要ありません。
|
||||
- 編集距離では、状態が直接上、左、左上の状態に依存します。したがって、空間最適化後、前方走査も逆走査も正しく状態遷移を実行できません。これに対処するため、変数を使用して左上の状態を一時的に保存し、無制限ナップサック問題の状況と同等にし、空間最適化後に前方走査を可能にします。
|
||||
- 編集距離(Levenshtein 距離)は 2 つの文字列間の類似度を測るために用いられ、ある文字列を別の文字列へ変換するための最小編集回数として定義されます。編集操作には追加、削除、置換が含まれます。
|
||||
- 編集距離問題の状態は、$s$ の前 $i$ 文字を $t$ の前 $j$ 文字へ変更するのに必要な最小編集回数として定義されます。$s[i] \ne t[j]$ のときは、追加、削除、置換の 3 つの判断があり、それぞれに対応する残りの部分問題があります。これにより最適部分構造を見いだし、状態遷移方程式を構築できます。一方、$s[i] = t[j]$ のときは現在の文字を編集する必要はありません。
|
||||
- 編集距離では、状態は真上、真左、左上の状態に依存します。そのため、空間最適化後は順方向でも逆方向でも正しく状態遷移できません。そこで、変数を 1 つ用いて左上の状態を一時保存し、完全ナップサック問題と等価な形へ変換することで、空間最適化後に順方向走査を行えるようにします。
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
# 無制限ナップサック問題
|
||||
# 完全ナップサック問題
|
||||
|
||||
このセクションでは、まず別の一般的なナップサック問題である無制限ナップサックを解決し、次にその特殊ケースであるコイン交換問題を探索します。
|
||||
本節では、まずもう 1 つの代表的なナップサック問題である完全ナップサック問題を解き、その特殊例である硬貨交換問題について見ていきます。
|
||||
|
||||
## 無制限ナップサック問題
|
||||
## 完全ナップサック問題
|
||||
|
||||
!!! question
|
||||
|
||||
$n$ 個のアイテムが与えられ、$i$ 番目のアイテムの重量は $wgt[i-1]$ で値は $val[i-1]$ です。容量が $cap$ のバックパックがあります。**各アイテムは複数回選択できます**。容量を超えることなくバックパックに入れることができるアイテムの最大値は何ですか?以下の例を参照してください。
|
||||
$n$ 個の品物が与えられ、$i$ 番目の品物の重さは $wgt[i-1]$、価値は $val[i-1]$ であり、容量 $cap$ のナップサックがあります。**各品物は繰り返し選択できます**。ナップサック容量の制約下で入れられる品物の最大価値を求めてください。例を以下の図に示します。
|
||||
|
||||

|
||||

|
||||
|
||||
### 動的プログラミングアプローチ
|
||||
### 動的計画法の考え方
|
||||
|
||||
無制限ナップサック問題は0-1ナップサック問題と非常に似ており、**唯一の違いはアイテムを選択できる回数に制限がないことです**。
|
||||
完全ナップサック問題は 0-1 ナップサック問題と非常によく似ています。**違いは、品物の選択回数に制限がない点だけです**。
|
||||
|
||||
- 0-1ナップサック問題では、各アイテムは1つしかないため、アイテム $i$ をバックパックに入れた後は、前の $i-1$ 個のアイテムからのみ選択できます。
|
||||
- 無制限ナップサック問題では、各アイテムの数量は無制限であるため、アイテム $i$ をバックパックに入れた後も、**前の $i$ 個のアイテムから引き続き選択できます**。
|
||||
- 0-1 ナップサック問題では、各品物は 1 つしかないため、品物 $i$ をナップサックに入れた後は先頭 $i-1$ 個の品物からしか選べません。
|
||||
- 完全ナップサック問題では、各品物の数は無限であるため、品物 $i$ をナップサックに入れた後も、**引き続き先頭 $i$ 個の品物から選べます**。
|
||||
|
||||
無制限ナップサック問題のルールの下で、状態 $[i, c]$ は2つの方法で変化できます。
|
||||
完全ナップサック問題では、状態 $[i, c]$ の変化は 2 つの場合に分けられます。
|
||||
|
||||
- **アイテム $i$ を入れない**:0-1ナップサック問題と同様に、$[i-1, c]$ に遷移します。
|
||||
- **アイテム $i$ を入れる**:0-1ナップサック問題とは異なり、$[i, c-wgt[i-1]]$ に遷移します。
|
||||
- **品物 $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])
|
||||
@@ -30,7 +30,7 @@ $$
|
||||
|
||||
### コード実装
|
||||
|
||||
2つの問題のコードを比較すると、状態遷移が $i-1$ から $i$ に変わり、残りは完全に同一です:
|
||||
2 つの問題のコードを比較すると、状態遷移の中で 1 か所だけ $i-1$ が $i$ に変わり、それ以外は完全に同じです。
|
||||
|
||||
```src
|
||||
[file]{unbounded_knapsack}-[class]{}-[func]{unbounded_knapsack_dp}
|
||||
@@ -38,12 +38,12 @@ $$
|
||||
|
||||
### 空間最適化
|
||||
|
||||
現在の状態は左と上の状態から来るため、**空間最適化解法は $dp$ テーブルの各行に対して前方走査を実行する必要があります**。
|
||||
現在の状態は左側と上側の状態から遷移してくるため、**空間最適化後は $dp$ テーブルの各行を順方向に走査する必要があります**。
|
||||
|
||||
この走査順序は0-1ナップサックの場合とは逆です。違いを理解するために下の図を参照してください。
|
||||
この走査順序は 0-1 ナップサックとはちょうど逆です。両者の違いは次の図を用いて理解してください。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -60,67 +60,67 @@ $$
|
||||
=== "<6>"
|
||||

|
||||
|
||||
コード実装は非常に簡単で、配列 `dp` の最初の次元を削除するだけです:
|
||||
コード実装は比較的簡単で、配列 `dp` の第 1 次元を削除するだけです。
|
||||
|
||||
```src
|
||||
[file]{unbounded_knapsack}-[class]{}-[func]{unbounded_knapsack_dp_comp}
|
||||
```
|
||||
|
||||
## コイン交換問題
|
||||
## 硬貨交換問題
|
||||
|
||||
ナップサック問題は動的プログラミング問題の大きなクラスの代表であり、コイン交換問題など多くの変種があります。
|
||||
ナップサック問題は動的計画法の代表的な問題群であり、多くの派生問題があります。硬貨交換問題もその 1 つです。
|
||||
|
||||
!!! question
|
||||
|
||||
$n$ 種類のコインが与えられ、$i$ 番目の種類のコインの額面は $coins[i - 1]$ で、目標金額は $amt$ です。**各種類のコインは複数回選択できます**。目標金額を構成するのに必要な最小コイン数は何ですか?目標金額を構成できない場合は $-1$ を返してください。以下の例を参照してください。
|
||||
$n$ 種類の硬貨が与えられ、$i$ 番目の硬貨の額面は $coins[i - 1]$ 、目標金額は $amt$ です。**各硬貨は繰り返し選択できます**。目標金額を作るために必要な最小の硬貨枚数を求めてください。目標金額を作れない場合は $-1$ を返します。例を以下の図に示します。
|
||||
|
||||

|
||||

|
||||
|
||||
### 動的プログラミングアプローチ
|
||||
### 動的計画法の考え方
|
||||
|
||||
**コイン交換は無制限ナップサック問題の特殊ケースと見なすことができ**、以下の類似点と相違点を共有しています。
|
||||
**硬貨交換は完全ナップサック問題の特殊なケースとみなせます**。両者には次の対応関係と相違点があります。
|
||||
|
||||
- 2つの問題は互いに変換できます:「アイテム」は「コイン」に対応し、「アイテムの重量」は「コインの額面」に対応し、「バックパックの容量」は「目標金額」に対応します。
|
||||
- 最適化目標は逆です:無制限ナップサック問題はアイテムの値を最大化することを目的とし、コイン交換問題はコインの数を最小化することを目的とします。
|
||||
- 無制限ナップサック問題はバックパック容量を「超えない」解を求め、コイン交換は目標金額を「正確に」構成する解を求めます。
|
||||
- 2 つの問題は相互に変換でき、「品物」は「硬貨」、「品物の重さ」は「硬貨の額面」、「ナップサック容量」は「目標金額」に対応します。
|
||||
- 最適化の目標は逆であり、完全ナップサック問題は品物価値の最大化、硬貨交換問題は硬貨枚数の最小化を目指します。
|
||||
- 完全ナップサック問題はナップサック容量を「超えない」解を求めますが、硬貨交換は目標金額に「ちょうど」一致する解を求めます。
|
||||
|
||||
**第1ステップ:各ラウンドの意思決定を考え、状態を定義し、それにより $dp$ テーブルを導出する**
|
||||
**ステップ 1:各ラウンドの選択を考え、状態を定義して、$dp$ テーブルを得る**
|
||||
|
||||
状態 $[i, a]$ は部分問題に対応します:**最初の $i$ 種類のコインを使用して金額 $a$ を構成できる最小コイン数**、$dp[i, a]$ と表記されます。
|
||||
状態 $[i, a]$ に対応する部分問題は、**先頭 $i$ 種類の硬貨で金額 $a$ を作るための最小硬貨枚数**であり、これを $dp[i, a]$ と表します。
|
||||
|
||||
二次元 $dp$ テーブルのサイズは $(n+1) \times (amt+1)$ です。
|
||||
2 次元 $dp$ テーブルのサイズは $(n+1) \times (amt+1)$ です。
|
||||
|
||||
**第2ステップ:最適部分構造を特定し、状態遷移方程式を導出する**
|
||||
**ステップ 2:最適部分構造を見つけ、状態遷移方程式を導く**
|
||||
|
||||
この問題は状態遷移方程式の2つの側面で無制限ナップサック問題と異なります。
|
||||
本問の状態遷移方程式は、完全ナップサック問題と比べて次の 2 点が異なります。
|
||||
|
||||
- この問題は最小値を求めるため、演算子 $\max()$ を $\min()$ に変更する必要があります。
|
||||
- 最適化はコインの数に焦点を当てているため、コインが選択されたときに単純に $+1$ を追加します。
|
||||
- 本問では最小値を求めるため、演算子 $\max()$ を $\min()$ に変更する必要があります。
|
||||
- 最適化の対象は品物価値ではなく硬貨枚数であるため、硬貨を選んだときは $+1$ すれば十分です。
|
||||
|
||||
$$
|
||||
dp[i, a] = \min(dp[i-1, a], dp[i, a - coins[i-1]] + 1)
|
||||
$$
|
||||
|
||||
**第3ステップ:境界条件と状態遷移順序を定義する**
|
||||
**ステップ 3:境界条件と状態遷移順序を決める**
|
||||
|
||||
目標金額が $0$ の場合、それを構成するのに必要な最小コイン数は $0$ であるため、最初の列のすべての $dp[i, 0]$ は $0$ です。
|
||||
目標金額が $0$ のとき、それを作るための最小硬貨枚数は $0$ です。つまり、先頭列のすべての $dp[i, 0]$ は $0$ になります。
|
||||
|
||||
コインがない場合、**任意の金額 >0 を構成することは不可能**であり、これは無効な解です。状態遷移方程式の $\min()$ 関数が無効な解を認識してフィルタリングできるように、$+\infty$ を使用してそれらを表現することを検討し、つまり最初の行のすべての $dp[0, a]$ を $+\infty$ に設定します。
|
||||
硬貨が 1 枚もない場合、**任意の $> 0$ の目標金額を作ることはできません**。これは無効解です。状態遷移方程式内の $\min()$ 関数が無効解を識別して除外できるように、それらを $+ \infty$ で表すことを考えます。すなわち、先頭行のすべての $dp[0, a]$ を $+ \infty$ とします。
|
||||
|
||||
### コード実装
|
||||
|
||||
ほとんどのプログラミング言語は $+\infty$ 変数を提供しておらず、整数 `int` の最大値のみを代替として使用できます。これによりオーバーフローが発生する可能性があります:状態遷移方程式の $+1$ 演算がオーバーフローする可能性があります。
|
||||
多くのプログラミング言語には $+ \infty$ を表す変数が用意されていないため、通常は整数型 `int` の最大値で代用します。しかし、その場合は大きな数のオーバーフローが起こり得ます。状態遷移方程式中の $+ 1$ 操作で桁あふれが発生する可能性があるためです。
|
||||
|
||||
この理由で、数値 $amt + 1$ を使用して無効な解を表します。なぜなら、$amt$ を構成するのに必要な最大コイン数は最大でも $amt$ だからです。結果を返す前に、$dp[n, amt]$ が $amt + 1$ に等しいかどうかを確認し、そうであれば $-1$ を返し、目標金額を構成できないことを示します。コードは以下の通りです:
|
||||
そのため、ここでは数値 $amt + 1$ を無効解の表現として用います。金額 $amt$ を作るための硬貨枚数は最大でも $amt$ 枚だからです。最後に返す前に、$dp[n, amt]$ が $amt + 1$ に等しいかを判定し、等しければ $-1$ を返して目標金額を作れないことを表します。コードは次のとおりです。
|
||||
|
||||
```src
|
||||
[file]{coin_change}-[class]{}-[func]{coin_change_dp}
|
||||
```
|
||||
|
||||
下の図はコイン交換問題の動的プログラミングプロセスを示しており、無制限ナップサック問題と非常に似ています。
|
||||
次の図は硬貨交換の動的計画法の過程を示しており、完全ナップサック問題と非常によく似ています。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -166,31 +166,31 @@ $$
|
||||
|
||||
### 空間最適化
|
||||
|
||||
コイン交換問題の空間最適化は無制限ナップサック問題と同じ方法で処理されます:
|
||||
硬貨交換の空間最適化の方法は、完全ナップサック問題と同じです。
|
||||
|
||||
```src
|
||||
[file]{coin_change}-[class]{}-[func]{coin_change_dp_comp}
|
||||
```
|
||||
|
||||
## コイン交換問題II
|
||||
## 硬貨交換問題 II
|
||||
|
||||
!!! question
|
||||
|
||||
$n$ 種類のコインが与えられ、$i$ 番目の種類のコインの額面は $coins[i - 1]$ で、目標金額は $amt$ です。各種類のコインは複数回選択でき、**目標金額を構成できるコインの組み合わせは何通りありますか**。以下の例を参照してください。
|
||||
$n$ 種類の硬貨が与えられ、$i$ 番目の硬貨の額面は $coins[i - 1]$ 、目標金額は $amt$ です。各硬貨は繰り返し選択できるとして、**目標金額を作る硬貨の組合せ数**を求めてください。例を以下の図に示します。
|
||||
|
||||

|
||||

|
||||
|
||||
### 動的プログラミングアプローチ
|
||||
### 動的計画法の考え方
|
||||
|
||||
前の問題と比較して、この問題の目標は組み合わせの数を決定することであるため、部分問題は次のようになります:**最初の $i$ 種類のコインを使用して金額 $a$ を構成できる組み合わせの数**。$dp$ テーブルはサイズ $(n+1) \times (amt + 1)$ の二次元行列のまま残ります。
|
||||
前問と比べて、本問の目的は組合せ数を求めることです。そのため、部分問題は **先頭 $i$ 種類の硬貨で金額 $a$ を作れる組合せ数** になります。一方、$dp$ テーブルは引き続きサイズ $(n+1) \times (amt + 1)$ の 2 次元行列です。
|
||||
|
||||
現在の状態の組み合わせ数は、現在のコインを選択しない組み合わせと現在のコインを選択する組み合わせの合計です。状態遷移方程式は:
|
||||
現在の状態における組合せ数は、現在の硬貨を選ばない場合と選ぶ場合の 2 つの選択肢の組合せ数の和に等しくなります。状態遷移方程式は次のとおりです。
|
||||
|
||||
$$
|
||||
dp[i, a] = dp[i-1, a] + dp[i, a - coins[i-1]]
|
||||
$$
|
||||
|
||||
目標金額が $0$ の場合、目標金額を構成するのにコインは必要ないため、最初の列のすべての $dp[i, 0]$ は $1$ に初期化されるべきです。コインがない場合、任意の金額 >0 を構成することは不可能であるため、最初の行のすべての $dp[0, a]$ は $0$ に設定されるべきです。
|
||||
目標金額が $0$ のときは、どの硬貨も選ばなくても目標金額を作れるため、先頭列のすべての $dp[i, 0]$ を $1$ に初期化します。硬貨がないときは、任意の $>0$ の目標金額を作れないため、先頭行のすべての $dp[0, a]$ は $0$ になります。
|
||||
|
||||
### コード実装
|
||||
|
||||
@@ -200,7 +200,7 @@ $$
|
||||
|
||||
### 空間最適化
|
||||
|
||||
空間最適化アプローチは同じで、コインの次元を削除するだけです:
|
||||
空間最適化の方法も同様で、硬貨の次元を削除するだけです。
|
||||
|
||||
```src
|
||||
[file]{coin_change_ii}-[class]{}-[func]{coin_change_ii_dp_comp}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# グラフ
|
||||
|
||||
<u>グラフ</u>は非線形データ構造の一種で、<u>頂点</u>と<u>辺</u>で構成されます。グラフ$G$は、頂点の集合$V$と辺の集合$E$の組み合わせとして抽象的に表現できます。以下の例は、5つの頂点と7つの辺を含むグラフを示しています。
|
||||
<u>グラフ(graph)</u>は、<u>頂点(vertex)</u>と<u>辺(edge)</u>から構成される非線形データ構造です。グラフ $G$ は、頂点集合 $V$ と辺集合 $E$ からなる集合として抽象的に表せます。以下の例は、5 個の頂点と 7 本の辺を含むグラフを示しています。
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
@@ -10,74 +10,74 @@ G & = \{ V, E \} \newline
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
頂点をノード、辺をノードを接続する参照(ポインタ)と見なすと、グラフは連結リストから拡張されたデータ構造として見ることができます。下図に示すように、**線形関係(連結リスト)や分割統治関係(木)と比較して、ネットワーク関係(グラフ)は自由度が高いため、より複雑です**。
|
||||
頂点をノード、辺を各ノードをつなぐ参照(ポインタ)とみなせば、グラフは連結リストを拡張したデータ構造の一種と捉えられます。次の図に示すように、**線形関係(連結リスト)や分治関係(木)と比べて、ネットワーク関係(グラフ)は自由度が高く**、そのぶん複雑です。
|
||||
|
||||

|
||||
|
||||
## グラフの一般的な種類と用語
|
||||
|
||||
グラフは、辺に方向があるかどうかによって<u>無向グラフ</u>と<u>有向グラフ</u>に分けることができます(下図参照)。
|
||||
辺が方向性を持つかどうかに応じて、<u>無向グラフ(undirected graph)</u>と<u>有向グラフ(directed graph)</u>に分けられます。次の図のとおりです。
|
||||
|
||||
- 無向グラフでは、辺は2つの頂点間の「双方向」接続を表します。例えば、Facebookの「友達」関係です。
|
||||
- 有向グラフでは、辺に方向性があります。つまり、辺$A \rightarrow B$と$A \leftarrow B$は互いに独立しています。例えば、InstagramやTikTokの「フォロー」と「フォロワー」の関係です。
|
||||
- 無向グラフでは、辺は 2 つの頂点間の「双方向」の接続関係を表します。例えば WeChat や QQ における「友だち関係」です。
|
||||
- 有向グラフでは、辺は方向性を持ち、すなわち $A \rightarrow B$ と $A \leftarrow B$ の 2 方向の辺は互いに独立です。例えば Weibo や Douyin における「フォロー」と「フォロワー」の関係です。
|
||||
|
||||

|
||||
|
||||
すべての頂点が接続されているかどうかによって、グラフは<u>連結グラフ</u>と<u>非連結グラフ</u>に分けることができます(下図参照)。
|
||||
すべての頂点が連結しているかどうかに応じて、<u>連結グラフ(connected graph)</u>と<u>非連結グラフ(disconnected graph)</u>に分けられます。次の図のとおりです。
|
||||
|
||||
- 連結グラフでは、任意の頂点から開始して他の任意の頂点に到達することが可能です。
|
||||
- 非連結グラフでは、任意の開始頂点から到達できない頂点が少なくとも1つ存在します。
|
||||
- 連結グラフでは、ある頂点から出発すると、ほかの任意の頂点に到達できます。
|
||||
- 非連結グラフでは、ある頂点から出発すると、少なくとも 1 つの頂点には到達できません。
|
||||
|
||||

|
||||
|
||||
辺に重み変数を追加することもでき、その結果として<u>重み付きグラフ</u>が生まれます(下図参照)。例えば、Instagramでは、システムがあなたと他のユーザーとの間の相互作用レベル(いいね、閲覧、コメントなど)によってフォロワーとフォロー中のリストをソートします。このような相互作用ネットワークは重み付きグラフで表現できます。
|
||||
辺に「重み」の変数を追加すると、次の図に示すような<u>重み付きグラフ(weighted graph)</u>が得られます。例えば『Honor of Kings』のようなモバイルゲームでは、システムが共にプレイした時間に基づいてプレイヤー間の「親密度」を計算します。この親密度ネットワークは重み付きグラフで表せます。
|
||||
|
||||

|
||||
|
||||
グラフデータ構造には、以下のような一般的に使用される用語があります。
|
||||
グラフというデータ構造には、次のような基本用語があります。
|
||||
|
||||
- <u>隣接</u>:2つの頂点を接続する辺がある場合、これら2つの頂点は「隣接」していると言われます。上図では、頂点1の隣接頂点は頂点2、3、5です。
|
||||
- <u>パス</u>:頂点Aから頂点Bまでに通過する辺のシーケンスを、AからBへのパスと呼びます。上図では、辺のシーケンス1-5-2-4は頂点1から頂点4へのパスです。
|
||||
- <u>次数</u>:頂点が持つ辺の数です。有向グラフの場合、<u>入次数</u>はその頂点を指す辺の数、<u>出次数</u>はその頂点から出る辺の数を指します。
|
||||
- <u>隣接(adjacency)</u>:2 つの頂点の間に辺が存在するとき、この 2 つの頂点は「隣接している」といいます。上図では、頂点 1 に隣接する頂点は 2、3、5 です。
|
||||
- <u>経路(path)</u>:頂点 A から頂点 B までに通過する辺で構成された列を、A から B への「経路」と呼びます。上図では、辺の列 1-5-2-4 は頂点 1 から頂点 4 への 1 本の経路です。
|
||||
- <u>次数(degree)</u>:ある頂点が持つ辺の本数です。有向グラフでは、<u>入次数(in-degree)</u>はその頂点に向かう辺の本数を表し、<u>出次数(out-degree)</u>はその頂点から出る辺の本数を表します。
|
||||
|
||||
## グラフの表現
|
||||
|
||||
グラフの一般的な表現には「隣接行列」と「隣接リスト」があります。以下の例では無向グラフを使用します。
|
||||
グラフの一般的な表現方法には「隣接行列」と「隣接リスト」があります。以下では無向グラフを例に説明します。
|
||||
|
||||
### 隣接行列
|
||||
|
||||
グラフの頂点数を$n$とすると、<u>隣接行列</u>は$n \times n$の行列を使用してグラフを表現します。各行(列)は頂点を表し、行列要素は辺を表し、2つの頂点間に辺があるかどうかを$1$または$0$で示します。
|
||||
グラフの頂点数を $n$ とすると、<u>隣接行列(adjacency matrix)</u>は $n \times n$ の行列を用いてグラフを表します。各行(列)は 1 つの頂点を表し、行列要素は辺を表します。$1$ または $0$ を用いて、2 つの頂点の間に辺があるかどうかを示します。
|
||||
|
||||
下図に示すように、隣接行列を$M$、頂点のリストを$V$とすると、行列要素$M[i, j] = 1$は頂点$V[i]$と頂点$V[j]$の間に辺があることを示し、逆に$M[i, j] = 0$は2つの頂点間に辺がないことを示します。
|
||||
次の図のように、隣接行列を $M$、頂点リストを $V$ とすると、行列要素 $M[i, j] = 1$ は頂点 $V[i]$ から頂点 $V[j]$ への辺が存在することを表し、逆に $M[i, j] = 0$ は 2 つの頂点の間に辺がないことを表します。
|
||||
|
||||

|
||||

|
||||
|
||||
隣接行列には以下の特性があります。
|
||||
隣接行列には次の特徴があります。
|
||||
|
||||
- 頂点は自分自身に接続することはできないため、隣接行列の主対角線上の要素は意味がありません。
|
||||
- 無向グラフの場合、両方向の辺は等価であるため、隣接行列は主対角線に関して対称です。
|
||||
- 隣接行列の要素を$1$と$0$から重みに置き換えることで、重み付きグラフを表現できます。
|
||||
- 単純グラフでは、頂点は自分自身とは接続できないため、このとき隣接行列の主対角線上の要素には意味がありません。
|
||||
- 無向グラフでは、2 方向の辺は等価であるため、このとき隣接行列は主対角線に関して対称です。
|
||||
- 隣接行列の要素を $1$ と $0$ から重みに置き換えると、重み付きグラフを表せます。
|
||||
|
||||
隣接行列でグラフを表現する場合、行列要素に直接アクセスして辺を取得できるため、追加、削除、検索、変更の操作が効率的で、すべて時間計算量$O(1)$です。ただし、行列の空間計算量は$O(n^2)$で、より多くのメモリを消費します。
|
||||
隣接行列でグラフを表す場合、行列要素に直接アクセスして辺を取得できるため、追加・削除・検索・更新の操作効率は高く、時間計算量はいずれも $O(1)$ です。しかし、行列の空間計算量は $O(n^2)$ であり、メモリ使用量は多くなります。
|
||||
|
||||
### 隣接リスト
|
||||
|
||||
<u>隣接リスト</u>は$n$個の連結リストを使用してグラフを表現し、各連結リストノードは頂点を表します。$i$番目の連結リストは頂点$i$に対応し、すべての隣接頂点(その頂点に接続された頂点)を含みます。下図は隣接リストを使用して格納されたグラフの例を示しています。
|
||||
<u>隣接リスト(adjacency list)</u>は、$n$ 本の連結リストを使ってグラフを表します。連結リストのノードは頂点を表します。第 $i$ 本の連結リストは頂点 $i$ に対応し、その頂点に隣接するすべての頂点(その頂点と接続された頂点)を格納します。次の図は、隣接リストで保存したグラフの例です。
|
||||
|
||||

|
||||

|
||||
|
||||
隣接リストは実際の辺のみを格納し、辺の総数は$n^2$よりもはるかに少ないことが多く、より空間効率的です。ただし、隣接リストで辺を見つけるには連結リストを走査する必要があるため、その時間効率は隣接行列ほど良くありません。
|
||||
隣接リストは実際に存在する辺だけを格納し、辺の総数は通常 $n^2$ よりはるかに小さいため、より省スペースです。しかし、隣接リストでは辺を見つけるために連結リストを走査する必要があるため、時間効率は隣接行列に及びません。
|
||||
|
||||
上図を観察すると、**隣接リストの構造はハッシュテーブルの「チェイン法」と非常に似ているため、同様の方法を使用して効率を最適化できます**。例えば、連結リストが長い場合、それをAVL木や赤黒木に変換して、時間効率を$O(n)$から$O(\log n)$に最適化できます。連結リストをハッシュテーブルに変換することで、時間計算量を$O(1)$に削減することもできます。
|
||||
上図を見ると、**隣接リストの構造はハッシュテーブルにおける「連鎖アドレス法」と非常によく似ているため、同様の方法で効率を最適化できます**。例えば、連結リストが長い場合は AVL 木や赤黒木に変換して時間効率を $O(n)$ から $O(\log n)$ に改善できます。さらに、連結リストをハッシュテーブルに変換すれば、時間計算量を $O(1)$ まで下げられます。
|
||||
|
||||
## グラフの一般的な応用
|
||||
|
||||
下表に示すように、多くの現実世界のシステムはグラフでモデル化でき、対応する問題はグラフ計算問題に削減できます。
|
||||
次の表のように、多くの現実のシステムはグラフでモデル化でき、対応する問題もグラフ計算の問題に帰着できます。
|
||||
|
||||
<p align="center"> 表 <id> 現実生活の一般的なグラフ </p>
|
||||
<p align="center"> 表 <id> 現実世界でよく見られるグラフ </p>
|
||||
|
||||
| | 頂点 | 辺 | グラフ計算問題 |
|
||||
| -------------- | -------------- | -------------------------------- | --------------------------- |
|
||||
| ソーシャルネットワーク | ユーザー | フォロー / フォロワー関係 | 潜在的フォロー推薦 |
|
||||
| 地下鉄路線 | 駅 | 駅間の接続性 | 最短ルート推薦 |
|
||||
| 太陽系 | 天体 | 天体間の重力 | 惑星軌道計算 |
|
||||
| | 頂点 | 辺 | グラフ計算問題 |
|
||||
| -------- | ---- | -------------------- | ------------ |
|
||||
| ソーシャルネットワーク | ユーザー | 友だち関係 | 潜在的な友だちの推薦 |
|
||||
| 地下鉄路線 | 駅 | 駅間の接続性 | 最短経路の推薦 |
|
||||
| 太陽系 | 天体 | 天体間の万有引力作用 | 惑星軌道の計算 |
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
# グラフの基本操作
|
||||
|
||||
グラフの基本操作は「辺」に対する操作と「頂点」に対する操作に分けることができます。「隣接行列」と「隣接リスト」の2つの表現方法の下では、実装が異なります。
|
||||
グラフの基本操作は、「辺」に対する操作と「頂点」に対する操作に分けられます。「隣接行列」と「隣接リスト」の 2 つの表現方法では、実装方法が異なります。
|
||||
|
||||
## 隣接行列に基づく実装
|
||||
|
||||
$n$個の頂点を持つ無向グラフが与えられた場合、さまざまな操作は下図のように実装されます。
|
||||
頂点数が $n$ の無向グラフを与えると、各種操作の実装方法は次図のとおりです。
|
||||
|
||||
- **辺の追加または削除**:隣接行列内の指定された辺を直接変更し、$O(1)$時間を使用します。無向グラフであるため、両方向の辺を同時に更新する必要があります。
|
||||
- **頂点の追加**:隣接行列の末尾に行と列を追加し、すべて$0$で埋めます。$O(n)$時間を使用します。
|
||||
- **頂点の削除**:隣接行列内の行と列を削除します。最悪の場合は最初の行と列が削除されるときで、$(n-1)^2$個の要素を「上と左に移動」する必要があり、$O(n^2)$時間を使用します。
|
||||
- **初期化**:$n$個の頂点を渡し、長さ$n$の頂点リスト`vertices`を初期化し、$O(n)$時間を使用します。$n \times n$サイズの隣接行列`adjMat`を初期化し、$O(n^2)$時間を使用します。
|
||||
- **辺の追加または削除**:隣接行列で指定した辺を直接変更すればよく、$O(1)$ 時間です。無向グラフであるため、2 方向の辺を同時に更新する必要があります。
|
||||
- **頂点の追加**:隣接行列の末尾に 1 行 1 列を追加し、すべてを $0$ で埋めればよく、$O(n)$ 時間です。
|
||||
- **頂点の削除**:隣接行列から 1 行 1 列を削除します。先頭行と先頭列を削除する場合が最悪で、$(n-1)^2$ 個の要素を「左上へ移動」させる必要があるため、$O(n^2)$ 時間です。
|
||||
- **初期化**:$n$ 個の頂点を受け取り、長さ $n$ の頂点リスト `vertices` を初期化するのに $O(n)$ 時間、サイズ $n \times n$ の隣接行列 `adjMat` を初期化するのに $O(n^2)$ 時間かかります。
|
||||
|
||||
=== "隣接行列の初期化"
|
||||

|
||||

|
||||
|
||||
=== "辺の追加"
|
||||

|
||||
@@ -26,7 +26,7 @@ $n$個の頂点を持つ無向グラフが与えられた場合、さまざま
|
||||
=== "頂点の削除"
|
||||

|
||||
|
||||
以下は隣接行列を使用して表現されたグラフの実装コードです:
|
||||
以下は、隣接行列でグラフを表した実装コードです:
|
||||
|
||||
```src
|
||||
[file]{graph_adjacency_matrix}-[class]{graph_adj_mat}-[func]{}
|
||||
@@ -34,16 +34,16 @@ $n$個の頂点を持つ無向グラフが与えられた場合、さまざま
|
||||
|
||||
## 隣接リストに基づく実装
|
||||
|
||||
総計$n$個の頂点と$m$個の辺を持つ無向グラフが与えられた場合、さまざまな操作は下図のように実装できます。
|
||||
無向グラフの頂点総数を $n$、辺総数を $m$ とすると、各種操作は次図の方法で実装できます。
|
||||
|
||||
- **辺の追加**:対応する頂点の連結リストの末尾に辺を追加するだけで、$O(1)$時間を使用します。無向グラフであるため、両方向に同時に辺を追加する必要があります。
|
||||
- **辺の削除**:対応する頂点の連結リスト内で指定された辺を見つけて削除し、$O(m)$時間を使用します。無向グラフでは、両方向の辺を同時に削除する必要があります。
|
||||
- **頂点の追加**:隣接リストに連結リストを追加し、新しい頂点をリストのヘッドノードにし、$O(1)$時間を使用します。
|
||||
- **頂点の削除**:隣接リスト全体を走査し、指定された頂点を含むすべての辺を削除する必要があり、$O(n + m)$時間を使用します。
|
||||
- **初期化**:隣接リストに$n$個の頂点と$2m$個の辺を作成し、$O(n + m)$時間を使用します。
|
||||
- **辺の追加**:頂点に対応する連結リストの末尾に辺を追加すればよく、$O(1)$ 時間です。無向グラフなので、2 方向の辺を同時に追加する必要があります。
|
||||
- **辺の削除**:頂点に対応する連結リストから指定した辺を探して削除するため、$O(m)$ 時間です。無向グラフでは、2 方向の辺を同時に削除する必要があります。
|
||||
- **頂点の追加**:隣接リストに 1 つの連結リストを追加し、新しい頂点をその連結リストの先頭ノードとするため、$O(1)$ 時間です。
|
||||
- **頂点の削除**:隣接リスト全体を走査し、指定した頂点を含むすべての辺を削除する必要があるため、$O(n + m)$ 時間です。
|
||||
- **初期化**:隣接リストに $n$ 個の頂点と $2m$ 本の辺を作成するため、$O(n + m)$ 時間です。
|
||||
|
||||
=== "隣接リストの初期化"
|
||||

|
||||

|
||||
|
||||
=== "辺の追加"
|
||||

|
||||
@@ -57,12 +57,12 @@ $n$個の頂点を持つ無向グラフが与えられた場合、さまざま
|
||||
=== "頂点の削除"
|
||||

|
||||
|
||||
以下は隣接リストのコード実装です。上図と比較して、実際のコードには以下の違いがあります。
|
||||
以下は隣接リストのコード実装です。上図と比べると、実際のコードには次の違いがあります。
|
||||
|
||||
- 頂点の追加と削除の便宜、およびコードの簡素化のため、連結リストの代わりにリスト(動的配列)を使用します。
|
||||
- ハッシュテーブルを使用して隣接リストを格納し、`key`が頂点インスタンス、`value`がその頂点の隣接頂点のリスト(連結リスト)です。
|
||||
- 頂点の追加と削除を容易にし、コードを簡潔にするため、連結リストの代わりにリスト(動的配列)を使用しています。
|
||||
- ハッシュテーブルを用いて隣接リストを格納しており、`key` は頂点インスタンス、`value` はその頂点に隣接する頂点のリスト(連結リスト)です。
|
||||
|
||||
さらに、隣接リストで頂点を表現するために`Vertex`クラスを使用します。その理由は:隣接行列のようにリストインデックスを使用して異なる頂点を区別する場合、インデックス$i$の頂点を削除したい場合、隣接リスト全体を走査し、$i$より大きいすべてのインデックスを1つずつ減少させる必要があり、これは非常に非効率的です。しかし、各頂点が一意の`Vertex`インスタンスである場合、頂点を削除しても他の頂点に変更を加える必要がありません。
|
||||
また、隣接リストでは頂点を表すために `Vertex` クラスを使用しています。その理由は、もし隣接行列と同様にリストのインデックスで異なる頂点を区別すると、インデックス $i$ の頂点を削除する場合、隣接リスト全体を走査して、$i$ より大きいすべてのインデックスを $1$ 減らす必要があり、効率が非常に低いためです。これに対して、各頂点が一意な `Vertex` インスタンスであれば、ある頂点を削除しても他の頂点を変更する必要はありません。
|
||||
|
||||
```src
|
||||
[file]{graph_adjacency_list}-[class]{graph_adj_list}-[func]{}
|
||||
@@ -70,17 +70,17 @@ $n$個の頂点を持つ無向グラフが与えられた場合、さまざま
|
||||
|
||||
## 効率の比較
|
||||
|
||||
グラフに$n$個の頂点と$m$個の辺があると仮定すると、下表は隣接行列と隣接リストの時間効率と空間効率を比較しています。
|
||||
グラフに $n$ 個の頂点と $m$ 本の辺があるとすると、次の表は隣接行列と隣接リストの時間効率および空間効率を比較したものです。なお、隣接リスト(連結リスト)は本記事の実装に対応し、隣接リスト(ハッシュテーブル)はすべての連結リストをハッシュテーブルに置き換えた実装を指します。
|
||||
|
||||
<p align="center"> 表 <id> 隣接行列と隣接リストの比較 </p>
|
||||
|
||||
| | 隣接行列 | 隣接リスト(連結リスト) | 隣接リスト(ハッシュテーブル) |
|
||||
| ---------------- | -------------- | ----------------------- | ----------------------------- |
|
||||
| 隣接性の判定 | $O(1)$ | $O(m)$ | $O(1)$ |
|
||||
| 辺の追加 | $O(1)$ | $O(1)$ | $O(1)$ |
|
||||
| 辺の削除 | $O(1)$ | $O(m)$ | $O(1)$ |
|
||||
| 頂点の追加 | $O(n)$ | $O(1)$ | $O(1)$ |
|
||||
| 頂点の削除 | $O(n^2)$ | $O(n + m)$ | $O(n)$ |
|
||||
| メモリ空間使用量 | $O(n^2)$ | $O(n + m)$ | $O(n + m)$ |
|
||||
| | 隣接行列 | 隣接リスト(連結リスト) | 隣接リスト(ハッシュテーブル) |
|
||||
| ------------ | -------- | -------------- | ---------------- |
|
||||
| 隣接判定 | $O(1)$ | $O(n)$ | $O(1)$ |
|
||||
| 辺の追加 | $O(1)$ | $O(1)$ | $O(1)$ |
|
||||
| 辺の削除 | $O(1)$ | $O(n)$ | $O(1)$ |
|
||||
| 頂点の追加 | $O(n)$ | $O(1)$ | $O(1)$ |
|
||||
| 頂点の削除 | $O(n^2)$ | $O(n + m)$ | $O(n)$ |
|
||||
| メモリ使用量 | $O(n^2)$ | $O(n + m)$ | $O(n + m)$ |
|
||||
|
||||
上表を観察すると、隣接リスト(ハッシュテーブル)が最高の時間効率と空間効率を持っているように見えます。しかし、実際には、隣接行列での辺に対する操作がより効率的で、単一の配列アクセスまたは代入操作のみが必要です。全体的に、隣接行列は「空間と時間のトレードオフ」の原則を例示し、隣接リストは「時間と空間のトレードオフ」を例示しています。
|
||||
上表を見ると、隣接リスト(ハッシュテーブル)の時間効率と空間効率が最も優れているように見えます。しかし実際には、隣接行列のほうが辺の操作効率は高く、必要なのは 1 回の配列アクセスまたは代入だけです。総合的に見ると、隣接行列は「空間を時間と引き換えにする」原則を体現し、隣接リストは「時間を空間と引き換えにする」原則を体現しています。
|
||||
|
||||
@@ -1,33 +1,37 @@
|
||||
# グラフ走査
|
||||
# グラフの走査
|
||||
|
||||
木は「一対多」の関係を表現し、グラフはより高い自由度を持ち、任意の「多対多」の関係を表現できます。したがって、木をグラフの特別なケースと見なすことができます。明らかに、**木の走査操作もグラフ走査操作の特別なケースです**。
|
||||
木は「一対多」の関係を表すのに対し、グラフはより高い自由度を持ち、任意の「多対多」の関係を表現できます。したがって、木はグラフの一種の特殊な場合とみなせます。明らかに、**木の走査操作もグラフの走査操作の一種の特殊な場合です**。
|
||||
|
||||
グラフと木の両方で、走査操作を実装するために探索アルゴリズムの応用が必要です。グラフ走査は2つのタイプに分けることができます:<u>幅優先探索(BFS)</u>と<u>深さ優先探索(DFS)</u>です。
|
||||
グラフと木はいずれも、走査操作を実現するために探索アルゴリズムを用いる必要があります。グラフの走査方法も、<u>幅優先走査</u>と<u>深さ優先走査</u>の 2 種類に分けられます。
|
||||
|
||||
## 幅優先探索
|
||||
## 幅優先走査
|
||||
|
||||
**幅優先探索は近くから遠くへの走査方法で、ある頂点から開始し、常に最も近い頂点を優先的に訪問し、層ごとに外側に展開していきます**。下図に示すように、左上の頂点から開始し、まずその頂点のすべての隣接頂点を走査し、次に次の頂点のすべての隣接頂点を走査し、以下同様に、すべての頂点が訪問されるまで続けます。
|
||||
**幅優先走査は、近いところから遠いところへ向かう走査方法であり、ある頂点から出発して、常に最も近い頂点を優先して訪問し、層ごとに外側へ広がっていきます**。以下の図に示すように、左上の頂点から出発し、まずその頂点のすべての隣接頂点を走査し、次に次の頂点のすべての隣接頂点を走査し、これを繰り返して、すべての頂点を訪問するまで続けます。
|
||||
|
||||

|
||||
|
||||
### アルゴリズムの実装
|
||||
|
||||
BFSは通常キューの助けを借りて実装されます(下記のコード参照)。キューは「先入先出」で、これは「近くから遠くへ」走査するBFSの考え方と一致します。
|
||||
BFS は通常キューを用いて実装され、コードは以下のとおりです。キューは「先入れ先出し」という性質を持ち、これは BFS の「近いところから遠いところへ」という考え方と本質的に一致しています。
|
||||
|
||||
1. 開始頂点`startVet`をキューに追加し、ループを開始します。
|
||||
2. ループの各反復で、キューの先頭の頂点をポップし、それを訪問済みとして記録し、次にその頂点のすべての隣接頂点をキューの末尾に追加します。
|
||||
3. すべての頂点が訪問されるまで手順`2.`を繰り返します。
|
||||
1. 走査の開始頂点 `startVet` をキューに追加し、ループを開始します。
|
||||
2. ループの各反復で、キュー先頭の頂点を取り出して訪問を記録し、その後その頂点のすべての隣接頂点をキューの末尾に追加します。
|
||||
3. 手順 `2.` を繰り返し、すべての頂点が訪問されると終了します。
|
||||
|
||||
頂点の再訪問を防ぐために、ハッシュセット`visited`を使用してどのノードが訪問されたかを記録します。
|
||||
頂点の重複走査を防ぐために、どの頂点が訪問済みかを記録するハッシュ集合 `visited` を用います。
|
||||
|
||||
!!! tip
|
||||
|
||||
ハッシュ集合は、`value` を持たず `key` だけを格納するハッシュテーブルとみなせます。これは $O(1)$ の時間計算量で `key` の追加・削除・検索・更新を行えます。`key` の一意性にもとづき、ハッシュ集合は通常、データの重複排除などの場面で用いられます。
|
||||
|
||||
```src
|
||||
[file]{graph_bfs}-[class]{}-[func]{graph_bfs}
|
||||
```
|
||||
|
||||
コードは比較的抽象的ですが、下図と比較することでより良く理解できます。
|
||||
コードはやや抽象的なので、以下の図と照らし合わせて理解を深めることを勧めます。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -59,39 +63,39 @@ BFSは通常キューの助けを借りて実装されます(下記のコー
|
||||
=== "<11>"
|
||||

|
||||
|
||||
!!! question "幅優先走査のシーケンスは一意ですか?"
|
||||
!!! question "幅優先走査の順序列は一意ですか?"
|
||||
|
||||
一意ではありません。幅優先走査は「近くから遠く」の順序で走査することのみを要求し、**同じ距離の頂点の走査順序は任意にできます**。例えば、上図では、頂点$1$と$3$の訪問順序を交換できますし、頂点$2$、$4$、$6$の順序も同様です。
|
||||
一意ではありません。幅優先走査は「近いところから遠いところへ」の順で走査することだけを要求し、**同じ距離にある複数の頂点の走査順は任意に入れ替えて構いません**。上図を例にすると、頂点 $1$ と $3$ の訪問順は交換でき、頂点 $2$、$4$、$6$ の訪問順も任意に入れ替えられます。
|
||||
|
||||
### 計算量分析
|
||||
### 計算量の分析
|
||||
|
||||
**時間計算量**:すべての頂点が一度ずつエンキューおよびデキューされ、$O(|V|)$時間を使用します。隣接頂点を走査する過程で、無向グラフであるため、すべての辺が$2$回訪問され、$O(2|E|)$時間を使用します。全体で$O(|V| + |E|)$時間を使用します。
|
||||
**時間計算量**:すべての頂点は 1 回ずつキューに入り、1 回ずつキューから出るため、$O(|V|)$ 時間です。隣接頂点を走査する過程では、無向グラフであるため、すべての辺が $2$ 回訪問され、$O(2|E|)$ 時間です。したがって全体では $O(|V| + |E|)$ 時間です。
|
||||
|
||||
**空間計算量**:リスト`res`、ハッシュセット`visited`、キュー`que`の最大頂点数は$|V|$で、$O(|V|)$空間を使用します。
|
||||
**空間計算量**:リスト `res`、ハッシュ集合 `visited`、キュー `que` に含まれる頂点数は最大で $|V|$ であるため、$O(|V|)$ 空間です。
|
||||
|
||||
## 深さ優先探索
|
||||
## 深さ優先走査
|
||||
|
||||
**深さ優先探索は可能な限り遠くまで行き、それ以上のパスがない場合にバックトラックする走査方法です**。下図に示すように、左上の頂点から開始し、それ以上のパスがなくなるまで現在の頂点のいずれかの隣接頂点を訪問し、次に戻って続行し、すべての頂点が走査されるまで続けます。
|
||||
**深さ優先走査は、まず行けるところまで進み、進めなくなったら戻る走査方法です**。以下の図に示すように、左上の頂点から出発し、現在の頂点のある隣接頂点を訪問して、行き止まりに達するまで進んだら戻り、再び別の方向へ進んで行き止まりまで進んで戻る、ということを繰り返し、すべての頂点の走査が完了するまで続けます。
|
||||
|
||||

|
||||
|
||||
### アルゴリズムの実装
|
||||
|
||||
この「可能な限り遠くまで行ってから戻る」アルゴリズムパラダイムは通常再帰に基づいて実装されます。幅優先探索と同様に、深さ優先探索でも、再訪問を避けるために訪問済み頂点を記録するハッシュセット`visited`の助けが必要です。
|
||||
この「行き止まりまで進んでから戻る」アルゴリズムのパターンは、通常再帰にもとづいて実装されます。幅優先走査と同様に、深さ優先走査でも、頂点の重複訪問を避けるために、訪問済みの頂点を記録するハッシュ集合 `visited` を用います。
|
||||
|
||||
```src
|
||||
[file]{graph_dfs}-[class]{}-[func]{graph_dfs}
|
||||
```
|
||||
|
||||
深さ優先探索のアルゴリズムプロセスを下図に示します。
|
||||
深さ優先走査のアルゴリズムの流れは以下の図のとおりです。
|
||||
|
||||
- **破線は下向きの再帰を表し**、新しい頂点を訪問するために新しい再帰メソッドが開始されたことを示します。
|
||||
- **曲線の破線は上向きのバックトラックを表し**、この再帰メソッドがこのメソッドが開始された位置に戻ったことを示します。
|
||||
- **直線の破線は下向きの再帰呼び出し**を表し、新しい頂点を訪問するために新たな再帰メソッドが開始されたことを意味します。
|
||||
- **曲線の破線は上向きのバックトラック**を表し、この再帰メソッドがすでに戻って、呼び出し元の位置までたどり着いたことを意味します。
|
||||
|
||||
理解を深めるため、下図とコードを組み合わせて、DFSプロセス全体を頭の中でシミュレート(または描画)することをお勧めします。各再帰メソッドがいつ開始され、いつ戻るかを含めてです。
|
||||
理解を深めるために、以下の図とコードを結びつけて、DFS 全体の過程を頭の中でシミュレーションする(あるいは紙に書き出す)ことを勧めます。各再帰メソッドがいつ開始し、いつ戻るかも含めて追ってみてください。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -123,14 +127,14 @@ BFSは通常キューの助けを借りて実装されます(下記のコー
|
||||
=== "<11>"
|
||||

|
||||
|
||||
!!! question "深さ優先走査のシーケンスは一意ですか?"
|
||||
!!! question "深さ優先走査の順序列は一意ですか?"
|
||||
|
||||
幅優先走査と同様に、深さ優先走査シーケンスの順序も一意ではありません。ある頂点が与えられた場合、どの方向を最初に探索することも可能です。つまり、隣接頂点の順序は任意にシャッフルできますが、すべて深さ優先走査の一部です。
|
||||
幅優先走査と同様に、深さ優先走査の順序列も一意ではありません。ある頂点が与えられたとき、どの方向を先に探索してもよく、つまり隣接頂点の順序は任意に入れ替えられ、それでも深さ優先走査になります。
|
||||
|
||||
木の走査を例にすると、「根 $\rightarrow$ 左 $\rightarrow$ 右」「左 $\rightarrow$ 根 $\rightarrow$ 右」「左 $\rightarrow$ 右 $\rightarrow$ 根」は、それぞれ先行順、中間順、後行順走査に対応します。これらは 3 種類の走査優先順位を示していますが、いずれも深さ優先走査に属します。
|
||||
|
||||
木の走査を例に取ると、「根 $\rightarrow$ 左 $\rightarrow$ 右」、「左 $\rightarrow$ 根 $\rightarrow$ 右」、「左 $\rightarrow$ 右 $\rightarrow$ 根」は、それぞれ前順、中順、後順走査に対応します。これらは3つの異なる走査優先度を示していますが、3つすべてが深さ優先走査と見なされます。
|
||||
### 計算量の分析
|
||||
|
||||
### 計算量分析
|
||||
**時間計算量**:すべての頂点は $1$ 回ずつ訪問されるため、$O(|V|)$ 時間です。すべての辺は $2$ 回ずつ訪問されるため、$O(2|E|)$ 時間です。したがって全体では $O(|V| + |E|)$ 時間です。
|
||||
|
||||
**時間計算量**:すべての頂点が一度訪問され、$O(|V|)$時間を使用します。すべての辺が2回訪問され、$O(2|E|)$時間を使用します。全体で$O(|V| + |E|)$時間を使用します。
|
||||
|
||||
**空間計算量**:リスト`res`、ハッシュセット`visited`の最大頂点数は$|V|$で、最大再帰深度は$|V|$です。したがって、$O(|V|)$空間を使用します。
|
||||
**空間計算量**:リスト `res` とハッシュ集合 `visited` に含まれる頂点数は最大で $|V|$ であり、再帰の深さも最大で $|V|$ であるため、$O(|V|)$ 空間です。
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
|
||||
!!! abstract
|
||||
|
||||
人生の旅路において、私たちの一人一人はノードであり、無数の見えない辺で結ばれています。
|
||||
|
||||
一つ一つの出会いと別れが、この広大な人生のグラフに独特の印を残していきます。
|
||||
人生の旅路において、私たちはそれぞれ一つひとつのノードのようなものであり、無数の見えない辺によって結ばれています。
|
||||
|
||||
出会いと別れのたびに、この巨大なネットワークグラフの中に固有の足跡が刻まれます。
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
# まとめ
|
||||
|
||||
### 重要な復習
|
||||
### 重要なポイントの振り返り
|
||||
|
||||
- グラフは頂点と辺で構成されます。頂点の集合と辺の集合として記述できます。
|
||||
- 線形関係(連結リストなど)や階層関係(木など)と比較して、ネットワーク関係(グラフ)はより大きな柔軟性を提供し、より複雑になります。
|
||||
- 有向グラフでは、辺に方向があります。連結グラフでは、任意の頂点から他の任意の頂点に到達できます。重み付きグラフでは、各辺に関連する重み変数があります。
|
||||
- 隣接行列は、行列(2次元配列)を使用してグラフを表現する方法です。行と列は頂点を表します。行列要素の値は、2つの頂点間に辺があるかどうかを示し、辺がある場合は$1$、ない場合は$0$を使用します。隣接行列は辺の追加、削除、チェックなどの操作に非常に効率的ですが、より多くのスペースが必要です。
|
||||
- 隣接リストは、連結リストの集合を使用してグラフを表現するもう一つの一般的な方法です。グラフ内の各頂点には、その隣接するすべての頂点を含むリストがあります。$i$番目のリストは頂点$i$を表します。隣接リストは隣接行列と比較してより少ないスペースを使用します。ただし、辺を見つけるためにリストを走査する必要があるため、時間効率は低くなります。
|
||||
- 隣接リストの連結リストが十分に長くなったとき、ルックアップ効率を向上させるために赤黒木やハッシュテーブルに変換できます。
|
||||
- アルゴリズム設計の観点から、隣接行列は「空間と時間のトレードオフ」の概念を反映し、隣接リストは「時間と空間のトレードオフ」を反映します。
|
||||
- グラフは、ソーシャルネットワークや地下鉄路線など、さまざまな現実世界のシステムをモデル化するために使用できます。
|
||||
- 木はグラフの特別なケースであり、木の走査もグラフ走査の特別なケースです。
|
||||
- グラフの幅優先走査は、近くから遠くへと層ごとに拡張する探索方法で、通常キューを使用します。
|
||||
- グラフの深さ優先走査は、それ以上のパスがない場合にバックトラックする前に、まず終端に到達することを優先する探索方法です。しばしば再帰を使用して実装されます。
|
||||
- グラフは頂点と辺から構成され、一組の頂点と一組の辺からなる集合として表せます。
|
||||
- 線形関係(連結リスト)や分治関係(木)と比べて、ネットワーク関係(グラフ)は自由度が高く、そのぶん複雑です。
|
||||
- 有向グラフの辺は方向性を持ち、連結グラフでは任意の頂点に到達でき、重み付きグラフの各辺は重み変数を含みます。
|
||||
- 隣接行列は行列を用いてグラフを表し、各行(列)が 1 つの頂点を表し、行列要素が辺を表します。$1$ または $0$ を用いて、2 つの頂点の間に辺があるかないかを示します。隣接行列は追加・削除・検索・更新の操作効率が高い一方で、より多くの空間を消費します。
|
||||
- 隣接リストは複数の連結リストを使ってグラフを表し、第 $i$ 個の連結リストが頂点 $i$ に対応し、その頂点に隣接するすべての頂点を格納します。隣接リストは隣接行列よりも省スペースですが、辺を探すために連結リストを走査する必要があるため、時間効率は低くなります。
|
||||
- 隣接リスト内の連結リストが長くなりすぎた場合は、赤黒木やハッシュテーブルに変換することで、検索効率を高められます。
|
||||
- アルゴリズムの考え方という観点では、隣接行列は「空間を時間と引き換えにする」ことを体現し、隣接リストは「時間を空間と引き換えにする」ことを体現します。
|
||||
- グラフは、ソーシャルネットワークや地下鉄路線など、さまざまな現実のシステムをモデル化するために使えます。
|
||||
- 木はグラフの特殊な一例であり、木の走査もグラフ走査の特殊な一例です。
|
||||
- グラフの幅優先探索は、近いところから遠いところへ、層ごとに広がっていく探索方法であり、通常はキューを使って実装します。
|
||||
- グラフの深さ優先探索は、まず行けるところまで進み、進めなくなったらバックトラックする探索方法であり、通常は再帰に基づいて実装します。
|
||||
|
||||
### Q & A
|
||||
|
||||
**Q**: パスは頂点のシーケンスとして定義されますか、それとも辺のシーケンスとして定義されますか?
|
||||
**Q**:経路の定義は頂点列ですか、それとも辺列ですか?
|
||||
|
||||
グラフ理論では、グラフ内のパスは頂点のシーケンスを結ぶ有限または無限の辺のシーケンスです。
|
||||
Wikipedia では言語版ごとに定義が一致していません。英語版では「経路は辺の列」であり、中国語版では「経路は頂点の列」です。以下は英語版の原文です:In graph theory, a path in a graph is a finite or infinite sequence of edges which joins a sequence of vertices.
|
||||
|
||||
この文書では、パスは頂点のシーケンスではなく、辺のシーケンスと考えられます。これは、2つの頂点を結ぶ複数の辺がある可能性があり、その場合各辺がパスに対応するためです。
|
||||
本書では、経路を頂点列ではなく辺列とみなします。これは、2 つの頂点の間に複数の辺が存在する可能性があり、その場合は各辺がそれぞれ 1 本の経路に対応するためです。
|
||||
|
||||
**Q**: 非連結グラフでは、走査できない点がありますか?
|
||||
**Q**:非連結グラフには到達できない頂点がありますか?
|
||||
|
||||
非連結グラフでは、特定の点から到達できない頂点が少なくとも1つあります。非連結グラフを走査するには、グラフのすべての連結成分を走査するために複数の開始点を設定する必要があります。
|
||||
非連結グラフでは、ある頂点から出発すると、少なくとも 1 つの頂点には到達できません。非連結グラフ全体を走査するには、グラフ内のすべての連結成分をたどれるように複数の始点を設定する必要があります。
|
||||
|
||||
**Q**: 隣接リストで、「その頂点に接続されたすべての頂点」の順序は重要ですか?
|
||||
**Q**:隣接リストにおいて、「その頂点に接続されたすべての頂点」の順序に決まりはありますか?
|
||||
|
||||
任意の順序で構いません。ただし、実際のアプリケーションでは、頂点が追加された順序や頂点値の順序など、特定のルールに従ってそれらをソートする必要がある場合があります。これにより、特定の極値を持つ頂点を素早く見つけることができます。
|
||||
順序は任意でかまいません。ただし実際の応用では、頂点を追加した順序や頂点値の大小順など、特定の規則に従って並べ替える必要がある場合があります。そうすることで、「ある種の極値を持つ」頂点をすばやく見つけやすくなります。
|
||||
|
||||
@@ -2,49 +2,51 @@
|
||||
|
||||
!!! question
|
||||
|
||||
$n$ 個のアイテムが与えられ、$i$ 番目のアイテムの重量は $wgt[i-1]$ で値は $val[i-1]$ です。容量が $cap$ のナップサックがあります。各アイテムは1回のみ選択できますが、**アイテムの一部を選択することができ、その値は選択された重量の割合に基づいて計算されます**。限られた容量の下でナップサック内のアイテムの最大値は何ですか?例を下の図に示します。
|
||||
$n$ 個の品物が与えられ、第 $i$ 個の品物の重さは $wgt[i-1]$、価値は $val[i-1]$ であり、容量が $cap$ のナップサックがある。各品物は 1 回だけ選択できるが、**品物の一部を選ぶこともでき、価値は選択した重量の割合に応じて計算される**。容量制限の下でナップサック内の品物の最大価値を求めよ。例を以下に示す。
|
||||
|
||||

|
||||
|
||||
分数ナップサック問題は全体的に0-1ナップサック問題と非常に似ており、現在のアイテム $i$ と容量 $c$ を含み、ナップサックの限られた容量内で値を最大化することを目的としています。
|
||||
分数ナップサック問題は 0-1 ナップサック問題と全体として非常によく似ており、状態には現在の品物 $i$ と容量 $c$ が含まれ、目標は容量制限下での最大価値を求めることである。
|
||||
|
||||
違いは、この問題ではアイテムの一部のみを選択できることです。下の図に示すように、**アイテムを任意に分割し、重量の割合に基づいて対応する値を計算できます**。
|
||||
異なる点は、本問では品物の一部だけを選べることである。以下に示すように、**品物は任意に分割でき、対応する価値は重量の割合に応じて計算される**。
|
||||
|
||||
1. アイテム $i$ について、その単位重量あたりの値は $val[i-1] / wgt[i-1]$ で、単位値と呼ばれます。
|
||||
2. 重量 $w$ のアイテム $i$ の一部をナップサックに入れるとすると、ナップサックに追加される値は $w \times val[i-1] / wgt[i-1]$ です。
|
||||
1. 品物 $i$ について、単位重量あたりの価値は $val[i-1] / wgt[i-1]$ であり、これを単位価値と呼ぶ。
|
||||
2. 品物 $i$ の一部を重さ $w$ だけ入れると、ナップサックに増える価値は $w \times val[i-1] / wgt[i-1]$ となる。
|
||||
|
||||

|
||||

|
||||
|
||||
### 貪欲戦略の決定
|
||||
|
||||
ナップサック内のアイテムの総値を最大化することは、**本質的に単位重量あたりの値を最大化することを意味します**。これから、下の図に示す貪欲戦略を導出できます。
|
||||
ナップサック内の品物の総価値を最大化することは、**本質的には単位重量あたりの品物価値を最大化すること**である。そこから、以下に示す貪欲戦略を導ける。
|
||||
|
||||
1. アイテムを単位値の高い順から低い順にソートします。
|
||||
2. すべてのアイテムを反復し、**各ラウンドで最も高い単位値を持つアイテムを貪欲に選択**します。
|
||||
3. ナップサックの残り容量が不十分な場合、現在のアイテムの一部を使用してナップサックを満たします。
|
||||
1. 品物を単位価値の高い順にソートする。
|
||||
2. すべての品物を走査し、**各回で単位価値が最も高い品物を貪欲に選択する**。
|
||||
3. 残りのナップサック容量が足りない場合は、現在の品物の一部を使ってナップサックを満たす。
|
||||
|
||||

|
||||
|
||||
### コード実装
|
||||
|
||||
アイテムを単位値でソートするために `Item` クラスを作成しました。ナップサックが満たされるまでループして貪欲な選択を行い、その後終了して解を返します:
|
||||
品物を単位価値でソートできるように、`Item` クラスを定義する。貪欲選択を繰り返し、ナップサックが満杯になったら終了して解を返す。
|
||||
|
||||
```src
|
||||
[file]{fractional_knapsack}-[class]{}-[func]{fractional_knapsack}
|
||||
```
|
||||
|
||||
ソート以外に、最悪の場合、アイテムのリスト全体を走査する必要があるため、**時間計算量は $O(n)$** です。ここで $n$ はアイテムの数です。
|
||||
組み込みのソートアルゴリズムの時間計算量は通常 $O(\log n)$、空間計算量は通常 $O(\log n)$ または $O(n)$ であり、具体的な値はプログラミング言語の実装に依存する。
|
||||
|
||||
`Item` オブジェクトリストが初期化されるため、**空間計算量は $O(n)$** です。
|
||||
ソートを除けば、最悪の場合は品物リスト全体を走査する必要があるため、**時間計算量は $O(n)$** であり、ここで $n$ は品物数である。
|
||||
|
||||
`Item` オブジェクトのリストを初期化しているため、**空間計算量は $O(n)$** である。
|
||||
|
||||
### 正しさの証明
|
||||
|
||||
背理法を使用します。アイテム $x$ が最高の単位値を持ち、あるアルゴリズムが最大値 `res` を生成するが、解にアイテム $x$ が含まれていないと仮定します。
|
||||
背理法を用いる。品物 $x$ が単位価値最大の品物であり、あるアルゴリズムで得られた最大価値を `res` とするが、その解には品物 $x$ が含まれていないと仮定する。
|
||||
|
||||
今、ナップサックから任意のアイテムの単位重量を取り除き、アイテム $x$ の単位重量で置き換えます。アイテム $x$ の単位値が最高であるため、置き換え後の総値は確実に `res` より大きくなります。**これは `res` が最適解であるという仮定と矛盾し、最適解には必ずアイテム $x$ が含まれることを証明します**。
|
||||
ここでナップサックから単位重量の任意の品物を取り出し、単位重量の品物 $x$ に置き換える。品物 $x$ の単位価値が最大であるため、置き換え後の総価値は必ず `res` より大きくなる。**これは `res` が最適解であることに矛盾し、最適解には必ず品物 $x$ が含まれなければならないことを示す**。
|
||||
|
||||
この解の他のアイテムについても、上記の矛盾を構築できます。全体的に、**単位値がより大きいアイテムは常により良い選択**であり、貪欲戦略が効果的であることを証明します。
|
||||
この解に含まれる他の品物についても、同様の矛盾を構成できる。要するに、**単位価値がより大きい品物は常により良い選択である**。これは貪欲戦略が有効であることを示している。
|
||||
|
||||
下の図に示すように、アイテムの重量と単位値をそれぞれ二次元チャートの横軸と縦軸と見なすと、分数ナップサック問題は「限られた横軸範囲内で囲まれる最大面積を求める」ことに変換できます。この類推は、幾何学的観点から貪欲戦略の効果を理解するのに役立ちます。
|
||||
以下に示すように、品物の重さと品物の単位価値をそれぞれ二次元グラフの横軸と縦軸とみなすと、分数ナップサック問題は「有限な横軸区間で囲まれる最大面積を求める問題」に変換できる。この類比は、幾何学的な観点から貪欲戦略の有効性を理解する助けになる。
|
||||
|
||||

|
||||
|
||||
@@ -1,94 +1,94 @@
|
||||
# 貪欲アルゴリズム
|
||||
# 貪欲法
|
||||
|
||||
<u>貪欲アルゴリズム</u>は最適化問題を解決するための一般的なアルゴリズムで、基本的に問題の各意思決定段階で最も良い選択をすること、つまり局所的に最適な決定を貪欲に行い、グローバルに最適な解を見つけることを望みます。貪欲アルゴリズムは簡潔で効率的であり、多くの実用的な問題で広く使用されています。
|
||||
<u>貪欲法(greedy algorithm)</u>は、最適化問題を解くための一般的なアルゴリズムです。その基本的な考え方は、問題の各意思決定段階において、その時点で最善に見える選択を行い、すなわち貪欲に局所最適な決定を下すことで、大域最適解を得ようとするものです。貪欲法は簡潔で効率的であり、多くの実際の問題で広く用いられています。
|
||||
|
||||
貪欲アルゴリズムと動的プログラミングは、どちらも最適化問題を解決するためによく使用されます。両者は最適部分構造の性質に依存するなど、いくつかの類似点を共有していますが、動作方法が異なります。
|
||||
貪欲法と動的計画法は、どちらも最適化問題を解く際によく用いられます。両者には、最適部分構造に依存するなどの共通点がありますが、その動作原理は異なります。
|
||||
|
||||
- 動的プログラミングは現在の決定段階ですべての以前の決定を考慮し、過去の部分問題の解を使用して現在の部分問題の解を構築します。
|
||||
- 貪欲アルゴリズムは過去の決定を考慮せず、代わりに貪欲な選択を続け、問題が解決されるまで問題の範囲を継続的に狭めます。
|
||||
- 動的計画法は、前の段階までのすべての決定に基づいて現在の決定を考え、過去の部分問題の解を用いて現在の部分問題の解を構築します。
|
||||
- 貪欲法は過去の決定を考慮せず、ひたすら前に進みながら貪欲な選択を行い、問題の範囲を縮小し続けて、最終的に問題を解決します。
|
||||
|
||||
まず、「完全ナップサック問題」の章で紹介された「コイン交換」の例を通じて貪欲アルゴリズムの動作原理を理解しましょう。すでによく知っていると思います。
|
||||
まずは例題「コイン両替」を通して、貪欲法の仕組みを理解しましょう。この問題はすでに「完全ナップサック問題」の節で紹介しているので、見覚えがあるはずです。
|
||||
|
||||
!!! question
|
||||
|
||||
$n$ 種類のコインが与えられ、$i$ 番目の種類のコインの額面は $coins[i - 1]$ で、目標金額は $amt$ です。各種類のコインは無制限に利用可能で、目標金額を構成するのに必要な最小コイン数は何ですか?目標金額を構成できない場合は $-1$ を返してください。
|
||||
$n$ 種類の硬貨が与えられ、$i$ 番目の硬貨の額面は $coins[i - 1]$ 、目標金額は $amt$ です。各硬貨は何度でも選べるとき、目標金額を作るために必要な最小の硬貨枚数を求めてください。目標金額を作れない場合は $-1$ を返します。
|
||||
|
||||
この問題で採用される貪欲戦略を下の図に示します。目標金額が与えられたとき、**それに最も近く、それを超えないコインを貪欲に選択**し、目標金額が満たされるまでこのステップを繰り返します。
|
||||
この問題で採用する貪欲戦略は下図のとおりです。目標金額が与えられたら、**それを超えず、かつ最も近い硬貨を貪欲に選択し**、この手順を目標金額を作り切るまで繰り返します。
|
||||
|
||||

|
||||

|
||||
|
||||
実装コードは以下の通りです:
|
||||
実装コードは次のとおりです。
|
||||
|
||||
```src
|
||||
[file]{coin_change_greedy}-[class]{}-[func]{coin_change_greedy}
|
||||
```
|
||||
|
||||
感嘆するかもしれません:なんて簡潔なんだ!貪欲アルゴリズムは約10行のコードでコイン交換問題を解決します。
|
||||
思わずこう言いたくなるかもしれません。So clean!貪欲法はわずか十行ほどのコードでコイン両替問題を解いてしまいます。
|
||||
|
||||
## 貪欲アルゴリズムの利点と制限
|
||||
## 貪欲法の利点と限界
|
||||
|
||||
**貪欲アルゴリズムは直接的で実装が簡単であるだけでなく、通常非常に効率的でもあります**。上記のコードで、最小のコイン額面を $\min(coins)$ とすると、貪欲な選択ループは最大 $amt / \min(coins)$ 回実行され、時間計算量は $O(amt / \min(coins))$ になります。これは動的プログラミング解法の時間計算量 $O(n \times amt)$ よりも一桁小さいです。
|
||||
**貪欲法は操作が直接的で実装が簡単なだけでなく、通常は効率も高い**です。上のコードでは、硬貨の最小額面を $\min(coins)$ とすると、貪欲選択のループ回数は高々 $amt / \min(coins)$ 回であり、時間計算量は $O(amt / \min(coins))$ です。これは動的計画法による解法の時間計算量 $O(n \times amt)$ より 1 桁小さいオーダーです。
|
||||
|
||||
しかし、**一部のコイン額面の組み合わせでは、貪欲アルゴリズムは最適解を見つけることができません**。下の図は2つの例を示しています。
|
||||
しかし、**硬貨の額面の組み合わせによっては、貪欲法では最適解を見つけられません**。下図に 2 つの例を示します。
|
||||
|
||||
- **正の例 $coins = [1, 5, 10, 20, 50, 100]$**:このコインの組み合わせでは、任意の $amt$ に対して、貪欲アルゴリズムは最適解を見つけることができます。
|
||||
- **負の例 $coins = [1, 20, 50]$**:$amt = 60$ とすると、貪欲アルゴリズムは組み合わせ $50 + 1 \times 10$ しか見つけられず、合計11枚のコインですが、動的プログラミングは最適解 $20 + 20 + 20$ を見つけることができ、3枚のコインのみが必要です。
|
||||
- **負の例 $coins = [1, 49, 50]$**:$amt = 98$ とすると、貪欲アルゴリズムは組み合わせ $50 + 1 \times 48$ しか見つけられず、合計49枚のコインですが、動的プログラミングは最適解 $49 + 49$ を見つけることができ、2枚のコインのみが必要です。
|
||||
- **正例 $coins = [1, 5, 10, 20, 50, 100]$**:この硬貨の組み合わせでは、任意の $amt$ に対して貪欲法で最適解を見つけられます。
|
||||
- **反例 $coins = [1, 20, 50]$**:$amt = 60$ とすると、貪欲法では $50 + 1 \times 10$ という両替しか見つからず、硬貨は合計 $11$ 枚になります。しかし動的計画法なら最適解 $20 + 20 + 20$ を見つけられ、必要なのはわずか $3$ 枚です。
|
||||
- **反例 $coins = [1, 49, 50]$**:$amt = 98$ とすると、貪欲法では $50 + 1 \times 48$ という両替しか見つからず、硬貨は合計 $49$ 枚になります。しかし動的計画法なら最適解 $49 + 49$ を見つけられ、必要なのはわずか $2$ 枚です。
|
||||
|
||||

|
||||

|
||||
|
||||
これは、コイン交換問題において、貪欲アルゴリズムがグローバルに最適な解を見つけることを保証できず、非常に悪い解を見つける可能性があることを意味します。動的プログラミングの方が適しています。
|
||||
つまり、コイン両替問題に対して、貪欲法は大域最適解を保証できず、非常に悪い解を見つけてしまうこともあります。この問題は動的計画法で解くほうが適しています。
|
||||
|
||||
一般的に、貪欲アルゴリズムの適用性は2つのカテゴリに分類されます。
|
||||
一般に、貪欲法が適用できる状況は次の 2 つに分けられます。
|
||||
|
||||
1. **最適解を見つけることが保証される**:これらの場合、貪欲アルゴリズムはしばしば最良の選択で、バックトラッキングや動的プログラミングよりも効率的である傾向があります。
|
||||
2. **準最適解を見つけることができる**:貪欲アルゴリズムはここでも適用可能です。多くの複雑な問題では、グローバル最適解を見つけることは非常に困難であり、高効率の準最適解を見つけることも非常に価値があります。
|
||||
1. **最適解を保証できる場合**:この場合、貪欲法はしばしば最良の選択です。多くの場合、バックトラッキングや動的計画法より効率的だからです。
|
||||
2. **近似最適解を見つけられる場合**:この場合も貪欲法は有効です。多くの複雑な問題では、大域最適解を求めること自体が非常に難しく、より高い効率で準最適解を得られるだけでも十分価値があります。
|
||||
|
||||
## 貪欲アルゴリズムの特徴
|
||||
## 貪欲法の特性
|
||||
|
||||
それでは、どのような問題が貪欲アルゴリズムで解決するのに適しているのでしょうか?言い換えれば、どのような条件下で貪欲アルゴリズムは最適解を見つけることを保証できるのでしょうか?
|
||||
では、どのような問題が貪欲法に適しているのでしょうか。言い換えると、貪欲法はどのような場合に最適解を保証できるのでしょうか。
|
||||
|
||||
動的プログラミングと比較して、貪欲アルゴリズムはより厳しい使用条件を持ち、主に問題の2つの性質に焦点を当てています。
|
||||
動的計画法と比べると、貪欲法の適用条件はより厳しく、主に次の 2 つの性質に注目します。
|
||||
|
||||
- **貪欲選択性**:局所的に最適な選択が常にグローバルに最適な解に導くことができる場合のみ、貪欲アルゴリズムは最適解を得ることを保証できます。
|
||||
- **最適部分構造**:元の問題の最適解はその部分問題の最適解を含みます。
|
||||
- **貪欲選択性**:局所最適な選択が常に大域最適解につながる場合にのみ、貪欲法は最適解を保証できます。
|
||||
- **最適部分構造**:元の問題の最適解が、部分問題の最適解を含むことです。
|
||||
|
||||
最適部分構造は「動的プログラミング」の章ですでに紹介されているため、ここではこれ以上議論しません。一部の問題には明らかな最適部分構造がありませんが、それでも貪欲アルゴリズムを使用して解決できることに注意することが重要です。
|
||||
最適部分構造については「動的計画法」の節ですでに紹介したので、ここでは繰り返しません。なお、問題によっては最適部分構造が明確でなくても、貪欲法で解ける場合があります。
|
||||
|
||||
主に貪欲選択性を決定する方法を探索します。その記述は単純に見えますが、**実際には、多くの問題の貪欲選択性を証明することは容易ではありません**。
|
||||
ここでは主に、貪欲選択性をどのように判定するかを考えます。説明だけを見ると単純そうですが、**実際には多くの問題で、貪欲選択性を証明するのは容易ではありません**。
|
||||
|
||||
例えば、コイン交換問題では、貪欲選択性を反証するために反例を簡単に挙げることができますが、それを証明することははるかに困難です。**コインの組み合わせが貪欲アルゴリズムを使用して解決できるためには、どのような条件を満たす必要があるか**と尋ねられた場合、厳密な数学的証明を提供することが困難であるため、しばしば直感や例に頼って曖昧な答えを提供しなければなりません。
|
||||
たとえばコイン両替問題では、反例を挙げて貪欲選択性が成り立たないことを示すのは簡単ですが、成り立つことを証明するのは難しいです。もし、**どのような条件を満たす硬貨の組み合わせなら貪欲法で解けるのか**と問われると、直感や例示に頼った曖昧な答えしか出せず、厳密な数学的証明を与えるのは困難です。
|
||||
|
||||
!!! quote
|
||||
|
||||
ある論文では、コインの組み合わせが任意の金額に対して貪欲アルゴリズムを使用して最適解を見つけることができるかどうかを判定するための時間計算量 $O(n^3)$ のアルゴリズムが提示されています。
|
||||
ある論文では、ある硬貨の組み合わせについて、任意の金額に対する最適解を貪欲法で求められるかどうかを判定する、時間計算量 $O(n^3)$ のアルゴリズムが示されています。
|
||||
|
||||
Pearson, D. A polynomial-time algorithm for the change-making problem[J]. Operations Research Letters, 2005, 33(3): 231-234.
|
||||
|
||||
## 貪欲アルゴリズムによる問題解決のステップ
|
||||
## 貪欲法の問題解決手順
|
||||
|
||||
貪欲問題の問題解決プロセスは、一般的に以下の3つのステップに分けることができます。
|
||||
貪欲法による問題解決の流れは、おおむね次の 3 段階に分けられます。
|
||||
|
||||
1. **問題分析**:問題の特徴を整理し理解する。状態定義、最適化目標、制約などを含みます。このステップはバックトラッキングや動的プログラミングでも関与します。
|
||||
2. **貪欲戦略の決定**:各ステップで貪欲な選択をする方法を決定する。この戦略は各ステップで問題の規模を縮小し、最終的に問題全体を解決できます。
|
||||
3. **正確性の証明**:通常、問題が貪欲選択性と最適部分構造の両方を持つことを証明する必要があります。このステップには、帰納法や背理法などの数学的証明が必要な場合があります。
|
||||
1. **問題分析**:状態の定義、最適化目標、制約条件などを整理し、問題の性質を理解します。この段階はバックトラッキングや動的計画法でも共通して現れます。
|
||||
2. **貪欲戦略の決定**:各ステップでどのように貪欲選択を行うかを定めます。この戦略により各ステップで問題規模を縮小し、最終的に問題全体を解決します。
|
||||
3. **正しさの証明**:通常は、その問題が貪欲選択性と最適部分構造を持つことを示す必要があります。この段階では、帰納法や背理法などの数学的証明が必要になることがあります。
|
||||
|
||||
貪欲戦略の決定は問題解決の核心ステップですが、実装は容易ではない場合があります。主な理由は以下の通りです。
|
||||
貪欲戦略を定めることは問題解決の核心ですが、実際には簡単ではないことも多く、主な理由は次のとおりです。
|
||||
|
||||
- **異なる問題間で貪欲戦略は大きく異なる**。多くの問題では、貪欲戦略はかなり直接的で、一般的な思考と試行を通じて思いつくことができます。しかし、一部の複雑な問題では、貪欲戦略は非常に見つけにくく、これは個人の問題解決経験とアルゴリズム能力の真のテストです。
|
||||
- **一部の貪欲戦略は非常に誤解を招く**。自信を持って貪欲戦略を設計し、コードを書いてテストに提出したとき、一部のテストケースが通らない可能性が高いです。これは設計された貪欲戦略が「部分的に正しい」だけであるためで、上記のコイン交換の例で説明した通りです。
|
||||
- **問題ごとに貪欲戦略の差が大きい**。多くの問題では貪欲戦略は比較的わかりやすく、おおまかな考察や試行だけで見つけられます。しかし複雑な問題では、貪欲戦略が非常に見えにくいことがあり、その場合は解法経験やアルゴリズム力が大きく問われます。
|
||||
- **一見もっともらしい貪欲戦略もある**。自信を持って貪欲戦略を設計し、コードを書いて提出しても、一部のテストケースを通過できないことがあります。これは、その貪欲戦略が「部分的にしか正しくない」ためであり、先ほどのコイン両替は典型例です。
|
||||
|
||||
正確性を確保するために、貪欲戦略に対して厳密な数学的証明を提供すべきで、**通常は背理法や数学的帰納法を含みます**。
|
||||
正しさを保証するためには、貪欲戦略に対して厳密な数学的証明を行うべきであり、**通常は背理法や数学的帰納法が必要になります**。
|
||||
|
||||
しかし、正確性を証明することは容易な作業ではない場合があります。途方に暮れた場合、通常はテストケースに基づいてコードをデバッグし、貪欲戦略を段階的に修正し検証することを選択します。
|
||||
しかし、正しさの証明もまた簡単とは限りません。手がかりがない場合には、テストケースを使ってコードをデバッグしながら、貪欲戦略を少しずつ修正して検証していくことがよくあります。
|
||||
|
||||
## 貪欲アルゴリズムで解決される典型的な問題
|
||||
## 貪欲法の典型問題
|
||||
|
||||
貪欲アルゴリズムは、貪欲選択と最適部分構造の性質を満たす最適化問題によく適用されます。以下は典型的な貪欲アルゴリズム問題のいくつかです。
|
||||
貪欲法は、貪欲選択性と最適部分構造を満たす最適化問題によく用いられます。以下に典型的な貪欲法の問題をいくつか挙げます。
|
||||
|
||||
- **コイン交換問題**:一部のコインの組み合わせでは、貪欲アルゴリズムは常に最適解を提供します。
|
||||
- **区間スケジューリング問題**:いくつかのタスクがあり、それぞれが一定期間にわたって行われるとします。目標はできるだけ多くのタスクを完了することです。常に最も早く終了するタスクを選択すると、貪欲アルゴリズムは最適解を達成できます。
|
||||
- **分数ナップサック問題**:アイテムのセットと運搬容量が与えられ、目標は総重量が運搬容量を超えず、総価値が最大化されるようなアイテムのセットを選択することです。常に最高の価値対重量比(価値/重量)のアイテムを選択すると、貪欲アルゴリズムは一部のケースで最適解を達成できます。
|
||||
- **株式取引問題**:株価の履歴のセットが与えられ、複数回の取引を行うことができますが、すでに株式を所有している場合は売却後でないと再度購入できません。目標は最大利益を達成することです。
|
||||
- **ハフマン符号化**:ハフマン符号化は無損失データ圧縮に使用される貪欲アルゴリズムです。ハフマン木を構築することにより、常に最低頻度の2つのノードを統合し、最小重み付きパス長(符号化長)のハフマン木を生成します。
|
||||
- **ダイクストラのアルゴリズム**:これは与えられたソース頂点から他のすべての頂点への最短経路問題を解決するための貪欲アルゴリズムです。
|
||||
- **硬貨のお釣り問題**:ある種の硬貨の組み合わせでは、貪欲法で常に最適解が得られます。
|
||||
- **区間スケジューリング問題**:いくつかのタスクがあり、それぞれがある時間区間で実行されるとします。できるだけ多くのタスクを完了することが目標で、毎回終了時刻が最も早いタスクを選ぶなら、貪欲法で最適解を得られます。
|
||||
- **分数ナップサック問題**:一群の品物と積載容量が与えられたとき、総重量が容量を超えず、かつ総価値が最大になるように品物を選ぶ問題です。毎回、価値対重量比(価値 / 重量)が最も高い品物を選ぶなら、ある条件下で貪欲法は最適解を得られます。
|
||||
- **株式売買問題**:株価の履歴が与えられ、複数回の売買が可能ですが、すでに株を保有している場合は売却前に再度購入することはできません。目標は最大利益を得ることです。
|
||||
- **ハフマン符号化**:ハフマン符号化は、可逆データ圧縮に用いられる貪欲法です。ハフマン木を構築する際、毎回出現頻度が最も低い 2 つのノードを選んで併合すると、最終的に得られるハフマン木の重み付きパス長(符号長)は最小になります。
|
||||
- **Dijkstra アルゴリズム**:与えられた始点から他の各頂点への最短経路問題を解く貪欲法です。
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
|
||||
!!! abstract
|
||||
|
||||
ひまわりは太陽の方を向き、常に自分にとって最大の成長を求めます。
|
||||
ヒマワリは太陽に向かって回り、自らが最も大きく成長できる可能性を常に追い求める。
|
||||
|
||||
貪欲な戦略は、一連の単純な選択を通じて、段階的に最良の答えへと導きます。
|
||||
貪欲戦略は、一回ごとの単純な選択を通じて、徐々に最適な答えへと導く。
|
||||
|
||||
@@ -2,51 +2,51 @@
|
||||
|
||||
!!! question
|
||||
|
||||
配列 $ht$ を入力します。各要素は垂直仕切りの高さを表します。配列内の任意の2つの仕切りと、それらの間のスペースによってコンテナを形成できます。
|
||||
配列 $ht$ が与えられ、各要素は垂直な仕切り板の高さを表します。配列内の任意の 2 枚の仕切り板と、その間の空間で容器を構成できます。
|
||||
|
||||
容器の容量は高さと幅の積(面積)に等しく、高さは短い方の仕切り板で決まり、幅は 2 枚の仕切り板の配列インデックスの差です。
|
||||
|
||||
配列から 2 枚の仕切り板を選び、構成される容器の容量が最大となるようにしてください。最大容量を返します。例を以下の図に示します。
|
||||
|
||||
コンテナの容量は高さと幅の積(面積)で、高さは短い方の仕切りによって決定され、幅は2つの仕切りの配列インデックスの差です。
|
||||

|
||||
|
||||
コンテナの容量を最大化する2つの仕切りを配列から選択し、この最大容量を返してください。例を下の図に示します。
|
||||
容器は任意の 2 枚の仕切り板で囲まれるため、**本問の状態は 2 枚の仕切り板のインデックスで表され、$[i, j]$ と記します**。
|
||||
|
||||

|
||||
|
||||
コンテナは任意の2つの仕切りによって形成されるため、**この問題の状態は2つの仕切りのインデックスで表現され、$[i, j]$ と表記されます**。
|
||||
|
||||
問題の記述によれば、容量は高さと幅の積に等しく、高さは短い方の仕切りによって決定され、幅は2つの仕切りの配列インデックスの差です。容量 $cap[i, j]$ の式は:
|
||||
問題の条件より、容量は高さと幅の積に等しく、高さは短い板で決まり、幅は 2 枚の仕切り板の配列インデックスの差です。容量を $cap[i, j]$ とすると、計算式は次のようになります。
|
||||
|
||||
$$
|
||||
cap[i, j] = \min(ht[i], ht[j]) \times (j - i)
|
||||
$$
|
||||
|
||||
配列の長さを $n$ と仮定すると、2つの仕切りの組み合わせ数(状態の総数)は $C_n^2 = \frac{n(n - 1)}{2}$ です。最も直接的なアプローチは**すべての可能な状態を列挙する**ことで、時間計算量は $O(n^2)$ になります。
|
||||
配列の長さを $n$ とすると、2 枚の仕切り板の組合せ数(状態総数)は $C_n^2 = \frac{n(n - 1)}{2}$ 個です。最も直接的には、**すべての状態を総当たりできます**。これにより最大容量を求められ、時間計算量は $O(n^2)$ です。
|
||||
|
||||
### 貪欲戦略の決定
|
||||
|
||||
この問題にはより効率的な解法があります。下の図に示すように、インデックス $i < j$ かつ高さ $ht[i] < ht[j]$ の状態 $[i, j]$ を選択します。つまり、$i$ は短い仕切り、$j$ は高い仕切りです。
|
||||
この問題にはさらに効率的な解法があります。以下の図のように、状態 $[i, j]$ を 1 つ選び、インデックスが $i < j$ かつ高さが $ht[i] < ht[j]$ を満たすとします。つまり、$i$ が短い板、$j$ が長い板です。
|
||||
|
||||

|
||||
|
||||
下の図に示すように、**高い仕切り $j$ を短い仕切り $i$ に近づけて移動すると、容量は確実に減少します**。
|
||||
以下の図のように、**このとき長い板 $j$ を短い板 $i$ に近づけると、容量は必ず小さくなります**。
|
||||
|
||||
これは、高い仕切り $j$ を移動すると、幅 $j-i$ が確実に減少するためです。高さは短い仕切りによって決定されるため、高さは同じまま($i$ が短い仕切りのまま)か減少(移動した $j$ が短い仕切りになる)しかありません。
|
||||
これは、長い板 $j$ を動かした後は幅 $j-i$ が必ず小さくなるためです。また、高さは短い板で決まるので、高さは変わらない( $i$ が依然として短い板)か、小さくなる(移動後の $j$ が短い板になる)ことしかありません。
|
||||
|
||||

|
||||

|
||||
|
||||
逆に、**短い仕切り $i$ を内側に移動することによってのみ容量を増加させることが可能です**。幅は確実に減少しますが、**高さが増加する可能性があります**(移動した短い仕切り $i$ が高くなる場合)。例えば、下の図では、短い仕切りを移動した後に面積が増加しています。
|
||||
逆に考えると、**短い板 $i$ を内側へ縮めた場合にのみ、容量が大きくなる可能性があります**。幅は必ず小さくなりますが、**高さは大きくなる可能性がある**からです(移動後の短い板 $i$ がより長くなる可能性があります)。たとえば次の図では、短い板を動かした後に面積が大きくなっています。
|
||||
|
||||

|
||||

|
||||
|
||||
これにより、この問題の貪欲戦略が導かれます:コンテナの両端に2つのポインタを初期化し、各ラウンドで短い仕切りに対応するポインタを内側に移動し、2つのポインタが出会うまで続けます。
|
||||
以上から、本問の貪欲戦略を導けます。2 本のポインタを初期化して容器の両端に置き、各ラウンドで短い板に対応するポインタを内側へ縮め、2 本のポインタが出会うまで続けます。
|
||||
|
||||
下の図は貪欲戦略の実行を示しています。
|
||||
以下の図は、貪欲戦略の実行過程を示しています。
|
||||
|
||||
1. 最初に、ポインタ $i$ と $j$ が配列の両端に配置されます。
|
||||
1. 初期状態では、ポインタ $i$ と $j$ は配列の両端にあります。
|
||||
2. 現在の状態の容量 $cap[i, j]$ を計算し、最大容量を更新します。
|
||||
3. 仕切り $i$ と $j$ の高さを比較し、短い仕切りを1ステップ内側に移動します。
|
||||
4. $i$ と $j$ が出会うまでステップ `2.` と `3.` を繰り返します。
|
||||
3. 板 $i$ と板 $j$ の高さを比較し、短い板を内側へ 1 マス移動します。
|
||||
4. `2.` と `3.` を繰り返し実行し、$i$ と $j$ が出会ったら終了します。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -72,11 +72,11 @@ $$
|
||||
=== "<9>"
|
||||

|
||||
|
||||
### 実装
|
||||
### コード実装
|
||||
|
||||
コードは最大 $n$ 回ループするため、**時間計算量は $O(n)$** です。
|
||||
コードのループ回数は最大でも $n$ 回であるため、**時間計算量は $O(n)$** です。
|
||||
|
||||
変数 $i$、$j$、$res$ は一定量の追加スペースを使用するため、**空間計算量は $O(1)$** です。
|
||||
変数 $i$、$j$、$res$ が使う追加領域は定数サイズなので、**空間計算量は $O(1)$** です。
|
||||
|
||||
```src
|
||||
[file]{max_capacity}-[class]{}-[func]{max_capacity}
|
||||
@@ -84,16 +84,16 @@ $$
|
||||
|
||||
### 正しさの証明
|
||||
|
||||
貪欲法が列挙よりも高速である理由は、各ラウンドの貪欲選択が一部の状態を「スキップ」するからです。
|
||||
貪欲法が総当たりより速いのは、各ラウンドの貪欲な選択がいくつかの状態を「スキップ」するためです。
|
||||
|
||||
例えば、$i$ が短い仕切りで $j$ が高い仕切りである状態 $cap[i, j]$ の下で、短い仕切り $i$ を貪欲に1ステップ内側に移動すると、下の図に示す「スキップされた」状態につながります。**これは、これらの状態の容量を後で検証できないことを意味します**。
|
||||
たとえば状態 $cap[i, j]$ において、$i$ が短い板、$j$ が長い板だとします。貪欲に短い板 $i$ を内側へ 1 マス動かすと、次の図に示す状態が「スキップ」されます。**これは、その後それらの状態の容量を検証できないことを意味します**。
|
||||
|
||||
$$
|
||||
cap[i, i+1], cap[i, i+2], \dots, cap[i, j-2], cap[i, j-1]
|
||||
$$
|
||||
|
||||

|
||||

|
||||
|
||||
観察すると、**これらのスキップされた状態は実際には高い仕切り $j$ が内側に移動したすべての状態**です。高い仕切りを内側に移動すると容量が確実に減少することをすでに証明しました。したがって、スキップされた状態は最適解である可能性がなく、**それらをスキップしても最適解を見逃すことはありません**。
|
||||
観察すると、**これらのスキップされた状態は、実際には長い板 $j$ を内側へ動かしたすべての状態そのものです**。前述のとおり、長い板を内側へ動かすと容量は必ず小さくなります。つまり、スキップされた状態はいずれも最適解にはなりえず、**それらを飛ばしても最適解を逃すことはありません**。
|
||||
|
||||
分析により、短い仕切りを移動する操作は「安全」であり、貪欲戦略が効果的であることが示されます。
|
||||
以上の分析から、短い板を動かす操作は「安全」であり、貪欲戦略は有効であると分かります。
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
# 最大積切断問題
|
||||
# 最大積分割問題
|
||||
|
||||
!!! question
|
||||
|
||||
正の整数 $n$ が与えられたとき、それを合計が $n$ になる少なくとも2つの正の整数に分割し、これらの整数の最大積を求めてください。下の図に示すとおりです。
|
||||
正整数 $n$ が与えられたとき、それを少なくとも 2 つの正整数の和に分割し、分割後のすべての整数の積の最大値を求めよ。下図に示す。
|
||||
|
||||

|
||||

|
||||
|
||||
$n$ を $m$ 個の整数因子に分割すると仮定し、$i$ 番目の因子を $n_i$ と表記すると、
|
||||
仮に $n$ を $m$ 個の整数因子に分割し、そのうち第 $i$ 個の因子を $n_i$ と記すと、
|
||||
|
||||
$$
|
||||
n = \sum_{i=1}^{m}n_i
|
||||
$$
|
||||
|
||||
この問題の目標は、すべての整数因子の最大積を見つけることです。すなわち、
|
||||
本問題の目的は、すべての整数因子の積の最大値を求めることであり、すなわち
|
||||
|
||||
$$
|
||||
\max(\prod_{i=1}^{m}n_i)
|
||||
$$
|
||||
|
||||
考慮すべき点:分割数 $m$ はどの程度大きくすべきか、各 $n_i$ は何であるべきか?
|
||||
考えるべきことは、分割数 $m$ をいくつにすべきか、各 $n_i$ をいくつにすべきかである。
|
||||
|
||||
### 貪欲戦略の決定
|
||||
|
||||
経験的に、2つの整数の積は多くの場合その和よりも大きくなります。$n$ から因子 $2$ を分割すると仮定すると、その積は $2(n-2)$ です。この積を $n$ と比較します:
|
||||
経験的に、2 つの整数の積はその和より大きくなることが多い。$n$ から因子 $2$ を 1 つ切り出すと、それらの積は $2(n-2)$ となる。この積を $n$ と比較すると、
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
@@ -32,54 +32,54 @@ n & \geq 4
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
下の図に示すように、$n \geq 4$ のとき、$2$ を分割すると積が増加します。**これは4以上の整数を分割すべきであることを示しています**。
|
||||
下図のように、$n \geq 4$ のとき、$2$ を 1 つ切り出すと積は大きくなる。**これは、$4$ 以上の整数はすべて分割すべきことを意味する**。
|
||||
|
||||
**貪欲戦略1**:分割スキームが $\geq 4$ の因子を含む場合、それらはさらに分割されるべきです。最終的な分割は因子 $1$、$2$、$3$ のみを含むべきです。
|
||||
**貪欲戦略一**:分割方法に $\geq 4$ の因子が含まれるなら、それはさらに分割すべきである。最終的な分割方法に現れる因子は $1$、$2$、$3$ の 3 種類だけである。
|
||||
|
||||

|
||||

|
||||
|
||||
次に、どの因子が最適かを考慮します。因子 $1$、$2$、$3$ の中で、明らかに $1$ が最悪です。$1 \times (n-1) < n$ が常に成り立つため、$1$ を分割すると実際に積が減少します。
|
||||
次に、どの因子が最適かを考える。$1$、$2$、$3$ の 3 つの因子のうち、明らかに $1$ が最も悪い。なぜなら $1 \times (n-1) < n$ は常に成り立ち、$1$ を切り出すとかえって積が小さくなるからである。
|
||||
|
||||
下の図に示すように、$n = 6$ のとき、$3 \times 3 > 2 \times 2 \times 2$ です。**これは $3$ を分割する方が $2$ を分割するよりも良いことを意味します**。
|
||||
下図のように、$n = 6$ のとき、$3 \times 3 > 2 \times 2 \times 2$ が成り立つ。**これは、$2$ を切り出すより $3$ を切り出すほうが有利であることを意味する**。
|
||||
|
||||
**貪欲戦略2**:分割スキームには最大で2つの $2$ があるべきです。3つの $2$ は常に2つの $3$ に置き換えてより高い積を得ることができるからです。
|
||||
**貪欲戦略二**:分割方法の中に存在してよい $2$ は高々 2 つである。なぜなら、3 つの $2$ は常に 2 つの $3$ に置き換えられ、より大きな積を得られるからである。
|
||||
|
||||

|
||||
|
||||
上記から、以下の貪欲戦略を導出できます。
|
||||
以上より、次の貪欲戦略が導かれる。
|
||||
|
||||
1. 入力整数 $n$ について、余りが $0$、$1$、または $2$ になるまで因子 $3$ を継続的に分割します。
|
||||
2. 余りが $0$ の場合、$n$ が $3$ の倍数であることを意味するため、それ以上の行動は取りません。
|
||||
3. 余りが $2$ の場合、さらに分割を続けず、そのまま保持します。
|
||||
4. 余りが $1$ の場合、$2 \times 2 > 1 \times 3$ であるため、最後の $3$ を $2$ に置き換えるべきです。
|
||||
1. 整数 $n$ を入力し、余りが $0$、$1$、$2$ になるまで、そこから因子 $3$ を繰り返し切り出す。
|
||||
2. 余りが $0$ のとき、$n$ は $3$ の倍数であることを表すため、何も処理しない。
|
||||
3. 余りが $2$ のときは、それ以上分割せず、そのまま残す。
|
||||
4. 余りが $1$ のとき、$2 \times 2 > 1 \times 3$ であるため、最後の $3$ を $2$ に置き換えるべきである。
|
||||
|
||||
### コード実装
|
||||
|
||||
下の図に示すように、整数を分割するためにループを使用する必要はなく、床除算演算を使用して $3$ の数 $a$ を取得し、剰余演算を使用して余り $b$ を取得できます。したがって:
|
||||
下図のように、ループで整数を分割する必要はなく、切り捨て除算によって $3$ の個数 $a$ を、剰余演算によって余り $b$ を得られる。このとき、
|
||||
|
||||
$$
|
||||
n = 3a + b
|
||||
n = 3 a + b
|
||||
$$
|
||||
|
||||
$n \leq 3$ の境界ケースでは、$1$ を分割する必要があり、積は $1 \times (n - 1)$ であることに注意してください。
|
||||
なお、$n \leq 3$ の境界ケースでは、必ず $1$ を 1 つ分割する必要があり、積は $1 \times (n - 1)$ となる。
|
||||
|
||||
```src
|
||||
[file]{max_product_cutting}-[class]{}-[func]{max_product_cutting}
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
**時間計算量はプログラミング言語のべき乗演算の実装に依存します**。Pythonでは、よく使用されるべき乗計算関数は3種類あります:
|
||||
**時間計算量は、プログラミング言語におけるべき乗演算の実装方法に依存する**。Python を例に取ると、よく使われるべき乗計算関数は 3 種類ある。
|
||||
|
||||
- 演算子 `**` と関数 `pow()` の両方の時間計算量は $O(\log a)$ です。
|
||||
- `math.pow()` 関数は内部でC言語ライブラリの `pow()` 関数を呼び出し、浮動小数点べき乗を実行し、時間計算量は $O(1)$ です。
|
||||
- 演算子 `**` と関数 `pow()` の時間計算量はいずれも $O(\log a)$ である。
|
||||
- 関数 `math.pow()` は内部で C 言語ライブラリの `pow()` 関数を呼び出し、浮動小数点のべき乗を実行するため、時間計算量は $O(1)$ である。
|
||||
|
||||
変数 $a$ と $b$ は一定サイズの追加スペースを使用するため、**空間計算量は $O(1)$** です。
|
||||
変数 $a$ と $b$ が使う追加領域は定数サイズであり、**したがって空間計算量は $O(1)$ である**。
|
||||
|
||||
### 正しさの証明
|
||||
|
||||
背理法を使用し、$n \geq 3$ のケースのみを分析します。
|
||||
背理法を用い、$n \geq 4$ の場合のみを考える。
|
||||
|
||||
1. **すべての因子 $\leq 3$**:最適分割スキームが因子 $x \geq 4$ を含むと仮定すると、それを確実に $2(x-2)$ にさらに分割でき、より大きな積を得られます。これは仮定と矛盾します。
|
||||
2. **分割スキームに $1$ が含まれない**:最適分割スキームが因子 $1$ を含むと仮定すると、それを確実に別の因子と結合してより大きな積を得られます。これは仮定と矛盾します。
|
||||
3. **分割スキームには最大で2つの $2$ が含まれる**:最適分割スキームが3つの $2$ を含むと仮定すると、それらを確実に2つの $3$ に置き換えて、より高い積を達成できます。これは仮定と矛盾します。
|
||||
1. **すべての因子は $\leq 3$** :最適な分割方法に $\geq 4$ の因子 $x$ が存在すると仮定すると、それは必ずさらに $2(x-2)$ に分割でき、より大きい(または等しい)積が得られる。これは仮定に矛盾する。
|
||||
2. **分割方法に $1$ は含まれない** :最適な分割方法に因子 $1$ が 1 つ存在すると仮定すると、それは必ず別の因子に併合でき、より大きい積を得られる。これは仮定に矛盾する。
|
||||
3. **分割方法に含まれる $2$ は高々 2 つ** :最適な分割方法に 3 つの $2$ が含まれると仮定すると、それは必ず 2 つの $3$ に置き換えられ、積はより大きくなる。これは仮定に矛盾する。
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# まとめ
|
||||
|
||||
- 貪欲アルゴリズムは最適化問題を解決するためによく使用され、原理は各決定段階で局所的に最適な決定を行い、グローバルに最適な解を達成することです。
|
||||
- 貪欲アルゴリズムは貪欲な選択を次々と反復的に行い、各ラウンドで問題をより小さな部分問題に変換し、問題が解決されるまで続けます。
|
||||
- 貪欲アルゴリズムは実装が簡単なだけでなく、問題解決効率も高いです。動的プログラミングと比較して、貪欲アルゴリズムは一般的により低い時間計算量を持ちます。
|
||||
- コイン交換問題において、貪欲アルゴリズムは特定のコインの組み合わせに対して最適解を保証できますが、他の組み合わせでは貪欲アルゴリズムが非常に悪い解を見つける可能性があります。
|
||||
- 貪欲アルゴリズム解法に適した問題は2つの主要な性質を持ちます:貪欲選択性と最適部分構造。貪欲選択性は貪欲戦略の効果を表します。
|
||||
- 一部の複雑な問題では、貪欲選択性を証明することは簡単ではありません。逆に、無効性を証明することはしばしばより容易で、コイン交換問題などがその例です。
|
||||
- 貪欲問題の解決は主に3つのステップから構成されます:問題分析、貪欲戦略の決定、正しさの証明。このうち、貪欲戦略の決定が重要なステップであり、正しさの証明がしばしば挑戦となります。
|
||||
- 分数ナップサック問題は0-1ナップサック問題に基づいてアイテムの一部の選択を可能にし、したがって貪欲アルゴリズムを使用して解決できます。貪欲戦略の正しさは背理法によって証明できます。
|
||||
- 最大容量問題は全探索法で解決でき、時間計算量は $O(n^2)$ です。貪欲戦略を設計することで、各ラウンドで短い板を内側に移動し、時間計算量を $O(n)$ に最適化します。
|
||||
- 切断後の最大積問題において、2つの貪欲戦略を導出します:$\geq 4$ の整数は継続的に切断されるべきで、最適な切断因子は $3$ です。コードにはべき乗演算が含まれ、時間計算量はべき乗演算の実装方法に依存し、一般的に $O(1)$ または $O(\log n)$ です。
|
||||
### 重要な振り返り
|
||||
|
||||
- 貪欲法は通常、最適化問題を解くために用いられ、その原理は各意思決定段階で局所最適な決定を行い、全体最適解を得ることを目指すというものである。
|
||||
- 貪欲法は反復的に次々と貪欲な選択を行い、各ラウンドで問題をより小さな部分問題へと変換し、最終的に問題を解決する。
|
||||
- 貪欲法は実装が簡単であるだけでなく、問題を解く効率も高い。動的計画法と比べると、貪欲法の時間計算量は通常より低い。
|
||||
- 硬貨両替問題では、ある種の硬貨の組み合わせに対しては貪欲法で最適解を保証できるが、別の組み合わせではそうではなく、非常に悪い解を見つけてしまう可能性がある。
|
||||
- 貪欲法による解法に適した問題は、貪欲選択性と最適部分構造という 2 つの性質を備えている。貪欲選択性は、貪欲戦略の有効性を表している。
|
||||
- 一部の複雑な問題では、貪欲選択性を証明するのは容易ではない。相対的には、反例による否定のほうが簡単であり、硬貨両替問題がその一例である。
|
||||
- 貪欲法の問題を解く流れは主に 3 段階に分かれる。すなわち、問題分析、貪欲戦略の決定、正しさの証明である。このうち、貪欲戦略の決定が中核であり、正しさの証明はしばしば難所となる。
|
||||
- 分数ナップサック問題は 0-1 ナップサックを基に、品物の一部を選ぶことを許しているため、貪欲法で解くことができる。貪欲戦略の正しさは背理法で証明できる。
|
||||
- 最大容量問題は全探索で解くことができ、時間計算量は $O(n^2)$ である。貪欲戦略を設計し、各ラウンドで短い板を内側へ動かすことで、時間計算量を $O(n)$ に最適化できる。
|
||||
- 最大分割積問題では、2 つの貪欲戦略を順に導いた。すなわち、$\geq 4$ の整数はすべてさらに分割すべきであり、最適な分割因子は $3$ である。コードにはべき乗演算が含まれており、時間計算量はその実装方法に依存し、通常は $O(1)$ または $O(\log n)$ である。
|
||||
|
||||
@@ -1,60 +1,60 @@
|
||||
# ハッシュアルゴリズム
|
||||
|
||||
前の2つの節では、ハッシュ表の動作原理とハッシュ衝突を処理する方法を紹介しました。しかし、オープンアドレス法と連鎖法はどちらも**衝突が発生した際にハッシュ表が正常に機能することのみを保証でき、ハッシュ衝突の発生頻度を減らすことはできません**。
|
||||
前の 2 節では、ハッシュテーブルの動作原理とハッシュ衝突の処理方法を紹介しました。しかし、オープンアドレス法であれ連鎖方式であれ、**それらが保証できるのは衝突発生時でもハッシュテーブルが正常に動作することだけであり、ハッシュ衝突そのものを減らすことはできません**。
|
||||
|
||||
ハッシュ衝突があまりにも頻繁に発生すると、ハッシュ表の性能は劇的に悪化します。下図に示すように、連鎖法ハッシュ表では、理想的なケースではキー値ペアがバケット間に均等に分散され、最適なクエリ効率を実現します。最悪のケースでは、すべてのキー値ペアが同じバケットに格納され、時間計算量が$O(n)$に悪化します。
|
||||
ハッシュ衝突があまりにも頻繁に発生すると、ハッシュテーブルの性能は急激に劣化します。下図のように、連鎖方式のハッシュテーブルでは、理想的な場合にはキーと値のペアが各バケットに均等に分布し、最良の検索効率を達成します。最悪の場合には、すべてのキーと値のペアが同じバケットに格納され、時間計算量は $O(n)$ に劣化します。
|
||||
|
||||

|
||||

|
||||
|
||||
**キー値ペアの分布はハッシュ関数によって決定されます**。ハッシュ関数の計算ステップを思い出すと、まずハッシュ値を計算し、次に配列長で剰余を取ります:
|
||||
**キーと値のペアの分布はハッシュ関数によって決まります**。ハッシュ関数の計算手順を思い出すと、まずハッシュ値を計算し、その後で配列長に対して剰余を取ります。
|
||||
|
||||
```shell
|
||||
index = hash(key) % capacity
|
||||
```
|
||||
|
||||
上記の式を観察すると、ハッシュ表の容量`capacity`が固定されている場合、**ハッシュアルゴリズム`hash()`が出力値を決定し**、それによってハッシュ表におけるキー値ペアの分布を決定します。
|
||||
上の式から分かるように、ハッシュテーブルの容量 `capacity` が固定されているとき、**出力値を決めるのはハッシュアルゴリズム `hash()` です**。したがって、それがキーと値のペアのハッシュテーブル内での分布も決定します。
|
||||
|
||||
これは、ハッシュ衝突の確率を減らすために、ハッシュアルゴリズム`hash()`の設計に焦点を当てるべきであることを意味します。
|
||||
これは、ハッシュ衝突の発生確率を下げるには、ハッシュアルゴリズム `hash()` の設計に注目すべきだということを意味します。
|
||||
|
||||
## ハッシュアルゴリズムの目標
|
||||
|
||||
「高速で安定した」ハッシュ表データ構造を実現するために、ハッシュアルゴリズムは以下の特性を持つべきです:
|
||||
「高速かつ安定した」ハッシュテーブルというデータ構造を実現するために、ハッシュアルゴリズムは次の特徴を備える必要があります。
|
||||
|
||||
- **決定性**: 同じ入力に対して、ハッシュアルゴリズムは常に同じ出力を生成するべきです。そうでなければハッシュ表は信頼できません。
|
||||
- **高効率**: ハッシュ値を計算するプロセスは十分に高速である必要があります。計算オーバーヘッドが小さいほど、ハッシュ表はより実用的になります。
|
||||
- **均等分散**: ハッシュアルゴリズムはキー値ペアがハッシュ表に均等に分散されることを保証するべきです。分散が均等であるほど、ハッシュ衝突の確率は低くなります。
|
||||
- **決定性**:同じ入力に対して、ハッシュアルゴリズムは常に同じ出力を生成しなければなりません。そうして初めて、ハッシュテーブルの信頼性が保たれます。
|
||||
- **高効率**:ハッシュ値の計算過程は十分に高速であるべきです。計算コストが小さいほど、ハッシュテーブルの実用性は高くなります。
|
||||
- **均一分布**:ハッシュアルゴリズムは、キーと値のペアがハッシュテーブル内に均等に分布するようにすべきです。分布が均一であるほど、ハッシュ衝突の確率は低くなります。
|
||||
|
||||
実際、ハッシュアルゴリズムはハッシュ表の実装だけでなく、他の分野でも広く応用されています。
|
||||
実際には、ハッシュアルゴリズムはハッシュテーブルの実装だけでなく、ほかの多くの分野でも広く利用されています。
|
||||
|
||||
- **パスワード保存**: ユーザーパスワードのセキュリティを保護するために、システムは通常平文パスワードを保存せず、パスワードのハッシュ値を保存します。ユーザーがパスワードを入力すると、システムは入力のハッシュ値を計算し、保存されているハッシュ値と比較します。一致すれば、パスワードは正しいと見なされます。
|
||||
- **データ整合性チェック**: データ送信者はデータのハッシュ値を計算して一緒に送信できます。受信者は受信したデータのハッシュ値を再計算し、受信したハッシュ値と比較できます。一致すれば、データは完全であると見なされます。
|
||||
- **パスワード保存**:ユーザーのパスワードを保護するために、システムは通常、平文パスワードを直接保存せず、そのハッシュ値を保存します。ユーザーがパスワードを入力すると、システムは入力内容のハッシュ値を計算し、保存済みのハッシュ値と比較します。一致すれば、そのパスワードは正しいと見なされます。
|
||||
- **データ完全性検査**:送信側はデータのハッシュ値を計算してデータと一緒に送信できます。受信側は受け取ったデータのハッシュ値を再計算し、受信したハッシュ値と比較できます。両者が一致すれば、そのデータは完全だと見なされます。
|
||||
|
||||
暗号化アプリケーションでは、ハッシュ値から元のパスワードを推測するなどの逆行分析を防ぐために、ハッシュアルゴリズムはより高いレベルのセキュリティ機能が必要です。
|
||||
暗号分野の応用では、ハッシュ値から元のパスワードを推測するといった逆解析を防ぐために、ハッシュアルゴリズムにはさらに高いレベルの安全性が求められます。
|
||||
|
||||
- **一方向性**: ハッシュ値から入力データに関する情報を推測することは不可能であるべきです。
|
||||
- **衝突耐性**: 同じハッシュ値を生成する2つの異なる入力を見つけることは極めて困難であるべきです。
|
||||
- **雪崩効果**: 入力の小さな変更は、出力に大きく予測不可能な変化をもたらすべきです。
|
||||
- **一方向性**:ハッシュ値から入力データに関するいかなる情報も逆算できないこと。
|
||||
- **耐衝突性**:異なる 2 つの入力で同じハッシュ値になるものを見つけることが、極めて困難であること。
|
||||
- **アバランシェ効果**:入力のわずかな変化が、出力の大きく予測不能な変化を引き起こすこと。
|
||||
|
||||
**「均等分散」と「衝突耐性」は2つの別々の概念**であることに注意してください。均等分散を満たしても、必ずしも衝突耐性があるとは限りません。例えば、ランダムな入力`key`の下で、ハッシュ関数`key % 100`は均等に分散された出力を生成できます。しかし、このハッシュアルゴリズムは過度にシンプルで、下二桁が同じすべての`key`は同じ出力を持つため、ハッシュ値から使用可能な`key`を簡単に推測でき、パスワードを破ることができます。
|
||||
注意してほしいのは、**「均一分布」と「耐衝突性」は独立した 2 つの概念である**という点です。均一分布を満たしていても、耐衝突性を満たすとは限りません。たとえば、入力 `key` がランダムである場合、ハッシュ関数 `key % 100` は均一に分布した出力を生成できます。しかし、このハッシュアルゴリズムはあまりにも単純で、下 2 桁が同じ `key` はすべて同じ出力になります。そのため、ハッシュ値から利用可能な `key` を容易に逆算でき、結果としてパスワードが破られてしまいます。
|
||||
|
||||
## ハッシュアルゴリズムの設計
|
||||
|
||||
ハッシュアルゴリズムの設計は多くの要因を考慮する必要がある複雑な問題です。しかし、要求が少ない一部のシナリオでは、いくつかの簡単なハッシュアルゴリズムを設計することもできます。
|
||||
ハッシュアルゴリズムの設計は、多くの要素を考慮しなければならない複雑な問題です。しかし、要求の高くない場面であれば、いくつかの単純なハッシュアルゴリズムを設計することもできます。
|
||||
|
||||
- **加算ハッシュ**: 入力の各文字のASCIIコードを合計し、合計をハッシュ値として使用します。
|
||||
- **乗算ハッシュ**: 乗算の非相関性を利用し、各ラウンドで定数を乗算し、各文字のASCIIコードをハッシュ値に累積します。
|
||||
- **XORハッシュ**: 入力データの各要素をXORすることでハッシュ値を累積します。
|
||||
- **回転ハッシュ**: 各文字のASCIIコードをハッシュ値に累積し、各累積前にハッシュ値に回転操作を実行します。
|
||||
- **加算ハッシュ**:入力の各文字の ASCII コードを足し合わせ、その合計をハッシュ値とします。
|
||||
- **乗算ハッシュ**:乗算の非相関性を利用し、各ラウンドで定数を掛けながら、各文字の ASCII コードをハッシュ値に累積します。
|
||||
- **XOR ハッシュ**:入力データの各要素を XOR 演算で 1 つのハッシュ値に累積します。
|
||||
- **回転ハッシュ**:各文字の ASCII コードを 1 つのハッシュ値に累積し、各回の累積前にハッシュ値を回転させます。
|
||||
|
||||
```src
|
||||
[file]{simple_hash}-[class]{}-[func]{rot_hash}
|
||||
```
|
||||
|
||||
各ハッシュアルゴリズムの最後のステップが大きな素数$1000000007$の剰余を取ることで、ハッシュ値が適切な範囲内にあることを保証していることが観察されます。なぜ素数の剰余を取ることが強調されるのか、または合成数の剰余を取ることの欠点は何かを考える価値があります。これは興味深い質問です。
|
||||
見て分かるように、各ハッシュアルゴリズムの最後のステップでは、大きな素数 $1000000007$ で剰余を取り、ハッシュ値が適切な範囲に収まるようにしています。ここで考えてみる価値があるのは、なぜ素数での剰余を強調するのか、あるいは合成数で剰余を取ることにどんな欠点があるのか、という点です。これは興味深い問題です。
|
||||
|
||||
結論として:**大きな素数を剰余として使用することで、ハッシュ値の均等分散を最大化できます**。素数は他の数と共通因子を持たないため、剰余演算によって引き起こされる周期的パターンを減らし、ハッシュ衝突を回避できます。
|
||||
先に結論を述べると、**法として大きな素数を使うと、ハッシュ値が均一に分布することを最大限に保証できます**。素数はほかの数と公約数を持たないため、剰余演算によって生じる周期的なパターンを減らし、ハッシュ衝突を避けやすくなります。
|
||||
|
||||
例えば、合成数$9$を剰余として選択するとします。これは$3$で割り切れるため、$3$で割り切れるすべての`key`はハッシュ値$0$、$3$、$6$にマッピングされます。
|
||||
たとえば、法として合成数 $9$ を選ぶとします。これは $3$ で割り切れるため、$3$ で割り切れるすべての `key` は、$0$、$3$、$6$ の 3 つのハッシュ値に写像されます。
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
@@ -64,7 +64,7 @@ $$
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
入力`key`がたまたまこの種の等差数列分布を持つ場合、ハッシュ値がクラスターし、ハッシュ衝突を悪化させます。今度は`modulus`を素数$13$に置き換えるとします。`key`と`modulus`の間に共通因子がないため、出力ハッシュ値の均等性が大幅に改善されます。
|
||||
入力 `key` がたまたまこのような等差数列の分布をしていると、ハッシュ値に偏りが生じ、ハッシュ衝突がさらに深刻になります。そこで `modulus` を素数 $13$ に置き換えると仮定すると、`key` と `modulus` の間に公約数が存在しないため、出力されるハッシュ値の均一性は明らかに向上します。
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
@@ -74,71 +74,71 @@ $$
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
`key`がランダムで均等に分散されることが保証されている場合、剰余として素数または合成数を選択しても、両方とも均等に分散されたハッシュ値を生成できることは注目に値します。しかし、`key`の分布にある種の周期性がある場合、合成数の剰余はクラスタリングを引き起こしやすくなります。
|
||||
補足すると、`key` がランダムかつ均一に分布していると保証できるなら、法に素数を選んでも合成数を選んでも構いません。どちらでも均一に分布したハッシュ値を出力できます。しかし、`key` の分布に何らかの周期性がある場合、合成数で剰余を取るほうが偏りが生じやすくなります。
|
||||
|
||||
要約すると、通常は素数を剰余として選択し、この素数は周期的パターンを可能な限り排除し、ハッシュアルゴリズムの堅牢性を向上させるために十分大きくある必要があります。
|
||||
要するに、通常は法として素数を選び、その素数はできるだけ大きいほうが望ましいです。そうすることで周期的なパターンをできる限り取り除き、ハッシュアルゴリズムの堅牢性を高められます。
|
||||
|
||||
## 一般的なハッシュアルゴリズム
|
||||
|
||||
上記で言及した簡単なハッシュアルゴリズムはかなり「脆弱」で、ハッシュアルゴリズムの設計目標から程遠いことは難しくありません。例えば、加算とXORは交換法則に従うため、加算ハッシュとXORハッシュは同じ内容だが順序が異なる文字列を区別できず、ハッシュ衝突を悪化させ、セキュリティ問題を引き起こす可能性があります。
|
||||
上で紹介した単純なハッシュアルゴリズムは、どれも比較的「脆弱」であり、ハッシュアルゴリズムの設計目標にはほど遠いことが分かります。たとえば、加算と XOR は交換法則を満たすため、加算ハッシュと XOR ハッシュでは、内容が同じで順序だけ異なる文字列を区別できません。これはハッシュ衝突を悪化させ、一部の安全上の問題を引き起こす可能性があります。
|
||||
|
||||
実際には、通常MD5、SHA-1、SHA-2、SHA-3などの標準ハッシュアルゴリズムを使用します。これらは任意の長さの入力データを固定長のハッシュ値にマッピングできます。
|
||||
実際には、MD5、SHA-1、SHA-2、SHA-3 などの標準的なハッシュアルゴリズムを用いることが一般的です。これらは任意長の入力データを、固定長のハッシュ値へ写像できます。
|
||||
|
||||
過去1世紀にわたって、ハッシュアルゴリズムは継続的なアップグレードと最適化のプロセスにありました。一部の研究者はハッシュアルゴリズムの性能向上に努め、ハッカーを含む他の人々はハッシュアルゴリズムのセキュリティ問題を見つけることに専念しています。以下の表は、実用的なアプリケーションで一般的に使用されるハッシュアルゴリズムを示しています。
|
||||
ここ 1 世紀近くの間、ハッシュアルゴリズムは継続的に改良と最適化が進められてきました。ある研究者たちは性能向上に取り組み、別の研究者やハッカーたちは安全性の弱点を探し続けてきました。次の表は、実際の応用でよく使われるハッシュアルゴリズムを示したものです。
|
||||
|
||||
- MD5とSHA-1は複数回攻撃に成功しており、さまざまなセキュリティアプリケーションで放棄されています。
|
||||
- SHA-2シリーズ、特にSHA-256は、現在最も安全なハッシュアルゴリズムの1つで、成功した攻撃は報告されておらず、さまざまなセキュリティアプリケーションとプロトコルで一般的に使用されています。
|
||||
- SHA-3はSHA-2と比較して実装コストが低く、計算効率が高いですが、現在の使用範囲はSHA-2シリーズほど広範囲ではありません。
|
||||
- MD5 と SHA-1 は何度も攻撃に成功されているため、各種のセキュリティ用途では廃止されています。
|
||||
- SHA-2 系列の SHA-256 は最も安全なハッシュアルゴリズムの 1 つであり、いまだに成功した攻撃例がないため、多くのセキュリティ用途やプロトコルで広く使われています。
|
||||
- SHA-3 は SHA-2 と比べて実装コストが低く、計算効率も高い一方で、現時点での普及度は SHA-2 系列に及びません。
|
||||
|
||||
<p align="center"> 表 <id> 一般的なハッシュアルゴリズム </p>
|
||||
|
||||
| | MD5 | SHA-1 | SHA-2 | SHA-3 |
|
||||
| --------------- | ----------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------- | ---------------------------- |
|
||||
| リリース年 | 1992 | 1995 | 2002 | 2008 |
|
||||
| 出力長 | 128 bit | 160 bit | 256/512 bit | 224/256/384/512 bit |
|
||||
| ハッシュ衝突 | 頻繁 | 頻繁 | まれ | まれ |
|
||||
| セキュリティレベル | 低、攻撃に成功している | 低、攻撃に成功している | 高 | 高 |
|
||||
| アプリケーション | 放棄、データ整合性チェックにまだ使用 | 放棄 | 暗号通貨取引検証、デジタル署名など | SHA-2の代替として使用可能 |
|
||||
| | MD5 | SHA-1 | SHA-2 | SHA-3 |
|
||||
| -------- | ------------------------------ | ---------------- | ---------------------------- | ------------------- |
|
||||
| 発表年 | 1992 | 1995 | 2002 | 2008 |
|
||||
| 出力長 | 128 bit | 160 bit | 256/512 bit | 224/256/384/512 bit |
|
||||
| ハッシュ衝突 | 多い | 多い | 非常に少ない | 非常に少ない |
|
||||
| セキュリティレベル | 低く、攻撃に成功されている | 低く、攻撃に成功されている | 高い | 高い |
|
||||
| 用途 | 廃止済みだが、データ完全性検査には使われる | 廃止済み | 暗号資産の取引検証、デジタル署名など | SHA-2 の代替に使える |
|
||||
|
||||
# データ構造におけるハッシュ値
|
||||
## データ構造のハッシュ値
|
||||
|
||||
ハッシュ表のキーは整数、小数、文字列などのさまざまなデータ型にできることを知っています。プログラミング言語は通常、これらのデータ型に対して組み込みのハッシュアルゴリズムを提供し、ハッシュ表のバケットインデックスを計算します。Pythonを例に取ると、`hash()`関数を使用してさまざまなデータ型のハッシュ値を計算できます。
|
||||
ご存じのように、ハッシュテーブルの `key` には整数、小数、文字列などのデータ型を使えます。プログラミング言語は通常、これらのデータ型に対して組み込みのハッシュアルゴリズムを提供し、ハッシュテーブル内のバケットインデックス計算に利用します。Python を例にすると、`hash()` 関数を呼び出して各種データ型のハッシュ値を計算できます。
|
||||
|
||||
- 整数とブール値のハッシュ値は、それら自身の値です。
|
||||
- 浮動小数点数と文字列のハッシュ値の計算はより複雑で、興味のある読者は自分で研究することをお勧めします。
|
||||
- タプルのハッシュ値は、その各要素のハッシュ値の組み合わせで、単一のハッシュ値になります。
|
||||
- オブジェクトのハッシュ値は、そのメモリアドレスに基づいて生成されます。オブジェクトのハッシュメソッドをオーバーライドすることで、内容に基づいてハッシュ値を生成できます。
|
||||
- 整数と真理値のハッシュ値は、その値自身です。
|
||||
- 浮動小数点数と文字列のハッシュ値の計算はやや複雑なので、興味がある読者は自分で調べてみてください。
|
||||
- タプルのハッシュ値は、各要素のハッシュ値を求めてから、それらを組み合わせて 1 つのハッシュ値にしたものです。
|
||||
- オブジェクトのハッシュ値は、そのメモリアドレスに基づいて生成されます。オブジェクトのハッシュメソッドをオーバーライドすれば、内容に基づくハッシュ値を実装できます。
|
||||
|
||||
!!! tip
|
||||
|
||||
異なるプログラミング言語における組み込みハッシュ値計算関数の定義と方法は異なることに注意してください。
|
||||
注意してください。組み込みのハッシュ値計算関数の定義や方法は、プログラミング言語ごとに異なります。
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="built_in_hash.py"
|
||||
num = 3
|
||||
hash_num = hash(num)
|
||||
# 整数3のハッシュ値は3
|
||||
# 整数 3 のハッシュ値は 3
|
||||
|
||||
bol = True
|
||||
hash_bol = hash(bol)
|
||||
# ブール値Trueのハッシュ値は1
|
||||
# 真理値 True のハッシュ値は 1
|
||||
|
||||
dec = 3.14159
|
||||
hash_dec = hash(dec)
|
||||
# 小数3.14159のハッシュ値は326484311674566659
|
||||
# 小数 3.14159 のハッシュ値は 326484311674566659
|
||||
|
||||
str = "Hello 算法"
|
||||
str = "Hello アルゴリズム"
|
||||
hash_str = hash(str)
|
||||
# 文字列"Hello 算法"のハッシュ値は4617003410720528961
|
||||
# 文字列「Hello アルゴリズム」のハッシュ値は 4617003410720528961
|
||||
|
||||
tup = (12836, "小哈")
|
||||
tup = (12836, "シャオハ")
|
||||
hash_tup = hash(tup)
|
||||
# タプル(12836, '小哈')のハッシュ値は1029005403108185979
|
||||
# タプル (12836, 'シャオハ') のハッシュ値は 1029005403108185979
|
||||
|
||||
obj = ListNode(0)
|
||||
hash_obj = hash(obj)
|
||||
# ListNodeオブジェクト0x1058fd810のハッシュ値は274267521
|
||||
# ノードオブジェクト <ListNode object at 0x1058fd810> のハッシュ値は 274267521
|
||||
```
|
||||
|
||||
=== "C++"
|
||||
@@ -146,22 +146,22 @@ $$
|
||||
```cpp title="built_in_hash.cpp"
|
||||
int num = 3;
|
||||
size_t hashNum = hash<int>()(num);
|
||||
// 整数3のハッシュ値は3
|
||||
// 整数 3 のハッシュ値は 3
|
||||
|
||||
bool bol = true;
|
||||
size_t hashBol = hash<bool>()(bol);
|
||||
// ブール値1のハッシュ値は1
|
||||
// 真理値 1 のハッシュ値は 1
|
||||
|
||||
double dec = 3.14159;
|
||||
size_t hashDec = hash<double>()(dec);
|
||||
// 小数3.14159のハッシュ値は4614256650576692846
|
||||
// 小数 3.14159 のハッシュ値は 4614256650576692846
|
||||
|
||||
string str = "Hello 算法";
|
||||
string str = "Hello アルゴリズム";
|
||||
size_t hashStr = hash<string>()(str);
|
||||
// 文字列"Hello 算法"のハッシュ値は15466937326284535026
|
||||
// 文字列「Hello アルゴリズム」のハッシュ値は 15466937326284535026
|
||||
|
||||
// C++では、組み込みstd::hash()は基本データ型のハッシュ値のみを提供
|
||||
// 配列とオブジェクトのハッシュ値は別途実装が必要
|
||||
// C++ では、組み込みの std:hash() は基本データ型のハッシュ値計算のみを提供する
|
||||
// 配列やオブジェクトのハッシュ値計算は自分で実装する必要がある
|
||||
```
|
||||
|
||||
=== "Java"
|
||||
@@ -169,27 +169,27 @@ $$
|
||||
```java title="built_in_hash.java"
|
||||
int num = 3;
|
||||
int hashNum = Integer.hashCode(num);
|
||||
// 整数3のハッシュ値は3
|
||||
// 整数 3 のハッシュ値は 3
|
||||
|
||||
boolean bol = true;
|
||||
int hashBol = Boolean.hashCode(bol);
|
||||
// ブール値trueのハッシュ値は1231
|
||||
// 真理値 true のハッシュ値は 1231
|
||||
|
||||
double dec = 3.14159;
|
||||
int hashDec = Double.hashCode(dec);
|
||||
// 小数3.14159のハッシュ値は-1340954729
|
||||
// 小数 3.14159 のハッシュ値は -1340954729
|
||||
|
||||
String str = "Hello 算法";
|
||||
String str = "Hello アルゴリズム";
|
||||
int hashStr = str.hashCode();
|
||||
// 文字列"Hello 算法"のハッシュ値は-727081396
|
||||
// 文字列「Hello アルゴリズム」のハッシュ値は -727081396
|
||||
|
||||
Object[] arr = { 12836, "小哈" };
|
||||
Object[] arr = { 12836, "シャオハ" };
|
||||
int hashTup = Arrays.hashCode(arr);
|
||||
// 配列[12836, 小哈]のハッシュ値は1151158
|
||||
// 配列 [12836, シャオハ] のハッシュ値は 1151158
|
||||
|
||||
ListNode obj = new ListNode(0);
|
||||
int hashObj = obj.hashCode();
|
||||
// ListNodeオブジェクトutils.ListNode@7dc5e7b4のハッシュ値は2110121908
|
||||
// ノードオブジェクト utils.ListNode@7dc5e7b4 のハッシュ値は 2110121908
|
||||
```
|
||||
|
||||
=== "C#"
|
||||
@@ -197,33 +197,33 @@ $$
|
||||
```csharp title="built_in_hash.cs"
|
||||
int num = 3;
|
||||
int hashNum = num.GetHashCode();
|
||||
// 整数3のハッシュ値は3;
|
||||
// 整数 3 のハッシュ値は 3;
|
||||
|
||||
bool bol = true;
|
||||
int hashBol = bol.GetHashCode();
|
||||
// ブール値trueのハッシュ値は1;
|
||||
// 真理値 true のハッシュ値は 1;
|
||||
|
||||
double dec = 3.14159;
|
||||
int hashDec = dec.GetHashCode();
|
||||
// 小数3.14159のハッシュ値は-1340954729;
|
||||
// 小数 3.14159 のハッシュ値は -1340954729;
|
||||
|
||||
string str = "Hello 算法";
|
||||
string str = "Hello アルゴリズム";
|
||||
int hashStr = str.GetHashCode();
|
||||
// 文字列"Hello 算法"のハッシュ値は-586107568;
|
||||
// 文字列「Hello アルゴリズム」のハッシュ値は -586107568;
|
||||
|
||||
object[] arr = [12836, "小哈"];
|
||||
object[] arr = [12836, "シャオハ"];
|
||||
int hashTup = arr.GetHashCode();
|
||||
// 配列[12836, 小哈]のハッシュ値は42931033;
|
||||
// 配列 [12836, シャオハ] のハッシュ値は 42931033;
|
||||
|
||||
ListNode obj = new(0);
|
||||
int hashObj = obj.GetHashCode();
|
||||
// ListNodeオブジェクト0のハッシュ値は39053774;
|
||||
// ノードオブジェクト 0 のハッシュ値は 39053774;
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
||||
```go title="built_in_hash.go"
|
||||
// Goには組み込みのハッシュコード関数が提供されていません
|
||||
// Go は組み込みの hash code 関数を提供していない
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
@@ -231,39 +231,39 @@ $$
|
||||
```swift title="built_in_hash.swift"
|
||||
let num = 3
|
||||
let hashNum = num.hashValue
|
||||
// 整数3のハッシュ値は9047044699613009734
|
||||
// 整数 3 のハッシュ値は 9047044699613009734
|
||||
|
||||
let bol = true
|
||||
let hashBol = bol.hashValue
|
||||
// ブール値trueのハッシュ値は-4431640247352757451
|
||||
// 真理値 true のハッシュ値は -4431640247352757451
|
||||
|
||||
let dec = 3.14159
|
||||
let hashDec = dec.hashValue
|
||||
// 小数3.14159のハッシュ値は-2465384235396674631
|
||||
// 小数 3.14159 のハッシュ値は -2465384235396674631
|
||||
|
||||
let str = "Hello 算法"
|
||||
let str = "Hello アルゴリズム"
|
||||
let hashStr = str.hashValue
|
||||
// 文字列"Hello 算法"のハッシュ値は-7850626797806988787
|
||||
// 文字列「Hello アルゴリズム」のハッシュ値は -7850626797806988787
|
||||
|
||||
let arr = [AnyHashable(12836), AnyHashable("小哈")]
|
||||
let arr = [AnyHashable(12836), AnyHashable("シャオハ")]
|
||||
let hashTup = arr.hashValue
|
||||
// 配列[AnyHashable(12836), AnyHashable("小哈")]のハッシュ値は-2308633508154532996
|
||||
// 配列 [AnyHashable(12836), AnyHashable("シャオハ")] のハッシュ値は -2308633508154532996
|
||||
|
||||
let obj = ListNode(x: 0)
|
||||
let hashObj = obj.hashValue
|
||||
// ListNodeオブジェクトutils.ListNodeのハッシュ値は-2434780518035996159
|
||||
// ノードオブジェクト utils.ListNode のハッシュ値は -2434780518035996159
|
||||
```
|
||||
|
||||
=== "JS"
|
||||
|
||||
```javascript title="built_in_hash.js"
|
||||
// JavaScriptには組み込みのハッシュコード関数が提供されていません
|
||||
// JavaScript は組み込みの hash code 関数を提供していない
|
||||
```
|
||||
|
||||
=== "TS"
|
||||
|
||||
```typescript title="built_in_hash.ts"
|
||||
// TypeScriptには組み込みのハッシュコード関数が提供されていません
|
||||
// TypeScript は組み込みの hash code 関数を提供していない
|
||||
```
|
||||
|
||||
=== "Dart"
|
||||
@@ -271,27 +271,27 @@ $$
|
||||
```dart title="built_in_hash.dart"
|
||||
int num = 3;
|
||||
int hashNum = num.hashCode;
|
||||
// 整数3のハッシュ値は34803
|
||||
// 整数 3 のハッシュ値は 34803
|
||||
|
||||
bool bol = true;
|
||||
int hashBol = bol.hashCode;
|
||||
// ブール値trueのハッシュ値は1231
|
||||
// 真理値 true のハッシュ値は 1231
|
||||
|
||||
double dec = 3.14159;
|
||||
int hashDec = dec.hashCode;
|
||||
// 小数3.14159のハッシュ値は2570631074981783
|
||||
// 小数 3.14159 のハッシュ値は 2570631074981783
|
||||
|
||||
String str = "Hello 算法";
|
||||
String str = "Hello アルゴリズム";
|
||||
int hashStr = str.hashCode;
|
||||
// 文字列"Hello 算法"のハッシュ値は468167534
|
||||
// 文字列「Hello アルゴリズム」のハッシュ値は 468167534
|
||||
|
||||
List arr = [12836, "小哈"];
|
||||
List arr = [12836, "シャオハ"];
|
||||
int hashArr = arr.hashCode;
|
||||
// 配列[12836, 小哈]のハッシュ値は976512528
|
||||
// 配列 [12836, シャオハ] のハッシュ値は 976512528
|
||||
|
||||
ListNode obj = new ListNode(0);
|
||||
int hashObj = obj.hashCode;
|
||||
// ListNodeオブジェクトInstance of 'ListNode'のハッシュ値は1033450432
|
||||
// ノードオブジェクト Instance of 'ListNode' のハッシュ値は 1033450432
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
@@ -304,53 +304,107 @@ $$
|
||||
let mut num_hasher = DefaultHasher::new();
|
||||
num.hash(&mut num_hasher);
|
||||
let hash_num = num_hasher.finish();
|
||||
// 整数3のハッシュ値は568126464209439262
|
||||
// 整数 3 のハッシュ値は 568126464209439262
|
||||
|
||||
let bol = true;
|
||||
let mut bol_hasher = DefaultHasher::new();
|
||||
bol.hash(&mut bol_hasher);
|
||||
let hash_bol = bol_hasher.finish();
|
||||
// ブール値trueのハッシュ値は4952851536318644461
|
||||
// 真理値 true のハッシュ値は 4952851536318644461
|
||||
|
||||
let dec: f32 = 3.14159;
|
||||
let mut dec_hasher = DefaultHasher::new();
|
||||
dec.to_bits().hash(&mut dec_hasher);
|
||||
let hash_dec = dec_hasher.finish();
|
||||
// 小数3.14159のハッシュ値は2566941990314602357
|
||||
// 小数 3.14159 のハッシュ値は 2566941990314602357
|
||||
|
||||
let str = "Hello 算法";
|
||||
let str = "Hello アルゴリズム";
|
||||
let mut str_hasher = DefaultHasher::new();
|
||||
str.hash(&mut str_hasher);
|
||||
let hash_str = str_hasher.finish();
|
||||
// 文字列"Hello 算法"のハッシュ値は16092673739211250988
|
||||
// 文字列「Hello アルゴリズム」のハッシュ値は 16092673739211250988
|
||||
|
||||
let arr = (&12836, &"小哈");
|
||||
let arr = (&12836, &"シャオハ");
|
||||
let mut tup_hasher = DefaultHasher::new();
|
||||
arr.hash(&mut tup_hasher);
|
||||
let hash_tup = tup_hasher.finish();
|
||||
// タプル(12836, "小哈")のハッシュ値は1885128010422702749
|
||||
// タプル (12836, "シャオハ") のハッシュ値は 1885128010422702749
|
||||
|
||||
let node = ListNode::new(42);
|
||||
let mut hasher = DefaultHasher::new();
|
||||
node.borrow().val.hash(&mut hasher);
|
||||
let hash = hasher.finish();
|
||||
// ListNodeオブジェクトRefCell { value: ListNode { val: 42, next: None } }のハッシュ値は15387811073369036852
|
||||
// ノードオブジェクト RefCell { value: ListNode { val: 42, next: None } } のハッシュ値は15387811073369036852
|
||||
```
|
||||
|
||||
=== "C"
|
||||
|
||||
```c title="built_in_hash.c"
|
||||
// Cには組み込みのハッシュコード関数が提供されていません
|
||||
// C は組み込みの hash code 関数を提供していない
|
||||
```
|
||||
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title="built_in_hash.kt"
|
||||
val num = 3
|
||||
val hashNum = num.hashCode()
|
||||
// 整数 3 のハッシュ値は 3
|
||||
|
||||
val bol = true
|
||||
val hashBol = bol.hashCode()
|
||||
// 真理値 true のハッシュ値は 1231
|
||||
|
||||
val dec = 3.14159
|
||||
val hashDec = dec.hashCode()
|
||||
// 小数 3.14159 のハッシュ値は -1340954729
|
||||
|
||||
val str = "Hello アルゴリズム"
|
||||
val hashStr = str.hashCode()
|
||||
// 文字列「Hello アルゴリズム」のハッシュ値は -727081396
|
||||
|
||||
val arr = arrayOf<Any>(12836, "シャオハ")
|
||||
val hashTup = arr.hashCode()
|
||||
// 配列 [12836, シャオハ] のハッシュ値は 189568618
|
||||
|
||||
val obj = ListNode(0)
|
||||
val hashObj = obj.hashCode()
|
||||
// ノードオブジェクト utils.ListNode@1d81eb93 のハッシュ値は 495053715
|
||||
```
|
||||
|
||||
多くのプログラミング言語では、**不変オブジェクトのみがハッシュ表の`key`として機能できます**。リスト(動的配列)を`key`として使用する場合、リストの内容が変更されると、そのハッシュ値も変更され、ハッシュ表で元の`value`を見つけることができなくなります。
|
||||
=== "Ruby"
|
||||
|
||||
カスタムオブジェクト(連結リストノードなど)のメンバー変数は可変ですが、ハッシュ可能です。**これは、オブジェクトのハッシュ値が通常そのメモリアドレスに基づいて生成されるためです**。オブジェクトの内容が変更されても、メモリアドレスは同じままなので、ハッシュ値は変更されません。
|
||||
```ruby title="built_in_hash.rb"
|
||||
num = 3
|
||||
hash_num = num.hash
|
||||
# 整数 3 のハッシュ値は -4385856518450339636
|
||||
|
||||
異なるコンソールで出力されるハッシュ値が異なることに気づいたかもしれません。**これは、Pythonインタープリターが起動するたびに文字列ハッシュ関数にランダムソルトを追加するためです**。このアプローチはHashDoS攻撃を効果的に防ぎ、ハッシュアルゴリズムのセキュリティを向上させます。
|
||||
bol = true
|
||||
hash_bol = bol.hash
|
||||
# 真理値 true のハッシュ値は -1617938112149317027
|
||||
|
||||
dec = 3.14159
|
||||
hash_dec = dec.hash
|
||||
# 小数 3.14159 のハッシュ値は -1479186995943067893
|
||||
|
||||
str = "Hello アルゴリズム"
|
||||
hash_str = str.hash
|
||||
# 文字列「Hello アルゴリズム」のハッシュ値は -4075943250025831763
|
||||
|
||||
tup = [12836, 'シャオハ']
|
||||
hash_tup = tup.hash
|
||||
# タプル (12836, 'シャオハ') のハッシュ値は 1999544809202288822
|
||||
|
||||
obj = ListNode.new(0)
|
||||
hash_obj = obj.hash
|
||||
# ノードオブジェクト #<ListNode:0x000078133140ab70> のハッシュ値は 4302940560806366381
|
||||
```
|
||||
|
||||
??? pythontutor "可視化実行"
|
||||
|
||||
https://pythontutor.com/render.html#code=class%20ListNode%3A%0A%20%20%20%20%22%22%22%E9%93%BE%E8%A1%A8%E8%8A%82%E7%82%B9%E7%B1%BB%22%22%22%0A%20%20%20%20def%20__init__%28self,%20val%3A%20int%29%3A%0A%20%20%20%20%20%20%20%20self.val%3A%20int%20%3D%20val%20%20%23%20%E8%8A%82%E7%82%B9%E5%80%BC%0A%20%20%20%20%20%20%20%20self.next%3A%20ListNode%20%7C%20None%20%3D%20None%20%20%23%20%E5%90%8E%E7%BB%A7%E8%8A%82%E7%82%B9%E5%BC%95%E7%94%A8%0A%0A%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20num%20%3D%203%0A%20%20%20%20hash_num%20%3D%20hash%28num%29%0A%20%20%20%20%23%20%E6%95%B4%E6%95%B0%203%20%E7%9A%84%E5%93%88%E5%B8%8C%E5%80%BC%E4%B8%BA%203%0A%0A%20%20%20%20bol%20%3D%20True%0A%20%20%20%20hash_bol%20%3D%20hash%28bol%29%0A%20%20%20%20%23%20%E5%B8%83%E5%B0%94%E9%87%8F%20True%20%E7%9A%84%E5%93%88%E5%B8%8C%E5%80%BC%E4%B8%BA%201%0A%0A%20%20%20%20dec%20%3D%203.14159%0A%20%20%20%20hash_dec%20%3D%20hash%28dec%29%0A%20%20%20%20%23%20%E5%B0%8F%E6%95%B0%203.14159%20%E7%9A%84%E5%93%88%E5%B8%8C%E5%80%BC%E4%B8%BA%20326484311674566659%0A%0A%20%20%20%20str%20%3D%20%22Hello%20%E7%AE%97%E6%B3%95%22%0A%20%20%20%20hash_str%20%3D%20hash%28str%29%0A%20%20%20%20%23%20%E5%AD%97%E7%AC%A6%E4%B8%B2%E2%80%9CHello%20%E7%AE%97%E6%B3%95%E2%80%9D%E7%9A%84%E5%93%88%E5%B8%8C%E5%80%BC%E4%B8%BA%204617003410720528961%0A%0A%20%20%20%20tup%20%3D%20%2812836,%20%22%E5%B0%8F%E5%93%88%22%29%0A%20%20%20%20hash_tup%20%3D%20hash%28tup%29%0A%20%20%20%20%23%20%E5%85%83%E7%BB%84%20%2812836,%20'%E5%B0%8F%E5%93%88'%29%20%E7%9A%84%E5%93%88%E5%B8%8C%E5%80%BC%E4%B8%BA%201029005403108185979%0A%0A%20%20%20%20obj%20%3D%20ListNode%280%29%0A%20%20%20%20hash_obj%20%3D%20hash%28obj%29%0A%20%20%20%20%23%20%E8%8A%82%E7%82%B9%E5%AF%B9%E8%B1%A1%20%3CListNode%20object%20at%200x1058fd810%3E%20%E7%9A%84%E5%93%88%E5%B8%8C%E5%80%BC%E4%B8%BA%20274267521&cumulative=false&curInstr=19&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
|
||||
|
||||
多くのプログラミング言語では、**不変オブジェクトだけがハッシュテーブルの `key` として使えます**。仮にリスト(動的配列)を `key` とすると、その内容が変化したときにハッシュ値も変わってしまうため、もとの `value` をハッシュテーブルから検索できなくなります。
|
||||
|
||||
カスタムオブジェクト(たとえば連結リストのノード)のメンバ変数は可変ですが、それでもハッシュ可能です。**これは、オブジェクトのハッシュ値が通常はメモリアドレスに基づいて生成されるためです**。オブジェクトの内容が変化しても、メモリアドレスが変わらなければ、ハッシュ値も変わりません。
|
||||
|
||||
注意深い人なら、異なるコンソールでプログラムを実行したときに、出力されるハッシュ値が異なることに気づくかもしれません。**これは、Python インタプリタが起動のたびに文字列ハッシュ関数へランダムな salt 値を追加しているためです**。この方法によって HashDoS 攻撃を効果的に防ぎ、ハッシュアルゴリズムの安全性を高めています。
|
||||
|
||||
@@ -1,108 +1,108 @@
|
||||
# ハッシュ衝突
|
||||
|
||||
前節で述べたように、**ほとんどの場合、ハッシュ関数の入力空間は出力空間よりもはるかに大きい**ため、理論的にはハッシュ衝突は避けられません。例えば、入力空間がすべての整数で、出力空間が配列容量のサイズの場合、複数の整数が必然的に同じバケットインデックスにマッピングされます。
|
||||
前節で述べたように、**通常、ハッシュ関数の入力空間は出力空間よりもはるかに大きい**ため、理論上ハッシュ衝突は避けられません。例えば、入力空間がすべての整数で、出力空間が配列の容量サイズである場合、必然的に複数の整数が同じバケットインデックスに写像されます。
|
||||
|
||||
ハッシュ衝突は誤ったクエリ結果につながり、ハッシュ表の使いやすさに深刻な影響を与える可能性があります。この問題に対処するために、ハッシュ衝突が発生するたびに、衝突が消えるまでハッシュ表のリサイズを実行します。このアプローチは非常にシンプルで直接的であり、うまく機能します。しかし、テーブルの拡張には大量のデータ移行とハッシュコードの再計算が含まれ、これらは高コストであるため、非常に非効率的に見えます。効率を向上させるために、以下の戦略を採用できます:
|
||||
ハッシュ衝突は検索結果の誤りを招き、ハッシュテーブルの利用可能性に深刻な影響を与えます。この問題を解決するために、ハッシュ衝突が発生するたびにハッシュテーブルを拡張し、衝突が消えるまで続けることが考えられます。この方法は単純で効果的ですが、効率が低すぎます。なぜなら、ハッシュテーブルの拡張には大量のデータ移動とハッシュ値の計算が必要だからです。効率を高めるために、次の戦略を採用できます。
|
||||
|
||||
1. **ハッシュ衝突が発生した場合でも、ターゲット要素の検索が適切に機能する**ようにハッシュ表のデータ構造を改善する。
|
||||
2. 深刻な衝突が観察され、必要になる前に、拡張は最後の手段とする。
|
||||
1. ハッシュテーブルのデータ構造を改良し、**ハッシュ衝突が発生してもハッシュテーブルが正常に動作できるようにする**。
|
||||
2. 必要な場合、すなわちハッシュ衝突が比較的深刻なときにのみ、拡張操作を実行する。
|
||||
|
||||
ハッシュ表の構造を改善する主な方法は2つあります:「連鎖法」と「オープンアドレス法」です。
|
||||
ハッシュテーブルの構造改善方法には、主に「チェイン法」と「オープンアドレッシング」があります。
|
||||
|
||||
## 連鎖法
|
||||
## チェイン法
|
||||
|
||||
元のハッシュ表では、各バケットは1つのキー値ペアのみを格納できます。<u>連鎖法</u>は単一の要素を連結リストに変換し、キー値ペアをリストノードとして扱い、衝突するすべてのキー値ペアを同じ連結リストに格納します。下図は連鎖法を使用したハッシュ表の例を示しています。
|
||||
元のハッシュテーブルでは、各バケットには 1 つのキーと値のペアしか格納できません。<u>チェイン法(separate chaining)</u>では、単一要素を連結リストに置き換え、キーと値のペアを連結リストのノードとして扱い、衝突したすべてのキーと値のペアを同じ連結リストに格納します。下図はチェイン法によるハッシュテーブルの例を示しています。
|
||||
|
||||

|
||||

|
||||
|
||||
連鎖法で実装されたハッシュ表の操作は以下のように変更されます:
|
||||
チェイン法で実装されたハッシュテーブルでは、操作方法が次のように変わります。
|
||||
|
||||
- **要素のクエリ**: `key`を入力し、ハッシュ関数を通してバケットインデックスを取得し、連結リストのヘッドノードにアクセスします。連結リストを走査してキーを比較し、ターゲットキー値ペアを見つけます。
|
||||
- **要素の追加**: ハッシュ関数を通して連結リストのヘッドノードにアクセスし、ノード(キー値ペア)をリストに追加します。
|
||||
- **要素の削除**: ハッシュ関数の結果に基づいて連結リストのヘッドにアクセスし、連結リストを走査してターゲットノードを見つけて削除します。
|
||||
- **要素の検索**:入力 `key` をハッシュ関数に通してバケットインデックスを得ると、連結リストの先頭ノードにアクセスできます。その後、連結リストを走査して `key` を比較し、目的のキーと値のペアを探します。
|
||||
- **要素の追加**:まずハッシュ関数で連結リストの先頭ノードにアクセスし、その後ノード(キーと値のペア)を連結リストに追加します。
|
||||
- **要素の削除**:ハッシュ関数の結果に基づいて連結リストの先頭にアクセスし、続いて連結リストを走査して対象ノードを探し、削除します。
|
||||
|
||||
連鎖法には以下の制限があります:
|
||||
チェイン法には次の制約があります。
|
||||
|
||||
- **空間使用量の増加**: 連結リストにはノードポインタが含まれており、配列よりも多くのメモリ空間を消費します。
|
||||
- **クエリ効率の低下**: 対応する要素を見つけるために連結リストの線形走査が必要になるためです。
|
||||
- **使用メモリの増加**:連結リストにはノードポインタが含まれるため、配列よりも多くのメモリを消費します。
|
||||
- **検索効率の低下**:対応する要素を見つけるために連結リストを線形走査する必要があるためです。
|
||||
|
||||
以下のコードは連鎖法ハッシュ表の簡単な実装を提供し、注意すべき2つの点があります:
|
||||
以下のコードはチェイン法ハッシュテーブルの簡単な実装を示しています。注意すべき点は 2 つあります。
|
||||
|
||||
- 簡単にするために、連結リストの代わりにリスト(動的配列)を使用します。この設定では、ハッシュ表(配列)は複数のバケットを含み、各バケットはリストです。
|
||||
- この実装にはハッシュ表のリサイズメソッドが含まれています。負荷率が$\frac{2}{3}$を超えると、ハッシュ表を元のサイズの2倍に拡張します。
|
||||
- 連結リストの代わりにリスト(動的配列)を使って、コードを簡潔にしています。この設定では、ハッシュテーブル(配列)は複数のバケットを含み、各バケットは 1 つのリストです。
|
||||
- 以下の実装にはハッシュテーブルの拡張メソッドが含まれています。負荷率が $\frac{2}{3}$ を超えたとき、ハッシュテーブルを元の $2$ 倍に拡張します。
|
||||
|
||||
```src
|
||||
[file]{hash_map_chaining}-[class]{hash_map_chaining}-[func]{}
|
||||
```
|
||||
|
||||
連結リストが非常に長い場合、クエリ効率$O(n)$が悪いことは注目に値します。**この場合、リストを「AVL木」または「赤黒木」に変換して**、クエリ操作の時間計算量を$O(\log n)$に最適化できます。
|
||||
注意すべきなのは、連結リストが長い場合、検索効率 $O(n)$ は非常に低いことです。**このとき、連結リストを「AVL 木」または「赤黒木」に変換することで**、検索操作の時間計算量を $O(\log n)$ に最適化できます。
|
||||
|
||||
## オープンアドレス法
|
||||
## オープンアドレッシング
|
||||
|
||||
<u>オープンアドレス法</u>は追加のデータ構造を導入せず、代わりに「複数回プローブ」を通してハッシュ衝突を処理します。プローブ方法には主に線形プローブ、二次プローブ、二重ハッシュがあります。
|
||||
<u>オープンアドレッシング(open addressing)</u>では追加のデータ構造を導入せず、「複数回の探索」によってハッシュ衝突を処理します。探索方法には主に線形探索、二次探索、多重ハッシュなどがあります。
|
||||
|
||||
線形プローブを例にして、オープンアドレス法ハッシュ表のメカニズムを紹介しましょう。
|
||||
以下では線形探索を例に、オープンアドレッシングハッシュテーブルの動作の仕組みを説明します。
|
||||
|
||||
### 線形プローブ
|
||||
### 線形探索
|
||||
|
||||
線形プローブは固定ステップの線形検索をプローブに使用し、通常のハッシュ表とは異なります。
|
||||
線形探索では、固定ステップ長の線形探索によって探索を行います。その操作方法は通常のハッシュテーブルとは異なります。
|
||||
|
||||
- **要素の挿入**: ハッシュ関数を使用してバケットインデックスを計算します。バケットに既に要素が含まれている場合、衝突位置から線形に前方に走査し(通常ステップサイズは$1$)、空のバケットが見つかるまで進み、要素を挿入します。
|
||||
- **要素の検索**: ハッシュ衝突に遭遇した場合、同じステップサイズを使用して線形に前方に走査し、対応する要素が見つかったら`value`を返します。空のバケットに遭遇した場合、ターゲット要素がハッシュ表にないことを意味するため、`None`を返します。
|
||||
- **要素の挿入**:ハッシュ関数によってバケットインデックスを計算し、バケット内にすでに要素がある場合は、衝突位置から後方へ線形に走査し(ステップ長は通常 $1$ )、空のバケットが見つかるまで進み、その中に要素を挿入します。
|
||||
- **要素の検索**:ハッシュ衝突が見つかった場合は、同じステップ長で後方へ線形走査を行い、対応する要素が見つかるまで続け、 `value` を返します。空のバケットに遭遇した場合は、対象要素がハッシュテーブル内に存在しないことを意味するため、 `None` を返します。
|
||||
|
||||
下図はオープンアドレス法(線形プローブ)ハッシュ表におけるキー値ペアの分布を示しています。このハッシュ関数によると、下二桁が同じキーは同じバケットにマッピングされます。線形プローブを通して、それらはそのバケットとその下のバケットに順次格納されます。
|
||||
下図はオープンアドレッシング(線形探索)ハッシュテーブルにおけるキーと値のペアの分布を示しています。このハッシュ関数では、末尾 2 桁が同じ `key` はすべて同じバケットに写像されます。線形探索によって、それらはそのバケットとその後続のバケットに順に格納されます。
|
||||
|
||||

|
||||

|
||||
|
||||
しかし、**線形プローブは「クラスタリング」を作りやすい傾向があります**。具体的には、配列内の連続的に占有された位置が長いほど、これらの連続した位置でハッシュ衝突が発生する確率が高くなり、その位置でのクラスタリングの成長をさらに促進し、悪循環を形成し、最終的に挿入、削除、クエリ、更新操作の効率低下につながります。
|
||||
しかし、**線形探索では「クラスタリング現象」が起こりやすい**です。具体的には、配列内で連続して占有された位置が長いほど、それらの連続位置でハッシュ衝突が発生する可能性が高くなり、さらにその位置の集積成長を促して悪循環を生み、最終的には追加・削除・検索・更新操作の効率低下を招きます。
|
||||
|
||||
**オープンアドレス法ハッシュ表では要素を直接削除できない**ことに注意することが重要です。要素を削除すると、配列に空のバケット`None`が作成されます。要素を検索する際、線形プローブがこの空のバケットに遭遇すると戻ってしまい、このバケットの下の要素にアクセスできなくなります。プログラムはこれらの要素が存在しないと誤って仮定する可能性があります。下図に示すとおりです。
|
||||
注意すべきなのは、**オープンアドレッシングハッシュテーブルでは要素を直接削除できない**ことです。これは、要素を削除すると配列内に空バケット `None` が生じ、要素を検索するときに線形探索がその空バケットに到達した時点で返ってしまうため、その空バケットより後ろの要素には二度とアクセスできなくなるからです。結果として、プログラムがそれらの要素を存在しないと誤判定する可能性があります。下図のとおりです。
|
||||
|
||||

|
||||

|
||||
|
||||
この問題を解決するために、<u>遅延削除</u>メカニズムを採用できます:ハッシュ表から要素を直接削除する代わりに、**定数`TOMBSTONE`を使用してバケットをマークします**。このメカニズムでは、`None`と`TOMBSTONE`の両方が空のバケットを表し、キー値ペアを保持できます。ただし、線形プローブが`TOMBSTONE`に遭遇した場合、その下にまだキー値ペアがある可能性があるため、走査を続ける必要があります。
|
||||
この問題を解決するために、<u>遅延削除(lazy deletion)</u>の仕組みを採用できます。これは要素をハッシュテーブルから直接取り除かず、**代わりに定数 `TOMBSTONE` を使ってこのバケットをマークします**。この仕組みでは、`None` と `TOMBSTONE` はどちらも空バケットを表し、どちらにもキーと値のペアを配置できます。ただし異なるのは、線形探索が `TOMBSTONE` に到達した場合は、その先にキーと値のペアが存在する可能性があるため、探索を続けるべきだという点です。
|
||||
|
||||
しかし、**遅延削除はハッシュ表の性能劣化を加速する可能性があります**。削除操作のたびに削除マークが生成され、`TOMBSTONE`が増加すると、線形プローブがターゲット要素を見つけるために複数の`TOMBSTONE`をスキップする必要がある可能性があるため、検索時間も増加します。
|
||||
しかし、**遅延削除はハッシュテーブルの性能劣化を加速させる可能性があります**。これは、削除操作のたびに削除マークが 1 つ生成され、`TOMBSTONE` が増えるにつれて探索時間も増加するためです。線形探索では、対象要素を見つけるまでに複数の `TOMBSTONE` を飛び越える必要があるかもしれません。
|
||||
|
||||
これに対処するために、線形プローブ中に最初に遭遇した`TOMBSTONE`のインデックスを記録し、検索されたターゲット要素とその`TOMBSTONE`の位置を交換することを検討してください。これを行う利点は、要素がクエリまたは追加されるたびに、要素がその理想的な位置(プローブの開始点)により近いバケットに移動され、クエリ効率が最適化されることです。
|
||||
そのため、線形探索では、遭遇した最初の `TOMBSTONE` のインデックスを記録し、見つかった対象要素とその `TOMBSTONE` を交換することを考えます。こうする利点は、要素を検索または追加するたびに、要素が理想位置(探索開始点)により近いバケットへ移動し、検索効率が向上することです。
|
||||
|
||||
以下のコードは、遅延削除を使用したオープンアドレス法(線形プローブ)ハッシュ表を実装しています。ハッシュ表の空間をより有効に活用するために、ハッシュ表を「循環配列」として扱います。配列の終わりを超えると、最初に戻って走査を続けます。
|
||||
以下のコードは、遅延削除を含むオープンアドレッシング(線形探索)ハッシュテーブルを実装したものです。ハッシュテーブルの空間をより十分に活用するために、ハッシュテーブルを「環状配列」とみなし、配列末尾を越えたら先頭に戻って探索を続けます。
|
||||
|
||||
```src
|
||||
[file]{hash_map_open_addressing}-[class]{hash_map_open_addressing}-[func]{}
|
||||
```
|
||||
|
||||
### 二次プローブ
|
||||
### 二次探索
|
||||
|
||||
二次プローブは線形プローブに似ており、オープンアドレス法の一般的な戦略の1つです。衝突が発生した場合、二次プローブは単純に固定ステップ数をスキップするのではなく、「プローブ回数の二乗」に等しいステップ数、つまり$1, 4, 9, \dots$ステップをスキップします。
|
||||
二次探索は線形探索に似ており、オープンアドレッシングの一般的な戦略の 1 つです。衝突が発生したとき、二次探索では単純に固定歩数を飛ばすのではなく、「探索回数の二乗」に相当する歩数、すなわち $1, 4, 9, \dots$ 歩を飛ばします。
|
||||
|
||||
二次プローブには以下の利点があります:
|
||||
二次探索には主に次の利点があります。
|
||||
|
||||
- 二次プローブは、プローブ回数の二乗の距離をスキップすることで、線形プローブのクラスタリング効果を軽減しようとします。
|
||||
- 二次プローブはより大きな距離をスキップして空の位置を見つけ、データをより均等に分散するのに役立ちます。
|
||||
- 二次探索は、探索回数の二乗の距離を飛ばすことで、線形探索のクラスタリング効果を緩和しようとします。
|
||||
- 二次探索はより大きな距離を飛ばして空き位置を探すため、データ分布がより均一になるのに役立ちます。
|
||||
|
||||
しかし、二次プローブは完璧ではありません:
|
||||
しかし、二次探索は完璧ではありません。
|
||||
|
||||
- クラスタリングは依然として存在し、つまり一部の位置は他の位置よりも占有される可能性が高いです。
|
||||
- 二乗の成長により、二次プローブはハッシュ表全体をプローブできない可能性があり、ハッシュ表に空のバケットがあっても、二次プローブがアクセスできない可能性があります。
|
||||
- 依然としてクラスタリング現象は存在し、ある位置が他の位置より占有されやすいことがあります。
|
||||
- 二乗の増加により、二次探索はハッシュテーブル全体を探索できない可能性があります。これは、ハッシュテーブルに空バケットがあっても、二次探索ではそこに到達できないことがあることを意味します。
|
||||
|
||||
### 二重ハッシュ
|
||||
### 多重ハッシュ
|
||||
|
||||
名前が示すように、二重ハッシュ法は複数のハッシュ関数$f_1(x)$、$f_2(x)$、$f_3(x)$、$\dots$をプローブに使用します。
|
||||
その名のとおり、多重ハッシュ法では複数のハッシュ関数 $f_1(x)$、$f_2(x)$、$f_3(x)$、$\dots$ を使って探索を行います。
|
||||
|
||||
- **要素の挿入**: ハッシュ関数$f_1(x)$が衝突に遭遇した場合、$f_2(x)$を試し、以下同様に、空の位置が見つかって要素が挿入されるまで続けます。
|
||||
- **要素の検索**: 同じハッシュ関数の順序で検索し、ターゲット要素が見つかって返されるまで、または空の位置に遭遇するかすべてのハッシュ関数が試されるまで続け、要素がハッシュ表にないことを示し、`None`を返します。
|
||||
- **要素の挿入**:ハッシュ関数 $f_1(x)$ で衝突が発生した場合は、$f_2(x)$ を試し、以下同様に、空き位置が見つかるまで続けてから要素を挿入します。
|
||||
- **要素の検索**:同じハッシュ関数の順序で探索し、対象要素が見つかった時点で返します。空き位置に遭遇するか、すべてのハッシュ関数を試しても見つからない場合は、ハッシュテーブル内にその要素は存在しないため、 `None` を返します。
|
||||
|
||||
線形プローブと比較して、二重ハッシュ法はクラスタリングが起こりにくいですが、複数のハッシュ関数は追加の計算オーバーヘッドを導入します。
|
||||
線形探索と比べると、多重ハッシュ法はクラスタリングを起こしにくい一方で、複数のハッシュ関数により追加の計算量が発生します。
|
||||
|
||||
!!! tip
|
||||
|
||||
オープンアドレス法(線形プローブ、二次プローブ、二重ハッシュ)ハッシュ表はすべて「要素を直接削除できない」という問題があることに注意してください。
|
||||
注意してください。オープンアドレッシング(線形探索、二次探索、多重ハッシュ)のハッシュテーブルには、いずれも「要素を直接削除できない」という問題があります。
|
||||
|
||||
## プログラミング言語の選択
|
||||
|
||||
異なるプログラミング言語は異なるハッシュ表実装戦略を採用しています。以下にいくつかの例を示します:
|
||||
各種プログラミング言語は異なるハッシュテーブル実装戦略を採用しています。以下にいくつか例を挙げます。
|
||||
|
||||
- Pythonはオープンアドレス法を使用します。`dict`辞書はプローブに疑似乱数を使用します。
|
||||
- Javaは連鎖法を使用します。JDK 1.8以降、`HashMap`の配列長が64に達し、連結リストの長さが8に達すると、連結リストは検索性能を向上させるために赤黒木に変換されます。
|
||||
- Goは連鎖法を使用します。Goは各バケットが最大8つのキー値ペアを格納できることを規定し、容量を超えた場合はオーバーフローバケットが連結されます。オーバーフローバケットが多すぎる場合、性能を確保するために特別な等容量リサイズ操作が実行されます。
|
||||
- Python はオープンアドレッシングを採用しています。辞書 `dict` は疑似乱数を用いて探索します。
|
||||
- Java はチェイン法を採用しています。JDK 1.8 以降、`HashMap` 内の配列長が 64 に達し、かつ連結リスト長が 8 に達すると、連結リストは検索性能を高めるため赤黒木に変換されます。
|
||||
- Go はチェイン法を採用しています。Go では各バケットに最大 8 個のキーと値のペアを格納でき、容量を超えるとオーバーフローバケットを連結します。オーバーフローバケットが多すぎる場合は、性能を確保するために特殊な等量拡張操作を実行します。
|
||||
|
||||
+259
-185
@@ -1,238 +1,238 @@
|
||||
# ハッシュ表
|
||||
# ハッシュテーブル
|
||||
|
||||
<u>ハッシュ表</u>は<u>ハッシュマップ</u>とも呼ばれ、キーと値の間のマッピングを確立し、効率的な要素の取得を可能にするデータ構造です。具体的には、ハッシュ表に`key`を入力すると、$O(1)$の時間計算量で対応する`value`を取得できます。
|
||||
<u>ハッシュテーブル(hash table)</u>は、<u>散列表</u>とも呼ばれ、キー `key` と値 `value` の対応関係を構築することで、高効率な要素検索を実現します。具体的には、ハッシュテーブルにキー `key` を入力すると、対応する値 `value` を $O(1)$ 時間で取得できます。
|
||||
|
||||
下図に示すように、$n$人の学生がいて、各学生には「名前」と「学籍番号」の2つのデータフィールドがあるとします。学籍番号を入力として対応する名前を返すクエリ機能を実装したい場合、下図に示すハッシュ表を使用できます。
|
||||
以下の図に示すように、$n$ 人の学生がいるとし、各学生は「名前」と「学籍番号」の 2 つの情報を持っています。もし「学籍番号を入力すると対応する名前を返す」という検索機能を実現したいなら、下図のようなハッシュテーブルを用いることができます。
|
||||
|
||||

|
||||

|
||||
|
||||
ハッシュ表に加えて、配列や連結リストもクエリ機能の実装に使用できますが、時間計算量が異なります。効率は以下の表で比較されています:
|
||||
ハッシュテーブルのほかに、配列や連結リストでも検索機能を実現できます。それらの効率比較を次の表に示します。
|
||||
|
||||
- **要素の挿入**: 配列(または連結リスト)の末尾に要素を追加するだけです。この操作の時間計算量は$O(1)$です。
|
||||
- **要素の検索**: 配列(または連結リスト)がソートされていないため、要素を検索するにはすべての要素を走査する必要があります。この操作の時間計算量は$O(n)$です。
|
||||
- **要素の削除**: 要素を削除するには、まずその要素を見つけてから、配列(または連結リスト)から削除します。この操作の時間計算量は$O(n)$です。
|
||||
- **要素の追加**:要素を配列(連結リスト)の末尾に追加するだけでよく、$O(1)$ 時間です。
|
||||
- **要素の検索**:配列(連結リスト)は無秩序なので、すべての要素を走査する必要があり、$O(n)$ 時間かかります。
|
||||
- **要素の削除**:先に要素を検索してから配列(連結リスト)から削除する必要があり、$O(n)$ 時間かかります。
|
||||
|
||||
<p align="center"> 表 <id> 一般的な操作の時間効率の比較 </p>
|
||||
<p align="center"> 表 <id> 要素検索効率の比較 </p>
|
||||
|
||||
| | 配列 | 連結リスト | ハッシュ表 |
|
||||
| -------------- | ------ | ----------- | ---------- |
|
||||
| 要素の検索 | $O(n)$ | $O(n)$ | $O(1)$ |
|
||||
| 要素の挿入 | $O(1)$ | $O(1)$ | $O(1)$ |
|
||||
| 要素の削除 | $O(n)$ | $O(n)$ | $O(1)$ |
|
||||
| | 配列 | 連結リスト | ハッシュテーブル |
|
||||
| -------- | ------ | ------ | ------ |
|
||||
| 要素の検索 | $O(n)$ | $O(n)$ | $O(1)$ |
|
||||
| 要素の追加 | $O(1)$ | $O(1)$ | $O(1)$ |
|
||||
| 要素の削除 | $O(n)$ | $O(n)$ | $O(1)$ |
|
||||
|
||||
観察されるように、**ハッシュ表における操作(挿入、削除、検索、変更)の時間計算量は$O(1)$**で、非常に効率的です。
|
||||
以上から分かるように、**ハッシュテーブルにおける追加・削除・検索・更新の時間計算量はいずれも $O(1)$** であり、非常に高効率です。
|
||||
|
||||
## ハッシュ表の一般的な操作
|
||||
## ハッシュテーブルの基本操作
|
||||
|
||||
ハッシュ表の一般的な操作には、初期化、クエリ、キー値ペアの追加、キー値ペアの削除があります。以下はコード例です:
|
||||
ハッシュテーブルの一般的な操作には、初期化、検索、キーと値のペアの追加、キーと値のペアの削除などがあります。コード例は以下のとおりです:
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="hash_map.py"
|
||||
# ハッシュ表を初期化
|
||||
# ハッシュテーブルを初期化
|
||||
hmap: dict = {}
|
||||
|
||||
# 追加操作
|
||||
# ハッシュ表にキー値ペア (key, value) を追加
|
||||
hmap[12836] = "小哈"
|
||||
hmap[15937] = "小啰"
|
||||
hmap[16750] = "小算"
|
||||
hmap[13276] = "小法"
|
||||
hmap[10583] = "小鸭"
|
||||
# ハッシュテーブルにキーと値のペア (key, value) を追加
|
||||
hmap[12836] = "シャオハ"
|
||||
hmap[15937] = "シャオルオ"
|
||||
hmap[16750] = "シャオスワン"
|
||||
hmap[13276] = "シャオファ"
|
||||
hmap[10583] = "シャオヤー"
|
||||
|
||||
# クエリ操作
|
||||
# ハッシュ表にキーを入力し、値を取得
|
||||
# 検索操作
|
||||
# ハッシュテーブルにキー key を入力し、値 value を取得
|
||||
name: str = hmap[15937]
|
||||
|
||||
# 削除操作
|
||||
# ハッシュ表からキー値ペア (key, value) を削除
|
||||
# ハッシュテーブルからキーと値のペア (key, value) を削除
|
||||
hmap.pop(10583)
|
||||
```
|
||||
|
||||
=== "C++"
|
||||
|
||||
```cpp title="hash_map.cpp"
|
||||
/* ハッシュ表を初期化 */
|
||||
/* ハッシュテーブルを初期化 */
|
||||
unordered_map<int, string> map;
|
||||
|
||||
/* 追加操作 */
|
||||
// ハッシュ表にキー値ペア (key, value) を追加
|
||||
map[12836] = "小哈";
|
||||
map[15937] = "小啰";
|
||||
map[16750] = "小算";
|
||||
map[13276] = "小法";
|
||||
map[10583] = "小鸭";
|
||||
// ハッシュテーブルにキーと値のペア (key, value) を追加
|
||||
map[12836] = "シャオハ";
|
||||
map[15937] = "シャオルオ";
|
||||
map[16750] = "シャオスワン";
|
||||
map[13276] = "シャオファ";
|
||||
map[10583] = "シャオヤー";
|
||||
|
||||
/* クエリ操作 */
|
||||
// ハッシュ表にキーを入力し、値を取得
|
||||
/* 検索操作 */
|
||||
// ハッシュテーブルにキー key を入力し、値 value を取得
|
||||
string name = map[15937];
|
||||
|
||||
/* 削除操作 */
|
||||
// ハッシュ表からキー値ペア (key, value) を削除
|
||||
// ハッシュテーブルからキーと値のペア (key, value) を削除
|
||||
map.erase(10583);
|
||||
```
|
||||
|
||||
=== "Java"
|
||||
|
||||
```java title="hash_map.java"
|
||||
/* ハッシュ表を初期化 */
|
||||
/* ハッシュテーブルを初期化 */
|
||||
Map<Integer, String> map = new HashMap<>();
|
||||
|
||||
/* 追加操作 */
|
||||
// ハッシュ表にキー値ペア (key, value) を追加
|
||||
map.put(12836, "小哈");
|
||||
map.put(15937, "小啰");
|
||||
map.put(16750, "小算");
|
||||
map.put(13276, "小法");
|
||||
map.put(10583, "小鸭");
|
||||
// ハッシュテーブルにキーと値のペア (key, value) を追加
|
||||
map.put(12836, "シャオハ");
|
||||
map.put(15937, "シャオルオ");
|
||||
map.put(16750, "シャオスワン");
|
||||
map.put(13276, "シャオファ");
|
||||
map.put(10583, "シャオヤー");
|
||||
|
||||
/* クエリ操作 */
|
||||
// ハッシュ表にキーを入力し、値を取得
|
||||
/* 検索操作 */
|
||||
// ハッシュテーブルにキー key を入力し、値 value を取得
|
||||
String name = map.get(15937);
|
||||
|
||||
/* 削除操作 */
|
||||
// ハッシュ表からキー値ペア (key, value) を削除
|
||||
// ハッシュテーブルからキーと値のペア (key, value) を削除
|
||||
map.remove(10583);
|
||||
```
|
||||
|
||||
=== "C#"
|
||||
|
||||
```csharp title="hash_map.cs"
|
||||
/* ハッシュ表を初期化 */
|
||||
/* ハッシュテーブルを初期化 */
|
||||
Dictionary<int, string> map = new() {
|
||||
/* 追加操作 */
|
||||
// ハッシュ表にキー値ペア (key, value) を追加
|
||||
{ 12836, "小哈" },
|
||||
{ 15937, "小啰" },
|
||||
{ 16750, "小算" },
|
||||
{ 13276, "小法" },
|
||||
{ 10583, "小鸭" }
|
||||
// ハッシュテーブルにキーと値のペア (key, value) を追加
|
||||
{ 12836, "シャオハ" },
|
||||
{ 15937, "シャオルオ" },
|
||||
{ 16750, "シャオスワン" },
|
||||
{ 13276, "シャオファ" },
|
||||
{ 10583, "シャオヤー" }
|
||||
};
|
||||
|
||||
/* クエリ操作 */
|
||||
// ハッシュ表にキーを入力し、値を取得
|
||||
/* 検索操作 */
|
||||
// ハッシュテーブルにキー key を入力し、値 value を取得
|
||||
string name = map[15937];
|
||||
|
||||
/* 削除操作 */
|
||||
// ハッシュ表からキー値ペア (key, value) を削除
|
||||
// ハッシュテーブルからキーと値のペア (key, value) を削除
|
||||
map.Remove(10583);
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
||||
```go title="hash_map_test.go"
|
||||
/* ハッシュ表を初期化 */
|
||||
/* ハッシュテーブルを初期化 */
|
||||
hmap := make(map[int]string)
|
||||
|
||||
/* 追加操作 */
|
||||
// ハッシュ表にキー値ペア (key, value) を追加
|
||||
hmap[12836] = "小哈"
|
||||
hmap[15937] = "小啰"
|
||||
hmap[16750] = "小算"
|
||||
hmap[13276] = "小法"
|
||||
hmap[10583] = "小鸭"
|
||||
// ハッシュテーブルにキーと値のペア (key, value) を追加
|
||||
hmap[12836] = "シャオハ"
|
||||
hmap[15937] = "シャオルオ"
|
||||
hmap[16750] = "シャオスワン"
|
||||
hmap[13276] = "シャオファ"
|
||||
hmap[10583] = "シャオヤー"
|
||||
|
||||
/* クエリ操作 */
|
||||
// ハッシュ表にキーを入力し、値を取得
|
||||
/* 検索操作 */
|
||||
// ハッシュテーブルにキー key を入力し、値 value を取得
|
||||
name := hmap[15937]
|
||||
|
||||
/* 削除操作 */
|
||||
// ハッシュ表からキー値ペア (key, value) を削除
|
||||
// ハッシュテーブルからキーと値のペア (key, value) を削除
|
||||
delete(hmap, 10583)
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
|
||||
```swift title="hash_map.swift"
|
||||
/* ハッシュ表を初期化 */
|
||||
/* ハッシュテーブルを初期化 */
|
||||
var map: [Int: String] = [:]
|
||||
|
||||
/* 追加操作 */
|
||||
// ハッシュ表にキー値ペア (key, value) を追加
|
||||
map[12836] = "小哈"
|
||||
map[15937] = "小啰"
|
||||
map[16750] = "小算"
|
||||
map[13276] = "小法"
|
||||
map[10583] = "小鸭"
|
||||
// ハッシュテーブルにキーと値のペア (key, value) を追加
|
||||
map[12836] = "シャオハ"
|
||||
map[15937] = "シャオルオ"
|
||||
map[16750] = "シャオスワン"
|
||||
map[13276] = "シャオファ"
|
||||
map[10583] = "シャオヤー"
|
||||
|
||||
/* クエリ操作 */
|
||||
// ハッシュ表にキーを入力し、値を取得
|
||||
/* 検索操作 */
|
||||
// ハッシュテーブルにキー key を入力し、値 value を取得
|
||||
let name = map[15937]!
|
||||
|
||||
/* 削除操作 */
|
||||
// ハッシュ表からキー値ペア (key, value) を削除
|
||||
// ハッシュテーブルからキーと値のペア (key, value) を削除
|
||||
map.removeValue(forKey: 10583)
|
||||
```
|
||||
|
||||
=== "JS"
|
||||
|
||||
```javascript title="hash_map.js"
|
||||
/* ハッシュ表を初期化 */
|
||||
/* ハッシュテーブルを初期化 */
|
||||
const map = new Map();
|
||||
/* 追加操作 */
|
||||
// ハッシュ表にキー値ペア (key, value) を追加
|
||||
map.set(12836, '小哈');
|
||||
map.set(15937, '小啰');
|
||||
map.set(16750, '小算');
|
||||
map.set(13276, '小法');
|
||||
map.set(10583, '小鸭');
|
||||
// ハッシュテーブルにキーと値のペア (key, value) を追加
|
||||
map.set(12836, 'シャオハ');
|
||||
map.set(15937, 'シャオルオ');
|
||||
map.set(16750, 'シャオスワン');
|
||||
map.set(13276, 'シャオファ');
|
||||
map.set(10583, 'シャオヤー');
|
||||
|
||||
/* クエリ操作 */
|
||||
// ハッシュ表にキーを入力し、値を取得
|
||||
/* 検索操作 */
|
||||
// ハッシュテーブルにキー key を入力し、値 value を取得
|
||||
let name = map.get(15937);
|
||||
|
||||
/* 削除操作 */
|
||||
// ハッシュ表からキー値ペア (key, value) を削除
|
||||
// ハッシュテーブルからキーと値のペア (key, value) を削除
|
||||
map.delete(10583);
|
||||
```
|
||||
|
||||
=== "TS"
|
||||
|
||||
```typescript title="hash_map.ts"
|
||||
/* ハッシュ表を初期化 */
|
||||
/* ハッシュテーブルを初期化 */
|
||||
const map = new Map<number, string>();
|
||||
/* 追加操作 */
|
||||
// ハッシュ表にキー値ペア (key, value) を追加
|
||||
map.set(12836, '小哈');
|
||||
map.set(15937, '小啰');
|
||||
map.set(16750, '小算');
|
||||
map.set(13276, '小法');
|
||||
map.set(10583, '小鸭');
|
||||
console.info('\n追加後、ハッシュ表は\nKey -> Value');
|
||||
// ハッシュテーブルにキーと値のペア (key, value) を追加
|
||||
map.set(12836, 'シャオハ');
|
||||
map.set(15937, 'シャオルオ');
|
||||
map.set(16750, 'シャオスワン');
|
||||
map.set(13276, 'シャオファ');
|
||||
map.set(10583, 'シャオヤー');
|
||||
console.info('\n追加後のハッシュテーブルは次のとおりです\nKey -> Value');
|
||||
console.info(map);
|
||||
|
||||
/* クエリ操作 */
|
||||
// ハッシュ表にキーを入力し、値を取得
|
||||
/* 検索操作 */
|
||||
// ハッシュテーブルにキー key を入力し、値 value を取得
|
||||
let name = map.get(15937);
|
||||
console.info('\n学籍番号15937を入力、名前を問い合わせ ' + name);
|
||||
console.info('\n学籍番号 15937 を入力し、名前を検索: ' + name);
|
||||
|
||||
/* 削除操作 */
|
||||
// ハッシュ表からキー値ペア (key, value) を削除
|
||||
// ハッシュテーブルからキーと値のペア (key, value) を削除
|
||||
map.delete(10583);
|
||||
console.info('\n10583を削除後、ハッシュ表は\nKey -> Value');
|
||||
console.info('\n10583 を削除した後のハッシュテーブル\nKey -> Value');
|
||||
console.info(map);
|
||||
```
|
||||
|
||||
=== "Dart"
|
||||
|
||||
```dart title="hash_map.dart"
|
||||
/* ハッシュ表を初期化 */
|
||||
/* ハッシュテーブルを初期化 */
|
||||
Map<int, String> map = {};
|
||||
|
||||
/* 追加操作 */
|
||||
// ハッシュ表にキー値ペア (key, value) を追加
|
||||
map[12836] = "小哈";
|
||||
map[15937] = "小啰";
|
||||
map[16750] = "小算";
|
||||
map[13276] = "小法";
|
||||
map[10583] = "小鸭";
|
||||
// ハッシュテーブルにキーと値のペア (key, value) を追加
|
||||
map[12836] = "シャオハ";
|
||||
map[15937] = "シャオルオ";
|
||||
map[16750] = "シャオスワン";
|
||||
map[13276] = "シャオファ";
|
||||
map[10583] = "シャオヤー";
|
||||
|
||||
/* クエリ操作 */
|
||||
// ハッシュ表にキーを入力し、値を取得
|
||||
/* 検索操作 */
|
||||
// ハッシュテーブルにキー key を入力し、値 value を取得
|
||||
String name = map[15937];
|
||||
|
||||
/* 削除操作 */
|
||||
// ハッシュ表からキー値ペア (key, value) を削除
|
||||
// ハッシュテーブルからキーと値のペア (key, value) を削除
|
||||
map.remove(10583);
|
||||
```
|
||||
|
||||
@@ -241,51 +241,95 @@
|
||||
```rust title="hash_map.rs"
|
||||
use std::collections::HashMap;
|
||||
|
||||
/* ハッシュ表を初期化 */
|
||||
/* ハッシュテーブルを初期化 */
|
||||
let mut map: HashMap<i32, String> = HashMap::new();
|
||||
|
||||
/* 追加操作 */
|
||||
// ハッシュ表にキー値ペア (key, value) を追加
|
||||
map.insert(12836, "小哈".to_string());
|
||||
map.insert(15937, "小啰".to_string());
|
||||
map.insert(16750, "小算".to_string());
|
||||
map.insert(13279, "小法".to_string());
|
||||
map.insert(10583, "小鸭".to_string());
|
||||
// ハッシュテーブルにキーと値のペア (key, value) を追加
|
||||
map.insert(12836, "シャオハ".to_string());
|
||||
map.insert(15937, "シャオルオ".to_string());
|
||||
map.insert(16750, "シャオスワン".to_string());
|
||||
map.insert(13279, "シャオファ".to_string());
|
||||
map.insert(10583, "シャオヤー".to_string());
|
||||
|
||||
/* クエリ操作 */
|
||||
// ハッシュ表にキーを入力し、値を取得
|
||||
/* 検索操作 */
|
||||
// ハッシュテーブルにキー key を入力し、値 value を取得
|
||||
let _name: Option<&String> = map.get(&15937);
|
||||
|
||||
/* 削除操作 */
|
||||
// ハッシュ表からキー値ペア (key, value) を削除
|
||||
// ハッシュテーブルからキーと値のペア (key, value) を削除
|
||||
let _removed_value: Option<String> = map.remove(&10583);
|
||||
```
|
||||
|
||||
=== "C"
|
||||
|
||||
```c title="hash_map.c"
|
||||
// Cには組み込みのハッシュ表が提供されていません
|
||||
// C には組み込みのハッシュテーブルはありません
|
||||
```
|
||||
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title="hash_map.kt"
|
||||
/* ハッシュテーブルを初期化 */
|
||||
val map = HashMap<Int,String>()
|
||||
|
||||
/* 追加操作 */
|
||||
// ハッシュテーブルにキーと値のペア (key, value) を追加
|
||||
map[12836] = "シャオハ"
|
||||
map[15937] = "シャオルオ"
|
||||
map[16750] = "シャオスワン"
|
||||
map[13276] = "シャオファ"
|
||||
map[10583] = "シャオヤー"
|
||||
|
||||
/* 検索操作 */
|
||||
// ハッシュテーブルにキー key を入力し、値 value を取得
|
||||
val name = map[15937]
|
||||
|
||||
/* 削除操作 */
|
||||
// ハッシュテーブルからキーと値のペア (key, value) を削除
|
||||
map.remove(10583)
|
||||
```
|
||||
|
||||
ハッシュ表を走査する一般的な方法は3つあります:キー値ペアの走査、キーの走査、値の走査。以下はコード例です:
|
||||
=== "Ruby"
|
||||
|
||||
```ruby title="hash_map.rb"
|
||||
# ハッシュテーブルを初期化
|
||||
hmap = {}
|
||||
|
||||
# 追加操作
|
||||
# ハッシュテーブルにキーと値のペア (key, value) を追加
|
||||
hmap[12836] = "シャオハ"
|
||||
hmap[15937] = "シャオルオ"
|
||||
hmap[16750] = "シャオスワン"
|
||||
hmap[13276] = "シャオファ"
|
||||
hmap[10583] = "シャオヤー"
|
||||
|
||||
# 検索操作
|
||||
# ハッシュテーブルにキー key を入力し、値 value を取得
|
||||
name = hmap[15937]
|
||||
|
||||
# 削除操作
|
||||
# ハッシュテーブルからキーと値のペア (key, value) を削除
|
||||
hmap.delete(10583)
|
||||
```
|
||||
|
||||
??? pythontutor "可視化実行"
|
||||
|
||||
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E5%93%88%E5%B8%8C%E8%A1%A8%0A%20%20%20%20hmap%20%3D%20%7B%7D%0A%20%20%20%20%0A%20%20%20%20%23%20%E6%B7%BB%E5%8A%A0%E6%93%8D%E4%BD%9C%0A%20%20%20%20%23%20%E5%9C%A8%E5%93%88%E5%B8%8C%E8%A1%A8%E4%B8%AD%E6%B7%BB%E5%8A%A0%E9%94%AE%E5%80%BC%E5%AF%B9%20%28key,%20value%29%0A%20%20%20%20hmap%5B12836%5D%20%3D%20%22%E5%B0%8F%E5%93%88%22%0A%20%20%20%20hmap%5B15937%5D%20%3D%20%22%E5%B0%8F%E5%95%B0%22%0A%20%20%20%20hmap%5B16750%5D%20%3D%20%22%E5%B0%8F%E7%AE%97%22%0A%20%20%20%20hmap%5B13276%5D%20%3D%20%22%E5%B0%8F%E6%B3%95%22%0A%20%20%20%20hmap%5B10583%5D%20%3D%20%22%E5%B0%8F%E9%B8%AD%22%0A%20%20%20%20%0A%20%20%20%20%23%20%E6%9F%A5%E8%AF%A2%E6%93%8D%E4%BD%9C%0A%20%20%20%20%23%20%E5%90%91%E5%93%88%E5%B8%8C%E8%A1%A8%E4%B8%AD%E8%BE%93%E5%85%A5%E9%94%AE%20key%20%EF%BC%8C%E5%BE%97%E5%88%B0%E5%80%BC%20value%0A%20%20%20%20name%20%3D%20hmap%5B15937%5D%0A%20%20%20%20%0A%20%20%20%20%23%20%E5%88%A0%E9%99%A4%E6%93%8D%E4%BD%9C%0A%20%20%20%20%23%20%E5%9C%A8%E5%93%88%E5%B8%8C%E8%A1%A8%E4%B8%AD%E5%88%A0%E9%99%A4%E9%94%AE%E5%80%BC%E5%AF%B9%20%28key,%20value%29%0A%20%20%20%20hmap.pop%2810583%29&cumulative=false&curInstr=2&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
|
||||
|
||||
ハッシュテーブルには、キーと値のペア、キー、値を走査する 3 つの一般的な方法があります。コード例は以下のとおりです:
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="hash_map.py"
|
||||
# ハッシュ表を走査
|
||||
# キー値ペア key->value を走査
|
||||
# ハッシュテーブルを走査
|
||||
# キーと値のペア key->value を走査
|
||||
for key, value in hmap.items():
|
||||
print(key, "->", value)
|
||||
# キーのみを走査
|
||||
# キー key のみを走査
|
||||
for key in hmap.keys():
|
||||
print(key)
|
||||
# 値のみを走査
|
||||
# 値 value のみを走査
|
||||
for value in hmap.values():
|
||||
print(value)
|
||||
```
|
||||
@@ -293,12 +337,12 @@
|
||||
=== "C++"
|
||||
|
||||
```cpp title="hash_map.cpp"
|
||||
/* ハッシュ表を走査 */
|
||||
// キー値ペア key->value を走査
|
||||
/* ハッシュテーブルを走査 */
|
||||
// キーと値のペア key->value を走査
|
||||
for (auto kv: map) {
|
||||
cout << kv.first << " -> " << kv.second << endl;
|
||||
}
|
||||
// イテレータを使用してキー値ペア key->value を走査
|
||||
// イテレータを使って key->value を走査
|
||||
for (auto iter = map.begin(); iter != map.end(); iter++) {
|
||||
cout << iter->first << "->" << iter->second << endl;
|
||||
}
|
||||
@@ -307,16 +351,16 @@
|
||||
=== "Java"
|
||||
|
||||
```java title="hash_map.java"
|
||||
/* ハッシュ表を走査 */
|
||||
// キー値ペア key->value を走査
|
||||
for (Map.Entry<Integer, String> kv: map.entrySet()) {
|
||||
/* ハッシュテーブルを走査 */
|
||||
// キーと値のペア key->value を走査
|
||||
for (Map.Entry <Integer, String> kv: map.entrySet()) {
|
||||
System.out.println(kv.getKey() + " -> " + kv.getValue());
|
||||
}
|
||||
// キーのみを走査
|
||||
// キー key のみを走査
|
||||
for (int key: map.keySet()) {
|
||||
System.out.println(key);
|
||||
}
|
||||
// 値のみを走査
|
||||
// 値 value のみを走査
|
||||
for (String val: map.values()) {
|
||||
System.out.println(val);
|
||||
}
|
||||
@@ -325,16 +369,16 @@
|
||||
=== "C#"
|
||||
|
||||
```csharp title="hash_map.cs"
|
||||
/* ハッシュ表を走査 */
|
||||
// キー値ペア Key->Value を走査
|
||||
/* ハッシュテーブルを走査 */
|
||||
// キーと値のペア Key->Value を走査
|
||||
foreach (var kv in map) {
|
||||
Console.WriteLine(kv.Key + " -> " + kv.Value);
|
||||
}
|
||||
// キーのみを走査
|
||||
// キー key のみを走査
|
||||
foreach (int key in map.Keys) {
|
||||
Console.WriteLine(key);
|
||||
}
|
||||
// 値のみを走査
|
||||
// 値 value のみを走査
|
||||
foreach (string val in map.Values) {
|
||||
Console.WriteLine(val);
|
||||
}
|
||||
@@ -343,16 +387,16 @@
|
||||
=== "Go"
|
||||
|
||||
```go title="hash_map_test.go"
|
||||
/* ハッシュ表を走査 */
|
||||
// キー値ペア key->value を走査
|
||||
/* ハッシュテーブルを走査 */
|
||||
// キーと値のペア key->value を走査
|
||||
for key, value := range hmap {
|
||||
fmt.Println(key, "->", value)
|
||||
}
|
||||
// キーのみを走査
|
||||
// キー key のみを走査
|
||||
for key := range hmap {
|
||||
fmt.Println(key)
|
||||
}
|
||||
// 値のみを走査
|
||||
// 値 value のみを走査
|
||||
for _, value := range hmap {
|
||||
fmt.Println(value)
|
||||
}
|
||||
@@ -361,16 +405,16 @@
|
||||
=== "Swift"
|
||||
|
||||
```swift title="hash_map.swift"
|
||||
/* ハッシュ表を走査 */
|
||||
// キー値ペア Key->Value を走査
|
||||
/* ハッシュテーブルを走査 */
|
||||
// キーと値のペア Key->Value を走査
|
||||
for (key, value) in map {
|
||||
print("\(key) -> \(value)")
|
||||
}
|
||||
// キーのみを走査
|
||||
// キー Key のみを走査
|
||||
for key in map.keys {
|
||||
print(key)
|
||||
}
|
||||
// 値のみを走査
|
||||
// 値 Value のみを走査
|
||||
for value in map.values {
|
||||
print(value)
|
||||
}
|
||||
@@ -379,16 +423,16 @@
|
||||
=== "JS"
|
||||
|
||||
```javascript title="hash_map.js"
|
||||
/* ハッシュ表を走査 */
|
||||
console.info('\nキー値ペア Key->Value を走査');
|
||||
/* ハッシュテーブルを走査 */
|
||||
console.info('\nキーと値のペア Key->Value を走査');
|
||||
for (const [k, v] of map.entries()) {
|
||||
console.info(k + ' -> ' + v);
|
||||
}
|
||||
console.info('\nキーのみを走査 Key');
|
||||
console.info('\nキー Key のみを走査');
|
||||
for (const k of map.keys()) {
|
||||
console.info(k);
|
||||
}
|
||||
console.info('\n値のみを走査 Value');
|
||||
console.info('\n値 Value のみを走査');
|
||||
for (const v of map.values()) {
|
||||
console.info(v);
|
||||
}
|
||||
@@ -397,16 +441,16 @@
|
||||
=== "TS"
|
||||
|
||||
```typescript title="hash_map.ts"
|
||||
/* ハッシュ表を走査 */
|
||||
console.info('\nキー値ペア Key->Value を走査');
|
||||
/* ハッシュテーブルを走査 */
|
||||
console.info('\nキーと値のペア Key->Value を走査');
|
||||
for (const [k, v] of map.entries()) {
|
||||
console.info(k + ' -> ' + v);
|
||||
}
|
||||
console.info('\nキーのみを走査 Key');
|
||||
console.info('\nキー Key のみを走査');
|
||||
for (const k of map.keys()) {
|
||||
console.info(k);
|
||||
}
|
||||
console.info('\n値のみを走査 Value');
|
||||
console.info('\n値 Value のみを走査');
|
||||
for (const v of map.values()) {
|
||||
console.info(v);
|
||||
}
|
||||
@@ -415,38 +459,38 @@
|
||||
=== "Dart"
|
||||
|
||||
```dart title="hash_map.dart"
|
||||
/* ハッシュ表を走査 */
|
||||
// キー値ペア Key->Value を走査
|
||||
/* ハッシュテーブルを走査 */
|
||||
// キーと値のペア Key->Value を走査
|
||||
map.forEach((key, value) {
|
||||
print('$key -> $value');
|
||||
print('$key -> $value');
|
||||
});
|
||||
|
||||
// キーのみを走査 Key
|
||||
// キー Key のみを走査
|
||||
map.keys.forEach((key) {
|
||||
print(key);
|
||||
print(key);
|
||||
});
|
||||
|
||||
// 値のみを走査 Value
|
||||
// 値 Value のみを走査
|
||||
map.values.forEach((value) {
|
||||
print(value);
|
||||
print(value);
|
||||
});
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
|
||||
```rust title="hash_map.rs"
|
||||
/* ハッシュ表を走査 */
|
||||
// キー値ペア Key->Value を走査
|
||||
/* ハッシュテーブルを走査 */
|
||||
// キーと値のペア Key->Value を走査
|
||||
for (key, value) in &map {
|
||||
println!("{key} -> {value}");
|
||||
}
|
||||
|
||||
// キーのみを走査 Key
|
||||
// キー Key のみを走査
|
||||
for key in map.keys() {
|
||||
println!("{key}");
|
||||
}
|
||||
|
||||
// 値のみを走査 Value
|
||||
// 値 Value のみを走査
|
||||
for value in map.values() {
|
||||
println!("{value}");
|
||||
}
|
||||
@@ -455,63 +499,93 @@
|
||||
=== "C"
|
||||
|
||||
```c title="hash_map.c"
|
||||
// Cには組み込みのハッシュ表が提供されていません
|
||||
// C には組み込みのハッシュテーブルはありません
|
||||
```
|
||||
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title="hash_map.kt"
|
||||
|
||||
/* ハッシュテーブルを走査 */
|
||||
// キーと値のペア key->value を走査
|
||||
for ((key, value) in map) {
|
||||
println("$key -> $value")
|
||||
}
|
||||
// キー key のみを走査
|
||||
for (key in map.keys) {
|
||||
println(key)
|
||||
}
|
||||
// 値 value のみを走査
|
||||
for (_val in map.values) {
|
||||
println(_val)
|
||||
}
|
||||
```
|
||||
|
||||
## ハッシュ表の簡単な実装
|
||||
=== "Ruby"
|
||||
|
||||
まず、最も簡単なケースを考えてみましょう:**配列のみを使ってハッシュ表を実装すること**。ハッシュ表において、配列の各空きスロットは<u>バケット</u>と呼ばれ、各バケットはキー値ペアを格納できます。したがって、クエリ操作は`key`に対応するバケットを見つけ、そこから`value`を取得することになります。
|
||||
```ruby title="hash_map.rb"
|
||||
# ハッシュテーブルを走査
|
||||
# キーと値のペア key->value を走査
|
||||
hmap.entries.each { |key, value| puts "#{key} -> #{value}" }
|
||||
|
||||
では、`key`に基づいて対応するバケットをどのように特定するのでしょうか?これは<u>ハッシュ関数</u>によって実現されます。ハッシュ関数の役割は、より大きな入力空間をより小さな出力空間にマッピングすることです。ハッシュ表では、入力空間はすべてのキーで構成され、出力空間はすべてのバケット(配列インデックス)で構成されます。つまり、`key`が与えられた場合、**ハッシュ関数を使用して対応するキー値ペアの配列内の格納位置を決定できます**。
|
||||
# キー key のみを走査
|
||||
hmap.keys.each { |key| puts key }
|
||||
|
||||
与えられた`key`に対して、ハッシュ関数の計算は2つのステップで構成されます:
|
||||
# 値 value のみを走査
|
||||
hmap.values.each { |val| puts val }
|
||||
```
|
||||
|
||||
1. 特定のハッシュアルゴリズム`hash()`を使用してハッシュ値を計算します。
|
||||
2. ハッシュ値をバケット数(配列長)`capacity`で剰余を取り、キーに対応する配列`index`を取得します。
|
||||
??? pythontutor "可視化実行"
|
||||
|
||||
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E5%93%88%E5%B8%8C%E8%A1%A8%0A%20%20%20%20hmap%20%3D%20%7B%7D%0A%20%20%20%20%0A%20%20%20%20%23%20%E6%B7%BB%E5%8A%A0%E6%93%8D%E4%BD%9C%0A%20%20%20%20%23%20%E5%9C%A8%E5%93%88%E5%B8%8C%E8%A1%A8%E4%B8%AD%E6%B7%BB%E5%8A%A0%E9%94%AE%E5%80%BC%E5%AF%B9%20%28key,%20value%29%0A%20%20%20%20hmap%5B12836%5D%20%3D%20%22%E5%B0%8F%E5%93%88%22%0A%20%20%20%20hmap%5B15937%5D%20%3D%20%22%E5%B0%8F%E5%95%B0%22%0A%20%20%20%20hmap%5B16750%5D%20%3D%20%22%E5%B0%8F%E7%AE%97%22%0A%20%20%20%20hmap%5B13276%5D%20%3D%20%22%E5%B0%8F%E6%B3%95%22%0A%20%20%20%20hmap%5B10583%5D%20%3D%20%22%E5%B0%8F%E9%B8%AD%22%0A%20%20%20%20%0A%20%20%20%20%23%20%E9%81%8D%E5%8E%86%E5%93%88%E5%B8%8C%E8%A1%A8%0A%20%20%20%20%23%20%E9%81%8D%E5%8E%86%E9%94%AE%E5%80%BC%E5%AF%B9%20key-%3Evalue%0A%20%20%20%20for%20key,%20value%20in%20hmap.items%28%29%3A%0A%20%20%20%20%20%20%20%20print%28key,%20%22-%3E%22,%20value%29%0A%20%20%20%20%23%20%E5%8D%95%E7%8B%AC%E9%81%8D%E5%8E%86%E9%94%AE%20key%0A%20%20%20%20for%20key%20in%20hmap.keys%28%29%3A%0A%20%20%20%20%20%20%20%20print%28key%29%0A%20%20%20%20%23%20%E5%8D%95%E7%8B%AC%E9%81%8D%E5%8E%86%E5%80%BC%20value%0A%20%20%20%20for%20value%20in%20hmap.values%28%29%3A%0A%20%20%20%20%20%20%20%20print%28value%29&cumulative=false&curInstr=8&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
|
||||
|
||||
## ハッシュテーブルの簡単な実装
|
||||
|
||||
まずは最も単純なケースとして、**1 つの配列だけでハッシュテーブルを実装する**ことを考えます。ハッシュテーブルでは、配列中の各空き位置を<u>バケット(bucket)</u>と呼び、各バケットには 1 つのキーと値のペアを格納できます。したがって、検索操作とは `key` に対応するバケットを見つけ、そのバケットから `value` を取得することです。
|
||||
|
||||
では、`key` に基づいて対応するバケットをどのように特定するのでしょうか。これは<u>ハッシュ関数(hash function)</u>によって実現されます。ハッシュ関数の役割は、大きな入力空間をより小さな出力空間に写像することです。ハッシュテーブルでは、入力空間はすべての `key` 、出力空間はすべてのバケット(配列インデックス)です。言い換えると、`key` を入力すると、**ハッシュ関数によってその `key` に対応するキーと値のペアの配列内での格納位置を求められます**。
|
||||
|
||||
`key` を入力したとき、ハッシュ関数の計算過程は次の 2 段階に分かれます。
|
||||
|
||||
1. あるハッシュアルゴリズム `hash()` を用いてハッシュ値を計算します。
|
||||
2. ハッシュ値をバケット数(配列長)`capacity` で剰余し、その `key` に対応するバケット(配列インデックス)`index` を求めます。
|
||||
|
||||
```shell
|
||||
index = hash(key) % capacity
|
||||
```
|
||||
|
||||
その後、`index`を使用してハッシュ表内の対応するバケットにアクセスし、`value`を取得できます。
|
||||
その後、`index` を使ってハッシュテーブル内の対応するバケットにアクセスし、`value` を取得できます。
|
||||
|
||||
配列長が`capacity = 100`で、ハッシュアルゴリズムが`hash(key) = key`として定義されているとします。したがって、ハッシュ関数は`key % 100`として表現できます。以下の図は、`key`を学籍番号、`value`を名前として、ハッシュ関数の動作原理を示しています。
|
||||
配列長を `capacity = 100` 、ハッシュアルゴリズムを `hash(key) = key` とすると、ハッシュ関数は `key % 100` となります。次の図では、`key` を学籍番号、`value` を名前の例として、ハッシュ関数の動作原理を示します。
|
||||
|
||||

|
||||
|
||||
以下のコードは簡単なハッシュ表を実装しています。ここでは、`key`と`value`を`Pair`クラスにカプセル化してキー値ペアを表現しています。
|
||||
以下のコードは、単純なハッシュテーブルを実装したものです。ここでは、キーと値のペアを表すために `key` と `value` をクラス `Pair` にまとめています。
|
||||
|
||||
```src
|
||||
[file]{array_hash_map}-[class]{array_hash_map}-[func]{}
|
||||
```
|
||||
|
||||
## ハッシュ衝突とリサイズ
|
||||
## ハッシュ衝突と拡張
|
||||
|
||||
本質的に、ハッシュ関数の役割は、すべてのキーの入力空間全体を、すべての配列インデックスの出力空間にマッピングすることです。しかし、入力空間は出力空間よりもはるかに大きいことがよくあります。したがって、**理論的には、「複数の入力が同じ出力に対応する」ケースが常に存在します**。
|
||||
本質的には、ハッシュ関数の役割は、すべての `key` からなる入力空間を、配列のすべてのインデックスからなる出力空間に写像することです。しかし、入力空間は多くの場合、出力空間よりはるかに大きいため、**理論上は必ず「複数の入力が同じ出力に対応する」状況が存在します**。
|
||||
|
||||
上記の例では、与えられたハッシュ関数で、入力`key`の下二桁が同じ場合、ハッシュ関数は同じ出力を生成します。例えば、学籍番号12836と20336の2人の学生をクエリすると、以下のことがわかります:
|
||||
上の例のハッシュ関数では、入力 `key` の下 2 桁が同じであれば、出力結果も同じになります。たとえば、学籍番号 12836 と 20336 の 2 人の学生を検索すると、次の結果を得ます:
|
||||
|
||||
```shell
|
||||
12836 % 100 = 36
|
||||
20336 % 100 = 36
|
||||
```
|
||||
|
||||
下図に示すように、両方の学籍番号が同じ名前を指しており、これは明らかに間違っています。この複数の入力が同じ出力に対応する状況を<u>ハッシュ衝突</u>と呼びます。
|
||||
次の図に示すように、2 つの学籍番号が同じ名前を指してしまっており、これは明らかに誤りです。このような、複数の入力が同じ出力に対応する状況を<u>ハッシュ衝突(hash collision)</u>と呼びます。
|
||||
|
||||

|
||||
|
||||
ハッシュ表の容量$n$が増加するにつれて、複数のキーが同じバケットに割り当てられる確率が減少し、衝突が少なくなることは理解しやすいです。したがって、**ハッシュ表をリサイズすることでハッシュ衝突を減らすことができます**。
|
||||
容易に分かるように、ハッシュテーブルの容量 $n$ が大きいほど、複数の `key` が同じバケットに割り当てられる確率は低くなり、衝突も少なくなります。したがって、**ハッシュテーブルを拡張することでハッシュ衝突を減らせます**。
|
||||
|
||||
下図に示すように、リサイズ前は、キー値ペア`(136, A)`と`(236, D)`が衝突していました。しかし、リサイズ後は衝突が解決されています。
|
||||
次の図に示すように、拡張前はキーと値のペア `(136, A)` と `(236, D)` が衝突していますが、拡張後は衝突が解消されます。
|
||||
|
||||

|
||||

|
||||
|
||||
配列の拡張と同様に、ハッシュ表のリサイズにはすべてのキー値ペアを元のハッシュ表から新しいものに移行する必要があり、時間がかかります。さらに、ハッシュ表の`capacity`が変更されるため、ハッシュ関数を使用してすべてのキー値ペアの格納位置を再計算する必要があり、リサイズプロセスの計算オーバーヘッドがさらに増加します。したがって、プログラミング言語は頻繁なリサイズを防ぐために、ハッシュ表に十分大きな容量を割り当てることがよくあります。
|
||||
配列の拡張と同様に、ハッシュテーブルの拡張ではすべてのキーと値のペアを元のハッシュテーブルから新しいハッシュテーブルへ移し替える必要があり、非常に時間がかかります。また、ハッシュテーブルの容量 `capacity` が変わるため、ハッシュ関数を使ってすべてのキーと値のペアの格納位置を再計算しなければならず、これによって拡張過程の計算コストがさらに増加します。そのため、プログラミング言語では通常、頻繁な拡張を防ぐために十分大きなハッシュテーブル容量をあらかじめ確保します。
|
||||
|
||||
<u>負荷率</u>はハッシュ表の重要な概念です。ハッシュ表内の要素数とバケット数の比率として定義されます。ハッシュ衝突の深刻度を測定するために使用され、**しばしばハッシュ表のリサイズのトリガーとしても機能します**。例えば、Javaでは、負荷率が$0.75$を超えると、システムはハッシュ表を元のサイズの2倍にリサイズします。
|
||||
<u>負荷率(load factor)</u>はハッシュテーブルにおける重要な概念であり、ハッシュテーブル内の要素数をバケット数で割ったものとして定義され、ハッシュ衝突の深刻さを測るために用いられます。**また、ハッシュテーブル拡張の発動条件としてもよく使われます**。例えば Java では、負荷率が $0.75$ を超えると、システムはハッシュテーブルを元の $2$ 倍に拡張します。
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# ハッシュ表
|
||||
# ハッシュテーブル
|
||||
|
||||

|
||||

|
||||
|
||||
!!! abstract
|
||||
|
||||
コンピューティングの世界において、ハッシュ表は賢い司書のようなものです。
|
||||
|
||||
インデックス番号の計算方法を理解し、目的の本を迅速に取得することを可能にします。
|
||||
コンピュータの世界では、ハッシュテーブルは聡明な図書館員のような存在です。
|
||||
|
||||
彼は請求記号の計算方法を知っており、そのため目的の本を素早く見つけられます。
|
||||
|
||||
@@ -1,47 +1,51 @@
|
||||
# まとめ
|
||||
|
||||
### 重要なポイント
|
||||
### 重要ポイントの振り返り
|
||||
|
||||
- 入力`key`が与えられると、ハッシュ表は$O(1)$の時間で対応する`value`を取得でき、非常に効率的です。
|
||||
- 一般的なハッシュ表の操作には、クエリ、キー値ペアの追加、キー値ペアの削除、ハッシュ表の走査があります。
|
||||
- ハッシュ関数は`key`を配列インデックスにマッピングし、対応するバケットにアクセスして`value`を取得できるようにします。
|
||||
- 2つの異なるキーがハッシュ化後に同じ配列インデックスになる場合があり、誤ったクエリ結果につながります。この現象はハッシュ衝突として知られています。
|
||||
- ハッシュ表の容量が大きいほど、ハッシュ衝突の確率は低くなります。したがって、ハッシュ表のリサイズはハッシュ衝突を緩和できます。配列のリサイズと同様に、ハッシュ表のリサイズはコストが高いです。
|
||||
- 要素数をバケット数で割った負荷率は、ハッシュ衝突の深刻度を反映し、しばしばハッシュ表リサイズのトリガー条件として使用されます。
|
||||
- 連鎖法は各要素を連結リストに変換し、衝突するすべての要素を同じリストに格納することでハッシュ衝突に対処します。ただし、過度に長いリストはクエリ効率を低下させる可能性があり、リストを赤黒木に変換することで改善できます。
|
||||
- オープンアドレス法は複数回のプローブを通してハッシュ衝突を処理します。線形プローブは固定ステップサイズを使用しますが、要素を削除できず、クラスタリングを起こしやすい傾向があります。多重ハッシュはプローブに複数のハッシュ関数を使用し、線形プローブと比較してクラスタリングを減らしますが、計算オーバーヘッドが増加します。
|
||||
- 異なるプログラミング言語はさまざまなハッシュ表実装を採用しています。例えば、Javaの`HashMap`は連鎖法を使用し、Pythonの`dict`はオープンアドレス法を採用しています。
|
||||
- ハッシュ表では、決定性、高効率、均等分散を持つハッシュアルゴリズムが望まれます。暗号化では、ハッシュアルゴリズムは衝突耐性と雪崩効果も持つべきです。
|
||||
- ハッシュアルゴリズムは通常、ハッシュ値の均等分散を保証し、ハッシュ衝突を減らすために、大きな素数を剰余として使用します。
|
||||
- 一般的なハッシュアルゴリズムには、MD5、SHA-1、SHA-2、SHA-3があります。MD5はファイル整合性チェックによく使用され、SHA-2は安全なアプリケーションとプロトコルで一般的に使用されます。
|
||||
- プログラミング言語は通常、ハッシュ表のバケットインデックスを計算するために、データ型に対して組み込みのハッシュアルゴリズムを提供します。一般的に、不変オブジェクトのみがハッシュ可能です。
|
||||
- `key` を入力すると、ハッシュテーブルは $O(1)$ 時間で `value` を検索でき、非常に高効率である。
|
||||
- 一般的なハッシュテーブルの操作には、検索、キーと値のペアの追加、キーと値のペアの削除、ハッシュテーブルの走査などがある。
|
||||
- ハッシュ関数は `key` を配列インデックスに写像し、それによって対応するバケットにアクセスして `value` を取得する。
|
||||
- 異なる 2 つの `key` が、ハッシュ関数を通した後に同じ配列インデックスになることがあり、検索結果の誤りを引き起こす。この現象をハッシュ衝突と呼ぶ。
|
||||
- ハッシュテーブルの容量が大きいほど、ハッシュ衝突の確率は低くなる。そのため、ハッシュテーブルを拡張することでハッシュ衝突を緩和できる。配列の拡張と同様に、ハッシュテーブルの拡張操作のコストは大きい。
|
||||
- 負荷率は、ハッシュテーブル内の要素数をバケット数で割ったものと定義され、ハッシュ衝突の深刻さを反映する。ハッシュテーブル拡張を発動する条件としてよく用いられる。
|
||||
- 連鎖方式では、単一要素を連結リストに変換し、衝突したすべての要素を同じ連結リストに格納する。しかし、連結リストが長すぎると検索効率が低下するため、さらに連結リストを赤黒木に変換して効率を高めることができる。
|
||||
- オープンアドレス法は複数回の探索によってハッシュ衝突を処理する。線形探索は固定のステップ幅を用いるが、要素を削除できず、クラスタリングが発生しやすいという欠点がある。二重ハッシュは複数のハッシュ関数を用いて探索するため、線形探索に比べてクラスタリングが起きにくいが、複数のハッシュ関数によって計算量が増える。
|
||||
- プログラミング言語ごとに、異なるハッシュテーブル実装が採用されている。たとえば、Java の `HashMap` は連鎖方式を使用し、Python の `Dict` はオープンアドレス法を採用している。
|
||||
- ハッシュテーブルでは、ハッシュアルゴリズムに決定性、高効率、均一分布という特徴が求められる。暗号学では、ハッシュアルゴリズムはさらに耐衝突性とアバランシェ効果も備えるべきである。
|
||||
- ハッシュアルゴリズムは通常、大きな素数を法として用い、ハッシュ値の均一分布を最大限に保証してハッシュ衝突を減らす。
|
||||
- 一般的なハッシュアルゴリズムには MD5、SHA-1、SHA-2、SHA-3 などがある。MD5 はファイル完全性の検証によく用いられ、SHA-2 はセキュリティ用途やプロトコルでよく用いられる。
|
||||
- プログラミング言語は通常、データ型に対して組み込みのハッシュアルゴリズムを提供し、ハッシュテーブル内のバケットインデックスの計算に用いる。通常、ハッシュ可能なのは不変オブジェクトだけである。
|
||||
|
||||
### Q & A
|
||||
|
||||
**Q**: ハッシュ表の時間計算量が$O(n)$に悪化するのはいつですか?
|
||||
**Q**:ハッシュテーブルの時間計算量が $O(n)$ になるのはどのような場合ですか?
|
||||
|
||||
ハッシュ表の時間計算量は、ハッシュ衝突が深刻な場合に$O(n)$に悪化する可能性があります。ハッシュ関数が適切に設計され、容量が適切に設定され、衝突が均等に分散されている場合、時間計算量は$O(1)$です。プログラミング言語の組み込みハッシュ表を使用する場合、通常は時間計算量を$O(1)$と考えます。
|
||||
ハッシュ衝突が深刻な場合、ハッシュテーブルの時間計算量は $O(n)$ に劣化する。ハッシュ関数の設計が適切で、容量設定が合理的で、衝突が比較的均等な場合、時間計算量は $O(1)$ である。プログラミング言語組み込みのハッシュテーブルを使うとき、通常は時間計算量を $O(1)$ とみなす。
|
||||
|
||||
**Q**: なぜハッシュ関数$f(x) = x$を使用しないのですか?これなら衝突を排除できます。
|
||||
**Q**:なぜハッシュ関数 $f(x) = x$ を使わないのですか? そうすれば衝突は起きません。
|
||||
|
||||
ハッシュ関数$f(x) = x$では、各要素が一意のバケットインデックスに対応し、これは配列と同等です。しかし、入力空間は通常出力空間(配列長)よりもはるかに大きいため、ハッシュ関数の最後のステップは配列長の剰余を取ることがよくあります。言い換えると、ハッシュ表の目標は、$O(1)$のクエリ効率を提供しながら、より大きな状態空間をより小さなものにマッピングすることです。
|
||||
$f(x) = x$ というハッシュ関数では、各要素は一意のバケットインデックスに対応し、これは配列と等価である。しかし、入力空間は通常、出力空間(配列長)よりはるかに大きいため、ハッシュ関数の最後のステップはたいてい配列長での剰余になる。言い換えると、ハッシュテーブルの目的は、大きな状態空間をより小さな空間に写像し、$O(1)$ の検索効率を提供することである。
|
||||
|
||||
**Q**: ハッシュ表がこれらの構造を使って実装されているにもかかわらず、なぜ配列、連結リスト、二分木よりも効率的になれるのですか?
|
||||
**Q**:ハッシュテーブルの基礎実装は配列、連結リスト、二分木なのに、なぜそれらより高効率になり得るのですか?
|
||||
|
||||
まず、ハッシュ表は時間効率が高いですが、空間効率は低いです。ハッシュ表のメモリの大部分は未使用のままです。
|
||||
まず、ハッシュテーブルは時間効率が高くなる一方で、空間効率は低くなる。ハッシュテーブルには、かなりの部分で未使用のメモリが存在する。
|
||||
|
||||
次に、ハッシュ表は特定のユースケースでのみ時間効率が高いです。配列や連結リストを使用して同じ時間計算量で機能を実装できる場合、通常はハッシュ表を使用するよりも高速です。これは、ハッシュ関数の計算がオーバーヘッドを発生させ、時間計算量の定数因子が大きくなるためです。
|
||||
次に、時間効率が高くなるのは特定の利用場面に限られる。ある機能が同じ時間計算量で配列や連結リストによって実装できるなら、通常はハッシュテーブルより速い。これは、ハッシュ関数の計算にコストがかかり、時間計算量の定数項がより大きいからである。
|
||||
|
||||
最後に、ハッシュ表の時間計算量は悪化する可能性があります。例えば、連鎖法では、連結リストや赤黒木で検索操作を実行し、これは依然として$O(n)$時間に悪化するリスクがあります。
|
||||
最後に、ハッシュテーブルの時間計算量は劣化する可能性がある。たとえば連鎖方式では、連結リストや赤黒木で検索操作を行うため、なお $O(n)$ 時間に劣化するリスクがある。
|
||||
|
||||
**Q**: 多重ハッシュにも要素を直接削除できないという欠陥がありますか?削除としてマークされた空間は再利用できますか?
|
||||
**Q**:二重ハッシュにも要素を直接削除できない欠点がありますか? 削除済みとマークした領域は再利用できますか?
|
||||
|
||||
多重ハッシュはオープンアドレス法の一形態であり、すべてのオープンアドレス法には要素を直接削除できないという欠点があります。要素を削除済みとしてマークする必要があります。マークされた空間は再利用できます。ハッシュ表に新しい要素を挿入する際、ハッシュ関数が削除済みとしてマークされた位置を指している場合、その位置は新しい要素によって使用できます。これにより、ハッシュ表のプローブシーケンスを維持しながら、空間の効率的な使用が保証されます。
|
||||
二重ハッシュはオープンアドレス法の一種であり、オープンアドレス法はいずれも要素を直接削除できないという欠点があるため、削除のマーク付けが必要になる。削除済みとマークされた領域は再利用できる。新しい要素をハッシュテーブルに挿入し、ハッシュ関数によって削除済みとマークされた位置を見つけた場合、その位置は新しい要素に使用できる。こうすることで、ハッシュテーブルの探索系列を変えずに保ちつつ、空間利用率も確保できる。
|
||||
|
||||
**Q**: なぜ線形プローブの検索プロセス中にハッシュ衝突が発生するのですか?
|
||||
**Q**:なぜ線形探索では、要素を探すときにハッシュ衝突が発生するのですか?
|
||||
|
||||
検索プロセス中、ハッシュ関数は対応するバケットとキー値ペアを指します。`key`が一致しない場合、ハッシュ衝突を示します。したがって、線形プローブは正しいキー値ペアが見つかるか検索が失敗するまで、事前に決められたステップサイズで下方向に検索します。
|
||||
探索時には、ハッシュ関数で対応するバケットとキーと値のペアを見つけ、`key` が一致しないことが分かると、それはハッシュ衝突を意味する。そのため、線形探索法では事前に設定したステップ幅に従って順に探索し、正しいキーと値のペアを見つけるか、見つからずに終了するまで続ける。
|
||||
|
||||
**Q**: なぜハッシュ表のリサイズがハッシュ衝突を緩和できるのですか?
|
||||
**Q**:なぜハッシュテーブルの拡張でハッシュ衝突を緩和できるのですか?
|
||||
|
||||
ハッシュ関数の最後のステップは、出力を配列インデックス範囲内に保つために、配列長$n$の剰余を取ることがよくあります。リサイズ時、配列長$n$が変化し、キーに対応するインデックスも変化する可能性があります。以前に同じバケットにマッピングされていたキーが、リサイズ後に複数のバケットに分散される可能性があり、それによってハッシュ衝突が緩和されます。
|
||||
ハッシュ関数の最後のステップは、たいてい配列長 $n$ での剰余を取り、出力値を配列インデックスの範囲内に収めることである。拡張後は配列長 $n$ が変化し、`key` に対応するインデックスも変化する可能性がある。もともと同じバケットに入っていた複数の `key` は、拡張後には複数のバケットに割り当てられる可能性があり、それによってハッシュ衝突が緩和される。
|
||||
|
||||
**Q**:高効率な読み書きのためなら、配列を直接使えばよいのではないですか?
|
||||
|
||||
データの `key` が連続した小範囲の整数であれば、配列を直接使えばよく、単純で高効率である。しかし `key` が別の型(たとえば文字列)の場合は、ハッシュ関数を用いて `key` を配列インデックスに写像し、さらにバケット配列を通じて要素を格納する必要がある。このような構造がハッシュテーブルである。
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
# ヒープ構築操作
|
||||
# ヒープ構築
|
||||
|
||||
場合によっては、リストのすべての要素を使用してヒープを構築したいことがあり、このプロセスは「ヒープ構築操作」として知られています。
|
||||
場合によっては、リスト内のすべての要素を使ってヒープを構築したいことがあります。この過程を「ヒープ構築」と呼びます。
|
||||
|
||||
## ヒープ挿入操作による実装
|
||||
## ヒープへの挿入操作による実現
|
||||
|
||||
まず、空のヒープを作成し、次にリストを反復処理して、各要素に対して順番に「ヒープ挿入操作」を実行します。これは、要素をヒープの末尾に追加し、次に下から上に「ヒープ化」することを意味します。
|
||||
まず空のヒープを作成し、次にリストを走査して、各要素に対して順に「ヒープへの挿入操作」を実行します。つまり、要素をヒープの末尾に追加してから、その要素に対して「下から上へ」のヒープ化を行います。
|
||||
|
||||
ヒープに要素が追加されるたびに、ヒープの長さは1つずつ増加します。ノードは二分木に上から下に追加されるため、ヒープは「上から下に」構築されます。
|
||||
要素が1つヒープに挿入されるたびに、ヒープの長さは1増加します。ノードは上から下へ順に二分木へ追加されるため、ヒープは「上から下へ」構築されます。
|
||||
|
||||
要素数を$n$とすると、各要素の挿入操作は$O(\log{n})$時間かかるため、このヒープ構築方法の時間計算量は$O(n \log n)$です。
|
||||
要素数を $n$ とすると、各要素のヒープへの挿入操作には $O(\log{n})$ の時間がかかるため、このヒープ構築法の時間計算量は $O(n \log n)$ です。
|
||||
|
||||
## 走査によるヒープ化の実装
|
||||
## 走査によるヒープ化で実現
|
||||
|
||||
実際には、2つのステップでより効率的なヒープ構築方法を実装できます。
|
||||
実際には、より効率的なヒープ構築法を実現でき、全体は2つの手順に分かれます。
|
||||
|
||||
1. リストのすべての要素をそのままヒープに追加します。この時点では、ヒープの性質はまだ満たされていません。
|
||||
2. ヒープを逆順(レベル順走査の逆)で走査し、各非葉ノードに対して「上から下のヒープ化」を実行します。
|
||||
2. ヒープを逆順で走査し(レベル順走査の逆順)、各非葉ノードに対して順に「上から下へ」のヒープ化を実行します。
|
||||
|
||||
**ノードをヒープ化した後、そのノードを根とする部分木は有効な部分ヒープになります**。走査が逆順であるため、ヒープは「下から上に」構築されます。
|
||||
**あるノードをヒープ化するたびに、そのノードを根とする部分木は合法な部分ヒープになります**。また、逆順で走査するため、ヒープは「下から上へ」構築されます。
|
||||
|
||||
逆走査を選択する理由は、現在のノードの下の部分木がすでに有効な部分ヒープであることを保証し、現在のノードのヒープ化を効果的にするためです。
|
||||
逆順走査を選ぶのは、この方法なら現在のノードの下にある部分木がすでに合法な部分ヒープであることを保証でき、そのうえで現在のノードをヒープ化してはじめて有効になるからです。
|
||||
|
||||
言及する価値があるのは、**葉ノードは子を持たないため、自然に有効な部分ヒープを形成し、ヒープ化する必要がない**ということです。以下のコードに示すように、最後の非葉ノードは最後のノードの親です。そこから開始して逆順に走査してヒープ化を実行します:
|
||||
なお、**葉ノードには子ノードがないため、それ自体が自然に合法な部分ヒープであり、ヒープ化は不要です**。以下のコードが示すように、最後の非葉ノードは最後のノードの親ノードであり、そこから逆順に走査してヒープ化を実行します。
|
||||
|
||||
```src
|
||||
[file]{my_heap}-[class]{max_heap}-[func]{__init__}
|
||||
```
|
||||
|
||||
## 計算量分析
|
||||
## 計算量の分析
|
||||
|
||||
次に、この第2のヒープ構築方法の時間計算量を計算してみましょう。
|
||||
以下では、2つ目のヒープ構築法の時間計算量を求めてみましょう。
|
||||
|
||||
- 完備二分木のノード数を$n$と仮定すると、葉ノードの数は$(n + 1) / 2$です。ここで$/$ は整数除算です。したがって、ヒープ化が必要なノードの数は$(n - 1) / 2$です。
|
||||
- 「上から下のヒープ化」のプロセスでは、各ノードは最大で葉ノードまでヒープ化されるため、最大反復回数は二分木の高さ$\log n$です。
|
||||
- 完全二分木のノード数を $n$ とすると、葉ノード数は $(n + 1) / 2$ です。ここで $/$ は切り捨て除算を表します。したがって、ヒープ化が必要なノード数は $(n - 1) / 2$ です。
|
||||
- 上から下へのヒープ化の過程では、各ノードは最大で葉ノードまでヒープ化されるため、最大反復回数は二分木の高さ $\log n$ です。
|
||||
|
||||
この2つを掛け合わせると、ヒープ構築プロセスの時間計算量は$O(n \log n)$となります。**しかし、この推定は正確ではありません。二分木の下位レベルには上位よりもはるかに多くのノードがあるという性質を考慮していないからです。**
|
||||
上の2つを掛け合わせると、ヒープ構築過程の時間計算量は $O(n \log n)$ となります。**しかし、この見積もりは正確ではありません。二分木では下層のノード数が上層よりはるかに多いという性質を考慮していないためです**。
|
||||
|
||||
より正確な計算を行いましょう。計算を簡素化するため、$n$個のノードと高さ$h$を持つ「完全二分木」を仮定します。この仮定は結果の正確性に影響しません。
|
||||
次に、より正確な計算を行います。計算を簡単にするため、ノード数が $n$ 、高さが $h$ の「満二分木」を仮定します。この仮定は計算結果の正しさに影響しません。
|
||||
|
||||

|
||||

|
||||
|
||||
上図に示すように、ノードが「上から下にヒープ化される」最大反復回数は、そのノードから葉ノードまでの距離と等しく、これは正確に「ノードの高さ」です。したがって、各レベルで「ノード数×ノードの高さ」を合計して、**すべてのノードの総ヒープ化反復回数を得る**ことができます。
|
||||
上図に示すように、ノードを「上から下へヒープ化」する最大反復回数は、そのノードから葉ノードまでの距離に等しく、この距離こそが「ノードの高さ」です。したがって、各層の「ノード数 $\times$ ノードの高さ」を合計すれば、**すべてのノードのヒープ化反復回数の総和**が得られます。
|
||||
|
||||
$$
|
||||
T(h) = 2^0h + 2^1(h-1) + 2^2(h-2) + \dots + 2^{(h-1)}\times1
|
||||
$$
|
||||
|
||||
上記の方程式を簡素化するために、高校の数列の知識を使用する必要があります。まず$T(h)$に$2$を掛けて以下を得ます:
|
||||
上式を簡約するには中学の数列の知識を用います。まず $T(h)$ に $2$ を掛けると、次のようになります。
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
T(h) & = 2^0h + 2^1(h-1) + 2^2(h-2) + \dots + 2^{h-1}\times1 \newline
|
||||
2T(h) & = 2^1h + 2^2(h-1) + 2^3(h-2) + \dots + 2^h\times1 \newline
|
||||
2 T(h) & = 2^1h + 2^2(h-1) + 2^3(h-2) + \dots + 2^{h}\times1 \newline
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
変位法を使用して$2T(h)$から$T(h)$を減算すると、以下を得ます:
|
||||
ずらして引く方法を用い、下式の $2 T(h)$ から上式の $T(h)$ を引くと、次が得られます。
|
||||
|
||||
$$
|
||||
2T(h) - T(h) = T(h) = -2^0h + 2^1 + 2^2 + \dots + 2^{h-1} + 2^h
|
||||
$$
|
||||
|
||||
方程式を観察すると、$T(h)$は等比数列であり、和の公式を使用して直接計算でき、時間計算量は以下になります:
|
||||
上式を見ると、$T(h)$ は等比数列であることがわかるため、和の公式を直接用いて、時間計算量は次のように求められます。
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
@@ -71,4 +71,4 @@ T(h) & = 2 \frac{1 - 2^h}{1 - 2} - h \newline
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
さらに、高さ$h$の完全二分木は$n = 2^{h+1} - 1$個のノードを持つため、計算量は$O(2^h) = O(n)$です。この計算は、**リストを入力してヒープを構築する時間計算量が$O(n)$であり、非常に効率的である**ことを示しています。
|
||||
さらに、高さ $h$ の満二分木のノード数は $n = 2^{h+1} - 1$ であるため、計算量は容易に $O(2^h) = O(n)$ とわかります。以上の導出は、**入力リストからヒープを構築する時間計算量が $O(n)$ であり、非常に効率的である**ことを示しています。
|
||||
|
||||
+141
-137
@@ -1,64 +1,64 @@
|
||||
# ヒープ
|
||||
|
||||
<u>ヒープ</u>は特定の条件を満たす完備二分木で、主に次の2つのタイプに分類されます(下図参照)。
|
||||
<u>ヒープ(heap)</u>は、特定の条件を満たす完全二分木であり、主に次の 2 種類に分けられます。
|
||||
|
||||
- <u>最小ヒープ</u>:任意のノードの値 $\leq$ その子ノードの値。
|
||||
- <u>最大ヒープ</u>:任意のノードの値 $\geq$ その子ノードの値。
|
||||
- <u>最小ヒープ(min heap)</u>:任意のノードの値 $\leq$ その子ノードの値。
|
||||
- <u>最大ヒープ(max heap)</u>:任意のノードの値 $\geq$ その子ノードの値。
|
||||
|
||||

|
||||
|
||||
完備二分木の特別なケースとして、ヒープには以下の特性があります:
|
||||
ヒープは完全二分木の特殊な例であり、次の性質を持ちます。
|
||||
|
||||
- 最下位層のノードは左から右に埋められ、他の層のノードは完全に埋められています。
|
||||
- 二分木の根ノードをヒープの「先頭」と呼び、最も右下のノードをヒープの「末尾」と呼びます。
|
||||
- 最大ヒープ(最小ヒープ)の場合、先頭要素(根)の値はすべての要素の中で最大(最小)です。
|
||||
- 最下層のノードは左から順に埋められ、ほかの層のノードはすべて埋まっています。
|
||||
- 二分木の根ノードを「ヒープ頂点」、最下層で最も右にあるノードを「ヒープ底」と呼びます。
|
||||
- 最大ヒープ(最小ヒープ)では、ヒープ頂点の要素(根ノード)の値が最大(最小)です。
|
||||
|
||||
## ヒープの一般的な操作
|
||||
## ヒープの基本操作
|
||||
|
||||
多くのプログラミング言語が<u>優先度キュー</u>を提供していることに注意してください。これは優先度付きソートを持つキューとして定義される抽象データ構造です。
|
||||
ここで注意したいのは、多くのプログラミング言語が提供しているのは<u>優先度付きキュー(priority queue)</u>であり、これは優先度順に並ぶキューとして定義される抽象データ構造だということです。
|
||||
|
||||
実際には、**ヒープは優先度キューを実装するためによく使用されます。最大ヒープは、要素が降順でデキューされる優先度キューに対応します**。使用の観点から、「優先度キュー」と「ヒープ」を同等のデータ構造と考えることができます。したがって、この本では両者を特別に区別せず、統一して「ヒープ」と呼びます。
|
||||
実際には、**ヒープは通常、優先度付きキューの実装に用いられ、最大ヒープは要素が大きい順に取り出される優先度付きキューに相当します**。利用の観点では、「優先度付きキュー」と「ヒープ」は等価なデータ構造とみなせます。そのため、本書では両者を特に区別せず、まとめて「ヒープ」と呼びます。
|
||||
|
||||
ヒープの一般的な操作を下表に示します。メソッド名はプログラミング言語によって異なる場合があります。
|
||||
ヒープの基本操作を以下の表に示します。メソッド名はプログラミング言語によって異なります。
|
||||
|
||||
<p align="center"> 表 <id> ヒープ操作の効率 </p>
|
||||
<p align="center"> 表 <id> ヒープの操作効率 </p>
|
||||
|
||||
| メソッド名 | 説明 | 時間計算量 |
|
||||
| ----------- | ------------------------------------------------- | ----------- |
|
||||
| `push()` | ヒープに要素を追加 | $O(\log n)$ |
|
||||
| `pop()` | ヒープから先頭要素を削除 | $O(\log n)$ |
|
||||
| `peek()` | 先頭要素にアクセス(最大/最小ヒープの場合、最大/最小値) | $O(1)$ |
|
||||
| `size()` | ヒープ内の要素数を取得 | $O(1)$ |
|
||||
| `isEmpty()` | ヒープが空かどうかをチェック | $O(1)$ |
|
||||
| メソッド名 | 説明 | 時間計算量 |
|
||||
| ----------- | ------------------------------------------------ | ----------- |
|
||||
| `push()` | 要素をヒープに追加 | $O(\log n)$ |
|
||||
| `pop()` | ヒープ頂点の要素を取り出す | $O(\log n)$ |
|
||||
| `peek()` | ヒープ頂点の要素にアクセス(最大 / 最小ヒープではそれぞれ最大 / 最小値) | $O(1)$ |
|
||||
| `size()` | ヒープ内の要素数を取得 | $O(1)$ |
|
||||
| `isEmpty()` | ヒープが空かどうかを判定 | $O(1)$ |
|
||||
|
||||
実際には、プログラミング言語によって提供されるヒープクラス(または優先度キュークラス)を直接使用できます。
|
||||
実際の応用では、プログラミング言語が提供するヒープクラス(または優先度付きキュークラス)をそのまま使えます。
|
||||
|
||||
ソートアルゴリズムで「昇順」と「降順」があるように、`flag`を設定するか`Comparator`を変更することで「最小ヒープ」と「最大ヒープ」を切り替えることができます。コードは以下の通りです:
|
||||
ソートアルゴリズムにおける「昇順」と「降順」と同様に、`flag` を設定したり `Comparator` を変更したりすることで、「最小ヒープ」と「最大ヒープ」を切り替えられます。コードは以下のとおりです:
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="heap.py"
|
||||
# 最小ヒープの初期化
|
||||
# 最小ヒープを初期化
|
||||
min_heap, flag = [], 1
|
||||
# 最大ヒープの初期化
|
||||
# 最大ヒープを初期化
|
||||
max_heap, flag = [], -1
|
||||
|
||||
# Pythonのheapqモジュールはデフォルトで最小ヒープを実装
|
||||
# 要素をヒープにプッシュする前に負の値にすることで、順序を反転させ、最大ヒープを実装
|
||||
# この例では、flag = 1は最小ヒープに対応し、flag = -1は最大ヒープに対応
|
||||
# Python の heapq モジュールはデフォルトで最小ヒープを実装している
|
||||
# 「要素を負にして」からヒープに追加すると、大小関係を反転させて最大ヒープを実現できる
|
||||
# この例では、flag = 1 のときは最小ヒープ、flag = -1 のときは最大ヒープに対応する
|
||||
|
||||
# ヒープに要素をプッシュ
|
||||
# 要素をヒープに追加
|
||||
heapq.heappush(max_heap, flag * 1)
|
||||
heapq.heappush(max_heap, flag * 3)
|
||||
heapq.heappush(max_heap, flag * 2)
|
||||
heapq.heappush(max_heap, flag * 5)
|
||||
heapq.heappush(max_heap, flag * 4)
|
||||
|
||||
# ヒープの先頭要素を取得
|
||||
# ヒープ頂点の要素を取得
|
||||
peek: int = flag * max_heap[0] # 5
|
||||
|
||||
# ヒープの先頭要素をポップ
|
||||
# ポップされた要素は降順のシーケンスを形成
|
||||
# ヒープ頂点の要素を取り出す
|
||||
# 取り出された要素は大きい順の列になる
|
||||
val = flag * heapq.heappop(max_heap) # 5
|
||||
val = flag * heapq.heappop(max_heap) # 4
|
||||
val = flag * heapq.heappop(max_heap) # 3
|
||||
@@ -68,10 +68,10 @@
|
||||
# ヒープのサイズを取得
|
||||
size: int = len(max_heap)
|
||||
|
||||
# ヒープが空かどうかをチェック
|
||||
# ヒープが空かどうかを判定
|
||||
is_empty: bool = not max_heap
|
||||
|
||||
# リストからヒープを作成
|
||||
# 入力リストからヒープを構築
|
||||
min_heap: list[int] = [1, 3, 2, 5, 4]
|
||||
heapq.heapify(min_heap)
|
||||
```
|
||||
@@ -79,24 +79,24 @@
|
||||
=== "C++"
|
||||
|
||||
```cpp title="heap.cpp"
|
||||
/* ヒープの初期化 */
|
||||
// 最小ヒープの初期化
|
||||
/* ヒープを初期化 */
|
||||
// 最小ヒープを初期化
|
||||
priority_queue<int, vector<int>, greater<int>> minHeap;
|
||||
// 最大ヒープの初期化
|
||||
// 最大ヒープを初期化
|
||||
priority_queue<int, vector<int>, less<int>> maxHeap;
|
||||
|
||||
/* ヒープに要素をプッシュ */
|
||||
/* 要素をヒープに追加 */
|
||||
maxHeap.push(1);
|
||||
maxHeap.push(3);
|
||||
maxHeap.push(2);
|
||||
maxHeap.push(5);
|
||||
maxHeap.push(4);
|
||||
|
||||
/* ヒープの先頭要素を取得 */
|
||||
/* ヒープ頂点の要素を取得 */
|
||||
int peek = maxHeap.top(); // 5
|
||||
|
||||
/* ヒープの先頭要素をポップ */
|
||||
// ポップされた要素は降順のシーケンスを形成
|
||||
/* ヒープ頂点の要素を取り出す */
|
||||
// 取り出された要素は大きい順の列になる
|
||||
maxHeap.pop(); // 5
|
||||
maxHeap.pop(); // 4
|
||||
maxHeap.pop(); // 3
|
||||
@@ -106,10 +106,10 @@
|
||||
/* ヒープのサイズを取得 */
|
||||
int size = maxHeap.size();
|
||||
|
||||
/* ヒープが空かどうかをチェック */
|
||||
/* ヒープが空かどうかを判定 */
|
||||
bool isEmpty = maxHeap.empty();
|
||||
|
||||
/* リストからヒープを作成 */
|
||||
/* 入力リストからヒープを構築 */
|
||||
vector<int> input{1, 3, 2, 5, 4};
|
||||
priority_queue<int, vector<int>, greater<int>> minHeap(input.begin(), input.end());
|
||||
```
|
||||
@@ -117,24 +117,24 @@
|
||||
=== "Java"
|
||||
|
||||
```java title="heap.java"
|
||||
/* ヒープの初期化 */
|
||||
// 最小ヒープの初期化
|
||||
/* ヒープを初期化 */
|
||||
// 最小ヒープを初期化
|
||||
Queue<Integer> minHeap = new PriorityQueue<>();
|
||||
// 最大ヒープの初期化(ラムダ式でComparatorを変更するだけ)
|
||||
// 最大ヒープを初期化(lambda 式で Comparator を変更すればよい)
|
||||
Queue<Integer> maxHeap = new PriorityQueue<>((a, b) -> b - a);
|
||||
|
||||
/* ヒープに要素をプッシュ */
|
||||
/* 要素をヒープに追加 */
|
||||
maxHeap.offer(1);
|
||||
maxHeap.offer(3);
|
||||
maxHeap.offer(2);
|
||||
maxHeap.offer(5);
|
||||
maxHeap.offer(4);
|
||||
|
||||
/* ヒープの先頭要素を取得 */
|
||||
/* ヒープ頂点の要素を取得 */
|
||||
int peek = maxHeap.peek(); // 5
|
||||
|
||||
/* ヒープの先頭要素をポップ */
|
||||
// ポップされた要素は降順のシーケンスを形成
|
||||
/* ヒープ頂点の要素を取り出す */
|
||||
// 取り出された要素は大きい順の列になる
|
||||
peek = maxHeap.poll(); // 5
|
||||
peek = maxHeap.poll(); // 4
|
||||
peek = maxHeap.poll(); // 3
|
||||
@@ -144,34 +144,34 @@
|
||||
/* ヒープのサイズを取得 */
|
||||
int size = maxHeap.size();
|
||||
|
||||
/* ヒープが空かどうかをチェック */
|
||||
/* ヒープが空かどうかを判定 */
|
||||
boolean isEmpty = maxHeap.isEmpty();
|
||||
|
||||
/* リストからヒープを作成 */
|
||||
/* 入力リストからヒープを構築 */
|
||||
minHeap = new PriorityQueue<>(Arrays.asList(1, 3, 2, 5, 4));
|
||||
```
|
||||
|
||||
=== "C#"
|
||||
|
||||
```csharp title="heap.cs"
|
||||
/* ヒープの初期化 */
|
||||
// 最小ヒープの初期化
|
||||
/* ヒープを初期化 */
|
||||
// 最小ヒープを初期化
|
||||
PriorityQueue<int, int> minHeap = new();
|
||||
// 最大ヒープの初期化(ラムダ式でComparatorを変更するだけ)
|
||||
PriorityQueue<int, int> maxHeap = new(Comparer<int>.Create((x, y) => y - x));
|
||||
// 最大ヒープを初期化(lambda 式で Comparer を変更すればよい)
|
||||
PriorityQueue<int, int> maxHeap = new(Comparer<int>.Create((x, y) => y.CompareTo(x)));
|
||||
|
||||
/* ヒープに要素をプッシュ */
|
||||
/* 要素をヒープに追加 */
|
||||
maxHeap.Enqueue(1, 1);
|
||||
maxHeap.Enqueue(3, 3);
|
||||
maxHeap.Enqueue(2, 2);
|
||||
maxHeap.Enqueue(5, 5);
|
||||
maxHeap.Enqueue(4, 4);
|
||||
|
||||
/* ヒープの先頭要素を取得 */
|
||||
/* ヒープ頂点の要素を取得 */
|
||||
int peek = maxHeap.Peek();//5
|
||||
|
||||
/* ヒープの先頭要素をポップ */
|
||||
// ポップされた要素は降順のシーケンスを形成
|
||||
/* ヒープ頂点の要素を取り出す */
|
||||
// 取り出された要素は大きい順の列になる
|
||||
peek = maxHeap.Dequeue(); // 5
|
||||
peek = maxHeap.Dequeue(); // 4
|
||||
peek = maxHeap.Dequeue(); // 3
|
||||
@@ -181,76 +181,76 @@
|
||||
/* ヒープのサイズを取得 */
|
||||
int size = maxHeap.Count;
|
||||
|
||||
/* ヒープが空かどうかをチェック */
|
||||
/* ヒープが空かどうかを判定 */
|
||||
bool isEmpty = maxHeap.Count == 0;
|
||||
|
||||
/* リストからヒープを作成 */
|
||||
/* 入力リストからヒープを構築 */
|
||||
minHeap = new PriorityQueue<int, int>([(1, 1), (3, 3), (2, 2), (5, 5), (4, 4)]);
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
||||
```go title="heap.go"
|
||||
// Goでは、heap.Interfaceを実装することで整数の最大ヒープを構築できます
|
||||
// heap.Interfaceを実装するには、sort.Interfaceも実装する必要があります
|
||||
// Go では、heap.Interface を実装することで整数の最大ヒープを構築できる
|
||||
// heap.Interface を実装するには、同時に sort.Interface も実装する必要がある
|
||||
type intHeap []any
|
||||
|
||||
// heap.InterfaceのPushメソッド、要素をヒープにプッシュ
|
||||
// Push は heap.Interface のメソッドで、要素をヒープに追加する
|
||||
func (h *intHeap) Push(x any) {
|
||||
// PushとPopの両方でポインタレシーバーを使用
|
||||
// スライスの要素を調整するだけでなく、その長さも変更するため
|
||||
// Push と Pop は pointer receiver を引数に取る
|
||||
// スライスの内容を調整するだけでなく、スライスの長さも変更するため。
|
||||
*h = append(*h, x.(int))
|
||||
}
|
||||
|
||||
// heap.InterfaceのPopメソッド、ヒープの先頭要素を削除
|
||||
// Pop は heap.Interface のメソッドで、ヒープ頂点の要素を取り出す
|
||||
func (h *intHeap) Pop() any {
|
||||
// ヒープからポップする要素は末尾に格納
|
||||
// 取り出す要素は末尾に格納されている
|
||||
last := (*h)[len(*h)-1]
|
||||
*h = (*h)[:len(*h)-1]
|
||||
return last
|
||||
}
|
||||
|
||||
// sort.InterfaceのLenメソッド
|
||||
// Len は sort.Interface のメソッド
|
||||
func (h *intHeap) Len() int {
|
||||
return len(*h)
|
||||
}
|
||||
|
||||
// sort.InterfaceのLessメソッド
|
||||
// Less は sort.Interface のメソッド
|
||||
func (h *intHeap) Less(i, j int) bool {
|
||||
// 最小ヒープを実装したい場合は、これを小なり比較に変更
|
||||
// 最小ヒープを実装する場合は、不等号を小なりに変更する
|
||||
return (*h)[i].(int) > (*h)[j].(int)
|
||||
}
|
||||
|
||||
// sort.InterfaceのSwapメソッド
|
||||
// Swap は sort.Interface のメソッド
|
||||
func (h *intHeap) Swap(i, j int) {
|
||||
(*h)[i], (*h)[j] = (*h)[j], (*h)[i]
|
||||
}
|
||||
|
||||
// Top ヒープの先頭要素を取得
|
||||
// Top はヒープ頂点の要素を取得
|
||||
func (h *intHeap) Top() any {
|
||||
return (*h)[0]
|
||||
}
|
||||
|
||||
/* ドライバーコード */
|
||||
/* Driver Code */
|
||||
func TestHeap(t *testing.T) {
|
||||
/* ヒープの初期化 */
|
||||
// 最大ヒープの初期化
|
||||
/* ヒープを初期化 */
|
||||
// 最大ヒープを初期化
|
||||
maxHeap := &intHeap{}
|
||||
heap.Init(maxHeap)
|
||||
/* ヒープに要素をプッシュ */
|
||||
// heap.Interfaceのメソッドを呼び出して要素を追加
|
||||
/* 要素をヒープに追加 */
|
||||
// heap.Interface のメソッドを呼び出して要素を追加する
|
||||
heap.Push(maxHeap, 1)
|
||||
heap.Push(maxHeap, 3)
|
||||
heap.Push(maxHeap, 2)
|
||||
heap.Push(maxHeap, 4)
|
||||
heap.Push(maxHeap, 5)
|
||||
|
||||
/* ヒープの先頭要素を取得 */
|
||||
/* ヒープ頂点の要素を取得 */
|
||||
top := maxHeap.Top()
|
||||
fmt.Printf("ヒープの先頭要素は %d\n", top)
|
||||
fmt.Printf("ヒープ頂点の要素は %d\n", top)
|
||||
|
||||
/* ヒープの先頭要素をポップ */
|
||||
// heap.Interfaceのメソッドを呼び出して要素を削除
|
||||
/* ヒープ頂点の要素を取り出す */
|
||||
// heap.Interface のメソッドを呼び出して要素を削除する
|
||||
heap.Pop(maxHeap) // 5
|
||||
heap.Pop(maxHeap) // 4
|
||||
heap.Pop(maxHeap) // 3
|
||||
@@ -261,30 +261,30 @@
|
||||
size := len(*maxHeap)
|
||||
fmt.Printf("ヒープ内の要素数は %d\n", size)
|
||||
|
||||
/* ヒープが空かどうかをチェック */
|
||||
/* ヒープが空かどうかを判定 */
|
||||
isEmpty := len(*maxHeap) == 0
|
||||
fmt.Printf("ヒープは空ですか? %t\n", isEmpty)
|
||||
fmt.Printf("ヒープは空か %t\n", isEmpty)
|
||||
}
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
|
||||
```swift title="heap.swift"
|
||||
/* ヒープの初期化 */
|
||||
// SwiftのHeap型は最大ヒープと最小ヒープの両方をサポートし、swift-collectionsライブラリが必要
|
||||
/* ヒープを初期化 */
|
||||
// Swift の Heap 型は最大ヒープと最小ヒープの両方をサポートしており、swift-collections の導入が必要
|
||||
var heap = Heap<Int>()
|
||||
|
||||
/* ヒープに要素をプッシュ */
|
||||
/* 要素をヒープに追加 */
|
||||
heap.insert(1)
|
||||
heap.insert(3)
|
||||
heap.insert(2)
|
||||
heap.insert(5)
|
||||
heap.insert(4)
|
||||
|
||||
/* ヒープの先頭要素を取得 */
|
||||
/* ヒープ頂点の要素を取得 */
|
||||
var peek = heap.max()!
|
||||
|
||||
/* ヒープの先頭要素をポップ */
|
||||
/* ヒープ頂点の要素を取り出す */
|
||||
peek = heap.removeMax() // 5
|
||||
peek = heap.removeMax() // 4
|
||||
peek = heap.removeMax() // 3
|
||||
@@ -294,29 +294,29 @@
|
||||
/* ヒープのサイズを取得 */
|
||||
let size = heap.count
|
||||
|
||||
/* ヒープが空かどうかをチェック */
|
||||
/* ヒープが空かどうかを判定 */
|
||||
let isEmpty = heap.isEmpty
|
||||
|
||||
/* リストからヒープを作成 */
|
||||
/* 入力リストからヒープを構築 */
|
||||
let heap2 = Heap([1, 3, 2, 5, 4])
|
||||
```
|
||||
|
||||
=== "JS"
|
||||
|
||||
```javascript title="heap.js"
|
||||
// JavaScriptは組み込みのHeapクラスを提供していません
|
||||
// JavaScript には組み込みの Heap クラスがない
|
||||
```
|
||||
|
||||
=== "TS"
|
||||
|
||||
```typescript title="heap.ts"
|
||||
// TypeScriptは組み込みのHeapクラスを提供していません
|
||||
// TypeScript には組み込みの Heap クラスがない
|
||||
```
|
||||
|
||||
=== "Dart"
|
||||
|
||||
```dart title="heap.dart"
|
||||
// Dartは組み込みのHeapクラスを提供していません
|
||||
// Dart には組み込みの Heap クラスがない
|
||||
```
|
||||
|
||||
=== "Rust"
|
||||
@@ -325,24 +325,24 @@
|
||||
use std::collections::BinaryHeap;
|
||||
use std::cmp::Reverse;
|
||||
|
||||
/* ヒープの初期化 */
|
||||
// 最小ヒープの初期化
|
||||
/* ヒープを初期化 */
|
||||
// 最小ヒープを初期化
|
||||
let mut min_heap = BinaryHeap::<Reverse<i32>>::new();
|
||||
// 最大ヒープの初期化
|
||||
// 最大ヒープを初期化
|
||||
let mut max_heap = BinaryHeap::new();
|
||||
|
||||
/* ヒープに要素をプッシュ */
|
||||
/* 要素をヒープに追加 */
|
||||
max_heap.push(1);
|
||||
max_heap.push(3);
|
||||
max_heap.push(2);
|
||||
max_heap.push(5);
|
||||
max_heap.push(4);
|
||||
|
||||
/* ヒープの先頭要素を取得 */
|
||||
/* ヒープ頂点の要素を取得 */
|
||||
let peek = max_heap.peek().unwrap(); // 5
|
||||
|
||||
/* ヒープの先頭要素をポップ */
|
||||
// ポップされた要素は降順のシーケンスを形成
|
||||
/* ヒープ頂点の要素を取り出す */
|
||||
// 取り出された要素は大きい順の列になる
|
||||
let peek = max_heap.pop().unwrap(); // 5
|
||||
let peek = max_heap.pop().unwrap(); // 4
|
||||
let peek = max_heap.pop().unwrap(); // 3
|
||||
@@ -352,40 +352,40 @@
|
||||
/* ヒープのサイズを取得 */
|
||||
let size = max_heap.len();
|
||||
|
||||
/* ヒープが空かどうかをチェック */
|
||||
/* ヒープが空かどうかを判定 */
|
||||
let is_empty = max_heap.is_empty();
|
||||
|
||||
/* リストからヒープを作成 */
|
||||
/* 入力リストからヒープを構築 */
|
||||
let min_heap = BinaryHeap::from(vec![Reverse(1), Reverse(3), Reverse(2), Reverse(5), Reverse(4)]);
|
||||
```
|
||||
|
||||
=== "C"
|
||||
|
||||
```c title="heap.c"
|
||||
// Cは組み込みのHeapクラスを提供していません
|
||||
// C には組み込みの Heap クラスがない
|
||||
```
|
||||
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title="heap.kt"
|
||||
/* ヒープの初期化 */
|
||||
// 最小ヒープの初期化
|
||||
/* ヒープを初期化 */
|
||||
// 最小ヒープを初期化
|
||||
var minHeap = PriorityQueue<Int>()
|
||||
// 最大ヒープの初期化(ラムダ式でComparatorを変更するだけ)
|
||||
// 最大ヒープを初期化(lambda 式で Comparator を変更すればよい)
|
||||
val maxHeap = PriorityQueue { a: Int, b: Int -> b - a }
|
||||
|
||||
/* ヒープに要素をプッシュ */
|
||||
/* 要素をヒープに追加 */
|
||||
maxHeap.offer(1)
|
||||
maxHeap.offer(3)
|
||||
maxHeap.offer(2)
|
||||
maxHeap.offer(5)
|
||||
maxHeap.offer(4)
|
||||
|
||||
/* ヒープの先頭要素を取得 */
|
||||
/* ヒープ頂点の要素を取得 */
|
||||
var peek = maxHeap.peek() // 5
|
||||
|
||||
/* ヒープの先頭要素をポップ */
|
||||
// ポップされた要素は降順のシーケンスを形成
|
||||
/* ヒープ頂点の要素を取り出す */
|
||||
// 取り出された要素は大きい順の列になる
|
||||
peek = maxHeap.poll() // 5
|
||||
peek = maxHeap.poll() // 4
|
||||
peek = maxHeap.poll() // 3
|
||||
@@ -395,55 +395,59 @@
|
||||
/* ヒープのサイズを取得 */
|
||||
val size = maxHeap.size
|
||||
|
||||
/* ヒープが空かどうかをチェック */
|
||||
/* ヒープが空かどうかを判定 */
|
||||
val isEmpty = maxHeap.isEmpty()
|
||||
|
||||
/* リストからヒープを作成 */
|
||||
/* 入力リストからヒープを構築 */
|
||||
minHeap = PriorityQueue(mutableListOf(1, 3, 2, 5, 4))
|
||||
```
|
||||
|
||||
=== "Ruby"
|
||||
|
||||
```ruby title="heap.rb"
|
||||
|
||||
# Ruby には組み込みの Heap クラスがない
|
||||
```
|
||||
|
||||
??? pythontutor "実行を可視化"
|
||||
|
||||
https://pythontutor.com/render.html#code=import%20heapq%0A%0A%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E5%B0%8F%E9%A1%B6%E5%A0%86%0A%20%20%20%20min_heap,%20flag%20%3D%20%5B%5D,%201%0A%20%20%20%20%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E5%A4%A7%E9%A1%B6%E5%A0%86%0A%20%20%20%20max_heap,%20flag%20%3D%20%5B%5D,%20-1%0A%20%20%20%20%0A%20%20%20%20%23%20Python%20%E7%9A%84%20heapq%20%E6%A8%A1%E5%9D%97%E9%BB%98%E8%AE%A4%E5%AE%9E%E7%8E%B0%E5%B0%8F%E9%A1%B6%E5%A0%86%0A%20%20%20%20%23%20%E8%80%83%E8%99%91%E5%B0%86%E2%80%9C%E5%85%83%E7%B4%A0%E5%8F%96%E8%B4%9F%E2%80%9D%E5%90%8E%E5%86%8D%E5%85%A5%E5%A0%86%EF%BC%8C%E8%BF%99%E6%A0%B7%E5%B0%B1%E5%8F%AF%E4%BB%A5%E5%B0%86%E5%A4%A7%E5%B0%8F%E5%85%B3%E7%B3%BB%E9%A2%A0%E5%80%92%EF%BC%8C%E4%BB%8E%E8%80%8C%E5%AE%9E%E7%8E%B0%E5%A4%A7%E9%A1%B6%E5%A0%86%0A%20%20%20%20%23%20%E5%9C%A8%E6%9C%AC%E7%A4%BA%E4%BE%8B%E4%B8%AD%EF%BC%8Cflag%20%3D%201%20%E6%97%B6%E5%AF%B9%E5%BA%94%E5%B0%8F%E9%A1%B6%E5%A0%86%EF%BC%8Cflag%20%3D%20-1%20%E6%97%B6%E5%AF%B9%E5%BA%94%E5%A4%A7%E9%A1%B6%E5%A0%86%0A%20%20%20%20%0A%20%20%20%20%23%20%E5%85%83%E7%B4%A0%E5%85%A5%E5%A0%86%0A%20%20%20%20heapq.heappush%28max_heap,%20flag%20*%201%29%0A%20%20%20%20heapq.heappush%28max_heap,%20flag%20*%203%29%0A%20%20%20%20heapq.heappush%28max_heap,%20flag%20*%202%29%0A%20%20%20%20heapq.heappush%28max_heap,%20flag%20*%205%29%0A%20%20%20%20heapq.heappush%28max_heap,%20flag%20*%204%29%0A%20%20%20%20%0A%20%20%20%20%23%20%E8%8E%B7%E5%8F%96%E5%A0%86%E9%A1%B6%E5%85%83%E7%B4%A0%0A%20%20%20%20peek%20%3D%20flag%20*%20max_heap%5B0%5D%20%23%205%0A%20%20%20%20%0A%20%20%20%20%23%20%E5%A0%86%E9%A1%B6%E5%85%83%E7%B4%A0%E5%87%BA%E5%A0%86%0A%20%20%20%20%23%20%E5%87%BA%E5%A0%86%E5%85%83%E7%B4%A0%E4%BC%9A%E5%BD%A2%E6%88%90%E4%B8%80%E4%B8%AA%E4%BB%8E%E5%A4%A7%E5%88%B0%E5%B0%8F%E7%9A%84%E5%BA%8F%E5%88%97%0A%20%20%20%20val%20%3D%20flag%20*%20heapq.heappop%28max_heap%29%20%23%205%0A%20%20%20%20val%20%3D%20flag%20*%20heapq.heappop%28max_heap%29%20%23%204%0A%20%20%20%20val%20%3D%20flag%20*%20heapq.heappop%28max_heap%29%20%23%203%0A%20%20%20%20val%20%3D%20flag%20*%20heapq.heappop%28max_heap%29%20%23%202%0A%20%20%20%20val%20%3D%20flag%20*%20heapq.heappop%28max_heap%29%20%23%201%0A%20%20%20%20%0A%20%20%20%20%23%20%E8%8E%B7%E5%8F%96%E5%A0%86%E5%A4%A7%E5%B0%8F%0A%20%20%20%20size%20%3D%20len%28max_heap%29%0A%20%20%20%20%0A%20%20%20%20%23%20%E5%88%A4%E6%96%AD%E5%A0%86%E6%98%AF%E5%90%A6%E4%B8%BA%E7%A9%BA%0A%20%20%20%20is_empty%20%3D%20not%20max_heap%0A%20%20%20%20%0A%20%20%20%20%23%20%E8%BE%93%E5%85%A5%E5%88%97%E8%A1%A8%E5%B9%B6%E5%BB%BA%E5%A0%86%0A%20%20%20%20min_heap%20%3D%20%5B1,%203,%202,%205,%204%5D%0A%20%20%20%20heapq.heapify%28min_heap%29&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
|
||||
|
||||
## ヒープの実装
|
||||
|
||||
以下の実装は最大ヒープです。最小ヒープに変換するには、すべてのサイズ論理比較を反転させるだけです(例えば、$\geq$を$\leq$に置き換える)。興味のある読者は自分で実装することをお勧めします。
|
||||
以下では最大ヒープを実装します。最小ヒープに変換したい場合は、すべての大小比較ロジックを反転させるだけです(たとえば、$\geq$ を $\leq$ に置き換えます)。興味のある読者は自分で実装してみてください。
|
||||
|
||||
### ヒープの格納と表現
|
||||
|
||||
「二分木」の節で述べたように、完備二分木は配列表現に非常に適しています。ヒープは完備二分木の一種なので、**配列を使用してヒープを格納します**。
|
||||
「二分木」の章で述べたように、完全二分木は配列で表現するのに非常に適しています。ヒープはまさに完全二分木の一種なので、**ここでは配列を使ってヒープを格納します**。
|
||||
|
||||
配列を使用して二分木を表現する場合、要素はノード値を表し、インデックスは二分木内のノード位置を表します。**ノードポインタはインデックスマッピング公式を通じて実装されます**。
|
||||
配列で二分木を表す場合、要素はノードの値を表し、インデックスは二分木におけるノードの位置を表します。**ノード間の参照関係はインデックスの対応式によって実現できます**。
|
||||
|
||||
下図に示すように、インデックス$i$が与えられた場合、その左の子のインデックスは$2i + 1$、右の子のインデックスは$2i + 2$、親のインデックスは$(i - 1) / 2$(床除算)です。インデックスが範囲外の場合、nullノードまたはノードが存在しないことを意味します。
|
||||
次の図に示すように、インデックス $i$ に対して、左子ノードのインデックスは $2i + 1$ 、右子ノードのインデックスは $2i + 2$ 、親ノードのインデックスは $(i - 1) / 2$(切り捨て除算)です。インデックスが範囲外であれば、空ノードまたはノードが存在しないことを表します。
|
||||
|
||||

|
||||
|
||||
後で便利に使用するため、インデックスマッピング公式を関数にカプセル化できます:
|
||||
インデックスの対応式は関数にまとめておくと、後続で使いやすくなります:
|
||||
|
||||
```src
|
||||
[file]{my_heap}-[class]{max_heap}-[func]{parent}
|
||||
```
|
||||
|
||||
### ヒープの先頭要素へのアクセス
|
||||
### ヒープ頂点の要素にアクセス
|
||||
|
||||
ヒープの先頭要素は二分木の根ノードで、リストの最初の要素でもあります:
|
||||
ヒープ頂点の要素は二分木の根ノード、すなわちリストの先頭要素です:
|
||||
|
||||
```src
|
||||
[file]{my_heap}-[class]{max_heap}-[func]{peek}
|
||||
```
|
||||
|
||||
### ヒープへの要素挿入
|
||||
### 要素をヒープに追加
|
||||
|
||||
要素`val`が与えられた場合、まずそれをヒープの底に追加します。追加後、`val`がヒープ内の他の要素より大きい可能性があるため、ヒープの完全性が損なわれる可能性があります。**したがって、挿入されたノードから根ノードまでのパスを修復する必要があります**。この操作は<u>ヒープ化</u>と呼ばれます。
|
||||
与えられた要素 `val` を、まずヒープの底に追加します。追加後、`val` がヒープ内のほかの要素より大きい可能性があるため、ヒープ条件が崩れているかもしれません。**そのため、挿入ノードから根ノードまでの経路上にある各ノードを修復する必要があります**。この操作を<u>ヒープ化(heapify)</u>と呼びます。
|
||||
|
||||
挿入されたノードから開始して、**下から上にヒープ化を実行します**。下図に示すように、挿入されたノードの値をその親ノードと比較し、挿入されたノードが大きい場合はそれらを交換します。次にこの操作を続行し、根に到達するか、交換が不要なノードに遭遇するまで、下から上にヒープ内の各ノードを修復します。
|
||||
ヒープへ追加したノードから始めて、**下から上へヒープ化**を行います。次の図のように、挿入ノードとその親ノードの値を比較し、挿入ノードのほうが大きければそれらを交換します。その後もこの操作を繰り返し、下から上へ各ノードを修復して、根ノードを越えるか交換不要のノードに達した時点で終了します。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -469,24 +473,24 @@
|
||||
=== "<9>"
|
||||

|
||||
|
||||
総ノード数を$n$とすると、木の高さは$O(\log n)$です。したがって、ヒープ化操作のループ反復回数は最大$O(\log n)$で、**要素挿入操作の時間計算量は$O(\log n)$になります**。コードは以下の通りです:
|
||||
ノード総数を $n$ とすると、木の高さは $O(\log n)$ です。したがって、ヒープ化操作のループ回数は高々 $O(\log n)$ であり、**要素をヒープに追加する操作の時間計算量は $O(\log n)$** です。コードは以下のとおりです:
|
||||
|
||||
```src
|
||||
[file]{my_heap}-[class]{max_heap}-[func]{sift_up}
|
||||
```
|
||||
|
||||
### ヒープからの先頭要素削除
|
||||
### ヒープ頂点の要素を取り出す
|
||||
|
||||
ヒープの先頭要素は二分木の根ノード、つまりリストの最初の要素です。リストから最初の要素を直接削除すると、二分木内のすべてのノードインデックスが変更され、後続の修復にヒープ化を使用することが困難になります。要素インデックスの変更を最小限に抑えるため、次の手順を使用します。
|
||||
ヒープ頂点の要素は二分木の根ノード、すなわちリストの先頭要素です。もし先頭要素をそのまま削除すると、二分木内のすべてのノードのインデックスが変化してしまい、その後のヒープ化による修復が困難になります。要素インデックスの変動をできるだけ小さくするため、次の手順を取ります。
|
||||
|
||||
1. ヒープの先頭要素と底の要素を交換します(根ノードと最も右の葉ノードを交換)。
|
||||
2. 交換後、リストからヒープの底を削除します(交換されているため、実際には元の先頭要素が削除される)。
|
||||
3. 根ノードから開始して、**上から下にヒープ化を実行します**。
|
||||
1. ヒープ頂点の要素とヒープ底の要素を交換する(根ノードと最も右の葉ノードを交換する)。
|
||||
2. 交換後、ヒープ底をリストから削除する(すでに交換済みであるため、実際に削除されるのは元のヒープ頂点の要素であることに注意)。
|
||||
3. 根ノードから開始し、**上から下へヒープ化**を行う。
|
||||
|
||||
下図に示すように、**「上から下のヒープ化」の方向は「下から上のヒープ化」と反対です**。根ノードの値をその2つの子と比較し、最大の子と交換します。次に、葉ノードに到達するか、交換が不要なノードに遭遇するまで、この操作を繰り返します。
|
||||
次の図のように、**「上から下へのヒープ化」の方向は「下から上へのヒープ化」と逆**です。根ノードの値を 2 つの子ノードと比較し、最大の子ノードと根ノードを交換します。その後、この操作を繰り返し、葉ノードを越えるか交換不要のノードに達した時点で終了します。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -515,14 +519,14 @@
|
||||
=== "<10>"
|
||||

|
||||
|
||||
要素挿入操作と同様に、先頭要素削除操作の時間計算量も$O(\log n)$です。コードは以下の通りです:
|
||||
要素をヒープに追加する操作と同様に、ヒープ頂点の要素を取り出す操作の時間計算量も $O(\log n)$ です。コードは以下のとおりです:
|
||||
|
||||
```src
|
||||
[file]{my_heap}-[class]{max_heap}-[func]{sift_down}
|
||||
```
|
||||
|
||||
## ヒープの一般的な応用
|
||||
## ヒープの代表的な応用
|
||||
|
||||
- **優先度キュー**:ヒープは優先度キューを実装するための好ましいデータ構造で、エンキュー操作とデキュー操作の両方の時間計算量が$O(\log n)$、キュー構築の時間計算量が$O(n)$で、すべて非常に効率的です。
|
||||
- **ヒープソート**:データセットが与えられた場合、それらからヒープを作成し、次に要素削除操作を継続的に実行して順序付けされたデータを取得できます。ただし、ヒープソートを実装するより洗練された方法があり、「ヒープソート」の章で説明されています。
|
||||
- **最大$k$個の要素の発見**:これは古典的なアルゴリズム問題であり、一般的な使用例でもあります。Weiboホット検索のトップ10ホットニュースの選択や、トップ10の売れ筋商品の選択などです。
|
||||
- **優先度付きキュー**:ヒープは、優先度付きキューを実装するための代表的なデータ構造です。キューへの追加と取り出しの時間計算量はいずれも $O(\log n)$ で、ヒープ構築は $O(n)$ であり、これらの操作はいずれも非常に効率的です。
|
||||
- **ヒープソート**:与えられたデータ群からヒープを構築し、要素の取り出しを繰り返すことで整列済みデータを得られます。ただし、通常はより洗練された方法でヒープソートを実装します。詳しくは「ヒープソート」の章を参照してください。
|
||||
- **最大の $k$ 個の要素を取得**:これは古典的なアルゴリズム問題であると同時に、典型的な応用でもあります。たとえば、人気上位 10 件のニュースをホットトピックとして選んだり、売上上位 10 件の商品を選んだりする場面です。
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
|
||||
!!! abstract
|
||||
|
||||
ヒープは山とその険しい峰のように、層をなして起伏し、それぞれが独特の形を持っています。
|
||||
|
||||
各山の頂は散らばった高さで上下しますが、最も高いものが常に最初に注目を集めます。
|
||||
ヒープは連なる山々の峰のように、幾重にも重なり、さまざまな形をしている。
|
||||
|
||||
いくつもの山の高さはまちまちだが、最も高い峰がいつも最初に目に入る。
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
# まとめ
|
||||
|
||||
### 重要な復習
|
||||
### 重要なポイントの振り返り
|
||||
|
||||
- ヒープは完備二分木で、その構築性質に基づいて最大ヒープまたは最小ヒープに分類できます。最大ヒープの先頭要素は最大で、最小ヒープの先頭要素は最小です。
|
||||
- 優先度キューは、デキューの優先度を持つキューとして定義され、通常ヒープを使用して実装されます。
|
||||
- ヒープの一般的な操作とそれに対応する時間計算量には以下があります:ヒープへの要素挿入$O(\log n)$、ヒープからの先頭要素削除$O(\log n)$、ヒープの先頭要素へのアクセス$O(1)$。
|
||||
- 完備二分木は配列で表現するのに適しているため、ヒープは一般的に配列を使用して格納されます。
|
||||
- ヒープ化操作はヒープの性質を維持するために使用され、ヒープの挿入操作と削除操作の両方で使用されます。
|
||||
- $n$個の要素が入力として与えられた場合のヒープ構築の時間計算量は$O(n)$に最適化でき、これは非常に効率的です。
|
||||
- Top-kは古典的なアルゴリズム問題で、ヒープデータ構造を使用して効率的に解決でき、時間計算量は$O(n \log k)$です。
|
||||
- ヒープは完全二分木であり、条件の違いによって最大ヒープと最小ヒープに分けられる。最大(最小)ヒープの根の要素は最大値(最小値)である。
|
||||
- 優先度付きキューとは、取り出し時に優先度が考慮されるキューであり、通常はヒープを用いて実装される。
|
||||
- ヒープの代表的な操作とそれに対応する時間計算量には、要素の挿入 $O(\log n)$、根の要素の削除 $O(\log n)$、根の要素へのアクセス $O(1)$ などがある。
|
||||
- 完全二分木は配列で表現するのに非常に適しているため、通常は配列を使ってヒープを格納する。
|
||||
- ヒープ化操作はヒープの性質を保つために用いられ、挿入操作と削除操作の両方で使用される。
|
||||
- $n$ 個の要素を入力してヒープを構築する時間計算量は $O(n)$ まで最適化でき、非常に効率的である。
|
||||
- Top-k は古典的なアルゴリズム問題であり、ヒープ構造を用いることで効率的に解くことができ、時間計算量は $O(n \log k)$ である。
|
||||
|
||||
### Q & A
|
||||
|
||||
**Q**: データ構造の「ヒープ」とメモリ管理の「ヒープ」は同じ概念ですか?
|
||||
**Q**:データ構造の「ヒープ」とメモリ管理の「ヒープ」は同じ概念ですか?
|
||||
|
||||
この2つは、どちらも「ヒープ」と呼ばれますが、同じ概念ではありません。コンピュータシステムメモリのヒープは動的メモリ割り当ての一部で、プログラムが実行中にデータを格納するために使用できます。プログラムは、オブジェクトや配列などの複雑な構造を格納するために、一定量のヒープメモリを要求できます。割り当てられたデータが不要になったときは、メモリリークを防ぐためにプログラムがこのメモリを解放する必要があります。スタックメモリと比較して、ヒープメモリの管理と使用にはより多くの注意が必要で、不適切な使用はメモリリークやダングリングポインタにつながる可能性があります。
|
||||
両者は同じ概念ではなく、たまたまどちらも「ヒープ」と呼ばれているだけである。コンピュータシステムのメモリにおけるヒープは動的メモリ割り当ての一部であり、プログラムは実行時にこれを使ってデータを格納できる。プログラムは一定量のヒープメモリを要求し、オブジェクトや配列などの複雑な構造を保存できる。これらのデータが不要になったときは、メモリリークを防ぐためにそのメモリを解放する必要がある。スタックメモリと比べると、ヒープメモリの管理と使用にはより慎重さが求められ、不適切に扱うとメモリリークやダングリングポインタなどの問題を引き起こす可能性がある。
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
# Top-k問題
|
||||
# Top-k 問題
|
||||
|
||||
!!! question
|
||||
|
||||
長さ$n$の順序付けられていない配列`nums`が与えられたとき、配列内の最大$k$個の要素を返してください。
|
||||
長さ $n$ の未整列配列 `nums` が与えられたとき、配列内で最大の $k$ 個の要素を返してください。
|
||||
|
||||
この問題について、まず2つの直接的な解法を紹介し、次により効率的なヒープベースの方法を説明します。
|
||||
この問題について、まずは発想が比較的直接的な 2 つの解法を紹介し、その後でより効率の高いヒープ解法を紹介します。
|
||||
|
||||
## 方法1:反復選択
|
||||
## 方法一:走査による選択
|
||||
|
||||
下図に示すように、$k$回の反復を実行し、各回で$1$番目、$2$番目、$\dots$、$k$番目に大きい要素を抽出できます。時間計算量は$O(nk)$です。
|
||||
以下の図に示すように $k$ 回の走査を行い、各ラウンドでそれぞれ第 $1$、$2$、$\dots$、$k$ 位の要素を取り出すことができます。時間計算量は $O(nk)$ です。
|
||||
|
||||
この方法は$k \ll n$の場合にのみ適しています。$k$が$n$に近い場合、時間計算量は$O(n^2)$に近づき、非常に時間がかかります。
|
||||
この方法は $k \ll n$ の場合にしか適していません。$k$ が $n$ にかなり近いと、時間計算量は $O(n^2)$ に近づき、非常に時間がかかるためです。
|
||||
|
||||

|
||||

|
||||
|
||||
!!! tip
|
||||
|
||||
$k = n$の場合、完全に順序付けられたシーケンスを得ることができ、これは「選択ソート」アルゴリズムと同等です。
|
||||
$k = n$ のとき、完全な昇順列を得ることができ、この場合は「選択ソート」アルゴリズムと等価になります。
|
||||
|
||||
## 方法2:ソート
|
||||
## 方法二:ソート
|
||||
|
||||
下図に示すように、まず配列`nums`をソートし、次に最後の$k$個の要素を返すことができます。時間計算量は$O(n \log n)$です。
|
||||
以下の図に示すように、まず配列 `nums` をソートし、その後で右端の $k$ 個の要素を返すことができます。時間計算量は $O(n \log n)$ です。
|
||||
|
||||
明らかに、この方法はタスクを「やりすぎ」ています。最大$k$個の要素を見つけるだけでよく、他の要素をソートする必要はありません。
|
||||
明らかに、この方法は必要以上の処理を行っています。なぜなら、必要なのは最大の $k$ 個の要素を見つけることだけであり、他の要素をソートする必要はないからです。
|
||||
|
||||

|
||||

|
||||
|
||||
## 方法3:ヒープ
|
||||
## 方法三:ヒープ
|
||||
|
||||
以下のプロセスに示すように、ヒープに基づいてTop-k問題をより効率的に解決できます。
|
||||
ヒープを用いることで、Top-k 問題をより効率的に解くことができます。手順は以下の図のとおりです。
|
||||
|
||||
1. 最小ヒープを初期化します。先頭要素が最小になります。
|
||||
2. まず、配列の最初の$k$個の要素をヒープに挿入します。
|
||||
3. $k + 1$番目の要素から開始し、現在の要素がヒープの先頭要素より大きい場合、ヒープの先頭要素を削除し、現在の要素をヒープに挿入します。
|
||||
4. 走査を完了した後、ヒープには最大$k$個の要素が含まれています。
|
||||
1. 最小ヒープを初期化し、そのヒープ頂点の要素が最小となるようにします。
|
||||
2. まず配列の先頭 $k$ 個の要素を順にヒープへ挿入します。
|
||||
3. $k + 1$ 番目の要素から開始し、現在の要素がヒープ頂点の要素より大きければ、ヒープ頂点の要素を取り出し、現在の要素をヒープへ挿入します。
|
||||
4. 走査が完了した後、ヒープに保持されているのが最大の $k$ 個の要素です。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -62,12 +62,12 @@
|
||||
=== "<9>"
|
||||

|
||||
|
||||
サンプルコードは以下の通りです:
|
||||
サンプルコードは以下のとおりです。
|
||||
|
||||
```src
|
||||
[file]{top_k}-[class]{}-[func]{top_k_heap}
|
||||
```
|
||||
|
||||
合計$n$回のヒープ挿入と削除が実行され、最大ヒープサイズが$k$であるため、時間計算量は$O(n \log k)$です。この方法は非常に効率的で、$k$が小さい場合、時間計算量は$O(n)$に近づき、$k$が大きい場合でも、時間計算量は$O(n \log n)$を超えません。
|
||||
合計で $n$ 回のヒープ挿入と取り出しを行い、ヒープの最大長は $k$ であるため、時間計算量は $O(n \log k)$ です。この方法は非常に効率が高く、$k$ が小さいときは時間計算量が $O(n)$ に近づき、$k$ が大きいときでも $O(n \log n)$ を超えることはありません。
|
||||
|
||||
さらに、この方法は動的データストリームのシナリオに適しています。データを継続的に追加することで、ヒープ内の要素を維持し、最大$k$個の要素の動的更新を実現できます。
|
||||
さらに、この方法は動的データストリームの利用シーンにも適しています。データが継続的に追加される場合でも、ヒープ内の要素を保ち続けることで、最大の $k$ 個の要素を動的に更新できます。
|
||||
|
||||
@@ -5,26 +5,26 @@ icon: material/rocket-launch-outline
|
||||
|
||||
# はじめに
|
||||
|
||||
数年前、私はLeetCodeで「剣指Offer」の問題解答を共有し、多くの読者から励ましとサポートを受けました。読者とのやり取りの中で、最もよく聞かれた質問は「アルゴリズムの勉強をどう始めたらよいか」でした。次第に、私はこの質問に強い関心を抱くようになりました。
|
||||
数年前、私は LeetCode 中国版で「剣指 Offer」シリーズの解説を共有し、多くの読者から励ましと支持をいただきました。読者との交流の中で、最もよく聞かれた質問の一つが「アルゴリズムをどう学び始めればよいか」でした。次第に、私はこの問題に強い関心を抱くようになりました。
|
||||
|
||||
問題を直接解くことが最も人気のある方法のようです。これはシンプルで直接的で効果的です。しかし、問題解決はマインスイーパーをプレイするようなものです。自学自習の能力が高い人は、地雷を一つずつ回避していくことができますが、しっかりとした基礎がない人は、何度もつまずいて挫折しながら後退することになるかもしれません。教科書を読むことも一般的な方法ですが、就職活動中の人にとって、卒業論文の執筆、履歴書の提出、筆記試験や面接の準備が既にエネルギーの大部分を消費しており、分厚い本を読むことはしばしば困難な挑戦となります。
|
||||
手探りでひたすら問題を解くことは、最も人気のある方法のようです。単純で、直接的で、しかも効果的です。しかし問題演習は「マインスイーパー」を遊ぶことに似ており、独学力の高い人は地雷を一つずつうまく取り除ける一方で、基礎が十分でない人は大きな痛手を受け、挫折の中で少しずつ後退してしまいがちです。教材を通読するのもよくある方法ですが、就職を目指す人にとっては、卒業論文、履歴書の提出、筆記試験や面接の準備ですでに大半の力を使い果たしており、分厚い本を読み込むことはしばしば困難な挑戦になってしまいます。
|
||||
|
||||
もしあなたが同様の悩みを抱えているなら、この本があなたを見つけることができて幸運です。この本は、この質問に対する私の答えです。これが最良の解決策ではないかもしれませんが、少なくとも積極的な試みです。この本があなたに直接内定をもたらすことはできませんが、データ構造とアルゴリズムの「知識地図」を探索する手引きとなり、さまざまな「地雷」の形、大きさ、位置を理解し、さまざまな「地雷除去方法」をマスターできるようお手伝いします。これらのスキルがあれば、より快適に問題を解き、文献を読むことができ、徐々に知識体系を構築できると信じています。
|
||||
もしあなたも同じような悩みを抱えているなら、この本があなたのもとに“たどり着いた”のは幸運なことです。本書は、この問いに対して私が示す答えです。たとえ最良の解ではなくても、少なくとも前向きな試みではあります。本書だけで直接内定を得られるわけではありませんが、データ構造とアルゴリズムの「知識地図」を探る手助けをし、さまざまな「地雷」の形や大きさ、分布を理解し、いろいろな「地雷除去の方法」を身につけられるよう導きます。こうした力があれば、問題演習や文献読解をより自在に進め、やがて完整な知識体系を築いていけると信じています。
|
||||
|
||||
私は、ファインマン教授の言葉に深く同感します。「知識は無料ではありません。注意を払わなければならないのです。」この意味で、この本は完全に「無料」ではありません。この本に対するあなたの貴重な「注意」に応えるために、私は最善を尽くし、最大の「注意」を払ってこの本を書きます。
|
||||
私はファインマン教授の言葉に深く賛同しています。「Knowledge isn't free. You have to pay attention.」この意味において、本書は完全に「無料」ではありません。本書のためにあなたが払ってくれる貴重な「注意」に応えるため、私はできる限りの力を尽くし、最大限の「注意」を注いで本書を書き上げます。
|
||||
|
||||
自分の限界を認識しており、この本の内容が時間をかけて洗練されたにもかかわらず、間違いは確実に残っていることを理解しています。先生方や学生の皆様からの批評と訂正を心から歓迎いたします。
|
||||
私は自らの学識と力量の浅さをよく承知しています。本書の内容はある程度磨きをかけてきたものの、なお多くの誤りが残っているはずです。先生方、そして学習者の皆さまからのご批判とご指摘を心よりお願いいたします。
|
||||
|
||||
{ class="cover-image" }
|
||||
{ class="cover-image" }
|
||||
|
||||
<div style="text-align: center;">
|
||||
<h2 style="margin-top: 0.8em; margin-bottom: 0.8em;">Hello, Algo!</h2>
|
||||
<h2 style="margin-top: 0.8em; margin-bottom: 0.8em;">Hello、アルゴリズム!</h2>
|
||||
</div>
|
||||
|
||||
コンピュータの出現は世界に大きな変化をもたらしました。高速な計算能力と優れたプログラム可能性により、コンピュータはアルゴリズムを実行しデータを処理するための理想的な媒体となりました。ビデオゲームのリアルなグラフィックス、自動運転の知的な判断、AlphaGoの見事な囲碁ゲーム、ChatGPTの自然な対話など、これらのアプリケーションはすべて、コンピュータ上で動作するアルゴリズムの精巧な実演です。
|
||||
コンピュータの登場は世界に大きな変革をもたらしました。高速な計算能力と優れたプログラム可能性によって、アルゴリズムの実行とデータ処理の理想的な媒体となったのです。ビデオゲームのリアルな映像、自動運転の知的な意思決定、AlphaGo の見事な対局、ChatGPT の自然な対話に至るまで、これらの応用はいずれもコンピュータ上でアルゴリズムが巧みに表現されたものです。
|
||||
|
||||
実際、コンピュータの出現以前から、アルゴリズムとデータ構造は世界の至る所に存在していました。初期のアルゴリズムは比較的シンプルで、古代の計数方法や道具作りの手順などがありました。文明が進歩するにつれて、アルゴリズムはより洗練され複雑になりました。職人の精巧な技術から、生産力を解放する工業製品、宇宙を支配する科学法則まで、ほぼすべての平凡または驚異的なことの背後には、アルゴリズムの巧妙な思考があります。
|
||||
実際には、コンピュータが誕生する以前から、アルゴリズムとデータ構造は世界のいたるところに存在していました。初期のアルゴリズムは比較的単純で、たとえば古代の数え方や道具作りの手順などがそれに当たります。文明の進歩とともに、アルゴリズムは次第により精緻で複雑なものになっていきました。匠の巧みな技から、生産力を解放する工業製品、さらには宇宙の運行を支配する科学法則に至るまで、ほとんどあらゆる平凡なもの、あるいは驚嘆すべきものの背後には、精妙なアルゴリズムの思想が潜んでいます。
|
||||
|
||||
同様に、データ構造は至る所にあります。ソーシャルネットワークから地下鉄路線まで、多くのシステムは「グラフ」としてモデル化できます。国から家族まで、社会組織の主要な形態は「木」の特徴を示します。冬服は「スタック」のようで、最初に着たものが最後に脱がれます。バドミントンのシャトル筒は「キュー」に似ており、一方の端で挿入し、もう一方の端で取り出します。辞書は「ハッシュテーブル」のようで、目標エントリを素早く検索できます。
|
||||
同様に、データ構造も至るところに存在します。社会ネットワークのような大きなものから地下鉄路線のような小さなものまで、多くのシステムは「グラフ」としてモデル化できます。国家のような大きな単位から家庭のような小さな単位まで、社会の主な組織形態には「木」の特徴があります。冬服は「スタック」のように、最初に着たものが最後に脱がれます。バドミントンシャトルの筒は「キュー」のように、一方から入れて他方から取り出します。辞書は「ハッシュテーブル」のようなもので、目的の見出し語を素早く探せます。
|
||||
|
||||
この本は、明確で理解しやすいアニメーション図解と実行可能なコード例を通じて、読者がアルゴリズムとデータ構造の核心概念を理解し、プログラミングを通じてそれらを実装できるようになることを目指しています。この基盤の上で、この本は複雑な世界におけるアルゴリズムの生き生きとした現れを明らかにし、アルゴリズムの美しさを示すことに努めています。この本があなたのお役に立てることを願っています!
|
||||
本書は、わかりやすいアニメーション図解と実行可能なコード例を通じて、読者がアルゴリズムとデータ構造の核心概念を理解し、さらにプログラミングによってそれらを実装できるようになることを目指しています。そのうえで、本書は複雑な世界の中にあるアルゴリズムの生き生きとした現れを明らかにし、アルゴリズムの美しさを示そうとしています。本書があなたの助けになれば幸いです。
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
# アルゴリズムは至る所にある
|
||||
# アルゴリズムは至るところにある
|
||||
|
||||
「アルゴリズム」という言葉を聞くと、自然に数学を思い浮かべます。しかし、多くのアルゴリズムは複雑な数学を含まず、基本的な論理により多く依存しており、これは私たちの日常生活の至る所で見ることができます。
|
||||
「アルゴリズム」という言葉を聞くと、自然に数学を思い浮かべます。しかし実際には、多くのアルゴリズムは複雑な数学を必要とせず、むしろ基本的な論理に依存しており、その論理は私たちの日常生活のいたるところで見られます。
|
||||
|
||||
アルゴリズムについて正式に議論を始める前に、興味深い事実を共有する価値があります。**あなたは無意識のうちに多くのアルゴリズムを学び、日常生活でそれらを応用することに慣れています**。ここで、この点を証明するためにいくつかの具体的な例を挙げます。
|
||||
アルゴリズムを本格的に議論する前に、ひとつ面白い事実を共有しておきます。**あなたはすでに知らず知らずのうちに多くのアルゴリズムを身につけ、それらを日常生活に応用することに慣れているのです**。以下では、いくつかの具体例を挙げてこれを示します。
|
||||
|
||||
**例1:辞書の引き方**。英語の辞書では、単語がアルファベット順に並んでいます。$r$で始まる単語を探していると仮定すると、通常は以下の方法で行います:
|
||||
**例1:辞書を引く**。辞書では、各漢字に対応するピンインがあり、辞書はピンインのアルファベット順に並んでいます。ピンインの先頭文字が $r$ の字を探すと仮定すると、通常は次の図のような方法で行います。
|
||||
|
||||
1. 辞書を大体半分ぐらいのところで開き、そのページの最初の語彙を確認します。例えば$m$で始まる文字だとしましょう。
|
||||
2. $r$はアルファベットで$m$の後に来るので、前半を無視して、探索空間を後半に絞ります。
|
||||
3. $r$で始まる単語を見つけるまで、ステップ`1.`と`2.`を繰り返します。
|
||||
1. 辞書をおよそ半分のところまで開き、そのページの先頭文字を確認し、先頭文字が $m$ だとします。
|
||||
2. ピンインのアルファベット表では $r$ は $m$ の後にあるため、辞書の前半を除外し、探索範囲を後半に絞ります。
|
||||
3. ピンインの先頭文字が $r$ のページを見つけるまで、手順 `1.` と手順 `2.` を繰り返します。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||

|
||||
|
||||
=== "<3>"
|
||||

|
||||

|
||||
|
||||
=== "<4>"
|
||||

|
||||

|
||||
|
||||
=== "<5>"
|
||||

|
||||

|
||||
|
||||
辞書を引くことは、小学生にとって必須のスキルですが、実際には有名な「二分探索」アルゴリズムです。データ構造の観点から、辞書をソートされた「配列」と考えることができます。アルゴリズムの観点から、辞書で単語を探すために取られる一連の行動は、「二分探索」アルゴリズムと見なすことができます。
|
||||
辞書を引くという小学生の必須スキルは、実は有名な「二分探索」アルゴリズムそのものです。データ構造の観点では、辞書を整列済みの「配列」とみなせます。アルゴリズムの観点では、上記の一連の辞書引きの操作を「二分探索」とみなせます。
|
||||
|
||||
**例2:トランプの整理**。トランプをプレイするとき、手札を昇順に並べる必要があります。以下の過程で示されます。
|
||||
**例2:トランプを整理する**。カードゲームをするとき、毎回手札のトランプを小さい順に並べ替える必要があります。その流れは次の図のとおりです。
|
||||
|
||||
1. トランプを「整列済み」と「未整列」のセクションに分けます。最初は一番左のカードが既に整列していると仮定します。
|
||||
2. 未整列セクションからカードを1枚取り出し、整列済みセクションの正しい位置に挿入します。この後、左端の2枚のカードが整列します。
|
||||
3. すべてのカードが整列するまで、ステップ`2`を繰り返します。
|
||||
1. トランプを「整列済み」と「未整列」の2つの部分に分け、初期状態では一番左の1枚がすでに整列済みだとします。
|
||||
2. 未整列部分から1枚のトランプを取り出し、整列済み部分の正しい位置に挿入します。完了すると、左端の2枚は整列済みになります。
|
||||
3. 手順 `2.` を繰り返し、各ラウンドで未整列部分から1枚を整列済み部分へ挿入し、すべてのトランプが整列済みになるまで続けます。
|
||||
|
||||

|
||||

|
||||
|
||||
上記のトランプを整理する方法は、実質的に「挿入ソート」アルゴリズムであり、小さなデータセットに対して非常に効率的です。多くのプログラミング言語のソート関数には挿入ソートが含まれています。
|
||||
上記のトランプ整理の方法は、本質的には「挿入ソート」アルゴリズムです。これは小規模なデータ集合を処理する際に非常に効率的で、多くのプログラミング言語のソートライブラリ関数にも挿入ソートが使われています。
|
||||
|
||||
**例3:お釣りの計算**。スーパーマーケットで$69$の買い物をしたと仮定します。レジ係に$100$を渡すと、$31$のお釣りを提供する必要があります。この過程は以下の図で明確に理解できます。
|
||||
**例3:お釣りを出す**。スーパーで $69$ 元の商品を購入し、店員に $100$ 元渡したとすると、店員は $31$ 元のお釣りを返す必要があります。店員は自然に次の図のような考え方をします。
|
||||
|
||||
1. 選択肢は$31$以下の価値のある通貨で、$1$、$5$、$10$、$20$が含まれます。
|
||||
2. 選択肢から最大の$20$を取り出し、$31 - 20 = 11$が残ります。
|
||||
3. 残りの選択肢から最大の$10$を取り出し、$11 - 10 = 1$が残ります。
|
||||
4. 残りの選択肢から最大の$1$を取り出し、$1 - 1 = 0$が残ります。
|
||||
5. お釣りの計算が完了し、解答は$20 + 10 + 1 = 31$です。
|
||||
1. 選択肢は $31$ 元より小さい額面の貨幣で、$1$ 元、$5$ 元、$10$ 元、$20$ 元があります。
|
||||
2. 選択肢の中から最大の $20$ 元を取り出すと、残りは $31 - 20 = 11$ 元です。
|
||||
3. 残りの選択肢の中から最大の $10$ 元を取り出すと、残りは $11 - 10 = 1$ 元です。
|
||||
4. 残りの選択肢の中から最大の $1$ 元を取り出すと、残りは $1 - 1 = 0$ 元です。
|
||||
5. お釣りは完了し、内訳は $20 + 10 + 1 = 31$ 元です。
|
||||
|
||||

|
||||

|
||||
|
||||
記述されたステップでは、利用可能な最大の額面を使用して各段階で最良の選択肢を選ぶことで、効果的なお釣り計算戦略につながります。データ構造とアルゴリズムの観点から、このアプローチは「貪欲」アルゴリズムとして知られています。
|
||||
以上の手順では、各ステップでその時点で最善と思われる選択肢を取っています。つまり、できるだけ額面の大きい貨幣を使い、最終的に実行可能なお釣りの方案を得ています。データ構造とアルゴリズムの観点から見ると、この方法は本質的に「貪欲法」です。
|
||||
|
||||
料理の準備から宇宙旅行まで、ほぼすべての問題解決にはアルゴリズムが関わっています。コンピュータの出現により、メモリにデータ構造を格納し、CPUとGPUを呼び出してアルゴリズムを実行するコードを書くことができるようになりました。このようにして、現実世界の問題をコンピュータに移し、より効率的な方法でさまざまな複雑な問題を解決できます。
|
||||
料理を一品作ることから星間航行に至るまで、ほとんどあらゆる問題の解決にアルゴリズムは欠かせません。コンピュータの登場によって、プログラミングを通じてデータ構造をメモリに格納し、さらにコードを書いて CPU や GPU にアルゴリズムを実行させることが可能になりました。こうして、生活の中の問題をコンピュータに移し、より効率的な方法でさまざまな複雑な問題を解決できるのです。
|
||||
|
||||
!!! tip
|
||||
|
||||
データ構造、アルゴリズム、配列、二分探索などの概念についてまだ混乱している場合は、読み続けることをお勧めします。この本は、データ構造とアルゴリズムの理解の領域へと優しく導いてくれるでしょう。
|
||||
データ構造、アルゴリズム、配列、二分探索といった概念がまだ少し曖昧でも、そのまま読み進めてください。本書がデータ構造とアルゴリズムの知識の世界へと案内します。
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# アルゴリズムとの出会い
|
||||
# アルゴリズム入門
|
||||
|
||||

|
||||

|
||||
|
||||
!!! abstract
|
||||
|
||||
優雅な乙女が踊ります。データと絡み合い、アルゴリズムのメロディーに合わせてスカートをなびかせながら。
|
||||
|
||||
彼女があなたをダンスに誘います。彼女のステップに従って、論理と美に満ちたアルゴリズムの世界に入りましょう。
|
||||
一人の少女が軽やかに舞い、データと織り重なり合いながら、スカートの裾にはアルゴリズムの旋律がたなびいています。
|
||||
|
||||
彼女はあなたをこの舞いへと誘います。その足取りに続いて、論理と美しさに満ちたアルゴリズムの世界へ踏み入りましょう。
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
# まとめ
|
||||
|
||||
- アルゴリズムは日常生活にありふれており、思っているほどアクセスしにくく複雑なものではありません。実際、私たちは既に無意識のうちに多くのアルゴリズムを学び、生活の様々な問題を解決するために使用しています。
|
||||
- 辞書で単語を引く原理は二分探索アルゴリズムと一致しています。二分探索アルゴリズムは分割統治という重要なアルゴリズム概念を体現しています。
|
||||
- トランプを整理する過程は挿入ソートアルゴリズムと非常に似ています。挿入ソートアルゴリズムは小さなデータセットのソートに適しています。
|
||||
- 通貨でお釣りを計算するステップは本質的に貪欲アルゴリズムに従っており、各ステップでその時点での最良の選択をします。
|
||||
- アルゴリズムは有限時間内で特定の問題を解決するための段階的な指示のセットですが、データ構造はコンピュータでのデータの組織化と保存方法を定義します。
|
||||
- データ構造とアルゴリズムは密接に関連しています。データ構造はアルゴリズムの基礎であり、アルゴリズムはデータ構造の機能を活用するステージです。
|
||||
- データ構造とアルゴリズムをブロックの組み立てに例えることができます。ブロックはデータを表し、ブロックの形状と接続方法はデータ構造を表し、ブロックを組み立てるステップはアルゴリズムに対応します。
|
||||
### 要点の振り返り
|
||||
|
||||
- アルゴリズムは日常生活の至る所にあり、決して手の届かない難解な知識ではありません。実際、私たちは気づかないうちに多くのアルゴリズムを身につけ、生活のさまざまな問題を解決しています。
|
||||
- 辞書を引く原理は二分探索アルゴリズムと一致しています。二分探索アルゴリズムは分割統治という重要なアルゴリズム思想を体現しています。
|
||||
- トランプを整理する過程は挿入ソートアルゴリズムと非常によく似ています。挿入ソートアルゴリズムは小規模なデータ集合のソートに適しています。
|
||||
- 貨幣の釣り銭を求める手順の本質は貪欲アルゴリズムであり、各ステップでその時点で最善と思われる選択を取ります。
|
||||
- アルゴリズムとは、限られた時間内に特定の問題を解決するための一連の命令または操作手順であり、データ構造とは、コンピュータ内でデータを組織し保存する方法です。
|
||||
- データ構造とアルゴリズムは密接に結びついています。データ構造はアルゴリズムの土台であり、アルゴリズムはデータ構造に生命を吹き込みます。
|
||||
- データ構造とアルゴリズムは積み木の組み立てにたとえることができます。積み木はデータを表し、積み木の形や接続方法などはデータ構造を表し、積み木を組み立てる手順がアルゴリズムに対応します。
|
||||
|
||||
### Q & A
|
||||
|
||||
**Q**:プログラマーとして、日常の仕事でアルゴリズムを手動で実装する必要があることはめったにありません。最も一般的に使用されるアルゴリズムは、既にプログラミング言語とライブラリに組み込まれており、すぐに使用できます。これは、私たちが仕事で遭遇する問題が、カスタムアルゴリズム設計を必要とする複雑さのレベルにまだ達していないことを示唆していますか?
|
||||
**Q**:プログラマーとして、私は日常業務でアルゴリズムを使って問題を解決したことがありません。よく使うアルゴリズムはプログラミング言語にすべてカプセル化されており、そのまま使えばよいです。これは、仕事上の問題がまだアルゴリズムを必要とする段階に達していないことを意味するのでしょうか?
|
||||
|
||||
特定の仕事スキルが武術の「技」のようなものだとすれば、基礎科目は「内功」のようなものです。
|
||||
具体的な仕事のスキルを武術の「型」にたとえるなら、基礎科目はむしろ「内功」に近いものです。
|
||||
|
||||
アルゴリズム(およびその他の基礎科目)を学ぶ意義は、必ずしも仕事でそれらを一から実装することではなく、概念の確固たる理解に基づいて、より専門的な意思決定と問題解決を可能にし、それによって仕事の全体的な質を向上させることだと私は信じています。例えば、すべてのプログラミング言語には組み込みのソート関数があります:
|
||||
私は、アルゴリズム(およびその他の基礎科目)を学ぶ意義は、仕事でそれをゼロから実装することではなく、学んだ知識に基づいて問題解決の際に専門的な反応や判断を下せるようになり、その結果として仕事全体の品質を高めることにあると考えています。簡単な例を挙げると、どのプログラミング言語にもソート関数が組み込まれています。
|
||||
|
||||
- データ構造とアルゴリズムを学んでいない場合、どんなデータが与えられても、このソート関数に渡すだけかもしれません。スムーズに動作し、良いパフォーマンスを示し、問題がないように見えます。
|
||||
- しかし、アルゴリズムを学んだことがあれば、組み込みのソート関数の時間複雑度は通常$O(n \log n)$であることを理解しています。さらに、データが固定桁数の整数(学生IDなど)で構成されている場合、基数ソートのようなより効率的なアプローチを適用でき、時間複雑度をO(nk)に削減できます。ここでkは桁数です。大量のデータを処理する際、節約された時間は重要な価値に変わります — コストの削減、ユーザーエクスペリエンスの向上、システムパフォーマンスの向上。
|
||||
- もしデータ構造とアルゴリズムを学んでいなければ、どんなデータが与えられても、そのソート関数に任せてしまうかもしれません。問題なく動き、性能も悪くなく、一見すると特に問題はありません。
|
||||
- しかしアルゴリズムを学んでいれば、組み込みのソート関数の時間計算量が $O(n \log n)$ であることを知っています。さらに、与えられたデータが固定桁数の整数(例えば学籍番号)であれば、より効率の高い「基数ソート」を使って、時間計算量を $O(nk)$ に下げることができます。ここで $k$ は桁数です。データ量が非常に大きい場合、節約できた実行時間は大きな価値を生みます(コスト削減、体験向上など)。
|
||||
|
||||
エンジニアリングでは、多くの問題を最適に解決することは困難です。ほとんどは「準最適」解決策で対処されます。問題の難しさは、その固有の複雑さだけでなく、それに取り組む人の知識と経験にも依存します。専門知識と経験が深いほど、分析がより徹底的になり、問題をより優雅に解決できます。
|
||||
工学分野では、多くの問題で最適解に到達することは難しく、少なくない問題は「だいたい」解決されているにすぎません。問題の難しさは、一方では問題そのものの性質に依存し、他方ではそれを観測する人の知識の蓄積にも依存します。知識が充実し、経験が豊富であるほど、問題分析はより深くなり、問題はより洗練された形で解決できるようになります。
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
# アルゴリズムとは何か
|
||||
# アルゴリズムとは
|
||||
|
||||
## アルゴリズムの定義
|
||||
|
||||
<u>アルゴリズム</u>は、有限時間内で特定の問題を解決するための一連の指示またはステップです。以下の特徴があります:
|
||||
<u>アルゴリズム(algorithm)</u>とは、限られた時間内に特定の問題を解決するための一連の命令または操作手順であり、次のような特徴を持ちます。
|
||||
|
||||
- 問題が明確に定義されており、入力と出力の明確な定義が含まれています。
|
||||
- アルゴリズムは実行可能で、有限の回数のステップ、時間、メモリ空間内で完了できることを意味します。
|
||||
- 各ステップには明確な意味があります。同じ入力と条件の下で出力は一貫して同じです。
|
||||
- 問題が明確であり、入力と出力の定義がはっきりしています。
|
||||
- 実行可能であり、有限の手順、時間、メモリ空間で完了できます。
|
||||
- 各手順の意味が確定しており、同じ入力と実行条件では常に同じ出力になります。
|
||||
|
||||
## データ構造の定義
|
||||
|
||||
<u>データ構造</u>は、コンピュータ内でデータを組織し保存する方法で、以下の設計目標があります:
|
||||
<u>データ構造(data structure)</u>とは、データを整理して保存する方式であり、データの内容、データ間の関係、データの操作方法を含み、次のような設計目標があります。
|
||||
|
||||
- コンピュータのメモリを節約するために空間占有を最小化する。
|
||||
- データ操作を可能な限り高速にし、データのアクセス、追加、削除、更新などをカバーする。
|
||||
- 効率的なアルゴリズム実行を可能にするために、簡潔なデータ表現と論理情報を提供する。
|
||||
- 使用する空間をできるだけ少なくし、コンピュータのメモリを節約します。
|
||||
- データの操作をできるだけ高速にし、アクセス、追加、削除、更新などを含みます。
|
||||
- 簡潔なデータ表現と論理情報を提供し、アルゴリズムが効率よく動作できるようにします。
|
||||
|
||||
**データ構造の設計はバランスを取る行為であり、しばしばトレードオフが必要です**。一つの側面を改善したい場合、しばしば別の側面で妥協する必要があります。以下は2つの例です:
|
||||
**データ構造の設計はトレードオフに満ちた過程です**。ある面を改善したい場合、別の面で妥協が必要になることがよくあります。以下に 2 つの例を示します。
|
||||
|
||||
- 配列と比較して、連結リストはデータの追加と削除においてより便利ですが、データアクセス速度を犠牲にします。
|
||||
- 連結リストと比較して、グラフはより豊富な論理情報を提供しますが、より多くのメモリ空間が必要です。
|
||||
- 連結リストは配列に比べてデータの追加や削除がしやすい一方で、データアクセス速度を犠牲にしています。
|
||||
- グラフは連結リストに比べてより豊富な論理情報を提供しますが、より大きなメモリ空間を必要とします。
|
||||
|
||||
## データ構造とアルゴリズムの関係
|
||||
|
||||
以下の図に示すように、データ構造とアルゴリズムは高度に関連し、密接に統合されており、具体的には以下の3つの側面があります:
|
||||
以下の図のように、データ構造とアルゴリズムは高度に関連し、密接に結び付いており、具体的には次の 3 つの点に表れます。
|
||||
|
||||
- データ構造はアルゴリズムの基礎です。構造化されたデータ保存とアルゴリズムのためのデータ操作方法を提供します。
|
||||
- アルゴリズムはデータ構造に活力を注入します。データ構造だけではデータ情報を保存するだけです。アルゴリズムの応用によって、特定の問題を解決できます。
|
||||
- アルゴリズムは異なるデータ構造に基づいて実装できることが多いですが、実行効率は大きく異なることがあります。適切なデータ構造を選択することが鍵です。
|
||||
- データ構造はアルゴリズムの土台です。データ構造はアルゴリズムに対して、構造化して格納されたデータと、そのデータを操作する方法を提供します。
|
||||
- アルゴリズムはデータ構造に命を吹き込みます。データ構造そのものはデータ情報を保存するだけであり、アルゴリズムと組み合わせて初めて特定の問題を解決できます。
|
||||
- アルゴリズムは通常、異なるデータ構造に基づいて実装できますが、実行効率が大きく異なる場合があり、適切なデータ構造を選ぶことが重要です。
|
||||
|
||||

|
||||
|
||||
データ構造とアルゴリズムは、以下の図に示すように、ブロックのセットに例えることができます。ブロックセットには多数のピースが含まれ、詳細な組み立て説明書が付いています。これらの説明書に段階的に従うことで、複雑なブロックモデルを構築できます。
|
||||
データ構造とアルゴリズムは、以下の図に示す組み立てブロックのようなものです。1 セットのブロックには多くの部品が含まれるだけでなく、詳しい組み立て説明書も付いています。説明書に従って一歩ずつ操作すれば、精巧なブロック模型を組み立てられます。
|
||||
|
||||

|
||||

|
||||
|
||||
両者の詳細な対応関係は以下の表に示されています。
|
||||
両者の詳細な対応関係を次の表に示します。
|
||||
|
||||
<p align="center"> 表 <id> データ構造とアルゴリズムをブロックと比較 </p>
|
||||
<p align="center"> 表 <id> データ構造とアルゴリズムを組み立てブロックにたとえる </p>
|
||||
|
||||
| データ構造とアルゴリズム | ブロック |
|
||||
| ------------------------ | ----------------------------------------------------- |
|
||||
| 入力データ | 未組み立てのブロック |
|
||||
| データ構造 | ブロックの組織、形状、サイズ、接続などを含む |
|
||||
| アルゴリズム | ブロックを望ましい形状に組み立てる一連のステップ |
|
||||
| 出力データ | 完成したブロックモデル |
|
||||
| データ構造とアルゴリズム | 組み立てブロック |
|
||||
| -------------- | ---------------------------------------- |
|
||||
| 入力データ | まだ組み立てていないブロック |
|
||||
| データ構造 | ブロックの構成形式。形状、大きさ、接続方法などを含む |
|
||||
| アルゴリズム | ブロックを目標の形に組み上げる一連の操作手順 |
|
||||
| 出力データ | ブロック模型 |
|
||||
|
||||
データ構造とアルゴリズムはプログラミング言語から独立していることは注目に値します。この理由により、この本は複数のプログラミング言語での実装を提供できます。
|
||||
特筆すべき点として、データ構造とアルゴリズムはプログラミング言語から独立しています。だからこそ、本書では複数のプログラミング言語に基づく実装を提供できます。
|
||||
|
||||
!!! tip "慣習的な略語"
|
||||
!!! tip "慣習的な略称"
|
||||
|
||||
実生活の議論では、「データ構造とアルゴリズム」を単純に「アルゴリズム」と呼ぶことがよくあります。例えば、よく知られたLeetCodeアルゴリズム問題は、実際にはデータ構造とアルゴリズムの両方の知識をテストしています。
|
||||
実際の議論では、私たちは通常「データ構造とアルゴリズム」を略して「アルゴリズム」と呼びます。たとえば広く知られている LeetCode のアルゴリズム問題は、実際にはデータ構造とアルゴリズムの両方の知識を同時に問うています。
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
comments: true
|
||||
icon: material/book-open-page-variant
|
||||
status: new
|
||||
---
|
||||
|
||||
# 紙の書籍
|
||||
|
||||
長い時間をかけて磨き上げた『Hello アルゴリズム』の紙の書籍が、ついに発売されました!今の気持ちは、次の一節で表せます:
|
||||
|
||||
<p align="center">風を追い月を追って立ち止まるな、草原の果てには春の山がある。</p>
|
||||
|
||||
{ class="animation-figure" }
|
||||
|
||||
以下の動画では紙の書籍を紹介しており、私の考えもいくつか含まれています:
|
||||
|
||||
- データ構造とアルゴリズムを学ぶ重要性。
|
||||
- なぜ紙の書籍で Python を選んだのか。
|
||||
- 知識共有に対する理解。
|
||||
|
||||
> 新人 UP 主ですので、ぜひ応援と高評価・チャンネル登録をお願いします~ありがとうございます!
|
||||
|
||||
<div class="video-container">
|
||||
<iframe src="//player.bilibili.com/player.html?aid=1051597767&bvid=BV1QH4y157uC&cid=1462564112&p=1&autoplay=0" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"> </iframe>
|
||||
</div>
|
||||
|
||||
紙の書籍のスナップショット:
|
||||
|
||||
{ class="animation-figure" }
|
||||
|
||||
{ class="animation-figure" }
|
||||
|
||||
## 長所と短所
|
||||
|
||||
紙の書籍ならではの魅力を、簡単にまとめると次のとおりです:
|
||||
|
||||
- フルカラー印刷を採用し、本書の「アニメーション図解」の強みをそのまま活かせます。
|
||||
- 紙の素材にもこだわり、色彩を高い精度で再現しつつ、紙の書籍ならではの質感も残しています。
|
||||
- 紙の書籍版は Web 版よりも書式が整っており、たとえば図中の数式には斜体を用いています。
|
||||
- 価格を上げずに、マインドマップの折り込みページやしおりも付属します。
|
||||
- 紙の書籍、Web 版、PDF 版で内容は同期しており、自由に切り替えて読めます。
|
||||
|
||||
!!! tip
|
||||
|
||||
紙の書籍と Web 版を同期させるのは難しいため、細かな違いが生じる場合があります。ご了承ください!
|
||||
|
||||
もちろん、購入前に検討しておくべき点もいくつかあります:
|
||||
|
||||
- Python 言語を使用しているため、あなたの主言語と合わない可能性があります(Python は疑似コードと捉え、考え方の理解を重視してください)。
|
||||
- フルカラー印刷は図解やコードの読みやすさを大きく高める一方で、白黒印刷より価格はやや高くなります。
|
||||
|
||||
!!! tip
|
||||
|
||||
「印刷品質」と「価格」は、アルゴリズムにおける「時間効率」と「空間効率」のようなもので、両立は容易ではありません。そして私は、「印刷品質」は「時間効率」に当たるため、より重視すべきだと考えています。
|
||||
|
||||
## 購入リンク
|
||||
|
||||
紙の書籍に興味があれば、ぜひ一冊ご検討ください。新刊の 5 割引を用意していただきましたので、[こちらのリンク](https://3.cn/1X-qmTD3)をご覧いただくか、以下の QR コードをスキャンしてください:
|
||||
|
||||
{ class="animation-figure" }
|
||||
|
||||
## あとがき
|
||||
|
||||
当初、私は紙の書籍出版に必要な作業量を甘く見ていて、オープンソースプロジェクトをきちんと保守していれば、紙の書籍版も何らかの自動化手段で生成できると思っていました。実際には、紙の書籍の制作フローとオープンソースプロジェクトの更新の仕組みには大きな違いがあり、その間をつなぐには多くの追加作業が必要でした。
|
||||
|
||||
一冊の本の初稿と出版基準を満たす完成稿との間には、なお大きな隔たりがあります。出版社(企画、編集、デザイン、マーケティングなど)と著者が力を合わせ、長い時間をかけて磨き上げていく必要があります。ここで、図霊の企画編集者である王軍花さん、そして人民郵電出版社と図霊コミュニティで本書の出版工程に携わってくださったすべての皆さまに感謝いたします!
|
||||
|
||||
この本があなたの助けになれば幸いです!
|
||||
@@ -1,52 +1,54 @@
|
||||
# この本について
|
||||
# 本書について
|
||||
|
||||
このオープンソースプロジェクトは、データ構造とアルゴリズムに関する無料で初心者にやさしいクラッシュコースの作成を目指しています。
|
||||
本プロジェクトは、オープンソースで無料、かつ初心者にやさしいデータ構造とアルゴリズムの入門書を作ることを目的としています。
|
||||
|
||||
- アニメーション付きの図解、理解しやすい内容、滑らかな学習曲線により、初心者がデータ構造とアルゴリズムの「知識マップ」を探索するのに役立ちます。
|
||||
- ワンクリックでコードを実行できるため、読者のプログラミングスキルの向上と、アルゴリズムの動作原理およびデータ構造の基礎実装の理解に役立ちます。
|
||||
- 教えることによる学習を促進し、質問や洞察の共有を自由に行ってください。議論を通じて一緒に成長しましょう。
|
||||
- 全編でアニメーション付きの図解を採用し、内容は明快で理解しやすく、学習曲線もなだらかで、初心者がデータ構造とアルゴリズムの知識地図を探求できるよう導きます。
|
||||
- ソースコードはワンクリックで実行でき、読者が演習を通じてプログラミング能力を高め、アルゴリズムの動作原理とデータ構造の内部実装を理解する助けとなります。
|
||||
- 読者どうしの助け合いによる学習を推奨しており、コメント欄で質問や見解を共有し、対話と議論を通じてともに成長していくことを歓迎します。
|
||||
|
||||
## 対象読者
|
||||
|
||||
もしあなたがアルゴリズムに触れたばかりで経験が限られている場合、またはアルゴリズムである程度の経験を積んでいても、データ構造とアルゴリズムについて曖昧な理解しかなく、常に「分かった」と「うーん」の間を行き来している場合、この本はあなたのためのものです!
|
||||
もしあなたがアルゴリズム初心者で、これまでアルゴリズムに触れたことがない、あるいはすでに多少の問題演習の経験はあるものの、データ構造とアルゴリズムについてはまだ曖昧な理解にとどまり、できるかできないかの間を行き来しているなら、本書はまさにあなたのために作られています!
|
||||
|
||||
すでにある程度の問題解決経験を積んでおり、ほとんどのタイプの問題に精通している場合、この本はアルゴリズムの知識体系を復習し整理するのに役立ちます。リポジトリのソースコードは「問題解決ツールキット」や「アルゴリズムチートシート」として使用できます。
|
||||
もしすでに一定量の問題演習を積み、ほとんどの問題パターンに慣れているなら、本書はアルゴリズム知識体系の復習と整理に役立ちます。リポジトリのソースコードは「問題演習ツール集」や「アルゴリズム辞典」として活用できます。
|
||||
|
||||
もしあなたがアルゴリズムの専門家であれば、貴重な提案をいただくか、[参加して協力](https://www.hello-algo.com/chapter_appendix/contribution/)していただければと思います。
|
||||
もしあなたがアルゴリズムの「達人」なら、貴重なご提案をいただけることを楽しみにしています。あるいは[一緒に執筆に参加](https://www.hello-algo.com/chapter_appendix/contribution/)してください。
|
||||
|
||||
!!! success "前提条件"
|
||||
|
||||
少なくとも一つのプログラミング言語で簡単なコードを書いて読むことができる必要があります。
|
||||
少なくともいずれか一つの言語でのプログラミング基礎があり、簡単なコードを読んだり書いたりできる必要があります。
|
||||
|
||||
## 内容構成
|
||||
|
||||
本書の主な内容を下図に示します。
|
||||
本書の主な内容は以下の図のとおりです。
|
||||
|
||||
- **計算量解析**: データ構造とアルゴリズムを評価する側面と方法を探求します。時間計算量と空間計算量を導出する方法、および一般的なタイプと例を扱います。
|
||||
- **データ構造**: 基本的なデータ型、分類方法、定義、長所と短所、一般的な操作、タイプ、応用、および配列、連結リスト、スタック、キュー、ハッシュテーブル、木、ヒープ、グラフなどのデータ構造の実装方法に焦点を当てます。
|
||||
- **アルゴリズム**: アルゴリズムを定義し、その長所と短所、効率性、応用シナリオ、問題解決ステップについて議論し、検索、ソート、分割統治、バックトラッキング、動的プログラミング、貪欲アルゴリズムなど、さまざまなアルゴリズムのサンプル問題を含みます。
|
||||
- **計算量解析**:データ構造とアルゴリズムを評価する観点と方法。時間計算量と空間計算量の求め方、代表的な種類、例など。
|
||||
- **データ構造**:基本データ型とデータ構造の分類方法。配列、連結リスト、スタック、キュー、ハッシュテーブル、木、ヒープ、グラフなどのデータ構造の定義、長所と短所、基本操作、代表的な種類、典型的な応用、実装方法など。
|
||||
- **アルゴリズム**:探索、ソート、分割統治、バックトラッキング、動的計画法、貪欲法などのアルゴリズムの定義、長所と短所、効率、適用場面、問題を解く手順、例題など。
|
||||
|
||||

|
||||
|
||||
## 謝辞
|
||||
|
||||
本書は、オープンソースコミュニティの多数の貢献者による共同の努力のもと、継続的に改善されています。時間と労力を注いで執筆に携わってくださったすべての方々に感謝します。貢献者は以下のとおりです(GitHub により自動生成された順序):krahets、coderonion、Gonglja、nuomi1、Reanon、justin-tse、hpstory、danielsss、curtishd、night-cruise、S-N-O-R-L-A-X、rongyi、msk397、gvenusleo、khoaxuantu、rivertwilight、K3v123、gyt95、zhuoqinyue、yuelinxin、Zuoxun、mingXta、Phoenix0415、FangYuan33、GN-Yu、longsizhuo、IsChristina、xBLACKICEx、guowei-gong、Cathay-Chen、pengchzn、QiLOL、magentaqin、hello-ikun、JoseHung、qualifier1024、thomasq0、sunshinesDL、L-Super、Guanngxu、Transmigration-zhou、WSL0809、Slone123c、lhxsm、yuan0221、what-is-me、Shyam-Chen、theNefelibatas、longranger2、codeberg-user、xiongsp、JeffersonHuang、prinpal、seven1240、Wonderdch、malone6、xiaomiusa87、gaofer、bluebean-cloud、a16su、SamJin98、hongyun-robot、nanlei、XiaChuerwu、yd-j、iron-irax、mgisr、steventimes、junminhong、heshuyue、danny900714、MolDuM、Nigh、Dr-XYZ、XC-Zero、reeswell、PXG-XPG、NI-SW、Horbin-Magician、Enlightenus、YangXuanyi、beatrix-chan、DullSword、xjr7670、jiaxianhua、qq909244296、iStig、boloboloda、hts0000、gledfish、wenjianmin、keshida、kilikilikid、lclc6、lwbaptx、linyejoe2、liuxjerry、llql1211、fbigm、echo1937、szu17dmy、dshlstarr、Yucao-cy、coderlef、czruby、bongbongbakudan、beintentional、ZongYangL、ZhongYuuu、ZhongGuanbin、hezhizhen、linzeyan、ZJKung、luluxia、xb534、ztkuaikuai、yw-1021、ElaBosak233、baagod、zhouLion、yishangzhang、yi427、yanedie、yabo083、weibk、wangwang105、th1nk3r-ing、tao363、4yDX3906、syd168、sslmj2020、smilelsb、siqyka、selear、sdshaoda、Xi-Row、popozhu、nuquist19、noobcodemaker、XiaoK29、chadyi、lyl625760、lucaswangdev、0130w、shanghai-Jerry、EJackYang、Javesun99、eltociear、lipusheng、KNChiu、BlindTerran、ShiMaRing、lovelock、FreddieLi、FloranceYeh、fanchenggang、gltianwen、goerll、nedchu、curly210102、CuB3y0nd、KraHsu、CarrotDLaw、youshaoXG、bubble9um、Asashishi、Asa0oo0o0o、fanenr、eagleanurag、akshiterate、52coder、foursevenlove、KorsChen、GaochaoZhu、hopkings2008、yang-le、realwujing、Evilrabbit520、Umer-Jahangir、Turing-1024-Lee、Suremotoo、paoxiaomooo、Chieko-Seren、Allen-Scai、ymmmas、Risuntsy、Richard-Zhang1019、RafaelCaso、qingpeng9802、primexiao、Urbaner3、zhongfq、nidhoggfgg、MwumLi、CreatorMetaSky、martinx、ZnYang2018、hugtyftg、logan-qiu、psychelzh、Keynman、KeiichiKasai、KawaiiAsh。
|
||||
本書は、オープンソースコミュニティの多くの貢献者による共同の努力のもとで、継続的に改善されています。時間と労力を注いでくださったすべての執筆者の皆さんに感謝します。お名前は次のとおりです(GitHub により自動生成された順序):krahets、coderonion、Gonglja、nuomi1、Reanon、justin-tse、hpstory、danielsss、curtishd、night-cruise、S-N-O-R-L-A-X、rongyi、msk397、gvenusleo、khoaxuantu、rivertwilight、K3v123、gyt95、zhuoqinyue、yuelinxin、Zuoxun、mingXta、Phoenix0415、FangYuan33、GN-Yu、longsizhuo、IsChristina、xBLACKICEx、guowei-gong、Cathay-Chen、pengchzn、QiLOL、magentaqin、hello-ikun、JoseHung、qualifier1024、thomasq0、sunshinesDL、L-Super、Guanngxu、Transmigration-zhou、WSL0809、Slone123c、lhxsm、yuan0221、what-is-me、Shyam-Chen、theNefelibatas、longranger2、codeberg-user、xiongsp、JeffersonHuang、prinpal、seven1240、Wonderdch、malone6、xiaomiusa87、gaofer、bluebean-cloud、a16su、SamJin98、hongyun-robot、nanlei、XiaChuerwu、yd-j、iron-irax、mgisr、steventimes、junminhong、heshuyue、danny900714、MolDuM、Nigh、Dr-XYZ、XC-Zero、reeswell、PXG-XPG、NI-SW、Horbin-Magician、Enlightenus、YangXuanyi、beatrix-chan、DullSword、xjr7670、jiaxianhua、qq909244296、iStig、boloboloda、hts0000、gledfish、wenjianmin、keshida、kilikilikid、lclc6、lwbaptx、linyejoe2、liuxjerry、llql1211、fbigm、echo1937、szu17dmy、dshlstarr、Yucao-cy、coderlef、czruby、bongbongbakudan、beintentional、ZongYangL、ZhongYuuu、ZhongGuanbin、hezhizhen、linzeyan、ZJKung、luluxia、xb534、ztkuaikuai、yw-1021、ElaBosak233、baagod、zhouLion、yishangzhang、yi427、yanedie、yabo083、weibk、wangwang105、th1nk3r-ing、tao363、4yDX3906、syd168、sslmj2020、smilelsb、siqyka、selear、sdshaoda、Xi-Row、popozhu、nuquist19、noobcodemaker、XiaoK29、chadyi、lyl625760、lucaswangdev、0130w、shanghai-Jerry、EJackYang、Javesun99、eltociear、lipusheng、KNChiu、BlindTerran、ShiMaRing、lovelock、FreddieLi、FloranceYeh、fanchenggang、gltianwen、goerll、nedchu、curly210102、CuB3y0nd、KraHsu、CarrotDLaw、youshaoXG、bubble9um、Asashishi、Asa0oo0o0o、fanenr、eagleanurag、akshiterate、52coder、foursevenlove、KorsChen、GaochaoZhu、hopkings2008、yang-le、realwujing、Evilrabbit520、Umer-Jahangir、Turing-1024-Lee、Suremotoo、paoxiaomooo、Chieko-Seren、Allen-Scai、ymmmas、Risuntsy、Richard-Zhang1019、RafaelCaso、qingpeng9802、primexiao、Urbaner3、zhongfq、nidhoggfgg、MwumLi、CreatorMetaSky、martinx、ZnYang2018、hugtyftg、logan-qiu、psychelzh、Keynman、KeiichiKasai と KawaiiAsh。
|
||||
|
||||
この本のコードレビュー作業は、coderonion, Gonglja, gvenusleo, hpstory, justin‐tse, khoaxuantu, krahets, night-cruise, nuomi1, Reanon and rongyi(アルファベット順)によって完了されました。彼らの時間と努力に感謝し、様々な言語でのコードの標準化と統一性を確保してくださいました。
|
||||
本書のコードレビューは coderonion、curtishd、Gonglja、gvenusleo、hpstory、justin-tse、khoaxuantu、krahets、night-cruise、nuomi1、Reanon と rongyi によって行われました(アルファベット順)。彼らが費やしてくれた時間と労力に感謝します。各言語のコードの規範性と統一性が保たれているのは、まさに彼らのおかげです。
|
||||
|
||||
本書の繁体字中国語版は Shyam-Chen および Dr-XYZ によってレビューされ、英語版は yuelinxin、K3v123、QiLOL、Phoenix0415、SamJin98、yanedie、RafaelCaso、pengchzn、thomasq0、magentaqin によってレビューされ、日本語版は eltociear によってレビューされました。彼らの継続的な貢献のおかげで、本書はより幅広い読者層に提供することができています。ここに深く感謝いたします。
|
||||
本書の繁体字中国語版は Shyam-Chen と Dr-XYZ がレビューし、英語版は yuelinxin、K3v123、QiLOL、Phoenix0415、SamJin98、yanedie、RafaelCaso、pengchzn、thomasq0 と magentaqin がレビューし、日本語版は eltociear がレビューしました。彼らの継続的な貢献があってこそ、本書はより幅広い読者に届けられています。感謝いたします。
|
||||
|
||||
この本の制作過程において、多くの方々から貴重な支援をいただきました。これらに限定されませんが:
|
||||
本書の ePub 電子書籍生成ツールは zhongfq によって開発されました。彼の貢献に感謝します。読者により自由な読書方法を提供してくれました。
|
||||
|
||||
- 会社でのメンター、李熙博士に感謝します。ある会話で「早く行動しろ」と励ましていただき、この本を書く決意を固めることができました。
|
||||
- ガールフレンドのBubbleに感謝します。この本の最初の読者として、アルゴリズム初心者の視点から多くの貴重な提案をいただき、この本を初心者により適したものにしてくださいました。
|
||||
- Tengbao、Qibao、Feibaoに感謝します。この本のクリエイティブな名前を考えてくださり、みんなが初めて「Hello World!」を書いた時の素晴らしい思い出を呼び起こしてくれました。
|
||||
- Xiaoquanに感謝します。知的財産に関する専門的な支援を提供してくださり、このオープンソース本の開発において重要な役割を果たしてくださいました。
|
||||
- Sutongに感謝します。この本の美しいカバーとロゴをデザインしてくださり、私の要求で何度も修正を辛抱強く行ってくださいました。
|
||||
- @squidfunk に感謝します。執筆と組版の提案、および彼が開発したオープンソースドキュメントテーマ [Material-for-MkDocs](https://github.com/squidfunk/mkdocs-material/tree/master) を提供してくださいました。
|
||||
本書の執筆過程で、私は多くの方々の助けを得ました。
|
||||
|
||||
執筆の過程で、データ構造とアルゴリズムに関する多数の教科書や記事を深く研究しました。これらの作品は模範的なモデルとして機能し、この本の内容の正確性と品質を確保してくださいました。先人の方々の貴重な貢献に感謝いたします!
|
||||
- 会社での私の指導教員である李汐博士に感謝します。ある対話の中で「すぐに行動しよう」と励ましてくださり、この本を書く決意を固めることができました;
|
||||
- 私の恋人であり、本書の最初の読者でもある泡泡に感謝します。アルゴリズム初心者の視点から多くの貴重な提案をしてくれたおかげで、本書はより初心者に適したものになりました;
|
||||
- 腾宝、琦宝、飞宝が本書に創造性あふれる名前を付けてくれたことに感謝します。みんなが最初のコード行「Hello World!」を書いた美しい記憶を呼び起こしてくれました;
|
||||
- 校铨が知的財産の面で専門的な支援をしてくれたことに感謝します。これは本オープンソース書籍の改善に重要な役割を果たしました;
|
||||
- 苏潼が本書の美しい表紙と logo をデザインし、私の完璧主義につき合って何度も辛抱強く修正してくれたことに感謝します;
|
||||
- @squidfunk が組版に関する助言を提供してくれたこと、そして彼が開発したオープンソースのドキュメントテーマ [Material-for-MkDocs](https://github.com/squidfunk/mkdocs-material/tree/master) に感謝します。
|
||||
|
||||
この本は、理論と実践を組み合わせた学習を提唱しており、この点で ["Dive into Deep Learning"](https://github.com/d2l-ai/d2l-en) からインスピレーションを受けています。この優れた本をすべての読者に強くお勧めします。
|
||||
執筆の過程で、私はデータ構造とアルゴリズムに関する多くの教材や記事を読みました。これらの作品は本書に優れた手本を与え、本書の内容の正確性と品質を支えてくれました。ここに、すべての先生方と先人たちの卓越した貢献に感謝します!
|
||||
|
||||
**継続的な支援と励ましにより、この興味深い仕事をすることを可能にしてくださった両親に心から感謝いたします**。
|
||||
本書は手と頭を同時に使う学習方法を提唱しています。この点で私は[『手を動かして学ぶ深層学習』](https://github.com/d2l-ai/d2l-zh)から大きな啓発を受けました。ここで読者の皆さんにこの優れた著作を強くお勧めします。
|
||||
|
||||
**心から両親に感謝します。いつも支え励ましてくれたからこそ、私はこの興味深いことに取り組む機会を得ることができました**。
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# 序文
|
||||
# はじめに
|
||||
|
||||

|
||||

|
||||
|
||||
!!! abstract
|
||||
|
||||
アルゴリズムは美しい交響曲のようで、コードの一行一行がリズムのように流れています。
|
||||
|
||||
この本があなたの心の中で静かに響き、独特で深い旋律を残すことを願っています。
|
||||
アルゴリズムは美しい交響曲のようであり、コードの一行一行が旋律のように流れていきます。
|
||||
|
||||
この本があなたの心の中でそっと響き、独自で深い旋律を残してくれることを願っています。
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
# 読み方
|
||||
# 本書の使い方
|
||||
|
||||
!!! tip
|
||||
|
||||
最良の読書体験のために、このセクションを通読することをお勧めします。
|
||||
最適な読書体験を得るために、本節の内容を一通り読むことをおすすめします。
|
||||
|
||||
## 記述規則
|
||||
## 文章スタイルの約束
|
||||
|
||||
- タイトルの後に「*」が付いた章は任意であり、比較的難易度の高い内容が含まれています。時間に制約がある場合は、これらをスキップすることをお勧めします。
|
||||
- 技術用語は太字(印刷版およびPDF版)または下線(Web版)で表示されます。例えば、<u>配列</u>などです。技術文書をより良く理解するために、これらに慣れることをお勧めします。
|
||||
- **太字のテキスト**は重要な内容や要約文を示し、特別な注意を払う価値があります。
|
||||
- 特定の意味を持つ単語や句は「引用符」で示され、曖昧さを避けます。
|
||||
- プログラミング言語間で一致しない用語については、この本はPythonに従います。例えば、`null`を意味するために`None`を使用します。
|
||||
- この本は、よりコンパクトなコンテンツレイアウトと引き換えに、プログラミング言語のコメント規約を部分的に無視しています。コメントは主に3つのタイプで構成されています:タイトルコメント、内容コメント、複数行コメント。
|
||||
- 見出しの後に `*` が付いているのは選読章で、内容は比較的難しめです。時間が限られている場合は、先に読み飛ばしてもかまいません。
|
||||
- 専門用語は太字(紙書籍版と PDF 版)または下線付き(Web 版)で示します。たとえば<u>配列(array)</u>のようなものです。文献を読む際に役立つため、覚えておくことをおすすめします。
|
||||
- 重要な内容やまとめの文は **太字** で示します。これらの文章には特に注意してください。
|
||||
- 特定の意味を持つ語句には“引用符”を付け、曖昧さを避けます。
|
||||
- プログラミング言語ごとに用語が一致しない場合、本書では Python を基準とします。たとえば、“空”を表すのに `None` を使います。
|
||||
- 本書では、よりコンパクトなレイアウトのために、言語ごとのコメント規約を一部省略しています。コメントは主に3種類あります。タイトルコメント、内容コメント、複数行コメントです。
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title=""
|
||||
"""関数、クラス、テストサンプルなどをラベル付けするためのヘッダーコメント"""
|
||||
|
||||
# 詳細を説明するためのコメント
|
||||
|
||||
"""タイトルコメント。関数、クラス、テストケースなどを示すために使います"""
|
||||
|
||||
# 内容コメント。コードを詳しく説明するために使います
|
||||
|
||||
"""
|
||||
複数行
|
||||
コメント
|
||||
@@ -29,10 +29,10 @@
|
||||
=== "C++"
|
||||
|
||||
```cpp title=""
|
||||
/* 関数、クラス、テストサンプルなどをラベル付けするためのヘッダーコメント */
|
||||
|
||||
// 詳細を説明するためのコメント
|
||||
|
||||
/* タイトルコメント。関数、クラス、テストケースなどを示すために使います */
|
||||
|
||||
// 内容コメント。コードを詳しく説明するために使います
|
||||
|
||||
/**
|
||||
* 複数行
|
||||
* コメント
|
||||
@@ -42,10 +42,10 @@
|
||||
=== "Java"
|
||||
|
||||
```java title=""
|
||||
/* 関数、クラス、テストサンプルなどをラベル付けするためのヘッダーコメント */
|
||||
|
||||
// 詳細を説明するためのコメント
|
||||
|
||||
/* タイトルコメント。関数、クラス、テストケースなどを示すために使います */
|
||||
|
||||
// 内容コメント。コードを詳しく説明するために使います
|
||||
|
||||
/**
|
||||
* 複数行
|
||||
* コメント
|
||||
@@ -55,10 +55,10 @@
|
||||
=== "C#"
|
||||
|
||||
```csharp title=""
|
||||
/* 関数、クラス、テストサンプルなどをラベル付けするためのヘッダーコメント */
|
||||
|
||||
// 詳細を説明するためのコメント
|
||||
|
||||
/* タイトルコメント。関数、クラス、テストケースなどを示すために使います */
|
||||
|
||||
// 内容コメント。コードを詳しく説明するために使います
|
||||
|
||||
/**
|
||||
* 複数行
|
||||
* コメント
|
||||
@@ -68,10 +68,10 @@
|
||||
=== "Go"
|
||||
|
||||
```go title=""
|
||||
/* 関数、クラス、テストサンプルなどをラベル付けするためのヘッダーコメント */
|
||||
|
||||
// 詳細を説明するためのコメント
|
||||
|
||||
/* タイトルコメント。関数、クラス、テストケースなどを示すために使います */
|
||||
|
||||
// 内容コメント。コードを詳しく説明するために使います
|
||||
|
||||
/**
|
||||
* 複数行
|
||||
* コメント
|
||||
@@ -81,10 +81,10 @@
|
||||
=== "Swift"
|
||||
|
||||
```swift title=""
|
||||
/* 関数、クラス、テストサンプルなどをラベル付けするためのヘッダーコメント */
|
||||
|
||||
// 詳細を説明するためのコメント
|
||||
|
||||
/* タイトルコメント。関数、クラス、テストケースなどを示すために使います */
|
||||
|
||||
// 内容コメント。コードを詳しく説明するために使います
|
||||
|
||||
/**
|
||||
* 複数行
|
||||
* コメント
|
||||
@@ -94,10 +94,10 @@
|
||||
=== "JS"
|
||||
|
||||
```javascript title=""
|
||||
/* 関数、クラス、テストサンプルなどをラベル付けするためのヘッダーコメント */
|
||||
|
||||
// 詳細を説明するためのコメント
|
||||
|
||||
/* タイトルコメント。関数、クラス、テストケースなどを示すために使います */
|
||||
|
||||
// 内容コメント。コードを詳しく説明するために使います
|
||||
|
||||
/**
|
||||
* 複数行
|
||||
* コメント
|
||||
@@ -107,10 +107,10 @@
|
||||
=== "TS"
|
||||
|
||||
```typescript title=""
|
||||
/* 関数、クラス、テストサンプルなどをラベル付けするためのヘッダーコメント */
|
||||
|
||||
// 詳細を説明するためのコメント
|
||||
|
||||
/* タイトルコメント。関数、クラス、テストケースなどを示すために使います */
|
||||
|
||||
// 内容コメント。コードを詳しく説明するために使います
|
||||
|
||||
/**
|
||||
* 複数行
|
||||
* コメント
|
||||
@@ -120,10 +120,10 @@
|
||||
=== "Dart"
|
||||
|
||||
```dart title=""
|
||||
/* 関数、クラス、テストサンプルなどをラベル付けするためのヘッダーコメント */
|
||||
|
||||
// 詳細を説明するためのコメント
|
||||
|
||||
/* タイトルコメント。関数、クラス、テストケースなどを示すために使います */
|
||||
|
||||
// 内容コメント。コードを詳しく説明するために使います
|
||||
|
||||
/**
|
||||
* 複数行
|
||||
* コメント
|
||||
@@ -133,10 +133,10 @@
|
||||
=== "Rust"
|
||||
|
||||
```rust title=""
|
||||
/* 関数、クラス、テストサンプルなどをラベル付けするためのヘッダーコメント */
|
||||
|
||||
// 詳細を説明するためのコメント
|
||||
/* タイトルコメント。関数、クラス、テストケースなどを示すために使います */
|
||||
|
||||
// 内容コメント。コードを詳しく説明するために使います
|
||||
|
||||
/**
|
||||
* 複数行
|
||||
* コメント
|
||||
@@ -146,10 +146,10 @@
|
||||
=== "C"
|
||||
|
||||
```c title=""
|
||||
/* 関数、クラス、テストサンプルなどをラベル付けするためのヘッダーコメント */
|
||||
|
||||
// 詳細を説明するためのコメント
|
||||
|
||||
/* タイトルコメント。関数、クラス、テストケースなどを示すために使います */
|
||||
|
||||
// 内容コメント。コードを詳しく説明するために使います
|
||||
|
||||
/**
|
||||
* 複数行
|
||||
* コメント
|
||||
@@ -159,70 +159,83 @@
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title=""
|
||||
/* 関数、クラス、テストサンプルなどをラベル付けするためのヘッダーコメント */
|
||||
|
||||
// 詳細を説明するためのコメント
|
||||
|
||||
/* タイトルコメント。関数、クラス、テストケースなどを示すために使います */
|
||||
|
||||
// 内容コメント。コードを詳しく説明するために使います
|
||||
|
||||
/**
|
||||
* 複数行
|
||||
* コメント
|
||||
*/
|
||||
```
|
||||
|
||||
## アニメーション図解による効率的学習
|
||||
=== "Ruby"
|
||||
|
||||
テキストと比較して、動画や画像は情報密度が高く、より構造化されており、理解しやすくなっています。この本では、**重要で難しい概念は主にアニメーションと図解を通じて提示され**、テキストは説明と補足として機能します。
|
||||
```ruby title=""
|
||||
### タイトルコメント。関数、クラス、テストケースなどを示すために使います ###
|
||||
|
||||
下図に示すようなアニメーションや図解のある内容に遭遇した場合、**図の理解を優先し、テキストを補足として**、両方を統合して包括的な理解を得てください。
|
||||
# 内容コメント。コードを詳しく説明するために使います
|
||||
|
||||
# 複数行
|
||||
# コメント
|
||||
```
|
||||
|
||||
## アニメーション図解で効率よく学ぶ
|
||||
|
||||
文字と比べて、動画や画像は情報密度と構造化の度合いが高く、理解しやすいものです。本書では、**重要かつ難解な知識は主にアニメーションによる図解で示し**、文章は説明と補足を担います。
|
||||
|
||||
本書を読んでいて、ある内容に以下の図のようなアニメーション図解がある場合は、**図を主、文章を従として**、両方を合わせて理解してください。
|
||||
|
||||

|
||||
|
||||
## コーディング実践による理解の深化
|
||||
## コード実践で理解を深める
|
||||
|
||||
この本のソースコードは[GitHubリポジトリ](https://github.com/krahets/hello-algo)でホストされています。下図に示すように、**ソースコードにはテスト例が付属しており、ワンクリックで実行できます**。
|
||||
本書のサンプルコードは [GitHub リポジトリ](https://github.com/krahets/hello-algo) で管理されています。以下の図のように、**ソースコードにはテストケースが付いており、ワンクリックで実行できます**。
|
||||
|
||||
時間に余裕がある場合は、**自分でコードをタイプすることをお勧めします**。時間がない場合は、少なくともすべてのコードを読んで実行してください。
|
||||
時間に余裕があれば、**コードを見ながら自分で一度書いてみることをおすすめします**。学習時間が限られている場合でも、少なくともすべてのコードに目を通し、実行してください。
|
||||
|
||||
コードを読むだけと比較して、コードを書くことは多くの場合、より多くの学習をもたらします。**実践による学習こそが真の学習方法です。**
|
||||
コードを読むのに比べて、書く過程のほうが得られるものは多いものです。**手を動かしてこそ、本当に学んだことになります**。
|
||||
|
||||

|
||||
|
||||
コードを実行するための設定には、主に3つのステップが含まれます。
|
||||
コードを実行する前準備は主に3ステップです。
|
||||
|
||||
**ステップ1:ローカルプログラミング環境をインストール**。付録の[チュートリアル](https://www.hello-algo.com/chapter_appendix/installation/)に従ってインストールするか、すでにインストールされている場合はこのステップをスキップしてください。
|
||||
**第1ステップ:ローカルのプログラミング環境をインストールする**。付録の[チュートリアル](https://www.hello-algo.com/chapter_appendix/installation/)を参照してインストールしてください。すでにインストール済みであれば、この手順は省略できます。
|
||||
|
||||
**ステップ2:コードリポジトリをクローンまたはダウンロード**。[GitHubリポジトリ](https://github.com/krahets/hello-algo)を訪問してください。
|
||||
|
||||
[Git](https://git-scm.com/downloads)がインストールされている場合は、次のコマンドを使用してリポジトリをクローンします:
|
||||
**第2ステップ:コードリポジトリをクローンまたはダウンロードする**。 [GitHub リポジトリ](https://github.com/krahets/hello-algo) にアクセスしてください。すでに [Git](https://git-scm.com/downloads) をインストールしている場合は、次のコマンドでこのリポジトリをクローンできます:
|
||||
|
||||
```shell
|
||||
git clone https://github.com/krahets/hello-algo.git
|
||||
```
|
||||
|
||||
または、下図に示す場所にある「Download ZIP」ボタンをクリックして、コードを圧縮ZIPファイルとして直接ダウンロードすることもできます。その後、ローカルで展開するだけです。
|
||||
もちろん、以下の図に示す場所で“Download ZIP”ボタンをクリックし、コードの圧縮ファイルを直接ダウンロードしてローカルで展開することもできます。
|
||||
|
||||

|
||||
|
||||
**ステップ3:ソースコードを実行**。下図に示すように、上部にファイル名が記載されたコードブロックについては、リポジトリの`codes`フォルダで対応するソースコードファイルを見つけることができます。これらのファイルはワンクリックで実行でき、不要なデバッグ時間を節約し、学習に集中できます。
|
||||
**第3ステップ:ソースコードを実行する**。以下の図のように、上部にファイル名が表示されているコードブロックについては、リポジトリの `codes` フォルダ内に対応するソースコードファイルがあります。ソースコードファイルはワンクリックで実行できるため、不要なデバッグ時間を減らし、学習内容に集中できます。
|
||||
|
||||

|
||||
|
||||
## 議論による共同学習
|
||||
ローカルでコードを実行するだけでなく、**Web 版では Python コードの可視化実行にも対応しています**([pythontutor](https://pythontutor.com/) を利用)。以下の図のように、コードブロックの下にある“可視化実行”をクリックすると表示を展開し、アルゴリズムコードの実行過程を観察できます。また、“全画面表示”をクリックすると、より見やすい閲覧体験が得られます。
|
||||
|
||||
この本を読んでいる間、学べなかった点を飛ばさないでください。**コメントセクションで気軽に質問してください**。喜んでお答えし、通常2日以内に回答できます。
|
||||

|
||||
|
||||
下図に示すように、各章の下部にコメントセクションがあります。これらのコメントに注意を払うことをお勧めします。他の人が遭遇した問題を知ることで、知識のギャップを特定し、より深い思索を促すだけでなく、仲間の読者の質問に答えたり、洞察を共有したり、相互の向上を促進したりすることで寛大に貢献することも招待します。
|
||||
## 質問と議論を通じてともに成長する
|
||||
|
||||

|
||||
本書を読んでいて、理解できていない知識点を安易に読み飛ばさないでください。**コメント欄で気軽に質問してください**。私と仲間たちが誠意をもって回答し、通常は 2 日以内に返信します。
|
||||
|
||||
## アルゴリズム学習パス
|
||||
以下の図のように、Web 版では各章の下部にコメント欄があります。ぜひコメント欄の内容にも目を通してください。一方では、みんなが直面した問題を知ることで知識の抜けを補い、より深い思考を促せます。もう一方では、ほかの仲間の質問にも積極的に答え、見解を共有し、互いの成長を助けてほしいと思います。
|
||||
|
||||
全体的に、データ構造とアルゴリズムをマスターする旅は3つの段階に分けることができます:
|
||||

|
||||
|
||||
1. **段階1:アルゴリズムの入門**。さまざまなデータ構造の特性と使用法に慣れ、異なるアルゴリズムの原理、プロセス、用途、効率について学ぶ必要があります。
|
||||
2. **段階2:アルゴリズム問題の練習**。[Sword for Offer](https://leetcode.cn/studyplan/coding-interviews/)や[LeetCode Hot 100](https://leetcode.cn/studyplan/top-100-liked/)などの人気のある問題から始めることをお勧めし、少なくとも100問を蓄積して主流のアルゴリズム問題に慣れることです。練習を始めると忘却が課題になる可能性がありますが、これは正常なことですのでご安心ください。「エビングハウスの忘却曲線」に従って問題を復習することができ、通常3〜5回の反復の後、それらを覚えることができるでしょう。
|
||||
3. **段階3:知識体系の構築**。学習の面では、アルゴリズムコラム記事、解法フレームワーク、アルゴリズム教科書を読んで知識体系を継続的に豊かにすることができます。練習の面では、トピック別分類、一つの問題に対する複数の解法、複数の問題に対する一つの解法など、高度な戦略を試すことができます。これらの戦略に関する洞察は、さまざまなコミュニティで見つけることができます。
|
||||
## アルゴリズム学習ロードマップ
|
||||
|
||||
下図に示すように、この本は主に「段階1」をカバーしており、段階2と3により効率的に取り組むのに役立つことを目的としています。
|
||||
全体として見ると、データ構造とアルゴリズムの学習過程は 3 つの段階に分けられます。
|
||||
|
||||

|
||||
1. **第 1 段階:アルゴリズム入門**。さまざまなデータ構造の特徴と使い方に慣れ、異なるアルゴリズムの原理、流れ、用途、効率などを学ぶ必要があります。
|
||||
2. **第 2 段階:アルゴリズム問題を解く**。まずは人気の高い問題から取り組み、少なくとも 100 問は蓄積して、主流のアルゴリズム問題に慣れることをおすすめします。最初のうちは、“知識の忘却”が課題になるかもしれませんが、心配はいりません。これはごく自然なことです。“エビングハウスの忘却曲線”に沿って問題を復習すれば、通常は 3~5 回繰り返すことでしっかり記憶に定着します。おすすめの問題リストと学習計画は、この [GitHub リポジトリ](https://github.com/krahets/LeetCode-Book) を参照してください。
|
||||
3. **第 3 段階:知識体系を構築する**。学習面では、アルゴリズムの連載記事、解法フレームワーク、教材などを読むことで、知識体系を継続的に充実させられます。問題演習の面では、トピック別分類、1 問多解、1 解多題といった発展的な戦略も試せます。関連する学習ノウハウは各コミュニティで見つけられます。
|
||||
|
||||
以下の図のように、本書の内容は主に“第 1 段階”を扱っており、第 2 段階と第 3 段階の学習をより効率的に進める助けとなることを目的としています。
|
||||
|
||||

|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# まとめ
|
||||
|
||||
- この本の主な読者はアルゴリズムの初心者です。すでに基本的な知識をお持ちの場合、この本はアルゴリズムの知識を体系的に復習するのに役立ち、この本のソースコードは「コーディングツールキット」としても使用できます。
|
||||
- この本は3つの主要なセクション、計算量解析、データ構造、アルゴリズムで構成されており、この分野のほとんどのトピックをカバーしています。
|
||||
- アルゴリズムの初心者にとって、多くの回り道や一般的な落とし穴を避けるために、初期段階で入門書を読むことが重要です。
|
||||
- 本書内のアニメーションと図は通常、重要なポイントと難しい知識を紹介するために使用されます。本を読む際にはこれらにより多くの注意を払う必要があります。
|
||||
- 実践はプログラミングを学ぶ最良の方法です。ソースコードを実行し、自分でコードをタイプすることを強くお勧めします。
|
||||
- この本のWeb版の各章には議論セクションがあり、いつでも質問や洞察を共有することを歓迎します。
|
||||
### 重要ポイントの振り返り
|
||||
|
||||
- 本書の主な対象読者はアルゴリズム初学者です。すでにある程度の基礎がある場合でも、本書はアルゴリズム知識を体系的に振り返る助けとなり、書中のソースコードは「問題演習用ツール集」としても利用できます。
|
||||
- 本書の内容は主に計算量解析、データ構造、アルゴリズムの三部からなり、この分野の大部分のテーマを網羅しています。
|
||||
- アルゴリズム初心者にとって、学習初期の段階で入門書を読むことは非常に重要であり、多くの遠回りを避けられます。
|
||||
- 本書のアニメーション図解は通常、重要な知識や難しい知識を紹介するために用いられます。本書を読む際は、これらの内容により多く注意を払うべきです。
|
||||
- 実践はプログラミングを学ぶ最良の方法です。ソースコードを実行し、実際に自分でコードを書くことを強く勧めます。
|
||||
- 本書のWeb版の各章にはコメント欄が設けられており、疑問や見解をいつでも共有することを歓迎します。
|
||||
|
||||
@@ -10,15 +10,15 @@ icon: material/bookshelf
|
||||
|
||||
[3] Robert Sedgewick, et al. Algorithms (4th Edition).
|
||||
|
||||
[4] Yan Weimin. Data Structures (C Language Version).
|
||||
[4] 严蔚敏. データ構造(C 言語版).
|
||||
|
||||
[5] Deng Junhui. Data Structures (C++ Language Version, Third Edition).
|
||||
[5] 邓俊辉. データ構造(C++ 言語版、第3版).
|
||||
|
||||
[6] Mark Allen Weiss, translated by Chen Yue. Data Structures and Algorithm Analysis in Java (Third Edition).
|
||||
[6] マーク・アレン・ワイス著,陈越訳. データ構造とアルゴリズム解析:Java言語による記述(第3版).
|
||||
|
||||
[7] Cheng Jie. Speaking of Data Structures.
|
||||
[7] 程杰. データ構造の話.
|
||||
|
||||
[8] Wang Zheng. The Beauty of Data Structures and Algorithms.
|
||||
[8] 王争. データ構造とアルゴリズムの美.
|
||||
|
||||
[9] Gayle Laakmann McDowell. Cracking the Coding Interview: 189 Programming Questions and Solutions (6th Edition).
|
||||
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
# 二分探索
|
||||
|
||||
<u>二分探索</u>は分割統治戦略を用いる効率的な探索アルゴリズムです。配列内の要素の整列順序を利用し、各反復で探索区間を半分に減らしながら、目標要素が見つかるか探索区間が空になるまで続行します。
|
||||
<u>二分探索(binary search)</u>は分割統治法に基づく効率的な探索アルゴリズムです。データが整列済みである性質を利用し、各ラウンドで探索範囲を半分に縮小し、目標要素を見つけるか探索区間が空になるまで続けます。
|
||||
|
||||
!!! question
|
||||
|
||||
長さ$n$の配列`nums`が与えられ、要素は重複なしで昇順に配列されています。この配列内の要素`target`のインデックスを見つけて返してください。配列に要素が含まれていない場合は$-1$を返してください。例を下図に示します。
|
||||
長さ $n$ の配列 `nums` が与えられます。要素は小さい順に並んでおり、重複しません。要素 `target` がこの配列内にある場合はそのインデックスを返し、含まれない場合は $-1$ を返してください。例を次の図に示します。
|
||||
|
||||

|
||||

|
||||
|
||||
下図に示すように、まず$i = 0$と$j = n - 1$でポインタを初期化し、それぞれ配列の最初と最後の要素を指します。これらはまた全体の探索区間$[0, n - 1]$を表します。角括弧は閉区間を示し、境界値自身も含むことに注意してください。
|
||||
次の図に示すように、まずポインタ $i = 0$ と $j = n - 1$ を初期化し、それぞれ配列の先頭要素と末尾要素を指すようにして、探索区間 $[0, n - 1]$ を表します。角括弧は閉区間を表し、境界値自体を含むことに注意してください。
|
||||
|
||||
そして、以下の2つのステップをループで実行する可能性があります。
|
||||
次に、以下の 2 つの手順を繰り返します。
|
||||
|
||||
1. 中点インデックス$m = \lfloor {(i + j) / 2} \rfloor$を計算します。ここで$\lfloor \: \rfloor$は床関数を表します。
|
||||
2. `nums[m]`と`target`の比較に基づいて、以下の3つのケースのうち1つを選択して実行します。
|
||||
1. `nums[m] < target`の場合、`target`は区間$[m + 1, j]$にあることを示すため、$i = m + 1$とします。
|
||||
2. `nums[m] > target`の場合、`target`は区間$[i, m - 1]$にあることを示すため、$j = m - 1$とします。
|
||||
3. `nums[m] = target`の場合、`target`が見つかったことを示すため、インデックス$m$を返します。
|
||||
1. 中央のインデックス $m = \lfloor {(i + j) / 2} \rfloor$ を計算します。ここで $\lfloor \: \rfloor$ は切り捨てを表します。
|
||||
2. `nums[m]` と `target` の大小関係を判定し、次の 3 つの場合に分かれます。
|
||||
1. `nums[m] < target` のとき、`target` は区間 $[m + 1, j]$ にあるため、$i = m + 1$ を実行します。
|
||||
2. `nums[m] > target` のとき、`target` は区間 $[i, m - 1]$ にあるため、$j = m - 1$ を実行します。
|
||||
3. `nums[m] = target` のとき、`target` が見つかったので、インデックス $m$ を返します。
|
||||
|
||||
配列に目標要素が含まれていない場合、探索区間は最終的に空になり、$-1$を返して終了します。
|
||||
配列に目標要素が含まれない場合、探索区間は最終的に空まで縮小されます。このとき $-1$ を返します。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -41,43 +41,43 @@
|
||||
=== "<7>"
|
||||

|
||||
|
||||
$i$と$j$が両方とも`int`型であるため、**$i + j$は`int`型の範囲を超える可能性がある**ことは注目に値します。大きな数のオーバーフローを避けるため、通常は式$m = \lfloor {i + (j - i) / 2} \rfloor$を使用して中点を計算します。
|
||||
注意すべき点として、$i$ と $j$ はどちらも `int` 型であるため、**$i + j$ が `int` 型の範囲を超える可能性があります**。大きな数によるオーバーフローを避けるため、通常は式 $m = \lfloor {i + (j - i) / 2} \rfloor$ を用いて中点を計算します。
|
||||
|
||||
コードは以下の通りです:
|
||||
コードは次のとおりです。
|
||||
|
||||
```src
|
||||
[file]{binary_search}-[class]{}-[func]{binary_search}
|
||||
```
|
||||
|
||||
**時間計算量は$O(\log n)$です**:二分ループにおいて、区間は各ラウンドで半分に減少するため、反復回数は$\log_2 n$となります。
|
||||
**時間計算量は $O(\log n)$** :二分探索のループでは各ラウンドで区間が半分になるため、ループ回数は $\log_2 n$ です。
|
||||
|
||||
**空間計算量は$O(1)$です**:ポインタ$i$と$j$は定数サイズの空間を占有します。
|
||||
**空間計算量は $O(1)$** :ポインタ $i$ と $j$ に必要なのは定数サイズの空間だけです。
|
||||
|
||||
## 区間表現方法
|
||||
## 区間の表し方
|
||||
|
||||
上記の閉区間の他に、もう一つの一般的な区間表現は「左閉右開」区間で、$[0, n)$として定義され、左境界は自身を含み、右境界は含みません。この表現では、$i = j$のとき区間$[i, j)$は空になります。
|
||||
上記の両閉区間のほかに、一般的な区間表現として「左閉右開」区間があり、$[0, n)$ と定義されます。つまり左端は含み、右端は含みません。この表現では、区間 $[i, j)$ は $i = j$ のとき空です。
|
||||
|
||||
この表現に基づいて同じ機能を持つ二分探索アルゴリズムを実装できます:
|
||||
この表現に基づいて、同じ機能を持つ二分探索アルゴリズムを実装できます。
|
||||
|
||||
```src
|
||||
[file]{binary_search}-[class]{}-[func]{binary_search_lcro}
|
||||
```
|
||||
|
||||
下図に示すように、2つの区間表現タイプにおいて、二分探索アルゴリズムの初期化、ループ条件、区間縮小操作が異なります。
|
||||
次の図に示すように、2 種類の区間表現では、二分探索アルゴリズムの初期化、ループ条件、区間の縮小操作がそれぞれ異なります。
|
||||
|
||||
「閉区間」表現では両方の境界が包含的であるため、ポインタ$i$と$j$による区間縮小操作も対称的です。これによりエラーが発生しにくくなるため、**一般的に「閉区間」アプローチの使用が推奨されます**。
|
||||
「両閉区間」の表現では左右の境界がどちらも閉区間として定義されるため、ポインタ $i$ とポインタ $j$ による区間縮小の操作も対称になります。このほうがミスをしにくいため、**一般には「両閉区間」の書き方を推奨します**。
|
||||
|
||||

|
||||

|
||||
|
||||
## 利点と制限
|
||||
## 利点と限界
|
||||
|
||||
二分探索は時間と空間の両方の面で良好な性能を示します。
|
||||
二分探索は時間と空間の両面で優れた性能を持ちます。
|
||||
|
||||
- 二分探索は時間効率が良いです。大きなデータセットでは、対数時間計算量が大きな利点を提供します。例えば、サイズ$n = 2^{20}$のデータセットが与えられた場合、線形探索は$2^{20} = 1048576$回の反復が必要ですが、二分探索は$\log_2 2^{20} = 20$回のループのみで済みます。
|
||||
- 二分探索には追加の空間が必要ありません。追加の空間に依存する探索アルゴリズム(ハッシュ探索など)と比較して、二分探索はより空間効率的です。
|
||||
- 二分探索は時間効率が高いです。データ量が大きい場合、対数時間計算量は大きな優位性を持ちます。たとえば、データサイズ $n = 2^{20}$ のとき、線形探索では $2^{20} = 1048576$ 回のループが必要ですが、二分探索では $\log_2 2^{20} = 20$ 回で済みます。
|
||||
- 二分探索は追加の空間を必要としません。追加領域を要する探索アルゴリズム(たとえばハッシュ探索)と比べて、二分探索はより省メモリです。
|
||||
|
||||
しかし、二分探索は以下の懸念により、すべてのシナリオに適しているとは限りません。
|
||||
しかし、二分探索があらゆる状況に適しているわけではなく、主な理由は次のとおりです。
|
||||
|
||||
- 二分探索はソート済みデータにのみ適用できます。未ソートのデータは二分探索を適用する前にソートする必要があり、ソートアルゴリズムは通常$O(n \log n)$の時間計算量を持つため、これは価値がないかもしれません。このコストは線形探索よりも高く、二分探索自体は言うまでもありません。頻繁な挿入があるシナリオでは、配列を順序に保つコストは非常に高く、特定の位置に新しい要素を挿入する時間計算量は$O(n)$です。
|
||||
- 二分探索は配列のみを使用できます。二分探索には非連続(ジャンプ)要素アクセスが必要で、これは連結リストでは非効率的です。そのため、連結リストや連結リストに基づくデータ構造はこのアルゴリズムに適していない可能性があります。
|
||||
- 線形探索は小さなデータセットでより良い性能を示します。線形探索では各反復で1つの判定操作のみが必要ですが、二分探索では1つの加算、1つの除算、1つから3つの判定操作、1つの加算(減算)を含み、合計4つから6つの操作が必要です。そのため、データサイズ$n$が小さい場合、線形探索は二分探索よりも高速です。
|
||||
- 二分探索は整列済みデータにしか適用できません。入力データが無秩序な場合、二分探索を使うためだけにソートするのは割に合いません。ソートアルゴリズムの時間計算量は通常 $O(n \log n)$ であり、線形探索や二分探索よりも高いからです。要素を頻繁に挿入する場面では、配列の整列性を保つために特定位置へ挿入する必要があり、その時間計算量は $O(n)$ と高コストです。
|
||||
- 二分探索は配列にしか適していません。二分探索では要素へ飛び飛びにアクセスする必要がありますが、連結リストでそのようなアクセスを行う効率は低いため、連結リストやそれを基に実装されたデータ構造には向きません。
|
||||
- データ量が小さい場合は線形探索のほうが高性能です。線形探索では各ラウンドで 1 回の比較だけで済みますが、二分探索では 1 回の加算、1 回の除算、1 ~ 3 回の比較、1 回の加算(減算)が必要で、合計 4 ~ 6 個の基本操作になります。したがって、データ量 $n$ が小さいときは、線形探索のほうがかえって速くなります。
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
# 二分探索の境界
|
||||
|
||||
## 左境界を見つける
|
||||
## 左端境界を探す
|
||||
|
||||
!!! question
|
||||
|
||||
重複要素を含む可能性がある長さ$n$のソート済み配列`nums`が与えられ、最も左の要素`target`のインデックスを返してください。要素が配列に存在しない場合は、$-1$を返してください。
|
||||
長さ $n$ のソート済み配列 `nums` が与えられ、その中には重複要素が含まれる可能性があります。配列内で最も左にある要素 `target` のインデックスを返してください。配列にこの要素が含まれない場合は、$-1$ を返します。
|
||||
|
||||
挿入位置の二分探索方法を思い出すと、探索完了後、インデックス$i$は`target`の最も左の出現を指します。したがって、**挿入位置の探索は本質的に最も左の`target`のインデックスを見つけることと同じです**。
|
||||
二分探索で挿入位置を求める方法を思い出すと、探索完了後に $i$ は最も左にある `target` を指します。**したがって、挿入位置を探すことの本質は、最も左にある `target` のインデックスを探すことです**。
|
||||
|
||||
挿入位置を見つける関数を使用して`target`の左境界を見つけることができます。配列に`target`が含まれていない可能性があることに注意してください。これは以下の2つの結果につながる可能性があります:
|
||||
挿入位置を探す関数を使って左端境界を求めることを考えます。なお、配列に `target` が含まれない場合があり、そのときは次の 2 つの結果が起こりえます。
|
||||
|
||||
- 挿入位置のインデックス$i$が範囲外です。
|
||||
- 要素`nums[i]`が`target`と等しくありません。
|
||||
- 挿入位置のインデックス $i$ が範囲外になる。
|
||||
- 要素 `nums[i]` が `target` と等しくない。
|
||||
|
||||
これらの場合、単に$-1$を返します。コードは以下の通りです:
|
||||
上の 2 つの状況に当てはまる場合は、直接 $-1$ を返せば十分です。コードは以下のとおりです:
|
||||
|
||||
```src
|
||||
[file]{binary_search_edge}-[class]{}-[func]{binary_search_left_edge}
|
||||
```
|
||||
|
||||
## 右境界を見つける
|
||||
## 右端境界を探す
|
||||
|
||||
`target`の最も右の出現をどのように見つけるでしょうか?最も直接的な方法は、`nums[m] == target`の場合に探索境界を調整する方法を変更して、従来の二分探索ロジックを修正することです。コードはここでは省略されています。興味がある場合は、自分でコードを実装してみてください。
|
||||
では、最も右にある `target` はどのように探せるでしょうか。最も直接的な方法はコードを修正し、`nums[m] == target` の場合のポインタの縮小操作を置き換えることです。ここではコードを省略するので、興味があれば自分で実装してみてください。
|
||||
|
||||
以下では、さらに2つの巧妙な方法を紹介します。
|
||||
ここでは、より巧妙な 2 つの方法を紹介します。
|
||||
|
||||
### 左境界探索を再利用する
|
||||
### 左端境界探索を再利用する
|
||||
|
||||
`target`の最も右の出現を見つけるには、最も左の`target`を見つけるために使用された関数を再利用できます。具体的には、最も右のターゲットの探索を最も左のターゲット + 1の探索に変換します。
|
||||
実際には、最も左の要素を探す関数を利用して最も右の要素を探せます。具体的には、**最も右にある `target` を探すことを、最も左にある `target + 1` を探すことに変換します**。
|
||||
|
||||
下図に示すように、探索完了後、ポインタ$i$は最も左の`target + 1`(存在する場合)を指し、ポインタ$j$は`target`の最も右の出現を指します。したがって、$j$を返すことで右境界が得られます。
|
||||
下図のように、探索完了後、ポインタ $i$ は最も左にある `target + 1`(存在する場合)を指し、$j$ は最も右にある `target` を指します。**したがって $j$ を返せばよいです**。
|
||||
|
||||

|
||||

|
||||
|
||||
返される挿入位置は$i$であることに注意してください。したがって、$j$を得るためには1を引く必要があります:
|
||||
返される挿入位置は $i$ なので、そこから $1$ を引いて $j$ を得る必要があることに注意してください:
|
||||
|
||||
```src
|
||||
[file]{binary_search_edge}-[class]{}-[func]{binary_search_right_edge}
|
||||
@@ -41,16 +41,16 @@
|
||||
|
||||
### 要素探索に変換する
|
||||
|
||||
配列に`target`が含まれていない場合、$i$と$j$は最終的に`target`より大きい最初の要素と小さい最初の要素をそれぞれ指します。
|
||||
配列に `target` が含まれない場合、最終的に $i$ と $j$ はそれぞれ `target` より大きい最初の要素と、`target` より小さい最初の要素を指すことになります。
|
||||
|
||||
したがって、下図に示すように、配列に存在しない要素を構築して、左と右の境界を探索できます。
|
||||
したがって、下図のように、配列中に存在しない要素を構成して、それを使って左右の境界を探せます。
|
||||
|
||||
- 最も左の`target`を見つけるには:`target - 0.5`を探索することに変換でき、ポインタ$i$を返します。
|
||||
- 最も右の`target`を見つけるには:`target + 0.5`を探索することに変換でき、ポインタ$j$を返します。
|
||||
- 最も左にある `target` の探索:`target - 0.5` を探すことに変換でき、ポインタ $i$ を返します。
|
||||
- 最も右にある `target` の探索:`target + 0.5` を探すことに変換でき、ポインタ $j$ を返します。
|
||||
|
||||

|
||||

|
||||
|
||||
コードはここでは省略されていますが、このアプローチについて注意すべき2つの重要な点があります。
|
||||
ここではコードを省略しますが、次の 2 点に注意が必要です。
|
||||
|
||||
- 与えられた配列`nums`には小数が含まれていないため、等しい場合の処理は心配ありません。
|
||||
- ただし、このアプローチで小数を導入するには、`target`変数を浮動小数点型に変更する必要があります(Pythonでは変更は不要です)。
|
||||
- 与えられた配列には小数が含まれないため、等しい場合をどう処理するかを気にする必要はありません。
|
||||
- この方法では小数を導入するため、関数内の変数 `target` を浮動小数点数型に変更する必要があります(Python は変更不要です)。
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
# 二分探索による挿入
|
||||
# 二分探索の挿入位置
|
||||
|
||||
二分探索は目標要素を探索するだけでなく、目標要素の挿入位置を探索するなど、多くの変種問題を解決するためにも使用されます。
|
||||
二分探索は目標要素の検索だけでなく、目標要素の挿入位置を探すなど、多くの派生問題の解決にも利用できます。
|
||||
|
||||
## 重複要素がない場合
|
||||
|
||||
!!! question
|
||||
|
||||
一意の要素を持つ長さ$n$のソート済み配列`nums`と要素`target`が与えられ、ソート順を維持しながら`target`を`nums`に挿入します。`target`が配列にすでに存在する場合は、既存の要素の左側に挿入します。挿入後の配列における`target`のインデックスを返してください。下図に示す例を参照してください。
|
||||
長さ $n$ の整列済み配列 `nums` と要素 `target` が与えられます。配列には重複要素は存在しません。ここで `target` を配列 `nums` に挿入し、その順序を保ちます。配列中にすでに要素 `target` が存在する場合は、その左側に挿入します。挿入後の配列における `target` のインデックスを返してください。例を以下の図に示します。
|
||||
|
||||

|
||||

|
||||
|
||||
前のセクションの二分探索コードを再利用したい場合、以下の2つの質問に答える必要があります。
|
||||
前節の二分探索コードを再利用したい場合は、次の二つの問題に答える必要があります。
|
||||
|
||||
**質問1**:配列にすでに`target`が含まれている場合、挿入位置は既存要素のインデックスになりますか?
|
||||
**問題 1**:配列に `target` が含まれる場合、挿入位置のインデックスはその要素のインデックスですか?
|
||||
|
||||
`target`を等しい要素の左側に挿入するという要件は、新しく挿入される`target`が元の`target`の位置を置き換えることを意味します。つまり、**配列に`target`が含まれている場合、挿入位置は確かにその`target`のインデックスです**。
|
||||
問題では `target` を等しい要素の左側に挿入するよう求めているため、新しく挿入された `target` は元の `target` の位置に入ります。つまり、**配列に `target` が含まれる場合、挿入位置のインデックスはその `target` のインデックスです**。
|
||||
|
||||
**質問2**:配列に`target`が含まれていない場合、どのインデックスに挿入されますか?
|
||||
**問題 2**:配列に `target` が存在しない場合、挿入位置はどの要素のインデックスですか?
|
||||
|
||||
二分探索プロセスをさらに考えてみましょう:`nums[m] < target`のとき、ポインタ$i$が移動します。これは、ポインタ$i$が`target`以上の要素に近づいていることを意味します。同様に、ポインタ$j$は常に`target`以下の要素に近づいています。
|
||||
二分探索の過程をさらに考えると、`nums[m] < target` のときは $i$ が移動します。これは、ポインタ $i$ が `target` 以上の要素へ近づいていることを意味します。同様に、ポインタ $j$ は常に `target` 以下の要素へ近づいています。
|
||||
|
||||
したがって、二分の終了時には確実に:$i$は`target`より大きい最初の要素を指し、$j$は`target`より小さい最初の要素を指します。**配列に`target`が含まれていない場合、挿入位置は$i$であることは明らかです**。コードは以下の通りです:
|
||||
したがって二分探索の終了時には、$i$ は最初の `target` より大きい要素を指し、$j$ は最初の `target` より小さい要素を指します。**よって、配列に `target` が含まれない場合、挿入インデックスは $i$ です**。コードは次のとおりです:
|
||||
|
||||
```src
|
||||
[file]{binary_search_insertion}-[class]{}-[func]{binary_search_insertion_simple}
|
||||
@@ -30,28 +30,28 @@
|
||||
|
||||
!!! question
|
||||
|
||||
前の質問に基づいて、配列に重複要素が含まれている可能性があると仮定し、他はすべて同じとします。
|
||||
前問を踏まえ、配列には重複要素が含まれる可能性があるものとし、それ以外の条件は変わりません。
|
||||
|
||||
配列に`target`の複数の出現がある場合、通常の二分探索は`target`の1つの出現のインデックスのみを返すことができ、**その位置の左右に`target`の出現がいくつあるかを特定することはできません**。
|
||||
配列中に複数の `target` が存在する場合、通常の二分探索ではそのうち一つの `target` のインデックスしか返せず、**その要素の左側と右側にあといくつ `target` があるかは分かりません**。
|
||||
|
||||
問題では目標要素を最も左の位置に挿入することが要求されているため、**配列内の最も左の`target`のインデックスを見つける必要があります**。最初に下図に示すステップを通してこれを実装することを考えてみましょう。
|
||||
問題では目標要素を最も左に挿入する必要があるため、**配列中で最も左にある `target` のインデックスを探す必要があります**。まずは以下の図に示す手順で実現することを考えます。
|
||||
|
||||
1. 二分探索を実行して`target`の任意のインデックス、例えば$k$を見つけます。
|
||||
2. インデックス$k$から開始して、最も左の`target`の出現が見つかるまで左に線形探索を行い、このインデックスを返します。
|
||||
1. 二分探索を実行し、任意の `target` のインデックスを得て、これを $k$ とします。
|
||||
2. インデックス $k$ から始めて左へ線形探索し、最も左の `target` を見つけたら返します。
|
||||
|
||||

|
||||

|
||||
|
||||
この方法は実現可能ですが、線形探索を含むため、時間計算量は$O(n)$です。この方法は、配列に多くの重複する`target`が含まれている場合に非効率です。
|
||||
この方法は使用できますが、線形探索を含むため、時間計算量は $O(n)$ です。配列中に重複した `target` が多い場合、この方法の効率は低くなります。
|
||||
|
||||
今度は二分探索コードを拡張することを考えてみましょう。下図に示すように、全体的なプロセスは同じままです。各ラウンドで、まず中間インデックス$m$を計算し、次に`target`と`nums[m]`の値を比較して、以下のケースになります。
|
||||
次に、二分探索のコードを拡張することを考えます。以下の図に示すように、全体の流れは変えず、各反復でまず中点インデックス $m$ を計算し、その後 `target` と `nums[m]` の大小関係を判定して、次のいくつかの状況に分けます。
|
||||
|
||||
- `nums[m] < target`または`nums[m] > target`のとき、これは`target`がまだ見つかっていないことを意味するため、通常の二分探索を使用して探索範囲を狭め、**ポインタ$i$と$j$を`target`に近づけます**。
|
||||
- `nums[m] == target`のとき、これは`target`より小さい要素が範囲$[i, m - 1]$にあることを示すため、$j = m - 1$を使用して範囲を狭め、**ポインタ$j$を`target`より小さい要素に近づけます**。
|
||||
- `nums[m] < target` または `nums[m] > target` のときは、まだ `target` を見つけていないことを意味するため、通常の二分探索と同じ区間縮小を行い、**ポインタ $i$ と $j$ を `target` に近づけます**。
|
||||
- `nums[m] == target` のときは、`target` より小さい要素が区間 $[i, m - 1]$ にあることを意味するため、$j = m - 1$ として区間を縮小し、**ポインタ $j$ を `target` より小さい要素に近づけます**。
|
||||
|
||||
ループ後、$i$は最も左の`target`を指し、$j$は`target`より小さい最初の要素を指すため、**インデックス$i$が挿入位置です**。
|
||||
ループ終了後、$i$ は最も左の `target` を指し、$j$ は最初の `target` より小さい要素を指すため、**インデックス $i$ が挿入位置です**。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -74,9 +74,9 @@
|
||||
=== "<8>"
|
||||

|
||||
|
||||
以下のコードを観察してください。分岐`nums[m] > target`と`nums[m] == target`の操作は同じであるため、これら2つの分岐をマージできます。
|
||||
以下のコードを観察すると、分岐 `nums[m] > target` と `nums[m] == target` の処理は同じであるため、両者はまとめることができます。
|
||||
|
||||
それでも、ロジックがより明確になり、可読性が向上するため、条件を展開したままにしておくことができます。
|
||||
それでも、判定条件を分けたままにしておくことは可能であり、そのほうがロジックがより明確で、可読性も高くなります。
|
||||
|
||||
```src
|
||||
[file]{binary_search_insertion}-[class]{}-[func]{binary_search_insertion}
|
||||
@@ -84,8 +84,8 @@
|
||||
|
||||
!!! tip
|
||||
|
||||
このセクションのコードは「閉区間」を使用しています。「左閉右開」に興味がある場合は、自分でコードを実装してみてください。
|
||||
本節のコードはすべて「両閉区間」の書き方です。興味のある読者は「左閉右開」の書き方を自分で実装してみてください。
|
||||
|
||||
要約すると、二分探索は本質的にポインタ$i$と$j$の探索目標を設定することです。これらの目標は特定の要素(`target`など)または要素の範囲(`target`より小さいものなど)である可能性があります。
|
||||
要するに、二分探索とはポインタ $i$ と $j$ にそれぞれ探索目標を設定することにほかなりません。目標は具体的な要素(たとえば `target`)である場合もあれば、要素の範囲(たとえば `target` より小さい要素)である場合もあります。
|
||||
|
||||
二分探索の連続ループにおいて、ポインタ$i$と$j$は段階的に事前定義された目標に近づきます。最終的に、それらは答えを見つけるか、境界を越えた後に停止します。
|
||||
繰り返される二分のループの中で、ポインタ $i$ と $j$ はどちらも事前に定めた目標へ徐々に近づいていきます。最終的に、それらは答えを見つけるか、境界を越えたところで停止します。
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# 探索
|
||||
|
||||

|
||||

|
||||
|
||||
!!! abstract
|
||||
|
||||
探索は未知への冒険です。神秘的な空間の隅々まで巡る必要があるかもしれませんし、あるいはすぐに目標を見つけることができるかもしれません。
|
||||
|
||||
この発見の旅において、それぞれの探査は予期しない答えで終わるかもしれません。
|
||||
探索は未知の冒険であり、私たちは神秘的な空間の隅々まで歩き回る必要があるかもしれず、あるいは素早く目標を特定できるかもしれません。
|
||||
|
||||
この探索の旅において、すべての探求が思いもよらなかった答えをもたらすかもしれません。
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
# ハッシュ最適化戦略
|
||||
# ハッシュによる最適化戦略
|
||||
|
||||
アルゴリズム問題において、**線形探索をハッシュベースの探索に置き換えることで、アルゴリズムの時間計算量を削減することがよくあります**。アルゴリズム問題を使用して理解を深めましょう。
|
||||
アルゴリズムの問題では,**線形探索をハッシュ探索に置き換えることでアルゴリズムの時間計算量を下げることがよくあります**。ここでは,あるアルゴリズム問題を通じて理解を深めましょう。
|
||||
|
||||
!!! question
|
||||
|
||||
整数配列`nums`と目標要素`target`が与えられ、配列内で「和」が`target`に等しい2つの要素を探索し、それらの配列インデックスを返してください。任意の解が受け入れられます。
|
||||
整数配列 `nums` と目標要素 `target` が与えられたとき,配列内から和が `target` となる 2 つの要素を探索し,それらの配列インデックスを返してください。任意の 1 つの解を返せば十分です。
|
||||
|
||||
## 線形探索:時間を空間と交換
|
||||
## 線形探索:時間と引き換えに空間を節約
|
||||
|
||||
すべての可能な組み合わせを直接横断することを考えてみます。下図に示すように、ネストしたループを開始し、各反復で2つの整数の和が`target`に等しいかどうかを判断します。そうであれば、それらのインデックスを返します。
|
||||
考えられるすべての組み合わせを直接走査することを考えます。次の図に示すように,2 重ループを開始し,各ラウンドで 2 つの整数の和が `target` であるかを判定します。そうであれば,それらのインデックスを返します。
|
||||
|
||||

|
||||

|
||||
|
||||
コードは以下の通りです:
|
||||
コードは次のとおりです:
|
||||
|
||||
```src
|
||||
[file]{two_sum}-[class]{}-[func]{two_sum_brute_force}
|
||||
```
|
||||
|
||||
この方法の時間計算量は$O(n^2)$、空間計算量は$O(1)$で、大容量データでは非常に時間がかかる可能性があります。
|
||||
この方法の時間計算量は $O(n^2)$ ,空間計算量は $O(1)$ であり,大規模データでは非常に時間がかかります。
|
||||
|
||||
## ハッシュ探索:空間を時間と交換
|
||||
## ハッシュ探索:空間と引き換えに時間を節約
|
||||
|
||||
ハッシュテーブルの使用を考えてみましょう。キーと値のペアはそれぞれ配列要素とそのインデックスです。配列をループし、各反復中に下図に示すステップを実行します。
|
||||
ハッシュテーブルを利用し,キーと値をそれぞれ配列要素と要素のインデックスにします。配列をループで走査し,各ラウンドで次の図に示す手順を実行します。
|
||||
|
||||
1. 数値`target - nums[i]`がハッシュテーブルにあるかどうかを確認します。ある場合は、これら2つの要素のインデックスを直接返します。
|
||||
2. キーと値のペア`nums[i]`とインデックス`i`をハッシュテーブルに追加します。
|
||||
1. 数値 `target - nums[i]` がハッシュテーブル内にあるかを判定します。あれば,この 2 つの要素のインデックスを直接返します。
|
||||
2. キーと値の組 `nums[i]` とインデックス `i` をハッシュテーブルに追加します。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -36,12 +36,12 @@
|
||||
=== "<3>"
|
||||

|
||||
|
||||
実装コードは以下に示され、単一のループのみが必要です:
|
||||
実装コードは次のとおりで,単一ループだけで済みます:
|
||||
|
||||
```src
|
||||
[file]{two_sum}-[class]{}-[func]{two_sum_hash_table}
|
||||
```
|
||||
|
||||
この方法は、ハッシュ探索を使用することで時間計算量を$O(n^2)$から$O(n)$に削減し、実行時効率を大幅に向上させます。
|
||||
この方法ではハッシュ探索によって時間計算量を $O(n^2)$ から $O(n)$ に下げ,実行効率を大幅に向上させます。
|
||||
|
||||
追加のハッシュテーブルを維持する必要があるため、空間計算量は$O(n)$です。**それにもかかわらず、この方法は全体的により均衡のとれた時空間効率を持ち、この問題の最適解となります**。
|
||||
追加のハッシュテーブルを維持する必要があるため,空間計算量は $O(n)$ です。**それでも,この方法は全体として時間と空間の効率のバランスがより良く,本問の最適解です**。
|
||||
|
||||
@@ -1,84 +1,84 @@
|
||||
# 探索アルゴリズムの再検討
|
||||
# 探索アルゴリズム再考
|
||||
|
||||
<u>探索アルゴリズム(検索アルゴリズム)</u>は、配列、連結リスト、木、グラフなどのデータ構造内で特定の基準を満たす1つ以上の要素を取得するために使用されます。
|
||||
<u>探索アルゴリズム(searching algorithm)</u>は、データ構造(配列、連結リスト、木、グラフなど)の中から、特定の条件を満たす 1 つまたは複数の要素を探索するために用いられます。
|
||||
|
||||
探索アルゴリズムは、そのアプローチに基づいて以下の2つのカテゴリに分けることができます。
|
||||
探索アルゴリズムは、実装の考え方に応じて次の 2 種類に分けられます。
|
||||
|
||||
- **データ構造を横断することで目標要素を特定する**:配列、連結リスト、木、グラフの横断など。
|
||||
- **データの組織構造や既存のデータを使用して効率的な要素探索を実現する**:二分探索、ハッシュ探索、二分探索木探索など。
|
||||
- **データ構造を走査して目標要素を特定する方法**。配列、連結リスト、木、グラフの走査などがこれに当たります。
|
||||
- **データの構成やデータに含まれる事前情報を利用して、要素を効率よく探す方法**。二分探索、ハッシュ探索、二分探索木による探索などがこれに当たります。
|
||||
|
||||
これらのトピックは前の章で紹介されたため、私たちには馴染みのないものではありません。このセクションでは、より体系的な観点から探索アルゴリズムを再検討します。
|
||||
これらのトピックはすでに前の章で扱っているため、探索アルゴリズムは私たちにとって見慣れたものです。本節では、より体系的な視点から探索アルゴリズムをあらためて見直します。
|
||||
|
||||
## 総当たり探索
|
||||
|
||||
総当たり探索は、データ構造のすべての要素を横断することで目標要素を特定します。
|
||||
総当たり探索は、データ構造の各要素を順に調べて目標要素を特定します。
|
||||
|
||||
- 「線形探索」は配列や連結リストなどの線形データ構造に適しています。データ構造の一端から開始し、目標要素が見つかるか、目標要素を見つけることなく他端に到達するまで、各要素に一つずつアクセスします。
|
||||
- 「幅優先探索」と「深さ優先探索」は、グラフと木の2つの横断戦略です。幅優先探索は初期ノードから開始し、層ごと(左から右へ)に探索し、近くから遠くのノードにアクセスします。深さ優先探索は初期ノードから開始し、パスの終端(上から下へ)まで追跡し、その後バックトラックして他のパスを試し、データ構造全体が横断されるまで続行します。
|
||||
- “線形探索”は配列や連結リストなどの線形データ構造に適しています。データ構造の一端から始めて、要素を 1 つずつ調べ、目標要素が見つかるか、もう一方の端に達しても見つからないまで続けます。
|
||||
- “幅優先探索”と“深さ優先探索”は、グラフと木における 2 つの走査戦略です。幅優先探索は初期ノードから始めて層ごとに探索し、近いところから遠いところへ各ノードを訪れます。深さ優先探索は初期ノードから始めて 1 本の経路を最後までたどり、その後でバックトラックしてほかの経路を試し、データ構造全体を走査し終えるまで続けます。
|
||||
|
||||
総当たり探索の利点は、その単純さと汎用性であり、**データの前処理や追加のデータ構造の助けが不要**です。
|
||||
総当たり探索の利点は、単純で汎用性が高く、**データの前処理や追加のデータ構造を必要としない**ことです。
|
||||
|
||||
ただし、**このタイプのアルゴリズムの時間計算量は$O(n)$**で、$n$は要素数であるため、大規模なデータセットでは性能が悪くなります。
|
||||
しかし、**この種のアルゴリズムの時間計算量は $O(n)$ です**。ここで $n$ は要素数であり、そのためデータ量が大きい場合は性能が低くなります。
|
||||
|
||||
## 適応的探索
|
||||
## 適応的な探索
|
||||
|
||||
適応的探索は、データの固有の性質(順序など)を使用して探索プロセスを最適化し、それにより目標要素をより効率的に特定します。
|
||||
適応的な探索は、データが持つ固有の性質(整列性など)を利用して探索過程を最適化し、目標要素をより効率よく特定します。
|
||||
|
||||
- 「二分探索」はデータの整列性を使用して効率的な探索を実現し、配列にのみ適用可能です。
|
||||
- 「ハッシュ探索」はハッシュテーブルを使用して探索データと目標データの間にキーと値のマッピングを確立し、それによりクエリ操作を実装します。
|
||||
- 特定の木構造(二分探索木など)での「木探索」は、ノード値の比較に基づいてノードを迅速に除外し、それにより目標要素を特定します。
|
||||
- “二分探索”は、データの順序性を利用して効率的な探索を行う方法で、配列にしか適用できません。
|
||||
- “ハッシュ探索”は、ハッシュ表を用いて探索対象のデータと目標データをキーと値の対応にし、問い合わせ操作を実現します。
|
||||
- “木探索”は、特定の木構造(たとえば二分探索木)の中で、ノード値の比較に基づいて不要なノードをすばやく除外し、目標要素を特定します。
|
||||
|
||||
これらのアルゴリズムの利点は高効率であり、**時間計算量が$O(\log n)$または$O(1)$にまで達します**。
|
||||
この種のアルゴリズムの利点は効率が高く、**時間計算量が $O(\log n)$ あるいは $O(1)$ に達する**ことです。
|
||||
|
||||
ただし、**これらのアルゴリズムを使用するには、多くの場合データの前処理が必要です**。例えば、二分探索では事前に配列をソートする必要があり、ハッシュ探索と木探索の両方で追加のデータ構造の助けが必要です。これらの構造を維持することも、時間と空間の面でより多くのオーバーヘッドが必要です。
|
||||
しかし、**これらのアルゴリズムを使うには、たいていデータの前処理が必要です**。たとえば、二分探索では事前に配列をソートする必要があり、ハッシュ探索と木探索では追加のデータ構造が必要です。これらのデータ構造を維持するにも、追加の時間と空間のコストがかかります。
|
||||
|
||||
!!! tip
|
||||
|
||||
適応的探索アルゴリズムは、多くの場合探索アルゴリズムと呼ばれ、**主に特定のデータ構造内で目標要素を迅速に取得するために使用されます**。
|
||||
適応的な探索アルゴリズムは、しばしば検索アルゴリズムとも呼ばれ、**主に特定のデータ構造の中で目標要素を高速に取得するために用いられます**。
|
||||
|
||||
## 探索方法の選択
|
||||
## 探索手法の選択
|
||||
|
||||
サイズ$n$のデータセットが与えられた場合、線形探索、二分探索、木探索、ハッシュ探索、またはその他の方法を使用して目標要素を取得できます。これらの方法の動作原理を下図に示します。
|
||||
大きさ $n$ のデータ集合が与えられたとき、線形探索、二分探索、木探索、ハッシュ探索など、さまざまな方法で目標要素を探索できます。各手法の動作原理を下図に示します。
|
||||
|
||||

|
||||

|
||||
|
||||
前述の方法の特性と操作効率を以下の表に示します。
|
||||
上記のいくつかの手法について、操作効率と特性を次の表に示します。
|
||||
|
||||
<p align="center"> 表 <id> 探索アルゴリズム効率の比較 </p>
|
||||
<p align="center"> 表 <id> 探索アルゴリズムの効率比較 </p>
|
||||
|
||||
| | 線形探索 | 二分探索 | 木探索 | ハッシュ探索 |
|
||||
| ------------------ | ------------- | --------------------- | --------------------------- | -------------------------- |
|
||||
| 要素探索 | $O(n)$ | $O(\log n)$ | $O(\log n)$ | $O(1)$ |
|
||||
| 要素挿入 | $O(1)$ | $O(n)$ | $O(\log n)$ | $O(1)$ |
|
||||
| 要素削除 | $O(n)$ | $O(n)$ | $O(\log n)$ | $O(1)$ |
|
||||
| 追加空間 | $O(1)$ | $O(1)$ | $O(n)$ | $O(n)$ |
|
||||
| データ前処理 | / | ソート $O(n \log n)$ | 木構築 $O(n \log n)$ | ハッシュテーブル構築 $O(n)$ |
|
||||
| データ順序性 | 無順序 | 順序 | 順序 | 無順序 |
|
||||
| | 線形探索 | 二分探索 | 木探索 | ハッシュ探索 |
|
||||
| ------------ | -------- | ------------------ | ------------------ | --------------- |
|
||||
| 要素探索 | $O(n)$ | $O(\log n)$ | $O(\log n)$ | $O(1)$ |
|
||||
| 要素挿入 | $O(1)$ | $O(n)$ | $O(\log n)$ | $O(1)$ |
|
||||
| 要素削除 | $O(n)$ | $O(n)$ | $O(\log n)$ | $O(1)$ |
|
||||
| 追加領域 | $O(1)$ | $O(1)$ | $O(n)$ | $O(n)$ |
|
||||
| データ前処理 | / | ソート $O(n \log n)$ | 木構築 $O(n \log n)$ | ハッシュ表構築 $O(n)$ |
|
||||
| データの順序性 | なし | あり | あり | なし |
|
||||
|
||||
探索アルゴリズムの選択は、データ量、探索性能要件、データクエリと更新の頻度などにも依存します。
|
||||
探索アルゴリズムの選択は、規模、探索性能の要求、データの問い合わせ頻度や更新頻度などにも左右されます。
|
||||
|
||||
**線形探索**
|
||||
|
||||
- 汎用性が良く、データ前処理操作が不要です。データを一度だけクエリする必要がある場合、他の3つの方法のデータ前処理時間は線形探索の時間よりも長くなります。
|
||||
- 小容量のデータに適しており、時間計算量が効率に与える影響は小さいです。
|
||||
- データ更新が非常に頻繁なシナリオに適しています。この方法はデータの追加メンテナンスを必要としないためです。
|
||||
- 汎用性が高く、データの前処理をまったく必要としません。データを 1 回だけ問い合わせればよい場合、ほか 3 つの手法では前処理にかかる時間のほうが、線形探索そのものより長くなることがあります。
|
||||
- 規模の小さいデータに適しています。この場合、時間計算量が効率に与える影響は比較的小さいです。
|
||||
- データ更新頻度が高い場面に適しています。この手法では、データに対する追加の保守が不要だからです。
|
||||
|
||||
**二分探索**
|
||||
|
||||
- より大きなデータ量に適しており、安定した性能と最悪ケースの時間計算量$O(\log n)$を持ちます。
|
||||
- ただし、データ量が大きすぎることはできません。配列の保存には連続したメモリ空間が必要だからです。
|
||||
- 頻繁な追加と削除があるシナリオには適していません。順序付き配列の維持に多くのオーバーヘッドが発生するためです。
|
||||
- 大規模データに適しており、効率が安定しています。最悪時間計算量は $O(\log n)$ です。
|
||||
- データ量が大きすぎる場合には向きません。配列の格納には連続したメモリ領域が必要だからです。
|
||||
- 頻繁な挿入・削除がある場面には向きません。整列配列を維持するコストが高いためです。
|
||||
|
||||
**ハッシュ探索**
|
||||
|
||||
- 高速クエリ性能が不可欠なシナリオに適しており、平均時間計算量は$O(1)$です。
|
||||
- 順序付きデータや範囲探索が必要なシナリオには適していません。ハッシュテーブルはデータの順序性を維持できないためです。
|
||||
- 問い合わせ性能への要求が高い場面に適しており、平均時間計算量は $O(1)$ です。
|
||||
- 順序付きデータや範囲探索が必要な場面には向きません。ハッシュ表ではデータの順序性を維持できないからです。
|
||||
- ハッシュ関数とハッシュ衝突処理戦略への依存度が高く、性能劣化のリスクが大きいです。
|
||||
- 過度に大容量のデータには適していません。ハッシュテーブルは衝突を最小化し、良好なクエリ性能を提供するために追加の空間が必要だからです。
|
||||
- データ量が大きすぎる場合には向きません。ハッシュ表は衝突をできるだけ減らして良好な問い合わせ性能を出すために、追加の空間を必要とするからです。
|
||||
|
||||
**木探索**
|
||||
|
||||
- 大容量データに適しています。木ノードはメモリ内に分散して保存されるためです。
|
||||
- 順序付きデータの維持や範囲探索に適しています。
|
||||
- ノードの継続的な追加と削除により、二分探索木は偏る可能性があり、時間計算量が$O(n)$に劣化する可能性があります。
|
||||
- AVL木や赤黒木を使用する場合、操作は$O(\log n)$効率で安定して実行できますが、木のバランスを維持する操作により追加のオーバーヘッドが追加されます。
|
||||
- 巨大データに適しています。木ノードはメモリ上に分散して格納されるためです。
|
||||
- 順序付きデータの維持や範囲探索が必要な場面に適しています。
|
||||
- ノードの挿入・削除を続ける過程で、二分探索木は偏ることがあり、時間計算量は $O(n)$ まで劣化する可能性があります。
|
||||
- AVL 木や赤黒木を使えば、各種操作を $O(\log n)$ の効率で安定して実行できますが、木の平衡を保つ処理による追加コストが発生します。
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# まとめ
|
||||
|
||||
- 二分探索はデータの順序に依存し、探索区間を反復的に半分にすることで探索を実行します。入力データがソート済みである必要があり、配列または配列ベースのデータ構造にのみ適用可能です。
|
||||
- 無順序データセット内のエントリを見つけるには、総当たり探索が必要な場合があります。データ構造に基づいて異なる探索アルゴリズムを適用できます:線形探索は配列と連結リストに適しており、幅優先探索(BFS)と深さ優先探索(DFS)はグラフと木に適しています。これらのアルゴリズムは非常に汎用性が高く、データの前処理が不要ですが、$O(n)$という高い時間計算量を持ちます。
|
||||
- ハッシュ探索、木探索、二分探索は効率的な探索方法で、特定のデータ構造内で目標要素を迅速に特定できます。これらのアルゴリズムは非常に効率的で、時間計算量が$O(\log n)$または$O(1)$にまで達しますが、通常は追加のデータ構造を収容するために追加の空間が必要です。
|
||||
- 実際には、データ量、探索性能要件、データクエリと更新頻度などの要因を分析して、適切な探索方法を選択する必要があります。
|
||||
- 線形探索は小さなデータや頻繁に更新される(変動性の高い)データに理想的です。二分探索は大きくてソート済みのデータに適しています。ハッシュ探索は高いクエリ効率が必要で範囲クエリが不要なデータに適しています。木探索は順序を維持し、範囲クエリをサポートする必要がある大きな動的データに最も適しています。
|
||||
- 線形探索をハッシュ探索に置き換えることは、実行時性能を最適化する一般的な戦略で、時間計算量を$O(n)$から$O(1)$に削減します。
|
||||
### 要点の振り返り
|
||||
|
||||
- 二分探索はデータの順序性に依存し、ループによって探索区間を半分ずつ縮小しながら探索を行う。入力データがソート済みであることを前提とし、配列または配列ベースで実装されたデータ構造にのみ適用できる。
|
||||
- 総当たり探索はデータ構造を走査してデータを特定する。線形探索は配列と連結リストに適しており、幅優先探索と深さ優先探索はグラフと木に適している。この種のアルゴリズムは汎用性が高く、データの前処理を必要としないが、時間計算量 $O(n)$ は高い。
|
||||
- ハッシュ探索、木探索、二分探索は高効率な探索手法であり、特定のデータ構造内で目的の要素を高速に特定できる。この種のアルゴリズムは効率が高く、時間計算量は $O(\log n)$ あるいは $O(1)$ に達するが、通常は追加のデータ構造を必要とする。
|
||||
- 実際には、データ規模、探索性能の要件、データの問い合わせ頻度や更新頻度などの要因を具体的に分析し、そのうえで適切な探索手法を選択する必要がある。
|
||||
- 線形探索は小規模または頻繁に更新されるデータに適している。二分探索は大規模でソート済みのデータに適している。ハッシュ探索は問い合わせ効率への要求が高く、範囲検索を必要としないデータに適している。木探索は順序の維持と範囲検索のサポートが必要な大規模動的データに適している。
|
||||
- ハッシュ探索で線形探索を置き換えることは、実行時間を最適化するための一般的な戦略であり、時間計算量を $O(n)$ から $O(1)$ へと下げられる。
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# バブルソート
|
||||
|
||||
<u>バブルソート</u>は、隣接する要素を継続的に比較し交換することで動作します。このプロセスは泡が底から上に上昇するようなものなので、「バブルソート」と名付けられました。
|
||||
<u>バブルソート(bubble sort)</u>は、隣接する要素を繰り返し比較して交換することで整列を行います。この過程が泡のように下から上へ浮かび上がることから、バブルソートと呼ばれます。
|
||||
|
||||
下図に示すように、バブリングプロセスは要素交換を使用してシミュレートできます:配列の左端から開始して右に移動し、隣接する要素の各ペアを比較します。左の要素が右の要素より大きい場合は、それらを交換します。横断後、最大要素は配列の右端にバブルアップします。
|
||||
次の図に示すように、バブル処理は要素の交換操作によってシミュレートできます。配列の最も左の端から右へ走査し、隣接する要素の大小を順に比較して、「左要素 > 右要素」であれば両者を交換します。走査が終わると、最大の要素は配列の最も右端へ移動します。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -25,18 +25,18 @@
|
||||
=== "<7>"
|
||||

|
||||
|
||||
## アルゴリズムプロセス
|
||||
## アルゴリズムの流れ
|
||||
|
||||
配列の長さを$n$とします。バブルソートのステップは下図に示されます:
|
||||
配列の長さを $n$ とすると、バブルソートの手順は次の図のとおりです。
|
||||
|
||||
1. まず、$n$個の要素に対して1回の「バブル」パスを実行し、**最大要素を正しい位置に交換します**。
|
||||
2. 次に、残りの$n - 1$個の要素に対して「バブル」パスを実行し、**2番目に大きい要素を正しい位置に交換します**。
|
||||
3. この方法で続行します;$n - 1$回のパスの後、**最大$n - 1$個の要素が正しい位置に移動されます**。
|
||||
4. 残りの唯一の要素は**必ず**最小であるため、**さらなる**ソートは必要ありません。この時点で、配列はソートされます。
|
||||
1. まず、$n$ 個の要素に対して「バブル処理」を行い、**配列中の最大要素を正しい位置へ交換します**。
|
||||
2. 次に、残りの $n - 1$ 個の要素に対して「バブル処理」を行い、**2 番目に大きい要素を正しい位置へ交換します**。
|
||||
3. このようにして、$n - 1$ 回の「バブル処理」を終えると、**大きいほうから $n - 1$ 個の要素がすべて正しい位置へ交換されます**。
|
||||
4. 残った 1 つの要素は必ず最小要素なので、並べ替える必要はなく、これで配列のソートが完了します。
|
||||
|
||||

|
||||

|
||||
|
||||
コード例は以下の通りです:
|
||||
コード例は次のとおりです。
|
||||
|
||||
```src
|
||||
[file]{bubble_sort}-[class]{}-[func]{bubble_sort}
|
||||
@@ -44,16 +44,16 @@
|
||||
|
||||
## 効率の最適化
|
||||
|
||||
「バブリング」のラウンド中に交換が発生しない場合、配列はすでにソートされているため、すぐに戻ることができます。これを検出するために、`flag`変数を追加できます;パスで交換が行われない場合は、フラグを設定して早期に戻ります。
|
||||
ある回の「バブル処理」で交換操作が一度も行われなければ、配列はすでにソート済みであり、結果をそのまま返せることがわかります。したがって、この状況を検出するためのフラグ `flag` を追加し、発生した時点で直ちに返すようにできます。
|
||||
|
||||
この最適化があっても、バブルソートの最悪時間計算量と平均時間計算量は$O(n^2)$のままです。ただし、入力配列がすでにソートされている場合、最良ケース時間計算量は$O(n)$まで低くなる可能性があります。
|
||||
最適化後も、バブルソートの最悪時間計算量と平均時間計算量は依然として $O(n^2)$ です。ただし、入力配列が完全に整列済みであれば、最良時間計算量は $O(n)$ に達します。
|
||||
|
||||
```src
|
||||
[file]{bubble_sort}-[class]{}-[func]{bubble_sort_with_flag}
|
||||
```
|
||||
|
||||
## アルゴリズムの特性
|
||||
## アルゴリズムの特徴
|
||||
|
||||
- **$O(n^2)$の時間計算量、適応ソート。** 各「バブリング」ラウンドは長さ$n - 1$、$n - 2$、$\dots$、$2$、$1$の配列セグメントを横断し、合計は$(n - 1) n / 2$となります。`flag`最適化により、配列がすでにソートされている場合、最良ケース時間計算量は$O(n)$に達する可能性があります。
|
||||
- **$O(1)$の空間計算量、インプレースソート。** ポインタ$i$と$j$によって定数量の追加空間のみが使用されます。
|
||||
- **安定ソート。** 等しい要素は「バブリング」中に交換されないため、元の順序が保持され、これは安定ソートになります。
|
||||
- **時間計算量は $O(n^2)$、適応的ソート**:各回の「バブル処理」で走査する配列の長さは順に $n - 1$、$n - 2$、$\dots$、$2$、$1$ であり、その総和は $(n - 1) n / 2$ です。`flag` による最適化を導入すると、最良時間計算量は $O(n)$ に達します。
|
||||
- **空間計算量は $O(1)$、インプレースソート**:ポインタ $i$ と $j$ は定数サイズの追加領域しか使用しません。
|
||||
- **安定ソート**:「バブル処理」では等しい要素に出会っても交換しないためです。
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
# バケットソート
|
||||
|
||||
前述のソートアルゴリズムはすべて「比較ベースのソートアルゴリズム」で、値を比較することで要素をソートします。このようなソートアルゴリズムは $O(n \log n)$ より良い時間計算量を持つことはできません。次に、線形時間計算量を達成できるいくつかの「非比較ソートアルゴリズム」について議論します。
|
||||
前述のいくつかのソートアルゴリズムは、いずれも「比較ベースのソートアルゴリズム」に属し、要素間の大小を比較することで整列を実現します。この種のソートアルゴリズムの時間計算量は $O(n \log n)$ を超えられません。続いて、時間計算量が線形オーダーに達しうる「非比較ソートアルゴリズム」をいくつか見ていきます。
|
||||
|
||||
<u>バケットソート</u>は分割統治戦略の典型的な応用です。一連の順序付けられたバケットを設定し、各バケットがデータの範囲を含み、入力データをこれらのバケットに均等に分散させることで動作します。そして、各バケット内のデータを個別にソートします。最後に、すべてのバケットからのソート済みデータを順次マージして最終結果を生成します。
|
||||
<u>バケットソート(bucket sort)</u>は分割統治戦略の典型的な応用です。大小関係をもつ複数のバケットを用意し、各バケットがあるデータ範囲に対応するようにして、データを各バケットへ均等に分配します。その後、各バケット内でそれぞれソートを行い、最後にバケットの順序に従ってすべてのデータを結合します。
|
||||
|
||||
## アルゴリズムの過程
|
||||
## アルゴリズムの流れ
|
||||
|
||||
長さ $n$ の配列で、$[0, 1)$ の範囲の浮動小数点数を考えてみます。バケットソートの過程は以下の図に示されています。
|
||||
長さ $n$ の配列を考え、その要素は範囲 $[0, 1)$ の浮動小数点数であるとします。バケットソートの流れを以下の図に示します。
|
||||
|
||||
1. $k$ 個のバケットを初期化し、$n$ 個の要素をこれらの $k$ 個のバケットに分散させます。
|
||||
2. 各バケットを個別にソートします(プログラミング言語の組み込みソート関数を使用)。
|
||||
3. 最小から最大のバケットの順序で結果をマージします。
|
||||
1. $k$ 個のバケットを初期化し、$n$ 個の要素を $k$ 個のバケットに分配します。
|
||||
2. 各バケットに対してそれぞれソートを実行します(ここではプログラミング言語の組み込みソート関数を用います)。
|
||||
3. バケットを小さい順にたどって結果を結合します。
|
||||
|
||||

|
||||

|
||||
|
||||
コードは以下の通りです:
|
||||
コードは以下のとおりです:
|
||||
|
||||
```src
|
||||
[file]{bucket_sort}-[class]{}-[func]{bucket_sort}
|
||||
```
|
||||
|
||||
## アルゴリズムの特徴
|
||||
## アルゴリズムの特性
|
||||
|
||||
バケットソートは非常に大きなデータセットの処理に適しています。例えば、入力データに100万個の要素が含まれ、システムメモリの制限によりすべてのデータを同時にロードできない場合、データを1,000個のバケットに分割し、各バケットを個別にソートしてから結果をマージできます。
|
||||
バケットソートは、非常に大規模なデータの処理に適しています。たとえば、入力データに 100 万個の要素が含まれ、空間の制約によりシステムメモリへすべてのデータを一度に読み込めない場合です。このとき、データを 1000 個のバケットに分け、それぞれのバケットを個別にソートしてから、最後に結果を結合できます。
|
||||
|
||||
- **時間計算量は $O(n + k)$**:要素がバケット間で均等に分散されていると仮定すると、各バケット内の要素数は $n/k$ です。単一のバケットのソートに $O(n/k \log(n/k))$ 時間がかかると仮定すると、すべてのバケットのソートに $O(n \log(n/k))$ 時間がかかります。**バケット数 $k$ が比較的大きいとき、時間計算量は $O(n)$ に近づきます**。結果のマージには、すべてのバケットと要素を走査する必要があり、$O(n + k)$ 時間がかかります。最悪の場合、すべてのデータが単一のバケットに分散され、そのバケットのソートには $O(n^2)$ 時間がかかります。
|
||||
- **空間計算量は $O(n + k)$、非インプレースソート**:$k$ 個のバケットと合計 $n$ 個の要素のための追加スペースが必要です。
|
||||
- バケットソートが安定かどうかは、各バケット内で使用されるソートアルゴリズムが安定かどうかに依存します。
|
||||
- **時間計算量は $O(n + k)$** :要素が各バケット内に平均的に分布していると仮定すると、各バケット内の要素数は $\frac{n}{k}$ です。1 つのバケットをソートするのに $O(\frac{n}{k} \log\frac{n}{k})$ の時間がかかるなら、すべてのバケットのソートには $O(n \log\frac{n}{k})$ の時間がかかります。**バケット数 $k$ が十分大きいとき、時間計算量は $O(n)$ に近づきます**。結果を結合する際には、すべてのバケットと要素を走査する必要があり、$O(n + k)$ の時間を要します。最悪の場合、すべてのデータが 1 つのバケットに割り当てられ、そのバケットのソートに $O(n^2)$ の時間がかかります。
|
||||
- **空間計算量は $O(n + k)$、非インプレースソート**:$k$ 個のバケットと合計 $n$ 個の要素ぶんの追加領域が必要です。
|
||||
- バケットソートが安定かどうかは、バケット内要素のソートに用いるアルゴリズムが安定かどうかに依存します。
|
||||
|
||||
## 均等分散を達成する方法
|
||||
## 均等な分配を実現するには
|
||||
|
||||
バケットソートの理論的時間計算量は $O(n)$ に達することができます。**重要なことは、すべてのバケットに要素を均等に分散させることです**。実世界のデータはしばしば均一に分散されていないからです。例えば、eBayのすべての商品を価格範囲で10個のバケットに均等に分散させたいとします。しかし、商品価格の分散は均等でない可能性があり、100ドル未満の商品が多く、500ドル以上の商品が少ないかもしれません。価格範囲を均等に10分割すると、各バケットの商品数の差が大きくなります。
|
||||
バケットソートの時間計算量は理論上 $O(n)$ に達しますが、**鍵は要素を各バケットへ均等に分配すること** にあります。実際のデータは均一に分布していないことが多いからです。たとえば、Taobao 上のすべての商品を価格帯ごとに 10 個のバケットへ均等に分けたいとしても、商品の価格分布は偏っており、100 元未満は非常に多く、1000 元超は非常に少ないかもしれません。価格区間を単純に 10 等分すると、各バケットの商品数には大きな差が生じます。
|
||||
|
||||
均等分散を達成するために、最初におおよその境界を設定して、データを3つのバケットに大まかに分割できます。**分散が完了した後、より多くのアイテムを持つバケットをさらに3つのバケットに分割し、すべてのバケットの要素数がほぼ等しくなるまで続けます**。
|
||||
均等な分配を実現するために、まず大まかな境界線を設定し、データをひとまず 3 個のバケットに粗く振り分けます。**分配後は、商品数の多いバケットをさらに 3 個のバケットに分割し、すべてのバケット内の要素数がおおむね等しくなるまでこれを続けます**。
|
||||
|
||||
以下の図に示すように、この方法は本質的に再帰木を構築し、葉ノードの要素数ができるだけ均等になることを目指します。もちろん、各ラウンドでデータを3つのバケットに分割する必要はありません - 分割戦略はデータの独特な特性に適応的に調整できます。
|
||||
以下の図に示すように、この方法の本質は再帰木を構築することにあり、目標は葉ノードの値をできるだけ均等にすることです。もちろん、毎回データを 3 個のバケットに分割する必要はなく、具体的な分け方はデータの特徴に応じて柔軟に選べます。
|
||||
|
||||

|
||||

|
||||
|
||||
商品価格の確率分布を事前に知っている場合、**データの確率分布に基づいて各バケットの価格境界を設定できます**。データ分布を具体的に計算する必要は必ずしもなく、代わりに確率モデルを使用してデータ特性に基づいて近似できることに注意してください。
|
||||
商品価格の確率分布をあらかじめ把握しているなら、**データの確率分布に基づいて各バケットの価格境界を設定できます**。なお、データ分布は必ずしも特別に統計を取る必要はなく、データの特徴に応じて何らかの確率モデルで近似することもできます。
|
||||
|
||||
以下の図に示すように、商品価格が正規分布に従うと仮定すると、バケット間でアイテムの分散のバランスを取るために合理的な価格区間を定義できます。
|
||||
以下の図に示すように、商品価格が正規分布に従うと仮定すれば、価格区間を合理的に設定でき、それによって商品を各バケットへ均等に分配できます。
|
||||
|
||||

|
||||

|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
# 計数ソート
|
||||
|
||||
<u>計数ソート</u>は要素の数をカウントすることでソートを実現し、通常は整数配列に適用されます。
|
||||
<u>計数ソート(counting sort)</u>は要素数を集計することでソートを実現し、通常は整数配列に適用されます。
|
||||
|
||||
## 簡単な実装
|
||||
## 単純な実装
|
||||
|
||||
簡単な例から始めましょう。長さ $n$ の配列 `nums` が与えられ、すべての要素が「非負整数」である場合、計数ソートの全体的な過程は以下の図に示されています。
|
||||
まず簡単な例を見てみましょう。長さ $n$ の配列 `nums` が与えられ、その要素はすべて「非負整数」であるとします。計数ソートの全体的な流れを以下の図に示します。
|
||||
|
||||
1. 配列を走査して最大数を見つけ、それを $m$ とし、長さ $m + 1$ の補助配列 `counter` を作成します。
|
||||
2. **`counter` を使用して `nums` 内の各数の出現回数をカウントします**。ここで `counter[num]` は数 `num` の出現回数に対応します。カウント方法は簡単で、`nums` を走査し(現在の数を `num` とする)、各ラウンドで `counter[num]` を $1$ 増やします。
|
||||
3. **`counter` のインデックスは自然に順序付けられているため、すべての数は本質的にすでにソートされています**。次に、`counter` を走査し、出現順に `nums` を昇順で埋めます。
|
||||
1. 配列を走査し、その中の最大値を見つけて $m$ とし、続いて長さ $m + 1$ の補助配列 `counter` を作成します。
|
||||
2. **`counter` を用いて `nums` 内の各数値の出現回数を集計します**。ここで `counter[num]` は数値 `num` の出現回数に対応します。集計方法は非常に簡単で、`nums` を走査し(現在の数値を `num` とする)、各回で `counter[num]` を $1$ 増やせばよいです。
|
||||
3. **`counter` の各インデックスは自然に順序づけられているため、すべての数値はすでに整列された状態とみなせます**。続いて `counter` を走査し、各数値の出現回数に応じて小さい順に `nums` へ書き戻せば完了です。
|
||||
|
||||

|
||||

|
||||
|
||||
コードは以下の通りです:
|
||||
コードは以下のとおりです:
|
||||
|
||||
```src
|
||||
[file]{counting_sort}-[class]{}-[func]{counting_sort_naive}
|
||||
@@ -20,27 +20,27 @@
|
||||
|
||||
!!! note "計数ソートとバケットソートの関係"
|
||||
|
||||
バケットソートの観点から、計数ソートにおける計数配列 `counter` の各インデックスをバケットと考え、カウントの過程を要素を対応するバケットに分散させることと考えることができます。本質的に、計数ソートは整数データのためのバケットソートの特別なケースです。
|
||||
バケットソートの観点から見ると、計数ソートにおける計数配列 `counter` の各インデックスを 1 つのバケットとみなし、個数を数える過程を各要素を対応するバケットへ振り分ける操作とみなせます。本質的には、計数ソートは整数データにおけるバケットソートの特殊な一例です。
|
||||
|
||||
## 完全な実装
|
||||
|
||||
注意深い読者は気付くかもしれませんが、**入力データがオブジェクトの場合、上記の手順 `3.` は無効です**。入力データが商品オブジェクトで、価格(クラスメンバ変数)で商品をソートしたいとします。しかし、上記のアルゴリズムは結果としてソート済みの価格のみを提供できます。
|
||||
注意深い読者なら、**入力データがオブジェクトである場合、上記の手順 `3.` は機能しない**ことに気づくかもしれません。入力データが商品オブジェクトであり、商品価格(クラスのメンバ変数)に基づいて商品をソートしたいとします。しかし上記のアルゴリズムが返せるのは価格のソート結果だけです。
|
||||
|
||||
では、元のデータのソート結果をどのように取得できるでしょうか?まず、`counter` の「前置和」を計算します。名前が示すように、インデックス `i` での前置和 `prefix[i]` は、配列の最初の `i` 個の要素の和に等しいです:
|
||||
では、元のデータのソート結果を得るにはどうすればよいのでしょうか。まず `counter` の「累積和」を計算します。名前のとおり、インデックス `i` における累積和 `prefix[i]` は、配列の先頭から `i` 番目までの要素の総和に等しくなります:
|
||||
|
||||
$$
|
||||
\text{prefix}[i] = \sum_{j=0}^i \text{counter[j]}
|
||||
$$
|
||||
|
||||
**前置和には明確な意味があります。`prefix[num] - 1` は結果配列 `res` における要素 `num` の最後の出現のインデックスを表します**。この情報は重要で、各要素が結果配列のどこに現れるべきかを教えてくれます。次に、元の配列 `nums` の各要素 `num` を逆順で走査し、各反復で以下の2つの手順を実行します。
|
||||
**累積和には明確な意味があり、`prefix[num] - 1` は要素 `num` が結果配列 `res` に最後に現れるインデックスを表します**。この情報は非常に重要で、各要素が結果配列のどの位置に現れるべきかを示してくれます。続いて元の配列 `nums` を逆順に走査し、各要素 `num` に対して各反復で次の 2 つの手順を行います。
|
||||
|
||||
1. インデックス `prefix[num] - 1` で配列 `res` に `num` を埋めます。
|
||||
2. 前置和 `prefix[num]` を $1$ 減らして、`num` を配置する次のインデックスを取得します。
|
||||
1. `num` を配列 `res` のインデックス `prefix[num] - 1` に格納します。
|
||||
2. 累積和 `prefix[num]` を $1$ 減らし、次に `num` を配置するインデックスを得ます。
|
||||
|
||||
走査後、配列 `res` にはソートされた結果が含まれ、最後に `res` が元の配列 `nums` を置き換えます。完全な計数ソートの過程は以下の図に示されています。
|
||||
走査が完了すると、配列 `res` にソート済みの結果が格納されます。最後に `res` で元の配列 `nums` を上書きすれば完了です。以下の図は完全な計数ソートの流れを示しています。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -63,22 +63,22 @@ $$
|
||||
=== "<8>"
|
||||

|
||||
|
||||
計数ソートの実装コードは以下の通りです:
|
||||
計数ソートの実装コードは以下のとおりです:
|
||||
|
||||
```src
|
||||
[file]{counting_sort}-[class]{}-[func]{counting_sort}
|
||||
```
|
||||
|
||||
## アルゴリズムの特徴
|
||||
## アルゴリズムの特性
|
||||
|
||||
- **時間計算量は $O(n + m)$、非適応ソート**:`nums` と `counter` の走査が含まれ、どちらも線形時間を使用します。一般的に、$n \gg m$ であり、時間計算量は $O(n)$ に近づきます。
|
||||
- **空間計算量は $O(n + m)$、非インプレースソート**:長さ $n$ の配列 `res` と長さ $m$ の配列 `counter` をそれぞれ使用します。
|
||||
- **安定ソート**:要素が「右から左」の順序で `res` に埋められるため、`nums` の走査を逆順にすることで、等しい要素間の相対位置の変化を防ぎ、安定したソートを実現できます。実際、`nums` を順番に走査しても正しいソート結果を生成できますが、結果は不安定です。
|
||||
- **時間計算量は $O(n + m)$、非適応ソート** :`nums` の走査と `counter` の走査が含まれ、いずれも線形時間です。一般には $n \gg m$ であり、時間計算量は $O(n)$ に近づきます。
|
||||
- **空間計算量は $O(n + m)$、非インプレースソート**:長さがそれぞれ $n$ と $m$ の配列 `res` と `counter` を利用します。
|
||||
- **安定ソート**:`res` に要素を埋める順序が「右から左」であるため、`nums` を逆順に走査することで等しい要素どうしの相対位置が変化するのを防ぎ、安定ソートを実現できます。実際には、`nums` を順方向に走査しても正しいソート結果は得られますが、その結果は安定ではありません。
|
||||
|
||||
## 制限事項
|
||||
## 制約
|
||||
|
||||
今までに、計数ソートは非常に巧妙だと感じるかもしれません。単に量をカウントするだけで効率的なソートを実現できるからです。しかし、計数ソートを使用するための前提条件は比較的厳しいです。
|
||||
ここまで読むと、計数ソートは非常に巧妙で、個数を数えるだけで効率的なソートを実現できると感じるかもしれません。しかし、計数ソートを利用するための前提条件は比較的厳格です。
|
||||
|
||||
**計数ソートは非負整数にのみ適用できます**。他のタイプのデータに適用したい場合、これらのデータが要素の元の順序を変更することなく非負整数に変換できることを保証する必要があります。例えば、負の整数を含む配列の場合、最初にすべての数に定数を加えて、すべてを正の数に変換し、ソート完了後に元に戻すことができます。
|
||||
**計数ソートは非負整数にしか適用できません**。ほかの型のデータに適用したい場合は、それらのデータを非負整数に変換でき、かつ変換の過程で要素間の相対的な大小関係が変わらないことを保証する必要があります。たとえば、負数を含む整数配列に対しては、すべての数値に定数を加えて正数へ変換し、ソート後に元へ戻すことができます。
|
||||
|
||||
**計数ソートは値の範囲が小さい大きなデータセットに適しています**。例えば、上記の例では、$m$ は大きすぎるべきではありません。そうでなければ、あまりにも多くのスペースを占有してしまいます。そして $n \ll m$ の場合、計数ソートは $O(m)$ 時間を使用し、$O(n \log n)$ ソートアルゴリズムより遅い可能性があります。
|
||||
**計数ソートはデータ量が多く、値域が小さい場合に適しています**。たとえば上記の例では $m$ が大きすぎてはならず、そうでないと過剰な空間を消費します。また、$n \ll m$ のとき、計数ソートは $O(m)$ 時間を要するため、$O(n \log n)$ のソートアルゴリズムより遅くなる可能性があります。
|
||||
|
||||
@@ -2,30 +2,30 @@
|
||||
|
||||
!!! tip
|
||||
|
||||
この節を読む前に、「ヒープ」の章を必ず完了させてください。
|
||||
本節を読む前に、「ヒープ」の章を学習済みであることを確認してください。
|
||||
|
||||
<u>ヒープソート</u>は、ヒープデータ構造に基づく効率的なソートアルゴリズムです。すでに学習した「ヒープの構築」と「要素の抽出」操作を使用してヒープソートを実装できます。
|
||||
<u>ヒープソート(heap sort)</u>は、ヒープデータ構造に基づいて実装される効率的なソートアルゴリズムです。すでに学んだ「ヒープ構築操作」と「要素の取り出し操作」を利用してヒープソートを実現できます。
|
||||
|
||||
1. 配列を入力し、最小ヒープを構築します。ここで、最小要素がヒープの頂上に位置します。
|
||||
2. 継続的に抽出操作を実行し、抽出された要素を順次記録して、最小から最大までのソート済みリストを取得します。
|
||||
1. 配列を入力して最小ヒープを構築すると、このとき最小要素はヒープの頂点にあります。
|
||||
2. 取り出し操作を繰り返し実行し、取り出された要素を順に記録すれば、昇順に並んだ列が得られます。
|
||||
|
||||
上記の方法は実現可能ですが、ポップされた要素を格納するための追加の配列が必要で、やや空間を消費します。実際には、通常、より優雅な実装を使用します。
|
||||
以上の方法でも実行できますが、取り出した要素を保存するために追加の配列が必要となり、空間をやや無駄にします。実際には、通常はより洗練された実装方法を用います。
|
||||
|
||||
## アルゴリズムの流れ
|
||||
|
||||
配列の長さを $n$ とすると、ヒープソートの過程は以下の通りです。
|
||||
配列の長さを $n$ とすると、ヒープソートの流れは次図のとおりです。
|
||||
|
||||
1. 配列を入力し、最大ヒープを構築します。この手順の後、最大要素がヒープの頂上に位置します。
|
||||
2. ヒープの頂上要素(最初の要素)とヒープの底部要素(最後の要素)を交換します。この交換の後、ヒープの長さを $1$ 減らし、ソート済み要素の数を $1$ 増やします。
|
||||
3. ヒープの頂上から開始して、上から下へのsift-down操作を実行します。sift-downの後、ヒープの性質が復元されます。
|
||||
4. 手順 `2.` と `3.` を繰り返します。$n - 1$ ラウンドループして、配列のソートを完了します。
|
||||
1. 配列を入力して最大ヒープを構築します。完了後、最大要素はヒープの頂点にあります。
|
||||
2. ヒープ頂点の要素(最初の要素)とヒープ末尾の要素(最後の要素)を交換します。交換後、ヒープの長さは $1$ 減少し、整列済み要素数は $1$ 増加します。
|
||||
3. ヒープ頂点の要素から始めて、上から下へヒープ化操作(sift down)を実行します。ヒープ化が完了すると、ヒープの性質が回復します。
|
||||
4. 第 `2.` ステップと第 `3.` ステップを繰り返し実行します。これを $n - 1$ 回繰り返すと、配列の整列が完了します。
|
||||
|
||||
!!! tip
|
||||
|
||||
実際、要素抽出操作も手順 `2.` と `3.` を含み、抽出された要素をヒープから削除する追加の手順があります。
|
||||
実際には、要素の取り出し操作にも第 `2.` ステップと第 `3.` ステップが含まれており、要素を取り出す処理が 1 つ加わるだけです。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -60,14 +60,14 @@
|
||||
=== "<12>"
|
||||

|
||||
|
||||
コードの実装では、「ヒープ」の章からのsift-down関数 `sift_down()` を使用しました。最大要素が抽出されるにつれてヒープの長さが減少するため、`sift_down()` 関数に長さパラメータ $n$ を追加して、ヒープの現在の有効長を指定する必要があることに注意することが重要です。コードは以下の通りです:
|
||||
コード実装では、「ヒープ」の章と同じ上から下へのヒープ化 `sift_down()` 関数を使用します。注意すべき点として、ヒープの長さは最大要素を取り出すたびに短くなるため、`sift_down()` 関数に長さパラメータ $n$ を追加し、ヒープの現在の有効な長さを指定する必要があります。コードは以下のとおりです。
|
||||
|
||||
```src
|
||||
[file]{heap_sort}-[class]{}-[func]{heap_sort}
|
||||
```
|
||||
|
||||
## アルゴリズムの特徴
|
||||
## アルゴリズムの特性
|
||||
|
||||
- **時間計算量は $O(n \log n)$、非適応ソート**:ヒープの構築は $O(n)$ 時間を使用します。ヒープから最大要素を抽出するには $O(\log n)$ 時間がかかり、$n - 1$ ラウンドループします。
|
||||
- **空間計算量は $O(1)$、インプレースソート**:いくつかのポインタ変数が $O(1)$ 空間を使用します。要素の交換とヒープ化操作は元の配列で実行されます。
|
||||
- **非安定ソート**:ヒープの頂上と底部要素の交換中に、等しい要素の相対位置が変わる可能性があります。
|
||||
- **時間計算量は $O(n \log n)$、非適応ソート**:ヒープ構築操作には $O(n)$ の時間がかかります。ヒープから最大要素を取り出す時間計算量は $O(\log n)$ であり、これを合計 $n - 1$ 回繰り返します。
|
||||
- **空間計算量は $O(1)$、インプレースソート**:いくつかのポインタ変数が使う空間は $O(1)$ です。要素の交換とヒープ化操作はいずれも元の配列上で行われます。
|
||||
- **非安定ソート**:ヒープ頂点の要素とヒープ末尾の要素を交換する際、等しい要素どうしの相対位置が変化する可能性があります。
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# ソート
|
||||
|
||||

|
||||

|
||||
|
||||
!!! abstract
|
||||
|
||||
ソートは混沌を秩序に変える魔法の鍵のようなもので、データをより効率的に理解し処理することを可能にします。
|
||||
ソートは、混沌を秩序へと変える魔法の鍵のようなものであり、データをより効率的に理解し処理することを可能にします。
|
||||
|
||||
単純な昇順であろうと複雑なカテゴリ配列であろうと、ソートはデータの調和美を明らかにします。
|
||||
単純な昇順であれ、複雑な分類配列であれ、ソートはデータの調和のとれた美しさを私たちに示してくれます。
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
# 挿入ソート
|
||||
|
||||
<u>挿入ソート</u>は、トランプのデッキを手動でソートするプロセスによく似た動作をするシンプルなソートアルゴリズムです。
|
||||
<u>挿入ソート(insertion sort)</u>は単純なソートアルゴリズムであり、その動作原理は手作業でトランプの山を整える過程と非常によく似ています。
|
||||
|
||||
具体的には、未ソート区間からベース要素を選択し、その左側のソート済み区間の要素と比較して、要素を正しい位置に挿入します。
|
||||
具体的には、未ソート区間から基準要素を 1 つ選び、その要素を左側の整列済み区間の要素と 1 つずつ比較し、正しい位置に挿入します。
|
||||
|
||||
下図は、要素が配列に挿入される方法を示しています。ベース要素を`base`とすると、ターゲットインデックスから`base`までのすべての要素を右に1つずつシフトし、その後`base`をターゲットインデックスに割り当てる必要があります。
|
||||
以下の図は、配列に要素を挿入する操作の流れを示しています。基準要素を `base` とすると、目的のインデックスから `base` までのすべての要素を 1 つずつ右に移動し、その後 `base` を目的のインデックスに代入する必要があります。
|
||||
|
||||

|
||||

|
||||
|
||||
## アルゴリズムプロセス
|
||||
## アルゴリズムの流れ
|
||||
|
||||
挿入ソートの全体的なプロセスは下図に示されます。
|
||||
挿入ソート全体の流れを以下の図に示します。
|
||||
|
||||
1. 配列の最初の要素をソート済みとみなします。
|
||||
2. 2番目の要素を`base`として選択し、正しい位置に挿入して、**最初の2つの要素をソート済みにします**。
|
||||
3. 3番目の要素を`base`として選択し、正しい位置に挿入して、**最初の3つの要素をソート済みにします**。
|
||||
4. この方法で続行し、最後の反復では、最後の要素を`base`として取り、正しい位置に挿入した後、**すべての要素がソートされます**。
|
||||
1. 初期状態では、配列の 1 番目の要素はすでに整列済みです。
|
||||
2. 配列の 2 番目の要素を `base` として選び、正しい位置に挿入すると、**配列の先頭 2 要素が整列済み**になります。
|
||||
3. 3 番目の要素を `base` として選び、正しい位置に挿入すると、**配列の先頭 3 要素が整列済み**になります。
|
||||
4. このように繰り返し、最後のラウンドで最後の要素を `base` として選んで正しい位置に挿入すると、**すべての要素が整列済み**になります。
|
||||
|
||||

|
||||

|
||||
|
||||
コード例は以下の通りです:
|
||||
コード例は以下のとおりです。
|
||||
|
||||
```src
|
||||
[file]{insertion_sort}-[class]{}-[func]{insertion_sort}
|
||||
```
|
||||
|
||||
## アルゴリズムの特性
|
||||
## アルゴリズムの特徴
|
||||
|
||||
- **時間計算量は$O(n^2)$、適応ソート**:最悪の場合、各挿入操作には$n - 1$、$n-2$、...、$2$、$1$のループが必要で、合計は$(n - 1) n / 2$となり、時間計算量は$O(n^2)$です。順序付きデータの場合、挿入操作は早期に終了します。入力配列が完全に順序付けられている場合、挿入ソートは最良時間計算量$O(n)$を実現します。
|
||||
- **空間計算量は$O(1)$、インプレースソート**:ポインタ$i$と$j$は定数量の追加空間を使用します。
|
||||
- **安定ソート**:挿入操作中、等しい要素の右側に要素を挿入し、順序を変更しません。
|
||||
- **計算量は $O(n^2)$、適応的ソート**:最悪の場合、各挿入操作ではそれぞれ $n - 1$、$n-2$、$\dots$、$2$、$1$ 回のループが必要であり、合計は $(n - 1) n / 2$ となるため、時間計算量は $O(n^2)$ です。データが整列済みであれば、挿入操作は早期に終了します。入力配列が完全に整列済みである場合、挿入ソートは最良の時間計算量 $O(n)$ に達します。
|
||||
- **空間計算量は $O(1)$、インプレースソート**:ポインタ $i$ と $j$ は定数サイズの追加領域しか使用しません。
|
||||
- **安定ソート**:挿入操作の過程では、要素を等しい要素の右側に挿入するため、それらの順序は変化しません。
|
||||
|
||||
## 挿入ソートの利点
|
||||
|
||||
挿入ソートの時間計算量は$O(n^2)$で、次に学習するクイックソートの時間計算量は$O(n \log n)$です。挿入ソートはより高い時間計算量を持ちますが、**小さな入力サイズでは通常より高速です**。
|
||||
挿入ソートの時間計算量は $O(n^2)$ であり、これから学ぶクイックソートの時間計算量は $O(n \log n)$ です。挿入ソートの時間計算量のほうが大きいにもかかわらず、**データ量が小さい場合には、挿入ソートのほうが通常は高速**です。
|
||||
|
||||
この結論は線形探索と二分探索の結論と似ています。時間計算量が$O(n \log n)$で分割統治戦略に基づくクイックソートなどのアルゴリズムは、多くの場合より多くの単位操作を含みます。小さな入力サイズでは、$n^2$と$n \log n$の数値は近く、計算量が支配的でなく、ラウンドあたりの単位操作数が決定的な役割を果たします。
|
||||
この結論は、線形探索と二分探索の適用条件に関する結論と似ています。クイックソートのような $O(n \log n)$ のアルゴリズムは分割統治法に基づくソートアルゴリズムであり、一般により多くの基本演算を含みます。一方、データ量が小さい場合は、$n^2$ と $n \log n$ の値は比較的近く、計算量が支配的ではなくなり、各ラウンドにおける基本演算の回数が決定的な役割を果たします。
|
||||
|
||||
実際、多くのプログラミング言語(Javaなど)は、組み込みソート関数内で挿入ソートを使用しています。一般的なアプローチは:長い配列に対しては、クイックソートなどの分割統治戦略に基づくソートアルゴリズムを使用し、短い配列に対しては挿入ソートを直接使用します。
|
||||
実際、多くのプログラミング言語(たとえば Java)の組み込みソート関数では挿入ソートが採用されており、その大まかな考え方は次のとおりです。長い配列にはクイックソートなどの分割統治法に基づくソートアルゴリズムを使い、短い配列には直接挿入ソートを使います。
|
||||
|
||||
バブルソート、選択ソート、挿入ソートはすべて時間計算量$O(n^2)$を持ちますが、実際には、**挿入ソートはバブルソートや選択ソートよりも一般的に使用されます**。主な理由は以下の通りです。
|
||||
バブルソート、選択ソート、挿入ソートはいずれも時間計算量が $O(n^2)$ ですが、実際には、**挿入ソートはバブルソートや選択ソートよりもはるかに高い頻度で使われます**。主な理由は次のとおりです。
|
||||
|
||||
- バブルソートは要素交換に基づき、一時変数の使用が必要で、3つの単位操作を含みます;挿入ソートは要素代入に基づき、1つの単位操作のみが必要です。したがって、**バブルソートの計算オーバーヘッドは一般的に挿入ソートよりも高くなります**。
|
||||
- 選択ソートの時間計算量は常に$O(n^2)$です。**部分的に順序付けられたデータのセットが与えられた場合、挿入ソートは通常選択ソートよりも効率的です**。
|
||||
- 選択ソートは不安定で、マルチレベルソートに適用できません。
|
||||
- バブルソートは要素の交換によって実装され、1 つの一時変数を必要とするため、合計で 3 回の基本演算が関わります。これに対して、挿入ソートは要素の代入に基づいており、必要な基本演算は 1 回だけです。したがって、**バブルソートの計算コストは通常、挿入ソートより高くなります**。
|
||||
- 選択ソートの時間計算量はどのような場合でも $O(n^2)$ です。**部分的に整列されたデータが与えられた場合、挿入ソートは通常、選択ソートより効率的**です。
|
||||
- 選択ソートは安定ではないため、多段ソートには適用できません。
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# マージソート
|
||||
|
||||
<u>マージソート</u>は分割統治戦略に基づくソートアルゴリズムで、下図に示す「分割」と「マージ」フェーズを含みます。
|
||||
<u>マージソート(merge sort)</u>は分割統治戦略に基づくソートアルゴリズムであり、以下の図に示す「分割」と「マージ」の段階から構成されます。
|
||||
|
||||
1. **分割フェーズ**:中点から配列を再帰的に分割し、長い配列のソート問題をより短い配列に変換します。
|
||||
2. **マージフェーズ**:サブ配列の長さが1になったときに分割を停止し、その後マージを開始します。2つの短いソート済み配列を連続的により長いソート済み配列にマージし、プロセスが完了するまで続行します。
|
||||
1. **分割段階**:再帰によって配列を中点で繰り返し分割し、長い配列のソート問題を短い配列のソート問題へ変換します。
|
||||
2. **マージ段階**:部分配列の長さが 1 になったら分割を終了し、マージを開始して、左右 2 つの短いソート済み配列をより長いソート済み配列へと繰り返しマージしていきます。
|
||||
|
||||

|
||||

|
||||
|
||||
## アルゴリズムワークフロー
|
||||
## アルゴリズムの流れ
|
||||
|
||||
下図に示すように、「分割フェーズ」は中点から配列を上から下に2つのサブ配列に再帰的に分割します。
|
||||
以下の図に示すように、「分割段階」では配列を上から下へ再帰的に中点で 2 つの部分配列へ分割します。
|
||||
|
||||
1. 中点`mid`を計算し、左サブ配列(区間`[left, mid]`)と右サブ配列(区間`[mid + 1, right]`)を再帰的に分割します。
|
||||
2. サブ配列の長さが1になるまでステップ`1.`を再帰的に続行し、その後停止します。
|
||||
1. 配列の中点 `mid` を計算し、左部分配列(区間 `[left, mid]` )と右部分配列(区間 `[mid + 1, right]` )を再帰的に分割します。
|
||||
2. 手順 `1.` を再帰的に実行し、部分配列区間の長さが 1 になった時点で終了します。
|
||||
|
||||
「マージフェーズ」は左と右のサブ配列を下から上にソート済み配列に結合します。重要なのは、マージが長さ1のサブ配列から開始され、マージフェーズ中に各サブ配列がソートされることです。
|
||||
「マージ段階」では左部分配列と右部分配列を下から上へとマージし、1 つのソート済み配列にします。長さ 1 の部分配列からマージを始めるため、この段階の各部分配列はすでに整列されています。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -46,12 +46,12 @@
|
||||
=== "<10>"
|
||||

|
||||
|
||||
マージソートの再帰順序は二分木の後順横断と一致することが観察できます。
|
||||
観察すると、マージソートの再帰順序は二分木の後順走査と一致していることがわかります。
|
||||
|
||||
- **後順横断**:まず左のサブツリーを再帰的に横断し、次に右のサブツリーを横断し、最後にルートノードを処理します。
|
||||
- **マージソート**:まず左のサブ配列を再帰的に処理し、次に右のサブ配列を処理し、最後にマージを実行します。
|
||||
- **後順走査**:まず左部分木を再帰し、次に右部分木を再帰し、最後に根ノードを処理します。
|
||||
- **マージソート**:まず左部分配列を再帰し、次に右部分配列を再帰し、最後にマージを処理します。
|
||||
|
||||
マージソートの実装は以下のコードに示されます。`nums`でマージされる区間は`[left, right]`で、`tmp`の対応する区間は`[0, right - left]`であることに注意してください。
|
||||
マージソートの実装を以下のコードに示します。注意として、`nums` のマージ対象区間は `[left, right]` であり、`tmp` の対応区間は `[0, right - left]` です。
|
||||
|
||||
```src
|
||||
[file]{merge_sort}-[class]{}-[func]{merge_sort}
|
||||
@@ -59,15 +59,15 @@
|
||||
|
||||
## アルゴリズムの特性
|
||||
|
||||
- **$O(n \log n)$の時間計算量、非適応ソート**:分割により高さ$\log n$の再帰ツリーが作成され、各層で合計$n$回の操作をマージし、全体的な時間計算量は$O(n \log n)$となります。
|
||||
- **$O(n)$の空間計算量、非インプレースソート**:再帰深度は$\log n$で、$O(\log n)$のスタックフレーム空間を使用します。マージ操作には補助配列が必要で、追加の$O(n)$空間を使用します。
|
||||
- **安定ソート**:マージプロセス中、等しい要素の順序は変更されません。
|
||||
- **時間計算量は $O(n \log n)$、非適応型ソート**:分割によって高さ $\log n$ の再帰木が生成され、各層でのマージ操作の総数は $n$ であるため、全体の時間計算量は $O(n \log n)$ です。
|
||||
- **空間計算量は $O(n)$、インプレースではないソート**:再帰の深さは $\log n$ であり、サイズ $O(\log n)$ のスタックフレーム領域を使用します。マージ操作は補助配列を用いて実装する必要があり、サイズ $O(n)$ の追加領域を使用します。
|
||||
- **安定ソート**:マージの過程では、等しい要素の順序は変化しません。
|
||||
|
||||
## 連結リストのソート
|
||||
|
||||
連結リストの場合、マージソートは他のソートアルゴリズムよりも大きな利点があります。**連結リストソートタスクの空間計算量を$O(1)$に最適化できます**。
|
||||
連結リストに対しては、マージソートは他のソートアルゴリズムと比べて顕著な利点があり、**連結リストのソート問題の空間計算量を $O(1)$ まで最適化できます** 。
|
||||
|
||||
- **分割フェーズ**:「再帰」の代わりに「反復」を使用して連結リスト分割作業を実行できるため、再帰で使用されるスタックフレーム空間を節約できます。
|
||||
- **マージフェーズ**:連結リストでは、ノードの挿入と削除操作は参照(ポインタ)を変更することで実現できるため、マージフェーズ(2つの短い順序付きリストを1つの長い順序付きリストに結合)中に追加のリストを作成する必要がありません。
|
||||
- **分割段階**:連結リストの分割は「再帰」の代わりに「反復」で実装できるため、再帰で使用するスタックフレーム領域を省けます。
|
||||
- **マージ段階**:連結リストでは、ノードの追加や削除は参照(ポインタ)を変更するだけで実現できるため、マージ段階(2 つの短いソート済み連結リストを 1 つの長いソート済み連結リストにマージすること)では追加の連結リストを作成する必要がありません。
|
||||
|
||||
実装の詳細は比較的複雑で、興味のある読者は関連資料を参照して学習してください。
|
||||
具体的な実装の詳細は比較的複雑なので、興味のある読者は関連資料を参照して学習してください。
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# クイックソート
|
||||
|
||||
<u>クイックソート</u>は分割統治戦略に基づくソートアルゴリズムで、その効率性と幅広い応用で知られています。
|
||||
<u>クイックソート(quick sort)</u>は分割統治戦略に基づくソートアルゴリズムであり、実行効率が高く、広く利用されています。
|
||||
|
||||
クイックソートのコア操作は「ピボット分割」で、配列から要素を「ピボット」として選択し、ピボットより小さいすべての要素をその左側に移動し、ピボットより大きいすべての要素をその右側に移動することを目的としています。具体的に、ピボット分割のプロセスは下図に示されます。
|
||||
クイックソートの中核操作は「パーティション」であり、その目的は、配列内のある要素を「基準数」として選び、基準数より小さいすべての要素を左側へ、大きい要素を右側へ移動することです。具体的には、パーティションの流れを下図に示します。
|
||||
|
||||
1. 配列の最も左の要素をピボットとして選択し、2つのポインタ`i`と`j`を初期化して配列の両端をそれぞれ指すようにします。
|
||||
2. 各ラウンドで`i`(`j`)を使用してピボットより大きい(小さい)最初の要素を探索し、次にこれら2つの要素を交換するループを設定します。
|
||||
3. `i`と`j`が出会うまでステップ`2.`を繰り返し、最後にピボットを2つのサブ配列の境界に交換します。
|
||||
1. 配列の最左端の要素を基準数として選び、2 つのポインタ `i` と `j` を初期化して、それぞれ配列の両端を指すようにします。
|
||||
2. ループを設定し、各ラウンドで `i`(`j`)を使ってそれぞれ基準数より大きい(小さい)最初の要素を探し、その後この 2 つの要素を交換します。
|
||||
3. `i` と `j` が出会うまでステップ `2.` を繰り返し、最後に基準数を 2 つの部分配列の境界へ交換します。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -35,66 +35,65 @@
|
||||
=== "<9>"
|
||||

|
||||
|
||||
ピボット分割後、元の配列は3つの部分に分割されます:左サブ配列、ピボット、右サブ配列で、「左サブ配列の任意の要素 $\leq$ ピボット $\leq$ 右サブ配列の任意の要素」を満たします。したがって、これら2つのサブ配列のみをソートすればよいのです。
|
||||
パーティションが完了すると、元の配列は左部分配列、基準数、右部分配列の 3 つに分けられ、「左部分配列の任意の要素 $\leq$ 基準数 $\leq$ 右部分配列の任意の要素」を満たします。したがって、次はこの 2 つの部分配列だけをソートすれば済みます。
|
||||
|
||||
!!! note "クイックソートの分割統治戦略"
|
||||
|
||||
ピボット分割の本質は、より長い配列のソート問題をより短い2つの配列に簡素化することです。
|
||||
パーティションの本質は、長い配列のソート問題を 2 つの短い配列のソート問題へ簡略化することです。
|
||||
|
||||
```src
|
||||
[file]{quick_sort}-[class]{quick_sort}-[func]{partition}
|
||||
```
|
||||
|
||||
## アルゴリズムプロセス
|
||||
## アルゴリズムの流れ
|
||||
|
||||
クイックソートの全体的なプロセスは下図に示されます。
|
||||
クイックソート全体の流れを下図に示します。
|
||||
|
||||
1. まず、元の配列に対して「ピボット分割」を実行し、未ソートの左と右のサブ配列を取得します。
|
||||
2. 次に、左と右のサブ配列に対してそれぞれ再帰的に「ピボット分割」を実行します。
|
||||
3. サブ配列の長さが1になるまで再帰を続け、配列全体のソートを完了します。
|
||||
1. まず、元の配列に対して 1 回「パーティション」を実行し、未ソートの左部分配列と右部分配列を得ます。
|
||||
2. 次に、左部分配列と右部分配列に対してそれぞれ再帰的に「パーティション」を実行します。
|
||||
3. 部分配列の長さが 1 になるまで再帰を続け、配列全体のソートを完了します。
|
||||
|
||||

|
||||

|
||||
|
||||
```src
|
||||
[file]{quick_sort}-[class]{quick_sort}-[func]{quick_sort}
|
||||
```
|
||||
|
||||
## アルゴリズムの特徴
|
||||
## アルゴリズムの特性
|
||||
|
||||
- **$O(n \log n)$の時間計算量、非適応ソート**:平均的なケースでは、ピボット分割の再帰レベルは$\log n$で、レベルあたりのループの総数は$n$であり、全体で$O(n \log n)$の時間を使用します。最悪の場合、各ラウンドのピボット分割は長さ$n$の配列を長さ$0$と$n - 1$の2つのサブ配列に分割し、再帰レベル数が$n$に達すると、各レベルのループ数は$n$で、使用される総時間は$O(n^2)$です。
|
||||
- **$O(n)$の空間計算量、インプレースソート**:入力配列が完全に逆順の場合、最悪の再帰深度は$n$に達し、$O(n)$のスタックフレーム空間を使用します。ソート操作は追加の配列の助けなしに元の配列で実行されます。
|
||||
- **非安定ソート**:ピボット分割の最終ステップで、ピボットは等しい要素の右側に交換される可能性があります。
|
||||
- **時間計算量は $O(n \log n)$、非適応型ソート**:平均的な場合、パーティションの再帰の深さは $\log n$ で、各層の総ループ回数は $n$ のため、全体で $O(n \log n)$ 時間を要します。最悪の場合、各回のパーティション操作で長さ $n$ の配列が長さ $0$ と $n - 1$ の 2 つの部分配列に分割され、このとき再帰の深さは $n$ に達し、各層のループ回数は $n$ となるため、全体で $O(n^2)$ 時間を要します。
|
||||
- **空間計算量は $O(n)$、インプレースソート**:入力配列が完全な逆順の場合、最悪の再帰深さ $n$ に達し、$O(n)$ のスタックフレーム空間を使用します。ソート操作は元の配列上で行われ、追加の配列は用いません。
|
||||
- **非安定ソート**:パーティションの最後のステップで、基準数が等しい要素の右側へ交換される可能性があります。
|
||||
|
||||
## なぜクイックソートは高速なのか
|
||||
## クイックソートが速い理由
|
||||
|
||||
名前が示すように、クイックソートは効率性の面で一定の利点を持つべきです。クイックソートの平均時間計算量は「マージソート」や「ヒープソート」と同じですが、以下の理由で一般的により効率的です。
|
||||
名前からも分かるように、クイックソートは効率面で一定の優位性を持っています。クイックソートの平均時間計算量は「マージソート」や「ヒープソート」と同じですが、通常はクイックソートのほうが高効率であり、主な理由は次のとおりです。
|
||||
|
||||
- **最悪ケースシナリオの低い確率**:クイックソートの最悪時間計算量は$O(n^2)$で、マージソートほど安定していませんが、ほとんどの場合、クイックソートは$O(n \log n)$の時間計算量で動作できます。
|
||||
- **高いキャッシュ利用率**:ピボット分割操作中、システムはサブ配列全体をキャッシュにロードできるため、要素により効率的にアクセスできます。対照的に、「ヒープソート」などのアルゴリズムは要素にジャンプ方式でアクセスする必要があり、この特徴を欠いています。
|
||||
- **計算量の小さな定数係数**:上記3つのアルゴリズムの中で、クイックソートは比較、代入、交換などの操作の総数が最も少ないです。これは「挿入ソート」が「バブルソート」よりも高速な理由と似ています。
|
||||
- **最悪ケースが起こる確率が低い**:クイックソートの最悪時間計算量は $O(n^2)$ で、「マージソート」ほど安定ではありませんが、大半のケースでは $O(n \log n)$ の時間計算量で動作します。
|
||||
- **キャッシュ利用効率が高い**:パーティション操作の実行時には、システムが部分配列全体をキャッシュに読み込めるため、要素アクセスの効率が高くなります。一方、「ヒープソート」のようなアルゴリズムは要素へ飛び飛びにアクセスする必要があり、この性質を持ちません。
|
||||
- **計算量の定数係数が小さい**:上記 3 つのアルゴリズムの中で、クイックソートは比較、代入、交換などの操作総数が最も少なくなります。これは「挿入ソート」が「バブルソート」より速い理由と似ています。
|
||||
|
||||
## ピボット最適化
|
||||
## 基準数の最適化
|
||||
|
||||
**クイックソートの時間効率は特定の入力で劣化する可能性があります**。例えば、入力配列が完全に逆順の場合、最も左の要素をピボットとして選択するため、ピボット分割後、ピボットは配列の右端に交換され、左サブ配列の長さが$n - 1$、右サブ配列の長さが$0$になります。この方法を続けると、各ラウンドのピボット分割でサブ配列の長さが$0$になり、分割統治戦略が失敗し、クイックソートは「バブルソート」に似た形に劣化します。
|
||||
**クイックソートは、入力によっては時間効率が低下する可能性があります**。極端な例として、入力配列が完全な逆順であるとします。最左端の要素を基準数として選ぶため、パーティション完了後には基準数が配列の最右端へ交換され、左部分配列の長さが $n - 1$、右部分配列の長さが $0$ になります。この再帰を続けると、各回のパーティション後に必ず一方の部分配列の長さが $0$ となり、分割統治戦略が機能せず、クイックソートは「バブルソート」に近い形へ退化します。
|
||||
|
||||
この状況を避けるため、**ピボット分割でピボット選択戦略を最適化できます**。例えば、要素をランダムに選択してピボットとすることができます。ただし、運が悪く、一貫して最適でないピボットを選択した場合、効率はまだ満足できません。
|
||||
この状況をできるだけ避けるため、**パーティションにおける基準数の選び方を最適化できます**。たとえば、ランダムに 1 つの要素を選んで基準数にできます。しかし、運が悪く毎回望ましくない基準数を選んでしまうと、効率は依然として十分ではありません。
|
||||
|
||||
プログラミング言語は通常「疑似乱数」を生成することに注意することが重要です。疑似乱数シーケンスに対して特定のテストケースを構築すると、クイックソートの効率はまだ劣化する可能性があります。
|
||||
注意すべき点として、プログラミング言語が通常生成するのは「疑似乱数」です。疑似乱数列に合わせて特定のテストケースを構築すると、クイックソートの効率はやはり劣化する可能性があります。
|
||||
|
||||
さらなる改善のため、3つの候補要素(通常は配列の最初、最後、中点の要素)を選択し、**これら3つの候補要素の中央値をピボットとして使用**できます。この方法で、ピボットが「小さすぎず大きすぎない」確率が大幅に増加します。もちろん、さらに多くの候補要素を選択してアルゴリズムの堅牢性をさらに向上させることもできます。この方法により、時間計算量が$O(n^2)$に劣化する確率が大幅に削減されます。
|
||||
さらに改善するために、配列から 3 つの候補要素(通常は先頭、末尾、中間の要素)を選び、**その 3 つの候補要素の中央値を基準数とする**ことができます。こうすると、基準数が「小さすぎず大きすぎもしない」確率が大幅に上がります。もちろん、候補要素をさらに増やして、アルゴリズムの頑健性をいっそう高めることも可能です。この方法を採用すると、時間計算量が $O(n^2)$ まで劣化する確率は大きく下がります。
|
||||
|
||||
サンプルコードは以下の通りです:
|
||||
コード例を以下に示します。
|
||||
|
||||
```src
|
||||
[file]{quick_sort}-[class]{quick_sort_median}-[func]{partition}
|
||||
```
|
||||
|
||||
## 末尾再帰最適化
|
||||
## 再帰の深さの最適化
|
||||
|
||||
**特定の入力では、クイックソートはより多くの空間を占有する可能性があります**。例えば、完全に順序付けられた入力配列を考えてみましょう。再帰でのサブ配列の長さを$m$とします。各ラウンドのピボット分割で、長さ$0$の左サブ配列と長さ$m - 1$の右サブ配列が生成されます。これは、再帰呼び出しごとに問題サイズが1つの要素のみ減少することを意味し、各レベルの再帰での削減が非常に小さくなります。
|
||||
結果として、再帰ツリーの高さは$n − 1$に達する可能性があり、これには$O(n)$のスタックフレーム空間が必要です。
|
||||
**一部の入力では、クイックソートは多くの空間を消費する可能性があります**。完全に整列済みの入力配列を例にとり、再帰中の部分配列の長さを $m$ とします。各回のパーティション操作では長さ $0$ の左部分配列と長さ $m - 1$ の右部分配列が生成されます。これは、各再帰呼び出しで減る問題サイズが非常に小さいこと(要素が 1 つ減るだけ)を意味し、再帰木の高さは $n - 1$ に達するため、このとき $O(n)$ のスタックフレーム空間を占有します。
|
||||
|
||||
スタックフレーム空間の蓄積を防ぐため、各ラウンドのピボットソート後に2つのサブ配列の長さを比較し、**より短いサブ配列のみを再帰的にソート**できます。より短いサブ配列の長さは$n / 2$を超えないため、この方法は再帰深度が$\log n$を超えないことを保証し、最悪空間計算量を$O(\log n)$に最適化します。コードは以下の通りです:
|
||||
スタックフレーム空間の蓄積を防ぐために、各回のパーティション完了後に 2 つの部分配列の長さを比較し、**短いほうの部分配列に対してのみ再帰**を行えます。短い部分配列の長さは $n / 2$ を超えないため、この方法なら再帰の深さを $\log n$ 以下に抑えられ、最悪時の空間計算量を $O(\log n)$ まで最適化できます。コードを以下に示します。
|
||||
|
||||
```src
|
||||
[file]{quick_sort}-[class]{quick_sort_tail_call}-[func]{quick_sort}
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
# 基数ソート
|
||||
|
||||
前の節では計数ソートを紹介しました。これは、データサイズ $n$ が大きいがデータ範囲 $m$ が小さいシナリオに適しています。$n = 10^6$ の学生IDをソートする必要があり、各IDが $8$ 桁の数字であるとします。これは、データ範囲 $m = 10^8$ が非常に大きいことを意味します。この場合、計数ソートを使用すると、大量のメモリスペースが必要になります。基数ソートはこの状況を回避できます。
|
||||
前節では計数ソートを紹介しました。これは、データ量 $n$ が大きく、データ範囲 $m$ が小さい場合に適しています。$n = 10^6$ 個の学籍番号をソートすると仮定すると、学籍番号は $8$ 桁の数字なので、データ範囲 $m = 10^8$ は非常に大きくなります。計数ソートでは大量のメモリ空間を確保する必要がありますが、基数ソートではこの問題を回避できます。
|
||||
|
||||
<u>基数ソート</u>は計数ソートと同じ核心概念を共有し、要素の頻度をカウントすることでソートします。同時に、基数ソートは数字の桁間の漸進的関係を利用してこれを基盤としています。桁を一度に一つずつ処理してソートし、最終的なソート順序を達成します。
|
||||
<u>基数ソート(radix sort)</u>の基本的な考え方は計数ソートと同じで、個数を数えることによってソートを実現します。そのうえで、基数ソートは各桁の段階的な関係を利用し、各桁を順にソートすることで、最終的なソート結果を得ます。
|
||||
|
||||
## アルゴリズムの過程
|
||||
## アルゴリズムの流れ
|
||||
|
||||
学生IDデータを例として、最下位桁を $1$ 番目、最上位桁を $8$ 番目とすると、基数ソートの過程は以下の図に示されています。
|
||||
学籍番号データを例にすると、数字の最下位桁を第 $1$ 位、最上位桁を第 $8$ 位としたとき、基数ソートの流れは次図のようになります。
|
||||
|
||||
1. 桁 $k = 1$ を初期化します。
|
||||
2. 学生IDの $k$ 番目の桁に対して「計数ソート」を実行します。完了後、データは $k$ 番目の桁に基づいて最小から最大までソートされます。
|
||||
3. $k$ を $1$ 増やし、手順 `2.` に戻って反復を続け、すべての桁がソートされるまで続けます。この時点で過程が終了します。
|
||||
1. 桁番号 $k = 1$ を初期化します。
|
||||
2. 学籍番号の第 $k$ 位に対して「計数ソート」を実行します。完了すると、データは第 $k$ 位に従って昇順に並びます。
|
||||
3. $k$ を $1$ 増やし、手順 `2.` に戻って反復を続けます。すべての桁のソートが完了したら終了します。
|
||||
|
||||

|
||||

|
||||
|
||||
以下、コード実装を詳しく見てみます。基数 $d$ での数 $x$ に対して、その $k$ 番目の桁 $x_k$ を取得するには、以下の計算式を使用できます:
|
||||
以下ではコード実装を分解して見ていきます。$d$ 進数の数値 $x$ について、その第 $k$ 位 $x_k$ を取得するには、次の計算式を用います。
|
||||
|
||||
$$
|
||||
x_k = \lfloor\frac{x}{d^{k-1}}\rfloor \bmod d
|
||||
$$
|
||||
|
||||
ここで $\lfloor a \rfloor$ は浮動小数点数 $a$ の切り捨てを表し、$\bmod \: d$ は $d$ による剰余を表します。学生IDデータの場合、$d = 10$ で $k \in [1, 8]$ です。
|
||||
ここで、$\lfloor a \rfloor$ は浮動小数点数 $a$ の切り捨てを表し、$\bmod \: d$ は $d$ による剰余を表します。学籍番号データでは、$d = 10$ かつ $k \in [1, 8]$ です。
|
||||
|
||||
さらに、$k$ 番目の桁に基づいてソートできるように、計数ソートのコードを少し修正する必要があります:
|
||||
さらに、数字の第 $k$ 位に基づいてソートできるように、計数ソートのコードを少し変更する必要があります。
|
||||
|
||||
```src
|
||||
[file]{radix_sort}-[class]{}-[func]{radix_sort}
|
||||
```
|
||||
|
||||
!!! question "なぜ最下位桁から開始するのか?"
|
||||
!!! question "なぜ最下位桁からソートするのですか?"
|
||||
|
||||
連続するソートラウンドでは、後のラウンドの結果が前のラウンドの結果を上書きします。例えば、最初のラウンドの結果が $a < b$ で、2番目のラウンドが $a > b$ の場合、2番目のラウンドの結果が最初のラウンドの結果を置き換えます。上位桁は下位桁より優先されるため、上位桁の前に下位桁をソートすることが理にかなっています。
|
||||
連続するソートの各ラウンドでは、後のラウンドの結果が前のラウンドの結果を上書きします。たとえば、第1ラウンドで $a < b$ となっていても、第2ラウンドで $a > b$ となれば、第2ラウンドの結果が優先されます。数字では高位の優先度が低位より高いため、先に低位をソートし、その後で高位をソートする必要があります。
|
||||
|
||||
## アルゴリズムの特徴
|
||||
|
||||
計数ソートと比較して、基数ソートはより大きな数値範囲に適していますが、**データが固定桁数で表現でき、桁数があまり大きくないことを前提としています**。例えば、浮動小数点数は桁数 $k$ が大きい可能性があり、時間計算量 $O(nk) \gg O(n^2)$ につながる可能性があるため、基数ソートには適していません。
|
||||
計数ソートと比べると、基数ソートは値の範囲が大きい場合に適しています。**ただし、データが固定桁数の形式で表せること、かつ桁数が大きすぎないことが前提です**。たとえば、浮動小数点数は基数ソートに適していません。桁数 $k$ が大きすぎて、時間計算量が $O(nk) \gg O(n^2)$ になる可能性があるためです。
|
||||
|
||||
- **時間計算量は $O(nk)$、非適応ソート**:データサイズを $n$、データが基数 $d$、最大桁数を $k$ とすると、単一桁のソートには $O(n + d)$ 時間がかかり、すべての $k$ 桁のソートには $O((n + d)k)$ 時間がかかります。一般的に、$d$ と $k$ はどちらも比較的小さく、時間計算量は $O(n)$ に近づきます。
|
||||
- **空間計算量は $O(n + d)$、非インプレースソート**:計数ソートと同様に、基数ソートは長さ $n$ と $d$ の配列 `res` と `counter` にそれぞれ依存します。
|
||||
- **安定ソート**:計数ソートが安定な場合、基数ソートも安定です。計数ソートが不安定な場合、基数ソートは正しいソート順序を保証できません。
|
||||
- **時間計算量は $O(nk)$、非適応ソート**:データ量を $n$、データが $d$ 進数、最大桁数を $k$ とすると、ある1桁に対して計数ソートを実行する時間は $O(n + d)$ であり、全 $k$ 桁をソートする時間は $O((n + d)k)$ です。通常、$d$ と $k$ はどちらも比較的小さいため、時間計算量は $O(n)$ に近づきます。
|
||||
- **空間計算量は $O(n + d)$、非原地ソート**:計数ソートと同様に、基数ソートでは長さ $n$ と $d$ の配列 `res` と `counter` を補助的に用います。
|
||||
- **安定ソート**:計数ソートが安定であれば基数ソートも安定です。計数ソートが不安定な場合、基数ソートでは正しいソート結果を保証できません。
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
# 選択ソート
|
||||
|
||||
<u>選択ソート</u>は非常にシンプルな原理で動作します:各反復で未ソート区間から最小要素を選択し、ソート済みセクションの末尾に移動するループを使用します。
|
||||
<u>選択ソート(selection sort)</u>の仕組みは非常に単純です。ループを開始し、各ラウンドで未ソート区間から最小の要素を選び、整列済み区間の末尾に配置します。
|
||||
|
||||
配列の長さを$n$とすると、選択ソートのステップは下図に示されます。
|
||||
配列の長さを $n$ とすると、選択ソートの手順は次の図のようになります。
|
||||
|
||||
1. 最初に、すべての要素は未ソートで、つまり未ソート(インデックス)区間は$[0, n-1]$です。
|
||||
2. 区間$[0, n-1]$の最小要素を選択し、インデックス$0$の要素と交換します。この後、配列の最初の要素がソートされます。
|
||||
3. 区間$[1, n-1]$の最小要素を選択し、インデックス$1$の要素と交換します。この後、配列の最初の2つの要素がソートされます。
|
||||
4. この方法で続行します。$n - 1$ラウンドの選択と交換の後、最初の$n - 1$個の要素がソートされます。
|
||||
5. 残りの唯一の要素は結果的に最大要素であり、ソートする必要がないため、配列はソートされます。
|
||||
1. 初期状態では、すべての要素が未ソートであり、未ソートな(インデックス)区間は $[0, n-1]$ です。
|
||||
2. 区間 $[0, n-1]$ 内の最小要素を選び、インデックス $0$ の要素と交換します。これにより、配列の先頭 1 要素が整列済みになります。
|
||||
3. 区間 $[1, n-1]$ 内の最小要素を選び、インデックス $1$ の要素と交換します。これにより、配列の先頭 2 要素が整列済みになります。
|
||||
4. これを繰り返します。$n - 1$ 回の選択と交換を経ると、配列の先頭 $n - 1$ 要素が整列済みになります。
|
||||
5. 残った 1 つの要素は必ず最大要素なので、ソートは不要です。これで配列のソートは完了します。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -43,16 +43,16 @@
|
||||
=== "<11>"
|
||||

|
||||
|
||||
コードでは、$k$を使用して未ソート区間内の最小要素を記録します:
|
||||
コードでは、$k$ を用いて未ソート区間内の最小要素を記録します。
|
||||
|
||||
```src
|
||||
[file]{selection_sort}-[class]{}-[func]{selection_sort}
|
||||
```
|
||||
|
||||
## アルゴリズムの特性
|
||||
## アルゴリズムの特徴
|
||||
|
||||
- **$O(n^2)$の時間計算量、非適応ソート**:外側ループに$n - 1$回の反復があり、未ソートセクションの長さは最初の反復で$n$から始まり、最後の反復で$2$まで減少します。つまり、各外側ループ反復にはそれぞれ$n$、$n - 1$、$\dots$、$3$、$2$回の内側ループ反復が含まれ、合計は$\frac{(n - 1)(n + 2)}{2}$となります。
|
||||
- **$O(1)$の空間計算量、インプレースソート**:ポインタ$i$と$j$で定数の追加空間を使用します。
|
||||
- **非安定ソート**:下図に示すように、要素`nums[i]`は等しい要素の右側に交換される可能性があり、相対順序が変わる原因となります。
|
||||
- **時間計算量は $O(n^2)$、非適応ソート**:外側のループは合計 $n - 1$ 回です。最初のラウンドの未ソート区間の長さは $n$、最後のラウンドでは $2$ であり、各ラウンドの内側のループ回数はそれぞれ $n$、$n - 1$、$\dots$、$3$、$2$ となります。総和は $\frac{(n - 1)(n + 2)}{2}$ です。
|
||||
- **空間計算量は $O(1)$、インプレースソート**:ポインタ $i$ と $j$ は定数サイズの追加領域しか使用しません。
|
||||
- **不安定ソート**:次の図のように、要素 `nums[i]` がそれと等しい要素の右側へ交換され、両者の相対的な順序が変わる可能性があります。
|
||||
|
||||

|
||||

|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
# ソートアルゴリズム
|
||||
|
||||
<u>ソートアルゴリズム</u>は、データセットを特定の順序で配列するために使用されます。ソートアルゴリズムは、順序付けられたデータは通常、より効率的に探索、分析、処理できるため、幅広い応用があります。
|
||||
<u>ソートアルゴリズム(sorting algorithm)</u>は、データの集合を特定の順序に従って並べ替えるために用いられます。ソートアルゴリズムは幅広く応用されており、整列済みデータは通常、より効率的に検索、分析、処理できるためです。
|
||||
|
||||
下図に示すように、ソートアルゴリズムのデータ型は整数、浮動小数点数、文字、文字列などです。ソート基準は、数値サイズ、文字ASCII順序、またはカスタム基準など、必要に応じて設定できます。
|
||||
下図に示すように、ソートアルゴリズムにおけるデータ型は整数、浮動小数点数、文字、文字列などです。ソートの判定規則は、数値の大小、文字の ASCII コード順、またはカスタムルールなど、要件に応じて設定できます。
|
||||
|
||||

|
||||

|
||||
|
||||
## 評価次元
|
||||
## 評価軸
|
||||
|
||||
**実行効率**:ソートアルゴリズムの時間計算量ができるだけ低いことを期待し、全体的な操作数も少ないこと(時間計算量の定数項を下げる)を望みます。大容量データでは、実行効率が特に重要です。
|
||||
**実行効率**:ソートアルゴリズムの時間計算量はできるだけ低く、かつ全体の操作回数も少ないこと(時間計算量における定数項が小さいこと)が望まれます。大量データの場合、実行効率はとりわけ重要です。
|
||||
|
||||
**インプレース性**:名前が示すとおり、<u>インプレースソート</u>は元の配列を直接操作することで実現され、追加のヘルパー配列が不要であるため、メモリを節約します。一般的に、インプレースソートはデータ移動操作が少なく、高速です。
|
||||
**インプレース性**:その名のとおり、<u>インプレースソート</u>は元の配列を直接操作して並べ替えを行うため、追加の補助配列を必要とせず、メモリを節約できます。通常、インプレースソートはデータの移動操作が少なく、実行速度もより高速です。
|
||||
|
||||
**安定性**:<u>安定ソート</u>は、ソート後に配列内の等しい要素の相対順序が変わらないことを保証します。
|
||||
**安定性**:<u>安定ソート</u>は、並べ替え完了後も、等しい要素の配列内での相対順序が変化しません。
|
||||
|
||||
安定ソートは、マルチキーソートシナリオにおいて必要条件です。学生情報を格納するテーブルがあり、第1列と第2列がそれぞれ名前と年齢であるとします。この場合、<u>不安定ソート</u>は入力データの順序を失う可能性があります:
|
||||
安定ソートは多段ソートの場面で必要条件となります。学生情報を保存した表があり、第 1 列と第 2 列がそれぞれ氏名と年齢であると仮定します。この場合、<u>不安定ソート</u>によって入力データの順序性が失われる可能性があります。
|
||||
|
||||
```shell
|
||||
# 入力データは名前でソート済み
|
||||
# (名前, 年齢)
|
||||
# 入力データは氏名順にソートされている
|
||||
# (name, age)
|
||||
('A', 19)
|
||||
('B', 18)
|
||||
('C', 21)
|
||||
('D', 19)
|
||||
('E', 23)
|
||||
|
||||
# 不安定ソートアルゴリズムを使用してリストを年齢でソートすると仮定すると、
|
||||
# 結果は('D', 19)と('A', 19)の相対位置を変更し、
|
||||
# 入力データが名前でソート済みであるという性質が失われる
|
||||
# 不安定ソートアルゴリズムで年齢順にリストを並べ替えると仮定すると、
|
||||
# 結果では ('D', 19) と ('A', 19) の相対位置が変わり、
|
||||
# 入力データが氏名順である性質が失われる
|
||||
('B', 18)
|
||||
('D', 19)
|
||||
('A', 19)
|
||||
@@ -35,12 +35,12 @@
|
||||
('E', 23)
|
||||
```
|
||||
|
||||
**適応性**:<u>適応ソート</u>は入力データ内の既存の順序情報を活用して計算負荷を削減し、より最適な時間効率を実現します。適応ソートアルゴリズムの最良ケース時間計算量は、通常平均ケース時間計算量よりも優れています。
|
||||
**適応性**:<u>適応的ソート</u>は、入力データに既に存在する順序情報を利用して計算量を減らし、より優れた時間効率を実現できます。適応的ソートアルゴリズムの最良時間計算量は、通常、平均時間計算量より優れています。
|
||||
|
||||
**比較ベースまたは非比較ベース**:<u>比較ベースソート</u>は比較演算子($<$、$=$、$>$)に依存して要素の相対順序を決定し、配列全体をソートします。理論的最適時間計算量は$O(n \log n)$です。一方、<u>非比較ソート</u>は比較演算子を使用せず、$O(n)$の時間計算量を実現できますが、汎用性は比較的劣ります。
|
||||
**比較ベースかどうか**:<u>比較ベースのソート</u>は、比較演算子($<$、$=$、$>$)に依存して要素の相対順序を判定し、それによって配列全体をソートします。理論上の最良時間計算量は $O(n \log n)$ です。一方、<u>非比較ソート</u>は比較演算子を使用せず、時間計算量は $O(n)$ に達しますが、汎用性は相対的に低くなります。
|
||||
|
||||
## 理想的なソートアルゴリズム
|
||||
|
||||
**高速実行、インプレース、安定、適応、汎用**。明らかに、これらのすべての特徴を組み合わせたソートアルゴリズムは今日まで見つかっていません。したがって、ソートアルゴリズムを選択する際は、データの特定の特徴と問題の要件に基づいて決定する必要があります。
|
||||
**高速、インプレース、安定、適応的、高い汎用性**。明らかに、これまでのところ、以上のすべての特性を兼ね備えたソートアルゴリズムはまだ見つかっていません。そのため、ソートアルゴリズムを選択する際には、具体的なデータの特徴と問題の要件に応じて判断する必要があります。
|
||||
|
||||
次に、さまざまなソートアルゴリズムを一緒に学び、上記の評価次元に基づいてそれぞれの利点と欠点を分析します。
|
||||
次に、さまざまなソートアルゴリズムを一緒に学び、上記の評価軸に基づいて各ソートアルゴリズムの長所と短所を分析していきます。
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
# まとめ
|
||||
|
||||
### 重要な復習
|
||||
### 重要なポイントの振り返り
|
||||
|
||||
- バブルソートは隣接する要素を交換することで動作します。フラグを追加して早期リターンを可能にすることで、バブルソートの最良ケースの時間計算量を $O(n)$ に最適化できます。
|
||||
- 挿入ソートは、未ソート区間から要素を取り出してソート済み区間の正しい位置に挿入することで各ラウンドをソートします。挿入ソートの時間計算量は $O(n^2)$ ですが、単位あたりの操作が比較的少ないため、少量のデータのソートでは非常に人気があります。
|
||||
- クイックソートは歩哨分割操作に基づいています。歩哨分割では、常に最悪のピボットを選ぶ可能性があり、時間計算量が $O(n^2)$ に劣化する可能性があります。中央値やランダムピボットを導入することで、そのような劣化の確率を減らすことができます。末尾再帰は再帰の深さを効果的に減らし、空間計算量を $O(\log n)$ に最適化します。
|
||||
- マージソートには分割とマージの2つの段階があり、通常分割統治戦略を体現しています。マージソートでは、配列のソートには補助配列の作成が必要で、空間計算量は $O(n)$ になります。しかし、リストのソートの空間計算量は $O(1)$ に最適化できます。
|
||||
- バケットソートは3つの手順から構成されます:データをバケットに分散、各バケット内でのソート、バケット順での結果のマージ。これも分割統治戦略を体現し、非常に大きなデータセットに適しています。バケットソートの鍵はデータの均等分散です。
|
||||
- 計数ソートはバケットソートの変形で、各データポイントの出現回数をカウントすることでソートします。計数ソートは限られた範囲のデータを持つ大きなデータセットに適しており、データを正の整数に変換する必要があります。
|
||||
- 基数ソートは桁ごとにソートすることでデータを処理し、データが固定長の数値として表現される必要があります。
|
||||
- 全体的に、私たちは高効率、安定性、インプレース操作、適応性を持つソートアルゴリズムを求めています。しかし、他のデータ構造やアルゴリズムと同様に、これらすべての条件を同時に満たすソートアルゴリズムは存在しません。実際の応用では、データの特性に基づいて適切なソートアルゴリズムを選択する必要があります。
|
||||
- 以下の図は、効率性、安定性、インプレース性、適応性の観点から主流のソートアルゴリズムを比較しています。
|
||||
- バブルソートは隣接する要素を交換することで整列を行います。フラグを追加して早期リターンを可能にすると、バブルソートの最良時間計算量を $O(n)$ に最適化できます。
|
||||
- 挿入ソートは各ラウンドで未整列区間の要素を整列済み区間の正しい位置に挿入することで整列を完了します。挿入ソートの時間計算量は $O(n^2)$ ですが、基本操作が比較的少ないため、小規模データのソート処理で非常に人気があります。
|
||||
- クイックソートは番兵分割操作に基づいて整列を行います。番兵分割では毎回最悪の基準値を選んでしまう可能性があり、その結果、時間計算量は $O(n^2)$ まで劣化することがあります。中央値の基準値やランダムな基準値を導入すると、この劣化の確率を下げられます。短い部分配列を優先して再帰すれば、再帰の深さを効果的に抑え、空間計算量を $O(\log n)$ に最適化できます。
|
||||
- マージソートは分割とマージという 2 つの段階からなり、分割統治戦略を典型的に体現しています。マージソートでは配列を整列する際に補助配列の作成が必要で、空間計算量は $O(n)$ です。一方、連結リストを整列する場合の空間計算量は $O(1)$ まで最適化できます。
|
||||
- バケットソートはデータのバケット分配、バケット内ソート、結果の結合という 3 つの手順を含みます。これも分割統治戦略を体現しており、データ量が非常に大きい場合に適しています。バケットソートの鍵は、データを平均的に分配することにあります。
|
||||
- カウントソートはバケットソートの特例であり、データの出現回数を数えることで整列を行います。カウントソートはデータ量が大きく、かつデータ範囲が限られている場合に適しており、データを正の整数に変換できることが前提です。
|
||||
- 基数ソートは各桁ごとの整列によってデータを整列し、データが固定桁数の数値として表せることを前提とします。
|
||||
- 総じて言えば、私たちは高効率で、安定で、インプレースで、さらに適応的であるといった利点を備えたソートアルゴリズムを見つけたいと考えます。しかし、ほかのデータ構造やアルゴリズムと同様に、これらすべての条件を同時に満たせるソートアルゴリズムは存在しません。実際の応用では、データの特性に応じて適切なソートアルゴリズムを選ぶ必要があります。
|
||||
- 下図では、主流のソートアルゴリズムについて、効率、安定性、インプレース性、適応性などを比較しています。
|
||||
|
||||

|
||||
|
||||
### Q & A
|
||||
|
||||
**Q**: ソートアルゴリズムの安定性はいつ必要ですか?
|
||||
**Q**:ソートアルゴリズムの安定性は、どのような場合に必須ですか?
|
||||
|
||||
実際には、オブジェクトの一つの属性に基づいてソートする場合があります。例えば、学生は名前と身長の属性を持ち、多段階ソートを実装することを目指します:最初に名前で `(A, 180) (B, 185) (C, 170) (D, 170)` を取得し、次に身長で。ソートアルゴリズムが不安定なため、`(D, 170) (C, 170) (A, 180) (B, 185)` になってしまう可能性があります。
|
||||
現実には、オブジェクトのある属性に基づいて整列することがあります。たとえば、学生には氏名と身長という 2 つの属性があり、多段階のソートを行いたいとします。まず氏名で整列して `(A, 180) (B, 185) (C, 170) (D, 170)` を得て、その後に身長で整列します。ソートアルゴリズムが不安定である場合、結果は `(D, 170) (C, 170) (A, 180) (B, 185)` になる可能性があります。
|
||||
|
||||
学生DとCの位置が交換され、名前の順序性が破られているのが分かります。これは望ましくありません。
|
||||
このように、学生 D と C の位置が入れ替わり、氏名に関する順序性が壊れてしまいます。これは望ましくありません。
|
||||
|
||||
**Q**: 歩哨分割での「右から左への検索」と「左から右への検索」の順序を交換できますか?
|
||||
**Q**:番兵分割において、「右から左へ探索する」順序と「左から右へ探索する」順序は入れ替えられますか?
|
||||
|
||||
いいえ、最左要素をピボットとして使用する場合、最初に「右から左への検索」を行い、次に「左から右への検索」を行う必要があります。この結論はやや直観に反するので、理由を分析してみましょう。
|
||||
できません。最も左端の要素を基準値とする場合は、必ず先に「右から左へ探索する」を行い、その後に「左から右へ探索する」を行う必要があります。この結論はやや直感に反するので、理由を分析してみましょう。
|
||||
|
||||
歩哨分割 `partition()` の最後のステップは `nums[left]` と `nums[i]` を交換することです。交換後、ピボットの左側の要素はすべてピボット以下になります。**これには最後の交換前に `nums[left] >= nums[i]` が成り立つ必要があります**。「左から右への検索」を最初に行い、ピボットより大きい要素が見つからない場合、**`i == j` でループを終了し、`nums[j] == nums[i] > nums[left]` となる可能性があります**。つまり、最終交換操作はピボットより大きい要素を配列の左端に交換し、歩哨分割を失敗させます。
|
||||
番兵分割 `partition()` の最後の手順は、`nums[left]` と `nums[i]` を交換することです。交換が終わると、基準値の左側にある要素はすべて基準値 `<=` になります。**したがって、最後の交換の前に `nums[left] >= nums[i]` が必ず成り立っていなければなりません**。仮に先に「左から右へ探索する」を行うと、基準値より大きい要素が見つからない場合、**`i == j` の時点でループを抜け、このとき `nums[j] == nums[i] > nums[left]` となる可能性があります**。つまり、この最後の交換によって、基準値より大きい要素が配列の最左端へ移されてしまい、番兵分割は失敗します。
|
||||
|
||||
例えば、配列 `[0, 0, 0, 0, 1]` が与えられた場合、最初に「左から右への検索」を行うと、歩哨分割後の配列は `[1, 0, 0, 0, 0]` となり、これは正しくありません。
|
||||
たとえば、配列 `[0, 0, 0, 0, 1]` が与えられたとき、先に「左から右へ探索する」を行うと、番兵分割後の配列は `[1, 0, 0, 0, 0]` になります。これは誤った結果です。
|
||||
|
||||
さらに考えると、`nums[right]` をピボットとして選択する場合、まったく逆で、最初に「左から右への検索」を行う必要があります。
|
||||
さらに考えると、`nums[right]` を基準値に選ぶ場合はちょうど逆になり、必ず先に「左から右へ探索する」を行う必要があります。
|
||||
|
||||
**Q**: 末尾再帰最適化について、短い配列を選択することで再帰の深さが $\log n$ を超えないことを保証するのはなぜですか?
|
||||
**Q**:クイックソートの再帰深度最適化について、短い配列を選ぶとなぜ再帰深度が $\log n$ を超えないと保証できるのですか?
|
||||
|
||||
再帰の深さは現在リターンしていない再帰メソッドの数です。歩哨分割の各ラウンドは元の配列を2つの副配列に分割します。末尾再帰最適化により、再帰的に続行する副配列の長さは最大でも元の配列長の半分です。最悪の場合常に長さを半分にすると仮定すると、最終的な再帰の深さは $\log n$ になります。
|
||||
再帰深度とは、現在まだ戻っていない再帰呼び出しの数のことです。各ラウンドの番兵分割では、元の配列を 2 つの部分配列に分けます。再帰深度の最適化後は、下方向に再帰する部分配列の長さは最大でも元の配列長の半分です。最悪の場合でも毎回半分の長さになると仮定すれば、最終的な再帰深度は $\log n$ になります。
|
||||
|
||||
元のクイックソートを見直すと、より大きな配列を継続的に再帰処理する可能性があり、最悪の場合 $n$、$n - 1$、...、$2$、$1$ で、再帰の深さは $n$ になります。末尾再帰最適化はこのシナリオを回避できます。
|
||||
元のクイックソートを振り返ると、長いほうの配列に対して連続して再帰してしまう可能性があり、最悪の場合は $n$、$n - 1$、$\dots$、$2$、$1$ と続き、再帰深度は $n$ になります。再帰深度の最適化により、このような状況を避けられます。
|
||||
|
||||
**Q**: 配列のすべての要素が等しい場合、クイックソートの時間計算量は $O(n^2)$ ですか?この劣化ケースをどう処理すべきですか?
|
||||
**Q**:配列内のすべての要素が等しい場合、クイックソートの時間計算量は $O(n^2)$ になりますか?このような退化はどう処理すべきですか?
|
||||
|
||||
はい。この状況については、歩哨分割を使用して配列をピボットより小さい、等しい、大きいの3つの部分に分割することを検討してください。小さい部分と大きい部分のみを再帰的に進めます。この方法では、すべての入力要素が等しい配列を1ラウンドの歩哨分割だけでソートできます。
|
||||
はい。この場合は、番兵分割によって配列を「基準値より小さい」「基準値に等しい」「基準値より大きい」の 3 つの部分に分ける方法を検討できます。下方向に再帰するのは、小さい部分と大きい部分だけです。この方法では、入力要素がすべて等しい配列は、1 回の番兵分割だけで整列を完了できます。
|
||||
|
||||
**Q**: なぜバケットソートの最悪ケース時間計算量は $O(n^2)$ ですか?
|
||||
**Q**:バケットソートの最悪時間計算量が $O(n^2)$ なのはなぜですか?
|
||||
|
||||
最悪の場合、すべての要素が同じバケットに配置されます。これらの要素をソートするために $O(n^2)$ アルゴリズムを使用する場合、時間計算量は $O(n^2)$ になります。
|
||||
最悪の場合、すべての要素が同じバケットに振り分けられます。その要素群を整列するのに $O(n^2)$ のアルゴリズムを使えば、時間計算量は $O(n^2)$ になります。
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
# 両端キュー
|
||||
|
||||
キューでは、先頭からの要素の削除や末尾への要素の追加のみが可能です。下図に示すように、<u>両端キュー(deque)</u>はより柔軟性を提供し、先頭と末尾の両方で要素の追加や削除を可能にします。
|
||||
キューでは、先頭要素を削除するか末尾に要素を追加することしかできません。次の図に示すように、<u>両端キュー(double-ended queue)</u>はより高い柔軟性を備えており、先頭と末尾の両方で要素の追加や削除を行えます。
|
||||
|
||||

|
||||
|
||||
## 両端キューの一般的な操作
|
||||
## 両端キューの基本操作
|
||||
|
||||
両端キューの一般的な操作は以下の通りです。具体的なメソッド名は使用するプログラミング言語によって異なります。
|
||||
両端キューの基本操作を次の表に示します。具体的なメソッド名は、使用するプログラミング言語によって異なります。
|
||||
|
||||
<p align="center"> 表 <id> 両端キューの操作効率 </p>
|
||||
|
||||
| メソッド名 | 説明 | 時間計算量 |
|
||||
| ------------- | ------------------ | ------------- |
|
||||
| `pushFirst()` | 先頭に要素を追加 | $O(1)$ |
|
||||
| `pushLast()` | 末尾に要素を追加 | $O(1)$ |
|
||||
| `popFirst()` | 先頭要素を削除 | $O(1)$ |
|
||||
| `popLast()` | 末尾要素を削除 | $O(1)$ |
|
||||
| `peekFirst()` | 先頭要素にアクセス | $O(1)$ |
|
||||
| `peekLast()` | 末尾要素にアクセス | $O(1)$ |
|
||||
| メソッド名 | 説明 | 時間計算量 |
|
||||
| -------------- | ---------------- | ---------- |
|
||||
| `push_first()` | 先頭に要素を追加 | $O(1)$ |
|
||||
| `push_last()` | 末尾に要素を追加 | $O(1)$ |
|
||||
| `pop_first()` | 先頭要素を削除 | $O(1)$ |
|
||||
| `pop_last()` | 末尾要素を削除 | $O(1)$ |
|
||||
| `peek_first()` | 先頭要素にアクセス | $O(1)$ |
|
||||
| `peek_last()` | 末尾要素にアクセス | $O(1)$ |
|
||||
|
||||
同様に、プログラミング言語で実装された両端キュークラスを直接使用することができます:
|
||||
同様に、プログラミング言語に組み込み実装されている両端キューのクラスを直接使うこともできます:
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
# 両端キューの長さを取得
|
||||
size: int = len(deq)
|
||||
|
||||
# 両端キューが空かどうかを確認
|
||||
# 両端キューが空かどうかを判定
|
||||
is_empty: bool = len(deq) == 0
|
||||
```
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
/* 両端キューの長さを取得 */
|
||||
int size = deque.size();
|
||||
|
||||
/* 両端キューが空かどうかを確認 */
|
||||
/* 両端キューが空かどうかを判定 */
|
||||
bool empty = deque.empty();
|
||||
```
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
/* 両端キューの長さを取得 */
|
||||
int size = deque.size();
|
||||
|
||||
/* 両端キューが空かどうかを確認 */
|
||||
/* 両端キューが空かどうかを判定 */
|
||||
boolean isEmpty = deque.isEmpty();
|
||||
```
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
|
||||
```csharp title="deque.cs"
|
||||
/* 両端キューを初期化 */
|
||||
// C#では、LinkedListを両端キューとして使用
|
||||
// C# では、連結リスト LinkedList を両端キューとして使用する
|
||||
LinkedList<int> deque = new();
|
||||
|
||||
/* 要素をエンキュー */
|
||||
@@ -132,7 +132,7 @@
|
||||
/* 両端キューの長さを取得 */
|
||||
int size = deque.Count;
|
||||
|
||||
/* 両端キューが空かどうかを確認 */
|
||||
/* 両端キューが空かどうかを判定 */
|
||||
bool isEmpty = deque.Count == 0;
|
||||
```
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
|
||||
```go title="deque_test.go"
|
||||
/* 両端キューを初期化 */
|
||||
// Goでは、listを両端キューとして使用
|
||||
// Go では、list を両端キューとして使用する
|
||||
deque := list.New()
|
||||
|
||||
/* 要素をエンキュー */
|
||||
@@ -161,7 +161,7 @@
|
||||
/* 両端キューの長さを取得 */
|
||||
size := deque.Len()
|
||||
|
||||
/* 両端キューが空かどうかを確認 */
|
||||
/* 両端キューが空かどうかを判定 */
|
||||
isEmpty := deque.Len() == 0
|
||||
```
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
|
||||
```swift title="deque.swift"
|
||||
/* 両端キューを初期化 */
|
||||
// Swiftには組み込みの両端キュークラスがないため、Arrayを両端キューとして使用
|
||||
// Swift には組み込みの両端キュークラスがないため、Array を両端キューとして使用する
|
||||
var deque: [Int] = []
|
||||
|
||||
/* 要素をエンキュー */
|
||||
@@ -181,17 +181,17 @@
|
||||
|
||||
/* 要素にアクセス */
|
||||
let peekFirst = deque.first! // 先頭要素
|
||||
let peekLast = deque.last! // 末尾要素
|
||||
let peekLast = deque.last! // 末尾要素
|
||||
|
||||
/* 要素をデキュー */
|
||||
// Arrayを使用する場合、popFirstの計算量はO(n)
|
||||
// Array で模擬する場合、popFirst の計算量は O(n)
|
||||
let popFirst = deque.removeFirst() // 先頭要素をデキュー
|
||||
let popLast = deque.removeLast() // 末尾要素をデキュー
|
||||
let popLast = deque.removeLast() // 末尾要素をデキュー
|
||||
|
||||
/* 両端キューの長さを取得 */
|
||||
let size = deque.count
|
||||
|
||||
/* 両端キューが空かどうかを確認 */
|
||||
/* 両端キューが空かどうかを判定 */
|
||||
let isEmpty = deque.isEmpty
|
||||
```
|
||||
|
||||
@@ -199,30 +199,30 @@
|
||||
|
||||
```javascript title="deque.js"
|
||||
/* 両端キューを初期化 */
|
||||
// JavaScriptには組み込みの両端キューがないため、Arrayを両端キューとして使用
|
||||
// JavaScript には組み込みの両端キューがないため、Array を両端キューとして使用するしかない
|
||||
const deque = [];
|
||||
|
||||
/* 要素をエンキュー */
|
||||
deque.push(2);
|
||||
deque.push(5);
|
||||
deque.push(4);
|
||||
// 注意:unshift()は配列のため時間計算量がO(n)
|
||||
// 配列であるため、unshift() メソッドの時間計算量は O(n) です
|
||||
deque.unshift(3);
|
||||
deque.unshift(1);
|
||||
|
||||
/* 要素にアクセス */
|
||||
const peekFirst = deque[0]; // 先頭要素
|
||||
const peekLast = deque[deque.length - 1]; // 末尾要素
|
||||
const peekFirst = deque[0];
|
||||
const peekLast = deque[deque.length - 1];
|
||||
|
||||
/* 要素をデキュー */
|
||||
// 注意:shift()は配列のため時間計算量がO(n)
|
||||
const popFront = deque.shift(); // 先頭要素をデキュー
|
||||
const popBack = deque.pop(); // 末尾要素をデキュー
|
||||
// 配列であるため、shift() メソッドの時間計算量は O(n) です
|
||||
const popFront = deque.shift();
|
||||
const popBack = deque.pop();
|
||||
|
||||
/* 両端キューの長さを取得 */
|
||||
const size = deque.length;
|
||||
|
||||
/* 両端キューが空かどうかを確認 */
|
||||
/* 両端キューが空かどうかを判定 */
|
||||
const isEmpty = size === 0;
|
||||
```
|
||||
|
||||
@@ -230,30 +230,30 @@
|
||||
|
||||
```typescript title="deque.ts"
|
||||
/* 両端キューを初期化 */
|
||||
// TypeScriptには組み込みの両端キューがないため、Arrayを両端キューとして使用
|
||||
// TypeScript には組み込みの両端キューがないため、Array を両端キューとして使用するしかない
|
||||
const deque: number[] = [];
|
||||
|
||||
/* 要素をエンキュー */
|
||||
deque.push(2);
|
||||
deque.push(5);
|
||||
deque.push(4);
|
||||
// 注意:unshift()は配列のため時間計算量がO(n)
|
||||
// 配列であるため、unshift() メソッドの時間計算量は O(n) です
|
||||
deque.unshift(3);
|
||||
deque.unshift(1);
|
||||
|
||||
/* 要素にアクセス */
|
||||
const peekFirst: number = deque[0]; // 先頭要素
|
||||
const peekLast: number = deque[deque.length - 1]; // 末尾要素
|
||||
const peekFirst: number = deque[0];
|
||||
const peekLast: number = deque[deque.length - 1];
|
||||
|
||||
/* 要素をデキュー */
|
||||
// 注意:shift()は配列のため時間計算量がO(n)
|
||||
const popFront: number = deque.shift() as number; // 先頭要素をデキュー
|
||||
const popBack: number = deque.pop() as number; // 末尾要素をデキュー
|
||||
// 配列であるため、shift() メソッドの時間計算量は O(n) です
|
||||
const popFront: number = deque.shift() as number;
|
||||
const popBack: number = deque.pop() as number;
|
||||
|
||||
/* 両端キューの長さを取得 */
|
||||
const size: number = deque.length;
|
||||
|
||||
/* 両端キューが空かどうかを確認 */
|
||||
/* 両端キューが空かどうかを判定 */
|
||||
const isEmpty: boolean = size === 0;
|
||||
```
|
||||
|
||||
@@ -261,7 +261,7 @@
|
||||
|
||||
```dart title="deque.dart"
|
||||
/* 両端キューを初期化 */
|
||||
// Dartでは、Queueが両端キューとして定義される
|
||||
// Dart では、Queue は両端キューとして定義されています
|
||||
Queue<int> deque = Queue<int>();
|
||||
|
||||
/* 要素をエンキュー */
|
||||
@@ -282,7 +282,7 @@
|
||||
/* 両端キューの長さを取得 */
|
||||
int size = deque.length;
|
||||
|
||||
/* 両端キューが空かどうかを確認 */
|
||||
/* 両端キューが空かどうかを判定 */
|
||||
bool isEmpty = deque.isEmpty;
|
||||
```
|
||||
|
||||
@@ -314,50 +314,107 @@
|
||||
/* 両端キューの長さを取得 */
|
||||
let size = deque.len();
|
||||
|
||||
/* 両端キューが空かどうかを確認 */
|
||||
/* 両端キューが空かどうかを判定 */
|
||||
let is_empty = deque.is_empty();
|
||||
```
|
||||
|
||||
=== "C"
|
||||
|
||||
```c title="deque.c"
|
||||
// Cには組み込みの両端キューが提供されていません
|
||||
// C には組み込みの両端キューがありません
|
||||
```
|
||||
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title="deque.kt"
|
||||
/* 両端キューを初期化 */
|
||||
val deque = LinkedList<Int>()
|
||||
|
||||
/* 要素をエンキュー */
|
||||
deque.offerLast(2) // 末尾に追加
|
||||
deque.offerLast(5)
|
||||
deque.offerLast(4)
|
||||
deque.offerFirst(3) // 先頭に追加
|
||||
deque.offerFirst(1)
|
||||
|
||||
/* 要素にアクセス */
|
||||
val peekFirst = deque.peekFirst() // 先頭要素
|
||||
val peekLast = deque.peekLast() // 末尾要素
|
||||
|
||||
/* 要素をデキュー */
|
||||
val popFirst = deque.pollFirst() // 先頭要素をデキュー
|
||||
val popLast = deque.pollLast() // 末尾要素をデキュー
|
||||
|
||||
/* 両端キューの長さを取得 */
|
||||
val size = deque.size
|
||||
|
||||
/* 両端キューが空かどうかを判定 */
|
||||
val isEmpty = deque.isEmpty()
|
||||
```
|
||||
|
||||
=== "Ruby"
|
||||
|
||||
```ruby title="deque.rb"
|
||||
# 両端キューを初期化
|
||||
# Ruby には組み込みの両端キューがないため、Array を両端キューとして使用するしかありません
|
||||
deque = []
|
||||
|
||||
# 要素をエンキュー
|
||||
deque << 2
|
||||
deque << 5
|
||||
deque << 4
|
||||
# 配列であるため、Array#unshift メソッドの時間計算量は O(n) です
|
||||
deque.unshift(3)
|
||||
deque.unshift(1)
|
||||
|
||||
# 要素にアクセス
|
||||
peek_first = deque.first
|
||||
peek_last = deque.last
|
||||
|
||||
# 要素をデキュー
|
||||
# 配列であるため、 Array#shift メソッドの時間計算量は O(n) です
|
||||
pop_front = deque.shift
|
||||
pop_back = deque.pop
|
||||
|
||||
# 両端キューの長さを取得
|
||||
size = deque.length
|
||||
|
||||
# 両端キューが空かどうかを判定
|
||||
is_empty = size.zero?
|
||||
```
|
||||
|
||||
??? pythontutor "実行の可視化"
|
||||
|
||||
https://pythontutor.com/render.html#code=from%20collections%20import%20deque%0A%0A%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E5%8F%8C%E5%90%91%E9%98%9F%E5%88%97%0A%20%20%20%20deq%20%3D%20deque%28%29%0A%0A%20%20%20%20%23%20%E5%85%83%E7%B4%A0%E5%85%A5%E9%98%9F%0A%20%20%20%20deq.append%282%29%20%20%23%20%E6%B7%BB%E5%8A%A0%E8%87%B3%E9%98%9F%E5%B0%BE%0A%20%20%20%20deq.append%285%29%0A%20%20%20%20deq.append%284%29%0A%20%20%20%20deq.appendleft%283%29%20%20%23%20%E6%B7%BB%E5%8A%A0%E8%87%B3%E9%98%9F%E9%A6%96%0A%20%20%20%20deq.appendleft%281%29%0A%20%20%20%20print%28%22%E5%8F%8C%E5%90%91%E9%98%9F%E5%88%97%20deque%20%3D%22,%20deq%29%0A%0A%20%20%20%20%23%20%E8%AE%BF%E9%97%AE%E5%85%83%E7%B4%A0%0A%20%20%20%20front%20%3D%20deq%5B0%5D%20%20%23%20%E9%98%9F%E9%A6%96%E5%85%83%E7%B4%A0%0A%20%20%20%20print%28%22%E9%98%9F%E9%A6%96%E5%85%83%E7%B4%A0%20front%20%3D%22,%20front%29%0A%20%20%20%20rear%20%3D%20deq%5B-1%5D%20%20%23%20%E9%98%9F%E5%B0%BE%E5%85%83%E7%B4%A0%0A%20%20%20%20print%28%22%E9%98%9F%E5%B0%BE%E5%85%83%E7%B4%A0%20rear%20%3D%22,%20rear%29%0A%0A%20%20%20%20%23%20%E5%85%83%E7%B4%A0%E5%87%BA%E9%98%9F%0A%20%20%20%20pop_front%20%3D%20deq.popleft%28%29%20%20%23%20%E9%98%9F%E9%A6%96%E5%85%83%E7%B4%A0%E5%87%BA%E9%98%9F%0A%20%20%20%20print%28%22%E9%98%9F%E9%A6%96%E5%87%BA%E9%98%9F%E5%85%83%E7%B4%A0%20%20pop_front%20%3D%22,%20pop_front%29%0A%20%20%20%20print%28%22%E9%98%9F%E9%A6%96%E5%87%BA%E9%98%9F%E5%90%8E%20deque%20%3D%22,%20deq%29%0A%20%20%20%20pop_rear%20%3D%20deq.pop%28%29%20%20%23%20%E9%98%9F%E5%B0%BE%E5%85%83%E7%B4%A0%E5%87%BA%E9%98%9F%0A%20%20%20%20print%28%22%E9%98%9F%E5%B0%BE%E5%87%BA%E9%98%9F%E5%85%83%E7%B4%A0%20%20pop_rear%20%3D%22,%20pop_rear%29%0A%20%20%20%20print%28%22%E9%98%9F%E5%B0%BE%E5%87%BA%E9%98%9F%E5%90%8E%20deque%20%3D%22,%20deq%29%0A%0A%20%20%20%20%23%20%E8%8E%B7%E5%8F%96%E5%8F%8C%E5%90%91%E9%98%9F%E5%88%97%E7%9A%84%E9%95%BF%E5%BA%A6%0A%20%20%20%20size%20%3D%20len%28deq%29%0A%20%20%20%20print%28%22%E5%8F%8C%E5%90%91%E9%98%9F%E5%88%97%E9%95%BF%E5%BA%A6%20size%20%3D%22,%20size%29%0A%0A%20%20%20%20%23%20%E5%88%A4%E6%96%AD%E5%8F%8C%E5%90%91%E9%98%9F%E5%88%97%E6%98%AF%E5%90%A6%E4%B8%BA%E7%A9%BA%0A%20%20%20%20is_empty%20%3D%20len%28deq%29%20%3D%3D%200%0A%20%20%20%20print%28%22%E5%8F%8C%E5%90%91%E9%98%9F%E5%88%97%E6%98%AF%E5%90%A6%E4%B8%BA%E7%A9%BA%20%3D%22,%20is_empty%29&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
|
||||
|
||||
## 両端キューの実装 *
|
||||
|
||||
両端キューの実装は通常のキューの実装と似ており、連結リストまたは配列を基盤となるデータ構造として使用できます。
|
||||
両端キューの実装はキューと似ており、連結リストまたは配列を基盤となるデータ構造として選べます。
|
||||
|
||||
### 双方向連結リストに基づく実装
|
||||
|
||||
前節で、通常の単一連結リストを使ってキューを実装したことを思い出してください。これは先頭からの削除(デキュー操作に対応)と末尾への新しい要素の追加(エンキュー操作に対応)を便利に行えるためでした。
|
||||
前節を振り返ると、通常の単方向連結リストを使ってキューを実装しました。これは、先頭ノードの削除(デキューに対応)と末尾ノードの後ろへの新規ノード追加(エンキューに対応)を容易に行えるためです。
|
||||
|
||||
両端キューでは、先頭と末尾の両方でエンキューとデキュー操作を実行できます。つまり、両端キューは逆方向の操作も実装する必要があります。このため、両端キューの基盤となるデータ構造として「双方向連結リスト」を使用します。
|
||||
両端キューでは、先頭と末尾のどちらでもエンキューとデキューを行えます。言い換えると、両端キューではもう一方の対称方向の操作も実装する必要があります。そのため、両端キューの基盤データ構造として「双方向連結リスト」を採用します。
|
||||
|
||||
下図に示すように、双方向連結リストの先頭ノードと末尾ノードをそれぞれ両端キューの前端と後端として扱い、両端でのノードの追加と削除機能を実装します。
|
||||
次の図に示すように、双方向連結リストの先頭ノードと末尾ノードを両端キューの先頭と末尾と見なし、両端でノードを追加および削除する機能を実現します。
|
||||
|
||||
=== "LinkedListDeque"
|
||||

|
||||

|
||||
|
||||
=== "pushLast()"
|
||||
=== "push_last()"
|
||||

|
||||
|
||||
=== "pushFirst()"
|
||||
=== "push_first()"
|
||||

|
||||
|
||||
=== "popLast()"
|
||||
=== "pop_last()"
|
||||

|
||||
|
||||
=== "popFirst()"
|
||||
=== "pop_first()"
|
||||

|
||||
|
||||
実装コードは以下の通りです:
|
||||
実装コードは次のとおりです:
|
||||
|
||||
```src
|
||||
[file]{linkedlist_deque}-[class]{linked_list_deque}-[func]{}
|
||||
@@ -365,24 +422,24 @@
|
||||
|
||||
### 配列に基づく実装
|
||||
|
||||
下図に示すように、配列でキューを実装するのと同様に、循環配列を使って両端キューを実装することもできます。
|
||||
次の図に示すように、配列によるキュー実装と同様に、循環配列を使って両端キューを実装することもできます。
|
||||
|
||||
=== "ArrayDeque"
|
||||

|
||||

|
||||
|
||||
=== "pushLast()"
|
||||
=== "push_last()"
|
||||

|
||||
|
||||
=== "pushFirst()"
|
||||
=== "push_first()"
|
||||

|
||||
|
||||
=== "popLast()"
|
||||
=== "pop_last()"
|
||||

|
||||
|
||||
=== "popFirst()"
|
||||
=== "pop_first()"
|
||||

|
||||
|
||||
実装では「前端エンキュー」と「後端デキュー」のメソッドを追加するだけです:
|
||||
キュー実装を土台として、「先頭へのエンキュー」と「末尾からのデキュー」のメソッドを追加するだけで済みます:
|
||||
|
||||
```src
|
||||
[file]{array_deque}-[class]{array_deque}-[func]{}
|
||||
@@ -390,6 +447,6 @@
|
||||
|
||||
## 両端キューの応用
|
||||
|
||||
両端キューはスタックとキューの両方のロジックを組み合わせているため、**それぞれのすべてのユースケースを実装でき、より大きな柔軟性を提供します**。
|
||||
両端キューはスタックとキューの両方の論理を備えているため、**これら 2 つのすべての応用場面を実現でき、さらに高い自由度を提供します**。
|
||||
|
||||
ソフトウェアの「元に戻す」機能は通常スタックを使って実装されることを知っています:システムは各変更操作をスタックに`push`し、次に`pop`して元に戻すことを実装します。しかし、システムリソースの制限を考慮して、ソフトウェアは元に戻すステップの数を制限することがよくあります(例えば、最後の50ステップのみを許可)。スタックの長さが50を超えた場合、ソフトウェアはスタックの底部(キューの前端)で削除操作を実行する必要があります。**しかし、通常のスタックではこの機能を実行できないため、両端キューが必要になります**。「元に戻す」のコアロジックは依然としてスタックの後入れ先出し原則に従いますが、両端キューはより柔軟にいくつかの追加ロジックを実装できることに注意してください。
|
||||
私たちが知っているように、ソフトウェアの「元に戻す」機能は通常スタックを使って実装されます。システムは変更操作を毎回スタックに `push` し、その後 `pop` によって取り消しを実現します。しかし、システム資源の制約を考慮すると、通常ソフトウェアは取り消し可能な手数を制限します(たとえば $50$ 手まで保存可能)。スタックの長さが $50$ を超えると、ソフトウェアはスタックの底部(先頭)で削除操作を行う必要があります。**しかしスタックではこの機能を実現できないため、この場合はスタックの代わりに両端キューを使用する必要があります**。なお、「元に戻す」の中核ロジック自体は依然としてスタックの後入れ先出し原則に従っており、両端キューは追加のロジックをより柔軟に実装できるだけです。
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
|
||||
!!! abstract
|
||||
|
||||
スタックは積み重ねられた猫のようなもので、キューは一列に並んだ猫のようなものです。
|
||||
|
||||
それらはそれぞれ、後入先出(LIFO)と先入先出(FIFO)の論理関係を表しています。
|
||||
スタックは猫を積み重ねるようなもので、キューは猫が列に並ぶようなものです。
|
||||
|
||||
両者はそれぞれ、後入れ先出しと先入れ先出しの論理関係を表します。
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
# キュー
|
||||
|
||||
<u>キュー</u>は、先入先出(FIFO)ルールに従う線形データ構造です。名前が示すように、キューは行列の現象をシミュレートし、新参者は列の後ろに並び、前の人が最初に列を離れます。
|
||||
<u>キュー(queue)</u>は、先入れ先出しの規則に従う線形データ構造です。名前のとおり、キューは順番待ちの現象を模したもので、新しく来た人は絶えずキュー末尾に加わり、キュー先頭にいる人から順に離れていきます。
|
||||
|
||||
下図に示すように、キューの前面を「ヘッド」、後面を「テール」と呼びます。キューの後ろに要素を追加する操作を「エンキュー」、前から要素を削除する操作を「デキュー」と呼びます。
|
||||
下図のように、キューの先頭を「キュー先頭」、末尾を「キュー末尾」と呼びます。要素をキュー末尾に加える操作を「エンキュー」、キュー先頭の要素を削除する操作を「デキュー」と呼びます。
|
||||
|
||||

|
||||

|
||||
|
||||
## キューの一般的な操作
|
||||
## キューの基本操作
|
||||
|
||||
キューの一般的な操作を下表に示します。メソッド名はプログラミング言語によって異なる場合があることに注意してください。ここでは、スタックで使用したのと同じ命名規則を使用します。
|
||||
キューの基本操作を以下の表に示します。なお、メソッド名はプログラミング言語によって異なる場合があります。ここではスタックと同じ命名を採用します。
|
||||
|
||||
<p align="center"> 表 <id> キュー操作の効率 </p>
|
||||
|
||||
| メソッド名 | 説明 | 時間計算量 |
|
||||
| ----------- | -------------------------------------- | --------------- |
|
||||
| `push()` | 要素をエンキュー、テールに追加 | $O(1)$ |
|
||||
| `pop()` | ヘッド要素をデキュー | $O(1)$ |
|
||||
| `peek()` | ヘッド要素にアクセス | $O(1)$ |
|
||||
| メソッド名 | 説明 | 時間計算量 |
|
||||
| -------- | ---------------------------- | ---------- |
|
||||
| `push()` | 要素をエンキューし、キュー末尾に追加する | $O(1)$ |
|
||||
| `pop()` | キュー先頭の要素をデキューする | $O(1)$ |
|
||||
| `peek()` | キュー先頭の要素にアクセスする | $O(1)$ |
|
||||
|
||||
プログラミング言語で用意されているキュークラスを直接使用できます:
|
||||
プログラミング言語に用意された既存のキュークラスをそのまま利用できます:
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
from collections import deque
|
||||
|
||||
# キューを初期化
|
||||
# Pythonでは、一般的にdequeクラスをキューとして使用します
|
||||
# queue.Queue()は純粋なキュークラスですが、使いにくいため推奨されません
|
||||
# Python では、通常は双方向キュークラス deque をキューとして使用する
|
||||
# queue.Queue() は純粋なキュークラスだが、やや使いにくいため非推奨
|
||||
que: deque[int] = deque()
|
||||
|
||||
# 要素をエンキュー
|
||||
@@ -37,7 +37,7 @@
|
||||
que.append(5)
|
||||
que.append(4)
|
||||
|
||||
# 最初の要素にアクセス
|
||||
# キュー先頭の要素にアクセス
|
||||
front: int = que[0]
|
||||
|
||||
# 要素をデキュー
|
||||
@@ -46,7 +46,7 @@
|
||||
# キューの長さを取得
|
||||
size: int = len(que)
|
||||
|
||||
# キューが空かどうかチェック
|
||||
# キューが空かどうかを判定
|
||||
is_empty: bool = len(que) == 0
|
||||
```
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
queue.push(5);
|
||||
queue.push(4);
|
||||
|
||||
/* 最初の要素にアクセス */
|
||||
/* キュー先頭の要素にアクセス */
|
||||
int front = queue.front();
|
||||
|
||||
/* 要素をデキュー */
|
||||
@@ -72,7 +72,7 @@
|
||||
/* キューの長さを取得 */
|
||||
int size = queue.size();
|
||||
|
||||
/* キューが空かどうかチェック */
|
||||
/* キューが空かどうかを判定 */
|
||||
bool empty = queue.empty();
|
||||
```
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
queue.offer(5);
|
||||
queue.offer(4);
|
||||
|
||||
/* 最初の要素にアクセス */
|
||||
/* キュー先頭の要素にアクセス */
|
||||
int peek = queue.peek();
|
||||
|
||||
/* 要素をデキュー */
|
||||
@@ -98,7 +98,7 @@
|
||||
/* キューの長さを取得 */
|
||||
int size = queue.size();
|
||||
|
||||
/* キューが空かどうかチェック */
|
||||
/* キューが空かどうかを判定 */
|
||||
boolean isEmpty = queue.isEmpty();
|
||||
```
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
queue.Enqueue(5);
|
||||
queue.Enqueue(4);
|
||||
|
||||
/* 最初の要素にアクセス */
|
||||
/* キュー先頭の要素にアクセス */
|
||||
int peek = queue.Peek();
|
||||
|
||||
/* 要素をデキュー */
|
||||
@@ -124,7 +124,7 @@
|
||||
/* キューの長さを取得 */
|
||||
int size = queue.Count;
|
||||
|
||||
/* キューが空かどうかチェック */
|
||||
/* キューが空かどうかを判定 */
|
||||
bool isEmpty = queue.Count == 0;
|
||||
```
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
|
||||
```go title="queue_test.go"
|
||||
/* キューを初期化 */
|
||||
// Goでは、listをキューとして使用
|
||||
// Go では、list をキューとして使用する
|
||||
queue := list.New()
|
||||
|
||||
/* 要素をエンキュー */
|
||||
@@ -142,7 +142,7 @@
|
||||
queue.PushBack(5)
|
||||
queue.PushBack(4)
|
||||
|
||||
/* 最初の要素にアクセス */
|
||||
/* キュー先頭の要素にアクセス */
|
||||
peek := queue.Front()
|
||||
|
||||
/* 要素をデキュー */
|
||||
@@ -152,7 +152,7 @@
|
||||
/* キューの長さを取得 */
|
||||
size := queue.Len()
|
||||
|
||||
/* キューが空かどうかチェック */
|
||||
/* キューが空かどうかを判定 */
|
||||
isEmpty := queue.Len() == 0
|
||||
```
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
|
||||
```swift title="queue.swift"
|
||||
/* キューを初期化 */
|
||||
// Swiftには組み込みのキュークラスがないため、Arrayをキューとして使用
|
||||
// Swift には組み込みのキュークラスがないため、Array をキューとして使える
|
||||
var queue: [Int] = []
|
||||
|
||||
/* 要素をエンキュー */
|
||||
@@ -170,17 +170,17 @@
|
||||
queue.append(5)
|
||||
queue.append(4)
|
||||
|
||||
/* 最初の要素にアクセス */
|
||||
/* キュー先頭の要素にアクセス */
|
||||
let peek = queue.first!
|
||||
|
||||
/* 要素をデキュー */
|
||||
// 配列なので、removeFirstの計算量はO(n)
|
||||
// 配列であるため、removeFirst の計算量は O(n)
|
||||
let pool = queue.removeFirst()
|
||||
|
||||
/* キューの長さを取得 */
|
||||
let size = queue.count
|
||||
|
||||
/* キューが空かどうかチェック */
|
||||
/* キューが空かどうかを判定 */
|
||||
let isEmpty = queue.isEmpty
|
||||
```
|
||||
|
||||
@@ -188,7 +188,7 @@
|
||||
|
||||
```javascript title="queue.js"
|
||||
/* キューを初期化 */
|
||||
// JavaScriptには組み込みのキューがないため、Arrayをキューとして使用
|
||||
// JavaScript には組み込みのキューがないため、Array をキューとして使える
|
||||
const queue = [];
|
||||
|
||||
/* 要素をエンキュー */
|
||||
@@ -198,17 +198,17 @@
|
||||
queue.push(5);
|
||||
queue.push(4);
|
||||
|
||||
/* 最初の要素にアクセス */
|
||||
/* キュー先頭の要素にアクセス */
|
||||
const peek = queue[0];
|
||||
|
||||
/* 要素をデキュー */
|
||||
// 基礎構造が配列なので、shift()メソッドの時間計算量はO(n)
|
||||
// 基盤は配列であるため、shift() メソッドの時間計算量は O(n)
|
||||
const pop = queue.shift();
|
||||
|
||||
/* キューの長さを取得 */
|
||||
const size = queue.length;
|
||||
|
||||
/* キューが空かどうかチェック */
|
||||
/* キューが空かどうかを判定 */
|
||||
const empty = queue.length === 0;
|
||||
```
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
|
||||
```typescript title="queue.ts"
|
||||
/* キューを初期化 */
|
||||
// TypeScriptには組み込みのキューがないため、Arrayをキューとして使用
|
||||
// TypeScript には組み込みのキューがないため、Array をキューとして使える
|
||||
const queue: number[] = [];
|
||||
|
||||
/* 要素をエンキュー */
|
||||
@@ -226,17 +226,17 @@
|
||||
queue.push(5);
|
||||
queue.push(4);
|
||||
|
||||
/* 最初の要素にアクセス */
|
||||
/* キュー先頭の要素にアクセス */
|
||||
const peek = queue[0];
|
||||
|
||||
/* 要素をデキュー */
|
||||
// 基礎構造が配列なので、shift()メソッドの時間計算量はO(n)
|
||||
// 基盤は配列であるため、shift() メソッドの時間計算量は O(n)
|
||||
const pop = queue.shift();
|
||||
|
||||
/* キューの長さを取得 */
|
||||
const size = queue.length;
|
||||
|
||||
/* キューが空かどうかチェック */
|
||||
/* キューが空かどうかを判定 */
|
||||
const empty = queue.length === 0;
|
||||
```
|
||||
|
||||
@@ -244,7 +244,7 @@
|
||||
|
||||
```dart title="queue.dart"
|
||||
/* キューを初期化 */
|
||||
// DartのQueueクラスは双方向キューですが、キューとして使用できます
|
||||
// Dart では、キュークラス Qeque は双方向キューであり、キューとしても使用できる
|
||||
Queue<int> queue = Queue();
|
||||
|
||||
/* 要素をエンキュー */
|
||||
@@ -254,7 +254,7 @@
|
||||
queue.add(5);
|
||||
queue.add(4);
|
||||
|
||||
/* 最初の要素にアクセス */
|
||||
/* キュー先頭の要素にアクセス */
|
||||
int peek = queue.first;
|
||||
|
||||
/* 要素をデキュー */
|
||||
@@ -263,7 +263,7 @@
|
||||
/* キューの長さを取得 */
|
||||
int size = queue.length;
|
||||
|
||||
/* キューが空かどうかチェック */
|
||||
/* キューが空かどうかを判定 */
|
||||
bool isEmpty = queue.isEmpty;
|
||||
```
|
||||
|
||||
@@ -271,7 +271,7 @@
|
||||
|
||||
```rust title="queue.rs"
|
||||
/* 双方向キューを初期化 */
|
||||
// Rustでは、双方向キューを通常のキューとして使用
|
||||
// Rust では双方向キューを通常のキューとして使う
|
||||
let mut deque: VecDeque<u32> = VecDeque::new();
|
||||
|
||||
/* 要素をエンキュー */
|
||||
@@ -281,7 +281,7 @@
|
||||
deque.push_back(5);
|
||||
deque.push_back(4);
|
||||
|
||||
/* 最初の要素にアクセス */
|
||||
/* キュー先頭の要素にアクセス */
|
||||
if let Some(front) = deque.front() {
|
||||
}
|
||||
|
||||
@@ -292,32 +292,84 @@
|
||||
/* キューの長さを取得 */
|
||||
let size = deque.len();
|
||||
|
||||
/* キューが空かどうかチェック */
|
||||
/* キューが空かどうかを判定 */
|
||||
let is_empty = deque.is_empty();
|
||||
```
|
||||
|
||||
=== "C"
|
||||
|
||||
```c title="queue.c"
|
||||
// Cは組み込みのキューを提供していません
|
||||
// C には組み込みのキューがない
|
||||
```
|
||||
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title="queue.kt"
|
||||
/* キューを初期化 */
|
||||
val queue = LinkedList<Int>()
|
||||
|
||||
/* 要素をエンキュー */
|
||||
queue.offer(1)
|
||||
queue.offer(3)
|
||||
queue.offer(2)
|
||||
queue.offer(5)
|
||||
queue.offer(4)
|
||||
|
||||
/* キュー先頭の要素にアクセス */
|
||||
val peek = queue.peek()
|
||||
|
||||
/* 要素をデキュー */
|
||||
val pop = queue.poll()
|
||||
|
||||
/* キューの長さを取得 */
|
||||
val size = queue.size
|
||||
|
||||
/* キューが空かどうかを判定 */
|
||||
val isEmpty = queue.isEmpty()
|
||||
```
|
||||
|
||||
=== "Ruby"
|
||||
|
||||
```ruby title="queue.rb"
|
||||
# キューを初期化
|
||||
# Ruby 組み込みのキュー(Thread::Queue) には peek と走査メソッドがないため、Array をキューとして使える
|
||||
queue = []
|
||||
|
||||
# 要素をエンキュー
|
||||
queue.push(1)
|
||||
queue.push(3)
|
||||
queue.push(2)
|
||||
queue.push(5)
|
||||
queue.push(4)
|
||||
|
||||
# キュー先頭の要素にアクセス
|
||||
peek = queue.first
|
||||
|
||||
# 要素をデキュー
|
||||
# 注意:配列であるため、Array#shift メソッドの時間計算量は O(n)
|
||||
pop = queue.shift
|
||||
|
||||
# キューの長さを取得
|
||||
size = queue.length
|
||||
|
||||
# キューが空かどうかを判定
|
||||
is_empty = queue.empty?
|
||||
```
|
||||
|
||||
??? pythontutor "可視化実行"
|
||||
|
||||
https://pythontutor.com/render.html#code=from%20collections%20import%20deque%0A%0A%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E9%98%9F%E5%88%97%0A%20%20%20%20%23%20%E5%9C%A8%20Python%20%E4%B8%AD%EF%BC%8C%E6%88%91%E4%BB%AC%E4%B8%80%E8%88%AC%E5%B0%86%E5%8F%8C%E5%90%91%E9%98%9F%E5%88%97%E7%B1%BB%20deque%20%E7%9C%8B%E4%BD%9C%E9%98%9F%E5%88%97%E4%BD%BF%E7%94%A8%0A%20%20%20%20%23%20%E8%99%BD%E7%84%B6%20queue.Queue%28%29%20%E6%98%AF%E7%BA%AF%E6%AD%A3%E7%9A%84%E9%98%9F%E5%88%97%E7%B1%BB%EF%BC%8C%E4%BD%86%E4%B8%8D%E5%A4%AA%E5%A5%BD%E7%94%A8%0A%20%20%20%20que%20%3D%20deque%28%29%0A%0A%20%20%20%20%23%20%E5%85%83%E7%B4%A0%E5%85%A5%E9%98%9F%0A%20%20%20%20que.append%281%29%0A%20%20%20%20que.append%283%29%0A%20%20%20%20que.append%282%29%0A%20%20%20%20que.append%285%29%0A%20%20%20%20que.append%284%29%0A%20%20%20%20print%28%22%E9%98%9F%E5%88%97%20que%20%3D%22,%20que%29%0A%0A%20%20%20%20%23%20%E8%AE%BF%E9%97%AE%E9%98%9F%E9%A6%96%E5%85%83%E7%B4%A0%0A%20%20%20%20front%20%3D%20que%5B0%5D%0A%20%20%20%20print%28%22%E9%98%9F%E9%A6%96%E5%85%83%E7%B4%A0%20front%20%3D%22,%20front%29%0A%0A%20%20%20%20%23%20%E5%85%83%E7%B4%A0%E5%87%BA%E9%98%9F%0A%20%20%20%20pop%20%3D%20que.popleft%28%29%0A%20%20%20%20print%28%22%E5%87%BA%E9%98%9F%E5%85%83%E7%B4%A0%20pop%20%3D%22,%20pop%29%0A%20%20%20%20print%28%22%E5%87%BA%E9%98%9F%E5%90%8E%20que%20%3D%22,%20que%29%0A%0A%20%20%20%20%23%20%E8%8E%B7%E5%8F%96%E9%98%9F%E5%88%97%E7%9A%84%E9%95%BF%E5%BA%A6%0A%20%20%20%20size%20%3D%20len%28que%29%0A%20%20%20%20print%28%22%E9%98%9F%E5%88%97%E9%95%BF%E5%BA%A6%20size%20%3D%22,%20size%29%0A%0A%20%20%20%20%23%20%E5%88%A4%E6%96%AD%E9%98%9F%E5%88%97%E6%98%AF%E5%90%A6%E4%B8%BA%E7%A9%BA%0A%20%20%20%20is_empty%20%3D%20len%28que%29%20%3D%3D%200%0A%20%20%20%20print%28%22%E9%98%9F%E5%88%97%E6%98%AF%E5%90%A6%E4%B8%BA%E7%A9%BA%20%3D%22,%20is_empty%29&cumulative=false&curInstr=3&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
|
||||
|
||||
## キューの実装
|
||||
|
||||
キューを実装するには、一方の端で要素を追加し、もう一方の端で要素を削除できるデータ構造が必要です。連結リストと配列の両方がこの要件を満たします。
|
||||
キューを実装するには、一方の端で要素を追加し、もう一方の端で要素を削除できるデータ構造が必要です。連結リストと配列はいずれもこの条件を満たします。
|
||||
|
||||
### 連結リストベースの実装
|
||||
### 連結リストに基づく実装
|
||||
|
||||
下図に示すように、連結リストの「ヘッドノード」と「テールノード」をそれぞれキューの「フロント」と「リア」と考えることができます。ノードは後ろでのみ追加でき、前でのみ削除できるように規定されています。
|
||||
下図のように、連結リストの「先頭ノード」と「末尾ノード」をそれぞれ「キュー先頭」と「キュー末尾」とみなし、キュー末尾ではノードの追加のみ、キュー先頭ではノードの削除のみを行うようにします。
|
||||
|
||||
=== "LinkedListQueue"
|
||||

|
||||

|
||||
|
||||
=== "push()"
|
||||

|
||||
@@ -325,27 +377,27 @@
|
||||
=== "pop()"
|
||||

|
||||
|
||||
以下は、連結リストを使用してキューを実装するコードです:
|
||||
以下は連結リストでキューを実装するコードです:
|
||||
|
||||
```src
|
||||
[file]{linkedlist_queue}-[class]{linked_list_queue}-[func]{}
|
||||
```
|
||||
|
||||
### 配列ベースの実装
|
||||
### 配列に基づく実装
|
||||
|
||||
配列の最初の要素を削除する時間計算量は$O(n)$で、デキュー操作が非効率になります。しかし、この問題は以下のように巧妙に回避できます。
|
||||
配列で先頭要素を削除する時間計算量は $O(n)$ であり、そのままではデキュー操作の効率が低くなります。しかし、次の巧妙な方法によってこの問題を回避できます。
|
||||
|
||||
変数`front`を使用してフロント要素のインデックスを示し、変数`size`を維持してキューの長さを記録します。`rear = front + size`を定義し、これはテール要素の直後の位置を指します。
|
||||
変数 `front` を用いてキュー先頭要素のインデックスを指し、さらに変数 `size` でキューの長さを記録できます。`rear = front + size` と定義すると、この式で得られる `rear` はキュー末尾要素の次の位置を指します。
|
||||
|
||||
この設計により、**配列内の要素の有効な間隔は`[front, rear - 1]`です**。各操作の実装方法を下図に示します。
|
||||
この設計に基づくと、**配列内で要素を含む有効区間は `[front, rear - 1]`** となります。各種操作の実装方法を下図に示します。
|
||||
|
||||
- エンキュー操作:入力要素を`rear`インデックスに割り当て、`size`を1増加させます。
|
||||
- デキュー操作:単に`front`を1増加させ、`size`を1減少させます。
|
||||
- エンキュー操作:入力要素を `rear` の位置に代入し、`size` を 1 増やします。
|
||||
- デキュー操作:`front` を 1 増やし、`size` を 1 減らすだけです。
|
||||
|
||||
エンキューとデキュー操作は両方とも単一の操作のみを必要とし、それぞれの時間計算量は$O(1)$です。
|
||||
このように、エンキューとデキューはいずれも 1 回の操作だけで済み、時間計算量はともに $O(1)$ です。
|
||||
|
||||
=== "ArrayQueue"
|
||||

|
||||

|
||||
|
||||
=== "push()"
|
||||

|
||||
@@ -353,19 +405,19 @@
|
||||
=== "pop()"
|
||||

|
||||
|
||||
問題に気づくかもしれません:エンキューとデキュー操作が継続的に実行されると、`front`と`rear`の両方が右に移動し、**最終的に配列の末尾に到達してそれ以上移動できなくなります**。これを解決するために、配列を「循環配列」として扱い、配列の末尾を先頭に接続します。
|
||||
ここで 1 つ問題があります。エンキューとデキューを繰り返すと、`front` と `rear` はどちらも右へ移動し続け、**配列の末尾に達するとそれ以上進めなくなります**。この問題を解決するために、配列を先頭と末尾がつながった「環状配列」とみなします。
|
||||
|
||||
循環配列では、`front`または`rear`が末尾に到達すると、配列の先頭にループバックする必要があります。この循環パターンは、以下のコードに示すように「剰余演算」で実現できます:
|
||||
環状配列では、`front` または `rear` が配列末尾を越えたときに、直ちに配列先頭へ戻って走査を続けられるようにする必要があります。この周期的な規則は「剰余演算」によって実現できます。コードは次のとおりです:
|
||||
|
||||
```src
|
||||
[file]{array_queue}-[class]{array_queue}-[func]{}
|
||||
```
|
||||
|
||||
上記のキュー実装にはまだ制限があります:長さが固定されています。しかし、この問題は解決が困難ではありません。配列を必要に応じて自動拡張できる動的配列に置き換えることができます。興味のある読者は自分で実装してみてください。
|
||||
上記の実装によるキューにも制約があり、長さを可変にできません。しかし、この問題の解決は難しくなく、配列を動的配列に置き換えれば容量拡張の仕組みを導入できます。興味があれば自分で実装してみてください。
|
||||
|
||||
2つの実装の比較はスタックの場合と一貫しており、ここでは繰り返しません。
|
||||
2 つの実装の比較に関する結論はスタックの場合と同じなので、ここでは繰り返しません。
|
||||
|
||||
## キューの典型的な応用
|
||||
|
||||
- **Amazonの注文**:買い物客が注文を行った後、これらの注文はキューに参加し、システムは順番に処理します。独身の日などのイベント中は、短時間で大量の注文が生成され、高い同時実行性がエンジニアにとって重要な課題となります。
|
||||
- **様々なToDoリスト**:「先着順」機能が必要なシナリオ、例えばプリンターのタスクキューやレストランの配達キューなど、キューで処理順序を効果的に維持できます。
|
||||
- **淘宝の注文**。購入者が注文すると、その注文はキューに追加され、システムは順番に従って注文を処理します。ダブルイレブンの期間には短時間で膨大な注文が発生するため、高並行性がエンジニアにとって重点的に解決すべき課題になります。
|
||||
- **各種の待機事項**。先着順の機能を実現する必要があるあらゆる場面、たとえばプリンターのジョブキューや飲食店の配膳キューなどでは、キューによって処理順序を効果的に維持できます。
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
# スタック
|
||||
|
||||
<u>スタック</u>は、後入先出(LIFO)の原則に従う線形データ構造です。
|
||||
<u>スタック(stack)</u>は、後入れ先出しの論理に従う線形データ構造です。
|
||||
|
||||
スタックをテーブル上の皿の山に例えることができます。底の皿にアクセスするには、まず上の皿を取り除く必要があります。皿を様々な種類の要素(整数、文字、オブジェクトなど)に置き換えることで、スタックと呼ばれるデータ構造を得ることができます。
|
||||
スタックは机の上に積まれた皿の山にたとえられます。1回に1枚の皿しか動かせないとすると、いちばん下の皿を取り出すには、上にある皿を順番にどかす必要があります。この皿をさまざまな型の要素(整数、文字、オブジェクトなど)に置き換えたものが、スタックというデータ構造です。
|
||||
|
||||
下図に示すように、要素の山の上部を「スタックのトップ」、下部を「スタックのボトム」と呼びます。スタックのトップに要素を追加する操作を「プッシュ」、トップ要素を削除する操作を「ポップ」と呼びます。
|
||||
下図のように、積み重なった要素の上端を「スタックトップ」、下端を「スタックボトム」と呼びます。要素をスタックトップに追加する操作を「プッシュ」、スタックトップの要素を削除する操作を「ポップ」と呼びます。
|
||||
|
||||

|
||||

|
||||
|
||||
## スタックの一般的な操作
|
||||
## スタックの基本操作
|
||||
|
||||
スタックの一般的な操作を下表に示します。具体的なメソッド名は使用するプログラミング言語によって異なります。ここでは、例として`push()`、`pop()`、`peek()`を使用します。
|
||||
スタックの基本操作を以下の表に示します。具体的なメソッド名は使用するプログラミング言語によって異なります。ここでは、一般的な `push()`、`pop()`、`peek()` を例に挙げます。
|
||||
|
||||
<p align="center"> 表 <id> スタック操作の効率 </p>
|
||||
<p align="center"> 表 <id> スタックの操作効率 </p>
|
||||
|
||||
| メソッド | 説明 | 時間計算量 |
|
||||
| -------- | ----------------------------------------------- | --------------- |
|
||||
| `push()` | 要素をスタックにプッシュ(トップに追加) | $O(1)$ |
|
||||
| `pop()` | スタックからトップ要素をポップ | $O(1)$ |
|
||||
| `peek()` | スタックのトップ要素にアクセス | $O(1)$ |
|
||||
| メソッド | 説明 | 時間計算量 |
|
||||
| -------- | ---------------------- | ---------- |
|
||||
| `push()` | 要素をプッシュする(スタックトップに追加) | $O(1)$ |
|
||||
| `pop()` | スタックトップの要素をポップする | $O(1)$ |
|
||||
| `peek()` | スタックトップの要素にアクセスする | $O(1)$ |
|
||||
|
||||
通常、プログラミング言語に組み込まれているスタッククラスを直接使用できます。ただし、一部の言語では具体的にスタッククラスを提供していない場合があります。これらの場合、言語の「配列」または「連結リスト」をスタックとして使用し、プログラムでスタックロジックに関連しない操作を無視できます。
|
||||
通常は、プログラミング言語に組み込まれているスタッククラスをそのまま利用できます。ただし、専用のスタッククラスが用意されていない言語もあります。その場合は、その言語の「配列」や「連結リスト」をスタックとして用い、プログラムのロジック上でスタックに無関係な操作を無視します。
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title="stack.py"
|
||||
# スタックを初期化
|
||||
# Pythonには組み込みのスタッククラスがないため、listをスタックとして使用
|
||||
# Python には組み込みのスタッククラスがないため、list をスタックとして使用できる
|
||||
stack: list[int] = []
|
||||
|
||||
# 要素をスタックにプッシュ
|
||||
# 要素をプッシュ
|
||||
stack.append(1)
|
||||
stack.append(3)
|
||||
stack.append(2)
|
||||
stack.append(5)
|
||||
stack.append(4)
|
||||
|
||||
# スタックのトップ要素にアクセス
|
||||
# スタックトップの要素にアクセス
|
||||
peek: int = stack[-1]
|
||||
|
||||
# スタックから要素をポップ
|
||||
# 要素をポップ
|
||||
pop: int = stack.pop()
|
||||
|
||||
# スタックの長さを取得
|
||||
size: int = len(stack)
|
||||
|
||||
# スタックが空かどうかチェック
|
||||
# 空かどうかを判定
|
||||
is_empty: bool = len(stack) == 0
|
||||
```
|
||||
|
||||
@@ -55,23 +55,23 @@
|
||||
/* スタックを初期化 */
|
||||
stack<int> stack;
|
||||
|
||||
/* 要素をスタックにプッシュ */
|
||||
/* 要素をプッシュ */
|
||||
stack.push(1);
|
||||
stack.push(3);
|
||||
stack.push(2);
|
||||
stack.push(5);
|
||||
stack.push(4);
|
||||
|
||||
/* スタックのトップ要素にアクセス */
|
||||
/* スタックトップの要素にアクセス */
|
||||
int top = stack.top();
|
||||
|
||||
/* スタックから要素をポップ */
|
||||
/* 要素をポップ */
|
||||
stack.pop(); // 戻り値なし
|
||||
|
||||
/* スタックの長さを取得 */
|
||||
int size = stack.size();
|
||||
|
||||
/* スタックが空かどうかチェック */
|
||||
/* 空かどうかを判定 */
|
||||
bool empty = stack.empty();
|
||||
```
|
||||
|
||||
@@ -81,23 +81,23 @@
|
||||
/* スタックを初期化 */
|
||||
Stack<Integer> stack = new Stack<>();
|
||||
|
||||
/* 要素をスタックにプッシュ */
|
||||
/* 要素をプッシュ */
|
||||
stack.push(1);
|
||||
stack.push(3);
|
||||
stack.push(2);
|
||||
stack.push(5);
|
||||
stack.push(4);
|
||||
|
||||
/* スタックのトップ要素にアクセス */
|
||||
/* スタックトップの要素にアクセス */
|
||||
int peek = stack.peek();
|
||||
|
||||
/* スタックから要素をポップ */
|
||||
/* 要素をポップ */
|
||||
int pop = stack.pop();
|
||||
|
||||
/* スタックの長さを取得 */
|
||||
int size = stack.size();
|
||||
|
||||
/* スタックが空かどうかチェック */
|
||||
/* 空かどうかを判定 */
|
||||
boolean isEmpty = stack.isEmpty();
|
||||
```
|
||||
|
||||
@@ -107,23 +107,23 @@
|
||||
/* スタックを初期化 */
|
||||
Stack<int> stack = new();
|
||||
|
||||
/* 要素をスタックにプッシュ */
|
||||
/* 要素をプッシュ */
|
||||
stack.Push(1);
|
||||
stack.Push(3);
|
||||
stack.Push(2);
|
||||
stack.Push(5);
|
||||
stack.Push(4);
|
||||
|
||||
/* スタックのトップ要素にアクセス */
|
||||
/* スタックトップの要素にアクセス */
|
||||
int peek = stack.Peek();
|
||||
|
||||
/* スタックから要素をポップ */
|
||||
/* 要素をポップ */
|
||||
int pop = stack.Pop();
|
||||
|
||||
/* スタックの長さを取得 */
|
||||
int size = stack.Count;
|
||||
|
||||
/* スタックが空かどうかチェック */
|
||||
/* 空かどうかを判定 */
|
||||
bool isEmpty = stack.Count == 0;
|
||||
```
|
||||
|
||||
@@ -131,27 +131,27 @@
|
||||
|
||||
```go title="stack_test.go"
|
||||
/* スタックを初期化 */
|
||||
// Goでは、Sliceをスタックとして使用することが推奨されます
|
||||
// Go では、Slice をスタックとして使うのが一般的
|
||||
var stack []int
|
||||
|
||||
/* 要素をスタックにプッシュ */
|
||||
/* 要素をプッシュ */
|
||||
stack = append(stack, 1)
|
||||
stack = append(stack, 3)
|
||||
stack = append(stack, 2)
|
||||
stack = append(stack, 5)
|
||||
stack = append(stack, 4)
|
||||
|
||||
/* スタックのトップ要素にアクセス */
|
||||
/* スタックトップの要素にアクセス */
|
||||
peek := stack[len(stack)-1]
|
||||
|
||||
/* スタックから要素をポップ */
|
||||
/* 要素をポップ */
|
||||
pop := stack[len(stack)-1]
|
||||
stack = stack[:len(stack)-1]
|
||||
|
||||
/* スタックの長さを取得 */
|
||||
size := len(stack)
|
||||
|
||||
/* スタックが空かどうかチェック */
|
||||
/* 空かどうかを判定 */
|
||||
isEmpty := len(stack) == 0
|
||||
```
|
||||
|
||||
@@ -159,26 +159,26 @@
|
||||
|
||||
```swift title="stack.swift"
|
||||
/* スタックを初期化 */
|
||||
// Swiftには組み込みのスタッククラスがないため、Arrayをスタックとして使用
|
||||
// Swift には組み込みのスタッククラスがないため、Array をスタックとして使用できる
|
||||
var stack: [Int] = []
|
||||
|
||||
/* 要素をスタックにプッシュ */
|
||||
/* 要素をプッシュ */
|
||||
stack.append(1)
|
||||
stack.append(3)
|
||||
stack.append(2)
|
||||
stack.append(5)
|
||||
stack.append(4)
|
||||
|
||||
/* スタックのトップ要素にアクセス */
|
||||
/* スタックトップの要素にアクセス */
|
||||
let peek = stack.last!
|
||||
|
||||
/* スタックから要素をポップ */
|
||||
/* 要素をポップ */
|
||||
let pop = stack.removeLast()
|
||||
|
||||
/* スタックの長さを取得 */
|
||||
let size = stack.count
|
||||
|
||||
/* スタックが空かどうかチェック */
|
||||
/* 空かどうかを判定 */
|
||||
let isEmpty = stack.isEmpty
|
||||
```
|
||||
|
||||
@@ -186,26 +186,26 @@
|
||||
|
||||
```javascript title="stack.js"
|
||||
/* スタックを初期化 */
|
||||
// JavaScriptには組み込みのスタッククラスがないため、Arrayをスタックとして使用
|
||||
// JavaScript には組み込みのスタッククラスがないため、Array をスタックとして使用できる
|
||||
const stack = [];
|
||||
|
||||
/* 要素をスタックにプッシュ */
|
||||
/* 要素をプッシュ */
|
||||
stack.push(1);
|
||||
stack.push(3);
|
||||
stack.push(2);
|
||||
stack.push(5);
|
||||
stack.push(4);
|
||||
|
||||
/* スタックのトップ要素にアクセス */
|
||||
/* スタックトップの要素にアクセス */
|
||||
const peek = stack[stack.length-1];
|
||||
|
||||
/* スタックから要素をポップ */
|
||||
/* 要素をポップ */
|
||||
const pop = stack.pop();
|
||||
|
||||
/* スタックの長さを取得 */
|
||||
const size = stack.length;
|
||||
|
||||
/* スタックが空かどうかチェック */
|
||||
/* 空かどうかを判定 */
|
||||
const is_empty = stack.length === 0;
|
||||
```
|
||||
|
||||
@@ -213,26 +213,26 @@
|
||||
|
||||
```typescript title="stack.ts"
|
||||
/* スタックを初期化 */
|
||||
// TypeScriptには組み込みのスタッククラスがないため、Arrayをスタックとして使用
|
||||
// TypeScript には組み込みのスタッククラスがないため、Array をスタックとして使用できる
|
||||
const stack: number[] = [];
|
||||
|
||||
/* 要素をスタックにプッシュ */
|
||||
/* 要素をプッシュ */
|
||||
stack.push(1);
|
||||
stack.push(3);
|
||||
stack.push(2);
|
||||
stack.push(5);
|
||||
stack.push(4);
|
||||
|
||||
/* スタックのトップ要素にアクセス */
|
||||
/* スタックトップの要素にアクセス */
|
||||
const peek = stack[stack.length - 1];
|
||||
|
||||
/* スタックから要素をポップ */
|
||||
/* 要素をポップ */
|
||||
const pop = stack.pop();
|
||||
|
||||
/* スタックの長さを取得 */
|
||||
const size = stack.length;
|
||||
|
||||
/* スタックが空かどうかチェック */
|
||||
/* 空かどうかを判定 */
|
||||
const is_empty = stack.length === 0;
|
||||
```
|
||||
|
||||
@@ -240,26 +240,26 @@
|
||||
|
||||
```dart title="stack.dart"
|
||||
/* スタックを初期化 */
|
||||
// Dartには組み込みのスタッククラスがないため、Listをスタックとして使用
|
||||
// Dart には組み込みのスタッククラスがないため、List をスタックとして使用できる
|
||||
List<int> stack = [];
|
||||
|
||||
/* 要素をスタックにプッシュ */
|
||||
/* 要素をプッシュ */
|
||||
stack.add(1);
|
||||
stack.add(3);
|
||||
stack.add(2);
|
||||
stack.add(5);
|
||||
stack.add(4);
|
||||
|
||||
/* スタックのトップ要素にアクセス */
|
||||
/* スタックトップの要素にアクセス */
|
||||
int peek = stack.last;
|
||||
|
||||
/* スタックから要素をポップ */
|
||||
/* 要素をポップ */
|
||||
int pop = stack.removeLast();
|
||||
|
||||
/* スタックの長さを取得 */
|
||||
int size = stack.length;
|
||||
|
||||
/* スタックが空かどうかチェック */
|
||||
/* 空かどうかを判定 */
|
||||
bool isEmpty = stack.isEmpty;
|
||||
```
|
||||
|
||||
@@ -267,55 +267,106 @@
|
||||
|
||||
```rust title="stack.rs"
|
||||
/* スタックを初期化 */
|
||||
// Vecをスタックとして使用
|
||||
// Vec をスタックとして使用する
|
||||
let mut stack: Vec<i32> = Vec::new();
|
||||
|
||||
/* 要素をスタックにプッシュ */
|
||||
/* 要素をプッシュ */
|
||||
stack.push(1);
|
||||
stack.push(3);
|
||||
stack.push(2);
|
||||
stack.push(5);
|
||||
stack.push(4);
|
||||
|
||||
/* スタックのトップ要素にアクセス */
|
||||
/* スタックトップの要素にアクセス */
|
||||
let top = stack.last().unwrap();
|
||||
|
||||
/* スタックから要素をポップ */
|
||||
/* 要素をポップ */
|
||||
let pop = stack.pop().unwrap();
|
||||
|
||||
/* スタックの長さを取得 */
|
||||
let size = stack.len();
|
||||
|
||||
/* スタックが空かどうかチェック */
|
||||
/* 空かどうかを判定 */
|
||||
let is_empty = stack.is_empty();
|
||||
```
|
||||
|
||||
=== "C"
|
||||
|
||||
```c title="stack.c"
|
||||
// Cは組み込みのスタックを提供していません
|
||||
// C には組み込みのスタックがない
|
||||
```
|
||||
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title="stack.kt"
|
||||
/* スタックを初期化 */
|
||||
val stack = Stack<Int>()
|
||||
|
||||
/* 要素をプッシュ */
|
||||
stack.push(1)
|
||||
stack.push(3)
|
||||
stack.push(2)
|
||||
stack.push(5)
|
||||
stack.push(4)
|
||||
|
||||
/* スタックトップの要素にアクセス */
|
||||
val peek = stack.peek()
|
||||
|
||||
/* 要素をポップ */
|
||||
val pop = stack.pop()
|
||||
|
||||
/* スタックの長さを取得 */
|
||||
val size = stack.size
|
||||
|
||||
/* 空かどうかを判定 */
|
||||
val isEmpty = stack.isEmpty()
|
||||
```
|
||||
|
||||
=== "Ruby"
|
||||
|
||||
```ruby title="stack.rb"
|
||||
# スタックを初期化
|
||||
# Ruby には組み込みのスタッククラスがないため、Array をスタックとして使用できる
|
||||
stack = []
|
||||
|
||||
# 要素をプッシュ
|
||||
stack << 1
|
||||
stack << 3
|
||||
stack << 2
|
||||
stack << 5
|
||||
stack << 4
|
||||
|
||||
# スタックトップの要素にアクセス
|
||||
peek = stack.last
|
||||
|
||||
# 要素をポップ
|
||||
pop = stack.pop
|
||||
|
||||
# スタックの長さを取得
|
||||
size = stack.length
|
||||
|
||||
# 空かどうかを判定
|
||||
is_empty = stack.empty?
|
||||
```
|
||||
|
||||
??? pythontutor "実行の可視化"
|
||||
|
||||
https://pythontutor.com/render.html#code=%22%22%22Driver%20Code%22%22%22%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20%23%20%E5%88%9D%E5%A7%8B%E5%8C%96%E6%A0%88%0A%20%20%20%20%23%20Python%20%E6%B2%A1%E6%9C%89%E5%86%85%E7%BD%AE%E7%9A%84%E6%A0%88%E7%B1%BB%EF%BC%8C%E5%8F%AF%E4%BB%A5%E6%8A%8A%20list%20%E5%BD%93%E4%BD%9C%E6%A0%88%E6%9D%A5%E4%BD%BF%E7%94%A8%0A%20%20%20%20stack%20%3D%20%5B%5D%0A%0A%20%20%20%20%23%20%E5%85%83%E7%B4%A0%E5%85%A5%E6%A0%88%0A%20%20%20%20stack.append%281%29%0A%20%20%20%20stack.append%283%29%0A%20%20%20%20stack.append%282%29%0A%20%20%20%20stack.append%285%29%0A%20%20%20%20stack.append%284%29%0A%20%20%20%20print%28%22%E6%A0%88%20stack%20%3D%22,%20stack%29%0A%0A%20%20%20%20%23%20%E8%AE%BF%E9%97%AE%E6%A0%88%E9%A1%B6%E5%85%83%E7%B4%A0%0A%20%20%20%20peek%20%3D%20stack%5B-1%5D%0A%20%20%20%20print%28%22%E6%A0%88%E9%A1%B6%E5%85%83%E7%B4%A0%20peek%20%3D%22,%20peek%29%0A%0A%20%20%20%20%23%20%E5%85%83%E7%B4%A0%E5%87%BA%E6%A0%88%0A%20%20%20%20pop%20%3D%20stack.pop%28%29%0A%20%20%20%20print%28%22%E5%87%BA%E6%A0%88%E5%85%83%E7%B4%A0%20pop%20%3D%22,%20pop%29%0A%20%20%20%20print%28%22%E5%87%BA%E6%A0%88%E5%90%8E%20stack%20%3D%22,%20stack%29%0A%0A%20%20%20%20%23%20%E8%8E%B7%E5%8F%96%E6%A0%88%E7%9A%84%E9%95%BF%E5%BA%A6%0A%20%20%20%20size%20%3D%20len%28stack%29%0A%20%20%20%20print%28%22%E6%A0%88%E7%9A%84%E9%95%BF%E5%BA%A6%20size%20%3D%22,%20size%29%0A%0A%20%20%20%20%23%20%E5%88%A4%E6%96%AD%E6%98%AF%E5%90%A6%E4%B8%BA%E7%A9%BA%0A%20%20%20%20is_empty%20%3D%20len%28stack%29%20%3D%3D%200%0A%20%20%20%20print%28%22%E6%A0%88%E6%98%AF%E5%90%A6%E4%B8%BA%E7%A9%BA%20%3D%22,%20is_empty%29&cumulative=false&curInstr=2&heapPrimitives=nevernest&mode=display&origin=opt-frontend.js&py=311&rawInputLstJSON=%5B%5D&textReferences=false
|
||||
|
||||
## スタックの実装
|
||||
|
||||
スタックがどのように動作するかをより深く理解するために、自分でスタッククラスを実装してみましょう。
|
||||
スタックの動作の仕組みをより深く理解するために、自分でスタッククラスを実装してみましょう。
|
||||
|
||||
スタックは後入先出の原則に従うため、スタックのトップでのみ要素を追加または削除できます。しかし、配列と連結リストの両方は任意の位置で要素を追加・削除できるため、**スタックは制限された配列または連結リストと見なすことができます**。言い換えれば、配列や連結リストの特定の無関係な操作を「遮蔽」して、外部の動作をスタックの特性に合わせることができます。
|
||||
スタックは後入れ先出しの原則に従うため、要素の追加や削除はスタックトップでしか行えません。一方、配列や連結リストでは任意の位置で要素を追加・削除できます。**つまり、スタックは制限付きの配列または連結リストとみなせます。** 言い換えると、配列や連結リストのうち無関係な操作を「隠蔽」することで、外から見た振る舞いをスタックの特性に合わせられます。
|
||||
|
||||
### 連結リストベースの実装
|
||||
### 連結リストによる実装
|
||||
|
||||
連結リストを使用してスタックを実装する場合、リストのヘッドノードをスタックのトップ、テールノードをスタックのボトムと考えることができます。
|
||||
連結リストでスタックを実装する場合、連結リストの先頭ノードをスタックトップ、末尾ノードをスタックボトムとみなせます。
|
||||
|
||||
下図に示すように、プッシュ操作では、単に連結リストのヘッドに要素を挿入します。このノード挿入方法は「ヘッド挿入」として知られています。ポップ操作では、リストからヘッドノードを削除するだけです。
|
||||
下図のように、プッシュ操作では要素を連結リストの先頭に挿入するだけでよく、このノード挿入方法は「頭部挿入法」と呼ばれます。ポップ操作では、先頭ノードを連結リストから削除するだけです。
|
||||
|
||||
=== "LinkedListStack"
|
||||

|
||||

|
||||
|
||||
=== "push()"
|
||||

|
||||
@@ -323,18 +374,18 @@
|
||||
=== "pop()"
|
||||

|
||||
|
||||
以下は、連結リストに基づくスタック実装のサンプルコードです:
|
||||
以下は、連結リストによってスタックを実装したコード例です:
|
||||
|
||||
```src
|
||||
[file]{linkedlist_stack}-[class]{linked_list_stack}-[func]{}
|
||||
```
|
||||
|
||||
### 配列ベースの実装
|
||||
### 配列による実装
|
||||
|
||||
配列を使用してスタックを実装する場合、配列の末尾をスタックのトップと考えることができます。下図に示すように、プッシュとポップ操作は、それぞれ配列の末尾での要素の追加と削除に対応し、どちらも時間計算量$O(1)$です。
|
||||
配列でスタックを実装する場合、配列の末尾をスタックトップとして扱えます。下図のように、プッシュとポップはそれぞれ配列末尾への要素追加と削除に対応し、どちらの時間計算量も $O(1)$ です。
|
||||
|
||||
=== "ArrayStack"
|
||||

|
||||

|
||||
|
||||
=== "push()"
|
||||

|
||||
@@ -342,7 +393,7 @@
|
||||
=== "pop()"
|
||||

|
||||
|
||||
スタックにプッシュされる要素が継続的に増加する可能性があるため、動的配列を使用でき、配列拡張を自分で処理する必要がありません。以下はサンプルコードです:
|
||||
プッシュされる要素は際限なく増える可能性があるため、動的配列を使えば、配列の拡張を自前で処理する必要がありません。以下にコード例を示します:
|
||||
|
||||
```src
|
||||
[file]{array_stack}-[class]{array_stack}-[func]{}
|
||||
@@ -350,30 +401,30 @@
|
||||
|
||||
## 2つの実装の比較
|
||||
|
||||
**サポートされる操作**
|
||||
**対応する操作**
|
||||
|
||||
両方の実装は、スタックで定義されたすべての操作をサポートします。配列実装はさらにランダムアクセスをサポートしますが、これはスタック定義の範囲を超えており、一般的には使用されません。
|
||||
どちらの実装も、スタックの定義に含まれる各種操作をサポートします。配列ベースの実装はランダムアクセスも可能ですが、これはスタックの定義範囲を超えているため、通常は利用しません。
|
||||
|
||||
**時間効率**
|
||||
|
||||
配列ベースの実装では、プッシュとポップ操作の両方が事前に割り当てられた連続メモリで発生し、良好なキャッシュ局所性があるため効率が高くなります。しかし、プッシュ操作が配列容量を超える場合、リサイズメカニズムがトリガーされ、そのプッシュ操作の時間計算量は$O(n)$になります。
|
||||
配列ベースの実装では、プッシュとポップの両方があらかじめ確保された連続メモリ上で行われるため、キャッシュ局所性が高く、効率に優れます。ただし、プッシュ時に配列容量を超えると拡張処理が発生し、その1回のプッシュの時間計算量は $O(n)$ になります。
|
||||
|
||||
連結リスト実装では、リスト拡張は非常に柔軟で、配列拡張のような効率低下の問題はありません。しかし、プッシュ操作にはノードオブジェクトの初期化とポインタの変更が必要なため、効率は比較的低くなります。プッシュされる要素がすでにノードオブジェクトの場合、初期化ステップをスキップでき、効率が向上します。
|
||||
連結リストベースの実装では、サイズ拡張が非常に柔軟であり、前述のような配列拡張による効率低下はありません。ただし、プッシュ時にはノードオブジェクトの初期化とポインタの更新が必要になるため、効率は相対的に低くなります。もっとも、プッシュする要素自体がノードオブジェクトであれば、初期化の手間を省けるため、効率を高められます。
|
||||
|
||||
したがって、プッシュとポップ操作の要素が`int`や`double`などの基本データ型の場合、以下の結論を導くことができます:
|
||||
以上を踏まえると、プッシュおよびポップの対象が `int` や `double` のような基本データ型である場合、次の結論が得られます。
|
||||
|
||||
- 配列ベースのスタック実装は拡張時に効率が低下しますが、拡張は低頻度操作であるため、平均効率は高くなります。
|
||||
- 連結リストベースのスタック実装はより安定した効率パフォーマンスを提供します。
|
||||
- 配列ベースのスタックは拡張時に効率が低下しますが、拡張は低頻度の操作であるため、平均効率はより高くなります。
|
||||
- 連結リストベースのスタックは、より安定した効率を提供できます。
|
||||
|
||||
**空間効率**
|
||||
|
||||
リストを初期化する際、システムは「初期容量」を割り当てますが、これは実際の必要量を超える可能性があります。さらに、拡張メカニズムは通常、特定の係数(2倍など)で容量を増加させ、これも実際の必要量を超える可能性があります。したがって、**配列ベースのスタックは一部の空間を無駄にする可能性があります**。
|
||||
リストを初期化するとき、システムは「初期容量」を割り当てますが、この容量は実際の必要量を上回ることがあります。また、拡張は通常、一定の倍率(たとえば2倍)で行われるため、拡張後の容量も実際の必要量を超える可能性があります。したがって、**配列ベースのスタックは一定のメモリ浪費を招く可能性があります。**
|
||||
|
||||
しかし、連結リストノードはポインタを格納するための追加空間が必要なため、**連結リストノードが占有する空間は比較的大きくなります**。
|
||||
一方で、連結リストのノードはポインタを追加で保持する必要があるため、**連結リストノードは相対的に大きな領域を占有します。**
|
||||
|
||||
まとめると、どちらの実装がよりメモリ効率的かを単純に判断することはできません。特定の状況に基づく分析が必要です。
|
||||
以上より、どちらの実装がより省メモリかを単純に断定することはできず、具体的な状況に応じて分析する必要があります。
|
||||
|
||||
## スタックの典型的な応用
|
||||
|
||||
- **ブラウザの戻ると進む、ソフトウェアの元に戻すとやり直し**。新しいWebページを開くたびに、ブラウザは前のページをスタックにプッシュし、戻る操作(本質的にはポップ操作)を通じて前のページに戻ることができます。戻ると進むの両方をサポートするには、2つのスタックが連携して動作する必要があります。
|
||||
- **プログラムのメモリ管理**。関数が呼び出されるたびに、システムはスタックのトップにスタックフレームを追加して関数のコンテキスト情報を記録します。再帰関数では、下方向の再帰フェーズはスタックへのプッシュを続け、上方向のバックトラッキングフェーズはスタックからのポップを続けます。
|
||||
- **ブラウザにおける戻ると進む、ソフトウェアにおける取り消しとやり直し**。新しいWebページを開くたびに、ブラウザは直前のページをスタックにプッシュするため、戻る操作によって前のページに戻れます。戻る操作は実際にはポップに相当します。戻ると進むを同時にサポートするには、2つのスタックを組み合わせて実現する必要があります。
|
||||
- **プログラムのメモリ管理**。関数を呼び出すたびに、システムはスタックトップにスタックフレームを追加し、関数のコンテキスト情報を記録します。再帰関数では、下向きに再帰していく段階でプッシュが繰り返され、上向きにバックトラックする段階でポップが繰り返されます。
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
# まとめ
|
||||
|
||||
### 重要なポイント
|
||||
### 要点の振り返り
|
||||
|
||||
- スタックは後入れ先出し(LIFO)の原則に従うデータ構造で、配列または連結リストを使って実装できます。
|
||||
- 時間効率の観点では、スタックの配列実装の方が平均的な効率が高いです。ただし、拡張時には単一のプッシュ操作の時間計算量が$O(n)$に悪化する可能性があります。対照的に、スタックの連結リスト実装はより安定した効率を提供します。
|
||||
- 空間効率に関しては、スタックの配列実装は一定程度の空間の無駄につながる可能性があります。ただし、連結リストのノードが占有するメモリ空間は一般的に配列の要素よりも大きいことに注意することが重要です。
|
||||
- キューは先入れ先出し(FIFO)の原則に従うデータ構造で、同様に配列または連結リストを使って実装できます。キューの時間と空間効率に関する結論は、スタックと似ています。
|
||||
- 両端キュー(deque)はより柔軟なキューの種類で、両端での要素の追加と削除を可能にします。
|
||||
- スタックは後入れ先出しの原則に従うデータ構造であり、配列または連結リストで実装できます。
|
||||
- 時間効率の面では、スタックの配列実装は平均効率が高い一方、拡張時には 1 回のプッシュ操作の時間計算量が $O(n)$ まで悪化します。これに対して、スタックの連結リスト実装はより安定した効率を示します。
|
||||
- 空間効率の面では、スタックの配列実装はある程度の領域の無駄を生む可能性があります。ただし、連結リストのノードが占有するメモリは配列要素よりも大きい点に注意が必要です。
|
||||
- キューは先入れ先出しの原則に従うデータ構造であり、同様に配列または連結リストで実装できます。時間効率と空間効率の比較における結論は、前述のスタックの場合と似ています。
|
||||
- 両端キューはより高い自由度を持つキューであり、両端で要素の追加と削除を行えます。
|
||||
|
||||
### Q & A
|
||||
|
||||
**Q**: ブラウザの進む・戻る機能は双方向連結リストで実装されているのですか?
|
||||
**Q**:ブラウザの進む・戻るは双方向連結リストで実装されているのですか?
|
||||
|
||||
ブラウザの進む・戻るナビゲーションは本質的に「スタック」概念の現れです。ユーザーが新しいページを訪問すると、そのページがスタックの先頭に追加されます。戻るボタンをクリックすると、ページがスタックの先頭からポップされます。両端キュー(deque)は、「両端キュー」の章で述べたように、いくつかの追加操作を便利に実装できます。
|
||||
ブラウザの進む・戻る機能の本質は「スタック」の表れです。ユーザーが新しいページにアクセスすると、そのページはスタックの先頭に追加されます。ユーザーが戻るボタンをクリックすると、そのページはスタックの先頭から取り出されます。両端キューを使うといくつかの追加操作を簡単に実装でき、この点は「両端キュー」の章で触れています。
|
||||
|
||||
**Q**: スタックからポップした後、ポップされたノードのメモリを解放する必要がありますか?
|
||||
**Q**:ポップした後、そのノードのメモリを解放する必要はありますか?
|
||||
|
||||
ポップされたノードが後で使用される場合は、そのメモリを解放する必要はありません。自動ガベージコレクションを持つJavaやPythonなどの言語では、手動のメモリ解放は必要ありません。CやC++では、手動のメモリ解放が必要です。
|
||||
後で取り出したノードを引き続き使うのであれば、メモリを解放する必要はありません。以降そのノードを使わない場合でも、`Java` や `Python` などの言語には自動ガベージコレクション機構があるため、手動でメモリを解放する必要はありません。一方、`C` と `C++` では手動でメモリを解放する必要があります。
|
||||
|
||||
**Q**: 両端キューは2つのスタックを結合したもののように見えます。その用途は何ですか?
|
||||
**Q**:両端キューは 2 つのスタックをつなげたように見えますが、用途は何ですか?
|
||||
|
||||
両端キューは、スタックとキューの組み合わせまたは2つのスタックを結合したもので、スタックとキューの両方のロジックを示します。したがって、スタックとキューのすべてのアプリケーションを実装でき、より大きな柔軟性を提供します。
|
||||
両端キューは、スタックとキューの組み合わせ、あるいは 2 つのスタックをつなげたもののような構造です。表しているのはスタック + キューのロジックなので、スタックとキューのすべての応用を実現でき、しかもより柔軟です。
|
||||
|
||||
**Q**: 元に戻すとやり直しは具体的にどのように実装されるのですか?
|
||||
**Q**:取り消し(undo)とやり直し(redo)は具体的にどのように実装されますか?
|
||||
|
||||
元に戻すとやり直しの操作は2つのスタックを使って実装されます:元に戻す用のスタック`A`とやり直し用のスタック`B`です。
|
||||
2 つのスタックを使い、スタック `A` を取り消し用、スタック `B` をやり直し用に使います。
|
||||
|
||||
1. ユーザーが操作を実行するたびに、それがスタック`A`にプッシュされ、スタック`B`がクリアされます。
|
||||
2. ユーザーが「元に戻す」を実行すると、最新の操作がスタック`A`からポップされ、スタック`B`にプッシュされます。
|
||||
3. ユーザーが「やり直し」を実行すると、最新の操作がスタック`B`からポップされ、スタック`A`に戻されます。
|
||||
1. ユーザーが操作を 1 つ実行するたびに、その操作をスタック `A` にプッシュし、スタック `B` を空にします。
|
||||
2. ユーザーが「取り消し」を実行したときは、スタック `A` から直近の操作をポップし、それをスタック `B` にプッシュします。
|
||||
3. ユーザーが「やり直し」を実行したときは、スタック `B` から直近の操作をポップし、それをスタック `A` にプッシュします。
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
# 二分木の配列表現
|
||||
|
||||
連結リスト表現では、二分木の格納単位はノード`TreeNode`であり、ノードはポインタによって接続されます。連結リスト表現での二分木の基本操作については前の節で紹介しました。
|
||||
連結リスト表現では、二分木の記憶単位はノード `TreeNode` であり、ノード同士はポインタによって接続されます。前節では、連結リスト表現における二分木の各種基本操作を紹介しました。
|
||||
|
||||
では、配列を使って二分木を表現することはできるでしょうか?答えはイエスです。
|
||||
では、配列で二分木を表現できるでしょうか?答えはもちろん可能です。
|
||||
|
||||
## 完全二分木の表現
|
||||
## 充足二分木を表現する
|
||||
|
||||
まず簡単なケースから分析してみましょう。完全二分木が与えられたとき、レベル順探索の順序に従ってすべてのノードを配列に格納し、各ノードは一意の配列インデックスに対応します。
|
||||
まずは簡単な例を考えます。与えられた 1 本の充足二分木について、すべてのノードをレベル順走査の順に配列へ格納すると、各ノードは一意な配列インデックスに対応します。
|
||||
|
||||
レベル順探索の特性に基づいて、親ノードのインデックスとその子ノードの間の「マッピング公式」を導き出すことができます:**ノードのインデックスが$i$の場合、その左の子のインデックスは$2i + 1$、右の子のインデックスは$2i + 2$です**。下図は、さまざまなノードのインデックス間のマッピング関係を示しています。
|
||||
レベル順走査の性質に基づくと、親ノードのインデックスと子ノードのインデックスの間にある「対応式」を導けます。**あるノードのインデックスが $i$ なら、その左子ノードのインデックスは $2i + 1$ 、右子ノードのインデックスは $2i + 2$ です**。以下の図は、各ノードインデックス間の対応関係を示しています。
|
||||
|
||||

|
||||

|
||||
|
||||
**マッピング公式は、連結リストのノード参照(ポインタ)と同様の役割を果たします**。配列内の任意のノードが与えられたとき、マッピング公式を使用してその左(右)の子ノードにアクセスできます。
|
||||
**対応式は、連結リストにおけるノード参照(ポインタ)と同じ役割を果たします**。与えられた配列内の任意のノードについて、この対応式を使えばその左(右)子ノードにアクセスできます。
|
||||
|
||||
## 任意の二分木の表現
|
||||
## 任意の二分木を表現する
|
||||
|
||||
完全二分木は特別なケースです。二分木の中間レベルには多くの`None`値が存在することがよくあります。レベル順探索のシーケンスにはこれらの`None`値が含まれないため、このシーケンスだけに依存して`None`値の数と分布を推測することはできません。**つまり、複数の二分木構造が同じレベル順探索シーケンスと一致する可能性があります**。
|
||||
充足二分木は特殊なケースであり、一般の二分木では中間層に多数の `None` が存在することがよくあります。レベル順走査の列にはこれらの `None` が含まれないため、その列だけから `None` の数や分布位置を推定することはできません。**つまり、このレベル順走査列に一致する二分木構造は複数存在し得ます**。
|
||||
|
||||
下図に示すように、完全でない二分木が与えられた場合、上記の配列表現方法は失敗します。
|
||||
次の図のように、非充足二分木が与えられると、上記の配列表現はすでに成り立ちません。
|
||||
|
||||

|
||||

|
||||
|
||||
この問題を解決するために、**レベル順探索シーケンスですべての`None`値を明示的に書き出すことを検討できます**。下図に示すように、この処理後、レベル順探索シーケンスは二分木を一意に表現できます。サンプルコードは以下の通りです:
|
||||
この問題を解決するために、**レベル順走査列にすべての `None` を明示的に書き込む**ことを考えられます。次の図のように、このように処理すればレベル順走査列で二分木を一意に表現できます。コード例は以下のとおりです:
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title=""
|
||||
# 二分木の配列表現
|
||||
# Noneを使用して空のスロットを表現
|
||||
# 空き位置を表すために None を使う
|
||||
tree = [1, 2, 3, 4, None, 6, 7, 8, 9, None, None, 12, None, None, 15]
|
||||
```
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
```cpp title=""
|
||||
/* 二分木の配列表現 */
|
||||
// 最大整数値INT_MAXを使用して空のスロットをマーク
|
||||
// int の最大値 INT_MAX を使って空き位置を示す
|
||||
vector<int> tree = {1, 2, 3, 4, INT_MAX, 6, 7, 8, 9, INT_MAX, INT_MAX, 12, INT_MAX, INT_MAX, 15};
|
||||
```
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
```java title=""
|
||||
/* 二分木の配列表現 */
|
||||
// Integerラッパークラスを使用してnullで空のスロットをマーク
|
||||
// int のラッパークラス Integer を使えば、null で空き位置を示せる
|
||||
Integer[] tree = { 1, 2, 3, 4, null, 6, 7, 8, 9, null, null, 12, null, null, 15 };
|
||||
```
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
```csharp title=""
|
||||
/* 二分木の配列表現 */
|
||||
// nullable int (int?)を使用してnullで空のスロットをマーク
|
||||
// nullable な int? 型を使えば、null で空き位置を示せる
|
||||
int?[] tree = [1, 2, 3, 4, null, 6, 7, 8, 9, null, null, 12, null, null, 15];
|
||||
```
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
```go title=""
|
||||
/* 二分木の配列表現 */
|
||||
// any型スライスを使用してnilで空のスロットをマーク
|
||||
// any 型のスライスを使えば、nil で空き位置を示せる
|
||||
tree := []any{1, 2, 3, 4, nil, 6, 7, 8, 9, nil, nil, 12, nil, nil, 15}
|
||||
```
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
|
||||
```swift title=""
|
||||
/* 二分木の配列表現 */
|
||||
// optional Int (Int?)を使用してnilで空のスロットをマーク
|
||||
// nullable な Int? 型を使えば、nil で空き位置を示せる
|
||||
let tree: [Int?] = [1, 2, 3, 4, nil, 6, 7, 8, 9, nil, nil, 12, nil, nil, 15]
|
||||
```
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
|
||||
```javascript title=""
|
||||
/* 二分木の配列表現 */
|
||||
// nullを使用して空のスロットを表現
|
||||
// null を使って空き位置を表す
|
||||
let tree = [1, 2, 3, 4, null, 6, 7, 8, 9, null, null, 12, null, null, 15];
|
||||
```
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
|
||||
```typescript title=""
|
||||
/* 二分木の配列表現 */
|
||||
// nullを使用して空のスロットを表現
|
||||
// null を使って空き位置を表す
|
||||
let tree: (number | null)[] = [1, 2, 3, 4, null, 6, 7, 8, 9, null, null, 12, null, null, 15];
|
||||
```
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
|
||||
```dart title=""
|
||||
/* 二分木の配列表現 */
|
||||
// nullable int (int?)を使用してnullで空のスロットをマーク
|
||||
// nullable な int? 型を使えば、null で空き位置を示せる
|
||||
List<int?> tree = [1, 2, 3, 4, null, 6, 7, 8, 9, null, null, 12, null, null, 15];
|
||||
```
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
|
||||
```rust title=""
|
||||
/* 二分木の配列表現 */
|
||||
// Noneを使用して空のスロットをマーク
|
||||
// None を使って空き位置を示す
|
||||
let tree = [Some(1), Some(2), Some(3), Some(4), None, Some(6), Some(7), Some(8), Some(9), None, None, Some(12), None, None, Some(15)];
|
||||
```
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
|
||||
```c title=""
|
||||
/* 二分木の配列表現 */
|
||||
// 最大int値を使用して空のスロットをマーク、したがってノード値はINT_MAXであってはならない
|
||||
// int の最大値で空き位置を示すため、ノード値は INT_MAX であってはならない
|
||||
int tree[] = {1, 2, 3, 4, INT_MAX, 6, 7, 8, 9, INT_MAX, INT_MAX, 12, INT_MAX, INT_MAX, 15};
|
||||
```
|
||||
|
||||
@@ -116,43 +116,45 @@
|
||||
|
||||
```kotlin title=""
|
||||
/* 二分木の配列表現 */
|
||||
// nullを使用して空のスロットを表現
|
||||
val tree = mutableListOf( 1, 2, 3, 4, null, 6, 7, 8, 9, null, null, 12, null, null, 15 )
|
||||
// null を使って空き位置を表す
|
||||
val tree = arrayOf( 1, 2, 3, 4, null, 6, 7, 8, 9, null, null, 12, null, null, 15 )
|
||||
```
|
||||
|
||||
=== "Ruby"
|
||||
|
||||
```ruby title=""
|
||||
|
||||
### 二分木の配列表現 ###
|
||||
# nil を使って空き位置を表す
|
||||
tree = [1, 2, 3, 4, nil, 6, 7, 8, 9, nil, nil, 12, nil, nil, 15]
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
注目すべきは、**完備二分木は配列表現に非常に適している**ということです。完備二分木の定義を思い出すと、`None`は最下位レベルでのみ、かつ右側に向かって現れます。**つまり、すべての`None`値は確実にレベル順探索シーケンスの最後に現れます**。
|
||||
補足すると、**完全二分木は配列による表現に非常に適しています**。完全二分木の定義を振り返ると、`None` は最下層の右側にしか現れないため、**すべての `None` は必ずレベル順走査列の末尾に現れます**。
|
||||
|
||||
これは、配列を使用して完備二分木を表現する際、すべての`None`値の格納を省略できることを意味し、非常に便利です。下図に例を示します。
|
||||
つまり、完全二分木を配列で表す場合は、すべての `None` の格納を省略できるため、非常に便利です。次の図に例を示します。
|
||||
|
||||

|
||||

|
||||
|
||||
以下のコードは、配列表現に基づく二分木を実装し、次の操作を含みます:
|
||||
以下のコードでは、配列ベースで表現した二分木を実装しており、次の操作を含みます。
|
||||
|
||||
- ノードが与えられたとき、その値、左(右)の子ノード、および親ノードを取得する。
|
||||
- 前順、中順、後順、およびレベル順探索シーケンスを取得する。
|
||||
- あるノードが与えられたとき、その値、左(右)子ノード、親ノードを取得する。
|
||||
- 前順走査、中順走査、後順走査、レベル順走査の列を取得する。
|
||||
|
||||
```src
|
||||
[file]{array_binary_tree}-[class]{array_binary_tree}-[func]{}
|
||||
```
|
||||
|
||||
## 利点と制限
|
||||
## 利点と制約
|
||||
|
||||
二分木の配列表現には以下の利点があります:
|
||||
二分木の配列表現には主に次の利点があります。
|
||||
|
||||
- 配列は連続したメモリ空間に格納されるため、キャッシュフレンドリーで、より高速なアクセスと探索が可能です。
|
||||
- ポインタを格納する必要がないため、スペースを節約できます。
|
||||
- ノードへのランダムアクセスが可能です。
|
||||
- 配列は連続したメモリ空間に格納されるため、キャッシュ効率が高く、アクセスと走査が速い。
|
||||
- ポインタを格納する必要がなく、比較的省スペースである。
|
||||
- ノードへのランダムアクセスが可能である。
|
||||
|
||||
しかし、配列表現にはいくつかの制限もあります:
|
||||
ただし、配列表現にはいくつかの制約もあります。
|
||||
|
||||
- 配列格納には連続したメモリ空間が必要なため、大量のデータを持つ木の格納には適していません。
|
||||
- ノードの追加や削除には配列の挿入や削除操作が必要で、効率が低くなります。
|
||||
- 二分木に多くの`None`値がある場合、配列に含まれるノードデータの割合が低くなり、空間利用率が低下します。
|
||||
- 配列による格納には連続したメモリ空間が必要なため、データ量が大きすぎる木の格納には向かない。
|
||||
- ノードの追加と削除は配列の挿入・削除操作で実現する必要があり、効率は低い。
|
||||
- 二分木に大量の `None` が存在すると、配列に占める実ノードデータの比率が低くなり、空間利用率も低下する。
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
# AVL木 *
|
||||
# AVL 木 *
|
||||
|
||||
「二分探索木」の節では、複数の挿入と削除の後、二分探索木が連結リストに退化する可能性があることを述べました。このような場合、すべての操作の時間計算量が$O(\log n)$から$O(n)$に悪化します。
|
||||
「二分探索木」章で述べたように、挿入と削除を何度も繰り返すと、二分探索木は連結リストへ退化する可能性があります。この場合、すべての操作の時間計算量は $O(\log n)$ から $O(n)$ へ劣化します。
|
||||
|
||||
下図に示すように、2つのノード削除操作の後、この二分探索木は連結リストに退化します。
|
||||
以下の図に示すように、ノード削除を 2 回行うと、この二分探索木は連結リストへ退化します。
|
||||
|
||||

|
||||

|
||||
|
||||
例えば、下図に示す完全二分木では、2つのノードを挿入した後、木が左に大きく傾き、検索操作の時間計算量も悪化します。
|
||||
別の例として、以下の図に示す完全二分木に 2 つのノードを挿入すると、木は大きく左に傾き、探索操作の時間計算量もそれに伴って劣化します。
|
||||
|
||||

|
||||

|
||||
|
||||
1962年、G. M. Adelson-VelskyとE. M. Landisが論文「An algorithm for the organization of information」で<u>AVL木</u>を提案しました。この論文では、ノードの継続的な追加と削除の後もAVL木が退化しないことを保証する一連の操作について詳述し、さまざまな操作の時間計算量を$O(\log n)$レベルに維持しました。つまり、頻繁な追加、削除、検索、変更が必要なシナリオで、AVL木は常に効率的なデータ操作性能を維持でき、大きな応用価値があります。
|
||||
1962 年、G. M. Adelson-Velsky と E. M. Landis は論文“An algorithm for the organization of information”の中で <u>AVL 木</u> を提案しました。論文では一連の操作が詳しく説明されており、ノードの追加と削除を続けても AVL 木が退化しないようにして、各種操作の時間計算量を $O(\log n)$ の水準に保ちます。言い換えると、追加・削除・探索・更新を頻繁に行う場面でも、AVL 木は常に高いデータ操作性能を維持でき、実用価値の高い構造です。
|
||||
|
||||
## AVL木の一般的な用語
|
||||
## AVL 木の基本用語
|
||||
|
||||
AVL木は二分探索木でありかつ平衡二分木でもあり、これら2つの種類の二分木のすべての性質を満たしているため、<u>平衡二分探索木</u>です。
|
||||
AVL 木は二分探索木であると同時に平衡二分木でもあり、これら 2 種類の二分木の性質をすべて満たします。したがって、<u>平衡二分探索木(balanced binary search tree)</u>の一種です。
|
||||
|
||||
### ノードの高さ
|
||||
|
||||
AVL木に関連する操作ではノードの高さを取得する必要があるため、ノードクラスに`height`変数を追加する必要があります:
|
||||
AVL 木の操作ではノードの高さを取得する必要があるため、ノードクラスに `height` 変数を追加します:
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python title=""
|
||||
class TreeNode:
|
||||
"""AVL木ノード"""
|
||||
"""AVL 木ノードクラス"""
|
||||
def __init__(self, val: int):
|
||||
self.val: int = val # ノード値
|
||||
self.height: int = 0 # ノードの高さ
|
||||
self.left: TreeNode | None = None # 左の子への参照
|
||||
self.right: TreeNode | None = None # 右の子への参照
|
||||
self.left: TreeNode | None = None # 左の子ノード参照
|
||||
self.right: TreeNode | None = None # 右の子ノード参照
|
||||
```
|
||||
|
||||
=== "C++"
|
||||
|
||||
```cpp title=""
|
||||
/* AVL木ノード */
|
||||
/* AVL 木ノードクラス */
|
||||
struct TreeNode {
|
||||
int val{}; // ノード値
|
||||
int height = 0; // ノードの高さ
|
||||
TreeNode *left{}; // 左の子
|
||||
TreeNode *right{}; // 右の子
|
||||
TreeNode *left{}; // 左の子ノード
|
||||
TreeNode *right{}; // 右の子ノード
|
||||
TreeNode() = default;
|
||||
explicit TreeNode(int x) : val(x){}
|
||||
};
|
||||
@@ -49,12 +49,12 @@ AVL木に関連する操作ではノードの高さを取得する必要があ
|
||||
=== "Java"
|
||||
|
||||
```java title=""
|
||||
/* AVL木ノード */
|
||||
/* AVL 木ノードクラス */
|
||||
class TreeNode {
|
||||
public int val; // ノード値
|
||||
public int height; // ノードの高さ
|
||||
public TreeNode left; // 左の子
|
||||
public TreeNode right; // 右の子
|
||||
public TreeNode left; // 左の子ノード
|
||||
public TreeNode right; // 右の子ノード
|
||||
public TreeNode(int x) { val = x; }
|
||||
}
|
||||
```
|
||||
@@ -62,36 +62,36 @@ AVL木に関連する操作ではノードの高さを取得する必要があ
|
||||
=== "C#"
|
||||
|
||||
```csharp title=""
|
||||
/* AVL木ノード */
|
||||
/* AVL 木ノードクラス */
|
||||
class TreeNode(int? x) {
|
||||
public int? val = x; // ノード値
|
||||
public int height; // ノードの高さ
|
||||
public TreeNode? left; // 左の子への参照
|
||||
public TreeNode? right; // 右の子への参照
|
||||
public TreeNode? left; // 左の子ノード参照
|
||||
public TreeNode? right; // 右の子ノード参照
|
||||
}
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
|
||||
```go title=""
|
||||
/* AVL木ノード */
|
||||
/* AVL 木ノード構造体 */
|
||||
type TreeNode struct {
|
||||
Val int // ノード値
|
||||
Height int // ノードの高さ
|
||||
Left *TreeNode // 左の子への参照
|
||||
Right *TreeNode // 右の子への参照
|
||||
Left *TreeNode // 左の子ノード参照
|
||||
Right *TreeNode // 右の子ノード参照
|
||||
}
|
||||
```
|
||||
|
||||
=== "Swift"
|
||||
|
||||
```swift title=""
|
||||
/* AVL木ノード */
|
||||
/* AVL 木ノードクラス */
|
||||
class TreeNode {
|
||||
var val: Int // ノード値
|
||||
var height: Int // ノードの高さ
|
||||
var left: TreeNode? // 左の子
|
||||
var right: TreeNode? // 右の子
|
||||
var left: TreeNode? // 左の子ノード
|
||||
var right: TreeNode? // 右の子ノード
|
||||
|
||||
init(x: Int) {
|
||||
val = x
|
||||
@@ -103,12 +103,12 @@ AVL木に関連する操作ではノードの高さを取得する必要があ
|
||||
=== "JS"
|
||||
|
||||
```javascript title=""
|
||||
/* AVL木ノード */
|
||||
/* AVL 木ノードクラス */
|
||||
class TreeNode {
|
||||
val; // ノード値
|
||||
height; // ノードの高さ
|
||||
left; // 左の子ポインタ
|
||||
right; // 右の子ポインタ
|
||||
height; //ノードの高さ
|
||||
left; // 左の子ノードポインタ
|
||||
right; // 右の子ノードポインタ
|
||||
constructor(val, left, right, height) {
|
||||
this.val = val === undefined ? 0 : val;
|
||||
this.height = height === undefined ? 0 : height;
|
||||
@@ -121,12 +121,12 @@ AVL木に関連する操作ではノードの高さを取得する必要があ
|
||||
=== "TS"
|
||||
|
||||
```typescript title=""
|
||||
/* AVL木ノード */
|
||||
/* AVL 木ノードクラス */
|
||||
class TreeNode {
|
||||
val: number; // ノード値
|
||||
height: number; // ノードの高さ
|
||||
left: TreeNode | null; // 左の子ポインタ
|
||||
right: TreeNode | null; // 右の子ポインタ
|
||||
left: TreeNode | null; // 左の子ノードポインタ
|
||||
right: TreeNode | null; // 右の子ノードポインタ
|
||||
constructor(val?: number, height?: number, left?: TreeNode | null, right?: TreeNode | null) {
|
||||
this.val = val === undefined ? 0 : val;
|
||||
this.height = height === undefined ? 0 : height;
|
||||
@@ -139,12 +139,12 @@ AVL木に関連する操作ではノードの高さを取得する必要があ
|
||||
=== "Dart"
|
||||
|
||||
```dart title=""
|
||||
/* AVL木ノード */
|
||||
/* AVL 木ノードクラス */
|
||||
class TreeNode {
|
||||
int val; // ノード値
|
||||
int height; // ノードの高さ
|
||||
TreeNode? left; // 左の子
|
||||
TreeNode? right; // 右の子
|
||||
TreeNode? left; // 左の子ノード
|
||||
TreeNode? right; // 右の子ノード
|
||||
TreeNode(this.val, [this.height = 0, this.left, this.right]);
|
||||
}
|
||||
```
|
||||
@@ -155,12 +155,12 @@ AVL木に関連する操作ではノードの高さを取得する必要があ
|
||||
use std::rc::Rc;
|
||||
use std::cell::RefCell;
|
||||
|
||||
/* AVL木ノード */
|
||||
/* AVL 木ノード構造体 */
|
||||
struct TreeNode {
|
||||
val: i32, // ノード値
|
||||
height: i32, // ノードの高さ
|
||||
left: Option<Rc<RefCell<TreeNode>>>, // 左の子
|
||||
right: Option<Rc<RefCell<TreeNode>>>, // 右の子
|
||||
left: Option<Rc<RefCell<TreeNode>>>, // 左の子ノード
|
||||
right: Option<Rc<RefCell<TreeNode>>>, // 右の子ノード
|
||||
}
|
||||
|
||||
impl TreeNode {
|
||||
@@ -179,8 +179,8 @@ AVL木に関連する操作ではノードの高さを取得する必要があ
|
||||
=== "C"
|
||||
|
||||
```c title=""
|
||||
/* AVL木ノード */
|
||||
TreeNode struct TreeNode {
|
||||
/* AVL 木ノード構造体 */
|
||||
typedef struct TreeNode {
|
||||
int val;
|
||||
int height;
|
||||
struct TreeNode *left;
|
||||
@@ -203,29 +203,40 @@ AVL木に関連する操作ではノードの高さを取得する必要があ
|
||||
=== "Kotlin"
|
||||
|
||||
```kotlin title=""
|
||||
/* AVL木ノード */
|
||||
/* AVL 木ノードクラス */
|
||||
class TreeNode(val _val: Int) { // ノード値
|
||||
val height: Int = 0 // ノードの高さ
|
||||
val left: TreeNode? = null // 左の子
|
||||
val right: TreeNode? = null // 右の子
|
||||
val left: TreeNode? = null // 左の子ノード
|
||||
val right: TreeNode? = null // 右の子ノード
|
||||
}
|
||||
```
|
||||
|
||||
=== "Ruby"
|
||||
|
||||
```ruby title=""
|
||||
### AVL 木ノードクラス ###
|
||||
class TreeNode
|
||||
attr_accessor :val # ノード値
|
||||
attr_accessor :height # ノードの高さ
|
||||
attr_accessor :left # 左の子ノード参照
|
||||
attr_accessor :right # 右の子ノード参照
|
||||
|
||||
def initialize(val)
|
||||
@val = val
|
||||
@height = 0
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
「ノードの高さ」とは、そのノードから最も遠い葉ノードまでの距離、つまり通過する「辺」の数を指します。重要なのは、葉ノードの高さは$0$で、nullノードの高さは$-1$であることです。ノードの高さを取得し、更新するための2つのユーティリティ関数を作成します:
|
||||
「ノードの高さ」とは、そのノードから最も遠い葉ノードまでの距離、すなわち通過する「辺」の本数を指します。特に、葉ノードの高さは $0$、空ノードの高さは $-1$ です。ここでは、ノードの高さを取得・更新するための 2 つの補助関数を用意します:
|
||||
|
||||
```src
|
||||
[file]{avl_tree}-[class]{avl_tree}-[func]{update_height}
|
||||
```
|
||||
|
||||
### ノードの平衡因子
|
||||
### ノードの平衡係数
|
||||
|
||||
ノードの<u>平衡因子</u>は、そのノードの左部分木の高さから右部分木の高さを引いた値として定義され、nullノードの平衡因子は$0$として定義されます。後で使いやすくするため、ノードの平衡因子を取得する機能も関数にカプセル化します:
|
||||
ノードの<u>平衡係数(balance factor)</u>は、左部分木の高さから右部分木の高さを引いた値と定義し、空ノードの平衡係数は $0$ とします。同様に、ノードの平衡係数を取得する機能も関数にカプセル化して、後続で使いやすくします:
|
||||
|
||||
```src
|
||||
[file]{avl_tree}-[class]{avl_tree}-[func]{balance_factor}
|
||||
@@ -233,17 +244,17 @@ AVL木に関連する操作ではノードの高さを取得する必要があ
|
||||
|
||||
!!! tip
|
||||
|
||||
平衡因子を$f$とすると、AVL木の任意のノードの平衡因子は$-1 \le f \le 1$を満たします。
|
||||
平衡係数を $f$ とすると、AVL 木の任意のノードの平衡係数は常に $-1 \le f \le 1$ を満たします。
|
||||
|
||||
## AVL木の回転
|
||||
## AVL 木の回転
|
||||
|
||||
AVL木の特徴的な機能は「回転」操作で、これは二分木の中順探索シーケンスに影響を与えることなく、不平衡なノードのバランスを回復できます。つまり、**回転操作は「二分探索木」の性質を維持しながら、木を「平衡二分木」に戻すことができます**。
|
||||
AVL 木の特徴は「回転」操作にあり、二分木の中順走査列を変えずに、不平衡ノードを再び平衡に戻せます。言い換えると、**回転操作は「二分探索木」の性質を保ちながら、木を再び「平衡二分木」に戻すことができます**。
|
||||
|
||||
絶対平衡因子が$> 1$のノードを「不平衡ノード」と呼びます。不平衡のタイプに応じて、4種類の回転があります:右回転、左回転、右左回転、左右回転です。以下、これらの回転操作について詳しく説明します。
|
||||
平衡係数の絶対値が $> 1$ のノードを「不平衡ノード」と呼びます。ノードの不平衡の形に応じて、回転操作は 4 種類に分かれます。右回転、左回転、右回転してから左回転、左回転してから右回転です。以下でこれらを順に説明します。
|
||||
|
||||
### 右回転
|
||||
|
||||
下図に示すように、二分木で下から上への最初の不平衡ノードは「ノード3」です。この不平衡ノードを根とする部分木に焦点を当て、これを`node`とし、その左の子を`child`として、「右回転」を実行します。右回転後、部分木は再びバランスが取れ、同時に二分探索木の性質も維持されます。
|
||||
以下の図では、ノードの下に平衡係数を示しています。下から上へ見ると、二分木で最初に不平衡になるのは「ノード 3」です。この不平衡ノードを根とする部分木に注目し、そのノードを `node`、左の子ノードを `child` として、「右回転」を行います。右回転後、部分木は平衡を回復し、なおかつ二分探索木の性質も保たれます。
|
||||
|
||||
=== "<1>"
|
||||

|
||||
@@ -257,11 +268,11 @@ AVL木の特徴的な機能は「回転」操作で、これは二分木の中
|
||||
=== "<4>"
|
||||

|
||||
|
||||
下図に示すように、`child`ノードに右の子(`grand_child`と表記)がある場合、右回転で手順を追加する必要があります:`grand_child`を`node`の左の子に設定します。
|
||||
以下の図に示すように、ノード `child` に右の子ノード(`grand_child` と記す)がある場合、右回転には 1 ステップ追加する必要があります。すなわち、`grand_child` を `node` の左の子ノードにします。
|
||||
|
||||

|
||||

|
||||
|
||||
「右回転」は比喩的な用語で、実際にはノードポインタを変更することで実現されます。以下のコードで示されます:
|
||||
「右に回転する」というのはあくまでイメージしやすい表現であり、実際にはノードポインタを変更して実現します。コードは次のとおりです:
|
||||
|
||||
```src
|
||||
[file]{avl_tree}-[class]{avl_tree}-[func]{right_rotate}
|
||||
@@ -269,60 +280,60 @@ AVL木の特徴的な機能は「回転」操作で、これは二分木の中
|
||||
|
||||
### 左回転
|
||||
|
||||
対応して、上記の不平衡二分木の「鏡像」を考慮すると、下図に示す「左回転」操作を実行する必要があります。
|
||||
対応する鏡像として、上記の不平衡二分木を左右反転して考えると、以下の図に示す「左回転」が必要になります。
|
||||
|
||||

|
||||

|
||||
|
||||
同様に、下図に示すように、`child`ノードに左の子(`grand_child`と表記)がある場合、左回転で手順を追加する必要があります:`grand_child`を`node`の右の子に設定します。
|
||||
同様に、以下の図に示すように、ノード `child` に左の子ノード(`grand_child` と記す)がある場合、左回転にも 1 ステップ追加する必要があります。すなわち、`grand_child` を `node` の右の子ノードにします。
|
||||
|
||||

|
||||

|
||||
|
||||
**右回転と左回転の操作は論理的に対称であり、2つの対称的な不平衡タイプを解決します**ことが観察できます。対称性に基づいて、右回転の実装コードですべての`left`を`right`に、すべての`right`を`left`に置き換えることで、左回転の実装コードを得ることができます:
|
||||
分かるように、**右回転と左回転は論理的に鏡像対称であり、それぞれが解決する 2 種類の不平衡も対称です**。この対称性に基づけば、右回転の実装コードにあるすべての `left` を `right` に、すべての `right` を `left` に置き換えるだけで、左回転の実装コードが得られます:
|
||||
|
||||
```src
|
||||
[file]{avl_tree}-[class]{avl_tree}-[func]{left_rotate}
|
||||
```
|
||||
|
||||
### 左右回転
|
||||
### 左回転してから右回転
|
||||
|
||||
下図に示す不平衡ノード3の場合、左回転または右回転のいずれかだけでは部分木のバランスを回復できません。この場合、まず`child`に対して「左回転」を実行し、次に`node`に対して「右回転」を実行する必要があります。
|
||||
以下の図の不平衡ノード 3 では、左回転だけでも右回転だけでも部分木を平衡に戻せません。この場合は、まず `child` に「左回転」を行い、次に `node` に「右回転」を行います。
|
||||
|
||||

|
||||

|
||||
|
||||
### 右左回転
|
||||
### 右回転してから左回転
|
||||
|
||||
下図に示すように、上記の不平衡二分木の鏡像ケースでは、まず`child`に対して「右回転」を実行し、次に`node`に対して「左回転」を実行する必要があります。
|
||||
以下の図に示すように、上記の不平衡二分木の鏡像のケースでは、まず `child` に「右回転」を行い、次に `node` に「左回転」を行います。
|
||||
|
||||

|
||||

|
||||
|
||||
### 回転の選択
|
||||
|
||||
下図に示す4種類の不平衡は、それぞれ上記で説明したケースに対応し、右回転、左右回転、右左回転、左回転が必要です。
|
||||
以下の図に示す 4 種類の不平衡は、上の各ケースにそれぞれ対応しており、必要な操作は順に右回転、左回転してから右回転、右回転してから左回転、左回転です。
|
||||
|
||||

|
||||

|
||||
|
||||
下表に示すように、不平衡ノードの平衡因子とその高い側の子の平衡因子の符号を判断することで、不平衡ノードが上記のどのケースに属するかを決定します。
|
||||
以下の表に示すように、不平衡ノードの平衡係数と、高い側の子ノードの平衡係数の符号を判定することで、その不平衡ノードが上図のどのケースに属するかを判断できます。
|
||||
|
||||
<p align="center"> 表 <id> 4つの回転ケースの選択条件 </p>
|
||||
<p align="center"> 表 <id> 4 種類の回転ケースの選択条件 </p>
|
||||
|
||||
| 不平衡ノードの平衡因子 | 子ノードの平衡因子 | 使用する回転方法 |
|
||||
| --------------------- | ----------------- | --------------------------- |
|
||||
| $> 1$(左に傾いた木) | $\geq 0$ | 右回転 |
|
||||
| $> 1$(左に傾いた木) | $<0$ | 左回転してから右回転 |
|
||||
| $< -1$(右に傾いた木) | $\leq 0$ | 左回転 |
|
||||
| $< -1$(右に傾いた木) | $>0$ | 右回転してから左回転 |
|
||||
| 不平衡ノードの平衡係数 | 子ノードの平衡係数 | 採用すべき回転方法 |
|
||||
| ------------------ | ---------------- | ---------------- |
|
||||
| $> 1$ (左に偏った木) | $\geq 0$ | 右回転 |
|
||||
| $> 1$ (左に偏った木) | $<0$ | 左回転してから右回転 |
|
||||
| $< -1$ (右に偏った木) | $\leq 0$ | 左回転 |
|
||||
| $< -1$ (右に偏った木) | $>0$ | 右回転してから左回転 |
|
||||
|
||||
便宜上、回転操作を関数にカプセル化します。**この関数により、さまざまな種類の不平衡に対して回転を実行し、不平衡ノードのバランスを回復できます**。コードは以下の通りです:
|
||||
使いやすくするために、回転操作を 1 つの関数にカプセル化します。**この関数があれば、さまざまな不平衡ケースに対して回転を行い、不平衡ノードを再び平衡に戻せます**。コードは次のとおりです:
|
||||
|
||||
```src
|
||||
[file]{avl_tree}-[class]{avl_tree}-[func]{rotate}
|
||||
```
|
||||
|
||||
## AVL木の一般的な操作
|
||||
## AVL 木の基本操作
|
||||
|
||||
### ノードの挿入
|
||||
|
||||
AVL木のノード挿入操作は二分探索木のそれと似ています。唯一の違いは、AVL木でノードを挿入した後、そのノードから根ノードまでのパス上に一連の不平衡ノードが現れる可能性があることです。したがって、**このノードから始めて上向きに回転操作を実行し、すべての不平衡ノードのバランスを回復する必要があります**。コードは以下の通りです:
|
||||
AVL 木のノード挿入は、基本的には二分探索木と同じです。唯一の違いは、AVL 木ではノード挿入後に、そのノードから根ノードまでの経路上に複数の不平衡ノードが現れる可能性があることです。したがって、**このノードから開始して、下から上へ回転操作を行い、すべての不平衡ノードを平衡に戻す必要があります**。コードは次のとおりです:
|
||||
|
||||
```src
|
||||
[file]{avl_tree}-[class]{avl_tree}-[func]{insert_helper}
|
||||
@@ -330,18 +341,18 @@ AVL木のノード挿入操作は二分探索木のそれと似ています。
|
||||
|
||||
### ノードの削除
|
||||
|
||||
同様に、二分探索木でのノード削除方法に基づいて、下から上へ回転操作を実行してすべての不平衡ノードのバランスを回復する必要があります。コードは以下の通りです:
|
||||
同様に、二分探索木のノード削除メソッドを土台として、下から上へ回転操作を行い、すべての不平衡ノードを平衡に戻す必要があります。コードは次のとおりです:
|
||||
|
||||
```src
|
||||
[file]{avl_tree}-[class]{avl_tree}-[func]{remove_helper}
|
||||
```
|
||||
|
||||
### ノードの検索
|
||||
### ノードの探索
|
||||
|
||||
AVL木でのノード検索操作は二分探索木のそれと一致しており、ここでは詳述しません。
|
||||
AVL 木のノード探索操作は二分探索木と同じなので、ここでは繰り返しません。
|
||||
|
||||
## AVL木の典型的な応用
|
||||
## AVL 木の代表的な応用
|
||||
|
||||
- 大量のデータの整理と格納に使用され、検索頻度が高く、挿入と削除の頻度が低いシナリオに適しています。
|
||||
- データベースのインデックスシステムの構築に使用されます。
|
||||
- 赤黒木も一般的な平衡二分探索木の一種です。AVL木と比較して、赤黒木はより緩い平衡条件を持ち、ノードの挿入と削除にかかる回転数が少なく、ノードの追加と削除操作の平均効率が高くなります。
|
||||
- 大規模データの整理・格納に用いられ、高頻度の探索と低頻度の追加・削除に適しています。
|
||||
- データベースのインデックスシステムの構築に使われます。
|
||||
- 赤黒木も代表的な平衡二分探索木の一つです。AVL 木と比べると、赤黒木は平衡条件がより緩く、ノードの挿入・削除に必要な回転操作が少ないため、平均的な更新効率はより高くなります。
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
# 二分探索木
|
||||
|
||||
下図に示すように、<u>二分探索木</u>は以下の条件を満たします。
|
||||
以下の図に示すように、<u>二分探索木(binary search tree)</u>は次の条件を満たします。
|
||||
|
||||
1. 根ノードについて、左部分木のすべてのノードの値 $<$ 根ノードの値 $<$ 右部分木のすべてのノードの値。
|
||||
2. 任意のノードの左と右の部分木も二分探索木です。つまり、条件`1.`も満たします。
|
||||
2. 任意のノードの左部分木と右部分木も二分探索木であり、すなわち条件 `1.` も満たします。
|
||||
|
||||

|
||||
|
||||
## 二分探索木の操作
|
||||
|
||||
二分探索木をクラス`BinarySearchTree`としてカプセル化し、木の根ノードを指すメンバー変数`root`を宣言します。
|
||||
二分探索木をクラス `BinarySearchTree` としてカプセル化し、木の根ノードを指すメンバ変数 `root` を宣言します。
|
||||
|
||||
### ノードの検索
|
||||
### ノードの探索
|
||||
|
||||
ターゲットノード値`num`が与えられた場合、二分探索木の性質に従って検索できます。下図に示すように、ノード`cur`を宣言し、二分木の根ノード`root`から開始し、ノード値`cur.val`と`num`のサイズを比較するループを行います。
|
||||
目標ノードの値 `num` が与えられたら、二分探索木の性質に基づいて探索できます。以下の図に示すように、ノード `cur` を宣言し、二分木の根ノード `root` から出発して、ノード値 `cur.val` と `num` の大小関係を繰り返し比較します。
|
||||
|
||||
- `cur.val < num`の場合、ターゲットノードは`cur`の右部分木にあることを意味するため、`cur = cur.right`を実行します。
|
||||
- `cur.val > num`の場合、ターゲットノードは`cur`の左部分木にあることを意味するため、`cur = cur.left`を実行します。
|
||||
- `cur.val = num`の場合、ターゲットノードが見つかったことを意味するため、ループを終了してノードを返します。
|
||||
- `cur.val < num` の場合、目標ノードは `cur` の右部分木にあるため、`cur = cur.right` を実行します。
|
||||
- `cur.val > num` の場合、目標ノードは `cur` の左部分木にあるため、`cur = cur.left` を実行します。
|
||||
- `cur.val = num` の場合、目標ノードが見つかったことを表し、ループを抜けてそのノードを返します。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -31,7 +31,7 @@
|
||||
=== "<4>"
|
||||

|
||||
|
||||
二分探索木での検索操作は二分探索アルゴリズムと同じ原理で動作し、各ラウンドでケースの半分を排除します。ループ数は最大で二分木の高さです。二分木が平衡している場合、$O(\log n)$の時間を使用します。コード例は以下の通りです:
|
||||
二分探索木の探索操作は二分探索アルゴリズムと同じ原理で動作し、各ラウンドで半分の候補を除外します。ループ回数の上限は二分木の高さであり、二分木が平衡であれば $O(\log n)$ 時間です。コード例は次のとおりです。
|
||||
|
||||
```src
|
||||
[file]{binary_search_tree}-[class]{binary_search_tree}-[func]{search}
|
||||
@@ -39,45 +39,45 @@
|
||||
|
||||
### ノードの挿入
|
||||
|
||||
挿入する要素`num`が与えられた場合、二分探索木の性質「左部分木 < 根ノード < 右部分木」を維持するため、挿入操作は下図に示すように進行します。
|
||||
挿入する要素 `num` が与えられたとき、二分探索木の「左部分木 < 根ノード < 右部分木」という性質を保つため、挿入操作の流れは以下の図のようになります。
|
||||
|
||||
1. **挿入位置を見つける**: 検索操作と同様に、根ノードから開始し、現在のノード値と`num`のサイズ関係に従って下向きにループし、葉ノードを通過(`None`に走査)するまで、ループを終了します。
|
||||
2. **この位置にノードを挿入**: ノード`num`を初期化し、`None`があった場所に配置します。
|
||||
1. **挿入位置を探索する**:探索操作と同様に、根ノードから出発し、現在のノード値と `num` の大小関係に基づいて下方向へ探索を繰り返し、葉ノードを越えて(`None` まで到達して)ループを抜けます。
|
||||
2. **その位置にノードを挿入する**:ノード `num` を初期化し、そのノードを `None` の位置に置きます。
|
||||
|
||||

|
||||

|
||||
|
||||
コード実装では、以下の2点に注意してください。
|
||||
コード実装では、次の 2 点に注意が必要です。
|
||||
|
||||
- 二分探索木は重複ノードの存在を許可しません。そうでなければ、その定義に違反します。したがって、挿入するノードが既に木に存在する場合、挿入は実行されず、ノードは直接戻ります。
|
||||
- 挿入操作を実行するには、前のループからのノードを保存するためにノード`pre`を使用する必要があります。このようにして、`None`に走査したときに、その親ノードを取得でき、ノード挿入操作を完了できます。
|
||||
- 二分探索木では重複ノードを許可しません。そうでないと定義に反するためです。したがって、挿入対象のノードが木内にすでに存在する場合は、挿入を行わずそのまま返します。
|
||||
- ノード挿入を実現するために、ノード `pre` を用いて前回のループのノードを保持する必要があります。これにより、`None` までたどり着いたときにその親ノードを取得でき、ノード挿入を完了できます。
|
||||
|
||||
```src
|
||||
[file]{binary_search_tree}-[class]{binary_search_tree}-[func]{insert}
|
||||
```
|
||||
|
||||
ノードの検索と同様に、ノードの挿入には$O(\log n)$の時間を使用します。
|
||||
ノード探索と同様に、ノード挿入には $O(\log n)$ 時間を要します。
|
||||
|
||||
### ノードの削除
|
||||
|
||||
まず、二分木でターゲットノードを見つけ、それを削除します。ノードの挿入と同様に、削除操作が完了した後も、二分探索木の性質「左部分木 < 根ノード < 右部分木」が満たされることを保証する必要があります。したがって、ターゲットノードの子ノード数に基づいて、0、1、2の3つのケースに分け、対応するノード削除操作を実行します。
|
||||
まず二分木内で目標ノードを見つけ、その後で削除します。ノード挿入と同様に、削除操作の完了後も二分探索木の「左部分木 < 根ノード < 右部分木」という性質が保たれる必要があります。そのため、目標ノードの子ノード数に応じて、0、1、2 の 3 つのケースに分けて対応する削除操作を行います。
|
||||
|
||||
下図に示すように、削除するノードの次数が$0$の場合、そのノードは葉ノードであることを意味し、直接削除できます。
|
||||
以下の図に示すように、削除対象ノードの次数が $0$ のとき、そのノードは葉ノードであり、直接削除できます。
|
||||
|
||||

|
||||

|
||||
|
||||
下図に示すように、削除するノードの次数が$1$の場合、削除するノードをその子ノードで置き換えるだけで十分です。
|
||||
以下の図に示すように、削除対象ノードの次数が $1$ のとき、削除対象ノードをその子ノードで置き換えれば十分です。
|
||||
|
||||

|
||||

|
||||
|
||||
削除するノードの次数が$2$の場合、直接削除することはできませんが、ノードを使用して置き換える必要があります。二分探索木の性質「左部分木 $<$ 根ノード $<$ 右部分木」を維持するため、**このノードは右部分木の最小ノードまたは左部分木の最大ノードのいずれかです**。
|
||||
削除対象ノードの次数が $2$ のときは、直接削除できず、別のノードでそのノードを置き換える必要があります。二分探索木の「左部分木 $<$ 根ノード $<$ 右部分木」という性質を保つ必要があるため、**このノードには右部分木の最小ノードまたは左部分木の最大ノードを使えます**。
|
||||
|
||||
右部分木の最小ノード(中順走査での次のノード)を選択すると仮定すると、削除操作は下図に示すように進行します。
|
||||
右部分木の最小ノード(中順走査で次のノード)を選ぶと仮定すると、削除操作の流れは以下の図のようになります。
|
||||
|
||||
1. 削除するノードの「中順走査シーケンス」での次のノードを見つけ、`tmp`として示します。
|
||||
2. 削除するノードの値を`tmp`の値で置き換え、木内でノード`tmp`を再帰的に削除します。
|
||||
1. 削除対象ノードの「中順走査列」における次のノードを見つけ、`tmp` と記します。
|
||||
2. `tmp` の値で削除対象ノードの値を上書きし、木の中でノード `tmp` を再帰的に削除します。
|
||||
|
||||
=== "<1>"
|
||||

|
||||

|
||||
|
||||
=== "<2>"
|
||||

|
||||
@@ -88,42 +88,42 @@
|
||||
=== "<4>"
|
||||

|
||||
|
||||
ノードを削除する操作も$O(\log n)$の時間を使用します。削除するノードを見つけるのに$O(\log n)$の時間が必要で、中順走査の後継ノードを取得するのに$O(\log n)$の時間が必要です。コード例は以下の通りです:
|
||||
ノード削除操作も同様に $O(\log n)$ 時間を要します。削除対象ノードの探索に $O(\log n)$ 時間、中順走査の後続ノードの取得に $O(\log n)$ 時間が必要です。コード例は次のとおりです。
|
||||
|
||||
```src
|
||||
[file]{binary_search_tree}-[class]{binary_search_tree}-[func]{remove}
|
||||
```
|
||||
|
||||
### 中順走査は順序付けされている
|
||||
### 中順走査は昇順
|
||||
|
||||
下図に示すように、二分木の中順走査は「左 $\rightarrow$ 根 $\rightarrow$ 右」の走査順序に従い、二分探索木は「左子ノード $<$ 根ノード $<$ 右子ノード」のサイズ関係を満たします。
|
||||
以下の図に示すように、二分木の中順走査は「左 $\rightarrow$ 根 $\rightarrow$ 右」という順序に従い、二分探索木は「左子ノード $<$ 根ノード $<$ 右子ノード」という大小関係を満たします。
|
||||
|
||||
これは、二分探索木で中順走査を実行するときに、常に次に小さいノードが最初に走査されることを意味し、重要な性質につながります:**二分探索木の中順走査のシーケンスは昇順です**。
|
||||
これは、二分探索木で中順走査を行うと常に次の最小ノードが優先して走査されることを意味し、そこから重要な性質が導かれます。**二分探索木の中順走査列は昇順です**。
|
||||
|
||||
中順走査の昇順性質を使用して、二分探索木で順序付けされたデータを取得するには$O(n)$の時間のみが必要で、追加のソート操作は不要であり、非常に効率的です。
|
||||
中順走査が昇順になる性質を利用すれば、二分探索木から整列済みデータを取得するのに必要な時間は $O(n)$ のみで、追加のソート操作は不要です。非常に効率的です。
|
||||
|
||||

|
||||

|
||||
|
||||
## 二分探索木の効率
|
||||
|
||||
データのセットが与えられた場合、配列または二分探索木を使用して格納することを検討します。下の表を観察すると、二分探索木のすべての操作は対数時間計算量を持ち、安定して効率的です。配列は、頻繁な追加と検索や削除の頻度が少ないシナリオでのみ、二分探索木よりも効率的です。
|
||||
あるデータ集合が与えられたとき、配列または二分探索木で格納する場合を考えます。次の表を見ると、二分探索木の各操作の時間計算量はいずれも対数オーダーであり、安定して高効率です。高頻度の追加と低頻度の探索・削除という場面でのみ、配列のほうが二分探索木より効率的です。
|
||||
|
||||
<p align="center"> 表 <id> 配列と探索木の効率比較 </p>
|
||||
|
||||
| | 未ソート配列 | 二分探索木 |
|
||||
| -------------- | -------------- | ------------------ |
|
||||
| 要素の検索 | $O(n)$ | $O(\log n)$ |
|
||||
| 要素の挿入 | $O(1)$ | $O(\log n)$ |
|
||||
| 要素の削除 | $O(n)$ | $O(\log n)$ |
|
||||
| | 無秩序配列 | 二分探索木 |
|
||||
| -------- | -------- | ----------- |
|
||||
| 要素の探索 | $O(n)$ | $O(\log n)$ |
|
||||
| 要素の挿入 | $O(1)$ | $O(\log n)$ |
|
||||
| 要素の削除 | $O(n)$ | $O(\log n)$ |
|
||||
|
||||
理想的には、二分探索木は「平衡」しており、任意のノードを$\log n$ループ内で見つけることができます。
|
||||
理想的な状況では、二分探索木は「平衡」しており、その場合は $\log n$ 回のループ内で任意のノードを探索できます。
|
||||
|
||||
しかし、二分探索木で継続的にノードを挿入および削除すると、下図に示すように連結リストに退化する可能性があり、さまざまな操作の時間計算量も$O(n)$に悪化します。
|
||||
しかし、二分探索木でノードの挿入と削除を繰り返すと、二分木が以下の図のような連結リストへ退化する可能性があり、このとき各操作の時間計算量も $O(n)$ に退化します。
|
||||
|
||||

|
||||
|
||||
## 二分探索木の一般的な応用
|
||||
## 二分探索木の代表的な応用
|
||||
|
||||
- システムでの多レベルインデックスとして使用され、効率的な検索、挿入、削除操作を実装します。
|
||||
- 特定の検索アルゴリズムの基盤となるデータ構造として機能します。
|
||||
- データストリームを格納して、その順序付けされた状態を維持するために使用されます。
|
||||
- システム内の多段インデックスとして用いられ、効率的な探索、挿入、削除操作を実現します。
|
||||
- 一部の探索アルゴリズムの基盤データ構造として使われます。
|
||||
- データストリームを格納し、その順序状態を保つために使われます。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user