【问题标题】:Algorithm with O(n log n) time and O(1) space complexity vs O(n) time and O(n) space complexityO(n log n) 时间和 O(1) 空间复杂度与 O(n) 时间和 O(n) 空间复杂度的算法
【发布时间】:2015-05-25 21:30:31
【问题描述】:

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

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

大多数在O(n long n)时间和常数空间内求解的算法都可以在O(n)时间内通过空间上的惩罚来求解。哪种算法更好? 我如何在这两个参数之间做出决定?

示例:数组对和

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

【问题讨论】:

  • 如果您有空间并且时间紧迫,请使用更快的那个。如果您不着急,但没有任何可用空间,请使用空间较少的空间。如果您对两者都感到压力,请进行基准测试并找出哪个看起来更好,即提出可以捕捉您的需求并根据它们进行评估的指标。如果您不关心任何一种方式,请掷硬币/请某人说“A”或“B”/让您的猫决定(最后一个有点轻率,但基本上:如果您不在乎,选择没关系)
  • @G.Bach:: 同意,但“问某人”!=“问 SO”。 (问你的猫没事。)
  • (1, 2) 和 (2, 1) 哪个更好?取决于您对 x 和 y 的值。
  • Most of the [algorithms requiring Θ(n log n)] time and constant space can be solved in O(n) time [and space] - 现在这是一个大胆的断言。除了例子之外还有什么证据吗?
  • 我试图将此问题标记为主要基于意见,但赏金保护了它。 叹息。必须简单地投反对票并继续前进。

标签: algorithm runtime time-complexity space-complexity


【解决方案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) 变得更好,(我没有使用交换)。

【讨论】:

    【解决方案2】:

    假设您的假设是正确的。 考虑到在现实生活中不存在无限资源并且在实施解决方案时您会尽力实施最可靠的解决方案(不会因为您消耗了所有允许的内存而中断的解决方案)这一事实,我会是明智的并继续:

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

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

    【讨论】:

    • 优秀的补充!我认为这个 (T(n) O(n log n), S(n) = O(1)) 很好地回答了如何管理动态数据的情况以及 I/O 读/写、备份和故障问题。我认为您也可以使用具有时间滞后 \tau 的 O(n log n) 算法来表示连续输出。心电图信号的等表示。对吗?
    【解决方案3】:

    在选择算法方法时应牢记三件事。

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

    鉴于这三点,我们可以决定哪种方法适合我们的应用。

    如果我将获得有限的空间和合理的数据,那么条件 2 将发挥主要作用。在这里,我们可以用O(nlogn)检查平滑度,并尝试优化代码并重视条件3。 (例如,Array Pair Sum 中使用的排序算法可以在我的代码中的其他地方重用。)

    如果我有足够的空间,那么按时即兴创作将是主要问题。在这里,而不是可重用性,人们将专注于编写省时的程序。

    【讨论】:

    • 假设你有一个实时应用程序,你的输出只有一个时间延迟 \tau。例如,x == x + 1 是 T(n) = O(n) 和 S(n) = O(n),信号具有例如 ECG 信号作为输入,只是少量数据。我认为 T(n) = O(nlogn), S(n) = O(1) 在这样的应用中比 T(n) = O(n), S(n) = O(n) 更糟糕。跨度>
    • @Masi:没错,鉴于数据集的数量足够小,这意味着即使在最坏的情况下也不会担心空间问题。在这里,我们可以专注于时间效率高的程序,这肯定是 T(n) = O(n) 和 S(n) = O(n)。
    【解决方案4】:

    您总是可以将 O(n lg n) 时间 O(1) 空间算法替换为 O(n) 时间 O(n) 空间算法,这是不正确的。这真的取决于问题,并且有许多不同的算法具有不同的时间和空间复杂性,而不仅仅是线性或线性算法(例如 n log n)。

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

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

    因此,一般的经验法则是选择能够满足其空间需求的最快算法(不仅是渐近复杂度,而且是实际现实世界中常规工作负载的最快执行时间)。

    【讨论】:

      【解决方案5】:

      要比较两种算法,首先应该很清楚我们正在比较它们的目的。 如果我们的优先级是空间,那么 T(n)=O(n log n) & S(n)=O(1) 的算法会更好。 一般情况下,T(n)=O(n) & S(n)=O(n) 的第二个更好,因为空间可以补偿但时间不能。

      【讨论】:

        【解决方案6】:

        使用您的特定算法示例数组对总和,具有 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];
        }
        

        【讨论】:

        • 您是否有任何理由滚动自己的非递归快速排序而不是使用库排序例程?
        • @templatetypedef - 原因是,它比内置的 Array.prototype.sort ~~ function(a,b) {return ab;} 更快,如果你检查 jsfiddle 你会看到快速排序和基数排序实现。如果你用内置排序替换其中一个,你可能会得到一个长时间运行的脚本错误。
        • 我不知道为什么这被否决了。提供的算法或基准的工作方式是否存在错误?
        • 直到遇到 N 太大以至于无法将所有内容都放入内存的情况。
        • @JimMischel - 我的结论是“• 优先使用 O(N) 时间和 O(N) 空间算法,因为实现会更简单,这意味着它更容易维护和调试” .如果 N 大于您在内存中存储的 arrayPairSum( Stream data ),您将如何解决上述 Array Pair Sum 问题?
        【解决方案7】:

        经验:

        • 如果您绝对负担不起空间,请前往 O(1) 空间路线。
        • 当随机访问不可避免时,前往 O(n) 空间路线。 (通常更简单,时间常数更小。)
        • 当随机访问速度较慢时(例如寻道时间),请前往 O(1) 空间路线。 (你通常可以想办法让缓存保持一致。)
        • 否则,随机访问速度很快——走 O(n) 空间路线。 (通常时间常数越小越简单。)

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

        【讨论】:

          【解决方案8】:

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

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

          然后是缓存和引用位置的影响。计算机内存中有大量缓存,这些缓存针对读取和写入相邻的情况进行了优化。缓存未命中的代价可能是巨大的——比命中慢数百或数千倍——所以你想尽量减少这种情况。如果算法使用 O(n) 内存,那么随着 n 变大,您需要开始担心内存访问的密集程度。如果它们分散开,那么缓存未命中的成本可能会开始很快增加,从而显着提高隐藏在时间复杂度的大 O 表示法中的系数。如果它们更连续,那么您可能不需要太担心这一点。

          您还需要注意可用的总内存。如果您的系统上有 8GB 的​​ RAM 并获得一个包含十亿个 32 位整数的数组,那么如果您需要 O(n) 辅助空间以及一个合理的常数,那么您将无法容纳辅助内存进入主内存,它将开始被操作系统分页,真的会杀死你的运行时。

          最后,还有随机性的问题。基于散列的算法预期运行速度很快,但如果你得到一个糟糕的散列函数,算法就有可能会变慢。生成好的随机位是困难的,所以大多数哈希表只使用“相当好的”哈希函数,冒着最坏情况输入的风险,这会使算法的性能退化。

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

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

          • 高内存使用意味着对于大型输入,您必须担心内存被分页进出,从而加剧上述问题。

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

          • 哈希不是最坏情况下的效率,因此可能存在导致性能显着下降的输入。

          现在,考虑 O(n log n) 时间,O(1) 空间算法,该算法通过进行就地数组排序(例如堆排序),然后从左右向内走并查看如果您能找到总和为目标的一对。此过程的第二步具有出色的引用局部性——几乎所有数组访问都是相邻的——并且几乎所有您将要获得的缓存未命中都将出现在排序步骤中。这将增加隐藏在大 O 符号中的常数因子。然而,该算法没有退化的输入,并且它的低内存占用可能意味着引用的局部性将优于哈希表方法。因此,如果我不得不猜测,我会把钱花在这个算法上。

          ...嗯,实际上,我会把钱花在第三种算法上:O(n log n)-time, O(log n)-space 算法,基本上就是上面的算法,但是使用 introsort 而不是堆排序。 Introsort 是一种 O(n log n) 时间、O(log n) 空间算法,它使用随机快速排序对数组进行主要排序,如果快速排序看起来即将退化,则切换到堆排序,并进行最后的插入排序传递清理一切。快速排序具有惊人的参考局部性——这就是它如此之快的原因——并且插入排序在小输入上更快,所以这是一个很好的折衷方案。另外,O(log n) 额外的内存基本上没什么 - 请记住,实际上,log n 最多为 240。该算法具有您可以获得的最佳参考局部性,给出了 O( n log n) 项,因此在实践中它可能会优于其他算法。

          当然,我也必须限定这个答案。我在上面所做的分析假设我们正在讨论算法的相当大的输入。如果您只查看少量输入,那么整个分析就会消失,因为我考虑的影响不会开始出现。在这种情况下,最好的选择就是分析这些方法,看看哪种方法最有效。从那里,您可能能够构建一种“混合”方法,在这种方法中,您对一个大小范围内的输入使用一种算法,而对不同大小范围内的输入使用一种不同算法。这很有可能会提供一种优于任何一种方法的方法。

          也就是说,套用 Don Knuth 的话说,“请注意上述分析 - 我只是证明它是正确的,并没有实际尝试过。”最好的选择是分析所有内容并查看它是如何工作的。我没有这样做的原因是分析需要注意哪些因素,并强调比较两种算法的纯大 O 分析的弱点。我希望实践能证明这一点!如果没有,我很想看看我哪里弄错了。 :-)

          【讨论】:

          • 这是一本非常有趣的书。 +1 将 log(n) 的限制设置为 240,我从来没有这样想过 :)
          • @Masi 我的想法是十亿个 32 位整数是十亿乘以四个字节等于 4GB,大约是系统上所有内存的一半。如果您需要相同数量的辅助空间,则如果不将某些内容分页到磁盘,就无法将其放入主内存。对于 64 位整数,十亿个整数将使用全部 8GB。
          • @Masi 当然!只需将项目数乘以每个项目的大小即可。 32 位整数每个占用 4 个字节,而您给出的数字基本上是 2^31。因此,您需要 2^33 个字节,大约 8GB。 (也就是说,我认为我遗漏了一些东西,因为我不确定这与原始问题有何关系。)
          • “宇宙中的原子数”并不是一个很大的数字,在实际算法中我们面临的数量要大得多
          • @AntonMalyshev 对于将序列作为输入的算法,我认为这是一个相当合理的界限。对于数字算法 - 特别是在加密货币中 - 你是对的,它是一个非常低的数字。
          猜你喜欢
          • 1970-01-01
          • 2015-06-12
          • 2011-07-09
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2019-12-09
          • 1970-01-01
          相关资源
          最近更新 更多