Just for fun: Countdown numbers game solver

T

Terry Jones

dg> It's great how many different sorts of solutions (or almost solutions)
dg> this puzzle has generated. Speedwise, for reference my solution posted
dg> above takes about 40 seconds on my 1.8GHz laptop, and the less elegant
dg> version (on my webpage linked to in the original post) takes about 15
dg> seconds. It seems to me like surely this problem can be more
dg> efficiently solved than that?

dg> Paul: 758 = 8+(5*((2+4)*25))

For this I get:

2 solutions to: target 758, numbers = (2, 4, 5, 8, 25)
(4, 2, 'add', 25, 'mul', 5, 'mul', 8, 'add')
(25, 4, 'mul', 5, 'sub', 8, 'mul', 2, 'sub')

real 0m0.118s
user 0m0.074s
sys 0m0.044s


Terry
 
A

Arnaud Delobelle

Hi all,

It's great how many different sorts of solutions (or almost solutions)
this puzzle has generated. Speedwise, for reference my solution posted
above takes about 40 seconds on my 1.8GHz laptop, and the less elegant
version (on my webpage linked to in the original post) takes about 15
seconds. It seems to me like surely this problem can be more
efficiently solved than that?

I haven't had the time to look at your solution yet. I wanted to have
a go without being influenced.
Arnaud: I haven't had time to play with your solution yet - how quick
does it run?

I can't run test from here (work) but last night it seemed of the
order of a second on my 2GHz MacBook Pro (it was late and I was quite
tired so I didn't have the energy to time anything...). It depends if
you stop when you hit the first solution or you want to go through all
of them. I guess it will run quicker if I don't build a string
representation of each calculation.

You should run a test on your own machine though to make comparisons
meaningful.
My fantasy is that there is a solution that isn't TOO slow where you
can just look at the code and go 'Oh yes, of course that works!' and
understand it immediately. Maybe that's too much to ask even of
Python! ;-)

It's a laudable goal. We might get there :)
 
C

cokofreedom

dg> It's great how many different sorts of solutions (or almost solutions)
dg> this puzzle has generated. Speedwise, for reference my solution posted
dg> above takes about 40 seconds on my 1.8GHz laptop, and the less elegant
dg> version (on my webpage linked to in the original post) takes about 15
dg> seconds. It seems to me like surely this problem can be more
dg> efficiently solved than that?

dg> Paul: 758 = 8+(5*((2+4)*25))

For this I get:

2 solutions to: target 758, numbers = (2, 4, 5, 8, 25)
(4, 2, 'add', 25, 'mul', 5, 'mul', 8, 'add')
(25, 4, 'mul', 5, 'sub', 8, 'mul', 2, 'sub')

real 0m0.118s
user 0m0.074s
sys 0m0.044s

Terry

Terry, your technique is efficient and pretty readable! All that could
be added now is a way to output the data in a more user-friendly
print. (it currently reminds me of Lisp operands done backwards :) )

Also perhaps the creation of a random generator for the 6 numbers and
the target :)

I do not remember if countdown enforced that it must be possible to
actually get the target, you could also get close to it. (I believe
you would get different points depending on this)

Will have to read up on how many large numbers you can have, as well
as small. Currently I think it is you can take up to 4 large numbers,
and up to 6 small numbers to a total of 6. (you must always have 6
numbers to work with)
 
A

Arnaud Delobelle

Hi Arnaud.

Hi Terry

[...]
WRT to the missing solution, note that my code only allowed multiplication
by 1 if it was the last thing done. That was because you can multiply by 1
at any time, and I didn't want to see those trivially equivalent solutions
(same goes for adding 0). Seeing as you're allowed to omit numbers, I've
now gotten rid of those trivial operations altogether in my solution.

Sorry I gave an incorrect example to illustrate my question last night
(I blame this on baby-induced sleep deprivation ;), so I'll have
another go:

Say I have 2, 3, 4, 100 and I want to make 406. AFAICS there is only
one way: (2*3)+(4*100), i.e. in postfix notation:

2 3 * 4 100 * +

It seemed to me that your function wouldn't generate that sort of
solution (as you always extend partialSolution by [num, op] making the
subsequence [mul, add] impossible). Am I wrong?
 
T

Terry Jones

cokofreedom> Terry, your technique is efficient and pretty readable! All
cokofreedom> that could be added now is a way to output the data in a more
cokofreedom> user-friendly print.

Yes, and a fix for the bug Arnaud just pointed out :)
Below is code to print things more nicely for you.

Terry


from operator import *
from itertools import izip

def countdown(target, nums, numsAvail, value, partialSolution, solutions, ops=(add, mul, sub, div)):
if value == target or not any(numsAvail):
# Add the solution, if we're right.
if value == target:
solutions.add(tuple(partialSolution))
elif value is None:
# Use each distinct number as a starting value.
used = set()
for i, num in enumerate(nums):
if num not in used:
numsAvail = False
used.add(num)
partialSolution.append(num)
countdown(target, nums, numsAvail, num, partialSolution, solutions, ops)
numsAvail = True
partialSolution.pop()
else:
for op in ops:
for i, num in enumerate(nums):
if numsAvail:
numsAvail = False
moreAvail = any(numsAvail)
try:
lastNum, lastOp = partialSolution[-2:]
except ValueError:
lastNum, lastOp = partialSolution[-1], None
# Don't allow any of:
if not (
# Div: after mul, by 1, by 0, producing a fraction.
(op == div and (lastOp == 'mul' or num <= 1 or value % num != 0)) or
# If initial mul/add, canonicalize to 2nd operator biggest.
((op == mul or op == add) and lastOp is None and num > lastNum) or
# Don't allow add or sub of 0.
((op == add or op == sub) and num == 0) or
# Don't allow mult by 1.
(op == mul and num == 1) or
# Don't allow sub after add (allow add after sub).
(op == sub and lastOp == 'add') or
# If same op twice in a row, canonicalize operand order.
(lastOp == op.__name__ and num > lastNum)
):
partialSolution.extend([num, op.__name__])
countdown(target, nums, numsAvail, op(value, num), partialSolution, solutions, ops)
del partialSolution[-2:]
numsAvail = True

op2sym = { 'add' : '+', 'sub' : '-', 'mul' : '*', 'div': '/', }

def pretty(s):
out = [str(s[0])]
lastOp = None
for value, op in izip(*(iter(s[1:]),) * 2):
if (op == 'mul' or op == 'div') and (lastOp == 'add' or lastOp == 'sub'):
out.insert(0, '(')
out.append(')')
out.append(op2sym[op])
out.append(str(value))
lastOp = op
return ''.join(out)

for nums, target in (
((100, 9, 7, 6, 3, 1), 253),
((100, 9, 7, 6, 3, 1), 234),
((2, 3, 5), 21),
((7, 8, 50, 8, 1, 3), 923),
((8, 8), 16),
((8, 8, 8), 8),
((8, 0), 8),
((7,), 8),
((), 8),
((8, 8, 8, 8), 32),
((2, 4, 5, 8, 25), 758),
((2, 3, 4, 100), 406),
):
solutions = set()
countdown(target, nums, [True,] * len(nums), value=None, partialSolution=[], solutions=solutions)
print "%d solutions to: target %d, numbers = %s" % (len(solutions), target, nums)
for s in sorted(solutions, cmp=lambda a, b: cmp(len(a), len(b)) or cmp(a, b)):
print '\t', pretty(s)


Sample output:

8 solutions to: target 253, numbers = (100, 9, 7, 6, 3, 1)
(6*3-1)*9+100
(7*6+9)*3+100
(9-3)*7*6+1
(100-9-7)*3+1
((3+1)*6-7)*9+100
(7+1)*6*3+100+9
(7+6+3+1)*9+100
(100-7-6)*3-9+1
19 solutions to: target 234, numbers = (100, 9, 7, 6, 3, 1)
(6*3+7+1)*9
((7+1)*9+6)*3
(7*3-1+6)*9
(7*6*3-100)*9
((100-1)/3-7)*9
((100-1)*7+9)/3
(100*7*3+6)/9
(100-9)/7*6*3
(100-9-7-6)*3
((6+1)*100-7+9)/3
((6-9)*7-1+100)*3
(7*3-6)*9-1+100
7*6*3-1+100+9
(100-1)/3*7-6+9
((100-1)*7-9)/3+6
(100*7-6-1+9)/3
((100-7)/3-1+9)*6
((100-7)/3-6+1)*9
(100+9+7+1)/3*6
 
T

Terry Jones

Arnaud> Sorry I gave an incorrect example to illustrate my question last
Arnaud> night (I blame this on baby-induced sleep deprivation ;), so I'll
Arnaud> have another go:

Arnaud> Say I have 2, 3, 4, 100 and I want to make 406. AFAICS there is only
Arnaud> one way: (2*3)+(4*100), i.e. in postfix notation:
Arnaud> 2 3 * 4 100 * +

Arnaud> It seemed to me that your function wouldn't generate that sort of
Arnaud> solution (as you always extend partialSolution by [num, op] making
Arnaud> the subsequence [mul, add] impossible). Am I wrong?

No, you're right. I actually started out with a stack solution, and then
switched to something simpler (too simple). I'm going to redo it, but maybe
not right now...

Thanks!

Terry
 
A

Arnaud Delobelle

Arnaud: I haven't had time to play with your solution yet - how quick
does it run?

Ok I've done some quick timings, it's faster than I remembered it:

numbers = [2, 4, 5, 8, 25]
target = 758

Average time to find
(1) best solution (in terms of length of repr): 0.0418s
(2) first solution: 0.00421s

(2.2GHz MacBook Pro, done with the timeit module)

(1) involves working out all possible calculations with the 6 numbers.
 
A

Arnaud Delobelle

Ever since I learnt to program I've always loved writing solvers for
the Countdown numbers game problem in different languages, and so now
I'm wondering what the most elegant solution in Python is.

I've tried a completely different approach, that I imagine as
'folding'. I thought it would improve performance over my previous
effort but extremely limited and crude benchmarking seems to indicate
disappointingly comparable performance...

def ops(a, b):
"Generate all possible ops on two numbers with histories"
x, hx = a
y, hy = b
if x < y:
x, y, hx, hy = y, x, hy, hx
yield x + y, (hx, '+', hy)
if x != 1 and y != 1:
yield x * y, (hx, '*', hy)
if x != y:
yield x - y, (hx, '-', hy)
if not x % y and y != 1:
yield x / y, (hx, '/', hy)

def fold(nums, action, ops=ops):
"""Perform all possible ways of folding nums using ops.
Side-effect action triggered for every fold."""
nums = zip(nums, nums) # Attach a history to each number
def recfold():
for i in xrange(1, len(nums)):
a = nums.pop(i) # Take out a number;
for j in xrange(i):
b = nums[j] # choose another before it;
for x in ops(a, b): # combine them
nums[j] = x # into one;
action(x) # (with side-effect)
recfold() # then fold the shorter list.
nums[j] = b
nums.insert(i, a)
recfold()

def all_countdown(nums, target):
"Return all ways of reaching target with nums"
all = set()
def action(nh):
if nh[0] == target:
all.add(pretty(nh[1]))
fold(nums, action)
return all

def print_countdown(nums, target):
"Print all solutions"
print '\n'.join(all_countdown(nums, target))

class FoundSolution(Exception):
def __init__(self, sol):
self.sol = sol

def first_countdown(nums, target):
"Return one way of reaching target with nums"
def action(nh):
if nh[0] == target:
raise FoundSolution(nh[1])
try:
fold(nums, action)
except FoundSolution, fs:
return pretty(fs.sol)

# pretty helpers
def getop(h): return 'n' if isinstance(h, int) else h[1]
lbracket = ['+*', '-*', '+/', '-/', '/*', '//']
rbracket = ['*+', '*-', '/+', '/-', '/*', '//', '-+', '--']

def pretty(h):
"Print a readable form of a number's history"
if isinstance(h, int):
return str(h)
else:
x, op, y = h
x, y, xop, yop = pretty(x), pretty(y), getop(x), getop(y)
if xop + op in lbracket: x = "(%s)" % x
if op + yop in rbracket: y = "(%s)" % y
return ''.join((x, op, y))
 
T

Terry Jones

Hi Arnaud
I've tried a completely different approach, that I imagine as 'folding'. I
thought it would improve performance over my previous effort but extremely
limited and crude benchmarking seems to indicate disappointingly comparable
performance...

I wrote a stack-based version yesterday and it's also slow. It keeps track
of the stack computation and allows you to backtrack. I'll post it
sometime, but it's too slow for my liking and I need to put in some more
optimizations. I'm trying not to think about this problem.

What was wrong with the very fast(?) code you sent earlier?

Terry
 
D

dg.google.groups

Arnaud and Terry,

Great solutions both of you! Much nicer than mine. I particularly like
Arnaud's latest one based on folding because it's so neat and
conceptually simple. For me, it's the closest so far to my goal of the
most elegant solution.

So anyone got an answer to which set of numbers gives the most targets
from 100 onwards say (or from 0 onwards)? Is Python up to the task?

A thought on that last one. Two ways to improve speed. First of all,
you don't need to rerun from scratch for each target. Secondly, you
can try multiple different sets of numbers at the same time by passing
numpy arrays instead of single values (although you have to give up
the commutativity and division by zero optimisations).

Dan Goodman
 
A

Arnaud Delobelle

Hi Arnaud


I wrote a stack-based version yesterday and it's also slow. It keeps track
of the stack computation and allows you to backtrack. I'll post it
sometime, but it's too slow for my liking and I need to put in some more
optimizations. I'm trying not to think about this problem.

What was wrong with the very fast(?) code you sent earlier?

I thought it was a bit convoluted, wanted to try something I thought
had more potential. I think the problem with the second one is that I
repeat the same 'fold' too many times. I'll take a closer look at the
weekend.
 
A

Arnaud Delobelle

Arnaud and Terry,

Great solutions both of you! Much nicer than mine. I particularly like
Arnaud's latest one based on folding because it's so neat and
conceptually simple. For me, it's the closest so far to my goal of the
most elegant solution.

Thanks! It's a great little problem to think of and it helps bring
more fun to this list. Sadly work takes over fun during the week, but
I will try to improve it at the weekend.
So anyone got an answer to which set of numbers gives the most targets
from 100 onwards say (or from 0 onwards)? Is Python up to the task?

I bet it is :)
A thought on that last one. Two ways to improve speed. First of all,
you don't need to rerun from scratch for each target

Yes, I've been doing this by writing an 'action' (see my code) that
takes note of all reached results.
Secondly, you
can try multiple different sets of numbers at the same time by passing
numpy arrays instead of single values (although you have to give up
the commutativity and division by zero optimisations).

Have to think about this.
 
T

Terry Jones

Hi Arnaud and Dan

Arnaud> I thought it was a bit convoluted, wanted to try something I
Arnaud> thought had more potential. I think the problem with the second
Arnaud> one is that I repeat the same 'fold' too many times.

and later:

Arnaud> Yes, I've been doing this by writing an 'action' (see my code) that
Arnaud> takes note of all reached results.

These are both comments about pruning, if I understand you. In the first
you weren't pruning enough and in the second you're planning to prune more.

I'm giving up thinking about this problem because I've realized that the
pruning solution is fundamentally subjective. I.e., whether or not you
consider two solutions to be the "same" depends on how hard you're willing
to think about them (and argue that they're the same), or how smart you
are.

I have a new version that does some things nicely, but in trying to do
efficient pruning, I've realized that you can't satisfy everyone.

Some examples for the problem with target 253, numbers = 100, 9, 7, 6, 3, 1

Firstly, there are nice solutions that go way negative, like:

1 7 6 3 9 sub mul mul sub or 1 - 7 * 6 * (3 - 9)


Here's a pruning example. Are these the "same"?

1 3 7 100 9 sub sub mul sub or 1 - 3 * (7 - 100 - 9)
1 3 7 9 100 sub add mul sub or 1 - 3 * (7 - 9 - 100)

I think many people would argue that that's really the "same" and that one
of the two should not appear in the output. The same goes for your earlier
example for 406. It's 4 * 100 + 2 x 3, and 2 x 3 + 100 * 4, and so on.

My latest program does all these prunings.

But you can argue that you should push even further into eliminating things
that are the same. You could probably make a pretty fast program that
stores globally all the states it has passed through (what's on the stack,
what numbers are yet to be used, what's the proposed op) and never does
them again. But if you push that, you'll wind up saying that any two
solutions that look like this:

....... 1 add

e.g.

6 9 3 sub mul 7 mul 1 add or 6 * (9 - 3) * 7 + 1
7 6 mul 9 3 sub mul 1 add or 7 * 6 * (9 - 3) + 1

are the same. And if we go that far, then a really smart person might argue
that this

100 7 sub 9 sub 3 mul 1 add or (100 - 7 - 9) * 3 + 1

is also the "same" (because all these solutions get to 252 and then add 1,
so they're clearly the "same", right?):


Once you've gone that far, you might even argue that on a particular
problem of a particular difficulty (subjectively matching what your brain
is capable of) all solutions that start out by pushing 1 onto the stack are
actually the "same".

And if you're even smarter than that, you might just look at any problem
and say "Hey, that's easy! The answer is X" or "There's clearly no
solution" because you'd immediately just see the answer (if any) and that
all others were just obvious variants.

E.g., if I said to you: "make 20 out of (20, 10, 10, 3)", I imagine you
could immediately list the answer(s?)

20 = 20
20 = 10 + 10
20 = 20 + 10 - 10
20 = 20 - 10 + 10

etc., and explain that those are all really the same. You'd prune on the
spot, and consider it obvious that the pruning was fully justified.

But my 6-year-old son couldn't tell you that, and I doubt he'd agree with
your prunings.

OK, enough examples. I'm just trying to illustrate that the (difficult)
problem of efficiently pruning doesn't have an objective solution. Pruning
is subjective. OTOH, the decision problem "does this puzzle have any
solutions or not (and if so, produce one)" does.

That's why I'm going to stop working on this. My stack solution code is
fun, but working on making it prune better is a black hole. And to make
matters worse, the more I push it (i.e., the more advanced its prunings
get), the less likely (some) people are to agree that its output is still
correct. You can't win.

If anyone wants the stack simulation code, send me an email.

Terry
 
A

Arnaud Delobelle

Hi Arnaud and Dan

Hi Terry
Arnaud> I thought it was a bit convoluted, wanted to try something I
Arnaud> thought had more potential.  I think the problem with the second
Arnaud> one is that I repeat the same 'fold' too many times.

and later:

Arnaud> Yes, I've been doing this by writing an 'action' (see my code) that
Arnaud> takes note of all reached results.

These are both comments about pruning, if I understand you. In the first
you weren't pruning enough and in the second you're planning to prune more..

Yes I think the first one is about pruning, I couldn't think of the
english word.
I'm giving up thinking about this problem because I've realized that the
pruning solution is fundamentally subjective. I.e., whether or not you
consider two solutions to be the "same" depends on how hard you're willing
to think about them (and argue that they're the same), or how smart you
are.

FWIW, I have a clear idea of what the space of solutions is, and which
solutions I consider to be equivalent. I'll explain it below. I'm
not saying it's the right model, but it's the one within which I'm
thinking.
I have a new version that does some things nicely, but in trying to do
efficient pruning, I've realized that you can't satisfy everyone.

Some examples for the problem with target 253, numbers = 100, 9, 7, 6, 3, 1

Firstly, there are nice solutions that go way negative, like:

  1 7 6 3 9 sub mul mul sub   or   1 - 7 * 6 * (3 - 9)

I think it's best to forbid negatives. Solutions always be written
without any negative intermediate answer. E.g. 1-7*6*(3-9) can be
done as 1+7*6*(9-3).
Here's a pruning example. Are these the "same"?

  1 3 7 100 9 sub sub mul sub  or  1 - 3 * (7 - 100 - 9)

rather 1 - 3*(7 - (100 - 9))
  1 3 7 9 100 sub add mul sub  or  1 - 3 * (7 - 9 - 100)

rather 1 - 3*(7 + (9 - 100))

Not allowing negatives means these are
3*(100 - 9 - 7) + 1
or 3*(100 - (9 + 7)) + 1
or 3*(100 - 7 - 9) + 1

I don't consider these to be equivalent, because their equivalence
depends on understanding the meaning of subtraction and addition.
(I've also applied the 'big then small' rule explained below)
I think many people would argue that that's really the "same" and that one
of the two should not appear in the output. The same goes for your earlier
example for 406. It's 4 * 100 + 2 x 3, and 2 x 3 + 100 * 4, and so on.

There is a simple rule that goes a long way: "big then small", i.e.
My latest program does all these prunings.

But you can argue that you should push even further into eliminating things
that are the same. You could probably make a pretty fast program that
stores globally all the states it has passed through (what's on the stack,
what numbers are yet to be used, what's the proposed op) and never does
them again. But if you push that, you'll wind up saying that any two
solutions that look like this:

  ....... 1 add

e.g.

  6 9 3 sub mul 7 mul 1 add   or  6 * (9 - 3) * 7 + 1
  7 6 mul 9 3 sub mul 1 add   or  7 * 6 * (9 - 3) + 1

are the same.

I honestly think this is outside the scope of this problem, which must
remain simple. I'm not trying to convince you of anything, but I'll
just explain briefly what solutions I don't want to repeat.

I see a solution as a full ordered binary tree. Each node has a
weight (which is the result of the calculation it represents) and the
left child of a node has to be at least as heavy as the right child.
Each parent node can be labeled + - * or /.
If a node x has two children y and z and is labeled <op>, let me write
x = (y <op> z)

so 6 9 3 sub mul 7 mul 1 add -> (((6 * (9 - 3)) * 7) + 1)
and 7 6 mul 9 3 sub mul 1 add -> (((7 * 6) * (9 - 3)) + 1)

are two distinct solutions according to my criterion.

But
9 3 sub 6 mul 7 mul 1 add -> ((((9 - 3) * 6) * 7) + 1)

is not a solution because 9-3 < 6

To be perfectly honest (and expose my approach a little to your
argument) I added a three additional rules:

* Don't allow x - x
* Don't allow x * 1
* Don't allow x / 1

My aim was find and efficient way to generate all solutions exactly
once if there is no repeat in the list of starting numbers. This is a
clearly defined problem and I wanted to find a nice way of solving
it. I realised that the folding method was redundant in that respect
and this is what I wanted to fix (I have a fix, I think, but the
'proof' of it is only in my head and might be flawed).

If there are repeats in the list of starting numbers, I don't worry
about repeating solutions.
If anyone wants the stack simulation code, send me an email.

I'd like to see it :)
 
T

Terry Jones

Arnaud> FWIW, I have a clear idea of what the space of solutions is, and
Arnaud> which solutions I consider to be equivalent. I'll explain it
Arnaud> below. I'm not saying it's the right model, but it's the one
Arnaud> within which I'm thinking.

OK. This reinforces why I'm not going to work on it anymore, the solution
is subjective (once you start pruning).

Arnaud> I think it's best to forbid negatives. Solutions always be written
Arnaud> without any negative intermediate answer. E.g. 1-7*6*(3-9) can be
Arnaud> done as 1+7*6*(9-3).

That's a good optimization, and I think it's easy to prove that it's
correct supposing the target is positive and the inputs are all positive.

If you consider the intermediate results in a solution, then if you ever go
negative it's because of an operation X (must be a sub or a div) and when
you next become positive it's due to an operation Y (must be add or
mul). So you can "reflect" that part of the computation by doing the
opposite operations for that formerly sub-zero intermediate sequence.

Arnaud> I don't consider these to be equivalent, because their equivalence
Arnaud> depends on understanding the meaning of subtraction and addition.

Ha - you can't have it both ways Arnaud! You don't want the computation to
go negative... doesn't that (and my "proof") have something to do with the
inverse nature of add and sub? :)

Arnaud> (I've also applied the 'big then small' rule explained below)

And now you're taking advantage of your knowledge of > and < ...

My original code did this big then small canonicalization too.

That's my point exactly - pruning depends on who you are, how smart you
are, how hard you work, your personal taste, etc.

Arnaud> I see a solution as a full ordered binary tree. Each node has a
Arnaud> weight (which is the result of the calculation it represents) and
Arnaud> the left child of a node has to be at least as heavy as the right
Arnaud> child. Each parent node can be labeled + - * or /. If a node x
Arnaud> has two children y and z and is labeled <op>, let me write x = (y
Arnaud> <op> z)

Where does the sequence of numbers enter into this? You have a tree of
operations - is it acting on a stack? What's on the stack?

It sounds similar to what I've done. I walk up and down the tree, keeping
the stack and the stack history, doing operations (including pushing onto
the stack) and undoing them.

There are several more prunings I could be doing, but these require looking
further back in the stack. E.g., I force div before mul and sub before
add, and I also apply the "big then small" rule to the intermediate stack
results if there are series of identical operations (not just to a single
operation). E.g. X / Y / Z can be re-ordered so that Y >= Z, and A + B + C
can be reordered so A >= B >= C. Doing it on the stack results is different
(but similar) to doing it on the raw input numbers.

There are lots of other little and more complex things you can do to prune.

You want to prune early, of course. The stack model of computation make
this hard because it's always legitimate to push all the numbers onto the
stack, by which point you're already deep in the tree.

And this approach only lets you do local pruning - i.e., that affect the
branch of the tree you're on. If you stored the state of the stack, the
operation you're about to do, and the (sorted) numbers remaining to be
input, then if you ever hit that configuration elsewhere in the massive
tree, you could know that you'd been that way before. But are you justified
in pruning at that point? The identical state of the computation could have
been arrived at via a very different method.

But that's where the big speed-up in this approach is.

At this realization I decided to give up :)

Arnaud> To be perfectly honest (and expose my approach a little to your
Arnaud> argument) I added a three additional rules:

Arnaud> * Don't allow x - x
Arnaud> * Don't allow x * 1
Arnaud> * Don't allow x / 1

Yes, I do these too, including not allowing a zero intermediate (which is a
useless calculation that simply could not have been done - see, I have deep
knowledge of zero!).

Arnaud> If there are repeats in the list of starting numbers, I don't worry
Arnaud> about repeating solutions.

I handle most of those cases, but I didn't push all the way. With target 32
and input 8, 8, 8, 8 my code still gives 2 answers:

8 8 add 8 8 add add
8 8 add 8 add 8 add

Arnaud> I'd like to see it :)

I'll send it.

Terry
 
D

dg.google.groups

Well I tried the NumPy array thing that I was talking about, to
parallelise the problem, and there were some difficulties with it.
Firstly, the pruning makes a really big difference to the speed, and
you don't get that if you're trying to parallelise the problem because
what is an equivalent calculation for one set of numbers is obviously
not for another set. I think this problem disappears when you consider
very large sets of numbers though (where the cost of doing equivalent
computations vanishes in comparison to the alternative cost of
starting up the whole recursive computation from scratch many times).
The second problem is that you can't weed out division by zero and
intermediate fractions. I haven't looked at the internals of how NumPy
deals with these though, so this might be fixable or it might not.

For my part, I'd consider the following equivalences to be right for
defining equivalent expressions (where here a, b and c are any
subexpression, and 1 and 0 means any subexpression that evaluates to 1
and 0).

Commutativity:

a*b <-> b*a
a+b <-> b+a

Associativity:

(a+b)+c <-> a+(b+c)
(a+b)-c <-> a+(b-c)
(a-b)+c <-> a-(b-c)
(a-b)-c <-> a-(b+c)
(a*b)*c <-> a*(b*c)
(a*b)/c <-> a*(b/c)
(a/b)*c <-> a/(b/c)
(a/b)/c <-> a/(b*c)

Units (1 is multiplicative unit, 0 is additive unit):

a*1 <-> a
a/1 <-> a
a+0 <-> a
a-0 <-> a

Substitution (swapping equal starting numbers is equivalent):

expr(a,b,c,...,z) <-> expr(s(a,b,c,...,z)) where a,b,c,...,z are the
original numbers given and s is a permutation of (a,b,c...,z) so that
(a,b,c,...z) evaluates to the same thing as s(a,b,c,...,z)

or equivalently, expr1 <-> expr2 if str(expr1)==str(expr2)

Then, any two expressions which can be transformed into one another by
the equivalences above are equivalent. Commutativity and units can be
easily implemented as you go and most of the programs suggested so far
do this, by for example only allowing a*b or a+b if a>=b, and not
allowing a*1 or 1*a, etc. Substitution can be implemented by just
taking set(map(str,expressions)) at the end. The associativity ones
are a little more tricky to implement, but Arnaud's idea of the fully
ordered binary tree seems to do it. Another way of saying it is that
any subexpression consisting only of + and - operations should be
reduced to a+b+c+...-z-y-x-.... where a>b>c>... and z>y>x>... (and
similarly for an expression involving only * and /).

Terry, I'd also be interested in a copy of your stack simulation code,
btw.
 
A

Arnaud Delobelle

Arnaud> FWIW, I have a clear idea of what the space of solutions is, and
Arnaud> which solutions I consider to be equivalent.  I'll explain it
Arnaud> below.  I'm not saying it's the right model, but it's the one
Arnaud> within which I'm thinking.

OK. This reinforces why I'm not going to work on it anymore, the solution
is subjective (once you start pruning).

Arnaud> I think it's best to forbid negatives.  Solutions always be written
Arnaud> without any negative intermediate answer.  E.g. 1-7*6*(3-9) can be
Arnaud> done as 1+7*6*(9-3).

That's a good optimization, and I think it's easy to prove that it's
correct supposing the target is positive and the inputs are all positive.

If you consider the intermediate results in a solution, then if you ever go
negative it's because of an operation X (must be a sub or a div) and when
you next become positive it's due to an operation Y (must be add or
mul). So you can "reflect" that part of the computation by doing the
opposite operations for that formerly sub-zero intermediate sequence.

Arnaud> I don't consider these to be equivalent, because their equivalence
Arnaud> depends on understanding the meaning of subtraction and addition.

Ha - you can't have it both ways Arnaud! You don't want the computation to
go negative... doesn't that (and my "proof") have something to do with the
inverse nature of add and sub? :)

I think I can have it both ways, here's why: the "big then small" and
"no negatives" rules are applied indiscriminately by the algorithm:
it doesn't need to know about the history of operations in order to
make a decision depending on their nature. OTOH, identifying (a + b)
- c and a + (b - c) for example, requires inspection of the stack/tree/
calculation history. It is an *informed* decision made by the
algorithm
Arnaud> (I've also applied the 'big then small' rule explained below)

And now you're taking advantage of your knowledge of > and < ...

Again, *I* have the knowledge, the algorithm does it
indiscriminately...

[...]
Arnaud> To be perfectly honest (and expose my approach a little to your
Arnaud> argument) I added a three additional rules:

Arnaud> * Don't allow x - x
Arnaud> * Don't allow x * 1
Arnaud> * Don't allow x / 1

Yes, I do these too, including not allowing a zero intermediate (which is a
useless calculation that simply could not have been done - see, I have deep
knowledge of zero!).

Note that disallowing 0 and disallowing x - x are equivalent, as the
only way to get your first 0 is by doing x - x.

Thanks for the discussion, it's made me realise more clearly what I
was doing.
 
T

Terry Jones

Arnaud> I think I can have it both ways, here's why: the "big then small"
Arnaud> and "no negatives" rules are applied indiscriminately by the
Arnaud> algorithm: it doesn't need to know about the history of operations
Arnaud> in order to make a decision depending on their nature. OTOH,
Arnaud> identifying (a + b) - c and a + (b - c) for example, requires
Arnaud> inspection of the stack/tree/ calculation history. It is an
Arnaud> *informed* decision made by the algorithm

But this is having it both ways too :)

The reason the algorithm can do it indiscriminately is because *you* know
that it always applies, and *you* figure out and implement the algorithm
that way. The algorithm doesn't have a mind of its own, after all.

BTW, there are some very important issues here. One is about representation
(thinking about representation is one of my pet vices). When you try to
solve some real-world problem with a computer, you must choose a
representation. What many people seem to fail to recognize is that in so
doing you've already begun to solve the problem. If you pick a better
representation, your algorithm can potentially be much dumber and still be
hugely faster than a brilliant algorithm on a terrible representation.

In maintaining an A > B invariant in operations A + B and A * B, you're
using insight into the problem to influence representation. With that
better representation, your algorithm doesn't have to do so much work.

I wrote some more about this a while ago at

http://www.fluidinfo.com/terry/2007...tation-is-the-key-to-the-coming-semantic-web/

Arnaud> Note that disallowing 0 and disallowing x - x are equivalent, as
Arnaud> the only way to get your first 0 is by doing x - x.

That depends. I allow negative numbers in the input, and also 0 (I just
don't let you use it :))

Arnaud> Thanks for the discussion, it's made me realise more clearly what I
Arnaud> was doing.

Me too! Thanks.

Terry
 
A

Arnaud Delobelle

Right, I've cleaned up my efforts a bit:

* tried to improve the efficiency of the 'fold' technique that I
posted earlier.
* taken on Dan's email about equivalence of expressions
* created a problem random generator
* created a little function test to time and compare various things,
and a 'random test' function.

In the file there are two methods for combining two expressions: 'ops'
and 'cleverops'. Ops is vanilla, cleverops only returns canonical
expressions (roughly) as defined in Dan's email.

The two strategies for walking the solution tree are 'divide and
conquer' (divide) and 'origami' (fold). The code is pretty
straighforward and is commented!

Then there are some convenience functions for running the algorithms:
print_countdown, all_countdown and first_countdown.

Here's an example of how it works:
>>> # print all solutions using the divide method, and cleverops:
>>> print_countdown([50, 8, 5, 4, 9, 10], 983, method=divide,
ops=cleverops)
50*5*4-9-8
(50*5+8-10)*4-9
(50+8/4)*(10+9)-5
50*(10-5)*4-9-8
>>> # Test which method is fastest, divide or fold:
>>> randtest(10, 'method', method=[divide, fold], ops=cleverops)
divide fold
0.317304 0.366359 ([1, 2, 4, 3, 6, 7], 249)
0.495667 0.660426 ([75, 100, 50, 25, 9, 3], 289)
0.443912 0.562409 ([50, 8, 5, 4, 9, 10], 399)
0.199696 0.231997 ([100, 1, 5, 7, 1, 4], 833)
0.406256 0.527588 ([50, 25, 10, 9, 3, 8], 123)
0.263348 0.315722 ([9, 8, 7, 5, 1, 7], 730)
0.403028 0.517426 ([25, 75, 9, 4, 10, 6], 605)
0.420140 0.564138 ([10, 6, 10, 9, 5, 4], 900)
0.278489 0.343525 ([4, 10, 5, 9, 9, 1], 388)
0.485815 0.643627 ([100, 10, 2, 6, 3, 9], 146)
------------ ------------
0.371365 0.473322
>>> # Test which method is best for finding just one solution:
>>> randtest(10, 'method', method=[divide, fold],
countdown=first_countdown)
divide fold
0.001674 0.043920 ([50, 75, 25, 9, 5, 8], 333)
0.164332 0.072060 ([75, 2, 7, 8, 8, 5], 409)
0.028889 0.212317 ([50, 100, 75, 6, 3, 9], 782)
0.049070 0.005830 ([75, 4, 3, 2, 1, 6], 471)
0.014728 0.091845 ([100, 75, 25, 50, 8, 7], 483)
0.290982 0.367972 ([3, 1, 7, 6, 5, 3], 794)
0.240363 0.118508 ([50, 100, 75, 3, 1, 10], 537)
0.001693 0.009519 ([50, 75, 8, 7, 5, 5], 180)
0.000289 0.037539 ([3, 9, 2, 4, 4, 1], 123)
0.079161 0.174323 ([50, 75, 100, 25, 4, 10], 723)
------------ ------------
0.087118 0.113383
>>> # Test how cleverops improves over ops for the fold method:
>>> randtest(10, 'ops', method=fold, ops=[ops, cleverops])
ops cleverops
1.689920 0.671041 ([75, 9, 6, 10, 3, 9], 874)
0.938402 0.338120 ([5, 7, 8, 2, 1, 7], 258)
0.982800 0.333443 ([25, 50, 9, 4, 8, 1], 309)
1.152037 0.407845 ([25, 50, 3, 5, 10, 1], 736)
0.892541 0.323406 ([9, 7, 1, 9, 4, 10], 108)
1.794778 0.677161 ([25, 50, 10, 8, 2, 6], 357)
1.534185 0.591878 ([50, 100, 25, 7, 7, 3], 773)
1.013421 0.350179 ([50, 6, 3, 1, 8, 9], 761)
0.612838 0.228354 ([25, 1, 4, 3, 1, 4], 148)
1.213055 0.430611 ([50, 100, 5, 3, 10, 1], 814)
------------ ------------
1.182398 0.435204

I have found that the 'divide & conquer' strategy is faster than the
'origami' one. Moreover cleverops (i.e. clever pruning) improves
origami by much more than divide&conquer.

Code follows. Terry: I'm going to look at your code tonight and see
if I can interface it with my little testing suite. Thanks for
posting it!

It was a lot of fun thinking about this, although I am sure that there
is a lot of room for improvement. In particular, there must be a
simple way to avoid the amount of list tinkering in fold(). Any
feedback greatly appreciated.

--
Arnaud

====================== countdown.py =======================
def getop(h): return 'n' if isinstance(h, int) else h[1]

# An ops function takes two numbers with histories and yield all
suitable
# ways of combining them together.

def ops(a, b):
if a < b: a, b = b, a
x, hx = a
y, hy = b
yield x + y, (a, '+', b)
if x != 1 and y != 1:
yield x * y, (a, '*', b)
if x != y:
yield x - y, (a, '-', b)
if not x % y and y != 1:
yield x / y, (a, '/', b)

def cleverops(a, b, getop=getop):
if a < b: a, b = b, a
x, hx = a
y, hy = b
opx, opy = getop(hx), getop(hy)
# rx is the right operand of hx (or x if no history)
rx = x if opx == 'n' else hx[2][0]
if opy not in '+-':
# Only allow a+b+c-x-y-z if a >= b >= c...
if (opx == '+' and rx >= y) or (opx not in '+-' and x >= y):
yield x + y, (a, '+', b)
# ... and x >= y >= z
if x > y and (opx != '-' or rx >= y):
yield x - y, (a, '-', b)
if y != 1 and opy not in '*/':
# Only allow a*b*c/x/y/z if a >= b >= c...
if (opx == '*' and rx >= y) or (opx not in '*/' and x >= y):
yield x * y, (a, '*', b)
# ... and x >= y >= z
if not x % y and (opx != '/' or rx >= y):
yield x / y, (a, '/', b)

# a method function takes a list of numbers, an action, and and ops
# function. It should go through all ways of combining the numbers
# together (using ops) and apply action to each.

def fold(nums, action, ops=cleverops):
"Use the 'origami' approach"
nums = zip(nums, nums) # Attach a history to each number
def recfold(start=1):
for i in xrange(start, len(nums)):
a, ii = nums, i-1 # Pick a number;
for j in xrange(i):
b = nums.pop(j) # Take out another before it;
for x in ops(a, b): # combine them
nums[ii] = x # into one;
action(*x) # (with side-effect)
recfold(ii or 1) # then fold the shorter list.
nums.insert(j, b)
nums = a
recfold()

def divide(nums, action, ops=cleverops):
"Use the 'divide and conquer' approach"
def partitions(l):
"generate all 2-partitions of l"
for i in xrange(1, 2**len(l)-1, 2):
partition = [], []
for x in l:
i, r = divmod(i, 2)
partition[r].append(x)
yield partition
def recdiv(l):
if len(l) == 1: # if l in a singleton,
yield l[0] # we're done.
else:
for l1, l2 in partitions(l): # Divide l in two;
for a in recdiv(l1): # conquer the two
for b in recdiv(l2): # smaller lists;
for x in ops(a, b): # combine results
action(*x) # (with side-effect)
yield x # and yield answer.
for x in recdiv(zip(nums, nums)): pass

# Countdown functions

def all_countdown(nums, target, method=fold, ops=cleverops):
"Return all ways of reaching target with nums"
all = []
def action(n, h):
if n == target:
all.append(h)
method(nums, action, ops)
return all

def print_countdown(nums, target, method=fold, ops=cleverops):
"Print all solutions"
def action(n, h):
if n == target:
print pretty(h)
method(nums, action, ops)

class FoundSolution(Exception):
"Helper exception class for first_countdown"
def __init__(self, sol):
self.sol = sol

def first_countdown(nums, target, method=fold, ops=cleverops):
"Return one way of reaching target with nums"
def action(n, h):
if n == target:
raise FoundSolution(h)
try:
method(nums, action, ops)
except FoundSolution, fs:
return pretty(fs.sol)

# Pretty representation of a number's history

lbracket = ['+*', '-*', '+/', '-/', '/*']
rbracket = ['*+', '*-', '/+', '/-', '/*', '-+', '--']

def pretty(h):
"Print a readable form of a number's history"
if isinstance(h, int):
return str(h)
else:
x, op, y = h
x, y = x[1], y[1]
x, y, xop, yop = pretty(x), pretty(y), getop(x), getop(y)
if xop + op in lbracket: x = "(%s)" % x
if op + yop in rbracket: y = "(%s)" % y
return ''.join((x, op, y))

# This test function times a call to a countdown function, it allows
# comparisons between differents things (methods, ops, ...)

def test(enumkey=None, **kwargs):
from time import time
def do_countdown(countdown=all_countdown, target=758,
nums=[2, 4, 5, 8, 9, 25], **kwargs):
return countdown(nums, target, **kwargs)
enum = kwargs.pop(enumkey) if enumkey else ['time']
for val in enum:
if enumkey: kwargs[enumkey] = val
t0 = time()
do_countdown(**kwargs)
t1 = time()
yield t1-t0

# Tools for generating random countdown problems and doing random
# tests.

bignums, smallnums = [25, 50, 75, 100], range(1, 11)*2
from random import sample, randrange

def randnums(nbig=None):
if nbig is None:
nbig = randrange(5)
return sample(bignums, nbig) + sample(smallnums, 6-nbig)

def randtarget(): return randrange(100, 1000)

# Like test() but generates n tests with a new random problem to solve
# each time, and prints the results.

def randtest(n=1, enumkey=None, col=12, **kwargs):
if enumkey:
enums = kwargs[enumkey]
nenums = len(enums)
enums = [getattr(obj, '__name__', obj) for obj in enums]
print ' '.join("%*s" % (col, obj[:col]) for obj in enums)
else:
nenums = 1
acc = [0] * nenums
for i in xrange(n):
target, nums = randtarget(), randnums()
kwargs.update(target=target, nums=nums)
times = tuple(test(enumkey, **kwargs))
print ' '.join("%*f" % (col, t) for t in times),
print ' (%s, %s)' % (nums, target)
for i, t in enumerate(times): acc += t
if n > 1:
print ' '.join(['-'*col]*nenums)
print ' '.join("%*f" % (col, t/n) for t in acc)
 
D

david.hotham

I also had a go at this problem for a bit of python practice, about 6
months ago. I tried a few optimizations and my experience was that
with only 6 seeds, a hash table was very effective. So effective, in
fact, that it made all other optimizations more or less pointless.

Code below. Arguments are seeds first, then targets. Like this:

C:\utils>countdown.py 7 8 50 8 1 3 923
made target!
50 * 8 = 400
400 - 1 = 399
399 / 3 = 133
133 * 7 = 931
931 - 8 = 923

Took 0.421 seconds






from time import time
from bisect import insort
from sys import argv

#------------------------------------------------------------------------------
# Hash table is a global variable
#------------------------------------------------------------------------------
global hash_table

#------------------------------------------------------------------------------
# countdown.py
#
# Python script to solve the Countdown numbers game
#
# Remarks on optimization:
#
# - Without the hash table, the following all proved beneficial:
# - Keep a list of numbers used so far, and never create a number
that
# you've already used
# - Make sure only to return unique pairs from the generate_pairs
function
# - Don't ever perform two consecutive substractions
# - (Don't ever perform two consecutive divisions would also be
valid,
# though it's not something that happens a lot so the benefit is
small)
#
# - These tricks work by avoiding performing the same search more
than once
#
# - With only six seeds, it's possible to implement a perfect hash
table that
# remembers every list that we try to solve (and indeed this is
what the
# implementation here does)
#
# - With that hash table, the optimizations above become largely
redundant, so
# for the sake of simplicity I've removed them
#
# - Solving for larger numbers of seeds would require a smarter
approach, as
# it would soon become infeasible to maintain a complete hash
table. Then
# the tricks above might be useful again.
#
#------------------------------------------------------------------------------

#------------------------------------------------------------------------------
# Returns all useful combinations of two numbers, and a string
representing
# the operation used to get there.
#------------------------------------------------------------------------------
def generate_combinations(higher_number, lower_number):

#--------------------------------------------------------------------------
# Useful operations are:
# - addition (always)
# - subtraction (of the lower number from the higher number, so
long as
# they are not equal)
# - multiplication (so long as not multiplying by one)
# - division (if it's exact, and not by one)

#--------------------------------------------------------------------------
yield "+", higher_number + lower_number
if (higher_number != lower_number):
yield "-", higher_number - lower_number
if (lower_number != 1):
yield "*", higher_number * lower_number
if ((higher_number % lower_number) == 0):
yield "/", higher_number / lower_number

#------------------------------------------------------------------------------
# Returns all pairs from a list of seeds.
#
# Pairs always have the first number lower than or equal to the second
number,
# provided that the list is ordered on entry (as it should be).
#------------------------------------------------------------------------------
def generate_pairs(seeds):
for ii in xrange(len(seeds)):
for higher_num in seeds[ii+1:]:
yield seeds[ii], higher_num

#------------------------------------------------------------------------------
# Solves a seed list. Takes pairs, combines them, and recursively
calls
# solve_list again with the new shorter list.
#
# Seeds should be sorted on entry.
#------------------------------------------------------------------------------
def solve_list(seeds, target, depth, solution_so_far):

#--------------------------------------------------------------------------
# Loop through possible pairs.

#--------------------------------------------------------------------------
for lower_num, higher_num in generate_pairs(seeds):

#----------------------------------------------------------------------
# Make a copy of the list, and remove this pair.
#
# Taking a copy appears to be quicker than using the original
list and
# then reinstating the chosen pair later.

#----------------------------------------------------------------------
new_seeds = seeds[:]
new_seeds.remove(lower_num)
new_seeds.remove(higher_num)

#----------------------------------------------------------------------
# Try out all possible combinations of our pair.

#----------------------------------------------------------------------
for operation, combination in
generate_combinations(higher_num,

lower_num):

#------------------------------------------------------------------
# If we hit our target, we're happy.
#
# Else if the list has gotten too short already, move on.
#
# Else make a new, shorter, list containing our new value.
#
# If we've already tried to solve the new list, there's no
point in
# trying again.
#
# Else try to solve the shorter list.

#------------------------------------------------------------------
if combination == target:
print "made target!"
print "%s%d %s %d = %d\n" % (solution_so_far,
higher_num,
operation,
lower_num,
combination)
return(0)
elif (depth > 0):
insort(new_seeds, combination)
seeds_tuple = tuple(new_seeds)
if (seeds_tuple in hash_table):
pass
else:
hash_table[seeds_tuple] = 1
new_soln_so_far = ("%s%d %s %d = %d\n" %
(solution_so_far,

higher_num,

operation,

lower_num,

combination))
if (solve_list(new_seeds,
target,
depth - 1,
new_soln_so_far) == 0):

#------------------------------------------------------
# Success!

#------------------------------------------------------
return(0)

#--------------------------------------------------------------
# Remove the value that we made out of our number
pair, in
# preparation for the next try.

#--------------------------------------------------------------
new_seeds.remove(combination)

#--------------------------------------------------------------------------
# Didn't solve it.

#--------------------------------------------------------------------------
return(1)

#------------------------------------------------------------------------------
# OK, let's go. Get the puzzle, and solve it. The last argument is
the target
# and the others are the seeds.
#------------------------------------------------------------------------------
original_seeds = map(int, argv[1:-1])
target = int(argv[-1])
start_time = time()
failed = 1;
if target in original_seeds:
print "Target is amongst seeds!"
else:
original_seeds.sort()

#--------------------------------------------------------------------------
# First look for one-step solutions, then for two-step solutions,
etc.
# That way we always get a shortest solution first.

#--------------------------------------------------------------------------
for depth in xrange(len(original_seeds)):
hash_table = {}
failed = solve_list(original_seeds, target, depth, "")
if (failed == 0):
break
if (failed != 0):
print "No solution!"
print "Took %.3f seconds" % (time() - start_time)
 

Ask a Question

Want to reply to this thread or ask your own question?

You'll need to choose a username for the site, which only take a couple of moments. After that, you can post your question and our members will help you out.

Ask a Question

Members online

Forum statistics

Threads
473,982
Messages
2,570,190
Members
46,736
Latest member
zacharyharris

Latest Threads

Top