将一个数字表示为连续质数之和

4
如何检查数字n是否可以分解为一系列连续质数之和。
例如,12可以表示为5+7,其中5和7是连续的质数,但20可以表示为3+17,其中3和17不是连续的。
请注意,不允许重复使用质数。
我的想法是找到并列出小于n的所有质数,然后使用两个循环来计算所有质数的总和。首先是前两个数字,第二个是前两个数字之后的两个数字,第三个是前三个数字等等。但这需要很长时间和大量内存。

3
提示:没有必要分别构建前2个质数的列表,然后再构建前3个质数的列表等等。如果前2个质数的总和太小,您就知道需要添加至少1个质数,同样,如果到目前为止序列的总和太大,您就知道没有必要进一步扩展此序列。 - j_random_hacker
3
直觉认为从质数列表中计算所有可能的总和比逐个检查候选数字更容易(更高效)。 - 500 - Internal Server Error
1
@aruisdante:它的时间复杂度为O(n^2),比O(n!)好得多。不过,另一种算法可以在O(n)时间内解决这个问题。 - j_random_hacker
4
提交你的作业时,别忘了注明这个网站帮助你开发算法,这样才不会违反学校的学术诚信规定。请注意,要确保翻译后的内容与原文意思一致,且更加通俗易懂。 - Raymond Chen
1
@j_random_hacker 哎呀,你说得对,我忘了连续约束将限制每个数字范围为 **O(n)**(假设输入质数的预计算列表)。你是对的。 - aruisdante
显示剩余6条评论
5个回答

3
请注意,连续质数列表仅由两个信息确定,即起始和结束的质数。您只需找到这两个数字。
假设您可以使用所有质数,并将其按数组称为“primes”。在内存中保留三个变量:“sum”初始值为2(最小质数),“first_index”和“last_index”最初为0(数组“primes”中最小质数的索引)。
现在,您必须“调整”这两个索引,并在循环中沿途“旅行”数组:
如果“sum == n”,则完成。您已经找到了一系列质数。
如果“sum < n”,则通过添加下一个可用质数来扩大列表。将“last_index”增加一,然后将“sum”增加新质数的值,即“primes[last_index]”。重复循环。但是,如果“primes[last_index]”大于“n”,则没有解决方案,您必须结束。
如果“sum > n”,则通过从列表中删除最小质数来减少列表。将“sum”减去该值,即“primes[first_index]”,然后将“first_index”增加一。重复循环。

1
谢谢,它像魔法一样有效,但是为什么?你能再解释一下吗? - mrdaliri
@kikio 不知道该怎么解释。对我来说似乎很明显。我只是描述了如果没有计算机,我会尝试通过在一端添加数字并从另一端删除数字来查找列表的方法。 - Dialecticus
但是现在,当我将其视为手动劳动而不使用计算机时,我认为有一些小的改进可以稍微加快搜索速度。例如,算法可能会在某个点得出结论,即无法构造p个质数的总和,并且结果必须少于p个质数。算法可以以某种方式“跳转”到数组中第一个满足p-1个质数之和不小于n的位置,从而跳过数组的“无聊”部分。当您从2个质数跳到单个质数并跳过一半的数组时,效果最为明显。 - Dialecticus
这种“跳跃”完全改变了算法,因为现在我意识到第一个具有 p 个质数的位置也是最后一个位置。算法应该只是“跳跃”到下一个位置,并沿途递减 p - Dialecticus
@kikio:我已经添加了一个详细解释为什么这个算法是正确的答案。这可能是我理解的第一个非平凡的正确性证明,所以我希望它对你有所帮助 :) - j_random_hacker

2
Dialecticus算法是解决这种问题的经典O(m)-时间,O(1)-空间的方法(这里我使用m表示小于n的质数数量)。它不依赖于任何质数的神秘属性。(有趣的是,对于特定的质数情况,AlexAlvarez算法也是线性时间!)Dialecticus给出了一个清晰正确的描述,但似乎无法解释为什么它是正确的,所以我在这里尝试解释一下。我真的认为花时间理解这个特定算法的正确性证明非常有价值:虽然我不得不阅读多个解释,直到最后才“领会”,但当我理解时,这是一个真正的“啊哈!”时刻! :) (而且可以以同样的方式高效地解决的问题很多。)
该算法尝试的候选解可以表示为数字范围(i,j),其中i和j仅是素数列表中第一个和最后一个素数的索引。该算法通过两种不同的方式排除(即不考虑)数字范围集来提高效率。为了证明它总是给出正确答案,我们需要证明它从不排除唯一具有正确和的范围。为此,只需证明它从不排除具有正确和的第一个(最左侧)范围即可。
它应用的第一个规则是每当我们找到一个范围(i,j)使得sum(i,j)> n时,我们就排除所有k> j的范围(i,k)。很容易看出这是合理的:随着我们添加更多项,总和只会变得更大,而我们已经确定它已经太大了。
第二个更棘手的规则对于线性时间复杂度至关重要,即每当我们将区间(i,j)的起始点从i推进到i + 1时,我们不是从(i + 1,i + 1)重新开始,而是从(i + 1,j)开始 - 也就是说,我们避免考虑所有满足i + 1 <= k < j的(i + 1,k)。为什么这样做没问题? (换句话说:这样做会不会导致我们跳过一些具有正确和的范围?)
(编辑:下一段的原始版本忽略了一个细节:我们可能已经在任何以前的步骤中将范围结束点推进到j。)
为了确保它不会跳过有效范围,我们需要考虑范围 (i, j-1)。为了让算法推进当前范围的起始点,使其从 (i, j) 变为 (i+1, j),必须满足 sum(i, j) > n;正如我们将看到的那样,要达到一个程序状态,在这个状态中考虑范围 (i, j),必须满足 sum(i, j-1) < n。第二个声明是微妙的,因为有两种不同的方式来到达这样一个程序状态:要么我们刚刚增加了结束点,意味着前一个范围是 (i, j-1),并且发现这个范围太小了(在这种情况下,我们想要的属性 sum(i, j-1) < n 显然成立);要么我们在考虑了 (i-1, j) 并发现它太大后刚刚增加了起始点(在这种情况下,这个属性是否仍然成立并不明显)。
我们所知道的是,无论在前一步将终点从j-1增加到j,还是在之前的某个时间点上一定已经增加了。因此,让我们称触发这个终点增加的范围为(k,j-1)。显然,sum(k,j-1) < n,因为这是导致我们将终点从j-1增加到j的范围(根据定义);并且很明显k <= i,因为我们只按照它们的起始点递增地处理范围。由于i >= k,因此sum(i,j-1)与sum(k,j-1)相同,但左端可能已经删除了零个或多个条款,而所有这些条款都是正数,因此必须sum(i,j-1) <= sum(k,j-1) < n。
所以我们已经确定,每当我们将i增加到i + 1时,我们知道sum(i,j-1)从此总和的任一端删除项都不能使其变得更大。删除第一个术语会使我们得到sum(i + 1,j-1)<= sum(i,j-1)
一个可能的绊脚石是,因为我们正在推进两个循环索引,所以时间复杂度应该是O(m^2)。但请注意,每次通过循环体时,我们都会将其中一个索引(i或j)向前移动一位,并且我们从不将它们中的任何一个向后移动,因此如果在进行2m次循环迭代后仍在运行,则必须满足i+j=2m。由于两个索引都不能超过m,这种情况只有当i=j=m时才能成立,这意味着我们已经到达了结尾:即在最多2m次迭代后保证终止。

1
由于质数必须是连续的,因此可以以n为基础高效地解决这个问题。假设我们先前计算了所有小于或等于n的质数。因此,我们可以轻松地将sum(i)计算为前i个质数的和。
有了这个预先计算的函数,我们可以循环遍历小于或等于n的质数,并查看是否存在一个长度,从该质数开始,我们可以加到n。但是请注意,对于固定的起始质数,总和序列是单调递增的,因此我们可以在长度上进行二分搜索。
因此,让k成为小于或等于n的质数的数量。预先计算总和的成本为O(k),而循环的成本为O(klogk),支配成本。使用素数定理,我们知道k = O(n/logn),然后整个算法的成本为O(n/logn log(n/logn)) = O(n)

让我用C++编写一些代码,以使其更清晰,希望没有错误:

#include <iostream>
#include <vector>
using namespace std;

typedef long long ll;

int main() {
  //Get the limit for the numbers
  int MAX_N;
  cin >> MAX_N;

  //Compute the primes less or equal than MAX_N
  vector<bool> is_prime(MAX_N + 1,  true);
  for (int i = 2; i*i <= MAX_N; ++i) {
    if (is_prime[i]) {
      for (int j = i*i; j <= MAX_N; j += i) is_prime[j] = false;
    }
  }
  vector<int> prime;
  for (int i = 2; i <= MAX_N; ++i) if (is_prime[i]) prime.push_back(i);

  //Compute the prefixed sums
  vector<ll> sum(prime.size() + 1, 0);
  for (int i = 0; i < prime.size(); ++i) sum[i + 1] = sum[i] + prime[i];

  //Get the number of queries
  int n_queries;  
  cin >> n_queries;
  for (int z = 1; z <= n_queries; ++z) {
    int n;
    cin >> n;

    //Solve the query
    bool found = false;
    for (int i = 0; i < prime.size() and prime[i] <= n and not found; ++i) {

      //Do binary search over the lenght of the sum:
      //For all x < ini, [i, x] sums <= n
      int ini = i, fin = int(prime.size()) - 1;
      while (ini <= fin) {
        int mid = (ini + fin)/2;
        int value = sum[mid + 1] - sum[i];
        if (value <= n) ini = mid + 1;
        else fin = mid - 1;
      }

      //Check the candidate of the binary search
      int candidate = ini - 1;
      if (candidate >= i and sum[candidate + 1] - sum[i] == n) {
        found = true;
        cout << n << " =";
        for (int j = i; j <= candidate; ++j) {
          cout << " ";
          if (j > i) cout << "+ ";
          cout << prime[j];
        }
        cout << endl;
      }
    }

    if (not found) cout << "No solution" << endl;
  }
}

样例输入:

1000
5
12
20
28
17
29

示例输出:

12 = 5 + 7
No solution
28 = 2 + 3 + 5 + 7 + 11
17 = 2 + 3 + 5 + 7
29 = 29

有趣的方法。我喜欢你如何利用质数密度来展示这个通常为O(n log n)的方法在这种情况下其实可以适用于O(n)! - j_random_hacker

1
我首先要指出的是,对于一对相邻的质数来说,它们的和等于这个数字,其中一个质数必须小于N/2,另一个质数必须大于N/2。为了它们成为相邻的质数,它们必须是离N/2最近的质数,一个比N/2小,另一个比N/2大。
如果您从质数表开始,基本上可以对N/2进行二进制搜索。查看比它大和比它小的质数。将这些数字相加,看看它们是否等于您的目标数字。如果不是,则它不能是两个相邻质数的和。
如果您没有从质数表开始,它的工作方式基本相同-仍然从N/2开始,并找到下一个更大的质数(我们称之为prime1)。然后,您减去N-prime1以获得prime2的候选项。检查它是否是质数,如果是,请搜索prime2 ... N/2范围内的其他质数,以查看是否有一个质数在中间。如果在这个范围内有一个质数,则您的数字是非连续质数的总和。如果在该范围内没有其他质数,则它是连续质数的总和。
对于三个或更多质数序列,相同的基本思想适用,只是(当然)您的搜索从N/3(或者您想要求和以达到该数字的质数数量)开始。因此,要使三个连续的质数总和为N,其中两个必须是小于N/3的第一个质数和大于N/3的第一个质数。所以,我们首先要找到这些,然后计算N-(prime1+prime2)。这给我们提供了第三个候选项。我们知道这三个数字总和为N。我们仍需要证明这第三个数字是质数。如果它是质数,我们需要验证它是否与其他两个连续。举个具体的例子,对于10,我们从3.333开始。下一个较小的质数是3,下一个较大的是5。它们加起来是8。 10-8 = 2。2是质数,与3连续,因此我们找到了三个连续的质数,它们的总和为10。
还有其他一些细化可以进行。最明显的是,除了2以外的所有质数都是奇数。因此(假设我们可以忽略2),一个偶数只能是偶数个质数之和,而一个奇数只能是奇数个质数之和。因此,对于给定的123456789,我们立即知道它不可能是2(或4、6、8、10等)个连续质数的和,因此唯一需要考虑的候选项是3、5、7、9等质数。当然,反过来也一样:例如,给定12345678,它是偶数这个简单事实让我们立即排除了它可能是3、5、7或9个连续质数之和的可能性;我们只需要考虑2、4、6、8等质数序列。只有当我们得到足够多的质数时,才会违反这个基本规则,这时我们可以将2作为序列的一部分。
我尚未计算出对于给定数字会有多少个这样的质数,但我非常确定这应该相当容易,而且我们也想知道这个数量(因为它是寻找给定数字连续质数的上限)。如果我们使用M表示质数的数量,则限制应该是大约 M <= sqrt(N),但这绝对只是一个近似值。

谢谢,但我不明白如何找出3个质数。例如,对于数字10,10/3等于3.34。接下来该怎么做? - mrdaliri

0

我知道这个问题有点旧,但我不能克制回复先前答案中所做的分析。确实,已经强调了三个算法中所有提出的算法的运行时间基本上是线性的 n。但事实上,不难产生一个运行时间严格较小的 n 的算法。

为了看到如何做到这一点,让我们选择一个介于 1 和 n 之间的参数 K,并假设我们需要的质数已经被列成表格(如果它们必须从头开始计算,请参见下文)。然后,这是我们要做的,以搜索将 n 表示为 k 个连续质数之和:

  • 首先,我们使用 Jerry Coffin 的答案中提出的思想来搜索 k<K;也就是说,我们搜索位置在 n/k 周围的 k 个质数。
  • 然后,为了探索 k>=K 的总和,我们使用 Dialecticus 解释的算法;也就是说,我们从其第一个元素为 2 的总和开始,然后每次向前移动第一个元素一步。

第一部分涉及大质数的短和,需要进行O(log n)次操作来二分搜索一个接近n/k的质数,然后需要进行O(k)次操作来搜索其他k个质数(有几种简单的可能实现)。总体而言,这使得运行时间为

R_1=O(K^2)+O(Klog n)。

第二部分是关于小质数的长和,要求我们考虑连续质数的和p_1<\dots<p_k,其中第一个元素最多为n/K。因此,需要访问最多n/K+K个质数(可以通过素数定理的弱版本节省对数因子)。由于在算法中每个质数最多被访问O(1)次,因此运行时间为

R_2=O(n/K) + O(K)。

现在,如果 log n < K < \sqrt n,那么第一部分将运行 O(K^2) 次操作,第二部分将以 O(n/K) 运行。我们通过选择 K=n^{1/3} 来进行优化,从而使整体运行时间为

R_1+R_2=O(n^{2/3}).

如果素数未被列出

如果我们还需要找到素数,以下是我们的方法。 首先,我们使用 Erathostenes,在 C_2=O(T log log T) 次操作中找到所有小于 T 的素数,其中 T=O(n/K) 是算法第二部分访问的小素数的上限。

为了执行算法的第一部分,我们需要对于每个 k<K,找到位于 n/k 周围的 O(k) 个质数。黎曼猜想暗示如果对于某个常数 c>0y>c log x (k+\sqrt x),则区间 [x,x+y] 中至少有 k 个质数。因此,我们需要先找到包含在以 n/k 为中心、宽度为 |I_k|= O(k log n)+O(\sqrt {n/k} log n) 的区间 I_k 中的质数。

使用筛法埃拉托色尼筛选区间 I_k 需要 O(|I_k|log log n) + O(\sqrt n) 次操作。如果 k<K<\sqrt n,则对于每个 k<K,时间复杂度为 C_1=O(\sqrt n log n log log n)

综上所述,当

C_1+C_2+R_1+R_2

时,时间复杂度最大化。

K = n^{1/4} / (log n \sqrt{log log n}).

选择这个公式可以实现次线性时间复杂度。

R_1+R_2+C_1+C_2 = O(n^{3/4}\sqrt{log log n}.

如果我们不假设黎曼猜想,我们将不得不在更大的区间上搜索,但最终仍然可以获得次线性时间复杂度。相反,如果我们对质数间隙做出更强的猜想,我们可能只需要在宽度为|I_k|=k (log n)^A的区间I_k上进行搜索,其中A>0。然后,我们可以使用其他确定性素性测试,而不是埃拉托斯特尼筛法。例如,假设您可以在O((log n)^B)操作中测试单个数字的素性,其中B>0。 那么您可以在O(k(log n)^{A+B})操作中搜索区间I_k。在这种情况下,最优的K仍然是K\approx n^{1/3},除了对数因子之外,因此总复杂度为O(n^{2/3}(log n)^D,其中D>0


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