はじめに
前回の記事↓ で、Daily Akari というパズルの ZDD による解法をサラッと紹介しました。
しかし、どうにも 「ZDD とはなんぞや?」「よくわからんが難しそう」といった風に感じている人がなんとなく多そうな気がしており、これは由々しき事態だなと。
ZDD はすんごくシンプルなアイデアで色々な問題を解けてしまう激面白データ構造なので、何としてでも布教したいと思いました。
なので、アルゴリズム学徒だけでなくプログラミングにそこまで明るくない非応用数学系の人々にも届くように一から丁寧に解説していきます。ゴールがあった方がいいと思うので、Daily Akari ソルバーの作成を目標にしてしまいましょう。
目指せ、一家に一台 ZDD!
なお、全体の実装例は ↓ に置いてあります。
(本記事シリーズでは Java の方を実装していきます。)
この記事でやること
この記事でやらないこと
- ZDD の演算の話(第2回でガッツリやる予定。ZDD の激面白ポイントはここ。)
- Daily Akari の話(第3回で入る予定。気長にやりましょう。)
1. ZDD の概要
1.1. ZDD とは
Zero-supressed Binary Dicision Diagram の略です。日本語では「ゼロサプレス(抑制?)型二分決定グラフ」になります。あまり日本語で呼ばれているところは見ないですが、提案されたのは日本の教授、湊真一先生です[1]。2025年現在は京大で教鞭をとられています。
与えられた集合 に対して、ZDD は部分集合族
を表現します。以下では
を 台集合、
を アイテム、各部分集合
を 組合せ集合 と呼びます。
例えば、 に対し
を表す ZDD は【図 1.1-1】になります。

【図 1.1-1】において、赤い辺を 0-枝、青い辺を 1-枝と呼びます。また、一番上の のラベルがついた節点を 根、一番下の
のラベルがついた節点を 0-終端節点、
のラベルがついた節点を 1-終端節点 と呼びます。
終端節点を除く各節点からは、それぞれ0-枝と1-枝が1つずつ出ています。これが、「二分決定グラフ」と呼ばれる所以です。
ZDD において、根から1-終端節点までのパスが各組合せ集合に対応します。
すなわち、各パスについて節点から出る際に
- 1-枝を通るなら、その節点のアイテムを組合せ集合に追加する。
- 0-枝を通るなら、追加しない。
とすることで組合せ集合と対応付けます。(【図 1.1-2】)

特に、この対応付けになぞらえると、0-終端節点のみからなる ZDD は部分集合族 を、 1-終端節点のみからなる ZDD は部分集合族
をそれぞれ表していることに注意してください。
以上が、ZDD の"読み方"になります。
この説明だけ聞くと「どこからそのグラフは降ってきたんですか?」という疑問が出ると思うので、次はその解説をします。
1.2. ZDD の基礎的な構成方法
前節に引き続き、台集合 に対し部分集合族
を表す ZDD を例にしましょう。
まず、【図 1.2-1】に示す完全二分決定木を考えます。

この木において、根から各終端節点までのパスは の全ての部分集合と対応付けられます。特に、終端節点の
と
を切り替えることで任意の部分集合族(アイテム数が
なら、
通り)を表現できることに注意してください。
さて、このままだと節点数が となり、
が大きいと困ってしまいます。実用上は表現したい部分集合族がべき集合に対して十分疎であることが多いので、もっと効率的に表現したいところ。
そこで、次の2つの圧縮規則(【図 1.2-2】)を導入します。
- 削除規則:
1-枝の先が0-終端節点であるような節点を省略する。 - 共有規則:
等価な節点(ラベル、0-枝の行先、1-枝の行先が同一である節点)を纏める。特に、終端節点も等価な節点とみなす。

この2つの圧縮規則を、【図 1.2-1】で示した完全二分決定木に対し(任意の手順で)繰り返し適用することで、最終的に【図 1.1-1】に示す ZDD が得られます*1。また、この圧縮規則が適用できない状態のことを既約であると言います*2。
というわけで、ZDD の構成法が得られました。
しかし、この構成法には大きな問題があります。
というのも、最初に完全二分決定木を作ってしまうと、その時点で計算量が爆発してしまいます。
目標としている Daily Akari ソルバーにおいては、台集合をマス全体として、解であるような灯りの置き方(=マスの部分集合)全体から成る ZDD を構成します。グリッドサイズは最大 なので*3、完全二分決定木の節点数は
になってしまいます。これは宇宙の原子の数より多いらしいので[2]、この構成法は物理的に不可能です。
というわけで、本節で解説した完全二分決定木を経由する構成法は、現実的ではありません。ただし、2種類の圧縮規則とアイテムの添字順(=完全二分決定木上でアイテムをどの順に置くか)によって ZDD の形状が定まっているということは、知識として知ってくおくとよいでしょう。
次項では、「じゃあ結局 ZDD ってどう作るの?」という疑問に答えていきます。
1.3. ZDD の効率的な構成方法
1.3.1. 部分集合族の分解
ZDD の各接点は、それ自体を根とする ZDD と捉えられ、対応する部分集合族が存在します。そこで、根の0-枝の先と1-枝の先がそれぞれどのような部分集合族を表しているかを考えます。
以下、台集合を とします。
また、便利な記号として、部分集合族 とアイテム
に対する二項演算子
を次のように定義します。
\begin{equation} \mathcal{P} \triangleleft e_i := \{P \cup \{e_i\} \mid P \in \mathcal{P}\} \tag{1}\end{equation}
すると、任意の非空な組合せ集合を少なくとも1つもつ部分集合族 について、
\begin{align} e_i & := \min(\bigcup_{P \in \mathcal{P}} P) \\ \mathcal{P}_0 & := \{P \mid e_i \notin P \in \mathcal{P}\} \tag{2}\\ \mathcal{P}_1 & := \{P \setminus \{e_i\} \mid e_i \in P \in \mathcal{P} \} \end{align}
(アイテムの大小は添字で比較するものとします。)とおいたとき、次式が成り立ちます。
\begin{equation} \mathcal{P} = \mathcal{P}_0 \cup (\mathcal{P}_1 \triangleleft e_i) \tag{3}\end{equation}
この 式(3) は、【図 1.3.1-1】に示す ZDD の抽象図をそのまま表現しています。

ふわふわとお絵描きしていたものが、かっちりと扱えそうになってきましたね。
1.3.2. 圧縮規則の再帰的な適用
さて、勘のいい読者は気づかれているかもしれませんが、式(3) は ZDD の再帰的な構成方法を示唆しています。
すなわち、 と
に対応した ZDD が得られていると仮定すると、そこに
をラベルに持つ節点を追加し、枝を エイヤッ!! とそれぞれの根に突き刺すことで、
の ZDD が完成しそうです。
この枝を エイヤッ!! する操作に圧縮規則を適用したものが、次の手続き になります。
-
なら、
の根を返す。
- そうでないなら、以下の節点を作成して返す。ただし、等価な節点を既に作成済みならそれを返す。
- ラベル:
- 0-枝の先:
の根
- 1-枝の先:
の根
- ラベル:
この手続きを用いることで、与えられた部分集合族 に対し ZDD を構築しその根を返す手続き
は、下記のように再帰的に記述できます。
なら、0-終端節点を返す。
なら、1-終端節点を返す。
- 上記以外なら、式(2) により
を得て、
を返す。
というわけで、ZDD の良い構成方法が得られました。
しかし、この という手続きは、部分集合族
を表現する手段が存在する前提で記述されており、結局それをどう実装するのか?という点で言えばあまり意味を成しません*4。大事なのは、このように再帰的に記述できることを理解しておくことです。
(ちなみに、最初からこの という手続きで ZDD を定義してしまっても問題ありません。数学的にはその方が簡潔かもしれませんね。)
「え、じゃあ結局 ZDD ってどうやって使うの???」
とツッコミが入るかもしれませんが、解説する分量の都合上、次回までお待ちください。実際のところそのあたりが ZDD で一番面白い話で「おわりに」の章でも軽く触れますが、本記事ではとにかく ZDD のベースとなる部分の実装を目指します。
1.3.3. ハッシュ関数
1.3.2項 にて という手続きを紹介しましたが、これを実装するうえでは等価節点をどのように保存・検索するのかという点にも着目しなくてはなりません。いわゆるデータ構造ってやつです。(ZDD も部分集合族を表現するためのデータ構造と言えます。)
計算機で扱える基本的なデータ構造として、配列があります。これは、複数のデータを1列に並べたもので、任意の非負整数 に対し
番目*5のデータの読み書きを高速に処理できます。なお、計算機のメモリにも限りがあるため、配列の長さとして有限な非負整数
を決めておく必要があります。
そして、配列を流用するデータ構造として、ハッシュテーブルというものがあります。
ハッシュテーブルは、保存したいデータに対し予め用意した"いい感じの関数"を使って 以上
未満の非負整数
に変換し、配列の
番目に保存すると取り決めたものです。この"いい感じの関数"をハッシュ関数と呼びます。また、ハッシュ関数で得られた整数値のことをハッシュ値と呼びます。
(用意した関数が単射でない場合、異なるデータに対して同じ番地が充てられてしまう場合があり、これを衝突と呼びます。衝突した場合にどう保存するかは別途取り決めておく必要があるのと、そもそも衝突があまり起きないように関数を定義することが重要です。)
ハッシュテーブルに ZDD の節点を保存できれば、検索が高速にできてうれしいです。なので、サクッとハッシュ関数を作ってしまいましょう*6。
ハッシュ関数を作る上での便利な道具として、Xorshift と RollingHash という 2 つの関数を使います。具体的な中身はリンク先の解説が易しいのでそちらを参照ください。
:
正整数値を何かしらの正整数値に飛ばす疑似乱数発生器
blog.visvirial.com:
任意長の正整数列を何かしらの法
の整数値に飛ばすハッシュ関数
maspypy.com
0-終端節点のハッシュ値を 、
1-終端節点のハッシュ値を とします。
終端節点でない節点 について、ラベルのアイテムを
、0-枝の先を
、1-枝の先を
とし、
と
のハッシュ値は既に求まっていると仮定します。
この のハッシュ値を次のように定義します:
\begin{align} \text{hash}(\mathcal{P}) := \text{RollingHash}(& \text{Xorshift}(i+1), \\ & \text{Xorshift}(\text{hash}(\mathcal{P}_0)+1), \tag{4} \\ & \text{Xorshift}(\text{hash}(\mathcal{P}_1)+1)) \end{align}
要するに、アイテムと枝の先にある2節点のハッシュ値から自身のハッシュ値を定めているわけですね。これでハッシュ関数の完成です。
以上で ZDD のベースとなる考え方の解説は終了です。
次章からは実装に入っていきます。
2. 実装
zddForDailyAkari パッケージに、続く3つのクラスを実装します。
なお、3つ目の ZDD_Visualizer はなくてもDailyAkari は解けるので、飛ばしても問題ありません。
2.1. RollingHash クラス
式(4) に基づいたハッシュ関数を実装したクラスです。 RollingHash という名前ですが、中に Xorshift も内蔵しています。
コンストラクタは 法 m を引数に取り、コンストラクタ内で基数 r を乱択しています。これは Rolling Hashについて(survey + 研究) | maspyのHP にて解説された内容を実装したものになります。
2.2. ZDD<T>、ZDD<T>.ZDD_Node クラス
ZDD クラスと、その内部クラスとして節点を表す ZDD_Node クラスを実装しています。これは、台集合や節点のハッシュテーブルを ZDD クラスに管理してもらいたいという思想があるからです。 また、1.3.2項 ので解説した も ZDD クラスのメソッドとします。
T はアイテムのクラスを表します。ZDD<T> のコンストラクタは台集合として ArrayList<T> を引数に取り、ArrayList での順に沿ってアイテムの添字を決定します。
決定した添字は HashMap<T, Integer> idx に保存しています。
ZDD_Node クラスでは hashCode() と eauals() をオーバーライドしています。
注意点として、equals() 内の
this.zero == o.zero && this.one == o.one
としている箇所を
this.zero.equals(o.zero) && this.one.equals(o.one)
としてしまうと、equals() が終端節点まで再帰的に呼び出されてしまい計算量が爆発してしまいます。 で適用される共有規則を踏まえると、「==」での比較(同一のインスタンスかどうかの比較)で十分です。
同様に、終端節点まで hashCode() が再帰的に呼び出されることを回避するため、コンストラクタ内でハッシュ値を予め計算し保存しておくようにしています。
2.3. ZDD_Visualizer<T> クラス
外部ライブラリである jung-2.0.1 を利用するため、導入方法をまず紹介します。
まず、下記リンクからライブラリの圧縮ファイルをダウンロードしてください。
ダウンロードした圧縮ファイルを適当なディレクトリに解凍しておき(.jar ファイルがいっぱい入っています。)、自身の java 実行環境に合わせてクラスパスを通せばよいです。
ここでは一応、筆者の環境(vscode, Extension Pack For Java)でのパスの通し方を紹介しておきます。
- 画面左下の ☕java Ready と表示された箇所をクリック

- Open Project Settings を選択

【図 2.3-2】 Open Project Settings - Libraries タブを開き、解凍した .jar ファイルを追加する。

【図 2.3-4】 Libraries タブを開き、解凍した .jar ファイルを追加する。 - Apply Settings をクリック
以上で完了です。
【図 2.3-4】 Apply Settings をクリック
導入出来たら、実装例をそのままコピペしてもらえばよいです。ZDD の本筋とは関係ないので解説は省略します。
参考にした記事 ↓(ライブラリの設計思想がなんとなくわかります)
あとは API とにらめっこしました。
2.4. テスト
実行結果:

おわりに+次回予告
お疲れ様でした。
今回は ZDD のベースとなる考え方として、再帰的な構築法を解説しました。
しかし、実際のところまだ ZDD の嬉しさについては全く解説していません。
ZDD のハイパー面白ポイントは部分集合族の“演算”が ZDD 上でも扱えるという点にあり、これにより様々な組合せ問題、すなわち条件を満たす組合せ集合全体からなる部分集合族を求める問題が簡単に解けてしまうのです。
そして、まさに今回目標としている Daily Akariも組合せ問題の一種で、ZDD の演算だけで解けちゃうわけです。凄い。
次回はそんな ZDD の演算を解説していきます。お楽しみに。
参考文献
-
S. Minato: "Zero-Suppressed BDDs and Their Applications", International Journal on Software Tools for Technology Transfer, Vol. 3, No. 2, pp. 156-170, Springer, May 2001.
*1:任意の完全二分木について、圧縮規則を繰り返し適用することで、対応した ZDD が手順に依らず一意に得られます。このことは、後ほど登場する 式(2), 式(3) をもとに帰納法を用いることで示せます。
*2:既約になっていない二分決定グラフも広く ZDD と呼ぶ場合もありますが、ここでは既約なものを指して ZDD と呼ぶことにします。
*3:筆者調べ。もっと大きい日もあるかもしれません。
*4:無論、 をプログラム上で表現する別の手段があれば、この手続きにより ZDD を構築できます。
*5:配列は、0番目から数え始めるのが一般的です。
*6:ここで紹介するハッシュ関数は筆者が実装時に作成した一例であり、人々がどのように実装しているのかは知りません。また、この関数がハッシュ関数として優れているかどうかの数学的な考察も一切していません。ごめんなさい。