Игра в одну букву Проблема?

Недавно на собеседовании мне поставили следующую задачу:

  1. Напишите скрипт, способный работать в командной строке как python.

  2. Он должен состоять из двух слов в командной строке (или, если вы предпочитаете, он может запросить у пользователя два слова через консоль).

  3. Учитывая эти два слова: а. Убедитесь, что они имеют одинаковую длину b. Убедитесь, что оба слова присутствуют в словаре допустимых слов английского языка, который вы скачали.

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

  5. Если два слова достижимы, сценарий должен распечатать путь, который ведет как единственный кратчайший путь от одного слова к другому.

  6. Вы можете использовать /usr/share/dict/words для своего словаря слов.

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

Ребята, вы знаете, что я мог сделать не так? Большое спасибо.

import collections
import functools
import re

def time_func(func):
    import time

    def wrapper(*args, **kwargs):
        start = time.time()
        res = func(*args, **kwargs)
        timed = time.time() - start

        setattr(wrapper, 'time_taken', timed)
        return res

    functools.update_wrapper(wrapper, func)
    return wrapper

class OneLetterGame:
    def __init__(self, dict_path):
        self.dict_path = dict_path
        self.words = set()

    def run(self, start_word, end_word):
        '''Runs the one letter game with the given start and end words.
        '''
        assert len(start_word) == len(end_word), \
            'Start word and end word must of the same length.'

        self.read_dict(len(start_word))

        path = self.shortest_path(start_word, end_word)
        if not path:
            print 'There is no path between %s and %s (took %.2f sec.)' % (
                start_word, end_word, find_shortest_path.time_taken)
        else:
            print 'The shortest path (found in %.2f sec.) is:\n=> %s' % (
                self.shortest_path.time_taken, ' -- '.join(path))

    def _bfs(self, start):
        '''Implementation of breadth first search as a generator.

        The portion of the graph to explore is given on demand using get_neighboors.
        Care was taken so that a vertex / node is explored only once.
        '''
        queue = collections.deque([(None, start)])
        inqueue = set([start])

        while queue:
            parent, node = queue.popleft()
            yield parent, node

            new = set(self.get_neighbours(node)) - inqueue
            inqueue = inqueue | new
            queue.extend([(node, child) for child in new])

    @time_func
    def shortest_path(self, start, end):
        '''Returns the shortest path from start to end using bfs.
        '''
        assert start in self.words, 'Start word not in dictionnary.'
        assert end in self.words, 'End word not in dictionnary.'

        paths = {None: []}
        for parent, child in self._bfs(start):
            paths[child] = paths[parent] + [child]
            if child == end:
                return paths[child]
        return None

    def get_neighbours(self, word):
        '''Gets every word one letter away from the a given word.

        We do not keep these words in memory because bfs accesses 
        a given vertex only once.
        '''
        neighbours = []

        p_word = ['^' + word[0:i] + '\w' + word[i+1:] + '$' 
            for i, w in enumerate(word)]
        p_word = '|'.join(p_word)

        for w in self.words:
            if w != word and re.match(p_word, w, re.I|re.U):
                neighbours += [w]
        return neighbours

    def read_dict(self, size):
        '''Loads every word of a specific size from the dictionnary into memory.
        '''
        for l in open(self.dict_path):
            l = l.decode('latin-1').strip().lower()
            if len(l) == size:
                self.words.add(l)

if __name__ == '__main__':
    import sys
    if len(sys.argv) not in [3, 4]:
        print 'Usage: python one_letter_game.py start_word end_word'
    else:
        g = OneLetterGame(dict_path = '/usr/share/dict/words')
        try:
            g.run(*sys.argv[1:])
        except AssertionError, e:
            print e

Спасибо за все отличные ответы. Я думаю, что меня действительно поразил тот факт, что я каждый раз перебираю ВСЕ слова в словаре, чтобы рассмотреть возможные соседние слова. Вместо этого я мог бы использовать перевернутый индекс, как указали Дункана и Мэтта Андерсона. Подход A* определенно помог бы. Большое спасибо, теперь я знаю, что я сделал не так.

Вот тот же код с перевернутым индексом:

import collections
import functools
import re

def time_func(func):
    import time

    def wrapper(*args, **kwargs):
        start = time.time()
        res = func(*args, **kwargs)
        timed = time.time() - start

        setattr(wrapper, 'time_taken', timed)
        return res

    functools.update_wrapper(wrapper, func)
    return wrapper

class OneLetterGame:
    def __init__(self, dict_path):
        self.dict_path = dict_path
        self.words = {}

    def run(self, start_word, end_word):
        '''Runs the one letter game with the given start and end words.
        '''
        assert len(start_word) == len(end_word), \
            'Start word and end word must of the same length.'

        self.read_dict(len(start_word))

        path = self.shortest_path(start_word, end_word)
        if not path:
            print 'There is no path between %s and %s (took %.2f sec.)' % (
                start_word, end_word, self.shortest_path.time_taken)
        else:
            print 'The shortest path (found in %.2f sec.) is:\n=> %s' % (
                self.shortest_path.time_taken, ' -- '.join(path))

    def _bfs(self, start):
        '''Implementation of breadth first search as a generator.

        The portion of the graph to explore is given on demand using get_neighboors.
        Care was taken so that a vertex / node is explored only once.
        '''
        queue = collections.deque([(None, start)])
        inqueue = set([start])

        while queue:
            parent, node = queue.popleft()
            yield parent, node

            new = set(self.get_neighbours(node)) - inqueue
            inqueue = inqueue | new
            queue.extend([(node, child) for child in new])

    @time_func
    def shortest_path(self, start, end):
        '''Returns the shortest path from start to end using bfs.
        '''
        assert self.in_dictionnary(start), 'Start word not in dictionnary.'
        assert self.in_dictionnary(end), 'End word not in dictionnary.'

        paths = {None: []}
        for parent, child in self._bfs(start):
            paths[child] = paths[parent] + [child]
            if child == end:
                return paths[child]
        return None

    def in_dictionnary(self, word):
        for s in self.get_steps(word):
            if s in self.words:
                return True
        return False

    def get_neighbours(self, word):
        '''Gets every word one letter away from the a given word.
        '''
        for step in self.get_steps(word):
            for neighbour in self.words[step]:
                yield neighbour

    def get_steps(self, word):
        return (word[0:i] + '*' + word[i+1:] 
            for i, w in enumerate(word))

    def read_dict(self, size):
        '''Loads every word of a specific size from the dictionnary into an inverted index.
        '''
        for w in open(self.dict_path):
            w = w.decode('latin-1').strip().lower()
            if len(w) != size:
                continue
            for step in self.get_steps(w):
                if step not in self.words:
                    self.words[step] = [] 
                self.words[step].append(w)

if __name__ == '__main__':
    import sys
    if len(sys.argv) not in [3, 4]:
        print 'Usage: python one_letter_game.py start_word end_word'
    else:
        g = OneLetterGame(dict_path = '/usr/share/dict/words')
        try:
            g.run(*sys.argv[1:])
        except AssertionError, e:
            print e

И сравнение времени:

% python one_letter_game_old.py happy hello Кратчайший путь (найдено за 91,57 сек.):
=> happy -- гарпия -- арфы -- олени -- привалы -- залы -- ады -- привет

% python one_letter_game.py happy hello Кратчайший путь (найдено за 1,71 сек.):
=> happy -- harpy -- harps -- harts -- halts -- Halls -- hells -- hello


person Alex Ksikes    schedule 27.04.2010    source источник
comment
Я не просматривал ваш код, но то, что вы не получили работу, не означает, что это была ваша ошибка. Они сказали вам это?   -  person MJB    schedule 27.04.2010
comment
ну, я пытался спросить, но их политика такова, что им не разрешается давать дальнейшие отзывы ...   -  person Alex Ksikes    schedule 27.04.2010
comment
Аналогичная проблема: stackoverflow.com/questions/2534087/   -  person FogleBird    schedule 27.04.2010
comment
Я согласен с МЖБ. Вероятно, есть более эффективные решения, но ваш код выглядит нормально. Если они будут расплывчатыми и необщительными, я не могу себе представить, что это было бы веселое место для работы.   -  person Cerin    schedule 27.04.2010
comment
@Alex K: Я не вникал в это глубоко, но разве это не проблема, которую можно значительно ускорить с помощью мемоизации?   -  person SyntaxT3rr0r    schedule 27.04.2010
comment
@Alex K: забудь, что я сказал, я перечитал и не думаю, что больше запоминание здесь поможет.   -  person SyntaxT3rr0r    schedule 27.04.2010
comment
Это кажется немного сумасшедшим для вопроса на собеседовании. Если бы это не был вопрос типа Интернета или какая-то аналогичная ситуация, как у интервьюера, я бы не ожидал идеального решения. Не унывайте, хотя, как говорили другие, это само по себе не означает, что вы не получили работу. Иногда интервью не удаются.   -  person The Jug    schedule 27.04.2010


Ответы (5)


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

  1. Поиск в ширину будет проходить по всем путям длины на единицу короче, чем необходимо, а также по некоторым путям необходимой длины, прежде чем он сможет дать вам ответ. Поиск с поиском по первому наилучшему (A*) в идеале пропускает большинство нерелевантных путей.

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

Вот еще одно решение вашей проблемы:

import collections
import heapq
import time

def distance(start, end):
    steps = 0
    for pos in range(len(start)):
        if start[pos] != end[pos]:
            steps += 1
    return steps


class SearchHeap(object):
    def __init__(self):
        self.on_heap = set()
        self.heap = []

    def push(self, distance, word, path):
        if word in self.on_heap:
            return
        self.on_heap.add(word)
        heapq.heappush(self.heap, ((distance, len(path)), word, path))

    def __len__(self):
        return len(self.heap)

    def pop(self):
        return heapq.heappop(self.heap)


class OneLetterGame(object):
    _word_data = None

    def __init__(self, dict_path):
        self.dict_path = dict_path

    def run(self, start_word, end_word):
        start_time = time.time()
        self._word_data = collections.defaultdict(list)
        if len(start_word) != len(end_word):
            print 'words of different length; no path'
            return

        found_start, found_end = self._load_words(start_word, end_word)
        if not found_start:
            print 'start word %r not found in dictionary' % start_word
            return
        if not found_end:
            print 'end word %r not found in dictionary' % end_word
            return

        search_start_time = time.time()
        path = self._shortest_path(start_word, end_word)
        search_time = time.time() - search_start_time
        print 'search time was %.4f seconds' % search_time

        if path:
            print path
        else:
            print 'no path found from %r to %r' % (start_word, end_word)

        run_time = time.time() - start_time
        print 'total run time was %.4f seconds' % run_time

    def _load_words(self, start_word, end_word):
        found_start, found_end = False, False
        length = len(start_word)
        with open(self.dict_path) as words:
            for word in words:
                word = word.strip()
                if len(word) == length:
                    if start_word == word: found_start = True
                    if end_word == word: found_end = True
                    for bucket in self._buckets_for(word):
                        self._word_data[bucket].append(word)
        return found_start, found_end

    def _shortest_path(self, start_word, end_word):
        heap = SearchHeap()
        heap.push(distance(start_word, end_word), start_word, (start_word,))
        while len(heap):
            dist, word, path = heap.pop()
            if word == end_word:
                return path
            for neighbor in self._neighbors_of(word):
                heap.push(
                    distance(neighbor, end_word), 
                    neighbor, 
                    path + (neighbor,))
        return ()

    def _buckets_for(self, word):
        buckets = []
        for pos in range(len(word)):
            front, back = word[:pos], word[pos+1:]
            buckets.append(front+'*'+back)
        return buckets

    def _neighbors_of(self, word):
        for bucket in self._buckets_for(word):
            for word in self._word_data[bucket]:
                yield word

if __name__ == '__main__':
    import sys
    if len(sys.argv) not in [3, 4]:
        print 'Usage: python one_letter_game.py start_word end_word'
    else:
        matt = OneLetterGame(dict_path = '/usr/share/dict/words')
        matt.run(*sys.argv[1:])

И, сравнение времени:

% python /tmp/one_letter_alex.py canoe happy
The shortest path (found in 51.98 sec.) is:
=> canoe -- canon -- caxon -- taxon -- taxor -- taxer -- taper -- paper -- papey -- pappy -- happy

% python /tmp/one_letter_matt.py canoe happy
search time was 0.0020 seconds
('canoe', 'canon', 'caxon', 'taxon', 'taxor', 'taxer', 'taper', 'paper', 'papey', 'pappy', 'happy')
total run time was 0.2416 seconds
person Matt Anderson    schedule 27.04.2010

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

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

words = {}

def get_keys(word):
    """Generate keys from a word by replacing each letter in turn by an asterisk"""
    for i in range(len(word)):
        yield word[:i]+'*'+word[i+1:]

def get_steps(word):
    """Find the set of all words that can link to the given word."""
    steps = []
    for key in get_keys(word):
        steps.extend(words[key])
    steps = set(steps) - set([word])
    return steps

# Load the dictionary
for word in ('start', 'stare', 'spare', 'spore'):
    for key in get_keys(word):
        if key not in words:
            words[key] = []
        words[key].append(word)

print(words)
print(get_steps('stare'))
person Duncan    schedule 27.04.2010

Может быть, они ожидали поиска A* с расстоянием редактирования в качестве оценки?

person Rafał Dowgird    schedule 27.04.2010

Может быть, вы не хотели работать в такой дерьмовой компании с самого начала. Лично я не верю в обзоры кода. Я думаю, что если вы достаточно хорошо справитесь с проверкой портфолио и прошлых рекомендаций, то нет необходимости в таких тестах кода на месте. Компании с такой строгой политикой, как эта, никогда не добиваются успеха, поскольку все, что они используют, — это ботаники с одним кодом, которые думают о коде 24/7. Просто мои 2 цента.

person jini    schedule 27.04.2010
comment
Это сатирический пост? Я честно не могу сказать. - person Andrew Barrett; 27.04.2010
comment
Почему вы считаете это сатирическим? - person jini; 27.04.2010
comment
Потому что знатоки кода — это те, кто делает проект успешным… и в основном это те, кто посещает такие сайты. - person BlueRaja - Danny Pflughoeft; 30.04.2010

Может быть, вы забыли добавить шебанг? >-|

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

person fortran    schedule 27.04.2010