Документация Engee

Задача о коммивояжере

Это руководство было изначально предоставлено Дэниелом Шермером (Daniel Schermer).

В этом руководстве описывается, как реализовать задачу о коммивояжере в JuMP с использованием независимых от решателя отложенных ограничений, которые динамически разделяют подциклы. Если говорить точнее, отложенные ограничения используются для отсечения недопустимых подциклов только тогда, когда это необходимо, но не раньше.

В нем используются следующие пакеты.

using JuMP
import GLPK
import Random
import Plots

Математическая формулировка

Предположим, дан полный граф , где  — это множество вершин (или городов), а  — множество ребер (или дорог). Для каждой пары вершин с ребром связан вес (или расстояние) .

В этом руководстве предполагается, что задача симметрична, то есть .

В задаче о коммивояжере нам предстоит найти маршрут минимальной длины ровно с одним заходом в каждую вершину и возвращением в исходную точку, то есть гамильтонов цикл с минимальным весом.

Для моделирования задачи введем двоичную переменную , которая указывает, является ли ребро частью маршрута. Используя эти переменные, задачу о коммивояжере можно смоделировать в виде следующей целочисленной линейной программы.

Целевая функция

Цель состоит в том, чтобы минимизировать длину маршрута (из-за предполагаемой симметрии вторая сумма содержит только ):

Обратите внимание, что вместо этого можно также использовать следующую целевую функцию:

Ограничения

В нашей формулировке имеется четыре класса ограничений.

Во-первых, ввиду предполагаемой симметрии должны выполняться следующие ограничения:

Во-вторых, для каждой вершины необходимо выбрать ровно два ребра, соединяющих ее с другими вершинами графа :

В-третьих, не допускаются циклы:

Четвертое ограничение более сложное. Основная трудность задачи о коммивояжере заключается в том, что необходимо предотвратить образование подциклов, то есть наличие нескольких различных гамильтоновых циклов в подграфах .

Обратите внимание, что предыдущие ограничения не гарантируют отсутствия подциклов в решении. С этой целью мы обозначим подмножество вершин как . Тогда для каждого строгого подмножества следующие ограничения гарантируют невозможность образования подциклов:

Проблема в том, что по мере увеличения количество требуемых ограничений такого типа растет экспоненциально. Поэтому мы будем добавлять их только при необходимости.

Реализация

В JuMP есть два способа устранения подциклов, и оба они будут показаны ниже:

  • итеративное решение новой модели, которая включает ранее выявленные подциклы;

  • добавление подциклов в виде отложенных ограничений.

Данные

Предполагается, что вершины случайным образом распределены в евклидовом пространстве; таким образом, вес (расстояние) каждого ребра определяется следующим образом.

function generate_distance_matrix(n; random_seed = 1)
    rng = Random.MersenneTwister(random_seed)
    X = 100 * rand(rng, n)
    Y = 100 * rand(rng, n)
    d = [sqrt((X[i] - X[j])^2 + (Y[i] - Y[j])^2) for i in 1:n, j in 1:n]
    return X, Y, d
end

n = 40
X, Y, d = generate_distance_matrix(n)
([23.603334566204694, 34.651701419196044, 31.27069683360675, 0.790928339056074, 48.86128300795012, 21.096820215853597, 95.1916339835734, 99.99046588986135, 25.166218303197184, 98.66663668987997  …  46.33502459235987, 18.582130997265377, 11.198087695816717, 97.6311881619359, 5.161462067432709, 53.80295812064833, 45.56920516275036, 27.93951106725605, 17.824610354168602, 54.89828719625274], [37.097066286146884, 89.41659192657593, 64.80537482231894, 41.70393538841062, 14.456554241360564, 62.24031828206811, 87.23344353741976, 52.49746566167794, 24.159060827129643, 88.48369255734127  …  66.12321555087209, 19.45678064479248, 39.3193497656424, 99.07406554003964, 55.03342139580574, 58.07816346526631, 76.83586278313636, 51.952465724186084, 51.486297110544356, 99.81360570779374], [0.0 53.473350122820904 … 15.506244459460921 70.09092934998034; 53.473350122820904 0.0 … 41.49527995497558 22.760099542720535; … ; 15.506244459460921 41.49527995497558 … 0.0 60.9096566304971; 70.09092934998034 22.760099542720535 … 60.9096566304971 0.0])

Для модели JuMP сначала инициализируем объект модели. Затем создадим двоичные переменные решения и добавим целевую функцию и ограничения. Определяя матрицу x как Symmetric, мы избегаем необходимости явным образом добавлять ограничения, согласно которым x[i, j] == x[j, i].

function build_tsp_model(d, n)
    model = Model(GLPK.Optimizer)
    @variable(model, x[1:n, 1:n], Bin, Symmetric)
    @objective(model, Min, sum(d .* x) / 2)
    @constraint(model, [i in 1:n], sum(x[i, :]) == 2)
    @constraint(model, [i in 1:n], x[i, i] == 0)
    return model
end
build_tsp_model (generic function with 1 method)

Для поиска нарушенных ограничений на основе ребер, имеющихся в решении (то есть имеющих значение ), мы определяем кратчайший цикл посредством функции subtour(). При каждом обнаружении подцикла в модель можно добавить ограничение в приведенной выше форме.

function subtour(edges::Vector{Tuple{Int,Int}}, n)
    shortest_subtour, unvisited = collect(1:n), Set(collect(1:n))
    while !isempty(unvisited)
        this_cycle, neighbors = Int[], unvisited
        while !isempty(neighbors)
            current = pop!(neighbors)
            push!(this_cycle, current)
            if length(this_cycle) > 1
                pop!(unvisited, current)
            end
            neighbors =
                [j for (i, j) in edges if i == current && j in unvisited]
        end
        if length(this_cycle) < length(shortest_subtour)
            shortest_subtour = this_cycle
        end
    end
    return shortest_subtour
end
subtour (generic function with 1 method)

Объявим вспомогательную функцию selected_edges(), которая будет неоднократно использоваться в дальнейшем.

function selected_edges(x::Matrix{Float64}, n)
    return Tuple{Int,Int}[(i, j) for i in 1:n, j in 1:n if x[i, j] > 0.5]
end
selected_edges (generic function with 1 method)

Другие вспомогательные функции для вычисления подциклов:

subtour(x::Matrix{Float64}) = subtour(selected_edges(x, size(x, 1)), size(x, 1))
subtour(x::AbstractMatrix{VariableRef}) = subtour(value.(x))
subtour (generic function with 3 methods)

Итеративный метод

Итеративный способ устранения подциклов заключается в следующем.

Однако разумно будет предположить, что этот способ не самый эффективный: всякий раз, когда в модель добавляется новое ограничение для исключения подцикла, оптимизацию приходится начинать с самого начала.

В результате решатель будет постоянно терять полезную информацию, полученную во время предыдущих решений (например, все разрезы, рекордное решение или нижние границы).

Обратите внимание, что в принципе в модель можно было бы добавить все подциклы (а не только самый короткий). Однако часто бывает достаточно устранить только самый короткий цикл, чтобы разорвать другие подциклы, а модель получается меньше.

iterative_model = build_tsp_model(d, n)
optimize!(iterative_model)
@assert is_solved_and_feasible(iterative_model)
time_iterated = solve_time(iterative_model)
cycle = subtour(iterative_model[:x])
while 1 < length(cycle) < n
    println("Found cycle of length $(length(cycle))")
    S = [(i, j) for (i, j) in Iterators.product(cycle, cycle) if i < j]
    @constraint(
        iterative_model,
        sum(iterative_model[:x][i, j] for (i, j) in S) <= length(cycle) - 1,
    )
    optimize!(iterative_model)
    @assert is_solved_and_feasible(iterative_model)
    global time_iterated += solve_time(iterative_model)
    global cycle = subtour(iterative_model[:x])
end

objective_value(iterative_model)
525.7039004442727
time_iterated
0.03748679161071777

Для быстрой проверки работоспособности мы визуализируем оптимальный маршрут, чтобы убедиться в отсутствии подциклов:

function plot_tour(X, Y, x)
    plot = Plots.plot()
    for (i, j) in selected_edges(x, size(x, 1))
        Plots.plot!([X[i], X[j]], [Y[i], Y[j]]; legend = false)
    end
    return plot
end

plot_tour(X, Y, value.(iterative_model[:x]))

Метод отложенных ограничений

При более изощренном подходе задействуются отложенные ограничения. Точнее, это делается с помощью функции subtour_elimination_callback() ниже, которая выполняется только тогда, когда встречается новое целочисленное допустимое решение.

lazy_model = build_tsp_model(d, n)
function subtour_elimination_callback(cb_data)
    status = callback_node_status(cb_data, lazy_model)
    if status != MOI.CALLBACK_NODE_STATUS_INTEGER
        return  # Выполнить только при целочисленных решениях
    end
    cycle = subtour(callback_value.(cb_data, lazy_model[:x]))
    if !(1 < length(cycle) < n)
        return  # Добавлять ограничение только при наличии цикла
    end
    println("Found cycle of length $(length(cycle))")
    S = [(i, j) for (i, j) in Iterators.product(cycle, cycle) if i < j]
    con = @build_constraint(
        sum(lazy_model[:x][i, j] for (i, j) in S) <= length(cycle) - 1,
    )
    MOI.submit(lazy_model, MOI.LazyConstraint(cb_data), con)
    return
end
set_attribute(
    lazy_model,
    MOI.LazyConstraintCallback(),
    subtour_elimination_callback,
)
optimize!(lazy_model)
@assert is_solved_and_feasible(lazy_model)
objective_value(lazy_model)
525.7039004442727

Находится тот же оптимальный маршрут:

plot_tour(X, Y, value.(lazy_model[:x]))

Удивительно, но для этой конкретной модели с GLPK время решения хуже, чем при итеративном методе:

time_lazy = solve_time(lazy_model)
0.38207197189331055

Однако в большинстве других случаев и с большинством других решателей метод отложенных ограничений должен выполняться быстрее итеративного метода.