4.6. State Space Lower Bounds Proofs¶
We now consider the problem of finding both the minimum and the maximum from an (unsorted) list of values. This might be useful if we want to know the range of a collection of values to be plotted, for the purpose of drawing the plot’s scales. Of course we could find them independently in \(2n-2\) comparisons. A slight modification is to find the maximum in \(n-1\) comparisons, remove it from the list, and then find the minimum in \(n-2\) further comparisons for a total of \(2n-3\) comparisons. Can we do better than this?
Before continuing, think a moment about how this problem of finding the minimum and the maximum compares to the problem of the last section, that of finding the second biggest value (and by implication, the maximum). Which of these two problems do you think is harder? It is probably not at all obvious to you that one problem is harder or easier than the other. There is intuition that argues for either case. On the one hand intuition might argue that the process of finding the maximum should tell you something about the second biggest value, more than that process should tell you about the minimum value. On the other hand, any given comparison tells you something about which of two can be a candidate for maximum value, and which can be a candidate for minimum value, thus making progress in both directions.
We will start by considering a simple divide-and-conquer approach to finding the minimum and maximum. Split the list into two parts and find the minimum and maximum elements in each part. Then compare the two minimums and maximums to each other with a further two comparisons to get the final result. The algorithm is as follows:
// Return the minimum and maximum values in A between positions l and r
void MinMax(int A[], int l, int r, int Out[]) {
if (l == r) { // n=1
Out[0] = A[r];
Out[1] = A[r];
}
else if (l+1 == r) { // n=2
Out[0] = Math.min(A[l], A[r]);
Out[1] = Math.max(A[l], A[r]);
}
else { // n>2
int[] Out1 = new int[2];
int[] Out2 = new int[2];
int mid = (l + r)/2;
MinMax(A, l, mid, Out1);
MinMax(A, mid+1, r, Out2);
Out[0] = Math.min(Out1[0], Out2[0]);
Out[1] = Math.max(Out1[1], Out2[1]);
}
}
The cost of this algorithm can be modeled by the following recurrence.
This is a rather interesting recurrence, in that we can think of it as having a range of closed form solutions. First let’s solve this for \(n = 2^k\).
Let’s expand the recurrence a bit.
We can continue to get the final closed form:
But the input is not always a power of two, and this really does matter. One way to view the issue is that odd input size helps if the cost is the floor of \(n/2\), but it hurts if the cost is the ceiling of \(n/2\). If you always do both, maybe it doesn’t matter. But in this case, we end up doing one or the other in practice, which means that the cost can change. Consider this:
The true cost for $f(n)$ ranges between \(3n/2 - 2\) (when \(n = 2^i\) or \(n=2^1 \pm 1\)) and \(5n/3 - 2\) (when \(n = 3 \times 2^i\)). We can infer from this behavior that how we divide the list affects the performance of the algorithm. For example, what if we have six items in the list? If we break the list into two sublists of three elements, the cost would be 8. If we break the list into a sublist of size two and another of size four, then the cost would only be 7.
With divide and conquer, the best algorithm is the one that minimizes the work, not necessarily the one that balances the input sizes. One lesson to learn from this example is that it can be important to pay attention to what happens for small sizes of \(n\), because any division of the list will eventually produce many small lists.
We can calculate the minimum for all possible divide-and-conquer strategies for this problem with the following recurrence.
That is, we want to find a way to break up the list that will minimize the total work. It might help us to investigate what happens for a few small cases.
If we examine various ways of breaking up small lists, we will eventually recognize that breaking the list into a sublist of size 2 and a sublist of size (n-2) will always produce results as good as any other division. This strategy yields the following recurrence.
This recurrence (and the corresponding algorithm) yields \(\mathbf{T}(n) = \lceil 3n/2 \rceil - 2\) comparisons. Is this optimal? We now introduce yet another tool to our collection of lower bounds proof techniques: The state space proof.
We will model our algorithm by defining a state that the algorithm must be in at any given instant. We can then define the start state, the end state, and the transitions between states that any algorithm can support. From this, we will reason about the minimum number of states that the algorithm must go through to get from the start to the end, to reach a state space lower bound.
At any given instant, we can track the following four categories of elements based on their prior history of comparisons:
Untested: Elements that have not been compared.
Winners: Elements that have won at least one comparison, and never lost.
Losers: Elements that have lost at least one comparison, and never won.
Middle: Elements that have both won and lost at least once.
We define the current state to be a vector of four values, \((U, W, L, M)\) for untested, winners, losers, and middles, respectively. For a set of \(n\) elements, the initial state of the algorithm is \((n, 0, 0, 0)\) and the end state is \((0, 1, 1, n-2)\). Thus, every run for any algorithm must go from state \((n, 0, 0, 0)\) to state \((0, 1, 1, n-2)\). We also observe that once an element is identified to be a middle, it can then be ignored because it can neither be the minimum nor the maximum.
Given that there are four types of elements, there are 10 types of comparison. Comparing with an element in the middle state cannot be more efficient than other comparisons, so we should ignore those. This leaves six types of comparison of interest. We can enumerate the effects of each comparison type as follows. If we are in state \((i, j, k, l)\) and we have a comparison, then the state changes are as follows.
Now, let us make use of the adversary concept and consider what an adversary will do for the various comparisons. The adversary will make sure that each comparison does the least possible amount of work in taking the algorithm toward the goal state. For example, comparing a winner to a loser is of no value because the worst case result is always to learn nothing new (the winner remains a winner and the loser remains a loser). And we might compare an untested against a winner or loser (we have to if the number of competitors is odd), but the adversary will never take the choice that adds to the number of middles. Thus, only the following five transitions are of interest:
In the table we have separated the ones that increase the number of middles from those that do not, since that is a critical part of the total process. Only the last two transition types increase the number of middles, each by one at a time, so there must be \(n-2\) of these comparisons. The number of untested elements must go to 0, and the first transition is the most efficient way to do this. Thus, \(\lceil n/2 \rceil\) of these are required. Our conclusion is that the minimum possible number of transitions (comparisons) is \(n + \lceil n/2 \rceil - 2\). This gives us a simple, optimal algorithm:
First, pair up all the inputs and compare them to generate winners and losers.
Then compare winners to winners or losers to losers to generate \(n-2\) middles.
4.6.1. Acknowledgement¶
This page borrows heavily from presentation in Section 3.4 of Compared to What? by Gregory J.E. Rawlins.