P1127 词链 | C++

一、问题分析

形式化描述

给定 n n n 个单词,每个单词可以看作边:
edge ( w ) : (first(w)) → (last(w)) . \text{edge}(w): \text{(first(w))} \to \text{(last(w))}. edge(w):(first(w))→(last(w)).

其中 first ( w ) \text{first}(w) first(w) 为单词 w w w 的首字母对应的节点, last ( w ) \text{last}(w) last(w) 为其末字母对应的节点。

我们希望把这些边在图中依次走完且每条边恰好用一次,形成一个欧拉路径(或欧拉回路),并且在所有可能的欧拉路径中,按单词依次连接出的结果字符串字典序最小。

欧拉路径存在条件

在一个有向图中,存在欧拉路径(一次遍历所有边且只用一次)的必要且充分条件如下:

  1. 整个有向图中所有有边的顶点连通性: 若把有向图视作无向图后,所有包含边的顶点在同一个连通分量里(忽略孤立点)。
  2. 节点入度和出度的限制
    • 要么所有节点的入度 = = = 出度(这时存在欧拉回路,也是一种欧拉路径);
    • 要么正好有一个节点满足 outdegree = indegree + 1 \text{outdegree} = \text{indegree} + 1 outdegree=indegree+1(它将成为起点),一个节点满足 indegree = outdegree + 1 \text{indegree} = \text{outdegree} + 1 indegree=outdegree+1(它将成为终点),其余所有节点满足入度 = = = 出度。

若不满足上述条件,则欧拉路径不存在,也就不可能把所有单词排成一个满足题意的链,应输出 ***

字典序最小的欧拉路径

  1. 边排序:将每个顶点的出边按照单词的字典序从小到大排序。
  2. 字典序优先遍历:在 Hierholzer 算法求欧拉路径的过程中,始终按顶点剩余出边里字典序最小的边优先走,就能得到最终的欧拉路径在单词序列层面也是字典序最小的。
  3. 栈模拟:可以用一个栈来模拟具体实现,详见下文。

二、算法思路与实现步骤

以下假设英文字母只会是 'a' 到 'z',用 ID ( c ) = c − ′ a ′ \text{ID}(c) = c - 'a' ID(c)=c−′a′ 做节点编号(0 ~ 25)。

1. 读入与建图

  1. 读入 n n n 个单词 w w w。
  2. u = ID ( w 0 ) u = \text{ID}(w0) u=ID(w0), v = ID ( w len ( w ) − 1 ) v = \text{ID}(w\\text{len}(w) - 1) v=ID(wlen(w)−1)。
  3. 更新出度和入度:
    • outdeg u + + \text{outdeg}u++ outdegu++,
    • indeg v + + \text{indeg}v++ indegv++。
  4. 在邻接表 adj u \text{adj}u adju 中插入一条记录 ( w , v ) (w, v) (w,v),记录出边指向的节点 v v v 以及边对应单词 w w w。

2. 排序每个节点的出边

对每个 u ∈ 0..25 u \in 0..25 u∈0..25,将 adj u \text{adj}u adju 按照单词 w w w 的字典序从小到大排序。这样在后续寻路时,总能优先取最小的单词。

3. 检查欧拉路径存在条件

统计:

  • cntStart \text{cntStart} cntStart = 节点个数中满足 outdeg i = indeg i + 1 \text{outdeg}i = \text{indeg}i + 1 outdegi=indegi+1 的个数;
  • cntEnd \text{cntEnd} cntEnd = 节点个数中满足 indeg i = outdeg i + 1 \text{indeg}i = \text{outdeg}i + 1 indegi=outdegi+1 的个数;
  • cntOthers \text{cntOthers} cntOthers = 节点个数中满足 ∣ indeg i − outdeg i ∣ > 1 |\text{indeg}i - \text{outdeg}i| > 1 ∣indegi−outdegi∣>1 的个数。

满足:

  • 要么 cntStart = 1 ∧ cntEnd = 1 ∧ cntOthers = 0 \text{cntStart} = 1 \land \text{cntEnd} = 1 \land \text{cntOthers} = 0 cntStart=1∧cntEnd=1∧cntOthers=0(有向图欧拉"开放"路径);
  • 要么 cntStart = 0 ∧ cntEnd = 0 ∧ cntOthers = 0 \text{cntStart} = 0 \land \text{cntEnd} = 0 \land \text{cntOthers} = 0 cntStart=0∧cntEnd=0∧cntOthers=0(有向图欧拉回路)。

否则输出 *** 并结束。

4. 确定起点

  • 若存在节点 i i i 满足 outdeg i = indeg i + 1 \text{outdeg}i = \text{indeg}i + 1 outdegi=indegi+1,那么必须从该 i i i 出发(欧拉开放路径)。
  • 否则在所有有出边的节点里,取最小编号(对应最小字母)的一个作为起点(欧拉回路场景时任取一个有出边的节点即可,但要拿字母最小的,以保证后续路径字典序也最优)。

5. 连通性检查(忽略方向)

  • 要求所有用到的字母节点(出度或入度 > > > 0)都在同一个连通分量里。
  • 构造无向邻接表,并在任意一个有出/入边的节点上做 DFS/BFS,统计能到达的节点数目。
  • 如果尚有其他节点也有出/入度但无法访问到,则说明图不连通,输出 ***

6. Hierholzer 算法找字典序最小欧拉路径

  1. 定义一个栈 st \text{st} st,存放当前行进中的节点;另一个栈 edgeStack \text{edgeStack} edgeStack 用来记录"使用了哪条边(单词)"。
  2. 初始时 st.push(start) \text{st.push(start)} st.push(start)。
  3. st \text{st} st 不为空时:
    • v = st.top() v = \text{st.top()} v=st.top()。
    • 如果 adj v \text{adj}v adjv 还有未使用的出边:
      • adj v \text{adj}v adjv 中字典序最小的一条 ( word , nxt ) (\text{word}, \text{nxt}) (word,nxt),将其"弹出"或标记已使用;
      • st.push(nxt) \text{st.push(nxt)} st.push(nxt);
      • edgeStack.push(word) \text{edgeStack.push(word)} edgeStack.push(word)(表示我们走了这个单词的边)。
    • adj v \text{adj}v adjv 全部用完:
      • st.pop() \text{st.pop()} st.pop();
      • 如果此时 edgeStack \text{edgeStack} edgeStack 非空,则将 edgeStack.top() \text{edgeStack.top()} edgeStack.top() 加入结果序列 euler \text{euler} euler,并 edgeStack.pop() \text{edgeStack.pop()} edgeStack.pop()。
  4. 循环结束后, euler \text{euler} euler 中的单词顺序会是逆序(因为最后弹出的边是最先加入序列)。逆序一下得到真正的从头到尾的顺序。
  5. 如果最终得到的单词序列 euler.size() \text{euler.size()} euler.size() 恰好是 n n n,则成功,否则说明不连通或还有边没用掉,输出 ***

7. 输出结果

  • euler.size() = n \text{euler.size()} = n 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;
}

四、复杂度与适用范围

  • n ≤ 1000 n \leq 1000 n≤1000,每个单词长度最多 20。
  • 时间复杂度
    • 建图和排序每个节点的出边耗时 O ( n log ⁡ n ) O(n \log n) O(nlogn);
    • 连通性检查 O ( 26 + n ) O(26 + n) O(26+n);
    • Hierholzer 算法本身是 O ( n ) O(n) O(n)。
  • 空间复杂度 O ( n ) O(n) O(n)。

本解法足够应付题目给出的上限。


小结

核心是将「每个单词」视为有向边,做出度、入度与连通性检查,确定起点,然后用字典序优先的欧拉路径算法来得到答案。

  • 若路径不存在或者无法用完所有单词,就输出 ***
  • 否则将得到的欧拉路径按单词顺序输出并用 . 连接。
相关推荐
_清歌10 小时前
DSpark 深度解读:DeepSeek-V4 如何用「半自回归」把推理速度提升 85%
算法
统计实现局10 小时前
SVD 的三步走:双对角化、Givens 收敛、排序
算法
躬行见万象10 小时前
《VLA 系列》UniLab 强化训练 | G1 机器人 |复现
算法
统计实现局10 小时前
对称不定分解(Bunch-Kaufman):为什么 Cholesky 不够用
算法
统计实现局10 小时前
dqrsl 拆解:拿着 QR 结果能算出哪 5 种东西
算法
统计实现局10 小时前
为什么 Cholesky 求逆比 Gauss-Jordan 快一倍——行列式溢出防护详
算法
To_OC21 小时前
LC 994 腐烂的橘子:人人都说是 BFS 入门题,我却写了三遍才过
javascript·算法·leetcode
金銀銅鐵1 天前
[Python] 扩展欧几里得算法
python·数学·算法
To_OC1 天前
LC 200 岛屿数量:经典 DFS 入门题,我第一次写居然连方向都搞错了
javascript·算法·leetcode