[题解] HDU Contest 1248 1006. 好多石头(仿射映射 + 链上匹配)

[题解] HDU Contest 1248 1006. 好多石头(仿射映射 + 链上匹配)

题目链接:https://acm.hdu.edu.cn/contest/problem?cid=1248&pid=1006

TAG:计算几何、仿射映射、匹配、贪心、哈希表

题意

给定 2n 块能量石,每块石头上有一个整数。需要把它们分成 n 对。

对于每一对石头,可以任选一个数作为斜率 \(k\),另一个数作为截距 \(b\),得到一条直线

\[y=kx+b. \]

问能否完成分组与每组内部的顺序选择,使得到的 n 条直线经过同一个公共点。

记输入中不同整数的数量为 \(m\)。

一、第一层转换:把共点直线变成参数平面上的共线点

设所有生成的直线都经过公共点 \((X,Y)\)。一对石头经过定向后得到 \((k,b)\),对应直线

\[y=kx+b. \]

它经过 \((X,Y)\) 当且仅当

\[Y=kX+b, \]

\[b=-Xk+Y. \]

把每一对定向后的数字看成参数平面中的点

\[P=(k,b), \]

那么所有生成直线经过同一个点,等价于所有参数点都落在同一条非竖直直线上:

\[b=-Xk+Y. \]

因此原问题等价于:

能否把这 \(2n\) 个数分成 \(n\) 个有序对 \((x,y)\),使所有点 \((x,y)\) 共线,并且这条参数直线不是竖线?

注意,每一对石头内部可以交换,所以无序对 \({u,v}\) 可以对应点 \((u,v)\),也可以对应点 \((v,u)\)。

代码把候选参数直线统一写成整数形式

\[dx\cdot y=dy\cdot x+c, \]

其中 \(dx>0\),并将 \((dy,dx,c)\) 同除以最大公因数进行规范化。它对应的仿射函数为

\[F(x)=\frac{dy\cdot x+c}{dx}. \]

于是两个数 \(u,v\) 能在当前直线上组成一对,当且仅当

\[v=F(u) \]

\[u=F(v). \]

这就把问题进一步转化成了一个由仿射映射决定的匹配问题。

二、先处理水平直线

若某个数字 \(z\) 至少出现 \(n\) 次,可以取其中 \(n\) 个 \(z\) 分别作为每一对的第二个坐标,其余 \(n\) 块石头作为第一个坐标。

此时所有参数点都位于水平直线

\[y=z \]

上,所以答案一定为 YES

反过来,如果合法参数直线是水平线 \(y=z\),每一对都必须贡献一个值为 \(z\) 的第二坐标,因此 \(z\) 至少出现 \(n\) 次。故这个特判恰好覆盖全部水平线情况。

排除该情况后,每个数的出现次数都小于 \(n\)。此时不同数字至少有三个:若只有一个或两个不同值,由抽屉原理必有某个值至少出现 \(n\) 次,与当前前提矛盾。

后面只需要枚举斜率非零的参数直线。

三、第二个关键:如何在 \(O(m^2)\) 规模内枚举所有候选直线

1. 固定含最小值的第一个点

设所有不同值从小到大为

\[v_0<v_1<\cdots<v_{m-1}, \]

并记最小值为

\[fst=v_0. \]

在任何合法分组中,每一个 fst 都必须属于某一对。由于一对内部允许交换,总能把其中一对定向成

\[P_1=(fst,v_{bi}). \]

因此枚举 bi 即可覆盖合法直线中 fst 所在配对的所有可能。

若 \(bi=0\),点 \((fst,fst)\) 会用掉两个 fst,所以必须检查其出现次数是否至少为二。代码中的

if(cnt[0]-(bi==0)<1) continue;

正是在检查构造 \(P_1\) 时是否还有足够的石头。

2. 固定第二个不同值

一条非退化直线需要两个不同点才能确定。

在排除水平线后有 \(m\ge 3\),所以一定能找到一个值 \(v_{pi}\),满足

\[pi\ne0,\qquad pi\ne bi. \]

代码直接取第一个满足条件的 pi。在真实的合法分组中,某个 \(v_{pi}\) 必然与另一个值 \(v_{qi}\) 配成一对。由于组内方向未知,第二个参数点可能是

\[P_2=(v_{pi},v_{qi}), \]

也可能是

\[P_2=(v_{qi},v_{pi}). \]

枚举所有 qi 并尝试这两种方向,就一定能枚举到真实合法直线。

3. 为什么固定一个 \(pi\) 不会漏解

这里并不需要枚举第二个点使用哪个“特殊值”。任取一个尚未被 \(P_1\) 用掉的不同值 \(v_{pi}\),它在真实方案里总要与某块石头配对。

我们不知道它的搭档,也不知道它在有序对中的方向,但这两部分已经分别由 qi 和两次 mkline 完整枚举。因此只固定一个 pi 足够,不会漏掉任何合法直线。

4. 重数检查

构造 \(P_1,P_2\) 时,不能重复使用不存在的石头。对准备作为搭档的 \(v_{qi}\),此前已经可能使用了:

  • \(P_1\) 的横坐标 \(v_0\);
  • \(P_1\) 的纵坐标 \(v_{bi}\);
  • \(P_2\) 已固定的一块 \(v_{pi}\)。

所以需要满足

\[cnt_{qi}> [qi=0]+[qi=bi]+[qi=pi]. \]

这就是代码中的

if(cnt[qi]<=((qi==0)+(qi==bi)+(qi==pi))) continue;

其中方括号表示条件成立时取 \(1\),否则取 \(0\)。

5. 候选直线的数量

共有 \(O(m)\) 种 bi,对每个 bi 枚举 \(O(m)\) 个 qi,每次至多产生两条直线,因此原始候选数量为

\[O(m^2). \]

同一个 bi 下对候选三元组排序去重,避免重复调用 check。排序总代价为

\[O(m^2\log m). \]

6. mkline 的作用

两点 \((x_1,y_1),(x_2,y_2)\) 确定直线:

\[dx=x_2-x_1,\qquad dy=y_2-y_1, \]

\[c=y_1dx-dy\cdot x_1. \]

于是

\[dx\cdot y=dy\cdot x+c. \]

  • 若 \(dx=0\),候选是竖线,不对应原问题中的有限公共点,舍去;
  • 若 \(dy=0\),候选是水平线,已由高频数字特判完整处理,舍去;
  • 其余情况约去 \(dx,dy,c\) 的公因数,并令 \(dx>0\),使同一直线拥有唯一表示。

乘法和行列式计算使用 __int128,避免中间乘积溢出。

四、抽离子问题:仿射函数图上的带重数完美匹配

固定一条候选直线后,问题可以单独抽象为下面的模型。

模型

给定整数多重集合,每个不同值 \(x\) 的出现次数为 \(cnt_x\)。再给定一个仿射映射

\[F(x)=kx+b. \]

若两个值 \(x,y\) 满足

\[y=F(x) \]

\[x=F(y), \]

则允许它们配成一对。问能否恰好用完所有元素。

通用做法:显式建图后剥叶

把每个不同值看成一个点。若 \(y=F(x)\),就在 \(x,y\) 之间连一条无向边;若 \(F(x)=x\),则得到一个自环。

因为 \(F\) 是一一映射,一个点只可能连向:

  • 它的像 \(F(x)\);
  • 它的原像 \(F^{-1}(x)\)。

因此忽略自环后,每个点的度数至多为 \(2\),每个连通块通常是一条链或一个环。

对一条链,可以采用经典的度数贪心:

  1. 找到度数为 \(1\) 的端点 \(x\);
  2. 设它唯一的邻居为 \(y\);
  3. 因为 \(x\) 没有其他选择,全部 \(cnt_x\) 个 \(x\) 都必须与 \(y\) 配对;
  4. 若 \(cnt_y<cnt_x\),立即无解;
  5. 否则令 \(cnt_y\leftarrow cnt_y-cnt_x\),删除 \(x\),继续剥叶。

若最后只剩自环,则对应值的出现次数必须为偶数。

显式建图当然可以做,但代码进一步利用仿射函数的几何结构,直接找到当前链端点,避免真正建立和维护图。

五、一般斜率:利用不动点隐式找到链端点

下面先讨论

\[k\ne1,\qquad k\ne-1. \]

1. 选择压缩映射

原映射为

\[F(x)=kx+b. \]

无向匹配边只关心“\(y=F(x)\) 或 \(x=F(y)\)”,所以把 \(F\) 换成 \(F^{-1}\) 不会改变任何边。

因此可以选择

\[G= \begin{cases} F,&|k|<1,\\ F^{-1},&|k|>1. \end{cases} \]

这样 G 总能写成

\[G(x)=\lambda x+\mu,\qquad |\lambda|<1. \]

它是一个严格压缩映射。

在代码的整数直线表示中:

  • 若 \(|dy|<dx\),使用

\[G(x)=\frac{dy\cdot x+c}{dx}; \]

  • 否则使用逆映射

\[G(x)=\frac{dx\cdot x-c}{dy}. \]

代码把它们统一保存为

\[G(x)=\frac{Ax+B}{D}, \]

并保证 \(D>0\)。

2. 不动点 \(\left(\frac{b}{1-k},\frac{b}{1-k}\right)\)

考虑函数图像

\[y=kx+b. \]

它与直线 \(y=x\) 的交点满足

\[x=kx+b, \]

所以不动点横坐标为

\[p=\frac{b}{1-k}. \]

也就是说,函数图像上的不动点是

\[\left(\frac{b}{1-k},\frac{b}{1-k}\right). \]

在代码的表示

\[dx\cdot y=dy\cdot x+c \]

中,有

\[k=\frac{dy}{dx},\qquad b=\frac{c}{dx}, \]

因此

\[p=\frac{c}{dx-dy}. \]

FF^{-1} 的不动点相同,所以无论选择哪一个作为 G,都使用这个 \(p\)。

3. 距离按固定比例收缩

因为 \(G(p)=p\),有

\[G(x)-p=\lambda(x-p). \]

取绝对值得

\[|G(x)-p|=|\lambda|\cdot|x-p|. \]

又因为 \(|\lambda|<1\),从 \(x\) 沿映射走到 \(G(x)\) 后,到不动点的距离严格减小。

反过来,若 \(z\) 是 \(x\) 的原像,即

\[G(z)=x, \]

\[|z-p|=\frac{|x-p|}{|\lambda|}>|x-p|. \]

所以链在“远离不动点”的方向上是原像方向,在“靠近不动点”的方向上是映射方向。

4. 最远点一定是当前链端点

在所有仍有剩余次数的值中,选择到 \(p\) 距离最远的值 \(x\)。

假设它还有一个原像 \(z\) 存在,即 \(G(z)=x\)。根据上式,

\[|z-p|>|x-p|, \]

这与 \(x\) 已经最远矛盾。

因此,当前值 \(x\) 不可能还有现存原像。它在隐式无向图中只可能连接到 \(G(x)\),所以它必然是当前链的端点。

于是全部 \(cnt_x\) 个 \(x\) 都只能与 \(G(x)\) 配对:

  • 若 \(G(x)\) 不是整数,失败;
  • 若 \(G(x)\) 不在输入集合中,失败;
  • 若 \(cnt_{G(x)}<cnt_x\),失败;
  • 否则删除全部 \(x\),并从 \(G(x)\) 的次数中减去 \(cnt_x\)。

这与显式图上的“度数为 \(1\) 剥叶”完全相同,只是端点由不动点距离直接给出。

5. 为什么只比较最小值和最大值

将当前仍存在的值按大小排列,设最小值为 \(L\),最大值为 \(R\)。所有现存值都位于闭区间 \([L,R]\) 内。

函数 \(|x-p|\) 在一个区间上的最大值一定出现在端点,所以最远值只可能是 \(L\) 或 \(R\)。

代码不直接计算分数 \(p\),而是比较

\[|(dx-dy)x-c|. \]

因为

\[|(dx-dy)x-c|=|dx-dy|\cdot|x-p|, \]

乘上的 \(|dx-dy|\) 对所有值相同,不影响大小关系。

因此每轮只需比较

|(dx-dy)*v[l]-c|
|(dx-dy)*v[r]-c|

即可选出最远端点。这里也使用 __int128 防止乘法溢出。

6. 自环

G(x)==x,则 \(x=p\),它只能在相同数字内部两两配对,所以要求

\[cnt_x\equiv0\pmod 2. \]

一般斜率下除不动点外不存在有限环。事实上,若 \(G^t(x)=x\),则

\[G^t(x)-p=\lambda^t(x-p). \]

由于 \(|\lambda|<1\),只有 \(x=p\) 才可能成立。这说明不断从最远端点剥离后,不会残留一个无法开始贪心的非平凡环。

六、两个特殊斜率

一般证明依赖 \(|\lambda|<1\),因此 \(k=\pm1\) 需要单独处理。

1. 斜率 \(k=1\)

此时

\[F(x)=x+d,\qquad d=\frac{c}{dx}. \]

若 \(c\) 不能被 \(dx\) 整除,则整数不可能映射到整数,直接无解。

\(d=0\)

此时 F(x)=x,每个数只能与相同数字配对,所以所有出现次数都必须为偶数。

\(d\ne0\)

忽略方向后,允许配对的两个数相差固定值 \(|d|\)。每个连通块是一条公差为 \(|d|\) 的链:

\[\cdots,\ x-|d|,\ x,\ x+|d|,\ \cdots. \]

当前全局最小值不可能还有更小的链内邻居,因此一定是某条链的端点,只能与

\[x+|d| \]

配对。代码不断取最小现存值,执行同样的次数削减。

2. 斜率 \(k=-1\)

此时

\[F(x)=s-x,\qquad s=\frac{c}{dx}. \]

F 是一个对合:

\[F(F(x))=x. \]

所以每个值只有唯一搭档 \(s-x\)。

  • 若 \(x\ne s-x\),两种值的出现次数必须完全相等;
  • 若 \(x=s-x\),即 \(2x=s\),则同值内部配对,出现次数必须为偶数。

这就是 dy==-dx 分支。

七、时间戳次数数组

check 会被调用很多次。若每次都复制整个 cnt,即使候选直线在第一步就失败,也要先付出 \(O(m)\) 的复制代价。

代码使用三个数组:

  • cnt[i]:原始次数;
  • cur[i]:当前候选直线检查中的剩余次数;
  • tag[i]cur[i] 属于哪一次检查。

每次 check 只增加时间戳 tim。第一次修改位置 \(i\) 时,才把

\[cur_i\leftarrow cnt_i \]

并记录当前时间戳。

于是:

  • 从未在本轮修改的位置直接读取 cnt
  • 已修改的位置读取 cur
  • 失败得越早,实际访问和修改的状态就越少。

这个优化不改变单次检查的 \(O(m)\) 上界,但显著减少大量错误候选的常数。

八、正确性证明

引理 1:原问题等价于参数平面中的非竖直共线配对

一对石头定向为 \((k,b)\) 后,生成直线经过 \((X,Y)\) 当且仅当 \(b=-Xk+Y\)。所以所有生成直线共点,当且仅当所有参数点 \((k,b)\) 位于同一条非竖直直线上。反向按照参数点的两个坐标取斜率和截距即可恢复原方案。

引理 2:候选枚举不会漏掉任何合法非水平直线

取真实方案中包含最小值 fst 的一对,并将其定向为 \((fst,v_{bi})\),该 bi 会被枚举。再取代码固定的不同值 \(v_{pi}\),它在真实方案中必有搭档 \(v_{qi}\),且方向必为 \((v_{pi},v_{qi})\) 或 \((v_{qi},v_{pi})\)。代码枚举全部 qi 和两个方向,因此会用真实方案中的两个不同参数点构造出真实直线。

引理 3:一般斜率分支每次选择的值都是隐式图端点

代码选择 FF^{-1} 得到严格压缩映射 G。距公共不动点最远的现存值不可能存在现存原像,否则原像距离更远,产生矛盾。因此它只有 G(x) 这一个可能邻居,是当前链端点。

引理 4:端点次数削减是必要且安全的

端点 \(x\) 的所有副本都只能与唯一邻居 \(y\) 配对。若 \(cnt_y<cnt_x\),任何方案都不可能匹配完 \(x\);否则所有合法方案都必须用掉 \(cnt_x\) 个 \(y\)。删除 \(x\) 并令 \(cnt_y\leftarrow cnt_y-cnt_x\),得到的问题与原问题剩余部分完全等价。

引理 5:check 对固定直线的判断正确

  • \(k=1\) 时,图由固定差值链或自环组成,最小值剥叶正确;
  • \(k=-1\) 时,图由二元对合边和自环组成,次数相等或偶数条件充要;
  • 其余斜率时,由引理 3 找到端点,并由引理 4 等价削减,直到全部次数归零或发现必要条件不满足。

因此 check 返回真,当且仅当所有石头能在当前直线上完成配对。

定理:算法输出正确

若算法输出 YES

  • 要么某个数至少出现 \(n\) 次,可以构造水平参数直线;
  • 要么某条候选非水平直线通过 check,由引理 5 得到完整共线配对。

结合引理 1,原问题有解。

若原问题有解,水平线由高频特判发现;非水平线由引理 2 枚举,并由引理 5 验证通过。因此算法一定输出 YES。故算法正确。

九、复杂度分析

记不同数字数量为 \(m\),显然 \(m\le2n\)。

  • 统计次数与离散值:\(O(n\log m)\);
  • 构造候选直线:\(O(m^2)\);
  • 分组排序去重:\(O(m^2\log m)\);
  • 候选直线数为 \(O(m^2)\),每次 check 最多删除 \(m\) 个不同值,使用哈希表时单次期望 \(O(m)\)。

因此整体最坏期望时间复杂度为

\[O(m^3+m^2\log m). \]

空间复杂度为

\[O(m). \]

实际运行中,大多数错误候选会在映射结果不是整数、搭档不存在或次数不足时很快退出;时间戳修改避免为这些候选预先复制整个次数数组。

十、实现细节

  1. map 同时完成次数统计和不同值排序;
  2. unordered_map 维护“数值到离散下标”的映射,使用 splitmix64 降低被构造数据卡哈希的风险;
  3. shrink(l,r) 维护当前仍有剩余次数的最小值和最大值;
  4. 直线行列式、不动点距离分子与仿射映射分子均使用 __int128
  5. num%D 用来判断 G(x) 是否仍是整数;
  6. 一般分支选择 G 时保证分母 D>0,便于统一整除和范围判断。

十一、完整代码

下面保留上述实现,代码已经通过样例、编译检查和小数据穷举对拍。

展开完整代码(共 137 行)收起代码
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define endl '\n'
using i128=__int128_t;
struct Line{
	int dy,dx,c;
	bool operator<(const Line&o)const{
		if(dy!=o.dy) return dy<o.dy;
		if(dx!=o.dx) return dx<o.dx;
		return c<o.c;
	}
	bool operator==(const Line&o)const{return dy==o.dy&&dx==o.dx&&c==o.c;}
};
struct Hsh{
	static uint64_t splitmix64(uint64_t x){
		x+=0x9e3779b97f4a7c15ULL;
		x=(x^(x>>30))*0xbf58476d1ce4e5b9ULL;
		x=(x^(x>>27))*0x94d049bb133111ebULL;
		return x^(x>>31);
	}
	size_t operator()(int x)const{
		static const uint64_t seed=chrono::steady_clock::now().time_since_epoch().count();
		return splitmix64((uint64_t)x+seed);
	}
};
int n,m,tim;
vector<int> v,cnt,cur,tag;
unordered_map<int,int,Hsh> pos;
int gcnt(int i){return tag[i]==tim?cur[i]:cnt[i];}
void scnt(int i,int x){if(tag[i]!=tim) tag[i]=tim,cur[i]=cnt[i];cur[i]=x;}
void shrink(int&l,int&r){while(l<=r&&gcnt(l)==0) l++;while(l<=r&&gcnt(r)==0) r--;}
i128 ab128(i128 x){return x<0?-x:x;}
bool mkline(int x1,int y1,int x2,int y2,Line&ln){
	int dx=x2-x1,dy=y2-y1;
	if(dx==0) return false;
	int c=(int)((i128)y1*dx-(i128)dy*x1);
	int g=gcd(gcd(llabs(dx),llabs(dy)),llabs(c));
	if(g) dx/=g,dy/=g,c/=g;
	if(dx<0) dx=-dx,dy=-dy,c=-c;
	if(dy==0) return false;
	ln={dy,dx,c};
	return true;
}
bool check(const Line&ln){
	++tim;
	int dy=ln.dy,dx=ln.dx,c=ln.c;
	int l=0,r=m-1;
	if(dy==dx){
		if(c%dx) return false;
		int d=c/dx;
		if(d==0){for(int x:cnt) if(x&1) return false;return true;}
		d=llabs(d);
		while(true){
			shrink(l,r);
			if(l>r) return true;
			int i=l,k=gcnt(i),to=v[i]+d;
			auto it=pos.find(to);
			if(it==pos.end()) return false;
			int j=it->second,t=gcnt(j);
			if(t<k) return false;
			scnt(i,0);scnt(j,t-k);
		}
	}
	if(dy==-dx){
		if(c%dx) return false;
		int s=c/dx;
		while(true){
			shrink(l,r);
			if(l>r) return true;
			int i=l,k=gcnt(i),to=s-v[i];
			auto it=pos.find(to);
			if(it==pos.end()) return false;
			int j=it->second,t=gcnt(j);
			if(i==j){if(k&1) return false;scnt(i,0);}
			else{if(t!=k) return false;scnt(i,0);scnt(j,0);}
		}
	}
	int A,B,D;
	if(llabs(dy)<dx) A=dy,B=c,D=dx;
	else A=dx,B=-c,D=dy;
	if(D<0) A=-A,B=-B,D=-D;
	int fd=dx-dy;
	while(true){
		shrink(l,r);
		if(l>r) return true;
		i128 dl=ab128((i128)fd*v[l]-c),dr=ab128((i128)fd*v[r]-c);
		int i=dl>=dr?l:r,k=gcnt(i);
		i128 num=(i128)A*v[i]+B;
		if(num%D) return false;
		i128 y=num/D;
		if(y<LLONG_MIN||y>LLONG_MAX) return false;
		int to=(int)y;
		auto it=pos.find(to);
		if(it==pos.end()) return false;
		int j=it->second,t=gcnt(j);
		if(i==j){if(k&1) return false;scnt(i,0);}
		else{if(t<k) return false;scnt(i,0);scnt(j,t-k);}
	}
}
void solve(){
	cin>>n;
	map<int,int> mp;
	for(int i=1,x;i<=2*n;i++){cin>>x;mp[x]++;}
	for(auto [x,c]:mp) if(c>=n){cout<<"YES"<<endl;return;}
	v.clear();cnt.clear();
	for(auto [x,c]:mp) v.push_back(x),cnt.push_back(c);
	m=v.size();cur.assign(m,0);tag.assign(m,0);tim=0;
	pos.clear();pos.reserve(m*2+10);
	for(int i=0;i<m;i++) pos[v[i]]=i;
	int fst=v[0];
	for(int bi=0;bi<m;bi++){
		if(cnt[0]-(bi==0)<1) continue;
		int pi=-1;
		for(int i=0;i<m;i++) if(i!=0&&i!=bi){pi=i;break;}
		if(pi==-1) continue;
		vector<Line> cand;
		cand.reserve(2*m);
		for(int qi=0;qi<m;qi++){
			if(cnt[qi]<=((qi==0)+(qi==bi)+(qi==pi))) continue;
			Line ln;
			if(mkline(fst,v[bi],v[pi],v[qi],ln)) cand.push_back(ln);
			if(qi!=pi&&mkline(fst,v[bi],v[qi],v[pi],ln)) cand.push_back(ln);
		}
		sort(cand.begin(),cand.end());
		cand.erase(unique(cand.begin(),cand.end()),cand.end());
		for(const Line&ln:cand) if(check(ln)){cout<<"YES"<<endl;return;}
	}
	cout<<"NO"<<endl;
}
signed main(){
	ios::sync_with_stdio(false);
	cin.tie(nullptr);
	int T;cin>>T;
	while(T--) solve();
	return 0;
}
posted @ 2026-08-03 17:44  艾拉别哭  阅读(2)  评论(0)    收藏  举报