从CSP-J真题“小熊的果篮”解析链表与队列在动态序列维护中的应用
1. 项目概述:从一道真题看信息学竞赛的实战思维
最近在整理CSP-J(原NOIP普及组)的历年真题,2021年的T4“小熊的果篮”这道题给我留下了挺深的印象。它不像一些纯考算法的“硬骨头”题,而是更侧重于考察选手对问题本质的抽象能力、数据结构的灵活运用,以及编写稳定、高效代码的工程化思维。很多刚接触竞赛的同学,一看到题目描述里“果篮”、“水果”、“拿出”这些生活化的词汇,可能会觉得这是一道模拟题,想着直接按照题意一步步去“拿”水果就行了。但如果你真这么做了,在竞赛有限的时间和内存限制下,大概率会“时间超限”或者“内存超限”。这道题的精髓,恰恰在于如何跳出“模拟”的思维定式,用一种更聪明、更高效的方式来处理这个看似简单的过程。
简单来说,题目是这样的:有一个果篮,里面放着一排水果,每个水果可能是苹果(用0表示)或橘子(用1表示)。小熊会重复进行以下操作:从当前果篮中,将所有“块”最左边的水果同时拿出来。这里的“块”指的是连续的同一种水果。例如,果篮状态是[0, 0, 1, 1, 0, 1],那么就有三个块:第一个是连续的两个0,第二个是连续的两个1,第三个是一个0,第四个是一个1。第一次操作,拿出每个块最左边的水果,即第一个0、第一个1和第三个0(注意,此时第三个0自成一个块),拿出后序列变为[0, 1, 1]。第二次操作,此时序列形成两个块:一个0和两个1,拿出每个块最左边的水果(0和第一个1),序列变为[1]。第三次操作,拿出最后一个1,果篮变空。
你的任务就是模拟这个过程,并输出每次拿出的水果编号(按拿出顺序)。题目会给出初始的水果序列,长度N最大可达2×10^5。如果你试图在每一轮中,都去扫描整个序列,找出每个块的起始位置然后删除,那么对于近乎满数据的序列,操作轮数可能接近N轮,每轮扫描O(N),整体复杂度就是O(N^2),这对于20万的数据量来说是绝对无法接受的。因此,这道题是一个典型的需要我们优化“模拟过程”的题目,核心思路是使用合适的数据结构来维护“块”的信息,并高效地找到每一轮需要删除的元素。
这就像让你管理一个排着长队的队伍,队伍里相邻的同班同学会自然地站在一起(形成一个块)。你需要一次次地叫走每个“小团体”最前面的一个人。最笨的办法是每次从头到尾喊一遍,记录谁该走,然后让他们出列,队伍剩下的人再重新整理。而聪明的办法是,你手里有一张表,上面记录着每个“小团体”的起始位置和人数,每次只需要看这张表,让每个团体第一个人出列,然后更新这张表的信息即可。后者效率要高得多。“小熊的果篮”考验的就是你能否设计出这样一张高效的“管理表”,并用代码实现它。
2. 核心思路与数据结构选型分析
面对这种需要高效进行“查找块首”和“删除元素”的操作,我们首先要放弃直接使用数组或链表进行暴力模拟的想法。数组的随机访问快,但删除中间元素需要移动后续所有元素,代价是O(N)。链表的删除操作快,但查找特定位置的元素又需要遍历。我们需要一种能结合两者优点的结构,或者更上层地,我们不需要真正地频繁删除物理元素,而是逻辑上标记它们已被移除,并动态维护“块”的边界。
2.1 主流解法:双向链表 + 块结构维护
这是解决此题最经典和直观的方法。核心思想是:
- 用双向链表存储所有水果节点:每个节点保存水果类型、左指针、右指针。这为我们提供了O(1)时间删除任意已知节点的能力。
- 预处理出初始的所有“块”:遍历初始序列,每当水果类型发生变化时,就标志着一个新块的开始。我们把每个“块”的起始节点记录下来。
- 模拟“拿出”过程:
- 每一轮,将所有“块”的起始节点(即本轮要拿出的水果)放入一个待删除列表。
- 按顺序输出这些节点的编号。
- 删除这些节点(从链表中移除)。
- 关键步骤:更新“块”的信息。一个节点被删除后,可能会影响它左右邻居所在的“块”:
- 如果这个节点原本是一个块的唯一节点,那么这个块就消失了。
- 如果这个节点被删除后,它左右两边的节点(如果存在且未被删除)是同一类型,那么这两个节点所属的块就会合并成一个新的块。
这个方法的巧妙之处在于,我们并不需要在每一轮都重新扫描整个链表来寻找“块”。我们维护了一个“当前所有块的起始节点集合”。每一轮,我们直接从这个集合中取元素进行操作。操作完成后,我们只需要检查被删除节点的左右邻居,看看是否有新的块产生或旧的块需要合并,并更新这个集合即可。
数据结构选择的具体理由:
- 双向链表:选择双向链表而非单向链表,是因为在删除一个节点后,我们需要方便地访问它的前驱和后继节点来判断块合并的可能性。
std::list是一个选择,但为了更精细的控制和更好的性能(避免频繁的内存分配),在竞赛中更常见的做法是使用数组模拟链表。即用数组l[i]和r[i]分别记录编号为i的水果的左邻居和右邻居编号,用vis[i]标记是否已被删除。这样所有操作都是基于数组索引的,速度极快。 - “块”的集合:可以用一个队列(如
queue)或向量(如vector)来存储当前轮次需要处理的块首。为了高效地判断一个节点是否已经是下一轮待处理的块首(避免重复入队),通常还需要一个标记数组。
2.2 思路拆解与复杂度分析
让我们把上述思路再细化成几个可执行的步骤,并分析为什么这样做是高效的:
第一步:初始化双向链表与块首集合我们读入长度为N的序列a[1..N]。
- 初始化
l[i] = i-1,r[i] = i+1(边界节点特殊处理,如l[1]=0, r[N]=0表示空)。 - 初始化
vis[i] = false,表示所有水果都未被拿走。 - 遍历
a数组,当a[i] != a[i-1]时,说明i是一个新块的开始,将节点i加入一个初始队列q中。这里注意,第一个节点1一定是某个块的开始。
这一步的时间复杂度是O(N)。
第二步:模拟轮次只要队列q不为空,就说明还有块存在,还需要进行操作。
- 创建一个临时队列
nxt,用于存储下一轮可能成为块首的节点。为什么需要这个?因为本轮删除操作后,产生的新块首只可能在下一轮被处理。 - 处理当前轮的所有块首:
- 只要
q不为空,就从中取出一个节点x。 - 如果
vis[x]为真,说明这个节点已经被之前的操作间接删除了(比如它所在的块被合并了),直接跳过。 - 否则,输出
x,标记vis[x] = true,并准备将其从链表中删除。 - 删除操作:令
L = l[x],R = r[x]。将r[L]指向R,l[R]指向L。这样就从逻辑上“移除”了节点x。 - 检查合并:这是算法的核心。删除
x后,L和R可能变得相邻。如果L和R都存在(即不等于边界0)且未被删除 (!vis[L] && !vis[R]),并且它们的水果类型相同 (a[L] == a[R]),那么L所在的块和R所在的块就应该合并。- 合并意味着什么?意味着
R不再是一个块的起始节点了!因为现在L和R属于同一个块,这个块的起点是L(或者更早)。因此,如果R原本在nxt队列中(作为下一轮的候选块首),我们需要将其移除,因为它已经“不配”当块首了。为了实现这个,我们需要一个标记数组in_nxt来记录哪些节点在nxt中。 - 同时,合并后,这个新块的首节点就是
L(或者L所在块的原始首节点)。但这里有个技巧:我们不需要立刻将L加入nxt,因为L可能在本轮或更早的轮次已经被作为块首处理过了。我们只需要确保在下一轮开始前,正确的块首被加入即可。一个稳妥的做法是,在合并发生时,如果R在nxt中,就标记它无效;而L的块首资格会在下一轮遍历链表寻找块首时被自然发现。但更高效的做法是,我们可以在本轮最后,通过检查L和R的邻居关系来将新的块首(可能是L,也可能是R的后继)加入nxt。一个常见的实现是:在合并后,如果L是某个块的起点(即L的左邻居不存在或是不同类型),那么L就是新块的起点,将其加入nxt(如果尚未加入)。
- 合并意味着什么?意味着
- 只要
- 处理完所有当前块首并输出换行后,将
nxt队列赋值给q,作为下一轮要处理的块首集合。
复杂度分析:
- 每个水果节点只会被放入
q(作为块首)一次,也只会被从链表中删除一次。 - 每个节点的删除操作是O(1)。
- 检查合并和更新
nxt队列的操作,也是每个节点最多被涉及常数次(作为被删除节点的左邻或右邻)。 - 因此,算法的总时间复杂度是O(N),完美满足了题目2×10^5数据量的要求。空间复杂度也是O(N)。
注意:这里有一个非常容易出错的细节,就是“重复块首”的处理。因为合并操作可能导致某个节点在
nxt中,但随后又因为成为另一个合并后块的非首部而失效。如果不加判断,下一轮就会处理到一个无效的块首,导致错误。所以in_nxt标记和从nxt中移除无效节点的操作至关重要。
3. 代码实现与关键细节剖析
理解了思路,我们来看具体的代码实现。我会用C++为例进行讲解,因为这是信息学竞赛中最主流的语言。我们将把上述思路转化为可运行的代码,并逐一拆解其中的关键点。
3.1 数据结构定义与初始化
#include <iostream> #include <cstdio> #include <queue> #include <vector> using namespace std; const int MAXN = 200005; // 根据题目数据范围设定 int a[MAXN]; // 存储水果类型,0或1 int l[MAXN], r[MAXN]; // 双向链表,l[i]和r[i]表示节点i的左右邻居编号 bool vis[MAXN]; // 标记节点是否已被删除 bool in_nxt[MAXN]; // 标记节点是否已在下一轮的待处理队列nxt中 int n; // 水果总数 queue<int> q; // 当前轮需要处理的块首队列初始化函数init():
void init() { scanf("%d", &n); for (int i = 1; i <= n; ++i) { scanf("%d", &a[i]); l[i] = i - 1; r[i] = i + 1; vis[i] = false; in_nxt[i] = false; } // 设置边界,0和n+1作为哨兵节点,方便处理 l[1] = 0; r[n] = 0; a[0] = a[n+1] = -1; // 哨兵类型设为-1,与0/1都不同 // 初始化块首队列:找出所有块的起始位置 while (!q.empty()) q.pop(); // 清空队列 for (int i = 1; i <= n; ++i) { if (i == 1 || a[i] != a[i-1]) { // 是块的开始 q.push(i); } } }关键点:
- 哨兵节点:我们将
a[0]和a[n+1]设置为-1,并将l[1]指向0,r[n]指向0。这样在判断边界条件时(如L是否为0),可以统一处理,避免复杂的条件判断。 - 初始化块首:遍历数组,将每个块的第一个节点加入队列
q。注意条件i == 1 || a[i] != a[i-1],它涵盖了第一个节点一定是块首的情况。
3.2 核心模拟过程实现
这是整个程序最核心的部分,我们把它封装成一个simulate()函数。
void simulate() { vector<int> output; // 用于临时存储本轮要输出的编号,凑够一行再输出 queue<int> nxt; // 下一轮的候选块首队列 while (!q.empty()) { output.clear(); // 清空in_nxt标记,为新一轮做准备。注意不能简单memset,效率低且没必要。 // 我们会在使用in_nxt时动态标记和清除。 // 处理当前轮的所有块首 while (!q.empty()) { int x = q.front(); q.pop(); if (vis[x]) continue; // 该节点已被删除,跳过 // 1. 记录输出 output.push_back(x); vis[x] = true; // 2. 从链表中删除x int L = l[x], R = r[x]; if (L > 0) r[L] = R; if (R > 0) l[R] = L; // 3. 检查删除x后,其左右邻居L和R是否会形成新块或需要合并 // 重点:处理左邻居L if (L > 0 && R > 0 && !vis[L] && !vis[R] && a[L] == a[R]) { // L和R类型相同,需要合并 // 此时,R一定不再是块的起点(因为它和左边的L同类型) // 如果R已经在nxt队列中,需要将其移除 if (in_nxt[R]) { // 从nxt中移除R是一个麻烦事,因为queue不支持随机删除。 // 常用技巧:不真正移除,而是打上“无效”标记,等从nxt中取出时再跳过。 // 更优的做法是,我们保证不将无效的R加入nxt。见下方对“新块首”的处理。 } // 合并后,新的块的起点是谁? // 是L吗?不一定。如果L本身也是一个块的起点(即L的左邻居和L类型不同),那么L就是新块起点。 // 如果L不是起点,那么新块的起点是L所在块的原始起点。 // 但我们可以用一个更简洁的方法:在每一轮的最后,重新扫描所有“可能成为新块首”的节点。 // 这个“可能成为新块首”的节点,就是所有“被删除节点的左邻居”。 // 因为只有当一个节点的左邻居被删除后,它才可能“露出来”成为新的块首。 // 所以,我们把L加入一个待检查列表。 } // 注意:我们不在这里直接将L加入nxt,因为L可能因为后续的其他删除操作而改变状态。 } // 输出本轮结果 for (size_t i = 0; i < output.size(); ++i) { printf("%d%c", output[i], " \n"[i == output.size()-1]); } // 关键:寻找下一轮的块首 // 我们如何高效地找到下一轮所有块的起点? // 方法:遍历本轮所有被删除的节点,检查它们的左邻居(L)和右邻居(R)。 // 但更高效且不易出错的方法是:在删除每个节点x时,将其左邻居L记录到一个“待检查集合”中。 // 然后,在处理完本轮所有删除后,遍历这个“待检查集合”中的节点。 // 对于集合中的每个节点c,如果它未被删除,并且它“是一个块的起点”,则将其加入nxt队列。 // 判断c是块的起点的条件:c的左邻居不存在(即l[c]==0) 或 c的左邻居已被删除(vis[l[c]]) 或 a[c] != a[l[c]]。 // 因为如果c的左邻居存在且类型相同,它们本应属于同一个块,c就不可能是起点。 // 由于我们之前没有维护这个“待检查集合”,我们需要换一种思路。 // 另一种实现策略,也是竞赛中更常见、更清晰的策略: // 在每一轮开始处理q之前,q中存储的就是当前轮所有块的起点。 // 我们处理完这些起点(删除它们)后,下一轮的起点只可能从“本轮被删除节点的直接邻居”中产生。 // 因此,我们可以在删除节点x时,将其左右邻居L和R都放入一个“候选集合”中(用vector或set暂存,注意去重)。 // 本轮所有删除完成后,遍历这个“候选集合”,对其中每个未被删除的节点,判断它是否为块的起点,如果是则加入nxt。 // 下面我们采用这种“候选集合”法来实现。 } }上面的代码注释详细解释了过程,但也揭示了实现中的几个难点:如何管理nxt队列,如何高效判断和更新块首。让我们写一个更完整、更清晰的版本。
3.3 清晰且高效的标准实现
#include <bits/stdc++.h> using namespace std; const int MAXN = 200010; int n; int a[MAXN], l[MAXN], r[MAXN]; bool vis[MAXN], inq[MAXN]; // inq[i]表示i是否在当前或下一轮的块首队列中 int main() { scanf("%d", &n); for (int i = 1; i <= n; ++i) { scanf("%d", &a[i]); l[i] = i - 1; r[i] = i + 1; } // 设置哨兵 l[1] = 0; r[n] = 0; a[0] = a[n + 1] = -1; queue<int> q; // 预处理初始块首 for (int i = 1; i <= n; ++i) { if (a[i] != a[i - 1]) { // i=1时,a[0]=-1,肯定不等,所以包含i=1的情况 q.push(i); inq[i] = true; } } vector<int> del_list; // 存储本轮被删除的节点 vector<int> cand; // 存储候选节点(被删除节点的邻居) while (!q.empty()) { del_list.clear(); cand.clear(); // 步骤1:取出当前轮所有块首,准备删除 while (!q.empty()) { int x = q.front(); q.pop(); inq[x] = false; // 出队,标记不在队列中 if (vis[x]) continue; // 已被删除,跳过(防止重复) del_list.push_back(x); } // 步骤2:按顺序输出并标记删除 for (int x : del_list) { printf("%d ", x); vis[x] = true; // 将其左右邻居加入候选集合 if (l[x] > 0) cand.push_back(l[x]); if (r[x] > 0) cand.push_back(r[x]); } if (!del_list.empty()) puts(""); // 输出换行 // 步骤3:正式从链表中断开被删除节点 for (int x : del_list) { int L = l[x], R = r[x]; if (L > 0) r[L] = R; if (R > 0) l[R] = L; } // 步骤4:从候选节点中筛选出下一轮的块首 // 需要去重,因为一个节点可能被多个删除节点推荐 sort(cand.begin(), cand.end()); cand.erase(unique(cand.begin(), cand.end()), cand.end()); for (int x : cand) { if (vis[x]) continue; // 如果候选节点自己已经被删除,跳过 if (inq[x]) continue; // 如果已经在队列中,跳过 // 判断x是否为块的起点:左邻居不存在或已被删除或类型不同 int left = l[x]; if (left == 0 || vis[left] || a[x] != a[left]) { q.push(x); inq[x] = true; } } } return 0; }这个实现的关键改进与解析:
- 两阶段删除:先收集所有要删除的节点
del_list,输出它们。然后再统一更新链表。这样做的好处是,在判断“左邻居”时,使用的链表状态是本轮删除前的状态,逻辑更清晰。如果边删边更新链表,判断逻辑会复杂一些。 - 候选节点法:将本轮所有被删除节点的左右邻居收集到
cand向量中。这些节点是下一轮可能成为块首的唯一来源。因为只有当一个节点的左邻居被删除后,它才可能“露出来”成为新块的起点。 - 去重与判断:对
cand去重后,遍历每个候选节点。判断它是否为块起点的标准是经典的:左邻居为空或左邻居已被删除或自己与左邻居类型不同。满足条件则加入下一轮队列q,并用inq标记防止重复入队。 - 复杂度保证:虽然使用了
sort和unique对cand去重,但每个节点最多作为邻居被加入cand两次(左邻居删它一次,右邻居删它一次),所以所有轮次的cand总大小是 O(N) 的,排序的总复杂度也在 O(N log N) 级别,对于20万的数据完全可接受。这是一种用少许额外时间换取编码清晰度和正确性的典型权衡。
实操心得:在竞赛中,正确性和稳定性永远比极致的常数优化更重要。上述实现逻辑清晰,不易出错,虽然有一个排序操作,但足以在时间限制内通过。如果追求极致,可以使用链表或哈希表手动去重,但代码复杂度会显著增加,调试成本高。在考场上,优先选择思路清晰、易于调试的实现。
4. 常见错误与调试技巧
即使理解了算法,在实现“小熊的果篮”时,依然有几个“坑点”容易让程序出错或超时。
4.1 错误类型与原因分析
时间超限 (TLE)
- 原因1:暴力模拟。最直接的原因就是使用数组或vector,在每一轮中扫描整个序列寻找块首,并物理删除元素。这会导致O(N^2)的复杂度。
- 原因2:数据结构使用不当。比如使用了
std::list但频繁调用size()或进行线性查找。或者在没有必要的情况下使用了复杂度较高的容器操作。 - 原因3:死循环。在更新块首队列时逻辑有误,导致某些节点被重复加入队列,循环无法结束。
答案错误 (WA)
- 原因1:块首判断错误。这是最常见的错误。判断一个节点
i是否为块首,条件必须是i == 1 || a[i] != a[i-1]。注意,这个判断是基于原始数组a的,并且是在初始状态下。在模拟过程中,判断逻辑变为“左邻居不存在/被删除/类型不同”。两者不能混淆。 - 原因2:输出顺序错误。题目要求按拿出顺序输出编号。这意味着在同一轮内,拿出的水果编号必须按照它们在原始序列中从左到右的顺序输出。如果你用队列存储当前轮块首,那么队列本身的FIFO性质就自然保证了从左到右的顺序(因为初始化和后续加入都是按从左到右扫描的逻辑)。但如果你用了其他容器(如vector)存储后又没有排序,就可能出错。
- 原因3:合并逻辑遗漏。只处理了删除节点,没有处理删除后左右邻居可能合并的情况。或者合并逻辑写反了,导致不该合并的合并了,该合并的没合并。
- 原因4:哨兵处理不当。没有正确设置链表边界(0或n+1),导致访问
l[0]或r[n+1]造成数组越界或逻辑错误。 - 原因5:重复删除。一个节点被删除后,由于它可能还在队列中(比如它同时是两个块的起点?这不可能,一个节点只能是一个块的起点),如果没有用
vis数组标记,后续可能再次尝试删除它,导致链表指针错乱。
- 原因1:块首判断错误。这是最常见的错误。判断一个节点
4.2 调试技巧与测试数据设计
当你觉得代码逻辑没错但提交总是WA时,系统地调试至关重要。
设计小规模测试数据:
- 边界测试:N=1的情况。输入
1和0(或1),输出应该是1。 - 全相同测试:所有水果都一样。如
5和0 0 0 0 0。输出应该是1 2 3 4 5(每轮拿一个,因为只有一个大块)。 - 交替测试:水果类型交替出现。如
4和0 1 0 1。初始块为[0], [1], [0], [1]。第一轮输出1 2 3 4,序列清空。这是检验合并逻辑是否多余的好例子(本例中不存在合并)。 - 引发合并的测试:这是核心。例如
6和0 0 1 1 0 1(题目样例)。过程如前所述。再如5和0 1 1 0 0。- 初始块:
[0], [1,1], [0,0] - 第一轮:删除
1, 2, 4(编号)。序列变为[1, 0]。 - 此时,节点3(类型1)和节点5(类型0)相邻但类型不同,不合并。形成两个块
[1], [0]。 - 第二轮:删除
3, 5。序列清空。 - 输出应为:
1 2 4 3 5
- 初始块:
- 更大规模的随机测试:自己写一个生成器,生成N=10或20的随机序列,用你的程序和另一个暴力但正确的程序(可以很简单,效率低没关系)对比输出。
- 边界测试:N=1的情况。输入
输出中间状态: 在代码中关键步骤后打印调试信息。例如,在每轮开始前打印当前队列
q的内容,每轮删除后打印链表状态(可以写一个函数遍历链表打印未被删除的节点)。这能帮你直观看到算法是否按预期运行。void debug_print(int round) { printf("Round %d: q = ", round); // 注意:这里不能直接遍历queue,可以复制一份 queue<int> tmp = q; while(!tmp.empty()) {printf("%d ", tmp.front()); tmp.pop();} printf("\nList: "); for(int i=1; i<=n; i++) if(!vis[i]) printf("[%d:%d] ", i, a[i]); printf("\n"); }使用静态查错:
- 仔细检查所有数组大小是否足够(MAXN是否大于200000)。
- 检查
scanf的格式字符串是否匹配。 - 检查循环变量范围。
- 检查条件判断中的
==是否误写为=。
4.3 一个更鲁棒的实现细节
在上面的标准实现中,我们用了sort和unique来对候选节点去重。这里提供一个无需排序的去重方法,使用一个标记数组in_cand来保证每个节点只被加入cand一次,但在每轮结束后需要清空这个标记数组。清空时如果对整个数组memset会导致 O(N) 开销,累加起来可能变 O(N^2)。我们可以用一个时间戳技巧来优化:
int ts[MAXN], cur_ts = 0; // 时间戳数组和当前时间戳 // 在每轮循环开始时 cur_ts++; // 当想将节点x加入cand时 if (ts[x] != cur_ts) { ts[x] = cur_ts; cand.push_back(x); }这样,我们通过判断ts[x]是否等于本轮的cur_ts来实现去重,而无需在每轮结束后清理整个数组。这是一种在竞赛中常用的优化小技巧。
5. 算法扩展与思维提升
“小熊的果篮”虽然解决了,但其中蕴含的思想可以应用到更广的场景。这本质上是一个**动态维护序列中连续段(块)**的问题。我们维护了一个块首的队列,并通过监听“删除”事件来更新块的结构。
思维提升点:
- 从模拟到维护:这是竞赛编程中的一个重要思维飞跃。不要被题面的“模拟”二字迷惑,很多模拟题都需要找到高效维护状态变化的方法,而不是亦步亦趋地模拟过程。
- 事件驱动更新:我们的更新不是全局的,而是局部的、由事件(删除一个节点)触发的。只关注事件直接影响的范围(被删节点的邻居),这大大减少了计算量。
- 利用数据结构降维:使用双向链表,我们将“删除元素”和“查找邻居”这两个操作都优化到了O(1)。而维护块首队列,则将“查找所有块首”这个操作从O(N)降到了O(当前块数)。
类似问题举一反三:
- 约瑟夫问题变种:不再是简单报数出圈,而是每次根据某种规则(比如相邻两人的属性)决定下一个出圈的人,并动态更新圈子。
- 区间合并与分裂:有一系列区间,不断有区间被删除,删除后相邻的区间如果满足条件(如端点相连)则合并。这和我们维护“水果块”非常相似。
- 动态连通性(简易版):可以想象每个水果是一个点,相邻的同色水果有一条边,形成一个连通块。删除一个点后,可能会分裂或合并连通块。本题是链上的特殊情况。
解决这类问题的通用思路是:
- 定义清楚需要维护的“对象”是什么(本题是“块”)。
- 定义清楚对象的“状态变化”由哪些基本操作引起(本题是“删除一个节点”)。
- 设计数据结构,能够高效地:
- 执行基本操作。
- 查询当前所有对象。
- 在基本操作后,更新受影响的对象状态。
最后,关于这道题,我个人的体会是,它完美地诠释了CSP-J/NOIP普及组对选手的要求:不仅仅是会写代码,更要会思考,会优化,会从生活化的描述中抽象出计算模型。它考察的链表操作、队列应用、模拟优化,都是非常基础且重要的编程基本功。把这道题吃透,对于理解如何将复杂过程转化为高效算法,有着极大的帮助。在练习时,不妨多尝试几种不同的数据,甚至自己改动一下题目规则(比如每次拿出每个块最右边的水果),看看算法需要如何调整,这样能更深刻地掌握其核心思想。
