Эвристические алгоритмы#
Муравьиный алгоритм (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:
Обозначения:
| Символ | Смысл |
|---|---|
J_{i,k} |
Список городов, доступных муравью k из города i |
τ_{i,j} |
Уровень феромона на ребре (i, j) |
η_{i,j} = 1/D_{i,j} |
Видимость — обратная длина ребра |
α |
Степень влияния феромона («стадность»). При α→0 алгоритм случаен, при α→∞ — детерминирован |
β |
Степень влияния расстояния («жадность»). При β=0 расстояние игнорируется; при β→∞ — жадный выбор |
Крайние случаи:
α = 0 → алгоритм выбирает ближайший город (жадный алгоритм)
β = 0 → алгоритм следует только феромону (может застрять в локальном оптимуме)
2. Депозит феромона#
После завершения тура муравей k откладывает феромон на пройденных рёбрах:
| Символ | Смысл |
|---|---|
Q |
Константа; порядок длины оптимального пути |
L_k |
Полная длина тура муравья k |
T_k |
Множество рёбер тура муравья k |
Суммарное обновление феромона на ребре от всей колонии:
3. Испарение и обновление феромонов#
| Символ | Смысл |
|---|---|
ρ ∈ (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)