🌳树:内存中的“分层王国”,一文搞懂原理与实战!
一、树的本质:分层管理的 “家族族谱”
在数据结构的世界里,树(Tree)就像是一个倒挂的家族族谱,是一种非线性的数据结构,它以一种层次化的方式来组织数据。与数组、链表这些线性数据结构不同,树中的每个节点可以有多个子节点,从而形成了一种树形的层次结构。
想象一下你的电脑文件系统,文件夹和文件就构成了树状结构。最顶层是根目录,类似树的根节点,根目录下有各种文件夹(子节点),文件夹里又能嵌套更多文件夹和文件,文件就如同树的叶节点,没有子节点啦。再比如公司的组织架构图,CEO 是根节点,下面各部门领导是子节点,部门员工又接着细分,层级清晰,这都是树结构在生活中的体现。
树的基本术语有很多,比如根节点,它是树的起始点,独一无二,就像族谱里的老祖宗;叶节点是没有子节点的末端节点,类似家族里没有后代的成员;父节点和子节点,就像父子关系,一个节点是它子节点的父节点;还有兄弟节点,同一父节点下的子节点互为兄弟。
// 定义树的节点类
class TreeNode {
int data;
TreeNode[] children;
// 构造函数,初始化节点数据和子节点数组
public TreeNode(int data, int numChildren) {
this.data = data;
this.children = new TreeNode[numChildren];
}
}
在这段代码中,我们定义了一个TreeNode
类来表示树的节点。每个节点包含一个data
字段用于存储数据,以及一个TreeNode
类型的数组children
来存储子节点。构造函数接受两个参数,data
表示节点的数据,numChildren
表示该节点最多可以拥有的子节点数量。通过这种方式,我们可以构建出具有多个子节点的树结构。在实际使用时,可以根据需要创建不同的TreeNode
对象,并将它们按照树的结构进行组织。例如:
public class TreeExample {
public static void main(String[] args) {
// 创建根节点,最多有3个子节点
TreeNode root = new TreeNode(1, 3);
// 创建子节点
TreeNode child1 = new TreeNode(2, 0);
TreeNode child2 = new TreeNode(3, 0);
// 将子节点添加到根节点的子节点数组中
root.children[0] = child1;
root.children[1] = child2;
}
}
在这个示例中,我们创建了一个根节点root
,它最多可以有 3 个子节点,然后创建了两个子节点child1
和child2
,并将它们添加到根节点的子节点数组中,这样就初步构建了一个简单的树结构。
树在计算机科学领域应用广泛,文件系统通过树结构高效管理文件和目录;数据库索引使用 B 树、B + 树等优化数据查询;路由表利用树结构快速查找目标地址;机器学习中的决策树,基于特征划分数据进行分类和回归。毫不夸张地说,树结构无处不在,是解决各种复杂问题的强大工具。
二、内存存储:树的三种 “户籍管理” 方式
树在内存中的存储方式决定了数据的访问和操作效率,常见的有双亲表示法、孩子表示法和孩子兄弟表示法,各有优劣。
2.1 双亲表示法
双亲表示法就像给每个节点发了一本 “户口本”,每个节点都记录着自己父节点在数组中的索引。根节点比较特殊,它没有父节点,所以父索引设为 - 1。例如公司组织架构中,每个员工(节点)都知道自己直属领导(父节点)的编号,通过这个编号能快速找到上级。
class PNode<E> {
E data;
int parent;
public PNode(E data, int parent) {
this.data = data;
this.parent = parent;
}
}
class PTree<E> {
private static final int DEFAULT_TREE_SIZE = 100;
private PNode<E>[] pNodes;
private int treeSize;
private int nodeNums;
@SuppressWarnings("unchecked")
public PTree(E data) {
treeSize = DEFAULT_TREE_SIZE;
pNodes = new PNode[treeSize];
pNodes[0] = new PNode<>(data, -1);
nodeNums = 1;
}
// 为指定节点添加子节点
public void addNode(E data, PNode<E> parent) {
for (int i = 0; i < treeSize; i++) {
if (pNodes[i] == null) {
pNodes[i] = new PNode<>(data, getPos(parent));
nodeNums++;
return;
}
}
throw new RuntimeException("该树已满,无法添加新节点");
}
// 返回指定节点(非根节点)的父节点
public PNode<E> getParent(PNode<E> pNode) {
if (pNode != null) {
return pNodes[pNode.parent];
}
return null;
}
// 返回包含指定值的节点在数组中的位置
public int getPos(PNode<E> node) {
for (int i = 0; i < treeSize; i++) {
if (pNodes[i] != null && pNodes[i].equals(node)) {
return i;
}
}
return -1;
}
}
上述代码中,PNode
类表示树中的节点,包含数据data
和父节点索引parent
。PTree
类实现了树的双亲表示法,提供了添加节点、获取父节点等方法。比如创建一个根节点为1
的树,再添加一个父节点为根节点,数据为2
的子节点,可以这样做:
public class ParentTreeTest {
public static void main(String[] args) {
PTree<Integer> pTree = new PTree<>(1);
PNode<Integer> parent = pTree.pNodes[0];
pTree.addNode(2, parent);
PNode<Integer> child = pTree.pNodes[1];
PNode<Integer> grandParent = pTree.getParent(child);
System.out.println("子节点2的父节点是:" + grandParent.data);
}
}
优点:查找父节点极为高效,时间复杂度为 O(1),就像员工直接看自己的 “户口本” 就能找到直属领导。
缺点:若要查找子节点,就需要遍历整个数组,时间复杂度达到 O(n),想象一下在公司里通过领导找所有下属,得把所有人的 “户口本” 翻一遍,效率较低。
2.2 孩子表示法
孩子表示法为每个节点配备了一个 “子女通讯录”,通过链表来维护它的子节点。每个节点包含数据和一个指向第一个子节点的指针,若有多个子节点,通过兄弟关系链接起来。例如在文件系统中,每个文件夹(节点)都有一个列表记录里面的子文件夹和文件(子节点)。
class CNode<E> {
int child;
CNode<E> nextChild;
public CNode(int child) {
this.child = child;
this.nextChild = null;
}
}
class PLNode<E> {
E data;
CNode<E> firstChild;
public PLNode(E data) {
this.data = data;
this.firstChild = null;
}
}
class CTree<E> {
private static final int DEFAULT_TREE_SIZE = 100;
private PLNode<E>[] nodes;
private int treeSize;
private int n;
@SuppressWarnings("unchecked")
public CTree() {
treeSize = DEFAULT_TREE_SIZE;
nodes = new PLNode[treeSize];
n = 0;
}
// 创建树
public void creatTree() {
java.util.Queue<PLNode<E>> q = new java.util.LinkedList<>();
java.util.Scanner sc = new java.util.Scanner(System.in);
System.out.println("请输入根节点");
E rootData = (E) sc.next();
nodes[0] = new PLNode<>(rootData);
q.add(nodes[0]);
n++;
while (!q.isEmpty() && n < treeSize) {
PLNode<E> p = q.remove();
System.out.println("请输入" + p.data + "的所有孩子,没有则输入# :");
String input = sc.next();
if (!"#".equals(input)) {
for (char c : input.toCharArray()) {
E childData = (E) (Character) c;
PLNode<E> newNode = new PLNode<>(childData);
nodes[n] = newNode;
q.add(newNode);
if (p.firstChild == null) {
p.firstChild = new CNode<>(n);
} else {
CNode<E> child = p.firstChild;
while (child.nextChild != null) {
child = child.nextChild;
}
child.nextChild = new CNode<>(n);
}
n++;
}
} else {
p.firstChild = null;
}
}
}
// 层次遍历树
public void levelQueueOrder() {
java.util.Queue<Integer> q = new java.util.LinkedList<>();
q.add(0);
while (!q.isEmpty()) {
int step = q.remove();
System.out.println(nodes[step].data);
CNode<E> p = nodes[step].firstChild;
while (p != null) {
q.add(p.child);
p = p.nextChild;
}
}
}
}
在这段代码中,CNode
类表示孩子链表中的节点,PLNode
类表示树中的节点,包含数据和指向第一个孩子节点的指针。CTree
类实现了树的孩子表示法,提供了创建树和层次遍历树的方法。用户可以通过输入创建树的节点关系,然后使用层次遍历输出树的节点。比如创建一个简单的树:
public class ChildTreeTest {
public static void main(String[] args) {
CTree<Character> ct = new CTree<>();
ct.creatTree();
System.out.println("层次遍历");
ct.levelQueueOrder();
}
}
优点:遍历子节点非常快,时间复杂度为 O(k),k 是子节点个数,就像文件夹直接查看自己的 “子女通讯录”,能迅速找到里面的内容。
缺点:如果节点没有子节点,链表为空,但仍然占用一定内存空间,造成空间浪费,就像一个空文件夹也占了存储位置。
2.3 孩子兄弟表示法
孩子兄弟表示法给每个节点安排了两个 “助手”,一个指向第一个孩子,另一个指向右兄弟。这种方式巧妙地将树转化为类似二叉树的结构,方便进行各种操作。比如在家族族谱中,可以通过第一个孩子找到这一脉的后代,通过右兄弟找到同辈的其他分支。
class TreeNode<E> {
E data;
TreeNode<E> firstChild;
TreeNode<E> nextSibling;
public TreeNode(E data) {
this.data = data;
this.firstChild = null;
this.nextSibling = null;
}
}
class SBTree<E> {
private TreeNode<E> root;
public SBTree(E rootValue) {
root = new TreeNode<>(rootValue);
}
// 为指定节点插入子节点
public TreeNode<E> insertSonNode(TreeNode<E> targetNode, E value) {
TreeNode<E> newSonNode = new TreeNode<>(value);
if (targetNode.firstChild != null) {
return insertBrotherNode(targetNode.firstChild, value);
} else {
targetNode.firstChild = newSonNode;
return newSonNode;
}
}
// 为指定节点插入兄弟节点
public TreeNode<E> insertBrotherNode(TreeNode<E> targetNode, E value) {
TreeNode<E> newBrotherNode = new TreeNode<>(value);
TreeNode<E> current = targetNode;
while (current.nextSibling != null) {
current = current.nextSibling;
}
current.nextSibling = newBrotherNode;
return newBrotherNode;
}
// 打印树
public static void printTree(TreeNode<E> root, int depth) {
if (root != null) {
for (int i = 0; i < depth; i++) {
System.out.print("\t|");
}
System.out.println(root.data);
printTree(root.firstChild, depth + 1);
printTree(root.nextSibling, depth);
}
}
}
这里TreeNode
类表示树中的节点,包含数据、指向第一个孩子的指针和指向右兄弟的指针。SBTree
类实现了树的孩子兄弟表示法,提供了插入子节点、插入兄弟节点和打印树的方法。通过这些方法,可以构建和展示一个树结构。例如创建一个根节点为A
的树,并插入一些子节点和兄弟节点:
public class SiblingTreeTest {
public static void main(String[] args) {
SBTree<String> tree = new SBTree<>("A");
TreeNode<String> son1 = tree.insertSonNode(tree.root, "B");
tree.insertSonNode(son1, "C");
TreeNode<String> son2 = tree.insertSonNode(tree.root, "D");
tree.insertBrotherNode(son2, "E");
System.out.println("树的结构如下:");
SBTree.printTree(tree.root, 0);
}
}
优点:空间利用率高,而且能方便地转化为二叉树进行操作,很多二叉树的算法都能复用,比如树的遍历、查找等操作变得更简单。
缺点:每个节点需要额外的指针来指向第一个孩子和右兄弟,增加了内存开销,不过换来的是更高效的操作。
三、树的核心操作与 Java 实现
3.1 基础操作
树的基础操作是理解和使用树结构的关键,包括插入节点、删除节点和查找节点。
插入节点:插入节点就像是往族谱里添加新成员。以普通树为例,在 Java 中,我们可以为TreeNode
类添加插入子节点的方法。
class TreeNode {
int data;
TreeNode[] children;
int childCount;
public TreeNode(int data, int numChildren) {
this.data = data;
this.children = new TreeNode[numChildren];
this.childCount = 0;
}
// 插入子节点的方法
public void insertChild(TreeNode child) {
if (childCount < children.length) {
children[childCount] = child;
childCount++;
} else {
System.out.println("无法插入,子节点已满");
}
}
}
这里的insertChild
方法用于将一个子节点插入到当前节点中。如果当前节点的子节点数组还有空间,就将子节点添加到数组中,并更新子节点计数;否则输出提示信息。使用时可以这样:
public class TreeInsertExample {
public static void main(String[] args) {
TreeNode root = new TreeNode(1, 3);
TreeNode child1 = new TreeNode(2, 0);
root.insertChild(child1);
}
}
删除节点:删除节点如同从族谱中移除某个成员及其后代。对于普通树,删除节点相对复杂,需要考虑节点的父节点、子节点以及兄弟节点的关系。假设我们要删除一个节点,并且让它的子节点直接连接到它的父节点(这里只是简单示例,实际可能更复杂)。
class Tree {
private TreeNode root;
public Tree(TreeNode root) {
this.root = root;
}
// 删除节点的方法
public void deleteNode(TreeNode nodeToDelete) {
if (nodeToDelete == root) {
root = null;
return;
}
TreeNode parent = findParent(nodeToDelete);
if (parent != null) {
for (int i = 0; i < parent.childCount; i++) {
if (parent.children[i] == nodeToDelete) {
for (int j = i; j < parent.childCount - 1; j++) {
parent.children[j] = parent.children[j + 1];
}
parent.childCount--;
// 将被删除节点的子节点直接连接到父节点
for (TreeNode child : nodeToDelete.children) {
if (child != null) {
parent.insertChild(child);
}
}
break;
}
}
}
}
// 辅助方法:查找节点的父节点
private TreeNode findParent(TreeNode node) {
if (node == root) {
return null;
}
java.util.Queue<TreeNode> queue = new java.util.LinkedList<>();
queue.add(root);
while (!queue.isEmpty()) {
TreeNode current = queue.poll();
for (TreeNode child : current.children) {
if (child != null) {
if (child == node) {
return current;
}
queue.add(child);
}
}
}
return null;
}
}
在deleteNode
方法中,首先判断要删除的节点是否是根节点,如果是则直接将根节点设为null
。然后通过findParent
方法找到要删除节点的父节点,在父节点的子节点数组中移除该节点,并将被删除节点的子节点添加到父节点。例如:
public class TreeDeleteExample {
public static void main(String[] args) {
TreeNode root = new TreeNode(1, 3);
TreeNode child1 = new TreeNode(2, 0);
TreeNode child2 = new TreeNode(3, 0);
root.insertChild(child1);
root.insertChild(child2);
Tree tree = new Tree(root);
tree.deleteNode(child1);
}
}
查找节点:查找节点就像在族谱中寻找特定的人。可以通过深度优先搜索(DFS)或广度优先搜索(BFS)来实现。下面是使用 DFS 查找节点的示例。
class TreeNode {
int data;
TreeNode[] children;
int childCount;
public TreeNode(int data, int numChildren) {
this.data = data;
this.children = new TreeNode[numChildren];
this.childCount = 0;
}
// 查找节点的方法(DFS)
public TreeNode findNode(int targetData) {
if (data == targetData) {
return this;
}
for (TreeNode child : children) {
if (child != null) {
TreeNode found = child.findNode(targetData);
if (found != null) {
return found;
}
}
}
return null;
}
}
findNode
方法通过递归的方式在树中查找目标节点。如果当前节点的数据等于目标数据,则返回当前节点;否则递归地在子节点中查找。使用时:
public class TreeFindExample {
public static void main(String[] args) {
TreeNode root = new TreeNode(1, 3);
TreeNode child1 = new TreeNode(2, 0);
TreeNode child2 = new TreeNode(3, 0);
root.insertChild(child1);
root.insertChild(child2);
TreeNode found = root.findNode(2);
if (found != null) {
System.out.println("找到节点,数据为:" + found.data);
} else {
System.out.println("未找到节点");
}
}
}
3.2 层序遍历(BFS)
层序遍历(BFS)是树的一种重要遍历方式,它按照从上到下、从左到右的顺序逐层访问树中的节点,就像按楼层依次访问大楼里的房间。BFS 基于队列实现。
import java.util.LinkedList;
import java.util.Queue;
class TreeNode {
int data;
TreeNode[] children;
int childCount;
public TreeNode(int data, int numChildren) {
this.data = data;
this.children = new TreeNode[numChildren];
this.childCount = 0;
}
}
class TreeTraversal {
// 层序遍历的方法
public static void levelOrderTraversal(TreeNode root) {
if (root == null) {
return;
}
Queue<TreeNode> queue = new LinkedList<>();
queue.add(root);
while (!queue.isEmpty()) {
TreeNode current = queue.poll();
System.out.print(current.data + " ");
for (TreeNode child : current.children) {
if (child != null) {
queue.add(child);
}
}
}
}
}
在levelOrderTraversal
方法中,首先将根节点加入队列。然后在队列不为空的情况下,取出队列头部的节点,输出其数据,并将其所有子节点加入队列。这样就实现了层序遍历。例如:
public class TreeLevelOrderExample {
public static void main(String[] args) {
TreeNode root = new TreeNode(1, 3);
TreeNode child1 = new TreeNode(2, 0);
TreeNode child2 = new TreeNode(3, 0);
TreeNode child3 = new TreeNode(4, 0);
root.insertChild(child1);
root.insertChild(child2);
child1.insertChild(child3);
System.out.println("层序遍历结果:");
TreeTraversal.levelOrderTraversal(root);
}
}
输出结果会按照层序依次打印出树中节点的数据。层序遍历在实际应用中非常有用,比如在计算树的高度时,可以通过层序遍历找到最深的叶子节点;在判断一棵树是否为完全二叉树时,也可以利用层序遍历的特性来实现。
四、树的典型应用场景
4.1 文件系统管理
在文件系统中,树结构用于组织文件和目录,就像一个庞大的家族族谱,每个目录是一个节点,文件是叶节点。以 Windows 文件系统为例,C 盘是根节点,下面有 “Program Files”“Users” 等文件夹(子节点),“Users” 文件夹下又有各个用户的文件夹,每个用户文件夹里还有文档、图片等文件。这种树状结构使得文件的管理和查找非常高效。比如要查找 “C:\Users\John\Documents\report.docx” 这个文件,通过树结构可以快速定位到 “Users” 文件夹,再找到 “John” 文件夹,最后找到 “Documents” 文件夹里的 “report.docx” 文件。如果文件系统不用树结构,而是把所有文件和文件夹放在一个线性列表里,查找文件时就需要逐个遍历,效率会极其低下。
4.2 字典树(Trie)
字典树(Trie)是一种专门用于处理字符串的多叉树结构,它的每个节点代表一个字符,从根节点到叶节点的路径表示一个完整的字符串。在搜索引擎的自动补全功能中,字典树发挥着重要作用。当用户输入 “app” 时,搜索引擎利用字典树可以快速找到 “apple”“application”“apparatus” 等以 “app” 为前缀的单词,并展示给用户作为搜索建议。再比如在拼写检查中,将正确的单词构建成字典树,当用户输入错误拼写时,能快速找到可能的正确拼写建议。
class TrieNode {
private TrieNode[] children;
private boolean isEndOfWord;
public TrieNode() {
children = new TrieNode[26];
isEndOfWord = false;
}
public TrieNode[] getChildren() {
return children;
}
public boolean isEndOfWord() {
return isEndOfWord;
}
public void setEndOfWord(boolean endOfWord) {
isEndOfWord = endOfWord;
}
}
class Trie {
private TrieNode root;
public Trie() {
root = new TrieNode();
}
// 插入单词到字典树
public void insert(String word) {
TrieNode current = root;
for (char c : word.toCharArray()) {
int index = c - 'a';
if (current.getChildren()[index] == null) {
current.getChildren()[index] = new TrieNode();
}
current = current.getChildren()[index];
}
current.setEndOfWord(true);
}
// 检查单词是否在字典树中
public boolean search(String word) {
TrieNode current = root;
for (char c : word.toCharArray()) {
int index = c - 'a';
if (current.getChildren()[index] == null) {
return false;
}
current = current.getChildren()[index];
}
return current.isEndOfWord();
}
// 检查是否有以prefix为前缀的单词
public boolean startsWith(String prefix) {
TrieNode current = root;
for (char c : prefix.toCharArray()) {
int index = c - 'a';
if (current.getChildren()[index] == null) {
return false;
}
current = current.getChildren()[index];
}
return true;
}
}
在上述代码中,TrieNode
类表示字典树的节点,包含一个长度为 26 的TrieNode
数组children
,用于存储 26 个英文字母对应的子节点,以及一个布尔变量isEndOfWord
,用于标记该节点是否是一个单词的结尾。Trie
类实现了字典树的基本操作,包括插入单词、搜索单词和检查前缀。例如:
public class TrieExample {
public static void main(String[] args) {
Trie trie = new Trie();
trie.insert("apple");
System.out.println(trie.search("apple"));
System.out.println(trie.search("app"));
System.out.println(trie.startsWith("app"));
}
}
这段代码创建了一个Trie
对象,并插入了单词 “apple”。然后分别调用search
方法查找 “apple” 和 “app”,调用startsWith
方法检查是否有以 “app” 为前缀的单词。通过字典树,这些操作的时间复杂度都与单词的长度成正比,大大提高了字符串处理的效率。
4.3 赫夫曼编码
赫夫曼编码是一种用于数据压缩的算法,它基于赫夫曼树(一种特殊的二叉树)来实现。赫夫曼树的构建依据字符的出现频率,频率高的字符靠近根节点,频率低的字符远离根节点。在文本压缩中,假设一段文本中字符‘a’出现 100 次,字符‘b’出现 50 次,字符‘c’出现 20 次,字符‘d’出现 30 次。通过构建赫夫曼树,给出现频率高的字符‘a’分配较短的编码(比如 0),给出现频率较低的字符‘c’分配较长的编码(比如 110)。这样在存储或传输文本时,就能用较短的编码代替原始字符,从而实现数据压缩。
import java.util.PriorityQueue;
class HuffmanNode implements Comparable<HuffmanNode> {
char ch;
int freq;
HuffmanNode left, right;
HuffmanNode(char ch, int freq) {
this.ch = ch;
this.freq = freq;
this.left = null;
this.right = null;
}
@Override
public int compareTo(HuffmanNode other) {
return this.freq - other.freq;
}
}
class HuffmanCoding {
public static void printCodes(HuffmanNode root, String code) {
if (root == null) {
return;
}
if (root.left == null && root.right == null) {
System.out.println(root.ch + ": " + code);
return;
}
printCodes(root.left, code + "0");
printCodes(root.right, code + "1");
}
public static void main(String[] args) {
char[] chars = {'a', 'b', 'c', 'd'};
int[] freq = {100, 50, 20, 30};
PriorityQueue<HuffmanNode> pq = new PriorityQueue<>();
for (int i = 0; i < chars.length; i++) {
HuffmanNode node = new HuffmanNode(chars[i], freq[i]);
pq.add(node);
}
while (pq.size() > 1) {
HuffmanNode left = pq.poll();
HuffmanNode right = pq.poll();
HuffmanNode parent = new HuffmanNode('\0', left.freq + right.freq);
parent.left = left;
parent.right = right;
pq.add(parent);
}
HuffmanNode root = pq.poll();
printCodes(root, "");
}
}
在这段代码中,HuffmanNode
类表示赫夫曼树的节点,包含字符ch
、频率freq
以及左右子节点。HuffmanCoding
类实现了赫夫曼编码的过程。首先,将每个字符及其频率封装成HuffmanNode
对象并放入优先队列pq
中。然后,不断从优先队列中取出频率最小的两个节点,构建一个新的父节点,将这两个节点作为父节点的左右子节点,再将父节点放回优先队列。直到优先队列中只剩下一个节点,这个节点就是赫夫曼树的根节点。最后,通过递归遍历赫夫曼树,为每个字符生成对应的编码并输出。通过赫夫曼编码,能够有效地减少数据的存储空间,提高数据传输和存储的效率。
五、树的优化与陷阱
5.1 平衡树优化
在树的应用中,为了提高性能,平衡树是一种重要的优化手段。常见的平衡树有 AVL 树和红黑树,它们通过不同的方式来维持树的平衡,避免树在插入和删除节点时出现极端的不平衡情况,从而保证操作的时间复杂度在合理范围内。
AVL 树:AVL 树是一种高度平衡的二叉搜索树,它的每个节点都有一个平衡因子,用于衡量该节点的左右子树的高度差,平衡因子的绝对值不超过 1。这意味着 AVL 树的左右子树高度差始终控制在 1 以内,保证了树的高度相对较低,从而提高了查找、插入和删除操作的效率。例如,当我们依次插入节点 1、2、3 时,如果是普通的二叉搜索树,可能会形成一个单边的树,高度为 3;但在 AVL 树中,它会自动调整节点的位置,使树的高度保持为 2,提高了操作效率。
class AVLNode {
int data;
AVLNode left;
AVLNode right;
int height;
public AVLNode(int data) {
this.data = data;
this.height = 1;
}
}
class AVLTree {
private AVLNode root;
// 获取节点高度
private int height(AVLNode node) {
return node == null ? 0 : node.height;
}
// 更新节点高度
private void updateHeight(AVLNode node) {
node.height = Math.max(height(node.left), height(node.right)) + 1;
}
// 获取平衡因子
private int getBalanceFactor(AVLNode node) {
return height(node.left) - height(node.right);
}
// 左旋
private AVLNode rotateLeft(AVLNode z) {
AVLNode y = z.right;
AVLNode T2 = y.left;
y.left = z;
z.right = T2;
updateHeight(z);
updateHeight(y);
return y;
}
// 右旋
private AVLNode rotateRight(AVLNode y) {
AVLNode x = y.left;
AVLNode T2 = x.right;
x.right = y;
y.left = T2;
updateHeight(y);
updateHeight(x);
return x;
}
// 插入节点
public AVLNode insert(AVLNode node, int data) {
if (node == null) {
return new AVLNode(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;
}
updateHeight(node);
int balance = getBalanceFactor(node);
// 左左情况
if (balance > 1 && data < node.left.data) {
return rotateRight(node);
}
// 右右情况
if (balance < -1 && data > node.right.data) {
return rotateLeft(node);
}
// 左右情况
if (balance > 1 && data > node.left.data) {
node.left = rotateLeft(node.left);
return rotateRight(node);
}
// 右左情况
if (balance < -1 && data < node.right.data) {
node.right = rotateRight(node.right);
return rotateLeft(node);
}
return node;
}
}
在上述代码中,AVLNode
类表示 AVL 树的节点,包含数据data
、左右子节点指针left
和right
以及节点高度height
。AVLTree
类实现了 AVL 树的基本操作,包括获取节点高度、更新节点高度、获取平衡因子、左旋、右旋和插入节点。在插入节点时,会检查树的平衡性,如果出现不平衡的情况,根据不同的类型(左左、右右、左右、右左)进行相应的旋转操作,以恢复树的平衡。
红黑树:红黑树也是一种自平衡的二叉搜索树,它通过对节点进行颜色标记(红色或黑色)来实现平衡。红黑树满足以下性质:根节点是黑色;每个叶子节点(NIL 节点,即空节点)都是黑色;如果一个节点是红色,那么它的两个子节点都是黑色;从任一节点到其每个叶子的所有路径上具有相同数目的黑色节点。这些性质保证了红黑树的最长路径不会超过最短路径的两倍,从而实现了近似平衡。例如,在插入新节点时,如果破坏了红黑树的性质,会通过旋转和颜色调整来恢复平衡。
class RedBlackNode {
int data;
RedBlackNode left;
RedBlackNode right;
RedBlackNode parent;
boolean isRed;
public RedBlackNode(int data) {
this.data = data;
this.isRed = true;
}
}
class RedBlackTree {
private RedBlackNode root;
// 左旋
private void leftRotate(RedBlackNode x) {
RedBlackNode y = x.right;
x.right = y.left;
if (y.left != null) {
y.left.parent = x;
}
y.parent = x.parent;
if (x.parent == null) {
root = y;
} else if (x == x.parent.left) {
x.parent.left = y;
} else {
x.parent.right = y;
}
y.left = x;
x.parent = y;
}
// 右旋
private void rightRotate(RedBlackNode y) {
RedBlackNode x = y.left;
y.left = x.right;
if (x.right != null) {
x.right.parent = y;
}
x.parent = y.parent;
if (y.parent == null) {
root = x;
} else if (y == y.parent.right) {
y.parent.right = x;
} else {
y.parent.left = x;
}
x.right = y;
y.parent = x;
}
// 插入修复
private void insertFixup(RedBlackNode z) {
while (z.parent != null && z.parent.isRed) {
if (z.parent == z.parent.parent.left) {
RedBlackNode y = z.parent.parent.right;
if (y != null && y.isRed) {
z.parent.isRed = false;
y.isRed = false;
z.parent.parent.isRed = true;
z = z.parent.parent;
} else {
if (z == z.parent.right) {
z = z.parent;
leftRotate(z);
}
z.parent.isRed = false;
z.parent.parent.isRed = true;
rightRotate(z.parent.parent);
}
} else {
RedBlackNode y = z.parent.parent.left;
if (y != null && y.isRed) {
z.parent.isRed = false;
y.isRed = false;
z.parent.parent.isRed = true;
z = z.parent.parent;
} else {
if (z == z.parent.left) {
z = z.parent;
rightRotate(z);
}
z.parent.isRed = false;
z.parent.parent.isRed = true;
leftRotate(z.parent.parent);
}
}
}
root.isRed = false;
}
// 插入节点
public void insert(int data) {
RedBlackNode z = new RedBlackNode(data);
RedBlackNode y = null;
RedBlackNode x = root;
while (x != null) {
y = x;
if (z.data < x.data) {
x = x.left;
} else {
x = x.right;
}
}
z.parent = y;
if (y == null) {
root = z;
} else if (z.data < y.data) {
y.left = z;
} else {
y.right = z;
}
if (z.parent == null) {
z.isRed = false;
return;
}
if (z.parent.parent == null) {
return;
}
insertFixup(z);
}
}
在这段代码中,RedBlackNode
类表示红黑树的节点,包含数据data
、左右子节点指针left
和right
、父节点指针parent
以及颜色标记isRed
。RedBlackTree
类实现了红黑树的插入操作,在插入新节点后,通过insertFixup
方法进行旋转和颜色调整,以保证红黑树的性质。左旋和右旋操作是调整树结构的关键,通过这些操作来恢复树的平衡。
5.2 内存泄漏风险
在使用树结构时,内存泄漏是一个需要警惕的问题。当我们动态分配内存来创建树节点,但在不再需要这些节点时没有正确释放内存,就会导致内存泄漏。比如在删除树节点时,如果没有递归地释放子节点的内存,就会造成内存浪费。在 C++ 中,使用new
创建节点后,需要用delete
来释放内存;在 Java 中,虽然有垃圾回收机制,但如果对象之间存在循环引用,也可能导致内存无法被及时回收。例如,两个节点互相引用对方作为子节点,形成循环,垃圾回收器可能无法识别并回收它们占用的内存。
class MemoryLeakTreeNode {
int data;
MemoryLeakTreeNode left;
MemoryLeakTreeNode right;
public MemoryLeakTreeNode(int data) {
this.data = data;
}
}
class MemoryLeakTree {
private MemoryLeakTreeNode root;
// 插入节点(简单示例,不涉及内存泄漏问题)
public void insert(int data) {
MemoryLeakTreeNode newNode = new MemoryLeakTreeNode(data);
if (root == null) {
root = newNode;
return;
}
MemoryLeakTreeNode current = root;
while (true) {
if (data < current.data) {
if (current.left == null) {
current.left = newNode;
return;
}
current = current.left;
} else {
if (current.right == null) {
current.right = newNode;
return;
}
current = current.right;
}
}
}
// 错误的删除节点方法,会导致内存泄漏
public void wrongDelete(MemoryLeakTreeNode nodeToDelete) {
if (nodeToDelete == root) {
root = null;
return;
}
MemoryLeakTreeNode parent = findParent(nodeToDelete);
if (parent != null) {
if (parent.left == nodeToDelete) {
parent.left = null;
} else {
parent.right = null;
}
// 这里没有释放nodeToDelete子节点的内存,会导致内存泄漏
}
}
// 辅助方法:查找节点的父节点
private MemoryLeakTreeNode findParent(MemoryLeakTreeNode node) {
if (node == root) {
return null;
}
MemoryLeakTreeNode current = root;
while (current != null) {
if (current.left == node || current.right == node) {
return current;
}
if (node.data < current.data) {
current = current.left;
} else {
current = current.right;
}
}
return null;
}
}
在上述代码中,wrongDelete
方法在删除节点时,只是将父节点指向被删除节点的指针设为null
,但没有处理被删除节点的子节点,导致子节点占用的内存无法被释放,从而引发内存泄漏。正确的做法应该是在删除节点时,递归地释放子节点的内存。
为了避免内存泄漏,在使用树结构时,要养成良好的内存管理习惯。在 C++ 中,确保每个new
都有对应的delete
;在 Java 中,尽量避免对象之间的循环引用,如果无法避免,可以手动打破循环引用,以便垃圾回收器能够正常工作。同时,在编写代码时,要仔细考虑节点的生命周期,在节点不再需要时及时释放内存,以提高程序的性能和稳定性。
六、总结与避坑指南
树作为一种基础且强大的数据结构,在层次化数据管理、高效路径查找等方面有着独特优势,广泛应用于文件系统、字典树、赫夫曼编码等场景。掌握树在内存中的存储方式,如双亲表示法、孩子表示法和孩子兄弟表示法,是深入理解树结构的关键,不同存储方式各有优劣,需根据实际需求选择。
在使用树结构时,要注意一些常见问题。采用链式存储时,可能会产生内存碎片,影响内存利用率;在进行递归操作时,要警惕递归深度过深导致栈溢出。为了避免这些问题,可以使用孩子兄弟表示法将树转化为二叉树,利用二叉树的成熟算法提高效率;在内存敏感的场景中,优先选择双亲表示法;对于递归操作,尽量用迭代代替递归,防止栈溢出。
树是分层数据结构的基石,掌握其内存原理能帮你设计出更高效的算法。你在哪些场景用过树结构?评论区聊聊你的实战经验!👇