πŸ§‘β€πŸ’»
CS2040S Data Structures and Algorithms
  • Algorithms
    • Overview
    • Time Complexity
    • Binary Search
  • Sorting Algorithms
  • Order Statistics
  • Interval Searching
  • Orthogonal Range Queries
  • Random Permutation Generation
  • Disjoint Set Union
  • Data Structures
    • Binary Search Tree
    • Trie
    • Hash (Symbol) Table
    • (a, b)-Trees
    • (k, d)-Trees
    • Heap
  • Graphs
    • Introduction
    • BFS and DFS
    • DAG and Topological Sort
    • SCC (Tarjan's and Kosaraju's)
    • SSSP (Bellman-Ford and Dijkstra)
    • MST (Prim's and Kruskal's)
  • Dynamic Programming
    • Concept
    • APSP Floyd-Warshall
    • Longest Increasing Subsequence
    • 0-1 Knapsack
    • Prize Collecting
    • Vertex Cover on a Tree
Powered by GitBook
On this page
  • Introduction
  • Useful Mathematical Facts
  • Common Recurrence Relations
  • Some Fun Time Complexity Analysis Questions
  • Big-O Notation Questions
  • Amortized Analysis
  • Accounting Method
  • Binary Counter Amortized Analysis
  • Stack 2 Queue
  • Clarification

Was this helpful?

  1. Algorithms

Time Complexity

Introduction

Time complexity of a program shows the order of growth of the running time as the input gets larger and larger.

It tells nothing about the absolute/actual running time of the algorithm. So, it is not always a good idea to compare two algorithms simply based on their time complexities. For example, MergeSort runs in O(nlog⁑n)O(n \log n)O(nlogn) whereas InsertionSort runs in O(n2)O(n^2)O(n2). But in practice, for smaller size arrays, InsertionSort often runs faster than MergeSort!!

Since constants are omitted while dealing with time complexity of algorithms, two programs may have the same complexity but very different running times. For example, consider program A with T(n)=108n=O(n)T(n) = 10^8n = O(n)T(n)=108n=O(n) and program B with T(n)=n=O(n)T(n) = n = O(n)T(n)=n=O(n). Obviously program B will run faster than program A, but this is not evident if you only look at their orders of growth.

Useful Mathematical Facts

It is useful to know the following properties of exponents and logarithms:

  1. ax=mβ€…β€ŠβŸΊβ€…β€Šx=loga(m)a^x = m \iff x = log_a (m)ax=m⟺x=loga​(m) β†’ definition of logarithm

  2. loga(mn)=loga(m)+loga(n)log_a (mn) = log_a (m) + log_a (n)loga​(mn)=loga​(m)+loga​(n) β†’ product property

  3. loga(m/n)=loga(m)βˆ’loga(n)log_a(m/n) = log_a(m) - log_a(n)loga​(m/n)=loga​(m)βˆ’loga​(n) β†’ quotient property

  4. log(mn)=nlog(m)log(m^n) = nlog(m)log(mn)=nlog(m) β†’ power property

  5. loga(b)=1/logb(a)log_a(b) = 1/log_b(a)loga​(b)=1/logb​(a)

  6. logba=logc(a)Γ—logb(c)=logc(a)/logc(b)log_b a = log_c(a) \times log_b(c) = log_c(a)/log_c(b)logb​a=logc​(a)Γ—logb​(c)=logc​(a)/logc​(b) β†’ change of base property

  7. aloga(x)=aa^{log_a(x)} = aaloga​(x)=a β†’ number raised to log

  8. loga(a)=1log_a(a) = 1loga​(a)=1

  9. loga(1)=0log_a(1) = 0loga​(1)=0

  10. loga(1/b)=βˆ’loga(b)log_a(1/b) = -log_a(b)loga​(1/b)=βˆ’loga​(b)

  11. alogb(x)=xlogb(a)a^{log_b(x)} =x^{log_b(a)}alogb​(x)=xlogb​(a) (when x,a>0x, a > 0x,a>0) β†’ can take logloglog on both sides to verify its true

  12. aman=am+na^ma^n = a^{m + n}aman=am+n

  13. am/an=amβˆ’na^m/a^n = a^{m -n}am/an=amβˆ’n

  14. 1/am=aβˆ’m1/a^m = a^{-m}1/am=aβˆ’m

  15. (am)n=amn(a^m)^n = a^{mn}(am)n=amn

  16. (ab)m=ambm(ab)^m = a^mb^m(ab)m=ambm

1+2+3+...+n=n(n+1)2=O(n2)1 + 2 + 3 + ... + n = \dfrac{n(n+1)}{2} = O(n^2)1+2+3+...+n=2n(n+1)​=O(n2)

The above arithmetic progression can appear in various different forms. The more general form is:

Given an arithmetic series a+(a+d)+(a+2d)+β‹―+a+(nβˆ’1)d=n2(2a+(nβˆ’1)Γ—d)=n2(a+l)a + (a + d) + (a + 2d) + \dots + a + (n-1)d = \dfrac{n}{2}(2a + (n-1)\times d) = \dfrac{n}{2}(a + l)a+(a+d)+(a+2d)+β‹―+a+(nβˆ’1)d=2n​(2a+(nβˆ’1)Γ—d)=2n​(a+l) where lll is the last term of the series and nnn is the number of terms.

12+22+32+...+n2=n(n+1)(2n+1)6=O(n3)1^2 + 2^2 + 3^2 + ... + n^2 = \dfrac{n(n+1)(2n+1)}{6} = O(n^3)12+22+32+...+n2=6n(n+1)(2n+1)​=O(n3)
13+23+β‹―+n3=(n(n+1)2)2=O(n4)1^3 + 2^3 + \dots + n^3 = \left(\dfrac{n(n+1)}{2} \right)^2 = O(n^4)13+23+β‹―+n3=(2n(n+1)​)2=O(n4)
1+r+r2+r3+....=11βˆ’r,for ∣r∣<11 + r + r^2 + r^3 + .... = \dfrac{1}{1-r}, \quad \text{for}\ |r| < 11+r+r2+r3+....=1βˆ’r1​,for ∣r∣<1

More generally, the solution to the geometric progression a+ar+ar2+β‹―+arnβˆ’1=a(rnβˆ’1)rβˆ’1a + ar + ar^2 + \dots + ar^{n-1} = \dfrac{a(r^n - 1)}{r-1}a+ar+ar2+β‹―+arnβˆ’1=rβˆ’1a(rnβˆ’1)​

11+12+13+β‹―+1n=O(log⁑n)\dfrac{1}{1} + \dfrac{1}{2} + \dfrac{1}{3} + \dots + \dfrac{1}{n} = O(\log n)11​+21​+31​+β‹―+n1​=O(logn)

The above series (called the harmonic series) is divergent but the sum of nnn terms is upper bounded by O(log⁑n)O(\log n)O(logn). This is because:

βˆ‘n=1k1n>∫1k+11xdx=ln(k+1)\sum_{n = 1}^{k} \dfrac{1}{n} > \int_1^{k+1} \dfrac{1}{x}dx = ln(k+1)n=1βˆ‘k​n1​>∫1k+1​x1​dx=ln(k+1)

It is easy to view the bound in terms of the graphs.

1+2+4+8+16+β‹―+2m=2m+1βˆ’1=O(2m)1 + 2 + 4 + 8 + 16 + \dots + 2^m = 2^{m + 1} - 1 = O(2^m)1+2+4+8+16+β‹―+2m=2m+1βˆ’1=O(2m)
1+2+4+8+16+β‹―+m=2mβˆ’1=O(m)1 + 2 + 4 + 8 + 16 + \dots + m = 2m - 1 = O(m)1+2+4+8+16+β‹―+m=2mβˆ’1=O(m)

The following result is also very useful (e.g. for deriving the time complexity of heapify)

βˆ‘i=1∞i2i=12+24+38+β‹―=2\sum_{i = 1}^{\infty} \dfrac{i}{2^i} = \dfrac{1}{2} + \dfrac{2}{4} + \dfrac{3}{8} + \dots = 2i=1βˆ‘βˆžβ€‹2ii​=21​+42​+83​+β‹―=2

Common Recurrence Relations

T(n)=2T(n2)+O(1)=O(n)T(n) = 2T(\dfrac{n}{2}) + O(1) = O(n)T(n)=2T(2n​)+O(1)=O(n)

In general, T(n)=cT(nc)+O(1)=O(n)T(n) = cT(\dfrac{n}{c}) + O(1) = O(n)T(n)=cT(cn​)+O(1)=O(n)


T(n)=2T(n2)+O(n)=O(nlog⁑n)T(n) = 2T(\dfrac{n}{2}) + O(n) = O(n \log n)T(n)=2T(2n​)+O(n)=O(nlogn)

A common example of an algorithm whose running time has the above recurrence relation is MergeSort

In general, T(n)=cT(nc)+O(n)=O(nlog⁑n)T(n) = cT(\dfrac{n}{c}) + O(n) = O(n\log n)T(n)=cT(cn​)+O(n)=O(nlogn)


T(n)=T(nβˆ’1)+T(nβˆ’2)+...+T(1)=O(2n)T(n) = T(n-1)+ T(n-2)+... + T(1) = O(2^n)T(n)=T(nβˆ’1)+T(nβˆ’2)+...+T(1)=O(2n)wherewherewhere where T(1)=1T(1) = 1T(1)=1

Proof

Let T(1)=1T(1) = 1T(1)=1. Then, T(2)=T(1)=1T(2) = T(1) = 1T(2)=T(1)=1.

T(3)=T(2)+T(1)=1+1=2T(3) = T(2) + T(1) = 1 + 1 = 2T(3)=T(2)+T(1)=1+1=2

T(4)=T(3)+T(2)+T(1)=2+1+1=4T(4) = T(3) + T(2) + T(1) = 2 + 1 + 1 = 4T(4)=T(3)+T(2)+T(1)=2+1+1=4

T(5)=T(4)+T(3)+T(2)+T(1)=4+2+1+1=8T(5) = T(4) + T(3) + T(2) + T(1) = 4 + 2 + 1 + 1 = 8T(5)=T(4)+T(3)+T(2)+T(1)=4+2+1+1=8

T(6)=T(5)+T(4)+T(3)+T(2)+T(1)=8+4+2+1+1=16T(6) = T(5) + T(4) + T(3) + T(2) + T(1) = 8 + 4 + 2 + 1 + 1 = 16T(6)=T(5)+T(4)+T(3)+T(2)+T(1)=8+4+2+1+1=16

It is clear from the pattern that that the running time doubles when n is increased by 1.

Hence, the solution of this recurrence is given by T(n)={1,Β ifΒ nΒ =Β 1Β orΒ 22nβˆ’2,Β otherwiseT(n) = \begin{cases} 1 \text{\qquad, if n = 1 or 2} \\ 2^{n-2} \text{\quad, otherwise}\end{cases}T(n)={1,Β ifΒ nΒ =Β 1Β orΒ 22nβˆ’2,Β otherwise​

Note that the solution depends on the exact value of T(1)T(1)T(1) and cannot be determined simply by knowing that T(1)=O(1)T(1) = O(1)T(1)=O(1) because when it comes to algorithmic time complexity, the base of the exponent matters!


T(n)=T(n/c)+O(1)=O(logn)forΒ someΒ constantΒ cΒ >Β 1T(n) = T(n/c) + O(1) = O(logn) \text{\qquad for some constant c > 1}T(n)=T(n/c)+O(1)=O(logn)forΒ someΒ constantΒ cΒ >Β 1

An example of an algorithm that follows the above recurrence relation is BinarySearch (here, c = 2).


T(n)=T(n/c)+O(n)=O(n)forΒ someΒ constantΒ cΒ >Β 1T(n) = T(n/c) + O(n) = O(n) \text{\qquad for some constant c > 1}T(n)=T(n/c)+O(n)=O(n)forΒ someΒ constantΒ cΒ >Β 1

Q. Solve the following recurrence relation: T(n)=2T(nβˆ’1)+O(1)T(n) = 2T(n-1) + O(1)T(n)=2T(nβˆ’1)+O(1)

T(n)=2T(nβˆ’1)+O(1)=2[2T(nβˆ’2)+O(1)]+O(1)=4T(nβˆ’2)+2O(1)+O(1)AssumingΒ thatΒ O(1)=1,=4T(nβˆ’2)+2+1=8T(nβˆ’3)+4+2+1=2n+2nβˆ’1+β‹―+4+2+1=2n+1βˆ’1=O(2n)\begin{equation*} \begin{split} T(n) &= 2T(n-1) + O(1) \\ &= 2[2T(n-2) + O(1)] + O(1) \\ &= 4T(n-2) + 2O(1) + O(1) \\ \text{Assuming that }O(1) = 1, \\ &= 4T(n-2) + 2 + 1 \\ &= 8T(n-3) + 4 + 2 + 1 \\ &= 2^n + 2^{n-1} + \dots + 4 + 2 + 1 \\ &= 2^{n+1} - 1 \\ &= O(2^n) \end{split} \end{equation*}T(n)AssumingΒ thatΒ O(1)=1,​=2T(nβˆ’1)+O(1)=2[2T(nβˆ’2)+O(1)]+O(1)=4T(nβˆ’2)+2O(1)+O(1)=4T(nβˆ’2)+2+1=8T(nβˆ’3)+4+2+1=2n+2nβˆ’1+β‹―+4+2+1=2n+1βˆ’1=O(2n)​​

Some Fun Time Complexity Analysis Questions

What is the order of growth of a program which has running time T(n)=(n217)(n4)+n3nβˆ’7+n2log⁑nT(n) = \left(\dfrac{n^2}{17}\right)\left(\dfrac{\sqrt{n}}{4}\right) + \dfrac{n^3}{n-7} + n^2\log nT(n)=(17n2​)(4n​​)+nβˆ’7n3​+n2logn

Answer: There is no bound! Because, as nnn approaches 7, T(n)T(n)T(n) grows without any bound. Mathematically, limnβ†’7T(n)=∞lim_{n \xrightarrow{} 7 } T(n) = \inftylimn​7​T(n)=∞

What is the order of growth of the following code?

public static int loopy(int n) {
	int j = 1;
	int n2 = n;
	for (int i = 0; i < n; i++)
		n2 *= 5.0/7.0;
		for (int k = 0; k < n2; k++) {
			System.out.println("Hello");
		}
	}
	return j;
}

Answer: O(n)O(n)O(n)

It is easy to observe that the total running time is directly proportional to the number of times β€œhello” is printed (i.e., the number of times the inner loop runs). This can be analysed as follows

T(n)=57n+(57)2n+(57)3n+....=n(57+(57)2+(57)3+....)=n571βˆ’57=2.5n=O(n)\begin{equation*} \begin{split} T(n) &= \dfrac{5}{7}n + \left(\dfrac{5}{7}\right)^2n + \left(\dfrac{5}{7}\right)^3n +.... \\ &= n \left( \dfrac{5}{7} + \left(\dfrac{5}{7}\right)^2 + \left(\dfrac{5}{7}\right)^3 +.... \right) \\ &= n \dfrac{\frac{5}{7}}{1-\frac{5}{7}} \\ &= 2.5n \\ &= O(n)\end{split}\end{equation*}T(n)​=75​n+(75​)2n+(75​)3n+....=n(75​+(75​)2+(75​)3+....)=n1βˆ’75​75​​=2.5n=O(n)​​

Note that even though the outer loop runs n times, since the value of n2 falls below 1 in less than n iterations, in the last few iterations of the outerloop, the inner loop does not run at all!

This is also a good example to not just assume O(n2)O(n^2)O(n2) because there is a nested for loop or assume O(nlogn)O(nlogn)O(nlogn) since at each stage, n2 is reduced by a fraction. Don’t jump to conclusions!

T(n)T(n)T(n) is the running time of a divide-and-conquer algorithm that divides the input of size nnn into n10\dfrac{n}{10}10n​ equal parts and recurses on all of them. It uses O(n)O(n)O(n) work in dividing/recombining on all of them (and there’s no other cost, i.e., no other work done). The base case for the recursion is when the input is less than size 20, which costs O(1)O(1)O(1). What is the order of growth of T(n)T(n)T(n)?

Ans: O(n)O(n)O(n) (Read the question carefully! IT IS NOT O(nlog⁑n)O(n\log n)O(nlogn)) T(n)=n10T(nn/10)+O(n)=n10O(1)+O(n)=O(n)T(n) = \dfrac{n}{10}T(\dfrac{n}{n/10}) + O(n) = \dfrac{n}{10}O(1) + O(n) = O(n)T(n)=10n​T(n/10n​)+O(n)=10n​O(1)+O(n)=O(n)

What is the running time of the following code, as a function of nnn:

public static int recursiveloopy(int n) {
	for (int i = 0; i < n; i++) {
		for (int j = 0; j < n; j++) {
			System.out.println("Hello");
		}
	}
	if (n <= 2) return 1;
	else if (n%2 == 0) return recursiveloopy(n+1);
	else return recursiveloopy(n-2);

Ans: O(n3)O(n^3)O(n3)

It is obvious that the nested for loops run in O(n2)O(n^2)O(n2) time. The slightly more interesting part is the recursive call. If nnn is even, recursiveloopy(n+1) is called to make it odd. If nnn is odd, recursiveloopy(n-2) is called (and nnn remains odd). So, recursiveloopy(n+1) is called at most 1 time. All other times, we can safely assume that recursiveloopy(n-2) is called. Now, the recurrence relation would be T(n)=T(nβˆ’2)+O(n2)T(n) = T(n-2) + O(n^2)T(n)=T(nβˆ’2)+O(n2) (Since we can ignore the one time that recursiveloopy(n+1) is called, if ever). Solving the recurrence relation,

It is clear that the sum of all even squares and sum of all odd squares should contribute nearly equally to the sum of squares of all integers from 1 through n. That is, we are estimating that the sum of squares of odd integers from 1 to n would be roughly n(n+1)(2n+1)12\dfrac{n(n+1)(2n+1)}{12}12n(n+1)(2n+1)​, which would still be order O(n3)O(n^3)O(n3)

What is the running time of the following code, as a function of nnn:

public int f(int n) {
	for (int i = 0l i < n; i++) {
		for (int j = 1; j < i; j *= 2) {
			System.out.println(".");
		}
	}
	return 0;
}

Ans: O(nlogn)O(nlogn)O(nlogn) This is a classic example of 2 nested for loops that produce order O(nlogn)O(nlogn)O(nlogn) when the inner loop variable grows by a constant factor. The detailed analysis is as follows:

During the ithi^{th}ith iteration of the outer for loop, the inner loop runs for about log(i)log(i)log(i)iterations. So,

T(n)=βˆ‘i=1nlog(i)=log(1)+log(2)+...+log(n)=log(1β‹…2β‹―n)=log(n!)<log(nn)=nlogn=O(nlog⁑n)T(n) = \sum_{i = 1}^{n} log(i) = log(1) + log(2) + ... + log(n) = log(1\cdot 2 \cdots n) = log(n!) < log(n^n) = nlogn = O(n\log n)T(n)=i=1βˆ‘n​log(i)=log(1)+log(2)+...+log(n)=log(1β‹…2β‹―n)=log(n!)<log(nn)=nlogn=O(nlogn)

What is the running time of the following code, as a function of nnn:

public String f(int n) {
	String s = "";
	for (int i = 0; i < n; i++) {
		s += "?";
	}
	return s;
}

Ans: O(n2)O(n^2)O(n2). This appears to be O(n)O(n)O(n) at first glance since there is only 1 for loop that runs exactly n times. However, string concatenation takes O(l)O(l)O(l) time, where lll is the length of the string. During the ithi^{th}ith iteration of the loop, the string is iβˆ’1i - 1iβˆ’1 characters long. So, adding all of them up gives us T(n)=βˆ‘i=0nβˆ’1=0+1+2+...+nβˆ’1=n(nβˆ’1)2=O(n2)T(n) = \sum_{i = 0}^{n-1} = 0 + 1 + 2 + ... + n -1 = \dfrac{n(n-1)}{2} = O(n^2)T(n)=βˆ‘i=0nβˆ’1​=0+1+2+...+nβˆ’1=2n(nβˆ’1)​=O(n2)

What is the asymptotic running time of the following code, as a function of nnn when you execute loopyloop(n,n) ?

public static void loopyloop(int a, int b) {
	if (b == 1) {
		for (int i = 1; i <= a; i ++) {
			System.out.println("Loopy!");
		}
	}
	else loopyloop(a - 1, b/2);
}

Ans: O(n)O(n)O(n). By the time b equals 1, there have been log2nlog_2nlog2​n recursive calls made to loopyloop and so, the value of a would have reduced to nβˆ’log2nn - log_2nnβˆ’log2​n. So, the for loop runs for nβˆ’log2nn - log_2nnβˆ’log2​n times. This is still O(n)O(n)O(n).

What is the asymptotic running time of the following code, as a function of nnn when you execute doubleTwist(n,n) ?

public static void doubleTwist(int a) {
	if (a == 1) return
	twist(a/2);
	twist(a/2);
}
public static void twist(int b) {
	if (b == 1) return;
	doubleTwist(b/2);
	doubleTwist(b/2);
}

Ans: Let the running time of doubleTwist be f(n)f(n)f(n) and that of twist be g(n)g(n)g(n). Then, f(n)=2g(n/2)+O(1)f(n) = 2g(n/2) + O(1)f(n)=2g(n/2)+O(1) and g(n)=2f(n/2)+O(1)g(n) = 2f(n/2) + O(1)g(n)=2f(n/2)+O(1). We can draw out the recursive tree or we can solve the recurrence relation rigorously. Either way the answer is O(n)O(n)O(n).

f(n)=2Γ—g(n/2)=4Γ—f(n/4)=16f(n/16)=β‹―=O(n)f(n) = 2 \times g(n/2) = 4 \times f(n/4) = 16 f(n/16) = \dots = O(n)f(n)=2Γ—g(n/2)=4Γ—f(n/4)=16f(n/16)=β‹―=O(n)

Find the running time of the following program as a function of n (Assume that isDivisible(x,y) takes O(1)O(1)O(1) to run):

public static boolean isPrime(int n) {
	if (n < 2) return false;
	for (int i = 2; i <= sqrt(n); i++) {
		if (isPrime(i) && isDivisible(n,i)) {
			return false;
		}
	}
	return true;
}

Ans: O(n)O(n)O(n)

Let us consider the recurrence relation of the function. T(n)=n+βˆ‘i=2nT(i)T(n) = \sqrt{n} + \sum_{i = 2}^{\sqrt{n}}T(i)T(n)=n​+βˆ‘i=2n​​T(i)

This is because the for loop runs for O(n)O(\sqrt{n})O(n​) times (and each call of isDivisible is O(1)O(1)O(1)). So, excluding the recursive call, the time complexity of T(n)T(n)T(n) should be O(n)O(\sqrt{n})O(n​). But it also makes recursive calls in every loop - so we add those too.

Note that this is not a trivial recurrence relation to solve.

T(n)=n+βˆ‘i=2nT(i)≀n+nΓ—T(n)=n1/2+n1/2Γ—T(n1/2)=n1/2+n1/2(n1/4+n1/4Γ—T(n1/4))=n1/2+n3/4+n3/4T(n1/4)=n1/2+n3/4+n3/4(n1/8+n1/8Γ—T(n1/8))=n1/2+n3/4+n7/8+n7/8Γ—T(n1/8)……=n1/2+n3/4+n7/8+β‹―+nT(1)=nT(1)+β‹―+nn1/32+nn1/16+nn1/8+nn1/4+nn1/2LetΒ usΒ assumeΒ thatΒ T(1)=1/2.Β Then,T(n)=n2+n22+n24+n28+…=nβˆ‘i=0∞122i=O(n)sinceΒ βˆ‘i=0∞122iΒ isΒ aΒ constantΒ approximatelyΒ equalΒ toΒ 0.816\begin{equation*} \begin{split} T(n) &= \sqrt{n} + \sum_{i = 2}^{\sqrt{n}}T(i) \\ &\leq \sqrt{n} + \sqrt{n}\times T(\sqrt{n}) \\ & = n^{1/2} + n^{1/2}\times T(n^{1/2}) \\ &= n^{1/2} + n^{1/2}(n^{1/4} + n^{1/4}\times T(n^{1/4})) \\ &= n^{1/2} + n^{3/4} + n^{3/4}T(n^{1/4}) \\ &= n^{1/2} + n^{3/4} + n^{3/4}(n^{1/8} + n^{1/8} \times T(n^{1/8})) \\ & = n^{1/2} + n^{3/4} + n^{7/8} + n^{7/8} \times T(n^{1/8}) \\ \dots \\ \dots \\ &= n^{1/2} + n^{3/4} + n^{7/8} + \dots + nT(1) \\ &= nT(1) + \dots + \dfrac{n}{n^{1/32}} + \dfrac{n}{n^{1/16}} + \dfrac{n}{n^{1/8}} + \dfrac{n}{n^{1/4}} + \dfrac{n}{n^{1/2}}\\ \text{Let us assume that }T(1) = 1/2. \text{ Then,}\\ T(n) &= \dfrac{n}{2} + \dfrac{n}{2^2} + \dfrac{n}{2^4} + \dfrac{n}{2^8} + \dots \\ &= n \sum_{i = 0}^{\infty} \dfrac{1}{2^{2^i}} = O(n) \text{\qquad since }\sum_{i = 0}^{\infty} \dfrac{1}{2^{2^i}} \text{ is a constant approximately equal to 0.816} \end{split} \end{equation*}T(n)……LetΒ usΒ assumeΒ thatΒ T(1)=1/2.Β Then,T(n)​=n​+i=2βˆ‘n​​T(i)≀n​+n​×T(n​)=n1/2+n1/2Γ—T(n1/2)=n1/2+n1/2(n1/4+n1/4Γ—T(n1/4))=n1/2+n3/4+n3/4T(n1/4)=n1/2+n3/4+n3/4(n1/8+n1/8Γ—T(n1/8))=n1/2+n3/4+n7/8+n7/8Γ—T(n1/8)=n1/2+n3/4+n7/8+β‹―+nT(1)=nT(1)+β‹―+n1/32n​+n1/16n​+n1/8n​+n1/4n​+n1/2n​=2n​+22n​+24n​+28n​+…=ni=0βˆ‘βˆžβ€‹22i1​=O(n)sinceΒ i=0βˆ‘βˆžβ€‹22i1​ isΒ aΒ constantΒ approximatelyΒ equalΒ toΒ 0.816​​

Big-O Notation Questions

Is 22n=O(2n)2^{2n} = O(2^n)22n=O(2n)?

Ans: No! 22n=2n2n=4n≠O(2n)2^{2n} = 2^n 2^n = 4^n \neq O(2^n)22n=2n2n=4n=O(2n) (The base of the exponent matters!)

Is log2n=O(log10n)log_2n = O(log_{10}n)log2​n=O(log10​n)?

Ans: Yes!! Because they differ only by a constant (log2n=log10nβˆ—log210log_2n = log_{10}n* log_210log2​n=log10​nβˆ—log2​10)

What is the best (tightest) asymptotic upper bound for the following?

f(n)=24logn+n5f(n) = 2^{4logn} + n^5f(n)=24logn+n5

Ans: O(n5)O(n^5)O(n5). Because, 24logn=2logn4=n42^{4logn} = 2^{logn^4} = n^424logn=2logn4=n4 (obviously we assume that the base of log is 2 since we are computer scientists :) )

f(n)=22n2+4n+7f(n) = 2^{2n^2 + 4n + 7}f(n)=22n2+4n+7

Ans: O(22n2+4n)O(2^{2n^2 + 4n})O(22n2+4n). Only 272^727 is a constant. All other terms are being multiplied (not added!) and hence, cannot be ignored as being insignificant. In particular, the answer cannot be O(2n2)O(2^{n^2})O(2n2) or O(22n2)O(2^{2n^2})O(22n2) since you are forgetting to multiply a factor of 24n2^{4n}24nwhich is essentially 16n16^n16n, not a constant!

Amortized Analysis

It is a common technique for analyzing β€œaverage” cost per operation. We use this when most operations are cheap but once in a while, we need to pay a lot. This is similar to how you pay rent: you don’t pay rent every second or every day (although you could technically). So, you can think of it like you’re living for free on 29 days of the month and on 1 day you need to pay a large amount. But of course this does not give the true picture - so you find the cost you’re paying per day.

Similarly, we use amortized analysis for data structures.

An operation is said to have amortized cost T(n)T(n)T(n) if, for every integer kkk, the cost of kkk operations is ≀kT(n)\leq kT(n)≀kT(n)

When we say kkk operations, we mean kkk continuous operations, starting from the first operation. You cannot pick any random kkk operations from the middle and say that the amortized cost is so high.

In other words, for every prefix sequence, the total cost of that sequence of operations cannot exceed T(n)T(n)T(n) times the length of that prefix sequence.

Amortized β‰  Average! Amortized is a much stronger constraint than average since it needs to hold for every value of kkk. In case of average, the total cost of all operations should not exceed T(n)T(n)T(n) times the total number of operations.

Example (hash table): Inserting kkk elements into a hash table takes time O(k)O(k)O(k). Therefore, the insert operation has amortized cost O(1)O(1)O(1).

Accounting Method

Imagine a bank account BBB. Each operation performed adds money to the bank acconut. Every step of the algorithm spends money:

  • Immediate money: to perform that particular operation

  • Deferred operation: from the bank account

Total cost of execution = total money (Average time per operation = total money divided by number of operations)

For each operation, you are given T(n)T(n)T(n) time. For most operations you need less than that. So, you’re saving the time when performing cheap operations and using that saved time to perform expensive operations (that take β‰₯T(n)\geq T(n)β‰₯T(n) ) once in a while.

Binary Counter Amortized Analysis

Binary counter ADT is a data structure that counts in base two, i.e. 0s and 1s. Binary Counter ADT supports two operations:

  • increment() increases the counter by 1.

  • read() reads the current value of the binary counter

To make it clearer, suppose that we have a k-bit binary counter. Each bit is stored in an array A of size kkk, where A[k]denotes the k-th bit (0-th bit denotes the most significant bit). For example, suppose A = [0, 1, 1, 0], which corresponds to the number 110110110 in binary. Calling increment() will yield A = [0, 1, 1, 1], i.e. 111111111. Calling increment() again will yield A = [1, 0, 0, 0], the number 100010001000 in binary.

Suppose that the k-bit binary counter starts at 0, i.e. all the values in A is 0. A loose bound on the time complexity if increment() is called nnn times is O(nk)O(nk)O(nk) (since each operation flips at most kkk bits). What is the amortized time complexity of increment() operation if we call increment() n times?

Let us look at a few operations to gain some insight into the amortized cost (the cost is essentially just the number of bits we need to flip):

Binary Counter

Cost of operation

1

[0,0,0,0,1]

1

1

1

2

[0,0,0,1,0]

2

3

1.5

3

[0,0,0,1,1]

1

4

1.3

4

[0,0,1,0,0]

3

7

1.75

5

[0,0,1,0,1]

1

8

1.6

6

[0,0,1,1,0]

2

10

1.67

7

[0,0,1,1,1]

1

11

8

[0,1,0,0,1]

4

15

So, for each operation, if we assign a cost of 222, the β€œbank balance” will never be negative and we can pay for expensive operations using previously saved money.

We can solve this problem using the Accounting Method – having a β€œbank” of β€œtime saved up” which pays for expensive operations. We know to use amortized analysis on this question because increment takes a variable number of flips (read: some operations are expensive and others are cheap), and we are looking for a tighter bound than O(nk)O(nk)O(nk) where nnn is the number of times increment is called and kkk is the total number of digits.

We can also observe that in order for a digit to be flipped to 0, it must have been flipped to 1 first. By the accounting method, we want to β€œsave” up for the more expensive operations, which is when many 1s have to be flipped to 0s. We propose that each flip of a bit takes 2 units of time - 1 unit to flip from 0 to 1, and 1 unit to be saved in the bank for when the bit must be flipped from 1 to 0 in the future. This has the invariant that the amount in the bank never dips below 0 (as when you flip the bit from 0 to 1, you pay in advance for the future flip back to 0). Hence the amortised bound is 2 flips per increment. Increment is O(1)O(1)O(1) in amortized time per increment and nnn increments is bounded by O(n)O(n)O(n).

Observe that the 0th bit flips nnn times when increment is called nnn times. The 1st bit flips n/2n/2n/2 times when increment is called nnn times. The 2nd bit flips n/4n/4n/4 times when increment is called nnn times. And so on. Also observe that in a sequence of nnn operations, the total number of flips is n+n/2+n/4+β‹―+1≀2nn + n/2 + n/4 + \dots + 1 \leq 2nn+n/2+n/4+β‹―+1≀2n. So, the total cost of nnn operations is ≀2n\leq 2n≀2n (Notice that this is true for any value of nnn by the nature of how binary counting works)

Stack 2 Queue

It is possible to implement a queue using two stacks. But is it really efficient? (a) Design an algorithm to push and pop an element from the queue. (b) Determine the worst case and amortized runtime for each operation

(a) Let’s call the two stacks as S1S_1S1​ and S2S_2S2​. When we push an element, we push it to stack S1S_1S1​. When we want to pop an element, note that the first element pushed in S1S_1S1​ should be popped first. We should pop the elements from S1S_1S1​ one by one and push it back at the same time to S2. Note that the ordering will be reversed in S2S_2S2​, hence when we pop from S2S_2S2​, it will be the first element pushed in S1S_1S1​. Therefore, when pop operation is called, we pop the element from S2S_2S2​. If S2S_2S2​ is empty, then we transfer the elements in S1S_1S1​ to S2S_2S2​.

(b) The worst case for one operation is O(n)O(n)O(n), where n is the number of inserted elements. However, the amortized cost is much smaller than that. We will prove that the amortized cost for each operation is O(1)O(1)O(1) using Accounting Method. Whenever we push a new element, we will deposit $2 to the bank. When we transfer an element from S1S_1S1​ to S2S_2S2​ or popping an element from S2S_2S2​, we will pay $1. Note that when pop is called and S2S_2S2​ is empty, we have at least $2k deposited in the bank, where k is the number of inserted elements in S1S_1S1​. This is enough money to pay for the transferring cost that takes O(k)O(k)O(k) time. Note that the remaining money $2k βˆ’ $k = $k can be used to pay for when the element is popped from S2S_2S2​.

Clarification

There is a very important distinction between worst-case analysis, big-O notation, average-case analysis, expected time analysis, big-theta notation, etc.

Worst case analysis deals with the worst possible kind of input to a program. We carefully handpick the worst possible input by analysing how the algorithm works. When we say that insertion sort takes Θ(n2)\Theta(n^2)Θ(n2) in the worst case, we mean that the algorithm running time grows quadratically as the array size increases and the worst possible input (e.g. reversed array) is fed to the program. It is not necessary to use OOO while talking about worst-case analysis.

O(f(n))O(f(n))O(f(n)) simply represents a family of functions that grow slower than cf(n)cf(n)cf(n) for some positive constant ccc, i.e., T(n)∈O(f(n))β€…β€ŠβŸΉβ€…β€Šβˆƒn0,cβˆ€n>n0cf(n)>T(n)T(n) \in O(f(n)) \implies \exists n_0, c \quad \forall n>n_0 \quad cf(n) > T(n)T(n)∈O(f(n))βŸΉβˆƒn0​,cβˆ€n>n0​cf(n)>T(n). We often abuse notation and for convenience, simply write that T(n)=O(f(n))T(n) = O(f(n))T(n)=O(f(n)) to mean that the running time T(n)T(n)T(n) is upper bounded by f(n)f(n)f(n). BUT this does not give us any information regarding what type of running time we are dealing with or what scenario we are analysing.

Average-case analysis deals with how the program performs when a random input is fed to the algorithm. It is important to note that here, while the input is random, the program may still be deterministic.

An indeterministic program is one which makes random choices while the algorithm is running. When talking about indeterministic programs, the running time is a random variable (it depends on the random choice made by the algorithm). In fact, even for the same input, the running time may vary. For example, in case of QuickSort the running time depends heavily on the choice of a pivot. So, it is more practical to talk about the expected running time of a randomised algorithm.

In short, big-O and big-theta notations only give us bounds of a function. They do not tell us anything about the scenario for which the function is being considered.

In CS2040S, we normally consider the worst-case scenario unless otherwise specified.

PreviousOverviewNextBinary Search

Last updated 8 months ago

Was this helpful?

(increment index)

Total cost of operations

Total cost/

nnn
nnn
nnn
<2< 2<2
<2< 2<2