Анализ задачи с собеседования в Google: конь и телефонные кнопки |
||
МЕНЮ Главная страница Поиск Регистрация на сайте Помощь проекту Архив новостей ТЕМЫ Новости ИИ Голосовой помощник Разработка ИИГородские сумасшедшие ИИ в медицине ИИ проекты Искусственные нейросети Искусственный интеллект Слежка за людьми Угроза ИИ ИИ теория Внедрение ИИКомпьютерные науки Машинное обуч. (Ошибки) Машинное обучение Машинный перевод Нейронные сети начинающим Психология ИИ Реализация ИИ Реализация нейросетей Создание беспилотных авто Трезво про ИИ Философия ИИ Big data Работа разума и сознаниеМодель мозгаРобототехника, БПЛАТрансгуманизмОбработка текстаТеория эволюцииДополненная реальностьЖелезоКиберугрозыНаучный мирИТ индустрияРазработка ПОТеория информацииМатематикаЦифровая экономика
Генетические алгоритмы Капсульные нейросети Основы нейронных сетей Распознавание лиц Распознавание образов Распознавание речи Творчество ИИ Техническое зрение Чат-боты Авторизация |
2024-11-03 11:38 Для начала я должен заявить: хотя собеседование кандидатов — это одна из моих профессиональных обязанностей, в этой статье представлены лишь личные наблюдения, истории и мнения. Они ни в коем случае не являются официальными заявлениями Google, Alphabet или любых других лиц или организаций.
Если вы студент или ищете работу в технологической сфере, то, надеюсь, после прочтения статьи вы будете лучше понимать, чего ожидать от задач на собеседованиях. Если же вы проводите собеседования, то мне бы хотелось поделиться своим мыслительным процессом и стилистическим подходом к собеседованиям. Код будет написан на Python. Мне нравится Python, потому что его легко учить, он компактен и имеет огромную стандартную библиотеку. Кандидатам он тоже нравится: хоть мы и никак не ограничиваем использование языков, 90% людей на моих собеседованиях выбирают Python. Задача Представьте, что мы помещаем шахматного коня на телефонный номеронабиратель. Эта шахматная фигура ходит буквой «Г»: две клетки по горизонтали, и одна по вертикали или одна клетка по горизонтали и две по вертикали: Не обращайте внимания на плохо замазанные кнопки со звёздочкой и решёткойКнопки на номеронабирателе можно нажимать только ходом коня. Каждый раз, когда конь попадает на кнопку, мы нажимаем её и делаем ещё один ход. Начальная позиция тоже считается нажатой. Сколько уникальных чисел можно набрать за N ходов из конкретной начальной позиции? Обсуждение Все проводимые мной собеседования, по сути, разбиты на две части: сначала мы находим алгоритмическое решение, а затем кандидат реализует его в коде. Я говорю «мы находим», потому что я не просто бессловесный наблюдатель: 45 минут — это не так много времени, чтобы спроектировать и реализовать что-то даже в самых лучших условиях, не говоря уже о стрессовом состоянии на собеседовании. Я позволяю кандидатам брать на себя инициативу в рассуждениях, генерировать идеи, решать разные случаи задачи и так далее, но с радостью готов и подталкивать их в правильном направлении. Чем лучше кандидат, тем меньше подсказок я обычно даю, но пока я ещё не видел ни одного кандидата, которому бы вообще не требовались подсказки.
То есть всего шесть комбинаций. Если читая статью, вы будете повторять процесс решения, то возьмите карандаш с бумагой и попробуйте вывести их самостоятельно. Это невозможно передать в посте, но поверьте мне, когда я говорю, что есть нечто волшебное в решении задачи вручную: это приводит к гораздо большему количеству наблюдений, чем просто чтение и рассуждения. Возможно, у вас в голове уже начало формироваться решение. Но прежде чем прийти к нему… Уровень 0: сделаем следующий ход Одна из неожиданностей, с которыми я столкнулся, когда начал использовать эту задачу на собеседованиях: кандидаты очень часто заходили в тупик при вычислении кнопок, на которые можно перейти из позиции (они называются «соседними»); мой совет здесь будет таким: если есть сомнения, то напишите пустой заполнитель и спросите у проводящего собеседование, можно ли будет заполнить его позже.
К тому же вы практически ничего не потеряете, если спросите, можно ли использовать заглушку: если сложность задачи находится где-то в другом месте, я разрешу. Если же нет, то я попрошу реализовать функцию. Меня не волнует, если кандидаты не понимают, в чём состоит истинная сложность задачи, особенно на ранних этапах, когда они ещё не изучили её полностью. Учитывая, что функция поиска соседей не меняется, мы можем просто создать map и возвращать соответствующее значение: Уровень 1: рекурсивно генерируемые числа Ну, перейдём к решению. Возможно, вы уже заметили, что задачу можно решить, перечислив все возможные числа и подсчитав их. Для генерации этих значений можно использовать рекурсию:
Такое решение сработает, и оно часто встречалось мне на собеседованиях в качестве начальной точки. Однако стоит отметить, что после генерации чисел мы их не используем. В задаче говорится о количестве чисел, а не о самих числах. Учтя число в подсчёте, мы больше никогда к нему не возвращаемся. В общем случае я рекомендую обращать внимание на случаи, когда ваше решение вычисляет то, что позже никогда не используется. Часто можно избавиться от таких ситуаций и прийти к более совершенным решениям. Давайте так и сделаем. Уровень 2: подсчёт без подсчёта Как можно подсчитать телефонные номера, не генерируя их? Это возможно, но требуются дополнительные рассуждения. Обратите внимание, что количество чисел, которые можно сгенерировать из начальной позиции за N ходов, равно сумме количества ходов, которые можно сгенерировать, начиная с каждого из её соседей за N-1 ходов. Можно записать это математически как рекуррентное отношение: Это становится интуитивно понятно, когда мы рассмотрим происходящее при только одном ходе: кнопка 6 имеет 3 соседа (1, 7 и 0), и за ноль ходов мы можем получить по одному числу на каждый, то есть набрать лишь три числа. Вы можете спросить: как же прийти к такому выводу? Если вы изучали рекурсию, то это должно стать очевидным после изучения задачи на белой доске. Многие кандидаты, практиковавшиеся в рекурсии, сразу замечали, что эту задачу можно разбить на несколько более мелких подзадач, что становится очень серьёзной подсказкой. Если я провожу собеседование с вами и вижу, что вы не можете дойти до этого наблюдения, то обычно даю подсказки, а если они не помогают, то говорю прямо.Дойдя до этой мысли, вы уже готовы двигаться дальше и снова решать задачу. Это наблюдение используется во многих реализациях, но я начну с той, которую встречаю на собеседованиях чаще всего: наивный рекурсивный подход:
Вот и всё! Осталось добавить функцию для вычисления соседей, и решение задачи готово! Можете похвалить себя, вы справились. Ниже мы рассмотрим ещё множество подробностей, но здесь мы сделали серьёзный шаг. Создание любого работающего решения уже серьёзно выделяет вас на фоне удивительно большого количества кандидатов. Дальше вы часто будете слышать от меня такой вопрос: каким будет сложность («О» большое) этого решения? Если вы не знаете этого понятия, то вот неформальное объяснение: сложность алгоритма («О» большое) — это условное обозначение скорости, с которой растёт количество необходимых вычислений как функция от размера входных данных. В нашей задаче размер входных данных — это количество ходов. Если вас интересует строгое математическое определение, то его можно прочитать в Википедии. В этой реализации каждый вызов count_sequences() рекурсивно вызывает count_sequences() как минимум дважды, потому что у каждой кнопки есть минимум два соседа. Так как количество рекурсий равно нужному количеству ходов, а количество вызовов count_sequences() при каждом вызове как минимум удваивается, то сложность времени выполнения не меньше экспоненциального времени.Это плохо. Добавление каждого нового хода удваивает, если не утраивает время выполнения. Для небольших чисел, допустим, от 1 до 20, это приемлемо, но если количество ходов становится всё больше и больше, то рано или поздно мы уткнёмся в стену. Например, для обработки 500 ходов потребуется сильно больше времени, чем до тепловой смерти Вселенной. Уровень 3: мемоизация Можно ли придумать что-то получше? Если не использовать ничего, кроме представленного выше математического отношения, то практически нет. Однако одна из причин моей любви к этой задаче заключается в том, что она позволяет размышлять слой за слоем, приходя ко всё более эффективным решениям. Чтобы найти следующее, давайте разложим вызовы функции, выполняемые этой функцией. Рассмотрим случай C(6, 2) выполняется три раза, и каждый раз он выполняет одни и те же вычисления, возвращая одно и то же значение. Ключевой вывод здесь заключается в том, что эти вызовы функции повторяются, каждый раз возвращая одинаковое значение. После первого вычисления их результата повторно вычислять его не нужно.Вероятно, вы задаётесь вопросом, как же прийти к этому наблюдению: проще всего это сделать при помощи старой доброй белой доски: внимательное прочтение абстрактной формулировки задачи — это, конечно, здорово, но я всегда подталкиваю кандидатов к тому, чтобы они набросали простое решение на доске. Подобный процесс решения и рисование дерева даст нам понять, что мы много раз рисуем поддерево для C(6, 2) , что сложно не заметить. Иногда этого наблюдения даже достаточно, чтобы кандидаты полностью пропустили решения 1 и 2, напрямую перейдя к этому этапу. Не нужно и говорить, что это сильно экономит время собеседования, в котором на решение задачи выделено всего 45 минут.Как же нам решить эту задачу, вооружившись этим наблюдением? Можно воспользоваться мемоизацией, то есть, по сути, записью результатов вызовов функции, которые мы уже видели ранее, и использовать их вместо повторного выполнения работы. Благодаря этому, когда мы встретим в графе вызовов место, где могли бы без необходимости заново пересчитывать всё поддерево, достаточно будет сразу же вернуть уже вычисленный результат. Вот пример реализации:
Ну хорошо, а какой же теперь стала сложность («О» большое)? На этот вопрос ответить сложнее. В предыдущей реализации для вычисления времени выполнения достаточно было подсчитать количество вызовов рекурсивной функции, которых всегда было от двух до трёх на один вызов. Теперь подсчитать будет сложнее, потому что рекурсивный вызов зависит от условного оператора. На первый взгляд кажется, что нет очевидных способов подсчёта вызовов функций. Мы можем решить эту загадку, взглянув на кэш. Результат каждого вызова функции сохраняется в кэше и вставляется туда ровно один раз. Это позволяет нам переформулировать вопрос так: с какой скоростью растёт размер кэша в зависимости от размера входных данных? Учитывая то, что доступ к кэшу выполняется по позиции и количеству ходов, и что есть ровно десять позиций, мы можем прийти к выводу, что кэш растёт в прямой пропорции к количеству запрошенных ходов. Это следует из принципа Дирихле (принципа голубей и ящиков): как только у нас есть запись в кэше для каждой комбинации позиции и количества переходов, все вызовы будут попадать в кэш, а не приводить к новому вызову функции. Линейное время! Совсем неплохо. На самом деле, замечательный результат: добавление простого кэша изменило время выполнения алгоритма с экспоненциального на линейное. На моём старом MacBook Air выполнение рекурсивной реализации для двадцати ходов занимает около 45 секунд. Новая реализация способна обработать 500 ходов примерно за 50 миллисекунд. Отлично! Ну так что, мы на этом закончили? Не совсем. Это решение имеет два недостатка: один серьёзный (более-менее), другой не особо. Серьёзный недостаток заключается в рекурсивности. Большинство языков накладывает ограничение на размер своего стека вызовов, то есть существует максимальное количество ходов, которое может поддерживать реализация. На моей машине ошибка возникает примерно после 1000 ходов. Я называл это ограничение «более-менее» серьёзным, потому что любую рекурсивную функцию можно реализовать итеративным образом, но повозиться всё равно придётся. Что же касается несерьёзного ограничения, то оно приводит нас к следующему решению… Уровень 4: динамическое программирование Несущественное ограничение рекурсивного решения с мемоизацией становится очевидным, если взглянуть на показанное выше рекуррентное отношение: Можно заметить, что результаты для N ходов зависят только от результатов для вызовов с N-1 ходов. В то же время кэш содержит записи для каждого (ненулевого) количества ходов. Я называю это ограничение незначительным, потому что оно не вызывает никаких реальных проблем, учитывая то, что кэш растёт только линейно в зависимости от количества ходов. Необходимость линейного пространства — это не конец света, но это всё равно неэффективно. Что тут происходит? Как всегда, результат становится очевидным, если взглянуть на расписанное решение и на код. Обратите внимание, что код начинается с наибольшего количества ходов и рекурсивно спускается до наименьшего:Если представить весь граф вызовов функции как своего рода виртуальное дерево, то можно сразу увидеть, что мы выполняем обход в глубину. Это нормально, ведь так получается правильное решение, но при этом мы не пользуемся преимуществом свойства неглубокой зависимости, о котором я говорил выше. Можно ли вместо этого выполнить обход в ширину, начав с вершины и «посещая» вызовы функции для N-1 ходов только после того, как мы посетили вызовы для N ходов? К сожалению, нет. Значения вызовов функции с ненулевым количеством ходов обязательно требуют значений подсчёта при меньшем количестве ходов, поэтому мы не получим никаких результатов, пока не достигнем слоя нулевых ходов и не начнём возвращать числа вместо дополнительных вызовов функции (следует учесть, что здесь не показан слой нулевых ходов). Однако можно обратить порядок: посещать слои с N ходов только после того, как мы посетили слои с N-1 ходов. Если вы изучали или изучаете дискретную математику, то уже заметили все необходимые ингредиенты для индукции: мы знаем, что значения вызовов функции для нулевых ходов всегда равны единице (базовый случай). Также мы знаем, как комбинировать значения N-1 ходов для получения значений N ходов при помощи рекуррентного отношения (шаг индукции). Мы можем начать с базового случая с нулём ходов и индукцией получить все значения больше нуля. Вот реализация:
Чем же эта версия лучше рекурсивного решения с обходом в глубину? Не особо многим, но она имеет некоторые преимущества. Во-первых, она не рекурсивная, то есть может без вылетов работать для чрезвычайно больших значений. Во-вторых, она использует константную память, потому что ей всегда требуется два массива фиксированного размера, а не постоянно растущий кэш в случае решения с мемоизацией. Наконец, время по-прежнему остаётся линейным: я могу вычислить 200000 ходов меньше чем за двадцать секунд. Оценка Итак, мы ведь закончили? Практически. Проектирование и реализация решения с линейным временем и константным пространством — очень хороший результат для собеседования на должность. Когда я использовал эту задачу, то давал превосходный рейтинг кандидатам, пришедшим к решению с динамическим программированием. Подведём итог Представленная в посте задача может показаться пугающей, особенно учитывая описанный здесь долгий процесс решения. Однако стоит помнить о том, что пост намеренно сделан более подробным, чем будет любое собеседование. Я не перечисляю всё то, что ожидаю увидеть в кандидате, а в мельчайших деталях анализирую задачу, чтобы ничего не упустить.
Источник: habr.com Комментарии: |
|