之前关于深度和广度优先遍历觉得算是比较简单的东西了,特别是深度优先遍历,用递归实现起来几乎是非常自然的,然而最近进行了一些思考探索,仍然有一些非常有意思的点,不论是从实际应用,还是优化方向。由于线性结构遍历比较朴素就不讨论了,这里主要针对图和树两种模型来探讨。
深度优先遍历还是广度优先遍历
从结果上看,二者都是遍历整个关联结构,而且时间复杂度都一样,跟对象群的规模呈线性关系, 没有太大的影响,但过程上还是有些差别。
我们先来聊下深度优先遍历
深度优先遍历有递归和非递归的写法,对于递归来说,它的代价在于递归的栈开销,所来带的问题是当递归的层次过深,便可能有栈溢出的问题。我们知道栈空间相比堆空间来说要小很多。但递归的好处在于设计思路清晰,实现起来非常的自然和容易。因此在具有较多栈空间开销的递归实现中(参数多,局部变量多),就要考虑优化。
我们可以看一个经典的例子:C#中的Sort
我们一般认为商业代码中,会使用快排作为默认的排序实现,实际上C#中的Sort有两种以快排为原型的改进,一种称为DepthLimitedQuickSort,一种称为IntroSort
通过DepthLimited关键字我们也能猜出大概的意思,就是对递归深度做限制,我们看一段源代码
internal static void DepthLimitedQuickSort(
T[] keys,
int left,
int right,
IComparer comparer,
int depthLimit)
{
.....
while (depthLimit != 0)
{
--depthLimit;
if (index2 - left <= right - index1)
{
if (left < index2)
ArraySortHelper.DepthLimitedQuickSort(keys, left, index2, comparer, depthLimit);
left = index1;
}
else
{
if (index1 < right)
ArraySortHelper.DepthLimitedQuickSort(keys, index1, right, comparer, depthLimit);
right = index2;
}
if (left >= right)
return;
}
ArraySortHelper.Heapsort(keys, left, right, comparer);
}
期间我省略了一些不重要的代码,通过观察可以知道,在递归深度限制之内,仍然是递归调用排序,当超出递归深度后转而调用堆排序,这就是对递归深度的优化。
而C#官方给的这个最大递归深度是32
ArraySortHelper.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 res = new List();
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::get_Count()
关于非递归算法的补充说明
非递归的实现方式还有一个好处是,可以做分步处理,因为所有的待考察对象都维护在一个栈或队列等容器里,因此我们可以将这个容器保存起来,每次只执行规定的迭代次数。这也是了解垃圾回收算法中的增量式回收的概念时看到的。不过递归算法,特别是广度优先,改写为多线程似乎更容易一些,因为没有实际应用过,就不讨论了
总结
在实际的应用开发中,搜索和遍历是经常遇到的情况,这里仅针对两种常见的遍历方法进行了一些深入的探讨,不过也可以发现,我们例子里多数情况下,遍历的过程并不会改变原对象,而在实际的处理中,还会有在遍历中操作等更复杂的行为,比如先序遍历和后续遍历,对于遍历过程中比较依赖于父子关系的情况,就要仔细考虑实现的模式了。代码设计的难易度,时间,空间效率都要根据实际情况去做权衡。