Комбинаторика: группировка вызовов персонажей - PullRequest
8 голосов
/ 14 июля 2009

Я работал над некоторыми групповыми проблемами на моей работе. Есть довольно много вопросов, пожалуйста, потерпите меня. Я нахожу их довольно интересными. Если кто-то здесь также интересуется комбинаторикой, пожалуйста, помогите мне.

Хорошо, у нас есть куча персонажей, вот я и взял.

  1. Как мы можем сгруппировать элементы? Допустим, у нас есть 4 символа. Допустимые группы (сохраняющие порядок) будут:

    а я д с
    а я дс
    Идентификатор s
    ай д с
    ай дс
    идентификаторы
    помощь S
    СПИД

Как вы перечисляете все группы? Не могли бы вы сказать, сколько комбинаций существует для любых n букв?

2. Особые случаи

  • Что, если дело изменило ситуацию, как Ai SD и AID SD две группы?

  • Сколько времени вам потребуется, чтобы перечислить все дела? Какая разница во времени между поиском 4 букв и 5 букв?

  • Если вы возьмете «пробел» как символ. После всего перечисления, сколько символов вы бы написали?

  • Если вы определяете преобразование из слова в другое слово в терминах расстояния. Скажем, «ai ds» и «a i ds» имеют 1 расстояние, потому что вы должны переместить букву «i» на один шаг. Не могли бы вы найти слова на расстоянии n в любую сторону от любого слова?

Edit:
«СПИД» - это одно слово.
«a ids» «aid s» - это два слова, которые на расстоянии 1 от исходного слова «aids».
«id s» - это слово, которое находится на расстоянии двух от исходного слова «aids».
«a i d s» - это слово, которое находится на расстоянии трех слов.

Эта проблема, кажется, золотая жила.

Бонус: Наименьшее расстояние между двумя словами. Например, «СПИД» - это одно расстояние от «ИД», а также два расстояния. Есть ли слово "середина", откуда вы можете достичь любого другого слова в перечислении с наименьшим расстоянием? Сколько существует путей от одного слова к другому?

Ответы [ 5 ]

18 голосов
/ 14 июля 2009

Вычисление количества комбинаций тривиально. По сути, у вас есть символ между каждыми двумя буквами. Это может быть «эпсилон» (пустой) или пробел. Итак, для n букв у вас n-1 таких символов-разделителей. Поскольку каждый символ может иметь только два значения, это то же самое, что двоичное число из n-1 цифр. Таким образом, вы можете иметь 2 в степени n-1 комбинаций.

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

Теперь для особых случаев. Если каждый символ может быть в нижнем или верхнем регистре, то это 2 степени n вариаций для символов (если они являются буквами). Теперь каждая вышеприведенная комбинация имеет 2 n вариаций в зависимости от капитализации, поэтому конечный результат равен (2 (n-1)) * (2 ** n), что равно 2 ** (2 * n -1).

Для каждого дополнительного письма вы удваиваете или удваиваете (в зависимости от заглавных букв) время, необходимое для их перечисления, как легко понять из формулы.

Общее количество символов немного сложнее, но достаточно заметить, что каждый "пробел" равен "эпсилону" наполовину. Таким образом, мы имеем (2 ** (n-1)) * n (буквы) + ((2 ** (n-1)) * (n-1)) / 2 (пробелы). В примере:

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

Наконец, проблема расстояния связана с расстоянием Левенштейна . Я думал об использовании дерева Буркхарда-Келлера , но теперь я вижу, что в этом нет необходимости, поскольку проблема довольно проста.

Во-первых, минимальное количество вставок / удалений / изменений, необходимое для того, чтобы сделать одну строку равной другой, называется расстоянием Левенштейна . Это непосредственно относится к проблеме: вы добавляете пробелы, удаляете пробелы, меняете строчные / прописные буквы по мере необходимости. Обычно это лучше всего решить с помощью Динамического программирования , которое обычно можно рассматривать как хранение данных о решении небольших частей проблемы, которые затем повторно используются для вычисления других частей / больших частей.

Но, учитывая конкретные ограничения нашей задачи, мы можем просто сравнить «двоичные» числа, представляющие пробелы / эпсилон.

Скажем, функция f (word) вернет двоичное число, представляющее пробелы в этом слове. Например, он вернет «010» для «ai ds» и «111» для «a i d s». Число изменений между каждой комбинацией задается путем XOR для результатов 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)}
}

Итак, расстояние Левенштейна:

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

Нам также известны следующие свойства о расстоянии Левенштейна. Скажем, d (x, y) - расстояние Левенштейна между x и y. Тогда мы знаем:

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).

Хорошо, давайте подумаем о бонусных вопросах.

Сколько путей существует между двумя словами? Что ж, если расстояние Левенштейна равно 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 букв существует одна и только одна соответствующая комбинация, так что расстояние Левенштейна между этими двумя комбинациями составляет 2 * n - 1, максимально возможное расстояние между любыми двумя комбинации.

Вижу, я забыл вычислить все комбинации, чье (минимальное) расстояние до s равно n. Итак, мы знаем, что каждая комбинация может быть представлена ​​в виде двух двоичных чисел, представляющих пробелы и заглавные буквы каждой буквы, первая из которых имеет длину n-1 цифр, а вторая длиной n цифр.

Мы также знаем, что мы можем просто инвертировать любую из этих «цифр», чтобы получить разницу. Таким образом, если мы получаем одно большое двоичное число длиной 2 * n - 1 цифра и перечисляем все его цифры, комбинации на минимальном расстоянии d от него задаются комбинацией 2 * n-1 цифр в группах размера «д» без повторений. Для 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)
)

Пространство переключения сложнее. Мы будем использовать определенные выше пробелы, преобразуем их в список установленных битовых чисел, переключаем запрошенные биты и возвращаем их. После этого мы используем другую функцию для вставки пробелов в нужных местах:

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
2 голосов
/ 14 июля 2009

Как уже говорилось в других ответах, для пункта 1 существует 2 ^ (n-1) возможностей. О некоторых особых случаях (пункт 2):

  • Что, если в этом случае разница, как Ai SD и AID SD две группы?

Ну, в этом случае у вас было 2 ^ n различных комбинаций, так что у вас было 2 ^ n * 2 ^ (n-1) = 2 ^ (2 * n - 1) возможностей вообще.

  • Если вы возьмете «пробел» как символ. После всего перечисления, сколько символов вы бы написали?

Это более интересный вопрос. У вас есть 1 возможность для размещения без пробелов, 3 возможности для размещения 1 пробела, 3 возможности для размещения 2 пробелов и 1 возможность для размещения 3 пробелов. Это биномиальное распределение, если я правильно помню, и есть формулы для вычисления чисел. Вы также можете использовать Треугольник Паскаля для этого:

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

После того, как вы получили эти числа, рассчитайте общее количество символов следующим образом:

1*4 + 3*5 + 3*6 + 1*7 = 44 
1 голос
/ 14 июля 2009

Простой алгоритм для посещения каждого слова на расстоянии k или меньше: использование хеш-таблицы для посещения каждой цепочки битов только один раз (или массив из 2 ^ (n-1) битов, но это может быть слишком большим), рекурсивно посещать каждую из смежных разниц с одним редактированием (при условии, что расстояние Хэмминга: для i от 1 .. (n-1), XOR 2 ^ i с исходной цепочкой битов, переключая i-й бит).

Сделайте это на глубине k (глубина передается вместе с вашей рекурсией), и вы увидите все правки на расстоянии k. Конечно, если вам нужны именно те, которые имеют глубину k, вам нужно использовать поиск в ширину: вместо того, чтобы сразу посещать каждого соседа, держите их в очереди для посещения. При посещении очереди для элементов данного поколения (j) (все с одинаковым наилучшим расстоянием редактирования) ставьте в очередь будущие элементы в другой очереди для следующего поколения (j + 1). Таким образом, вы сначала просматриваете каждую строку, используя наименьшее возможное количество правок (ширина сначала = лучшая сначала, когда каждый переход имеет одинаковую стоимость).

Если вы не хотите выполнять поиск в ширину, то вы всегда можете вычислить набор слов в пределах k или меньше, а набор в пределах k-1 или меньше и взять разницу (вы бы использовали два отдельных таблицы). Это фактически «итеративный углубленный поиск».

B-K деревья здесь не подходят, если вы не рассматриваете неструктурированный набор слов (общий словарь). Мы точно знаем структуру разделов для одного слова.

1 голос
/ 14 июля 2009

http://www -cs-faculty.stanford.edu / ~ knuth /asc3b.ps.gz (загрузите Ghostscript / Ghostview, если вы не можете просматривать postscript), подробно обсуждаются разделы.

Для последовательности длины n существует 2 ^ (n-1) разбиения. Подумайте немного между каждой парой последовательных предметов. Если бит установлен, то они разделены (пробелом, как вы их перечислили). «СПИД» (длина 4) имеет 2 ^ 3 возможных разделов.

В ответ на другие ваши вопросы:

Время перечисления: O (n * 2 ^ n) - константа в длине вывода. Количество элементов не только увеличивается с увеличением длины ввода, но также увеличивается количество символов в каждом элементе.

Количество написанных символов: давайте не будем считать новые строки (если вы добавите еще 2 ^ (n-1) символов). Затем у вас есть n * 2 ^ (n-1) непробельных символов плюс число 1 во всех уникальных n-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-разрядных битовых строках.

0 голосов
/ 14 июля 2009

Аргументы подсчета верны.

Существует общий способ программирования таких проблем с использованием ветвлений и границ. Вот пример.

По сути, вместо того, чтобы писать цикл для сканирования строки, вы пишете рекурсивную функцию и отслеживаете стоимость в качестве одного из ее аргументов. Затем на каждом шаге вы можете: 1) шагнуть вниз по строке за дополнительную нулевую стоимость, затем 2) внести небольшое изменение в строку, добавить приращение к стоимости, а затем сделать шаг вперед и 3) повторить 2 для столько разных видов изменений, которые вы хотите рассмотреть.

Затем составьте общий бюджет затрат и откажитесь от участия в любом филиале, где стоимость будет превышать бюджет.

Наконец, в качестве внешнего цикла выполните все это один раз с бюджетом 0. Если это не приведет к каким-либо совпадениям, повторите это со стоимостью 1 и т. Д., Пока не получите одно или несколько совпадений.

...