树木是否由“后代”组成?如果没有,为什么不呢?


12

通常,树数据结构的组织方式是每个节点都包含指向其所有子级的指针。

       +-----------------------------------------+
       |        root                             | 
       | child1            child2         child3 |
       +--+------------------+----------------+--+
          |                  |                |
+---------------+    +---------------+    +---------------+
|    node1      |    |     node2     |    |     node3     |
| child1 child2 |    | child1 child2 |    | child1 child2 |
+--+---------+--+    +--+---------+--+    +--+---------+--+
   |         |          |         |          |         |

这看起来很自然,但是会带来一些问题。例如,当子节点的数量变化时,您需要诸如数组或列表之类的内容来管理子节点。

通过仅使用(第一个)子级和(下一个)兄弟级指针,我们得到如下所示的内容:

       +-------------------+
       |        root       |
       | child    sibling  +--->NULL
       +--+----------------+
          |             
+----------------+    +----------------+    +----------------+
|    node1       |    |     node2      |    |     node3      |
| child  sibling +--->| child  sibling +--->| child  sibling +--->NULL
+--+-------------+    +--+-------------+    +--+-------------+
   |                     |                     |

显然,这种结构也可以代表树木,但是它也提供了一些优势。最重要的是,我们不必再担心子节点的数量。当用于解析树时,它为“ a + b + c + d + e”之类的术语提供了自然的表示,而不会成为一棵深树。

集合库是否提供像这样的树结构?解析器是否使用这种结构?若否,原因为何?


2
好吧,这种结构显然要付出更高的复杂性。仅当您实际上需要数量可变的孩子时,这才值得。许多树木在其设计中固有有固定数量的子代(或至少具有固定的最大值)。在这些情况下,其他间接寻址不会增加任何值。
约阿希姆·绍尔

4
将项目放在链表中会引入O(n)算法中的一个因素。

为了从根目录进入node3,您需要使用根目录的cddar ...
Tacroy 2012年

Tacroy:正确,找到根源并不是一件容易的事,但是如果我真的需要,那么返回指针将是适当的(尽管它会破坏图表;-)
user281377 2012年

Answers:


7

像列表一样,树是“抽象数据类型”,可以用不同的方式实现。每种方式都有其优点和缺点。

在第一个示例中,此结构的主要优点是可以访问O(1)中的任何子级。缺点是,当必须扩展数组时,附加子项有时可能会稍微贵一点。但是,此成本相对较小。它也是最简单的实现之一。

在第二个示例中,主要优点是您总是在O(1)中附加一个孩子。主要缺点是对孩子的随机访问要花费O(n)。此外,它可以是那么有趣巨大的原因有两个树:它有一个对象头,每个节点有两个指针的内存开销,以及节点随机分布在存储这可能导致CPU缓存和之间交换的很多遍历树时的内存,使此实现对它们的吸引力降低。对于普通的树和应用程序来说,这不是问题。

没有提到的最后一个有趣的可能性是将整个树存储在单个数组中。这样会导致代码更复杂,但是在某些情况下(特别是对于大型固定树)有时是非常有利的实现,因为您可以节省对象标头的成本并分配连续的内存。


1
例如:B +树将永远不会使用此“第一个孩子,nextsibling”结构。对于基于磁盘的树来说,这将是低效的,而对于基于内存的树而言,它仍然是非常低效的。内存中的R树可以容忍这种结构,但是它仍然意味着更多的缓存丢失。我很难想像这样的情况,“后生,后生”会更好。是的,它可以用于ammoQ提到的语法树。还要别的吗?
Qwertie 2012年

3
“您总是在O(1)中附加一个孩子”-我认为您总是可以在O(1)的索引0处插入一个孩子,但是附加一个孩子似乎显然是O(n)。
Scott Whitlock,2012年

将整个树存储在单个阵列中对于堆来说很常见。
布莱恩(Brian)

1
@Scott:好吧,我假设链接列表也包含指向最后一项的指针/引用,这会使它的第一个或最后一个位置成为O(1)...尽管在OPs示例中缺少它
dagnelies

我敢打赌(除非在极端退化的情况下),“ firstchild,nextsibling”实现永远不会比基于数组的子表实现更高效。缓存本地胜出,时间充裕。事实证明,B树是现代体系结构上最有效的实现,正是由于改进了缓存局部性,使得B树与传统使用的红黑树相抗衡。
Konrad Rudolph

2

几乎每个具有某些可编辑模型或文档的项目都将具有层次结构。将“分层节点”实现为不同实体的基类可能会派上用场。通常,链接列表(子级同级,第二个模型)是许多类库增长的自然方式,但是子级可能是多种类型的,通常在讨论树时,我们不考虑“ 对象模型 ”。

我最喜欢的第一个模型的树(节点)的实现是单线(在C#中):

public class node : List<node> { /* props go here */ }

从您自己类型的通用列表继承(或从您自己类型的任何其他通用集合继承)。可能在一个方向上行走:向下扎根(物品不认识其父母)。

父母唯一的树

您没有提到的另一种模型是每个孩子都有对其父对象的引用的模型:

               null
                 |
       +---------+---------------------------------+
       |       parent                              |
       | root                                      |
       +-------------------------------------------+
          |                   |                |
+---------+------+    +-------+--------+    +--+-------------+
|     parent     |    |     parent     |    |     parent     |
|     node 1     |    |     node 2     |    |     node 3     |
+----------------+    +----------------+    +----------------+

只有沿着相反的方向走这棵树时,通常所有这些节点都将存储在一个集合中(数组,哈希表,字典等),并且通过在除层次结构中的分层位置以外的条件下搜索该集合来定位一个节点。通常不是最重要的树。

这些仅父树通常在数据库应用程序中看到。使用“ SELECT * WHERE ParentId = x”语句查找节点的子代非常容易。但是,我们很少发现它们被转换成树节点类对象。在全状态(桌面)应用程序中,它们可以包装到现有的树节点控件中。在无状态(Web)应用程序中,甚至不可能。我已经看到ORM映射类生成器工具在为与自身有关系的表生成类时会抛出堆栈溢出错误(笑),因此也许这些树毕竟不是那么常见。

双向通航树

但是,在大多数实际情况下,两全其美是很方便的。具有子级列表且另外还知道其父级的节点:双向可导航树。

                          null
                            |
       +--------------------+--------------------+
       |                  parent                 |
       |        root                             | 
       | child1            child2         child3 |
       +--+------------------+----------------+--+
          |                  |                |
+---------+-----+    +-------+-------+    +---+-----------+
|      parent   |    |     parent    |    |  parent       |
|    node1      |    |     node2     |    |     node3     |
| child1 child2 |    | child1 child2 |    | child1 child2 |
+--+---------+--+    +--+---------+--+    +--+---------+--+
   |         |          |         |          |         |

这带来了更多要考虑的方面:

  • 在哪里实现父级的链接和取消链接?
    • 让业务逻辑谨慎处理,并将方面留在节点之外(他们会忘记!)
    • 节点具有创建子级的方法(不允许重新排序)(Microsoft在其System.Xml.XmlDocument DOM实现中的选择,这在我第一次遇到它时几乎让我发疯了)
    • 节点在其构造函数中采用父级(不允许重新排序)
    • 在所有add(),insert()和remove()方法及其节点的重载中(通常是我的选择)
  • 持久性
    • 持久时如何走树(例如,省略父链接)
    • 反序列化后如何重建双向链接(将所有父级重新设置为反序列化后的操作)
  • 通知事项
    • 静态机制(IsDirty标志)在属性中递归处理吗?
    • 事件,通过父母冒泡,通过孩子冒泡或两种方式(例如,考虑Windows消息泵)。

现在要回答这个问题,双向可导航树往往是(到目前为止,在我的职业和领域中)使用最广泛的树。示例是Microsoft对System.Windows.Forms.Control或.Net框架中的System.Web.UI.Control的实现,但是每个DOM(文档对象模型)实现都将具有知道其父级以及枚举的节点他们的孩子。原因:易用性而不是易于实现。同样,这些通常是更特定类的基类(XmlNode可能是Tag,Attribute和Text类的基类),这些基类是放置通用序列化和事件处理体系结构的自然场所。

Tree是许多体系结构的核心,能够自由导航意味着能够更快地实现解决方案。


1

我不知道直接支持您的第二种情况的任何容器库,但是大多数容器库都可以轻松地支持这种情况。例如,在C ++中,您可能具有:

class Node;  // forward reference to satisfy the compiler
typedef std::list<Node*> NodeList;
class Node : public NodeList { /* . . . */ };  // a node is also a list

Node* n = new Node;
n->push_back(new Node);
Node* tree = new Node;
tree->push_back(new Node);
tree->push_back(n);

解析器可能使用与此类似的结构,因为它有效地支持具有可变数量的项和子项的节点。我不确定,因为我通常不阅读它们的源代码。


1

具有子级数组的一种情况是可取的,当您需要随机访问子级时。通常是在对孩子进行排序时。例如,类似文件的层次树可以使用它进行更快的路径搜索。索引访问非常自然时使用DOM标签树

另一个示例是让所有孩子都拥有“指针”可以更方便地使用。例如,在与关系数据库实现树关系时,可以使用您描述的两种类型。但是前者(在这种情况下是从父级到子级的主从详细信息)将允许使用通用SQL查询有用的数据,而后者则将极大地限制您。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.