Document

行列とグラフのデータ構造
・ 行列
・ 2値行列
・ 疎行列
・ 行列とベクトルの演算
・ グラフと隣接行列
・ 隣接リスト
行列とグラフ
・ 行列は、2次元の構造を持ったデータ
・ 数値計算や物理シミュレーションから、顧客データ管理ま
で、非常に幅広く使われる
・ グラフも、非常に多くの分野で使われる
・ 行列やグラフをどのように保持/更新して、どのように扱
うか、データ構造とアルゴリズムを見てみよう
行列の2次元構造
・ n×m 行列には、数値がn×m 個ある
 大きさ n×m の配列を作れば記録できる
[i,j] 要素にアクセスするときには、配列 の[i*m+j]番目
の要素にアクセスすればよい
・ とりあえずはこれでいいのだが、もう少し深く見てみよう
2次元配列
・ 配列は通常1次元だが、2次元の配列を作ることもできる
(よくある方法)
・ まず、大きさ n の、ポインタ配列を用意する
・次に、大きさ m の配列を n 個用意し、ポインタがそれぞれ
の先頭を指すようにする
・ [i,j] 要素にアクセスするには、a[i][j] と書く( Cの場合)
きれいに保存できる
メモリは O(nm)
2次元配列の確保
・ 一応、コード
int *MATRIX_alloc ( int n, int m ){
int i, **a, flag =0;
a = malloc ( sizeof(int *)*n );
if ( a=NULL ) return (NULL);
for ( i=0 ; i<n ; i++ ){
a[i] = malloc (sizeof(int)*m);
if ( a[i] = NULL ) flag = 1;
}
if ( flag == 1 ) return (NULL); else return (a);
}
int *MATRIX_free ( int **a ){
int i;
for ( i=0 ; i<n ; i++ ) free ( a[i] );
}
2値行列
・ 値が0と1しかないような行列を2値行列という
- ○×のみが書いてあるデータ
- 後で出てくる、グラフの隣接行列も
・ 値が01だけなのに、整数1つ使って記憶するのはもったいない
 圧縮しよう
01010
10001
11110
11000
ビットによる表現
・ 01からなる2値行列の各行を2進数だと思うと、通常の整数1
つになる
 しかし、桁が大きすぎて1つの整数には入りきらない
 入りきるように、ぶつ切りにしよう
(例えば、整数が32ビットだったら、32個ずつ区切る)
 m/32 (切り上げ) 個の整数で記録できる!
(メモリを節約している。キャッシュ効率も多少良くなる)
・ [i,j] 要素にアクセスするときには、 i行目の j/32 (切捨て)個目
の整数の j%32ビット目にアクセス
ビット取出しを簡単に
・ [i,j] 要素にアクセスするときには、 i行目の j/32 (切捨て)個目
の整数の j%32ビット目にアクセス
 めんどくさい、簡単にアクセスできるようにしよう
・ 配列を用意しておく
BIT_MASK[]= {1,2,4,8,16,…}
BIT_MASK_[]= {0xfffffffe, 0xfffffffd, 0xfffffffb, …}
-値の読み出し: a[i][j/32] & BITMASK[j%32]
-1 にする:
a[i][j/32] = a[i][j/32] | BITMASK[j%32]
-0 にする:
a[i][j/32] = a[i][j/32] & BITMASK_[j%32]
疎行列
・ 行列の、簡単な格納の方法は、以上でおしまい
・ メモリの効率も、ある意味で最適
・ しかし、現実にはこれでは不十分なことがある
 行列が疎だと、多くの部分が無駄
・ 疎な行列とは、ほとんどの要素に同じ値が入っている(通常
0)行列
・ 疎な行列は、0でないところのみを記憶したい
疎な行列の格納
・ 簡単のため、まずは2値行列から考えよう
 ほとんどの要素は 0 で、少し 1 がある
・ 簡単なアイディアとしては、1 の場所だけとっておく、というも
のがある
・ つまり、配列か何かに (x1,y1),(x2,y2),(x3,y3),… と、1 である要
素の行番号と列番号を記録する、というもの
・ これで、使用するメモリは、「1の数」(値が1である要素の数)
の2倍になった。疎であれば、非常に有利
・ しかし、アクセスは悪い。ある場所が1かどうか調べるには、
場所の配列を全部スキャンしなければいけない
行ごとに格納
・ 各要素へのアクセスを良くするため、少々構造をつけよう
・ まずは、1 である要素を、行ごとに分類し、記憶
 配列を n 個用意し、そこに各行にある 1 である要素の場
所(列番号のみ) を記録
・配列が n 個必要になったので、n個メモリが必要だが、行番
号を記録しなくてよくなったので、その分節約できる
・ これで、使用するメモリは、「1の数」+行の数×2(ほんとは1
にできる)になった。
・ アクセスも良くできる。各行の位置を小さい順に並べれば、2
分探索できる (1行の要素数が小さければスキャンで十分)
各行のデータ構造
・ だいぶ効率は良くなったが、要素の挿入や削除を効率良く行
おうとすると、やはり問題がある
・ このあたり、スタックやキューのときとまったく同じ
・ なので、目的に応じて、リスト、片方向リスト、ハッシュ、2分
木などを使い分ければよい
(そもそも、行列のデータ自体がバケツのようなもの)
・ 行列全体をハッシュで格納する荒業もある
(行番号・列番号の組からハッシュキーを計算するようにする)
実際のデータ
・ 実際の疎な行列データでの状況はと言うと、
- 構造計算で使われる、メッシュの隣接行列
幾何的に隣り合うものが少ないので、各行には必ず非ゼロ
の要素が複数あり、上限も10程度で抑えられる
 各行のスキャンで十分。データの更新も、普通ないので、配
列でとる
- 道路データ(交差点の隣接関係+その距離)
 これも同じ。ただし、時々更新があるので、更新しやすい
ほうがいい (が、配列のサイズを変えるだけで十分かも)
実際のデータ
・ 列が単語、行が文書であり、単語が文書に含まれると1にな
るような行列は疎になる
(顧客売り上げデータ、webのリンクなども)
- 平均すると、1行の1の数は定数だが、1部にすごく密なと
ころがある
(多くに含まれる単語、多くの単語を含む文章)
- パワー則、スケールフリー、zip則と言われる分布で、大き
さがDであるもの数がΔのD乗に反比例するような分布。現
実データに良く現れる
(幾何分布とは違う)
- こういったデータに対しては、密な部分がボトルネックにな
らないよう注意する(スキャンしないようにする)
2値でない疎な行列
・ 通常の行列(2値でない値を持つ)で、疎なものを保持するに
は、0でない場所を覚えておくだけではだめ
 場所と値を覚えなければいけない
・ 配列なら、場所・値・場所・値・・・、あるいは、場所・場所・・・
値・値・・・と記録する
・ リストや2分木なら、各要素・セルに場所と値が記録できるよ
うにする。あるいは、リストor2分木を2つ用意する
練習問題
・ 次の行列を、疎な形式で表したデータを作れ
0,0,1,4,0
0,1,0,0,5
2,0,0,0,0
1,2,5,0,2
0,0,0,0,0
~余談~:2次元配列の大きさを節約
・バケツ、あるいは疎な行列は、2次元配列で素直に確保すると、
1行当たり少なくとも2つデータが要る
 1次元配列の先頭へのポインタと、配列の大きさ ki
・これを1つに減らそう
・ まず、行列全体の大きさと等しい配列を1つ準備する
- 0行目は 0 番目から k0-1 番目までを使う
- 1行目は k0番目から k0+k1-1 番目までを使う
- i 行目は k0+…+ki-1番目から k0+…+ki-1番目までを使う
とし、開始位置だけを覚える
・ i 行目の大きさは、 i+1 行目の開始位置- i+1 行目の開始位
置で計算できるので、不要になる
行列演算
・ 行列の演算は、標準的なもので、加算、乗算。
(ベクトルの内積は行列の掛け算の特殊ケース)
その他、2値行列は、ANDをとったりORをとったりする
・ 2次元配列で持っている場合、なんの問題もないが、疎な形
式で持っている場合は、演算も自明ではない
・ また、行列演算がやりやすいようにするデータ構造もある
行列の足し算
・ 行列を足し算するには、各行の足し算ができれば十分。それを
各行に対して行えばよい
(つまりは、ベクトルの足し算)
・ まずは、簡単な、ベクトルの内積計算を見てみよう
ベクトルの内積
・疎なベクトルの内積計算をする
・ 各行(ベクトル)を、添え字の小さい順に並びかえておく
・ 内積を取るベクトル2つを、小さいほうから同時にスキャンする
同時に、の意味は、同じ値、あるいは両者を混ぜ合わせて小さ
い順に並べたときに隣り合う要素を常に見ている、ということ
・ 両方とも非ゼロであるところが見つかったら、その要素の積を
答えに加える
1 5 5 1 7 3
1 1 3 3 5 4
ベクトル内積計算のコード
int SVECTOR_innerpro (int *va, int ta, int *vb, int tb){
int ia=0, ib=0, c=0;
while ( ia<ta && ib<tb){
if (va[ia*2] < vb[ib*2] ) ia++;
else if (va[ia*2] > vb[ib*2] ) ib++;
else {
c = c + va[ia*2+1]*vb[ib*2+1];
ia++; ib++;
}
}
return ( c );
}
1 5 5 1 7 3
2 1 3 3 5 4
ベクトルの足し算
・ ベクトルの足し算も、やり方は同じ
・ 各行(ベクトル)を、添え字の小さい順に並びかえておく
・ 足すベクトル2つを、小さいほうから同時にスキャンする
・ 足して、非ゼロになるところは、片方で非ゼロになるところだけ
なので、スキャンすれば全てわかる
1 5 5 1 7 3
2 1 3 3 5 4
ベクトルの足し算のコード
int SVECTOR_add (int *vc, int *va, int ta, int *vb, int tb){
int ia=0, ib=0, ic=0, c, cc;
while ( ia<ta || ib<tb){
if (ia == ta ){ c = vb[ib*2+1]; cc = vb[ib*2]; ib++; }
else if ( ib == tb ){c = va[ia*2+1]; cc = va[ia*2]; ia++; }
else if (va[ia*2] > vb[ib*2] ) { c = vb[ib*2+1]; cc = vb[ib*2]; ib++; }
else if (va[ia*2] < vb[ib*2] ) { c = va[ia*2+1]; cc = va[ia*2]; ia++; }
else {
c = va[ia*2+1] + vb[ib*2+1]; cc = vb[ib*2];
ia++; ib++;
}
vc[ic*2] = cc; vc[ic*2+1] = c; ic++;
}
return ( ic );
}
1 5 5 1 7 3
2 1 3 3 5 4
~余談~: エンドマークの活用
・ ベクトルの内積に比べ、ベクトルの足し算はプログラムが長い
 添え字が配列の終りにいっている場合、例外処理をしな
ければならないから
・ エンドマークを使って、プログラムを簡単にしよう
(エンドマークとは、配列の要素の、最後のセルの次のセルに、
特別な値を書き込むことで、そこが端だと認識させるもの)
・ エンドマークには、とても大きな(小さな)値、あるいは 0 などを
用いる
・ あらかじめ配列の大きさを1つ大きめに確保し、最後の要素の
次にエンドマークを書いておく
~余談~: エンドマークの活用 (2)
int SVECTOR_innerpro (int *vc, int *va, int ta, int *vb, int tb){
int ia=0, ib=0, ic=0, c, cc;
while ( va[ia*2] != ENDMARK && vb[ib*2] != ENDMARK){
if (va[ia*2] > vb[ib*2] ) { c = vb[ib*2+1]; cc = vb[ib*2]; ib++; }
else if (va[ia*2] < vb[ib*2] ) { c = va[ia*2+1]; cc = va[ia*2]; ia++; }
else {
c = va[ia*2+1] + vb[ib*2+1]; cc = vb[ib*2];
ia++; ib++;
}
vc[ic*2] = cc; vc[ic*2+1] = c; ic++;
}
vc[ic*2] = ENDMARK;
return ( ic );
1 5 5 1 7 3 ■
}
2 1 3 3 5 4 ■
行列の掛け算
・疎行列の掛け算をするには、各行と各列の内積を全ての組合
せについて計算すればいい
・ しかし、疎な行列は、行ごとにデータを取っているので、列ベク
トルを取得するのは簡単ではない
・ 簡単な方法は、バケツの使用例で出てきた、疎な行列を転置
するプログラムを使う方法。これで向きがそろうので計算でき
る
・ その他に、最初から列方向にもたどりやすい構造を使う方法
がある
4方向リスト
・ 疎なベクトルの保持にはリストが向いている
・ しかし、ベクトルを集めても、縦方向にスキャンできないのが困
る
・ では、縦横につながるリストを作ろう
・ 各セルが、腕を4本持っていて、上下左右に隣り合うセルに対
してポインタを張る
4
7
2
腕の伸ばし方
・ 4方向に腕が出ると、一見、メッシュ構造になりそうだが、そう
はならない
・ 腕と腕が交差することがある
・ 腕別の見方をすると、横方向、縦方向のベクトルのリストを作
り、同一のセルに対応する要素を1つにしたもの
4
4
4
7
2
4
リストを2方向作るのと比べて
・ リストを行ベクトル、列ベクトル方向に持てば、同じようなアクセシ
ビリティが得られるが、セルの挿入・削除を行うときに差が出る
・ 例えば、横方向にスキャンして行き、これを消そう、と思ったときに、
縦方向のリストのどの位置にあるか調べるために、縦方向のリ
ストをもう一度スキャンしなくてはいけない
4
4
7
グラフ構造
・ 頂点の集合と、枝の集合からなる構造をグラフという
(枝は2つの頂点の対)
・ 集合による定義なので、絵で書いたときに、頂点や枝の位置情
報、枝の交わる/交わらないといった情報は、グラフとは直接
関係がない
(位置情報が付いたグラフを、
グラフの描画、平面に埋め
込んだグラフ、などとよぶ)
・枝には、向きをつけてもよい(有向グラフという)
非常にポピュラーな構造
グラフデータの例
・ 隣接関係、上下関係、類似関係...
・ web ネットワーク(ページのリンクを枝と思う)
グラフの用語
・ 枝 e が頂点 u,vを結んでいるとき、e は u,v に接続する、u,v は
e に接続する、 u,v は隣接する、 u とは v に隣接する、という
・ 頂点 v に接続する枝の数を、v の次数という
・ 全ての頂点間に枝があるグラフを 完全グラフ という
・ 2つの頂点間に枝が2本以上ある場合、その枝を 多重枝 という
・ 頂点集合が2つのグループに分かれ、同一グループ間には枝
がない、というグラフを 2部グラフ という
グラフの保持
・ n 個の頂点は、0,…,n-1 の数の集合とみなせる
・ すると、各枝は、2つの数値の組
 配列などにこの組を記憶すれば、データとして保持できる
・ しかし、何も構造がないので、使いづらい
(例えば、ある頂点に接続する枝を全てスキャンする、といった操
作がやりにくい。特に疎な場合)
・ 構造を作って、いじりやすくしよう
行列の利用
・ グラフは行列の形で表現できるので、行列として保持できる
① 各列、各行を頂点に対応させ、頂点 i と頂点 j を結ぶ枝があ
るとき、 i 行 j 列を1にする (隣接行列という)
- 密なグラフを2次元配列で保持した場合、効率いい
② 各行を頂点に対応させ、各列を枝に対応させる。頂点 i が枝 j
に接続しているとき、 i 行 j 列を1にする (接続行列という)
- 密なグラフを2次元配列で保持すると、効率が悪い
・ 両方とも、2値行列になる
実用上は
・ 実用上は、比較的小規模なシミュレーションを行う場合などは、
2次元で持つ形式で十分。速度も速い。
・ 頂点数が1000で、次数が平均的に10程度といったすかすか
のデータでは、疎な持ち方をしたほうが効率が良い
(目安としては、枝数が頂点数の2乗の1/10くらいであるときが、
境目)
・ 各頂点に隣接する枝をスキャンする、といった操作(例えばグラ
フ探索で使う)が多いときは、疎な持ち方が有利
- 2つの頂点間に枝があるかどうかを調べることが多いときは2
次元配列で持つのが有利
接続行列を持つ
・ 接続行列は、頂点と枝の接続関係を表した行列
・ 頂点に 0,…,n-1、枝に 0,…,m-1 の番号をつける
・ 接続行列が何をしているかというと、
- 各頂点に対して、接続する枝の番号を記憶
- 各枝に対して、接続する頂点の番号、2つを記憶
0
1
5
5
4
0
2
6
1
3
4
2
8
3
7
0: 1,3
1: 0,2,4,5
2: 1,3,4,5
3: 0,2
4: 1,2,5
5: 1,2,4
0: 0,2
1: 0,1,3,4
2: 4,6,7,8
3: 2,7
4: 3,5,8
5: 1,5,6
+
0: 0,1
1: 1,5
2: 0,3
3: 1,4
4: 1,2
5: 4,5
6: 2,5
7: 2,3
8: 2,4
接続行列の利点
・ 接続行列だと、枝がIDを持つので、枝がデータを持つ場合に、
管理がしやすい (重み、距離など)
 枝に 0,…,m-1 の番号が付いていれば、大きさ m の配列を確
保すればよい
・ 隣接行列だと、枝にIDがない(各頂点について、隣接する頂点
しかえられない)ので、枝と重みの対応をとるのが面倒
0: 0,1
1: 1,5
・ 多重枝の管理も楽
2: 0,3
0: 1,3
0: 0,2
3: 1,4
0 0
1: 0,2,4,5
1: 0,1,3,4
4: 1,2
1
5
1
2
2: 1,3,4,5
2: 4,6,7,8 + 5: 4,5
6
3
4
5
3: 0,2
3: 2,7
6: 2,5
4 8
2
4: 1,2,5
4: 3,5,8
7: 2,3
5: 1,2,4
5: 1,5,6
8: 2,4
3 7
各セルでメモリを確保
・ 接続行列の中身は、各枝がリストのようにつながったものなの
で、リストのように、各枝に対して構造体を作り、メモリを確保し
ても良い(4方向に腕のあるセルを使う)
 配列のようなメモリの制限がなくなる
・ 配列型のリストを使う場合、リストを2つ使うのもよし
・ あるいは、枝 i が対応するセルは、 2i と 2i+1 、のようにして4
0: 0,1
本の腕を実現してもいい
1: 1,5
2: 0,3
0: 1,3
0: 0,2
3: 1,4
0 0
1: 0,2,4,5
1: 0,1,3,4
4: 1,2
1
5
1
2
2: 1,3,4,5
2: 4,6,7,8 + 5: 4,5
6
3
4
5
3: 0,2
3: 2,7
6: 2,5
4 8
2
4: 1,2,5
4: 3,5,8
7: 2,3
5: 1,2,4
5: 1,5,6
8: 2,4
3 7
練習問題
・ このグラフの隣接行列と、疎な形式の接続行列を作れ
6
0
5
1
4
2
3
2部グラフの保存
・ 2部グラフは、行列のデータなどと同一視されることが多い
 片方の頂点集合と行を、もう片方の頂点集合と列を対応させ、
枝のあるところに1を立てる
・ 違う形の隣接行列が得られる
0
4
1
5
2
6
3
0: 4,6
1: 4,5
2: 5,6
3: 5,6
~余談~: 巨大なグラフを保存する
・ グラフを保持しようとすれば、1つの枝に対して少なくとも2つ
のポインタ(あるいは整数)が必要。重みなどの情報が加わ
るなら、さらに増える
・ 32ビット整数なら、1つの枝に64ビットは必要
・ しかし、web などのグラフだと、ページ数が10億、枝数が200億
といったレベルになる
 そのまま蓄えると160GB!
・ これではとてもメモリに入らない。どのようにして記憶しよう
か?
~余談~: 巨大なグラフを保存する (2)
① 次数の大きな頂点は少ない。つまり、ほとんどの枝は、数の
少ない、次数の大きな頂点に接続している
 頂点の添え字を次数順にして、小さな添え字は少ないビット
で表すようにする。つまり、数字の大きさによって、必要なビッ
ト数を変える
例)
・ ビット列の最初が0なら、残り7ビットが添え字を表す 0-127
・ ビット列の最初が10 なら、残り14ビットが添え字を表す (016383)+128
・ ビット列の最初が11 なら、残り30ビットが添え字を表す
~余談~: 巨大なグラフを保存する (3)
② 頂点をサイトのアドレスの辞書順で並べる
 サイトからのリンクは、自分のそばのページをさしているこ
とが多いので、頂点の差分が小さくなる
・ ここで、さっきと同じ手を使う。つまり、行き先の頂点との差で
枝を記録し、可変長のビット列で表現する
・ これで、1枝 10ビット程度まで小さくできるそうだ。
もっと小さくすると、1つの枝に5ビット程度になるそうだ
 なんとか20GB程度におさまり、ワークステーション級のコン
ピュータならメモリ上に記録できる
まとめ
・ 行列の保持の仕方、2次元配列と疎な形式
・ 4方向リストを使った行列の保持
・ グラフ構造の保持:隣接行列と接続行列、隣接リスト