第6回:ソート(1)バブルソート・選択ソート¶
5枚程度のカードは手で並べ替えられるが、データが大きくなると手順を決めて機械的に並べる必要がある。今回は最も基本的な2つのソート(バブルソート・選択ソート)を扱う。
昇順・降順
- 昇順: 後ろにいくにつれて昇っていく順。小 → 大。
- 降順: 後ろにいくにつれて降りていく順。大 → 小。
演習6-1¶
カード [5, 3, 1, 4, 2] を昇順に並べ替える手順を、他人に伝わるように書き出す。言葉でも、矢印つきの図でもよい。
そもそもソートとは¶
ソート: データをある順序で並べる操作。多くの場合、昇順または降順に並べる。
なぜ重要か:
- 二分探索が使える(第5回)
- 重複・中央値・上位N件などの処理が速くなる
- 後の回(ツリー・ヒープ)の前提にもなる
コンピュータの「目」¶
人間は5枚のカードを一目で見渡して、最小値を瞬時に見つけられる。しかしコンピュータはそうではない。
- コンピュータは基本的に2つの値を比べることしかできない(2項比較)
- 「最小を選ぶ」も、実は1ステップではなく、全体を1回なめる O(n) の小さなアルゴリズム
アルゴリズムを学ぶ = 中身を見える化する
- 第1回: アルゴリズム = 基本動作(順次・分岐・反復)の組み立て
- 第2回: 計算量 = 基本動作の回数
速さを測るには、min() や sorted() のような便利関数の中身を開いて「2項比較」レベルまで降りる必要がある。便利関数を使うのと、中身を組み立てるのは別の話。今回からしばらく、その「組み立てる」側を学ぶ。
演習6-2¶
裏返しの [5, 3, 1, 4, 2] を昇順に並べ替える手順を、他人に伝わるように書き出す。ルール:
- 1度に2枚しかめくれない
- 確認したら裏返す
[2, 1, 4, 3, 5]のような別パターンでも使える手順にする- 何回めくったか(= 何回比較したか)をカウントする
バブルソート¶
直感¶
- 裏返しのカードを隣同士でめくって比べる
- 左が大きければ入れ替えて、また裏返す
- これを左から右へ繰り返す
- 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秒