Part 10 - Complexity (2)

We have actually covered everything in this course - in this part we’ll do some exercises!

Order of growth of Functions

Let’s find out the complexity (O\mathcal{O}) of:

T(n)=5(3n2+2n+6)(4 log10(n)+1)T(n) = 5(3n^2 + 2n + 6)(4\ log_{10}(n) + 1)

Since we seek the growth rate - we can use our rules about complexity. We can remove all constants:

T(n)=(n2+n)(log10(n))T(n) = (n^2 + n)(log_{10}(n))

The next rule we can apply is, “the most dominating factor ‘wins’” as I like to call it. Therefore:

T(n)=(n2)(log10(n))T(n) = (n^2)(log_{10}(n))

Then we just multiply!

T(n)=n2 log10(n)T(n) = n^2\ log_{10}(n)

And since we usually write loglog when using Big-O notation:

T(n)=n2 log(n)T(n) = n^2\ log(n)

Now we can say that T(n)T(n) has a O(n2 log(n))\mathcal{O}(n^2\ log(n)) complexity!. Since we are talking about O\mathcal{O}, this means this function has a lower bound of this. This means that T(n)T(n) also has a complexity of O(n3)\mathcal{O}(n^3) for example.

So what we really mean is that T(n)T(n) has a Θ(n2 log(n))\Theta(n^2\ log(n)) complexity.

Suppose an algorithm takes time t on an input of size n. How many times longer does it take on an input of size 10n if…

  • If the algorithm is Θ(n)\Theta(n)?
  • If the algorithm is Θ(n2)\Theta(n^2)?
  • If the algorithm is Θ(n3)\Theta(n^3)?
  • If the algorithm is Θ(n log(n))\Theta(n\ log(n))?
  • If the algorithm is Θ(log(n))\Theta(log(n))?

This is quite easy! We just plug in our new nn, and see how much tt grows!

  • If the algorithm is Θ(n)\Theta(n)?
    • We get 10n10t10n \rightarrow 10t!
  • If the algorithm is Θ(n2)\Theta(n^2)?
    • We get 100n100t100n \rightarrow 100t!
  • If the algorithm is Θ(n3)\Theta(n^3)?
    • We get 1000n1000t1000n \rightarrow 1000t!
  • If the algorithm is Θ(n log(n))\Theta(n\ log(n))?
    • We get 10n log(10n)10n\ \cdot log(10n)!
    • This isn’t just $10 log(10)“ times more - it’s a little bit longer, or so called “logarithmic linear”.
  • If the algorithm is Θ(log(n))\Theta(log(n))?
    • We get log(10n)log(10n)!
    • This means just a constant more time!

Complexity Analysis

Let’s analyze the following snippet of code and, it’s complexity.

found = false
for x in list:
for y in list:
if x + y == 0:
found = true

If we say that the length of list is nn. In the first loop we will have a complexity of O(n)\mathcal{O}(n). The inner loop will follow, using our previous rule of ‘nested loops means multiplication’. This means our final program will have the complexity of O(n2)\mathcal{O}(n^2).

Now let’s see over this code snippet:

found = false
for i in 0 .. n - 1:
for j in i .. n - 1:
x = list[i]
y = list[j]
if x + y == 0:
found = true

In this snippet - we’ll have the first loop iterating nn times - however, the second loop, it will iterate (0,,n1),(1,,n2),(0, \dots ,n -1), (1, \dots , n - 2), \dots

This means that the number of times the second loop will run is between 1 and nn times - using our definition of complexity. Let’s call the number of times our loop runs mm, mnm \leq n which means m has a complexity of O(n)\mathcal{O}(n).

This finally means we have a total complexity of O(n2)\mathcal{O}(n^2)

Now let’s do the same, but for three numbers!

found = false
for i in 0 .. n - 1:
for j in i .. n - 1:
for k in j .. n - 1:
x = list[i]
y = list[j]
z = list[k]
if x + y + z == 0:
found = true

Exactly the same logic goes as from the last question to this, we can prove that each loop has a complexity of O(n)\mathcal{O}(n).

Which gives the total complexity of O(n3)\mathcal{O}(n^3).

Now let’s look at a similar program:

pairs_list = []
for i in 0 .. n - 1:
for j in i .. n - 1:
x = list[i]
y = list[j]
pairs_list.add(x + y)
found = false
for xplusy in pairs_list:
for zplusw in pairs_list:
if xplusy + zplusw == 0:
found = true

As we’ve stated above, the first part of the program will have a complexity of O(n2)\mathcal{O}(n^2). Note that the add() function takes O(1)\mathcal{O}(1) for dynamic arrays.

However, in the next block, the new array length is n2n^2, since we have added all possible permutations of pairs. So the loops will now through n2n^2 elements. Which in total results a complexity of O(n4)\mathcal{O}(n^4).

From our earlier rules, we ‘add’ blocks of codes, so the complexity is O(n2)+O(n4)\mathcal{O}(n^2) + \mathcal{O}(n^4). Which means the resulting complexity becomes O(n4)\mathcal{O}(n^4).

Data Structure Complexities

Let’s refresh our memory and state all the complexities for our data structures and their functions.

  • Dynamic Arrays:
    • Get/Set:
      • O(1)\mathcal{O}(1)
    • Add/Remove at end:
      • O(1)\mathcal{O}(1)
    • Add/remove elsewhere:
      • O(n)\mathcal{O}(n)
  • Stacks/queues:
    • Push/Pop:
      • O(1)\mathcal{O}(1)
    • Enqueue/Dequeue:
      • O(1)\mathcal{O}(1)
  • Binary Heaps:
    • Add/RemoveMin (or Max):
      • O(log(n))\mathcal{O}(log(n))
    • getMin (or Max):
      • O(1)\mathcal{O}(1)
  • BSTs:
    • Add/Remove/Search (worst case, meaning it’s already sorted):
      • O(n)\mathcal{O}(n)
    • Otherwise:
      • O(log(n))\mathcal{O}(log(n))
  • Stacks/queues:
    • Add/Remove/Search (Always!):
      • O(log(n))\mathcal{O}(log(n))
  • Hash Tables:
    • Add/Remove/Search (Given that the hash function is ‘good’):
      • O(1)\mathcal{O}(1)
  • General Tree:
    • If you down in a tree, you’ll visit:
      • O(height)\mathcal{O}(height) nodes
    • If you explore every node, you’ll visit:
      • O(n)\mathcal{O}(n) nodes
    • A tree has the worst case O(n)\mathcal{O}(n) height.
    • A balanced tree is always O(log(n))\mathcal{O}(log(n)) height.

Analyzing more complexities

Let’s take a look at program which utilizes different data structures now:

pairs_list = []
for i in 0 .. n - 1:
for j in i .. n - 1:
x = list[i]
y = list[j]
pairs_list.add(x + y)
merge_sort(pairs_list)
found = false
for xplusy in pairs_list:
if binary_search(pairs_list, -xplusy):
found = true

So, we’ve seen that first part, we know it’s O(n2)\mathcal{O}(n^2). But now we see a merge_sort() - this has a complexity of O(n log(n))\mathcal{O}(n\ log(n)). As we stated before, the list after the first block has a length of n2n^2. Which means merge_sort() will have a complexity of O(n2 log(n2))\mathcal{O}(n^2\ log(n^2))

This will just sort it so, no length is added.

Then the next block, the for loop will have a complexity of O(n2)\mathcal{O}(n^2). The binary search algorithm, has a complexity of O(log(n2))\mathcal{O}(log(n^2)).

So this block will in total have a complexity of O(n2 log(n2))\mathcal{O}(n^2\ log(n^2)).

So if we add these blocks together and apply our rules we will get a total complexity of: O(n2 log(n2))\mathcal{O}(n^2\ log(n^2)). We can apply some log rules to this:

O(n2 log(n2))O(n2 2 log(n))O(n2 log(n))\mathcal{O}(n^2\ log(n^2)) \newline \mathcal{O}(n^2\ 2\ log(n)) \newline \mathcal{O}(n^2\ log(n))

So finally our answer is, O(n2log(n))\mathcal{O}(n^2 log(n))

Let’s now look at a case using a tree:

pairs_set = empty AVL_tree
for i in 0 .. n - 1:
for j in i .. n - 1:
x = pairs_set[i]
y = pairs_set[j]
if pairs_set.contains(-(x+y)):
return true
pairs_set.add(x+y)

As we’ve seen before, the loops are O(n2)\mathcal{O}(n^2) - now the interesting part is the contains() to check if an element is present. To check whether an element is present in a tree, has a complexity of O(log(n))\mathcal{O}(log(n)). The rest of the operations are constant so, we can ignore them (including the add() for the AVL tree).

So-therefore the final complexity is O(n2 log(n))\mathcal{O}(n^2\ log(n)). In the absolute worst case the contains() will be O(n)\mathcal{O}(n), but let’s ignore that :).

Now let’s look at a hash table:

pairs_set = empty Hash_table
for i in 0 .. n - 1:
for j in i .. n - 1:
x = pairs.set[i]
y = pairs_set[j]
if pairs_set.contains(-(x + y)):
return true
pairs_set.add(x + y)

As per usual, the loops together create a complexity of O(n2)\mathcal{O}(n^2), now, a search in a hash table is O(1)\mathcal{O}(1), if our hash function is ‘good’.

The rest of the operations are constant. Therefore, the overall complexity is O(n2)\mathcal{O}(n^2).

Now let’s look at a BST example:

pairs_set = empty BST
for i in 0 .. n - 1:
for j in i .. n - 1:
x = pairs_set[i]
y = pairs_set[j]
if pairs_set.contains(-(x + y)):
return true
pairs_set.add(x + y)

The usual O(n2)\mathcal{O}(n^2) loops :). Now the contains() is the interesting part. Since this is a BST, the contains() will have a complexity of O(n)\mathcal{O}(n) in the worst case, since the BST can become unbalanced.

The same applies for add(). Since the rest of the operations are constant therefore it will be, O(n4)\mathcal{O}(n^4).

Different kinds of complexities

We also need to consider the different cases

  • Best-case
    • This is not useful.
  • Worst-case
    • This is the most useful.
  • Average-case
    • Can be useful sometimes, mostly gives us a ‘indicator’.

Let’s now talk about expected and amortised complexity.

Expected Complexity

This is useful for randomized algorithms! It’s the average over all possible random choice for a particular input.

For example, if we choose a random pivot, we turn quicksort from average-case O(n log(n))\mathcal{O}(n\ log(n)) to expected O(n log(n))\mathcal{O}(n\ log(n))

Amortised Complexity:

Amortised complexity is, the average over any sequence of operations, this is super useful!

For example, we use this to make dynamic arrays have an amortised complexity of O(1)\mathcal{O}(1).

However, when we’re calculating the total runtime of a program, it’s safe to forget about this amortised bit and just treat each operation as costing O(1)\mathcal{O}(1).

Conclusion

This was it for this part - and the final part in this series. I really enjoyed this DSA course, super fun :).