
题目描述
给定一个包含 n 个顶点的无向图(双向图),顶点编号从 0 到 n-1,边由二维数组 edges 表示(edges[i] = [ui, vi] 表示 ui 和 vi 之间有一条双向边)。要求判断是否存在从 source 顶点到 destination 顶点的有效路径。
核心问题
本质是无向图的连通性判断问题:判断两个顶点是否处于同一个连通分量中。
输入输出约束
1 <= n <= 2 * 10^5(顶点数量较大,要求算法时间复杂度不能过高)0 <= edges.length <= 2 * 10^5edges[i].length == 20 <= ui, vi <= n - 1ui != vi(无自环)0 <= source, destination <= n - 1- 边是唯一的(无重边)
算法分析
针对无向图连通性问题,有两种经典解法:
解法 1:并查集(Union-Find/Disjoint Set Union, DSU)
核心思想
- 初始化:每个顶点自成一个集合(父节点指向自己)
- 合并:遍历所有边,将每条边的两个顶点所在集合合并
- 查询:判断
source和destination是否属于同一个集合
优势
- 时间复杂度接近线性(带路径压缩和按秩合并优化)
- 空间复杂度低,适合处理大规模数据(本题顶点数可达 2e5)
(1)路径压缩(Find 操作优化)
- 问题:如果不优化,查找路径可能退化成链表(如 0→1→2→3→4),查找效率低
- 优化:查找时将节点直接指向根节点,后续查找只需一步
- 示例:查找 0 的根节点时,将 0、1、2 都直接指向根节点 4
(2)按秩合并(Union 操作优化)
- 问题:如果随意合并,树可能变得很高,查找效率低
- 优化:记录每个根节点的「秩」(树的高度),将秩小的树合并到秩大的树下
- 示例:秩为 2 的树合并到秩为 3 的树下,避免树高增加
3. 并查集操作流程
- 初始化:每个节点的父节点是自己,秩为 1
- 合并:遍历所有边,将每条边的两个节点合并
- 查询:判断起点和终点的根节点是否相同(相同则连通)
objectivec
// 并查集数组:parent[i]表示顶点i的父节点
int* parent;
// 秩数组:rank[i]表示以i为根的集合的高度(用于按秩合并)
int* rank;
objectivec
#include <stdio.h>
#include <stdlib.h>
// 初始化并查集
void initUnionFind(int n, int** parent, int** rank) {
*parent = (int*)malloc(sizeof(int) * n);
*rank = (int*)malloc(sizeof(int) * n);
for (int i = 0; i < n; i++) {
(*parent)[i] = i; // 初始父节点为自身
(*rank)[i] = 1; // 初始秩为1
}
}
// 查找根节点(带路径压缩)
int find(int* parent, int x) {
if (parent[x] != x) {
parent[x] = find(parent, parent[x]); // 路径压缩:直接指向根节点
}
return parent[x];
}
// 合并两个集合(按秩合并)
void unionSets(int* parent, int* rank, int x, int y) {
int rootX = find(parent, x);
int rootY = find(parent, y);
if (rootX == rootY) return; // 已在同一集合
// 按秩合并:将秩小的树合并到秩大的树下
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
parent[rootY] = rootX;
rank[rootX]++; // 秩相等时,合并后秩+1
}
}
// 主函数:判断是否存在有效路径
bool validPath(int n, int** edges, int edgesSize, int* edgesColSize, int source, int destination) {
// 边界条件:起点=终点直接返回true
if (source == destination) return true;
int* parent = NULL;
int* rank = NULL;
initUnionFind(n, &parent, &rank);
// 合并所有边的两个顶点
for (int i = 0; i < edgesSize; i++) {
int u = edges[i][0];
int v = edges[i][1];
unionSets(parent, u, v);
}
// 判断起点和终点是否在同一集合
bool result = (find(parent, source) == find(parent, destination));
// 释放内存
free(parent);
free(rank);
return result;
}
int main() {
// 示例1:存在路径
int n1 = 3;
int edges1[][2] = {{0,1}, {1,2}, {2,0}};
int edgesSize1 = 3;
int edgesColSize1[] = {2,2,2};
int source1 = 0, dest1 = 2;
bool res1 = validPath(n1, (int**)edges1, edgesSize1, edgesColSize1, source1, dest1);
printf("示例1结果:%s\n", res1 ? "true" : "false"); // 输出true
// 示例2:不存在路径
int n2 = 6;
int edges2[][2] = {{0,1}, {0,2}, {3,5}, {5,4}, {4,3}};
int edgesSize2 = 5;
int edgesColSize2[] = {2,2,2,2,2};
int source2 = 0, dest2 = 5;
bool res2 = validPath(n2, (int**)edges2, edgesSize2, edgesColSize2, source2, dest2);
printf("示例2结果:%s\n", res2 ? "true" : "false"); // 输出false
return 0;
}
解法 2:深度优先搜索(DFS)/ 广度优先搜索(BFS)
核心思想
- 构建邻接表表示图
- 从
source出发,通过 DFS/BFS 遍历可达顶点 - 检查
destination是否被遍历到
局限性
- 对于大规模图(2e5 顶点),递归 DFS 可能栈溢出,迭代 DFS/BFS 需要额外的访问标记数组
- 时间复杂度虽为线性,但常数项高于并查集
BFS 核心思路
- 构建邻接表:存储每个节点的相邻节点(无向图需双向添加)
- 初始化队列:将起点入队,并标记为已访问
- 遍历队列:取出队首节点,遍历其所有相邻节点
- 若相邻节点是终点,返回 true
- 若未访问过,标记为已访问并入队
- 队列为空仍未找到终点,返回 false
objectivec
// 邻接表:adj[i]存储与顶点i相连的所有顶点
typedef struct {
int* nodes; // 顶点数组
int size; // 当前元素个数
int capacity; // 容量
} AdjListNode;
AdjListNode* adj;
objectivec
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
// 邻接表节点结构
typedef struct {
int* nodes;
int size;
int capacity;
} AdjListNode;
// 队列结构(BFS用)
typedef struct {
int* data;
int front;
int rear;
int capacity;
} Queue;
// 初始化队列
Queue* initQueue(int capacity) {
Queue* q = (Queue*)malloc(sizeof(Queue));
q->data = (int*)malloc(sizeof(int) * capacity);
q->front = 0;
q->rear = 0;
q->capacity = capacity;
return q;
}
// 入队
void enqueue(Queue* q, int val) {
q->data[q->rear++] = val;
}
// 出队
int dequeue(Queue* q) {
return q->data[q->front++];
}
// 判断队列是否为空
bool isEmpty(Queue* q) {
return q->front == q->rear;
}
// 释放队列
void freeQueue(Queue* q) {
free(q->data);
free(q);
}
// 初始化邻接表
AdjListNode* initAdjList(int n) {
AdjListNode* adj = (AdjListNode*)malloc(sizeof(AdjListNode) * n);
for (int i = 0; i < n; i++) {
adj[i].nodes = (int*)malloc(sizeof(int) * 4); // 初始容量4
adj[i].size = 0;
adj[i].capacity = 4;
}
return adj;
}
// 添加边到邻接表
void addEdge(AdjListNode* adj, int u, int v) {
// 扩容检查
if (adj[u].size >= adj[u].capacity) {
adj[u].capacity *= 2;
adj[u].nodes = (int*)realloc(adj[u].nodes, sizeof(int) * adj[u].capacity);
}
adj[u].nodes[adj[u].size++] = v;
// 无向图:双向添加
if (adj[v].size >= adj[v].capacity) {
adj[v].capacity *= 2;
adj[v].nodes = (int*)realloc(adj[v].nodes, sizeof(int) * adj[v].capacity);
}
adj[v].nodes[adj[v].size++] = u;
}
// BFS判断路径
bool validPathBFS(int n, int** edges, int edgesSize, int* edgesColSize, int source, int destination) {
if (source == destination) return true;
// 初始化邻接表
AdjListNode* adj = initAdjList(n);
for (int i = 0; i < edgesSize; i++) {
addEdge(adj, edges[i][0], edges[i][1]);
}
// 访问标记数组
bool* visited = (bool*)calloc(n, sizeof(bool));
Queue* q = initQueue(n);
// 起点入队
enqueue(q, source);
visited[source] = true;
// BFS遍历
while (!isEmpty(q)) {
int curr = dequeue(q);
// 遍历当前节点的所有邻接节点
for (int i = 0; i < adj[curr].size; i++) {
int next = adj[curr].nodes[i];
if (next == destination) {
// 释放资源
free(visited);
freeQueue(q);
for (int j = 0; j < n; j++) {
free(adj[j].nodes);
}
free(adj);
return true;
}
if (!visited[next]) {
visited[next] = true;
enqueue(q, next);
}
}
}
// 释放资源
free(visited);
freeQueue(q);
for (int j = 0; j < n; j++) {
free(adj[j].nodes);
}
free(adj);
return false;
}
int main() {
int n1 = 3;
int edges1[][2] = {{0,1}, {1,2}, {2,0}};
int edgesSize1 = 3;
int edgesColSize1[] = {2,2,2};
printf("BFS示例1:%s\n", validPathBFS(n1, (int**)edges1, edgesSize1, edgesColSize1, 0, 2) ? "true" : "false");
int n2 = 6;
int edges2[][2] = {{0,1}, {0,2}, {3,5}, {5,4}, {4,3}};
int edgesSize2 = 5;
int edgesColSize2[] = {2,2,2,2,2};
printf("BFS示例2:%s\n", validPathBFS(n2, (int**)edges2, edgesSize2, edgesColSize2, 0, 5) ? "true" : "false");
return 0;
}
复杂度分析
并查集解法
时间复杂度
- 初始化:O (n)
- 查找操作(带路径压缩):均摊 O (α(n)),α 是阿克曼函数的反函数,增长极慢(对于 n≤2^65536,α(n)≤5)
- 合并操作:O (α(n))
- 整体时间复杂度:O (n + m・α(n)),其中 m 是边数(edgesSize)对于本题数据规模(n,m≤2e5),实际效率接近 O (n + m)
空间复杂度
- O (n):主要为
parent和rank数组的空间开销
BFS 解法
时间复杂度
- 构建邻接表:O (n + m)
- BFS 遍历:O (n + m)(每个顶点和边仅访问一次)
- 整体时间复杂度:O (n + m)
空间复杂度
- O (n + m):邻接表存储所有边(O (m)),队列和访问标记数组(O (n))