第3回 - 逐次探索と二分探索、数値の並べ替え (ソート)

  1. チャレンジ課題 A. のヒント
  2. 逐次探索と二分探索
  3. 二分探索を実装する
  4. ソート (並べ替え) アルゴリズムとは
  5. ブレイクアウトルーム練習
  6. 本日の課題

雑談

年末なので、世の中を良くすることについて考える。

現代の日本で 「社会を変える」「世の中を良くする」などと言うと、 頭のおかしい人と思われるのがオチであるが…

0. チャレンジ課題 A. のヒント

関数 perm(a, n) を、以下のように定義する:

1. 逐次探索と二分探索

計算量の理論の応用例のひとつが、より効率のよい計算方法 (プログラム) の開発である。 ここでは簡単な例として、逐次探索 (線形探索) と二分探索をとりあげる。

1.1. 逐次探索とは

問題

0番目の要素からひとつひとつ調べていく、これが 逐次探索 (sequential search) である。

a = [5,9,4,0,7,3, ... ]  # n個の要素
x = 1729                 # 探す数
for i in range(n):
    if a[i] == x:
        print(f"found {i}")
演習 3-1. 逐次探索の計算量

上のコードの n に対する計算量はいくらか。Big-O 記法で答えよ。

逐次探索は、検索対象のリストが長くなればなるほど、 それに比例して実行時間も長くなる。リストの要素数が 1万倍になれば、 実行時間も1万倍になる (実際には、実行時間の期待値が 1万倍になる)。 そのため、逐次探索はときに線形探索 (linear search) とも呼ばれる。

1.2. 二分探索とは

いっぽうで、これとは別の計算方法である二分探索 (binary search) というものがある。 これは次のように動作する:

  1. まず、リストの要素がすべて昇順に並んでいるようにしておく。
  2. リストの中央の要素を見る。
  3. もしその要素が目標の要素 x より小さければ、 x はそのリストの右半分にあるということである。
    一方、もしその要素が x より大きければ、 x はそのリストの左半分にあるということである。
  4. 以後、範囲をせばめながら 2. 〜 3. をくり返す。
  5. 最終的に対象とするリストの要素が 1個になったら、それが x のはずだ。

これは、(紙の) 辞書を引くときの動作に似ている。 ある単語を見つけるのに、辞書の先頭から 1ページずつ見ていく人はいない。 ほとんどの人は辞書の単語がすべてアルファベット順に並んでいることを利用して、 アタリをつけていくのだ。二分探索はこれを一般化したものである。

たとえば a = [0, 1, 3, 4, 8, 9, 13] というリストで、 x = 3 のケースを考えてみる。 まず、リスト a の中央の要素は 4 である。

0 1 3 4 8 9 13

この時点で、リスト a の右側の要素はすべて「捜査対象外」であるとわかる。 なぜなら、リスト a の要素は昇順に並んでいるからだ。 ついでに中央の要素も対象外となるので、捜査範囲は 3つの要素となる:

0 1 3 4 8 9 13

捜査対象をこの3要素に絞ったときの中央の値は 1 である。 xはこれより大きいので、捜査範囲はこれよりは右側 (しかし最初の中心よりは左側) にあることがわかる。 するともはや対象は 1つしか残っていない。 したがって、これが x だ、ということになる:

0 1 3 4 8 9 13

二分探索を使うと、(リストの要素があらかじめ昇順に並んでいなければならない、 という制限はあるものの) n個の要素のリストに対して 平均 log2(n) 回のくり返しで発見できることになる。 これは nの値が大きければ大きいほど、スピードアップにつながる。 たとえばリストの要素数が 1万倍になったとしても、 実行回数はたかだか 14回増えるだけである (214 = 16384)。

演習 3-2. 手動で二分探索
  1. 上の例 a = [0, 1, 3, 4, 8, 9, 13] の場合について x = 13 のケースを考えてみよ。何回のくり返しで x を発見できるか?
  2. 別の例 a = [-6, 2, 5, 7, 10, 14, 25, 37, 63, 92, 108] の場合について x = 2 および x = 63 のケースを考えてみよ。 何回のくり返しで x を発見できるか? (範囲が偶数個のときは、どちらの値を中央としてもよい)

2. 二分探索を実装する

では、二分探索を実際に Python で実装することを考えてみる。 二分探索は、再帰的な関数として実装するとわかりやすい。

bsearch.py
# a: 探索する対象のリスト。
# x: 見つける値。
# i0: 探索範囲の開始位置。
# i1: 探索範囲の終了位置。
def bsearch(a, x, i0, i1):
    #print(f"a={a}, x={x}, i0={i0}, i1={i1}")
    i = (i0+i1) // 2
    if a[i] == x:
        print(f"found {i}")
    elif a[i] < x:
        bsearch(a, x, i+1, i1)
    else:  # x < a[i]
        bsearch(a, x, i0, i)
    return

# a の中から 13 を発見する。
a = [0, 1, 3, 4, 8, 9, 13]
bsearch(a, 13, 0, len(a))
# a の中から 63 を発見する。
a = [-6, 2, 5, 7, 10, 14, 25, 37, 63, 92, 108]
bsearch(a, 63, 0, len(a))

関数 bsearch() は、以下のように動作する。 まず開始位置 i0 と終了位置 i1 は、以下のように リストの各要素と要素の間を指すことに決めておく。

リスト a 0 len(a) i0 i1 x

最初は i0 = 0 および i1 = len(a) として呼び出す。 つまり、i0 から i1 で囲まれた要素の間に 値 x が存在すると仮定している。

次に i0i1 の中間位置 i を計算し、 その要素 a[i]x の値を比較する。 同じであればここで探索は終了する。

i0 i1 x i a[i]
    i = (i0+i1) // 2
    if a[i] == x:
        print(f"found {i}")

リスト a の各要素は昇順に並んでおり、 しかもすでに a[i] == x でないことが判明している。 したがって、もし a[i] < x であれば、 探索範囲は i+1i1 までの間に狭めることができる。 これが次の i0 および i1 となる。

i1 x i+1 a[i]
    elif a[i] < x:
        bsearch(a, x, i+1, i1)

一方、もし x < a[i] であれば、 探索範囲は i0i までの間に狭めることができる。 これが次の i0 および i1 となる。

i0 i
    else:  # x < a[i]
        bsearch(a, x, i0, i)

プログラムから明らかなように、二分探索は再帰的な処理である。 ある範囲における bsearch() は、 そのどちらかの半分に対して再帰的に bsearch() を呼び出す。 以後、これは範囲がただ 1つの要素に絞られるまで続く。

演習 3-3. 二分探索の実行

上のプログラム bsearch.py を実際に実行せよ。 print()関数のコメントをはずし、内部の動きを観察せよ。

要素が存在しない場合

さて、これまでリスト a の中には x が必ず含まれていると 仮定してきたが、x が含まれていなかったらどうなるだろうか? 実は探索範囲 i0 〜 i1 の間はだんだん縮まっていくので、 最後には i0 == i1 となる。こうなったら、 もはや間に要素はひとつもないので、x は存在しなかったということになる。

i0 i1

2.1. 関数 bsearch() の計算量

この関数 bsearch() の計算量を考える。 実際には、プログラムの動き方は入力によって変わるため、 厳密な計算量をいう場合には「最悪計算量」「平均計算量」などという用語を使う。 「最悪計算量」とは、文字どおりプログラムが 「もっとも運の悪い動き方をした場合」にかかる計算量のことである。 以後、この授業では計算量といえば最悪計算量のことをさす。 ここでの最悪計算量は、x が存在し、 それが最後に発見されると仮定した場合である。

関数 bsearch() が探索する範囲の大きさを n = |i1 - i0| とすると、 n に対する計算量 Bn は以下のような漸化式で求められる:

⌊n/2⌋ は床関数をあらわす。 ここで f(p) = B2n とおくと、 f(p) = p であり、Bn は事実上 2n の 逆関数 log2(n) であるので、 このプログラムの最悪計算量は O(log(n)) であるということができる。

2.2. このプログラムは必ず停止するのか?

コンピュータサイエンスの中でもっとも重要な問題のひとつが 「このプログラムはいつか必ず停止するのか?」という問題である (停止性問題)。幸運にも、 関数 bsearch() の場合は、 必ず停止することが証明できる。

def bsearch(a, x, i0, i1):
    i = (i0+i1) // 2
    ...
    elif a[i] < x:
        bsearch(a, x, i+1, i1)
    else:  # x < a[i]
        bsearch(a, x, i0, i)

具体的には、上の各ケースにおいて、探索範囲の大きさ (i1 - i0) が つねに減少する (単調減少) であることを証明すればよい。 Python における (i0+i1) // 2 は床関数 ⌊(i0+i1)/2⌋ であるので、以下が成り立つ:

i0(i0+i1) // 2 = i < i1

したがって、以下のことがいえる:

これを繰り返していくと、最終的にこの関数は以下のどちらかの状態に到達する。

  1. i0 == i1 となり、x が存在しないことがわかる。
  2. a[i] == x となり、x が発見される。
結論: 関数 bsearch() は、必ず停止する。

停止するかどうか不明なプログラム

プログラムが停止するかどうかを判断するのは、実は非常に難しい。 たとえば以下の関数 col() は 何の変哲もない Python の関数である:

def col(n):
    while 1 < n:
        print(n)
        if (n % 2) == 0:  # n が偶数の場合
           n = n // 2
        else:             # n が奇数の場合
           n = n*3 + 1
    return

col(10)col(27) を実行してみるとよい。 この関数が、すべての正の整数 n に対して停止するかどうかは 数学の未解決問題とされている (コラッツの問題)。 一般的には、プログラムの停止性問題は難しいのである。

3. ソート (並べ替え) アルゴリズムとは

二分探索では、リストのすべての要素はあらかじめ昇順に並んでいる必要があった。 ソーティング (sorting) あるいはソート (sort, 並べ替え) とは、 文字通りランダムに並んだ要素を昇順 (あるいは降順) に並べ替える 処理である。ソーティングは二分探索だけでなく多くのデータの集計処理で使われる、 コンピュータの基本的な処理のひとつである。

結論からいうと、ソーティング処理はけっこう複雑である。 要素が 10個程度のものであれば目視で容易に並べ替えが可能だが、 数万〜数億要素のデータを効率よくソートするために、 これまでいくつもの方式が提案されてきており、それぞれに一長一短がある。 本授業では中でもわかりやすい 2つの方法 (挿入法とマージソート) を紹介する。

3.1. 挿入法 (insertion sort)

挿入法のアイデアは単純で、これは人間が数字を並べ替えるときの方法に近い。 まずリストの最初から、隣り合った要素が昇順に並んでいるかどうかをチェックしていく。

5 9 4 0 7 3 1 8 i i+1

昇順になっていない (a[i+1] < a[i] であるような) 箇所を見つけたら、その数字を適切な場所に挿入する。 すでに a[i] までの数列はソートされていると仮定しているので、 この数字は左側のどこかの位置に入るはずである。(これを p としよう)

5 9 4 0 7 3 1 8 i i+1

挿入後、a[0] 〜 a[i] までの数列が昇順に並んでおり、 a[i+1] より右側の要素はまだ変化していない。 したがって i は次の値から続行する。

5 9 4 0 7 3 1 8 i i+1

i がリストの終わりに到達するまでこれを続ければ、 すべての要素が正しく並んだことになる。

演習 3-4. 手動で挿入法をやってみる

挿入法を使って、リスト [7, 3, 1, 8, 6, 2] を昇順にソートせよ。

  1. まずリストの左から見ていく。(i = 0 とする。)
  2. 昇順になっていない箇所 a[i], a[i+1] があったら...
    1. a[i+1] の要素が入る位置 p を見つける。
    2. a[i+1] を削除し、位置 p に挿入する。
  3. i を 1 増やす。

以上の方法をプログラムにすると、以下のようになる:

insort.py (... の部分をクリックすると全部表示される)
# リスト a の要素を破壊的にソートする。
def insort(a):
    for i in range(len(a)-1):
        # 隣り合った要素 a[i] と a[i+1] を比べる。
        if a[i+1] < a[i]:
            # a[i+1] のほうが a[i] よりも小さい。
            x = a[i+1]
            # したがって、x の値を、a[0]〜a[i-1] までのどこかに挿入する。
... # 適切な位置 p を発見する。 # これは現在の i より左にある要素を見ていく。 p = i while (0 < p) and (x <= a[p-1]): p = p - 1 # この時点で a[p] = x となるべきであるから # a[p]〜a[i] の要素をひとつずつ右にずらす。 q = i while p <= q: a[q+1] = a[q] q = q - 1 # 最後に a[p] を x とする。 a[p] = x
return a = [5,9,4,0,7,3,1,8] insort(a) print(a)
演習 3-5. 挿入法の実行

上のプログラムを実行し、結果を確認せよ。 print() 関数を随所に入れ、何が起きているか観察せよ。

挿入法の計算量

挿入法の最悪計算量は、たとえば [5,4,3,2,1,0] などのリストを ソートしようとしたときを想定してみるとよい。これは以下のように考える:

  1. まず、n個のリストに 1個の要素を挿入することを考える。これは
    1. 挿入すべき適切な場所を左から探していく … 最高 (n-1) 回のループ。
    2. 挿入したあとに後の要素をずらす … 最高 (n-1) 回のループ。
    という2つのステップなので、O(n) である。
  2. 最悪の場合、 a. の O(n) の過程がさらに n 回繰り返される。
結局、挿入法で n要素のリストをソートするには O(n2) の計算量が必要である。 (実際には Σn = n(n-1)/2 かもしれないが、係数を無視すると n2 となる)

3.2. マージソート (merge sort)

一方、マージソートはまったく異なる方法で並べ替えをおこなう。 まず、すでにソートされている (昇順に並んでいる) 2つのリスト pq を一本の昇順な列に統合する関数 merge(p, q) を考える。

p q 0 4 5 9 1 3 7 8 0 1 3 4 5 7 8 9

このような関数は以下のようにして作ることができる。 まず目印となる変数 ij を用意し、 これを右へ右へと動かしていく。 p[i] の値と q[j] の値を比較し、 小さいほうを結果となるリストに足していけばよい。

p q 0 4 5 9 1 3 7 8 i j
def merge(p, q):
    i = 0
    j = 0
    r = []
    while i < len(p) and j < len(q):
        # p[i] と q[j] を比較し、
        # 小さいほうを r に加える。
        # その後、i または j をひとつ進める。
        ...
    return r

リスト pq がともに n要素あったとすると、 関数 merge() 内のループ繰り返しはたかだか 2n回である。 つまり計算量は O(n) である。

merge() ができたら、それを使ってマージソートをおこなう。 これは再帰的なプログラムとして定義される。

  1. まず、最初の 8個の要素を二等分し、それぞれに対してマージソートを開始する。
    5 9 4 0 7 3 1 8
  2. 各4個のリストはさらに二等分され、それぞれに対してマージソートが開始される。
    5 9 4 0 7 3 1 8
  3. さらに二等分する。
    5 9 4 0 7 3 1 8
  4. ここで、要素が1つだけのリストはすでにソートされている。 あとはこれを merge() して一本に統一すればよい。
    5 9 0 4 3 7 1 8
  5. 各2要素のソートされたリストはさらに merge() され、 4要素ずつのソートされたリストとなる。
    0 4 5 9 1 3 7 8
  6. 最終的に 4要素のリストが merge() され、マージソートが完成する。
    0 1 3 4 5 7 8 9

以上の処理を実際の関数 mergesort() として実装すると、 おおむね以下のようになる。 (注意: 挿入法の関数 insort() は与えられたリストを破壊的に 変更してソートしたのに対して、mergesort() は 新しいリストを作成して返す、いわゆる「普通の関数」になっている。)

def mergesort(a):
    n = len(a)
    if n == 1:
        # 長さ 1 のリストはすでにソートされている。
        return a
    else:
        # 最初の半分を切り取ってソートする。
        p = mergesort(a[:n//2])
        # 残りの半分を切り取ってソートする。
        q = mergesort(a[n//2:])
        # 結果を統合し、一本のソートされたリストを返す。
        return merge(p, q)

マージソートの計算量

関数 mergesort() に与えられる要素の数は、 呼び出しごとに半分ずつになっていくため、もともとのリストの各要素に対して mergesort() は約 O(log(n)) 回呼び出されることになる。 この中で O(n) の計算量をもつ関数 merge() を実行するので、 したがって、全体の計算量をあわせると O(n・log(n)) ということになる。 これは、挿入法の計算量よりもオーダーが少なく、n の値が大きいときに より効率的であるといえる。

挿入法と違い、マージソートはどのようなリストに対しても同じ回数で処理するので 必ず O(n・log(n)) の時間がかかり、最悪計算量と平均計算量の差が存在しない。 いっぽう関数 merge() は毎回、新しいリストを作成しなければならないため、 これは n要素の入力に対して O(n) 要素分の追加メモリ容量を必要とする。 挿入法では、追加のメモリ容量は必要とされない。

まとめると、どちらの方式も一長一短があることがわかる:

4. ブレイクアウトルーム演習

ブレイクアウトルーム演習の方法:

  1. ブレイクアウトルーム中はカメラを使うこと。
  2. まず自己紹介をする。名前・所属・趣味などを簡単に説明する。
  3. 最初の問題をやる担当者が PC の画面共有をおこない、課題のプログラムを考えながら書く。 このとき周囲の人は手助けする。
  4. 終わったら、次の担当者が次の問題をやる。これを繰り返す。
  5. 全部終わったら、適当に雑談する (← これが本当の目的)。
演習 3-6. bsearch に「not found」を追加する

bsearch.py における bsearch() 関数は、x がリスト中に 含まれていない場合を想定していないため、以下のようなコードではエラーが発生する:

bsearch.py
def bsearch(a, x, i0, i1):
    ...

# a の中から 5 を発見する。
a = [0, 1, 3, 4, 8, 9, 13]
bsearch(a, 5, 0, len(a))

要素が存在しない場合の説明を参考に、 bsearch() 関数に if 文を追加し、 要素が存在しなかった場合には "not found" と表示して終了するようにせよ。

演習 3-7. 挿入法の最悪計算量を測定する

insort.py で、 リスト [5, 4, 3, 2, 1, 0] をソートしたときに、 内側の a[q+1] = a[q] は合計何回実行されるか? これを数えるようにプログラムを改良し、その値を表示せよ。

$ python insort.py
15
[0, 1, 2, 3, 4, 5]

5. 本日の課題

小課題 3. 挿入法の改良 (12月27日締切)

以下は 3.1. で説明した挿入法のソートプログラムの再掲である:

# insort.py

def insort(a):
    for i in range(len(a)-1):
        # 隣り合った要素 a[i] と a[i+1] を比べる。
        if a[i+1] < a[i]:
            # a[i+1] のほうが a[i] よりも小さい。
            x = a[i+1]
            # したがって、x の値を、a[0]〜a[i-1] までのどこかに挿入する。
            # 適切な位置 p を発見する。
            # これは現在の i より左にある要素を見ていく。
            p = i
            while (0 < p) and (x <= a[p-1]):
                p = p - 1
            # この時点で a[p] = x となるべきであるから
            # a[p]〜a[i] の要素をひとつずつ右にずらす。
            q = i
            while p <= q:
                a[q+1] = a[q]
                q = q - 1
            # 最後に a[p] を x とする。
            a[p] = x
    return

# コマンド引数をすべて整数のリストにする。
import sys
a = []
for i in range(len(sys.argv)-1):
    v = int(sys.argv[i+1])
    a = a + [v]
# ソートを実行し、結果を表示する。
insort(a)
print(a)

このプログラムには実は無駄な部分がある。途中に 2つの while文があるが、 本来、「適切な挿入位置 p の発見」と「要素をずらす処理」は、 ひとつの while文中で同時におこなうことができるはずである。 このプログラムを while文をひとつだけ使ったバージョンに 改良して提出せよ。(外側の for文はそのままでよい。) ソートする数をコマンド引数から指定できるようにすること。

実行例:

$ python insort.py 5 9 4 0 7 3
[0, 3, 4, 5, 7, 9]
中課題 1. マージソートの実装 (2022年1月10日締切)

授業中に説明したマージソートをおこなうプログラムを完成させ、提出せよ。 insort.py と同じく、このプログラムもリストの内容を コマンド引数で指定できるようにすること。 さらに、各関数の適切な場所にコメントを入れ、 以下のことを説明すること。

注意: 関数 merge(p, q) を書くとき、 リスト p と q が同じ長さとは限らないので注意すること (例: [0,2,4][1]) など。

実行例:

$ python mergesort.py 5 9 4 0 7 3 1 8 6 2
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

提出するプログラムは以下のような構成になっていることが望ましい:

# mergesort.py
import sys

# merge(p, q): あらかじめソートされたリスト p と q をまとめる。
def merge(p, q):
    ...

# mergesort(a): リスト a をマージソートしたものを返す。
def mergesort(a):
    ...

# コマンド引数をすべてリストにする。
a = []
for i in range(len(sys.argv)-1):
    v = int(sys.argv[i+1])
    a = a + [v]
# ソートを実行し、結果を表示する。
print(mergesort(a))

Yusuke Shinyama