USACO 2021 Jan Silver

Dance Mooves

题目解析与算法可视化

1. 题目回顾

输入:

  • $N$ 头牛 ($1 \dots N$),初始在位置 $1 \dots N$。
  • $K$ 个交换操作序列 ($K \le 2 \cdot 10^5$)。

过程:

  • 这 $K$ 个操作会无限循环地执行 (例如:每分钟执行一次交换)。

目标:

  • 对于每一头牛 $i$,求出它在无限的时间内,总共到达过多少个不同的位置

2. 朴素思路的瓶颈

如果是直接模拟...


// 伪代码
for (long long time = 1; time <= INFINITY; time++) {
    perform_swap(ops[time % K]);
    record_position();
    if (state_repeated) break;
}
                
  • 时间是无限的,但状态有限。
  • 然而,$N!$ 种排列组合太大了,虽然循环节一定存在,但可能非常长。
  • 关键观察: 操作序列每 $K$ 次一循环。我们可以把 $K$ 次操作看作是一个大置换 (Permutation) $P$

3. 核心洞察:置换环 (Cycles)

每过一轮($K$ 次操作),牛的位置变化形成一个置换 $P$。

1 2 3 Cycle A 4 5 Cycle B

定理: 同一个环上的所有牛,在无限次循环后,访问的位置集合是完全相同的(即该环上所有位置以及途中经过的位置的并集)。

4. 算法策略

  1. 预处理置换 P: 模拟一遍 $K$ 个操作,算出每头牛在一轮后去了哪里。
  2. 找环: 利用并查集或 DFS/BFS 将牛分解成若干个不相交的环 (Cycle)。
  3. 统计集合:
    • 我们不需要模拟无限轮。
    • 只需再模拟一轮 ($K$次)
    • 在这一轮中,记录每个环 (Cycle ID) 到达了哪些位置。

难点:如何高效统计每个环经过的所有点?

巧妙的集合统计

与其追踪“牛”,不如追踪“位置属于哪个环”。

位置:

当前环ID:

Pos 1 C_A Pos 2 C_B

当发生 swap(1, 2) 时:

  • 环 $C_A$ 的牛到达了 Pos 2 $\to$ set[C_A].insert(2)
  • 环 $C_B$ 的牛到达了 Pos 1 $\to$ set[C_B].insert(1)
  • 交换位置上的环归属: swap(c[1], c[2])

代码实现:第一步

求一轮后的置换 $P$ 与 环分解

int p[100005];  // p[i] 表示初始在 i 的牛,一轮后到了 p[i]
int c[100005];  // c[i] 表示牛 i 属于哪个环

// ... main ...
// 1. 初始化 p[i] = i
for (int i = 1; i <= n; i++) p[i] = i;

// 2. 模拟 K 次操作,得到最终置换 P
for (int i = 0; i < k; i++) {
    scanf("%d %d", &ops[i].first, &ops[i].second);
    swap(p[ops[i].first], p[ops[i].second]); 
    // 注意:这里 p 存的是位置上的牛的内容,
    // 最终 p[pos] 是该位置结束时的牛编号
}

注:代码中 p 数组其实被当作“位置上的内容”来交换,最终用于构建图。

代码实现:第二步

构建环 (Cycle Decomposition)

for (int i = 1; i <= n; i++) {
    if (c[i]) continue; // 如果这个位置已经在环里了,跳过
    
    // 发现新环,以 i 为 ID
    int x = p[i];
    c[i] = i; 
    while (x != i) {
        c[x] = i;   // 标记环上所有节点
        x = p[x];   // 沿着置换走
    }
}

代码实现:第三步 (核心)

再模拟一轮,利用 std::set 统计

for (int i = 1; i <= n; i++)
    pc[c[i]].insert(i); // 初始位置加入各自环的集合

for (int i = 0; i < k; i++) {
    int a = ops[i].first, b = ops[i].second;
    // 关键逻辑:
    // 当前在 a 的牛(属于环 c[a]) 访问了 b
    pc[c[a]].insert(b);
    // 当前在 b 的牛(属于环 c[b]) 访问了 a
    pc[c[b]].insert(a);
    
    swap(c[a], c[b]); // 随牛移动,更新位置上的环ID
}

复杂度分析

$N, K \le 10^5$

  • 时间复杂度:
    • 找环: $O(N)$
    • 模拟过程: $K$ 次操作
    • Set 插入: 每次 $O(\log (\text{Distinct Pos}))$
    • 总计: $O(N + K \log N)$。在 1s 时限内非常稳。
  • 空间复杂度:
    • $O(N + K)$ 存储操作和集合。

完整 AC 代码


#include 
#include 
#include 
#include 
#include 
#include 
using namespace std;

int n, k;
pair ops[200005];
int p[100005];  // 置换P
int c[100005];  // 位置 i 当前所属的环编号
set pc[100005]; // 每个环的牛占据的所有位置集合

int main() {
    scanf("%d %d", &n, &k);
    
    // 初始化位置
    for (int i = 1; i <= n; i++) p[i] = i;
    
    // 读取并计算一轮后的置换关系
    for (int i = 0; i < k; i++) {
        scanf("%d %d", &ops[i].first, &ops[i].second);
        swap(p[ops[i].first], p[ops[i].second]);
    }
    
    // 分解置换环
    for (int i = 1; i <= n; i++) {
        if (c[i]) continue;
        int x = p[i];
        c[i] = i; // 用起始点作为环的ID
        while (x != i) {
            c[x] = i;
            x = p[x];
        }
    }
    
    // 将初始位置加入集合
    for (int i = 1; i <= n; i++)
        pc[c[i]].insert(i);
        
    // 再次模拟 K 次操作,累积路径
    for (int i = 0; i < k; i++) {
        int a = ops[i].first, b = ops[i].second;
        
        // 当前在位置 a 的牛 (属于环 c[a]) 去了 b
        pc[c[a]].insert(b);
        // 当前在位置 b 的牛 (属于环 c[b]) 去了 a
        pc[c[b]].insert(a);
        
        // 交换位置上的环标记 (跟着牛走)
        swap(c[a], c[b]);
    }
    
    // 输出结果:牛 i 的答案就是它所属环的大小
    for (int i = 1; i <= n; i++)
        printf("%d\n", pc[c[i]].size());
        
    return 0;
}

Thanks

Q & A