【数据结构】【学习笔记】图
⚙️定义
图是一种用于描述多对多关系的数据结构,其善于处理关系型数据,使用它可以很轻松地表示数据之间是如何关联的。
每个结点都是一个顶点,每条线段都是一条边。当两个顶点通过一条边联系在一起时,我们会说这两个顶点是相邻的。
- 顶点(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,就可以,否则不可以。
但是,度为单数的点相连有可能会重边,所以还得判断这些点是否已经有边。
整理后,得:
- 当奇数节点为0时,满足条件,返回true;
- 当奇数节点为2时(设为a, b):
- a和b无边,则在ab连边即可,返回true;
- a和b有边时,则寻找是否有一点m,在a无边,在b也无边,则相连am和bm即可,返回true;
- 否则,返回false;
- 当奇数节点为4时(设为a,b,c,d),必须要这四个点能分别连边,只有以下3种情况:
- (a,b)和(c,d)连线
- (a,c)和(b,d)连线
- (a,d)和(b,c)连线
只要满足其中一个,就返回true;否则返回false。
- 剩下的情况都是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 辅助整理。

浙公网安备 33010602011771号