数据结构笔记--并查集 哈希表 堆
系列文章目录:
并查集
并查集(Disjoint Set Union, Union-Find)是一种高效维护多个不相交集合的数据结构。它用一个森林(Forest)实现:每个集合(集合间不相交)对应一棵树,树的根(root)作为该集合的“代表”或“标识”。
主要操作有两类:
- 查找
Find(x)
:查找元素所在的集合,即找到它的根。 - 合并
Union(x, y)
:将两个集合合并为一个集合,即找到两个根,将一个根的父节点指向另一个根。
用途包括判断两个元素是否属于同一个集合、合并集合、判环(比如在 Kruskal 算法里避免生成环),等等。
基本实现
由于是基于森林的写法,我们这里用父节点表示法来表示树,以及再开一个root[]
来记录根节点的位置。
初始化
我们把每个节点都看作一个集合,初始化时每个节点的父节点都指向自己,表示每个节点都是根节点。
1 | parent[i] = i; |
查找
我们需要沿着树向上移动,直至找到根节点。
1 | int find(int x) |
合并
要合并两棵树,我们只需要将一棵树的根节点连到另一棵树的根节点。
1 | void unite(int a, int b) |
优化
路径压缩(Path Compression)
经典的find
会让树变得很深,效率下降。路径压缩优化的步骤是:每次find(x)
时,都让访问路径上的节点直接挂到根上,极大降低树的高度:
1 | int find(int x) |
这样平均下来每次操作几乎就是常数时间(用的是反阿克曼函数 α(n) 量级)
按秩合并(Union by Rank/Size)
当合并两个集合(树)时,我们可以让秩(rank,size)小的树挂在秩大的树下面,这样能防止树高过快增长:
- 秩通常代表树的高度估计值
- 合并后仅在秩相等时,新的根秩加一
1 | int rank[N]; |
应用
- 对于连通性问题,可以用并查集维护连通性,判断两个节点是否连通,或者合并两个连通区域。
- 对于图论中的最小生成树问题,可以用并查集维护连通性,避免生成环。
哈希表
哈希表(Hash Table),又叫散列表,是一种基于 键Key
直接访问值Value
的数据结构。它的思想是:通过哈希函数(Hash Function),把键映射到一个数组下标,从而实现 O(1) 时间复杂度的查找和存取。
可以把哈希表理解为一种高级的数组,这种数组的下标可以是很大的整数,浮点数,字符串甚至结构体。
以上即哈希表的一个简单例子,其中b[1012]
到b[1016]
是哈希表的各个桶,每个桶里存放的元素存在哈希冲突(即哈希函数计算出的下标相同),这个问题在哈希函数中会讲到。
哈希函数
要让键值对应到内存中的位置,就要为键值计算索引,也就是计算这个数据应该放到哪里。这个根据键值计算索引的函数就叫做哈希函数,也称散列函数。举个例子,如果键值是一个人的身份证号码,哈希函数就可以是号码的后四位,当然也可以是号码的前四位。生活中常用的「手机尾号」也是一种哈希函数。在实际的应用中,键值可能是更复杂的东西,比如浮点数、字符串、结构体等,这时候就要根据具体情况设计合适的哈希函数。哈希函数应当易于计算,并且尽量使计算出来的索引均匀分布。
常见的hash值设计方法有:
- 除法散列法:
$key\ = \ value \ (\mathbf{mod} \ M)$ 其中 $M$ 是一个素数或者 $2^p-1$ - 乘法散列法:
$key\ = \ \left \lfloor M \cdot (A \cdot key \ (\mathbf {mod} \ 1)) \right \rfloor $
其中 $0<A<1$ 并且最好接近黄金比例 $\frac{\sqrt{5}-1}{2} \approx 0.618$ ,并且这里的 $(\mathbf {mod} \ 1)$ 表示取(double)(A*key)
的小数部分 - 字符串hash:
$key = (\sum_{i=0}^{n-1} s[i] \cdot p^i) \ \mathbf {mod} \ M$
这里的p是常数,M是素数,s是字符串(字符按照ASCII码来计算),n是字符串长度 - CRC/位运算哈希:
h = (h << 5) ^ (h >> 27) ^ key
这里使用的是迭代的方式 $h_{i+1}=H(h_i, key_i)$
好哈希函数的标准:简单快速、分布均匀、冲突尽可能少
哈希冲突
哈希函数计算出来的索引可能相同,这就是哈希冲突。哈希冲突的解决方法有:
- 开放定址法(Open Addressing)
如果位置被占用那么寻找下一个空位- 线性探测: $H_i = ( H(key) + d_i) \ (\mathbf {mod} \ M)$,$d_i = 0,1,2,3,…,M-1$
- 二次探测: $H_i = ( H(key) + d_i) \ (\mathbf {mod} \ M)$,$d_i = 0,1^2,-1^2,2^2,-2^2,…,k^2,-k^2$,$k < \sqrt{M}$
- 双重散列: $H_i = ( H_1(key) + i \cdot H_2(value) ) \ (\mathbf {mod} \ M)$,$H_1$和$H_2$是两个不同的哈希函数
- 链地址法(Chaining)
每个位置存储一个链表或者vector
,冲突的元素直接加到链表上,前面的图例就是链地址法。查询的时候需要把对应位置的链表整个扫一遍,对其中的每个数据比较其键值与查询的键值是否一致。如果索引的范围是 $1\ldots M$ ,哈希表的大小为 $N$ ,那么一次插入/查询需要进行期望 $O(\frac{N}{M})$ 次比较。
这里提供一个io wiki上一个不错的代码1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27struct hash_map { // 哈希表模板
struct data {
long long u;
int v, nex;
}; // 前向星结构
data e[SZ << 1]; // SZ 是 const int 表示大小
int h[SZ], cnt;
int hash(long long u) { return (u % SZ + SZ) % SZ; }
// 这里使用 (u % SZ + SZ) % SZ 而非 u % SZ 的原因是
// C++ 中的 % 运算无法将负数转为正数
int& operator[](long long u) {
int hu = hash(u); // 获取头指针
for (int i = h[hu]; i; i = e[i].nex)
if (e[i].u == u) return e[i].v;
return e[++cnt] = data{u, -1, h[hu]}, h[hu] = cnt, e[cnt].v;
}
hash_map() {
cnt = 0;
memset(h, 0, sizeof(h));
}
};代码实现
1 | struct HashTable { |
字符串哈希
字符串的哈希函数设计可以参考上面的字符串hash公式,以及oi wiki 上已经解释地很清楚了,这里给出c++的模板实现:
模数 Hash:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18using std::string;
constexpr int M = 1e9 + 7;
constexpr int B = 233;
using ll = long long;
int get_hash(const string& s) {
int res = 0;
for (int i = 0; i < s.size(); ++i) {
res = ((ll)res * B + s[i]) % M;
}
return res;
}
bool cmp(const string& s, const string& t) {
return get_hash(s) == get_hash(t);
}双值 Hash:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23using ull = unsigned long long;
ull base = 131;
ull mod1 = 212370440130137957, mod2 = 1e9 + 7;
ull get_hash1(std::string s) {
int len = s.size();
ull ans = 0;
for (int i = 0; i < len; i++) ans = (ans * base + (ull)s[i]) % mod1;
return ans;
}
ull get_hash2(std::string s) {
int len = s.size();
ull ans = 0;
for (int i = 0; i < len; i++) ans = (ans * base + (ull)s[i]) % mod2;
return ans;
}
bool cmp(const std::string s, const std::string t) {
bool f1 = get_hash1(s) != get_hash1(t);
bool f2 = get_hash2(s) != get_hash2(t);
return f1 || f2;
}个人偏好(用于子字符串的匹配)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24struct StringHash
{
using ull = unsigned long long;
static const ull base = 131;
vector<ull> h, p; // h 前缀哈希, p 幂次表
StringHash(const string &s)
{
int n = s.size();
h.assign(n + 1, 0);
p.assign(n + 1, 1);
for (int i = 1; i <= n; i++)
{
h[i] = h[i-1] * base + (s[i-1] - 'a' + 1);
p[i] = p[i-1] * base;
}
}
// 查询子串 [l,r] 的哈希 (1-indexed)
ull get(int l, int r)
{
return h[r] - h[l-1] * p[r-l+1];
}
};
具体使用
- 判断两个字符串是否相等
if(!cmp(s,t))cout << "No";
- 判断字字串符串是否相等
- 这里先预处理字符串的hash前缀
- 然后再计算子串
s[l,l+1,,,r-1,r]
的hash,并对比 - 这里取单模哈希的方法做例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
using namespace std;
using ull = unsigned long long;
const ull base = 131;
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
string s, t;
cin >> s >> t;
int n=s.size(), m=t.size();
vector<ull> hp(n+1, 0), pb(n+1, 1);
for(int i=1; i<=n; i++)
{
hp[i]=hp[i-1]*base + (s[i - 1] - 'a' + 1);
pb[i]=pb[i-1]*base; //同时计算base的幂
}
// 计算模式串哈希
ull ht = 0;
for (int i=0; i<m; i++)
ht=ht*base+(t[i]-'a'+1);
// 枚举所有长度 m 的子串哈希比较
int cnt=0;
for(int i=0; i+m<=n; i++)
{
ull sub=hp[i+m]-hp[i]*pb[m];
if(sub==ht) cnt++;
}
cout << cnt << "\n";
return 0;
}
- 最长公共前缀 / 最长重复子串
- 二分+双hash
- 二分长度L
- 枚举长度为L的子串,存哈希值
- 果存在重复 → 增大 L,否则减小 L
- 回文或反向匹配
遍历两边,计算字符串的正反hash,然后判断是否相等
堆
堆就是一颗树,树上的节点都有各自的键值(key),且每个节点的key都大于或等于其子节点的key(大根堆),或者小于或等于其子节点的key(小根堆)。
在C++ STL
中priority_queue
就是堆的一种实现,默认为大根堆。
堆的主要操作有:
- 建堆(Build Heap):
- 从一个无序数组构建一个堆 $O(n)$
- 插入(Insert/Push):
- 把新元素插入到堆的末尾,然后上浮(Sift Up) $O(\log n)$
- 取堆顶(Get Top/Peek):
- 直接返回堆顶元素 $O(1)$
- 删除(Delete/Pop):
- 把堆顶元素删除,然后把堆的最后一个元素放到堆顶,然后下沉(Sift Down) $O(\log n)$
- 调整(Adjust/Heapify):
- 调整某个节点以下的子树,使其满足堆性质 $O(\log n)$
- 合并(Merge/Meld):
- 合并两个堆,斐波那契堆可以达到 $O(1)$
二叉堆(Binary Heap)
二叉堆就是一棵完全二叉树,且满足堆性质。
这里以大根堆即父节点的权值大于等于子节点的权值为例。
存储方式:数组,从1开始存储。
- 根节点
a[1]
- 对于某个节点
a[i]
- 左子节点为
a[2i]
- 右子节点为
a[2i+1]
- 父节点为
a[i/2]
- 左子节点为
这些性质在讲二叉树时强调过。
过程
插入(Insert/Push)
- 把新元素放到数组末尾(堆的最后一个位置)
- 然后不断与其父节点比较,如果比父节点大,则交换位置,直到比父节点小或到达根节点(满足堆的性质),这个过程称为上浮(Sift Up)
- 时间复杂度:$O(\log n)$
1 | void push(vector<int> &heap,int x) |
删除(Delete/Pop)
如果直接从欲删除的点断开,则会多出一颗树,我们可以按照以下步骤来删除堆顶元素:
- 取出堆顶元素,把堆的最后一个元素放到堆顶
- 从根开始,不断和其子节点比较,如果比子节点小,则交换位置,直到比子节点大或到达叶子节点(满足堆的性质),这个过程称为下沉(Sift Down)
- 时间复杂度:$O(\log n)$
1 | void pop(vector<int> &heap) |
建堆(Build Heap)
假设我们有一个无序数组,如何把它变成一个堆呢?下面有两种方式:
- 利用刚刚写的
push
操作,一个一个插入,时间复杂度是$O(n\log n)$ - 从最后一个非叶子节点开始下沉,时间复杂度是$O(n)$
这里以第二种方式为例:
1 | void buildHeap(vector<int> &heap) |
完整的代码(大根堆)
1 | struct BinaryHeap |
配对堆(Pairing Heap)
配对堆本质是一种可并堆(Meldable Heap),比二叉堆更适合频繁的合并merge
操作,常用于优先队列的实现。支持插入,查询/删除堆顶,合并等操作。有速度快和结构简单的优势,但由于其为基于势能分析的均摊复杂度,无法可持久化。
由于配对堆使用的是一颗多叉树,每个节点的权值都