defmodule CPSolver.Examples.BinPacking.Search do
alias CPSolver.Variable.Interface
alias CPSolver.Search.Partition
@doc """
Complete decreasing best fit branching,
roughly as per https://www.gecode.dev/doc-latest/MPG.pdf, chapter 20
"""
def cdbf(item_weights, item_assignment_vars, bin_load_vars, capacity) do
## Create a list [{item_assignment_index, item_weight}]
## (will be used for matching the item assignment variables with items' weights)
##
## Note: item weights are sorted in decreasing order
item_assignment_map =
Enum.zip(item_assignment_vars, item_weights)
|> Map.new(fn {var, weight} -> {var.name, weight} end)
fn
:init, _, _ ->
:ok
:branch, variables, _data ->
case get_item_variables(variables, item_assignment_map) do
nil ->
nil
item_variables ->
selected_variable = List.first(item_variables)
{bins, slack, no_fit_bins} =
value_branching(selected_variable, bin_load_vars, item_assignment_map, capacity)
partitions(
item_variables,
bins,
slack,
no_fit_bins,
bin_load_vars,
item_assignment_map,
capacity
)
end
end
end
## Get the (unfixed) variables with the largest item weight.
defp get_item_variables(variables, item_assignment_map) do
## We rely on:
## - item weights sorted in descending order
## - the item assignment variables are adjacent to each other within the variable list;
## that is, all of them are located in the single block.
Enum.reduce_while(variables, {nil, nil}, fn v, {last_item_weight, item_vars_acc} = acc ->
## Is variable an 'item assignment' variable?
item_weight = Map.get(item_assignment_map, v.name)
cond do
is_nil(item_vars_acc) ->
(item_weight && {:cont, {item_weight, [v]}}) || {:cont, {nil, nil}}
true ->
if item_weight do
## Found another item assignment var
## The weight is different?
## We are only interested in the vars with the identical weights
if last_item_weight == item_weight do
{:cont, {item_weight, [v | item_vars_acc]}}
else
{:halt, acc}
end
else
## The end of item assignment variables' block
## (these variables have to be adjacent in the list of all variables)
{:halt, acc}
end
end
end)
|> then(fn {_, res} -> if res, do: Enum.reverse(res) end)
end
defp value_branching(var, bin_load_vars, item_assignment_map, capacity) do
## The variable is quaranteed to be unfixed `item assignment`,
## (see `choose_variable_fun`)
item_weight = get_item_weight(var, item_assignment_map)
## Find bins with minimal slack
##
{_bins, _bin_slack, _no_fit_bins} =
Enum.reduce_while(Enum.with_index(bin_load_vars, 1), {[], nil, []}, fn
{load_var, bin_idx}, {min_bins, min_slack, no_fit_bins} = slack_acc ->
cond do
Interface.contains?(var, bin_idx) ->
slack = slack(item_weight, capacity, load_var)
cond do
Interface.fixed?(load_var) ->
## The bin load has already been fixed,
## so the item has to be there (no choice).
## TODO: this is the case where further branching doesn't make sense.
## The related issue: https://github.com/bokner/fixpoint/issues/96
{:halt, {[bin_idx], nil, []}}
slack == 0 ->
## Perfect fit
{:halt, {[bin_idx], 0, []}}
slack < 0 ->
## No fit
{:cont, {min_bins, min_slack, [bin_idx | no_fit_bins]}}
slack < min_slack ->
## Better fit
{:cont, {[bin_idx], slack, no_fit_bins}}
true ->
## Keep current min, add bin to the list of current min bins
{:cont, {[bin_idx | min_bins], min_slack, no_fit_bins}}
end
true ->
{:cont, slack_acc}
end
end)
end
defp partitions(
[selected_variable | other_item_variables] = _item_variables,
bins,
slack,
no_fit_bins,
bin_load_vars,
item_assignment_map,
capacity
) do
cond do
Enum.empty?(bins) ->
if Enum.empty?(no_fit_bins) do
## all item variables are fixed
nil
else
## no bin is available for placement
throw(:fail)
end
is_nil(slack) ->
# The bin for the item has already been fixed.
## We only need a single partition with the fixed value.
selected_bin = List.first(bins)
[
Partition.fixed_value_partition(selected_variable, selected_bin)
]
slack == 0 || Enum.empty?(no_fit_bins) ->
## Perfect fit or all bins are fitting and have the same slack
selected_bin = List.first(bins)
[
Partition.fixed_value_partition(selected_variable, selected_bin),
Partition.removed_value_partition(selected_variable, selected_bin)
]
true ->
## As suggested by Gecode docs for 2-alternative branching
## (https://www.gecode.dev/doc-latest/MPG.pdf, chapter 20):
###
### – Not only prune bin b from the potential bins for item i,
## but also prune all bins with the same slack as b
## from the potential bins for all items with the same size as i
##
##
## Note: at this point, all item variables are assocated with the same item size as the first item variable.
##
## For each item variable, we will iterate over bin load vars to
## identify the ones with the same slack as computed for the first variable.
##
selected_bin = List.first(bins)
## We will also prune the selected variable, if there are no-fit bins
selected_variable_prun_partition =
Enum.empty?(no_fit_bins) &&
Partition.removed_value_partition(selected_variable, selected_bin) ||
Partition.remove_multiple_values_partition(selected_variable, no_fit_bins)
prune_partition =
Enum.reduce(other_item_variables, selected_variable_prun_partition,
fn variable, acc ->
w = get_item_weight(variable, item_assignment_map)
{_idx, acc} =
Enum.reduce(bin_load_vars, {1, acc}, fn load_var, {bin, acc2} ->
{bin + 1,
cond do
!Interface.contains?(variable, bin) ->
acc2
Interface.fixed?(load_var) ->
Map.put(acc2, variable.id, fn variable ->
Interface.fix(variable, bin)
end)
slack(w, capacity, load_var) == slack ->
Map.put(acc2, variable.id, fn variable ->
Interface.remove(variable, bin)
end)
true ->
acc2
end}
end)
acc
end
)
[
Partition.fixed_value_partition(selected_variable, selected_bin),
prune_partition
]
end
end
defp slack(item_weight, capacity, load_variable) do
capacity - Interface.min(load_variable) - item_weight
end
defp get_item_weight(variable, item_assignment_map) do
Map.get(item_assignment_map, variable.name)
end
end