モジュール、エラーとアルゴリズム#
# 警告メッセージを非表示
import warnings
warnings.filterwarnings("ignore")
モジュール、パッケージ、そしてライブラリー#
説明#
Pythonには組み込み関数が多く用意されているが,Jupyter NotebookでPython
のセッションを開始しても全ての関数が使える状態になっていない。使えるようにするためには,モジュール(modules)、パッケージ(package)、ライブラリー(library)と呼ばれるものを読み込む必要がある。違いを簡単に説明すると次のようになる。
モジュール(module)は1つのファイル(拡張子が
.py
)にまとめられた関数群パッケージは(package)複数つのファイル(拡張子が
.py
)で構成(フォルダーにまとめられている)ライブラリー(library)は複数のパッケージをまとめたもの
従って,モジュール全体を読み込んだり,あるパッケージの1つのモジュールだけを読み込むということも可能である。ここでは例として高速計算用のnumpy
モジュールとランダム変数生成用のrandom
モジュールを取り上げる。
含まれる関数を使うためにはimport
を使って読み込む必要がある。モジュールの全てを読み込むとモジュール内の全ての関数が使用可能となる。
numpy
ライブラリー#
numpy
はnumerical pythonの略称であり、高速計算や数学に関する関数が多く含まれ、データサイエンスには欠かせないライブラリーである(詳細はこのリンクを参照)。numpy
は次のコードで読み込むことができる。
import numpy
最初の例として平方根を計算してみよう。使う関数名は,square rootの略となるsqrt()
。
numpy.sqrt(4)
2.0
numpy.sqrt
とは「numpy
モジュールのsqrt
」という意味であり,numpy
をつけるのは他のモジュールの関数sqrt
とバッティングしないようにするためである。
モジュール名が長い場合は,as
(「〜として」の意味)を使い短い名前で読み込むことも可能である。numpy
の場合はnp
としてインポートするのが慣例である。
import numpy as np
np.sqrt(9)
3.0
特定の関数だけを読み込むことも可能である。
from numpy import sqrt, log # logは自然対数で, sqrtの両方を読み込む
このコードは「numpy
からsqrt
とlog
を読み込む」と読むことができる。
sqrt(10), log(10)
(3.1622776601683795, 2.302585092994046)
ただ、この場合は他のモジュール、パッケージやライブラリー、もしくは自分が定義した関数とバッティングする可能性があるため注意が必要だ。よく使われる関数名をインポートする場合は、避けた方が良いだろう。
random
モジュール#
random
モジュールを使うことにより乱数を発生させることができる(詳細はこのリンクを参照)。まずrandom
をインポートしよう。
import random
様々な関数が用意されているが,その中のrandint()
関数は,引数で指定するランダムな整数を返す。
第1引数:最小値(整数型)
第2引数:最大値(整数型)
次のコードは\([1,10]\)の間の整数を返す。1
と10
は戻り値に含まれることに注意しよう。
random.randint(1, 10)
では実際にコードを実行してみよう。次のコードはサイコロの目(1~6)を1つを返す。
random.randint(1,6)
3
内包表記を使って10回サイコロを振った結果を表示してみよう。
[random.randint(1,6) for _ in range(10)]
[4, 4, 1, 3, 1, 3, 5, 6, 2, 3]
Note
内包表記に_
(アンダースコア)を使っている。i
やj
を使っても良いが,回数を数える以外に役目はない。その様な場合に良く使われるのが_
である。for
ループでも同じことが言える。次のコードが例として挙げられる。
for _ in range(5):
print('hello')
2行目に_
は入っていないので,ループの回数を数える以外の役割はない。
次に,gauss()
を紹介する。この関数は,正規分布から生成されたランダム変数を1つ返す。引数は次の様になっている。
第1引数:平均値
第2引数:標準偏差
従って,次のコードは標準正規分布からのランダム変数を生成する。
random.gauss(0, 1)
関数名がgauss
なのは,正規分布はガウス分布とも呼ばれるためである。
次のコードは,平均10
,標準偏差2
からランダム変数を生成している。
random.gauss(10,2)
10.23208523696136
内包表記を使って,標準正規分布から生成された10
のランダム変数からなるリストを作成しよう。
[random.gauss(0,1) for _ in range(10)]
[0.7263351527428119,
-0.40469745277424474,
-1.1399227870719957,
0.9908606440624688,
-0.9984899665221708,
1.2893285574208313,
-0.313706975965069,
0.1419547819253785,
0.41905248557292785,
0.4917713978652062]
エラー#
エラー(Errors)は次の2つに分けられる。
構文エラー(Syntax Errors)
構文自体が間違っている場合に発生するエラー(例えば,スペル間違い)。
例外(Exceptoins)
構文は間違っていなくてもコードの実行中に発生するエラー(例えば,数字を
0
で割る)
Hint
エラー・メッセージにエラーの理由のヒントがある。はじめは意味が分からないかも知れないが,パターンがあるので慣れると直ぐに理解できるケースも多くあるだろう。
例外の場合,最初にエラーが発生するとそれに付随して他の場所でもエラーが誘発される場合がある。
Python
はエラーを追跡し,最後に確認したエラーをメッセージの一番最後に表示する。最初は、一番最後のエラー・メッセージを確認しよう!
構文エラー#
例1#
(正)sum
(誤)sun
sun([1,2,3])
---------------------------------------------------------------------------
NameError Traceback (most recent call last)
Cell In[13], line 1
----> 1 sun([1,2,3])
NameError: name 'sun' is not defined
矢印(—->)でエラー箇所が示されている。
NameError
として最終行にスペル間違いであるsun
が示されている。name 'sun' is not defined
とは「sun
という名前(変数のこと)は上で定義されていない」という意味。
例2#
if
文の最初の行の終わりに:
が抜けている。
a = 3
if a > 0
print(a)
Cell In[14], line 3
if a > 0
^
SyntaxError: expected ':'
line 3
はセル内の3行目(if
の行)を示している。SyntaxError
として3行目の最後で:
が足りない箇所を^
で指し示している。expected ':'
とは「:
があるはず」という意味。
例3#
括弧を閉じていない。
2 / (10.1 + 3.2 + 1
Cell In[15], line 1
2 / (10.1 + 3.2 + 1
^
SyntaxError: incomplete input
Python
はプログラマーがどこに)
を入れようとしたかは分からない。従って,最後に)
が入ると想定して^
を文末に置いている。
incomplete input
とは「不完全なインプット(コード)」という意味。
例外#
例1#
0
が分母にある。
3 + 1 / 0
---------------------------------------------------------------------------
ZeroDivisionError Traceback (most recent call last)
Cell In[16], line 1
----> 1 3 + 1 / 0
ZeroDivisionError: division by zero
division by zero
とは「0
で除している」という意味。
例2#
定義されていない変数yy
が使われている。
1.5 + yy * 2
---------------------------------------------------------------------------
NameError Traceback (most recent call last)
Cell In[17], line 1
----> 1 1.5 + yy * 2
NameError: name 'yy' is not defined
name 'yy' is not defined
とは「yy
という名前(変数)は定義されていない」という意味。
例3#
整数型と文字列を足している。
10 + '3'
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[18], line 1
----> 1 10 + '3'
TypeError: unsupported operand type(s) for +: 'int' and 'str'
unsupported operand type(s) for +: 'int' and 'str'
とは「+
に使用不可のデータ型が使われている:整数型と文字列型」という意味。
アルゴリズム#
コンピューターは0
と1
で書かれている機械語しか理解できないが,プログラマーにとって機械語は非常に難解である。つまりプログラマーとコンピューターは直接「会話」ができない。この問題を解決するために,プログラミング言語が開発され「通訳・翻訳」の役割を担っている。Python
を使う場合は,Python
コードを書き,それをPython
が機械語に「通訳」し,コンピューターが命令を実行することになる。結果が表示されると,プログラマーは内容を確認し,コードを追加・修正などをおこない開発が進んでいく。Jupyter Notebookでコードを書く場合でも同じである。
+--------------+ Pythonが「通訳」 +-------+ | Pythonコード | ▷ ▷ ▷ ▷ ▷ ▷ ▷ ▷ ▷ | 機械語 | +--------------+ +-------+ ▲ ▽ ▲ ▽ ▲ アルゴリズム ▽ 実 ▲ 疑似コード ▽ 行 ▲ ▽ ▲ ▽ +-------------+ 内容を確認 +-----+ | プログラマー | ◀︎ ◀︎ ◀︎ ◀︎ ◀︎ ◀︎ ◀︎ ◀︎ ◀︎ ◀︎ | 結果 | +-------------+ +-----+ ▶︎:プログラマーがおこなう ▷:コンピューターがおこなう
このサイクルの中でアルゴリズムは,プログラマーがPython
コードを書く際に重要な役割を果たす。日本産業規格(JIS X0001)は次のように定義している。
問題を解くためのものであって,明確に定義され,順序付けられた有限個の規則からなる集合
平たく言えば,コンピューターが実行すると正しい結果が保証された計算手続である。更に,その計算手続きはどのプログラミング言語でも使える問題解決の明確な考え方である。
例えば,カレーを作るとしよう。アルゴリズムはその場合のレシピである。ある程度料理ができる人であれば,書かれている手順に沿って作れば美味しいカレーが出来上がるレシピがアルゴリズムである。様々な問題には異なるアルゴリズムがある。また1つの問題には複数のアルゴリズムが存在する。カレーでも色々なレシピがあるのと同じである。
以下では,比較的に簡単であり,且つ代表的な4つのアルゴリズムについて解説する。また,アルゴリズムによって計算速度が異なり,その理由についても考えることにする。
例1:合計#
次のコードは標準正規分布から抽出した100万個のランダム変数から構成されるリストを作成する。
lst1 = [random.gauss(0,1) for _ in range(10**6)]
このリストの値を合計するためのアルゴリズムを考える。
for
ループ#
まずfor
ループを使うアルゴリズムを考えてみよう。
def sum_for_loop(lst): #1
x = 0 #2
for i in lst: #3
x += i #4
return x #5
<コードの説明>
#1
: 引数はイタラブルであるリストを想定している。#2
:for
ループではlst
の要素を1つずつx
に加算することになるが,その初期値を0
とする。#3
:for
ループの開始#4
: 累積加算演算子+=
を使い要素をx
に加算する。#5
:for
ループ終了後にlst
の要素の合計であるx
を返す。
実際に計算してみよう。
sum_for_loop(lst1)
55.35202158598987
while
ループ#
次にwhile
ループを使う場合のコードを考えてみよう。
def sum_while_loop(lst): #1
x = 0 #2
idx = 0 #2a
while idx < len(lst): #3
x += lst[idx] #4
idx += 1 #4a
return x #5
<コードの説明>
#1
: 引数はイタラブルであるリストを想定している。#2
:for
ループのx
と同じ役目をする。#2a
:while
ループでは,lst
の要素を抽出する際にインデックス番号を使うことになるが,idx
(indexの略)はインデックス番号を捉えている。lst
の最初の項から抽出するために初期値を0
としている。#3
:while
ループが開始され,idx<len(lst)
がFalse
となる時点でループは終了する。例として,lst=[0,10,20]
としてみよう。len(lst)
は3
を返すことになり,idx
が3
になるとidx<len(lst)
がFalse
となるため,その時点で(#5
以下は実行されずに)while
ループは終了する。#4
:lst[idx]
ではlst
のidx
番目の要素を抽出し,それを+=
を使いx
に加算する。#4a
: 次のループでlst
の次の要素を抽出できるようにidx
を1
増やしwhile
ループを継続する。#5
:for
ループ終了後にlst
の要素の合計であるx
を返す。
実際に計算してみよう。
sum_while_loop(lst1)
55.35202158598987
for
ループと同じ値となっていることが確認できる。
実行スピード比較#
次に2つのコードの終了までの時間を比較してみよう。ここではマジック・コマンドと呼ばれる%timeit
を使うが,使い方は簡単で行頭に%timeit
と半角スペースを置いて実行コードを書くだけである。まずsum_for_loop()
関数を実行してみよう。
%timeit sum_for_loop(lst1)
13.6 ms ± 69.2 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
表示される結果は,使うPCのスペックなどによって若干異なることになる。 結果の解釈方法を説明するため,次のような結果になったとしよう。
MMM ms ± NNN µs per loop (mean ± std. dev. of YYY runs, XXX loops each)
次のように解釈する。
コードを
XXX
回実行する試行をYYY
回おこない(合計でXXX
\(\times\)YYY
回),その結果の平均であるmean
はMMM ms
であり,標準偏差であるstd. dev.
はNNN µs
である。ここでms
:ミリ秒(1/1000秒)µs
:マイクロ秒(1/1,000,000秒)ns
:ナノ秒(1/1,000,000,000秒; 10億分の1秒)
試行する度に結果が異り,その変動を標準偏差で捉えている。結果は正規分布に従うと仮定すると,約68%の結果が平均\(\pm\)標準偏差の範囲内に収まることになる。
次にsum_while_loop()
の速度を計測してみよう。
%timeit sum_while_loop(lst1)
49.3 ms ± 715 μs per loop (mean ± std. dev. of 7 runs, 10 loops each)
while
ループに比べてfor
ループが約4倍速いことが確認できる。リストから要素を一つずつ抽出し合計するというアルゴリズムでも,コードにどのように表現するかによって速度に大きな違いが発生している。
では,なぜそのような大きな違いがあるのだろうか。それは次の2つの違いに起因する。
sum_for_loop()
とsum_while_loop()
の#3
を考えてみよう。前者の場合,ループ毎にlst
の要素をi
に割り当てることを行なっている。一方,後者では,ループ毎にidx<len(lst)
の条件が満たされているかどうかを確認する必要がある。sum_while_loop()
では#2a
と#4a
が追加されており,「割り当て」と言う意味ではsum_for_loop()
の#3
と同じである。
従って,while
ループにはidx<len(lst)
がTrue
なのかFalse
なのかの確認が追加作業となり,それが時間が掛かる要因となっている。
最後に,組み込み関数であるsum()
の速度を計測してみよう。
%timeit sum(lst1)
2.55 ms ± 3.69 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
sum_while_loop()
関数よりも6倍以上も速いことが分かる。組み込み関数は様々なトリックを使い最適化されていることが伺える。
Hint
複数行にまたがるコードの実行スピードを計測する場合は,下の例のように%%timeit
を1行目に置き,2行目以降にコードを書くと良いだろう。
%%timeit
sum_while_loop(lst1)
print('Pythonは楽しいね(^o^)/')
例2:値の探索#
次のコードは0
から999,999
までの整数が並ぶリストを作成する。
lst2 = list(range(1_000_000))
ここで考えるのは,999,000
がlst2
にあるかどうかを確かめることである。具体的には,lst2
の最初の要素から一つずつ確認し
見つかれば
1
を返す(探索は途中で終了)見つからなければ
-1
を返す(最後の要素まで確認する)
関数を考える。
for
ループ#
for
ループを使うコードを考えよう。
def search_for_loop(target, lst): #1
for i in lst: #2
if i == target: #3
return 1 #4
return -1 #5
<コードの説明>
#1
: 引数のtarget
は探索する数字であり,lst
はイタラブルであるリストを想定している。#2
:for
ループの開始#3
〜#4
:i
がtarget
と等しければ1
を返し,for
ループは終了する。#4
が実行されると,関数の実行は終わる。これはreturn
には,「関数の実行を終了させる」役割があるため。
#5
:#4
の実行されずにfor
ループ終了した場合にのみ実行され-1
が返される。即ち,lst
の最後まで探索し,target
がない場合に実行される行である。
実際に計算してみよう。
search_for_loop(999_000, lst2)
1
while
ループ#
次にwhile
ループを使う場合を考えてみよう。
def search_while_loop(target, lst): #1
idx = 0 #2
while True: #3
if idx == len(lst): #4
return -1 #5
elif lst[idx] == target: #6
return 1 #7
idx += 1 #8
<コードの説明>
#1
: 引数のtarget
は探索する数字であり,lst
はイタラブルであるリストを想定している。#2
:while
ループでは,lst
の要素を抽出する際にインデックス番号を使うことになるが,idx
(indexの略)はインデックス番号を捉えている。#4
と#6
で使う。lst
の最初の項から抽出するために初期値を0
としている。#3
:while
ループの開始。#5
もしくは#7
が実行されるまでwhile
ループは継続する。#4
〜#5
:#4
の条件はlst
の最後の要素まで確認した場合にのみ満たされる。その場合,-1
が返される。#5
が実行されると,関数の実行は終わる。これはreturn
には,「関数の実行を終了させる」役割があるため。
#6
〜#7
:lst
のidx
番目の要素とtarget
が等しい場合に1
が返される。その時点で関数の実行(while
ループも含めて)は終了する。#7
が実行されると,関数の実行は終わる。これはreturn
には,「関数の実行を終了させる」役割があるため。
#8
:#4
と#5
の条件がFalse
であれば,idx
を1
増加させてwhile
ループを継続する。
実際に計算してみよう。
search_while_loop(999_000, lst2)
1
search_for_loop()
関数とsearch_while_loop()
関数の実行スピードを比較してみよう。
%timeit search_for_loop(999_000, lst2)
8.76 ms ± 9.81 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit search_while_loop(999_000, lst2)
46 ms ± 828 μs per loop (mean ± std. dev. of 7 runs, 10 loops each)
for
ループよりwhile
ループは4倍以上遅いことがわかる。もう理由は明らかであろう。search_linear_for()
関数ではループ一回毎に1つのif
文の条件#3
を確認する必要があるが,search_linear_while()
では条件が#4
と#6
の2つがある。この確認作業の回数の差が数字として現れている。このことから分かることは,while
ループであっても条件を1つにすれば速度が倍になると言うことである。それを実現するのが次に考える番兵法と呼ばれるアルゴリズムである。
番兵法#
番兵法はwhile
ループを使うが,次の手続きが必要となる。
リストの最後に探索する値を追加する
search_while_loop()
関数との違いはこの点だけである。これにより,実質的にはsearch_linear_while()
の条件#4
が不要になり実行スピードの高速化される。コードを確認してみよう。
def search_sentinel(target, lst): #1
size = len(lst) #2
lst.append(target) #3
idx = 0 #4
while True: #5
if lst[idx] == target: #6
break #7
idx += 1 #8
if idx < size: #9
return 1 #10
else: #11
return -1 #12
<コードの説明>
#1
: 引数のtarget
は探索する数字であり,lst
はイタラブルであるリストを想定している。#2
:lst
の要素数をsize
に割り当てる。size
は#9
で使うことになる。#3
:lst
の最後にtarget
を追加する。これが「番兵」である。#4
:lst
の要素を抽出する際にインデックス番号を使うことになるが,idx
(indexの略)はインデックス番号を捉えている。#6
,#8
,#9
で使う。lst
の最初の項から抽出するために初期値を0
としている。#5
:while
ループの開始。#6
〜#7
:lst
のidx
番目の要素とtarget
が等しい場合はbreak
を発動させwhile
を中断する。等しくない場合は#7
を実行せずに#8
でidx
を1
増加さる。#3
でtarget
(番兵)がlst
に追加されたので,lst
にtarget
がなくても#6
は必ず満たされてbreak
が発動される。これが番兵の役割であり,高速化のための仕込みとなる。#9
〜#12
:while
ループが中断された後に実行される。#9
の条件がTrue
の場合はlst
にtarget
が含まれたと言う意味になるため1
を返す。一方,#9
の条件がFalse
の場合(即ち,idx
とsize
は等しい場合),idx
は番兵のインデックスとなるため-1
を返す。
<break
とreturn
の違い>
break
はループを終わらせるだけであり,関数の実行は続くことになる。既出の
search_for_loop()
関数やsearch_while_loop()
関数で明らか様に,return
は関数の評価を終了させる役割がある。従って,ループ計算が最後まで終わっていなくても,return
が実行されると関数の実行が終わることにより,その時点でループ計算は終了することになる。
実際に計算してみよう。
search_sentinel(999_000, lst2)
1
実行スピードを計測してみよう。
%timeit search_sentinel(999_000, lst2)
25.4 ms ± 268 μs per loop (mean ± std. dev. of 7 runs, 10 loops each)
search_while_loop()
関数と比べると約2倍高速化できた。
組み込み関数#
組み込み関数に1
もしくは-1
を返す関数は見当たらないため,類似するメソッドを考えてみよう。リストには.index()
というメソッドが用意されており,引数に要素を指定すると,その要素のインデックス番号を返す。試してみよう。
lst2.index(999_000)
999000
999_000
はlst2
の999000
番目の要素だという意味。実行速度を計測してみよう。
%timeit lst2.index(999_000)
5.75 ms ± 33.2 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
search_for_loop()
関数よりも約2倍速い。最適化されていることが伺える。
例3:最小値の探索#
次のコードは標準正規分布から抽出した100万個のランダム変数から構成されるリストを作成する。
lst3 = [random.gauss(0,1) for _ in range(1_000_000)]
この中から最小値を探索するアルゴリズムを考えてみよう。lst3
の全ての要素を確認する必要があるのでfor
ループを使おう。
直接的な探索#
まず,リストの最小値を直接探索するコードを考えてみる。
def search_min(lst): #1
min_ = lst[0] #2
for v in lst[1:]: #3
if v < min_: #4
min_ = v #5
return min_ #6
<コードの説明>
#1
: 引数lst
はイタラブルであるリストを想定している。#2
:lst
の0
番目の値をmin_
に割り当てる。#3
のfor
ループで更に小さな値が発見されれば,min_
の値と入れ替えることにする。#3
:lst
に対するfor
ループの開始。#2
で0
番目の要素がmin_
に割り当てられているので,for
ループは1
番目の要素から始める。
#4
〜#5
:v
がmin_
より小さければmin_
に割り当てる。そうでなければ,min_
の値は変えない。#6
:for
ループが終わった後(即ち,lst
の値を全て確認した後),min_
の値を返す。
実際に計算してみよう。
search_min(lst3)
-4.702758935119593
間接的な探索#
リストの要素はインデックス番号を使ってアクセスすることができる。それを利用して,上のコードをインデックス番号を使って間接的に要素にアクセスする場合を考える。即ち,最小値のインデックスを探し,最終的に最小値を返す関数となる。
def search_min_idx(lst): #1
n_lst = len(lst) #2
idx_min = 0 #3
for idx in range(1,n_lst): #4
if lst[idx] < lst[idx_min]: #5
idx_min = idx #6
return lst[idx_min] #7
<コードの説明>
#1
: 引数lst
はイタラブルであるリストを想定している。#2
:lst
の要素数をn_lst
に割り当てる。n_lst
は#4
で使うことになる。#3
:idx_min
は最小値の要素のインデックス番号を割り当てる変数として使う。0
番目の要素から考えるので,初期値として0
を設定している。#4
:range(1,n_lst)
に対するfor
ループの開始。#3
でインデック0
がidx_min
に割り当てられているので,for
ループはインデックス1
から始める。
#5
〜#6
:lst[idx_min]
は,その時点で最小値となる値である。その値よりlst[idx]
が小さい場合,idx
をidx_min
に割り当てることによりidx_min
のインデックス番号を更新する。そうでない場合はidx_min
の値は変更しない。 がmin_
より小さければmin_
に割り当てる。そうでなければ,min_
の値は変えない。#7
:for
ループが終わった後(即ち,lst
の値を全て確認した後),idx_min
に対応する要素を返す。
実際に計算してみよう。
search_min_idx(lst3)
-4.702758935119593
実行スピード比較#
それぞれのコードの実行速度を比べてみよう。
%timeit search_min(lst3)
11.1 ms ± 88.7 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit search_min_idx(lst3)
24.2 ms ± 72.4 μs per loop (mean ± std. dev. of 7 runs, 10 loops each)
間接的にインデックス番号を使うと実行速度を減速させることになる。
次に組み込み関数であるmin()
と比べてみよう。
min(lst3)
-4.702758935119593
%timeit min(lst3)
7.06 ms ± 26.7 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
直接的な方法より2倍以上高速実行されている。
例4:ソート#
次のコードは0
から999
までの整数をランダムに並べたリストを作成する。
lst4 = [random.randint(0,1000) for _ in range(1_000)]
このリストを昇順に並び替えるコードを考える。
単純選択ソート#
最初に考えるアルゴリズムである単純選択ソートは次のような考え方に基づいている。
リストには0
番目からn-1
番目の要素があるとしよう。
要素の数は
n
n>1
とする。
アルゴリズム(説明をより明確になるようにループを0
回目から数える。)
0
回目のループ(外側ループ)0
番目から最後までの要素の最小値を見つける(内側ループ)最小値を
0
番目の要素と入れ替える(値の入れ替え)
1
回目のループ(外側ループ)1
番目から最後までの要素の最小値を見つける(内側ループ)最小値を
1
番目の要素と入れ替える(値の入れ替え)
2
回目のループ(外側ループ)2
番目から最後までの要素の最小値を見つける(内側ループ)最小値を
2
番目の要素と入れ替える(値の入れ替え)
……
n-2
回目のループ(外側ループ)n-2
番目から最後までの要素の最小値を見つける(内側ループ)最小値を
n-2
番目の要素と入れ替える(値の入れ替え)
(注意)n-1
回目のループは必要ない。
この手順に基づくコードを確認しよう。
def insertion_sort(lst): #1
n = len(lst) #2
for i in range(n-1): #3
i_min = i #4
for j in range(i+1,n): #5
if lst[j] < lst[i_min]: #6
i_min = j #7
lst[i], lst[i_min] = lst[i_min], lst[i] #8
return lst #9
<コードの説明>
#1
: 引数lst
はイタラブルであるリストを想定している。#2
:lst
の要素数をn
に割り当てる。n
は#3
と#5
で使うことになる。#3
: 上の説明にある「外側ループ」に対応している。合計でn-2
回のループ計算をおこなう(最後のインデックス番号であるn-1
を使ったループを考える必要はないため)。#4
:i_min
は#5
のfor
ループ(上の説明にある「内側ループ」)で見つかる最小値を割り当てる変数のインデックス番号として使う。#5
: 上の説明にある「内側ループ」に対応している。i
を所与として,i+1
からn-1
番目までのループとなる(最小値を探索するためには最後まで確認する必要があるため)。#6
〜#7
:lst[j]
がlst[i_min]
より小さければj
をi_min
に割り当てる。そうでなければ,i_min
の値は変えない。#8
: 上の説明にある「値を入れ替え」に対応している。#5
の内側ループが終わった後(即ち,i+1
からlst
の全ての値を確認した後),lst[i]
とlst[i_min]
入れ替えている。#9
: 外側ループが終わった後,lst
を返す。
コードを実行し,最初の10
の要素を表示してみよう。
res = insertion_sort(lst4)
res[:10]
[0, 0, 0, 2, 2, 4, 6, 8, 10, 10]
最後の10
の要素を表示してみよう。
res[990:]
[980, 981, 981, 985, 986, 989, 992, 994, 996, 998]
昇順に並んでいることが確認できる。
バブル・ソート#
次にバブル・ソートと呼ばれるアルゴリズムを考えてみよう。手順は次の内容となる。
リストには0
番目からn-1
番目の要素があるとしよう。
要素の数は
n
n>1
とする。
アルゴリズム(ここではループを1
回目から数える。)
1
回目のループ(外側ループ)(以下の手順が内側ループ)
n-2
番目とn-1
番目(最後)の要素を比べる小さい方を左に,大きい方を右に入れ替える
同じであれば何もしない
n-3
番目とn-2
番目の要素を比べる小さい方を左に,大きい方を右に入れ替える
同じであれば何もしない
……
1
番目と2
番目の要素を比べる小さい方を左に,大きい方を右に入れ替える
同じであれば何もしない
0
番目と1
番目の要素を比べる小さい方を左に,大きい方を右に入れ替える
同じであれば何もしない
これで
0
番目の要素は0
番目からn-1
番目の要素の最小値となる。
2
回目のループ(外側ループ)以下の手順が内側ループ
n-2
番目とn-1
番目の要素を比べる小さい方を左に,大きい方を右に入れ替える
同じであれば何もしない
n-3
番目とn-2
番目の要素を比べる小さい方を左に,大きい方を右に入れ替える
同じであれば何もしない
……
2
番目と3
番目の要素を比べる小さい方を左に,大きい方を右に入れ替える
同じであれば何もしない
1
番目と2
番目の要素を比べる小さい方を左に,大きい方を右に入れ替える
同じであれば何もしない
これで
1
番目の要素は1
番目からn-1
番目の要素の最小値となる。
……
n-2
回目のループ(外側ループ)以下の手順が内側ループ
n-2
番目とn-1
番目の要素を比べる小さい方を左に,大きい方を右に入れ替える
同じであれば何もしない
n-3
番目とn-2
番目の要素を比べる小さい方を左に,大きい方を右に入れ替える
同じであれば何もしない
これで
n-3
番目の要素はn-3
番目からn-1
番目の要素の最小値となる。
n-1
回目のループ(外側ループ)以下の手順が内側ループ
n-2
番目とn-1
番目の要素を比べる小さい方を左に,大きい方を右に入れ替える
同じであれば何もしない
これで
n-2
番目の要素はn-2
番目からn-1
番目の要素の最小値となる。
完成!
def bubble_sort(lst): #1
n = len(lst) #2
for i in range(n-1): #3
for j in range(n-1, i, -1): #4
if lst[j-1] > lst[j]: #5
lst[j-1], lst[j] = lst[j], lst[j-1] #6
return lst #7
<コードの説明>
#1
: 引数lst
はイタラブルであるリストを想定している。#2
:lst
の要素数をn
に割り当てる。n
は#3
と#4
で使うことになる。#3
: 上の説明にある「外側ループ」に対応している。合計でn-1
回のループ計算をおこない,lst
の全ての要素インデックスに対応している。#4
: 上の説明にある「内側ループ」に対応している。range(n-1, i, -1)
はi+1
からn-1
までの番号を逆順に並べている。例えば,list(range(10, 5, -1))
は[10,9,8,7,6]
を返す。これは#5
と#6
に置いて最後の要素から始まってからi+1
番目の要素までを順に考える必要があるため。#5
〜#6
:lst[j-1]>lst[j]
とは左の要素が右の要素より大きい場合にTrue
を返し,その場合,#6
では左右の要素を入れ替えている。#6
は上の説明の2つの要素の入れ替えに対応している。#7
: 外側ループが終わった後,lst
を返す。
コードを実行し,最初の10
の要素を表示してみよう。
res = bubble_sort(lst4)
res[:10]
[0, 0, 0, 2, 2, 4, 6, 8, 10, 10]
最後の10
の要素を表示してみよう。
res[990:]
[980, 981, 981, 985, 986, 989, 992, 994, 996, 998]
実行スピード比較#
それぞれの実行スピードを比較してみよう。
%timeit insertion_sort(lst4)
11.8 ms ± 94.4 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit bubble_sort(lst4)
17.5 ms ± 19.7 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
スピードの違いの一つの要因は「値の入れ替え」の回数の差である。
insertion_sort()
の場合,#8
で値の入れ替えがおこなわれており,最大で\(n-1\)回となる。
一方,bubble_sort()
の値の入れ替えは#6
でおこなわれており,最大で\(n^2/2\)回となる。
\(n>2\)の場合,\(n^2/2>n-1\)となるため,実行時間の差に現れていると考えられる。
最後に組み込み関数sorted()
と比較してみよう。
%timeit sorted(lst4)
3.12 μs ± 3.68 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
平均の単位はms
ではなくµs
となっている。やはり組み込み関数は速い!