Pythonプログラミング(ステップ8・ソーティング)
リストの応用として、データの整列(ソーティング)について、すでに取り上げた。 データの件数が多くなると、整列により時間がかかるのは当然であるが、問題は、その遅くなり方である。 ここでは、データ件数が増えても実用的な、ふたつのアルゴリズムを、再帰的な関数の応用例として、紹介する。
1.作業を小分けにしてゆく:クイックソート
ソーティングの中で、一番簡単な問題は、要素がひとつだけのリストの並べ替えで、リスト
次に簡単なのは、
ここで、
次に、あるリスト
リストを大小ふたつの部分リストに分割するには、ある参照値を決めて、それより小さいグループと大きいグループに選別すればよい。 この参照値はピボット(pivot)と呼ばれている。
ピボットの選び方は大切である。 なぜなら、もし、リスト中の最大値以上の数や最小値以下の数を選んでしまうと、片側のリストが「空」になってしまう場合が生じる。 つまり、分割したはずなのに、分割されていない、という状況が生まれてしまうからである。 実用上よく使われるのは、リストの中からいくつか要素をピックアップして、その平均値をピボットとする、という方法である。 そうすれば、よほど運が悪く無い限り、ピボットが極端な値となることは無いはずだ。
こうして、長いリスト
Pythonにはクィックソートでリストをソートする関数やメソッドがあらかじめ組み込まれている。 通常のプログラミングでは、それらを呼び出せば事足りる場合がほとんどである。
このように、リストを、要素の値の大小によって2つのグループに分割する操作を繰り返し、全体を整列させるアルゴリズムはクイックソート(quicksort)と呼ばれ、その名のとおり、高速な整列アルゴリズムの定番である。
要素数
ただし、上記は分割が順調に行われた場合で、長さ
アルゴリズムの検証
以上のアイデアをリストを使ってそのまま表現してみたPythonコードの例を以下に示す。
ピボットの値は、リストの要素を最初から試しつつ、それより小さな値のデータをリストxsm
、値の大きなデータをリストxlg
に加えて、
それぞれのリストについてソーティングした結果を連結した上で、関数の値として返すようにしている。
リストの長さが1の場合は、並べ替える必要がもう無いので、リストのそのまま返す。
また、リストがふたつに分割できなかった場合は、ピボットとなる要素を順に変えながら試すようになっている。
メモリ効率の悪いクィックソート
def quick_sort(x): n = len(x) if n<=1: return x ip=0 while ip<n: pv = x[ip] xsm=[ ] xlg=[ ] for i in range(n): if x[i] <= pv: xsm.append(x[i]) else: xlg.append(x[i]) if len(xsm)>0 and len(xlg)>0: return quick_sort(xsm) + quick_sort(xlg) ip=ip+1 return x # メイン部 X=[0.3, 1.3, 10.2, 3.3, 9.3, 7.9, 2.1, 0.1, 4.2, 8.3, 5.9, 7.7, 8.1, 2.8] Xsorted = quick_sort(X) print(Xsorted)
このコードは正しく動作はするものの、関数呼び出しの都度リストを生成しながら処理を行うために、メモリ効率の点ではなはだ無駄が多い。 そこで、一旦確保済みのデータの配列領域の内部(in-place:インプレース)で処理を進めるように工夫すると良い。
インプレースなリストの分割の具体的な手順
前節で述べたとおり、リストの分割を行なう際、データの臨時置き場が必要となるようではメモリー効率が悪いので、リストの前半部分にはピボット以下の要素が、後半にはピボットより大きな要素は配されるよう、「リスト上で」並べ替えを行なう。そのとき、前半部と後半部それぞれの中では、データの大小関係はでたらめで構わない。
そこで、下図のように、リストの先頭から後方に向かって「動く」変数
リストの中(in-place)で、値の大小に応じて、ふたつのグループに仕分けを進める例。 この場合のピボットは5。 二つの変数を使って「挟み撃ち」にしている。
クィックソートのコーディングの例
以上のアイデアを元に、リストx[]
のfirst
番目からlast
番目までの区間をソートする関数をPythonでコーディングした例を示す:
右の例では、まず最初の要素をピボットに設定してみて、分割後、片方が空になっていたら(つまり、分割されていなければ)、 2番目の要素をピボットにし直して、再度分割を試みる・・・、を繰り返すようにプログラムされている。 分割できなければ、全ての要素が同じ値ということなので、ソーティングをそこで止めにする。
def quick_sort(first, last, x): if first==last: return ip=first while ip<=last: pv=x[ip] i=first j=last while True: while i<=last and x[i]<=pv: i=i+1 while j>=first and x[j]>pv: j=j-1 if i<j: x[j],x[i] = x[i],x[j] else: break if j<last: quick_sort(first,j,x) quick_sort(i,last,x) return ip=ip+1 return
練習:クイックソート
上記の関数の設計を参考に、クイックソートで昇順にリストを整列するプログラムを完成させ、動作を確認しなさい。
さらに、上記のコードを手直しして、クィックソートで降順に整列するプログラムを作成しなさい。
練習:大きなデータの統計量
ある調査で得た100万件のデータを、ファイルsample-big-array.txtとして用意した。 このデータは、正の整数値が各行1つずつ、100万行分記されたテキストファイルである。 このデータを読みこんで、中央値、最頻値、平均、分散を求めるコードを作成してみなさい。
ヒント
上記のクィックソートのコードをそのまま使ってサンプルデータをソートしようとすると、かなりの時間を要するはずである。 その理由について考察し、さらなる改良の余地がないかを検討するとよい(例:ピボットの選択方法)。
2.ボトムアップなアプローチ:マージソート
すでに整列済みの2つのリスト
具体的には、リスト
並べ替えたいデータが、予め、小さな破片に分割されていたとする。すると、上記の手続きを、全体が1つのリストに併合されるまで繰り返して適用すると、最終的に得られるリストの内容は、きちんと整列されているはずである。 その様子を図に示す:
このように、分割と併合を組み合わせてデータを整列するアルゴリズムは併合ソート(マージソート: merge sort)と呼ばれ、高速で性質が良いことが知られており、実用上も重要である。
クイックソートがトップダウン的なアプローチとすれば、マージソートは、ボトムアップ的な手法と言うことができるだろう。
ここでは、マージソートによってリストを並べ替える関数 merge_sort()
を設計してみよう:
関数 merge_sort
(リスト
もし
2つの部分区間 merge_sort()
を行なう
整列済みとなった2つの部分区間
これをPythonの関数としてコーディングした例を示す:
def merge_sort(first, last, x, w): if first==last: return m = (first + last)//2 merge_sort(first,m,x,w) merge_sort(m+1,last,x,w) perform_merge(first, m, m+1, last, x, w)
特別な工夫を凝らさない限り、マージソートでは併合の際に作業用の記憶領域が必要となる。
ここで、リスト w[ ]
は、併合の際のデータの「仮置き場」である。
関数の中で、分割した2つの区間の併合を行なうために、さらに別の関数perform_merge()
を呼び出している。
そちらをコーディングした例を以下に示す:
def perform_merge(i1, i2, j1, j2, x, w): k = i1 i=i1 j=j1 while True: if i<=i2 and (j>j2 or x[i] <= x[j]): w[k] = x[i] i=i+1 elif j<=j2 and (i>i2 or x[i] > x[j]): w[k] = x[j] j=j+1 else: break k=k+1 for k in range(i1,j2+1,1): x[k] = w[k]
練習:マージソート
上記の関数の設計を参考に、マージソートで昇順にリストを整列するPythonプログラムを完成させ、動作を確認しなさい。
さらに、上記のコードを手直しして、マージソートで降順に整列するプログラムを作成しなさい。
3.分割統治アルゴリズム
このページに登場したふたつのアルゴリズム(クイックソートとマージソート)は、いずれも、問題をより規模の小さな問題に分割し、 それぞれの結果を統合して大きな問題を解く、というアプローチで組み立てられている。 このようなアルゴリズムは分割統治アルゴリズム(divide and conquer algorithms) と呼ばれ、ソーティングに限らず、いくつもの実用的で重要な応用例が知られている。
大きな問題が与えられた際、それを小さな問題に分解してみてはどうか、とは誰でも思いつくだろうが、分割統治が上手く働くためには、問題を分割するだけでは不十分で、分割によって生じた部分同士がお互いに依存し合わないようになっていなければならない。 例えば、クィックソートの過程で、生成した部分列内の整列はその部分列の中だけで関係しており、別の部分列の状態は全く処理に関係しない。 マージソートでも、併合は二つの部分列の間だけで行なわれる。 加えて、問題の大小に関わらず、必要な手続きが全く同様(あるいは、「相似」)であれば、以下のように、再帰によって簡潔にそのアルゴリズムを記述できる:
データ
1: 関数
2:
3: そうでなければ:
4:
5:
6:
によって再帰的に定義できる。
練習:分割統治アルゴリズムとしてのソーティング
このページで示したクィックソートとマージソートの関数の例を読み、上記の分割統治の形式的な関数定義との対応関係(Pythonのコードのどの箇所が、形式的な定義の何行目と対応しているか、および、