Ключевые детали реализации:
Два базовых случая: файл (возвращаем размер) и нефайл (обрабатываем детей). Операция Files.isRegularFile() требует минимальных системных вызовов и не читает весь файл в память.
Защита от null: Files.newDirectoryStream() может вернуть null или бросить исключение, если доступ запрещен.
Обработка символических ссылок: Текущая реализация следует по символическим ссылкам, что может создать циклы и StackOverflowError.
Для защиты нужно добавить Set<Path> посещенных узлов:
Анализ производительности и памяти
Время: O(N), где N — общее количество файлов и каталогов. Каждый узел посещается ровно один раз.
Память (стек): O(h), где h — максимальная глубина вложенности. Для домашней директории h обычно < 50, для серверов — < 500. Безопасно для 1MB стека.
Память (куча): O(h) на множество visited, если используется защита от циклов.
Иерархия жанров библиотеки — поиск в поддереве
Представим модель библиотеки, где жанры организованы в дерево:
Каждый узел содержит название и список поджанров.
Задача: найти все книги, относящиеся к жанру «Фантастика», включая все поджанры.
Модель данных
#Java #для_новичков #beginner #algorithm #recursion
Два базовых случая: файл (возвращаем размер) и нефайл (обрабатываем детей). Операция Files.isRegularFile() требует минимальных системных вызовов и не читает весь файл в память.
Защита от null: Files.newDirectoryStream() может вернуть null или бросить исключение, если доступ запрещен.
Обработка символических ссылок: Текущая реализация следует по символическим ссылкам, что может создать циклы и StackOverflowError.
Для защиты нужно добавить Set<Path> посещенных узлов:
private static long calculateRecursiveSafe(Path currentPath, Set<Path> visited) throws IOException {
// Проверка на цикл
if (!visited.add(currentPath.toRealPath())) {
System.err.println("Cycle detected, skipping: " + currentPath);
return 0;
}
if (Files.isRegularFile(currentPath)) {
return Files.size(currentPath);
}
long totalSize = 0;
try (DirectoryStream<Path> stream = Files.newDirectoryStream(currentPath)) {
for (Path child : stream) {
totalSize += calculateRecursiveSafe(child, visited);
}
}
// Важно: не удаляем из visited при возврате, так как путь может быть повторен через другую ссылку
return totalSize;
}Анализ производительности и памяти
Время: O(N), где N — общее количество файлов и каталогов. Каждый узел посещается ровно один раз.
Память (стек): O(h), где h — максимальная глубина вложенности. Для домашней директории h обычно < 50, для серверов — < 500. Безопасно для 1MB стека.
Память (куча): O(h) на множество visited, если используется защита от циклов.
Иерархия жанров библиотеки — поиск в поддереве
Представим модель библиотеки, где жанры организованы в дерево:
Литература
├── Художественная
│ ├── Фантастика
│ │ ├── Космическая
│ │ └── Альтернативная история
│ └── Детектив
└── Научная
└── Программирование
└── Java
Каждый узел содержит название и список поджанров.
Задача: найти все книги, относящиеся к жанру «Фантастика», включая все поджанры.
Модель данных
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class GenreNode {
private final String name;
private final List<GenreNode> subgenres;
private final List<Book> books; // Книги непосредственно этого жанра
public GenreNode(String name) {
this.name = name;
this.subgenres = new ArrayList<>();
this.books = new ArrayList<>();
}
// Методы добавления поджанров и книг...
public void addSubgenre(GenreNode subgenre) {
subgenres.add(subgenre);
}
public void addBook(Book book) {
books.add(book);
}
public String getName() {
return name;
}
public List<GenreNode> getSubgenres() {
return Collections.unmodifiableList(subgenres);
}
public List<Book> getBooks() {
return Collections.unmodifiableList(books);
}
}
class Book {
final String title;
final String author;
Book(String title, String author) {
this.title = title;
this.author = author;
}
}
#Java #для_новичков #beginner #algorithm #recursion
👍3
Рекурсивный поиск по поддереву
Проблема текущей реализации: Она добавляет книги промежуточных узлов, когда находит цель в поддереве. Для задачи «все книги в поддереве Фантастики» это неправильно.
Исправим:
#Java #для_новичков #beginner #algorithm #recursion
import java.util.List;
import java.util.ArrayList;
public class GenreSearchService {
/**
* Находит все книги в указанном жанре и его поджанрах.
*
* @param root корень дерева жанров
* @param targetGenreName имя целевого жанра
* @return список всех книг в поддереве
*/
public static List<Book> findAllBooksInGenre(GenreNode root, String targetGenreName) {
if (root == null || targetGenreName == null) {
throw new IllegalArgumentException("Parameters cannot be null");
}
List<Book> result = new ArrayList<>();
findGenreAndCollectBooks(root, targetGenreName, result);
return result;
}
/**
* Рекурсивный обход с поиском целевого жанра.
*
* @param current текущий узел
* @param target имя искомого жанра
* @param accumulator список для накопления результатов
* @return true, если целевой жанр найден в текущем поддереве
*/
private static boolean findGenreAndCollectBooks(GenreNode current, String target, List<Book> accumulator) {
// Базовый случай 1: мы в целевом узле
boolean isTarget = current.getName().equals(target);
// Если нашли целевой жанр, добавляем все книги этого узла
if (isTarget) {
accumulator.addAll(current.getBooks());
}
// Рекурсивный шаг: обходим все поджанры
// Даже если текущий узел не целевой, его потомки могут иметь целевой жанр
for (GenreNode child : current.getSubgenres()) {
boolean foundInChild = findGenreAndCollectBooks(child, target, accumulator);
// Если целевой жанр найден в поддереве, добавляем книги текущего узла
// Это зависит от бизнес-логики: если нужны ТОЛЬКО книги целевого жанра и его поджанров,
// но не промежуточных узлов, логика меняется
if (foundInChild) {
accumulator.addAll(current.getBooks());
}
}
// Возвращаем true, если целевой жанр найден в этом поддереве
return isTarget;
}
}
Проблема текущей реализации: Она добавляет книги промежуточных узлов, когда находит цель в поддереве. Для задачи «все книги в поддереве Фантастики» это неправильно.
Исправим:
/**
* Версия 2: Корректный поиск всех книг в поддереве целевого жанра.
*/
public static List<Book> findAllBooksInSubtree(GenreNode root, String targetGenreName) {
List<Book> result = new ArrayList<>();
GenreNode targetNode = findNode(root, targetGenreName);
if (targetNode != null) {
collectAllBooksInSubtree(targetNode, result);
}
return result;
}
/**
* Первый проход: находим целевой узел.
*/
private static GenreNode findNode(GenreNode current, String target) {
if (current.getName().equals(target)) {
return current;
}
for (GenreNode child : current.getSubgenres()) {
GenreNode found = findNode(child, target);
if (found != null) {
return found; // Остановка при первом нахождении
}
}
return null;
}
/**
* Второй проход: собираем все книги в поддереве.
*/
private static void collectAllBooksInSubtree(GenreNode current, List<Book> accumulator) {
accumulator.addAll(current.getBooks());
for (GenreNode child : current.getSubgenres()) {
collectAllBooksInSubtree(child, accumulator);
}
}
#Java #для_новичков #beginner #algorithm #recursion
👍2
Оптимизация одним проходом:
Можно совместить поиск и сбор:
Эта версия сложнее для понимания. Лучше разделить ответственности: отдельно поиск, отдельно сбор.
Анализ сложности
Время: O(N), где N — общее количество узлов. В худшем случае ищем до самого последнего узла.
Память (стек): O(h), где h — высота дерева. Для сбалансированного дерева h = log N, для вырожденного (списка) h = N.
Сравнение рекурсивного и итеративного обхода
Итеративная версия с явным стеком
#Java #для_новичков #beginner #algorithm #recursion
Можно совместить поиск и сбор:
private static boolean collectIfTarget(GenreNode current, String target, List<Book> accumulator) {
// Проверяем, является ли текущий узел целевым
boolean isTargetNode = current.getName().equals(target);
// Если мы уже внутри целевого поддерева (аккумулятор в режиме сбора)...
// Или если текущий узел — цель, начинаем сбор
if (isTargetNode || (accumulator.size() > 0 && accumulator.get(accumulator.size() - 1) == null)) {
accumulator.addAll(current.getBooks());
}
// Рекурсивно обходим детей
for (GenreNode child : current.getSubgenres()) {
collectIfTarget(child, target, accumulator);
}
return isTargetNode;
}Эта версия сложнее для понимания. Лучше разделить ответственности: отдельно поиск, отдельно сбор.
Анализ сложности
Время: O(N), где N — общее количество узлов. В худшем случае ищем до самого последнего узла.
Память (стек): O(h), где h — высота дерева. Для сбалансированного дерева h = log N, для вырожденного (списка) h = N.
Сравнение рекурсивного и итеративного обхода
Итеративная версия с явным стеком
import java.util.ArrayDeque;
import java.util.Deque;
public class IterativeTreeTraversal {
/**
* Итеративный подсчет размера каталога с использованием ArrayDeque.
* Использует кучу (heap) вместо стека вызовов.
*/
public static long calculateSizeIterative(Path rootPath) throws IOException {
if (!Files.isDirectory(rootPath)) {
return Files.size(rootPath);
}
long totalSize = 0;
Deque<Path> stack = new ArrayDeque<>();
stack.push(rootPath);
while (!stack.isEmpty()) {
Path current = stack.pop();
// Если это файл, добавляем размер
if (Files.isRegularFile(current)) {
totalSize += Files.size(current);
continue;
}
// Если это директория, добавляем всех детей в стек
try (DirectoryStream<Path> stream = Files.newDirectoryStream(current)) {
for (Path child : stream) {
stack.push(child);
}
} catch (IOException e) {
System.err.println("Error reading " + current + ": " + e.getMessage());
}
}
return totalSize;
}
/**
* Итеративный поиск книг в поддереве жанров.
*/
public static List<Book> findBooksIterative(GenreNode root, String targetGenre) {
List<Book> result = new ArrayList<>();
// Находим целевой узел
GenreNode targetNode = null;
Deque<GenreNode> nodeStack = new ArrayDeque<>();
nodeStack.push(root);
while (!nodeStack.isEmpty() && targetNode == null) {
GenreNode current = nodeStack.pop();
if (current.getName().equals(targetGenre)) {
targetNode = current;
break;
}
// Добавляем детей в порядке обратном, чтобы обходить в исходном порядке
List<GenreNode> children = current.getSubgenres();
for (int i = children.size() - 1; i >= 0; i--) {
nodeStack.push(children.get(i));
}
}
if (targetNode == null) {
return result;
}
// Собираем все книги в поддереве
nodeStack.clear();
nodeStack.push(targetNode);
while (!nodeStack.isEmpty()) {
GenreNode current = nodeStack.pop();
result.addAll(current.getBooks());
// Добавляем всех детей
for (GenreNode child : current.getSubgenres()) {
nodeStack.push(child);
}
}
return result;
}
}
#Java #для_новичков #beginner #algorithm #recursion
👍3
Сравнительный анализ
Читаемость и поддерживаемость:
Рекурсия: Естественно отражает структуру дерева. Код короче, интуитивно понятен. Подходит для алгоритмов с разделением задач (divide-and-conquer).
Итерация: Требует ручного управления стеком. Легче допустить ошибку в порядке обхода. Однако в Java культуре итерация часто считается более «идиоматичной» из-за отсутствия TCO.
Производительность:
Скорость: Рекурсия медленнее на 5-15% из-за накладных расходов на создание фреймов, проверку безопасности, сохранение регистров. Итерация работает как цикл while.
Локальность: Рекурсия хуже кэшируется, так как фреймы разбросаны в памяти. Итерация с ArrayDeque использует непрерывный массив с высокой локальностью.
Вызовы: Каждый рекурсивный вызов — это отдельный invocation в профилировщике. При больших N это замедляет JIT-компиляцию.
Память:
Рекурсия: O(h) в стеке вызовов (ограниченный ресурс). Каждый фрейм занимает 32-128 байт в зависимости от аргументов и локальных переменных.
Итерация: O(h) в куче (heap), где ограничений практически нет. ArrayDeque может расти до Integer.MAX_VALUE элементов. Один элемент стека — Path или GenreNode — занимает 8 байт для ссылки + размер объекта.
Надежность:
Стековерфлов: Рекурсия может упасть с StackOverflowError на глубине 1000-10000. Итерация не имеет этого ограничения.
Ошибки доступа: В рекурсии ошибка в одной ветви прерывает весь обход. В итерации легче продолжить после ошибки.
Параллелизм:
Рекурсия: Сложно распараллелить, так как каждый поток имеет свой стек вызовов. Нужно вручную создавать задачи для ForkJoinPool.
Итерация: Легко разделить работу между потоками, разбив стек на части.
#Java #для_новичков #beginner #algorithm #recursion
Читаемость и поддерживаемость:
Рекурсия: Естественно отражает структуру дерева. Код короче, интуитивно понятен. Подходит для алгоритмов с разделением задач (divide-and-conquer).
Итерация: Требует ручного управления стеком. Легче допустить ошибку в порядке обхода. Однако в Java культуре итерация часто считается более «идиоматичной» из-за отсутствия TCO.
Производительность:
Скорость: Рекурсия медленнее на 5-15% из-за накладных расходов на создание фреймов, проверку безопасности, сохранение регистров. Итерация работает как цикл while.
Локальность: Рекурсия хуже кэшируется, так как фреймы разбросаны в памяти. Итерация с ArrayDeque использует непрерывный массив с высокой локальностью.
Вызовы: Каждый рекурсивный вызов — это отдельный invocation в профилировщике. При больших N это замедляет JIT-компиляцию.
Память:
Рекурсия: O(h) в стеке вызовов (ограниченный ресурс). Каждый фрейм занимает 32-128 байт в зависимости от аргументов и локальных переменных.
Итерация: O(h) в куче (heap), где ограничений практически нет. ArrayDeque может расти до Integer.MAX_VALUE элементов. Один элемент стека — Path или GenreNode — занимает 8 байт для ссылки + размер объекта.
Надежность:
Стековерфлов: Рекурсия может упасть с StackOverflowError на глубине 1000-10000. Итерация не имеет этого ограничения.
Ошибки доступа: В рекурсии ошибка в одной ветви прерывает весь обход. В итерации легче продолжить после ошибки.
Параллелизм:
Рекурсия: Сложно распараллелить, так как каждый поток имеет свой стек вызовов. Нужно вручную создавать задачи для ForkJoinPool.
Итерация: Легко разделить работу между потоками, разбив стек на части.
#Java #для_новичков #beginner #algorithm #recursion
👍3
Раздел 7. Алгоритмы
Глава 5: Рекурсия, деревья и введение в динамическое программирование
От рекурсии к динамическому программированию
Ранее мы рассмотрели рекурсию как естественный инструмент для работы с иерархическими структурами. Но в мире вычислений есть задачи, где прямое применение рекурсии превращает элегантный код в тормозной механизм, пожирающий процессорное время и память. Классический пример такого провала — вычисление чисел Фибоначчи.
Числа Фибоначчи определяются простейшей рекуррентной формулой: F(0) = 0, F(1) = 1, а для n > 1 выполняется F(n) = F(n-1) + F(n-2). Это определение так и просится быть преобразованным в рекурсивную функцию. Однако наивная реализация скрывает в себе экспоненциальную бомбу. Мы разберем, почему это происходит, как мемоизация превращает катастрофу в триумф, и как эти идеи ложатся в основу парадигмы динамического программирования.
Наивная рекурсия и экспоненциальная сложность
Вот как выглядит буквальная имплементация определения Фибоначчи:
Код выглядит правильно, компилируется и дает правильные результаты для малых n. Но попробуйте вызвать fibonacci(50) и приготовьтесь ждать. Не секунды, а минуты. А fibonacci(100) на обычном железе не завершится за разумное время.
Дерево вызовов: визуализация катастрофы
Чтобы понять, почему это происходит, нужно построить дерево вызовов.
Для fibonacci(5) оно выглядит так:
Заметьте: fibonacci(2) вычисляется три раза, fibonacci(3) — дважды. При увеличении n это дублирование становится катастрофическим. Дерево вызовов — не дерево, а граф, где один и тот же узел достигается множеством путей. Но наивная рекурсия это не знает и вычисляет повторно каждый раз.
Математический анализ сложности: O(2ⁿ)
Количество вызовов функции можно описать рекуррентным соотношением T(n) = T(n-1) + T(n-2) + O(1), что очень похоже на саму последовательность Фибоначчи. Можно доказать индукцией, что T(n) ≥ F(n). А поскольку F(n) ≈ φⁿ / √5, где φ ≈ 1.618 — золотое сечение, получаем экспоненциальный рост.
Более грубая, но наглядная оценка: пусть каждый вызов порождает два новых. Тогда на глубине n будет не более 2ⁿ узлов. Действительное число вызовов немного меньше из-за сокращения базовых случаев, но порядок остается экспоненциальным — O(2ⁿ).
Практические измерения (на JVM с отключенным JIT для чистоты эксперимента):
fibonacci(30) — ~2.7 миллиона вызовов, ~10 мс
fibonacci(40) — ~3.3 миллиарда вызовов, ~1000 мс
fibonacci(50) — ~4.1 триллионов вызовов, ~120 секунд
fibonacci(100) — вызовов больше, чем атомов на Земле
Это не медленный алгоритм — это неработающий алгоритм. Любая задача, где n может быть 50 или больше, становится неприемлемой.
#Java #для_новичков #beginner #algorithm #recursion
Глава 5: Рекурсия, деревья и введение в динамическое программирование
От рекурсии к динамическому программированию
Ранее мы рассмотрели рекурсию как естественный инструмент для работы с иерархическими структурами. Но в мире вычислений есть задачи, где прямое применение рекурсии превращает элегантный код в тормозной механизм, пожирающий процессорное время и память. Классический пример такого провала — вычисление чисел Фибоначчи.
Числа Фибоначчи определяются простейшей рекуррентной формулой: F(0) = 0, F(1) = 1, а для n > 1 выполняется F(n) = F(n-1) + F(n-2). Это определение так и просится быть преобразованным в рекурсивную функцию. Однако наивная реализация скрывает в себе экспоненциальную бомбу. Мы разберем, почему это происходит, как мемоизация превращает катастрофу в триумф, и как эти идеи ложатся в основу парадигмы динамического программирования.
Наивная рекурсия и экспоненциальная сложность
Вот как выглядит буквальная имплементация определения Фибоначчи:
public class NaiveFibonacci {
/**
* Вычисляет n-е число Фибоначчи наивной рекурсией.
*
* @param n порядковый номер, начиная с 0 (F(0)=0, F(1)=1)
* @return n-е число Фибоначчи
* @throws IllegalArgumentException если n отрицательный
*/
public static long fibonacci(int n) {
// Базовые случаи, соответствующие математическому определению
if (n < 0) {
throw new IllegalArgumentException("n must be non-negative");
}
if (n == 0) {
return 0;
}
if (n == 1) {
return 1;
}
// Рекурсивный шаг: F(n) = F(n-1) + F(n-2)
// Ключевой момент: два вызова на каждом уровне
return fibonacci(n - 1) + fibonacci(n - 2);
}
}Код выглядит правильно, компилируется и дает правильные результаты для малых n. Но попробуйте вызвать fibonacci(50) и приготовьтесь ждать. Не секунды, а минуты. А fibonacci(100) на обычном железе не завершится за разумное время.
Дерево вызовов: визуализация катастрофы
Чтобы понять, почему это происходит, нужно построить дерево вызовов.
Для fibonacci(5) оно выглядит так:
fibonacci(5)
├── fibonacci(4)
│ ├── fibonacci(3)
│ │ ├── fibonacci(2)
│ │ │ ├── fibonacci(1) = 1
│ │ │ └── fibonacci(0) = 0
│ │ ├── fibonacci(1) = 1
│ │ └── 1 + 1 = 2
│ ├── fibonacci(2)
│ │ ├── fibonacci(1) = 1
│ │ └── fibonacci(0) = 0
│ └── 2 + 1 = 3
├── fibonacci(3)
│ ├── fibonacci(2)
│ │ ├── fibonacci(1) = 1
│ │ └── fibonacci(0) = 0
│ ├── fibonacci(1) = 1
│ └── 1 + 1 = 2
└── 3 + 2 = 5
Заметьте: fibonacci(2) вычисляется три раза, fibonacci(3) — дважды. При увеличении n это дублирование становится катастрофическим. Дерево вызовов — не дерево, а граф, где один и тот же узел достигается множеством путей. Но наивная рекурсия это не знает и вычисляет повторно каждый раз.
Математический анализ сложности: O(2ⁿ)
Количество вызовов функции можно описать рекуррентным соотношением T(n) = T(n-1) + T(n-2) + O(1), что очень похоже на саму последовательность Фибоначчи. Можно доказать индукцией, что T(n) ≥ F(n). А поскольку F(n) ≈ φⁿ / √5, где φ ≈ 1.618 — золотое сечение, получаем экспоненциальный рост.
Более грубая, но наглядная оценка: пусть каждый вызов порождает два новых. Тогда на глубине n будет не более 2ⁿ узлов. Действительное число вызовов немного меньше из-за сокращения базовых случаев, но порядок остается экспоненциальным — O(2ⁿ).
Практические измерения (на JVM с отключенным JIT для чистоты эксперимента):
fibonacci(30) — ~2.7 миллиона вызовов, ~10 мс
fibonacci(40) — ~3.3 миллиарда вызовов, ~1000 мс
fibonacci(50) — ~4.1 триллионов вызовов, ~120 секунд
fibonacci(100) — вызовов больше, чем атомов на Земле
Это не медленный алгоритм — это неработающий алгоритм. Любая задача, где n может быть 50 или больше, становится неприемлемой.
#Java #для_новичков #beginner #algorithm #recursion
👍4
Мемоизация — кэширование как спасение
Мемоизация — это техника сохранения результатов дорогих вызовов функций и повторного использования вместо повторного вычисления. Это не специфично для рекурсии, но в контексте рекурсивных алгоритмов она трансформирует экспоненциальное время в линейное.
Идея проста: перед вычислением проверяем, есть ли результат в кэше. Если есть — возвращаем его. Если нет — вычисляем, кэшируем, возвращаем.
Реализация наивной мемоизации через HashMap
Что изменилось в дереве вызовов?
При fibonacci(5) первый вызов вычислит все подзначения от 0 до 5 и сохранит их в memo. Последующие вызовы из глубины рекурсии не пойдут дальше одного уровня, так как fibonacci(3) уже в кэше. Дерево вызовов превращается в прямолинейный граф с N узлов.
Анализ сложности:
Время: O(n). Каждое значение от 0 до n вычисляется ровно один раз. Всего n+1 вычислений, каждое — O(1).
Память: O(n) на кэш + O(h) на стек вызовов. Для Фибоначчи h = n в худшем случае, так что суммарно O(n). Но теперь память расходуется в куче (heap), где ограничения гораздо мягче стека.
На практике fibonacci(100) выполняется за < 1 мс, а fibonacci(1000) — за несколько микросекунд, хотя результат уже не помещается в long (можно использовать BigInteger).
Потоковая безопасность и жизненный цикл кэша
В примере выше memo — это статическое поле, общее для всех вызовов.
Это создает несколько проблем:
Потоконебезопасность: Одновременный вызов из двух потоков может повредить HashMap. Решение: ConcurrentHashMap или ThreadLocal<Map>.
Утечка памяти: Кэш живет вечно, захватывая память. Решение: использовать WeakHashMap или явный Cache из библиотеки (Caffeine, Guava).
Тестируемость: Тесты влияют друг на друга через общий кэш. Решение: инстанцировать кэш вне метода и передавать как параметр ( dependency injection).
Правильный production-ready подход:
#Java #для_новичков #beginner #algorithm #recursion
Мемоизация — это техника сохранения результатов дорогих вызовов функций и повторного использования вместо повторного вычисления. Это не специфично для рекурсии, но в контексте рекурсивных алгоритмов она трансформирует экспоненциальное время в линейное.
Идея проста: перед вычислением проверяем, есть ли результат в кэше. Если есть — возвращаем его. Если нет — вычисляем, кэшируем, возвращаем.
Реализация наивной мемоизации через HashMap
import java.util.HashMap;
import java.util.Map;
public class MemoizedFibonacci {
// Кэш для хранения вычисленных значений. Map<аргумент, результат>
// Используем long для n, т.к. int может переполниться при больших значениях
private static final Map<Long, Long> memo = new HashMap<>();
// Инициализация базовых случаев в кэше
static {
memo.put(0L, 0L);
memo.put(1L, 1L);
}
/**
* Потоко-НЕБЕЗОПАСНАЯ версия. Для многопоточности использовать ConcurrentHashMap.
*/
public static long fibonacci(long n) {
if (n < 0) {
throw new IllegalArgumentException("n must be non-negative");
}
// Проверка кэша: если значение уже вычислено, возвращаем мгновенно
if (memo.containsKey(n)) {
return memo.get(n);
}
// Рекурсивный шаг с мемоизацией
// Вычисляем только один раз, результат сохраняется в memo
long result = fibonacci(n - 1) + fibonacci(n - 2);
// Сохраняем в кэш перед возвратом
memo.put(n, result);
return result;
}
/**
* Метод для сброса кэша в тестах. В продакшене так делать не стоит.
*/
public static void clearCache() {
memo.clear();
memo.put(0L, 0L);
memo.put(1L, 1L);
}
}
Что изменилось в дереве вызовов?
При fibonacci(5) первый вызов вычислит все подзначения от 0 до 5 и сохранит их в memo. Последующие вызовы из глубины рекурсии не пойдут дальше одного уровня, так как fibonacci(3) уже в кэше. Дерево вызовов превращается в прямолинейный граф с N узлов.
Анализ сложности:
Время: O(n). Каждое значение от 0 до n вычисляется ровно один раз. Всего n+1 вычислений, каждое — O(1).
Память: O(n) на кэш + O(h) на стек вызовов. Для Фибоначчи h = n в худшем случае, так что суммарно O(n). Но теперь память расходуется в куче (heap), где ограничения гораздо мягче стека.
На практике fibonacci(100) выполняется за < 1 мс, а fibonacci(1000) — за несколько микросекунд, хотя результат уже не помещается в long (можно использовать BigInteger).
Потоковая безопасность и жизненный цикл кэша
В примере выше memo — это статическое поле, общее для всех вызовов.
Это создает несколько проблем:
Потоконебезопасность: Одновременный вызов из двух потоков может повредить HashMap. Решение: ConcurrentHashMap или ThreadLocal<Map>.
Утечка памяти: Кэш живет вечно, захватывая память. Решение: использовать WeakHashMap или явный Cache из библиотеки (Caffeine, Guava).
Тестируемость: Тесты влияют друг на друга через общий кэш. Решение: инстанцировать кэш вне метода и передавать как параметр ( dependency injection).
Правильный production-ready подход:
public class FibonacciService {
private final Map<Long, Long> memo = new ConcurrentHashMap<>();
public FibonacciService() {
memo.put(0L, 0L);
memo.put(1L, 1L);
}
public long fibonacci(long n) {
// computeIfAbsent — атомарная операция, потокобезопасная
return memo.computeIfAbsent(n, key -> fibonacci(key - 1) + fibonacci(key - 2));
}
}#Java #для_новичков #beginner #algorithm #recursion
👍2🔥1
Динамическое программирование сверху вниз
Мемоизация — это техника.
Динамическое программирование (Dynamic Programming, DP) — это парадигма решения задач оптимизации, основанная на двух принципах:
Оптимальная подструктура: Оптимальное решение задачи содержит в себе оптимальные решения подзадач. Для Фибоначчи: F(n) оптимально вычисляется через оптимальные F(n-1) и F(n-2).
Перекрывающиеся подзадачи: Разные подзадачи содержат одни и те же еще более мелкие подзадачи. Фибоначчи — идеальный пример: F(n-1) и F(n-2) оба нуждаются в F(n-3).
Top-down DP (сверху вниз) — это именно рекурсия с мемоизацией. Мы начинаем с верхней задачи и по мере погружения кэшируем результаты.
Инвариант динамического программирования
В любом DP-алгоритме существует таблица состояний (state table). В мемоизированной версии это Map. Важно правильно определить ключ состояния — минимальный набор параметров, однозначно определяющих подзадачу. Для Фибоначчи это просто n. Для более сложных задач это может быть кортеж параметров.
Bottom-up подход: альтернатива top-down
Есть и другой способ DP — bottom-up (снизу вверх).
Мы не рекурсивно спускаемся, а итеративно строим таблицу от базовых случаев к цели:
Для Фибоначчи можно сжать память до O(1), храня только два последних значения:
Это уже не DP, а простая математическая оптимизация, но она показывает, что DP — промежуточный этап от наивной рекурсии к оптимальному решению.
Практические применения DP в библиотечных системах
Задача: оптимальное размещение книг на полках
Представьте: у вас есть полки разной высоты и книги разной толщины. Нужно разместить книги так, чтобы минимизировать пустое пространство. Это вариация задачи о рюкзаке с ограничениями.
Это классический bottom-up DP: строим таблицу, каждая ячейка определяется предыдущими.
#Java #для_новичков #beginner #algorithm #recursion
Мемоизация — это техника.
Динамическое программирование (Dynamic Programming, DP) — это парадигма решения задач оптимизации, основанная на двух принципах:
Оптимальная подструктура: Оптимальное решение задачи содержит в себе оптимальные решения подзадач. Для Фибоначчи: F(n) оптимально вычисляется через оптимальные F(n-1) и F(n-2).
Перекрывающиеся подзадачи: Разные подзадачи содержат одни и те же еще более мелкие подзадачи. Фибоначчи — идеальный пример: F(n-1) и F(n-2) оба нуждаются в F(n-3).
Top-down DP (сверху вниз) — это именно рекурсия с мемоизацией. Мы начинаем с верхней задачи и по мере погружения кэшируем результаты.
Инвариант динамического программирования
В любом DP-алгоритме существует таблица состояний (state table). В мемоизированной версии это Map. Важно правильно определить ключ состояния — минимальный набор параметров, однозначно определяющих подзадачу. Для Фибоначчи это просто n. Для более сложных задач это может быть кортеж параметров.
Bottom-up подход: альтернатива top-down
Есть и другой способ DP — bottom-up (снизу вверх).
Мы не рекурсивно спускаемся, а итеративно строим таблицу от базовых случаев к цели:
public static long fibonacciBottomUp(long n) {
if (n < 0) throw new IllegalArgumentException();
if (n == 0) return 0;
if (n == 1) return 1;
long[] dp = new long[(int)n + 1];
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[(int)n];
}Для Фибоначчи можно сжать память до O(1), храня только два последних значения:
public static long fibonacciOptimized(long n) {
if (n < 0) throw new IllegalArgumentException();
if (n == 0) return 0;
long a = 0, b = 1;
for (long i = 2; i <= n; i++) {
long next = a + b;
a = b;
b = next;
}
return b;
}Это уже не DP, а простая математическая оптимизация, но она показывает, что DP — промежуточный этап от наивной рекурсии к оптимальному решению.
Практические применения DP в библиотечных системах
Задача: оптимальное размещение книг на полках
Представьте: у вас есть полки разной высоты и книги разной толщины. Нужно разместить книги так, чтобы минимизировать пустое пространство. Это вариация задачи о рюкзаке с ограничениями.
/**
* Динамическое программирование для оптимального размещения.
*
* Состояние: dp[i][w] = минимальное незаполненное пространство
* после размещения первых i книг на полках общей ширины w.
*/
public class ShelfOptimizer {
public static int optimize(List<Integer> bookThicknesses, int shelfWidth) {
int n = bookThicknesses.size();
int[][] dp = new int[n + 1][shelfWidth + 1];
// Инициализация
for (int w = 0; w <= shelfWidth; w++) {
dp[0][w] = w; // Без книг осталось все свободное пространство
}
// Заполнение таблицы
for (int i = 1; i <= n; i++) {
int thickness = bookThicknesses.get(i - 1);
for (int w = 0; w <= shelfWidth; w++) {
if (thickness > w) {
// Книга не помещается — переносим предыдущее состояние
dp[i][w] = dp[i - 1][w];
} else {
// Выбираем лучшее: либо не кладем книгу, либо кладем
dp[i][w] = Math.min(
dp[i - 1][w], // Не кладем
dp[i - 1][w - thickness] // Кладем, освобождаем оставшееся место
);
}
}
}
return dp[n][shelfWidth];
}
}
Это классический bottom-up DP: строим таблицу, каждая ячейка определяется предыдущими.
#Java #для_новичков #beginner #algorithm #recursion
👍1
Поиск кратчайшего пути в графе книг
Если книги связаны рекомендациями «после прочтения этой, читайте эту», можно найти кратчайшую цепочку от книги A до книги B через DP:
Подводные камни и best practices
1. Выбор ключа состояния
Ключ должен быть минимальным и однозначным. Для задачи «подсчет путей в сетке» ключ — это (x, y). Для задачи «редактирование строки» — (i, j), где i и j — позиции в строках. Если ключ слишком большой (содержит лишние поля), кэш раздувается. Если неоднозначный — получите неверные результаты.
2. Порядок вычислений и рекурсивные циклы
Если подзадачи зависят друг от друга (например, в графе с циклами), naive top-down DP может зациклиться. Решение:
Добавить состояние "в процессе" в кэш и бросать исключение при повторном входе
Использовать bottom-up
Применять алгоритмы для графов с циклами (topological sort)
3. Память vs время
DP всегда торгует памятью на время. Для задач с большим количеством состояний (например, dp[1000][1000][1000]) память может стать bottlenek'ом. Тогда применяют:
Оптимизацию памяти: хранить только нужные строки таблицы
Встречный поиск (meet-in-the-middle): разбить задачу на две половины
Аппроксимацию: отказаться от точного DP в пользу эвристик
4. Не всё можно решить DP
DP требует оптимальной подструктуры и перекрывающихся подзадач. Если решение задачи не складывается из решений подзадач (например, задача о ранце с взаимозависимыми вещами), DP неприменим.
5. Состояние гонки в мемоизации
В многопоточной среде используйте ConcurrentHashMap.computeIfAbsent, который атомарно вычисляет значение, если его нет. Но будьте осторожны: рекурсивный вызов внутри лямбды может заблокировать bucket и вызвать deadlock при рекурсивных зависимостях.
Раздел 6: От DP к современным практикам
Кэширование в распределенных системах
В микросервисах, управляющих библиотекой, DP-подход применяется через распределенный кэш (Redis, Memcached). Состояние — ключ в Redis, рекурсия — это серийный вызов сервисов. Но теперь нужно думать о сетевых задержках, а не только о процессоре.
Машинное обучение и DP
В NLP для разбора предложений используется CYK-алгоритм — DP по грамматике. В компьютерном зрении для выравнивания изображений — динамическое искажение времени (DTW). DP — фундаментальная парадигма не только в классических алгоритмах.
#Java #для_новичков #beginner #algorithm #recursion
Если книги связаны рекомендациями «после прочтения этой, читайте эту», можно найти кратчайшую цепочку от книги A до книги B через DP:
/**
* Определение длины кратчайшего пути от start до end.
* dp[v] = минимальная длина пути от start до v.
*/
public static int shortestPathLength(Map<String, List<String>> graph, String start, String end) {
Map<String, Integer> dp = new HashMap<>();
dp.put(start, 0);
// BFS + DP гибрид
Queue<String> queue = new ArrayDeque<>();
queue.add(start);
while (!queue.isEmpty()) {
String current = queue.poll();
int currentDist = dp.get(current);
if (current.equals(end)) {
return currentDist;
}
for (String neighbor : graph.getOrDefault(current, List.of())) {
if (!dp.containsKey(neighbor) || dp.get(neighbor) > currentDist + 1) {
dp.put(neighbor, currentDist + 1);
queue.add(neighbor);
}
}
}
return -1; // Путь не найден
}
Это пример DP, где состояние — это вершина графа.
Подводные камни и best practices
1. Выбор ключа состояния
Ключ должен быть минимальным и однозначным. Для задачи «подсчет путей в сетке» ключ — это (x, y). Для задачи «редактирование строки» — (i, j), где i и j — позиции в строках. Если ключ слишком большой (содержит лишние поля), кэш раздувается. Если неоднозначный — получите неверные результаты.
2. Порядок вычислений и рекурсивные циклы
Если подзадачи зависят друг от друга (например, в графе с циклами), naive top-down DP может зациклиться. Решение:
Добавить состояние "в процессе" в кэш и бросать исключение при повторном входе
Использовать bottom-up
Применять алгоритмы для графов с циклами (topological sort)
3. Память vs время
DP всегда торгует памятью на время. Для задач с большим количеством состояний (например, dp[1000][1000][1000]) память может стать bottlenek'ом. Тогда применяют:
Оптимизацию памяти: хранить только нужные строки таблицы
Встречный поиск (meet-in-the-middle): разбить задачу на две половины
Аппроксимацию: отказаться от точного DP в пользу эвристик
4. Не всё можно решить DP
DP требует оптимальной подструктуры и перекрывающихся подзадач. Если решение задачи не складывается из решений подзадач (например, задача о ранце с взаимозависимыми вещами), DP неприменим.
5. Состояние гонки в мемоизации
В многопоточной среде используйте ConcurrentHashMap.computeIfAbsent, который атомарно вычисляет значение, если его нет. Но будьте осторожны: рекурсивный вызов внутри лямбды может заблокировать bucket и вызвать deadlock при рекурсивных зависимостях.
Раздел 6: От DP к современным практикам
Кэширование в распределенных системах
В микросервисах, управляющих библиотекой, DP-подход применяется через распределенный кэш (Redis, Memcached). Состояние — ключ в Redis, рекурсия — это серийный вызов сервисов. Но теперь нужно думать о сетевых задержках, а не только о процессоре.
Машинное обучение и DP
В NLP для разбора предложений используется CYK-алгоритм — DP по грамматике. В компьютерном зрении для выравнивания изображений — динамическое искажение времени (DTW). DP — фундаментальная парадигма не только в классических алгоритмах.
#Java #для_новичков #beginner #algorithm #recursion
👍1
Раздел 7. Алгоритмы
Глава 5: Рекурсия, деревья и введение в динамическое программирование
Практическое применение рекурсии в библиотечной системе
Цели:
Освоить принципы работы с древовидными структурами
Научиться применять рекурсию для обхода иерархических данных
Сравнить рекурсивные и итеративные подходы
Понять оптимизацию рекурсии через мемоизацию
Часть 1: Моделирование древовидной структуры жанров
Постановка задачи:
В нашей библиотеке жанры книг образуют иерархию.
Например:
Литература (корень)
Художественная литература
Фантастика
Научная фантастика
Фэнтези
Нехудожественная литература
Вам нужно: Создать структуру данных, которая позволит хранить такую иерархию.
Шаг 1.1: Создайте класс Genre
Что нужно сделать:
Определите, какие поля понадобятся для хранения:
Название жанра
Список поджанров (дочерние элементы)
Список книг, относящихся к этому жанру
Реализуйте:
Конструктор, принимающий название жанра
Методы для добавления поджанра и книги
Геттеры для доступа к данным
Вопрос для размышления: Почему важно, чтобы поджанры хранились как список объектов Genre?
Шаг 1.2: Свяжите книги с жанрами
Что нужно сделать:
Обновите класс Book:
Добавьте поле для хранения основного жанра книги
Измените конструктор для установки жанра
Подумайте: Может ли книга принадлежать к нескольким жанрам? Как это реализовать?
Шаг 1.3: Построение дерева жанров
Практическое задание:
В классе Library создайте метод initGenres(), который:
Создаёт корневой жанр "Литература"
Добавляет 3-4 уровня вложенности жанров
Распределяет существующие книги по соответствующим жанрам
Проверка: Убедитесь, что у вас получилось дерево глубиной не менее 4 уровней.
Часть 2: Рекурсивный подсчёт книг в жанре
Задача: Напишите метод, который считает все книги в жанре и его поджанрах
Рекурсия работает так:
Базовый случай: если у жанра нет поджанров, возвращаем количество его книг
Рекурсивный случай: суммируем книги текущего жанра + рекурсивно вызываем метод для каждого поджанра
Пример:
Шаг 2.1: Реализуйте метод countBooksRecursive()
Что нужно сделать:
Добавьте метод в класс Genre
Реализуйте рекурсивный подсчёт по описанному алгоритму
Протестируйте на разных уровнях дерева
Контрольные вопросы:
Что произойдёт, если в дереве будет циклическая ссылка?
Какова максимальная глубина рекурсии для вашего дерева?
Часть 3: Сравнение рекурсивного и итеративного обхода
Задача: Вывести структуру дерева жанров двумя способами
Шаг 3.1: Рекурсивный обход (Pre-order)
Алгоритм:
Обработать текущий узел (вывести информацию)
Рекурсивно обработать все дочерние узлы
Что нужно сделать:
Реализуйте метод printTreeRecursive(Genre genre, int depth), где depth используется для создания отступов при выводе.
Шаг 3.2: Итеративный обход с использованием стека
Можно эмулировать рекурсию с помощью стека:
Помещаем корневой элемент в стек
Пока стек не пуст:
Извлекаем элемент
Обрабатываем его
Добавляем в стек его детей
Что нужно сделать:
Реализуйте метод printTreeIterative(Genre root) с использованием Stack<Genre>.
Вопрос: Почему детей нужно добавлять в стек в обратном порядке?
Шаг 3.3: Сравнение подходов
Практическое задание:
Создайте дерево глубиной 10 уровней
Замерьте время выполнения обоих методов
Ответьте на вопросы:
Какой подход читаемее?
Какой безопаснее для глубоких деревьев?
Когда стоит использовать каждый из них?
#Java #для_новичков #beginner #algorithm #recursion #практика
Глава 5: Рекурсия, деревья и введение в динамическое программирование
Практическое применение рекурсии в библиотечной системе
Цели:
Освоить принципы работы с древовидными структурами
Научиться применять рекурсию для обхода иерархических данных
Сравнить рекурсивные и итеративные подходы
Понять оптимизацию рекурсии через мемоизацию
Часть 1: Моделирование древовидной структуры жанров
Постановка задачи:
В нашей библиотеке жанры книг образуют иерархию.
Например:
Литература (корень)
Художественная литература
Фантастика
Научная фантастика
Фэнтези
Нехудожественная литература
Вам нужно: Создать структуру данных, которая позволит хранить такую иерархию.
Шаг 1.1: Создайте класс Genre
Что нужно сделать:
Определите, какие поля понадобятся для хранения:
Название жанра
Список поджанров (дочерние элементы)
Список книг, относящихся к этому жанру
Реализуйте:
Конструктор, принимающий название жанра
Методы для добавления поджанра и книги
Геттеры для доступа к данным
Вопрос для размышления: Почему важно, чтобы поджанры хранились как список объектов Genre?
Шаг 1.2: Свяжите книги с жанрами
Что нужно сделать:
Обновите класс Book:
Добавьте поле для хранения основного жанра книги
Измените конструктор для установки жанра
Подумайте: Может ли книга принадлежать к нескольким жанрам? Как это реализовать?
Шаг 1.3: Построение дерева жанров
Практическое задание:
В классе Library создайте метод initGenres(), который:
Создаёт корневой жанр "Литература"
Добавляет 3-4 уровня вложенности жанров
Распределяет существующие книги по соответствующим жанрам
Проверка: Убедитесь, что у вас получилось дерево глубиной не менее 4 уровней.
Часть 2: Рекурсивный подсчёт книг в жанре
Задача: Напишите метод, который считает все книги в жанре и его поджанрах
Рекурсия работает так:
Базовый случай: если у жанра нет поджанров, возвращаем количество его книг
Рекурсивный случай: суммируем книги текущего жанра + рекурсивно вызываем метод для каждого поджанра
Пример:
Книг в "Фантастике" =
книги в "Фантастике" +
книги в "Научной фантастике" +
книги в "Фэнтези"
Шаг 2.1: Реализуйте метод countBooksRecursive()
Что нужно сделать:
Добавьте метод в класс Genre
Реализуйте рекурсивный подсчёт по описанному алгоритму
Протестируйте на разных уровнях дерева
Контрольные вопросы:
Что произойдёт, если в дереве будет циклическая ссылка?
Какова максимальная глубина рекурсии для вашего дерева?
Часть 3: Сравнение рекурсивного и итеративного обхода
Задача: Вывести структуру дерева жанров двумя способами
Шаг 3.1: Рекурсивный обход (Pre-order)
Алгоритм:
Обработать текущий узел (вывести информацию)
Рекурсивно обработать все дочерние узлы
Что нужно сделать:
Реализуйте метод printTreeRecursive(Genre genre, int depth), где depth используется для создания отступов при выводе.
Шаг 3.2: Итеративный обход с использованием стека
Можно эмулировать рекурсию с помощью стека:
Помещаем корневой элемент в стек
Пока стек не пуст:
Извлекаем элемент
Обрабатываем его
Добавляем в стек его детей
Что нужно сделать:
Реализуйте метод printTreeIterative(Genre root) с использованием Stack<Genre>.
Вопрос: Почему детей нужно добавлять в стек в обратном порядке?
Шаг 3.3: Сравнение подходов
Практическое задание:
Создайте дерево глубиной 10 уровней
Замерьте время выполнения обоих методов
Ответьте на вопросы:
Какой подход читаемее?
Какой безопаснее для глубоких деревьев?
Когда стоит использовать каждый из них?
#Java #для_новичков #beginner #algorithm #recursion #практика
👍3
Часть 4: Числа Фибоначчи - три подхода
Задача: Вычислить n-ное число Фибоначчи
Определение:
F(0) = 0, F(1) = 1, F(n) = F(n-1) + F(n-2)
Шаг 4.1: Наивная рекурсия
Что нужно сделать:
Реализуйте метод fibRecursive(int n) по определению.
Анализ проблемы:
Нарисуйте дерево вызовов для n=5. Сколько раз вычисляется F(2)? F(3)?
Задание: Замерьте время выполнения для n=40. Почему это так долго?
Шаг 4.2: Мемоизация (Top-down динамическое программирование)
Идея: Сохранять уже вычисленные значения, чтобы не вычислять их повторно.
Что нужно сделать:
Создайте HashMap<Integer, Long> для хранения вычисленных значений
Измените рекурсивную функцию так, чтобы она:
Проверяла, нет ли значения в кэше
Сохраняла новое значение после вычисления
Вопрос: Как изменилось дерево вызовов?
Шаг 4.3: Итеративное решение (Bottom-up)
Алгоритм:
Если n <= 1: вернуть n
Иначе: последовательно вычислить все числа от 2 до n
Что нужно сделать:
Реализуйте метод fibIterative(int n) без рекурсии.
Задание: Сравните производительность всех трёх методов для n=50.
Итоговые задания
Задание 1: Рефакторинг
Перепишите один из рекурсивных методов в итеративный. Объясните, что изменилось в сложности кода.
Задание 2: Оптимизация
Добавьте проверку на циклические ссылки в дереве жанров. Что произойдёт без этой проверки?
Задание 3: Эксперимент
Создайте глубокое дерево (20+ уровней). Протестируйте оба метода обхода. При какой глубине возникает StackOverflowError?
Задание 4: Документация
Напишите комментарии к методам, объясняющие:
Сложность алгоритма (Big O)
Ограничения (максимальная глубина)
Рекомендации по использованию
#Java #для_новичков #beginner #algorithm #recursion #практика
Задача: Вычислить n-ное число Фибоначчи
Определение:
F(0) = 0, F(1) = 1, F(n) = F(n-1) + F(n-2)
Шаг 4.1: Наивная рекурсия
Что нужно сделать:
Реализуйте метод fibRecursive(int n) по определению.
Анализ проблемы:
Нарисуйте дерево вызовов для n=5. Сколько раз вычисляется F(2)? F(3)?
Задание: Замерьте время выполнения для n=40. Почему это так долго?
Шаг 4.2: Мемоизация (Top-down динамическое программирование)
Идея: Сохранять уже вычисленные значения, чтобы не вычислять их повторно.
Что нужно сделать:
Создайте HashMap<Integer, Long> для хранения вычисленных значений
Измените рекурсивную функцию так, чтобы она:
Проверяла, нет ли значения в кэше
Сохраняла новое значение после вычисления
Вопрос: Как изменилось дерево вызовов?
Шаг 4.3: Итеративное решение (Bottom-up)
Алгоритм:
Если n <= 1: вернуть n
Иначе: последовательно вычислить все числа от 2 до n
Что нужно сделать:
Реализуйте метод fibIterative(int n) без рекурсии.
Задание: Сравните производительность всех трёх методов для n=50.
Итоговые задания
Задание 1: Рефакторинг
Перепишите один из рекурсивных методов в итеративный. Объясните, что изменилось в сложности кода.
Задание 2: Оптимизация
Добавьте проверку на циклические ссылки в дереве жанров. Что произойдёт без этой проверки?
Задание 3: Эксперимент
Создайте глубокое дерево (20+ уровней). Протестируйте оба метода обхода. При какой глубине возникает StackOverflowError?
Задание 4: Документация
Напишите комментарии к методам, объясняющие:
Сложность алгоритма (Big O)
Ограничения (максимальная глубина)
Рекомендации по использованию
#Java #для_новичков #beginner #algorithm #recursion #практика
👍4