## [$AcWing$ $396$ 矿场搭建](https://www.acwing.com/problem/content/description/398/) ### 一、题目描述 煤矿工地可以看成是由 **隧道** 连接 **挖煤点** 组成的 **无向图**。 为安全起见,希望在工地发生事故时 **所有挖煤点的工人都能有一条出路逃到** 救援出口处 于是矿主决定在 **某些挖煤点** 设立 **救援出口**,使得无论哪一个挖煤点坍塌之后,其他挖煤点的工人都有一条道路通向救援出口。 请写一个程序,用来计算 **至少** 需要设置几个救援出口,以及不同最少救援出口的设置方案总数。 **输入格式** 输入文件有若干组数据,每组数据的第一行是一个正整数 $N$,表示工地的隧道数。 接下来的 $N$ 行每行是用空格隔开的两个整数 $S$ 和 $T$,表示挖煤点 $S$ 与挖煤点 $T$ 由隧道直接连接。 注意,每组数据的挖煤点的编号为 $1$∼$Max$,其中 $Max$ 表示由隧道连接的挖煤点中,编号最大的挖煤点的编号,可能存在没有被隧道连接的挖煤点。 输入数据以 $0$ 结尾。 **输出格式** 每组数据输出结果占一行。 其中第 $i$ 行以 $Case$ $i$: 开始(注意大小写,$Case$ 与 $i$ 之间有空格,$i$ 与 : 之间无空格,: 之后有空格)。 其后是用空格隔开的两个正整数,第一个正整数表示对于第 $i$ 组输入数据至少需要设置几个救援出口,第二个正整数表示对于第 $i$ 组输入数据不同最少救援出口的设置方案总数。 输入数据保证答案小于 $264$,输出格式参照以下输入输出样例。 **数据范围** $1≤N≤500,1≤Max≤1000$ **输入样例**: ```cpp {.line-numbers} 9 1 3 4 1 3 5 1 2 2 6 1 5 6 3 1 6 3 2 6 1 2 1 3 2 4 2 5 3 6 3 7 0 ``` **输出样例**: ```cpp {.line-numbers} Case 1: 2 4 Case 2: 4 1 ``` ### 二、题解 #### 1、加法原理 与 乘法原理 首先,这个无向图不一定是连通的,所以可以把它分成若干个 **连通块** 来讨论,对于每个连通块,标记数最少直接 **累加** (**加法原理**),方案数用 **乘法原理** #### 2、点双与割点 以测试用例画一张图,标识出点双连通分量绿色和紫色两个区域,其中,点$1$是割点。 ![](https://dsideal.obs.cn-north-1.myhuaweicloud.com/HuangHai/BlogImages/%7Byear%7D/%7Bmonth%7D/%7Bmd5%7D.%7BextName%7D/20230725130302.png) ```cpp {.line-numbers} #include using namespace std; const int N = 1010, M = 1010; int n, m; int dfn[N], low[N], stk[N], ts, top, root; int bcnt; vector bcc[N]; bool cut[N]; int e[M], h[N], idx, w[M], ne[M]; void add(int a, int b, int c = 0) { e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++; } void tarjan(int u, int fa) { low[u] = dfn[u] = ++ts; stk[++top] = u; int son = 0; for (int i = h[u]; ~i; i = ne[i]) { int v = e[i]; if (v == fa) continue; if (!dfn[v]) { son++; tarjan(v, u); low[u] = min(low[u], low[v]); if (low[v] >= dfn[u]) { int x; if (u != root || son > 1) cut[u] = 1; bcnt++; do { x = stk[top--]; bcc[bcnt].push_back(x); } while (x != v); bcc[bcnt].push_back(u); } } else low[u] = min(low[u], dfn[v]); } if (fa == -1 && son == 0) bcc[++bcnt].push_back(u); } int main() { #ifndef ONLINE_JUDGE freopen("396_Prepare.in", "r", stdin); #endif memset(h, -1, sizeof h); scanf("%d", &m); while (m--) { int a, b; scanf("%d %d", &a, &b); n = max(n, a), n = max(n, b); // 鄙视一下~ if (a != b) add(a, b), add(b, a); } for (root = 1; root <= n; root++) if (!dfn[root]) tarjan(root, -1); cout << "点双个数:" << bcnt << endl; for (int i = 1; i <= bcnt; i++) { cout << "点双编号:" << i << ", 内部节点:"; for (int j = 0; j < bcc[i].size(); j++) cout << bcc[i][j] << " "; cout << endl; } return 0; } ``` #### 3、思路 很显然能够联想到 **点双**,我们考虑一个点双里面的 **割点数量**: - ① $0$个时 说明这个点双与其他的点双之间没有联系,那么这个点双内部至少要建两个救援出口,因为是点双,所以建在哪里都可以,不能只建一个,因为如果恰好是那个点塌了就完了。当然,如果此点双是特殊的点双,也就是 **只有一个节点** 的情况,那么需要建一个。 - ② $1$个时 说明这个点双与另一个点双之间有联系,那么这个点双里面就只需要建一个救援出口,如果这个出口塌了,剩下的点也可以去另一个点双里面找救援出口,如果那个割点塌了,那么这个点双内的点去这个点双里面的救援出口就好了。 - ③ 大于等于$2$个时 无论哪一个点塌了,这个点双里面的点都可以去其他点双里面找救援出口,所以这个点双里面不需要建救援出口。 > **注意**:**不能在割点上建救援出口,这样一下子就断了两个方向来源的路线,在这创建救援出口不是傻吗?** #### 4、总结 - **无割点,与其它连通块彼此独立** - ① **点双中点的数量为$1$** 放$1$个出口,方案数 $*= 1$ **注:** 题目中数据似乎没有特意卡这个~ - ② **点双中点的数量大于$1$** 放$2$个出口,方案数 $*= C_{cnt}^{2} = cnt*(cnt-1)/2$ - **割点数量$=1$** 放$1$个出口,方案数 $*= C_{cnt-1}^{1} = cnt-1$ (不包含割点) - **割点数量$=2$** 放$0$个出口,方案数 $*= 1$ ### 三、实现代码 ```cpp {.line-numbers} #include using namespace std; typedef long long LL; const int N = 1010, M = N << 2; int n, m; // 链式前向星 int e[M], h[N], idx, w[M], ne[M]; void add(int a, int b, int c = 0) { e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++; } /* 1. 用tarjan跑出所有的v_bcc和 原图中哪些点是割点 2. 遍历每个v_bcc(点双),考查点双里的割点个数: (1). 若此点双内包含的割点数>1,则无论哪一个节点被毁,连通性依旧,不用处理。贡献为0 (2). 若此点双内包含的割点数=1,则在分量内任意一点建一个(割点处不用建),一旦分量内建立好的救援出口被毁, 可以通过割点跑到相临的分量中,走别人的救援出口,ans*=bcc.size()-1。贡献为1 (3). 若此点双内包含的割点数=0,则任建两个,ans=bcc.size()(bcc.size()-1)/2。贡献为2 */ int dfn[N], low[N], stk[N], ts, top, root; int bcnt; vector bcc[N]; // 双连通分量 bool cut[N]; // 记录割点的桶,割点可能会重复,所以用桶来记录,最后用循环来统计 void tarjan(int u, int fa) { low[u] = dfn[u] = ++ts; stk[++top] = u; int son = 0; // 求割点时,需要记录点双中节点的数量,用于判断是不是单个节点组成的 for (int i = h[u]; ~i; i = ne[i]) { int v = e[i]; if (v == fa) continue; if (!dfn[v]) { son++; tarjan(v, u); low[u] = min(low[u], low[v]); // 总结:需要孩子仔细理解原理,能清晰的讲解原理,并默写画出原理图,才能把代码写明白,不能靠简单的背代码,会记不住的 // 原理 : https://www.cnblogs.com/littlehb/p/16091406.html if (low[v] >= dfn[u]) { int x; // 记录割点 if (u != root) cut[u] = 1; if (u == root && son >= 2) cut[u] = 1; // 如果u是根节点,但是它至少有两个子节点:则u是割点 // 如果u是根,并且有1个子节点,那么去掉u后,剩下的那个节点还是双点,所以,u不是割点 // 记录点双中节点有哪些 bcnt++; do { x = stk[top--]; bcc[bcnt].push_back(x); } while (x != v); // 将子树出栈 bcc[bcnt].push_back(u); // 把割点/树根也丢到点双里 } } else low[u] = min(low[u], dfn[v]); } // 因为上面枚举的是边,如果是一个孤立的根,是没有边的,上面的代码不会执行,但它确实是一个点双 if (u == root && son == 0) bcc[++bcnt].push_back(u); } int main() { int T = 1; while (scanf("%d", &m), m) { // 每次清除上次记录的bcnt连通块中点的向量数组 for (int i = 1; i <= bcnt; i++) bcc[i].clear(); // n:这题太讨厌了,n居然让我们自己取max计算出来,shit~ idx = n = ts = top = bcnt = 0; memset(h, -1, sizeof h); // 初始化链式前向星 memset(dfn, 0, sizeof dfn); // 每个节点的dfs序时间戳 memset(low, 0, sizeof low); memset(stk, 0, sizeof stk); // 栈 memset(cut, 0, sizeof cut); // 清空割点数组 while (m--) { int a, b; scanf("%d %d", &a, &b); n = max(n, a), n = max(n, b); // 鄙视一下~ if (a != b) add(a, b), add(b, a); } for (root = 1; root <= n; root++) if (!dfn[root]) tarjan(root, -1); // 以root为根开始找出 割点 和 点双 int res = 0; // 增加的救援出口个数 LL num = 1; // 增加的救援出口方案数 for (int i = 1; i <= bcnt; i++) { // 枚举每个点双 int cnt = 0; // 此点双中割点的数量 for (int j = 0; j < bcc[i].size(); j++) // 枚举点双中每个点,通过cut这个桶判断是不是割点 if (cut[bcc[i][j]]) cnt++; if (cnt == 0) { // 如果没有割点 // 如果点双中点的数量大于1,救援出口需要在bcc[i].size()中选择两个,一个坏了还可以走另一个 if (bcc[i].size() > 1) res += 2, num *= bcc[i].size() * (bcc[i].size() - 1) / 2; else // 如果点双中点的数量等于1,孤立的点,那么必须单独设立一个救援出口方案数量不用变化 res++; } else if (cnt == 1) // 如果有一个割点 res++, num *= bcc[i].size() - 1; // 需要添加一个救援出口 // 如果有2个或以上的割点,就不用管了,因为一旦某个割点被毁,可以走另一个 } printf("Case %d: %d %lld\n", T++, res, num); // 救援出口个数,方案数 } return 0; } ```