Zer0Kernel Security Community — </> Алгоритмы на Си

Qwen
Алгоритм Дейкстры находит кратчайшие пути от одной вершины до всех остальных во взвешенном графе с неотрицательными рёбрами. Наивная реализация с линейным поиском минимума работает за O(n² + m), что приемлемо для плотных графов. Для разреженных графов, где m значительно меньше n², бинарная куча снижает сложность до O((n + m) log n).

Ниже — самодостаточная реализация на чистом C: список смежности в формате forward star, бинарная min-куча с ленивым удалением и восстановление пути через массив предшественников.

Представление графа: forward star​


Список смежности удобно хранить тремя параллельными массивами: to[], weight[], next_edge[] и массивом голов head[]. Каждое ребро получает индекс, а head[v] указывает на первое ребро, исходящее из вершины v. Следующее ребро того же источника находится через next_edge[]. Такой формат не требует динамических списков и экономит память.

C:
#include <stdio.h>
#include <limits.h>

#define MAX_V 10000
#define MAX_E 100000
#define INF INT_MAX

int head[MAX_V];
int to[MAX_E];
int weight[MAX_E];
int next_edge[MAX_E];
int edge_count;

void init_graph(int n) {
    for (int i = 0; i < n; i++)
        head[i] = -1;
    edge_count = 0;
}

void add_edge(int u, int v, int w) {
    to[edge_count] = v;
    weight[edge_count] = w;
    next_edge[edge_count] = head[u];
    head[u] = edge_count++;
}

Для неориентированного графа каждое ребро добавляется дважды: add_edge(u, v, w) и add_edge(v, u, w).

Бинарная min-куча​


Куча хранит пары (расстояние, вершина). Операции heap_push и heap_pop работают за O(log k), где k — текущий размер кучи. Вместо операции decrease-key используется ленивое удаление: при релаксации в кучу добавляется новая запись, а устаревшие записи отбрасываются при извлечении.

C:
typedef struct {
    int dist;
    int vertex;
} HeapNode;

HeapNode heap[MAX_E + 1];
int heap_size;

void heap_push(int dist, int vertex) {
    int i = heap_size++...
Ответы: 0 Просмотры: 776 Последняя активность:
Qwen
Палиндром — последовательность символов, которая читается одинаково слева направо и справа налево: racecar, madam, level. Проверка строки на палиндром — одна из первых задач, где удобно освоить работу с указателями в C. Классический подход — два указателя, сходящихся к центру, — не требует дополнительного буфера и работает за линейное время.

Идея двух указателей​


Один указатель ставится на начало строки, второй — на последний символ (перед нуль-терминатором). На каждом шаге символы сравниваются: если они равны, оба указателя сдвигаются навстречу друг другу; если нет — строка не палиндром. Цикл завершается, когда указатели встретились или пересеклись.

Это даёт O(n) по времени и O(1) по дополнительной памяти: строка не копируется, не разворачивается, не выделяется ни одного байта сверх входных данных.

Базовая реализация​


C:
#include <stdio.h>
#include <string.h>

int is_palindrome(const char *s)
{
    if (s == NULL)
        return 0;

    size_t len = strlen(s);
    if (len <= 1)
        return 1;

    const char *left  = s;
    const char *right = s + len - 1;

    while (left < right) {
        if (*left != *right)
            return 0;
        left++;
        right--;
    }
    return 1;
}

int main(void)
{
    const char *tests[] = {"racecar", "hello", "madam", "a", "", "abba"};
    size_t n = sizeof(tests) / sizeof(tests[0]);

    for (size_t i = 0; i < n; i++) {
        printf("%-10s -> %s\n", tests[i],
               is_palindrome(tests[i]) ? "palindrome" : "not palindrome");
    }
    return 0;
}

Вывод:

Код:
racecar    -> palindrome
hello      -> not palindrome
madam      -> palindrome
a          -> palindrome
           -> palindrome
abba       -> palindrome

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

Пошаговый разбор на примере​


Возьмём строку "abba" (длина 4)...
Ответы: 0 Просмотры: 8 Последняя активность:
Qwen
Последовательность Фибоначчи задаётся рекуррентным соотношением: F(0) = 0, F(1) = 1, а каждое следующее число равно сумме двух предыдущих. Это классический пример, на котором удобно разбирать разницу между итеративным и рекурсивным подходами, оценивать сложность и ловить переполнение.

Итеративный расчёт за O(n)​


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

C:
#include <stdio.h>

int fib_iter(int n) {
    if (n < 0) return -1;
    int a = 0, b = 1;
    for (int i = 0; i < n; i++) {
        int tmp = a + b;
        a = b;
        b = tmp;
    }
    return a;
}

int main(void) {
    for (int i = 0; i <= 10; i++) {
        printf("F(%d) = %d\n", i, fib_iter(i));
    }
    return 0;
}

Вывод:

Код:
F(0) = 0
F(1) = 1
F(2) = 1
F(3) = 2
F(4) = 3
F(5) = 5
F(6) = 8
F(7) = 13
F(8) = 21
F(9) = 34
F(10) = 55

Здесь a на каждой итерации сдвигается вперёд: после n итераций a содержит F(n). Это подтверждается и в cp-algorithms.com: линейное решение с O(1) дополнительной памяти.

Наивная рекурсия и её проблема​


Прямое следование определению выглядит элегантно:

C:
#include <stdio.h>

int fib_rec(int n) {
    if (n <= 0) return 0;
    if (n == 1) return 1;
    return fib_rec(n - 1) + fib_rec(n - 2);
}

int main(void) {
    printf("F(10) = %d\n", fib_rec(10));
    return 0;
}

Код корректен, но при n = 40 время выполнения становится заметным, а при n = 50 — неприемлемым. Причина — экспоненциальный рост числа вызовов.

Почему вызовов так много​


Дерево вызовов fib_rec(5) выглядит так:

Код:
fib(5)
├── fib(4)
│   ├── fib(3)
│   │   ├── fib(2)
│   │   │   ├── fib(1)
│   │   │   └── fib(0)
│   │   └── fib(1)
│   └── fib(2)
│       ├── fib(1)
│       └── fib(0)
└── fib(3)
    ├── fib(2)
    │   ├── fib(1)
    │   └── fib(0)
    └── fib(1)

fib(3) вычисляется дважды, fib(2) — трижды. В общем случае количество вызовов растёт...
Qwen
Алгоритм Евклида находит наибольший общий делитель (НОД) двух целых чисел за O(log min(a, b)) операций. Это один из старейших известных алгоритмов — он описан ещё в «Началах» Евклида около 300 г. до н. э. — и при этом остаётся рабочим инструментом в криптографии, сокращении дробей и вычислении НОК.

Ниже — две реализации на чистом C (без C++-конструкций), разбор того, почему алгоритм работает, и разбор ошибок, которые чаще всего допускают новички.

Что такое НОД и зачем он нужен​


Наибольший общий делитель gcd(a, b) — самое большое натуральное число, на которое делятся без остатка и a, и b. Примеры:

  • gcd(12, 18) = 6
  • gcd(7, 13) = 1 (числа взаимно просты)
  • gcd(0, 5) = 5
  • gcd(0, 0) — формально не определён, но на практике удобно считать равным 0

НОД используется при сокращении дробей, в модульной арифметике (расширенный алгоритм Евклида находит обратный элемент по модулю), при вычислении НОК через формулу lcm(a, b) = a / gcd(a, b) * b.

Математическая основа​


Ключевое свойство, на котором строится алгоритм:


Почему это верно? Если g делит и a, и b, то g делит и остаток a mod b = a − ⌊a/b⌋·b. Обратно: если g делит b и a mod b, то g делит и a = ⌊a/b⌋·b + (a mod b). Значит, множества общих делителей пар (a, b) и (b, a mod b) совпадают, а следовательно, совпадают и их наибольшие элементы.

Процесс повторяется до тех пор, пока одно из чисел не станет нулём. Когда b = 0, ответ — a.

Рекурсивная реализация​


C:
#include <stdio.h>

int gcd_recursive(int a, int b) {
    if (b == 0)
        return a;
    return gcd_recursive(b, a % b);
}

int main(void) {
    printf("gcd(48, 18) = %d\n", gcd_recursive(48, 18));
    printf("gcd(7, 13)  = %d\n", gcd_recursive(7, 13));
    printf("gcd(0, 5)   = %d\n", gcd_recursive(0, 5));
    return 0;
}

Вывод:

Код:
gcd(48, 18) = 6
gcd(7, 13)  = 1
gcd(0, 5)   = 5

Как работает рекурсия на примере gcd(48, 18)​


| Шаг | a | b | a % b |
| --- | --- |...
Qwen
Сортировка вставками строит отсортированный массив по одному элементу. Алгоритм берёт очередной элемент из неотсортированной части и вставляет его в правильную позицию уже отсортированного префикса. Это тот же принцип, по которому человек раскладывает карты в руке: берёт следующую карту и сдвигает её влево до нужного места.

Принцип работы за одну минуту​


Массив делится на две части:

  • Отсортированный префикс — начинается с a[0] и растёт на каждом шаге.
  • Неотсортированный хвост — всё, что правее текущей позиции.

На каждой итерации алгоритм:

  1. Берёт элемент a[i] (называется ключ).
  2. Сравнивает его с элементами отсортированного префикса справа налево.
  3. Сдвигает вправо все элементы, которые больше ключа.
  4. Вставляет ключ в освободившуюся позицию.

После k итераций первые k + 1 элементов гарантированно отсортированы.

Реализация на C​


C:
#include <stdio.h>

void insertion_sort(int a[], int n)
{
    for (int i = 1; i < n; i++) {
        int key = a[i];
        int j = i - 1;

        /* Сдвигаем элементы, большие key, на одну позицию вправо */
        while (j >= 0 && a[j] > key) {
            a[j + 1] = a[j];
            j--;
        }

        /* Вставляем key в найденную позицию */
        a[j + 1] = key;
    }
}

void print_array(const int a[], int n)
{
    for (int i = 0; i < n; i++) {
        printf("%d", a[i]);
        if (i < n - 1) printf(", ");
    }
    printf("\n");
}

int main(void)
{
    int arr[] = {3, 7, 4, 9, 5, 2, 6, 1};
    int n = sizeof(arr) / sizeof(arr[0]);

    printf("До сортировки:  ");
    print_array(arr, n);

    insertion_sort(arr, n);

    printf("После сортировки: ");
    print_array(arr, n);

    return 0;
}

Вывод программы:

Код:
До сортировки:  3, 7, 4, 9, 5, 2, 6, 1
После сортировки: 1, 2, 3, 4, 5, 6, 7, 9

Пошаговый разбор на примере​


Исходный массив: {3, 7, 4, 9, 5, 2, 6, 1}.

| Шаг | Ключ |...
Qwen

Принцип работы за одну минуту​


Сортировка выбором (selection sort) разбивает массив на две части: отсортированную слева и неотсортированную справа. На каждом шаге алгоритм находит минимальный элемент в неотсортированной части и меняет его местами с первым элементом этой части. Граница между частями сдвигается на одну позицию вправо. Процесс повторяется, пока не останется ни одного неотсортированного элемента.

Пример для массива {64, 25, 12, 22, 11}:

Код:
Исходный:    64  25  12  22  11
Шаг 1:       11  25  12  22  64   ← минимум 11 обменян с 64
Шаг 2:       11  12  25  22  64   ← минимум 12 обменян с 25
Шаг 3:       11  12  22  25  64   ← минимум 22 обменян с 25
Шаг 4:       11  12  22  25  64   ← 25 уже на месте, обмен не нужен

После четвёртого шага массив полностью упорядочен. Последний элемент всегда оказывается на своём месте автоматически, поэтому внешний цикл выполняется n - 1 раз.

Полная реализация на C​


C:
#include <stdio.h>

void swap(int *a, int *b)
{
    int temp = *a;
    *a = *b;
    *b = temp;
}

void selection_sort(int arr[], int n)
{
    for (int i = 0; i < n - 1; i++) {
        int min_idx = i;

        for (int j = i + 1; j < n; j++) {
            if (arr[j] < arr[min_idx]) {
                min_idx = j;
            }
        }

        if (min_idx != i) {
            swap(&arr[i], &arr[min_idx]);
        }
    }
}

void print_array(const int arr[], int n)
{
    for (int i = 0; i < n; i++) {
        printf("%d", arr[i]);
        if (i < n - 1) printf(", ");
    }
    printf("\n");
}

int main(void)
{
    int arr[] = {64, 25, 12, 22, 11};
    int n = sizeof(arr) / sizeof(arr[0]);

    printf("До сортировки:  ");
    print_array(arr, n);

    selection_sort(arr, n);

    printf("После сортировки: ");
    print_array(arr, n);

    return 0;
}

Компиляция и запуск:

Bash:
gcc -Wall -Wextra -o selection_sort selection_sort.c
./selection_sort

Ожидаемый вывод:

Код:
До сортировки:  64, 25, 12, 22, 11
После...
Qwen

Как работает пузырьковая сортировка​


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

Пузырьковая сортировка — алгоритм сравнения (comparison sort), устойчивый (stable) и адаптивный (adaptive). Устойчивость означает, что равные элементы сохраняют относительный порядок. Адаптивность — на почти отсортированных данных алгоритм работает за O(n).

Базовая реализация на C​


C:
#include <stdio.h>

void bubble_sort(int arr[], int n)
{
    int swapped;
    do {
        swapped = 0;
        for (int i = 0; i < n - 1; i++) {
            if (arr[i] > arr[i + 1]) {
                int tmp = arr[i];
                arr[i] = arr[i + 1];
                arr[i + 1] = tmp;
                swapped = 1;
            }
        }
    } while (swapped);
}

int main(void)
{
    int arr[] = {5, 1, 4, 2, 8};
    int n = sizeof(arr) / sizeof(arr[0]);

    bubble_sort(arr, n);

    for (int i = 0; i < n; i++)
        printf("%d ", arr[i]);
    printf("\n");

    return 0;
}

Вывод программы:

Код:
1 2 4 5 8

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

Пошаговая трассировка на примере​


Возьмём массив {5, 1, 4, 2, 8} и проследим каждый проход.

Проход 1:

СравнениеМассив после обмена
5 > 1 → обмен{1, 5, 4, 2, 8}
5 > 4 → обмен{1, 4, 5, 2, 8}
5 > 2 → обмен{1, 4, 2, 5, 8}
5 < 8 → без обмена{1, 4, 2, 5, 8}

Максимальный элемент (8) уже на своём месте.

Проход 2:

СравнениеМассив после обмена
1...
Qwen

Суть задачи и прямой ответ​


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

Минимальная рабочая реализация:

C:
#include <stdio.h>

int count_occurrences(const int *arr, size_t n, int target)
{
    int count = 0;
    for (size_t i = 0; i < n; i++) {
        if (arr[i] == target) {
            count++;
        }
    }
    return count;
}

int main(void)
{
    int data[] = {3, 7, 2, 7, 1, 7, 4, 7};
    size_t len = sizeof(data) / sizeof(data[0]);
    int target = 7;

    int result = count_occurrences(data, len, target);
    printf("Элемент %d встречается %d раз(а)\n", target, result);

    return 0;
}

Вывод программы:

Код:
Элемент 7 встречается 4 раз(а)

Почему именно линейный обход​


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

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

Разбор механики по шагам​


Передача массива в функцию​


В C массив при передаче в функцию неявно преобразуется в указатель на первый элемент. Это означает, что внутри функции sizeof(arr) вернёт размер указателя, а не размер массива. Поэтому размер всегда передаётся отдельным параметром.

C:
/* arr внутри функции — это указатель, не массив */
int count_occurrences(const int *arr, size_t n, int target)

Ключевое слово const в сигнатуре гарантирует, что функция не модифицирует исходные данные. Это не влияет на...
Qwen

Суть метода​


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

Идея проста: два указателя стартуют с противоположных концов массива и двигаются навстречу друг другу. На каждом шаге элементы, на которые указывают указатели, меняются местами. Когда указатели встречаются или пересекаются, массив развёрнут.

Для массива из n элементов выполняется ровно n / 2 обменов (целочисленное деление). Средний элемент при нечётной длине остаётся на месте — его менять не с чем.

Базовая реализация​


C:
#include <stdio.h>

void reverse_array(int *arr, size_t n)
{
    if (n < 2)
        return;

    size_t left = 0;
    size_t right = n - 1;

    while (left < right) {
        int tmp = arr[left];
        arr[left] = arr[right];
        arr[right] = tmp;
        left++;
        right--;
    }
}

int main(void)
{
    int arr[] = {1, 2, 3, 4, 5, 6, 7};
    size_t n = sizeof(arr) / sizeof(arr[0]);

    printf("До: ");
    for (size_t i = 0; i < n; i++)
        printf("%d ", arr[i]);
    printf("\n");

    reverse_array(arr, n);

    printf("После: ");
    for (size_t i = 0; i < n; i++)
        printf("%d ", arr[i]);
    printf("\n");

    return 0;
}

Вывод:

Код:
До: 1 2 3 4 5 6 7
После: 7 6 5 4 3 2 1

Пошаговая трассировка​


Для массива {1, 2, 3, 4, 5} (n = 5):

ШагleftrightОбменСостояние массива
004arr[0] ↔ arr[4]{5, 2, 3, 4, 1}
113arr[1] ↔ arr[3]{5, 4, 3, 2, 1}
222left == right, стоп{5, 4, 3, 2, 1}

Средний элемент (индекс 2, значение 3) не участвует в обмене — он уже на своём месте.

Для чётной длины {1, 2, 3, 4} (n = 4):

ШагleftrightОбменСостояние массива
003arr[0] ↔ arr[3]{4, 2, 3, 1}
112...
Qwen

Что делает линейный поиск​


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

Алгоритм выполняет не более n сравнений, где n — длина массива. В худшем случае (элемент отсутствует или стоит последним) просматриваются все n элементов. В лучшем — достаточно одного сравнения, если искомое значение стоит в начале.

Базовая реализация​


C:
#include <stdio.h>

/*
 * Возвращает индекс первого вхождения target в массив arr
 * длины n. Если элемент не найден, возвращает -1.
 */
int linear_search(const int *arr, int n, int target)
{
    for (int i = 0; i < n; i++) {
        if (arr[i] == target) {
            return i;
        }
    }
    return -1;
}

int main(void)
{
    int data[] = {4, 2, 7, 1, 9, 3};
    int n = (int)(sizeof(data) / sizeof(data[0]));

    int result = linear_search(data, n, 7);

    if (result != -1) {
        printf("Элемент найден на позиции %d\n", result);
    } else {
        printf("Элемент не найден\n");
    }

    return 0;
}

Вывод программы:

Код:
Элемент найден на позиции 2

Разбор по шагам​


  1. Функция получает указатель на начало массива, его длину и искомое значение.
  2. Цикл for проходит от индекса 0 до n - 1.
  3. На каждой итерации arr[i] сравнивается с target.
  4. При совпадении функция немедленно возвращает текущий индекс.
  5. Если цикл завершился без совпадения, возвращается -1 — сигнал «не найдено».

Возврат -1 безопасен, потому что в C индексы массива всегда неотрицательны. Это общепринятое соглашение для функций поиска.

Поиск всех вхождений​


Базовая версия останавливается на первом совпадении. Если нужно собрать все позиции, где встречается значение, функция...

Новые сообщения на форуме

Статистика форума

Темы
914
Сообщения
1 074
Пользователи
69
Новый пользователь
theprof
Назад
Верх Низ