P1127 词链 | C++

一、问题分析

形式化描述

给定 <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) 为其末字母对应的节点。

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

欧拉路径存在条件

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

  1. 整个有向图中所有有边的顶点连通性: 若把有向图视作无向图后,所有包含边的顶点在同一个连通分量里(忽略孤立点)。
  2. 节点入度和出度的限制
    • 要么所有节点的入度 <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>= 出度。

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

字典序最小的欧拉路径

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

二、算法思路与实现步骤

以下假设英文字母只会是 '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. 读入与建图

  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。
  2. 令 <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])。
  3. 更新出度和入度:
    • <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]++。
  4. 在邻接表 <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 算法找字典序最小欧拉路径

  1. 定义一个栈 <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 用来记录"使用了哪条边(单词)"。
  2. 初始时 <math xmlns="http://www.w3.org/1998/Math/MathML"> st.push(start) \text{st.push(start)} </math>st.push(start)。
  3. 当 <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()。
  4. 循环结束后, <math xmlns="http://www.w3.org/1998/Math/MathML"> euler \text{euler} </math>euler 中的单词顺序会是逆序(因为最后弹出的边是最先加入序列)。逆序一下得到真正的从头到尾的顺序。
  5. 如果最终得到的单词序列 <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)。

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


小结

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

  • 若路径不存在或者无法用完所有单词,就输出 ***
  • 否则将得到的欧拉路径按单词顺序输出并用 . 连接。
相关推荐
汉克老师1 小时前
GESP2024年3月认证C++六级( 第三部分编程题(1)游戏)
c++·学习·算法·游戏·动态规划·gesp6级
闻缺陷则喜何志丹1 小时前
【C++图论】2685. 统计完全连通分量的数量|1769
c++·算法·力扣·图论·数量·完全·连通分量
利刃大大1 小时前
【二叉树深搜】二叉搜索树中第K小的元素 && 二叉树的所有路径
c++·算法·二叉树·深度优先·dfs
CaptainDrake1 小时前
力扣 Hot 100 题解 (js版)更新ing
javascript·算法·leetcode
一缕叶2 小时前
洛谷P9420 [蓝桥杯 2023 国 B] 子 2023 / 双子数
算法·蓝桥杯
甜甜向上呀2 小时前
【数据结构】空间复杂度
数据结构·算法
Great Bruce Young2 小时前
GPS信号生成:C/A码序列生成【MATLAB实现】
算法·matlab·自动驾驶·信息与通信·信号处理
Mryan20052 小时前
LeetCode | 不同路径
数据结构·c++·算法·leetcode
qy发大财3 小时前
验证二叉搜索树(力扣98)
数据结构·算法·leetcode·职场和发展
人类群星闪耀时3 小时前
用深度学习优化供应链管理:让算法成为商业决策的引擎
人工智能·深度学习·算法