Написать пост

10 логико-математических задач для программистов от нейросетей

Аватарка пользователя Андрей Коротков

Попросили ChatGPT и Yandex GPT придумать несколько задач, которые зайдут программистам. Сумеете решить?

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

Задача о хакере и замках

Хакер пытается взломать 100 замков на сервере, которые все изначально закрыты. В первый проход, он переключает каждый замок (открытый становится закрытым, закрытый — открытым). Во второй проход, он переключает каждый второй замок. В третий — каждый третий, и так далее до 100-го прохода, где он переключает только 100-й замок. Сколько замков будут открыты после 100-го прохода?

Решение

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

Проходы осуществляются следующим образом:

1. В первом проходе хакер переключает каждый замок, так что замки 1, 2, 3, …, 100 теперь открыты, потому что они переключены один раз.

2. Во втором проходе он переключает каждый второй замок (замки 2, 4, 6, …, 100), меняя их состояние на противоположное, т.е. закрытые становятся открытыми, и наоборот.

3. В третьем проходе переключается каждый третий замок (замки 3, 6, 9, …), снова изменяя их состояния.

Этот процесс продолжается до 100-го прохода, когда хакер переключает только 100-й замок.

Ключевое наблюдение здесь — замок переключается в тех проходах, которые являются делителями его номера. Например, 12-й замок будет переключен в 1-м, 2-м, 3-м, 4-м, 6-м и 12-м проходах.

Замок в итоге останется открытым, если он будет переключен нечетное количество раз. Это произойдет, если у замка нечетное количество делителей. Однако, у большинства чисел четное количество делителей. Например, число 12 имеет делители 1, 2, 3, 4, 6, 12, которых шесть, и это четное число.

Исключение составляют точные квадраты, потому что у них есть один делитель, который встречается в паре сам с собой (одно и то же число). Например, число 9 имеет делители 1, 3, 9, и число 3 в списке только один раз. Квадраты 1, 4, 9, …, 100^2 — это числа 1, 16, 81, …, 10000. Точные квадраты имеют нечетное количество делителей.

Таким образом, только квадратные числа с метками 1^2, 2^2, …, 10^2 останутся открытыми, потому что они переключены нечетное количество раз. В итоге открыто будет ровно 10 замков — это квадраты чисел от 1 до 10.

Задача о бинарном дереве

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

Решение

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

Псевдокод такой функции может выглядеть примерно так:

			Функция find_max_sum(node)
    Если node == NULL (т.е., узел не существует)
        Вернуть 0
    Конец Если
    // Рекурсивно вызвать функцию для левого и правого потомков узла
    left_max = find_max_sum(node->left)
    right_max = find_max_sum(node->right)
    // Получить максимальную сумму, выбирая бóльшее из значений левого и правого потомка
    max_path = max(left_max, right_max) + node->value
    Вернуть max_path
Конец Функции
		

Интерпретация этой функции в реальном коде зависит от языка программирования. Например, на Python это будет выглядеть примерно так:

			class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right
def find_max_sum(node):
    if not node:
        return 0
    left_max = find_max_sum(node.left)
    right_max = find_max_sum(node.right)
    return max(left_max, right_max) + node.val
# Создаем пример бинарного дерева
#        10
#       /  \
#      5   20
#     / \    \
#    3   7   30
root = TreeNode(10)
root.left = TreeNode(5, TreeNode(3), TreeNode(7))
root.right = TreeNode(20, None, TreeNode(30))
# Вызываем функцию для корня дерева
max_sum = find_max_sum(root)
print(max_sum)  # Ожидаемый вывод: 10 + 20 + 30 = 60
		

Этот алгоритм будет обходить дерево в глубину (Depth-First Search, DFS) и вычислять максимальную сумму для каждого узла, двигаясь обратно к корню. В результате вызова функции find_max_sum для корня дерева, мы получим максимальную сумму на пути от корня до листьев.

Задача о сгенерированных паролях

Алгоритм генерирует пароли, состоящие из цифр. Каждый новый пароль должен содержать хотя бы одну цифру, которая не входила в предыдущий пароль. Если начать с пароля “1”, каким будет сто первый сгенерированный пароль?

Решение

Важный момент, на котором стоит сосредоточиться, — весь диапазон цифр от 0 до 9 может быть использован, прежде чем требуется повторение. Как только все цифры использованы, цикл может начаться заново, но с дополнительным нулём к текущему числу для увеличения его разрядности, тем самым создавая новую уникальную цифру, которой не было в предыдущем пароле.

Рассмотрим генерацию паролей пошагово:

1. Первый пароль — 1.

2. Второй пароль содержит следующую по порядку цифру, не использованную ранее, так что это — 2.

3. Следующие пароли будут 3, 4, 5, …, 9, последовательно добавляя новую цифру.

После достижения 9, нам нужно использовать цифру 0, чтобы сгенерировать следующий пароль. Поэтому

10. Одиннадцатый пароль будет 10.

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

Рассуждая далее, каждый десяток последовательностей (от 10 до 19, от 20 до 29 и так далее) требует добавление одного нового пароля в начале каждых десяти паролей, т.е. при переходе от 19 к 20, от 29 к 30 и так далее.

Таким образом, каждые 10 новых паролей генерируются по следующему правилу: n0, n1, n2, …, n9, где n — это предшествующий десятичный префикс.

Следовательно, 100-й пароль будет 109, и следующий, 101-й, будет 110, потому что мы начинаем новый цикл с увеличенной на единицу старшей цифрой после завершения предыдущего десятичного блока.

Ответ: 101-й пароль в последовательности будет 110.

Задача о синхронизации данных

У вас есть две базы данных, которые должны быть синхронизированы. Однако из-за сбоя сети некоторые записи перестали синхронизироваться. Если каждая запись имеет уникальный идентификатор и время создания, как вы разработаете алгоритм, который минимизирует количество необходимых проверок для восстановления полной синхронизации?

Решение

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

1. Алгоритмы консенсуса: Вводятся механизмы для достижения согласованности между узлами, например, Paxos или Raft. Эти алгоритмы помогают узлам ‘договориться’ о том, какие данные считать актуальными.

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

3. Конфликтующие реплики: При использовании реплицируемых баз данных, таких как Cassandra или DynamoDB, каждый узел содержит копию данных и может принимать обновления независимо. Конфликты разрешаются через механизмы, такие как последний записанный выигрывает (Last Write Wins) или с помощью более сложной системы разрешения конфликтов.

4. Транзакционность: Такие системы, как Google Spanner, используют комбинацию внутренних атомарных часов и строгих протоколов транзакций для обеспечения консистентности в глобальной распределённой базе данных.

5. Событийная консистентность: Это модель, при которой события отправляются постоянно, и каждый узел обновляется согласно поступающим событиям, используется в системах на основе очередей сообщений (например, Kafka).

При выборе подхода стоит учитывать требуемые свойства системы, такие как доступность, масштабируемость, толерантность к разделению сети (partition tolerance), время отклика и потенциальные затраты на инфраструктуру. В зависимости от критически важных требований, система может быть больше нацелена на консистентность (strong consistency) или на доступность и толерантность к разделениям (eventual consistency).

Задача о XOR-связи

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

Решение

Эта задача может быть решена с использованием свойств битовой операции XOR:

1. Свойства XOR:

– Любое число, примененное XOR само с собой, дает 0 (например, a XOR a = 0).

– Любое число, выполненное XOR с 0, дает это число (например, a XOR 0 = a).

– Операция XOR коммутативна и ассоциативна, то есть, a XOR b = b XOR a и (a XOR b) XOR c = a XOR (b XOR c).

Используя эти свойства, можно применить XOR ко всем числам в массиве. Парные числа «аннулируются», и в итоге останется только уникальное число, не имеющее пары.

Пример кода на языке программирования, который решает данную задачу:

			def find_unique_number(nums):
    unique = 0
    for num in nums:
        unique ^= num
    return unique
# Пример массива    
nums = [4, 1, 2, 1, 2]
print(find_unique_number(nums)) # Выведет 4, так как только число 4 не имеет пары
		

Здесь find_unique_number функция перебирает все элементы в списке nums и выполняет битовую операцию XOR на переменной unique, которая начально установлена в 0. В результате всех XOR-операций все числа, встречающиеся в парах, «исключают« друг друга из-за свойства «a XOR a = 0», и в unique остается только одно, не имеющее пары число, которое является искомым уникальным числом.

Задача о компиляторе языков

Дан набор правил грамматики для создания компилятора некоторого нового программного языка. Каковы будут основные шаги для создания LALR-парсера, и какие сложности могут возникнуть при компиляции языков с амбивалентной грамматикой?

Решение

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

1. Лексический анализ (Лексинг)

— Входной исходный код разбивается на последовательность лексем (токенов) с помощью лексического анализатора (лексера).

— Лексемы — это значимые последовательности символов, такие как идентификаторы, ключевые слова, операторы, литералы и т.д.

2. Синтаксический анализ (Парсинг)

— С использованием синтаксических правил языка парсер преобразовывает последовательность токенов в синтаксическое дерево (AST – Abstract Syntax Tree).

— AST отражает структурную иерархию исходного кода.

3. Семантический анализ

— Анализируется смысловое (семантическое) содержание программы на основе AST.

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

4. Генерация промежуточного кода

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

5. Оптимизация

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

6. Генерация машинного кода

— Оптимизированный промежуточный код транслируется в машинный код целевой платформы.

7. Линковка

— Машинный код соединяется с кодом из библиотек и других модулей для формирования исполняемого файла.

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

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

Задача о двух компьютерах

У вас есть два компьютера, которые начинают одновременно работать над одним и тем же заданием. Первому компьютеру на выполнение задания требуется 4 часа, второму — 6 часов. Спустя какое время после начала работы задание будет завершено, если задание не может быть разделено и каждый компьютер работает с полной производительностью, но с разной скоростью?

Решение

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

Поскольку первый компьютер завершает задание быстрее (за 4 часа, в то время как второму нужно 6 часов), то задание будет выполнено за 4 часа, это и есть время, которое понадобится для завершения работы.

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

Решение: 4 часа, так как это время, за которое работу завершит более быстрый (первый) компьютер.

Задача «Разбитый счётчик»

Программист обнаружил, что счётчик на его приложении перестал учитывать каждое пятое нажатие. Если известно, что счётчик показывает 10,000 кликов, сколько кликов было на самом деле, учитывая пропущенные за каждые четыре нажатия?

Решение

Пусть S — показания счётчика, то есть S = 10,000. Мы знаем, что S является 80% от реального числа нажатий (потому что пропускается каждое пятое нажатие, таким образом, счётчик регистрирует 4 нажатия, когда на самом деле их 5). Реальное количество нажатий (R) связано с показаниями счётчика следующей зависимостью:

Отсюда можно выразить реальное количество нажатий:

Подставляем показания счётчика (10,000) в формулу:

Таким образом, реальное количество нажатий на счётчике составляет 12,500.

Это решение предполагает, что показания счётчика являются точной кратной величиной числа реальных нажатий поделенных на 4, то есть счётчик зарегистрировал целое число групп из 4 нажатий и никакие нажатия не были потеряны частично (например, последняя группа из трех нажатий, которая не была зафиксирована полностью). Если предположить, что последняя группа могла быть неполной, то нужно округлить реальное количество нажатий до ближайшего целого числа, которое больше или равно значению R. В данной задаче уже обеспечено, что S является точной кратной величиной, так что реальное количество нажатий — 12,500.

Задача о рекурсивном списке

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

Решение

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

			[ L(n) = 
\begin{cases}
0, & \text{если } n = 0 \\
n + L(n-1), & \text{если } n > 0
\end{cases}
\]
		

Здесь \( L(n) \) обозначает n-ый элемент последовательности, а \( L(n-1) \) — предыдущий элемент этой последовательности. Чтобы вычислить \( L(n) \), мы должны добавить к его индексу \( n \) значение предыдущего элемента \( L(n-1) \).

Применим это определение к первым нескольким элементам последовательности, чтобы увидеть закономерность:

			- \( L(0) \) по определению равно 0.
- \( L(1) = 1 + L(0) = 1 + 0 = 1 \)
- \( L(2) = 2 + L(1) = 2 + 1 = 3 \)
- \( L(3) = 3 + L(2) = 3 + 3 = 6 \)
- И так далее.
		

Заметим, что значения \( L(n) \) соответствуют сумме арифметической прогрессии от 1 до \( n \), потому что каждый следующий элемент последовательности увеличивается на значение больше предыдущего элемента на 1. Сумма первых \( n \) натуральных чисел дается известной формулой:

Это сумма арифметической прогрессии, где каждое число увеличивается на единицу начиная с 1. Таким образом, можно сказать что наша последовательность \( L(n) \) не что иное, как просто сумма первых \( n \) натуральных чисел.

Если мы перепишем наше определение \( L(n) \) через сумму арифметической прогрессии, получим:

Это позволяет нам вычислить n-ый элемент последовательности напрямую, без необходимости вычислять все предыдущие элементы. Таким образом, если вам нужно найти, например, \( L(100) \), вы просто воспользуетесь формулой:

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

Задача о оптимальном пути

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

Решение

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

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

2. Алгоритм Беллмана-Форда: Тоже находит кратчайшие пути от одной вершины до всех других, но может работать с графами с отрицательными весами ребер (но без отрицательных циклов). Чуть менее эффективен, чем алгоритм Дейкстры.

3. Алгоритм Флойда-Уоршелла: Ищет кратчайшие пути между всеми парами вершин в графе с положительными или отрицательными весами (но без отрицательных циклов). Хорош для маленьких или плотных графов, где нужно знать пути между всеми вершинами.

4. A (А звездочка): Эвристический алгоритм, который при наличии хорошей эвристики позволяет быстро находить кратчайший путь. Является оптимальным и полным, если эвристика не переоценивает дистанцию.

5. Алгоритм Йена: Находит K кратчайших непересекающихся путей в графе.

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

Следите за новыми постами
Следите за новыми постами по любимым темам
2К открытий3К показов