[题解] 2026“钉耙编程”中国大学生算法设计暑期联赛(5)1003. 括号凸包

题目链接

TAG:计算几何、极角排序、凸链 DP、前缀和

题意简述

平面上有 (n) 个互不相同的点,且不存在三点共线。每个点标有一个括号:

  • (t_i=0):左括号 (
  • (t_i=1):右括号 )

从中选择若干个点作为顶点,按照凸多边形的边界顺序连接。要求该凸多边形至少包含一个左括号,并且从边界上的任意左括号出发,无论顺时针还是逆时针遍历一周,得到的括号串都必须是合法括号序列。

求满足条件的凸多边形数量,对 (998244353) 取模。


一、括号条件的等价转化

原条件看起来需要对每个左括号、两个遍历方向分别检查合法括号序列,但实际上可以化成一个很简单的局部条件:

\[\boxed{\text{凸多边形边界上任意两个相邻顶点的括号类型不同}} \]

也就是说,括号必须沿边界严格交替:

\[()()()\cdots() \]

1. 必要性

任取边界上的一个左括号顶点 (v)。

从 (v) 出发顺时针遍历,得到一个合法括号序列。任何非空合法括号序列的最后一个字符一定是右括号,因此 (v) 在逆时针方向上的相邻点必须是右括号。

同理,从 (v) 出发逆时针遍历时,最后访问的是 (v) 在顺时针方向上的相邻点,所以这个相邻点也必须是右括号。

因此,每个左括号的两个相邻点都是右括号,即边界上不存在相邻的两个左括号。

另一方面,合法括号序列中左右括号数量相等。把所有左括号按环形顺序排列,相邻两个左括号之间至少有一个右括号;左括号之间共有与左括号数量相同的若干个间隔,而右括号总数又恰好等于左括号总数,所以每个间隔中只能有一个右括号。

因此整个环只能严格交替。

2. 充分性

若边界括号严格交替,那么从任意左括号开始,无论顺时针还是逆时针,得到的序列都是

\[()()()\cdots() \]

其前缀和依次为 (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),必有

\[y_v>y_s \]

\[y_v=y_s,\qquad x_v>x_s. \]

因此,其他顶点相对于 (s) 的方向全部位于上半平面,其中包含 (x) 轴正方向,不包含 (x) 轴负方向。

代码中使用

vy>0||(vy==0&&vx>0)

判断一个点是否属于这个上半平面。


三、极角排序

1. 极角序列

对每个点 (u),把其余 (n-1) 个点按照相对于 (u) 的极角从小到大排序。

定义:

p[u][i]

表示以 (u) 为中心,极角第 (i) 小的点的原编号。

定义:

rk[u][v]

表示点 (v) 在以 (u) 为中心的极角序列中的位置。

两者互为逆映射:

\[p[u][rk[u][v]]=v. \]

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) 为中心时,上半平面的最后一个位置。

于是

\[p[u][1],p[u][2],\ldots,p[u][lst0[u]] \]

恰好是所有满足

\[(y_v,x_v)>(y_u,x_u) \]

的点。


四、固定起点后的候选序列

固定起点 (s),令

\[m=lst0[s] \]

并记

\[q_i=p[s][i],\qquad 1\le i\le m. \]

这些点按照相对于 (s) 的极角递增排列。

若一个凸多边形以 (s) 为 ((y,x)) 字典序最小点,并按逆时针方向写成

\[s,v_1,v_2,\ldots,v_k, \]

那么其余顶点相对于 (s) 的极角严格递增,因此一定对应候选序列中的一个递增子序列:

\[q_{i_1},q_{i_2},\ldots,q_{i_k}, \qquad i_1<i_2<\cdots<i_k. \]

反过来,候选序列中的任意递增子序列都按照绕 (s) 逆时针的顺序排列;之后只要保证链上每个新转角严格左转,并检查末端闭合条件,就能得到一个严格凸多边形。因此问题转化为:

在极角序列 (q_1,q_2,\ldots,q_m) 中选择一个递增子序列,使它与 (s) 构成严格凸多边形,并且相邻顶点的括号类型不同。


五、叉积与凸性

定义

\[\operatorname{cross}(i,j,k) =(P_j-P_i)\times(P_k-P_i). \]

代码为:

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);
}

\[\operatorname{cross}(i,j,k)>0, \]

则从边 (i\to j) 转向 (j\to k) 时严格左转。

按照逆时针方向排列的严格凸多边形,每个顶点处都必须严格左转。


六、DP 状态

固定起点 (s),定义

\[dp[j][k] \]

表示满足以下条件的凸链数量:

\[s\to\cdots\to q_j\to q_k, \qquad j<k. \]

这条链满足:

  1. 选择的候选点下标严格递增;
  2. 链上相邻顶点括号类型不同;
  3. 所有已经形成的转角都严格左转;
  4. 最后两个顶点分别为 (q_j,q_k)。

只记录最后两个点,是因为加入一个新点时,新的凸性限制只与最后两个旧点有关。

设前一个状态的末两点为 (q_i,q_j),现在加入 (q_k),只需要检查

\[\operatorname{cross}(q_i,q_j,q_k)>0. \]


七、朴素转移

\[u=q_j,\qquad v=q_k. \]

若 (u,v) 的括号类型相同,则它们不能相邻:

\[t_u=t_v\Longrightarrow dp[j][k]=0. \]

若括号类型不同,则有两类贡献。

1. 直接建立三点凸链

直接选择

\[s\to u\to v. \]

因为 (j<k),且 (u,v) 都位于 (s) 的上半平面,所以它们相对于 (s) 的极角满足

\[\arg(u-s)<\arg(v-s)<\arg(u-s)+\pi, \]

自动有

\[\operatorname{cross}(s,u,v)>0. \]

因此只需检查

\[t_s\ne t_u. \]

2. 从已有凸链转移

枚举前驱 (q_i),要求

\[i<j \]

以及

\[\operatorname{cross}(q_i,u,v)>0. \]

因此朴素转移为

\[\boxed{ dp[j][k] = [t_u\ne t_v] \left( [t_s\ne t_u] + \sum_{\substack{i<j\\ \operatorname{cross}(q_i,u,v)>0}} dp[i][j] \right) } \]

这里的基础项统计的是只有三个点的凸链,还不是最终答案。由于二元括号不可能在三元环上两两交替,三角形最终一定不会通过闭合时的括号检查;合法多边形的最少顶点数仍为 (4)。

直接枚举 (s,i,j,k) 会达到 (O(n^4)),需要继续优化。


八、将凸性条件转成半平面查询

设前驱点为 (z=q_i)。

\[\operatorname{cross}(z,u,v) = \operatorname{cross}(u,v,z). \]

因为三点叉积在循环置换下保持不变:

\[(P_u-P_z)\times(P_v-P_z) = (P_v-P_u)\times(P_z-P_u). \]

所以

\[\operatorname{cross}(z,u,v)>0 \]

等价于

\[\operatorname{cross}(u,v,z)>0. \]

几何意义是:

点 (z) 严格位于有向直线 (u\to v) 的左侧。

以 (u) 为中心进行极角排序后,位于有向边 (u\to v) 左侧的点,恰好是从方向 (u\to v) 开始逆时针旋转严格小于 (\pi) 所覆盖的点。

这些点在循环极角序列中构成一个连续区间,因此可以用前缀和求和。


九、预处理左半平面区间 ed

定义:

ed[u][l]

表示从方向

\[u\to p[u][l] \]

开始逆时针旋转严格小于 (\pi) 时,能够到达的最后一个位置。

满足条件的点对应循环区间

\[(l,ed[u][l]]. \]

注意 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),只要

\[\operatorname{cross}(u,p[u][l],p[u][r])>0, \]

就说明右端点仍处于该射线左侧。

把循环极角序列展开成长度 (2L) 的序列,并记第 (i) 个方向的极角为 (\theta_i)。对固定的 (l),合法右端点恰好满足

\[\theta_l<\theta_r<\theta_l+\pi. \]

当 (l) 向右移动时,(\theta_l+\pi) 也只会增大,因此最大的合法 (r) 不会后退,可以使用双指针。右指针在一次中心点的扫描中至多移动 (O(n)) 次,所以每个中心点的 ed 预处理复杂度为 (O(n))。


十、按照当前点的极角序建立前缀和

固定起点 (s) 和倒数第二个点

\[u=q_j. \]

需要计算

\[\sum_{\substack{i<j\\ \operatorname{cross}(q_i,u,v)>0}} dp[i][j]. \]

这里的合法前驱由有向边 (u\to v) 决定,所以必须按照以 (u) 为中心的极角顺序建立前缀和,而不是按照以 (s) 为中心的候选顺序。

首先建立映射:

id[v]

表示点 (v) 在当前候选序列 (q_1,\ldots,q_m) 中的位置;不在候选序列中的点令其为 (0)。

随后按照

\[p[u][1],p[u][2],\ldots,p[u][n-1] \]

扫描。若

\[z=p[u][pos] \]

在当前候选序列中的位置为 (i),且 (i<j),则它作为前驱的权值为

\[dp[i][j]. \]

定义

\[pre[pos] = \sum_{r=1}^{pos}w(p[u][r]), \]

其中

\[w(z)= \begin{cases} dp[i][j],&z=q_i,\ i<j,\\ 0,&\text{其他情况}. \end{cases} \]

代码为:

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];

则所有满足

\[\operatorname{cross}(u,v,z)>0 \]

的点 (z) 在循环极角序列中构成区间

\[(l,r]. \]

1. 区间没有跨过末尾

\[r\le n-1, \]

则答案为

\[pre[r]-pre[l]. \]

2. 区间跨过末尾

\[r>n-1, \]

则循环区间被拆成

\[(l,n-1] \]

\[[1,r-(n-1)]. \]

答案为

\[pre[n-1]-pre[l]+pre[r-(n-1)]. \]

代码为:

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))。


十二、闭合成凸多边形

状态

\[dp[j][k] \]

目前表示凸链

\[s\to\cdots\to u\to v. \]

要将其闭合为多边形,还需要连接边

\[v\to s. \]

需要检查两个条件。

1. 括号交替

\[t_v\ne t_s. \]

2. 在 (v) 处严格左转

\[\operatorname{cross}(u,v,s)>0. \]

因此:

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)。

由于候选下标递增,有

\[f<k. \]

所以相对于 (s),(q_f) 的极角小于 (v) 的极角,并且二者都位于同一个上半平面中,于是

\[\operatorname{cross}(s,q_f,v)>0. \]

而三点叉积循环置换不变,因此

\[\operatorname{cross}(v,s,q_f) = \operatorname{cross}(s,q_f,v)>0. \]

所以起点 (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) 时只需检查

\[\operatorname{cross}(q_i,q_j,q_k)>0 \]

\[t_{q_j}\ne t_{q_k}. \]

这正是凸链增加一个新顶点所需且仅需的条件。因此转移既不会加入非法凸链,也不会遗漏合法凸链。

引理 4:前缀和查询恰好覆盖所有合法前驱

条件

\[\operatorname{cross}(q_i,u,v)>0 \]

等价于

\[\operatorname{cross}(u,v,q_i)>0. \]

这些点恰好位于有向边 (u\to v) 左侧,在以 (u) 为中心的循环极角序列中构成区间

\[(rk[u][v],ed[u][rk[u][v]]]. \]

前缀和只给当前候选序列中下标小于 (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


十五、复杂度分析

极角预处理

每个点作为中心排序一次:

\[O(n\log n). \]

共有 (n) 个中心,因此为

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

所有 ed 数组通过双指针预处理,总复杂度为

\[O(n^2). \]

DP

枚举起点 (s)、倒数第二个点 (j),每次建立一个长度 (O(n)) 的前缀和,并枚举所有后继 (k)。

总复杂度为

\[O(n^3). \]

因此整体时间复杂度为

\[\boxed{O(n^3+n^2\log n)=O(n^3)}. \]

空间复杂度为

\[\boxed{O(n^2)}. \]


十六、完整代码

展开完整 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;
}
posted @ 2026-08-05 01:44  艾拉别哭  阅读(17)  评论(0)    收藏  举报