题目分析
首先,我们来深入理解题目的要求。假设我们有 \(k\) 个不同的品种。我们需要找到一个 \(x\) 坐标的区间 \([x_{min}, x_{max}]\),这个区间内的所有奶牛,其品种集合包含了全部 \(k\) 个品种,并且我们希望 \(x_{max} - x_{min}\) 的值尽可能小。
直接处理无序的 \(x\) 坐标非常困难。如果我们想确定一个区间的最小和最大 \(x\) 坐标,我们最好先让这些坐标有序。因此,一个非常自然且关键的预处理步骤是:将所有奶牛按照它们的 \(x\) 坐标从小到大排序。
排序后,问题就转化为了:在一个按 \(x\) 坐标排好序的奶牛数组 cows 中,找到一个连续的子数组 cows[i...j],这个子数组包含了所有品种的奶牛,并且 cows[j].x - cows[i].x 的值最小。这个问题就变成了一个经典的寻找满足特定条件的最短连续子数组的问题,非常适合使用双指针(滑动窗口)算法来解决。
算法思路:滑动窗口
滑动窗口算法是解决这类问题的绝佳工具。我们可以想象一个“窗口”在排好序的奶牛数组上滑动。这个窗口由两个指针定义:左指针 i 和右指针 j。窗口代表了我们当前考虑的奶牛子数组 cows[i...j]。
我们的策略是:
- 扩展窗口:不断向右移动右指针
j,将新的奶牛纳入窗口,直到窗口内的奶牛满足“包含所有品种”的条件。 - 记录并收缩窗口:一旦条件满足,我们就找到了一个“可行解”。我们计算当前窗口的成本 (
cows[j].x - cows[i].x),并用它来更新全局最小成本。然后,我们尝试让这个窗口变得更小以寻找更优解。方法是向右移动左指针i,将奶牛cows[i]移出窗口。 - 重复:移出
cows[i]后,窗口可能不再满足条件(比如cows[i]是窗口内最后一个某品种的奶牛)。这时,我们回到步骤1,继续向右移动j,寻找下一个满足条件的窗口。
这个过程持续进行,直到两个指针都遍历完整个数组。因为两个指针都只从左到右单向移动,所以总的时间复杂度是线性的。我们需要以下的数据结构:
- 品种计数器 (
counts):一个哈希表(在 Python 中是dict或defaultdict),用来记录当前窗口内每个品种的奶牛数量。例如counts[breed_id] = 2表示窗口内有2头breed_id品种的奶牛。 - 已满足的品种数 (
have):一个整数,记录当前窗口内包含了多少个不同的品种。 - 总品种数 (
total_type):一个整数,表示牛群中总共有多少个不同的品种。这是我们判断窗口是否满足条件的目标。
重要步骤:窗口移动
- 我们使用一个
for循环来遍历左指针i,从0到N-1。在每次循环中,i代表了我们尝试收缩的窗口的左边界。 - 在
for循环内部,我们使用一个while循环来扩展右指针j。while循环的条件是have < total_type并且j < N。这意味着只要我们还没找齐所有品种,并且右指针没有越界,就继续扩展。- 在
while循环中:- 获取右指针
j指向的奶牛cows[j]的品种p = cows[j][1]。 - 如果
counts[p] == 0,说明这是一种新加入窗口的品种,所以have += 1。 - 然后,将该品种的计数加一:
counts[p] += 1。 - 最后,右指针向右移动:
j += 1。
- 获取右指针
- 当
while循环结束时,有两种可能:j到达了数组末尾,但have仍然小于total_type。这意味着从当前i开始,无法构成一个包含所有品种的有效窗口了。循环可以提前结束。have == total_type。我们成功找到了一个从i开始的,包含所有品种的最短窗口。这个窗口是cows[i...j-1](因为j在最后一次成功添加后又自增了1)。
- 如果
have == total_type:- 计算成本:
cost = cows[j-1][0] - cows[i][0]。 - 更新全局最小成本:
ans = min(ans, cost)。
- 计算成本:
- 在
for循环的末尾(准备下一次i的迭代前),我们需要将cows[i]移出窗口:- 获取左指针
i指向的奶牛的品种p_left = cows[i][1]。 - 将该品种的计数减一:
counts[p_left] -= 1。 - 如果
counts[p_left] == 0,说明我们失去了窗口中该品种的最后一头牛,所以have -= 1。
- 获取左指针
代码解析
import sys
from collections import defaultdict# 1. 快速读入
# sys.stdin.read().strip().split() 一次性读取所有输入,分割成一个字符串列表
# 这种方式比逐行读取 input() 在数据量大时更快
data = sys.stdin.read().strip().split()
it = iter(data) # 创建一个迭代器,方便按顺序取数# 2. 预处理
n = int(next(it))
cows, seen_type, total_type = [], {}, 0
for _ in range(n):x = int(next(it))p = int(next(it))cows.append((x, p))# 使用字典 seen_type 来模拟集合,统计总品种数if not seen_type.get(p, False):seen_type[p] = Truetotal_type += 1# 3. 关键的排序步骤
cows.sort(key=lambda tp: tp[0])# 4. 滑动窗口初始化
cnt = defaultdict(int) # 品种计数器,对应我们分析中的 counts
have = 0 # 当前窗口的品种数
ans = 10**9 # 最小成本,初始化为极大值# tail 是右指针 j。这里代码的实现是 for 循环 i,while 循环 tail
# 它没有像我们分析中那样将 j 的初始化放在 i 循环外
# 而是通过一个 tail 变量来维护右指针的位置
tail = 0
# 先把第一头牛放进窗口,进行初始化
cnt[cows[0][1]] += 1
have = 1# 5. 窗口移动
# 主循环,i 是左指针
for i in range(n):# 扩展窗口:只要品种没齐,并且 tail 还能向右移动while have < total_type and tail + 1 < n:tail += 1 # 移动右指针p = cows[tail][1]cnt[p] += 1# 如果这个品种是新加入的,have++if cnt[p] == 1:have += 1# 如果当前窗口满足条件,更新答案if have == total_type:ans = min(ans, cows[tail][0] - cows[i][0])# 收缩窗口:将 i 指向的牛移出窗口p_left = cows[i][1]cnt[p_left] -= 1# 如果移出后,该品种数量为0,说明我们失去了一个品种if cnt[p_left] == 0:have -= 1# 这段代码是处理一个特殊情况:当左指针 i 追上甚至超过右指针 tail 时# 意味着窗口已经为空或者无效了。此时需要重置窗口。# 让 tail 指向 i 的下一个位置,并重置计数器和 have。# 这是一个实现上的选择,确保窗口始终至少有一个元素(如果可能)。# 在标准的双指针模板中,这个逻辑可能不这么写,但效果是类似的。if tail < i + 1 and i + 1 < n:tail = i + 1p = cows[tail][1]cnt = defaultdict(int) # 重置计数器cnt[p], have = 1, 1# 6. 输出结果
print(ans)
