jsborjesson Yet another dev blog

Big Oh No

What the hell does O(log n) mean?

This can be understood by investigating the space complexity of how many bits that is needed to store an integer. (Spoiler: it's O(log n)).

Warning: nasty oneliners ahead, the output is the interesting part.

binary representation and number of bits

pry(main)> (1..16).each{|n| puts "n: %2i    binary: %5s    num_bits: %i" % [n, n.to_s(2), n.to_s(2).length]}
n:  1    binary:     1    num_bits: 1
n:  2    binary:    10    num_bits: 2
n:  3    binary:    11    num_bits: 2
n:  4    binary:   100    num_bits: 3
n:  5    binary:   101    num_bits: 3
n:  6    binary:   110    num_bits: 3
n:  7    binary:   111    num_bits: 3
n:  8    binary:  1000    num_bits: 4
n:  9    binary:  1001    num_bits: 4
n: 10    binary:  1010    num_bits: 4
n: 11    binary:  1011    num_bits: 4
n: 12    binary:  1100    num_bits: 4
n: 13    binary:  1101    num_bits: 4
n: 14    binary:  1110    num_bits: 4
n: 15    binary:  1111    num_bits: 4
n: 16    binary: 10000    num_bits: 5

It seems like the number of bits increases more slowly over time. To get more insight I counted how long the number of bits stays the same until it needs to increase (2 bits is used 2 times, 3 bits is used 4 times etc. as seen above)

numbers until adding a new bit

pry(main)> (1..1023).map{|n| n.to_s(2).length}.chunk{|n| n}.map{|n| n.last.length}
=> [1, 2, 4, 8, 16, 32, 64, 128, 256, 512]

These numbers are looking quite familiar. At this point we are obviously working with powers of 2, and my gut is telling me that the complexity is probably \(O(\sqrt{n})\), the graph seems to even out in a similar way, right?

plotting the square root

sqrt(n)

pry(main)> (1..16).each{|n| puts "n: %4i    binary: %12s    num_bits: %2i    sqrt(n): %i" % [n, n.to_s(2), n.to_s(2).length, Math.sqrt(n).ceil]}
n:    1    binary:            1    num_bits:  1    sqrt(n): 1
n:    2    binary:           10    num_bits:  2    sqrt(n): 2
n:    3    binary:           11    num_bits:  2    sqrt(n): 2
n:    4    binary:          100    num_bits:  3    sqrt(n): 2
n:    5    binary:          101    num_bits:  3    sqrt(n): 3
n:    6    binary:          110    num_bits:  3    sqrt(n): 3
n:    7    binary:          111    num_bits:  3    sqrt(n): 3
n:    8    binary:         1000    num_bits:  4    sqrt(n): 3
n:    9    binary:         1001    num_bits:  4    sqrt(n): 3
n:   10    binary:         1010    num_bits:  4    sqrt(n): 4
n:   11    binary:         1011    num_bits:  4    sqrt(n): 4
n:   12    binary:         1100    num_bits:  4    sqrt(n): 4
n:   13    binary:         1101    num_bits:  4    sqrt(n): 4
n:   14    binary:         1110    num_bits:  4    sqrt(n): 4
n:   15    binary:         1111    num_bits:  4    sqrt(n): 4
n:   16    binary:        10000    num_bits:  5    sqrt(n): 4

When adding the rounded square root to our little table, it looks quite promising. I think I'm onto something here, but if we throw some bigger numbers at it we are quickly disappointed:

square root continued

[30] pry(main)> (1..1023).step(100).each{|n| puts "n: %4i    binary: %12s    num_bits: %2i    sqrt(n): %i" % [n, n.to_s(2), n.to_s(2).length, Math.sqrt(n).ceil]}
n:    1    binary:            1    num_bits:  1    sqrt(n): 1
n:  101    binary:      1100101    num_bits:  7    sqrt(n): 11
n:  201    binary:     11001001    num_bits:  8    sqrt(n): 15
n:  301    binary:    100101101    num_bits:  9    sqrt(n): 18
n:  401    binary:    110010001    num_bits:  9    sqrt(n): 21
n:  501    binary:    111110101    num_bits:  9    sqrt(n): 23
n:  601    binary:   1001011001    num_bits: 10    sqrt(n): 25
n:  701    binary:   1010111101    num_bits: 10    sqrt(n): 27
n:  801    binary:   1100100001    num_bits: 10    sqrt(n): 29
n:  901    binary:   1110000101    num_bits: 10    sqrt(n): 31
n: 1001    binary:   1111101001    num_bits: 10    sqrt(n): 32

adding the logarithm

Now it's time to see if logarithms are the answer to this, when adding them into our table it works out beautifully. Here it is for some bigger numbers:

pry(main)> (1..10000).step(1000).each{|n| puts "n: %4i    binary: %14s    num_bits: %2i    log2(n): %2i    sqrt(n): %i" % [n, n.to_s(2), n.to_s(2).length, Math.log2(n).ceil, Math.sqrt(n).ceil]}
n:    1    binary:              1    num_bits:  1    log2(n):  0    sqrt(n): 1
n: 1001    binary:     1111101001    num_bits: 10    log2(n): 10    sqrt(n): 32
n: 2001    binary:    11111010001    num_bits: 11    log2(n): 11    sqrt(n): 45
n: 3001    binary:   101110111001    num_bits: 12    log2(n): 12    sqrt(n): 55
n: 4001    binary:   111110100001    num_bits: 12    log2(n): 12    sqrt(n): 64
n: 5001    binary:  1001110001001    num_bits: 13    log2(n): 13    sqrt(n): 71
n: 6001    binary:  1011101110001    num_bits: 13    log2(n): 13    sqrt(n): 78
n: 7001    binary:  1101101011001    num_bits: 13    log2(n): 13    sqrt(n): 84
n: 8001    binary:  1111101000001    num_bits: 13    log2(n): 13    sqrt(n): 90
n: 9001    binary: 10001100101001    num_bits: 14    log2(n): 14    sqrt(n): 95

Success!

Note that I'm using 2 as the base for the logarithm, the normal log function has a base of e.

sqrt(n)

This shows how amazingly good logarithmic complexity actually is, maths is cool.

Learnings

  • \(\sqrt{n}\) finds x in \(x^2=n\)
  • \(log_{2}(n)\) finds x in \(2^x=n\)
  • logarithmic complexity is crazy good, almost O(1).