每日算法 — 使用java实现Prüfer序列:树编码解码与Cayley公式

引言

在组合数学中,Prüfer 序列 提供了一种将带标号树(labeled tree)与序列一一对应的美妙方法。这一编码由 Heinz Prüfer 于1918年提出,不仅证明了 Cayley 公式(n 个顶点的完全图有 nⁿ⁻² 棵生成树),更为树的随机生成、动态树问题提供了实用工具。本文将用 Java 完整实现 Prüfer 序列的编码与解码算法,并展示其在树计数和随机树生成中的应用。

Cayley 公式与 Prüfer 序列

Cayley 公式:n 个带标号顶点的不同树共有 nⁿ⁻² 棵。

Prüfer 序列:长度为 n-2 的序列,每个元素取值于 [1, n]。关键定理:n 个顶点的带标号树与长度为 n-2 的 Prüfer 序列之间存在双射(一一对应)。

这意味着:
– 每棵树对应唯一的 Prüfer 序列
– 每个序列对应唯一的树
– 因此树的计数 = 序列的计数 = nⁿ⁻²

编码算法:树 → Prüfer 序列

算法步骤:
1. 找到当前树中标号最小的叶子节点(度为1的节点)
2. 记录该叶子的邻居节点标号,加入序列
3. 删除该叶子节点
4. 重复上述步骤,直到树中只剩2个节点

时间复杂度:O(n log n)(使用优先队列),或 O(n)(配合度数数组优化)

import java.util.*;

/**
 * Prüfer 序列编码与解码实现
 * 用于带标号树的编码、解码、计数与随机生成
 */
public class PruferSequence {

    private int n;                    // 顶点数量
    private List<int[]> edges;        // 树的边集

    public PruferSequence(int n) {
        this.n = n;
        this.edges = new ArrayList<>();
    }

    /**
     * 添加无向边
     */
    public void addEdge(int u, int v) {
        edges.add(new int[]{u, v});
    }

    /**
     * 树 → Prüfer 序列编码
     * 时间复杂度: O(n log n)
     * @return Prüfer 序列(长度为 n-2)
     */
    public int[] encode() {
        if (n <= 2) {
            return new int[0];
        }

        // 构建邻接表和度数数组
        List<Integer>[] adj = new ArrayList[n + 1];
        for (int i = 1; i <= n; i++) {
            adj[i] = new ArrayList<>();
        }
        int[] degree = new int[n + 1];

        for (int[] e : edges) {
            adj[e[0]].add(e[1]);
            adj[e[1]].add(e[0]);
            degree[e[0]]++;
            degree[e[1]]++;
        }

        // 优先队列存储当前所有叶子节点(度为1的节点)
        PriorityQueue<Integer> leaves = new PriorityQueue<>();
        for (int i = 1; i <= n; i++) {
            if (degree[i] == 1) {
                leaves.offer(i);
            }
        }

        int[] prufer = new int[n - 2];
        for (int i = 0; i < n - 2; i++) {
            // 取出标号最小的叶子
            int leaf = leaves.poll();
            // 找到其唯一邻居
            int neighbor = -1;
            for (int v : adj[leaf]) {
                if (degree[v] > 0) {
                    neighbor = v;
                    break;
                }
            }

            prufer[i] = neighbor;
            degree[leaf]--;
            degree[neighbor]--;

            // 若邻居变为叶子,加入队列
            if (degree[neighbor] == 1) {
                leaves.offer(neighbor);
            }
        }

        return prufer;
    }

    /**
     * Prüfer 序列 → 树解码
     * 时间复杂度: O(n log n)
     * @param prufer Prüfer 序列
     * @return 树的边集
     */
    public static List<int[]> decode(int[] prufer, int n) {
        List<int[]> resultEdges = new ArrayList<>();
        if (n <= 1) return resultEdges;
        if (n == 2) {
            resultEdges.add(new int[]{1, 2});
            return resultEdges;
        }

        // 统计每个顶点在序列中出现的次数
        int[] degree = new int[n + 1];
        Arrays.fill(degree, 1);  // 每个顶点初始度数为1
        for (int v : prufer) {
            degree[v]++;
        }

        // 优先队列存储当前可用的叶子节点
        PriorityQueue<Integer> leaves = new PriorityQueue<>();
        for (int i = 1; i <= n; i++) {
            if (degree[i] == 1) {
                leaves.offer(i);
            }
        }

        // 按序列依次连接叶子与序列中的顶点
        for (int v : prufer) {
            int leaf = leaves.poll();
            resultEdges.add(new int[]{leaf, v});
            degree[leaf]--;
            degree[v]--;
            if (degree[v] == 1) {
                leaves.offer(v);
            }
        }

        // 最后剩下两个节点,连接它们
        int last1 = leaves.poll();
        int last2 = leaves.poll();
        resultEdges.add(new int[]{last1, last2});

        return resultEdges;
    }

    /**
     * 根据Prüfer序列计算每个顶点的度数
     * 度数为 1 + 该顶点在序列中出现次数
     */
    public static int[] calculateDegrees(int[] prufer, int n) {
        int[] degree = new int[n + 1];
        Arrays.fill(degree, 1);
        for (int v : prufer) {
            degree[v]++;
        }
        return degree;
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder("Tree edges:\n");
        for (int[] e : edges) {
            sb.append("  ").append(e[0]).append(" - ").append(e[1]).append("\n");
        }
        return sb.toString();
    }

    // ========== 演示与测试 ==========

    public static void main(String[] args) {
        System.out.println("===== Prüfer 序列编码解码演示 =====\n");

        // 示例1: 编码一棵7个节点的树
        System.out.println("【示例1】编码一棵7个节点的树");
        PruferSequence tree1 = new PruferSequence(7);
        tree1.addEdge(1, 4);
        tree1.addEdge(2, 4);
        tree1.addEdge(3, 4);
        tree1.addEdge(4, 5);
        tree1.addEdge(5, 6);
        tree1.addEdge(6, 7);
        System.out.println(tree1);

        int[] prufer1 = tree1.encode();
        System.out.println("Prüfer 序列: " + Arrays.toString(prufer1));
        System.out.println();

        // 示例2: 解码回树
        System.out.println("【示例2】将上述序列解码回树");
        List<int[]> decodedEdges = PruferSequence.decode(prufer1, 7);
        System.out.println("解码后的边:");
        for (int[] e : decodedEdges) {
            System.out.println("  " + e[0] + " - " + e[1]);
        }
        System.out.println();

        // 示例3: 验证 Cayley 公式
        System.out.println("【示例3】验证 Cayley 公式");
        for (int n = 2; n <= 6; n++) {
            long treeCount = cayleyFormula(n);
            System.out.println("  n=" + n + " 时,带标号树数量 = " + n + "^" + (n-2) + " = " + treeCount);
        }
        System.out.println();

        // 示例4: 随机树生成
        System.out.println("【示例4】随机生成一棵10个节点的树");
        List<int[]> randomTree = generateRandomTree(10);
        System.out.println("随机树边集:");
        for (int[] e : randomTree) {
            System.out.println("  " + e[0] + " - " + e[1]);
        }
    }

    /**
     * Cayley 公式: n^(n-2)
     */
    public static long cayleyFormula(int n) {
        if (n <= 1) return 1;
        long result = 1;
        for (int i = 0; i < n - 2; i++) {
            result *= n;
        }
        return result;
    }

    /**
     * 随机生成一棵 n 个节点的带标号树
     * 先生成随机 Prüfer 序列,再解码为树
     */
    public static List<int[]> generateRandomTree(int n) {
        if (n <= 1) return new ArrayList<>();
        if (n == 2) {
            List<int[]> edges = new ArrayList<>();
            edges.add(new int[]{1, 2});
            return edges;
        }

        Random rand = new Random();
        int[] prufer = new int[n - 2];
        for (int i = 0; i < n - 2; i++) {
            prufer[i] = rand.nextInt(n) + 1;  // [1, n]
        }

        System.out.println("  生成的随机 Prüfer 序列: " + Arrays.toString(prufer));
        return decode(prufer, n);
    }
}

线性时间优化版本

上述实现使用优先队列,时间复杂度为 O(n log n)。通过指针扫描可以优化到 O(n):

    /**
     * O(n) 编码实现(不使用优先队列)
     * 利用指针维护当前最小叶子,配合度数数组快速查找
     */
    public int[] encodeLinear() {
        if (n <= 2) return new int[0];

        List<Integer>[] adj = new ArrayList[n + 1];
        for (int i = 1; i <= n; i++) adj[i] = new ArrayList<>();
        int[] degree = new int[n + 1];

        for (int[] e : edges) {
            adj[e[0]].add(e[1]);
            adj[e[1]].add(e[0]);
            degree[e[0]]++;
            degree[e[1]]++;
        }

        int ptr = 1;  // 指针,指向当前最小的候选叶子
        while (ptr <= n && degree[ptr] != 1) ptr++;

        int[] prufer = new int[n - 2];
        int leaf = ptr;

        for (int i = 0; i < n - 2; i++) {
            int neighbor = -1;
            for (int v : adj[leaf]) {
                if (degree[v] > 0) {
                    neighbor = v;
                    break;
                }
            }
            prufer[i] = neighbor;
            degree[leaf]--;
            degree[neighbor]--;

            if (degree[neighbor] == 1 && neighbor < ptr) {
                leaf = neighbor;
            } else {
                ptr++;
                while (ptr <= n && degree[ptr] != 1) ptr++;
                leaf = ptr;
            }
        }
        return prufer;
    }

扩展应用:统计特定度数序列的树数量

由 Prüfer 序列的性质,度数为 dᵢ 的顶点在序列中恰好出现 dᵢ – 1 次。因此,给定度数序列 (d₁, d₂, …, dₙ),对应的树数量为多重组合数:

$$\frac{(n-2)!}{(d_1-1)!(d_2-1)!\cdots(d_n-1)!}$$

    /**
     * 计算具有指定度数序列的带标号树数量
     */
    public static long countTreesWithDegreeSequence(int[] degrees) {
        int n = degrees.length;
        int sum = 0;
        for (int d : degrees) sum += d;
        // 验证度数和是否为 2(n-1)
        if (sum != 2 * (n - 1)) return 0;

        long numerator = factorial(n - 2);
        long denominator = 1;
        for (int d : degrees) {
            denominator *= factorial(d - 1);
        }
        return numerator / denominator;
    }

    private static long factorial(int n) {
        long result = 1;
        for (int i = 2; i <= n; i++) result *= i;
        return result;
    }

总结

Prüfer 序列揭示了树与序列之间深刻而简洁的对应关系。通过这一编码,我们不仅优雅地证明了 Cayley 公式,还获得了线性时间生成随机树、统计特定结构树数量等实用工具。掌握 Prüfer 序列,是理解组合数学中”编码-解码”思想的重要一步,也是解决图论计数问题的有力武器。