Retranslate the complete Japanese online edition from Chinese (#2008)

* Retranslate the complete Japanese online edition from Chinese

* Fit Japanese homepage labels and localize heading permalinks

* Point Japanese contribution instructions to the Japanese preview

* Replace locale conditionals with multilingual UI tables
This commit is contained in:
Yudong Jin
2026-10-30 02:12:26 +08:00
committed by GitHub
parent 455e8c1281
commit 7257641e0e
1987 changed files with 23531 additions and 23261 deletions

View File

@@ -1,11 +1,11 @@
# 演習
# 練習問題
## 確認問題
## 知識の確認
### 反復と再帰の時間・空間計算量
### 反復と再帰の時間と空間
次の 2 つのコードは、どちらも $1 + 2 + \dots + n$ を計算します($n \ge 1$ とします)。`n` を 4 として、
プログラムが実際に実行される順序に沿って次の問いに答え、2 つの書き方の効率を比較してください。
次の二つのコードは、どちらも $1 + 2 + \dots + n$($n \ge 1$ とします)を計算します。`n` を 4 に設定し、
プログラムが実際に実行される順に沿って問いに答え、二つの書き方の効率を比較してください。
```src
[file]{complexity_exercises}-[class]{}-[func]{sum_iter}
@@ -13,75 +13,75 @@
<!-- numbered-subquestions -->
1. `n = 4` を入力して反復関数を実行すると、各ループの終了時に累積変数 `res` はそれぞれいくつになりますか?
2. `n = 4` を入力して再帰関数を実行すると、引数 `n` はどの値を順に取りますか?最も深い呼び出しから戻るとき、結果はどのように求められますか?
3. 2 つの書き方の時間計算量と空間計算量は、それぞれいくつですか?問い 1、2 の実行過程と結び付けて理由を説明してください。
1. `n = 4` を入力して反復関数を実行すると、各反復の終了後に累積変数 `res` の値はそれぞれいくつになりますか。
2. `n = 4` を入力して再帰関数を実行すると、引数 `n` は順にどの値を取りますか。最も深い呼び出しから戻るとき、結果はどのように求まりますか。
3. 二つの書き方の時間計算量と空間計算量はそれぞれいくつですか。問い 1、2 の実行過程に基づいて理由を説明してください。
??? success "解答"
??? success "解答例"
1. ループ変数 `i` は `1、2、3、4` の順に変化し、各ループの終了時に `res` は
1. ループ変数 `i` は順に `1、2、3、4` となり、各反復の終了後に `res` は
`1、3、6、10` となります。したがって、反復関数は 10 を返します。
2. 引数 `n` は `4 → 3 → 2 → 1` の順に変化します。
最も深い呼び出しが 1 を返した後、各呼び出しは順に `2 + 1 = 3`、`3 + 3 = 6`、`4 + 6 = 10` を得ます。
最も深い呼び出しに到達した時点では、4 回の関数呼び出しはどれもまだ終了していません。
2. 引数 `n` は順に `4 → 3 → 2 → 1` となります。
最も深い呼び出しが 1 を返し、その後、各呼び出しで順に `2 + 1 = 3`、`3 + 3 = 6`、`4 + 6 = 10` が求まります。
最も深い位置では、4 回の関数呼び出しがすべて未完了です。
3. どちらのコードも、$n$ に比例する回数のループまたは呼び出しを行うため、時間計算量はともに $O(n)$ です。
一方、空間計算量は異なります。反復版が使う変数は定数個だけなので $O(1)$ です。
再帰版では、終了条件に到達するまで、それまでの関数呼び出しが結果を待つ必要があります。そのため、呼び出しスタックには最大で $n$ 回分の呼び出しが同時に保存され、
空間計算量は $O(n)$ です。
3. どちらも $n$ に比例する回数のループまたは呼び出しを行うため、時間計算量はともに $O(n)$ です。
空間計算量は異なります。反復版は定数個の変数だけを使うので、$O(1)$ です。
再帰版では、終了条件に達するまで、先に行った関数呼び出しが結果を待ち続けます。このため、呼び出しスタックには最大で $n$ 回の呼び出しが同時に保持され、
空間計算量は $O(n)$ となります。
空間計算量を分析するときは、コード中の変数だけでなく、再帰呼び出しが使う空間も考える必要があります。
空間計算量の解析では、コード中の変数に加え、再帰呼び出しが使う領域も考慮する必要があります。
### 3 つのコードの時間計算量
### 三つのコードの時間計算量
次の 3 つのコード片はいずれも、正の整数 $n$ を入力とします。時間計算量が小さい順に並べ、それぞれの計算量を書いてください。
次の三つのコード片には、いずれも正の整数 $n$ を入力します。時間計算量が小さい順に並べ、それぞれの計算量を書いてください。
```src
[file]{complexity_exercises}-[class]{}-[func]{linear_loop}
```
??? success "解答"
??? success "解答例"
小さい順に、コード片 3 は $O(\log n)$、コード片 1 は $O(n)$、コード片 2 は $O(n^2)$ です。
コード片 3 では、ループのたびに $n$ が半分になるため、ループ回数は約 $\log_2 n$ 回です。
コード片 1 のループはちょうど $n$ 回実行されます。コード片 2 の内側のループ回数は順に
$n,n-1,\dots,1$ で、合計は $n(n+1)/2$ となるため、二次の計算量です。
小さい順に、コード片 3 が $O(\log n)$、コード片 1 が $O(n)$、コード片 2 が $O(n^2)$ です。
コード片 3 は、各反復で $n$ を半分にするため、約 $\log_2 n$ 回繰り返します。
コード片 1 のループは、ちょうど $n$ 回実行されます。コード片 2 の内側のループ回数は順に
$n,n-1,\dots,1$ で、合計 $n(n+1)/2$ 回となるため、二次のオーダーです。
### どちらの反転が空間を節約できるか
### 領域を節約できる反転方法
配列 `nums` のすべての要素を逆順にする方法として、次の 2 つがあります。
配列 `nums` の全要素を反転する方法として、次の二つがあります。
<!-- numbered-subquestions -->
1. 同じ長さの新しい配列 `res` を作り、逆順にコピーして返す。
2. 2 つのインデックス `i` と `j` をそれぞれ先頭と末尾から中央へ動かし、`nums[i]` と `nums[j]` を順に交換する。
1. 同じ長さの配列 `res` を新しく作り、逆順にコピーして返します。
2. 二つのインデックス `i` と `j` をそれぞれ先頭と末尾から中央へ進め、`nums[i]` と `nums[j]` を一組ずつ交換します。
2 つの方法の空間計算量は、それぞれいくつですか?どちらが「インプレース」な操作ですか?
それぞれの空間計算量はいくつですか。どちらが「インプレース」の操作ですか。
??? success "解答"
??? success "解答例"
1. 入力と同じ長さの補助配列が必要なため、空間計算量は $O(n)$ です。
1. 入力と同じ長さの補助配列が必要なので、空間計算量は $O(n)$ です。
2. 使うのは 2 つのインデックス変数だけなので、
空間計算量は $O(1)$ であり、インプレースな操作です。
2. 二つのインデックス変数だけを使うため、
空間計算量は $O(1)$ で、インプレースの操作です。
ただし、インプレースな反転は入力配列を変更します。
入力の変更が許される場合にのみ優先して使うべきです。元の配列を残す必要がある場合は、方法 1 のコピーにかかる空間を省くことはできません。
注意点として、インプレースの反転は入力配列を変更します。
入力の変更が許される場合に限って優先すべきです。元の配列を残す必要があれば、方法 1 のコピーにかかるコストは避けられません。
## プログラミング演習
### フィボナッチ数
フィボナッチ数列は、$F(0)=0$、$F(1)=1$ を満たし、$n\ge2$ のとき
$F(n)=F(n-1)+F(n-2)$ となります。
$F(n)=F(n-1)+F(n-2)$ が成り立ちます。
0 以上の整数 `n` が与えられます。再帰を使わず、ループを使って $F(n)$ を計算し、返してください。
非負整数 `n` が与えられます。再帰を使わず、ループで $F(n)$ を計算して返してください。
??? tip "解法のヒント"
??? tip "解くためのヒント"
1. まず、n が 0 と 1 の場合をそれぞれ処理します
2. 次の項の計算に必要なのは直前の 2 項だけで、数列全体を保存する必要はありません
3. 2 つの変数を更新するときは、後で使う古い値を先に上書きしないよう注意します
1. まず n が 0 と 1 の場合を個別に処理します
2. 次の項の計算には直前の二項だけが必要で、数列全体を保持する必要はありません
3. 二つの変数を更新するときは、まだ使う古い値を先に上書きしないように注意します
[LeetCode](https://leetcode.com/problems/fibonacci-number/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" }

View File

@@ -1,9 +1,9 @@
# 計算量解析
# 計算量の解析
![計算量解析](../assets/covers/chapter_complexity_analysis.jpg)
![計算量の解析](../assets/covers/chapter_complexity_analysis.jpg)
!!! abstract
計算量解析は、広大なアルゴリズム宇宙における時空の案内人のようなものです。
計算量の解析は、広大なアルゴリズムの宇宙で時空を案内するガイドのようです。
それは、時間と空間という二つの次元で私たちをより深く探求へ導き、より洗練された解決策を見つけ出します。
時間と空間という二つの次元を深く探り、より洗練された解法を見つける手助けをしてくれます。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,194 +1,194 @@
# 反復と再帰
アルゴリズムでは、ある処理を繰り返し実行することがよくあり、これは複雑度解析と密接に関係しています。そのため、時間計算量と空間計算量を紹介する前に、まずプログラム内で反復実行を実現する方法、つまり 2 つの基本的な制御構造である反復と再帰について見ていきます。
アルゴリズムでは、同じ処理を繰り返すことがよくあり、計算量の解析とも密接に関わります。そこで時間計算量と空間計算量を説明する前に、繰り返しを実現する二つの基本的な制御構造、反復と再帰を学びます。
## 反復
<u>反復(iteration)</u>は、ある処理を繰り返し実行するための制御構造です。反復では、プログラムは一定の条件を満たす間、あるコード片を繰り返し実行し、その条件を満たさなくなるまで続けます。
<u>反復(iteration)</u>は、ある処理を繰り返し実行する制御構造です。一定の条件を満たす間、同じコードを実行し続け、条件を満たさなくなったら終了します。
### for ループ
`for` ループは最も一般的な反復形式の 1 つで、**反復回数があらかじめ分かっている場合に適しています**。
`for` ループは最も一般的な反復の形の一つで、**繰り返し回数があらかじめわかっている場合に適しています**。
次の関数は `for` ループを用いて $1 + 2 + \dots + n$ の総和を計算しており、その結果は変数 `res` に記録されます。なお、Python の `range(a, b)` に対応する区間は「左閉右開」であり、走査範囲は $a, a + 1, \dots, 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}
```
次の図は、この総和関数のフローチャートです。
下図に、この合計を求める関数のフローチャートを示します。
![総和関数のフローチャート](iteration_and_recursion.assets/iteration.png)
![合計を求める関数のフローチャート](iteration_and_recursion.assets/iteration.png)
この総和関数の操作回数は入力データサイズ $n$ に比例し、言い換えれば「線形関係」にあります。実際、**時間計算量が記述するのはこの「線形関係」そのものです**。関連内容は次節で詳しく説明します。
この関数の操作回数は入力の大きさ $n$ に比例し、「線形の関係」にあります。実際、**時間計算量が記述するのは、この「線形の関係」です**。詳しくは次の節で説明します。
### while ループ
`for` ループと同様に、`while` ループも反復を実現する方法の 1 つです。`while` ループでは、各反復のたびにまず条件を確認し、条件が真であれば実行を続け、そうでなければループを終了します。
`for` と同様に、`while` ループも反復を実現する方法です。毎回、最初に条件を調べ、真なら処理を続け、偽ならループを終了します。
次に、`while` ループを使って $1 + 2 + \dots + n$ の総和を求めてみましょう。
`while` ループで $1 + 2 + \dots + n$ を計算してみましょう。
```src
[file]{iteration}-[class]{}-[func]{while_loop}
```
**`while` ループは `for` ループより自由度が高い**です。`while` ループでは、条件変数の初期化や更新手順を柔軟に設計できます。
**`while` ループは `for` ループより自由度が高く**、条件変数の初期化と更新の手順を自由に設計できます。
たとえば次のコードでは、条件変数 $i$ が各反復で 2 回更新されており、このようなケースは `for` ループではあまり扱いやすくありません。
たとえば次のコードでは、条件変数 $i$ を各回で 2 回更新します。このような処理は、`for` ループではやや書きにくくなります。
```src
[file]{iteration}-[class]{}-[func]{while_loop_ii}
```
総じて、**`for` ループのコードはより簡潔で、`while` ループはより柔軟**です。どちらも反復構造を実現できますが、どちらを使うかは問題ごとの要件に応じて決めるべきです。
全体として、**`for` はコードが簡潔で、`while` は柔軟です**。どちらも反復を実現でき、問題の要件に応じて選びます。
### ネストしたループ
### 入れ子のループ
1 つのループ構造の中に別のループ構造を入れ子にできます。以下では `for` ループを例にします。
ループの中に別のループを入れられます。`for` の例を示します。
```src
[file]{iteration}-[class]{}-[func]{nested_for_loop}
```
次の図は、このネストしたループのフローチャートです。
下図に、この入れ子のループのフローチャートを示します。
![ネストしたループのフローチャート](iteration_and_recursion.assets/nested_iteration.png)
![入れ子のループのフローチャート](iteration_and_recursion.assets/nested_iteration.png)
この場合、関数の操作回数は $n^2$ に比例し、言い換えればアルゴリズムの実行時間は入力データサイズ $n$ と「二次関係」にあります。
この場合、操作回数は $n^2$ に比例します。言い換えると、実行時間は入力の大きさ $n$ と「二乗の関係」にあります。
さらにネストしたループを追加することもできます。ネストが 1 段増えるたびに「次元が 1 つ上がる」ことになり、時間計算量は「三次関係」「四次関係」へと高くなっていきます。
さらにループを入れ子にすると、そのたびに「次元が上がり」、時間計算量は「三乗」「四乗」というように増えていきます。
## 再帰
<u>再帰(recursion)</u>は、関数が自分自身を呼び出すことで問題を解決するアルゴリズム戦略です。主に 2 つの段階から成ります。
<u>再帰(recursion)</u>は、関数が自分自身を呼び出して問題を解くアルゴリズム戦略です。主に二つの段階があります。
1. **再帰呼び出し**:プログラムは自分自身をより深く呼び出し続け、通常はより小さい、またはより単純化された引数を渡し、「終了条件」に達するまで進みます。
2. **復帰**: 「終了条件」が満たされると、プログラムは最も深い再帰関数から 1 層ずつ戻り、各層の結果をまとめていきます。
1. **呼び出しを進める**:通常は小さく、または単純になった引数を渡して自分自身を呼び出し続け、「終了条件」に到達するまで深く進みます。
2. **戻る**:「終了条件」に達したら、最も深い再帰関数から順に戻り、各段階の結果を集めます。
実装の観点から見ると、再帰コードは主に 3 つの要素から成ります。
実装の観点では、再帰のコードは主に三つの要素からなります。
1. **終了条件**:いつ再帰呼び出しから復帰へ切り替わるかを決めます。
2. **再帰呼び出し**:再帰呼び出しに対応し、関数が自分自身を呼び出します。通常はより小さい、またはより単純化された引数を入力します。
3. **結果の返却**:復帰に対応し、現在の再帰レベルの結果を 1 つ上の層へ返します。
1. **終了条件**:呼び出しを進める段階から、戻る段階へ切り替える時点を決めます。
2. **再帰呼び出し**:関数が自分自身を呼び出し、通常は小さく、または単純になった引数を渡します。
3. **結果の返却**:現在の再帰段階の結果を、一つ上の段階へ返します。
次のコードを見ると、関数 `recur(n)` を呼び出すだけで $1 + 2 + \dots + n$ を計算できます。
次のコードでは、`recur(n)` を呼び出すだけで $1 + 2 + \dots + n$ を計算できます。
```src
[file]{recursion}-[class]{}-[func]{recur}
```
次の図は、この関数の再帰過程を示しています。
下図に、この関数の再帰の流れを示します。
![総和関数の再帰過程](iteration_and_recursion.assets/recursion_sum.png)
![合計を求める関数の再帰の流れ](iteration_and_recursion.assets/recursion_sum.png)
計算の観点では、反復と再帰は同じ結果を得られますが、**それらは問題を考え解決するためのまったく異なる 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 つの結果が生じます。
再帰関数が自分自身を呼ぶたびに、システムは新しい呼び出し用のメモリを確保し、ローカル変数、呼び出しアドレスなどを保存します。これにより、二つの影響が生じます。
- 関数のコンテキストデータは「スタックフレーム領域」と呼ばれるメモリ領域に保存され、関数が戻るまで解放されません。したがって、**再帰は通常、反復より多くのメモリ空間を消費します**。
- 再帰による関数呼び出しには追加のオーバーヘッドが発生します。**そのため再帰は通常、ループより時間効率が低くなります**。
- 関数の実行に必要な情報は「スタックフレーム」と呼ぶメモリ領域に保存され、関数から戻るまで解放されません。そのため、**再帰は通常、反復より多くのメモリを使います**。
- 再帰呼び出しには追加のコストがかかります。**そのため、再帰は通常、ループより時間効率が低くなります**。
次の図のように、終了条件が発動する前には、まだ戻っていない再帰関数が同時に $n$ 個存在し、**再帰の深さは $n$** になります。
下図のように、終了条件に到達する直前には、まだ戻っていない再帰関数が $n$ 個あり、**再帰の深さは $n$ です**。
![再帰呼び出しの深さ](iteration_and_recursion.assets/recursion_sum_depth.png)
実際には、プログラミング言語が許容する再帰の深さには通常上限があり、深すぎる再帰はスタックオーバーフローを引き起こす可能性があります。
実際には、言語が許す再帰の深さには通常、制限があります。深すぎる再帰はスタックオーバーフローを引き起こす可能性があります。
### 末尾再帰
興味深いことに、**関数が返る直前の最後の処理で再帰呼び出しを行う場合**、その関数はコンパイラやインタプリタによって最適化され、空間効率が反復と同程度になることがあります。これを<u>末尾再帰(tail recursion)</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}
```
末尾再帰の実行過程を次の図に示します。通常の再帰と末尾再帰を比べると、加算処理が実行されるタイミングが異なります。
下図に末尾再帰の流れを示します。通常の再帰とは、加算を行うタイミングが異なります。
- **通常の再帰**:加算処理は復帰の過程で実行され、各層が戻るたびにもう一度加算を行います。
- **末尾再帰**:加算処理は再帰呼び出しの過程で実行され、復帰の過程では各層が戻るだけで済みます。
- **通常の再帰**:戻る段階で加算し、各呼び出しから戻った後に 1 回ずつ加算します。
- **末尾再帰**:呼び出しを進める段階で加算し、戻る段階では順に戻るだけです。
![末尾再帰の過程](iteration_and_recursion.assets/tail_recursion_sum.png)
![末尾再帰の流れ](iteration_and_recursion.assets/tail_recursion_sum.png)
!!! 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 項は $f(1) = 0$ と $f(2) = 1$ です。
- 数列中の各項は直前の 2 項の和であり、すなわち $f(n) = f(n - 1) + f(n - 2)$ です。
- 最初の二つの数は、$f(1) = 0$ と $f(2) = 1$ です。
- 各数は直前の二つの数の和であり、$f(n) = f(n - 1) + f(n - 2)$ です。
漸化式に従って再帰呼び出しを行い、最初の 2 項を終了条件とすれば、再帰コードを書けます。`fib(n)` を呼び出すことでフィボナッチ数列の第 $n$ 項を得られます。
この漸化式に従って再帰呼び出しを行い、最初の二つの数を終了条件にすれば、再帰のコードを書けます。`fib(n)` を呼ぶと、第 $n$ 番目の数が得られます。
```src
[file]{recursion}-[class]{}-[func]{fib}
```
上のコードを見ると、関数内で 2 回の再帰呼び出しを行っています。**これは 1 回の呼び出しから 2 つの呼び出し分岐が生じることを意味します**。次の図のように、この再帰呼び出しを繰り返していくと、最終的に深さ $n$ の<u>再帰木(recursion tree)</u>が生成されます。
このコードでは、関数内で再帰呼び出しを 2 回行い、**一つの呼び出しから二つの分岐が生じます**。下図のように呼び出しを繰り返すと、最終的に $n$ 層の<u>再帰木(recursion tree)</u>ができます。
![フィボナッチ数列の再帰木](iteration_and_recursion.assets/recursion_tree.png)
本質的に見ると、再帰は「問題をより小さな部分問題へ分解する」という思考パラダイムを体現しており、この分割統治の戦略は非常に重要です。
本質的に、再帰は「問題を小さな部分問題に分解する」という考え方を表しており、この分割統治戦略は非常に重要です。
- アルゴリズムの観点では、探索、ソート、バックトラッキング、分割統治、動的計画法など、多くの重要な戦略が直接または間接にこの考え方を用いています。
- データ構造の観点では、再帰は連結リスト、木、グラフに関する問題の処理に本質的に適しており、これらは分割統治の考え方で分析しやすいからです。
- アルゴリズムの面では、探索、ソート、バックトラッキング、分割統治、動的計画法など、多くの重要な戦略が直接または間接的にこの考え方を使っています。
- データ構造の面では、連結リスト、木、グラフの問題は分割統治で分析しやすいため、再帰が自然に適しています。
## 両者の比較
## 反復と再帰の比較
以上をまとめると、次の表のように、反復と再帰は実装、性能、適用性の面で違いがあります。
ここまでの内容をまとめると、下表のように実装、性能、用途に違いがあります。
<p align="center"> 表 <id> &nbsp; 反復と再帰の特徴の比較 </p>
| | 反復 | 再帰 |
| -------- | -------------------------------------- | ------------------------------------------------------------ |
| 実装方法 | ループ構造 | 関数が自分自身を呼び出す |
| 時間効率 | 通常は効率が高く、関数呼び出しの負荷がない | 関数呼び出しのたびにオーバーヘッドが発生する |
| メモリ使用 | 通常は固定サイズのメモリ空間を使う | 関数呼び出しの蓄積により大量のスタックフレーム領域を使う可能性がある |
| 適用対象 | 単純な反復処理に適し、コードが直感的で読みやすい | 木、グラフ、分割統治、バックトラッキングなどの部分問題分解に適し、コード構造が簡潔で明快 |
| 実装方法 | ループ構造 | 関数が自分自身を呼び出す |
| 時間効率 | 通常は高く、関数呼び出しのコストがない | 各関数呼び出しにコストがかかる |
| メモリ使用量 | 通常は一定量のメモリを使う | 呼び出しの積み重ねで大量のスタックフレームを使う場合がある |
| 適した問題 | 単純な繰り返し処理に適し、コードが直感的で読みやすい | 木、グラフ、分割統治、バックトラッキングなど、部分問題への分解に適し、コード構造が簡潔で明快 |
!!! tip
以下の内容が難しいと感じる場合は、「スタック」の章を読み終えた後に改めて復習してください。
以下の内容が難しく感じられたら、「スタック」の章を読んでから復習してください。
では、反復と再帰にはどのような内在的な関係があるのでしょうか。前述の再帰関数を例にすると、加算処理は再帰の復帰段階で行われます。これは、最初に呼び出された関数が実際には最後に加算を完了することを意味しており、**この動作の仕組みはスタックの「後入れ先出し」の原則とよく似ています**。
では、反復と再帰にはどのような関係があるのでしょうか。先ほどの再帰関数では、戻る段階で加算します。つまり、最初に呼ばれた関数が、最後に加算を終えます。**この仕組みは、スタックの「先入れ後出し」の原則と共通しています**。
実際、「呼び出しスタック」や「スタックフレーム領域」といった再帰の用語自体が、再帰とスタックの密接な関係を示唆しています。
実際、「呼び出しスタック」や「スタックフレーム」という用語自体が、再帰とスタックの密接な関係を示しています。
1. **再帰呼び出し**:関数が呼び出されると、システムは「呼び出しスタック」上にその関数のための新しいスタックフレームを割り当て、局所変数、引数、返却先アドレスなどのデータを保存します。
2. **復帰**:関数の実行が完了して戻ると、対応するスタックフレームは「呼び出しスタック」から取り除かれ、前の関数の実行環境が復元されます。
1. **呼び出しを進める**:関数を呼ぶと、システムは「呼び出しスタック」に新しいフレームを確保し、ローカル変数、引数、戻り先アドレスなどを保存します。
2. **戻る**:関数の実行が終わって戻ると、対応するフレームを「呼び出しスタック」から取り除き、前の関数の実行環境を復元します。
したがって、**明示的なスタックを使って呼び出しスタックの振る舞いを模擬することができ**、その結果として再帰を反復形式へ変換できます。
したがって、**明示的なスタックで呼び出しスタックの動作を模倣すれば**、再帰を反復に変換できます。
```src
[file]{recursion}-[class]{}-[func]{for_loop_recur}
```
上のコードを見ると、再帰を反復へ変換すると、コードはより複雑になります。反復と再帰は多くの場合に相互変換できますが、常にそうする価値があるとは限りません。理由は次の 2 点です。
このコードでは、反復に変換したことで複雑になっています。反復と再帰は多くの場合、相互に変換できますが、必ずしもそうする価値があるとは限りません。理由は二つあります。
- 変換後のコードは理解しにくくなり、可読性が下がる可能性があります。
- 複雑な問題によっては、システムの呼び出しスタックの振る舞いを模擬すること自体が非常に難しい場合があります。
- 変換後のコードの方が理解しにくく、可読性が下がる可能性があります。
- 複雑な問題では、システムの呼び出しスタックの動作を模倣すること自体が非常に難しい場合があります。
要するに、**反復を選ぶか再帰を選ぶかは、対象となる問題の性質によって決まります**。実際のプログラミングでは、両者の長所と短所を見極め、状況に応じて適切な方法を選ぶことが重要です。
**反復と再帰のどちらを選ぶかは、問題の性質によって決まります**。実際のプログラミングでは、両者の利点と欠点を比較し、状況に合った方法を選ぶことが大切です。

View File

@@ -1,49 +1,49 @@
# アルゴリズム効率の評価
# アルゴリズムの効率の評価
アルゴリズム設計では、次の 2 つのレベルの目標を順に追求します。
アルゴリズムの設計では、次の二つの目標を順に目指します。
1. **問題の解法を見つける**:アルゴリズムは、定められた入力範囲内で問題の正しい解を確実に求められる必要があります。
2. **最適な解法を追求する**:同じ問題に対して複数の解法が存在する場合があり、私たちはできるだけ効率的なアルゴリズムを見つけたいと考えます。
1. **解法を見つける**:指定された入力範囲で、問題の正しい解を確実に求められる必要があります。
2. **最良の解法を探す**:同じ問題に複数の解法がある場合、できるだけ効率的なアルゴリズムを見つけたいと考えます。
つまり、問題を解けることを前提として、アルゴリズム効率はその良し悪しを測る主要な評価指標となっており、次の 2 つの観点を含みます。
つまり、問題を解けることを前提に、その効率がアルゴリズムの良し悪しを測る主な基準になります。効率には次の二つの側面があります。
- **時間効率**:アルゴリズムの実行時間の長さ。
- **空間効率**:アルゴリズムが使用するメモリ空間の大きさ。
- **時間効率**:アルゴリズムの実行時間の長さです。
- **空間効率**:アルゴリズムが使うメモリ空間の大きさです。
簡単に言えば、**私たちの目標は「高速で省メモリ」なデータ構造とアルゴリズムを設計すること**です。そして、アルゴリズム効率を効果的に評価することは非常に重要です。そうすることで初めて、さまざまなアルゴリズムを比較し、さらにアルゴリズム設計と最適化の過程を導けるからです。
**目標は、「速くて省メモリ」なデータ構造とアルゴリズムを設計することです**。そのためには、効率を適切に評価することが欠かせません。評価できて初めて、アルゴリズム同士を比較し、設計や最適化の指針を得られるからです。
効率の評価方法は主に 2 種類に分けられます。実測と理論的な見積もりです。
効率の評価方法は、主に実測と理論的な見積もりの二つに分けられます。
## 実測
いまアルゴリズム `A` とアルゴリズム `B` があり、どちらも同じ問題を解けるとします。この 2 つのアルゴリズムの効率を比較する必要がある場合、最も直接的な方法は 1 台のコンピュータで両者を実行し、その実行時間とメモリ使用量を監視して記録することです。この評価方法は実際の状況を反映できますが、大きな制約もあります。
同じ問題を解けるアルゴリズム `A` と `B` の効率を比べたいとします。最も直接的なのは、コンピュータで両方を実行し、実行時間とメモリ使用量を計測・記録する方法です。実態を反映できますが、大きな制約もあります。
一方では、**テスト環境による干渉要因を排除しにくい**という問題があります。ハードウェア構成はアルゴリズムの性能に影響します。たとえば、並列度の高いアルゴリズムはマルチコア CPU での実行により適しており、メモリアクセスが集中的なアルゴリズムは高性能メモリ上でより良い性能を示します。つまり、異なるマシンでのテスト結果は一致しない可能性があります。これは、さまざまなマシンでテストして平均効率を統計的に求める必要があることを意味しますが、それは現実的ではありません。
一つは、**測定環境の影響を取り除きにくいこと**です。性能はハードウェア構成に左右されます。並列性が高いアルゴリズムならマルチコア CPU に適し、メモリ操作が多いアルゴリズムなら高性能なメモリでよい結果を得られます。つまり、マシンが違えば測定結果も異なる可能性があります。さまざまなマシンで測定して平均的な効率を求める必要がありますが、それは現実的ではありません。
他方では、**完全なテストを実施するには非常に多くの資源が必要**です。入力データ量が変化すると、アルゴリズムは異なる効率を示します。たとえば、入力データ量が小さいときはアルゴリズム `A` の実行時間がアルゴリズム `B` より短くても、入力データ量が大きいときには結果がちょうど逆になるかもしれません。そのため、説得力のある結論を得るには、さまざまな規模の入力データでテストする必要があり、それには大量の計算資源を要します。
もう一つは、**十分な測定には大量の資源が必要なこと**です。入力データ量によって効率は変わります。データが少なければ `A` の方が `B` より速くても、多ければ結果が逆転するかもしれません。説得力のある結論には、さまざまな規模の入力を試す必要があり、大量の計算資源を使います。
## 理論的な見積もり
実測には大きな制約があるため、いくつかの計算だけによってアルゴリズムの効率を評価することを考えられます。この見積もり方法は<u>漸近計算量解析(asymptotic complexity analysis)</u>と呼ばれ、略して<u>計算量解析</u>といいます。
実測には大きな制約があるため、計算だけで効率を評価することを考えます。この見積もり方法を<u>漸近計算量解析(asymptotic complexity analysis)</u>、略して<u>計算量の解析</u>と呼びます。
計算量解析は、アルゴリズムの実行に必要な時間資源と空間資源が入力データ規模とどのような関係にあるかを表します。**これは、入力データ規模が増加するにつれて、アルゴリズムの実行に必要な時間と空間がどのように増加するかという傾向を記述するものです**。この定義はややわかりにくいので、次の 3 つのポイントに分けて理解できます。
計算量の解析は、実行に必要な時間・空間資源と入力データの大きさの関係を表します。**入力データが大きくなるにつれ、必要な時間や空間がどのように増えるかを記述するものです**。少し抽象的な定義なので、三つに分けて理解しましょう。
- 「時間資源と空間資源」は、それぞれ<u>時間計算量(time complexity)</u>と<u>空間計算量(space complexity)</u>に対応します。
- 「入力データ規模が増加するにつれて」とは、計算量がアルゴリズムの実行効率と入力データ規模との関係を反映していることを意味します。
- 「時間と空間の増加傾向」とは、計算量解析が注目するのは実行時間や使用空間の具体的な値ではなく、時間や空間の増加の「速さ」であることを示します。
- 「時間・空間資源」は、それぞれ<u>時間計算量(time complexity)</u>と<u>空間計算量(space complexity)</u>に対応します。
- 「入力データが大きくなるにつれ」とは、実行効率と入力データの大きさの関係を表すという意味です。
- 「時間や空間がどのように増えるか」とは、具体的な実行時間や使用量ではなく、増加の「速さ」に注目するという意味です。
**計算量解析は実測という方法の欠点を克服しています**。その点は次のように表れます。
**計算量の解析は、次の点で実測の欠点を補えます**。
- 実際にコードを動かす必要がなく、より環境にやさしく省エネルギーです。
- テスト環境から独立しており、解析結果はすべての実行プラットフォームに適用できます。
- 異なるデータ量におけるアルゴリズム効率を表せ、とくに大規模データ量での性能を反映できます。
- 実際にコードを動かす必要がなく、省エネルギーです。
- 測定環境に依存せず、結果をすべての実行環境に適用できます。
- 異なるデータ量での効率、特に大量のデータに対する性能を示せます。
!!! tip
それでも計算量の概念がまだわかりにくくても、心配はいりません。後続の章で詳しく説明します。
まだ計算量の概念がわかりにくくても、心配はいりません。後の節で詳しく説明します。
計算量解析は、アルゴリズム効率を評価するための「物差し」を私たちに与えてくれます。これにより、あるアルゴリズムの実行に必要な時間資源と空間資源を測り、異なるアルゴリズム同士の効率を比較できます。
計算量の解析は効率を評価する「物差し」です。必要な時間・空間資源を見積もり、異なるアルゴリズムの効率を比較できます。
計算量は数学的な概念であり、初学者にとってはやや抽象的で、学習の難度も比較的高いかもしれません。この観点から見ると、計算量解析は最初に紹介する内容としてはあまり適していない可能性があります。しかし、あるデータ構造やアルゴリズムの特徴を議論する際には、その実行速度や空間使用状況の分析を避けることはできません。
計算量は数学的な概念なので、初学者には抽象的で、やや難しいかもしれません。その意味では、最初に説明する内容として適していない面もあります。しかし、データ構造やアルゴリズムの特徴を論じる際は、実行速度や空間使用量の分析を避けられません。
以上を踏まえると、データ構造とアルゴリズムを深く学ぶ前に、**まず計算量解析について初歩的な理解を持ち、簡単なアルゴリズムの計算量解析ができるようにしておくこと**を勧めます。
そこで、データ構造とアルゴリズムを深く学ぶ前に、**計算量の解析をひとまず理解し、簡単なアルゴリズムの計算量を分析できるようにしておくこと**をおすすめします。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,28 +1,28 @@
# 空間計算量
<u>空間計算量(space complexity)</u>は、アルゴリズムが占有するメモリ空間がデータ量の増加に伴ってどのように増えるかを測る指標です。この概念は時間計算量と非常によく似ており、「実行時間」を「占有メモリ空間」に置き換えるだけです。
<u>空間計算量(space complexity)</u>は、データ量が増えるにつれて、アルゴリズムのメモリ使用量がどのように増えるかを測ります。時間計算量とよく似ており、「実行時間」を「メモリ使用量」に置き換えた概念です。
## アルゴリズムに関連する空間
## アルゴリズムに関わる空間
アルゴリズムが実行中に使用するメモリ空間には、主に次の種類があります。
実行中に使うメモリ空間は、主に次の種類に分かれます。
- **入力空間**:アルゴリズムの入力データを格納するための空間。
- **一時空間**:アルゴリズムの実行中に使用する変数、オブジェクト、関数コンテキストなどのデータを格納するための空間。
- **出力空間**:アルゴリズムの出力データを格納するための空間。
- **入力空間**:入力データを保存します。
- **一時空間**:実行中の変数、オブジェクト、関数の実行状態などを保存します。
- **出力空間**:出力データを保存します。
一般に、空間計算量の集計範囲は「一時空間」と「出力空間」を合わせたものです。
通常、空間計算量には「一時空間」と「出力空間」を含めます。
一時空間はさらに三つに分けられます。
一時空間は、さらに三つに分けられます。
- **一時データ**:アルゴリズム実行中の各種定数、変数、オブジェクトなどを保存するための空間。
- **スタックフレーム空間**:呼び出された関数のコンテキストデータを保存するための空間。システムは関数を呼び出すたびにスタックの先頭にスタックフレームを作成し、関数が戻るとその空間を解放します。
- **命令空間**:コンパイル後のプログラム命令を保存するための空間で、実際の集計では通常無視されます。
- **一時データ**:実行中の定数、変数、オブジェクトなどを保存します。
- **スタックフレーム**:呼び出した関数の実行状態を保存します。関数を呼ぶたびにスタックの先頭にフレームが作られ、戻ると解放されます。
- **命令空間**:コンパイルされた命令を保存します。実際の集計では通常、無視します。
プログラムの空間計算量を分析する際には、**通常、一時データ、スタックフレーム空間、出力データの三つを数えます**。以下の図に示すとおりです。
プログラムの空間計算量では、下図のように、**通常は一時データ、スタックフレーム、出力データの三つを数えます**。
![アルゴリズムで使用される関連空間](space_complexity.assets/space_types.png)
![アルゴリズムが使う空間](space_complexity.assets/space_types.png)
関連するコードを以下に示します。
関連するコードを次に示します。
=== "Python"
@@ -35,14 +35,14 @@
def function() -> int:
"""関数"""
# いくつかの処理を実行...
# 何らかの処理を実行...
return 0
def algorithm(n) -> int: # 入力データ
A = 0 # 一時データ(定数。一般に大文字で表す)
A = 0 # 一時データ(定数、通常は大文字で表す)
b = 0 # 一時データ(変数)
node = Node(0) # 一時データ(オブジェクト)
c = function() # スタックフレーム空間(関数呼び出し)
c = function() # スタックフレーム(関数呼び出し)
return A + b + c # 出力データ
```
@@ -58,7 +58,7 @@
/* 関数 */
int func() {
// いくつかの処理を実行...
// 何らかの処理を実行...
return 0;
}
@@ -66,7 +66,7 @@
const int a = 0; // 一時データ(定数)
int b = 0; // 一時データ(変数)
Node* node = new Node(0); // 一時データ(オブジェクト)
int c = func(); // スタックフレーム空間(関数呼び出し)
int c = func(); // スタックフレーム(関数呼び出し)
return a + b + c; // 出力データ
}
```
@@ -83,7 +83,7 @@
/* 関数 */
int function() {
// いくつかの処理を実行...
// 何らかの処理を実行...
return 0;
}
@@ -91,7 +91,7 @@
final int a = 0; // 一時データ(定数)
int b = 0; // 一時データ(変数)
Node node = new Node(0); // 一時データ(オブジェクト)
int c = function(); // スタックフレーム空間(関数呼び出し)
int c = function(); // スタックフレーム(関数呼び出し)
return a + b + c; // 出力データ
}
```
@@ -107,7 +107,7 @@
/* 関数 */
int Function() {
// いくつかの処理を実行...
// 何らかの処理を実行...
return 0;
}
@@ -115,7 +115,7 @@
const int a = 0; // 一時データ(定数)
int b = 0; // 一時データ(変数)
Node node = new(0); // 一時データ(オブジェクト)
int c = Function(); // スタックフレーム空間(関数呼び出し)
int c = Function(); // スタックフレーム(関数呼び出し)
return a + b + c; // 出力データ
}
```
@@ -129,14 +129,14 @@
next *node
}
/* node 構造体を作成 */
/* node 構造体を作成 */
func newNode(val int) *node {
return &node{val: val}
}
/* 関数 */
func function() int {
// いくつかの処理を実行...
// 何らかの処理を実行...
return 0
}
@@ -144,7 +144,7 @@
const a = 0 // 一時データ(定数)
b := 0 // 一時データ(変数)
newNode(0) // 一時データ(オブジェクト)
c := function() // スタックフレーム空間(関数呼び出し)
c := function() // スタックフレーム(関数呼び出し)
return a + b + c // 出力データ
}
```
@@ -164,7 +164,7 @@
/* 関数 */
func function() -> Int {
// いくつかの処理を実行...
// 何らかの処理を実行...
return 0
}
@@ -172,7 +172,7 @@
let a = 0 // 一時データ(定数)
var b = 0 // 一時データ(変数)
let node = Node(x: 0) // 一時データ(オブジェクト)
let c = function() // スタックフレーム空間(関数呼び出し)
let c = function() // スタックフレーム(関数呼び出し)
return a + b + c // 出力データ
}
```
@@ -192,7 +192,7 @@
/* 関数 */
function constFunc() {
// いくつかの処理を実行
// 何らかの処理を実行
return 0;
}
@@ -200,7 +200,7 @@
const a = 0; // 一時データ(定数)
let b = 0; // 一時データ(変数)
const node = new Node(0); // 一時データ(オブジェクト)
const c = constFunc(); // スタックフレーム空間(関数呼び出し)
const c = constFunc(); // スタックフレーム(関数呼び出し)
return a + b + c; // 出力データ
}
```
@@ -220,7 +220,7 @@
/* 関数 */
function constFunc(): number {
// いくつかの処理を実行
// 何らかの処理を実行
return 0;
}
@@ -228,7 +228,7 @@
const a = 0; // 一時データ(定数)
let b = 0; // 一時データ(変数)
const node = new Node(0); // 一時データ(オブジェクト)
const c = constFunc(); // スタックフレーム空間(関数呼び出し)
const c = constFunc(); // スタックフレーム(関数呼び出し)
return a + b + c; // 出力データ
}
```
@@ -245,7 +245,7 @@
/* 関数 */
int function() {
// いくつかの処理を実行...
// 何らかの処理を実行...
return 0;
}
@@ -253,7 +253,7 @@
const int a = 0; // 一時データ(定数)
int b = 0; // 一時データ(変数)
Node node = Node(0); // 一時データ(オブジェクト)
int c = function(); // スタックフレーム空間(関数呼び出し)
int c = function(); // スタックフレーム(関数呼び出し)
return a + b + c; // 出力データ
}
```
@@ -279,7 +279,7 @@
/* 関数 */
fn function() -> i32 {
// いくつかの処理を実行...
// 何らかの処理を実行...
return 0;
}
@@ -287,7 +287,7 @@
const a: i32 = 0; // 一時データ(定数)
let mut b = 0; // 一時データ(変数)
let node = Node::new(0); // 一時データ(オブジェクト)
let c = function(); // スタックフレーム空間(関数呼び出し)
let c = function(); // スタックフレーム(関数呼び出し)
return a + b + c; // 出力データ
}
```
@@ -297,14 +297,14 @@
```c title=""
/* 関数 */
int func() {
// いくつかの処理を実行...
// 何らかの処理を実行...
return 0;
}
int algorithm(int n) { // 入力データ
const int a = 0; // 一時データ(定数)
int b = 0; // 一時データ(変数)
int c = func(); // スタックフレーム空間(関数呼び出し)
int c = func(); // スタックフレーム(関数呼び出し)
return a + b + c; // 出力データ
}
```
@@ -319,7 +319,7 @@
/* 関数 */
fun function(): Int {
// いくつかの処理を実行...
// 何らかの処理を実行...
return 0
}
@@ -327,7 +327,7 @@
val a = 0 // 一時データ(定数)
var b = 0 // 一時データ(変数)
val node = Node(0) // 一時データ(オブジェクト)
val c = function() // スタックフレーム空間(関数呼び出し)
val c = function() // スタックフレーム(関数呼び出し)
return a + b + c // 出力データ
}
```
@@ -347,7 +347,7 @@
### 関数 ###
def function
# いくつかの処理を実行...
# 何らかの処理を実行...
0
end
@@ -356,21 +356,21 @@
a = 0 # 一時データ(定数)
b = 0 # 一時データ(変数)
node = Node.new(0) # 一時データ(オブジェクト)
c = function # スタックフレーム空間(関数呼び出し)
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)$ です。
1. **最悪の入力を基準にする**:$n < 10$ なら $O(1)$ ですが、$n > 10$ なら配列 `nums` が $O(n)$ の空間を使うため、最悪空間計算量は $O(n)$ です。
2. **実行中の最大メモリ使用量を基準にする**:たとえば、最後の行の実行前は $O(1)$ ですが、配列 `nums` を初期化すると $O(n)$ の空間を使うため、最悪空間計算量は $O(n)$ です。
=== "Python"
@@ -523,13 +523,13 @@
end
```
**再帰関数では、スタックフレーム空間の集計に注意が必要です**。以下のコードを見てみましょう。
**再帰関数では、スタックフレームを数え忘れないよう注意が必要です**。次のコードを見てください。
=== "Python"
```python title=""
def function() -> int:
# いくつかの処理を実行
# 何らかの処理を実行
return 0
def loop(n: int):
@@ -548,7 +548,7 @@
```cpp title=""
int func() {
// いくつかの処理を実行
// 何らかの処理を実行
return 0;
}
/* ループの空間計算量は O(1) */
@@ -568,7 +568,7 @@
```java title=""
int function() {
// いくつかの処理を実行
// 何らかの処理を実行
return 0;
}
/* ループの空間計算量は O(1) */
@@ -588,7 +588,7 @@
```csharp title=""
int Function() {
// いくつかの処理を実行
// 何らかの処理を実行
return 0;
}
/* ループの空間計算量は O(1) */
@@ -608,7 +608,7 @@
```go title=""
func function() int {
// いくつかの処理を実行
// 何らかの処理を実行
return 0
}
@@ -633,7 +633,7 @@
```swift title=""
@discardableResult
func function() -> Int {
// いくつかの処理を実行
// 何らかの処理を実行
return 0
}
@@ -657,7 +657,7 @@
```javascript title=""
function constFunc() {
// いくつかの処理を実行
// 何らかの処理を実行
return 0;
}
/* ループの空間計算量は O(1) */
@@ -677,7 +677,7 @@
```typescript title=""
function constFunc(): number {
// いくつかの処理を実行
// 何らかの処理を実行
return 0;
}
/* ループの空間計算量は O(1) */
@@ -697,7 +697,7 @@
```dart title=""
int function() {
// いくつかの処理を実行
// 何らかの処理を実行
return 0;
}
/* ループの空間計算量は O(1) */
@@ -717,7 +717,7 @@
```rust title=""
fn function() -> i32 {
// いくつかの処理を実行
// 何らかの処理を実行
return 0;
}
/* ループの空間計算量は O(1) */
@@ -739,7 +739,7 @@
```c title=""
int func() {
// いくつかの処理を実行
// 何らかの処理を実行
return 0;
}
/* ループの空間計算量は O(1) */
@@ -759,7 +759,7 @@
```kotlin title=""
fun function(): Int {
// いくつかの処理を実行
// 何らかの処理を実行
return 0
}
/* ループの空間計算量は O(1) */
@@ -779,7 +779,7 @@
```ruby title=""
def function
# いくつかの処理を実行
# 何らかの処理を実行
0
end
@@ -795,86 +795,86 @@
end
```
関数 `loop()` と `recur()` はどちらも時間計算量は $O(n)$ ですが、空間計算量は異なります。
関数 `loop()` と `recur()` の時間計算量はどちらも $O(n)$ ですが、空間計算量は異なります。
- 関数 `loop()` はループの中で `function()` を $n$ 回呼び出しますが、各反復での `function()` は戻るたびにスタックフレーム空間が解放されるため、空間計算量は依然として $O(1)$ です。
- 再帰関数 `recur()` は実行中に未返却の `recur()` が同時に $n$ 個存在するため、$O(n)$ のスタックフレーム空間を占有します。
- `loop()` は `function()` を $n$ 回呼びますが、毎回戻ってスタックフレームを解放するため、空間計算量は $O(1)$ のままです。
- 再帰関数 `recur()` では、戻っていない `recur()` が同時に $n$ 個存在するため、スタックフレームに $O(n)$ の空間を使います。
## よくある型
## 代表的な種類
入力データサイズを $n$ とすると、以下の図はよくある空間計算量の型を低い順から高い順に示しています。
入力の大きさを $n$ とし、代表的な空間計算量を小さい順に下図に示します。
$$
\begin{aligned}
& O(1) < O(\log n) < O(n) < O(n^2) < O(2^n) \newline
& \text{定数階} < \text{対数階} < \text{線形階} < \text{平方階} < \text{指数階}
& \text{定数} < \text{対数} < \text{線形} < \text{二乗} < \text{指数}
\end{aligned}
$$
![よくある空間計算量の型](space_complexity.assets/space_complexity_common_types.png)
![代表的な空間計算量](space_complexity.assets/space_complexity_common_types.png)
### 定数階 $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$ で、まだ戻っていない `linear_recur()` が同時に $n$ 個存在するため、スタックフレームに $O(n)$ の空間を使います。
```src
[file]{space_complexity}-[class]{}-[func]{linear_recur}
```
![再帰関数が生み出す線形階の空間計算量](space_complexity.assets/space_complexity_recursive_linear.png)
![再帰関数による線形の空間計算量](space_complexity.assets/space_complexity_recursive_linear.png)
### 平方階 $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}
```
![再帰関数が生み出す平方階の空間計算量](space_complexity.assets/space_complexity_recursive_quadratic.png)
![再帰関数による二乗の空間計算量](space_complexity.assets/space_complexity_recursive_quadratic.png)
### 指数階 $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}
```
![満二分木が生み出す指数階の空間計算量](space_complexity.assets/space_complexity_exponential.png)
![パーフェクト二分木による指数の空間計算量](space_complexity.assets/space_complexity_exponential.png)
### 対数階 $O(\log n)$
### 対数のオーダー $O(\log n)$
対数階は分割統治アルゴリズムによく現れます。例えばマージソートでは、長さ $n$ の配列を入力として、各再帰で配列を中央から二つに分割するため、高さ $\log n$ の再帰木が形成され、$O(\log n)$ のスタックフレーム空間を使用します。
対数のオーダーは分割統治によく見られます。たとえばマージソートは、長さ $n$ の配列を各再帰で中央から半分に分け、高さ $\log n$ の再帰木を作るため、スタックフレームに $O(\log n)$ の空間を使います。
また、数値を文字列に変換する場合を考えると、正の整数 $n$ の桁数は $\lfloor \log_{10} n \rfloor + 1$ であり、対応する文字列長も $\lfloor \log_{10} n \rfloor + 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)$ です。
## 時間と空間のトレードオフ
理想的には、アルゴリズムの時間計算量と空間計算量の両方を最適にしたいところです。しかし実際には、この二つを同時に最適化するのは通常きわめて困難です。
理想的には、時間計算量も空間計算量も最適にしたいところです。しかし実際には、両方を同時に最適化するのは非常に難しいことが多いです。
**時間計算量を下げるには、通常、空間計算量を増やす代償が必要であり、その逆も同様です**。メモリ空間を犠牲にして実行速度を上げる考え方を「空間を時間と引き換えにする」と呼び、その逆を「時間を空間と引き換えにする」と呼びます。
**時間計算量を減らすには、空間計算量を増やす必要があることが多く、その逆も同様です**。メモリを使って実行を速くする考え方を「空間を使って時間を節約する」、その逆を「時間を使って空間を節約する」と呼びます。
どちらの考え方を選ぶかは、何をより重視するかによって決まります。多くの場合、空間より時間のほうが貴重なので、「空間を時間と引き換えにする」戦略のほうが一般的です。もちろん、データ量が非常に大きい場合には、空間計算量を抑えることも同じくらい重要です。
どちらを選ぶかは、何を重視するかによります。多くの場合、空間より時間の方が貴重なため、空間を使って時間を節約する戦略が一般的です。ただし、データ量が非常に多い場合は、空間計算量を抑えることも重要です。

View File

@@ -2,54 +2,54 @@
### 要点の振り返り
**アルゴリズム効率の評価**
**アルゴリズムの効率の評価**
- 時間効率と空間効率は、アルゴリズムの良し悪しを測る二つの主要な評価指標です。
- 実測によってアルゴリズム効率を評価できますが、テスト環境の影響を排除しにくく、多くの計算資源も消費します。
- 複雑度分析は実測の欠点を補い、分析結果はすべての実行プラットフォームに適用でき、データ規模ごとの効率も明らかにできます。
- 時間効率と空間効率は、アルゴリズムの良し悪しを測る二つの主な基準です。
- 実測でも効率を評価できますが、測定環境の影響を除くのが難しく、大量の計算資源を使います。
- 計算量の解析は実測の欠点を補い、すべての実行環境に適用できる結果と、異なるデータ量での効率を示します。
**時間計算量**
- 時間計算量は、アルゴリズムの実行時間がデータ量の増加に伴ってどう変化するかを測るためのものであり、効率評価に有効です。ただし、入力データ量が小さい場合や時間計算量が同じ場合などには、効率の優劣を正確に比較できないことがあります。
- 最悪時間計算量はビッグオー記法 $O$ で表され、関数の漸近上界に対応し、$n$ が正の無限大に近づくときの操作回数 $T(n)$ の増加の度合いを表します。
- 時間計算量の推定は二段階に分かれ、まず操作回数を数え、次に漸近上界を判断します。
- 一般的な時間計算量を低い順から並べると、$O(1)$、$O(\log n)$、$O(n)$、$O(n \log n)$、$O(n^2)$、$O(2^n)$、$O(n!)$ などがあります。
- 一部のアルゴリズムの時間計算量は固定ではなく、入力データの分布に関係します。時間計算量には最悪、最良、平均時間計算量がありますが、最良時間計算量は入力データが厳しい条件を満たす必要があるため、ほとんど使われません。
- 平均時間計算量は、ランダムな入力データに対するアルゴリズムの実行効率を表し、実運用時の性能に最も近い指標です。平均時間計算量を求めるには、入力データの分布と、それを踏まえた数学的期待値を統計する必要があります。
- 時間計算量は、データ量の増加に伴う実行時間の増え方を測るもので、効率の評価に役立ちます。ただし、入力が小さい場合や計算量が同じ場合など、効率を正確に比較できないこともあります。
- 最悪時間計算量はビッグ $O$ 記法で表し、関数の漸近上界に対応します。$n$ が正の無限大に近づくときの、操作回数 $T(n)$ の増加のオーダーを表します。
- 時間計算量は、まず操作回数を数え、次に漸近上界を求める、という 2 段階で見積もります。
- 代表的な時間計算量を小さい順に並べると、$O(1)$、$O(\log n)$、$O(n)$、$O(n \log n)$、$O(n^2)$、$O(2^n)$、$O(n!)$ などがあります。
- 計算量が一定ではなく、入力データの分布によって変わるアルゴリズムもあります。時間計算量には最悪・最良・平均があります。最良の場合を達成するには入力に厳しい条件が必要なため、最良時間計算量はほとんど使いません。
- 平均時間計算量はランダムな入力での実行効率を表し、実際の用途での性能に最も近いものです。求めるには、入力データの分布と、それらを総合した期待値を計算する必要があります。
**空間計算量**
- 空間計算量の役割は時間計算量に似ており、アルゴリズムが使用するメモリ空間がデータ量の増加に伴ってどう変化するかを測ります。
- アルゴリズム実行中に関係するメモリ空間は、入力空間、一時空間、出力空間に分けられます。通常、入力空間は空間計算量の計算に含めません。一時空間は一時データ、スタックフレーム空間、命令空間に分けられ、このうちスタックフレーム空間は通常、再帰関数でのみ空間計算量に影響します。
- 私たちは通常、最悪空間計算量のみに注目し、最悪の入力データと最悪の実行時点における空間計算量を数えます。
- 一般的な空間計算量を低い順から並べると、$O(1)$、$O(\log n)$、$O(n)$、$O(n^2)$、$O(2^n)$ などがあります。
- 空間計算量は時間計算量と同様に、データ量の増加に伴うメモリ使用量の増え方を測ります。
- 実行に関わるメモリ空間は、入力空間、一時空間、出力空間に分けられます。通常、入力空間は空間計算量に含めません。一時空間は、一時データ、スタックフレーム、命令の空間に分けられ、スタックフレームが計算量に影響するのは通常、再帰関数の場合だけです。
- 通常は最悪空間計算量だけに注目し、最悪の入力と最もメモリを使う時点での空間計算量を求めます。
- 代表的な空間計算量を小さい順に並べると、$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>関数(function)</u>は独立して実行でき、すべての引数は明示的に渡されます。<u>メソッド(method)</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** 実際の利用場面に応じて、時間(または空間)を犠牲にしてアルゴリズムを設計することはありますか?
**Q** 実際の用途に応じて、時間や空間を犠牲にして設計することはありますか。
実際の応用では、多くの場合、空間を犠牲にして時間を得る選択をします。たとえばデータベースのインデックスでは、通常 B+ 木やハッシュインデックスを構築し、大量のメモリ空間を使う代わりに、$O(\log n)$ あるいは $O(1)$ の高速な検索を実現します。
実際の用途では、空間を使って時間を節約する選択が多く見られます。たとえばデータベースのインデックスでは、B+ 木やハッシュインデックスに大量のメモリを使うことで、$O(\log n)$、さらには $O(1)$ の高速な検索を実現します。
空間資源が貴重な場面では、時間を犠牲にして空間を得ることもあります。たとえば組み込み開発では、デバイスのメモリが非常に貴重なため、エンジニアはハッシュテーブルの使用をやめ、配列による順次探索を選んでメモリ使用量を節約することがあります。その代償として探索は遅くなります。
空間資源が貴重な場面では、時間を使って空間を節約することもあります。たとえば組み込み開発ではメモリが貴重なため、ハッシュテーブルを使わず、配列の順次探索を選ぶ場合があります。メモリを節約できる代わりに、探索は遅くなります。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

File diff suppressed because it is too large Load Diff