[题解]P1452 【模板】旋转卡壳 | [USACO03FALL] Beauty Contest G
P1452 【模板】旋转卡壳 | [USACO03FALL] Beauty Contest G
旋转卡壳模板题。凸包用的是Andrew算法,就不详述了,具体可以查查资料了解,但提一嘴Andrew算法的一些细节问题:
Andrew算法的一些细节
Andrew算法的模板代码如下:
sort(a+1,a+1+n,cmp);
st[++top]=1;
for(int i=2;i<=n;i++){
while(top>=2&&cross(a[st[top]]-a[st[top-1]],a[i]-a[st[top]])>=0)
vis[st[top--]]=0;
vis[i]=1,st[++top]=i;
}
int tmp=top;
for(int i=n-1;i>=1;i--){
if(vis[i]) continue;
while(top>tmp&&cross(a[st[top]]-a[st[top-1]],a[i]-a[st[top]])>=0)
vis[st[top--]]=0;
vis[i]=1,st[++top]=i;
}
其中求叉积的部分,\(\ge\)和\(>\)是有区别的。\(\ge\)表示如果方向相同也要删去,结果就不会出现点在凸包边上的情况;\(>\)则是不删,结果就可能出现点在凸包边上的情况。
如果是求周长的话,倒是两种都可以,不过大多数情况下使用\(>\)会很麻烦。
比如这道题,考虑所有点都共线,这样最终结果就是所有点都在凸包上,如果不特判,下一步找直径就会出现高度相同导致的死循环(具体见下面的实现细节)。如果特判共线的话,代码又难以实现(所以这道题用这种写法几乎是没法做的)。
不如仅保留两端的点,这样我们特判条件就是\(n=3\),还能避免这种死循环。
所以写凸包真的真的不建议保留点在凸包边上,否则可能出一系列奇奇怪怪的问题!
下面的内容都默认没有点在凸包边上。
还有就是\(\ge\)和\(\le\)的区别。其实这两种的答案都是对的,只不过求出凸包的顺序一个顺时针一个逆时针而已。
还有还有,代码中的\(vis\)是可以去掉的,原因显然。
(如果你的代码中while
循环的循环条件非得要写>
或者<
的话,那么应对所有点共线的情况,每个点两次循环都会进栈,因此栈空间需要开成\(2\)倍,用>=
或<=
则不需要。)
(到现在才直到原来\(vis\)数组是不需要的(汗)
旋转卡壳
一个拥有\(2^4\)种读音的算法
旋转卡壳是用于解决凸包直径、最小矩形覆盖等问题的算法。求得凸包后时间复杂度为\(O(n)\)。这里介绍求凸包直径的做法。
凸包直径,就是凸包上所有点对的最长距离。
我们定义“对踵点对”为:\(2\)组平行的切线与凸多边形相交形成的顶点点对。
如下图,\(C\)和\(G\)是一组对踵点对,而\(C\)和\(H\)也是一组对踵点对,然而\(C\)和\(A\)不是对踵点对。
对踵点对可以分为\(3\)种形态:点-点、点-边、边-边:
然后我们发现,“点-边”情况就是\(2\)个“点-点”情况,而“边-边”情况就是\(4\)个“点-点”情况。我们可以发现,所有对踵点对都能通过“点-边”情况来确定,也就是枚举每一条边,找到距离这条边最远的点,这样找到的点一定与这条边的\(2\)个端点分别对踵。这也是旋转卡壳的核心思想。
如果我们枚举点呢?这种情况就不是很好做了,因为一个点的对踵点不一定是离它最远的点,其对踵点可以有很多个,代码实现不太容易,而且不好处理重复计算的情况,效率可能较低。
还想到了另一种枚举边的方法,似乎不是很传统的做法所以放到文章最后了。
需要注意的一点是“凸多边形中,到一个顶点距离最远的顶点一定是它的对踵点”这个命题是错误的。
可以放上一个反例:
如上图,到\(M\)最远的点是\(L\),但\(L\)和\(M\)并不是对踵点对。
看到有个题解是用“有一个点到指定边的距离最大,那么就可以推出他到这条边的两个端点中的一个一定是最远的”这个结论来说明旋转卡壳的正确性的。根据上面的反例,我们同样可以推翻这个结论。
那么旋转卡壳的正确性是怎么保证的?我们枚举对踵点对有什么用呢?
我们凭直觉就能感受到:直径一定是\(1\)组对踵点对的连线!
(注意和上面错误的结论区分)
但仔细一想这个结论并不是那么显然的,我们理论证明一下:
证明“直径一定是对踵点对确定的”,即证明“非对踵点对一定不是直径”。
我们思考一下,怎么判断两个点是不是对踵点对呢?
如上图,\(\angle\alpha+\angle\beta>180^\circ\),故\(L\)和\(N\)不是对踵点对。
而\(\angle\delta+\angle\gamma\le 180^\circ\),故\(K\)和\(P\)是对踵点对。故我们判断两点是否对踵,就要判断它们和周围边的夹角(一般是两组,上面为了方便看只给了\(1\)组)的和与\(180^\circ\)的关系。
对于图中不对踵的两点\(L,N\),我们仅需证明\(LN\)一定不是直径即可。
由于\(\angle\alpha+\angle\beta>180^\circ\),则必有\(1\)个角是钝角。我们选择那个钝角(这里选择\(\angle\alpha\)),连接形成一个三角形:
由于这是一个钝角三角形,钝角的对边一定\(>\)其他的边。自然\(LN\)就不是最长边。
理论支撑有了,我们有一套完整的过程了:
- 枚举每一条边,找到离它最远的点(即对踵点),用它分别连接这条边的\(2\)个端点,用形成的\(2\)条线段的长度更新答案即可。
由于这是一个凸多边形,所以从一边开始,到所有点的距离构成一个单峰函数。所以我们只需要按边移动的顺序去移动点,直到找到这个峰值。
但这种做法是\(O(n^2)\)的,我们可以用双指针优化,即每次点可以从上一次的位置开始移动。为什么这样可行呢?因为如果我们枚举顺时针方向的下一条边,点是一定不会逆时针移动的。这样,边转一圈,点也只会转一圈(这个应该不太需要证明),就是\(O(n)\)了。
找峰值的话,一般有\(2\)种写法:
- 一种是\(>\)作为循环条件,即遇到距离相等就停止,不继续找。如果你写的是这种情况,则指针的初始值应为\(2\),否则指针就会一直卡在第\(1\)个点不动了(想一想为什么)。
- 一种是\(\ge\)作为循环条件,遇到距离相等仍然继续找。如果你写的是这种情况,则需要特判凸包大小为\(2\)的情况,否则距离相同会不断去取一个节点,就会死循环。
为什么\(2\)种写法都对呢?
如图,两条红色线是平行的,相当于对踵点对的边-边形态(我们已经保证凸包的边上不存在节点)。假设我们求凸包的顺序是顺时针。那么对于第\(1\)种写法,我们找到\(HG\)的对应点就是\(B\),用\(HB\)和\(GB\)更新了答案,找到\(BC\)的对应点是\(G\),又用\(HC\)和\(GC\)更新了答案,可以发现\(BC,HG\)形成的\(4\)组边都参与了更新。
第\(2\)种写法\(HG\)找到点\(C\),\(BC\)找到点\(G\),同样所有\(4\)条边都参与了答案的更新。就是说两种写法都能遍历到所有对踵点对,所以说两种写法都是正确的。
\(2\)种写法都有需要注意的事项,具体实现需要留心。
总结一下实现细节:
- 凸包实现上,不要让点出现在凸包边上,否则很多步骤都可能出问题。
- 旋转卡壳找峰值,不同的写法有不同的注意事项:
- 将\(>\)作为循环条件的话,指针的初始值应为\(2\),否则指针就会一直卡在第\(1\)个点不动了。
- 将\(\ge\)作为循环条件的话,需要特判凸包大小为\(2\)的情况,否则距离相同会不断去取一个节点,就会死循环。
该题的实现细节
- 注意要输出长度的平方,如果你是中途计算距离,最后再平方,可能会出现浮点误差导致科学计数法,需要取一下整。
当然更好的做法是中途不开根号,这样最后也不用平方了,避免误差还高效。
Code
点击查看代码
#include<bits/stdc++.h>
#define nxtnode(x) (x%top)+1
using namespace std;
struct vec{
double x,y;
vec operator+(const vec b){return {x+b.x,y+b.y};}
vec operator-(const vec b){return {x-b.x,y-b.y};}
int squalen(){return x*x+y*y;}
}a[50010];
bool cmp(vec a,vec b){
if(a.x<b.x) return 1;
if(a.x>b.x) return 0;
return a.y<b.y;
}
inline double cross(vec a,vec b){return a.x*b.y-a.y*b.x;}
int n,st[50010],top;
int ans;
void convex_hull(){
sort(a+1,a+1+n,cmp);
st[top=1]=1;
for(int i=2;i<=n;i++){
while(top>1&&cross(a[st[top]]-a[st[top-1]],a[i]-a[st[top]])>=0)
top--;
st[++top]=i;
}
int tmp=top;
for(int i=n-1;i>=1;i--){
while(top!=tmp&&cross(a[st[top]]-a[st[top-1]],a[i]-a[st[top]])>=0)
top--;
st[++top]=i;
}
top--;
}
int main(){
cin>>n;
for(int i=1;i<=n;i++) cin>>a[i].x>>a[i].y;
convex_hull();
if(top==2) ans=(a[st[2]]-a[st[1]]).squalen();
else{
int cur=1;
for(int i=1;i<=top;i++){
vec bottom=a[st[i+1]]-a[st[i]];
while(fabs(cross(bottom,a[st[nxtnode(cur)]]-a[st[i]]))>=
fabs(cross(bottom,a[st[cur]]-a[st[i]]))){
cur=nxtnode(cur);
}
ans=max(ans,max((a[st[cur]]-a[st[i]]).squalen(),(a[st[cur]]-a[st[i+1]]).squalen()));
}
}
cout<<ans<<"\n";
return 0;
}
另一种找对踵点的方法
上面的方法是通过叉积法找到一边最远的点,就是边上两点的对踵点了。
还可以通过向量的方向来求,其实是差不多的(而且代码实现还短点?)。
如图,红色边和所有绿色边求叉积都是\(<0\)的,和所有蓝色边求叉积都是\(>0\)的。我们要找的红色边的两个端点的对踵点,就是绿色和蓝色的分界处那个点。所以只需要从\(A\)开始遍历每个点,直到该点即将走的下一条边和红色边求叉积\(>/\ge0\)为止。
实际上,停止条件需要根据凸包求出的顺序来决定,如果凸包求出来的是顺时针,则是\(>/\ge\)为止,逆时针就是\(</\le\)为止。这里拿顺时针举例,代码也是顺时针的凸包。
同样考虑一下边界,还是\(2\)种写法,和上面的很像:
- 叉积\(\le 0\)作为循环条件,则需要特判大小为\(2\)的情况。
- 叉积\(<0\)作为循环条件,指针需要从\(2\)开始。
这个过程很像上面的过程逆过来,上面是用边找点,这个是用点找边,正确性证明可以参照上面。
给出代码实现。
点击查看代码
#include<bits/stdc++.h>
#define nxtnode(x) (x%top+1)
#define N 50010
using namespace std;
struct vec{
double x,y;
vec operator+(vec b){return {x+b.x,y+b.y};}
vec operator-(vec b){return {x-b.x,y-b.y};}
int squalen(){return x*x+y*y;}
}a[N];
bool cmp(vec a,vec b){return a.x==b.x?a.y<b.y:a.x<b.x;}
double cross(vec a,vec b){return a.x*b.y-a.y*b.x;}
int n,st[N],top,ans;
bool vis[N];
void convex_hull(){
sort(a+1,a+1+n,cmp);
memset(vis,0,sizeof vis);
st[top=1]=1;
for(int i=2;i<=n;i++){
while(top>1&&cross(a[st[top]]-a[st[top-1]],a[i]-a[st[top]])>=0){
vis[st[top--]]=0;
}
vis[i]=1,st[++top]=i;
}
int tmp=top;
for(int i=n-1;i>=1;i--){
if(vis[i]) continue;
while(top!=tmp&&cross(a[st[top]]-a[st[top-1]],a[i]-a[st[top]])>=0){
vis[st[top--]]=0;
}
vis[i]=1,st[++top]=i;
}
top--;
}
int main(){
cin>>n;
for(int i=1;i<=n;i++) cin>>a[i].x>>a[i].y;
convex_hull();
int j=2;
for(int i=1;i<=top;i++){
double t;
while((t=cross(a[st[nxtnode(i)]]-a[st[i]],a[st[nxtnode(j)]]-a[st[j]]))<0){
j=nxtnode(j);
}
ans=max(ans,(a[st[i]]-a[st[j]]).squalen());
if(t==0) ans=max(ans,(a[st[i]]-a[st[nxtnode(j)]]).squalen());
}
cout<<ans;
return 0;
}