3

What the program should do is take steps and a number and than output you how many unique sequences there are with exactly x steps to create number.

Does someone know how I can save some memory - as I should make this work for pretty huge numbers within a 4 second limit.

def IsaacRule(steps, number):
    if number in IsaacRule.numbers:
        return 0
    else:
        IsaacRule.numbers.add(number)
    if steps == 0:
        return 1
    counter = 0
    if ((number - 1) / 3) % 2 == 1:
        counter += IsaacRule(steps-1, (number - 1) / 3)
    if (number * 2) % 2 == 0:
        counter += IsaacRule(steps-1, number*2)

    return counter

IsaacRule.numbers = set()

print(IsaacRule(6, 2))

If someone knows a version with memoization I would be thankful, right now it works, but there is still room for improvement.

Karl
  • 1,664
  • 2
  • 12
  • 19
Dominik Lemberger
  • 2,287
  • 2
  • 21
  • 33
  • 1
    You can speed it up a little with a memoization cache, eg [`functools.lru_cache`](https://docs.python.org/3/library/functools.html#functools.lru_cache). That will use _more_ memory though. And it doesn't save a lot of recursive calls. Eg with `IsaacRule(40, 1)` I get a count of 66588 and these cache stats: CacheInfo(hits=6057, misses=48335, maxsize=None, currsize=48335). But I guess 12% or so cache hits is better than nothing. – PM 2Ring Jul 26 '18 at 12:10
  • 3
    BTW, you should be using the floor division operator `//` for this. It won't hurt to use `/` in Python 2, but it makes a difference in Python 3. – PM 2Ring Jul 26 '18 at 12:11
  • 2
    I just noticed that your 2nd `if` is redundant: `(number * 2) % 2 == 0` is true for any integer. But I haven't closely analyzed your code's logic. Are you _sure_ it's doing the right thing? – PM 2Ring Jul 26 '18 at 12:17
  • haha nice thx PM 2Ring - removing the second if already made the deal for <= 53 steps and 2^(53-steps) numbers now its just about optimication :P Why should I use floor divition? ps: with floor divition ((number - 1) // 3) % 2 == 1 I get the wrong output 5 instead of 4 – Dominik Lemberger Jul 26 '18 at 12:33
  • In Python 2, using `/` on two integers does floor division, giving an integer result, but in Python 3 `/` always does float division. In both versions, `//` always does floor division, and using it on two integers gives an integer result. The Collatz conjecture is about integers, so you should only be using integer arithmetic. It's more efficient than using float arithmetic, and using float division instead of integer division can give inaccurate results. – PM 2Ring Jul 26 '18 at 13:24
  • 1
    We need to use float division in the condition check, though. As an example, 23 cannot be generated by the 3n+1 rule, but `((23 - 1) // 3) % 2 == 1`. Alternatively, this condition should be replaced with `number % 6 == 4` – c2huc2hu Jul 26 '18 at 14:09
  • @user3080953 yeah works and gave me a few extra seconds – Dominik Lemberger Jul 26 '18 at 14:18

1 Answers1

0

Baseline: IsaacRule(50, 2) takes 6.96s

0) Use the LRU Cache

This made the code take longer, and gave a different final result

1) Eliminate the if condition: (number * 2) % 2 == 0 to True

IsaacRule(50, 2) takes 0.679s. Thanks Pm2Ring for this one.

2) Simplify ((number - 1) / 3) % 2 == 1 to number % 6 == 4 and use floor division where possible:

IsaacRule(50, 2) takes 0.499s

Truth table:

| n | n-1 | (n-1)/3 | (n-1)/3 % 2 | ((n-1)/3)%2 == 1 |
|---|-----|---------|-------------|------------------|
| 1 | 0   | 0.00    | 0.00        | FALSE            |
| 2 | 1   | 0.33    | 0.33        | FALSE            |
| 3 | 2   | 0.67    | 0.67        | FALSE            |
| 4 | 3   | 1.00    | 1.00        | TRUE             |
| 5 | 4   | 1.33    | 1.33        | FALSE            |
| 6 | 5   | 1.67    | 1.67        | FALSE            |
| 7 | 6   | 2.00    | 0.00        | FALSE            |

Code:

def IsaacRule(steps, number):
    if number in IsaacRule.numbers:
        return 0
    else:
        IsaacRule.numbers.add(number)
    if steps == 0:
        return 1
    counter = 0
    if number % 6 == 4:
        counter += IsaacRule(steps-1, (number - 1) // 3)
    counter += IsaacRule(steps-1, number*2)

    return counter

3) Rewrite code using sets

IsaacRule(50, 2) takes 0.381s

This lets us take advantage of any optimizations made for sets. Basically I do a breadth first search here.

4) Break the cycle so we can skip keeping track of previous states.

IsaacRule(50, 2) takes 0.256s

We just need to add a check that number != 1 to break the only known cycle. This gives a speed up, but you need to add a special case if you start from 1. Thanks Paul for suggesting this!

START = 2
STEPS = 50

# Special case since we broke the cycle
if START == 1:
    START = 2
    STEPS -= 1

current_candidates = {START} # set of states that can be reached in `step` steps

for step in range(STEPS):
    # Get all states that can be reached from current_candidates
    next_candidates = set(number * 2 for number in current_candidates if number != 1) | set((number - 1) // 3 for number in current_candidates if number % 6 == 4)

    # Next step of BFS
    current_candidates = next_candidates
print(len(next_candidates))
c2huc2hu
  • 2,447
  • 17
  • 26
  • really nice, couldnt think of a way without recursion to do this even tho I don't quite understand it yet ... why is next_candidates = set(...) | set(...) choosing the right next number with * 2 or the other – Dominik Lemberger Jul 26 '18 at 15:04
  • 1
    It's doing the equivalent of: `for number in current_candidates: next_candidates.append(2 * number); if (... == 4) next_candidates.append(...)`. basically I'm trying to get all the possible successors of `current_candidates` – c2huc2hu Jul 26 '18 at 15:12
  • 1
    I don't think you need `seen_numbers` if you filter out `number != 1` in the first set comprehension. – Paul Hankin Jul 26 '18 at 15:28
  • @PaulHankin good point, thanks! Strictly speaking, that's only true if the Collatz conjecture is true :). We also need a special case for starting at 1, but it gives a nice speed-up – c2huc2hu Jul 26 '18 at 15:48
  • I found that `{n * 2 for n in c - {1}}` works better for larger number of steps than `set(number * 2 for number in current_candidates if number != 1)` . cheaper to generate a set with number and then remove 1 after just once instead of checking every potential number for equiality – Vlad Jul 26 '18 at 23:07
  • @Vlad that's clever, thanks :) I'll benchmark it tomorrow – c2huc2hu Jul 27 '18 at 01:28