【数据结构与算法】2 - 4 树与二叉树

§2-4 树与二叉树

下文内容参考/引用自:

《数据结构教程(第 6 版)》 李春葆 著

2-4.1 树形数据结构

2-4.1.1 树的常用术语

(tree)是由 \(n(n \geq 0)\) 个结点(或元素)组成的有限集合,记为 \(T\)

  • \(n = 0\),它是一棵空树;
  • \(n > 0\),这 \(n\) 个结点中有且仅有一个结点作为树的根节点,简称为(root),其余结点可分为 \(m(m \geq 0)\) 个互不交集的有限集 \(T_1, T_2, \cdots, T_m\),其中每个子集本身又是一个符合本定义的树,称为根节点的子树(subtree);

树的定义是递归的,在树的定义中又用到了树的定义。树的递归定义使得树的许多基本运算也可以使用递归实现。

树的常用术语

  • 结点的度(degree of node):树中某个结点的子树的个数称为该结点的度。
  • 树的度(degree of tree):树中所有结点的度中的最大值称为树的度。
  • 分支结点(branch):树中度不为零的结点称为非终端结点,也称为分支结点。
  • 叶子结点(leaf):树中度为零的结点。
  • 路径(path):对于树中的任意两个结点 \(k_i, k_j\),若树中存在一个结点序列 \((k_i, k_{i1}, k_{i2}, \cdots, k_{in}, k_j)\),使得序列中除 \(k_i\) 以外的任一结点都是其在序列中的前一个结点的后继结点,则称这个结点序列为由 \(k_i\)\(k_j\) 的一条路径。
  • 路径长度(path length):路径长度是指一条路径所通过的结点数目减 1,即路径上的分支数目。
  • 结点层次(level):又称结点深度(depth),从树根开始定义,根结点为第一层,其子结点为第二层,以此类推,一个结点所在层次为其父结点的层次加 1。
  • 树的高度或深度(height/depth of tree):树中结点的最大层次称为树的高度或深度。
  • 有序树与无序树:若树中各结点的子树按照一定次序从左到右安排的,且相对次序不能随意变换,则称为有序树(ordered tree),否则为无序树(unordered tree)。一般情况下,若无特别说明,默认树为有序树。
  • 森林(forest):\(m(m \geq 0)\) 棵互不相交的树的集合称为森林。

2-4.1.2 树的性质

树具有以下性质(证明略)。

  1. 树中的结点数等于所有结点的度数之和加 1;

    \[n = \sum_i \text{deg}(n_i) + 1 \]

  2. 度为 \(m\) 的树中第 \(m\) 层上最多有 \(m^{i-1}\) 个结点(\(i \geq 1\));

    推广:当一棵 \(m\) 次树的第 \(i\) 层上有 \(m^{i-1}\) 个结点(\(i \geq 1\))时,称该层是满的,若这棵树每一层都是满的,则称之为\(m\) 次树(full m-tree);

    对于 \(n\) 个结点构造出的 \(m\) 次树,其为满 \(m\) 次树或接近满 \(m\) 次树时高度最小;

  3. 高度为 \(h\)\(m\) 次树最多有 \(\frac{m^h - 1}{m-1}\) 个结点;

  4. 具有 \(n\) 个结点的 \(m\) 次树最小高度为 \(\lceil\log_m (n(m-1)+1) \rceil\)

2-4.13 树的基本运算

树的基本运算主要有寻找满足某种特定关系的结点、插入与删除结点、遍历。其中,遍历指的是按某种方式访问树中所有结点且每个结点只访问一次。遍历方式有先序遍历、后序遍历、层次遍历。利用树的递归定义,树的先序遍历和后序遍历都是递归的。

  • 先序遍历(pre-order traversal):先访问根结点,然后按照从左到右的顺序先序遍历根结点的每一棵子树;
  • 后序遍历(post-order traversal):先按照从左到右的顺序后序遍历根结点的每一棵子树,然后访问根结点;
  • 层次遍历(level traversal):先访问根结点,然后以从上到下、从左到右的次序访问树中的每一个结点;

2-4.1.4 树的存储结构

存储树时,既要考虑树中结点数据字段本身,还需要考虑结点之间的逻辑关系。这里给出采用顺序结构和链式结构存储的不同存储结构。

  • 双亲存储结构(parent storage structure):一种顺序逻辑结构,用一片连续的存储空间存储树中所有结点,每一个结点内部附有一个伪指针指向该结点的父结点,而树的根结点无双亲,置其指针指向 -1.

    该结构利用了书中所有结点(除根结点外)有且仅有一个父结点的特性存储。这种结构寻找父结点容易,寻找子结点困难。

    typedef struct
    {
        ElemType data;	// 数据域
        int parent;		// 父结点指针域
    } PTree[MAX_SIZE];
    
  • 孩子链存储结构(child chain storage structure):一种链式存储结构,每一个结点除了数据域外还存储指向其子结点的指针域。对于叶子结点,置其子结点指针为 null

    该结构更直观地体现了树的逻辑结构。这种结构寻找子结点容易,寻找父结点困难。

    typedef struct Node
    {
        ElemType data;							// 数据字段
        struct Node* children[MAX_CHILDREN];	// 子结点指针域
    } ChildrenChainNode;
    
  • 孩子兄弟链存储结构(child brother chain storage structure):一种链式存储结构,每个结点具有三个域:数据域、长子域、兄弟域。其中,长子域指向该结点左边第一个子结点,兄弟域指向该结点的下一个兄弟结点。

    该结构适用于将一棵非二叉树转化为二叉树。同样地,该结构查找子结点和兄弟结点容易,查找父结点困难。

    struct Node
    {
        ElemType data;		// 数据字段
        struct Node* child;	// 长子域
        struct Node* peer;	// 兄弟域
    }
    

2-4.5 二叉树

二叉树(binary tree)是一个有限的结点集合,这个集合或为空,或由一个根结点和两棵互不相交的左右子树的二叉树构成。

二叉树的定义也是递归的,和树一样,二叉树的许多操作都可由递归完成。其结构简单,存储效率高,运算算法相对简单,且能够由任何的 \(m\) 次树转化为二叉树。且注意,二叉树是有序的

与二次树(度为 2)不同,二次树要求树中至少有一个结点的度为 2(即至少有三个结点),而二叉树没有这个要求(可以为空);二次树不区分左右子树,而二叉树严格区分左右子树。

满二叉树(full binary tree):一棵二叉树中所有结点的分支都有左右子结点,且叶子结点集中在二叉树的最下一层,称这样的二叉树为满二叉树。可对满二叉树层序编号(level coding),约定层序编号从 1 开始。从上到下、从左到右依次对树中的每一个结点编号。

以下是一颗二叉树,也是一棵满二叉树的示意图:

image

对于一棵非空满二叉树,其所有结点的度为 2(分支结点)或 0(叶子结点);所有叶子结点都位于最下面一层。

完全二叉树(complete binary tree):若一棵二叉树中最多只有最下面两层的结点度数可以小于 2,且最下面一层叶子结点依次排列在该层最左边的位置上,称这样的二叉树为完全二叉树

同样地,可以采用和满二叉树的相同的方法对一棵完全二叉树中的所有结点编号。

一棵非空完全二叉树具有以下特点:

  1. 叶子结点只可能出现在最下面两层,其前面层次所构成的树一定是一棵满二叉树;
  2. 位于最大层次的叶子结点,都依次排列在该层最左边的位置上;
  3. 若有度为 1的结点,则有且仅有一个,且该结点只有左子结点而无右子结点;
  4. 按层序编号,一旦出现编号为 \(i\) 的结点是叶子结点或只有左子结点,则编号大于 \(i\) 的结点全都是叶子结点;
  5. 结点总数 \(n\) 为奇数时,\(n_1 = 0\)(度为 1 的结点个数,后同),若 \(n\) 为偶数时, \(n_1 = 1\)

2-4.2.1 二叉树的性质

二叉树具有以下性质(证明略):

  1. 非空二叉树上的叶子结点数等于双分支结点数 + 1;

    \[n_0 = n_2 + 1 \]

  2. 非空二叉树的第 \(i\) 层上最多有 \(2^{i-1}\) 个结点;

  3. 高度为 \(h\) 的二叉树最多有 \(2^h - 1\) 个结点;

  4. 完全二叉树中层序编号为 \(i, (1 \leq i \leq n, n\geq 1)\) 的结点具有以下性质:

    • \(i \leq \lfloor n/2 \rfloor\),则编号为 \(i\) 的结点为分支结点,否则为叶子结点;
    • \(n\) 为奇数,则每个分支都具有左右子结点,若 \(n\) 为偶数,则编号为 \(\lfloor n/2 \rfloor\) 的结点只有左子结点,其余分支结点都有左右子结点;
    • 编号为 \(i\) 的结点的左子结点编号为 \(2i\),右子结点编号为 \(2i + 1\)(注意编号从 1 开始);
    • 除根结点外,一个编号为 \(i\) 的结点,其父结点编号为 \(\lfloor i/2 \rfloor\)

2-4.3.2 树/森林与二叉树的互换

前文提到,可使用孩子兄弟链表示法的二叉树对任意一棵树转换为二叉树。下文的转换皆使用这种存储结构进行。

单棵树转化为二叉树

  1. 为树中所有相邻兄弟结点添加一条连线;
  2. 树中每一个结点仅保留其左子结点连线;
  3. 将所得树倾斜(旋转或倾斜 \(45^\circ\)),即可得到一棵转化后的二叉树。

所得二叉树是一棵根结点只有左子树的二叉树。

森林转化为二叉树

  1. 将每一棵树使用上述方法转化为二叉树;
  2. 从第一棵树开始,保持第一棵树不动,将第二棵树作为第一棵树的右子树添加到树中,对于第三棵树,添加为第二棵树根结点的右子树中,以此类推,直至所有的树连接至一棵树上为止。

所得的二叉树的最右结点必定无右子树,只有左子树。

二叉树还原为单棵树

  1. 恢复亲子关系连线,若某一结点是其父结点的左子结点,则将该结点的兄弟结点全部作为其父结点的子结点;
  2. 删除兄弟结点之间的连线;
  3. 将所得树反向倾斜(旋转或倾斜 \(45^\circ\)),恢复其原本结构。

二叉树还原为森林

  1. 将树根结点的右子树分开,直到获得一棵棵根结点无右子树的二叉树;
  2. 对所有二叉树进行上述上述还原操作即可。

2-4.3.3 使用递归遍历二叉树

鉴于二叉树定义的递归性质,许多针对二叉树的操作本身可使用递归实现。因此,可以使用递归的方式遍历二叉树,代码实现较为简单。

后文中所有的二叉树的存储结构都使用二叉链存储结构。

/* 使用二叉链存储的二叉树。
 *
 */
typedef struct BinaryTreeNode
{
    int data;						// 数据字段
    struct BinaryTreeNode* left;	// 左子结点
    struct BinaryTreeNode* right;	// 右子结点
} TreeNode;

先序遍历递归实现

/* 
 * 先序遍历递归实现。
 */
void preOrderTraversalRecursively(TreeNode* tree)
{
    if (tree == NULL)
    {
        return;
    }
    
    // 先访问根结点
    printf("%d ", tree->data);
    // 然后遍历左子树
    preOrderTraversalRecursively(tree->left);
    // 最后访问右子树
    preOrderTraversalRecursively(tree->right);
}

中序遍历递归实现

/* 
 * 中序遍历递归实现。
 */
void inOrderTraversalRecursively(TreeNode* tree)
{
    if (tree == NULL)
    {
        return;
    }
    
    // 先遍历左子树
    inOrderTraversalRecursively(tree->left);
    // 再访问根结点
    printf("%d ", tree->data);
    // 然后访问右子树
    inOrderTraversalRecursively(tree->right);
}

后序遍历递归实现

/* 
 * 后序遍历递归实现。
 */
void postOrderTraversalRecursively(TreeNode* tree)
{
    if (tree == NULL)
    {
        return;
    }
    
    // 先遍历左子树
    postOrderTraversalRecursively(tree->left);
    // 然后访问右子树
    postOrderTraversalRecursively(tree->right);
    // 最后访问根结点
    printf("%d ", tree->data);
}

2-4.2.4 使用循环遍历二叉树

递归和循环在一定条件下可以相互转化。递归实际上是一种形式的循环,现将上述递归遍历算法改写成循环遍历。

先序遍历循环实现

先序遍历的顺序是根结点、左子树、右子树。遍历过程可借助栈实现。

/* 
 * 链式栈结点。
 * 采用头插法插入。
 */
struct Stack
{
    struct StackNode* top;	// 栈顶指针
};

struct StackNode
{
    TreeNode* node;			// 数据域
    struct StackNode* next;
}

栈的对应算法实现省略。

/* 
 * 先序遍历循环实现。
 */
void preOrderTraversal(TreeNode* tree)
{
    if (tree == NULL)
    {
        return;
    }
    
    // 初始化栈
    Stack* stack = NULL;
    initStack(stack);	// 初始化栈
    
    // 根结点入栈
    push(stack, tree);
    
    // 栈不空时循环
    while (!isStackEmpty(stack))
    {
        // 出栈
        TreeNode* node = NULL;
        pop(stack, node);
        
        // 访问出栈结点
        printf("%d ", node->data);
        // 将结点的左右子结点入栈
        if (node->left != NULL)
            push(stack, node->left);
        if (node->right != NULL)
            push(stack, node->right);
    }
    
    // 结束
    printf("\n");
    // 销毁栈
    destroyStack(stack);
}

中序遍历循环实现

同样地,中序遍历的循环过程也需要借助栈来实现。可使用上述的栈实现。

/* 
 * 中序遍历循环实现。
 */
void inOrderTraversal(TreeNode* tree)
{
    if (tree != NULL)
    {
        return;
    }
    
    // 创建并初始化栈
    Stack* stack = NULL;
    initStack(stack);
    
    // 辅助指针
    TreeNode* node = tree;
    
    // 栈不为空,或指针不为空时循环
    while(!isStackEmpty(stack) || node != NULL)
    {
        // 入栈 node 指针的所有左结点
        while (node != NULL)
        {
            push(stack, node);
            node = node->left;
        }
        
        // 在栈非空的情况下出栈
        if (!isStackEmpty(stack))
        {
            // 出栈并访问
            pop(stack, node);
            printf("%d ", node->data);
            
            // 转向出栈结点的右子树
            node = node->right;
        }
    }
    
    // 结束并销毁栈
    printf("\n");
    destroyStack(stack);
}

后序遍历循环实现

/* 
 * 后序遍历循环实现。
 */
void postOrderTraversal(TreeNode* tree)
{
    if (tree == NULL)
    {
        return;
    }
    
    // 创建并初始化栈
    Stack* stack = NULL;
    initStack(stack);
    
    // 辅助指针
    TreeNode* node = tree;
    // 刚访问结点
    TreeNode* visited = NULL;
    
    while (node != NULL || !isStackEmpty(stack))
    {
       if (node != NULL)
       {
           // 一直寻找左结点入栈
           push(stack, node);
           node = node->left;
       }
        else
        {
            // 查看栈顶结点,但不取出
            peek(stack, node);
            
            // 若存在尚未访问的右子树,转向右子树
            if (node->right != NULL && node->right != visited)
            {
                node = node->right;
            }
            // 否则,右子树为空或已访问
            else
            {
                // 出栈并访问
                pop(stack, node);
                printf("%d ", node->data);
                visited = node;
                
                // 置空 node
                node = NULL;
            }
        }
    }
    
    // 结束,销毁栈
    printf("\n");
    destroyStack(stack);
}

2-4.2.5 层序遍历二叉树

层序遍历二叉树需要借助队列实现。实际上,层序遍历是广度优先遍历(搜索),涉及到广度优先的算法都可以采用队列实现。层序遍历二叉树时,要求将每次访问过的结点出列后立即入列其子结点。

借助链式队列实现:

/* 
 * 链式队列
 */
struct Queue
{
    struct QueueNode* head;		// 队首
    struct QueueNode* tail;		// 队尾
}

struct QueueNode
{
    TreeNode* node;			// 数据字段
    struct QueueNode* next;	// 下一结点
}

算法实现:

/* 
 * 层序遍历。
 */
void levelOrderTraversal(TreeNode* tree)
{
    if (tree == NULL)
    {
        return;
    }
    
    // 创建并初始化队列
    Queue* queue = NULL;
    initQueue(queue);
    
    // 根结点入列
    enqueue(queue, tree);
    
    // 队列不为空时循环
    while (!isQueueEmpty(queue))
    {
        // 出列并访问
		TreeNode* node = NULL;
        dequeue(queue, node);
        printf("%d ", node->data);
        
        // 入列子结点
        if (node->left != NULL)
            enqueue(queue, node->left);
        if (node->rigt != NULL)
            enqueue(queue, node->right);
    }
    
    // 结束,销毁队列
    printf("\n");
    destroyQueue(queue);
}

2-4.6 线索二叉树

对于一棵具有 n 个结点的二叉树(采用二叉链存储结构),都会有 n + 1 个空指针域。将这些空指针域重新利用,让这些空域指向某一遍历序列的前驱或后继,将能够提高遍历效率,提高空间利用率。这样的指针称为线索(thread)。

创建线索的过程称为线索化,线索化得到的二叉树称为线索二叉树(threaded binary tree)。

由于指针域既可以指向子结点,也可以指向线性序列的前驱结点或后继结点,则有必要修改原二叉树的存储结构,以明确这一指向关系。

/* 
 * 线索二叉树结点
 */
typedef struct ThreadedBinaryTreeNode
{
    int data;								// 数据字段
    struct ThreadedBinaryTreeNode* left; 	// 左指针域
    struct ThreadedBinaryTreeNode* right;	// 右指针域
    bool isLeftThreaded;					// 左指针线索化标志,线索化指向序列前驱结点
    bool isRightThreaded;					// 右指针线索化标志,线索化指向序列后继结点
} TBTNode;

基于上述存储结构,可设计算法线索化一棵二叉树。方便起见,为线索二叉树另设一个根结点(表头),其右指针域线索化,左指针域指向二叉树根结点,右指针域指向序列尾结点。

现基于一棵使用上述结构存储,但尚未线索化的二叉树(不存在表头),使用中序遍历序列,中序线索化二叉树。

线索化需要双指针进行,全过程保持只修改前驱结点的线索。

/* 
 * 创建中序线索化二叉树。
 */
TBTNode* createThreadedTree(TBTNode* tree)
{
    // 线索化表头
    TBTNode* root = (TBTNode*)malloc(sizeof(TBTNode));
    root->isLeftThreaded = root->isRightThreaded = true;
    root->right = tree;
    
    // 若为空树
    if (tree == NULL)
    {
        root->left = root;		// 表头指向自己
    }
    // 非空树
    else
    {
        root->left = tree;
        
        TBTNode* prev = root;	// 前驱结点指针
        threadify(tree, prev);	// 中序遍历线索化二叉树
        
    }
}

/* 
 * 递归方式中序线索化二叉树。
 */
void threadify(TBTNode*& node, TBTNode*& prev)
{
    if (node == NULL)
        return;
    
    threadify(node->left, prev);		// 递归中序线索化左子树
    
    if (node->left == NULL)
    {
        node->left = prev;		// 线索化指针域(左)
        node->isLeftThreaded = true;
    }
    else
        node->isLeftThreaded = false;
    
    if (prev->right == NULL)	// 线索化 prev 后继结点
    {
        prev->right = node;
        prev->isRightThreaded = true;
    }
    else
        prev->isRightThreaded = false;
    
    threadify(node->right, prev);		// 递归中序线索化二叉树
}

有了线索,中序遍历上述二叉树将会简便很多。

/* 
 * 中序遍历线索二叉树。
 */
void inOrderTraverse(TBTNode* tree)
{
    if (tree == NULL)
        return;
    
    TBTNode* node = tree->left;		// 树的根结点(不是表头)
    while (node != tree)
    {
        // 定位开始结点
        while (!node->isLeftThreaded)
            node = node->left;
        
        // 访问结点
        printf("%d ", node->data);
        
        // 结点具有右线索的情况下一直访问
        while (node->isRightThreaded && node->right != tree)
        {
            node = node->right;
            printf("%d ", node->data);
        }
        
        // 转向右子结点
        node = node->right;
    }
}

2-4.7 哈夫曼树

在实际应用中,常将树中结点赋予一个带有某种意义的值,称为结点的权。从根结点到该结点之间的路径长度与该结点的权的乘积称为该结点的带权路径长度(weighted path length)。

\[\text{WPL} = \sum_{i=1}^{n_0} \omega_i l_i \]

其中,\(n_0\) 表示叶子结点个数(无分支结点数),\(\omega_i, l_i\) 表示该结点的权以及从根结点到该结点的路径长度。

\(n_0\) 个带权叶子结点构成的所有二叉树中,带权路径长度最小的二叉树称为哈夫曼树(Huffman tree),又称最优二叉树。构造该树的算法最早由哈夫曼于 1952 年提出,故以他的名字命名。

2-4.7.1 构造哈夫曼树

给定 \(n_0\) 个带权值的叶结点,构造一棵带有所给定叶结点的哈夫曼树。构造哈夫曼树所用到的算法为哈夫曼算法。算法流程如下:

  1. 根据给定的 \(n_0\) 个权值(\(\omega_1 \cdots, \omega_{n_0}\))对应结点构成 \(n_0\) 棵二叉树森林 \(F = (T_1, T_2, \cdots, T_{n_0})\),其中每一棵二叉树 \(T_i(1 \leq i \leq n_0)\) 都有一个权值为 \(\omega_i\) 的根结点,左右子树均为空;
  2. 在森林中选取两棵权值最小的二叉树分别作为左右子树构造一棵新的二叉树,其根结点的权值为左右子树根结点权值之和;
  3. 用新得到的二叉树代替森林中的这两棵树;
  4. 重复上述 (2) 和 (3) 过程,直到森林中只含一棵树为止,即构建完成哈夫曼树。

image

注意

  1. 构造过程中若遇到权值相同的结点,后构造的二叉树后选。
  2. 哈夫曼树中不存在单分支结点,树中结点树一定为奇数,存在关系 \(n = n_0 + n_2 = 2n_0 -1\)

设计哈夫曼树结点类型:

typdef struct HuffmanTreeNode
{
    int data;		// 数据字段
    int weight;		// 权重
    int parent;		// 父结点
    int left;		// 左子结点
    int right;		// 右子结点
} HTNode;

算法将使用数组存储哈夫曼树,算法实现如下:

/* 
 * 构造哈夫曼树。
 * 
 * @param tree		使用数组存储的哈夫曼树
 * @param leaves	叶子结点数
 */
void createHuffmanTree(HTNode*& tree, int leaves)
{
    // 初始化结点相关数据域
    for (int i = 0; i < 2 * leaves - 1; i++)
        tree[i].parent = tree[i].left = tree[i].right = -1;
    
    // 构造哈夫曼树的剩余结点
    for (int i = leaves; i < 2 * leaves - 1; i++)
    {
        int minimumWeight = INT_MAX;	// 最小权值
        int minimalWeight = INT_MAX;	// 次小权值
        
        int minimumNode = -1;			// 最小结点(左)
        int minimalNode = -1;			// 次小结点(右)
        
        for (int j = 0; j < i; j++)		// 在 tree[0..i-1]中寻找权值最小结点
        {
            if (tree[j].parent == -1)
            {
                if (tree[j].weight < minimumWeight)
                {
                    minimumWeight = minimalWeight;
                    minimumNode = minimalNode;
                    minimumWeight = tree[j].weight;
                    minimumNode = j;
                }
                else if (tree[j].weight < minimalWeight)
                {
                    minimalWeight = tree[j].weight;
                    minimalNode = j;
                }
            }
        }
        
        tree[i].weight = tree[minimumNode].weight + tree[minimalNode].weight;
        tree[i].left = minimumNode;
        tree[i].right = minimalNode;
        tree[minimumNode].parent = tree[minimalNode].parent = i;
    }
}

2-4.7.2 哈夫曼编码

哈夫曼树可用于构建使得电文编码长度最短的编码方案。规定哈夫曼树中的左分支为 0,右分支为 1,从根结点到每个叶子结点所经过的分支对应的 0、1序列即该结点对应的编码。这样的编码称为哈夫曼编码(Huffman coding)。

哈夫曼编码的特点除了长度最短,另外一个特点是任何一个结点的编码都不是任何一个其他结点的编码的前缀。这两个特性使得哈夫曼编码广泛地应用在压缩算法中,常见的用例是使用哈夫曼编码压缩文本。以压缩文本为例,应当先扫描文本,统计文本中不同字符的出现频率。频率越高的字符,其哈夫曼编码越短。

2-4.8 并查集

主要运算为查找和合并的数据结构称为并查集(disjoint set)。并查集可用于高效求解等价类问题。

对于集合 \(S\) 中的关系 \(R\),若具有自反、对称、传递性,则称关系 \(R\) 为等价关系。由等价关系 \(R\) 可产生集合 \(S\) 等价类。

并查集的运算类型有三种:初始化、查找结点所属等价类、合并两个等价类。

运算实现依赖于存储结构,整个并查集中所有的结点使用支持随机存取的顺序表实现(这里使用数组),每一个等价类采用双亲存储结构(树形结构)存储。这样,并查集就是一座等价类森林。并查集结点类型声明:

typedef struct Node
{
    int rank;			// 结点的秩(对应子树高度的下界)
    int parent;			// 结点双亲
} DisjointSet;

初始化并查集:每一个结点单独形成一个等价类,其秩为 0,自己即为自己的根结点。

void initSet(DisjointSet[] set, int n)
{
    for (int i = 0; i < n; i++)
    {
        set[i].rank = 0;
        set[i].parent = i;
    }
}

查找等价类:查找等价类的本质实际上就是查找指定结点所在等价类树的根结点。为方便后续查找,提高查找效率,查找等价类的过程中,还需压缩查找路径,让查找路径上的所有结点全都指向根结点。

路径压缩只在查找时进行,考虑到并查集还有合并运算,压缩可能会破坏这一结构。

// 递归方式实现
int findRecursively(DisjointSet[] set, int x)
{
    if (x != set[x].parent)										// 查找结点本身不是所求根结点
        set[x].parent = findRecursively(set, set[x].parent);	// 压缩路径
    return set[x].parent;
}

// 循环方式实现
int find(DisjointSet[] set, int x)
{
    int root = x;								// root 变量定位所求结点等价类树根结点
    while (root != set[root].parent)
        root = set[root].parent;				// 定位等价类树根结点
    
    int intermediate = x;						// 压缩路径
    while (intermediate != root)
    {
        int next = set[intermediate].parent;
        set[intermediate].parent = root;
        intermediate = next;
    }
    
    return root;
}

合并两个等价类:合并时不做路径压缩。合并的本质是将两个两个结点 x,y 所属的子树合并为一棵树。算法首先需要查找二者的根结点 rootX, rootY,若有 rootX == rootY,二者无需合并。否则则需要合并,合并时,我们希望合并所得到的子树高度尽可能地小(子树高度由 rank 反映)。因此,需要合并时,共有三种情况:

  1. rank[rootX] < rank[rootY],则将高度较小的 rootX 并入 rootYrootY 子树高度不变;
  2. rank[rootX] > rank[rootY],则将高度较小的 rootY 并入 rootXrootX 子树高度不变;
  3. rank[rootX] == rank[rootY],可将 rootX(或 rootY)并入 rootY(或 rootX)结点,所形成的子树高度 + 1;

简而言之,若两子树具有高度差时,将高度低的并入高度高的,新树高度不变。否则,新树高度加 1。

void merge(DisjointSet[] set, int x, int y)
{
    int rootX = find(set, x);				// 查找 x 的根结点
    int rootY = find(set, y);				// 查找 y 的根结点
    
    if (rootX == rootY)						// 二者本身同属一棵树
        return;								// 无需合并,直接退出
    
    if (set[rootX].rank < set[rootY].rank)	// rootX 高度小
    {
        set[rootX].parent = rootY;
    }
    else
    {
        set[rootY].parent = rootX;			// rootY 高度小
        if (set[rootX].rank == set[rootY].rank)
            set[rootX].rank++;				// 高度相同,子树高度加 1
    }
}
posted @ 2023-07-30 22:35  Zebt  阅读(139)  评论(0)    收藏  举报