USACO2025FEB Silver 题解

USACO2025FEB Silver 题解

注:本文公开时间为比赛结束后。

不得不吐槽一下 USACO 的评测系统居然不忽略行末空格和文末空行,因为这个 WA 了两发(但我怎么记得 1 月打的时候是忽略这些空白字符的)(但是怎么打金组的时候又可以忽略了?)

A.

PS:赛时花了半个小时写了个假做法,发现假了之后改了半个小时交上去除了样例全过不了,红温了。但后来发现只是 >= 少打了一个 =,没想到这居然一个点都过不了。这种数据强度值得某个造出了全输出 No 都能有 45 分的数据的机构学习。

先考虑不进行操作时怎么做。题面中创建序列 b 的过程本质上就是选出 a 的一个子序列。可以发现,选出“字典序最大”的子序列的限制是很强的:选出的第一个元素一定是 a 中最大的元素,选择其它更小的元素一定更劣。如果有多个最大的元素,那么贪心地选择最靠前的那个,这样就给后面的选择留下了更多的机会。而选择的第二个元素一定是第一个元素之后的最大元素,以此类推,每次选择的元素都是前一个选择的元素之后的最大元素,直到选完 a 的最后一个元素。

代码实现上有极其简单的方法:先计算出 a 的后缀最大值数组 suf,然后选取所有满足 ai=sufi 的位置即可。这是因为,如果 ai 不是后缀最大值,那么选择它之后的更大的数一定更优。

然后考虑如何通过操作优化答案。观察这个数据:a=(4,1,3,2,3,2),其中红色的是未进行操作时选的数。首先发现移动未被选择的数是没有用的,因为如果未移动时某个数不是后缀最大值,向前移动后它也不会变成后缀最大值,不影响答案。而如果要移动当前已经选择的数 x,则一定不会移到另一个已被选择的数 y 之前,因为 y 先被选择,说明 yx,把 x 移动到 y 之前一定不优。

进一步,可以发现最优的操作一定是:把某个已经选择的数 x 往前移,使得某个原本在 x 之前的数 y 变成在 x 后面,并且 y 大于等于原本 x 之后最大的数。例如对于上述数据,最优的移动方案是把最后一个 3 移到倒数第二个 3 后面,那么序列变成 a=(4,1,3,3,2,2),这样就可以多选一个 2。这是容易理解的:如果移动之后没有更大的数变到 x 后面,那么这样移动是没有意义的,我们仍然不会选择那些在移动后变到 x 后面的数。

由于最多只能移动 1 次,所以一定是在第一个满足要求的 x 处操作。用 ST 表维护区间最大值即可。

Code
#include<bits/stdc++.h>

using namespace std;

int t, n;

struct ST {
    vector<vector<int>> c;

    ST(vector<int> &a) {
        c.resize(n + 1, vector<int>(__lg(n) + 1));
        for(int i = 1; i <= n; i++) {
            c[i][0] = a[i];
        }
        for(int k = 1; (1 << k) <= n; k++) {
            for(int i = 1; i + (1 << k) - 1 <= n; i++) {
                c[i][k] = max(c[i][k - 1], c[i + (1 << (k - 1))][k - 1]);
            }
        }
    }

    int query(int l, int r) {
        if(l > r) return 0;
        int d = __lg(r - l + 1);
        return max(c[l][d], c[r - (1 << d) + 1][d]);
    }
};

void print(vector<int> &a) {
    vector<int> suf(n + 2);
    for(int i = n; i >= 1; i--) {
        suf[i] = max(suf[i + 1], a[i]);
    }

    vector<int> ans;
    for(int i = 1; i <= n; i++) {
        if(a[i] == suf[i]) {
            ans.push_back(a[i]);
        }
    }
    int m = (int)ans.size();
    for(int i = 0; i < m; i++) {
        cout << ans[i];
        if(i != m - 1) cout << ' ';
    }
    if(t) cout << '\n';
}

void solve() {
    cin >> n;
    vector<int> a(n + 1);
    for(int i = 1; i <= n; i++) {
        cin >> a[i];
    }

    vector<int> suf(n + 2);
    for(int i = n; i >= 1; i--) {
        suf[i] = max(suf[i + 1], a[i]);
    }

    ST st(a);

    for(int i = 1, lst = 0; i <= n; i++) {
        if(suf[i] == a[i]) {
            if(st.query(lst + 1, i - 1) >= suf[i + 1]) {
                int tmp = a[i];
                // cerr << "move " << i << ", lst = " << lst << '\n';
                a.erase(a.begin() + i);
                a.insert(a.begin() + lst + 1, tmp);
                break;
            } else {
                lst = i;
            }
        }
    }

    // for(int i = 1; i <= n; i++) {
    //     cerr << a[i] << " \n"[i == n];
    // }

    print(a);
}

int main() {
    cin.tie(nullptr) -> sync_with_stdio(false);

    cin >> t;
    while(t--) {
        solve();
    }

    return 0;
}

B.

题面中描述字符串的形式构成一棵 Trie,并且所有词库中的字符串都在叶子节点。念字符串的过程可以看作在 Trie 上行走:一开始在根节点,代表空串。每念一个字母,就相当于在根节点与该字符串对应的叶子节点之间的简单路径上走一条边。

假设当前走到了节点 u,可以发现,仅当 u 的子树内只有一个没有被念过的字符串时,才可以唯一确定当前念的字符串是哪个。所以可以维护 sum(u) 表示 u 子树内未被念过的字符串数量(在一开始也就相当于叶子节点的数量),查询时从叶子节点暴力往跟节点跳,找到深度最浅的节点 v,满足 sum(v)=1,则 dep(v) 就是答案。同时还要把叶子节点到根节点简单路径上所有点的 sum1,表示这个字符串已经被念过了。

这种做法的时间复杂度和叶子节点的深度有关,很容易被卡到 O(N2),可以通过前两档部分分。

优化需要一点小巧思。如果你是数据结构领域大神,可能一下就想到用树剖维护路径修改,查询时二分找到深度最浅的 sum=1 的点。但这样做的时间复杂度是 O(Nlog2N),想要通过可能需要卡卡常,并且码量还很大。

实际上正解很简单:正难则反,把询问离线下来倒序处理。定义 f(u) 表示 u 子树内是否有未被念出的字符串。查询 u 代表的字符串时,仍然从根节点往上跳,遇到 f 不为 1 的点就退出,深度最浅的满足 f(v)=0 的点 v 就是答案。由于是倒序处理,为了体现这个字符串的状态由“已念出”变为“未被念出”,把 uv 路径上所有点的 f 置为 1

这样做的优势在于:每次访问一个点 u 都会把 f(u)0 置为 1,而遇到一个 f1 的点就不会继续跳了,所以总时间复杂度是 O(N)

Code
#include<bits/stdc++.h>

using namespace std;

constexpr int rt = 0;
int n, m;
vector<vector<int>> G;
vector<int> fa, sum, dep, qry, ans, f;

int dfs(int u) {
    if(!G[u].size()) {
        return 1;
    }
    int res = 0;
    for(int v: G[u]) {
        dep[v] = dep[u] + 1;
        res += dfs(v);
    }
    return res;
}

int solve(int u) {
    int res = -1;
    while(u != -1 && f[u] != 1) {
        f[u] = 1, res = dep[u], u = fa[u];
    }
    return res;
}

int main() {
    cin.tie(nullptr) -> sync_with_stdio(false);

    cin >> n;
    G.resize(n + 1), fa.resize(n + 1), fa[0] = -1;
    for(int i = 1; i <= n; i++) {
        cin >> fa[i];
        G[fa[i]].push_back(i);
    }

    dep.resize(n + 1);
    m = dfs(rt);

    qry.resize(m + 1), ans = qry, f.resize(n + 1);
    for(int i = 1; i <= m; i++) {
        cin >> qry[i];
    }
    for(int i = m; i >= 1; i--) {
        ans[i] = solve(qry[i]);
    }

    for(int i = 1; i <= m; i++) {
        cout << ans[i];
        if(i != m) cout << '\n';
    }

    return 0;
}

C.

这题比较诈骗。正着考虑是比较困难的,不妨倒着来:如果能通过某种变化把 (a,b) 变成 (c,d),那么倒着进行操作就能把 (c,d) 变成 (a,b)。具体而言,如果原先有某种操作序列可以把 (a,b) 变成 (c,d),那么倒着做这个操作序列,把 aa+bba+b 分别改成 ccdddc,就可以把 (c,d) 变成 (a,b)

我们发现,这样转换的好处在于可以进行的操作是唯一确定的。正着进行操作时,我们不知道当前应该令 aa+b 还是 ba+b。但是反过来操作时,如果 cd,那么一定是用大的减去小的,否则就会出现负数,这是不合法的—— ab 一开始都是正的,不可能在某个时刻出现负数。

这样就搞定了!我们可以直接模拟这个过程,记录当前操作的次数,如果到某个时刻有 (a,b)=(c,d) 就返回答案,如果最终 c0d0 就无解。为了加速这个过程,可以使用除法,简单分类讨论即可。

Code
#include<bits/stdc++.h>

using namespace std;

typedef long long i64;
int t;
i64 a, b, c, d;

i64 ceil(i64 x, i64 y) { return x / y + !!(x % y); }

void print(i64 x) {
    cout << x;
    if(t) cout << '\n';
}

void solve() {
    cin >> a >> b >> c >> d;
    i64 ans = 0;
    // cerr << a << ' ' << b << ' ' << c << ' ' << d << '\n';
    while(c > 0 && d > 0) {
        // cerr << c << ' ' << d << '\n';
        if(a == c && b == d) {
            print(ans); return;
        }
        if(c < a || d < b) { print(-1); return; }
        if(a == c) {
            if(d < c) { print(-1); return; }
            i64 k = ceil(d - b, c);
            ans += k, d -= k * c;
        } else if(b == d) {
            if(c < d) { print(-1); return; }
            i64 k = ceil(c - a, d);
            ans += k, c -= k * d;
        } else {
            if(c > d) {
                i64 k = c / d;
                c -= k * d, ans += k;
            } else {
                i64 k = d / c;
                d -= k * c, ans += k;
            }
        }
    }
    print(-1);
}

int main() {
    cin.tie(nullptr) -> sync_with_stdio(false);

    cin >> t;
    while(t--) {
        solve();
    }

    return 0;
}

总结:正难则反。如果难以确定如何正着操作,不妨想想反着来,反着操作可能会有一些优秀的性质,例如可以唯一确定操作序列。

posted @   DengStar  阅读(53)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】
点击右上角即可分享
微信分享提示