Двоичный поиск: алгоритм, примеры на C и Python, нюансы использования
Начинаем цикл статей, где можно будет простыми словами изучить алгоритмы.
Данный цикл полезен будет для подготовки к собесам и если вы хотите изучить базовые алгоритмы и структуры данных.
Будем рассматривать питон и Си.
А также сравнительный анализ этих языков для решения алгоритмических задач.
Двоичный поиск: алгоритм, примеры на C и Python, нюансы использования
- Что делать, если у вас отсортированный массив, и нужно быстро найти элемент?
- Правильный ответ: применить двоичный (бинарный) поиск.
- Он работает в логарифмическом времени: O(log₂ n).
- Это один из базовых, но при этом часто используемых алгоритмов в программировании.
Что такое двоичный поиск?
Представьте, что вы ищете слово в словаре. Вы не листаете по одной странице — вы раскрываете словарь в середине, смотрите, в каком направлении двигаться — и снова делите пополам.
Вот так же работает и двоичный поиск.


Пример на языке C
C:
#include <stdio.h>
int binary_search(int arr[], int size, int target) {
int left = 0, right = size - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] == target)
return mid; // нашли!
else if (arr[mid] < target)
left = mid + 1;
else
right = mid - 1;
}
return -1; // не найден
}
int main() {
int numbers[] = {3, 8, 15, 23, 42, 55, 60};
int index = binary_search(numbers, 7, 23);
if (index >= 0)
printf("Найдено на позиции %d\n", index);
else
printf("Элемент не найден\n");
return 0;
}
Почему ?
Код:
int mid = left + (right - left) / 2
Это защита от переполнения при сложении:
если left + right превысит INT_MAX, произойдёт integer overflow.
Вместо этого безопасно сначала вычислить (right - left) / 2 — это гарантированно меньше диапазона int, и только потом прибавить left.
А вот в питоне можно:
Код:
mid = (left + right) // 2
Здесь можно безопасно сложить left + right, потому что Python использует произвольную точность для целых чисел (big integers).
Также про это будет рассказано в следующих статьях.)

Python:
def binary_search(arr, target):
left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
return mid # найден
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1 # не найден
nums = [3, 8, 15, 23, 42, 55, 60]
index = binary_search(nums, 23)
if index >= 0:
print(f"Найдено на позиции {index}")
else:
print("Элемент не найден")
C vs Python: нюансы реализации
C даёт полный контроль над памятью — придётся вручную считать границы, выделять массив, компилировать.
Python проще и нагляднее, особенно для обучения, но его скорость ниже.
На больших объёмах данных C покажет себя лучше по производительности.
Python же хорош как "скетчборд" — для прототипирования и отладки.
Временная сложность
Операция | Сложность |
---|---|
Лучший случай | O(1) – элемент в центре |
Средний случай | O(log n) |
Худший случай | O(log n) |
Почему важно понимать двоичный поиск?
- Он — основа многих алгоритмов, включая поиск в словарях, базах данных, деревьях.
- Работает везде, где есть отсортированные структуры.
- Часто используется на собеседованиях (Google, Яндекс, VK).
- Иногда возникает необходимость его модифицировать — например, найти первое вхождение.
Вариации и практики




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

5 классических ловушек при реализации бинарного поиска
Бинарный поиск — алгоритм, который все считают простым. Но именно в нём разработчики (включая опытных!) чаще всего делают незаметные ошибки, которые проявляются только при граничных случаях.

- Разберём типичные баги
- Покажем примеры на C и Python
- Поймём, почему в алгоритме «из трёх строк» столько нюансов
❶ Переполнение при вычислении середины в C
C:
// ⚠️ Ошибочный способ:
int mid = (left + right) / 2;
Если
left
и right
большие (например, 2 000 000 000), произойдёт переполнение int, и mid станет отрицательным. Это сложно отследить.
C:
int mid = left + (right - left) / 2;


Python:
mid = (left + right) // 2 # безопасно
❷ Бесконечный цикл при неправильно сдвинутых границах
Если забыть сдвигать
left
или right
, можно застрять в бесконечном цикле.
Python:
# ⚠️ Пример ошибки
while left < right:
mid = (left + right) // 2
if arr[mid] < target:
left = mid # ❌ не left + 1!
else:
right = mid

Python:
left = mid + 1 # если текущая середина точно меньше цели

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

Python:
left, right = 0, len(arr)
# Поиск первого вхождения
while left < right:
mid = (left + right) // 2
if arr[mid] < target:
left = mid + 1
else:
right = mid
После цикла
left
будет указывать на первый индекс, где встречается значение target
.Обратите внимание на отличие от binary_search, тут right = len(arr), а не len(arr) - 1, а также while left < right.
Это важно т.к.:
binary_search использует закрытый интервал [left, right].
- Оба конца включены.
- Проверка: while (left <= right)
- Подходит для поиска конкретного значения.
lower_bound использует полуоткрытый интервал [left, right)
- Правая граница не входит в диапазон.
- Проверка: while (left < right)
- Это позволяет корректно обрабатывать случай, когда элемент должен быть вставлен в конец массива.
❹ Выход за границы массива
Иногда встречается следующая ошибка:
C:
// ❌ доступ к arr[mid + 1] без проверки
if (arr[mid + 1] == target) { ... }
Если
mid == size - 1
, произойдёт выход за границы. Это undefined behavior и может привести к крашу или ошибкам только на определённых входах.
C:
if (mid + 1 < size && arr[mid + 1] == target)
❺ Неверное условие остановки цикла
Частая ошибка — неправильное условие
while
.
Python:
# ⚠️ Нестабильный вариант
while left <= right:
...
Это работает, но может быть трудно понять, что происходит на последних шагах.

left < right
и сохранением инварианта:
Python:
while left < right:
...
Подход
left < right
часто используют для поиска границ, особенно в задачах на бинарный поиск по функции (Binary Search on Answer
).
Вывод
- Бинарный поиск обманчиво прост — пока не столкнёшься с багами.
- Будь особенно осторожен с границами массива, сдвигом указателей и переполнением.
- На собеседованиях часто просят реализовать его с нуля на бумаге — с аккуратной логикой.


Сложность алгоритмов: O(n), O(log n), O(n²) на пальцах
Когда разработчик говорит, что алгоритм работает за O(n log n) — это не мистика и не математика для гениев.
Это просто способ выразить, насколько быстро (или медленно) алгоритм ведёт себя при росте объёма данных.
Это Big O Notation — обозначение асимптотической сложности. Оно показывает, как изменяется время работы или количество операций при росте входных данных
Важно: Big O не даёт точного времени, только порядок роста.
Пример:
Пример на Python:
Пример на C:
Пример: найти элемент в отсортированном массиве двоичным поиском.
Если элементов 1 000 000 — двоичный поиск сделает максимум 20 шагов.
Это логарифм по основанию 2:
Алгоритм двоичного поиска на каждом шаге делит массив пополам. Количество шагов, нужных чтобы "сжать" диапазон до одного элемента — это:
Где:
log2(1 000 000)≈19.93
Добавляем 1 (последний шаг сравнения) → округляем до целого: будет 20.
Пример:
Если данных в 10 раз больше — будет 10 раз больше операций.
Пример на C — сортировка пузырьком:
1000 элементов → миллион сравнений.
Вопрос к читателю: какой алгоритм у тебя был настолько неэффективный, что ты его потом переписал с нуля? Делись примерами 
Это просто способ выразить, насколько быстро (или медленно) алгоритм ведёт себя при росте объёма данных.
Что такое "O" вообще?
Это Big O Notation — обозначение асимптотической сложности. Оно показывает, как изменяется время работы или количество операций при росте входных данных
n
.
Пример:
O(n)
= "если данных в 2 раза больше, то и времени нужно в 2 раза больше".
O(1) — константное время
- Пример: получить элемент массива по индексу —
arr[5]
- Сколько бы ни было данных, операция всегда занимает одинаковое время
- Быстро, стабильно, идеально

Python:
arr = [3, 8, 13, 21]
x = arr[2] # O(1)

C:
int arr[] = {3, 8, 13, 21};
int x = arr[2]; // O(1)
O(log n) — логарифмическое время
- Делим задачу пополам каждый раз
- Часто встречается в двоичном поиске, деревьях, оптимизированных алгоритмах
- Очень хорошо масштабируется!

Python:
# O(log n)
def binary_search(arr, target):
left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1

Это логарифм по основанию 2:
Алгоритм двоичного поиска на каждом шаге делит массив пополам. Количество шагов, нужных чтобы "сжать" диапазон до одного элемента — это:
Код:
максимальное количество шагов=log₂(n)+1
Где:
n
— количество элементов,log₂(n)
— логарифм по основанию 2 (то есть: сколько раз можно делить на 2).
Пример: n = 1 000 000
log2(1 000 000)≈19.93Добавляем 1 (последний шаг сравнения) → округляем до целого: будет 20.
Табличка: для наглядности
Кол-во элементов: n | log₂n | Шагов |
---|---|---|
1 | 0 | 1 |
10 | ≈ 3.3 | 4 |
1000 | ≈ 9.96 | 10 |
1 000 000 | ≈ 19.93 | 20 |
1 000 000 000 | ≈ 29.9 | 30 |
O( n ) — линейное время
- Простой перебор: от начала до конца
- Хуже, чем
log n
, но иногда — единственный способ - Часто встречается в задачах на поиск, фильтрацию, подсчёт

Python:
# O(n)
def contains(arr, target):
for x in arr:
if x == target:
return True
return False

O(n²) — квадратичное время
- Каждый элемент сравнивается с каждым
- Работает медленно при большом объёме
- Встречается в пузырьковой сортировке, наивных реализациях

C:
void bubble_sort(int arr[], int size) {
for (int i = 0; i < size; i++) {
for (int j = 0; j < size - 1; j++) {
if (arr[j] > arr[j + 1]) {
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
}
}
}
}

Вывод
- Понимание сложности — фундамент любой алгоритмики
- Не всегда нужен самый быстрый алгоритм — главное: подходящий по задаче
- Если можешь улучшить
O(n²)
доO(n log n)
— делай это!


Сортировка массивов:Изучаем алгоритмы
Пузырьковая сортировка: медленно, грустно, зато понятно
Есть алгоритмы, от которых веет скоростью и изяществом (привет,
quick sort
).А есть bubble sort — максимально наглядный, максимально неторопливый.
Но даже у него есть своё предназначение. И в этой статье:
- Покажем, как он работает
- Напишем реализацию на C и Python
- Разберём сложность и когда всё же можно применять
Как работает пузырёк?
Представь, что у тебя есть массив чисел. И ты идёшь по нему, сравниваешь пары соседей и меняешь их местами, если они стоят "неправильно". И так — снова, и снова, пока всё не станет отсортировано.
Вот так числа как бы «всплывают» вверх — отсюда и название.
Реализация на C
C:
void bubble_sort(int arr[], int size) {
for (int i = 0; i < size - 1; i++) {
for (int j = 0; j < size - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
🛠 Тут два вложенных цикла:
- внешний контролирует количество «проходов»,
- внутренний — сравнивает пары и «всплывает» большие числа вправо.
Реализация на Python
Python:
def bubble_sort(arr):
n = len(arr)
for i in range(n - 1):
for j in range(n - i - 1):
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
Python-версия выглядит проще, но делает те же самые действия.

swapped
— чтобы закончить раньше, если всё уже отсортировано.
Сложность алгоритма
| Сценарий | Сложность |
|------------------------- |--------------|
| Лучший случай | O ( n ) |
| Средний и худший | O(n²) |
| Память | O(1) |

Но при большом
n
— всё, привет тормоза.
- Каждый элемент участвует в многочисленных сравнениях
- Неэффективен при больших объёмах
- Сильно уступает
merge sort
иquick sort
Вывод

- понятный,
- легко реализуемый,
- полезен в олимпиадной практике и тестах.
Если нужно быстро что-то отсортировать на 10–15 элементах — он сойдёт.
Другие алгоритмы сортировки: сравнение на Python и C
Пузырёк — самый первый алгоритм сортировки, который изучают.
Но что дальше? Где скорость? Где надёжность?
Давайте изучем и сравним:
- Insertion Sort
- Merge Sort
- Quick Sort
Insertion Sort — вставками

Перебираем элементы один за другим
- ⬅ Сдвигаем все большие элементы вправо
Вставляем текущий на своё место

Python:
def insertion_sort(arr):
for i in range(1, len(arr)):
key = arr[i]
j = i - 1
while j >= 0 and arr[j] > key:
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = key

C:
void insertion_sort(int arr[], int size) {
for (int i = 1; i < size; i++) {
int key = arr[i];
int j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}

---
Merge Sort — сортировка слиянием

Он разбивает массив на две части, сортирует каждую из них и затем сливает обратно в один отсортированный массив.

- Разделить массив пополам
- Рекурсивно отсортировать каждую часть
- Аккуратно объединить их в один отсортированный результат
---

Python:
def merge_sort(arr):
# База рекурсии
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)
def merge(left, right):
result = []
i = j = 0
# Сливаем два отсортированных массива
while i < len(left) and j < len(right):
if left[i] < right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
# Добавляем оставшиеся элементы
result.extend(left[i:])
result.extend(right[j:])
return result
---

C:
void merge(int arr[], int left, int mid, int right) {
int n1 = mid - left + 1;
int n2 = right - mid;
// Временные массивы
int L[n1], R[n2];
for (int i = 0; i < n1; i++) L[i] = arr[left + i];
for (int j = 0; j < n2; j++) R[j] = arr[mid + 1 + j];
int i = 0, j = 0, k = left;
// Сливаем L[] и R[] обратно в arr[]
while (i < n1 && j < n2) {
if (L[i] <= R[j])
arr[k++] = L[i++];
else
arr[k++] = R[j++];
}
// Добавляем оставшиеся элементы
while (i < n1) arr[k++] = L[i++];
while (j < n2) arr[k++] = R[j++];
}
void merge_sort(int arr[], int left, int right) {
if (left < right) {
int mid = left + (right - left) / 2;
merge_sort(arr, left, mid);
merge_sort(arr, mid + 1, right);
merge(arr, left, mid, right);
}
}
Пример использования:
C:
int main() {
int arr[] = {42, 15, 3, 23, 8, 5};
int n = sizeof(arr) / sizeof(arr[0]);
merge_sort(arr, 0, n - 1);
printf("Отсортированный массив: ");
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]);
}
printf("\n");
return 0;
}
Пример итерационного подхода, без рекурсии:
C:
#include <stdio.h>
/* ――― вспомогательная функция слияния двух отсортированных отрезков ――― */
void merge(int a[], int left_start, int left_end,
int right_start, int right_end)
{
int n_left = left_end - left_start + 1;
int n_right = right_end - right_start + 1;
/* временные массивы для копирования левой и правой частей */
int L[n_left], R[n_right];
for (int i = 0; i < n_left; ++i) L[i] = a[left_start + i];
for (int j = 0; j < n_right; ++j) R[j] = a[right_start + j];
/* сливаем обратно в a[left_start … right_end] */
int i = 0, j = 0, k = left_start;
while (i < n_left && j < n_right) {
if (L[i] <= R[j])
a[k++] = L[i++];
else
a[k++] = R[j++];
}
/* дописываем «хвосты», если остались */
while (i < n_left) a[k++] = L[i++];
while (j < n_right) a[k++] = R[j++];
}
/* ――― итеративная (без рекурсии) сортировка слиянием ――― */
void merge_sort(int a[], int n)
{
/* длина сливаемых блоков: 1, 2, 4, 8, … */
for (int block = 1; block < n; block *= 2) {
/* перебираем пары блоков по всему массиву */
for (int left_start = 0; left_start < n; left_start += 2 * block) {
int left_end = left_start + block - 1;
int right_start = left_end + 1;
int right_end = right_start + block - 1;
/* корректируем правую границу, чтобы не выйти за массив */
if (right_start >= n) continue; /* второй блока нет */
if (right_end >= n) right_end = n - 1;
/* объединяем два соседних блока */
merge(a,
left_start, left_end,
right_start, right_end);
}
}
}
/* ――― демонстрация ――― */
int main(void)
{
int data[] = {42, 15, 3, 23, 8, 5};
int n = sizeof data / sizeof data[0];
printf("До : ");
for (int i = 0; i < n; ++i) printf("%d ", data[i]);
puts("");
merge_sort(data, n);
printf("После: ");
for (int i = 0; i < n; ++i) printf("%d ", data[i]);
puts("");
return 0;
}
---


---
🛠 Когда использовать Merge Sort:
- Когда важна стабильность сортировки (сохраняет порядок равных элементов)
- Когда нужно предсказуемое время выполнения, независимо от входа
- Когда работаешь с связанными структурами (может быть реализован без выделения дополнительной памяти)
---
Quick Sort — быстрая сортировка

Рекурсивное разбиение массива
В среднем
O(n log n)
, в худшем —O(n²)
- ⏱ Часто используется как дефолтная сортировка в стандартных библиотеках

Python:
def quick_sort(arr):
if len(arr) <= 1:
return arr
pivot = arr[len(arr) // 2]
left = [x for x in arr if x < pivot]
mid = [x for x in arr if x == pivot]
right = [x for x in arr if x > pivot]
return quick_sort(left) + mid + quick_sort(right)

C:
void quick_sort(int arr[], int left, int right) {
if (left >= right) return;
int pivot = arr[(left + right) / 2];
int i = left, j = right;
while (i <= j) {
while (arr[i] < pivot) i++;
while (arr[j] > pivot) j--;
if (i <= j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
i++; j--;
}
}
quick_sort(arr, left, j);
quick_sort(arr, i, right);
}
Пример итерационного подхода, без рекурсии:
C:
#include <stdio.h>
/* свап без temp-макросов для простоты */
static inline void swap(int *a, int *b)
{
int t = *a; *a = *b; *b = t;
}
/* итеративный quick sort */
void quick_sort_iter(int a[], int n)
{
/* стек хранит пары [left, right] */
int stack[n];
int top = -1;
/* помещаем первый диапазон (весь массив) */
stack[++top] = 0;
stack[++top] = n - 1;
while (top >= 0)
{
/* извлекаем правую и левую границу */
int right = stack[top--];
int left = stack[top--];
if (left >= right) continue;
/* стандартное разбиение */
int pivot = a[(left + right) / 2];
int i = left, j = right;
while (i <= j)
{
while (a[i] < pivot) i++;
while (a[j] > pivot) j--;
if (i <= j)
{
swap(&a[i], &a[j]);
i++; j--;
}
}
/* класть в стек сначала большую половину — экономнее по памяти */
if (i < right) { /* правая часть */
stack[++top] = i;
stack[++top] = right;
}
if (left < j) { /* левая часть */
stack[++top] = left;
stack[++top] = j;
}
}
}
/* демонстрация */
int main(void)
{
int data[] = {42, 15, 7, 23, 3, 9, 31};
int n = sizeof data / sizeof data[0];
printf("До : ");
for (int k = 0; k < n; ++k) printf("%d ", data[k]);
puts("");
quick_sort_iter(data, n);
printf("После: ");
for (int k = 0; k < n; ++k) printf("%d ", data[k]);
puts("");
return 0;
}

---
Вывод
Код:
| Алгоритм | Когда использовать |
|------------------ |----------------------------------------------|
| Insertion Sort | маленькие массивы, почти отсортированные |
| Merge Sort | нужен гарантированный результат и стабильность |
| Quick Sort | максимальная скорость при средних данных |

Стек и очередь: фундаментальные структуры данных
Если ты всерьёз изучаешь алгоритмы — начни с основ.
Стек (stack) и очередь (queue) — две простые, но очень мощные структуры.
Они лежат в основе всего: от рекурсии до BFS, парсинга выражений и undo-функций.
Принцип: LIFO — Last In, First Out (последний пришёл — первый ушёл)
Представь стопку тарелок: ты кладёшь сверху — и снимаешь сверху.
Пример на Python:
Пример на C:
---
Принцип: FIFO — First In, First Out (первый пришёл — первый ушёл)
Как живая очередь: кто первый стал, тот и обслужен первым.
Пример на Python:
Пример на C:
---
---
Стек и очередь — это как гаечный ключ и отвёртка в наборе алгоритмиста.
Без них не пишется ни один парсер, ни одна поиск-алгоритм, ни одна работающая архитектура.
А ты помнишь, когда впервые написал свой стек? Или очередь? Делись историями 
Стек (stack) и очередь (queue) — две простые, но очень мощные структуры.
Они лежат в основе всего: от рекурсии до BFS, парсинга выражений и undo-функций.
Что такое стек (stack)?

Представь стопку тарелок: ты кладёшь сверху — и снимаешь сверху.
push(x)
— положить элементpop()
— снять последнийtop()
— посмотреть на верхний

Python:
stack = []
stack.append(10)
stack.append(20)
print(stack.pop()) # 20
print(stack[-1]) # 10 (top)

C:
#define MAX 100
int stack[MAX], top = -1;
void push(int x) { if (top < MAX - 1) stack[++top] = x; }
int pop() { return (top >= 0) ? stack[top--] : -1; }
int peek() { return (top >= 0) ? stack[top] : -1; }
---
🛤 А очередь (queue)?

Как живая очередь: кто первый стал, тот и обслужен первым.
enqueue(x)
— поставить в конецdequeue()
— взять с начала

Python:
from collections import deque
q = deque()
q.append(1) # enqueue
q.append(2)
print(q.popleft()) # 1
print(q[0]) # 2 (front)

C:
#define MAX 100
int queue[MAX], front = 0, rear = 0;
void enqueue(int x) {
if (rear < MAX) queue[rear++] = x;
}
int dequeue() {
return (front < rear) ? queue[front++] : -1;
}
---
Где используются стек и очередь
Парсеры выражений, интерпретаторы → стек
Undo/Redo в редакторах → стек
Поиск в ширину (BFS) → очередь
Менеджеры задач, буферы событий → очередь
Поддержка рекурсии → стек вызовов
Сравнение
Операция | Stack | Queue |
---|---|---|
Добавить элемент | push(x) | enqueue(x) |
Удалить элемент | pop() | dequeue() |
Порядок | LIFO | FIFO |
Типичная реализация | Массив / список | Кольцевой буфер / deque |
---
Вывод
Стек и очередь — это как гаечный ключ и отвёртка в наборе алгоритмиста.
Без них не пишется ни один парсер, ни одна поиск-алгоритм, ни одна работающая архитектура.


Списки, деревья, графы — в чём разница и где применяются
Список, дерево, граф — три кита, на которых стоит любая алгоритмическая логика.
Они встречаются везде: от браузеров и маршрутов до очередей, файлов и рекомендаций.Разберём их с нуля: как устроены, где применяются и как реализуются.
Список (List)
Линейная структура: каждый элемент знает своего соседа.

- Очереди задач
- Буферы сообщений
- История действий

| Операция | Сложность |
|----------------|-----------|
| Поиск по значению | O( n ) |
| Добавление в начало | O(1) |
| Добавление в конец (односвязный) | O( n ) |
---
Дерево (Tree)
Иерархическая структура: каждый узел может иметь несколько потомков.
Особый случай — двоичное дерево поиска (BST).
Пример BST:
Код:
10
/ \
5 15
/ \ \
2 7 20

- Файловые системы
- Словари и индексы
- Сжатие данных (Huffman)
- DOM-дерево

| Операция | Среднее | Худшее (несбаланс) |
|------------|--------- |------------------------|
| Поиск | O(log n) | O ( n ) |
| Вставка | O(log n) | O ( n ) |
| Удаление | O(log n) | O ( n ) |
---
Граф (Graph)
Гибкая структура, где вершины могут быть связаны как угодно.
Может содержать циклы, направленные и ненаправленные связи.
Пример графа:
Граф — это множество вершин (nodes) и рёбер (edges), которые соединяют эти вершины.
Бывает:
- Ориентированный (стрелки: A → B)
- Неориентированный (двусторонние связи: A — B)

- Навигация и маршруты
- Социальные сети (связи)
- Анализ зависимостей
- Поиск путей (Dijkstra, A*)

| Операция | Сложность |
|------------------ |---------------- |
| Добавление вершины | O(1) |
| Добавление ребра | O(1) |
| Поиск соседей | O(k) (где k — степень вершины) |
Сравнение структур
Свойство | Список | Дерево | Граф |
---|---|---|---|
Форма | Линейная | Иерархия | Сеть |
Связи | 1 на 1 | 1 ко многим | Произвольные |
Циклы | Нет | Нет | Да |
Поиск | O ( n ) | O(log n) | Зависит от алгоритма |
Применение | Очереди, undo | ФС, индексы | Маршруты, связи |
---
Вывод
Списки — простые и линейные, отлично подходят для очередей и стеков
Деревья — иерархии и быстрый поиск
Графы — универсальный инструмент для сетей и навигации
⚙ Операции: поиск, вставка и удаление в списках, деревьях и графах
Теперь добавим в практику: не просто структура, а как с ней работать.Ниже — реализации трёх базовых операций для списка, дерева и графа на Python и C.
---
Односвязный список

Python:
class Node:
def __init__(self, val):
self.val = val
self.next = None
def insert(head, value):
new_node = Node(value)
new_node.next = head
return new_node # новый head
def find(head, value):
while head:
if head.val == value:
return True
head = head.next
return False
def delete(head, value):
dummy = Node(0)
dummy.next = head
prev, curr = dummy, head
while curr:
if curr.val == value:
prev.next = curr.next
break
prev, curr = curr, curr.next
return dummy.next

C:
typedef struct Node {
int val;
struct Node* next;
} Node;
Node* insert(Node* head, int value) {
Node* n = malloc(sizeof(Node));
n->val = value;
n->next = head;
return n;
}
int find(Node* head, int value) {
while (head) {
if (head->val == value) return 1;
head = head->next;
}
return 0;
}
Node* delete(Node* head, int value) {
Node dummy = {.next = head};
Node* prev = &dummy;
Node* curr = head;
while (curr) {
if (curr->val == value) {
prev->next = curr->next;
free(curr);
break;
}
prev = curr;
curr = curr->next;
}
return dummy.next;
}
Двоичное дерево поиска (BST)

Python:
class Node:
def __init__(self, key):
self.key = key
self.left = self.right = None
def insert(root, key):
if not root:
return Node(key)
if key < root.key:
root.left = insert(root.left, key)
else:
root.right = insert(root.right, key)
return root
def search(root, key):
if not root or root.key == key:
return root
return search(root.left, key) if key < root.key else search(root.right, key)
def delete(root, key):
if not root:
return None
if key < root.key:
root.left = delete(root.left, key)
elif key > root.key:
root.right = delete(root.right, key)
else:
if not root.left:
return root.right
if not root.right:
return root.left
temp = root.right
while temp.left:
temp = temp.left
root.key = temp.key
root.right = delete(root.right, temp.key)
return root

C:
typedef struct Node {
int key;
struct Node *left, *right;
} Node;
Node* new_node(int key) {
Node* node = malloc(sizeof(Node));
node->key = key;
node->left = node->right = NULL;
return node;
}
Node* insert(Node* root, int key) {
if (!root) return new_node(key);
if (key < root->key)
root->left = insert(root->left, key);
else
root->right = insert(root->right, key);
return root;
}
Node* search(Node* root, int key) {
if (!root || root->key == key) return root;
return (key < root->key) ? search(root->left, key) : search(root->right, key);
}
Node* min_value_node(Node* node) {
while (node->left) node = node->left;
return node;
}
Node* delete(Node* root, int key) {
if (!root) return NULL;
if (key < root->key)
root->left = delete(root->left, key);
else if (key > root->key)
root->right = delete(root->right, key);
else {
if (!root->left) {
Node* temp = root->right;
free(root); return temp;
} else if (!root->right) {
Node* temp = root->left;
free(root); return temp;
}
Node* temp = min_value_node(root->right);
root->key = temp->key;
root->right = delete(root->right, temp->key);
}
return root;
}
---
Граф (ориентированный, список смежности)

Python:
graph = {}
def add_vertex(v):
if v not in graph:
graph[v] = []
def add_edge(u, v):
graph[u].append(v)
def remove_edge(u, v):
if v in graph[u]:
graph[u].remove(v)
def has_edge(u, v):
return v in graph.get(u, [])

C:
#define MAX 100
typedef struct Node {
int vertex;
struct Node* next;
} Node;
Node* adj[MAX];
void add_edge(int u, int v) {
Node* n = malloc(sizeof(Node));
n->vertex = v;
n->next = adj[u];
adj[u] = n;
}
int has_edge(int u, int v) {
Node* cur = adj[u];
while (cur) {
if (cur->vertex == v) return 1;
cur = cur->next;
}
return 0;
}
void remove_edge(int u, int v) {
Node** cur = &adj[u];
while (*cur) {
if ((*cur)->vertex == v) {
Node* tmp = *cur;
*cur = (*cur)->next;
free(tmp);
return;
}
cur = &((*cur)->next);
}
}
Пример работы с графом:
C:
#include <stdio.h>
#include <stdlib.h>
#define N 4 // кол-во вершин: A, B, C, D
typedef struct Node {
int vertex;
struct Node* next;
} Node;
Node* graph[N]; // массив смежности
void add_edge(int from, int to) {
Node* n = malloc(sizeof(Node));
n->vertex = to;
n->next = graph[from];
graph[from] = n;
}
void print_graph() {
char labels[] = {'A', 'B', 'C', 'D'};
for (int i = 0; i < N; i++) {
printf("%c -> ", labels[i]);
Node* temp = graph[i];
while (temp) {
printf("%c ", labels[temp->vertex]);
temp = temp->next;
}
printf("\n");
}
}
int main() {
add_edge(0, 1); // A → B
add_edge(0, 2); // A → C
add_edge(1, 3); // B → D
add_edge(2, 3); // C → D
print_graph();
return 0;
}
---
Вывод
Знать структуру — мало. Нужно уметь с ней работать:
добавлять, искать, удалять.
Список — простейшая динамика
Дерево — база для логарифмичного поиска
- ⚙ Граф — мощный инструмент моделирования отношений

Алгоритмы Дейкстры и A*: поиск кратчайшего пути
Как найти самый быстрый путь на карте? Как ИИ в игре находит путь до цели, обходя стены?
Ответ — в алгоритмах поиска кратчайшего пути. Сегодня разберём два самых популярных:

---
Что решают эти алгоритмы
И Дейкстра, и A* ищут путь из точки A в точку B по графу, где:
- Вершины — узлы (например, перекрёстки, клетки карты, точки маршрута)
- Рёбра — связи между ними с весами (расстояние, время, цена и т.д.)
Они гарантируют самый дешёвый путь, если веса неотрицательные.
---
Алгоритм Дейкстры (Dijkstra)
Простой и надёжный: на каждом шаге выбираем узел с минимальной стоимостью и обновляем соседей.
- Не использует эвристик
- Работает во всех графах без отрицательных весов
- Может найти все кратчайшие пути от одной вершины

Python:
import heapq
def dijkstra(graph, start):
dist = {v: float('inf') for v in graph}
dist[start] = 0
queue = [(0, start)]
while queue:
cost, u = heapq.heappop(queue)
if cost > dist[u]:
continue
for v, weight in graph[u]:
alt = dist[u] + weight
if alt < dist[v]:
dist[v] = alt
heapq.heappush(queue, (alt, v))
return dist

Python:
graph = {
'A': [('B', 1), ('C', 4)],
'B': [('C', 2), ('D', 5)],
'C': [('D', 1)],
'D': []
}
Код на Си:
C:
#include <stdio.h>
#include <limits.h>
#define V 4 // количество вершин
int min_distance(int dist[], int visited[]) {
int min = INT_MAX, index = -1;
for (int i = 0; i < V; i++) {
if (!visited[i] && dist[i] < min) {
min = dist[i];
index = i;
}
}
return index;
}
void dijkstra(int graph[V][V], int start) {
int dist[V];
int visited[V] = {0};
for (int i = 0; i < V; i++)
dist[i] = INT_MAX;
dist[start] = 0;
for (int count = 0; count < V - 1; count++) {
int u = min_distance(dist, visited);
visited[u] = 1;
for (int v = 0; v < V; v++) {
if (!visited[v] && graph[u][v] && dist[u] != INT_MAX &&
dist[u] + graph[u][v] < dist[v])
{
dist[v] = dist[u] + graph[u][v];
}
}
}
printf("Кратчайшие расстояния от вершины %d:\n", start);
for (int i = 0; i < V; i++)
printf("До %d = %d\n", i, dist[i]);
}
int main() {
// Матрица смежности (веса)
int graph[V][V] = {
{0, 1, 4, 0},
{0, 0, 2, 5},
{0, 0, 0, 1},
{0, 0, 0, 0}
};
dijkstra(graph, 0); // из вершины 0
return 0;
}
---
Алгоритм A* (A-star)
Это оптимизированная версия Дейкстры с «подсказками».
Он использует эвристику — оценку расстояния до цели.
Что такое эвристика в A*
Эвристика — это оценка оставшегося расстояния от текущей вершины до цели.Она помогает A* "предугадывать", где находится цель, и двигаться более умно, чем Дейкстра.

Код:
f(n) = g(n) + h(n)
g(n)
— расстояние от старта доn
(как у Дейкстры)h(n)
— приближённая оценка отn
до цели (например, Евклидово или Manhattan-расстояние)


Python:
import heapq
def a_star(graph, start, goal, heuristic):
open_set = [(0, start)]
g = {start: 0}
came_from = {}
while open_set:
_, current = heapq.heappop(open_set)
if current == goal:
break
for neighbor, cost in graph[current]:
tentative_g = g[current] + cost
if neighbor not in g or tentative_g < g[neighbor]:
g[neighbor] = tentative_g
f = tentative_g + heuristic(neighbor, goal)
heapq.heappush(open_set, (f, neighbor))
came_from[neighbor] = current
return g.get(goal, float('inf'))

Что происходит по шагам:
- g[n] — стоимость пути от старта до узла n (накапливаем её)
- heuristic(n, goal) — оценка до цели
- f = g + h — итоговая "стоимость" маршрута
- Используется heapq — приоритетная очередь, которая выбирает вершину с наименьшим f
Python:
def manhattan(node, goal):
x1, y1 = node
x2, y2 = goal
return abs(x1 - x2) + abs(y1 - y2)
Как вызывать:
Python:
res = a_star(graph, 'A', 'D', manhattan)
print("Минимальное расстояние A → D:", res)
Пример на Си, ориентирован на поле 5×5, где можно двигаться по 4 направлениям (вверх, вниз, влево, вправо), а 1 — проходимо, 0 — препятствие.
Код:
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <limits.h>
#define N 5
typedef struct {
int x, y;
int g; // стоимость от старта
int f; // общая стоимость (g + h)
} Node;
int goal_x = 4, goal_y = 4;
int heuristic(int x, int y) {
return abs(goal_x - x) + abs(goal_y - y); // Манхэттен
}
int in_bounds(int x, int y) {
return x >= 0 && x < N && y >= 0 && y < N;
}
int is_passable(int grid[N][N], int x, int y) {
return grid[x][y] == 1;
}
// простая очередь с приоритетом (по f)
void push(Node* open[], int* size, Node* n) {
int i = *size;
while (i > 0 && open[i - 1]->f > n->f) {
open[i] = open[i - 1];
i--;
}
open[i] = n;
(*size)++;
}
Node* pop(Node* open[], int* size) {
return open[--(*size)];
}
void a_star(int grid[N][N], int start_x, int start_y) {
int closed[N][N] = {0};
int came_from[N][N][2];
Node* open[100];
int open_size = 0;
Node* start = malloc(sizeof(Node));
start->x = start_x;
start->y = start_y;
start->g = 0;
start->f = heuristic(start_x, start_y);
push(open, &open_size, start);
int dirs[4][2] = {{0,1},{1,0},{0,-1},{-1,0}};
while (open_size > 0) {
Node* current = pop(open, &open_size);
if (current->x == goal_x && current->y == goal_y) {
printf("Путь найден:\n");
int x = goal_x, y = goal_y;
while (!(x == start_x && y == start_y)) {
printf("(%d, %d) <- ", x, y);
int px = came_from[x][y][0];
int py = came_from[x][y][1];
x = px;
y = py;
}
printf("(%d, %d)\n", start_x, start_y);
return;
}
closed[current->x][current->y] = 1;
for (int i = 0; i < 4; i++) {
int nx = current->x + dirs[i][0];
int ny = current->y + dirs[i][1];
if (!in_bounds(nx, ny) || !is_passable(grid, nx, ny) || closed[nx][ny])
continue;
int new_g = current->g + 1;
Node* neighbor = malloc(sizeof(Node));
neighbor->x = nx;
neighbor->y = ny;
neighbor->g = new_g;
neighbor->f = new_g + heuristic(nx, ny);
came_from[nx][ny][0] = current->x;
came_from[nx][ny][1] = current->y;
push(open, &open_size, neighbor);
}
}
printf("Путь не найден.\n");
}
Использование:
C:
int main() {
int grid[N][N] = {
{1, 1, 1, 1, 1},
{0, 0, 0, 1, 0},
{1, 1, 1, 1, 1},
{1, 0, 0, 0, 1},
{1, 1, 1, 1, 1}
};
a_star(grid, 0, 0);
return 0;
}
---
Сравнение: Дейкстра vs A*
Критерий | Дейкстра | A* |
---|---|---|
Эвристика | Нет | Да |
Оптимальность | Гарантирована | Если эвристика допустима |
Скорость | Медленнее | Быстрее на практике |
Подходит для | Все кратчайшие пути | Целевой путь (start → goal) |
Гарантирует точный путь | Да | Да (если h не переоценивает) |
---
Где применяются?

- Маршруты в GPS (все пути из одной точки)
- Планирование в графах без эвристик
- Сети и маршрутизаторы

- Искусственный интеллект в играх
- Роботы и дроны (путь от A до B)
- Динамическая навигация
Вывод
Алгоритмы поиска кратчайшего пути — основа навигации, планирования, анализа маршрутов.
- Дейкстра — надёжен, универсален
- A\* — быстрее, если есть эвристика


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

---
Классический пример: сдача монетами
Жадный алгоритм:
- берёт по максимуму из крупных: 4
- остаётся 2 → берёт два по 1

Но оптимально: 3 + 3 = 2 монеты
→ жадность здесь не сработала
---
Где жадность работает отлично



---
Пример: выбор непересекающихся отрезков на Python
Python:
intervals = [(1, 3), (2, 5), (4, 6), (7, 9)]
intervals.sort(key=lambda x: x[1]) # сортировка по правому краю
res = []
end = 0
for start, finish in intervals:
if start >= end:
res.append((start, finish))
end = finish
print("Выбранные интервалы:", res)

Код:
Выбранные интервалы: [(1, 3), (4, 6), (7, 9)]
---
То же самое на C
C:
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int start, end;
} Interval;
int cmp(const void* a, const void* b) {
return ((Interval*)a)->end - ((Interval*)b)->end;
}
int main() {
Interval intervals[] = {{1,3},{2,5},{4,6},{7,9}};
int n = sizeof(intervals)/sizeof(intervals[0]);
qsort(intervals, n, sizeof(Interval), cmp);
int end = 0;
for (int i = 0; i < n; i++) {
if (intervals[i].start >= end) {
printf("(%d, %d)\n", intervals[i].start, intervals[i].end);
end = intervals[i].end;
}
}
return 0;
}
Вывод
Жадные алгоритмы — это "алгоритмический инстинкт".Они хороши, когда каждое локальное решение приближает к глобальной цели.
Но они не всегда универсальны — будь внимателен: там, где жадность не гарантирует оптимум, лучше использовать динамику или полный перебор.

Хеш-таблицы: как работают и зачем нужны
Если ты хочешь мгновенный доступ к данным по ключу, — забудь про списки и переборы.
Тебе нужны хеш-таблицы — одна из самых быстрых структур данных.---
Что такое хеш-таблица?
Хеш-таблица (или хеш-таблица, hash map) — это структура данных, которая:
- хранит пары
ключ → значение
- ищет, вставляет и удаляет за O(1) в среднем
- основана на хеш-функции — преобразовании ключа в индекс массива
⚙ Как работает
1. Ключ (например, строка "user42") подаётся в хеш-функцию
2. Хеш-функция возвращает число (индекс) в массиве
3. Значение сохраняется в этом месте
---
Пример на Python (встроенный dict)
Python:
# Создание хеш-таблицы
table = {}
# Вставка
table["user42"] = "Alice"
table["user17"] = "Bob"
# Чтение
print(table["user42"]) # Alice
# Удаление
del table["user17"]

dict
.---
Простейшая реализация хеш-таблицы на Python
Python:
class HashTable:
def __init__(self, size=10):
self.size = size
self.table = [[] for _ in range(size)]
def _hash(self, key):
return hash(key) % self.size
def put(self, key, value):
idx = self._hash(key)
for i, (k, v) in enumerate(self.table[idx]):
if k == key:
self.table[idx][i] = (key, value)
return
self.table[idx].append((key, value))
def get(self, key):
idx = self._hash(key)
for k, v in self.table[idx]:
if k == key:
return v
return None
---
Простая хеш-таблица на C (цепочки)
C:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define SIZE 10
typedef struct Item {
char* key;
char* value;
struct Item* next;
} Item;
Item* table[SIZE];
int hash(const char* key) {
int sum = 0;
while (*key) sum += *key++;
return sum % SIZE;
}
void put(const char* key, const char* value) {
int idx = hash(key);
Item* node = malloc(sizeof(Item));
node->key = strdup(key);
node->value = strdup(value);
node->next = table[idx];
table[idx] = node;
}
const char* get(const char* key) {
int idx = hash(key);
Item* node = table[idx];
while (node) {
if (strcmp(node->key, key) == 0)
return node->value;
node = node->next;
}
return NULL;
}

Как устроены хорошие хеш-таблицы: правила, размер, хеш-функции
Хеш-таблицы (hash tables) — мощная структура, но только если она правильно настроена.
Здесь разберём, какой размер таблицы выбрать, как выбрать хеш-функцию, и что такое коллизии и когда нужно рехешировать.
Размер таблицы: больше — не значит лучше

table_size ≈ 2 * num_elements
---
Почему размер должен быть простым или нечётным
Если используешь:
Код:
index = hash(key) % table_size
и
table_size
— степень двойки (например, 64, 128),то плохая хеш-функция создаёт массовые коллизии.

- Хорошо: простое число (101, 1009, 10007)
- Неплохо: нечётное число (99, 199)
- Плохо: 64, 128, 256, 512 (степени 2)
⚙ Лучшая хеш-функция — быстрая и равномерная
Хеш-функция должна быть:
- Быстрой
- Давать равномерное распределение
- Учитывать все биты ключа

C:
unsigned long hash(const char *str) {
unsigned long h = 5381;
int c;
while ((c = *str++))
h = ((h << 5) + h) + c; // h * 33 + c
return h;
}
---

Показатель заполненности:
load_factor = count / table_size
Если
load_factor > 0.7
— пора увеличивать таблицу.
- Создаём новый массив в 2× больше
- Перехешируем все ключи в новую таблицу


- Проще реализовать
- Удаление проще
- Подходят при большом количестве коллизий

- Экономия памяти (нет указателей)
- Сложнее удаление
- Работает хорошо при загрузке < 0.7
Золотые правила хеш-таблицы
Размер таблицы ≈ 2× записей
Размер — простое или нечётное число
- ⚙ Хорошая хеш-функция (djb2, murmur, siphash)
Рехешировать при загрузке > 0.7
Продумать стратегию разрешения коллизий (цепочки или адресация)
Вывод
Хеш-таблица даёт O(1) доступ, только если ты правильно подобрал:
- размер,
- хеш-функцию,
- коллизионную стратегию.
---
Где используются хеш-таблицы
- Реализация
dict
,set
в Python - Поиск по ключу в словарях, индексах, БД
- Кеши и быстрый доступ к данным
- Поиск уникальных элементов и частот
- Хеширование паролей (в связке с криптографией)
Сложность операций
---
Вывод
Хеш-таблицы — один из мощнейших инструментов в арсенале программиста.В задачах, где нужен быстрый доступ по ключу, они дают практически мгновенный поиск.
