栈
经典场景:
- 括号画家
一排括号序列,其中包含小括号 ( )、中括号 [ ] 和大括号 { },总长度为 N。现在求最长合理的字串长度。
这道题是栈的经典题,先把各类括号的左半边入栈,然后遇到右半边时,弹栈时更新答案。如果遇到不匹配的地方,需要重新设置下次合理字串的起点。有一个小技巧是对于字符配对,可以创建一个128长度的整形数组。
#include<cstdio>#include<cmath>using namespace std;const int N = 1e5 + 10;char s[N];int stk[N], top = 0;char mp[128];int main() {scanf("%s", s);// "([{->123"mp[')'] = '(';mp[']'] = '[';mp['}'] = '{';int ans = 0;stk[0] = -1; // 特殊处理:当完全匹配时,长度应为i+1for(int i = 0; s[i]; i++) {if(s[i] == '(' || s[i] == '[' || s[i] == '{') {stk[++top] = i;}else if (top && s[stk[top]] == mp[(int)s[i]]) {top--;ans = max(ans, i - stk[top]); // 匹配成功,更新最大长度}else {top = 0;stk[top] = i; // 特殊处理:如果匹配错误,则将栈顶作为新的起点}}printf("%d\n", ans);return 0;}
- 表达式计算
给你一个表达式,类似于-(2+2)^4-3*5/(2-4),输出答案。
由于这道题存在括号,一个通用的做法是将表达式转化为后缀表达式,然后计算。总体思路是维护一个数栈,一个操作符栈。这道题需要注意的有2点,第一点是负数的处理,直接出现负号,我们可以通过向数栈中加0处理,第二点是在处理+-这种低优先级运算符时,需要先弹栈计算。
#include<cstdio>#include<cmath>using namespace std;const int N = 1e5 + 10;char s[N];int stk_num[N], num_top = 0, stk_op[N], op_top = 0;int cal(int a, int b, char c) {if(c == '+') return a + b;else if(c == '-') return a - b;else if(c == '*') return a * b;else if(c == '^') return pow(a, b);return a / b;}int main() {scanf("%s", s);// "(+-*/^" -> "012345"for(int i = 0; s[i]; i++) {char c = s[i];if(c >= '0' && c <= '9') { // 得出数字int num = 0;while(s[i] && s[i] >= '0' && s[i] <= '9') {num = num * 10 + s[i] - '0';i++;}i--;stk_num[++num_top] = num;}else if(c == ')') {while(num_top > 1 && op_top && stk_op[op_top] != '(') { // 计算括号内的表达式int a = stk_num[num_top--];int b = stk_num[num_top--];char o = stk_op[op_top--];//printf("%d %c %d == %d\n", b, o, a, cal(b, a, o));stk_num[++num_top] = cal(b, a, o);}op_top--;}else if(c == '*' || c == '/' || c == '^' || c == '(') {stk_op[++op_top] = c;}else {if(c == '-' && !(s[i - 1] >= '0' && s[i - 1] <= '9') && s[i - 1] != ')') { // 处理负数情况stk_num[++num_top] = 0;}while(num_top > 1 && op_top && stk_op[op_top] != '(') {int a = stk_num[num_top--];int b = stk_num[num_top--];char o = stk_op[op_top--];//printf("%d %c %d == %d\n", b, o, a, cal(b, a, o));stk_num[++num_top] = cal(b, a, o);}stk_op[++op_top] = c;}}while(op_top && num_top > 1) {int a = stk_num[num_top--];int b = stk_num[num_top--];char o = stk_op[op_top--];stk_num[++num_top] = cal(b, a, o);//printf("%d %c %d == %d\n", b, o, a, cal(b, a, o));}printf("%d\n", stk_num[num_top]);return 0;}
- 双栈排序
给出一个长度为N的序列(1~N,N<=1000),能否利用2个栈对序列进行排序,将第一个栈的入栈出栈操作记为ab,将第二个栈的入栈出栈操作记为cd,求字典序最小的操作序列。
这道题比较难切入,如果用暴力搜索,4^1000会超时。但是如何能判定能否用双栈进行排序,以及如何排序就能难。实际上,我们首先需要将这个序列中的数字划分成两部分,一部分通过栈A完成排序,另一部分通过栈B完成排序,因为假设数据可以通过一个栈进行排序,要想将其中的元素进行升序或降序,入栈和出栈的操作顺序都是唯一的。那如何判断哪些数字该划分为一起。这里就需要用到离散数学中的一个知识点:如果一个栈不可以对一个序列进行排序,那么对于i, j,满足i<j,存在k(i < j < k),满足a[k] < a[i] < a[j]。这样,因为a[k]的存在,a[i]必须先保留在栈中,且a[i]因为a[j]无法弹出了。通过这种方式将数据划分两部分,可能会存在矛盾,比如a 不能和 b 一起, a 不能和 c一起, c又不能和b一起,这样也无法通过两个栈完成排序。这个问题对应到图论中的模型,就是通过染色法来判断一个图是否为二分图。如果是二分图,那么就可以用两个栈来模拟答案,如果不是二分图,则无解。
#include<cstdio>#include<cmath>#include<cstring>using namespace std;const int N = 1010, M = N * N;int s1[N], s2[N], t1, t2;int color[N];int h[N], e[M], ne[M], idx;int n, a[N], f[N];void add(int a, int b) {e[idx] = b, ne[idx] = h[a], h[a] = idx++;}bool dfs(int u, int c) {color[u] = c;for(int i = h[u]; ~i; i = ne[i]) {int j = e[i];if(color[j] == c) {return false;}if(color[j] == -1 && !dfs(j, 1 - c)) {return false;}}return true;}int main() {scanf("%d", &n);for(int i = 0; i < n; i++) {scanf("%d", &a[i]);}f[n] = n + 1;for(int i = n - 1; i >= 0; i--) {f[i] = min(f[i + 1], a[i]);}// 判断两个数字能否输入一个栈,不能则连边memset(h, -1, sizeof h);idx = 0;// 根据 i<j<k, 存在k使得a[k]<a[i]<a[j] 建图for(int i = 0; i < n; i++) {for(int j = i + 1; j < n; j++) {if(a[i] < a[j] && a[i] > f[j + 1]) {add(i, j);add(j, i);}}}// 用染色法判断构造的图是否为二分图,用颜色数组表示每个点应该属于哪个栈bool ok = true;memset(color, -1, sizeof color);for(int i = 0; i < n; i++) {if(color[i] == -1 && !dfs(i, 0)) {ok = false;break;}}// 如果不是二分图,则无法用两个栈来完成升序排序if(!ok) {printf("0\n");return 0;}// 用两个栈来模拟答案t1 = t2 = 0;int num = 1;for(int i = 0; i < n; i++) { // 将第一个栈的操作放在第二个栈之前,保证操作字典序最小if(color[i] == 0) {s1[++t1] = a[i];printf("a ");}else {s2[++t2] = a[i];printf("c ");}while(true) {bool flag = false;while(t1 && s1[t1] == num) {printf("b ");t1--;num++;flag = true;}while(t2 && s2[t2] == num) {printf("d ");t2--;num++;flag = true;}if(!flag) {break;}}}printf("\n");return 0;}
单调栈
定义:当后面的数据需要确定“边界”时,发现并不是所有的数据都会被用到,比如只有比自己小的数据才有用,这种情况就可以用单调栈来优化判断,将O(n2)判断过程优化为O(n),及时地不可能的候选项排除掉。
经典例题
- 直方图中最大的矩形
```cpp
// 思路:枚举以每个长方形为最低点的最大面积
// 优化:寻找每个长方形的左右边界的过程用单调栈优化
include
include
using namespace std;
typedef long long int64;
const int N = 1e5 + 10;
int l[N], r[N], a[N], st[N], tt = 0;
int main() { int n; while(true) { scanf(“%d”, &n); if(!n) { break; }
for(int i = 0; i < n; i++) {scanf("%d", &a[i]);}tt = -1;st[++tt] = -1;for(int i = 0; i < n; i++) {while(tt && a[st[tt]] >= a[i]) tt--;l[i] = st[tt]; // 左边界(取不到)st[++tt] = i;}tt = -1;st[++tt] = n;for(int i = n - 1; i >= 0; i--) {while(tt && a[st[tt]] >= a[i]) tt--;r[i] = st[tt]; // 右边界(取不到)st[++tt] = i;}int64 ans = 0;for(int i = 0; i < n; i++) {ans = max(ans, 1ll * a[i] * (r[i] - l[i] - 1));}printf("%lld\n", ans);}return 0;
}
2. 城市游戏本质是求01矩形中,全1子矩阵的最大面积。这道题是上一题的拓展,我们可以对每一行求一遍直方图中的最大面积,更新答案即可。```cpp#include<cstdio>#include<iostream>#include<cstring>using namespace std;typedef long long ll;const int N = 1010;int a[N][N];int l[N], r[N], h[N], stk[N], top;int main() {int n, m;scanf("%d%d", &n, &m);string s;getline(cin, s);for(int i = 1; i <= n; i++) {getline(cin, s);//cout << s << endl;for(int j = 1, k = 1; j <= s.size(); j++) {if(s[j - 1] == 'R') {a[i][k++] = 0;}else if(s[j - 1] == 'F') {a[i][k++] = 1;}}}memset(h, 0, sizeof h);ll ans = 0;for(int i = 1; i <= n; i++) {for(int j = 1; j <= m; j++) {if(a[i][j] == 1) {h[j]++;}else {h[j] = 0;}}top = 0;stk[0] = 0;for(int j = 1; j <= m; j++) {while(top && h[stk[top]] >= h[j]) top--;l[j] = stk[top];stk[++top] = j;}top = 0;stk[0] = m + 1;for(int j = m; j >= 1; j--) {while(top && h[stk[top]] >= h[j]) top--;r[j] = stk[top];stk[++top] = j;}for(int j = 1; j <= m; j++) {ans = max(ans, 1LL * (r[j] - l[j] - 1) * h[j]);}}printf("%lld\n", 3 * ans);return 0;}
单调队列
定义:和单调栈一样,维护可能的候选项,即使排除不可能的选项。
经典场景
- 蚯蚓:
有很多蚯蚓,每秒钟将一个最长的蚯蚓一分为二,其他的蚯蚓增加一个非负整数q。由于所有元素都需要变大,而互相之间的大小关系并没有发生变化,这时可以使用一个偏移量,表示所有元素的增大量,而别切开的蚯蚓减去q后再放回最大堆即可。值得注意的是,元素出队时需要加上偏移量还原真实值,入队时需要减去偏移量,进行还原。
如果单纯地用优先队列,有多次入队和出队操作,每次都要消耗O(logn)的时间,在本题中有一个独特的性质,蚯蚓都是按等比例切分。如果切分前蚯蚓A长于蚯蚓B,则切分后对应的两段A也比B长,因此可以维护3个单调队列,分别存放原始降序排列的元素(队列1),切分后较长的一段(队列2),切分后较短的一段(队列3),这样整体最大值就是三个队列的队头的最大值,而切分后的两端一定小于之前所有切分的情况,直接添加在队列的最后即可,时间复杂度由O(logn)降为O(1)。
#include<cstdio>#include<algorithm>using namespace std;const int N = 1e8 + 10;int a[N], q1[N], q2[N], q3[N];int hh1, hh2, hh3, tt1, tt2, tt3;int get_max() {int v1 = hh1 <= tt1 ? q1[hh1] : -1e9;int v2 = hh2 <= tt2 ? q2[hh2] : -1e9;int v3 = hh3 <= tt3 ? q3[hh3] : -1e9;int x = max(v1, max(v2, v3));//printf("x: %d\n", x);if(x == v2) {hh2++;}else if(x == v3) {hh3++;}else {hh1++;}return x;}int main() {int n, m, q, u, v, t;scanf("%d%d%d%d%d%d", &n, &m, &q, &u, &v, &t);hh1 = hh2 = hh3 = 0;tt1 = tt2 = tt3 = -1;for(int i = 0; i < n; i++) {scanf("%d", &a[i]);}sort(a, a + n);for(int i = n - 1; i >= 0; i--) {q1[++tt1] = a[i];}int diff = 0;for(int i = 1; i <= m; i++) {int x = get_max();x += diff;if(i % t == 0) {printf("%d ", x);}int half1 = 1.0 * u / v * x;int half2 = x - half1;q2[++tt2] = max(half1 - diff - q, half2 - diff - q);q3[++tt3] = min(half1 - diff - q, half2 - diff - q);diff += q;}printf("\n");for(int i = 1; i <= n + m; i++) {int x = get_max();//printf("i: %d, x: %d\n", i, x);if(i % t == 0) {printf("%d ", x + diff);}}printf("\n");return 0;}
- 最大子序列和:
求一个数组中连续子序列的和的最大值,子序列长度不超过m。思路是通过单调队列维护候选项,队头是最小值,但是最远,队尾是最大值,但是最近,因为后面队头的元素因为距离太远会失效,所以可以通过单调队列维护。
#include<cstdio>#include<cmath>using namespace std;const int N = 3e5 + 10;int q[N], hh, tt;int a[N], s[N];int main() {int n, m;scanf("%d%d", &n, &m);for(int i = 1; i <= n; i++) {scanf("%d", &a[i]);}for(int i = 1; i <= n; i++) {s[i] = s[i - 1] + a[i];}hh = 0, tt = -1;q[++tt] = 0;int ans = -1e9;for(int i = 1; i <= n; i++) {if(hh <= tt && i - q[hh] > m) hh++; // m不小于1,所以第一次不会发生弹出的情况ans = max(ans, s[i] - s[q[hh]]);while(hh <= tt && s[q[tt]] >= s[i]) tt--;q[++tt] = i;}printf("%d\n", ans);return 0;}
- 滑动窗口
滑动窗口是单调队列最经典的应用,在这总结一下单调队列应用时的几个常见的操作:将不合法的元素出队,入队时先将无效选项弹出,入队。
#include<iostream>using namespace std;const int N = 1e6 + 10;int a[N];int q[N], hh, tt;int main() {int n, k;scanf("%d%d", &n, &k);for(int i = 0; i < n; i++) {scanf("%d", &a[i]);}hh = 0, tt = -1;for(int i = 0; i < n; i++) {if(hh <= tt && q[hh] < i - k + 1) hh++;while(hh <= tt && a[q[tt]] >= a[i]) tt--;q[++tt] = i;if(i >= k - 1) {printf("%d ", a[q[hh]]);}}printf("\n");hh = 0, tt = -1;for(int i = 0; i < n; i++) {if(hh <= tt && q[hh] < i - k + 1) hh++;while(hh <= tt && a[q[tt]] <= a[i]) tt--;q[++tt] = i;if(i >= k - 1) {printf("%d ", a[q[hh]]);}}printf("\n");return 0;}
链表
定义:链式的结构,可以找到前一个结点,后一个结点,并且能很方便地删除结点,在中间插入结点。
经典场景:
- 邻值查找
给定一个长度为 n 的序列 A,A 中的数各不相同。对于 A 中的每一个数 A[i],求:MIN1≤j以及令上式取到最小值的 j(记为 p[i])。若最小值点不唯一,则选择使 j 较小的那个。
思路:首先n2算法默认一致忽略-_-。这道题有两种解法,分别利用了链表的方便插入和删除的两个性质。解法一从正向做,先建立一个双向链表,对于一个点,先利用set找到插入位置,将数值插入链表,并得到答案,过程中还需要利用map存储元素到结点编号的映射。解法二从反向做,先对数组进行一轮带下标的排序(可以用pair
由于解法一会多次利用set查找插入位置(1238ms),效率不如解法二更优(255ms),下面是解法二的代码
#include<cstdio>#include<algorithm>using namespace std;typedef long long int64;typedef pair<int64,int> pli;const int N = 1e5 + 10;pli a[N];int p[N], l[N], r[N];int ans[N][2];int main() {int n;scanf("%d", &n);for(int i = 1; i <= n; i++) {scanf("%lld", &a[i].first);a[i].second = i;}a[0].first = -3e9, a[0].second = 0;a[n + 1].first = 3e9, a[n + 1].second = n + 1;r[0] = 1, l[n + 1] = n;sort(a + 1, a + 1 + n);for(int i = 1; i <= n; i++) {l[i] = i - 1, r[i] = i + 1;p[a[i].second] = i;}for(int i = n; i > 1; i--) {int j = p[i]; // 找到排序后原来第N个元素的新位置int left = l[j], right = r[j];int64 t1 = abs(a[left].first - a[j].first), t2 = abs(a[right].first - a[j].first);if(t1 <= t2) {ans[i][0] = t1, ans[i][1] = a[left].second;}else {ans[i][0] = t2, ans[i][1] = a[right].second;}r[left] = right, l[right] = left; // 利用链表快速删除和查找结点}for(int i = 2; i <= n; i++) {printf("%d %d\n", ans[i][0], ans[i][1]);}return 0;}
哈希
哈希通常是为了快速计算一个较为复杂的东西(比如一个数组)有没有出现过,通常包括两部分,哈希值计算,以及冲突处理(我一般是通过链表处理)。
经典场景:
- 字符串哈希
给出一段很长的字符串序列,然后再给出若干区间,判断这些区间的字串内容是否相同。该题是字符串哈希的模板题,里面值得注意的一点是首字母是权值最高的元素,然后通过前缀和的思想得到某一部分的哈希值,另外通过unsigned long long避免位溢出。
#include<cstdio>#include<cstring>using namespace std;typedef unsigned long long ull;const int N = 1e6 + 10, M = 131;ull p[N], h[N];char s[N];bool same(int l1, int r1, int l2, int r2) {ull v1 = h[r1] - h[l1 - 1] * p[r1 - l1 + 1];ull v2 = h[r2] - h[l2 - 1] * p[r2 - l2 + 1];return v1 == v2;}int main() {scanf("%s", s + 1);int n = strlen(s + 1);p[0] = 1, s[0] = 0;for(int i = 1; i <= n; i++) {p[i] = p[i - 1] * M;h[i] = h[i - 1] * M + s[i] - 'a';}int m;scanf("%d", &m);for(int i = 0; i < m; i++) {int l1, r1, l2, r2;scanf("%d%d%d%d", &l1, &r1, &l2, &r2);if(same(l1, r1, l2, r2)) {printf("Yes\n");}else {printf("No\n");}}return 0;}
- 最长回文字符子串
给出一个字符串,求该字符串的最长回文字符字串的长度。这道题也可以用字符串哈希处理,我们可以二分最长回文字符字串的长度,然后结合二分,快速缩小答案的区间。这里值得注意的一点是,回文字符串有两种形式,分别是长度是奇数的字符串和长度为偶数的字符串,这两种不可以一起判断。比如我们判断长度为8的回文不存在,长度为4的回文不存在,然而长度为5的回文可能存在。因此,这里不能简单的二分长度,然后枚举左端点来做。结合两种二分的情况,我们可以枚举中间点,然后写两遍二分,分别判断两种情况下答案的最大值。
#include<cstdio>#include<cstring>#include<cmath>using namespace std;typedef unsigned long long ull;const int N = 1e6 + 10, M = 131;char s[N];ull p[N], h1[N], h2[N];int n;bool same(int l, int r) {int l1 = n + 1 - r, r1 = n + 1 - l;ull v1 = h1[r] - h1[l - 1] * p[r - l + 1];ull v2 = h2[r1] - h2[l1 - 1] * p[r1 - l1 + 1];return v1 == v2;}bool check_even(int len) {for(int i = 1; i + 1 <= n; i++) {int l = i - len + 1, r = i + len;if(l < 1) continue;if(r > n) break;if(same(l, r)) {return true;}}return false;}bool check_odd(int len) {for(int i = 1; i <= n; i++) {int l = i - len, r = i + len;if(l < 1) continue;if(r > n) break;if(same(l, r)) {return true;}}return false;}int main() {int T = 0;while(true) {scanf("%s", s + 1);if(s[1] == 'E') {break;}n = strlen(s + 1);p[0] = 1, h1[0] = h2[0] = 0;for(int i = 1; i <= n; i++) {p[i] = p[i - 1] * M;h1[i] = h1[i - 1] * M + s[i] - 'a' + 1;h2[i] = h2[i - 1] * M + s[n + 1 - i] - 'a' + 1;}int ans = 0;int l = 0, r = n >> 1;while(l < r) {int mid = l + r + 1 >> 1;if(check_odd(mid)) {l = mid;}else {r = mid - 1;}}ans = max(ans, l * 2 + 1);l = 0, r = n >> 1;while(l < r) {int mid = l + r + 1 >> 1;if(check_even(mid)) {l = mid;}else {r = mid - 1;}}ans = max(ans, l * 2);printf("Case %d: %d\n", ++T, ans);}return 0;}
- 后缀数组的朴素求法()
后缀数组包含2个数组,数组SA和Height。我们将从k(0<=k
// 求SA,快排logn层 * (n次比较 * 二分和哈希logn每次) = nlogn^2// 求height, n次比较 * 二分加哈希logn每次 = nlogn#include<cstdio>#include<cstring>#include<cmath>using namespace std;typedef unsigned long long ull;const int N = 3e5 + 10, M = 131;char s[N];ull p[N], h[N];int a[N], n, hei[N];bool same(int l1, int l2, int len) {int r1 = l1 + len - 1, r2 = l2 + len - 1;ull v1 = h[r1] - h[l1 - 1] * p[r1 - l1 + 1];ull v2 = h[r2] - h[l2 - 1] * p[r2 - l2 + 1];return v1 == v2;}int common_len(int u, int v) {int len = min(n - u + 1, n - v + 1);int l = 0, r = len;while(l < r) {int mid = l + r + 1 >> 1;if(same(u, v, mid)) {l = mid;}else {r = mid - 1;}}return l;}bool cmp(int u, int v) {int len = min(n - u + 1, n - v + 1);int c_len = common_len(u, v);if(c_len == len) {return u > v; // 如果全部相等,长度短的字符串排序在前}return s[u + c_len] < s[v + c_len]; // 比较不相等的第一个字符}void qsort(int l, int r) {if(l >= r) {return;}int i = l - 1, j = r + 1, x = a[l + r >> 1];while(i < j) {do i++; while(cmp(a[i], x));do j--; while(cmp(x, a[j]));if(i < j) {swap(a[i], a[j]);}}qsort(l, j), qsort(j + 1, r);}int main() {scanf("%s", s + 1);n = strlen(s + 1);p[0] = 1;for(int i = 1; i <= n; i++) {a[i] = i;p[i] = p[i - 1] * M;h[i] = h[i - 1] * M + s[i] - 'a';}qsort(1, n);hei[1] = 0;for(int i = 2; i <= n; i++) {hei[i] = common_len(a[i - 1], a[i]);}for(int i = 1; i <= n; i++) {printf("%d ", a[i] - 1);}printf("\n");for(int i = 1; i <= n; i++) {printf("%d ", hei[i]);}printf("\n");return 0;}
- 矩阵
给出一个NM的01矩阵,再给出若干个AB的小矩阵,询问这些小矩阵是否在大矩阵中出现过。
这道题相当于问一些字符串是否出现过。我们可以先把每个A*B的矩阵的哈希值计算出来,然后存在一个数组里,通过二分快速查找。现在唯一的问题就是如何计算小矩阵的hash值,这涉及到二维哈希值的计算,我们可以先计算大矩阵中每一行的哈希值,然后再构造出小矩阵的hash值,这就是二维hash计算。
#include<cstdio>#include<cstring>#include<algorithm>using namespace std;typedef unsigned long long ull;const int N = 1010;ull h[N][N], p[N];int P = 131;int n, m, a, b, q;char s[N];ull v[N * N];int idx = 0;ull get(ull hr[], int l, int r) {return hr[r] - hr[l - 1] * p[r - l + 1];}void cal(int x, int y) {ull tmp = 0;for(int i = x - a + 1; i <= x; i++) {tmp = tmp * p[b] + get(h[i], y - b + 1, y);}v[idx++] = tmp;}int find(ull x) {int l = 0, r = idx - 1;while(l < r) {int mid = l + r >> 1;if(v[mid] >= x) {r = mid;}else {l = mid + 1;}}if(v[l] != x) {return 0;}return 1;}int main() {scanf("%d%d%d%d", &n, &m, &a, &b);memset(h, 0, sizeof h);p[0] = 1;for(int i = 1; i <= n; i++) {p[i] = p[i - 1] * P;}for(int i = 1; i <= n; i++) {scanf("%s", s + 1);for(int j = 1; j <= m; j++) {h[i][j] = h[i][j - 1] * P + s[j] - '0';}}for(int i = a; i <= n; i++) {for(int j = b; j <= m; j++) {cal(i, j); // 计算小矩阵的hash值}}sort(v, v + idx);scanf("%d", &q);while(q--) {ull tmp = 0;for(int i = 0; i < a; i++) {scanf("%s", s);for(int j = 0; j < b; j++) {tmp = tmp * P + s[j] - '0';}}printf("%d\n", find(tmp));}return 0;}
- 匹配统计
给出一个字符串A和一个字符串B,给出一个数字x,求A的所有后缀子串中和B匹配长度(从头匹配)为x的字串有多少?
本题可以先计算一遍字符串哈希,然后对A的每一个后缀子串直接采用二分结合字符串hash的方式判断该后缀子串的匹配长度,时间复杂度:O(N+NlogN)。
#include<iostream>using namespace std;typedef unsigned long long ull;const int N = 2e5 + 10, P = 131;ull p[N], h1[N], h2[N];char s1[N], s2[N];int n, m, q, x;int cnt[N];ull get_hash(ull h[], int l, int r) {return h[r] - h[l - 1] * p[r - l + 1];}int main() {scanf("%d%d%d", &n, &m, &q);scanf("%s%s", s1 + 1, s2 + 1);p[0] = 1;h1[0] = h2[0] = 0;for(int i = 1; i <= n; i++) {p[i] = p[i - 1] * P;h1[i] = h1[i - 1] * P + s1[i] - 'a';}for(int i = 1; i <= m; i++) {h2[i] = h2[i - 1] * P + s2[i] - 'a';}for(int i = 1; i <= n; i++) {int l = i - 1, r = min(n, i + m - 1); // 求相等的右端点while(l < r) {int mid = l + r + 1 >> 1;int len = mid - i + 1;if(get_hash(h1, i, mid) == get_hash(h2, 1, len)) {l = mid;}else {r = mid - 1;}}cnt[r - i + 1]++;}while(q--) {scanf("%d", &x);printf("%d\n", cnt[x]);}return 0;}
KMP
KMP算法求一个字符串的每个位置为终点的子串,求最长前缀匹配,即s[1]~s[i]=s[j - i + 1]~s[j]。
经典场景:
- 求字符串所有前缀的最小循环节(前缀长度大于1,循环次数大于1)
推论:字符串s具有长度为len的循环节,是 len|n 且 s[1]~s[n-len]=s[len + 1]~s[n]的充要条件。两边都很好证明。因此,如果想得到字符串s的最小循环节,则需要len最小,对应的是s[len + 1]~s[n]的长度最大,等价于s[n]的next[n],转化为KMP求next数组。n-len = next[n],故len = n - next[n],如果n能整除(n - next[n]),则字符串s具有长度为n-next[n]的最小循环节。
此外,一个字符串的任意循环节的长度最小循环节的长度的整数倍。因为若s[i]=s[i+x]=s[i+y],则s[i]=s[i+mx+ny]=s[i+gcd(x,y)],最小循环节长度为len,则必然满足 len | gcd(x,y)且len<=gcd(x,y),因此得出len | x 和 len | y。
#include<cstdio>using namespace std;const int N = 1e6 + 10;char s[N];int ne[N], n;int main() {int T = 0;while(true) {scanf("%d", &n);if(n == 0) {break;}scanf("%s", s + 1);for(int i = 2, j = 0; i <= n; i++) {while(j > 0 && s[i] != s[j + 1]) j = ne[j];if(s[i] == s[j + 1]) {j++;}ne[i] = j;}printf("Test case #%d\n", ++T);for(int i = 2; i <= n; i++) {if(ne[i] != 0 && i % (i - ne[i]) == 0) {printf("%d %d\n", i, i / (i - ne[i]));}}printf("\n");}return 0;}
- 奶牛矩阵
给出一个由字母构成的矩阵,求最小子矩阵的面积,使得子矩阵扩张任意次后,可以包含原来的矩阵。
这道题本质上是二维的最小循环节问题,我们可以把一行看作一个字母,然后做一遍字符串的最小循环节,然后得到行的最小循环节长度。同理,在列上也做一遍,两者的乘积既是答案。这里有个判断两个字符串数组是否相等的API,若两个字符串相等,则strcmp(s1, s2)返回0,不等则返回1。值得注意的一点是,在做kmp的时候,字符串的起始下标习惯上用1,而strcmp需要让s1和s2从下标0开始比较,所以采用strcmp(s1+1,s2+1)。
#include<iostream>#include<cstring>using namespace std;const int N = 1e4 + 10, M = 80;char s1[N][M], s2[M][N];int ne1[N], ne2[M];int n, m;int main() {scanf("%d%d", &n, &m);for(int i = 1; i <= n; i++) {scanf("%s", s1[i] + 1);}for(int i = 1; i <= n; i++) {for(int j = 1; j <= m; j++) {s2[j][i] = s1[i][j];}}ne1[1] = ne2[1] = 0;for(int i = 2, j = 0; i <= n; i++) {while(j > 0 && strcmp(s1[i] + 1, s1[j + 1] + 1)) {j = ne1[j];}if(!strcmp(s1[i] + 1, s1[j + 1] + 1)) {j++;}ne1[i] = j;}for(int i = 2, j = 0; i <= m; i++) {while(j > 0 && strcmp(s2[i] + 1, s2[j + 1] + 1)) {j = ne2[j];}if(!strcmp(s2[i] + 1, s2[j + 1] + 1)) {j++;}ne2[i] = j;}int w = m - ne2[m], h = n - ne1[n];printf("%d\n", w * h);return 0;}
- 匹配统计
给出一个字符串A和一个字符串B,给出一个数字x,求A的所有后缀子串中和B匹配长度(从头匹配)为x的子串有多少?
这个题之前用字符串哈希的方式做过一次,这里介绍一种KMP的方法。匹配理所当然的可以想到KMP,但是KMP求的是前缀子串的结尾的匹配。
假设前缀子串的在位置i的匹配是[i-j+1,i],那么匹配长度是j,我们记录cnt[j]++,但是对于从i-j+1开始的后缀子串,这不是该子串的真正匹配,因为后面的字符可能进一步扩大匹配长度,所以cnt[j]表示的含义是匹配长度至少是j的后缀子串数量(难点一)。
而且对于从i-j+1开始的后缀子串,其还包括[i-ne[j]+1,i]的匹配,所以我们计算cnt[j]++的同时,还需要计算cnt[ne[j]]++,这样往复造成算法效率变低。因为对于所有的位置,如果存在cnt[j]++的计算,必然存在cnt[ne[j]]++。因此,我们不妨先在每个位置记录下“最长”的匹配cnt[j],对于cnt[ne[j]],我们可以用一个倒序遍历来累加,即cnt[ne[j]]+=cnt[j](难点二)。
最后,我们得到了cnt数组,在求匹配长度为x的子串数量时,采用后缀和的思想,ans=cnt[x] - cnt[x+1]。(难点三)
#include<iostream>#include<unordered_map>#include<cstring>using namespace std;const int N = 2e5 + 10;char s1[N], s2[N];int ne[N];int n, m, q, x;unordered_map<int,int> mp;int cnt[N];int main() {scanf("%d%d%d", &n, &m, &q);scanf("%s%s", s1 + 1, s2 + 1);ne[1] = 0;for(int i = 2, j = 0; i <= m; i++) {while(j && s2[i] != s2[j + 1]) {j = ne[j];}if(s2[i] == s2[j + 1]) {j++;}ne[i] = j;}memset(cnt, 0, sizeof cnt);for(int i = 1, j = 0; i <= n; i++) {while(j && s1[i] != s2[j + 1]) {j = ne[j];}if(s1[i] == s2[j + 1]) {j++;}cnt[j]++; // 以位置i作为结尾的匹配的长度是j(kmp定义),后续可能继续拓展,因此cnt[j]定义是匹配长度至少为j的数量,先统计最大}for(int i = m; i >= 1; i--) {cnt[ne[i]] += cnt[i]; // cnt[j]表示匹配长度至少为j,一定会被cnt[ne[j]]所包含,依次,cnt[ne[ne[j]]会包含cnt[j],倒序累加}while(q--) {scanf("%d", &x);printf("%d\n", cnt[x] - cnt[x + 1]); // 后缀和思想}return 0;}
字典树
存储多个字符串,整理成一颗树的形式,方便进行特定的统计。
经典场景:
- 前缀统计
给出n个字符串,再给出m次询问,每次询问给出一个字符串s,回答之前输入的n个字符串中有多少个是字符串s的前缀。
这道题可以用字典树,将输入的n个字符串构造字典树,然后每插入一个字符串,给对应位置的结点数量加一。之后每次询问时,我们根据字符串s沿着字典树向下走,如果某个结点的数量大于0,则说明出现了前缀,累加进答案。
#include<cstdio>using namespace std;const int N = 1e6 + 10;int son[N][26], idx = 0, cnt[N * 26];char s[N];void insert(char *s) {int p = 0;for(int i = 0; s[i]; i++) {int c = s[i] - 'a';if(!son[p][c]) {son[p][c] = ++idx;}p = son[p][c];}cnt[p]++;}int query(char *s) {int p = 0, ans = 0;for(int i = 0; s[i]; i++) {int c = s[i] - 'a';if(!son[p][c]) {break;}p = son[p][c];ans += cnt[p];}return ans;}int main() {int n, m;scanf("%d%d", &n, &m);for(int i = 0; i < n; i++) {scanf("%s", s);insert(s);}for(int i = 0; i < m; i++) {scanf("%s", s);printf("%d\n", query(s));}return 0;}
- 最大异或对
给出一个数组,求其中任意两个元素异或的最大值。
#include<cstdio>#include<cmath>using namespace std;const int N = 1e5 + 10;int son[N * 32][2], idx = 0;int a[N], n;void insert(int x) {int p = 0;for(int i = 30; i >= 0; i--) {int v = x >> i & 1;if(!son[p][v]) {son[p][v] = ++idx; // 有多少个节点,对应son数组的第一维的大小}p = son[p][v];}}int query(int x) {int ans = 0, p = 0;for(int i = 30; i >= 0; i--) {int v = x >> i & 1;if(son[p][!v]) {p = son[p][!v];ans |= (!v) << i;}else {p = son[p][v];ans |= v << i;}}return ans ^ x;}int main() {scanf("%d", &n);for(int i = 0; i < n; i++) {scanf("%d", &a[i]);}for(int i = 0; i < n; i++) {insert(a[i]);}int ans = 0;for(int i = 0; i < n; i++) {ans = max(ans, query(a[i]));}printf("%d\n", ans);return 0;}
- 最长异或值路径
给出一个树,树边有权值,求树上任意两点间路径的最大异或值。首先任意两点之间的路径异或值,可以看作这两点到根结点的路径的异或值的异或(公共部分抵消),因此这道题可以转化为:给你N个数字(所有节点到根的异或值),然后求任意两个数字的异或的最大值,就等价于上一题了。考的是建图+树简单遍历+字典树。
#include<cstdio>#include<cmath>#include<cstring>using namespace std;const int N = 1e5 + 10;int son[N * 30][2], s_idx = 0;int h[N], e[N], ne[N], w[N], h_idx, n;int ind[N], a[N];void add(int a, int b, int c) {e[h_idx] = b, w[h_idx] = c, ne[h_idx] = h[a], h[a] = h_idx++;}void dfs(int u, int v) {a[u] = v;for(int i = h[u]; ~i; i = ne[i]) {int j = e[i];dfs(j, v ^ w[i]);}}void insert(int x) {int p = 0;for(int i = 30; i >= 0; i--) {int v = x >> i & 1;if(!son[p][v]) {son[p][v] = ++s_idx;}p = son[p][v];}}int query(int x) {int ans = 0, p = 0;for(int i = 30; i >= 0; i--) {int v = x >> i & 1;if(son[p][!v]) {p = son[p][!v];ans |= (!v) << i;}else {p = son[p][v];ans |= v << i;}}return ans ^ x;}int main() {memset(h, -1, sizeof h);h_idx = 0;memset(ind, 0, sizeof ind);scanf("%d", &n);for(int i = 0; i < n - 1; i++) {int u, v, x;scanf("%d%d%d", &u, &v, &x);add(u, v, x);ind[v]++;}for(int i = 0; i < n; i++) {if(!ind[i]) {dfs(i, 0);break;}}for(int i = 0; i < n; i++) {insert(a[i]);}int ans = 0;for(int i = 0; i < n; i++) {ans = max(ans, query(a[i]));}printf("%d\n", ans);return 0;}
- 电话列表
给出一系列字符串,判断是否存在某个字符串是另一个字符串的前缀,如果是,返回NO
。
多个字符串之间的联系,考虑字典树。字符串出现前缀有两种情况,第一种情况是插入的字符串完全在某个路径上,第二种情况是插入的字符串经过某个字符串的结尾节点。
#include<iostream>#include<cstring>using namespace std;const int N = 100010, M = 11; // 10000个字符串,每个字符串最多创造10个新节点,故第一维1e5+10int son[N][M], idx = 0;int cnt[N];char s[M];bool insert(char *s) { // 发现前缀返回truebool suffix = false;bool same = true;int p = 0;for(int i = 0; s[i]; i++) {int x = s[i] - '0';if(!son[p][x]) {son[p][x] = ++idx;same = false;}p = son[p][x];if(cnt[p]) {suffix = true;}}cnt[p] = 1;return same || suffix;}int main() {int T;scanf("%d", &T);while(T--) {int n;scanf("%d", &n);memset(son, 0, sizeof son);memset(cnt, 0, sizeof cnt);idx = 0;bool flag = false;for(int i = 0; i < n; i++) {scanf("%s", s);if(insert(s)) {flag = true;}}if(flag) {printf("NO\n");}else {printf("YES\n");}}return 0;}
二叉堆
一般在程序里,直接使用优先队列的即可,这里有一点需要注意,如果只是简单地需要int或pair
struct Node {int i, j, val;bool operator< (const Node & x) const { // 注意:这是小根堆的写法return val > x.val;}};priority_queue<Node> pq;
经典场景:
- 序列
给出N个数组,每个数组有M个数字,如果每个数组选一个数字,一共可以组成m^n个序列,求所有序列中最小的m个序列的值。
这道题蕴含了一个很好的思维方式。直观地看,第一个最小的序列一定是所有序列排序后的第一个数字组成,我们可以把这种排列方式和对应的值定义成结构体,然后用队列拓展m次即可,但是这个方法隐藏了两个很大的问题:第一个问题是重复的状态会被多次拓展,导致答案错误;第二个问题是最小值一次会拓展出很多候选项,状态数量会爆炸。这道题可以用数学归纳法的思想来做,我们可以先得到前两个数组中最小的前m个元素,合并后再和第三个数组进行合并,合并N-1次之后就是答案。而每次合并两个数组的状态数量大大减少,我们只需要保证其中一个数组有序,将有序数组的第一个数字和第二个数组的全部数字组成的状态全部加入,后面不断向后移动第一个数组的指针即可。由于合并过程会按顺序得到当前最小值,所以整个算法只需要对第一个数组排序。
这种先将问题简化,再拓展的思维方法(数学归纳法)值得好好学习!
固定一个变量,然后主要看另一个变量的变化,这种方式有点像微积分里求偏导数的做法,数学的思维方式在程序设计里还挺常见的。
#include<cstdio>#include<queue>#include<cstring>#include<algorithm>using namespace std;const int N = 1010, M = 2010;int a[M], b[M], c[M];int n, m;struct Node {int i, j, val;bool operator< (const Node & x) const { // 注意:这是小根堆的写法return val > x.val;}};void merge() { // 合并a, b, 将答案升序放在a中priority_queue<Node> pq;for(int i = 0; i < m; i++) {pq.push({0, i, a[0] + b[i]}); // 先把所有的b全部入队,这样可以保证后续只需要移动a的指针,避免出现重复情况}for(int k = 0; k < m; k++) {Node node = pq.top();pq.pop();c[k] = node.val;pq.push({node.i + 1, node.j, a[node.i + 1] + b[node.j]});}memcpy(a, c, sizeof c);}int main() {int T;scanf("%d", &T);while(T--) {scanf("%d%d", &n, &m);for(int i = 0; i < m; i++) {scanf("%d", &a[i]);}sort(a, a + m);for(int i = 1; i < n; i++) {for(int j = 0; j < m; j++) {scanf("%d", &b[j]);}merge();}for(int i = 0; i < m; i++) {printf("%d ", a[i]);}printf("\n");}return 0;}
- 数据备份
有N个点在一排,有K个线,每个线可以连接2个点,K条线一共可以连接2K个点,求K条线的最短长度。(K个线的两端都不一样)。
首先,如果要总长度最短,那么线段之间不能有重合部分,且每条线段都应该连接相邻的两个点。因此,相当于给出N-1个数字(每两个点之间的间距),然后我们从中选择K个,并且有个限制条件,选择了第i个数字,就不能选择第i-1个数字和第i+1个数字,因为线段的两端不能重合。在这种限制条件下,最优选择可以有2种情况:第一种就是我们选择最小的数字,然后删除最小的数字左右两边的数字。第二种是选择最小的数字左右两边的数字,然后删除3个数字。如何统一来考虑这两种情况是本题的精髓,我们可以先采用第一种选择,先选出当前集合中最小的数字,然后删除左右两边的数字,然后再将最小的数字a[i]的值修改为a[i-1]+a[i+1]-a[i],这样,如果之后选择了修改后的数字,说明选择二比选择一更好,同时也能维护数据结构的一致性。此外,由于我们需要的操作需要能得到最小值,且要能删除指定位置和插入元素,可以采用小根堆和双向链表来实现。另外有一点值得注意的是,如果删除的结点包含哨兵结点,那么新加入的结点应该不能被选择,因为哨兵节点没有实际意义,不能表示为一个真实的结点,这个时候只需要将哨兵结点的值设为大数值,避免新节点被选择即可。(在这个点上卡了1整天)
#include<cstdio>#include<queue>#include<cstring>using namespace std;typedef pair<int,int> pii;const int N = 2e5 + 10;int a[N], b[N];int e[N], r[N], l[N], head, tail, idx;void insert(int x, int pos) { // 在pos之后插入xe[idx] = x;r[idx] = r[pos], l[r[pos]] = idx;l[idx] = pos, r[pos] = idx;idx++;}int delete_node(int pos) { //删除掉一个点,并返回删除结点的前一个点的编号l[r[pos]] = l[pos];r[l[pos]] = r[pos];return l[pos];}int main() {int n, k;scanf("%d%d", &n, &k);for(int i = 0, p, x; i < n; i++) {scanf("%d", &x);if(i != 0) {a[i] = x - p;}p = x;}head = 1, tail = 2, idx = 3;e[head] = 1e9, e[tail] = 1e9; // 如果要删除哨兵结点,则新加入的结点不该被选择,所以数值设为最大r[head] = tail, l[tail] = head;priority_queue<pii,vector<pii>,greater<pii>> pq;for(int i = 1; i < n; i++) {pq.push({a[i], idx});insert(a[i], l[tail]);}memset(b, 0, sizeof b); // 标记结点编号是不是处理过int ans = 0;for(int i = 0; i < k; i++) {pii p = pq.top();pq.pop();int id = p.second;int val = 0;if(b[id]) { // 如果是删除过的结点,就忽略i--;continue;}ans += p.first;b[l[id]] = 1;val += e[l[id]];delete_node(l[id]);b[r[id]] = 1;val += e[r[id]];delete_node(r[id]);b[id] = 1;val -= e[id];int n_pos = delete_node(id);pq.push({val, idx});insert(val, n_pos);}printf("%d\n", ans);return 0;}
- 荷马史诗
给出N个单词和对应的频率,我们希望将单词变成K进制的形式,然后保证编码后整体的长度最短,其次希望最长的单词的长度最短。
这道题本质上是K进制的哈夫曼树。整体思路还是用小根堆优先合并数值小的元素,但是需要注意两点。一是这是K进制,对应的是K叉树,如果直接合并,可能会让最后一次合并的元素数量不足K个,这样就不是最优的,所以我们需要添加若干个数值为0的结点,让大数值的结点,充满整个合并过程。二是如何保证最长的单词长度最短,以后二叉树形式合并时,大多是直接求一个最优结果,并没有让我们思考第二问,而最长单词的最短长度等价于问合并好后树高最低是多少,我们合并时可以将结点高度作为第二关键字,在数值相同时,优先合并高度较低的结点,这样就能保证在不影响最优解的情况下,保证树高最低。
#include<cstdio>#include<queue>using namespace std;typedef long long ll;typedef pair<ll,int> pli;const int N = 1e5 + 10;ll a[N];int main() {int n, k;scanf("%d%d", &n, &k);for(int i = 0; i < n; i++) {scanf("%lld", &a[i]);}if((n - 1) % (k - 1)) { // 添加0结点,保证最后一次合并不会出现空缺,从而整体最优int s = (n - 1 + k - 2) / (k - 1) * (k - 1) - (n - 1);for(int i = 0; i < s; i++) {a[n + i] = 0;}n += s;}priority_queue<pli,vector<pli>,greater<pli>> pq; // 维护值和高度,在值等大时,选择高度更低的合并,降低整体树高for(int i = 0; i < n; i++) {pq.push({a[i], 0});}ll ans = 0;int len = 0;while(pq.size() > 1) {ll s = 0;int height = 0;for(int i = 0; i < k; i++) {pli p = pq.top();pq.pop();s += p.first;height = max(height, p.second);}height++; // 合并之后的结点高度加1pq.push({s,height});ans += s;len = max(height, len); // 更新最大高度}printf("%lld\n%d\n", ans, len);return 0;}
