BFS(幅優先探索)とぶつかり稽古
AtCoderをやっていく上で必ずぶつかるのがこのBFS。グラフ探索の手法である。
ここでは原理の復習から実装までを行う。
なおこの記事を書く上で以下の記事をものすごく参考にさせてもらった。ほんま感謝やでえ。
BFSの動作
BFSは重みなしグラフ(もしくは各辺の重みが等しいグラフ)における、探索の始点となる頂点から各頂点への最短経路を求めることのできるアルゴリズムである。
BFSのアイデアは非常に単純で、まず始点の近傍を探索、次にその近傍を探索,さらにそのまた近傍を探索...と続け、近傍から到達可能な全ての頂点が探索済みになった時点で終了するというものだ。
アルゴリズムの概要は以下の通り。
ぼくを含めたみんなはこれを見ただけじゃどういうことか分からないと思うので以下でイメージの図(おんなじ意味!)を与える。
オレンジが「訪問予定」、赤が「訪問済み」、各点左上に書かれた数字がその点までの最短経路長である。10枚目の画像でキューが空となったのでアルゴリズムは終了する。
(追記)
10枚目の頂点6の上に3を書き忘れてました。ごめんな。
BFSが最短経路長を正しく与える証明
BFSの正当性は自明では無いためここで証明を与える。
なお、以下ではグラフが有向グラフである場合のBFSの正当性の証明を与えるが、無向グラフは各辺を二本の有向辺と捉えることで有向グラフとして表現できるため、同時に無向グラフの場合でもBFSが正当であることが証明される。
では、まず証明に用いる補題の証明から。
を有向グラフとし、とする。また、に対してをからへの最短路とする。
この時、 (経路上に含まれる頂点集合)に対して上のまでの経路はからへの最短路である。
(証明)
あるに対して上のまでの経路がからまでの最短路になっていないような頂点の存在を仮定する。
すると、と上の以降の経路を繋いだ経路はより短い経路となりが最短路であることに矛盾する。
続いて本題の証明。
(証明)
に対して、をからへの最短路長とする。からへの経路がない場合はとする。このとき、任意のに対してとなることを示す。まず、任意のと任意のに対してとなることを示す。
k=0のとき、となる頂点はのみである。このときである。また、となる頂点ものみであり、である。したがってのとき成立する。
のときに主張が成立すると仮定する。まずを示す。
となる頂点を固定する。と仮定すると、帰納法の仮定よりとなり矛盾する。したがってこのときである。
をからへの最短路とする。Lemmaよりのの1つ前の頂点をとすると、から辺と頂点を取り除いた経路はからへの最短路になっていてが成り立つ。
すると仮定より、となる。よって、任意のへの最短路についての1つ前の頂点をとすると、となる。
以上をまとめると、という仮定のもとに入る頂点の中でとなるものが存在する。
同時に、に入る頂点でとなるものは存在しない(そのようなが存在する場合、帰納法の過程よりとなるがそのとき明らかにとなってしまうので矛盾)。
BFSのアルゴリズムではの値の昇順に探索が行われるのでの値はとなる頂点によって書き換えられ、となる。
次にを示す。
となる頂点を固定する。と仮定すると上と同様の理由から矛盾。したがってである。
の値を書き換えた頂点をとすると、となる。すると帰納法の仮定よりである。
をからまでの最短路とし、をに辺と頂点を付け加えた経路とすると、の長さはである。であったのではからへの最短路となっている。したがってこのときとなる。
よって、任意のと任意のに対してとなることが示された。
また、上の証明よりの有限性との有限性も同値であることがわかる。したがってには到達不可能となる。これらを合わせて、任意の頂点に対してとなることが示された。
まあ証明なんて別にどうでも良い。次に実装を行っていこう。
BFSの実装
以下のコードでBFSが実装できる。なお入力は以下の形式を想定している。
はグラフの頂点数、はグラフの辺数、各辺は頂点とをつないでいることを表している。
N,M=map(int,input().split()) G=[[] for i in range(N)] #グラフを隣接リストで表現する for i in range(M): a,b=map(int,input().split()) G[a].append(b) #aからbに入る有向辺が存在 G[b].append(a) #bからaに入る有向辺が存在 from collections import deque def BFS(u): #点uから各点への最短距離のリストを返す関数 queue=deque([u]) dist=[float('inf')] * N #uからの距離の初期化 dist[u]=0 #始点との距離は0 while queue: v=queue.popleft() for i in G[v]: if dist[i] == float('inf'): dist[i]=dist[v]+1 queue.append(i) return dist dist=BFS(0) #点0から各頂点への最短路 print(dist) #最短路のリストが出力される。到達不可能点の場合、最短路はinfとなる。
コード中で出てきた隣接リスト表現とは、各頂点に対し、から出ている辺のリスト(または接続している辺のリスト)を保持するというグラフの格納方法である。
またdequeとは、pythonの標準ライブラリcollectionsモジュールの型の一つでありデータをキューとして扱うときに用いる。様々なメソッドがあり、append()でキューの末尾に追加、popleft()で先頭要素の取得が可能である。
以上が最も基本的なBFSの実装であった。よくよく見れば何も難しい話はない。
名前が某K-POPグループに似ているからと言って、恐るるに足りない。
迷路問題でBFS
実際にAtCoderをやっているとBFSを用いる場面はたくさん出てくるが、その多くは上で見たようなグラフの問題ではなく、"迷路問題"であるように感じる(迷路問題が正しい呼称かは知らないが、とりあえず以下ではそう呼ぶ)。
具体的に以下の問題を考える。
大きさがの迷路が与えられる。迷路は通路と壁からできており、1ターンに隣接する上下左右4マスの通路へ移動することができる。スタートからゴールまで移動するのに必要な最小のターン数を求めよ。
これに対しては以下のようにBFSを行うことができる。
from collections import deque def debug_print(maze): #mazeをきれいに表示するだけの関数(無くても良い) for x in maze: for y in x: print(y,end=" ") print("\n") def clear_maze(Sx, Sy, Gx, Gy, maze): #(Sx,Sy):スタートの座標、(Gx,Gy):ゴールの座標 debug_print(maze) INF = float("inf") field_x_length = len(maze) #行数 field_y_length = len(maze[0]) #列数 dist = [[INF for i in range(field_x_length)] for j in range(field_y_length)] #距離の初期化 def bfs(): queue = deque([(Sx,Sy)]) #訪問予定点の座標をキューに追加していく dist[Sx][Sy] = 0 while queue: #queueに要素が入っている限り周り続ける x, y = queue.popleft() if x == Gx and y == Gy: #ゴール到着 break for i in range(0, 4): #4方向で動ける方向を探す nx = x + [1, 0, -1, 0][i] ny = y + [0, 1, 0, -1][i] #i=0:下、i=1:右、i=2:上、i=3:左 へ1マス移動 if (0 <= nx and nx < field_x_length and 0 <= ny and ny < field_y_length and maze[nx][ny] != '#' and dist[nx][ny] == INF): #動いた先が未到達点で壁じゃなかったら queue.append([nx,ny]) dist[nx][ny] = dist[x][y] + 1 return dist[Gx][Gy] return bfs() maze = [ ['#', 'S', '#', '#', '#', '#', '#', '#', '.', '#'], ['.', '.', '.', '.', '.', '.', '#', '.', '.', '#'], ['.', '#', '.', '#', '#', '.', '#', '#', '.', '#'], ['.', '#', '.', '.', '.', '.', '.', '.', '.', '.'], ['#', '#', '.', '#', '#', '.', '#', '#', '#', '#'], ['.', '.', '.', '.', '#', '.', '.', '.', '.', '#'], ['.', '#', '#', '#', '#', '#', '#', '#', '.', '#'], ['.', '.', '.', '.', '#', '.', '.', '.', '.', '.'], ['.', '#', '#', '#', '#', '.', '#', '#', '#', '.'], ['.', '.', '.', '.', '#', '.', '.', '.', 'G', '#'], ] Sx, Sy = 0, 1 # スタート地点の座標 Gx, Gy = 9, 8 # ゴール地点の座標 print (clear_maze(Sx, Sy, Gx, Gy, maze))
これに対する出力は以下の通り。
# S # # # # # # . # . . . . . . # . . # . # . # # . # # . # . # . . . . . . . . # # . # # . # # # # . . . . # . . . . # . # # # # # # # . # . . . . # . . . . . . # # # # . # # # . . . . . # . . . G # 22
ゴールまでのステップ数が正しく出力されている様子がわかる。
迷路はマス同士が辺で結ばれているグラフであると解釈することができる。
その解釈にのっとると、あるマスの上下左右の4マスを見ることでグラフで言うところのその点から出ていく辺全てを確認したことになる。
よって、グラフと同じようにBFSを実装することが可能なのだ。
せっかくなので、最新のABC176にて出題された迷路問題も扱ってみよう。
縦マス、横マスのマスからなる迷路があります。
上から行目、左から列目のマスは、が'#'
のとき壁であり、'.'
のとき道です。
マスに魔法使いがいます。魔法使いは次の2種類の方法で移動することができます。
- 移動A:現在いるマスと上下左右に隣接する道のマスへ歩いて移動する。
- 移動B:現在いるマスを中心とするの範囲内にある道へのマスへワープ魔法で移動する。
どちらの行動でも、迷路の外へ移動することはできません。
マスまで移動するには、ワープ魔法を最低で何度使う必要があるでしょうか。
実はこの記事を書き始めたのも、この問題を解けずにぐぬぬとなったからである。
今の僕はBFSとがっぷり4つに組んだ後なので余裕だらと思っていたが、解説を見たところどうやらBFSを少しだけ複雑にした"01-BFS"という手法を用いるようだ。
記事が長くなってしまっているので、"01-BFS"については次の記事で扱うことにする。魔法使いには少し待っていてもらおう。
*1:データ構造の一つ。データを追加する時、データはキューの末尾に追加され、データを取り出す時、キューの先頭のデータが取り出されるという特徴を持つ。店前にできる行列のイメージ。