AtCoder ABC 369 A-D题详解:从解题思路到代码实现的完整指南

AtCoder ABC 369 A-D题详解:从解题思路到代码实现的完整指南

1. 项目概述:从解题到授人以渔

最近在社区和群里看到不少朋友在讨论AtCoder Beginner Contest 369的题目,尤其是A到D这四道题。很多人提交后WA(错误答案)或者TLE(超时)了,却不太清楚问题出在哪里,只是对照着别人的代码改几个数字,下次遇到类似问题还是不会。这让我想起自己刚开始打比赛的时候,也是这么过来的。所以,我决定花点时间,把这次ABC 369的A、B、C、D四道题,从头到尾、掰开揉碎了讲一遍。

这次题解的目的,绝不仅仅是给你一个能AC(通过)的代码。我更想分享的是解题的思路:拿到一道题,第一步应该看什么、想什么;如何把题目描述转化成清晰的逻辑步骤;在编码时有哪些常见的“坑”需要避开;以及为什么C++和Python的写法会有这些细微的差别。我会为每道题提供C++和Python两种语言的实现,并详细解释代码每一部分的作用,特别是两种语言在处理输入输出、循环、条件判断时的不同习惯和性能考量。

无论你是刚接触算法竞赛的新手,还是想巩固基础的爱好者,希望这篇详尽的题解能帮你建立起一套属于自己的解题框架,而不仅仅是复制粘贴代码。毕竟,比赛的乐趣在于思考的过程,而不仅仅是绿色的“AC”标记。

2. 核心解题思路与思维框架拆解

在具体看题目之前,我们先统一一下面对AtCoder Beginner Contest题目的通用思考流程。这套流程能帮你快速理解题意,并形成清晰的实现路径,避免一开始就陷入代码细节的泥潭。

2.1 五步解题法:从读题到AC

我习惯将解题分为五个步骤,你可以把它当作一个检查清单:

第一步:精确理解题意与约束这是最重要也最容易出错的一步。你需要弄清楚:

  1. 输入格式:有几个数字?是整数还是浮点数?数字之间用什么分隔(空格还是换行)?题目给的样例输入一定要亲手照着打一遍,感受一下。
  2. 输出格式:要输出一个数字,还是多个?如果是多个,用空格还是换行分隔?末尾是否需要换行?(AtCoder通常不敏感,但养成好习惯很重要)。
  3. 问题本质:抛开所有修饰词,题目到底要我们计算什么?很多时候,题目描述了一个小故事,但核心可能就是一个简单的公式或规则。
  4. 数据范围:题目中的N, M等变量最大是多少?这个范围直接决定了你可以使用什么算法。比如N <= 10^3,你可能可以用O(N^2)的算法;如果N <= 10^5,通常就需要O(N log N)或O(N)的算法了。

第二步:设计算法与逻辑流程不要急着写代码!先用自然语言或伪代码描述出步骤。

  1. 模拟样例:用手算一遍题目给的样例,确保你的理解是正确的。如果样例都算不对,代码肯定不对。
  2. 抽象模型:将题目中的具体场景抽象成编程中的概念,比如数组、循环、条件判断。
  3. 考虑边界:数字为0、为1、为最大值、为负数(如果允许)时,你的逻辑是否还成立?
  4. 复杂度估算:根据第一步得到的数据范围,评估你设计的步骤需要多少计算量,会不会超时。

第三步:编写代码与实现细节选择你熟悉的语言,将第二步的流程翻译成代码。注意:

  1. 变量命名:使用有意义的变量名,如n,sum,count,避免全是a,b,c
  2. 输入输出:熟练掌握所用语言的快速输入输出方式(尤其是C++的cin/coutscanf/printf,Python的input().split())。
  3. 模块化:即使题目简单,也可以把不同的逻辑功能用函数或代码块分开,这样调试起来更清晰。

第四步:测试与调试代码写完不代表结束。

  1. 用样例测试:这是最基本的。确保输出和样例输出完全一致(包括空格和换行)。
  2. 构造边界测试:自己构造一些极端数据,比如最小值、最大值、特殊情况,看看程序表现如何。
  3. 在脑中单步执行:对于复杂的逻辑,可以在心里或纸上模拟代码运行,检查每一步变量的值是否符合预期。

第五步:提交与反思AC了当然好,如果WA或TLE了:

  1. 看错误类型:WA是答案错,TLE是超时,RE是运行时错误(如数组越界、除零)。
  2. 定位问题:WA可以尝试自己构造更多测试数据;TLE需要优化算法复杂度;RE需要检查数组大小和指针。
  3. 学习他人代码:AC之后,一定要去看一下排名靠前的选手的代码,学习他们更简洁、更高效的写法。

掌握了这个通用框架,我们再来具体看ABC 369的题目,你就会发现,再复杂的题目也是由这些基本步骤组合而成的。

3. A题 “T-shirt” 详细题解:逻辑判断与分支处理

3.1 题目重述与核心诉求

A题通常是热身题,考察基本的输入输出和条件判断。我们先把题目翻译成更直白的语言:

你手上有A件T恤。你的朋友给了你B件T恤。现在,你总共有多少件T恤?

输入:一行,两个整数AB,用空格隔开。输出:一个整数,表示T恤的总数。约束:0 <= A, B <= 100

注意:题目原文是日文,但核心就是加法。这里的关键是训练你“提取核心问题”的能力。很多题目会包装成生活场景,但内核极其简单。

3.2 解题思路与步骤拆解

这道题的思路简单到几乎不需要“设计”:

  1. 读取两个整数AB
  2. 计算A + B
  3. 输出计算结果。

为什么这么简单还要讲?因为这是建立信心的第一步,也是熟悉比赛环境(如何提交、如何看结果)的最佳机会。同时,这里隐藏着一个新手常见的“坑”:输入读取。你必须严格按照题目要求的格式来读。

3.3 C++ 实现与逐行解析

#include <iostream> using namespace std; int main() { int A, B; cin >> A >> B; // 步骤1:读取两个整数 cout << A + B << endl; // 步骤2&3:计算并输出,endl表示换行 return 0; }

代码解析与注意事项:

  • #include <iostream>using namespace std;是C++标准输入输出的标配,几乎每道题都会用。
  • cin >> A >> B;这行代码会从标准输入(键盘或评测系统)读取数据,按照空格或换行自动分割,并依次存入变量AB。这是最常用的读取方式。
  • cout << A + B << endl;输出计算结果。endl除了输出换行,还会强制刷新输出缓冲区。在算法竞赛中,有时为了效率,我们会在程序开头加上ios::sync_with_stdio(false); cin.tie(0);来加速cin/cout,但对于如此简单的题目,不加也无所谓。
  • return 0;表示程序正常结束。

一个潜在的“坑”:如果题目输入是“1 2”,cin能正确读取。但如果误写成“1,2”(带逗号),cin就会读取失败,A得到1,但B得不到值(通常为0)。所以务必确认输入格式

3.4 Python 实现与语言特性对比

# 方法1:使用 map 和 split,更Pythonic A, B = map(int, input().split()) print(A + B) # 方法2:分步读取,更清晰易懂 # data = input().split() # 读取一行,按空格分割成字符串列表,如 ['10', '20'] # A = int(data[0]) # 将第一个字符串转为整数 # B = int(data[1]) # 将第二个字符串转为整数 # print(A + B)

代码解析与注意事项:

  • input()函数读取一行输入(包括末尾的换行符,但会被自动处理掉),返回一个字符串。
  • .split()方法将这个字符串按空白字符(空格、制表符等)分割成一个字符串列表。例如,输入“10 20”,得到['10', '20']
  • map(int, ...)是一个高阶函数,它将int函数应用到列表的每一个元素上,将其转换成整数。map对象可以解包赋值给多个变量。
  • A, B = map(int, input().split())是一行非常经典的Python竞赛输入语句,简洁高效。
  • print(A + B)默认在输出末尾加换行,符合题目要求。

Python与C++的关键差异:

  1. 变量类型:Python是动态类型,不需要声明int A, B;,直接赋值即可。
  2. 输入处理:C++的cin直接解析到变量;Python需要先拿到字符串,再手动转换类型。
  3. 代码简洁性:对于简单输入,Python的一行流写法往往更短。

给新手的建议:如果你刚开始学,可以从“方法2”这种分步的写法开始,每一步在干什么非常清晰。熟练之后,再过渡到“方法1”的简洁写法。

4. B题 “Vertical Reading” 详细题解:字符串操作与循环控制

4.1 题目重述与核心诉求

B题开始引入字符串和循环操作。题目描述如下:

你有两个字符串ST。你需要按“垂直阅读”的方式输出它们。具体来说,你需要先输出S的第一个字符和T的第一个字符(中间用空格隔开),然后换行;接着输出S的第二个字符和T的第二个字符,再换行;以此类推,直到输出完较短字符串的最后一个字符。

输入:两行,第一行是字符串S,第二行是字符串T输出:多行,每行两个字符,中间有一个空格。约束:1 <= |S|, |T| <= 100 (|S|表示字符串S的长度)

核心诉求:遍历两个字符串,按照索引对齐输出字符对,遍历的长度以较短字符串为准。

4.2 解题思路与算法设计

  1. 确定循环次数:我们需要遍历的次数是min(len(S), len(T))。因为如果两个字符串长度不同,我们只输出它们“对齐”的部分。
  2. 遍历与访问:使用一个索引i,从0循环到最小长度-1。在每一轮循环中,我们访问S[i]T[i]
  3. 格式化输出:将两个字符用空格连接,然后输出。注意,在C++和Python中,访问字符串特定位置的字符语法是相似的。

思维难点:新手可能会想先处理完较短的字符串,再单独处理长字符串多出来的部分。但根据题意,只输出对齐部分即可,这简化了问题。一定要仔细读题,输出描述有时就是最好的提示。

4.3 C++ 实现:长度处理与字符访问

#include <iostream> #include <string> #include <algorithm> // 为了使用 min 函数 using namespace std; int main() { string S, T; getline(cin, S); // 读取整行,包括可能的空格 getline(cin, T); int len = min(S.size(), T.size()); // 步骤1:计算最小长度 for (int i = 0; i < len; ++i) { // 步骤2:循环遍历 cout << S[i] << ' ' << T[i] << endl; // 步骤3:格式化输出 } return 0; }

代码解析与深度探讨:

  • #include <string>:必须包含这个头文件才能使用string类型。
  • getline(cin, S):为什么不用cin >> S?因为cin >>遇到空格就会停止读取。如果字符串本身含有空格(本题虽然没说,但用getline是更安全的习惯),cin >>就会出错。getline会读取整行,直到换行符。
  • S.size()S.length():返回字符串的长度,类型是size_t(一个无符号整数)。我们把它和int一起用在min里是安全的,因为长度不超过100。
  • min(S.size(), T.size()):来自<algorithm>头文件的标准函数,返回两个值中的较小者。
  • for (int i = 0; i < len; ++i):经典的循环结构。++ii++在这里效果一样,但有些情况下++i性能略好,养成习惯也不错。
  • S[i]:通过下标运算符[]访问字符串中第i个字符(从0开始)。这是常数时间操作。

一个重要的边界情况:如果ST是空字符串(虽然本题约束长度至少为1),S.size()返回0,min得到0,循环不会执行,程序输出空(什么都不输出),这也是符合逻辑的。

4.4 Python 实现:zip函数的妙用

S = input().rstrip('\n') # 读取并去除末尾的换行符,input()默认会去掉,但显式处理是好习惯 T = input().rstrip('\n') # 方法1:使用range和min,类似C++思路 length = min(len(S), len(T)) for i in range(length): print(S[i], T[i]) # print函数默认用空格分隔多个参数,输出后自动换行 # 方法2:使用zip函数,更Pythonic # for char_s, char_t in zip(S, T): # print(char_s, char_t)

代码解析与语言特性:

  • input()默认会去掉末尾的换行符,所以S = input()通常就够了。这里加上.rstrip('\n')是为了代码意图更明确。
  • len(S):Python内置函数,获取字符串(或任何可迭代对象)的长度。
  • 方法1range(length)生成一个从0到length-1的整数序列。S[i]T[i]通过索引访问字符。这种写法和C++逻辑完全一致,易于理解。
  • 方法2(推荐):使用zip(S, T)。这是Python中一个极其强大的内置函数。它接收两个(或多个)可迭代对象(这里是字符串),返回一个迭代器。这个迭代器每次产生一个元组,包含来自每个输入对象相同位置的一个元素。
    • 例如,S = "abc",T = "123"zip(S, T)会产生:('a', '1'),('b', '2'),('c', '3')
    • for char_s, char_t in zip(S, T):会直接将这些元组解包到变量char_schar_t中,代码非常简洁优雅。
    • 最关键的是zip会自动以较短的那个输入为准停止迭代。这正是我们需要的功能!所以我们甚至不需要手动计算min(len(S), len(T))

Pythonic思维zip函数是“Pythonic”代码的典型代表。它避免了显式的索引操作,让代码的意图(“将两个序列配对”)更加清晰,同时减少了出错的可能(比如索引写错)。在算法竞赛中,多熟悉这类内置函数能大幅提升编码速度和代码可读性。

5. C题 “Max Min” 详细题解:数组处理与极值查找

5.1 题目重述与问题抽象

C题的难度通常会上一个台阶,涉及到数组(列表)的处理和简单的算法思想。题目描述如下:

给定一个长度为N的整数序列A。请你找出这个序列中最大值最小值

输入

  • 第一行一个整数N
  • 第二行包含N个整数A1, A2, ..., AN,用空格隔开。输出:一个整数,即序列中最大值与最小值的差(最大值 - 最小值)。约束:2 <= N <= 10^5, -10^9 <= Ai <= 10^9

问题抽象:遍历一个数组,记录遇到的最大值和最小值,最后计算它们的差值。

5.2 解题思路与算法分析

这是一个经典的“极值查找”问题。最直观的解法:

  1. 初始化两个变量max_valmin_val。初始化值非常关键!
  2. 遍历数组A中的每一个数字x
  3. 如果x > max_val,则更新max_val = x
  4. 如果x < min_val,则更新min_val = x
  5. 遍历结束后,输出max_val - min_val

算法复杂度:我们只需要遍历数组一次,每次操作是常数时间(比较和赋值),所以时间复杂度是O(N),对于 N 最大为 10^5 来说绰绰有余。空间复杂度是O(1),只用了几个变量。

思维难点与陷阱

  • 初始化问题max_valmin_val不能初始化为0!因为数组里可能全是负数(那么最大值初始化为0就错了),或者全是正数(那么最小值初始化为0也错了)。正确的做法是初始化为数组的第一个元素A[0],或者初始化为一个理论上的极限值(如max_val = -10^18,min_val = 10^18)。
  • 整数溢出问题:题目中 Ai 的范围是 -10^9 到 10^9,它们的差最大可能是 2 * 10^9,这在C++的int(通常32位,范围约-21亿到21亿)和Python的int(无限精度)中都是安全的。但养成考虑数据范围的习惯很重要,如果差可能超过21亿,在C++中就需要使用long long类型。

5.3 C++ 实现:初始化陷阱与循环优化

#include <iostream> #include <vector> #include <climits> // 为了使用 INT_MIN 和 INT_MAX using namespace std; int main() { int N; cin >> N; vector<long long> A(N); // 使用long long避免后续计算溢出 for (int i = 0; i < N; ++i) { cin >> A[i]; } // 方法1:初始化为第一个元素(最安全通用的方法) long long max_val = A[0]; long long min_val = A[0]; for (int i = 1; i < N; ++i) { // 注意循环从1开始,因为0已经用过了 if (A[i] > max_val) { max_val = A[i]; } if (A[i] < min_val) { min_val = A[i]; } } // 方法2:初始化为极限值(需要知道范围) // long long max_val = -1e18; // 一个比所有可能Ai都小的数 // long long min_val = 1e18; // 一个比所有可能Ai都大的数 // for (int i = 0; i < N; ++i) { // 循环可以从0开始 // if (A[i] > max_val) max_val = A[i]; // if (A[i] < min_val) min_val = A[i]; // } cout << max_val - min_val << endl; return 0; }

代码解析与最佳实践:

  • #include <vector>:使用vector动态数组来存储序列,比原生数组更安全方便。
  • vector<long long> A(N);:声明一个大小为Nvector,元素类型为long long。虽然本题int足够,但使用long long是竞赛中防止整数溢出的好习惯,尤其是涉及乘法或累加时。
  • 初始化方法1(推荐)max_val = min_val = A[0];然后从i=1开始循环。这是最稳妥的方法,适用于任何情况,因为你用了一个实际存在的元素作为初始值。
  • 初始化方法2:初始化为一个理论上的“极小值”和“极大值”。这里用-1e181e18,因为题目数据范围是 ±1e9,这两个值足够作为边界。C++标准库中的<climits>定义了INT_MININT_MAX,但那是int型的极限,对于long long可以用LLONG_MINLLONG_MAX(来自<climits>)。
  • 循环中的比较:注意是两个独立的if语句,而不是if...else if。因为同一个数字有可能同时更新最大值和最小值吗?不可能,一个数不可能既大于最大值又小于最小值。但写成两个独立的if逻辑更清晰。有些优化会写成if (A[i] > max_val) max_val = A[i]; else if (A[i] < min_val) min_val = A[i];,理论上稍微快一点点,但可读性稍差。

性能考量:对于 10^5 的数据量,一次线性扫描完全不是问题。如果数据量达到 10^7 或更高,就需要考虑循环内的操作是否足够高效,以及是否可以使用更快的输入输出(如scanf/printf或关闭cin/cout同步)。

5.4 Python 实现:内置函数的威力与手动遍历

N = int(input()) A = list(map(int, input().split())) # 步骤1:读取并转换为整数列表 # 方法1:使用Python内置函数(最简单,效率高) max_val = max(A) min_val = min(A) print(max_val - min_val) # 方法2:手动遍历(理解原理,适用于更复杂的自定义比较) # max_val = A[0] # min_val = A[0] # for x in A[1:]: # 使用切片从第二个元素开始迭代 # if x > max_val: # max_val = x # if x < min_val: # min_val = x # print(max_val - min_val) # 方法3:一行流(炫技,可读性稍差) # print(max(A) - min(A))

代码解析与语言对比:

  • A = list(map(int, input().split())):这行代码是Python竞赛读入数字列表的“标准范式”。input().split()得到字符串列表,map(int, ...)将其映射为整数迭代器,list(...)再将其转换为列表。
  • 方法1(推荐):直接使用内置函数max()min()。这是最Pythonic、最高效(底层是C实现)且最不易出错的方法。代码意图一目了然:“求列表的最大值和最小值”。在绝大多数情况下,这应该是首选。
  • 方法2:手动遍历。逻辑和C++版本完全一致。这里演示了Python的for x in A[1:]:写法,A[1:]是一个列表切片,创建了一个从索引1到末尾的新列表(浅拷贝)。你也可以用for i in range(1, N):然后访问A[i]
  • 方法3:极简的一行流。直接将表达式作为print的参数。这在简单题目中很酷,但稍微复杂一点就会影响可读性。

关于性能的讨论max(A)min(A)各自需要遍历一次列表,所以总共是O(2N)的时间复杂度,常数因子为2。手动遍历一次同时找最大最小值是O(N)。对于 N=10^5,这两种方法在实际运行时间上几乎没有区别,因为Python内置函数是C实现的,速度极快。在竞赛中,优先选择代码简洁、不易出错的方式。只有当数据量极大(例如10^7以上),且这成为性能瓶颈时,才需要考虑手动合并遍历。

一个重要的心得:很多新手会纠结于“哪种方法更快”,但在算法竞赛中,时间复杂度的大O级别才是关键。O(N)和O(2N)属于同一量级。代码的清晰度、正确性和可维护性往往比微小的常数优化更重要,尤其是在时间紧张的比赛里。

6. D题 “Path Graph?” 详细题解:图论基础与度序列判断

6.1 题目重述与图论建模

D题通常涉及一个简单的算法或数据结构概念。这道题是关于图论中最基础的概念之一——路径图。

题目描述如下: 给定一个包含N个顶点(编号1到N)和M条边的无向图。判断这个图是否是一个“路径图”。路径图的定义:一个图,其顶点可以排成一条线v1, v2, ..., vN,使得对于所有的i(1 <= i < N),顶点viv(i+1)之间有一条边,并且没有其他边。简单说,就是一条“线”,没有分叉,没有环。

输入

  • 第一行两个整数NM
  • 接下来M行,每行两个整数uivi,表示一条连接顶点uivi的无向边。保证没有重边(同一对顶点之间没有多条边)和自环(ui != vi)。输出:如果是路径图,输出"Yes",否则输出"No"约束:2 <= N <= 210^5, 0 <= M <= 210^5

问题抽象:给你一个图,判断它是否恰好是一条“链”。这需要我们从图的性质入手。

6.2 解题思路与图性质分析

如何判断一个图是不是一条简单的路径呢?我们可以从路径图的性质反推:

  1. 边数必须恰好为 N-1。一条N个顶点的路径,恰好有N-1条边连接它们。如果 M != N-1,直接可以判断不是路径图。
  2. 所有顶点的度数(连接的边数)必须符合特定模式。在一条路径中:
    • 两个端点(链的两头)的度数为1。
    • 中间的所有顶点的度数都为2。
    • 不可能出现度数大于2的顶点(那意味着分叉),也不可能出现度数为0的顶点(除非N=1,但题目N>=2),那意味着这个点是孤立的。
  3. 连通性:满足以上两点的图,还必须保证是连通的(所有顶点通过边连接在一起)。试想,如果有两个满足度数要求的链,它们之间没有连接,整体边数也可能是N-1,但它不是一条完整的路径。

所以,完整的判断逻辑是:

  • 如果 M != N-1,输出"No"
  • 否则,统计每个顶点的度数。
  • 检查是否恰好有两个顶点的度数为1,其余 N-2 个顶点的度数都为2。
  • 仅凭度数是否足够?考虑一个“哑铃”形状:两个三角形由一条边连接。这个图有4个顶点,边数=3+1+3? 不对,我们重新构思:一个更简单的反例是,一个环(所有顶点度数为2)加上一个孤立的边(两个顶点度数为1),总顶点数N,边数M可能凑巧等于N-1吗?我们来严格推导一下。
    • 假设图由两部分组成:一个环有k个顶点(k>=3),每个点度数为2;一条路径有m个顶点(m>=2),两个端点度数为1,中间点度数为2。总顶点数 N = k + m,总边数 M = k + (m-1)。要满足 M = N-1,即 k + m -1 = k + m -1,恒成立!所以确实存在非连通图满足度数列要求(两个度数为1,其余为2)且边数=N-1。例如:一个三角形(3个点,3条边,每个点度数2)加上一条单独的边(2个点,1条边,两个点度数1)。总N=5,M=4,度数列为:两个1,三个2。但它不是一条完整的路径。
  • 因此,必须额外检查连通性。或者,在检查度数的同时,我们可以用更聪明的方法:对于一个有N个顶点、N-1条边、且所有顶点度数不超过2的连通图,它必然是一棵树,并且由于最大度数为2,这棵树只能是一条链。所以,我们可以在统计度数的过程中,如果发现任何顶点度数>2,直接判否。然后,再使用并查集(Disjoint Set Union, DSU)或深度优先搜索(DFS)检查图的连通性。

最终算法步骤

  1. 读入N, M。如果 M != N-1,输出"No",结束。
  2. 初始化一个大小为 N+1 的数组deg(度数表),所有元素为0。
  3. 读入每条边(u, v)deg[u]++,deg[v]++
  4. 遍历deg[1]deg[N]
    • 如果存在任何deg[i] > 2,输出"No",结束。(因为路径中点的最大度数是2)
  5. 检查连通性。使用并查集(DSU):
    • 初始化并查集,每个顶点是自己的父节点。
    • 对于每条边(u, v),合并uv所在的集合。
    • 最后检查是否所有顶点都在同一个集合中。如果是,则图连通。
  6. 如果通过了度数检查和连通性检查,输出"Yes",否则输出"No"

复杂度分析:读入和度数统计是 O(N+M)。并查集操作近似 O(α(N)),其中α是反阿克曼函数,效率极高。总复杂度 O(N+M),对于 2*10^5 的数据规模完全可行。

6.3 C++ 实现:并查集检查连通性

#include <iostream> #include <vector> using namespace std; // 并查集类 class DSU { private: vector<int> parent; public: DSU(int n) : parent(n + 1) { // 顶点编号从1开始,所以大小设为n+1 for (int i = 1; i <= n; ++i) { parent[i] = i; // 初始时,每个节点的父节点是自己 } } // 查找根节点,带路径压缩 int find(int x) { if (parent[x] != x) { parent[x] = find(parent[x]); // 递归压缩路径 } return parent[x]; } // 合并两个节点所在的集合 void unite(int x, int y) { int rootX = find(x); int rootY = find(y); if (rootX != rootY) { parent[rootY] = rootX; // 将rootY的父节点设为rootX } } // 判断所有节点是否连通(是否属于同一个集合) bool isConnected(int n) { int root = find(1); for (int i = 2; i <= n; ++i) { if (find(i) != root) { return false; } } return true; } }; int main() { int N, M; cin >> N >> M; // 条件1:边数必须为 N-1 if (M != N - 1) { cout << "No" << endl; return 0; // 直接结束程序 } vector<int> deg(N + 1, 0); // 度数数组,索引从1到N DSU dsu(N); // 初始化并查集 for (int i = 0; i < M; ++i) { int u, v; cin >> u >> v; deg[u]++; deg[v]++; // 如果发现某个点的度数已经超过2,可以提前结束(可选优化) if (deg[u] > 2 || deg[v] > 2) { cout << "No" << endl; return 0; } dsu.unite(u, v); // 合并边连接的两个顶点 } // 条件2:检查度数是否符合路径图要求(两个1,其余为2) int cnt1 = 0, cnt2 = 0; for (int i = 1; i <= N; ++i) { if (deg[i] == 1) cnt1++; else if (deg[i] == 2) cnt2++; else { // 度数既不是1也不是2(可能是0,或者前面没提前判断的>2) cout << "No" << endl; return 0; } } if (!(cnt1 == 2 && cnt2 == N - 2)) { cout << "No" << endl; return 0; } // 条件3:检查连通性 if (dsu.isConnected(N)) { cout << "Yes" << endl; } else { cout << "No" << endl; } return 0; }

代码解析与实现细节:

  • 并查集(DSU)实现:这是解决连通性问题的利器。核心是find(查找根节点,带路径压缩)和unite(合并集合)。isConnected函数检查是否所有顶点都在同一个集合中,只需检查任意一个顶点(这里用1号顶点)的根是否与其他所有顶点的根相同。
  • 度数统计与提前判断:在读边时,我们同时增加顶点uv的度数。如果发现某个顶点的度数瞬间超过2,我们可以立即判定不是路径图并退出,这是一个有效的剪枝优化。
  • 度数最终检查:遍历所有顶点,统计度数为1和2的顶点个数。必须满足:cnt1 == 2cnt2 == N-2。注意,如果图是连通的且边数为N-1,那么不可能出现度数为0的顶点(除非N=1)。但我们的检查else分支已经处理了度数不为1或2的情况。
  • 逻辑顺序:先判断边数,再结合读边处理度数和连通性,最后进行度数合规性检查和连通性检查。这样的流程清晰且高效。

一个易错点:并查集的parent数组大小要设为N+1,因为顶点编号从1开始。如果设为N,访问parent[N]是合法的,但我们的循环是从1到N,这会导致访问越界(parent[N]对应索引N,而大小为N的数组最后一个索引是N-1)。

6.4 Python 实现:简洁的集合操作与逻辑整合

import sys sys.setrecursionlimit(300000) # 设置递归深度,防止DFS递归过深 def main(): N, M = map(int, sys.stdin.readline().split()) # 条件1:边数检查 if M != N - 1: print("No") return deg = [0] * (N + 1) # 使用邻接表存储图,用于DFS检查连通性(也可以用并查集) graph = [[] for _ in range(N + 1)] for _ in range(M): u, v = map(int, sys.stdin.readline().split()) deg[u] += 1 deg[v] += 1 graph[u].append(v) graph[v].append(u) # 提前判断度数大于2的情况 if deg[u] > 2 or deg[v] > 2: print("No") return # 条件2:度数最终检查 cnt1 = sum(1 for d in deg[1:] if d == 1) cnt2 = sum(1 for d in deg[1:] if d == 2) if not (cnt1 == 2 and cnt2 == N - 2): print("No") return # 条件3:使用DFS检查连通性 visited = [False] * (N + 1) stack = [1] # 从任意顶点开始,这里从1开始 visited[1] = True count = 1 # 记录访问到的顶点数 while stack: node = stack.pop() for neighbor in graph[node]: if not visited[neighbor]: visited[neighbor] = True stack.append(neighbor) count += 1 # 如果DFS访问到的顶点数等于总顶点数N,则图连通 if count == N: print("Yes") else: print("No") if __name__ == "__main__": main()

代码解析与Python特色:

  • sys.setrecursionlimit(300000):Python的默认递归深度有限(约1000)。如果使用递归DFS,对于N=2*10^5的链状图,递归深度可能达到N,会导致递归溢出。这里设置一个更大的限制。我们实际使用了栈迭代实现DFS,所以这行不是必须的,但是一个好习惯。
  • sys.stdin.readline():比input()稍快,在处理大量输入时推荐使用。
  • 使用邻接表graph = [[] for _ in range(N + 1)]创建了一个列表的列表,graph[i]存储与顶点i相邻的所有顶点。这是存储稀疏图的常用方式。
  • 度数统计的Pythonic写法cnt1 = sum(1 for d in deg[1:] if d == 1)使用了生成器表达式,遍历deg列表(从索引1开始),每当d == 1时,就生成一个1,然后用sum求和。这比写循环更简洁。
  • DFS检查连通性(迭代栈版本)
    • visited列表记录顶点是否被访问过。
    • stack模拟递归栈。从顶点1开始,将其放入栈中并标记已访问。
    • 当栈不为空时,弹出栈顶节点,遍历其所有邻居。如果邻居未被访问,则标记并压入栈中,同时计数count加1。
    • 最终,如果count == N,说明从顶点1出发能访问到所有顶点,图是连通的。
    • 使用栈迭代而非递归,避免了递归深度限制的问题,是竞赛中的常用技巧。
  • 逻辑整合:Python代码将度数提前判断和最终检查分开,逻辑清晰。DFS连通性检查放在最后。

对比C++与Python实现

  • C++版本使用了并查集,Python版本使用了DFS。两者都可以,时间复杂度都是O(N+M)。并查集在理论上平均效率略高,但DFS写起来更直观,对于路径图这种稀疏结构,两者性能差异很小。
  • Python的代码更简短,得益于其高级语法(如列表推导、动态数组)。但C++版本在绝对速度上仍有优势,尤其是在输入量极大时。

本题总结:D题考察了对简单图论概念(度数、连通性)的理解和基本实现能力。关键点在于不止检查度数,还必须检查连通性,这是很多新手容易遗漏的地方,也是本题主要的考察点。通过这道题,你应该掌握判断一个图是否为树或链的基本方法。