# Problem Set 1: Introduction to Algorithms ###### tags: `MIT` `6.006` `algorithm` :::success - contributed by < `TYChan6028` > - problem set: [Asymptotic complexity, recurrence relations, peak finding](https://ocw.mit.edu/courses/electrical-engineering-and-computer-science/6-006-introduction-to-algorithms-fall-2011/assignments/MIT6_006F11_ps1.pdf) - course website: [MIT 6.006 Introduction to Algorithms, Fall 2011](https://ocw.mit.edu/courses/electrical-engineering-and-computer-science/6-006-introduction-to-algorithms-fall-2011/) ::: ## Problem 1-1. Asymptotic Practice ### a. Group 1: \begin{eqnarray} f_1(n) &=& n^{0.999999}\log n\\ &=& O(n^{0.999999}) \cdot O(n^{0.000001})\\ &=& O(n) \end{eqnarray} Since $\log n$ is asymptotically smaller than $n^c$ for all $c>0$, we can make the assumption that $c=0.000001$. :::spoiler **Proof**: O(log n) < O(n^c) :::warning First, we know that $O(\log(\log n)) < O(\log n)$ because $O(\log n) < O(n)$. Therefore, there exists $N, c$ such that for all $n>N \land c>0$, \begin{eqnarray} \log(\log n) &<& c\log n\\ e^{\log(\log n)} &<& e^{c\log n} = e^{\log n^c}\\ \log n &<& n^c \end{eqnarray} *Reference*: [How to prove this: $\log n = O(n^c)$ [closed]](https://stackoverflow.com/questions/63381337/how-to-prove-this-log-n-onc) ::: \begin{eqnarray} f_2(n) &=& 10000000n=O(n)\\ f_3(n) &=& 1.000001^n=O(c^n)\\ f_4(n) &=& n^2=O(n^2) \end{eqnarray} Sorted in increasing order of asymptotic complexity, ==$f_1(n)<f_2(n)<f_4(n)<f_3(n)$==. ### b. Group 2: \begin{eqnarray} f_1(n) &=& 2^{2^{1000000}}=4^{1000000}=O(1)\\ f_2(n) &=& 2^{100000n}=O(2^n)\\ f_3(n) &=& {n\choose 2}=\frac{n!}{2!(n-2)!}\\ &=& \frac{n(n-1)}{2}\\ &=& O(n^2)\\ f_4(n) &=& n\sqrt{n}=O(n^{1.5}) \end{eqnarray} Sorted in increasing order of asymptotic complexity, ==$f_1(n)<f_4(n)<f_3(n)<f_2(n)$==. *Readings*: [Stirling's approximation for n!](https://en.wikipedia.org/wiki/Stirling%27s_approximation) ### c. Group 3: \begin{eqnarray} f_1(n) &=& n^{\sqrt{n}}=\sqrt{n}^{n}=O(\sqrt{n}^{n})\\ f_2(n) &=& 2^n=O(2^n)\\ f_3(n) &=& n^{10} \cdot 2^{n/2}=n^{10} \cdot (2^{1/2})^n=O(n^{10}) \cdot O(\sqrt{2}^n)=O(\sqrt{2}^n)\\ f_4(n) &=& \sum_{i=1}^{n}(i+1)=\sum_{i=1}^{n}i+\sum_{i=1}^{n}1=\frac{n(n+1)}{2}+n=O(n^2) \end{eqnarray} Sorted in increasing order of asymptotic complexity, ==$f_4(n)<f_3(n)<f_2(n)<f_1(n)$==. :::danger **Correction:** $f_4(n)<f_1(n)<f_3(n)<f_2(n)$ \begin{eqnarray} f_1(n) &=& n^{\sqrt{n}}=n^{(n^{0.5})} \neq (n^n)^{0.5}=(n^{0.5})^n=\sqrt{n}^n\\ f_1(n) &=& n^{\sqrt{n}}=(2^{\lg n})^\sqrt{n}=2^{\sqrt{n}\lg n}\\ f_3(n) &=& n^{10} \cdot 2^{n/2}=(2^{\lg n})^{10} \cdot 2^{n/2}=2^{10\lg n+n/2}\\ &\because& O(2^{\sqrt{n}}) < O(\sqrt{2}^n)\\ &\Rightarrow& O(2^{\sqrt{n}\lg n}) < O(2^{10\lg n+n/2})\\ &\therefore& f_1(n) < f_3(n) \end{eqnarray} ::: ## Problem 1-2. Recurrence Relation Resolution ### a. For $c \leq 2$: \begin{eqnarray} T(x,c) &=& \Theta(x)\\ T(c,y) &=& \Theta(y) \end{eqnarray} The asymptotic complexity: \begin{eqnarray} T(x,y) &=& \Theta(x+y)+T(x/2,y/2)\\ &=& c(x+y)+T(x/2,y/2)\\ &=& c(x+y)+c(\frac{x+y}{2})+T(x/4,y/4)\\ &\vdots& \frac{x}{2^{i-1}} = 2 \Rightarrow x = 2^i \Rightarrow \lg x = i\\ &=& c(x+y)+c(\frac{x+y}{2})+\cdots+c(\frac{x+y}{2^{i-2}})+\Big[c(\frac{y}{2^{i-1}})+c(\frac{x}{2^{i-1}})\Big]\\ &=& c(x+y)(1+\frac{1}{2}+\cdots+\frac{1}{2^{i-1}})\\ &<& 2c(x+y)\\ &=& O(n) \end{eqnarray} Answer: ==2. $\Theta(n)$== ### b. For $c \leq 2$: \begin{eqnarray} T(x,c) &=& \Theta(x)\\ T(c,y) &=& \Theta(y) \end{eqnarray} The asymptotic complexity: \begin{eqnarray} T(x,y) &=& \Theta(x)+T(x,y/2)\\ &=& cx+T(x,y/2)\\ &=& cx+cx+T(x,y/4)\\ &\vdots& \frac{y}{2^{i-1}} = 2 \Rightarrow y = 2^i \Rightarrow \lg y = i\\ &=& cx+cx+\cdots+cx+[cx]\\ &=& cn\cdot(\lg n-1)+cn\\ &=& O(n\lg n) \end{eqnarray} Answer: ==3. $\Theta(n\lg n)$== ### c. For $c \leq 2$: \begin{eqnarray} T(x,c) &=& \Theta(x)\\ S(c,y) &=& \Theta(y) \end{eqnarray} The asymptotic complexity: \begin{eqnarray} T(x,y) &=& \Theta(x)+S(x,y/2)\\ &=& \Theta(x)+\Big[\Theta(y/2)+T(x/2,y/2)\Big]\\ &=& \Theta(x+y/2)+T(x/2,y/2)\\ &=& \Theta(x+y)+T(x/2,y/2) \end{eqnarray} Answer: ==2. $\Theta(n)$== ## Problem 1-3 & 1-4. Peak-Finding Correctness & Efficiency ### a. `algorithm1` ```python= def algorithm1(problem, trace = None): # if it's empty, we're done if problem.numRow <= 0 or problem.numCol <= 0: return None # the recursive subproblem will involve half the number of columns mid = problem.numCol // 2 # information about the two subproblems (subStartR, subNumR) = (0, problem.numRow) (subStartC1, subNumC1) = (0, mid) (subStartC2, subNumC2) = (mid + 1, problem.numCol - (mid + 1)) subproblems = [] subproblems.append((subStartR, subStartC1, subNumR, subNumC1)) subproblems.append((subStartR, subStartC2, subNumR, subNumC2)) # get a list of all locations in the dividing column divider = crossProduct(range(problem.numRow), [mid]) # find the maximum in the dividing column bestLoc = problem.getMaximum(divider, trace) # see if the maximum value we found on the dividing line has a better # neighbor (which cannot be on the dividing line, because we know that # this location is the best on the dividing line) neighbor = problem.getBetterNeighbor(bestLoc, trace) # this is a peak, so return it if neighbor == bestLoc: return bestLoc # otherwise, figure out which subproblem contains the neighbor, and # recurse in that half sub = problem.getSubproblemContaining(subproblems, neighbor) result = algorithm1(sub, trace) return problem.getLocationInSelf(sub, result) ``` #### 1. Correct? ==Yes==, `algorithm1` is correct. #### 2. Worst-case runtime on a problem of size $n \times n$? Important operations are listed below: ```python=19 divider = crossProduct(range(problem.numRow), [mid]) # RUNTIME: O(n*1) ``` ```python=22 bestLoc = problem.getMaximum(divider, trace) # RUNTIME: O(n) ``` ```python=27 neighbor = problem.getBetterNeighbor(bestLoc, trace) # RUNTIME: O(1) ``` ```python=35 sub = problem.getSubproblemContaining(subproblems, neighbor) # RUNTIME: O(1) ``` ```python=36 result = algorithm1(sub, trace) # target ``` \begin{eqnarray} T(n) &=& T(\frac{n}{2}) + O(n)\\ &=& T(\frac{n}{2}) + cn\\ &=& T(\frac{n}{4}) + c\frac{n}{2} + cn\\ &\vdots&\\ &=& T(1) + cn(1 + \frac{1}{2} + \dots + \frac{1}{2^{i-1}})\\ &=& O(n) \end{eqnarray} Worst-case runtime: ==2. $\Theta(n)$== :::danger **Correction:** $\Theta(n\log n)$ \begin{eqnarray} T(n,m) &=& T(n,\frac{m}{2}) + O(n)\\ &=& T(n,\frac{m}{2}) + cn\\ &=& T(n,\frac{m}{4}) + cn + cn\\ &\vdots&\\ &=& O(n \log m) \end{eqnarray} Be careful when there are two independent variables in a given problem. They have to be considered separately. In this case, $O(n\log m)=O(n\log n)$ when $n \sim m$. ::: ### b. `algorithm2` ```python= def algorithm2(problem, location=(0, 0), trace=None): # if it's empty, we're done if problem.numRow <= 0 or problem.numCol <= 0: return None nextLocation = problem.getBetterNeighbor(location, trace) if nextLocation == location: # there is no better neighbor, so return this peak return location else: # there is a better neighbor, so move to the neighbor and recurse return algorithm2(problem, nextLocation, trace) ``` #### 1. Correct? ==Yes==, `algorithm2` is correct. #### 2. Worst-case runtime on a problem of size $n \times n$? `getBetterNeighbor()` traverses through its neighbors one step at a time, selecting the largest neighbor as its next location. Consider a zig-zagged pattern (faster than the worst-case) ```python [[1, 0, 11, 12, 13], [2, 0, 10, 0, 14], [3, 0, 9, 0, 15], [4, 0, 8, 0, 16], [5, 6, 7, 0, 17]] ``` which takes $n\lceil \frac{m}{2} \rceil + \lfloor \frac{m}{2} \rfloor$ steps to find a peak, its time complexity is $O(nm)$. Therefore, the worst-case runtime for a problem of size $n \times n$ is ==5. $\Theta(n^2)$==. ### c. `algorithm3` ```python= def algorithm3(problem, bestSeen=None, trace=None): # if it's empty, we're done if problem.numRow <= 0 or problem.numCol <= 0: return None midRow = problem.numRow // 2 midCol = problem.numCol // 2 # first, get the list of all subproblems subproblems = [] (subStartR1, subNumR1) = (0, midRow) (subStartR2, subNumR2) = (midRow + 1, problem.numRow - (midRow + 1)) (subStartC1, subNumC1) = (0, midCol) (subStartC2, subNumC2) = (midCol + 1, problem.numCol - (midCol + 1)) subproblems.append((subStartR1, subStartC1, subNumR1, subNumC1)) subproblems.append((subStartR1, subStartC2, subNumR1, subNumC2)) subproblems.append((subStartR2, subStartC1, subNumR2, subNumC1)) subproblems.append((subStartR2, subStartC2, subNumR2, subNumC2)) # find the best location on the cross (the middle row combined with the # middle column) cross = [] cross.extend(crossProduct([midRow], range(problem.numCol))) cross.extend(crossProduct(range(problem.numRow), [midCol])) crossLoc = problem.getMaximum(cross, trace) neighbor = problem.getBetterNeighbor(crossLoc, trace) # update the best we've seen so far based on this new maximum if bestSeen is None or problem.get(neighbor) > problem.get(bestSeen): bestSeen = neighbor # return if we can't see any better neighbors if neighbor == crossLoc: return crossLoc # figure out which subproblem contains the largest number we've seen so # far, and recurse sub = problem.getSubproblemContaining(subproblems, bestSeen) newBest = sub.getLocationInSelf(problem, bestSeen) result = algorithm3(sub, newBest, trace) return problem.getLocationInSelf(sub, result) ``` #### 1. Correct? ==Yes==, `algorithm3` is correct. :::danger **Correction:** `algorithm3` is incorrect. Consider a possible $3 \times 3$ subproblem `sub` shown below: ``` 0 0 4 | 3 0 0 0 | x 0 1 0 | x ... ------- x x 2 x x x ... ``` In this case, `1` would be returned as a peak because `2` would be outside of `sub`'s dimensions when the recursive function call `algorithm3(sub, newBest, trace)` is made. ::: #### 2. Worst-case runtime on a problem of size $n \times n$? Important operations are listed below: ```python=26 cross.extend(crossProduct([midRow], range(problem.numCol))) # RUNTIME: O(m+m) cross.extend(crossProduct(range(problem.numRow), [midCol])) # RUNTIME: O(n+n) ``` ```python=29 crossLoc = problem.getMaximum(cross, trace) # RUNTIME: O(n+m) ``` ```python=44 result = algorithm3(sub, newBest, trace) # target ``` \begin{eqnarray} T(n,m) &=& T(\frac{n}{2},\frac{m}{2}) + O(n+m)\\ &=& T(\frac{n}{2},\frac{m}{2}) + c(n+m)\\ &=& T(\frac{n}{4},\frac{m}{4}) + c(\frac{n+m}{2}) + c(n+m)\\ &\vdots&\\ &=& T(1) + c(n+m)(1+\frac{1}{2}+\dots+\frac{1}{2^{i-1}})\\ &<& 2c(n+m)\\ &=& O(n+m) \end{eqnarray} Worst-case runtime: ==2. $\Theta(n)$== ### d. `algorithm4` ```python= def algorithm4(problem, bestSeen=None, rowSplit=True, trace=None): # if it's empty, we're done if problem.numRow <= 0 or problem.numCol <= 0: return None subproblems = [] divider = [] if rowSplit: # the recursive subproblem will involve half the number of rows mid = problem.numRow // 2 # information about the two subproblems (subStartR1, subNumR1) = (0, mid) (subStartR2, subNumR2) = (mid + 1, problem.numRow - (mid + 1)) (subStartC, subNumC) = (0, problem.numCol) subproblems.append((subStartR1, subStartC, subNumR1, subNumC)) subproblems.append((subStartR2, subStartC, subNumR2, subNumC)) # get a list of all locations in the dividing column divider = crossProduct([mid], range(problem.numCol)) else: # the recursive subproblem will involve half the number of columns mid = problem.numCol // 2 # information about the two subproblems (subStartR, subNumR) = (0, problem.numRow) (subStartC1, subNumC1) = (0, mid) (subStartC2, subNumC2) = (mid + 1, problem.numCol - (mid + 1)) subproblems.append((subStartR, subStartC1, subNumR, subNumC1)) subproblems.append((subStartR, subStartC2, subNumR, subNumC2)) # get a list of all locations in the dividing column divider = crossProduct(range(problem.numRow), [mid]) # find the maximum in the dividing row or column bestLoc = problem.getMaximum(divider, trace) neighbor = problem.getBetterNeighbor(bestLoc, trace) # update the best we've seen so far based on this new maximum if bestSeen is None or problem.get(neighbor) > problem.get(bestSeen): bestSeen = neighbor # return when we know we've found a peak if neighbor == bestLoc and problem.get(bestLoc) >= problem.get(bestSeen): return bestLoc # figure out which subproblem contains the largest number we've seen so # far, and recurse, alternating between splitting on rows and splitting # on columns sub = problem.getSubproblemContaining(subproblems, bestSeen) newBest = sub.getLocationInSelf(problem, bestSeen) result = algorithm4(sub, newBest, not rowSplit, trace) return problem.getLocationInSelf(sub, result) ``` #### 1. Correct? ==Yes==, `algorithm4` is correct. #### 2. Worst-case runtime on a problem of size $n \times n$? Important operations are listed below: ```python divider = crossProduct(...) # RUNTIME: O(m) or O(n) ``` ```python=39 bestLoc = problem.getMaximum(divider, trace) # RUNTIME: O(m) or O(n) ``` ```python=55 result = algorithm4(sub, newBest, not rowSplit, trace) # target ``` \begin{eqnarray} T(n,m) &=& T(\frac{n}{2},m) + O(m)\\ &=& T(\frac{n}{2},\frac{m}{2}) + O(n) + O(m)\\ &=& T(\frac{n}{2},\frac{m}{2}) + O(n+m) \end{eqnarray} Same as `algorithm3`, worst-case runtime: ==2. $\Theta(n)$== ## Problem 1-5. Peak-Finding Proof Proof of correctness for the *most efficient correct algorithm*: `algorithm4`. We wish to show that `algorithm4` will always return a peak, as long as the problem is not empty. To that end, we wish to prove the following two statements: #### 1. If the peak problem is not empty, then `algorithm4` will always return a location. 1. Say that we start with a problem of size $n \times m$. The recursive subproblem examined by `algorithm4` would have one of the following dimensions: 1. $\lfloor n/2 \rfloor \times m$ 2. $(n - \lfloor n/2 \rfloor - 1) \times m$ 3. $n \times \lfloor m/2 \rfloor$ 4. $n \times (m - \lfloor m/2 \rfloor - 1)$ 2. The number of rows / columns in the problem decreases with each recursive call as long as $n > 0$ or $m > 0$. So `algorithm4` either returns a location at some point, or eventually examines a subproblem with a non-positive number of rows / columns. 3. The only way for the number of rows / columns to become strictly negative, according to lines `15` & `30`, is to have $n = 0$ or $m = 0$ at some point. So if `algorithm4` doesn’t return a location, it must eventually examine an empty subproblem. 4. Just prior to `algorithm4` examining an empty subproblem, it must either examine a subproblem of size $n × 1$, $n × 2$, $1 × m$ or $2 × m$. 5. If the problem is of size $n × 1$ or $1 × m$, then calculating the maximum of the central row / column is equivalent to calculating the maximum of the entire problem. Hence, the maximum found must be a peak $\rightarrow$ returns location. 6. If the problem has dimensions $n × 2$ or $2 × m$, then there are two possibilities: 1. the maximum of the central row / column is a peak $\rightarrow$ returns location 2. found a strictly better neighbor in the other row / column $\rightarrow$ reduces to Step 5. 7. So `algorithm4` can never recurse into an empty subproblem, and therefore `algorithm4` must eventually return a location. #### 2. If algorithm1 returns a location, it will be a peak in the original problem. 1. If `algorithm4` returns a location $(r_1,c_1)$, then that location must 1. have been a peak within some recursive subproblem 2. be the largest value seen yet 2. Assume, for the sake of contradiction, that $(r_1,c_1)$ is not a peak within the original problem. 1. As the location $(r_1, c_1)$ is passed up the chain of recursive calls, it must eventually reach a level where it stops being a peak. 2. At that level, there exists a location `neighbor` adjacent to the location $(r_1, c_1)$ such that $val(r_1, c_1) < val($`neighbor`$)$. 3. Since $(r_1, c_1)$ is the largest value seen yet, it must be that $val($`neighbor`$) \leq val(r_1, c_1)$. 4. Hence, we have the following chain of inequalities: $val(r_1, c_1) < val($`neighbor`$) \leq val(r_1, c_1) \Longrightarrow val(r_1, c_1) < val(r_1, c_1)$ A contradiction. 3. Assume, for the sake of contradiction, that $(r_1,c_1)$ is not the largest value seen yet. 1. Let level $l_k$ be the level from which $val(r_1, c_1)$ was returned, then $l_k$ must be the last level of recursion. 2. Since the location $(r_1, c_1)$ is a peak on level $l_k$, it has the largest value in the dividing row / column. Therefore, the location `bestSeen` must be in either one of the subproblems. 3. We could then call `algorithm4` with the subproblem that contains location `bestSeen` and recurse one level deeper into level $l_{k+1}$. But level $l_k$ is the last level of recursion, hence we have a contradiction. 4. So `algorithm4` must return a location which is a peak within some recursive subproblem and the largest value seen yet, making it a peak in the original problem. ## Problem 1-6. Peak-Finding Counterexamples #### `algorithm3`: ```python= problemMatrix = [ [0, 0, 9, 8, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 1, 0, 0, 0, 0, 0], [0, 2, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0] ] ```