Перейти к содержанию

Эвристические алгоритмы#

Презентация Лекция 11

Муравьиный алгоритм (Ant Colony Optimization, ACO)#

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

История алгоритма#

Год Событие
1959 Пьер-Поль Грассе изложил теорию стигмергии — непрямой координации через среду (на примере термитов)
1983 Денеборг и коллеги систематически исследовали коллективное поведение муравьёв
1989 Арон, Госс, Денеборг, Пастелес — работа о маршрутном выборе аргентинских муравьёв, ставшая теоретической базой
1991 Марко Дориго предложил «Муравьиную систему» (Ant System) в докторской диссертации (опубл. 1992)
1996 Дориго, Манъеццо, Колорни опубликовали классическую статью об Ant System в IEEE
1997 Дориго и Гамбарделла предложили Ant Colony System (ACS) — улучшенную версию с локальным обновлением феромонов
1999 Штюцле и Хуз разработали MAX–MIN Ant System (MMAS) с ограничением диапазона феромонов
2001 Первый многоцелевой муравьиный алгоритм
2004 Показана эквивалентность ряда вариантов ACO стохастическому градиентному спуску
2005 Применение ACO к задаче сворачивания белка

Биологическая основа#

Реальные муравьи не видят всего маршрута целиком. Каждый муравей принимает локальное решение: в какую сторону идти из текущей точки. Механизм координации — феромонный след: муравей, прошедший короткий путь, возвращается быстрее и успевает обновить след до его испарения. Со временем короткие пути накапливают больше феромона и привлекают всё больше муравьёв — положительная обратная связь приводит к самоорганизации.

Стигмергия — форма косвенной коммуникации через изменение среды. Муравьи не «общаются» напрямую: они читают и пишут феромонные сигналы в окружающей среде.

Биологи провели эксперимент: между гнездом и едой установили мост с двумя ветками разной длины. В начале муравьи расходились равномерно. Затем те, кто случайно выбрал короткую ветку, раньше возвращались и оставляли более свежий след. Постепенно вся колония переходила на короткий маршрут — без какого-либо централизованного управления.

Именно этот эффект воспроизводит алгоритм: глобально оптимальное поведение возникает из множества простых локальных правил.

Как работает алгоритм#

Инициализация феромонов τ₀ на всех рёбрах
Повторять до критерия останова:
    Для каждого муравья k = 1..m:
        Построить полный тур, выбирая города по вероятности P_{i,j}
        Запомнить длину тура L_k
    Испарить феромоны: τ_{i,j} ← (1 − ρ) · τ_{i,j}
    Обновить феромоны по завершённым турам
    Обновить лучшее найденное решение
Вернуть лучший тур

Каждый муравей на каждом шаге опирается на три источника информации:

  • Память — список уже посещённых городов (запрет на повторное посещение)
  • Зрение — эвристическая привлекательность ребра: η_{i,j} = 1 / D_{i,j}
  • Обоняние — накопленный феромон на ребре: τ_{i,j}

Фазы одной итерации#

Фаза 1 — Построение решений.
Каждый из m муравьёв независимо строит полный тур, стартуя из случайного города. На каждом шаге муравей выбирает следующий город вероятностно: чем больше феромона и чем короче ребро, тем выше шанс его выбора. Посещённые города заносятся в «список запретов» и исключаются из дальнейшего выбора.

Фаза 2 — Испарение.
Феромон на всех рёбрах умножается на (1 − ρ). Это снижает влияние старых решений и предотвращает бесконечное накопление феромона на ранних, возможно неоптимальных, маршрутах.

Фаза 3 — Обновление феромонов.
Каждый муравей откладывает феромон на рёбрах своего тура пропорционально Q / L_k. Чем короче тур — тем больше феромона на его рёбрах. Хорошие решения автоматически получают больший вес в следующей итерации.

Фаза 4 — Обновление лучшего решения.
Если в текущей итерации найден тур короче рекордного — он запоминается как глобальный оптимум.

Ключевая идея: алгоритм реализует адаптивное случайное сэмплирование — распределение вероятностей выбора путей меняется итеративно в сторону более коротких маршрутов.

Формальное описание#

Граф алгоритма

1. Вероятность перехода#

Вероятность того, что муравей k, находясь в городе i, выберет город j:

P_{i,j}^{k} = \frac{\tau_{i,j}^{\alpha} \cdot \eta_{i,j}^{\beta}} {\displaystyle\sum_{l \in J_{i,k}} \tau_{i,l}^{\alpha} \cdot \eta_{i,l}^{\beta}}, \quad j \in J_{i,k}

Обозначения:

Символ Смысл
J_{i,k} Список городов, доступных муравью k из города i
τ_{i,j} Уровень феромона на ребре (i, j)
η_{i,j} = 1/D_{i,j} Видимость — обратная длина ребра
α Степень влияния феромона («стадность»). При α→0 алгоритм случаен, при α→∞ — детерминирован
β Степень влияния расстояния («жадность»). При β=0 расстояние игнорируется; при β→∞ — жадный выбор

Крайние случаи:
α = 0 → алгоритм выбирает ближайший город (жадный алгоритм)
β = 0 → алгоритм следует только феромону (может застрять в локальном оптимуме)


2. Депозит феромона#

После завершения тура муравей k откладывает феромон на пройденных рёбрах:

\Delta \tau_{i,j}^{k} = \begin{cases} \dfrac{Q}{L_k}, & \text{если } (i, j) \in T_k \\[6pt] 0, & \text{иначе} \end{cases}
Символ Смысл
Q Константа; порядок длины оптимального пути
L_k Полная длина тура муравья k
T_k Множество рёбер тура муравья k

Суммарное обновление феромона на ребре от всей колонии:

\Delta \tau_{i,j} = \sum_{k=1}^{m} \Delta \tau_{i,j}^{k}

3. Испарение и обновление феромонов#

\tau_{i,j}(t+1) = (1 - \rho) \cdot \tau_{i,j}(t) + \Delta\tau_{i,j}(t)
Символ Смысл
ρ ∈ (0, 1) Коэффициент испарения. Малое ρ — медленное забывание; большое ρ — быстрая адаптация
m Число муравьёв в колонии

Роль испарения: без него феромон только накапливается, и алгоритм застревает в первом найденном решении. Испарение обеспечивает «забывание» неудачных путей и поддерживает разнообразие поиска.


Выбор параметров#

Параметр Типичные значения Влияние
α 1–5 Увеличение → сильнее стадное поведение, риск преждевременной сходимости
β 2–5 Увеличение → ближе к жадному алгоритму, быстрее сходится, хуже качество
ρ 0.01–0.5 Увеличение → быстрее адаптация, но нестабильнее
Q ~длина опт. пути Влияет на масштаб феромона, не на поведение
m n…2n городов Больше → лучше исследование, медленнее итерация

Взаимодействие параметров α и β#

Параметры α и β работают в паре и определяют баланс между разведкой (exploration) и использованием (exploitation):

  • Малое α, большое β — муравьи почти не обращают внимания на феромон и выбирают ближайший непосещённый город. Алгоритм ведёт себя как жадный и быстро сходится, но легко застревает в локальных оптимумах.
  • Большое α, малое β — муравьи следуют феромонным следам, игнорируя расстояния. Без исходного разнообразия алгоритм быстро вырождается и зацикливается на раннем, не лучшем решении.
  • Сбалансированные α ≈ 1, β ≈ 3–5 — рекомендуемый режим: муравьи учитывают оба сигнала, сохраняя разнообразие достаточное количество итераций.

Роль коэффициента испарения ρ#

Испарение контролирует память алгоритма:

  • При малом ρ (0.01–0.05) феромон испаряется медленно. Алгоритм долго помнит прошлые решения — хорошо для стабильных задач, но медленно реагирует на новую информацию.
  • При большом ρ (0.3–0.5) феромон быстро обнуляется. Алгоритм гибкий и исследует пространство активно, но рискует «забыть» хорошее решение прежде, чем закрепит его.

Практически: для задач с n < 50 можно использовать ρ = 0.3–0.5; для больших задач предпочтительнее ρ = 0.05–0.1.

Сложность алгоритма#

Аспект Оценка
Построение туров за итерацию O(m · n²)
Обновление феромонов O(n²)
Одна итерация итого O(m · n²)
Всего за I итераций O(I · m · n²)
Память O(n²) — матрица феромонов

Для n = 100 городов, m = 100 муравьёв, I = 500 итераций: ≈ 5 · 10⁹ операций. На практике константа мала, алгоритм работает за секунды.

Сходимость#

ACO не гарантирует нахождение глобального оптимума за фиксированное число итераций. Однако доказано, что при достаточно медленном испарении (ρ → 0) и бесконечном времени алгоритм сходится к оптимуму с вероятностью 1. На практике алгоритм показывает хорошие результаты в пределах 5–15% от оптимума уже за 100–500 итераций.

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

Сравнение с другими методами#

Метод Качество Скорость Настройка
Полный перебор Оптимальный O(n!) — неприменим при n > 20 Не нужна
Жадный (ближайший сосед) ~20–25% хуже оптимума O(n²) Не нужна
Генетический алгоритм Сопоставимо с ACO Схожая Сложная
ACO ~5–15% хуже оптимума O(I · m · n²) Умеренная
Имитация отжига Сопоставимо с ACO Зависит от расписания Умеренная

ACO особенно эффективен для задач, где граф меняется со временем: алгоритм адаптируется к изменениям через испарение, не требуя полного перезапуска.

Применение к задаче коммивояжёра#

import random
import math
import matplotlib.pyplot as plt

NUM_CITIES  = 20
NUM_ANTS    = 50
ALPHA       = 1.0   # влияние феромона
BETA        = 5.0   # влияние расстояния
EVAPORATION = 0.5
Q           = 100
GENERATIONS = 100

cities = [(random.uniform(0, 100), random.uniform(0, 100)) for _ in range(NUM_CITIES)]

distance_matrix = [
    [math.hypot(x1 - x2, y1 - y2) for x2, y2 in cities]
    for x1, y1 in cities
]

pheromone = [[1.0] * NUM_CITIES for _ in range(NUM_CITIES)]

def select_next_city(ant_path, current_city):
    probabilities = []
    for city in range(NUM_CITIES):
        if city in ant_path:
            probabilities.append(0)
        else:
            tau = pheromone[current_city][city] ** ALPHA
            eta = (1 / distance_matrix[current_city][city]) ** BETA
            probabilities.append(tau * eta)
    total = sum(probabilities)
    if total == 0:
        return random.choice([c for c in range(NUM_CITIES) if c not in ant_path])
    probabilities = [p / total for p in probabilities]
    return random.choices(range(NUM_CITIES), weights=probabilities)[0]

best_path, best_length = None, float('inf')

for gen in range(GENERATIONS):
    all_paths, all_lengths = [], []

    for _ in range(NUM_ANTS):
        path = [random.randint(0, NUM_CITIES - 1)]
        while len(path) < NUM_CITIES:
            path.append(select_next_city(path, path[-1]))
        length = sum(
            distance_matrix[path[i]][path[(i + 1) % NUM_CITIES]]
            for i in range(NUM_CITIES)
        )
        all_paths.append(path)
        all_lengths.append(length)
        if length < best_length:
            best_length, best_path = length, path

    # испарение
    for i in range(NUM_CITIES):
        for j in range(NUM_CITIES):
            pheromone[i][j] *= (1 - EVAPORATION)

    # обновление
    for path, length in zip(all_paths, all_lengths):
        deposit = Q / length
        for i in range(NUM_CITIES):
            a, b = path[i], path[(i + 1) % NUM_CITIES]
            pheromone[a][b] += deposit
            pheromone[b][a] += deposit

    print(f"Generation {gen:3d}: best = {best_length:.2f}")

def plot_route(route):
    x = [cities[i][0] for i in route] + [cities[route[0]][0]]
    y = [cities[i][1] for i in route] + [cities[route[0]][1]]
    plt.figure(figsize=(10, 6))
    plt.plot(x, y, marker='o')
    plt.title("Лучший найденный маршрут (ACO)")
    plt.show()

plot_route(best_path)
#include <algorithm>
#include <cmath>
#include <iostream>
#include <limits>
#include <numeric>
#include <random>
#include <vector>

// ── Параметры ────────────────────────────────────────────────────────────────
constexpr int    NUM_CITIES  = 20;
constexpr int    NUM_ANTS    = 50;
constexpr double ALPHA       = 1.0;   // влияние феромона
constexpr double BETA        = 5.0;   // влияние расстояния
constexpr double EVAPORATION = 0.5;   // коэффициент испарения ρ
constexpr double Q           = 100.0; // интенсивность феромона
constexpr int    GENERATIONS = 100;

using Matrix = std::vector<std::vector<double>>;

double euclidean(std::pair<double,double> a, std::pair<double,double> b) {
    auto [x1, y1] = a;
    auto [x2, y2] = b;
    return std::hypot(x1 - x2, y1 - y2);
}

Matrix buildDistMatrix(const std::vector<std::pair<double,double>>& cities) {
    int n = cities.size();
    Matrix dist(n, std::vector<double>(n, 0.0));
    for (int i = 0; i < n; ++i)
        for (int j = 0; j < n; ++j)
            if (i != j)
                dist[i][j] = euclidean(cities[i], cities[j]);
    return dist;
}

class Ant {
public:
    std::vector<int> tour;
    double tourLength = 0.0;

    void buildTour(int startCity,
                   const Matrix& dist,
                   const Matrix& pheromone,
                   std::mt19937& rng)
    {
        int n = dist.size();
        tour.clear();
        tour.reserve(n);

        std::vector<bool> visited(n, false);
        tour.push_back(startCity);
        visited[startCity] = true;

        for (int step = 1; step < n; ++step) {
            int current = tour.back();
            std::vector<double> weights(n, 0.0);
            double total = 0.0;

            for (int j = 0; j < n; ++j) {
                if (!visited[j]) {
                    double tau = std::pow(pheromone[current][j], ALPHA);
                    double eta = std::pow(1.0 / dist[current][j], BETA);
                    weights[j] = tau * eta;
                    total += weights[j];
                }
            }

            std::uniform_real_distribution<double> uni(0.0, total);
            double r = uni(rng), cumulative = 0.0;
            int chosen = -1;

            for (int j = 0; j < n; ++j) {
                if (!visited[j]) {
                    cumulative += weights[j];
                    if (r <= cumulative) { chosen = j; break; }
                }
            }
            if (chosen == -1)
                for (int j = 0; j < n; ++j)
                    if (!visited[j]) { chosen = j; break; }

            tour.push_back(chosen);
            visited[chosen] = true;
        }

        tourLength = 0.0;
        for (int i = 0; i < n; ++i)
            tourLength += dist[tour[i]][tour[(i + 1) % n]];
    }
};

int main() {
    std::mt19937 rng(std::random_device{}());
    std::uniform_real_distribution<double> coord(0.0, 100.0);

    std::vector<std::pair<double,double>> cities(NUM_CITIES);
    for (auto& [x, y] : cities) { x = coord(rng); y = coord(rng); }

    auto dist = buildDistMatrix(cities);
    Matrix pheromone(NUM_CITIES, std::vector<double>(NUM_CITIES, 1.0));

    std::vector<int> bestTour;
    double bestLength = std::numeric_limits<double>::infinity();
    std::vector<Ant> ants(NUM_ANTS);

    for (int gen = 0; gen < GENERATIONS; ++gen) {
        std::uniform_int_distribution<int> startDist(0, NUM_CITIES - 1);
        for (auto& ant : ants)
            ant.buildTour(startDist(rng), dist, pheromone, rng);

        for (const auto& ant : ants)
            if (ant.tourLength < bestLength) {
                bestLength = ant.tourLength;
                bestTour   = ant.tour;
            }

        for (auto& row : pheromone)
            for (auto& tau : row)
                tau *= (1.0 - EVAPORATION);

        for (const auto& ant : ants) {
            double deposit = Q / ant.tourLength;
            int n = ant.tour.size();
            for (int i = 0; i < n; ++i) {
                int a = ant.tour[i], b = ant.tour[(i + 1) % n];
                pheromone[a][b] += deposit;
                pheromone[b][a] += deposit;
            }
        }

        std::cout << "Generation " << gen << ": best = " << bestLength << "\n";
    }

    std::cout << "\nBest tour length: " << bestLength << "\nTour: ";
    for (int c : bestTour) std::cout << c << " ";
    std::cout << "\n";
    return 0;
}

Интерактивная визуализация#

Вопросы для самоконтроля#

Задание 1 — Наблюдение за феромонами

Запустите визуализацию с параметрами по умолчанию. Понаблюдайте за первыми 5–10 итерациями. Объясните: почему в начале работы алгоритма все рёбра графа одинаково тусклые, а затем часть из них начинает светлеть? Какой процесс за это отвечает?

Задание 2 — Влияние испарения

Переведите слайдер «Испарение» в минимальное значение (0.01), затем в максимальное (0.20). Сбросьте симуляцию между опытами. Опишите визуальную разницу: как меняется количество видимых троп, скорость их угасания и итоговое качество маршрута?

Задание 3 — Стадное поведение

Включите режим «Тепловая карта». Понаблюдайте, как со временем колония концентрируется на нескольких рёбрах. Свяжите увиденное с параметром α: почему при высоком α муравьи быстрее «сбиваются в стадо» на одном маршруте?

Задание 4 — Жадный алгоритм против ACO

Нажмите кнопку «Жадный алг.» и дождитесь, пока ACO завершит не менее 20 итераций. Сравните длины двух маршрутов в панели статистики. На сколько процентов ACO превосходит жадный алгоритм? Повторите опыт после сброса 3 раза и усредните результат.

Задание 5 — Размер популяции

Установите количество муравьёв равным 20, запустите до 30-й итерации и запомните лучший маршрут. Затем сбросьте и повторите с популяцией 500. Объясните: как размер колонии влияет на скорость сходимости и качество найденного решения? Есть ли смысл бесконечно увеличивать популяцию?

Задание 6 — Добавление города

Дождитесь, пока алгоритм найдёт стабильный маршрут (лучший маршрут не обновляется 10+ итераций). Кликните по пустому месту на поле, добавив новый город. Объясните, почему симуляция перезапускается: можно ли было просто «вставить» город в готовый маршрут без перезапуска алгоритма?

Генетический алгоритм#

import random
import string

# Целевая строка
TARGET = "Hello world!"
# Размер популяции
POPULATION_SIZE = 100
# Вероятность мутации
MUTATION_RATE = 0.01
# Символы, которые могут использоваться
CHARS = string.printable

def random_string(length):
    return ''.join(random.choice(CHARS) for _ in range(length))

def fitness(individual):
    return sum(1 for expected, actual in zip(TARGET, individual) if expected == actual)

def mutate(individual):
    return ''.join(
        c if random.random() > MUTATION_RATE else random.choice(CHARS)
        for c in individual
    )

def crossover(parent1, parent2):
    split = random.randint(0, len(TARGET))
    return parent1[:split] + parent2[split:]

# Инициализация популяции
population = [random_string(len(TARGET)) for _ in range(POPULATION_SIZE)]

generation = 0
while True:
    # Оценка приспособленности
    population = sorted(population, key=fitness, reverse=True)
    best = population[0]
    print(f"Gen {generation}: {best} (fitness: {fitness(best)})")

    if best == TARGET:
        break

    # Отбор лучших и создание новой популяции
    new_population = population[:2]  # Элитизм: сохранить лучших
    while len(new_population) < POPULATION_SIZE:
        parents = random.choices(population[:50], k=2)  # Скрещивать лучших
        child = mutate(crossover(*parents))
        new_population.append(child)

    population = new_population
    generation += 1

Применение к задачи коммивояжера#

import random
import math
import matplotlib.pyplot as plt

# ------------------------------
# Настройки задачи
NUM_CITIES = 20
POPULATION_SIZE = 100
GENERATIONS = 500
MUTATION_RATE = 0.01
# ------------------------------

# Генерация случайных координат городов
cities = [(random.uniform(0, 100), random.uniform(0, 100)) for _ in range(NUM_CITIES)]

def distance(a, b):
    return math.hypot(a[0] - b[0], a[1] - b[1])

def total_distance(tour):
    return sum(distance(cities[tour[i]], cities[tour[(i+1)%NUM_CITIES]]) for i in range(NUM_CITIES))

def create_route():
    route = list(range(NUM_CITIES))
    random.shuffle(route)
    return route

def mutate(route):
    if random.random() < MUTATION_RATE:
        i, j = random.sample(range(NUM_CITIES), 2)
        route[i], route[j] = route[j], route[i]
    return route

def crossover(parent1, parent2):
    start, end = sorted(random.sample(range(NUM_CITIES), 2))
    child = [None] * NUM_CITIES
    child[start:end] = parent1[start:end]
    ptr = 0
    for city in parent2:
        if city not in child:
            while child[ptr] is not None:
                ptr += 1
            child[ptr] = city
    return child

# Инициализация популяции
population = [create_route() for _ in range(POPULATION_SIZE)]

# Основной цикл
for gen in range(GENERATIONS):
    population.sort(key=total_distance)
    best = population[0]
    print(f"Gen {gen}, Distance: {total_distance(best):.2f}")

    # Сохранение лучших и создание потомков
    new_population = population[:10]
    while len(new_population) < POPULATION_SIZE:
        parents = random.choices(population[:50], k=2)
        child = mutate(crossover(*parents))
        new_population.append(child)
    population = new_population

# Визуализация лучшего маршрута
def plot_route(route):
    x = [cities[i][0] for i in route] + [cities[route[0]][0]]
    y = [cities[i][1] for i in route] + [cities[route[0]][1]]
    plt.figure(figsize=(10, 6))
    plt.plot(x, y, marker='o')
    plt.title("Лучший найденный маршрут")
    plt.show()

plot_route(best)

Метод имитации отжига#