生成所有 multiset size-n 分区的算法

Algorithm to generate all multiset size-n partitions

我一直在尝试找出一种方法来生成多重集的所有不同的大小为 n 的分区,但到目前为止我还是空手而归。首先让我展示一下我要实现的目标。

假设我们有一个输入向量 uint32_t:

std::vector<uint32_t> input = {1, 1, 2, 2}

假设我们要创建所有不同的 2 大小分区。只有其中两个,即:

[[1, 1], [2, 2]], [[1, 2], [1, 2]]

请注意,顺序无关紧要,即以下所有都是重复的、不正确的解决方案。

顺便说一句,不是什么家庭作业。我在工作中编码时遇到过这个问题,但现在我想知道如何处理这个问题是出于个人兴趣。与工作相关的问题的参数足够小,生成几千个重复的解决方案并不重要。

当前解决方案(生成重复项)

为了说明我不是在没有尝试提出解决方案的情况下就问问题,让我尝试解释一下我当前的算法(当与多重集一起使用时会生成重复的解决方案)。

它的工作原理如下:状态有一个位集,每个分区块的 n 位设置为 1。位集的长度是 size(input) - n * index_block(),例如如果输入向量有 8 个元素且 n = 2,则第一个分区块使用 8 位位集,其中 2 位设置为 1,下一个分区块使用 6 位位集,其中 2 位设置为 1,等等

通过按顺序迭代每个位集并提取索引等于当前位集中 1 位位置的输入向量的元素,从这些位集创建一个分区。

为了生成下一个分区,我以相反的顺序遍历位集。计算下一个 bitset 排列(使用 Gosper 的 hack 的反向)。如果当前位集中的第一位未设置(即向量索引 0 不是 selected),则该位集将重置为其起始状态。强制始终设置第一位可防止在创建大小为 n 的集合分区时生成重复项(上面显示的第二种重复项)。如果当前位集等于其起始值,则对前一个(更长的)位集重复此步骤。

这对集合非常有用(而且非常快)。但是,当与多重集一起使用时,它会生成重复的解决方案,因为它不知道两个元素在输入向量中出现了不止一次。这是一些示例输出:

std::vector<uint32_t> input = {1, 2, 3, 4};
printAllSolutions(myCurrentAlgo(input, 2));
=> [[2, 1], [4, 3]], [[3, 1], [4, 2]], [[4, 1], [3, 2]]

std::vector<uint32_t> input = {1, 1, 2, 2};
printAllSolutions(myCurrentAlgo(input, 2));
=> [[1, 1], [2, 2]], [[2, 1], [2, 1]], [[2, 1], [2, 1]]

最后一个(重复的)解决方案的生成仅仅是因为算法不知道输入中的重复项,它在两个示例中生成完全相同的内部状态(即 select 的索引)。

想要的解决方案

我想现在我想要得到的结果已经很清楚了。为了完整起见,它看起来有点像下面这样:

std::vector<uint32_t> multiset = {1, 1, 2, 2};
MagicClass myGenerator(multiset, 2);
do {
  std::vector<std::vector<uint32_t> > nextSolution = myGenerator.getCurrent();
  std::cout << nextSolution << std::endl;
} while (myGenerator.calcNext());
=> [[1, 1], [2, 2]]
   [[1, 2], [1, 2]]

即该代码的工作方式有点像 std::next_permutation,通知它已生成所有解决方案并返回到 "first" 解决方案(对于您要使用的 first 的任何定义,可能按字典顺序排列,但不需要成为)。

我发现最接近的相关算法是 Knuth 的计算机编程艺术,第 4 卷第 1 部分,第 7.2.1.5 节(第 430 页)中的算法 M。但是,这会生成所有可能的多重集分区。书中还有一个关于如何修改 Alg 的练习(7.2.1.5.69,第 778 页的解决方案)。 M 以便仅生成具有最多 r 个分区的解决方案。但是,这仍然允许不同大小的分区(例如 [[1, 2, 2], [1]] 将是 r = 2 的有效输出)。

有什么 ideas/tricks/existing 算法可以解决这个问题吗?请注意,解决方案应该是高效的,即跟踪所有先前生成的解决方案,确定当前生成的解决方案是否是排列,如果是,则跳过它,这是不可行的,因为解决方案 space 爆炸的速度更长的输入,更多的重复。

这是我的纸笔算法:

Describe the multiset in item quantities, e.g., {(1,2),(2,2)}

f(multiset,result):
  if the multiset is empty:
    return result
  otherwise:
    call f again with each unique distribution of one element added to result and 
    removed from the multiset state


Example:
{(1,2),(2,2),(3,2)} n = 2

11       -> 11 22    -> 11 22 33
            11 2  2  -> 11 23 23
1  1     -> 12 12    -> 12 12 33
            12 1  2  -> 12 13 23


Example:
{(1,2),(2,2),(3,2)} n = 3

11      -> 112 2   -> 112 233
           11  22  -> 113 223
1   1   -> 122 1   -> 122 133
           12  12  -> 123 123

让我们来解决m69在下面评论的处理潜在重复分布的问题:

{A,B,B,C,C,D,D,D,D}

We've reached {A, , }{B, , }{B, , }, have 2 C's to distribute
and we'd like to avoid `ac  bc  b` generated along with `ac  b   bc`.

Because our generation in the level just above is ordered, the series of identical 
counts will be continuous. When a series of identical counts is encountered, make 
the assignment for the whole block of identical counts (rather than each one), 
and partition that contribution in descending parts; for example,

      | identical |
ac     b      b
ac     bc     b     // descending parts [1,0]

Example of longer block:

      |    identical block     |  descending parts
ac     bcccc  b      b      b    // [4,0,0,0] 
ac     bccc   bc     b      b    // [3,1,0,0]
ac     bcc    bcc    b      b    // [2,2,0,0]
...

逐个分配元素的递归算法可以基于一些简单的规则:

  • 首先对不同的元素进行排序或计数;它们不必按任何特定顺序排列,您只需将相同的元素组合在一起即可。 (此步骤将简化后面的一些步骤,但可以跳过。)
   {A,B,D,C,C,D,B,A,C} -> {A,A,B,B,D,D,C,C,C}  
  • 从一个空的解决方案开始,使用以下规则一个接一个地插入元素:
   { , , } { , , } { , , }  
  • 在插入元素之前,找到重复的块,例如:
   {A, , } { , , } { , , }  
                    ^dup^

   {A, , } {A, , } {A, , }  
            ^dup^   ^dup^
  • 将元素插入每个可用的非重复块space:
   partial solution: {A, , } {A, , } { , , }  
                              ^dup^

   insert element B: {A,B, } {A, , } { , , }  
                     {A, , } {A, , } {B, , }  
  • 如果已经存在相同的元素,则不要将新元素放在它之前:
   partial solution:  {A, , } {B, , } { , , }  
   insert another B:  {A,B, } {B, , } { , , }  <- ILLEGAL  
                      {A, , } {B,B, } { , , }  <- OK
                      {A, , } {B, , } {B, , }  <- OK
  • 插入一个元素时,其中有另外N个相同的元素,确保在当前元素之后留下N个空位:
   partial solution:  {A, , } {A, , } {B,B, }  
   insert first D:    {A,D, } {A, , } {B,B, }  <- OK  
                      {A, , } {A, , } {B,B,D}  <- ILLEGAL (NO SPACE FOR 2ND D)  
  • 可以一次性插入最后一组相同的元素:
   partial solution:  {A,A, } {B,B,D} {D, , }  
   insert C,C,C:      {A,A,C} {B,B,D} {D,C,C}  

所以算法应该是这样的:

// PREPARATION  
Sort or group input.              // {A,B,D,C,C,D,B,A,C} -> {A,A,B,B,D,D,C,C,C}  
Create empty partial solution.    // { , , } { , , } { , , }  
Start recursion with empty partial solution and index at start of input.  

// RECURSION  
Receive partial solution, index, group size and last-used block.  
If group size is zero:  
    Find group size of identical elements in input, starting at index.  
    Set last-used block to first block.  
Find empty places in partial solution, starting at last-used block.  
If index is at last group in input:  
    Fill empty spaces with elements of last group.
    Store complete solution.
    Return from recursion.
Mark duplicate blocks in partial solution.  
For each block in partial solution, starting at last-used block:  
    If current block is not a duplicate, and has empty places,  
    and the places left in current and later blocks is not less than the group size:
        Insert element into copy of partial solution.
        Recurse with copy, index + 1, group size - 1, current block.

我测试了该算法的一个简单 JavaScript 实现,它给出了正确的输出。

这是一个有效的解决方案,它使用 herve 命名空间内的 N2639. The comments should make it pretty self-explanatory. The "herve/combinatorics.hpp" file contains the code listed in N2639 中的 Hervé Brönnimann 提出的 next_combination 函数。它在 C++11/14 中,转换为较旧的标准应该非常简单。

请注意,我只是快速测试了解决方案。此外,几分钟前我从基于 class 的实现中提取了它,因此可能会出现一些额外的错误。快速初始测试似乎可以确认它有效,但可能存在一些极端情况惯于。

#include <cstdint>
#include <iterator>

#include "herve/combinatorics.hpp"

template <typename BidirIter>
bool next_combination_partition (BidirIter const & startIt,
  BidirIter const & endIt, uint32_t const groupSize) {
  // Typedefs
  using tDiff = typename std::iterator_traits<BidirIter>::difference_type;

  // Skip the last partition, because is consists of the remaining elements.
  // Thus if there's 2 groups or less, the start should be at position 0.
  tDiff const totalLength = std::distance(startIt, endIt);
  uint32_t const numTotalGroups = std::max(static_cast<uint32_t>((totalLength - 1) / groupSize + 1), 2u);
  uint32_t curBegin = (numTotalGroups - 2) * groupSize;
  uint32_t const lastGroupBegin = curBegin - 1;
  uint32_t curMid = curBegin + groupSize;
  bool atStart = (totalLength != 0);

  // Iterate over combinations from back of list to front. If a combination ends
  // up at its starting value, update the previous one as well.
  for (; (curMid != 0) && (atStart);
    curMid = curBegin, curBegin -= groupSize) {
    // To prevent duplicates, first element of each combination partition needs
    // to be fixed. So move start iterator to the next element. This is not true
    // for the starting (2nd to last) group though.
    uint32_t const startIndex = std::min(curBegin + 1, lastGroupBegin + 1);
    auto const iterStart = std::next(startIt, startIndex);
    auto const iterMid = std::next(startIt, curMid);
    atStart = !herve::next_combination(iterStart, iterMid, endIt);
  }

  return !atStart;
}

编辑下面是我快速拼凑的测试代码("combopart.hpp"显然是包含上述功能的文件)。

#include "combopart.hpp"

#include <algorithm>
#include <cstdint>
#include <iostream>
#include <iterator>
#include <vector>

int main (int argc, char* argv[]) {
  uint32_t const groupSize = 2;

  std::vector<uint32_t> v;
  v = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
  v = {0, 0, 0, 1, 1, 1, 2, 2, 2, 3};
  v = {1, 1, 2, 2};

  // Make sure contents are sorted
  std::sort(v.begin(), v.end());

  uint64_t count = 0;
  do {
    ++count;

    std::cout << "[ ";
    uint32_t elemCount = 0;
    for (auto it = v.begin(); it != v.end(); ++it) {
      std::cout << *it << " ";
      elemCount++;
      if ((elemCount % groupSize == 0) && (it != std::prev(v.end()))) {
        std::cout << "| ";
      }
    }
    std::cout << "]" << std::endl;
  } while (next_combination_partition(v.begin(), v.end(), groupSize));

  std::cout << std::endl << "# elements: " << v.size() << " - group size: " <<
    groupSize << " - # combination partitions: " << count << std::endl;

  return 0;
}

编辑 2 改进算法。将早期退出分支替换为条件移动(使用 std::max)和将 atStart 布尔值设置为 false 的组合。虽然未经测试,但请注意。

编辑 3 需要额外修改,以免 "fix" 倒数第二个分区中的第一个元素。附加代码应该编译为条件移动,因此不应有与之相关的分支成本。

P.S.: 我知道@Howard Hinnant 生成组合的代码(可在 https://howardhinnant.github.io/combinations.html 获得)比 Hervé Brönnimann 的代码快得多。但是,该代码无法处理输入中的重复项(因为据我所知,它甚至从不取消引用迭代器),这是我的问题明确要求的。另一方面,如果您确定您的输入不会包含重复项,那么它绝对是您想要与我上面的函数一起使用的代码。