Введение в Структуры данных (Data Structures) и алгоритмы

Table of Contents

Введение

Нотация Big O

Нотация Big O — это математический способ описывать асимптотическую сложность алгоритмов, то есть то, как растут затраты ресурсов при увеличении размера входных данных n. Она абстрагируется от конкретного железа, языка и констант и показывает порядок роста, что делает её универсальным инструментом анализа.

Временная сложность (time complexity) описывает, как растёт количество элементарных операций алгоритма в зависимости от n. Формально говорят, что алгоритм имеет сложность O(f(n)), если существует такая константа C, что при достаточно больших n время работы не превышает C · f(n). Важный момент: учитывается худший случай, если не указано иное. Константы, коэффициенты и члены меньшего порядка отбрасываются, потому что при больших n они не влияют на характер роста. Например, 3n² + 10n + 5 в Big O записывается как O(n²).

Пространственная сложность (space complexity) описывает, как растёт объём дополнительной памяти, используемой алгоритмом, также как функция от n. Обычно считают дополнительную память, а не входные данные. Например, сортировка «на месте» может иметь O(1) по памяти, а алгоритм, создающий вспомогательный массив размера n, — O(n).

Интуитивно Big O отвечает не на вопрос «сколько именно секунд или мегабайт», а на вопрос «насколько хуже станет, если данных станет в 10 раз больше». Именно поэтому нотация широко используется в теории алгоритмов и на практике при выборе подходящего решения.

Часто встречающиеся порядки роста, от лучшего к худшему, выглядят так:

  • константный O(1) — затраты не зависят от размера входа;
  • логарифмический O(log n) — рост очень медленный (типично для бинарного поиска);
  • линейный O(n) — один проход по данным;
  • квазилинейный O(n log n) — характерен для эффективных сортировок;
  • квадратичный O(n²) и выше — быстро становятся непрактичными при больших n.

Важно понимать, что Big O — это верхняя оценка, а не точный прогноз. Она не заменяет профилирование, но позволяет заранее отсеять заведомо плохие алгоритмы и сравнивать решения на концептуальном уровне.

Нотация Big O Название Описание Пример алгоритма
O(1) Константная Время и/или память не зависят от размера входных данных Доступ к элементу массива по индексу a[i], dict[key]
O(log n) Логарифмическая Рост очень медленный: при увеличении входа в 2 раза добавляется 1 шаг Бинарный поиск, операции в сбалансированных деревьях
O(log² n) Полилогарифмическая Чаще всего результат вложенных логарифмических операций Некоторые алгоритмы на деревьях и графах
O(√n) Сублинейная Медленнее линейной, но быстрее логарифмической Проверка простоты числа перебором до √n
O(n) Линейная Время пропорционально размеру входа Линейный поиск, подсчёт суммы элементов
O(n log n) Квазилинейная Типична для эффективных алгоритмов сортировки Merge sort, Heap sort, Quick sort (в среднем)
O(n log² n) Почти квазилинейная Встречается в более сложных алгоритмах Некоторые алгоритмы на строках и графах
O(n²) Квадратичная Обычно два вложенных цикла по входным данным Bubble sort, Insertion sort (в худшем случае)
O(n³) Кубическая Три вложенных цикла, быстро становится непрактичной Алгоритм Флойда–Уоршелла
O(nᵏ) Полиномиальная Обобщение квадратичной и кубической Многие алгоритмы из теории NP
O(2ⁿ) Экспоненциальная Удваивается при каждом новом элементе Перебор всех подмножеств
O(3ⁿ) Экспоненциальная Ещё быстрее растущая экспонента Некоторые задачи динамики без оптимизации
O(n!) Факториальная Практически неиспользуема для больших n Задача коммивояжёра перебором
O(cⁿ) Экспоненциальная (обобщённая) Любая константа > 1 в степени n Brute-force задачи
O(n + m) Линейная по входу Зависит от нескольких параметров Алгоритмы на графах (вершины + рёбра)
O(n·m) Двухпараметрическая Часто в графах и матрицах Обход всех пар вершин
O(1) память Константная память Используется фиксированное количество памяти In-place сортировки
O(n) память Линейная память Дополнительная память пропорциональна входу Вспомогательный массив
O(n²) память Квадратичная память Хранение матрицы или таблицы Матрицы смежности

Шпаргалка по Big O

Профилирование

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

В отличие от асимптотического анализа и Big O, профилирование работает не с абстрактными оценками, а с конкретными метриками: временем выполнения функций, числом вызовов, потреблением памяти, частотой аллокаций, загрузкой CPU. Часто оказывается, что код с хорошей теоретической сложностью работает медленно из-за констант, кэш-промахов, лишних аллокаций или неудачных структур данных — и именно профилирование это выявляет.

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

Различают несколько основных видов профилирования:

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

В контексте Python профилирование особенно важно, потому что язык интерпретируемый, и реальные узкие места часто находятся не там, где их ожидают по Big O. Стандартный модуль cProfile позволяет получить детальный отчёт по времени выполнения функций, line_profiler показывает стоимость отдельных строк, а memory_profiler помогает анализировать использование памяти.

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

Динамическое программирование

Динамическое программирование (dynamic programming, DP) — это метод проектирования алгоритмов, который применяется к задачам, где решение можно разложить на перекрывающиеся подзадачи, а итоговый ответ строится из решений этих подзадач. Ключевая идея: не решать одну и ту же подзадачу больше одного раза.

Если кратко: рекурсия даёт корректность, а динамическое программирование — эффективность.

Когда задача решается динамическим программированием

У задачи должны быть два свойства.

Первое — оптимальная подструктура. Это означает, что оптимальное решение всей задачи можно получить из оптимальных решений её подзадач. Например, кратчайший путь до точки i проходит через кратчайший путь до предыдущих точек.

Второе — перекрывающиеся подзадачи. Одни и те же подзадачи возникают многократно. Обычная рекурсия будет пересчитывать их снова и снова, а DP — сохраняет и переиспользует результат.

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

Основные подходы DP

Top-down (memoization)

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

Плюс: код близок к математическому определению.
Минус: накладные расходы рекурсии и риск переполнения стека.

Bottom-up (tabulation)

Решение строится итеративно, начиная с самых простых подзадач и постепенно переходя к более сложным.

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

Оба подхода эквивалентны по асимптотике.

Классический пример: числа Фибоначчи

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

DP-решение хранит уже вычисленные значения и получает сложность O(n) по времени. При этом память можно сократить до O(1), храня только два последних значения. Это важный момент: DP часто можно оптимизировать по памяти.

Как выглядит DP-решение концептуально

Почти любая задача DP решается по одному шаблону:

Определяется состояние DP — что именно мы храним (например, dp[i] — ответ для первых i элементов).

Формулируется переход — как получить dp[i] из более простых состояний.

Задаются базовые случаи.

Определяется порядок вычислений.

При необходимости оптимизируется память.

Умение правильно выбрать состояние — самый сложный этап.

Основные типы задач динамического программирования

DP на одномерном массиве
Примеры: Фибоначчи, максимальная сумма подмассива, лестница с шагами.

DP на двумерной таблице
Примеры: наибольшая общая подпоследовательность (LCS), расстояние Левенштейна, рюкзак.

DP на строках
Сравнение строк, редактирование, палиндромы.

DP на деревьях (Tree DP)
Решения строятся в postorder-обходе. Примеры: диаметр дерева, максимальный путь.

DP по подмножествам (Bitmask DP)
Используется, когда количество элементов мало (обычно до 20). Пример: задача коммивояжёра.

DP с оптимизацией переходов
Монотонная очередь, divide and conquer DP, convex hull trick — продвинутые темы для сильных кандидатов.

Временная и пространственная сложность

Сложность DP обычно равна количеству состояний, умноженному на стоимость перехода.
Например, таблица n × m с O(1) переходом даёт O(nm) по времени и памяти.

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

Типичные ошибки на собеседованиях

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

Как понять, что тут DP

Фразы-триггеры в условиях:
«найти максимальный/минимальный…»,
«сколькими способами…»,
«оптимальное решение»,
«учитывая предыдущие шаги».

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

Резюме

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

Алгоритмы сортировки

Bubble Sort — одна из самых простых и наименее эффективных сортировок. Алгоритм многократно проходит по массиву и сравнивает соседние элементы, меняя их местами, если они стоят в неправильном порядке. После каждого прохода самый большой элемент «всплывает» в конец массива, отсюда и название. В худшем и среднем случае сложность по времени составляет O(n²), так как выполняются вложенные проходы. В лучшем случае (если массив уже отсортирован и есть оптимизация с флагом) — O(n). По памяти алгоритм работает за O(1), так как сортирует массив на месте. Bubble Sort стабилен, но практически не используется из-за низкой эффективности.

Selection Sort работает иначе: на каждом шаге он находит минимальный элемент в неотсортированной части массива и ставит его на правильную позицию. Число сравнений всегда одинаково, независимо от входных данных, поэтому и лучший, и худший, и средний случаи имеют сложность O(n²). Память — O(1), так как сортировка выполняется на месте. Алгоритм не является стабильным, но имеет предсказуемое поведение и минимальное число обменов, что иногда полезно при дорогих операциях записи.

Insertion Sort имитирует процесс сортировки карт вручную. Алгоритм берёт элементы по одному и вставляет каждый новый элемент в уже отсортированную часть массива, сдвигая большие элементы вправо. В худшем и среднем случае сложность O(n²), но в лучшем случае, когда массив почти отсортирован, — O(n). По памяти — O(1). Insertion Sort стабилен и очень эффективен на маленьких массивах или как вспомогательная сортировка внутри более сложных алгоритмов.

QuickSort — одна из самых популярных и быстрых сортировок на практике. Он использует стратегию «разделяй и властвуй»: выбирается опорный элемент (pivot), массив разбивается на элементы меньше и больше него, после чего рекурсивно сортируются подмассивы. Средняя сложность по времени — O(n log n), но в худшем случае (неудачный выбор pivot, например уже отсортированный массив) — O(n²). Память обычно O(log n) из-за глубины рекурсии. QuickSort не стабилен, но выигрывает за счёт хорошей локальности данных и отсутствия дополнительной памяти.

Merge Sort также основан на «разделяй и властвуй». Массив рекурсивно делится пополам, затем отсортированные половины сливаются в один отсортированный массив. В отличие от QuickSort, Merge Sort всегда работает за O(n log n), независимо от входных данных. Основной минус — необходимость дополнительной памяти O(n) для слияния. Алгоритм стабилен и хорошо подходит для сортировки связанных списков и внешней сортировки (когда данные не помещаются в память).

Heap Sort использует структуру данных «куча» (обычно бинарная max-heap). Сначала массив преобразуется в кучу, затем максимальный элемент извлекается и помещается в конец массива, после чего куча перестраивается. Сложность по времени — O(n log n) во всех случаях, память — O(1), так как сортировка выполняется на месте. Heap Sort не стабилен и на практике часто медленнее QuickSort из-за плохой кэш-локальности, но ценится за гарантированное время работы.

Counting Sort — не сравнительная сортировка. Она работает только для целочисленных ключей из ограниченного диапазона. Алгоритм подсчитывает, сколько раз встречается каждое значение, а затем по этим подсчётам восстанавливает отсортированный массив. Время работы — O(n + k), где k — размер диапазона значений. Память также O(n + k). Counting Sort стабилен и очень быстр, но применим только при небольшом диапазоне ключей.

Radix Sort — тоже не сравнительная сортировка. Она сортирует числа (или строки) поразрядно, начиная с младшего или старшего разряда, используя стабильную сортировку (часто Counting Sort) на каждом шаге. Если число разрядов равно d, а диапазон разряда k, то сложность — O(d · (n + k)). Память — O(n + k). Radix Sort эффективен для больших массивов чисел фиксированной длины, но менее универсален, чем сравнительные алгоритмы.

Алгоритмы обхода деревьев

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

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

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

Важно отличать бинарное дерево от других похожих структур:

  • Бинарное дерево — это общее понятие, не накладывающее ограничений на значения в узлах.
  • Бинарное дерево поиска (BST) — это частный случай, где для каждого узла все значения в левом поддереве меньше (или равны), а в правом — больше (или равны) значения узла.
  • Также существуют полные, совершенные, сбалансированные бинарные деревья — это уже свойства формы, а не данных.

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

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

Под деревом ниже подразумевается в первую очередь бинарное дерево, но большинство идей обобщаются и на n-арные деревья.

Depth-First Search (DFS) — обход в глубину

Это общий класс обходов, при котором алгоритм уходит как можно глубже по одной ветке, прежде чем возвращаться назад. Почти всегда реализуется рекурсивно или с помощью стека. Все классические «in/pre/post-order» — это частные случаи DFS. Временная сложность любого DFS — O(n), память — O(h), где h — высота дерева (в худшем случае O(n)).

DFS часто спрашивают концептуально: чем отличается от BFS, где используется стек, где рекурсия, какие риски (переполнение стека).

Preorder Traversal (прямой обход)

Порядок: сначала текущий узел → затем левое поддерево → затем правое поддерево.

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

Сложность: время O(n), память O(h).
Реализуется рекурсивно или итеративно через стек.

На собеседованиях часто спрашивают: как восстановить дерево по preorder + inorder.

Inorder Traversal (симметричный обход)

Порядок: левое поддерево → текущий узел → правое поддерево.

Ключевое свойство: для бинарного дерева поиска (BST) inorder-обход выдаёт отсортированную последовательность.

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

Сложность стандартная: O(n) по времени, O(h) по памяти.

Postorder Traversal (обратный обход)

Порядок: левое поддерево → правое поддерево → текущий узел.

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

Часто спрашивают как более «неочевидный» обход, особенно в итеративной реализации.

DFS с явным стеком (итеративный DFS)

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

На собеседованиях могут спросить:
почему рекурсия = стек,
как переписать рекурсивный обход в итеративный,
чем они отличаются по памяти.

Morris Traversal

Это продвинутый вариант inorder (иногда preorder), который выполняется за O(1) дополнительной памяти.

Идея: временно «перепрошивать» указатели дерева, чтобы избежать стека и рекурсии, а потом восстанавливать структуру.

Время — O(n), память — O(1).
Алгоритм сложный и редко используется на практике, но иногда встречается как «вопрос со звёздочкой» для сильных кандидатов.

Breadth-First Search (BFS) / Level Order Traversal — обход в ширину

Обход по уровням: сначала корень, затем все узлы уровня 1, затем уровня 2 и так далее.

Реализуется с помощью очереди, а не стека.
Время — O(n), память — O(w), где w — максимальная ширина дерева (в худшем случае O(n)).

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

Level Order с вариантами

Это не отдельные алгоритмы, а частые модификации BFS, которые почти гарантированно встречаются на собеседованиях:

  • обход по уровням с группировкой значений
  • zigzag level order
  • обход с указанием уровня
  • поиск первого элемента, удовлетворяющего условию

Важно понимать, что все они — один и тот же BFS с небольшой логикой поверх.

Обход n-арного дерева

Концептуально тот же DFS или BFS, но вместо левого и правого поддерева — список детей. На собеседованиях проверяют, понимаешь ли ты, что логика не меняется, меняется только цикл по детям.

Обход с возвратом значения (Tree DP)

Часто интервьюеры не называют это «обходом», но по сути это postorder DFS, где функция возвращает значение наверх: высоту, сумму, флаг, максимум и т.д.

Примеры задач:

  • высота дерева
  • диаметр дерева
  • проверка сбалансированности
  • максимальный путь

Это один из самых важных практических паттернов.

Алгоритмы поиска чисел

Под «поиском чисел» здесь понимается поиск элемента в массиве, диапазоне или числовом пространстве.

Линейный поиск (Linear Search)

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

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

Временная сложность в худшем и среднем случае — O(n), в лучшем — O(1), если искомый элемент находится в начале. Память — O(1).

Пример: поиск числа в неотсортированном списке, поиск первого элемента, удовлетворяющего условию.

Бинарный поиск (Binary Search)

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

Ключевая идея — уменьшать пространство поиска в два раза на каждом шаге.

Временная сложность — O(log n) во всех случаях, память — O(1) в итеративной реализации или O(log n) при рекурсии.

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

Пример: поиск числа в отсортированном массиве, поиск позиции вставки.

Поиск по ответу (Binary Search on Answer)

Это обобщение бинарного поиска, но применяется не к массиву, а к пространству возможных ответов. Используется, когда есть монотонное условие вида: «если ответ X подходит, то все ответы больше (или меньше) тоже подходят».

Алгоритм не ищет конкретное число в массиве, а подбирает минимальное или максимальное значение, удовлетворяющее условию.

Сложность — O(log R), где R — диапазон возможных значений, умноженная на стоимость проверки условия.

Очень популярный паттерн на собеседованиях.

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

Интерполяционный поиск (Interpolation Search)

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

Если данные действительно равномерны, алгоритм работает очень быстро.

Средняя сложность — O(log log n), худшая — O(n). Память — O(1).

На практике используется редко, но иногда появляется в теоретических вопросах.

Jump Search

Алгоритм разбивает отсортированный массив на блоки фиксированного размера и «прыгает» по ним, пока не найдёт блок, в котором может находиться элемент. Затем выполняется линейный поиск внутри блока.

Оптимальный размер блока — √n, что даёт временную сложность O(√n).

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

Exponential Search

Этот алгоритм применяется для поиска в очень больших или потенциально бесконечных отсортированных массивах. Сначала он экспоненциально увеличивает диапазон поиска (1, 2, 4, 8, …), пока не превысит искомое значение, а затем запускает бинарный поиск в найденном диапазоне.

Сложность — O(log n), память — O(1).

Пример: поиск в массиве неизвестного размера, потоках данных.

Поиск в хеш-таблице

Формально это не алгоритм поиска чисел в массиве, но на практике — один из самых частых способов поиска.

Идея: число преобразуется хеш-функцией в индекс, по которому элемент хранится в таблице.

Средняя сложность — O(1), худшая — O(n) при большом количестве коллизий. Память — O(n).

Пример: поиск числа в set или dict в Python.

Поиск в бинарном дереве поиска (BST)

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

Средняя сложность — O(log n), но в худшем случае (несбалансированное дерево) — O(n). Память — O(h), где h — высота дерева.

Пример: поиск элемента в сбалансированном дереве (AVL, Red-Black Tree).

Линейный поиск с двумя указателями

Часто используется в задачах с отсортированными массивами. Вместо поиска одного числа ищется пара, сумма, разность или другое условие.

Алгоритм движется с двух концов массива навстречу друг другу.

Сложность — O(n), память — O(1).

Пример: найти два числа с заданной суммой.

Поиск с использованием битовых операций

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

Алгоритмы используют свойства XOR, битовых масок и арифметики.

Сложность — O(n), память — O(1).

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

Важное резюме для собеседований

Если данные не отсортированы — начинай с линейного поиска или хеш-таблицы.
Если данные отсортированы — почти всегда подходит бинарный поиск или его вариации.
Если ищешь минимальный/максимальный ответ по условию — это бинарный поиск по ответу.
Если ограничения на память жёсткие — избегай хеш-таблиц.

0
Оставьте комментарий! Напишите, что думаете по поводу статьи.x