この記事でやること
グラフの橋、関節点、二重辺連結成分、二重頂点連結成分といった概念と、それらを求める際に用いる「lowlink」という手法を解説します。
注意事項
この記事で扱うグラフは、全て単純な連結無向グラフとします。
ソースコード は全てPython3.8を用いております。コピペはご自由にどうぞ。(AtCoder 等で使用する際はPyPy3で提出して問題ありません。)
モチベーション
グラフが与えられたときに、ある2頂点についてそれら結ぶ互いに素な(同じ辺/頂点を通らない)2つ以上のパスが存在するか?という問題を考えます。
これはグラフの二重連 結性 に関する問題と言えますね。
この問題の場合は最大フローで解けそうですが、もっと複雑な問題になってくると計算量が壊れてしまいます。そこで、特に二重連 結性に関する問題を効率よく解く手段が欲しいわけですね。
lowlinkの基本
前提:DFS木と後退辺・横断辺
lowlinkの話に入る前に、知っておく必要のある用語と命題について解説します。
DFS木
グラフにおいて適当な頂点 を始点としてDFS(深さ優先探索 )したとき、使用した辺を取ることで構成される、 を根とする木。特にこの記事では、DFS木上の各辺を葉方向の有向辺とします。
後退辺
グラフから全域木 を取ります。このとき使われなかった辺 について、 と が における祖先と子孫の関係にあるとき、後退辺 といいます。
横断辺
グラフから全域木を取ります。このとき使われなかった辺のうち後退辺でないものを横断辺 といいます。
赤辺 :後退辺、青辺 :横断辺
命題:グラフからDFS木を取ったとき、使われなかった辺は全て後退辺となる
DFS木に使われない辺(赤辺 )は全て後退辺 となっている
証明
背理法 で示します。
DFS木 について、横断辺 が存在すると仮定します。
また、DFSにおける各頂点 の行きがけ順(pre-order) を で表すとします。
一般性を失わず、 とします。
は横断辺であることから、 に訪れるのは から帰ったあと であるはずです。
つまり、任意の の隣接点 について、 が成り立ちます。
ここで、 より となり、矛盾 ☐
ちなみに、全ての使われていない辺が後退辺となる全域木はDFS木ですが、実は全て横断辺となる全域木も存在します。どんな木でしょう?
参考↓
atcoder.jp
lowlinkでやること
ordの定義
先ほどの証明で使った定義通り、DFSにおける各頂点 の行きがけ順(pre-order) を で表すとします。
lowの定義
DFSによって と、以下で定義される を求めておきます:
頂点 から後退辺を高々 回まで使って到達できる頂点の集合 について、
例です。定義と照らし合わせて確認してみてください。
青数字 : 、赤数字 :
この と を用いて、二重連 結性に関するアレコレを求めていく手法をlowlinkといいます。
ordとlowの求め方
まずDFSをしながら を埋めていき、 もその値で初期化します。その最中に後退辺 が見つかれば、 を でchmin*1 します。
次に、ボトムアップ 順に各頂点 について、 を各 の子 について でchminしていくことで、 を求めることができます。
実装例
下記の実装例では、DFSをするついでに各頂点の子を表す二重配列 、頂点のトポロジカルソート順を表す配列 の作成を行っています。
計算量は
class LowLink ():
def __init__ (self, adj):
self.n = len (adj)
self.ord = [None ]*self.n
self.low = [None ]*self.n
self.son = [[] for _ in range (self.n)]
self.back_edge = [[] for _ in range (N)]
self.tps = []
t = 0
stack = [(None , 0 )]
while stack:
pre, now = stack.pop()
if self.ord[now] is not None :
if self.ord[pre] < self.ord[now]: continue
self.low[pre] = min (self.low[pre], self.ord[now])
self.back_edge[pre].append(now)
continue
if pre is not None :
self.son[pre].append(now)
self.tps.append(now)
self.ord[now] = t
self.low[now] = self.ord[now]
t += 1
for next in adj[now]:
if next == pre: continue
stack.append((now, next ))
for u in reversed (self.tps[1 :]):
for v in self.son[u]:
self.low[u] = min (self.low[u], self.low[v])
lowlinkの活用例
ここからは、先ほど求めた と を用いて、二重連 結性に関するアレコレを求める方法を解説していきます。
橋と二重辺連結成分
橋(bridge)
削除するとグラフが連結でなくなるような辺。
どの閉路にも含まれない辺とも言い換えられます。
現実ネットワークで考えると「断線したらヤバい辺」にあたります。
二重辺連結成分(two-edge connected component)
グラフから橋を全て削除したときの連結成分。
任意の異なる2つの二重辺連結成分 について、以下の性質が成り立ちます。
任意の異なる2頂点 について、辺について互いに素なパス が少なくとも2つ存在する
任意の2頂点 について、辺について互いに素なパス を2つ以上取ることはできない
橋 と二重辺連結成分
二重連 結成分分解の方法
グラフから橋と二重辺連結成分を列挙する方法を考えます。
適当な頂点からDFSして と を求めておきましょう。
橋の判定
後退辺は明らかに橋ではないので、木上の辺のみ見ていきます。
辺 が閉路に含まれる
から後退辺を高々 回まで使って または の祖先に到達できる
より、閉路に含まれない、すなわち橋であるための必要十分条件 は
辺 が橋である
となります。
緑の辺 が橋。 となっている
二重辺連結成分の列挙
橋を通らないようにDFSして連結成分を求めていけばいいですね。
例えば頂点 からスタートすると、 と連結成分が求まる
実装例
各連結成分の頂点集合を二重配列で持つ 、元グラフの頂点から連結成分を逆引きするための配列 を求めたいです。
根から始め、橋があれば「その先の頂点を始点としてまたDFSしますよー」というフラグを追加する、という感じの方針で実装します。
二重連 結成分分解のついでに、連結成分を1点に縮約することで得られる、連結成分を頂点、橋を辺とした木を構築します。
(この木を作っておくことで、一般的に木で扱えるような色々なテクニックを用いて問題を効率よく解けたりします。)
この木を表現するために隣接リスト も追加で作ります。
計算量は
def two_edge_connected_component (self):
tecc = []
tecc_idx = [None ]*self.n
tecc_tree = []
sub_roots = [(None , 0 )]
idx = 0
while sub_roots:
stack = [sub_roots.pop()]
tecc.append([])
tecc_tree.append([])
while stack:
pre, now = stack.pop()
tecc[idx].append(now)
tecc_idx[now] = idx
if pre is not None and idx != tecc_idx[pre]:
tecc_tree[idx].append(tecc_idx[pre])
tecc_tree[tecc_idx[pre]].append(idx)
for next in self.son[now]:
if self.low[next ] > self.ord[now]:
sub_roots.append((now, next ))
else :
stack.append((now, next ))
idx += 1
return tecc, tecc_idx, tecc_tree
例題
atcoder.jp
解説
二重辺連結成分分解して縮約した木を考えます。この木上の辺は全て橋なので、異なる2頂点間を結ぶパスで、辺について素なものは高々1つしか取れません。よって、( を木上の対応する頂点で置きなおしたとして) というトレイルで同じ辺を通らないためには が パス上に存在することが必要十分条件 となります。
この判定方法は色々考えられますが、私の提出コードではオイラー ツアー*2 を使用しました。
二重辺連結成分分解し縮約した木上で、A-Cパス上にBが存在するかを判定すればよい
ACコード
Submission #34875923 - AtCoder Regular Contest 039
関節点と二重頂点連結成分
関節点(articulation point)
削除するとグラフが連結でなくなるような頂点。
現実ネットワークで考えると「ダウンしたらやばい中継点」にあたります。
二重頂点連結成分(biconnected component)
頂点の部分集合 が二重頂点連結成分 である
任意の頂点 について、 は連結であり、かつ は極大である
最後の「極大である」というのは、前半の二重頂点連結性に関する条件を満たす について なら の方を二重頂点連結成分としますよー、という意味です。
破線部分 ではなく全体を二重頂点連結成分とする
この定義から、関節点は複数の二重頂点連結成分に属する場合もあることに注意してください。
関節点 と二重頂点連結成分
二重頂点連結成分分解の方法
グラフから関節点と二重頂点連結成分を列挙する方法を考えます。
適当な頂点からDFSして と を求めておきましょう。
関節点の判定
関節点を考える上で重要なのは「”どの頂点に対して”関節点なのか」という点。
まず、根について考えます。
少し観察すれば直ちに下の事実が得られるはずです。
根 が子 に対して関節点
根 の出次数が 以上
根の出次数が2以上 なら子が全部分離される
次に、根以外の頂点について考えます。
DFS木上で根以外のある頂点を削除したとき、それによって根側から分離される頂点はどれか?を見る必要があります。
では、頂点 を削除した際に子 が分離されるとはどういうときでしょうか?
橋を列挙したときと同様に考えると、 から の祖先である頂点に後退辺を高々1つだけ到達できれば分離されないことになるので
根でない頂点 が子 に対して関節点
となります。
のとき、 は に対して関節点 となる
二重頂点連結成分の列挙
二重頂点連結成分は辺集合で見ると重複無しに分解できます。となると辺基準の分解しておくと嬉しい場合もある(かもしれない、要出典)ので、辺基準の分解と頂点基準の分解を両方行うことにします。
辺集合で見ると、各連結成分は互いに独立している
辺基準の分解で考えると、二重連 結成分分解は「木上の、親側の端点を削除したら子側の端点が根から分離されてしまう辺」すなわち下図の緑の辺 からスタートして、緑の辺を通らないようにDFSすることで実現できるとわかるでしょう。
例えば辺 からスタートすると、 と連結成分が求まる
頂点基準で分解する場合は、辺基準で分解中に通った頂点を順次記録していけばよいですね。
実装例
辺基準で分解した際の各連結成分の辺集合を二重配列で持つ 、頂点基準で分解した際の各連結成分の頂点集合を二重配列で持つ 、各頂点が関節点であるかどうかを表すbool値配列 を作っています。
二重辺連結成分分解と同様、根から始め、緑の辺 があれば「その先の頂点を始点としてまたDFSしますよー」というフラグを追加する、という感じの方針で実装しました。
注意点として、DFSを開始するごとにそのパートの連結成分を格納する空listを用意していますが、根が関節点(出次数が2以上 )の場合のみそのパートでは連結成分が構成されないという例外があるので、そのときは一時的に作ったlistをすぐにpop()しています。
計算量は
( を作らない、つまり、後退辺を無視するなら )
def biconnected_component (self):
bce = []
bcv = []
is_ap = [False ]*self.n
sub_roots = [(None , 0 )]
idx = 0
while sub_roots:
stack = [sub_roots.pop()]
bce.append([])
bcv.append([])
if stack[0 ][0 ] is not None :
bcv[idx].append(stack[0 ][0 ])
while stack:
pre, now = stack.pop()
if pre is not None :
bce[idx].append((pre, now))
bcv[idx].append(now)
if now == 0 :
if len (self.son[now]) >= 2 :
for next in self.son[now]:
is_ap[now] = True
sub_roots.append((now, next ))
bce.pop()
bcv.pop()
idx -= 1
else :
for next in self.son[now]:
stack.append((now, next ))
else :
for next in self.son[now]:
if self.low[next ] >= self.ord[now]:
is_ap[now] = True
sub_roots.append((now, next ))
else :
stack.append((now, next ))
if now == 0 and len (self.son[now]) <= 1 :
for back in self.back_edge[now]:
bce[idx].append((now, back))
idx += 1
return bce, bcv, is_ap
block-cut tree
二重辺連結成分分解では連結成分を縮約するだけで木が構築できました。では、二重頂点連結成分分解でも同様な「意味のある木」を構築したいです。
しかし、そのままだと関節点が複数の連結成分に共有されていて縮約は難しそうですね。そこで、「連結成分そのもの」を表す頂点を用意し、各関節点についてそれが含まれる連結成分を表す頂点を辺で結ぶことにします。
このようにして作られる木をblock-cut tree と呼ぶそうです。
グラフをblock-cut treeに変換
実装例
データの表現方法としては、関節点の個数を とおき、木上の頂点について関節点について順に の番号を割り振り、連結成分を表す頂点は の番号を割り振って隣接リスト を作っています。
また、各木の頂点が持つ元グラフに関する頂点集合を
関節点 ⇒ それ1つの単元集合
連結成分を表す頂点 ⇒ 元の連結成分から関節点を除いた頂点集合(空集合 になる可能性もある)
と定義して二重配列で持った 、また元のグラフの頂点から木上の頂点を逆引きする配列 を作っています。
具体的な方法はコードで確認して頂きたいのですが、先ほど求めた と を使うことで簡単に作れます。
計算量は
( にて を作らない、つまり、後退辺を無視するなら )
def block_cut_tree (self):
bce, bcv, is_ap = self.biconnected_component()
num_ap = sum (is_ap)
bc = [[] for _ in range (num_ap+len (bcv))]
bc_idx = [None ]*self.n
bc_tree = [[] for _ in range (num_ap+len (bcv))]
idx = 0
for v in range (self.n):
if is_ap[v]:
bc[idx].append(v)
bc_idx[v] = idx
idx += 1
for bcv_i in bcv:
for v in bcv_i:
if is_ap[v]:
bc_tree[idx].append(bc_idx[v])
bc_tree[bc_idx[v]].append(idx)
else :
bc[idx].append(v)
bc_idx[v] = idx
idx += 1
return bc, bc_idx, bc_tree, num_ap, bce, bcv, is_ap
例題
atcoder.jp
解説
まず、以下のように 座標または 座標が等しい頂点を辺で結ぶことでグラフを構築します。
座標または 座標が等しい頂点を辺で結ぶ
このグラフにおいてマッチングを考えるとき、頂点数が偶数の連結成分には完全マッチングが存在する ことが示せます。(証明は公式解説 を参照してください。)
よって、この問題は各頂点を取り除いたときに全ての連結成分の頂点数が偶数になるかを判定する問題に帰着されます。
ここで、例えば全ての頂点の 座標が等しいようなケースで辺の本数が となってしまい、扱うことができません。
そこで、「頂点を1つ取り除いたときの連結性を判定する」という問題の性質から三重以上の頂点連結成分は二重まで緩めても解に影響が出ないことを利用して、下図のように辺の本数を に落とします。
三重以上の頂点連結成分を二重に緩める
また、全頂点数が奇数なので、頂点数が奇数の連結成分は少なくとも つ存在しますが、 つ以上存在する場合は明らかに つの頂点を削除するだけで全ての連結成分の頂点数を奇数にすることはできないので、頂点数が奇数の連結成分がただ つ存在する場合のみ考えます。
さらに、そのような場合において頂点数が偶数の連結成分から頂点を取り除くのも明らかに不適なので、ただ つの頂点数が奇数の連結成分にのみ着目します。
まず、「 頂点を取り除いたときに連結でなくなるかどうか」が知りたいので、とりあえず二重頂点連結成分分解し、block-cut treeを作っておきましょう。
block-cut treeを構築。木の頂点 内の数字は、 を表す
この連結成分において、関節点でない頂点を つ削除すると、連結性は崩れずに頂点数が 減るので、全ての連結成分の頂点数が偶数となります。では、関節点を取り除くとどうなるでしょうか?
これは実際に木から関節点を取り除いてみることですぐに結果がわかります。
すなわち、いくつかの部分木に分離するので、そのサイズの偶奇を確かめ、その中に つでも奇数のものがあれば不適、そうでないなら適切となります。
block-cut treeから関節点を つ削除すると、いくつかの部分木に分離する
あとは部分木のサイズが求まればよいので、根を適当に固定した木dpをすることで解くことができます。
以上より、全体 で解けました。
ACコード
Submission #34866716 - AtCoder Regular Contest 045
(MLEとの戦いの結果、 を求めないといったような実装例からの変更点が存在します。)
class LowLink ():
def __init__ (self, adj):
self.n = len (adj)
self.ord = [None ]*self.n
self.low = [None ]*self.n
self.son = [[] for _ in range (self.n)]
self.back_edge = [[] for _ in range (N)]
self.tps = []
t = 0
stack = [(None , 0 )]
while stack:
pre, now = stack.pop()
if self.ord[now] is not None :
if self.ord[pre] < self.ord[now]: continue
self.low[pre] = min (self.low[pre], self.ord[now])
self.back_edge[pre].append(now)
continue
if pre is not None :
self.son[pre].append(now)
self.tps.append(now)
self.ord[now] = t
self.low[now] = self.ord[now]
t += 1
for next in adj[now]:
if next == pre: continue
stack.append((now, next ))
for u in reversed (self.tps[1 :]):
for v in self.son[u]:
self.low[u] = min (self.low[u], self.low[v])
def two_edge_connected_component (self):
tecc = []
tecc_idx = [None ]*self.n
tecc_tree = []
sub_roots = [(None , 0 )]
idx = 0
while sub_roots:
stack = [sub_roots.pop()]
tecc.append([])
tecc_tree.append([])
while stack:
pre, now = stack.pop()
tecc[idx].append(now)
tecc_idx[now] = idx
if pre is not None and idx != tecc_idx[pre]:
tecc_tree[idx].append(tecc_idx[pre])
tecc_tree[tecc_idx[pre]].append(idx)
for next in self.son[now]:
if self.low[next ] > self.ord[now]:
sub_roots.append((now, next ))
else :
stack.append((now, next ))
idx += 1
return tecc, tecc_idx, tecc_tree
def biconnected_component (self):
bce = []
bcv = []
is_ap = [False ]*self.n
sub_roots = [(None , 0 )]
idx = 0
while sub_roots:
stack = [sub_roots.pop()]
bce.append([])
bcv.append([])
if stack[0 ][0 ] is not None :
bcv[idx].append(stack[0 ][0 ])
while stack:
pre, now = stack.pop()
if pre is not None :
bce[idx].append((pre, now))
bcv[idx].append(now)
if now == 0 :
if len (self.son[now]) >= 2 :
for next in self.son[now]:
is_ap[now] = True
sub_roots.append((now, next ))
bce.pop()
bcv.pop()
idx -= 1
else :
for next in self.son[now]:
stack.append((now, next ))
else :
for next in self.son[now]:
if self.low[next ] >= self.ord[now]:
is_ap[now] = True
sub_roots.append((now, next ))
else :
stack.append((now, next ))
if now == 0 and len (self.son[now]) <= 1 :
for back in self.back_edge[now]:
bce[idx].append((now, back))
idx += 1
return bce, bcv, is_ap
def block_cut_tree (self):
bce, bcv, is_ap = self.biconnected_component()
num_ap = sum (is_ap)
bc = [[] for _ in range (num_ap+len (bcv))]
bc_idx = [None ]*self.n
bc_tree = [[] for _ in range (num_ap+len (bcv))]
idx = 0
for v in range (self.n):
if is_ap[v]:
bc[idx].append(v)
bc_idx[v] = idx
idx += 1
for bcv_i in bcv:
for v in bcv_i:
if is_ap[v]:
bc_tree[idx].append(bc_idx[v])
bc_tree[bc_idx[v]].append(idx)
else :
bc[idx].append(v)
bc_idx[v] = idx
idx += 1
return bc, bc_idx, bc_tree, num_ap, bce, bcv, is_ap
類題
https://onlinejudge.u-aizu.ac.jp/problems/3022
H - Tourist on Graph
参考資料
lowlink
グラフにおける橋(bridge)を検出するアルゴリズム | アルゴリズムロジック
Biconnected component - Wikipedia
https://web.iitd.ac.in/~bspanda/biconnectedMTL776.pdf
オイラー ツアー
オイラーツアーした木に対するクエリ - Qiita
Euler Tour のお勉強 | maspyのHP