O(n log n)时间复杂度和O(1)空间复杂度的算法与O(n)时间复杂度和O(n)空间复杂度的算法比较。

17
我很想知道哪种算法更好:
  • O(n log n)时间复杂度和O(1)空间复杂度的算法
  • O(n)时间复杂度和O(n)空间复杂度的算法
在大多数情况下,可以通过牺牲空间来将时间复杂度从O(n log n)降至O(n)。那么哪种算法更好呢? 我该如何在这两个参数之间做出决定?
例如:数组对求和问题
  1. 通过排序可以在O(n logn)时间内解决
  2. 可以使用哈希映射在O(n)时间内解决,但需要O(n)空间

7
如果你有足够的空间但时间紧迫,使用更快的那个。如果你不急,但没有多余的空间,请选择占用空间较少的那个。如果两者都很紧迫,就进行基准测试并确定哪个看起来更好,也就是说,制定能够满足你需求的度量标准,并根据它们进行评估。如果你无所谓,可以抛硬币/请别人说"A"或"B"/让你的猫决定(最后一种方法有点随意,但基本上是说:如果你无所谓,那么选择并不重要)。 - G. Bach
1
哪个更好(1,2)还是(2,1)?这取决于你对x和y的价值观。 - Paul Hankin
10
这是一个大胆的声明:“大多数需要Θ(n log n)时间和常量空间的算法可以在O(n)时间[和空间]内解决。” 是否有除示例之外的证明呢? - greybeard
3
我试图将这个问题标记为主观性强,但因为悬赏保护它而无法这样做。叹气 只能简单地点个踩然后继续前进了。 - Erick G. Hagstrom
我认为这在很大程度上取决于情况,更多地取决于您的要求。例如,您可以参考此链接 - Neel Shah
显示剩余6条评论
8个回答

35

没有实际测试(这是一种冒险行为!),我想说O(n log n)时间复杂度且O(1)空间复杂度的算法可能比O(n)时间复杂度且O(n)空间复杂度的算法更快,但仍然可能不是最优算法。

首先,让我们从高层次的角度来看待这个问题,忽略你所描述的算法的具体细节。需要记住的一个细节是,虽然O(n)时间复杂度的算法渐近上比O(n log n)时间复杂度的算法更快,但它们只快了对数因子。需要注意的是,考虑到宇宙中的原子数量约为1080(感谢物理学!),宇宙中原子数量的以2为底的对数约为240。从实用的角度来看,这意味着您可以将额外的O(log n)因子视为常数。因此,要确定O(n log n)算法在特定输入上是否比O(n)算法更快或更慢,您需要了解大O符号隐藏的常数有哪些。例如,运行时间为600n的算法将比运行时间为2n log n的算法慢,不管n的大小如何,只要它适合于宇宙中的任何n。因此,从外部表现的角度来看,为了评估哪个算法更快,您可能需要对算法进行一些剖析,以确定哪个算法更快。

然后还有缓存和引用局部性的影响。计算机内存中有大量缓存,这些缓存针对读写相邻的情况进行了优化。缓存未命中的成本可能非常巨大,比命中慢上数百或数千倍,因此您希望尽量将其减少。如果算法使用O(n)内存,则随着n变得越来越大,您需要开始担心内存访问的密集程度。如果它们分散在不同位置,那么缓存未命中的成本可能会迅速累积,从而显著增加时间复杂度隐藏在大O符号中的系数。如果它们更顺序地排列,那么您可能不需要太担心这个问题。

您还需要注意可用的总内存。如果您的系统有8GB的RAM,并且有一个包含十亿个32位整数的数组,则即使有合理的常数,如果需要O(n)的辅助空间,则无法将辅助内存放入主内存中,并且它将开始被操作系统换出页面,从而大大降低运行时间。

最后还有随机性的问题。基于哈希的算法具有预期的快速运行时间,但如果您使用了糟糕的哈希函数,则算法可能会变慢。生成好的随机位很难,因此大多数哈希表只采用“相当不错”的哈希函数,冒着使算法性能退化的最坏情况输入的风险。

那么这些问题在实践中是如何发挥作用的呢?让我们来看看算法。O(n)-时间,O(n)-空间算法通过建立一个哈希表来存储数组中的所有元素,以便您可以轻松地检查给定的元素是否存在于数组中,然后扫描数组,查看是否有一对元素的和为总和。考虑以上因素,让我们思考一下这个算法的工作原理。
- 内存使用量为O(n),由于哈希的工作原理,对哈希表的访问不太可能是顺序的(理想的哈希表几乎具有随机的访问模式)。这意味着您将会有很多的缓存未命中。 - 高内存使用量意味着对于大型输入,您必须担心内存被分页,加剧上述问题。 - 由于以上两个因素的影响,隐藏在O(n)运行时间中的常数项可能比它看起来要高得多。 - 哈希不是最坏情况下的高效算法,因此可能会有导致性能显著降低的输入。
现在,考虑O(n log n)-时间,O(1)空间算法,该算法通过进行原地排序(例如堆排序),然后从左侧和右侧向内走并查看是否可以找到一对元素之和等于目标。该过程中的第二步具有良好的引用局部性 - 几乎所有的数组访问都是相邻的,而且您将得到的几乎所有缓存未命中都将在排序步骤中发生。这将增加大O符号中隐藏的常数因子。然而,该算法没有退化的输入,并且其低内存占用可能意味着引用局部性优于哈希表方法。因此,如果我必须猜测,我会把我的钱花在这个算法上。
...嗯,实际上,我会把我的钱花在第三个算法上:一个O(n log n)-时间,O(log n)-空间的算法,基本上是以上算法,但使用introsort而不是heapsort。Introsort是一个O(n log n)-时间,O(log n)-空间的算法,它使用随机快速排序来大部分地排序数组,在快速排序即将退化时切换到堆排序,并进行最后的插入排序通道以清除所有东西。快速排序具有惊人的引用局部性 - 这就是为什么它如此快的原因 - 而插入排序在小型输入上更快,因此这是一个很好的折衷方案。此外,O(log n)额外的内存基本上什么都不是 - 记住,在实践中,log n最多为240。该算法具有最好的引用局部性,O(n log n)项隐藏的常数因子很低,因此在实践中它可能会胜过其他算法。当然,我必须也要对这个答案进行限制。我上面的分析假设我们正在讨论输入相当大的算法。如果你只看小输入,那么整个分析都没有意义,因为我考虑的影响不会出现。在这种情况下,最好的选择就是对方法进行剖析,看看哪种方法最有效。从那里开始,您可能能够构建一个“混合”方法,在一个大小范围内使用一种算法,在另一个大小范围内使用另一种算法。很有可能这将提供一种方法,打败任何单一的方法。
话虽如此,引用唐·纳斯(Don Knuth)的话,“当心上述分析-我只是证明它的正确性,而不是实际尝试它。”最好的选择是对所有内容进行剖析,看看效果如何。我之所以没有这样做,是为了经过分析注意什么因素,并强调比较两种算法的纯大O分析的弱点。我希望实践证明这一点!如果不是,我很愿意看到我的错误之处。 :-)

6
这是一篇非常有趣的阅读。+1 对于将log(n)的极限设为240,我从未想过这种想法 :) - Anshul Goyal
1
@Masi 我的想法是,十亿个32位整数是十亿乘以四个字节等于4GB,大约占系统所有内存的一半。如果你需要同样数量的辅助空间,没有办法将其全部放入主内存中而不将某些内容分页到磁盘上。使用64位整数,十亿个整数将使用全部8GB内存。 - templatetypedef
1
@Masi 当然可以!只需将项目数量乘以每个项目的大小即可。32位整数每个占用4个字节,而您提供的数字基本上是2^31。因此,您需要2^33字节,大约为8GB。(话虽如此,我认为我可能漏掉了什么,因为我不确定这与原始问题有何关系。) - templatetypedef
1
"宇宙中的原子数量"并不是一个非常大的数字,在实际算法中,我们面临着更大的数量。 - Anton Malyshev
3
对于以序列作为输入的算法,我认为这是一个相当合理的限制。对于数字算法 - 特别是在加密领域 - 您是正确的,这是一个相当低的数值。 - templatetypedef
显示剩余6条评论

6

从经验来看:

  • 如果你绝对无法承受空间,选择O(1)空间路线。
  • 当随机访问不可避免时,选择O(n)空间路线。(通常更简单,时间常数更小。)
  • 当随机访问速度较慢(例如寻道时间)时,请选择O(1)空间路线。(通常可以找到一种方法使缓存保持一致。)
  • 否则,当随机访问速度快时,请选择O(n)空间路线。(通常更简单,时间常数更小。)

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


2
并不是总能用O(n)时间复杂度和O(1)空间复杂度的算法替代O(n lg n)时间复杂度和O(1)空间复杂度的算法。这取决于具体问题,有许多不同的算法,它们的时间和空间复杂度也各不相同,不仅仅是线性或线性对数级别(例如n log n)。
需要注意的是,O(1)空间复杂度有时意味着(就像在你的例子中一样)你需要修改输入数组。因此,这实际上意味着你确实需要O(n)的空间,但可以将输入数组作为你的空间使用(与真正只使用常量空间的情况不同)。更改输入数组并不总是可能或允许的。
至于在不同时间和空间特性的算法之间进行选择,这取决于你的优先级。通常,时间最重要,因此如果你有足够的内存,你会选择最快的算法(请记住,这些内存仅在算法运行时暂时使用)。如果你真的没有所需的空间,那么你会选择一个需要更少空间但速度较慢的算法。
因此,经验法则是选择最快的算法(不仅通过渐近复杂度,还要考虑实际工作负载下的实际最快执行时间),并尽可能满足其空间要求。

2

比较两个算法,首先需要明确我们要比较的是什么。 如果我们的优先考虑空间,那么具有 T(n)=O(n log n) & S(n)=O(1) 的算法更好。 在一般情况下,第二个具有 T(n)=O(n) & S(n)=O(n) 的算法更好,因为空间可以弥补但时间无法。


2
使用你的具体算法示例Array Pair Sum,哈希版本O(n)时间复杂度和O(n)空间复杂度将更快。这里有一个小的JavaScript基准测试,您可以玩一下http://jsfiddle.net/bbxb0bt4/1/ 我在基准测试中使用了两种不同的排序算法,快速排序和基数排序。在这种情况下(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];
}

1
你为什么要自己编写非递归快速排序算法,而不使用库函数的排序例程呢? - templatetypedef
1
@templatetypedef - 原因是它比内置的 Array.prototype.sort ~~ function(a,b) {return a-b;} 更快,如果您检查 jsfiddle,您将看到快速排序和基数排序实现。如果您将其中一个替换为内置排序,则可能会出现长时间运行的脚本错误。 - Louis Ricci
1
我不确定为什么这个被踩了。提供的算法有错误还是基准测试的方式有问题? - templatetypedef
直到你遇到一个N太大,无法将所有内容都放入内存的情况。 - Jim Mischel
1
@JimMischel - 我的结论是“•使用O(N)时间和O(N)空间算法更好,因为实现会更简单,这意味着维护和调试会更容易”。如果N大于您可以存储在内存中的大小arrayPairSum(流数据),您将如何解决上述数组对求和问题? - Louis Ricci
我也想得到最后一条评论里Ricci提出的问题的答案。 - Léo Léopold Hertz 준영

1

在选择算法方法时,应该牢记三点。

  1. 最坏情况下应用程序能够平稳运行的时间。
  2. 基于程序运行环境的空间可用性。
  3. 所创建的函数的可重用性。

考虑到这三点,我们可以决定哪种方法适合我们的应用程序。

如果我有限的空间和合理的数据供应,那么条件2将起主要作用。在这里,我们可以检查 O(nlogn) 的平稳性,并尝试优化代码并重视条件3。(例如,在数组对求和中使用的排序算法可以在我的代码的其他地方重复使用。)

如果我有足够的空间,则改进时间将是主要关注点。在这里,与可重用性不同,人们会专注于编写高效的程序。


假设您有一个实时应用程序,在其中输出仅具有时间延迟τ。例如,执行“x == x + 1”是T(n) = O(n),S(n) = O(n),输入为ECG信号,只有少量数据。我认为在这种应用程序中,T(n) = O(n),S(n) = O(n)比T(n) = O(nlogn),S(n) = O(1)更糟糕。 - Léo Léopold Hertz 준영
1
@ Masi:没错,鉴于数据集的数量足够小,在最坏情况下甚至空间也不会成为问题。在这种情况下,我们可以专注于时间效率良好的程序,其时间复杂度肯定是T(n) = O(n),空间复杂度是S(n) = O(n)。 - user5139444

1
假设你的假设是正确的。考虑到在现实生活中,不存在无限的资源,并且在实施解决方案时,你会尽力实现最可靠的解决方案(一个不会因为消耗了所有允许的内存而崩溃的解决方案),我认为明智的做法是选择:时间复杂度为O(n log n),空间复杂度为O(1)的算法即使你有大量的内存,并且你确信你永远不会耗尽你的内存,使用消耗大量内存的解决方案也可能导致许多问题(I/O读/写速度,备份数据在出现故障的情况下)。我想没有人喜欢在启动时使用2Go内存并随着时间的推移不断增长,就像存在内存泄漏一样。

1
非常棒的补充!我认为这个(T(n) O(n log n),S(n) = O(1))出色地回答了如何处理动态数据和I/O读/写、备份和故障问题的情况。我认为你也可以用时间延迟\tau的O(n log n)算法来表示连续输出,例如ECG信号的表示。对吧? - Léo Léopold Hertz 준영

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) 表现更好(我没有使用交换)。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接