P1127 词链 | C++

116 阅读1分钟

一、问题分析

形式化描述

给定 nn 个单词,每个单词可以看作边:

edge(w):(first(w))(last(w)).\text{edge}(w): \text{(first(w))} \to \text{(last(w))}.

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

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

欧拉路径存在条件

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

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

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

字典序最小的欧拉路径

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

二、算法思路与实现步骤

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

1. 读入与建图

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

2. 排序每个节点的出边

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

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

统计:

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

满足:

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

否则输出 *** 并结束。

4. 确定起点

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

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

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

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

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

7. 输出结果

  • euler.size()=n\text{euler.size()} = n,将其用 . 连接起来输出;
  • 否则输出 ***

三、代码示例(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;
}

四、复杂度与适用范围

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

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


小结

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

  • 若路径不存在或者无法用完所有单词,就输出 ***
  • 否则将得到的欧拉路径按单词顺序输出并用 . 连接。