关于深度优先遍历和广度优先遍历的一些深入思考

3 篇文章 0 订阅
3 篇文章 0 订阅

之前关于深度和广度优先遍历觉得算是比较简单的东西了,特别是深度优先遍历,用递归实现起来几乎是非常自然的,然而最近进行了一些思考探索,仍然有一些非常有意思的点,不论是从实际应用,还是优化方向。由于线性结构遍历比较朴素就不讨论了,这里主要针对图和树两种模型来探讨。

深度优先遍历还是广度优先遍历

从结果上看,二者都是遍历整个关联结构,而且时间复杂度都一样,跟对象群的规模呈线性关系, 没有太大的影响,但过程上还是有些差别。

我们先来聊下深度优先遍历

深度优先遍历有递归和非递归的写法,对于递归来说,它的代价在于递归的栈开销,所来带的问题是当递归的层次过深,便可能有栈溢出的问题。我们知道栈空间相比堆空间来说要小很多。但递归的好处在于设计思路清晰,实现起来非常的自然和容易。因此在具有较多栈空间开销的递归实现中(参数多,局部变量多),就要考虑优化。

我们可以看一个经典的例子:C#中的Sort

我们一般认为商业代码中,会使用快排作为默认的排序实现,实际上C#中的Sort有两种以快排为原型的改进,一种称为DepthLimitedQuickSort,一种称为IntroSort

通过DepthLimited关键字我们也能猜出大概的意思,就是对递归深度做限制,我们看一段源代码

internal static void DepthLimitedQuickSort(
      T[] keys,
      int left,
      int right,
      IComparer<T> comparer,
      int depthLimit)
    {
      .....
      while (depthLimit != 0)
      {
        --depthLimit;
        if (index2 - left <= right - index1)
        {
          if (left < index2)
            ArraySortHelper<T>.DepthLimitedQuickSort(keys, left, index2, comparer, depthLimit);
          left = index1;
        }
        else
        {
          if (index1 < right)
            ArraySortHelper<T>.DepthLimitedQuickSort(keys, index1, right, comparer, depthLimit);
          right = index2;
        }
        if (left >= right)
          return;
      }
      ArraySortHelper<T>.Heapsort(keys, left, right, comparer);
    }

期间我省略了一些不重要的代码,通过观察可以知道,在递归深度限制之内,仍然是递归调用排序,当超出递归深度后转而调用堆排序,这就是对递归深度的优化。

而C#官方给的这个最大递归深度是32

ArraySortHelper<T>.DepthLimitedQuickSort(keys, index, length + index - 1, comparer, 32);

这里给出微软官方底层实现的源代码链接,感兴趣的可以看一下,多看源码收获还是很大的,读商业代码和学习代码完全是两种感受。

https://referencesource.microsoft.com/#mscorlib/system/collections/generic/arraysorthelper.cs

至于IntroSort就做了更多的优化,不仅有深度限制,还会在元素个数少于16个的时候,直接调用插入排序等其他针对更小规模数据的排序方法。

有些同学可能会觉得,32是不是很大了,按照我们对二叉树的理解,一棵32层深的书,包含的叶子节点数在2^31。这只是理论上限,虽然快排做了很多优化,但快排的轴划分毕竟不会是完全均衡的。我们学习快排的时候知道,快排在最差的情况下,复杂度是O(N^2)的,就是因为轴划分不均等的影响。在此种情况下,递归深度就不能按照这个来计算了。

那如果是非递归实现呢?我们知道深度优先遍历的非递归实现依赖于一个栈,递归过程中会将遍历的节点子孩子压入栈,通过逆序弹出来达到深度有限的效果,但对于一个简单实现来说,栈最大存储占用并不是仅仅和树的高度有关,因为你总是会将遍历的某个子树的第一个节点的所有孩子都压入栈。在极端情况下,当你真正开始遍历第一个节点的时候。所有的元素都已经压入栈中,感兴趣的同学可以思考下这是种什么情况。

另外在一些通过深度优先遍历进行重新分配空间的情况来说,比如GC中的压缩算法,通过深度优先遍历,可以让有关联的对象排列在一起,从而增加缓存的命中,提高速度。

另外请注意,前边所涉及的情况都是在假设我们所遍历的结构是一棵树,如果是图结构的话,还必须考虑带环的问题,需要记录下已经访问过点以防止重复访问。

我们在笔试的时候曾出过一道题目,就是深拷贝一个对象。很多同学能够意识到对引用类型对象需要递归的进行拷贝,却忽略了不同属性引用同一个对象,属性引用自身,以及环式引用(循环链表)。在考虑遍历时一定要对所遍历对象的模型认识清楚,从而选择最优的实现方法。

接下来我们说一下广度优先遍历

广度优先遍历一般是需要依赖队列这种数据结构来实现的,其天然的迭代属性使得其结构性开销通常来说会比较小。如果我们把遍历的过程展开成一棵遍历树的情况下会发现,队列中最大元素的个数是和树的宽度相关的,而深度优先遍历之前也说了,是和树的高度相关的。因此树越扁平化队列的峰值消耗就会越大。但在实际应用中,广度优先遍历的消耗会比预计的要大。我们从两点来考虑这个问题。

第一、从缓存命中的角度看,队列的入队和出队是在队列的两头进行操作的,相对于总是从一端进行操作的栈来说,当队列元素过多的时候,缓存失效的可能性会更大。

第二、从容器的扩容机制来说,队列底层也是用数组实现的(C#),当数组元素不足以容纳元素的个数的时候,数组会以2倍扩容的机制进行扩容。当我们卡到临界点的时候,甚至可能会造成一倍的空间浪费。

关于空间的浪费其实是增大了峰值内存的消耗,我们关注峰值是因为过高的峰值内存可能会引起程序的闪退,特别是做移动应用开发的时候。因为这部分内存最终在遍历结束后会清空,所以并不会造成后续的困扰。

在一些特定的情况下,广度优先遍历可以省去队列这种额外的需要。例如我们需要用线性表收集所有遍历后的结果。就可以直接将线性表本身作为队列,通过双指针的模式来模拟队列的行为,就可以达到最终的效果。

举个例子:

List<Node> res = new List<Node>();
res.Add(root);
for (int i = 0; i < res.Count; ++i)
{
    res[i].Children.ForEach(child => res.Add(child));
}

请注意,这里仍然是以C#为例去写的,请确保你足够了解系统的机制,比如这里的res.Count,这是一个属性而不是表达式,并不会提前计算结果。因此才能保证每次插入后循环会继续进行。

通过IL代码可以看到,每次循环都会重新调用方法获取Count

IL_0048: callvirt     instance int32 class [mscorlib]System.Collections.Generic.List`1<int32>::get_Count()

关于非递归算法的补充说明

非递归的实现方式还有一个好处是,可以做分步处理,因为所有的待考察对象都维护在一个栈或队列等容器里,因此我们可以将这个容器保存起来,每次只执行规定的迭代次数。这也是了解垃圾回收算法中的增量式回收的概念时看到的。不过递归算法,特别是广度优先,改写为多线程似乎更容易一些,因为没有实际应用过,就不讨论了

总结

在实际的应用开发中,搜索和遍历是经常遇到的情况,这里仅针对两种常见的遍历方法进行了一些深入的探讨,不过也可以发现,我们例子里多数情况下,遍历的过程并不会改变原对象,而在实际的处理中,还会有在遍历中操作等更复杂的行为,比如先序遍历和后续遍历,对于遍历过程中比较依赖于父子关系的情况,就要仔细考虑实现的模式了。代码设计的难易度,时间,空间效率都要根据实际情况去做权衡。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值