树的基本概念篇
前言
由于我们后面讲的一些结构有很多是树结构实现的比如堆,然后基于堆可以实现优先级队列,有界优先级队列等,所以我们先讲述树结构,我们可能常见到的是二叉树,但是还有一些其他的树的概念:比如二叉搜索树,AVL树,红黑树,B树,决策树等。以便于在特定场景下使用。
树的一些应用场景
- CFBinaryHeap 这个类在iOS中你可能会见到,这是一个二叉搜索算法实现的一个二叉堆,后面的priority queues这个结构就是用这个二叉堆实现的。还可以实现二叉搜索树。对高效率的搜索和排序有帮助。
- iOS 中视图的层级结构就是一个很形象的树。如下图所示:添加顺序是A,B,C。先添加的在数组中的索引小。
hit-test 逻辑:此方法通过hitTest:withEvent:从最后到第一个向其每个子视图发送消息来遍历接收者的子树,直到其中一个返回非nil值。
代码:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
return nil;
}
if ([self pointInside:point withEvent:event]) {
for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
CGPoint convertedPoint = [subview convertPoint:point fromView:self];
UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
if (hitTestView) {
return hitTestView;
}
}
return self;
}
return nil;
}
复制代码
采用reverse pre-order depth-first traversal algorithm遍历。首先访问根节点,然后从较高到较低的索引遍历其子树,这样做是为了快速遍历到我们需要的节点,试想如果从低到高遍历这个View,层级很多的情况下岂不是要遍历很多节点。如下图所示:
比如“View A.2”和“View B.1”都是重叠的。但由于“View B”的子视图索引高于“View A”,因此“View B”及其子视图呈现在“View A”及其子视图上方。因此,当用户的手指在与“视图A.2”重叠的区域中触摸“视图B.1”时,应通过命中测试返回“视图B.1”。
一个小提示打印当前View下所有子View采用了递归遍历并打印,此刻看出来算法的重要性了吧。
- (void)listSubviewsOfView:(UIView *)view {
NSArray *subviews = [view subviews];
if ([subviews count] == 0) return;
for (UIView *subview in subviews)
{
NSLog(@"%@", subview);
[self listSubviewsOfView:subview];
}
}
复制代码
- 其他比如人工智能下国际象棋采用决策树来解决。
- 数据库中我们需要高效的访问,插入删除等操作。为了降低磁盘IO操作开销,就用到了B树。
- 用二叉树表示数学表达式我们叫做表达式树。还记得之前我们用栈结构结合后缀表达式来计算数学表达式吗?其实下图可以通过前序中序后序遍历方式得到前后中不同的表达式。当然后缀表达式适合计算表达式,因为它很容易通过栈结构来计算。
上图后序遍历得到后缀表达式:( ((70 10 - )32 / ) (24 13 + ) X )
- 堆排序,我们利用二叉堆来实现堆排序,堆又是二叉树来实现的,近似于完全二叉树的结构。
- 霍夫曼编码是数据编码的的一种算法,用于JPEG和zip等压缩图像或者文件。该方法利用霍夫曼树来压缩一组数据,霍夫曼树是一颗二叉树。
二叉树介绍篇
一张图来描述Binary Tree
二叉树的节点最大分支度是2,也说明每个节点最多拥有2个子节点,范围是[0-2]。
Binary Tree的几个常见类型
- A degenerate (or pathological) tree。(树的每个节点只有一个子节点或者是右孩子或者是左孩子,这时候这个树就和链表性能差不多了。)
- Full Binary Tree (树的任何一个节点都有0或者2个孩子节点。或者这样定义树的任何一个非叶子节点都有两个孩子节点)
- Complete Binary Tree(可能除了树的最后一层其它层级的每个节点都有左右孩子节点,最后一层要么是满的要么节点都靠左边)
- Perfect Binary Tree (它是一个这样的二叉树,他所有的非叶子节点都有左右子节点,并且所有的叶子节点都在同一层级)
和Binary Tree有关的一些公式
- 节点数和二叉树树Height的关系,假如h是树的Height,n是树节点个数。那么Min Nodes(n = h+1),Max Nodes(2h+1-1)。看下图例子,很容易推导出Min Nodes(n = h+1)。
下面我们推导下Max Nodes。上图第三种情况h = 3,Max Nodes = 1 +2 + 22+ 23 = 15,也就是Max Nodes = 1 +2 + 22+ 23 + ....+ 2h= ,也就是等比数列求和,如下图:
代入求和 Max Nodes = 1 +2 + 2 2+ 2 3 + ....+ 2 h=2 h+1-1等比数列求和可以参考如下链接: zh.wikipedia.org/wiki/等比数列
反过来可以很容易推导出Min Height (h = Log2(n+1)-1),Max Height(h = n-1)。
-
如果是full binary tree那么节点数和树Height的关系又是什么呢? 推导过程可以参考上面的步骤,Min Nodes(n = 2h+1),Max Nodes(2h+1-1),反过来可以很容易推导出Min Height (h = Log2(n+1)-1),Max Height(h = )。
-
第i层至多拥有2i-1个节点,最少有1个节点。从下图可以很容易看出来,
-
度为0的节点数n1和度为2节点数n2的关系。n1 = n2 + 1。看下图
二叉树的存储方式
- Array Representation
- Linked Representation
Array Representation
二叉树可以被以广度优先的顺序作为隐式数据结构存储在数组中。注意的是如果这个二叉树是complete binary tree,这些不会浪费空间,但是如果对于A degenerate (or pathological) tree这种高度很大的树就很浪费空间,可以参考后面根据这个存储方式判断这个树是不是complete binary tree的介绍。这种存储方法通常也用在binary heaps。
举例:找E的父节点,E的索引是5,那么Parent = i/2 = 5/2 = 2.5,向下取整就是2,对应的就是B。反之假如找A的左右孩子,A的索引是1,那么左孩子索引就是2对应B,右孩子索引就是3对应C。
注意:Parent的索引如果有存在小数情况是向下取整。
下面我们看怎么根据这个表示方法判断是不是complete binary tree。
上三个图中1,2元素之间没有空白的空间是complete binary tree,图3元素之间有空白的空间说明不是complete binary tree。Linked Representation
@interface DSTreeNode : NSObject
@property (nonatomic, strong) NSObject *object;
@property (nonatomic, strong) DSTreeNode *leftChild;
@property (nonatomic, strong) DSTreeNode *rightChild;
@property (nonatomic, strong) DSTreeNode *parent;
@property (nonatomic, assign) SEL compareSelector;
- (void)printDescription;
//是否是左还是结点
- (BOOL)isLeftChildOfParent;
@end
复制代码
这种存储二叉树方法浪费了不少内存,由于那些节点的左右指针(为null或者指向某些节点)。
二叉树的周游算法篇
二叉树的周游算法
- 前序遍历:visit(node),preorder(left Subtree), preorder(right Subtree)。
- 中序遍历:in-order(left Subtree),visit(node),in-order(right Subtree)。
- 后序遍历:post-order(left Subtree),post-order(right Subtree),visit(node)。
- 层级遍历:一层层访问每个节点。
通过上述四种方式遍历二叉树的每个节点。
练习周游算法的技巧 1
思路:一般我们习惯 ,根节点-左节点-右节点,这样的模型,我们就把例如上图A的左子树当做一个块,类似一个大节点用括号圈起来,同样的右子树也这样做。然后每个块里做前中后遍历。
-
前序遍历。A,(B,D,E),(C,F,G)。得到结果是 A,B,D,E,C,F,G 。
-
中序遍历。(D,B,E),A,(F,C,G)。得到的结果是 D,B,E,A,F,C,G 。
-
后序遍历。(D,E,B),(F,G,C),A。得到的结果是 D,E,B,F,G,C,A 。
-
层级遍历。 A,B,C,D,E,F,G 。
练习周游算法的技巧 2
前序遍历思路:每个节点从左边画线一直到底部这个线,然后按照从左到右的顺序读取节点。 结果是:A,B,D,E,C,F,G 。
中序遍历思路:每个节点从中间画线到底部这个线,然后按照从左到右的顺序读取节点。 结果是 D,B,E,A,F,C,G 。
后序遍历思路:每个节点从右边画线到底部这条线,然后从左到右的顺序读取节点。 结果是 D,E,B,F,G,C,A 。
练习周游算法的技巧 3
前序遍历思路:从每个节点左边画出一个线,然后从根结点开始转一圈,经过每个节点和树的分支,包裹这个树。经过这些短线的顺序就是结果。A,B,D,E,C,F,G 。
中序遍历思路:从每个节点底部边画出一个线,然后从根结点开始转一圈,经过每个节点和树的分支,包裹这个树。经过这些短线的顺序就是结果。D,B,E,A,F,C,G 。
后序遍历思路:从每个节点右边画出一个线,然后从根结点开始转一圈,经过每个节点和树的分支,包裹这个树。经过这些短线的顺序就是结果。D,E,B,F,G,C,A 。
周游算法延伸
- 前序遍历,中序遍历,后续遍历的思想是按照深度优先的顺序遍历的。层级遍历的思想是按照广度优先的顺序遍历的。
- 由于要遍历树的每个节点因此时间复杂度是O(n)。
- 广度优先遍历思想的层级遍历需要的额外的空间是O(w),w是这个二叉树的最大的宽,比如Perfect Binary Tree这种情况下最大节点在最后一层,第i层至多拥有2i-1个节点,因此需要额外空间O(Ceil(n/2));深度优先遍历思想的其他三种方式需要额外空间是O(h),这个h是二叉树的最大高度,比如一个平衡树h是Log2(n) ,但是对于极不平衡的左倾斜或者右倾斜树来说h就是n 。所以在最坏的情况下,两者所需的额外空间是O(n)。但最坏的情况发生在不同类型的树木上,因此针对不同种类不同性质的树需要的额外空间有不尽相同。从以上可以明显看出,当树更平衡时,广度优先遍历思想的层级遍历所需的额外空间可能更多,并且当树不太平衡时,深度优先遍历思想的其他三种遍历方式的额外空间可能更多。
这节主要介绍二叉树的代码实现,我们讲述Linked Representation的实现,主要包含下面几个操作。
- 构建
- 插入
- 查找
- 前序,中序,后续,层级遍历
二叉树的实现篇
节点类
从上图可以看出,每个节点除了本身以外,还得有一个父子以及左右孩子节点信息。因此需要一个节点类。主要代码实现如下:
@interface DSTreeNode : NSObject
@property (nonatomic, strong) NSObject *object;
@property (nonatomic, strong) DSTreeNode *leftChild;
@property (nonatomic, strong) DSTreeNode *rightChild;
@property (nonatomic, strong) DSTreeNode *parent;
@property (nonatomic, assign) SEL compareSelector;
- (void)printDescription;
//是否是左还是结点
- (BOOL)isLeftChildOfParent;
@end
复制代码
构建
对于二叉树的创建我们初始化一个根节点的方式创建,如下代码实现:
- (instancetype)initWithObject:(NSObject *)object
{
if (self = [super init]) {
_root = [[DSTreeNode alloc] init];
self.root.object = object;
}
return self;
}
复制代码
插入
以插入节点的方式构建整个二叉树如下代码:
//插入结点
- (BOOL)insertNode:(NSObject *)node parent:(NSObject *)parent isLeftChild:(BOOL)value
{
DSTreeNode *treeNode = [[DSTreeNode alloc] init];
treeNode.object = node;
DSTreeNode *parentNode = [self find:parent];
//1
if (value == true && parentNode.leftChild == nil) {
//2
treeNode.parent = parentNode;
//3
parentNode.leftChild = treeNode;
}
//4
else if (parentNode.rightChild == nil) {
treeNode.parent = parentNode;
parentNode.rightChild = treeNode;
}
//5
else {
NSAssert(parentNode.leftChild != nil || parentNode.rightChild != nil, @"Can't insert into parent node!");
return false;
}
return true;
}
复制代码
代码解释:
- 如果插入的位置是当前节点的左孩子并且左孩子结点不存在可以插入。
- 被插入的节点的parent指针指向当前节点,此处是必须的,不然这个树分支就断了,也就不能构成完整的树。
- 当前节点左孩子指针指向被插入的节点,此处是必须的,和第二步原因一样。
- 否则插入的是右孩子节点。
- 如果某个节点的左右孩子节点都存在则提示不能插入的信息。
查找
查找某个节点
- (DSTreeNode *)find:(NSObject *)object
{
//1
DSQueue*queue = [[DSQueue alloc] init];
[queue enqueue:self.root];
DSTreeNode *node;
//2
while (![queue isEmpty]) {
node = [queue dequeue];
if ([node.object isEqualTo:object]) {
return node;
}
if (node.leftChild) {
[queue enqueue:node.leftChild];
}
if (node.rightChild) {
[queue enqueue:node.rightChild];
}
}
return nil;
}
复制代码
- 利用队列先进先出特性遍历每个结点
- 注意这个遍历的顺序是层级遍历顺序
前序,中序,后续,层级遍历
层级遍历的思路和上述查找的思路类似。前中后序遍历的思路利用递归的思路实现,然后按照之前介绍二叉树遍历算法的思路就可以实现了。前序遍历的代码如下:
//如果当前根结点存在则前序遍历这个树
- (void)preOrderTraversal
{
if (self.root) {
[DSBinaryTree preOrderTraversalRecursive:self.root];
}
}
//递归的遍历并打印树 顺序是根 左 右
+ (void)preOrderTraversalRecursive:(DSTreeNode *)node
{
if (node) {
NSLog(@"%@",node.object);
[DSBinaryTree preOrderTraversalRecursive:node.leftChild];
[DSBinaryTree preOrderTraversalRecursive:node.rightChild];
}
}
复制代码
二叉树算法实战篇
题目大意
Given a binary tree, return all root-to-leaf paths.
For example, given the following binary tree:
1
/ \
2 3
\
5
All root-to-leaf paths are:
["1->2->5", "1->3"]
复制代码
灵感思路
给我们一个二叉树,让我们返回所有根到叶节点的路径。我们可以采用递归的思路,不停的DFS到叶结点,如果遇到叶结点的时候,那么此时一条完整的路径已经形成,我们加上当前的叶结点后变成的完整路径放到数组中。
需要注意的是对空节点的判断,以及递归函数回溯时候对一些对象的影响。
主要代码
- (void)printPathsRecurTreeNode:(DSTreeNode *)treeNode path:(NSString *)path results:(NSMutableArray <NSString *>*)results
{
//1
if (treeNode == nil) {
return;
}
//2
if (treeNode.leftChild == nil && treeNode.rightChild == nil)
{
NSString *resultsStr = [NSString stringWithFormat:@"%@%@",path,treeNode.object];
[results addObject:resultsStr];
}
else
{
//3
if (treeNode.leftChild != nil)
{
NSString *resultsStr = [NSString stringWithFormat:@"%@%@",path,[NSString stringWithFormat:@"%@->",treeNode.object]];
[self printPathsRecurTreeNode:treeNode.leftChild path:resultsStr results:results];
}
//4
if (treeNode.rightChild != nil )
{
NSString *resultsStr = [NSString stringWithFormat:@"%@%@",path,[NSString stringWithFormat:@"%@->",treeNode.object]];
[self printPathsRecurTreeNode:treeNode.rightChild path:resultsStr results:results];
}
}
}
复制代码
代码解释
- 如果节点是空则返回。
- 如果当前节点是叶子节点则把这个完整路径加到数组里。
- 如果当前节点存在左孩子节点,则继续DFS直到叶子节点。
- 如果当前节点存在右孩子节点,则继续DFS直到叶子节点。