根据经验估算大时间效率

时间:2012-01-12 14:08:15

标签: java algorithm scala big-o

背景

我想通过基准测试估算库中某些方法的性能。我不需要精确度 - 它足以证明某些东西是O(1),O(logn),O(n),O(nlogn),O(n ^ 2)或更糟。因为大哦意味着上限,所以估计O(logn)的东西是O(log logn)不是问题。

现在,我正在考虑找到最适合每个大数据的常数乘数k(但会覆盖所有结果),然后选择最适合的大哦。

问题

  1. 有没有更好的方式来做这件事而不是我的意思?如果是这样,他们是什么?
  2. 否则,是否有人可以指出算法来估算k以获得最佳拟合,并比较每条曲线与数据吻合的程度?
  3. Notes&约束

    鉴于到目前为止的评论,我需要做一些清楚的事情:

    • 这需要自动化。我无法“查看”数据并做出判断。
    • 我将使用多个n尺寸对方法进行基准测试。对于每个大小n,我将使用经过验证的基准框架来提供可靠的统计结果。
    • 我实际上事先知道将要测试的大多数方法的大部分。我的主要目的是为他们提供性能回归测试。
    • 代码将使用Scala编写,并且可以使用任何免费的Java库。

    实施例

    这是我要测量的那种东西的一个例子。我有一个带有此签名的方法:

    def apply(n: Int): A
    

    给定n,它将返回序列的第n个元素。给定现有实现,该方法可以具有O(1),O(logn)或O(n),并且小的改变可以使其错误地使用次优实现。或者,更容易的是,可以使用依赖的其他方法来使用它的次优版本。

10 个答案:

答案 0 :(得分:16)

为了开始,你必须做出一些假设。

  1. n与任何常数术语相比都很大。
  2. 您可以有效地随机化输入数据
  3. 您可以以足够的密度进行采样,以便更好地处理运行时的分布
  4. 特别是,(3)与(1)一致很难实现。所以你可能得到一些具有指数最坏情况的东西,但从来没有碰到那种最坏的情况,因此认为你的算法比它平均好得多。

    话虽如此,您只需要任何标准的曲线拟合库。 Apache Commons Math有一个完全足够的。然后,您可以创建一个包含要测试的所有常用术语的函数(例如,常量,log n,n,n log n,n n,n n * n,e ^ n),或者你获取数据的对数并拟合指数,然后如果你得到一个不接近整数的指数,看看抛出一个log n是否更适合。

    (更详细地说,如果您适合C*x^aC,或更轻松a,则可以获得指数log C + a log x;对于每个术语的所有常用术语,您将获得每个术语的权重,因此,如果a n*n + C*n*log(n)很大,那么您也将获得该术语。)< / p>

    你需要足够大小改变大小,以便你可以区分不同的情况(如果你关心那些可能很难用日志术语),并且安全地比你有参数的大小更多(可能是3倍过量)只要你至少打了十几个左右就会开始好起来。


    编辑:这是Scala代码,可以为您完成所有这些操作。我不会解释每一小块,而是留给你调查;它使用C * x ^ a拟合实现上面的方案,并返回((a,C),(a的上限为a的上限))。边界是相当保守的,你可以看到几次运行这个东西。 C的单位是秒(C是无单位的),但不要太信任太多,因为有一些循环开销(以及一些噪音)。

    a

    请注意class TimeLord[A: ClassManifest,B: ClassManifest](setup: Int => A, static: Boolean = true)(run: A => B) { @annotation.tailrec final def exceed(time: Double, size: Int, step: Int => Int = _*2, first: Int = 1): (Int,Double) = { var i = 0 val elapsed = 1e-9 * { if (static) { val a = setup(size) var b: B = null.asInstanceOf[B] val t0 = System.nanoTime var i = 0 while (i < first) { b = run(a) i += 1 } System.nanoTime - t0 } else { val starts = if (static) { val a = setup(size); Array.fill(first)(a) } else Array.fill(first)(setup(size)) val answers = new Array[B](first) val t0 = System.nanoTime var i = 0 while (i < first) { answers(i) = run(starts(i)) i += 1 } System.nanoTime - t0 } } if (time > elapsed) { val second = step(first) if (second <= first) throw new IllegalArgumentException("Iteration size increase failed: %d to %d".format(first,second)) else exceed(time, size, step, second) } else (first, elapsed) } def multibench(smallest: Int, largest: Int, time: Double, n: Int, m: Int = 1) = { if (m < 1 || n < 1 || largest < smallest || (n>1 && largest==smallest)) throw new IllegalArgumentException("Poor choice of sizes") val frac = (largest.toDouble)/smallest (0 until n).map(x => (smallest*math.pow(frac,x/((n-1).toDouble))).toInt).map{ i => val (k,dt) = exceed(time,i) if (m==1) i -> Array(dt/k) else { i -> ( (dt/k) +: (1 until m).map(_ => exceed(time,i,first=k)).map{ case (j,dt2) => dt2/j }.toArray ) } }.foldLeft(Vector[(Int,Array[Double])]()){ (acc,x) => if (acc.length==0 || acc.last._1 != x._1) acc :+ x else acc.dropRight(1) :+ (x._1, acc.last._2 ++ x._2) } } def alpha(data: Seq[(Int,Array[Double])]) = { // Use Theil-Sen estimator for calculation of straight-line fit for exponent // Assume timing relationship is t(n) = A*n^alpha val dat = data.map{ case (i,ad) => math.log(i) -> ad.map(x => math.log(i) -> math.log(x)) } val slopes = (for { i <- dat.indices j <- ((i+1) until dat.length) (pi,px) <- dat(i)._2 (qi,qx) <- dat(j)._2 } yield (qx - px)/(qi - pi)).sorted val mbest = slopes(slopes.length/2) val mp05 = slopes(slopes.length/20) val mp95 = slopes(slopes.length-(1+slopes.length/20)) val intercepts = dat.flatMap{ case (i,a) => a.map{ case (li,lx) => lx - li*mbest } }.sorted val bbest = intercepts(intercepts.length/2) ((mbest,math.exp(bbest)),(mp05,mp95)) } } 方法运行时间大约需要sqrt(2) n m *,假设使用了静态初始化数据并且相对于您而言相对便宜'跑步。以下是一些参数,选择参数运行〜15s:

    multibench

    无论如何,对于规定的用例 - 您要检查以确保订单不会改变 - 这可能就足够了,因为您可以在设置测试时稍微玩一下这些值以确保它们给一些明智的东西。人们还可以创建寻求稳定性的启发式方法,但这可能有点过分。

    (顺便说一下,这里没有明确的预热步骤; Theil-Sen估计器的稳健拟合应该使得它不需要合理的大基准。这也是我不使用任何其他长凳框架的原因;任何统计数据都是如此只是从这次测试中失去了力量。)


    再次编辑:如果您使用以下内容替换val tl1 = new TimeLord(x => List.range(0,x))(_.sum) // Should be linear // Try list sizes 100 to 10000, with each run taking at least 0.1s; // use 10 different sizes and 10 repeats of each size scala> tl1.alpha( tl1.multibench(100,10000,0.1,10,10) ) res0: ((Double, Double), (Double, Double)) = ((1.0075537890632216,7.061397125245351E-9),(0.8763463348353099,1.102663784225697)) val longList = List.range(0,100000) val tl2 = new TimeLord(x=>x)(longList.apply) // Again, should be linear scala> tl2.alpha( tl2.multibench(100,10000,0.1,10,10) ) res1: ((Double, Double), (Double, Double)) = ((1.4534378213477026,1.1325696181862922E-10),(0.969955396265306,1.8294175293676322)) // 1.45?! That's not linear. Maybe the short ones are cached? scala> tl2.alpha( tl2.multibench(9000,90000,0.1,100,1) ) res2: ((Double, Double), (Double, Double)) = ((0.9973235607566956,1.9214696731124573E-9),(0.9486294398193154,1.0365312207345019)) // Let's try some sorting val tl3 = new TimeLord(x=>Vector.fill(x)(util.Random.nextInt))(_.sorted) scala> tl3.alpha( tl3.multibench(100,10000,0.1,10,10) ) res3: ((Double, Double), (Double, Double)) = ((1.1713142886974603,3.882658025586512E-8),(1.0521099621639414,1.3392622111121666)) // Note the log(n) term comes out as a fractional power // (which will decrease as the sizes increase) // Maybe sort some arrays? // This may take longer to run because we have to recreate the (mutable) array each time val tl4 = new TimeLord(x=>Array.fill(x)(util.Random.nextInt), false)(java.util.Arrays.sort) scala> tl4.alpha( tl4.multibench(100,10000,0.1,10,10) ) res4: ((Double, Double), (Double, Double)) = ((1.1216172965292541,2.2206198821180513E-8),(1.0929414090177318,1.1543697719880128)) // Let's time something slow def kube(n: Int) = (for (i <- 1 to n; j <- 1 to n; k <- 1 to n) yield 1).sum val tl5 = new TimeLord(x=>x)(kube) scala> tl5.alpha( tl5.multibench(10,100,0.1,10,10) ) res5: ((Double, Double), (Double, Double)) = ((2.8456382116915484,1.0433534274508799E-7),(2.6416659356198617,2.999094292838751)) // Okay, we're a little short of 3; there's constant overhead on the small sizes 方法:

    alpha

    然后你也可以在有一个日志条件的时候得到指数的估计值 - 存在错误估计来选择对数是否是正确的方式,但是你可以接听电话(即我我假设你最初会监督它并阅读掉的数字:

      // We'll need this math
      @inline private[this] def sq(x: Double) = x*x
      final private[this] val inv_log_of_2 = 1/math.log(2)
      @inline private[this] def log2(x: Double) = math.log(x)*inv_log_of_2
      import math.{log,exp,pow}
    
      // All the info you need to calculate a y value, e.g. y = x*m+b
      case class Yp(x: Double, m: Double, b: Double) {}
    
      // Estimators for data order
      //   fx = transformation to apply to x-data before linear fitting
      //   fy = transformation to apply to y-data before linear fitting
      //   model = given x, slope, and intercept, calculate predicted y
      case class Estimator(fx: Double => Double, invfx: Double=> Double, fy: (Double,Double) => Double, model: Yp => Double) {}
      // C*n^alpha
      val alpha = Estimator(log, exp, (x,y) => log(y), p => p.b*pow(p.x,p.m))
      // C*log(n)*n^alpha
      val logalpha = Estimator(log, exp, (x,y) =>log(y/log2(x)), p => p.b*log2(p.x)*pow(p.x,p.m))
    
      // Use Theil-Sen estimator for calculation of straight-line fit
      case class Fit(slope: Double, const: Double, bounds: (Double,Double), fracrms: Double) {}
      def theilsen(data: Seq[(Int,Array[Double])], est: Estimator = alpha) = {
        // Use Theil-Sen estimator for calculation of straight-line fit for exponent
        // Assume timing relationship is t(n) = A*n^alpha
        val dat = data.map{ case (i,ad) => ad.map(x => est.fx(i) -> est.fy(i,x)) }
        val slopes = (for {
          i <- dat.indices
          j <- ((i+1) until dat.length)
          (pi,px) <- dat(i)
          (qi,qx) <- dat(j)
        } yield (qx - px)/(qi - pi)).sorted
        val mbest = slopes(slopes.length/2)
        val mp05 = slopes(slopes.length/20)
        val mp95 = slopes(slopes.length-(1+slopes.length/20))
        val intercepts = dat.flatMap{ _.map{ case (li,lx) => lx - li*mbest } }.sorted
        val bbest = est.invfx(intercepts(intercepts.length/2))
        val fracrms = math.sqrt(data.map{ case (x,ys) => ys.map(y => sq(1 - y/est.model(Yp(x,mbest,bbest)))).sum }.sum / data.map(_._2.length).sum)
        Fit(mbest, bbest, (mp05,mp95), fracrms)
      }
    

    (编辑:修正了RMS计算,所以它实际上是平均值,再加上证明你只需要做一次计时,然后可以尝试两种拟合。)

答案 1 :(得分:14)

我认为你的方法一般都不会起作用。

问题是&#34;大O&#34;复杂性基于限制,因为某些缩放变量趋于无穷大。对于该变量的较小值,性能行为可能看起来完全适合不同的曲线。

问题在于,使用经验方法,您永远无法知道缩放变量是否足够大,以使限制在结果中显而易见。

另一个问题是,如果你在Java / Scala中实现它,你必须花费相当长的时间来消除失真和&#34;噪声&#34;由于诸如JVM预热(例如类加载,JIT编译,堆大小调整)和垃圾收集之类的事情,在你的时间安排。

最后,没有人会对复杂性的经验估计给予太多信任。或者至少,如果他们理解复杂性分析的数学,他们就不会这样做。


<强>后续

回应此评论:

  

您估计的重要性将大大改善您使用的越来越多的样本。

这是事实,虽然我的观点是你(丹尼尔)没有考虑到这一点。

  

此外,运行时功能通常具有可被利用的特殊特征;例如,算法往往不会在某些巨大的n上改变它们的行为。

对于简单的情况,是的。

对于复杂案例和现实案例,这是一个可疑的假设。例如:

  • 假设某些算法使用具有较大但固定大小的主哈希数组的哈希表,并使用外部列表来处理冲突。对于小于主哈希数组大小的N(==条目数),大多数操作的行为将显示为O(1)。只有当N远大于此时,才能通过曲线拟合检测到真正的O(N)行为。

  • 假设算法使用大量内存或网络带宽。通常情况下,它会很有效,直到达到资源限制,然后性能会严重下降。你怎么解释这个?如果它是&#34;经验复杂性的一部分,那么如何确保你到达过渡点?如果你想排除它,你怎么做?

答案 2 :(得分:7)

如果您乐意根据经验估算这一点,您可以衡量指数增加数量的操作所需的时间。使用该比率,您可以获得您估计的功能。

e.g。如果1000次操作与10000次操作的比率(10x)是(先测试较长的操作)你需要做一些实际的操作来查看你所拥有的范围的顺序。

  • 1x =&gt; O(1)
  • 1.2x =&gt; O(ln ln n)
  • ~2-5x =&gt; O(恩)
  • 10x =&gt;为O(n)
  • 20-50x =&gt; O(n nn)
  • 100x =&gt; O(n ^ 2)

它只是一个估计值,因为时间复杂度适用于理想的机器,应该可以通过数学证明而不是测量。

e.g。许多人试图凭经验证明PI是一个分数。当他们测量圆圈与直径的比例时,他们已经确定它总是一小部分。最终,人们普遍认为PI不是一个分数。

答案 3 :(得分:4)

一般来说,你想要实现的目标是不可能的。在一般情况下,即使是算法永远停止的事实也无法证明(参见Halting Problem)。即使它确实停止了您的数据,您仍然无法通过运行它来推断复杂性。例如,冒泡排序具有复杂度O(n ^ 2),而在已经排序的数据上,它执行就好像它是O(n)。没有办法为未知算法选择“适当的”数据来估计其最坏的情况。

答案 4 :(得分:4)

我们最近实施了一个为JVM代码执行半自动平均运行时分析的工具。您甚至无需访问源。它尚未发布(仍在解决一些可用性缺陷)但很快就会发布,我希望。

它基于程序执行的最大似然模型 [1]。简而言之,字节代码使用成本计数器进行扩充。然后,在您控制的分布的一堆输入上运行目标算法(如果需要,可以分布)。聚合计数器使用涉及的启发式(裂缝上的最小二乘法,类型)外推到函数。从那些,更多的科学导致对平均运行时渐近性的估计(例如3.576n - 1.23log(n) + 1.7)。例如,该方法能够以高精度再现Knuth和Sedgewick所做的严格经典分析。

与其他人发布的方法相比,此方法的一大优势在于您独立于时间估计,尤其是独立于机器,虚拟机甚至编程语言。您真的可以获得有关算法的信息,而不会产生所有噪音。

并且---可能是杀手级功能---它带有一个完整的GUI,可以指导您完成整个过程。

有关更多详细信息和更多参考资料,请参阅my answer on cs.SE。 您可以找到初步网站(包括该工具的测试版和发布的论文)here

(注意平均运行时间可以这样估算,而最坏情况运行时永远不会,除非你知道最坏情况。如果你这样做,你可以使用最坏情况下的平均情况分析;只提供最坏情况下的工具实例。通常,运行时限制can not be decided。)


  1. Maximum likelihood analysis of algorithms and data structures由U. Laube和M.E. Nebel撰写(2010)。 [preprint]

答案 5 :(得分:1)

您应该考虑更改任务的关键方面。

将您正在使用的术语更改为:“估算算法的运行时间”或“设置性能回归测试”

您能估计算法的运行时间吗?那么你建议尝试不同的输入尺寸,并测量一些关键操作或时间。然后,对于一系列输入大小,您计划以编程方式估计算法的运行时是否没有增长,不断增长,指数增长等。

因此,您有两个问题,运行测试,并在输入集增长时以编程方式估计增长率。这听起来像是一项合理的任务。

答案 6 :(得分:1)

我不确定我100%得到你想要的东西。但我知道你测试自己的代码,所以你可以修改它,例如注入观察陈述。否则你可以使用某种形式的方面编织?

如何将可重置计数器添加到数据结构中,然后在每次调用特定子函数时增加它们?您可以对这些进行计数@elidable,以便它们在已部署的库中消失。

然后对于给定的方法,比如delete(x),您将使用各种自动生成的数据集测试它,尝试给它们一些倾斜等,并收集计数。虽然Igor指出你无法验证数据结构是否会违反大O界限,但你至少能够断言在实际实验中永远不会超过给定的极限计数(例如,在树永远不会超过4 * log(n)次 - 所以你可以发现一些错误。

当然,您需要某些假设,例如:在您的计算机模型中调用方法是O(1)。

答案 7 :(得分:1)

  

我实际上事先知道大多数方法的大哦   进行测试。我的主要目的是提供性能回归   测试它们。

这个要求很关键。您希望检测具有最少数据的异常值(因为测试应该快速,该死),并且根据我的经验,将曲线拟合到复杂递归的数值评估,线性回归等将过度拟合。我认为你最初的想法很好。

我要做的是准备一份预期复杂度函数列表g1,g2,...,对于数据f,测试每个i的常数f / gi + gi / f的接近程度。使用最小二乘成本函数,这只是为每个i计算该数量的variance并报告最小值。最后注意差异并手动检查异常不合适的情况。

答案 8 :(得分:0)

对于程序复杂性的经验分析,您要做的是运行(和时间)给定10,50,100,500,1000等输入元素的算法。然后,您可以绘制结果图并从最常见的基本类型确定最佳拟合函数顺序:常数,对数,线性,nlogn,二次,三次,高次多项式,指数。这是负载测试的正常部分,它确保算法首先表现为理论化,其次是它满足现实世界的性能预期,尽管其理论复杂性(对数时间算法,其中每个步骤需要5分钟)除了绝对最高基数测试之外,所有测试都失去了二次复杂度算法,其中每一步都是几毫安)。

编辑:打破它,算法非常简单:

定义要评估其性能的各种基数的列表N(10,100,1000,10000等)

对于N中的每个元素X:

创建一组包含X元素的合适测试数据。

启动秒表,或确定并存储当前系统时间。

在X-element测试集上运行算法。

停止秒表,或重新确定系统时间。

开始和停止时间之间的差异是算法在X元素上的运行时间。

对N中的每个X重复。

绘制结果;给定X个元素(x轴),算法需要T时间(y轴)。控制T增加的最接近的基本函数是X增加是你的Big-Oh近似。正如拉斐尔所说,这种近似就是这样,并且不会得到非常精细的区别,例如N的系数,这可能会使N ^ 2算法和2N ^ 2算法之间产生差异(两者在技术上都是O(N) ^ 2)但是给定相同数量的元素,其执行速度将快两倍。

答案 9 :(得分:0)

也想分享我的实验。从理论上讲并没有什么新鲜事物,但是它是一个功能齐全的Python模块,可以轻松地对其进行扩展。

要点:

  • 它基于scipy Python库curve_fit函数,该函数允许 使任何函数适合给定的点集,以将 平方差;

  • 因为已经完成测试,所以问题的大小成倍增加 接近开始将具有更大的权重,这不会 帮助确定正确的近似值,所以在我看来 简单的线性插值来平均分配点确实有帮助;

  • 我们尝试拟合的近似值完全在我们的 控制;我添加了以下内容:

        def fn_linear(x, k, c):
            return k * x + c

        def fn_squared(x, k, c):
            return k * x ** 2 + c

        def fn_pow3(x, k, c):
            return k * x ** 3 + c

        def fn_log(x, k, c):
            return k * np.log10(x) + c

        def fn_nlogn(x, k, c):
            return k * x * np.log10(x) + c

这是一个功能齐全的Python模块,可与https://gist.github.com/gubenkoved/d9876ccf3ceb935e81f45c8208931fa4一起使用,并产生一些图片(请注意-每个示例在不同轴比例下有4个图形)。

sorting

bisect

cartesian product