第二章 数据结构(二、三)——Trie树,并查集,堆与哈希表

Trie树

用来高效的存储和查找字符串集合的数据结构
Trie树的根节点不存储有效字符,假设根节点为第0层,Trie树从第1层开始存储有效数据
假设n为字符串str的长度,k < n 时,第k层存储字符串的第k个字符

通过Trie树可以快速地查找某个字符串是否出现在集合中
如何插入一个字符串到Trie树中?
遍历字符串的每个字符,从字符串的第一个字符与Trie树的第0层开始,判断下一层(第一层)是否存在存储当前字符的节点

  • 若存在,则插入该节点,并走向该节点
  • 否则走向该节点
    重复以上操作直到字符串的所有字符被遍历完,并对最后一个节点进行标记,表示从根节点到该节点构成的字符串数量+1

如何查询字符串在集合中出现的次数?
遍历字符串的每一个字符,从第一个字符和第0层开始,判断下一层是否存在存储当前字符的节点

  • 若不存在,表示集合中没有该字符串
  • 若存在,走向该节点
    重复以上操作直到所有字符遍历完,对于遍历的最后一个Trie树节点,返回其标记:集合中从根节点到该节点构成的字符串数量

如何用代码实现Trie树?

// 假设Trie树中只存储小写字符
// son[N][26]:Trie树中所有节点与其子节点,以下标的形式存储与索引
// cnt[N]:Trie树中,从根节点到某个节点,组成的字符串数量
// idx:数组中可用可见的第一块空间,同时也表示节点数量
int son[N][26], cnt[n], idx;
char str[N];

void insert(char str[])
{
	int p = 0;
	for (int i = 0; str[i]; ++ i )
	{
		int u = str[i] - 'a';
		if (!son[p][u]) son[p][u] = ++ idx;
		p = son[p][u];
	}
	cnt[p]++;
}

int query(char str[])
{
	int p = 0;
	for (int i = 0; str[i]; ++ i )
	{
		int u = str[i] - 'a';
		if (!son[p][u]) return 0;
		p = son[p][u];
	}
	return cnt[p];
}

并查集

用来快速的处理

  1. 将两个集合合并
  2. 询问两个元素是否在一个集合当中

暴力做法:用belong数组存储某个元素属于的集合,belong[x] = a:表示x属于a这个集合
此时询问两个元素是否在一个集合中,这个操作是O(1)的
但将两个集合合并,假设一个集合的元素有1000个,一个集合的元素有2000个,至少需要修改1000次belong数组,才能完成这个操作
这时使用并查集完成这两个操作,能够达到近乎O(1)的时间复杂度

分别用树的形式存储每个集合,每颗树用根节点进行唯一标识(根节点表示集合
树中除了根节点,其他节点存储其父节点的下标
判断某个节点在哪个集合中,我们只需要从该节点开始,不断地往父节点遍历,找到其根节点,就找到其属于的集合

如何判断树根:特殊设置根节点p[x] == x,只有父节点的父指针指向自己
如何求x的集合编号:while (p[x] != x) x = p[x],最后的x就是集合编号
如何合并两个集合:将一棵树插入到另一棵树下。假设x和y分别是两集合的根节点,p[x] = y,将x的父指针指向y,不再指向自己,从而将x集合合并到y集合中,x集合中所有的元素都属于y节点

其中第二个问题可以优化:查询x节点属于哪个集合时,需要从x节点遍历到根节点,此时将这条路径上的所有点的父指针都指向根节点
之后再查找这条路径上的节点属于的集合时,只需一次查找便能找到所属集合
以上优化叫做路径压缩,其本质就是尽可能的降低树的高度

模板:

int find(int x)
{
	if (x != p[x]) p[x] = find(p[x]);
	return p[x];
}

void merge(int x, int y)
{
	p[find(x)] = p[find(y)];
}

有些题目需要记录集合中的节点数量,此时额外使用一个size数组保存该值。注意:只保证数组中根节点对应的值有意义
合并数组时,需要维护size数组,若x集合合并了y集合,那么size[x] += size[y]


需要实现的操作:

  1. 插入一个数
  2. 求集合中的最小值
  3. 删除最小值
  4. 删除任意一个元素
  5. 修改任意一个元素

其中1~3操作是STL的priority_queue支持的操作

堆的结构:完全二叉树,小根堆,即堆顶为最小值
存储方式:完全二叉树用一维数组存储,当x是某个元素的下标时

  • x的左孩子:2 * x
  • x的右孩子:2 * x + 1
  • x的父亲:x / 2

删除最小值涉及到down操作,down将某个元素向下调整,使调整后的堆满足小根堆的性质
为什么需要down,因为删除操作将堆顶元素与最后一个叶子进行了交换,并删除最后一个叶子(原堆顶元素),此时堆顶的值变大。不满足小根堆的性质,需要将变大的值向下调整,使堆重新满足小根堆的性质
除此之外,还有up操作,删除最小值不会用到up,只有在某个位置的值变小时,才需要对其进行up操作

模板:
cnt为堆中元素的数量,注意:不使用数组的0号下标,因为0不利于计算左右孩子的下标
heap数组的1号下标开始使用

  1. 插入一个数:heap[++ cnt] = x, up(cnt);
  2. 求集合中的最小值:heap[1];
  3. 删除最小值:heap[1] = heap[cnt], down(1);
  4. 删除任意一个元素:heap[k] = heap[cnt], down(k), up(k);
  5. 修改任意一个元素:heap[k] = x, down(k), up(k);

为什么4、5操作需要downup操作?因为删除或者修改某个元素之后,该位置的值可能变大可能变小。为了简化代码,不写判断条件直接进行两个操作,但是这两个操作只会执行一个

为了使堆满足小根堆的性质需要down操作:当前节点小于两个子节点,若不满足该性质,需要将当前节点与子节点中的较小节点进行交换
当前节点走到某个子节点的位置上,具有了新的子节点
交换后的三个节点满足了小根堆的性质,此时继续判断当前节点与其两个新的子节点是否满足小根堆的性质
若不满足则继续交换,直到满足或者当前节点无子节点

void down(int u)
{
	int t = u;
	if (u * 2 <= n && h[u * 2] < h[t]) t = u * 2;
	if (u * 2 + 1 <= n && h[u * 2 + 1] < h[t]) t = u * 2 + 1;
	if (u != t) swap(h[t], h[u]), down(t);
}

对数组中下标为u的元素进行down操作,u作为父节点,与其两个子节点进行比较,用t保存三者中的最小值

  • 若t没有变化,说明u就是三者中的最小值,满足小根堆性质,不用更新
  • 若t发生了变化,交换u与三者中的最小值,使三者满足小根堆的性质
    • 然后以交换后的u作为父节点,继续以上比较直到满足性质或u走到最后成为叶子

建堆时从下标为cnt/2的节点开始:x / 2操作将得到下标为x节点的父节点
对于叶子,不需要down操作,因为叶子没有子节点。所以从最后一个具有子节点的节点开始,向上进行down操作直到根节点
最后一个具有子节点的节点,其子节点一定是叶子。问题转换下就是:最后一个叶子的父节点
cnt为整个堆的元素个数,cnt指向最后一个叶节点,该节点的父节点开始down到根节点,建堆操作就完成了

for (int i = cnt / 2; i; -- i ) down(i);

计算每一层的down次数,利用错位相减,可以得到时间复杂度为O(n)

删除操作:

h[1] = h[cnt --], down[1];

操作4~5,需要支持删除与修改第k个插入堆中的数
在堆结构中,第k个插入的数与数组(堆结构用一维数组维护)下标没有直接的关系。在链表结构中,第k个插入的数,k就是e数组的下标,这是直接映射的关系。由于堆需要满足某些性质,元素插入后可能要进行元素间的交换,由于元素后续可能的改动,所以第k个插入的元素与数组下标没有直接的关系

因此,要支持操作4~5就要维护第k个插入的数在堆中的下标,用数组ph[k]表示第k个插入的点在堆中的下标。假设现在修改了堆的第k个元素,此时需要对其进行up或者down操作,以维护堆的性质。不论是up操作还是down操作,都涉及到两个元素的交换。这个操作会修改两个元素在堆中(一维数组)中的下标,因为修改的是第k个插入数,元素交换后修改ph[k]为新的下标即可。但是虽然知道另一个元素在堆中的下标,交换操作可以进行,却不知道该元素是第几个操作的,也就无法维护ph数组

为了维护ph数组,可以再维护一个数组hp[k]:堆中下标为k的点,是第几个插入的点
这样在交换时,我们知道两个数在堆中的下标,就可以通过hp数组获取其是第几个插入的,进而维护ph数组

以上,要支持操作4~5,就要维护phhp数组,其中hp数组是为了维护ph数组而存在的
什么时候要维护ph数组?向堆插入元素与交换两数时

  • 向堆中插入元素时,假设带元素是第k个插入的,那么该元素就存储在堆中下标为k的位置。注意:数组从1开始使用。ph[k] = k,之后ph[k]可能修改,这是因为第k个插入的数进行了交换
  • 交换两数时,假设交换第i个插入和第j个插入的数
    • swap(ph[i], ph[j]),表示第i个插入的数现在在堆中的下标是第j个插入的数在堆中的下标,同理,第j个插入的数在堆中的下标是…
    • 但是交换前我们不知道两数是第几个插入的,我们只知道两数在堆中的下标。假设两数的下标分别是x和y,所以刚才的操作是swap(ph[hp[x]], ph[hp[y]])
    • 最后swap(hp[x], hp[y]),表示堆中下标为x的数是第j次插入的,下标为y的数是第i次插入的

注意:数组名中,p表示第几次插入,h表示数在堆中的下标
从上面的推导也可以看出,hp数组是为了维护ph数组而存在的。首先是因为我们要支持删除/修改第k个插入的数,所以需要知道插入次数到元素在堆中下标的映射,从而维护了ph数组
而交换操作时,由于我们需要维护ph数组,但我们只知道两数的下标,不知道两数的插入次数,无法在ph数组中索引元素
因此无法维护ph数组,此时创建hp数组,维护下标与插入次数的映射关系,先通过hp数组得知元素的插入次数,再维护ph数组,当然ph数组也需要维护

模板:

// x和y为交换两数在堆中的下标
void head_swap(int x, int y)
{
	swap(h[x], h[y]);
	swap(ph[hp[x]], ph[hp[y]]);
	swap(hp[x], hp[y]);
}

void down(int u)
{
	int t = u;
	if (u * 2 <= n && h[u * 2] < h[t]) t = u * 2;
	if (u * 2 + 1 <= n && h[u * 2 + 1] < h[t]) t = u * 2 + 1;
	if (u != t) heap_swap(u, t), down(t);
}

void up(int u)
{
	while (u / 2 && h[u / 2] > h[u]) heap_swap(u, u / 2), u /= 2;
}

哈希表

离散化是一种非常特殊的哈希,因为离散化保证了数据的相对顺序

哈希通常用来查找,比如快速判断一个数是否存在于一个集合中。通过哈希函数将数据范围大的数据映射成数据范围小的数据
为什么要将数据范围映射得更小?因为哈希使用连续的空间标记数据是否存在,这时才能以O(1)的时间复杂度查找数据是否存在。若数据范围越大大,使用的空间也就越大,浪费空间的现象就越严重。此时需要压缩空间,减少空间的浪费,但是这样做却导致了哈希冲突的概率增加

一个常见的哈希函数是: x mod 1 0 5 10^5 105,将原数据模上一个值,前提是该值小于原数据可能出现的最大值

tips:将取模的数取成质数,冲突概率比较小
根据处理方式的不同,哈希分为拉链法开放散列法

拉链法

拉链法:将数据转换成哈希值后,若该哈希值已经被使用,则共享该哈希值
也就是两数冲突时,将两数存储到同一单链表中
每个哈希值对应一个单链表,用一维数组保存所有哈希值对应单链表的头指针。开始时,初始化一维数组为空,表示当前没有数据被映射为哈希值

模板中使用一维数组e[]ne[]表示哈希桶中所有的单链表 其中(x % N+ N) % N`:是为了保证取模后是一个正数
模板:

// 两个主要操作insert和find
// insert将某个数映射为哈希值并存储,find查找某个数是否存在于哈希桶中
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; i = ne[i]) 
		if (e[i] == x) return true;
	return false;
}

开放寻址法

开放寻址法:将数据转换成哈希值后,若该哈希值已经被使用,则往后寻找一个没有被使用的哈希值存储该数
在开放寻址法中,开辟的一维数组h长度要比集合中的数据量大一到两倍,至少也要比集合中的数据量多1,否则寻找未被使用的哈希值时将导致死循环
开放寻址法的关键在于find函数,find

  • 若x存在,返回x的位置
  • 若不存在返回x应该在的位置

所以插入操作可以通过find函数完成:h[find[x]] = x
初始化h数组时,将其所有元素初始化为0x3f3f3f3f,因为这个数大于 1 0 9 10^9 109,而一般情况下题目给定的数据是小于 1 0 9 10^9 109的,所以可以用这个不可能被使用的数表示哈希值未被使用

find(x)返回的位置上存储了0x3f3f3f3f,表示经过相同的映射规则,x的哈希值没有被使用,此时可以说明集合中没有x这个元素

模板:在线性寻址未被使用的哈希值时,注意循环遍历

const int null = 0x3f3f3f3f;

int find(int x)
{
	int k = (x % N + N) % N;
	while (h[k] != null && h[k] != x) 
	{
		k ++;
		if (k == N) k = 0;
	}
	return k;
}

字符串前缀哈希

如何将长度为n的字符串表示成P进制的n位整数?
比如长度为4的字符串DBAE,表示成P将其看成一个P进制的整数,E的位数最低,D的位数最高
将A看成1,B看成2…以此类推
DBAE的P进制值表示为:5 * P 0 P^0 P0 + 1 * P 1 P^1 P1 + 2 * P 2 P^2 P2 + 4 * P 3 P^3 P3
需要注意的是:最后需要将P进制表示模上一个值,以成为哈希值
所以最终的哈希值为:(5 * P 0 P^0 P0 + 1 * P 1 P^1 P1 + 2 * P 2 P^2 P2 + 4 * P 3 P^3 P3) % mod

同时,不能将某个字符映射为0,最低也要从1开始映射,若A这个字符表示0,那么AAA这两个字符串就发生了冲突
发生冲突将影响我们最终的判断,所以我们要尽量减少冲突
将mod设置为 2 64 2^{64} 264,将P设置为131或者13331可以使冲突的概率降到最小
将保存哈希值的变量类型设置为unsigned long long,由于该变量可表示的范围为0 ~ 2 64 2^{64} 264 - 1,这个范围和取模 2 64 2^{64} 264的结果范围相同,所以用unsigned long long存储哈希值就不用进行取模运算了

字符串前缀哈希的主要运用是:快速比较两个字符串是否相等,在不冲突的情况下,通过判断两个字符串的哈希值是否相等,间接判断两字符串是否相等。所以现在的问题就是对于某一字符串,如何获取任意子串的哈希值?

获取任意子串哈希值的具体步骤:
对于一个字符串,先预处理出所有前缀的哈希值
特殊处理h[0] = 0,表示前0个字符的哈希值为0,
h[i] = x,x表示:从字符串的第一个字符开始,截止第i个字符的子串的哈希值
如以下板书:
image.png
预处理后得到的数组类似于前缀和数组
如何求任意子串的哈希值?
利用公式,要计算str[l, r]的哈希值时,只需计算:h[r] - h[l - 1] * P r − l + 1 P^{r - l + 1} Prl+1

为什么最后需要* P r − l + 1 P^{r - l + 1} Prl+1
这是在进行对齐操作,h[l - 1] 表示的P进制数位数小于h[r]表示的P进制位数
比如h[l - 1]表示P进制数:123
h[r]表示P进制数:12345,将h[h - 1] 对齐后得到12300,将两者相减得到str[l, r]的哈希值45

模板:

// 假设字符串str的0号下标不存储有效数据
// 数组h[i]为str的前缀和数组,p[i]为P进制中,第i位的权值
// 这里直接将p设置为131,将大写字符的ASCII码直接映射是没有问题的
const int p = 131;
typedef unsigned long long ull;
// 获取前缀和数组以及保存p进制的权值
p[0] = 1;
for (int i = 1; i <= n; ++ i )
{
	h[i] = h[i - 1] * p + str[i];
	p[i] = p[i - 1] * p;
}

// 返回任意子串的哈希值
ull get(int l, int r)
{
	return h[r] - h[l - 1] * p[r - l + 1];
}

Trie树练习题

835. Trie字符串统计

835. Trie字符串统计 - AcWing题库
image.png

#include <iostream>
using namespace std;

const int N = 1e5 + 10;

int son[N][26], cnt[N], idx;
char str[N], op[2];
int n;

void insert(char str[])
{
    int p = 0;
    for (int i = 0; str[i]; ++ i )
    {
        int u = str[i] - 'a';
        if (!son[p][u]) son[p][u] = ++ idx;
        p = son[p][u];
    }
    cnt[p]++;
}

int query(char str[])
{
    int p = 0;
    for (int i = 0; str[i]; ++ i )
    {
        int u = str[i] - 'a';
        if (!son[p][u]) return 0;
        p = son[p][u];
    }
    return cnt[p];
}

int main()
{
    scanf("%d", &n);
    while (n -- )
    {
        scanf("%s%s", op, str);
        if (op[0] == 'I') insert(str);
        else printf("%d\n", query(str));
    }
    return 0;
}

143. 最大异或对

143. 最大异或对 - AcWing题库
image.png

先从暴力解法入手:

  • 枚举数组中的每个数 a i a_i ai
  • 对于 a i a_i ai,每次枚举数组中的一个数 a j a_j aj
  • a i a_i ai ^ a j a_j aj,做异或运算,直到 a j a_j aj枚举完数组中的所有数
  • 枚举 a i a_i ai ^ a j a_j aj的过程中维护运算结果最大值

以上解法需要枚举n a i a_i ai,然后再枚举n a j a_j aj。时间复杂度为O( n 2 n^2 n2)
思考如何优化暴力解法,每次枚举n a i a_i ai,这个无法做优化。每次 a i a_i ai需要和数组中所有的数进行异或运算吗?
根据异或运算,相同为0,不同为1。我们想要得到的结果最大,也就是结果中的1越多越好,准确的说,高位的1越多越好
题目中给的数据能够用int存下,且不是负数
用i遍历 a i a_i ai的每一位,初始i指向 a i a_i ai的第31位,最后指向 a i a_i ai的第1位
a i a_i ai的最高位开始,通过找当前位与 a i a_i ai不同的数,理想情况下,最终能找到一个所有位和 a i a_i ai不同的数,此时异或的结果为全1
a i a_i ai的最高位开始,若有些数和 a i a_i ai的当前位相同,那么 a i a_i ai就不用与这些数做异或运算,因为运算结果为0, a i a_i ai只要和当前位与其不同的数异或即可
把和 a i a_i ai当前位不同的数扔掉,这样就能缩小数据范围
若所有数的当前位都和 a i a_i ai的当前位相同,即 a i a_i ai的当前位与所有数的异或结果都是0,无法得到1,此时无法缩小数据范围

用Trie树存储所有的数据,从每一个数据的高位开始,Trie树只存储0与1,由于所有的数都是整数,所以Trie数不存储符号位,即Trie的高度为31
Trie树中,若某个元素为0,表示该元素不存在
考虑最坏情况,每个数都需要使用Trie树的31个节点存储,那么Trie树中有31 * n个节点

用Tire树存储每个数,在异或运算中。知道两数中的一数 a i a_i ai,如何找到另一个数 a j a_j aj,使得异或的结果最大?
从Trie的根节点开始,从 a i a_i ai第31位第1位,根据 a i a_i ai的每一位数,反向选择 a j a_j aj

  • 若Trie树中,存在某些数的第i位 a i a_i ai第i位相反,那么这些数与 a i a_i ai异或的结果中,第i位就是1
    • 假设异或结果的初始值0,此时res += 1 << i
  • 若Trie树中,所有数的第i位 a i a_i ai第i位相同,不论怎样, a i a_i ai异或的结果中,第i位就是0,此时res(异或结果)的值不变

debug:query函数中,res为两数异或的结果,不是和 a i a_i ai异或的 a j a_j aj
若修改query的逻辑,使res为 a j a_j aj,那么维护最大异或结果以及输出最大异或结果时,需要进行异或运算
只有Trie树存在某些数,它们的某一位与 a i a_i ai不同时,res才会改变

// query的res保存aj
#include <iostream>
using namespace std;

const int N = 1e5 + 10, M = 3100010;
int son[M][2], a[N], idx;
int n, res;

void insert(int x)
{
    int p = 0;
    for (int i = 30; ~i; -- i )
    {
        int u = x >> i & 1;
        if (!son[p][u]) son[p][u] = ++ idx;
        p = son[p][u];
    }
}

int query(int x)
{
    int p = 0, res = 0;
    for (int i = 30; ~i; -- i )
    {
        int u = x >> i & 1;
        if (son[p][!u]) res += !u << i, p = son[p][!u];
        else res += u << i, p = son[p][u];
    }
    return res;
}

int main()
{
    scanf("%d", &n);
    for (int i = 0; i < n; ++ i ) scanf("%d", &a[i]), insert(a[i]);
    
    for (int i = 0; i < n; ++ i ) res = max(res, a[i] ^ query(a[i]));
    printf("%d\n", res);
    
    return 0;
}
// query的res保存异或运算的结果
#include <iostream>
using namespace std;

const int N = 1e5 + 10, M = 3100010;
int son[M][2], a[N], idx;
int n, res;

void insert(int x)
{
    int p = 0;
    for (int i = 30; ~i; -- i )
    {
        int u = x >> i & 1;
        if (!son[p][u]) son[p][u] = ++ idx;
        p = son[p][u];
    }
}

int query(int x)
{
    int p = 0, res = 0;
    for (int i = 30; ~i; -- i )
    {
        int u = x >> i & 1;
        if (son[p][!u]) res += 1 << i, p = son[p][!u];
        else p = son[p][u];
    }
    return res;
}

int main()
{
    scanf("%d", &n);
    for (int i = 0; i < n; ++ i ) scanf("%d", &a[i]), insert(a[i]);
    
    for (int i = 0; i < n; ++ i ) res = max(res, query(a[i]));
    printf("%d\n", res);
    
    return 0;
}

并查集练习题

836. 合并集合

836. 合并集合 - AcWing题库
image.png

#include <iostream>
using namespace std;

const int N = 1e5 + 10;
int n, m, x, y, p[N];
char op[2];

int find(int x)
{
    if (x != p[x]) p[x] = find(p[x]);
    return p[x];
}

int main()
{
    scanf("%d%d", &n, &m);
    for (int i = 0; i < n; ++ i ) p[i] = i;
    
    while (m -- )
    {
        scanf("%s%d%d", op, &x, &y);
        if (op[0] == 'M') p[find(x)] = p[find(y)];
        else printf("%s\n", find(x) == find(y) ? "Yes" : "No");
    }
    
    return 0;
}

debug:find中return对象是p[x]不是x,因为路径压缩中将修改x的父节点指针


837. 连通块中点的数量

837. 连通块中点的数量 - AcWing题库
image.png

#include <iostream>
using namespace std;

const int N = 1e5 + 10;
int p[N], s[N];
char op[3];
int x, y;

int n, m;

int find(int x)
{
    if (x != p[x]) p[x] = find(p[x]);
    return p[x];
}

int main()
{
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; ++ i) p[i] = i, s[i] = 1;
    
    while (m -- )
    {
        scanf("%s", op);
        
        if (op[0] == 'C') 
        {
            scanf("%d%d", &x, &y);
            x = find(x), y = find(y);
            if (x != y) s[y] += s[x], p[x] = p[y];
        }
        else if (op[1] == '1') 
        {
	        scanf("%d%d", &x, &y);
	        printf("%s\n", find(x) == find(y) ? "Yes" : "No");
	    }
        else scanf("%d", &x), printf("%d\n", s[find(x)]);
    }
    return 0;
}
  1. 因为s数组中,只有根节点对应下标有效,所以查找某个元素所属集合的元素数量时(索引s数组时),需要先进行find操作
  2. 合并两集合中,需要特判。上一道板子题没写特判不会有问题,而这题涉及到询问集合元素数量的操作。若不进行特判,同一集合中的元素合并将导致该集合的数量翻倍

240. 食物链

240. 食物链 - AcWing题库
image.png

并查集的变形题,一般的并查集只有合并两集合与判断两元素是否在同一集合中的操作
此题需要在这两个操作的基础上,维护一些额外信息。如连通块中的数量,额外维护了集合中的元素数量这一信息
根据这题的题意,我们需要额外维护的信息是:食物链关系。题目给定的食物链中只有三个物种,所以我们需要在并查集中表示这三个物种的吃与被吃的关系,同时也要表示哪些动物是同一物种

一个比较容易陷入的误区是:同一集合中的动物属于同一物种,这个很容易想到,不过此时食物链关系要如何表示?读者可以想想,一开始我就是这个思路,不过感觉食物链关系很难想,于是放弃

其中一个解法是:根据元素到根节点的距离维护食物链关系

  1. 距离% 3 == 0,元素与根节点属于同一物种
  2. 距离% 3 == 1,元素属于的物种吃根节点属于的物种
  3. 距离% 3 == 2,元素属于的物种被根节点属于的物种吃

总之就是:父子节点之间存在吃与被吃的关系,子节点可以吃父节点
x节点的父节点为p,p的父节点为pp,pp的父节点为ppp
x吃p,p吃pp,pp吃ppp,由于食物链关系存在环,所以ppp吃x
由于食物链关系中只有三个物种,所以x节点与ppp节点为同一物种

根据题目给定的“话”,维护并查集。一开始每个动物自己为一个集合,每个集合表示根据“真话”维护的食物链关系
根据这些关系,推断接下来的话是否为“真话”

  • 若为真话,根据该信息合并并查集或者向集合中添加元素
  • 若为假话,则增加出现的假话数量

保存距离数组d,表示并查集中的元素到父节点的距离。进行并查集的查找操作时,将进行路径压缩,路径压缩需要维护d数组
根节点到查找元素之间的所有元素都将作为根节点的子节点,此时距离数组的含义变成了元素根节点的距离
如何维护d数组呢?以下是路径压缩模板

int find(int x)
{
	if (x != p[x]) p[x] = find(p[x]);
	return p[x];
}

数组p保存节点的父节点,若当前元素不是集合的根节点,那么进行递归查找。最后修改每个节点的父节点为根节点的操作,是从根节点的子节点开始修改,向下到当前节点结束
由于数组d保存节点到父节点的距离(注意这个距离和层数没有关系,这个距离指的是食物链中的距离),假设从当前节点到根节点的路径上有4个节点(不包括根节点),从根节点的子节点开始维护数组dd[x] += d[px],节点到父节点的距离加等父节点到其父节点的距离
根节点到其父节点的距离为0(根节点的父节点就是自己),由于更新从根节点的子节点开始,到当前节点结束,所以这样的更新操作的结果就是:路径上所有节点在d数组中的含义变成了到根节点的距离
根据节点与根节点的绝对距离,就能推导出节点之间的相对距离,也就能推导出动物之间的食物链关系
所以递归模板就能改写为:

int find(int x)
{
	if (x != p[x])
	{
		int t = p[x];
		p[x] = find(p[x]);
		d[x] += d[t];
	}
	return p[x];
}

若题目表示X与Y具有某些关系时,那么需要根据题目之前描述过的X与Y的关系,推导当前描述是否正确,此时X与Y处于同一并查集中
若题目之前没有描述过X与Y的关系,那么需要建立X与Y之间的关系,此时X与Y不处于同一并查集中

如何建立X与Y是同类的关系?X与Y分别位于两个不同的集合中,由于之前进行过find操作,此时X与Y的父节点就是根节点。假设X位于的集合被合并到Y位于的集合中,那么X集合的根节点作为了Y集合根节点的子节点
X集合的根节点为px,Y集合的根节点为py
即需要满足关系:(d[px] + d[x] - d[y]) % 3 == 0,在合并后的集合中,X到根节点的距离与Y到节点的距离同余3
假设两者距离相同,即d[px] + d[x] == d[y],那么两者肯定同余3。此时修改d[px] = d[y] - d[x],为什么不需要修改d[x]?,修改了d[px],那么以px节点为祖先节点的所有节点在数组d中的信息都需要修改
由于find操作将维护数组d,只要修改了d[px],那么其子孙节点在数组d中的信息都将通过find操作修改
由于每次查询操作都将调用find操作,因此就能保证每次查询的结果都是正确的

如何建立X吃Y这个关系?和建立X与Y是同类的关系一样,假设X所属集合被合并到Y所属集合中
合并后的集合中,两元素需要满足关系(d[x] + d[px] - d[y] - 1) % 3 == 0,即d[x] + d[px]d[y] + 1同余,假设两者的值相等,那么肯定是同余的
所以合并后,需要修改d[px] = d[y] + 1 - d[x]

题目给定的话中,若两动物在同一集合中,此时不需要建立关系。只需判断两动物之间的关系是否和话中描述的一样,若存在矛盾说明这句话是错误的

#include <iostream>
using namespace std;

const int N = 1e5 + 10;
int p[N], d[N];
int n, k, px, py;
int res, t, x, y;

int find(int x)
{
    if (x != p[x]) 
    {
        int t = p[x];
        p[x] = find(p[x]);
        d[x] += d[t];
    }
    return p[x];
}

int main()
{
    scanf("%d%d", &n, &k);
    for (int i = 0; i < n; ++ i ) p[i] = i;
    while (k -- )
    {
        scanf("%d%d%d", &t, &x, &y);
        
        if (x > n || y > n) 
        {
            res++;
            continue;
        }
       
        px = find(x), py = find(y);
        if (t == 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] + 1 - d[x];
            }
        } 
    }
    printf("%d", res);
    
    return 0;
}

堆练习题

838. 堆排序

838. 堆排序 - AcWing题库
image.png

获取数组,建立小堆,进行m次删除操作,每次删除前输出堆顶元素即可

#include <iostream>
using namespace std;

const int N = 1e6 + 10;
int n, m;
int h[N];

void down(int u)
{
    int t = u;
    if (u * 2 <= n && h[u * 2] < h[t]) t = u * 2;
    if (u * 2 + 1 <= n && h[u * 2 + 1] < h[t]) t = u * 2 + 1;
    if (u != t) swap(h[u], h[t]), down(t);
}

int main()
{
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; ++ i ) scanf("%d", &h[i]);
    for (int i = n / 2; i; -- i ) down(i);
    
    while (m -- )
    {
        printf("%d ", h[1]);
        h[1] = h[n -- ], down(1);
    }
    
    return 0;
}

839. 模拟堆

839. 模拟堆 - AcWing题库
image.png

int main()
{
    scanf("%d", &n);
    while (n --)
    {
        scanf("%s", op);
        if (op[0] == 'I') 
        {
            scanf("%d", &x);
            h[++ cnt] = x, ph[cnt] = cnt, hp[cnt] = cnt;
            up(cnt);
        }
        else if (strcmp(op, "PM") == 0) 
            printf("%d\n", h[1]);
        else if (strcmp(op, "DM") == 0) 
            h[1] = h[cnt -- ], down(1);
        else if (op[0] == 'D') 
            scanf("%d", &k), k = ph[k], h[k] = h[cnt -- ], down(k), up(k);
        else 
            scanf("%d%d", &k, &x), k = ph[k], h[k] = x, down(k), up(k);
    }
    return 0;
}

debug了很久,heap_swap,down,up三个函数没有问题,问题出在main函数的处理上
有三个问题:

  1. downup需要的参数为某个元素在堆中具体的下标,而不是某个元素的值,以上代码已经修改此错误。这个属于写题时思路有些模糊了,一时没有注意到
  2. 以前写的堆只支持操作13,不支持操作45。所以删除堆顶元素时,直接是一个赋值操作,用最后元素替换堆顶元素。赋值操作写习惯了,而且“替换操作”这个概念也深入我心。所以实现删除任意元素的操作时直接一个赋值语句,可见以上代码。因为直接赋值没有维护phhp数组,导致后续的操作出现偏差,所以赋值操作应该改为heap_swap
  3. 之前写单链表时,第k个插入的元素在数组中的下标就是k。而在堆中这个说法不成立,这个我之前也强调了。但是写代码时却没有多想,新增元素时维护phhp数组时,直接认为第k个插入元素在堆中的下标为k。只有进行了维护,“第k个插入元素的下标才可能不是k吧”。但是这个堆有删除操作,插入第k个元素时,堆中的元素数量不一定是k - 1。所以第k个插入的元素,不一定使用下标为k的位置。而在单链表中,由于数组中的元素不用连续,同时不用考虑内存泄漏问题,第k个插入的元素使用的下标一定是k。而堆的元素需要连续的存储,删除了某个元素后,这样的直接映射不成立。所以需要使用两个变量保存堆的元素数量以及插入的次数

以上,是我debug1小时得出的教训
总结:downup接收某个元素的下标
需要维护phhp数组时,应该慎重使用直接赋值替换元素的操作,是否要维护phhp数组?
堆的元素是连续存储的,若支持删除操作,那么第k个插入的元素在数组中的下标不一定是k,这个假设总是成立,无论是在插入数据前还是在插入数据并维护后

以下是AC代码:

#include <iostream>
#include <cstring>
using namespace std;

const int N = 1e5 + 10;
int n, m, k, x, cnt, h[N], ph[N], hp[N];
char op[3];

void heap_swap(int x, int y)
{
    swap(h[x], h[y]);
    swap(ph[hp[x]], ph[hp[y]]);
    swap(hp[x], hp[y]);
}

void down(int u)
{
    int t = u;
    if (u * 2 <= cnt && h[u * 2] < h[t]) t = u * 2;
    if (u * 2 + 1 <= cnt && h[u * 2 + 1] < h[t]) t = u * 2 + 1;
    if (u != t) heap_swap(u, t), down(t);
}

void up(int u)
{
    while (u / 2 && h[u / 2] > h[u]) heap_swap(u / 2, u), u /= 2;
}

int main()
{
    scanf("%d", &n);
    while (n --)
    {
        scanf("%s", op);
        if (op[0] == 'I') 
        {
            scanf("%d", &x);
            h[++ cnt] = x, ph[++ m] = cnt, hp[cnt] = m;
            up(cnt);
        }
        else if (strcmp(op, "PM") == 0) 
            printf("%d\n", h[1]);
        else if (strcmp(op, "DM") == 0) 
            heap_swap(1, cnt -- ), down(1);
        else if (op[0] == 'D') 
            scanf("%d", &k), k = ph[k], heap_swap(k, cnt -- ), down(k), up(k);
        else 
            scanf("%d%d", &k, &x), k = ph[k], h[k] = x, down(k), up(k);
    }
    return 0;
}

哈希练习题

840. 模拟散列表

840. 模拟散列表 - AcWing题库
image.png

#include <iostream>
#include <cstring>
using namespace std;

const int N = 100003;
int h[N], e[N], ne[N], idx = 1;
int n, x;
char op[2];

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;
}

int main()
{
    scanf("%d", &n);
    memset(h, -1, sizeof(h));
    while (n -- )
    {
        scanf("%s%d", op, &x);
        if (op[0] == 'I') insert(x);
        else
        {
            if (find(x)) puts("Yes");
            else puts("No");
        }
    }
    
    return 0;
}

假设数据集的数据数量最大值为N
题目给定的N最大为 1 0 5 10^5 105。在拉链法中,一维数组h的长度以及哈希函数的模数可以任意设置(模数小于等于长度,但是在拉链法中一般是等于),但不能过小或者过大。这里的任意是指可以大于N,也可以小于N,不受N影响,但是过大或者过小会对空间或者时间造成影响
ene数组的长度必须大于N,因为这两个数组要存储题目给定的所有数据

以上代码中,若数组长度与模数都小于 1 0 5 10^5 105,那么就要设置另一个变量保存数据集数据个数的最大值
以下是同样能AC的代码:

#include <iostream>
#include <cstring>
using namespace std;

const int N = 100003, M = 50003;
int h[M], e[N], ne[N], idx = 1;
int n, x;
char op[2];

void insert(int x)
{
    int k = (x % M + M) % M;
    e[idx] = x, ne[idx] = h[k], h[k] = idx ++;
}

bool find(int x)
{
    int k = (x % M + M) % M;
    for (int i = h[k]; i != -1; i = ne[i])
        if (e[i] == x) return true;
    return false;
}

int main()
{
    scanf("%d", &n);
    memset(h, -1, sizeof(h));
    while (n -- )
    {
        scanf("%s%d", op, &x);
        if (op[0] == 'I') insert(x);
        else
        {
            if (find(x)) puts("Yes");
            else puts("No");
        }
    }
    
    return 0;
}

以上代码只是为了说明,拉链法中一维数组的长度(哈希函数模数)与数据集数据数量无关

开放地址法:

#include <iostream>
#include <cstring>

const int null = 0x3f3f3f3f, N = 2e5 + 3;
int h[N], x, n;
char op[2];

int find(int x)
{
    int k = (x % N + N) % N;
    while (h[k] != null && h[k] != x) 
    {
        k ++;
        if (k == N) k = 0;
    }
    return k;
}

int main()
{
    memset(h, 0x3f, sizeof(h));
    scanf("%d", &n);
    while (n --)
    {
        scanf("%s%d", op, &x);
        if (op[0] == 'I') h[find(x)] = x;
        else printf("%s\n", h[find(x)] == null ? "No" : "Yes");
    }
    
    return 0;
}

假设数据集的数据数量最大值为N
在开放地址法中,一维数组的长度就必须大于N,而哈希函数的模数却与N无关,但是却会影响冲突的概率。一般情况下,哈希函数的模数小于等于一维数组的长度
为什么不能和拉链法一样,一维数组的长度也小于N?这是由于存储结构导致的差别,拉链法的一维数组存储单链表的头指针,一张单链表中可能有一个或多个数据。所以拉链法的一维数组中的每个元素可以存储多个数据
而开放地址法的一维数组中,每个元素只能存储一个数据,每个元素存储的是数据本身,不是拉链法的单链表头指针

为什么开放地址法的一维数组要大于等于N?

  • 第一,肯定是为了存储下所有的数据
  • 第二,若长度小于N,即无法存储下所有数据,还可能导致寻找未使用的哈希值时,陷入死循环

841. 字符串哈希

841. 字符串哈希 - AcWing题库
image.png

#include <iostream>
using namespace std;

typedef unsigned long long ull;
const int P = 131, N = 1e6 + 10;
int p[N], h[N];
char str[N];
int n, m;
int l1, l2, r1, r2;

ull get(int l, int r)
{
    return h[r] - h[l - 1] * p[r - l + 1];   
}

int main()
{
    scanf("%d%d", &n, &m);
    scanf("%s", str + 1);
    p[0] = 1;
    for (int i = 1; i <= n; ++ i ) 
    {
        h[i] = h[i - 1] * P + str[i];
        p[i] = p[i - 1] * P;
    }

    while (m -- )
    {
        scanf("%d%d%d%d", &l1, &r1, &l2, &r2);
        if (get(l1, r1) == get(l2, r2)) printf("Yes\n");
        else printf("No\n");
    }
    
    return 0;
}
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值