問題
アルファベットサイズ $k$、窓幅 $n$ が与えられる。長さ $k^n$ の巡回列であって、長さ $n$ のすべての文字列がちょうど1回ずつ部分文字列として(巡回的に)現れるde Bruijn数列 $B(k,n)$を構築せよ。
de Bruijnグラフ: 頂点は長さ$n-1$の全文字列($k^{n-1}$個)。各頂点$u$から各文字$c$に対し辺 $u\to(u+c)[1:]$ が張られる(多重辺・自己ループ含む)。各頂点の入次数=出次数=$k$なので必ずオイラー閉路が存在する。
構築(Hierholzer法・小さい文字優先の決定的DFS):
circuit = [] # 全再帰で共有
def dfs(u):
for c in 0..k-1(小さい順):
if 辺(u,c)未使用:
使用済みにする
v = (u + str(c))[1:]
dfs(v)
circuit.append(c) # post-orderで追記
start = "0" * (n-1)
dfs(start)
circuit.reverse()
answer = (start + "".join(circuit))[0 : k**n]
入力形式
k n
制約
$2 \le k \le 8$
$1 \le n \le 5$
$k^n \le 20000$
入出力例
入力例1
2 3
出力例1
00010111
入力例2
2 1
出力例2
01
概念図
ヒント(段階的開示)
ヒント1: 方向性
「長さ$n$の文字列を頂点にして1文字ずらして重なる関係を辺にする」のはやや筋が悪い。逆に「長さ$n-1$の文字列」を頂点、「長さ$n$の文字列」を辺とみなすと、頂点数$k^{n-1}$・辺数$k^n$の綺麗な有向グラフになり、オイラー閉路の問題に帰着できることに気づけるかがカギ。
ヒント2: アプローチ
de Bruijnグラフでは各頂点の入次数=出次数=$k$なので自動的にオイラー閉路が存在する。Hierholzer法(再帰DFSで、使った辺の文字をpost-orderでリストに積み、最後に全体を反転)で求める。この閉路の文字を順に並べると「開始頂点のラベル+辺の文字列」という長さ$(n-1)+k^n$の文字列ができ、閉路が出発点に戻るため末尾$n-1$文字が先頭$n-1$文字と一致する。先頭$k^n$文字だけを取れば答え。
ヒント3: 誘導(コード骨格)
def dfs(u):
arr = get_used(u)
for c in range(k):
if not arr[c]:
arr[c] = True
v = (u + str(c))[1:] # u[1:]+str(c)ではなくこの形で書く
dfs(v)
circuit.append(c)
`v = (u + str(c))[1:]`という書き方にしておくと$n=1$(頂点が空文字列)のケースも特別扱いせずに正しく動く。
模範解答 (Python)
import sys
def solve():
sys.setrecursionlimit(1 << 20)
k, n = map(int, sys.stdin.read().split())
used = {}
def get_used(node):
if node not in used:
used[node] = [False] * k
return used[node]
circuit = []
start = "0" * (n - 1)
def dfs(u):
arr = get_used(u)
for c in range(k):
if not arr[c]:
arr[c] = True
v = (u + str(c))[1:]
dfs(v)
circuit.append(c)
dfs(start)
circuit.reverse()
seq = "".join(map(str, circuit))
full = start + seq
print(full[: k ** n])
solve()
計算量: $O(k^n)$(辺の本数分だけDFSが走る)。
Step-by-Step 解説
1頂点と辺の対応づけ
長さ$n-1$の文字列を頂点、長さ$n$の文字列を辺とみなし、頂点は`dict`で遅延生成。
長さ$n-1$の文字列を頂点、長さ$n$の文字列を辺とみなし、頂点は`dict`で遅延生成。
2決定的DFS
各頂点で小さい文字から順に未使用の辺を試し、出力を一意に定める。
各頂点で小さい文字から順に未使用の辺を試し、出力を一意に定める。
3post-orderで積んで最後に反転
行き止まりから遡って閉路を組み立てる形になり、最後にリスト全体を反転すると正しい走査順が得られる。
行き止まりから遡って閉路を組み立てる形になり、最後にリスト全体を反転すると正しい走査順が得られる。
4巡回的重複部分の切り捨て
「start+辺の文字列」の末尾$n-1$文字は先頭と一致するため、先頭$k^n$文字だけを取り出す。
「start+辺の文字列」の末尾$n-1$文字は先頭と一致するため、先頭$k^n$文字だけを取り出す。
5$n=1$の特殊性
`(u+str(c))[1:]`の形で書けば`u=""`のときも正しく空文字列に戻る。
`(u+str(c))[1:]`の形で書けば`u=""`のときも正しく空文字列に戻る。
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| `v = u[1:] + str(c)`と書く | $n\ge2$では動くが$n=1$で壊れる | `v = (u + str(c))[1:]`の形で統一 |
| circuitを反転し忘れる | post-order順とそのまま出力すべき順が逆 | DFS完了後に必ず`circuit.reverse()` |
| `RecursionError` | 辺の本数($k^n$)がPythonの再帰上限を超える | `sys.setrecursionlimit`を$k^n$より十分大きくする |
| 巡回重複部分を切り捨てずに出力 | 「開始点+辺の文字列」をそのまま答えと誤解 | `full[:k**n]`で長さ$k^n$に切り詰める |
次のステップ
- 発展: 得られた数列が本当に全長さ$n$文字列を巡回的にちょうど1回ずつ含むか`set`で検証する
- 発展: 辞書順最小のde Bruijn数列(granddaddy sequence)をFKM algorithmで再現し一致を確認する
- 次回予告: Lehmer's GCD Algorithm(多倍長整数の高速ユークリッド互除法)