970 likes | 1.08k Views
基礎的なアルゴリズム. ・ 再帰呼び出し ・ 分割統治とバランス化 ・ グラフ探索 ・ 列挙法 ・ 動的計画. 再帰呼び出し. サブルーチンを作る. ・ サブルーチンとは、いわばプログラムの部品 ・ プログラム実行中、そこに行き一定の処理をして、またもとの場所に帰ってこれる ・ 通常プログラムを作る際には、作業を階層的に「部品化」し、設計をやりやすくする - 作業単位がわかる - 全体の見通しがよくなる ・ サブルーチンを実行することを、 呼び出す という. 再帰呼び出し. ・ 通常の部品の概念では、部品の中に同じ部品が入ることはない
E N D
基礎的なアルゴリズム ・ 再帰呼び出し ・ 分割統治とバランス化 ・ グラフ探索 ・ 列挙法 ・ 動的計画
サブルーチンを作る ・サブルーチンとは、いわばプログラムの部品 ・ プログラム実行中、そこに行き一定の処理をして、またもとの場所に帰ってこれる ・通常プログラムを作る際には、作業を階層的に「部品化」し、設計をやりやすくする - 作業単位がわかる - 全体の見通しがよくなる ・サブルーチンを実行することを、呼び出すという
再帰呼び出し ・通常の部品の概念では、部品の中に同じ部品が入ることはない ・ しかし、サブルーチンは、自分自身を呼び出せる (サブルーチンが自分自身を呼び出すことを再帰呼び出しという) ・自分を呼び出すと、サブルーチンの最初から、呼び出すところまでを繰り返すループになる for ループと同じものが作れる sub (int i){ printf (“%d\n”, i); // i の値を表示 if ( i<100 ) sub(i+1); }
練習問題 ・次のループを再帰呼び出しで書け。擬似コードでよい - 10 から 20 までを表示 - 50 から 20 までの偶数を表示 - 1,2,…,9,10,10,9,…,1という順番で数字を表示。ただし、ひとつのサブルーチンで - m×n の長方形を “#” で表示
複数回の再帰呼び出し ・for ループは単純に繰り返すだけだが、再帰呼び出しは、1つのサブルーチンが自分を複数回呼び出すことができる sub (int i){ printf (“%d\n”, i); // i の値を表示 if ( i<100 ){ sub(i+1); sub(i+1); } } ・ どのように動くのだろうか? 1レベル目が1回、2レベル目が2回、3レベル目が4回、と指数的に実行解数が増える
2分木のような計算構造 ・1つのサブルーチン実行を1つの点で書き、再帰的に呼び出した関係があるところに線を引く ・2分木の構造が得られる ・3回呼び出せば3分木、 4回呼び出せば4分木、 ・呼び出しの回数が変化すれば、より複雑な構造になる いろいろな計算ができる
組合せ的な処理の例 ・例:8文字の○×△からなる文字列を全て表示 char s[9]; sub (int i){ if ( i = = 8 ){ s[8] = 0; printf (“%s\n”, s); // ○×△の表示 } else { s[i] = ‘o’; sub(i+1); s[i] = ‘x’; sub(i+1); s[i] = ‘A’; sub(i+1); } }
組合せ的な検索 ・再帰呼び出しの計算構造は、(逆さにした)木のようである ・このような、計算の構造を表した木を計算木、あるいは再帰木という ・このように、処理を分けることを分枝するという
分枝の例 ・a[0],…,a[9]の組合せで、合計がちょうど bになるものを表示 int a[10], flag[10]; sub (int i, int s){ int j; if ( i = = 10 ){ if ( s != b ) return; for (j=0 ; j<10 ; j++ ) if (flag[j] = = 1) printf (“%d ”, a[j]); // 数 の表示 printf (“\n”); } else { flag[i] = 1; sub(i+1, s+a[i]); flag[i] = 0; sub(i+1, s); } }
分枝限定法 ・分枝操作を行うと、全ての組合せを検索できる ・しかし、全ての組合せのうち、一部しか見る必要がないのであれば、全て見るのは無駄 ・見る必要のない部分を省略したい ・分枝作業をした後、「見る必要のない再帰呼び出し」は呼び出さないようにしよう ・ 必要(可能性)のない分枝を切ることを、限定操作という 限定操作をしながら分枝する方法を、分枝限定法という
限定操作の例 ・a[0],…,a[9]の組合せで、合計が b以下になるものを表示 int a[10], flag[10]; sub (int i, int s){ int j; if ( s > b ) return; // 限定操作! if ( i = = 10 ){ for (j=0 ; j<10 ; j++ ) if (flag[j] = = 1) printf (“%d ”, a[j]); // 数の表示 printf ("\n"); } else { flag[i] = 1; sub(i+1, s+a[i]); flag[i] = 0; sub(i+1, s); } }
問題の分割 ・人間が問題を解く(作業をする)場合、問題なりやることなりを分割して、小さくしてやることがある 見通しを良くする、という点と、やりやすくする、という点 ・例えば、部屋の片づけのしかた -まず、各部屋にしまうものを分類 -次に各部屋の中を、個別に整理 ・さらに各部屋の片づけをする際に、本棚や机、それぞれにしまうものを集めてから、それぞれをやっつける ・ このように再帰的に問題を分割して解く方法を分割統治法という
分割統治法の構造 ・分割統治法を、手法としてしっかり見てみる ・問題が与えられると、 -まず、問題を分割できるように整形する -問題を分割する(できた問題を子問題という) - 各子問題を解く(再帰呼び出し) - 各子問題の解を合わせて、もとの問題の解を作る ・ 問題によっては、必ずしも全ての手続きが必要なわけではない
分割統治法の例 ・先の、再帰呼び出しの例はだいたい分割統治法といってよい ・合計がちょうど bになる組合せを求める i番目の数を含む組合せを求める問題と、そうでない組合せを求める問題に分割している 整形と分割、統合に関して、複雑な処理が必要ない
ソート ・分割統治法を使うと、効率良く解ける問題がいくつかある ・基本的な例がソート ・数値 a[0],…,a[n] を小さい順に並び替える問題を考える ・戦略として、次の手順を考える - まず、数値を a[0],…,a[k] と a[k+1],…,a[n]に分割する -それぞれをソートする -ソートしてできた列を合わせて、全体のソート列を得る (この操作をマージ(合併・併合)という) -この操作を再帰的に行うことで、ソートをする
マージの例 ・例題 1,9,5,3,7,6,2,0,8,4 - 1,9,5,3,7,6 と 2,0,8,4 に分割 -それぞれをソートする: 1,3,5,6,7,9 と 0,2,4,8 -マージする (両者を同時に先頭から見ていく) 1,3,5,6,7,9 と 0,2,4,8 0,1,2,3,4,5,6,7,8,9 ・マージは線形時間でできる
マージをするプログラムの例 ・配列 a,b の数列をマージして、配列 c に格納 ・配列 a,b の最後に、大きな数 HUGEが入っているとする { int ia, ib=0, ic=0; for ( ia=0 ; a[ia]<HUGE; ia++){ while (b[ib]<a[ia] ){ c[ic] = b[ib]; ic++; ib++; } c[ic] = a[ia]; ic++; } }
マージソートをするプログラムの例 ・配列 a,b 両方に数列を格納しておくと、配列 a にソートした列が入る merge_sort (int *a, int *b, int s, int t){ int i1, i2, ia; if ( s = = t ) return; merge_sort (b, a, s, (s+t)/2 ); merge_sort (b, a, (s+t)/2, t); for ( i1=s,i2=(s+t)/2,ia=s ; i1<(s+t)/2; i1++){ while ( i2<t && b[i2]<b[i1] ){ a[ia] = b[i2]; ia++; i2++; } a[ia] = b[i1]; ia++; } while ( i2<t ){ a[ia] = b[i2]; ia++; i2++; } }
再帰呼び出しの様子 ・マージソートの再帰呼び出しの様子は、2分木になる merge_sort (int *a, int *b, int s, int t){ int i1, i2, ia; if ( s = = t ) return; merge_sort (b, a, s, (s+t)/2 ); merge_sort (b, a, (s+t)/2, t); for ( i1=s,i2=(s+t)/2,ia=s ; i1<(s+t)/2; i1++){ …
分割は、「同じ大きさ」が正解? ・ 先ほどのマージソートは配列をだいたい同じ大きさに分割していたが、もっといい分割はあるか? 分割のしかたを変えると、速さは変わるのか? ・ まず、2つ目の疑問は、Yes ・ マージソートの各反復の計算時間は、ソートする配列の大きさに線形 O(n) ・ もし、毎回 「1個と残り」という分割をすると、配列の長さは毎回1ずつ小さくなる 計算時間は 1+・・・+nに比例、つまり O(n2)
「同じ大きさ」が正解 ・ 毎回ちょうど半分に分割すると、配列の長さは毎回半分+1以下になる O(log n) 回分割すると長さが1になる 各レベルで計算時間を合計すると、O(n) になるので、合計はO(nlog n) ・ 分割の仕方で計算量のオーダーが変わった ・ これは最適なんだろうか? ソートの計算時間の下界と一致するので、最適
分割すればいい、と言うものではない ・ 分割ができるからといって、分割統治法を使えば速くなる、というものでもない a[1],…,a[n] の中から最大のものを見つける問題は、分割しても解けるが、速くならない - a[1],…,a[k] と a[k+1],…,a[n] に分割してそれぞれの最大を再帰的に求める - 両最大値のうち、大きいほうを全体の最大値として返す ・計算木の各頂点で O(1) しか時間 をかけないので、どう分割しても O(n) 時間になる
クイックソート ・ マージソートは、分割前に何もせず、分割後にがんばる -まず、問題を分割できるように整形する -問題を分割する(できた問題を子問題という) - 各子問題を解く(再帰呼び出し) - 各子問題の解を合わせて、もとの問題の解を作る ・逆のアイディアでソートができないか? ・ クイックソートと言う方法がある
分割の前にがんばる ・ まず、数値をある数 kより大きなものと小さなものに分割する ・それぞれを小さい順に並べる ・ 終わると、全体が小さい順になっている k
k との大小で分割 ・ 数値を kとの大小比較で分割するにはどうすればいいか? - 右からスキャンして kより小さいものを見つけて止まる - 左からスキャンして kより大きいものを見つけて止まる ・両者を入れ替える ・ 繰り返す。スキャンしている場所がすれ違ったら終わる
分割のバランスがとれない ・ kの値によって、分割してできる子問題の大きさは変わる ・しかし、真ん中の値(中央値)を見つけるのは、簡単ではない ・しょうがないので、適当なものを選ぼう 最悪の場合、1つと残り、という分割を繰り返すので 計算時間は O(n2) になる ・ しかし、例えば、分割が 1:9 以上になる確率は 2/10 で、 こういうことは、起こりにくい。そんな分割がたくさん起こることは、なおない。平均的には、 O(nlog n) になる
合計が最も大きい区間を求める ・ a[0],…,a[n-1] の区間 a[h],…,a[k] 中で、合計が最も大きいものを求める (負の値があると、全体=最大とはかぎらない) ・実験の時系列データの解析など? 各区間の取り方は O(n2) なので、素朴に計算すると O(n3) 時間になる 各区間の合計の計算の仕方を工夫する(左端を固定して、右側をひとつずつずらし、新しく入ってくる要素を今までの合計に足し込む)と O(n2) になる ・ 分割統治法を使うと、速く解ける
分割して計算 ① a[0],…,a[n-1] を a[0],…,a[k-1] と a[k],…,a[n-1]に分割 ②右端が a[k-1] となる区間の中で最大を求める ③左端が a[k] となる区間の中で最大を求める a[k] と a[k] を両方含む区間の中で最大なものは、②と③を合わせると求まる ④再帰呼び出しで、a[0],…,a[k-1] の中の最大と、a[k],…,a[n-1] の中の最大を求める ・ ①と②と③ はO(n) 時間 問題を半分に分割するようにすれば、マージソートと同じ計算量になり、全体の計算時間は O(nlog n)
~余談~: k番目を線形時間で見つける ・ クイックソートは、問題を分割するときに、だいたい真ん中になる数が知りたい ・ こういう、数の中から k番目にあるものを見つける問題を k best 問題という ・ 簡単にやろうとしたら、数字をソートすればよい (本末転倒だが) ・ だが、凝った方法で、ソートせずに線形時間の方法がある
~余談~: k番目を線形時間で見つける 2 ・ 入力した数を a[0],…,a[n-1] とする ・ 基本的な戦略は、数 xを選び、 a[0],…,a[n-1] を xをより大きなものと小さなものに分ける ・ xより小さいものが k個以上あったら、k番目はxより小さい xより小さいものの中で k番目を見つける ・ xより大きいものが k個以上あったら、k番目はxより大きい xより大きいものの中で n-1-k番目を見つける ・ という具合に小さくしていく。効率は xの選び方しだい
~余談~: k番目を線形時間で見つける 3 ・ xを選ぶのには、「いいかげんな」 中央値を使う (クイックソートにも、いいかげんな中央値で十分だが) ・ a[0],…,a[n-1] 中を7個ずつに分け、それぞれの7個の中で中央値を見つける(定数時間が n/7 回で O(n) 時間) ・ 見つけた中央値の中でさらに中央値を見つける (1/7 の大きさの問題を再帰的に解く) ・ これは、真ん中 1/4 以上はじっこに行くことはない (3/4 の大きさの問題を再帰的に解く、上と合わせて 25/28 )
~余談~: k番目を線形時間で見つける 4 ・ それぞれの7個の中で中央値より、半分のものは大きい ・ 半分のもののさらに半分は、●より大きい ・ 半分のもののさらに半分は、●より小さい ・ よって、真ん中 1/4 以上はじっこに行くことはない 大 小
~余談~: k番目を線形時間で見つける 5 ・ 問題の大きさの合計が、毎回 25/28 になる ・ 問題を分けるのにかかる時間は O(n) ・ 全体の計算時間は O(n + (25/28)n + (25/28)2n + (25/28)3n …) = O(n)
行列のかけ算 ・ 行列の積を求める演算は、行列の操作の中でも基本的なものだが、計算に比較的時間がかかる (次元の3乗のオーダー) ・うまいことやって、もっと速くできないだろうか? × =
分割統治法は使えるか? ・ ソートでは、問題を分割して高速化した。行列も問題を分割したらうまく解けるかも ・うまいことやって、もっと速くできないだろうか? ・分割するといっても、どのように分割する?どのように解く? ・まずはどのように分割するか、分割した問題をどのように解くかを考えないと × =
簡単な分割 ・ 左の行列は横長の行列2つに、右の行列は縦長の行列2つに分けてみよう ・横長と縦長の行列の組合せ1つの積を求めると、答えの1ブロックが埋まる 4回積を求めると、もとの行列の積が求まる ・でも、「正方行列の積」が「正方でない行列の積」になるので、気持ちが悪いなあ × =
もう少し分割 ・ 両行列ともに4つに分割する ・1/4 ブロックの積を2つ求め、足すと、1ブロック埋まる 正方行列の積を8回求めると、もとの行列の積が求まる 同じ問題を解いているので、再帰呼び出しできる ・さて、これは速くなっているのでしょうか??? 解析してみないと、よくわからない。。。 AE +BG AF +BH A B E F × = CE +DG CF +DH C D G H
計算時間の再帰式 ・ 計算が再帰的なので、計算時間の再帰式を作ろう ・ この方法でn次元行列のかけ算をする時間を T(n)とする ・ 問題を分割したり、積の足し算をして解を求める時間は O(n2)であるので、定数を使って、cn2時間であるとする ・ 再帰は、大きさ n/2 の問題について 8回行われるので、 T(n) = cn2 + 8T(n/2) となる AE +BG AF +BH A B E F × = CE +DG CF +DH C D G H
計算時間の再帰式 ・T(n) = cn2 + 8T(n/2)を満たす関数 T(n) とは、どのような関数であろうか? ・ 図示して考えてみよう (簡単のため、n = 2kであるとする) ・ 大きさ nの問題を受け取ったら、cn2 の計算時間をかけ、 8 個の大きさ n/2の問題に関して再帰呼び出しが起こる ・ (8個の)大きさ n/2の問題は、それぞれ c(n/2)2 の計算時間をかけ、それぞれ 8 個の、大きさ n/4の問題を作る ・ (64個の)大きさ n/4の問題は... (以下省略) ・ (???個の)大きさ 1の問題が呼び出される
計算時間の再帰式 ・ 大きさ nの問題を受け取ったら、cn2 の計算時間をかけ、 8 個の大きさ n/2の問題に関して再帰呼び出しが起こる... ・ 1レベル下がると大きさは 1/2になり、計算時間は 2倍に増える 全体の計算時間は一番下のレベルの2倍を超えない ・ k = log2n レベル下がると、問題の大きさが 1になり、再帰呼び出しは起こらない そのレベルでの計算時間の合計は cn2 ×2k = cn3 ・ 計算時間は、O(cn3) ・ 苦労したのに何もかわらないや。。。
再帰式、一般的には ・T(n) = cn2 + 8T(n/2)の n2 の部分や、8の部分、n/2 の 2の部分が変化すると、関数 T(n) のオーダーも変化する ・ どのように変わるか、少し調べてみよう ・ n2 の部分、8の部分が変わると、1レベル下がったときの計算時間の増加量が変わる -8の部分が 2 になると、1レベル下がると計算時間が半分に 全体の時間はトップレベルの2倍を超えず、O(n2) -8の部分が 4 になると、各レベルでの計算時間は等しくなる 全体の計算時間はトップレベルの高さ倍となり、O(n2 log n)
再帰式、一般的には - n2 が nになると 1レベル下がるごとに計算時間は4倍になる 一番下のレベルの計算時間の合計はトップレベルの n2倍になる。しかし、トップレベルの計算時間が O(n) であるので、掛け合わせると O(n3) - n2 が n4になると、1レベル下の計算時間は半分になる 全体の計算時間はトップレベルの2倍を超えず、 O(n4) - n2 が n3になると、各レベル下の計算時間は等しくなる 全体の計算時間は O(n3 log n)
再帰式、一般的には - n/2が n/4になると、1レベル下がるごとに計算時間は1/2になり、さらにレベルの高さが半分に 計算時間の合計はトップレベルの2倍以下。O(n2) - n/2が n/4になり、n2 が nになると、1レベル下がるごとに計算時間は2倍になる。レベルの高さが半分になる 一番下のレベルでは、トップレベルの n1/2 倍。O(n3/2)
再帰式、一般的には ・ 一般に、T(n) = cna + bT(n/c)という再帰式が成り立つとき、 - a > logcbならば、トップレベルが全体の計算時間を支配し、計算量は O(na) となる - a < logcbならば、一番下のレベルが全体の計算時間を支配し、計算量は O(nlogc b) となる - a = logcbならば、各レベルの計算時間は等しくなり、計算量は高さのファクターが入り、O(nalog n) となる
行列積の高速化 ・ 先の問題の分割では、答えの行列をブロックごとに求めていた その結果、8回のかけ算を必要としている 行列積は a < logcb (2 < log28)の場合であるので、 再帰呼び出しの回数 8を減らすか、子問題の大きさ n/2 を小さくしなければならない ・ 工夫して、再帰の数、つまりかけ算の数を減らした人がいる AE +BG AF +BH A B E F × = CE +DG CF +DH C D G H
分配則の利用 ・ 行列を足してからかけると、行列積の和が得られる 例)(A + B)E = AE + BE B(G – E) = BG – BE ・ これらをうまく組合わせて足し合わせると、目的である行列積が出てくる 例)(AE + BE) + (BG – BE) = AE+BG AE +BG AF +BH A B E F × = CE +DG CF +DH C D G H
再帰式の変化 ・ うまい和積取り方と組合せ方を考えて、7回の行列積で、4つのブロックの解が得られるようにした ・ 計算時間の再帰式は、T(n) = cn2 + 8T(n/2)から T(n) = cn2 + 7T(n/2)になった ・log27 = 2.80… なので、およそ O(cn2.80) 時間になる ・ この方法で一番すごいやつは、100個以上の行列に分解して同様のことを行い、O(cn2.31…) 時間を達成 AE +BG AF +BH A B E F × = CE +DG CF +DH C D G H