这是本节的多页打印视图。 点击此处打印.

返回本页常规视图.

树与二叉树

本章在选择题中考察,需熟练掌握树的各种概念,并且能够手工模拟基于树的各种算法流程。

学习思维导图

# 树和二叉树

## 树的基本概念

## 二叉树

- 定义和主要特性
- 顺序存储结构和链式存储结构
- 遍历
- 线索二叉树的基本概念和构造

## 树、森林

- 树的存储结构
- 森林和二叉树的转化
- 树和森林的遍历

## 树和二叉树的应用

- 哈夫曼树和哈夫曼编码
- 并查集及其应用

1 - 树

需掌握树的概念和存储结构,以及与森林和二叉树之间的转换,在选择题中考察。

基本概念

  1. 双亲、兄弟、孩子
  2. 结点的度:结点的孩子数量
  3. 树的度:等于树中所有结点度的最大值
  4. 分支结点(非终端结点):度大于0的结点
  5. 叶子结点(终端结点):度等于0的结点
  6. 树的高度:树的结点有几层
  7. 节点间路径长度:从一个结点到另一个结点需要经过的边的个数(用边连接两个结点)
  8. 森林:m (m ≥ 0) 颗互不相交的数的集合

树的存储结构

1. 双亲表示法

双亲表示法主要是使用一个数组,其中每个结点都有一个指示其双亲结点在数组中位置的索引。

#define MAXSIZE 100
typedef struct {
    int data;         // 节点数据
    int parent;       // 双亲的位置
} PTNode;

typedef struct {
    PTNode nodes[MAXSIZE];  // 结点数组
    int n;                  // 结点数
} PTree;
A
B
C
D
E
F
G
H
I
J
A
-1
B
0
C
0
D
0
E
1
F
1
G
3
H
6
I
6
J
6
Data
Parent
0
1
2
3
4
5
6
7
8
9
A
B
C
D
E
F
G
H
I
J
Tree
Data Structure
Representation

2. 孩子表示法

孩子表示法将每个结点的孩子结点排列起来,以单链表作为存储结构。然后再用一个数组与之相配合。

#define MAXSIZE 100

// 孩子结点
typedef struct ChildNode {
    int child;                   // 孩子结点在数组中的位置
    struct ChildNode* next;     // 下一个孩子
} *ChildPtr;

// 表头结构
typedef struct {
    int data;                   // 结点数据
    ChildPtr firstchild;        // 第一个孩子的指针
} CTBox;

typedef struct {
    CTBox nodes[MAXSIZE];       // 结点数组
    int n;                      // 结点数
} CTree;
A
B
C
D
E
F
G
H
I
J
Tree
0
A
1
B
2
C
3
D
4
E
5
F
6
G
7
H
8
I
9
J
1
4
2
5
3
6
7
8
9
Representation

3. 孩子兄弟表示法

孩子兄弟表示法是将树转化为二叉树的形式来存储。每个结点有两个指针,一个指向它的第一个孩子,另一个指向它的右兄弟。

typedef struct CSNode {
    int data;                     // 结点数据
    struct CSNode* firstchild;   // 第一个孩子
    struct CSNode* rightsib;     // 右兄弟
} CSNode, *CSTree;
A
B
C
D
E
F
G
H
I
J
Tree
A
^
B
^
E
^
F
^
C
D
^
^
H
^
I
^
J
^
G
^
Representation

树、森林和二叉树的转换

树转化为二叉树

  • 若树的根结点有孩子,那么第一个孩子是二叉树的左孩子,其他的孩子结点依次作为前一个孩子结点的右孩子。
  • 对每个孩子执行上述步骤。
A
B
C
D
E
F
G
A
B
C
D
E
F
G
树转化为二叉树

森林转二叉树

  • 把森林中的每一棵树转换为二叉树。
  • 第一棵二叉树不动,从第二棵二叉树开始,依次将后一棵二叉树的根作为前一棵二叉树的右孩子。其结果是一个二叉树。
A
B
C
D
E
F
G
H
I
A
B
C
D
E
F
G
H
I
A
B
C
D
E
F
G
H
I
森林转化为二叉树

树和森林的遍历

树的遍历

对于一个给定的树,通常有以下两种遍历方式:

  1. 先根遍历 (类似于二叉树的前序遍历):
    • 访问树的根结点。
    • 递归地先根遍历根的每一棵子树。
  2. 后根遍历 (类似于二叉树的后序遍历):
    • 递归地后根遍历根的每一棵子树。
    • 访问树的根结点。

注意树是没有中根遍历的,除非这棵树是二叉树。

#define MAXCHILD 20

typedef struct TreeNode {
    int value;
    int numChildren; // 子节点的数量
    struct TreeNode *children[MAXCHILD]; // 子节点指针数组
} TreeNode;
void preOrderTraversal(TreeNode* root) {
    if (root == NULL) {
        return;
    }
    // 先访问根节点
    // 然后遍历子节点
    for (int i = 0; i < root->numChildren; ++i) {
        preOrderTraversal(root->children[i]);
    }
}
void postOrderTraversal(TreeNode* root) {
    if (root == NULL) {
        return;
    }
    
    // 先遍历子节点
    for (int i = 0; i < root->numChildren; ++i) {
        postOrderTraversal(root->children[i]);
    }
    // 然后访问根节点
}

对于上图所示的树:其先根遍历为 A, B, E, F, C, D, G,后根遍历为E, F, B, C, G, D, A

观察可以得到如下结论:

  • 树的先根遍历和其对应的二叉树的先序遍历相同
  • 树的后根遍历和其对应的二叉树的中序遍历相同

森林的遍历

对于一个给定的森林,遍历方式如下:

  1. 先根遍历 (与树的先根遍历相似):依次先根遍历森林中的每一棵树。
  2. 后根遍历 (与树的后根遍历相似):依次后根遍历森林中的每一棵树。
  3. 中根遍历(普通的树构成的森林是不存在中序遍历的,这里的中序遍历指代的是二叉树森林):依次中序遍历森林中的每一棵二叉树。

对于上图所示的森林:其先根遍历为 A, B, C, D, E, F, G, H, I,后根遍历为 B, C, D, A, F, E, H, I, G,中根遍历为 B, C, D, A, F, E, H, I, G

观察可以得到如下结论:

  • 森林的先根遍历和其对应的二叉树的先序遍历相同
  • 森林的中根遍历和其对应的二叉树的中序遍历相同

2 - 二叉树

重点内容,需熟练掌握多种二叉树的概念,在选择题中会考察,在大题中也可能出相关的概念题。

二叉树存储结构

  1. 链式存储结构:
    • 链式存储是最常见的二叉树存储方式。在链式存储中,每个节点都包含一个数据元素以及指向其左子树和右子树的指针。
    • 链式存储结构适用于表示任意形状和大小的二叉树,并且对于树的动态操作(插入、删除节点)非常方便。
  2. 顺序存储结构(数组表示):
    • 顺序存储结构使用数组来表示二叉树。通常,数组的索引与二叉树的节点之间存在特定的关系,例如,对于索引 $i$ 的节点,其左子节点在索引 $2i+1$ 处,右子节点在索引 $2i+2$ 处,父节点在索引 $(i-1)$ / $2$ 处。
    • 顺序存储结构通常用于堆数据结构(如二叉堆)的实现,其中对于堆的性质要求,使得数组表示变得非常有效。
typedef struct TreeNode {
    ElementType data;
    struct TreeNode* left;
    struct TreeNode* right;
} TreeNode;
#define MAX_SIZE 100

ElementType tree[MAX_SIZE];

ElementType lchild(int index) {
    return tree[2 * i + 1];
})

ElementType rchild(int index) {
    return tree[2 * i + 2];
}

特殊的二叉树

Full Binary Tree
A
B
C
D
E
F
G
Complete Binary Tree
A
B
C
D
E
  • 满二叉树(Full Binary Tree)
    • 每个节点要么没有子节点,要么有两个子节点。
    • 每一层都被完全填满。
    • 高度为 $h$ 的满二叉树节点数目为 $2^h - 1$。
  • 完全二叉树(Complete Binary Tree)
    • 由满二叉树通过删除最后一层的一些节点而得到的。
    • 除了最后一层外,所有其他层都是完全填满的,而且最后一层的节点都集中在左侧。
    • 结点数量为 $n$ 的完全二叉树的高度为 $log_2^{}{(n+1)}$。

遍历方式

  • 前序:根左右
  • 中序:左根右
  • 后续:左右根
void preorderTraversal(struct TreeNode* root) {
    if (root == NULL) {
        return;
    }
    // operation here
    preorderTraversal(root->left); // 遍历左子树
    preorderTraversal(root->right); // 遍历右子树
}
void inorderTraversal(struct TreeNode* root) {
    if (root == NULL) {
        return;
    }
    inorderTraversal(root->left); // 遍历左子树
    // operation here
    inorderTraversal(root->right); // 遍历右子树
}
void inorderTraversal(struct TreeNode* root) {
    if (root == NULL) {
        return;
    }
    inorderTraversal(root->left); // 遍历左子树
    inorderTraversal(root->right); // 遍历右子树
    // operation here
}
// 层次遍历二叉树
void levelOrderTraversal(struct TreeNode* root) {
    if (root == NULL) {
        return;
    }

    struct Queue* queue = createQueue();
    enqueue(queue, root);

    while (queue->front != NULL) {
        struct TreeNode* current = dequeue(queue);
        // operation here

        if (current->left != NULL) {
            enqueue(queue, current->left);
        }

        if (current->right != NULL) {
            enqueue(queue, current->right);
        }
    }
}

线索二叉树

Threaded Binary Tree
A
B
C
D
E
F
G
H
I

线索二叉树(Threaded Binary Tree)是一种对普通二叉树进行优化的数据结构。它的主要目的是在二叉树上添加一些线索(thread),使得在遍历树的过程中可以更加高效地找到前驱和后继节点。线索二叉树在某些应用中能够提高对树的遍历性能,尤其是中序遍历。

线索二叉树中的每个节点都可以包含以下线索信息:

  • 前驱线索(Inorder Predecessor Thread): 如果节点有左子树为空,那么它的左子树指针可以指向其前驱节点,即在中序遍历中位于它之前的节点。
  • 后继线索(Inorder Successor Thread): 如果节点有右子树为空,那么它的右子树指针可以指向其后继节点,即在中序遍历中位于它之后的节点。

线索二叉树的主要优点是,在不使用递归或栈的情况下,可以通过线索信息直接找到下一个要访问的节点,从而提高了中序遍历的效率。这对于需要频繁进行中序遍历的应用非常有用,例如数据库中的 B 树和 AVL 树的实现。

// ltag = 0 时表示 lchild 域指向节点的左孩子,ltag = 1 时表示 lchild 域指向节点的前驱
// rtag = 0 时表示 rchild 域指向节点的右孩子,rtag = 1 时表示 rchild 域指向节点的后继
typedef struct ThreadNode {
    ElementType data;
    struct ThreadNode *lchild, *rchild;
    int ltag, rtag;
} ThreadNode;
// 中序遍历,找到中序遍历的第一个节点,然后遍历 rchild 即可
void InOrder(ThreadNode *n) {
    ThreadNode *p = n;
    while (p->lchild) {
        p = p->lchild;
    }
    for (; p != NULL; p = p->rchild) {
        visit(p);
    }
}

常见操作

int getHeight(struct TreeNode* node) {
    if (node == NULL) {
        return 0;
    }
    return max(getHeight(node->left), getHeight(node->right)) + 1;
}

二叉树构建

从前序、中序、后序序列中选两个构造二叉树

  1. 前序 + 中序:这是最常见的组合,因为前序确定了根节点的位置,而中序则可以区分哪些节点在左子树,哪些在右子树。有了这两个信息,就可以唯一确定一棵二叉树。
  2. 后序 + 中序:后序确定了根节点的位置(它是后序遍历的最后一个节点),而中序同样可以帮助我们区分左、右子树。因此,这两者也可以唯一确定一棵二叉树。
  3. 前序 + 后序:前序和后序遍历是不足以唯一确定一个二叉树的。原因是前序和后序遍历本身不足以提供足够的信息来唯一确定一个树的结构。

例子

前序遍历为:ABDCE,中序遍历为:BDACE,树的结构如下:

    A
   / \
  B   C
   \   \
    D   E

我们如何从这两个遍历中恢复这棵树?

  • 前序遍历的第一个元素 A 是根节点。
  • 在中序遍历中, A 将序列分为两部分: BDCE 。左边部分 BD 对应于左子树,右边部分 CE 对应于右子树。
  • 在前序遍历中,继 A 之后的 BD 对应于左子树,而 CE 对应于右子树。
  • 对于左子树,其前序遍历为 BD ,中序遍历为 BD 。从这可以确定 B 是左子树的根,而 D 是它的右孩子。
  • 对于右子树,其前序遍历为 CE ,中序遍历为 CE 。从这可以确定 C 是右子树的根,而 E 是它的右孩子。

BST

二叉排序树,也称为二叉查找树(Binary Search Tree, BST),是一种特殊的二叉树。它或者是一棵空树,或者是满足以下性质的二叉树:

  1. 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值。
  2. 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值。
  3. 它的左、右子树也分别为二叉排序树。

二叉查找树的 主要操作 有:

  1. 插入:从根节点开始,如果待插入的值小于当前节点的值,就将其插入到左子树中,否则插入到右子树中。
  2. 查找:从根节点开始,如果待查找的值小于当前节点的值,就在左子树中查找,否则在右子树中查找。
  3. 删除:有三种情况:
    • 如果是叶子节点,直接删除。
    • 如果只有一个子节点,删除它并将其子节点连接到它的父节点。
    • 如果有两个子节点,找到其右子树的最小值或左子树的最大值来替换该节点,然后删除那个节点。
typedef struct TreeNode {
    int data;
    struct TreeNode* left;
    struct TreeNode* right;
} TreeNode;

// 创建新节点
TreeNode* newNode(int data) {
    TreeNode* node = (TreeNode*)malloc(sizeof(TreeNode));
    node->data = data;
    node->left = NULL;
    node->right = NULL;
    return node;
}
// 插入新节点
TreeNode* insert(TreeNode* root, int data) {
    if (root == NULL) {
        return newNode(data);
    }
    if (data < root->data) {
        root->left = insert(root->left, data);
    } else if (data > root->data) {
        root->right = insert(root->right, data);
    }
    return root;
}
// 查找节点
TreeNode* search(TreeNode* root, int data) {
    if (root == NULL || root->data == data) {
        return root;
    }
    if (data < root->data) {
        return search(root->left, data);
    }
    return search(root->right, data);
}
// 删除节点
TreeNode* deleteNode(TreeNode* root, int data) {
    if (root == NULL) {
        return root;
    }
    if (data < root->data) {
        root->left = deleteNode(root->left, data);
    } else if (data > root->data) {
        root->right = deleteNode(root->right, data);
    } else {
        // 节点有一个或没有子节点
        if (root->left == NULL) {
            TreeNode* temp = root->right;
            free(root);
            return temp;
        } else if (root->right == NULL) {
            TreeNode* temp = root->left;
            free(root);
            return temp;
        }

        // 节点有两个子节点,找到右子树的最小节点
        TreeNode* temp = findMin(root->right);

        // 复制右子树的最小节点的值
        root->data = temp->data;

        // 删除右子树的最小节点
        root->right = deleteNode(root->right, temp->data);
    }
    return root;
}

AVL

平衡二叉树(Balanced Binary Tree),也叫做 AVL 树。它的特点是任意节点的左右子树高度差(平衡因子)不超过 1,这确保了树的高度始终保持在 $O(log_2 n)$ 的水平,使得查找、插入和删除操作的时间复杂度都保持在 $O(log_2 n)$。

理解 AVL 的关键在于理解 AVL 的旋转过程,以下针对几个疑问带大家迅速了解关键知识:

  1. 为什么需要旋转?

我们时常需要对 AVL 中的结点进行插入和删除操作,这些操作可能导致 AVL 中的某个子树进入不平衡的状态。 通过旋转操作可以使得不平衡的子树 和 整棵 AVL 树 都变得重新平衡。

  1. 从哪个节点开始旋转?

从最小不平衡子树的根节点旋转,旋转操作会使得最小不平衡子树从不平衡变得平衡。

理解这句话你需要掌握以下概念:

  • 平衡因子:每个节点的平衡因子是其左子树的高度减去右子树的高度
  • 最小不平衡子树:最小不平衡子树是指在整棵二叉树中,高度差(平衡因子的绝对值)最小的子树,该子树的平衡因子绝对值超过 1,即它是导致整棵树不平衡的最小子树。
  1. 如何旋转?

执行插入操作时,如果该次插入使得 AVL 不平衡的话,首先找到最小不平衡子树根节点(用 A 表示该节点)的位置,然后根据插入节点(用 N 表示)相对于 A 的位置,可能有四种不同的旋转操作:

  1. LL,N 在 A 的左子树的左子树中(A 的平衡因子 +2,A 的左子树根节点平衡因子 +1):A 右旋
  2. RR,N 在 A 的右子树的右子树中(A 的平衡因子 -2,A 的右子树根节点平衡因子 -1):A 左旋
  3. LR,N 在 A 的左子树的右子树中(A 的平衡因子 +2,A 的左子树根节点平衡因子 -1):A 的左子树左旋,然后 A 右旋
  4. RL,N 在 A 的右子树的左子树中(A 的平衡因子 -2,A 的右子树根节点平衡因子 +1):A 的右子树右旋,然后 A 左旋
A
B
(+1)
(0)
BL
BR
AR
h
A
B
(+2)
(+1)
BL
BR
AR
h
h+1
X
X
Insert X into BL
LL Rotation
B
A
BR
AR
BL
h+1
X
h
h
h
(0)
(0)
A
B
(-1)
(0)
BL
BR
AL
h
h
Insert X into BR
A
B
(-2)
(-1)
BL
BR
AL
h
X
h
h+1
RR Rotation
B
A
AL
BL
h
(0)
(0)
BR
X
h+1
A
B
C
BL
CL
CR
(0)
(+1)
(0)
h
h-1
h
AR
Insert X into CR
A
B
C
BL
CL
CR
(-1)
(+2)
(-1)
h
h-1
h
AR
X
h
LR Rotation
C
A
B
BL
h
CL
CR
X
h
h
AR
h-1
(0)
(+1)
A
B
C
CL
CR
(0)
h-1
BR
h
h
(0)
(-1)
Insert X into CR
AL
A
B
C
CL
CR
(-1)
h-1
BR
h
(-2)
AL
X
h
RL Rotation
C
A
B
BR
h
CR
X
h
h
AL
CL
h-1
(+1)
(+1)
LL Rotation
RR Rotation
LR Rotation
RL Rotation
(0)
(0)
(0)

AVL 平衡旋转的代码实现如下所示,过程比较繁琐,了解即可,重点在与如何掌握如何 “人脑” 模拟旋转过程。

struct TreeNode {
    int data;
    struct TreeNode* left;
    struct TreeNode* right;
    int height; // 节点高度
};
struct TreeNode* leftRotate(struct TreeNode* x) {
    struct TreeNode* y = x->right;
    struct TreeNode* T2 = y->left;

    // 执行旋转
    y->left = x;
    x->right = T2;

    // 更新高度
    updateHeight(x);
    updateHeight(y);

    return y;
}
struct TreeNode* rightRotate(struct TreeNode* y) {
    struct TreeNode* x = y->left;
    struct TreeNode* T2 = x->right;

    // 执行旋转
    x->right = y;
    y->left = T2;

    // 更新高度
    updateHeight(y);
    updateHeight(x);

    return x;
}
// 计算以 node 为根节点的子树高度
int getHeight(struct TreeNode* node) {
    if (node == NULL) {
        return 0;
    }
    return node->height;
}

// 获取平衡因子
int getBalanceFactor(struct TreeNode* node) {
    if (node == NULL) {
        return 0;
    }
    return getHeight(node->left) - getHeight(node->right);
}
// 在插入时需要使用该操作,重新计算子树高度
void updateHeight(struct TreeNode* node) {
    int leftHeight = getHeight(node->left);
    int rightHeight = getHeight(node->right);
    node->height = (leftHeight > rightHeight ? leftHeight : rightHeight) + 1;
}
struct TreeNode* insert(struct TreeNode* node, int data) {
    // 步骤 1:执行标准 BST 插入
    if (node == NULL) {
        return createNode(data);
    }

    if (data < node->data) {
        node->left = insert(node->left, data);
    } else if (data > node->data) {
        node->right = insert(node->right, data);
    } else { // 如果键值相等,则不插入
        return node;
    }

    // 步骤 2:更新节点的高度
    updateHeight(node);

    // 步骤 3:获取节点的平衡因子
    int balanceFactor = getBalanceFactor(node);

    // 步骤 4:平衡调:根据新插入结点的位置 相对于 最小不平衡子树根节点的位置进行旋转
    // LL 型:右旋
    if (balanceFactor > 1 && data < node->left->data) {
        return rightRotate(node);
    }
    // RR 型:左旋
    if (balanceFactor < -1 && data > node->right->data) {
        return leftRotate(node);
    }
    // LR 型:先左旋,再右旋
    if (balanceFactor > 1 && data > node->left->data) {
        node->left = leftRotate(node->left);
        return rightRotate(node);
    }
    // RL 型:先右旋,再左旋
    if (balanceFactor < -1 && data < node->right->data) {
        node->right = rightRotate(node->right);
        return leftRotate(node);
    }

    // 返回未被修改的节点指针
    return node;
}

红黑树

为了保证 AVL 的平衡性,插入和删除操作后,非常频繁地调整全树整体拓扑结构,代价很大。为此在 AVL 树的平衡标准上进一步放宽条件,引入了红黑树的结构。

Red-black_tree_example_with_NIL

红黑树的考察不会很深,了解以下概念即可:

  1. 从根结点到叶结点的最长路径不大于最短路径的 2 倍。
  2. 根节点和叶结点是黑色的。
  3. 不存在两个相邻的红结点。
  4. 对每个结点,从该结点到任一叶结点的简单路径上,所含黑结点的数量相同。

视频讲解

3 - 树的应用

需熟练掌握哈夫曼树的构建过程,可能在选择题或大题中考察,另外需了解并查集的概念。

哈夫曼树

哈夫曼树(Huffman Tree)是一种特殊的二叉树,通常用于数据压缩算法中,特别是用于构建哈夫曼编码(Huffman Coding)。哈夫曼树的主要目标是实现数据的无损压缩,通过赋予不同的数据符号不同长度的编码来减少数据的存储空间。

以下是哈夫曼树的关键特点和构建过程:

特点

  1. 哈夫曼树是一棵二叉树,通常是带权二叉树,其中每个叶子节点都对应一个数据符号,而每个内部节点都没有数据,只有权值。
  2. 哈夫曼树的叶子节点的权值通常表示数据符号的出现频率,而内部节点的权值等于其子节点权值之和。
  3. 哈夫曼树的构建目标是找到一棵树,使得权值较高的数据符号拥有较短的编码,权值较低的数据符号拥有较长的编码。

构建过程

  1. 创建一个包含所有数据符号的森林(初始状态下,每个数据符号都是一棵单节点树)。
  2. 从森林中选择两棵树,这两棵树的权值最小。将它们合并为一棵新的树,新树的权值为两棵树的权值之和。
  3. 将新的树放回森林中,重复步骤2,直到森林中只剩下一棵树,这棵树就是哈夫曼树。
  4. 构建好的哈夫曼树具有一个重要的性质:权值较高的数据符号在树中的深度较浅,而权值较低的数据符号在树中的深度较深。

一旦构建了哈夫曼树,就可以生成数据符号的哈夫曼编码。哈夫曼编码是一种变长编码,用于表示不同数据符号。在哈夫曼编码中,权值较高的数据符号通常对应较短的编码,权值较低的数据符号对应较长的编码。这种编码方式可以实现数据的高效压缩和解压缩。

哈夫曼树和哈夫曼编码在数据压缩领域具有广泛的应用,例如在无损压缩算法中,如ZIP文件压缩,图像压缩(如JPEG)等。通过构建适用的哈夫曼树和编码,可以大幅减少数据的存储和传输成本。

哈夫曼编码

遍历哈夫曼树,为每个数据符号生成相应的哈夫曼编码。编码的生成方式如下(左0右1):

  • 向左走时添加一个0位。
  • 向右走时添加一个1位。
  • 沿着树的路径一直到达叶子节点时,即可生成该叶子节点对应的数据符号的编码。

实例

为a, b, c, d四个字母生成哈夫曼编码,其对应的权值分别为7, 5, 2, 4

a
b
c
d
7
5
2
4
a
b
7
5
c
d
6
a
b
7
c
d
11
a
b
c
d
18
0
0
0
1
1
1

并查集

并查集(Union-Find)是一种数据结构,主要用于解决集合划分及查询问题。它主要支持两种操作:查找(Find)和合并(Union)。其核心思想是使用一个数组(或其他数据结构)来存储每个元素的父节点信息。

查找

查找操作的目的是找到给定元素所属集合的代表。这可以通过追踪父节点来实现,直到找到根元素(即父节点为其自身的元素)。路径压缩可以在查找过程中应用,使得从指定节点到其根的路径上的每个节点都直接指向根,从而提高后续查找的效率。

合并

合并操作的目的是将两个集合合并为一个集合。为了执行合并,首先使用Find操作找到两个集合的代表,然后决定哪个代表成为新的根。为了保持树的平衡性,并减少查找时间,常用的策略是按秩合并。其中,秩通常表示树的高度。较低的树会被附加到较高的树的根上。

0
6
7
8
1
6
8
2
3
5
S1
S2
S3
index
0
1
2
3
4
5
6
7
8
9
parent
0
1
2
2
1
2
0
0
0
1
Array Representation of S1, S2, S3
0
6
7
8
1
6
8
2
3
5
S3
index
0
1
2
3
4
5
6
7
8
9
parent
0
0
2
2
1
2
0
0
0
1
After union  S1, S2

代码实现

#define MAXN 1000

int parent[MAXN];  // 存储每个点的父节点
int rank[MAXN];    // 秩

// 初始化
void initialize(int n) {
    for (int i = 0; i < n; i++) {
        parent[i] = i;  // 初始时,每个元素的父节点是其自身
        rank[i] = 0;    // 初始时,每个元素的秩为0
    }
}

// 查找
int find(int x) {
    if (parent[x] != x) {
        // 路径压缩
        parent[x] = find(parent[x]);
    }
    return parent[x];
}

// 合并
void unionSet(int x, int y) {
    int rootX = find(x);
    int rootY = find(y);
    if (rootX != rootY) {
        if (rank[rootX] > rank[rootY]) {
            parent[rootY] = rootX;
        } else if (rank[rootX] < rank[rootY]) {
            parent[rootX] = rootY;
        } else {
            parent[rootY] = rootX;
            rank[rootX]++;
        }
    }
}