数据结构实验:二叉搜索树与表达式树的实现与应用
1. 二叉搜索树操作
在二叉搜索树的操作中,有一个需求是输出小于指定键的所有键。下面是具体的操作步骤和分析。
1.1 输出小于指定键的键
-
操作定义
:
writeLessThan
操作的目的是输出二叉搜索树中小于searchKey
的所有键,且按升序输出。 -
低效实现方式
:可以使用中序遍历整个树,将每个键与
searchKey
比较,输出小于searchKey
的键。但这种方法效率低,因为会搜索那些不可能包含小于searchKey
键的子树。 -
高效实现思路
:根据根节点的值与
searchKey
的大小关系,决定是否搜索右子树。例如,若根节点键为 43,searchKey
为 37,则无需搜索右子树。 -
操作步骤
:
-
实现
writeLessThan
操作并添加到bstree.cpp
文件中,其原型已包含在bstree.h
的BSTree
类声明中。 -
激活
test11.cpp
测试程序中的<
命令,通过移除以//<
开头行的注释分隔符(和字符<
)。 -
准备测试计划,涵盖各种树和
searchKey
值,包括不在特定树中的值。测试用例应包括限制搜索到根节点左子树、左子树和部分右子树、树中最左分支以及整棵树的情况。 - 执行测试计划,若发现实现中的错误,修正后再次执行。
-
实现
以下是测试计划的表格形式:
| 测试用例 | 命令 | 预期结果 | 检查 |
| ---- | ---- | ---- | ---- |
| | | | |
1.2 二叉搜索树高度问题
- 最短和最高树高度 :对于由 N 个不同键构建的二叉搜索树,最短树的高度为 $\lfloor log_2 N \rfloor$,最高树的高度为 N - 1。例如,当 N = 3 时,最短树高度为 1(完全二叉树),最高树高度为 2(退化为链表)。
1.3 最短二叉搜索树操作时间复杂度
对于包含 N 个不同键的最短二叉搜索树,以下是各操作的最坏情况时间复杂度分析:
| 操作 | 时间复杂度 | 解释 |
| ---- | ---- | ---- |
| retrieve | O(log N) | 最短树接近完全二叉树,每次查找可排除一半节点,类似二分查找。 |
| insert | O(log N) | 插入时需查找插入位置,同样接近二分查找的时间复杂度。 |
| remove | O(log N) | 移除节点时也需查找节点位置,时间复杂度与查找类似。 |
| writeKeys | O(N) | 需遍历树中所有节点,因此时间复杂度为线性。 |
2. 表达式树相关操作
表达式树是一种用于表示算术表达式的二叉树结构,下面将详细介绍其操作和实现。
2.1 表达式树概述
通常算术表达式以线性形式书写,但求值时将其视为层次结构。例如,表达式
(1 + 3) * (6 - 4)
,先计算
1 + 3
和
6 - 4
,再将结果相乘。可以用二叉树表示这种层次结构,即表达式树。
表达式树的数据项包括算术运算符或数值,结构上每个含运算符的节点有两个子节点,分别表示操作数;含数值的节点为叶节点。
表达式树的操作包括:
-
ExprTree()
:构造函数,创建空表达式树。
-
~ExprTree()
:析构函数,释放表达式树占用的内存。
-
build()
:从键盘读取前缀形式的算术表达式并构建相应的表达式树。
-
expression()
:以完全括号化的中缀形式输出表达式。
-
evaluate()
:计算表达式的值。
-
clear()
:移除表达式树中的所有数据项。
-
showStructure()
:以从左(根)到右(叶)的方向输出表达式树,用于测试/调试。
graph TD;
A[*] --> B[+];
A --> C[-];
B --> D[1];
B --> E[3];
C --> F[6];
C --> G[4];
2.2 表达式树的构建
从前缀形式的算术表达式构建表达式树的递归过程如下:
1. 读取下一个算术运算符或数值。
2. 创建包含该运算符或数值的节点。
3. 若节点包含运算符,则递归构建对应操作数的子树;否则,该节点为叶节点。
例如,对于表达式
* + 1 3 - 6 4
,构建过程如下:
1. 读取
*
,创建根节点。
2. 读取
+
,创建左子节点。
3. 读取
1
,创建左子节点的左子节点。
4. 读取
3
,创建左子节点的右子节点。
5. 读取
-
,创建右子节点。
6. 读取
6
,创建右子节点的左子节点。
7. 读取
4
,创建右子节点的右子节点。
2.3 表达式树的实现步骤
-
预实验练习 :
-
使用链表树结构实现表达式树 ADT,假设算术表达式由一位非负整数和四个基本算术运算符组成,且以单行前缀形式输入。实现使用两个类:
ExprTreeNode
表示树节点,ExprTree
表示整个树结构。
```cpp
class ExprTree; // Forward declaration of the ExprTree class
class ExprTreeNode // Facilitator class for the ExprTree class
{
private:
// Constructor
ExprTreeNode ( char elem,
ExprTreeNode leftPtr, ExprTreeNode rightPtr );
// Data members
char dataItem; // Expression tree data item
ExprTreeNode left, // Pointer to the left child
right; // Pointer to the right child
friend class ExprTree;
};
class ExprTree
{
public:
// Constructor
ExprTree ();
// Destructor
~ExprTree ();
// Expression tree manipulation operations
void build () // Build tree from prefix expression
throw ( bad_alloc );
void expression () const; // Output expression in infix form
float evaluate () const // Evaluate expression
throw ( logic_error );
void clear (); // Clear tree
// Output the tree structure – used in testing/debugging
void showStructure () const;
private:
// Recursive partners of the public member functions – insert
// prototypes of these functions here.
void showSub ( ExprTreeNode p, int level ) const;
// Data member
ExprTreeNode root; // Pointer to the root node
};
`` 2. 在
exprtree.h文件中添加递归私有成员函数的原型。 3. 将表达式树 ADT 的实现保存到
exprtree.cpp文件中,并做好代码文档。 - **桥梁练习**: 1. 编译
exprtree.cpp文件中的表达式树 ADT 实现。 2. 编译
test12.cpp` 中的测试程序。
3. 链接步骤 1 和 2 生成的目标文件。
4. 完成测试计划,填写每个算术表达式的预期结果,可添加更多表达式。
5. 执行测试计划,若发现错误,修正后再次执行。 -
使用链表树结构实现表达式树 ADT,假设算术表达式由一位非负整数和四个基本算术运算符组成,且以单行前缀形式输入。实现使用两个类:
以下是表达式树操作测试计划的表格:
| 测试用例 | 算术表达式 | 预期结果 | 检查 |
| ---- | ---- | ---- | ---- |
| 一个运算符 | +34 | | |
| 嵌套运算符 |
+34/52 | | |
| 所有运算符在开头 | -/
9321 | | |
| 不均匀嵌套 | *4+6-75 | | |
| 零被除数 | /02 | | |
| 一位数 | 7 | | |
3. 逻辑表达式树操作
计算机的逻辑电路可以用逻辑表达式树表示,下面介绍逻辑表达式树的构建和应用。
3.1 逻辑表达式树构建
逻辑表达式由布尔逻辑运算符(AND、OR、NOT)和布尔值(True 为 1,False 为 0)组成。例如,逻辑表达式
(1 * 0) + (1 * -0)
可表示为前缀形式
+*10*1-0
。
构建逻辑表达式树时,对于一元运算符 NOT(
-
),将其右子节点指向操作数,左子节点置为 null。
3.2 操作步骤
-
修改
exprtree.h
中evaluate()
函数的原型,使其返回整数值而非浮点数,可能还需修改相关递归私有成员函数的原型,保存到logitree.h
文件中。 -
基于
logitree.h
中的声明,实现支持逻辑表达式的表达式树 ADT,保存到logitree.cpp
文件中。 -
修改
test12.cpp
测试程序,包含logitree.h
头文件代替算术表达式树的头文件。 - 编译和链接逻辑表达式树 ADT 实现和修改后的测试程序。
- 完成测试计划,填写每个逻辑表达式的预期结果,可添加更多表达式。
- 执行测试计划,若发现错误,修正后再次执行。
以下是逻辑表达式树操作测试计划的表格:
| 测试用例 | 逻辑表达式 | 预期结果 | 检查 |
| ---- | ---- | ---- | ---- |
| 一个运算符 | +10 | | |
| 嵌套运算符 |
+10+01 | | |
| NOT(布尔值) | +
10
1-0 | | |
| NOT(子表达式) | +-1-
11 | | |
| NOT(嵌套表达式) | -*+110 | | |
| 双重否定 | –1 | | |
| 布尔值 | 1 | | |
3.3 二进制加法应用
使用逻辑表达式树实现一位二进制数加法,输入为两个一位二进制数 X 和 Y,输出为一位和 S 和一位进位 C。逻辑表达式为:
- C =
XY
- S = +
X - Y * -XY
通过逻辑表达式树实现验证以下表格:
| X | Y | C =
XY | S = +
X - Y * -XY |
| ---- | ---- | ---- | ---- |
| 0 | 0 |
00 = 0 | +
0 - 0 * -00 = 0 |
| 0 | 1 |
01 = 0 | +
0 - 1 * -01 = 1 |
| 1 | 0 |
10 = 0 | +
1 - 0 * -10 = 1 |
| 1 | 1 |
11 = 1 | +
1 - 1 * -11 = 0 |
4. 表达式树其他操作
除了上述操作,表达式树还有复制构造函数和交换操作。
4.1 复制构造函数
-
操作定义
:
ExprTree ( const ExprTree &valueTree )
复制构造函数,创建valueTree
的副本。 -
操作步骤
:
-
实现该操作并添加到
exprtree.cpp
文件中,其原型已包含在exprtree.h
的ExprTree
类声明中。 -
激活
test12.cpp
测试程序中复制构造函数的测试,通过移除以//2
开头行的注释分隔符(和字符2
)。 - 准备测试计划,涵盖各种表达式树,包括空树和仅含一个数据项的树。
- 执行测试计划,若发现错误,修正后再次执行。
-
实现该操作并添加到
以下是复制构造函数测试计划的表格:
| 测试用例 | 算术表达式 | 预期结果 | 检查 |
| ---- | ---- | ---- | ---- |
| | | | |
4.2 交换操作
-
操作定义
:
commute()
操作交换表达式树中每个算术运算符的操作数。 -
操作步骤
:
-
实现该操作并添加到
exprtree.cpp
文件中,其原型已包含在exprtree.h
的ExprTree
类声明中。 -
激活
test12.cpp
测试程序中交换操作的测试,通过移除以//3
开头行的注释分隔符(和字符3
)。 - 准备测试计划,涵盖各种算术表达式。
- 执行测试计划,若发现错误,修正后再次执行。
-
实现该操作并添加到
以下是交换操作测试计划的表格:
| 测试用例 | 算术表达式 | 预期结果 | 检查 |
| ---- | ---- | ---- | ---- |
| | | | |
5. 表达式树遍历分析
在实现表达式树 ADT 操作时,不同操作基于不同的树遍历方式:
| 操作 | 遍历方式 | 解释 |
| ---- | ---- | ---- |
| build | 前序遍历 | 从前缀表达式构建树,先处理根节点(运算符),再递归构建子树。 |
| expression | 中序遍历 | 中序遍历可按中缀形式输出表达式。 |
| evaluate | 后序遍历 | 后序遍历先计算子树的值,再计算根节点的值。 |
| clear | 后序遍历 | 先释放子树内存,再释放根节点内存,避免内存泄漏。 |
6. 函数输出分析
考虑以下两个函数:
void writeSub1 ( ExprTreeNode *p ) const
{
if ( p != 0 )
{
writeSub1(p->left);
cout << p->dataItem;
writeSub1(p->right);
}
}
void writeSub2 ( ExprTreeNode *p ) const
{
if ( p->left != 0 ) writeSub2(p->left);
cout << p->dataItem;
if ( p->right != 0 ) writeSub2(p->right);
}
对于非空表达式树的根节点指针
root
,
writeSub1(root);
和
writeSub2(root);
通常会产生相同输出。但
writeSub2
会在子节点为空时避免递归调用,可减少不必要的函数调用开销,在处理大规模树时可能更高效。
数据结构实验:二叉搜索树与表达式树的实现与应用
7. 总结与拓展
通过上述对二叉搜索树和表达式树的操作实现与分析,我们深入了解了这些数据结构的特性和应用场景。下面对整个过程进行总结,并探讨一些可能的拓展方向。
7.1 总结
- 二叉搜索树 :实现了输出小于指定键的操作,分析了不同高度二叉搜索树的特性以及最短树中各操作的时间复杂度。这些操作和分析有助于我们在实际应用中根据需求选择合适的数据结构和算法。
- 表达式树 :完成了表达式树的构建、求值、复制、交换等操作,同时分析了不同操作所基于的树遍历方式。表达式树为处理算术和逻辑表达式提供了一种有效的数据结构,能够方便地进行表达式的解析和计算。
7.2 拓展方向
- 支持更多运算符和数据类型 :当前实现仅支持基本的算术和布尔运算符,以及一位非负整数和布尔值。可以拓展支持更多的运算符(如幂运算、取模运算等)和数据类型(如浮点数、多位数等),以处理更复杂的表达式。
- 优化算法性能 :虽然在某些操作中已经考虑了效率问题,但仍有进一步优化的空间。例如,可以使用更高效的树平衡算法来保持二叉搜索树的平衡性,从而提高查找、插入和删除操作的性能。
- 应用拓展 :表达式树可以应用于更多领域,如编译器设计、计算器开发、人工智能中的规则引擎等。可以将这些操作集成到实际的应用程序中,实现更强大的功能。
8. 常见问题与解决方案
在实现过程中,可能会遇到一些常见问题,下面列举并给出解决方案。
8.1 内存管理问题
在使用链表树结构时,需要注意内存的分配和释放。如果没有正确释放内存,会导致内存泄漏。解决方案是在析构函数中递归释放所有节点的内存,确保没有内存残留。
ExprTree::~ExprTree() {
clear();
}
void ExprTree::clear() {
if (root != nullptr) {
clearSub(root);
root = nullptr;
}
}
void ExprTree::clearSub(ExprTreeNode *p) {
if (p != nullptr) {
clearSub(p->left);
clearSub(p->right);
delete p;
}
}
8.2 递归调用栈溢出问题
在使用递归方法构建和遍历树时,如果树的深度过大,可能会导致递归调用栈溢出。解决方案是使用迭代方法代替递归方法,或者对递归深度进行限制。
8.3 输入格式错误问题
在读取前缀表达式时,如果输入格式不符合要求,可能会导致程序出错。解决方案是在读取输入时进行格式检查,对不符合要求的输入进行错误处理。
9. 流程图总结
下面是表达式树构建和求值的流程图,帮助我们更直观地理解整个过程。
graph TD;
A[开始] --> B[读取前缀表达式];
B --> C[创建根节点];
C --> D{节点为运算符?};
D -- 是 --> E[递归构建左子树];
E --> F[递归构建右子树];
D -- 否 --> G[节点为叶节点];
F --> H[表达式树构建完成];
H --> I{表达式树非空?};
I -- 是 --> J[后序遍历求值];
I -- 否 --> K[输出错误信息];
J --> L[输出表达式值];
K --> M[结束];
L --> M[结束];
10. 总结回顾
通过本次实验,我们学习了二叉搜索树和表达式树的相关知识,掌握了它们的操作实现和应用。在实现过程中,我们不仅提高了编程能力,还深入理解了数据结构和算法的重要性。
在实际应用中,我们可以根据具体需求选择合适的数据结构和算法,以提高程序的性能和效率。同时,我们也应该注意内存管理、递归调用栈溢出等问题,确保程序的稳定性和可靠性。
希望通过本文的介绍,能够帮助读者更好地理解和应用二叉搜索树和表达式树,在实际开发中发挥它们的作用。