Chapter 7
Algorithms and Numerical Methods
Although the percentage of time that a quant spends on programming varies with the job function (e.g., quant analyst/researcher versus quant developer) and firm culture, a typical quant generally devotes part of his or her time to implementing models through programming. Therefore, programming skill test is often an inherent part of the quantitative interview.
To a great extent, the programming problems asked in quantitative interviews are similar to those asked in technology interviews. Not surprisingly, many of these problems are platform- or language-specific. Although C++ and Java still dominate the market, we’ve seen a growing diversification to other programming languages such as Matlab, SAS, S-Plus, and R. Since there are many existing books and websites dedicated to technology interviews, this chapter will not give a comprehensive review of programming problems. Instead, it discusses some algorithm problems and numerical methods that are favorite topics of quantitative interviews.
7.1. Algorithms
In programming, the analysis of algorithm complexity often uses asymptotic analysis that ignores machine-dependent constants and studies the running time —the number of primitive operations such as addition, multiplication, and comparison—as the number of inputs .1
Three of the most important notations in algorithm complexity are big- notation, notation and notation:
there exist positive constants and such that for all . It is the asymptotic upper bound of .
there exist positive constants and such that for all . It is the asymptotic lower bound of .
there exist positive constants , , and such that for all . It is the asymptotic tight bound of .
Besides notations, it is also important to explain two concepts in algorithm complexity:
172Worst-case running time : an upper bound on the running time for any inputs.
Average-case running time : the expected running time if the inputs are randomly selected.
For many algorithms, and have the same . But as we will discuss in some problems, they may well be different and their relative importance often depends on the specific problem at hand.
A problem with inputs can often be split into subproblems with inputs in each subproblem. This paradigm is commonly called divide-and-conquer. If it takes primitive operations to divide the problem into subproblems and to merge the solutions of the subproblems, the running time can be expressed as a recurrence equation , where , , and .
The master theorem is a valuable tool in finding the tight bound for recurrence equation : If for some constant , , since grows slower than . If for some , , since and grow at similar rates. If for some constant , and for some constant , , since grows faster than .
Let’s use binary search to show the application of the master theorem. To find an element in an array, if the numbers in the array are sorted (), we can use binary search: The algorithm starts with . If , the search stops. If , we only need to search . If , we only need to search . Each time we can reduce the number of elements to search by half after making one comparison. So we have , , and . Hence, and the binary search has complexity .
Number swap
How do you swap two integers, and , without using additional storage space?
Solution: Comparison and swap are the basic operations for many algorithms. The most common technique for swap uses a temporary variable, which unfortunately is forbidden in this problem since the temporary variable requires additional storage space. A simple 173 mathematic approach is to store the sum of and first, then extract ’s value and assign it to and finally assign ’s value to . The implementation is shown in the following code:2
void swap(int &i, int &j) {
i = i + j; //store the sum of i and j
j = i - j; //change j to i's value
i = i - j; //change i to j's value
}
An alternative solution uses bitwise XOR (^) function by taking advantage of the fact that and :
void swap(int &i, int &j){
i = i ^ j;
j = j ^ i; //j = i ^ (j ^ i) = i
i = i ^ j; //i = (i ^ j) ^ i = j
}
Unique elements
If you are given a sorted array, can you write some code to extract the unique elements from the array? For example, if the array is [1, 1, 3, 3, 3, 5, 5, 5, 9, 9, 9, 9], the unique elements should be [1, 3, 5, 9].
Solution: Let be an -element sorted array with elements . Whenever we encounter a new element in the sorted array, its value is different from its previous element (). Using this property we can easily extract the unique elements. One implementation in C++ is shown as the following function:3
template <class T> vector<T> unique(T a[], int n) {
vector<T> vec; // vector used to avoid resizing problem
vec.reserve(n); //reserver to avoid reallocation
vec.push_back(a[0]);
for(int i=1; i<n; ++i) {
174
if(a[i] != a[i-1])
vec.push_back(a[i]);
}
return vec;
}
Horner's algorithm
Write an algorithm to compute .
Solution: A naive approach calculates each component of the polynomial and adds them up, which takes number of multiplications. We can use Horner’s algorithm to reduce the number of multiplications to . The algorithm expresses the original polynomial as and sequentially calculate , , , . We have with at most multiplications.
Moving average
Given a large array of length , can you develop an efficient algorithm to build another array containing the -element moving average of the original array (, , )?
Solution: When we calculate the moving average of the next consecutive numbers, we can reuse the previously computed moving average. Just multiply that average by , subtract the first number in that moving average and then add the new number, and you have the new sum. Dividing the new sum by yields the new moving average. Here is the pseudo-code for calculating the moving average:
S= A[1] + ... + A[n]; B[n] = S/n;
for (i=n+1 to m) { S = S − A[i-n] + A[i]; B[i] = S/n; }
Sorting algorithm
Could you explain three sorting algorithms to sort distinct values and analyze the complexity of each algorithm?
Solution: Sorting is a fundamental process that is directly or indirectly implemented in many programs. So a variety of sorting algorithms have been developed for different 175 purposes. Here let’s discuss three such algorithms: insertion sort, merge sort and quick sort.
Insertion sort: Insertion sort uses an incremental approach. Assume that we have sorted subarray . We insert element into the appropriate place in , which yields sorted subarray . Starting with and increases step by step to , we will have a fully sorted array. For each step, the expected number of comparisons is and the worst-case number of comparisons is . So we have
Merge sort: Merge sort uses the divide-and-conquer paradigm. It divides the array into two subarrays each with items and sorts each subarray. Unless the subarray is small enough (with no more than a few elements), the subarray is again divided for sorting. Finally, the sorted subarrays are merged to form a single sorted array.
The algorithm can be expressed as the following pseudocode:
mergesort(A, beginindex, endindex)
if beginindex < endindex
then centerindex ← (beginindex + endindex)/2
merge1 <- mergesort(A, beginindex, centerindex)
merge2 <- mergesort(A, centerindex + 1, endindex)
merge(merge1, merge2)
The merge of two sorted arrays with elements each into one array takes primitive operations. The running time follows the following recursive function:
Applying the master theorem to with , , and , we have . So . For merge sort, and are the same as .
Quicksort: Quicksort is another recursive sorting method. It chooses one of the elements, , from the sequence and compares all other values with it. Those elements smaller than are put in a subarray to the left of ; those elements larger than are put in a subarray to the right of . The algorithm is then repeated on both subarrays (and any subarrays from them) until all values are sorted.
176In the worst case, quicksort requires the same number of comparisons as the insertion sort. For example, if we always choose the first element in the array (subarray) and compare all other elements with it, the worst case happens when are already sorted. In such cases, one of the subarray is empty and the other has element. Each step only reduces the subarray size by one. Hence, .
To estimate the average-case running time, let’s assume that the initial ordering is random so that each comparison is likely to be any pair of elements chosen from . If we suspect that the original sequence of elements has a certain pattern, we can always randomly permute the sequence first with complexity as explained in the next problem. Let and be the th and th element () in the final sorted array. There are numbers between and . The probability that and is compared is the probability that is compared with before or is compared with either or (otherwise, and are separated into different subarrays and will not be compared), which happens with probability (you can again use the symmetry argument to derive this probability).
The total expected number of comparison is .
Although theoretically quicksort can be slower than merge sort in the worst cases, it is often as fast as, if not faster than, merge sort.
Random permutation
A. If you have a random number generator that can generate random numbers from either discrete or continuous uniform distributions, how do you shuffle a deck of 52 cards so that every permutation is equally likely?
Solution: A simple algorithm to permute elements is random permutation by sorting. It assigns a random number to each card and then sorts the cards in order of their assigned random numbers.4 By symmetry, every possible order (out of possible ordered sequences) is equally likely. The complexity is determined by the sorting step, so the 177 running time is . For a small , such as in a deck of cards, the complexity is acceptable. For large , we may want to use a faster algorithm known as the Knuth shuffle. For elements , the Knuth shuffle uses the following loop to generate a random permutation:
for (i=1 to n) swap(A[i], A[Random(i, n)]),
where Random() is a random number from the discrete uniform distribution between and .
The Knuth shuffle has a complexity of and an intuitive interpretation. In the first step, each of the cards has equal probability of being chosen as the first card since the card number is chosen from the discrete uniform distribution between 1 and ; in the second step, each of the remaining cards elements has equal probability of being chosen as the second card; and so on. So naturally each ordered sequence has probability.
B. You have a file consisting of characters. The characters in the file can be read sequentially, but the length of the file is unknown. How do you pick a character so that every character in the file has equal probability of being chosen?
Solution: Let’s start with picking the first character. If there is a second character, we keep the first character with probability 1/2 and replace the pick with the second character with probability 1/2. If there is a third character, we keep the pick (from the first two characters) with probability 2/3 and replace the pick with the third character with probability 1/3. The same process is continued until the final character. In other words, let be the character that we pick after we have scanned characters and the character exists, the probability of keeping the pick is and the probability of switching to the character is . Using simple induction, we can easily prove that each character has probability of being chosen if there are characters.
Search algorithm
A. Develop an algorithm to find both the minimum and the maximum of numbers using no more than comparisons.
Solution: For an unsorted array of numbers, it takes comparisons to identify either the minimum or the maximum of the array. However, it takes at most comparisons to identify both the minimum and the maximum. If we separate the elements to pairs, compare the elements in each pair and put the smaller one in group 178 and the larger one in group . This step takes comparisons. Since the minimum of the whole array must be in group and the maximum must be in group , we only need to find the minimum in and the maximum in , either of which takes comparisons. So the total number of comparisons is at most .5
B. You are given an array of numbers. From the beginning of the array to some position, all elements are zero; after that position, all elements are nonzero. If you don't know the size of the array, how do you find the position of the first nonzero element?
Solution: We can start with the 1st element; if it is zero, we check the 2nd element; if the 2nd element is zero, we check the 4th element... The process is repeated until the th step when the element is nonzero. Then we check the element. If it is zero, the search range is limited to the elements between the element and the element; otherwise the search range is limited to the elements between the element and the element... Each time, we cut the range by half. This method is basically a binary search. If the first nonzero element is at position , the algorithm complexity is .
C. You have a square grid of numbers. The numbers in each row increase from left to right. The numbers in each column increase from top to bottom. Design an algorithm to find a given number from the grid. What is the complexity of your algorithm?
Solution: Let be an matrix representing the grid of numbers and be the number we want to find in the grid. Begin the search with the last column from top to bottom: . If the number is found, then stop the search. If , is not in the grid and the search stops as well. If , then we know that all the numbers in rows are less than and are eliminated as well.6 Then we search the row from right to left. If the number is found in the row, the search stops. If , is not in the grid since all the number in rows and above are larger than . If , we eliminate all the numbers in columns . Then we can search along column from towards until we find (or does not exist in 179 the grid) or a that makes , and then we search left along the row from towards ... Using this algorithm, the search takes at most steps. So its complexity is .
Fibonacci numbers
Consider the following C++ program for producing Fibonacci numbers:
int Fibonacci(int n)
{
if (n <= 0)
return 0;
else if (n==1)
return 1;
else
return Fibonacci(n-1)+Fibonacci(n-2);
}
If for some large , it takes 100 seconds to compute Fibonacci(), how long will it take to compute Fibonacci(), to the nearest second? Is this algorithm efficient? How would you calculate Fibonacci numbers?
Solution: This C++ function uses a rather inefficient recursive method to calculate Fibonacci numbers. Fibonacci numbers are defined as the following recurrence:
has closed-formed solution , which can be easily proven using induction. From the function, it is clear that
So the running time is a proportional to a sequence of Fibonacci numbers as well. For a large , , so . If it takes 100 seconds to compute Fibonacci(), the time to compute Fibonacci() is seconds.7
180The recursive algorithm has exponential complexity , which is surely inefficient. The reason is that it fails to effectively use the information from Fibonacci numbers with smaller in the Fibonacci number sequence. If we compute in sequence using the definition, the running time has complexity .
An algorithm called recursive squaring can further reduce the complexity to . Since and , we can show that using induction. Let , we can again apply the divide-and-conquer paradigm to calculate :
The multiplication of two matrices has complexity . So . Applying the master theorem, we have .
Maximum contiguous subarray
Suppose you have a one-dimensional array with length that contains both positive and negative numbers. Design an algorithm to find the maximum sum of any contiguous subarray of : , .
Solution: Almost all trading systems need such an algorithm to calculate maximum run-up or maximum drawdown of either real trading books or simulated strategies. Therefore this is a favorite algorithm question of interviewers, especially interviewers at hedge funds and trading desks.
The most apparent algorithm is an algorithm that sequentially calculates the ’s from scratch using the following equations:
As the ’s are calculated, we also keep track of the maximum of as well as the corresponding subarray indices and .
181A more efficient approach uses the divide-and-conquer paradigm. Let’s define and , then , . Clearly for any fixed , when is minimized, is maximized. So the maximum subarray ending at is where . If we keep track of and update and as increases, we can develop the following algorithm:
For to
{
If then
If , then
}
Return
The following is a corresponding C++ function that returns and indices and given an array and its length:
double maxSubarray(double A[], int len, int &i, int &j)
{
double T=A[0], Vmax=A[0];
double Tmin = min(0.0, T);
for(int k=1; k<len; ++k)
{
T+=A[k];
if (T-Tmin > Vmax) {Vmax=T-Tmin; j=k;}
if (T<Tmin) {Tmin = T; i = (k+1<j)? (k+1):j;}
}
return Vmax;
}
Applying it to the following array ,
double A[]={1.0,2.0,-5.0,4.0,-3.0, 2.0, 6.0, -5.0, -1.0};
int i = 0, j =0;
182
double Vmax = maxSubarray(A, sizeof(a)/sizeof(A[1]), i, j);
will give , and . So the subarray is [4.0, −3.0, 2.0, 6.0].