mirror of
https://github.com/krahets/hello-algo.git
synced 2026-07-08 21:46: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,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
Reference in New Issue
Block a user