Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Java Benchmark for recursive stairs climbing puzzle

This now very common algorithm question was asked by a proctor during a whiteboard exam session. My job was to observe, listen to and objectively judge the answers given, but I had neither control over this question asked nor could interact with the person answering. There was five minutes given to analyze the problem, where the candidate could write bullet notes, pseudo code (this was allowed during actual code-writing as well as long as it was clearly indicated, and people including pseudo-code as comments or TODO tasks before figuring out the algorithm got bonus points).

  • "A child is climbing up a staircase with n steps, and can hop either 1 step, 2 steps, or 3 steps at a time. Implement a method to count how many possible ways the child can jump up the stairs."

The person who got this question couldn't get started on the recursion algorithm on the spot, so the proctor eventually piece-by-piece led him to HIS solution, which in my opinion was not optimal (well, different from my chosen solution making it difficult to grade someone objectively with respect to code optimization).

Proctor:

public class Staircase {

public static int stairs;

public Staircase() {

    int a = counting(stairs);
    System.out.println(a);

}

static int counting(int n) {
    if (n < 0)
        return 0;
    else if (n == 0)
        return 1;
    else
        return counting(n - 1) + counting(n - 2) + counting(n - 3);
}

public static void main(String[] args) {
    Staircase child;
    long t1 = System.nanoTime();
    for (int i = 0; i < 30; i++) {
        stairs = i;
        child = new Staircase();            
    }
    System.out.println("Time:" + ((System.nanoTime() - t1)/1000000));

}
}

//

Mine:

public class Steps {

public static int stairs;
int c2 = 0;

public Steps() {

    int a = step2(0);
    System.out.println(a);

}

public static void main(String[] args) {
    Steps steps;
    long t1 = System.nanoTime();
    for (int i = 0; i < 30; i++) {
        stairs = i;
        steps = new Steps();
    }
    System.out.println("Time:" + ((System.nanoTime() - t1) / 1000000));
}

public int step2(int c) {

    if (c + 1 < stairs) {
        if (c + 2 <= stairs) {
            if (c + 3 <= stairs) {
                step2(c + 3);
            }
            step2(c + 2);
        }
        step2(c + 1);
    } else {
        c2++;
    }
    return c2;
}
}

OUTPUT: Proctor: Time: 356 Mine: Time: 166

Could someone clarify which algorithm is better/ more optimal? The execution time of my algorithm appears to be less than half as long, (but I am referencing and updating an additional integer which i thought was rather inconsequential) and it allows for setting arbitrary starting and ending step without needing to first now their difference (although for anything higher than n=40 you will need a beast of a CPU).

My question: (feel free to ignore the above example) How do you properly benchmark a similar recursion-based problem (tower of Hanoi etc.). Do you just look at the timing, or take other things into consideration (heap?).

like image 371
NetIceGear Avatar asked Sep 03 '14 03:09

NetIceGear


1 Answers

Teaser: You may perform this computation easily in less than one millisecond. Details follow...


Which one is "better"?

The question of which algorithm is "better" may refer to the execution time, but also to other things, like the implementation style.

The Staircase implementation is shorter, more concise and IMHO more readable. And more importantly: It does not involve a state. The c2 variable that you introduced there destroys the advantages (and beauty) of a purely functional recursive implementation. This may easily be fixed, although the implementation then already becomes more similar to the Staircase one.


Measuring performance

Regarding the question about execution time: Properly measuring execution time in Java is tricky.

Related reading:

  • How do I write a correct micro-benchmark in Java?
  • Java theory and practice: Anatomy of a flawed microbenchmark
  • HotSpot Internals

In order to properly and reliably measure execution times, there exist several options. Apart from a profiler, like VisualVM, there are frameworks like JMH or Caliper, but admittedly, using them may be some effort.

For the simplest form of a very basic, manual Java Microbenchmark you have to consider the following:

  • Run the algorithms multiple times, to give the JIT a chance to kick in
  • Run the algorithms alternatingly and not only one after the other
  • Run the algorithms with increasing input size
  • Somehow save and print the results of the computation, to prevent the computation from being optimized away
  • Don't print anything to the console during the benchmark
  • Consider that timings may be distorted by the garbage collector (GC)

Again: These are only rules of thumb, and there may still be unexpected results (refer to the links above for more details). But with this strategy, you usually obtain a good indication about the performance, and at least can see whether it's likely that there really are significant differences between the algorithms.


The differences between the approaches

The Staircase implementation and the Steps implementation are not very different.

The main conceptual difference is that the Staircase implementation is counting down, and the Steps implementation is counting up.

The main difference that actually affects the performance is how the Base Case is handled (see Recursion on Wikipedia). In your implementation, you avoid calling the method recursively when it is not necessary, at the cost of some additional if statements. The Staircase implementation uses a very generic treatment of the base case, by just checking whether n < 0.

One could consider an "intermediate" solution that combines ideas from both approaches:

class Staircase2
{
    public static int counting(int n)
    {
        int result = 0;
        if (n >= 1) 
        {
            result += counting(n-1);
            if (n >= 2) 
            {
                result += counting(n-2);
                if (n >= 3) 
                {
                    result += counting(n-3);
                }
            }
        }
        else
        {
            result += 1;
        }
        return result;
    }
}

It's still recursive without a state, and sums up the intermediate results, avoiding many of the "useless" calls by using some if queries. It's already noticably faster than the original Staircase implementation, but still a tad slower than the Steps implementation.


Why both solutions are slow

For both implementations, there's not really anything to be computed. The method consists of few if statements and some additions. The most expensive thing here is actually the recursion itself, with the deeeeply nested call tree.

And that's the key point here: It's a call tree. Imagine what it is computing for a given number of steps, as a "pseudocode call hierarchy":

 compute(5)
  compute(4)
   compute(3)
    compute(2)
     compute(1)
      compute(0)
      compute(0)
     compute(1)
      compute(0)
      compute(0)
    compute(2)
     compute(1)
      compute(0)
      compute(0)
     compute(1)
      compute(0)
   compute(3)
    compute(2)
     compute(1)
      compute(0)
      compute(0)
     compute(1)
      compute(0)
      compute(0)
    compute(2)
     compute(1)
      compute(0)
      compute(0)

One can imagine that this grows exponentially when the number becomes larger. And all the results are computed hundreds, thousands or or millions of times. This can be avoided


The fast solution

The key idea to make the computation faster is to use Dynamic Programming. This basically means that intermediate results are stored for later retrieval, so that they don't have to be computed again and again.

It's implemented in this example, which also compares the execution time of all approaches:

import java.util.Arrays;

public class StaircaseSteps
{
    public static void main(String[] args)
    {
        for (int i = 5; i < 33; i++)
        {
            runStaircase(i);
            runSteps(i);
            runDynamic(i);
        }
    }

    private static void runStaircase(int max)
    {
        long before = System.nanoTime();
        long sum = 0;
        for (int i = 0; i < max; i++)
        {
            sum += Staircase.counting(i);
        }
        long after = System.nanoTime();
        System.out.println("Staircase  up to "+max+" gives "+sum+" time "+(after-before)/1e6);
    }

    private static void runSteps(int max)
    {
        long before = System.nanoTime();
        long sum = 0;
        for (int i = 0; i < max; i++)
        {
            sum += Steps.step(i);
        }
        long after = System.nanoTime();
        System.out.println("Steps      up to "+max+" gives "+sum+" time "+(after-before)/1e6);
    }

    private static void runDynamic(int max)
    {
        long before = System.nanoTime();
        long sum = 0;
        for (int i = 0; i < max; i++)
        {
            sum += StaircaseDynamicProgramming.counting(i);
        }
        long after = System.nanoTime();
        System.out.println("Dynamic    up to "+max+" gives "+sum+" time "+(after-before)/1e6);
    }
}

class Staircase
{
    public static int counting(int n)
    {
        if (n < 0)
            return 0;
        else if (n == 0)
            return 1;
        else
            return counting(n - 1) + counting(n - 2) + counting(n - 3);
    }
}




class Steps
{
    static int c2 = 0;
    static int stairs;

    public static int step(int c)
    {
        c2 = 0;
        stairs = c;
        return step2(0);
    }

    private static int step2(int c)
    {
        if (c + 1 < stairs)
        {
            if (c + 2 <= stairs)
            {
                if (c + 3 <= stairs)
                {
                    step2(c + 3);
                }
                step2(c + 2);
            }
            step2(c + 1);
        }
        else
        {
            c2++;
        }
        return c2;
    }
}


class StaircaseDynamicProgramming
{
    public static int counting(int n)
    {
        int results[] = new int[n+1];
        Arrays.fill(results, -1);
        return counting(n, results);
    }

    private static int counting(int n, int results[])
    {
        int result = results[n];
        if (result == -1)
        {
            result = 0;
            if (n >= 1) 
            {
                result += counting(n-1, results);
                if (n >= 2) 
                {
                    result += counting(n-2, results);
                    if (n >= 3) 
                    {
                        result += counting(n-3, results);
                    }
                }
            }
            else
            {
                result += 1;
            }
        }
        results[n] = result;
        return result;
    }
}

The results on my PC are as follows:

...
Staircase  up to 29 gives 34850335 time 310.672814
Steps      up to 29 gives 34850335 time 112.237711
Dynamic    up to 29 gives 34850335 time 0.089785
Staircase  up to 30 gives 64099760 time 578.072582
Steps      up to 30 gives 64099760 time 204.264142
Dynamic    up to 30 gives 64099760 time 0.091524
Staircase  up to 31 gives 117897840 time 1050.152703
Steps      up to 31 gives 117897840 time 381.293274
Dynamic    up to 31 gives 117897840 time 0.084565
Staircase  up to 32 gives 216847936 time 1929.43348
Steps      up to 32 gives 216847936 time 699.066728
Dynamic    up to 32 gives 216847936 time 0.089089

Small changes in the order of statements or so ("micro-optimizations") may have a small impact, or make a noticable difference. But using an entirely different approach can make the real difference.

like image 58
Marco13 Avatar answered Nov 17 '22 22:11

Marco13