题解:Atcoder Beginner Contest abc467 F - Email Scheduling Optimization

题目描述

给你长度为 N 的正整数序列 A=(A_1,A_2,\\dots,A_N)B=(B_1,B_2,\\dots,B_N)

给你 Q 个查询。

每个查询都属于以下两种类型之一。

  • 1 i x :将 A_i 更改为 x
  • 2 i x :将 B_i 更改为 x

处理完每个查询后,请解决以下问题。

  • 高桥需要向 N 家公司分别发送一封邮件,并收到每家公司的回复。
  • 写电子邮件发送给第 j 个公司需要 A_j 分钟,回复在发送后 B_j 分钟到达。
  • 他从 0 开始写邮件。
  • 他可以按照任意顺序写 N 封邮件,但不能同时写两封或多封邮件。
  • 求他收到所有回复的最短时间?
  • 假设发送电子邮件所花费的时间可以忽略不计。

数据范围

  • 1 \\leq N \\leq 10\^5
  • 1 \\leq Q \\leq 10\^5
  • 1 \\leq A_j,B_j \\leq 10\^9
  • 在每个查询中, 1 \\leq i \\leq N .
  • 在每个查询中, 1 \\leq x \\leq 10\^9
  • 所有输入值均为整数。

输入格式

复制代码
N Q
A1 A2 ...... An
B1 B2 ...... Bn
query-1
query-2
.
.
.
.
.
.
query-Q

每个查询 \\mathrm{query}_q 、查询类型( 12 )、 ix 依次给出,中间用空格隔开。

也就是说,每个查询以以下两种格式之一给出:

复制代码
1 i x

2 i x

解题思路

一、题意转化与数学建模

1. 场景描述

有 N 个任务,任务 j:

  • 书写耗时 \(A_j\),任务必须串行执行;
  • 完成书写的时刻为 S,回复到达时刻为 \(S+B_j\); 最终全部收到回复的时间 = 所有回复到达时间的最大值。 设书写顺序为 \(p_1,p_2,\dots,p_N\),定义前缀和:

\(S_k = A_{p_1}+A_{p_2}+\dots+A_{p_k}\) 答案为:

\(\max_{1\le k\le N}(S_k+B_{p_k})\) 目标:排列任务顺序,让这个最大值尽可能小;同时支持单点修改 \(A_i/B_i\),每次修改后输出最优答案。

2. 数据范围约束

\(N,Q\le 10^5\),单次操作必须 \(O(\log N)\),暴力排序 \(O(N\log N)\) 无法通过。

二、贪心排序结论(交换论证证明)

最优排序规则

任务按 \(B_j\) 从大到小降序排列

证明(两任务交换法)

仅两个任务 \(X(A_1,B_1),Y(A_2,B_2)\):

  1. 先 X 后 Y:结果 \(\max(A_1+B_1,\ A_1+A_2+B_2)\)
  2. 先 Y 后 X:结果 \(\max(A_2+B_2,\ A_1+A_2+B_1)\)

若 \(B_1>B_2\),交换后第二项 \(A_1+A_2+B_1\) 会变大,总答案只会变差; 推广到 N 个任务:任意相邻逆序(小 B 在前、大 B 在后)交换后答案不增,全局最优一定是 B 降序。

问题简化

现在所有元素固定按 B 降序排成有序序列,我们只需要动态维护:

  1. 序列有序(按 B 降序);
  2. 区间子树总 A 和(求前缀和);
  3. 区间内 \(\max(前缀A和+B)\); 每次单点修改后快速查询全局最大值。

三、排序关键字设计(适配平衡树)

平衡树需要唯一有序 key 实现二分分裂: pair 比较规则:先比 first,再比 second。 令 key = (-B_i, i)

  • B 越大,\(-B_i\) 越小,会排在树左侧;
  • 下标 i 作为第二关键字,保证 B 相同时 key 不重复,删除时能精准定位单个节点。

四、数据结构选择:FHQ-Treap(无旋 Treap)

选择理由

  1. split+merge 天然维护有序序列,插入 / 删除单点 \(O(\log N)\);
  2. 可在每个节点向上合并维护区间信息(总和、区间最大值);
  3. 相比旋转 Treap,区间信息合并(pushup/pull)写法更直观。

节点维护信息:

复制代码
struct Node{
    int l,r,pri;    // 左右儿子、随机堆权维护堆性质
    ll A,B;         // 当前任务A、B值
    ll sum;         // 子树内所有A的总和
    ll mx;          // 子树区间内 max(前缀A和+B)
    pii key;        // 排序关键字 (-B_i,i)
}

核心合并函数 pull(pushup)

设当前节点 t,左子树 L,右子树 R:

  1. 子树总和更新:

\(trt.sum = trL.sum + trt.A + trR.sum\) 2. 区间最大值更新(三种候选取最大):

  • 最大值出现在左子树内部:\(trL.mx\)
  • 最大值恰好是当前节点:左子树全部 A + 当前 A + 当前 B
  • 最大值出现在右子树内部:左子树全部 A + 当前 A + 右子树内部最优值

\(trt.mx = \max\big(trL.mx,\ trL.sum+trt.A+trt.B,\ trL.sum+trt.A+trR.mx\big)\) 空节点 \(tr0.mx\) 设为极小负无穷,避免干扰 max 计算。

五、FHQ 基础操作作用

  1. split_less(root, key, a, b) 将树拆成两棵:a 存所有 key < 目标 key(B 更大的元素),b 存剩余;用于插入。
  2. split_leq(root, key, a, b) 拆出所有 key ≤ 目标 key,两次 split 精准分离单个目标节点,用于删除。
  3. merge(a,b) 保证 a 内所有 key < b 内所有 key,按随机权小根堆合并,合并后自动 pull 更新信息。
  4. insert_node(id) 分裂出左树,合并「左树 + 当前节点 + 右树」完成插入。
  5. erase_by_key(key) 两次 split 取出唯一目标节点,合并剩余两棵树,返回被摘下的节点(复用修改)。

六、处理查询流程

每次修改操作统一流程:

  1. 摘出节点:用旧 key 删除该元素,拿到独立节点;
  2. 修改属性
    • type1(改 A):仅更新节点 A,调用 pull 刷新 sum、mx;
    • type2(改 B):更新节点 B,同时更新排序 key (-新B,i),再 pull;
  3. 重新插入:把修改后的节点放回平衡树;
  4. 输出答案 :根节点的 tr[root].mx 就是当前最优答案。

最后,输出trroot.mx即可。

完整代码

复制代码
#include<bits/stdc++.h>
#define fr1(i,a,b) for(int (i)=(a);(i)<=(b);(i)++)
#define fr2(i,a,b) for(int (i)=(a);(i)>=(b);(i)--)
#define fv(i,p) for(auto (i):(p))
#define ll long long
#define ull unsigned ll
#define pii pair<int,int>
#define pll pair<ll,ll>
#define _1st first
#define _2nd second
#define elif else if
#define debug cout<<endl<<"-------------------------------------------------------------"<<endl
using namespace std;
int n,q;
const int MAXN=100000+5;
const ll NEG_INF=-(1LL<<60);
struct Node{
	int l,r,pri;
	ll A,B;
	ll sum,mx;
	pii key;
}tr[MAXN];
int root=0;
void pull(int t){
	if(!t)return;
	int l=tr[t].l,r=tr[t].r;
	tr[t].sum=tr[l].sum+tr[t].A+tr[r].sum;
	tr[t].mx=max({tr[l].mx,tr[l].sum+tr[t].A+tr[t].B,tr[l].sum+tr[t].A+tr[r].mx});
}
void split_less(int t,pii key,int &a,int &b){
	if(!t){a=b=0;return;}
	if(tr[t].key<key){
		a=t;
		split_less(tr[t].r,key,tr[t].r,b);
		pull(t);
	}else{
		b=t;
		split_less(tr[t].l,key,a,tr[t].l);
		pull(t);
	}
}
void split_leq(int t,pii key,int &a,int &b){
	if(!t){a=b=0;return;}
	if(tr[t].key<=key){
		a=t;
		split_leq(tr[t].r,key,tr[t].r,b);
		pull(t);
	}else{
		b=t;
		split_leq(tr[t].l,key,a,tr[t].l);
		pull(t);
	}
}
int merge(int a,int b){
	if(!a||!b)return a?a:b;
	if(tr[a].pri<tr[b].pri){
		tr[a].r=merge(tr[a].r,b);
		pull(a);
		return a;
	}else{
		tr[b].l=merge(a,tr[b].l);
		pull(b);
		return b;
	}
}
void insert_node(int id){
	int a,b;
	split_less(root,tr[id].key,a,b);
	root=merge(merge(a,id),b);
}
int erase_by_key(pii key){
	int a,b,c,d;
	split_less(root,key,a,b);
	split_leq(b,key,c,d);
	root=merge(a,d);
	if(c){
		tr[c].l=tr[c].r=0;
		pull(c);
	}
	return c;
}
int main(){
	ios::sync_with_stdio(false);
	cin.tie(NULL);cout.tie(NULL);
	cin>>n>>q;
	vector<ll>a(n+1),b(n+1);
	fr1(i,1,n)cin>>a[i];
	fr1(i,1,n)cin>>b[i];
	tr[0].mx=NEG_INF;
	mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());
	fr1(i,1,n){
		tr[i].l=tr[i].r=0;
		tr[i].pri=(int)rng();
		tr[i].A=a[i];
		tr[i].B=b[i];
		tr[i].key={-(int)b[i],i};
		tr[i].sum=a[i];
		tr[i].mx=a[i]+b[i];
		insert_node(i);
	}
	while(q--){
		int type,i;
		ll x;
		cin>>type>>i>>x;
		if(type==1){
			int node=erase_by_key(tr[i].key);
			tr[node].A=x;
			pull(node);
			insert_node(node);
		}else{
			int node=erase_by_key(tr[i].key);
			tr[node].B=x;
			tr[node].key={-(int)x,i};
			pull(node);
			insert_node(node);
		}
		cout<<tr[root].mx<<'\n';
	}
	return 0;
}
相关推荐
郝学胜-神的一滴2 小时前
中级OpenGL教程 023:Assimp模型加载全解——从源码到架构的骈文探秘
c++·unity·游戏引擎·cmake·unreal engine·opengl
星恒随风2 小时前
C++ STL 详解:list 的使用、迭代器失效、模拟实现与 vector 对比
开发语言·数据结构·c++·笔记·学习·list
牢姐与蒯2 小时前
c++之异常
开发语言·c++·c++11
此生决int2 小时前
深入理解C++系列(02)——类和对象(上)
开发语言·c++
zjun10013 小时前
C++:单例模式
c++·单例模式
同勉共进12 小时前
记一例 vibe coding + gcc bug 导致的线程池死锁问题
c++·线程池·gcc·死锁·vibe coding
我叫洋洋12 小时前
C ++ [ hello world ]
c语言·c++·算法
王维同学16 小时前
[自学][Windows C++]RunOnceEx 注册表分组键的安全遍历
c++·windows·安全·开源
Scott9999HH17 小时前
告别流量波动玄学!从法拉第电磁定律到底层 C/C++ 流量累积算法,深度解密工业级电磁流量计选型与开发
c语言·c++·算法