递归与循环的效率迷思

我会带着你远行 2021-12-21 08:43 331阅读 0赞

本文简单比较了一下相同逻辑下,递归实现和循环实现的效率差异

已经不记得最初是从哪里获取的信息了,自己总有一个印象是递归的效率比循环差,因为递归有很大的函数调用开销,再加上递归可能存在的堆栈溢出问题(本文暂不考虑该问题),所以书写代码时还是尽量使用循环为好.

简单举个加法的例子(求解前 n 个自然数的和):

  1. // C#
  2. // recur version
  3. int AddRecur(int val)
  4. {
  5. if (val > 0)
  6. {
  7. return val + AddRecur(val - 1);
  8. }
  9. return 0;
  10. }
  11. // iter version
  12. int AddIter(int val)
  13. {
  14. var ret = 0;
  15. for (int i = 1; i <= val; ++i)
  16. {
  17. ret += i;
  18. }
  19. return ret;
  20. }

简单 Profile 一下,发现循环版本比递归版本要快 64% 左右 ~

再举个耳熟能详的例子: 斐波那契数列

  1. // C#
  2. // recur version
  3. long FibonacciRecur(long index)
  4. {
  5. if (index <= 1)
  6. {
  7. return index;
  8. }
  9. else
  10. {
  11. return FibonacciRecur(index - 1) + FibonacciRecur(index - 2);
  12. }
  13. }
  14. // iter version
  15. long FibonacciIter(long index)
  16. {
  17. if (index <= 1)
  18. {
  19. return index;
  20. }
  21. else
  22. {
  23. long pre = 0;
  24. long cur = 1;
  25. long next = 0;
  26. for (long i = 2; i <= index; ++i)
  27. {
  28. // calc next
  29. next = pre + cur;
  30. // update pre and cur
  31. pre = cur;
  32. cur = next;
  33. }
  34. return next;
  35. }
  36. }

继续 Profile 一下,发现循环版本比递归版本要快了 96% 左右 !
不过稍有递归经验的朋友都会看出,上面的递归实现会做很多的重复计算,更好的方式就是缓存一下中间的计算结果:

  1. // C#
  2. Dictionary<long, long> s_buffer = new Dictionary<long, long>();
  3. long FibonacciRecur(long index)
  4. {
  5. if (index <= 1)
  6. {
  7. return index;
  8. }
  9. else
  10. {
  11. long pre = 0;
  12. if (!s_buffer.TryGetValue(index - 1, out pre))
  13. {
  14. pre = FibonacciRecur(index - 1);
  15. s_buffer[index - 1] = pre;
  16. }
  17. long cur = 0;
  18. if (!s_buffer.TryGetValue(index - 2, out cur))
  19. {
  20. cur = FibonacciRecur(index - 2);
  21. s_buffer[index - 2] = cur;
  22. }
  23. return pre + cur;
  24. }
  25. }

改动之后,循环版本比递归版本就只快 64% 左右了 ~

试验到现在,似乎都印证了我之前的印象: 递归比循环慢,写代码就要写循环~

我们最后来看个真实的(也更复杂的)示例:查找指定名字的子节点(假设我们有一颗树形结构的节点树,给出根节点,查找某个指定名字的子节点)

以下是一个简易的树形结构实现:

  1. // C#
  2. public class Node
  3. {
  4. string m_name;
  5. List<Node> m_children = new List<Node>();
  6. public Node(string name, params Node[] children)
  7. {
  8. m_name = name;
  9. m_children.AddRange(children);
  10. }
  11. public string GetName()
  12. {
  13. return m_name;
  14. }
  15. public int GetChildCount()
  16. {
  17. return m_children.Count;
  18. }
  19. public Node GetChild(int index)
  20. {
  21. if (index >= 0 && index < m_children.Count)
  22. {
  23. return m_children[index];
  24. }
  25. return null;
  26. }
  27. }

查找树形结构的指定节点一般可以采用 BFS 或 DFS,这里我们使用 DFS :

  1. // C#
  2. Node FindChildRecur(Node parent, string name)
  3. {
  4. if (parent != null)
  5. {
  6. var childCount = parent.GetChildCount();
  7. for (int i = 0; i < childCount; ++i)
  8. {
  9. var child = parent.GetChild(i);
  10. if (child.GetName() == name)
  11. {
  12. return child;
  13. }
  14. else
  15. {
  16. var childNode = FindChildRecur(child, name);
  17. if (childNode != null)
  18. {
  19. return childNode;
  20. }
  21. }
  22. }
  23. }
  24. return null;
  25. }

如果要将上面的递归代码改为循环代码,方法就没有之前那么简明了,需要我们自行来模拟调用栈:

  1. // C#
  2. Stack<Node> s_stack = new Stack<Node>();
  3. Node FindChildIter(Node parent, string name)
  4. {
  5. if (parent != null)
  6. {
  7. s_stack.Clear();
  8. s_stack.Push(parent);
  9. while (s_stack.Count > 0)
  10. {
  11. var curParent = s_stack.Pop();
  12. var childCount = curParent.GetChildCount();
  13. for (int i = childCount - 1; i >= 0; --i)
  14. {
  15. var child = curParent.GetChild(i);
  16. if (child.GetName() == name)
  17. {
  18. return child;
  19. }
  20. else
  21. {
  22. if (child.GetChildCount() > 0)
  23. {
  24. s_stack.Push(child);
  25. }
  26. }
  27. }
  28. }
  29. }
  30. return null;
  31. }

考虑到递归调用的高消耗,似乎我们应该将之前的递归代码改写为这种循环形式,但是 Profile 之后发现,其实循环版本还略慢于递归版本,原因就在于(模拟)调用栈的引入抵消了(甚至超过了)函数调用的开销.

其实一般而言,栈内存的操作消耗都要小于堆内存的操作消耗,上面例子中引入的(模拟)调用栈其实就是一种堆操作,考虑到 CLR(C#) 的可能影响,我也用 C++ 进行了一样的实现对比,最终结果也是一致的,甚至在 C++ 中实现的循环版本还要显著慢于其递归版本.

还有一个问题之前没有提及,就是代码可读性问题,从我个人经验来讲,递归代码的可读性大体上还是要优于循环代码的.

结论

一般而言,将递归代码改写为循环代码可以提高效率,但是一旦改写过程中引入了堆操作,那么结果往往是相反的.

发表评论

表情:
评论列表 (有 0 条评论,331人围观)

还没有评论,来说两句吧...

相关阅读

    相关 AI测试

    近年来,我一直关注AI相关的测试,并积极参与多个全国性测试社区和社群。在这些社区中,我与不同公司和领域的测试专家交流探讨AI测试相关话题,包括业界顶尖公司的专家和国内知名测试学

    相关 -PTA循环日程表

    设有N个选手进行循环比赛,其中N=2^M,要求每名选手要与其他N−1名选手都赛一次,每名选手每天比赛一次,循环赛共进行N−1天,要求每天没有选手轮空。 输入格式: 输入:

    相关 之AI

    AI属于当下热点,但是阿里巴巴人工智能实验室(AI Labs)已经解散这一消息无疑给资本市场泼了一盆冷水。 AI就目前来看,仍然是一个高投入产出低的科研项目。研究人员的研究进

    相关 循环优化

    (一)场景描述 在电子商城项目中,我们会有一个获取目录的查询,目录下面有子目录。在后台的时候,我们怎样获取数据库中已经设计好的目录呢? 首先,面对一个需求,我们想的是,

    相关 循环效率

    > 本文简单比较了一下相同逻辑下,递归实现和循环实现的效率差异 已经不记得最初是从哪里获取的信息了,自己总有一个印象是递归的效率比循环差,因为递归有很大的函数调用开销,再加上