Tire 树
用来存储字符串,将要存储的字符串插入Tire树,并查找一个字符串在树中出现的次数
如图所示,并将每个字符串的结尾做标记,用cnt[N]存储依此字符作为结尾的字符串的个数。用son[idx][26] 存储节点为idx的节点所链接的最多26个字母的子节点的下标,其插入操作代码模板
void insert(char *str)
{
int p = 0;
for (int i = 0; str[i]; i ++ )
{
int u = str[i] - 'a';
if (!s[p][u]) s[p][u] = ++ idx;
p = s[p][u];
}
cnt[p] ++;
}
查询操作代码(返回字符串出现的次数):
int query(char *str)
{
int p = 0;
for (int i = 0; str[i]; i ++ )
{
int u = str[i] - 'a';
if (!s[p][u]) return 0;
p = s[p][u];
}
return cnt[p];
}
一个例题:AcWing 143. 最大异或对
解题思路:先思考暴力做法,即如下所示:
for(int i = 1; i <= n; i ++ )
for(int j = i + 1; j <= n; j ++ )
res = max(res, a[i] ^ a[j]);
时间复杂度为O(n),会超时,需要简化
可将每一个数(数的范围为0-2^31即二进制有31位)通过二进制的形式存储到 Tire树中,根据异或运算和贪心思想进行简化,可将时间复杂度变为O(31),对两个数来说,最高位的二进制数不等其余二进制数相等和最高位二进制数相等其余二进制数都不等来说还要大1。所以从最高位开始比较可获得最大值。核心代码如下(先插入再查询):
void insert(int x)
{
int p = 0;
for(int i = 30; i >= 0; i -- )
{
int u = x >> i & 1;
if(!s[p][u]) s[p][u] = ++ idx;
p = s[p][u];
}
}
int query(int x)
{
int p = 0, sum = 0;
for(int i = 30; i >= 0; i -- )
{
int u = x >> i & 1;
if(s[p][!u])
{
p = s[p][!u];
sum = sum * 2 + !u;
}
else
{
p = s[p][u];
sum = sum * 2 + u;
}
}
return sum;
}
-
并查集
并查集用于判断任意两个元素是否在同一个集合中,并合并两个集合。若用暴力做法将大小为1000的集合与大小为10000的集合合并,时间复杂度最小为O(1000) ,用并查集可将时间复杂度近似为O(1) ,并查集用的为类似为树的结构。若将编号为a,b的集合合并,即将a所在集合根节点作为b所在集合根节点的一个子节点。
1. 在实现朴素并查集的代码中,p[N]存储节点的祖宗节点(根节点),find(x) 表示找到编号x的祖宗节点(并更新每个点的祖宗节点)
int p[N];
int find(int x)
{
if (p[x] != x) p[x] = find(p[x]);
return p[x];
}
// 对集合进行初始化(未进行任何合并)
for (int i = 1; i <= n; i ++ ) p[i] = i;
// 合并两个集合
p[find(a)] = find(b);
2. 对朴素并查集进行改进,用size[] 维护集合中元素的个数(只对根节点进行维护)。
for (int i = 1; i <= n; i ++ )
{
p[i] = i, size[i] = 1;
}
// 合并两个集合,先加上集合的元素大小,再进行合并
size[find(b)] += size[find(a)];
p[find(a)] = find(b);
3. 维护到祖宗节点距离的并查集,其中d[x]存储x到p[x]的距离
int p[N], d[N];
int find(int x)
{
if (p[x] != x)
{
int u = find(x);
d[x] += d[p[x]];
p[x] = u;
}
return p[x];
}
for (int i = 1; i <= n; i ++ )
{
p[i] = i;
d[i] = 0;
}
//合并
p[find(a)] = find(b);
d[find(a)] = distance; // 根据具体问题,初始化find(a)的偏移量
一个例题:AcWing 240. 食物链
此题可用并查集进行解决,本题核心:通过距离来表示关系
有三类动物A,B,C 若A,B有关系 B,C有关系 则A,C就有关系
将所有有关系的动物放入一个集合中,通过这些动物节点与根节点的距离 % 3 (这三类动物构成一个闭合的圈所以可用%3进行简化)来判断这些动物与根节点动物是同类或者吃,被吃。
若新出现的两个动物所在的集合没有关系,则可通过合并并查集的方式将两个集合合并,并且按要求更新一个集合中各点到根节点的距离。整体代码如下:
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 5e4 + 10;
//若x到根节点y的距离为1:x吃y 若距离为2:y吃x (维持这种情况)
int p[N], d[N];
int k, n, res;
int find(int x)
{
if(p[x] != x)
{
int cnt = find(p[x]);
d[x] += d[p[x]];
p[x] = cnt;
}
return p[x];
}
int main()
{
cin >> n >> k;
for(int i = 1; i <= n; i ++ ) p[i] = i;
while(k -- )
{
int D, x, y;
cin >> D >> x >> y;
if(x > n || y > n) res ++;
else
{
int px = find(x), py = find(y);
if(D == 1)
{
if(px == py && (d[x] - d[y]) % 3) res ++;
else if(px != py)
{
p[px] = py;
d[px] = d[y] - d[x];
}
}
else
{
if(px == py && (d[x] - d[y] - 1) % 3) res ++;
else if(px != py)
{
p[px] = py;
d[px] = d[y] - d[x] + 1;
}
}
}
}
cout << res << endl;
return 0;
}
-
堆
1. 堆是完全二叉树
2. 分为大根堆和小根堆,大根堆是任意 一个父节点都大于其左右子节点,小根堆是父节点都小于其左右子节点。
对于小根堆来说 对堆的维护分为从下向上维护和从上往下维护 ,从上向下是将父节点与两个子节点对比找到三个中最小的与父节点交换 ,从下向上是将一个子节点与其父节点比较, 若子节点小于父节点就交换两节点。两种操作分别称为down up 操作
在此代码中用数组模拟堆:
down(函数向下进行操作直到无子节点的节点为止) 代码如下:
void down(int u)
{
int t = u;
// 在下标不越界的情况下进行值的大小比较
if (u * 2 <= size && h[u * 2] < h[t]) t = u * 2;
if (u * 2 + 1 <= size && h[u * 2 + 1] < h[t]) t = u * 2 + 1;
if (u != t)
{
heap_swap(u, t);
// 再从t位置继续down操作
down(t);
}
}
up函数 (从一个节点与其父节点进行比较直至父节点变为根节点) 代码:
void up(int u)
{
while (u / 2 && h[u] < h[u / 2])
{
heap_swap(u, u / 2);
u >>= 1;
}
}
对一个数组a[n]进行初始化的堆排序后:
for (int i = n / 2; i; i -- ) down(i);
数组大致有序,只能从数组a[0]取的整体的最小值,若要获得全部数组的有序排列,则应该将最小值去除,再堆排序,每次都进行这样的操作,直至数组完全取出。
1.将最小值去除的操作为,将数组最后一个值与第一个交换后,再将数组大小减1,后进行down(1)。
2.将数组的第k个插入的数(其下标为cnt)去除,将第k个插入的数与数组最后一个数进行交换,将数组大小减1,再从cnt出进行down(cnt)和up(cnt)操作, down和up操作只会进行其中的一个!!
-
哈希表
将数据范围大的一系列数对应到范围相对较小的数组中,如将范围为-1e9 - 1e9 的范围的数x,存储到0-1e5 的范围的数组中数组下标k上面,而h(x) = k,叫做x的哈希函数.
哈希表的存储结构有两种: 拉链法和开放寻址法(解决哈希冲突的方法)
拉链法:将大范围数x映射到小范围数k并再数组h[k]上插入时,若h[k]已有其他的数y存储时,就将x通过链表的方式存储到y所在的链表上(即再每一个h[k]上都形成一个链表,若有映射相同的数时就插入到链表中)代码如下:
int h[N], e[N], ne[N], idx;
// 向哈希表中插入一个数
void insert(int x)
{
int k = (x % N + N) % N;
e[idx] = x;
ne[idx] = h[k];
h[k] = idx ++ ;
}
// 在哈希表中查询某个数是否存在
bool find(int x)
{
int k = (x % N + N) % N;
for (int i = h[k]; i != -1; i = ne[i])
if (e[i] == x)
return true;
return false;
}
注意点:
1. 其中N的选择应该是选择距离2的整次幂最远的素数(可降低哈希冲突)以便于后续的取模运算,此素数应是大于题中数组的最大的数据范围的最小的素数。
2. 取模运算:因为大范围的数里有负数 要考虑到负数的取模运算,通过(x % N + N) % N 可将负数的模变为正的(此公式对于正数负数取模通用)
开放寻址法:
不需要借助链表,生成一个大范围的数组(原数组范围的两到三倍),再数组中进行不断查找直到找到一个空位置进行插入。代码如下:
// null 在c++中为很大的值
const int null = 0x3f3f3f3f;
int h[N];
// 如果x在哈希表中,返回x的下标;如果x不在哈希表中,返回x应该插入的位置
int find(int x)
{
int t = (x % N + N) % N;
while (h[t] != null && h[t] != x)
{
t ++ ;
if (t == N) t = 0;
}
return t;
}
说明:对数组h[N]进行初始化为null时,若用memset函数即memset(h, 0x3f, sizeof(h))应清楚memset函数是按字节进行赋值的,0x3f只是单字节的大小为00111111,int占4个字节,所以赋值后的大小是null=00111111 00111111 00111111 00111111。使用memset初始化一定要慎重,对于整形数组,一般只用来初始化0、-1、0x3f这几个数字,其他的建议使用循环初始化,初始化其他值时尽量用for
来初始化
字符串哈希:
1. 将字符串看为一个p进制的数,
2. 将字符串的字串前缀将p进制转换为10进制的数,再将此数模上一个数Q便得到字串前缀的哈希值,用h[N]存储哈希值
3. 由经验计算当q取131或13331,Q取2^64时发生冲突得概率最小
4. 当h[N]得类型为 unsigned long long (64位)时可免去取模步骤,当h[x]得数溢出时会自动取模
代码如下:
核心思想:将字符串看成P进制数,P的经验值是131或13331,取这两个值的冲突概率低
小技巧:取模的数用2^64,这样直接用unsigned long long存储,溢出的结果就是取模的结果
typedef unsigned long long ULL;
ULL h[N], p[N]; // h[k]存储字符串前k个字母的哈希值, p[k]存储 P^k mod 2^64
// 初始化
p[0] = 1;
for (int i = 1; i <= n; i ++ )
{
h[i] = h[i - 1] * P + str[i];
p[i] = p[i - 1] * P;
}
// 计算子串 str[l ~ r] 的哈希值
ULL get(int l, int r)
{
return h[r] - h[l - 1] * p[r - l + 1];
}