In module 0077, we have already explored the non-recursive merge sort in terms of an example to show how it works. Here, we begin with the algorithm, then we will do some analysis on the algorithm.
Listing 1 is the split algorithm as a subroutine in pseudocode.
In listing 1, we rely on a FIFO data structure. The methods for a FIFO are as follows:
The split operation reads from the input FIFO in, then output consecutive runs to the two output FIFOs in an interleaved way. Whenever a run is broken, as detected on line 11, the pointers to the output FIFOs are swapped. This ensures that we only want to output to the FIFO pointed to by out1.
Listing 2 is the merge algorithm as a subroutine.
The merge algorithm is a little more complicated than the split algorithm. Much of the logic is captured by the lookup array. This array is three-dimensional, but each dimension is only a Boolean.
The first index indicates whether lastValue is less than or equal to the head of the first input FIFO. If this is true, it means the head of the first input FIFO is a candidate as the next value to append to the output FIFO.
The second index indicates whether lastValue is less than or equal to the head of the second input FIFO. Again, if this is true, the head of the second input FIFO is a candidate to be next value appended to the output FIFO.
The third index indicates whether the head of the first input FIFO is less than or equal to that of the second input FIFO. This is useful when the heads of both input FIFOs are candidates, or when both of them are not candidates.
The initialization of this lookup array handles all the possible cases:
The values of the lookup array simply indicates the index of the input FIFO to use.
The conditions on lines 24 and 26 checks to see if a FIFO is empty. If so, use the other FIFO. Because of the precondition of the loop, we are guaranteed that at least one FIFO is not empty.
The actual operations are specified on lines 33 to 35. First, we maintain the sorted status of the output FIFO. This can be done by comparing what we are about to append to the last value appended. Next, we remember the value to be appended as the “last value”. Then, we append the value to the output FIFO.
Because we keep track of whether the output FIFO is sorted, we can return the value of the local variable sorted to indicate whether the output FIFO is sorted.
Once we define the merge and split operations, then we can define the merge sort algorithm itself in listing 3.
This algorithm is rather simple. All we have to do is to keep split-merging until the merged result is sorted.
This section deals with the mysterious FIFO type.
The FIFO type is an abstraction of data types that have the first-in-first-out nature. The queue abstract data type fits in this category. However, a sequential file also does. This is because when a sequential file is opened for reading, we can only read in one direction. When a sequential file is opened for writing, we can only write in one direction (same direction as reading).
This is why there are the rewind, rewrite and close methods.
A queue abstract data type is a FIFO. The translation of a queue ADT to a FIFO is straightforward. Some FIFO methods are not applicable to queues.
To abstract a sequential file as a FIFO, we need a little bit of work.
The head method allows the algorithm to “peek” the first item of a FIFO without removing it. Neither the ADT of a queue nor file operations implement this kind of operation.
As a result, the FIFO implementation needs some additional data members. First of all, as we rewind a FIFO, we need to follow the buffered read logic in listing 4 after performing the implementation dependent rewind operation (reopening a file, for example).
The FIFO head method just needs to return the data member buffer.g.
The FIFO remove method needs use buffered read. However, it needs to remember the value to return first, otherwise the buffered read operation overwrite the value to be returned. The logic of FIFO remove is listed in listing 5
The FIFO isempty is not a wrapper of the isempty method of the implementation. This is because we are performing buffered read. The actual implementation may be empty, but the FIFO itself may not be empty yet, because the last item in the actual implementation is actually stored in the data member buffer. In other words, FIFO isempty is always behind the implementation isempty by one read.
The split subroutine reads every value from the input FIFO, and outputs every value to one of the two output FIFOs. As a result, the amount of of time required by each invocation of “split” is proportional to the number of items to sort.
The merge subroutine reads every value from the two input FIFOs, and outputs each value to the output FIFO. As a result, the amount of time required by each invocation of “merge” is proportional to the number of items to sort.
How many iterations do we need? That’s the real question. Each iteration of the loop in “mergesort” calls split and merge each once. In other words, the execution time of each iteration of the loop in mergesort is proportional to the number of items to sort.
The question boils down to how quickly the algorithm reduce the number of runs in the worst case.
A run can be considered as a tuple . By definition, elements in a run are sorted. In our example, , assuming the tuple is sorted in a non-decreasing way.
Now, let us consider two runs, and . We claim that after we merge there two runs, the result is a single run with values.
How do we prove this?
Let us use proof-by-contradiction. We first negate the proposition that we want to prove, yielding “ and are two runs, after the merge process, the result is not a single run.”
Let us symbolically represent the merge result as . Let us define the first break point of the run as . Let us assume corresponds to .
Furthermore, let us define as follows: .
At this point, we have the sequence like this:
in which and .
If one of the comes from run , we have a contradiction because run is sorted.
If all of the come from run , we still have a contradiction. Let correspond to . When we choose the value of , the two candidates must have been and . We know that neither nor breaks the run as because , and is defined to be the first break of the run in .
According to the logic of the merge algorithm, we do not choose the larger one, which means that . But this contradicts the fact that .
Because we can only arrive at contradictions, the original proposition is true. This means that when we merge to runs, and , the result tuple (list) must be a single run.
The split algorithm ensures that if the original FIFO has runs, then each output FIFO has about runs. When is odd, then one output FIFO has runs.
The merge algorithm, with the proof from the previous section, combines the runs from both input FIFOs into runs when is odd. If is even, then the number of runs in the result is /
This means that if we start with 17 runs, there will be at most 9 runs after the first split-merge, at most 5 runs after the second iterations, at most 3 runs after the third, at most 2 runs after the fourth, and sorted after the fifth.
We can think about this backwards. If the result of a split-merge has runs, then the previous iteration has runs in the worst case. As a result, split-merge iterations can sort runs.
Want to see the proof of this? Let us define , and . Here, the function computes the least number of runs given the algorithm completes in iterations. I claim that . We can prove this by induction. is our base case. The induction step states that for all , if we assume , then
In the worst case that each run is a single value, split-merge iterations sort values. Because the execution time of each iteration is proportional to the number of values, the execution time of merge sort is proportional to for an input size of .
Instead of expressing everything in , which is the number of iterations needed to sort the input, let us use instead. We can, then, express the execution time to be proportional to . Note that . That means . As a result, the execution time is proportional to .
This means merge sort has a complexity of . A sorting algorithm cannot have a better complexity! Another module discusses the concept of the big-oh notation.