Принцип достаточно прост - делаем два указателя и пускаем их по списку с разной скоростью. Первый медленный или черепаха идет по каждому узлу списка, а второй, быстрый Заяц - скачет через один.
Что из этого можно получить?
В магию математики погружаться не будем...
Если заяц доскакал до конца, то черепаха будет как раз на середине. Красота!
Возвращаем указатель "черепаха" вот вам и решение задачи.
Опять запускаем наших животных и ждем когда они встретятся. А они точно встретятся (магия математики).
Т.е. если последний узел зациклен на любой из узлов списка, то заяц обязательно войдет в цикл и догонит черепаху.
И вот если два указателя показывают на одно и то же место, то список однозначно зациклен.
Если наш список зациклен, то исходя их пункта 2 мы можем узнать где "встретились" черепаха и заяц.
А дальше опять магия, если начать движение по каждому следующему узлу от головы списка и от точки встречи, то точка пересечения, т.е. когда оба указателя дойдут до одного узла, то этот узел и будет первым узлом цикла.
Не верите, проверьте! Магия работает!!!
Ну тут совсем просто, нашли первый узел цикла, и топаем дальше пока не найдем узел который на него ссылается. Вот вам и последний элемент.
И еще парочка задач, когда дано два списка
Тут сначала проходим эти списки до конца, если конец один и тот же элемент значит пересекаются.
А тут опять магия, берем "хвост" и зацикливаем его на одну из голов.
А со второй ищем первую точку цикла - которая и есть точка пересечения.
Только после не забывайте убрать цикл с хвоста, что бы чего не вышло...
Основная мысль бинарного поиска заключается в эффективном нахождении заданного элемента в упорядоченном массиве данных. Основная идея состоит в том, чтобы разделить массив пополам и проверить, находится ли искомый элемент в левой или правой половине массива. Затем процесс повторяется для выбранной половины, пока не будет найден искомый элемент или пока не останется только один элемент в массиве.
Основной алгоритм бинарного поиска:
Пока low не превышает high, выполните следующие шаги:
Бинарный поиск работает ТОЛЬКО на отсортированных массивах.
Пример кода:
public static intbinarySearch (int[] arr, int target) { int left = 0; int right = arr.length - 1; while (left <= right) { int mid = left + (right - left) / 2; if (arr[mid] == target) { return mid; } if (arr[mid] < target) { left = mid + 1; } else { right = mid - 1; } } return -1; }
Много где используются, но мне попадались в задачах на сравнение строк.
Представим, что у нас есть строка, где 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 intP = 29; private static final longMODULE = 1_000_000_007L;
Что бы не считать каждый раз степени модуля вычисляем их сразу столько сколько надо:
private static voidinitPower (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 voidbuildPrefixHashArray (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 longcalculateHash (String s) { long hash = 0; int p = 0; for (char c : s.toCharArray()) { hash = (hash + getCharPowerValue(c) * power[p++]) % MODULE; } return hash; }
И последнее - надо научится получать хеш подстроки, а это достаточно просто:
private static longgetIntervalHash (int startIndex, int finishIndex) { long sub = prefixHashArray[finishIndex] - prefixHashArray[startIndex]; return sub > -1 ? sub : sub + MODULE; }
Обратите внимание на то, как метод getIntervalHash возвращает ответ - это магия циклических колец. Этот момент я долго не мог понять, а поэтому и не учитывал в коде.
Теперь у нас есть все инструменты, что бы сравнивать строки или подстроки.
Один из источников вдохновения.Преимущества использования полиномиальных хешей при сравнении строк заключаются в следующем:
Эффективность:
Полиномиальные хеши позволяют вычислять хеш-коды строки за линейное время и сравнивать их
за константное время. Это значительно ускоряет задачу сравнения строк по сравнению с посимвольным
сравнением, особенно для длинных строк.
Сравнение подстрок:
Полиномиальные хеши поддерживают эффективное вычисление хешей для подстрок, что
полезно в задачах, связанных с поиском подстрок и алгоритмами поиска шаблонов.
Устойчивость к коллизиям:
При правильно выбранных параметрах (в том числе модуле и базе) вероятность
коллизий хешей низка. Это делает подход надежным на практике.
Простота реализации:
Алгоритм полиномиального хеширования прост в реализации и может быть легко
адаптирован для различных задач, связанных с обработкой строк.
Эти качества делают полиномиальные хеши распространённым выбором для задач, связанных со сравнением строк, таких как текстовые алгоритмы и структуры данных, например, "суффиксные массивы" или "интервалные деревья".
Find (Найти):
Определяет, к какому множеству принадлежит элемент. Это может быть использовано для проверки
принадлежности двух элементов одному множеству.
Union (Объединить):
Объединяет два множества в одно.
Репрезентатив (Представитель):
Каждый элемент множества имеет некоторое представительное значение (обычно голову дерева),
которое является корнем дерева представления данного множества.
Ранговое дерево и сжатие путей:
Используются для оптимизации работы алгоритма.
Сжатие путей помогает уменьшить глубину дерева, быстро находя представителя,
а ранговое дерево помогает поддерживать низкую высоту дерева при объединении.
Реализуем это как отдельную структуру данных, т.е. сделаем отдельный класс
Создаем массивы, представляющие "родителей" каждого узла и их "ранги" (глубину деревьев).
private int[]parent ; private int[]rank ;
Инициализирует массивы. Изначально каждый элемент является своим собственным родителем и имеет ранг 1.
publicUnionFind (int size) { init(size); } private voidinit (int size) { parent = new int[size]; rank = new int[size]; for (int i = 0; i < size; i++) { parent[i] = i; rank[i] = 1; } }
Можно конечно всю инициализацию сделать в конструкторе.
Метод находит корень узла p с помощью рекурсивного сжатия путей, что позволяет быстро находить корень и уменьшает глубину существующих деревьев.
Рекурсивное сжатие путей
Это оптимизация, используемая в структуре данных Union-Find
для ускорения операции find.
Основная идея заключается в том, чтобы сделать деревья более плоскими,
что уменьшает время выполнения последующих операций.
Когда мы выполняем операцию find, цель состоит в том, чтобы определить корень элемента p. При стандартной реализации достаточно следовать по цепочке родительских связей до корня. Но, применяя сжатие путей, мы не просто ищем корень, но и изменяем родителя каждого посещённого узла на реальный корень. Это помогает в будущем значительно сократить путь поиска для других узлов, проходящих через те же промежуточные узлы.
Предположим, у нас есть цепочка узлов: 5 -> 3 -> 1, где 1 — корень.
Обычная операция find(5) (без сжатия путей):
Идём от 5 к 3, затем от 3 к 1.
Находим, что корень — это 1.
Операция find(5) с сжатием путей:
После поиска корня (1), мы меняем так,
чтобы 5 указывал непосредственно на 1. Теперь 5 -> 1.
Это уменьшает глубину дерева и позволяет в следующий раз быстрее находить
корень.
Уменьшение глубины дерева:
Чем более плоские деревья, тем быстрее операции.
Оптимизация времени:
После применения сжатия путей, операции find работают почти за постоянное время
— O(α(n)), где α(n) — обратная функция Аккермана, которая растет чрезвычайно
медленно. На практике это фактически O(1) для вычислимых значений.
public intfind (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-Find используется для объединения двух множеств, которые содержат заданные элементы. Он объединяет два поддерева в одно, следуя определённой стратегии, чтобы минимизировать глубину полученного дерева. Это ключевая операция для обеспечения эффективности Union-Find.
Основная мысль
Цель операции union — сделать так,
чтобы корень одного из деревьев стал родителем корня другого, effectively merging the trees.
Чтобы минимизировать высоту дерева, применяется стратегия "сжатия по рангу"
(или по размеру).
Сжатие по рангу
Ранг узла часто коррелируется с высотой дерева, хотя это не обязательно абсолютная
высота. Идея в том, что банально объединять деревья по этому рангу.
Объединение по рангу
При объединении деревьев мы делаем корневым родителем то дерево,
ранг (высота) которого выше.
Это помогает поддерживать как можно более плоскую структуру дерева.
Если ранги одинаковы, одно из деревьев становится корнем,
и его ранг увеличивается на единицу, так как глубина увеличилась.
public voidunion (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]++;// увеличиваем ранг, если высоты равны } } }
Нахождение корней:
Сначала мы находим корни p и q с помощью метода find,
применяя сжатие путей для сокращения деревьев.
Проверка равенства корней:
Если корни этих элементов равны, они уже в одном множестве,
и union ничего не делает.
Сравнение и объединение по рангам:
Если ранг корня rootP больше, то rootQ присоединяется к rootP.
Если ранг rootQ больше, то наоборот: rootP присоединяется к rootQ.
Если ранги равны, выбирается один из них в качестве нового корня,
и его ранг увеличивается на 1.