Assigning 4 pairs of people to 5 tasks over 5 days

combinatorics

8 people are to be assigned tasks for each day of the work week (5 days).

There are 5 tasks available that are performed in pairs – so there are 4 pairs performing different tasks each day, and one task does not get accomplished each day.

Each person must work with a different partner each day, and no person may perform the same task more than once during the week.

Is this problem solvable?

Best Answer

I don't see any way to do this except exhaustive search. The problem is amenable to Donald Knuth's dancing links algorithm, in that it can be viewed as a generalized set exact cover problem.

As explained in the linked paper, (or in Wikipedia) we can represent the problem as a $0$-$1$ matrix in which we must select a subset of the rows, such that each column has exactly one $1$ (the original problem) or at most one $1$ (the generalization.)

Consider a matrix in which each row represents an assignment of two workers to a task. The row $(A, B, 1, \text{Monday})$ means that workers $A$ and $B$ are assigned to task $1$ on Monday. There are $28$ possible pairings of $8$ workers, so we have a priori $28\cdot5\cdot5=1400$ rows.

For each combination of a worker and a task gives a column that must have exactly one $1$, since each worked performs each task once. Each combination of a worker and a day gives another "exactly one" column, since each worker is assigned to exactly one job each day. Each combination of a task and a day gives an "at most" column since each task is performed at most once in a particular day. Finally, each pair gives an "at most column", since no two workers can be paired twice. A priori, we have $118$ columns.

It is easy to cut down both the rows and columns substantially, since we may make the Monday assignments arbitrarily. We may assume that on Monday, workers $1$ and $2$ do job $1$, workers $3$ and $4$ do job $2$, and so on. This allows us to remove all rows involving Monday, all rows where workers $1$ and $2$ are paired, all rows where $1$ or $2$ is assigned to task $1$, and so on. We can also remove all the columns relating to Monday, and the worker-task columns that are obviated by Monday's assignments.

We can make another substantial reduction by deciding in advance which tasks are skipped on which days. Just as we assumed that tasks $1$ though $4$ are done on Monday and task $5$ is skipped, we can assumed that task $4$ is skipped on Tuesday, task $3$ is skipped on Wednesday, and so on. This allows us to eliminate rows that assign tasks on days they are not performed. We can also remove the corresponding task-day columns, and change the ones remaining to exact columns.

That said, I don't know how feasible it is to solve the problem. If there are many solutions, a computer program may find one quickly. If there are no solutions, I have no estimate of how long the program would run before exhausting all possibilities.

If the solution isn't feasible, we can break the problem down further by looking more closely at the worker pairings. Each worker is paired with $5$ others, so there are $2$ he isn't paired with. If we make a graph whose vertices are the workers, and where two vertices are adjacent if the workers never work together, then the graph is either an $8$-cycle, or the disjoint union of two $4$-cycles, or the disjoint union of a $3$-cycle and and $5$-cycle. We can treat each of these cases separately, deciding in each case which workers aren't paired, and eliminating corresponding rows and columns, in addition to the eliminations made above. In this case, all the columns become exact columns.

Still, I don't know if it's feasible. You just have to try.

EDIT

I did an exhaustive search, and it's insoluble, as you suspected. I used the first set of reductions that I mentioned, fixing a Monday schedule, and deciding which tasks would be skipped on which days. I had second thoughts about the reduction I mentioned at the end. It's okay when we have the $8$-cycle, because all the workers are interchangeable, but in the other cases, it's not clear that we can arbitrarily assign a Monday schedule. The python script ran in an eye blink anyway, so further reduction wasn't necessary.

Here is the python script I used for dancing links. I've used it many times, and I'm confident it's correct.

# algorithmX.py
# Retrieved from http://www.cs.mcgill.ca/~aassaf9/pRowsthon/algorithm_Cols.html
# on 18 December 2014
# Link no longer works.  Seems to have been replaced by
# https://www.cs.mcgill.ca/~aassaf9/python/algorithm_x.html

'''
In the set exact cover problem we are have a familiy F of subsets of a  set S,
and we seek to proude a subset of F that partitions S.
In the matrix formulation, Cols represents the columns of a 0-1 matrix, 
and Rows its rows.  The problem is to choose a subset of the rows, such that 
a one appears exactly once in each column of the submatrix comprising those rows.  

This script solves a generalized version of the exact cover problem.  Exactly one
one must appear in each primary column and at most one one must appear in each
secondary column.

Both Cols and Rows are implemented as dicts, with the key a column id in the
case of Cols or a row id in the case of Rows.  In Cols the value is a set of row ids, 
and for Rows the value is a list of column ids.  The ids in the collection are those
with a one in the indicated line of the matrix.

The only changes I've made to Ali Assaf's implementation, other than giving more
meaningful names to the variables, and expanding the comments, is to add the little 
wrapper to specify the maximum number of solutions to generate, and to extend it 
to handle secondary columns.
'''
global highWater
highWater = 0

def solve(Cols,  Rows, SecondaryIDs=set(), limit = 0):
    '''
    SecondaryIDs is an iterable of IDs for the secondary columns
    limit is the maximum number of solutions to generate.
    0 means generate all solutions
    '''
    for soln in solver(Cols, Rows, SecondaryIDs):
        yield soln
        limit -= 1
        if limit == 0: return

def solver(Cols, Rows, SecondaryIDs, solution=None):
    global highWater
    if solution is None: solution = [ ]
    live=[col for col in Cols if col not in SecondaryIDs]   # only place secondaryIds is actually used
    if not live:
        yield tuple(solution)   #freeze the answer to avoid later mutation
    else:
        col = min(live, key = lambda col: len(Cols[col]))        # hardest primary column to cover is best for branching
        for row in list(Cols[col]):                                          # for each row with a 1 in the current column
            solution.append(row)                                            # tentatively add it to the solution
            highWater = max(highWater, len(solution))
            columns = select(Cols, Rows, row)                        
            for soln in solver(Cols, Rows, SecondaryIDs, solution):
                yield soln
            deselect(Cols, Rows, row, columns)
            solution.pop()

def select(Cols, Rows, row):
    # row has just been tenatively added to the solution 
    # we need to remove any column with a 1 in row from the matrix,
    # since it's been covered.  For each such column, we need to
    # remove any row that has a 1 in the column, since adding that
    # row to the solution would cover the column a second time.
    # For any column with a 1 in such a removed row, we have to 
    # remove the 1, since it can no longer be covered by that row.
    # Finally, we return a list of the deleted columns, so that they
    # can be restored during backtrack.
    columns = []
    for col in Rows[row]:
        for rrow in Cols[col]:
            for ccol in Rows[rrow]:
                if ccol != col:
                    Cols[ccol].remove(rrow)
        columns.append(Cols.pop(col))  # the column is removed here
    return columns

def deselect(Cols, Rows, row, columns):
    # This is the inverse of select, where we remove row
    # from the solution, and put back the columns and rows
    # that were deleted from the matrix.  
    for col in reversed(Rows[row]):
        Cols[col] = columns.pop()
        for rrow in Cols[col]:
            for ccol in Rows[rrow]:
                if ccol != col:
                    Cols[ccol].add(rrow)

This is the script I wrote to generate input for the solver. You probably ought to check its correctness, at least if this problem is important to you. Note that there is no code to print the solution. I was waiting to see if one existed before writing the pretty printing.

'''
8 people are to be assigned tasks for each day of the work week (5 days).
There are 5 tasks available that are performed in pairs - so there are 4 pairs 
performing different tasks each day, and one task does not get accomplished each day.
Each person must work with a different partner each day, and no person may perform 
the same task more than once during the week.
'''
#schedule.py
from algorithmX import solve
from itertools import combinations, product
from collections  import namedtuple

Job = namedtuple('Job', 'x y task day'.split())
tasks = range(5)
days = 'mtwTf'
workers = 'ABCDEFGH'
jobs = []
skip = {t:days[t] for t in tasks}
jobs.append(Job('A', 'B',1,'m'))
jobs.append(Job('C', 'D',2,'m'))
jobs.append(Job('E', 'F',3,'m'))
jobs.append(Job('G', 'H',4,'m'))
pairs = list(combinations(workers,2))
for j in jobs:
    pairs.remove((j.x,j.y))
for p, t, d in product(pairs, tasks, days[1:]):
    if skip[t] == d: 
        continue
    jobs.append(Job(x=p[0], y=p[1], task=t, day=d))


rows = {  }    
exactly1 = {  }
atMost1 = { }
# the value for each row is a LIST of ids of the columns with a 1 in that row
# the value for each column is a SET of ids of the rows with a 1 in that column

for j in jobs:
    rows['%s%s%s%s'%j] = ['%s%s'%(j.x,j.day), '%s%s'%(j.y,j.day), '%s%s'%(j.x,j.task), 
                                          '%s%s'%(j.y,j.task), '%s%s'%(j.task,j.day), '%s%s'%(j.x,j.y)]

for w, d in product(workers, days):
    ident = '%s%s'%(w,d)
    exactly1[ident] = {r for r in rows if ident in rows[r] }
for w, t in product(workers, tasks):
    ident = '%s%s'%(w,t)
    exactly1[ident] = {r for r in rows if ident in rows[r] }  
for t, d in product(tasks, days):
        if skip[t] == d:
            continue
        ident = '%s%s'%(t,d)
        exactly1[ident] = {r for r in rows if ident in rows[r] } 
for x, y in combinations(workers, 2):
    ident = '%s%s'%(x,y)
    atMost1[ident] = {r for r in rows if ident in rows[r] }      

primary = exactly1
primary.update(atMost1)
secondary = atMost1.keys()
solution = [r for r in solve(primary, rows, secondary)]
if not solution:
    print('No solutions')
Related Question