一、问题分析
形式化描述
给定 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n 个单词,每个单词可以看作边:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> edge ( w ) : (first(w)) → (last(w)) . \text{edge}(w): \text{(first(w))} \to \text{(last(w))}. </math>edge(w):(first(w))→(last(w)).
其中 <math xmlns="http://www.w3.org/1998/Math/MathML"> first ( w ) \text{first}(w) </math>first(w) 为单词 <math xmlns="http://www.w3.org/1998/Math/MathML"> w w </math>w 的首字母对应的节点, <math xmlns="http://www.w3.org/1998/Math/MathML"> last ( w ) \text{last}(w) </math>last(w) 为其末字母对应的节点。
我们希望把这些边在图中依次走完且每条边恰好用一次,形成一个欧拉路径(或欧拉回路),并且在所有可能的欧拉路径中,按单词依次连接出的结果字符串字典序最小。
欧拉路径存在条件
在一个有向图中,存在欧拉路径(一次遍历所有边且只用一次)的必要且充分条件如下:
- 整个有向图中所有有边的顶点连通性: 若把有向图视作无向图后,所有包含边的顶点在同一个连通分量里(忽略孤立点)。
- 节点入度和出度的限制 :
- 要么所有节点的入度 <math xmlns="http://www.w3.org/1998/Math/MathML"> = = </math>= 出度(这时存在欧拉回路,也是一种欧拉路径);
- 要么正好有一个节点满足 <math xmlns="http://www.w3.org/1998/Math/MathML"> outdegree = indegree + 1 \text{outdegree} = \text{indegree} + 1 </math>outdegree=indegree+1(它将成为起点),一个节点满足 <math xmlns="http://www.w3.org/1998/Math/MathML"> indegree = outdegree + 1 \text{indegree} = \text{outdegree} + 1 </math>indegree=outdegree+1(它将成为终点),其余所有节点满足入度 <math xmlns="http://www.w3.org/1998/Math/MathML"> = = </math>= 出度。
若不满足上述条件,则欧拉路径不存在,也就不可能把所有单词排成一个满足题意的链,应输出 ***
。
字典序最小的欧拉路径
- 边排序:将每个顶点的出边按照单词的字典序从小到大排序。
- 字典序优先遍历:在 Hierholzer 算法求欧拉路径的过程中,始终按顶点剩余出边里字典序最小的边优先走,就能得到最终的欧拉路径在单词序列层面也是字典序最小的。
- 栈模拟:可以用一个栈来模拟具体实现,详见下文。
二、算法思路与实现步骤
以下假设英文字母只会是 'a' 到 'z',用 <math xmlns="http://www.w3.org/1998/Math/MathML"> ID ( c ) = c − ′ a ′ \text{ID}(c) = c - 'a' </math>ID(c)=c−′a′ 做节点编号(0 ~ 25)。
1. 读入与建图
- 读入 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n 个单词 <math xmlns="http://www.w3.org/1998/Math/MathML"> w w </math>w。
- 令 <math xmlns="http://www.w3.org/1998/Math/MathML"> u = ID ( w [ 0 ] ) u = \text{ID}(w[0]) </math>u=ID(w[0]), <math xmlns="http://www.w3.org/1998/Math/MathML"> v = ID ( w [ len ( w ) − 1 ] ) v = \text{ID}(w[\text{len}(w) - 1]) </math>v=ID(w[len(w)−1])。
- 更新出度和入度:
- <math xmlns="http://www.w3.org/1998/Math/MathML"> outdeg [ u ] + + \text{outdeg}[u]++ </math>outdeg[u]++,
- <math xmlns="http://www.w3.org/1998/Math/MathML"> indeg [ v ] + + \text{indeg}[v]++ </math>indeg[v]++。
- 在邻接表 <math xmlns="http://www.w3.org/1998/Math/MathML"> adj [ u ] \text{adj}[u] </math>adj[u] 中插入一条记录 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( w , v ) (w, v) </math>(w,v),记录出边指向的节点 <math xmlns="http://www.w3.org/1998/Math/MathML"> v v </math>v 以及边对应单词 <math xmlns="http://www.w3.org/1998/Math/MathML"> w w </math>w。
2. 排序每个节点的出边
对每个 <math xmlns="http://www.w3.org/1998/Math/MathML"> u ∈ [ 0..25 ] u \in [0..25] </math>u∈[0..25],将 <math xmlns="http://www.w3.org/1998/Math/MathML"> adj [ u ] \text{adj}[u] </math>adj[u] 按照单词 <math xmlns="http://www.w3.org/1998/Math/MathML"> w w </math>w 的字典序从小到大排序。这样在后续寻路时,总能优先取最小的单词。
3. 检查欧拉路径存在条件
统计:
- <math xmlns="http://www.w3.org/1998/Math/MathML"> cntStart \text{cntStart} </math>cntStart = 节点个数中满足 <math xmlns="http://www.w3.org/1998/Math/MathML"> outdeg [ i ] = indeg [ i ] + 1 \text{outdeg}[i] = \text{indeg}[i] + 1 </math>outdeg[i]=indeg[i]+1 的个数;
- <math xmlns="http://www.w3.org/1998/Math/MathML"> cntEnd \text{cntEnd} </math>cntEnd = 节点个数中满足 <math xmlns="http://www.w3.org/1998/Math/MathML"> indeg [ i ] = outdeg [ i ] + 1 \text{indeg}[i] = \text{outdeg}[i] + 1 </math>indeg[i]=outdeg[i]+1 的个数;
- <math xmlns="http://www.w3.org/1998/Math/MathML"> cntOthers \text{cntOthers} </math>cntOthers = 节点个数中满足 <math xmlns="http://www.w3.org/1998/Math/MathML"> ∣ indeg [ i ] − outdeg [ i ] ∣ > 1 |\text{indeg}[i] - \text{outdeg}[i]| > 1 </math>∣indeg[i]−outdeg[i]∣>1 的个数。
满足:
- 要么 <math xmlns="http://www.w3.org/1998/Math/MathML"> cntStart = 1 ∧ cntEnd = 1 ∧ cntOthers = 0 \text{cntStart} = 1 \land \text{cntEnd} = 1 \land \text{cntOthers} = 0 </math>cntStart=1∧cntEnd=1∧cntOthers=0(有向图欧拉"开放"路径);
- 要么 <math xmlns="http://www.w3.org/1998/Math/MathML"> cntStart = 0 ∧ cntEnd = 0 ∧ cntOthers = 0 \text{cntStart} = 0 \land \text{cntEnd} = 0 \land \text{cntOthers} = 0 </math>cntStart=0∧cntEnd=0∧cntOthers=0(有向图欧拉回路)。
否则输出 ***
并结束。
4. 确定起点
- 若存在节点 <math xmlns="http://www.w3.org/1998/Math/MathML"> i i </math>i 满足 <math xmlns="http://www.w3.org/1998/Math/MathML"> outdeg [ i ] = indeg [ i ] + 1 \text{outdeg}[i] = \text{indeg}[i] + 1 </math>outdeg[i]=indeg[i]+1,那么必须从该 <math xmlns="http://www.w3.org/1998/Math/MathML"> i i </math>i 出发(欧拉开放路径)。
- 否则在所有有出边的节点里,取最小编号(对应最小字母)的一个作为起点(欧拉回路场景时任取一个有出边的节点即可,但要拿字母最小的,以保证后续路径字典序也最优)。
5. 连通性检查(忽略方向)
- 要求所有用到的字母节点(出度或入度 <math xmlns="http://www.w3.org/1998/Math/MathML"> > > </math>> 0)都在同一个连通分量里。
- 构造无向邻接表,并在任意一个有出/入边的节点上做 DFS/BFS,统计能到达的节点数目。
- 如果尚有其他节点也有出/入度但无法访问到,则说明图不连通,输出
***
。
6. Hierholzer 算法找字典序最小欧拉路径
- 定义一个栈 <math xmlns="http://www.w3.org/1998/Math/MathML"> st \text{st} </math>st,存放当前行进中的节点;另一个栈 <math xmlns="http://www.w3.org/1998/Math/MathML"> edgeStack \text{edgeStack} </math>edgeStack 用来记录"使用了哪条边(单词)"。
- 初始时 <math xmlns="http://www.w3.org/1998/Math/MathML"> st.push(start) \text{st.push(start)} </math>st.push(start)。
- 当 <math xmlns="http://www.w3.org/1998/Math/MathML"> st \text{st} </math>st 不为空时:
- 令 <math xmlns="http://www.w3.org/1998/Math/MathML"> v = st.top() v = \text{st.top()} </math>v=st.top()。
- 如果 <math xmlns="http://www.w3.org/1998/Math/MathML"> adj [ v ] \text{adj}[v] </math>adj[v] 还有未使用的出边:
- 取 <math xmlns="http://www.w3.org/1998/Math/MathML"> adj [ v ] \text{adj}[v] </math>adj[v] 中字典序最小的一条 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( word , nxt ) (\text{word}, \text{nxt}) </math>(word,nxt),将其"弹出"或标记已使用;
- <math xmlns="http://www.w3.org/1998/Math/MathML"> st.push(nxt) \text{st.push(nxt)} </math>st.push(nxt);
- <math xmlns="http://www.w3.org/1998/Math/MathML"> edgeStack.push(word) \text{edgeStack.push(word)} </math>edgeStack.push(word)(表示我们走了这个单词的边)。
- 若 <math xmlns="http://www.w3.org/1998/Math/MathML"> adj [ v ] \text{adj}[v] </math>adj[v] 全部用完:
- <math xmlns="http://www.w3.org/1998/Math/MathML"> st.pop() \text{st.pop()} </math>st.pop();
- 如果此时 <math xmlns="http://www.w3.org/1998/Math/MathML"> edgeStack \text{edgeStack} </math>edgeStack 非空,则将 <math xmlns="http://www.w3.org/1998/Math/MathML"> edgeStack.top() \text{edgeStack.top()} </math>edgeStack.top() 加入结果序列 <math xmlns="http://www.w3.org/1998/Math/MathML"> euler \text{euler} </math>euler,并 <math xmlns="http://www.w3.org/1998/Math/MathML"> edgeStack.pop() \text{edgeStack.pop()} </math>edgeStack.pop()。
- 循环结束后, <math xmlns="http://www.w3.org/1998/Math/MathML"> euler \text{euler} </math>euler 中的单词顺序会是逆序(因为最后弹出的边是最先加入序列)。逆序一下得到真正的从头到尾的顺序。
- 如果最终得到的单词序列 <math xmlns="http://www.w3.org/1998/Math/MathML"> euler.size() \text{euler.size()} </math>euler.size() 恰好是 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n,则成功,否则说明不连通或还有边没用掉,输出
***
。
7. 输出结果
- 若 <math xmlns="http://www.w3.org/1998/Math/MathML"> euler.size() = n \text{euler.size()} = n </math>euler.size()=n,将其用
.
连接起来输出; - 否则输出
***
。
三、代码示例(C++)
cpp
#include <bits/stdc++.h>
using namespace std;
inline int letterID(char c) { return c - 'a'; }
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<pair<string, int>> adj[26];
vector<int> indeg(26, 0), outdeg(26, 0);
vector<string> words(n);
for (int i = 0; i < n; i++) {
cin >> words[i];
int u = letterID(words[i].front());
int v = letterID(words[i].back());
outdeg[u]++;
indeg[v]++;
adj[u].push_back({words[i], v});
}
for (int i = 0; i < 26; i++) {
sort(adj[i].begin(), adj[i].end(), [](auto &a, auto &b) {
return a.first < b.first;
});
}
int cntStart = 0, cntEnd = 0, cntOthers = 0;
int startNode = -1;
for (int i = 0; i < 26; i++) {
int diff = outdeg[i] - indeg[i];
if (diff == 1) {
cntStart++;
startNode = i;
} else if (diff == -1) {
cntEnd++;
} else if (diff != 0) {
cntOthers++;
}
}
if (!((cntStart == 1 && cntEnd == 1) || (cntStart == 0 && cntEnd == 0)) || cntOthers > 0) {
cout << "***\n";
return 0;
}
if (cntStart == 0) {
for (int i = 0; i < 26; i++) {
if (outdeg[i] > 0) {
startNode = i;
break;
}
}
}
vector<vector<int>> undirected(26);
for (int i = 0; i < 26; i++) {
for (auto &edge : adj[i]) {
int j = edge.second;
undirected[i].push_back(j);
undirected[j].push_back(i);
}
}
int startCheck = -1;
for (int i = 0; i < 26; i++) {
if ((indeg[i] + outdeg[i]) > 0) {
startCheck = i;
break;
}
}
vector<bool> visited(26, false);
if (startCheck != -1) {
queue<int> q;
q.push(startCheck);
visited[startCheck] = true;
while (!q.empty()) {
int u = q.front();
q.pop();
for (int v : undirected[u]) {
if (!visited[v]) {
visited[v] = true;
q.push(v);
}
}
}
}
for (int i = 0; i < 26; i++) {
if ((indeg[i] + outdeg[i]) > 0 && !visited[i]) {
cout << "***\n";
return 0;
}
}
stack<int> st;
stack<string> edgeStack;
vector<int> idx(26, 0);
st.push(startNode);
vector<string> euler;
euler.reserve(n);
while (!st.empty()) {
int v = st.top();
if (idx[v] < (int)adj[v].size()) {
auto &e = adj[v][idx[v]];
idx[v]++;
st.push(e.second);
edgeStack.push(e.first);
} else {
st.pop();
if (!edgeStack.empty()) {
euler.push_back(edgeStack.top());
edgeStack.pop();
}
}
}
if ((int)euler.size() != n) {
cout << "***\n";
return 0;
}
reverse(euler.begin(), euler.end());
for (int i = 0; i < n; i++) {
if (i > 0) cout << ".";
cout << euler[i];
}
cout << "\n";
return 0;
}
四、复杂度与适用范围
- <math xmlns="http://www.w3.org/1998/Math/MathML"> n ≤ 1000 n \leq 1000 </math>n≤1000,每个单词长度最多 20。
- 时间复杂度 :
- 建图和排序每个节点的出边耗时 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n log n ) O(n \log n) </math>O(nlogn);
- 连通性检查 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( 26 + n ) O(26 + n) </math>O(26+n);
- Hierholzer 算法本身是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n ) O(n) </math>O(n)。
- 空间复杂度 : <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n ) O(n) </math>O(n)。
本解法足够应付题目给出的上限。
小结
核心是将「每个单词」视为有向边,做出度、入度与连通性检查,确定起点,然后用字典序优先的欧拉路径算法来得到答案。
- 若路径不存在或者无法用完所有单词,就输出
***
; - 否则将得到的欧拉路径按单词顺序输出并用
.
连接。