* 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
11 KiB
まとめ
要点の振り返り
- 配列と連結リストは二つの基本的なデータ構造であり、コンピュータのメモリにデータを格納する二つの方法、すなわち連続した領域への格納と分散した領域への格納をそれぞれ表します。両者の特徴は互いに補い合う関係にあります。
- 配列はランダムアクセスをサポートし、メモリ使用量も少なく済みます。ただし、要素の挿入と削除の効率は低く、初期化後に長さを変えることはできません。
- 連結リストは参照(ポインタ)の変更によりノードを効率よく挿入・削除でき、長さも柔軟に調整できます。ただし、ノードへのアクセス効率は低く、メモリ使用量が多くなります。代表的な種類には、単方向連結リスト、循環連結リスト、双方向連結リストがあります。
- リストは、挿入・削除・検索・更新に対応する、順序を持つ要素の集合です。通常は動的配列で実装されます。配列の利点を保ちながら、長さを柔軟に調整できます。
- リストの登場により配列の実用性は大幅に向上しましたが、メモリ領域の一部が無駄になる可能性もあります。
- プログラムの実行時、データは主にメモリに格納されます。配列はメモリ空間を効率よく使えますが、連結リストはメモリをより柔軟に使えます。
- キャッシュは、キャッシュライン、プリフェッチ、空間的局所性、時間的局所性などの仕組みでデータを読み込み、CPU に高速なデータアクセスを提供して、プログラムの実行効率を大幅に高めます。
- 配列はキャッシュヒット率が高いため、通常は連結リストより効率的です。データ構造は、具体的な要件や場面に応じて適切に選ぶ必要があります。
Q & A
Q:配列をスタックに置く場合とヒープに置く場合で、時間効率や空間効率に違いはありますか。
スタック上でもヒープ上でも、配列は連続したメモリ領域に格納されるため、データ操作の効率は基本的に同じです。ただし、スタックとヒープにはそれぞれ特徴があり、次のような違いが生じます。
- 確保と解放の効率:スタックは比較的小さなメモリ領域で、割り当てはコンパイラが自動的に行います。一方、ヒープは比較的大きく、コードから動的に確保できる反面、断片化しやすくなります。そのため、ヒープでの確保と解放は通常、スタックより遅くなります。
- サイズの制限:スタックのメモリは比較的小さく、ヒープの大きさは通常、利用可能なメモリに制限されます。そのため、大きな配列を格納するにはヒープの方が適しています。
- 柔軟性:スタック上の配列はコンパイル時に大きさを決める必要がありますが、ヒープ上の配列は実行時に動的に決められます。
Q:配列では要素が同じ型でなければならないのに、連結リストでは同じ型であることを強調しないのはなぜですか。
連結リストはノードで構成され、ノード同士が参照(ポインタ)でつながっています。各ノードには int、double、string、object など、異なる型のデータを格納できます。
一方、配列ではオフセットを計算して要素の位置を求めるため、要素が同じ型でなければなりません。たとえば配列に int と long の両方が含まれ、1 要素の大きさがそれぞれ 4 バイトと 8 バイトだとします。この場合は「要素の長さ」が 2 種類あるため、次の式でオフセットを計算できなくなります。
# 要素のメモリアドレス = 配列のメモリアドレス(先頭要素のメモリアドレス) + 要素の長さ * 要素のインデックス
Q:ノード P を削除した後、P.next を None にする必要はありますか。
P.next は変更しなくてもかまいません。その連結リストから見ると、先頭ノードから末尾ノードまで走査しても、もはや P には出会いません。つまり、ノード P はすでに連結リストから削除されており、どこを指していてもその連結リストには影響しません。
データ構造とアルゴリズムの問題を解くという観点では、プログラムのロジックが正しければ、参照を切らなくても問題ありません。標準ライブラリという観点では、切り離す方が安全で、ロジックも明確になります。参照を切らず、削除したノードが適切に回収されなかった場合、その後続ノードのメモリ回収に影響します。
Q:連結リストの挿入と削除の時間計算量は O(1) ですが、その前に要素を探すのに O(n) の時間がかかります。それなら時間計算量は O(n) ではないのでしょうか。
先に要素を探してから削除するなら、時間計算量は確かに O(n) です。ただし、連結リストでの挿入・削除が O(1) である利点は、ほかの用途で活かせます。たとえば、両端キューは連結リストでの実装に適しています。先頭ノードと末尾ノードを常に指すポインタ変数を保持すれば、各回の挿入と削除は O(1) になります。
Q:「連結リストの定義と格納方法」の図にある、水色のノードへのポインタは、一つのメモリアドレスに相当する領域を占めるのでしょうか。それともノードの値と半分ずつ占めるのでしょうか。
この図は定性的な模式図です。実際の大きさは、具体的な状況に応じて検討する必要があります。
- ノードの値が占める空間は、
int、long、double、インスタンスオブジェクトなど、型によって異なります。 - ポインタ変数が占めるメモリの大きさは、使用する OS とコンパイル環境によって決まり、多くの場合は 8 バイトまたは 4 バイトです。
Q:リストの末尾に要素を追加する操作は、常に O(1) ですか。
追加によってリストの長さを超える場合は、まずリストの容量を拡張してから追加する必要があります。システムは新しいメモリ領域を確保し、元のリストの全要素を移すため、このときの時間計算量は O(n) になります。
Q:「リストの登場により配列の実用性は大幅に向上しましたが、メモリ領域の一部が無駄になる可能性もあります」とあります。この無駄とは、容量、長さ、拡張倍率などの追加の変数が占めるメモリのことですか。
ここでいう空間の無駄には、主に二つの意味があります。一つは、リストに設定する初期の長さが、実際に必要な量より大きい場合があることです。もう一つは、頻繁な容量拡張を避けるため、通常は \times 1.5 のように一定の係数を掛けて拡張することです。その結果、多くの空き領域が生まれ、通常はすべてを埋めることはできません。
Q:Python で n = [1, 2, 3] と初期化すると、この 3 要素のアドレスは連続しています。しかし m = [2, 1, 3] と初期化すると、各要素の id は連続しておらず、それぞれ n の同じ要素と一致します。要素のアドレスが連続していなくても、m は配列なのでしょうか。
リストの要素を連結リストのノードにして n = [n1, n2, n3, n4, n5] とした場合も、通常、この 5 個のノードオブジェクトはメモリの各所に分散して格納されます。それでも、リストのインデックスを指定すれば、O(1) 時間でノードのメモリアドレスを取得し、対応するノードにアクセスできます。これは、配列に格納されているのがノード自体ではなく、ノードへの参照だからです。
多くの言語と異なり、Python では数値もオブジェクトとして扱われます。リストには数値自体ではなく、数値への参照が格納されます。そのため、二つの配列にある同じ数値の id が一致し、それらの数値のメモリアドレスが連続している必要もないのです。
Q:C++ STL の std::list は双方向連結リストを実装していますが、アルゴリズムの本ではあまり直接使われていないようです。何か制約があるのでしょうか。
一つには、アルゴリズムの実装では配列が好まれ、連結リストは必要な場合にだけ使うことが多いためです。主な理由は二つあります。
- 空間コスト:各要素に追加で二つのポインタ(前の要素用と次の要素用)が必要なため、
std::listは通常、std::vectorより多くの空間を使います。 - キャッシュとの相性:データが連続して格納されていないため、
std::listはキャッシュの利用効率が低くなります。一般に、std::vectorの方が高い性能を得られます。
もう一つには、連結リストを使う必要があるのは主に二分木やグラフの場合だという点があります。スタックやキューには、連結リストではなく、プログラミング言語が提供する stack や queue を使うことが多いです。
Q:res = [[0]] * n で作った二次元リストでは、それぞれの [0] は独立していますか。
独立していません。この二次元リストでは、すべての [0] が実際には同じオブジェクトへの参照です。一つの要素を変更すると、対応するすべての要素も変わります。
二次元リストの各 [0] を独立させたい場合は、res = [[0] for _ in range(n)] とします。この方法では、独立した [0] というリストオブジェクトを n 個初期化します。
Q:res = [0] * n で作ったリストでは、それぞれの整数 0 は独立していますか。
このリストでは、すべての整数 0 が同じオブジェクトへの参照です。Python は小さな整数(通常は -5 から 256)をキャッシュプールで管理し、オブジェクトをできるだけ再利用して性能を高めているためです。
同じオブジェクトを指していても、リストの各要素は独立に変更できます。Python の整数が「不変オブジェクト」だからです。要素を変更するときは、元のオブジェクト自体を変えるのではなく、別のオブジェクトへの参照に切り替えています。
ただし、リストの要素が「可変オブジェクト」(リスト、辞書、クラスのインスタンスなど)の場合、その要素を変更するとオブジェクト自体が変わり、それを参照するすべての要素に同じ変更が反映されます。