第8回 アルゴリズム: 再帰#


Open In Colab


この授業で学ぶこと#

アルゴリズムとは、ある問題についての解を求めるための計算手順のことである。

これまでPythonに既に用意された関数を利用してきたが、今回はまず、新しく関数を定義する方法を学ぶ。 また、アルゴリズムの一つとして、関数を反復的に呼び出すことで繰り返しを実現する手法である再帰について学ぶ。

関数#

関数の定義#

第5回で作成したBMIの計算プログラムを例に、関数を作成してみよう。関数はdef文により作成することができる。

def bmi(a, b):   # aは身長[cm], bは体重[kg]
    return 10000 * b / a ** 2
bmi(193, 95)
25.50404037692287

def文の書き方について解説する。上のコードを図で表すと次のようになる。

_images/def.png

Fig. 18 def文の書き方#

def と書いたあとに空白を開けて関数名を書き、() の中に引数を表す変数名を書く。引数が複数ある場合は , で区切って並べる。 def文の最後にコロン : を置いて、次の行からインデントを入れてブロックを作るのはif文やfor文と同じである。 ブロックの中に関数が行う処理を書いていく。

def文に特徴的なのは、基本的にブロックの最後にreturn文を書くことである。 return文は return 戻り値 という形式で記述する。 処理がreturn文に到達すると、関数の処理はそこで終了となり、戻り値を返してブロックを抜ける。

関数を使用する際には、基本的に引数の順番通りに値を渡す必要がある。 例えば上の例で bmi(193, 95) と呼び出すと、引数に a = 193b = 95 が代入されてブロックの処理が行われる。 したがって、10000 * 95 / 193 ** 2 を計算した結果が戻り値として返される。

ただし、引数を直接指定するキーワード引数という呼び出し方をする場合は、順番を気にしなくて良い。 キーワード引数は、関数の使用時に次のように 引数名 = という形式で書く。

bmi(b = 95, a = 193)
25.50404037692287

また関数を使用する際には、基本的に引数に値を渡す必要がある。ただし、デフォルト引数という方法を利用すると、あらかじめ引数に値を設定しておくことができ、関数の呼び出し時にその引数を省略することが可能になる。デフォルト引数は、関数の定義時に次のように 引数名 = デフォルト値 という形式で記述する。

def bmi(a = 157, b = 50):
    return 10000 * b / a ** 2
bmi()
20.28479857195018
bmi(193, 95)  # 引数に値を渡したら、デフォルト値よりこちらが優先される。
25.50404037692287

引数やreturn文のない関数もよく使われる。 return文がない場合は、ブロックの最後まで実行してからブロックを抜ける。 このとき戻り値がないことを表す None という値が返される。

def greeting():
    print("Hi.")
a = greeting()
Hi.

ノートブックによる表示機能では何も表示されないが、print() することで None が代入されていることを確認できる。

a
print(a)
None

スコープ#

次のプログラムを結果を予想しながら実行してみよう。

a = 1
def func():
    a = 2
    print(a)
        
func()
print(a)
2
1

コード上でオブジェクトが有効になる範囲のことをスコープという。 スコープにはいくつか種類があるが、その中でもモジュールスコープローカルスコープが重要である。 ノートブックを起動するとモジュールスコープが始まる。 ノートブックのセル上で変数を定義すると、他のセルからも参照できるのは、これらが同じモジュールスコープに属するためである。 ただし関数内だけは別で、関数内はローカルスコープという独自のスコープを持つ。 モジュールスコープに属する変数をグローバル変数、ローカルスコープに属する変数をローカル変数という。

上の例では、1行目の変数 a はモジュールスコープに属するグローバル変数である。 一方で3行目の変数 a はローカルスコープに属するローカル変数であり、1行目の変数とは別物とみなされる。 したがって、func() 関数を実行してもグローバル変数の値は変更されず、最後の print(a) の出力は1となる。

ローカルスコープからグローバル変数を参照することはできるが、モジュールスコープからローカル変数を直接参照することはできない。

# ローカルスコープからグローバル変数は参照できる
b = 1
def func():
    print(b)
    
func()
1
# モジュールスコープからローカル変数は参照できない
def func():
    c = 1
    
func()
print(c)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
/var/folders/p0/0zrw9jsj03v3vlszs8v3m2p40000gn/T/ipykernel_46217/2212004489.py in <module>
      4 
      5 func()
----> 6 print(c)

NameError: name 'c' is not defined

モジュールスコープからローカル変数の値を利用するには、戻り値か引数を経由する必要がある。引数を経由する方法は、引数がミュータブルなオブジェクトのときのみ使えることに注意する。

# 戻り値を経由する例
def func():
    c = 1
    return c
    
c = func()
print(c)
1
# 引数を経由する例

## 引数がミュータブルなオブジェクトの場合
def func(x):
    c = 1
    x[0] = c

x = [0]
func(x)
print(x) # [1]になる

## 引数がイミュータブルなオブジェクトの場合
def func2(x):
    c = 1
    x = c

x = 0
func2(x)
print(x)  # 0のまま
[1]
0

スコープ周りを初めから完璧に理解するのは難しいので、エラーが起きたり、変数の値が想定からズレたりした場合に、適宜思い出して調べるのがよいだろう。

再帰#

第3回の課題1で用いたフィボナッチ数列の \(n\) 番目の数を求める数式を覚えているだろうか。この数式を用いてフィボナッチ数列の \(n\) 番目の数を返す関数を記述すると以下のようになる。

def fibonacci(n):
    return round((((1 + 5 ** 0.5) / 2) ** n - ((1 - 5 ** 0.5) / 2) ** n) / 5 ** 0.5)

fibonacci(30)
832040

この fibonacci(n) の実現手段は他にもある。 それは、 最初の2つは1で、3つ目以降は「前の2つを足したもの」 を素直に表す方法である。 まずはfor文による繰り返しを用いる方法で書いてみる。

def fibonacci(n):
    if n <= 2:
        return 1 # 最初の2つは1
    a = 1
    b = 1
    for i in range(n - 2): # 3つ目以降は
        c = a + b # 前の2つを足したもの
        a = b # 1個ずつずらす
        b = c
    return c

fibonacci(30)
832040

ところで、実は1つの代入文では実は複数の値を複数の変数にまとめて代入することが可能である。

a, b = 1, 2
print(a)
print(b)
1
2

これを用いるともう少しすっきりした書き方ができる。

def fibonacci(n):
    if n <= 2:
        return 1 # 最初の2つは1
    a, b = 1, 1
    for _ in range(n - 2): # 3つ目以降は
        a, b = b, a + b # 前の2つを足したもの。かつ1個ずつずらす
    return b

fibonacci(30)
832040

繰り返しで表す方法は、平方根やべき乗の出てくる公式で一発で求める方法よりも幾分か直感的である。しかし、繰り返しの各回で各々の変数の値を更新する(後ろにずらす)必要がある点が若干煩雑である。

次に 再帰 による方法で書いてみる。

def fibonacci(n):
    if n <= 2:
        return 1 # 最初の2つは1
    else:
        return fibonacci(n - 1) + fibonacci(n - 2) # 3つ目以降は前の2つを足したもの

fibonacci(30)
832040

再帰 とは、ある関数の中で自分自身を再び呼び出すことである。この例では、 fibonacci(n) の中で fibonacci(n - 1)fibonacci(n - 2) を呼び出している。 fibonacci(n - 1) を呼び出すとき、引数の n には n - 1 の値が代入される。 fibonacci(n - 2) の場合も同様である。

この例は 最初の2つは1で、3つ目以降は「前の2つを足したもの」 をかなり素直に表したものと考えることが出来る。if文で場合分けされているものを展開して疑似的なコードで書くと以下のようになる。

fibonacci(1) = 1
fibonacci(2) = 1
fibonacci(n) = fibonacci(n - 1) + fibonacci(n - 2)

演習#

課題1
再帰の例で出てきた関数 fibonacci(n) の難点は、1回の呼び出しで自分自身を複数回(2回)再帰呼び出ししているため呼び出し回数が指数関数的に増えることである。関数を再帰呼び出しする場合、呼び出し元の関数の引数はいったん特別な場所に自動的に退避されるようになっているが、無数の再帰呼び出しが発生した場合、いずれその退避場所が足りなくなって溢れてしまいエラーが発生してしまう。これを回避するために関数内の再帰呼び出しを極力抑えるための工夫を行う必要がある。

以下のコードはその工夫を行った関数が含まれているが、2箇所の None の部分が空欄であり、不完全である。None の部分に入るべき式を埋めて関数を完成させよ。

def fibonacci(n, a = 1, b = 1):
	if n > 2:
		return fibonacci(n - 1, None, None)
	return b

fibonacci(30)

課題2
リストを引数にとり、リストの要素の順序を逆にしたリストを返す関数 reverse(x) を完成させよ。

l = [1, 2, 3, 4, 5]

def reverse(x):
    pass # この部分を実装する

reverse(l) # [5, 4, 3, 2, 1]と出力される