Before I start, I need to admit that I am not a mathematician and if possible would need this explained in laymen terms. I appreciate your patience with this.
The problem: A class consisting of n students whom I'd like to pair up throughout the school year.
The number of pairs is n/2. I'd like to maximize students working with new people as much as possible, and so exhaust all possible combinations. Permutations don't matter — student 1 + student 2 is same as student 2 + student 1.
What is an efficient way to build all non-repeating combinations of pairs?
One way, (suggested by Aleksei Malushkin), is to find all unique pairs, then all combinations of n/2 pair groups by brute-forcing out all non-valid groups.
Finding all unique pairs is trivial in Ruby: [1,2,3,4,5,6,7,8].combination(2).to_a
provides an array of all 2-item pairs.
What I need, however, is to produce all groups consisting of 4 pairs each, each group without repetition of students. If the class consists of 20 students, I will need all groups of 10 pairs.
The bruteforce approach creates all combinations of pairs and throws away the ones containing repeating pairs, but this falls apart very quickly with higher numbers of students.
Here's the ruby code which solves for 8 students:
# Ruby 2.5.3
students = [1,2,3,4,5,6,7,8]
possible_pairs = students.combination(2).to_a # https://ruby-doc.org/core-2.4.1/Array.html#method-i-combination
puts "Possible pairs: (#{possible_pairs.size}) #{possible_pairs}"
puts "Possible pair combinations: #{possible_pairs.combination(a.size/2).size}"
groups_without_repetition = possible_pairs.
combination(students.size/2). # create all possible groups with students.size/2 (4) members each
each_with_object([]) do |group, accumulator| # with each of these groups, and an "accumulator" variable starting as an empty array
next unless group.flatten.uniq.length == (students.size) # skip any group with repeating elements
next if accumulator.find {|existing_group| existing_group & group != []} # skip any group that may be found in the accumulator
accumulator << group # add any non-skipped group to the accumulator
end # returnn the value of the accumulator and assign it to groups_without_repetition
puts "actual pair combinations without repetition (#{groups_without_repetition.size}):"
groups_without_repetition.each_with_index do |arr, i|
puts "#{i+1}: #{arr}"
end
When run, this returns:
Possible pairs: (28) [[1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [4, 5], [4, 6], [4, 7], [4, 8], [5, 6], [5, 7], [5, 8], [6, 7], [6, 8], [7, 8]]
Possible pair combinations: 376740
actual pair combinations without repetition (7):
1: [[1, 2], [3, 4], [5, 6], [7, 8]]
2: [[1, 3], [2, 4], [5, 7], [6, 8]]
3: [[1, 4], [2, 3], [5, 8], [6, 7]]
4: [[1, 5], [2, 6], [3, 7], [4, 8]]
5: [[1, 6], [2, 5], [3, 8], [4, 7]]
6: [[1, 7], [2, 8], [3, 5], [4, 6]]
7: [[1, 8], [2, 7], [3, 6], [4, 5]]
It is however not efficient. With only 12 students, the possible pairs are 66, and the possible pair combinations 90858768. I am looking to apply this to a class with 80+ participants so this approach is clearly not going to work.
Question 1: What would be an efficient approach to construct these combinations?
Looking at the results, it seems to me that the number of valid groups is n/2 – 1, as a student can only belong to one of n/2 possible pairs.
My sense is that it would be more efficient to construct only the valid groups_without_repetition instead of creating all possible groups and throwing out the non-valid ones. I am not sure how to approach this process, and would appreciate your help.
Question 2: How to approach this with an odd number of students?
This may need to be a separate discussion so I would not worry about it unless it has a known solution.
a. In a case where one of the students will have to participate twice to accommodate the odd number
b. In a case where one of the pairs would become a trio
In each of these cases, the students who were a part of the above non-conventional pairings, should be excluded from further exceptions for as many rotations as possible.
Edit: Posting a Ruby-based solution, based on Alexander Burstein's answer
class SafeArray < Array
# make it possible to return items from the beginning of the array when
# the item's index exceeds the size of the array.
# Ruby does this automatically for negative indexes.
def [](i)
if i > size-1
super(i % size)
else
super(i)
end
end
end
class AllGroups
attr_reader :items, groups
def initialize(items)
@items = items
end
def generate!
@groups = []
return if items.size < 2
return @groups = [[ items[0], items[1] ]] if items.size < 4
n = items.size
infinity_point = items[0]
candidates = SafeArray.new( items[1..-1] )
(0..n-2).each do |i|
group = []
group << [infinity_point, candidates[i]] # first pair
(1..(n/2 - 1)).each do |k|
current_pair = [ candidates[i+k], candidates[i-k] ]
group << current_pair
end
@groups << group
end
end
end
# run with 8 "students"
students = %w{1 2 3 4 5 6 7 8}
all_groups = AllGroups.new(students)
all_groups.generate!
all_groups.groups.each_with_index do |g, i|
puts "#{i+1}: #{g}"
end
# results:
# 1: [["1", "2"], ["3", "8"], ["4", "7"], ["5", "6"]]
# 2: [["1", "3"], ["4", "2"], ["5", "8"], ["6", "7"]]
# 3: [["1", "4"], ["5", "3"], ["6", "2"], ["7", "8"]]
# 4: [["1", "5"], ["6", "4"], ["7", "3"], ["8", "2"]]
# 5: [["1", "6"], ["7", "5"], ["8", "4"], ["2", "3"]]
# 6: [["1", "7"], ["8", "6"], ["2", "5"], ["3", "4"]]
# 7: [["1", "8"], ["2", "7"], ["3", "6"], ["4", "5"]]
```
Best Answer
In graph theory terms, you would like to partition a complete graph into matchings ($1$-regular graphs). There is a well-known algorithm for doing that. Your students would be the vertices of this graph, and a pair of students working together would be an edge of this graph.
First, label your $n$ students (where $n$ is even) as follows: $\infty,0,1,2,\dots,n-2$. Think of the finite (non-$\infty$) labels as remainders modulo $n-1$. Now, for each $i=0,1,2,\dots,n-2$, pair your students as follows: $(\infty,i)$ and $(i+k,i-k)$ for $k=1,2,\dots,\frac{n}{2}-1$. In other words, except for the special pair with $\infty$, the sum of the two labels in a pair should be $2i$ modulo $n-1$.
For example, let $n=8$, then $n-1=7$ (so all labels are remainders modulo $7$), and your vertex labels are $\infty,0,1,2,3,4,5,6$. The matchings you need are as follows:
$$ \begin{matrix} i=0: & (\infty,0), (1,6), (2,5), (3,4)\\ i=1: & (\infty,1), (2,0), (3,6), (4,5)\\ i=2: & (\infty,2), (3,1), (4,0), (5,6)\\ i=3: & (\infty,3), (4,2), (5,1), (6,0)\\ i=4: & (\infty,4), (5,3), (6,2), (0,1)\\ i=5: & (\infty,5), (6,4), (0,3), (1,2)\\ i=6: & (\infty,6), (0,5), (1,4), (2,3) \end{matrix} $$
To see that this as a picture, imagine the labels $0,1,2,\dots,n-2$ as spaced evenly around a circle while $\infty$ is placed in the center. Then $(\infty,i)$ is a "minute hand" pointing at $i$, while the rest of the pairs are all edges perpendicular to it.