Codeforces Round #781 题解

本文探讨了在编程竞赛中如何利用数学思维和算法技巧解决实际问题。针对不同类型的题目,提出了针对性的解决方案,如利用最大公约数和最小公倍数的概念解决整数划分问题,通过二分查找和贪心策略优化树形结构的感染扩散问题,以及借助位运算和欧几里得算法快速确定未知数的质因数分解。文章强调了对数、数组克隆技术和树的传播模型的理解,并提供了相关代码实现。
摘要由CSDN通过智能技术生成

个人思路,仅作记录,可以参考,欢迎交流。

比赛地址:传送门


A. GCD vs LCM

传送门

【题意】给定一个正整数n,求任意一个满足「a+b+c+d=n且gcd(a,b)=lcm(c,d)」的正整数a,b,c,d的组合

【思路】一方面,根据条件容易想到:当n为4的倍数时,只需要将n四等分即可,有gcd(n/4,n/4)=lcm(n/4,n/4)=n/4。此时已经解决了四分之一的情况,而剩下的情况就很自然地想到可以根据n%4的值划分为三类。另一方面,从gcd(a,b)=lcm(c,d)的取值考虑,一种较为典型的情况是gcd(a,b)=lcm(c,d)=1。lcm(c,d)=1,则一定有c=d=1,剩下的n-2由a和b瓜分;而gcd(a,b)=1,有两种最简单的情况: 

  1. a和b为连续的自然数。这种情况只要n-2=a+b为奇数就可以构造出,对应n%4==1n%4==3的情况。
  2. a和b为间隔一个偶数的奇数。这种情况只要n-2=a+b为偶数就可以构造出,对应n%4==2的情况。

综上所述,按照我们的做法:

  1. 当n%4==0时,答案为(n/4, n/4, n/4, n/4)
  2. 当n%4==1或n%4==3时,答案为((n-2)/2, (n-2)/2+1, 1, 1)
  3. 当n%4==2时,答案为((n-2)/2-1, (n-2)/2+1, 1, 1)

【代码】

void solve()
{
	int n;
	cin >> n;
	if (n % 4 == 1 || n % 4 == 3)
	{
		cout << (n - 3) / 2 << ' ' << (n - 3) / 2 + 1 << ' ' << 1 << ' ' << 1 << '\n';
	}
	else if (n % 4 == 2)
	{
		cout << (n - 2) / 2 + 1 << ' ' << (n - 2) / 2 - 1 << ' ' << 1 << ' ' << 1 << '\n';
	}
	else
	{
		cout << n / 4 << ' ' << n / 4 << ' ' << n / 4 << ' ' << n / 4 << '\n';
	}
	return;
}

B. Array Cloning Technique

传送门

【题意】给定一个数组,规定两种操作:

  1. 将现有任一数组复制一份
  2. 将现有的任意两个元素交换位置

求最少需要多少次操作才能使某一数组的所有元素相同

【思路】不妨假设我们的目标为让原数组中所有元素相同。要通过最少的操作使所有元素相同,显然是要将数组中出现次数最多的一种数值复制并换到原数组里来。所以影响我们答案的信息只有:

  • 原数组长度n。题目已给。
  • 原数组中任一众数(假设为num)的出现次数m。可以使用STL map统计每种数值出现的次数,最后遍历map得到最大次数m。

获得所有需要的信息后,就可以开始计算答案。对于交换操作,原数组中非num的数有n-m个,所以最少需要进行n-m次交换操作。而对于复制操作,最高效的方法是,每复制一次数组,就将新数组的所有num都换进原数组来,再继续复制原数组,如此循环。进行k次循环后num的总个数为m*(2^k),由m*(2^k)≥n就能解得最少的复制操作次数k。最终就能得到答案为n-m+k

【代码】

int a[100005];

int Log(int n, int m)//求方程m*(2^x)≥n的最小整数解
{
	int res = 0;
	while (m < n)
	{
		res++;
		m *= 2;
	}
	return res;
}

void solve()
{
	map<int, int> cnt;
	int n;
	cin >> n;
	for (int i = 1; i <= n; ++i)
	{
		cin >> a[i];
		//统计每个数出现次数
		if (cnt.find(a[i]) == cnt.end())
		{
			cnt.insert({ a[i],1 });
		}
		else
		{
			cnt[a[i]]++;
		}
	}

	//遍历map得到最大次数m
	int m = 0;
	for (map<int, int>::iterator iter = cnt.begin(); iter != cnt.end(); ++iter)
	{
		m = max(m, iter->second);
	}

	cout << n - m + Log(n, m) << '\n';
	return;
}

C. Tree Infection

传送门

【题意】给定一个有根树,每秒可以按顺序执行以下两个操作:

  1. 传播:对每一个已经有子结点被感染的结点,使它的另一个子结点被感染
  2. 注射:选定任意一个结点,直接使该结点被感染。求至少要多少秒才能使整棵树被感染

【思路】分析这两种操作:传播是有条件的,且能感染的结点多;而注射没有限制条件,且一次只能感染一个结点。显然注射更为“珍贵”,所以易传播到的结点尽量用传播感染,不易传播到的结点使用注射感染。结点是否“易传播到”,取决于它的兄弟结点数量,也就是与它同父的结点数量。容易想到,我们可以把所有结点根据父结点的不同分为若干(假设为m)组,其中根结点单独成一组

对每一组来说,至少要有一个结点被注射才有可能被感染,且越早被注射的组就可以越早开始传播,所以优先注射未被感染的组中结点最多的组。这需要我们将所有组根据结点数排序

本题中随着结点的增减答案是单调增减的,很明显暗示我们使用二分查找。每次判断中,只要按照上述规则进行感染,计算给定长度时间内能否感染整棵树即可。具体为:

因为第一秒的传播操作无法进行,所以在mid秒时间内,可以注射mid次,传播mid-1次,所以感染进度相当于一个mid*mid的三角形状阶梯(如下图中红色虚线所示)。当每一组都有感染者后,剩下mid-m次注射就用来感染此时还健康(在红色虚线外)的结点,所以只要算出剩余健康结点数surplus并与mid-m进行比较即可。更多细节见代码及注释。

【代码】

int son[200005];

int search(int start, int end, int lef, int rig)//(lef, rig]范围内搜索
{
	if (rig - lef == 1)
	{
		return rig;
	}
	int mid = lef + (rig - lef) / 2;

	int dist = end - start + 1;//组数
	int maxv = mid;//每一组的感染进度
	int surplus = 0;//dist秒后健康结点数

	//统计mid秒后健康结点数
	for (int i = end; i >= start; --i,--maxv)
	{
		if (son[i] > maxv)
		{
			surplus += son[i] - maxv;//在感染进度外就是健康结点
		}
	}

	//判断多出来的注射次数能否将剩余健康结点全部感染
	if (mid - dist >= surplus)
	{
		return search(start, end, lef, mid);
	}
	else
	{
		return search(start, end, mid, rig);
	}
}

void solve()
{
	memset(son, 0, sizeof(son));
	int n;
	cin >> n;

	//分组
	int tmp;
	for(int i = 2; i <= n; ++i)
	{
		cin >> tmp;
		son[tmp]++;
	}

	//排序
	sort(son + 1, son + n + 1);

	//求son数组中有结点的组的起点下标
	int lef = 0;
	while (son[lef] == 0)
	{
		lef++;
	}
	son[--lef] = 1;//加上只有根结点的一组

	cout << search(lef, n, n - lef, n) <<'\n';
	return;
}

D. GCD Guess

传送门

【题意】对于[1,1e9]内任意一个数x,求如何在30次「选择一组(a,b)并询问得到gcd(x+a,x+b)」的操作后确定该数

【思路】一方面,分析gcd(x+a,x+b)=c这个式子,a和b的绝对大小似乎不太重要,因为gcd(x+(a-m),x+(b-m))=gcd((x-m)+a,(x-m)+b),a和b同时变化m只是相当于猜的对象x变化m;而a和b的相对大小是关键,因为x+a和x+b相差b-a(不妨假设b≥a),根据欧几里德算法的原理,c一定是b-a的因数。若c=b-a,x+a一定是b-a的倍数;若c<b-a,x+a一定不是b-a的倍数。如果使a=b-a即b=2a,询问gcd(x+a,x+2a)就可以判断x是不是a的倍数了。而想知道x-m是不是a的倍数,也可以通过询问gcd((x-m)+a,(x-m)+2a)即gcd(x+(a-m),x+(2a-m))来得到答案。

另一方面,1e9恰好在2^30范围内,明显暗示我们每次排除掉一半的整数。而将整数集等分为两半的方法中最简单的就是按照奇偶(即是不是2的倍数,也即num二进制的最低位)划分,而偶数集又可以根据是否为4的倍数等分为两份,奇数集则可以根据「-1后是否为4的倍数」等分为两份,这样就相当于按照二进制的第二位划分了。容易联想到,像这样每次确定二进制的一个位,30次询问后一定能确定[0,2^30-1]内的任意一个数

结合两方面,可以得出一个基本解决方案:起初先询问x是不是2的倍数,若是,则确定x的最低位为0,继续询问x是不是2^2的倍数,若不是,则确定x的最低位为1,继续询问x的最低位减为0后的数是不是2^2的倍数。一般化,对第n次询问,要想确定x的第n位,就询问「将x前n-1位数都减为0后的数」是不是2^n的倍数,若是,则确定x的第n位为0;若不是,则确定x的第n位为1。

上述方法对于前29位都适用,但是到第30位时问题就出现了:题目限定了a和b的范围在[1,2e9],而若x非常小,最后一次询问的b=2*2^30-x,有可能超过限制。但是第30位又是特殊的,因为它之后的所有位都已经可以确定是0了,也就是说,「将x前30-1位数都减为0后的数」只有可能是0(此时是2^30的倍数)或2^29(此时不是2^30的倍数),所以只要判断该数是0还是2^29即可。具体见代码。

【代码】

/*
int gcd(int a, int b)
{
	return a == 0 ? b : gcd(b % a, a);
}
*/

void solve()
{
	int ans = 0;
	int q1, q2, res;
	for (int i = 1; i <= 29; ++i)//先判断前29位
	{
		//x-ans即为将x当前位之前的所有位置为0后的数
		q1 = (1 << i) - ans;
		q2 = (1 << i) * 2 - ans;
		cout << "? " << q1 << " " << q2 << endl;
		//res = gcd(x + q1, x + q2);
		//cout << res << endl;
		cin >> res;
		if (res < (1 << i))//不是倍数
		{
			ans += (1 << (i - 1));//答案第i位为1
		}
	}
	//最后一位特判
	q1 = (1 << 29) - ans;
	q2 = q1 + (1 << 30);
	cout << "? " << q1 << " " << q2 << endl;
	//res = gcd(x + q1, x + q2);
	//cout << res << endl;
	cin >> res;
	if (res == (1 << 30))
	{
		ans += (1 << 29);
	}
	cout << "! " << ans << endl;
	return;
}

E. MinimizOR

传送门

【题意】给定一个非负整数组,多次询问数组的指定片段中任取两个数a和b能得到的最小的a|b。

【思路】如果对每个询问的片段都从片段内所有数中选取a和b并枚举所有组合的话显然必定超时,所以我们要想方法优化时间复杂度。

先分析一下怎样的a和b组合会得到最小结果。把所有数的每一位拆开来看,要想使结果尽可能小,可以从最高位开始考虑起,让结果的每一位尽可能为“0”。对片段中所有数的第n位,有两种情况:

  1. 如果有两个以上的数第n位为0,则只要在这些数中选取a和b,答案的第n位就可以为0。此时只要从a和b的选择范围中剔除第n位为1的数,然后继续考虑更低位即可。
  2. 如果第n位为0的数不足两个(0个或1个),则答案的第n位只能为1。此时a和b的选择范围不变,直接继续考虑更低位。

显然,可能影响答案的都是较小的数,所以初始时a和b的选择范围只要限定为最小的若干个数就能保证得出的答案考虑周全了。但是需要考虑最小的多少个数呢?

先想想,为什么答案不一定是取最小的两个数而有可能在最小的若干个数中呢?因为当某一位只有一个数为0其他都为1时,所有数都没有被剔除选择范围,都有可能被选为最终结果,这就给了较大的数“逆袭”的机会。考虑最极端的情况:

此时初始选择范围为最小的n+1个数即可保证答案正确(n为总位数)。而本题中元素的大小范围为[0,2^30),总位数为30,所以对每个片段只要取31个最小的数(片段长度不足31则取整个片段)作为选择范围就可以保证答案是考虑周全的,也能满足时间复杂度的要求。

对每次询问的片段,都要找出片段中最小的31个数。这可以用线段树维护原数组片段的最小值来实现。查询时,每取走一个最小值后将其用无穷大代替,这样就能再取剩余数中的最小值,最后使用完后再将取出的数全部放回原处即可。

【代码】

const int INF = 2000000000;

struct NODE
{
	int lef, rig;
	int min, ord;
};

int a[100005];
NODE tree[100005 * 4];

void build_tree(int src, int lef, int rig)
{
	tree[src].lef = lef;
	tree[src].rig = rig;
	if (lef == rig)//叶子结点递归结束
	{
		//更新叶子结点信息
		tree[src].min = a[lef];
		tree[src].ord = lef;
	}
	else
	{
		build_tree(src * 2, lef, lef + (rig - lef) / 2);
		build_tree(src * 2 + 1, lef + (rig - lef) / 2 + 1, rig);
		//由子结点信息更新父结点信息
		tree[src].min = min(tree[src * 2].min, tree[src * 2 + 1].min);
		tree[src].ord = tree[src * 2].min < tree[src * 2 + 1].min ? tree[src * 2].ord : tree[src * 2 + 1].ord;
	}
	return;
}

void replace(int src, int dst, NODE v)
{
	if (dst >= tree[src].lef && dst <= tree[src].rig)
	{
		if (tree[src].rig == tree[src].lef)
		{
			tree[src].min = v.min;
			tree[src].ord = v.ord;
		}
		else
		{
			replace(src * 2, dst, v);
			replace(src * 2 + 1, dst, v);
			//由子结点信息更新父结点信息
			tree[src].min = min(tree[src * 2].min, tree[src * 2 + 1].min);
			tree[src].ord = tree[src * 2].min < tree[src * 2 + 1].min ? tree[src * 2].ord : tree[src * 2 + 1].ord;
		}
	}
	return;
}

NODE find_min(int src, int lef, int rig)
{
	if (tree[src].lef >= lef && tree[src].rig <= rig)//查询区间完全包含结点区间
	{
		return { 0,0,tree[src].min, tree[src].ord };
	}
	else if (tree[src].lef <= rig && tree[src].rig >= lef)//查询区间与结点区间有交集
	{
		NODE n1 = find_min(src * 2, lef, rig);
		NODE n2 = find_min(src * 2 + 1, lef, rig);
		NODE min_node = n1.min < n2.min ? n1 : n2;
		return { 0,0,min_node.min, min_node.ord };
	}
	else//查询区间与结点区间无交集
	{
		return { 0,0,INF,0 };
	}
}

int query(int l, int r)
{
	int ans = INF;
	vector<NODE> vec;
	for (int i = l; i <= min(l + 30, r); ++i)
	{
		NODE find=find_min(1, l, r);
		replace(1, find.ord, { 0,0,INF,find.ord });
		vec.push_back(find);
	}
	for (unsigned i = 0; i < vec.size(); ++i)
	{
		for (unsigned j = i + 1; j < vec.size(); ++j)
		{
			ans = min(ans, vec[i].min | vec[j].min);
		}
	}
	for (unsigned i = 0; i < vec.size(); ++i)
	{
		replace(1, vec[i].ord, vec[i]);
	}
	return ans;
}

void solve()
{
	int n, q;
	cin >> n;
	for (int i = 1; i <= n; ++i)
	{
		cin >> a[i];
	}
	build_tree(1, 1, n);
	cin >> q;
	int l, r;
	for (int i = 1; i <= q; ++i)
	{
		cin >> l >> r;
		cout << query(l, r) << '\n';
	}
	return;
}

//线段树还不是很熟悉,写得有点丑陋,build_tree()和replace()功能重复了,但是懒得改了,摆!

本篇草稿写于200公里的密接隔离转运路途中。

祈祷疫情早日结束!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值