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)。

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


小结

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

  • 若路径不存在或者无法用完所有单词,就输出 ***
  • 否则将得到的欧拉路径按单词顺序输出并用 . 连接。
相关推荐
江畔柳前堤8 小时前
大语言模型分布式训练:从并行策略到万卡工程的系统梳理
人工智能·分布式·深度学习·算法·目标检测·机器学习·语言模型
Forever Nore9 小时前
LeetCode 4 寻找两个正序数组的中位数 - 二分
算法·leetcode
罗西的思考11 小时前
【OpenClaw具身硬件】MiniClaw 阅读笔记---(1)基础
人工智能·算法·机器学习
蛋先生DX12 小时前
大模型参数存储格式揭秘:BF不是男朋友
深度学习·算法·llm
爱跳舞的烤冷面12 小时前
自学嵌入式第22天(数据结构——哈希)
数据结构·算法·哈希算法
猎嘤一号12 小时前
博弈论(Game Theory)的理论、算法与工程
人工智能·算法·安全·博弈论
月光船幽幽13 小时前
影子模式下保护 logits 不被修改
人工智能·python·算法
马拉AI13 小时前
腾讯开源 Agent 记忆系统,AI“换对话就忘”的问题有了新解法(附安装使用教程)
人工智能·算法·开源·科研
地平线开发者14 小时前
征程6工具链模型X86推理方式说明
算法
地平线开发者14 小时前
【征程6】校准量化中HistogramObserver解析
算法