ECS 110: Data Structures and Programming Discussion Section Notes -- Week 3 John Black (blackj@cs.ucdavis.edu) Ted Krovetz (krovetz@cs.ucdavis.edu) LAST OF THE C++ --------------- 1. CLASS TEMPLATES In the stack example we saw in the first week, we could have used a typedef to isolate the type of data manipulated by our stack. By defining in a single place the type of data our stack contains, it is possible to adapt easily our stack for different uses. Simply changing the typedef and recompiling produces an entirely new type of stack. What would happen if you wanted to have two stacks containing different types? In our old model, we would have to duplicate the code for each type. However, C++ lets you write classes for generic types, allowing you to use the same class definition for instantiations of multiple data types. That is, you write your class for some place-holder type T, and then each time you need your class to work on a different data type, you simply inform the compiler to substitute your data type for the generic T. This is sometimes called "parametric polymorphism" because you can cause a type definition to change behaviour by use of a parameter upon instantiation. Let's look at the Stack example of last week: #include template class Stack { public: Stack(); ~Stack(); void Push(TYPE); void Pop(); TYPE Top(); int Empty(); friend ostream& operator<< (ostream& os, Stack& s); private: struct node_type { TYPE elem; node_type* prev; }; node_type* top; }; template Stack::Stack() { top = NULL; } template Stack::~Stack() { while ( ! Empty()) Pop(); } template void Stack::Push(TYPE new_value) { node_type* temp = new node_type; temp->prev = top; temp->elem = new_value; top = temp; } template void Stack::Pop() { if ( ! Empty()) { node_type* temp = top->prev; delete top; top = temp; } } template TYPE Stack::Top() { return (top->elem); } template int Stack::Empty() { return (top == NULL); } template ostream& operator<< (ostream& os, Stack& s) { if (s.Empty()) os << "The stack is empty"; else { Stack::node_type* temp = s.top; while (temp != NULL) { os << temp->elem << " "; temp = temp->prev; } } os << endl; return os; } int main() { Stack s; int num; cout << "How many would you like to push? "; cin >> num; for (int i = 1; i <= num; i++) { s.Push(i); cout << s; } while ( ! s.Empty()) { cout << "The top of the stack is " << s.Top() << endl; s.Pop(); } cout << s; return 0; } This program behaves identically to last week's example. The only coding changes need to accommodate the parameterization is to change all references from elem_type to TYPE and change all type references from Stack to Stack. You will not be required to use templates in your programs. Many compilers don't support them properly. This brief introduction is being provided so that you can gain a reading knowledge of template use. Your textbook, "Fundamentals of Data Structures in C++," uses templates often, and misunderstanding their use could easily get in the way of understanding parts of the book. 2. Sorting Functions by Growth Rate Recently we learned, both from the reading and from lecture, about 'big-oh' notion. We also learned the related big-Theta and big-Omega notations (big-oh is used most often, but the other two occur frequently enough that you should be familiar with them). For practice, we're going to sort a list of functions; remember these are just functions and not necessarily running times of algorithms, but they could be. We will sort from smallest growth rate to largest (that is to say, if f(n) = O(g(n)) then f(n) will appear in the list before g(n); if f(n) = THETA(g(n)), then f(n) and g(n) will appear side-by-side in our list). Here are the functions to sort: 1, n^(.001), ln n, 2^n, n^3, (2/3)^n, lg n, n^2, 4^(lg n), lg lg n, n!, lg(n!), n lg n, n The key to comparing two functions is to use logarithm identities, which you probably have known since high school, and Stirling's approximation for factorials, which is a nice fact to know. Stirling's says that n! ~ (n/e)^n * sqrt(2 * n * 3.1416) so for example, 50! is about (18.4)^50 * sqrt(314). Notice that the sqrt term doesn't really have a strong affect compared to the huge exponential term! (By the way, the above is about 3.0857e64 and the real value of 50! is about 3.0414e64, so it's pretty close!) So the sorted list comes out as: (2/3)^n (grows slower than 1!) 1 lg lg n ln n lg n (different bases, same growth rate) n^(.001) (exponentials are always faster than logs) n n lg n lg(n!) n^2 4^(lg n) n^3 2^n n! 3. Analyzing algorithms You have read Chapter 2 of Weiss, and some of this may be hard at first; don't worry, it is for everyone. But study it carefully, because a good understanding now will carry you far in upper-division C.S. courses. Since time in discussion is limited, we will do just a simple example; let's consider the sorting example from the hw0 spec and analyze it's run-time: for (int i = 0; i < array_length - 1; i++) for (int j = array_length - 1; i < j; j--) if (int_array[j-1] > int_array[j]) swap(&int_array[j-1], &int_array[j]); The outer loop executes array_length times, and the inner loop goes from array_length-1 down to i. The inner if-statement uses a constant amount of time, so let's see how many times the inner if-statement is executed. On the first iteration, i is 0 and the inner loop executes from array_length-1 down to 1, or array_length-1 times; on the next iteration i is 1 and the inner for loop executes array_length-2 times. This proceeds until the ends when the inner loop executes only once. So it would seem our task is to sum array_length-1 + array_length-2 + ... + 2 + 1. But summing an arithmetic sequence is easy; everyone should know that 1+2+...+n = n(n+1)/2. So our sum is just array_length(array_length-1)/2. This is clearly THETA(array_length^2) which means we have an n^2 sorting algorithm on our hands. Anyone know if there are faster sorting algorithms? What are their running times?