Фразы из таблицы менделеева элементов

Обновлено: 22.12.2024

Сидя на пятичасовом занятии по химии, я часто скользил взглядом по таблице Менделеева, висящей на стене. Чтобы скоротать время, я начал искать слова, которые мог бы написать, используя лишь обозначения элементов из таблицы. Например: ScAlEs, FeArS, ErAsURe, WAsTe, PoInTlEsSnEsS, MoISTeN, SAlMoN, PuFFInEsS.

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

Генерирование группировок обозначений

Если бы обозначения всех элементов были одной длины, задача оказалась бы тривиальной. Но некоторые обозначения состоят из двух символов, некоторые — из одного. Это сильно усложняет дело. Например, pu в Amputations может означать плутоний (Pu) или фосфор с ураном (PU). Любое входное слово нужно разбивать на все возможные комбинации одно- и двухсимвольных обозначений.

Такие преобразования я решил назвать «группировки». Они определяют конкретное разделение слова на обозначения. Группировка может быть представлена как кортеж из единиц и двоек, где 1 представляет односимвольное обозначение, а 2 — двухсимвольное. Каждое разбиение на элементы соответствует какой-то группировке:

Анализируя задачу, в попытке найти паттерны я написал в тетради такую таблицу.

Вопрос: дана строка длиной n, сколько для неё может существовать последовательностей единиц и двоек, чтобы количество цифр в каждой последовательности равнялось n?

Ответ: fib(n + 1) !?

Я был удивлён, обнаружив последовательность Фибоначчи в таком неожиданном месте. Во время последующих изысканий я был удивлён ещё больше, узнав, что об этом паттерне было известно ещё две тысячи лет назад. Стихотворцы-просодисты из древней Индии открыли его, исследуя преобразования коротких и длинных слогов ведических песнопений. Об этом и о других прекрасных исследованиях в истории комбинаторики можете почитать в главе 7.2.1.7 книги The Art of Computer Programming Дональда Кнута.

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

Декартово произведение — это набор всех кортежей, скомпонованных из имеющегося набора элементов. Стандартная библиотека Python предоставляет функцию itertools.product() , которая возвращает декартово произведение элементов для данного итерируемого. cartesian_products — генерирующее выражение, которое собирает все возможные преобразования элементов в glyph_sizes вплоть до заданной в word_length длины.

Если word_length равно 3, то cartesian_products сгенерирует:

Затем результат фильтруется, чтобы groupings включало только те преобразования, количество элементов которых удовлетворяет word_length .

Конечно, здесь много лишней работы. Функция вычислила 14 преобразований, но остались только 3. Производительность сильно падает с увеличением длины слова. Но к этому мы вернёмся позже. А пока я получил работающую функцию и перешёл к следующей задаче.

Сопоставление слов с группировками

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

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

После сопоставления слово готово к сравнению со списком обозначений химических элементов.

Поиск вариантов написания

Я написал функцию spell() , которая собирает вместе все предыдущие операции:

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

Множества в Python очень похожи на математические множества. Это неупорядоченные коллекции уникальных элементов. За кулисами они реализованы как словари (хеш-таблицы) с ключами, но без значений. Поскольку все элементы множества хешируемы, то проверка на принадлежность (membership test) работает очень эффективно (в среднем О(1)). Операторы сравнения перегружаются для проверки на подмножества с использованием этих эффективных операций по проверке на принадлежность. Множества и словари хорошо описаны в замечательной книге Fluent Python Лучано Рамальо (Luciano Ramalho).

Заработал последний компонент, и я получил функционирующую программу!

Самое длинное слово?

Довольный своей реализацией основной функциональности, я назвал программу Stoichiograph и сделал для неё обёртку, использующую командную строку. Обёртка берёт слово в качестве аргумента или из файла и выводит варианты написания. Добавив функцию сортировки слов по убыванию, я натравил программу на список слов.

Отлично! Сам я бы это слово не нашёл. Программа уже решает поставленную задачу. Я поигрался ещё и нашёл более длинное слово:

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

Решение проблем с производительностью

Обработка 119 095 слов (многие из которых были довольно короткими) заняла у программы примерно 16 минут:

В среднем около 120 слов в секунду. Я был уверен, что можно делать гораздо быстрее. Мне требовалась более подробная информация о производительности, чтобы понять, где копать.

Line profiler — инструмент для определения узких мест в производительности кода на Python. Я воспользовался им для профилирования программы, когда она искала написание для 23-буквенного слова. Вот сжатая версия отчёта:

Неудивительно, что generate_groupings() работает так долго. Проблема, которую она пытается решить, — это особый случай задачи о сумме подмножеств, которая является NP-полной задачей. Поиск декартова произведения быстро становится дорогим, а generate_groupings() ищет многочисленные декартовы произведения.

Можно провести асимптотический анализ, чтобы понять, насколько всё плохо:

  1. Мы предполагаем, что glyph_sizes всегда содержат два элемента (1 и 2).
  2. product() находит r раз декартово произведение множества из двух элементов, так что временная сложность для product() равна O(2^r) .
  3. product() вызывается в цикле, который повторяется word_length раз, так что, если мы приравняем n к word_length , временная сложность для всего цикла будет равна O(2^r * n) .
  4. Но r получает разные значения при каждом прогоне цикла, так что на самом деле временная сложность ближе к O(2^1 + 2^2 + 2^3 + . + 2^(n-1) + 2^n) .
  5. А поскольку 2^0 + 2^1 + . + 2^n = 2^(n+1) - 1 , результирующая временная сложность равна O(2^(n+1) - 1) , или O(2^n) .

С O(2^n) можно ожидать, что время выполнения будет удваиваться при каждом инкрементировании word_length . Ужасно!

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

  1. Обработка списка слов разной длины.
  2. Обработка одного, но очень длинного слова.

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

Задача 1: быть ленивым

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

Проверка на неправильные символы

Естественно, я подумал, что в списке наверняка есть слова, содержащие символы, не представленные в таблице Менделеева. Нет смысла тратить время на поиск написаний для таких слов. А значит, список можно будет обработать быстрее, если быстро найти и выкинуть такие слова.

К сожалению, единственными символами, не представленными в таблице, оказались j и q.

А в моём словаре только 3 % слов содержали эти буквы:

Выкинув их, я получил прирост производительности всего на 2 %:

Это было не то улучшение, на которое я надеялся, так что я перешёл к следующей идее.

Мемоизация

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

generate_groupings() была идеальным кандидатом. Она вряд ли столкнётся с очень большим диапазоном входных данных и очень дорога в выполнении при обработке длинных слов. Пакет functools облегчает мемоизацию, предоставляя декоратор @lru_cache() .

Мемоизация generate_groupings() привела к тому, что время выполнения уменьшилось — заметно, хотя и недостаточно:

Но всё же неплохо для единственного декоратора из стандартной библиотеки!

Задача 2: быть умным

Мои оптимизации немного помогли с первой задачей, но ключевой нерешённой проблемой оставалась неэффективность работы generate_groupings() , большие отдельные слова всё ещё обрабатывались очень долго:

Лень может привести к определённому прогрессу, но иногда нужно быть умным.

Рекурсия и DAG

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

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

Если серия рекурсивных вызовов функции для прекрасного слова amputation выглядит так:

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

Получился направленный ациклический граф (DAG), каждый узел которого содержит обозначение химического элемента. Все пути от первого узла к последнему будут валидными написаниями исходного слова в виде химических элементов!

До этого я не работал с графами, но нашёл очень полезное эссе, в котором описаны основы, включая эффективный поиск всех путей между двумя узлами. В прекрасной книге 500 Lines or Less есть глава с другим примером реализации графа на Python. Эти примеры я и взял за основу.

Реализовав и протестировав простой графовый класс, я превратил свой рисунок на доске в функцию:

Выигрыш

В то время как алгоритм «в лоб» выполнялся ужасно долго ( O(2^n) ), рекурсивный длился O(n) . Гораздо лучше! Когда я в первый раз прогнал свежеоптимизированную программу на своём словаре, то был потрясён:

Вместо 16 минут я получил 10 секунд, вместо 120 слов в секунду — 10 800 слов!

Впервые я действительно оценил силу и ценность структур данных и алгоритмов.

Самое длинное слово

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

Floccinaucinihilipilificatiousness можно представить в виде 54 написаний, все они зашифрованы в этом прекрасном графе:


Оригинал

Хорошо потраченное время

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

Тем не менее я многому научился и много с чем познакомился. Это:

  • Комбинаторика
  • Профилирование производительности
  • Временная сложность
  • Мемоизация
  • Рекурсия
  • Графы и деревья

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

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

Добрые люди (и несколько благонамеренных ботов) поучаствовали в обсуждении этой статьи в ветке r/programming.

Допматериалы

Я получил немалую часть вдохновения из элегантных решений некоторых интересных проблем, решения принадлежат Питеру Норвигу (Peter Norvig):

Читайте также: