无序字母对

OJ: luogu

题目 ID: P1341

标签: 欧拉路

日期: 2025-12-19 08:07

这道题是 欧拉路 (Euler Path) 的经典应用。

简而言之:把字母看作“点”,把给定的字母对看作“边”。题目就是要求一笔画走完所有边。

核心解题逻辑

1 建模 (Mapping)

  • 节点:ASCII 字符(‘A’-‘Z’, ‘a’-‘z’)。
  • :输入的两个字母之间连一条无向边。
  • 目标:找一条路径,经过每条边恰好一次(欧拉路/欧拉回路)。

2 判别条件 (Existence)

图必须连通,且满足度数要求:

  • 欧拉回路(起点回到起点):所有点的度数都是偶数。
  • 欧拉路(起点终点不同):只有 2个 点的度数是奇数,其余全为偶数。
  • 无解:奇数度点的个数不是 0 也不是 2,或者图不连通。

3 字典序最小 (Lexicographical Order)

题目要求字典序最小,包含两层含义:

1 起点选择

  • 如果有奇点,必须从ASCII码较小的那个奇点出发。
  • 如果是回路(全偶点),从ASCII码最小的有边节点出发。

2 路径选择

  • 在 DFS 过程中,优先走 ASCII 码小的邻居(使用邻接矩阵枚举即可自然满足)。

4 算法流程 (Hierholzer’s Algorithm 变体)

  • DFS 遍历:每走过一条边,就删除这条边(防止走回头路)。

  • 入栈时机回溯时记录节点(当一个点没有路可走时,将其加入栈/结果集)。

  • 为什么? 因为DFS会先钻到底,最后回溯的顺序才是正确的拼接顺序(逆序)。

  • 输出:将记录的节点倒序输出。


代码实现

cpp
#include <iostream>
#include <string>
#include <stack>
#include <algorithm>

using namespace std;

// 邻接矩阵存图,G[u][v]=1 表示有边,自动满足字典序遍历
int G[150][150]; 
int du[150]; // 记录度数
stack<char> st; // 存路径
int n; // 边数

// 核心 DFS:Hierholzer 算法
void dfs(int u) {
    // 按 ASCII 顺序从小到大遍历邻居
    for (int v = 0; v < 150; v++) {
        if (G[u][v]) {
            G[u][v]--; // 删边 (无向图删两边)
            G[v][u]--;
            dfs(v); // 递归
        }
    }
    st.push(u); // 【关键】无路可走时入栈
}

int main() {
    cin >> n;
    for (int i = 0; i < n; i++) {
        string s; cin >> s;
        G[s[0]][s[1]] = G[s[1]][s[0]] = 1; // 建边
        du[s[0]]++; du[s[1]]++; // 统计度数
    }

    int start_node = 0;
    int odd_count = 0;
    
    // 1. 找起点 & 检查度数
    for (int i = 0; i < 150; i++) {
        if (du[i] % 2 != 0) odd_count++;
    }

    if (odd_count != 0 && odd_count != 2) {
        cout << "No Solution";
        return 0;
    }

    // 确定起点:
    // 如果有奇点,找最小的奇点;
    // 如果全偶,找最小的有度数的点。
    for (int i = 0; i < 150; i++) {
        if (du[i]) { // 这是一个存在的点
            if (odd_count == 2) {
                if (du[i] % 2 != 0) {
                    start_node = i;
                    break;
                }
            } else { // odd_count == 0
                start_node = i;
                break;
            }
        }
    }
    // std::cout << odd_count << "\n";
    // std::cout << "start_node  "<< "\n";
    // std::cout << (char)start_node << "\n";

    // 2. 开始搜索
    dfs(start_node);

    // 3. 检查连通性 (防止图不连通的情况)
    // 欧拉路径的点数应该是 边数 + 1
    if (st.size() != n + 1) {
        cout << "No Solution";
        return 0;
    }

    // 4. 输出
    while (!st.empty()) {
        cout << (char)st.top();
        st.pop();
    }

    return 0;
}

但是这个代码M L E了

问题出在 dfs 函数中删除边的逻辑上。

题目中提到“无序字母对”,并没有明确禁止 自环(即两个字母相同,如 AA)。虽然题目说“各不相同的无序字母对”,但通常测试数据中可能会包含 AA 这样的合法输入(或者即使没有,代码逻辑也应严谨)。

看看上面的代码:

cpp
if (G[u][v]) {
    G[u][v]--; // 第一步:减 1
    G[v][u]--; // 第二步:再减 1
    dfs(v); 
}

假设输入中有一个自环 X X

  1. 建图时:G[‘X’][‘X’] = 1。
  2. 进入 dfs(‘X’),遍历到 v = ‘X’。
  3. G[‘X’][‘X’] 是 1,进入 if。
  4. G[‘X’][‘X’]–,此时值变为 0。
  5. G[‘v’][‘u’]-- (因为 u=vu=v,还是操作同一个位置),此时值变为 -1。
  6. 递归调用 dfs(‘X’)。在下一层递归中,检查 if (G[‘X’][‘X’])。
  7. 在 C++ 中,非 0 值(包括 -1)都为真。
  8. 条件成立,继续递归,值变为 -2, -3… 导致无限递归,最终爆栈。
cpp
#include <iostream>
#include <string>
#include <stack>
#include <algorithm>
#include <cmath>

using namespace std;

int G[150][150]; 
int du[150]; 
stack<char> st; 
int n; 

void dfs(int u) {
    for (int v = 0; v < 150; v++) {
        // 这里必须判断 G[u][v] > 0,防止逻辑错误访问到负数(虽然改了删边逻辑后一般不会)
        if (G[u][v] > 0) {
            G[u][v]--; 
            // 【修复点】如果是自环(u==v),只减一次;否则两边都减
            if (u != v) G[v][u]--; 
            
            dfs(v); 
        }
    }
    st.push(u); 
}

int main() {
    // 优化输入输出效率
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n;
    for (int i = 0; i < n; i++) {
        string s; cin >> s;
        int u = s[0];
        int v = s[1];
        // 建议使用 ++ 支持多重图(尽管本题说各不相同,这样写更稳健)
        G[u][v]++; 
        if (u != v) G[v][u]++; 
        
        du[u]++; 
        du[v]++; 
    }

    int start_node = 0;
    int odd_count = 0;
    
    // 寻找奇点
    for (int i = 0; i < 150; i++) {
        if (du[i] % 2 != 0) odd_count++;
    }

    // 欧拉路径存在条件:奇点数为 0 或 2
    if (odd_count != 0 && odd_count != 2) {
        cout << "No Solution";
        return 0;
    }

    // 确定起点
    // 策略:
    // 1. 如果有奇点,起点必须是奇点中 ASCII 较小的那个。
    // 2. 如果全是偶点,起点是所有有度数的点中 ASCII 最小的。
    for (int i = 0; i < 150; i++) {
        if (du[i] > 0) { // 必须是图中的点
            if (odd_count == 2) {
                if (du[i] % 2 != 0) {
                    start_node = i;
                    break; // 找到第一个奇点就是最小的(因为从小到大遍历)
                }
            } else { // odd_count == 0
                start_node = i;
                break; // 找到第一个有点就是最小的
            }
        }
    }

    dfs(start_node);

    // 检查是否走完了所有的边 (路径点数 = 边数 + 1)
    if (st.size() != n + 1) {
        cout << "No Solution";
        return 0;
    }

    while (!st.empty()) {
        cout << (char)st.top();
        st.pop();
    }

    return 0;
}

记忆要点

1 奇点数:必须是 0 或 2。 2 删边:DFS 走过就拆桥。 3 回溯入栈:走到死胡同再记录,最后倒序输出。 4 贪心:邻居从小到大找。