naruの日記

いい記事を書きます。あと、日記ではないです。

BFS(幅優先探索)とぶつかり稽古

AtCoderをやっていく上で必ずぶつかるのがこのBFS。グラフ探索の手法である。

ここでは原理の復習から実装までを行う。

なおこの記事を書く上で以下の記事をものすごく参考にさせてもらった。ほんま感謝やでえ。

qiita.com

BFSの動作

BFSは重みなしグラフ(もしくは各辺の重みが等しいグラフ)における、探索の始点となる頂点から各頂点への最短経路を求めることのできるアルゴリズムである。

BFSのアイデアは非常に単純で、まず始点の近傍を探索、次にその近傍を探索,さらにそのまた近傍を探索...と続け、近傍から到達可能な全ての頂点が探索済みになった時点で終了するというものだ。

アルゴリズムの概要は以下の通り。


  • まず始点をキュー*1に格納する。キューに格納されているというのは、その点が訪問予定(発見済みだが隣接点を未探索)の状態であることを意味する。

  • キューから先頭の要素vを取り出す。

  • vの隣接点の中で未発見であった点に対し、始点からの距離を定め、その点をキューの末尾に追加する。

  • 2,3を繰り返し、キューが空になった段階で終了。

  • ぼくを含めたみんなはこれを見ただけじゃどういうことか分からないと思うので以下でイメージの図(おんなじ意味!)を与える。

    f:id:politeness:20200824115846j:plainf:id:politeness:20200824120337j:plain
    f:id:politeness:20200824120404j:plainf:id:politeness:20200824120848j:plain
    f:id:politeness:20200824120901j:plainf:id:politeness:20200824120919j:plain
    f:id:politeness:20200824121439j:plainf:id:politeness:20200824121454j:plain
    f:id:politeness:20200824121559j:plainf:id:politeness:20200824121611j:plain

    オレンジが「訪問予定」、赤が「訪問済み」、各点左上に書かれた数字がその点までの最短経路長である。10枚目の画像でキューが空となったのでアルゴリズムは終了する。

    (追記)
    10枚目の頂点6の上に3を書き忘れてました。ごめんな。

    BFSが最短経路長を正しく与える証明

    BFSの正当性は自明では無いためここで証明を与える。

    なお、以下ではグラフが有向グラフである場合のBFSの正当性の証明を与えるが、無向グラフは各辺を二本の有向辺と捉えることで有向グラフとして表現できるため、同時に無向グラフの場合でもBFSが正当であることが証明される。

    では、まず証明に用いる補題の証明から。

    Lemma

    G=(V,E)を有向グラフとし、v\in Vとする。また、u\in Vに対してP_uvからuへの最短路とする。
    この時、\forall w \in V(P_u)\, (経路P_u上に含まれる頂点集合)に対してP_u上のwまでの経路はvからwへの最短路である。

    (証明)
    あるu\in Vに対してP_u上のwまでの経路がvからwまでの最短路になっていないような頂点w\in Vの存在を仮定する。

    すると、P_wP_u上のw以降の経路を繋いだ経路P'_uP_uより短い経路となりP_uが最短路であることに矛盾する。

    (証明終わり)

    続いて本題の証明。

    Theorem

    頂点数n、辺数mの有向グラフG=(V,E)が与えられた時、頂点s\in Vを始点とする幅優先探索を実行し終わった段階で、d(v)\,(アルゴリズム中で求めた各点への距離)が始点から各頂点v\in Vへの最短路長となる。

    (証明)
    v\in Vに対して、l_vsからvへの最短路長とする。sからvへの経路がない場合はl_v=\inftyとする。このとき、任意のv\in Vに対してl_v=d(v)となることを示す。まず、任意のk\in \mathbb{N}と任意のv\in Vに対してl_v=k\Leftrightarrow d(v)=kとなることを示す。

    k=0のとき、l_v=0となる頂点はsのみである。このときd(s)=0である。また、d(v)=0となる頂点もsのみであり、l_s=0である。したがってk=0のとき成立する。

    k\leq nのときに主張が成立すると仮定する。まずl_w=n+1 \Rightarrow d(w)=n+1を示す。

    l_w=n+1となる頂点w\in Vを固定する。d(w) < n+1と仮定すると、帰納法の仮定よりd(w)=l_w < n+1となり矛盾する。したがってこのときd(w)\geq n+1である。

    P_wsからwへの最短路とする。LemmaよりP_wwの1つ前の頂点をvとすると、P_v := (P_wから辺(v,w)と頂点wを取り除いた経路)sからvへの最短路になっていてl_v=nが成り立つ。
    すると仮定より、d(v)=nとなる。よって、任意のwへの最短路についてwの1つ前の頂点をvとすると、d(v)=nとなる。

    以上をまとめると、l_w=n+1という仮定のもとwに入る頂点vの中でd(v)=nとなるものが存在する。
    同時に、wに入る頂点xd(x) < nとなるものは存在しない(そのようなxが存在する場合、帰納法の過程よりl_x < nとなるがそのとき明らかにl_w < n+1となってしまうので矛盾)。
    BFSのアルゴリズムではd(v)の値の昇順に探索が行われるのでd(w)の値はd(v) = nとなる頂点vによって書き換えられ、d(w)=n+1となる。

    次にd(w)=n+1 \Rightarrow l_w=n+1を示す。

    d(w)=n+1となる頂点w\in Vを固定する。l_w < n+1と仮定すると上と同様の理由から矛盾。したがってl_w \geq n+1である。

    d(w)の値を書き換えた頂点をvとすると、d(v)=nとなる。すると帰納法の仮定よりl_v=nである。
    P_vsからvまでの最短路とし、P_wP_vに辺(v,w)と頂点wを付け加えた経路とすると、P_wの長さはn+1である。l_w \geq n+1であったのでP_wsからvへの最短路となっている。したがってこのときl_w=n+1となる。

    よって、任意のk\in \mathbb{N}と任意のv\in Vに対してl_v=k \Leftrightarrow d(v)=kとなることが示された。

    また、上の証明よりl_vの有限性とd(v)の有限性も同値であることがわかる。したがってl_v=\infty \Leftrightarrow d(v)=\infty\,(d(v)=\infty:vには到達不可能)となる。これらを合わせて、任意の頂点v\in Vに対してl_v=d(v)となることが示された。

    (証明終わり)

    まあ証明なんて別にどうでも良い。次に実装を行っていこう。

    BFSの実装

    以下のコードでBFSが実装できる。なお入力は以下の形式を想定している。

    
\begin{align*}
&N\quad M\\
&a_0\quad b_0\\
&a_1\quad b_1\\
&\cdots \\
&a_{M-1}\quad b_{M-1} \\
\end{align*}

    Nはグラフの頂点数、Mはグラフの辺数、各辺i=0,1,\ldots ,M-1は頂点a_ib_iをつないでいることを表している。

    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となる。
    

    コード中で出てきた隣接リスト表現とは、各頂点vに対し、vから出ている辺のリスト\mathrm{Adj}_v(または接続している辺のリスト)を保持するというグラフの格納方法である。

    またdequeとは、pythonの標準ライブラリcollectionsモジュールの型の一つでありデータをキューとして扱うときに用いる。様々なメソッドがあり、append()でキューの末尾に追加、popleft()で先頭要素の取得が可能である。

    以上が最も基本的なBFSの実装であった。よくよく見れば何も難しい話はない。
    名前が某K-POPグループに似ているからと言って、恐るるに足りない。

    迷路問題でBFS

    実際にAtCoderをやっているとBFSを用いる場面はたくさん出てくるが、その多くは上で見たようなグラフの問題ではなく、"迷路問題"であるように感じる(迷路問題が正しい呼称かは知らないが、とりあえず以下ではそう呼ぶ)。

    具体的に以下の問題を考える。

    迷路問題1

    大きさがN\times Mの迷路が与えられる。迷路は通路と壁からできており、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

    Hマス、横WマスのH\times Wマスからなる迷路があります。

    上からi行目、左からj列目のマス(i,j)は、S_{ij}'#'のとき壁であり、'.'のとき道です。

    マス(C_h,C_w)に魔法使いがいます。魔法使いは次の2種類の方法で移動することができます。

    • 移動A:現在いるマスと上下左右に隣接する道のマスへ歩いて移動する。
    • 移動B:現在いるマスを中心とする5\times 5の範囲内にある道へのマスへワープ魔法で移動する。

    どちらの行動でも、迷路の外へ移動することはできません。

    マス(D_h,D_w)まで移動するには、ワープ魔法を最低で何度使う必要があるでしょうか。

    実はこの記事を書き始めたのも、この問題を解けずにぐぬぬとなったからである。

    今の僕はBFSとがっぷり4つに組んだ後なので余裕だらと思っていたが、解説を見たところどうやらBFSを少しだけ複雑にした"01-BFS"という手法を用いるようだ。

    記事が長くなってしまっているので、"01-BFS"については次の記事で扱うことにする。魔法使いには少し待っていてもらおう。



    続き↓
    politeness.hatenadiary.com

    *1:データ構造の一つ。データを追加する時、データはキューの末尾に追加され、データを取り出す時、キューの先頭のデータが取り出されるという特徴を持つ。店前にできる行列のイメージ。