[Update 2-25-2004 - Duh. I forgot that
for each path I could not only flip and rotate it; but also run it forwards
AND BACKWARDS, doubling the number of solutions. Fix is written up
below in the solutions section]
Well, well. Here is the problem that has
plagued the ages. I got the inspiration to solve this problem from my
good friend Ben Geesa. You can see his original project write-up [here].
THE PROBLEM:
Here's the breakdown, given:
The fifteen balls used in pool are numbered
1 to 15 and are commonly placed more or less at random in a triangular
rack at the beginning of the game. The GPBP concerns the distance your
finger would have to travel to touch each of the balls in numerical
order given a specific physical arrangement of the balls. It is easy to
see that the minimum distance your finger would have to travel is 14
units( one unit is equal to the diameter of a ball). This could be
accomplished in any number of arrangements. The GPBP is "What is the
maximum distance your finger could travel and what arrangement of the
balls causes it?" |
THE ANALYSIS:
So, in doing some research on this problem, I find that the basic question
it is asking is the computer science problem classified as all-points
longest path, or the longest simple cycle problem. We all know that
all-points shortest path is an easily solvable problem in a very easy
fashion computationally in O(n²) time. This isn't
bad, where n is the number of balls in the rack. Well, all-points
LONGEST path is much different. It cannot be solved in O(n²)
time; but how long will it take? How bad is it and what does this tell
us about the solution?
We know there is only a finite number of possibilities, so the
problem is bounded, but what is that bound? Well, the total number of
combinations is pretty easy to compute. For our pool ball problem with 15
balls, we can go from any one ball to any other ball. So there are
15*14 possible first paths. After that there are only 13 balls left:
15*14*13. And so on. So for us we have a total
of:15*14*13*12*11*10*9*8*7*6*5*4*3*2*1 = 15! = 1,307,674,368,000 (1.3
trillion paths - that still less than the national debt). So, the
bounding on this problem is n!; which is a problem that grows faster than
polynomial time (n^k) where n is the number of elements and k is some
constant. Clearly n! > n^k; so this problem does NOT have a polynomial
solution. This type of problem is classified as intractable, or the
very technical term of 'hard'. Remember, that in computer science, the
terms 'easy' and 'hard' actually refer to the mathematical properties of the
problem - they are not the relative terms we use to describe the difficulty
of learning a skateboard trick or rating how difficult it is to convince a
spouse that you are right. :)
Tractable – a problem is tractable if it can be solved in polynomial
time. It is in some sense,
easy.
Intractable – a problem requiring super polynomial time is called
intractable. It is in some sense, hard.
|
So, this problem is a NP-complete problem (has a
solution), but is considered hard - which is getting up to the bounds of
what we can actually solve on a computer. Once this type of problem gets up to about 70
nodes, it becomes theoretically unsolvable because it has more computations
than atoms in the universe. But are there any algorithmic shortcuts?
Unfortunately, doing some theory work, we find the longest simple cycle
problem has no shortcuts or tricks. You simply must try
all the possibilities to ensure the correct solution. You can come up
with heuristics (which I do just to see) but you are not guaranteed the
longest path. There is no way around computing all the path lengths; but as we'll see,
the particular properties of this problem help us out a lot.
So, we have 1.3
trillion paths, so we can set about solving it.
THE COMPUTATION
Attempt 1 - naive solution:
First off, I wanted to find out what ball-park the solution is in.
Clearly the shortest path is 14, but what is the rough range of the longest
path - 20, 50, 100? So, I wrote up a quick heuristic solution. I
took Dykstra's greedy algorithm (used to find the shortest path) and inverted
it to find the longest path. This method simply takes the longest
path, take the next longest path, next longest, etc until you build up a
cycle that touches each ball only once. The solution it comes up with
takes all of like 5 milliseconds and gives the solution of:
|
|
|
|
A |
|
|
|
|
|
|
|
B |
|
C |
|
|
|
|
|
D |
|
E |
|
F |
|
|
|
G |
|
H |
|
I |
|
J |
|
K |
|
L |
|
M |
|
N |
|
O |
Path: i - e - l - c - k - a - o - b - n - h - f - g
- j - d - m
Length:37.966709
This was interesting, I didn't think it would be so big right off.
However, theory tells us there is likely a longer solution out there; but it
gives us a ballpark.
Attempt 2 - Recursive solution:
Ok, the next solution that popped into my head says to do this recursively;
trying each path.
Simply start with a dirty and un-optimized C++ version realized about 150,000
paths/sec. Well, a little calculation tells us that at that rate, we'd
need about:
(1,307,674,368,000 / 150,000) / 60 /60 /24 = 100.9 or 101
days of computation time on my P3 1GHZ laptop. Not only that, it
sucked up a good amount of memory space because it needed to do this in a
semi-breadth-first search. At each new level, it needed to keep track
of what paths were left. If we had the following paths, we had to
store a bunch of other info:
A (store b,c,d,e,f,g,h,i,j,k,l,m,n,o as not tried yet) -call B (store
c,d,e,f,g,h,i,j,k,l,m,n,o as not tried yet) - call C (store
d,e,f,g,h,i,j,k,l,m,n,o as not tried yet) and so forth.
I had to be very careful about memory leaks because even a 1 byte leak would have sucked
up all available memory in 100 days. Well, I wasn't going to keep a process
like that running for 1/3 of a year. There was a lot of memory
thrashing, allocating new blocks at each new recursive call. This also
carried a lot of overhead with recursive state saves/restores. I
realized there was probably better solutions. But I got busy with
school and
didn't get to work on it for a while. But my mind churned on it in my
free time.
Attempt 3 - Symmetric
realizations
I realized there was a ton of symmetry in this
problem. You can start with just the balls A, B, D, E and all other starting
points are simply just those positions mirrored left to right, or rotated to
a different corner. This takes our solution down to 4*14! which is:
348,713,164,800 - only 350 million paths instead of 1.5 trillion.
There are lots of other symmetric optimizations you can do from there, but I
stopped with that one because it was easiest to code up and removed a ton of the work
already. With our old program, we could solve this problem in:
(348,713,164,800 / 150,000) /60/60/24 = 26.9 days. Better, but still
way longer than I wanted to keep it running.
I wondered if there was a systematic way the computer could eliminate
duplicated paths; but the record keeping on such a large problem started
getting more bulky than seemed worth it. It would be an interesting
optimization experiment.
Another thought that came to mind is that we compute some paths of the balls
again and again. What if we started building up paths, walking thru
and eliminated duplicates or using the pre-computed paths we've already
done. But again, there isn't a clear way to leverage this advantage
without costing lots of memory for record keeping and searching costs.
Attempt 4 - The dull
approach that works best.
I was losing a lot of compute time because of the
overhead of call stacks and memory thrashing. What I needed was a
faster solution. I started by optimizing the free lists at each level to be a bit
field in a single 16-bit integer instead of an array of free elements. I
also made all the path-length calculations integer based instead of floating
points, all obtained from a pre-computed distance table; so
calculations turned into constant time lookups with integer additions. Then,
I removed the recursion and made it simply a bunch of nested FOR loops.
This let me introduce the symmetric observations I made above more easily
because I didn't need to keep track of which level of recursion I was in.
By removing recursion, there was no more memory allocating because there were no calls stacks
being created. Because the
free-list of paths was stored in a single integer at each level, checking for free elements became simple
bit twiddling on an array of 15 statically allocated integers - which is way faster than searches in lists. Well, roll
all this into one project and do some more optimization and things get much
faster. On top of that, I made the app multi-threaded so that I could
divide and conquer on multiple processors if available. Well, with
this new approach on a single-processor P3 1GHZ laptop, I got the following
processing numbers:
Paths/sec: 5,000,000 on average. 5 million paths/second, not
bad!
Total computation time: (348,713,164,800 / 5,000,000 ) / 60/ 60 = 19.37
hours. Way better, and now being threaded I could set the priority
settings on the thread so I could use the machine for my normal work or
crank it up to full speed overnight. Also, there is no memory
thrashing and everything fits nicely in the processor cache with no swapping.
It's about as fast as it gets. This just goes to show you that some stupid
FOR loops are faster than more complicated methods. As an interesting
side note, I tried little optimizations like
searching for free paths from both directions, which should have made finding free paths twice as fast;
but it actually slowed things down instead of speeding them up.
This is due to the acceleration and look-ahead nature of the Intel processors.
If you
violate these linear execution and data manipulation rules, it actual costs you more to because the processor
has mis-predicted the look-ahead work and must throw it away as well
and start up another path.
Solution (spoiler
alert):
So, press go and wait. I had my app well
instrumented and could watch the progress. My calculation of 19 hours
was pretty close: I got a final solution after 18:45:14 h/m/s of computation. It
is important to note, that if you find one solution in this problem set, you
have immediately found 12. Because the problem is symmetrical, you can
rotate the solution by 60 or 120 degrees, flip it on each axis of rotation
AND run it backwards on each turn. As it turns out, there were two unique (non-mirror/rotated)
solutions to this problem. Very interesting:
|
|
|
|
14 |
|
|
|
|
|
|
|
12 |
|
13 |
|
|
|
|
|
9 |
|
10 |
|
11 |
|
|
|
5 |
|
6 |
|
7 |
|
8 |
|
0 |
|
1 |
|
2 |
|
3 |
|
4 |
Length: 40.316002
Path: 10-3-12-2-14-1-13-6-11-0-8-5-4-9-7
and it's mirrors:
Path: 10-1-13-2-14-3-12-7-9-4-5-8-0-11-6
Path: 6-13-1-11-0-8-5-7-9-4-12-3-14-2-10
Path: 6-8-5-11-0-13-1-10-2-14-3-12-4-9-7
Path: 7-5-8-9-4-12-3-10-2-14-1-13-0-11-6
Path: 7-12-3-9-4-5-8-6-11-0-13-1-14-2-10
And then take each one backwards - I'll leave that to you.
Length: 40.316002
Path
10-2-14-3-12-7-9-4-5-8-0-11-1-13-6
and it's mirrors:
Path: 10-2-14-1-13-6-11-0-8-5-4-9-3-12-7
Path: 6-11-0-12-1-10-2-14-3-12-4-9-8-5-7
Path: 6-11-0-8-5-7-9-4-12-3-14-2-13-1-10
Path: 7-9-4-5-8-6-11-0-13-1-14-2-12-3-10
Path: 7-9-4-12-3-10-2-14-1-13-0-11-5-8-6
And then take each one backwards - I'll leave that to you again.
So, the total number of paths that are the longest,
because of symmetry, mirrors, and forwards/backwards is 24, all having 40.316002
units in length. And
that is that. Hope you enjoyed it.
|