【C++】 高阶数据结构图(1)并查集

数据结构:图------邻接矩阵与邻接表深度剖析


一、图的基础概念

Graph是多对多关系的数据结构,由顶点集合V、边集合E组成,记为 G=(V,E)。

  • 顶点(Vertex):图中的节点
  • 边(Edge):顶点之间的关系。
    • 无向图:边没有方向,( v1,v2 )等价于( v2,v1 )
    • 有向图:边有方向,< v1,v2 >代表从 v1 指向 v2 ,和反向不等价
  • 权值:边上可以附带数值,此时叫带权图(网)
  • 度:
    • 无向图:顶点的度 = 相连边的数量
    • 有向图:入度(指向该点)、出度(该点指出)

树是图的特例:树是连通无环无向图;图可以存在环、可以不连通。

图最大难点: 没有天然线性顺序,必须专门设计存储结构保存顶点与边的关联关系。

主流两种方案:邻接矩阵、邻接表。


二、邻接矩阵(Adjacency Matrix)

1. 原理

使用二维数组A[n][n]存储边信息,n为顶点总数。

无向图:

复制代码
A[i][j] = 1  顶点i,j之间有边
A[i][j] = 0  没有边

无向图邻接矩阵是对称矩阵,A[i][j] = A[j][i]

有向图:A[i][j]=1代表存在一条从i指向j的边,矩阵不一定对称。

带权图:把1替换成边的权值,无边用无穷大INF表示,A[i][i]=0

2. 内存布局

n个顶点,开辟 n × n 的二维数组。

空间复杂度:O(n²),只和顶点数相关,和实际边数无关。

举例子:4个顶点无向图,矩阵是4×4。哪怕整张图只有2条边,依然要完整存储16个单元。

3. 核心操作时间复杂度

操作 复杂度 说明
判断i,j两点是否连通 O(1) 直接访问数组下标,极速
添加边 O(1) 直接赋值矩阵元素
删除边 O(1) 直接赋值清零
求某个顶点的所有邻接点 O(n) 需要遍历一整行n个元素

✅优点

  1. 边的查询、增删边是常数时间,判断两点是否相连极快
  2. 代码实现简单,数组操作,没有链表指针开销
  3. 适合做矩阵运算(图论的矩阵幂、可达性计算)

❌缺点

  1. 空间浪费巨大。稀疏图(顶点多、边很少),大量单元都是0。例如10000个顶点,矩阵需要1亿个存储单元,直接内存爆炸。
  2. 遍历某个点所有邻居,必须遍历整行,效率低下。

C++ 模板邻接矩阵图类

cpp 复制代码
#include <iostream>
#include <vector>
#include <map>
#include <stdexcept>
#include <climits>

template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
class Graph
{
public:
    using Self = Graph<V, W, MAX_W, Direction>;

    Graph() = default;

    Graph(const V* vertexs, size_t n)
    {
        _vertexs.reserve(n);
        for (size_t i = 0; i < n; ++i)
        {
            _vertexs.push_back(vertexs[i]);
            _vIndexMap[vertexs[i]] = i;
        }
        // MAX_W 作为不存在边的标识值
        _matrix.resize(n);
        for (auto& e : _matrix)
        {
            e.resize(n, MAX_W);
        }
    }

    size_t GetVertexIndex(const V& v)
    {
        auto ret = _vIndexMap.find(v);
        if (ret != _vIndexMap.end())
        {
            return ret->second;
        }
        else
        {
            throw std::invalid_argument("不存在的顶点");
        }
    }

    void _AddEdge(size_t srci, size_t dsti, const W& w)
    {
        _matrix[srci][dsti] = w;
        // 无向图双向赋值
        if (Direction == false)
        {
            _matrix[dsti][srci] = w;
        }
    }

    void AddEdge(const V& src, const V& dst, const W& w)
    {
        size_t srci = GetVertexIndex(src);
        size_t dsti = GetVertexIndex(dst);
        _AddEdge(srci, dsti, w);
    }

    void Print()
    {
        // 打印顶点和下标映射关系
        for (size_t i = 0; i < _vertexs.size(); ++i)
        {
            std::cout << _vertexs[i] << "-" << i << " ";
        }
        std::cout << "\n\n";

        std::cout << " ";
        for (size_t i = 0; i < _vertexs.size(); ++i)
        {
            std::cout << i << " ";
        }
        std::cout << "\n";

        // 打印邻接矩阵
        for (size_t i = 0; i < _matrix.size(); ++i)
        {
            std::cout << i << " ";
            for (size_t j = 0; j < _matrix[i].size(); ++j)
            {
                if (_matrix[i][j] != MAX_W)
                    std::cout << _matrix[i][j] << " ";
                else
                    std::cout << "#" << " ";
            }
            std::cout << "\n";
        }
        std::cout << "\n\n";

        // 打印所有边(无向图 i<j 去重输出)
        for (size_t i = 0; i < _matrix.size(); ++i)
        {
            for (size_t j = 0; j < _matrix[i].size(); ++j)
            {
                if (i < j && _matrix[i][j] != MAX_W)
                {
                    std::cout << _vertexs[i] << "-" << _vertexs[j] << ":" << _matrix[i][j] << "\n";
                }
            }
        }
    }

private:
    std::map<V, size_t> _vIndexMap;       // 顶点 -> 数组下标映射
    std::vector<V> _vertexs;               // 顶点集合
    std::vector<std::vector<W>> _matrix;   // 邻接矩阵
};

三、邻接表(Adjacency List)

1. 原理

不使用完整二维数组。

  • 开辟数组,数组下标代表顶点;数组每个位置存放一个链表/vector,保存该顶点所有直接相连的邻接顶点。

无向图:增加边u‑v,需要同时把v加入u的链表,把u加入v的链表。

有向图:u→v,只把v加入u的链表。

带权图:链表存储结构体,保存邻接点编号+权值。

2. 内存布局

总存储单元 = 顶点数 + 边数×2(无向图每条边存2次)。

空间复杂度:O(n+e),n顶点数,e边数。只存储真实存在的边。

稀疏图优势巨大:比如10000顶点,只有几千条边,只需要存储几千条记录,不需要n²矩阵。

3. 核心操作时间复杂度

操作 复杂度 说明
添加边 O(1) 链表尾部插入
遍历顶点i所有邻接点 O(deg(i)) 只遍历真实邻居,deg(i)是该点度数
删除边 O(deg(i)) 需要遍历链表找到目标节点再删除
判断i,j之间是否存在边 O(deg(i)) 遍历i的邻接链表挨个比对

✅优点

  1. 稀疏图极度省内存,绝大多数现实场景图都是稀疏图(社交网络、路网)
  2. 遍历一个点的所有邻居效率很高,只访问真实相连节点。BFS、DFS遍历图几乎都优先用邻接表。

❌缺点

  1. 判断两个点之间有没有边,最坏要遍历链表,时间开销高。
  2. 结构是链表/vector,内存不连续;稠密图下,链表额外开销会大于邻接矩阵。

C++ vector版邻接表(工程最常用)

cpp 复制代码
#include <vector>
#include <map>
#include <stdexcept>

namespace LinkTable
{
    // 边结点结构体
    template<class W>
    struct LinkEdge
    {
        int _srcIndex;
        int _dstIndex;
        W _w;
        LinkEdge<W>* _next;

        LinkEdge(const W& w)
            : _srcIndex(-1)
            , _dstIndex(-1)
            , _w(w)
            , _next(nullptr)
        {}
    };

    // V:顶点类型;W:权值类型;Direction=true代表有向图,false无向图
    template<class V, class W, bool Direction = false>
    class Graph
    {
        typedef LinkEdge<W> Edge;
    public:
        // 构造函数:传入顶点数组,初始化顶点集合与邻接表头
        Graph(const V* vertexs, size_t n)
        {
            _vertexs.reserve(n);
            for (size_t i = 0; i < n; ++i)
            {
                _vertexs.push_back(vertexs[i]);
                _vIndexMap[vertexs[i]] = i;
            }
            _linkTable.resize(n, nullptr);
        }

        // 根据顶点值获取下标
        size_t GetVertexIndex(const V& v)
        {
            auto ret = _vIndexMap.find(v);
            if (ret != _vIndexMap.end())
            {
                return ret->second;
            }
            else
            {
                throw std::invalid_argument("不存在的顶点");
            }
        }

        // 添加边 src -> dst,权值w
        void AddEdge(const V& src, const V& dst, const W& w)
        {
            size_t srcindex = GetVertexIndex(src);
            size_t dstindex = GetVertexIndex(dst);

            // 创建 src -> dst 的边头插法插入邻接表
            Edge* sd_edge = new Edge(w);
            sd_edge->_srcIndex = static_cast<int>(srcindex);
            sd_edge->_dstIndex = static_cast<int>(dstindex);
            sd_edge->_next = _linkTable[srcindex];
            _linkTable[srcindex] = sd_edge;

            // 如果是无向图,反向再加一条 dst -> src
            if (Direction == false)
            {
                Edge* ds_edge = new Edge(w);
                ds_edge->_srcIndex = static_cast<int>(dstindex);
                ds_edge->_dstIndex = static_cast<int>(srcindex);
                ds_edge->_next = _linkTable[dstindex];
                _linkTable[dstindex] = ds_edge;
            }
        }

    private:
        // 修复:模板V,不能写死map<string,int>
        std::map<V, int> _vIndexMap;      // 顶点 -> 下标映射
        std::vector<V> _vertexs;          // 顶点集合
        std::vector<Edge*> _linkTable;    // 邻接表,每个元素是链表头指针
    };
}

适用场景:稀疏图,绝大多数算法题、现实工程场景。

BFS、DFS、Dijkstra、拓扑排序全部默认使用邻接表。


四、邻接矩阵 vs 邻接表深度对比

对比维度 邻接矩阵 邻接表
空间复杂度 O(n²),只看顶点数 O(n+e),顶点+真实边数
适合图类型 稠密图(边很多) 稀疏图(边很少,绝大多数场景)
查询两点是否相连 O(1) O(deg(u))
遍历某顶点全部邻居 O(n) O(deg(u))
增加边 O(1) O(1)
删除边 O(1) O(deg(u))
内存特征 连续数组,缓存友好 链表/vector,碎片化
典型应用 Floyd多源最短路 BFS/DFS/Dijkstra/拓扑排序

💡经典面试题:Floyd算法为什么用邻接矩阵?

Floyd是三重循环遍历所有顶点对,需要高频查询任意两点之间的边,邻接矩阵O(1)访问;如果用邻接表,遍历所有点对代价极高。

💡面试题:Dijkstra为什么大多用邻接表?

现实中图大多稀疏,邻接表节省内存,并且Dijkstra需要频繁遍历某个点的全部邻接点,邻接表只遍历真实边。


五、衍生拓展(提升博客深度)

1. 逆邻接表

针对有向图:普通邻接表存出边;逆邻接表每个顶点保存入边。快速求一个点的全部入度邻接点。

2. 十字链表(有向图优化存储)

结合邻接表+逆邻接表,一套结构同时存出边、入边,有向图专用,复杂,工程很少手写。

3. 邻接多重表(无向图优化存储)

解决无向图邻接表中一条边存两份,删除边需要删两处的问题。

4. 现实工程思考

  1. 社交网络,亿级顶点,绝对不可能邻接矩阵,只能邻接表思路;
  2. 小规模网格图,稠密,邻接矩阵简单好用;
  3. C++中邻接表不一定要用链表,vector比原生链表性能强很多,vector是连续内存,缓存命中率高,刷题全部用vector。

5. 常见坑

  1. 无向图添加边,千万记住双向添加,很多新手bug;
  2. 邻接矩阵顶点数量大时直接爆内存,n=1e4,10^4 ×10^4 =1e8个int,约400MB,n再大直接不可行;
  3. 邻接表判断两点连通不要暴力遍历,如果需要高频查询,可以额外搭配哈希集合unordered_set

六、小结

  1. 邻接矩阵:以顶点为中心,记录全部顶点两两之间的关系。牺牲空间换取两点之间O(1)查询,适合稠密图。
  2. 邻接表:以边为中心,只记录真实存在的边。空间高效,适合稀疏图,图遍历算法的主流选择。

图的存储是所有图算法的地基。BFS、DFS、最短路径、拓扑排序、最小生成树全部建立在这两种存储之上。选对存储结构,算法的时间复杂度才有意义。


数据结构:并查集(Disjoint‑Set‑Union,DSU)深度讲解

1. 什么是并查集

并查集,也叫不相交集合。 我们有一堆元素,元素会划分成若干互不相交的集合。支持两大核心操作:

  1. Find(x):查找x属于哪个集合(找根节点);判断两个元素是否在同一个集合,等价于Find(x) == Find(y)
  2. Union(x,y):把x所在集合 和 y所在集合 合并成一个集合。

典型场景举例:

  • 判断图中两个点是否连通;
  • Kruskal算法选边构建最小生成树,判断两个顶点是否已经连通,避免成环;
  • 社交网络,判断两个人是否属于同一个朋友圈;
  • 检测无向图添加一条边之后会不会产生环:如果Find(u)==Find(v),加边就会成环。

并查集底层用数组实现,没有树的父子那种严格层级,是逻辑上的森林。


2. 基础版本实现

数组parent[]parent[x]代表元素x的父节点。

  • 如果parent[x] = x,说明x就是这个集合的根。

Find:查找根

递归版本:

复制代码
int Find(int x)
{
    if(parent[x] != x)
    {
        return Find(parent[x]);
    }
    return x;
}

Union:集合合并

简单粗暴:直接把其中一棵树的根,挂到另一棵树的根下面。

复制代码
void Union(int x,int y)
{
    int rx = Find(x);
    int ry = Find(y);
    if(rx != ry)
    {
        parent[ry] = rx;
    }
}

初始化:每个元素自己是一个集合,parent[i]=i

复制代码
//初始化 n个元素
for(int i=0;i<n;i++)
    parent[i]=i;

❗基础版本致命问题: 合并的时候随便挂接,树有可能退化成链表。

比如:1→2→3→4→...→n,此时Find查找时间复杂度退化(O(n)),性能很差。


3. 两大优化(并查集灵魂)

优化1:路径压缩(Path Compression)【优化Find】

查找的时候,把路径上所有节点直接指向根节点。 下一次Find,几乎一步直达根。

路径压缩Find(递归版)

复制代码
int Find(int x)
{
    if(parent[x] != x)
    {
        parent[x] = Find(parent[x]); // 递归回溯,把沿途节点父直接改成根
    }
    return parent[x];
}

路径压缩Find(迭代版,避免栈溢出,工程推荐)

复制代码
int Find(int x)
{
    // 先找到根
    int root = x;
    while(parent[root] != root)
    {
        root = parent[root];
    }
    // 路径压缩:全部节点直接指向root
    while(parent[x] != root)
    {
        int next = parent[x];
        parent[x] = root;
        x = next;
    }
    return root;
}

优化2:按秩合并 / 按大小合并(Union优化)【优化合并逻辑】

秩(rank):可以理解为树的高度;或者数组size\[\]记录每颗树的节点数量。

思想:小树挂到大树的根下面,不让树变高。 不要大树挂小树,避免树深度暴涨。

两种实现:

  1. 按秩(高度rank)合并:rank数组记录树高度。矮树接到高树根;两棵树高度相等,新树rank+1。
  2. 按集合size大小合并:size\[\]记录集合元素总数,小集合挂到大集合根下面。

工程上按size合并写起来更简单,不容易出错。

完整带路径压缩+按size合并的DSU:

cpp 复制代码
#include <vector>
using namespace std;

class DSU
{
public:
    vector<int> parent;
    vector<int> size; // size[i]只有i是根的时候才有意义,代表集合元素个数

    DSU(int n)
    {
        parent.resize(n);
        size.resize(n,1);
        for(int i=0;i<n;i++)
        {
            parent[i]=i;
        }
    }

    //查找 + 路径压缩
    int Find(int x)
    {
        if(parent[x] != x)
        {
            parent[x] = Find(parent[x]);
        }
        return parent[x];
    }

    //合并,返回true代表真正完成合并;false代表已经在同一个集合
    bool Union(int x,int y)
    {
        int rx = Find(x);
        int ry = Find(y);
        if(rx == ry)
        {
            return false; //同一个集合,不用合并
        }
        //小集合合并到大集合下面
        if(size[rx] < size[ry])
        {
            swap(rx,ry);
        }
        parent[ry] = rx;
        size[rx] += size[ry];
        return true;
    }

    //判断x y是否连通,同一集合
    bool IsConnected(int x,int y)
    {
        return Find(x) == Find(y);
    }

    //获取x所在集合元素数量
    int GetSetSize(int x)
    {
        return size[Find(x)];
    }
};

4. 时间复杂度深度说明

同时开启路径压缩 + 按秩/大小合并: 单次Find/Union操作时间复杂度是 O(a(n))

a(n)是阿克曼函数的反函数。

a(n)增长极其缓慢,对于现实所有计算机n(n<=10^60),a(n)<=5。

可以近似理解为常数时间。

⚠️注意:不是严格O(1),是接近常数的准常数。面试一定要说\\alpha(n),不要直接说O(1)。


5. 并查集核心面试考点

考点1:Kruskal最小生成树中怎么用?

  1. 将全部边按权从小到大排序;
  2. 初始化DSU;
  3. 依次拿最小的边(u,v,w)
    • 如果Find(u) != Find(v),说明两点不在同一集合,选这条边,执行Union(u,v)
    • 如果已经连通,丢弃这条边,防止产生环。

考点2:如何检测无向图是否有环?

遍历每一条边(u,v)

  • 如果Find(u) == Find(v):u、v已经连通,再加入这条边,直接产生环。
  • 否则执行Union(u,v)。

注意:并查集只适合无向图判环,不能处理有向图!有向图判环要用拓扑排序/DFS。

考点3:路径压缩、按秩合并各自作用?

路径压缩:优化Find,压扁树,减少查找深度;

按秩/大小合并:优化Union,防止合并时树变成链表。

只开其中一个优化,达不到近乎常数的复杂度,两个优化要同时使用。

考点4:并查集缺点

  1. 适合合并、查询连通性,不支持集合的拆分(分裂集合很难做);
  2. 不适合遍历一个集合里面全部元素;
  3. 原生版本只处理无向关系,不能处理有向图。

6. 拓展:带权并查集

普通DSU只记录连通关系;带权并查集(偏移并查集),维护节点到父节点的相对权值。 经典题目:食物链、情侣关系、分数/距离约束。 多开数组dist[]dist[x]代表x到parentx的偏移量,Find查找的时候同步更新偏移。


7. 代码使用示例

复制代码
int main()
{
    DSU dsu(5); //0~4一共5个元素
    dsu.Union(0,1);
    dsu.Union(1,2);

    cout << dsu.IsConnected(0,2) << endl; //true
    cout << dsu.IsConnected(0,3) << endl; //false
    cout << dsu.GetSetSize(0) << endl;    //集合大小3
    return 0;
}

8. OJ运用

1. 省份数量

LCR 116. 省份数量https://leetcode.cn/problems/bLyHh0/

n 个城市,其中一些彼此相连,另一些没有相连。如果城市 a 与城市 b 直接相连,且城市 b 与城市 c 直接相连,那么城市 a 与城市 c 间接相连。

省份 是一组直接或间接相连的城市,组内不含其他没有相连的城市。

给你一个 n x n 的矩阵 isConnected ,其中 isConnected[i][j] = 1 表示第 i 个城市和第 j 个城市直接相连,而 isConnected[i][j] = 0 表示二者不直接相连。

返回矩阵中 省份 的数量

cpp 复制代码
class Solution {
public:
    int findCircleNum(vector<vector<int>>& isConnected) {
        vector<int> ufs(isConnected.size(),-1);
        auto findroot=[&ufs](int x){while(ufs[x]>=0)x=ufs[x];return x;};
        for(int i=0;i<isConnected.size();i++)
        {
            for(int j=0;j<isConnected[i].size();j++)
            {
                if(isConnected[i][j]==1)
                {
                    int root1=findroot(i);
                    int root2=findroot(j);
                    if(root1!=root2)
                    {
                        ufs[root1]+=ufs[root2];
                        ufs[root2]=root1;
                    }
                }
                
            }
        }
        int n=0;
        for(auto& e:ufs)
        {
            if(e<0)n++;
        }
        return n;
    }
};

2. 等式方程的可满足性

990. 等式方程的可满足性https://leetcode.cn/problems/satisfiability-of-equality-equations/

给定一个由表示变量之间关系的字符串方程组成的数组,每个字符串方程 equations[i] 的长度为 4,并采用两种不同的形式之一:"a==b""a!=b"

在这里,a 和 b 是小写字母(不一定不同),表示单字母变量名。

只有当可以将整数分配给变量名,以便满足所有给定的方程时才返回 true,否则返回 false

cpp 复制代码
class Solution {
public:
    bool equationsPossible(vector<string>& equations) {
        vector<int> ufs(26,-1);
        auto findroot=[&ufs](int x){while(ufs[x]>=0)x=ufs[x];return x;};
        for(int i=0;i<equations.size();i++)
        {
            if(equations[i][1]=='=')
            {
                int root1=findroot(equations[i][0]-'a');
                int root2=findroot(equations[i][3]-'a');
                if(root1!=root2)
                {
                    ufs[root1]+=ufs[root2];
                    ufs[root2]=root1;
                }
            }
        }
        for(int i=0;i<equations.size();i++)
        {
            if(equations[i][1]=='!')
            {
                int root1=findroot(equations[i][0]-'a');
                int root2=findroot(equations[i][3]-'a');
                if(root1==root2)
                {
                    return false;
                }
            }
        }
        return true;
    }
};

9.小结

  1. DSU两大基础操作:Find找根(路径压缩)、Union合并集合(按大小/秩合并);
  2. 两大优化同时使用,复杂度接近常数 a(n);
  3. 典型应用:无向图连通性、Kruskal最小生成树、无向图环检测;
  4. 不能做集合拆分,不适合有向图。

相关推荐
冻柠檬飞冰走茶1 小时前
《数据结构实验指导-C++语言版》 在顺序表 list 中查找元素 x
开发语言·数据结构·c++·算法·list
lancyu1 小时前
# Agent Loop:AI Agent 的唯一直心骨
开发语言·javascript·人工智能
小鱼仙官1 小时前
Ubuntu C++ NTP校时程序
linux·c++·ubuntu
frjc1 小时前
阿里云 ACK 环境 Arthas 在线调试指南
开发语言·python
老当益壮梁奶奶2 小时前
Linux软件编程学习笔记(二)Linux文件IO编程入门:从fopen到fgetc
linux·c语言·c++·笔记·学习
雪的季节2 小时前
C++ 高级(泛型编程与模板)(有空再研究吧)
c++
三十岁老牛再出发2 小时前
8月21日总结
c++·python
longxiaozhang62 小时前
C#继承:让代码少写一点,偷懒的好方法
开发语言·c#
weixin_440730502 小时前
python+request实现接口-参数化小结
开发语言·python