Cookies 题解

校内模拟赛搬了这道题,考场上想出来的一个感觉更加无脑的方法,不需要 dp,但是实现起来会更恶心,细节多很多,码量大概是其它做法的两倍,仅供参考。


首先我们可以用线段树上二分来求出根节点到每个结点的路径能够吃掉的最大饼干数量。

具体的做法是:树上 dfs 的时候维护从根到当前节点的链上每个结点的饼干。贪心地想,想要在相同时间内吃掉更多的饼干,那么肯定是需要时间更短的饼干先吃。

所以对吃掉饼干需要的时间建线段树,维护某个时间区间(这个区间代表着它包含的每个饼干需要的时间下限和上限)吃掉所有的饼干所需时间以及饼干数量,要求出能吃掉的最大数量的饼干就可以线段树上二分解决了。

这一步需要注意,每次进入下一个结点的时候要将剩余时间减去边权的两倍,因为往返的时间都要计算。

接着我们可以将求得的所有答案从大到小排序。

因为先手是希望吃掉最多的饼干,所以后手肯定是想要将这些可能的答案从大到小依次给抹除。

当然,后手不是直接将这些答案去掉的,是通过让先手不能走这条链上的某些边来限制先手的。

于是我们可以考虑这条链上可以被加上限制的边,这些边应该是这条链上除了与根节点直接相连以外的所有边,并且只用割掉这些边中的一条就够了,割多了没用。

会是“深度”最小的那条边吗(这里的“深度”是指连接的两结点中较小的深度)?不是,反例很容易举出来,就是构造一个全局次大值与之相连。

那是不是割掉“深度”最大的边呢?手动模拟一下感觉好像是对的,但是感觉不好证明。其实可以用一个贪心的思路去理解:在同一条链上,深度越小的点覆盖到的子树越大,对应的可以去掉的答案范围也更大,应该留给更需要割与这个点相连的边的答案。

这一点类似与一个非常经典的贪心问题,就是每个任务有一个权重和一个最晚的完成时间,要求一个安排这些任务的方案。我们每次会选择在可选范围内最晚的时间去完成,因为越早的时间可以分配给的任务数量越多,它的可利用价值就更大,应该安排给时间范围更窄的任务。

回到这一题也是同样的道理,割掉哪一条边就对应着“时间”,每次割边给出的链就对应着“最晚完成时间”。

贪心策略已经有了,但是现在还有一个问题:每次后手只能割掉与所在点相连的一条边,如果按照上面的贪心策略可能会多割掉一些边或是在还没走到一个点的时候就割掉了下面的边。如何证明这种做法的正确性?

其实我们刚刚所谓的“割边”并不是真正的割掉了某条边,而是排除掉了先手可能的一些答案,我们选择了一条边“割掉”相当于在这条边上打了个标记,如果先手走到了这条边连接的点上,那么后手割掉这条边是最优的。所以最后如果真正模拟一边先手和后手的决策,仍然是符合题目要求的。

当然不用真的再模拟一遍,因为在排除答案时如果某个答案无法通过割边排除掉,直接输出这个答案就好。

下面是关于代码实现的一些细节:

在“割边”,也就是对边打标记的时候,要注意需要把标记打在深度较低的点上,因为决策是在走到每个结点上做出的,而且这样做也会更方便我们之后判断“割”哪条边的环节。

在对一条边打了标记过后,需要把以深度较大的结点为根的子树上的所有节点打上标记(这里的标记是另一个),因为“割掉”这条边的时候可能会把一些另外的答案排除掉,需要对整棵子树标记一下。在判断一个答案能否被排除掉的时候也要先检查有没有被打过标记。子树覆盖和单点查询就再另外用一个数据结构对 dfn 序维护一下就好了,我的代码实现里用了线段树。

我在考场上脑抽了最开始统计答案的时候没用普通线段树而是用了主席树,实际上只需要在 dfs 的过程中,进入一个结点的时候加入这个节点的饼干,退出结点的时候删掉这上面的饼干就行了。

时间复杂度的话是 $\mathcal{O}(n \log n)$,涉及到了排序,线段树,并查集,倍增。

快速找到应该被“割掉”的边这一部分我是用并查集实现的,因为打标记的时候正好可以通过更改结点的“父结点”(这里的父结点是在并查集中的)来达到打标记的效果,就是以后访问到这个结点的时候会直接跳过然后访问它的“父节点”。

我的做法大概是一个数据结构的融合,就是哪里的时间复杂度高了哪里就用数据结构优化一下,所以会很丑陋也不是很优雅,可以配合代码看一下。对于贪心的证明可能有一点模糊,欢迎提问。

代码里在比较关键的地方写了详细的注释。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int n, cnt, x[100005], t[100005], p[100005], l[100005], father[100005], dfn[100005], edf[100005], dep[100005], fa[100005][18];
ll tim[100005];
vector<int> g[100005];
vector< pair<ll, int> > v;
struct Segment_Tree {//主席树
    struct Segment {
        int lc, rc;
        ll sum, cnt;
        Segment(int Lc = 0, int Rc = 0, ll Sum = 0, ll Cnt = 0): lc(Lc), rc(Rc), sum(Sum), cnt(Cnt) {}
        Segment operator = (const Segment& _) {
            lc = _.lc, rc = _.rc, sum = _.sum, cnt = _.cnt;
            return *this;
        }
    } t[10000005];
    int idx, root[100005];
    #define mid ((l + r) >> 1)
    int new_node(int k) {
        ++idx;
        t[idx] = t[k];
        return idx;
    }
    int change(int k, const ll& pos, const ll& v, int l = 1, int r = 1000000) {//加入一个饼干
        int rt = new_node(k);
        t[rt].sum += pos * v, t[rt].cnt += v;
        if(l == r) return rt;
        if(pos <= mid) t[rt].lc = change(t[rt].lc, pos, v, l, mid);
        else t[rt].rc = change(t[rt].rc, pos, v, mid + 1, r);
        return rt;
    }
    ll ask(int k, ll rk, int l = 1, int r = 1000000) {//主席树上二分
        if(rk >= t[k].sum) return t[k].cnt;
        if(rk <= 0) return 0;
        if(l == r) return min(rk / l, t[k].cnt);
        if(t[t[k].lc].sum >= rk) return ask(t[k].lc, rk, l, mid);
        else return t[t[k].lc].cnt + ask(t[k].rc, rk - t[t[k].lc].sum, mid + 1, r);
    }
    #undef mid
} tree;
struct SGT {//这颗线段树是处理子树的标记的,用了标记永久化,也可以改成懒标记
    int L, R, l[400005], r[400005];
    bool tag[400005];
    #define lc (k << 1)
    #define rc (lc | 1)
    #define mid ((l[k] + r[k]) >> 1)
    void build(int k) {
        if(l[k] == r[k]) return;
        l[lc] = l[k], r[lc] = mid, l[rc] = mid + 1, r[rc] = r[k];
        build(lc), build(rc);
    }
    void change(int k) {
        if(tag[k]) return;
        if(L <= l[k] && r[k] <= R) {
            tag[k] = true;
            return;
        }
        if(L <= mid) change(lc);
        if(R > mid) change(rc);
    }
    bool get(int k, const int& pos) {
        if(tag[k]) return true;
        if(l[k] == r[k]) return false;
        if(pos <= mid) return get(lc, pos);
        else return get(rc, pos);
    }
    void change(int lt, int rt) {
        L = lt, R = rt;
        return change(1);
    }
} tr;
int findset(int x) {return father[x] == x ? x : father[x] = findset(father[x]);}//并查集
void dfs(int now) {//搜索得到dfn序,处理倍增数组
    dep[now] = dep[p[now]] + 1, fa[now][0] = p[now];
    for(int i = 1; i <= __lg(n); ++i) fa[now][i] = fa[fa[now][i - 1]][i - 1];
    dfn[now] = ++cnt;
    for(const auto& i : g[now]) {
        dfs(i);
    }
    edf[now] = cnt;
}
bool check(int pos) {
    if(pos == 1) return false;//特判根节点的情况,但是好像没什么用?
    if(tr.get(1, dfn[pos])) return true;//被打过标记了就不用再多割别的边了
    int ck = findset(p[pos]);
    if(ck == 1) return false;//如果需要割与根节点相连的边就可以直接返回了,因为先手是先移动然后后手再割边的,与根节点相连的边不可能被割掉
    while(dep[pos] > dep[ck] + 1) pos = fa[pos][__lg(dep[pos] - dep[ck] - 1)];//这里是倍增找到需要打标记的子树的根节点,边的标记是打在深度较低的点上的,但是给子树打标记需要找到深度较大的点
    tr.change(dfn[pos], edf[pos]);//给子树打标记
    father[ck] = father[p[ck]];//给边打标记,这里把深度较低的点的“父结点”更改了的话以后就不会访问到这个结点了。
    return true;
}
int main() {
    ios::sync_with_stdio(0);
    cin.tie(0), cout.tie(0);
    cin >> n >> tim[0];
    for(int i = 1; i <= n; ++i) cin >> x[i];
    for(int i = 1; i <= n; ++i) cin >> t[i];
    for(int i = 2; i <= n; ++i) {
        cin >> p[i] >> l[i];
        g[p[i]].push_back(i);
    }
    //考试的时候看到题目的父结点输入方式就想到了这样处理
    //实际的话换成dfs就可以避免使用主席树了,我这样处理本质上是bfs,所以得用主席树
    for(int i = 1; i <= n; ++i) {//这一步是求走到每个结点的时候可以获得的最大饼干数量
        father[i] = i;
        tim[i] = tim[p[i]] - l[i] - l[i];
        tree.root[i] = tree.change(tree.root[p[i]], t[i], x[i]);
        v.push_back(make_pair(-tree.ask(tree.root[i], tim[i]), i));
    }
    stable_sort(v.begin(), v.end());//将可能的答案从大到小排序
    tr.l[1] = 1, tr.r[1] = n;//这里的线段树是“割边”的时候给子树打标记用的线段树
    tr.build(1);
    dfs(1);//处理dfn序,同时也处理倍增数组
    for(const auto& i : v) {
        if(!check(i.second)) {
            cout << -i.first;//无法把这个答案去掉的时候就输出答案
            break;
        }
    }
    return 0;
}
posted @ 2023-12-11 17:11  A_box_of_yogurt  阅读(4)  评论(0编辑  收藏  举报  来源
Document