[题解] HDU Contest 1248 1006. 好多石头(仿射映射 + 链上匹配)
[题解] HDU Contest 1248 1006. 好多石头(仿射映射 + 链上匹配)
题目链接:https://acm.hdu.edu.cn/contest/problem?cid=1248&pid=1006
TAG:计算几何、仿射映射、匹配、贪心、哈希表
题意
给定 2n 块能量石,每块石头上有一个整数。需要把它们分成 n 对。
对于每一对石头,可以任选一个数作为斜率 \(k\),另一个数作为截距 \(b\),得到一条直线
问能否完成分组与每组内部的顺序选择,使得到的 n 条直线经过同一个公共点。
记输入中不同整数的数量为 \(m\)。
一、第一层转换:把共点直线变成参数平面上的共线点
设所有生成的直线都经过公共点 \((X,Y)\)。一对石头经过定向后得到 \((k,b)\),对应直线
它经过 \((X,Y)\) 当且仅当
即
把每一对定向后的数字看成参数平面中的点
那么所有生成直线经过同一个点,等价于所有参数点都落在同一条非竖直直线上:
因此原问题等价于:
能否把这 \(2n\) 个数分成 \(n\) 个有序对 \((x,y)\),使所有点 \((x,y)\) 共线,并且这条参数直线不是竖线?
注意,每一对石头内部可以交换,所以无序对 \({u,v}\) 可以对应点 \((u,v)\),也可以对应点 \((v,u)\)。
代码把候选参数直线统一写成整数形式
其中 \(dx>0\),并将 \((dy,dx,c)\) 同除以最大公因数进行规范化。它对应的仿射函数为
于是两个数 \(u,v\) 能在当前直线上组成一对,当且仅当
或
这就把问题进一步转化成了一个由仿射映射决定的匹配问题。
二、先处理水平直线
若某个数字 \(z\) 至少出现 \(n\) 次,可以取其中 \(n\) 个 \(z\) 分别作为每一对的第二个坐标,其余 \(n\) 块石头作为第一个坐标。
此时所有参数点都位于水平直线
上,所以答案一定为 YES。
反过来,如果合法参数直线是水平线 \(y=z\),每一对都必须贡献一个值为 \(z\) 的第二坐标,因此 \(z\) 至少出现 \(n\) 次。故这个特判恰好覆盖全部水平线情况。
排除该情况后,每个数的出现次数都小于 \(n\)。此时不同数字至少有三个:若只有一个或两个不同值,由抽屉原理必有某个值至少出现 \(n\) 次,与当前前提矛盾。
后面只需要枚举斜率非零的参数直线。
三、第二个关键:如何在 \(O(m^2)\) 规模内枚举所有候选直线
1. 固定含最小值的第一个点
设所有不同值从小到大为
并记最小值为
在任何合法分组中,每一个 fst 都必须属于某一对。由于一对内部允许交换,总能把其中一对定向成
因此枚举 bi 即可覆盖合法直线中 fst 所在配对的所有可能。
若 \(bi=0\),点 \((fst,fst)\) 会用掉两个 fst,所以必须检查其出现次数是否至少为二。代码中的
if(cnt[0]-(bi==0)<1) continue;
正是在检查构造 \(P_1\) 时是否还有足够的石头。
2. 固定第二个不同值
一条非退化直线需要两个不同点才能确定。
在排除水平线后有 \(m\ge 3\),所以一定能找到一个值 \(v_{pi}\),满足
代码直接取第一个满足条件的 pi。在真实的合法分组中,某个 \(v_{pi}\) 必然与另一个值 \(v_{qi}\) 配成一对。由于组内方向未知,第二个参数点可能是
也可能是
枚举所有 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}\)。
所以需要满足
这就是代码中的
if(cnt[qi]<=((qi==0)+(qi==bi)+(qi==pi))) continue;
其中方括号表示条件成立时取 \(1\),否则取 \(0\)。
5. 候选直线的数量
共有 \(O(m)\) 种 bi,对每个 bi 枚举 \(O(m)\) 个 qi,每次至多产生两条直线,因此原始候选数量为
同一个 bi 下对候选三元组排序去重,避免重复调用 check。排序总代价为
6. mkline 的作用
两点 \((x_1,y_1),(x_2,y_2)\) 确定直线:
于是
- 若 \(dx=0\),候选是竖线,不对应原问题中的有限公共点,舍去;
- 若 \(dy=0\),候选是水平线,已由高频数字特判完整处理,舍去;
- 其余情况约去 \(dx,dy,c\) 的公因数,并令 \(dx>0\),使同一直线拥有唯一表示。
乘法和行列式计算使用 __int128,避免中间乘积溢出。
四、抽离子问题:仿射函数图上的带重数完美匹配
固定一条候选直线后,问题可以单独抽象为下面的模型。
模型
给定整数多重集合,每个不同值 \(x\) 的出现次数为 \(cnt_x\)。再给定一个仿射映射
若两个值 \(x,y\) 满足
或
则允许它们配成一对。问能否恰好用完所有元素。
通用做法:显式建图后剥叶
把每个不同值看成一个点。若 \(y=F(x)\),就在 \(x,y\) 之间连一条无向边;若 \(F(x)=x\),则得到一个自环。
因为 \(F\) 是一一映射,一个点只可能连向:
- 它的像 \(F(x)\);
- 它的原像 \(F^{-1}(x)\)。
因此忽略自环后,每个点的度数至多为 \(2\),每个连通块通常是一条链或一个环。
对一条链,可以采用经典的度数贪心:
- 找到度数为 \(1\) 的端点 \(x\);
- 设它唯一的邻居为 \(y\);
- 因为 \(x\) 没有其他选择,全部 \(cnt_x\) 个 \(x\) 都必须与 \(y\) 配对;
- 若 \(cnt_y<cnt_x\),立即无解;
- 否则令 \(cnt_y\leftarrow cnt_y-cnt_x\),删除 \(x\),继续剥叶。
若最后只剩自环,则对应值的出现次数必须为偶数。
显式建图当然可以做,但代码进一步利用仿射函数的几何结构,直接找到当前链端点,避免真正建立和维护图。
五、一般斜率:利用不动点隐式找到链端点
下面先讨论
1. 选择压缩映射
原映射为
无向匹配边只关心“\(y=F(x)\) 或 \(x=F(y)\)”,所以把 \(F\) 换成 \(F^{-1}\) 不会改变任何边。
因此可以选择
这样 G 总能写成
它是一个严格压缩映射。
在代码的整数直线表示中:
- 若 \(|dy|<dx\),使用
- 否则使用逆映射
代码把它们统一保存为
并保证 \(D>0\)。
2. 不动点 \(\left(\frac{b}{1-k},\frac{b}{1-k}\right)\)
考虑函数图像
它与直线 \(y=x\) 的交点满足
所以不动点横坐标为
也就是说,函数图像上的不动点是
在代码的表示
中,有
因此
F 与 F^{-1} 的不动点相同,所以无论选择哪一个作为 G,都使用这个 \(p\)。
3. 距离按固定比例收缩
因为 \(G(p)=p\),有
取绝对值得
又因为 \(|\lambda|<1\),从 \(x\) 沿映射走到 \(G(x)\) 后,到不动点的距离严格减小。
反过来,若 \(z\) 是 \(x\) 的原像,即
则
所以链在“远离不动点”的方向上是原像方向,在“靠近不动点”的方向上是映射方向。
4. 最远点一定是当前链端点
在所有仍有剩余次数的值中,选择到 \(p\) 距离最远的值 \(x\)。
假设它还有一个原像 \(z\) 存在,即 \(G(z)=x\)。根据上式,
这与 \(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|\) 对所有值相同,不影响大小关系。
因此每轮只需比较
|(dx-dy)*v[l]-c| |(dx-dy)*v[r]-c|
即可选出最远端点。这里也使用 __int128 防止乘法溢出。
6. 自环
若 G(x)==x,则 \(x=p\),它只能在相同数字内部两两配对,所以要求
一般斜率下除不动点外不存在有限环。事实上,若 \(G^t(x)=x\),则
由于 \(|\lambda|<1\),只有 \(x=p\) 才可能成立。这说明不断从最远端点剥离后,不会残留一个无法开始贪心的非平凡环。
六、两个特殊斜率
一般证明依赖 \(|\lambda|<1\),因此 \(k=\pm1\) 需要单独处理。
1. 斜率 \(k=1\)
此时
若 \(c\) 不能被 \(dx\) 整除,则整数不可能映射到整数,直接无解。
\(d=0\)
此时 F(x)=x,每个数只能与相同数字配对,所以所有出现次数都必须为偶数。
\(d\ne0\)
忽略方向后,允许配对的两个数相差固定值 \(|d|\)。每个连通块是一条公差为 \(|d|\) 的链:
当前全局最小值不可能还有更小的链内邻居,因此一定是某条链的端点,只能与
配对。代码不断取最小现存值,执行同样的次数削减。
2. 斜率 \(k=-1\)
此时
F 是一个对合:
所以每个值只有唯一搭档 \(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\) 时,才把
并记录当前时间戳。
于是:
- 从未在本轮修改的位置直接读取
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:一般斜率分支每次选择的值都是隐式图端点
代码选择 F 或 F^{-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)\)。
因此整体最坏期望时间复杂度为
空间复杂度为
实际运行中,大多数错误候选会在映射结果不是整数、搭档不存在或次数不足时很快退出;时间戳修改避免为这些候选预先复制整个次数数组。
十、实现细节
map同时完成次数统计和不同值排序;unordered_map维护“数值到离散下标”的映射,使用splitmix64降低被构造数据卡哈希的风险;shrink(l,r)维护当前仍有剩余次数的最小值和最大值;- 直线行列式、不动点距离分子与仿射映射分子均使用
__int128; num%D用来判断G(x)是否仍是整数;- 一般分支选择
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;
}
浙公网安备 33010602011771号