计算字谜时如何避免溢出?

Posted

技术标签:

【中文标题】计算字谜时如何避免溢出?【英文标题】:How to avoid overflow when counting anagrams? 【发布时间】:2020-12-06 01:47:41 【问题描述】:

令 N 为字符串的大小。设 A, B, C..., Z 为字符串中每个字母出现的次数。

我需要计算字谜的数量:N!/(A!*B!*C!...*Z!)。

最终的结果是保证适合整数,但原始字符串的长度可以是任意大小。

到目前为止,我唯一的想法是对乘积中的数字进行质因式分解,然后消除分母中也存在的分子因子。

有没有更实用的方法来实现这一点?

【问题讨论】:

使用 bigint 库怎么样? 【参考方案1】:

您可以通过交错乘法和除法来进行计算,而不是先进行所有分子乘法,然后再除以所有除数。交错操作显着减小了中间值的大小,但并不能完全保证中间结果不会大于最终结果。稍加努力,我们可以找到一个乘法和除法的顺序,其中除法总是精确的,没有中间结果超过最终结果。 (如果您不关心解释,请跳至此答案底部的示例代码。)

了解交错乘法和除法的工作原理很有用。为了精确除法,除法之前的中间值必须是除数的精确倍数。在这种情况下,这是真的,因为乘数是一个稳定递增的整数序列。

这是一个简单的交错示例,只有两个字母。我们要计算 (7 C 5),即aaaaabb 的字谜数。 (这也是一个二项式系数,因为它与询问长度为 7 的列表中 5 个位置的集合的数量相同。我们可以通过将as 放在所选的五个位置中,将bs 放在另外两个。)所以天真的计算是:

  1   ×2   ×3   ×4   ×5   ×6   ×7   ÷1   ÷2   ÷3   ÷4   ÷5   ÷1   ÷2
  1    2    6   24  120  720 5040 5040 2520  840  210   42   42   21

最大的中间值是 5040。这不是溢出(除非我们使用 8 位算术),但它比需要的大得多。这是交错选项:

  1   ÷1   ×2   ÷2   ×3   ÷3   ×4   ÷4   ×5   ÷5   ×6   ÷1   ×7   ÷2
  1    1    2    1    3    1    4    1    5    1    6    6   42   21

现在,最大的中间结果是 42,它甚至不会溢出 char。如果我们除以 2,我们将得到相同的结果!首先,而不是从 5 开始!:

  1   ÷1   ×2   ÷2   ×3   ÷1   ×4   ÷2   ×5   ÷3   ×6   ÷4   ×7   ÷5
  1    1    2    1    3    3   12    6   30   10   60   15  105   21

按照这个顺序,有更多的中间值超过了最终结果,但最大的仍然没有接近原始的 5040。

很明显,在上述两种情况下,划分都是准确的,但可能并不那么明显,为什么必须如此。证明(使用归纳法)并不难,但直观的解释并不是很复杂。考虑上面第二个示例中的(最终)除以 5。在这个简单的例子中,之前没有除以因子 5 的除法,而且肯定有之前乘以 5 的倍数,所以除法是精确的也就不足为奇了。

但是假设之前有一次除以 5 的倍数。如果是这样,那么该除法一定早很长时间,因为之前的四次除法是被小于 5 的数字所除。换句话说,在任何时候在我们除以p 的情况下,先前除以p 的倍数之前必须至少有p 连续整数的乘法。其中一个乘法必须是p 的倍数,因为每个p 整数都有一个p 的倍数。由于自那次乘法以来没有除以 p,因此我们可以依赖 p 仍然是累积结果的一部分,因此除以 p 是安全的。

也很容易看出,除法之后的中间结果是单调递增的。这是因为在乘法/除法序列中,乘数必须大于除数;乘数只是一个递增序列,而除数会定期重置为 1。这反过来意味着最大的中间值不能超过最大除数乘以最终结果。因此,如果我们能够以稍宽的整数类型进行中间计算,我们将能够避免溢出。这可能是一个足够好的解决方案,但可能允许最终结果是语言的最大整数类型,在这种情况下,中间计算没有更广泛的类型。我们需要更好的保证。

所以让我们回到解释为什么当我们要除以p 时,我们知道中间值可以被p 整除。关键是在最近的p 乘法中必须有p 的乘法。现在,考虑两种可能性:

    最后一次乘以 p 的倍数。 最后的乘法不是p的倍数。

在情况2中,最后一次乘法之前的中间值已经有p作为因子,所以我们可以先进行除法。在情况 1 中,最后一个乘法本身是 p 的某个倍数,因此我们可以在进行乘法运算之前将乘数除以 p。很容易知道我们正在研究这两种情况中的哪一种,只需将乘数除以除数即可。通过该修改,我们保证没有中间结果大于最终结果,因此如果最终结果可表示,则不会溢出。

这是一个简单的 C 实现。可以进行多种优化,但我尽量保持简单;因为它的执行时间通常以微秒为单位:

long long count_anagrams(int n, int letters[26]) 
  long long count = 1;
  for (int mult = 1, divisor = 1, letter = 0; mult <= n; ++mult, ++divisor) 
    while (divisor > letters[letter]) 
      ++letter;
      divisor = 1;
    
    if (mult % divisor == 0)
      count *= mult / divisor;
    else 
      count /= divisor;
      count *= mult;
    
  
  return count;

一个测试用例,针对一个使用 bignums 的简单 Python 程序进行验证:

$ ./anagrams abcdddddddddddddddddddddddddddeeeeeffffggg
There are 7467095163297369600 anagrams of abcdddddddddddddddddddddddddddeeeeeffffggg

【讨论】:

【参考方案2】:

我找到了一个使用 2 个数组以及每个部分的乘法项的解决方案。使用 GCD 简化术语。这是我的 C++ 代码:(地图有字符及其各自的频率)。

unsigned int fatnk(int n, map<char, int> &k)

   vector<int> numerator, denominator;
   for (int i = 2; i <= n; i++)
     numerator.push_back(i);
   for (auto it : k)
      if (it.second > 1)
         for (int i = 2; i <= it.second; i++)
            denominator.push_back(i);
   for (int i = 0; i < numerator.size(); i++)
      for (int j = 0; numerator[i] > 1 && j < denominator.size(); j++)
      
          if (denominator[j] == 1)
             continue;
          int d = gcd(numerator[i], denominator[j]);
          if (d == 1) 
             continue;
          numerator[i] /= d;
          denominator[j] /= d;
      
  unsigned int ans = 1;
  for (auto it : numerator)
     ans *= it;
  return ans;

【讨论】:

【参考方案3】:

您不需要单独分解数字。只需分解 1..n 范围内所有内容的乘积即可。这是一个O(n log(log(n))) 操作。然后你就可以取消了。

这里是 Python:

def factor_range(n):
    is_prime = [True for i in range(n+1)]
    factorization = 
    
    for p in range(2, n+1):
        if is_prime[p]:
            power = p
            factors = 0
            while power <= n:
                s = power
                while s <= n:
                    factors = factors + 1
                    is_prime[s] = 0
                    s = s + power
                power = power * p
            factorization[p] = factors
    return factorization

(在我的笔记本电脑上,这可以在一秒钟内给出 1000000 的完全分解版本。)

【讨论】:

【参考方案4】:

为 N 的素数创建一个映射,其中键是 int(素数),值是计数。 为 N 做这张地图。

say, N = 10
factors = 2 x 5
map[2] = 1
map[5] = 1

然后遍历 A,B,...Z 之类的计数并找到素因数并从上图减少计数

say A= 5, factors= 5 x1
//just mark 
map[5] = 1-1 = 0

similarly, for B....Z

现在为了答案,从最大的素数开始遍历map,如果值为正则继续乘键,如果值为负则继续除以键。


    tmp = 5 , //largest prime factor
    result = 1
    for(int i=tmp;i>1;i--) 
      if(map[tmp]>0) 
        result = result * tmp * map[tmp];
        else if( map[tmp]<0) 
          result = result / (tmp * map[tmp] * -1);`
       
    
    
    print(result)

【讨论】:

好吧,我的问题是如何在没有素因数的情况下获得结果

以上是关于计算字谜时如何避免溢出?的主要内容,如果未能解决你的问题,请参考以下文章

如何避免内存溢出和频繁的垃圾回收

如何避免内存溢出和频繁的垃圾回收

当 Fortran 生成大型内部临时数组时,如何避免堆栈溢出?

如何避免内存泄漏溢出

如何使用递归函数(C#,D&C)避免非常大的数字的溢出乘法

计算机系统漫游