タートルグラフィックスで学ぶ Python のキホン 第 17 章
教材コード
第 17 章

リストとアルゴリズム

探す,並べ替える.手順を自分で組み立てる.

前の章,たくさんのデータを 1 つの名前でまとめて持つリストを習いました. 今回は,そのリストに対して「何かをする手順」を自分で組み立てます. 探す.いちばん小さいものを見つける.小さい順に並べ替える. どれも,やりたいことは一言で言えるのに, コンピュータにやらせようとすると手順に分解しなければなりません.

この「手順」のことをアルゴリズムといいます. プログラミングの本当のおもしろさは,ここから始まります. 命令の書き方を覚えることではなく, 「どうやったらできるか」を自分で考えて組み立てること. 今回はそのための最初の一歩です.

並べ替えは目に見えない処理なので,途中で何が起きているのか分かりにくいものです. そこで今回は,データの大きさを棒の高さで描いて, 並べ替わっていく様子をタートルに見せてもらいます.

この章でできるようになること
  • 「アルゴリズム」という言葉の意味を説明でき,同じ結果を出す手順が何通りもあることがわかる
  • 線形探索(先頭から順に見る)を自分で書ける
  • inindex() と,自分で書いた探索との違いがわかる
  • リストの中の最小値と,その場所を見つけられる
  • a, b = b, a で 2 つの値を入れ替えられる
  • 選択ソート挿入ソートバブルソートの考え方を,図で説明できる
  • sorted()sort() を使える.そのうえで,自分で書けることの意味がわかる
  • データが増えたとき,手間がどれくらい増えるかを見積もれる

17.1 手順を決めるということ

アルゴリズムとは,何かを実現するための手順のことです. もう少しきちんと言うと, 小さな命令を組み合わせ,順序立てて実行することで, より大きな目的を達成する手順を指します.

料理のレシピを思い浮かべてください.「カレーを作る」は目的です. 「玉ねぎを切る」「炒める」「水を加える」……という順番が手順です. レシピを見ずに作れる人には当たり前でも, まったく料理を知らない相手に伝えるには,手順に分解して書き下さなければなりません. コンピュータは,まさにその「まったく知らない相手」です.

17.1.1 同じ結果を出す手順は 1 つではない

たとえば「1 から 100 までの合計を求めよ」という目的を考えます. 手順はすぐに 2 通り思いつきます.

手順やり方たし算の回数
ぜんぶ足すs = 0 から始めて,1 から 100 まで順に足していく100 回
公式を使うs = 100 * 101 // 2 (ガウスが子どものころ見つけた式)0 回(かけ算 1 回とわり算 1 回)
two_ways.py
# 1 から 100 までの合計を,2 通りの手順で求める

# 手順 A:ぜんぶ足す
s = 0
for i in range(1, 101):
    s = s + i
print("手順 A の答え:", s)

# 手順 B:公式を使う
t = 100 * 101 // 2
print("手順 B の答え:", t)

答えはどちらも 5050 です. 結果が同じでも,手順は別ものだということが大事な点です. プログラムを書くというのは, 「どんな手順にするか」をあなたが決めるということなのです.

17.1.2 手順の良し悪し

手順には,良し悪しがあります.評価の物差しはおもに 3 つです.

  1. 正しいか.どんなデータを与えても正しい答えになるか.たまたまうまくいっただけ,では困ります.
  2. 速いか.手間(計算の回数)が少ないか.データが 10 個なら差は出ませんが,10 万個になると,1 秒で終わるか 3 時間かかるかの差になります(17.10 節).
  3. 読めるか.人間が読んで分かるか.直せるか.プログラムは書く時間より,読まれる時間のほうがずっと長いものです.
まず正しく,それから速く 1 番目の「正しいか」が最優先です. 速いけれど時々間違える手順より,遅くても必ず正しい手順のほうが,はるかに価値があります. 速くするのは,正しく動くものができたの話です.

17.1.3 手順を先に書き出す

いきなり Python で書き始めると,たいてい途中で迷子になります. 先に日本語で手順を書き出すと,見通しがよくなります.

日本語で書いた手順(擬似コード)
# 「リストの中からいちばん小さい数を見つける」手順

1. いまのところの最小値を,先頭の数だと思っておく
2. 2 番目から最後まで,1 つずつ順に見ていく
3.   見ている数が「いまのところの最小値」より小さければ,
4.     「いまのところの最小値」をその数に取り替える
5. 全部見終わったら,「いまのところの最小値」が答え

このように,プログラムの形を借りながら日本語で書いたものを 擬似(ぎじ)コードといいます. 古くからある方法としては,処理を箱と矢印でつないだ フローチャート(流れ図)もよく使われます. どちらでもかまいません.大事なのは 「書く前に手順を決める」という順番です.

コラム:設計と実装 設計(どんな手順にするか決めること)に対して, それを実際に動くプログラムにすることを実装(じっそう)といいます. ソフトウェアの仕事の現場でよく使う言葉です. 「設計はできているが実装がまだ」「実装は終わったがテストがまだ」のように使います. 設計と実装を分けて考える癖がつくと,行きづまったときに 「手順が間違っているのか,書き方を間違えているのか」を切り分けられるようになります.

17.2 カードを描く道具をつくる

並べ替えの話をする前に,リストの中身を目で見られるようにしておきます. 数字を書いたカードを横一列に並べて描く関数を作りましょう. トランプを机に並べるところを想像してください.

cards.py
# リストの中身をカードの列として描く

from turtle import *

def card(x, y, n):
    # (x, y) を左下のかどとして,数 n の書かれたカードを 1 枚描く
    pu()
    goto(x, y)
    seth(0)
    pd()
    pencolor("saddlebrown")
    fillcolor("lightyellow")
    begin_fill()
    fd(40)
    lt(90)
    fd(56)
    lt(90)
    fd(40)
    lt(90)
    fd(56)
    lt(90)
    end_fill()
    pu()
    goto(x + 9, y + 18)
    pencolor("black")
    write(n, font=("Arial", 20, "normal"))

def show(a, y):
    # リスト a を横一列に並べて描く
    x = -23 * len(a)
    for n in a:
        card(x, y, n)
        x = x + 46

ht()
speed(0)
pensize(2)

a = [5, 2, 9, 1, 7, 3]
show(a, 0)

done()
529173
図 17.1 リスト [5, 2, 9, 1, 7, 3] をカードの列として描いたもの.

show(a, y) は,リストをそのまま受け取って, 中身を左から順に並べて描きます. リストの中身が変わってから呼び直せば,変わった後の姿が描けます. これから何度も使うので,覚えておいてください.

無い部品は自分で作る 「カードを描く部品」は,はじめから用意されているわけではありません. 無ければ自分で作ります. 無ければ作れる,というのが第 13 章で関数を習った意味です.

17.2.1 入れ物から入れ物へ 1 枚ずつ移す

並べ替えの前に,カードを 1 枚ずつ別の入れ物に移す練習をします. 並べ替えのアルゴリズムは,どれも結局「カードをどこへ移すか」の話だからです.

リストの先頭から 1 枚抜き取るには pop(0), 末尾に 1 枚加えるには append() を使います.

movecards.py
# 10 枚のカードを,入れ物 1 から入れ物 2 へ 1 枚ずつ移す

from turtle import *

def card(x, y, n):
    pu()
    goto(x, y)
    seth(0)
    pd()
    pencolor("saddlebrown")
    fillcolor("lightyellow")
    begin_fill()
    fd(40)
    lt(90)
    fd(56)
    lt(90)
    fd(40)
    lt(90)
    fd(56)
    lt(90)
    end_fill()
    pu()
    goto(x + 5, y + 18)
    pencolor("black")
    write(n, font=("Arial", 18, "normal"))

def show(a, y):
    x = -23 * len(a)
    for n in a:
        card(x, y, n)
        x = x + 46

ht()
speed(0)
pensize(2)

list1 = [0, 10, 20, 30, 40, 50]
list2 = []

show(list1, 60)         # はじめの入れ物 1

while len(list1) > 0:   # 入れ物 1 が空になるまで
    n = list1.pop(0)    # 先頭を 1 枚抜く
    list2.append(n)     # 末尾に加える
    print("移した:", n, "/残り", len(list1), "枚")

show(list2, -80)        # 移し終わった入れ物 2

done()
0102030405001020304050
図 17.2 上が移す前の入れ物 1,下が移し終わった入れ物 2.

while len(list1) > 0: は「入れ物 1 に 1 枚でも残っているあいだ繰り返す」 という意味です.中で pop(0) するたびにリストは 1 枚ずつ減るので, いつかは 0 枚になり,繰り返しが終わります.

while は終わり方を先に決める while を書くときは, 「いつ終わるのか」を必ず自分で言えるようにしてください. 上の例なら「pop するたびに 1 枚減るから,いつか 0 になる」です. もし pop(0) を書き忘れると,条件がいつまでも成り立ち続けて, プログラムは止まらなくなります(無限ループ). この教材では while True: は使えません.ブラウザが固まるからです.
0 回で終わることもある while先に条件を調べるので, 条件が最初から成り立たなければ1 回も実行されません. 「必ず 1 回はやりたい」ときは,while の前にその 1 回を書くか, while True: の中の最後で break します (ただしこの教材では while True: は使いません).
  • while … 先に条件を調べる.0 回で終わることもある
  • break … 繰り返しの途中で,その場ですぐ抜け出す(17.3.2 節)

17.3 探す — 線形探索

いちばん基本のアルゴリズムは探すことです. 「このリストの中に 25 はあるか.あるなら何番目か」. 人間なら一目で分かりますが,コンピュータには手順を教えなければなりません.

17.3.1 先頭から順に見る

いちばん素直な手順は,先頭から 1 つずつ見ていって, 目当てのものと同じかどうか調べるというものです. これを線形探索(リニアサーチ,linear search)といいます. 「線のように一直線に見ていく」という意味です.

先頭から順に,1 つずつ見ていく127253198見たいま見ているまだ見ていない
図 17.3 線形探索.左から順に見ていき,見つかったところで止まる.
linear.py
# リストの中から 25 を探す

a = [12, 7, 25, 3, 19, 8]
target = 25

found = -1                    # 見つかった場所.-1 は「まだ見つかっていない」印

for i in range(len(a)):
    print(i, "番目を見る:", a[i])
    if a[i] == target:
        found = i

if found >= 0:
    print(target, "は", found, "番目にありました")
else:
    print(target, "は入っていません")

コンソールを見ると,6 つ全部を見ていることがわかります. 25 は 2 番目で見つかっているのに,そのあとも 3・4・5 番目を見ています.無駄です.

「まだ無い」を表す値 found = -1 のように, 「まだ何もない」ことを表す値をあらかじめ入れておくやり方を覚えてください. リストの番号は 0 から始まるので,0 は「見つからなかった」印には使えません (0 番目に見つかったのか,見つからなかったのか区別できない). そこで,絶対にあり得ない -1 を使います.

17.3.2 見つかったら止める — break

見つかったらそこで探すのをやめれば,無駄がなくなります. 繰り返しの途中で抜け出す命令が break です.

linear_break.py
# 見つかったところで探すのをやめる

a = [12, 7, 25, 3, 19, 8]
target = 25

found = -1

for i in range(len(a)):
    print(i, "番目を見る:", a[i])
    if a[i] == target:
        found = i
        break              # ← ここで for から抜け出す

if found >= 0:
    print(target, "は", found, "番目にありました")
else:
    print(target, "は入っていません")

今度は「0 番目を見る」「1 番目を見る」「2 番目を見る」で止まります. break は,それが入っているいちばん内側の繰り返しだけを抜けます. 二重の繰り返しの内側で break しても,外側の繰り返しは続きます.

コラム:break は使ってよいのか break は,「入り口が 1 つ,出口が 1 つ」というきれいな処理の形 (構造化プログラミング)を崩すものなので, 昔は「行儀が悪い」と言われることもありました. 実際,break を使わなくても,変数と条件で同じ動きは書けます.
python
i = 0
while i < len(a) and a[i] != target:   # 「まだ範囲内」かつ「まだ違う」あいだ
    i = i + 1
ただし今の Python では,探索のように 「見つけたらもう用は無い」という場面で break を使うのは, むしろ読みやすい書き方だと考えられています. 大事なのは,どちらが読みやすいかを自分で判断することです.

17.3.3 できあいの道具 — in と index()

実は Python には,同じことをする道具がはじめから用意されています.

in_index.py
a = [12, 7, 25, 3, 19, 8]

print(25 in a)          # 入っているか? → True
print(100 in a)         # → False

print(a.index(25))      # 何番目か? → 2

# 入っていないものを index() で探すとエラーになる
# print(a.index(100))   # ValueError: 100 is not in list

# だから,先に in で確かめてから使う
if 100 in a:
    print("100 は", a.index(100), "番目")
else:
    print("100 は入っていません")
書き方返すもの入っていないとき
x in aTrue / FalseFalse が返る
a.index(x)見つかった場所(0 から数えた番号)エラーValueError)で止まる
a.count(x)いくつ入っているかの個数0 が返る

in は「あるか / ないか」だけ.index() は「何番目か」まで. 目的に合ったほうを選びます. そして,どちらも中では線形探索をしています. 先頭から順に 1 つずつ比べているのです.書く手間が省けているだけで,手順は同じです.

自分で書けることと,道具を使うこと 「できあいの道具があるなら,自分で書く意味は無いのでは」と思ったかもしれません. 意味は 2 つあります.1 つは, 道具の中で何が起きているか分かるようになること (速いのか遅いのか,いつ使ってよいのかが判断できる). もう 1 つは,ぴったりの道具が無いときに自分で作れることです. たとえば「30 以上の数が最初に出てくるのは何番目か」を求める道具は,Python にはありません. 自分で書くしかないのです.
虫食い虫食い 17-A 線形探索

リスト a の中から,入力された数を探して 「何番目にあるか」を表示するプログラムです. ____ を埋めて完成させてください. 見つかったらすぐに探すのをやめること.

ヒント コンソールの入力行には 19 が入れてあります. 別の数(たとえば 100)に書き換えて, 「入っていません」になることも確かめてください.
fill16_linear.py
a = [12, 7, 25, 3, 19, 8]

target = int(input("探す数:"))

found = -1

for i in range(____(a)):
    if a[i] == ____:
        found = ____
        ____

if found >= 0:
    print(target, "は", found, "番目にありました")
else:
    print(target, "は入っていません")

17.4 いちばん小さいものを探す

並べ替えの土台になるのが最小値を探す手順です. ここが分かれば,並べ替えは半分できたようなものです.

17.4.1 「いまのところの最小値」を覚えておく

コンピュータは,人間のように全体を一目で見ることができません. 一度に 1 つずつしか見られないのです. ではどうするか.「いまのところ,いちばん小さかった数」を覚えておくのです.

  1. まず,先頭の数を「いまのところの最小値」だと思っておく
  2. 2 番目から順に見ていく.
  3. 見ている数が「いまのところの最小値」より小さければ,取り替える
  4. 最後まで見終わったら,「いまのところの最小値」が本当の最小値になっている.
はじめ:先頭を最小値だと思う529173min = 5
図 17.4 まず先頭の 5 を「いまのところの最小値」にする.
2 は 5 より小さい → 取り替える529173min = 2
図 17.5 2 のほうが小さいので,最小値を 2 に取り替える.
9 は 2 より大きい → そのまま529173min = 2
図 17.6 9 は大きいので何もしない.
1 は 2 より小さい → 取り替える529173min = 1
図 17.7 1 に取り替える.以降 7・3 は大きいので変わらない.
minimum.py
# リストの中でいちばん小さい数を見つける

a = [5, 2, 9, 1, 7, 3]

m = a[0]                      # まず先頭を「いまのところの最小値」にする

for i in range(1, len(a)):    # 1 番目から最後まで
    print(i, "番目 =", a[i], "/いまのところの最小値 =", m)
    if a[i] < m:
        m = a[i]              # 取り替える

print("最小値は", m)

range(1, len(a)) になっているところに注目してください. 先頭(0 番目)はすでに m に入れたので,1 番目から調べれば十分です. range(len(a)) と書いても答えは同じですが, 0 番目を自分自身と比べることになり,1 回分だけ無駄になります.

初めの値を,決め打ちの数にしない m の初めの値を a[0] ではなく, 0100 のような決め打ちの数にする 書き方を見かけます.これは危ういやり方です.
  • 最大値を m = 0 から探すと,気温のようにマイナスがあるデータでは,答えがいつも 0 になってしまいます.
  • 最小値を m = 100 から探すと,101 点満点のテストが出てきたとたんに壊れます.
先頭の要素を初めの値にすると決めておけば, データの中身がどうであっても正しく動きます. 「あり得ない大きな数」を探して悩むより,こちらのほうが確実です.

17.4.2 「場所」を覚えておく

並べ替えのためには,最小値そのものよりも 「それが何番目にあったか」のほうが必要です. 入れ替えるには場所が分からないといけないからです. 覚えておく変数を,値ではなく番号にします.

argmin.py
# いちばん小さい数が「何番目」にあるかを見つける

a = [5, 2, 9, 1, 7, 3]

k = 0                         # いまのところ最小なのは 0 番目

for i in range(1, len(a)):
    if a[i] < a[k]:           # 「値」ではなく a[k] と比べるのがポイント
        k = i                 # 覚えるのは番号

print("最小値", a[k], "は", k, "番目にあります")
番号と値を取り違えない if a[i] < a[k]:a[k]k と 書き間違えるのが定番の失敗です. k番号a[k] はその場所に入っている. 比べたいのは値のほうです.エラーにはならず,答えだけが静かに狂います.

なお,最小値・最大値を求めるだけなら min(a)max(a) という道具があります. 「何番目か」まで知りたいときは a.index(min(a)) と書けますが, これはリストを2 回なぞることになります. 上の書き方なら 1 回で済みます.

虫食い虫食い 17-B 最大値の場所

今度はいちばん大きい数が何番目にあるかを探し, その棒だけを赤くして描くプログラムです. ____ を埋めてください. 最小値のときと,どこが変わるでしょうか.

fill16_argmax.py
from turtle import *

def bars(vals, cols, y):
    x = -15 * len(vals)
    for i in range(len(vals)):
        fillcolor(cols[i])
        pu()
        goto(x, y)
        seth(0)
        pd()
        begin_fill()
        fd(24)
        lt(90)
        fd(vals[i] * 5)
        lt(90)
        fd(24)
        lt(90)
        fd(vals[i] * 5)
        lt(90)
        end_fill()
        x = x + 30

ht()
speed(0)

a = [5, 2, 9, 1, 7, 3]

k = 0
for i in range(1, len(a)):
    if a[i] ____ a[k]:
        k = ____

cols = []
for i in range(len(a)):
    if i == ____:
        cols.append("crimson")
    else:
        cols.append("skyblue")

bars(a, cols, 0)
print("最大値", a[k], "は", k, "番目")

done()

17.5 2 つの値を入れ替える

並べ替えのもう 1 つの部品が交換(スワップ)です. a[0]a[3] の中身を入れ替えたい. 素直に書くと,こうしたくなります.

やってはいけない交換
a[0] = a[3]      # ← これをやると a[0] にあった値が消える
a[3] = a[0]      # ← もう a[0] は a[3] と同じ値になっている

両方とも a[3] の値になってしまいます. コップの水を入れ替えるのに,空のコップがもう 1 つ要るのと同じで, 一時的に値を置いておく場所が必要です.

正しい交換(多くの言語ではこう書く)
tmp = a[0]       # まず a[0] を避難させる
a[0] = a[3]
a[3] = tmp       # 避難させておいた値を入れる

この tmp のように,値を一時的にあずかるためだけに用意する変数を 退避用変数(たいひようへんすう)といいます. tmp(temporary の略)や temp という名前がよく使われます.

ところが Python では,これを1 行で書けます.

swap.py
# 2 つの値を入れ替える

a = [5, 2, 9, 1, 7, 3]
print("前:", a)

a[0], a[3] = a[3], a[0]      # 0 番目と 3 番目を入れ替える

print("後:", a)

# ふつうの変数でも同じことができる
x = 100
y = 200
x, y = y, x
print("x =", x, "/ y =", y)
なぜ 1 行で書けるのか a, b = b, a は,右側の b, a先にまとめて読み取ってから,左側へ配ります. だから避難用の変数が要りません. Python の書き方のなかでも,とくに気持ちのよいものの 1 つです.
虫食い虫食い 17-C 両端から入れ替えて逆順にする

リストを逆順にします. 両端どうし(0 番目と最後,1 番目と最後から 2 番目,……)を入れ替えていけば, 真ん中まで進んだところで逆順になります. ____ を埋めてください.

ヒント n // 2 回だけ繰り返せば十分です. 全部繰り返すと,入れ替えたものをもう一度入れ替えて元に戻ってしまいます. できあがったら a.reverse()a[::-1] でも 同じことができると知っておいてください.
fill16_reverse.py
a = [5, 2, 9, 1, 7, 3, 6, 4]
print("前:", a)

n = len(a)

for i in range(n ____ 2):
    a[i], a[____] = a[____], a[i]

print("後:", a)

17.6 選択ソート — 最小値を選んで前に置く

材料がそろいました.最小値を探す手順と, 入れ替える手順です.この 2 つを組み合わせると,並べ替えができます.

考え方はこうです.

  1. 全体からいちばん小さいものを探し,0 番目と入れ替える.これで 0 番目は確定.
  2. 1 番目から後ろでいちばん小さいものを探し,1 番目と入れ替える.これで 1 番目も確定.
  3. 2 番目から後ろで……と,これを最後まで繰り返す.

この方法を選択ソート(セレクションソート,selection sort), 日本語では最小値選択法といいます. トランプを机に広げて並べ替えるとき,多くの人が自然にやる方法です.

17.6.1 1 ステップずつ見る

[5, 2, 9, 1, 7, 3] を並べ替えてみましょう. 各章,上の段が入れ替える前,下の段が入れ替えた後です. オレンジは確定したところ,赤は見つけた最小値, 黄は入れ替える相手(その章の先頭)です.

1 回目:0 番目から後ろの最小は 1(3 番目)529173→ 0 番目と 3 番目を入れ替える129573
図 17.8 1 回目.最小の 1 を先頭に持ってくる.0 番目が確定した.
2 回目:1 番目から後ろの最小は 2(1 番目)129573→ 自分自身なので入れ替えなし129573
図 17.9 2 回目.すでに正しい場所にあるので,入れ替えは起きない.
3 回目:2 番目から後ろの最小は 3(5 番目)129573→ 2 番目と 5 番目を入れ替える123579
図 17.10 3 回目.たまたまこれで全部そろったが,機械は最後まで確かめる.
機械は「もう並んでいる」ことに気づかない 図 17.10 で見た目はもう完成していますが,プログラムはそれを知りません. 人間には一目で分かることが,コンピュータには分からない. だから 4 回目・5 回目も同じ手順をきちんと実行します. ここが,手順を機械に渡すということの実感できるところです.

17.6.2 プログラムにする

selection.py
# 選択ソート(最小値選択法)

a = [5, 2, 9, 1, 7, 3]
print("はじめ:", a)

for i in range(len(a) - 1):       # 0 番目から,最後の 1 つ手前まで
    k = i                         # i 番目から後ろの最小値の場所を探す
    for j in range(i + 1, len(a)):
        if a[j] < a[k]:
            k = j
    a[i], a[k] = a[k], a[i]       # 見つけた最小値を i 番目に持ってくる
    print(i + 1, "回目:", a)

print("できあがり:", a)

繰り返しが二重になっています. 外側の i が「いま何番目を確定させようとしているか」, 内側の j が「そこから後ろを見て回る係」です.

場所意味
for i in range(len(a) - 1)確定させる場所を前から順に決める.最後の 1 つは自動的に決まるので - 1 でよい
k = i「いまのところ最小なのは i 番目」と思っておく(17.4.2 節と同じ)
for j in range(i + 1, len(a))i より後ろだけを見て回る.前は確定済みなので見なくてよい
a[i], a[k] = a[k], a[i]見つけた最小値を i 番目へ.i == k のときは何も変わらない
場所を覚えず,そのつど入れ替える書き方 同じ「前から確定させる」考え方でも,書き方はもう 1 つあります. 最小値の場所を覚えておくのをやめて, 自分より小さいものを見つけるたびに,その場で入れ替えてしまうやり方です.
python
for i in range(len(a) - 1):
    for j in range(i + 1, len(a)):
        if a[j] < a[i]:
            a[i], a[j] = a[j], a[i]    # 見つけるたびに入れ替える
結果はまったく同じです.ただし,入れ替えの回数が増えます. 上の書き方は 1 回の外回りにつき入れ替えが 1 回きりですが, こちらは小さいものが見つかるたびに入れ替えるからです. 17.1.1 節で見た「同じ結果を出す手順は 1 つではない」のよい例です. どちらを選ぶかは,読みやすさと手間のどちらを重く見るかで決まります.
1 ステップずつ,自分の目で追う 手元の VS Code なら,デバッガで 1 行ずつ止めながら値の変化を見られます (第 1 章 1.13 節). a[i], a[k] = a[k], a[i] の行にブレークポイントを置いて F5 で止め,あとは F10 を押していけば, ika が 1 手ずつ変わっていく様子が, 17.6.1 節の図とそのままの順番で「変数」に出てきます.

17.6.3 並べ替えの途中を目で見る

さて,本題です.並べ替わっていく様子をタートルに描かせます. 1 回終わるごとに,そのときのリストを棒グラフにして 1 段ずつ下に描いていきます. いちばん上が並べ替える前,いちばん下ができあがりです.

絵に描かせるのは,あとから全体を見くらべるためです. これに対して,手元の VS Code のデバッガ(第 1 章 1.13 節)は, その場で 1 行ずつ止めて中を覗くためのものです. どちらも「途中を見る」道具ですが,向いている場面が違います.

selection_vis.py
# 選択ソートの途中経過を棒グラフで見る

from turtle import *

def bars(vals, fixed, y):
    # vals を棒グラフにして描く.先頭から fixed 個はオレンジ(確定済み)
    x = -13 * len(vals)
    for i in range(len(vals)):
        if i < fixed:
            fillcolor("orange")
        else:
            fillcolor("skyblue")
        pu()
        goto(x, y)
        seth(0)
        pd()
        begin_fill()
        fd(20)
        lt(90)
        fd(vals[i] * 4)
        lt(90)
        fd(20)
        lt(90)
        fd(vals[i] * 4)
        lt(90)
        end_fill()
        x = x + 26

ht()
speed(0)
pensize(1)
pencolor("dimgray")

a = [5, 2, 9, 1, 7, 3, 6, 4]

y = 180
bars(a, 0, y)                     # 並べ替える前の姿

for i in range(len(a) - 1):
    k = i
    for j in range(i + 1, len(a)):
        if a[j] < a[k]:
            k = j
    a[i], a[k] = a[k], a[i]
    y = y - 50                    # 1 段下へ
    bars(a, i + 1, y)             # そのときの姿を描く

done()
図 17.11 上から下へ,1 回ごとに棒が並び替わっていく様子.オレンジが確定した部分.

オレンジが左から 1 本ずつ増えていくのが見えます. これが選択ソートの動きです. 速さを「とてもゆっくり」にして実行すると,棒が 1 本ずつ描かれていくので, さらによく分かります.

やってみよう データを変えて試してください.a = [1, 2, 3, 4, 5, 6, 7, 8](すでに並んでいる)や a = [8, 7, 6, 5, 4, 3, 2, 1](逆順)だとどうなるでしょうか. 選択ソートは,すでに並んでいても手を抜きません. 毎回きちんと後ろ全部を見て回ります.この性質は 17.7 節の挿入ソートと対照的です.
虫食い虫食い 17-D 大きい順に並べ替える

選択ソートを書き換えて,大きい順(降順)に並べ替えます. ____ を埋めてください. 変えるところは,たった 1 か所です.

fill16_desc.py
a = [5, 2, 9, 1, 7, 3]
print("はじめ:", a)

for i in range(len(a) - 1):
    k = i
    for j in range(____, len(a)):
        if a[j] ____ a[k]:
            k = j
    a[i], a[k] = ____, ____
    print(i + 1, "回目:", a)

print("できあがり:", a)

17.7 挿入ソート — 手札を並べる要領

並べ替えの手順は 1 つではありません(17.1.1 節). まったく別の考え方を見てみましょう.

トランプの手札を配られたところを想像してください. 多くの人は,1 枚引いては,すでに並べてある札の正しい場所に差し込む という並べ方をします.これが挿入ソート(insertion sort)です. 日本語では挿入法といいます.

  1. 左端の 1 枚は,それだけで「並んでいる」とみなす.
  2. 次の 1 枚を取り上げる.これを手に持っている札とする.
  3. 並んでいる札を右から左へ見ていき,手札より大きい札は1 つ右へずらす
  4. ずらせなくなったところ(=手札より小さい札に出会ったところ)に手札を置く.
  5. これを最後の 1 枚まで繰り返す.
1 と 9 の列に 3 を差し込む1939 を右へずらし,3 を置く139
図 17.12 挿入ソートの 1 ステップ.大きい札をずらして場所を空ける.
insertion.py
# 挿入ソート(挿入法)

a = [5, 2, 9, 1, 7, 3]
print("はじめ:", a)

for i in range(1, len(a)):
    v = a[i]                   # 手に持つ札
    j = i - 1                  # そのすぐ左から見ていく
    while j >= 0 and a[j] > v:
        a[j + 1] = a[j]        # 大きい札を 1 つ右へずらす
        j = j - 1
    a[j + 1] = v               # 空いた場所に手札を置く
    print(i, "回目:", a)

print("できあがり:", a)

while j >= 0 and a[j] > v: の条件は 2 つの意味を持っています. j >= 0 は「まだ左に札が残っている」, a[j] > v は「その札は手札より大きい」. どちらかが成り立たなくなったら,そこが置き場所です.

条件を書く順番には意味がある 条件の順番を入れ替えて while a[j] > v and j >= 0: と書くと, j-1 になったときに a[-1] (=リストの最後の要素)を見てしまい,おかしな結果になります. Python の and左から順に調べ,左が偽なら右を調べません. この性質を使って「安全を確かめてから中を見る」と書くのは,よくある作法です.
選択ソート挿入ソート
考え方後ろから最小値を選んで前に置く1 つずつ取り上げて正しい場所に差し込む
途中の見え方左から確定していく.右側はばらばらのまま左側は常に並んでいる.右側は手つかずのまま
入れ替えの回数多くても n 回(毎回 1 回だけ)ずらす回数はデータ次第.最悪だと多い
すでに並んでいるとき変わらず全部調べるほとんど何もしないで終わる(速い)
虫食い虫食い 17-E 挿入ソートの途中を見る

挿入ソートの途中経過を棒グラフで描きます. ____ を埋めて完成させてください. できあがったら選択ソート(図 17.11)と見比べて, 並び方の進み方がどう違うかを確かめてください.

fill16_insvis.py
from turtle import *

def bars(vals, fixed, y):
    x = -13 * len(vals)
    for i in range(len(vals)):
        if i < fixed:
            fillcolor("mediumseagreen")
        else:
            fillcolor("skyblue")
        pu()
        goto(x, y)
        seth(0)
        pd()
        begin_fill()
        fd(20)
        lt(90)
        fd(vals[i] * 4)
        lt(90)
        fd(20)
        lt(90)
        fd(vals[i] * 4)
        lt(90)
        end_fill()
        x = x + 26

ht()
speed(0)
pensize(1)
pencolor("dimgray")

a = [5, 2, 9, 1, 7, 3, 6, 4]

y = 180
bars(a, 1, y)

for i in range(1, len(a)):
    v = a[i]
    j = i - 1
    while j >= 0 and a[j] ____ v:
        a[j + 1] = ____
        j = j - 1
    a[____] = v
    y = y - 50
    bars(a, i + 1, y)

done()

17.8 バブルソート — となりどうしを比べる

3 つめの考え方を見ます. 選択ソートは「後ろ全部を見わたして最小値を選ぶ」, 挿入ソートは「正しい場所を探して差し込む」でした. バブルソート(bubble sort)は,もっと視野が狭くなります. 見るのはとなりどうしの 2 つだけです.

  1. 左端から順に,となりあう 2 つを比べる.
  2. 左のほうが大きければ,入れ替える
  3. これを右端まで続けると,いちばん大きいものが右端に落ち着く
  4. 右端は確定したので,次はその手前までで同じことを繰り返す.
1 回目:左から順に,となりどうしを比べる529173→ ひと回りすると,最大の 9 が右端へ送られる251739
図 17.13 バブルソートの 1 回目.となりどうしの比べっこを右端まで続けると,いちばん大きいものが右端に届く.

確定していく場所が,選択ソートとは逆だということに注目してください. 選択ソートは左から確定しましたが,バブルソートは右から確定していきます.

bubble.py
# バブルソート(隣接交換法)

a = [5, 2, 9, 1, 7, 3]
n = len(a)
print("はじめ:", 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]    # 左が大きければ入れ替える
    print(i + 1, "回目:", a)

print("できあがり:", a)

内側の range(n - 1 - i) がこの手順のかなめです. ja[j]a[j + 1] の 2 つを見るので, j は最後の 1 つ手前までしか進めません(だから n - 1). さらに,ひと回り終わるごとに右端が 1 つずつ確定していくので, 見る範囲を i だけ狭められます.

なぜ「泡」なのか 「バブル(泡)」という名前は, 小さい値が,水の中の泡のように少しずつ上(左)へ上がっていく ように見えることから付きました. 大きい値がすとんと右端まで落ちるのに対して, 小さい値はひと回りにつき 1 つ分しか左へ進めません. 上の実行結果で,1 がどう動いていくかを目で追ってみてください.
選択ソート挿入ソートバブルソート
見るもの後ろ全部左どなりから順に左へとなりの 1 つだけ
確定する側左から左から右から
入れ替えの回数多くても n 回ずらす回数はデータ次第いちばん多い
すでに並んでいるとき変わらず全部調べるほとんど何もしない工夫すればひと回りで終われる
書きやすさふつうやや難しいいちばんやさしい

バブルソートは,3 つのなかでいちばん書くのがやさしく, いちばん手間がかかる方法です. 実用のために使うことはまずありませんが, 「となりを比べて入れ替えるだけで並べ替えができてしまう」という驚きがあるので, アルゴリズムの入門では必ず出てきます.

ひと回りして何も起きなかったら,終わってよい バブルソートには,簡単で効果の大きい工夫があります. ひと回りのあいだに 1 回も入れ替えが起きなかったら, もう全部並んでいるということです.そこでやめてしまえばよいのです.
python
for i in range(n - 1):
    kokan = 0                      # このひと回りで入れ替えた回数
    for j in range(n - 1 - i):
        if a[j] > a[j + 1]:
            a[j], a[j + 1] = a[j + 1], a[j]
            kokan = kokan + 1
    if kokan == 0:                 # 1 回も入れ替えなかった
        break                      # = もう並んでいるので終わり
17.3.2 節で覚えた break が,ここで効いてきます. こうすると,すでに並んでいるデータはひと回りで終わります. 選択ソートには,この手が使えません (毎回きちんと後ろ全部を見ないと,最小値が分からないからです). 手順を少し変えるだけで性質が変わる.これがアルゴリズムの面白さです.
虫食い虫食い 17-F バブルソートの途中を見る

バブルソートの途中経過を,1 回ごとに 1 段ずつ棒グラフで描きます. ____ を埋めてください. 確定するのは右側なので,色を塗る場所も右側になります.

ヒント できあがったら,選択ソート(図 17.11)と見くらべてください. オレンジが増えていく向きが逆になっているはずです.
fill16_bubble.py
from turtle import *

def bars(vals, kakutei, y):
    # 右端から kakutei 本ぶんをオレンジ(確定済み)にして描く
    n = len(vals)
    x = -13 * n
    for i in range(n):
        if i >= n - kakutei:
            fillcolor("orange")
        else:
            fillcolor("skyblue")
        pu()
        goto(x, y)
        seth(0)
        pd()
        begin_fill()
        fd(20)
        lt(90)
        fd(vals[i] * 4)
        lt(90)
        fd(20)
        lt(90)
        fd(vals[i] * 4)
        lt(90)
        end_fill()
        x = x + 26

ht()
speed(0)
pensize(1)
pencolor("dimgray")

a = [5, 2, 9, 1, 7, 3, 6, 4]
n = len(a)

y = 180
bars(a, 0, y)                      # 並べ替える前の姿

for i in range(n - 1):
    for j in range(n - 1 - ____):
        if a[j] ____ a[j + 1]:
            a[j], a[j + 1] = ____, ____
    y = y - 50
    bars(a, ____ + 1, y)           # 右から i + 1 本が確定した

print("できあがり:", a)

done()

17.9 できあいの道具 — sorted() と sort()

ここまで自分で並べ替えを書いてきました. しかし実際の仕事では,Python が用意した道具を使います. 速くて,正しくて,短いからです.

sorted.py
a = [5, 2, 9, 1, 7, 3]

# sorted(a) … 並べ替えた「新しいリスト」を返す.a はそのまま
b = sorted(a)
print("a =", a)
print("b =", b)

# a.sort() … a 自身を並べ替える.返り値は無い
a.sort()
print("a =", a)

# 大きい順
a.sort(reverse=True)
print("大きい順:", a)

# 文字列も並べ替えられる
w = ["ringo", "ichigo", "suika", "momo"]
print(sorted(w))                 # アルファベット順
print(sorted(w, key=len))        # 文字数の少ない順
書き方元のリスト返すもの使いどころ
sorted(a)変わらない新しい並べ替え済みリスト元の順番も残しておきたいとき
a.sort()並べ替わるNone(無し)元の順番はもう要らないとき
b = a.sort() は失敗する b = a.sort() と書くと,b には None が入ります. sort()その場で並べ替えるだけで,何も返さないからです. 新しいリストが欲しいときは sorted() を使ってください. とてもよくある間違いです.
コラム:それでも自分で書けることが大事 では,自分で書く練習に意味はなかったのでしょうか.とんでもない.
  • 中で何が起きているか分かる.「データが 10 万件になったら時間がかかるかも」と想像できるようになります.
  • 道具が無い場面に出会う.「点数の高い順,同点なら名前の五十音順」のような並べ替えは,条件を自分で組み立てなければ書けません.
  • 手順を組み立てる力そのものが身につく.並べ替えはその練習台にすぎません.本当に鍛えているのは,問題を手順に分解する力です.
プロの料理人も,既製のソースを使います. それでも,一から作れる人と作れない人とでは, 出来合いの使い方も,味の直し方も,まったく違います.

17.10 計算の手間 — データが 2 倍になると

選択ソートは,データが n 個のとき何回比べているでしょうか. 数えてみます.1 回目は n−1 回,2 回目は n−2 回,……,最後は 1 回. 全部足すと n(n−1)/2 回です.

データの個数 n比べる回数(およそ)n が 10 倍になると
1045 回
1004,950 回約 100 倍
1,000499,500 回約 100 倍
10,00049,995,000 回約 100 倍
100,000約 50 億回約 100 倍

データが 2 倍になると手間は約 4 倍, 10 倍になると約 100 倍です.n の 2 乗で増えるので, このような手順を「n の 2 乗に比例するアルゴリズム」といいます.

挿入ソートもバブルソートも,比べる回数は同じく n の 2 乗に比例します. 今回覚えた 3 つは,どれも同じ「n の 2 乗」の仲間なのです. 細かい速さの差はあっても,データが増えたときの苦しさは変わりません. 本当に速くしたければ,まったく別の手順に取り替える必要があります.

これは思ったより深刻です. 1 万件で 1 秒かかるプログラムは,100 万件では 1 秒 × 100 × 100 = 10000 秒,つまり約 3 時間かかります. パソコンを速いものに買い替えても,せいぜい数倍にしかなりません. 手順を変えるほうが,機械を変えるよりずっと効きます.

もっと速い手順がある 世の中には,もっと賢い並べ替えの手順があります. マージソートクイックソートと呼ばれるもので, 手間は「n × log n に比例する」程度です. 100 万件でも数秒で終わります. sort() が中で使っているのも,その仲間です. くわしくは 2 年次の「データ構造とアルゴリズム」で習います. 今回は,「手順によって手間がまるで違う」ということだけ 実感しておいてください.

ところで,探すほうはどうでしょうか. 線形探索は,最悪の場合 n 回比べます(n に比例). しかしすでに小さい順に並んでいるなら, まん中を見て「探すものはこれより前か後ろか」を判断し, 毎回範囲を半分にしていく方法が使えます(二分探索). 1000 件なら 10 回ほどで見つかります. 並べ替えておくと,探すのが速くなる. これが,並べ替えがこれほど重要視される理由の 1 つです.

17.11 この章で出てきた書き方

書き方意味
len(a)リストの要素数
a[i]i 番目の要素.番号は 0 から
a.append(x)末尾に x を加える
a.pop()末尾を取り出して取り除く
a.pop(0)先頭を取り出して取り除く
x in ax が入っていれば True
a.index(x)x が何番目にあるか.無ければエラー
a.count(x)x がいくつ入っているか
min(a) / max(a)最小値 / 最大値
sum(a)合計
a, b = b, a2 つの値を入れ替える
sorted(a)並べ替えた新しいリストを返す(a はそのまま)
a.sort()a 自身を並べ替える
a.sort(reverse=True)大きい順に並べ替える
a.reverse()a 自身を逆順にする
break繰り返しの途中で抜け出す
while 条件:条件が成り立つあいだ繰り返す

演習

出発点のプログラムを「▶ この演習を開く」で右側に読み込み, それを発展させて作品にしてください. できあがったら「⤓ 保存」でファイルに保存して,手元に残しておきましょう.

書く前に手順を決める まず日本語で手順を書いてからプログラムにしてください(17.1.3 節). 行きづまったら,紙にカードの絵を描いて,自分の手で動かしてみるのがいちばん速い近道です.
演習演習 17-1 10 枚のカードで遊ぶ

ウォーミングアップです.次の 3 つを,1 つのファイルの中で順に作ってください.

  1. 1 から 10 までの数が書かれたカードを 10 枚作り,横一列に並べて描く.
  2. 1 枚ずつ別の入れ物へ移し,移すたびにコンソールに「○ を移しました.残り △ 枚」と表示する.
  3. カードをかきまぜてから並べ直し,「先頭にある数は ○○ です」と表示する.(かきまぜるには random.shuffle(a) を使います)
ヒント range(1, 11) を使うと 1 から 10 までの数が作れます. list(range(1, 11)) と書けば,そのままリストになります.
演習演習 17-2 探すプログラム

カードの列を描き,入力された数がどこにあるかを探して, そのカードだけ色を変えて描き直すプログラムを作ってください.

  • 見つかったときは,そのカードを赤く塗り,コンソールに「○ は △ 番目にありました」と表示する.
  • 見つからなかったときは,「○ は入っていません」と表示する.
  • 見つかったらそこで探すのをやめること(break).
ヒント card() に色を渡せるように def card(x, y, n, col): と書き換えると楽になります. コンソールの入力行には 19 を入れてあります.書き換えて試してください.
演習演習 17-3 挿入法の並べ替えプログラム

挿入ソート(挿入法)で並べ替えるプログラムを, 途中経過が見えるように作ってください.

  1. まず,日本語で手順を書き出す(17.7 節の 5 つのステップを自分の言葉で).
  2. コンソールに 1 回ごとのリストを print する形でプログラムを作る.
  3. それができたら,棒グラフで 1 段ずつ描くように直す.
ヒント いきなり図を描こうとすると難しくなります. まず print だけで正しく並べ替えられることを確かめてから, 描く処理を足してください.これが確実な作り方です.
演習演習 17-4 アルゴリズムを改良しよう

17.6 節の選択ソートを,より良い手順に改良してください. 次のどれか 1 つ以上に取り組みます.

  1. 入れ替えを減らすi == k のとき(すでに正しい場所にあるとき)は,入れ替えを行わない.何回の入れ替えが省けたか数えて表示する.
  2. 比べた回数を数えるcount という変数を用意し,比べるたびに 1 増やす.17.10 節の n(n−1)/2 と一致するか確かめる.
  3. 両端から同時に.1 回のなかで最小値と最大値を同時に探し,最小値は前へ,最大値は後ろへ置く.繰り返しの回数が半分になる.
ヒント 改良したら,必ず元の手順と同じ答えになるかを確かめてください. 速くしたつもりで壊してしまう,というのはよくあることです. いくつかの違うデータ(すでに並んでいるもの,逆順のもの,同じ数が入っているもの) で試すのがコツです.
演習演習 17-5 身長の順に並ぶ

8 人の身長(cm)のリストがあります.これを小さい順に並べ替えて, 棒グラフで並べ替え前と後を見比べられるようにしてください.

  • 並べ替えは,自分で書いた選択ソートか挿入ソートを使うこと(sort() は使わない).
  • 上段に並べ替え前,下段に並べ替え後を描く.
  • いちばん高い人といちばん低い人の身長,および平均も表示する(sum(a) / len(a)).
  • 余裕があれば,棒の下に身長の数値を write で書き添える.
ヒント 身長は 150〜180 くらいの数なので,そのまま棒の高さにすると画面からはみ出ます. (h - 140) * 3 のように, 基準を引いてから倍率をかけると,違いが見やすくなります.
演習演習 17-6 名簿から探す

名前のリスト(名簿)から,キーボードで打ち込んだ文字を 名前の一部に含む人を全員探し出すプログラムを作ってください. ここまでの探索は「ぴったり同じ値」を探すものでしたが, 今度は一部だけ一致すればよいという探索です.

  • 文字列 b の中に文字列 a が含まれているかは,if a in b: で調べられる.
  • 見つかった人を全員コンソールに表示し,何人見つかったかを数えて「○ 件見つかりました」と出す.
  • 1 人も見つからなければ「見つかりませんでした」と出す.
  • 名簿をタートルで縦に並べて write し,見つかった人だけ色を変える
ヒント 今回は全員ぶん調べ切る必要があるので, break は使いません. 「見つかったらやめる」探索(17.3.2 節)と, 「全部数え上げる」探索の違いに気づいてください. コンソールの入力行には を入れてあります.書き換えて試してください.
自由演習 17-7 手順を見せる作品

自由制作です.アルゴリズムの動きが目で分かる作品を作ってください. テーマは自由ですが,たとえば次のようなものが考えられます.

  • 3 つのソート(選択・挿入・バブル)の途中経過を1 つの画面に並べて描き,進み方の違いを見くらべる.
  • シェーカーソートを自分で調べて実装する(バブルソートを,左から右・右から左と交互に往復させる方法です).
  • 棒グラフではなく,色の濃さ円の大きさでデータを表して並べ替える.
  • random で作ったばらばらのデータを並べ替え,毎回ちがう模様ができるようにする.
  • 二分探索(17.10 節)を実装し,見ている範囲がどんどん狭くなる様子を描く.
  • 並べ替えの途中を ontimerアニメーションにする(第 10 章の復習).
ヒント 先頭にコメントで,題名・氏名・日付・ 「どんな手順を,どう見せようとしたか」を書いてください. 絵の美しさよりも,手順が伝わるかを採点します.

まとめ

  • アルゴリズムとは,目的を達成するための手順.同じ結果を出す手順は 1 つではなく,良し悪しがある.
  • 線形探索は先頭から順に見ていく方法.見つかったら break で止める.
  • in は「あるか」,index() は「何番目か」.どちらも中身は線形探索.
  • 最小値は「いまのところの最小値」を覚えながら 1 つずつ比べて探す.並べ替えには値ではなく場所(番号)を覚える.
  • a, b = b, a で 2 つの値を入れ替えられる.
  • 選択ソートは,最小値を選んで前から確定させる方法.挿入ソートは,1 つずつ正しい場所に差し込む方法.バブルソートは,となりどうしを比べて入れ替え,後ろから確定させる方法.
  • sorted() は新しいリストを返し,sort() はその場で並べ替える.道具は使う.それでも中身が分かっていることが大事.
  • データが 2 倍になると,選択ソートの手間は約 4 倍になる.手順を変えるほうが,機械を速くするより効く.

次の回では,辞書を習います. リストは「0 番目・1 番目」と番号で引く入れ物でした. 辞書は「りんご・みかん」のように名前で引ける入れ物です. データに意味を持たせられるようになると,作れるものが一気に広がります.