字符串hash+二分处理回文子串问题

1. 背景

如果使用DP求解字符串最长回文子串问题,假设字符串长度为\(N\),那么时间复杂度为\(O(N^2)\)。当字符串很长时(如长达100万字节),那么平方复杂度肯定行不通。
如果使用字符串hash+二分的方法,那么可以在\(O(NlgN)\)的时间复杂度内求解。


2. 字符串hash

字符串hash指输入一个字符串,在常数时间内得到一个正整数。理想哈希函数有两个要求:无碰撞性随机性
这里采用算法笔记中的哈希函数。
假设有一个长度为\(N\)的整型数组\(H\),其中\(H[i]\)表示字符串前i个字符组成的子串的hash值。
\(H[0]=str[0]\)
\(H[i]=(H[i-1]*p+str[i]) \%mod\)
\(其中p为进制数,mod为模。一般p取一个10^7级别的素数,mod取一个10^9级别的素数\)
在求出H数组后,就可以求出子串的hash值:
假设求第i位到第j位的子串的hash值,则有\(H[i..j]=((H[j]-H[i-1]*p^{j-i+1} ) \% mod+mod ) \% mod\)


3. 二分法查找

对给定输入的字符串,可以每两个字符之间插入一个特殊符号(如'#'),得到新的字符串str。这样做可以避免后续讨论回文子串长度是奇数还是偶数。
定义两个变量lb与rb,分别表示回文子串半径的可取的范围的上下界。定义变量k,表示当前判断的回文子串的半径。
初始时,lb=0,rb=min{i,len-i-1},即要找回文子串的半径就可以从这个范围中找。
很容易想到,若由一个半径得到的子串不是回文的,那么可知继续增大半径是没有用的,那么就需要减小半径继续搜索。同理,若字串是回文的,就增大半径来搜索。这就启发我们用二分的方法,挑选下界和上界的中间值来作为下一次的半径。对应的行为:子串是回文,lb=k;否则rb=k-1。下一次的半径k=(lb+rb)/2+1。
重复上述过程,直到lb>=rb为止。然后判断中心点i对应的字符c。若c为用于分隔的特殊字符,那么说明回文子串长度为偶数,子串长度为\(2*((lb+1)/2)\)
(注意,是整数除法,不得化简式子);否则回文子串长度为奇数,子串长度为\(2*(lb/2)+1\)


例题:acwing139--回文子串的最大长度

这里用了O2优化

#include<cstdio>
#include<cstring>
#include<string>
#include<iostream>
#include<unordered_map>
#include<vector>
#include<algorithm>
#pragma GCC optimize(2)
using namespace std;

typedef long long ll;
const int MAXLEN = 1000005;
const int MAXNUM = 35;
int strnum=0;		//字符串的数量
//正序和逆序的字符串
string s1, s2;
//结果
int result[MAXNUM] = { 0 };
//进制数
ll p = 10000019;
//mod
ll mod = 1000000007;
//H数组,H[i]表示字符串前i个字符的子串的hash值
ll H1[MAXLEN] = { 0 }, H2[MAXLEN] = { 0 };
//powp[i]表示p的i次方
ll powp[MAXLEN] = { 0 };

//计算字符串的H数组
void calH(ll* H,string& str) {
	H[0] = str[0];
	for (int i = 1; i < str.size(); i++) {
		H[i] = (H[i - 1] * p + str[i]) % mod;
	}
}

//打表,预先求出p的幂次
void getpowp() {
	powp[0] = 1;
	for (int i = 1; i < MAXLEN; i++) {
		powp[i] = (powp[i - 1] * p)%mod;
	}
}

//计算子串的哈希值
ll calsubH(ll* H,int i,int j){
	//对i为0的情况特殊处理
	if (i == 0)
		return H[j];
	else
		return ((H[j] - H[i - 1] * powp[j - i + 1]) % mod + mod) % mod;
}

//比较两个子串是否相等
bool cmp(int i1,int j1,int i2,int j2) {
	return calsubH(H1, i1, j1) == calsubH(H2, i2, j2);	
}

//对字符串进行预处理,每两个字符之间插入一个'#'
void initstring(string& tmp,int& len) {
	len = 2 * len - 1;
	char* ch = new char[len + 1];
	for (int i = 0; i < len; i++) {
		if (i % 2 == 0)	ch[i] = tmp[i / 2];
		else    ch[i] = '#';
	}
	ch[len] = '\0';
	tmp = string(ch);
}

//计算以第i个字符为中心时,回文子串的最大长度
//字符串s1,字符串s2为其反序
int calbyi(int i,int len) {
	//搜索区间的最小值、最大值
	int lb = 0, rb = min(i, len - i-1);	
	//字串的半径(不包括中心点),注意必须初始化应对i=0的情况
	int k=0;
	
	//使用二分法求解
	while (rb > lb) {
		//注意半径要大于等于1,否则会在第一个分支中死循环
		k = (lb + rb) / 2+1;
		//正、反序字串中要比较的子串的开始位置
		int b1 = i - k, b2 = len - k - (i + 1);
		//判断子串哈希值是否相等
		if (cmp(b1,b1+k-1,b2,b2+k-1)) {
			//以i为中心,k为半径的子串是回文子串,可以向两边拓展(用二分的方法)
			//所以修改区间的最小值,表示半径最少有k个字符
			lb = k;
		}
		else {
			//以i为中心,k为半径的子串不是回文子串,所以向中心收缩半径
			//修改区间的最大值,表示半径要小于k个字符
			rb = k - 1;
		}		
	}

	//求除掉'#'后的回文子串的长度,注意这里半径不能用k,因为上面退出循环时可能k的值大于正确半径
	//要根据中心字符的类型来判断
	//中心为'#',回文子串为偶数长度
	if (s1[i] == '#') {
		return ((lb + 1) / 2) * 2;
	}
	//中心为英文字符,回文串为奇数长度
	else{
		return 2*(lb/2) + 1;
	}
}

//计算字符串s1的最长回文字串长度
int cal(int len) {
	int maxlen=1;	//回文串的最大长度
	//遍历所有点,将其作为中心点求解
	for (int i = 0; i < len; i++) {
		maxlen = max(calbyi(i,len), maxlen);
	}
	return maxlen;
}


int main(void) {
	ios::sync_with_stdio(false);
	getpowp();		//打表

	//给s1,s2预留空间来优化
	s1.reserve(MAXLEN);
	s2.reserve(MAXLEN);

	//对所有字符串求解其最长回文子串长度
	string inputstr;
	int strnum = 0;
	int len = 0;
	while(true) {
		getline(cin, s1);
		if (s1 == "END")		break;
		//原始字符串和反序字符串
		len = s1.size();
		initstring(s1,len);		//给字符之间插入'#'
		s2 = s1;
		reverse(s2.begin(), s2.end());
		//计算两个字符串的H数组
		calH(H1, s1);
		calH(H2, s2);
		//求解
		result[++strnum]=cal(len);
	}
	
	//输出结果
	for (int i = 1; i <= strnum; i++) {
		cout << "Case " << i << ": " << result[i] << endl;
	}
}
posted @ 2022-07-01 20:23  带带绝缘体  阅读(311)  评论(0)    收藏  举报