diff --git a/doc/README.md b/doc/README.md index d4924493..0221d4d5 100644 --- a/doc/README.md +++ b/doc/README.md @@ -1 +1,102 @@ -# Теория по алгоритмам: + ПРОГРАММА “АЛГОРИТМЫ” ВЕСНА 2024 ФАЛТ. + +# ВВЕДЕНИЕ + +1 лекция. + +- Определение алгоритма. Примеры простых алгоритмов: вычисление числа Фибоначчи, проверка числа на простоту, быстрое возведение в степень. +- Асимптотические обозначения, работа с ними. +- Определение структуры данных, абстрактного типа данных (интерфейса). +- Массив. Линейный поиск. Бинарный поиск. + +# ТЕМА 1. БАЗОВЫЕ СТРУКТУРЫ ДАННЫХ + +1 лекция. + +- Динамический массив. +- Амортизационный анализ. Метод потенциалов. Метод монето +- Амортизированное (учетное) время добавления элемента в динамический массив. +- Двусвязный и односвязный список. Операции. Объединение списков. +- Стек. +- Очередь. +- Дек. +- Хранение стека, очереди и дека в массиве. Циклическая очередь в массиве. +- Хранение стека, очереди и дека в списке. +- Поддержка минимума в стеке. +- Представление очереди в виде двух стеков. Время извлечения элемента. +- Поддержка минимума в очереди. +- Двоичная куча. АТД “Очередь с приоритетом”. + +# ТЕМА 2. СОРТИРОВКИ И ПОРЯДКОВЫЕ СТАТИСТИКИ + +3 лекции. + +- Формулировка задачи. Устойчивость. +- Квадратичные сортировки: сортировка вставками, выбором. +- Сортировка слиянием. +- Сортировка с помощью кучи. +- Нижняя оценка времени работы для сортировок сравнением. +- Поиск числа инверсий +- Быстрая сортировка. Выбор опорного элемента. Доказательство среднего времени работы. +- Сортировка подсчетом. Карманная сортировка. +- Поразрядная сортировка. +- MSD, LSD. Сортировка строк. +- Поиск k-ой порядковой статистики методом QuickSelect. +- Поиск k-ой порядковой статистики за линейное время. + +# ТЕМА 3. ДЕРЕВЬЯ ПОИСКА + +4 лекции. + +- Определение дерева, дерева с корнем. Высота дерева, родительские, дочерние узлы, листья. Количество ребер. +- Обходы в глубину. pre-order, post-order и in-order для бинарных деревьев. +- Обход в ширину. +- Дерево поиска. +- Поиск ключа, вставка, удаление. +- Необходимость балансировки. Три типа самобалансирующихся деревьев. +- Декартово дерево. Оценка средней высоты декартового дерева при случайных приоритетах (без доказательства). +- Построение за O(n), если ключи упорядочены. +- Основные операции над декартовым деревом. +- АВЛ-дерево. Вращения. +- Оценка высоты АВЛ-дерева. +- Операции вставки и удаления в АВЛ-дереве. +- Красно-черное дерево. +- Оценка высоты красно-черного дерева. +- Операции вставки и удаления в красно-черном дереве. +- Сплей-дерево. Операция Splay. +- Поиск, вставка, удаление в сплей-дереве. +- Учетная оценка операций в сплей-дереве = O(log n) . +- B-деревья. + +# ТЕМА 4. ХЕШ-ТАБЛИЦЫ + +2 лекции. + +- Хеш-функции. Остаток от деления, мультипликативная. +- Деление многочленов - CRC. +- Полиномиальная. Ее использование для строк. Метод Горнера для уменьшения количества операций умножения при ее вычислении. +- Хеш-таблицы. Понятие коллизии. +- Метод цепочек (открытое хеширование). +- Метод прямой адресации (закрытое хеширование). +- Линейное пробирование. Проблема кластеризации. +- Квадратичное пробирование. +- Двойное хеширование. + +# ТЕМА 5. ЖАДНЫЕ АЛГОРИТМЫ И ДИНАМИЧЕСКОЕ ПРОГРАММИРОВАНИЕ + +1 лекция. + +- [Составляющие ДП (формула пересчета, порядок, база, где ответ)](topic5/question1.md) +- [Задача о кузнечике. Задача о черепахе](topic5/question2.md) +- [Задача о наибольшей общей подпоследовательности](topic5/question3.md) +- [Задача о наибольшей возрастающей подпоследовательности](topic5/question4.md) +- [Дерево отрезков для решения задачи о НВП](topic5/question5.md) + +2 лекция + +- Задача о рюкзаке. Жадный и динамический подходы +- Почему нет решения задачи о рюкзаке жадным методом? +- ДП на матрицах. Числа Фиббоначи +- ДП на матрицах. Кол-во путей в графе из u в v длины ровно k +- ДП на матрицах. Кол-во путей в графе из u в v длины <= k +- ДП на матрицах. Есть ли хотя бы один путь из u в v длины ровно k? \ No newline at end of file diff --git a/doc/topic5/question1.md b/doc/topic5/question1.md new file mode 100644 index 00000000..3a2454d1 --- /dev/null +++ b/doc/topic5/question1.md @@ -0,0 +1,50 @@ +# Составляющие ДП (формула пересчета, порядок, база, где ответ)Составляющие ДП (формула пересчета, порядок, база, где ответ) + +**Динамическое программирование** — это когда у нас есть задача, которую непонятно как решать, и мы разбиваем ее на меньшие задачи, которые тоже непонятно как решать. + +Чтобы успешно решить задачу динамикой нужно: +1) Состояние динамики: параметр(ы), однозначно задающие подзадачу. +2) Значения начальных состояний. +3) Переходы между состояниями: формула пересчёта. +4) Порядок пересчёта. +5) Положение ответа на задачу: иногда это сумма или, например, максимум из значений нескольких состояний. + +## Порядок пересчёта + +Существует три порядка пересчёта: +**1)** Прямой порядок: +Состояния последовательно пересчитывается исходя из уже посчитанных.![](https://habrastorage.org/r/w1560/storage3/f1f/36c/875/f1f36c87585e05a9beb692324fa6b72e.png) + +**2)** Обратный порядок: +Обновляются все состояния, зависящие от текущего состоянияю.![](https://habrastorage.org/r/w1560/storage3/0ae/3a1/bf4/0ae3a1bf4aed4161e8375305693cbf69.png) + +**3)** Ленивая динамика: +Рекурсивная мемоизированная функция пересчёта динамики. Это что-то вроде поиска в глубину по ацикличному графу состояний, где рёбра — это зависимости между ними. +![](https://habrastorage.org/r/w1560/storage3/045/d67/fbe/045d67fbe78f0d724d75dd89a352cfe2.png) + +**Прямой порядок:** +```python +fib[1] = 1 # Начальные значения +fib[2] = 1 # Начальные значения +for i in range(3, n + 1): + fib[i] = fib[i - 1] + fib[i - 2] # Пересчёт состояния i +``` + +**Обратный порядок:** +```python +fib[1] = 1 # Начальные значения +for i in range(1, n): + fib[i + 1] += fib[i] # Обновление состояния i + 1 + fib[i + 2] += fib[i] # Обновление состояния i + 2 +``` + +**Ленивая динамика:** +```python +def get_fib(i): + if (i <= 2): # Начальные значения + return 1 + if (fib[i] != -1): # Ленивость + return fib[i] + fib[i] = get_fib(i - 1) + get_fib(i - 2) # Пересчёт + return fib[i] +``` \ No newline at end of file diff --git a/doc/topic5/question2.md b/doc/topic5/question2.md new file mode 100644 index 00000000..e5f9fdaf --- /dev/null +++ b/doc/topic5/question2.md @@ -0,0 +1,62 @@ +# Задача о кузнечике. Задача о черепахе + +## Задача о кузнечике: + Рассмотрим следующую задачу. На числовой прямой сидит кузнечик, который может прыгать вправо на одну или на две единицы. Первоначально кузнечик находится в точке с координатой 0. +Определите количество различных маршрутов кузнечика, приводящих его в точку с координатой n. + +**Рекурсивное решение:** +```python +# неэффективное решение +def F(n): + if n < 2: + return 1 + else: + return F(n - 1) + F(n - 2) +``` + +**Нерекурсивное решение: ** +```python +F = [0] * (n + 1) +F[0] = 1 +F[1] = 1 +for i in range(2, n + 1): + F[i] = F[i - 2] + F[i — 1] +``` + +## Модификации задачи о кузнечике: + Модифицируем задачу. Пусть кузнечик прыгает на одну, две или три единицы, необходимо также вычислить количество способов попасть в точку n. В рекуррентном соотношении добавится еще одно слагаемое: F(n) = F(n - 1) + F(n - 2) + F(n - 3). И начальные значения для вычисления + теперь должны состоять из трех чисел: F(0), F(1), F(2). Решение изменится не сильно: + +```python +F = [0] * (n + 1) +F[0] = 1 +F[1] = F[0] +F[2] = F[1] + F[0] +for i in range(3, n + 1): + F[i] = F[i - 3] + F[i — 2] + F[i — 1] +``` + +## Задача про черепашку: + Эволюционируем от кузнечика к черепахе. Черепашка перемещается по прямоугольному полю n×m и собирает цветочки. На клетке (i,j) растет cij + цветочков. Изначально черепашка стоит в клетеке (0,0), а ей надо добраться до клетки (n−1,m−1), собрав как можно больше цветочков. Двигаться она может только на одну клетку вниз или на одну клетку вправо за раз. + +Решение во многом похоже на задачу про кузнечика: + +dp[i][j] + - состояние динамики, равное максимальному количеству цветочков, которое можно набрать по пути до клетки (i,j) +. +dp[i][j]=max(dp[i−1][j],dp[i][j−1])+cij + - правило пересчета +Порядок обхода - надо проходить через клетки так, чтобы все состояния, которые нужны для пересчета, уже были валидны. Можно во внешнем цикле перебирать строку, а во внутреннем столбец по возрастанию. Восстановление ответа аналогично задаче про кузнечика. Расход времени и памяти составит O(n*m). + +## Количество путей: + Забудем пока про цветочки. Пусть теперь неокторые клетки поля заблокированы, черепашка не может заходить на них. Мы хотим найти, сколькими способами черепашка может добраться до клетки (n−1,m−1). + +Пусть dp[i][j] + - количество способов добраться до клетки (i,j) + из начальной. Формула пересчета тогда будет следующей: dp[i][j]=dp[i−1[j]+dp[i][j−1]. + То есть в клетку (i,j) можно пройти либо из клетки над ней, либо слева от нее. + +Заметим, что такое правило пересчета разрешает нам ходить по заблокированным клеткам в общем случае. Это можно исправить так: при обходе поля и подсчете состояний можно не считать способы для заблокированных клеток, значение динамики в них всегда будет 0, что равносильно запрету посещать клетку. + +Какие стартовые значения нам нужны? Пока что мы знаем только, что в исходную клетку можно пройти единственным способом - остаться в ней, поэтому dp[0][0]=1. \ No newline at end of file diff --git a/doc/topic5/question3.md b/doc/topic5/question3.md new file mode 100644 index 00000000..3319d5bf --- /dev/null +++ b/doc/topic5/question3.md @@ -0,0 +1,56 @@ +# Нахождение максимальной общей подпоследовательности + +## Определения + +Последовательность представляет собой упорядоченный набор элементов. **Строка** — это частный случай последовательности, дальнейшие примеры будут для простоты рассматривать именно строки, но без изменений их можно использовать и для произвольного текста или чего-нибудь последовательного еще. + +Пусть имеется последовательность x, состоящая из элементов x1x2...xm и последовательность y, состоящая из элементов y1y2...yn. z — подпоследовательность x в том случае, если существует строго возрастающий набор индексов элементов x, из которых получается z. + +**Общей подпоследовательностью** для x и y считаем такую последовательность z, которая является одновременно подпоследовательностью x и подпоследовательностью y. + +**Максимальная общая подпоследовательность** — это общая подпоследовательность с максимальной длинной. Далее по тексту будем использовать сокращение LCS. +В качестве примера, пусть x=HABRAHABR, y=HARBOUR, в этом случае LCS(x, y)=HARBR. Можно уже переходить непосредственно к алгоритму вычисления LCS, но, хорошо бы понять, для чего нам может это может понадобиться. + +## Динамическое программирование + +> **Рассматриваемый алгоритм также известен как алгоритм Нидлмана—Вунша (Needleman-Wunsch).** +Весь подход сводится к поэтапному заполнению матрицы, где строки представляют собой элементы x, а колонки элементы y. При заполнении действуют два правила, вытекающие из уже сделанных наблюдений: + 1. Если элемент xi равен yj то в ячейке (i,j) записывается значение ячейки (i-1,j-1) с добавлением единицы + 2. Если элемент xi не равен yj то в ячейку (i,j) записывается максимум из значений(i-1,j) и (i,j-1). + +Заполнение происходит в двойном цикле по i и j с увеличением значений на единицу, таким образом на каждой итерации нужные на этом шаге значения ячеек уже вычислены: +``` +def fill_dyn_matrix(x, y): + L = [[0]*(len(y)+1) for _ in xrange(len(x)+1)] + for x_i,x_elem in enumerate(x): + for y_i,y_elem in enumerate(y): + if x_elem == y_elem: + L[x_i][y_i] = L[x_i-1][y_i-1] + 1 + else: + L[x_i][y_i] = max((L[x_i][y_i-1],L[x_i-1][y_i])) + return L +``` + +**Иллюстрация происходящего:** + +![](https://habrastorage.org/storage2/4c7/061/02a/4c706102aa8f467337723aa092f4bd5a.gif) + +> **Ячейки, в которых непосредственно происходило увеличение значения подсвечены. После заполнения матрицы, соединив эти ячейки, мы получим искомый LCS. Соединять при этом нужно двигаясь от максимальных индексов к минимальным, если ячейка подсвечена, то добавляем соответствующий элемент к LCS, если нет, то двигаемся вверх или влево в зависимости от того где находится большее значение в матрице:** +``` +def LCS_DYN(x, y): + L = fill_dyn_matrix(x, y) + LCS = [] + x_i,y_i = len(x)-1,len(y)-1 + while x_i >= 0 and y_i >= 0: + if x[x_i] == y[y_i]: + LCS.append(x[x_i]) + x_i, y_i = x_i-1, y_i-1 + elif L[x_i-1][y_i] > L[x_i][y_i-1]: + x_i -= 1 + else: + y_i -= 1 + LCS.reverse() + return LCS +``` + +**Сложность алогоритма** — O(len(x)*len(y)), такая же оценка по памяти. Таким образом, если я захочу построчно сравнить два файла из 100000 строк, то нужно будет хранить в памяти матрицу из 1010 элементов. Т.е. реальное использование грозит получением MemoryError почти на ровном месте. Перед тем как перейти к следующему алгоритму заметим, что при заполнении матрицы L на каждой итерации по элементам x нам нужна только строка, полученная на предыщем ходу. Т.е. если бы задача ограничивалась только нахождением длины LCS без необходимости вычисления самой LCS, то можно было бы снизить использование памяти до O(len(y)), сохраняя одновременно только две строки матрицы L. \ No newline at end of file diff --git a/doc/topic5/question4.md b/doc/topic5/question4.md new file mode 100644 index 00000000..47cade77 --- /dev/null +++ b/doc/topic5/question4.md @@ -0,0 +1,85 @@ +# Задача о наибольшей возрастающей последовательности (нвп) + +**Наибольшая возрастающая подпоследовательность** (нвп) (англ. longest increasing subsequence, lis) строки x, длины n — это последовательность x[i1] ## Решение за время o(n^2) + +Построим массив d, где d[i] — это длина наибольшей возрастающей подпоследовательности, оканчивающейся в элементе, с индексом i. Массив будем заполнять постепенно — сначала d[0], потом d[1] и т.д. Ответом на нашу задачу будет максимум из всех элементов массива d. Заполнение массива будет следующим: если d[i]=1, то искомая последовательность состоит только из числа a[i]. если d[i]>1, то перед числом a[i] в подпоследовательности стоит какое-то другое число. переберем его: это может быть любой элемент a[j](j=0...i−1), но такой, что a[j] findLIS(vector a): + int n = a.size //размер исходной последовательности + int prev[0..n - 1] + int d[0..n - 1] + + for i = 0 to n - 1 + d[i] = 1 + prev[i] = -1 + for j = 0 to i - 1 + if (a[j] < a[i] and d[j] + 1 > d[i]) + d[i] = d[j] + 1 + prev[i] = j + + pos = 0 // индекс последнего элемента НВП + length = d[0] // длина НВП + for i = 0 to n - 1 + if d[i] > length + pos = i + length = d[i] + + // восстановление ответа + vector answer + while pos != -1 + answer.push_back(a[pos]) + pos = prev[pos] + reverse(answer) + + return answer +``` + +> ## Решение за O(N log N) + +Для более быстрого решения данной задачи построим следующую динамику: пусть d[i](i=0...n) — число, на которое оканчивается возрастающая последовательность длины i, а если таких чисел несколько — то наименьшее из них. Изначально мы предполагаем, что d[0]=−∞, а все остальные элементы d[i]=∞. Заметим два важных свойства этой динамики: d[i−1]⩽d[i], для всех i=1...nи каждый элемент a[i] обновляет максимум один элемент d[j]. Это означает, что при обработке очередного a[i], мы можем за O(logn) c помощью двоичного поиска в массиве d найти первое число, которое больше либо равно текущего a[i] и обновить его. + +Для восстановления ответа будем поддерживать заполнение двух массивов: pos +и prev. В pos[i] будем хранить индекс элемента, на который заканчивается оптимальная подпоследовательность длины i, а в prev[i] — позицию предыдущего элемента для a[i]. + +Пример: + + +## Псевдокод алгоритма + +```c++ +vector findLIS(vector a): + int n = a.size //размер исходной последовательности + int d[0..n] + int pos[0..n] + int prev[0..n - 1] + length = 0 + + pos[0] = -1 + d[0] = -INF + for i = 1 to n + d[i] = INF + for i = 0 to n - 1 + j = binary_search(d, a[i]) + if (d[j - 1] < a[i] and a[i] < d[j]) + d[j] = a[i] + pos[j] = i + prev[i] = pos[j - 1] + length = max(length, j) + + // восстановление ответа + vector answer + p = pos[length] + while p != -1 + answer.push_back(a[p]) + p = prev[p] + reverse(answer) + + return answer +``` \ No newline at end of file diff --git a/doc/topic5/question5.md b/doc/topic5/question5.md new file mode 100644 index 00000000..a25661c3 --- /dev/null +++ b/doc/topic5/question5.md @@ -0,0 +1,96 @@ +# Дерево отрезков для решения задачи о НВП + +## Условие + Дан массив arr целых чисел размером n. Необходимо удалить из него min число элементов так, чтобы оставшиеся составляли возрастающую последовательность. + +## Пример: + Дан массив 6,2,5,4,2,5,6, если удалить индексы 0, 2, 4, то получится последовательность 2,4,5,6 - которая возрастает и имеет наибольшую длину. + +## Динамическое программирование(решение похожее на решение из файла. O(n^2)Динамическое программирование(решение похожее на решение из файла. O(n^2) + Будем хранить в массиве d значения, d[i] равен длине наибольшей возрастающей подпоследовательности, которая оканчивается в arr[i] + Для примера массив d будет равен + a = 6,2,5,4,2,5,6 + d = 1,1,2,2,1,3,4 + + На каждой итерации у нас есть выбор, либо мы запускаем новую подпоследовательность, либо присоединяемся к какой-нибудь подпоследовательности слева. То есть d[i] = max(1, max(d[j]) + 1) при условии, что 0 <= j < i и a[j] < a[i] + +Так как подпоследовательность необязательно заканчивается в конце, то ответом будет max(d): + +```c++ +int a[100000]; +int d[100000]; + +int main() { + for (int i = 0; i < n; i++) { + d[i] = 1; + for (int j = 0; j < i; j++) { + if (a[j] < a[i]) { + d[i] = max(d[i], d[j] + 1); + } + } + } + int ans = *max_element(d, d + n); +} +``` + +> Нетрудно заметить, что такое решение будет работать за O(n^2) + + Будем максимум на префиксе за O(logn), надо просто объединить динамическое программирование и дерево Фенвика(https://neerc.ifmo.ru/wiki/index.php?title=Дерево_Фенвика): +```c++ +class fenwick { +public: + explicit fenwick() { + f.resize(1000001, 0); + } + + int mx(int i) { + int res = INT_MIN; + for (; i >= 0; i = (i & (i + 1)) - 1) { + res = max(res, f[i]); + } + return res; + } + + void set(int i, int v) { + for (; i < 100000; i |= i + 1) { + f[i] = max(f[i], v); + } + } + +private: + vector f; +}; + +int main() { + ios::sync_with_stdio(false); + cin.tie(nullptr); + cout.tie(nullptr); + int n; + cin >> n; + vector arr(n); + for (int i = 0; i < n; ++i) cin >> arr[i]; + + fenwick tree; + for (int i = 0; i < n; ++i) { + int q = tree.mx(arr[i]); + tree.set(arr[i], q + 1); + } + cout << tree.mx(100000); + return 0; +} +``` + +Если элемент в оригинальном массиве <= 10^6, то можно решать без проблем, иначе нужно будет сжать значения. +Что значит сжать значение. Допустим нам дан массив длиной 10^6, но при этом сами числа в массиве могут быть от -10^9 до 10^9. То есть в целом значение имеет 10^18 вариантов значений, что уже не подходит для дерева Фенвика. Поэтому значения нужно сжать. Как это можно сделать. Так как количество элементов у нас ограничено, то мы можем определить биекцию между реальным элементов и его индексом. Причем два одинаковых элемента должны возвращать один и тот индекс. Таким образом даже в худшем случае, когда все числа уникальны, то получившиеся индексы не превысят 10^6, что подходит для использования в Дереве Фенвика. + + Еще раз, теперь наш индекс в дереве Фенвика - это значение. Мы можем быстро найти максимум в отрезке от 0 до i. Более наглядно (d - это обычное динамическое программирование): + a = 6,2,5,4,2,5,6 + d = 1,1,2,2,1,3,4 + f = 0,0,1,0,2,3,4,0,0.... + +Мы пробегаемся по всем элементам массива и делаем две операции: + + Находим максимальное значение, которое было до этого значения + Меняем текущее значение на полученное максимальное + 1 + +![](https://habrastorage.org/getpro/habr/post_images/4e1/a12/ba4/4e1a12ba49be37da4bc571c0cbfc6641.jpg) \ No newline at end of file