2
$\begingroup$

The "standard" box-stacking algorithm(s) AFAIK assume a single stack and try to put the "largest" boxes on the bottom. The case I want to solve is simply to distribute the N boxes over (all) M stacks, such that the stacks are all approximately the same height (to the extent possible). The specific order of the boxes is not relevant. The boxes vary in height over a range {0.25, 1.00}. At first, I thought some "binomial" approach might be useful, but can't seem to get my head around it. Appreciate any ideas!

EDIT: @PC1 - Among multiple solutions, "a" would be "better" than "b" if the average difference in stack heights was smaller.
@nicoguaro- Minimizing std dev sounds promising! I'll test that!

$\endgroup$
2
  • $\begingroup$ I don't really understand what is the objective function that you're trying to express here. How can you say for instance that among 2 solutions, one is "better" than another? $\endgroup$
    – PC1
    Nov 27, 2021 at 18:37
  • 1
    $\begingroup$ @PC1, I guess that you could use a descriptor like the standard deviation or something l that describes "dispersion". You would want to minimize that. $\endgroup$
    – nicoguaro
    Nov 28, 2021 at 2:11

1 Answer 1

4
$\begingroup$

Overview

An optimization problem like this has almost always been considered previously, so one of the first lines of attack is to think of all the different ways your problem could be expressed and use those to search for research on the subject.

Your problem is equivalent to multiway number partitioning. Which asks for a multiset of numbers $S$ how they can be partitioned between $k$ sets such that the sums of each set are as close as possible. Your problem is also related to a number of machine scheduling problems.

As you and the commenters have discussed, there are a few ways to define "as close as possible". For instance, you could:

  • Minimize the difference between the largest and smallest sum
  • Minimize the largest sum
  • Maximize the smallest sum

These three objective functions give different values for $k\ge 3$, but all are approximations of formalizing what you're looking for.

For all three objective functions the problem is NP-hard. This means that you cannot know you have the best solution without somehow considering all possible solutions.

NP-hard problems are often approached by looking for approximate answers with provable quality guarantees because it would take far too long to examine the entire solution space to the true best solution.

Several algorithms give quick, approximate answers to your problem.

Approximate Solutions

List Scheduling

List scheduling, an example of greedy number partitioning iterates over the numbers in $S$ and assigns them one by one whatever set currently has the smallest sum. An implementation might look like this:

#!/usr/bin/env python3
from typing import List
import numpy as np

np.random.seed(123456)
num_sets = 5
numbers = np.random.random(size=100)
sets: List[List[int]] = [[] for _ in range(num_sets)]
set_sums = np.array([0.0 for _ in range(num_sets)])

for n in numbers:
  smallest_set = np.argmin(set_sums)
  sets[smallest_set].append(n)
  set_sums[smallest_set] += n

print(set_sums)

Note that this solution is "online" - you can run it continuously without knowing what the next number in $S$ will be.

This algorithm produces a solution whose largest sum is at most $2-\frac{1}{k}$ times the optimal largest sum.

Longest Processing Time

Longest-processing-time-first (LPT) is similar to the above, except the numbers are sorted in descending order before being added to the sets. Because sorting is required, the algorithm is no longer "online". An implementation might look like this:

#!/usr/bin/env python3
from typing import List
import numpy as np

np.random.seed(123456)
num_sets = 5
numbers = np.random.random(size=100)
numbers = -np.sort(-numbers) #Reverse-sort array
sets: List[List[int]] = [[] for _ in range(num_sets)]
set_sums = np.array([0.0 for _ in range(num_sets)])

for n in numbers:
  smallest_set = np.argmin(set_sums)
  sets[smallest_set].append(n)
  set_sums[smallest_set] += n

print(set_sums)

This algorithm produces a solution whose largest sum is at most $\frac{4}{3}-\frac{1}{k}$ times the optimal largest sum. The smallest sum will be at most $\frac{3k-1}{4k-2}$ times the optimal smallest sum. For iid values, the bound may be better: $1+O(\log \log n / n)$, but you should verify this using Frenk and Kan (1986, link).

SLACK

Croce and Scatamacchia (2018) introduce a heuristic they call SLACK for solving the problem. Though I haven't found a proven bound for it, they claim it outperforms LPT on test instances. The idea is to sort the numbers in decreasing order, split them into $k$ tuples, order those tuples by the difference between the maximum and minimum values of their entries, concatenate the tuples, and then use list scheduling to get the final solution. An implementation of this would look like:

#!/usr/bin/env python3
from typing import List
import numpy as np

np.random.seed(123456)
num_sets = 5
numbers = np.random.random(size=100)
numbers = -np.sort(-numbers) #Reverse-sort array
expected_size = int(np.ceil(len(numbers) / num_sets))
numbers_split = np.array_split(numbers, num_sets)
numbers_split.sort(key = lambda x: np.max(x) - np.min(x) if len(x)==expected_size else np.max(x), reverse=True)
numbers_comb = np.hstack(numbers_split)

sets: List[List[int]] = [[] for _ in range(num_sets)]
set_sums = np.array([0.0 for _ in range(num_sets)])
for n in numbers_comb:
  smallest_set = np.argmin(set_sums)
  sets[smallest_set].append(n)
  set_sums[smallest_set] += n

print(set_sums)

Karmarkar-Karp

The Karmarkar-Karp Algorithm Algorithm is known to perform no worse and often better than LPT in simulations where numbers are drawn uniformly from the range $[0,1]$. If $k+2\le n \le 2k$ then the largest sum it produces is at most $\frac{4}{3}-\frac{1}{3(n-k-1)}$ times the optimal largest sum. In all cases, the largest sum is at most $\frac{4}{3}-\frac{1}{3k}$ time the optimal largest sum.

The algorithm proceeds in several steps:

  1. For each number $i$ in $S$, we construct a $k$-tuple of subsets, in which one subset is $\{i\}$ and the other $k-1$ subsets are empty.
  2. At each iteration, we select the two $k$-tuples $A$ and $B$ in which the difference between their maximum and minimum sums is largest, and combine them in reverse order of sizes, i.e.: smallest subset in $A$ with largest subset in $B$, second-smallest in $A$ with the second-largest in $B$, and so on.
  3. We repeat Step 2 until a single partition remains.

An implementation might look like the following:

#!/usr/bin/env python3
import heapq
from typing import List, Tuple
from itertools import count

import numpy as np

Partition = List[List[float]]

def KarmarkarKarp(
  numbers: List[float], num_sets: int
) -> Tuple[Partition, List[float]]:
    def argsort(seq: List[float]) -> List[int]:
        return sorted(range(len(seq)), key=seq.__getitem__)
    partitions: List[Tuple[float, int, Partition, List[float]]] = []
    heap_count = count()  # To avoid ambiguity in heap
    for i in range(len(numbers)):
        this_partition: Partition = [[] for _ in range(num_sets -1)]
        this_partition.append([numbers[i]])
        this_sizes: List[float] = [0.0] * (num_sets - 1) + [numbers[i]]
        heapq.heappush(
            partitions, (-numbers[i], next(heap_count), this_partition, this_sizes)
        )
    for _ in range(len(numbers) - 1):
        _, _, p1, p1_sum = heapq.heappop(partitions)
        _, _, p2, p2_sum = heapq.heappop(partitions)
        new_sizes: List[float] = [
            p1_sum[j] + p2_sum[num_sets - j - 1] for j in range(num_sets)
        ]
        new_partition: Partition = [
            p1[j] + p2[num_sets - j - 1] for j in range(num_sets)
        ]
        indices = argsort(new_sizes)
        new_sizes = [new_sizes[i] for i in indices]
        new_partition = [new_partition[i] for i in indices]
        diff = new_sizes[-1] - new_sizes[0]
        heapq.heappush(partitions, (-diff, next(heap_count), new_partition, new_sizes))
    _, _, final_partition, final_sums = partitions[0]
    return final_partition, final_sums

np.random.seed(123456)
num_sets = 5
numbers = np.random.random(size=100)

sets, set_sums = KarmarkarKarp(numbers, num_sets)
print(set_sums)

Comparison

The above algorithms give these results as the subset sums:

List scheduling
    [9.78607846, 9.89566122, 9.62750904, 9.62253621, 9.59228928]
    max = 9.89566122
LPT
    [9.69177628, 9.69818904, 9.70391739, 9.70841387, 9.72177763]
    max = 9.72177763
SLACK
    [9.69539566 9.69812035 9.71967084 9.70318243 9.70770493]
    max = 9.719670841141273
Karmarkar-Karp
    [9.704530541764623, 9.704676654571145, 9.704761244535716, 9.704787373425246, 9.705318390488912]
    max = 9.705318390488912

List scheduling is the only online algorithm and, with O(N) time, the fastest, but it also gives the worst result (for this input). LPT is better than List Scheduling and SLACK is better than LPT. Karmarkar-Karp gives the best result, but has the trickiest implementation.

Exact Solutions

We can solve the problem exactly using algorithms such as the Complete Karmarkar-Karp. These turn out to be difficult to implement and, perhaps because of this, don't have many implementations available online. A good alternative is to used mixed-integer programming.

Basic formulation. Do to so, let the binary variable $x_{o,b}$ indicate whether object $o$ is assigned to bin $b$, and let $c_b$ be the capacity of bin $b$ (in case we want that). The problem is to minimize $z$ subject to \begin{align} \sum_b x_{o,b} &= 1 &&\text{for all $o$} \tag1\\ \sum_o w_o x_{o,b} &\le c_b &&\text{for all $b$} \tag2\\ \sum_o w_o x_{o,b} &\le z &&\text{for all $b$} \tag3\\ \end{align} Constraint $(1)$ assigns each object to exactly one bin. Constraint $(2)$ enforces the bin capacity. Constraint $(3)$ enforces the minimax objective.

Sparse formulation. For performance reasons, it's convenient to make the problem more sparse by introducing a variable $y_b$ to represent the common LHS of $(1)$ and $(2)$, and then minimizing $z$ subject to \begin{align} \sum_b x_{o,b} &= 1 &&\text{for all $o$} \\ \sum_o w_o x_{o,b} &= y_b &&\text{for all $b$} \\ y_b &\le c_b &&\text{for all $b$} \\ y_b &\le z &&\text{for all $b$} \\ \end{align}

Symmetry breaking. Note that the problem, as stated, has a symmetry: a solution that assigns a set $A$ of items to Bin 1 and a set $B$ of items to Bin 2 is just as good as a solution that assigns $A$ to 2 and $B$ to 1. We would like to avoid having our integer programming solver examine both solutions, since they are really the same, so we constrain the bins such that \begin{align} y_b &\le y_{b+1} &&\text{for all $b$} \end{align}

Heuristic upper-bound. The Karmarkar-Karp algorithm above is not guaranteed to give an optimal answer, but it is guaranteed to give a solution within $\frac{4}{3}-\frac{1}{3k}$ of the optimal solution. Thus, we can use the Karmarkar-Karp algorithm to get an upper bound on the actual solution and use this to constrain the integer programming solution as follows: \begin{align} y_b &\le KK_\textrm{sol} &&\text{for all $b$} \end{align}

Few Distinct Items. If we know that many items have the same weight (not the case for the question you've posed, but relevant to others), we can exploit this symmetry to reduce the problem size by letting $n_t$ be the number of objects of type $t$ (with weight $w_t$) and treating $x_{t,b}$ as an integer variable that represents the number of objects of type $t$ that are assigned to bin $b$. The problem then is to minimize $z$ subject to \begin{align} \sum_b x_{t,b} &= n_t &&\text{for all $t$} \\ \sum_t w_t x_{t,b} &= y_b &&\text{for all $b$} \\ y_b &\le c_b &&\text{for all $b$} \\ y_b &\le z &&\text{for all $b$} \\ \end{align}

The above leads to two implementations. Both use the sparse formulation with symmetry breaking and optional heuristic upper bounds. We call the one optimized for many distinct items DistinctItemsILP and the one optimized for few distinct items DuplicateItemsILP.

Unfortunately, MIPs can take a while to run, so we'll have to add a timeout value.

We can implement all that as follows:

#!/usr/bin/env python3
import heapq
from collections import Counter
from itertools import count
from typing import List, Optional, Tuple

import cvxpy as cp
import numpy as np

ResultType = Tuple[List[List[float]], List[float]]
Partition = List[List[float]]

def KarmarkarKarp(
  numbers: List[float], num_sets: int
) -> Tuple[Partition, List[float]]:
    def argsort(seq: List[float]) -> List[int]:
        return sorted(range(len(seq)), key=seq.__getitem__)
    partitions: List[Tuple[float, int, Partition, List[float]]] = []
    heap_count = count()  # To avoid ambiguity in heap
    for i in range(len(numbers)):
        this_partition: Partition = [[] for _ in range(num_sets -1)]
        this_partition.append([numbers[i]])
        this_sizes: List[float] = [0.0] * (num_sets - 1) + [numbers[i]]
        heapq.heappush(
            partitions, (-numbers[i], next(heap_count), this_partition, this_sizes)
        )
    for _ in range(len(numbers) - 1):
        _, _, p1, p1_sum = heapq.heappop(partitions)
        _, _, p2, p2_sum = heapq.heappop(partitions)
        new_sizes: List[float] = [
            p1_sum[j] + p2_sum[num_sets - j - 1] for j in range(num_sets)
        ]
        new_partition: Partition = [
            p1[j] + p2[num_sets - j - 1] for j in range(num_sets)
        ]
        indices = argsort(new_sizes)
        new_sizes = [new_sizes[i] for i in indices]
        new_partition = [new_partition[i] for i in indices]
        diff = new_sizes[-1] - new_sizes[0]
        heapq.heappush(partitions, (-diff, next(heap_count), new_partition, new_sizes))
    _, _, final_partition, final_sums = partitions[0]
    return final_partition, final_sums


def DistinctItemsILP(
  items: List[float], bins: int, bin_cap: Optional[List[float]] = None, timeout: int = 30, use_heuristic: bool = True
) -> ResultType:
  if bin_cap and len(bin_cap)!=bins:
    raise Exception("Number of bin capacities must equal number of bins!")

  heuristic_bound = None
  if use_heuristic:
    heuristic_bound = np.max(KarmarkarKarp(items, bins)[1])

  if bin_cap:
    bin_cap.sort()

  # Binary matrix indicating if an item is in a bin
  x = {}
  for item_i in range(len(items)):
    for bin_i in range(bins):
      x[item_i, bin_i] = cp.Variable(boolean=True)

  # Each item is assigned to 1 bin
  constraints = []
  for item_i in range(len(items)):
    constraints.append(cp.sum([x[item_i, bin_i] for bin_i in range(bins)]) == 1)

  # Get bin weights
  bin_weights = [cp.Variable(pos=True) for _ in range(bins)]
  for bin_i in range(bins):
    constraints.append(cp.sum([x[item_i, bin_i] * items[item_i] for item_i in range(len(items))]) == bin_weights[bin_i])
    # Each bin cannot exceed its capacity
    if bin_cap:
      constraints.append(bin_weights[bin_i] <= bin_cap[bin_i])
    if heuristic_bound:
      constraints.append(bin_weights[bin_i] <= heuristic_bound)

  # Use this to break symmetries
  if bin_cap:
    for b in range(bins-1):
      if bin_cap[b] == bin_cap[b+1]:
        constraints.append(bin_weights[b] <= bin_weights[b+1])
  else:
    for b in range(bins-1):
      constraints.append(bin_weights[b] <= bin_weights[b+1])

  # The weight of each bin is limited by max_bin_size, which we try to minimize
  max_bin_size = cp.Variable(pos=True)
  constraints += [bin_weights[bin_i] <= max_bin_size for bin_i in range(bins)]

  # Construct and solve the problem
  problem = cp.Problem(cp.Minimize(max_bin_size), constraints)
  optval = problem.solve(solver='CBC', maximumSeconds=timeout, verbose=True)

  # Extract the solution
  sets: List[List[float]] = []
  set_sums: List[float] = []
  for bin_i in range(bins):
    set_sums.append(sum([items[item_i] * x[item_i, bin_i].value for item_i in range(len(items))]))
    sets.append([items[item_i] for item_i in range(len(items)) if x[item_i, bin_i].value])

  return sets, set_sums


def DuplicateItemsILP(
  items: List[float], bins: int, bin_cap: Optional[List[float]] = None, timeout: int = 30, use_heuristic: bool = True
) -> ResultType:
  if bin_cap and len(bin_cap)!=bins:
    raise Exception("Number of bin capacities must equal number of bins!")

  heuristic_bound = None
  if use_heuristic:
    heuristic_bound = np.max(KarmarkarKarp(items, bins)[1])

  if bin_cap:
    bin_cap.sort()

  icounts = Counter(items) # Get a dictionary of <Item, Count> pairs

  constraints = []

  # Matrix of distinct items and how many times they appear in each bin
  x = {}
  for item, _ in icounts.items():
    for b in range(bins):
      x[item, b] = cp.Variable(integer=True)
      constraints.append(x[item, b] >= 0)

  # There are only so many of each item
  for item, count in icounts.items():
    constraints.append(cp.sum([x[item, b] for b in range(bins)]) == count)

  # Get bin weights
  bin_weights = [cp.Variable(pos=True) for _ in range(bins)]
  for bin_i in range(bins):
    constraints.append(cp.sum([item*x[item, bin_i] for item, _ in icounts.items()]) == bin_weights[bin_i])
    # Each bin cannot exceed its capacity
    if bin_cap:
      constraints.append(bin_weights[bin_i] <= bin_cap[bin_i])
    if heuristic_bound:
      constraints.append(bin_weights[bin_i] <= heuristic_bound)

  # Use this to break symmetries
  if bin_cap:
    for b in range(bins-1):
      if bin_cap[b] == bin_cap[b+1]:
        constraints.append(bin_weights[b] <= bin_weights[b+1])
  else:
    for b in range(bins-1):
      constraints.append(bin_weights[b] <= bin_weights[b+1])

  # The weight of each bin is limited by max_bin_size, which we try to minimize
  max_bin_size = cp.Variable(pos=True)
  constraints += [bin_weights[bin_i] <= max_bin_size for bin_i in range(bins)]

  # Construct and solve the problem
  problem = cp.Problem(cp.Minimize(max_bin_size), constraints)
  optval = problem.solve(solver='CBC', maximumSeconds=timeout, verbose=True)

  # Extract the solution
  sets: List[List[float]] = []
  set_sums: List[float] = []
  for bin_i in range(bins):
    set_sums.append(sum([item * x[item, bin_i].value for item, _ in icounts.items()]))
    sets.append([])
    for item, _ in icounts.items():
      sets[-1].extend([item for _ in range(int(x[item, bin_i].value))])

  return sets, set_sums

np.random.seed(123456)
num_sets = 5
numbers = np.random.random(size=100)

sets, set_sums = DistinctItemsILP(numbers, num_sets, timeout=480)
print(f"DistinctItemsILP: {set_sums}, max={max(set_sums)}")
sets, set_sums = DuplicateItemsILP(numbers, num_sets, timeout=480)
print(f"DuplicateItemsILP: {set_sums}, max={max(set_sums)}")
sets, set_sums = DistinctItemsILP(numbers, num_sets, timeout=480, use_heuristic=False)
print(f"DistinctItemsILP-no-heuristic: {set_sums}, max={max(set_sums)}")
sets, set_sums = DuplicateItemsILP(numbers, num_sets, timeout=480, use_heuristic=False)
print(f"DuplicateItemsILP-no-heuristic: {set_sums}, max={max(set_sums)}")

After running the above we find (duplicating the approximate solutions for comparison)

List scheduling
    [9.78607846, 9.89566122, 9.62750904, 9.62253621, 9.59228928]
    max = 9.89566122
LPT
    [9.69177628, 9.69818904, 9.70391739, 9.70841387, 9.72177763]
    max = 9.72177763
SLACK
    [9.69539566 9.69812035 9.71967084 9.70318243 9.70770493]
    max = 9.719670841141273
Karmarkar-Karp
    [9.704530541764623, 9.704676654571145, 9.704761244535716, 9.704787373425246, 9.705318390488912]
    max = 9.705318390488912
DistinctItemsILP
    **No feasible solution found within 480s**
DuplicateItemsILP (timed out)
    [9.70454479311396, 9.704684091094213, 9.704939500837135, 9.704939781454529, 9.704966038285805]
    max = 9.704966038285805
DistinctItemsILP-no-heuristic (timed out)
    [9.698474076805965, 9.705322596572808, 9.706392810380152, 9.706451385041921, 9.707433335984797]
    max = 9.707433335984797
DuplicateItemsILP-no-heuristic (timed out)
    [9.704106603441915, 9.704882096788454, 9.704910115144814, 9.705052586081315, 9.705122803329143]
    max = 9.705122803329143

Note that using the heuristic meant that DistinctItemsILP was unable to find any feasible solution within the allotted time while DistinctItemsILP without the heuristic found a solution, but one that was worse than that provided by the Karmarkar-Karp algorithm. The best way to deal with this would be to use the heuristic and then, if the MIP doesn't find a solution or finds a worse solution, to return the heuristic value.

DuplicateItemsILP performed better. With the heuristic it finds the best solution of any of the algorithms we've looked at. Running it without the heuristic gives a slight degradation in the optimal value found, but it still provides a better solution than Karmarkar-Karp. Again, running with the heuristic and returning the best value between the heuristic and the MIP is a good way to handle this.

Local Search

We used Karmarkar-Karp as a heuristic to get an upper bound for the MIP solvers. The MIP solvers are exact, but can take a long time to run. An alternative method for improving the results of Karmarkar-Karp or any other solver than doesn't return an optimal solution is to randomly swap numbers between sets accepting only those swaps that improve the solution. We can implement this for Karmarkar-Karp like so:

def SwapSearchFromExisting(sets: List[List[float]]) -> ResultType:
  def good_swap(set_ai: int, set_bi: int, aval: Optional[float], bval: Optional[float]):
    nonlocal set_sums, max_sum
    test_sums = set_sums.copy()
    test_sums[set_ai] = test_sums[set_ai] - (aval if aval else 0) + (bval if bval else 0)
    test_sums[set_bi] = test_sums[set_bi] + (aval if aval else 0) - (bval if bval else 0)
    if max(test_sums) < max_sum:
      set_sums = test_sums
      max_sum = max(test_sums)
      return True
    return False

  set_sums: List[float] = [sum(x) for x in sets]

  max_sum: float = max(set_sums)

  for _ in range(100000):
    set_ai = random.randint(0, len(sets)-1)
    set_bi = random.randint(0, len(sets)-1)
    set_a = sets[set_ai]
    set_b = sets[set_bi]
    item_a = random.randint(0, len(set_a)-1) if len(set_a)>0 else None
    item_b = random.randint(0, len(set_b)-1) if len(set_b)>0 else None
    if item_a and item_b and good_swap(set_ai, set_bi, set_a[item_a], set_b[item_b]):
      set_a[item_a], set_b[item_b] = set_b[item_b], set_a[item_a]
    elif item_a and good_swap(set_ai, set_bi, set_a[item_a], None):
      set_b.append(set_a[item_a])
      set_a.pop(item_a)
    elif item_b and good_swap(set_ai, set_bi, None, set_b[item_b]):
      set_a.append(set_b[item_b])
      set_b.pop(item_b)

  return sets, set_sums

sets, set_sums = SwapSearchFromExisting(KarmarkarKarp(numbers, num_sets)[0])
print(f"SwapSearchFromExisting: {set_sums}, max={max(set_sums)}")

This gives us

SwapSearchFromExisting
    [9.704991039048654, 9.704676654571143, 9.704761244535712, 9.704787373425246, 9.704857893204878]
    max = 9.704991039048654

This is slightly worse than the MIP solver but took a fraction of the time.

$\endgroup$
2
  • $\begingroup$ As a software developer (not mathematician :) ), I obviously had trouble searching because I'm not well-versed in the vernacular... your point is well taken! :) $\endgroup$
    – Jim
    Nov 28, 2021 at 17:13
  • $\begingroup$ A brief perusal of your (very informative) answer(s) leads me to think that the Karmarkar-Karp + local search approach, along with putting a bound on allowed execution time, could be a decent approach for my application. I appreciate your input! $\endgroup$
    – Jim
    Nov 28, 2021 at 17:20

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.