将n个物体分成k组的方法数,使得后面的每组物体数量不少于之前已经形成的组?

7
示例: n=8,k=4 答案:5
[1,1,1,5],[1,1,2,4],[1,1,3,3],[1,2,2,3],[2,2,2,2]
我考虑应用动态规划来计算将8个物体分成4组的方法数,但是不知道如何跟踪前一组中对象的数量。
DP方法:
for(int j=0;j<=n;j++)
{
    for(int i=1;i<=k;i++)
    {
        if(j<i)
            continue;
        if(j==i)
            dp[j]=1;
        for(int k=1;k<i;k++)
        {
            dp[j]+=dp[k]*dp[j-k];
        }
    }
}

请帮忙提供方法。我在DP方面比较新。
4个回答

7

这些被称为“具有受限制部分数量的划分”(partitions with restricted number of parts)。该递推公式背后的思想是:如果划分的最小部分为1,则我们将1添加到所有将 n-1 分成 k-1 部分的划分中(以确保最小部分为1);如果最小部分不为1,则我们将1添加到每个在将n-k分成 k 部分的所有划分中的 k 部分中(确保每个部分都大于1)。 (证明留作简短且有趣的阅读)。

这是一个直接的记忆化:

function f(n, k, memo={}){
  if (k == 0 && n == 0)
    return 1

  if (n <= 0 || k <= 0)
    return 0

  let key = String([n, k]) // Thanks to comment by user633183

  if (memo.hasOwnProperty(key))
    return memo[key]

  return memo[key] = f(n - k, k, memo) + f(n - 1, k - 1, memo)
}

console.time('time taken')
console.log(f(1000, 10))
console.timeEnd('time taken')

这里是自下而上:

function f(n, k){
  let dp = new Array(n + 1)
  for (let i=0; i<n+1; i++)
    dp[i] = new Array(k + 1).fill(0)
  dp[0][0] = 1
  
  for (let i=1; i<=n; i++)
    for (let j=1; j<=Math.min(i, k); j++)
      dp[i][j] = dp[i - j][j] + dp[i - 1][j - 1]

  return dp[n][k]
}

console.time('time taken')
console.log(f(1000, 10))
console.timeEnd('time taken')


1
你可以通过仅对[n, k]进行一次字符串化来显著减少memo示例中的运行时间 - 即,const key = \${n},${k}`,然后使用memo.hasOwnProperty(key)memo[key]memo[key] = ...。你也可以使用const key = String([n, k])`,但实际上没有必要分配数组。 - Mulan
不错!这个算法比我的干净多了。我仍然更喜欢外部记忆化,但在性能上几乎没有什么区别。 - Scott Sauyet
我创建了一个版本的这个(https://ramdajs.com/repl/?v=0.26.1#?const%20f%20%3D%20%28n%20%2C%20k%29%20%3D%3E%20%0A%20%20n%20%3C%3D%200%20%7C%7C%20k%20%3C%3D%200%0A%20%20%20%20%3F%20%5B%5D%0A%20%20%3A%20n%20%3D%3D%20k%0A%20%20%20%20%3F%20%5BArray%20%28n%29%20.fill%20%281%29%5D%0A%20%20%3A%20%5B%0A%20%20%20%20%20%20...f%20%28n%20-%201%2C%20k%20-%201%29%20.map%20%28xs%20%3D%3E%20%5B1%2C%20...xs%5D%29%2C%0A%20%20%20%20%20%20...f%20%28n%20-%20k%2C%20k%29%20.map%20%28xs%20%3D%3E%20xs%20.map%20%28n%20%3D%3E%20n%20%2B%201%29%29%0A%20%20%20%20%5D%0A%0Af%20%288%2C%204%29%0A)显示原始分区 - Scott Sauyet
dp[i][j] = dp[i - j][j] + dp[i - 1][j - 1] 我无法理解其背后的逻辑。 - aman_41907
@aman_41907中的ij对应于上面递归公式中的nk - גלעד ברקן

1

更新

虽然下面的讨论仍然有用,但 גלעד ברקן的答案提供了一种更好的基础算法,使我们可以跳过我的min参数。(我知道我应该查一下的!) 这种理解可以显著提高下面使用的算法的性能。


将动态规划(DP)视为一种简单的优化技术,可以加速某些递归过程。如果您的递归调用重复(如斐波那契数列),则存储它们的结果可以极大地加快程序的运行速度。但是基本逻辑仍然是递归调用。因此,让我们首先通过递归解决这个问题,看看我们可以在哪里应用DP优化。
即使算法时间是指数级别的,(8,4)只有五个解决方案,因此足够小,可能还是可以处理的。让我们尝试一个简单的递归。起初,让我们实际构建输出而不是计算它,以便进行双重检查,确保我们做得正确。
这个版本是基于这样的想法:我们可以设置列表的第一个数字,将该值作为剩余元素的最小值跟踪,并对剩余位置进行递归。最后,我们使用更高的初始数字再次尝试。因此,除了我们的n和k输入外,我们还需要保留一个min参数,我们从1开始。
以下是一个版本:

const f = (n, k, min = 1) => 
  k < 1 || n < k * min
    ? []
  : k == 1
    ? [[n]]
  : [
      ... f (n - min, k - 1, min) .map (xs => [min, ...xs]), 
      ... f (n, k, min + 1)
    ]

console .log (
  f (8, 4) //~> [[1, 1, 1, 5], [1, 1, 2, 4], [1, 1, 3, 3], [1, 2, 2, 3], [2, 2, 2, 2]]
)

你没有指定语言标签;如果JavaScript ES6语法不清楚,我们可以用另一种方式重写。

既然看起来正确,我们可以写一个更简单的版本来计算结果:

const f = (n, k, min = 1) => 
  k < 1 || n < k * min
    ? 0
  : k == 1
    ? 1
  : f (n - min, k - 1, min) + f (n, k, min + 1)

console .log (
  f (8, 4) //~> 5
)

但如果我们要尝试一个更大的集合,比如f(1000,10)(经过检验,应该是8867456966532531),计算可能需要一些时间。我们的算法可能是指数级的。因此,我们可以采用两种动态规划方法来解决这个问题。最明显的方法是从下往上的方法:

const f = (_n, _k, _min = 1) => {
  const cache = {}
  for (let n = 1; n <= _n; n ++) {
    for (let k = 1; k <= Math.min(_k, n); k++) {
      for (let min = n; min >= 0; min--) {
        cache [n] = cache[n] || {}
        cache [n] [k] = cache [n] [k] || {}
        cache [n] [k] [min] = 
          k < 1 || n < k * min
            ? 0
            : k == 1
               ? 1
               : cache [n - min] [k - 1] [min]  + cache [n] [k] [min + 1]
      }
    }
  }
  return cache [_n] [_k] [_min]
}

console.time('time taken')
console .log (
  f (1000, 10) //~> 886745696653253
)
console.timeEnd('time taken')

这里确定正确的边界很棘手,如果没有其他原因,因为递归基于min值的增加。很可能我们在计算中得到了不需要的东西。
这也是丑陋的代码,失去了原始代码的优雅和可读性,只获得了性能。
我们仍然可以通过记忆化函数来保持优雅,这是一种自上而下的方法。通过使用可重用的memoize函数,我们几乎可以完整地使用我们的递归解决方案:

const memoize = (makeKey, fn) => {
  const cache = {}
  return (...args) => {
    const key = makeKey(...args)
    return cache[key] || (cache[key] = fn(...args))
  }
}

const makeKey = (n, k, min) => `${n}-${k}-${min}`        
        
const f = memoize(makeKey, (n, k, min = 1) => 
  k < 1 || n < k * min
    ? 0
  : k == 1
    ? 1
  : f (n - min, k - 1, min)  + f (n, k, min + 1)
)

console.time('time taken')
console .log (
  f (1000, 10) //~> 886745696653253
)
console.timeEnd('time taken')

memoize 将一个每次调用都计算其结果的函数转换为仅在第一次看到某个特定输入集时计算它们的函数。这个版本需要您提供另一个将参数转换成唯一键的函数。还有其他写法,但它们有点丑陋。在这里,我们只是将 (8, 4, 1) 转换为 "8-4-1",然后在该键下存储结果。没有歧义。下一次使用 (8, 4, 1) 调用时,已经计算出的结果将立即从缓存中返回。

请注意,有一种诱惑尝试...

const f = (...args) => {...}
const g = memoize(createKey, f)

但是,如果函数 f 中的递归调用指向 f 本身,则此方法无法正常工作。如果它们指向 g,则我们已经混淆了实现,f 不再是独立的,因此几乎没有理由这样做。因此,我们将其编写为 memomize(createKey, (...args) => {...})。高级技术 提供替代方案 超出了本文讨论的范围。
决定使用自底向上的DP还是自顶向下的DP是一个复杂的问题。从上面的案例中可以看出,对于给定的输入,自底向上版本运行得更快。额外的函数调用会产生一些递归开销,并且在某些情况下可能会受到递归深度限制的影响。但是,自顶向下的技术有时完全可以通过仅计算所需内容来抵消这一点。自底向上将计算所有较小的输入(对于“较小”的某种定义),以便找到您的值。自顶向下只会计算解决您的问题所必需的那些值。

1 玩笑!我只有在使用动态规划后才发现了价值。


非常全面!我创建的一个小模块,DeepMap,非常适合记忆化复合数据,其中不能使用像${n}-${k}-${min}这样的字符串化。 - Mulan
1
console.time和console.timeEnd也有助于在基准演示中减少噪音 ^^ - Mulan
@user633183:谢谢。已更新为使用 console.time/.timeEnd - Scott Sauyet
@user633183:DeepMap非常不错。在Ramda的前身中,同样的想法有一个简单得多的版本,称为“memoize”的初始版本。但是它主要使用对象而不是映射,因此大多限于字符串和数字键。 - Scott Sauyet
顺便说一下,我的自顶向下和自底向上版本都似乎快得多。 - גלעד ברקן
@גלעדברקן:是的,那是一个更快的算法。做得好。更新了这个答案并指向它。 - Scott Sauyet

0

备忘录解法可以通过添加一些检查进一步改进。如果n和k相等,则答案为1。我们不需要对1000,1000进行递归。如果k为1,无论n是什么,答案都是1。1000,1是1,因此可以节省内存和时间。 更新的代码:由于声望低,无法将其添加为上述解决方案的评论,抱歉。您也可以在这里找到简单的解释: N into K groups recursion

function f(n, k, memo = {}) {
    if (k == 0 && n == 0) return 1;
    if (k == 1 && n != 0) return 1; //when k is 1 no matter what n is
    if (n == k) return 1; // when k and n are equal.

    if (n <= 0 || k <= 0) return 0;

    let key = String([n, k]); // Thanks to comment by user633183

    if (memo.hasOwnProperty(key)) return memo[key];


    return (memo[key] = f(n - k, k, memo) + f(n - 1, k - 1, memo));
}

N into K groups recursion。你可以在这里找到简单的解释。 - Farrukh Taqveem Haider

0

从这个例子中,我假设没有任何一个组是空的。同时也假设n、k的值小于等于1000。

动态规划状态将会是剩余物品剩余组f(remObject,remGroup)将会是把remObject放入remGroup的方式数量,其中没有任何一组的物品数量少于之前形成的组。

我们将考虑两种情况。

如果我们想要把一个物品放在最左边的组中,我们也需要把一个物品放到所有其他的组中。所以我们必须确保remaining Objects >= remaining Groups。在这种情况下,我们将会把f(remObject - remGroup, remGroup)加入到我们的答案中。

如果我们不再想把任何物品放在最左边的组中,我们将会把f(remObject,remGroup - 1)与我们的答案相加。

基本情况将会是当没有任何组需要考虑并且所有的物品都已经被放置。

由于任何组都不能是空的,在调用我们的动态规划之前,我们将会把一个物品放入所有的k个组中。

请查看代码以获取更多细节。

#define mxn 1003
#define i64 long long int
#define mod 1000000007

i64 dp[mxn][mxn];

i64 f(int remObject,int remGroup) {
        if(!remGroup) {
                if(!remObject)
                        return 1;
                return 0;
        }

        if(dp[remObject][remGroup] != -1)
                return dp[remObject][remGroup];

        i64 ans = 0;
        if(remObject >= remGroup)
                ans += f(remObject - remGroup, remGroup);
        ans += f(remObject,remGroup - 1);
        ans %= mod;

         return dp[remObject][remGroup] = ans;
}

int main()
{
        int t,n,k;
        memset(dp,-1,sizeof dp);
        cin >> t;
        while(t--) {
                cin >> n >> k;
                if(n < k)
                        cout << 0 << endl;
                else
                        cout << f(n-k,k) << endl;
        }
        return 0;
}

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