伐木机不要石头!!!(hard version) - 题解
喵~ 主人,欢迎来到我的题解小屋!今天我们要征服的这道题,看起来有点小复杂,但别担心,只要跟着我的猫步,一步一步来,再难的树林我们也能轻松伐倒,的说!
标签与难度
标签: 数据结构, 线段树, 树状数组, 离线处理, 扫描线, 贪心, 思维 难度: 2500
题目大意喵~
简单来说,我们有一片排成一列的森林,里面有 棵树,每棵树都有一个坚硬度 。我们还有 把无敌手斧,每把斧头都有一个破坏程度 。一把斧头能砍倒一棵树,当且仅当斧头的破坏程度不小于树的坚硬度。每把斧头都是一次性的,用完就坏,喵~
然后呢,会有 次询问。每次询问会给我们一个区间 ,问我们如果只考虑这个区间里的树,用上我们所有的 把斧头,最多能砍倒多少棵树呢?
Hard version 和 Easy version 的区别就在于,Hard version 有很多很多次询问,所以我们需要一个更高效的办法来处理它们,呐。
解题思路分析
面对这种多组区间查询的问题,如果对每个查询都独立计算一次,那肯定会慢得像只追不到激光笔的小猫咪,超时的说!所以,我们的第一个想法就是:离线处理!
我们可以不按输入顺序回答询问,而是把所有询问存起来,用一种更聪明的方式批量处理。这里,扫描线算法就是我们的不二之选,喵~
扫描线大法好!
想象我们有一条扫描线从左到右(从位置 1 到 )扫过这片森林。当我们的扫描线停在位置 时,我们就一口气回答所有右端点是 的询问。
当扫描线在 时,我们考虑的是 这个前缀区间里的所有树。对于一个询问 ,我们实际上想知道的是,在 区间的树中,我们能砍掉多少。
贪心!如何最大化砍树数量?
对于一个固定的树木集合和我们所有的斧头,怎么砍才能砍最多呢?这其实是个很经典的贪心问题,喵~
- 把树木按坚硬度从小到大排序。
- 把斧头按破坏程度从小到大排序。
- 用最弱的斧头去砍最软的树,只要能砍动就行。
这样匹配下来,砍掉的树木数量就是最多的。
但是,在扫描线的过程中,树木的集合是动态变化的,每次都排序匹配还是太慢了。我们需要一个更高明的方法来判断,在加入了第 棵树后,我们最多能砍掉多少棵树。
平衡的艺术:斧头 vs 树木
我们可以换个角度思考。把所有的树木(在 区间内)和所有的斧头放在一起,按它们的数值(坚硬度/破坏度)从小到大排个序。
我们给每个斧头一个 的能量值,给每棵树一个 的能量值。如果我们要砍掉某棵树,就意味着要消耗一把斧头。
现在,把这些带能量值的物品排成一队。一个至关重要的结论是:对于这个队列的任意一个后缀,能量值的总和都必须大于等于 0。
为什么呢?想象一下,如果从某个值 开始,到最后的后缀和是负数,这意味着坚硬度/破坏度大于 的树比斧头还多!那这些坚硬的树肯定没法全部被砍掉了,因为连足够强力的斧头都没有,喵~
神奇数据结构登场!
所以,我们的任务就变成了:
- 随着扫描线向右移动到 ,将第 棵树加入我们的“待砍伐”集合。
- 维护一个表示“斧头-树木”能量平衡的系统。
- 检查这个系统的平衡状态(即,所有后缀和是否 )。
- 如果系统失衡了(某个后缀和 ),我们必须放弃一棵树来恢复平衡。
- 回答所有以 为右端点的询问。
这听起来就需要强大的数据结构来帮忙啦!
离散化:树的坚硬度和斧头的破坏度数值可能很大,但我们只关心它们的相对大小。所以,我们把 个 和 个 放在一起,排序去重,给它们一个从 到 的排名。这样,我们就可以用排名作为数据结构的下标了。
线段树:我们需要一个数据结构,能快速地:
- 在某个排名上,将能量值从 变成 (加入一棵树)。
- 查询全局的最小后缀和。
- 如果最小后缀和为负,找到是哪个后缀出了问题,以及在这个后缀范围内,我们应该放弃哪棵树。
这只线段树有点特别,它需要维护两个核心信息:
sum: 区间能量和。min_suffix: 区间最小后缀和以及其起始位置。一个节点的最小后缀和,要么是它右儿子的最小后缀和,要么是它左儿子的最小后缀和加上整个右儿子的和。min_pos: 为了决定放弃哪棵树,我们还需要知道在某个排名区间内,位置最靠前(即原始坐标最小)的树是哪棵。所以线段树每个节点还要维护一个区间内树的最小原始坐标。
树状数组 (Fenwick Tree):当扫描线在 时,我们怎么快速计算出 区间里到底有多少棵树被我们标记为“已砍伐”呢?树状数组最适合干这个了!我们用一个长度为 的树状数组,如果位置 的树被砍了,就在树状数组的第 位记上 ,否则是 。查询 的和就是
query(r) - query(l-1)。
完整流程
好,把所有零件组装起来,我们的伐木机器人就绪了,喵!
准备阶段:
- 把所有 棵树的坚硬度 和 把斧头的破坏度 混合在一起,进行离散化,得到它们各自的排名。
- 把 个询问
(l, r)按右端点r分类,挂在对应的r上。 - 初始化线段树。对于每个斧头的排名,其能量值为
+1。 - 初始化一个全为 0 的树状数组。
扫描阶段(
r从1遍历到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]的和。我们把答案存起来。收尾阶段:所有位置都扫完了,按原始询问顺序输出所有答案。
这个过程就像是,我们每遇到一棵新树,都贪心地想把它砍了。但如果发现斧头不够用了(平衡被打破),就不得不放弃之前砍过的一棵树。为了不影响未来的选择,我们总是放弃那个“最古老”(位置最靠前)的树。真是个深思熟虑的策略呢,喵~
代码实现
这是我根据上面的思路,精心重构的一份代码,加了很多注释,希望能帮到主人哦!
#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;
}复杂度分析
时间复杂度: 。
- 排序离散化需要 。
- 处理询问的挂载,如果排序的话是 ,用
vector数组是 。 - 主循环遍历 从 到 。在循环内部,
while循环可能会执行多次。每次修复,我们都会永久移除一棵树(将其pos设为POS_INF)。一棵树最多被加入一次,移除一次。所以总的修复次数不会超过 次。 - 每次线段树操作是 ,树状数组操作是 。
- 因此,扫描线部分的总复杂度近似为 。
- 综合起来,瓶颈在于扫描线部分,总时间复杂度是 。
空间复杂度: 。
- 存储树、斧头和询问需要 。
- 线段树需要 的空间。
- 树状数组需要 的空间。
- 所以总空间复杂度是 ,喵~
知识点总结
这道题是多种算法思想的完美结合,就像一盘精致的猫饭,营养均衡,味道超棒!
- 离线处理: 解决复杂区间查询问题的利器,特别是当查询可以被重新排序处理时。
- 扫描线: 一种将几何问题或区间问题转化为动态一维问题的思想,通常与离线处理结合使用。
- 贪心思想: 在确定砍伐策略时,我们找到了一个关键的贪心准则(后缀和非负),这是解题的理论基础。
- 数据结构组合拳:
- 线段树: 用于维护动态的、复杂的区间信息。本题中的线段树维护了区间和、最小后缀和、区间最小位置,功能非常强大!
- 树状数组: 用于高效地进行单点更新和前缀和查询,是计算最终答案的得力助手。
- 离散化: 将大范围、稀疏的数值映射到小范围、连续的整数,是使用基于下标的数据结构(如线段树)的前提。
希望我的讲解能帮助主人彻底理解这道题,喵!下次再遇到难题,也请随时来找我哦~