跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客GitHub 精选镜像工具UI配色美学隐私政策关于联系
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
C++算法

数据结构:图的存储结构与核心算法解析

图结构由顶点集合及边集合组成,用于描述现实世界中的复杂关系。深入探讨图的两种核心存储结构:邻接矩阵适合稠密图,邻接表适合稀疏图。详细讲解广度优先搜索(BFS)与深度优先搜索(DFS)的遍历逻辑及非连通图处理。重点剖析最小生成树算法(Kruskal 与 Prim)及最短路径算法(Dijkstra、Bellman-Ford、Floyd-Warshall),对比其适用场景与时间复杂度。结合 C++ 模板代码演示关键步骤,解析贪心策略与动态规划在图论中的应用,提供实战参考。

lzdxwyh发布于 2026/3/15更新于 2026/6/1015 浏览
数据结构:图的存储结构与核心算法解析

1. 图的基本概念

图是由顶点集合及顶点间的关系组成的一种数据结构:G = (V, E),其中: 顶点集合 V = {x|x 属于某个数据对象集} 是有穷非空集合; E = {(x,y)|x,y 属于 V} 或者 E = {<x, y>|x,y 属于 V && Path(x, y)} 是顶点间关系的有穷集合,也叫做边的集合。

(x, y) 表示 x 到 y 的一条双向通路,即 (x, y) 是无方向的;Path(x, y) 表示从 x 到 y 的一条单向通路,即 Path(x, y) 是有方向的。

顶点、边和权值:图中结点称为顶点,第 i 个顶点记作 vi。两个顶点 vi 和 vj 相关联称作顶点 vi 和顶点 vj 之间有一条边,图中的第 k 条边记作 ek,ek = (vi, vj) 或 <vi, vj>。边上附带的数据信息称为权值。

图中的顶点可以理解成生活中的某一个地点或者个体,边可以理解成两个地点或者个体之间具有的某种联系,这种联系有强有弱,我们可以通过数值去衡量,称作权值。可以看到图描述的是现实生活中的某种具体的情形。如下图,城市就是顶点,城市的边可以是高铁距离、高铁价格之类。比如今天想要从西安前往贵阳,我们就可以通过比较边的权值来衡量怎么走最划算。

文章配图

有向图和无向图:在有向图中,顶点对 <x, y> 是有序的,顶点对 <x, y> 称为顶点 x 到顶点 y 的一条边 (弧),<x, y> 和 <y, x> 是两条不同的边,比如下图 G3 和 G4 为有向图。在无向图中,顶点对 (x, y) 是无序的,顶点对 (x,y) 称为顶点 x 和顶点 y 相关联的一条边,这条边没有特定方向,(x, y) 和 (y, x) 是同一条边,比如下图 G1 和 G2 为无向图。

注意:无向边 (x, y) 等于有向边 <x, y> 和 <y, x>。

文章配图

对于有向与无向图的理解,我们可以通过社交关系类比,顶点是人,边表示好友关系,权值表示亲密度。对于像微信、QQ 这里社交软件来说,一旦建立好友关系,两个人就互为好友,这种强社交关系就是无向图,如下图 G1;而对于像抖音、微博等社交软件,关注某个人只能建立单向的关系,只有当对方互关,双方才能正式建立好友关系,这种弱社交模式就是有向图,即边存在方向,是单向的,如下图 G2。

文章配图

文章配图

完全图:在有 n 个顶点的无向图中,若有 n * (n-1)/2 条边,即任意两个顶点之间有且仅有一条边,则称此图为无向完全图,比如上图 G1;

在 n 个顶点的有向图中,若有 n * (n-1) 条边,即任意两个顶点之间有且仅有方向相反的边,则称此图为有向完全图,比如上图 G4。(完全图任意两个顶点之间都直接相连,这是最稠密的图)

注:我们发现上图 G2 的结构就一棵树,其实树是一种特殊的(无环连通)图,但图不一定是树。

树是一种存储型的数据结构,树关注的是节点(顶点中存储的值),数据的存储,图是一种表示型的数据结构,关心的是顶点及边的权值,图描述的是现实中的具体情形。

邻接顶点:在无向图中 G 中,若 (u, v) 是 E(G) 中的一条边,则称 u 和 v 互为邻接顶点,并称边 (u,v) 依附于顶点 u 和 v;在有向图G 中,若 <u, v> 是 E(G) 中的一条边(自 u 指向 v 的一条边),则称顶点 u 邻接到 v,顶点 v 邻接自顶点 u,并称边 <u, v> 与顶点 u 和顶点 v 相关联。

顶点的度:顶点 v 的度是指与它相关联的边的条数,记作 deg(v)。

在有向图中**,顶点的度等于该顶点的入度与出度之和**,其中顶点 v 的入度是以 v 为终点的有向边的条数,记作 indeg(v); 顶点 v 的出度是以 v 为起始点的有向边的条数,记作 outdeg(v)。因此:deg(v) = indeg(v) + outdeg(v)。

注意:对于无向图,顶点的度等于该顶点的入度和出度,即 deg(v) = indeg(v) = outdeg(v)。

路径:在图 G = (V, E) 中,**若从顶点 vi 出发有一组边使其可到达顶点 vj,则称顶点 vi 到顶点 vj 的顶点序列为从顶点 vi 到顶点 vj 的路径。**如下图从 1 经过 2、5、8 到达 7 就是一条路径,也可以是从 1 经过 2、5、3、6 到达 7。路径可以有多条。

路径长度:对于不带权的图,一条路径的路径长度是指该路径上的边的条数;对于带权的图,一条路径的路径长度是指该路径上各个边权值的总和。

文章配图

简单路径与回路:若路径上各顶点 v1,v2,v3,…,vm 均不重复,则称这样的路径为简单路径。

若路径上第一个顶点 v1 和最后一个顶点 vm 重合,则称这样的路径为回路或环。

文章配图

子图:设图 G = {V, E} 和图 G1 = {V1,E1},若 V1 属于 V 且 E1 属于 E,即 G1 的顶点、边都是 G 的一部分,则称 G1 是 G 的子图。

文章配图

连通图:在无向图中,若从顶点 v1 到顶点 v2 有路径,则称顶点 v1 与顶点 v2 是连通的。如果图中任意一对顶点都是连通的,则称此图为连通图。

强连通图:在有向图中**,若在每一对顶点 vi 和 vj 之间都存在一条从 vi 到 vj 的路径,也存在一条从 vj 到 vi 的路径,则称此图是强连通图**。

生成树:在无向图中,一个连通图的最小连通子图称作该图的生成树。**有 n 个顶点的连通图的生成树有 n 个顶点和 n-1 条边。**我们通过 n-1 条边将 n 个顶点相连形成的连通图就是原图的最小生成树。

如子图 1,我们可以通过三条边将 4 个顶点相连,并且任意个点都有通往其他点的路径,即该子图也是一个连通图,使用的边少于 3 个,我们无法将所有点连通起来,所以子图 1 就是 G1 的最小生成树。

文章配图

文章配图

如果不考虑边权值的情况下

2. 图的存储结构

因为图中既有节点,又有边 (节点与节点之间的关系),因此,在图的存储中,只需要保存:节点和边关系即可。节点保存比较简单,只需要一段连续空间即可,那边关系该怎么保存呢?

2.1 邻接矩阵

因为节点与节点之间的关系就是连通与否,即为 0 或者 1,因此邻接矩阵 (二维数组) 即是:先用一个数组将定点保存,然后采用矩阵来表示节点与节点之间的关系。

文章配图

注意: 1. 无向图的邻接矩阵是对称的,第 i 行 (列) 元素之和,就是顶点 i 的度。有向图的邻接矩阵则不一定是对称的,第 i 行 (列) 元素之后就是顶点 i 的出 (入) 度。

2. 如果边带有权值,并且两个节点之间是连通的,上图中的边的关系就用权值代替,如果两个顶点不通,则使用无穷大代替。

文章配图

3. 用邻接矩阵存储图的优点是能够快速知道两个顶点是否连通,缺陷是如果顶点比较多,边比较少时,矩阵中存储了大量的 0 成为系数矩阵,比较浪费空间,并且要求两个节点之间的路径不是很好求。

// V 存储数据的类型,可能是字符串、整形等,所以这里用模版
// W 权值的类型,可能有 int、double 等,所以也是使用模版
// 表示边不存在的特殊值默认是 int 的最大值,其他类型需要显式传入
// Direction 用来控制是无向还是有向图
template<class V, class W, W MAX_W = INT_MAX, bool Direction = false>
class Graph {
public:
    typedef Graph<V, W, MAX_W, Direction> Self;
    Graph() = default;
    // 实际邻接矩阵的实现中边关系的存储,有读取 OJ 输入、从文件读取、以及自己输入
    // 这里为了方便调试控制,我们边的关系,手动插入
    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 invalid_argument("不存在的顶点");
            return -1;
        }
    }
    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) {
            cout << _vertexs[i] << "-" << i << " ";
        }
        cout << endl << endl;
        cout << " ";
        for (size_t i = 0; i < _vertexs.size(); ++i) {
            cout << i << " ";
        }
        cout << endl;
        // 打印矩阵
        for (size_t i = 0; i < _matrix.size(); ++i) {
            cout << i << " ";
            for (size_t j = 0; j < _matrix[i].size(); ++j) {
                if (_matrix[i][j] != MAX_W) cout << _matrix[i][j] << " ";
                else cout << "#" << " ";
            }
            cout << endl;
        }
        cout << endl << endl;
        // 打印所有的边
        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) {
                    cout << _vertexs[i] << "-" << _vertexs[j] << ":" << _matrix[i][j] << endl;
                }
            }
        }
    }
private:
    // 因为数据的类型不定,进而映射的下标不好直接确定
    // 我们这里通过 map 映射,可以根据数据快速确定对应的下标,方便找到边
    map<V, size_t> _vIndexMap;
    // 存储数据与下标的映射关系
    vector<V> _vertexs;
    // 顶点集合
    vector<vector<W>> _matrix;
    // 存储边集合的矩阵
};

void TestGraph() {
    Graph<char, int, INT_MAX, true> g("0123", 4);
    g.AddEdge('0', '1', 1);
    g.AddEdge('0', '3', 4);
    g.AddEdge('1', '3', 2);
    g.AddEdge('1', '2', 9);
    g.AddEdge('2', '3', 8);
    g.AddEdge('2', '1', 5);
    g.AddEdge('2', '0', 3);
    g.AddEdge('3', '2', 6);
    g.Print();
}

总结:

邻接矩阵可以 O(1) 判断两个顶点的连接关系,并取到权值,所以邻接矩阵存储方式非常适合稠密图。

但是相对而言邻接矩阵不适合查找一个顶点连接的所有边,因为要想确定从某一个点出发的所有边是否存在,必须从该点映射下标出发,遍历_matrix 某一行/列,复杂度为 O(N)。

2.2 邻接表

邻接表:使用指针数组表示顶点的集合,使用链表表示边的关系。将从该顶点出发的所有边通过链表方式挂在对应顶点位置上。

1. 无向图邻接表存储

无向图因为边没有方向,所以实际存储时按照双向边处理。从 B->A 和从 A->B 都要存储。

文章配图

注意:无向图中同一条边在邻接表中出现了两次。如果想知道顶点 vi 的度,只需要知道顶点 vi 边链表集合中结点的数目即可。

2. 有向图邻接表存储

有向图中,一条边从顶点指向外界,称作出边;从外界指向节点称作入边,分别存储。

文章配图

注意:有向图中每条边在邻接表中只出现一次,与顶点 vi 对应的邻接表所含结点的个数,就是该顶点的出度,也称出度表,要得到 vi 顶点的入度,必须检测其他所有顶点对应的边链表,看有多少边顶点的 dst 取值是 i。

// 邻接表
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) {}
    };
    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 invalid_argument("不存在的顶点");
                return -1;
            }
        }
        void AddEdge(const V& src, const V& dst, const W& w) {
            size_t srcindex = GetVertexIndex(src);
            size_t dstindex = GetVertexIndex(dst);
            // 0 1
            Edge* sd_edge = new Edge(w);
            sd_edge->_srcIndex = srcindex;
            sd_edge->_dstIndex = dstindex;
            sd_edge->_next = _linkTable[srcindex];
            _linkTable[srcindex] = sd_edge;
            // 1 0
            // 无向图
            if (Direction == false) {
                Edge* ds_edge = new Edge(w);
                ds_edge->_srcIndex = dstindex;
                ds_edge->_dstIndex = srcindex;
                ds_edge->_next = _linkTable[dstindex];
                _linkTable[dstindex] = ds_edge;
            }
        }
    private:
        map<string, int> _vIndexMap;
        vector<V> _vertexs; // 顶点集合
        vector<Edge*> _linkTable; // 边的集合的临接表
    };
    void TestGraph() {
        string a[] = { "张三", "李四", "王五", "赵六" };
        Graph<string, int> g1(a, 4);
        g1.AddEdge("张三", "李四", 100);
        g1.AddEdge("张三", "王五", 200);
        g1.AddEdge("王五", "赵六", 30);
    }
}

总结:相比邻接矩阵,邻接表每个顶点下直接挂上自己连接的顶点,我们从顶点出发遍历一下链表就可以获得所有连接的边,不需要遍历所有方向再判断,所以邻接表适合查找一个顶点连接出去的边。

但是由于本身指针数组 + 链表的结构不适合稠密图,适合稀疏图,同时不适合确定两个顶点是否相连及权值。

注:下文的算法为了方便处理,都是用邻接矩阵结构

3. 图的遍历

给定一个图 G 和其中任意一个顶点 v0,从 v0 出发,沿着图中各边访问图中的所有顶点,且每个顶点仅被遍历一次。"遍历"即对结点进行某种操作的意思。

3.1 图的广度优先遍历

文章配图

我们以某一个顶点开始,先遍历该点连接所有的顶点,再遍历这些顶点所连接的所有顶点,依此类推,形成了一种一层一层的向外遍历的效果,这就是广度遍历。

文章配图

为了实现广度遍历,这里我们采用数据结构树部分的思路,利用队列先进先出的效果,遍历一个顶点就将其入队,遍历完将其出队再将该点连接的所有顶点入队,依次类推继续遍历。

不过与树部分不同的是图中顶点会相互的连接,我们在根据顶点入队连接的点时,可能会将之前已经遍历过的点重新入队,我们这需要多借助一个容器用来标记哪个节点之前已经访问过了。入队时对应顶点标记,我们就不入队。

文章配图

因为广度遍历视觉上的一层一层外扩遍历的效果,一些问题可能会要求对同一层的节点进行标记。

文章配图

针对这种问题,我们增加一个变量,记录一整层的顶点数,用来控制一整层出完。

void BFS(const V& src) {
    size_t srci = GetVertexIndex(src);
    // 队列和标记数组
    queue<int> q;
    vector<bool> visited(_vertexs.size(), false);
    // 选择一个点作为起点
    q.push(srci);
    visited[srci] = true;
    int levelSize = 1;
    size_t n = _vertexs.size();
    while (!q.empty()) {
        // 一层一层出
        for (int i = 0; i < levelSize; ++i) {
            int front = q.front();
            q.pop();
            cout << front << ":" << _vertexs[front] << " ";
            // 把 front 顶点的邻接顶点入队列
            for (size_t i = 0; i < n; ++i) {
                if (_matrix[front][i] != MAX_W) {
                    if (visited[i] == false) {
                        q.push(i);
                        visited[i] = true;
                    }
                }
            }
        }
        cout << endl;
        levelSize = q.size();
    }
    cout << endl;
}
void TestGraphDBFS() {
    string a[] = { "张三", "李四", "王五", "赵六", "周七" };
    Graph<string, int> g1(a, sizeof(a) / sizeof(string));
    g1.AddEdge("张三", "李四", 100);
    g1.AddEdge("张三", "王五", 200);
    g1.AddEdge("王五", "赵六", 30);
    g1.AddEdge("王五", "周七", 30);
    g1.BFS("张三");
    g1.DFS("张三");
}

3.2 图的深度优先遍历

文章配图

文章配图

与树中的深度遍历类似,图的深度遍历是先将一条路上的所有顶点遍历完再返回去遍历其他路径。

因为同样有遍历已经访问过顶点的可能性,所以这里我们同样需要一个容器用来标记顶点。

void _DFS(size_t srci, vector<bool>& visited) {
    cout << srci << ":" << _vertexs[srci] << endl;
    // 标记已经访问过的顶点
    visited[srci] = true;
    // 找一个 srci 相邻的没有访问过的点,去往深度遍历
    for (size_t i = 0; i < _vertexs.size(); ++i) {
        if (_matrix[srci][i] != MAX_W && visited[i] == false) {
            // 先访问完一条路上的所有顶点
            _DFS(i, visited);
        }
    }
}
void DFS(const V& src) {
    // 以一个点为起点
    size_t srci = GetVertexIndex(src);
    // 标记容器
    vector<bool> visited(_vertexs.size(), false);
    _DFS(srci, visited);
}
void TestGraphDBFS() {
    string a[] = { "张三", "李四", "王五", "赵六", "周七" };
    Graph<string, int> g1(a, sizeof(a) / sizeof(string));
    g1.AddEdge("张三", "李四", 100);
    g1.AddEdge("张三", "王五", 200);
    g1.AddEdge("王五", "赵六", 30);
    g1.AddEdge("王五", "周七", 30);
    g1.BFS("张三");
    g1.DFS("张三");
}

3.3 非连通图情形

文章配图

上文的广度与深度遍历是根据边来遍历顶点的,如果我们给的并不是连通图,那就以某一个点位出发就无法完成全部的遍历,所以针对这种情况,我们需要借助一个 vector 的容器来标记已经遍历过的点,完成一轮遍历后,我们再根据标记遍历剩下未遍历的点。

4. 最小生成树

连通图中的每一棵生成树,都是原图的一个极大无环子图,即:从其中删去任何一条边,生成树就不在连通;反之,在其中引入任何一条新边,都会形成一条回路。

最小生成树:构成生成树的所有边加起来权值最小,即我们需要用最小的代价(权值)让 N 个顶点连接起来。

若连通图由 n 个顶点组成,则其生成树必含 n 个顶点和 n-1 条边。因此构造最小生成树的准则有三条: 1. 只能使用图中的权值最小边来构造最小生成树 2. 只能使用恰好 n-1 条边来连接图中的 n 个顶点 3. 选用的 n-1 条边不能构成回路

构造最小生成树的方法**:Kruskal 算法和 Prim 算法**。这两个算法都采用了逐步求解的贪心策略。

贪心算法:是指在问题求解时,总是做出当前看起来最好的选择。也就是说贪心算法做出的不是整体最优的选择,而是某种意义上的局部最优解。贪心算法不是对所有的问题都能得到整体最优解。

4.1 Kruskal 算法

任给一个有 n 个顶点的连通网络 N={V,E}, 首先构造一个由这 n 个顶点组成、不含任何边的图 G={V,NULL},其中每个顶点自成一个连通分量,其次不断从 E 中取出权值最小的一条边 (若有多条相同的任取其一),若该边的两个顶点来自不同的连通分量,则将此边加入到 G 中。如此重复,直到所有顶点在同一个连通分量上为止。 核心:每次迭代时,选出一条具有最小权值,且两端点不在同一连通分量上的边,加入生成树。

文章配图

文章配图

Kruskal 算法贪心的地方就在于每次都是从所有边中挑选最小的那条,以期最终整体的权值最小。

需要注意的是根据最小生成树的生成准则,我们选取边时候不能形成一个环出来,这会导致我们的代价不能最小。

为了能够从所有边中选最小,我们这里需要借助堆。为了避免形成环,我们这里需要借助并查集。

文章配图

对于已经相连(直接/间接)的边,我们可以视作已经在同一个集合中了,那么下次选取一条边之前,我们可以判断下两个顶点是否在同一个集合内,如果在,说明之前已经连接了,再选取该边会成环。

W Kruskal(Self& minTree) {
    size_t n = _vertexs.size();
    minTree._vertexs = _vertexs;
    minTree._indexMap = _indexMap;
    minTree._matrix.resize(n);
    for (size_t i = 0; i < n; ++i) {
        minTree._matrix[i].resize(n, MAX_W);
    }
    priority_queue<Edge, vector<Edge>, greater<Edge>> minque;
    for (size_t i = 0; i < n; ++i) {
        for (size_t j = 0; j < n; ++j) {
            if (i < j && _matrix[i][j] != MAX_W) {
                minque.push(Edge(i, j, _matrix[i][j]));
            }
        }
    }
    // 选出 n-1 条边
    // 贪心算法,从最小的边开始选
    int size = 0;
    W totalW = W();
    UnionFindSet ufs(n);
    while (!minque.empty()) {
        Edge min = minque.top();
        minque.pop();
        // 边不在一个集合,说明不会构成环,则添加到最小生成树
        if (!ufs.InSet(min._srci, min._dsti)) {
            cout << _vertexs[min._srci] << "->" << _vertexs[min._dsti] << ":" << min._w << endl;
            minTree._AddEdge(min._srci, min._dsti, min._w);
            ufs.Union(min._srci, min._dsti);
            ++size;
            totalW += min._w;
        } else {
            cout << "构成环:";
            cout << _vertexs[min._srci] << "->" << _vertexs[min._dsti] << ":" << min._w << endl;
        }
    }
    // 因为最小生成树的准则,边数达到 N-1,说明我们已经挑完了
    // 如果我们所有边都遍历完了,还是没找到 N-1 条边说明无法找到最小生成树
    if (size == n - 1) {
        return totalW;
    } else {
        return W();
    }
}
void TestGraphMinTree() {
    const char* str = "abcdefghi";
    Graph<char, int> g(str, strlen(str));
    g.AddEdge('a', 'b', 4);
    g.AddEdge('a', 'h', 8);
    //g.AddEdge('a', 'h', 9);
    g.AddEdge('b', 'c', 8);
    g.AddEdge('b', 'h', 11);
    g.AddEdge('c', 'i', 2);
    g.AddEdge('c', 'f', 4);
    g.AddEdge('c', 'd', 7);
    g.AddEdge('d', 'f', 14);
    g.AddEdge('d', 'e', 9);
    g.AddEdge('e', 'f', 10);
    g.AddEdge('f', 'g', 2);
    g.AddEdge('g', 'h', 1);
    g.AddEdge('g', 'i', 6);
    g.AddEdge('h', 'i', 7);
    Graph<char, int> kminTree;
    cout << "Kruskal:" << g.Kruskal(kminTree) << endl;
    kminTree.Print();
    Graph<char, int> pminTree;
    cout << "Prim:" << g.Prim(pminTree, 'a') << endl;
    pminTree.Print();
}

4.2 Prim 算法

文章配图

文章配图

与 Kruskal 相比,prim 不是从所有边中直接选取,而是将点分成两个集合,再从两个集合中的点对应形成的边中选取最小权值的边,这天然就利用了并查集的逻辑,因此 prim 避免选边成环,可以直接利用原有的存储点的集合 X、Y,选取一条边前,如果一条边的两个顶点都在 X 内,说明它们已经直接/间接连接了,因此我们不能选取这条边(因为 X 集合中一开始只有一个点,通过这个点相邻的边再加入点的,所以最终 X 集合中的所有点都是直接或间接相连的)

W Prim(Self& minTree, const V& src) {
    size_t srci = GetVertexIndex(src);
    size_t n = _vertexs.size();
    minTree._vertexs = _vertexs;
    minTree._indexMap = _indexMap;
    minTree._matrix.resize(n);
    for (size_t i = 0; i < n; ++i) {
        minTree._matrix[i].resize(n, MAX_W);
    }
    // X 和 Y 集合
    vector<bool> X(n, false);
    vector<bool> Y(n, true);
    X[srci] = true;
    Y[srci] = false;
    // 从 X->Y 集合中连接的边里面选出最小的边
    priority_queue<Edge, vector<Edge>, greater<Edge>> minq;
    // 先把 srci 连接的边添加到队列中
    for (size_t i = 0; i < n; ++i) {
        if (_matrix[srci][i] != MAX_W) {
            minq.push(Edge(srci, i, _matrix[srci][i]));
        }
    }
    cout << "Prim 开始选边" << endl;
    size_t size = 0;
    W totalW = W();
    while (!minq.empty()) {
        Edge min = minq.top();
        minq.pop();
        // 如果最小边的目标点也在 X 集合,则构成环
        if (X[min._dsti]) {
            cout << "构成环:";
            cout << _vertexs[min._srci] << "->" << _vertexs[min._dsti] << ":" << min._w << endl;
        } else {
            // 添加边,并更新点的集合
            minTree._AddEdge(min._srci, min._dsti, min._w);
            //cout << _vertexs[min._srci] << "->" << _vertexs[min._dsti] << ":" << min._w << endl;
            X[min._dsti] = true;
            Y[min._dsti] = false;
            // 因为最小生成树的准则,边数达到 N-1,说明我们已经挑完了
            ++size;
            totalW += min._w;
            if (size == n - 1) break;
            // 只有边的 dsti 不在 Y 中说明不会成环,我们加入到队列中
            for (size_t i = 0; i < n; ++i) {
                if (_matrix[min._dsti][i] != MAX_W && Y[i]) {
                    minq.push(Edge(min._dsti, i, _matrix[min._dsti][i]));
                }
            }
        }
    }
    // 如果我们所有边都遍历完了,还是没找到 N-1 条边说明无法找到最小生成树
    if (size == n - 1) {
        return totalW;
    } else {
        return W();
    }
}
void TestGraphMinTree() {
    const char* str = "abcdefghi";
    Graph<char, int> g(str, strlen(str));
    g.AddEdge('a', 'b', 4);
    g.AddEdge('a', 'h', 8);
    //g.AddEdge('a', 'h', 9);
    g.AddEdge('b', 'c', 8);
    g.AddEdge('b', 'h', 11);
    g.AddEdge('c', 'i', 2);
    g.AddEdge('c', 'f', 4);
    g.AddEdge('c', 'd', 7);
    g.AddEdge('d', 'f', 14);
    g.AddEdge('d', 'e', 9);
    g.AddEdge('e', 'f', 10);
    g.AddEdge('f', 'g', 2);
    g.AddEdge('g', 'h', 1);
    g.AddEdge('g', 'i', 6);
    g.AddEdge('h', 'i', 7);
    Graph<char, int> kminTree;
    cout << "Kruskal:" << g.Kruskal(kminTree) << endl;
    kminTree.Print();
    Graph<char, int> pminTree;
    cout << "Prim:" << g.Prim(pminTree, 'a') << endl;
    pminTree.Print();
}

5. 最短路径

最短路径问题:从在带权有向图 G 中的某一顶点出发,找出一条通往另一顶点的最短路径,最短也就是沿路径各边的权值总和达到最小。

5.1 单源最短路径--Dijkstra 算法

单源最短路径问题:给定一个图 G = (V, E),求源结点 s ∈ V 到图中每个结点 v ∈ V 的最短路径。

Dijkstra 算法就适用于解决带权重的有向图上的单源最短路径问题,同时算法要求图中所有边的权重非负。一般在求解最短路径的时候都是已知一个起点和一个终点,所以使用 Dijkstra 算法求解过后也就得到了所需起点到终点的最短路径。

针对一个带权有向图 G,将所有结点分为两组 S 和 Q,S 是已经确定最短路径的结点集合,在初始时为空(初始时就可以将源节点 s 放入,毕竟源节点到自己的代价是 0),Q 为其余未确定最短路径的结点集合,每次从 Q 中找出一个起点到该结点代价最小的结点 u,将 u 从 Q 中移出,并放入 S 中,对 u 的每一个相邻结点 v 进行松弛操作。松弛即对每一个相邻结点 v,判断源节点 s 到结点 u 的代价与 u 到 v 的代价之和是否比原来 s 到 v 的代价更小,若代价比原来小则要将 s 到 v 的代价更新为 s 到 u 与 u 到 v 的代价之和,否则维持原样。如此一直循环直至集合 Q 为空,即所有节点都已经查找过一遍并确定了最短路径,至于一些起点到达不了的结点在算法循环后其代价仍为初始设定的值,不发生变化。

Dijkstra 算法每次都是选择 V-S 中最小的路径节点来进行更新,并加入 S 中,所以该算法使用的是贪心策略。

文章配图

总结来说 Dijkstra 的贪心策略就是给定一个起点,然后选择起点到其他点中的最短路径再去更新出起点到其他点的最短路径。

文章配图

上述过程中需要解释的是:

本质上 Dijkstra 算法从起点出发从连接的路径中选择一条最短的,比如上图中 s->y,那么此时 s->y 的最短路径就可以确定,因为此时 s 出发,如果走 s->t 再到 y,那么路径大小不管怎么走都是一定大于等于 10 的,一定比 s->y 的 5 大(可以看出 Dijkstra 算法就是基于边不为负权,多走路权值增加的逻辑,所以Dijkstra 算法是不支持图中带负权路径,如果带有负权路径,则可能会找不到一些路径的最短路径。);反过来说 s->t 的路径此时可能不是最小路径,因为 s->y 的路径是 5,从 y 出发最后再到 t 的路中是可能存在大于 5 小于 10 的情况的,所以此时 s->t 不能确地为最短路径。

s->y 确定为最短路径,之后都不在更新(因为不可能更短),所以当前 s->y 是最短路径,所以我们从 y 出发再去其他点得出来的路径一定会更小(这就是 Dijkstra 的贪心策略),更新出 s->t、s->z、s->x 的路径,我们选取最小路径 s->t,因为 s->y 是之前的最小,那么加上 y->t 得出来的 s->t 一定是 s->t 的最小路径,不可能走出更小的。之后基于上述逻辑,我们就可以一次更新出 s 到其他所有点的最小路径。

这里我们使用数组 vector dist 容器记录 s 到某一点的距离,对应下标对顶顶点,存储的就是 s 到该点的距离,我们同样使用 vectorpPath 用来还原最短路,下标对应顶点,存储的是 s 到该点路径中该点的上一个节点,比如 s->y->x,那么 x 对应下标存储 y 对应小标,y 对应下标存储 s 对应下标,这样我们就可以还原出一整条路径了。

文章配图

// 顶点个数是 N -> 时间复杂度:O(N^2)空间复杂度:O(N)
// 需要借助 vector<int> 存储空间复杂度是 O(N)
// 最坏情况下,每个顶点遍历剩下所有顶点,等差式遍历,所以时间复杂度为 O(N^2)
void Dijkstra(const V& src, vector<W>& dist, vector<int>& pPath) {
    size_t srci = GetVertexIndex(src);
    size_t n = _vertexs.size();
    // vector<W> dist, 记录 srci-其他顶点最短路径权值数组
    dist.resize(n, MAX_W);
    // vector<int> parentPath 记录 srci-其他顶点最短路径父顶点数组
    pPath.resize(n, -1);
    // srci 的权值给一个最小值,方便贪心第一次找到这个节点
    dist[srci] = 0;
    pPath[srci] = srci;
    // 标记是否找到最短路径的顶点集合 S
    vector<bool> S(n, false);
    for (size_t j = 0; j < n; ++j) {
        // 贪心算法:srci 到不在 S 中路径最短的那个顶点 u
        // 选最短路径顶点且不在 S 更新其他路径
        int u = 0;
        W min = MAX_W;
        for (size_t i = 0; i < n; ++i) {
            if (S[i] == false && dist[i] < min) {
                u = i;
                min = dist[i];
            }
        }
        S[u] = true;
        // 松弛更新 u 连接顶点 v srci->u + u->v < srci->v 更新
        for (size_t v = 0; v < n; ++v) {
            if (S[v] == false && _matrix[u][v] != MAX_W && dist[u] + _matrix[u][v] < dist[v]) {
                dist[v] = dist[u] + _matrix[u][v];
                pPath[v] = u;
            }
        }
    }
}
// 打印最短路径的逻辑算法
void PrinrtShotPath(const V& src, const vector<W>& dist, const vector<int>& parentPath) {
    size_t N = _vertexs.size();
    size_t srci = GetVertexIndex(src);
    for (size_t i = 0; i < N; ++i) {
        if (i == srci) continue;
        vector<int> path;
        int parenti = i;
        while (parenti != srci) {
            path.push_back(parenti);
            parenti = parentPath[parenti];
        }
        path.push_back(srci);
        reverse(path.begin(), path.end());
        for (auto pos : path) {
            cout << _vertexs[pos] << "->";
        }
        cout << dist[i] << endl;
    }
}
void TestGraphDijkstra() {
    const char* str = "syztx";
    Graph<char, int, INT_MAX, true> g(str, strlen(str));
    g.AddEdge('s', 't', 10);
    g.AddEdge('s', 'y', 5);
    g.AddEdge('y', 't', 3);
    g.AddEdge('y', 'x', 9);
    g.AddEdge('y', 'z', 2);
    g.AddEdge('z', 's', 7);
    g.AddEdge('z', 'x', 6);
    g.AddEdge('t', 'y', 2);
    g.AddEdge('t', 'x', 1);
    g.AddEdge('x', 'z', 4);
    vector<int> dist;
    vector<int> parentPath;
    g.Dijkstra('s', dist, parentPath);
    g.PrinrtShotPath('s', dist, parentPath);
    // 图中带有负权路径时,贪心策略则失效了。
    // 测试结果可以看到 s->t->y 之间的最短路径没更新出来
    /*const char* str = "sytx";
    Graph<char, int, INT_MAX, true> g(str, strlen(str));
    g.AddEdge('s', 't', 10);
    g.AddEdge('s', 'y', 5);
    g.AddEdge('t', 'y', -7);
    g.AddEdge('y', 'x', 3);
    vector<int> dist;
    vector<int> parentPath;
    g.Dijkstra('s', dist, parentPath);
    g.PrinrtShotPath('s', dist, parentPath);*/
}

5.2 单源最短路径--Bellman-Ford 算法

Dijkstra 算法只能用来解决正权图的单源最短路径问题,但有些题目会出现负权图。这时这个算法就不能帮助我们解决问题了,而 bellman—ford 算法可以解决负权图的单源最短路径问题。

bellman—ford 的优点是可以解决有负权边的单源最短路径问题,而且可以用来判断是否有负权回路。它也有明显的缺点,它的时间复杂度 O(N*E) (N 是点数,E 是边数) 普遍是要高于 Dijkstra 算法 O(N²) 的。像这里如果我们使用邻接矩阵实现,那么遍历所有边的数量的时间复杂度就是 O(N^3),这里也可以看出来Bellman-Ford 就是一种暴力求解更新。

文章配图

因为边可能出现负权,前面路径长,加上后面路径可能更短,所以 Dijkstra 的逻辑无法成立,而 Bellman-Ford 针对负权选择从一条路径的后段开始选,s->i,i->j,其中 i 是终点的前一个经过的点,Bellman-Ford 就选择从多个到 j 的 i 暴力的遍历,尝试出最短的那一条路径,然后再反过来一段一段往前更新 s->i 的部分,如下端代码

for (size_t i = 0; i < n; ++i) {
    for (size_t j = 0; j < n; ++j) {
        // srci -> i + i ->j
        if (_matrix[i][j] != MAX_W && dist[i] != MAX_W && dist[i] + _matrix[i][j] < dist[j]) {
            //cout << _vertexs[i] << "->" << _vertexs[j] << ":" << _matrix[i][j] << endl;
            dist[j] = dist[i] + _matrix[i][j];
            pPath[j] = i;
        }
    }
}

文章配图

但是上段代码还存在的一个问题是由于路径是挨个暴力更新的,所以会出现上图中 s->t 更新了更短的路径,但是之前已经更新的 s->t->z 中 s->t 路径没有更新,出现问题。

所以我们应该在外层再套一层循环,来根据已经更新过的边纠正其他未更新的边,因为一个起点到其他点(包括自己本身),用 dist 记录,对应 8 个下标,所以最多更新 n 轮,就可以将所有情况修正完全。

同时为了优化,我们记录更新的轮数,如果更新一轮下来,没有发生一次修正,我们可以直接结束,如果更新 n 轮后仍然可以更新,说明图中有负权构成的回路(一但负权回路存在,那么总是可以更新出更短的路径,所以负权回路问题是无法解决的)

// 时间复杂度:O(N^3) 空间复杂度:O(N)
bool BellmanFord(const V& src, vector<W>& dist, vector<int>& pPath) {
    size_t n = _vertexs.size();
    size_t srci = GetVertexIndex(src);
    // vector<W> dist, 记录 srci-其他顶点最短路径权值数组
    dist.resize(n, MAX_W);
    // vector<int> pPath 记录 srci-其他顶点最短路径父顶点数组
    pPath.resize(n, -1);
    // 先更新 srci->srci 为缺省值
    dist[srci] = W();
    //cout << "更新边:i->j" << endl;
    // 总体最多更新 n 轮
    for (size_t k = 0; k < n; ++k) {
        // i->j 更新松弛
        bool update = false;
        cout << "更新第:" << k << "轮" << endl;
        for (size_t i = 0; i < n; ++i) {
            for (size_t j = 0; j < n; ++j) {
                // srci -> i + i ->j
                if (_matrix[i][j] != MAX_W && dist[i] != MAX_W && dist[i] + _matrix[i][j] < dist[j]) {
                    update = true;
                    //cout << _vertexs[i] << "->" << _vertexs[j] << ":" << _matrix[i][j] << endl;
                    dist[j] = dist[i] + _matrix[i][j];
                    pPath[j] = i;
                }
            }
        }
        // 如果这个轮次中没有更新出更短路径,那么后续轮次就不需要再走了
        if (update == false) {
            break;
        }
    }
    // 还能更新就是带负权回路
    for (size_t i = 0; i < n; ++i) {
        for (size_t j = 0; j < n; ++j) {
            // srci -> i + i ->j
            // 检查有没有负权回路
            if (_matrix[i][j] != MAX_W && dist[i] + _matrix[i][j] < dist[j]) {
                return false;
            }
        }
    }
    return true;
}
void TestGraphBellmanFord() {
    const char* str = "syztx";
    Graph<char, int, INT_MAX, true> g(str, strlen(str));
    g.AddEdge('s', 't', 6);
    g.AddEdge('s', 'y', 7);
    g.AddEdge('y', 'z', 9);
    g.AddEdge('y', 'x', -3);
    g.AddEdge('z', 's', 2);
    g.AddEdge('z', 'x', 7);
    g.AddEdge('t', 'x', 5);
    g.AddEdge('t', 'y', 8);
    g.AddEdge('t', 'z', -4);
    g.AddEdge('x', 't', -2);
    vector<int> dist;
    vector<int> parentPath;
    if (g.BellmanFord('s', dist, parentPath)) {
        g.PrinrtShotPath('s', dist, parentPath);
    } else {
        cout << "存在负权回路" << endl;
    }
    // 微调图结构,带有负权回路的测试
    //const char* str = "syztx";
    //Graph<char, int, INT_MAX, true> g(str, strlen(str));
    //g.AddEdge('s', 't', 6);
    //g.AddEdge('s', 'y', 7);
    //g.AddEdge('y', 'x', -3);
    //g.AddEdge('y', 'z', 9);
    //g.AddEdge('y', 'x', -3);
    //g.AddEdge('y', 's', 1);
    // 新增
    //g.AddEdge('z', 's', 2);
    //g.AddEdge('z', 'x', 7);
    //g.AddEdge('t', 'x', 5);
    //g.AddEdge('t', 'y', -8);
    // 更改
    //g.AddEdge('t', 'z', -4);
    //g.AddEdge('x', 't', -2);
    //vector<int> dist;
    //vector<int> parentPath;
    //if (g.BellmanFord('s', dist, parentPath))
    //{
    // g.PrinrtShotPath('s', dist, parentPath);
    //}
    //else
    //{
    // cout << "存在负权回路" << endl;
    //}
}

5.3 多源最短路径--Floyd-Warshall 算法

如果说 Dijkstra 选取的是起始最短边往后更新,Bellman-Ford 选取的终止最短边往前更新路径

那么 Floyd-Warshall 算法是解决任意两点间的最短路径的一种算法。Floyd-Warshall 更新的是 s->k->j 路径上的每一小段路径(k 表示 0 个或多个顶点),如 s->k,k->j 都要更新。 Floyd 算法考虑的是一条最短路径的中间节点,即简单路径 p={v1,v2,…,vn} 上除 v1 和 vn 的任意节 点。设 k 是 p 的一个中间节点,那么从 i 到 j 的最短路径 p 就被分成 i 到 k 和 k 到 j 的两段最短路径 p1,p2。p1 是从 i 到 k 且中间节点属于{1,2,…,k-1}取得的一条最短路径。p2 是从 k 到 j 且中间节点属于{1, 2,…,k-1}取得的一条最短路径。

文章配图

文章配图

即 Floyd 算法本质是三维动态规划,D[i][j][k] 表示从点 i 到点 j 只经过 0 到 k 个点最短路径,然后建立 起转移方程,然后通过空间优化,优化掉最后一维度,变成一个最短路径的迭代算法,最后即得 到所以点的最短路。

文章配图

void FloydWarshall(vector<vector<W>>& vvDist, vector<vector<int>>& vvpPath) {
    size_t n = _vertexs.size();
    vvDist.resize(n);
    vvpPath.resize(n);
    // 初始化权值和路径矩阵
    for (size_t i = 0; i < n; ++i) {
        vvDist[i].resize(n, MAX_W);
        vvpPath[i].resize(n, -1);
    }
    // // 将直接相连的路径初始化,更新一下
    for (size_t i = 0; i < n; ++i) {
        for (size_t j = 0; j < n; ++j) {
            if (_matrix[i][j] != MAX_W) {
                vvDist[i][j] = _matrix[i][j];
                vvpPath[i][j] = i;
            }
            if (i == j) {
                vvDist[i][j] = W();
            }
        }
    }
    // abcdef a {} f || b {} c
    // 最短路径的更新 i-> {其他顶点} ->j
    for (size_t k = 0; k < n; ++k) {
        for (size_t i = 0; i < n; ++i) {
            for (size_t j = 0; j < n; ++j) {
                // 依次用顶点 k 作为中转点尝试去更新 i->j 的路径
                if (vvDist[i][k] != MAX_W && vvDist[k][j] != MAX_W && vvDist[i][k] + vvDist[k][j] < vvDist[i][j]) {
                    vvDist[i][j] = vvDist[i][k] + vvDist[k][j];
                    // 找跟 j 相连的上一个邻接顶点
                    // 如果 k->j 直接相连,上一个点就 k,vvpPath[k][j] 存就是 k
                    // 如果 k->j 没有直接相连,k->...->x->j,vvpPath[k][j] 存就是 x
                    vvpPath[i][j] = vvpPath[k][j];
                }
            }
        }
    }
}
void TestFloydWarShall() {
    const char* str = "12345";
    Graph<char, int, INT_MAX, true> g(str, strlen(str));
    g.AddEdge('1', '2', 3);
    g.AddEdge('1', '3', 8);
    g.AddEdge('1', '5', -4);
    g.AddEdge('2', '4', 1);
    g.AddEdge('2', '5', 7);
    g.AddEdge('3', '2', 4);
    g.AddEdge('4', '1', 2);
    g.AddEdge('4', '3', -5);
    g.AddEdge('5', '4', 6);
    vector<vector<int>> vvDist;
    vector<vector<int>> vvParentPath;
    g.FloydWarShall(vvDist, vvParentPath);
    // 打印任意两点之间的最短路径
    for (size_t i = 0; i < strlen(str); ++i) {
        g.PrinrtShotPath(str[i], vvDist[i], vvParentPath[i]);
        cout << endl;
    }
}

注:文中图片结论参考了《算法导论》和《殷人昆 数据结构:用面向对象方法与 C++ 语言描述(第二版)》的内容

目录

  1. 1. 图的基本概念
  2. 2. 图的存储结构
  3. 2.1 邻接矩阵
  4. 2.2 邻接表
  5. 1. 无向图邻接表存储
  6. 2. 有向图邻接表存储
  7. 3. 图的遍历
  8. 3.1 图的广度优先遍历
  9. 3.2 图的深度优先遍历
  10. 3.3 非连通图情形
  11. 4. 最小生成树
  12. 4.1 Kruskal 算法
  13. 4.2 Prim 算法
  14. 5. 最短路径
  15. 5.1 单源最短路径--Dijkstra 算法
  16. 5.2 单源最短路径--Bellman-Ford 算法
  17. 5.3 多源最短路径--Floyd-Warshall 算法
  • 💰 8折买阿里云服务器限时8折了解详情
  • Magick API 一键接入全球大模型注册送1000万token查看
  • 🤖 一键搭建Deepseek满血版了解详情
  • 一键打造专属AI 智能体了解详情
极客日志微信公众号二维码

微信扫一扫,关注极客日志

微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

更多推荐文章

查看全部
  • C++ 一级等级考试真题解析
  • Llama 3-8B-Instruct 在昇腾 NPU 上的 SGLang 性能实测
  • RAG 系统查询优化技巧:扩展、分解、消歧与抽象
  • Fun-ASR 在中文普通话任务准确率超越 Whisper-small 近 5 个百分点
  • LongCat-Image-Editn 效果展示:博物馆文物图添加 AR 扫描框与说明文字
  • nanobot 轻量级 AI Agent 框架搭建 QQ 机器人实践与开源贡献
  • 开源 AI 网络搜索工具 OpenWebSearch MCP 支持多引擎与流式响应
  • Java 及 Spring 环境下的 Redis 操作实战
  • MySQL GROUP_CONCAT 函数:将多行数据合并成一行
  • 使用 AI 快速构建在线 CRM 原型验证产品思路
  • Amazon SageMaker 部署 AIGC 应用:模型训练、优化与 Web 前端集成
  • 25% → 90%!别让 Skills 吃灰:Hooks + Commands + Agents 协同激活 AI 全部能力:Claude Code 工程化实践
  • 基于 OpenClaw 与 Claude 的自动化写作工作流搭建
  • 安卓 Termux 部署 AstrBot 与 NapCat 搭建 QQ 机器人指南
  • 5 款开源量化 AI 交易工具实战指南
  • 利用 RAII 实现 C++ 作用域退出钩子:手写类似 Go defer 的宏
  • JavaWeb 核心:JSON 数据交换与 Ajax 异步请求详解
  • 删除排序链表中的重复元素 II(链表)
  • OpenClaw:AI Agent 框架的安全挑战与未来演进
  • OpenClaw 开源 AI 智能体安装与配置指南

相关免费在线工具

  • 加密/解密文本

    使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online

  • Gemini 图片去水印

    基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online

  • Markdown转HTML

    将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online

  • HTML转Markdown

    将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online