リストとアルゴリズム
探す,並べ替える.手順を自分で組み立てる.
前の章,たくさんのデータを 1 つの名前でまとめて持つリストを習いました. 今回は,そのリストに対して「何かをする手順」を自分で組み立てます. 探す.いちばん小さいものを見つける.小さい順に並べ替える. どれも,やりたいことは一言で言えるのに, コンピュータにやらせようとすると手順に分解しなければなりません.
この「手順」のことをアルゴリズムといいます. プログラミングの本当のおもしろさは,ここから始まります. 命令の書き方を覚えることではなく, 「どうやったらできるか」を自分で考えて組み立てること. 今回はそのための最初の一歩です.
並べ替えは目に見えない処理なので,途中で何が起きているのか分かりにくいものです. そこで今回は,データの大きさを棒の高さで描いて, 並べ替わっていく様子をタートルに見せてもらいます.
- 「アルゴリズム」という言葉の意味を説明でき,同じ結果を出す手順が何通りもあることがわかる
- 線形探索(先頭から順に見る)を自分で書ける
in・index()と,自分で書いた探索との違いがわかる- リストの中の最小値と,その場所を見つけられる
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 回) |
# 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 つです.
- 正しいか.どんなデータを与えても正しい答えになるか.たまたまうまくいっただけ,では困ります.
- 速いか.手間(計算の回数)が少ないか.データが 10 個なら差は出ませんが,10 万個になると,1 秒で終わるか 3 時間かかるかの差になります(17.10 節).
- 読めるか.人間が読んで分かるか.直せるか.プログラムは書く時間より,読まれる時間のほうがずっと長いものです.
17.1.3 手順を先に書き出す
いきなり Python で書き始めると,たいてい途中で迷子になります. 先に日本語で手順を書き出すと,見通しがよくなります.
# 「リストの中からいちばん小さい数を見つける」手順 1. いまのところの最小値を,先頭の数だと思っておく 2. 2 番目から最後まで,1 つずつ順に見ていく 3. 見ている数が「いまのところの最小値」より小さければ, 4. 「いまのところの最小値」をその数に取り替える 5. 全部見終わったら,「いまのところの最小値」が答え
このように,プログラムの形を借りながら日本語で書いたものを 擬似(ぎじ)コードといいます. 古くからある方法としては,処理を箱と矢印でつないだ フローチャート(流れ図)もよく使われます. どちらでもかまいません.大事なのは 「書く前に手順を決める」という順番です.
17.2 カードを描く道具をつくる
並べ替えの話をする前に,リストの中身を目で見られるようにしておきます. 数字を書いたカードを横一列に並べて描く関数を作りましょう. トランプを机に並べるところを想像してください.
# リストの中身をカードの列として描く
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()
[5, 2, 9, 1, 7, 3] をカードの列として描いたもの.show(a, y) は,リストをそのまま受け取って,
中身を左から順に並べて描きます.
リストの中身が変わってから呼び直せば,変わった後の姿が描けます.
これから何度も使うので,覚えておいてください.
17.2.1 入れ物から入れ物へ 1 枚ずつ移す
並べ替えの前に,カードを 1 枚ずつ別の入れ物に移す練習をします. 並べ替えのアルゴリズムは,どれも結局「カードをどこへ移すか」の話だからです.
リストの先頭から 1 枚抜き取るには pop(0),
末尾に 1 枚加えるには append() を使います.
# 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()
while len(list1) > 0: は「入れ物 1 に 1 枚でも残っているあいだ繰り返す」
という意味です.中で pop(0) するたびにリストは 1 枚ずつ減るので,
いつかは 0 枚になり,繰り返しが終わります.
while を書くときは,
「いつ終わるのか」を必ず自分で言えるようにしてください.
上の例なら「pop するたびに 1 枚減るから,いつか 0 になる」です.
もし pop(0) を書き忘れると,条件がいつまでも成り立ち続けて,
プログラムは止まらなくなります(無限ループ).
この教材では while True: は使えません.ブラウザが固まるからです.
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)といいます. 「線のように一直線に見ていく」という意味です.
# リストの中から 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 です.
# 見つかったところで探すのをやめる
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 は,「入り口が 1 つ,出口が 1 つ」というきれいな処理の形
(構造化プログラミング)を崩すものなので,
昔は「行儀が悪い」と言われることもありました.
実際,break を使わなくても,変数と条件で同じ動きは書けます.
i = 0
while i < len(a) and a[i] != target: # 「まだ範囲内」かつ「まだ違う」あいだ
i = i + 1
break を使うのは,
むしろ読みやすい書き方だと考えられています.
大事なのは,どちらが読みやすいかを自分で判断することです.
17.3.3 できあいの道具 — in と index()
実は Python には,同じことをする道具がはじめから用意されています.
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 a | True / False | False が返る |
a.index(x) | 見つかった場所(0 から数えた番号) | エラー(ValueError)で止まる |
a.count(x) | いくつ入っているかの個数 | 0 が返る |
in は「あるか / ないか」だけ.index() は「何番目か」まで.
目的に合ったほうを選びます.
そして,どちらも中では線形探索をしています.
先頭から順に 1 つずつ比べているのです.書く手間が省けているだけで,手順は同じです.
リスト a の中から,入力された数を探して
「何番目にあるか」を表示するプログラムです.
____ を埋めて完成させてください.
見つかったらすぐに探すのをやめること.
19 が入れてあります.
別の数(たとえば 100)に書き換えて,
「入っていません」になることも確かめてください.
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 つずつしか見られないのです. ではどうするか.「いまのところ,いちばん小さかった数」を覚えておくのです.
- まず,先頭の数を「いまのところの最小値」だと思っておく.
- 2 番目から順に見ていく.
- 見ている数が「いまのところの最小値」より小さければ,取り替える.
- 最後まで見終わったら,「いまのところの最小値」が本当の最小値になっている.
# リストの中でいちばん小さい数を見つける
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] ではなく,
0 や 100 のような決め打ちの数にする
書き方を見かけます.これは危ういやり方です.
- 最大値を
m = 0から探すと,気温のようにマイナスがあるデータでは,答えがいつも0になってしまいます. - 最小値を
m = 100から探すと,101 点満点のテストが出てきたとたんに壊れます.
17.4.2 「場所」を覚えておく
並べ替えのためには,最小値そのものよりも 「それが何番目にあったか」のほうが必要です. 入れ替えるには場所が分からないといけないからです. 覚えておく変数を,値ではなく番号にします.
# いちばん小さい数が「何番目」にあるかを見つける
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 回で済みます.
今度はいちばん大きい数が何番目にあるかを探し, その棒だけを赤くして描くプログラムです. ____ を埋めてください. 最小値のときと,どこが変わるでしょうか.
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 行で書けます.
# 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)
a, b = b, a は,右側の b, a を
先にまとめて読み取ってから,左側へ配ります.
だから避難用の変数が要りません.
Python の書き方のなかでも,とくに気持ちのよいものの 1 つです.
リストを逆順にします. 両端どうし(0 番目と最後,1 番目と最後から 2 番目,……)を入れ替えていけば, 真ん中まで進んだところで逆順になります. ____ を埋めてください.
n // 2 回だけ繰り返せば十分です.
全部繰り返すと,入れ替えたものをもう一度入れ替えて元に戻ってしまいます.
できあがったら a.reverse() や a[::-1] でも
同じことができると知っておいてください.
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 つを組み合わせると,並べ替えができます.
考え方はこうです.
- 全体からいちばん小さいものを探し,0 番目と入れ替える.これで 0 番目は確定.
- 1 番目から後ろでいちばん小さいものを探し,1 番目と入れ替える.これで 1 番目も確定.
- 2 番目から後ろで……と,これを最後まで繰り返す.
この方法を選択ソート(セレクションソート,selection sort), 日本語では最小値選択法といいます. トランプを机に広げて並べ替えるとき,多くの人が自然にやる方法です.
17.6.1 1 ステップずつ見る
[5, 2, 9, 1, 7, 3] を並べ替えてみましょう.
各章,上の段が入れ替える前,下の段が入れ替えた後です.
オレンジは確定したところ,赤は見つけた最小値,
黄は入れ替える相手(その章の先頭)です.
17.6.2 プログラムにする
# 選択ソート(最小値選択法)
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 のときは何も変わらない |
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] # 見つけるたびに入れ替える
a[i], a[k] = a[k], a[i] の行にブレークポイントを置いて
F5 で止め,あとは F10 を押していけば,
i・k・a が 1 手ずつ変わっていく様子が,
17.6.1 節の図とそのままの順番で「変数」に出てきます.
17.6.3 並べ替えの途中を目で見る
さて,本題です.並べ替わっていく様子をタートルに描かせます. 1 回終わるごとに,そのときのリストを棒グラフにして 1 段ずつ下に描いていきます. いちばん上が並べ替える前,いちばん下ができあがりです.
絵に描かせるのは,あとから全体を見くらべるためです. これに対して,手元の VS Code のデバッガ(第 1 章 1.13 節)は, その場で 1 行ずつ止めて中を覗くためのものです. どちらも「途中を見る」道具ですが,向いている場面が違います.
# 選択ソートの途中経過を棒グラフで見る
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()
オレンジが左から 1 本ずつ増えていくのが見えます. これが選択ソートの動きです. 速さを「とてもゆっくり」にして実行すると,棒が 1 本ずつ描かれていくので, さらによく分かります.
a = [1, 2, 3, 4, 5, 6, 7, 8](すでに並んでいる)や
a = [8, 7, 6, 5, 4, 3, 2, 1](逆順)だとどうなるでしょうか.
選択ソートは,すでに並んでいても手を抜きません.
毎回きちんと後ろ全部を見て回ります.この性質は 17.7 節の挿入ソートと対照的です.
選択ソートを書き換えて,大きい順(降順)に並べ替えます. ____ を埋めてください. 変えるところは,たった 1 か所です.
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 枚を取り上げる.これを手に持っている札とする.
- 並んでいる札を右から左へ見ていき,手札より大きい札は1 つ右へずらす.
- ずらせなくなったところ(=手札より小さい札に出会ったところ)に手札を置く.
- これを最後の 1 枚まで繰り返す.
# 挿入ソート(挿入法)
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.11)と見比べて, 並び方の進み方がどう違うかを確かめてください.
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 つだけです.
- 左端から順に,となりあう 2 つを比べる.
- 左のほうが大きければ,入れ替える.
- これを右端まで続けると,いちばん大きいものが右端に落ち着く.
- 右端は確定したので,次はその手前までで同じことを繰り返す.
確定していく場所が,選択ソートとは逆だということに注目してください. 選択ソートは左から確定しましたが,バブルソートは右から確定していきます.
# バブルソート(隣接交換法)
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) がこの手順のかなめです.
j は a[j] と a[j + 1] の 2 つを見るので,
j は最後の 1 つ手前までしか進めません(だから n - 1).
さらに,ひと回り終わるごとに右端が 1 つずつ確定していくので,
見る範囲を i だけ狭められます.
1 がどう動いていくかを目で追ってみてください.
| 選択ソート | 挿入ソート | バブルソート | |
|---|---|---|---|
| 見るもの | 後ろ全部 | 左どなりから順に左へ | となりの 1 つだけ |
| 確定する側 | 左から | 左から | 右から |
| 入れ替えの回数 | 多くても n 回 | ずらす回数はデータ次第 | いちばん多い |
| すでに並んでいるとき | 変わらず全部調べる | ほとんど何もしない | 工夫すればひと回りで終われる |
| 書きやすさ | ふつう | やや難しい | いちばんやさしい |
バブルソートは,3 つのなかでいちばん書くのがやさしく, いちばん手間がかかる方法です. 実用のために使うことはまずありませんが, 「となりを比べて入れ替えるだけで並べ替えができてしまう」という驚きがあるので, アルゴリズムの入門では必ず出てきます.
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 # = もう並んでいるので終わり
break が,ここで効いてきます.
こうすると,すでに並んでいるデータはひと回りで終わります.
選択ソートには,この手が使えません
(毎回きちんと後ろ全部を見ないと,最小値が分からないからです).
手順を少し変えるだけで性質が変わる.これがアルゴリズムの面白さです.
バブルソートの途中経過を,1 回ごとに 1 段ずつ棒グラフで描きます. ____ を埋めてください. 確定するのは右側なので,色を塗る場所も右側になります.
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 が用意した道具を使います. 速くて,正しくて,短いからです.
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 には None が入ります.
sort() はその場で並べ替えるだけで,何も返さないからです.
新しいリストが欲しいときは sorted() を使ってください.
とてもよくある間違いです.
- 中で何が起きているか分かる.「データが 10 万件になったら時間がかかるかも」と想像できるようになります.
- 道具が無い場面に出会う.「点数の高い順,同点なら名前の五十音順」のような並べ替えは,条件を自分で組み立てなければ書けません.
- 手順を組み立てる力そのものが身につく.並べ替えはその練習台にすぎません.本当に鍛えているのは,問題を手順に分解する力です.
17.10 計算の手間 — データが 2 倍になると
選択ソートは,データが n 個のとき何回比べているでしょうか. 数えてみます.1 回目は n−1 回,2 回目は n−2 回,……,最後は 1 回. 全部足すと n(n−1)/2 回です.
| データの個数 n | 比べる回数(およそ) | n が 10 倍になると |
|---|---|---|
| 10 | 45 回 | — |
| 100 | 4,950 回 | 約 100 倍 |
| 1,000 | 499,500 回 | 約 100 倍 |
| 10,000 | 49,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 時間かかります. パソコンを速いものに買い替えても,せいぜい数倍にしかなりません. 手順を変えるほうが,機械を変えるよりずっと効きます.
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 a | x が入っていれば True |
a.index(x) | x が何番目にあるか.無ければエラー |
a.count(x) | x がいくつ入っているか |
min(a) / max(a) | 最小値 / 最大値 |
sum(a) | 合計 |
a, b = b, a | 2 つの値を入れ替える |
sorted(a) | 並べ替えた新しいリストを返す(a はそのまま) |
a.sort() | a 自身を並べ替える |
a.sort(reverse=True) | 大きい順に並べ替える |
a.reverse() | a 自身を逆順にする |
break | 繰り返しの途中で抜け出す |
while 条件: | 条件が成り立つあいだ繰り返す |
演習
出発点のプログラムを「▶ この演習を開く」で右側に読み込み, それを発展させて作品にしてください. できあがったら「⤓ 保存」でファイルに保存して,手元に残しておきましょう.
ウォーミングアップです.次の 3 つを,1 つのファイルの中で順に作ってください.
- 1 から 10 までの数が書かれたカードを 10 枚作り,横一列に並べて描く.
- 1 枚ずつ別の入れ物へ移し,移すたびにコンソールに「○ を移しました.残り △ 枚」と表示する.
- カードをかきまぜてから並べ直し,「先頭にある数は ○○ です」と表示する.(かきまぜるには
random.shuffle(a)を使います)
range(1, 11) を使うと 1 から 10 までの数が作れます.
list(range(1, 11)) と書けば,そのままリストになります.
カードの列を描き,入力された数がどこにあるかを探して, そのカードだけ色を変えて描き直すプログラムを作ってください.
- 見つかったときは,そのカードを赤く塗り,コンソールに「○ は △ 番目にありました」と表示する.
- 見つからなかったときは,「○ は入っていません」と表示する.
- 見つかったらそこで探すのをやめること(
break).
card() に色を渡せるように
def card(x, y, n, col): と書き換えると楽になります.
コンソールの入力行には 19 を入れてあります.書き換えて試してください.
挿入ソート(挿入法)で並べ替えるプログラムを, 途中経過が見えるように作ってください.
- まず,日本語で手順を書き出す(17.7 節の 5 つのステップを自分の言葉で).
- コンソールに 1 回ごとのリストを
printする形でプログラムを作る. - それができたら,棒グラフで 1 段ずつ描くように直す.
print だけで正しく並べ替えられることを確かめてから,
描く処理を足してください.これが確実な作り方です.
17.6 節の選択ソートを,より良い手順に改良してください. 次のどれか 1 つ以上に取り組みます.
- 入れ替えを減らす.
i == kのとき(すでに正しい場所にあるとき)は,入れ替えを行わない.何回の入れ替えが省けたか数えて表示する. - 比べた回数を数える.
countという変数を用意し,比べるたびに 1 増やす.17.10 節の n(n−1)/2 と一致するか確かめる. - 両端から同時に.1 回のなかで最小値と最大値を同時に探し,最小値は前へ,最大値は後ろへ置く.繰り返しの回数が半分になる.
8 人の身長(cm)のリストがあります.これを小さい順に並べ替えて, 棒グラフで並べ替え前と後を見比べられるようにしてください.
- 並べ替えは,自分で書いた選択ソートか挿入ソートを使うこと(
sort()は使わない). - 上段に並べ替え前,下段に並べ替え後を描く.
- いちばん高い人といちばん低い人の身長,および平均も表示する(
sum(a) / len(a)). - 余裕があれば,棒の下に身長の数値を
writeで書き添える.
(h - 140) * 3 のように,
基準を引いてから倍率をかけると,違いが見やすくなります.
名前のリスト(名簿)から,キーボードで打ち込んだ文字を 名前の一部に含む人を全員探し出すプログラムを作ってください. ここまでの探索は「ぴったり同じ値」を探すものでしたが, 今度は一部だけ一致すればよいという探索です.
- 文字列
bの中に文字列aが含まれているかは,if a in b:で調べられる. - 見つかった人を全員コンソールに表示し,何人見つかったかを数えて「○ 件見つかりました」と出す.
- 1 人も見つからなければ「見つかりませんでした」と出す.
- 名簿をタートルで縦に並べて
writeし,見つかった人だけ色を変える.
break は使いません.
「見つかったらやめる」探索(17.3.2 節)と,
「全部数え上げる」探索の違いに気づいてください.
コンソールの入力行には た を入れてあります.書き換えて試してください.
自由制作です.アルゴリズムの動きが目で分かる作品を作ってください. テーマは自由ですが,たとえば次のようなものが考えられます.
- 3 つのソート(選択・挿入・バブル)の途中経過を1 つの画面に並べて描き,進み方の違いを見くらべる.
- シェーカーソートを自分で調べて実装する(バブルソートを,左から右・右から左と交互に往復させる方法です).
- 棒グラフではなく,色の濃さや円の大きさでデータを表して並べ替える.
randomで作ったばらばらのデータを並べ替え,毎回ちがう模様ができるようにする.- 二分探索(17.10 節)を実装し,見ている範囲がどんどん狭くなる様子を描く.
- 並べ替えの途中を
ontimerでアニメーションにする(第 10 章の復習).
まとめ
- アルゴリズムとは,目的を達成するための手順.同じ結果を出す手順は 1 つではなく,良し悪しがある.
- 線形探索は先頭から順に見ていく方法.見つかったら
breakで止める. inは「あるか」,index()は「何番目か」.どちらも中身は線形探索.- 最小値は「いまのところの最小値」を覚えながら 1 つずつ比べて探す.並べ替えには値ではなく場所(番号)を覚える.
a, b = b, aで 2 つの値を入れ替えられる.- 選択ソートは,最小値を選んで前から確定させる方法.挿入ソートは,1 つずつ正しい場所に差し込む方法.バブルソートは,となりどうしを比べて入れ替え,後ろから確定させる方法.
sorted()は新しいリストを返し,sort()はその場で並べ替える.道具は使う.それでも中身が分かっていることが大事.- データが 2 倍になると,選択ソートの手間は約 4 倍になる.手順を変えるほうが,機械を速くするより効く.
次の回では,辞書を習います. リストは「0 番目・1 番目」と番号で引く入れ物でした. 辞書は「りんご・みかん」のように名前で引ける入れ物です. データに意味を持たせられるようになると,作れるものが一気に広がります.