先说说我实现二叉树的方法,才能开始讲述我被指针搞得晕头晕脑的痛苦经历。
/****************二叉树非递归的总体分析*****************/
(1)建立二叉树
栈的应用:用空格键表示空指针,我们利用栈来保存左右孩子指针未都被赋值的节点,空指针不允许入栈
开始建立:以循环遍历用户输入的字符串来建立二叉树,如:"AB#D##CE###"(这里先用#代替空格,方便查看)
我们以先序建立二叉树,按上面例子中的字符串建立的二叉树如下:
(visio不怎么会用,这图看着。。。忍耐一下)
从图中可以清晰的发现一个规律:
字符串中,任意一个字符的前一个(即i-1)字符若不为空格的话,这个字符一定是前一个非空格字符的左子树
在由先序这个条件还可以发现,我们始终是从左向右建立子树的,所以,如果第i-1个字符为空格,第i个字符就
一定是前一个刚建立的子树的右子树
(2)遍历二叉树
栈的应用:我们从二叉树顶端开始走的时候,将未遍历的节点的数据入栈,遍历一个节点从栈中弹一个出来
中序遍历:我们采用中序遍历二叉树,在非递归算法中,根据中序的定义,我们也需要让第一个取得的头节点A
从右开始向下走,一直往右边走(途中遇到的节点依次入栈),直到走到第一个左子树为空的节点,这里显然是B,
然后访问B,然后B退栈,若B右指针非空,则遍历往右走一步(为空的时候下面讨论),然后B的右子树入栈,
继续循环以B的右子树为头节点走到最左端,直到把节点都走完,这里走到D 的时候就已经结束了,直接访问D,
D退栈,根据栈顶元素A,访问A,A退栈,这是往右走,C入栈,走到最左端E,E入栈,访问E,E退栈,这时和D
的情况一样了,已经是一个叶子节点了,就根据栈顶C,访问C,C退栈,栈空,表明遍历结束
特殊情况:当我们遍历完最左端的节点之后,会向右走一步,如果此节点的右子树本为空,就不能往右走了,依据
中序的定义,如果已经访问到了右子树并且为空,说明这个双亲节点已经被访问过了,那么我们就要去访问栈顶数据,
弹出栈顶元素,如果新的栈顶元素的右子树依旧为空并且此时栈还非空,就继续这种循环,直到找到一个右子树为被
访问过的栈顶元素,或者栈以为空(这种情况表明遍历结束)
(3)销毁二叉树
栈的应用:从头节点依次入栈,从栈顶开始销毁节点
后序销毁:需要从叶子节点开始销毁,并且要修改其双亲节点的指针值。首先,依旧要走到最左端B,依次入栈A、B,
发现B右子树非空,往右走一步,D入栈,这个时候,以D为头节点走到D的最左端行不通了,因为D的左子树为空,就看
能不能往D的右边走一步,发现D的右子树也为空,就说明D为叶子节点,弹出它,并将新的栈顶元素的右指针设为空,
freeD,(这里有个疑问:怎么确定当前的节点是双亲节点的左孩子还是右孩子呢?根据后序的定义,如果一个节点的右
指针为空的话,就说明这个节点为叶子节点,如果它的双亲节点左孩子还不为空,就说明这个节点对应的是左指针,如果
双亲节点的左孩子已经为空,说明它本身就是空或者已经被释放了,则这个节点就一定是右指针。)释放了D之后,根据
栈顶指示,销毁B,再跳到C,跳到E,释放E、C、A,最后栈空,表明销毁结束。
/****************指针的误用*****************/
从上面的设计过程来看,建立和遍历中对栈的使用是大不同的。
遍历二叉树的时候,我们只需要访问节点的数据即可,但在建立二叉树的时候,我们需要节点的准确地址来连接这个树。
一开始我并没有注意到这个巨大的区别,依然用遍历时采用的栈的使用来建立二叉树,结果可想而知,“灵活”的指针肯定
已经失控了。
栈的指针误区:
栈里面是申请的一块连续的动态内存空间,每一次压入或弹出的元素其实都不是你真正想用的数据,因为数据的位置变了。
栈中存放的每一个数据都是放到了你申请的这块空间上,但你实际的数据是放到另一块内存空间上的。
一开始我就直接从栈中弹出节点,将新建的节点连到里面,额。。。情况具体是这样的(就以前两个节点为例吧):
| 实际的内存地址 | 栈中的内存地址 |
A | 0x004758h6 | 0x00853542 |
B(A的左子树) | 0x004758f0 | 0x0085354c |
很显然,B实际应该连到A的左子树上,但是,如果先把A入栈,根据栈顶指示发现应该将B连到栈顶元素(A)的左子树上,
于是便直接获取栈顶地址0x00853542,将0x004758f0处的内容连上,然后B再入栈,遇到B的左子树应该为空的时候,获取
栈顶元素(B)的地址0x0085354c,将0x00000000给连上,没有调试程序的话,是很难发现这里的所谓“连上”其实根本就是
断的。因为我们用的是正确的子树地址,但是却把它连到了栈里面去了,相当于从栈那棵“笔直的大树”里长了许多叶子出来。。。
而原本的“根”却只是孤零零的一片小叶子而已。
以前总听牛人说指针经常是C的双刃剑,如果没有足够的功力是无法驾驭它的,现在也是深有体会了,因为这个错误我调试
了不下十次才发现。
指针误区的解决:
为了解决指针指向错误的问题,我想了很多方法,甚至还想将建立二叉树的算法来个大转变,我试过这种方法:
将入栈、出栈的语句改为:
elem = --StackPtr->TopPtr;
这是出栈语句,两边都是指针,入栈的时候也采用地址赋值,本以为这样就可以轻易的改变栈中元素的指向的,整个树就可以
接到一块儿了,结果,随之而来又是另一个指针使用的误区:
地址赋值会将指针的地址修改,也就是将连续的栈空间的每一个元素的地址改为了树节点的地址了,这样栈的结构又被破坏了。。。
如果是*elem = *--StackPtr->TopPtr;就只是改变栈中元素的指向内容而已,也就是最开始存放节点的数据内容的说法
痛苦了好久。。。。。不过还好找到了一个解决办法:
就是将自身节点的实际内存地址保存起来,保存在哪里可以自己定,因为只要能在连接子树的时候连到正确的地址就可以解决
问题,我是把它保存到自身结构体里面,如下:
typedef struct tree
{
ELEMTYPETREE data;
struct tree *This;
struct tree *RightChildPtr;
struct tree *LeftChildPtr;
}ThreadBinaryTree;
This指针就指向其自身,由于它是放到结构体里面的,在入栈、出栈的时候是不会被改变的,这样我们就可以使用类似这样
的方法来连接节点了:
(TopPtr->This)->LeftChildPtr = LeavePtr;
同时我们也不必关心TopPtr所指向的的数据内容了,只管对TopPtr的元素this进行修改,因为那才是节点的真身。
(这个方法不知道会不会引起节点数据过大的后果,请大牛们指出此方法是否恰当)
/****************内存管理*****************/
接下来就是C中的内存管理了,我们需要把在各个函数中申请的动态内存释放了,不然会造成严重的内存泄漏。
(1)对栈的释放:
栈的释放比较简单,用栈顶指针从上到小依次指向要被释放的空间即可
(2)二叉树的内存申请与释放:
建立二叉树的函数最终原型:(传入的Root为RootPtr,RootPtr类型为ThreadBinaryTree *)
int DIY_ThreadBinaryTree(ThreadBinaryTree **Root,char *str)
最初的RootPtr使用的类型是ThreadBinaryTree *。
我们的先序建立二叉树中动态申请了每个节点所需的内存,并且已经按要求连接到了一块儿,现在他们是一个整体,领头的
就是头节点A,我们需要将A节点的地址传出去,大家都知道子函数里面定义的内容是会随着子函数的结束而释放的,如果我们
只是简单的想把RootPtr这个指针传入二叉树建立的函数的话,当函数结束时,我们真正的头节点确实没有被释放,因为它需要
我们认为释放,但是保存它地址的RootPtr是不会正确保存它的地址的,因为函数的参数传递终究是相当于赋值的,也就是函数
的参数Root和我们传入函数的RootPtr不是同一个东西,Root中有我们需要的地址,但是它会随着函数的结束而被销毁,也就是
不会把正确的地址传回给RootPtr。
我们要怎么样才能将地址正确返回?答案就是我们要将参数设为指向指针的指针的,这样,当我们传入一个*RootPtr的时候,我
们就需要用&RootPtr传入,传给函数的参数**Root,解释出来就是**(&RootPtr),这样就相当于*RootPtr,实现了对Root地址的
间接传递,保护了其实际内容。
释放的方法在最开始的算法分析中已经解释过了。
/****************编程总结******************/
在学习C的时候,一定要深入底层,从内存地址着手,不然真的会被指针给耍得团团转。
(二叉树的建立、遍历、销毁的实现代码放代码分享里了)