Module 0079: Non-recursive merge sort

Tak Auyeung, Ph.D.

November 28, 2018

Contents

1 About this module
2 Non-recursive merge sort
 2.1 Split
 2.2 Merge
 2.3 Mergesort
3 Data structure
 3.1 What is it?
 3.2 Linked list queue
 3.3 File
 3.4 Buffered read
4 How long does it take?
 4.1 Split
 4.2 Merge
 4.3 Mergesort
  4.3.1 Merging two runs
  4.3.2 Number of iterations

1 About this module

2 Non-recursive merge sort

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.

2.1 Split

Listing 1 is the split algorithm as a subroutine in pseudocode.


Listing 1: split
1define sub split 
2  by reference in : FIFO 
3  by value out1 : pointer to FIFO 
4  by value out2 : pointer to FIFO 
5  local lastValue : integer 
6  in.rewind() 
7  out1->rewrite() 
8  out2->rewrite() 
9  lastValue  
10  while ¬ (in.isempty()) do 
11    if in.head() < lastValue then  
12      // cannot continue the run 
13      local tmp : pointer to FIFO 
14      tmp  out1 
15      out1  out2 
16      out2  tmp 
17    end if 
18    lastValue  in.remove() 
19    out1>append(lastValue) 
20  end while 
21  in.close() 
22  out1>close() 
23  out2>close() 
24end define sub

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.

2.2 Merge

Listing 2 is the merge algorithm as a subroutine.


Listing 2: merge
1define sub merge 
2  by reference out : FIFO 
3  by reference in : array [2] of FIFO 
4  local lastValue : integer 
5  local sorted : boolean 
6  local op : integer 
7  static lookup : array [boolean][boolean][boolean] of integer 
8   initialized to 
9   //      lvin0 lvin1 in0in1 
10      lookup[true ] [true ] [true ] = 0,  
11      lookup[true ] [true ] [false] = 1,  
12      lookup[true ] [false] [true ] = 0, 
13      lookup[true ] [false] [false] = 0, 
14      lookup[false] [true ] [true ] = 1, 
15      lookup[false] [true ] [false] = 1, 
16      lookup[false] [false] [true ] = 0, 
17      lookup[false] [false] [false] = 1 
18  sorted  true 
19  out.rewrite() 
20  in[0].rewind() 
21  in[1].rewind() 
22  lastValue  
23  while ¬ (in[0].isempty()  in[1].isempty()) do 
24    if in[0].isempty() then  
25      op 1 
26    else if in[1].isempty() then  
27      op 0 
28    else 
29      op  lookup[lastValuein[0].head()] 
30                 [lastValuein[1].head()] 
31                 [in[0].head()in[1].head()] 
32    end if 
33    sorted  sorted  lastValue  (in[op].head())  
34    lastValue  in[op].head() 
35    out.append(in[op].remove())  
36  end while 
37  out.close() 
38  in[0].close() 
39  in[1].close() 
40  return sorted 
41end define sub

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.

2.3 Mergesort

Once we define the merge and split operations, then we can define the merge sort algorithm itself in listing 3.


Listing 3: mergesort
1define sub mergesort 
2  by reference toBeSorted : FIFO 
3  local tmpFIFO : array [2] of FIFO 
4  repeat 
5   split(toBeSorted, address of tmpFIFO[0], address of tmpFIFO[1]) 
6  until merge(toBeSorted, tmpFIFO) 
7end define sub

This algorithm is rather simple. All we have to do is to keep split-merging until the merged result is sorted.

3 Data structure

This section deals with the mysterious FIFO type.

3.1 What is it?

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.

3.2 Linked list queue

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.

3.3 File

To abstract a sequential file as a FIFO, we need a little bit of work.

3.4 Buffered read

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).


Listing 4: bufferread
1empty  implementation.isempty() 
2if ¬ empty then 
3  buffer  implementation.remove() 
4end if

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


Listing 5: remove
1tmp  buffer 
2bufferread() 
3return tmp

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.

4 How long does it take?

4.1 Split

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.

4.2 Merge

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.

4.3 Mergesort

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.

4.3.1 Merging two runs

A run can be considered as a tuple (a1,a2,a3,an). By definition, elements in a run are sorted. In our example, i,j [1n] : (i j) (ai aj), assuming the tuple is sorted in a non-decreasing way.

Now, let us consider two runs, A = (a1,an) and B = (b1,bm). We claim that after we merge there two runs, the result is a single run with n + m values.

How do we prove this?

Let us use proof-by-contradiction. We first negate the proposition that we want to prove, yielding “(a1,an) and (b1,bm) are two runs, after the merge process, the result is not a single run.”

Let us symbolically represent the merge result as C = (c1,cm+n). Let us define the first break point of the run as i [1m + n 1] : ci > ci+1 ¬(j [1i 1] : cj > cj + 1). Let us assume ci+1 corresponds to bq.

Furthermore, let us define p as follows: p = min(j : cj > ci+1).

At this point, we have the sequence like this:

(c1,c2,,cp,cp+1,,ci,ci+1,,cm+n) in which ci > ci+1 and cp > ci+1.

If one of the cj,j [pi] comes from run B, we have a contradiction because run B is sorted.

If all of the cj,j [pi] come from run A, we still have a contradiction. Let cp correspond to ar. When we choose the value of cp, the two candidates must have been ar and bq. We know that neither ar nor bq breaks the run as cp because i > p, and i is defined to be the first break of the run in C.

According to the logic of the merge algorithm, we do not choose the larger one, which means that ar bq. But this contradicts the fact that ar = cp > ci+1 = bq.

Because we can only arrive at contradictions, the original proposition is true. This means that when we merge to runs, A and B, the result tuple (list) C must be a single run.

4.3.2 Number of iterations

The split algorithm ensures that if the original FIFO has n runs, then each output FIFO has about n2 runs. When n is odd, then one output FIFO has (n2) + 1 runs.

The merge algorithm, with the proof from the previous section, combines the runs from both input FIFOs into (n2) + 1 runs when n is odd. If n is even, then the number of runs in the result is n2/

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 n runs, then the previous iteration has 2(n 1) + 1 runs in the worst case. As a result, k split-merge iterations can sort 2k1 + 1 runs.

Want to see the proof of this? Let us define f(1) = 2, and f(x) = 2(f(x 1) 1) + 1. Here, the function f(x) computes the least number of runs given the algorithm completes in x iterations. I claim that x 1 : f(x) = 2x1 + 1. We can prove this by induction. f(1) = 211 + 1 = 2 is our base case. The induction step states that for all x 1, if we assume f(x) = 2x1 + 1, then

f(x + 1) = 2(f(x) 1) + 1 (1) = 2(((2x1 + 1) 1)) + 1 (2) = 2(2x1) + 1 (3) = 2x + 1 (4) (5)

In the worst case that each run is a single value, k split-merge iterations sort 2k1 + 1 values. Because the execution time of each iteration is proportional to the number of values, the execution time of merge sort is proportional to k(2k1 + 1) for an input size of 2k1 + 1.

Instead of expressing everything in k, which is the number of iterations needed to sort the input, let us use N = 2(k 1) + 1 instead. We can, then, express the execution time to be proportional to k N. Note that log2(N) = log2(2k1 + 1 log2(2k1) = k 1. That means log2(N) + 1 k. As a result, the execution time is proportional to (log2(N) + 1)N Nlog2(N).

This means merge sort has a complexity of O(nlog(n)). A sorting algorithm cannot have a better complexity! Another module discusses the concept of the big-oh notation.