Updates from October, 2025 Toggle Comment Threads | Keyboard Shortcuts

  • Unknown's avatar

    Jim Randell 10:09 am on 21 October 2025 Permalink | Reply
    Tags:   

    Teaser 2494: [Council election] 

    From The Sunday Times, 11th July 2010 [link] [link]

    George and Martha’s council has 40 seats shared between Labour, Conservatives and Democrats. Before a recent election, Labour had an overall majority, with Conservatives second. After it, the order was Conservatives first, Labour second, Democrats third, both Conservatives and Democrats having improved their numbers of seats. To achieve a majority, the Conservatives formed a coalition with the Democrats. Each party had a whole two-figure number percentage change in its number of seats; the Democrats’ percentage increase was an odd number.

    How many seats did each party win?

    This puzzle was originally published with no title.

    [teaser2494]

     
    • Jim Randell's avatar

      Jim Randell 10:10 am on 21 October 2025 Permalink | Reply

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

      It runs in 87ms. (Internal runtime of the generated code is 6.8ms).

      from enigma import (SubstitutedExpression, irange, div, sprintf as f, printf)
      
      # calculate (integer) percentage change
      def change(before, after): return div(100 * (after - before), before)
      
      # 2-digit numbers for change percentages
      d2 = irange(10, 99)
      d2o = irange(11, 99, step=2)  # odd numbers
      
      # symbols for each party before (= X1) and after (= X2)
      (L1, C1, D1, L2, C2, D2) = "ABCXYZ"
      
      # constraints for the solver
      exprs = [
        # total number of seats is 40
        f("{L1} + {C1} + {D1} == 40"),
        f("{L2} + {C2} + {D2} == 40"),
      
        # before: Lab had an overall majority, with Con second
        f("{L1} > {C1} + {D1}"),
        f("{C1} > {D1}"),
      
        # after: Con was first, Lab second, Dem third
        f("{C2} > {L2} > {D2}"),
      
        # both Con and Dem increased their numbers of seats
        f("{C2} > {C1}"),
        f("{D2} > {D1}"),
      
        # after: Con formed a coalition with Dem to get a majority
        f("not ({C2} > {L2} + {D2})"),
        f("{C2} + {D2} > {L2}"),
      
        # each party had a 2-digit percentage change in its number of seats
        f("abs(change({L1}, {L2})) in d2"),
        f("abs(change({C1}, {C2})) in d2"),
        f("abs(change({D1}, {D2})) in d2o"),  # Dem's change is odd
      ]
      
      # construct the solver
      p = SubstitutedExpression(
        exprs,
        base=41, # each value is 0 .. 40
        distinct="",
        env=dict(change=change, d2=d2, d2o=d2o),
        answer=f("({L1}, {C1}, {D1}, {L2}, {C2}, {D2})"),
      )
      
      # solve the puzzle
      for (L1, C1, D1, L2, C2, D2) in p.answers(verbose=0):
        # output solution
        printf("before (seats)   -> Lab  {L1:2d},  Con  {C1:2d},  Dem  {D1:2d}")
        printf("after  (seats)   -> Lab  {L2:2d},  Con  {C2:2d},  Dem  {D2:2d}")
        (cL, cC, cD) = (L2 - L1, C2 - C1, D2 - D1)
        printf("change (seats)   -> Lab {cL:+3d},  Con {cC:+3d},  Dem {cD:+3d}")
        (cL, cC, cD) = (change(L1, L2), change(C1, C2), change(D1, D2))
        printf("change (percent) -> Lab {cL:+d}%, Con {cC:+d}%, Dem {cD:+d}%")
        printf()
      

      Solution: The result of the recent election was: Conservatives = 19 seats; Labour = 11 seats; Democrats = 10 seats.

      Before the election the seats were allocated thus:

      Labour = 22 seats
      Conservatives = 10 seats
      Democrats = 8 seats

      So the changes are:

      Labour = −11 seats (−50%)
      Conservatives = +9 seats (+90%)
      Democrats = +2 seats (+25%)

      The percentages don’t add up to 0% because they are the percentage change over the previous number of seats, not the percentage change in the overall share of seats. If we use this measure, then there are many solutions, but the changes will sum to 0%. (This can be seen by making the [[ change() ]] function divide by the total number of seats [[ 40 ]] instead of [[ before ]]).

      Like

    • Ruud's avatar

      Ruud 8:24 am on 22 October 2025 Permalink | Reply

      As brute force as possible:

      import peek
      import itertools
      
      
      def check(x0, x1, has_to_be_odd=False):
          perc = (abs(x0 - x1) / x0) * 100
          if perc % 1 or perc < 10 or perc >= 100:
              return False
          return not has_to_be_odd or perc % 2 == 1
      
      
      for l0, c0, d0 in itertools.product(range(1, 41), repeat=3):
          if l0 + c0 + d0 == 40 and l0 > c0 + d0 and l0 > c0 > d0:
              for l1, c1, d1 in itertools.product(range(1, 41), repeat=3):
                  if l1 + c1 + d1 == 40 and c1 < l1 + d1 and c1 > l1 > d1 and c1 + d1 > 20 and c1 > c0 and d1 > d0:
                      if check(l0, l1) and check(c0, c1) and check(d0, d1, has_to_be_odd=True):
                          peek(l0, l1, c0, c1, d0, d1, l1 - l0, c1 - c0, d1 - d0)
      

      Like

    • Frits's avatar

      Frits 4:35 pm on 22 October 2025 Permalink | Reply

      # check for a two-figure number percentage change
      def check(old, new):
        d, r = divmod(100 * abs(new - old), old)
        if r or not (9 < d < 100): return False
        return d
            
      M = 40
      parties = "Labour Conservatives Democrats".split(" ")
      # Labour had a majority, Democrats had at least 2 seats (two-figure %)
      for l1 in range(M // 2 + 1, M - 4):
        for c1 in range((M - l1) // 2 + 1, M - l1 - 1):
          d1 = M - l1 - c1
          # up to 99 percent increase   
          for d2 in range(d1 + 1, min(2 * d1, M // 3)):
            if not (perc := check(d1, d2)): continue
            # the Democrats' percentage increase was an odd number
            if not (perc % 2): continue 
            for c2 in range(max(d1 + 1, (M - d2) // 2 + 1), M // 2 + 1):
              if not check(c1, c2): continue
              l2 = M - d2 - c2
              if l2 <= d2 or not check(l1, l2): continue
              print(list(zip(parties, [l2 - l1, c2 - c1, d2 - d1])))
      

      Like

  • Unknown's avatar

    Jim Randell 7:58 am on 19 October 2025 Permalink | Reply
    Tags:   

    Teaser 3291: Top of the Pops 

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

    George and Martha are keen pop music fans. They recently followed the progress of one record in the charts and noticed that it was in the Top Ten for three weeks in three different positions, the third week’s position being the highest. In practice, a record never zigzags; it reaches a peak and then drops. For example: 5,4,9 or 5,6,9 are possible but 2,5,3 is not.

    “That’s interesting!”, commented Martha. “If you add the three positions, you get the day of the month when my father was born; and if you multiply them, you get a number giving the month and last two digits of that year”. “Furthermore”, added George “two of the positions also indicate the last two digits of that year”.

    What were the three positions in chronological order, and what was the date of Martha’s father’s birth?

    [teaser3291]

     
    • Jim Randell's avatar

      Jim Randell 8:08 am on 19 October 2025 Permalink | Reply

      The position in the third week is the highest in the chart (i.e. the smallest number), and so the previous position must be lower (i.e. a larger number), and as the record does not “zig-zag” (i.e. go down the chart, and then back up the chart), the first position must be the lowest of the three (i.e. the largest number). So if the numbers are p1, p2, p3, we have:

      p1 > p2 > p3

      This Python program runs in 72ms. (Internal runtime is 126µs).

      from enigma import (irange, subsets, multiply, mdivmod, printf)
      
      # choose the three positions
      for ps in subsets(irange(10, 1, step=-1), size=3):
        # sum is D; product is MYY
        (d, (m, x, y)) = (sum(ps), mdivmod(multiply(ps), 10, 10))
        if m < 1: continue
        # the 2 digits of the year are also (different) positions
        if not (x != y and x in ps and y in ps): continue
      
        # output solution
        printf("{ps} -> {d}/{m}/{x}{y}")
      

      Solution: The positions were: 9, 5, 3. Martha’s father’s birth date is: 17/1/35.

      We have:

      9 + 5 + 3 = 17
      9 × 5 × 3 = 135

      Like

    • Frits's avatar

      Frits 9:14 am on 19 October 2025 Permalink | Reply

      Two solutions.

      from itertools import permutations
      from math import prod
      
      # disallow descending after ascending within sequence <s>
      def zigzag(s):
        asc = 0
        for x, y in zip(s, s[1:]):
          if y > x: 
            asc = 1
          else:
            if asc:
              return True
        return False
        
      assert not zigzag([5, 4, 9])  
      assert not zigzag([5, 6, 9])
      assert zigzag([2, 5, 3])         
      
      # a tenth position results in a year .0
      for p in permutations(range(1, 10), 3):
        # the third week's position being the highest
        if max(p) != p[-1]: continue
        # a record never zigzags
        if zigzag(p): continue
        # multiplying gives the month and last two digits of that year
        if (m := prod(p)) < 100: continue
        yy = m % 100
        if yy < 10 or yy % 10 == 0: continue
        # two of the positions also indicate the last two digits of that year
        if yy % 11 == 0: continue
        if any(x not in p for x in divmod(yy, 10)): continue
        print((f"three positions {p}, birthdate {sum(p)}/{m // 100}/{yy}"))
      

      Like

      • Jim Randell's avatar

        Jim Randell 9:25 am on 19 October 2025 Permalink | Reply

        @Frits: In the charts a “higher” position is the lower numbered one. (So the “highest” position is number 1).

        Like

        • Frits's avatar

          Frits 9:33 am on 19 October 2025 Permalink | Reply

          @Jim, “5,6,9 is possible” doesn’t help if that is the case.

          Like

          • Jim Randell's avatar

            Jim Randell 10:00 am on 19 October 2025 Permalink | Reply

            @Frits: I think that is just to illustrate that a record doesn’t go down the charts and then back up.

            So (5, 4, 9) is viable (the record peaks at number 4 and then drops down the chart the following week). (5, 6, 9) is viable (the record peaks at 5 and then drops down the charts for the next 2 weeks). But (2, 5, 3) is not viable (the record drops down from 2 to 5, but then goes back up again to 3).

            Like

    • Frits's avatar

      Frits 9:26 am on 19 October 2025 Permalink | Reply

      The wording is a bit confusing to me. A high position doesn’t seem to mean a position close to the top of the chart/pops.

      “the third week’s position being the highest” and ” … 5,6,9 are possible”.

      Like

      • Brian Gladman's avatar

        Brian Gladman 11:54 am on 19 October 2025 Permalink | Reply

        I agree that the wording is poor. The word ‘zigzag’ could mean (hi, lo,hi) or (lo,hi,lo) but is then defined as only one of these. And it hen gives an example that directly conflicts with an earlier constraint. It would have been easier to say ‘a hit never rises after it has fallen’. It almost seems as if the author is trying to confuse us!

        Like

        • Jim Randell's avatar

          Jim Randell 1:45 pm on 19 October 2025 Permalink | Reply

          @Brian: There is an implicit “up” to enter the Top 10, so (lo, hi, lo) is actually (up, up, down), which is allowable (not a zig-zag). But (hi, lo, hi) is (up, down, up), which is not allowed (a zig-zag)

          I agree that the wording seems to be deliberately complicated, but I didn’t have any problem interpreting it.

          Liked by 1 person

    • Brian Gladman's avatar

      Brian Gladman 12:10 pm on 19 October 2025 Permalink | Reply

      I am not sure of the use of the words ‘in practice’ either since it conflicts with real world experience where ‘rising after falling’ is not unusual. Even in ‘teaserland’ it is surely better to stay somewhere near reality.

      Like

  • Unknown's avatar

    Jim Randell 8:10 am on 17 October 2025 Permalink | Reply
    Tags: ,   

    Teaser 2493: [Driving competition] 

    From The Sunday Times, 4th July 2010 [link] [link]

    Pat won the driving competition. The course was a whole number of miles long and he completed the course in a whole number of minutes (less than two hours). His speed was a two-digit number of miles per hour. The time taken to complete the course was 17 minutes less than the time he would have taken to drive 17 miles less than the distance he would have gone if he had driven for 17 minutes less than the time he would have taken to complete the course at two-thirds of his speed.

    How long was the course?

    This puzzle was originally published with no title.

    [teaser2493]

     
    • Jim Randell's avatar

      Jim Randell 8:11 am on 17 October 2025 Permalink | Reply

      It is straightforward to consider the possible velocities (2-digits) and course times (< 2 hours).

      This Python program runs in 85ms. (Internal runtime is 11.4ms).

      from enigma import (Rational, irange, cproduct, printf)
      
      Q = Rational()
      
      # speed was a 2-digit mph, time was a whole number of minutes < 120
      for (v, t) in cproduct([irange(10, 99), irange(1, 119)]):
      
        # the course was a whole number of miles long
        d = Q(v * t, 60)
        if d % 1 != 0: continue
      
        # t1 = time taken at 2/3 of the speed
        t1 = Q(3, 2) * t
      
        # d1 = distance travelled in time (t1 - 17)
        if not (t1 > 17): continue
        d1 = Q(v * (t1 - 17), 60)
      
        # t2 = time to drive (d1 - 17)
        if not (d1 > 17): continue
        t2 = Q(d1 - 17, v) * 60
      
        # time take to complete the course was (t2 - 17)
        if not (t2 - 17 == t): continue
      
        # output solution
        printf("v={v} t={t} d={d} [t1={t1} d1={d1} t2={t2}]")
      

      Solution: The course was 102 miles long.

      Pat’s speed was 60 mph (i.e. 1 mile per minute), so he took 102 minutes to complete the course.

      If he had driven at 2/3 of his actual speed (i.e. 40 mph), then it would have taken 153 minutes to complete the course.

      And in 153 − 17 = 136 minutes, driving at 60 mph, he would have been able to complete 136 miles.

      The time taken to drive 136 − 17 = 119 miles at 60 mph is 119 minutes.

      And 119 − 17 = 102 minutes is the time actually taken to complete the course.


      With more analysis we can derive the following equations for t and d:

      t = 68 + 2040/v
      d = 34 + 17v/15

      from which we see v must be a multiple of 15.

      This allows a manual solution by considering just 6 cases:

      v = 15 → t = 204
      v = 30 → t = 136
      v = 45 → t = 113.333…
      v = 60 → t = 102
      v = 75 → t = 95.2
      v = 90 → t = 90.666…

      And only one of these gives an integer in the required range (< 120).

      Hence:

      d = 34 + 17×60/15 = 102

      Or, programatically:

      from enigma import (irange, div, printf)
      
      # v is a 2-digit multiple of 15
      for v in irange(15, 99, step=15):
      
        # calculate time taken (< 120)
        t = div(2040, v)
        if t is None: continue
        t += 68
        if not (t < 120): continue
      
        # calculate course distance
        d = div(17 * v, 15) + 34
      
        # output solution
        printf("v={v} t={t} d={d}")
      

      Like

  • Unknown's avatar

    Jim Randell 8:14 am on 14 October 2025 Permalink | Reply
    Tags:   

    Teaser 2490: [Cans of paint] 

    From The Sunday Times, 13th June 2010 [link] [link]

    George and Martha supply paint to the art class. They have 1 litre cans of red, yellow and blue, and can make 2 litre cans of orange (red and yellow equally), purple (red and blue) and green (yellow and blue), and 3 litre cans of brown (red, yellow and blue).

    The class orders numbers of litres of all seven colours (totalling less than 500 litres), with the seven numbers forming an arithmetic progression. The lowest quantity is 1 litre; the highest can be shared equally among the class, each student getting a whole plural number of litres. George and Martha used equal quantities of blue and yellow making up the order.

    How much green paint was ordered?

    This puzzle was originally published with no title.

    [teaser2490]

     
    • Jim Randell's avatar

      Jim Randell 8:14 am on 14 October 2025 Permalink | Reply

      This Python program runs in 69ms. (Internal runtime is 363µs).

      from enigma import (irange, inf, first, is_composite, disjoint_cproduct, printf)
      
      # colours are:
      # N = browN, O = Orange, P = Purple, G = Green, R = Red, Y = Yellow, B = Blue
      
      # consider common difference in the arithmetic progression
      for d in irange(1, inf):
        # calculate the arithmetic progression of quantities
        qs = first(irange(1, inf, step=d), count=7)
        t = sum(qs)
        if not (t < 500): break
        # final term in the progression is composite
        if not is_composite(qs[-1]): continue
      
        # find terms divisible by 2 (for O, P, G), 3 (for N)
        (qs2, qs3) = (list(q for q in qs if q % k == 0) for k in [2, 3])
        if len(qs2) < 3 or len(qs3) < 1: continue
      
        # choose quantities for N (qs3); O, P, G (qs2); R, Y, B (qs)
        for (N, O, P, G, R, Y, B) in disjoint_cproduct([qs3, qs2, qs2, qs2, qs, qs, qs]):
          # calculate total volume of R, Y, B used in mixing the colours
          (O2, P2, G2, N3) = (O // 2, P // 2, G // 2, N // 3)
          (tR, tY, tB) = (R + O2 + P2 + N3, Y + O2 + G2 + N3, B + P2 + G2 + N3)
          if tB == tY:
            # output solution
            printf("tR={tR} tY={tY} tB={tB} [R={R} Y={Y} B={B} O={O} P={P} G={G} N={N}]")
      

      Solution: 58 litres of green paint was ordered (29 cans).

      The total amount of paint in the order was 406 litres, consisting of:

      red = 72 litres
      yellow = 167 litres
      blue = 167 litres
      total = 406 litres

      Which is mixed as follows:

      red = 1 litre (= 1 can)
      yellow = 77 litres (= 77 cans)
      blue = 115 litres (= 115 cans)
      orange = 96 litres (= 48 cans)
      purple = 20 litres (= 10 cans)
      green = 58 litres (= 29 cans)
      brown = 39 litres (= 13 cans)
      total = 406 litres (= 293 cans)

      or:

      red = 1 litre (= 1 can)
      yellow = 115 litres (= 115 cans)
      blue = 77 litres (= 77 cans)
      orange = 20 litres (= 10 cans)
      purple = 96 litres (= 48 cans)
      green = 58 litres (= 29 cans)
      brown = 39 litres (= 13 cans)
      total = 406 litres (= 293 cans)

      Giving the following arithmetic progression (with a common difference of 19):

      [1, 20, 39, 58, 77, 96, 115]

      And this is the only possible progression, as it must end with a composite number, have (at least) 3 even numbers and (at least) one number that is divisible by 3.

      The largest amount can be expressed as 115 = 5 × 23.

      So there are either 5 students in the class (each would receive 23 litres) or 23 students in the class (each would receive 5 litres) (which is probably more likely).

      Like

    • Frits's avatar

      Frits 10:09 am on 14 October 2025 Permalink | Reply

      My original program had variable, ABC, DEF, GHI, …
      Apparently the number of liters of red paint (r * YZ + 1) is not relevant for solving this teaser.
      I didn’t have to use variable “r”.

      from enigma import SubstitutedExpression
      
      # invalid digit / symbol assignments
      d2i = dict()
      for dgt in range(0, 10):
        vs = set()
        if dgt > 2: vs.update('Y')
        if dgt > 6: vs.update('bgnopy')
        d2i[dgt] = vs
      
      # the alphametic puzzle
      # red:   ABC, yellow: DEF, blue: GHI, orange: JKL, purple: MNO
      # green: PQR, brown:  STU
      p = SubstitutedExpression(
        [ # difference in arithmetic expression YZ, tot = 21 * YZ + 7 < 500
          "YZ < 24",
          # the highest can be shared equally among the class
          "not is_prime(6 * {YZ} + 1)",
          # orange, purple and green
          "({o} * {YZ} + 1) % 2 == 0",
          "({p} * {YZ} + 1) % 2 == 0",
          "({g} * {YZ} + 1) % 2 == 0",
          # and brow(n)
          "({n} * {YZ} + 1) % 3 == 0",
          
          # equal quantities of blue and yellow making up the order
          #before "2 * {DEF} + {MNO} == 2 * {GHI} + {JKL}",
          "2 * {y} + {p} == 2 * {b} + {o}",
        ],
        answer="g * YZ + 1",
        symbols="YZbgnopy",
        d2i=d2i,
        distinct=("bgnopy"),
        verbose=0,    # use 256 to see the generated code
      )
      
      # print answers
      print("answer:", ' or '.join(set(str(x) for x in p.answers())), "liters")
      

      Like

  • Unknown's avatar

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

    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 11:09 am on 10 October 2025 Permalink | Reply
    Tags:   

    Teaser 2514: [The Hardy Annular] 

    From The Sunday Times, 28th November 2010 [link] [link]

    The Hardy Annular is an ornamental garden consisting of the area between two circles with the same centre, with the radius of each a whole number of metres. A line joining two points on the circumference of the bigger circle just touches the smaller circle and measures exactly 20 metres.

    What is the radius of each of the circles?

    This puzzle was originally published with no title.

    There are now 1250 Teaser puzzles available on the site (around 38% of all Teaser puzzles published).

    [teaser2514]

     
    • Jim Randell's avatar

      Jim Randell 11:10 am on 10 October 2025 Permalink | Reply

      See also: Puzzle #64.

      We are looking for solutions to the equation:

      R² = r² + 10²

      Here is a solution using the pells.py library (a bit of overkill perhaps for a simple problem, but the code is already written, so here goes):

      from enigma import printf
      import pells
      
      # solve: R^2 - r^2 = 10^2
      for (R, r) in pells.diop_quad(1, -1, 100):
        if R > r > 0:
          printf("R={R} r={r}")
      

      Solution: The circles have radii of 24 m and 26 m.


      Manually, we solve:

      R² − r² = 100
      (R − r)(R + r) = 100

      for positive integer values of R, r.

      So we can consider divisor pairs of 100:

      100 = 1×100 ⇒ R = 50.5, r = 49.5
      100 = 2×50 ⇒ R = 26, r = 24 [SOLUTION]
      100 = 4×25 ⇒ R = 14.5, r = 10.5
      100 = 5×20 ⇒ R = 12.5, r = 7.5
      100 = 10×10 ⇒ R = 10; r = 0

      Only one of the pairs gives positive integer solutions.

      And this is the same process followed by the program.

      Like

    • Ruud's avatar

      Ruud 8:33 am on 11 October 2025 Permalink | Reply

      Strictly speaking the circles could also have a radius of 0 and 10.

      Like

    • Ruud's avatar

      Ruud 8:37 am on 11 October 2025 Permalink | Reply

      for r in range(50):
          for R in range(r, 50):
              if R * R == 100 + r * r:
                  print(r, R)
      

      Like

  • Unknown's avatar

    Jim Randell 10:37 am on 7 October 2025 Permalink | Reply
    Tags:   

    Teaser 2510: [Extension number] 

    From The Sunday Times, 31st October 2010 [link] [link]

    Since we last met them, George has moved departments yet again and Martha has forgotten the extension number.

    “No problem”, said the operator.

    “If you write down the number itself, the average of its four digits, the sum of its four digits, and the cube root of the difference between the number and its reverse (which is smaller), then you will write no digit more than once”.

    What is George’s new extension number?

    This puzzle was originally published with no title.

    [teaser2510]

     
    • Jim Randell's avatar

      Jim Randell 10:37 am on 7 October 2025 Permalink | Reply

      Although not explicitly stated, I assumed both the average (mean) of the digits and the cube root are integers.

      You get the same answer to the puzzle if you do consider fractional averages, written either in fraction form (1/4, 1/2, 3/4) or in decimal (.25, .5, .75).

      The following Python program runs in 65ms. (Internal runtime is 1.9ms).

      from enigma import (irange, subsets, multiset, nsplit, nconcat, is_cube, rev, join, printf)
      
      # choose 4 (distinct) digits for the phone number
      for ds in subsets(irange(0, 9), size=4):
        # accumulate written digits
        m = multiset.from_seq(ds)
      
        # sum of the digits in the number
        t = sum(ds)
        m.update_from_seq(nsplit(t))
        if m.is_duplicate(): continue
      
        # average (= mean) of the digits in the number
        (a, f) = divmod(t, 4)
        if f > 0: continue
        m.add(a)
        if m.is_duplicate(): continue
      
        # choose an ordering for the digits in the number
        for ns in subsets(ds, size=len, select='P'):
          if ns[-1] > ns[0]: continue  # first digit > last digit
          n = nconcat(ns)
          r = nconcat(rev(ns))
          # is the difference a positive cube?
          c = is_cube(n - r)
          if not c: continue
          if m.combine(nsplit(c)).is_duplicate(): continue
      
          # output solution
          printf("number={ns} [sum={t} avg={a} cbrt={c}]", ns=join(ns))
      

      Solution: The number is: 8147.

      The sum of the digits is: 20.

      The average (mean) of the digits is: 5.

      And the difference between the number and its reverse is: 8147 − 7418 = 729 = 9³.

      So we would write the digits: “8147; 20; 5; 9”.

      Like

      • Jim Randell's avatar

        Jim Randell 5:04 pm on 7 October 2025 Permalink | Reply

        Or even faster with some analysis:

        If the phone number is abcd (where a > d, then we have:

        n^3 = 1000(a – d) + 100(b – c) + 10(c – b) + (d – a)

        n^3 = 999(a – d) + 90(b – c)

        So n^3 is a multiple of 9, and so n is a multiple of 3.

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

        
        from enigma import (irange, cb, group, unpack, subsets, div, nsplit, printf)
        
        # possible cubes
        cubes = dict((cb(n), nsplit(n)) for n in irange(3, 21, step=3))
        
        # map 90 * (b - c) values to possible (b, c) pairs
        fn = unpack(lambda b, c: 90 * (b - c))
        g = group(subsets(irange(0, 9), size=2, select='P'), by=fn)
        
        # choose (a, d) values (a > d)
        for (d, a) in subsets(irange(0, 9), size=2):
          x = 999 * (a - d)
          # consider possible cubes
          for (n3, n) in cubes.items():
            # find (a, b) values
            for (b, c) in g.get(n3 - x, ()):
              ds = [a, b, c, d]
              ds.extend(n)
              if len(set(ds)) != len(ds): continue
              t = a + b + c + d
              m = div(t, 4)
              if m is None: continue
              ds.append(m)
              ds.extend(nsplit(t))
              if len(set(ds)) != len(ds): continue
        
              # output solution
              printf("{a}{b}{c}{d} [cube={n3} total={t} avg={m}]")
        

        Like

    • Ruud's avatar

      Ruud 4:30 pm on 7 October 2025 Permalink | Reply

      import istr
      
      cube_root = {istr(i**3): istr(i) for i in range(22)}
      
      for i in istr.range(1000, 10000):
          if sum(i).is_divisible_by(4) and (diff := i - i[::-1]) in cube_root.keys() and (i | sum(i) / 4 | sum(i) | cube_root.get(diff, "**")).all_distinct():
              print(i)
      

      Like

    • Frits's avatar

      Frits 7:17 am on 8 October 2025 Permalink | Reply

      # n^3 = 1000(a - d) + 100(b - c) + 10(c - b) + (d - a)
      # n^3 = 999(a - d) + 90(b - c)
      # n^3 = 3^3.(37.(a - d) + 10.(b - c)/3)
      
      cubes = [i**3 for i in range(10)] 
      # dictionary of cubes per cube unit digit
      dct = {cubes[i] % 10: cubes[i] for i in range(10)}
      
      for a_d in range(1, 10):
        # 37.a_d  + delta should be a cube (delta in {-30, -20, -10, 10, 20, 30})
        m37 = a_d * 37
        delta = dct[m37 % 10] - m37
        if delta not in [-30, -20, -10, 10, 20, 30]: continue
        b_c = 3 * delta // 10
        # a + b + c + d = 4.avg or 2.d + 2.c + (a - d) + (b - c) = 4.avg
        if (a_d + b_c) % 2: continue 
        
        cuberoot2 = round((m37 + delta)**(1/3))
        cuberoot = 3 * cuberoot2
        ns = set(int(x) for x in str(cuberoot))
        
        for a in range(a_d, 10):
          d = a - a_d
          if len(ns_ := ns | {a, d}) != 3 + (cuberoot > 9): continue
          for b in range(b_c if b_c > 0 else 0, 10 if b_c > 0 else 10 + b_c):
            c = b - b_c
            if not ns_.isdisjoint({b, c}): continue
            avg, r = divmod(sm := a + b + c + d, 4)
            if r: continue
            nums = ns_ | {b, c, avg} | set(int(x) for x in str(sm))
            if len(nums) != 6 + len(ns) + (sm > 9): continue
            print("answer:", 1000 * a + 100 * b + 10 * c + d)
        

      Like

  • Unknown's avatar

    Jim Randell 6:08 am on 5 October 2025 Permalink | Reply
    Tags:   

    Teaser 3289: Spot check 

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

    Jack has a set of 28 standard dominoes: each domino has a spot pattern containing 0 to 6 spots at either end and every combination ([0-0], [0-1] and so on up to [6-6]) is represented in the set. He discarded a non-blank domino and arranged the remaining dominoes into six “groups”, each of which contained a positive cube number of spots; a group might comprise a single domino. He then discarded another non-blank domino and rearranged the remaining dominoes into six groups each of which again contained a positive cube number of spots. He managed to do this the maximum possible number of times.

    How many dominoes did Jack discard? And how many spots in total were there on the dominoes that remained at the end?

    [teaser3289]

     
    • Jim Randell's avatar

      Jim Randell 9:26 am on 5 October 2025 Permalink | Reply

      I ignored the [0-0] domino as it doesn’t affect the results (it can’t be one of the discarded dominoes, and it can be added to any of the piles without affecting the total number of pips).

      I assumed the groups are just collections of 1 or more dominoes. (And not that they had to fit together as in a domino game). So we can just keep track of the number of pips on each domino.

      And that a “non-blank” domino is a domino that does not have either half blank.

      My code gets multiple answers for the second part of the puzzle.

      from enigma import (
        Accumulator, irange, inf, multiset, subsets,
        peek, powers, first, lt, group, printf
      )
      
      # collect pips on dominoes; all; non-0; total
      # (we discard 0-0 as it does not affect the results)
      (ds_all, ds_non0, T) = (multiset(), multiset(), 0)
      # the complete set of dominoes
      for (x, y) in subsets(irange(0, 6), size=2, select='R'):
        if x == y == 0: continue  # skip [0-0]
        t = x + y
        ds_all.add(t)
        if x > 0 and y > 0: ds_non0.add(t)
        T += t
      
      # possible cubes
      cubes = list(first(powers(1, inf, 3), count=lt(T)))
      
      # decompositions of numbers that can be expressed as the sum of 6 cubes
      g = group(subsets(cubes, size=6, select='R'), by=sum, st=(lambda ns: sum(ns) < T))
      
      # make piles with total in <ts> from dominoes <ds>
      def piles(ts, ds, ps=list()):
        # are we done?
        if not ts:
          yield ps
        else:
          t = ts[0]
          for ss in ds.express(t):
            yield from piles(ts[1:], ds.difference(ss), ps + [ss])
      
      # starting with total <t>
      # discard a domino from <ds_non0> and form the remaining dominoes in piles
      # return [(t1, ps1), ..., (tk, psk)]
      def solve(t, ds_all, ds_non0, ss=list()):
        if ss: yield ss
        # look for a non0 domino to discard
        for x in ds_non0.keys():
          # remaining total pips
          t_ = t - x
          if t_ not in g: continue
          ds_all_ = ds_all.difference([x])
          # consider possible decompositions into 6 piles
          for ts in g[t_]:
            # can we make an appropriate set of piles?
            ps = peek(piles(ts, ds_all_), default=None)
            if ps:
              yield from solve(t_, ds_all_, ds_non0.difference([x]), ss + [(t_, ps)])
              break  # we only need one viable decomposition
      
      # find maximal length sequences
      r = Accumulator(fn=max, collect=1)
      for ss in solve(T, ds_all, ds_non0):
        r.accumulate_data(len(ss), ss)
      printf("max discards = {r.value}")
      
      # output example maximal sets
      rs = set()
      for ss in r.data:
        for (t, ps) in ss:
          printf("{t} -> {ps}", ps=list(list(p.sorted()) for p in ps))
        printf()
        rs.add(t)
      printf("remaining dominoes = {rs} pips", rs=sorted(rs))
      

      Solution: [See below]

      Like

      • Jim Randell's avatar

        Jim Randell 2:45 pm on 5 October 2025 Permalink | Reply

        Taking a “non-blank” domino to mean any domino other than [0-0] (which I excluded anyway) allows me to simplify my code (although we could just change the [[ and ]] on line 14 to an [[ or ]]), and get a single answer to both of the questions in the puzzle. And so may well be what the setter intended.

        If this is the case, I think it would probably have been better to say the [0-0] domino was removed from a standard 28-domino set, and then the puzzle is solved with the remaining 27 dominoes. That way we don’t need mention the “non-blank” condition (we can just discard any of the remaining dominoes).

        from enigma import (
          Accumulator, irange, inf, multiset, subsets,
          peek, powers, first, lt, group, printf
        )
        
        # collect pips on dominoes; and total pips
        ds = multiset.from_seq(x + y for (x, y) in subsets(irange(0, 6), size=2, select='R'))
        ds.remove(0)  # remove [0-0] as it does not affect the results
        T = ds.sum()
        
        # possible cubes
        cubes = list(first(powers(1, inf, 3), count=lt(T)))
        
        # decompositions of numbers that can be expressed as the sum of 6 cubes
        g = group(subsets(cubes, size=6, select='R'), by=sum, st=(lambda ns: sum(ns) < T))
        
        # make piles with total in <ts> from dominoes <ds>
        def piles(ts, ds, ps=list()):
          # are we done?
          if not ts:
            yield ps
          else:
            t = ts[0]
            for ss in ds.express(t):
              yield from piles(ts[1:], ds.difference(ss), ps + [ss])
        
        # starting with total <t>
        # discard a domino from <ds> and form the remaining dominoes in piles
        # return [(t1, ps1), ..., (tk, psk)]
        def solve(t, ds, ss=list()):
          if ss: yield ss
          # look for a domino to discard
          for x in ds.keys():
            # remaining total pips
            t_ = t - x
            if t_ not in g: continue
            ds_ = ds.difference([x])
            # consider possible decompositions into 6 piles
            for ts in g[t_]:
              # can we make an appropriate set of piles?
              ps = peek(piles(ts, ds_), default=None)
              if ps:
                yield from solve(t_, ds_, ss + [(t_, ps)])
                break  # we only need one viable decomposition
        
        # find maximal length sequences
        r = Accumulator(fn=max, collect=1)
        for ss in solve(T, ds):
          r.accumulate_data(len(ss), ss)
        printf("max discards = {r.value}")
        
        # output example maximal sets
        rs = set()
        for ss in r.data:
          for (t, ps) in ss:
            printf("{t} -> {ps}", ps=list(list(p.sorted()) for p in ps))
          printf()
          rs.add(t)
        printf("remaining dominoes = {rs} pips", rs=sorted(rs))
        

        Solution: Jack discarded 11 dominoes. At the end, the total number of spots on the remaining dominoes was 98.

        There are 3 sequences of totals that can be made:

        [165, 162, 160, 158, 153, 143, 136, 124, 116, 105, 98 ]
        [165, 162, 160, 158, 153, 143, 135, 124, 117, 105, 98 ]
        [165, 162, 160, 158, 153, 143, 135, 123, 116, 105, 98 ]

        Like

        • Frits's avatar

          Frits 3:42 am on 10 October 2025 Permalink | Reply

          @Jim, couldn’t you use a break on line 44 after the yield?

          In my opinion as soon as you have found a valid “ps” you don’t need to process another “ts”. It may depend on what you want to display as output.

          Like

          • Jim Randell's avatar

            Jim Randell 8:54 am on 10 October 2025 Permalink | Reply

            @Frits: Yes, we could. There only needs to be one viable decomposition into cubes. (Although for most totals there is only one possible decomposition anyway). I’ll add it in.

            Like

  • Unknown's avatar

    Jim Randell 9:38 am on 3 October 2025 Permalink | Reply
    Tags:   

    Teaser 2515: [Interleaved powers] 

    From The Sunday Times, 5th December 2010 [link] [link]

    Using each digit just once I wrote down two five-figure numbers. One was a cube and the other was a square. Then I combined these two five-figure numbers to give me a ten-figure number by using one of them to occupy the odd positions (from left to right) and the other to occupy the even positions (again from left to right). Then the four-figure number made up in order from the digits in the prime positions turned out to be prime.

    What was the ten-figure number?

    This puzzle was originally published with no title.

    [teaser2515]

     
    • Jim Randell's avatar

      Jim Randell 9:39 am on 3 October 2025 Permalink | Reply

      If we suppose the square number is ABCDE, and the cube is FGHIJ then the combined number is one of:

      AFBGCHDIEJ
      FAGBHCIDJE

      And the corresponding prime positions (2nd, 3rd, 5th, 7th) form a prime.

      So the prime is:

      AFBGCHDIEJFBCD
      FAGBHCIDJEAGHI

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

      It runs in 114ms. (Internal runtime is 43ms).

      from enigma import (SubstitutedExpression, is_prime, printf)
      
      # use the SubstitutedExpression solver to find the square and the cube
      p = SubstitutedExpression(
        ["is_square(ABCDE)", "is_cube(FGHIJ)"],
        answer="(ABCDE, FGHIJ, AFBGCHDIEJ, FBCD, FAGBHCIDJE, AGHI)",
        # [optional] squares cannot end in 2, 3, 7, 8
        d2i={ 0: 'AF', 2: 'E', 3: 'E', 7: 'E', 8: 'E' }
      )
      
      # look for viable solutions
      for (sq, cb, n1, p1, n2, p2) in p.answers(verbose=0):
        (f1, f2) = (is_prime(p1), is_prime(p2))
        if f1 or f2:
          printf("square = {sq}, cube = {cb}")
          if f1: printf("-> n = {n1}, prime = {p1}")
          if f2: printf("-> n = {n2}, prime = {p2}")
          printf()
      

      Solution: The 10-digit number is: 5204137869.

      So the 4-digit prime is 2017, the odd digits form 50176 (= 224^2), and the even digits form 24389 (= 29^3).


      For a faster runtime we can collect the squares and cubes up front.

      The following Python program has an internal runtime of 479µs.

      from enigma import (
        defaultdict, irange, sq, cb, diff, cproduct,
        interleave, restrict, is_prime, join, printf
      )
      
      # index numbers (with distinct digits) by digit content
      def numbers(seq):
        d = defaultdict(list)
        for n in seq:
          s = str(n)
          # check digits are distinct
          if len(s) != len(set(s)): continue
          d[join(sorted(s))].append(s)
        return d
      
      # find candidate squares and cubes
      sqs = numbers(sq(x) for x in irange(100, 316))
      cbs = numbers(cb(x) for x in irange(22, 46))
      
      # consider possible cubes
      for (kc, vs) in cbs.items():
        # and look for corresponding squares
        ks = join(diff("0123456789", kc))
        for (square, cube) in cproduct([sqs[ks], vs]):
          # look for possible interleavings
          for (a, b) in [(square, cube), (cube, square)]:
            n = join(interleave(a, b))
            # extract the digits in (1-indexed) prime positions
            p = int(restrict(n, [1, 2, 4, 6]))
            if is_prime(p):
              printf("square = {square}, cube = {cube} -> n = {n}, p = {p}")
      

      Like

  • Unknown's avatar

    Jim Randell 11:06 am on 30 September 2025 Permalink | Reply
    Tags:   

    Teaser 2482: [Running up that hill] 

    From The Sunday Times, 18th April 2010 [link]

    Jan went up the hill and down, and up and down again, running each stretch at a steady speed and taking a whole number of minutes for each of the four stretches (rather more going up!). John timed her over the first two-thirds of the overall course, Mark timed her for the middle two-thirds, and Philip over the final two-thirds. All three times were the same whole number of minutes. The time taken on one stretch was a two-figure number of minutes whose two digits, when reversed, gave the time taken overall for all four.

    What, in order, were the times taken on each of the four stretches?

    This puzzle was originally published with no title.

    [teaser2482]

     
    • Jim Randell's avatar

      Jim Randell 11:06 am on 30 September 2025 Permalink | Reply

      If we suppose the times taken on each of the 4 sections are A, B, C, D.

      The for the timings to be the same whole number value, we have:

      t = A + B + 2C/3
      t = A/3 + B + C + D/3
      t = 2B/3 + C + D

      From which we see that each of A, B, C, D (and hence the total time T = A + B + C + D) must be multiples of 3.

      This Python program runs in 75ms. (Internal runtime is 5.8ms).

      from enigma import (irange, decompose, all_same, nrev, printf)
      
      # decompose T/3 into C/3, B/3, C/3, D/3
      for (b, d, a, c) in decompose(irange(4, 32), 4, increasing=1, sep=0):
        (A, B, C, D) = (3*a, 3*b, 3*c, 3*d)
        # check times are the same
        if not all_same(A + B + 2*c, a + B + C + d, 2*b + C + D): continue
        # calculate total time
        T = A + B + C + D
        X = nrev(T)
        if not (X > 10 and X in {A, B, C, D}): continue
        printf("A={A} B={B} C={C} D={D} -> T={T}")
      

      Solution: The times taken were: A = 21 min; B = 9 min; C = 27 min; D = 15 min.

      And the total time is 72 min.

      Like

  • Unknown's avatar

    Jim Randell 6:58 am on 28 September 2025 Permalink | Reply
    Tags: by: Henry C Warburton   

    Teaser 3288: Todd’s password 

    From The Sunday Times, 28th September 2025 [link] [link]

    Todd privately tells each of four of his friends a letter of the password to his phone. All their letters have a different position in the password, but they do not know the position of their letter. He gives them all a list of 12 possible passwords, one of which is correct:

    STEW, BOYS, PANS, STIG, NILE, LEER, STEM, WERE, GEMS, STAB, TEST, SPOT.

    He asks them all in turn if they know the password, and they all say no. He repeats this and gets the same response a number of times until the first friend to be asked says yes, and the rest no. Upon announcing that the first friend’s letter is in the second half of the alphabet, they all say yes.

    What is Todd’s password?

    [teaser3288]

     
    • Jim Randell's avatar

      Jim Randell 8:06 am on 28 September 2025 Permalink | Reply

      I found the logic for this puzzle quite involved, in order to ensure all the given conditions are met.

      If the password contains a letter that appears in only one of the candidate words, then whoever is given that letter will be able to declare the password immediately. And if the password is declared, the other friends will know it is one of the candidates that contains a unique letter, and if the letter they have been given appears in only one of the uniquely identifiable words, then they too will be able to declare the password.

      This Python 3 program runs in 61ms. (Internal runtime is 192µs).

      from enigma import (defaultdict, irange, inf, multiset, singleton, diff, printf)
      
      # possible passwords
      passwords = [
        "STEW", "BOYS", "PANS", "STIG", "NILE", "LEER",
        "STEM", "WERE", "GEMS", "STAB", "TEST", "SPOT"
      ]
      
      # count the number of steps in the process
      for n in irange(1, inf):
      
        # collect passwords by letters involved
        d = defaultdict(list)
        for pw in passwords:
          for x in set(pw):
            d[x].append(pw)
      
        # collect candidates identified by a unique letter
        ks = set(vs[0] for vs in d.values() if len(vs) == 1)
        if not ks: break
        printf("[{n}] {ks} can be deduced")
      
        # there must be multiple passwords that can be deduced
        # (or everyone can declare); and the process is repeated
        # "a number of times", so lets say more than 2
        if len(ks) > 1 and n > 2:
      
          # consider possible passwords that can be declared
          ss = list()
          for k in ks:
            # just one friend can deduce the password
            j = singleton(j for (j, x) in enumerate(k) if len(d[x]) == 1)
            if j is not None:
              ss.append((k, j))
              printf("[{n}] only friend {j} can deduce {k!r}; friend was told {k[j]!r}")
      
          # letters other than that told to the declared friend must be ambiguous
          if len(ss) > 1:
            # collect how many passwords use the other letters
            m = multiset()
            for (k, j) in ss:
              m.update_from_seq(set(k).difference({k[j]}))
            # check all are ambiguous
            if all(v > 1 for v in m.values()):
              # knowing the letter told to the declared friend is in N-Z gives a unique solution
              r = singleton((k, j) for (k, j) in ss if k[j] > 'M')
              if r is not None:
                # output solution
                (k, j) = r
                printf("SOLUTION: password = {k!r} [friend {j} was told {k[j]!r}]")
      
        # remove the candidates
        passwords = diff(passwords, ks)
      

      Solution: Todd’s password is: STEW.


      A manual analysis:

      At step 1 BOYS can be uniquely identified by the letter Y. So if any friend had been told Y they would be able to deduce the password. And if they did declare they knew the password, then all the others could would also know the password, as it is the only candidate that can be declared at step 1.

      But this doesn’t happen, so BOYS can be removed from the list of candidate passwords.

      At step 2, from the remaining candidate passwords, both SPOT and STAB can be uniquely identified (by O and B respectively). And if one of the friends did declare they knew the password, then if it was SPOT, whoever had been told P would also be able to declare; and if it was STAB, whoever had been told A could declare. The friends that had been told S and T still would not know which it was. Although if they were told the first friend to declare had been told a letter in the second half of the alphabet, they could them both declare SPOT.

      But this doesn’t happen, so SPOT and STAB can be removed from the list.

      At step 3, PANS can be uniquely identified (by the letters P and A). So two friends could declare immediately, and the other two could then follow.

      But this doesn’t happen, so PANS can be removed from the list.

      At step 4, NILE can be identified (by the letter N). So one friend can declare immediately, and the other friends can then follow.

      But this doesn’t happen, so NILE can be removed from the list.

      At step 5, both STIG and LEER can be uniquely identified (by I and L respectively). And if one friend declares at this stage, the other friends were told (S, T, G) or (E, E, R). None of the letters are ambiguous between the choices, and so all the other friends could immediately follow.

      But this doesn’t happen, so STIG and LEER can be removed from the list.

      At step 6, both GEMS and WERE can be identified (by G and R respectively). And if one friend declares at this stage, the other friends were told (E, M, S) or (W, E, E). So the friends that were told a letter other than E can also declare. If this leads to two friends declaring, the one remaining friend (who was told E) can also declare for GEMS; but if only one additional friend declares, the two remaining friends (who were told E) can declare for WERE.

      But this doesn’t happen, so GEMS and WERE can be removed from the list.

      At step 7, the remaining candidates are: STEW, STEM, TEST. Both STEW and STEM can be uniquely identified (by W and M respectively). And if one friend declares, then the other friends must have been told (S, T, E) in either case, so there can be no further immediate declarations.

      This does match the situation described, and then Todd reveals that the friend who declared was told a letter in the second half of the alphabet, and so the password must be STEW, and the other friends can declare.

      So this is a viable situation, and leads to an answer to the puzzle.

      But if this doesn’t happen, STEW and STEM can be removed from the list.

      At step 8, the only remaining candidate is TEST, and so all friends can declare immediately.

      But this doesn’t happen.

      So the only viable solution is if the password is STEW and the first declaration occurs at step 7, by the friend who was told W.

      Like

    • Frits's avatar

      Frits 7:11 am on 4 October 2025 Permalink | Reply

      from itertools import combinations
       
      pws = {'STEW', 'BOYS', 'PANS', 'STIG', 'NILE', 'LEER', 
             'STEM', 'WERE', 'GEMS', 'STAB', 'TEST', 'SPOT'}       
      ltrs = sorted(set(''.join(pws)))
          
      rnd = 1      
      # while there are passwords with a unique letter
      while (uniq := [(x, y[0]) for x in ltrs if len(y := [pw for pw in pws 
                                                  if x in pw]) == 1]):
        uniq_ltrs, uniq_pws = [x for x, y in uniq], [y for x, y in uniq] 
        
        # the process is repeated "a number of times", so lets say more than 2
                        
        # we need at least 2 different passwords for people not to be sure 
        # only one missing letter in the second half identifies the password
        if (rnd > 3 and len(set(uniq_pws)) > 1 and 
           (uniq_ltrs[-2] < 'N' and uniq_ltrs[-1] > 'M')): 
          ans = uniq_pws[-1]
          # the answer may not contain more than one unique letter
          if uniq_pws.count(ans) > 1: continue
          oltrs = {x for x in ans if x != uniq_ltrs[-1]}
          opws = ''.join(uniq_pws[:-1])
          # all non-unique letters in answer must be found in other passwords
          if all(ltr in opws for ltr in oltrs):
            print("answer:", ans)
        
        print(f"eliminate {(ws := set(uniq_pws))}, remaining:", pws := pws - ws)  
        rnd += 1  
      

      Like

  • Unknown's avatar

    Jim Randell 10:15 am on 26 September 2025 Permalink | Reply
    Tags:   

    Teaser 2479: [The Sultan’s coins] 

    From The Sunday Times, 28th March 2010 [link]

    The Sultan of Aurica introduces five coins, each worth a different whole number of cents less than 100, with face values equal to the value of metal used. All are circular and of the same metal, but any coin worth an odd number of cents is twice as thick as the even-valued ones. The coin showing the sultan’s head can just cover two touching coins of equal value or seven smaller coins of equal value, with six arranged around the edge, one in the middle. All of that is also true of the coin showing the sultan’s palace.

    What are the five values?

    This puzzle was originally published with no title.

    [teaser2479]

     
    • Jim Randell's avatar

      Jim Randell 10:15 am on 26 September 2025 Permalink | Reply

      The following Python program runs in 61ms. (Internal runtime is 135µs).

      from enigma import (irange, div, subsets, union, printf)
      
      # collect radius^2 -> coin value
      d = dict()
      for v in irange(1, 99):
        k = (v if v % 2 == 1 else 2 * v)
        d[k] = v
      
      # now look for k values where k/9 and k/4 are also values
      ss = list()
      for (k, v) in d.items():
        (v3, v2) = (d.get(div(k, 9)), d.get(div(k, 4)))
        if v3 and v2:
          printf("[{v} covers 6x {v3} or 2x {v2}]")
          ss.append((v, v3, v2))
      
      # find 2 sets that use exactly 5 coins between them
      for (s1, s2) in subsets(ss, size=2):
        rs = union([s1, s2])
        if len(rs) == 5:
          printf("{s1} & {s2} -> {rs}", rs=sorted(rs))
      

      Solution: The coin values are: 2, 8, 9, 18, 72 cents.

      There are 4 candidate configurations of coins where the largest can exactly cover 6 and 2 of the smaller coins.

      18 cent covers 6× 2 cent or 2× 9 cent = (18, 2, 9)
      54 cent covers 6× 6 cent or 2× 27 cent = (54, 6, 27)
      72 cent covers 6× 8 cent or 2× 18 cent = (72, 8, 18)
      90 cent covers 6× 10 cent or 2× 45 cent = (90, 10, 45)

      And we need to find 5 coins that can make two of these sets (i.e. one of the coins is shared between sets).

      The solution comes from:

      (18, 2, 9) and (72, 8, 18)

      Like

  • Unknown's avatar

    Jim Randell 10:32 am on 23 September 2025 Permalink | Reply
    Tags:   

    Teaser 2506: [Domestic chores] 

    From The Sunday Times, 3rd October 2010 [link] [link]

    Three brothers share a house in a university city. Each is capable of undertaking two of the four domestic chores required. For instance, only one knows how to vacuum and only one can dust. They have recruited two friends who can do three chores each, so now any chore can be done by three people. Leo does not wash up; in fact, only one person can both wash up and do the laundry. There is one job both Leo and Neil can do, while Keith and Neil can do two jobs together. John can vacuum; Mike cannot. Neil is one of the brothers.

    Who are the other two brothers, and who does the dusting?

    This puzzle was originally published with no title.

    [teaser2506]

     
    • Jim Randell's avatar

      Jim Randell 10:34 am on 23 September 2025 Permalink | Reply

      We can think of filling out a 5×5 binary matrix, indicating whether a person is one of the brothers, and which of the 4 tasks he can do.

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

      It runs in 133ms. (Internal runtime is 439µs).

      #! python3 -m enigma -rr
      
      SubstitutedExpression
      
      #     bro  vcm  dst  wsh  lnd
      #  J:  J    A    F    Q    V
      #  K:  K    B    G    R    W
      #  L:  L    C    H    S    X
      #  M:  M    D    I    T    Y
      #  N:  N    E    P    U    Z
      
      --base=2
      --distinct=""
      
      # there are 3 brothers (and 2 friends)
      "J + K + L + M + N == 3"
      
      # each brother can do exactly 2 tasks (and each friend exactly 3 tasks) [rows]
      --code="person = lambda b, *ts: sum(ts) == (2 if b else 3)"
      "person(J, A, F, Q, V)"
      "person(K, B, G, R, W)"
      "person(L, C, H, S, X)"
      "person(M, D, I, T, Y)"
      "person(N, E, P, U, Z)"
      
      # each task can be done by exactly 3 people [cols]
      --code="task = lambda *ps: sum(ps) == 3"
      "task(A, B, C, D, E)"
      "task(F, G, H, I, P)"
      "task(Q, R, S, T, U)"
      "task(V, W, X, Y, Z)"
      
      # exactly 1 brother knows how to vacuum
      "dot([J, K, L, M, N], [A, B, C, D, E]) == 1"
      
      # exactly 1 brother knows how to dust
      "dot([J, K, L, M, N], [F, G, H, I, P]) == 1"
      
      # L does not wash-up
      "S == 0"
      
      # exactly 1 person can wash-up and do the laundry
      "[Q + V, R + W, S + X, T + Y, U + Z].count(2) == 1"
      
      # there is 1 job both L and N can do
      "[C + E, H + P, S + U, X + Z].count(2) == 1"
      
      # there are 2 jobs K and N can do
      "[B + E, G + P, R + U, W + Z].count(2) == 2"
      
      # J can vacuum
      "A == 1"
      
      # M cannot vacuum
      "D == 0"
      
      # N is one of the brothers
      "N == 1"
      
      --template="(J A F Q V) (K B G R W) (L C H S X) (M D I T Y) (N E P U Z)"
      --solution=""
      --denest=-1
      --verbose="1-W"
      

      The following program can be used to give a more friendly output to the answer:

      from enigma import (SubstitutedExpression, seq2str, printf)
      
      # load the puzzle
      p = SubstitutedExpression.from_file(["{dir}/teaser2506.run"])
      
      # names
      names = ["John", "Keith", "Leo", "Mike", "Neil"]
      tasks = ["brother", "vacuum", "dust", "wash-up", "laundry"]
      results = ["JAFQV", "KBGRW", "LCHSX", "MDITY", "NEPUZ"]
      
      # solve the puzzle and format the solutions
      for s in p.solve(verbose=0):
        # output solution
        for (n, rs) in zip(names, results):
          # collect tasks
          ts = list(t for (t, v) in zip(tasks, rs) if s[v])
          printf("{n}: {ts}", ts=seq2str(ts, sep=", ", enc=""))
        printf()
      

      Solution: The other two brothers are: John and Mike. Keith, Leo and Neil can do dusting.

      The complete solution is:

      Brothers:
      John: vacuuming, laundry
      Mike: washing-up, laundry
      Neil: dusting, washing-up

      Friends:
      Keith: vacuuming, dusting, washing-up
      Leo: vacuuming, dusting, laundry

      Like

      • Frits's avatar

        Frits 1:27 pm on 23 September 2025 Permalink | Reply

        The task function could also have been used instead of the person function.

        Like

  • Unknown's avatar

    Jim Randell 7:03 am on 21 September 2025 Permalink | Reply
    Tags:   

    Teaser 3287: Ferry route 

    From The Sunday Times, 21st September 2025 [link] [link]

    A large circular sea, whose diameter is less than 300km, is served by a ferry that makes a clockwise route around half of the sea, serving the ports of Ayton, Beaton, Seaton and Deaton in that order, then back to Ayton. Deaton is diametrically opposite Ayton. Each of the four legs of its route is a straight line, two of them being the same length. The lengths of all of the legs are whole numbers of km, and they all happen to be square numbers.

    In increasing order, what are the three different leg lengths?

    [teaser3287]

     
    • Jim Randell's avatar

      Jim Randell 7:18 am on 21 September 2025 Permalink | Reply

      See: Teaser 2549.

      If the diameter of the circle is x, and the lengths of the non-diameter legs are a, b, c, then we have:

      x³ − (a² + b² + c²)x − 2abc = 0

      The following Python program runs in 62ms. (Internal runtime is 643µs).

      from enigma import (powers, inf, first, lt, subsets, pi, cb, sumsq, printf)
      
      # squares less than 300
      sqs = list(first(powers(1, inf, 2), lt(300)))
      
      # choose 3 different lengths (x is the diameter)
      for (a, b, x) in subsets(sqs, size=3):
        # duplicate one of the short lengths
        for c in (a, b):
          # check for a viable quadrilateral
          if not (x < a + b + c < pi * x): continue
          if not (cb(x) - sumsq([a, b, c]) * x - 2 * a * b * c == 0): continue
          # output solution
          printf("{a}, {b}, {x} [and {c}]")
      

      Solution: The leg lengths are 36, 49, 81 km.

      The duplicate leg length is 36 km.

      The next smallest solution is at 4× these lengths, which brings the diameter to 324 km.

      Like

      • Jim Randell's avatar

        Jim Randell 8:49 am on 22 September 2025 Permalink | Reply

        We can suppose that the non-diameter sides are (a, b, a) (i.e. c = a, and it may be the case that a > b), and then the equation can be simplified to:

        2a² = x(x − b)

        So by choosing b and x we can then straightforwardly calculate the corresponding value for a, and check it is viable (i.e. a square):

        from enigma import (powers, inf, first, lt, subsets, is_square, div, ordered, printf)
        
        # squares less than 300
        sqs = list(first(powers(1, inf, 2), lt(300)))
        
        # choose 2 different lengths (x is the diameter)
        for (b, x) in subsets(sqs, size=2):
          # a is the duplicated side
          a = is_square(div(x * (x - b), 2))
          if a is None or a not in sqs: continue
          # output solution
          (p, q) = ordered(a, b)
          printf("{p}, {q}, {x} [and {a}]")
        

        Liked by 1 person

        • Frits's avatar

          Frits 2:29 pm on 22 September 2025 Permalink | Reply

          I had come up with similar equation. You can also deduce the parity of “a”.

          Like

    • Ruud's avatar

      Ruud 10:35 am on 21 September 2025 Permalink | Reply

      import math
      import itertools
      
      for ab, bs, sd, da in itertools.product((i * i for i in range(1, 18)), repeat=4):
          try:
              angle_ab = math.asin(ab / da) * 2
              angle_bs = math.asin(bs / da) * 2
              angle_sd = math.asin(sd / da) * 2
      
              if angle_ab + angle_bs + angle_sd == math.pi:
                  print(sorted({ab, bs, sd, da}))
          except ValueError:
              ...
      

      Like

      • Jim Randell's avatar

        Jim Randell 11:38 am on 21 September 2025 Permalink | Reply

        @Ruud: How does this code ensure that two of the leg lengths are the same?

        And it is probably better to use [[ math.isclose() ]] rather than testing floats for equality using ==.

        Like

        • Ruud's avatar

          Ruud 12:55 pm on 21 September 2025 Permalink | Reply

          I just checked manually by checking that there are 3 different length. Could have added a simple test, of course.

          Yes, math.isclose would have been better, but as the equality check gave already an answer, I thought I could just refrain from isclose.

          Like

        • Ruud's avatar

          Ruud 4:14 pm on 21 September 2025 Permalink | Reply

          @Jim
          I just checked manually that there are exactly three different lengths, but a test would be better.

          Yes, math.isclose would have beeen safer.

          Here’s my updated version:

          import math
          import itertools
          
          for ab, bs, sd, da in itertools.product((i * i for i in range(1, 18)), repeat=4):
              try:
                  angle_ab = math.asin(ab / da) * 2
                  angle_bs = math.asin(bs / da) * 2
                  angle_sd = math.asin(sd / da) * 2
          
                  if math.isclose(angle_ab + angle_bs + angle_sd, math.pi) and len(sol := {ab, bs, sd, da}) == 3:
                      print(sorted(sol))
              except ValueError:
                  ...
          
          peek("done")
          

          Like

    • Alex.T.Sutherland's avatar

      Alex.T.Sutherland 10:42 am on 28 September 2025 Permalink | Reply

      TEASER 3287.....FERRY ROUTE.
      Given:-		Cyclic Quadrilateral [A B C D] with lengths [a b c d], d = diameter; 
      		               All sides are of perfect square length.
      Method:-	Using Ptolemy's Theorem relating the diagonals to the sides
      		               of the quadrilateral and that right angles are created at B & C
      		              by the diagonals the following equation is derived:-
      
      		              (d^2 - a^2) * (d^2 - c^2) = (b*d + a*c)^2
      
      		              If 'c' is made equal to 'a' the following can be  derived:-
      
      Answer:-	2*a^2  = d*(d-b);
      
      		              Choose 3 squares from the given set to satisfy this equation.
      Side Line:-	The path could be 'abad' or'aabd' but the answer is the same for the lengths.
      		              (neglecting symmetry eg 'baa' is the same as 'aab')'
      		             For my answer the sum of the lengths 'aba' or 'aab' is a perfect square.
      

      Like

  • Unknown's avatar

    Jim Randell 9:05 am on 20 September 2025 Permalink | Reply
    Tags:   

    Teaser 2491: [Sharing sections] 

    From The Sunday Times, 20th June 2010 [link] [link]

    One Sunday, 11 friends — Burchy, Chop, Grubs, Hobnob, Lanky, Mouldy, Ruffan, Shultz, Tufty, Wotfor and Zebedee — shared out The Sunday Times to read. They each got one of the following sections: Appointments, Business, Culture, Home, InGear, Magazine, Money, News Review, Sport, Style and Travel. For each person, the title of their section contained no letter of the alphabet that was also in their name.

    Who read each of the sections? (With the sections in the above order, list the initials of their readers).

    This puzzle was originally published with no title.

    [teaser2491]

     
    • Jim Randell's avatar

      Jim Randell 9:06 am on 20 September 2025 Permalink | Reply

      This is the remaining puzzle from the list of Graham Smithers “grouping” style puzzles.

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

      It runs in 65ms. (Internal runtime is 3.1ms).

      from enigma import grouping
      
      # categories
      names = (
        "Burchy", "Chop", "Grubs", "Hobnob", "Lanky", "Mouldy",
        "Ruffian", "Schultz", "Tutfy", "Wotfor", "Zebedee",
      )
      sections = (
        "Appointments", "Business", "Culture", "Home", "InGear",
        "Magazine", "Money", "NewsReview", "Sport", "Style", "Travel",
      )
      
      grouping.solve([names, sections], grouping.share_letters(0))
      

      Solution: The initials of the readers (in section order) are: B, W, H, L, S, T, G, M, Z, R, C.

      Here is the full solution:

      Burchy, Appointments
      Chop, Travel
      Grubs, Money
      Hobnob, Culture
      Lanky, Home
      Mouldy, NewsReview
      Ruffian, Style
      Schultz, InGear
      Tutfy, Magazine
      Wotfor, Business
      Zebedee, Sport

      Like

  • Unknown's avatar

    Jim Randell 10:44 am on 19 September 2025 Permalink | Reply
    Tags:   

    Teaser 2460: [Birthdays] 

    From The Sunday Times, 15th November 2009 [link]

    Seven friends whose first names were Ben, Cecil, Dion, Echo, Flor, Gobbins and Hugh had surnames Jack, Kain, Lack, Mines, Neal, O’Hara and Platts in some order.

    They were all born on different days of the week. For each person, if you look at any two of their first name, their surname and the day of their birth, then there is just one letter of the alphabet that occurs in both.

    Who was born on Monday (first name and surname)?

    This puzzle was originally published with no title.

    [teaser2460]

     
    • Jim Randell's avatar

      Jim Randell 10:45 am on 19 September 2025 Permalink | Reply

      This is the earliest of Graham Smithers “grouping” style puzzles that I have found.

      Some time ago, I added the [[ grouping ]] functionality in enigma.py specifically to solve this kind of puzzle (see: Teaser 2816), and it can be used to solve this puzzle.

      This Python program runs in 65ms. (Internal runtime is 828µs).

      from enigma import grouping
      
      firsts = ["Ben", "Cecil", "Dion", "Echo", "Flor", "Gobbins", "Hugh"]
      lasts = ["Jack", "Kain", "Lack", "Mines", "Neal", "O'Hara", "Platts"]
      days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
      
      grouping.solve([firsts, lasts, days], grouping.share_letters(1))
      

      Solution: Flor Lack was born on Monday.

      The full list is:

      Ben Kain, Tuesday
      Cecil Jack, Wednesday
      Dion Neal, Saturday
      Echo Mines, Thursday
      Flor Lack, Monday
      Gobbins Platts, Friday
      Hugh O’Hara, Sunday


      The full list of “grouping” style puzzles I’ve found is (2009 – 2019):

      Teaser 2460, Teaser 2491, Teaser 2507, Teaser 2612, Teaser 2633, Teaser 2682, Teaser 2704, Teaser 2725, Teaser 2757, Teaser 2770, Teaser 2800, Teaser 2816, Teaser 2831, Teaser 2833, Teaser 2860, Teaser 2880, Teaser 2892, Teaser 2895, Teaser 2959

      Like

    • GeoffR's avatar

      GeoffR 3:52 pm on 27 September 2025 Permalink | Reply

      An interesting experience using AI for this teaser.
      ChatGPT Plus produced an initial solution for this teaser. Then I asked it to speed up the original solution, and it produced all the code in this posting to give a run-time of less than 1 millisecond! – apart from the Timer code from the enigma.py library. I the quizzed AI why this backtracking solution was so fast and about other solution techniques. ChatGPT then reverted to the basic AI model.

      AI response below:
      —————–
      Summary for This Teaser
      ———————–
      Problem type: Matching people (forename + surname) to days with constraints.

      Applicable techniques:
      ———————
      Backtracking (fastest)
      Forward checking / constraint propagation
      Heuristic ordering (most constrained first)
      Permutation filtering / brute force
      CSP solvers
      Manual logic deduction

      Why backtracking is fastest:
      —————————
      Incremental assignments with immediate constraint checks → prunes invalid branches early.

      Recursive structure matches problem naturally.

      Can combine heuristics to further reduce search space.

      Other viable methods: Brute force, CSP solvers, logical deduction — but backtracking is the simplest and fastest in Python for this teaser.

      # ST2460 ver 2 - ChatGPT Plus
      
      from enigma import Timer
      timer = Timer('ST2460', timer='E')
      timer.start()
      
      import itertools
      
      first_names = ["Ben", "Cecil", "Dion", "Echo", "Flor",
                     "Gobbins", "Hugh"]
      surnames = ["Jack", "Kain", "Lack", "Mines", "Neal",
                  "O'Hara", "Platts"]
      days = ["Monday", "Tuesday", "Wednesday", "Thursday",
              "Friday", "Saturday", "Sunday"]
      
      # Condition: For each person, any two of (first, surname, day)
      # must share exactly one common letter.
      def valid_person(first, surname, day):
          f, s, d = set(first.lower()), set(surname.lower()), set(day.lower())
          
      def one_common(a, b):
          return len(a & b) == 1
          return one_common(f, s) and one_common(f, d) and one_common(s, d)
      
      # Precompute valid surname - day pairs for each first name
      day_options = {}
      
      for f in first_names:
          options = []
          for s in surnames:
              for d in days:
                  if valid_person(f, s, d):
                      options.append((s, d))
          day_options[f] = options
      
      # Backtracking search
      def backtrack(idx, used_surnames, used_days, assignment):
          if idx == len(first_names):
              print("Solution found:")
              for f in first_names:
                  s, d = assignment[f]
                  print(f"  {f} {s} — {d}")
              return True
          
          f = first_names[idx]
          for s, d in day_options[f]:
              if s not in used_surnames and d not in used_days:
                  assignment[f] = (s, d)
                  if backtrack(idx + 1, used_surnames | {s}, \
                      used_days | {d}, assignment):
                      return True
                  del assignment[f]
          return False
      
      backtrack(0, set(), set(), {})
      
      timer.stop()      
      timer.report()
      
      '''
      Solution found:
        Ben Kain — Tuesday
        Cecil Jack — Wednesday
        Dion Neal — Saturday
        Echo Mines — Thursday
        Flor Lack — Monday
        Gobbins Platts — Friday
        Hugh O'Hara — Sunday
        
      [ST2460] total time: 0.0004500s (450.00us)
      '''
      
      
      

      Like

      • Jim Randell's avatar

        Jim Randell 10:46 pm on 27 September 2025 Permalink | Reply

        @Geoff: The posted code doesn’t work for me (no output). The valid_person() and one_common() functions don’t look right.

        Like

    • GeoffR's avatar

      GeoffR 10:59 pm on 27 September 2025 Permalink | Reply

      @ Jim: It worked OK on my laptop, but not when I tested it on my desktop.
      In view of your reservations, I think it best to remove my posting for now.
      Will have another look at the AI code.

      Like

      • GeoffR's avatar

        GeoffR 10:03 am on 28 September 2025 Permalink | Reply

        Maybe it was my error copying, reformatting, or posting the original code.

        Errors were found today by ChatGPT on re- submitting the code to it with a request to find any errors. Here is AI’s reply and the updated code:

        Mistakes:

        1) valid_person defines f, s, d but doesn’t actually return a result.

        2) The return inside one_common after the first return is unreachable.

        3)The logic for checking conditions belongs inside valid_person, not one_common.

        
        # ST 2460 - ChatGPT_update 28-9-25
        
        from enigma import Timer
        timer = Timer('ST2460', timer='E')
        timer.start()
        
        import itertools
        
        first_names = ["Ben", "Cecil", "Dion", "Echo", "Flor",
                       "Gobbins", "Hugh"]
        surnames = ["Jack", "Kain", "Lack", "Mines", "Neal",
                    "O'Hara", "Platts"]
        days = ["Monday", "Tuesday", "Wednesday", "Thursday",
                "Friday", "Saturday", "Sunday"]
        
        # Condition: For each person, any two of (first, surname, day)
        # must share exactly one common letter.
        def one_common(a, b):
            return len(a & b) == 1
        
        def valid_person(first, surname, day):
            f, s, d = set(first.lower()), set(surname.lower()), set(day.lower())
            return one_common(f, s) and one_common(f, d) and one_common(s, d)
        
        # Precompute valid surname - day pairs for each first name
        day_options = {}
        
        for f in first_names:
            options = []
            for s in surnames:
                for d in days:
                    if valid_person(f, s, d):
                        options.append((s, d))
            day_options[f] = options
        
        # Backtracking search
        def backtrack(idx, used_surnames, used_days, assignment):
            if idx == len(first_names):
                print("Solution found:")
                for f in first_names:
                    s, d = assignment[f]
                    print(f"  {f} {s} — {d}")
                return True
            
            f = first_names[idx]
            for s, d in day_options[f]:
                if s not in used_surnames and d not in used_days:
                    assignment[f] = (s, d)
                    if backtrack(idx + 1, used_surnames | {s}, \
                        used_days | {d}, assignment):
                        return True
                    del assignment[f]
            return False
        
        backtrack(0, set(), set(), {})
        
        timer.stop()      
        timer.report()
        
        '''
        Solution found:
          Ben Kain — Tuesday
          Cecil Jack — Wednesday
          Dion Neal — Saturday
          Echo Mines — Thursday
          Flor Lack — Monday
          Gobbins Platts — Friday
          Hugh O'Hara — Sunday
        [ST2460] total time: 0.1103383s (110.34ms)
        '''
        
        
        

        Like

  • Unknown's avatar

    Jim Randell 1:57 pm on 16 September 2025 Permalink | Reply
    Tags: ,   

    Teaser 2513: [Golf ball stacking] 

    From The Sunday Times, 21st November 2010 [link] [link]

    At the golf ball factory 10,000 balls are stored in a stack for some weeks to harden. The stack has several layers, the top layer being a single row of balls. The second layer has two rows, each with one ball more than the top row. The third layer has three rows each with two balls more than the top row, and so on.

    Of the several shapes of stack possible, the one used takes up the smallest floor area.

    How many balls are on the bottom layer?

    This puzzle was originally published with no title.

    [teaser2513]

     
    • Jim Randell's avatar

      Jim Randell 1:57 pm on 16 September 2025 Permalink | Reply

      If we consider a stack that is k layers deep and has a line of n balls along the top.

      First we see that the number of balls in the base layer is: A = k(n + k − 1).

      And we can chop a slice one ball thick from the end to get tri(k) balls, and we can do this n times (once for each ball on the top layer).

      The the number of “extra” ball in each layer is: 1×0, 2×1, 3×2, 4×3, …, k×(k − 1).

      So in an arrangement with k layers we can calculate a value of n that would give 10,000 balls in total, and if we get an integer out of the calculation, then this is a viable arrangement.

      This Python program runs in 68ms. (Internal runtime is 56µs).

      from enigma import (Accumulator, irange, inf, tri, printf)
      
      # total number of balls
      T = 10000
      
      # collect minimal base
      sol = Accumulator(fn=min)
      
      # consider increasing numbers of layers, k
      x = 0  # extra balls to complete the pile
      for k in irange(1, inf):
        # add in the extra balls
        x += k * (k - 1)
        # how many repeats of tri(k) can we make?
        (n, r) = divmod(T - x, tri(k))
        if n < 1: break
        if r != 0: continue
        # calculate area of base
        A = k * (n + k - 1)
        # output configuration
        printf("[{k} layers -> n={n} base={A}]")
        sol.accumulate(A)
      
      # output solution
      printf("minimal base = {sol.value}")
      

      Solution: There are 984 balls in the bottom layer.

      There are 24 layers:

      1: 1×18
      2: 2×19
      3: 3×20

      23: 23×40
      24: 24×41 = 984 balls

      We can check the total number of balls is 10,000, by adding the number of balls in each layer:

      >>> dot(irange(1, 24), irange(18, 41))
      10000
      

      We can determine a formula for the total number of balls T[n, k], starting with a line of n balls and constructing k layers:

      T[n, k] = n × tri(k) + (0×1 + 1×2 + … + (k − 1)×k)
      T[n, k] = n × k(k + 1)/2 + (k − 1)k(k + 1)/3
      T[n, k] = k(k + 1)(3n + 2k − 2)/6

      Like

  • Unknown's avatar

    Jim Randell 7:08 am on 14 September 2025 Permalink | Reply
    Tags:   

    Teaser 3286: Water stop 

    From The Sunday Times, 14th September 2025 [link] [link]

    Chuck and his brother live on a long straight road that runs from west to east through their ranches. Chuck’s ranch is 13 km west of his brother’s. The two brothers often go from one ranch to the other on horseback, but go via a nearby river so that their horses may be watered. The shortest route via the river consists of two straight sections, one being 11 km longer than the other. The point at which they reach the river is 6 km north of the road.

    What is the total length of the route between the ranches that goes via the river?

    [teaser3286]

     
    • Jim Randell's avatar

      Jim Randell 7:26 am on 14 September 2025 Permalink | Reply

      Solving a couple of simultaneous quadratic equations gives an unlikely looking answer:

      Suppose the the ranches are at C and B, and the water is at W, 6 km north of a point X on the road.

      (We can assume the distance BW is greater than CW, if it is not the situation is a reflection of this).

      Then we have two right angled triangles:

      CXW: horizontal = y; vertical = 6; hypotenuse = x
      BXW: horizontal = 13 − y; vertical = 6; hypotenuse = x + 11

      And the required answer is the sum of the two hypotenuses (= 2x + 11).

      This then gives rise to two equations:

      CXW: x² = y² + 6²
      BXW: (x + 11)² = (13 − y)² + 6²

      The equations can be solved manually, but here is a program that uses a numerical solver.

      This Python program runs in 70ms. (Internal runtime is 145µs).

      from enigma import (hypot, find_zero, printf)
      
      # calculate values for x from each triangle
      fx1 = lambda y: hypot(y, 6)
      fx2 = lambda y: hypot(13 - y, 6) - 11
      
      # calculate values of x from each triangle
      # and return the difference
      f = lambda y: fx1(y) - fx2(y)
      
      # find solutions when the difference is zero
      r = find_zero(f, -100, 100)
      y = r.v
      x = fx1(y)
      d = x + (x + 11)
      printf("d={d:.3f} [x={x:.3f} y={y:.3f}]")
      

      Solution: The total length of the route via the river is 26 km.

      The river is 7.5 km from one ranch, and 18.5 km from the other. So the total journey is twice as far as following the road.

      Or we can mirror the diagram (so that X is 4.5 km beyond B) to give a second scenario:


      I think the puzzle text would have been less confusing if the brothers had just met for a picnic at W. Each travelling via a straight track from their respective ranch; one travelling 11 km further than the other. What was the total distance to the picnic spot travelled by the brothers?

      Like

      • Jim Randell's avatar

        Jim Randell 10:22 am on 14 September 2025 Permalink | Reply

        Or a similar approach using 2D geometry routines from enigma.py:

        from enigma import (point_dist, find_values, printf)
        
        # positions for C and B
        (C, B) = ((0, 0), (13, 0))
        # and the river is at W = (x, 6)
        
        # calculate difference between distances CW, BW
        def f(x):
          W = (x, 6)
          return abs(point_dist(C, W) - point_dist(B, W))
        
        # find solutions when the difference is 11
        for r in find_values(f, 11, -100, 100):
          x = r.v
          W = (x, 6)
          (d1, d2) = (point_dist(C, W), point_dist(B, W))
          d = d1 + d2
          printf("d={d:.3f} [W=({x:.3f}, 6); d1={d1:.3f} d2={d2:.3f}]")
        

        Like

      • Jim Randell's avatar

        Jim Randell 1:20 pm on 14 September 2025 Permalink | Reply

        And here is an exact solution, that uses the [[ Polynomial ]] class from the enigma.py library, to derive a quadratic equation, which can then be solved to give the required answer.

        from enigma import (Rational, Polynomial, sq, printf)
        
        Q = Rational()
        q12 = Q(1, 2)  # q12 = 0.5 will also work
        
        # calculate area^2 of the triangle using: (1/2) base . height
        A2 = sq(q12 * 13 * 6)
        
        # sides of the triangle (as polynomials)
        (a, b, c) = (Polynomial("x"), Polynomial("x + 11"), 13)
        
        # polynomial for area^2 of the triangle using Heron's formula
        s = (a + b + c) * q12
        p = s * (s - a) * (s - b) * (s - c)
        
        # find solutions of p(x) = A2 for positive x
        for x in p.roots(A2, domain='F', include='+'):
          # calculate total distance
          d = x + (x + 11)
          printf("d={d} [x={x}]")
        

        The program determines that the required solution is derived from the positive solution of the following quadratic equation (which it then solves):

        12x² + 132x − 144 = 1521

        (2x − 15)(2x + 37) = 0

        x = 7.5

        Liked by 1 person

    • Ruud's avatar

      Ruud 10:11 am on 14 September 2025 Permalink | Reply

      @Jim
      I don’t know why you say that you get an unlikely answer.
      The negative y just means that the point W is west of C (or east of B). And then it all fits.

      I have the following solution:

      import sympy as sp
      
      l = sp.symbols("l", real=True)
      eq = sp.Eq(sp.sqrt(((l + 11) / 2) ** 2 - 36) - 13, sp.sqrt(((l - 11) / 2) ** 2 - 36))
      print(sp.solveset(eq, l, domain=sp.S.Reals))
      

      Liked by 1 person

      • Jim Randell's avatar

        Jim Randell 11:43 am on 14 September 2025 Permalink | Reply

        @ruud: I said it looks unlikely, because the puzzle text can be read to imply that the brothers do not follow the road because the horses would have to go for 13 km without water.

        Like

        • Ruud's avatar

          Ruud 11:59 am on 14 September 2025 Permalink | Reply

          I didn’t read it like that.

          Like

      • Ruud's avatar

        Ruud 3:41 pm on 14 September 2025 Permalink | Reply

        The advantage of sympy over enigma.find_zero is that sympy results in an exact answer, whereas find_zero is an approximation using a golden section search.
        But of courae, sympy can’t solve arbitary expressions.

        Like

        • Jim Randell's avatar

          Jim Randell 4:14 pm on 14 September 2025 Permalink | Reply

          @Ruud: True. Although if we substitute the answer found by my programs using [[ find_zero() ]] into the equations we see that they are indeed exact answers. And the code runs over 1000× faster than using SymPy.

          But I also wrote a program that gives an exact answer using the [[ Polynomial() ]] class. And it still runs many times faster than using SymPy. (It generates a quadratic equation, and then solves it using the quadratic formula).

          Like

          • Ruud's avatar

            Ruud 4:28 pm on 14 September 2025 Permalink | Reply

            Thanks for the clear explanation.

            Like

    • Alex T Sutherland's avatar

      Alex T Sutherland 3:45 pm on 17 September 2025 Permalink | Reply

      Problem Solution:-    Using Cosine Rule and Quadratic Equation. 
      Triangle:-            Obtuse. Sides 'a' , 'b' = 'a'+11, & 13.
      Method:-              Solve for side 'a' using the Cosine Rule.
                    ie      b^2 =      [a^2 + 13^2 - 2*a*13*cos(B)]; B =angle oppsite side 'b'.
                            (a+11)^2 = [    "                                                             ];.........(1)
      
                            -cos(B) is determined in terms of 'a' from the small 
                             right angled triangle.
      
                             -cos(B) = sqrt(1- (6/a)^2);                                 ;........(2)
                              
                             From (1) & (2) integer coefficients (m n p) can be
                             determined for the qudratic m*(a*a) + n*a + p = 0;
                             Solve the quadratic for 'a'. May use a roots function
                             or the standard equation.
      
      Problem Answer:-       2*a + 11;
      
      Time:-                 The following were those obtained  from a 15+ years old PC.  
                                         (Running Non Python).
      
      Elapsed time is 0.007039 seconds.    Jim Randell's initial solution.
      
      Elapsed time is 0.000216 seconds.    Above method using a roots solver.
      
      Elapsed time is 0.000102 seconds.    Above method using the standard equation. 
      
      Code length:-         1 line; Maybe 2 in specifing the coefficients.           
      
      SideLine:-            The sum of the digits in the coefficients is 1 more 
                                              than the problem answer.
      

      Like

  • Unknown's avatar

    Jim Randell 8:40 am on 12 September 2025 Permalink | Reply
    Tags:   

    Teaser 2507: [Favourite foods] 

    From The Sunday Times, 10th October 2010 [link] [link]

    Six friends have the first names Bubba, Chris, Derek, Emiren, Finsan and Gilson. They each have a different favourite food — apple, carrot, pear, pinto bean, raspberry and watermelon.

    No two friends have birthdays in the same month. For each friend, any two from their name, their favourite food and their month of birth have just one letter of the alphabet in common.

    In the order of names given above, what are their favourite foods?

    This puzzle was originally published with no title.

    [teaser2507]

     
    • Jim Randell's avatar

      Jim Randell 8:41 am on 12 September 2025 Permalink | Reply

      This is another puzzle that can be solved directly with the [[ grouping ]] solver from the enigma.py library.

      The following Python program runs in 66ms. (Internal runtime is 1.4ms).

      from enigma import grouping
      
      names = ["Bubba", "Chris", "Derek", "Emiren", "Finsan", "Gilson"]
      foods = ["apple", "carrot", "pear", "pinto bean", "raspberry", "watermelon"]
      months = [
        "January", "February", "March", "April", "May", "June", "July",
        "August", "September", "October", "November", "December",
      ]
      
      grouping.solve([names, foods, months], grouping.share_letters(1))
      

      Solution: The favourite foods are: watermelon, pear, pinto bean, carrot, apple, raspberry.

      The complete solution is:

      Bubba, watermelon, July
      Chris, pear, August
      Derek, pinto bean, March
      Emiren, carrot, May
      Finsan, apple, November
      Gilson, raspberry, June

      Like

    • Frits's avatar

      Frits 2:30 pm on 12 September 2025 Permalink | Reply

      names = ["Bubba", "Chris", "Derek", "Emiren", "Finsan", "Gilson"]
      ns = [x.lower() for x in names] 
      foods = ["apple", "carrot", "pear", "pinto bean", "raspberry", "watermelon"]
      fs = [x.strip() for x in foods]
      months = [
        "January", "February", "March", "April", "May", "June", "July",
        "August", "September", "October", "November", "December",
      ]
      ms = [x.lower() for x in months] 
       
      # does sequence <x> share exactly one item with all sequences in <g>
      share1 = lambda x, g: all(len(set(x).intersection(z)) == 1 for z in g)
      
      # pick one value from each entry of a <k>-dimensional dictionary <dct>
      # so that all elements in the <k> values are different
      def pickOneFromEach(k, dct, seen=set(), ss=[]):
        if k == 0:
           yield ss
        else:
          for vs in dct[k - 1]:
            # values in vs may not have been used before
            if seen.isdisjoint(vs):
              yield from pickOneFromEach(k - 1, dct, seen | set(vs), ss + [vs])
      
      # dictionary of food, month per name index
      d = {i: [(fo, mo) for fo in fs if share1(fo, [na]) for mo in ms
               if share1(mo, [na, fo])] for i, na in enumerate(ns)}
      
      for s in pickOneFromEach(len(names), d):
        for i, (f, m) in enumerate(s[::-1]):
          print(f"{names[i]}: {foods[fs.index(f)]}, {months[ms.index(m)]}")
        print()  
      

      Like

  • Unknown's avatar

    Jim Randell 7:06 am on 7 September 2025 Permalink | Reply
    Tags:   

    Teaser 3285: Daughters’ ages 

    From The Sunday Times, 7th September 2025 [link] [link]

    “How old are your five daughters?” Ignacio asked me.

    “Considering their ages as whole numbers, they are all under 20, some of them are the same, and they add up to a prime number”, I answered. “Also, if you were to choose any two of them, no matter which two, their ages would have a common divisor greater than 1”.

    “I can’t work out how old they are”, complained Ignacio.

    “And you still couldn’t even if I told you the sum of all the ages. However, if I told you how old the youngest and oldest are, then you would be able to work out all the ages”, I responded.

    “That’s great, because I now know how old all your daughters are!” Ignacio joyfully said after a pause.

    In increasing order, what are the five ages?

    [teaser3285]

     
    • Jim Randell's avatar

      Jim Randell 7:16 am on 7 September 2025 Permalink | Reply

      (See also: Teaser 2971).

      This is a job for the [[ filter_unique() ]] function from the enigma.py library.

      The following Python program runs in 70ms. (Internal runtime is 4.0ms).

      from enigma import (irange, is_prime, gcd, filter_unique, item, printf)
      
      # generate possible collections of ages
      def generate(k, min_v, max_v, ns=list()):
        # are we done?
        if k == 0:
          # there are duplicate ages; sum is prime
          if len(set(ns)) < len(ns) and is_prime(sum(ns)):
            yield ns
        elif k > 0:
          for n in irange(min_v, max_v):
            if all(gcd(n, x) > 1 for x in ns):
              yield from generate(k - 1, n, max_v, ns + [n])
      
      # candidate solutions
      ss = list(generate(5, min_v=2, max_v=19))
      
      # the sum is ambiguous
      ss = filter_unique(ss, sum).non_unique
      
      # but also knowing the (youngest, eldest) is unique
      ss = filter_unique(ss, item(0, -1)).unique
      
      # output solution(s)
      for ns in ss:
        printf("ages = {ns}")
      

      Solution: The ages are: 6, 10, 15, 15, 15.

      There are 14 sets of ages that meet the initial condition.

      This is reduced to 9 sets that have an ambiguous sum.

      And then further reduced to a single candidate that can be uniquely identified by the lowest and highest ages.


      The possible sets of ages are (grouped by sum):

      (6, 6, 6, 10, 15) = 43
      (6, 6, 10, 10, 15) = 47
      (6, 10, 10, 12, 15) = 53
      (10, 10, 15, 18, 18) = 71
      (10, 15, 18, 18, 18) = 79

      (6, 10, 10, 15, 18) = 59
      (10, 10, 12, 12, 15) = 59
      (6, 10, 15, 15, 15) = 61 [*]
      (10, 12, 12, 12, 15) = 61
      (6, 10, 15, 18, 18) = 67
      (10, 12, 12, 15, 18) = 67
      (10, 12, 15, 15, 15) = 67
      (10, 12, 15, 18, 18) = 73
      (10, 15, 15, 15, 18) = 73

      The first group of 5 have a unique sum, so if we were told the sum we could determine the ages. The second group of 9 have ambiguous sums, so (without knowing the value of the sum) the ages must be in this group.

      We are subsequently told that if we knew the youngest and oldest ages we could determine them all. This is only possible if these ages are 6 and 15 (all other pairs give multiple sets of ages), and so the set marked [*] gives the required solution.

      Like

      • Jim Randell's avatar

        Jim Randell 9:03 am on 10 September 2025 Permalink | Reply

        Here is a more efficient algorithm to generate candidate sets of ages, and this leads to a slightly faster version of the program.

        In the [[ generate() ]] function we generate sequences of distinct ages with length 1 – 4, and then choose duplicate ages to bring the total up to 5 ages.

        It has an internal runtime of 2.0ms.

        from enigma import (irange, is_prime, gcd, subsets, filter_unique, item, printf)
        
        # generate possible collections of ages
        def generate(k, min_v, max_v, ns=list()):
          if len(ns) > 0 and k > 0:
            # choose k duplicate ages
            for xs in subsets(ns, size=k, select='R', fn=list):
              rs = sorted(ns + xs)
              if is_prime(sum(rs)):
                yield rs
          if k > 1:
            for n in irange(min_v, max_v):
              if all(gcd(n, x) > 1 for x in ns):
                yield from generate(k - 1, n + 1, max_v, ns + [n])
        
        # candidate solutions
        ss = list(generate(5, min_v=2, max_v=19))
        
        # the sum is ambiguous
        ss = filter_unique(ss, sum).non_unique
        
        # but also knowing the (youngest, eldest) is unique
        ss = filter_unique(ss, item(0, -1)).unique
        
        # output solution(s)
        for ns in ss:
          printf("ages = {ns}")
        

        Like

    • Frits's avatar

      Frits 10:24 am on 7 September 2025 Permalink | Reply

      from enigma import gcd, is_prime
      from collections import defaultdict
      
      # generate combinations of <k> ages
      def solve(k, ns, t=0, ss=[]):
        if k == 0:
          # some of them are the same
          if len(set(ss)) != len(ss) and is_prime(t):
            yield ss
        else:
          for i, n in enumerate(ns):
            # any two ages have a common divisor greater than 1
            if not ss or all(gcd(a, n) > 1 for a in set(ss)):
              yield from solve(k - 1, ns[i:], t + n, ss + [n])
      
      totals, seen = set(), set()
      d = defaultdict(list)
      # generate all combinations of 5 ages (at least one of them is odd)
      for ages in solve(5, range(3, 20)):
        if (t := sum(ages)) in seen:
          totals.add(t)
        seen.add(t)  
        d[(ages[0], ages[-1])] += [(t, ages)]
      
      # filter ages if you know the youngest and oldest
      for (y, o), vs in d.items():
        sols = [ages for t, ages in vs if t in totals]
        if len(sols) == 1:
          print(f"answer: {sols[0]}")
      

      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