从未排序的数组中求最大的n个数之和

3

我试图解决这个问题:

给定一个未排序的整数数组,求其最大的n个数之和。

我编写了TypeScript实现,但这当然不依赖于任何语言:

const list_sum_largest_n_numbers = (list: Array<number> = [], n: number) => {
  let sum: number = 0;
  const sorted_list = list.sort((a, b) => a - b);

  for (let i = 0; i < n; i++) {
    let value_to_sum = sorted_list?.pop() || 0;
    sum += value_to_sum;
  }

  return sum;
};

let list = [17, 310, 32_432, 3, 2, 317, 34, 108_379];
let n = 3;

let result = list_sum_largest_n_numbers(list, n);
alert(result); // 141_128

请问在playground中解决这种类型的问题时,是否允许使用语言提供的方法,例如Array.sort()等,还是应该自己实现,保持低级别的解决方案?

此外,我不确定这是否是解决它的最佳方法。我认为这是O(n)的,但我没有考虑到我正在使用的Array.sort()方法。


你可以使用该算法来找到最大值,但要将其推广至n个元素。你可以将其视为一个包含n个元素的数组,在达到末尾之前不断替换n个最大数。 - Christian Vincenzo Traina
我认为将求和转换为“从无序数组中确定最大的k个项”并不能解决本质问题。 - greybeard
3个回答

2

排序已经是O(n logn)的复杂度。如果利用内置的排序功能,它可以成为一个简单的解决方案,但不会是问题的最有效解决方案。

简单解决方案O(kn)

如果您正在寻找更有效但简单的解决方案,可以通过迭代选择并删除(或标记为已访问)最大的数字k次来解决问题。您可以在进行迭代时将它们相加,而无需将它们存储在新数组中并再次迭代。这实际上每个数字都是O(n),因此总共是O(kn)

对于小型或恒定的k,这将更有效。当然,如果例如您知道k = O(n),那么更好的方法存在。

高效解决方案O(n)

对于稍微不太平凡的解决方案,您可以使用Quickselect查找第k大的数字x,然后在一次迭代中将所有大于x的数字相加,然后加上x和可能剩余的重复值(或者正如rici所指出的那样,在x的右侧迭代,因为quickselect将数组分区到我们的优势)。这将给您一个总时间复杂度为O(n)的加法操作。这是渐近性能和代码复杂性之间的权衡:)

关于使用内置排序

至于是否可以使用内置函数:是的,绝对可以。每当它们适合您的目的时,最好使用它们,而不是每次都重新发明轮子。但是,在学术或挑战上下文中,可能会有规则禁止使用库或内置函数,出于教育或竞争原因。


1
感谢@berthur,我非常感激你的解释。我没有想到使用Quickselect,但是现在你提到它,这是一个类似的问题。下面@chux建议使用二进制优先队列。我没有很好地理解这个解决方案,但显然这将是O(m·log(n))。这将比使用quickselect更有效,后者将是O(k·n) - Emille C.
1
快速选择可能更好地称为快速分区,因为它实际上会对数组进行分区。在调用quickselect(数组,p)之后,所有索引小于p的值都最多为array[p],所有索引大于p的值都至少为array[p]。(或者你可以反过来做)。因此,要找到和,只需对应的分区加上索引p处的值即可。 - rici
1
@Berthur,我之前指的是快速选择变体。但现在我意识到它是正确的,正如@rici所说:您可以通过第k个最大数将列表分区,因此左侧分区具有所有较低的数字,右侧分区具有所有较大的数字,并且长度为“ k-1”。这些数字的总和加上第k个更大的数字就是响应。 - Emille C.
1
现在我的问题是:Quickselect的最坏情况性能为О(n^2)。这不应该被认为是解决方案的性能吗? - Emille C.
@EmilleC. 就像这样 :) 不过,你可以通过利用随机性来避免那些不幸/恶意的输入。这样,遇到最坏情况的概率就会随着 n 的增加呈指数级下降。 - Berthur
显示剩余7条评论

1
给定数组 a[m],寻找最大的 n 个数:
  1. 如果 m <= n,则完成。

m 明显大于 n 时:成本为 O(m * log(n))

  1. 形成一个 二叉优先队列 p[n]。插入的时间复杂度为 O(log(n)),删除的时间复杂度为 O(log(n)),查看最小值的时间复杂度为 O(1)

  2. 迭代 a[],当 a[i] 大于 p[] 中的最小值时,删除最小值并添加 a[i]

否则,使用成本为 O(m * log(m)) 的简单排序 a[]


一些代码供OP开始使用:(扫描错误检查不充分,未经优化。)
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

static void pq_enqueue(size_t current_size, int *pq, int value) {
  size_t child = current_size;
  while (child > 0) {
    size_t parent = (child + 1) / 2 - 1;
    if (pq[parent] <= value) {
      break;
    }
    pq[child] = pq[parent];
    child = parent;
  }
  pq[child] = value;
}

static int pq_dequeue(size_t current_size, int *pq) {
  int v = pq[0];
  size_t root = 0;
  for (;;) {
    size_t lchild = (root + 1) * 2 - 1;
    size_t rchild = (root + 1) * 2 + 1 - 1;
    if (lchild >= current_size) {
      break;
    }
    int lvalue = pq[lchild];
    int rvalue = rchild < current_size ? pq[rchild] : INT_MAX;
    if (lvalue <= rvalue) {
      pq[root] = lvalue;
      root = lchild;
    } else {
      pq[root] = rvalue;
      root = rchild;
    }
  }
  if (root + 1 < current_size) {
    pq[root] = pq[current_size - 1];
    size_t child = root;
    int value = pq[root];
    while (child > 0) {
      size_t parent = (child + 1) / 2 - 1;
      if (pq[parent] <= value) {
        break;
      }
      pq[child] = pq[parent];
      child = parent;
    }
    pq[child] = value;
  }
  return v;
}

static clock_t test_pq(size_t n, int buf[n], size_t top_n) {
  clock_t c0 = clock();

  for (size_t i = 0; i < n; i++) {
    buf[i] = rand();
  }

  int top[top_n];
  for (size_t i = 0; i < top_n; i++) {
    pq_enqueue(i, top, buf[i]);
  }
  for (size_t i = top_n; i < n; i++) {
    if (buf[i] >= top[0]) {
      pq_dequeue(top_n--, top);
      pq_enqueue(top_n++, top, buf[i]);
    }
  }

  long long sum = 0;
  for (size_t i = 0; i < top_n; i++) {
    if (i < 3) {
      ; // printf("%zu: %d\n", i, top[i]);
    }
    sum += top[i];
  }

  clock_t c1 = clock();
  printf("n:%11zu, top_n:%5zu, sum: %15lld ticks:%9lld\n", n, top_n, sum,
      (long long) (c1 - c0));
  return c1 - c0;
}

int main() {
  printf("RAND_MAX:%d\n", RAND_MAX);
  size_t N = 1000000000;
  int *buf = malloc(sizeof *buf * N);
  if (buf) {
    for (size_t n = 1000000; n <= N; n *= 10) {
      test_pq(n, buf, 3);
    }
    for (size_t t = 4; t <= 1000; t *= 4) {
      test_pq(N, buf, t);
    }
    free(buf);
  }
  return 0;
}

输出

RAND_MAX:2147483647
n:    1000000, top_n:    3, sum:      6442444824 ticks:       16
n:   10000000, top_n:    3, sum:      6442450334 ticks:       78
n:  100000000, top_n:    3, sum:      6442450883 ticks:      797
n: 1000000000, top_n:    3, sum:      6442450934 ticks:     7891
n: 1000000000, top_n:    4, sum:      8589934585 ticks:     6610
n: 1000000000, top_n:   16, sum:     34359737961 ticks:     6593
n: 1000000000, top_n:   64, sum:    137438948345 ticks:     6610
n: 1000000000, top_n:  256, sum:    549755742014 ticks:     6578

请注意,数组大小增加10倍时,时间也增长10倍。
当前N增加4倍时,时间不增长,因为对于随机数据而言,几乎所有值都小于前几个并且没有遇到增长代价。所以我想这是一个不好的测试用例来演示在O(n * log(top_n))中的log(top_n)。当top_n远小于n且为随机数据时,我们会接近O(n)。嗯,稍后再审查一下。

1
如果我理解正确,您使用二进制优先队列的解决方案是O(m·log(n))。鉴于@berthur提出的使用快速排序的解决方案是O(k·n):使用二进制优先队列的解决方案更好吗? - Emille C.
@EmilleC。通过“二进制优先队列”,我指的是一个二叉树。该二叉树可以使用简单数组实现。 - chux - Reinstate Monica
谢谢@chux。但我不明白:树是一种分层结构,而数组是一种线性结构。我一直认为它们是互斥的。 - Emille C.
@EmilleC。想象一棵二叉树,其中根节点是a[1],它的两个子节点分别在a[2]a[3],任何节点a[i]的子节点在a[i*2]a[i*2+1]处。 - chux - Reinstate Monica
@chux-ReinstateMonica 我也不太明白 :) 在第二步访问 p 之前,我是否不应该填充 p?我是否应该像 Lajos 的答案一样用 a 中的 n 个数字来填充它呢? - Berthur
显示剩余6条评论

0
首先,我不会深入探讨你是否允许调用库排序。这取决于你和你的教授/老板/任务。
相反,我将深入探讨你是否应该这样做。想象一下你的数组中有1000000个项目的情况。初始排序只有O(n * log(n))复杂度(其中n代表数组中的项目数),这是很高的成本。此外,在排序后你还需要O(m)的复杂度来计算第一个/最后一个m个元素的总和,取决于排序的方向(其中m代表要求和的项目数),因此总的复杂度为
O(m + n * log(n))
下面我描述我的初始方法,以及@Elliott在评论部分提出的改进方法。
1. 取前n个项
创建一个数组,我们称其为“输出”(输入将在这里称为“输入”)。将输入中的前n个(未排序)元素复制到输出中。
2. 对输出排序

是的,这是一个nlog(n)复杂度的算法,但是你的output很可能比你的input小得多。

3. 继续循环

从第(n+1)个元素开始循环你的input,并且对于每个元素,将其与output中最小的元素进行比较(这就是为什么我们要对output进行排序)。如果output中最小的元素大于当前元素,则在此迭代中不执行任何操作。否则,找到output中仍然小于当前元素的最大元素,将output中所有更小的元素向左移动,并将当前元素放入其正确的位置。

Javascript示例逻辑

function(item, output) {
    let highestIndex = -1;
    while ((output[highestIndex + 1] < item) && (highestIndex + 1 < output.length)) highestIndex++;
    if (highestIndex >= 0) {
        for (let index = 0; index < highestIndex; index++) output[index] = output[index + 1];
    output[highestIndex] = item;
    }
}

4. 考虑边界情况

  • 如果 n 为 0,则结果为 0,无需任何循环
  • 如果 n 等于 input 的长度,则只需对 input 求和
  • 如果 n 为负数或大于 input 的长度,则抛出错误

这些边界情况非常重要,您可以轻松提高它们的性能。

基于Elliott建议的改进

即使要求求和的元素数量通常很低,但在需要求和的项目很多时,我之前描述的初始方法可能会增加复杂度,并且还有进一步的改进空间。

正如Elliott所指出的那样,我的基于堆栈的方法具有线性复杂度(如果要求求和的元素数量很大),他还建议使用AVL树来存储到目前为止的最高数字。

如果采用这种方法,那么这个想法将改变我们处理迄今为止发现的最大数字的方式。每当我们处理一个新元素时,我们可以在AVL树中搜索它(O(log(m))复杂度),删除最小的元素(如果新元素更大),添加新元素并将其插入到这个自平衡树中。
这是一种更复杂但更有效的方法,即它比我最初提出的方法时间上更简单。感谢Elliott提出的改进建议!

@Elliott,感谢您提供改进建议,我已相应地编辑了我的答案。 - Lajos Arpad
1
@Elliott:为什么要使用AVL树而不是Chux在他的答案中提出的优先队列? - Emille C.

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