为什么嵌套循环被认为是不好的做法?


33

我的讲师今天提到,可以在Java中“标记”循环,以便在处理嵌套循环时可以引用它们。因此,我在不了解该功能的情况下查找了该功能,并在很多地方对该功能进行了解释,然后警告并阻止嵌套循环。

我真的不明白为什么吗?是否因为它影响代码的可读性?还是更具“技术性”?


4
如果我没记错CS3课程,那是因为它通常会导致指数时间,这意味着如果您获得大量数据集,则您的应用程序将变得无法使用。
特拉维斯·佩塞托

21
关于CS讲师,您应该了解的一件事是,他们所说的并不是所有内容都适用于现实世界。我不建议嵌套多于几个的循环,但是如果您必须处理m x n个元素来解决问题,那么您将进行许多次迭代。
Blrfl 2013年

8
@TravisPessetto实际上,它仍然是多项式复杂度-O(n ^ k),k是嵌套数,而不是指数O(k ^ n),其中k是常数。
m3th0dman

1
@ m3th0dman感谢您纠正我。我的老师在这方面不是最出色的。他将O(n ^ 2)和O(k ^ n)视为相同。
特拉维斯·佩塞托

2
有人说,嵌套循环增加了循环复杂性(请参阅此处),从而降低了程序的可维护性。
马可(Marco)

Answers:


62

嵌套循环很好,只要它们描述正确的算法即可。

嵌套循环具有性能方面的考虑(请参见@ Travis-Pesetto的答案),但是有时它是完全正确的算法,例如,当您需要访问矩阵中的每个值时。

Java中的标记循环允许在其他嵌套循环繁琐的时候过早地脱离几个嵌套循环。例如,某些游戏可能具有如下代码:

Player chosen_one = null;
...
outer: // this is a label
for (Player player : party.getPlayers()) {
  for (Cell cell : player.getVisibleMapCells()) {
    for (Item artefact : cell.getItemsOnTheFloor())
      if (artefact == HOLY_GRAIL) {
        chosen_one = player;
        break outer; // everyone stop looking, we found it
      }
  }
}

尽管上述示例中的代码有时可能是表达某种算法的最佳方式,但通常最好将此代码分解为较小的函数,并最好使用return代替break。所以break带标签的气味是微弱的; 看到它时要多加注意。


2
就像旁注图形一样,图形使用的算法必须访问矩阵的每个部分。但是,GPU专门用于以省时的方式处理此问题。
Travis Pessetto

是的,GPU以大规模并行的方式执行此操作;我想这个问题是关于一个执行线程的。
9000

2
标签容易引起怀疑的原因之一是因为通常有其他选择。在这种情况下,您可以返回。
jgmjgm

22

嵌套循环经常(但并非总是)是不好的做法,因为嵌套循环经常(但并非总是)对您尝试执行的操作产生过大的杀伤力。在许多情况下,有一种更快,更省钱的方法来实现您要实现的目标。

例如,如果列表A中有100个项目,列表B中有100个项目,并且您知道列表A中的每个项目都与之匹配,则列表B中有一个项目(“ match”的定义故意含糊不清)在这里),并且您想要生成一个对列表,执行此操作的简单方法如下:

for each item X in list A:
  for each item Y in list B:
    if X matches Y then
      add (X, Y) to results
      break

每个列表中有100个项目,这将平均需要100 * 100/2(5,000)个matches操作。如果有更多的物品,或者如果不能确保1:1的相关性,则价格会更高。

另一方面,有一种更快的方法来执行这样的操作:

sort list A
sort list B (according to the same sort order)
I = 0
J = 0
repeat
  X = A[I]
  Y = B[J]
  if X matches Y then
    add (X, Y) to results
    increment I
    increment J
  else if X < Y then
    increment I
  else increment J
until either index reaches the end of its list

如果您采用这种方式,则不再matches基于进行操作length(A) * length(B),而是基于进行操作length(A) + length(B),这意味着您的代码将运行得更快。


10
需要注意的是O(n log n),如果使用Quicksort ,则排序设置将花费很短的时间(两次)。
罗伯特·哈维

@RobertHarvey:当然可以。但是,这仍然比远不如O(n^2)对N的非微小值
梅森惠勒

10
该算法的第二个版本通常不正确。首先,假设X和Y通过<运算符是可比较的,通常不能从matches运算符中得出。其次,即使X和Y都是数字,第二种算法仍然可能产生错误的结果,例如X matches Yis X + Y == 100
帕夏

9
@ user958624:显然,这是一般算法的非常高级的概述。像“ matches”运算符一样,必须在要比较的数据上下文中以正确的方式定义“ <”。如果正确完成此操作,则结果将是正确的。
梅森惠勒

PHP做了类似的最后一件事,我认为它们的数组是唯一的和/或相反。人们只会使用基于哈希的PHP数组,这样会更快。
jgmjgm

11

避免嵌套循环的一个原因是,将块结构嵌套得太深是个坏主意,无论它们是否为循环。

每个函数或方法都应该易于理解,既要达到目的(名称应表示其功能),也要使维护者易于理解(应该易于理解内部原理)。如果函数太复杂而难以理解,通常意味着应该将某些内部因素分解为单独的函数,以便可以在名称(现在更小)的主函数中引用它们。

嵌套循环可能很难相对较快地理解,尽管某些循环嵌套很好-正如其他人指出的那样,这并不意味着您正在使用非常(且不必要)的慢速算法造成性能问题。

实际上,您不需要嵌套循环即可获得非常慢的性能范围。例如,考虑一个循环,该循环在每次迭代中从队列中取出一个项目,然后可能放回多个项目-例如,迷宫的广度优先搜索。性能不是由循环嵌套的深度(仅为1)决定的,而是由队列最终耗尽(如果已经耗尽)之前放入该队列的项目数决定的-可达部分的大小迷宫是。


1
通常,您可以平整嵌套循环,但仍然需要花费相同的时间。取0到宽度;取0到高度;您可以改为将0乘以宽度乘以高度。
jgmjgm

@jgmjgm-是的,有使循环内的代码复杂化的风险。扁平化有时也可以简化操作,但是更常见的是,您至少增加了恢复实际需要的索引的复杂性。为此,一个技巧是使用索引类型,该索引类型将所有循环嵌套到逻辑中以增加特殊的复合索引-您不太可能只为一个循环执行此操作,但也许您有多个结构相似的循环或您可以编写一个更灵活的通用版本。为了清楚起见,使用该类型(如果有)的开销是值得的。
Steve314

我并不是建议这样做是一件好事,而是将两个循环变成一个循环有多么简单却又不影响时间复杂度。
jgmjgm

7

考虑到许多嵌套循环的情况,您最终将需要多项式时间。例如,给出此伪代码:

set i equal to 1
while i is not equal to 100
  increment i
  set j equal to 1
  while j is not equal to i
    increment j
  end
 end

这将被视为O(n ^ 2)时间,该时间图类似于: 在此处输入图片说明

其中y轴是程序终止时间,而x轴是数据量。

如果获取的数据过多,您的程序将变得如此缓慢,没人会等待它。而且我相信花不了太多的大约1000个数据条目。


10
您可能希望重新选择图形:那是指数曲线而不是二次曲线,递归也不会使O(n logn)变成O(n ^ 2)
棘轮异常

5
对于递归,我有两个词“ stack”和“ overflow”
Mateusz,

10
您关于递归可以将O(n ^ 2)运算简化为O(n log n)的说法非常不准确。递归与迭代实现的同一算法应具有完全相同的big-O时间复杂度。此外,由于每个递归调用都需要创建一个新的堆栈框架,而迭代仅需要一个分支/比较,因此递归通常会比较慢(取决于语言的实现)。函数调用通常很便宜,但不是免费的。
dckrooney

2
@TravisPessetto在过去的6个月中,由于递归或循环对象引用,在开发C#应用程序时,我已经看到3次堆栈溢出。有趣的是,它会崩溃并且您不知道是什么打击了您。当您看到嵌套循环时,您会知道可能会发生某些不良情况,并且很容易看到有关错误索引的异常。
Mateusz

2
@Mateusz同样,像Java这样的语言也允许您捕获堆栈溢出错误。带有堆栈跟踪的代码应该可以让您看到发生了什么。我经验不足,但是唯一一次看到堆栈溢出错误是在PHP中,该错误具有导致无限递归的错误,而PHP用光了分配给它的512MB内存。递归需要有一个最终的终止值,这对无限循环不利。像CS中的一切一样,所有事物都有时间和地点。
Travis Pessetto

0

驾驶三十吨卡车而不是小型乘用车是不好的做法。除非您需要运输20或30吨的东西。

使用嵌套循环时,这不是一个坏习惯。这要么完全是愚蠢的,要么恰恰是所需要的。你决定。

但是,有人抱怨标记循环。答案是:如果必须提出问题,则不要使用标签。如果您足够了解自己的决定权,那么您就可以自己决定。


如果您足够了解自己的决定权,那么您就知道不要使用带标签的循环。:-)
user949300

不会。当您教得足够多时,就被教导不要使用标签用途。当您知道足够多时,您就会超越教条,做正确的事。
gnasher729

1
标签的问题在于它很少需要,而且很多人都在早期使用它来解决流控制错误。就像人们在错误地进行流控制时如何放置整个地方一样。功能还使其在很大程度上变得多余。
jgmjgm

-1

嵌套循环本质上没有错误,甚至没有坏处。但是,它们确实具有某些考虑因素和陷阱。

导致您浏览的文章可能是出于简短的目的,或者是由于被称为“被烧毁”的心理过程所致,因此跳过了细节。

被烧毁是当您对某事有负面的经历,并且暗示要避免时。例如,我可能用锋利的刀切蔬菜并割伤自己。然后我可能会说,锋利的刀不好,不要用它们切菜,以免再次发生这种糟糕的经历。这显然是不切实际的。实际上,您只需要小心。如果您要告诉别人切蔬菜,那么您对此会有更强烈的认识。如果我指示孩子们切蔬菜,我会非常强烈地告诉他们不要使用锋利的刀,特别是如果我不能密切监督他们的话。

编程中的问题是,如果您始终优先选择安全性,那么您将无法达到峰值效率。在这种情况下,孩子们只能切软菜。面对其他任何东西,他们只会用钝刀弄乱它。重要的是要学习正确使用循环(包括嵌套循环)的方法,如果它们被认为是不好的并且永远不要尝试使用它们,则不能这样做。

正如这里的许多答案所指出的,嵌套的for循环表明程序的性能特征,每次嵌套可能使指数性能恶化。也就是说,O(n),O(n ^ 2),O(n ^ 3)等包含O(n ^ depth),其中depth表示您嵌套了多少个循环。随着嵌套的增长,所需的时间呈指数增长。问题在于,不确定时间或空间的复杂性(通常是* * b * c,但并非所有嵌套循环都可以一直运行)还是不确定性即使存在性能问题。

对于许多人来说,尤其是坦率的学生,作家和讲师,很少为生活而编程,或者每天为循环而编程,这也可能是他们不习惯的,并且在初次接触时会引起过多的认知负担。这是一个有问题的方面,因为总会有一条学习曲线,要避免将学习曲线转化为程序员不会有效。

嵌套循环会变得很疯狂,也就是说,它们最终可能嵌套得非常深。如果我遍历每个大洲,然后遍历每个国家,然后遍历每个城市,遍历每个商店,然后遍历每个货架,然后遍历每种产品(如果是一罐豆子,遍历每种豆子,并测量其大小即可得出平均值)可以看到它会非常深地嵌套。您将拥有一个金字塔,并且左边缘有很多浪费的空间。您甚至可能最终离开页面。

在屏幕较小且分辨率较低的情况下,这个问题在历史上会变得更加重要。在那种情况下,即使是多层嵌套也可能真正占用大量空间。今天,这是一个较小的问题,尽管如果有足够的嵌套它仍然会出现问题,但是阈值较高。

相关的是美学论点。与具有更一致的对齐方式的布局相比,许多人发现嵌套环在美学上并不令人愉悦,这可能会或可能不会与人们习惯使用的东西,眼睛跟踪和其他问题相关联。然而,这是有问题的,因为它倾向于自我增强并且最终可能使代码更难阅读,因为破坏了代码块,并且封装了诸如函数之类的抽象后面的循环也冒着破坏代码到执行流的映射的风险。

人们习惯了自然的倾向。如果您以最简单的方式进行编程,则不需要嵌套的概率最高,需要一个级别的概率下降一个数量级,而另一个级别的概率又下降。频率下降,从本质上讲,意味着嵌套越深,人类的感知就越缺乏训练。

与此相关的是,在任何可以考虑嵌套循环的复杂构造中,您都应该总是问:最简单的解决方案,因为遗漏的解决方案可能需要更少的循环。具有讽刺意味的是,嵌套解决方案通常是最简单的方法,可以以最小的工作量,复杂性和认知负荷来工作。嵌套循环通常是很自然的。例如,如果您考虑上面的答案之一,那么比嵌套的for循环更快的方法也要复杂得多,并且包含更多的代码。

需要进行大量护理,因为通常有可能将环路抽象化或弄平,但最终结果是治愈的结果比疾病还差,尤其是如果您没有例如从这项工作中获得可衡量的显着性能提升。

人们经常会遇到与循环相关的性能问题,这些循环会告诉计算机重复执行多次操作,并固有地会牵涉到性能瓶颈。不幸的是,对此的回应可能是非常肤浅的。人们通常会看到一个循环,看到一个不存在的性能问题,然后将循环隐藏起来,看不到任何实际效果。该代码“看起来”很快,但将其投放在道路上,键入点火开关,放下加速器并查看车速表,您可能会发现它仍然和老太太走齐默车架一样快。

这种隐藏类似于您的路线上有十个抢劫犯。如果不是沿着一条直线走到您要去的地方,而是安排它,以便在每个拐角后面都有一个抢劫犯,那么当您开始旅程时,就给人一种幻觉:没有抢劫犯。眼不见,心不烦。您仍然会被抢劫十次,但现在您看不到它来了。

您所提出的问题的答案是两者都存在,但没有一个问题是绝对的。它们要么完全是主观的,要么只是上下文客观的。不幸的是,有时候,完全主观的或相当主观的意见占据主导地位。

根据经验,如果它需要嵌套循环,或者看起来像是下一个显而易见的步骤,那么最好不要仔细研究并简单地做到这一点。但是,如果仍有任何疑问,则应稍后进行审查。

另一个经验法则是,您应始终检查基数,并问自己这个循环是否会成为问题。在前面的示例中,我经历了城市。为了进行测试,我可能只会经历十个城市,但是在实际使用中可以预期的合理最大城市数量是多少?然后,对于大陆,我可能会将其乘以相同。始终考虑使用循环是一个经验法则,尤其是要迭代动态(可变)的次数,这可能会转化为下一行。

不管总是先做什么。当您确实看到优化机会时,您可以将优化的解决方案与最容易上手的解决方案进行比较,并确认它产生了预期的收益。在进行测量之前,您还可能花费太长时间进行过早的优化,这会导致YAGNI或大量的时间浪费,以及错过了截止日期。


锋利的<->钝刀示例不是很好,因为钝刀通常更容易切割。和为O(n) - >为O(n ^ 2) - >为O(n ^ 3)不指数n,它的几何多项式
卡雷斯

钝刀变得更糟是一个城市神话。实际上,它是变化的,并且通常特定于钝刀特别不适合的情况,该钝刀通常需要很大的力并且涉及很多的打滑。没错,那里有一个隐藏但固有的限制,只能切软菜。我会考虑n ^ depth指数,但您说的对,那些例子不是。
jgmjgm

@jgmjgm:n ^ depth是多项式,depth ^ n是指数。这实际上不是解释问题。
Roel Schroeven

x ^ y与y ^ x不同吗?您犯的错误是您永远不会读答案。您已经为指数求grep,然后就本身不是指数的方程求grep。如果您阅读了一下,就会发现我说嵌套的每一层它都呈指数增长,如果您自己进行测试,时间为(a = 0; a <n; a ++); for(b = 0; b <n ; b ++); for(c = 0; c <n; c ++); 当添加或删除循环时,您会发现它确实是指数的。您会发现一个循环以n ^ 1执行,两个循环以n ^ 2执行,三个循环以n ^ 3执行。您无法理解嵌套的:D。心脏的子集是指数的。
jgmjgm

我想这确实证明了人们确实对嵌套构造感到挣扎。
jgmjgm
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.