Skip to content

图与树的基本概念

By 4627488

幻灯片PDF

关键词:图、邻接矩阵、邻接表、树、遍历算法

图的基本概念

定义与组成

G=(V,E),其中 V 是顶点集合,E 是边集合。

  • 顶点(Vertex):图中的节点。
  • 边(Edge):顶点间的连接关系。

alt text

图的分类

类型特点例子
无向图边无方向,(u,v)=(v,u)社交网络好友关系
有向图边有方向,uvvu网页超链接关系

G 的每条边 ek=(uk,vk) 都被赋予一个数作为该边的 ,则称 G赋权图。如果这些权都是正实数,就称 G正权图

形象地说,图是由若干点以及连接点与点的边构成的。

度(Degree)

  • 无向图:顶点连接的边数。

  • 有向图:入度(指向顶点的边数)、出度(顶点指向外部的边数)。

路径与环路

  • 途径 (walk):途径是连接一连串顶点的边的序列,可以为有限或无限长度。

    定义

    形式化地说,一条有限途径 w 是一个边的序列 e1,e2,,ek,使得存在一个顶点序列 v0,v1,,vk 满足 ei=(vi1,vi),其中 i[1,k]。这样的途径可以简写为 v0v1v2vk。通常来说,边的数量 k 被称作这条途径的 长度(如果边是带权的,长度通常指途径上的边权之和,题目中也可能另有定义)。

  • 路径(path):顶点序列 v1v2vk,相邻顶点间有边。
  • 简单路径(simple path):没有重复顶点的路径。
  • 环路/圈(cycle):起点和终点相同的路径(如 v1v2v1)。
  • 自环:起点和终点相同的边(如 (v1,v1))。
  • 重边:连接同一顶点的多条边(如 (v1,v2)(v1,v2))。

    注意

    在无向图中 (u,v)(v,u) 算一组重边,而在有向图中,uvvu 不为重边。

    在题目中,如果没有特殊说明,是可以存在自环和重边的,在做题时需特殊考虑。

连通性

  • 连通图:任意两顶点间存在路径(无向图)。
  • 强连通图:任意两顶点双向可达(有向图)。

子图

无向图

  • 定义:G=(V,E)G=(V,E) 的子图,当且仅当 VVEE
  • 若对 HG,满足 u,vV,只要 (u,v)E,均有 (u,v)E,则称 HG导出子图/诱导子图 (induced subgraph)

有向图

  • 定义:G=(V,E)G=(V,E) 的子图,当且仅当 VVEE
  • 若对 HG,满足 u,vV,只要 uvE,均有 uvE,则称 HG导出子图/诱导子图 (induced subgraph)

连通

  • 无向图:对于一张无向图 G=(V,E),对于 u,vV,若存在一条途径使得 v0=u,vk=v,则称 uv连通的 (connected)。由定义,任意一个顶点和自身连通,任意一条边的两个端点连通。 若一张无向图的节点两两互相连通,则称这张图是 连通的 (connected)

  • 有向图:对于一张有向图 G=(V,E),对于 u,vV,若存在一条途径使得 v0=u,vk=v,则称 u 可达 v。由定义,任意一个顶点可达自身,任意一条边的起点可达终点。(无向图中的连通也可以视作双向可达。) 若一张有向图的节点两两互相可达,则称这张图是 强连通的 (strongly connected)

应用场景

  • 社交网络:无向图表示用户对称关系。
  • 交通导航:权重图优化最短路径(Dijkstra算法)。
  • 状态机建模:有向图描述系统状态转移(如JK触发器制作模20计数器)。

图的存储方式

1. 邻接矩阵

  • 实现方式:
    • 二维数组 matrix[u][v] 表示顶点 uv 的连接关系。
    • 权重图:matrix[u][v] 存储权重值,无边时标记为 0
  • 复杂度分析:
    操作时间复杂度空间复杂度
    查询边是否存在O(1)O(V2)
  • 适用场景:稠密图(边数接近顶点数平方)。

2. 邻接表

  • 实现方式:
    • 每个顶点维护一个链表/数组,存储其所有邻接顶点。
    • 权重图:存储邻接顶点及权重(如 (v, weight))。
  • 复杂度分析:
    操作时间复杂度空间复杂度
    遍历某顶点的邻接点O(d)d 为度)O(V+E)
  • 适用场景:稀疏图(边数远小于顶点数平方)。

3. 存储方式对比

特性邻接矩阵邻接表
空间效率低(稠密图适用)高(稀疏图适用)
查询边效率O(1)O(d)
动态增删边效率O(1)O(1)(链表实现)
适用算法Floyd-WarshallDFS/BFS

树的基本性质

1. 定义与特性

  • 树是特殊的图:
    • 连通无环的无向图。
    • 数学性质:|E|=|V|1
  • 森林:由多棵树组成的非连通无环图。

2. 树的结构分类

  • 根树(Rooted Tree):
    • 层次结构:根节点、父节点、子节点。
    • 示例:文件系统目录树。
  • 二叉树(Binary Tree):
    • 每个节点最多有两个子节点(左子节点、右子节点)。
    • 特殊类型:
      • 满二叉树:所有非叶节点均有2个子节点。
      • 完全二叉树:除最后一层外,其他层节点全满。

有关树的定义

适用于无根树和有根树

  • 森林(forest):每个连通分量(连通块)都是树的图。按照定义,一棵树也是森林。

  • 生成树(spanning tree):一个连通无向图的生成子图,同时要求是树。也即在图的边集中选择 n1 条,将所有顶点连通。

  • 无根树的叶结点(leaf node):度数不超过 1 的结点。(考虑 n=1。)

  • 有根树的叶结点(leaf node):没有子结点的结点。

只适用于有根树

  • 父亲(parent node):对于除根以外的每个结点,定义为从该结点到根路径上的第二个结点。
    根结点没有父结点。
  • 祖先(ancestor):一个结点到根结点的路径上,除了它本身外的结点。
    根结点的祖先集合为空。
  • 子结点(child node):如果 uv 的父亲,那么 vu 的子结点。
    子结点的顺序一般不加以区分,二叉树是一个例外。
  • 结点的深度(depth):到根结点的路径上的边数。
  • 树的高度(height):所有结点的深度的最大值。
  • 兄弟(sibling):同一个父亲的多个子结点互为兄弟。
  • 后代(descendant):子结点和子结点的后代。或者理解成:如果 uv 的祖先,那么 vu 的后代。

树的遍历算法

深度优先遍历(DFS)

递归实现模板(以二叉树为例):

Python
def dfs(node):  
    if node is None:  
        return  
    # 前序遍历  
    print(node.val)  
    dfs(node.left)  
    dfs(node.right)  
    # 中序:调整print位置  
    # 后序:调整print位置
C++
void dfs(TreeNode* node) {
    if (node == nullptr) return;
    // 前序遍历
    cout << node->val << endl;
    dfs(node->left);
    dfs(node->right);

    // 中序:调整print位置
    // 后序:调整print位置
}
  • 应用场景:
    • 前序:克隆树结构、序列化。
    • 中序:二叉搜索树(BST)升序输出。
    • 后序:释放树内存(先处理子节点)。

广度优先遍历(BFS)

队列辅助实现

Python
from collections import deque  
def bfs(root):  
    queue = deque([root])  
    while queue:  
        node = queue.popleft()  
        print(node.val)  
        if node.left:  
            queue.append(node.left)  
        if node.right:  
            queue.append(node.right)
C++
#include <queue>
using namespace std;

void bfs(TreeNode* root) {
    queue<TreeNode*> q;
    q.push(root);
    while (!q.empty()) {
        TreeNode* node = q.front();
        q.pop();
        cout << node->val << endl;
        if (node->left) q.push(node->left);
        if (node->right) q.push(node->right);
    }
}

应用场景:最短路径问题、社交网络好友推荐。

遍历结果对比

遍历方式输出顺序(例子:根1,左2,右3)
前序123
中序213
后序231
层次123

一种新的二叉树非递归遍历方法 AtomFirst

一种新的二叉树非递归遍历方法

点击查看代码
cpp
#include <iostream>
#include <cstring>
using namespace std;

typedef int Status;
#define RET_OK 1
#define RET_FAIL -1

#define ORDER_MAX_LEN 3

typedef struct BiNode
{
    char val;
    struct BiNode *lchild, *rchild;
} BiNode, *BiTree;

typedef struct StackNode
{
    BiTree T;
    int tagIndex; // r, L, R
} StackNode;

typedef struct Stack
{
    int top;
    StackNode data[100];
} Stack;

char gInputStr[100];
int gIndex = 0;
char gTraverseOrder[20]; // rLR, LrR, LRr

void InitStack(Stack &S)
{
    S.top = 0;
}

void Push(Stack &S, BiTree T, int tagIndex)
{
    S.data[S.top].T = T;
    S.data[S.top].tagIndex = tagIndex;
    S.top++;
}

Status Pop(Stack &S, BiTree &T, int &tagIndex)
{
    if (S.top == 0)
        return RET_FAIL;
    S.top--;
    T = S.data[S.top].T;
    tagIndex = S.data[S.top].tagIndex;
    return RET_OK;
}

bool IsEmpty(Stack S)
{
    return S.top == 0;
}
void TraverseBiTree(BiTree T, char order[])
{
    Stack S;
    InitStack(S);
    BiTree p = T;
    int tagIndex;

    Push(S, p, 0);
    while (!IsEmpty(S))
    {
        Pop(S, p, tagIndex);

        if (tagIndex + 1 < ORDER_MAX_LEN)
        {
            Push(S, p, tagIndex + 1);
        }
        switch (order[tagIndex])
        {
        case 'L':
            if (p->lchild)
                Push(S, p->lchild, 0);
            break;
        case 'R':
            if (p->rchild)
                Push(S, p->rchild, 0);
            break;
        case 'r':
            cout << p->val << " ";
            break;
        default:
            cerr << "invaild order!";
            exit(-1);
        }
    }
}

BiTree CreateBiTree()
{
    BiTree T;

    if (gInputStr[gIndex] == '#')
    {
        T = NULL;
        gIndex++;
    }
    else
    {
        T = (BiTree)malloc(sizeof(BiNode));
        T->val = gInputStr[gIndex];
        gIndex++;
        T->lchild = CreateBiTree();
        T->rchild = CreateBiTree();
    }
    return T;
}

void DestroyBiTree(BiTree T)
{
    if (T)
    {
        DestroyBiTree(T->lchild);
        DestroyBiTree(T->rchild);
        free(T);
    }
}

void PreOrder(BiTree T)
{
    if (T)
    {
        cout << T->val << " ";
        PreOrder(T->lchild);
        PreOrder(T->rchild);
    }
}

void InOrder(BiTree T)
{
    if (T)
    {
        InOrder(T->lchild);
        cout << T->val << " ";
        InOrder(T->rchild);
    }
}

void PostOrder(BiTree T)
{
    if (T)
    {
        PostOrder(T->lchild);
        PostOrder(T->rchild);
        cout << T->val << " ";
    }
}

int main()
{
    strcpy(gInputStr, "ABD#G###CE##F##");
    gIndex = 0;
    BiTree T = CreateBiTree();

    cout << "PreOrder: ";
    cout << "\n";
    strcpy(gTraverseOrder, "rLR");
    TraverseBiTree(T, gTraverseOrder);
    cout << "\n";
    PreOrder(T);
    cout << "\n";

    cout << "InOrder: ";
    cout << "\n";
    strcpy(gTraverseOrder, "LrR");
    TraverseBiTree(T, gTraverseOrder);
    cout << "\n";
    InOrder(T);
    cout << "\n";

    cout << "PostOrder: ";
    cout << "\n";
    strcpy(gTraverseOrder, "LRr");
    TraverseBiTree(T, gTraverseOrder);
    cout << "\n";
    PostOrder(T);
    cout << "\n";

    DestroyBiTree(T);

    return 0;
}
cpp
// https://github.com/AtomFirst/MyDataStructureHomework/blob/master/report/bitree.cpp
#include <iostream>
#include <sstream>
#include <stack>

using namespace std;

template <typename T>
struct BiNode
{
    T elem;
    BiNode *lson, *rson;
    BiNode(const T &elem) : elem(elem), lson(nullptr), rson(nullptr) {}
};

template <typename T>
BiNode<char> *create(T &&in)
{
    char cur;
    if (not(in >> cur) or cur == '#')
    {
        return nullptr;
    }

    BiNode<char> *rt = new BiNode<char>(cur);
    rt->lson = create(forward<T>(in));
    rt->rson = create(forward<T>(in));
    return rt;
}

template <typename T>
void PreOrderTraverse(BiNode<T> *rt)
{
    cout << (rt->elem) << " "; // addr 0
    if (rt->lson)
        PreOrderTraverse(rt->lson); // addr 1
    if (rt->rson)
        PreOrderTraverse(rt->rson); // addr 2
}

template <typename T>
void Traverse(BiNode<T> *rt, const char order[])
{
    cout << "Traverse(Order: " << order << "): ";

    stack<pair<BiNode<T> *, int>> s;
    s.emplace(rt, 0);
    while (s.size())
    {
        auto [x, i] = s.top();
        s.pop();
        if (i + 1 < 3)
            s.emplace(x, i + 1);
        switch (order[i])
        {
        case 'L':
            if (x->lson)
                s.emplace(x->lson, 0);
            break;
        case 'R':
            if (x->rson)
                s.emplace(x->rson, 0);
            break;
        case 'r':
            cout << (x->elem) << " ";
            break;
        default:
            cerr << "invaild order!";
            exit(-1);
        }
    }

    cout << endl;
}

int main()
{
    BiNode<char> *rt = create(istringstream("ABD#G###CE##F##"));

    Traverse(rt, "rLR");
    Traverse(rt, "LrR");
    Traverse(rt, "LRr");

    return 0;
}

递归函数转非递归的一般方法

  1. 找到函数的所有局部变量 S(包括参数)
  2. 用一个变量 PC 表示函数内应执行的下一条语句
  3. 使用栈存储 SPC
  4. 每次根据栈顶信息执行指令,并更新 SPC 及进行入栈(函数调用)和出栈(函数结束)操作

总结

关键知识点

  1. 图与树的关系:树是连通无环图,森林是多棵树。
  2. 存储方式选择:稠密图用邻接矩阵,稀疏图用邻接表。
  3. 树遍历的核心逻辑:DFS递归 / BFS队列。

思考题

  • 如何判断图是否为树?
    1. 检查是否连通(通过DFS/BFS遍历所有顶点)。
    2. 验证边数是否满足 |E|=|V|1

附录

扩展阅读