Some decisions are not amounts but arrangements: an ordering, such as
the stops of a courier’s route or the jobs on a machine, or a one-to-one
assignment, such as which department moves into which office. quicopt
has a decision variable for this, the permutation. This article
introduces it with two examples, a delivery route and an office plan. It
assumes you have read vignette("quicopt").
A courier has five stops to make along one road. The courier can start at any stop and ends at the last one, with no return trip. Stop 4 must be visited before stop 1, because a parcel collected at stop 4 is delivered to stop 1. In which order should the stops be visited to keep the route as short as possible?
The stops lie at these distances along the road, so the distance between two stops is the difference of their positions:
A permutation arranges things, called items, in as many numbered places, called slots, with exactly one item in each slot. Items and slots are both numbered from 1. What they stand for depends on the problem:
| Problem | Items | Slots |
|---|---|---|
| a route | the stops | the visits: slot 1 is the first stop visited, slot 2 the second, … |
| a machine | the jobs | the positions in the queue |
| an office plan | the departments | the offices |
For the route, the items are the five stops and the slots are the
five visits. perm_var() declares the permutation, and
precede() requires one item to be in an earlier slot than
another:
m <- model()
route <- perm_var(m, "route", 5)
precede(route, 4, 1) # stop 4 is visited before stop 1Two functions read a permutation, one in each direction:
item_at(s, route) is the item in slot s:
the stop visited s-th.slot_of(i, route) is the slot of item i:
when stop i is visited.Neither is known before the solve. Like a decision variable, each is an expression whose value the service chooses.
The route goes from the first stop visited to the second, from the
second to the third, and so on: four legs in all. The length of leg
s is the distance from item_at(s, route) to
item_at(s + 1, route), which has to be looked up in
km.
That lookup cannot be written as
km[item_at(1, route), item_at(2, route)], because
km is an ordinary R matrix and the stops are not known yet.
Instead, lookup_table() adds the matrix to the model, under
a name, and indexing it with expressions builds a lookup that the
service carries out for whatever ordering it is considering:
dist <- lookup_table(m, "dist", km)
legs <- dist[item_at(1:4, route), item_at(2:5, route)]
minimize(m, sum(legs))Indexing a lookup table pairs up its indices, which is different from
an ordinary R matrix. km[1:4, 2:5] is a 4 × 4 block of
km, but legs has four elements: the distance
from the 1st stop to the 2nd, from the 2nd to the 3rd, from the 3rd to
the 4th and from the 4th to the 5th.
For each permutation in the model, res$structures holds
both directions as integer vectors. item_at lists the item
in each slot, which for a route is the stops in the order they are
visited:
| slot | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
item_at |
4 | 2 | 5 | 3 | 1 |
slot_of lists the slot of each item, which is when each
stop is visited:
Stop 1 is visited 5th, stop 2 2nd, and so on. The route runs from the far end of the road at kilometre 4 back to kilometre 0, a length of 4, and it is the only shortest route that visits stop 4 before stop 1.
Permutations combine with random variables (see
vignette("stochastic", package = "quicopt")). Suppose
traffic makes each leg take between 1 and 1.5 times as long as it would
on an empty road. The expected time of the route is then the
objective:
m <- model()
dist <- lookup_table(m, "dist", km)
route <- perm_var(m, "route", 5)
precede(route, 4, 1)
traffic <- rand_var(m, "traffic", uniform(1, 1.5), length = 4) # slowdown on each leg
set_scenarios(m, 256, seed = 3)
legs <- dist[item_at(1:4, route), item_at(2:5, route)]
minimize(m, expectation(sum(legs * traffic)))
res <- solve(m)
res$structures$route$item_at
#> [1] 4 2 5 3 1
res$objective
#> [1] 5.012369The route is the same, and its expected time is about 4 × 1.25 = 5,
since the average slowdown is 1.25. To check the figure on new
scenarios, resample() keeps the route fixed and redraws the
traffic:
The second example reads a permutation the other way round. Four departments move into four offices along a corridor, and the staff walk between departments several times a day. Which department should go into which office so that the total walking is as short as possible?
office <- c(0, 10, 20, 40) # metres along the corridor, offices 1 to 4
# departments: 1 production, 2 sales, 3 shipping, 4 design
from <- c(1, 1, 1, 2)
to <- c(2, 3, 4, 4)
trips <- c(5, 20, 10, 30) # walks per day between `from` and `to`Here the items are the four departments and the slots are the four
offices. The distance between two departments depends on the offices
they are in, which is what slot_of() gives:
m <- model()
walk <- lookup_table(m, "walk", abs(outer(office, office, "-"))) # metres between offices
place <- perm_var(m, "place", 4)
metres <- walk[slot_of(from, place), slot_of(to, place)] # one per pair of departments
minimize(m, sum(trips * metres))
res <- solve(m)
res$structures$place$slot_of
#> [1] 3 1 4 2
res$objective
#> [1] 900Production goes into office 3, sales into office 1, shipping into office 4 and design into office 2. Sales and design, the pair with the most walks, end up next to each other.
As a rule of thumb, item_at() fits data that belongs to
the slots and is read in slot order, such as the legs of a route.
slot_of() fits data that belongs to the items, such as the
walks between departments.
A lookup table is useful without a permutation, too. Indexed with an integer variable, it picks one entry out of a list. Here, the cheapest of four options:
perm_var(m, "route", 5, start = ...) sets the ordering
the search starts from, and set_start() takes the ordering
from an earlier result.evaluate() and resample() keep a
permutation fixed at the ordering in a result, as with
resample() above.precede() can be called several times on one
permutation. A set of requirements that contradicts itself, such as 1
before 2 and 2 before 1, is an error."heuristic".solve(m, time_limit = 60) lets it run for 60 seconds, which
can find a better ordering for a large model.