Minimum-Comparison Merging in spire
BinaryMerge - Efficient binary merge in spire
Problem
At the end of 2014, I was thinking about what would be the most efficient way to merge two sorted sequences.
The answer is obviously trivial if you consider copying elements to be roughly as expensive as comparing elements. In that case, simply compare the first remaining element of each sequence and take the smaller one, until you run out of elements in one of the sequences, then just copy the rest. Here is the code in spire.
But in many cases this the assumption that comparing is as expensive as copying is not true. Let’s say you have a sequence of BigInt
, Rational
, very long String
or complex tuples. In that case comparing two elements will be several orders of magnitude more expensive than copying an element.
So let’s consider the case where only the number of comparisons matters, and the copying is considered to be essentially free (Copying a pointer is just about the cheapest operation you can do. You can literally copy millions of pointers in less than a millisecond on a modern machine).
In that case, the seemingly trivial problem of merging two sorted lists turns into a problem that has 10 pages of TAOCP devoted to it (Volume 3, Pages 197-207, Minimum-Comparison Merging)
So I did what you usually do in this situation: ask on stackexchange. Given that this should be a pretty common problem, I was expecting an answer like “you obviously have to use the Foo-Bar algorithm described in 1969 by XYZ”. But to my surprise, the algorithm that was posted as the answer, despite being called A simple algorithm for merging two disjoint linearly-ordered sets (F. K. Hwang , S. Lin), is not very simple. It is asymptotically optimal, but too complex to degrade well in the case that the comparison is relatively cheap.
Also, it is pretty complex to implement. For example, it is using floating point operations to calculate indices.
So I tried to come up with a simpler solution.
Cases
There are several cases that have to be considered when merging two sorted sequences. Coming up with a good solution for any of these cases is simple. The challenge is to come up with a solution that works well for all of the cases and that gracefully degrades in the worst case.
a) Merging long sequence with single element sequence
a = [1,2,3,4,6,7,8,9,10]
b = [5]
The best solution in this case is to do a binary search for the insertion point of the single element of b
in a
, then just copy
- the part of
a
that is belowb(0)
- the element
b(0)
- the part of
a
that is aboveb(0)
Obviously it would be possible to just special-case this solution. But that would be unelegant and in any case would not help in case b)
b) Merging a long sequence and a short sequence
a = [1,2,4,5,6,7,9,10]
b = [3,8]
In this case you might be tempted to just insert all elements of the smaller list into the larger list, doing binary searches for each insert. But that would be less than optimal. From the insertion position of the first element, we know which elements are definitely smaller than the second element and thus do not have to be compared, so we can restrict the range of the second binary search based on the result of the first.
c) Merging two large sequences which are non-overlapping
a = [1,2,3,4,5]
b = [6,7,8,9,10]
This is a case where you can expect huge performance gains, because you just have to copy one list after the other. You could detect this case by comparing the first element of one sequence with the last element of the other sequence and vice versa. But the cost of that comparison will be overhead in other cases, so you can only justify this if you know that this case is very common (which we don’t).
d) Merging two completely interleaved sequences
a = [1,3,5,7,9]
b = [2,4,6,8,10]
This is the worst case, where it won’t be possible to get better results than the linear merge. Any good algorithm should gracefully handle this case without doing much more than m + n - 1 comparisons. Depending on what you expect as the average case, doing twice as many comparisons might still be OK. But e.g. o(m log n) comparisons, like you would get by inserting all n elements from the smaller list into the larger list with m elements, would not be ok.
Coming up with a good algorithm
Being a functional programmer, I think that the most elegant algorithms are recursive. So let’s think about how a recursion step would look like.
Naming
Let’s use a0
and a1
for the first (inclusive) and last (exclusive) index of a
that we’re currently interested in. Likewise, b0
and b1
for the first (inclusive) and last (exclusive) index of b
that we’re currently interested in.
The base cases
Before we start thinking about complex things, let’s consider the base case(s). Merging a section of a sequence with an empty section of another sequence means just copying over all elements of interest from that sequence to the target sequence. So if a0
is a1
, just copy everything from b0
until b1
to the result, and vice versa.
The first comparison
It is clear that we have to gain the maximum information from each comparison in order to limit the number of comparisons to the minimum. So it seems intuitively obvious that we have to compare the middle element of a
with the middle element of b
. No matter what the result of the comparison is, we have 50% of all elements in a
that we never again have to compare with 50% of the elements in b
. We have gained information for a quarter of all possible comparisons with just a single comparison. If you had a table of size m * n with each cell being a possible comparison, executing the comparison at the center of the table allows you to eliminate an entire quadrant of the table.
5 | 6 | 7 | 8 | 9 | |
---|---|---|---|---|---|
1 | > | > | > | ||
3 | > | > | > | ||
5 | > | > | > | ||
7 | |||||
9 |
am = (a0 + a1) / 2
bm = (b0 + b1) / 2
a(am) < b(bm)
, so all elements a(i), a0 ≤ i ≤ am
are smaller than all elements b(j), bm ≤ j < b1
.
The recursion step
Now that know what we have to do for the first comparison, what do we do with it? What I came up with is the following: we look for the insertion index of the center element of a
in b
, using a binary search. The first comparison done by the binary search will be exactly as described above. Once we have the result, which we shall call bm
, we can recurse.
We have to merge elements a0 until am
from a
with all elements b0 until bm
from b
. Then we have to copy the single element a(am)
to the result, and finally merge elements am + 1 until a1
from a
with all elements bm + 1 until b1
from b
.
And that’s it. Here is our code, for the case that a
and b
are disjoint ordered sets.
def merge0(a0: Int, a1: Int, b0: Int, b1: Int): Unit = {
if (a0 == a1) {
// base case 1
fromB(b0, b1)
} else if (b0 == b1) {
// base case 2
fromA(a0, a1)
} else {
// find position of center element of a in b
val am = (a0 + a1) / 2
// binary search for element a(am) in b, from b0 until b1
val res = binarySearchB(am, b0, b1)
// we know that res is negative, since a and b do not have common elements
val bm = -res - 1
// merge everything below a(am) with everything below the found insertion point into the result
merge0(a0, am, b0, bm)
// add a(am) to the result
fromA(am, am + 1)
// everything above a(am) with everything above the found insertion point into the result
merge0(am + 1, a1, bm, b1)
}
}
Note that while this method is using recursion, it is not referentially transparent. The result sequence is built in the methods fromA and fromB using a mutable builder for efficiency. Of course, you will typically wrap this algorithm in a referentially transparent way.
Also note that the version in spire is slightly more complex, because it also works for the case where there are common elements in a
and b
, and because it is sometimes an advantage to have the insertion point.
Here is an example how the merging strategy is used to merge two sorted Array[T]
given an Order[T]
.
Behavior for the cases described above
a) Merging long list with single element list
It might seem that the algorithm is not symmetric. But at least for the case of merging a large list with a single element list, the algorithm boils down to a binary search in both cases.
b) Merging a long list and a small list
The algorithm will use the information from the comparison of both middle elements to avoid unnecessary comparisons
c) Merging two long non-overlapping lists
The algorithm will figure out in O(log n) in the first recursion step that the lists are disjoint, and then just copy them
d) Merging interleaved lists
This is tricky, but tests with counting comparisons have indicated that the maximum number of comparisons is never much more than m + n - 1
.
Benchmarks
Case c: Merging two long non-overlapping lists
A simple benchmark was done to compare the linear merge with the binary merge in the case where two long, non-overlapping sequences are compared. This was a case that was very important for my original use case. The benchmark was done using both small rational numbers (as an example with a reasonably expensive comparison) and integers (as an example with a very cheap comparison).
val ar = (0 until 1000).map(Rational.apply).toArray
val br = (1000 until 1001).map(Rational.apply).toArray
th.pbenchOffWarm("binary merge vs. linear merge (Rational)")(th.Warm(LinearMerge.merge(ar,br)))(th.Warm(BinaryMerge.merge(ar,br)))
val ai = (0 until 1000).toArray
val bi = (1000 until 1001).toArray
th.pbenchOffWarm("binary merge vs. linear merge (Int)")(th.Warm(LinearMerge.merge(ai,bi)))(th.Warm(BinaryMerge.merge(ai,bi)))
Here are the results.
[info] Benchmark comparison (in 4.859 s): linear merge vs. binary merge (Rational)
[info] Significantly different (p ~= 0)
[info] Time ratio: 0.04289 95% CI 0.04228 - 0.04351 (n=20)
[info] First 20.01 us 95% CI 19.85 us - 20.17 us
[info] Second 858.3 ns 95% CI 848.2 ns - 868.5 ns
[info] Benchmark comparison (in 3.239 s): linear merge vs. binary merge (Int)
[info] Significantly different (p ~= 7.076e-05)
[info] Time ratio: 1.02244 95% CI 1.01220 - 1.03267 (n=20)
[info] First 805.4 ns 95% CI 799.6 ns - 811.2 ns
[info] Second 823.5 ns 95% CI 817.7 ns - 829.2 ns
As expected, the performance difference for the rational case is pretty large, despite this being small rational numbers. For rational numbers with complex fractions, the difference would be even larger.
In the integer case, there is no meaningful performance difference. This is to be expected, given that comparing two integers is almost as cheap as copying an integer.
Case d: Merging interleaved lists
Now let’s look at the results for the absolute worst case, where the two sequences are completely overlapping and exactly interleaved.
val ar = (0 until 2000 by 2).map(Rational.apply).toArray
val br = (1 until 2001 by 2).map(Rational.apply).toArray
th.pbenchOffWarm("binary merge vs. linear merge (Rational)")(th.Warm(LinearMerge.merge(ar,br)))(th.Warm(BinaryMerge.merge(ar,br)))
val ai = (0 until 2000 by 2).toArray
val bi = (1 until 2001 by 2).toArray
th.pbenchOffWarm("binary merge vs. linear merge (Int)")(th.Warm(LinearMerge.merge(ai,bi)))(th.Warm(BinaryMerge.merge(ai,bi)))
Here are the results.
[info] Significantly different (p ~= 0)
[info] Time ratio: 2.01071 95% CI 1.97655 - 2.04487 (n=20)
[info] First 50.57 us 95% CI 49.96 us - 51.18 us
[info] Second 101.7 us 95% CI 100.5 us - 102.9 us
[info] Benchmark comparison (in 4.595 s): linear merge vs. binary merge (Int)
[info] Significantly different (p ~= 0)
[info] Time ratio: 8.65913 95% CI 8.56595 - 8.75230 (n=20)
[info] First 6.016 us 95% CI 5.985 us - 6.047 us
[info] Second 52.09 us 95% CI 51.60 us - 52.59 us
In the case of rational numbers, the binary merge is a bit slower than the linear merge. But not by a significant factor. In the case of integers, where the cost of a comparison is almost too cheap to measure, the linear merge is superior by a factor of 5 or so. But this is to be expected, simply due to the fact that calls to System.arraycopy to copy a single element will be more expensive than just copying an integer. And again, this represents the exact opposite of what the binary merge is made for.
a) Merging long sequence with single element sequence
This case is important e.g. when adding single elements by means of a merge.
Benchmark
val ar = (0 until 2000).filter(_ != 666).map(Rational.apply).toArray
val br = Seq(666).map(Rational.apply).toArray
th.pbenchOffWarm("linear merge vs. binary merge (Rational)")(th.Warm(LinearMerge.merge(ar,br)))(th.Warm(BinaryMerge.merge(ar,br)))
val ai = (0 until 2000).filter(_ != 666).toArray
val bi = Seq(666).toArray
th.pbenchOffWarm("linear merge vs. binary merge (Int)")(th.Warm(LinearMerge.merge(ai,bi)))(th.Warm(BinaryMerge.merge(ai,bi)))
Result
[info] Benchmark comparison (in 4.918 s): linear merge vs. binary merge (Rational)
[info] Significantly different (p ~= 0)
[info] Time ratio: 0.06785 95% CI 0.06733 - 0.06838 (n=20)
[info] First 19.07 us 95% CI 18.96 us - 19.18 us
[info] Second 1.294 us 95% CI 1.287 us - 1.301 us
[info] Benchmark comparison (in 4.069 s): linear merge vs. binary merge (Int)
[info] Significantly different (p ~= 0)
[info] Time ratio: 0.73762 95% CI 0.73167 - 0.74357 (n=20)
[info] First 1.655 us 95% CI 1.644 us - 1.666 us
[info] Second 1.221 us 95% CI 1.216 us - 1.227 us
As expected, the linear merge is much faster in the case of rational (moderately expensive comparison). Perhaps unexpectedly, it is still a bit faster even with integers. I guess the reason for that is that using System.arraycopy to copy a number of integers is faster than copying them one by one in a while loop.
Questions
-
Does anybody know the name of this algorithm? I think that it is an improvement over the Hwang-Lin algorithm in real world cases. So if you know the name, I really would like to know it. If not, I call dibs. I find this tremendously useful and use it in several of my open source libraries such as abc and intervalset, as well as in proprietary libraries for my current employer.
-
I am pretty convinced that this algorithm is asymptotically optimal, just like Hwang-Lin. But I don’t really know how to prove this. Anybody have any hints how to approach something like this?
-
Does anybody know an implementation of the Hwang-Lin merge algorithm in java? I would like to do comparative benchmarks!