5章 再帰呼出しと分割統治

5章 再帰呼出しと分割統治
杉原厚吉.(2001).「データ構造とアルゴリズム」.
東京:共立出版.
2011/06/21
情報知能学科「アルゴリズムとデータ構造」
担当:白井英俊
本章の目的
• 問題の性質を見極めて、「複雑な問題を、より
単純な小問題に分割して解く」ことを学ぶ
• 効率は『計算量』だけではない。問題を解くた
めの考えやすさ、分かりやすさも大事
• 複雑な問題を分割して解くことの代表的なも
のが再帰 ― 小問題が大問題の類似形の場
合に有効
• 分割することで効率が悪くなることもある!
5.1 再帰呼び出し
• 再帰呼び出し(recursive call) ― 手続き(関
数)の中で自分自身(その関数)を呼び出すこと
• 特長:ある種の手続きを簡潔に表現できる
特に、問題自身が再帰的な場合に有効
例: 二進木のなぞり :「二進木」は『左の部分木』、と『右の
部分木』に分かれる。だから、木を中間順 (inorder)になぞ
るには、
(1)左の木をinorderでなぞる、(2)木の根を表示する、
(3)右の木をinorderでなぞる、
とすればよい。
これは、「左の木」も「右の木」も、木全体の類似形だから、
再帰を使うのが有効
中間順による二進木のなぞり:
inorder関数
この前は「インスタンスメソッド」として実現。今度は二進木のイ
ンスタンスを引数とする「関数」として実現することを考える
再帰の場合、『どのような場合に再帰しない(停止す
る)か』という検査を最初に実行(参考:数学的帰納法)
def inorder(tree)
if (tree != nil) # tree==nilなら再帰しない
inorder(tree.left) # 左の部分木を中間順で
print tree.data
# 頂点のデータを表示
inorder(tree.right) # 右の部分木を中間順で
end # if
end # def
インスタンスメソッドとの比較:
(1)「木」を表す引数が必要
インスタンスメソッドでは
不要(@selfと同じだから)
(2) インスタンスの部品(イン
スタンス変数)の参照方法
インスタンスメソッドでは
@data, @left, @right
インスタンスメソッドと「関数」
インスタンスメソッド
class Vertex
def initialize(data, left, right)
@data = data
@left = left
@right = right
end # initialize
attr_attribute :data, :left, :right
インスタンスメソッドの呼出し:
xをVertexのインスタンスとすると
x.inorder
def inorder
@left.inorder if @left != nil
print @data
@right.inorder if @right != nil
end # def of inorder
end # class
xのクラスに付随するメソッドinorder
が呼び出される
それに対し
関数としてのinorderの
実現では、Vertexのインス
タンスを引数して呼出す:
inorder(x)
中間順による木のなぞり(関数版)
• 実行結果
②
①
②
③
1
2 ③
①
② 4
5
③
① 8
9 10
inorder(1)
inorder(2)
print(1)
3
6
inorder(3)
7
に対して:
8
4
9
2
10
5 1
6
3
7
inorder(4)
inorder(6)
print(2)
print(3)
inorder(5)
inorder(7)
inorder(10)
inorder(8)
print(4)
print(5)
inorder(9)
先行順による木のなぞり
同様に、関数として実現することを考える
• 先行順(頂点、左の部分木、右の部分木)
def preorder(tree)
if (tree != nil) # tree==nilなら再帰しない
print tree.data
# 頂点のデータを表示
preorder(tree.left) # 左の部分木をpreorderで
preorder(tree.right) # 右の部分木をpreorderで
end # if
end # def
先行順による木のなぞり
①
実行
②
①
②
①
②
8
2
1
③
③
3
4 ③
5
9 10 ②
①
6
に対して:
1
2
4
8
9
5
10
3 6 7
7
後行順による木のなぞり
これも、関数として実現することを考える
• 後行順(左の部分木、右の部分木、頂点)
def postorder(tree)
if (tree != nil) # tree==nilなら再帰しない
postorder(tree.left) # 左の部分木をpostorderで
postorder(tree.right) # 右の部分木をpostorderで
print tree.data
# 頂点のデータを表示
end # if
end # def
後行順による木のなぞり
③
実行
①
①
③
③
2
1
8
3
②
4 ② ① 5
9 10
①
②
②
6
に対して:
8
9
4
10 5 2
6 7 3 1
7
応用:数式の構文木
例: (a+b)*(c-d*e) は次の二進木で表現される(葉に
は変数が、葉以外の頂点は演算子が書かれる)
これを数式の「構文木」
と呼ぶ。数式の構造
*
+
a
ー
b
(どこの塊同士がどのような
演算によって結合されてい
るか)が明示されている
*
c
d
e
数式の構文木の演習
*
+
ー
• 先の木を
b c
1) 中間順(inorder) a
*
2) 先行順(preorder)
d
e
3) 後行順(postorder)
でそれぞれなぞると…
参考:ポーランド記法、逆ポーランド記法
数式の構文木の演習(解)
• (a+b)*(c-d*e) の構文木を以下の方法でなぞ
ると:
1) 中間順(inorder)
a+b*c–d*e
2) 先行順(preorder): 数式のポーランド記法
*+a b–c* d e
3) 後行順(postorder): 逆ポーランド記法
日本語的!
a b+ c d e*–*
2進木以外の「木のなぞり」
• 「先行順」と「後行順」による木のなぞ
りは、2進木でなくとも使える(中間順も無
理すればできなくはない)
一般の木の表現
• 一般の木構造を次のようなデータ構造(ノード
と呼ぶ)で表す
class Node
@parent
def initialize(u,v,w,x)
@label = u
self
@parent = v
@nextBrother
@firstChild = w
@nextBrother = x
@firstChild
end
注意:枝の向きが明らかな場合は矢印を省略する
end
木の表現(続)
クラスNodeを図示すると、次のようになる
頂点 一個一個が以下の構造をもつ
@parent
親頂点
label (ラベル)
parent (親)
self
@nextBrother
firstChild(第一子)
nextBrother(次の兄弟)
@firstChild
子頂点
兄弟頂点
NodeクラスとVertexクラスの比較
class Node
# 一般の木の頂点を表現するクラス
def initialize(label, parent, firstChild, nextBrother)
@parent = parent
# 親ノード
@firstChild = firstChild
# 第一子
@nextBrother = nextBrother # 次の兄弟
@label = label
end # def initialize
end
頂点のラベル(値)
class Vertex
# 2進木用の頂点を表現するクラス
def initialize(data, left ,right)
@left = left
# 左の部分木
@right = right
# 右の部分木
@data = data
# 頂点の値
end # def initialize
end # class
子の頂点
一般の木のなぞり
class Node
• ある頂点 n からみると
def initialize(u,v,w,x)
子の頂点すべてを取り
@label = u
出すことは、すぐにはで
@parent = v
最初の子しか見えない
きない
@firstChild = w
比較:二進木では子の頂点は左と
@nextBrother = x
右だけで、それぞれ@leftと
end
@rightで取り出せた
end
だから、「木をなぞる」には、「子頂点をすべて取り
出す」ためのしかけが必要
それには、「次の兄弟」を使う!
一般の木において子頂点をすべて表
示する:「木のなぞり」の準備
右の木を例にとって考える。
v0を対象とすると、その「子」は
どのようにしたら取り出せるだろ
うか?
答え:
v0.firstChild = v1
v1
v0.firstChild.nextBrother
例題の木
v1
v4
v1
v0.firstChild.nextBrother.nextBrother
ある頂点の「兄弟」頂点をすべて取り出す
インスタンスメソッドがほしい!
子頂点=最初の子(s)+sの「兄弟」頂点
v0
v3
v2
v6
v5
Nodeクラス
@parent
self
@firstChild
@nextBrother
兄弟頂点を訪問するメソッド
class Node
# 一般の木の頂点を表現するクラス
def initialize(label, parent, firstChild, nextBrother)
@parent = parent
# 親ノード
@firstChild = firstChild
# 第一子
@nextBrother = nextBrother # 次の兄弟
@label = label
end # def initialize
attr_accessor :parent, :firstChild, :nextBrother, :label
def brothers
return if (@nextBrother == nil) # 次の兄弟がいない場合
print @nextBrother.label
@nextBrother.brothers
# 次次と兄弟を訪問
end
参考:先祖の頂点を要素とする配列
を求める
右の木を例にとって考える。v5
を対象とすると、その「親」は
どのようにしたら取り出せるだ
ろうか?
また「先祖」(親、親の親、親の
親の親…)は?
答え:
v5.parent = v1
v5.parent.parent = v0
ある頂点の「先祖」頂点とは、
(1) (親がいれば)親 、に加えて
(2) 親の「先祖」頂点
例題の木
v1
v4
v0
v3
v2
v6
v5
Nodeクラス
@parent
self
@firstChild
@nextBrother
先祖の頂点の配列を求める(続)
class Node
# 一般の木の頂点を表現するクラス
def initialize(label, parent, firstChild, nextBrother)
@parent = parent
# 親ノード
@firstChild = firstChild
# 第一子
@nextBrother = nextBrother # 次の兄弟
@label = label
end # def initialize
attr_accessor :parent, :firstChild, :nextBrother, :label
def ancestors
return [ ] if (@parent == nil) # 親がいない場合
# そうでなければ 親+親の先祖 を返す
return [@parent][email protected]
end
プログラミング演習
一般の木において(Nodeクラスを使用)
treeTemplate.rb 参照のこと
(1)先行順に木の頂点を訪れるインスタンスメ
ソッド preorder
(2) 後行順に木の頂点を訪れるインスタンスメ
ソッド postorder を作る
再帰的な構造、効率の悪い問題
• Hanoiの塔:再帰的な構造をしていて、計算量
が大きな問題の典型例
詳しくは別紙の資料参照
(右図はWikipediaより拝借)
問題:n枚の円盤を3本あるうちの一つの柱から
別な柱に移す手順を求める
入力:円盤の枚数、柱の名称(a,b,c)
出力:円盤を移す手順
プログラム Hanoi
def hanoi(n,a,b,c) # n枚の円盤をaからcへ移す
if (n==1) # 円盤が一枚ならば
print ’move the disk from ’,a,’ to ’,c,”\n”
else
hanoi(n-1,a,c,b) # n-1枚の円盤をaからbに移す
print ’move the disk from ’,a,’ to ’,c,”\n”
hanoi(n-1,b,a,c) # n-1枚の円盤をbからcに移す
end # if
end # def
ハノイの塔の計算量
• 円盤をn枚移すのにかかる計算量を f(n)
とすると、このアルゴリズムは g(n)=f(n)+αとすると、g(n)は
2の等比数列
f(n) = 2*f(n-1)+α
という計算量が必要である。(円盤を1枚移す手間を定数「 α 」と
している)
したがって、f(n) は O(2n)
これはアルゴリズムのせいではなく、問題自体が難し
い(指数オーダーの計算量を必要とする)から
=>アルゴリズムが簡潔に書けるからといって、その
アルゴリズムの効率がよいということにはならない
(予習)5.2 分割統治法
• 再帰呼び出しによる効率のよいアルゴリズムの例:
マージソート(merge sort)
• 入力: n = 2k (kは正整数)個の数の集合S(配列と
する)
• 出力: Sの要素を昇順に並べたもの(配列とする)
Sの最小要素
Sの最大要素
• 手続き:
|S|=2ならば、[min(S),max(S)]を返す
さもなくば、Sをn/2個ずつに分け、それぞれをマー
ジソート(mergeSort)する。この二つの結果を、昇順
に並べ(merge)、それを返す。
マージソートの実現(0)
アルゴリズムから、mergeSortの全体像は:
def mergeSort(s)
n = s.size
if (n == 2)
# |S|=2の場合の手続きを書く---(1)
else
# それ以外の場合の手続きを書く---(2)
end # if
end # def
マージソートの実現(1)
(1) |S|=2のときに[min(S),max(S)]を返す
これは簡単。条件文の使い方さえ分かっていれば…
def mergeSort(s)
n = s.size
return s if n==1 # 一般性を考慮
if (n == 2)
# s[0]とs[1]を比較して、
# [小さい方の要素、大きい方の要素] を返す
else
# 次で考える
end # if
end # def
マージソートの実現のために
アルゴリズムを実現するには、以下の作業をするための「アル
ゴリズム」が必要:
(1) |S|=2のときに[min(S),max(S)]を返す
(2) Sをn/2個ずつに分け、それぞれをマージ
ソート(mergeSort)し、二つのマージソートが
返した結果を合わせて、昇順に並べる
(mergeする)
マージソートの実現(2)
def mergeSort(s)
n = s.size
return s if n==1 # 一般性を考慮
if (s.size == 2)
# できているはず…
else
mid = (n-1)/2 # 真中の要素の番号
x = mergeSort(s[0 .. mid])
y = mergeSort(s[(mid+1) .. (n-1)])
return merge(x,y) # xとyを合わせて昇順に
end # if
end # def
注:Rubyでは、配列xのインデックスi番目の要素から
インデックスj番目の要素までを x[i..j]
により配列として取り出せる
マージ(merge)とは?
・「再帰法」は、今作ろうとしている手続き
(mergeSort)が小さな問題に対しては「ちゃん
と正しい値を返してくれる」と考えて作る方法。
だから、以下は「ソートされた数の配列が返さ
れると仮定してよい」:
mergeSort(s[0..mid]) # 配列sの前半
mergeSort(s[(mid+1)..(n-1)]) # 配列sの後半
とすると、考えなければならない問題は、返した結果を合わせて、昇順に並べる
方法
マージ(merge)
• つまり
mergeSort(s[0..mid])
mergeSort(s[(mid+1)..(n-1)])
は、それぞれソートされた配列を返す。
例: [1,3,5,9,11,15,18,20]
[2,4,6,8,10,12,14,16]
マージ(merge)は、この二つを入力として、
[1,2,3,4,5,6,8,9,10,11,12,14,15,16,18,20]
というような昇順の配列を返す関数
マージ(merge)関数を作る
def merge(x,y)
result = [ ]
# マージした結果の記録用
xlen = x.size ‐1; ylen = y.size ‐1
# xとyの最後の要素のインデックス
xc = 0; yc = 0 # xとyそれぞれ「見る」要素の番号
while (xc <= xlen && yc <= ylen ) # 未検査要素あり
# x[xc]とy[yc]を比較しresultに入れるなど
# 適切なプログラムを書く
end # while
# xかyに未検査のものがある場合の処理を書く
end # def
マージソートの完成
• mergeとmergeSortをちゃんと書くことができ
れば、次のような結果が得られるだろう:
(mergeSortTemplate.rb参照)ーーー課題
mergeSort([20, 10, 5, 15, 3, 7, 13, 18, 2, 6,
4, 1, 19])
の出力結果:
[1, 2, 3, 4, 5, 6, 7, 10, 13, 15, 18, 19, 20]
分割統治法
• 入力データの大きさをnとすると、そのデータ
を大きさ n/b の問題 a 個に分割し、それぞれ
を再帰的に解いた結果を利用して、元の問題
の解を作るという方法(教科書、p.51)で、かつ
計算時間 f(n) が
n=1の時、 f(n) = c (cは定数)
n ≧2の時、 f(n)=a*f(n/b) + cn (a,b,cは定数)
となるもの(マージソートでは a=b=2)
ハノイの塔とマージソートの比較
• ハノイの塔は再帰法を使っていても、その計
算量は O(2n)
• マージソートでは、O(n*log n)
この違いは何か?
答え:
元の問題に対し、一定の比で小さな問題にし
て解く(マージソート)か、一定の差で小さな問
題にして解く(ハノイの塔)か。(p.53-54)
再帰呼出しを使わない方がよい例
• フィボナッチ数列(Fibonacci sequence)
定義: 非負整数nに対するフィボナッチ数
F(n)とは
n=0 または n=1のとき、1
それ以外: F(n) = F(n-1)+F(n-2)
この形から、「ハノイの塔」タイプであることが明らか
フィボナッチ数F(n)を求める
• 先の定義を単純にプログラムすると以下のとおり:
def fibo1(n)
if (n==0 || n==1)
return 1
else
return fibo1(n-1)+fibo1(n-2)
end # if
end #def
フィボナッチ数F(n)の別な求め方
• 実は再帰を使わず、F(1),F(2),F(3),…
の順にF(n)まで計算すれば、ずっと効率がよい
…計算量のオーダーはO(n)
def fibo2(n)
return 1 if (n==0 || n==1)
昨年の教科書を使っている人に:アルゴリズム
x = 1; y = 1
FIBO2(p.54)の1行目を訂正
for i in 2..n
z = x+y ; x = y; y = z
end # for
return z
end # def
実は。。。
• 教科書p.56にあるように、フィボナッチ数F(n)
はO(log n)で求めることができる。