寒假训练营 第四节 数据结构基础(三)总结

  • 集合,数学中默认指无序集,用于表达元素的聚合关系。两个元素只有属于同一个集合与不属于同一集合两种关系。
  • 在实际应用中,可能需要关心集合元素顺序。集合上定义偏序关系(即≤号),可构成一个偏序集。有序性作为重要规律,可引入算法(如二分法)提升运行效率。

并查集

1、题目描述:洛谷P1551 亲戚

如果 x 和 y 是亲戚,y 和 z 是亲戚,那么 x 和 z 也是亲戚。如果x 和 y 是亲戚,那么 x 的亲戚都是 y 的亲戚,y 的亲戚也都是 x的亲戚。现在给出某个亲戚关系图,求任意给出的两个人是否具
有亲戚关系。

这道题是典型的并查集(合并添加、增删改查的查)
我们不知道任何人的亲属关系。可以认为每一个人单独属于一个集合,其代表为自己
每添加一组亲属关系,则等将于合并两者所属的集合
代表关系具有传递性

实现时,用数组 fa 来存储“代表”。代表具有传递性。当查找代表时,需要递归地向上,直到代表为自身。

int find(int x) { // 查询集合的“代表”
	if (x == fa[x])return x;
	return find(fa[x]);
}

当合并两个元素时,需先判断两者是否属于同一集合。若否,则将其中一个集合的代表设置为另一方的代表。

void join(int c1, int c2) { // 合并两集合
	// f1为c1的代表,f2为c2的代表
	int f1 = find(c1), f2 = find(c2);
	if (f1 != f2) fa[f1] = f2;
}

本题为并查集最基础应用。套用模板即可解决。

// 初始化
for (int i = 1; i <= n; ++i)
	fa[i] = i;
	// 合并亲属关系
	for (int i = 0; i < m; ++i) {
	cin >> x >> y;
	join(x, y);
}
// 根据代表是否相同,查询亲属关系
for (int i = 0; i < p; ++i) {
	cin >> x >> y;
if (find(x) == find(y))
	cout << "Yes" << endl;
else
	cout << "No" << endl;
}
优化并查集

寻找代表的过程可能需要多次溯源。随着集合合并的深度增加,溯源的次数会越来越多,严重影响效率。
这里,引入两种优化方式:路径压缩和启发式合并。

  • 路径压缩
    即在查询完成后,将路径每一个元素的fa值直接更新为代表,使得下一次递归时,只需要一步即可找到代表
  • 启发式合并
    当合并两个集合时,选择较大的集合代表作为代表,即更改元素较少的集合的代表,可以减少路径压缩的次数。

经过优化后的并查集实现(可以用作模板 )其查询的时间复杂度接近常数

// 一定不要忘了初始化,每个元素单独属于一个集合

void init() {
	for (int i = 1; i <= n; i++)
		f[i] = i;
}
int find(int x) { // 查询集合的“代表”
	if (x == fa[x])return x;
	return fa[x] = find(fa[x]); // 顺便【路径压缩】
}
void join(int c1, int c2) { // 合并两个集合
	// f1为c1的代表,f2为c2的代表
	int f1 = find(c1), f2 = find(c2);
	if (f1 != f2) {
		if (size[f1] < size[f2]) // 【取较大者】作为代表
			swap(f1, f2);
		fa[f2] = f1;
		size[f1] += size[f2]; // 只有“代表”的size是有效的
	}
}

本题的AC代码:

#include<bits/stdc++.h>
using namespace std ;
int f[5005] ;
int find(int x)
{
	if(x == f[x]) 
		return x ;
	return f[x] = find(f[x]) ;
}
void join(int c1, int c2) { // 合并两集合
	// f1为c1的代表,f2为c2的代表
	int f1 = find(c1), f2 = find(c2);
	if (f1 != f2) 
		f[f1] = f2;
}
int main()
{
	int a , b ;
	int n , m , p ;
	cin >> n >> m >> p ;
	for(int i = 1 ; i <= n ; ++i)
		f[i] = i ;
	for(int i = 1 ; i <= m ; ++i){
		cin >> a >> b ;
		join(a,b) ;
	}	
	for(int j = 1 ; j <= p ; ++j){
		cin >> a >> b ;
		if(find(a) == find(b))
			cout << "Yes\n" ;
		else
			cout << "No\n" ;
	}
	return 0 ;
}

Hash表

2、题目描述:洛谷P3370 【模板】字符串哈希

给定 N ( N ≤ 10000) 个字符串(第 i 个字符串长度为 M i M_{i} Mi( M i M_{i} Mi ≤ 1500),字符串内包含数字、大小写字母,大小写敏感),请求出N个字符串中共有多少个不同的字符串

字符串哈希:任意两个字符串两两比较时间上必然是不可取的,时间复杂度。时间只允许处理每一个字符串仅一次
哈希函数:理论表明,并不是任意选择 Hash 函数都能取得同样的效果。

  1. 使用较大质数作为模数
    模数越大,空间越多,越难以冲突。
    同时,由于质数除了1和自身外没有其他因子,包含乘除运算
    的 Hash 函数不会因为有公因子而导致不必要的 Hash 冲突。
  2. 使用复杂的 Hash 函数
    直接取模是最简单的方式。
    但复杂的 Hash 函数可使值域分布更均匀,降低冲突的可能。

模运算可以将数值折叠到一个小区间内。但是还有一个问题,折叠之后,不同的数可能映射到同一个区域,这一现象称为 Hash 冲突
可以提出三种解决方法:

  1. 使用稳健的 Hash 函数,效率最高,冲突率最高
  2. 使用十字链表,完全解决冲突,效率较低
  3. 使用 Multi-Hash,折中的方法

请注意Hash函数的输入应只与对象本身有关,而与随机数等任何外界环境无关。

十字链表
使用链表(或 std::vector 等结构)将 Hash 冲突的元素保存起来。
这样,查找一个元素时只需要与 Hash 冲突的较少元素进行比较。

vector<long long> hash[maxh];

// 以下是插入集合的方式,查找也是类似

void int insert(x){
	int h = f(x); //计算哈希值
	for (int i == 0, sz=hash[h].size(); i<sz; i++)
		if (x == hash[h][i]) // 从数组中找到了这一项
			return; // 什么都不做退出
	hash[h].push_back(x); // 插入这个元素
}

用这种方式可以完全解决 Hash 冲突问题。但是查找元素的复杂度会有所上升(取决于 Hash 冲突的次数)。

Multi Hash (多哈希)
另一种解决方式是将映射f调整为高维,例如同时使用两个模数:

此时,只有当多个Hash函数值同时相等才会导致Hash冲突。冲突概率大幅降低。
注意Multi Hash对空间的开销较大,因为需要使用二维数组。

字符串哈希
该如何将字符串变成为整数编号呢?
由于取模运算具有关于乘法的结合律和关于加法的分配率,可以构造出最简单的Hash函数:将字符串视作整数取模

string s; // ......
int hash = 0;
for (int i = 0; s[i]; i++)
	// 计算base进制下模mod的值作为hash值
	hash = ((long long)hash * base + s[i]) % mod;

计算哈希函数的 base mod 根据经验选取。

  • base 应当选择不小于字符集数的质数。例如,a-z 字符串为 26,
    任意 ASCII 字符串为 256。
  • 而 mod 应该选取允许范围内尽可能大的质数
int n, ans;
char s[MAXN];
vector <string> linker[mod + 2];
void insert();
int main() {
	cin >> n;
	for (int i = 1; i <= n; i++)
		cin >> s, insert();
	cout << ans << endl;
	return 0;
}
void insert() {
	int hash = 1;
	for (int i = 0; s[i]; i++)
		hash = (hash * 1ll * base + s[i]) % mod;
		//计算出字符串的hash值
	string t = s;
	for (int i = 0; i < linker[hash].size(); i++)
		//遍历hash值为该字符串hash值的链表,检查字符串是否存在
		if (linker[hash][i] == t)
			return; //如果找到同样的字符串,这个字符串不计答案
	linker[hash].push_back(t); //否则计入答案
	ans++;
}

这里取 base=261,mod=23333
本题AC的代码:

#include<bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
ull base=131;
ull a[10010];
char s[10010];
int n,ans=1;
int prime=233317; 
ull mod=212370440130137957ll;
ull hashe(char s[])
{
 int len=strlen(s);
 ull ans=0;
 for (int i=0;i<len;i++)
 ans=(ans*base+(ull)s[i])%mod+prime;
 return ans;
}
int main()
{
 scanf("%d",&n);
 for(int i=1;i<=n;i++)
 {
 	scanf("%s",s);
 	a[i]=hashe(s);
 }
 sort(a+1,a+n+1);
 for(int i=1;i<n;i++)
 {
 	if(a[i]!=a[i+1])
 	ans++;
 }
 printf("%d",ans);
} 

STL中的集合

STL中的集合与映射
之前已经提到过,STL中也有集合的实现,分为无序集和偏序集。
其中分为 集合(set)映射(map)

  • 无序集在 STL 中是 unordered_set unordered_map
    其本质为 Hash表,因此增删改查均为 O(1)
    对于复杂数据类型,需要手动实现 Hash函数
  • 偏序集在 STL 中是 set map
    本质为排序树,增删改查均为 O(logn)
    对于复杂数据类型,需要手动实现偏序关系,即<运算符。

集合在 STL 中有两种,分别是 有序集合无序集合,分别需要的头文件为 < unordered_set > 和 < set >,二者功能上类似,但有序集可找前驱后继

unordered_set 的行为(无序):

unordered_set<Type> s; //创建Type类型的集合
s.insert(x); // 插入元素x
s.erase(x); // 删除值为x的元素
s.erase(it); // 删除it所指的元素
s.end(); // 返回末位哨兵的迭代器
s.find(x); // 查询x;不存在则返回s.end()
s.empty(); // 判断是否为空
s.size(); // 返回集合的大小

set 的行为(有序):

set<Type> s; // 创建一个Type类型的集合
s.insert(x); // 插入元素x
s.erase(x); // 删除值为x的元素
s.erase(it); // 删除it所指的元素
s.end(); // 返回末位哨兵的迭代器
s.find(x); // 查询x;不存在则返回s.end()
s.empty(); // 判断是否为空
s.size(); // 返回集合的大小
s.upper_bound(x); // 查询大于x的最小元素
s.lower_bound(y); // 查询不小于x的最小元素
// 使用方法与二分查找一章所介绍的一致

STL中的映射
映射在 STL 中也有两种,分别是 有序映射 和 无序映射,分别需要的头文件 为< unordered_map > 需头文件 < map >
unordered_map 的行为(无序):

unordered_map <T1, T2> m; // 创建T1到T2的映射

// 其中T1称为键key,T2称为值value

m.insert({a,b});// 创建映射a->b

m.erase(a); // 删除key为a的映射

m.erase(it); // 删除it所指的映射

m.end(); // 返回末位哨兵的迭代器

m.find(x); // 寻找键x;若不存在则返回m.end()

m.empty(); // 判断是否为空

m.size(); // 返回映射数目

m[a] = b; // 修改a映射为b;若不存在则创建

map 的行为(有序):

map<T1, T2> m; // 创建一个T1到T2的映射

// 其中T1称为键key,T2称为值value

m.insert({a,b});// 创建映射a->b

m.erase(a); // 删除key为a的映射

m.erase(it); // 删除it所指的映射

m.end(); // 返回末位哨兵的迭代器

m.find(x); // 寻找键x;若不存在则返回m.end()

m.empty(); // 判断是否为空

m.size(); // 返回映射数目

m[a] = b; // 修改a映射为b;若不存在则创建

m.upper_bound(x); // 查询大于x的最小键

m.lower_bound(x); // 查询不小于x的最小键

// 使用方法与二分查找一章所介绍的一致
3、题目描述:P5250 【深基17.例5】木材仓库

仓库里面可存储各种长度的木材,但保证所有长度均不同。作为仓库负责人,你有时候会进货,有时候会出货,因此需要维护这个库存。有不超过 100000 条的操作:

  1. 进货,格式 1 Length:在仓库中放入一根长度为 Length(不超过 1 0 9 10^{9} 109) 的木材。如果已有相同长度的木材输出Already Exist。
  2. 出货,格式 2 Length:从仓库中取出长度为 Length 的木材。若无刚好长度的木材,取出在库的和要求长度最接近的木材。如有多根木材符合要求,取出比较短的一根。输出取出的木材长度。如果仓库是空的输出Empty。

一句话题意:维护一个集合,支持插入、删除最接近元素。
最接近,即前驱、自身(若存在)或后继。可知应当使用偏序集。

  • 使用 lower_bound 即可找自身或后继;
  • 而其 prev 即为所求前驱。
set <int> ::iterator p, q;
q = s.lower_bound(length);
p = prev(q); // 方法1
p = q; p--; // 方法2;本质等价

iterator 是指集合元素的迭代器,j 为前驱地址,i 为本身/后继地址,选择二者中与 length 接近者。如果 i 是 s.begin(),则 prev() 或 – 会得到错误的值,需特判。

set <int> ds;
set <int> ::iterator i, j;
i = ds.lower_bound(lenth);
j = i;
set <int> ds;
set <int> ::iterator i, j;
i = ds.lower_bound(lenth);
j = i;

本题AC的代码:

#include <iostream>
#include <set> 
using namespace std;
int n, op, t;
set<int>::iterator lwb, l2, l3;
set<int> s;
int main(){
	cin >> n;
	for (int i = 1;i <= n;i ++){
		cin >> op >> t;
		if (op == 1){
			if (!s.insert(t).second) cout << "Already Exist\n";
		}
		else {
			if (s.empty()){
				cout << "Empty\n";
				continue;
			}
			if (s.find(t) != s.end()) cout << t, s.erase(s.find(t));
			else {
				lwb = l2 = l3 = s.lower_bound(t);
				if (lwb == s.begin()) cout << *lwb, s.erase(lwb);
				else if (lwb == s.end()) cout << *(-- l3), s.erase(l3);
				else if (*lwb - t < t - *(-- l2)) cout << *(l3), s.erase(l3);
				else cout << *(-- l3), s.erase(l3);
			}
			cout << endl;
		}
	}
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值