「整列 (sorting)」はある規則に従ってデータを順番に並べ換える操作です。たとえば、データが整数であれば大きい順に並べる、もしくは小さい順に並べます。一般に、このような操作を「ソート (sort)」と呼んでいます。
ソートは昔から研究されている分野で、優秀なアルゴリズムが確立しています。Yet Another C++ Problems (2) では、バブルソート、選択ソート、単純挿入ソートを取り上げました。要素数を \(N\) とすると、これらは \(N^2\) に比例する遅いソートです。今回はもっと速いソートを紹介しましょう。
「シェルソート (shell sort)」は挿入ソートの改良版ともいえる方法です。最初は遠く離れた要素間でソートを開始し、徐々に間隔を狭めていきます。最後は隣り合った要素間でソートします。つまり、単純挿入ソートと同じになります。
間隔が大きいときは要素の個数が少ないので、単純なアルゴリズムでもソートにかかる時間は少なくてすみます。間隔が小さくなると要素の個数は多くなりますが、大まかにソートされているので挿入ソートでも高速にソートすることが可能です。
9 5 3 7 6 4 2 8 最初の状態 9 6 間隔を 4 で分割する 5 4 3 8 7 2 6 9 各群をソートする 4 5 3 8 2 7 6 3 9 8 間隔を 2 で分割する 4 2 5 7 3 6 8 9 各群をソートする 2 4 5 7 3 2 6 4 8 5 9 7 間隔を 1 で分割する(単純挿入ソートと同じ) 2 3 4 5 6 7 8 9 ソート完了 図 : シェルソート
プログラムは次のようになります。
リスト : シェルソート void shell_sort(int buff[], int size) { for (int gap = size / 2; gap > 0; gap /= 2) { for (int i = gap; i < size; i++) { int temp = buff[i]; int j = i - gap; for (; j >=0 && temp < buff[j]; j -= gap) buff[j + gap] = buff[j]; buff[j + gap] = temp; } } }
最初のループで間隔を徐々に狭めていきます。ここでは単純に 2 で割っていくことにしました。次のループで比較する要素を取り出します。最後のループでこの要素を挿入する位置を探索します。このときの探索は隣り合った要素ではなく gap 離れた要素を比較します。
2 番目のループでは、各群を並列にソートしていることに注意してください。群のひとつの要素を取り出して位置を決めたら、次の群の要素を取り出して位置を決めています。最後に gap は 1 になるので、挿入ソートと同じになりソートが完了します。
シェルソートの場合、gap を常に奇数になるようにすると、実行速度はデータの個数 n の 1.5 乗に比例します。また、クヌース先生によると、gap の値に次の数列を用いると、シェルソートは n の 1.25 乗に比例するそうです。
gap = ..., 121, 40, 13, 4, 1
この数列は 3 倍して 1 を加えることで得られる数列を逆にしたものです。興味のある方は拙作のページ お気楽C言語プログラミング超入門 の ソート をお読みください。
クイックソートはある値を基準にして、要素をそれより大きいものと小さいものの 2 つに分割していくことでソートを行います。2 つに分けた各々の区間を同様に分割して 2 つの区間に分けます。最後は区間の要素がひとつになってソートが完了します。
9 5 3 7 6 4 2 8 最初の状態 9 5 3 7 6 4 2 8 7 を枢軸にして左側から 7 以上の値を探し、 L R 右側から 7 以下の値を探す。 2 5 3 7 6 4 9 8 交換する L R 2 5 3 7 6 4 9 8 検索する L R 2 5 3 4 6 7 9 8 交換する L R 2 5 3 4 6 7 9 8 検索する。R と L が交差したら分割終了。 R L [2 5 3 4 6] [7 9 8] この 2 つの区間について再び同様な分割を行う 図 : クイックソート
基準になる値のことを「枢軸 (pivot)」といいます。枢軸は要素の中から適当な値を選びます。今回は中央にある要素を選ぶことにしましょう。上図を見てください。左側から枢軸 7 以上の要素を探し、左側から 7 以下の要素を探します。探索のときは枢軸が番兵の役割を果たすので、ソート範囲外の要素を探索することはありません。見つけたらお互いの要素を交換します。探索位置が交差したら分割は終了です。
あとは同じ手順を分割した 2 つの区間に適用します。これは再帰定義を使えば簡単に実現できます。分割した区間の要素数が 1 になったときが再帰の停止条件になります。プログラムは次のようになります。
リスト : クイックソート void qsort_sub(int buff[], int low, int high) { int pivot = buff[low + (high - low) / 2]; int i = low; int j = high; while (true) { while (pivot > buff[i]) i++; while (pivot < buff[j]) j--; if (i >= j) break; int temp = buff[i]; buff[i] = buff[j]; buff[j] = temp; i++; j--; } if (low < i - 1) qsort_sub(buff, low, i - 1); if (high > j + 1) qsort_sub(buff, j + 1, high); } void quick_sort(int buff[], int size) { qsort_sub(buff, 0, size - 1); }
実際の処理は関数 qsort_sub で行います。引数 buff がソートする配列、low が区間の下限値、high が区間の上限値です。qsort_sub は buff の low から high までの区間をソートします。最初に、区間の中央にあるデータを枢軸として選びます。そして、最初の while ループで pivot を基準にして区間を 2 つに分けます。
次の while ループで、左側から枢軸以上の要素を探しています。ここでは枢軸以上という条件を、枢軸より小さい間は探索位置を進める、というように置き換えています。同様に次の while ループで右側から枢軸以下の要素を探します。お互いの探索位置 i, j が交差したら分割は終了です。break 文で while ループから脱出します。そうでなければお互いの要素を交換します。交換したあとは i と j の値を更新しておくことを忘れないでください。
そして、分割した区間に対して quick_sort を再帰呼び出しします。このとき要素数をチェックして、2 個以上ある場合に再帰呼び出しを行います。この停止条件を忘れると正常に動作しません。ご注意ください。
クイックソートは、枢軸の選び方で効率が大きく左右されます。区間の中央値を枢軸に選ぶと、区間をほぼ半分に分割することができます。この場合がいちばん効率が良く、データ数を \(N\) とすると \(N \log_2 N\) に比例する時間でソートすることができます。
逆に、区間での最大値または最小値を枢軸に選ぶと、区間はその要素と残りの要素の 2 つに分割にされることになります。これが最悪の場合で、分割のたびに最大値もしくは最小値を選ぶと、実行時間は要素数の 2 乗に比例することになります。これは遅いソートアルゴリズムであるバブルソートや単純挿入ソートと同じです。
この問題は枢軸の選び方を工夫することで、完全ではありませんが回避することができます。区間の中からいくつかの要素を選び、その中で中央の値を持つ要素を枢軸とします。たくさんの要素を選ぶとそれだけ最悪の枢軸を選ぶ危険性は少なくなりますが、値を選ぶのに時間がかかってしまいます。実際には数個の要素を選び、その中央値を枢軸とする場合が多いようです。興味のある方は拙作のページ お気楽C言語プログラミング超入門 の ソート をお読みください。
「マージ (併合 : merge)」とはソート済みの複数の列を一つの列にまとめる操作のことです。配列のマージは拙作のページ Yet Another C++ Problems (2) で取り上げました。このマージを使ったソートを「マージソート (merge sort)」といいます。次の図を見てください。
9 5 3 7 6 4 2 8 最初の状態 |5 9|3 7|4 6|2 8| 長さ2の列に併合 |3 5 7 9|2 4 6 8| 長さ4の列に併合 2 3 4 5 6 7 8 9 ソート終了 図 : マージソート
マージをソートに応用する場合、最初は各要素をソート済みの配列として考えます。この状態で隣の配列とマージを行い、長さ 2 の配列を作ります。次に、この配列に対して再度マージを行い、長さ 4 の配列を作ります。このように順番にマージしていくと、最後には一つの配列にマージされソートが完了します。
それではプログラムを作りましょう。配列の長さを 1, 2, 4, 8, ... と増やしていくよりも、再帰的に考えた方が簡単です。マージは 2 つの列を一つの列にまとめる操作です。そこで、まずソートする配列を 2 つに分けて、前半部分をソートします。次に後半部分をソートして、その結果をマージすればいいわけです。
では、どうやってソートするのかというと、再帰呼び出しするのです。そうすると、どんどん配列を 2 つに割っていくことになり、最後にデータが一つとなります。それはソート済みの配列と考えることができるので、再帰呼び出しを終了してマージ処理に移ることができます。あとはデータを順番にマージしていってソートが完了します。
プログラムは次のようになります。
リスト : マージソート void msort_sub(int buff[], int low, int high, int work[]) { if (low >= high) return; int mid = low + (high - low) / 2; msort_sub(buff, low, mid, work); msort_sub(buff, mid + 1, high, work); // 前半部分を work に退避 int i = low; for (; i <= mid; i++) work[i] = buff[i]; int j = low, k = low; while (i <= high && j <= mid) { buff[k++] = work[j] <= buff[i] ? work[j++] : buff[i++]; } while (j <= mid) buff[k++] = work[j++]; } void merge_sort(int buff[], int n) { int *work = new int[n]; msort_sub(buff, 0, n - 1, work); delete[] work; }
merge_sort は作業用のメモリ領域 work を取得して、関数 msort_sub を呼び出します。work の大きさは n / 2 で十分なのですが、今回は同じ大きさのメモリ領域を取得することにします。
msort_sub の引数 low, high がソートする区間を表します。最初の if 文で要素数をチェックします。low >= high であれば区間の要素数は 1 以下なのでソートを終了します。そうでなければ、区間の中央を求めて変数 mid にセットします。それから、区間を前半 (low, mid) と後半 (mid+1, low) に分けてソートします。
次にマージ処理を行います。まず前半部分を work に退避します。次の while ループで、前半部分もしくは後半部分どちらかにデータがある間、データの比較と移動を繰り返し行います。前半部分と後半部分を先頭から順番に比較し、小さい方を区間の先頭から順番にセットしていきます。後半部分のデータが先になくなって、作業領域 work にデータが残っている場合は、最後の while ループでデータを後ろに追加します。
マージソートの実行時間は、要素数を \(N\) とすると平均して \(N \log_2 N\) に比例します。マージソートはクイックソートと同様に高速なアルゴリズムですが、実際にプログラムを作って比較してみるとクイックソートの方が高速です。しかし、マージソートは配列を単純に二分割していくため、クイックソートと違ってデータの種類によって性能が劣化することはありません。
なお、マージソートは「連結リスト (linked list)」のソートや「外部ソート」に適したアルゴリズムです。また、ソートする要素数が少ない場合は単純なソートアルゴリズムのほうが速くなります。興味のある方は拙作のページ Algorithms with Python 整列 [2], [3] をお読みください。
近代的なプログラミング言語は、ユーザーが既存のデータ型を組み合わせて、新しいデータ型を定義する機能を備えています。C言語の場合、typedef を使ってデータ型に別名を付けることができます。既存のデータ型の組み合わせは「構造体 (structure)」を使って行います。
C++はオブジェクト指向機能をサポートしているので、構造体と同様なことを「クラス」を使って行うこともできます。今回は構造体の使い方を簡単に説明します。
構造体の定義を下図に示します。
struct 名前 { データ型1 変数名1; ... データ型n 変数名n; }; 図 : 構造体の定義
struct の後ろに構造体の名前を書き、{ ... } の中に変数を定義します。C++の場合、構造体の名前はデータ型として使用することができます。また、C言語の同様に struct 名前 と宣言することもできます。もちろん、typedef で別名を付けてもかまいません。構造体の中の変数を「メンバ変数」といいます。他のプログラミング言語では、メンバ変数を「フィールド」とか「スロット」と呼ぶ場合もあります。
簡単な例を示しましょう。点の座標を格納する構造体 Point を作ります。
リスト : 点の定義 struct Point { double x, y; };
Point の先頭を英大文字にしましたが point にしてもかまいません。メンバ変数 x, y に x 座標と y 座標を格納します。
次は構造体の使い方を説明します。構造体を格納する変数は次のように宣言します。
構造体名 変数名; 構造体名 変数名 = {値1, 値2, ..., 値n};
メンバ変数の初期値を指定する場合、構造体で定義した順番で値を指定します。初期値が省略された場合、外部変数であれば値は 0 に、局所変数であれば値は不定になります。メンバ変数のアクセスは簡単です。変数名 + ドット ( . ) + メンバ名 で行います。ドットのことをメンバ選択演算子、または直接メンバ演算子といいます。
簡単な例を示しましょう。次のリストを見てください。
リスト : 構造体の使用例 (sample100.cpp) #include <iostream> #include <cmath> using namespace std; // Point の定義 struct Point { double x, y; }; // 距離を求める double distance_(Point p, Point q) { double dx = p.x - q.x; double dy = p.y - q.y; return sqrt(dx * dx + dy * dy); } int main() { Point p0; Point p1 = {0, 0}; Point p2 = {10, 10}; Point p3; p3 = p1; cout << p0.x << ", " << p0.y << endl; cout << p1.x << ", " << p1.y << endl; cout << p2.x << ", " << p2.y << endl; cout << p3.x << ", " << p3.y << endl; cout << distance_(p1, p2) << endl; }
$ clang++ sample100.cpp $ ./a.out 6.94905e-310, 6.94905e-310 0, 0 10, 10 0, 0 14.1421
点を表す構造体 Point を定義します。関数内で Point p0; と宣言すると、p0 は局所変数になるので、メンバ変数 x, y の値は不定です。点を (x, y) で表すと、変数 p1 と p2 は (0, 0), (10, 10) に初期化されます。
構造体は代入演算子で値をコピーすることができます。p3 = p1; とすると、メンバ変数 p3.x に p1.x の値が代入され、p3.y には p1.y の値が代入されます。また、Point p3 = p1; のように初期値に構造体を指定することもできます。C++は「初期化」と「代入」を厳密に区別しています。この場合、代入ではなく初期化が行われ、p1 のメンバ変数の値が p3 のメンバ変数の初期値として使用されます。
関数 distance_ は p と q の距離を求めます。distance という名前の関数が標準ライブラリに定義されているので、後ろにアンダースコア ( _ ) をつけました。関数の引数に構造体を渡すとき、配列と違って構造体はコピーされることに注意してください。コピーしたくない場合やメンバ変数の値を書き換えたい場合は参照を使用してください。
関数の返り値に構造体を指定することもできます。次の例を見てください。
リスト : 構造体を返す関数 (sample101.cpp) #include <iostream> using namespace std; struct Point { double x, y; }; Point make_point(double x, double y) { Point p = {x, y}; return p; } int main(void) { Point p1 = make_point(10, 20); cout << p1.x << ", " << p1.y << endl; }
$ clang++ sample101.cpp $ ./a.out 10, 20
関数 make_point は構造体を生成して返しますが、変数 p1 に代入するとき、構造体のコピーが行われることに注意してください。なお、構造体のサイズが大きくなると、コピーするのに時間が少々かかるようになります。少しの時間とはいえ、塵も積もれば山となるので、構造体は参照またはポインタを使って受け渡しを行うのが一般的です。
構造体は new 演算子を使って動的にメモリ領域を取得することができます。簡単な例を示しましょう。次のリストを見てください。
リスト : 構造体とポインタ (sample102.cpp) #include <iostream> #include <cmath> using namespace std; // Point の定義 struct Point { // メンバ変数 double x, y; }; // コンストラクタ Point* make_point(double x, double y) { Point* p = new Point; p->x = x; p->y = y; return p; } // 距離を求める double distance_(const Point& p1, const Point& p2) { double dx = p1.x - p2.x; double dy = p1.y - p2.y; return sqrt(dx * dx + dy * dy); } int main(void) { Point* p0 = make_point(0, 0);; Point* p1 = make_point(10, 10); Point* p2 = make_point(100, 100); cout << distance_(*p0, *p1) << endl; cout << distance_(*p2, *p0) << endl; delete p0; delete p1; delete p2; }
$ clang++ sample102.cpp $ ./a.out 14.1421 141.421
構造体へのポインタを宣言するときは構造体名の後ろに * を付けます。メンバ変数のアクセスは、ドット ( . ) ではなく、変数名 + 矢印 (->) + メンバ変数名 とします。矢印 (->) のことを「間接メンバ演算子」または「矢印演算子」といいます。
たとえば、Point* p のメンバ変数 x にアクセスする場合は p->x とします。(*p).x としてもアクセスすることはできますが、ポインタの場合は間接メンバ演算子を使うのが普通です。
構造体の実体は new を使って動的に確保することもできます。この場合、make_point のような生成関数 (コンストラクタ) [*1] を定義しておくと便利です。make_point は new で Point の実体を確保し、引数 x, y の値でメンバ変数の値を初期化し、取得した Point のアドレスを返します。返り値の型は Point* になります。
関数 distance_ の引数は参照型 Point& に変更します。これで Point をコピーしないで distance_ に渡すことができます。main では、make_point で Point を生成して、変数 p0, p1, p2 にセットします。distance_ の引数は参照型なので、p0, p1, p2 ではなく、*p0, *p1, *p2 を渡すことに注意してください。
構造体は配列に格納することもできます。宣言は次のようになります。
構造体名 配列名[大きさ]; 構造体名 配列名[大きさ] = { {値a, ...}, ..., {値z, ,,,,} }; 構造体名 配列名[] = { {値a, ...}, ..., {値z, ,,,,} };
構造体のポインタを格納する配列も定義することができます。この場合、構造体名の後ろに * を付けます。
簡単な例を示しましょう。次のリストを見てください。
リスト : 構造体と配列 (sample103.cpp) #include <iostream> #include <cmath> using namespace std; // Point の定義 struct Point { double x, y; }; // Point の生成 Point *make_point(double x, double y) { Point *p = new Point; p->x = x; p->y = y; return p; } // 距離を求める double distance_(Point& p1, Point& p2) { double dx = p1.x - p2.x; double dy = p1.y - p2.y; return sqrt(dx * dx + dy * dy); } int main() { Point p0[] = {{0, 0}, {10, 10}, {100, 100}}; Point* p1[] = { make_point(0,0), make_point(10, 10), make_point(100, 100) }; cout << distance_(p0[0], p0[1]) << endl; cout << distance_(p0[1], p0[2]) << endl; cout << distance_(*p1[0], *p1[1]) << endl; cout << distance_(*p1[1], *p1[2]) << endl; cout << p0[1].x << ", " << p0[1].y << endl; cout << p1[2]->x << ", " << p1[2]->y << endl; }
$ clang++ sample103.cpp $ ./a.out 14.1421 127.279 14.1421 127.279 10, 10 100, 100
配列 p0 は 3 個の Point を格納しています。配列 p1 は 3 個の Point へのポインタを格納します。p1 は make_point を使って初期化しています。distance_ を呼び出すとき引数は参照渡しなので、p0 の場合は p0[1] のように配列の要素 (構造体) をそのまま渡します。p1 の場合、要素の値は構造体の先頭アドレスなので、*p1[1] のように * を付けてください。メンバ変数にアクセスする場合も簡単で、p0 の場合は p0[n].x で、p1 の場合は p1[n]->x で行うことができます。
C/C++の配列は同じデータ型の要素しか格納することができません。たとえば、int a[10] と宣言すれば、この配列に格納できるのは整数 (int) だけで、浮動小数点数 (double) は格納できません。たいていの場合、これで問題はないのですが、時と場合によっては、一つの配列に異なるデータ型を混在させたいこともあります。このような場合、C/C++では「共用体 (union)」を使います。
共用体は複数のデータを同一のメモリ領域に割り当てる方法です。定義方法は構造体とよく似ています。共用体の定義を下図に示します。
union 名前 { データ型1 変数名1; ... データ型n 変数名n; }; 図 : 共用体の定義
union の後ろに共用体の名前を書き、{ ... } の中に変数を定義します。C++の場合、構造体と同様に名前はデータ型として使用することができます。また、C言語の同様に union 名前 と宣言することもできます。もちろん、typedef で別名を付けてもかまいません。
共用体は構造体と違って、メンバ変数は同一の領域(アドレス)に割り当てられます。簡単な例を示しましょう。
リスト : 共用体の使用例 (sample104.cpp) #include <iostream> using namespace std; union data { int fixnum; double fltnum; }; int main() { data n = {1}; cout << n.fixnum << endl; n.fltnum = 1.2345; cout << n.fltnum << endl; cout << n.fixnum << endl; return 0; }
int と double の共用体を考えてみましょう。構造体であれば、fixnum に 4 バイト、そのあと fltnum に 8 バイトの領域が割り当てられて、全体で 12 バイトの大きさになります。これに対し、共用体では fixnum と fltnum は同一アドレスから領域が確保されます。確保される領域は変数のなかで最大サイズの大きさ、この場合は fltnum の 8 バイトが割り当てられます。
つまり、2 つの変数領域が重なっているわけです。したがって、構造体のように変数に別々の値を代入することはできません。なお、共用体のアクセスは、構造体の場合と同じです。共用体を初期化する場合、初期値は共用体の先頭で定義したデータ型でなければいけません。
それでは実行してみましょう。
$ clang++ sample104.cpp $ ./a.out 1 1.2345 309237645
fltnum に代入した時点で fixnum の領域は上書きされるので、fixnum は破壊されていることがわかります。
このまま共用体を配列に格納すると、どのデータ型を格納しているか判別できなくなります。このため、データの種類を識別する方法が必要になります。よく使用される方法がデータに識別子 (タグ : tag) を付加することです。次の図を見てください。
一般には上図のように、データ部の先頭にタグを付加します。この方法はタグの分だけ余分なメモリを必要とすること、タグの判別に時間がかかること、などの欠点があります。現在は高速 CPU と大容量メモリを搭載しているマシンがほとんどなので、これらの欠点はあまり問題にならないでしょう。
タグを付け加えると、プログラムは次のようになります。
リスト : タグ付きデータ (sample105.cpp) #include <iostream> using namespace std; // 列挙 enum {FIX = 1, FLT}; union data { int fixnum; double fltnum; }; // データの定義 struct Data { int tag; data value; }; // 整数の生成 Data* make_fixnum(int x) { Data* d = new Data; d->tag = FIX; d->value.fixnum = x; return d; } // 浮動小数点数の生成 Data* make_fltnum(double x) { Data* d = new Data; d->tag = FLT; d->value.fltnum = x; return d; } // データの表示 void print_data(Data* d) { if (d->tag == FIX) cout << d->value.fixnum << endl; else cout << d->value.fltnum << endl; } int main() { Data* a[] = { make_fixnum(1), make_fltnum(1.234), make_fixnum(2), make_fltnum(5.678), }; for (int i = 0; i < 4; i++) print_data(a[i]); }
$ clang++ sample105.cpp $ ./a.out 1 1.234 2 5.678
構造体 Data を定義します。この中のメンバ変数 tag がタグを、value が値を表します。value のデータ型は union data なので、値は整数か浮動小数点数になります。関数 make_fixnum は整数を格納する Data を、関数 make_fltnum は浮動小数点数を格納する Data を生成して返します。タグは列挙型を使って宣言しています。
列挙型の宣言には enum を使います。
enum [タグ名] {name0, name1, name2, name3, name4, name5, ... }
C/C++の場合、enum タグ名 で列挙型を宣言します。{ ... } の中の要素を「列挙定数」とか「列挙子」と呼びます。列挙子には整数値が順番に割り当てられます。デフォルトでは先頭の name0 に 0 が割り当てられ、name1 に 1 が、name2 に 2 が割り当てられます。
列挙子の値を指定することもできます。たとえば、name3 = n とすると、name3 の値は n になり、name4 には n + 1, name5 には n + 2 が割り当てられます。なお、定数を定義するだけでよければ、タグ名を省略してもかまいません。列挙子を整数として扱うことができます。
関数 print_data は Data の値を表示します。タグ tag をチェックして、FIX ならば value.fixnum を、そうでなければ value.fltnum を表示します。main では Data へのポインタを格納する配列 a を定義して、要素を make_fixnum と make_fltnum で生成してセットします。あとは、for ループで配列の要素を取り出して、print_data で値を表示します。このように、共用体を使って一つの配列に異なるデータ型を混在させることができます。[*2]
C++の場合、名前を省略しても構造体や共用体を定義することが可能になりました。次の例を見てください。
リスト : 無名の共用体 // (1) 今までの方法 struct Data1 { int tag; union data { int fixnum; double fltnum; } value; ; // (2) タグ名を省略する struct Data2 { int tag; union { int fixnum; double fltnum; } value; }; // (3) 変数名も省略できる struct Data3 { int tag; union { int fixnum; double fltnum; }; };
(1) は今までの方法で Data を定義しなおしたものです。struct の中で union を定義しています。(2) は無名の共用体を使っています。名前を省略していることに注意してください。無名の共用体はもう一つ便利な機能があって、(3) のようにメンバ変数名も省略することができます。たとえば、Data3 d と宣言した場合、d.fixnum, d.fltnum で共用体のメンバ変数にアクセスすることができます。