So far, we have talked about differences in efficiency between various implementations of the Bag ADT and the Stack ADT, but we have been somewhat vague about it. Now we will look at algorithm efficiencies in a more formal, mathematical way. Why do we care about formalizing this? Consider all of the work involved in implementing a new ADT. It is non-trivial to get all of the operations working correctly. Plus, many special cases and much debugging is required during implementation. If we can pick the best implementation before implementing the ADT, it can save us a lot of time. Inefficient potential implementations could be abandoned before they are even started!
For a simple example, let's consider adding up a sequence of integers starting at zero. There are two algorithms below that both do just that. Which one is faster? Is there an even faster algorithm?
Let's take a look at another example. Let's try searching through a sorted array for a number. We'll consider two algorithms for searching: sequential search and binary search. What does each algorithm do?
Assume the array contains N items in sorted order. Sequential search can take up to N tests to find the item, but binary search will take at most log2(N) tests. (How do you think we could figure out the number of tests needed?) Are N and log2(N) that different from each other? Let's take a look at the number of tests for one search with different values of N:
N | Sequential Search | Binary Search |
---|---|---|
8 | ||
16 | ||
32 | ||
64 | ||
... | ||
1,024 | ||
1,048,576 | ||
1,073,741,824 |
What if we were doing 1 million searches instead of one? Well if we had N = 20 million, we could use this formula to figure out the number of tests:
If each test takes one nanosecond (10-9 seconds):
The difference is amazing. Just rethinking our algorithm takes us from something that would take hours to something that just takes a fraction of a second. Other examples can have even more extreme differences. CS/COE 1501 will have many examples. By analyzing our algorithm before implementing it, we can thus avoid implementing algorithms that will require too much time to run. A little analysis saves us a lot of programming.
How can you compare execution times of algorithms? Perhaps the most obvious approachis to time them empirically. This will give us actual run-times that we can use to compare. This is very useful for algorithms/ADTs that have already been implemented. However, we said previously that often it is good to get a ballpark on the runtime of an algorithm/ADT before actually implementing it. Perhaps we wouldn't want to go through the effort if the algorithm is not going to be useful. Additionally, measuring implementations depend on things independent of the ADTs/algorithms themselves, such as programming language, computer hardware, and input/data chosen.
The preferred approach is to not actually time a program, even if such a program exists. Instead, we use asymptotic analysis, which follows this procedure:
Let's take a look at some examples:
Name | Big-O | Example(s) |
---|---|---|
Constant Time | O(1) |
y = x; array[i] = array[i-1] + 1; |
Linear Time | O(N) |
for (int i = 0; i < N; i++) do_some_constant_time_operation |
Quadratic Time | O(N2) |
for (int i = 0; i < N; i++) for (int j = 0; j < N; j++) do_some_constant_time_operation |
There are many (infinitely) others, including some we will see soon.
Let's take a look at our search example from above. What is the key instruction we want to measure?
For sequential search, what do you think the runtime is? Code for sequential search is:
For binary search, the runtime analysis is a bit trickier. It has a loop like sequential search, but now the number of iterations is very different. Let's look at the code from the standard Java library in the java.util.Arrays class:
What is the worst case runtime for this? Let's simplify things a bit. First, assume that in each iteration, the array is cut exactly in half. In reality, this won't quite be true, but it's close enough. Second, assume that the initial size of the array is exactly a power of two (i.e. 2k for some positive integer k). While this will rarely be true, it makes the analysis easier (and has no effect on our results).
Let's combine these simplifying assumptions and apply them to determine the runtime. To determine the problem size at each iteration:
So, if we don't find the element in the array (i.e. we're in the worst case), then we have k+1 iterations. At each iteration, we do one comparison, so this yields k+1 comparisons. But we need this in terms of N. From our definition above:
This makes k+1 = log2(N) + 1. This makes our final answer O(log(N)). Why did we drop off the "+1"? Why did we drop off the base 2 from the log?
We've seen three implementations of the bag ADT. We can now analyze which implementation is better for which operations (if there are any differences). Let's now take a look at the runtime of each implementation.
What is the runtime of the ArrayBag's add operation?
Let's take a closer look at ResizableArrayBag. At first glance, it appears to be O(1) because you just go to the last location and insert there (all taking constant time). But what if the array is full? Well, we need to resize it. So, some adds are constant time (O(1)) while others take significantly more time, since we have to first allocate a new array and copy all of the data into it -- taking linear time (O(N)). So, we would have O(1) + O(N) = O(N), right?
Well, we have an operation that sometimes takes O(1) and sometimes takes O(N). What we need to do is figure out the average time required over a sequence of operations. This is called amortized analysis. Although individual operations may vary in their run-time, we can get a consistent time for the overall sequence. Let's stick with the add() method for Resizable Array Bag and consider two different options for resizing:
Let's take a look at the first option, where we increase the array size by 1 each time we resize. Note that with this approach, once we resize we will have to do it with every add. Thus rather than O(1) our add() is now O(N) all the time. To see why, assume the initial array is size 1:
Overall, for N add() operations look at the total number of assignments we have to make:
Therefore, the amortized add for one add operation is O(N2) / N = O(N).
Now we'll look at the second option, where we double the array size at each resize. Let's again assume that the initial array size is 1:
Add Operation # | # of Assignments | Array Size at End of Operation |
---|---|---|
1 | 1 | 1 |
2 | 2 = (copy old array) + (assign new value) = 1 + 1 |
2 |
3 | 3= 2 + 1 | 4 |
4 | 1 | 4 |
5 | 5 = 4 + 1 | 8 |
... | 1 | 8 |
9 | 9 = 8 + 1 | 16 |
... | 1 | 16 |
17 | 17 = 16 + 1 | 32 |
... | 1 | 32 |
32 | 1 | 32 |
Note that every row has at least one assignment (for the new value being added, in blue). Some rows have more than one assignment (for copying the old array, in red). For these additional assignments, notice that there is a pattern to their size. Rows that are 2K + 1 (for some positive integer K) have an additional 2K assignments to copy data.
So, for N adds, we have:
What is that x? Each term in that summation represents a time when the array is doubled. Notice that the array is doubled when
Now that we know what x is, we now need to figure out the summation of 20 + ... + 2x:
This summation is the geometric series, so we can apply the summation formula to get the result of the summation:
Finally, applying some simplifications, we arrive at the summation being O(N):
So, for N adds, we have:
Thus, the runtime for N adds is N + O(N) = O(N). So our amortized time for one add is O(N) / N = O(1).
Recall that when increasing by 1 we had O(N2) overall for the sequence, which gives us O(N) in amortized time. Note how much better our performance is when we double the array size!
Runtime analysis and amortized analysis can be complicated at times. Often, they'll have a good deal of math in it. That is what algorithm analysis is all about though. If you can do some math you can save yourself some programming.
What about the run-time for the singly linked list? Notice that this implementation always adds one to the size of the bag. How is this implementation's runtime similar/different from the ResizableArrayBag's implementation (where we increased the array size by one) and why?
The text discusses other Bag operations. It turns out that for the Bag, the run-times for the array and the linked list are the same for every operation. This will not always be the case, as we'll see later in the semester.
What about the Stack implementations? What are the runtimes for:
<< Previous Notes | Daily Schedule | Next Notes >> |