一、认识二叉树
在进入本文之前先要对二叉树有一定的了解,引用中文维基百科的说法:
二叉树是计算机科学中一种数据结构。在计算机科学中,二叉树(英语:Binary tree)是每个节点最多只有两个分支(即不存在分支度大于2的节点)的树结构。通常分支被称作“左子树”或“右子树”。二叉树的分支具有左右次序,不能随意颠倒。
二叉树通常作为数据结构应用,典型用法是对节点定义一个标记函数,将一些值与每个节点相关系。这样标记的二叉树就可以实现二叉搜索树和二叉堆,并应用于高效率的搜索和排序。
一个简单的二叉树
有了对二叉树的简单认知以后,我们一起来看看二叉树的遍历。
二、二叉树的先(前)序、中序、后序遍历的递归实现
三种遍历顺序如下:
先序遍历:根-左子树-右子树,简称 根左右
中序遍历:左子树-根-右子树,简称 左根右
后序遍历:左子树-右子树-根,简称 左右根
简单解释下:二叉树先序遍历的实现思想是:
- 访问根节点;
- 访问当前节点的左子树;
- 若当前节点无左子树,则访问当前节点的右子树;
剩下的两种遍历方式同理。
只要按照顺序去遍历相关节点就可以得到正确结果,用递归实现比较简单,三种不同的遍历结果由输出语句的位置决定。
具体代码如下:
#include <iostream>
#include <memory>
#include <vector>
//使用Cpp11新增的智能指针,避免因忘记delete而导致的内存泄露
//用结构体定义一个树节点
typedef int ElementType;
struct TreeNode {
TreeNode() :lchild(nullptr), rchild(nullptr) {};
TreeNode(ElementType d, std::shared_ptr<TreeNode> lhs, std::shared_ptr<TreeNode> rhs) :
data(d), lchild(lhs), rchild(rhs) {};
ElementType data;
std::shared_ptr<TreeNode> lchild;
std::shared_ptr<TreeNode> rchild;
};
using BinTree = std::shared_ptr<TreeNode>;
//typedef std::shared_ptr<TreeNode> BinTree;
//递归实现,改变输出语句位置即可
void printBTree(BinTree bt) {
if (bt == nullptr) {
return;
}
//对应的输出语句位置对应了不同的遍历顺序
//std::cout << bt->data << " "; //先序遍历
printBTree(bt->lchild);
//std::cout << bt->data << " "; //中序遍历
printBTree(bt->rchild);
std::cout << bt->data << " "; //后序遍历
}
可以看到,用递归实现比较简单,代码简洁,但是递归效率不高,在面对有大量节点的二叉树来说,甚至有栈溢出的风险,所以有时也需要非递归的遍历方法。
三、二叉树的先(前)序、中序、后序遍历的非递归实现
鉴于第二点里面提到的一些问题(递归效率低,栈溢出等),亟需非递归的实现方法。由于递归是用栈实现的,故我们也可以直接用栈来模拟递归的行为实现非递归遍历二叉树。
先序遍历非递归算法:
- 遇到一个节点,先访问它,然后把它压栈。并去遍历的它的左子树
- 当左子树遍历完毕后,从栈顶弹出一个节点并访问它
- 然后按照先序遍历该节点的右子树
中序遍历非递归算法:
- 遇到一个节点,把它压栈。并去遍历的它的左子树
- 当左子树遍历完毕后,从栈顶弹出一个节点并访问它
- 然后按照中序遍历该节点的右子树
后序遍历非递归算法:
- 遇到一个节点,把它压栈。并去遍历的它的左子树
- 当左子树遍历完毕后,从栈顶弹出一个节点
- 然后按照后序遍历该节点的右子树,完毕后再访问该节点
先序、中序、后序的非递归算法共同之处:用栈来保存先前走过的路径(即访问过的节点入栈),以便可以在访问完子树后,可以利用栈中的信息,回退到当前节点的双亲节点(即出栈),进行下一步操作。
首先实现一个栈,我自己写了个链式存储的栈类(虽然STL有stack类,就当锻炼下自己):
class MyStack
{
struct SNode
{
using pSNode = std::shared_ptr<SNode>;
std::shared_ptr<TreeNode> data;
//我这用的是双向链表,其实单向链表就可以了
pSNode Pre;
pSNode Next;
//构造函数
SNode() :Pre(nullptr), Next(nullptr) {};
SNode(std::shared_ptr<TreeNode> d, pSNode lp, pSNode rp)
:data(d), Pre(lp), Next(rp) {};
};
public:
MyStack(); //构造函数
void push(BinTree s); //入栈
std::shared_ptr<SNode> pop(); //出栈
bool empty(); //判断栈空
std::shared_ptr<SNode> top(); //返回栈顶元素
private:
std::shared_ptr<SNode> baseptr; //栈底指针,不存储数据,用于判断栈空与否
std::shared_ptr<SNode> toptr; //栈顶指针
};
//类方法
MyStack::MyStack() :baseptr(new SNode),toptr(baseptr) {};
void MyStack::push(BinTree s) {
auto newNode = std::make_shared<SNode>(); //new一个新节点用来保存数据
newNode->data = s;
newNode->Next = nullptr;
auto currNode = toptr;
toptr->Next = newNode;
toptr = newNode; //移动toptr指针,确保始终指向栈顶
toptr->Pre = currNode;
}
std::shared_ptr<MyStack::SNode> MyStack::pop() {
if (!empty()) {
//栈非空,弹出栈顶元素
auto ptr = toptr;
toptr = toptr->Pre;
return ptr;
}
else
{
std::cout << "栈空,pop失败!" << std::endl;
return nullptr;
}
}
bool MyStack::empty() {
return (baseptr == toptr) ? true : false;
}
std::shared_ptr<MyStack::SNode> MyStack::top() {
//栈非空时返回栈顶元素,否则返回空指针
return (!empty()) ? toptr: nullptr; //返回栈顶元素,但并不弹出(删除)它
}
接下来开始进行先序、中序遍历(后序遍历先跳过,相对要复杂些)。
void printBTreeByStack(BinTree bt) {
MyStack mystk; //创建一个空栈
BinTree T = bt;
while (T!=nullptr||!mystk.empty())
{
//输出语句不同的位置对应了不同的遍历方式
while (T) {
//std::cout << T->data << " "; //先序遍历
mystk.push(T); //入栈
T = T->lchild; //遍历左子树
}
//左子树遍历完毕
if (!mystk.empty()) {
T = mystk.pop()->data; //弹出栈顶元素
std::cout << T->data << " "; //中序遍历
T = T->rchild; //遍历右子树
}
}
}
后序遍历的非递归算法是三种顺序中最复杂的,主要是因为后序遍历先访问完左右子树,再访问根节点,而当回退到根节点时,无法确定上一个访问的是左子树还是右子树,也就无法确定此时是否需要访问该节点,换句话说,只有当节点的右子树为空或者右子树存在且已被访问过才能访问其自身。
我添加了一个临时指针用于保存上次出栈(访问)的元素,以便确定下一个栈顶的元素能否出栈(访问)。
void printBTreeByStack_PostOrder(BinTree bt) {
MyStack mystk; //创建一个空栈
BinTree T = bt, rchdptr=nullptr; //rchdptr用来保存访问过的节点
while (T != nullptr || !mystk.empty()) {
//左子树入栈
while (T!=nullptr)
{
mystk.push(T);
T = T->lchild;
}
//左子树入栈完毕
//访问栈顶保存的节点
T = mystk.top()->data;
//右子树存在且没有被访问过
if (T->rchild && T->rchild != rchdptr) {
T = T->rchild; //遍历右子树
mystk.push(T);
T = T->lchild; //继续遍历左子树
}
else
{ //没有右子树或已访问,打印当前节点
T = mystk.pop()->data; //弹出栈顶元素
std::cout << T->data << " ";
rchdptr = T; //保存被访问的节点
T = nullptr; //这一步很关键,置空,防止当前节点再次入栈
}
}
}
其实后序遍历也可以换个思路来实现,后序遍历访问节点顺序为 左右根,那如果按照 根右左 的顺序来访问节点,得到的结果应该是后序遍历结果的逆序,只要在输出时处理下就行了,好像这个是利用了二叉树的镜像?左右子树调换位置即可。
二叉树(左一)及其镜像(左二):
可以看出:二叉树的后序遍历=二叉树的镜像的先序遍历的逆序
具体实现代码(按照根右左访问节点)如下:
void printBTreeByStack_rPostOrder(BinTree bt) {
//换个思路
//后序遍历---左右根
//那如果按照 根右左 遍历结果应该是后序遍历结果的逆序
MyStack mystk; //创建空栈
BinTree T = bt;
std::vector<BinTree> rresult; //用来保存遍历结果
while (T != nullptr || !mystk.empty())
{
//遍历右子树先
while (T) {
rresult.push_back(T);
mystk.push(T);
T = T->rchild;
}
if (!mystk.empty()) {
T = mystk.pop()->data;
T = T->lchild; //遍历左子树
}
}
//调整输出顺序,使用反向迭代器
for (auto rbeg = rresult.crbegin(); rbeg != rresult.crend(); ++rbeg) {
std::cout << (*rbeg)->data << " ";
}
}
本文到此结束,虽然代码写得不是很好,但是二叉树的遍历算法还是基本体现了的,如有错误或建议,欢迎评论区指出,诸君共勉!