一个有效确定矩阵中[n][n]元素的算法

12

这是有关一份课程作业的问题,所以我希望您不要完全回答问题,而是给出改进当前算法的运行时间复杂度的提示。

我收到了以下信息:

一个函数 g(n),其中g(n) = f(n,n),其中f可以通过递归定义为

enter image description here

我已经使用以下代码以递归方式实现了此算法:

public static double f(int i, int j) 
{

    if (i == 0 && j == 0) {
        return 0;
    }
    if (i ==0 || j == 0) {
        return 1;
    }

    return ((f(i-1, j)) + (f(i-1, j-1)) + (f(i, j-1)))/3;
}

这个算法可以得到我想要的结果,但效率极低,现在我被要求改进运行时间复杂度。

我编写了一个算法来创建一个n*n矩阵,并计算每个元素直到[n][n]元素,然后返回[n][n]元素,例如f(1,1)将返回0.6循环。 [n][n]元素为0.6循环,因为它是(1+0+1)/3的结果。

我还创建了一个电子表格,显示从f(0,0)到f(7,7)的结果,如下所示:

Results

尽管这比我的递归算法快得多,但创建n*n矩阵的开销很大。

有什么建议可以改进这个算法将不胜感激!

我现在可以看到这个算法可以实现O(n)复杂度,但是否可能在不创建[n][n] 2D数组的情况下计算出结果?

我已经创建了一个使用Java编写的O(n)时间和空间的解决方案,并在提交作业后发布该解决方案,以防止抄袭。


2
是的,您不必创建一个[n][n]数组,因为在计算中您只需要[x-1]和[y-1]值,所以您可以只使用线性数组[n]来存储上一次迭代的值。 - Andrei Nikolaenko
@Marco13 同意我的看法。 - will
@will 那就写一个吧!我已经清楚地描述了方法。 - גלעד ברקן
使用一个 NxN 的矩阵,你无法实现 O(N) - user1196549
这个问题是不完整的(实际上,文本中没有问题!)。您需要计算[0 N]x [0 N]中所有值的f还是其中的一些或仅一个? - user1196549
显示剩余6条评论
6个回答

6
这是另一个需要先审慎分析后编写代码的问题。
我认为你应该首先查看数字的网格,而不是将它们表示为小数,而是改用分数。
显而易见的第一件事是,您所拥有的enter image description here的总数仅是从原点enter image description here的距离度量。
如果您以这种方式查看网格,则可以获得所有分母:

enter image description here

请注意,第一行和第一列并不全为“1”——它们被选中以遵循模式和适用于所有其他正方形的一般公式。
分子有点棘手,但仍然可行。与大多数类似的问题一样,答案与组合、阶乘以及更复杂的事物有关。典型的条目包括卡特兰数斯特林数帕斯卡三角形,你几乎总会看到使用超几何函数
除非你做了很多数学,否则你不太可能熟悉所有这些,而且有大量的文献。因此,我有一个更容易找到所需关系的方法,几乎总是有效的。它是这样的:
  1. 编写一个朴素且低效的算法来获取您想要的序列。
  2. 将相当大量的数字复制到Google中。
  3. 希望整数序列在线百科全书 中出现结果。

    3.b. 如果没有,则查看您的序列中的某些差异或与您的数据相关的其他序列。

  4. 使用找到的信息来实现所述序列。

因此,按照这种逻辑,这里是分子:

enter image description here

很不幸,用谷歌搜索这些内容没有得到任何结果。但是,你可以注意到它们有一些共同点,其中最主要的是第一行/列只是3的幂,并且第二行/列比三的幂小1。这种边界正好与帕斯卡三角形和许多相关序列相同。
以下是分子和分母之间差异的矩阵:

enter image description here

我们决定f(0,0)元素将遵循相同的模式。这些数字看起来已经简单了许多。还要注意一点 - 有趣的是,这些数字遵循与初始数字相同的规则 - 只是第一个数字是1(它们偏移了一列和一行)。T(i,j)=T(i-1,j)+T(i,j-1)+3*T(i-1,j-1)

                     1 
                  1     1
               1     5     1
            1     9     9     1
         1     13    33    13    1
      1     17    73    73    17    1
   1     21    129   245   192   21    1
1     25    201   593   593   201   25    1

这更像是组合数学中经常看到的序列。 如果你用这个矩阵中的数字搜索谷歌,你会得到一个结果。 然后,如果你去掉原始数据的链接,你会得到序列A081578,它被描述为“Pascal-(1,3,1)数组”,这很有意义——如果你旋转矩阵,使0,0元素在顶部,并且元素形成一个三角形,那么你取左侧元素的1*,上方元素的3*和右侧元素的1*
现在的问题是实现用于生成数字的公式。
很不幸,这通常说起来比做起来更容易。例如,页面上给出的公式:
T(n,k)=sum{j=0..n, C(k,j-k)*C(n+k-j,k)*3^(j-k)}
是错误的,需要读一些内容(页面上链接了该论文)才能找到正确的公式。您需要查看命题26和推论28。在命题13之后的表2中提到了该序列。请注意r=4
正确的公式在命题26中给出,但那里也有一个错别字:求和符号中的k=0应为j=0

enter image description here

其中T是包含系数的三角矩阵。

OEIS页面提供了一些计算这些数字的实现,但它们都不是用Java编写的,也不能轻松地转换为Java:

这里有一个Mathematica示例:

Table[ Hypergeometric2F1[-k, k-n, 1, 4], {n, 0, 10}, {k, 0, n}] // Flatten 

一如既往地简明扼要。同时也有一个 Haskell 版本,同样简洁。

a081578 n k = a081578_tabl !! n !! k
a081578_row n = a081578_tabl !! n
a081578_tabl = map fst $ iterate
   (\(us, vs) -> (vs, zipWith (+) (map (* 3) ([0] ++ us ++ [0])) $
                      zipWith (+) ([0] ++ vs) (vs ++ [0]))) ([1], [1, 1])

我知道你是用Java编写的,但我懒得把我的答案转换成Java(抱歉)。这是一个Python实现:

from __future__ import division
import math

#
# Helper functions
#

def cache(function):
  cachedResults = {}
  def wrapper(*args):
    if args in cachedResults:
      return cachedResults[args]
    else:
      result = function(*args)
      cachedResults[args] = result
      return result
  return wrapper


@cache
def fact(n):
 return math.factorial(n)

@cache
def binomial(n,k):
  if n < k: return 0
  return fact(n) / ( fact(k) * fact(n-k) )





def numerator(i,j):
  """
  Naive way to calculate numerator
  """
  if i == j == 0:
    return 0
  elif i == 0 or j == 0:
    return 3**(max(i,j)-1)
  else:
    return numerator(i-1,j) + numerator(i,j-1) + 3*numerator(i-1,j-1)

def denominator(i,j):
  return 3**(i+j-1)



def A081578(n,k):
  """
  http://oeis.org/A081578
  """
  total = 0
  for j in range(n-k+1):
    total += binomial(k, j) * binomial(n-k, j) * 4**(j)
  return int(total)

def diff(i,j):
  """
  Difference between the numerator, and the denominator. 
  Answer will then be 1-diff/denom.
  """
  if i == j == 0:
    return 1/3
  elif i==0 or j==0:
    return 0
  else:
    return A081578(j+i-2,i-1)

def answer(i,j):
  return 1 - diff(i,j) / denominator(i,j)




# And a little bit at the end to demonstrate it works.
N, M = 10,10

for i in range(N):
  row = "%10.5f"*M % tuple([numerator(i,j)/denominator(i,j) for j in range(M)])
  print row

print ""
for i in range(N):
  row = "%10.5f"*M % tuple([answer(i,j) for j in range(M)])
  print row

所以,对于一个闭合形式:

enter image description here

这里的enter image description here只是二项式系数。

以下是结果:

enter image description here

如果您想处理大量数据,那么您需要以不同的方式计算二项式系数,否则整数会溢出。但您的答案应该是浮点数。因为您似乎对大的 f(n) = T(n,n) 感兴趣,所以可以使用 Stirling 近似公式或其他方法。

@גלעדברקן 哎呀,我忘记转换矩阵索引了。等一下。 - will
你需要改变在 nCr 中计算阶乘的方式 - 这些是你总会遇到的问题。如果可以接受近似值,那么有很多关于 n! 的近似方法。获得正确的整数表示将更加困难。 - will
嗯,那么大的数字的阶乘是非常巨大的。 - will
@גלעדברקן,但我看到使用递归的问题是会有很多三的除法-这二进制数学无法准确解决-那时它将只是随机噪声。这给了我一个想法-用3为底数进行计算,然后除以三变成位移,加法也会非常简单,而且整个过程完全可以精确表示。我打算尝试在基数为3中实现它,只使用位移和简单加法。 - will
我正在进行某些工作,但进展缓慢 - 正在寻找某种模式。 - will
显示剩余9条评论

1
这个算法的最小复杂度为Ω(n),因为你只需要将矩阵第一列和第一行的值乘以一些因子,然后将它们相加即可。这些因子来自于递归展开n次。
但是,你需要执行递归展开,它本身的复杂度为O(n^2)。但通过平衡递归展开和计算,你应该能够将复杂度降低到O(n^x),其中1 <= x <= 2。这有点类似于矩阵乘法算法,其中朴素情况的复杂度为O(n^3),但Strassen算法的复杂度为O(n^2.807)
另一个问题是原始公式使用了1/3的因子。由于这不能准确地表示为定点数或ieee 754浮点数,当连续计算递归时误差会增加。因此,递归展开可能会给你带来更高的精度作为一个好的副作用。
例如,当您展开递归sqr(n)次时,复杂度为O((sqr(n))^2+(n/sqr(n))^2)。第一部分是展开的复杂度,第二部分是评估一个新的大小为n/sqr(n)的矩阵。这个新的复杂度实际上可以简化为O(n)

1

首先,有一些需要记住的事情:

这种情况只会发生一次,但你在每个循环中都测试它。

if (x == 0 && y == 0) {
    matrix[x][y] = 0;
}

在进入第一个循环并将x设置为1之前,您应该改为:matrix [0] [0] = 0;。由于您知道x永远不会是0,因此可以删除第二个条件的第一部分x == 0
for(int x = 1; x <= i; x++)
        {
            for(int y = 0; y <= j; y++)
            {             
                if (y == 0) {
                    matrix[x][y] = 1;
                }
                else
                matrix[x][y] = (matrix[x-1][y] + matrix[x-1][y-1] + matrix[x][y-1])/3;
            }
        }

没有必要声明行和列,因为它们只使用一次。double[][] matrix = new double[i+1][j+1];

1

为了描述时间复杂度,我们通常使用大O符号。重要的是要记住它仅描述了给定输入时增长的情况。O(n)是线性时间复杂度,但它并不表明当我们增加输入时时间增长的速度(快或慢)。例如:

n=3 -> 30 seconds
n=4 -> 40 seconds
n=5 -> 50 seconds

这是O(n),我们可以清楚地看到每次增加n都会使时间增加10秒。
n=3 -> 60 seconds
n=4 -> 80 seconds
n=5 -> 100 seconds

即使对于每个n,我们需要两倍的时间,而且每增加一个n,时间会增加20秒,但这也是O(n)。时间复杂度呈线性增长。

因此,如果您的时间复杂度为O(n*n),并且您将执行的操作减少一半,那么您将得到O(0.5*n*n),它等于O(n*n) - 也就是说,您的时间复杂度不会改变。

这是理论,实际上,操作的数量有时会有所不同。因为您有一个n乘以n的网格,您需要填充n*n个单元格,因此您可以实现的最佳时间复杂度是O(n*n),但是您可以进行一些优化:

  • 网格边缘的单元格可以在单独的循环中填充。当前,在大多数情况下,i和j等于0的两个不必要的条件。
  • 您的网格具有对称轴线,您可以利用它计算其中的一半,然后将结果复制到另一半。对于每个i和j,grid[i][j] = grid[j][i]
最终,代码的清晰度和可读性比性能更重要-如果你能阅读和理解代码,你可以更改它,但如果代码太丑陋以至于你无法理解它,你就不能优化它。这就是为什么我只会做第一次优化(它还增加了可读性),但不会做第二次优化-它会使代码更难理解。
一般来说,除非性能真正成为问题,否则不要优化代码。正如William Wulf所说:
“在效率的名义下(并不一定实现),犯下的计算罪行比任何其他单一原因都要多-包括盲目愚蠢。”
编辑:
我认为可能可以用O(1)复杂度实现此函数。虽然当需要填充整个网格时它没有任何好处,但使用O(1)时间复杂度,您可以立即获得任何值而无需拥有整个网格。

Grid

一些观察:
分母等于 3 ^ (i + j - 1) 如果 i = 2 或者 j = 2,则分子比分母少 1
编辑 2:
分子可以用以下函数表示:
public static int n(int i, int j) {
    if (i == 1 || j == 1) {
        return 1;
    } else {
        return 3 * n(i - 1, j - 1) + n(i - 1, j) + n(i, j - 1);
    }
}

非常类似于原问题,但没有除法,所有数字都是整数。

你最后的修改是错误的,你可以减少加法的数量,但这会增加乘法的数量。因此,你至少需要进行 Ω(n) 算术运算。对于每个可能的 n 预先计算因子是不可能的。 - SpaceTrucker
你在末尾的那一部分是解决这类问题的正确方法,我认为。看看问题,找出其中的模式。 - will
另外,如果你需要填满整个网格,用朴素方法实际上更有效率,因为每个计算单元会更简单。但问题特别涉及对角线项。 - will

1
如果问题是如何输出函数在 0<=i<N, 0<=j<N 的所有值,这里有一个时间复杂度为 O(N²),空间复杂度为 O(N) 的解决方案。时间复杂度是最优的。
Use a temporary array T of N numbers and set it to all ones, except for the first element.

Then row by row,

    use a temporary element TT and set it to 1,
    then column by column, assign simultaneously T[I-1], TT = TT, (TT + T[I-1] + T[I])/3.

0
感谢will的(第一个)答案,我有了这个想法:
考虑到任何正解只来自于x和y轴上的1。每次对f的递归调用都将解的每个组件除以3,这意味着我们可以通过组合地求和,计算每个1作为解的组件的方式数量,并将其“距离”(以f的调用次数为负3次幂来衡量)考虑为负3次幂。
JavaScript代码:
function f(n){

  var result = 0;

  for (var d=n; d<2*n; d++){

    var temp = 0;

    for (var NE=0; NE<2*n-d; NE++){

      temp += choose(n,NE);
    }

    result += choose(d - 1,d - n) * temp / Math.pow(3,d);
  }

  return 2 * result;
 }

function choose(n,k){
  if (k == 0 || n == k){
    return 1;
  }
  var product = n;
  for (var i=2; i<=k; i++){
    product *= (n + 1 - i) / i
  }
  return product;
}

输出:

for (var i=1; i<8; i++){
  console.log("F(" + i + "," + i + ") = " + f(i));
}

F(1,1) = 0.6666666666666666
F(2,2) = 0.8148148148148148
F(3,3) = 0.8641975308641975
F(4,4) = 0.8879743941472337
F(5,5) = 0.9024030889600163
F(6,6) = 0.9123609205913732
F(7,7) = 0.9197747256986194

你不能将包含求和的数学问题简化为O(n)。你可以优化其中的某些部分,但这并不会改变其O(X)特性或解决方案。 - will
@will 如果你在回答的结尾提供的公式与我的解法对于 F(n,n) 得出相同的结果,那么这个数学变换的简化版本也可以得到相同的结果。有趣的是,这个公式只将3的一个指数应用了一次。 - גלעד ברקן
我更惊讶于那里的4 ** j因子! - will
我有一个用Java编写的解决方案,其时间复杂度和空间复杂度均为O(n),在提交课程作业后我会发布该解决方案。虽然实际上这个解决方案的时间复杂度是O(n*n),但由于精度问题能够在O(n)内给出相同的结果。 - Thody
@thody 很好,它使用了Will研究中的公式吗? - גלעד ברקן
显示剩余18条评论

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