数据结构(c++)学习笔记--图


一、概述

1.基本术语

  • G = (V; E)
    • vertex: n = |V|
    • edge|arc: e = |E|

pSNopG9.png

    • 同一条边的两个顶点,彼此邻接(adjacency)
    • 同一顶点自我邻接,构成自环(self-loop)
    • 不含自环及重边,即为简单图(simple graph)
    • 非简单(non-simple)图,暂不讨论
  • 顶点与其所属的边,彼此关联(incidence)

  • 度(degree/valency):与同一顶点关联的边数

2.无向图 + 有向图

pSNoyoF.png

  • 若邻接顶点u和v的次序无所谓 则(u, v)为无向边(undirected edge)

  • 所有边均无方向的图,即无向图(undigraph)

  • 反之,有向图(digraph)中均为有向边(directed edge) u、v分别称作边(u, v)的尾(tail)、头(head)

  • 无向边、有向边并存的图,称作混合图(mixed graph)

3.路径 + 环路

pSNojyt.png

  • 路径$π=<v_0,v_1,…,v_k> ,长度|π| = k $

  • 简单路径: v i ≠ v j v_i ≠ v_j vi=vj 除非 i = j

  • 环/环路: v 0 = v k v_0 = v_k v0=vk

  • 有向无环图(DAG)

  • 欧拉环路:$|π| = |E| $,各边恰好出现一次

  • 哈密尔顿环路:$|π| = |V| $,各顶点恰好出现一次

4.支撑树 + 带权网络 + 最小支撑树

pSNT861.png

  • 图G = (V; E)的子图T = (V; F)若是树,即为其支撑树(spanning tree) 同一图的支撑树,通常并不唯一

  • 各边e均有对应的权值wt(e),则为带权网络(weighted network)

  • 同一网络的支撑树中,总权重最小者为最小支撑树(MST)

二、邻接矩阵

1.构思

1.1 Graph 模板类

template<typename Tv,typename Te> class Graph { 
private: 
    void reset() { //所有顶点、边的辅助信息复位 
        for ( Rank v = 0; v < n; v++ ) { //顶点 
            status(v) = UNDISCOVERED; 
            dTime(v) = fTime(v) = -1; 
            parent(v) = -1; 
            priority(v) = INT_MAX; 
            for ( Rank u = 0; u < n; u++ ) //边 
                if ( exists(v, u) ) type(v, u) = UNDETERMINED; 
        } 
    } 
public: 
    int n, e; //顶点、边数目 
    /* ... 顶点操作、边操作、图算法:无论如何实现,接口必须统一 ... */ 
}

1.2 邻接矩阵 + 关联矩阵

  • 邻接矩阵(adjacency matrix):记录顶点之间的邻接关系
    • 一一对应:矩阵元素 ⇔ 图中可能存在的边

    • A(v, u) = 1 (若顶点v与u之间存在一条边)/= 0 (否则)

    • 既然只考察简单图,对角线统一设置为0

    • 空间复杂度为(n2),与图中实际的边数无关

pSNHEZV.png

  • 关联矩阵(incidence matrix):记录顶点与边之间的关联关系
    • 空间复杂度为 Θ ( n ∗ e ) = O ( n 3 ) Θ(n*e) = O(n^3) Θ(ne)=O(n3)

    • 空间利用率 = 2e/ne = 2/n

    • 解决某些问题时十分有效

pSNHmiF.png

1.3 实例

pSNHuRJ.png

2.模板实现

//Vertex
using VStatus = enum { UNDISCOVERED, DISCOVERED, VISITED }; 
template<typename Tv> struct Vertex { //不再严格封装 
    Tv data; int inDegree, outDegree; 
    VStatus status; //(如上三种)状态 
    int dTime, fTime; //时间标签 
    Rank parent; //在遍历树中的父节点 
    int priority; //在遍历树中的优先级(最短通路、极短跨边等) 
    Vertex( Tv const & d ) : //构造新顶点 
        data( d ), inDegree( 0 ), outDegree( 0 ), status( UNDISCOVERED ), 
        dTime( -1 ), fTime( -1 ), parent( -1 ), priority( INT_MAX ) {} 
}

//Edge
using EType = enum { UNDETERMINED, TREE, CROSS, FORWARD, BACKWARD }; 
template<typename Te> struct Edge { //不再严格封装 
    Te data; //数据
    int weight; //权重 
    EType type; //在遍历树中所属的类型 
    Edge( Te const & d, int w ) : //构造新边 
        data(d), weight(w), type(UNDETERMINED) {} 
};

//GraphMatrix
template<typename Tv,typename Te> class GraphMatrix : public Graph { 
private: 
    Vector< Vertex > V; //顶点集 
    Vector< Vector< Edge* > > E; //边集 
public: // 操作接口:顶点相关、边相关、... 
    GraphMatrix() { n = e = 0; } 
    ~GraphMatrix() { 
    for ( Rank v = 0; v < n; v++ ) 
        for ( Rank u = 0; u < n; u++ ) 
            delete E[v][u]; //清除所有边记录 
    } 
}

3.静态操作

3.1 顶点的读写

pSNbSw6.png

Tv & vertex(Rank v) { return V[v].data; } //数据 
int inDegree(Rank v) { return V[v].inDegree; } //入度 
int outDegree(Rank v) { return V[v].outDegree; } //出度 
VStatus & status(Rank v) { return V[v].status; } //状态 
int & dTime(Rank v) { return V[v].dTime; } //时间标签dTime 
int & fTime(Rank v) { return V[v].fTime; } //时间标签fTime 
Rank & parent(Rank v) { return V[v].parent; } //在遍历树中的父亲 
int & priority(Rank v) { return V[v].priority; } //优先级数

3.2 边的读写
pSNbkSH.png

bool exists( Rank v, Rank u ) { //判断边(v, u)是否存在(短路求值) 
    return (v < n) && (u < n) && E[v][u] != NULL; 
} //以下假定exists(v, u) = true 

Te & edge( Rank v, Rank u ) { return E[v][u]->data; } //数值 
EType & type( Rank v, Rank u ) { return E[v][u]->type; } //类型 
int & weight( Rank v, Rank u ) { return E[v][u]->weight; } //权重

3.3 邻点的枚举

pSNbAld.png

Rank firstNbr( Rank v ) { return nextNbr( v, n ); } //假想哨兵 

Rank nextNbr( Rank v, Rank u ) { //若已枚举至邻居u,则转向下一邻居 
    while ( -1 < u ) && ! exists( v, --u ) ); //逆向顺序查找 
    return u; 
} //O(n)——改用邻接表,可提高至O(1 + outDegree(v))

4.动态操作

4.1 边的插入

pSNbMtS.png

void insert( Te const & edge, int w, Rank v, Rank u ) { 
    if ( exists(v, u) ) return; //忽略已有的边 
    E[v][u] = new Edge( edge, w ); //创建新边(权重为w) 
    e++; //更新边计数 
    V[v].outDegree++; //更新顶点v的出度 
    V[u].inDegree++; //更新顶点u的入度
}

4.2 边的删除

pSNbYmq.png

Te remove( Rank v, Rank u ) { //删除(已确认存在的)边(v, u) 
    Te eBak = edge(v, u); //备份边(v, u)的信息 
    delete E[v][u]; E[v][u] = NULL; //删除边(v, u) 
    e--; //更新边计数 
    V[v].outDegree--; //更新顶点v的出度 
    V[u].inDegree--; //更新顶点u的入度 
    return eBak; //返回被删除边的信息
}

4.3 顶点插入

pSNbakT.png

Rank insert( Tv const & vertex ) { //插入顶点,返回编号 
    for ( Rank u = 0; u < n; u++ ) E[u].insert( NULL ); n++; //① 
    E.insert( Vector< Edge* >( n, n, NULL ) ); //②③ 
    return V.insert( Vertex( vertex ) ); //④
}

4.4 顶点删除

Tv remove( Rank v ) { //删除顶点及其关联边,返回该顶点信息 
    for ( Rank u = 0; u < n; u++ ) //删除所有出边 
        if ( exists( v, u ) ) { delete E[v][u]; V[u].inDegree--; e-- } 
    E.remove(v); n--; //删除第v行 
    Tv vBak = vertex( v ); V.remove( v ); //备份之后,删除顶点v 
    for ( Rank u = 0; u < n; u++ ) //删除所有入边及第v列 
        if ( Edge * x = E[u].remove( v ) ) { delete x; V[u].outDegree--; e--; } 
    return vBak; //返回被删除顶点的信息
}

5.性能分析

5.1 优点

  • 直观,易于理解和实现

  • 适用范围广泛 尤其适用于稠密图(dense graph)

  • 判断两点之间是否存在联边:O(1)

  • 获取顶点的(出/入)度数:O(1)

  • 添加、删除边后更新度数:O(1)

  • 扩展性(scalability):得益于Vector良好的控制策略,空间溢出等情况可被“透明地”处理

5.2 缺点

  • Θ ( n 2 ) Θ(n^2) Θ(n2)空间,与边数无关!

  • 平面图(planar graph):可嵌入于平面的图

  • 欧拉公式(Euler’s formula): v - e + f - c = 1, for any PG

  • 平面图:e ≤ 3n - 6 = O(n) << n 2 n^2 n2,此时空间利用率 ≈ 1/n

  • 稀疏图(sparse graph):空间利用率同样很低,可采用压缩存储技术

三、邻接表

1.邻接表

  • 如何避免邻接矩阵的空间浪费?
  • 将邻接矩阵的各行组织为列表,只记录存在的边,等效于,每一顶点v对应于列表: L v = L_v= Lv={u | <v,u> ∈E }

pSNbc0x.png

2.复杂度

2.1 空间复杂度

  • 有向图 = O(n + e)

  • 无向图 = O(n + 2e) = O(n + e)

    • 注意:无向弧被重复存储
  • 适用于稀疏图

  • 平面图 = O(n + 3n) = O(n) 较之邻接矩阵,有极大改

2.2 时间复杂度

  • 建立邻接表(递增式构造):O( n + e )

  • 枚举所有以顶点v为尾的弧:O( 1 + deg(v) ) //遍历v的邻接表

  • 枚举(无向图中)顶点v的邻居:O( 1 + deg(v) ) //遍历v的邻接表

  • 枚举所有以顶点v为头的弧:O( n + e ) //遍历所有邻接表

    • 可改进至O( 1 + deg(v) ) //建立逆邻接表
  • 计算顶点v的出度/入度:

    • 增加度数记录域:O( n )附加空间

    • 增加/删除弧时更新度数:O( 1 )时间 //总体O(e)时间

    • 每次查询:O(1)时间!

3.取舍原则

邻接矩阵邻接表
适 用 场 合经常检测边的存在,经常做边的插入/删除,图的规模固定,稠密图经常计算顶点的度数,顶点数目不确定,经常做遍历,稀疏图

四、广度优先算法

1.算法

始自顶点s的广度优先搜索 
    访问顶点s 
    依次访问s所有尚未访问的邻接顶点 
    依次访问它们尚未访问的邻接顶点 
    ... 
    如此反复 
    直至没有尚未访问的邻接顶点
  • 以上策略及过程完全等同于树的层次遍历 ,事实上,BFS也的确会构造出原图的一棵支撑树(BFS tree)

pSNbv9g.png

template<typename Tv,typename Te> 
void Graph::BFS( Rank v, int & clock ) { 
    Queue Q; status(v) = DISCOVERED; Q.enqueue(v); //初始化 
    while ( ! Q.empty() ) { 
        Rank v = Q.dequeue(); dTime(v) = ++clock; //取出队首顶点v,并 
        for ( Rank u = firstNbr(v); -1 < u; u = nextNbr(v, u) ) //考查每一邻居u
            if ( UNDISCOVERED == status(u) ) { //若u尚未被发现,则 
                status(u) = DISCOVERED; Q.enqueue(u); //发现该顶点 
                type(v, u) = TREE; parent(u) = v; //引入树边 
            } else //若u已被发现(正在队列中),或者甚至已访问完毕(已出队列),则 
                type(v, u) = CROSS; //将(v, u)归类于跨边
        tatus(v) = VISITED; //至此,当前顶点访问完毕 
    } 
}

2.实例

pSNqFEV.png

3.推广

3.1 连通分量 + 可达分量

  • 问题

    • 给定无向图,找出其中任一顶点s所在的连通图
    • 给定有向图,找出源自其中任一顶点s的可达分量
  • 算法

    • 从s出发做BFS
    • 输出所有被发现的顶点
    • 队列为空后立即终止,无需考虑其它顶点

3.2 Graph::bfs()

template<typename Tv,typename Te> 
void Graph::bfs( Rank s ) { //s为起始顶点 
    reset(); int clock = 0; Rank v = s; //初始化Θ(n+e) 
    do //逐一检查所有顶点,一旦遇到尚未发现的顶点 
        if ( UNDISCOVERED == status(v) ) //累计Θ(n) 
            BFS( v, clock ); //即从该顶点出发启动一次BFS 
    while ( s != ( v = ( ( v+1 ) % n ) ) ); //按序号访问,不漏不重 
} //无论共有多少连通/可达分量...

3.3 复杂度

  • bfs()的初始化(reset()):O(n+e)

  • BFS()的迭代

    • 外循环(while ( !Q.empty() )),每个顶点各进入1次
    • 内循环(枚举v的每一邻居): O(1+deg(v))(改用邻接表)
    • 总共: O ( ∑ v ∈ V ( 1 + d e g ( v ) ) ) = O ( n + 2 e ) O(\sum_{v∈V}(1+deg(v)))=O(n+2e) O(vV(1+deg(v)))=O(n+2e)
  • 整个算法:O(n+e)+O(n+2e)=O(n+e)

4.性质及规律

4.1 边分类

  • 经BFS后,所有边将确定方向,且被分为两类

  • (v,u)被标记为TREE时,v为DISCOVERED且u为UNDISCOVERED

pSNqegJ.png

  • (v,u)被标记为CROSS时,v和u均为DISCOVERED 或者 v为DISCOVERED而u为VISITED

pSNqmv9.png

  • 不论(v,u)是有向边或无向边,两种情况均可能出现

4.2 BFS树/森林

  • 对于(起始于v的)每一连通/可达分量,bfs()进入BFS(v)恰好1次

  • 进入BFS(v)时,队列为空;v所属分量内的每个顶点

    • 迟早会以UNDISCOVERED状态进队1次

    • 进队后随即转为DISCOVERED状态,并生成一条树边

    • 迟早会出队并转为VISITED状态

    • 退出BFS(v)时,队列为空

  • BFS(v)以v为根,生成一棵BFS树

  • bfs()生成一个BFS森林包含 c 棵树、n-c 条树边和 e-n+c 条跨边

4.3 最短路径

  • 无向图中,顶点v到u的(最近)距离记作dist(v, u)

  • BFS过程中,队列Q犹如一条贪吃蛇

    • 其中的顶点按dist(s)单调排列

    • 相邻顶点的dist(s)相差不超过1

    • 首、末顶点的dist(s)相差不超过1

    • 由树边联接的顶点,dist(s)恰好相差1

    • 由跨边联接的顶点,dist(s)至多相差1

  • BFS树中从s到v的路径,即是二者在原图中的最短通路

pSNqMHx.png

五、深度优先搜索

1.算法

 DFS(s) //始自顶点s的深度优先搜索
     访问顶点s
     若s尚有未被访问的邻居,则 任取其一u,递归执行DFS(u)
     否则,返回 
 若此时尚有顶点未被访问,任取这样的一个顶点作起始点 
  • 重复上述过程,直至所有顶点都被访问到

  • 对树而言,等效于先序遍历:DFS也的确会构造出原图的一棵支撑树(DFS tree)

pSNqlE6.png

template<typename Tv,typename Te>  
void Graph::DFS( Rank v, int & clock ) { 
    dTime(v) = ++clock; status(v) = DISCOVERED; //发现当前顶点v 
    for ( Rank u = firstNbr(v); -1 < u; u = nextNbr(v, u) ) //考察v的每一邻居u 
        switch ( status(u) ) { //并视其状态分别处理 
            case UNDISCOVERED: //u尚未发现,意味着支撑树可在此拓展 
                type(v, u) = TREE; parent(u) = v; DFS( u, clock ); break; //递归 
            case DISCOVERED: //u已被发现但尚未访问完毕,应属被后代指向的祖先 
                type(v, u) = BACKWARD; break; 
            default: //u已访问完毕(VISITED,有向图),则视承袭关系分为前向边或跨边 
                type(v, u) = dTime(v) < dTime(u) ? FORWARD : CROSS; break; 
        }
    status(v) = VISITED; fTime(v) = ++clock; //至此,当前顶点v方告访问完毕
}

2.实例(无向图)

pSNq34O.png

YYwZ1.png

YYq1n.png

3.推广

  • 与BFS(v)类似,DFS(v)也可遍历v所属分量

  • 与bfs(s)类似(采用邻接表),dfs(s)也可在累计O(n+e)时间内

    • 对于每一连通/可达分量,从其起始顶点v进入DFS(v)恰好1次,并最终生成一个DFS森林(包含 c 棵树、n-c 条树边)

pSUVUMR.png

template<typename Tv,typename Te> 
void Graph::dfs( Rank s ) { //s为起始顶点 
    reset(); int clock = 0; Rank v = s; //初始化 
    do //逐一检查所有顶点,一旦遇到尚未发现的顶点v 
        if ( UNDISCOVERED == status(v) ) 
            DFS( v, clock ); //即从v出发启动一次DFS 
    while ( s != ( v = ( ( v+1 ) % n ) ) ); //按序号访问,不漏不重 
}

4.实例(有向图)

pSUVDIO.png

pSUVczd.png

pSUVRsI.png

pSUVWLt.png

5.性质

5.1 DFS树/森林

  • 从顶点s出发的DFS

    • 在无向图中将访问与s连通的所有顶点(connectivity)
    • 在有向图中将访问由s可达的所有顶点(reachability)
  • 经DFS确定的树边,不会构成回路

  • 从s出发的DFS,将以s为根生成一棵DFS树;所有DFS树,进而构成DFS森林

  • DFS树及森林由parent指针描述(只不过所有边取反向)

5.2 活跃期 & 括号引理

  • active[u]=(dTime[u],fTime[u])

  • 括号定理(Parenthesis Lemma):给定有向图G = (V, E)及其任一DFS森林,则

    • u是v的后代 iff
    • u是v的祖先 iff
    • u与v“无关”iff
  • 仅凭status[]、dTime[]和fTime[] 即可对各边分类.

pSUV4df.png

5.3 边分类

  • TREE(v, u): 可从当前v进入处于UNDISCOVERED状态的u

YYN42.png

  • BACKWARD(v, u): 试图从当前v进入处于DISCOVERED状态的u,DFS发现后向边 iff 存在回路

YYgKP.png

  • FORWARD(v, u): 试图从当前顶点v进入处于VISITED状态的u,且v更早被发现

YYGHD.png

  • CROSS(v, u): 试图从当前顶点v进入处于VISITED状态的u,且u更早被发现

YYKLj.png

5.4 遍历算法应用举例

连通图的支撑树(DFS/BFS Tree)DFS/BFS
非连通图的支撑森林DFS/BFS
连通性检测DFS/BFS
无向图环路检测/二部图判定DFS/BFS
有向图环路检测DFS
顶点之间可达性检测/路径求解DFS/BFS
顶点之间的最短距离BFS
直径/半径/围长/中心BFS
欧拉回路DFS
拓扑排序DFS
双连通分量、强连通分量分解DFS

六、拓扑排序

1.零入度算法

1.1 有向无环图(Directed Acyclic Graph)

  • 应用
    • 类派生和继承关系图中,是否存在循环定义
    • 操作系统中相互等待的一组线程,如何调度
    • 给定一组相互依赖的课程,设计可行的培养方案
    • 给定一组相互依赖的知识点,设计可行的教学进度方案
    • 项目工程图中,设计可串行施工的方案
    • email系统中,是否存在自动转发或回复的回路

YYVvS.png

1.2 拓扑排序

  • 任给有向图G(不一定是DAG),尝试 将所有顶点排成一个线性序列,使其次序须与原图相容 (每一顶点都不会通过边指向前驱顶点)
  • 接口要求
    • 若原图存在回路(即并非DAG),检查并报告
    • 否则,给出一个相容的线性序列

YYdEL.png

1.3 偏序 ~ 极值

  • 每个DAG对应于一个偏序集;拓扑排序对应于一个全序集 所谓的拓扑排序,即构造一个与指定偏序集相容的全序集

  • 可以拓扑排序的有向图,必定无环

  • 任何DAG,都存在(至少)一种拓扑排序

  • 有限偏序集必有极值元素

1.4 存在性

  • 任何有向无环图g中 必有一个零入度的顶点m

  • 若g{m}存在拓扑排序S={ v k 1 , v k 2 , . . . , v k n − 1 v_{k_1},v_{k_2},...,v_{k_{n-1}} vk1,vk2,...,vkn1},则 S ′ S' S={ m , v k 1 , v k 2 , . . . , v k n − 1 m,v_{k_1},v_{k_2},...,v_{k_{n-1}} m,vk1,vk2,...,vkn1}即为g的拓扑排序

  • 只要m不唯一,拓扑排序也应不唯一

1.5 策略:顺序输出零入度顶点

将所有入度为零的顶点存入栈S,取空队列Q //O(n) 
while ( ! S.empty() ) { //O(n) 
    Q.enqueue( v = S.pop() ); //栈顶v转入队列 
    for each edge( v, u ) //v的邻接顶点u若入度仅为1 
        if ( u.inDegree < 2 ) S.push( u ); //则入栈 
    G = G \ { v }; //删除v及其关联边(邻接顶点入度减1) 
} //总体O(n + e) 
return |G| ? "NOT_A_DAG"Q; //残留的G空,当且仅当原图可拓扑排序

1.6 实例
YYfZt.png

2.零出度算法

2.1 策略:逆序输出零出度顶点

//基于DFS,借助栈S 
对图GDFS,其间 //得到组成DFS森林的一系列DFS树 
    每当有顶点被标记为VISITED,则将其压入S 
    一旦发现有后向边,则报告“NOT_A_DAG”并退出
DFS结束后,顺序弹出S中的各个顶点
  • 各节点按fTime逆序排列,即是拓扑排序
  • 复杂度与DFS相当,也是O(n+e)

2.2 实例

YYrFq.png

template<typename Tv,typename Te> //顶点类型、边类型 
bool Graph::TSort( Rank v, int & clock, Stack* S ) { 
    dTime(v) = ++clock; status(v) = DISCOVERED; //发现顶点v 
    for ( Rank u = firstNbr(v); u < UINT_MAX; u = nextNbr(v, u) ) //考查v的每一邻居u 
        switch ( status(u) ) { //并视u的状态分别处理 
            case UNDISCOVERED: 
                parent(u) = v; type(v, u) = TREE; 
                if ( ! TSort(u, clock, S) ) return false; 
                break; //从顶点u处深入 
            case DISCOVERED: //一旦发现后向边(非DAG) 
                type(v, u) = BACKWARD; 
                return false; //则退出而不再深入 
            default: //VISITED (digraphs only) 
                type(v, u) = dTime(v) < dTime(u) ? FORWARD : CROSS; break; 
        }
    status(v) = VISITED; S->push( vertex(v) ); //顶点被标记为VISITED时入栈 
    return true; 
}
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值