Fork and clone the repository at https://github.com/Grinnell-CSC207/lab-merge-sort.
Merge sort over an input list can be pithily described as follows:
Divide the list in half.
Recursively merge sort the two halves.
Merge the two sorted halves together into a sorted whole.
Like binary search, we find ourselves needing to divide an input list in half.
Physically dividing up the list in half is costly, so, like binary search, our implementation will keep track of the lo
and hi
indices of the sub-list under consideration.
I recommend designating lo
to be inclusive and hi
to be exclusive so that the range 0
and list.size()
corresponds to the entire list.
Otherwise, two of the three steps of the algorithm are easy to implement. Dividing the list in half amounts to a midpoint calculation on indices and the recursion is realized with two recursive calls to merge sort, passing in the appropriate ranges. The difficulty—and where all the work actually lies—is in the merge operation which we will spend our time this lab honing.
At every step of the recursion, we perform a merge operation on two sub-lists. For example, suppose the list under consideration is:
[1, 4, 8, 3, 5, 9]
If we have already sorted the two halves of the list (which we have), we can merge the two halves into a sorted whole using a two-fingered approach. We maintain pointers to the two halves of the list—initially these pointers are pointing to the left-most (minimum) elements in the two halves:
V V
[1, 4, 8, 3, 5, 9]
We then repeatedly take the minimum of the two sublists (which by definition will be the minimum element of the elements not yet merged) and place that into a second scratch list.
For example, performing one iteration of this process, 1
is smaller than 3
so we place q
into our scratch list:
V V
[1, 4, 8, 3, 5, 9]
V
[1, ?, ?, ?, ?, ?]
Next 3
is less than 4
, so we place 3
in the scratch list and increment the right-hand pointer:
V V
[1, 4, 8, 3, 5, 9]
V
[1, 3, ?, ?, ?, ?]
This process continues until all the elements from the two halves of the list are exhausted. We can describe this process concisely with two invariants:
When we are done, the scratch list contains the merged elements of the original list. We can finally copy the elements of the scratch list back into the original list to complete the operation.
Sketch a picture of the invariants. It should look something like the following (with variable names instead of the X’s, and notes about the content of the different sections of the arrays).
+--- ---+---------+---------+---------+---------+--- ---+
| . . . | | | | | . . . |
+--- ---+---------+---------+---------+---------+--- ---+
| | | | | | |
0 X X X X X length
+-------------+-------------------------+
| | |
+-------------+-------------------------+
| | |
0 X X
Show your picture to your instructor when you are finished.
With this algorithm in mind, implement a merge
operation with the following function signature:
/**
* Merge the values from positions [lo..mid) and [mid..hi) back into
* the same part of the array.
*
* Preconditions: Each subarray is sorted accorting to comparator.
*/
static <T> void merge(T[] vals, int lo, int mid, int hi, Comparator<? super T> comparator) {
// ...
} // merge
You are likely to need a temporary array to merge into. There are a
variety of static methods in java.util.Arrays
that might be helpful
to create that temporary array. Here’s one.
static <T> T[] Arrays.copyOfRange(T[] original, int from, int to)
With merge
implemented, implement mergesort
:
/**
* Sort an array using the merge sort algorithm.
*/
public static <T> void sort(T[] vals, Comparator<? super T> comparator) {
// ...
} // sort
Because of the need to track bounds explicitly, you’ll need a helper version of mergeSort
that takes these bounds as arguments.
Initially you should pass 0
and vals.size()
to this helper method to kick off the merge sort process.
Note that you can compute the midpoint with (lo + hi)/2
. If you’ve paid attention in class, you might prefer to compute it with lo + (hi-lo)/2
.
Verify that your algorithm works on a number of examples. Make sure to check corner cases, e.g., zero-length lists, length-one lists, already-sorted lists, etc.
Analyze the time complexity of merge sort, giving an appropriate big-O bound. Does merge sort have a best-case or worst-case runtime that is different from this bound?
Analyze the space complexity of merge sort.
How much auxiliary space does merge sort use?
Remember to factor in both the amount of space dedicated to recursive function calls as well as additional heap allocations made by merge
.
If you build a new array each time you merge, you should have found the space complexity of merge sort to be unsatisfactory. It seems like you should be able to limit the creation of the scratch lists so that you only use O(n) space. Do so.
The original version of this laboratory was written by Peter-Michael Osera. Samuel A. Rebelsky made some revisions in spring 2019 and some more revisions in spring 2023. SamR added the code repository in spring 2023.