[题解] 2026“钉耙编程”中国大学生算法设计暑期联赛(5)1003. 括号凸包
TAG:计算几何、极角排序、凸链 DP、前缀和
题意简述
平面上有 (n) 个互不相同的点,且不存在三点共线。每个点标有一个括号:
- (t_i=0):左括号
(; - (t_i=1):右括号
)。
从中选择若干个点作为顶点,按照凸多边形的边界顺序连接。要求该凸多边形至少包含一个左括号,并且从边界上的任意左括号出发,无论顺时针还是逆时针遍历一周,得到的括号串都必须是合法括号序列。
求满足条件的凸多边形数量,对 (998244353) 取模。
一、括号条件的等价转化
原条件看起来需要对每个左括号、两个遍历方向分别检查合法括号序列,但实际上可以化成一个很简单的局部条件:
也就是说,括号必须沿边界严格交替:
1. 必要性
任取边界上的一个左括号顶点 (v)。
从 (v) 出发顺时针遍历,得到一个合法括号序列。任何非空合法括号序列的最后一个字符一定是右括号,因此 (v) 在逆时针方向上的相邻点必须是右括号。
同理,从 (v) 出发逆时针遍历时,最后访问的是 (v) 在顺时针方向上的相邻点,所以这个相邻点也必须是右括号。
因此,每个左括号的两个相邻点都是右括号,即边界上不存在相邻的两个左括号。
另一方面,合法括号序列中左右括号数量相等。把所有左括号按环形顺序排列,相邻两个左括号之间至少有一个右括号;左括号之间共有与左括号数量相同的若干个间隔,而右括号总数又恰好等于左括号总数,所以每个间隔中只能有一个右括号。
因此整个环只能严格交替。
2. 充分性
若边界括号严格交替,那么从任意左括号开始,无论顺时针还是逆时针,得到的序列都是
其前缀和依次为 (1,0,1,0,\ldots),始终非负,并且总和为 (0),所以一定是合法括号序列。
3. 一个直接结论
严格交替的环长度一定为偶数。
多边形至少有 (3) 个顶点,因此合法多边形实际上至少有 (4) 个顶点。固定一个起点 (s) 后,还需要至少选择另外 (3) 个点,所以代码中的
if(m<3) continue;
是正确的。
二、为每个凸多边形确定唯一的起点
为了避免同一个凸多边形从不同顶点开始被重复统计,规定:
选择凸多边形中 ((y,x)) 字典序最小的点作为起点 (s)。
即优先选择 (y) 最小的点;若有多个点的 (y) 相同,则选择其中 (x) 最小的点。
由于所有点互不相同,不可能存在两个点的 ((y,x)) 完全相同,所以这个点一定唯一。即使多边形存在一条水平的最低边,也只会选择这条边的左端点作为 (s)。
对于多边形中的其他顶点 (v),必有
或
因此,其他顶点相对于 (s) 的方向全部位于上半平面,其中包含 (x) 轴正方向,不包含 (x) 轴负方向。
代码中使用
vy>0||(vy==0&&vx>0)
判断一个点是否属于这个上半平面。
三、极角排序
1. 极角序列
对每个点 (u),把其余 (n-1) 个点按照相对于 (u) 的极角从小到大排序。
定义:
p[u][i]
表示以 (u) 为中心,极角第 (i) 小的点的原编号。
定义:
rk[u][v]
表示点 (v) 在以 (u) 为中心的极角序列中的位置。
两者互为逆映射:
2. 半平面划分
极角排序不能只依靠叉积,因为整个圆周上的角度具有循环性。代码先把向量分成两个半平面:
int h=(y>0||(y==0&&x>0))?0:1;
- (h=0):上半平面及 (x) 轴正方向;
- (h=1):下半平面及 (x) 轴负方向。
先比较半平面,再在同一半平面中通过叉积比较角度。
bool operator<(const node&o)const{
int ax=x-dx,ay=y-dy;
int bx=o.x-dx,by=o.y-dy;
int h1=(ay>0||(ay==0&&ax>0))?0:1;
int h2=(by>0||(by==0&&bx>0))?0:1;
if(h1!=h2) return h1<h2;
__int128 cr=(__int128)ax*by-(__int128)ay*bx;
if(cr!=0) return cr>0;
return id<o.id;
}
排序时必须排除中心点 (u),因为中心点相对于自身得到零向量 ((0,0)),而零向量没有极角。
3. lst0[u]
由于所有上半平面方向都排在下半平面方向之前,所以
lst0[u]
表示以 (u) 为中心时,上半平面的最后一个位置。
于是
恰好是所有满足
的点。
四、固定起点后的候选序列
固定起点 (s),令
并记
这些点按照相对于 (s) 的极角递增排列。
若一个凸多边形以 (s) 为 ((y,x)) 字典序最小点,并按逆时针方向写成
那么其余顶点相对于 (s) 的极角严格递增,因此一定对应候选序列中的一个递增子序列:
反过来,候选序列中的任意递增子序列都按照绕 (s) 逆时针的顺序排列;之后只要保证链上每个新转角严格左转,并检查末端闭合条件,就能得到一个严格凸多边形。因此问题转化为:
在极角序列 (q_1,q_2,\ldots,q_m) 中选择一个递增子序列,使它与 (s) 构成严格凸多边形,并且相邻顶点的括号类型不同。
五、叉积与凸性
定义
代码为:
inline __int128 cross(int i,int j,int k){
return (__int128)(a[j].x-a[i].x)*(a[k].y-a[i].y)
-(__int128)(a[j].y-a[i].y)*(a[k].x-a[i].x);
}
若
则从边 (i\to j) 转向 (j\to k) 时严格左转。
按照逆时针方向排列的严格凸多边形,每个顶点处都必须严格左转。
六、DP 状态
固定起点 (s),定义
表示满足以下条件的凸链数量:
这条链满足:
- 选择的候选点下标严格递增;
- 链上相邻顶点括号类型不同;
- 所有已经形成的转角都严格左转;
- 最后两个顶点分别为 (q_j,q_k)。
只记录最后两个点,是因为加入一个新点时,新的凸性限制只与最后两个旧点有关。
设前一个状态的末两点为 (q_i,q_j),现在加入 (q_k),只需要检查
七、朴素转移
令
若 (u,v) 的括号类型相同,则它们不能相邻:
若括号类型不同,则有两类贡献。
1. 直接建立三点凸链
直接选择
因为 (j<k),且 (u,v) 都位于 (s) 的上半平面,所以它们相对于 (s) 的极角满足
自动有
因此只需检查
2. 从已有凸链转移
枚举前驱 (q_i),要求
以及
因此朴素转移为
这里的基础项统计的是只有三个点的凸链,还不是最终答案。由于二元括号不可能在三元环上两两交替,三角形最终一定不会通过闭合时的括号检查;合法多边形的最少顶点数仍为 (4)。
直接枚举 (s,i,j,k) 会达到 (O(n^4)),需要继续优化。
八、将凸性条件转成半平面查询
设前驱点为 (z=q_i)。
有
因为三点叉积在循环置换下保持不变:
所以
等价于
几何意义是:
点 (z) 严格位于有向直线 (u\to v) 的左侧。
以 (u) 为中心进行极角排序后,位于有向边 (u\to v) 左侧的点,恰好是从方向 (u\to v) 开始逆时针旋转严格小于 (\pi) 所覆盖的点。
这些点在循环极角序列中构成一个连续区间,因此可以用前缀和求和。
九、预处理左半平面区间 ed
定义:
ed[u][l]
表示从方向
开始逆时针旋转严格小于 (\pi) 时,能够到达的最后一个位置。
满足条件的点对应循环区间
注意 ed[u][l] 使用展开后的循环下标,因此它可能大于 (n-1)。
双指针预处理
固定中心 (u),令 (L=n-1)。
int r=2;
for(int l=1;l<=L;l++){
r=max(r,l+1);
while(r<l+L){
int v=p[u][(r-1)%L+1];
if(cross(u,p[u][l],v)<=0) break;
r++;
}
ed[u][l]=r-1;
}
对于固定 (l),只要
就说明右端点仍处于该射线左侧。
把循环极角序列展开成长度 (2L) 的序列,并记第 (i) 个方向的极角为 (\theta_i)。对固定的 (l),合法右端点恰好满足
当 (l) 向右移动时,(\theta_l+\pi) 也只会增大,因此最大的合法 (r) 不会后退,可以使用双指针。右指针在一次中心点的扫描中至多移动 (O(n)) 次,所以每个中心点的 ed 预处理复杂度为 (O(n))。
十、按照当前点的极角序建立前缀和
固定起点 (s) 和倒数第二个点
需要计算
这里的合法前驱由有向边 (u\to v) 决定,所以必须按照以 (u) 为中心的极角顺序建立前缀和,而不是按照以 (s) 为中心的候选顺序。
首先建立映射:
id[v]
表示点 (v) 在当前候选序列 (q_1,\ldots,q_m) 中的位置;不在候选序列中的点令其为 (0)。
随后按照
扫描。若
在当前候选序列中的位置为 (i),且 (i<j),则它作为前驱的权值为
定义
其中
代码为:
pre[0]=0;
for(int pos=1;pos<=n-1;pos++){
pre[pos]=pre[pos-1];
int z=p[u][pos];
int i=id[z];
if(i&&i<j){
pre[pos]+=dp[i][j];
if(pre[pos]>=mod) pre[pos]-=mod;
}
}
十一、用前缀和查询合法前驱
固定后继 (v=q_k)。
令
int l=rk[u][v];
int r=ed[u][l];
则所有满足
的点 (z) 在循环极角序列中构成区间
1. 区间没有跨过末尾
若
则答案为
2. 区间跨过末尾
若
则循环区间被拆成
和
答案为
代码为:
if(r<=n-1) sum=pre[r]-pre[l];
else sum=(pre[n-1]-pre[l])+(pre[r-(n-1)]-pre[0]);
由于 pre[0]=0,第二项也可以直接写成 pre[r-(n-1)]。
这样,每次转移中的前驱求和从 (O(n)) 降为 (O(1))。
十二、闭合成凸多边形
状态
目前表示凸链
要将其闭合为多边形,还需要连接边
需要检查两个条件。
1. 括号交替
2. 在 (v) 处严格左转
因此:
if(a[s].t!=a[v].t&&cross(u,v,s)>0){
ans+=sum;
if(ans>=mod) ans-=mod;
}
为什么不需要检查 (s) 处的转角
设凸链中第一个候选点为 (q_f),最后一个点为 (v=q_k)。
由于候选下标递增,有
所以相对于 (s),(q_f) 的极角小于 (v) 的极角,并且二者都位于同一个上半平面中,于是
而三点叉积循环置换不变,因此
所以起点 (s) 处自动严格左转,不需要在 DP 中记录第一个候选点。
此外,所有候选点关于 (s) 的极角严格递增,任意一条相邻候选点之间的线段都落在对应的角扇区内,不同的不相邻边不会交叉。结合链上各处严格左转以及首尾两个转角的检查,闭合后得到的一定是简单严格凸多边形。
十三、正确性证明
引理 1:括号条件等价于边界括号严格交替
前文已经证明:
- 原题条件推出每个左括号的两个邻点都是右括号;
- 合法括号串左右括号数量相同;
- 因而整个环必须严格交替;
- 严格交替又能保证从任意左括号、任意方向出发都得到合法括号序列。
所以 DP 只检查相邻顶点类型不同是充要的。
引理 2:每个合法凸多边形具有唯一表示
每个合法凸多边形都有唯一的 ((y,x)) 字典序最小点 (s)。
固定 (s) 后,其余顶点按照逆时针边界顺序排列时,相对于 (s) 的极角严格递增,因此对应候选序列中的唯一递增子序列。反之,递增极角保证边界按围绕 (s) 的方向排列,严格左转与闭合检查保证该序列构成严格凸多边形。
所以每个凸多边形只会被一个起点和一个候选子序列表示。
引理 3:dp[j][k] 恰好统计所有以 (q_j,q_k) 结尾的合法凸链
基础状态 (s\to q_j\to q_k) 在括号类型合法时被加入,并且由于 (j<k),在 (s) 处自动严格左转。
对于更长的链,若前一个状态以 (q_i,q_j) 结尾,加入 (q_k) 时只需检查
和
这正是凸链增加一个新顶点所需且仅需的条件。因此转移既不会加入非法凸链,也不会遗漏合法凸链。
引理 4:前缀和查询恰好覆盖所有合法前驱
条件
等价于
这些点恰好位于有向边 (u\to v) 左侧,在以 (u) 为中心的循环极角序列中构成区间
前缀和只给当前候选序列中下标小于 (j) 的点赋予权值 (dp[i][j]),所以区间和恰好等于所有合法前驱状态之和。
定理:算法恰好统计所有满足要求的凸多边形
根据引理 2,每个合法凸多边形存在唯一的起点和唯一的极角递增顶点序列。
根据引理 3 和引理 4,这个序列对应的凸链一定会被 DP 统计。
闭合时检查最后一个顶点处的严格左转以及闭合边的括号交替,而起点处的严格左转自动成立,所以加入答案的方案一定是合法凸多边形。
因此算法不重不漏。
十四、为什么代码不需要每次清空整个 dp
对于固定起点 (s),外层按照第一维 (j) 从小到大计算。
计算 dp[j][k] 时,只会读取
dp[i][j], i<j
而这些状态已经在当前起点下、之前的外层循环中重新计算完成。
每个当前需要写入的状态又会先执行
dp[j][k]=0;
所以不会读取上一个起点残留的无效值,不需要对整个 (dp) 数组执行 memset。
十五、复杂度分析
极角预处理
每个点作为中心排序一次:
共有 (n) 个中心,因此为
所有 ed 数组通过双指针预处理,总复杂度为
DP
枚举起点 (s)、倒数第二个点 (j),每次建立一个长度 (O(n)) 的前缀和,并枚举所有后继 (k)。
总复杂度为
因此整体时间复杂度为
空间复杂度为
十六、完整代码
展开完整 AC 代码
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define endl '\n'
const int mod=998244353;
const int N=5e2+5;
int n,dx,dy;
struct node{
int x,y,t,id;
bool operator<(const node&o)const{
int ax=x-dx,ay=y-dy;
int bx=o.x-dx,by=o.y-dy;
int h1=(ay>0||(ay==0&&ax>0))?0:1;
int h2=(by>0||(by==0&&bx>0))?0:1;
if(h1!=h2) return h1<h2;
__int128 cr=(__int128)ax*by-(__int128)ay*bx;
if(cr!=0) return cr>0;
return id<o.id;
}
}a[N],b[N];
//p:位置找点
//rk:点找位置
int p[N][N];//p[u][i]:以u为中心,极角第i小的点的原编号
int rk[N][N];//rk[u][v]:点v在以u为中心的极角序列中的位置
int ed[N][N];//ed[u][i]:从方向p[u][i]逆时针转小于π,最后到达的位置
int lst0[N];//lst0[i]:以i为凸包起点,上半平面的最后一个点
int dp[N][N];//dp[j][k]:固定起点s,最后两个点为第j、k个候选点的凸链数
int id[N];//id[v]:点v在当前候选序列中的位置
int pre[N];
inline __int128 cross(int i,int j,int k){
return (__int128)(a[j].x-a[i].x)*(a[k].y-a[i].y)
-(__int128)(a[j].y-a[i].y)*(a[k].x-a[i].x);
}
void init(){
for(int u=1;u<=n;u++){
int cnt=0;
for(int i=1;i<=n;i++){
if(u!=i) b[++cnt]=a[i];
}
dx=a[u].x,dy=a[u].y;
sort(b+1,b+cnt+1);
lst0[u]=0;
for(int i=1;i<=n-1;i++){
int v=b[i].id;
p[u][i]=v;
rk[u][v]=i;
int vx=a[v].x-a[u].x;
int vy=a[v].y-a[u].y;
if(vy>0||(vy==0&&vx>0)) lst0[u]=i;
}
//双指针:在循环极角排序上求左半平面边界
int r=2;
for(int l=1;l<=n-1;l++){
r=max(r,l+1);
while(r<l+(n-1)){
int v=p[u][(r-1)%(n-1)+1];
if(cross(u,p[u][l],v)<=0) break;
r++;
}
ed[u][l]=r-1;
}
}
}
void solve(){
cin>>n;
for(int i=1;i<=n;i++){
cin>>a[i].x>>a[i].y>>a[i].t;
a[i].id=i;
}
init();
int ans=0;
//枚举起点s,s为凸包中(y,x)字典序最小的点
for(int s=1;s<=n;s++){
//候选序列q_i=p[s][i]
int m=lst0[s];
//合法括号环顶点数为偶数,最少为4,因此除s外至少还需3个点
if(m<3) continue;
for(int i=1;i<=n;i++) id[i]=0;
for(int i=1;i<=m;i++) id[p[s][i]]=i;
//枚举倒数第二个点u=q_j
for(int j=1;j<=m;j++){
int u=p[s][j];
//按照以u为中心的极角序建立前驱状态前缀和
pre[0]=0;
for(int pos=1;pos<=n-1;pos++){
pre[pos]=pre[pos-1];
int z=p[u][pos];
int i=id[z];
if(i&&i<j){
pre[pos]+=dp[i][j];
if(pre[pos]>=mod) pre[pos]-=mod;
}
}
//枚举最后一个点v=q_k
for(int k=j+1;k<=m;k++){
int v=p[s][k];
dp[j][k]=0;
if(a[u].t==a[v].t) continue;
//合法前驱位于有向边u->v左侧,对应循环极角区间(l,r]
int l=rk[u][v];
int r=ed[u][l];
int sum;
if(r<=n-1) sum=pre[r]-pre[l];
else sum=(pre[n-1]-pre[l])+(pre[r-(n-1)]-pre[0]);
sum%=mod;
if(sum<0) sum+=mod;
//基础凸链s->u->v
if(a[s].t!=a[u].t){
sum++;
if(sum==mod) sum=0;
}
dp[j][k]=sum;
//闭合为s->...->u->v->s
if(a[s].t!=a[v].t&&cross(u,v,s)>0){
ans+=sum;
if(ans>=mod) ans-=mod;
}
}
}
}
cout<<ans<<endl;
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin>>T;
while(T--) solve();
return 0;
}

浙公网安备 33010602011771号