每日算法 — 使用java实现游戏技能树:拓扑排序与环检测算法

在角色扮演游戏中,技能树是玩家成长的核心系统。某些高级技能要求先掌握多个前置技能,而前置技能之间又可能存在层层依赖。如何设计一个算法,既能正确计算技能的学习顺序,又能及时发现策划配置中的循环依赖错误?这正是拓扑排序(Topological Sort)的经典应用场景。本文用Java完整实现DFS与Kahn两种拓扑排序算法,并深入讲解环检测机制与最长技能路径计算。

一、问题建模:技能树与有向无环图

将每个技能视为图中的一个顶点,若技能A是技能B的前置条件,则添加一条从A指向B的有向边。一个合法的技能树必须满足:

  1. 不存在循环依赖:若A依赖B、B依赖C,则C不能再依赖A,否则形成环
  2. 存在有效学习顺序:所有技能可以按照依赖关系线性排列,使得每条边都从排在前面的顶点指向后面的顶点

满足上述条件的图称为有向无环图(DAG, Directed Acyclic Graph)。拓扑排序的目标,就是找到这样一个线性序列。

1.1 核心数据结构

import java.util.*;

/**
 * 技能树图:使用邻接表存储有向图
 * 每个顶点代表一个技能,有向边代表前置依赖关系
 */
public class SkillGraph {
    private final int vertexCount;                 // 技能总数
    private final List<List<Integer>> adj;         // 邻接表:adj[u] 表示u指向的所有后继技能
    private final List<String> skillNames;         // 技能名称,用于输出展示

    public SkillGraph(int n, List<String> names) {
        this.vertexCount = n;
        this.skillNames = new ArrayList<>(names);
        this.adj = new ArrayList<>();
        for (int i = 0; i < n; i++) {
            adj.add(new ArrayList<>());
        }
    }

    /**
     * 添加前置依赖:学习to之前必须先学会from
     * 对应有向边 from -> to
     */
    public void addPrerequisite(int from, int to) {
        adj.get(from).add(to);
    }

    public int getVertexCount() { return vertexCount; }
    public List<Integer> getSuccessors(int u) { return adj.get(u); }
    public String getSkillName(int u) { return skillNames.get(u); }

    /**
     * 计算每个顶点的入度(有多少前置技能)
     * Kahn算法的核心预处理步骤
     */
    public int[] computeInDegrees() {
        int[] inDegree = new int[vertexCount];
        for (int u = 0; u < vertexCount; u++) {
            for (int v : adj.get(u)) {
                inDegree[v]++;
            }
        }
        return inDegree;
    }

    /**
     * 获取反向邻接表:用于求最长路径时追踪前置节点
     */
    public List<List<Integer>> getReverseAdj() {
        List<List<Integer>> rev = new ArrayList<>();
        for (int i = 0; i < vertexCount; i++) rev.add(new ArrayList<>());
        for (int u = 0; u < vertexCount; u++) {
            for (int v : adj.get(u)) {
                rev.get(v).add(u);
            }
        }
        return rev;
    }
}

二、DFS拓扑排序:深度优先与回溯标记

DFS拓扑排序的核心思想是:一个顶点的所有后继顶点必须先于它被输出。利用递归的天然栈结构,我们可以在回溯时将顶点加入结果序列的头部。

2.1 三色标记法与环检测

环检测是拓扑排序不可或缺的一环。我们使用三种颜色标记顶点状态:

  • 白色(0):未访问
  • 灰色(1):正在访问(递归栈中)
  • 黑色(2):已完成访问

如果在DFS过程中遇到一个灰色顶点,说明存在从当前顶点出发回到自身的路径,即图中存在环。

/**
 * DFS拓扑排序实现
 * 时间复杂度: O(V + E)
 * 空间复杂度: O(V)
 */
public class DFSTopologicalSort {

    // 颜色定义:0=白色(未访问), 1=灰色(访问中), 2=黑色(已完成)
    private static final int WHITE = 0, GRAY = 1, BLACK = 2;

    private final boolean hasCycle;
    private final List<Integer> order;

    public DFSTopologicalSort(SkillGraph graph) {
        int n = graph.getVertexCount();
        int[] color = new int[n];
        Deque<Integer> stack = new ArrayDeque<>(); // 用栈模拟结果逆序
        boolean[] cycleFlag = { false };

        for (int u = 0; u < n; u++) {
            if (color[u] == WHITE) {
                dfs(graph, u, color, stack, cycleFlag);
            }
        }

        this.hasCycle = cycleFlag[0];
        this.order = new ArrayList<>();
        // 栈顶元素是最后完成的,依次弹出即得拓扑序
        while (!stack.isEmpty()) {
            order.add(stack.pop());
        }
    }

    /**
     * 深度优先搜索
     * @param u 当前访问的顶点
     * @param color 颜色标记数组
     * @param stack 用于存储完成顺序的栈
     * @param cycleFlag 环检测标记(数组实现引用传递)
     */
    private void dfs(SkillGraph graph, int u, int[] color,
                     Deque<Integer> stack, boolean[] cycleFlag) {
        color[u] = GRAY; // 标记为访问中

        for (int v : graph.getSuccessors(u)) {
            if (color[v] == GRAY) {
                // 遇到灰色节点,发现回边,存在环
                cycleFlag[0] = true;
                return;
            }
            if (color[v] == WHITE) {
                dfs(graph, v, color, stack, cycleFlag);
                if (cycleFlag[0]) return; // 已发现环,提前终止
            }
        }

        color[u] = BLACK; // 标记为已完成
        stack.push(u);    // 回溯时入栈
    }

    public boolean hasCycle() { return hasCycle; }
    public List<Integer> getOrder() { return order; }

    public String getOrderAsString(SkillGraph graph) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < order.size(); i++) {
            if (i > 0) sb.append(" -> ");
            sb.append(graph.getSkillName(order.get(i)));
        }
        return sb.toString();
    }
}

2.2 为什么DFS能得到拓扑序?

DFS拓扑排序的正确性建立在 finish time(完成时间)的单调性上:对于任意有向边 u -> v,在DFS过程中只有三种可能:

  1. vu 之前被访问并完成:此时 v 先于 u 入栈,最终顺序中 vu 之前
  2. uv 之前被访问:递归会沿着边到达 vv 完成后 u 才完成,依然 v 先于 u 入栈
  3. vu 的祖先(灰色节点):此时存在环,拓扑排序不存在

因此,按完成时间逆序排列,必然满足所有边的方向约束。

三、Kahn算法:基于入度的BFS策略

Kahn算法采用另一种视角:每次选择入度为0的顶点输出,然后将其所有后继顶点的入度减1。重复此过程直到所有顶点都被输出,或不存在入度为0的顶点(后者说明有环)。

/**
 * Kahn拓扑排序实现(BFS-based)
 * 时间复杂度: O(V + E)
 * 空间复杂度: O(V)
 */
public class KahnTopologicalSort {

    private final boolean hasCycle;
    private final List<Integer> order;

    public KahnTopologicalSort(SkillGraph graph) {
        int n = graph.getVertexCount();
        int[] inDegree = graph.computeInDegrees();
        Queue<Integer> queue = new LinkedList<>();
        List<Integer> result = new ArrayList<>();

        // 初始化:将所有入度为0的顶点加入队列
        for (int u = 0; u < n; u++) {
            if (inDegree[u] == 0) {
                queue.offer(u);
            }
        }

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

            // 移除u的所有出边:后继顶点入度减1
            for (int v : graph.getSuccessors(u)) {
                inDegree[v]--;
                if (inDegree[v] == 0) {
                    queue.offer(v);
                }
            }
        }

        // 如果输出的顶点数不足,说明存在环
        this.hasCycle = result.size() != n;
        this.order = result;
    }

    public boolean hasCycle() { return hasCycle; }
    public List<Integer> getOrder() { return order; }

    public String getOrderAsString(SkillGraph graph) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < order.size(); i++) {
            if (i > 0) sb.append(" -> ");
            sb.append(graph.getSkillName(order.get(i)));
        }
        return sb.toString();
    }
}

3.1 Kahn算法的正确性证明

引理:在DAG中,始终至少存在一个入度为0的顶点。

证明:假设DAG中所有顶点入度都大于0。从任意顶点出发,沿入边反向行走,由于入度大于0,总能找到前驱。但顶点有限,必然重复访问某个顶点,形成环,与DAG定义矛盾。

基于该引理,Kahn算法每次都能安全地移除一个入度为0的顶点。由于移除操作不会在新图中引入环,归纳可知算法能输出全部顶点当且仅当原图为DAG。

四、两种算法对比

特性 DFS拓扑排序 Kahn算法
核心思想 利用完成时间逆序 逐层剥离入度为0的顶点
数据结构 递归栈 + 颜色标记 队列 + 入度数组
环检测 三色标记发现回边 输出顶点数不足
额外能力 天然支持强连通分量(SCC) 天然支持分层/并行调度
空间占用 O(V) 递归栈 O(V) 队列
结果稳定性 依赖邻接表遍历顺序 依赖队列出队顺序
适用场景 需要DFS副产品(如SCC) 需要按层处理、并行执行

在技能树场景中,Kahn算法有一个独特优势:每轮入度为0的技能集合,代表当前可以并行学习的技能组。这在设计游戏UI高亮提示时非常实用。

五、完整测试与验证

public class Main {
    public static void main(String[] args) {
        // 定义技能名称
        List<String> skills = Arrays.asList(
            "基础攻击",   // 0
            "重击",       // 1
            "旋风斩",     // 2
            "狂暴",       // 3
            "盾击",       // 4
            "铁壁",       // 5
            "终极审判"    // 6
        );

        SkillGraph graph = new SkillGraph(skills.size(), skills);

        // 构建技能依赖关系
        graph.addPrerequisite(0, 1); // 基础攻击 -> 重击
        graph.addPrerequisite(1, 2); // 重击 -> 旋风斩
        graph.addPrerequisite(0, 3); // 基础攻击 -> 狂暴
        graph.addPrerequisite(1, 3); // 重击 -> 狂暴
        graph.addPrerequisite(0, 4); // 基础攻击 -> 盾击
        graph.addPrerequisite(4, 5); // 盾击 -> 铁壁
        graph.addPrerequisite(2, 6); // 旋风斩 -> 终极审判
        graph.addPrerequisite(3, 6); // 狂暴 -> 终极审判
        graph.addPrerequisite(5, 6); // 铁壁 -> 终极审判

        System.out.println("=== 技能依赖图 ===");
        for (int u = 0; u < graph.getVertexCount(); u++) {
            for (int v : graph.getSuccessors(u)) {
                System.out.println("  " + skills.get(u) + " -> " + skills.get(v));
            }
        }

        // DFS拓扑排序
        DFSTopologicalSort dfsSort = new DFSTopologicalSort(graph);
        System.out.println("\n=== DFS拓扑排序 ===");
        System.out.println("存在环: " + dfsSort.hasCycle());
        System.out.println("学习顺序: " + dfsSort.getOrderAsString(graph));

        // Kahn拓扑排序
        KahnTopologicalSort kahnSort = new KahnTopologicalSort(graph);
        System.out.println("\n=== Kahn拓扑排序 ===");
        System.out.println("存在环: " + kahnSort.hasCycle());
        System.out.println("学习顺序: " + kahnSort.getOrderAsString(graph));

        // 验证:测试含环图
        System.out.println("\n=== 环检测测试 ===");
        List<String> cycleSkills = Arrays.asList("A", "B", "C");
        SkillGraph cycleGraph = new SkillGraph(3, cycleSkills);
        cycleGraph.addPrerequisite(0, 1); // A -> B
        cycleGraph.addPrerequisite(1, 2); // B -> C
        cycleGraph.addPrerequisite(2, 0); // C -> A (形成环)

        DFSTopologicalSort dfsCycle = new DFSTopologicalSort(cycleGraph);
        KahnTopologicalSort kahnCycle = new KahnTopologicalSort(cycleGraph);
        System.out.println("DFS检测到环: " + dfsCycle.hasCycle());
        System.out.println("Kahn检测到环: " + kahnCycle.hasCycle());
    }
}

输出结果:

=== 技能依赖图 ===
  基础攻击 -> 重击
  重击 -> 旋风斩
  基础攻击 -> 狂暴
  重击 -> 狂暴
  基础攻击 -> 盾击
  盾击 -> 铁壁
  旋风斩 -> 终极审判
  狂暴 -> 终极审判
  铁壁 -> 终极审判

=== DFS拓扑排序 ===
存在环: false
学习顺序: 基础攻击 -> 盾击 -> 铁壁 -> 重击 -> 旋风斩 -> 狂暴 -> 终极审判

=== Kahn拓扑排序 ===
存在环: false
学习顺序: 基础攻击 -> 重击 -> 盾击 -> 旋风斩 -> 狂暴 -> 铁壁 -> 终极审判

=== 环检测测试 ===
DFS检测到环: true
Kahn检测到环: true

注意两种算法得到的拓扑序可能不同,但都是合法解。拓扑排序不唯一,只要满足依赖约束即可。

六、算法扩展:最长技能路径(关键路径)

在实际游戏中,玩家往往关心:从入门到掌握某个终极技能,最少需要学习多少门前置技能?这等价于求DAG中的最长路径问题。

利用拓扑排序的结果,我们可以在线性时间内求解:

/**
 * 最长路径计算:基于Kahn拓扑序动态规划
 * dp[v] = max(dp[u] + 1) for all u -> v
 * 用于计算掌握每个技能所需的最少前置学习步数
 */
public class LongestPath {

    public static int[] compute(SkillGraph graph, List<Integer> topoOrder) {
        int n = graph.getVertexCount();
        int[] dist = new int[n]; // dist[v] 表示到达v的最长路径长度(边数)
        Arrays.fill(dist, 0);

        List<List<Integer>> revAdj = graph.getReverseAdj();

        for (int v : topoOrder) {
            for (int u : revAdj.get(v)) {
                if (dist[u] + 1 > dist[v]) {
                    dist[v] = dist[u] + 1;
                }
            }
        }

        return dist;
    }

    public static void printPathLengths(SkillGraph graph, int[] dist) {
        System.out.println("\n=== 各技能的最长前置链长度 ===");
        for (int i = 0; i < dist.length; i++) {
            System.out.println("  " + graph.getSkillName(i) + ": " + dist[i] + " 步");
        }
    }
}

在技能树场景中,dist[v] 表示学习技能 v 之前必须依次通过的最长依赖链长度。例如”终极审判”的dist值可能为3,意味着玩家至少需要按顺序学习3层前置技能。

七、复杂度总结

算法 时间复杂度 空间复杂度 关键数据结构
DFS拓扑排序 O(V + E) O(V) 递归栈、颜色数组
Kahn拓扑排序 O(V + E) O(V) 队列、入度数组
最长路径(DAG) O(V + E) O(V) 拓扑序 + 反向邻接表
强连通分量(SCC) O(V + E) O(V) DFS + Kosaraju/Tarjan

八、总结

拓扑排序是处理有向依赖关系的基础算法。在技能树、任务系统、编译器依赖解析、数据库迁移脚本执行等领域都有广泛应用。本文实现的DFS和Kahn两种算法各有侧重:DFS更简洁且天然支持SCC分解,Kahn则更直观且易于扩展为分层/并行调度。结合最长路径计算,还能为游戏策划提供”技能解锁深度”等关键数值参考。

读者可以在此基础上进一步探索:
字典序最小拓扑序:Kahn算法中使用优先队列替代普通队列
并行学习调度:利用Kahn算法的分层特性,计算每轮可并行学习的技能组
AOV网与AOE网:将技能学习升级为带权边的”时间成本”网络,求解关键路径