プリント基板設計・シミュレーション

TOP > アポロレポート > コラム > 第52回 プログラミングについて『ソートを考える その2』
コラム
2023/11/28

第52回 プログラミングについて『ソートを考える その2』

アポロレポート

  今回は有名な高速ソートについて考えてみます。

 まずヒープソートというアルゴリズムを見てみましょう。実際の動作を考える前にヒープ木を理解した方がいいと思います。ヒープ木とは

               1
             ┌─┴─┐
             2   3
            ┌┴┐ ┌┴┐
            4 5 6 7
           ┌┴┐
           8 9

といった感じの木です。つまり木の根が一番小さい値で、その葉には次に小さい値が伸びるような構造になります。問題はこの木の構造をどのように配列内で実現するかということなのですが、1つの根と葉の構造を考えてみると要素の数は3つになります。配列の要素番号が1から始まるとすると、この木の根の要素番号がN番目のときに、葉をそれぞれN*2とN*2+1となる場所に葉があるとします。

 まずこの関係を根が最大になるように葉を並べ替えます。このとき木の末端の1つの木から計算を行ないます。この例の場合は最大数は9ですので9/2=4が末端の親になります。また葉どうしの大小はどちらでも構いません。

 382761549  親の値7を記憶しておく。
    ↑   ↑↑
    親   子子

 382961549  2つ子のうち大きい方を親に入れ、その子を親とする。
         ↑  このときこれ以上子がないので、記憶しておいた値を
         親  親に代入する。

 382961547  次に1つ前の木について同じことを行なう。
   ↑  ↑↑    親の値2を記憶しておく。
   親  子子

 385961547  2つ子のうち大きい方を親に入れ、その子を親とする。
       ↑    このときこれ以上子がないので、記憶しておいた値を
       親    親に代入する。

 385961247  次に1つ前の木について同じことを行なう。
  ↑ ↑↑      親の値8を記憶しておく。
  親 子子

 395961247  このときどちらの子も記憶してある値8より小さいので、
    ↑   ↑↑  親に記憶してある値8を代入し、これ以上の子の計算は
    親   子子  行なわない。

 395861247  次に1つ前の木について同じことを行なう。
 ↑↑↑        親の値3を記憶しておく。
 親子子

 995861247  2つ子のうち大きい方を親に入れ、その子を親とする。
  ↑ ↑↑
  親 子子

 985861247  2つ子のうち大きい方を親に入れ、その子を親とする。
    ↑   ↑↑
    親   子子

 985761247  2つ子のうち大きい方を親に入れ、その子を親とする。
         ↑  このときこれ以上子がないので、親に記憶しておいた値3
         親  を代入する。

 985761243

これで降順のヒープ木ができあがります。この結果を分かりやすく見てみると、

               9
             ┌─┴─┐
             8   5
            ┌┴┐ ┌┴┐
            7 6 1 2
           ┌┴┐
           4 3

もう少し分かりやすくすると、

               9
             ┌─┴─┐
             5   8
            ┌┴┐ ┌┴┐
            1 2 6 7
               ┌┴┐
               4 3

となり、木の根からみればどの葉も親よりも小さくなります。

このようにしてできたヒープ木の根が最大値になっているので、この値と配列の最後の値を交換し配列の数を1つ減らします。

               3
             ┌─┴─┐
             8   5
            ┌┴┐ ┌┴┐
            7 6 1 2
           ┌┴┐
           4 9

というヒープ木にし、要素が1つ減った配列とします。

               3
             ┌─┴─┐
             8   5
            ┌┴┐ ┌┴┐
            7 6 1 2
            │
            4

このとき、ヒープ木の根以外の関係は既に完成していますので、今度はヒープ木の根から子の関係を計算し直します。やり方は上の方法と同じです。

 38576124  9
 ↑↑↑
 親子子

 88576124  9
  ↑ ↑↑
  親 子子

 87576124  9
    ↑  ↑↑
    親  子子

 87546124  9
        ↑
        親

 87546123  9

これで再びヒープ木ができましたので、配列の先頭と最後を交換し、同じ事を繰返します。以下特に説明はしませんが経過のみを書きます。

 3754612  89
 ↑↑↑
 親子子

 7754612  89
  ↑ ↑↑
  親 子子

 7654612  89
     ↑
     親

 7654312  89

 265431  789
 ↑↑↑
 親子子

 665431  789
  ↑ ↑↑
  親 子子

 645431  789
    ↑
    親

 645231  789

 14523  6789
 ↑↑↑
 親子子

 54523  6789
   ↑
   親

 54123  6789

 3412  56789
 ↑↑↑
 親子子

 4412  56789
  ↑ ↑
  親 子

 4312  56789

 231  456789
 ↑↑↑
 親子子

 331  456789
  ↑
  親

 321  456789

 12  3456789
 ↑↑
 親子

 22  3456789
  ↑
  親

 21  3456789

 1  23456789

   123456789

というようになります。

 この一連の動作を見てみると、非常に巧妙なアルゴリズムであることが理解できると思います。配列の中にヒープ木の構造を仮想的に作成する方法(Nが親なら、N*2とN*2+1が子の関係)、最初にヒープ木を作成するときに末端の木から行なうなどなかなか思い付かないものです。さてこれをプログラムにしてみましょう。

 sort(int *data,int ldata)
 {
  int i,j,k,t;

   data--;
  ldata++;

  for(k=ldata/2;k>=1;k--){
   i=k;
   t=data[k];

   while((j=i*2)<=ldata){
    if(j<ldata && data[j]<data[j+1])j++;
    if(t>=data[j])break;
    data[i]=data[j];
    i=j;
   }

   data[i]=t;
  }

  while(ldata>1){
   t     =data[ldata];
   data[ldata]=data[1  ];

   ldata--;

   i=1;
   while((j=i*2)<=ldata){
    if(j<ldata && data[j]<data[j+1])j++;
    if(t>=data[j])break;
    data[i]=data[j];
    i=j;
   }

   data[i]=t;
  }
 }

今回の関数の約束事が配列の要素数の引数は要素数ー1ですので、関数の最初に配列のアドレスを1要素分だけ負に移動し、要素数を1つ増やしています。こうすると関数内では配列の添字が1からとすることができ、このアルゴリズムをスッキリ表現することができます。

 次にシェルソートというアルゴリズムを見てみましょう。このアルゴリズムはソートの初期におおざっぱにソートをし、だんだんと正確にソートを行なっていこうとするものです。言い換えると、最初は大きくジャンプした方がいい要素をだいたいの位置に置き、だんだんとあるべき位置に収束させようというものです。

次の例の要素数が9ですので、比較する要素の間隔をその半分の4とします。

 382761549
 ↑   ↑
 382761549
  ↑   ↑    交換
 312768549
   ↑   ↑
 312768549
    ↑   ↑  交換
 312468579
     ↑   ↑
 312468579

次に比較間隔を半分の2にします。

 312468579
 ↑ ↑       交換
 213468579
  ↑ ↑
 213468579
   ↑ ↑
 213468579
    ↑ ↑
 213468579
     ↑ ↑   交換。
           交換したときには交換がなくなるまで前の要素と比較する。
 213458679
   ↑ ↑     前の要素との比較。
 213468579
      ↑ ↑
 213467589
    ↑ ↑
 213468579
       ↑ ↑
 213468579

次に比較間隔を半分の1にし、同様に行ないます。

 213468579
 ↑↑
 123468579
  ↑↑
 123468579
   ↑↑
 123468579
    ↑↑
 123468579
     ↑↑
 123468579
      ↑↑
 123465879
     ↑↑
 123456879
    ↑↑
 123456879
       ↑↑
 123456789
      ↑↑
 123456789
        ↑↑
 123456789

これでソートが終了です。非常に単純なアルゴリズムですがこれも嬉しいくらいに高速です。この例では比較の間隔縮小率を0.5としましたが、この間隔縮小率の違いでソート時間が変ります。

これをプログラムにしてみましょう。

 ssort(data,ldata)
 int data[];
 int ldata;
 {
  int i,j,t,gap;

  gap=(ldata+1)/2;
  while(gap){
   i=gap;
   while(i<=ldata){
    j=i-gap;
    while(j>=0 && data[j]>data[j+gap]){
     t     =data[j  ];
     data[j  ]=data[j+gap];
     data[j+gap]=t     ;
     j-=gap;
    }
    i++;
   }
   gap/=2;
  }
 }

以外に簡単なプログラムになってしまいました。

 最後に最も有名はクイックソートを考えてみましょう。このアルゴリズムは、ある値よりも小さいグループと大きいグループに分け、順次それぞれのグループを小さいものと大きいものとに分けてそれ以上に分けることができなくなったときにソートが完成するというものです。通常ある値は配列の中央の要素の値を使用します。

 まず動作を見てみましょう。配列の要素の数が9ですのでその半分の位置は(9ー1
)/2=4となり値は7です。

 382761549  4番目の要素と最初の要素を交換します。
 ↑  ↑
 782361549  8は7より大きいのでそのまま。
 ↑↑
 782361549  2は7以下なので、7の次の要素と交換します。
 ↑ ↑
 728361549  3は7以下なので、2の次の要素と交換します。
  ↑ ↑
 723861549  6は7以下なので、3の次の要素と交換します。
   ↑ ↑
 723681549  1は7以下なので、6の次の要素と交換します。
    ↑ ↑
 723618549  5は7以下なので、1の次の要素と交換します。
     ↑ ↑
 723615849  4は7以下なので、5の次の要素と交換します。
      ↑ ↑
 723615489  9は7より大きいのでそのまま。
       ↑ ↑
 723615489  最後に先頭と4を交換。
 ↑     ↑

 423615 7 89

これで2つのグループができました。次に7以下のグループを2つに分けます。このグループの要素数は6ですので中央の位置は(6ー1)/2=2で値は3です。以下同様ですので説明は省略します。

 423615 7 89
 ↑ ↑
 324615 7 89
 ↑↑
 324615 7 89
  ↑↑
 324615 7 89
  ↑ ↑
 324615 7 89
  ↑  ↑
 321645 7 89
   ↑  ↑

 321645 7 89
 ↑ ↑

 123645 7 89

 12 3 645 7 89
      ↑↑
 12 3 465 7 89
      ↑↑
 12 3 465 7 89
      ↑ ↑

 12 3 465 7 89

 12 3 4 65 7 89
        ↑
 12 3 4 65 7 89
        ↑↑
 12 3 4 65 7 89
         ↑
 12 3 4 65 7 89
        ↑↑
 12 3 4 5 6 7 89

となります。部分配列を再び同様にソートするのでプログラムを作成するには工夫が必要になります。B.W.カーニハンとD.M.リッチーの書いた『プログラミング言語C』にクイックソートがでており、かなりスッキリとまとめてあるので紹介します(今回の例の関数の形式に合わせて少し書き直しています)。

 qsort(data,ldata)
 int *data;
 int ldata;
 {
  qsort_task(data,0,ldata);
 }

 qsort_task(data,left,right)
 int *data;
 int left,right;
 {
  int i,last,t;

  if(left<right){
   t          =data[ left     ];
   data[ left     ]=data[(left+right)/2];
   data[(left+right)/2]=t;

   last=left;

   for(i=left+1;i<=right;i++){
    if(data[left]>data[i]){
     last++;

     t     =data[last];
     data[last]=data[i  ];
     data[i  ]=t;
    }
   }

   t     =data[last];
   data[last]=data[left];
   data[left]=t;

   qsort_task(data,left ,last-1);
   qsort_task(data,last+1,right );
  }
 }

 実は上の動作の説明はこの関数を基にしているのですが、再帰呼び出しを使用してスマートにまとめているのには感心させられます。もし再帰呼び出しを行なわずにクイックソートを行なおうとすれば別に配列を用意する必要があるので結構面倒になります。厳密に考えれば再帰呼び出しを行なうということでそれに相当する(以上)のメモリーを消費しています。またスタックに余裕がないときには配列の要素数が大きくなると動作しないこともありますが、何にしてもUNIX上での動作を基本にした書籍ですのでこのように記述している訳です。

 どのソートが最も高速なのかはここでは論議はしませんが、私の好みではシェルソートです。アルゴリズムもプログラムも単純で余分なメモリーも消費しないのが大きな理由です。

 ところで、前回説明したソートと今回説明したソートと比較すると、特殊な条件を除けば今回のものの方が遥かに高速に動作するのですが、一体どうしてこの差が出てしまうのでしょうか。ここがソートのアルゴリズムを考えるときの大きな注目点になります。

 前回のソートは、配列の最初の要素から順に決定していくのに対し、今回のものは全体が徐々に決定されていくところにあります。言い換えれば、ソートの初期には大きく移動すべきものはだいたいの場所に一気に移動させ、徐々に本来の位置に収束させていくやり方をとっているのです。芋虫が這い回るように隣同士と交換していくよりは、バッタが飛び跳ねるように目標のそばに跳んでいく方が効率が良くなる訳です。

 それではまた次回。


そのお悩み、
アポロ技研に話してみませんか?

アポロ技研でワンランク上の物創りへ!
そのお悩み、アポロ技研に話してみませんか?