Skip to content

Latest commit

 

History

History
629 lines (564 loc) · 20.7 KB

File metadata and controls

629 lines (564 loc) · 20.7 KB

图的优势:灵活建模(有向/无向、加权/非加权); 缺点:存储O(V^2)(稠密图),遍历需vis标记防环。

概述

  • 核心思想:图由顶点(V)和边(E)组成。无向图边无方向,有向图有(弧)。加权图边有权值。
  • 分类:简单图(无重边/自环)、连通图(无向可达)、强连通(有向互达)。
  • 时间/空间复杂度:存储O(V+E),遍历O(V+E),最短路径O(V^2)(Dijkstra)。
  • 常见类型
    • 无向图 vs 有向图
    • 连通图 vs 非连通图
    • 有权图 vs 无权图
  • 重要术语:邻接、路径、度(入度/出度)

图的表示方法

邻接矩阵(2D数组,适合稠密图)

查询快O(1),但空间占用大O(n²) 在简单图(无自环的图)中,邻接矩阵的主对角线都为0。 使用一个二维数组(矩阵) 来记录图中各个顶点之间的连接关系。 给定一个顶点数量为𝑛的无向图,则各种操作的实现方式如图所示。

  • 添加或删除边:直接在邻接矩阵中修改指定的边即可,使用𝑂(1)时间。而由于是无向图,因此需要同 时更新两个方向的边。
  • 添加顶点:在邻接矩阵的尾部添加一行一列,并全部填0即可,使用𝑂(𝑛)时间。
  • 删除顶点:在邻接矩阵中删除一行一列。当删除首行首列时达到最差情况,需要将(𝑛−1)2个元素 “向左上移动”,从而使用𝑂(𝑛2)时间。
  • 初始化:传入𝑛个顶点,初始化长度为𝑛的顶点列表 小的邻接矩阵 adjMat ,使用𝑂(𝑛2)时间。 ![[Pasted image 20251115155458.png]]

代码

// === File: graph_adjacency_matrix.cpp ===

/* 基于邻接矩阵实现的无向图类 */
class GraphAdjMat {
    vector<int> vertices;           // 顶点列表,元素代表"顶点值",索引代表"顶点索引"
    vector<vector<int>> adjMat;     // 邻接矩阵,行列索引对应"顶点索引"

public:
    /* 构造方法 */
    GraphAdjMat(const vector<int> &vertices, const vector<vector<int>> &edges) {
        // 添加顶点
        for (int val : vertices) {
            addVertex(val);
        }
        // 添加边
        // 请注意,edges 元素代表顶点索引,即对应 vertices 元素索引
        for (const vector<int> &edge : edges) {
            addEdge(edge[0], edge[1]);// edge[0]和edge[1]是顶点索引
        }
    }

    /* 获取顶点数量 */
    int size() const {
        return vertices.size();
    }

    /* 添加顶点 */
    void addVertex(int val) {
        int n = size();
        // 向顶点列表中添加新顶点的值
        vertices.push_back(val);
        // 在邻接矩阵中添加一行
        adjMat.emplace_back(vector<int>(n, 0));
        // 在邻接矩阵中添加一列
        for (vector<int> &row : adjMat) {
            row.push_back(0);
        }
    }

    /* 删除顶点 */
    void removeVertex(int index) {
        if (index >= size()) {
            throw out_of_range("顶点不存在");
        }
        // 在顶点列表中移除索引 index 的顶点
        vertices.erase(vertices.begin() + index);
        // 在邻接矩阵中删除索引 index 的行
        adjMat.erase(adjMat.begin() + index);
        // 在邻接矩阵中删除索引 index 的列
        for (vector<int> &row : adjMat) {
            row.erase(row.begin() + index);
        }
    }

    /* 添加边 */
    // 参数 i, j 对应 vertices 元素索引
    void addEdge(int i, int j) {
        // 索引越界与相等处理
        if (i < 0 || j < 0 || i >= size() || j >= size() || i == j) {
            throw out_of_range("顶点不存在");
        }
        // 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i)
        adjMat[i][j] = 1;
        adjMat[j][i] = 1;
    }

    /* 删除边 */
    // 参数 i, j 对应 vertices 元素索引
    void removeEdge(int i, int j) {
        // 索引越界与相等处理
        if (i < 0 || j < 0 || i >= size() || j >= size() || i == j) {
            throw out_of_range("顶点不存在");
        }
        adjMat[i][j] = 0;
        adjMat[j][i] = 0;
    }

    /* 打印邻接矩阵 */
    void print() {
        cout << "顶点列表 = ";
        printVector(vertices);
        cout << "邻接矩阵 =" << endl;
        printVectorMatrix(adjMat);
    }
};

![[Pasted image 20251115160959.png]]

邻接表(推荐)(链表/向量,适合稀疏图)

空间节省O(n+m),但查询稍慢 邻接表结构与哈希表中的“链式地址”非常相似,因此我们也可以采用类似的方法来优化效率。 比如当链表较长时,可以将链表转化为AVL树或红黑树,从而将时间效率从𝑂(𝑛)优化至𝑂(log𝑛);还可 以把链表转换为哈希表,从而将时间复杂度降至𝑂(1)。

  • 添加边:在顶点对应链表的末尾添加边即可,使用𝑂(1)时间。因为是无向图,所以需要同时添加两个 方向的边。
  • 删除边:在顶点对应链表中查找并删除指定边,使用𝑂(𝑚)时间。在无向图中,需要同时删除两个方 向的边。
  • 添加顶点:在邻接表中添加一个链表,并将新增顶点作为链表头节点,使用𝑂(1)时间。
  • 删除顶点:需遍历整个邻接表,删除包含指定顶点的所有边,使用𝑂(𝑛+𝑚)时间。
  • 初始化:在邻接表中创建𝑛个顶点和2𝑚条边,使用𝑂(𝑛+𝑚)时间。 ![[Pasted image 20251115160730.png]]
  • **为了方便添加与删除顶点,以及简化代码,我们使用列表(动态数组)来代替链表。
  • 使用哈希表来存储邻接表,key为顶点实例, value 为该顶点的邻接顶点列表(链表)。 另外,我们在邻接表中使用 Vertex 类来表示顶点,这样做的原因是:如果与邻接矩阵一样,用列表索引来区 分不同顶点,那么假设要删除索引为𝑖的顶点,则需遍历整个邻接表,将所有大于𝑖的索引全部减1,效率 很低。而如果每个顶点都是唯一的 Vertex 实例,删除某一顶点之后就无须改动其他顶点了。

代码

// === File: graph_adjacency_list.cpp ===

/* 基于邻接表实现的无向图类 */
class GraphAdjList {
public:
    // 邻接表,key:顶点,value:该顶点的所有邻接顶点
    unordered_map<Vertex*, vector<Vertex*>> adjList;

    /* 在 vector 中删除指定节点 */
    void remove(vector<Vertex*> &vec, Vertex* vet) {
        for (int i = 0; i < vec.size(); i++) {
            if (vec[i] == vet) {
                vec.erase(vec.begin() + i);//从顶点列表中删除指定的顶点
                break;
            }
        }
    }

    /* 构造方法 */
    GraphAdjList(const vector<vector<Vertex*>> &edges) {
        // 添加所有顶点和边
        for (const vector<Vertex*> &edge : edges) {
            addVertex(edge[0]);
            addVertex(edge[1]);
            addEdge(edge[0], edge[1]);
        }
    }

    /* 获取顶点数量 */
    int size() {
        return adjList.size();
    }

    /* 添加边 */
    void addEdge(Vertex* vet1, Vertex* vet2) {
        if (!adjList.count(vet1) || !adjList.count(vet2) || vet1 == vet2)
            throw invalid_argument("不存在顶点");
        // 添加边 vet1 - vet2
        adjList[vet1].push_back(vet2);
        adjList[vet2].push_back(vet1);
    }

    /* 删除边 */
    void removeEdge(Vertex* vet1, Vertex* vet2) {
        if (!adjList.count(vet1) || !adjList.count(vet2) || vet1 == vet2)
            throw invalid_argument("不存在顶点");
        // 删除边 vet1 - vet2
        remove(adjList[vet1], vet2);
        remove(adjList[vet2], vet1);
    }

    /* 添加顶点 */
    void addVertex(Vertex* vet) {
        if (adjList.count(vet))
            return;
        // 在邻接表中添加一个新链表
        adjList[vet] = vector<Vertex*>();
    }

    /* 删除顶点 */
    void removeVertex(Vertex* vet) {
        if (!adjList.count(vet))
            throw invalid_argument("不存在顶点");
        // 在邻接表中删除顶点 vet 对应的链表
        adjList.erase(vet);
        // 遍历其他顶点的链表,删除所有包含 vet 的边
        for (auto &adj : adjList) {
            remove(adj.second, vet);
        }
    }

    /* 打印邻接表 */
    void print() {
        cout << "邻接表 =" << endl;
        for (auto &adj : adjList) {
            const auto &key = adj.first;
            const auto &vec = adj.second;
            cout << key->val << ": ";
            printVector(vetsToVals(vec));
        }
    }
};

![[Pasted image 20251115161618.png]] ![[Pasted image 20251115161626.png]]

存储比较表

存储方式 空间 查边 遍历 适用
矩阵 O(V^2) O(1) O(V^2) 稠密、小V
O(V+E) O(deg) O(V+E) 稀疏、大E

图的遍历(都需要一个哈希集合visited来记录访问过的元素)

广度优先遍历BFS(队列)

层序访问,适合最短路径预备。

// === File: graph_bfs.cpp ===

/* 广度优先遍历 */
// 使用邻接表来表示图,以便获取指定顶点的所有邻接顶点
vector<Vertex*> graphBFS(GraphAdjList &graph, Vertex* startVet) {
    // 顶点遍历序列
    vector<Vertex*> res;
    // 哈希集合,用于记录已被访问过的顶点
    unordered_set<Vertex*> visited = {startVet};// 已访问集合(含起点)
    // 队列用于实现 BFS
    queue<Vertex*> que;
    que.push(startVet);
    
    // 以顶点 vet 为起点,循环直至访问完所有顶点
    while (!que.empty()) {
        Vertex* vet = que.front();
        que.pop(); // 队首顶点出队
        res.push_back(vet); // 记录访问顶点
        
        // 遍历该顶点的所有邻接顶点
        for (auto adjVet : graph.adjList[vet]) {
            if (visited.count(adjVet))
                continue; // 跳过已被访问的顶点
            que.push(adjVet); // 只入队未访问的顶点
            visited.emplace(adjVet); // 标记该顶点已被访问
        }
    }
    
    // 返回顶点遍历序列
    return res;
}
  • 解释:vis入队时标记,防重复。输出层序。 ==广度优先遍历的序列是否唯一? 不唯一。广度优先遍历只要求按“由近及远”的顺序遍历,而多个相同距离的顶点的遍历顺序允许被 任意打乱。== 在BFS中使用 unordered_set 作为visited集合是因为:
  1. ✅ 防止重复访问 - 确保每个顶点只处理一次
  2. ✅ 高性能 - O(1)的查找和插入
  3. ✅ 灵活性 - 适用于指针等复杂标识符
  4. ✅ 简洁性 - 代码清晰易懂
  5. ✅ 安全性 - 避免无限循环

这是图遍历算法中的标准做法,也是保证算法正确性和效率的关键设计!

深度优先遍历DFS(递归)(栈)

访问未访邻接,适合路径枚举。 深度优先遍历是一种优先走到底、无路可走再回头的遍历方式

// === File: graph_dfs.cpp ===

/* 深度优先遍历辅助函数 */
void dfs(GraphAdjList &graph, unordered_set<Vertex*> &visited, 
         vector<Vertex*> &res, Vertex* vet) {
    res.push_back(vet);              // 记录访问顶点
    visited.emplace(vet);            // 标记该顶点已被访问
    
    // 遍历该顶点的所有邻接顶点
    for (Vertex* adjVet : graph.adjList[vet]) {
        if (visited.count(adjVet))
            continue;                // 跳过已被访问的顶点
        
        // 递归访问邻接顶点
        dfs(graph, visited, res, adjVet);
    }
}

/* 深度优先遍历 */
// 使用邻接表来表示图,以便获取指定顶点的所有邻接顶点
vector<Vertex*> graphDFS(GraphAdjList &graph, Vertex* startVet) {
    // 顶点遍历序列
    vector<Vertex*> res;
    // 哈希集合,用于记录已被访问过的顶点
    unordered_set<Vertex*> visited;
    dfs(graph, visited, res, startVet);
    return res;
}

最短路径

笔记重点:非负权用Dijkstra,有负/全源用Floyd。假设无负环。

拓扑排序

拓扑排序是对有向无环图(DAG)的所有顶点进行线性排序,使得对于任何从顶点u到顶点v的有向边(u, v),在排序中u都在v的前面。

方法1:Kahn算法(入度表+BFS)

vector<int> topologicalSort(int n, vector<vector<int>>& edges) {
    // 构建邻接表和入度数组
    vector<vector<int>> graph(n);
    vector<int> indegree(n, 0);
    
    // 初始化图
    for(auto& edge : edges) {
        int u = edge[0], v = edge[1];
        graph[u].push_back(v);  // u → v
        indegree[v]++;          // v的入度+1
    }
    
    // 将所有入度为0的顶点加入队列
    queue<int> q;
    for(int i = 0; i < n; i++) {
        if(indegree[i] == 0) {
            q.push(i);
        }
    }
    
    vector<int> result;
    while(!q.empty()) {
        int u = q.front();
        q.pop();
        result.push_back(u);
        
        // 处理u的所有邻居
        for(int v : graph[u]) {
            indegree[v]--;          // 删除边u→v,v入度-1
            if(indegree[v] == 0) {  // 如果v入度变为0
                q.push(v);          // 加入队列
            }
        }
    }
    
    // 检查是否有环
    if(result.size() != n) {
        return {};  // 存在环,无法拓扑排序
    }
    return result;
}

方法2:DFS算法

vector<int> topologicalSortDFS(int n, vector<vector<int>>& edges) {
    vector<vector<int>> graph(n);
    for(auto& edge : edges) {
        graph[edge[0]].push_back(edge[1]);
    }
    
    vector<int> result;
    vector<int> visited(n, 0);  // 0:未访问, 1:访问中, 2:已访问
    
    for(int i = 0; i < n; i++) {
        if(visited[i] == 0) {
            if(!dfs(i, graph, visited, result)) {
                return {};  // 有环
            }
        }
    }
    
    reverse(result.begin(), result.end());
    return result;
}

bool dfs(int u, vector<vector<int>>& graph, vector<int>& visited, vector<int>& result) {
    visited[u] = 1;  // 标记为访问中
    
    for(int v : graph[u]) {
        if(visited[v] == 1) return false;  // 发现环
        if(visited[v] == 0) {
            if(!dfs(v, graph, visited, result)) return false;
        }
    }
    
    visited[u] = 2;  // 标记为已访问
    result.push_back(u);
    return true;
}

"拓扑排序三要素"

  1. 有向图 - 只能用于有向图

  2. 无环 - 图中不能有循环依赖

  3. 线性序 - 结果是顶点的一个线性排列

"Kahn算法四步曲"

  1. 建图 - 构建邻接表和入度数组

  2. 找起点 - 所有入度为0的顶点入队

  3. 处理队列 - 出队、记录、更新邻居入度

  4. 检查环 - 结果数量是否等于顶点总数 拓扑排序是处理依赖关系问题的核心算法:

  • ✅ 解决"先决条件"类问题
  • ✅ 检测图中是否有环
  • ✅ 为任务提供合理的执行顺序
  • ✅ 时间复杂度优秀 O(V+E) 掌握拓扑排序对于解决LeetCode中很多图论问题(如207, 210, 329等)都非常有帮助!

字典树

https://leetcode.cn/problems/implement-trie-prefix-tree/solutions/98390/trie-tree-de-shi-xian-gua-he-chu-xue-zhe-by-huwt 讲的很好 字典树(Trie,发音同"try")是一种专门用于高效存储和检索字符串的树形数据结构。它也被称为前缀树、单词查找树。 ![[Pasted image 20251116144924.png]]

节点定义

class TrieNode {
public:
    vector<TrieNode*> children;  // 子节点数组
    bool isEnd;                  // 标记是否为单词结尾
    
    TrieNode() : children(26, nullptr), isEnd(false) {}
};

e.g.存储单词:["apple", "app", "banana", "bat"]

根节点
  |
  a (26个子节点,只用了a和b)
  |     \
  p      b
  |       \
  p (isEnd) a
  |          \
  l (isEnd)   n
  |            \
  e (isEnd)     a
                 \
                  n
                   \
                    a (isEnd)

插入

void insert(string word) {
    TrieNode* node = root;
    for(char c : word) {
        int index = c - 'a';
        if(!node->children[index]) {
            node->children[index] = new TrieNode();
        }
        node = node->children[index];
    }
    node->isEnd = true;  // 标记单词结束
}

搜索

bool search(string word) {
    TrieNode* node = root;
    for(char c : word) {
        int index = c - 'a';
        if(!node->children[index]) {
            return false;
        }
        node = node->children[index];
    }
    return node->isEnd;  // 必须正好是单词结尾
}

前缀搜索

bool startsWith(string prefix) {
    TrieNode* node = root;
    for(char c : prefix) {
        int index = c - 'a';
        if(!node->children[index]) {
            return false;
        }
        node = node->children[index];
    }
    return true;  // 只要前缀存在就返回true
}

总代码块

class Trie {
private:
    struct TrieNode {
        vector<TrieNode*> children;
        bool isEnd;
        TrieNode() : children(26, nullptr), isEnd(false) {}
    };
    
    TrieNode* root;

public:
    Trie() {
        root = new TrieNode();
    }
    
    void insert(string word) {
        TrieNode* node = root;
        for(char c : word) {
            int index = c - 'a';
            if(!node->children[index]) {
                node->children[index] = new TrieNode();
            }
            node = node->children[index];
        }
        node->isEnd = true;
    }
    
    bool search(string word) {
        TrieNode* node = root;
        for(char c : word) {
            int index = c - 'a';
            if(!node->children[index]) {
                return false;
            }
            node = node->children[index];
        }
        return node->isEnd;
    }
    
    bool startsWith(string prefix) {
        TrieNode* node = root;
        for(char c : prefix) {
            int index = c - 'a';
            if(!node->children[index]) {
                return false;
            }
            node = node->children[index];
        }
        return true;
    }
};

用途:

自动补完,拼写检查,IP路由表,单词游戏

Dijkstra(单源,邻接矩阵/表)

贪心:选dist最小未访节点,松弛邻接。笔记用优先队列优化O((V+E)logV)。

#include <queue>
typedef pair<int, int> PII;  // dist, 节点
const int INF = 1e9;
int dist[maxv];
bool vis[maxv] = {false};
vector<PII> adj[maxv];  // pair<节点,权>

void dijkstra(int s, int n) {
    fill(dist, dist + n, INF);
    dist[s] = 0;
    priority_queue<PII, vector<PII>, greater<PII>> pq;
    pq.push({0, s});
    while (!pq.empty()) {
        int u = pq.top().second; pq.pop();
        if (vis[u]) continue;
        vis[u] = true;
        for (auto [v, w] : adj[u]) {
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                pq.push({dist[v], v});
            }
        }
    }
}
  • 解释:vis选最小后标记,松弛更新。测试:节点0-1权1,0-2权3,1-2权1 → dist[2]=2。

Floyd(全源,矩阵)

动态规划:dist[i][j] = min(直达, 经k)。

int dist[maxv][maxv];
void floyd(int n) {
    for (int k = 0; k < n; k++)
        for (int i = 0; i < n; i++)
            for (int j = 0; j < n; j++)
                if (dist[i][k] < INF && dist[k][j] < INF)
                    dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
}
  • 解释:O(V^3),初始化dist[i][j]=g[i][j]。用于传递闭包。

最小生成树(MST)

无向连通加权图最小权子图(V-1边)。笔记两法:Prim(加点)、Kruskal(加边)。

Prim(矩阵/表)

从点集扩展,选最小边连未入点。

int prim(int n) {
    bool vis[maxv] = {false};
    int lowcost[maxv], total = 0;
    fill(lowcost, lowcost + n, INF);
    vis[0] = true;
    for (int i = 1; i < n; i++) lowcost[i] = g[0][i];  // 假设矩阵g
    for (int i = 1; i < n; i++) {
        int minv = INF, u = -1;
        for (int j = 0; j < n; j++)
            if (!vis[j] && lowcost[j] < minv) { minv = lowcost[j]; u = j; }
        if (u == -1) return -1;  // 非连通
        vis[u] = true; total += minv;
        for (int j = 0; j < n; j++)
            if (!vis[j] && g[u][j] < lowcost[j]) lowcost[j] = g[u][j];
    }
    return total;
}

Kruskal(边排序+并查集)

笔记需并查集(UF)防环:排序边,<阈值加边并union。

struct Edge { int u, v, w; } edges[maxe];
bool cmp(Edge a, Edge b) { return a.w < b.w; }
int find(int x) { /* UF标准实现 */ }
void kruskal(int m, int n) {
    sort(edges, edges + m, cmp);
    int total = 0, cnt = 0;
    for (int i = 0; i < m; i++) {
        int u = find(edges[i].u), v = find(edges[i].v);
        if (u != v) {
            union(u, v);  // 并查
            total += edges[i].w;
            if (++cnt == n-1) break;
        }
    }