一、问题分析
形式化描述
给定 个单词,每个单词可以看作边:
其中 为单词 的首字母对应的节点, 为其末字母对应的节点。
我们希望把这些边在图中依次走完且每条边恰好用一次,形成一个欧拉路径(或欧拉回路),并且在所有可能的欧拉路径中,按单词依次连接出的结果字符串字典序最小。
欧拉路径存在条件
在一个有向图中,存在欧拉路径(一次遍历所有边且只用一次)的必要且充分条件如下:
- 整个有向图中所有有边的顶点连通性: 若把有向图视作无向图后,所有包含边的顶点在同一个连通分量里(忽略孤立点)。
- 节点入度和出度的限制:
- 要么所有节点的入度 出度(这时存在欧拉回路,也是一种欧拉路径);
- 要么正好有一个节点满足 (它将成为起点),一个节点满足 (它将成为终点),其余所有节点满足入度 出度。
若不满足上述条件,则欧拉路径不存在,也就不可能把所有单词排成一个满足题意的链,应输出 ***。
字典序最小的欧拉路径
- 边排序:将每个顶点的出边按照单词的字典序从小到大排序。
- 字典序优先遍历:在 Hierholzer 算法求欧拉路径的过程中,始终按顶点剩余出边里字典序最小的边优先走,就能得到最终的欧拉路径在单词序列层面也是字典序最小的。
- 栈模拟:可以用一个栈来模拟具体实现,详见下文。
二、算法思路与实现步骤
以下假设英文字母只会是 'a' 到 'z',用 做节点编号(0 ~ 25)。
1. 读入与建图
- 读入 个单词 。
- 令 , 。
- 更新出度和入度:
- ,
- 。
- 在邻接表 中插入一条记录 ,记录出边指向的节点 以及边对应单词 。
2. 排序每个节点的出边
对每个 ,将 按照单词 的字典序从小到大排序。这样在后续寻路时,总能优先取最小的单词。
3. 检查欧拉路径存在条件
统计:
- = 节点个数中满足 的个数;
- = 节点个数中满足 的个数;
- = 节点个数中满足 的个数。
满足:
- 要么 (有向图欧拉“开放”路径);
- 要么 (有向图欧拉回路)。
否则输出 *** 并结束。
4. 确定起点
- 若存在节点 满足 ,那么必须从该 出发(欧拉开放路径)。
- 否则在所有有出边的节点里,取最小编号(对应最小字母)的一个作为起点(欧拉回路场景时任取一个有出边的节点即可,但要拿字母最小的,以保证后续路径字典序也最优)。
5. 连通性检查(忽略方向)
- 要求所有用到的字母节点(出度或入度 0)都在同一个连通分量里。
- 构造无向邻接表,并在任意一个有出/入边的节点上做 DFS/BFS,统计能到达的节点数目。
- 如果尚有其他节点也有出/入度但无法访问到,则说明图不连通,输出
***。
6. Hierholzer 算法找字典序最小欧拉路径
- 定义一个栈 ,存放当前行进中的节点;另一个栈 用来记录“使用了哪条边(单词)”。
- 初始时 。
- 当 不为空时:
- 令 。
- 如果 还有未使用的出边:
- 取 中字典序最小的一条 ,将其“弹出”或标记已使用;
- ;
- (表示我们走了这个单词的边)。
- 若 全部用完:
- ;
- 如果此时 非空,则将 加入结果序列 ,并 。
- 循环结束后, 中的单词顺序会是逆序(因为最后弹出的边是最先加入序列)。逆序一下得到真正的从头到尾的顺序。
- 如果最终得到的单词序列 恰好是 ,则成功,否则说明不连通或还有边没用掉,输出
***。
7. 输出结果
- 若 ,将其用
.连接起来输出; - 否则输出
***。
三、代码示例(C++)
#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;
}
四、复杂度与适用范围
- ,每个单词长度最多 20。
- 时间复杂度:
- 建图和排序每个节点的出边耗时 ;
- 连通性检查 ;
- Hierholzer 算法本身是 。
- 空间复杂度:。
本解法足够应付题目给出的上限。
小结
核心是将「每个单词」视为有向边,做出度、入度与连通性检查,确定起点,然后用字典序优先的欧拉路径算法来得到答案。
- 若路径不存在或者无法用完所有单词,就输出
***; - 否则将得到的欧拉路径按单词顺序输出并用
.连接。