Как построить словарную доску Плинко из словаря лучше, чем грубая сила?

Рассмотрим следующее расположение букв:

    B
   O A
  N R I
 D E N T

Начните с верхней буквы и выберите одну из двух букв ниже, в стиле Плинко, пока не дойдете до нижней. Независимо от того, какой путь вы выберете, вы создаете четырехбуквенное слово: BOND, BONE, BORE, BORN, BARE, BARN, BAIN или BAIT. Тот факт, что DENT читает через нижнюю часть, является просто хорошим совпадением.

Я хотел бы помочь выяснить алгоритм, который может спроектировать такой макет, где каждый возможный путь сверху вниз генерирует отдельное слово из (предоставленного) словаря. Входными данными для программы будут начальная буква (B, в этом примере) и длина слова n (4, в этом примере). Он будет возвращать либо буквы, которые составляют такой макет, либо сообщение о том, что это невозможно. Он не должен быть детерминированным, поэтому он может генерировать разные макеты с одним и тем же вводом.

До сих пор я не придумал ничего лучше, чем грубая сила. То есть для всех 26^[(n+2)(n-1)/2] способов выбора букв для нижней части макета, чтобы проверить, дают ли все возможные 2^(n-1) пути слова, которые в словаре. Я рассмотрел какое-то префиксное дерево, но тот факт, что пути могут пересекаться и обмениваться буквами, перепутал меня. Я чувствую себя наиболее комфортно в Python, но, как минимум, мне просто нравится идея алгоритма или подхода, который бы работал для этой проблемы. Благодарю.

Ответ 1

Я нахожу это довольно интересной проблемой.

Первая попытка была случайным решателем; другими словами, он просто заполняет треугольник буквами и подсчитывает, сколько "ошибок" присутствует (слов нет в словаре). Затем выполняется восхождение на холм путем случайного изменения одной или нескольких букв и проверки улучшения ошибки; если ошибка остается той же самой, изменения все еще принимаются (таким образом, делая случайную прогулку по областям плато).

Удивительно, но это может решить в разумные сроки неочевидные проблемы, такие как 5-буквенные слова, начинающиеся с 'b':

    b
   a u
  l n r
 l d g s
o y s a e

Затем я попробовал подход полного поиска, чтобы ответить и на часть "без решения", и идея состояла в том, чтобы написать рекурсивный поиск:

Первый шаг

Просто запишите все приемлемые слова на левой стороне; например

    b
   a ?
  l ? ?
 l ? ? ?
o ? ? ? ?

и вызывать рекурсивно, пока мы не найдем приемлемое решение или не сможем

Шаг 2

Запишите все допустимые слова на правой стороне, если вторая буква больше, чем вторая буква первого слова, например

    b
   a u
  l ? r
 l ? ? k
o ? ? ? e

Это сделано для того, чтобы избежать поиска симметричных решений (для любого данного решения другое можно получить простым зеркальным отображением по оси X)

Другие шаги

В общем случае первый знак вопроса заменяется на все буквы в алфавите, если для всех слов, использующих выбранный знак вопроса, либо

  1. слово не имеет вопросительных знаков и находится в словаре, или
  2. в словаре есть слова, которые совместимы (все символы, кроме вопросительных знаков, совпадают)

Если не найдено решение для конкретного выбранного вопросительного знака, то нет смысла продолжать поиск, поэтому возвращается False. Возможно, использование некоторой эвристики для выбора вопроса о том, какой знак вопроса будет заполнен первым, ускорит поиск, я не исследовал эту возможность.

Для случая 2 (поиск, если есть совместимые слова) я создаю 26*(N-1) наборов слов, которые имеют определенный символ в определенной позиции (позиция 1 не рассматривается), и я использую пересечение множеств на всех символы без знака вопроса.

При таком подходе за 30 секунд (PyPy) можно определить, что нет решения для 5-буквенных слов, начинающихся с w (в словаре 468 слов с этой начальной буквой).

Код для этой реализации можно увидеть на

https://gist.github.com/6502/26552858e93ce4d4ec3a8ef46100df79

(программа ожидает файл с именем words_alpha.txt содержащий все допустимые слова, а затем должен вызываться с указанием начальной буквы и размера; в качестве словаря я использовал файл с https://github.com/dwyl/english-words)

Ответ 2

Притворись VWXYZ внизу, здесь на самом деле полные слова.

    B
   A O
  I R N
 T N E D
V W X Y Z

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

Вставьте все слова размером n, начинающиеся с той же буквы, в простом дереве, как показано ниже. Теперь выполните поиск в глубину, утверждая следующее: каждому последующему уровню требуется одна дополнительная "общая" буква, что означает p(letter) экземпляры этого уровня на этом уровне, с дополнительным требованием, чтобы их два потомка были одинаковыми буквами (например, два R в скобках на уровне 2 могут быть "общими" буквами, потому что их дети одинаковы).

Что такое p(letter)? Треугольник Паскаля конечно! n choose r - это ровно количество экземпляров буквы, необходимых на соответствующем уровне этого простого дерева, согласно правлению Плинко. На уровне 3, если мы выбрали R и R, нам понадобятся 3 N и 3 E чтобы выразить "общие" буквы на этом уровне. И каждый из 3 N должен иметь одинаковые дочерние буквы (в данном случае W, X), и каждый из 3 E также должен (X, Y).

                     B
            /                 \
          A                     O
      /       \             /       \   
     I        (R)         (R)        N
    / \       / \         / \       / \
   T  (N)   (N)  E      (N)  E     E   D
  V W W X   W X X Y     W X X Y   X Y Y Z

4 W's, 6 X's, 4 Y 

ОБНОВИТЬ

Из любопытства, вот код Python :)

from itertools import combinations
from copy import deepcopy

# assumes words all start
# with the same letter and
# are of the same length
def insert(word, i, tree):
  if i == len(word):
    return
  if word[i] in tree:
    insert(word, i + 1, tree[word[i]])
  else:
    tree[word[i]] = {}
    insert(word, i + 1, tree[word[i]])

# Pascal triangle
def get_next_needed(needed):
  next_needed = [[1, None, 0]] + [None] * (len(needed) - 1) + [[1, None, 0]]

  for i, _ in enumerate(needed):
    if i == len(needed) - 1:
      next_needed[i + 1] = [1, None, 0]
    else:
      next_needed[i + 1] = [needed[i][0] + needed[i+1][0], None, 0]
  return next_needed

def get_candidates(next_needed, chosen, parents):
  global log
  if log:
    print "get_candidates: parents: %s" % parents
  # For each chosen node we need two children.
  # The corners have only one shared node, while
  # the others in each group are identical AND
  # must have all have a pair of children identical
  # to the others' in the group. Additionally, the
  # share sequence matches at the ends of each group.
  #    I       (R)     (R)      N
  #   / \      / \     / \     / \
  #  T  (N)  (N)  E  (N)  E   E   D

  # Iterate over the parents, choosing
  # two nodes for each one
  def g(cs, s, seq, i, h):
    if log:
      print "cs, seq, s, i, h: %s, %s, %s, %s, %s" % (cs, s, seq, i, h)

    # Base case, we've achieved a candidate sequence
    if i == len(parents):
      return [(cs, s, seq)]
    # The left character in the corner is
    # arbitrary; the next one, shared.
    # Left corner:
    if i == 0:
      candidates = []
      for (l, r) in combinations(chosen[0].keys(), 2):
        _cs = deepcopy(cs)
        _cs[0] = [1, l, 1]
        _cs[1][1] = r
        _cs[1][2] = 1
        _s = s[:]
        _s.extend([chosen[0][l], chosen[0][r]])
        _h = deepcopy(h)
        # save the indexes in cs of the
        # nodes chosen for the parent 
        _h[parents[1]] = [1, 2]
        candidates.extend(g(_cs, _s, l+r, 1, _h))
        _cs = deepcopy(cs)
        _cs[0] = [1, r, 1]
        _cs[1][1] = l
        _cs[1][2] = 1
        _s = s[:]
        _s.extend([chosen[0][r], chosen[0][l]])
        _h = deepcopy(h)
        # save the indexes in cs of the
        # nodes chosen for the parent
        _h[parents[1]] = [1, 2]
        candidates.extend(g(_cs, _s, r+l, 1, _h))
      if log:
        print "returning candidates: %s" % candidates
      return candidates
    # The right character is arbitrary but the
    # character before it must match the previous one.
    if i == len(parents)-1:
      l = cs[len(cs)-2][1]
      if log:
        print "rightmost_char: %s" % l
      if len(chosen[i]) < 2 or (not l in chosen[i]):
        if log:
          print "match not found: len(chosen[i]) < 2 or (not l in chosen[i])"
        return []
      else:
        result = []
        for r in [x for x in chosen[i].keys() if x != l]:
          _cs = deepcopy(cs)
          _cs[len(cs)-2][2] = _cs[len(cs)-2][2] + 1
          _cs[len(cs)-1] = [1, r, 1]
          _s = s[:] + [chosen[i][l], chosen[i][r]]
          result.append((_cs, _s, seq + l + r))
        return result

    parent = parents[i]
    if log:
      print "get_candidates: g: parent, i: %s, %s" % (parent, i)
    _h = deepcopy(h)
    if not parent in _h:
      prev = _h[parents[i-1]]
      _h[parent] = [prev[0] + 1, prev[1] + 1]
    # parent left and right children
    pl, pr = _h[parent]
    if log:
      print "pl, pr: %s, %s" % (pl, pr)
    l = cs[pl][1]
    if log:
      print "rightmost_char: %s" % l
    if len(chosen[i]) < 2 or (not l in chosen[i]):
      if log:
        print "match not found: len(chosen[i]) < 2 or (not l in chosen[i])"
      return []
    else:
      # "Base case," parent nodes have been filled
      # so this is a duplicate character on the same
      # row, which needs a new assignment
      if cs[pl][0] == cs[pl][2] and cs[pr][0] == cs[pr][2]:
        if log:
          print "TODO"
        return []
      # Case 2, right child is not assigned
      if not cs[pr][1]:
        candidates = []
        for r in [x for x in chosen[i].keys() if x != l]:
          _cs = deepcopy(cs)
          _cs[pl][2] += 1
          _cs[pr][1] = r
          _cs[pr][2] = 1
          _s = s[:]
          _s.extend([chosen[i][l], chosen[i][r]])
          # save the indexes in cs of the
          # nodes chosen for the parent
          candidates.extend(g(_cs, _s, seq+l+r, i+1, _h))
        return candidates
      # Case 3, right child is already assigned
      elif cs[pr][1]:
        r = cs[pr][1]
        if not r in chosen[i]:
          if log:
            print "match not found: r ('%s') not in chosen[i]" % r
          return []
        else:
          _cs = deepcopy(cs)
          _cs[pl][2] += 1
          _cs[pr][2] += 1
          _s = s[:]
          _s.extend([chosen[i][l], chosen[i][r]])
          # save the indexes in cs of the
          # nodes chosen for the parent
          return g(_cs, _s, seq+l+r, i+1, _h)
    # Otherwise, fail 
    return []

  return g(next_needed, [], "", 0, {})

def f(words, n):
  global log
  tree = {}
  for w in words:
    insert(w, 0, tree)

  stack = []
  root = tree[words[0][0]]
  head = words[0][0]
  for (l, r) in combinations(root.keys(), 2):
    # (shared-chars-needed, chosen-nodes, board)
    stack.append(([[1, None, 0],[1, None, 0]], [root[l], root[r]], [head, l + r], [head, l + r]))

  while stack:
    needed, chosen, seqs, board = stack.pop()
    if log:
      print "chosen: %s" % chosen
      print "board: %s" % board
    # Return early for demonstration
    if len(board) == n:
      # [y for x in chosen for y in x[1]]
      return board

    next_needed = get_next_needed(needed)
    candidates = get_candidates(next_needed, chosen, seqs[-1])
    for cs, s, seq in candidates:
      if log:
        print "  cs: %s" % cs
        print "  s: %s" % s
        print "  seq: %s" % seq
      _board = board[:]
      _board.append("".join([x[1] for x in cs]))
      _seqs = seqs[:]
      _seqs.append(seq)
      stack.append((cs, s, _seqs, _board))

"""
    B
   A O
  I R N
 T N E D
Z Y X W V
"""
words = [
  "BONDV",
  "BONDW",
  "BONEW",
  "BONEX",
  "BOREW",
  "BOREX",
  "BAREW",
  "BAREX",
  "BORNX",
  "BORNY",
  "BARNX",
  "BARNY",
  "BAINX",
  "BAINY",
  "BAITY",
  "BAITZ"]
N = 5
log = True

import time
start_time = time.time()
solution = f(list(words), N)
print ""
print ""
print("--- %s seconds ---" % (time.time() - start_time))
print "solution: %s" % solution
print ""
if solution:
  for i, row in enumerate(solution):
    print " " * (N - 1 - i) + " ".join(row)
  print ""
print "words: %s" % words