【问题标题】:Project Euler #15欧拉计划 #15
【发布时间】:2022-03-25 06:07:49
【问题描述】:

昨晚我试图解决challenge #15 from Project Euler

从左上角开始 2×2网格,有6条路线(不含 回溯)到右下角 角落。


(来源:projecteuler.net

有多少条路线通过 20×20 网格?

我觉得这应该不难,所以我写了一个基本的递归函数:

const int gridSize = 20;

// call with progress(0, 0)
static int progress(int x, int y)
{
    int i = 0;

    if (x < gridSize)
        i += progress(x + 1, y);
    if (y < gridSize)
        i += progress(x, y + 1);

    if (x == gridSize && y == gridSize)
        return 1;

    return i;
}

我验证它适用于较小的网格,例如 2×2 或 3×3,然后将其设置为运行 20×20 的网格。想象一下我的惊讶,5 小时后,程序仍在愉快地处理数字,只完成了大约 80%(基于检查它在网格中的当前位置/路线)。

显然我的做法是错误的。你将如何解决这个问题?我认为应该使用方程式而不是像我这样的方法来解决它,但不幸的是,这不是我的强项。

更新

我现在有一个工作版本。基本上,它会缓存在仍然需要遍历 n×m 块之前获得的结果。这是代码以及一些 cmets:

// the size of our grid
static int gridSize = 20;

// the amount of paths available for a "NxM" block, e.g. "2x2" => 4
static Dictionary<string, long> pathsByBlock = new Dictionary<string, long>();

// calculate the surface of the block to the finish line
static long calcsurface(long x, long y)
{
    return (gridSize - x) * (gridSize - y);
}

// call using progress (0, 0)
static long progress(long x, long y)
{
    // first calculate the surface of the block remaining
    long surface = calcsurface(x, y);
    long i = 0;

    // zero surface means only 1 path remains
    // (we either go only right, or only down)
    if (surface == 0)
        return 1;

    // create a textual representation of the remaining
    // block, for use in the dictionary
    string block = (gridSize - x) + "x" + (gridSize - y);

    // if a same block has not been processed before
    if (!pathsByBlock.ContainsKey(block))
    {
        // calculate it in the right direction
        if (x < gridSize)
            i += progress(x + 1, y);
        // and in the down direction
        if (y < gridSize)
            i += progress(x, y + 1);

        // and cache the result!
        pathsByBlock[block] = i;
    }

    // self-explanatory :)
    return pathsByBlock[block];
}

调用它 20 次,对于大小为 1×1 到 20×20 的网格会产生以下输出:

There are 2 paths in a 1 sized grid
0,0110006 seconds

There are 6 paths in a 2 sized grid
0,0030002 seconds

There are 20 paths in a 3 sized grid
0 seconds

There are 70 paths in a 4 sized grid
0 seconds

There are 252 paths in a 5 sized grid
0 seconds

There are 924 paths in a 6 sized grid
0 seconds

There are 3432 paths in a 7 sized grid
0 seconds

There are 12870 paths in a 8 sized grid
0,001 seconds

There are 48620 paths in a 9 sized grid
0,0010001 seconds

There are 184756 paths in a 10 sized grid
0,001 seconds

There are 705432 paths in a 11 sized grid
0 seconds

There are 2704156 paths in a 12 sized grid
0 seconds

There are 10400600 paths in a 13 sized grid
0,001 seconds

There are 40116600 paths in a 14 sized grid
0 seconds

There are 155117520 paths in a 15 sized grid
0 seconds

There are 601080390 paths in a 16 sized grid
0,0010001 seconds

There are 2333606220 paths in a 17 sized grid
0,001 seconds

There are 9075135300 paths in a 18 sized grid
0,001 seconds

There are 35345263800 paths in a 19 sized grid
0,001 seconds

There are 137846528820 paths in a 20 sized grid
0,0010001 seconds

0,0390022 seconds in total

我接受丹本的回答,因为他最能帮助我找到这个解决方案。但也赞成 Tim Goodman 和 Agos :)

奖金更新

在阅读了 Eric Lippert 的答案后,我又看了看,并对其进行了一些重写。基本思想仍然是相同的,但缓存部分已被取出并放入一个单独的函数中,就像在 Eric 的示例中一样。结果是一些看起来更优雅的代码。

// the size of our grid
const int gridSize = 20;

// magic.
static Func<A1, A2, R> Memoize<A1, A2, R>(this Func<A1, A2, R> f)
{
    // Return a function which is f with caching.
    var dictionary = new Dictionary<string, R>();
    return (A1 a1, A2 a2) =>
    {
        R r;
        string key = a1 + "x" + a2;
        if (!dictionary.TryGetValue(key, out r))
        {
            // not in cache yet
            r = f(a1, a2);
            dictionary.Add(key, r);
        }
        return r;
    };
}

// calculate the surface of the block to the finish line
static long calcsurface(long x, long y)
{
    return (gridSize - x) * (gridSize - y);
}

// call using progress (0, 0)
static Func<long, long, long> progress = ((Func<long, long, long>)((long x, long y) =>
{
    // first calculate the surface of the block remaining
    long surface = calcsurface(x, y);
    long i = 0;

    // zero surface means only 1 path remains
    // (we either go only right, or only down)
    if (surface == 0)
        return 1;

    // calculate it in the right direction
    if (x < gridSize)
        i += progress(x + 1, y);
    // and in the down direction
    if (y < gridSize)
        i += progress(x, y + 1);

    // self-explanatory :)
    return i;
})).Memoize();

顺便说一句,我想不出更好的方法来使用这两个参数作为字典的键。我用谷歌搜索了一下,似乎这是一个常见的解决方案。哦,好吧。

【问题讨论】:

  • 如果你愿意,你可以用离散数学而不是编程来解决它。前方剧透。在 2x2 广场上,需要 4 步才能到达终点。所以在 20x20 上需要 40 步。您可以使用 n 选择 r 公式来解决它:n!/r!(n-r)! r = 行数,即 20,n 为 2r,即 40。可以简化为 40!/20!^2。如果有人有兴趣看看这里:en.wikipedia.org/wiki/Binomial_coefficient
  • @RBarryYoung:从给出的示例中,“回溯”似乎意味着从 x 或 y 坐标中减去任何内容。如果不是这个例子,我会认为这只是直接越过你的路径。即,我会假设 x++, y++, x--, y++, x++, x++ 是一个有效的解决方案,但他们不包括这样的解决方案的事实说明了其他情况。
  • @Tim Goodman:所以我也会假设,但我讨厌做出这样的假设。
  • 有多种方法可以为字典使用两个参数。您的方法的问题在于,并非所有类型都“往返”到字符串。大多数类型实际上只是返回它们的类型名称作为它们的字符串表示。两个很好的技术:(1)您可以使用“元组”来保留两个元素,然后将其用作字典键。 (2) 制作一个 Dictionary>。也就是说,外部字典以第一个键为键。它只为键在第二个键上的第一个键返回一个字典。第二种技术往往速度较慢。
  • 缓存路径长度的好主意。

标签: c# math


【解决方案1】:

快速无编程解决方案(基于组合)

我认为“不回溯”意味着我们总是增加 x 或增加 y。

如果是这样,我们知道总共需要 40 步才能到达终点——x 增加 20 步,y 增加 20 步。

唯一的问题是 40 个中的哪一个是 x 的 20 个增加。问题相当于:你可以从 40 个元素中选择 20 个元素有多少种不同的方法。 (元素是:第 1 步、第 2 步等,我们选择的是 x 中增加的元素)。

有一个公式:它是二项式系数,顶部为 40,底部为 20。公式为40!/((20!)(40-20)!),即40!/(20!)^2。这里! 代表阶乘。 (例如,5! = 5*4*3*2*1

取消 20 个中的一个!和 40 的一部分!,这变成:(40*39*38*37*36*35*34*33*32*31*30*29*28*27*26*25*24*23*22*21)/(20*19*18*17*16*15*14*13*12*11*10*9*8*7*6*5*4*3*2*1)。因此问题被简化为简单的算术问题。答案是137,846,528,820

为了比较,请注意(4*3)/(2*1) 给出了他们示例中的答案,6

【讨论】:

  • @danben:伙计,如果我能在 10 分钟内用铅笔和纸解决这个问题,我不会在答案的顶部加粗。这将是答案中唯一的文字,并用十英尺高的火字写成:)
  • 没关系,我不会停止编码,直到我的应用程序输出正确的数字。
  • 顺便说一句,我希望任何阅读本文的人都会花时间确保他们理解解决方案。只是复制粘贴别人的答案而不知道它来自哪里只会欺骗自己。
  • 对此的解释也可以在 AoCP Fascicle 3, 7.2.1.3 中找到。它本质上是说,从 2R、0 和 1 的列表中,我们需要 R 1。如果你认为 1 代表“DOWN”,0 代表“RIGHT”,那么很容易看出这种方法。
  • 为什么不链接到空白图片,并以替代文字作为答案?
【解决方案2】:

如果您使用dynamic programming(存储子问题的结果而不是重新计算它们),这可以更快地完成。动态规划可以应用于表现出最优子结构的问题——这意味着可以从子问题的最优解构建最优解(信用Wikipedia)。

我宁愿不给出答案,但考虑到右下角的路径数可能与相邻方格的路径数相关。

另外 - 如果你要手工解决这个问题,你会怎么做?

【讨论】:

  • 因引起人们对一种非常有用的编程策略的关注而被投票赞成。尽管对于这个问题,我认为“考虑数学,然后将40!/20!/20! 打入谷歌”是更快的方法,但在一个人的思维工具包中同时使用组合方法和动态编程方法绝对是一件好事。
  • @Tim Goodman:当您可以简单地输入 40 选择 20 时,为什么还要在 Google 中输入所有这些内容? ;)
  • @Aistina:很酷,我不知道 Google 能理解这种语法
  • 感谢维基百科的链接,很高兴能围绕我过去在实践中学到的东西学习一些正式的理论......
【解决方案3】:

正如其他人所指出的,这个特定问题有一个离散数学解决方案。但是假设你确实想递归地解决它。您的性能问题是您一遍又一遍地解决相同的问题。

让我向您展示一个高阶编程技巧,它将带来巨大的收益。让我们来看一个更简单的递归问题:

long Fib(n) 
{
    if (n < 2) return 1;
    return Fib(n-1) + Fib(n-2);
}

您要求它计算 Fib(5)。计算 Fib(4) 和 Fib(3)。计算 Fib(4) 计算 Fib(3) 和 Fib(2)。计算 Fib(3) 计算 Fib(2) 和 Fib(1)。计算 Fib(2) 计算 Fib(1) 和 Fib(0)。现在我们返回并再次计算 Fib(2)。然后我们返回并再次计算 Fib(3)。大量的重新计算。

假设我们缓存了计算的结果。然后第二次请求计算时,我们只返回缓存的结果。现在来了高阶技巧。我想将“缓存函数结果”的概念表示为一个函数,它接收一个函数,并返回一个具有这个好属性的函数。我会把它写成函数的扩展方法:

static Func<A, R> Memoize(this Func<A, R> f)
{
    // Return a function which is f with caching.
    var dictionary = new Dictionary<A, R>();
    return (A a)=>
    {
        R r;
        if(!dictionary.TryGetValue(a, out r))
        { // cache miss
            r = f(a);
            dictionary.Add(a, r);
        }
        return r;
    };
}

现在我们对 Fib 进行一些小的重写:

Func<long, long> Fib = null;
Fib = (long n) => 
{
    if (n < 2) return 1;
    return Fib(n-1) + Fib(n-2);
};

好的,我们有了非记忆函数。现在,魔术:

Fib = Fib.Memoize();

然后,当我们调用 Fib(5) 时,现在我们进行字典查找。 5 不在字典中,所以我们调用原始函数。这会调用 Fib(4),它会执行另一个字典查找并丢失。这调用了 Fib(3),依此类推。当我们回到 second 次调用 Fib(2) 和 Fib(3) 时,结果已经在字典中,所以我们不会重新计算它们。

编写两个参数的版本:

static Func<A1, A2, R> Memoize(this Func<A1, A2, R>) { ... }

难度不大,留作练习。如果你这样做了,那么你可以把你原来的美丽递归逻辑,简单地重写成一个 lambda,然后说:

progress = progress.Memoize();

你的性能会突然提高,而不会损失原始算法的可读性。

【讨论】:

  • 喜欢 Memoize 的实现!
  • +1 非常好的解决方案和解释!正如您在我的最终编辑中看到的那样,这或多或少是我经过一些实验后最终自己做的事情,尽管方式不那么优雅。
  • 这个解释得很好!非常感谢@Eric
  • @Eric fib 应该包含 Fib(0) 的案例,对吗? Fib = (long n) =&gt; { if (n &lt; 0) return 0; if (n &lt; 2) return 1; return Fib(n-1) + Fib(n-2); }; 对吗?
【解决方案4】:

虽然动态规划无疑是解决此类问题的正确方法,但此特定实例显示了可以利用的规律性。

您可以将问题视为排列了许多“正确”和“向下”,小心不要多次计算相同的排列。
例如,size 2 问题的解决方案(在问题中的图像中报告)可以这样看:

→→↓↓  
→↓→↓
→↓↓→
↓→→↓
↓→↓→
↓↓→→

所以,对于任意n边的网格,你可以通过combinatorics的方式找到解:

from math import factorial
n = 20
print factorial(2*n)/(factorial(n)*factorial(n))

2n!是排列数的20→+20↓,而两个n!说明 → 和 ↓ 的排列方式相同。

【讨论】:

    【解决方案5】:

    顺便说一句,您可以通过意识到 2x3 与 3x2 具有相同数量的路径来进一步提高性能。您的记忆功能似乎只考虑一个正好是列 x 行的字符串。但是,您可以在记忆中包含 2x3 和 3x2 键的总路径。

    所以当你记住 4x2 等时,它会自动用相同数量的路径填充 2x4。这将大大缩短您的时间,因为您之前已经计算过通过该表面区域的所有路径,那么为什么要再次计算呢?

    【讨论】:

      【解决方案6】:

      尽管动态编程看起来是解决问题的一种有吸引力的方式(并使其成为一项有趣的编码挑战),但对数据结构进行一些创造性思考有助于立即给出答案。

      [剩下的内容本质上是解释为什么蒂姆古德曼的答案是最好的,因为“最好”的某些价值]
      如果我们有一个 nXm 网格,我们可以将每个有效的角到角路线表示为一个 n+m 位串,使用 0 或 1 来表示“向下”。再多思考一下,路线的确切数量是从 N+M 个项目中获取 N 个项目的方法的数量,这恰好是标准的简单组合 M 超过 N。

      因此,对于任何 N+M 矩形,从左上角到右下角的可能路径数为 (n+m)(n+m-1).. .(m+1)/(n * (n-1) * ... 1)。

      最快的程序是不需要存储太多、在存储方式上使用很多并且(理想情况下)具有封闭形式答案的程序。

      【讨论】:

        【解决方案7】:

        您实际上是在计算Catalan Numbers,其中有一个使用泰勒级数的封闭公式可用。

        因此,一个计算解决方案的程序可以计算二项式系数,如果您没有 BigInt 类,则很难正确计算...

        【讨论】:

        • 加泰罗尼亚数字出现在你有额外的要求,即路径保持在主对角线之上。
        【解决方案8】:

        您可以将计算时间减半,考虑到一旦将其缩小为正方形,网格就会是对称的。因此,只要您在 X 和 Y 方向上剩余的剩余空间相等,您就可以对增加的 x 行程和增加的 y 行程使用相同的计算。

        话虽如此,我是在 python 中完成的,并且对结果进行了大量缓存以避免重新计算。

        【讨论】:

        • 对称性将您的运行时间缩短了一半。缓存对于将运行时间缩短到合理水平至关重要。
        【解决方案9】:

        解决方案沿网格从 NW 到 SE 的对角线反映出来。所以,你应该只计算网格右上半部分的解决方案,然后反映它们以获得另一半......

        【讨论】:

        • 这将运行时间减少了一半,但仍然可能需要很长时间。
        【解决方案10】:

        我相信一些高中数学在这里会很有用,这个链接解释了所需的组合公式:

        http://mathworld.wolfram.com/Combination.html

        现在,使用它来计算通过方格的路径数,公式变为 2n 选择 n。 作为警告,您需要使用可以容纳相当大数字的数据类型

        【讨论】:

          【解决方案11】:

          这个问题比许多人想象的要简单得多。路径必须是具有 20 个“权利”和 20 个“下降”的序列。不同序列的数量是您可以从可能的 40 个中为(比如说)“权利”选择 20 个位置的方式数。

          【讨论】:

          • 你是对的。但是其他几个答案(和 cmets)也注意到了这一点。你的回答有什么新的东西吗?
          【解决方案12】:

          大家都表示动态规划,缓存结果。 我在某个地方有一个 Ruby 脚本,它以一个非常大的散列结束,所有数据都存储在其中。事实上,就像大多数欧拉项目问题一样,这是一个隐藏的数学“技巧”,并且有一些方法可以通过简单的计算得到结果。

          【讨论】:

          • 在这个问题中,动态规划解决方案所需的存储量不大于要解决的网格(实际上只有一半)。你可能做错了。
          • 哈哈,其实我脑筋急转弯。我正在考虑解决一个需要大量斐波那契数的欧拉 pb 并将它们全部存储起来。你是对的 - 这个非常无痛。
          【解决方案13】:

          我的解决方案很简单,但很容易理解:

          到网格上给定交叉点的路线数是到其两个邻居的路线数的总和。

          鉴于上边和左边的每个点只有一条路线,因此很容易遍历剩余点并填补空白。

          对于 x 或 y = 0:grid[x,y] = 1
          对于 x 和 y >=1:grid[x,y] = grid[x-1,y] + grid[x, y -1]

          所以在遍历所有方格后,最终答案包含在 grid[20,20] 中。

          【讨论】:

          • 这是 OP 的原始方法,也是非常缓慢的方法。
          【解决方案14】:

          这可以通过n个选择k个组合来完成。如果你看这个问题,无论你选择从起始单元格到目标单元格的路径,水平和垂直步骤的数量都是一样的。

          例如,取 2*2 的网格,这里水平步长为 2,垂直步长为 2 到达底部。

          使用仿生函数,(a+b) 一个

          a 和 be 是水平和垂直步长。

          static BigInteger getLatticePath(int m, int n) {
          
                  int totalSteps = m + n;
          
                  BigInteger result = Factorial.getFactorial(totalSteps)
                          .divide((Factorial.getFactorial(m).multiply(Factorial.getFactorial(totalSteps - m))));
          
                  return result;
              }
          
          public static BigInteger getFactorial(int n) {
          
                  BigInteger result = BigInteger.ONE;
                  for (int i = 2; i <= n; i++)
                      result = result.multiply(BigInteger.valueOf(i));
                  return result;
              }
          

          引用自https://www.quora.com/How-do-you-count-all-the-paths-from-the-first-element-to-the-last-element-in-a-2d-array-knowing-you-can-only-move-right-or-down

          【讨论】:

            【解决方案15】:

            到网格上给定交叉点的路线数是到它的两个邻居的路线数的总和。

            解析一半节点的非递归方法

            auto LatticeSize=21; //21 vertices  == 20 sides
            vector<vector<long >> node_routes (LatticeSize, vector<long >(LatticeSize, 0)); // row x col initialized to false       
            
            for (auto i=0; i<LatticeSize;i++)
            {
                for(auto j = i; j<LatticeSize; j++)
                {
                    if (j>i)
                    {
                        if(node_routes[i][j-1]==0)
                            node_routes[i][j]=1;
                        else
                         node_routes[i][j]+=node_routes[i][j-1];
                    }
                    if(i -1>=0)
                        node_routes[i][j]+=node_routes[i-1][j];
            
                    if (i==j)
                    node_routes[i][j]*=2;    
                   // cout<< node_routes[i][j]<<" ";    
                }
            //cout<< endl;;
            }
            cout<< "Euler 15: Lattice paths "<< node_routes[LatticeSize-1][LatticeSize-1] << endl;
            

            【讨论】:

              猜你喜欢
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              相关资源
              最近更新 更多