\(AcWing\) \(1165\) 单词环
一、题目描述
我们有 \(n\) 个字符串,每个字符串都是由 \(a\)∼\(z\) 的 小写英文字母 组成的。
如果字符串 \(A\) 的结尾两个字符刚好与字符串 \(B\) 的开头两个字符相匹配,那么我们称 \(A\) 与 \(B\) 能够相连(注意:\(A\) 能与 \(B\) 相连不代表 \(B\) 能与 \(A\) 相连)。
我们希望从给定的字符串中找出一些,使得它们首尾相连形成一个 环串(一个串首尾相连也算),我们想要使这个环串的 平均长度最大 。
如下例:
ababc
bckjaca
caahoynaab
第一个串能与第二个串相连,第二个串能与第三个串相连,第三个串能与第一个串相连,我们按照此顺序相连,便形成了一个环串,长度为 \(5+7+10=22\)(重复部分算两次),总共使用了 \(3\) 个串,所以平均长度是 \(\frac{22}{3}≈7.33\)。
输入格式
本题有多组数据。
每组数据的第一行,一个整数 \(n\),表示字符串数量;
接下来 \(n\) 行,每行一个长度小于等于 \(1000\) 的字符串。
读入以 \(n=0\) 结束。
输出格式
若不存在环串,输出 No solution
,否则输出最长的环串的平均长度。
只要答案与标准答案的差不超过 0.01
,就视为答案正确。
数据范围
\(1≤n≤10^5\)
二、题目解析
1、建图技巧
本题同样是考察 \(01\)分数规划,只不过难度较上题有所提升。首先需要考虑的是如何建图,如果常规的用每个字符串作为图中的节点,最多会有\(1e5\)个节点,边数也可能高达\(1e5 \times 1e5=1e10=100亿\)级别,显然难以承受,而且以字符串作为节点,还需要挨个比较每个字符串的末尾两个字符与其它字符串的开头两个字符是否相等,这也将耗费大量的时间。
我们知道,有小写字母构成的两个字符最多有\(26 * 26 = 676\)种可能,而这十万字符串中间的内容并不重要,我们只关心每个字符串的开始两个字符、末尾两个字符以及字符串的长度。这样我们读入一个字符串时,将其开头长度为\(2\)的字符串作为一个节点,末尾长度为\(2\)的字符串作为另一个节点,两个节点间有向边的长度就是该字符串的长度。这种 巧妙 的建图方式不仅使得节点数骤减,边数不超过限制\(676 \times 676 = 456976\),更方便的是每条边都象征着存在一个字符串开头字符串是\(a\),末尾字符串是\(b\),字符串长度是\(c\)。如果\(b\)指向另一个点\(d\),\(abd\)就自然的连接起来了,并且连接后字符串的长度就是边权之和,非常方便。既然节点最多有\(26*26\)种可能性,那么就可以将长度为\(2\)的字符串进行哈希映射到\(0\)到\(675\)的区间中,比如\(aa\)就映射到编号为\(0\)的节点,这样本题的图就建好了。
2、\(01\)分数规划
第二步就是按照\(01\)分数规划的解题思路推公式,环串的平均长度最大,等价于\(\displaystyle \large \frac{\sum s_i}{\sum 1}\)最大,其中
- \(\large \sum s_i\)表示环中各边的长度累加和
- \(\large \sum 1\)就是环上的 边数 累加和
要判断对于某个\(mid\)是否有\(\large \displaystyle \frac{\sum s_i}{\sum 1} >= mid\),只需要\(\displaystyle \sum s_i >= \sum 1*mid\),即\(\sum(mid - s_i) <= 0\)即可,即边权为\(mid - s_i\)的图中存在负权回路,问题就进一步转化为了\(spfa\)求负权回路问题了。
3、\(SPFA\)判断负环
使用\(spfa\)算法解决是否存在负环问题
求负环的常用方法,基于\(SPFA\),一般都用方法\(2\)(该题也是用方法 \(2\)):
- 方法\(1\):统计每个点入队的次数,如果某个点入队\(n\)次,则说明存在负环
【不推荐】
cnt[j]++;
if(cnt[j]>=n) return true;
- 方法\(2\):统计当前每个点的最短路中所包含的边数,如果某点的最短路所包含的边数大于等于\(n\),则也说明存在环
【推荐】
if(dist[j] > dist[t] + w[i]){
dist[j] = dist[t] + w[i];
cnt[j] = cnt[t] + 1;
if(cnt[j] >= n) return true;
if(!st[j]){
queue.add(j);
st[j] = true;
}
}
\(y\)总的原话
每点做一遍\(spfa()\)一定是正确的,但时间复杂度较高,可能会超时。
初始时将所有点插入队列中可以按如下方式理解:
在原图的基础上新建一个虚拟源点,从该点向其他所有点连一条权值为\(0\)的有向边。那么原图有负环等价于新图有负环。此时在新图上做\(spfa\),将虚拟源点加入队列中。然后进行\(spfa\)的第一次迭代,这时会将所有点的距离更新并将所有点插入队列中。执行到这一步,就等价于视频中的做法了。那么视频中的做法可以找到负环,等价于这次\(spfa\)可以找到负环,等价于新图有负环,等价于原图有负环。得证。
- 1、\(dist[x]\) 记录虚拟源点到\(x\)的最短距离
- 2、\(cnt[x]\) 记录当前\(x\)点到虚拟源点最短路的边数,初始每个点到虚拟源点的距离为\(0\),只要他能再走\(n\)步,即\(cnt[x] >= n\),则表示该图中一定存在负环,由于从虚拟源点到\(x\)至少经过\(n\)条边时,则说明图中至少有\(n + 1\)个点(抽屉原理),表示一定有点是重复使用
- 3、若\(dist[j] > dist[t] + w[i]\),则表示从\(t\)点走到\(j\)点能够让权值变少,因此进行对该点\(j\)进行更新,并且对应\(cnt[j] = cnt[t] + 1\),往前走一步
特殊情况
让你找负环,并不是让你找以\(1\)为起点负环,图并不是全部连通,存在单独一个点没有入度和出度。这时候就不能只从某一个点进去找,有可能根本更新不到其他点。
这时候可以假设一个虚拟源点与所有的点都相连,并且距离为零。
一次把所有点入队,后续更新就可以检测出负环。
但以上这个方法被硬卡数据有可能超时,可以尝试:
(1) \(dfs\)优化
其实,\(SPFA\)除了\(bfs\)写法外,还有一种\(dfs\)的写法:
\(dfs\)是换了一种思路:把\(dist\)数组的初值置为\(0\),这样就能保证走过的路径和一直为负,排除了大量无关路径。但是这样判断的是是否有经过起始点的负环,因此要判断整个图中是否有负环的话,得把\(n\)个点全跑一遍。看起来是简单,但有以下注意事项:
- 如果 只是判负环,使用\(dfs\)比\(bfs\)一般要 快得多
- \(dfs\)判断负环时,\(dist\)数组初值应该都设为\(0\)
- 不要指望\(dfs\)在判断负环的同时还能求最短路了
- 用\(dfs\)判断负环,不能只把一个点作为源点跑一次,而要把\(1-n\)每个都作为源点跑一遍\(dfs\),才能保证结果的正确。
\(bfs\)判负环的一种方式是用\(num[x]\)记录\(x\)入队的次数,如过某个\(num[x]>=n\)则判定有负环。但这种方法一般不如上面介绍的\(dfs\)快。
例如在\(n\)个结点构成一个负环的图中(这也是一种常见的卡的图),上面的方法只需绕环一次即可判定负环,而这种方法则需绕环\(n\)次。
\(70ms\) \(AC\)的代码:
#include <cstdio>
#include <cstring>
#include <iostream>
using namespace std;
const int N = 1e3 + 10;
const int M = 1e5 + 10;
const int U = 26 * 26;
int n;
int h[N], e[M], w[M], ne[M], idx;
double d[N];
bool st[N]; //如果当前这个点 u 可以松弛的点 v 是被访问过的,那么就说明一定存在负环。
char str[N]; //黄海替换为string,结果执行时间由70ms--> 400ms!!!
const double eps = 1e-3;
void add(int a, int b, int c) {
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
bool dfs(int u, double mid) {
if (st[u]) return true; //如果又见u,说明有环
bool flag = false; //我的后代们是不是有环?
st[u] = true; // u我出现过一次了~
for (int i = h[u]; ~i; i = ne[i]) {
int j = e[i];
//更新最小值,判负环
if (d[j] > d[u] - w[i] + mid) {
d[j] = d[u] - w[i] + mid;
//检查一下我的下一个节点j,它要是有负环检查到,我也汇报
flag = dfs(j, mid);
if (flag) break;
}
}
st[u] = false; //回溯
return flag;
}
bool check(double mid) {
memset(d, 0, sizeof d);
// memset(st, 0, sizeof st);//因为每次dfs完了,都进行了回溯,所以st一直保持空白,不用每次清空
for (int i = 0; i < U; i++)
if (dfs(i, mid)) return true;
return false;
}
void solve() {
memset(h, -1, sizeof h);
idx = 0;
while (n--) {
scanf("%s", str);
int len = strlen(str);
if (len < 2) continue;
int a = (str[0] - 'a') * 26 + str[1] - 'a';
int b = (str[len - 2] - 'a') * 26 + str[len - 1] - 'a';
add(a, b, len);
}
if (!check(0)) {
puts("No solution");
return;
}
double l = 0, r = 1e3;
while (r - l > eps) {
double mid = (l + r) / 2;
if (check(mid))
l = mid;
else
r = mid;
}
printf("%.2lf\n", r);
}
int main() {
//多组测试数组,喜欢单开一个solve()进行处理,确实看着舒服
while (scanf("%d", &n), n) solve();
return 0;
}
\(Q:\)为什么要写个\(st[u] = false;\)
\(A:\)因为我们懒,如果不写的话就无法把\(st\)数组置\(0\)了,这样我们每次\(dfs\)都得memset(st,0,sizeof st)
,麻烦~
本蒟蒻建议:用\(dfs\)判负环,用\(bfs\)求最小路。
(2) 玄学优化
当所有点的入队次数超过\(2*n\)时,认为很大可能存在负环,但也可以被卡掉
这个玄学优化是不是看着特别不靠谱?有骗分的嫌疑!!!
#include <bits/stdc++.h>
using namespace std;
// SPFA找环+ STL 玄学优化
// n=26 26*26=676 这里N设为700
const int N = 700, M = 100010;
int m; //边的数量
int cnt[N], q[N];
double dist[N];
bool st[N];
//邻接表
int idx, h[N], e[M], w[M], ne[M];
void add(int a, int b, int c) {
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
bool check(double k) {
memset(dist, 0x3f, sizeof dist);
memset(cnt, 0, sizeof cnt);
memset(st, false, sizeof st);
queue<int> q;
for (int i = 0; i < 676; i++) {
q.push(i);
st[i] = true;
}
//整体入队列次数,676*10表示所有点都入队10次了,还没有找到解
int count = 0;
while (q.size()) {
int u = q.front();
q.pop();
st[u] = false;
for (int i = h[u]; ~i; i = ne[i]) {
int j = e[i];
if (dist[j] > dist[u] + k - w[i]) {
dist[j] = dist[u] + k - w[i];
cnt[j] = cnt[u] + 1;
if (++count > 10000 || cnt[j] >= 676) return true;
if (!st[j]) {
q.push(j);
st[j] = true;
}
}
}
}
return false;
}
int main() {
string s;
while (cin >> m, m) {
//多组测试数据,清空邻接表
memset(h, -1, sizeof h);
idx = 0;
//读入每条边
for (int i = 0; i < m; i++) {
cin >> s;
//不够长,没用
if (s.size() < 2) continue;
//模拟节点号
int a = (s[0] - 'a') * 26 + s[1] - 'a';
int b = (s[s.size() - 2] - 'a') * 26 + s[s.size() - 1] - 'a';
//建图,有向图
add(a, b, s.size());
}
if (!check(0))
puts("No solution");
else {
double l = 0, r = 1000;
while (r - l > 1e-4) {
double mid = (l + r) / 2;
if (check(mid))
l = mid;
else
r = mid;
}
printf("%.2lf\n", l);
}
}
return 0;
}
(3) 双端队列\(SLF\)优化
双端队列\(SLF\)优化,又称 酸辣粉优化。当当前节点到起点距离小于队首的时候,将此点插入队首,否则,正常插入队尾。可能咋一看感觉这种优化可能被用到的机会很少,但很可能正好解决掉了专门卡\(spfa\)的数据以及网格图。
这家伙性能也不稳定,负环可以\(AC\),改成正环\(TLE\), 个人理解,判负环还是\(SPFA+dfs\)靠谱的多!
#include <bits/stdc++.h>
using namespace std;
// SPFA找环+ STL双端队列优化
//结果: Accepted通过了 3/3个数据
const int N = 700, M = 100010; // n=26 26*26=676 这里N设为700
const double eps = 1e-4;
//邻接表
int idx, h[N], e[M], w[M], ne[M];
void add(int a, int b, int c) {
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
int m; //边的数量
int cnt[N]; // SPFA用到的 cnt[i]:以i为起点的最短路走过的边的数量
double dist[N]; // SPFA用到的最短距离数组
bool st[N]; // SPFA用到的是否在队列中的数组
bool check(double mid) {
memset(dist, 0x3f, sizeof dist);
memset(cnt, 0, sizeof cnt);
memset(st, false, sizeof st);
//双端队列优化的SPFA,SLF+SPFA
deque<int> q;
for (int i = 0; i < 676; i++) {
q.push_back(i);
st[i] = true;
}
while (q.size()) {
int u = q.front();
q.pop_front();
st[u] = false;
for (int i = h[u]; ~i; i = ne[i]) {
int j = e[i];
if (dist[j] > dist[u] + mid - w[i]) {
dist[j] = dist[u] + mid - w[i];
cnt[j] = cnt[u] + 1;
if (cnt[j] >= 676) return true; //出现负环
if (!st[j]) {
// SLF优化
if (q.size() && dist[q.front()] > dist[j])
q.push_front(j);
else
q.push_back(j);
st[j] = true;
}
}
}
}
return false;
}
int main() {
string s;
while (cin >> m, m) {
//多组测试数据,清空邻接表
memset(h, -1, sizeof h);
idx = 0;
//读入每条边
while (m--) {
cin >> s;
if (s.size() < 2) continue; //不够长,没用,就是这样的小边界才是出问题的关键位置,千万要小心处理
//模拟节点号
int a = (s[0] - 'a') * 26 + s[1] - 'a';
int b = (s[s.size() - 2] - 'a') * 26 + s[s.size() - 1] - 'a';
//建图,有向图
add(a, b, s.size());
}
//连0都不可以的话,当边权更大时就更不可能了
if (!check(0))
puts("No solution");
else {
// 01分数规划+二分
double l = 0, r = 1000;
while (r - l > eps) {
double mid = (l + r) / 2;
if (check(mid))
l = mid;
else
r = mid;
}
printf("%.2lf\n", l);
}
}
return 0;
}
(4) 将队列替换为栈进行优化
/*
SPFA找环+手写栈优化
队列换成栈效率会更高
因为在队列中,一个点会把它更新的节点依次入队,当有环的时候,它需要遍历完队列中所有的节点才会发现。
但是栈不一样了,如果这个环只有三个节点,它很快就会找到了
个人理解换stack其实是大概率有环的情况下才会去换,也就是queue被卡的时候可以考虑,但是多数情况下queue的效率是要比stack好的,
因为对于stack来说只有在有环的时候它的的性质(队尾出)才有优势。
*/
#include <bits/stdc++.h>
using namespace std;
const double eps = 1e-4;
// N是点数, M是边数。
const int N = 700, M = 100010;
int m;
double dist[N];
int q[N], cnt[N];
bool st[N];
//链式前向星
int e[M], h[N], idx, w[M], ne[M];
void add(int a, int b, int c) {
e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++;
}
bool check(double mid) {
memset(st, 0, sizeof st);
memset(cnt, 0, sizeof cnt);
int hh = 0, tt = -1;
for (int i = 0; i < 676; i++) {
q[tt++] = i;
st[i] = true;
}
int count = 0;
while (hh <= tt) {
int t = q[--tt]; //注意这里是栈
st[t] = false;
for (int i = h[t]; ~i; i = ne[i]) {
int j = e[i];
if (dist[j] < dist[t] + w[i] - mid) {
dist[j] = dist[t] + w[i] - mid;
cnt[j] = cnt[t] + 1;
if (cnt[j] >= N) return true; // 走过的点超过了26*26,就存在环
if (!st[j]) {
q[tt++] = j;
st[j] = true;
}
}
}
}
return false;
}
int main() {
string s;
while (cin >> m, m) {
//多组测试数据,清空邻接表
memset(h, -1, sizeof h);
idx = 0;
//读入每条边
while (m--) {
cin >> s;
if (s.size() < 2) continue; //不够长,没用,就是这样的小边界才是出问题的关键位置,千万要小心处理
//模拟节点号
int a = (s[0] - 'a') * 26 + s[1] - 'a';
int b = (s[s.size() - 2] - 'a') * 26 + s[s.size() - 1] - 'a';
//建图,有向图
add(a, b, s.size());
}
//连0都不可以的话,当边权更大时就更不可能了
if (!check(0))
puts("No solution");
else {
// 01分数规划+二分
double l = 0, r = 1000;
while (r - l > eps) {
double mid = (l + r) / 2;
if (check(mid))
l = mid;
else
r = mid;
}
printf("%.2lf\n", l);
}
}
return 0;
}
三、怎么判断无环?
二分中的\(check\)函数就是用于判断当前的\(mid\)是否可以构成环的,那么给一个极限值,如果极限值都不能构成环,就说明没法构成环,极限值是\(0\)