Skip to content

伐木机不要石头!!!(hard version) - 题解

喵~ 主人,欢迎来到我的题解小屋!今天我们要征服的这道题,看起来有点小复杂,但别担心,只要跟着我的猫步,一步一步来,再难的树林我们也能轻松伐倒,的说!

标签与难度

标签: 数据结构, 线段树, 树状数组, 离线处理, 扫描线, 贪心, 思维 难度: 2500

题目大意喵~

简单来说,我们有一片排成一列的森林,里面有 nn 棵树,每棵树都有一个坚硬度 aia_i。我们还有 mm 把无敌手斧,每把斧头都有一个破坏程度 bjb_j。一把斧头能砍倒一棵树,当且仅当斧头的破坏程度不小于树的坚硬度。每把斧头都是一次性的,用完就坏,喵~

然后呢,会有 kk 次询问。每次询问会给我们一个区间 [l,r][l, r],问我们如果只考虑这个区间里的树,用上我们所有的 mm 把斧头,最多能砍倒多少棵树呢?

Hard version 和 Easy version 的区别就在于,Hard version 有很多很多次询问,所以我们需要一个更高效的办法来处理它们,呐。

解题思路分析

面对这种多组区间查询的问题,如果对每个查询都独立计算一次,那肯定会慢得像只追不到激光笔的小猫咪,超时的说!所以,我们的第一个想法就是:离线处理

我们可以不按输入顺序回答询问,而是把所有询问存起来,用一种更聪明的方式批量处理。这里,扫描线算法就是我们的不二之选,喵~

扫描线大法好!

想象我们有一条扫描线从左到右(从位置 1 到 nn)扫过这片森林。当我们的扫描线停在位置 rr 时,我们就一口气回答所有右端点是 rr 的询问。

当扫描线在 rr 时,我们考虑的是 [1,r][1, r] 这个前缀区间里的所有树。对于一个询问 [l,r][l, r],我们实际上想知道的是,在 [l,r][l, r] 区间的树中,我们能砍掉多少。

贪心!如何最大化砍树数量?

对于一个固定的树木集合和我们所有的斧头,怎么砍才能砍最多呢?这其实是个很经典的贪心问题,喵~

  1. 把树木按坚硬度从小到大排序。
  2. 把斧头按破坏程度从小到大排序。
  3. 用最弱的斧头去砍最软的树,只要能砍动就行。

这样匹配下来,砍掉的树木数量就是最多的。

但是,在扫描线的过程中,树木的集合是动态变化的,每次都排序匹配还是太慢了。我们需要一个更高明的方法来判断,在加入了第 rr 棵树后,我们最多能砍掉多少棵树。

平衡的艺术:斧头 vs 树木

我们可以换个角度思考。把所有的树木(在 [1,r][1, r] 区间内)和所有的斧头放在一起,按它们的数值(坚硬度/破坏度)从小到大排个序。

我们给每个斧头一个 +1+1 的能量值,给每棵树一个 1-1 的能量值。如果我们要砍掉某棵树,就意味着要消耗一把斧头。

现在,把这些带能量值的物品排成一队。一个至关重要的结论是:对于这个队列的任意一个后缀,能量值的总和都必须大于等于 0

为什么呢?想象一下,如果从某个值 VV 开始,到最后的后缀和是负数,这意味着坚硬度/破坏度大于 VV 的树比斧头还多!那这些坚硬的树肯定没法全部被砍掉了,因为连足够强力的斧头都没有,喵~

神奇数据结构登场!

所以,我们的任务就变成了:

  1. 随着扫描线向右移动到 rr,将第 rr 棵树加入我们的“待砍伐”集合。
  2. 维护一个表示“斧头-树木”能量平衡的系统。
  3. 检查这个系统的平衡状态(即,所有后缀和是否 0\ge 0)。
  4. 如果系统失衡了(某个后缀和 <0<0),我们必须放弃一棵树来恢复平衡。
  5. 回答所有以 rr 为右端点的询问。

这听起来就需要强大的数据结构来帮忙啦!

  1. 离散化:树的坚硬度和斧头的破坏度数值可能很大,但我们只关心它们的相对大小。所以,我们把 nnaia_immbjb_j 放在一起,排序去重,给它们一个从 11n+mn+m 的排名。这样,我们就可以用排名作为数据结构的下标了。

  2. 线段树:我们需要一个数据结构,能快速地:

    • 在某个排名上,将能量值从 00 变成 1-1(加入一棵树)。
    • 查询全局的最小后缀和。
    • 如果最小后缀和为负,找到是哪个后缀出了问题,以及在这个后缀范围内,我们应该放弃哪棵树。

    这只线段树有点特别,它需要维护两个核心信息:

    • sum: 区间能量和。
    • min_suffix: 区间最小后缀和以及其起始位置。一个节点的最小后缀和,要么是它右儿子的最小后缀和,要么是它左儿子的最小后缀和加上整个右儿子的和。

      min_suffix(p)=min(min_suffix(rs),min_suffix(ls)+sum(rs))\text{min\_suffix}(p) = \min(\text{min\_suffix}(rs), \text{min\_suffix}(ls) + \text{sum}(rs))

    • min_pos: 为了决定放弃哪棵树,我们还需要知道在某个排名区间内,位置最靠前(即原始坐标最小)的树是哪棵。所以线段树每个节点还要维护一个区间内树的最小原始坐标。
  3. 树状数组 (Fenwick Tree):当扫描线在 rr 时,我们怎么快速计算出 [l,r][l, r] 区间里到底有多少棵树被我们标记为“已砍伐”呢?树状数组最适合干这个了!我们用一个长度为 nn 的树状数组,如果位置 ii 的树被砍了,就在树状数组的第 ii 位记上 11,否则是 00。查询 [l,r][l, r] 的和就是 query(r) - query(l-1)

完整流程

好,把所有零件组装起来,我们的伐木机器人就绪了,喵!

  1. 准备阶段

    • 把所有 nn 棵树的坚硬度 aia_imm 把斧头的破坏度 bjb_j 混合在一起,进行离散化,得到它们各自的排名。
    • kk 个询问 (l, r) 按右端点 r 分类,挂在对应的 r 上。
    • 初始化线段树。对于每个斧头的排名,其能量值为 +1
    • 初始化一个全为 0 的树状数组。
  2. 扫描阶段r1 遍历到 n): a. 加入新树:把第 r 棵树加入考虑范围。在线段树中,找到这棵树坚硬度对应的排名,将其能量值设为 -1,并记录它的原始位置是 r。同时,在树状数组的第 r 位加 1,表示我们暂时认为这棵树能被砍掉。

    b. 检查与修复平衡

    • 查询线段树的全局最小后缀和。
    • 只要这个值小于 0,就说明我们的假设(所有树都能砍)太乐观了,平衡被打破了!
    • 设最小后缀和是从排名 p_min 开始的,我们就在排名区间 [p_min, n+m] 中,找到原始位置最靠前的那棵树(记为 victim_tree)。它就是我们“忍痛割爱”的目标。
    • 修复:在树状数组中,将 victim_tree 对应位置的值减 1(表示它最终没被砍掉)。在线段树中,将 victim_tree 对应排名的能量值改回 0,并把它从“候选受害者”名单中移除(比如把它的原始位置设为无穷大),这样下次就不会再选中它了。
    • 重复这个检查与修复的过程,直到最小后缀和重新变为非负。

    c. 回答询问:经过修复,现在系统是平衡的。对于所有右端点为 r 的询问 (l, id),其答案就是树状数组在区间 [l, r] 的和。我们把答案存起来。

  3. 收尾阶段:所有位置都扫完了,按原始询问顺序输出所有答案。

这个过程就像是,我们每遇到一棵新树,都贪心地想把它砍了。但如果发现斧头不够用了(平衡被打破),就不得不放弃之前砍过的一棵树。为了不影响未来的选择,我们总是放弃那个“最古老”(位置最靠前)的树。真是个深思熟虑的策略呢,喵~

代码实现

这是我根据上面的思路,精心重构的一份代码,加了很多注释,希望能帮到主人哦!

cpp
#include <iostream>
#include <vector>
#include <algorithm>
#include <map>

using namespace std;

const long long INF = 1e18;
const int POS_INF = 1e9 + 7;

// 用于离散化的节点
struct ValueNode {
    int value;
    int original_index; // 负数: 树, 正数: 斧头 (虽然斧头的index用不上)

    bool operator<(const ValueNode& other) const {
        if (value != other.value) {
            return value < other.value;
        }
        return original_index < other.original_index; // 斧头优先(正数小)
    }
};

// 询问结构体
struct Query {
    int l, id;
};

// 线段树节点
struct SegNode {
    long long sum; // 区间能量和 (+1斧头, -1树)
    pair<long long, int> min_suffix; // {最小后缀和, 起始排名}
    pair<int, int> min_pos; // {树的最小原始位置, 对应的排名}
};

vector<SegNode> seg_tree;
int total_rank;

// --- 线段树操作 ---
void push_up(int p) {
    int ls = p * 2, rs = p * 2 + 1;
    // 更新区间和
    seg_tree[p].sum = seg_tree[ls].sum + seg_tree[rs].sum;
    // 更新最小位置
    seg_tree[p].min_pos = min(seg_tree[ls].min_pos, seg_tree[rs].min_pos);
    // 更新最小后缀和
    // 最小后缀和要么在右子树,要么是左子树的某个后缀+右子树的和
    if (seg_tree[rs].min_suffix.first < seg_tree[ls].min_suffix.first + seg_tree[rs].sum) {
        seg_tree[p].min_suffix = seg_tree[rs].min_suffix;
    } else {
        seg_tree[p].min_suffix = {seg_tree[ls].min_suffix.first + seg_tree[rs].sum, seg_tree[ls].min_suffix.second};
    }
}

void build(int p, int l, int r) {
    if (l == r) {
        seg_tree[p] = {0, {0, l}, {POS_INF, l}};
        return;
    }
    int mid = l + (r - l) / 2;
    build(p * 2, l, mid);
    build(p * 2 + 1, mid + 1, r);
    push_up(p);
}

// 单点更新
void update(int p, int l, int r, int target_rank, int energy, int pos) {
    if (l == r) {
        seg_tree[p].sum = energy;
        seg_tree[p].min_suffix = {energy, l};
        seg_tree[p].min_pos = {pos, l};
        return;
    }
    int mid = l + (r - l) / 2;
    if (target_rank <= mid) {
        update(p * 2, l, mid, target_rank, energy, pos);
    } else {
        update(p * 2 + 1, mid + 1, r, target_rank, energy, pos);
    }
    push_up(p);
}

// 区间查询最小位置
pair<int, int> query_min_pos(int p, int l, int r, int ql, int qr) {
    if (ql <= l && r <= qr) {
        return seg_tree[p].min_pos;
    }
    int mid = l + (r - l) / 2;
    pair<int, int> res = {POS_INF, 0};
    if (ql <= mid) {
        res = min(res, query_min_pos(p * 2, l, mid, ql, qr));
    }
    if (qr > mid) {
        res = min(res, query_min_pos(p * 2 + 1, mid + 1, r, ql, qr));
    }
    return res;
}

// --- 树状数组操作 ---
vector<int> bit;
int N;

void bit_add(int idx, int val) {
    for (; idx <= N; idx += idx & -idx) {
        bit[idx] += val;
    }
}

int bit_query(int idx) {
    int sum = 0;
    for (; idx > 0; idx -= idx & -idx) {
        sum += bit[idx];
    }
    return sum;
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int m, k;
    cin >> N >> m >> k;

    vector<ValueNode> all_values;
    vector<int> tree_hardness(N + 1);
    for (int i = 1; i <= N; ++i) {
        cin >> tree_hardness[i];
        all_values.push_back({tree_hardness[i], -i}); // 树的原始下标用负数表示
    }
    for (int i = 1; i <= m; ++i) {
        int axe_power;
        cin >> axe_power;
        all_values.push_back({axe_power, i}); // 斧头的下标用正数
    }

    sort(all_values.begin(), all_values.end());

    map<int, int> tree_pos_to_rank;
    vector<int> axe_ranks;
    total_rank = all_values.size();
    for (int i = 0; i < total_rank; ++i) {
        if (all_values[i].original_index < 0) {
            tree_pos_to_rank[-all_values[i].original_index] = i + 1;
        } else {
            axe_ranks.push_back(i + 1);
        }
    }

    vector<vector<Query>> queries_by_r(N + 1);
    vector<long long> answers(k);
    for (int i = 0; i < k; ++i) {
        int l, r;
        cin >> l >> r;
        queries_by_r[r].push_back({l, i});
    }

    seg_tree.resize(total_rank * 4 + 4);
    build(1, 1, total_rank);

    for (int rank : axe_ranks) {
        update(1, 1, total_rank, rank, 1, POS_INF); // 斧头能量+1, 位置设为无穷大
    }

    bit.resize(N + 1, 0);

    for (int r = 1; r <= N; ++r) {
        // 1. 加入新树
        int rank_of_tree_r = tree_pos_to_rank[r];
        update(1, 1, total_rank, rank_of_tree_r, -1, r); // 能量-1, 记录原始位置r
        bit_add(r, 1); // 假设能砍掉

        // 2. 检查与修复平衡
        while (seg_tree[1].min_suffix.first < 0) {
            int start_rank = seg_tree[1].min_suffix.second;
            pair<int, int> victim = query_min_pos(1, 1, total_rank, start_rank, total_rank);
            
            int victim_pos = victim.first;
            int victim_rank = victim.second;

            bit_add(victim_pos, -1); // 放弃这棵树
            update(1, 1, total_rank, victim_rank, 0, POS_INF); // 恢复能量, 移除候选
        }

        // 3. 回答询问
        for (const auto& q : queries_by_r[r]) {
            answers[q.id] = bit_query(r) - bit_query(q.l - 1);
        }
    }

    for (int i = 0; i < k; ++i) {
        cout << answers[i] << "\n";
    }

    return 0;
}

复杂度分析

  • 时间复杂度: O((n+m)log(n+m)+klogk+nlog(n+m)logn)O((n+m)\log(n+m) + k\log k + n \cdot \log(n+m) \cdot \log n)

    • 排序离散化需要 O((n+m)log(n+m))O((n+m)\log(n+m))
    • 处理询问的挂载,如果排序的话是 O(klogk)O(k \log k),用 vector 数组是 O(k)O(k)
    • 主循环遍历 rr11nn。在循环内部,while 循环可能会执行多次。每次修复,我们都会永久移除一棵树(将其 pos 设为 POS_INF)。一棵树最多被加入一次,移除一次。所以总的修复次数不会超过 nn 次。
    • 每次线段树操作是 O(log(n+m))O(\log(n+m)),树状数组操作是 O(logn)O(\log n)
    • 因此,扫描线部分的总复杂度近似为 O(n(log(n+m)+logn))O(n \cdot (\log(n+m) + \log n))
    • 综合起来,瓶颈在于扫描线部分,总时间复杂度是 O((n+m)log(n+m)+nlog(n+m))O((n+m)\log(n+m) + n \log(n+m))
  • 空间复杂度: O(n+m+k)O(n+m+k)

    • 存储树、斧头和询问需要 O(n+m+k)O(n+m+k)
    • 线段树需要 O(4(n+m))O(4 \cdot (n+m)) 的空间。
    • 树状数组需要 O(n)O(n) 的空间。
    • 所以总空间复杂度是 O(n+m+k)O(n+m+k),喵~

知识点总结

这道题是多种算法思想的完美结合,就像一盘精致的猫饭,营养均衡,味道超棒!

  1. 离线处理: 解决复杂区间查询问题的利器,特别是当查询可以被重新排序处理时。
  2. 扫描线: 一种将几何问题或区间问题转化为动态一维问题的思想,通常与离线处理结合使用。
  3. 贪心思想: 在确定砍伐策略时,我们找到了一个关键的贪心准则(后缀和非负),这是解题的理论基础。
  4. 数据结构组合拳:
    • 线段树: 用于维护动态的、复杂的区间信息。本题中的线段树维护了区间和、最小后缀和、区间最小位置,功能非常强大!
    • 树状数组: 用于高效地进行单点更新和前缀和查询,是计算最终答案的得力助手。
    • 离散化: 将大范围、稀疏的数值映射到小范围、连续的整数,是使用基于下标的数据结构(如线段树)的前提。

希望我的讲解能帮助主人彻底理解这道题,喵!下次再遇到难题,也请随时来找我哦~