- 集合,数学中默认指无序集,用于表达元素的聚合关系。两个元素只有属于同一个集合与不属于同一集合两种关系。
- 在实际应用中,可能需要关心集合元素顺序。集合上定义偏序关系(即≤号),可构成一个偏序集。有序性作为重要规律,可引入算法(如二分法)提升运行效率。
并查集
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和自身外没有其他因子,包含乘除运算
的 Hash 函数不会因为有公因子而导致不必要的 Hash 冲突。- 使用复杂的 Hash 函数
直接取模是最简单的方式。
但复杂的 Hash 函数可使值域分布更均匀,降低冲突的可能。模运算可以将数值折叠到一个小区间内。但是还有一个问题,折叠之后,不同的数可能映射到同一个区域,这一现象称为 Hash 冲突。
可以提出三种解决方法:
- 使用稳健的 Hash 函数,效率最高,冲突率最高
- 使用十字链表,完全解决冲突,效率较低
- 使用 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 Length:在仓库中放入一根长度为 Length(不超过 1 0 9 10^{9} 109) 的木材。如果已有相同长度的木材输出Already Exist。
- 出货,格式 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;
}
}
}