【问题标题】:Combinatorics : Grouping Characters Challenges组合学:分组字符挑战
【发布时间】:2010-11-10 18:22:58
【问题描述】:

我在工作中解决了一些分组问题。有很多问题,请多多包涵。我觉得它们很有趣。如果这里有人也对组合学感兴趣,请帮助我。

好的,所以我们有一堆字符,我在这里拍了一张。

  1. 我们可以通过哪些方式对元素进行分组?假设我们有 4 个字符 a i d s。 有效组(保留顺序)是:

    a 我 d s
    a ds
    身份证
    援助
    艾滋病
    身份证
    援助
    帮助

你如何列举所有的组?你能告诉我任何 n 个字母有多少种组合吗?

2 。特殊情况

  • 如果像 Ai sd 和 ai sd 是两个组这样的情况有所不同怎么办?

  • 列举所有案例需要多长时间?找到 4 个字母和 5 个字母大小写的时间差是多少?

  • 如果你把“空格”当作一个字符。枚举完之后,你会写多少个字符?

  • 如果您根据距离定义从一个词到另一个词的转换。 说“ai ds”和“a i ds”有 1 个距离,因为您应该将字母“i”移动一步。 你能在任何单词的任意一边找到 n 距离的单词吗?

编辑:
“艾滋病”是一个词。
“a ids” “aid s” 是与原始单词“aids”相距 1 的两个单词。
“a id s”是一个与原始单词“aids”相距两个距离的单词。
“a i d s”是一个与该词相距三个距离的词。

这个问题似乎是金矿。

奖励:两个单词之间的最小距离是多少。就像“艾滋病”与“一个ID”的距离是一个距离,也是两个距离。是否有一个“中点”词,您可以从该词以最短的距离到达枚举中的任何其他词?从一个词到另一个词有多少条路径?

【问题讨论】:

  • 在1.中,“as id”是一个有效的分组吗? IE。分组是否必须保持顺序?
  • 是的,就我而言,我希望他们能保持秩序。

标签: algorithm string math computer-science combinatorics


【解决方案1】:

计算组合的数量是微不足道的。基本上,每两个字母之间都有一个字符。它可能是“epsilon”(空)或空格。因此,对于 n 个字母,您有 n-1 个这样的分隔符。由于每个字符只能有两个值,这与 n-1 位的二进制数相同。所以你可以有 2 的 n-1 次组合。

aids = 4 characters (n = 4)
n - 1 = 3
2 ** (n-1) = 8 combinations

现在,针对特殊情况。如果每个字符都可以是小写或大写,那就是字符的 n 次方(只要它们是字母)。现在,上面的每个组合都有 2n 个基于大小写的变化,所以最终结果是 (2(n-1)) * (2**n),等于 2 ** (2 *n -1)。

对于每个附加字母,您可以将枚举它们所花费的时间加倍或加倍(取决于大小写),这可以从公式中轻松理解。

字符总数有点困难,但足以注意到每个“空格”有一半时间是“epsilon”。所以我们有 (2 ** (n-1)) * n (字母) + ((2 ** (n-1)) * (n-1)) / 2 (空格)。在示例中:

(2 ** 3) * 4 = 32 characters
(2 ** 3) * 3 / 2 = 12 spaces
Total = 44 characters

最后,距离问题与Levenshtein distance有关。我考虑过使用Burkhard-Keller tree,但我现在发现这根本没有必要,因为问题相当简单。

首先,使一个字符串与另一个字符串相等所需的最少插入/删除/更改次数称为Levenshtein distance。这直接适用于问题:您可以根据需要添加空格、删除空格、更改小写/大写。通常,最好使用Dynamic Programming 来解决这个问题,这通常可以被认为是保存有关小部分问题解决方案的数据,然后在计算其他部分/更大部分时重复使用这些数据。

但鉴于我们问题的特殊限制,我们可以只比较表示空格/epsilon 的“二进制”数字。

假设函数 f(word) 将返回表示该单词中空格的二进制数。例如,它将为“ai ds”返回“010”,为“a i d s”返回“111”。每个组合之间的变化次数通过对 f (010 xor 111 = 101) 的结果进行异或运算得到,然后计算位数等于 1。让我们在 Scala 中为此编写几个函数:

def spacesToBinary(s: String) = {
  (s zip s.drop(1)) // transform "ai ds" into ((a,i), (i, ), ( ,d), (d, s))
  .foldLeft(0) { (binary, pair) => pair match {
      case (' ', _) => binary            // A space is always followed by a letter,
                                         // so we ignore it
      case (_, ' ') => (binary << 1) | 1 // If a letter is followed by a space, bit = 1
      case _ => binary << 1              // If not, bit = 0
  }}
}

def numberOfOnes(d: Int) = {
  var bits = 0
  var n = d
  while(n != 0) {
    if ((n & 1) == 1)
      bits += 1
    n >>= 1
  }
  bits
} 

def spaceDistance(s1: String, s2: String) = 
  numberOfOnes(spacesToBinary(s1) ^ spacesToBinary(s2))

现在,一旦我们丢弃空格,比较小写/大写就很容易了:

def caseDistance(s1: String, s2: String) = {
  val ns1 = s1 filter (_ != ' ')
  val ns2 = s2 filter (_ != ' ')
  (ns1 zip ns2).foldLeft(0){(acc, pair) => acc + (if (pair._1 == pair._2) 0 else 1)}
}

所以,Levenshtein 距离是:

def levenshtein(s1: String, s2: String) = spaceDistance(s1, s2) + caseDistance(s1, s2)

我们还知道关于 Levenshtein 距离的以下属性。假设 d(x,y) 是 x 和 y 之间的 Levenshtein 距离。那么我们就知道了:

d(x, y) = 0 if, and only if, x = y
d(x, y) = d(y, x)
d(x, y) + d(y, z) >= d(x, z)

我称为三角不等式的最后一个标准。简单地说,从 x 到 z 的路径至少与从 x 到 y 的路径加上从 y 到 z 的路径一样小(想象一个具有顶点 x、y 和 z 的三角形)。

好的,让我们考虑一下额外的问题。

两个词之间有多少条路径? 好吧,如果 Levenshtein 距离为 n,这意味着您有“n”个唯一修饰,a1、a2、...、an。对于这些修改的每个不同顺序,您都有一个路径。 “n”个元素的排列数是“n”(或n!)的阶乘:

def factorial(n: Int): Int = n match {
  case 0 => 1
  case _ => n * factorial(n-1)
}

2! = 2
3! = 6
4! = 24
5! = 120
etc

是否存在“中心”组合?实际上,没有。如果我们回过头来将组合视为表示空格/无空格和小写/大写的二进制数对,那么很明显,您可以简单地反转位以产生一个新组合,其与所选组合的距离是最大可能。

或者,换句话说,对于 n 个字母的每个组合,有一个,并且只有一个,对应的组合,所以两个组合之间的 Levenshtein 距离是 2*n - 1,任何两个组合之间的最大可能距离组合。

我发现我忘了计算到 s 的(最小)距离为 n 的所有组合。好吧,我们知道每个组合都可以表示为两个二进制数,分别表示空格和每个字母的大小写,第一个是 n-1 位长,第二个是 n 位长。

我们还知道,我们可以简单地反转这些“数字”中的任何一个来获得差异。因此,如果我们得到一个 2*n-1 位长的大二进制数,并且我们枚举它的所有位,则与它的最小距离 d 的组合由大小组中 2*n-1 位的组合给出“d”没有重复。对于N=2*n-1,这样的组合数为N!/((N-d)!*d!)。

例如,对于距离 2 和“辅助”,n=4,d=2,N=2*4-1=7,组合数为 7!/(5!*2!) = 7 * 6 / 2 = 21。

我们可以这样组合:

def computeCombinations(n: Int, d: Int): List[List[Int]] = {
  def gen(d: Int, l: List[Int]): List[List[Int]] = d match {
    case 0 => List(List())
    case _ => for(el <- l;
                  sl <- gen(d-1, l.dropWhile(_ != el).tail))
              yield el :: sl
  }

  gen(d, (0 until n).toList)
}

这将返回要更改的字母/空格列表。我们通过要切换的位数来指示哪个字母或空格需要更改。为简化起见,我们假设大写的二进制数和空格/无空格的二进制数连接成一个二进制数。

接下来,我们必须找到一种方法来从这些信息中产生变化。大写/小写很简单,假设我们收到不带空格的单词:

def toggleCharCase(c: Char) = (c ^ ('A' ^ 'a')).toChar
def toggleCases(s: String, bits: List[Int]) = (
  s.zipWithIndex
  map (t => if (Set(bits: _*) contains t._2) (toggleCharCase(t._1), t._2) else t)
  map (_._1)
)

切换空格更加困难。我们将使用上面定义的spacesToBinary,将其转换为设置的位数列表,切换请求的位并返回。之后,我们使用不同的函数在适当的位置插入空格:

def toggleSpaces(s: String, bits: List[Int]): List[Int] = {
  val before = spacesToBinary(s)
  val beforeBits = Set() ++ 
                   (for(i <- 0 to 30; // Assuming 32 bits, 1 for sign
                        if (scala.Math.pow(2, i).toInt & before) != 0)
                    yield i)
  val afterBits = (beforeBits union Set(bits: _*)) diff 
                  (beforeBits intersect Set(bits : _*))
  afterBits.toList
}

def addSpaces(s: String, bits: List[Int]): String = (
  s.filter(_ != ' ').zipWithIndex 
  map (t => t._1.toString + (if (bits contains t._2) " " else ""))
  mkString
)

现在我们必须计算空间差异,删除空间,切换案例,然后添加空间。让我们看看:

def changeCombination(s: String, n: Int, bits: List[Int]) = {
  // Split the binary number with all changes into case changes and space changes
  val spaceBits = bits filter (_ >= n) map (_ - n)
  val caseBits = bits filter (_ < n)

  // Compute the set of spaces after changing them
  val newSpaces = toggleSpaces(s, spaceBits)

  // Remove spaces and toggle case
  val noSpaces = s filter (_ != ' ')
  val caseChanged = toggleCases(noSpaces, caseBits).mkString

  // Now add the spaces as computed before
  val spacesAdded = addSpaces(caseChanged, newSpaces).mkString
  spacesAdded
}

最后,我们计算所有组合,然后为每个组合生成一个修改后的字符串:

def makeCombinations(s: String, d: Int) = {
  val n = (s filter (_ != ' ')).length
  for(combination <- computeCombinations(n*2-1, d))
  yield changeCombination(s, n, combination)
}

这段代码已经在 Scala 2.8 上测试过了(除了我刚刚做的一些重命名)。这是运行的结果:

scala> makeCombinations("ai ds", 2) foreach println
AI ds
Ai Ds
Ai dS
A i ds
Aids
Ai d s
aI Ds
aI dS
a I ds
aIds
aI d s
ai DS
a i Ds
aiDs
ai D s
a i dS
aidS
ai d S
a ids
a i d s
aid s

【讨论】:

  • Daniel 你能解释一下这个问题的 BK 树实现吗?你能评论一下奖金部分吗?
  • 嗯...好吧,让我们看看我能做什么。
  • 我无语了。你是怎么想到的?严重地。我真的很想知道思考的过程。您如何决定“Levenshtein 距离”是否合适?无论如何,丹尼尔,你拥有这个线程。非常感谢。
  • 顺便说一句,我从来没有听过有人说“让我们在 Scala 中为此编写几个函数:”:)
  • 碰巧我知道 B-K 树。您要求更多解释,所以我转到我发布的链接作为参考,并看到 Levenshtein 差异就足够了。
【解决方案2】:

http://www-cs-faculty.stanford.edu/~knuth/fasc3b.ps.gz(如果您看不到 postscript,请下载 Ghostscript/Ghostview)详细讨论分区。

对于长度为 n 的序列,有 2^(n-1) 个分区。在每对连续的项目之间考虑一下。如果设置了该位,则它们将被分隔(如您列出的那样,由空格分隔)。 “aids”(长度为 4)有 2^3 个可能的分区。

回复您的其他问题:

枚举时间:O(n*2^n) - 输出长度不变。不仅项目的数量随着输入长度的增加而增加,而且每个项目中的字符数也会增加。

写入的字符数:我们不计算换行符(如果有,请再添加 2^(n-1) 个字符)。然后你有 n*2^(n-1) 个非空格字符,加上所有唯一 n-1 位字符串中 1 的数量。 k位数的位串,写出来的时候有k*2^k位,其中一半是1。所以字符总数是[n+(n-1)/2]*2^(n-1) ),不计算换行符。在您列出的 8 个“艾滋病”变体列表中,有 32 个非空格字符和 12 个空格 - 分别为 4*2^3 和 (3/2)*2^3。

编辑距离:您必须更准确地了解转换及其成本。通过“单词”,我假设您的意思是单个分区(您的 8 个示例行之一)。如果编辑是删除或添加单个空格,那么您说的是 n-1 位字符串上的汉明距离。

【讨论】:

    【解决方案3】:

    正如其他答案已经说过的那样,第 1 点有 2^(n-1) 种可能性。关于一些特殊情况(第 2 点):

    • 如果案件有所作为,比如 Ai sd 和 ai sd 是两组?

    好吧,在这种情况下,你有 2^n 种不同的案例组合,所以你有 2^n * 2^(n-1) = 2^(2 * n - 1) 种可能性。

    • 如果你把“空格”当作一个字符。枚举完之后,你会写多少个字符?

    这是一个更有趣的问题。您有 1 种可能不放置空格,3 种可能放置 1 个空格,3 种可能放置 2 个空格和 1 种可能放置 3 个空格。如果我没记错的话,这是一个二项分布,并且有计算数字的公式。您也可以为此使用Pascal's triangle

       1
      1 1
     1 2 1
    1 3 3 1 <- your case (4th row)
      ...
    

    得到这些数字后,计算总字符数如下:

    1*4 + 3*5 + 3*6 + 1*7 = 44 
    

    【讨论】:

      【解决方案4】:

      访问距离为 k 或更小的每个单词的简单算法:使用哈希表只访问每个位串一次(或 2^(n-1) 位的数组,但这可能太大了),递归地访问每个相邻的单编辑差异(假设汉明距离:对于 i from 1..(n-1),XOR 2^i 与源位串,切换第 i 个位)。

      对 k 的深度执行此操作(深度与递归一起传递),您将访问距离 k 内的所有编辑。当然,如果你只想要深度为 k 的那些,你会想要使用广度优先搜索:而不是立即访问每个邻居,而是让他们排队等待访问。在访问给定一代 (j) 的项目的队列时(所有项目都具有相同的最佳编辑距离),将未来项目排队在不同的队列中以供下一代 (j+1) 使用。这样,您首先使用尽可能少的编辑次数访问每个字符串(广度优先 = 最佳优先,当每个转换的成本相同时)。

      如果您不想进行广度优先搜索,那么您始终可以计算 k 以内的词集和 k-1 以内的词集,然后取差值(您可以使用两个单独的表)。这实际上是“迭代深化搜索”。

      B-K 树在这里不合适,除非您正在考虑一组非结构化的单词(通用字典)。我们已经确切地知道单个单词的分区结构。

      【讨论】:

      • 你能试试附赠题吗?够清楚吗?我在打字时正在思考这些问题,所以我希望它们是清楚的。
      【解决方案5】:

      计数论点是对的。

      我有一种通用的方法来编写这样的问题,使用分支定界。 Here's an example.

      基本上,与其编写一个循环来扫描字符串,不如编写一个递归函数,并将成本作为其参数之一进行跟踪。然后在每一步,您可以 1) 降低字符串,额外成本为零,然后 2) 对字符串进行一些小的更改,增加成本,然后向前,3) 重复 2尽可能多地考虑不同类型的更改。

      然后有一个总体成本预算,拒绝任何成本超过预算的分支机构。

      最后,作为一个外部循环,以 0 的预算做一次整个事情。如果这不会产生任何匹配,则以 1 的成本再做一次,依此类推,直到获得一个或多个匹配。

      【讨论】: