Tagged: by: Colin Vout Toggle Comment Threads | Keyboard Shortcuts

  • Unknown's avatar

    Jim Randell 8:11 am on 25 January 2026 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3305: Things with scales and things to scale with 

    From The Sunday Times, 25th January 2026 [link] [link]

    Rummaging through a cupboard I came across an old Snakes and Ladders set. The ladders (label them A to E) went from square 4 to 26, 18 to 28, 19 to 73, 22 to 60, and 41 to 98. The snakes (label them W to Z) went from square 53 to 24, 58 to 5, 72 to 33, and 90 to 66. For old times’ sake I couldn’t resist playing a game on my own. Starting from square 1, rolling the die and moving appropriately, travelling travelling up ladders and down snakes when I chanced to land on their starting squares, I happened to finish exactly on square 100. The total of all the numbers I had thrown on the die was 51.

    What was the order of all my up and down jumps? (e.g. A, Y, D).

    [teaser3305]

     
    • Jim Randell's avatar

      Jim Randell 8:56 am on 25 January 2026 Permalink | Reply

      Here is a program that attempts to constructively play games that finish exactly on square 100 and hve a throw total of 51.

      We assume that once a possible game is found, all other possible games have the same sequence of jumps. (I’m thinking about better ways to show the answer to the puzzle is unique).

      It runs in 77ms. (Internal runtime is 1.1ms).

      from enigma import (tuples, seq2str, printf)
      
      # snakes and ladders
      jumps = {
        # ladders (lower -> higher)
        (4, 26): 'A', (18, 28): 'B', (19, 73): 'C', (22, 60): 'D', (41, 98): 'E',
        # snakes (higher -> lower)
        (53, 24): 'W', (58, 5): 'X', (72, 33): 'Y', (90, 66): 'Z',
      }
      
      # linked squares
      link = dict(jumps.keys())
      
      # possible throws
      throws = [6, 5, 4, 3, 2, 1]
      
      # play the game
      # p = current position
      # t = target position
      # b = throw budget (must not be exceeded)
      # ds = dice throws
      # ps = positions
      def play(p, t, b, ds=[], ps=[]):
        ps = ps + [p]
        # are we done
        if p == t or p >= 100 or b <= 0:
          yield (ds, ps, b)
        elif b > 0:
          # make the next roll
          for d in throws:
            if not (d > b):
              # move the player
              p1 = p + d
              # perform any jump
              p2 = link.get(p1)
              # continue play
              if p2 is None:
                yield from play(p1, t, b - d, ds + [d], ps)
              else:
                yield from play(p2, t, b - d, ds + [d], ps + [p1])
      
      # find a possible game
      for (ds, ps, r) in play(1, 100, 51):
        if r == 0 and ps[-1] == 100:
          printf("throws = {ds} (sum = {t}) -> positions = {ps}", t=sum(ds))
          # find the jumps involved
          js = tuple(filter(None, (jumps.get(k) for k in tuples(ps, 2))))
          printf("jumps = {js}", js=seq2str(js, enc="[]"))
          break
      

      Solution: [To Be Revealed]

      Like

      • Jim Randell's avatar

        Jim Randell 1:28 pm on 25 January 2026 Permalink | Reply

        Here is a better solution. It is shorter, faster, can be used to experiment with different throw budgets, and finds all solutions (thereby showing the answer to the original puzzle is unique):

        We don’t care about the actual dice throws, a game is made up of a sequence of contiguous blocks (that are moved with throws of the dice) with jumps (i.e. a snake or a ladder) between the blocks. The blocks themselves can usually be made by lots of dice throws (and the starts of the jumps are sufficiently fragmented that we can always avoid any unwanted jumps).

        This Python program makes a collection of possible contiguous segments, and then joins a sequence of them together with jumps inbetween to make a game that starts at 1, ends at 100, and requires a total of 51 to be scored on the dice throws during the segments.

        It runs in 71ms. (Internal runtime is 88µs).

        from enigma import (defaultdict, tuples, seq2str, printf)
        
        # snakes and ladders
        jumps = {
          # ladders (lower -> higher)
          (4, 26): 'A', (18, 28): 'B', (19, 73): 'C', (22, 60): 'D', (41, 98): 'E',
          # snakes (higher -> lower)
          (53, 24): 'W', (58, 5): 'X', (72, 33): 'Y', (90, 66): 'Z',
        }
        
        # linked squares
        link = dict(jumps.keys())
        
        # possible contiguous segments; segs: start -> ends
        segs = defaultdict(list)
        seg = lambda x, y: segs[x].append(y)
        seg(1, 100)  # no jumps
        for (a, b) in jumps.keys():
          # add in 1 to the start of a jump
          seg(1, a)
          # add in end of a jump to 100
          seg(b, 100)
          # add in end of a jump to start of a jump
          for (x, y) in jumps.keys():
            if b < x: seg(b, x)
        
        # find contiguous segments from <s> to <t> with throw budget <b>
        def solve(s, t, b, ss=[]):
          # are we done?
          if s == t and b == 0:
            yield ss
          elif s < 100 and b > 0:
            # add in the next segment
            for x in segs[s]:
              d = x - s
              if not (d > b):
                yield from solve(link.get(x, x), t, b - d, ss + [(s, x)])
        
        # find games from 1 to 100 with a throw budget of B
        B = 51
        for ss in solve(1, 100, B):
          # find jumps used
          js = tuple(jumps[(b, x)] for ((a, b), (x, y)) in tuples(ss, 2))
          printf("{B}: {ss} -> {js}", js=seq2str(js, enc="[]"))
        

        Like

  • Unknown's avatar

    Jim Randell 6:17 am on 23 November 2025 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3296: Woolly jumpers 

    From The Sunday Times, 23rd November 2025 [link] [link]

    Having racked my brains all day trying to devise a suitable Teaser, I woke with my mind racing, just after 4am according to my digital clock. I couldn’t get back to sleep, so I decided to try counting sheep. I imagined them to be numbered starting with the figures shown on the clock, then counting upwards. Each sheep jumped through a particular gap in a fence according to the number of prime factors of its number. Repetitions were counted, so that sheep 405 (= 3×3×3×3×5) jumped through the fifth gap.

    The last thing I remember noticing before eventually falling asleep was that, for the first time, five consecutive sheep had jumped through the same gap.

    What was the number of the last of these five sheep?

    [teaser3296]

     
    • Jim Randell's avatar

      Jim Randell 6:27 am on 23 November 2025 Permalink | Reply

      The function that counts the number of prime divisors (including repeats) of a number n is known as the “big omega” function = 𝛀(n) [@wikipedia].

      This Python program organises the numbers from 401 upwards into clumps with the same number of prime factors, and stops when it finds the first clump with (at least) 5 elements. Short and sweet (and fast)!

      It runs in 69ms. (Internal runtime is 399µs).

      from enigma import (irange, inf, prime_factors, clump, icount, printf)
      
      # count the number of prime factors in <n> (including repeats)
      n_prime_factors = lambda n: icount(prime_factors(n))
      
      # clump numbers together that have the same number of prime factors
      for ns in clump(irange(401, inf), n_prime_factors):
        k = len(ns)
        if k >= 5:
          printf("{k} -> {ns}; last = {n}", n=ns[4])
          break
      

      Solution: The number of the fifth sheep was 606.

      601 and 607 are prime, but between them each of 602 – 606 have exactly 3 prime factors:

      602 = 2 × 7 × 43
      603 = 3 × 3 × 67
      604 = 2 × 2 × 151
      605 = 5 × 11 × 11
      606 = 2 × 3 × 101

      Liked by 1 person

      • Tony Smith's avatar

        Tony Smith 10:47 am on 30 November 2025 Permalink | Reply

        All that is necessary for 601 and 607 is that 601 does not have the same number of prime factors as 602-606.

        Like

    • Frits's avatar

      Frits 6:58 am on 23 November 2025 Permalink | Reply

      def n_prime_factors(n):
        i = 2
        t = 0
        while i * i <= n:
          if n % i:
            i += 1
          else:
            n //= i
            t += 1
        return t + 1 if n > 1 else t
      
      p, t = -1, 0
      for hh in range(4, 24):
        h100 = 100 * hh
        for mm in range(1 if hh == 4 else 0, 60):
          if (n := n_prime_factors(h100 + mm)) == p:
            t += 1
            # five consecutive sheep had jumped through the same gap?
            if t == 5:
              print("answer:", h100 + mm)
              break
          else:
            t = 1
          p = n  
        else: # no break
          continue
        break  
      

      Like

      • Jim Randell's avatar

        Jim Randell 8:34 am on 23 November 2025 Permalink | Reply

        @Frits: The puzzle text says that the starting number comes from the clock. Not that all numbers come from the clock. So I don’t think it is right to skip 460 .. 499 etc.

        Like

        • Frits's avatar

          Frits 10:44 am on 23 November 2025 Permalink | Reply

          @Jim, you are right.

          Here is a program with fewer calls to n_prime_factors().

          def n_prime_factors(n):
            i = 2
            t = 0
            while i * i <= n:
              if n % i:
                i += 1
              else:
                n //= i
                t += 1
            return t + 1 if n > 1 else t
          
          '''
          .   n-3
          A   n-2
          .   n-1
          B   n
          .   n+1 
          C   n+2
          .
          '''
          
          p = -1
          n = 402
          while True:
            if (npf := n_prime_factors(n)) == p:
              # five consecutive sheep had jumped through the same gap?
              # numbers n-1 and n+1 must have the same number of prime factors
              if all(n_prime_factors(x) == p for x in [n - 1, n + 1]):
                if n_prime_factors(n - 3) == p:
                  print("answer:", n + 1)
                  break
                elif n_prime_factors(n + 2) == p:
                  print("answer:", n + 2)
                  break
            p = npf  
            n += 2
          

          Like

    • Ruud's avatar

      Ruud 7:52 am on 23 November 2025 Permalink | Reply

      import peek
      from sympy import factorint
      
      
      def count_prime_factors(n: int) -> int:
          factors = factorint(n)
          return sum(factors.values())
      
      
      def next_time(t, n):
          return t + n + (((t + n) % 100) > 59) * 40
      
      
      t = 400
      while True:
          gaps = {count_prime_factors(next_time(t, i)) for i in range(5)}
          if len(gaps) == 1:
              peek(next_time(t, 4))
              break
          t = next_time(t, 1)
      

      Like

    • Ruud's avatar

      Ruud 1:28 pm on 23 November 2025 Permalink | Reply

      I think @Jim Randell is right that the numbers are nit the clock times, which I had assumed as well.
      That makes the code much simpler and can even be done as a one liner):

      import sympy
      import itertools
      
      print(next(t + 4 for t in itertools.count(400) if len({sum(sympy.factorint(t).values()) for t in range(t, t + 5)}) == 1))
      

      Like

  • Unknown's avatar

    Jim Randell 6:29 am on 12 October 2025 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3290: Omnibus additions 

    From The Sunday Times, 12th October 2025 [link] [link]

    In our town centre the scheduled times taken for stages between bus stops are 5, 8 or 9 minutes. The only possible stages (in either direction) are between the following stops:

    Market and Castle;
    Market and Park;
    Market and Hospital;
    Station and Castle;
    Station and University;
    Castle and Park;
    University and Park;
    Park and Hospital.

    There are the following routes (with total time given):

    Route 1: from Market to Castle; 5 stages; 29 minutes.
    Route 2: from University to Market; 19 minutes.
    Route 3: from University to Hospital; 4 stages; 31 minutes.
    Route 4: from University to Station; 27 minutes.
    Route 5: from Market to University; 4 stages; 24 minutes.

    No route visits a stop more than once.

    What are the stage times (in order) for Route 1, and Route 4?

    [teaser3290]

     
    • Jim Randell's avatar

      Jim Randell 7:49 am on 12 October 2025 Permalink | Reply

      The lengths of routes 2 and 4 are not given, but their times are, and there are only certain combinations of 5, 8, 9 minutes that allow a given time to be achieved.

      This Python 3 program runs in 72ms. (Internal runtime is 1.5ms).

      from enigma import (express, cproduct, update, unpack, printf)
      
      # adjacent stops
      adj = dict(C="MPS", H="MP", M="CHP", P="CHMU", S="CU", U="PS")
      
      # possible stage times
      stages = [5, 8, 9]
      
      # find a n-length path from src to tgt
      def path(n, src, tgt, vs=list()):
        # are we done
        if n == 1:
          if tgt in adj[src]:
            yield vs + [src + tgt]
        elif n > 1:
          # move from src (but not directly to tgt)
          for v in adj[src]:
            if v != tgt and not any(v in p for p in vs):
              yield from path(n - 1, v, tgt, vs + [src + v])
      
      key = unpack(lambda x, y: (x + y if x < y else y + x))
      
      # allocate times for each stage to sum to t
      def times(t, vs, d):
        # are we done?
        if not vs:
          if t == 0:
            yield d
        else:
          # next stage
          k = key(vs[0])
          x = d.get(k)
          # is the time already chosen?
          if x is not None:
            if not (x > t):
              yield from times(t - x, vs[1:], d)
          else:
            # choose a time for this stage
            for x in stages:
              if not (x > t):
                yield from times(t - x, vs[1:], update(d, [(k, x)]))
      
      # solve a set of routes
      def solve(ks, rs, ns, ts, d=dict(), ps=dict()):
        # are we done?
        if not ks:
          yield (d, ps)
        else:
          # choose a remaining route
          k = ks[0]
          # find a path
          ((src, tgt), n) = (rs[k], ns[k])
          for vs in path(n, src, tgt):
            # allocate times
            for d_ in times(ts[k], vs, d):
              yield from solve(ks[1:], rs, ns, ts, d_, update(ps, [(k, vs)]))
      
      # keys; routes; lengths; times
      ks = [1, 2, 3, 4, 5]
      rs = { 1: "MC", 2: "UM", 3: "UH", 4: "US", 5: "MU" }
      ns = { 1: 5, 3: 4, 5: 4 }  # lengths for routes 2 and 4 are not given
      ts = { 1: 29, 2: 19, 3: 31, 4: 27, 5: 24 }
      
      # possible missing lengths (routes 2 and 4)
      (n2s, n4s) = (set(sum(qs) for qs in express(ts[k], stages)) for k in [2, 4])
      
      # choose lengths for routes 2 and 4
      for (n2, n4) in cproduct([n2s, n4s]):
        ns_ = update(ns, [(2, n2), (4, n4)])
        for (d, ps) in solve(ks, rs, ns_, ts):
          # output routes
          for k in ks:
            printf("Route {k}: {n} stages; {t} min", n=ns_[k], t=ts[k])
            for (src, tgt) in ps[k]:
              printf("  {src} -> {tgt} [{x} min]", x=d[key((src, tgt))])
          printf()
      

      Solution: Stage times (in minutes) are: Route 1 = (5, 5, 9, 5, 5); Route 4 = (9, 5, 8, 5).

      The complete solution is:

      Route 1: 5 stages; 29 min
      M → H [5 min]
      H → P [5 min]
      P → U [9 min]
      U → S [5 min]
      S → C [5 min]

      Route 2: 3 stages; 19 min
      U → P [9 min]
      P → H [5 min]
      H → M [5 min]

      Route 3: 4 stages; 31 min
      U → P [9 min]
      P → C [9 min]
      C → M [8 min]
      M → H [5 min]

      Route 4: 4 stages; 27 min
      U → P [9 min]
      P → M [5 min]
      M → C [8 min]
      C → S [5 min]

      Route 5: 4 stages; 24 min
      M → P [5 min]
      P → C [9 min]
      C → S [5 min]
      S → U [5 min]

      Like

    • Frits's avatar

      Frits 1:45 pm on 12 October 2025 Permalink | Reply

      from math import ceil
      from itertools import product, permutations
      
      # sorted tuple
      stup = lambda tup: tup if tup[0] < tup[1] else tup[::-1]
      
      # make total <t> with <k> elements taken from <m> numbers in list <ns> 
      def decompose_(rest, m, t, k, ns, ss=[]):
        if m == 2:
          # example:
          # 5.A + 8.B + 9.C = t and A + B + C = k with t=29 and k=5
          # (8 - 5).B + (9 - 5).C = t - 5.k
          ssrev = ss[::-1]
          # calculate 2nd quantity
          q2, r = divmod(t - ns[0] * k - sum([(x - ns[0]) * y  
                         for x, y in zip(ns[2:], ssrev)]), ns[1] - ns[0]) 
          if not r and q2 >= 0:
            if (q1 := k - q2 - sum(ss)) >= 0:
              yield [a for g in [y * [x] for x, y in zip(ns, [q1, q2] + ssrev)] 
                     for a in g] 
        else:
          n = ns[m - 1]
          for amt in range(min(n, k - sum(ss), rest // n) + 1):
            yield from decompose_(rest - amt * n, m - 1, t, k, ns, ss + [amt])  
       
      # make total <t> with <k> numbers from list <ns>      
      def decompose(t, k, ns):
        yield from decompose_(t, len(ns), t, k, ns) 
         
      # get path from <fr> to <to> in <k> steps visiting different towns
      def path(fr, to, k, ss=[]):
        if k == 0:
          if ss[-1] == to:
            yield ss
        else:
          for town in d[fr]:
            if town not in ss: 
              yield from path(town, to, k - 1, ss + [town])
      
      # solve route <n>, not contradicting settings in dictionary <d>
      def solve(n, d=dict(), ss=[]):
        if n < 0: 
          yield ss[::-1]
        else:  
          (fr, to), dur = routes[n], duration[n]
          rng = ([n_stops[n]] if n_stops[n] else 
                  range(ceil(dur / times[-1]), dur // times[0] + 1)) 
          # make the route in <k> stages
          for k in rng:
            # combine possible times with possible paths
            for ts, ps in product(decompose(duration[n], k, times), 
                                  path(fr, to, k, [fr])):
              # permutations of times  
              for ts in set(permutations(ts)):
                # assign times <ts> to paths in <ps>
                d_ = dict(s := tuple(zip([stup(x) for x in zip(ps, ps[1:])], ts))) 
                # is path, time already in dictionary?
                if all((k_ not in d or d[k_] == v_) for k_, v_ in d_.items()): 
                  yield from solve(n - 1, d | d_, ss + [s])  
                
      times    = [5, 8, 9]
      stages   = "MC MP MH SC SU CP UP PH".split()
      towns    = {y for x in stages for y in x}
      routes   = "MC UM UH US MU".split()
      n_stops  = [5, 0, 4, 0, 4]
      duration = [29, 19, 31, 27, 24]
      
      # dictionary of visitable towns
      d = {t: [s[1 - s.index(t)] for s in stages if t in s] for t in towns}
      
      # solve all 5 routes
      for s in solve(len(routes) - 1):
        print("answer:", [y for x, y in s[0]], "and", [y for x, y in s[3]])
      

      Like

    • Frits's avatar

      Frits 4:25 am on 13 October 2025 Permalink | Reply

      We probably should assume that the scheduled time from A to B is the same as the scheduled time from B to A. This is not always true for towns in the mountains/hilly terrain.

      Like

      • Jim Randell's avatar

        Jim Randell 8:39 am on 13 October 2025 Permalink | Reply

        @Frits: Yes. I think we need to assume the times are the same in both directions to get a single solution.

        I think changing the [[ key ]] function in my code to just return [[ x + y ]] shows this.

        Like

    • Ruud's avatar

      Ruud 3:12 pm on 13 October 2025 Permalink | Reply

      Brute force, but still instantaneous:

      import peek
      import collections
      import itertools
      
      
      def route(stop0, stop1, nstops, called=None):
          if called is None:
              called = [stop0]
          if nstops in (0, -1):
              if called and called[-1] == stop1:
                  yield called
              else:
                  if nstops == 0:
                      return
          for stop in next_stop[stop0]:
              if stop not in called:
                  yield from route(stop, stop1, (-1 if nstops == -1 else nstops - 1), called + [stop])
      
      
      stages = "mc mp mh sc su cp up ph".split()
      
      next_stop = collections.defaultdict(list)
      for stage in stages:
          stop0, stop1 = tuple(stage)
          next_stop[stop0].append(stop1)
          next_stop[stop1].append(stop0)
      
      
      routes = {1: list(route("m", "c", 5)), 2: list(route("u", "m", -1)), 3: list(route("u", "h", 4)), 4: list(route("u", "s", -1)), 5: list(route("m", "u", 4))}
      route_durations = {1: 29, 2: 19, 3: 31, 4: 27, 5: 24}
      
      for x in itertools.product((5,8,9), repeat=len(stages)):
          stage_duration = dict(zip(stages, x))
          found_route = {}
          for i in range(1, 6):
              for route in routes[i]:
                  if sum(stage_duration.get(stop0 + stop1, 0) + stage_duration.get(stop1 + stop0, 0) for stop0, stop1 in zip(route, route[1:])) == route_durations[i]:
                      found_route[i] = route
      
          if len(found_route) == 5:
              peek(found_route)
      
      

      Like

      • Frits's avatar

        Frits 1:31 pm on 15 October 2025 Permalink | Reply

        Hi Ruud,

        You don’t answer the question but that is an easy fix.
        The program is even more instantaneous with the following line after line 38:

        if i not in found_route: break 
        

        Like

    • Frits's avatar

      Frits 5:22 am on 16 October 2025 Permalink | Reply

      Only permuting times for unknown stages.

      from math import ceil
      from itertools import product, permutations
      
      # sorted key of a stage
      key = lambda tup: tup[0] + tup[1] if tup[0] < tup[1] else tup[1] + tup[0]
      
      # t_ and k_ are being decremented, t and k remain the same
      def decompose_(t_, k_, t, k, ns, ns_0, ss=[]):
        if k_ == 2:
          # example:
          # 5.A + 8.B + 9.C = t and A + B + C = k with t=29 and k=5
          # (8 - 5).B = t - 5.k - (9 - 5).C
          # calculate 2nd quantity
          q2, r = divmod(t - ns[0] * k - sum([x * y  for x, y in zip(ns_0, ss)]), 
                         ns[1] - ns[0]) 
          if not r and q2 >= 0:
            # calculate 1st quantity
            if (q1 := k - q2 - sum(ss)) >= 0:
              yield tuple(a for g in [y * [x] for x, y in zip(ns, [q1, q2] + ss)] 
                          for a in g) 
        else:
          n = ns[k_ - 1]
          for x in range(min(n, k - sum(ss), t_ // n) + 1):
            yield from decompose_(t_ - x * n, k_ - 1, t, k, ns, ns_0, [x] + ss)  
       
      # make total <t> with <k> numbers from list <ns>      
      def decompose(t, k, ns):
        ns_0 = [x - ns[0] for x in ns[2:]]
        yield from decompose_(t, len(ns), t, k, ns, ns_0) 
             
      # get paths from <fr> to <to> in <k> steps visiting different stops
      def paths(fr, to, k, ss):
        if k == 1:
          if to in d[fr]:
            ss = ss[1:] + [to]
            yield tuple(key(x) for x in zip(ss, ss[1:]))
        else:
          for stop in d[fr]:
            if stop not in ss: 
              yield from paths(stop, to, k - 1, ss + [stop])
      
      # remove elements of 2nd list from 1st list
      def list_diff(li1, li2):
        for x in li2:
          if x in li1:
            li1.remove(x)
      
      # solve route <n>, not contradicting settings in dictionary <d>
      def solve(n, d=dict(), ss=[]):
        if n < 0: 
          yield ss[::-1]
        else:  
          (fr, to), dur = routes[n], duration[n]
          rng = ([n_stops[n]] if n_stops[n] else 
                  range(ceil(dur / times[-1]), dur // times[0] + 1)) 
          # make the route in <k> stages
          for k in rng:
            # combine possible times with possible paths
            for ts, pth in product(decompose(duration[n], k, times), 
                                  paths(fr, to, k, [to, fr])):
              # determine times not yet used for this path (new_ts is updated)                     
              list_diff(new_ts := list(ts), pth_ := [d[x] for x in pth if x in d]) 
              if new_ts == []:
                yield from solve(n - 1, d, ss + 
                                 [tuple((k, v) for k, v in d.items() if k in pth)])  
              else:         
                # number of new times must equal number of new stages
                if len(new_ts) != k - len(pth_): continue
                # permutations of new times  
                for new_ts in set(permutations(new_ts)):
                  d_, ts_ = dict(), []
                  for st in pth:
                    if st in d: 
                      ts_.append(d[st])
                    else:  
                      # assign new time to stage <st>
                      d_[st] = new_ts[0]
                      ts_.append(new_ts[0])
                      new_ts = new_ts[1:]
                  yield from solve(n - 1, d | d_, ss + [tuple(zip(pth, ts_))])
                
      times    = [5, 8, 9]
      stages   = "MC MP MH SC SU CP UP PH".split()
      stops    = {y for x in stages for y in x}
      routes   = "MC UM UH US MU".split()
      n_stops  = [5, 0, 4, 0, 4]
      duration = [29, 19, 31, 27, 24]
      
      # dictionary of visitable stops
      d = {stp: {s[1 - s.index(stp)] for s in stages if stp in s} for stp in stops}
      
      # solve all 5 routes
      for s in solve(len(routes) - 1):
        print("answer:", [y for x, y in s[0]], "and", [y for x, y in s[3]])    
      

      Like

  • Unknown's avatar

    Jim Randell 6:30 am on 17 August 2025 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3282: Not necessarily in the right order 

    From The Sunday Times, 17th August 2025 [link] [link]

    For one composition, Skaredahora labelled the 12 notes in a standard octave from O to Z in some order, repeating cyclically. Each chord comprised four notes and had the same interval pattern: this meant the successive intervals between notes, when starting at the lowest note, ascending, and finishing with the addition of a note an octave above the lowest. The intervals all exceeded 1 and never increased. For instance, if the notes in an octave in ascending order were OQSYTVWXRPZU, then a chord of QWXZ would have an interval pattern of 5,1,3,3, which would fail for two reasons (an interval of 1 and an increase from 1 to 3).

    His chords (with notes in random order) were QPWR, WVQO, RXQS, VYRO, STUP and UVYW.

    Starting from O, what are the notes of the octave in ascending order?

    [teaser3282]

     
    • Jim Randell's avatar

      Jim Randell 7:26 am on 17 August 2025 Permalink | Reply

      The following Python program uses the [[ SubstitutedExpression ]] solver from the enigma.py library to assign values to the notes, such that each chord consists of non-decreasing intervals. And we then look for solutions where the chords share a common interval pattern.(This structure came about because initially I didn’t realise all the patterns had to be the same. So when I got multiple solutions, I re-read the puzzle, and then added a test at the end to make sure the chords found had the same pattern).

      It runs in 103ms. (Internal runtime is 32ms).

      from enigma import (
        SubstitutedExpression, tuples, is_decreasing, rotate, repeat,
        seq_all_same_r, sprintf, join, map2str, cache, printf
      )
      
      # calculate a valid pattern for a chord
      @cache
      def pattern(ns):
        # put the notes in ascending order, and add in the octave note
        ns = sorted(ns)
        ns.append(ns[0] + 12)
        # calculate the differences between adjacent notes
        ds = tuple(y - x for (x, y) in tuples(ns, 2))
        if 1 in ds: return
        # look for an order that gives non-increasing differences
        for xs in repeat(rotate, ds, len(ds) - 1):
          if is_decreasing(xs, strict=0):
            return xs
      
      # check a valid interval can be constructed
      check = lambda *ns: pattern(ns) is not None
      
      # chords to check
      chords = ["QPWR", "WVQO", "RXQS", "VYRO", "STUP", "UVYW"]
      
      # construct a solver for the puzzle
      exprs = list(sprintf("check({ns})", ns=join(crd, sep=", ")) for crd in chords)
      
      # assign notes O - Z values from 0 - 11 (with O = 0)
      p = SubstitutedExpression(
        exprs, base=12, symbols="OPQRSTUVWXYZ",
        env=dict(check=check), s2d=dict(O=0),
      )
      
      # find solutions with valid patterns
      for s in p.solve(verbose=0):
        # look for chords with a common pattern
        r = seq_all_same_r(pattern(tuple(s[x] for x in crd)) for crd in chords)
        if r.same:
          # output solution (notes and pattern)
          printf("{s} -> {r.value}", s=map2str(s))
      

      Solution: The notes in ascending order are: O P Y Q Z W S V X U R T.

      And so the chords all have an interval pattern of (5, 3, 2, 2). Here they are with the notes in ascending order:

      W R P Q
      V O Q W
      R Q S X
      Y V R O
      P S U T
      U Y W V

      Like

    • GeoffR's avatar

      GeoffR 11:36 am on 25 September 2025 Permalink | Reply

      AI code seems to have progressed significantly in the last few months, in my experience of testing it with teasers. Here is a solution, with all the code being generated by Chat GPT5, except the timer code at the beginning and end of the code from the enigma.py library.

      
      
      from enigma import Timer
      timer = Timer('ST3282', timer='E')
      timer.start()
      
      from itertools import permutations
      
      letters = ["O","P","Q","R","S","T","U","V","W","X","Y","Z"]
      chords = ["QPWR","WVQO","RXQS","VYRO","STUP","UVYW"]
      
      # All non-increasing 4-part partitions of 12 with parts >= 2
      patterns = [
          (6,2,2,2),
          (5,3,2,2),
          (4,4,2,2),
          (4,3,3,2),
          (3,3,3,3),
      ]
      
      def solve():
          for pat in patterns:
              a,b,c,d = pat
              offsets = [0, a, a+b, a+b+c]  # positions of the
              # 4 notes relative to chord's lowest note
      
              # build placements for each chord: list of dicts
              # letter -> absolute position (0..11)
              placements_by_chord = {}
              
              for ch in chords:
                  placements = []
                  seen = set()  # dedupe identical mappings
                  for perm in permutations(ch):
                      if "O" in perm:
                          # If 'O' is in this permutation it forces
                          # the base p so that O maps to 0
                          idxO = perm.index("O")
                          p = (-offsets[idxO]) % 12
                          mapping = {perm[i]: (p + offsets[i]) % 12 \
                                     for i in range(4)}
                          key = tuple(sorted(mapping.items()))
                          if key not in seen:
                              seen.add(key)
                              placements.append(mapping)
                      else:
                          # O not in chord: try all base positions
                          for p in range(12):
                              mapping = {perm[i]: (p + offsets[i]) \
                                         % 12 for i in range(4)}
                              key = tuple(sorted(mapping.items()))
                              if key not in seen:
                                  seen.add(key)
                                  placements.append(mapping)
                  placements_by_chord[ch] = placements
      
              # order chords by fewest placements to improve pruning
              chord_list = sorted(chords, \
                                  key=lambda c: len(placements_by_chord[c]))
      
              # anchor O at position 0
              assignment = {"O": 0}
              used_pos = {0}
      
              def backtrack(i):
                  if i == len(chord_list):
                      return dict(assignment)
                  ch = chord_list[i]
                  for mapping in placements_by_chord[ch]:
                      conflict = False
                      # quick conflict tests
                      for L, pos in mapping.items():
                          if L in assignment and assignment[L] != pos:
                              conflict = True;
                              break
                          if L not in assignment and pos in used_pos:
                              conflict = True;
                              break
                      if conflict:
                          continue
                      # apply
                      added = []
                      for L, pos in mapping.items():
                          if L not in assignment:
                              assignment[L] = pos
                              used_pos.add(pos)
                              added.append(L)
                      res = backtrack(i + 1)
                      if res:
                          return res
                      # undo
                      for L in added:
                          used_pos.remove(assignment[L])
                          del assignment[L]
                  return None
      
              sol = backtrack(0)
              if sol:
                  # fill missing letter (the one not appearing in
                  # any chord) into the free position
                  assigned_letters = set(sol.keys())
                  missing_letter = (set(letters) - assigned_letters).pop()
                  free_pos = (set(range(12)) - set(sol.values())).pop()
                  sol[missing_letter] = free_pos
      
                  # build octave list by position, rotate so O is first
                  order_by_pos = [None] * 12
                  for L, p in sol.items():
                      order_by_pos[p] = L
                  iO = order_by_pos.index("O")
                  octave = order_by_pos[iO:] + order_by_pos[:iO]
                  return pat, octave
          return None, None
      
      pattern, octave = solve()
      if octave:
          print("pattern:", pattern)
          print("octave starting at O:", " ".join(octave))
      else:
          print("No solution found.")
      
      timer.stop()
      timer.report()
      
      # pattern: (5, 3, 2, 2)
      # octave starting at O: O P Y Q Z W S V X U R T
      # [ST3282] total time: 0.0317849s (31.78ms)
      
      
      

      Like

      • Frits's avatar

        Frits 10:00 am on 26 September 2025 Permalink | Reply

        If I prefix the teaser text with “Write a Python program to solve the following puzzle:” the online ChatGPT generated slow code with no answer.

        Like

      • Frits's avatar

        Frits 10:28 am on 26 September 2025 Permalink | Reply

        The online ChatGPT version is the 4o version (may 2024). The version is not easy to find. I had to ask ChatGPT for it’s own version.

        Like

    • GeoffR's avatar

      GeoffR 12:13 pm on 26 September 2025 Permalink | Reply

      @Frits: I am using ChatGPT5 as an upgrade. However, usage is limited and its reverts to 4o version with continued use.

      Like

  • Unknown's avatar

    Jim Randell 6:33 am on 22 June 2025 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3274: Anarithm 

    From The Sunday Times, 22nd June 2025 [link] [link]

    If you rearrange the letters of a word to form another word, it’s called an anagram. So I think that if you rearrange the digits of a number to form another number, it could be called an anarithm.

    Some numbers when expressed in two different number bases have representations that are anarithms of each other. For instance, the decimal number 66 is represented as 123 in base 7 and 231 in base 5.

    I’ve recently been looking at numbers in base 8 and base 5 and found that it is possible to have a number whose representations in base 8 and base 5 are anarithms of each other.

    In decimal notation, what is the largest such number?

    [teaser3274]

     
    • Jim Randell's avatar

      Jim Randell 6:39 am on 22 June 2025 Permalink | Reply

      The following Python program produces non-trivial (i.e. >1-digit) representations that are “anarithms”, in numerical order.

      It runs in 66ms. (Internal runtime is 494µs).

      from enigma import (irange, inf, nsplit, join, printf)
      
      # the two bases (A < B)
      (A, B) = (5, 8)
      
      # consider increasing numbers of digits
      for k in irange(2, inf):
        (x, y) = (B**(k - 1), (A**k) - 1)
        if x > y: break
        for n in irange(x, y):
          (a, b) = (nsplit(n, base=A), nsplit(n, base=B))
          if sorted(a) == sorted(b):
            # output solution
            printf("k={k}: {n} -> {a} [base {A}], {b} [base {B}]", a=join(a), b=join(b))
      

      Solution: The number is 540 (decimal).

      In base 5 it is represented: 4130.

      And in base 8 it is represented: 1034.

      Like

      • Jim Randell's avatar

        Jim Randell 2:56 pm on 22 June 2025 Permalink | Reply

        Or, as we want the largest possible number, we can consider the candidate values starting with the largest. Then we can stop after the first solution is found.

        This Python program has an internal runtime of 227µs.

        from enigma import (irange, inf, nsplit, rev, join, printf)
        
        # find largest number for bases A, B (where A < B)
        def solve(A, B):
          assert A < B
          # intervals to consider
          ivs = list()
          for k in irange(2, inf):
            (x, y) = (B**(k - 1), (A**k) - 1)
            if x > y: break
            ivs.append((x, y))
        
          # consider each interval from largest to smallest
          for (x, y) in rev(ivs):
            for n in irange(y, x, step=-1):
              (a, b) = (nsplit(n, base=A), nsplit(n, base=B))
              if sorted(a) == sorted(b):
                # output solution
                printf("{n} -> {a} [base {A}], {b} [base {B}]", a=join(a), b=join(b))
                return
        
        # solve for base 5 and base 8
        solve(5, 8)
        

        Like

    • Frits's avatar

      Frits 8:00 pm on 23 June 2025 Permalink | Reply

      Not my fastest version but still fast and relatively simple.

      # get digits of a number in base <b> (converted from <n> in base 10)
      def int2base(n, b):
        ds = []
        while n:
            ds.append(int(n % b))
            n //= b
        return ds
      
      b1, b2 = 5, 8
      rng, invalid = [], set(range(b1, 10))
      # get a list of valid ranges per number of digits
      for n, _ in enumerate(iter(bool, 1), 2): # infinite  loop
        mn, mx = b2**(n - 1), b1**n - 1
        if mx < mn: break
        rng.append((mn, mx))
      
      # for decreasing number of digits
      for mn, mx in reversed(rng):
        # check all numbers in the valid range
        for n in range(mx, mn - 1, -1):
          # check for invalid digits
          if not invalid.isdisjoint(n_b2 := int2base(n, b2) ): continue
      
          # base 8 and base 5 are anarithms of each other
          if sorted(int2base(n, b1)) == sorted(n_b2):
            print(f"answer: {n}")
            exit(0)
      

      Like

  • Unknown's avatar

    Jim Randell 6:41 am on 27 April 2025 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3266: Where are the keys? 

    From The Sunday Times, 27th April 2025 [link] [link]

    Skaredahora’s venture into atonal music — music with no fixed key — had as its (so-called!) melody a long sequence employing 8 different notes, which he labelled Q to X. Behind his composition were 7 keys each using 4 notes. Every three consecutive melody notes obeyed specific rules:

    (1) all three had to be different;
    (2) they had to belong to exactly one of the keys;
    (3) they had not to repeat in the same order any other three consecutive notes;
    (4) the key had to change at every step.

    His chosen melody was:

    WUQRVWQRXWTRUQWRVQWTSRQWVRSTWQVRWQURTWXRQVWRTSX

    What were the keys involving X, in alphabetical order individually and collectively (e.g. QRTX, SUVX)?

    [teaser3266]

     
    • Jim Randell's avatar

      Jim Randell 8:08 am on 27 April 2025 Permalink | Reply

      Be careful to get the melody sequence exactly right! (I initially had a typo that made it impossible to find a solution).

      This Python 3 program solves the puzzle constructively. It first checks that triples of 3 consecutive notes are all different (3), and each consists of 3 different notes (1). It then fills out keys that can be used to play the triples in the required order, with a key change at each step (4). And then finally, once a set of keys is found, it checks that each triple belongs to exactly one of the keys (2).

      The program runs in 72ms. (Internal runtime is 1.2ms).

      from enigma import (tuples, join, fail, update, singleton, seq2str, printf)
      
      # the chosen melody
      melody = "WUQRVWQRXWTRUQWRVQWTSRQWVRSTWQVRWQURTWXRQVWRTSX"
      
      K = 4  # number of notes in a key
      N = 7  # number of keys
      
      # split into triples
      ts = list(tuples(melody, 3, fn=join))
      # check they are all different (3), and consist of different notes (1)
      if not (len(ts) == len(set(ts)) and all(len(t) == len(set(t)) for t in ts)):
        fail(msg="invalid melody")
      
      # find keys <ks> for the triples <ts>, with no adjacent repeats (4)
      # ts = triples to solve
      # n = number of next key to allocate
      # ks = map of keys to notes
      # pk = previous key
      def solve(ts, n=1, ks=dict(), pk=0):
        # are we done?
        if not ts:
          yield ks
          return
      
        # look at the next triple
        t = set(ts[0])
      
        # examine its relation to existing keys
        (ks1, ks2) = (list(), list())
        for (k, v) in ks.items():
          if v.issuperset(t):
            # it is already fully covered by an existing key
            ks1.append(k)
          else:
            vt = v.union(t)
            if len(vt) <= K:
              # an existing key can be extended to cover it
              ks2.append((k, vt))
      
        # there are three cases:
        if ks1:
          # it is already covered by exactly one key
          k = singleton(ks1, default=0)
          if k > 0 and k != pk: yield from solve(ts[1:], n, ks, k)
          return  # we are done
      
        # extend an existing key
        for (k, v) in ks2:
          if k != pk:
            yield from solve(ts[1:], n, update(ks, [(k, v)]), k)
      
        # allocate a new key
        if  n <= N:
          yield from solve(ts[1:], n + 1, update(ks, [(n, t)]), n)
      
      # construct the progression of keys, where each triple belongs to exactly one key (2)
      def check_keys(ts, ks):
        ss = list()
        # check each triple belongs to just one of the keys
        for t in ts:
          ss.append(singleton(k for (k, v) in ks.items() if v.issuperset(t)))
          if ss[-1] is None: return
        return ss
      
      # solve the puzzle
      for ks in solve(ts):
        ss = check_keys(ts, ks)
        if not ss: continue
        # output solution
        for k in sorted(ks):
          printf("{k}: {vs}{s}", vs=seq2str(ks[k], sort=1, sep=" "), s=(' <-' if 'X' in ks[k] else ''))
        printf("{ss}", ss=seq2str(ss, sep=" "))
        printf()
      

      Solution: The keys involving X are: QRUX, RVWX, STWX.

      The full set of keys is:

      1: (Q U V W)
      2: (Q R U X)
      3: (Q R S V)
      4: (R V W X)
      5: (Q R T W)
      6: (S T W X)
      7: (R S T U)

      (The required answer to the puzzle is keys 2, 4, 6, which all contain the note X).

      And this gives the following progression of keys:

      (1 2 3 4 1 5 2 4 6 5 7 2 1 5 4 3 1 5 6 7 3 5 1 4 3 7 6 5 1 3 4 5 1 2 7 5 6 4 2 3 1 4 5 7 6)

      Like

      • Jim Randell's avatar

        Jim Randell 12:06 pm on 4 May 2025 Permalink | Reply

        We can slightly simplify the code using the following observation (due to JohnZ [link]).

        Each set of 3 consecutive notes in the melody must uniquely identify a key, and adjacent triples must identify different keys, hence no key can consist of 4 consecutive notes in the melody, so we can identify a set of invalid keys that we can avoid to ensure each consecutive key must be different. And this means we don’t have to drag information about the previously used key as we construct the progression of keys.

        Here is a modified version of my program adapted to use this method. It has an internal runtime of 524µs.

        from enigma import (tuples, join, fail, update, singleton, seq2str, printf)
        
        # the chosen melody
        melody = "WUQRVWQRXWTRUQWRVQWTSRQWVRSTWQVRWQURTWXRQVWRTSX"
        
        K = 4  # number of notes in a key
        N = 7  # number of keys
        
        # split into triples
        ts = list(tuples(melody, 3, fn=join))
        # check they are all different (3), and consist of different notes (1)
        if not (len(ts) == len(set(ts)) and all(len(t) == len(set(t)) for t in ts)):
          fail(msg="invalid melody")
        
        # but no key can include 4 consecutive notes from the melody, so if we avoid
        # the following keys, there can be no adjacent repeats (4)
        invalid = set(tuples(melody, 4, fn=frozenset))
        
        # find keys <ks> for the triples <ts>, with no adjacent repeats (4)
        # ts = triples to solve
        # n = number of next key to allocate
        # ks = map of keys to notes
        def solve(ts, n=1, ks=dict()):
          # are we done?
          if not ts:
            yield ks
            return
        
          # look at the next triple
          t = set(ts[0])
        
          # examine its relation to existing keys
          (ks1, ks2) = (list(), list())
          for (k, v) in ks.items():
            if v.issuperset(t):
              # it is already fully covered by an existing key
              ks1.append(k)
            else:
              vt = v.union(t)
              if len(vt) <= K and vt not in invalid:
                # an existing key can be extended to cover it
                ks2.append((k, vt))
        
          # there are three cases:
          if ks1:
            # it is already covered by exactly one key
            k = singleton(ks1, default=0)
            if k > 0: yield from solve(ts[1:], n, ks)
            return  # we are done
        
          # extend an existing key
          for (k, v) in ks2:
            yield from solve(ts[1:], n, update(ks, [(k, v)]))
        
          # allocate a new key
          if  n <= N:
            yield from solve(ts[1:], n + 1, update(ks, [(n, t)]))
        
        # construct the progression of keys, where each triple belongs to exactly one key (2)
        def check_keys(ts, ks):
          ss = list()
          # check each triple belongs to just one of the keys
          for t in ts:
            ss.append(singleton(k for (k, v) in ks.items() if v.issuperset(t)))
            if ss[-1] is None: return
          return ss
        
        # solve the puzzle
        for ks in solve(ts):
          ss = check_keys(ts, ks)
          if not ss: continue
          # output solution
          for k in sorted(ks):
            printf("{k}: {vs}{s}", vs=seq2str(ks[k], sort=1, sep=" "), s=(' <-' if 'X' in ks[k] else ''))
          printf("{ss}", ss=seq2str(ss, sep=" "))
          printf()
        

        Like

    • Pete Good's avatar

      Pete Good 2:57 pm on 30 April 2025 Permalink | Reply

      Jim, The Sunday Times published this teaser under the title “Where Are the Keys?”

      Like

  • Unknown's avatar

    Jim Randell 6:20 am on 9 March 2025 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3259: Something to get your teeth into 

    From The Sunday Times, 9th March 2025 [link] [link]

    My bicycle has a set of three toothed rings (“chainrings”) attached to the pedal mechanism; I’d chosen these, with different numbers of teeth, from offered values of 30, 41, 45, 47, 58. Correspondingly, the rear wheel mechanism is attached to a set of five toothed rings (“sprockets”), each with no more than 20 teeth. A chain passes over one ring from each set, as selected by the gear controls; at any time the gear ratio is the number of teeth on the selected chainring divided by the number of teeth on the selected sprocket.

    There are exactly three pairs of chainring/sprocket combinations that give gear ratios whose difference equals one divided by a whole number more than 100.

    What are the numbers of teeth on the sprockets, in ascending order?

    [teaser3259]

     
    • Jim Randell's avatar

      Jim Randell 6:31 am on 9 March 2025 Permalink | Reply

      Here is a brute force solution.

      It runs in 1.7s.

      from enigma import (Rational, irange, subsets, cproduct, first, printf)
      
      Q = Rational()
      
      # check a collection of chainrings and sprockets
      # for differences in ratios of 1/N where N > 100
      def check(xs, ys):
        # find the gear ratios
        rs = list(Q(x, y) for (x, y) in cproduct([xs, ys]))
        # find differences that are 1/N
        for (a, b) in subsets(rs, size=2):
          d = abs(a - b)
          if d.numerator == 1 and d.denominator > 100:
            yield (a, b)
      
      # check possible chainrings and sprockets
      xss = subsets((30, 41, 45, 47, 58), size=3)  # 3 chainrings
      yss = subsets(irange(5, 20), size=5)  # 5 sprockets
      for (xs, ys) in cproduct([xss, yss]):
        # look for exactly 3 1/N differences
        ds = first(check(xs, ys), count=4)
        if len(ds) == 3:
          printf("chainrings = {xs}, sprockets = {ys}")
      

      Solution: The teeth on the sprockets are: 12, 13, 14, 16, 17.

      And the teeth on the chainring are: 41, 47, 58.

      And the three gear combinations are:

      58/16 − 47/13 = 1/104
      47/16 − 41/14 = 1/112
      41/12 − 58/17 = 1/204


      There are 6 pairs of gear ratios that give the required 1/N values (and they all give different values for N):

      58/16 − 47/13 = 1/104
      41/10 − 45/11 = 1/110
      47/16 − 41/14 = 1/112
      58/18 − 45/14 = 1/126
      41/15 − 30/11 = 1/165
      41/12 − 58/17 = 1/204

      But the only set of 3 N values that gives ≤ 3 numerators and ≤ 5 denominators is: 104, 112, 204, and this set gives exactly 3 numerators and exactly 5 denominators, so the set of cogs is fully determined.

      Like

      • Jim Randell's avatar

        Jim Randell 9:25 am on 9 March 2025 Permalink | Reply

        Here is a much faster approach. It looks for possible 1/N differences (with N > 100), and then assembles possible collections of chainrings and sprockets from 3 of the differences. This gives candidate sets of cogs, and fortunately there is only one that does not exceed the required numbers of chainrings and sprockets).

        It runs in 68ms. (Internal runtime is 4.7ms).

        from enigma import (
          irange, cproduct, subsets, fraction, ordered, flatten, unzip, diff, printf
        )
        
        # possible cogs
        ns = [30, 41, 45, 47, 58]
        ds = list(irange(5, 20))
        
        # find pairs with a difference that is 1/N, N > 100
        pairs = list()
        for ((a, b), (c, d)) in subsets(cproduct([ns, ds]), size=2):
          (p, q) = fraction(abs(a * d - b * c), b * d)
          if p == 1 and q > 100:
            pairs.append(ordered((a, b), (c, d)))
            printf("[abs({a}/{b} - {c}/{d}) -> {p}/{q}]")
        
        # check if pair <p> can be formed from <ns> and <ds>
        check = lambda p, ns, ds: all(n in ns and d in ds for (n, d) in p)
        
        # now choose 3 pairs
        for ps in subsets(pairs, size=3):
          # collect the numerators (chainrings) and denominators (sprockets)
          (cs, ss) = (sorted(set(s)) for s in unzip(flatten(ps)))
          # there are only 3 different chainrings, and 5 different sprockets
          if len(cs) > 3 or len(ss) > 5: continue
        
          # check no additional pairs can be formed
          if any(check(p, cs, ss) for p in diff(pairs, ps)): continue
        
          # output candidate solutions
          printf("chainrings = {cs}, sprockets = {ss}")
          printf("-> pairs = {ps}")
        

        Potentially the program could produce incomplete candidate sets of cogs. However, it turns out that only a single candidate solution is produced, and it is complete, so it doesn’t seem worth adding the code that completes set of cogs (which would be added at line 26), as it wouldn’t get called.

        Like

  • Unknown's avatar

    Jim Randell 1:45 am on 26 January 2025 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3253: Romanesque Numerals 

    From The Sunday Times, 26th January 2025 [link] [link]

    Equations in an archaic number system have been discovered:

    BBC + BC = CBA
    ABA#AB + ABAACA = ССВ
    АСААС × ААС = ACAABABA

    One letter (shown as #) is unclear, and could be either A or B. Letters basically represented different positive whole numbers; they might appear in any order in a string, but could contribute positively or negatively. The letter that was rightmost counted as a plus, but otherwise its basic value was compared with that of its right neighbour: if larger, it would count as plus; if smaller, as minus; if the same, then the same sign as that neighbour. (This works for interpreting Roman numerals too, e.g., VII = 5 + 1 + 1 = 7, and XLIX = −10 + 50 − 1 + 10 = 49).

    What are the basic values of A, B & C, in that order?

    [teaser3253]

     
    • Jim Randell's avatar

      Jim Randell 2:17 am on 26 January 2025 Permalink | Reply

      This Python program runs in 73ms. (Internal runtime is 4.9ms).

      from enigma import (irange, inf, decompose, tuples, rev, printf)
      
      # evaluate string <s> for symbol assignments <d>
      def val(s, d):
        # process the values in reverse order
        vs = rev(d[x] for x in s)
        # start with the rightmost
        rs = vs[0:1]
        # and process the rest in pairs
        for (x, y) in tuples(vs, 2):
          if y > x:
            rs.append(y)
          elif y < x:
            rs.append(-y)
          else:
            rs.append(rs[-1])
        return sum(rs)
      
      # evaluate strings <ss> for symbol assignments <d>
      def vals(ss, d): return list(val(s, d) for s in ss)
      
      # consider increasing totals for A + B + C
      for (A, B, C) in decompose(irange(6, inf), 3, increasing=0, sep=1, min_v=1):
        d = dict(A=A, B=B, C=C)
      
        # evaluate the given expressions:
        # [1] BBC + BC = CBA
        (a1, a2, a3) = vals(["BBC", "BC", "CBA"], d)
        if not (a1 + a2 == a3): continue
      
        # [2] (ABAAAB | ABABAB) + ABAACA = CCB"
        (b1a, b1b, b2, b3) = vals(["ABAAAB", "ABABAB", "ABAACA", "CCB"], d)
        if not (b1a + b2 == b3 or b1b + b2 == b3): continue
      
        # [3] ACAAC * AAC = ACAABABA
        (c1, c2, c3) = vals(["ACAAC", "AAC", "ACAABABA"], d)
        if not (c1 * c2 == c3): continue
      
        # output solution
        printf("A={A} B={B} C={C}")
        break  # we only need the first solution
      

      Solution: A=4; B=3; C=10.

      We have:

      BBC → [−3, −3, +10] = 4
      BC → [−3, +10] = 7
      CBA → [+10, −3, +4] = 11
      BBC + BC = CBA → 4 + 7 = 11

      ABAAAB → [+4, −3, +4, +4, +4, +3] = 16
      ABAACA → [+4, −3, −4, −4, +10, +4] = 7
      CCB → [+10, +10, +3] = 23
      ABAAAB + ABAACA = CCB → 16 + 7 = 23

      ACAAC → [−4, +10, −4, −4, +10] = 8
      AAC → [−4, −4, +10] = 2
      ACAABABA → [−4, +10, +4, +4, −3, +4, −3, +4] = 16
      ACAAC × AAC = ACAABABA → 8 × 2 = 16

      Note that these are not necessarily the simplest way to write these numbers. For example in the first sum 4 is denoted by BBC, when it would be simpler to use A.

      More compact forms of the sums are:

      4 + 7 = 11 → A + AB = AAB
      16 + 7 = 23 → CBB + BC = CCB
      8 × 2 = 16 → AA × AAC = ACC

      Like

      • Jim Randell's avatar

        Jim Randell 1:29 pm on 3 February 2025 Permalink | Reply

        If we relax the condition on the values being positive integers, we can find further solutions using (non-zero) rational numbers.

        For example:

        C = −2/9; A = 4/9; B = 5/9

        gives:

        BBC + BC = CBA → 8/9 + 1/3 = 11/9
        ABAAAB + ABAACA = CCB → −2/3 + 5/3 = 1
        ACAAC × AAC = ACAABABA → 4/3 × 2/3 = 8/9

        Other rational values that work are:

        A = -23/435; B = 161/870; C = 299/435
        A = -38/1225; B = 228/1225; C = 874/1225

        Like

    • Ruud's avatar

      Ruud 12:21 pm on 26 January 2025 Permalink | Reply

      import sys
      import itertools
      
      expressions = """\
      BBC + BC == CBA
      ABAXAB + ABAACA == CCB
      ACAAC * AAC == ACAABABA
      """.splitlines()
      
      
      def calc(s):
          l = [-1] + [globals()[ch] for ch in reversed(s)]
          result = 0
          for value, value_right in zip(l[1:], l):
              if value_right > value:
                  sign = -1
              if value_right < value:
                  sign = 1
              result += sign * value
          return str(result)
      
      
      def eval_expression(expression):
          return eval("".join(part if set(part) - set("ABCX") else calc(part) for part in expression.split()))
      
      
      for n in itertools.count(4):
          for A, B, C in itertools.permutations(range(20), 3):
              for X in (A, B, C):
                  if all(eval_expression(expression) for expression in expressions):
                      print(f"{A=} {B=} {C=}")
                      sys.exit()
      
      

      Like

      • Ruud's avatar

        Ruud 6:42 pm on 27 January 2025 Permalink | Reply

        I see now that # (X in my code) can only be A or B.
        So the second for loop should read
        for X in (A, B)
        It doesn’t change the solution, by the way.

        Like

    • Frits's avatar

      Frits 8:37 am on 3 February 2025 Permalink | Reply

      From the first expression we can deduce that B may not be greater than A and C.
      From the last expression we can deduce that A and C are both even.

      Like

  • Unknown's avatar

    Jim Randell 6:49 am on 17 November 2024 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3243: A break from tradition 

    From The Sunday Times, 17th November 2024 [link] [link]

    Occasionally we used to play a very non-traditional variation of snooker. We tried to “pot” into the pockets five differently-coloured balls, which had points values of 7, 8, 9, 10, 13 in some order.

    Our rules were that a red could only be followed by a blue or pink; a yellow by a red or green; a green by a yellow; a blue by a green; and a pink by a red or yellow. After a player had successfully potted a ball it was immediately returned to the table, and their turn continued, and this constituted a “break”.

    One evening I scored breaks that totalled 33, 55, 24 and 57 points (none with more than five pots).

    What were the values of red, yellow, green, blue and pink, in that order?

    [teaser3243]

     
    • Jim Randell's avatar

      Jim Randell 7:05 am on 17 November 2024 Permalink | Reply

      This Python 3 program collects possible allowable sequences of up to 5 balls, and then looks for assignments of colour values that allow all the given scores to be made.

      It runs in 75ms. (Internal runtime is 4.9ms).

      from enigma import (flatten, subsets, map2str, printf)
      
      # allowed transitions
      adj = dict(R="BP", Y="RG", G="Y", B="G", P="RY")
      
      # generate break with up to <k> more balls
      def solve(k, bs):
        yield bs
        if k > 0:
          for x in adj[bs[-1]]:
            yield from solve(k - 1, bs + x)
      
      # collect possible breaks with up to 5 balls
      seqs = flatten(solve(4, k) for k in adj.keys())
      
      # consider possible ball values
      for vs in subsets([7, 8, 9, 10, 13], size=len, select='P'):
        v = dict(zip("RYGBP", vs))
        # score each of the possible sequences
        scores = set(sum(v[x] for x in bs) for bs in seqs)
        if scores.issuperset({33, 55, 24, 57}):
          # output solution
          printf("{v}", v=map2str(v, enc="", sort=0))
      

      Solution: The values of the balls are: red = 9, yellow = 10, green = 8, blue = 7, pink = 13.

      The given breaks are:

      24 = RBG
      33 = BGYG
      55 = PYRPY
      57 = PRPRP

      Like

    • ruudvanderham's avatar

      ruudvanderham 3:22 pm on 17 November 2024 Permalink | Reply

      import itertools
      
      next_ball = dict(r="bp", y="rg", g="y", b="g", p="ry")
      
      targets = (33, 55, 24, 57)
      
      
      def shoot(balls, target, value):
          score = sum(value[ball] for ball in balls)
          if score <= target and len(balls) <= 5:
              if score == target:
                  yield balls
              for try_ball in next_ball[balls[-1]] if balls else next_ball.keys():
                  yield from shoot(balls=balls + try_ball, target=target, value=value)
      
      
      for permutation in itertools.permutations((7, 8, 9, 10, 13)):
          ball_value = dict(zip(next_ball.keys(), permutation))
          if all(any(shoot(balls="", target=target, value=ball_value)) for target in targets):
              print(" ".join(f"{ball}:{value}" for ball, value in ball_value.items()))
              for target in targets:
                  print(f'  {target} by playing {" or ".join(shoot(balls="",target=target,value=ball_value))}')

      Like

    • Frits's avatar

      Frits 3:44 pm on 17 November 2024 Permalink | Reply

      A lot of work for doing it a bit differently.

      from enigma import SubstitutedExpression
      
      # dictionary of possible next colours
      d = {"b": "g", "g": "y", "p":"ry", "r": "bp", "y": "rg"}
      balls = sorted(d.keys())
      
      points = [7, 8, 9, 10, 13]
      scores = [33, 55, 24, 57]
      
      # collect all possible breaks
      def solve(k, ss):
        yield ss
        if not k: return
        for x in d[ss[-1]]:
          yield from solve(k - 1, ss + x)       
      
      breaks = set(''.join(sorted(x for x in s)) for k in d.keys() 
                   for s in solve(4, k))
      
      # colours that may occur three times in a break
      balls3 = {k for k in balls if any(k in d[k1] for k1 in d[k])}
       
      # invalid digit / symbol assignments
      d2i = dict()
      for dgt in range(0, 10):
        vs = set() 
        if dgt in {1, 2, 4, 5, 6}: vs.update('SCHQZ')
        if dgt > 1: vs.update('RBGPY')
        if dgt > 3: vs.update('ADFIJKLMNOTUVWXjqxyz')
        if dgt == 3:
          for i, k in enumerate(d.keys()):
            if k in balls3: continue
            # colours that may not occur three times in a break
            vs.update(['ADFIJKLMNOTUVWXjqxyz'[5 * j + i] for j in range(4)])
        d2i[dgt] = vs   
      
      # check if a colour combination is allowed  
      def check(*s):
        # sorted used balls
        used = ''.join(balls[i] * x for i, x in enumerate(s) if x)
        return used in breaks
         
      # the alphametic puzzle
      p = SubstitutedExpression(
        [ # colors: b = BC, g = GH, p = PQ, r = RS, y = YZ
          "BC in {7, 8, 9, 10, 13}",
          "H != C",
          "GH in {7, 8, 9, 10, 13}",
          "Q not in {C, H}",
          "PQ in {7, 8, 9, 10, 13}",
          "S not in {C, H, Q}",
          "RS in {7, 8, 9, 10, 13}",
          
          "{7, 8, 9, 10, 13}.difference([BC, GH, PQ, RS]).pop() = YZ",
          
          #"A * BC + D * GH + F * PQ + I * RS + J * YZ == 57",
          "div(57 - (A * BC + D * GH + F * PQ + I * RS), YZ) = J",
          "A + D + F + I + J < 6",
          "check(A, D, F, I, J)", 
          
          #"T * BC + U * GH + V * PQ + W * RS + X * YZ == 55",
          "div(55 - (T * BC + U * GH + V * PQ + W * RS), YZ) = X",
          "T + U + V + W + X < 6",
          "check(T, U, V, W, X)", 
          
          #"K * BC + L * GH + M * PQ + N * RS + O * YZ == 24",
          "div(24 - (K * BC + L * GH + M * PQ + N * RS), YZ) = O",
          "K + L + M + N + O < 6",
          "check(K, L, M, N, O)", 
          
          #"j * BC + q * GH + x * PQ + y * RS + z * YZ == 33",
          "div(33 - (j * BC + q * GH + x * PQ + y * RS), YZ) = z",
          "j + q + x + y + z < 6",
          "check(j, q, x, y, z)", 
        ] 
        ,
        answer="(RS, YZ, GH, BC, PQ)",
        symbols="ABCDEFGHIJKLMNOPQRSTUVWXYZjqxyz",
        d2i=d2i,
        distinct="",
        env=dict(check=check),
        denest=64,
        reorder=0,
        verbose=0,    # use 256 to see the generated code
      ) 
      
      # print answers
      for ans in p.answers():
        print(f"answer: {ans}")
      

      Like

  • Unknown's avatar

    Jim Randell 6:17 am on 1 September 2024 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3232: Digital processing 

    From The Sunday Times, 1st September 2024 [link] [link]

    I divided 1 by 5 in base 7 in the following way. I started with 1, multiplied by the base (7), recorded the result on division by 5 as a digit (1), then used the remainder (2) to repeat the process until the remainder became 0 or the pattern was about to repeat. The result was 0.1254 with those four digits then repeating.

    For some base no more than 20 I also calculated the result of dividing 1 by all numbers from 2 up to the base minus 1. With one of these divisors the result had the “digit” 14 at one point followed immediately by the “digit” 13. Some “digits” never occurred in the fractional part with any of these divisors.

    What “digits” (in increasing order) never occurred?

    [teaser3232]

     
    • Jim Randell's avatar

      Jim Randell 6:36 am on 1 September 2024 Permalink | Reply

      We can use the [[ recurring() ]] function from the enigma.py library to calculate the expansion of a fraction in different bases. (Originally written for Enigma 1247).

      The following Python program runs in 69ms. (Internal runtime is 949µs).

      Run: [ @jdoodle ]

      from enigma import (irange, recurring, int2base, base2int, format_recurring, printf)
      
      # look for digit X followed by digit Y
      (X, Y) = (14, 13)  # {14}:{13} = "ED"
      
      # examine reciprocals in base <b>
      def solve(b, verbose=0):
        # look for subsequence X:Y
        i2b = lambda n: int2base(n, base=b)
        (ss, f) = (i2b(X) + i2b(Y), 0)
        # unused digits
        ds = set(i2b(n) for n in irange(0, b - 1))
        for k in irange(2, b - 1):
          (i, nr, rr) = recurring(1, k, base=b)
          if verbose: printf("[{b}] {k} -> {x}", x=format_recurring((i, nr, rr)))
          if ss in nr + rr + rr[:1]: f = 1
          ds.difference_update(nr, rr)
        if f:
          # output solution (using integer digits)
          ds = list(base2int(d, base=b) for d in ds)
          printf("base={b} -> unused digits = {ds}", ds=sorted(ds))
      
      # consider bases up to 20
      for b in irange(max(X, Y) + 1, 20):
        solve(b)
      

      Solution: The unused digits are: 0, 12, 15, 17.

      In base 18 the reciprocals from 1/2 to 1/17 have the following expansions:

      1/2 = 0.9
      1/3 = 0.6
      1/4 = 0.49
      1/5 = 0.(3AE7)…
      1/6 = 0.3
      1/7 = 0.(2A5)…
      1/8 = 0.249
      1/9 = 0.2
      1/10 = 0.1(E73A)…
      1/11 = 0.(1B834G69ED)… [this contains E followed by D]
      1/12 = 0.19
      1/13 = 0.(16GB)…
      1/14 = 0.1(52A)…
      1/15 = 0.1(3AE7)…
      1/16 = 0.1249
      1/17 = 0.(1)…

      The digits (from 0123456789ABCDEFGH) that do not occur in the fractional parts of these expansions are:

      0
      C = 12
      F = 15
      H = 17

      The puzzle limits us to bases up to 20, but the next solutions don’t occur until bases 58, 86, 142.

      Like

      • Frits's avatar

        Frits 3:00 pm on 2 September 2024 Permalink | Reply

        @Jim, Brian’s counting of the divisors that have the special subsequence is more pure than our approach (as it doesn’t say “With at least one of these divisors”).

        Like

        • Jim Randell's avatar

          Jim Randell 4:11 pm on 2 September 2024 Permalink | Reply

          @Frits: Unless the puzzle is explicit about there being exactly one case, I usually take a relaxed interpretation where the finding of one case is sufficient (a logical existential quantification). In this puzzle we don’t need to use the strict interpretation of “exactly one”.

          Like

    • Frits's avatar

      Frits 10:44 am on 1 September 2024 Permalink | Reply

      # does list <lst2> occur in list <lst1>
      inList = lambda lst1, lst2: any(lst1[i:i + len(lst2)] == lst2
                                      for i in range(len(lst1) - len(lst2) + 1))
      
      # return digits in base b in the fraction k / b (where k < b)
      # (not repeating decimals) 
      def divide(b, k):
        r, s, rs = 1, [], set()
        while True:
          (d, r) = divmod(r * b, k)
          s.append(d)
          # break if no remainder or repeating decimals encountered
          if not r or r in rs: break
          rs.add(r)  
        return s 
      
      # consider bases from 15 to 20 (base must be higher than 14)
      for b in range(15, 21):
        found1413, seen = 0, set()
        for k in range(2, b): 
          dgts = divide(b, k)
          # does list [14, 13] occur in <dgts>?
          if inList(dgts, [14, 13]):
            found1413 = 1
          seen |= set(dgts)
          
        if found1413:
          print(f"answer: {sorted(set(range(b)).difference(seen))}")
      

      Like

      • Jim Randell's avatar

        Jim Randell 11:31 am on 2 September 2024 Permalink | Reply

        @Frits: I don’t see how you can differentiate between recurring and non-recurring expansions returned by your [[ divide() ]] routine.

        >>> divide(10, 18)
        [0, 5]
        >>> divide(10, 20)
        [0, 5]
        

        Would your program spot the occurrence of adjacent digits D and 6 in the expansion of 1/15 in base 20?

        1/15 (base 20) = 0.1(6D)… = 0.16D6D6D…

        >>> divide(20, 15)
        [1, 6, 13]
        

        Like

        • Frits's avatar

          Frits 2:48 pm on 2 September 2024 Permalink | Reply

          @Jim, the divide function has been made with the comment “(where k less than b)” so the first question I don’t understand. I don’t think I need to differentiate between recurring and non-recurring except for the valid 2nd point you make (divide(20, 15)).

          Here is the adjustment (assuming we have have to scan for only 2 “digits”):

          # does list <lst2> occur in list <lst1>
          isSubList = lambda lst1, lst2: any(lst1[i:i + len(lst2)] == lst2
                                             for i in range(len(lst1) - len(lst2) + 1))
          
          # return "digits" in base b in the fraction 1 / k (where k < b)
          # (not repeating "decimals") 
          def divide(b, k):
            assert b > k 
            r, s, dct = 1, [], dict()
            while True:
              p = r 
              (d, r) = divmod(r * b, k)
              s.append(d)
              if not r: 
                return s
              # repeating "decimals" encountered?  
              if r in dct: 
                # also suffix first "digit" of repetend as we have to look 
                # for a sequence of two "digits"
                return s if r == p else s + [dct[r]] 
              dct[p] = d
            
          # consider bases from 15 to 20 (base must be higher than 14)
          for b in range(15, 21):
            found1413, seen = 0, set()
            for k in range(2, b): 
              dgts = divide(b, k)
              # does list [14, 13] occur in <dgts>?
              if isSubList(dgts, [14, 13]):
                found1413 = 1
              seen |= set(dgts)
              
            if found1413:
              print(f"answer: {sorted(set(range(b)).difference(seen))}")
          

          Like

          • Jim Randell's avatar

            Jim Randell 4:15 pm on 2 September 2024 Permalink | Reply

            @Frits: It was the same point really. I was trying to show a case where the return value was obviously ambiguous. But I strayed outside the acceptable parameters of your function. I should have used a different example.

            Like

    • GeoffR's avatar

      GeoffR 11:38 am on 2 September 2024 Permalink | Reply

      @Jim:
      Can you please show how the first paragraph works to get an answer of 0.1245 with th same repeating digits? (as the main teaser is the second paragraph)

      Like

      • Jim Randell's avatar

        Jim Randell 12:02 pm on 2 September 2024 Permalink | Reply

        @GeoffR:

        Starting with 1 we perform the following calculations:

        (1 × 7) / 5 = 1 (remainder 2)
        (2 × 7) / 5 = 2 (remainder 4)
        (4 × 7) / 5 = 5 (remainder 3)
        (3 × 7) / 5 = 4 (remainder 1)

        At the end of these 4 steps we have written down 1254 and are about to start the next step with the remainder 1. But this is the same as the value we started with at the beginning, so we will just go through the same steps again.

        So the result is:

        1/5 [base 7] = 0. 1254 1254 1254 …

        >>> recurring(1, 5, base=7)
        Recurring(i='0', nr='', rr='1254')
        

        Like

    • Frits's avatar

      Frits 9:07 pm on 4 September 2024 Permalink | Reply

      Limiting the calls to divide().

      # return "digits" in base b in the fraction 1 / k (where k < b)
      # (not repeating "decimals") 
      def divide(b, k):
        assert b > k 
        r, s, dct = 1, [], dict()
        while True:
          p = r 
          (d, r) = divmod(r * b, k)
          s.append(d)
          if not r: 
            return s
          # repeating "decimals" encountered?  
          if r in dct: 
            # also suffix first "digit" of repetend as we have to look 
            # for a sequence of two "digits"
            return s if r == p else s + [dct[r]] 
          dct[p] = d
        
      seq = [14, 13]
      
      # consider bases to 20
      for b in range(max(seq) + 1, 21):
        for k in range(2, b):   
          # 14 = (r * b) // k
          r1 = (seq[0] * k) // b + 1
          (d2, r2) = divmod(r1 * b, k)  
          if d2 != seq[0]: continue
          (d3, r3) = divmod(r2 * b, k) 
          # seq[0] has to be followed by seq[1]
          if d3 != seq[1]: continue
          
          # check if first specified item is one of the "digits" in the fraction
          dgts = divide(b, k)
          if seq[0] not in dgts: continue
          seen = set(dgts)
          for k2 in range(2, b): 
            if k2 == k: continue
            seen |= set(divide(b, k2))
            
          print(f"answer: {sorted(set(range(b)).difference(seen))}")  
          break
      

      Like

  • Unknown's avatar

    Jim Randell 4:27 pm on 5 July 2024 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3224: Put your finger on it 

    From The Sunday Times, 7th July 2024 [link] [link]

    On a particular stringed instrument, a fingering pattern of 2, 2, 3, 1 means that the pitches of strings 1 to 4 are raised by that many steps respectively from the “open strings” (i.e. their pitches when not fingered); this gives a “chord” of pitches C, E, G, A♯ in some order. The fingering pattern 4, 1, 0, x (for some whole number x from 0 to 11 inclusive) would give pitches F, A, C, D in some order, if these were all shifted by some fixed amount.

    [Pitches range through C, C♯, D, D♯, E, F, F♯, G, G♯, A, A♯, B, which then repeat].

    What are the pitches of “open strings” 1 to 4, and what is the value of x

    [teaser3224]

     
    • Jim Randell's avatar

      Jim Randell 4:46 pm on 5 July 2024 Permalink | Reply

      A simple exhaustive search of the problem space finds the answer fairly quickly. So it will do for now – I might refine it a bit later.

      This Python program runs in 99ms. (Internal runtime is 8.3ms).

      Run: [ @replit ]

      from enigma import (irange, subsets, join, printf)
      
      # format a set of notes
      fmt = lambda ns: join(ns, sep=" ", enc="()")
      
      # the order of notes
      notes = "C C# D D# E F F# G G# A A# B".split()
      n = len(notes)
      
      # finger strings <ss> at frets <fs>
      def finger(ss, fs):
        return list(notes[(notes.index(x) + y) % n] for (x, y) in zip(ss, fs))
      
      # target chords
      tgt1 = sorted("C E G A#".split())
      tgt2 = sorted("F A C D".split())
      
      # choose an ordering for the first target chord
      for ns1 in subsets(tgt1, size=4, select='P'):
        # and fret it down to open strings
        ss = finger(ns1, [-2, -2, -3, -1])
      
        # choose a value for x and a transposition
        for (x, t) in subsets(irange(0, 11), size=2, select='M'):
          ns2 = finger(ss, [4 + t, 1 + t, 0 + t, x + t])
          # check for the target chord
          if sorted(ns2) == tgt2:
            printf("{ss} @[2, 2, 3, 1] = {ns1}; @[4, 1, 0, {x}] + {t} = {ns2}", ss=fmt(ss), ns1=fmt(ns1), ns2=fmt(ns2))
      

      Solution: The open strings are [D, G♯, E, B]. And the value of x is 2.

      Fretting @[2, 2, 3, 1] gives [E, A♯, G, C].

      And, shifting up by 8 frets (e.g. using a capo) gives [A♯, E, C, G].

      Then fretting @[4, 1, 0, 2] (above the capo) gives [D, F, C, A].

      Like

    • Ruud's avatar

      Ruud 7:31 am on 6 July 2024 Permalink | Reply

      Here’s my brute force solution using sets:

      from itertools import product
      
      pitches = "C C# D D# E F F# G G# A A# B".split()
      
      def index_set(s):
          return {pitches.index(n) for n in s}
      
      def iadd(i, n):
          return (i + n) % 12
      
      for istrings in product(range(12), repeat=4):
          if {iadd(istrings[0], 2), iadd(istrings[1], 2), iadd(istrings[2], 3), iadd(istrings[3], 1)} == index_set({"C", "E", "G", "A#"}):
              for shift in range(12):
                  for x in range(12):
                      if {iadd(istrings[0], 4 + shift), iadd(istrings[1], 1 + shift), iadd(istrings[2], 0 + shift), iadd(istrings[3], x + shift)} == index_set(
                          {"F", "A", "C", "D"}
                      ):
                          print(f"open strings are {' '.join(pitches[i] for i in istrings)}")
                          print(f"x = {x}")
      

      Like

  • Unknown's avatar

    Jim Randell 4:41 pm on 19 April 2024 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3213: A streetcar named divisor 

    From The Sunday Times, 21st April 2024 [link] [link]

    In retrospect it was inadvisable to ask an overenthusiastic mathematician to overhaul our local tram routes. They allocated a positive whole number less than 50 to each district’s tram stop. To find the number of the tram going from one district to another you would “simply” (their word, not mine) find the largest prime divisor of the difference between the two districts’ numbers; if this was at least 5 it was the unique route number, and if not there was no direct route.

    The routes, each in increasing order of the stop numbers, were:

    Atworth, Bratton, Codford;
    Atworth, Downlands, Enford;
    Bratton, Figheldean, Enford;
    Downlands, Figheldean, Codford;
    Codford, Enford.

    What were the route numbers, in the order quoted above?

    [teaser3213]

     
    • Jim Randell's avatar

      Jim Randell 5:35 pm on 19 April 2024 Permalink | Reply

      Here is a solution using the [[ SubstitutedExpression ]] solver from the enigma.py library.

      This run file executes in 228ms. (Internal runtime of the generated program is 147ms).

      Run: [ @replit ]

      #! python3 -m enigma -rr
      
      SubstitutedExpression
      
      --code="""
      # largest prime factor (n > 0)
      primes.expand(50)
      @cache
      def lpf(n):
        fs = list(primes.prime_factor(n))
        if not fs: return 0
        r = fs[-1][0]
        return (0 if r < 5 else r)
      
      # route number between 2 districts
      route = lambda x, y: lpf(abs(x - y))
      """
      
      --base=50
      --digits="1-49"
      --distinct="ABCDEF,VWXYZ"
      
      # route V: A < B < C
      "A < B" "B < C"
      "route(A, B) = V"
      "route(B, C) = V"
      "route(C, A) = V"
      
      # route W: A < D < E
      "A < D" "D < E"
      "route(A, D) = W"
      "route(D, E) = W"
      "route(E, A) = W"
      
      # route X: B < F < E
      "B < F" "F < E"
      "route(B, F) = X"
      "route(F, E) = X"
      "route(E, B) = X"
      
      # route Y: D < F < C
      "D < F" "F < C"
      "route(D, F) = Y"
      "route(F, C) = Y"
      "route(C, D) = Y"
      
      # route Z: C < E
      "C < E"
      "route(C, E) = Z"
      
      # no routes between A - F, B - D
      "not route(A, F)"
      "not route(B, D)"
      
      --answer="(V, W, X, Y, Z)"
      --template=""
      

      Solution: The route numbers are: 5, 11, 13, 7, 19.

      And the numbers of the tram stops are:

      A = x + 1
      B = x + 6
      C = x + 26
      D = x + 12
      E = x + 45
      F = x + 19
      x = 0 .. 4

      Adding a constant value to each of the stop numbers does not change the differences between them, but x must be between 0 and 4 for all stop numbers to lie in the range 1 .. 49.

      In the program above, if we fix the value A=1 (with: [[ --assign="A,1" ]], we bring the runtime down to 93ms. (11.3ms internal).

      Like

      • Frits's avatar

        Frits 6:31 pm on 19 April 2024 Permalink | Reply

        @Jim, “They allocated a positive whole number less than 50 to each district’s tram stop.”. For some reason you seem to disregard tram stops 1-4 (that’s why I have five solutions). Could you explain why?

        Like

        • Jim Randell's avatar

          Jim Randell 6:40 pm on 19 April 2024 Permalink | Reply

          @Frits: In an earlier version of my code I disallowed stops 1-4, then I realised that we are looking for prime factors of the difference that are 5 or more.

          But there is only one answer to the puzzle (although there are multiple numberings of the stops).

          Like

      • Frits's avatar

        Frits 11:16 pm on 19 April 2024 Permalink | Reply

        I also started with SubstitutedExpression to get a quick solution but decided to publish something with a little analysis.

        # primes in range 1 - 48
        P = [3, 5]
        P = [x for x in range(47, 10, -2) if all(x % p for p in P)] + [7, 5]
        
        # largest prime divisor of the difference between the two numbers
        def check(a, b):
          diff = b - a
          for p in P:
            if diff % p == 0:
              return p
        
        sols = set()
        # order: A, (B|D), F, C, E
        #          5  1  7  11 13
        
        # E is the stop with the highest number with minimum 5+1+7+11+13  
        for E in range(38, 50):
          for C in range(25, E):
            if not (r5 := check(C, E)): continue
            for F in range(14, C): 
              if not (r4 := check(F, C)) or r4 == r5: continue
              if not (r3 := check(F, E)): continue
              if r3 in {r4, r5}: continue
              for D in range(6, F): 
                if check(D, F) != r4: continue
                if not (r2 := check(D, E)): continue
                if r2 in {r3, r4, r5}: continue
                for B in range(6, F): 
                  if check(B, F) != r3 or B == D: continue
                  if not (r1 := check(B, C)): continue
                  if r1 in {r2, r3, r4, r5}: continue
                  for A in range(1, min(B, D)): 
                    if check(A, D) != r2: continue
                    if check(A, B) != r1: continue
                    sols.add((r1, r2, r3, r4, r5))
        
        print("answer", *sols)    
        

        Like

      • Frits's avatar

        Frits 11:40 pm on 19 April 2024 Permalink | Reply

        “and if not there was no direct route”. This probably suggests to check the routes A – F and B – D for primary divisors. I didn’t use it as it is not explicitly specified that a tram route must exist if a direct route is possible.

        Like

        • Jim Randell's avatar

          Jim Randell 10:20 am on 20 April 2024 Permalink | Reply

          @Frits: I interpreted this as an “if any only if” condition.

          For every pair of stop numbers, if the calculation give an LPF ≥ 5, then this is the route number that the stops are both on. If it does not, then that pair are not on the same route.

          It would be simple to add the check to your program.

          Like

      • Frits's avatar

        Frits 11:13 am on 20 April 2024 Permalink | Reply

        With the implicit “if any only if” condition.

        # primes in range 1 - 48
        P = [3, 5]
        P = [x for x in range(47, 10, -2) if all(x % p for p in P)] + [7, 5]
        
        # largest prime divisor of a difference
        def check(diff):
          for p in P:
            if diff % p == 0:
              return p
          return 0   # faster then implicitly returning None
        
        # build dictionary of largest prime divisors for differences
        d = {diff: check(diff) for diff in range(1, 49)}
        
        # order: A, (B|D), F, C, E
        #          5  1  7  11 13
        
        sols = set()
        # E is the stop with the highest number with minimum 1+5+1+7+11+13  
        for E in range(minE := 2 + sum(P[-4:]), 50):
          for C in range(minE - P[-4], E - 4):
            if not (r5 := d[E - C]): continue
            for F in range(minE - P[-4] - P[-3], C - 4): 
              if not (r4 := d[C - F]) or r4 == r5: continue
              if not (r3 := d[E - F]): continue
              if r3 in {r4, r5}: continue
              for D in range(1 + P[-1], F - 4): 
                if d[F - D] != r4: continue
                if not (r2 := d[E - D]): continue
                if r2 in {r3, r4, r5}: continue
                for B in range(1 + P[-1], F - 4): 
                  if d[F - B] != r3 or B == D: continue
                  if not (r1 := d[C - B]): continue
                  if r1 in {r2, r3, r4, r5}: continue
                  for A in range(1, min(B, D) - 4): 
                    if d[D - A] != r2: continue
                    if d[B - A] != r1: continue
                    # no route Atworth - Figheldean nor Bratton - Downlands
                    if d[F - A] == d[D - B] == 0:       
                      sols.add((r1, r2, r3, r4, r5))
                     
        print("answer:", *sols)     
        

        Like

    • Jim Randell's avatar

      Jim Randell 1:00 pm on 20 April 2024 Permalink | Reply

      Here is a Python solution with an internal runtime of <1ms.

      A is the smallest stop number, so we can assume A=1, as adding the same constant value to each stop number will not change the differences. We can generate further solutions that give the same answer by incrementing the stop numbers until the largest stop number (E) reaches 49.

      Run: [ @replit ]

      from enigma import (irange, primes, cache, printf)
      
      # largest prime factor (n > 0)
      primes.expand(50)
      @cache
      def lpf(n):
        fs = list(primes.prime_factor(n))
        if not fs: return 0
        r = fs[-1][0]
        return (0 if r < 5 else r)
      # route number between 2 districts
      route = lambda x, y: lpf(abs(x - y))
      
      # A is the smallest stop number, so assume A=1
      A = 1
      for B in irange(A + 5, 34):
        r1 = route(A, B)
        if not r1: continue
        for C in irange(B + 5, 44):
          if route(B, C) != r1 or route(A, C) != r1: continue
          for E in irange(C + 5, 49):
            (r2, r3, r5) = (route(A, E), route(B, E), route(C, E))
            if not (r2 and r3 and r5): continue
            rs = {r1, r2, r3, r5}
            if len(rs) != 4: continue
            for F in irange(B + 5, C - 5):
              if route(B, F) != r3 or route(E, F) != r3 or route(A, F): continue
              r4 = route(C, F)
              if (not r4) or r4 in rs: continue
              for D in irange(A + 5, F - 5):
                if route(A, D) != r2 or route(D, E) != r2 or route(D, F) != r4 or route(C, D) != r4 or route(B, D): continue
                # output solution
                printf("routes = {r1} {r2} {r3} {r4} {r5} [A={A} B={B} C={C} D={D} E={E} F={F}]")
      

      Like

      • Frits's avatar

        Frits 2:45 pm on 20 April 2024 Permalink | Reply

        @Jim, a smart idea. I wonder if assuming E=49 is more optimal.
        You can also start C from B + 12 (as F is between B and C).

        Like

    • Frits's avatar

      Frits 1:03 pm on 20 April 2024 Permalink | Reply

      My first program in the Julia language.

      # primes in range 1 - 48
      P = [3, 5]
      P = [x for x in 47:-2:10 if all(x % p > 0 for p in P)] 
      push!(P, 7, 5)
      
      # largest prime divisor of a difference
      function check(diff)
        for p in P
          if diff % p == 0
            return p
          end  
        end    
        return 0   
      end
      
      # build dictionary of largest prime divisors for differences
      d = Dict(diff => check(diff) for diff in 1:49)
      
      sols = Set()
      # E is the stop with the highest number with minimum 1+5+1+7+11+13  
      
      minE = 2 + sum(P[end - 3:end])
      for E in minE:49
        for C in minE - P[end - 3]:E - 5
          r5 = d[E - C]
          if r5 == 0
            continue
          end
          
          for F in minE - P[end-3] - P[end-2]:C - 5 
            r4 = d[C - F]
            if r4 == 0 || r4 == r5 
              continue
            end  
            r3 = d[E - F]
            if r3 == 0 || r3 in Set([r4, r5])
              continue
            end  
      
           for D in 1 + P[end]:F - 5 
              if d[F - D] != r4 
                continue
              end  
              r2 = d[E - D]
              if r2 == 0 || r2 in Set([r3, r4, r5])
      	  continue
              end  
      
              for B in 1 + P[end]:F - 5
                if d[F - B] != r3 || B == D
                  continue
                end  
                r1 = d[C - B]
                if r1 == 0 || r1 in Set([r2, r3, r4, r5])
                  continue
                end  
                
                for A in 1: min(B, D) - 5 
                  if d[D - A] != r2 || d[B - A] != r1
                    continue
                  end  
                  # no route Atworth - Figheldean nor Bratton - Downlands
                  if d[F - A] == d[D - B] == 0   
                    #println("E = ", E, " C = ", C, " F = ", F, " D = ",  D,
                    #        " B = ", B, " A = ", A)
                    push!(sols, (r1, r2, r3, r4, r5))
                  end  
                end
              end
            end
          end          
        end
      end
      
      print("answer: ")
      for s in sols
        println(s, "  ") 
      end     
      

      Like

      • Jim Randell's avatar

        Jim Randell 11:01 am on 30 April 2024 Permalink | Reply

        What did you think of Julia? I briefly dabbled with it 10 years ago. (I think the only remnant of that is here [ link ]). But I decided to stick with Python.

        Like

        • Frits's avatar

          Frits 8:21 pm on 30 April 2024 Permalink | Reply

          I prefer the syntax of Python (without the “if end”). Not using the word “range” doesn’t make the code easy to read for me (I have to scan for a “:”). I don’t know all the features in Julia (like if assignment statements are possible). Comprehensions and dictionaries seem to work fine.

          I am mainly disappointed in the external run time of Julia programs. I would only use it again if we have a tough problem where a Python program runs longer than 30 minutes. Even then it is doubtful if it will be quicker than CPython or PyPy.

          Like

    • Frits's avatar

      Frits 1:23 pm on 22 April 2024 Permalink | Reply

      Adding E = 49 and negative step loops.

      Internal run time is approx 100 microseconds.

      # primes in range 1 - 48
      P = [3, 5]
      P = [x for x in range(47, 10, -2) if all(x % p for p in P)] + [7, 5]
      
      # largest prime divisor of a difference
      def check(diff):
        for p in P:
          if diff % p == 0:
            return p
      
      # build dictionary of largest prime divisors for differences
      d = {diff: check(diff) for diff in range(1, 49)}
      
      # order: A, (B|D), F, C, E
      #          5  1  7  11 13
      
      # since all route numbers depend on differences between
      # stop numbers, we can set E equal to 49
      E = 49
      sols = set()
      for F in range(2 + sum(P[-2:]), E - sum(P[-2:]) + 1): 
        if not (r3 := d[E - F]): continue
        for B in range(F - r3, 1 + P[-1], -r3): 
          if not (d[F - B] == r3): continue
          for C in range(F + P[-1], E - P[-1] + 1): 
            if not (r4 := d[C - F]) or r4 == r3: continue
            if not (r1 := d[C - B]) or r1 in {r3, r4}: continue
            if not (r5 := d[E - C]) or r5 in {r1, r3, r4}: continue
            for D in range(F - r4, 1 + P[-1], -r4): 
              if not (d[F - D] == r4): continue
              if not (r2 := d[E - D]) or r2 in {r1, r3, r4, r5}: continue
      
              mx, step = B - r1 if B < D else D - r2, -r1 if B < D else -r2
              for A in range(mx, 0, step): 
                if not (d[B - A] == r1): continue
                if not (d[D - A] == r2): continue
                # infeasible routes
                if d[abs(B - D)]: continue
                if d[F - A]: continue
                sols.add((r1, r2, r3, r4, r5))
                    
      print("answer:", *sols)         
      

      Like

    • Frits's avatar

      Frits 8:53 pm on 25 April 2024 Permalink | Reply

      Another program but now based on differences/multiples.

      # multiples diagram
      #
      # +----------mA_C---------+           r1, prime p1 <= (48 - 5) / 2
      # +-mA_B-+                |           r1
      #        B                |
      #        +-mB_F---+-----mF_E------+   r3, prime p3 <= (48 - 5) / 2
      # +--mA_D--+---------mD_E---------+   r2, prime p2 <= 48 / 2
      # A   -    D  -   F   -   C   -   E  
      #                 +-mF_C--+       |   r4, prime p4 <= (48 - 5 - 7) / 2 
      #          +----mD_C------+       |   r4           
      #                         +-mC_E--+   r5, prime p5 <= 48 - 2*5 - 7 - 1
      
      for mD_E, p2 in [(m, p) for p in [5, 7, 11, 13, 17, 19, 23] 
                       for m in range(1, 7) if 17 <= m * p <= 42 and
                                               (m + 1) * p <= 48]:
        for p4 in [5, 7, 11, 13, 17]:
          if p4 == p2: continue
          for mC_E in range(1, 6 if 5 not in {p2, p4} else 5):
            # p5 >= 5
            for mD_C in range(2, (mD_E * p2 - 5 * mC_E) // p4 + 1):
              p5, r = divmod(mD_E * p2 - mD_C * p4, mC_E)
              if r or p5 not in {5, 7, 11, 13, 17, 19, 23, 29} or p5 in {p2, p4}:
                continue
              # (mA_D + mD_E) * p2 <= 48  
              for mA_D in range(1, (48 // p2) - mD_E + 1):
                # p1 >= 5
                for mA_C in range(2, (mA_D * p2 + mD_C * p4) // 5 + 1):
                  p1, r = divmod(mD_C * p4 + mA_D * p2, mA_C)
                  if r or p1 not in {5, 7, 11, 13, 17, 19} or p1 in {p2, p4, p5}:
                    continue
                  for mF_C in range(1, mD_C):
                    # p3 >= 5
                    for mF_E in range(1, (mF_C * p4 + mC_E * p5) // 5 + 1):
                      p3, r = divmod(mF_C * p4 + mC_E * p5, mF_E)
                      if r or p3 not in {5, 7, 11, 13, 17, 19} or \
                        p3 in {p1, p2, p4, p5}: continue
                      for mB_F in range(1, 4):
                        mA_B, r = divmod((mD_E + mA_D) * p2 - (mB_F + mF_E) * p3, p1)
                        if r or mA_B not in {1, 2, 3}: continue
                        if not (mA_B < mA_C): continue
                        print("answer:", (p1, p2, p3, p4, p5))
      

      Like

  • Unknown's avatar

    Jim Randell 4:45 pm on 15 March 2024 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3208: The scores are all square 

    From The Sunday Times, 17th March 2024 [link] [link]

    For Skaredahora’s quartet four players read the same musical score, but from different compass directions. There are symbols of three types, indicating different whole number beat durations, on a square 17×17 grid. Each player reads the beat position in their left-to-right direction, and pitch in their bottom-to-top.

    Each player plays four notes; South reads a plus at (beat,pitch) position (3,12), a circle at (14,1), a cross at (16,3), and a plus at beat 9. For example, if a cross indicates three beats, South plays a note of pitch 3 at beat 16, which is still sounding at beats 17 and 18, while East plays a note of pitch 2 sounding at beats 3, 4 and 5.

    No player sounds more than one note at the same time. All possible pitch differences between notes sounding simultaneously from different players occur, except zero and exactly one other value.

    Which non-zero pitch difference never occurs? What pitch does South play at beat 9?

    [teaser3208]

     
    • Jim Randell's avatar

      Jim Randell 6:10 pm on 15 March 2024 Permalink | Reply

      This program considers possible positions for the unspecified “+” note, and then constructs the notes heard at each beat for each player and checks the remaining constraints.

      It runs in 59ms. (Internal runtime is 1.8ms).

      Run: [ @replit ]

      from enigma import (irange, inf, tuples, cproduct, subsets, diff, singleton, printf)
      
      # fill out beats for notes in <X>, durations <dd>
      def beats(X, dd):
        bs = [0] * 22
        for (b, p, d) in X:
          for i in irange(dd[d]):
            i += b
            if bs[i] != 0: return
            bs[i] = p
        return bs
      
      # check a set of beats
      def check(bs):
        if None in bs: return
        # find differences between non-zero pitches
        ds = set()
        for ps in zip(*bs):
          ds.update(abs(x - y) for (x, y) in subsets(filter(None, ps), size=2))
        # differences exclude 0 and exactly one other value
        if 0 in ds: return
        return singleton(diff(irange(1, 16), ds))
      
      # duration symbols
      ks = "ox+"
      
      # suppose the unspecified '+' is at pitch <x> for S
      for x in irange(1, 17):
        # construct the notes (<beat>, <pitch>, <duration>) for each player
        S = [(3, 12, '+'), (9, x, '+'), (14, 1, 'o'), (16, 3, 'x')]
        N = list((18 - b, 18 - p, d) for (b, p, d) in S)
        W = list((18 - p, b, d) for (b, p, d) in S)
        E = list((p, 18 - b, d) for (b, p, d) in S)
      
        # determine maximum possible note duration
        dm = dict((k, inf) for k in ks)
        for X in [S, N, W, E]:
          for ((a, _, k), (b, _, _)) in tuples(sorted(X), 2):
            dm[k] = min(dm[k], b - a)
        if 0 in dm.values(): continue
      
        # choose durations
        for ds in cproduct(irange(1, dm[k]) for k in ks):
          if len(set(ds)) != 3: continue
          dd = dict(zip(ks, ds))
      
          # record pitches on each beat
          (bS, bN, bW, bE) = (beats(X, dd) for X in [S, N, W, E])
          k = check((bS, bN, bW, bE))
          if not k: continue
      
          # output solution
          printf("S={bS}", bS=bS[1:])
          printf("N={bN}", bN=bN[1:])
          printf("W={bW}", bW=bW[1:])
          printf("E={bE}", bE=bE[1:])
          printf("x={x} dd={dd} -> k={k} S[9]={S9}", S9=bS[9])
          printf()
      

      Solution: A pitch difference of 4 does not occur. South plays pitch 17 at beat 9.

      The note durations are:

      o = 1 beat
      x = 2 beats
      + = 5 beats

      Here is a diagram of the notes played:

      Like

      • Frits's avatar

        Frits 3:08 pm on 22 March 2024 Permalink | Reply

        @Jim, only thing to improve for a general solution is getting rid of the hardcoded 22 in line 5.

        Like

    • Frits's avatar

      Frits 5:23 pm on 16 March 2024 Permalink | Reply

      Based on part of Jim’s first published program.

       
      from enigma import SubstitutedExpression, subsets
      
      # invalid digit / symbol assignments
      d2i = dict()
      for d in range(0, 10):
        vs = set()
        if d == 0: vs.update('KLM')
        if d > 1: vs.update('U')
        if d > 2: vs.update('KL') # by manual inspection of S resp. N
        if d > 5: vs.update('M')  # by manual inspection of S
        d2i[d] = vs  
      
      # check for 15 different pitch differences (without zero)
      def check(K, L, M, UV):
        S = sorted([(3,      12, M), (9,  UV, M), (14,      1, K), (16,  3, L)])
        E = sorted([(UV,      9, M), (1,   4, K), (3,       2, L), (12, 15, M)])
        N = sorted([(2,      15, L), (4,  17, K), (9, 18 - UV, M), (15,  6, M)])
        W = sorted([(18 - UV, 9, M), (6,   3, M), (15,     16, L), (17, 14, K)])
        d = dict()
        for pl in [S, E, N, W]:
          done = set()  
          for (b, p, s) in pl:
            for j in range(s):
              # no simultaneous notes by the same player
              if (k := b + j - 1) in done: return False
              d[k] = d.get(k, []) + [p]
              done.add(k)
              
        # pitch differences
        diffs = set(abs(p2 - p1) for vs in d.values() for p1, p2 in subsets(vs, 2))
        if 0 in diffs or len(diffs) != 15: return False
        
        return set(range(1, 17)).difference(diffs).pop()
      
      
      # the alphametic puzzle
      p = SubstitutedExpression(
        [ # duration 'o': K, duration 'x': L, duration '+': M
          # the unspecified pitch
          "0 < UV < 18",
          # main checks
          "(missing := check(K, L, M, UV)) != 0",
        ],
        answer="(K, L, M, UV, missing)",
        d2i=d2i,
        # different whole number beat durations
        distinct=("KLM"),
        env=dict(check=check),
        verbose=0,    # use 256 to see the generated code
      ) 
      
      # print answers
      for ans in p.answers():
        print(f"{ans}")
        print("answer: pitch difference that never occurs:", ans[4])
        print("        South plays pitch", ans[3], "at beat 9")    
      

      Like

  • Unknown's avatar

    Jim Randell 4:39 pm on 26 January 2024 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3201: Spare routes 

    From The Sunday Times, 27th January 2024 [link] [link]

    Three companies run scenic coach trips in both directions between some pairs of towns in my holiday destination. Each route between two towns is served by just one company, but has one or more alternatives via another town. In particular, every Redstart route could be replaced by a unique combination of two Bluetail routes, every Bluetail by a unique combination of two Greenfinches, and every Greenfinch by a unique combination of a Redstart and a Greenfinch. This wouldn’t be possible with fewer towns or fewer routes.

    I toured one route each day, starting from the town I’d arrived at the previous day but changing the company, never repeating a route in either direction, and involving as many of the routes as I could.

    Which companies did I use, in order (as initials, e.g., R, B, G, B)?

    [teaser3201]

     
    • Jim Randell's avatar

      Jim Randell 8:51 pm on 26 January 2024 Permalink | Reply

      There is probably some (isomorphic) duplication in the minimal graphs found, and this is not an efficient approach for larger graphs (but fortunately the minimal graphs found are quite small).

      Run: [ @replit ]

      from enigma import (Accumulator, irange, inf, subsets, ordered, singleton, join, map2str, printf)
      
      # find routes between x and y via z
      def route(m, x, y, zs, ss):
        for z in zs:
          if z == x or z == y: continue
          if ordered(m.get(ordered(x, z), 'X'), m.get(ordered(y, z), 'X')) == ss:
            yield z
      
      # requirements: colour -> replacements
      reqs = {
        'R': ('B', 'B'),  # R can be replaced by B+B
        'B': ('G', 'G'),  # B can be replaced by G+G
        'G': ('G', 'R'),  # G can be replaced by G+R
      }
      
      # check the requirements on map <m> with nodes <ns>
      def check(m, ns):
        vs = set()
        for ((x, y), v) in m.items():
          if v != 'X': vs.update((x, y))
          ss = reqs.get(v)
          # look for unique replacement
          if ss and singleton(route(m, x, y, ns, ss)) is None: return False
        # check all nodes are non-trivial
        return vs.issuperset(ns)
      
      # find paths on map <m> without repeated edges, and different colours on adjacent edges
      # vs = visited vertices
      # ks = visited edges
      def paths(m, vs=list(), ks=list()):
        if ks and ks[0] < ks[-1]: yield (vs, ks)
        # can we extend the path?
        for (k, v) in m.items():
          if v == 'X': continue
          if ks and (k in ks or v == m[ks[-1]]): continue
          if k[0] == vs[-1]:
            yield from paths(m, vs + [k[1]], ks + [k])
          if k[1] == vs[-1]:
            yield from paths(m, vs + [k[0]], ks + [k])
      
      # find minimal graphs (by (number of nodes, number of edges))
      graphs = Accumulator(fn=min, collect=1)
      
      # consider increasing numbers of towns (at least 4)
      for n in irange(4, inf):
        # vertices (= towns)
        ns = list(irange(n))
        # edges (= routes)
        rs = list(subsets(ns, size=2))
      
        # assign colours to routes ('X' = no route)
        for ss in subsets("RBGX", size=len(rs) - 1, select='M', fn=list):
          # check minimum number of routes
          if ss.count('B') < 2: continue
          if ss.count('G') < 3: continue
          ss.insert(0, 'R')
      
          # make the map
          m = dict(zip(rs, ss))
          # check requirements
          if check(m, ns):
            # record (number of nodes, number of edges)
            graphs.accumulate_data((n, len(ss) - ss.count('X')), m)
      
        if graphs.data: break  # we only need the minimal number of vertices
      
      # collect answers
      ans = set()
      
      # process minimal graphs
      (n, e) = graphs.value
      ns = list(irange(n))
      printf("minimal graph has {n} vertices, {e} edges")
      
      for m in graphs.data:
        printf("-> {m}", m=map2str(m))
      
        # collect maximal paths on this map
        r = Accumulator(fn=max, collect=1)
        for v in ns:
          for (vs, ks) in paths(m, [v]):
            r.accumulate_data(len(ks), (vs, ks))
      
        printf("max path len = {r.value}")
        # output paths
        for (vs, ks) in r.data:
          ss = join(m[k] for k in ks)
          printf("-> {vs} {ss}")
          ans.add(ss)
        printf()
      
      # output solution
      printf("tour = {t}", t=join(ans, sep=", "))
      

      Solution: The companies used are: G, B, R, B, R, B, G.

      A minimal graph has 5 vertices and 9 edges:

      The program finds 12 graphs in total, but they are all isomorphic to the one above.

      We see that node 4 only has green edges (and all green edges lead to 4), so that can only act as the start/end of the path, and we can use a maximum of 7 of the 9 edges. Each path can be traversed in both directions, so there are essentially 3 different maximal paths that can be taken. And the fact that puzzle should have a unique solutions implies the answer should be palindromic (otherwise the reverse would also be a valid answer), even though the example given in the puzzle text is not.

      Liked by 1 person

      • Jim Randell's avatar

        Jim Randell 10:15 am on 27 January 2024 Permalink | Reply

        I augmented my code to detect isomorphic graphs [@replit], and there is essentially only one viable minimal layout.

        from enigma import (Accumulator, irange, inf, subsets, ordered, singleton, join, map2str, printf)
        
        # find routes between x and y via z
        def route(m, x, y, zs, ss):
          for z in zs:
            if z == x or z == y: continue
            if ordered(m.get(ordered(x, z), 'X'), m.get(ordered(y, z), 'X')) == ss:
              yield z
        
        # requirements: colour -> replacements
        reqs = {
          'R': ('B', 'B'),  # R can be replaced by B+B
          'B': ('G', 'G'),  # B can be replaced by G+G
          'G': ('G', 'R'),  # G can be replaced by G+R
        }
        
        # check the requirements on map <m> with nodes <ns>
        def check(m, ns):
          vs = set()
          for ((x, y), v) in m.items():
            if v != 'X': vs.update((x, y))
            ss = reqs.get(v)
            # look for unique replacement
            if ss and singleton(route(m, x, y, ns, ss)) is None: return False
          # check all nodes are non-trivial
          return vs.issuperset(ns)
        
        # find paths on map <m> without repeated edges, and different colours on adjacent edges
        # vs = visited vertices
        # ks = visited edges
        def paths(m, vs=list(), ks=list()):
          if ks and ks[0] < ks[-1]: yield (vs, ks)
          # can we extend the path?
          for (k, v) in m.items():
            if v == 'X': continue
            if ks and (k in ks or v == m[ks[-1]]): continue
            if k[0] == vs[-1]:
              yield from paths(m, vs + [k[1]], ks + [k])
            if k[1] == vs[-1]:
              yield from paths(m, vs + [k[0]], ks + [k])
        
        # check 2 graphs for isomorphism
        def is_isomorphic(vs, m1, m2):
          # choose a mapping of vs
          for ss in subsets(vs, size=len, select='P'):
            # remap m1
            m3 = dict((ordered(ss[x], ss[y]), v) for ((x, y), v) in m1.items())
            if m3 == m2: return True
          return False
        
        # find minimal graphs
        graphs = Accumulator(fn=min, collect=1)
        
        # consider increasing numbers of towns (at least 4)
        for n in irange(4, inf):
          # vertices (= towns)
          ns = list(irange(n))
          # edges (= routes)
          rs = list(subsets(ns, size=2))
        
          # assign colours to routes ('X' = no route)
          for ss in subsets("RBGX", size=len(rs) - 1, select='M', fn=list):
            # check minimum number of routes
            (nR, nB, nG) = (ss.count('R') + 1, ss.count('B'), ss.count('G'))
            if nB < 2 or nG < 3 or nB < nR or nG < nB: continue
            ss.insert(0, 'R')
        
            # make the map
            m = dict(zip(rs, ss))
            # check requirements
            if check(m, ns):
              # record (number of nodes, number of edges)
              graphs.accumulate_data((n, len(ss) - ss.count('X')), m)
        
          if graphs.data: break  # we only need the minimal number of vertices
        
        # collect answers
        ans = set()
        morphs = list()
        
        # process minimal graphs
        (n, e) = graphs.value
        ns = list(irange(n))
        printf("minimal graph has {n} vertices, {e} edges")
        
        for m in graphs.data:
          printf("-> {m}", m=map2str(m))
        
          # check morphs
          if any(is_isomorphic(ns, m, m1) for (j, m1) in enumerate(morphs)): continue
          printf("= isomorph {n}", n=len(morphs))
          morphs.append(m)
        
          # collect maximal paths on this map
          r = Accumulator(fn=max, collect=1)
          for v in ns:
            for (vs, ks) in paths(m, [v]):
              r.accumulate_data(len(ks), (vs, ks))
        
          printf("max path len = {r.value}")
          # output paths
          for (vs, ks) in r.data:
            ss = join(m[k] for k in ks)
            printf("-> {vs} {ss}")
            ans.add(ss)
          printf()
        
        # output solution
        printf("tour = {t}", t=join(ans, sep=", "))
        

        Like

    • Frits's avatar

      Frits 7:42 am on 27 January 2024 Permalink | Reply

      Similar, the tour also finishes where it started.
      The generated routes and/or tours may contain duplicates.

        
      from itertools import product
      
      # is route <k> doable by a unique combination via two routes of types <tps> 
      def check_alt(m, n, k, tps):
        rem_towns = [i for i in range(n) if i not in k]
        alt = [a for a in rem_towns 
               if {m[tuple(sorted([a, x]))] for x in k} == tps]
        return len(alt) == 1
      
      def check(m, n):
        d = dict(zip(["R", "B", "G"], [{"B"}, {"G"}, {"R", "G"}]))
       
        # check for unique alternative combinations
        for k, v in m.items():
          if v in "RBG":
            if not check_alt(m, n, k, d[v]): return False
        return True    
              
      # generate routes that meet the requirements
      def gen_routes(n):
        # routes
        rs = [(i, j) for i in range(n - 1) for j in range(i + 1, n)]
      
        # assign company to a route (or not), (blank = no route)
        for cs in product("RBG ", repeat = n * (n - 1) // 2 - 1):
          cs = ("R", ) + cs
          # R < B < G
          if (cntR := cs.count('R')) >= (cntB := cs.count('B')): continue
          if cntB >= (cntR := cs.count('G')): continue
          
          # check requirements
          if check(d := dict(zip(rs, cs)), n):
            yield d
      
      # add route to tour
      def add_route(m, ss=[]):
        if not ss: # start
          for k, v in m.items():
            yield from add_route(m, ss + [(k, v)])
        else:
          yield ss
          # find a new unused route for a different company from the last
          rt, tp = ss[-1]
          for k, v in m.items():
            if v == tp: continue 
            # already added?
            if (k, v) in ss or (k[::-1], v) in ss: continue
            if k[0] == rt[1]:
              yield from add_route(m, ss + [(k, v)]) 
            if k[1] == rt[1]:
              yield from add_route(m, ss + [(k[::-1], v)])    
      
      n = 4 # we need at least 6 routes (1 R, 2 B, 3 G)
      # increase number of towns until all alternative routes can be made
      while True:
        sols = set()
        mx = 0
        
        # generate valid routes
        for r in gen_routes(n):
          # find longest tour
          mx = 0
          for tour in add_route({k: v for k, v in r.items() if v in "RGB"}):
            lt = len(tour)
            if lt >= mx:
              if lt > mx:
                sols = set() 
                mx = lt
              # finish where we have started (is this a feature of a tour?)    
              if tour[0][0][0] != tour[-1][0][1]: continue    
              sols.add(''.join(t for _, t in tour))
            
        if mx:
          print(f"answer: {' or '.join(sols)}") 
          break 
        else:
          n += 1
      

      Like

      • Jim Randell's avatar

        Jim Randell 11:35 am on 31 January 2024 Permalink | Reply

        @Frits: I don’t think it is required for the tour to start and finish at the same town. (Certainly this is not explicitly stated).

        Also, how does your program ensure that the condition “this wouldn’t be possible with … fewer routes” is met?

        Like

    • Frits's avatar

      Frits 3:55 am on 1 February 2024 Permalink | Reply

      Without demanding for cyclic tours and with minimal routes.
      Program is also running a little bit faster (due to the extra “cntR” condition).

       
      from itertools import product
      
      # is route <k> feasible by a unique combination via two routes of types <tps> 
      def check_alt(m, n, k, tps):
        alt = [a for a in [i for i in range(n) if i not in k]
               if {m[(a, x) if a < x else (x, a)] for x in k} == tps]
        return len(alt) == 1
      
      # perform checks for all routes
      def check(m, n):
        d = dict(zip(["R", "B", "G"], [{"B", "B"}, {"G", "G"}, {"R", "G"}]))
       
        # check for unique alternative combinations
        for r, c in m.items():
          if c in "RBG":
            if not check_alt(m, n, r, d[c]): return False
        return True    
              
      # generate routes that meet the requirements
      def gen_routes(n):
        # routes
        rs = [(i, j) for i in range(n - 1) for j in range(i + 1, n)]
      
        # assign company to a route (or not), (blank = no route)
        for cs in product("RBG ", repeat = n * (n - 1) // 2 - 1):
          cs = ("R", ) + cs
          # nR < nB < nG
          # 3 * r + 3 <= n * (n - 1) // 2
          if not ((cntR := cs.count('R')) <= (n * (n - 1) - 6) // 6): continue
          
          if not (cntR < cs.count('B') < cs.count('G')): continue
          # check requirements
          if check(d := dict(zip(rs, cs)), n):
            yield d
      
      # generate all valid tours (credit: John Z)
      def tours(m, prevt, prevc="x", rts = []):
        
        # consider next town to visit
        for nt in set(range(n)) - {prevt}:
          
          route = (prevt, nt) if prevt < nt else (nt, prevt) 
          if route not in rts and m[route] not in {" ", prevc}:
            yield from tours(m, nt, m[route], rts + [route])
      
        # any route is linked to 2 other routes
        if len(rts) > 2:
         yield ''.join(m[x] for x in rts)
      
      n = 4 # we need at least 6 routes (1 R, 2 B, 3 G)
      # increase number of towns until all alternative routes can be made
      while True:
        mrts = dict()  
        # generate valid routes and calculate number of blank routes
        for r in gen_routes(n):
          mrts[brts] = mrts.get(brts := list(r.values()).count(" "), []) + [r]
          
        if mrts:
          trs = set()
          # get minimimal routes by maximising the number of blank routes
          for r in mrts[max(mrts.keys())]:
            # find all valid tours
            trs |= set(tr for twn in range(n) for tr in tours(r, twn))
      
          print("answer:", ' or '.join([tr for tr in trs 
                                        if len(tr) == len(max(trs, key=len))]))
          break 
        else:
          n += 1   
      

      Like

  • Unknown's avatar

    Jim Randell 4:46 pm on 24 November 2023 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3192: In formation technology 

    From The Sunday Times, 26th November 2023 [link] [link]

    Football formations are generally described by three or four nonzero whole numbers summing to 10 (the goalkeeper isn’t counted), representing, from defence to attack, the number of players in approximate lines across the pitch.

    Last season we played a different formation every week, always using four lines, each with at most four players; the difference between one week and the next was that from one line two players moved, one to an adjacent line and the other to the line beyond that (e.g. 3-4-1-2 could only be followed by 3-2-2-3). Our number of fixtures was the largest it could have been, given these conditions. The first number in our formations was more often 3 than any other number; 3-1-3-3 gave us our worst result.

    How many games did we play, and what were our first three formations?

    [teaser3192]

     
    • Jim Randell's avatar

      Jim Randell 5:38 pm on 24 November 2023 Permalink | Reply

      This Python program runs in 68ms. (Internal runtime is 2.5ms).

      Run: [ @replit ]

      from enigma import (Accumulator, decompose, update, multiset, join, printf)
      
      # find possible formations
      fs = list(decompose(10, 4, increasing=0, sep=0, min_v=1, max_v=4))
      
      # find possible transitions
      trans = dict()
      for s in fs:
        ts = set()
        # choose an index to donate 2 players
        for (i, n) in enumerate(s):
          if n < 3: continue
          # i -> j, k
          for d in [-1, +1]:
            j = i + d
            if j < 0 or j > 3 or s[j] > 3: continue
            k = j + d
            if k < 0 or k > 3 or s[k] > 3: continue
            ts.add(update(s, [(i, n - 2), (j, s[j] + 1), (k, s[k] + 1)]))
        trans[s] = ts
      
      # find paths in graph <adj>
      def paths(adj, ss):
        yield ss
        # try to extend the path
        s = ss[-1]
        for t in adj[s]:
          if t not in ss:
            yield from paths(adj, ss + [t])
      
      # find maximal length paths
      r = Accumulator(fn=max, collect=1)
      for s in fs:
        for ss in paths(trans, [s]):
          r.accumulate_data(len(ss), ss)
      printf("max len = {r.value}")
      
      # consider maximal length paths
      for ss in r.data:
        # 3-1-3-3 must be present
        if (3, 1, 3, 3) not in ss: continue
        # 3 occurs most frequently as the first number
        m = multiset.from_seq(s[0] for s in ss)
        n3 = m.count(3)
        if not all(n3 > nk for (k, nk) in m.items() if k != 3): continue
        # output solution
        fmt = lambda x: join(x, sep='-')
        printf("{ss}", ss=join((fmt(x) for x in ss), sep=", ", enc="()"))
      

      Solution: There were 13 games. The first three formations were: 3-2-4-1, 4-3-2-1, 4-1-3-2.

      The full list of formations is:

      1: 3-2-4-1
      2: 4-3-2-1
      3: 4-1-3-2
      4: 2-2-4-2
      5: 3-3-2-2
      6: 3-1-3-3
      7: 4-2-1-3
      8: 2-3-2-3
      9: 2-1-3-4
      10: 3-2-1-4
      11: 1-3-2-4
      12: 1-4-3-2
      13: 1-2-4-3

      There are 4 weeks that begin with 3-, and 3 weeks that each start 1-, 2-, 4-.

      Like

      • Frits's avatar

        Frits 5:05 pm on 1 December 2023 Permalink | Reply

        @Jim,

        I noticed that you don’t see (among others ) 4-1-3-2 to 2-2-3-3 as a valid transition. I don’t consider “to the line beyond that” as the immediate adjacent line.

        Like

        • Jim Randell's avatar

          Jim Randell 5:12 pm on 1 December 2023 Permalink | Reply

          @Frits: Yes, it has to be adjacent (it is “the line beyond” not “a line beyond”). It would probably have been better expressed as “the next line beyond”.

          I think there are many solutions to the puzzle where you allow transfers to non-adjacent lines. (I originally tried it with this interpretation).

          And welcome back, it has been a while.

          Like

  • Unknown's avatar

    Jim Randell 4:45 pm on 15 September 2023 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3182: Stand and be counted 

    From The Sunday Times, 17th September 2023 [link] [link]

    When bad light ended the first day’s play, the total score was 182 with three batters having been “out” during the day.

    In the successive stands, the lower-numbered and the higher-numbered batters scored in the ratios 7:3, 10:7, 1:11 and 11:19, in that order. The total score was the sum of the scores of the five batters.

    The third batter was the highest scorer.

    [In cricket the batting team starts with batters 1 and 2 together, and each is able to score. When one of them is “out” the third batter comes “in” to replace them, when one of that pair is out the fourth batter comes in, and so on. The amount by which the team score has increased while a particular pair is together is called the “stand”].

    What were the scores of all the batters, in their batting order?

    [teaser3182]

     
    • Jim Randell's avatar

      Jim Randell 5:14 pm on 15 September 2023 Permalink | Reply

      This Python program works through each partnership, considering stands of the specified ratios.

      It runs in 60ms. (Internal runtime is 1.5ms).

      Run: [ @replit ]

      from enigma import (irange, inf, div, map2str, printf)
      
      # total score
      T = 182
      
      # add deltas <ds> to keys <ks> of dict <d>
      def add(d, ks, ds):
        d = dict(d)
        for (k, i) in zip(ks, ds):
          d[k] += i
        return d
      
      # n = number of next bat
      # A = current lower numbered bat
      # B = current higher numbered bat
      # rs = remaining stand ratios
      # ss = current scores per bat
      # t = total runs scored
      def solve(n, A, B, rs, ss, t):
        # choose scores for this stand
        (a, b) = rs[0]
        # remaining ratios
        rs_ = rs[1:]
        if rs_:
          # minimum remaining total
          rt = sum(a + b for (a, b) in rs_)
          # consider possible multipliers for this stand
          for k in irange(1, inf):
            (rA, rB) = (a * k, b * k)
            t_ = t + rA + rB
            if t_ + rt > T: break
            ss_ = add(ss, [A, B], [rA, rB])
            # each batsman may be dismissed
            yield from solve(n + 1, A, n, rs_, ss_, t_)
            yield from solve(n + 1, B, n, rs_, ss_, t_)
        else:
          # can we achieve the total?
          k = div(T - t, a + b)
          if k is not None:
            yield add(ss, [A, B], [a * k, b * k])
      
      rs = [(7, 3), (10, 7), (1, 11), (11, 19)]  # stand ratios
      ss = dict((k, 0) for k in irange(1, 5))  # scores
      
      # find totals for each batsman
      for ss in solve(3, 1, 2, rs, ss, 0):
        # batsman 3 scored the most
        if max(ss.values()) != ss[3]: continue
        # output solution
        printf("{ss}", ss=map2str(ss, sep=" ", enc=""))
      

      Solution: The totals are: bat 1 = 21; bat 2 = 49; bat 3 = 52; bat 4 = 22; bat 5 = 38.

      The stands are:

      1: bat 1 = 21; bat 2 = 9; ratio = 7:3; bat 1 dismissed
      2: bat 2 = 40; bat 3 = 28; ratio = 10:7; bat 2 dismissed
      3: bat 3 = 2; bat 4 = 22; ratio = 1:11; bat 4 dismissed
      4: bat 3 = 22; bat 5 = 38; ratio = 11:19; total = 182 runs

      Like

      • Jim Randell's avatar

        Jim Randell 7:18 pm on 15 September 2023 Permalink | Reply

        Here’s an alternative (faster) version.

        We can generate possible batting pairings for each stand. And the denominators of the ratios are all different, so we can use the [[ express() ]] function to determine the multipliers for each stand.

        We then use the ratios and the multiplies to calculate the scores for each batsman.

        This Python program runs in 58ms, and has an internal runtime of 386µs.

        from enigma import (irange, subsets, express, map2str, printf)
        
        # generate possible pairings
        def _pairs(k, A, B, n, ds=[]):
          ds = ds + [(A, B)]
          # are we done?
          if k < 1:
            yield ds
          else:
            # one of A and B is dismissed
            yield from _pairs(k - 1, B, n, n + 1, ds)
            yield from _pairs(k - 1, A, n, n + 1, ds)
        
        pairs = list(_pairs(3, 1, 2, 3))
        
        # calculate scores with a sequence of multipliers and a sequence of pairs
        def scores(ks, ps):
          ss = dict((i, 0) for i in irange(1, 5))
          for (k, (a, b), (x, y)) in zip(ks, ps, [(7, 3), (10, 7), (1, 11), (11, 19)]):
            ss[a] += k * x
            ss[b] += k * y
          return ss
        
        # generate possible multipliers for each stand
        for (k1, k3, k2, k4) in express(182, [10, 12, 17, 30], min_q=1):
          for ps in pairs:
            ss = scores((k1, k2, k3, k4), ps)
            if ss[3] == max(ss.values()):
              printf("{ss}", ss=map2str(ss, sep=" ", enc=""))
        

        Like

    • Frits's avatar

      Frits 6:01 pm on 15 September 2023 Permalink | Reply

      from enigma import SubstitutedExpression
      
      # the alphametic puzzle
      p = SubstitutedExpression(
        [
          # 10 * W + 17 * X + 12 * Y + 30 * Z = total score 182
          "div(182 - (10 * W + 17 * X + 12 * Y), 30) == Z",
          
          # who whon the first session
          "F < 3",
          # who whon the third session
          "2 < T < 5",
          
          # third batter must have won in 2nd session
          
          # possible score for the first  batter 7 * W + (F == 1) * 10 * X
          # possible score for the second batter 3 * W + (F == 2) * 10 * X 
          # possible score for the third  batter 7 * X + Y + 11 * (T == 3) * Z
          # possible score for the fourth batter 11 * Y + (T == 4) * 11 * Z
          #          score for the fifth  batter 19 * Z
          
          # the third batter was the highest scorer
          "max(sc := [7 * W + (F == 1) * 10 * X, \
                      3 * W + (F == 2) * 10 * X , \
                      7 * X + Y + 11 * (T == 3) * Z, \
                      11 * Y + (T == 4) * 11 * Z, \
                      19 * Z]) == sc[2]", 
        ],
        answer="sc",
        distinct="",
        digits=range(1, 10),
        reorder=0,
        verbose=0,    # use 256 to see the generated code
      )
      
      # print answers
      for ans in p.answers():
        print(f"answer: {ans}")   
      

      Like

      • Frits's avatar

        Frits 6:17 pm on 15 September 2023 Permalink | Reply

        @Jim, Thank you for leaving the easy solution (SubstitutedExpression) for me.

        Like

      • Frits's avatar

        Frits 6:30 pm on 15 September 2023 Permalink | Reply

        Theoretically I should have used base=13 and digits=range(1, 13) to allow for high W and Y’s.

        Like

    • Frits's avatar

      Frits 11:49 am on 16 September 2023 Permalink | Reply

      A more general version without analysis.

      from enigma import SubstitutedExpression
      
      # ratio sums
      rs = [10, 17, 12, 30]
      multipliers = "WXYZ"
      
      # maxima per multiplier
      d = {multipliers[i]: (182 - sum(rs) + rs[i]) // rs[i] for i in range(4)}    
      mx = max(d.values())
      
      # invalid digit / symbol assignments
      d2i = {n: set(k for k, v in d.items() if n > v) for n in range(1, mx + 1)} 
      # F/S/T were not "out" in the 1st/2nd/3rd play
      for i, v in enumerate("FST"):
        for j in range(3 + i, mx + 1):
          d2i[j].add(v)
      
      # the alphametic puzzle, use multipliers W, X, Y and Z
      p = SubstitutedExpression(
        [ 
          # 10 * W + 17 * X + 12 * Y + 30 * Z = total score 182
          "div(182 - (17 * X + 12 * Y + 30 * Z), 10) = W",
          
          # S wasn't "out" in the second play
          "S in {F, 3}",
          # T wasn't "out" in the third play
          "T in {S, 4}",
          
          # score for the first  batter 7W + (F == 1).10X + (S == 1).Y + (T == 1).11Z
          # score for the second batter 3W + (F == 2).10X + (S == 2).Y + (T == 2).11Z
          # score for the third  batter                7X + (S == 3).Y + (T == 3).11Z
          # score for the fourth batter                            11Y + (T == 4).11Z
          # score for the fifth  batter                                           19Z
          
          # the third batter was the highest scorer
          "max(sc := [7 * W + (F == 1) * 10 * X + (S == 1) * Y + (T == 1) * 11 * Z, \
                      3 * W + (F == 2) * 10 * X + (S == 2) * Y + (T == 2) * 11 * Z, \
                                          7 * X + (S == 3) * Y + (T == 3) * 11 * Z, \
                                                        11 * Y + (T == 4) * 11 * Z, \
                                                                            19 * Z])\
               == sc[2]", 
        ],
        answer="sc",
        d2i=d2i,
        distinct="",
        base=mx + 1,
        digits=range(1, mx + 1),
        reorder=0,
        verbose=0,    # use 256 to see the generated code
      )
      
      # print answers
      for ans in p.answers():
        print(f"answer: {ans}")   
      

      Like

    • Jim Olson's avatar

      Jim Olson 3:46 am on 26 September 2023 Permalink | Reply

      What is the correct answer?

      Like

  • Unknown's avatar

    Jim Randell 4:31 pm on 9 June 2023 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3168: Number cruncher 

    From The Sunday Times, 11th June 2023 [link] [link]

    The gaslight glittered on the polished brass of the machine. “My Number Cruncher can calculate mechanically the result of a calculation on three positive whole numbers used once each, provided it is restricted to plus, times and brackets operations, declared the Engineer. He opened the delicate cover and made meticulous adjustments. “There, I have disposed it for one particular expression. Please put it to the test”.

    I chose three numbers, all greater than 1, and rotated the three dials to those positions. Then I cranked the handle until a delicate bell rang, and the result was indicated as 451. I made one more try, using the same three numbers although in a different order; this time the machine yielded 331.

    In ascending order, what were the three numbers I selected?

    [teaser3168]

     
    • Jim Randell's avatar

      Jim Randell 4:59 pm on 9 June 2023 Permalink | Reply

      Here is a generic solution:

      Each operation combines two numbers, so if we start with three numbers and end with one number there must be two operations involved.

      And if we get different results by reordering the numbers then the operations must be different.

      So we are looking at one of the following expressions:

      (a + b) × c
      (a × b) + c

      So we can start with the output number and try reversing one of the operations to give us two numbers, and then choose one of these numbers, reverse the other operation on it, and we end up with the three original numbers.

      We then need to find which of the expressions gives a sequence of input numbers that are common to both the output numbers.

      This Python program runs in 63ms. (Internal runtime is 4.7ms).

      Run: [ @replit ]

      from enigma import (irange, divisors_pairs, intersect, printf)
      
      # destruct an addition
      def destruct_add(n):
        for a in irange(2, n // 2):
          yield [a, n - a]
      
      # destruct a multiplication
      def destruct_mul(n):
        for (a, b) in divisors_pairs(n):
          if a > 1:
            yield [a, b]
      
      # find sets that can give the result <n> using operations <ops>
      def destruct(ns, ops):
        # are we done?
        if not ops:
          yield tuple(sorted(ns))
        else:
          op = ops[0]
          # choose one of the numbers to destruct
          for (i, x) in enumerate(ns):
            for xs in op(x):
              yield from destruct(ns[:i] + xs + ns[i + 1:], ops[1:])
      
      # numbers to solve
      ns = [451, 331]
      
      # consider possible operation orders
      exprs = {
        '(a * b) + c': (destruct_add, destruct_mul),
        '(a + b) * c': (destruct_mul, destruct_add),
      }
      for (k, ops) in exprs.items():
        # find input numbers in all sets
        for ss in intersect(destruct([n], ops) for n in ns):
          printf("{ns} -> {ss} [{k}]")
      

      Solution: The numbers are: 16, 19, 27.

      So the machine was set to calculate:

      (a × b) + c → result

      And the calculations were:

      a=16, b=27, c=19 → result = (16 × 27) + 19 = 451
      a=16, b=19, c=27 → result = (16 × 19) + 27 = 331

      Like

      • Jim Randell's avatar

        Jim Randell 10:47 pm on 9 June 2023 Permalink | Reply

        Or we can solve the specific problem from the puzzle text in a much faster way:

        (a × b) + c = x
        (a × c) + b = y

        (a + 1)(b + c) = x + y

        This Python program runs in 57ms. (Internal runtime is 98µs).

        Run: [ @replit ]

        from enigma import (irange, divisors_pairs, divf, divc, ordered, printf)
        
        (x, y) = (451, 331)
        for (p, q) in divisors_pairs(x + y, every=1):
          if p < 3: continue
          a = p - 1
          for c in irange(divc(y - q + 2, a), divf(y - 2, a)):
            b = q - c
            if a * b + c == x and a * c + b == y:
              printf("({x}, {y}) -> {ns}", ns=ordered(a, b, c))
        

        And this approach can be used to give a manual solution where there are only a few cases to consider.

        (But I still like the generic solution).


        Manually:

        331 is prime, so the expression cannot be of the form (a + b) × c, so we must be looking at (a × b) + c.

        So we need to find a, b, c such that:

        (a × b) + c = 451
        (a × c) + b = 331

        Adding these equations gives:

        (a + 1)(b + c) = 782

        And if we have:

        782 = p × q
        ⇒ a = p − 1; c = (331 − q) / (a − 1); b = q − c

        So we can examine the divisors of 782.

        There are only four possible decompositions: 1 × 782, 2 × 391, 17 × 46, 23 × 24.

        Hence a ∈ {16, 22, 33, 45}.

        And only a = 16 gives integer values for b and c.

        782 = 17 × 46 ⇒ a = 16; c = 19; b = 27

        Like

        • Frits's avatar

          Frits 11:06 pm on 9 June 2023 Permalink | Reply

          Nice, we already know that the first 2 divisors pairs can be ignored.

          Like

        • Frits's avatar

          Frits 11:05 pm on 10 June 2023 Permalink | Reply

          @Jim, I tried to understand the boundaries of your latest version and saw that the minimum for variable c wasn’t tight enough for the first value of a.

          We don’t have to loop over c as we can state:

          c = (y – q) / (a – 1)

          which has to be a whole number greater than 1

          Like

          • Jim Randell's avatar

            Jim Randell 11:36 pm on 10 June 2023 Permalink | Reply

            @Frits: That is cool. Makes things even simpler, and saves a few more µs.

            from enigma import (divisors_pairs, div, ordered, printf)
            
            (x, y) = (451, 331)
            
            for (p, q) in divisors_pairs(x + y, every=1):
              if p < 3: continue
              a = p - 1
              c = div(y - q, a - 1)
              if c is None or c < 2: continue
              b = q - c
              if b < 2: continue
              printf("({x}, {y}) -> {ns}", ns=ordered(a, b, c))
            

            Like

    • Frits's avatar

      Frits 6:37 pm on 9 June 2023 Permalink | Reply

      Less pythonic but more chance to optimise.

         
      from enigma import divisor_pairs
      
      # determine lowest number to minimize number of iterations
      nums = sorted([451, 331])
      
      # a + b + c and a * b * c are not possible (nothing changes)
      # (a + b) * c is not possibe as 331 is a prime number
      
      # check a + (b * c)
      for a in range(2, nums[0] - 3):
        # ignore divisor_pair with 1
        for b, c in list(divisor_pairs(nums[0] - a))[1:]:
          # check 2 options
          if nums[1] in {b + (a * c), c + (b * a)}:
            pr("answer", sorted([a, b, c]))
      

      Like

      • Frits's avatar

        Frits 10:48 pm on 9 June 2023 Permalink | Reply

        More complex but a little different.

           
        from enigma import is_square, div
        
        # determine lowest number to minimise number of iterations
        nums = sorted([451, 331])
        
        # a + b + c and a * b * c are not possible (nothing changes)
        # (a + b) * c is not possibe as 331 is a prime number
        
        
        # check a + (b * c)
        
        # I: a + b.c = 331, b + a.c = 451
        #    a + b = 331 - 120 * b / (b - a)
        
        for a in range(2, nums[0] - 3):
          for b in range(2, int((nums[0] - a) / 2) + 1):
            if b == a: continue
            if a + b != 331 - (120 * b) / (b - a): continue
            
            c, r = divmod(nums[1] - b, a)
            if r or c < 2: continue
            
            # double check 
            if a + b * c == nums[0]:
              pr("answer", sorted([a, b, c]))
            
        # II: a + b.c = 331, c + a.b = 451
        #     a.b^2 - 451b + 331 - a = 0
        #     4a^2 - 1324a + 203401 must be square
        for a in range(2, nums[0] - 3):
          D = 4 * a**2 - 1324 * a + 203401
          D = is_square(D) 
          if D is None: continue 
          
          for b in [d for i in (-1, 1) if (d := div(451 + i * D, 2 * a))]:
            c = nums[1] - a * b
            
            # double check 
            if c > 1 and a + b * c == nums[0]:
              pr("answer", sorted([a, b, c]))
        

        Like

  • Unknown's avatar

    Jim Randell 4:14 pm on 21 April 2023 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3161: Going through the hoops 

    From The Sunday Times, 23rd April 2023 [link] [link]

    I arrived very late to watch the croquet game. Someone had listed the times when the four balls (blue, black, red and yellow) had run through hoops 1 to 12; none had yet hit the central peg to finish. Blue had run each hoop earlier than black, and every hoop had been run by colours in a different order. The only change in running order from one hoop to the next-numbered hoop was that two colours swapped positions. These swapping pairs (to obtain the order for the next-numbered hoop) were in non-adjacent positions for hoops 5 and 10, but no others. For one particular colour, all the hoops where it passed through earlier than the yellow were before all those where it passed through later than the yellow.

    In what order had the balls run the twelfth hoop?

    [teaser3161]

     
    • Jim Randell's avatar

      Jim Randell 4:44 pm on 21 April 2023 Permalink | Reply

      This Python program runs in 60ms. (Internal runtime is 4.9ms).

      Run: [ @replit ]

      from enigma import (subsets, update, join, filter2, irange, printf)
      
      # the colours (K = black)
      colours = "BKRY"
      
      # x before y
      before = lambda s, x, y: s.index(x) < s.index(y)
      
      # is there a colour where all "before yellow" < all "after yellow"
      def check(ss, n):
        for k in colours:
          if k == "Y": continue
          (bY, aY) = filter2((lambda i: before(ss[i], k, 'Y')), irange(n))
          if bY[-1] < aY[0]: return True
        return False
      
      # solve the puzzle
      def solve(ss):
        n = len(ss)
        # are we done?
        if n == 12:
          if check(ss, n):
            yield ss
        else:
          # choose 2 indices to swap
          s = ss[-1]
          for ((i, x), (j, y)) in subsets(enumerate(s), size=2):
            if (j == i + 1) == (n in {5, 10}): continue
            s_ = update(s, [(i, y), (j, x)])
            # blue before black, and no repeats
            if before(s_, 'B', 'K') and s_ not in ss:
              # move on to the next hoop
              yield from solve(ss + [s_])
      
      # choose an order for the first hoop, with blue before black
      for s in subsets(colours, size=len, select='P', fn=join):
        if not before(s, 'B', 'K'): continue
        # solve for the remaining hoops
        for ss in solve([s]):
          # output solution
          printf("hoop 12 = {s12} [{ss}]", s12=ss[-1], ss=join(ss, sep=" "))
      

      Or [here] is a (longer) version that verifies the “yellow” condition as it goes along (instead of just checking it at the end).

      Solution: The order for the 12th hoop was: Red, Yellow, Blue, Black.

      The full list of orders is (B = Blue, K = Black, R = Red, Y = Yellow):

       1: B K Y R (K before Y)
       2: B K R Y (3/4 swapped; K before Y)
       3: B R K Y (2/3 swapped; K before Y)
       4: R B K Y (1/2 swapped; K before Y)
       5: R B Y K (3/4 swapped; Y before K)
       6: Y B R K (1/3 swapped; Y before K)
       7: Y B K R (3/4 swapped; Y before K)
       8: B Y K R (1/2 swapped; Y before K)
       9: B Y R K (3/4 swapped; Y before K)
      10: B R Y K (2/3 swapped; Y before K)
      11: Y R B K (1/3 swapped; Y before K)
      12: R Y B K (1/2 swapped; Y before K)

      And we see the non-adjacent swaps are between hoops 5 & 6 and 10 & 11.

      Like

    • Frits's avatar

      Frits 11:44 pm on 21 April 2023 Permalink | Reply

      Without recursion.

         
      from itertools import permutations, product
      from collections import defaultdict
      
      # the colours (K = black)
      colours = "BKRY"
      
      # possible running orders (blue before black)
      ps = list(p for p in permutations(range(4)) if p.index(0) < p.index(1))
      
      adj_moves = defaultdict(list)
      nonadj_moves = defaultdict(list)
      
      # determine all possible next running orders for a specific running order
      for (x, y) in product(ps, repeat=2):
        if x == y: continue
        swaps = [i for i, (a, b) in enumerate(zip(x, y)) if a != b]
        # only one swap took place ...
        if len(swaps) != 2: continue
        # .. and store for adjacent fields and non-adjacent fields
        (adj_moves if abs(swaps[0] - swaps[1]) == 1 else nonadj_moves)[x].append(y)
       
      
      # first hoop
      ss = [(x, ) for x in ps]
      # process remaining hoops after first hoop
      for n in range(1, 12):
        ss_ = []
        for s in ss:
          # determine next running order
          for ro in (adj_moves if n not in {5, 10} else nonadj_moves)[s[-1]]:
            if ro not in s:
              ss_.append(s + (ro, ))
        ss = ss_
       
      # is there a colour where all "before yellow" < all "after yellow"
      for s in ss:  
        for k in range(3):
          # make string with only digits 3 and k 
          # (like k3-k3-3k-3k-3k-3k-k3-k3-3k-3k-3k-3k)
          s_3k = '-'.join([''.join(str(x) for x in y if x in {k, 3}) for y in s])
          f_3k = s_3k.index('3' + str(k))   # first 3k string
          
          if f_3k == 0: continue
          # all k3 strings must occur before first 3k string
          if s_3k[f_3k + 3:].find(str(k) + '3') >= 0: continue
          
          print("answer:", ''.join(colours[x] for x in s[-1]))
          break
      

      Like

    • Frits's avatar

      Frits 4:21 pm on 25 April 2023 Permalink | Reply

      John Crabtree said that it was not hard to determine the “particular colour”.
      The following code will reject two out of three colours.

       
      from itertools import permutations
      
      # the colours (K = black)
      colours = "BKRY"
      
      # possible running orders (blue before black)
      ps = list(p for p in permutations(range(4)) if p.index(0) < p.index(1))
      
      # return possible outcomes after a non-adjacent swap
      def n_adj(s):
        return set(((s[2], s[1], s[0], s[3]), 
                    (s[3], s[1], s[2], s[0]), 
                    (s[0], s[3], s[2], s[1])))
      
      particular_colours = []
      for k in range(3):
        a, b = [], []       # after/before
        for p in ps:
          (b if p.index(k) < p.index(3) else a).append(p)
        
        # the "after" group has at least 3 elements
        # with at least one non-adjacent move 
        if all(n_adj(e).isdisjoint(a) for e in a): continue
        particular_colours.append(colours[k])
      
      print("possible particular colours:", particular_colours) 
      

      Like

  • Unknown's avatar

    Jim Randell 4:00 pm on 17 February 2023 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3152: Stamps of approval 

    From The Sunday Times, 19th February 2023 [link] [link]

    I really like the postcards that a friend of mine has sent me each of the last three years from her annual holiday in Farflung. Each card had six stamps on it, involving the same four denominations, and at least one of each denomination was present on each card. These four denominations were all different and coloured differently, and of course represented whole numbers of pecahans. I can’t read what the denominations are, but my friend had remarked the postage totals for the three years were 23, 32 and 36 pecahans.

    What were the four denominations, in decreasing order?

    [teaser3152]

     
    • Jim Randell's avatar

      Jim Randell 4:16 pm on 17 February 2023 Permalink | Reply

      The following Python program considers possible sets of 4 denominations, and then looks to see if each of the required amounts can be made using these denominations, using 6 stamps, with at least one of each denomination.

      It runs in 89ms. (Internal runtime is 31ms).

      Run: [ @replit ]

      from enigma import (irange, tri, decompose, express, printf)
      
      # can we make value <v> from denominations <ds> using exactly <k> stamps?
      def make(v, ds, k):
        return list(qs for qs in express(v, ds, min_q=1) if sum(qs) == k)
      
      # amounts to make (ordered)
      vs = [23, 32, 36]
      
      # consider possible sets of stamps
      for t in irange(tri(4), vs[0] - 2):
        for ds in decompose(t, 4, increasing=1, sep=1, min_v=1):
          # can we make each amount using 6 stamps?
          xs = list(make(v, ds, 6) for v in vs)
          if all(xs):
            # output solution
            printf("stamps = {ds} [t = {t}]")
            for (v, qs) in zip(vs, xs):
              printf("-> {v} = {qs}")
            printf()
      

      Solution: The four denominations are: 9, 6, 5, 1 pecahans.

      And we can make the amounts using 6 stamps as follows:

      23 = 9 + 6 + 5 + 1 + 1 + 1
      32 = 9 + 6 + 6 + 5 + 5 + 1
      36 = 9 + 9 + 6 + 6 + 1 + 1

      And these are the only ways to make the required amounts.

      Like

      • Jim Randell's avatar

        Jim Randell 12:31 pm on 18 February 2023 Permalink | Reply

        Here is a faster version of my program.

        It starts by making a list of the decompositions of 6 stamps from 4 different denominations (where each denomination is represented). And then looks for sets of denominations that allow each of the required values to be made using (at least) one of the decompositions from the list.

        It runs in 61ms. (Internal runtime is 1.8ms).

        Run: [ @replit ]

        from enigma import (irange, tri, decompose, group, dot, printf)
        
        # possible arrangements for 6 stamps of 4 different denominations
        qss = list(decompose(6, 4, increasing=0, sep=0, min_v=1))
        
        # amounts to make (ordered)
        vs = [23, 32, 36]
        
        # consider total value of one stamp of each denomination
        for t in irange(tri(4), vs[0] - 2):
          # split the total into 4 different denominations
          for ds in decompose(t, 4, increasing=1, sep=1, min_v=1):
            # collect amounts that can be made from the arrangements
            g = group(qss, by=(lambda qs: dot(qs, ds)))
            # can we make each amount using 6 stamps?
            if all(g.get(v) for v in vs):
              # output solution
              printf("stamps = {ds} [t = {t}]")
              for v in vs:
                printf("-> {v} = {qs}", qs=g.get(v))
              printf()
        

        Like

    • GeoffR's avatar

      GeoffR 6:20 pm on 17 February 2023 Permalink | Reply

      % A Solution in MiniZinc
      include "globals.mzn";
      
      % Four different denominations of the stamps
      var 1..15:d1; var 1..15:d2; var 1..15:d3; var 1..15:d4;
      constraint all_different([d1, d2, d3, d4]);
      
      % Stamp numbers in each of three years
      var 1..3:a; var 1..3:b; var 1..3:c; var 1..3:d; % year 1 no. stamps
      var 1..3:e; var 1..3:f; var 1..3:g; var 1..3:h; % year 2 no. stamps
      var 1..3:i; var 1..3:j; var 1..3:k; var 1..3:m; % year 3 no. stamps
      
      % Stamp total values for each of three years are 23, 32 and 36 pecahans.
      constraint a * d1 + b * d2 + c * d3 + d * d4 == 23 /\ a + b + c + d == 6;
      constraint e * d1 + f * d2 + g * d3 + h * d4 == 32 /\ e + f + g + h == 6;
      constraint i * d1 + j * d2 + k * d3 + m * d4 == 36 /\ i + j + k + m == 6;
      
      % Order stamp denominations
      constraint decreasing ([d1, d2, d3, d4]);
      
      solve satisfy;
      
      output ["The four stamp denominations were " ++
      show([d1, d2, d3, d4]) ++ " pecahans."
      ++ "\nYear 1 stamp nos (each type) = " ++ show([a, b, c, d])
      ++ "\nYear 2 stamp nos (each type) = " ++ show([e, f, g, h])
      ++ "\nYear 3 stamp nos (each type) = " ++ show([i, j, k, m]) ];
      

      Like

    • Hugo's avatar

      Hugo 12:01 pm on 26 February 2023 Permalink | Reply

      I feel it would be more logical to issue stamps worth 1, 2, 5, and 10 units.
      Then the puzzle could have stated totals of 20 and 38, together with either 21, 28, 30, or 33;
      or else 21 and 38 together with one of 22, 24, 25, 28, 29, 30, 33.
      Any of those sets would lead to a unique solution, I think.

      Like

      • Jim Randell's avatar

        Jim Randell 1:46 pm on 26 February 2023 Permalink | Reply

        That would make more sense. And the values you give do each give a unique solution with denominations of 1, 2, 5, 10.

        But perhaps the setter went with a less obvious set of denominations, so that the answer couldn’t be easily guessed.

        Like

  • Unknown's avatar

    Jim Randell 4:17 pm on 9 December 2022 Permalink | Reply
    Tags: by: Colin Vout   

    Teaser 3142: Mrs Hyde’s garden design tips 

    From The Sunday Times, 11th December 2022 [link] [link]

    I’m planting four differently-coloured plots in a line. Mrs Hyde’s design principles classify eight colours into five categories. Adjacent plots mustn’t have the same category, or even certain pairs of categories.

    “Every scheme you’ve suggested has at least one adjacent pair of categories that breaks my rules”, said Mrs Hyde. “White, Green, Yellow, White has Honest and Social adjacent; Blue, Orange, Pink, Violet has Ardent and Social adjacent; Green, Violet, Blue, Orange has Ardent and Honest adjacent; Violet, White, Orange, Red has Droll and Honest adjacent; and White, Violet, Green, Blue has Jolly and Social adjacent. However, if you change one colour in one of your suggestions then it will work”.

    What are the four colours in order in the changed suggestion that would be allowable?

    [teaser3142]

     
    • Jim Randell's avatar

      Jim Randell 4:55 pm on 9 December 2022 Permalink | Reply

      Here is my first attempt. It is not particularly quick, but I’ll have another look at this puzzle later.

      The program maps the different colours to the categories and checks that each of the given arrangements provides (at least) the corresponding objection. One we have a viable map we change one of the colours in one of the given arrangements and look for new arrangements that do not give rise to any of the objections raised previously.

      This Python program runs in 866ms.

      Run: [ @replit ]

      from enigma import (subsets, tuples, update, wrap, uniq, map2str, printf)
      
      # colours and categories
      cols = "BGOPRVWY"
      cats = "ADHJS"
      
      # invalid arrangements and objections
      inv = {
        'WGYW': 'HS',
        'BOPV': 'AS',
        'GVBO': 'AH',
        'VWOR': 'DH',
        'WVGB': 'JS',
      }
      
      # banned adjacencies
      banned = set(frozenset([x]) for x in cats)
      banned.update(map(frozenset, inv.values()))
      
      # check each of the arrangements provides the required objection
      def check(m):
        # check each of the arrangements
        for (k, v) in inv.items():
          adj = set(v)
          if not any({m[x], m[y]} == adj for (x, y) in tuples(k, 2)): return False
        # looks OK
        return True
      
      @wrap(uniq)
      # change one of the arrangements
      def change(ks):
        for k in ks:
          for (i, x) in enumerate(k):
            # make a changed arrangement
            for y in cols:
              if y != x:
                yield update(k, [(i, y)])
      
      # map colours to categories
      for ss in subsets(cats, size=len(cols), select="M"):
        m = dict(zip(cols, ss))
        # each category must be represented
        if not set(m.values()).issuperset(cats): continue
        # check the given arrangements are objectionable
        if not check(m): continue
      
        # find a changed arrangement that is not objectionable
        for s in change(inv.keys()):
          # look for banned adjacencies in the new arrangement
          if not any(frozenset([m[x], m[y]]) in banned for (x, y) in tuples(s, 2)):
            # output solution
            printf("{s} [{m}]", m=map2str(m, sep=" ", enc=""))
      

      Solution: The allowable arrangement is: White, Red, Green, Blue.

      The colour assignments are:

      Blue → Ardent
      Green → Jolly
      Orange → Honest
      Pink → Ardent
      Red → Droll
      Violet → Social
      White → Social
      Yellow → Honest

      And so the given arrangements (and their objections) are:

      White (S); Green (J); Yellow (H); White (S) → S and J adjacent; H and S adjacent
      Blue (A); Orange (H); Pink (A); Violet (S) → A and H adjacent (twice); A and S adjacent
      Green (J); Violet (S); Blue (A); Orange (H) → A and S adjacent; A and H adjacent
      Violet (S); White (S); Orange (H); Red (D) → S and S adjacent; S and H adjacent; D and H adjacent
      White (S); Violet (S); Green (J); Blue (A) → S and S adjacent; J and S adjacent;

      However, swapping Violet for Red in the final arrangement gives:

      White (S); Red (D); Green (J); Blue (A)

      Which does not give any of the objections we have discovered so far.

      Like

      • Jim Randell's avatar

        Jim Randell 6:25 pm on 9 December 2022 Permalink | Reply

        Here is a much faster version of my program (and not much longer). It uses the [[ SubstitutedExpression() ]] solver from the enigma.py library to assign the colours to the categories, such that the objections for each arrangement applies.

        It runs in 57ms. (Internal runtime is 5.6ms).

        Run: [ @replit ]

        from enigma import (SubstitutedExpression, wrap, uniq, update, tuples, map2str, printf)
        
        # colours and categories
        cols = "BGOPRVWY"
        cats = "ADHJS"
        
        # invalid arrangements and objections
        inv = {
          'WGYW': 'HS',
          'BOPV': 'AS',
          'GVBO': 'AH',
          'VWOR': 'DH',
          'WVGB': 'JS',
        }
        
        # map categories to digits 1-5
        d = dict(A=1, D=2, H=3, J=4, S=5)
        
        # construct an alphametic puzzle to assign categories to the colours
        p = SubstitutedExpression(
          [
            # WGYW has H and S adjacent
            "{3, 5} in [{W, G}, {G, Y}, {Y, W}]",
            # BOPV has A and S adjacent
            "{1, 5} in [{B, O}, {O, P}, {P, V}]",
            # GVBO has A and H adjacent
            "{1, 3} in [{G, V}, {V, B}, {B, O}]",
            # VWOR has D and H adjacent
            "{2, 3} in [{V, W}, {W, O}, {O, R}]",
            # WVGB has J and S adjacent
            "{4, 5} in [{W, V}, {V, G}, {G, B}]",
          ],
          digits=(1, 2, 3, 4, 5),
          distinct=(),
        )
        
        # set of banned adjacencies
        banned = set(frozenset([d[x]]) for x in cats)
        banned.update(frozenset([d[x], d[y]]) for (x, y) in inv.values())
        
        @wrap(uniq)
        # change one of the arrangements
        def change(ks):
          for k in ks:
            for (i, x) in enumerate(k):
              # make a changed arrangement
              for y in cols:
                if y != x:
                  yield update(k, [(i, y)])
        
        # solve the alphametic puzzle to map colours to categories
        for m in p.solve(verbose=0):
          # find a changed arrangement that is not objectionable
          for s in change(inv.keys()):
            # look for banned adjacencies in the new arrangement
            if not any(frozenset([m[x], m[y]]) in banned for (x, y) in tuples(s, 2)):
              # output solution
              printf("{s} [{m}]", m=map2str(m, sep=" ", enc=""))
        

        Like

c
Compose new post
j
Next post/Next comment
k
Previous post/Previous comment
r
Reply
e
Edit
o
Show/Hide comments
t
Go to top
l
Go to login
h
Show/Hide help
shift + esc
Cancel
Design a site like this with WordPress.com
Get started