Algorithm Analysis
Algorithm analysis provides an estimate as to how much time or space your program will take to run. Knowing basic algorithm analysis can aid in quickly determining whether possible solution approaches are viable and in optimizing solutions that are too slow.
Big O notation
Big O notation shows how an program scales with the input size. Informally, we say that a program has a time complexity of if it performs around operations for an input of size .
For example, if a program is , then it performs around operations for an input of size . Using this fact, we can predict that — given an input of size — the program in question will perform on the order of operations.
A special time complexity is , often referred to as constant time. Programs that are take the same amount of time regardless of the input size.
It's important to keep in mind that Big O notation can only give you a general idea of how a program will perform from a purely theoretical standpoint; do not take it as gospel. In practice, there are a myriad of factors affecting the performance of programs that Big O does not take into account.
For example, as we will see in the following section, Big O treats addition and division as having the same complexity, which is inaccurate — on most processors, integer addition takes 1 cycle while division takes upward of 20.
Complexities of Common Programming Constructs
In the previous section, we provided an abstract introduction to the Big O concept. Here, we give the complexity of various built-in operations and constructs, which will help us analyze more complex programs later on.
Mathematical Operations
Mathematical operations are . Below are some examples:
1 + 1
2 * 7
n / 4 # where n is a variable
I/O Operations
Reading a line of input using input
is .
Likewise, writing a line of output using print
is .
List Operations
Operation | Time Complexity |
---|---|
list[...] (accessing by index) | |
list[...] = ... (updating by index) | |
list.append(...) | * |
list.pop() | |
len(list) | |
list.sort() |
*Astute readers may note that this is not entirely accurate; appending to a dynamic array takes amortized constant time as opposed to constant time. While this is technically correct, we gloss over the distinction in this introductory article for simplicity. Interested readers may take a look at the Wikipedia page on amortized analysis.
Dictionary Operations
Operation | Time Complexity |
---|---|
dict[...] (accessing by key) | |
dict[...] = ... (adding new element) | |
len(dict) |
Loops
To determine the complexity of a loop, multiply the number of iterations by the complexity of the work performed in each one. Concretely, consider:
for _ in range(n):
1 + 1
This code will loop times. In each iteration, one operation occurs, so the overall complexity is .
Nested loops are similar:
for _ in range(n):
for _ in range(n):
1 + 1
The outer loop runs times. In each iteration of the outer loop, the inner loop iterates times, performing one operation in each. Hence, the overall complexity is .
Sequences of Operations
Suppose we do not have a single mathematical operation or a single loop, but rather some combination of the two. For example, consider:
1 + 1
for _ in range(n):
1 + 1
for _ in range(2 * n):
1 + 1
In such a case, we can split the program into smaller parts (which we know the complexity of), analyze them individually, and sum everything at the end.
In particular, we know:
- The first addition contributes operation;
- The first loop contributes operations;
- The second loop contributes operations.
It follows that the overall time complexity is .
In practice, we will often omit constant terms and lesser factors in Big O notation; that is, we would write instead of .
The reasons for this are twofold:
- It conveys the same information using less space: both and tell us that our program runs in linear time with regard to the input size.
- As the input size grows, constant terms and lesser factors become increasingly irrelevant.
Analyzing a Simple Program
Now, let us try to determine how many operations a simple program takes (and hence find its Big O.)
n = int(input())
total = 0
for k in range(1, n + 1):
total += k
print(total)
This program accepts one integer input, , and outputs the sum of all integers from up to .
For example, if we input , would be outputted. Further, if we consider the main loop in the program, as shown below,
n = int(input())
total = 0
for k in range(1, n + 1):
total += k
print(total)
we observe that will start at and go up to , meaning that there will be a total of iterations.
Now, consider what happens as we increase , say to . In that case, the output would be . Again, if we consider the main loop, we see that will start at and go up to , resulting in a total of iterations.
More generally, if we input , then the program will loop times. Each iteration corresponds to operation, and thus there are a total of operations performed. Thus, we say that the program has a time complexity of .
Acceptable Complexities for Various Input Sizes
While reading a problem, you can often predict what time complexity your program should have based off the problem constraints. For example, if you needed to do some operations on a list of size where could be up to a million, you can rule out any solutions with a complexity exceeding . On the other hand, if the input size is small (e.g. ), brute force (all subsets / permutations) would suffice and should be the first thing that comes to mind.
Below is a table with different input sizes and acceptable time complexities.
Bound on | Maximum acceptable time complexity |
---|---|
Beyond | , |
Exercises
For each program, work out its time complexity. If needed, look up time complexities of syntax/functions (e.g. "what is the time complexity of taking a substring of a string?")
Question 1
n = int(input())
print(n + n)
Provide your answer in terms of .
Solution
Answer: .
Explanation:
input
is constant time, so the first line is .
Adding two integers is also constant time, so the second line is also .
— remember to discard constant factors.
Question 2
n = int(input())
m = int(input())
counter = 0
for i in range(n):
for j in range(m):
counter += 1
print(counter)
Provide your answer in terms of and .
Solution
Answer: .
Explanation:
input
is constant time, so the first two lines are .
The main construct of interest is the nested loop ranging from to ( iterations):
for i in range(n):
for j in range(m):
counter += 1
Examining the inner loop, we see that it ranges from to ( iterations):
for j in range(m):
counter += 1
Thus the overall time complexity is .
Question 3
n = int(input())
while n > 0:
for j in range(1000):
print(j)
n //= 2
Provide your answer in terms of .
Solution
Answer: .
Explanation: Observe that the loop halves every iteration until it reaches , which means that the number of iterations is .
while n > 0:
for j in range(1000):
print(j)
n //= 2
The inner loop over may look like it contributes to the time complexity, but since the number of iterations is constant it ends up discarded.
Question 4
s = input()
for i in range(1, len(s) + 1):
print(s[:i])
Provide your answer in terms of , the length of string s
.
Solution
Answer: .
Explanation:
Taking a substring of a larger string s[:i]
takes time.
As changes each iteration, so the time complexity of each iteration is not the same.
However, considering we consider the loop as a whole, we see that the time complexity of the first iteration is , the second iteration , the third and the -th .
Then, the total time complexity across all iteration is:
and by the formula for the sum of an arithmetic progression
we have
Question 5
nums = []
n = int(input())
for i in range(n):
num = int(input())
nums.append(num)
t = int(input())
counter = 0
for i in range(n):
for j in range(i):
if nums[i] + nums[j] == t:
counter += 1
print(counter)
Provide your answer in terms of .
Solution
Answer: .
Explanation:
The loop to read in nums
requires iterations and is thus .
The main loop is very similar to the previous problem, and can be analyzed in the exact same way.
for i in range(n):
for j in range(0, i):
# ...
The outer loop features iterations. In the first iteration, we see that the inner loop loops times; in the second, it loops time; in the third, times, and so on.
By the same method presented for Question 4, we obtain that the loop is overall.
Question 6
nums = []
n = int(input())
for i in range(n):
num = int(input())
nums.append(num)
i = 0
while i < len(nums):
run_len = 1
cur = nums[i]
i += 1
while i < len(nums) and nums[i] == cur:
i += 1
run_len += 1
print(run_len)
Provide your answer in terms of .
Solution
Answer: .
Explanation:
The loop to read in nums
requires iterations and is thus .
Though there are two nested loops, we observe that they share the same condition i < len(nums)
. This means that can only be incremented times before the loops break.
Moreover, observe that in each iteration of the inner loop:
while ...:
i += 1
run_len += 1
is incremented by . As noted previously, only increments can occur before the loops break; thus, the overall time complexity is .
Question 7 (Bonus)
def fac(n):
if n <= 1:
return n
return n * fac(n - 1)
n = int(input())
print(fac(n))
Provide your answer in terms of .
Solution
Answer: .
Explanation:
Every time fac
is called, it recurses with a decremented value of . When reaches or less, the recursion stops.
Thus fac(n)
will result in recursive calls, and as multiplication is constant time we obtain an overall time complexity of .