具有O(n log n)时间和O(1)空间复杂度与O(n)时间和O(n)空间复杂度的算法

时间:2015-03-22 23:10:03

标签: algorithm runtime time-complexity space-complexity

我很想知道哪种算法更好:

  • 具有O(n log n)时间和O(1)空间复杂度的算法
  • 具有O(n)时间和O(n)空间复杂度的算法

在O(n long n)时间和恒定空间中求解的大多数算法可以在O(n)时间内通过支付空间罚分来求解。哪种算法更好? 我如何决定这两个参数?

示例:数组对总和

  1. 可以通过排序
  2. 在O(n logn)时间内解决
  3. 可以在O(n)时间使用哈希映射解决,但使用O(n)空间

8 个答案:

答案 0 :(得分:29)

没有实际测试任何东西(冒险的举动!),我将声称O(n log n)时间,O(1)空间算法可能比O(n)更快 - 时间,O(n)空间算法,但仍可能不是最优算法。

首先,让我们从高层次的角度讨论这个问题,忽略您所描述的算法的特定细节。需要记住的一个细节是,尽管O(n)时间算法比O(n log n)时间算法渐近地快,但它们仅通过对数因子更快。请记住,宇宙中的原子数约为10 80 (多亏了物理!),宇宙中原子数的基数为2的对数约为240.从实际角度来看,这意味着您可以将额外的O(log n)因子视为常量。因此,要确定O(n log n)算法是否比特定输入上的O(n)算法更快或更慢,您需要更多地了解big-O表示法隐藏的常量。 。例如,对于适合宇宙中的任何n,在时间600n中运行的算法将比在时间2n log n中运行的算法慢。因此,就挂钟性能而言,要评估哪种算法更快,您可能需要对算法进行一些分析,以确定哪种算法更快。

然后是缓存和参考局部性的影响。计算机存储器中有大量的高速缓存,这些高速缓存针对读写彼此相邻的情况进行了优化。高速缓存未命中的成本可能很大 - 比命中慢几百或几千倍 - 因此您希望尽量减少这种情况。如果算法使用O(n)内存,那么当n变大时,您需要开始担心内存访问的密集程度。如果它们分散开来,那么缓存未命中的成本可能会相当快地增加,从而显着提高隐藏在时间复杂度的大O符号中的系数。如果他们更顺序,那么你可能不需要过多担心这一点。

您还需要注意可用的总内存。如果你的系统有8GB的RAM并且得到一个具有10亿个32位整数的数组,那么如果你需要O(n)辅助空间甚至是一个合理的常数,你将无法适应你的辅助内存进入主内存,它将开始被操作系统分页,真正杀死你的运行时。

最后,还有随机问题。基于散列的算法具有预期的快速运行时,但是如果你得到一个糟糕的散列函数,那么算法可能会减速。生成好的随机位很难,所以大多数哈希表只是为了#34;相当不错"散列函数,冒着最坏情况输入的风险,这会使算法的性能退化。

那么这些问题在实践中如何发挥作用呢?好吧,让我们来看看算法。 O(n)-time,O(n)-space算法通过构建数组中所有元素的哈希表来工作,这样您就可以轻松检查数组中是否存在给定元素,然后扫描数组和看是否有一对总计达到总和。考虑到上述因素,让我们考虑一下这个算法是如何工作的。

  • 内存使用量为O(n),并且由于散列的工作方式,对散列表的访问不太可能是顺序的(理想的散列表将具有相当多的随机访问模式)。这意味着您将有很多缓存未命中。

  • 高内存使用率意味着对于大输入,您必须担心内存被进入和退出,这会加剧上述问题。

  • 由于上述两个因素,O(n)运行时中隐藏的常数项可能比它看起来要高得多。

  • 散列不是最坏情况下的效率,因此可能会有输入导致性能显着下降。

现在,考虑一下O(n log n)-time,O(1)空间算法,它通过进行就地数组排序(比如heapsort)来工作,然后从左边和右边向内走,看到如果你能找到一对总和到目标的那一对。这个过程的第二步具有出色的参考局部性 - 几乎所有阵列访问都是相邻的 - 几乎所有你将要获得的缓存未命中都将在分类步骤中。这将增加隐藏在big-O表示法中的常数因子。但是,该算法没有退化输入,其低内存占用可能意味着引用的位置将优于哈希表方法。因此,如果我不得不猜测,我会把钱花在这个算法上。

......好吧,实际上,我把我的钱投入了第三种算法:O(n log n)时间,O(log n)空间算法基本上是上述算法,但使用introsort而不是heapsort。 Introsort是一种O(n log n)时间,O(log n)空间算法,它使用随机快速排序来对阵列进行大多数排序,如果快速排序看起来像是要退化,那么就切换到heapsort。最终插入排序传递以清理所有内容。 Quicksort具有惊人的参考位置 - 这就是为什么它如此之快 - 并且插入排序在小输入上更快,所以这是一个很好的折衷方案。另外,O(log n)额外内存基本上没什么 - 记住,在实践中,log n最多为240.此算法具有您可以获得的最佳参考局部性,给出了O隐藏的非常低的常数因子( n log n)term,所以在实践中它可能会胜过其他算法。

当然,我也有资格回答这个问题。我上面做的分析假设我们正在谈论算法的相当大的输入。如果你只是看着小输入,那么整个分析就会消失,因为我考虑的效果不会开始出现。在这种情况下,最好的选择就是分析方法,看看哪种方法效果最好。从那里,你可以建立一个"混合"在一个尺寸范围内使用一种算法进行输入,在不同尺寸范围内使用不同算法进行输入的方法。有可能这会给出一种方法,可以胜过任何一种方法。

也就是说,用Don Knuth的话来说,"要注意上面的分析 - 我只是证明它是正确的,而不是真的尝试过它。"最好的选择是分析一切,看看它是如何工作的。我没有这样做的原因是要分析哪些因素需要注意,并强调比较两种算法的纯粹大O分析的弱点。我希望这种做法能够证明这一点!如果没有,我很乐意看到我弄错了。 : - )

答案 1 :(得分:6)

根据经验:

  • 如果你绝对无法负担空间,请前往O(1)太空路线。
  • 当随机访问不可避免时,请前往O(n)空间路径。 (它通常更简单,时间常数更小。)
  • 当随机访问缓慢(例如搜索时间)时,请前往O(1)空间路径。 (您通常可以找到一种缓存一致的方法。)
  • 否则,随机访问速度很快 - 超越O(n)空间路径。 (通常更简单,时间常数更小。)

请注意,如果问题适合内存比瓶颈存储更快,则通常随机访问是“快速的”。 (例如,如果磁盘是瓶颈,主内存足够快以便随机访问 - 如果主内存是瓶颈,CPU缓存足够快以便随机访问)

答案 2 :(得分:2)

使用您的特定算法示例数组对总和,具有O(n)空间的哈希版本O(n)时间将更快。这是您可以使用http://jsfiddle.net/bbxb0bt4/1/

进行的一点JavaScript基准测试

我在基准测试中使用了两种不同的排序算法,快速排序和基数排序。在这个实例中的基数排序(32位整数数组)是理想的排序算法,即使它几乎不能与单通道哈希版本竞争。

如果您想要一些关于编程的一般性意见:

  • 使用O(N)时间和O(N)空间算法是首选,因为实现将更简单,这意味着它将更容易维护和调试。
function apsHash(arr, x) {
    var hash = new Set();
    for(var i = 0; i < arr.length; i++) {
        if(hash.has(x - arr[i])) {
            return [arr[i], x - arr[i]];
        }
        hash.add(arr[i]);
    }
    return [NaN, NaN];
}

function apsSortQS(arr, x) {
    arr = quickSortIP(arr);
    var l = 0;
    var r = arr.length - 1;
    while(l < r) {
        if(arr[l] + arr[r] === x) {
            return [arr[l], arr[r]];
        } else if(arr[l] + arr[r] < x) {
            l++;
        } else {
            r--;
        }
    }
    return [NaN, NaN];
}

答案 3 :(得分:2)

你不能总是用O(n ln n)时间O(1)空间算法替换O(n)时间O(n)空间1。它实际上取决于问题,并且存在许多不同的算法,它们具有不同的时间和空间复杂性,而不仅仅是线性或线性(例如n log n)。

请注意,O(1)空间有时意味着(如在您的示例中)您需要修改输入数组。所以这实际上意味着你确实需要O(n)空间,但你可以以某种方式使用输入数组作为你的空间(与真正只使用常量空间的情况相比)。不总是可以或允许更改输入数组。

至于在具有不同时间和空间特征的不同算法之间进行选择,这取决于您的优先级。通常,时间是最重要的,所以如果你有足够的内存,你会选择最快的算法(请记住,这个内存只在算法运行时暂时使用)。如果你真的没有所需的空间,那么你会选择一个较慢的算法,这需要更少的空间。

因此,一般的经验法则是选择最快的算法(不仅仅是渐近复杂度,而是实际的真实世界最快的执行时间),它可以满足其空间要求。

答案 4 :(得分:2)

为了比较两种算法,首先应该清楚地表明我们正在比较它们。 如果我们的优先级是空间,则算法具有T(n)= O(n log n)&amp; S(n)= O(1)更好。 一般情况下,第二个T(n)= O(n)&amp; S(n)= O(n)更好,因为空间可以得到补偿,但时间不能。

答案 5 :(得分:1)

在选择算法方法时,应该记住三件事。

  1. 在最坏的情况下应用程序将顺利运行的时间。
  2. 基于程序将运行的环境类型的空间可用性。
  3. 创建的功能的可重用性。
  4. 鉴于这三点,我们可以决定哪种方法适合我们的应用。

    如果我将提供有限的空间和合理的数据,那么条件2将起主要作用。在这里,我们可以使用sudo R检查平滑度,并尝试优化代码并重视条件3。 (例如,阵列对Sum中使用的排序算法可以在我的代码中的其他位置重用。)

    如果我有足够的空间,那么按时即兴即将成为主要关注点。在这里,相反可重用性,人们将专注于编写节省时间的程序。

答案 6 :(得分:1)

假设你的假设是真的。 事实上,在现实生活中,不存在无限的资源,并且在实施解决方案时,您将尽最大努力实施最可靠的解决方案(解决方案不会因为您消耗了所有允许的内存而中断),我会更明智并与:

Algorithm with O(n log n) time and O(1) space complexity

即使您拥有大量内存并且您确信使用占用大量内存的解决方案也不会耗尽内存可能会导致许多问题(I / O读/写速度,故障时备份数据) )我想没有人喜欢在启动时使用2Go内存的应用程序,并且随着时间的推移不断增长,就好像有内存泄漏一样。

答案 7 :(得分:1)

我认为最好是写一个测试,
实际算法,数据量(n),
和内存使用模式很重要。

这里是一个简单的模拟尝试;
时间复杂度的 random()函数调用和 mod 操作,
空间复杂度的随机存储器访问(读/写)。

#include <stdio.h>
#include <malloc.h>
#include <time.h>
#include <math.h>

int test_count = 10;

int* test (long time_cost, long mem_cost){
  // memory allocation cost is also included
  int* mem = malloc(sizeof(int) * mem_cost);
  long i;
  for (i = 0; i < time_cost; i++){
    //random memory access, read and write operations.
    *(mem + (random() % mem_cost)) = *(mem + (random() % mem_cost));
  }
  return mem;
}


int main(int argc, char** argv){
  if (argc != 2) {
    fprintf(stderr,"wrong argument count %d \nusage: complexity n", argc);
    return -1;
  }

  long n = atol(argv[1]);

  int *mem1, *mem2;
  clock_t start,stop;

  long long sum1 = 0;
  long long sum2 = 0;

  int i = 0;
  for (i; i < test_count; i++){
    start = clock();
    mem1 = test(n * log(n), 1);
    stop = clock();
    free(mem1);
    sum1 += (stop - start);

    start = clock();
    mem2 = test(n , n);
    stop = clock();
    free(mem2);
    sum2 += (stop - start);

  }

  fprintf(stdout, "%lld \t", sum1);
  fprintf(stdout, "%lld \n", sum2);

  return 0;
}

禁用优化;

gcc -o complexity -O0 -lm complexity.c

测试;

for ((i = 1000; i < 10000000; i *= 2)); do ./complexity $i; done | awk -e '{print $1 / $2}'

我得到的结果;

  

7.96269
  7.86233
  8.54565
  8.93554
  9.63891
  10.2098
  10.596
  10.9249
  10.8096
  10.9078
  8.08227
  6.63285
  5.63355
  5.45705

直到某点O(n)在我的机器中做得更好
在某一点之后,O(n * logn)越来越好,(我没有使用交换)。