Acwing - 算法基础课 - 笔记(五)

·  阅读 195

数据结构(二)

本节讲解的内容是,Trie树(字典树),并查集,堆(Dijkstra算法可以使用堆进行优化)

Trie树

Trie树,又称字典树,是用来高效存储和查找字符串集合的一种数据结构

查找时,可以高效的查找某个字符串是否在Trie树中出现过,并且可以查找出现了多少次

其逻辑结构如下

假设我们需要维护一个字符串集合,它需要支持两种操作

  • 向集合插入一个字符串x
  • 查询一个字符串在集合中出现了多少次

假设我们有一个字符串集合,包含如下的字符串

abcdabcacedbbacabdebcac

将这些字符串依次进行插入,构建出来的Trie树逻辑结构如下

其中红色的节点表示,存在一个以该节点为结尾的字符串

练习题:Acwing - 835: Trie字符串统计

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

const int N = 1e5 + 10;

int q[N][26];
int ctn[N]; // 用来计数
int idx; // 用来分配新的节点

// 注意这里, 使用数组来模拟指针的

// 下标为0的节点, 既是根节点,也用来表示空节点

// 如一个节点下标为1,则q[1][0]表示这个节点的a儿子,若q[1][0] = 0, 表示1这个节点没有a儿子(空节点),若q[1][0] = x , x不为0, 则表明1这个节点有a儿子,且a儿子的节点下标为x

// 更通俗地讲, q[i][j],表示了一个节点i连接其儿子节点的边,而j属于0~25, 表示了26个小写字母,当q[i][j] = x,且x不为0时,表明i这个节点有一儿子节点为某个字母, 且这个儿子节点下标为x

//ctn[i], 表示以节点i为结尾的字符串, 出现了多少次

void insert(string s) {
    int p = 0;
    for(int i = 0; i < s.size(); i++) {
        int u = s[i] - 'a';
        if(q[p][u] == 0) q[p][u] = ++idx;
        p = q[p][u]; // 更新当前节点
    }
    ctn[p]++;
}

int query(string s) {
    int p = 0;
    for(int i = 0; i < s.size(); i++) {
        int u = s[i] - 'a';
        if(q[p][u] == 0) return 0; // 不存在该节点
        p = q[p][u];
    }
    return ctn[p];
}

int main() {
    int n;
    char op;
    string s;
    scanf("%d", &n);
    while(n--) {
        cin >> op >> s;
        if(op == 'I') {
            insert(s);
        } else if(op == 'Q') {
            int c = query(s);
            printf("%d\n", c);
        }
    }
    return 0;
}
复制代码

课后题:Acwing - 143: 最大异或对

先想一个暴力做法,两层循环,穷举所有组合

int res = 0;
for(int i = 0; i < n; i++) {
    for(int j = 0; j < i; j++)
        res = max(res, a[i] ^ a[j]);
}
复制代码

上面的代码中,有两层循环,其中,内层循环的含义是:对于当前已经固定的a[i],在[0,i)之间找到一个j,使得a[j] ^ a[i]最大。

然而,对于内层循环,可以用Trie树优化,我们知道Trie树存的是字符串的集合,在这道题的场景下,我们考虑让Trie树来存储每个数字的二进制表示(二进制串)。每插入一个数到Trie树,从Trie树的根节点开始往下,先存这个数的二进制最高位,一直到叶子节点,存这个数的最低位。然后对于每一个a[i],从其二进制的高位开始,在Trie树中查找,每次尝试找与a[i]当前位不同的分支节点,一直找下去,找到叶子节点,这个二进制串代表的数,就是与a[i]做异或最大的数。代码题解如下:

#include<iostream>
using namespace std;

const int N = 1e5 + 10, M  = 31 * N;  // N 是数的个数上限, M是Trie树中节点个数的上限
// 由于每个二进制位,要么是0, 要么是1, 所以这个trie树是个二叉树
int son[M][2];  // 这个数组, 用来存Trie树
// 注意Trie树的节点个数不是N, 而是大于N的, 每个数最多有31个二进制位, 那么Trie树中总的节点数我们开大一些, 开为  31 * N 
int n, idx; // n 是数的个数, idx用来分配Trie树的节点下标
int q[N]; // 用来存输入的数

// 插入一个数到Trie树中
void insert(int x) {
	int p = 0; // 从根节点开始
	// 从数字二进制表示的最高位开始插入
	for(int i = 30; i >= 0; i--) {
		// 由于x的大小不超过2^31, 所以x的二进制表示最多31位
		// 也就是0-30, 从最高位第31位开始, 即下标30
		int u = (x >> i) & 1; //取这一位的二进制位
		if(son[p][u] == 0) son[p][u] = ++idx;
		p = son[p][u];
	}
}

// 查找和x做异或, 结果最大的值
int query(int x) {
	int p = 0, res = 0;
	for(int i = 30; i >= 0; i--) {
		int u = (x >> i) & 1;
		if(son[p][!u]) u = !u; // 若存在与x当前位相反的分支, 则走过去, 否则保持原样
		p = son[p][u];
		res = (res << 1) + u; // 计算这个最后的结果值
	}
	return res;
}

int main() {
	scanf("%d", &n);
	for(int i = 0; i < n; i++) scanf("%d", &q[i]);
	int res = 0;
	for(int i = 0; i < n; i++) {
		insert(q[i]); // 先插入当前的数q[i]到Trie树中
		int x = query(q[i]); // 此时Trie树中存的是全部 j = [0,i)的全部q[j]的二进制串
        // 这里相当于从[0,i)找与a[i]异或结果最大的那个数
		res = max(res, q[i] ^ x); // 更新结果
	}
	printf("%d", res);
	return 0;
}
复制代码

这道题,让我们知道,Trie树不仅可以存储字符串,还可以用来存储数字(二进制数)

并查集

并查集结构能够支持快速进行如下的操作

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

并查集可以在近乎O(1)的时间复杂度下,完成上述2个操作

并查集的基本原理:用树的形式来维护一个集合。用树的根节点来代表这个集合。对于树中的每个节点,都存储其父节点的编号。比如一个节点编号为x,我们用p[x]来表示其父节点的编号

当我们想求,某一个节点所属的集合时,找到其父节点,并一直往上找,直到找到根节点,则根节点的编号,就是该节点所属的集合的编号。

问题1:如何判断根节点?

对于根节点x,我们设置p[x] = x。那么,可以用p[x] == x 来判断是否是根节点

问题2:如何求某个节点x所属的集合编号?

while(p[x] != x) x = p[x]; 一直向上走,直到找到根节点

问题3:如何合并2个集合?

直接将一个集合作为另一个集合根节点的一个儿子节点即可。

假设一个A集合的根节点编号为x,另一个B集合根节点编号为y,则合并操作就是p[x] = y。即,将A集合的根节点作为B集合根节点的一个儿子。即,将A集合直接插到B集合里面。

对于查找某个节点x的所属集合,时间复杂度一开始可能没有O(1),可能需要O(logn),如果是二叉树的话。但可以采用路径压缩进行优化,当搜索完某个节点的所属集合时,直接将搜索路径上的所有节点的父节点,直接指向根节点,这样下次查询时就是O(1)。

并查集还有一种优化方式是按秩合并,大概是说的,将两个集合合并时,将高度较矮的那棵树,接到高度较高的树下面,具体的yxc没讲,这种优化用的比较少,而且好像也没有很有用

练习题:Acwing - 836: 合并集合(裸并查集,不带任何额外信息)

#include<iostream>
using namespace std;

const int N = 1e5 + 10;
int n, m;
int p[N]; //存每个节点的父亲节点编号

int find(int x) {
	if(p[x] != x) p[x] = find(p[x]); // 路径压缩, 递归方式, 可能会由于递归过深而栈溢出
	return p[x];
}

int find2(int x) {
    int r = x;
    while(p[r] != r) r = p[r]; // 先找到根
    // 再通过迭代循环的方式进行路径压缩
    int t;
    while(p[x] != x) {
        t = x;
        x = p[x];
        p[t] = r;
    }
    return r;
}

int main() {
	scanf("%d%d", &n, &m);
	for(int i = 0; i < n; i++) p[i] = i; // 初始化
	while(m--) {
		char op[2];
		int a, b;
		scanf("%s%d%d", op, &a, &b);
		if(op[0] == 'M') {
			p[find(a)] = find(b);
		} else if(op[0] == 'Q') {
			if(find(a) == find(b)) printf("Yes\n");
			else printf("No\n");
		}
	}
	return 0;
}
复制代码

课后题:Acwing - 837: 连通块中点的数量(额外维护集合中元素的个数)

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

const int N = 1e5 + 10;
int n, m;
int p[N], ctn[N];

int find(int x) {
	if(p[x] != 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; // 初始化
		ctn[i] = 1;
	}
	while(m--) {
		string op;
		int a, b;
		cin >> op;
		if(op == "C") {
			cin >> a >> b;
			if(find(a) == find(b)) continue; // 特判一下,若同属一个集合, 后续操作不用再进行
			ctn[find(b)] += ctn[find(a)]; // 将b集合的元素个数加上a的元素个数, 要先加, 再把a接到b
			p[find(a)] = find(b); // 将a接到b
		} else if(op == "Q1") {
			cin >> a >> b;
			if(find(a) == find(b)) printf("Yes\n");
			else printf("No\n");
		} else if(op == "Q2") {
			cin >> a;
			printf("%d\n", ctn[find(a)]);
		}
	}
	return 0;
}
复制代码

课后题(hard):Acwing - 240: 食物链

(并查集的变形,可以额外维护每个节点到根节点的距离)

维护每个点到根节点的距离,用不同的距离来表示不同种类的动物。每个点到根节点的距离模3余0,表示是1类,模3余1,表示是2类,模3余2,表示是3类。其中,2类吃1类,3类吃2类,1类吃3类。

#include<iostream>
using namespace std;

const int N = 5e4 + 10;
int n, m;
int p[N], d[N]; // parent distance

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

int main() {
	scanf("%d%d", &n, &m);
	for(int i = 1; i <= n; i++) p[i] = i;

	int res = 0;
	while(m--) {
		int t, x, y;
		scanf("%d%d%d", &t, &x, &y);
		if(x > n || y > n) res++;
		else {
			int 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;
}
复制代码

小结

并查集最核心的部分,就是记录每个节点的父节点的数组p[],以及查找某个节点所属的树的根节点的函数find(),以及路径压缩

初始化使得全部p[i] = i,表示每个点都是一个独立的集合。

合并时直接将一个集合的根节点,直接接入到另一个集合的根节点下面即可,即p[find(a)] = find(b)

核心就是找到某个节点所属的树的根节点

int find(int x) {
    if(p[x] != x) p[x] = find(p[x]); // 路径压缩
    return p[x];
}
复制代码

堆的基本操作(以小根堆为例)

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

堆的基本结构是一颗完全二叉树。以小根堆为例,每个节点都要小于其左右两个子树种的所有节点。

通常用数组来模拟存储一颗二叉树,采用二叉树层序遍历的方式作为数组的下标。若数组下标从0开始,若某个节点下标为x,则其左儿子下标为2x + 1,其右儿子下标为2x + 2。若数组下标从1开始,若某个节点下标为x,则其左儿子下标为2x,右儿子下标为2x + 1。

堆通过向下调整(down)和向上调整(up)来维持堆的特性。

down操作用来将一个较大的数,下沉到合适的位置

up操作用来将一个较小的数,上浮到合适的位置

可以通过down和up操作,完成上述的5个堆的基本操作(数组下标从1开始)

  1. 插入一个数

    插入到数组末尾,并对于新插入的这个数,向上调整

    heap[++size] = x; up(size);

  2. 求集合当中的最小值

    直接返回堆顶,即数组的首元素

    heap[1]

  3. 删除最小值

    先交换堆顶和堆尾,然后堆的大小减一,再针对新的堆顶,向下调整

    swap(heap[1], heap[size]); size--; down(1)

  4. 删除任意一个元素

    交换当前元素和堆尾,然后堆的大小减一,再根据新的当前元素的大小,决定是做down操作还是做up操作

    swap(heap[i], heap[size]); size--; down(i) 或者 up(i)

  5. 修改任意一个元素

    直接修改,并且根据修改后的新值,来决定做down还是up

    heap[i] = x; down(i) 或者 up(i)

建堆,可以从堆尾往上,找到第一个非叶子节点,从第一个非叶子节点往前,对所有的非叶子节点,依次执行down操作。这样,建堆的时间复杂度是O(n)

若数组下标从1开始,且堆总共有n个元素,则从后往前,第一个非叶子节点的下标为n/2。

练习题:Acwing - 838: 堆排序

#include<iostream>
using namespace std;

const int N = 1e5 + 10;

int h[N], hs;
int n, m;

// 堆的下标从1开始
void down(int x) {
	int min = x;
	if(2 * x <= hs && h[2 * x] < h[min]) min = 2 * x;
	if(2 * x + 1 <= hs && h[2 * x + 1] < h[min]) min = 2 * x + 1;
	if(min != x) {
		swap(h[min], h[x]); // 需要调整
		down(min); // 递归调整
	}
}

int main() {
	scanf("%d%d", &n, &m);
	for(int i = 1; i <= n; i++) scanf("%d", &h[i]);
	hs = n;
	for(int i = n / 2; i >= 1; i--) down(i); // 这个建堆过程实际的时间复杂度是O(n)
	while(m--) {
		printf("%d ", h[1]);
		swap(h[1], h[hs]);
		hs--;
		down(1);
	}
	return 0;
}
复制代码

堆的两个基本操作:down和up

// u是往下调整的节点下标
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) {
        swap(h[u], h[t]);
        down(t); // 因为最多是logn的复杂度, 可以直接用递归, 不用担心溢出
    }
}

void up(int u) {
    while(u / 2 > 0 && h[u / 2] > h[u]) {
        swap(h[u / 2], h[u]);
        u /= 2;
    }
}
复制代码

练习题:Acwing - 839: 模拟堆

#include<iostream>
#include<string>
using namespace std;
const int N = 1e5 + 10;
int h[N], ph[N], hp[N];
// ph[i] = k 表示第i个插入的数, 在堆中的节点的下标是k
// hp[k] = i 表示堆中下标为k的节点,是第i个插入的
int hSize, n;

void heap_swap(int i, int j) {
	swap(ph[hp[i]], ph[hp[j]]);
	swap(hp[i], hp[j]);
	swap(h[i], h[j]);
}

void down(int i) {
	int min = i;
	int leftSon = 2 * i;
	if(leftSon <= hSize && h[leftSon] < h[min]) min = leftSon;
	if(leftSon + 1 <= hSize && h[leftSon + 1] < h[min]) min = leftSon + 1;
	if(min != i) {
		heap_swap(i, min);
		down(min);
	}
}

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

int main() {
	scanf("%d", &n);
	string op;
	int x, k;
	int insertCnt = 0;
	for(int i = 1; i <= n; i++) {
		cin >> op;
		if(op == "I") {
			scanf("%d", &x);
			h[++hSize] = x;
			ph[++insertCnt] = hSize;
			hp[hSize] = insertCnt;
			up(hSize);
		} else if(op == "PM") {
			printf("%d\n", h[1]);
		} else if(op == "DM") {
			heap_swap(1, hSize--);
			down(1);
		} else if(op == "D") {
			scanf("%d", &k);
			int hPos = ph[k];
			heap_swap(hPos, hSize--);
			down(hPos);
			up(hPos);
		} else if(op == "C") {
			scanf("%d%d", &k, &x);
			int hPos = ph[k];
			h[hPos] = x;
			down(hPos);
			up(hPos);
		}
	}
	return 0;
}
复制代码
分类:
后端
标签: