Хороший алгоритм и структура данных для поиска слов с пропущенными буквами? - PullRequest
57 голосов
/ 23 декабря 2009

, поэтому мне нужно написать эффективный алгоритм поиска слов с пропущенными буквами в словаре, и я хочу набор возможных слов.

Например, если у меня есть что-то, я мог бы получить эти, те, темы, там и т. Д.

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

Спасибо!

РЕДАКТИРОВАТЬ: Trie слишком мало места и делает его слишком медленным. Любые другие модификации идей?

ОБНОВЛЕНИЕ: может быть до ДВУХ вопросительных знаков, и когда два вопросительных знака встречаются, они будут появляться последовательно.

В настоящее время я использую 3 хэш-таблицы для точного соответствия, 1 знак вопроса и 2 знака вопроса. Учитывая словарь, я хэширую все возможные слова. Например, если у меня есть слово WORD. Я хэш СЛОВО,? ORD, W? RD, WO? D, WOR ?,? RD, W? D, WO ??. в словарь. Затем я использую список ссылок, чтобы связать коллизии вместе. Так, скажем, hash (W? RD) = hash (STR? NG) = 17. hashtab (17) будет указывать на WORD, а WORD указывает на STRING, потому что это связанный список.

Время поиска в среднем одного слова составляет около 2е-6 с. Я хочу добиться большего успеха, желательно порядка 1e-9.

РЕДАКТИРОВАТЬ: я не смотрел на эту проблему снова, но это заняло 0,5 секунды для вставки записей 3м и 4 секунды для поиска записей 3м.

Спасибо!

Ответы [ 20 ]

66 голосов
/ 23 декабря 2009

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

Решение № 1: Использование Regex

Это рабочий код Ruby для этой проблемы:

def query(str, data)    
  r = Regexp.new("^#{str.gsub("?", ".")}$")
  idx = 0
  begin
    idx = data.index(r, idx)
    if idx
      yield data[idx, str.size]
      idx += str.size + 1
    end
  end while idx
end

start_time = Time.now
query("?r?te", File.read("wordlist.txt")) do |w|
  puts w
end
puts Time.now - start_time

Файл wordlist.txt содержит 45425 слов (можно загрузить здесь ). Вывод программы для запроса ?r?te:

brute
crate
Crete
grate
irate
prate
write
wrote
0.013689

Таким образом, требуется всего 37 миллисекунд, чтобы прочитать весь файл и найти все совпадения в нем. И он очень хорошо масштабируется для всех типов шаблонов запросов, даже если Trie очень медленный:

запрос ????????????????e

counterproductive
indistinguishable
microarchitecture
microprogrammable
0.018681

запрос ?h?a?r?c?l?

theatricals
0.013608

Это выглядит достаточно быстро для меня.

Решение № 2: регулярное выражение с подготовленными данными

Если вы хотите пойти еще быстрее, вы можете разбить список слов на строки, содержащие слова одинаковой длины, и просто найти правильный по длине запроса. Замените последние 5 строк этим кодом:

def query_split(str, data)
  query(str, data[str.length]) do |w|
    yield w
  end
end

# prepare data    
data = Hash.new("")
File.read("wordlist.txt").each_line do |w|
  data[w.length-1] += w
end

# use prepared data for query
start_time = Time.now
query_split("?r?te", data) do |w|
  puts w
end
puts Time.now - start_time

Построение структуры данных теперь занимает около 0,4 секунды, но все запросы выполняются примерно в 10 раз быстрее (в зависимости от количества слов с такой длиной):

  • ?r?te 0,001112 с
  • ?h?a?r?c?l? 0,000852 с
  • ????????????????e 0,000169 сек

Решение № 3: Одна большая хэш-таблица (обновленные требования)

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

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

def create_big_hash(data)
  h = Hash.new do |h,k|
    h[k] = Array.new
  end    
  data.each_line do |l|
    w = l.strip
    # add all words with one ?
    w.length.times do |i|
      q = String.new(w)
      q[i] = "?"
      h[q].push w
    end
    # add all words with two ??
    (w.length-1).times do |i|
      q = String.new(w)      
      q[i, 2] = "??"
      h[q].push w
    end
  end
  h
end

# prepare data    
t = Time.new
h = create_big_hash(File.read("wordlist.txt"))
puts "#{Time.new - t} sec preparing data\n#{h.size} entries in big hash"

# use prepared data for query
t = Time.new
h["?ood"].each do |w|
  puts w
end
puts (Time.new - t)

Выход

4.960255 sec preparing data
616745 entries in big hash
food
good
hood
mood
wood
2.0e-05

Производительность запроса O (1), это просто поиск в хеш-таблице. Время 2.0e-05, вероятно, ниже точности таймера. При выполнении этого 1000 раз, я получаю в среднем 1,958 e-6 секунд на запрос. Чтобы получить его быстрее, я бы переключился на C ++ и использовал Google Sparse Hash , который чрезвычайно экономно использует память и быстро.

Решение № 4: получить действительно серьезные

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

24 голосов
/ 30 декабря 2009

Учитывая текущие ограничения:

  • Там будет до 2 вопросительных знаков
  • Когда есть 2 знака вопроса, они появляются вместе
  • В словаре ~ 100 000 слов, средняя длина слова - 6.

У меня есть два жизнеспособных решения для вас:

Быстрое решение: HASH

Вы можете использовать хеш, ключами которого являются ваши слова с двумя «?», А значения являются списком подходящих слов. Этот хэш будет иметь около 100 000 + 100 000 * 6 + 100 000 * 5 = 1 200 000 записей (если у вас есть 2 знака вопроса, вам просто нужно найти место первого ...). Каждая запись может сохранить список слов или список указателей на существующие слова. Если вы сохраните список указателей, и мы предположим, что в среднем менее 20 слов соответствуют каждому слову с двумя '?', То дополнительная память будет меньше 20 * 1 200 000 = 24 000 000.

Если размер каждого указателя равен 4 байта, то здесь требуется память (24 000 000 + 1 200 000) * 4 байта = 100 800 000 байтов ~ = 96 мегабайт.

Чтобы подвести итог этого решения:

  • Потребление памяти: ~ 96 МБ
  • Время для каждого поиска: вычисление хеш-функции и отслеживание указателя. O (1)

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

Простое, но очень быстрое решение: вариация TRIE

Это решение использует следующее наблюдение:

Если '?' знаки были в конце слова, три было бы отличным решением.

Поиск в дереве будет искать по длине слова, а для последних двух букв обход DFS принесет все окончания. Очень быстрое и очень экономное решение.

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

Вы можете думать о каждом слове, которое у вас есть в словаре, как о слове, оканчивающемся на @ (или любом другом символе, которого нет в вашем словаре). Таким образом, слово «пространство» будет «пространство @». Теперь, если вы поворачиваете каждое из слов со знаком «@», вы получаете следующее:

space@, pace@s, ace@sp, *ce@spa*, e@spac

(без @ как первая буква).

Если вы вставите все эти варианты в TRIE, вы можете легко найти слово, которое вы ищете, по длине слова, «вращая» ваше слово.

* * Пример тысячу сорок-семь: Вы хотите найти все слова, которые соответствуют 's ce' (одно из них - пробел, другое - слайс). Вы строите слово: s ?? ce @ и поворачиваете его так, чтобы? знак в конце. то есть 'ce @ s ??'

Все вариации вращения существуют внутри дерева, а именно 'ce @ spa' (помечено * выше). После того, как начало найдено - вам нужно просмотреть все продолжения соответствующей длины и сохранить их. Затем вам нужно повернуть их снова, чтобы @ была последней буквой, а уолла - у вас есть все слова, которые вы искали!

Чтобы подвести итог этого решения:

  • Потребление памяти: Для каждого слова все его повороты появляются в дереве. В среднем * 6 из объема памяти сохраняется в три. Размер дерева составляет около * 3 (только догадываясь ...) пространства, сэкономленного внутри него. Таким образом, общее пространство, необходимое для этой записи, составляет 6 * 3 * 100 000 = 1 800 000 слов ~ = 6,8 мегабайта.

  • Время для каждого поиска:

    • вращение слова: O (длина слова)
    • ищет начало в дереве: O (длина слова)
    • прохождение всех концовок: O (количество совпадений)
    • вращение окончаний: O (общая длина ответов)

    Подводя итог, это очень очень быстро, и зависит от длины слова * маленькая константа.

Подводя итог ...

Второй вариант имеет большую временную / пространственную сложность и будет лучшим вариантом для вас. Есть несколько проблем со вторым решением (в этом случае вы можете использовать первое решение):

  • Более сложный для реализации. Я не уверен, есть ли языки программирования со встроенными попытками из коробки. Если нет - значит, вам нужно реализовать это самостоятельно ...
  • Не хорошо масштабируется. Если завтра вы решите, что вам нужно, чтобы ваши вопросительные знаки были разбросаны по всему слову и не обязательно были соединены вместе, вам нужно будет тщательно продумать, как подойти ко второму решению. В случае первого решения - это довольно легко обобщить.
22 голосов
/ 23 декабря 2009

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

РЕДАКТИРОВАТЬ : я написал простую реализацию этого в Ruby только сейчас: http://gist.github.com/262667.

16 голосов
/ 24 декабря 2009

Направленный ациклический граф слов был бы идеальной структурой данных для этой проблемы. Он сочетает в себе эффективность дерева (дерево может рассматриваться как частный случай DAWG), но гораздо более экономно. Типичная DAWG будет занимать часть размера, которую займет текстовый файл со словами.

Перечислять слова, которые удовлетворяют определенным условиям, просто и так же, как и в trie - вы должны пройти по графику в глубину.

9 голосов
/ 30 декабря 2009

Второе решение Анны - это вдохновение для этого.

Сначала загрузите все слова в память и разделите словарь на разделы в зависимости от длины слова.

Для каждой длины сделайте n копий массива указателей на слова. Сортируйте каждый массив так, чтобы строки выглядели в порядке при повороте на определенное количество букв . Например, предположим, что первоначальный список из 5 букв состоит из слов: [самолет, яблоко, пробел, поезд, счастливый, стек, хаки]. Тогда ваши пять массивов указателей будут:

rotated by 0 letters: [apple, hacks, happy, plane, space, stack, train]
rotated by 1 letter:  [hacks, happy, plane, space, apple, train, stack]
rotated by 2 letters: [space, stack, train, plane, hacks, apple, happy]
rotated by 3 letters: [space, stack, train, hacks, apple, plane, happy]
rotated by 4 letters: [apple, plane, space, stack, train, hacks, happy]

(Вместо указателей вы можете использовать целые числа, идентифицирующие слова, если это экономит место на вашей платформе.)

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

Если вам нужно найти совпадения для ?? ppy, вам придется повернуть его на 2, чтобы сделать ppy ??. Так что смотрите в массив, который находится в порядке, когда вращается на 2 буквы. Быстрый бинарный поиск обнаруживает, что «счастливый» является единственным соответствием.

Если вам нужно найти совпадения для th g, вам придется повернуть его на 4, чтобы получить gth ??. Итак, посмотрите в массиве 4, где бинарный поиск обнаруживает, что совпадений нет.

Это работает независимо от того, сколько существует вопросительных знаков, если они все появляются вместе.

Требуется пространство в дополнение к самому словарю: для слов длиной N требуется пространство (для N раз количество слов длины N) указателей или целых чисел.

Время на поиск : O (log n), где n - количество слов соответствующей длины.

Реализация на Python:

import bisect

class Matcher:
    def __init__(self, words):
        # Sort the words into bins by length.
        bins = []
        for w in words:
            while len(bins) <= len(w):
                bins.append([])
            bins[len(w)].append(w)

        # Make n copies of each list, sorted by rotations.
        for n in range(len(bins)):
            bins[n] = [sorted(bins[n], key=lambda w: w[i:]+w[:i]) for i in range(n)]
        self.bins = bins

    def find(self, pattern):
        bins = self.bins
        if len(pattern) >= len(bins):
            return []

        # Figure out which array to search.
        r = (pattern.rindex('?') + 1) % len(pattern)
        rpat = (pattern[r:] + pattern[:r]).rstrip('?')
        if '?' in rpat:
            raise ValueError("non-adjacent wildcards in pattern: " + repr(pattern))
        a = bins[len(pattern)][r]

        # Binary-search the array.
        class RotatedArray:
            def __len__(self):
                return len(a)
            def __getitem__(self, i):
                word = a[i]
                return word[r:] + word[:r]
        ra = RotatedArray()
        start = bisect.bisect(ra, rpat)
        stop = bisect.bisect(ra, rpat[:-1] + chr(ord(rpat[-1]) + 1))

        # Return the matches.
        return a[start:stop]

words = open('/usr/share/dict/words', 'r').read().split()
print "Building matcher..."
m = Matcher(words)  # takes 1-2 seconds, for me
print "Done."

print m.find("st??k")
print m.find("ov???low")

На моем компьютере системный словарь имеет размер 909 КБ, и эта программа использует около 3,2 МБ памяти в дополнение к тому, что требуется только для хранения слов (указатели 4 байта). Для этого словаря вы можете сократить его пополам, используя 2-байтовые целые числа вместо указателей, потому что имеется менее 2 16 слов каждой длины.

Измерения: На моей машине m.find("st??k") выполняется за 0,000032 секунды, m.find("ov???low") за 0,000034 секунды и m.find("????????????????e") за 0,000023 секунды.

Записав двоичный поиск вместо использования class RotatedArray и библиотеки bisect, я получил эти первые два числа до 0,000016 секунд: в два раза быстрее. Реализация этого в C ++ сделает его еще быстрее.

4 голосов
/ 23 декабря 2009

Сначала нам нужен способ сравнить строку запроса с заданной записью. Давайте предположим, что функция использует регулярные выражения: matches(query,trialstr).

Алгоритм O (n) будет состоять в том, чтобы просто проходить через каждый элемент списка (ваш словарь будет представлен в виде списка в программе), сравнивая каждый из них с вашей строкой запроса.

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

wordsbyletter = { 'a' : ['aardvark', 'abacus', ... ],
                  'b' : ['bat', 'bar', ...],
                  .... }

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

wordsmap = { 'a':{ 0:['aardvark', 'abacus'],
                   1:['bat','bar'] 
                   2:['abacus']},
             'b':{ 0:['bat','bar'],
                   1:['abacus']},
             ....
           }

Как вы можете видеть, без использования индексов вы в конечном итоге значительно увеличите объем требуемой памяти - в частности, словарь из n слов и средней длины m потребует нм 2 памяти. Тем не менее, теперь вы можете очень быстро выполнить поиск, чтобы получить все слова из каждого набора, которые могут соответствовать.

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

Эта версия будет O ( kx ), где k - это число известных букв в слове запроса и x = x ( n ) - время поиска отдельного элемента в словаре длиной n в вашей реализации (обычно log ( n *) 1033 *).

То есть с окончательным словарем вроде:

allmap = { 
           3 : { 
                  'a' : {
                          1 : ['ant','all'],
                          2 : ['bar','pat']
                         }
                  'b' : {
                          1 : ['bar','boy'],
                      ...
                }
           4 : {
                  'a' : {
                          1 : ['ante'],
                      ....

Тогда наш алгоритм просто:

possiblewords = set()
firsttime = True
wordlen = len(query)
for idx,letter in enumerate(query):
    if(letter is not '?'):
        matchesthisletter = set(allmap[wordlen][letter][idx])
        if firsttime:
             possiblewords = matchesthisletter
        else:
             possiblewords &= matchesthisletter

В конце, набор possiblewords будет содержать все соответствующие буквы.

3 голосов
/ 23 декабря 2009

Если вы генерируете все возможные слова, которые соответствуют шаблону (arate, arbte, arcte ... zryte, zrzte), а затем ищите их в двоичном древовидном представлении словаря, который будет иметь средние характеристики производительности O (e ^ N1 * log (N2)) , где N1 - количество знаков вопроса, а N2 - размер словаря. Мне это кажется достаточно хорошим, но я уверен, что можно найти лучший алгоритм.

РЕДАКТИРОВАТЬ : Если у вас будет больше, чем, скажем, три знака вопроса, взгляните на ответ Фила Х и его подход к индексированию букв.

3 голосов
/ 23 декабря 2009

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

from array import array
all_words = open("english-words").read().split()
big_map = {}

def populate_map(word):
  for i in range(pow(2, len(word))):
    bin = _bin(i, len(word))
    candidate = array('c', word)
    for j in range(len(word)):
      if bin[j] == "1":
        candidate[j] = "?"
    if candidate.tostring() in big_map:
      big_map[candidate.tostring()].add(word)
    else:
      big_map[candidate.tostring()] = set([word])

def _bin(x, width):
    return ''.join(str((x>>i)&1) for i in xrange(width-1,-1,-1))

def run():
  for word in all_words:
    populate_map(word)

run()

>>> big_map["y??r"]
set(['your', 'year'])
>>> big_map["yo?r"]
set(['your'])
>>> big_map["?o?r"]
set(['four', 'poor', 'door', 'your', 'hour'])
2 голосов
/ 31 декабря 2009

Создать хэш-набор всех слов Чтобы найти совпадения, замените вопросительные знаки в шаблоне каждой возможной комбинацией букв. Если имеется два знака вопроса, запрос состоит из 26 2 = 676 быстрых поисков в хеш-таблице с постоянным ожидаемым временем.

import itertools

words = set(open("/usr/share/dict/words").read().split())

def query(pattern):
    i = pattern.index('?')
    j = pattern.rindex('?') + 1
    for combo in itertools.product('abcdefghijklmnopqrstuvwxyz', repeat=j-i):
        attempt = pattern[:i] + ''.join(combo) + pattern[j:]
        if attempt in words:
            print attempt

Это использует меньше памяти, чем мой другой ответ, но становится экспоненциально медленнее, когда вы добавляете больше вопросительных знаков.

2 голосов
/ 03 января 2010

Если точность 80-90% приемлема, вы можете справиться с проверкой орфографии Питера Норвиг . Реализация небольшая и элегантная.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...