Алгоритмы

Краткое описание

Алгоритм Заяц и Черепаха

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

Что из этого можно получить?

В магию математики погружаться не будем...

1. Найти середину списка

Если заяц доскакал до конца, то черепаха будет как раз на середине. Красота!

Возвращаем указатель "черепаха" вот вам и решение задачи.

Поиск среднего 1 Поиск среднего 2

2. Определить цикличен список или нет?

Опять запускаем наших животных и ждем когда они встретятся. А они точно встретятся (магия математики).

Т.е. если последний узел зациклен на любой из узлов списка, то заяц обязательно войдет в цикл и догонит черепаху.

И вот если два указателя показывают на одно и то же место, то список однозначно зациклен.

Цикличный список

3. Определить начала цикла.

Если наш список зациклен, то исходя их пункта 2 мы можем узнать где "встретились" черепаха и заяц.

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

Не верите, проверьте! Магия работает!!!

4. Определить последний элемент замкнутого списка

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

И еще парочка задач, когда дано два списка

1. Узнать пересекаются они или нет?

Тут сначала проходим эти списки до конца, если конец один и тот же элемент значит пересекаются.

Пересечение списков

2. Если пресекаются, то где?

А тут опять магия, берем "хвост" и зацикливаем его на одну из голов.

А со второй ищем первую точку цикла - которая и есть точка пересечения.

Только после не забывайте убрать цикл с хвоста, что бы чего не вышло...

Полиномиальные хеши

Теория

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

Представим, что у нас есть строка, где si - это символ (или его код)

s0s1s2s3s4...sn

Считаем полиномиальный хеш, где p - это основание хеша

h[0,n] = s0p0 + s1p1 + s2p2 + s3p3 + s4p4 + ... + snpn

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

Далее если мы хотим посчитать хеш подстроки с индекса l до индекса r, для примера пусть l=1 и r=3, он будет выглядеть так:

h[1,3] = s1p0 + s2p1 + s3p2

т.е. это не что иное, как этот кусок на p1

h[0,n] = s0p0 + [s1p1 + s2p2 + s3p3] + s4p4 + ... snpn

Таким не сложным образом мы можем получить форму для вычисления хеша подстроки

h[k,m] = (h[0,m] - h[0,k-1]) / pk

Но как это работает при больших n?

Например, если p=29, то в уже в 15й степени длинна числа будем 22 символа, а в лонге всего 19 (если мне память не изменяет). Т.е. реализовав это в "как есть" у нас не получится полностью рабочий вариант для больших n.

B тут включается магия математики мы проводим операции по модулю.

Реализация

Первое что надо сдетать это определить основание P и модуль MODULE.
Это два простых числа, первое маленькое, второе - большое.

private static final int P = 29;
private static final long MODULE = 1_000_000_007L;

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

private static void initPower(int n) {
    power = new long[n + 1];
    power[0] = 1;
    for (int i = 1; i < n + 1; i++) {
        power[i] = (power[i - 1] * P) % MODULE;
    }
}

Тут уже используется модуль.

Теперь считаем и заполняем массив префиксных хэшей для того с чем будем работать:

private static void buildPrefixHashArray(String s) {
    prefixHashArray = new long[s.length() + 1];
    for (int i = 1; i < s.length() + 1; i++) {
        prefixHashArray[i] = (prefixHashArray[i - 1] + (s.charAt(i - 1) - 'a' + 1) * power[i - 1]) % MODULE;
    }
}

Запись "char-'a'+1" тут использована просто для получения порядкового номера символа в алфавите, т.к. в моей задаче строка состоит только из маленьких английских букв. Но использование кода символа тоже работает.

Так же нам понадобится просо вычислять хеш строки:

private static long calculateHash(String s) {
    long hash = 0;
    int p = 0;
    for (char c : s.toCharArray()) {
        hash = (hash + getCharPowerValue(c) * power[p++]) % MODULE;
    }
    return hash;
}

И последнее - надо научится получать хеш подстроки, а это достаточно просто:

private static long getIntervalHash(int startIndex, int finishIndex) {
    long sub = prefixHashArray[finishIndex] - prefixHashArray[startIndex];
    return sub > -1 ? sub : sub + MODULE;
}

Обратите внимание на то, как метод getIntervalHash возвращает ответ - это магия циклических колец. Этот момент я долго не мог понять, а поэтому и не учитывал в коде.

Теперь у нас есть все инструменты, что бы сравнивать строки или подстроки.

Один из источников вдохновения.

Краткое резюме от ИИ

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

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

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

Устойчивость к коллизиям:
При правильно выбранных параметрах (в том числе модуле и базе) вероятность коллизий хешей низка. Это делает подход надежным на практике.

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

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

Алгоритм FIND-UNION

Основные операции

  1. Find (Найти):
    Определяет, к какому множеству принадлежит элемент. Это может быть использовано для проверки принадлежности двух элементов одному множеству.

  2. Union (Объединить):
    Объединяет два множества в одно.

Основные концепции

  • Репрезентатив (Представитель):
    Каждый элемент множества имеет некоторое представительное значение (обычно голову дерева), которое является корнем дерева представления данного множества.

  • Ранговое дерево и сжатие путей:
    Используются для оптимизации работы алгоритма. Сжатие путей помогает уменьшить глубину дерева, быстро находя представителя, а ранговое дерево помогает поддерживать низкую высоту дерева при объединении.

Реализация

Реализуем это как отдельную структуру данных, т.е. сделаем отдельный класс

Конструктор

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

    private int[] parent;
    private int[] rank;

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

    public UnionFind(int size) {
        init(size);
    }

    private void init(int size) {
        parent = new int[size];
        rank = new int[size];
        for (int i = 0; i < size; i++) {
            parent[i] = i;
            rank[i] = 1;
        }
    }

Можно конечно всю инициализацию сделать в конструкторе.

FIND

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

Немного теории: FIND

Рекурсивное сжатие путей
Это оптимизация, используемая в структуре данных Union-Find для ускорения операции find.
Основная идея заключается в том, чтобы сделать деревья более плоскими, что уменьшает время выполнения последующих операций.

Как работает сжатие путей?

Когда мы выполняем операцию find, цель состоит в том, чтобы определить корень элемента p. При стандартной реализации достаточно следовать по цепочке родительских связей до корня. Но, применяя сжатие путей, мы не просто ищем корень, но и изменяем родителя каждого посещённого узла на реальный корень. Это помогает в будущем значительно сократить путь поиска для других узлов, проходящих через те же промежуточные узлы.

Пример работы

Предположим, у нас есть цепочка узлов: 5 -> 3 -> 1, где 1 — корень.

  1. Обычная операция find(5) (без сжатия путей):
    Идём от 5 к 3, затем от 3 к 1. Находим, что корень — это 1.

  2. Операция find(5) с сжатием путей:
    После поиска корня (1), мы меняем так, чтобы 5 указывал непосредственно на 1. Теперь 5 -> 1.
    Это уменьшает глубину дерева и позволяет в следующий раз быстрее находить корень.

Преимущества

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

Оптимизация времени:
После применения сжатия путей, операции find работают почти за постоянное время — O(α(n)), где α(n) — обратная функция Аккермана, которая растет чрезвычайно медленно. На практике это фактически O(1) для вычислимых значений.

   public int find(int p) {
        if (parent[p] != p) {
            parent[p] = find(parent[p]); // Рекурсивное сжатие путей
        }
        return parent[p];
    }
Пояснение к коду

Базовый случай:
Если parent[p] == p, значит p — корень, и мы просто возвращаем его.

Рекурсивный случай:
Если parent[p] != p, мы рекурсивно находим корень p и при этом изменяем parent[p] напрямую на найденный корень. Это основное сжатие путей.

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

UNION

Метод union в структуре данных Union-Find используется для объединения двух множеств, которые содержат заданные элементы. Он объединяет два поддерева в одно, следуя определённой стратегии, чтобы минимизировать глубину полученного дерева. Это ключевая операция для обеспечения эффективности Union-Find.

Немного теории: UNION

Основная мысль
Цель операции union — сделать так, чтобы корень одного из деревьев стал родителем корня другого, effectively merging the trees. Чтобы минимизировать высоту дерева, применяется стратегия "сжатия по рангу" (или по размеру).

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

Объединение по рангу
При объединении деревьев мы делаем корневым родителем то дерево, ранг (высота) которого выше. Это помогает поддерживать как можно более плоскую структуру дерева.
Если ранги одинаковы, одно из деревьев становится корнем, и его ранг увеличивается на единицу, так как глубина увеличилась.

   public void union(int p, int q) {
        int rootP = find(p);
        int rootQ = find(q);

        if (rootP != rootQ) {
            // Объединяем более низкое дерево под более высокое
            if (rank[rootP] > rank[rootQ]) {
                parent[rootQ] = rootP;
            } else if (rank[rootP] < rank[rootQ]) {
                parent[rootP] = rootQ;
            } else {
                parent[rootQ] = rootP;
                rank[rootP]++; // увеличиваем ранг, если высоты равны
            }
        }
    }
Пояснение к коду
  1. Нахождение корней:
    Сначала мы находим корни p и q с помощью метода find, применяя сжатие путей для сокращения деревьев.

  2. Проверка равенства корней:
    Если корни этих элементов равны, они уже в одном множестве, и union ничего не делает.

  3. Сравнение и объединение по рангам:
    Если ранг корня rootP больше, то rootQ присоединяется к rootP.
    Если ранг rootQ больше, то наоборот: rootP присоединяется к rootQ.
    Если ранги равны, выбирается один из них в качестве нового корня, и его ранг увеличивается на 1.