コンテンツにスキップ

第6回:ソート(1)バブルソート・選択ソート

5枚程度のカードは手で並べ替えられるが、データが大きくなると手順を決めて機械的に並べる必要がある。今回は最も基本的な2つのソート(バブルソート・選択ソート)を扱う。

昇順・降順

  • 昇順: 後ろにいくにつれて昇っていく順。小 → 大。
  • 降順: 後ろにいくにつれて降りていく順。大 → 小。

演習6-1

カード [5, 3, 1, 4, 2] を昇順に並べ替える手順を、他人に伝わるように書き出す。言葉でも、矢印つきの図でもよい。

そもそもソートとは

ソート: データをある順序で並べる操作。多くの場合、昇順または降順に並べる。

なぜ重要か:

  • 二分探索が使える(第5回)
  • 重複・中央値・上位N件などの処理が速くなる
  • 後の回(ツリー・ヒープ)の前提にもなる

コンピュータの「目」

人間は5枚のカードを一目で見渡して、最小値を瞬時に見つけられる。しかしコンピュータはそうではない。

  • コンピュータは基本的に2つの値を比べることしかできない(2項比較)
  • 「最小を選ぶ」も、実は1ステップではなく、全体を1回なめる O(n) の小さなアルゴリズム
def my_min(A):
    best = A[0]
    for x in A[1:]:
        if x < best:      # 2項比較を n-1 回
            best = x
    return best

アルゴリズムを学ぶ = 中身を見える化する

  • 第1回: アルゴリズム = 基本動作(順次・分岐・反復)の組み立て
  • 第2回: 計算量 = 基本動作の回数

速さを測るには、min()sorted() のような便利関数の中身を開いて「2項比較」レベルまで降りる必要がある。便利関数を使うのと、中身を組み立てるのは別の話。今回からしばらく、その「組み立てる」側を学ぶ。

演習6-2

裏返しの [5, 3, 1, 4, 2] を昇順に並べ替える手順を、他人に伝わるように書き出す。ルール:

  • 1度に2枚しかめくれない
  • 確認したら裏返す
  • [2, 1, 4, 3, 5] のような別パターンでも使える手順にする
  • 何回めくったか(= 何回比較したか)をカウントする

バブルソート

直感

  • 裏返しのカードを隣同士でめくって比べる
  • 左が大きければ入れ替えて、また裏返す
  • これを左から右へ繰り返す
  • 1巡終わると、一番大きいカードが末尾に着く(大きい値が泡=バブルのように末尾へ浮かぶ)

概念コード(疑似コード)

for i in 0 .. n-1:
    for j in 0 .. n-i-2:
        if A[j] > A[j+1]:
            swap A[j], A[j+1]
  • 外側ループ: n-1 巡
  • 内側ループ: 隣接ペアを左から右へ見ていく
  • 毎巡、末尾の i 枚は確定済みなので、内側の範囲は -i だけ短くなる

Pythonコード

def bubble_sort(A):
    n = len(A)
    for i in range(n - 1):                 # 外側:n-1 巡
        for j in range(n - 1 - i):         # 内側:確定済みの末尾 i 枚は見ない
            if A[j] > A[j + 1]:            # 隣を比較
                A[j], A[j + 1] = A[j + 1], A[j]   # 大きい方を後ろへ
    return A

概念コードとの対応

概念コード Pythonコード 意味
for i in 0 .. n-1 for i in range(n - 1) 巡を n-1 回繰り返す
for j in 0 .. n-i-2 for j in range(n - 1 - i) 確定済みの末尾 i 枚を除いて走査
if A[j] > A[j+1] if A[j] > A[j + 1] 隣同士を2項比較
swap A[j], A[j+1] A[j], A[j+1] = A[j+1], A[j] Pythonは多重代入で一発交換できる

Python の swap

A[j], A[j+1] = A[j+1], A[j] は、右辺がタプルとして先に評価されてから左辺に代入されるため、一時変数なしで2つの値を入れ替えられる。

1巡目の具体例

[5, 3, 1, 4, 2] の1巡目:

5,3 をめくる → 入替 → [3, 5, 1, 4, 2]
5,1 をめくる → 入替 → [3, 1, 5, 4, 2]
5,4 をめくる → 入替 → [3, 1, 4, 5, 2]
5,2 をめくる → 入替 → [3, 1, 4, 2, 5]

1巡目終了:末尾の 5 が浮かびきった。

演習6-3

[6, 3, 8, 1, 5] にバブルソート(昇順)を適用したとき、配列の変化を巡ごとに、処理が完了するまで書きなさい。

例: 1巡後 [3, 6, 1, 5, 8]

バブルソートの計算量

n = 5 のとき、各巡の比較回数は 4 + 3 + 2 + 1 = 10 回。一般に最悪 \(n(n-1)/2 \approx n^2/2\) 回。

O記法では係数を無視するので O(n²)

n 比較回数
10 50
100 5,000
1,000 500,000
10,000 50,000,000

データが10倍になると、時間は約100倍になる。

早期終了

ある巡で1回も入れ替えなかったら、もう整列済みなので打ち切ってよい。

def bubble_sort(A):
    n = len(A)
    for i in range(n - 1):
        swapped = False                    # この巡で交換したか
        for j in range(n - 1 - i):
            if A[j] > A[j + 1]:
                A[j], A[j + 1] = A[j + 1], A[j]
                swapped = True
        if not swapped:                    # 交換が1回もなければ整列済み
            break
    return A
  • 既に整列済みのデータなら O(n) で終わる(最良ケース)
  • ただし最悪ケースは変わらず O(n²)

選択ソート

バブルソートは「隣同士を比べて大きい値を後ろへ送る」方式だった。別の自然な発想がある。

直感

  • 未ソート部分のカードを全部めくって、最小を覚えておく
  • 最小のカードと、未ソート部分の先頭を入れ替える
  • これを n-1 回繰り返す(「最小を見つけて手前に置く」の繰り返し)

概念コード(疑似コード)

for i in 0 .. n-1:
    min_idx = i
    for j in i+1 .. n-1:
        if A[j] < A[min_idx]:
            min_idx = j
    swap A[i], A[min_idx]
  • 外側: n-1 回(先頭から順に確定していく)
  • 内側: 未ソート部分を全部見て最小を探す(= my_min の中身そのもの)
  • 毎巡、先頭の i 枚が確定する

Pythonコード

def selection_sort(A):
    n = len(A)
    for i in range(n - 1):                 # 外側:先頭を順に確定
        min_idx = i                        # とりあえず先頭を最小候補に
        for j in range(i + 1, n):          # 残りを全部見る
            if A[j] < A[min_idx]:          # より小さいものが見つかれば
                min_idx = j                # 最小候補を更新
        A[i], A[min_idx] = A[min_idx], A[i]   # 最小を未ソート先頭へ
    return A

概念コードとの対応

概念コード Pythonコード 意味
min_idx = i min_idx = i 未ソート部分の先頭を最小候補に
for j in i+1 .. n-1 for j in range(i + 1, n) 残り(未ソート部分)を走査
if A[j] < A[min_idx] if A[j] < A[min_idx] 候補より小さいか2項比較
swap A[i], A[min_idx] A[i], A[min_idx] = A[min_idx], A[i] 見つけた最小を先頭へ

選択ソートの内側ループ = my_min

内側ループは「未ソート部分の最小の位置を探す」処理で、これは先ほどの my_min の中身と同じ構造。便利関数の中身を組み立てているのがわかる。

動作例

[5, 3, 1, 4, 2]:

巡1:最小は 1(位置2)→ 先頭と交換 → [1, 3, 5, 4, 2]
巡2:残り [3,5,4,2] の最小は 2(位置4)→ 位置1と交換 → [1, 2, 5, 4, 3]
巡3:残り [5,4,3] の最小は 3(位置4)→ 位置2と交換 → [1, 2, 3, 4, 5]
巡4:残り [4,5] 最小は 4 → そのまま

演習6-4

[6, 2, 8, 1, 4] に選択ソート(昇順)を適用したとき、配列の変化を巡ごとに、処理が完了するまで書きなさい。

例: 1巡後 [1, 2, 8, 6, 4]

選択ソートの計算量

パス i の比較回数は n-i-1 回。合計 (n-1) + (n-2) + ... + 1 = \(n(n-1)/2 \approx n^2/2\)

O(n²)。バブルソートと同じ。


対決:バブル vs 選択

バブル 選択
比較回数 n(n-1)/2 n(n-1)/2
交換回数 最悪 n(n-1)/2 高々 n-1
既ソート時(最良) O(n) ※早期終了 O(n²)
計算量 O(n²) O(n²)

比較回数は同じ。違いは交換回数最良ケース

もう一つの違い:安定性

安定性: 同じ値が複数あるとき、元の順序を保つかどうか。

  • 保つソート: 安定(stable)
  • 保たないソート: 不安定(unstable)

計算量だけ見ていると見落とす性質。

安定性が効く場面

学生名簿が「学籍番号順」で並んでいる。そこから「成績の高い順」に並べ替えたい。同点の学生が複数いるとき、学籍番号順が保たれてほしい。安定ソートならOK、不安定だと同点学生の順序がバラバラになる。計算量は同じでも、これが選ぶ理由になる。

  • バブルソートは安定: 隣同士を比べ、左が大きいときだけ入れ替える。等しいとき(A[j] == A[j+1])は入れ替えないので、同じ値の相対順序は崩れない。
  • 選択ソートは不安定: 離れたカードを交換するので、同じ値の順序が崩れることがある。例えば [3a, 3b, 1](3a と 3b は同じ値、添字は元の順序)で、最小の 1 を位置0と交換すると [1, 3b, 3a] となり、3a と 3b の順序が入れ替わってしまう。

アルゴリズムは速さだけで選ぶものではない

計算量が同じでも、交換回数・最良ケース・安定性が違う。用途に応じて選ぶ。

演習6-5

以下のコードは「リストを昇順にソートする」目的で生成AIが出力したものです。問題点として最も適切なものを選べ。

def bubble_sort(A):
    n = len(A)
    for i in range(n):
        for j in range(n - 1):
            if A[j] > A[j + 1]:
                A[j], A[j + 1] = A[j + 1], A[j]
    return A
  • A. 正しく動作するが効率が悪い
  • B. 結果が間違っている
  • C. 空リストを渡すとエラーになる
  • D. 問題ない

LLMの出力を読むときの視点

「動くか」だけでなく「無駄がないか」も見る。内側ループの範囲に注目すると違いが見えてくる。

補足:O(n²) は限界ではない

バブルも選択も比較回数は n(n-1)/2 で「全ペアを1回ずつ見比べる」のと同じオーダー。だが全ペアを見ないとソートできないわけではなく、第5回の分割統治を使うと O(n log n) まで落とせる(第7回)。

n = 1,000,000 での違い

  • O(n²): 約5,000億回 → 数時間
  • O(n log n): 約2,000万回 → 0.2秒