解説 4月19日B

今回の学習項目です。


もっと関数

課題1-1

次のように配列aryが与えられたときに、 配列の要素の最小値を返す関数smallest(ary)を作成しなさい。

def smallest(ary)
  #
  #この部分を作る
  #
end # end of def

numbers = [4,12,45,21,18,7,20,2,51,16,10]
print smallest( numbers )

出力は以下のようになる。

2

課題1-2

次のように配列aryと変数xが与えられたときに、配列の要素の中からその変数の値にもっとも近い要素を探して返す関数nearest(x,ary)を作成しなさい。

def nearest(x, ary)
  #
  #この部分を作る
  #
end # end of def

target = 15
numbers = [4,12,45,21,18,7,20,5,51,16,10]
print nearest(target,numbers)

出力は以下のようになる。

16

[絶対値]

配列の要素の中からある値に最も近い要素を探すためには、 「差の絶対値」が必要になります。 そのためには、.abs を使います。以下がその使用例です。

x = 10
y = 15
p (x-y).abs
p (y-x).abs
この実行結果は、どちらもxとyの差の絶対値、つまり5が表示されます。

[課題1-2のヒント]

配列の要素の中からある値に最も近い要素を探すためには、 配列の要素とその値との差の最小値を与える要素を探せばよいのです。 課題1-1として「配列の要素の最小値」を求めるプログラムを作成しましたが、 今回は「配列の要素と与えられた値との差の絶対値の最小値」 を考えることが違いです。

課題1-3

次のように要素が小さいものから大きなものへと並べられた (昇順にソートされている、と言います)配列aryが与えられたときに、 与えられた数xを「順番を狂わせないよう」配列に加えた配列を返す関数 insert(x,ary)を作りなさい。 以下がその使用例です。

def insert(x, ary)
  #
  #この部分を作る
  #
end # end of def

ary = [4,12,45]
p insert(1, ary)
p insert(8, ary)
p insert(50,ary)
p insert(10, [])

出力は以下のようになる。

[1, 4, 12, 45]
[4, 8, 12, 45]
[4, 12, 45, 50]
[10]
[課題1-3のヒント]

問題では「すでにある配列に数を要素として加える」ように書いてあるが、 これをその通りやることは実際には難しい。そこで、 「与えられた条件を満たす配列を作って」返すようにすることを考えます。
おおまかな方針は次のようなものです。
注意: 3番目と4番目の処理を円滑に実現するため、 trueを初期値とする変数(flagとしましょう)を繰り返しに入る前に用意しておき、 繰り返しの間で「xをansに付け加えた場合」にその値をfalseにします。 そして、繰り返し終了後にその変数の値がtrueなら(つまりxがansに付け加えられていなければその時に限り)xをansに付け加えるようにするとよい。 この変数は以下の繰り返しにおいて、配列の要素とxをそもそも比較する必要があるかどうかの判定にも使えます。

  1. 変数aryの値が空の配列なら、xを唯一の要素とする配列を関数の値として終了。さもなければ、次を行う。  
  2. 空っぽの配列を用意し、変数ansの値とする。
  3. 配列aryの要素を順に取り出し(繰り返しによって)、以下を行う。

    その要素が与えられた数xよりも小さければ配列 ans に付け加える。  
    そうでなければ、(1)配列ansにxをまだ付け加えていなければ、 配列ansにxを付け加える。(2)さもなくば(配列ansにxを付け加えた後ならば)、 そのの要素を配列ansに付け加える。

  4. 上記の操作において配列ansにxをまだ付け加えていなければ (言い換えれば、配列aryの要素がみなxよりも小さかった場合は)、 xを配列ansに付け加える。
  5. 変数ansの値を関数の値として終了。

課題1-4

課題1-3のプログラムを利用して、 配列の要素を小さいものから大きなものへと並び替える (「昇順にソートする」、もしくは単に「ソートする」といいます) プログラムisortを作成しなさい。

ここでの考え方は次のとおりです。

  1. 要素をソートする配列を ary とする。 変数ansの値として空っぽの配列を与える。
  2. aryの1番目の要素から最後の要素まで順に取り出し(繰り返しによって)、以下を行う。

    aryから順に取り出された要素をxとする。 変数 ans に insert(x, ans) の値を代入する(ansの値を更新する)。

  3. 変数ansの値を関数の値として終了。

def isort(ary)
  #
  #この部分を作る
  #
end # end of def

numbers = [4,12,45,21,18,7,20,5,51,16,10]
p isort( numbers )

出力は以下のようになる。なお、この方式は挿入ソート(insertion sort)と呼ばれている。

 [4, 5, 7, 10, 12, 16, 18, 20, 21, 45, 51]

課題1-5

課題1-4の方法でソートができる理由を述べなさい。また、 n個の要素を持つ配列をソートするとき、 ソートが完成するまでに(insertで行われている)要素の比較の合計回数は、 最悪の場合(回数がもっとも多い場合)には何回くらい必要だろうか。 具体的にそのような配列を例で示しながら説明しなさい。

課題1-6

課題1-1のsmallestを改変し、次のように配列aryが与えられたときに、 配列の要素の最小値とそれ以外の要素からなる配列を求めて、 それらを要素とする配列を返す関数 select(ary)を作成しなさい。

def select(ary)
  #
  #この部分を作る
  #
end # end of def

numbers = [4,12,45,21,18,7,20,2,51,16,10]
p select( numbers )

出力は以下のようになる(これは一例であって必ずしもこのとおりではなくてもよい)。

[2, [12, 45, 21, 18, 7, 20, 4, 51, 16, 10]]
[課題1-6のヒント]

課題1-1と同様最小の要素を配列から取り出すが、 それ以外の要素を集めた配列も作り、その両方を要素とする配列を返す点が異なる。
それには、最小値の候補を記憶するための変数(変数名をsmallestとする)と、最小値以外の要素を記憶するための配列(初期値として空配列を与える, 変数名をrestとする)を最初に作っておき、 配列の要素を1つずつ調べ、それまでの最小値ならばsmallestの値とし、そうでなければrestに加えていく。ここで、smallestの値に変更があった場合には、昔のsmallestの値をrestに加えることを忘れないようにする。 そして最後に、関数の値として、[smallest, rest]を返せばよい。

課題1-7

課題1-6のプログラムを参考にして、 配列aryの要素を小さいものから大きなものへと並び替える(ソートする) プログラム ssort(ary) を作りなさい。これは選択ソート(selection sort)と呼ばれる方法です。

ここでの考え方は次のとおりです。

  1. 要素をソートする配列を ary とする。 変数ansの値として空っぽの配列を与える。
  2. 変数aryの値が空っぽの配列になるまで以下を繰り返す:

    aryの要素を、最小の要素と、それ以外の要素からなる配列に分ける(課題1-6 の主要部分)。 そして、最小の要素を配列ansに付け加える。 また、最小の要素以外の要素からなる配列を変数aryの値とする。

  3. 変数ansの値を関数の値として終了。

課題1-8

課題1-7の方法でソートができる理由を述べなさい。また、 n個の要素を持つ配列をソートするとき、 配列に要素を加える操作は、最も回数が多い場合に何回くらい必要だろうか。 具体的にそのような配列を例で示しながら説明しなさい。

関数の利用と再帰

今までは関数を一つだけ定義してそれを使ったプログラムを考えてきました。 しかし実際の大きなプログラムは、関数(やあとで学ぶクラス)の定義をたくさん用意し (しかも、それらは一つのファイルに書くのではなく、いくつかのファイルに分けて書き)、 それらを必要に応じて組み合わせて使う、というものです。

ここではそのための一歩として、いくつかの関数を利用するプログラムについて考えます。 特に、関数の中で別な関数を利用するプログラムを中心に考えます。 そして、その特殊な例として再帰関数を取り上げます。

関数の中で別な関数を利用する(呼び出す)

ここでは、いくつかの関数を使って、一つの目的を達成するプログラムを考えます。 特に、関数の中から別な関数を利用する、という点に注意します。

課題2-1

ある範囲の数が与えられた時に、それを日本語に翻訳する関数 yomiage を作りなさい。 ただ、その一部は以下で用意されているので、完成させて実行し、 妥当な結果が表示されることを確かめなさい。
$KCODE = "s"
def hitoketa(kazu)
  case kazu
    when 1
         return("いち")
    when 2
         return("に")
    when 3
       return("さん")
  #
  #この部分を作る
  # 
  end # case
end # def of hitoketa

def yomiage(kazu)
   if (kazu >= 100)   # ちょっと大きすぎる
      print "対象外です\n"
      return nil
   elsif (kazu >= 10)   # 2桁の数
      jukketa = kazu / 10
      amari =  kazu % 10
      return hitoketa(jukketa)+" じゅう "+hitoketa(amari)
   else    # 一桁の数
      return hitoketa(kazu)
   end # if
end # def of yomiage

for x in [23, 31, 75, 89]
   print x," => ", yomiage(x),"\n"
end   #23, 31 はこのままでも表示するが、他は動かない
出力は以下のようになる。
23 => に じゅう さん
31 => さん じゅう いち
75 => なな じゅう ご
89 => はち じゅう きゅう
[case文]
ある変数の値によって実行する文を変える場合、Rubyでは次のようにcase文を使って書くことができます。同じ事はif文を使ってもかけますが、caseの方がよりプログラムがわかりやすくなると考えられています。
case kazu
  when 1    # kazuの値が1の場合
      文1
  when 2    # kazuの値が2の場合
     文2
  when 3    # kazuの値が3の場合
     文3
  when 4    # kazuの値が4の場合
     文4
  else      # それ以外の場合
     文5
end
[このプログラムの問題点]

気がついた人もいるでしょうが、このプログラムは 「間違いではないが、日本語としては不自然」な数の表現を表示することがある。 どのような場合にそうなるかを考え、より自然な表現になるよう直すにはどうしたらよいかを考えてみよう。

課題2-2

配列[x,y]によって、ある点のx座標とy座標を表すことを考えよう。そして、 このような配列を2つとる関数distは、二次元平面における2つの点の距離を返す関数とする。
def dist(p, q) 
    # p, qは座標、 p = [x1,y1], q = [x2, y2]
   return (((p[0]-q[0])**2 + (p[1]-q[1])**2)**0.5) # √((x1-x2)**2+(y1-y2)**2)
end # def of dist
これを使って、次のように10個の点(座標の表現形式は先と同じで配列とする) が与えられたときに、これらの点の間の距離の最小値を返すnearestPoints を作れ。
points = [[92, 44], [90, 46], [28, 64], [85, 61], [4, 14], [52, 20], 
          [90, 15], [93, 55], [35, 83], [55, 74]]
def nearestPoints(ary)
  #
  #この部分を作る
  # 
end # of def
p nearestPoints(points)

出力は以下のようになる。
2.82842712474619
[課題2-2のヒント]

一つの方法は、すべての点を組み合わせ、その間の距離を計算し、 配列にいれておく。そして4月12日Aの課題2-2 のように、配列の最小値を答えるようにする。

課題2-3

課題2-2で作ったプログラムnearestPointsは、引数となる配列の大きさ(要素数) によらず答えられるものになっているだろうか? もしも配列の大きさを10に固定して考えていた場合は、その配列の要素がどんなに大きくても答えられるように、直しなさい。また、引数となる配列の大きさをn (n > 2)とした時に、 すべての点の組み合わせから計算される距離の個数はどのくらいになるだろうか、 nで表してみよ。このことから、 nが大きな値になるにつれてどのようにプログラムnearestPointsの計算時間が大きくなると予想されるか、答えよ。

[課題2-3のヒント]
これは「アルゴリズムとデータ構造」で学ぶ「計算量」の問題です。 まず、n個の点があるとすれば、その点の組み合わせは何通りあるかを考えましょう。 そうすれば、点が一組しかない場合に必要となる計算量(計算時間)を1と考えると、 n個の点がある時にはその点の組み合わせ分だけの計算量が必要になることがわかります。

再帰

再帰というと難しそうに聞こえますが、要するに 関数の中から関数を呼び出す一つの形式です。ただ先ほどの関数の使い方と違うのは、 「呼び出される関数が呼び出した関数と同じ関数」、つまり 「自分自身を呼び出す関数」というところにあります。

これは一見馬鹿げたもののように見えるかもしれません。 こんなのがうまく動くはずがない、と思うかもしれません。 確かに、下手に作ると動かないどころか、応答しない(迷宮入り?) プログラムになってしまいます。 そこで、ここではちゃんと動く再帰関数の作り方とその考えを学びます。

課題3-1

nを正の整数とした時、階乗n!を計算するプログラムを作成しなさい。 ただし、4月5日Bの課題3-6でやったのとは異なり、 繰り返しを使うのではなく、再帰関数として定義しなさい (以下のプログラムの欠けているところを埋めて完成させる)。 そして、いろいろな正の整数を引数に与えて、ちゃんと正しい答えが返ることを確かめなさい。

def factorial(m)  # m! を計算する
  if (m == 1)
     return 1
  else
     return m * factorial(   )  # ここを完成させる
  end
end # def of factorial

n = 20
print "factorial(",n,") = ",factorial(n),"\n"
factorial(20) = 2432902008176640000
[課題3-1のヒント]
nの階乗の定義は
n! = 1 * 2 * 3 * ... * (n-1) * n
です。これは次のようにもかけます。
n! = n * (n-1) *  ... * 3 * 2 * 1
   = n * (n-1)!
ここで factorial(n) はn!を計算する「はず」のものですから、 この関係から、factorial(n-1)とnとの積で表されるはずです、ね。

課題3-2

課題3-1で作ったプログラムを元に、間違って再帰のプログラムを作った場合に どんなことが起こるかを経験しましょう。そのために、次の注意を読んでおいてください。

注意:間違ったプログラムを作ってしまうとプログラムが止まらなくなって しまうことがあります。そのような時、Ctrlキーを押しながら C のキーを押すと、 プログラムは途中で強制的に終了します。

def fact(m)  # m! を計算するつもり
     return m * fact(m-1)
end # def of fact

n = 20
print "fact(",n,") = ",fact(n),"\n"
どんなことが起きたでしょうか。またそれはどうしてでしょうか? 説明しなさい。

課題3-3

課題3-1のプログラムと課題3-2のプログラムを比べて、 課題3-1のプログラムがちゃんと停止して正しい答えを返すことの説明を行いなさい。

再帰のプログラムをちゃんと理解して使いこなせるようになると、 プログラムの幅が広がります。 再帰のプログラムのこつは、終了条件(自分を再び呼び出すことなく答えを返す 処理)をまっさきに書くこと、そして繰り返し(自分を呼び出す「再帰」の部分) では、必ず引数が小さくなっていること(数の場合だけではなく後で見るように配列を引数とすることもありますが、その場合は要素数が少なくなっていることを意味します)、 そして再帰の呼び出しの部分は「ちゃんと動くもの」として仮定してプログラムの動きを考えること、です。

階乗の計算では、実は再帰よりも繰り返しを使う方がわかりやすく、 また効率がよいものでした。しかし、 再帰を使った方がプログラムが簡単になり、分かりやすくなる場合があります。 そのようなプログラムについてこれから見ていきましょう。

課題3-4

数学的に以下のように定義されるフィボナッチ関数fibo(n)を 関数として作成しなさい。
fibo(0) = 1
fibo(1) = 1
fibo(n) = fibo(n-1)+fibo(n-2)  (n ≥ 2のとき)
この関数fiboを使って次のように実行してみると
for n in 1..10
  print "fibo(",n,") = ",fibo(n),"\n"
end
その結果は以下のようになる:
fibo(1) = 1
fibo(2) = 2
fibo(3) = 3
fibo(4) = 5
fibo(5) = 8
fibo(6) = 13
fibo(7) = 21
fibo(8) = 34
fibo(9) = 55
fibo(10) = 89
[フィボナッチ関数]
アルゴリズムとデータ構造の教科書の54ページから56ページに説明があるように、 実は再帰関数でない方が効率のよいプログラムができる。しかし、わかりやすさ、 という点では、定義そのままの形でプログラムした方がわかりやすいでしょう。

課題3-5

Rubyでは、配列(仮に変数aの値とする)の要素と配列(仮に変数bの値とする) の要素を足し合わせた(appendする、という)配列を作る場合にも + が使える。 たとえば、以下を実行すると
a = [1,2,3]
b = [4,5,6,7]
c = a+b
p c
その結果は以下のようになる:
[1,2,3,4,5,6,7]
この知識を前提として、配列aryを引数にとり、 配列aryの要素を小さいものから大きなものへと並び替える関数qsort(ary) を作成しなさい。ただし qsortの中身は次のように計算が行われるものとする。
  1. aryの要素数が1以下なら、aryの値を返して終了
  2. aryの先頭の要素を取り出し(仮に変数pivotの値とする)、 aryの残りの要素をpivotと比べ、pivotよりも小さい要素だけからなる配列 (仮に変数smallerの値とする)と大きな要素だけからなる配列 (仮に変数largerの値とする)を作る。
  3. smallerを引数としてqsortを呼び出す。結果を変数 qs の値とする。 つまり、 qs = qsort(smaller)
  4. 同様に、largerを引数として qsortを呼び出す。その結果を ql の値とする。
  5. 配列 qs の要素と pivotと ql の要素を足し合わせた配列を返す。 つまり、 return(qs + [pivot] +ql)
このソートはクィック・ソート(quick sort)と呼ばれています。
[qsortが無限に回ることなく停止する理由]
このqsortでは、その中で二回もqsortがよばれており、 初めてみたときには何が起こっているのか、なぜ無事に停止できるのか、 わかりにくいかもしれません。
qsortのアルゴリズムでは、まず配列aryの大きさを見て、 要素数が1以下ならばaryをそのまま返して終了しています。 これがいわゆる「終了条件」です。 これを最初にチェックするから無限に回ることなく停止することができます。
また、aryが大きな場合でも、再帰的にqsortを呼ぶときには、 その引数は必ずもとのaryよりも要素数として小さなものになっています。 つまり、qsortは呼ばれるたびに小さな配列になり、 いつかは要素数が1以下のものになるので「終了条件」にかかって停止する、 という理屈です。

[配列の先頭要素を基準として それより小さい要素だけからなる配列を作る]

変数aryに配列が値として与えられているとします。 次のように、「先頭の要素(pivot)よりも小さい要素を入れる」ために変数smallerに 空っぽの配列を与え、配列の要素をひとつずつ見て、 pivotよりも小さければ、配列smallerにいれていけばできます。

pivot = ary[0]
smaller = []
for i in 1..(ary.size-1)
     if (ary[i] < pivot)
         smaller << ary[i]
     end 
end

課題3-6

課題3-5の方法でソートができる理由を述べなさい。また、 n個の要素を持つ配列をソートするとき、 配列に要素を加える操作は、最も回数が多い場合に何回くらい必要だろうか。 具体的にそのような配列を例で示しながら説明しなさい。


「アルゴリズムとデータ構造」のホームに戻る