【数据结构】【学习笔记】图

⚙️定义

图是一种用于描述多对多关系的数据结构,其善于处理关系型数据,使用它可以很轻松地表示数据之间是如何关联的。

每个结点都是一个顶点,每条线段都是一条。当两个顶点通过一条边联系在一起时,我们会说这两个顶点是相邻的。

  • 顶点(Vertex):表示对象;
  • 边(Edge):表示对象之间的关系。
  • 相邻(Adjacent):若两个顶点通过一条边直接相连,则称这两个顶点相邻。

🔎特性

根据边与顶点的约束方式,图具有以下核心特性与分类:

  • 方向性
    • 无向图(Undirected Graph):边没有方向,关系是双向的(如好友关系)。
    • 有向图(Directed Graph):边具有方向(\(u \to v\)),关系是单向的(如关注关系)。
  • 权值(Weight)
    • 带权图(Weighted Graph):边附带权值,常用来表示距离、花费或时间。
    • 无权图(Unweighted Graph):边仅表示连通性,可视为权值均为 1。
  • 度数(Degree)
    • 度(Degree):与该顶点相连的边数。
    • 入度(In-degree)/ 出度(Out-degree):有向图特有,入度指指向该点的边数,出度指从该点出发的边数。
  • 连通性与环
    • 路径(Path):从顶点 \(A\) 到顶点 \(B\) 所经过的顶点序列。
    • 环(Cycle):起点和终点相同的路径。
    • 有向无环图(DAG):无环的有向图,常用于拓扑排序、动态规划及任务依赖分析。

🤓实现

以下都假设顶点有v个,边数有e个。

1. 邻接表(Adjacency List)—— 推荐(适用于稀疏图)

使用数组/散列表存储每个顶点,并为每个顶点维护一个邻接节点集合(数组、链表或哈希集)。

  • 优点:空间复杂度低,遍历邻居极快。
  • 复杂度:空间复杂度 \(O(V + E)\),查询两点间是否有边 \(O(\text{度数})\)(用 HashSet 可降至 \(O(1)\))。
// TypeScript 实现(数组 + HashSet,查重边 O(1))
class Graph {
    private adj: Map<number, Set<number>> = new Map();

    addEdge(u: number, v: number, isDirected = false): void {
        if (!this.adj.has(u)) this.adj.set(u, new Set());
        this.adj.get(u)!.add(v);

        if (!isDirected) {
            if (!this.adj.has(v)) this.adj.set(v, new Set());
            this.adj.get(v)!.add(u);
        }
    }
}

2. 邻接矩阵(Adjacency Matrix)—— 适用于稠密图

使用二维数组 matrix[u][v] 存储节点 \(u\) 与节点 \(v\) 之间的边关系或权值。

  • 优点:能够 \(O(1)\) 时间判断两点间是否存在边。
  • 缺点:空间消耗大,空间复杂度为 \(O(V^2)\)

3. 边集数组(Edges Array)

使用数组直接存储所有边 [[u1, v1, w1], [u2, v2, w2], ...]

  • 适用场景:Kruskal 最小生成树算法、Bellman-Ford 算法等需要直接对所有边按权值排序的场景。

❔题目

1. 可以到达所有点的最少点数目

解题思路

一开始一下子去思考寻路了,但是仔细思考,按照图的特性,只要找到入度为0的节点即可。

证明:

  • 必要性:如果一个节点 \(x\) 的入度为 0,意味着没有任何其他点能到达它。为了覆盖点 \(x\),它必须被选入起点集合。
  • 充分性:在有向无环图(DAG)中,任意一个入度 \(> 0\) 的点,沿着指向它的边逆向追溯,最终必然会追溯到一个入度为 0 的源头节点。因此,只要把所有入度为 0 的节点选上,就天然能遍历到图中的所有点。

实现

function findSmallestSetOfVertices(n: number, edges: number[][]): number[] {
    const res: number[] = [];
    const hasIn: boolean[] = new Array(n).fill(false);

    // 标记所有有入边的节点
    for (const e of edges) {
        hasIn[e[1]] = true;
    }

    // 入度为 0 的节点即为起点
    for (let i = 0; i < n; i++) {
        if (!hasIn[i]) res.push(i);
    }

    return res;
};
public class Solution {
    public IList<int> FindSmallestSetOfVertices(int n, IList<IList<int>> edges) {
        List<int> res = new List<int>();
        bool[] hasIn = new bool[n];

        // 标记所有有入边的节点
        foreach (var e in edges) {
            hasIn[e[1]] = true;
        }

        // 入度为 0 的节点即为起点
        for (int i = 0; i < n; i++) {
            if (!hasIn[i]) res.Add(i);
        }

        return res;
    }
}
class Solution {
public:
    vector<int> findSmallestSetOfVertices(int n, vector<vector<int>>& edges) {
        vector<int> res;
        vector<bool> in(n, false);
        for(auto &e : edges){
            in[e[1]] = true;
        }

        for(int i = 0; i < n; i++){
            if(!in[i]) res.push_back(i);
        }

        return res;
    }
};

复杂度分析

  • 时间复杂度\(O(M+N)\)
    其中 \(N\) 为节点数,\(M\) 为边数 edges.length。需要遍历一次边数组与所有节点。
  • 空间复杂度\(O(N)\)
    需要辅助数组记录是否有入度。

2. 添加边使所有节点度数都为偶数

解题思路

原始思路:
只要计算每个点的度,然后假如度为单数的点数量为0,2,4,就可以,否则不可以。

但是,度为单数的点相连有可能会重边,所以还得判断这些点是否已经有边。

整理后,得:

  1. 当奇数节点为0时,满足条件,返回true;
  2. 当奇数节点为2时(设为a, b):
    • a和b无边,则在ab连边即可,返回true;
    • a和b有边时,则寻找是否有一点m,在a无边,在b也无边,则相连am和bm即可,返回true;
    • 否则,返回false;
  3. 当奇数节点为4时(设为a,b,c,d),必须要这四个点能分别连边,只有以下3种情况:
    • (a,b)和(c,d)连线
    • (a,c)和(b,d)连线
    • (a,d)和(b,c)连线
      只要满足其中一个,就返回true;否则返回false。
  4. 剩下的情况都是false。

实现

function isPossible(n: number, edges: number[][]): boolean {
    const du = new Int32Array(n + 1);
    const road: Set<number>[] = Array.from({ length: n + 1 }, () => new Set());

    // 统计度数与邻接表
    for (const [l, r] of edges) {
        du[l]++;
        du[r]++;
        road[l].add(r);
        road[r].add(l);
    }

    // 收集度数为奇数的节点
    const one: number[] = [];
    for (let i = 1; i <= n; i++) {
        if (du[i] % 2 !== 0) {
            one.push(i);
        }
    }

    const count = one.length;

    if (count === 0) {
        return true;
    }

    if (count === 2) {
        const [a, b] = one;
        // 1. 直接相连
        if (!road[a].has(b)) return true;

        // 2. 借助中间节点转发
        for (let i = 1; i <= n; i++) {
            if (i === a || i === b) continue;
            if (!road[a].has(i) && !road[b].has(i)) {
                return true;
            }
        }
        return false;
    }

    if (count === 4) {
        const [a, b, c, d] = one;
        // 3 种配对方案测试
        if ((!road[a].has(b) && !road[c].has(d)) ||
            (!road[a].has(c) && !road[b].has(d)) ||
            (!road[a].has(d) && !road[b].has(c))) {
            return true;
        }
        return false;
    }

    return false;
};
public class Solution {
    public bool IsPossible(int n, IList<IList<int>> edges) {
        var du = new int[n+1];
        var road = new HashSet<int>[n+1];
        for (int i = 1; i <= n; i++) {
            road[i] = new HashSet<int>();
        }

        // 统计度数与邻接表
        foreach(var e in edges){
            int l = e[0], r = e[1];
            du[l]++;
            du[r]++;
            road[l].Add(r);
            road[r].Add(l);
        }

        // 搜集度数为奇数的节点
        var one = new List<int>();
        for(int i = 1; i <= n; i++){
            if(du[i] % 2 != 0) one.Add(i);
        }

        int count = one.Count;

        if(count == 0){
            return true;
        }else if(count == 2){
            int a = one[0], b = one[1];
            // 1. 直接互相连接
            if(!road[a].Contains(b)) return true;
            // 2. 寻找中间节点连接
            for(int i = 1; i <= n; i++){
                if(i == a || i == b) continue;
                if(!road[i].Contains(a) && !road[i].Contains(b)) return true;
            }
            return false;
        }else if(count == 4){
            int a = one[0], b = one[1], c = one[2], d = one[3];
            if((!road[a].Contains(b) && !road[c].Contains(d)) || (!road[a].Contains(c) && !road[b].Contains(d)) || (!road[a].Contains(d) && !road[b].Contains(c))){
                return true;
            }
            return false;
        }else{
            return false;
        }
    }
}
class Solution {
public:
    bool isPossible(int n, vector<vector<int>>& edges) {
        vector<int> du(n + 1, 0);
        vector<unordered_set<int>> road(n + 1);

        // 统计度数与邻接表
        for (const auto& e : edges) {
            int l = e[0], r = e[1];
            du[l]++;
            du[r]++;
            road[l].insert(r);
            road[r].insert(l);
        }

        // 收集度数为奇数的节点
        vector<int> one;
        for (int i = 1; i <= n; i++) {
            if (du[i] % 2 != 0) {
                one.push_back(i);
            }
        }

        int count = one.size();

        if (count == 0) {
            return true;
        }

        if (count == 2) {
            int a = one[0], b = one[1];
            // 1. 直接相连
            if (!road[a].count(b)) return true;

            // 2. 借助中间节点转发
            for (int i = 1; i <= n; i++) {
                if (i == a || i == b) continue;
                if (!road[a].count(i) && !road[b].count(i)) {
                    return true;
                }
            }
            return false;
        }

        if (count == 4) {
            int a = one[0], b = one[1], c = one[2], d = one[3];
            // 3 种配对方案测试
            if ((!road[a].count(b) && !road[c].count(d)) ||
                (!road[a].count(c) && !road[b].count(d)) ||
                (!road[a].count(d) && !road[b].count(c))) {
                return true;
            }
            return false;
        }

        return false;
    }
};

复杂度分析

  • 时间复杂度\(O(N+E)\)
    构建邻接集合需要遍历所有边 \(O(E)\),统计奇数度节点需要遍历所有节点 \(O(N)\)
  • 空间复杂度\(O(N+E)\)
    邻接表需要的空间和度数数组。

3. 最大化一张图中的路径价值

解题思路

一开始思考,只要寻找满足:$\text{每条往前的路} \le \frac{maxTime}{2} $ ,就找到了合法的路,然后把所有这些路的单次价值加上即是答案。

但是这是不对的,因为题目要求的是最终回到 0,而不是每次走出去都必须原路返回。

可以使用DFS 搜索 + 回溯的方法。

注意题目给出的关键突破口:

  • 每个节点最多只有 4 条边(度数 \(\le 4\),因此从当前节点出发,下一步最多有 4 种选择。

  • 每条边的耗时 \(time_j \ge 10\),而 \(maxTime \le 100\)

这意味着路径最大步数最多只有 \(100 / 10 = 10\) 步,整棵 DFS 搜索树的最大状态数只有 \(4^{10} \approx 10^6\) 数量级,约为百万级,可以直接 DFS 暴力搜索。因此直接使用带回溯的深度优先搜索遍历所有可能的合法路径,更新起点 0 处能获得的最大价值即可。

虽然图是无向图,DFS 允许重复经过节点甚至边,但由于每条边的时间 \(time_j \ge 10\),而总时间最多为 \(maxTime \le 100\),因此一条路径最多只能走 10 条边,DFS 的递归深度有限,不会无限递归。

实现

function maximalPathQuality(values: number[], edges: number[][], maxTime: number): number {
    const n = values.length;
    const graph: [number, number][][] = Array.from({ length: n }, () => []);
    
    // 建图
    for (const [u, v, t] of edges) {
        graph[u].push([v, t]);
        graph[v].push([u, t]);
    }

    const visited = new Int32Array(n);
    let maxQuality = 0;

    function dfs(u: number, curTime: number, curQuality: number) {
        // 只要回到起点,就尝试更新最大价值(路径可以继续延伸)
        if (u === 0) {
            maxQuality = Math.max(maxQuality, curQuality);
        }

        for (const [v, t] of graph[u]) {
            // 当还没超过最大时间,可继续行走
            if (curTime + t <= maxTime) {
                // 查看是否首次访问
                const isFirstVisit = visited[v] === 0;
                visited[v]++;
                // 首次访问累加价值,非首次访问(重复经过)不重复加分
                dfs(v, curTime + t, curQuality + (isFirstVisit ? values[v] : 0));
                visited[v]--; // 回溯
            }
        }
    }

    visited[0] = 1;
    dfs(0, 0, values[0]);
    return maxQuality;
}
public class Solution {
    private int maxQuality = 0;

    public int MaximalPathQuality(int[] values, int[][] edges, int maxTime) {
        int n = values.Length;
        List<(int to, int time)>[] graph = new List<(int, int)>[n];
        for (int i = 0; i < n; i++) graph[i] = new List<(int, int)>();

        foreach (var e in edges) {
            graph[e[0]].Add((e[1], e[2]));
            graph[e[1]].Add((e[0], e[2]));
        }

        int[] visited = new int[n];
        visited[0] = 1;

        Dfs(0, 0, values[0], values, graph, visited, maxTime);
        return maxQuality;
    }

    private void Dfs(int u, int curTime, int curQuality, int[] values, List<(int to, int time)>[] graph, int[] visited, int maxTime) {
        if (u == 0) {
            maxQuality = Math.Max(maxQuality, curQuality);
        }

        foreach (var edge in graph[u]) {
            int v = edge.to, t = edge.time;
            if (curTime + t <= maxTime) {
                bool isFirst = visited[v] == 0;
                visited[v]++;
                Dfs(v, curTime + t, curQuality + (isFirst ? values[v] : 0), values, graph, visited, maxTime);
                visited[v]--;
            }
        }
    }
}
class Solution {
public:
    int maxQuality = 0;

    int maximalPathQuality(vector<int>& values, vector<vector<int>>& edges, int maxTime) {
        int n = values.size();
        vector<vector<pair<int, int>>> graph(n);
        for (const auto& e : edges) {
            graph[e[0]].push_back({e[1], e[2]});
            graph[e[1]].push_back({e[0], e[2]});
        }

        vector<int> visited(n, 0);
        visited[0] = 1;

        dfs(0, 0, values[0], values, graph, visited, maxTime);
        return maxQuality;
    }

    void dfs(int u, int curTime, int curQuality, const vector<int>& values, 
             const vector<vector<pair<int, int>>>& graph, vector<int>& visited, int maxTime) {
        if (u == 0) {
            maxQuality = max(maxQuality, curQuality);
        }

        for (const auto& [v, t] : graph[u]) {
            if (curTime + t <= maxTime) {
                bool isFirst = (visited[v] == 0);
                visited[v]++;
                dfs(v, curTime + t, curQuality + (isFirst ? values[v] : 0), values, graph, visited, maxTime);
                visited[v]--;
            }
        }
    }
};

复杂度分析

  • 时间复杂度\(O(N + M + 4^{\frac{\text{maxTime}}{10}})\)
    建图需要 \(O(N + M)\);由于度数 \(\le 4\),最大递归深度不超过 \(10\),DFS 的搜索树节点数上限为 \(4^{10}\)(实际分支去掉来路后仅约 \(4 \times 3^9 \approx 7.8 \times 10^4\) 次搜索),可以在毫秒级跑完。
  • 空间复杂度\(O(N+M)\)
    主要开销为存储图的邻接表 \(O(N + M)\) 以及递归栈和频次数组 \(O(N + \frac{\text{maxTime}}{10})\)

引用

[1] 力扣探索模式
[2] 杰伊·温格罗. 数据结构与算法图解 [M]. 微信读书版


注:本文为个人学习与刷题笔记,部分文本结构与排版格式由 AI 辅助整理。

posted @ 2026-08-21 22:03  SEHOD  阅读(2)  评论(0)    收藏  举报