From f814edcd3a943a4524088d6dd06401dc50bd68bb Mon Sep 17 00:00:00 2001 From: Peter Norvig Date: Wed, 16 Sep 2026 12:27:06 -0700 Subject: [PATCH] Add files via upload --- ipynb/palsum.ipynb | 644 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 644 insertions(+) create mode 100644 ipynb/palsum.ipynb diff --git a/ipynb/palsum.ipynb b/ipynb/palsum.ipynb new file mode 100644 index 0000000..01348c7 --- /dev/null +++ b/ipynb/palsum.ipynb @@ -0,0 +1,644 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "e57b9914-8169-4acb-ab11-03891732ecf1", + "metadata": {}, + "source": [ + "
Peter Norvig
Sept 2026
\n", + "\n", + "# Integer Palindromes\n", + "\n", + "Consider this slightly-modified version of a problem from the [2026 AIME](https://artofproblemsolving.com/wiki/index.php/2026_AIME_I_Problems/Problem_2?srsltid=AfmBOor4WAOSBdOAsLKKfku51DG21_h8NUzWEenLIieAMaUFWilw8QeB) (American Invitational Mathematics Examination): \n", + "\n", + "***Write a function to return the positive integer palindromes written in base 10, with no zero digits, and whose digits add up to N.***\n", + "\n", + "\n", + "Here are my thoughts about the problem:\n", + "- An integer can only become a [palindromic number](https://en.wikipedia.org/wiki/Palindromic_number) when expressed as a sequence of characters.\n", + " - Thus, my function's signature will be `palindromes_with_digit_sum(N: int) -> list[str]`\n", + " - For example, `palindromes_with_digit_sum(4) == ['1111', '121', '22', '4']`.\n", + " - The empty string is [considered](https://en.wikipedia.org/wiki/Empty_string) a palindrome, so `palindromes_with_digit_sum(0) == ['']`.\n", + "- Valid palindromes come from two cases:\n", + " - Base case: a palindrome can be empty (when *N* = 0) or can be a single digit (when 1 ≤ *N* ≤ 9).\n", + " - Multi-digit case: a palindrome can have first and last digit *d*, and a middle part that is a palindrome.\n", + " - Middle parts can be found by recursively asking for palindromes with digit sum equal to *N* - 2*d*.\n", + " - Since the recursive step always subtracts 2*d* from *N*, the process will always terminate in at most *N*/2 steps.\n", + " - (If zero digits were allowed, it would not terminate.)\n", + "\n", + "# My Solution\n", + "\n", + "Those thoughts easily translate into a function that is concise and reasonably efficient:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "c096f544-36dd-4d54-9b25-04add9e32bc8", + "metadata": {}, + "outputs": [], + "source": [ + "from functools import cache\n", + "\n", + "@cache\n", + "def palindromes_with_digit_sum(N: int) -> list[str]:\n", + " \"\"\"Palindromic digit-strings, with no zero digits, whose digits sum to `N`.\"\"\"\n", + " base_case = [''] if N == 0 else [str(N)] if 1 <= N <= 9 else []\n", + " multi_case = [f'{d}{middle}{d}'\n", + " for d in range(1, 10) if d * 2 <= N\n", + " for middle in palindromes_with_digit_sum(N - 2 * d)]\n", + " return multi_case + base_case" + ] + }, + { + "cell_type": "markdown", + "id": "0114e46a-a615-4687-adf9-15d6eb177f82", + "metadata": {}, + "source": [ + "Here's the program in action:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "8adb65e4-cb37-465c-95bd-8307a1bfe62c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "['1111111', '11311', '12121', '151', '21112', '232', '313', '7']" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "palindromes_with_digit_sum(7)" + ] + }, + { + "cell_type": "markdown", + "id": "78009bb9-88ba-4d91-9029-09c16288da46", + "metadata": {}, + "source": [ + " That looks right.\n", + " \n", + "# Analysis\n", + "\n", + "Here are some facts I noticed:\n", + " - If *N* is odd, each palindrome must have an odd length, with an odd middle digit.\n", + " - If *N* is even, each palindrome can have an even length, or an odd length with an even middle digit.\n", + " - The number of solutions for *N* = 2*m* is the same as for *N* = 2*m* + 1. Here's why:\n", + " - For each even-length 2*m* solution, you can insert 1 as the middle digit.\n", + " - For each odd-length 2*m* solution, you can increase the (even) middle digit by 1.\n", + " - The number of solutions for *N* + 2 is either double or slightly less than double the number of solutions for *N*.\n", + " - The doubling breaks down when we run out of digits. For example, with *N* = 7, one solution is \"7\", and for *N* + 2 we can change that solution to either \"171\" or \"9\", doubling the count (and similarly for every other palindrome with sum 7). But for *N* = 8, we we can't add 2 to the digit \"8\", so we get one less than double the number of solutions.\n", + "\n", + "Exploring the counts of number of valid palindromes:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "7215d254-48ac-449c-8712-dea49189abb9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1 1 2 2 4 4 8 8 16 16 31 31 62 62 124 124 248 248 496 496 991 991 1980 1980 3956 3956 7904 7904 15792 15792\n" + ] + } + ], + "source": [ + "def count(N) -> int: return len(palindromes_with_digit_sum(N))\n", + "\n", + "print(*(count(N) for N in range(30)))" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "886cca47-a2d7-4d8b-8e87-90d1c48f56e1", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{0: 2.0,\n", + " 1: 2.0,\n", + " 2: 2.0,\n", + " 3: 2.0,\n", + " 4: 2.0,\n", + " 5: 2.0,\n", + " 6: 2.0,\n", + " 7: 2.0,\n", + " 8: 1.9375,\n", + " 9: 1.9375,\n", + " 10: 2.0,\n", + " 11: 2.0,\n", + " 12: 2.0,\n", + " 13: 2.0,\n", + " 14: 2.0,\n", + " 15: 2.0,\n", + " 16: 2.0,\n", + " 17: 2.0,\n", + " 18: 1.997983870967742,\n", + " 19: 1.997983870967742,\n", + " 20: 1.997981836528759,\n", + " 21: 1.997981836528759,\n", + " 22: 1.997979797979798,\n", + " 23: 1.997979797979798,\n", + " 24: 1.9979777553083924,\n", + " 25: 1.9979777553083924,\n", + " 26: 1.9979757085020242,\n", + " 27: 1.9979757085020242,\n", + " 28: 1.9980369807497467,\n", + " 29: 1.9980369807497467}" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Does the number of solutions double from N to N+2?\n", + "{N: count(N + 2) / count(N) for N in range(30)}" + ] + }, + { + "cell_type": "markdown", + "id": "d0199f94-d8d6-4728-8dc6-11a3ee191abc", + "metadata": {}, + "source": [ + "# Tests\n", + "\n", + "I'll define `test` to test any function that purports to meet the specification. The output of the function is canonicalized because an implementation might reasonably choose to use ints instead of strings, and might return (or yield) the palindromes in any order (not necessarily the lexicographic order that my program produces). If the function has a cache, it is cleared at the start." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "0034dd91-13ab-498d-9e9c-9f7debfd00e7", + "metadata": {}, + "outputs": [], + "source": [ + "import time\n", + "\n", + "def test(palindrome_function) -> bool:\n", + " \"\"\"Tests for the palindrome digit sum problem.\"\"\"\n", + "\n", + " if hasattr(palindrome_function, 'cache_clear'):\n", + " palindrome_function.cache_clear()\n", + " \n", + " def f(N: int) -> list[str]: \n", + " \"\"\"Canonicalize the output of palindrome_function(N).\"\"\"\n", + " return sorted(map(str, palindrome_function(N)))\n", + "\n", + " # Tests for small values of N\n", + " assert f(0) == [''] or f(0) == [] # Spec is unclear; either answer is acceptable\n", + " assert f(1) == ['1']\n", + " assert f(2) == ['11', '2']\n", + " assert f(3) == ['111', '3']\n", + " assert f(4) == ['1111', '121', '22', '4']\n", + " assert f(5) == ['11111', '131', '212', '5']\n", + " assert f(6) == ['111111', '11211', '1221', '141', '2112', '222', '33', '6']\n", + " assert f(7) == ['1111111', '11311', '12121', '151', '21112', '232', '313', '7']\n", + "\n", + " # Try the function on some larger N and check the count of palindromes.\n", + " for N, count in {38: 502_864, 40: 1_004_737, 42: 2_007_494}.items():\n", + " assert len(f(N)) == count == len(f(N + 1))" + ] + }, + { + "cell_type": "markdown", + "id": "f4db4656-6146-4bae-9235-eae95943dd5a", + "metadata": {}, + "source": [ + "It takes about a second to run the test suite on cases up to *N* = 44 (for which there are 4,011,032 valid palindromes)." + ] + }, + { + "cell_type": "markdown", + "id": "9798c834-da89-4ff6-99e7-ae393dde9e93", + "metadata": {}, + "source": [ + "# LLM Solutions\n", + "\n", + "I asked four Large Language Model coding agents to solve this problem: Gemini 3.1 Pro, GPT 5.6 Sol, Claude Sonnet 5, and Kimi K3.\n", + "\n", + "On the positive side, they all wrote correct code that passed the tests, and they all got the idea that you only have to build up the first half of the digit string, and that you shouldn't generate half-strings with a sum greater than *N*/2.\n", + "\n", + "On the negative side, they all ran 10 to 20 times slower on the test suite compared to my solution, largely because none of them used `@cache`. Only Gemini produced what I considered a clear, simple solution (basically the same as mine, but using `for` and `if` statements instead of conditional expressions and list comprehensions). All the others had overly complicated code (with a line count 6 times mine). Only GPT provided type hints.\n", + "\n", + "For each coding agent, the table below lists the test pass/fail grade, the lines of code (including blank lines and comments), the wall time in seconds for the test suite, and my subjective code-quality judgment. So for this round, the human came out on top!\n", + "\n", + "|Author|Test|LOC|Time|Subjective|\n", + "|-----|---:|---:|---:|---|\n", + "|Human|pass|10|0.7|Good|\n", + "|Gemini|pass|19|7.4|Good|\n", + "|ChatGPT|pass|60|10.1|Too complicated|\n", + "|Claude|pass|62|16.1|Too complicated|\n", + "|Kimi|pass|62|14.9|Too complicated|\n", + "\n", + "\n", + "\n", + "# Gemini 3.1 Pro's Solution\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "f2e63e96-7cd9-4459-a626-f09f22bd8e84", + "metadata": {}, + "outputs": [], + "source": [ + "def gemini_generate_palindromes(target_sum):\n", + " \"\"\"\n", + " Yields all positive integer palindromes (as strings) with no zero digits\n", + " whose digits add up to the target_sum.\n", + " \"\"\"\n", + " # Base case 1: The remaining sum is exactly 0 (even-length palindrome center)\n", + " if target_sum == 0:\n", + " yield \"\"\n", + " \n", + " # Base case 2: The remaining sum is a single non-zero digit (odd-length center)\n", + " if 1 <= target_sum <= 9:\n", + " yield str(target_sum)\n", + " \n", + " # Recursive step: Wrap matching pairs of digits (1 through 9) around the outside\n", + " for d in range(1, 10):\n", + " # We need enough remaining sum for two 'd' digits\n", + " if target_sum >= 2 * d:\n", + " for inner in gemini_generate_palindromes(target_sum - 2 * d):\n", + " yield f\"{d}{inner}{d}\"" + ] + }, + { + "cell_type": "markdown", + "id": "b2794730-0eba-41c2-9cc8-38bf0250ce26", + "metadata": {}, + "source": [ + "# GPT 5.6 Sol's Solution" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "1fa05867-c009-44a1-b084-6198c3798123", + "metadata": {}, + "outputs": [], + "source": [ + "from collections.abc import Iterator\n", + "\n", + "\n", + "def _halves_with_sum(weights: tuple[int, ...], target: int) -> Iterator[str]:\n", + " \"\"\"Yield digit strings whose weighted digit sum is ``target``.\"\"\"\n", + "\n", + " suffix_weight = [0] * (len(weights) + 1)\n", + " for index in range(len(weights) - 1, -1, -1):\n", + " suffix_weight[index] = suffix_weight[index + 1] + weights[index]\n", + "\n", + " digits: list[str] = []\n", + "\n", + " def visit(index: int, remaining: int) -> Iterator[str]:\n", + " if index == len(weights):\n", + " if remaining == 0:\n", + " yield \"\".join(digits)\n", + " return\n", + "\n", + " weight = weights[index]\n", + " rest_weight = suffix_weight[index + 1]\n", + " for digit in range(1, 10):\n", + " after = remaining - weight * digit\n", + " # Every remaining digit is in [1, 9].\n", + " if rest_weight <= after <= 9 * rest_weight:\n", + " digits.append(str(digit))\n", + " yield from visit(index + 1, after)\n", + " digits.pop()\n", + "\n", + " yield from visit(0, target)\n", + "\n", + "\n", + "def chatGPT_palindromes_with_digit_sum(n: int) -> Iterator[int]:\n", + " \"\"\"Yield positive zero-free decimal palindromes with digit sum ``n``.\n", + "\n", + " Results are yielded in increasing numeric order. The sequence is finite:\n", + " since every digit is at least one, a result can contain at most ``n``\n", + " digits.\n", + " \"\"\"\n", + "\n", + " if n < 1:\n", + " return\n", + "\n", + " for length in range(1, n + 1):\n", + " paired_digits = length // 2\n", + " weights = (2,) * paired_digits\n", + " if length % 2:\n", + " weights += (1,)\n", + "\n", + " minimum_sum = sum(weights)\n", + " maximum_sum = 9 * minimum_sum\n", + " if not minimum_sum <= n <= maximum_sum:\n", + " continue\n", + "\n", + " for half in _halves_with_sum(weights, n):\n", + " left = half[:paired_digits]\n", + " if length % 2:\n", + " palindrome = half + left[::-1]\n", + " else:\n", + " palindrome = half + half[::-1]\n", + " yield int(palindrome)" + ] + }, + { + "cell_type": "markdown", + "id": "71ed927d-de25-4f9d-85a7-18c60fdab010", + "metadata": {}, + "source": [ + "# Claude Sonnet 5's Solution" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "98d21fdc-ac2f-4f46-bab2-e87dfb84db27", + "metadata": {}, + "outputs": [], + "source": [ + "def gen_digit_sequences(length, target):\n", + " \"\"\"\n", + " Yield every list of `length` digits, each in 1..9, that sums to `target`.\n", + " Uses feasibility pruning (min/max achievable sum) to avoid dead branches.\n", + " \"\"\"\n", + " if length == 0:\n", + " if target == 0:\n", + " yield []\n", + " return\n", + "\n", + " remaining_positions = length - 1\n", + " min_possible = remaining_positions * 1\n", + " max_possible = remaining_positions * 9\n", + "\n", + " for d in range(1, 10):\n", + " rem = target - d\n", + " if min_possible <= rem <= max_possible:\n", + " for tail in gen_digit_sequences(remaining_positions, rem):\n", + " yield [d] + tail\n", + "\n", + "\n", + "def claude_find_palindromes(n):\n", + " \"\"\"\n", + " Return a sorted list of ints: all zero-free palindromes whose digits\n", + " sum to n.\n", + " \"\"\"\n", + " if n < 1:\n", + " return []\n", + "\n", + " palindromes = []\n", + " max_length = n # each digit >= 1, so length can't exceed the target sum\n", + "\n", + " for L in range(1, max_length + 1):\n", + " if L % 2 == 0:\n", + " h = L // 2\n", + " if n % 2 != 0:\n", + " continue # 2*sum(half) is always even\n", + " target = n // 2\n", + " for half in gen_digit_sequences(h, target):\n", + " full_digits = half + half[::-1]\n", + " palindromes.append(full_digits)\n", + " else:\n", + " h = (L + 1) // 2\n", + " if h == 1:\n", + " # single-digit palindrome: the digit itself must equal n\n", + " if 1 <= n <= 9:\n", + " palindromes.append([n])\n", + " continue\n", + " # half = prefix (h-1 digits) + middle digit m\n", + " # n = 2*sum(prefix) + m\n", + " for m in range(1, 10):\n", + " rem = n - m\n", + " if rem < 0 or rem % 2 != 0:\n", + " continue\n", + " prefix_target = rem // 2\n", + " for prefix in gen_digit_sequences(h - 1, prefix_target):\n", + " half = prefix + [m]\n", + " full_digits = half + half[:-1][::-1]\n", + " palindromes.append(full_digits)\n", + "\n", + " numbers = sorted(int(\"\".join(map(str, digits))) for digits in palindromes)\n", + " return numbers" + ] + }, + { + "cell_type": "markdown", + "id": "549ea6b6-21f3-49c8-b54a-e70710edbfd5", + "metadata": {}, + "source": [ + "# Kimi K3's Solution" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "0754167e-a8bb-45a7-80a3-8dd15645e7a2", + "metadata": {}, + "outputs": [], + "source": [ + "def kimi_palindromes_with_digit_sum(digit_sum, *, as_int=False):\n", + " \"\"\"\n", + " Return every positive base-10 palindrome with no zero digits whose\n", + " digit sum equals `digit_sum`.\n", + "\n", + " Returns a numerically sorted list of strings by default.\n", + " Set as_int=True to return integers.\n", + " \"\"\"\n", + " if not isinstance(digit_sum, int) or isinstance(digit_sum, bool):\n", + " raise TypeError(\"digit_sum must be an integer\")\n", + " if digit_sum < 1:\n", + " return []\n", + "\n", + " def compositions(total, parts):\n", + " \"\"\"Generate compositions of total into parts, each between 1 and 9.\"\"\"\n", + " if parts == 1:\n", + " if 1 <= total <= 9:\n", + " yield (total,)\n", + " return\n", + "\n", + " lo = max(1, total - 9 * (parts - 1))\n", + " hi = min(9, total - (parts - 1))\n", + "\n", + " for first in range(lo, hi + 1):\n", + " for rest in compositions(total - first, parts - 1):\n", + " yield (first,) + rest\n", + "\n", + " found = set()\n", + "\n", + " # Single-digit palindromes.\n", + " if digit_sum <= 9:\n", + " found.add(str(digit_sum))\n", + "\n", + " # Even-length palindromes: left + reverse(left).\n", + " # These exist only when the digit sum is even.\n", + " if digit_sum % 2 == 0:\n", + " half_sum = digit_sum // 2\n", + "\n", + " for half_length in range(1, half_sum + 1):\n", + " for left in compositions(half_sum, half_length):\n", + " found.add(\"\".join(map(str, left + left[::-1])))\n", + "\n", + " # Odd-length palindromes: left + center + reverse(left).\n", + " # These must be checked for every digit sum because the center digit\n", + " # can make the total digit sum either even or odd.\n", + " for half_length in range(1, (digit_sum - 1) // 2 + 1):\n", + " for center in range(1, 10):\n", + " remaining = digit_sum - center\n", + "\n", + " if remaining < 0 or remaining % 2:\n", + " continue\n", + "\n", + " left_sum = remaining // 2\n", + " if not half_length <= left_sum <= 9 * half_length:\n", + " continue\n", + "\n", + " for left in compositions(left_sum, half_length):\n", + " digits = left + (center,) + left[::-1]\n", + " found.add(\"\".join(map(str, digits)))\n", + "\n", + " result = sorted(found, key=lambda x: (len(x), x))\n", + " return [int(x) for x in result] if as_int else result" + ] + }, + { + "cell_type": "markdown", + "id": "45edda37-33a7-4fbb-af18-4d5a8a18a9f6", + "metadata": {}, + "source": [ + "# Timing Results\n", + "\n", + "Here we see the time each function takes to run the test suite." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "e299bd97-e997-49f9-8160-e6500d9dbf23", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 627 ms, sys: 68.1 ms, total: 695 ms\n", + "Wall time: 695 ms\n" + ] + } + ], + "source": [ + "%time test(palindromes_with_digit_sum)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "3d830e10-711a-4702-aca7-7d6a16b03c31", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 7.31 s, sys: 96.8 ms, total: 7.41 s\n", + "Wall time: 7.42 s\n" + ] + } + ], + "source": [ + "%time test(gemini_generate_palindromes)" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "30fc1f93-9921-4512-81b8-0320208f0233", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 9.95 s, sys: 120 ms, total: 10.1 s\n", + "Wall time: 10.1 s\n" + ] + } + ], + "source": [ + "%time test(chatGPT_palindromes_with_digit_sum)" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "ceb02172-c40c-4db8-8b6d-49e29f0d4367", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 15.4 s, sys: 544 ms, total: 15.9 s\n", + "Wall time: 16.1 s\n" + ] + } + ], + "source": [ + "%time test(claude_find_palindromes)" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "2b058781-1692-44c6-8250-be9f1bde1be4", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 14.6 s, sys: 261 ms, total: 14.8 s\n", + "Wall time: 14.9 s\n" + ] + } + ], + "source": [ + "%time test(kimi_palindromes_with_digit_sum)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}