Search code examples
algorithmtime-complexitycomputer-sciencelogarithm

What is the best approach for computing logarithm of an integer x base 2 approximated to the greatest integer less than or equal to it?


What is the best amortized time complexity to compute floor(log(x)) amongst the algorithms that find floor(log(x)) for base 2?


Solution

  • There are many different algorithms for computing logarithms, each of which represents a different tradeoff of some sort. This answer surveys a variety of approaches and some of the tradeoffs involved.

    Approach 1: Iterated Multiplication

    One simple approach for computing ⌊logb n⌋ is to compute the sequence b0, b1, b2, etc. until we find a value greater than n. At that point, we can stop, and return the exponent just before this. The code for this is fairly straightforward:

    x  = 0;   # Exponent
    bX = 1;   # b^Exponent
    
    while (bx <= n) {
        x++;
        bX *= b;
    }
    
    return x - 1;
    

    How fast is this? Notice that the inner loop counts up x = 0, x = 1, x = 2, etc. until eventually we reach x = ⌊logb x⌋, doing one multiplication per iteration. If we assume all the integers we're dealing with fit into individual machine words - say, we're working with int or long or something like that - then each multiply takes time O(1) and the overall runtime is O(logb n), with a memory usage of O(1).

    Approach 2: Repeated Squaring

    There's an old interview question that goes something like this. I have a number n, and I'm not going to tell you what it is. You can make queries of the form "is x equal to n, less than n, or greater than n?," and your goal is to use the fewest queries to figure out what n is. Assuming you have literally no idea what n can be, one reasonable approach works like this: guess the values 1, 2, 4, 8, 16, 32, ..., 2k, ..., until you overshoot n. At that point, use a binary search on the range you just found to discover what n is. This runs in time O(log n), since after computing 21 + log2 n = 2n you'll overshoot n, and after that you're binary searching over a range of size n for a total runtime of O(log n).

    Finding logarithms, in a sense, kinda sorta matches this problem. We have a number n written as bx for some unknown x, and we want to find x. Using the above strategy as a starting point, we can therefore compute b20, b21, b22, etc. until we overshoot bx. From there, we can run a secondary binary search to figure out the exact exponent required.

    We can compute the series of values b2k by using the fact that

    b2k+1 = b2 · 2k = (b2k)2

    and find a value that overshoots as follows:

    x  = 1;    # exponent
    bX = b;    # b^x
    
    while (bX <= n) {
        bX *= bX;  # bX = bX^2
        x  *= 2;
    }
    
    # Overshot, now do the binary search
    

    The problem is how to do that binary search to figure things out. Specifically, we know that b2x is too big, but we don't know by how much. And unlike the "guess the number" game, binary searching over the exponent is a bit tricky.

    One cute solution to this is based on the idea that if x is the value that we're looking for, then we can write x as a series of bits in binary. For example, let's write x = am-12m-1 + am-22m-2 + ... + a121 + a020. Then

    bx = bam-12m-1 + am-22m-2 + ... + a121 + a020

    = 2am-12m-1 · 2am-22m-2 · ... · 2a0 20

    In other words, we can try to discover what bx is by building up x one bit at a time. To do so, as we compute the values b1, b2, b4, b8, etc., we can write down the values we discover. Then, once we overshoot, we can try multiplying them in and see which ones should be included and which should be excluded. Here's what that looks like:

    x  = 1;       // Exponent
    bX = b;       // b^x
    powers = [b]; // b^{2^0}
    exps   = [1]; // 2^0
    
    while (bX <= n) {
        bX *= bX;     // bX = bX^2
        powers += bX; // Append bX
        x++;
        exps += x;
    }
    
    # Overshot, now recover the bits
    resultExp = 1
    result    = 0;
    
    while (x > 0) {
        # If including this bit doesn't overshoot, it's part of the
        # representation of x.
        if (resultExp * powers[x] <= n) {
            resultExp *= powers[x];
            result += exps[x];
        }
    
        x--;
    }
    
    return result;
    

    This is certainly a more involved approach, but it is faster. Since the value we're looking for is ⌊bx⌋ and we're essentially using the solution from the "guess the number game" to figure out what x is, the number of multiplications is O(log logb n), with a memory usage of O(log logb n) to hold the intermediate powers. This is exponentially faster than the previous solution!

    Approach 3: Zeckendorf Representations

    There's a slight modification on the previous approach that keeps the runtime of O(log logb n) but drops the auxiliary space usage to O(1). The idea is that, instead of writing the exponent in binary using the regular system, we write the number out using Zeckendorf's theorem, which is a binary number system based on the Fibonacci sequence. The advantage is that instead of having to store the intermediate powers of two, we can use the fact that any two consecutive Fibonacci numbers are sufficient to let you compute the next or previous Fibonacci number, allowing us to regenerate the powers of b as needed. Here's an implementation of that idea in C++.

    Approach 4: Bitwise Iterated Multiplication

    In some cases, you need to find logarithms where the log base is two. In that case, you can take advantage of the fact that numbers on a computer are represented in binary and that multiplications and divisions by two correspond to bit shifts.

    For example, let's take the iterated multiplication approach from before, where we computed larger and larger powers of b until we overshot. We can do that same technique using bitshifts, and it's much faster:

    x = 0;   # Exponent
    
    while ((1 << x) <= n) {
        x++;
    }
    
    return x - 1;
    

    This approach still runs in time O(log n), as before, but is probably faster implemented this way than with multiplications because the CPU can do bit shifts much more quickly.

    Approach 5: Bitwise Binary Search

    The base-two logarithm of a number, written in binary, is equivalent to the position of the most-significant bit of that number. To find that bit, we can use a binary search technique somewhat reminiscent of Approach 2, though done faster because the machine can process multiple bits in parallel in a single instruction. Basically, as before, we generate the sequence 220, 221, etc. until we overshoot the number, giving an upper bound on how high the highest bit can be. From there, we do a binary search to find the highest 1 bit. Here's what that looks like:

    x = 1;
    
    while ((1 << x) <= n) {
        x *= 2;
    }
    
    # We've overshot the high-order bit. Do a binary search to find it.
    
    low  = 0;
    high = x;
    
    while (low < high) {
        mid = (low + high) / 2;
    
        # Form a bitmask with 1s up to and including bit number mid.
        # This can be done by computing 2^{m+1} - 1.
        mask = (1 << (mid + 1)) - 1
    
        # If the mask overlaps, branch higher
        if (mask & n) { 
            low = mid + 1
        }
        # Otherwise, branch lower
        else {
            high = mid
        }
    }
    
    return high - 1
    

    This approach runs in time O(log log n), since the binary search takes time logarithmic in the quantity being searched for and the quantity we're searching for is O(log n). It uses O(1) auxiliary space.

    Approach 6: Magical Word-Level Parallelism

    The speedup in Approach 5 is largely due to the fact that we can test multiple bits in parallel using a single bitwise operation. By doing some truly amazing things with machine words, it's possible to harness this parallelism to find the most-significant bit in a number in time O(1) using only basic arithmetic operations and bit-shifts, and to do so in a way where the runtime is completely independent of the size of a machine word (e.g. the algorithm works equally quickly on a 16-bit, 32-bit, and 64-bit machine). The techniques involved are fairly complex and I will confess that I had no idea this was possible to do until recently when I learned the technique when teaching an advanced data structures course.

    Summary

    To summarize, here are the approaches listed, their time complexity, and their space complexity.

    Approach              Which Bases?    Time Complexity    Space Complexity
    --------------------------------------------------------------------------
    Iter. Multiplication      Any            O(log_b n)           O(1)
    Repeated Squaring         Any          O(log log_b n)     O(log log_b n)
    Zeckendorf Logarithm      Any          O(log log_b n)         O(1)
    Bitwise Multiplication     2              O(log n)            O(1)
    Bitwise Binary Search      2            O(log log n)          O(1)
    Word-Level Parallelism     2                O(1)              O(1)
    

    There are many other algorithms I haven't mentioned here that are worth exploring. Some algorithms work by segmenting machine words into blocks of some fixed size, precomputing the position of the first 1 bit in each block, then testing one block at a time. These approaches have runtimes that depend on the size of the machine word, and (to the best of my knowledge) none of them are asymptotically faster than the approaches I've outlined here. Other approaches work by using the fact that some processors have instructions that immediately output the position of the most significant bit in a number, or work by using floating-point hardware. Those are also interesting and fascinating, be sure to check them out!

    Another area worth exploring is when you have arbitrary-precision integers. There, the costs of multiplications, divisions, shifts, etc. are not O(1), and the relative costs of these algorithms change. This is definitely worth exploring in more depth if you're curious!

    The code included here is written in pseudocode because it's designed mostly for exposition. In a real implementation, you'd need to worry about overflow, handling the case where the input is negative or zero, etc. Just FYI. :-)

    Hope this helps!