当前位置: 首页 > news >正文

并查集算法的一个实战应用详解

对于并查集这个算法一直一知半解,似懂非懂……
今天正好碰到一个相关的题目,就拿出来研究一下,顺带再深入理解一下并查集这个算法

题目描述

题目分析

已知条件1:小苯来到一座山脉,山脉中有n座山,每座山有一个高度,山与山之间有路径相连……

从这个条件我们大概可以知道这道题是要考察图论的算法,这里的每座山可以抽象成一个节点,山与山的路径可以抽象成节点的边

已知条件2:a号山和b号山之间如果有路径的话,可以从a走到b,也可以从b走到a……

从这个条件来看,它的意思是想告诉我们节点间的边没有方向,这是个无向图

已知条件3:如果两座山之间的高度差大于k,那么就不会走这条路,然后他提出了q次查询,如果高度差大于k的路他不走,能够找到一条路从a山到b山呢?

即如果两个节点的差值大于k,那么就把这条边删除,这样删完之后,从节点a到节点b是否还有连通的路径,是则返回YES,不是则返回NO。

这样把题目条件拆解开,突然发现题目就一目了然了!把题目分析清楚,再找对应的解题方法就简单多了!

这就是图论中一个经典的离线查询问题!

关于图论中的离线查询问题

在图论中,离线查询问题指的是:所有查询在处理开始前就已全部给出,我们可以先对查询和图的信息进行预处理(如排序、重组织等),再批量处理所有查询,从而获得更高的效率

与之相对的是在线查询问题:每次查询单独给出,必须立即处理并返回结果,无法利用后续查询的信息。

离线查询的核心优势

通过 “提前知晓所有查询” 的特性,可以对查询和图数据进行全局优化,常见策略包括:

  • 排序:将查询和图的边 / 节点按特定规则排序(如按权重、阈值等),使处理过程更高效(如本文代码中按k排序查询,按diff排序边)。
  • 批量处理:一次性处理多个查询,避免重复计算(如用并查集动态维护连通性,一次性添加符合条件的边)。
  • 复杂数据结构优化:结合线段树、树状数组等,通过预处理降低单查询的时间复杂度。

图论中常见的离线查询问题

  • 连通性查询:如本题目,判断 “当边的权重≤k 时,两个节点是否连通”。
  • 路径查询:如 “求多个查询中,两点间路径的最大边权最小值”。
  • 最短路径查询:在静态图中处理多个最短路径查询(如 Floyd 算法本质是离线处理所有点对查询)。
  • 计数问题:如 “统计多个查询中,满足特定条件的路径 / 节点数量”。

核心思路

采用离线处理策略:将所有查询和边按特定规则排序,然后用并查集(Union-Find) 动态添加符合条件的边,高效判断节点连通性。具体来说:

  • 边按 “高度差绝对值” 从小到大排序;
  • 查询按 “阈值 k” 从小到大排序;
  • 按 k 从小到大处理查询,每次先添加所有高度差≤当前 k 的边,再判断两个节点是否连通。

代码实现

1、定义节点和边的结构体
struct Edge {int u, v, diff;  // u、v:边的两个端点;diff:两点高度差的绝对值Edge(int u_, int v_, int diff_) : u(u_), v(v_), diff(diff_) {}bool operator<(const Edge& other) const {  // 进行小于号的运算符重载return diff < other.diff;  // 按diff升序排序}
};struct Query {int a, b, k, idx;  // a、b:查询的两个节点;k:阈值;idx:查询原始索引(用于结果排序)Query(int a_, int b_, int k_, int idx_) : a(a_), b(b_), k(k_), idx(idx_) {}bool operator<(const Query& other) const {  // 进行小于号的运算符重载return k < other.k;  // 按k升序排序}
};
  • Edge:存储边的信息,重载<用于按高度差排序。
  • Query:存储查询的信息,重载<用于按阈值 k 排序,idx用于最终结果按原始查询顺序输出。
2、并查集(Union-Find)实现
vector<int> parent;  // 存储每个节点的父节点int find(int x) {  // 查找x的根节点(带路径压缩优化)if (parent[x] != x) { // 如果x的父节点不是自己,那么说明x不是根节点,就继续往上查找parent[x] = find(parent[x]);  // 路径压缩:直接指向根节点}return parent[x];
}
void unite(int x, int y) {  // 合并x和y所在的集合x = find(x);  // 找到x的根节点y = find(y);  // 找到y的根节点if (x != y) {  // 若不在同一集合,合并(将y的根指向x的根)parent[y] = x;}
}

并查集是一种高效处理 “动态连通性” 问题的数据结构,支持两种核心操作:

  • find(x):查找 x 所在集合的根节点(路径压缩优化使查询接近 O (1))。
  • unite(x, y):将 x 和 y 所在的集合合并。
3、main函数逻辑
int main() {ios::sync_with_stdio(false);  // 关闭输入输出同步,加速cin/coutcin.tie(nullptr);int n, m, q;cin >> n >> m >> q;  // n:节点数;m:边数;q:查询数// 读取每个节点的高度(1-based索引,节点编号1~n)vector<int> h(n + 1);for (int i = 1; i <= n; ++i) {cin >> h[i];}// 读取所有边,计算高度差绝对值,存入edgesvector<Edge> edges;for (int i = 0; i < m; ++i) {int u, v;cin >> u >> v;int diff = abs(h[u] - h[v]);  // 边的权重:高度差绝对值edges.emplace_back(u, v, diff);}// 读取所有查询,存入queries(记录原始索引idx)vector<Query> queries;for (int i = 0; i < q; ++i) {int a, b, k;cin >> a >> b >> k;queries.emplace_back(a, b, k, i);  // idx=i表示第i个查询}// 关键:边按diff升序排序,查询按k升序排序sort(edges.begin(), edges.end());sort(queries.begin(), queries.end());// 初始化并查集:每个节点的父节点是自己parent.resize(n + 1);for (int i = 1; i <= n; ++i) {parent[i] = i;}vector<string> res(q);  // 存储查询结果(按原始索引顺序)int edge_idx = 0;  // 记录已添加到并查集的边的索引// 按k从小到大处理每个查询for (const auto& query : queries) {int a = query.a, b = query.b, k = query.k, idx = query.idx;// 第一步:添加所有diff ≤ 当前查询k的边(因为边已按diff排序)while (edge_idx < m && edges[edge_idx].diff <= k) {unite(edges[edge_idx].u, edges[edge_idx].v);  // 合并边的两个端点edge_idx++;  // 移动到下一条边}// 第二步:判断a和b是否连通(根节点是否相同)if (find(a) == find(b)) {res[idx] = "YES";  // 连通} else {res[idx] = "NO";   // 不连通}}// 按原始查询顺序输出结果for (const string& ans : res) {cout << ans << '\n';}return 0;
}

核心逻辑梳理

  • 输入读取:节点高度、边信息(计算高度差)、查询信息(记录原始索引)。
  • 排序:边按高度差升序,查询按 k 升序(确保处理查询时,边是从小到大依次添加的)。

离线处理:

  • 用edge_idx跟踪已添加的边,对于每个查询(按 k 从小到大),先添加所有高度差≤k 的边。
  • 边添加完成后,用并查集判断 a 和 b 是否连通,结果存入res的对应索引(保证原始顺序)。
  • 输出结果:按原始查询顺序输出所有结果。

时间复杂度

  • 排序边:O (m log m)
  • 排序查询:O (q log q)
  • 处理查询和添加边:每个边被添加一次,每个查询的find操作接近 O (1)(并查集路径压缩优化),总复杂度 O (m + q α(n))(α 是阿克曼函数,增长极慢,可视为常数)。
整体复杂度为 O (m log m + q log q),适合处理大规模数据。

关于这道题目中,对边按照差值diff从小到大排序,对查询按照k值从小到大排序的重要性

在这个问题中,边按差值(diff)排序、查询按k排序是核心优化手段,目的是配合并查集的特性实现高效处理。如果不排序,要么逻辑上无法正确执行,要么时间复杂度会大幅上升,失去算法的优势。

为什么必须排序?

我们先回顾核心逻辑:对于每个查询(a, b, k),需要判断 “只保留高度差≤k的边时,a和b是否连通”。
要高效解决这个问题,关键依赖并查集的 “增量合并” 特性:并查集擅长 “合并集合”,但不擅长 “拆分集合”(一旦两个节点被合并,很难再分开)。

边按diff排序 + 查询按k排序的必要性
  • 查询按k从小到大排序:
    当我们按k从小到大处理查询时,每次处理的k是递增的。这意味着:之前加入并查集的边(diff ≤ 前一个k),一定满足当前k’ ≥ 前一个k,因此这些边仍然有效,不需要移除。
    例如:处理k=2的查询时,之前为k=1加入的边(diff ≤1)仍然满足diff ≤2,可以继续保留在并查集中。
  • 边按diff从小到大排序:
    配合查询的排序,我们可以用一个指针(如代码中的edge_idx)依次扫描边。每次处理查询时,只需添加所有diff ≤ 当前k且尚未加入的边(因为边已按diff排序,指针只会向前移动,不会回头)。这样每个边只会被添加一次,总耗时O(m)。
不排序会导致什么问题?

如果不排序,有两种可能的处理方式,但都会存在严重问题:

  • 不排序查询,仍按原逻辑处理
    假设查询顺序是随机的(例如先处理k=5,再处理k=2):
    处理k=5时,会加入所有diff ≤5的边,合并大量集合。
    处理k=2时,需要 “只保留diff ≤2的边”,但并查集无法拆分之前合并的集合(例如diff=3的边在k=5时被加入,现在需要移除,但并查集做不到)。
    此时判断的连通性会包含diff>2的边的影响,结果完全错误。
  • 不排序边,按查询顺序处理
    即使查询按k排序,但边不排序:
    每次处理查询时,需要遍历所有边,检查是否diff ≤ 当前k且未加入过。
    例如:处理k=3时,需要遍历所有边,筛选出diff ≤3的边(无论之前是否处理过),这会导致重复检查,时间复杂度从O(m + q)变成O(qm)(q次查询,每次遍历m条边)。
    当m和q达到1e5时,O(q
    m)会达到1e10,完全无法运行。

总结

排序的核心目的是:

  • 利用查询k的递增性,避免并查集的 “拆分” 操作(并查集不支持拆分,只能增量合并);
  • 利用边diff的有序性,通过一次线性扫描完成所有边的添加,避免重复检查。

不排序的话,要么逻辑上无法正确维护连通性(因并查集无法拆分),要么时间复杂度爆炸,失去算法的实用价值。因此,排序是这个离线查询问题的 “灵魂优化”。

完整源码实现

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;struct Edge {int u, v, diff;Edge(int u_, int v_, int diff_) : u(u_), v(v_), diff(diff_) {}bool operator<(const Edge& other) const {return diff < other.diff;}
};struct Query {int a, b, k, idx;Query(int a_, int b_, int k_, int idx_) : a(a_), b(b_), k(k_), idx(idx_) {}bool operator<(const Query& other) const {return k < other.k;}
};vector<int> parent;int find(int x) {if (parent[x] != x) {parent[x] = find(parent[x]);}return parent[x];
}void unite(int x, int y) {x = find(x);y = find(y);if (x != y) {parent[y] = x;}
}int main() {ios::sync_with_stdio(false);cin.tie(nullptr);int n, m, q;cin >> n >> m >> q;vector<int> h(n + 1);for (int i = 1; i <= n; ++i) {cin >> h[i];}vector<Edge> edges;for (int i = 0; i < m; ++i) {int u, v;cin >> u >> v;int diff = abs(h[u] - h[v]);edges.emplace_back(u, v, diff);}vector<Query> queries;for (int i = 0; i < q; ++i) {int a, b, k;cin >> a >> b >> k;queries.emplace_back(a, b, k, i);}sort(edges.begin(), edges.end());sort(queries.begin(), queries.end());parent.resize(n + 1);for (int i = 1; i <= n; ++i) {parent[i] = i;}vector<string> res(q);int edge_idx = 0;for (const auto& query : queries) {int a = query.a, b = query.b, k = query.k, idx = query.idx;while (edge_idx < m && edges[edge_idx].diff <= k) {unite(edges[edge_idx].u, edges[edge_idx].v);edge_idx++;}if (find(a) == find(b)) {res[idx] = "YES";} else {res[idx] = "NO";}}for (const string& ans : res) {cout << ans << '\n';}return 0;
}
http://www.dtcms.com/a/320681.html

相关文章:

  • 基于Flask + Vue3 的新闻数据分析平台源代码+数据库+使用说明,爬取今日头条新闻数据,采集与清洗、数据分析、建立数据模型、数据可视化
  • 认识爬虫 —— 正则表达式提取
  • MySQL数据库操作练习
  • 基于大数据的地铁客流数据分析预测系统 Python+Django+Vue.js
  • css 瀑布流布局
  • 查看泰山派 ov5695研究(1)
  • 线程池基础知识
  • gmssl私钥文件格式
  • Arm Qt编译Qt例程出错 GLES3/gl3.h: No such file or directory
  • 【前端后端部署】将前后端项目部署到云服务器
  • 终端是什么,怎么用?
  • 基于Spring Boot的Minio图片定时清理实践总结
  • Mac下安装Conda虚拟环境管理器
  • Vue3 计算属性与监听器
  • 基于django电子产品销售系统的设计与实现/基于python的在线购物商城系统
  • 豆包新模型矩阵+PromptPilot:AI开发效率革命的终极方案
  • 3 种简单方法备份 iPhone 上的短信 [2025]
  • 僵尸进程、孤儿进程、进程优先级、/proc 文件系统、CRC 与网络溢出问题处理(实战 + 原理)
  • 从安卓兼容性困境到腾讯Bugly的救赎:全链路崩溃监控解决方案-卓伊凡|bigniu
  • 【前端】纯代码实现Power BI自动化
  • 【Linux系统】万字解析,文件IO
  • 代码随想录刷题Day26
  • 最长回文子串
  • Redis(④-消息队列削峰)
  • 使用OAK相机实现智能物料检测与ABB机械臂抓取
  • 《Hive、HBase、StarRocks、MySQL、OceanBase 全面对比:架构、优缺点与使用场景详解》
  • Numpy科学计算与数据分析:Numpy数据分析与图像处理入门
  • [激光原理与应用-182]:测量仪器 - 光束型 - 光束质量分析仪
  • 无人机航拍数据集|第9期 无人机风力电机表面损伤目标检测YOLO数据集2995张yolov11/yolov8/yolov5可训练
  • WORD接受修订,并修改修订后文字的颜色