[复健]蓝书精刷 0x10 基本数据结构

0x11 栈

历史最小值

维护一个支持 \(O(1)\) 查询栈中最小值的栈

维护两个栈,一个是原本的栈,另一个栈存储当前栈中的最小值即可,相当于使用一个线性结构保存历史上每个时刻的最小值.

int top;
int sta[N],minn[N];

void push(int x) {
	sta[++top]=x;
	minn[top]=std::min(minn[top-1],x);
}

void pop() {--top;}

int getmin() {return minn[top];}

对顶栈

P2201 数列编辑器

对顶栈模板.

满足“始终在序列中间某个指定位置进行修改”这一性质.

#include <cstdio>
#include <algorithm>

const int N=1e6+10;

int n;
int ltop,rtop;
int lsta[N],rsta[N],sum[N],f[N];

int main() {
	f[0]=-0x3f3f3f3f;
	scanf("%d",&n);
	while(n--) {
		char op;
		scanf(" %c",&op);
		if(op=='I') {
			scanf("%d",&lsta[++ltop]);
			sum[ltop]=sum[ltop-1]+lsta[ltop];
			f[ltop]=std::max(f[ltop-1],sum[ltop]);
		} 
		else if(op=='D') --ltop;
		else if(op=='L') rsta[++rtop]=lsta[ltop--];
		else if(op=='R') {
			lsta[++ltop]=rsta[rtop--];
			sum[ltop]=sum[ltop-1]+lsta[ltop];
			f[ltop]=std::max(f[ltop-1],sum[ltop]);
		} else if(op=='Q') {
			int x=0;scanf("%d",&x);
			printf("%d\n",f[x]);
		}
	}
	return 0;
}

出栈序列

P1044 [NOIP 2003 普及组] 栈

法一:搜索(枚举/递归),\(O(2^n)\)

int calc(int x) {
	if(x==n) return 1;
	int res=0;
	sta[++top]=x;
	res+=calc(x+1);
	--top;
	if(!top) return res;
	int ls=sta[top];
	--top;
	res+=calc(x);
	sta[++top]=ls;
	return res;
}

法二:递推,\(O(n^2)\)

由于不关心具体方案,所以只需考虑 \(1\) 这个数排在最终出栈序列中的位置,设排在第 \(k\) 个,说明出栈过程为 \(1\) 出栈前有 \(2 \sim k\)\(k-1\) 个数以某种顺序进出栈,方案数为 \(dp_{k-1}\),在 \(1\) 出栈后有 \(k+1 \sim n\)\(n-k\) 个数进出栈,方案数为 \(dp_{n-k}\),由乘法计数原理与加法计数原理可得递推方程为:

\[dp_n=\sum_{k=1}^n dp_{k-1} \times dp_{n-k} \]

dp[0]=1;
for(int i=1;i<=n;i++) for(int j=1;j<=i;j++) dp[i]+=dp[j-1]*dp[i-j];

法三:动态规划,\(O(n^2)\)

在任何一个时刻,我们只关心有多少个数尚未入栈,有多少个数在栈中,而不关心具体是哪些数,因此,用 \(dp_{i,j}\) 表示有 \(i\) 个数尚未入栈,有 \(j\) 在栈中,那么就有 \(n-i-j\) 个数已经出栈的方案数.

此处采用逆推的方式,因为最终状态的方案数是已经确定了的,即 \(dp_{0,0}=1\).

对于所有元素都已经进栈的情况,只可以出栈,因此 \(dp_{0,j}=1\).

而对于栈中没有元素的情况下,不可以出栈,只可以进栈,因此有 \(dp_{i,0}=dp_{i-1,1}\).

对于一般的情况,既可以进栈又可以出栈,因此方案数为 \(dp_{i,j}=dp_{i-1,j+1}+dp_{i,j-1}\).

最终答案为 \(dp_{n,0}\).

for(int j=0;j<=n;j++) dp[0][j]=1;
for(int i=1;i<=n;i++) {
	dp[i][0]=dp[i-1][1];
	for(int j=1;j<=n-i;j++) dp[i][j]=dp[i-1][j+1]+dp[i][j-1];
}

法四:数学,\(O(n)\)

该问题等价于卡特兰数,答案为 \(\frac{C_{2n}^n}{n+1}\).

表达式求值

后缀表达式(逆波兰表达式)

建立一个用于存数的栈,逐一扫描该后缀表达式中的元素.

若遇到一个数,则该数入栈.

若遇到运算符,则取出栈顶两个数进行运算,并将结果入栈.

最后栈中恰好剩下的一个元素即为答案.

中缀表达式

法一:化归为后缀表达式

建立一个用于存储运算符的栈,逐一扫描表达式中的元素.

若遇到一个数,输出该数.

若遇到左括号,左括号入栈.

若遇到右括号,不断取出栈顶并输出,直到遇到左括号将左括号出栈.

若遇到运算符,只要栈顶符号优先级不低于新符号就不断取出栈顶并输出,并把新符号入栈.

最后依次取出并输出剩余所有符号就是后缀表达式.

法二:递归

我们不断划分子问题,而子问题可以分为两种情况:

  1. 该子问题中存在没有被任何括号包含的运算符:若存在加减号,选最后一个,分成两半递归,结果相加减返回;若存在乘除号,选最后一个,分成左右两半递归,结果相乘除返回.
  2. 若不存在:若首尾字符是括号,则递归求解 \(l+1 \sim r-1\);反之,则直接返回数值.

单调栈

SP1805 HISTOGRA - Largest Rectangle in a Histogram

若我们以某个矩形的高度为最终矩形的高度,那么可以发现后续矩形的高度一定都不可以低于这个高度,因此具有单调性,我们可以借此来维护,用单调栈实现.

在维护时,我们始终保证栈内元素单调递增,在遇到一个新元素打破了该性质时,则不断地弹出栈顶元素,并累计宽度,计算以当前栈顶元素高度为高度的最大子矩形面积即可.

可以多增加一个高度为 \(0\) 的矩形简化代码.

h[n+1]=0;
for(int i=1;i<=n+1;i++) {
	if(top && h[i]<h[sta[top]]) {
		ll width=0;
		while(top && h[i]<h[sta[top]]) {
			width+=w[sta[top]];
			ans=std::max(ans,width*h[sta[top]]);
			--top;
		}
		w[i]=width;
	}
	sta[++top]=i;++w[i];
}

0x12 队列

循环队列

为了节省空间,把队列看成一个首尾相接的环,不断利用废弃空间.

STL 中的 queue 就是循环队列.

UVA540 团体队列 Team Queue

模拟题,建立 \(t+1\) 个队列即可.

语法糖:在情况 STL 时新建一个空的容器,然后 swap.

int times,n,bel[N];
bool vis[M];
char c[10];

std::queue<int> q[M];

void sol() {
	scanf("%d",&n);
	if(!n) exit(0);
	printf("Scenario #%d\n",++times);
	while(!q[0].empty()) {
		int k=q[0].front();
		q[0].pop();
		vis[k]=false;
		std::queue<int> empty;
		swap(q[k],empty);
	}
	for(int i=1;i<=n;i++) {
		int m=0;scanf("%d",&m);
		for(int j=1;j<=m;j++) {
			int k=0;scanf("%d",&k);
			bel[k]=i;
		}
	}
	while(1) {
		scanf(" %s",c);
		if(c[0]=='S') break;
		else if(c[0]=='E') {
			int x=0;scanf("%d",&x);
			int k=bel[x];
			q[k].push(x);
			if(!vis[k]) {
				vis[k]=true;
				q[0].push(k);
			}
		} else {
			int k=q[0].front();
			printf("%d\n",q[k].front());
			q[k].pop();
			if(q[k].empty()) {
				q[0].pop();
				vis[k]=false;
			}
		}
	}
}

P2827 [NOIP 2016 提高组] 蚯蚓

Trick:每个单位时间除了两条新的蚯蚓外,剩下的每一条蚯蚓分别增加 \(q\) 个单位长度,我们可以对这个集合维护一个偏移量 \(\Delta L\),每个单位时间加上 \(q\),对于两条新的蚯蚓则减去 \(q\).

每一轮操作相当于取出最大值,加上 \(\Delta L\),将 \(\lfloor px \rfloor -\Delta L -q\)\(x- \lfloor px \rfloor -\Delta L -q\) 插入集合,然后再将 \(\Delta L\) 加上 \(q\).

下证:

注意到 \(p,q\) 是固定常数,\(0<p<1\)\(q\) 是非负整数.

\(x_1,x_2\) 为非负整数,当 \(x_1 \ge x_2\) 时,有 \(\lfloor px_1 \rfloor+q=\lfloor px_1+q \rfloor \ge \lfloor px_2+pq \rfloor=\lfloor p(x_2+q) \rfloor\).

又因为 \(x_1-x_2 \ge p(x_1-x_2)\),所以 \(x_1-px_1 \ge x_2-px_2 \ge x_2-p(x_2+q)\).

进一步有 \(x_1-\lfloor px_1 \rfloor+q=\lfloor x_1-px_1 \rfloor+q \ge \lfloor x_2-p(x_2+q) \rfloor+q=x_2+q-\lfloor p(x_2+q) \rfloor\).

所以有 \(x_1 \ge x_2,\ \lfloor px_1 \rfloor+q \ge \lfloor p(x_2+q) \rfloor,\ x_1-px_1 +q \ge x_2+q-p(x_2+q)\) 成立.

而通过探究可以发现一个性质:从集合中取出的数和新产生的两类数值分别随着时间单调递减.

因此我们可以用三个队列来维护最大值,时间复杂度 \(O(m+n \log n)\).

#include <queue>
#include <cstdio>
#include <algorithm>

const int N=1e5+10;
const int M=7e6+10;

int n,m,q,u,v,t;
int a[N];

std::queue<int> Q[3];

int main() {
	scanf("%d%d%d%d%d%d",&n,&m,&q,&u,&v,&t);
	for(int i=1;i<=n;i++) scanf("%d",&a[i]);
	std::sort(a+1,a+1+n);
	for(int i=n;i>=1;i--) Q[0].push(a[i]);
	int delta=0;
	for(int i=1;i<=m;i++) {
		int x=-0x3fffffff;
		for(int k=0;k<3;k++) if(!Q[k].empty()) x=std::max(x,Q[k].front());
		for(int k=0;k<3;k++) if(!Q[k].empty() && Q[k].front()==x) {Q[k].pop();break;}
		x+=delta;
		int x1=1ll*u*x/v,x2=x-x1;
		x1-=delta+q,x2-=delta+q;
		Q[1].push(x1);Q[2].push(x2);
		delta+=q;
		if(!(i%t)) printf("%d ",x);
	}
	puts("");
	for(int i=1;i<=n+m;i++) {
		int x=-0x3fffffff;
		for(int k=0;k<3;k++) if(!Q[k].empty()) x=std::max(x,Q[k].front());
		for(int k=0;k<3;k++) if(!Q[k].empty() && Q[k].front()==x) {Q[k].pop();break;}
		x+=delta;
		if(!(i%t)) printf("%d ",x);
	}
	puts("");
	return 0;
}

P10465 双端队列

在不知道后面有哪些数的情况下,我们作出的局部决策很可能造成两个数 \(p,q\) 在同一个队列中,后面只要出现了介于两者之间的数,无论放到哪里都会无解,因此启示我们必须按数值的大小顺序而非读入顺序进行计算.

正难则反,先把数列按从小到大排序,然后分成尽可能少的几段,让每一段对应原问题中一个合法的双端队列,而合法的双端队列应满足单谷性质,先入队列的是满足单调递增的一段,后入队列的是满足单调递减的一段,而发现对于数字相同的部分,无论如何放在同一个队列中都不劣,因此可以合并,关键信息在于位置最小和位置最大两个位置.

时间复杂度 \(O(n \log n)\).

#include <cstdio>
#include <algorithm>

const int N=2e5+10;

struct num {
	int val,id;
}G[N];

struct group {
	int val,l,r;
}g[N];

int n;

int main() {
	scanf("%d",&n);
	for(int i=1;i<=n;i++) scanf("%d",&G[i].val);
	for(int i=1;i<=n;i++) G[i].id=i;
	std::sort(G+1,G+1+n,[](num a,num b) {
		return a.val==b.val?a.id<b.id:a.val<b.val;	
	});
	int m=0;
	for(int i=1;i<=n;i++) {
		if(i==1 || G[i].val!=G[i-1].val) {
			g[++m].val=G[i].val;
			g[m].l=g[m].r=G[i].id;
		} else g[m].r=G[i].id;
	}
	bool f=0;
	int ans=1;
	for(int i=2;i<=m;i++) {
		if(!f && g[i].r>g[i-1].l) f^=1;
		else if(f && g[i].l<g[i-1].r) {f^=1;++ans;}
	}
	printf("%d\n",ans);
	return 0;
}

单调队列

思想:在决策集合(队列)中及时排除一定不是最优解的选择.

U192528 最大子序和

在固定右端点的情况下,显然有我们可能成为最优选择的左端点的策略集合一定是一个下标位置递增、对应的前缀和 \(S\) 的值也递增的序列.

用单调队列维护.

注意一开始要保留 \(0\) 作为左端点的选择在集合中.

#include <cstdio>
#include <algorithm>

const int N=3e5+10;

int n,m,ans=-0x3fffffff;
int a[N],q[N],l=1,r=1,sum[N];

int main() {
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;i++) scanf("%d",&a[i]);
	for(int i=1;i<=n;i++) sum[i]=sum[i-1]+a[i];
	q[l]=0;
	for(int i=1;i<=n;i++) {
		while(l<=r && q[l]<i-m) ++l;
		ans=std::max(ans,sum[i]-sum[q[l]]);
		while(l<=r && sum[q[r]]>=sum[i]) --r;
		q[++r]=i;
	}
	printf("%d\n",ans);
	return 0;
}

0x13 链表与邻接表

链表

数组支持随机访问但不支持在任意位置插入或删除元素,而链表支持在任意位置插入或删除元素但只能按顺序访问.

同时为了降低编程复杂度,常建立额外两个结点 head 和 tail 代表链表头尾.

P10466 邻值查找

实际上是查找前驱后继的模板.

法一:平衡树(STL set)

支持在线.

法二:链表

仅支持离线.

先将序列从小到大排序,然后依次串成一个链表.

同时记录下每个元素在链表中的位置.

因为链表有序,所以链表中结点的前驱和后继即为所求.

从后往前求解并逐渐删除即可.

两种方法时间复杂度均为 \(O(n \log n)\).

#include <cmath>
#include <cstdio>
#include <algorithm>

typedef long long ll;

const int N=1e5+10;

struct list {
	struct node {
		int val;
		int pre,nxt;
	}G[N];
	
	int head=0,tail=0,tot=0;
	
	void init() {
		tot=2;
		head=1,tail=2;
		G[head].nxt=tail;
		G[tail].pre=head;
	}
	
	int insert(int cur,int x) {
		int p=++tot;
		G[p].val=x;
		G[G[cur].nxt].pre=p;
		G[p].nxt=G[cur].nxt;
		G[cur].nxt=p;
		G[p].pre=cur;
		return p;
	}
	
	void remove(int cur) {
		G[G[cur].nxt].pre=G[cur].pre;
		G[G[cur].pre].nxt=G[cur].nxt;
	}
	
	void clear() {
		head=tail=tot=0;
	}
	
	int pre(int cur) {
		if(G[cur].pre==head) return -1;
		return G[cur].pre;
	}
	
	int nxt(int cur) {
		if(G[cur].nxt==tail) return -1;
		return G[cur].nxt;
	}
	
}L;

struct node {
	int val,id; 
}G[N];

int n;
int pos[N];
int ans[N];

int main() {
	scanf("%d",&n);
	for(int i=1;i<=n;i++) scanf("%d",&G[i].val);
	for(int i=1;i<=n;i++) G[i].id=i;
	std::sort(G+1,G+1+n,[](node a,node b) {
		return a.val<b.val;
	});
	L.init();
	pos[G[1].id]=L.insert(1,G[1].id);
	for(int i=2;i<=n;i++) pos[G[i].id]=L.insert(pos[G[i-1].id],G[i].id);
	for(int i=n;i>=2;i--) {
		int pre=L.pre(pos[i]),nxt=L.nxt(pos[i]);
		pre-=2;nxt-=2;
		if(pre<=0) ans[i]=nxt;
		else if(nxt<=0) ans[i]=pre;
		else {
			if(abs(1ll*G[pos[i]-2].val-G[pre].val)>abs(1ll*G[pos[i]-2].val-G[nxt].val)) ans[i]=nxt;
			else ans[i]=pre;
		} 
		L.remove(pos[i]);
	}
	for(int i=2;i<=n;i++) printf("%lld %d\n",1ll*abs(1ll*G[ans[i]].val-G[pos[i]-2].val),G[ans[i]].id);
	return 0;
}

P1168 中位数

对于这种时刻要求序列有序的问题,可以用链表离线维护,与上一题一样.

从后往前求解即可.

#include <map>
#include <cstdio>
#include <algorithm>

const int N=1e5+10;

int n;

struct list {
	int val[N],pre[N],nxt[N];
	int tot,head,tail=n+1;
	
	void init() {
		nxt[head]=n+1;
	}
	
	int insert(int pos,int x) {
		int p=++tot;
		val[p]=x;
		pre[nxt[pos]]=p;
		nxt[p]=nxt[pos];
		nxt[pos]=p;
		pre[p]=pos;
		return p;
	}
	
	void remove(int pos) {
		pre[nxt[pos]]=pre[pos];
		nxt[pre[pos]]=nxt[pos];
	}
	
	int getpre(int pos) {
		return pre[pos];
	}
	
	int getnxt(int pos) {
		return nxt[pos];
	}
}L;

int ans[N];
int val[N],id[N],pid[N];

int main() {
	scanf("%d",&n);
	for(int i=1;i<=n;i++) scanf("%d",&val[i]);
	for(int i=1;i<=n;i++) id[i]=i;
	if(!(n&1)) --n;
	std::sort(id+1,id+1+n,[](int a,int b) {
		return val[a]<val[b];	
	});
	for(int i=1;i<=n;i++) pid[id[i]]=i;
	L.init();
	for(int i=1;i<=n;i++) L.insert(i-1,id[i]);
	int cur=(n+1)/2,delta=0;
	for(int i=n;i>=1;i--) {
		if(i&1) {
			if(delta>0) cur=L.getnxt(cur);
			if(delta<0) cur=L.getpre(cur);
			delta=0;
			ans[i]=val[id[cur]];
		}
		if(pid[i]>cur) --delta;
		if(pid[i]<cur) ++delta;
		L.remove(pid[i]);
	}
	for(int i=1;i<=n;i+=2) printf("%d\n",ans[i]);
	return 0;
}

邻接表

相当于可以随机访问的链表,其本质是一个索引数组,开若干个链表,将同一类的元素放到同一个链表中.

常用来存储图,对于有向图直接存储即可,无向图可以看作两条有向边进行处理,可以用成对变换的技巧进行存储.

int tot;
int head[N],ver[N],val[N],nxt[N];

void add(int u,int v,int w) {
	ver[++tot]=v;val[tot]=w;
	nxt[tot]=head[u];head[u]=tot;
}

void print(int u) {
	for(int i=head[u];i;i=nxt[i]) {
		//do something...
	}
}

0x14 Hash

Hash 表(散列表)

由 Hash 与链表共同构成,其中开散列由邻接表结构实现.

设原始元素总数为 \(O(A)\), 开散列的索引长度为 \(O(B)\),在 Hash 函数设计得较好的时候,期望复杂度为 \(\frac{O(A)}{O(B)}\).

P10467 [CCC 2007] Snowflake Snow Snowflakes

开散列存储然后匹配即可.

哈希函数由六个数的和与六个数的乘积之和构成.

#include <cstdio>

typedef long long ll;

const int N=1e5+10;
const int mod=13331;

ll hash(int a[]) {
	ll val1=0,val2=1;
	for(int i=0;i<6;i++) val1=(val1+a[i])%mod;
	for(int i=0;i<6;i++) val2=(val2*a[i])%mod;
	return (val1+val2)%mod;
}

int tot;
int head[mod+10],ver[N],nxt[N];
int n;
int c[N][10];

bool chk(int cur,int p) {
	for(int i=head[cur];i;i=nxt[i]) {
		int q=ver[i];
		bool eq=true;
		for(int k1=0;k1<6;k1++) {
			for(int k2=0;k2<6;k2++) {
				for(int k=0;k<6;k++) {
					if(c[p][(k1+k)%6]!=c[q][(k2+k)%6]) eq=false;
				}
				if(eq) return true;
				eq=true;
				for(int k=0;k<6;k++) {
					if(c[p][(k1+k)%6]!=c[q][(k2-k+6)%6]) eq=false;
				}
				if(eq) return true;
				eq=true;
			}
		}
	}
	return false;
}

int main() {
	scanf("%d",&n);
	for(int i=1;i<=n;i++) {
		for(int j=0;j<6;j++) scanf("%d",&c[i][j]);
		int cur=hash(c[i]);
		if(head[cur] && chk(cur,i)) {
			puts("Twin snowflakes found.");
			return 0;
		} else {
			ver[++tot]=i;
			nxt[tot]=head[cur];
			head[cur]=tot;
		}
	}
	puts("No two snowflakes are alike.");
	return 0;
}

字符串 Hash

字符串是一种一般化的信息,大多数性质都可以通过字符串 Hash 实现,如是否存在、出现次数等.

取一固定值 \(P\),把字符串看作 \(P\) 进制数,并给每种字符分配一个大于 \(0\) 的数值.

取一固定值 \(M\) 作模数,求出该 \(P\) 进制数对 \(M\) 的余数作为该字符串的 hash 值.

本质是将字符串化归为数进行数哈希.

\(P\) 通常取 \(131\)\(13331\)\(M\) 通常取 \(2^{64}\),即直接用 unsigned long long 类型存储自然溢出即可.

同时可以双哈希或多哈希等.

一些性质:

  1. \(H(S+T)=(H(S) \times P^{len(T)} + H(T)) \bmod M\).
  2. \(H(T)=(H(S+T) - H(S) \times P^{len(T)}) \bmod M\).

相当于左移/右移运算.

因此可以 \(O(n)\) 预处理字符串的前缀 Hash 值,然后 \(O(1)\) 查询任意子串的 Hash 值.

P10468 兔子与兔子

上面性质的应用.

#include <cstdio>
#include <cstring>

const int N=1e6+20;
const int P=131;

char s[N];
unsigned long long hash[N],p[N];

unsigned long long f(int l,int r) {
	int len=r-l+1;
	return hash[r]-hash[l-1]*p[len];
}

int main() {
	scanf("%s",s+1);
	int n=strlen(s+1);
	p[0]=1;
	for(int i=1;i<=n;i++) hash[i]=hash[i-1]*P+(s[i]-'a'+1);
	for(int i=1;i<=n;i++) p[i]=p[i-1]*P;
	int m=0;scanf("%d",&m);
	while(m--) {
		int l1=0,l2=0,r1=0,r2=0;
		scanf("%d%d%d%d",&l1,&r1,&l2,&r2);
		if(r1-l1!=r2-l2) {
			puts("No");
			continue;
		}
		puts(f(l1,r1)==f(l2,r2)?"Yes":"No");
	}
	return 0;
}

P1210 [USACO1.3] 最长的回文 Calf Flac

正着反着各扫一遍求出前后缀 hash 即可做到 \(O(1)\) 求前后缀子串 hash.

然后枚举中心点搭配二分求出答案即可,复杂度 \(O(n \log n)\) 求出最长回文子串.

使用 manacher 算法可以做到 \(O(n)\).

#include <cstdio>
#include <cstring>
#include <algorithm>

const int N=2e4+10;
const int P=131;

int n,m;
char c;
char a[N];
int b[N];
int pos[N];
unsigned long long prev[N],suff[N],p[N];

unsigned long long fprev(int l,int r) {
	return prev[r]-prev[l-1]*p[r-l+1];
}

unsigned long long fsuff(int l,int r) {
	return suff[l]-suff[r+1]*p[r-l+1];
}

int main() {
	while(scanf("%c",&c)!=EOF) a[++n]=c;
	for(int i=1;i<=n;i++) {
		if('a'<=a[i] && a[i]<='z') {
			b[++m]=a[i]-'a'+1;
			pos[m]=i;
		} else if('A'<=a[i] && a[i]<='Z') {
			b[++m]=a[i]-'A'+1;
			pos[m]=i;
		}
	}
	for(int i=1;i<=m;i++) prev[i]=prev[i-1]*P+b[i];
	for(int i=m;i>=1;i--) suff[i]=suff[i+1]*P+b[i];
	p[0]=1;
	for(int i=1;i<=m;i++) p[i]=p[i-1]*P;
	int cen1=1,cur1=0;
	for(int i=1;i<=m;i++) {
		int l=0,r=std::min(i-1,m-i);
		while(l<r) {
			int mid=(l+r+1)>>1;
			int l1=i-mid,r2=i+mid;
			if(fprev(l1,i-1)==fsuff(i+1,r2)) l=mid;
			else r=mid-1;
		}
		if(l>cur1) {
			cen1=i;cur1=l;
		} 
	}
	int cen2=0,cur2=0;
	for(int i=1;i<=m;i++) {
		int l=0,r=std::min(i,m-i);
		while(l<r) {
			int mid=(l+r+1)>>1;
			int l1=i-mid+1,r2=i+mid;
			if(fprev(l1,i)==fsuff(i+1,r2)) l=mid;
			else r=mid-1;
		}
		if(l>cur2) {
			cen2=i;cur2=l;
		}
	}
	if(cur1>=cur2) {
		printf("%d\n",cur1*2+1);
		for(int i=pos[cen1-cur1];i<=pos[cen1+cur1];i++) printf("%c",a[i]);
	} else {
		printf("%d\n",cur2*2);
		for(int i=pos[cen2-cur2+1];i<=pos[cen2+cur2];i++) printf("%c",a[i]);
	}
	return 0;
}

P10469 后缀数组

SA 通常使用倍增或 D3 算法实现,而使用快排、Hash、二分可以 \(O(n \log^2n)\) 实现.

对于任意两个串,只需找到最大公共前缀,再比较下一位即可明确字典序的大小关系,而对于找最大公共前缀这一过程可以通过二分实现,明确大小关系后可以用快排求出 SA,最后重复二分过程即可求出 Height.

#include <ctime>
#include <cstdio>
#include <random>
#include <cstring>
#include <algorithm>

const int N=3e5+10;
const int P=131;

int n;
char s[N];
int SA[N],Height[N];
unsigned long long prev[N],p[N];

unsigned long long hash(int l,int r) {
	return prev[r]-prev[l-1]*p[r-l+1];
}

int calc(int l1,int l2) {
	int l=0,r=std::min(n-l1+1,n-l2+1);
	while(l<r) {
		int mid=(l+r+1)>>1;
		if(hash(l1,l1+mid-1)==hash(l2,l2+mid-1)) l=mid;
		else r=mid-1;
	}
	return l;
}

bool cmp(int l1,int l2) {
	int l=calc(l1,l2);
	return s[l1+l]<s[l2+l];
}

int main() {
	scanf("%s",s+1);
	n=strlen(s+1);s[n+1]='a'-1;
	for(int i=1;i<=n;i++) prev[i]=prev[i-1]*P+s[i]-'a'+1;
	p[0]=1;
	for(int i=1;i<=n;i++) p[i]=p[i-1]*P;
	for(int i=1;i<=n;i++) SA[i]=i;
	std::sort(SA+1,SA+1+n,cmp);
	Height[1]=0;
	for(int i=2;i<=n;i++) Height[i]=calc(SA[i],SA[i-1]);
	for(int i=1;i<=n;i++) printf("%d ",SA[i]-1);
	puts("");
	for(int i=1;i<=n;i++) printf("%d ",Height[i]);
	puts("");
	return 0;
}

0x15 字符串

KMP 模式匹配

KMP 算法能够在线性时间内判定字符串 \(T\) 是否为字符串 \(S\) 的子串,并找出各次出现的位置,同时用字符串 Hash 也能够实现.

KMP 算法分两步:

  1. 对字符串 \(T\) 进行自我匹配,求出 \(next\),其中 \(next_i\) 表示 \(T\)\(i\) 结尾的非前缀子串与 \(T\) 的前缀能够匹配的最长长度,即:

\[next_i=\max{j},\ j<i\ \&\ T_{i-j+1 \sim i}=T_{1 \sim j} \]

  1. 对字符串 \(S\)\(T\) 进行匹配,求出 \(f\),其中 \(f_i\) 表示 \(S\) 中以 \(i\) 结尾的非前缀字串与 \(T\) 的前缀能够匹配的最长长度,即:

\[next_i=\max{j},\ j<i\ \&\ S_{i-j+1 \sim i}=T_{1 \sim j} \]

\(next\) 求法:

根据定义,\(next_1=0\).

我们称满足 \(next\) 的两个条件的 \(j\)\(next_i\) 的候选项.

引理:若 \(j_0\)\(next_i\) 的一个候选项,则小于 \(j_0\)\(next_i\) 的最大的候选项是 \(next_{j_0}\).

反证法可证,若不是 \(next_{j_0}\),则与最大性相悖.

因此根据 \(T\) 的子串之间的包含关系,可以得出 \(next_i\) 的候选项一定为 \(next_j\) 的候选项 \(+1\) 得到.

所以可以 \(O(m)\) 求出,而根据定义的相似性,第二步与第一步类似,复杂度 \(O(n+m)\).

next[1]=0;
for(int i=2,j=0;i<=m;i++) {
	while(j>0 && t[i]!=t[j+1]) j=next[j];
	if(t[i]==t[j+1]) ++j;
	next[i]=j;
}
for(int i=1,j=0;i<=n;i++) {
	while(j>0 && (j==m || s[i]!=t[j+1])) j=next[j];
	if(s[i]==t[j+1]) ++j;
	f[i]=j;
	if(f[i]==m) printf("%d\n",i-m+1);
}

SP263 SP263 - PERIOD - Period

引理:\(s_{1 \sim i}\) 具有长度为 \(len<i\) 的循环元的充要条件是 \(len\) 能整除 \(i\) 并且 \(s_{1 \sim i-len}=s_{len+1 \sim i}\),即 \(i-len\)\(next_i\) 的候选项.

证明:

先证必要性.

\(s_{1 \sim i}\) 具有长度为 \(len<i\) 的循环元,显然有 \(len\) 能整除 \(i\) 并且 \(s_{1 \sim i-len}=s_{len+1 \sim i}\) 成立,两者都是由 \(\frac{i}{len}-1\) 个循环元构成的.

后证充分性.

\(len<i\) 能整除 \(i\) 并且 \(s_{1 \sim i-len}=s_{len+1 \sim i}\),则有 \(s_{1 \sim i-len}\)\(s_{len+1 \sim i}\) 的长度不小于 \(len\) 且是 \(len\) 的倍数.

分别取前 \(len\) 个字符,则有 \(s_{1 \sim len}=s_{len+1 \sim 2 \times len}\),以此类推,可得 \(s_{1 \sim i}\) 具有长度为 \(len<i\) 的循环元,充分性得证.

因此根据引理及 \(next\) 的最大性,当 \(i-next_i\) 能整除 \(i\) 时,\(s_{1 \sim i-next_i}\) 就是 \(s_{1 \sim i}\) 的最小循环元.

进一步推广可得若 \(i-next_{next_i}\) 能整除 \(i\),那么 \(s_{1 \sim i-next_{next_i}}\) 也是 \(s_{1 \sim i}\) 的次小循环元,以此类推可得所有循环元.

#include <cstdio>

const int N=1e6+10;

int n;
char s[N];
int next[N];

void sol(int k) {
	scanf("%d",&n);
	scanf(" %s",s+1);
	next[1]=0;
	for(int i=2,j=0;i<=n;i++) {
		while(j>0 && s[i]!=s[j+1]) j=next[j];
		if(s[i]==s[j+1]) ++j;
		next[i]=j;
	}
	printf("Test case #%d\n",k);
	for(int i=2;i<=n;i++) {
		if(!(i%(i-next[i])) && i/(i-next[i])>1) printf("%d %d\n",i,i/(i-next[i]));
	}
	puts("");
}

int main() {
	int T=0;scanf("%d",&T);
	for(int i=1;i<=T;i++) sol(i);
	return 0;
}

最小表示法

给定一个字符串 \(s\),我们不断把它的最后一个字符放到开头,最终会得到 \(n\) 个字符串,称这 \(n\) 个字符串是循环同构的,这些字符串中字典序最小的一个称作该字符串的最小表示.

而对于求出字符串的最小表示,可以在 \(O(n)\) 的时间复杂度内解决.

我们用 \(b_i\) 表示从 \(i\) 开始的循环同构字符串.

显然可以将这个字符串复制一份放在末尾,这样 \(b_i\) 表示 \(s_{i \sim i+n-1}\).

比较任意两个字符串 \(b_i\)\(b_j\),只需比较第一个不相同的位置 \(k\) 即可,不妨假设 \(b_{i+k}>b_{j+k}\),那么 \(b_{i \sim i+k}\) 均不是最小表示,因为比较 \(b_{i+1 \sim i+k}\) 的过程包含在比较 \(b_i\)\(b_j\) 的过程中.

因此复杂度为 \(O(n)\).

P13270 【模板】最小表示法

#include <cstdio>
#include <algorithm>

const int N=1e7+10;

int n;
char s[N<<1];

int main() {
	scanf("%d",&n);
	scanf("%s",s+1);
	for(int i=1;i<=n;i++) s[i+n]=s[i];
	int i=1,j=2;
	while(i<=n && j<=n) {
		int k=0;
		while(k<n && s[i+k]==s[j+k]) ++k;
		if(k==n) break;
		if(s[i+k]>s[j+k]) {
			i=i+k+1;
			if(i==j) ++i;
		}
		else {
			j=j+k+1;
			if(i==j) ++j;
		}
	}
	int cur=std::min(i,j);
	for(int k=cur;k<=cur+n-1;k++) printf("%c",s[k]);
	return 0;
}

0x16 Trie

Trie 是一种用于实现字符串快速检索的多叉树结构,时间复杂度为 \(O(n)\),空间复杂度为 \(O(nC)\),其中 \(C\) 为字符集大小.

P8306 【模板】字典树

#include <cstdio>
#include <cstring>

const int N=3e6+10;
const int M=3e6+10;

int node[N][63],tot=1;
int cnt[M*63];

int trans(char c) {
	if('a'<=c && c<='z') return c-'a';
	if('A'<=c && c<='Z') return c-'A'+26;
	if('0'<=c && c<='9') return c-'0'+52;
}

void insert(char s[]) {
	int n=strlen(s),p=1;
	for(int i=0;i<n;i++) {
		int j=trans(s[i]);
		if(!node[p][j]) node[p][j]=++tot;
		p=node[p][j];++cnt[p];
	}
}

int search(char s[]) {
	int n=strlen(s),p=1;
	for(int i=0;i<n;i++) {
		int j=trans(s[i]);
		if(!node[p][j]) return 0;
		p=node[p][j];
	}
	return cnt[p];
}

void clear(int p) {
	cnt[p]=0;
	for(int i=0;i<62;i++) {
		if(node[p][i]) clear(node[p][i]);
		node[p][i]=0;
	}
}

int n,m;
char s[N];

void sol() {
	tot=1;
	clear(1);
	scanf("%d%d",&n,&m);
	while(n--) {
		scanf(" %s",s);
		insert(s);
	}
	while(m--) {
		scanf(" %s",s);
		printf("%d\n",search(s));
	}
}

int main() {
	int t=0;scanf("%d",&t);
	while(t--) sol();
	return 0;
}

P10470 前缀统计

\(n\) 个字符串插入 trie 中,在结尾打上标记,在询问时累加即可.

#include <cstdio>
#include <cstring>

const int N=1e6+10;

int tr[N][30],tot=1;
int cnt[N*26];

int trans(char c) {
	return c-'a';
}

void insert(char s[]) {
	int n=strlen(s),p=1;
	for(int i=0;i<n;i++) {
		int j=trans(s[i]);
		if(!tr[p][j]) tr[p][j]=++tot;
		p=tr[p][j];
	}
	++cnt[p];
}

int search(char s[]) {
	int n=strlen(s),p=1,res=0;
	for(int i=0;i<n;i++) {
		int j=trans(s[i]);
		if(!tr[p][j]) return res;
		p=tr[p][j];res+=cnt[p];
	}
	return res;
}

int n,m;
char s[N];

int main() {
	scanf("%d%d",&n,&m);
	while(n--) {
		scanf(" %s",s);
		insert(s);
	}
	while(m--) {
		scanf(" %s",s);
		printf("%d\n",search(s));
	}
	return 0;
}

01trie

将若干个二进制数插入到 trie 中并进行一些贪心操作,需注意空间应开尽可能地大.

P10471 最大异或对 The XOR Largest Pair

从高到低考虑,每次考虑走相反的方向进行贪心.

#include <cstdio>
#include <algorithm>

const int N=1e7+10;

int tr[N][2],tot=1;
bool tag[N*2];

void insert(int x) {
	int p=1;
	for(int i=31;i>=0;i--) {
		int j=(x>>i)&1;
		if(!tr[p][j]) tr[p][j]=++tot;
		p=tr[p][j];
	}
	tag[p]=true;
}

int search(int x) {
	int p=1,res=0;
	for(int i=31;i>=0;i--) {
		int j=(x>>i)&1;
		if(tr[p][j^1]) {
			p=tr[p][j^1];
			res+=(1<<i);
		} else p=tr[p][j];
	}
	return res;
}

int n,ans;

int main() {
	scanf("%d",&n);
	for(int i=1,x=0;i<=n;i++) {
		scanf("%d",&x);
		ans=std::max(ans,search(x));
		insert(x);
	}
	printf("%d\n",ans);
	return 0;
}

P4551 最长异或路径

根据 \(a \otimes a=0\),设 \(dp_i\) 表示从根节点到 \(i\) 的异或路径长度,则有 \(\text{dis}(i,j)=dp_i \otimes dp_j\).

因此求出 \(dp\) 后化归为上一题即可.

#include <cstdio>
#include <algorithm>

const int N=1e7+10;
const int M=1e5+10;

int tr[N][2],tot=1;
bool tag[N*2];

void insert(int x) {
	int p=1;
	for(int i=31;i>=0;i--) {
		int j=(x>>i)&1;
		if(!tr[p][j]) tr[p][j]=++tot;
		p=tr[p][j];
	}
	tag[p]=true;
}

int search(int x) {
	int p=1,res=0;
	for(int i=31;i>=0;i--) {
		int j=(x>>i)&1;
		if(tr[p][j^1]) {
			p=tr[p][j^1];
			res+=(1<<i);
		} else p=tr[p][j];
	}
	return res;
}

int ver[M],next[M],edge[M],head[M],cnt;

void add(int u,int v,int w) {
	ver[++cnt]=v;edge[cnt]=w;
	next[cnt]=head[u];head[u]=cnt;
}

int n,ans;
int dp[M];
bool vis[M];

void dfs(int u,int fa) {
	for(int i=head[u];i;i=next[i]) {
		int v=ver[i],w=edge[i];
		if(v==fa) continue;
		dp[v]=dp[u]^w;
		dfs(v,u);
	}
}

int main() {
	scanf("%d",&n);
	for(int i=1,u=0,v=0,w=0;i<n;i++) {
		scanf("%d%d%d",&u,&v,&w);
		add(u,v,w);
		vis[v]=true;
	}
	int root=0;
	for(int i=1;i<=n;i++) if(!vis[i]) root=i;
	dfs(root,0);
	for(int i=1;i<=n;i++) {
		ans=std::max(ans,search(dp[i]));
		insert(dp[i]);
	}
	printf("%d\n",ans);
	return 0;
}

0x17 二叉堆

二叉堆支持插入、删除、查询最值,是一颗满足堆性质的完全二叉树。

insert 操作:在树末尾插入一个数,然后不断向上调整直至满足堆性质.

extract 操作:删除堆顶,即将堆顶与树末尾交换,并不断向下调整直至满足堆性质.

remove 操作:将该结点与树末尾交换,并分别向上向下调整使之满足堆性质.

int heap[N],tot;

void up(int p) {
	while(p>1) {
		if(heap[p]<heap[p>>1]) {
			std::swap(heap[p],heap[p>>1]);
			p>>=1;
		} else break;
	}
}

void insert(int val) {
	heap[++tot]=val;
	up(tot);
}

int gettop() {
	return heap[1];
}

void down(int p) {
	int son=p<<1;
	while(son<=tot) {
		if(son<tot && heap[son+1]<heap[son]) ++son;
		if(heap[son]<heap[p]) {
			std::swap(heap[son],heap[p]);
			p=son;son<<=1;
		} else break;
	}
}

void extract() {
	heap[1]=heap[tot--];
	down(1);
}

void remove(int p) {
	heap[p]=heap[tot--];
	up(p),down(p);
}

而实际中,常用 STL 中的 priority_queue 来实现堆的作用.

UVA1316 Supermarket

常见的二维限制贪心,对过期日期进行排序,用一个小根堆维护,若当前堆中元素数量少于过期日期,则插入堆中,反之则比较其与堆顶的大小关系并取更优者.

std::sort(G+1,G+1+n,[](object a,object b) {
	return a.d<b.d;
});
int tot=0;
for(int i=1;i<=n;i++) {
	if(tot<G[i].d) {
		q.push(-G[i].p);
		++tot;
	} else if(-q.top()<G[i].p) {
		q.pop();
		q.push(-G[i].p);
	}
}
int ans=0;
while(!q.empty()) {ans-=q.top();q.pop();}
printf("%d\n",ans);

P1631 序列合并

两个指针 \(i,j\) 分别指向两个数列,每次取最小即可,可以发现取完当前最小后 \(i+1,j\)\(i,j+1\) 就成了候选项,因此可以用小根堆维护一个二元组,同时为了避免重复,可以钦定若要得到 \(i,j\) 必须先移动 \(i\) 再移动 \(j\),维护三元组即可.

可以推广到 \(m\) 个数列,两个两个逐一进行即可.

#include <queue>
#include <cstdio>
#include <algorithm>

const int N=1e5+10;

int n;
int a[N],b[N];

struct node {
	int val,x,y;
	bool tag;
};

inline bool operator <(node x,node y) {
	return x.val>y.val;
}

std::priority_queue<node> q;

int main() {
	scanf("%d",&n);
	for(int i=1;i<=n;i++) scanf("%d",&a[i]);
	for(int i=1;i<=n;i++) scanf("%d",&b[i]);
	q.push((node){a[1]+b[1],1,1,false});
	for(int i=1;i<=n;i++) {
		auto cur=q.top();q.pop();
		int x=cur.x,y=cur.y;
		bool tag=cur.tag;
		printf("%d ",cur.val);
		q.push((node){a[x]+b[y+1],x,y+1,true});
		if(!tag) q.push((node){a[x+1]+b[y],x+1,y,false});
	}
	puts("");
	return 0;
}

Huffman 树

构造一棵包含 \(n\) 个叶子节点的 \(k\) 叉树,其中第 \(i\) 个叶子结点带有权值 \(w_i\),要求最小化 \(\sum w_i \times d_i\),该问题的解为 \(k\) 叉 Huffman 树.

\(k=2\),贪心地想,我们要让权值大的结点的深度尽可能小,因此每次取出最小的两个节点并合并,新的结点的值为两个节点之和,不断重复直至只剩下一个结点即可.

\(k>2\),若我们每次取出前 \(k\) 小的结点进行合并,则会出现最后一轮结点不足 \(k\) 个,根节点不满的情况,显然不优,因此我们补充权值为 \(0\) 的结点直至 \((k-1) \mid (n-1)\),含义是除了根节点外,每一轮会消耗 \(k-1\) 个结点,因此应让他们满结点,调整至第一轮不满.

而给其每个节点的 \(k\) 个分支分别标号 \(0 \sim k-1\),此时每个叶子结点从根节点走到该结点可以得到一个编码,称为 Huffman 编码,它满足任意两串编码不满足前缀的关系.

若还要求编码最短,则令深度尽可能地小即可,具体来说,对于权值相同地结点,优先选取深度更小地进行合并.

P1090 [NOIP 2004 提高组] 合并果子

本质是构造 \(2\) 叉 Huffman 树.

#include <queue>
#include <cstdio>

template <class T>

inline void read(T &val) {
	T x=0,f=1;char ch=getchar();
	while(ch<'0' || ch>'9') {ch=='-'?f=-1:0;ch=getchar();}
	while(ch>='0' && ch<='9') {x=(x<<1)+(x<<3)+(ch^48);ch=getchar();}
	val=x*f;
}

typedef long long ll;

const int N=1e7+10;

int n;
ll ans;

std::priority_queue<ll> q;

int main() {
	read(n);
	for(int i=1,a=0;i<=n;i++) {
		read(a);
		q.push(-a);
	}
	int tot=n;
	while(tot>1) {
		ll a=-q.top();q.pop();
		ll b=-q.top();q.pop();
		ans+=a+b;--tot;
		q.push(-(a+b));
	}
	printf("%lld\n",ans);
	return 0;
}

P2168 [NOI2015] 荷马史诗

构造 \(k\) 叉 Huffman 树得出 Huffman 编码即可.

#include <queue>
#include <cstdio>
#include <algorithm>

template <class T>

inline void read(T &val) {
	T x=0,f=1;char ch=getchar();
	while(ch<'0' || ch>'9') {ch=='-'?f=-1:0;ch=getchar();}
	while(ch>='0' && ch<='9') {x=(x<<1)+(x<<3)+(ch^48);ch=getchar();}
	val=x*f; 
}

typedef long long ll;

const int N=1e5+10;

int n,k;
ll ans;
int maxn;

std::priority_queue<std::pair<ll,int>> q; 

int main() {
	read(n);read(k);
	ll a=0;
	for(int i=1;i<=n;i++) {
		read(a);
		q.push(std::make_pair(-a,-1));
	}
	int tot=n;
	while((tot-1)%(k-1)) {q.push(std::make_pair(0,-1));++tot;}
	while(tot>1) {
		ll val=0;int d=0;
		for(int i=1;i<=k;i++) {
			ans-=q.top().first;val-=q.top().first;
			maxn=std::max(maxn,-q.top().second);
			d=std::max(d,-q.top().second);
			--tot;q.pop();
		}
		q.push(std::make_pair(-val,-(d+1)));
		++tot;
	}
	printf("%lld\n%d\n",ans,maxn);
	return 0;
}
posted @ 2025-07-22 19:07  ldh081122  阅读(35)  评论(0)    收藏  举报