注意:这里是JAVA自学与了解的同步笔记与记录,如有问题欢迎指正说明
目录
一、关于二叉树的建立及其思路
我们在二叉树转储那一篇提到过要追求建立一个双向映射的二叉树存储的结构,但是我们之前一直在说怎么样建立一个从链树到顺序表的转化,今天我们就利用完全二叉树的特性来说说顺序表到链数的转化。
之前提到过了,对于顺序表构建的完全二叉树具有一种非常优良的特性:若孩子结点存在,则可以用当前节点的下标索引通过2 * i + 1直接访问其左孩子节点,2 * i + 2访问其右孩子节点。
这种优良特性是因为完全二叉树保证了除最后一层外之前的每一层都是完全填充的,而且上下层之间始终保持着2倍的关系。这样的特性能保证物理存储上连续的顺序表可以在逻辑空间上表示为一个树状结构,并且可以很方便借助顺序表的随机访问性质去对这个逻辑结构进行上下层次的快速转换,在1964年,Robert W.Floyd巧妙发现了这个特性并合理运用于排序算法中并提出了O(1)空间复杂度的O(NlogN)级别空间复杂度的堆排序( Heap Sort )算法。
我们之前提到了采用下标和数据相互对应的顺序表来模拟二叉树,这里的下标之间刚好就满足我们需要的2 * i + 1或者2 * i + 2的左右儿子关系。下标模拟了上面这个二叉树的顺序表关系:
'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' |
0 | 1 | 2 | 4 | 5 | 9 | 10 |
假设取出结点D对应下标4,4 * 2 + 1 = 9,而表中存在下标为9的结点F,因此这时可以将结点F连接做为节点D的右儿子,而结点F进行 9 * 2 + 1 = 19结果越界,故不添加其左儿子结点。若取出结点B,3 * 2 + 1 = 6,而顺序表中不存在这样的索引为6的结点,故对其进行添加左儿子操作。通过这段描述,我们可以总结出以下几点:
- 顺序表的下标索引是从小到大的
- 一个结点的儿子只有可能在他于顺序表位置的后方找到,相应的,一个结点的父级只有可能存在于此结点与顺序表中位置的前方
- 顺序表的第一个位置是根节点
总结来看,似乎树的上下级关系可以非常完美得与顺序表的前后继关系进行联系,这样的话我们只需要对顺序表的前后继进行相应的枚举即可完成树的结点关系枚举。具体来说,为了避免重复遍历,我们往往遵循下面的思路:从树的第一个非根分支结点开始,逐层向下进行遍历,遍历过程中枚举他的全部长辈,直到枚举出父级,并进行连接。这句话总结到顺序表,就是进行全部每个结点的前继枚举(若其由前继),是一个非常简单的暴力枚举。口述无凭,下面用代码来具体描述我们的行为。
二、代码实现过程
有了分析只是有了草图,但是我们还是需要用代码把它搭建起来,不懂的地方我们边搭边说吧~
// Step 1. Use a sequential list to store all nodes.
int tempNumNodes = paraDataArray.length;
BinaryCharTree[] tempAllNodes = new BinaryCharTree[tempNumNodes];
for (int i = 0; i < tempNumNodes; i++) {
tempAllNodes[i] = new BinaryCharTree(paraDataArray[i]);
} // Of for i
首先需要把链式树的结点存起来,因为我们后续的操作都是根据双顺序表(这里我把我上面表格这种结点数据内容与下标构成的结构暂且称之为双顺序表)的下标进行映射的,双顺序表的索引必须与每个结点实体构成联系,比如上表中的第3个元素(下标从0开始)就必须与结点D挂钩,这个挂钩不能说一会儿是一个结点,一会儿又是另一结点,它必须是一个固定不变地址下的结点,因为可能在开始,这个地址作为B结点的右儿子进行了操作;而后续我们又会将其作为父级结点,将F结点与G结点链到D结点上。
这也就解释为什么代码中是根据结点个数(双顺序表长度)逐一从i=0递增赋值链树引用数组,当然这个是你用我刚刚的思路得出的结果。其实你就用平常思路想,既然结点就这么多,肯定要这些循环存放这么多个啊,只是对问题的不同思考角度可以让我们看见其内涵。
// Step 2.Link these nodes.
for (int i = 1; i < tempNumNodes; i++) {
for (int j = 0; j < i; j++) {
System.out.println("indices " + paraIndicesArray[j] + " vs. " + paraIndicesArray[i]);
if (paraIndicesArray[i] == paraIndicesArray[j] * 2 + 1) {
tempAllNodes[j].leftChild = tempAllNodes[i];
System.out.println("Linking " + j + " with " + i);
break;
} else if (paraIndicesArray[i] == paraIndicesArray[j] * 2 + 2) {
tempAllNodes[j].rightChild = tempAllNodes[i];
System.out.println("Linking " + j + " with " + i);
break;
} // Of if
} // Of for j
} // Of for i
然后接下来就是核心代码部分,其实这部分只要理解了我之前说的:
遵循下面的思路:从树的第一个非根分支结点开始,逐层向下进行遍历,遍历过程中枚举他的全部长辈,直到枚举出父级,并进行连接。
这句话总结到顺序表,就是进行全部每个结点的前继枚举(若其由前继),是一个非常简单的
暴力枚举。
那么就很简单了,我们首先进行一次的枚举就好,这里我们用下标j表示判断中的父级结点,i表示子节点,按照我们总结的:
一个结点的儿子只有可能在他于顺序表位置的后方找到,相应的,一个结点的父级只有可能存在于此结点与顺序表中位置的前方
可得j<i,于是双重循环体就这样得出来了。具体描述下这个过程:我们先得到一个非根结点i,然后用j枚举所有小于i的数,得到结点j,结点j一定是i的父辈,而其中定然有一个结点j是i的直接父辈,于是进行连接(通过判定结点i与结点j的paraIndicesArray数组了解到这些结点在层次结构中的上下关系,从而得知是左孩子还是右孩子,这里一定要明白我们说的paraIndicesArray数组中的索引与i和j构成的索引的关系,前者是压缩存储下的抽象索引,反应树结构,后者是用于表示我们顺序表用于实际随机存取的物理索引,反应顺序表结构)
明白结点i与结点j在层次上的内涵后,下面两个if判断就迎刃而解,其内容来说就是:你是我的父亲,我是你的儿子吗?左儿子还是右儿子?啊是的,那么连接吧。
// Step 3. The root is the first node.
value = tempAllNodes[0].value;
leftChild = tempAllNodes[0].leftChild;
rightChild = tempAllNodes[0].rightChild;
最后,将我们创建的链树的根交付给当前对象的属性,从而完成基于双顺序表地对当前对象的二叉树创建。因为是引用,只要交付根节点即可实现访问。
三、数据模拟
我们就不再赘述main结构了,我直接描述main内代码了:
char[] tempCharArray = { 'A', 'B', 'C', 'D', 'E', 'F' };
int[] tempIndicesArray = { 0, 1, 2, 4, 5, 12 };
BinaryCharTree tempTree2 = new BinaryCharTree(tempCharArray, tempIndicesArray);
System.out.println("\r\nPreorder visit:");
tempTree2.preOrderVisit();
System.out.println("\r\nIn-order visit:");
tempTree2.inOrderVisit();
System.out.println("\r\nPost-order visit:");
tempTree2.postOrderVisit();
我们可以按照这个双顺序表在草图上画出我们的树的完全二叉树的补充版本以及通过这个补充版本得到的原二叉树版本:
得到输出(符合预期)
总结
今天内容比较独立且简单,只要能理解这个过程那么就不算太难,关键是要理清楚顺序表的下标索引和二叉树层次遍历顺序编号的索引关系。
完成代码后,不妨思索,这样的复杂度可以继续简化吗?今天我们的总结来说点不一样的,来谈谈今天代码可能的优化。
上述问题中我们在找的一个结点i后枚举了这个结点的全部父辈,这个过程其实存在一定的重复运算,因为,枚举这些父辈的基础是顺序表下标与树结点的层次遍历序号和树的结点引用的映射存在为基础的,而我们在对树结点初始化的时候,这些信息其实完全可以记录下来。所以最直接的一种优化方案就是利用哈希表记录下这些关键映射(我的想法是建立一个树层次遍历下标到树结点引用的映射),之后重新遍历的时候,我们得到 结点i 之后便不必再重新遍历父辈,而是可以直接向下判断(利用层次遍历的序号的特征向下判断其子节点的序号是否在哈希表中存在记录)。
口述无凭,这里我放出我理解的代码,供参考,欢迎指正:
public BinaryCharTree(char[] paraDataArray, int[] paraIndicesArray) {
// Step 1. Use a sequential list to store all nodes.
int tempNumNodes = paraDataArray.length;
HashMap<Integer, BinaryCharTree> indices2TreeNode = new HashMap<Integer, BinaryCharTree>();
BinaryCharTree[] tempAllNodes = new BinaryCharTree[tempNumNodes];
for (int i = 0; i < tempNumNodes; i++) {
tempAllNodes[i] = new BinaryCharTree(paraDataArray[i]);
indices2TreeNode.put(paraIndicesArray[i], tempAllNodes[i]); // record the mapping of tree indices to tree
// node
} // Of for i
// Step 2.Link these nodes.
for (int i = 0; i < tempNumNodes; i++) {
int leftChildIndices = paraIndicesArray[i] * 2 + 1;
int rightChildIndices = paraIndicesArray[i] * 2 + 2;
if (indices2TreeNode.containsKey(leftChildIndices)) {
tempAllNodes[i].leftChild = indices2TreeNode.get(leftChildIndices);
} // Of if
if (indices2TreeNode.containsKey(rightChildIndices)) {
tempAllNodes[i].rightChild = indices2TreeNode.get(rightChildIndices);
} // Of if
} // Of for i
// Step 3. The root is the first node.
value = tempAllNodes[0].value;
leftChild = tempAllNodes[0].leftChild;
rightChild = tempAllNodes[0].rightChild;
}// Of the second constructor
运行结果与优化前一致。
这种优化可以将级别的查找父辈的过程省去,改为向下判断子结点,单次判断只需要
。全局复杂度可以从
优化为
。其实哈希表优化是我们针对这种映射类问题常见的一种优化策略,但是相比优化,更重要还是学会这种用映射连接两种不同数据结构的技巧,其将会是一种不错的、低级语言难以胜任的、方便的编程工具。