1.二叉树的特点:和顺序表、链表有所差异的是,二叉树并不主要用于存储数据,它多用于数据的筛选、处理等操作。二叉树内核是分治思想,对递归运用的要求很高,这在二叉树的各种接口的实现上我们都能有所体会。
2.最小子问题和递归条件:二叉树的各种功能实现基本上都绕不开递归,解决这些问题需要用到递归,但递归的抽象程度很容易让人被绕进去,所以我们可以通过最小子问题和递归条件来理解、写代码。
核心:找左子树、右子树和当前树的关系(分治)
下面我会针对一些接口的实现来讲解如何寻找最小子问题和递归条件:
➀前序遍历(中左右):这个接口的实现在代码上很简单,但我们要进一步了解如何理解代码:
前序遍历可以被拆分为:当前树的根的值打印+左子树的根的值打印+右子树的根的值打印。
我们可以通过画一些很简单的树来检验我们的最小子问题:
同时,这种特例也可以帮我们找到递归条件,即树的根不能为空
找特例推荐使用以下两种形式,一般可以覆盖所有情况,同时也能很快地模拟:
➁树的高度:这个例子更能体现分治思想,树的高度等于左子树的高度和右子树的高度的较大者 + 1(当前树),找到这个最小子问题我们就能首先写出返回值:
通过上面的思路我们能确定return语句一定长这样,虽然其它细节还没有确定,确定了这一步其它的就不算太难了。
要写出剩余的代码,我们要举特例来进行探索:
这下,我们就能找出递归条件了:
那么,最关键的问题是:左子树和右子树的高度怎么求?
我们刚刚的分析是以根为起点来进行分析的,根的左子树和根的右子树的高度最大值+根所占的1个高度 == 树的高度。那么我们可以将左子树的第一个结点作为新的根,用上述方法来求其高度。
我们可以用例子来检验:
这里有一个问题,为什么不能将代码按下面的方式来替换?
这样似乎更加简便,但基本上所有人都能看出来这样函数调用次数会增加。但增加多少次呢?
大部分人认为只是翻倍,但仔细分析发现并不如此。我们之前的那种写法的时间复杂度是O(N),因为你可以想象每个结点都只访问了1次。但这种写法的时间复杂度达到了O(2 ^ N),并不是2N,下面分享如何分析的:
首先我们知道时间复杂度是针对最坏情况的预估,在刚刚那种写法下,最坏的情况就是树的结点都以链状结构连在一起,具体分析如下:
➂第k层结点的个数:找最小子问题联系当前树,左子树,右子树。我们发现以当前树的根为参考时,第k层的结点数相当于左子树和右子树在k - 1层的结点数之和,因此return语句又能先写出来了
要完善其余代码,继续举出特例:
我们发现,当k == 1时比较特殊,如果在k == 1时再向下递归似乎就无意义了,而此时访问到的结点都是我们想要统计的结点,如果访问到了就返回1给上一层函数。
举特例要多个防止某些情况不成立。按照上面推荐的方法,还有一种例子似乎存在漏洞:
如果传的子树为NULL,自然那里是没有节点的,也没有左右子树的,所以直接向上返回0即可
如此一来,我们的代码就完成了
当然,实际过程肯定没有这么顺畅,我们可以多检验我们代码的可行性。
➃寻找某个结点并返回其地址:首先来寻找最小子问题,这个结点要么在这棵树的根上,要么就在它的左子树中或者右子树中,也有可能根本不存在。我们可以先在当前根上找,在将左子树的第一个结点当成根继续找,形成了递归。但是,和上面不同的是,这里很难直接通过一条return解决问题,需要分好几种情况。
这时我们要根据递归的思路来走。如果根上找不到,会在左子树的第一个结点上找,找不到则又会以其左子树第一个节点为根来找,以此类推,如果都找不到,那么最后会停在NULL上,如果找到了,就返回其地址:
但是这只是递归条件,更重要的return的逻辑并没有写出来。此时我们要想,这个返回的NULL或root会对上一层函数有什么影响。如果是NULL,这个子树找不到,要到另一颗子树上找。如果是root,就没必要找了,直接层层返回就可以了。因此有如下代码:
总结:1.root为NULL一定要考虑,一定会作为递归条件之一;
2.可以像上面我的分析那样先通过最小子问题写出return语句,再通过特例来完善代码;
3.找最小子问题要拆分成当前树、左子树、右子树的关系。