5. 基礎的なアルゴリズム

Download Report

Transcript 5. 基礎的なアルゴリズム

基礎的なアルゴリズム
・ 再帰呼び出し
・ 分割統治とバランス化
・ グラフ探索
・ 列挙法
・ 動的計画
再帰呼び出し
サブルーチンを作る
・ サブルーチンとは、いわばプログラムの部品
・ プログラム実行中、そこに行き一定の処理をして、またもとの
場所に帰ってこれる
・ 通常プログラムを作る際には、作業を階層的に「部品化」し、
設計をやりやすくする
- 作業単位がわかる
- 全体の見通しがよくなる
・ サブルーチンを実行することを、呼び出すという
再帰呼び出し
・ 通常の部品の概念では、部品の中に同じ部品が入ることはない
・ しかし、サブルーチンは、自分自身を呼び出せる
(サブルーチンが自分自身を呼び出すことを再帰呼び出しという)
・ 自分を呼び出すと、サブルーチンの最初から、呼び出すところま
でを繰り返すループになる
 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回求めると、もとの行列の積が求まる
 同じ問題を解いているので、再帰呼び出しできる
・ さて、これは速くなっているのでしょうか???
解析してみないと、よくわからない。。。
A
B
E
F
×
C
D
=
G
H
AE
+BG
CE
+DG
AF
+BH
CF
+DH
計算時間の再帰式
・ 計算が再帰的なので、計算時間の再帰式を作ろう
・ この方法で n 次元行列のかけ算をする時間を T(n) とする
・ 問題を分割したり、積の足し算をして解を求める時間は O(n2)で
あるので、定数を使って、cn2 時間であるとする
・ 再帰は、大きさ n/2 の問題について 8回行われるので、
T(n) = cn2 + 8T(n/2)
となる
A
B
E
F
×
C
D
=
G
H
AE
+BG
CE
+DG
AF
+BH
CF
+DH
計算時間の再帰式
・ 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 > logc b ならば、トップレベルが全体の計算時間を支配し、
計算量は O(na) となる
- a < logc b ならば、一番下のレベルが全体の計算時間を支配
し、計算量は O(nlogc b) となる
- a = logc b ならば、各レベルの計算時間は等しくなり、計算量
は高さのファクターが入り、O(nalog n) となる
行列積の高速化
・ 先の問題の分割では、答えの行列をブロックごとに求めていた
 その結果、8回のかけ算を必要としている
 行列積は a < logc b (2 < log2 8) の場合であるので、
再帰呼び出しの回数 8 を減らすか、子問題の大きさ n/2 を小さく
しなければならない
・ 工夫して、再帰の数、つまりかけ算の数を減らした人がいる
A
B
E
F
×
C
D
=
G
H
AE
+BG
CE
+DG
AF
+BH
CF
+DH
分配則の利用
・ 行列を足してからかけると、行列積の和が得られる
例) (A + B)E = AE + BE B(G – E) = BG – BE
・ これらをうまく組合わせて足し合わせると、目的である行列積が
出てくる
例) (AE + BE) + (BG – BE) = AE+BG
A
B
E
F
×
C
D
=
G
H
AE
+BG
CE
+DG
AF
+BH
CF
+DH
再帰式の変化
・ うまい和積取り方と組合せ方を考えて、7回の行列積で、4つのブ
ロックの解が得られるようにした
・ 計算時間の再帰式は、T(n) = cn2 + 8T(n/2) から
T(n) = cn2 + 7T(n/2) になった
・ log2 7 = 2.80… なので、およそ O(cn2.80) 時間になる
・ この方法で一番すごいやつは、100個以上の行列に分解して同様
のことを行い、O(cn2.31…) 時間を達成
A
B
E
F
×
C
D
=
G
H
AE
+BG
CE
+DG
AF
+BH
CF
+DH
疎な行列のかけ算
疎な行列のかけ算はどうなる?
・ 疎な行列は、疎な持ち方をすると効率が良い
・ 疎な持ち方にあったかけ算の仕方をしたとき、計算時間はオ
ーダーの意味で改善されているのかな?
 最悪の場合、全部のセルに値が入るので、改善されない
・ しかし、疎な行列に興味があるので、行列の疎性に応じた速
さの評価がしたい
・ 行列の大きさの他に、非ゼロのセルの数を使って、行列積の
計算時間を評価してみよう
行ごとに格納
・ 各要素へのアクセスを良くするため、少々構造をつけよう
・ まずは、1 である要素を、行ごとに分類し、記憶
 配列を n 個用意し、そこに各行にある 1 である要素の場
所(列番号のみ) を記録
・配列が n 個必要になったので、n個メモリが必要だが、行番
号を記録しなくてよくなったので、その分節約できる
・ これで、使用するメモリは、「1の数」+行の数×2(ほんとは1
にできる)になった。
・ アクセスも良くできる。各行の位置を小さい順に並べれば、2
分探索できる (1行の要素数が小さければスキャンで十分)
ベクトルの内積
・疎なベクトルの内積計算をする
・ 各行(ベクトル)を、添え字の小さい順に並びかえておく
・ 内積を取るベクトル2つを、小さいほうから同時にスキャンする
同時に、の意味は、同じ値、あるいは両者を混ぜ合わせて小さ
い順に並べたときに隣り合う要素を常に見ている、ということ
・ 両方とも非ゼロであるところが見つかったら、その要素の積を
答えに加える
1 5 5 1 7 3
1 1 3 3 5 4
疎行列積の計算時間
・ 左側の行列の各行について、右側の各列との内積を取る
 各 i 行につき、① その行が n 回スキャンされ、左側行列
の各列が1回ずつスキャンされる
 (i 行の大きさ)×n + (右行列の大きさ)
・ これを各行について行い、時間の合計を取ると
(左行列の大きさ)×n + (右行列の大きさ)×n
・ 行列の大きさ(非ゼロのセル数)が n2 よりもオーダーの意味
で小さければ、計算量の改善がある
バケツを使った方法
・ バケツを使った行列転置の計算方法と同じアイディアを使う
と、もう少し速くできる
・ 左行列の第 i 行と右行列の各列の内積を取る問題を考える
(i 行と j 列の内積を p(i,j) と表記する)
・ 第 i 行の非ゼロ要素が、l1,…,lk 番目にあるとする
・ p(i,j) を各 j について計算するには、各 l=l1,…,lk について、
第 i 行の l 番目の要素と、第 j 列の l 番目の要素の積を求
め、その総和を計算すればよい
バケツを使った方法
・ バケツを使った行列転置の計算方法と同じアイディアを使う
と、もう少し速くできる
・ 左行列の第 i 行と右行列の各列の内積を取る問題を考える
(i 行と j 列の内積を p(i,j) と表記する)
・ 第 i 行の非ゼロ要素が、l1,…,lk 番目にあるとする
・ p(i,j) を各 j について計算するには、各 l=l1,…,lk について、
第 i 行の l 番目の要素と、第 j 列の l 番目の要素の積を求
め、その総和を計算すればよい
計算する部分を図示
・ 第 i 行の非ゼロ要素のある位置 l1,…,lk に対応する行を見る
・ 各列について、要素の積を計算する
第i行
右側の行列
計算の順番を変える
・ 各列について、緑の部分にある非ゼロ要素を見つけるのは多
少手間がかかる (ゼロである要素を何度もチェックする)
・ 各l 行について積を計算するのならば楽
・ つまり、第 i 行のl 番目の要素
に対して、各列のl 番目の要素
との積を計算するならば楽
・ 計算した積は、各列の下にある
箱に足し込めばよい
 全ての行についてスキャンすると
箱の中身はその行の内積の値になる
第i行
右側の行列
行のスキャンにも疎性を
・ i 行のl 番目の要素と、各列のl 番目の要素との積を計算
 各列のl 番目が非ゼロであるところのみ、計算すればよい
(そうでないところは、積は 0 になるから)
・ 右側の各行について
非ゼロである要素の位置を
覚える方式で行列を記録すると、
スキャンするのが楽
第i行
右側の行列
計算時間
・ 左側の行列の各 i 行の非ゼロ要素がある位置、l について、
右側の行列のl 番目の行をスキャン
・ 左側の行列の各 i 行についてこの操作を行うと、行列の積の
計算ができる
・ そのとき、右側の行列の第 j 行は、左側の行列の第 j 列に
ある非ゼロ要素の数だけ、スキャンされる
・ 右側の行列の第 j 行の
大きさを S(j)、左側の行列の
第 j 列の大きさを T(j) とすると
計算時間は S(j)×T(j) 時間
T(j)
S(j)
計算時間
・ そのとき、右側の行列の第 j 行は、左側の行列の第 j 列に
ある非ゼロ要素の数だけ、スキャンされる
・ 右第 j 行の大きさを S(j)、左第 j 列の大きさを T(j) とすると
- 第 j 行 に関する計算時間は S(j)×T(j) 時間
・ 全体では Σi S(j)×T(j) 時間
- 各行・列の大きさが
定数なら O(n) 時間
- 行・列の大きさがべき乗則
に従うなら、(次数が2以上の
とき) O(n) 時間
T(j)
S(j)
逆行列の計算(行列の三角化)
逆行列
・ 行列の A の逆行列 A-1 の定義は
A-1 が AA-1 = I (単位行列)を満たすこと
(逆行列 A-1 は A が非退化/フルランク/正規/non-singlarであ
るときのみ存在)
・ A から A-1 を求めるのが、逆行列を求める問題
(黄色の行列の要素が何であるか求める問題(方程式を解くような
もの))
1
×
=
1
1
・・・
1
1
連立方程式へ
・ 求める黄色の行列の i 行 j 列の要素を xij とおくと、A と黄色行列
の掛け算をすると、解の各要素には線型方程式が入る
例) 解の 1行1列 a11xij + a21x12 + ・・・ + an1x1n
・ 解の行列が、単位行列になるように xij を決めると、 A-1 が求まる
例) 解の 1行1列 a11xij + a21x12 + ・・・ + an1x1n が 1 になるように xij
を定める  方程式を解いているのと同じ
x11 x12 ・・・ x1n
x21 x22 ・・・ x2n
×
1
=
xn1 xn2 ・・・ xnn
1
1
・・・
1
1
連立方程式を解く
・ 解の i 行 j 列 a1jxi1 + a2jxi2 + ・・・ + anjxin = 1 (あるいは0)
こういう方程式が n2 本ある
・ 一方、変数の数は n2 個  退化していなければ解ける
・ 線型方程式なので、代入していけば解ける
 それを機械的にやるようにすれば、アルゴリズムができる
x11 x12 ・・・ x1n
x21 x22 ・・・ x2n
×
1
=
xn1 xn2 ・・・ xnn
1
1
・・・
1
1
1つずつ代入
・ こういう方程式を解くときは、ある変数について方程式を解き、そ
れを残りに代入する
例) a1jxi1 + a2jxi2 + ・・・ + anjxin = 1
 xi1 = (1 - (a2jxi2 + ・・・ + anjxin)) / a1j
・ こういう操作をやって、すべての変数を消せばよい
・ 1行1列から順に代入を使って変数を消すアルゴリズムができる
x11 x12 ・・・ x1n
x21 x22 ・・・ x2n
×
1
=
xn1 xn2 ・・・ xnn
1
1
・・・
1
1
計算時間
・ 1つの変数を消すには、代入の式を作り(O(n2)時間)、それを他
の全ての式 (O(n2)個) に代入 (O(n2)時間)
 変数は n2 個あるので、O(n6) 時間
逆行列はO(n6) 時間で求められる
・ 次に、もう少し速い方法を紹介しましょう
x11 x12 ・・・ x1n
x21 x22 ・・・ x2n
×
1
=
xn1 xn2 ・・・ xnn
1
1
・・・
1
1
行列の基本変形と三角化
・ 行列のある行の○○倍を他の行に足すのが行列の基本変形
 左から、その列と行の要素を○○にした単位行列をかけるこ
とと同じ
・ 対角成分の上(下)部分にしか非ゼロの数が入っていないとき、
その行列は上三角行列(下三角行列)という
1
1
1
・・・
1
1
LU分解
・ 行列の下の行に上の行(の○○倍)を足して、上三角化行列を作
る(行列の基本変形をする)
・ 行列の基本変形を表す行列をかけると、非ゼロ部分を重ね合わ
せた行列になる、ので、
・ 全ての基本変形を掛け合わせると、下三角行列ができる
((このように上・下三角行列に分解することを、LU分解という)
1
1
1
1
×
・・・
1
1
1
1
1
=
・・・
1
1
1
1
・・・
1
1
LU分解で逆行列を
・ 三角化した行列下の逆行列は簡単に求まる
 方程式を作ると、解行列の一番下の行では、変数が1つしか
ないので、簡単に解ける。その結果を代入すると、下から2番目
の行が解ける、、、という具合になる。
・ 左から三角行列の逆行列をかけて、逆行列を求める方程式を簡
単な方程式に変形してしまおう
1
1
1
1
×
・・・
1
1
=
1
1
・・・
1
1
逆行列を
・ 三角化した行列で表した逆行列の方程式に、左から基本変形行
列の逆行列をかける
 左側の三角行列がキャンセルして消える
・ 解きやすい形になりました
 三角化できていれば、行列積(O(n3))と代入(O(n3))の時間
(O(n3))で逆行列が求まる
A-1
A
-1
×
-1
×
×
=
1
× ・・・1
1
行列の基本変形と三角化
・ 上の行の定数倍を下の行から引く
 下の行の特定の要素が消せる
- 1列目を使って、それより下の行の1列目を消す
- 2列目を使って、それより下の行の2列目を消す
…
とすれば、上三角行列になる(基本変形の行列は下三角)
A
=
×
三角化の計算時間
- 1列目を使って、それより下の i 行の1列目を消す
 1列目の要素が同じ大きさになるように、
1行目に a1j /a11 を掛け、それをi 行から引く
・ 各行について、O(n2) 時間でできる。全部の行について計算
すると O(n3) 時間
逆行列はO(n3) 時間で求められる
A
=
×
疎な行列だと?
・「1行目に a1j /a11 を掛け、それを i 行から引く」
・ この操作で、 i 行のゼロ要素が一部非ゼロになる( fill in という)
 何回も行うと、多くのゼロだった要素が非ゼロになり、密になる
・ 現実的には、 fill in がなるべくおこらないように、行・列を入れ替
えておけばいい。例えば、上の行、左の列が疎になるようにとか
A
=
×
グラフ探索
探索問題
・ 自分のいるところから到達可能なところを全て調べる問題を
考える(これを探索とよぶ)
・ 例えば、グラフ・ネットワークで、現在地から到達できるところ
を全部調べる
 行けるとは、枝をたどって移動すること
・ グラフの中で、ある頂点から到達可能な頂点を全て調べる問
題・調べることをグラフ探索という
どういう戦略をとるか
・ 有向グラフ G=(V,A) の頂点 v から到達可能な頂点を全て出
力する
・ 素朴にするなら、全ての頂点に対して、v から到達可能かどう
か調べる
 v から到達可能でないことを確認すること自体が、ある種、 v
から到達可能な頂点を全て調べることに対応
・ 迷路を調べるように、たどっていけるところを少しずつ広げて
いくほうがいいだろう
迷路を解くには?
・ 迷路を解く(迷路でいけるところを全部調べる)にはどうする
か?
- 出発点から始め、道(枝)をたどる。分かれ道は適当に選ぶ
- 行き止まりに来たら、前の分かれ道まで戻って、行ってない
方に行く
- 行ってないほうがなくなったら、さらに戻る
- 一回行ったことのあるところに出たら、引き返す
・ 右手法は、立体交差があるとだめ、入り口と出口が外につい
ていないとだめ、なので、少々不完全
グラフバージョン
・ 迷路を解く方法を、グラフに焼きなおそう
- 出発頂点から始め、枝をたどる。各頂点で、次に進む枝は
適当に選ぶ
- 探索してない枝がない頂点に来たら、前の頂点まで戻って、
行ってない枝に行く
- 行ってない枝がなくなったら、さらに戻る
- 一回行ったことのある頂点に来たら、引き返す
・ 「行ったことのある頂点、たどったことのある枝」を覚えておく
必要がありますね
・ あと、各頂点に対して、「どの頂点からきたか」も
来たことあるか、を記憶
・ 「行ったことのある頂点、たどったことのある枝」を覚えておく
・ 行ったことのある頂点は、各頂点にフラグを用意しておく
フラグ=0  行っていない
フラグ=1  行ったことがある
・ さらに、「どの頂点から来たか」も一緒に記憶できる
フラグ=-1 or NULL など  行っていない
フラグ=それ以外
 直前にいた頂点
・ 枝も同じようにして覚えられる、がそうしなくても、もっといい方
法がある
順番に枝をたどる
・ 各頂点に隣接する枝は、番号順か何かで、1つずつたどれる
としよう(隣接リスト、隣接配列などで)
・ 最後にたどったのは何番目の枝かを覚えておき、次に枝をた
どるときは、その次をたどる
・ 頂点の数だけのメモリしかいらない
プログラムを書く
・ グラフの探索のための、データはそろった
・ さて、アルゴリズム(プログラム)はどうしようか
・ 各頂点で何をするか考える
 接続する枝をたどる
 訪問済み(行ったことある)だったら、戻る
 訪問してなかったら、先を探索する
 行った先の頂点でも、同じように枝をたどる
 行った先から帰ってきたら、次の枝を調べる
同じか。じゃあ、再帰呼び出しだ
再帰呼び出しで書く
・ 再帰呼び出しの各サブルーチンでは何をするか
・ 頂点を与えると、
- 接続する各枝に対して、その先を探索するよう、再帰呼び
出しをする
- ただし、訪問済みならば再帰呼び出ししない
- 全部の枝を調べたら、サブルーチン終了  自動的に、もと
来た頂点に戻る
・ どこから来たか、を覚えなくてよくなり、仕組みが簡単になりま
した
再帰呼び出しによるコード
・ deg[v] は vの次数、edge[v] はv に隣接する頂点の配列
・ flag[v] は v の訪問済みマーク。最初は全部0にしておく
int flag[n], edge[n][];
DFS (int v){
int j;
flag[v] = 1; // v に訪問済みマークをつける
for ( j=0 ; j<deg[v] ; j++){
if (flag[edge[v][j]] = = 0 ) DFS( edge[v][j]); // 訪問済みでなければ
再帰呼び出し
}
}
再帰呼び出しを使わないコード
・ deg[v] は vの次数、edge[v] はv に隣接する頂点の配列
・ flag[v] は v の訪問済みマーク。最初は全部 -1 にしておく
int flag[n], k[n], edge[n][];
DFS_ (int v){
int j, vv=v;
for ( j=0 ; j<n ; j++){ flag[j] = -1; k[j] = 0; }
flag[v] = v; // 最初の頂点だけ、自分から来たことにする。特別
while (1){
if (k[v] = = deg[v] ){
if (flag[v] = = vv ) return; // 最初の頂点の枝を全部たどったら終了
v = flag[v]; // そうでなければ、もときたところに戻る
} else {
if (flag[edge[v][k[v]]] < 0 )
{flag[edge[v][k[v]] = v; v = edge[v][k[v]]); } // 枝をたどる
}
}
}
深さ優先
・ 先ほどの探索方法は、先へ先へと探索を進めていく
いわば、深いほうをどんどんたどる
・ つまり、最後に見つけた頂点の隣を調べている
 スタックと考え方が同じ
・ じゃあ、スタックを使ってアルゴリズムをつくろう
 新しく訪れた頂点に隣接する頂点で、訪れたことがないもの
をスタックに入れる
 そして、スタックから頂点を取り出す
 ある頂点から先の探索が終わると、次に探索するべき頂点
が、スタックの最後にある
スタックを使ったコード
DFS_ (int v){
int j;
STACK S;
for ( j=0 ; j<n ; j++) flag[j] = -1;
STACK_init ( &S, n);
// スタックの初期化
STACK_push ( &S, v);
// スタックに最初の頂点を挿入
flag[v] = 1;
while ( S->t > 0){ // スタックが空になるまで繰り返す
STACK_pop (S, &v); // スタックから頂点を取り出す
for ( j=0 ; j<deg[v] ; j++ ){ // 接続する枝を順番に調べる
if (flag[edge[v][j] < 0 ){
flag[edge[v][j] = v; // もと来た頂点を v に設定
STACK_push (&S, edge[v][j]) } // 訪問していなければ、スタックに入れる
}
}
}
STACK_end ( &S );
// スタックの解放(終了操作)
}
探索の順番
・ 先ほどの探索方法は、いわばスタック的に新しい頂点を見つ
ける
・ それが、迷路をたどる操作とちょうど等価になっている
・ しかし、コンピュータは物理的に「たどる」必要がないので、探
索していない頂点なら、実はスタックに入っている頂点、どれ
を選んでもいい
 ランダムに選んでもいい
 探索済みの領域をじわじわと広げていくイメージ
・ スタックの代わりにキューを使ってもいい
探索の順番
・ キューは、最初に入れたものが最初に出てくる
 探索に使うと、出発点の近くが早くにキューに入る
つまり、優先されて探索される
・ こういった、次に探索する頂点をキューに入れて探索する探
索法を幅優先探索という
・ 対して、先ほどのスタックを使うものは、深さ優先探索とよば
れる
幅優先探索の性質
・ 幅優先探索を実行すると、
・ まず出発点に隣接する頂点(距離1の頂点)がキューに入る
・ 次に、距離1の頂点に隣接する頂点(距離2の頂点)がキュー
に入っていく
 これら距離2の頂点は、距離1の頂点が全部なくなってから、
キューから出てくる
 そのとき、距離2の頂点は全部キューに入っている
 距離2の頂点が全部キューに入ってから、距離2の頂点が処
理される
 以後、距離kの頂点が全部キューに入ってから、距離k+1の頂
点が処理される
距離の計算
・ つまり、幅優先探索を使うと、出発点からの距離がわかる
(出発点から頂点までの、移動に使う枝の最小)
・ 各頂点をキューに入れるときに、その頂点の距離を、今の頂
点の距離+1にする
・ これより短く行くことはできないので、これが正しい距離になる
出てくる
キューを使ったコード
BFS (int v){
int j;
QUEUE Q;
for ( j=0 ; j<n ; j++) flag[j] = -1;
QUEUE_init ( &Q, n );
// キューの初期化
QUEUE_ins ( &Q, v );
// キューに最初の点を挿入
flag[v] = 0;
while ( Q->s != Q->t){ // キューが空になるまで繰り返す
QUEUE_ext ( Q, &v ); // キューから頂点を取り出す
for ( j=0 ; j<deg[v] ; j++ ){
if (flag[edge[v][j] < 0 ){
flag[edge[v][j] = flag[v]+1; // 距離を v までの距離 +1 に設定
QUEUE_ins (&Q, edge[v][j]) } // 訪問していなければ、キューに入れる
}
}
}
QUEUE_end ( &Q );
// キューの解放(終了操作)
}
列挙アルゴリズム
列挙問題
・ 列挙問題とは、与えられた問題の解を全て出力する問題
 さっきから出てきている例は、列挙問題
- 入力した数の組合せで、合計がある範囲にあるものを全て
出力する
- 入力グラフに含まれるパスを全て見つける
- 入力グラフに含まれるクリークを全て見つける
- 大きさ k までの2分木を全て出力する
- 入力した文字列の部分文字列で、複数回現れるものを全て
見つける
- 入力した数列の中から部分降順列を全て見つける
・ 列挙問題を解くアルゴリズムを列挙アルゴリズムという
列挙アルゴリズムの評価
・ 列挙問題は自然に、指数個の解を持つ
 出力にかかる時間だけで指数時間なので、多項式時間の
議論ができない
・ そこで、「出力の大きさ」もパラメータとして考え、計算時間が
入力と出力の大きさの多項式であるとき、出力多項式時間と
いい、列挙アルゴリズムの効率性の指標にする
・ ただ、実際は出力が非常に大きいことが多いので、解1つあ
たりの計算時間(入力の多項式時間)で評価することも多い
遅延
・ 列挙問題の解の数は、計算してみないとわからない
 出力多かったり、時間がかかったりする
・ 時間がかかる場合、途中でやめる、という選択肢があるが、
それならば、早めになるべく多くの解が手に入るほうがいい
・ そこで、解が1つ出てから次の解が得られるまでの計算時間
の最大値を遅延(あるいは遅延時間)と定義し、それで早め
に解が出てくるかどうかを評価する
 出力多かったり、時間がかかったりする
・ 遅延が入力の多項式なら多項式遅延、今まで見つけた解の
数、と入力の多項式なら逐次多項式時間