题解:P16955「NLOI Round1」宇宙冷漠

题目大意

给定 \(n\) 条直线 \(y=k_i x + b_i\)\(q\) 次询问,每次询问第 \(l\) 到第 \(r\) 条直线是否交于同一点。

问题转化

如果直接求多线共点,时间复杂度太高了,但是注意到这些直线是静态的,也就是不会发生变化,那么我们可以先求相邻两线的交点,然后判断某区间内是否都是同一个交点。

细化

这个新问题涉及到以下几个方面:

  • 如何求两条直线的交点
  • 如何快速判断两个交点是否是同一个点
  • 如何判断区间内是否都是同一个交点

我们一个一个说。

整体思路

求两直线的交点其实就是加减乘除,但是平行或重合的“交点”的定义是个问题。

判断两个交点是否是同一个点看起来只要判断 \(x\) 坐标和 \(y\) 坐标都相等就行了,事实上这是可行的线段树做法

如何快速判断区间内是否都是同一个交点呢?如果我的序列元素不是两个数 \((x,y)\),而是一个数 \(k\) 呢?可以对序列求前缀和,然后判断左端点的数乘以区间长度是否等于区间和。

那么如何把可能为分数的交点 \((x,y)\) 映射为一个独一无二的数呢?可以选择哈希,也可以开一个 map 登记表,碰到一个新的交点就发一张新号牌。

至此整体思路已经出现:求出相邻直线的交点,将其映射到一个数,然后对所有交点映射的数列求前缀和,然后快速判断询问区间是否都是同一个交点。

特殊情况

现在还有最后两个刺头:平行和重合。

如果两线平行,不存在交点,那么映射的数字应该是什么呢?这可不能乱设,就连 \(+\inf\) 也不行,比如我有三线平行,当我查询这三条线时显然有 \(\inf \times 2 = \inf + \inf\),算出来是成立的,就会被误判成共点。

我的处理方式是将交点映射的数字设为 \(-1\),其他的交点编号从 \(1\) 开始,然后在求前缀和的时候将当前元素和 \(0\) 比最大值,再加到前缀和数组中,也就是只累加正数的交点编号:

\[sum_i = sum_{i-1} + \max(a_i,0) \]

这样,平行线对和的贡献就是 \(0\)

在判断区间 \([l,r]\) 是否全为同一交点时,检查:

\[sum_r - sum_{l-1} \stackrel{?}{=} p_l \times (r - l + 1) \]

  • 如果左端点 \(l\) 对应的是有效交点(\(p_l > 0\)),而区间内混入了平行线,那么左边的和会少掉平行线本该贡献的 \(p_l\),等式不成立。
  • 如果左端点本身就是平行线(\(p_l = -1\)),则等式右边是负数,而左边前缀和非负,等式同样不成立。

这样无论平行线出现在区间的哪个位置,都能正确返回 No,不会干扰到真正共点情况的判断。

重合更加不好用映射的方式处理,因为它既可以和左边的连起来,也可以和右边的连起来。举个例子:

\[\texttt{1 1 1 1 coincident 2 2 2} \]

其中 \(1\)\(2\) 表示坐标的映射,\(\texttt{coincident}\) 表示两线共点。如果查询区间 \([1,5]\),那么重合的编号应该为 \(1\),以契合查询;但如果查询区间 \([5,8]\),那么重合的编号应该为 \(2\),与前面矛盾。

我的处理方式是将重合的直线作缩点操作,就是重复的只记录一个。然后在查询的时候将左右端点变为当前数组的左右端点。

代码细节

  • 为了避免精度问题,在存入 map 中时,我同时记录了 \(x\) 的分子和分母,以及 \(y\) 的分子和分母,并且约分;
  • 特判区间长度为 \(1\) 时输出 Yes
  • 在进行缩点操作后,后续的循环不能再是从 \(1\)\(n\)

复杂度分析

如果使用哈希来映射坐标,时间复杂度较低,为 \(O(n+q)\);如果像我一样使用 map,时间复杂度为 \(O(n \log n + q)\)

空间复杂度均为 \(O(n)\)

代码

#include<bits/stdc++.h>
using namespace std;
#define int long long 
#define endl "\n"
#define div() cout << "-----------------------\n";
#define debug(n) cout << #n << " = " << n << "\n";
#define deb(n) cout << #n << " = " << n << "    ";
int n,q;
int k[1000005],b[1000005],p[1000005],tk[1000005],tb[1000005];
map<pair<pair<int,int>,pair<int,int>>,int>mp;
int sum[1000005];int tot;int idx;int skip[1000005];
// 约分函数
inline void simplify(int &x,int &y){int gc = __gcd(x,y);x /= gc;y /= gc;}
inline void work(int l){
	int r = l+1;
	int k1 = k[l];int k2 = k[r];int b1 = b[l];int b2 = b[r];
	// 特判
	if(k1 == k2 && b1 != b2){
		p[l] = -1;return;
	}
	int xnum = b2-b1;int xdeno = k1-k2;// x坐标的分子和分母
	int ynum = k1*b2-k2*b1;int ydeno = k1-k2;// y坐标的分子和分母
	simplify(xnum,xdeno);simplify(ynum,ydeno);// 约分
	if(!mp[{{xnum,xdeno},{ynum,ydeno}}]){++idx;mp[{{xnum,xdeno},{ynum,ydeno}}] = idx;}
	p[l] = mp[{{xnum,xdeno},{ynum,ydeno}}];
}
signed main(){
    ios::sync_with_stdio(0);cin.tie(0);cout.tie(0);
    cin >> n >> q;
    for(int i = 1;i <= n;i++){cin >> tk[i] >> tb[i];}
    // 缩点操作,skip[i]表示前i条直线,跳过了多少个
    k[++tot] = tk[1];b[tot] = tb[1];
    for(int i = 2;i <= n;i++){
		if(tk[i] != tk[i-1] || tb[i] != tb[i-1]){
			k[++tot] = tk[i];b[tot] = tb[i];
			skip[i] = skip[i-1];
		}
		else{skip[i] = skip[i-1]+1;}
	}
    for(int i = 1;i < tot;i++){work(i);}
    // 求前缀和
	for(int i = 1;i < tot;i++){sum[i] = sum[i-1]+max(0ll,p[i]);}
	// 查询
	for(int i = 1;i <= q;i++){
		int l,r;cin >> l >> r;
		if(l == r){puts("Yes");continue;}
		l -= skip[l];r -= skip[r];
		r--;// 要减去1,因为tot条直线只有n-1个交点,这类似于种树问题
		if(sum[r]-sum[l-1] == p[l]*(r-l+1)){puts("Yes");}
		else {puts("No");}
	}
    return 0;
}