并查集(带权/拓展域)
并查集 + 哈希/离散化
思路:
- 由于本题数据范围太大,并查集的数组肯定装不下,所以要离散化
- 我们只需要判断不相等的情况,然后把相等的放入一个集合。或者只判断相等的情况,把不相等的放入一个集合
- 并查集的数据要开两倍,因为极端情况下,每个x和y都不相同
- 在判断有没有错误的时候,要先把所有所有相等的情况全处理掉,然后再判断不相等的情况是否符合条件
写法1: 离散化
#include <iostream>
#include <algorithm>
#include <cstring>
#include <vector>
#include <unordered_map>
using namespace std;
typedef pair<int, int> PII;
const int N = 200010;
int T, n, pre[N];
int idx1, idx2;
PII add[N], query[N];
void init()
{
for(int i = 0; i < N; i ++ ) pre[i] = i;
}
int find(int x)
{
if(pre[x] == x) return pre[x];
return pre[x] = find(pre[x]);
}
void unite(int x, int y)
{
int fx = find(x), fy = find(y);
if(fx != fy) pre[fx] = fy;
}
int main()
{
cin >> T;
while(T -- )
{
init();
idx1 = idx2 = 0;
int n; cin >> n;
vector<int> v;
for(int i = 0; i < n; i ++ )
{
int x, y, op;
cin >> x >> y >> op;
v.push_back(x);
v.push_back(y);
if(op == 1) add[idx1 ++ ] = {x, y};
else query[idx2 ++ ] = {x , y};
}
sort(v.begin(), v.end());
v.erase(unique(v.begin(), v.end()), v.end());
for(int i = 0; i < idx1; i ++ )
{
int x = add[i].first, y = add[i].second;
auto a = lower_bound(v.begin(), v.end(), x) - v.begin() + 1;
auto b = lower_bound(v.begin(), v.end(), y) - v.begin() + 1;
// cout << a << ' ' << b << endl;
unite(a, b);
}
bool has_res = true;
for(int i = 0; i < idx2; i ++ )
{
int x = query[i].first, y = query[i].second;
auto a = lower_bound(v.begin(), v.end(), x) - v.begin() + 1;
auto b = lower_bound(v.begin(), v.end(), y) - v.begin() + 1;
if(find(a) == find(b))
{
has_res = false;
// cout << "false: " << a << ' ' << b << endl;
break;
}
}
if(has_res) puts("YES");
else puts("NO");
}
return 0;
}
写法2:哈希表、
#include <iostream>
#include <algorithm>
#include <cstring>
#include <unordered_map>
using namespace std;
typedef pair<int, int> PII;
const int N = 200010;
int T, idx, n, m;
int pre[N];
PII query[N];
unordered_map<int, int> Hash;
void init()
{
for(int i = 0; i < N; i ++ ) pre[i] = i;
}
int find(int x)
{
if(pre[x] == x) return x;
return pre[x] = find(pre[x]);
}
int get(int x)
{
if(!Hash.count(x)) Hash[x] = ++ n;
return Hash[x];
}
int main()
{
ios::sync_with_stdio(false);
cin >> T;
while(T -- )
{
init();
Hash.clear();
idx = n = 0;
cin >> m;
while(m -- )
{
int x, y, op;
cin >> x >> y >> op;
if(op == 1)
{
int fx = find(get(x)), fy = find(get(y));
pre[fx] = fy;
}
else query[idx ++ ] = {x, y};
}
bool flag = true;
for(int i = 0; i < idx; i ++ )
{
int x = query[i].first, y = query[i].second;
int fx = find(get(x)), fy = find(get(y));
if(fx == fy)
{
flag = false;
break;
}
}
if(flag) cout << "YES" << endl;
else cout << "NO" << endl;
}
return 0;
}
带权并查集
参考博客:
总结:
- 我们用一个数组d来记录一个点到它的根节点的距离,这样可以方便求出两个点之间的距离(前缀和思想),记录根节点而不是尾节点的距离是因为find函数可以轻松的找到根节点。
- 当我们合并两个集合的时候,a合并到b,我们只修改a的队头的d[],(相当于一个懒标记)。此时d[]表示a的队头到b的队头的距离,此时b的队头既是a的父节点,又是a的根节点。此后由于a不再是队头了(新队头是b),所以往后合并时a的d[]都不再修改。
- 如果我们从a的队头递归下去到a的尾节点的话把整个a的d[]都修改的话,时间复杂度是O(n)的,这非常不理想!
- 在find父节点的过程中,我们先找到根节点,然后从根节点想下回溯并修改d[]的值(回溯的思想),因为在合并的时候,我们的d[]是记录的该点到第一次合并时的父节点的距离,而不是根节点。之所以自根向下回溯,是因为底下的点会用到上面的点,看5的公式就明白了
- 即d[x] = d[x] + d[pre[x]],可以理解为一个节点到根节点的距离等于它到父节点的距离加上父节点到根节点的距离
- 我们已知d[x],需要求d[pre[x]],(递归)。
#include <iostream>
#include <algorithm>
#include <cstring>
#include <cmath>
using namespace std;
const int N = 30010;
int n, pre[N], s[N], d[N];
int find(int x)
{
if(pre[x] == x) return x;
int root = find(pre[x]);
d[x] += d[pre[x]];
return pre[x] = root;
}
int main()
{
for(int i = 1; i < N; i ++ )
{
pre[i] = i;
s[i] = 1;
}
cin >> n;
while(n -- )
{
string op;
int x, y;
cin >> op >> x >> y;
if(op == "M")
{
int fx = find(x), fy = find(y);
pre[fx] = fy;
d[fx] = s[fy];
s[fy] += s[fx];
}
else
{
int px = find(x), py = find(y);
if(px != py) puts("-1");
else cout << max(0, abs(d[x] - d[y]) - 1) << endl;
}
}
}
带权并查集 / 拓展域并查集
本题思路:
题目大意很简单,就是每次给定我们一段区间[l, r]的奇偶性,让我们找出出现矛盾的时刻。
即,a[l] + a[l + 1] + ... + a[r - 1] + a[r]
<==> s[r] - s[l - 1] (看到这种区间问题要马上想到前缀和思想)
<==> s[r] 和 s[l-1]的奇偶性关系 (关键)
得出前缀和公式只是第一步,对于前缀和s[r] - s[l-1],我们要善于从前缀和公式当中提取出一些比较深层次的信息(经常考到,例如公式的转换变形等等),在本题给定的s[r] - s[l-1]只有两种结果(奇数或者偶数),如果s[r]-s[l-1]为偶数,那么s[r]和s[l-1]的奇偶性一定相同!如果s[r]-s[l-1]为奇数,那么s[r]和s[l-1]的奇偶性一定不同。
我们只有推导出了上面一点,才能理解本题为什么可以使用并查集。
拓展域:
什么是拓展域?
在普通的并查集中,通常只有一个维度(元素本身),但是在拓展域当中,我们会对一个元素进行拓展,让它拥有多个维度(通常是某些性质)。在本题中,我们将一个元素拓展为两个维度,即两个域:奇数域和偶数域。
拓展域存放的是什么?
在普通的并查集,包括带权并查集中,一个集合中存放的仅仅是元素本身。而在拓展域并查集中,集合中存放的不仅仅是该元素本身,还有与它相关的条件。并且在同一个集合当中,只要有一个条件成立,其余所有条件必然成立。例如,如果一个集合当中的元素为{a1,a2,a3},如果a1为奇数,那么a2,a3也必然为奇数。
思路:
设置两个域:奇数域和偶数域,每多开一个域就要多开一倍的空间。我们令[1,N]表示奇数域,[1 + Base, N + Base]表示偶数域,Base是一个偏移量,一般是一个域的大小。
对于每个输入的a和b,先判断是否存在与当前输入的奇偶性相矛盾的情况。这里我们只需要判断一组情况即可(对称性),因为当我们合并的时候是对称合并的(即a的奇数域和b的偶数域如果在一个集合中,那么a的偶数域和b的奇数域也一定在一个集合中。同理,如果a的奇数域和b的奇数域在一个域中,那么a的偶数域和b的偶数域也一定在一个域中)含义就是a和b的奇偶性相同与否。
如果没有。当a和b的奇偶性相同的时候,合并a和b的奇数域以及a和b的偶数域。否则,合并a的奇数域和b的偶数域,以及a的偶数域和b的奇数域。
总体来看,拓展域的思路更清晰,也更简单一些,但是在数据比较大的情况下,可能只能使用带权并查集,因为拓展域的空间复杂度和时间复杂度可能不行。
#include <iostream>
#include <cstring>
#include <algorithm>
#include <unordered_map>
using namespace std;
const int N = 20010, Base = N / 2;
int idx, m, pre[N];
unordered_map<int, int> Hash;
string str;
int get(int x)
{
if(!Hash.count(x))//这个数没出现过
Hash[x] = ++ idx;
return Hash[x];
}
void init()
{
for(int i = 0; i < N; i ++ ) pre[i] = i;
}
int find(int x)
{
if(pre[x] == x) return x;
return pre[x] = find(pre[x]);
}
int main()
{
init();
cin >> str >> m;
int res = m;
for(int i = 1; i <= m; i ++ )
{
int a, b;
string op;
cin >> a >> b >> op;
a = get(a - 1), b = get(b);//哈希
// cout << a << ' ' << b << endl;
if(op == "even")//a,b奇偶性相同
{
if(find(a + Base) == find(b))//如果他们之前出现过奇偶性相反的情况
{
res = i - 1;
break;
}
pre[find(a)] = find(b);
pre[find(a + Base)] = find(b + Base);
}
else//a,b奇偶性相反
{
if(find(a) == find(b))//如果他们之前出现过奇偶性相同的情况
{
res = i - 1;
break;
}
pre[find(a + Base)] = find(b);
pre[find(b + Base)] = find(a);
}
}
cout << res << endl;
return 0;
}
带边权的并查集
思路:
在带边权并查集中,每个点都会与根节点有一个关系。
在上一题中,表示的是该节点到根节点的距离。利用前缀和的思想求得两个节点之间的距离distance = max(abs(d[a]-d[b] - 1), 0) 。
在本题中,我们如何通过d[a]和d[b]表示出两个节点之间的奇偶性?我们可以假象这么一个公式d[a] op d[b] = attr(op表示一种运算,attr表示运算之后的奇偶性)
我们可以这么构造d[x],d[x]表示x到pre[x]的距离。如果d[x]=1,表示x的奇偶性和pre[x]相反,如果d[x]=0,表示x的奇偶性和pre[x]相同。
现在我们可以求出每个节点到根节点的距离,也就是每个节点与根节点的奇偶性关系,那么如何求出两个节点之间的奇偶性关系呢?
以下是传递关系细目表(传递性)
- 如果说x1和x2奇偶性质相同,x2与x3奇偶性质相同,那么x1和x3也相同
- 如果说x1和x2奇偶性质相同,x2与x3奇偶性质不同,那么x1和x3也不同
- 如果说x1和x2奇偶性质不同,x2与x3奇偶性质不同,那么x1和x3就相同
通过上面的传递性,我们就可以知道任意两点之间的奇偶性关系了。同时也解答了我们一开始假定的公式,其中op=异或。
并且我们可以把d[]进行简化,让它到达根节点的距离非0即1。
- 当d[]为奇数时,d[]和1的意义是相同的,都是表示与根节点的奇偶性相反。因为我们可以把每个距离1看从根节点到该节点的一次奇偶性转换,那么没两个1就相当于转换两次,也就是没有转换,即抵消掉了。
- 同理,由于d[]为偶数,那么我们可以把所有的1抵消掉。
可是我们如何实现非0即1呢?
- 通过异或运算。在异或运算中,有两个重要的性质:0^x=x,x^x=0
- x^x=0就像等于1+1=0,两个1抵消掉了。0^x就相当于1+0=0。
- 因为在初始合并的时候,d[]是非0即1的,d[]能取得>1的时刻是在find函数合并的时候修改的,所以我们在find回溯的时候,要使用异或运算而不是加运算。
当然,我们没有必要非要使用这种非0即1的简化,但此时我们必须注意,由于d[]在find函数中的增长可能是非常大的(溢出变成负数),所以在加运算过程中要注意取模。
或者不用取模,通过(x%n+n)%n再变成正数
步骤:
- 对于给定的两个节点,先查找一下他们的根节点。判断一下根节点是否相同。
- 如果他们的根节点相同,说明这两个点之前出现过,所以说他们的奇偶关系之前已经确定了。此时我们需要求一下t=d[a]^d[b],如果当前给定的奇偶关系是相同,但t=1,说明出现了矛盾。同理,如果当前给定的奇偶关系是不同,但t=0,说明出现了矛盾。否则,没有矛盾。
- 如果根节点不同,说明这两个节点的关系是第一次给出,此时我们需要将两个节点合并。合并的过程可以看做从a的根节点pa连一条边到b的根节点pb(我们设合并后b的根节点为根节点),此时a和b的奇偶性关系t=d[a]^d[b]^?(?表示我们连过去的那条边的权值),我们可以通过当前给定的a和b的奇偶性关系求得?,并由x^x=0的性质,转换公式如下:?=d[a]^d[b]^t
再解释一下find函数的回溯:
- 只有当pre[x] != x的时候我们才会回溯。
- 由于pre[x]不是根节点,所以我们要先找到根节点:root=find(pre[x])
- 在找到根节点之后,由于路径压缩,pre[x]会一步到位指向root,此时d[pre[x]]就是pre[x]到根节点root的距离
- 现在我们要让x也一步到位指向root,同时让d[x]=图中两段红线的距离之和,由于在初始时d[x]=x到pre[x]的距离,所以我们只需要让d[x]加上d[pre[x]]即可
- 最后让x指向root并返回根节点
1.正常加法:
1#include <cstring>
#include <iostream>
#include <algorithm>
#include <unordered_map>
using namespace std;
const int N = 20010;
int n, m;
int p[N], d[N];
unordered_map<int, int> S;
int get(int x)
{
if (S.count(x) == 0) S[x] = ++ n;
return S[x];
}
int find(int x)
{
if (p[x] != x)
{
int root = find(p[x]);
d[x] += d[p[x]];
p[x] = root;
}
return p[x];
}
int main()
{
cin >> n >> m;
n = 0;
for (int i = 0; i < N; i ++ ) p[i] = i;
int res = m;
for (int i = 1; i <= m; i ++ )
{
int a, b;
string type;
cin >> a >> b >> type;
a = get(a - 1), b = get(b);
int t = 0;
if (type == "odd") t = 1;
int pa = find(a), pb = find(b);
if (pa == pb)
{
if (((d[a] + d[b]) % 2 + 2) % 2 != t)
{
res = i - 1;
break;
}
}
else
{
p[pa] = pb;
d[pa] = d[a] ^ d[b] ^ t;
}
}
cout << res << endl;
return 0;
}
2.非0即1的异或运算
#include <iostream>
#include <cstring>
#include <algorithm>
#include <unordered_map>
using namespace std;
const int N = 100010;
string str;
int idx, m, pre[N], d[N];
unordered_map<int, int> Hash;
int get(int x)//Hash
{
if(!Hash.count(x)) Hash[x] = ++ idx;
return Hash[x];
}
int find(int x)
{
if(pre[x] == x) return x;
int root = find(pre[x]);//先找到根节点
d[x] ^= d[pre[x]];
pre[x] = root;
return pre[x];
}
void init()
{
for(int i = 0; i < N; i ++ ) pre[i] = i;
}
int main()
{
cin >> str >> m;
init();
int res = m;
for(int i = 1; i <= m; i ++ )
{
int a, b;
string op;
cin >> a >> b >> op;
a = get(a - 1), b = get(b);
int t = 0;//t表示如果没有矛盾并且a和b之前已经合并过,a和b非0即1的关系
if(op == "odd") t = 1;
int pa = find(a), pb = find(b);
if(pa == pb)//之前已经合并过
{
if((d[a] ^ d[b]) != t)//不合法
{
res = i - 1;
break;
}
}
else//第一次出现
{
pre[pa] = pb;//连过去一条边
d[pa] = d[a] ^ d[b] ^ t;//边的权值
}
}
cout << res << endl;
return 0;
}