USACO Silver 2023 Dec

Problem 3: Target Practice

算法讲解与题解分析

Topic: Simulation, Prefix/Suffix Sums, Ad-hoc

1. 题目概要

  • 输入: $T$ 个目标位置,$C$ 个指令字符串 (L, R, F)。
  • 动作:
    • L: 向左移动 1 ($pos \leftarrow pos - 1$)
    • R: 向右移动 1 ($pos \leftarrow pos + 1$)
    • F: 射击 (如果当前位置有目标,则击中)
  • 核心任务: 我们可以更改恰好一个指令(L, R, F 互换)。
  • 目标: 求更改后,击中不同目标的最大数量。

2. 难点与初步思路

暴力解法?

尝试修改每一个位置 $i$ 为其他两种指令,然后重新模拟。

复杂度:$O(C^2)$。由于 $C \le 10^5$,这会超时。


优化思路:

我们需要 $O(C)$ 或 $O(C \log C)$ 的解法。

关键观察:牵一发而动全身

  • 修改第 $i$ 个指令,会影响 $i$ 之后所有时刻的绝对位置。
  • 但 $i$ 之后指令之间的相对移动是不变的。

3. 核心逻辑:路径偏移

修改指令主要导致后面的路径发生整体平移

Original (L at index i): i-1 L i Remaining Path Changed to R: R Shift +2 Path Shifted Right +2

如果在位置 $i$ 本来是向左走(-1),现在改成向右走(+1),那么 $i$ 之后的所有位置都比原计划大了 2。

4. 五种偏移情况 (The 5 States)

我们要预处理出这 5 种偏移下,后缀 (Suffix) 能击中多少目标。

原指令 新指令 位置变化 后续整体偏移 Array Index
R (+1)L (-1)$-2$Left 20
R (+1) / F (0)F (0) / L (-1)$-1$Left 11
AnySame$0$No Change2
L (-1) / F (0)F (0) / R (+1)$+1$Right 13
L (-1)R (+1)$+2$Right 24

5. 数据结构与算法流程

1. 离线预处理 (Suffix):

我们需要知道:“如果从现在开始,所有位置都平移 $K$,后面还能打中几个靶子?”

  • map<int, int> right[5]: 存储 5 种偏移情况下的击中统计。
  • Key: 目标位置, Value: 该位置被击中的次数 (Reference Count)。

2. 模拟扫描 (Prefix):

  • set<int> left: 当前时刻之前已经击中的目标(去重)。
  • 遍历 $i$ 从 $0$ 到 $C-1$,尝试修改当前指令。
  • 答案 = left.size() + right[shift_index].size() (注意去重)。

6. 关键逻辑:Claiming Mechanism

如果一个靶子在左边被击中了,它在右边就算再次被击中也不能重复得分。


// 如果当前是 'F' 指令,且真的击中了靶子
if (targets.count(p) && left[p] == 0) {       
    // 1. 将其加入左边已完成集合
    left[p]++;
    
    // 2. 从所有右边(未来)的可能列表中彻底移除这个靶子
    // 这样后续计算 right.size() 时,就不会包含这个靶子了
    for (int j = 0; j < 5; j++) right[j].erase(p);
}
                    

这种“贪心移除”策略是 AC 的关键:一旦前面打中了,后面无论怎么移,这个靶子的贡献已经被计算在 res (即 left.size) 里了,所以从所有 future maps 中删掉。

7. 完整代码 (C++)


#include <bits/stdc++.h>
using namespace std;

int t, c;
char s[100005];
set<int> targets;
// left: 记录左边(过去)实际击中的目标
map<int,int> left_hits; 
// right[5]: 记录右边(未来)在不同偏移下击中的目标及次数
// 0:偏移-2, 1:偏移-1, 2:不变, 3:偏移+1, 4:偏移+2
map<int,int> right_hits[5];     
int ans;

int main() {
    scanf("%d %d", &t, &c);
    for (int i = 0; i < t; i++) {
        int p; scanf("%d", &p); targets.insert(p);
    }
    scanf("%s", s);
    int p = 0;
    
    // --- Step 1: 预处理所有未来的可能性 (Fill Suffixes) ---
    for (int i = 0; i < c; i++) {
        if (s[i] == 'L') p--;
        if (s[i] == 'R') p++;
        if (s[i] == 'F') {
            // 记录如果当前位置偏移 k,能否击中目标
            if (targets.count(p-2)) right_hits[0][p-2]++;
            if (targets.count(p-1)) right_hits[1][p-1]++;
            if (targets.count(p))   right_hits[2][p]++;
            if (targets.count(p+1)) right_hits[3][p+1]++;
            if (targets.count(p+2)) right_hits[4][p+2]++;
        }
    }

    // --- Step 2: 模拟过程 (Sweep) ---
    p = 0;
    for (int i = 0; i < c; i++) {
        int current_score = left_hits.size();
        // 如果把当前指令改成F,能否击中新目标?(前提是左边没击中过)
        int hit_if_change_to_F = (left_hits.count(p) == 0 && targets.count(p)) ? 1 : 0; 

        if (s[i] == 'L') {
            // Case 1: L -> F (Shift +1)
            // 分数 = 左边得分 + (如果F击中新目标) + 右边偏移+1后的得分
            // 注意: right_hits[3] 可能会包含当前位置 p,需要小心处理(代码中使用 max 简化了覆盖逻辑)
            // 这里使用题目特性的简化逻辑:如果right包含p且我们现在击中p,不重复算? 
            // 实际上 map.size() 统计的是独特的。如果 change to F 击中 p,
            // 且 p 在 right[3] 中,那么 p 会被算两次吗?
            // 你的代码逻辑:如果是F,如果right[3]有p,ans加的时候不加newhit? 
            // 实际上:如果 right[3] 有 p,说明未来也会击中 p。
            if (right_hits[3].count(p)) 
                ans = max(ans, current_score + (int)right_hits[3].size());
            else 
                ans = max(ans, current_score + hit_if_change_to_F + (int)right_hits[3].size());
                
            // Case 2: L -> R (Shift +2)
            ans = max(ans, current_score + (int)right_hits[4].size());
            p--; // Execute actual move L
        } 
        else if (s[i] == 'R') {
            // Case 1: R -> F (Shift -1)
            if (right_hits[1].count(p))
                ans = max(ans, current_score + (int)right_hits[1].size());
            else
                ans = max(ans, current_score + hit_if_change_to_F + (int)right_hits[1].size());

            // Case 2: R -> L (Shift -2)
            ans = max(ans, current_score + (int)right_hits[0].size());
            p++; // Execute actual move R
        } 
        else { // s[i] == 'F'
            // 既然我们要走过这个 F 了,我们需要把这个 F 对未来的贡献从 right_hits 中移除
            // 因为这些 hits 变成了 "过去式"
            for (int j = 0; j < 5; j++)
                if (right_hits[j].count(p+j-2)) {
                    if (--right_hits[j][p+j-2] == 0) right_hits[j].erase(p+j-2);
                }
            
            // 尝试修改 F
            ans = max(ans, current_score + (int)right_hits[1].size()); // F -> L (Shift -1)
            ans = max(ans, current_score + (int)right_hits[3].size()); // F -> R (Shift +1)

            // 执行实际的 F 操作:如果击中,记录到 left_hits,并从所有 right maps 永久删除
            if (targets.count(p) && left_hits.count(p) == 0) {
                left_hits[p]++;
                for (int j = 0; j < 5; j++) right_hits[j].erase(p);
            }
        }
    }
    // 最后考虑完全不改的情况
    ans = max(ans, (int)left_hits.size());

    printf("%d\n", ans);
    return 0;
}
                    

8. 总结

  • 时间复杂度: 预处理 $O(C \log T)$,主循环 $O(C \log T)$。总复杂度 $O(C \log T)$,可以通过。
  • 空间复杂度: 需要 5 个 Map 存储后缀状态,空间线性 $O(T)$。
  • 核心思想:
    1. 利用修改指令造成的固定偏移量。
    2. 使用 Reference Counting Map 处理后缀击中次数。
    3. 贪心处理前缀击中 (Erase from future) 避免重复计数。