E - グループ分けとウイルス感染 / Group Division and Virus Infection 解説 by admin
gemini-3.5-flash-thinking概要
この問題は、15台という少ないコンピュータ(\(N \leq 15\))をいくつかのグループに分割し、ウイルスによる被害を受けるコンピュータの総数を最小化する問題です。 各グループ内で完結するウイルスの被害をあらかじめ計算しておき、ビットDP(集合DP)を用いて最適なグループ分け(集合の分割)を探索することで解くことができます。
考察
1. グループ内の被害の独立性
コンピュータの集合をいくつかのグループ \(T_1, T_2, \ldots, T_k\) に分割したとします(これらは互いに素で、和集合が全体集合になります)。 ウイルス \(j\) の感染対象 \(S_j\) が被害をもたらすのは、\(S_j\) がいずれか1つのグループ \(T_i\) に完全に含まれる(\(S_j \subseteq T_i\))ときのみです。
したがって、グループ \(T\) を1つ作ったときに、そのグループ内で被害を受けるコンピュータの集合は、以下のようになります。 $\(U(T) = \bigcup_{S_j \subseteq T} S_j\)\( このグループ \)T\( における被害台数を \)C[T] = |U(T)|\( と定義すると、全体のグループ分けに対する被害台数の総和は、各グループの被害台数の和 \)\sum C[T_i]$ となります。
2. \(C[T]\) の高速な計算(SOS DP の導入)
すべての \(T\)(\(0 \leq T < 2^N\))について \(C[T]\) を愚直に計算すると、ウイルスの数 \(M\) が最大 500 であるため、\(O(M \cdot 2^N)\) の計算量がかかります。\(N=15\) では \(2^{15} = 32,768\) なので、これでも間に合いますが、よりスマートかつ高速に求めるために SOS DP (Sum Over Subsets DP / 高速ゼータ変換) を利用します。
各ウイルス \(j\) について、初期値として W[S_j] = S_j としておきます(ビットマスクで表現)。
その後、SOS DP の要領で「部分集合の和集合」を累積していくことで、すべての \(T\) に対して \(S_j \subseteq T\) となるすべての \(S_j\) の論理和(和集合)W[T] を \(O(N 2^N)\) で求めることができます。
\(C[T]\) は W[T] の立っているビット数(popcount)となります。
3. グループ分割の最適化(ビットDP)
コンピュータの集合 \(S\) をグループ分けしたときの最小被害台数を dp[S] とします。
集合 \(S\) から、1つのグループ \(T\)(\(T \subseteq S\) かつ \(T \neq \emptyset\))を切り出すことを考えると、遷移式は以下のようになります。
$\(\text{dp}[S] = \min_{T \subseteq S, T \neq \emptyset} (C[T] + \text{dp}[S \setminus T])\)$
この遷移を素直に行うと、すべての \(S\) についてその部分集合 \(T\) を走査するため、計算量は \(O(3^N)\) になります。 \(3^{15} \approx 1.43 \times 10^7\) であり、制限時間内に十分実行可能です。
さらに、グループを切り出す順番による重複(例えば、\(\{1, 2\}\) と \(\{3\}\) に分ける遷移と、\(\{3\}\) と \(\{1, 2\}\) に分ける遷移)を排除するため、「\(S\) のうち最も右にある立っているビット(LSB)を必ず含むグループ \(T\) のみを選ぶ」という工夫を施します。これにより、遷移の探索数を約半分に削減できます。
アルゴリズム
ステップ 1: ウイルスの情報をビットマスクに変換
各ウイルス \(j\) の感染対象 \(S_j\) をビット配列(整数値) mask に変換します。
ステップ 2: SOS DP による \(C[T]\) の計算
- サイズ \(2^N\) の配列
Wを \(0\) で初期化します。 - 各ウイルス \(j\) の
maskに対し、W[mask] |= maskとします。 - 以下のループ(SOS DP)により、各
maskに対し、その部分集合となるウイルスの感染対象の和集合を計算します。for i in range(N): for mask in range(1 << N): if (mask >> i) & 1: W[mask] |= W[mask ^ (1 << i)] - 各 \(T\) について、
C[T] = popcount(W[T])を計算します。
ステップ 3: ビットDPによる最小値の探索
dp配列を \(0\) で初期化します。- \(S\) を \(1\) から \(2^N - 1\) まで昇順に走査します。
- \(S\) の LSB(最下位ビット)を
lsb = S & -Sとして取得し、残りの集合をrem = S ^ lsbとします。 remの部分集合T_primeをビット演算を用いて列挙し、T = T_prime | lsbを1つのグループとして切り出す遷移を行います。 $\(\text{dp}[S] = \min_{T' \subseteq \text{rem}} (C[T' \cup \{\text{lsb}\}] + \text{dp}[\text{rem} \setminus T'])\)$- 最終的な答えは
dp[(1 << N) - 1]となります。
計算量
時間計算量: \(O(M + N 2^N + 3^N)\)
- ウイルスの入力処理と初期化: \(O(M)\)
- SOS DP による
Wの計算: \(O(N 2^N)\) - ビットDPによる遷移: \(O(3^N)\)
- \(N = 15, M = 500\) のとき、全体の計算回数は約 \(1.5 \times 10^7\) 回となり、Pythonでも高速に動作します。
空間計算量: \(O(2^N)\)
W,C,dpテーブルのサイズはいずれも \(2^N\) です。- \(N = 15\) のとき \(2^{15} = 32,768\) 個の整数を保持するだけなので、メモリ使用量は数メガバイト程度と極めて少量です。
実装のポイント
部分集合の高速な列挙: ある集合
remの部分集合T_primeを降順に列挙する際、以下のビット演算テクニックを使用しています。T_prime = rem while T_prime > 0: # (T_prime に関する処理) T_prime = (T_prime - 1) & remこの手法を用いることで、立っているビットのみを対象とした効率的な部分集合の列挙が \(O(3^N)\) で可能になります。
重複排除による定数倍高速化:
lsbを用いて「特定の1要素を必ず含むグループ」のみを切り出すことで、遷移の探索空間を狭め、無駄な重複計算を完全に防いでいます。ソースコード
import sys
def main():
# 入力を一度にすべて読み込む
input_data = sys.stdin.read().split()
if not input_data:
return
N = int(input_data[0])
M = int(input_data[1])
idx = 2
masks = []
for _ in range(M):
k = int(input_data[idx])
idx += 1
mask = 0
for _ in range(k):
v = int(input_data[idx]) - 1
idx += 1
mask |= (1 << v)
masks.append(mask)
# SOS DP (Sum Over Subsets) を用いて、各部分集合 T に対して
# T に完全に含まれるウイルスの感染対象の和集合のマスク W[T] を計算する
W = [0] * (1 << N)
for mask in masks:
W[mask] |= mask
for i in range(N):
for mask in range(1 << N):
if (mask >> i) & 1:
W[mask] |= W[mask ^ (1 << i)]
# popcount テーブルの作成
popcount = [0] * (1 << N)
for i in range(1 << N):
popcount[i] = popcount[i >> 1] + (i & 1)
# 各部分集合 T が1つのグループを形成したときの、そのグループ内での被害台数 C[T]
C = [popcount[W[i]] for i in range(1 << N)]
# DP[S]: コンピュータの集合 S をいくつかのグループに分けたときの最小被害台数
dp = [0] * (1 << N)
for S in range(1, 1 << N):
# S のうち最も右にある立っているビット (LSB) を取り出す
# LSB を含むグループ T を選ぶことで、重複を排除し遷移数を約半分にする
lsb = S & -S
rem = S ^ lsb
val = C[lsb] + dp[rem]
# rem の部分集合 T_prime を列挙する
T_prime = rem
while T_prime > 0:
v = C[T_prime | lsb] + dp[rem ^ T_prime]
if v < val:
val = v
T_prime = (T_prime - 1) & rem
dp[S] = val
print(dp[(1 << N) - 1])
if __name__ == '__main__':
main()
この解説は gemini-3.5-flash-thinking によって生成されました。
投稿日時:
最終更新: