链式前向星建图和拓扑排序

链式前向星

链式前向星(Forward Star)是一种用于图的稀疏表示的结构,适合存储有向图和无向图的边信息,尤其在边较少的稀疏图中效率更高。其核心思想是将每个节点的出边信息以链表的形式存储,方便图的遍历和更新。

1. 链式前向星结构的优势

链式前向星结构在很多场景下比邻接表更高效,因为:

  • 空间效率高:适合稀疏图的存储,节省了存储空间。
  • 添加和遍历速度快:只需记录每个节点的第一条边和每条边的目标节点以及下一条边的编号,便能实现 O(1) 时间复杂度的添加操作。

2. 链式前向星的基本组成

在链式前向星结构中,一般会使用以下几个数组:

  • head[] 数组:存储每个节点的第一条出边的编号。
  • to[] 数组:存储每条边的目标节点,即边的终点。
  • next[] 数组:存储当前边的下一条边的编号,用于形成链式结构。
  • weight[] 数组(可选):存储边的权重。

3. 链式前向星的存储示例

假设图的结构如下,包含节点 1 到 4,边表示为 (起点 -> 终点, 权重)

  • (1 -> 2, 5)
  • (1 -> 3, 2)
  • (2 -> 4, 1)
  • (3 -> 4, 7)

链式前向星的数组配置如下:

  1. head[i]:表示节点 i 的第一条边的编号。
  2. to[i]:表示编号为 i 的边所指向的目标节点。
  3. next[i]:表示编号为 i 的边的下一条边编号。

示例

假设我们依次加入上述的边,并采用链式前向星结构存储:

节点 1: head[1] -> 边 2 (1 -> 3)
           next[2] -> 边 1 (1 -> 2)

节点 2: head[2] -> 边 3 (2 -> 4)

节点 3: head[3] -> 边 4 (3 -> 4)

对应的数组为:

  • head[] = [0, 2, 3, 4, 0]
  • to[] = [0, 2, 3, 4, 4]
  • next[] = [0, 0, 1, 0, 0]

4. 链式前向星的遍历

通过 head[] 找到每个节点的第一条边,然后通过 next[] 顺序遍历链表中的所有边。例如,对节点 1 的遍历过程是:

  1. head[1] = 2 开始,找到边 2,它指向节点 3
  2. 通过 next[2] 找到边 1,边 1 指向节点 2
  3. 遍历结束,因为 next[1] = 0

5.代码

package foundation.graph;

import java.util.Arrays;

// 链式前向星建图
public class graphBuild {
    /*
     链式前向星:
        head[i] 表示节点 i 的第一条出边的编号;
        如果节点 i 没有出边,则 head[i] = 0;
        如果节点 i 有出边,则 head[i] 为出边的边号。

        next[i] 表示编号为 i 的边的下一条边的编号;
        如果边 i 没有后续边,则 next[i] = 0;
        如果边 i 有后续边,则 next[i] 为下一条边的边号。

        对于一个节点 i,其所有出边的边号依次为 head[i]、next[head[i]]、next[next[head[i]]],
        直到某个边号的 next 值为 0 为止。

        对于每一条边而言,to[i] 表示该边的目标节点(终点),
        而边的起点由对应节点的 head 数组来确定。
     */

    public static int MAXN = 10001;  // 假设图最多有 10000 个节点

    // head 数组:索引表示节点编号,值表示该节点的第一条出边的边号
    public static int[] head = new int[MAXN];

    // next 数组:索引表示边号,值表示当前边的下一条边的边号
    public static int[] next = new int[MAXN];

    // to 数组:索引表示边号,值表示当前边的终点
    public static int[] to = new int[MAXN];

    // weight 数组:索引表示边号,值表示当前边的权重
    public static int[] weight = new int[MAXN];

    // 计数器,用于记录当前边的编号
    public static int cnt;

    // 节点的数量
    public static int n;

    /**
     * 初始化图
     * @param n 节点数量
     *   初始化 cnt 为 1
     *   将 head 数组的索引从 1 到 n 范围的值初始化为 0
     *   这样可以保证在添加新边时不会出现边重叠的情况
     */
    private static void build(int n) {
        cnt = 1;  // 初始化边编号为 1
        Arrays.fill(head, 1, n + 1, 0);  // 初始化每个节点的第一条边编号为 0
    }

    /**
     * 添加一条边 u -> v,权重为 w
     * @param u 起点
     * @param v 终点
     * @param w 权重
     */
    private static void addEdge(int u, int v, int w) {
        // 将新边的下一条边设为当前 u 节点的第一条边
        next[cnt] = head[u];
        // 设置新边的目标节点为 v
        to[cnt] = v;
        // 设置新边的权重
        weight[cnt] = w;
        // 更新 u 节点的第一条边为当前边的编号
        head[u] = cnt++;
    }

    /**
     * 显示每个节点的所有出边信息
     * 格式为:节点号(邻居,边权)
     */
    private static void display() {
        for (int i = 1; i <= n; i++) {
            System.out.print(i + "(邻居,边权) : ");
            // 遍历以 i 为起点的所有出边
            for (int ei = head[i]; ei > 0; ei = next[ei]) {
                System.out.print("(" + to[ei] + "," + weight[ei] + ") ");
            }
            System.out.println();
        }
    }

    public static void main(String[] args) {
        n = 5;  // 假设有 5 个节点
        int[][] arr = new int[][]{
                {1, 2, 3}, {1, 3, 4}, {2, 3, 5}, {2, 4, 6}, {3, 4, 7}, {4, 5, 8}
        };
        build(n);  // 初始化图

        // 根据 arr 中的数据添加边
        for (int[] edge : arr) {
            addEdge(edge[0], edge[1], edge[2]);
        }
        display();  // 显示图的出边信息
    }
}

代码解释

  • build(int n):初始化图的数据结构,将 head 数组的前 n 个节点的第一条边编号设为 0,并将边的计数器 cnt 重置为 1。
  • addEdge(int u, int v, int w):向图中添加一条从节点 u 到节点 v 的边,权重为 w。将 next[cnt] 设为当前 u 节点的第一条边,并更新 head[u] 为当前边号 cnt
  • display():展示每个节点的所有出边情况,通过 head[]next[] 顺序遍历节点的出边。

6. 总结

链式前向星适用于大规模稀疏图的存储,具有空间利用率高、边遍历快的特点。

拓扑排序

拓扑排序(Topological Sorting)是一种针对有向无环图(DAG,Directed Acyclic Graph)排序所有节点的线性排序方式。拓扑排序的顺序保证了图中每条边 (u, v) 都满足 u 出现在 v 之前,常用于表示依赖关系的场景,如任务调度、编译顺序等。

拓扑排序的特点

  • 适用于有向无环图(DAG),如果图中有环则无法进行拓扑排序。
  • 结果不是唯一的,可能存在多个合法的拓扑排序结果,取决于节点的依赖关系。

拓扑排序的实现方法

拓扑排序常用的实现方法有两种:

1. 基于 Kahn 算法(BFS 实现)

Kahn 算法是一种广度优先遍历的方法,依赖于节点的入度来完成拓扑排序。

步骤

  1. 计算图中所有节点的入度,将入度为 0 的节点加入队列。
  2. 从队列中取出一个入度为 0 的节点,将它加入拓扑序列。
  3. 移除该节点的出边,减少相邻节点的入度。如果相邻节点的入度减为 0,将其加入队列。
  4. 重复步骤 2 和 3,直到队列为空。
  5. 如果处理完所有节点后仍有节点未加入拓扑序列,说明图中存在环,不可能完成拓扑排序。

时间复杂度:O(V + E),其中 V 为节点数,E 为边数。

代码示例(Kahn 算法):

import java.util.*;

public class TopologicalSortKahn {
    public static List<Integer> topologicalSort(int n, List<int[]> edges) {
        List<Integer> result = new ArrayList<>();
        int[] inDegree = new int[n + 1]; // 记录每个节点的入度
        List<List<Integer>> graph = new ArrayList<>();

        for (int i = 0; i <= n; i++) graph.add(new ArrayList<>());
        for (int[] edge : edges) {
            int u = edge[0], v = edge[1];
            graph.get(u).add(v);
            inDegree[v]++;
        }

        Queue<Integer> queue = new LinkedList<>();
        for (int i = 1; i <= n; i++) {
            if (inDegree[i] == 0) queue.offer(i);
        }

        while (!queue.isEmpty()) {
            int node = queue.poll();
            result.add(node);

            for (int neighbor : graph.get(node)) {
                inDegree[neighbor]--;
                if (inDegree[neighbor] == 0) queue.offer(neighbor);
            }
        }

        if (result.size() != n) return Collections.emptyList(); // 存在环
        return result;
    }

    public static void main(String[] args) {
        int n = 6;
        List<int[]> edges = Arrays.asList(
            new int[]{1, 3}, new int[]{1, 4}, new int[]{3, 2},
            new int[]{4, 2}, new int[]{2, 5}, new int[]{5, 6}
        );
        List<Integer> result = topologicalSort(n, edges);
        System.out.println(result.isEmpty() ? "图中存在环" : result);
    }
}
2. 基于深度优先搜索(DFS)

DFS 实现拓扑排序会从每个节点开始深度遍历,按访问顺序将节点放入结果中。

步骤

  1. 初始化一个栈用于存储拓扑序列和一个布尔数组记录节点的访问状态。
  2. 对每个未访问的节点调用递归 DFS 函数:
    • 标记当前节点为已访问。
    • 对所有相邻节点递归执行 DFS,确保相邻节点先入栈。
    • 当前节点的所有相邻节点访问完毕后,将当前节点压入栈。
  3. 当所有节点访问完成后,栈中的节点顺序即为拓扑排序顺序(从栈顶到栈底)。

时间复杂度:O(V + E)。

代码示例(DFS 实现):

import java.util.*;

public class TopologicalSortDFS {
    public static List<Integer> topologicalSort(int n, List<int[]> edges) {
        List<Integer> result = new ArrayList<>();
        List<List<Integer>> graph = new ArrayList<>();
        boolean[] visited = new boolean[n + 1];
        boolean[] onPath = new boolean[n + 1]; // 检测环

        for (int i = 0; i <= n; i++) graph.add(new ArrayList<>());
        for (int[] edge : edges) {
            graph.get(edge[0]).add(edge[1]);
        }

        Stack<Integer> stack = new Stack<>();
        for (int i = 1; i <= n; i++) {
            if (!visited[i] && !dfs(i, graph, visited, onPath, stack)) {
                return Collections.emptyList(); // 存在环
            }
        }

        while (!stack.isEmpty()) result.add(stack.pop());
        return result;
    }

    private static boolean dfs(int node, List<List<Integer>> graph, boolean[] visited, boolean[] onPath, Stack<Integer> stack) {
        if (onPath[node]) return false; // 发现环
        if (visited[node]) return true;

        visited[node] = true;
        onPath[node] = true;
        for (int neighbor : graph.get(node)) {
            if (!dfs(neighbor, graph, visited, onPath, stack)) return false;
        }
        onPath[node] = false;
        stack.push(node);
        return true;
    }

    public static void main(String[] args) {
        int n = 6;
        List<int[]> edges = Arrays.asList(
            new int[]{1, 3}, new int[]{1, 4}, new int[]{3, 2},
            new int[]{4, 2}, new int[]{2, 5}, new int[]{5, 6}
        );
        List<Integer> result = topologicalSort(n, edges);
        System.out.println(result.isEmpty() ? "图中存在环" : result);
    }
}

拓扑排序的应用场景

  1. 任务调度:按依赖关系确定任务的执行顺序。
  2. 编译顺序:在编译器中按依赖关系确定代码文件的编译顺序。
  3. 课程安排:根据课程的先修关系,确定课程的学习顺序。

拓扑排序在有依赖关系的问题中尤为重要。

例题

leetcode : 207. 课程表

class Solution {
    // 最大节点数
    public static int MAXN = 10000;

    // head 数组表示每个节点的第一条边
    public static int[] head = new int[MAXN];

    // next 数组表示每条边的下一条边,用于链式前向星结构
    public static int[] next = new int[MAXN];

    // to 数组表示边的终点,即该边指向的节点
    public static int[] to = new int[MAXN];

    // in 数组记录每个节点的入度,用于拓扑排序
    private static int[] in = new int[MAXN];

    // 当前边的编号
    public static int cur;

    /**
     * 检查课程安排是否可以完成
     * @param numCourses 总课程数
     * @param prerequisites 每个课程的前置课程对
     * @return 如果可以完成所有课程则返回 true,否则返回 false
     */
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        // 初始化边编号
        cur = 1;

        // 初始化 head 和 in 数组,head 初始化为 0 表示没有边
        Arrays.fill(head, 0, numCourses, 0);
        Arrays.fill(in, 0, numCourses, 0);

        // 添加所有的边到链式前向星结构中
        for (int[] ints : prerequisites) {
            addEdge(ints[1], ints[0]); // ints[1] 是起点,ints[0] 是终点
        }
        
        // 执行拓扑排序的广度优先遍历搜索方法检查是否能完成所有课程
        return bfs(numCourses);
    }

    /**
     * 添加一条边 x -> y,更新链式前向星结构
     * @param x 边的起点
     * @param y 边的终点
     */
    private void addEdge(int x, int y) {
        next[cur] = head[x]; // 当前边的下一条边指向 x 的第一条边
        to[cur] = y;         // 当前边指向节点 y
        head[x] = cur++;     // 将 x 的第一条边设为当前边,并更新边编号
        in[y]++;             // 终点 y 的入度加一
    }

    /**
     * 基于广度优先遍历的拓扑排序,判断课程是否可完成
     * @param numCourses 课程总数
     * @return 所有课程是否可完成
     */
    private boolean bfs(int numCourses) {
        Stack<Integer> stack = new Stack<>();
        int completedCourses = 0; // 完成的课程数

        // 将所有入度为 0 的节点(无前置课程的课程)入栈
        for (int i = 0; i < numCourses; i++) {
            if (in[i] == 0) {
                stack.push(i);
                in[i]--; // 将入度设为 -1,避免重复处理
            }
        }

        // 拓扑排序过程
        while (!stack.isEmpty()) {
            int x = stack.pop(); // 取出当前可以学习的课程
            completedCourses++; // 增加完成课程的计数

            // 遍历 x 的所有邻接节点(依赖于 x 的课程)
            for (int ei = head[x]; ei > 0; ei = next[ei]) {
                int y = to[ei]; // y 是依赖 x 的课程
                in[y]--; // 移除 x 对 y 的依赖,减少 y 的入度
                if (in[y] == 0) { // 如果 y 的入度为 0,表示可以学习
                    stack.push(y); 
                    in[y]--; // 防止 y 重复入栈
                }
            }
        }

        // 如果完成的课程数等于总课程数,则可以完成课程安排
        return completedCourses == numCourses;
    }
}

解释

  1. 链式前向星结构:用 headnextto 数组表示图结构。head[i] 表示节点 i 的第一条边;next[i] 表示编号为 i 的边的下一条边;to[i] 表示编号为 i 的边指向的终点。
  2. 添加边addEdge(x, y)x -> y 的边添加到图中,并更新入度数组 in[y]
  3. 拓扑排序:在 dfs 方法中,用栈实现拓扑排序,先将所有入度为 0 的节点入栈,并从栈中依次弹出节点,减少其指向节点的入度,若入度变为 0 则将其入栈。

leetcode : 210. 课程表 II

class Solution {
    // 最大节点数
    public static int MAXN = 20001;

    // head 数组表示每个节点的第一条边
    public static int[] head = new int[MAXN];

    // next 数组表示每条边的下一条边,用于链式前向星结构
    public static int[] next = new int[MAXN];

    // to 数组表示边的终点,即该边指向的节点
    public static int[] to = new int[MAXN];

    // in 数组记录每个节点的入度,用于拓扑排序
    private static int[] in = new int[MAXN];

    // 当前边的编号
    public static int cur;

    // 当前节点数(课程数)
    private static int n;

    /**
     * 找到课程的学习顺序
     * @param numCourses 总课程数
     * @param prerequisites 每个课程的前置课程对
     * @return 如果可以完成所有课程则返回课程学习顺序,否则返回空数组
     */
    public int[] findOrder(int numCourses, int[][] prerequisites) {
        cur = 1; // 初始化边编号
        n = 0;   // 初始化已处理的课程数

        // 初始化 head 和 in 数组,head 初始化为 0 表示没有边
        Arrays.fill(head, 0, numCourses, 0);
        Arrays.fill(in, 0, numCourses, 0);

        // 添加所有的边到链式前向星结构中
        for (int[] ints : prerequisites) {
            addEdge(ints[1], ints[0]); // ints[1] 是起点,ints[0] 是终点
        }
        
       
        return bfs(numCourses);
    }

    /**
     * 添加一条边 x -> y,更新链式前向星结构
     * @param x 边的起点
     * @param y 边的终点
     */
    private void addEdge(int x, int y) {
        next[cur] = head[x]; // 当前边的下一条边指向 x 的第一条边
        to[cur] = y;         // 当前边指向节点 y
        head[x] = cur++;     // 将 x 的第一条边设为当前边,并更新边编号
        in[y]++;             // 终点 y 的入度加一
    }

    /**
     * 基于拓扑排序,判断课程是否可完成并返回学习顺序
     * @param numCourses 课程总数
     * @return 如果可以完成所有课程,返回课程学习顺序,否则返回空数组
     */
    private int[] bfs(int numCourses) {
        int[] ans = new int[numCourses]; // 存储课程学习顺序
        Stack<Integer> stack = new Stack<>();
        int completedCourses = 0; // 完成的课程数

        // 将所有入度为 0 的节点(无前置课程的课程)入栈
        for (int i = 0; i < numCourses; i++) {
            if (in[i] == 0) {
                stack.push(i);
                in[i]--; // 将入度设为 -1,避免重复处理
            }
        }

        // 拓扑排序过程
        while (!stack.isEmpty()) {
            int x = stack.pop(); // 取出当前可以学习的课程
            ans[completedCourses++] = x; // 将课程编号加入学习顺序
            
            // 遍历 x 的所有邻接节点(依赖于 x 的课程)
            for (int ei = head[x]; ei > 0; ei = next[ei]) {
                int y = to[ei]; // y 是依赖 x 的课程
                in[y]--; // 移除 x 对 y 的依赖,减少 y 的入度
                if (in[y] == 0) { // 如果 y 的入度为 0,表示可以学习
                    stack.push(y); 
                    in[y]--; // 防止 y 重复入栈
                }
            }
        }

        // 如果完成的课程数等于总课程数,则返回学习顺序,否则返回空数组
        if (completedCourses == numCourses) {
            return ans;
        } else {
            return new int[]{}; // 课程无法完成,返回空数组
        }
    }
}

解释

  1. 链式前向星结构:使用 headnextto 数组表示图结构。head[i] 表示节点 i 的第一条边;next[i] 表示编号为 i 的边的下一条边;to[i] 表示编号为 i 的边指向的终点。
  2. 添加边addEdge(x, y)x -> y 的边添加到图中,并更新入度数组 in[y]
  3. 拓扑排序:在 dfs 方法中,使用栈实现拓扑排序。入度为 0 的节点入栈,并从栈中依次弹出节点,减少其指向节点的入度,若入度变为 0 则将其入栈。
### 使用链式前向星数据结构 链式前向星是一种高效且节省空间的存储方式,特别适合于稀疏。这种结构结合了邻接矩阵邻接表的优点,在实际应用中表现出色[^1]。 #### 数据结构设计 为了实现链式前向星,通常会定义两个数组: - `head[]`:记录每个节点的第一条边的位置。 - `edge[]`:保存每一条边的信息,包括目标节点、权重以及下一条边的位置。 具体来说,可以这样声明这些变量: ```cpp const int MAXN = 1e5 + 5; // 节点数量的最大值 const int MAXM = 2e5 + 5; // 边的数量最大值 * 2 (考虑双向) struct Edge { int to; int next; int weight; } edge[MAXM]; int head[MAXN]; int tot = 0; // 记录当前已有的边数 ``` #### 添加新边的方法 当需要添加一条新的边时,可以通过如下函数来完成操作: ```cpp void addEdge(int from, int to, int weight) { edge[++tot].to = to; edge[tot].weight = weight; edge[tot].next = head[from]; // 将这条边链接到from结点的第一个位置上 head[from] = tot; } ``` 这段代码实现了动态增加边的功能,并保持了链表式的连接关系,使得查询某一点出发的所有边变得简单而快速[^4]。 通过上述方法初始化并维护好这两个核心组件之后,就可以方便地遍历整个形结构中的任意路径或执行其他复杂的论运算任务了。 #### 初始化过程 在程序开始之前,记得将所有的头指针设置为空(-1),以便能够正确识别不存在任何相连边的情况: ```cpp memset(head, -1, sizeof(head)); tot = 0; ``` 这一步骤确保每次运行都能获得干净的状态来进行下一步的操作。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值