AcWing139 哈希+二分+延伸字符串
目录
思考
1.题目询问的是一个字符串当中长度最大的回文串,数据长度为十万,暴力枚举是肯定不行的
考虑用哈希算法完成,那么如何用哈希算法来证明一个字符串是回文串呢?
当然是利用哈希前缀和,我们知道回文串就是一个正着读和倒着读都一样的字符串,那么,我们可以构造两个哈希前缀和,分别是输入字符串的正序和倒序,通过判断字符串前半段的前缀和以及字符串后半段的前缀和是否相等,就能知道该字符串是不是回文串了
但是,如果要找出字符的前半段和后半段,就涉及到分类讨论的问题,因为回文串的长度可能是奇数,也可能是偶数,那么,有没有一种办法,不用分类讨论呢?这就用到了下面介绍的延伸字符串的操作
2.延伸字符串,就是在每两个字符之间添加一个元素,这样无论原来的回文串是奇数还是偶数,都只变成了奇数回文串,下面证明这种方法的有效性:
假设字符串长度为 n ,延展字符串后,因为每两个字符间要添加一个字符,所以一共添加了 n - 1 个字符,这样字符串总长度变为 2 * n - 1 ,恒为奇数
代码实现
string s;
int n = s.length();
for(int i = n * 2; i >= 0; i -= 2) //拉伸字符串,并在每两个字符之间添加一个字符
{
s[i] = s[i / 2];
s[i - 1] = 'z' + 1; //添加的字符为 'z'的下一位
}
假设原字符串为 abc,延伸后变为 {a{b{c,可能你会问,不是添加 n - 1 个字符吗,那为什么 a 的前面会多添加了一个字符,因为其实这并不影响哈希算法判断该字符串中的一个字串是不是回文串以及回文串的最大长度。
3.现在我们已经知道怎么判断一个字串是不是回文串了,那么我们如何找出那个最大字串呢?
这里应该考虑二分算法:枚举延伸后的字符串的每一个字符作为中点,并二分查找半径,例如一个回文串 abcba ,他的半径就是 2 ,即不包含中点那个点的另一段 ab 和 ba 的长度
4.为什么要二分查找半径,我们知道,二分的使用条件一般需要有两个条件:单调性和有序性,而这个查找的半径就符合这两个特征,同时,我们可以根据这个半径很轻松的判断出回文串的最大长度,因为现在判断的回文串是延伸过的
例如我们现在查找的最大回文串是 a#b#a (为了方便观察,添加字符使用 '#' ),中点是 b ,属于原字符串,边界是 ‘a’,半径是 2,原回文串长度为 3
再例如 #b#c#b#c#,中点是 ’#‘ ,属于添加的字符,边界是 ‘#’ ,半径是 4,原回文串长度为4
由此可以推断出,当字符串边界是新添加的字符时,原字符长度 = 半径 + 1 ,当字符串边界是原字符串的字符时,长度 = 半径,
AC代码
#include<iostream>
#include<algorithm>
#include<string.h>
using namespace std;
const int N = 2000010,base = 131; //经验值131,13331
typedef unsigned long long ULL;
ULL hl[N],hr[N]; //hl 为正序的哈希前缀和,hr 为倒序的哈希前缀和
ULL p[N]; //指数数组 :pi 为 base 的 i 次方
char s[N];
ULL getHash(ULL a[], int l, int r)
{
return a[r] - a[l - 1] * p[r - l + 1];
}
int main()
{
int T = 1;
while(scanf("%s",s + 1), strcmp(s + 1, "END") ) //小细节:逗号表达式, &s+1 表示把字符串的第0位空出来,从下标1开始录入数据
{
int n = strlen(s + 1); //注意这里也要 +1,因为下标 0 处我们没有录入数据
for(int i = n * 2; i >= 0; i -= 2) //拉伸字符串,并在每两个字符之间添加一个字符
{
s[i] = s[i / 2];
s[i - 1] = 'z' + 1; //添加的字符为 'z'的下一位
}
n *= 2; //拉伸不要忘记将字符串长度 *2
//初始化Hash数组和指数数组
p[0] = 1;
for(int i = 1, j = n; i <= n; i ++, j --)
{
hl[i] = hl[i - 1] * base + s[i] - 'a' + 1;
hr[i] = hr[i - 1] * base + s[j] - 'a' + 1;
p[i] = p[i - 1] * base;
}
//枚举每一个字符作为中点,二分查找答案
int res = 0;
for(int i = 1; i <= n; i ++ )
{
int l = 0, r = min(i - 1, n - i);
while(l < r)
{
int mid = l + r + 1 >> 1; //要 +1 ,防止陷入死循环
if(getHash(hl, i - mid, i - 1) != getHash(hr, n - (i + mid) + 1, n - (i + 1) + 1)) //判断两端的前缀和是否相等
r = mid - 1;
else
l = mid;
}
if(s[i - l] <= 'z')
res = max(res, l + 1);
else
res = max(res, l);
}
printf("Case %d: %d\n", T ++, res);
}
return 0;
}
一个解释
if(getHash(hl, i - mid, i - 1) != getHash(hr, n - (i + mid) + 1, n - (i + 1) + 1))
对于一个字符数组,中点是 i,半径是 m,如何找到他的左右半段的哈希前缀和
假设 i = 4,m = 3
a1 a2 a3 a4 a5 a6 a7 a8 a9
i-m i-1 i i+1 i+m
前半段很好找,为区间[i - m, i - 1],后半段当然不是[i + 1, i + m],我们要把 a5,a6,a7 想象成 a3,a2,a1,注意不是 a1,a2,a3,
因为我们在前面定义了两个前缀和数组 HL,HR ,分别是正序和倒序,这里区间 [i + 1, i + m] 就相当于倒序前缀和 HR 的一个倒序,我们要把它转换成正序,推导公式如下(设字符串长度为 n):
原位置 反转后的位置
1 n
2 n-1
x n-(x-1)
,现在我们把 [i + 1, i + m] 反转,就成了 [n - (i + 1), n-( i + m) ],但是此时还没有完成,因为我们第一条已经说了,现在反转后的字符串相当于 a3,a2,a1,所以我们要反转区间,就成了最终结果:[n - (i + mid) + 1, n - (i + 1) + 1]
二分模板
// 注意 l,r 都是可取的
while(l < r)
{
int mid = l + r + 1 >> 1; //要 +1 ,防止陷入死循环
if(check() ) //符合条件
r = mid - 1;
else //否则
l = mid;
}
cout << l << endl;
// cout << r << endl;
关于二分的两个解释:
为什么二分会陷入死循环
(24条消息) 二分查找中的死循环_小白菜又菜-CSDN博客_二分查找死循环
二分边界问题
(24条消息) 二分查找以及二分边界的处理_qq_QIANXUN的博客-CSDN博客
补充
1.求hr反转后的范围,相当于求hl的前面的范围
2.拓展字符串,就可以忽略字符串长度为偶数或者奇数的时候的讨论,因为此时长度一定是奇数
3.strcmp函数是string compare(字符串比较)的缩写,
用于比较两个字符串并根据比较结果返回整数。
基本形式为strcmp(str1,str2),若str1=str2,则返回零;
若str1<str2,则返回负数;若str1>str2,则返回正数。
4.在C语言函数中:
原型声明:char *strcpy(char* dest, const char *src);
头文件:#include <string.h> 和 #include <stdio.h>
功能:把从src地址开始且含有NULL结束符的字符串复制到以dest开始的地址空间
说明:src和dest所指内存区域不可以重叠且dest必须有足够的空间来容纳src的字符串。
返回指向dest的指针