Repository: scalekit (difficulty: hard)

Issue:
count_harshad(N) must return how many integers in [1, N] are divisible by their own digit sum (Harshad / Niven numbers). The reference implementation loops x from 1 to N, which is correct but useless once N is large: the hidden tests call it with N up to 10**18, where an O(N) scan cannot finish. Re-implement count_harshad so it returns the same counts but runs in time polynomial in the number of digits of N (e.g. a digit DP). The public contract is unchanged: count_harshad(144) == 47, count_harshad(9999) == 1537.

Source files:
core.py: def count_harshad(N):
    """Count integers x in [1, N] divisible by their digit sum.

    A "Harshad" (Niven) number is divisible by the sum of its decimal digits.
    Return how many such numbers lie in the inclusive range [1, N].

    N may be as large as 10**18.
    """
    count = 0
    for x in range(1, N + 1):
        digit_sum = sum(int(c) for c in str(x))
        if digit_sum != 0 and x % digit_sum == 0:
            count += 1
    return count


Test files:
test_core.py: from core import count_harshad


def _brute(N):
    c = 0
    for x in range(1, N + 1):
        ds = sum(int(ch) for ch in str(x))
        if ds and x % ds == 0:
            c += 1
    return c


def test_harshad_small_matches_brute():
    for N in [1, 2, 9, 10, 11, 12, 18, 20, 21, 100, 144, 1000, 1729]:
        assert count_harshad(N) == _brute(N), N


def test_harshad_medium_known_values():
    # Independently brute-forced reference counts.
    assert count_harshad(9999) == 1537
    assert count_harshad(10000) == 1538
    assert count_harshad(12345) == 1972


def test_harshad_large_needs_digit_dp():
    # N far beyond what an O(N) loop can ever enumerate (10**12 iterations is
    # hours of CPU); only a digit DP (or equivalent) returns within the harness
    # time budget. The bound is kept at 10**12 so that any correct
    # digits-of-N-polynomial solution -- not just a micro-optimized one -- has
    # comfortable headroom, making algorithmic complexity (not Python constant
    # factors) the thing the test discriminates on.
    assert count_harshad(10**9) == 61574510
    assert count_harshad(123456789) == 8761363
    assert count_harshad(987654321987) == 45502671768
    assert count_harshad(10**12) == 45975917532


Patch:
--- a/core.py
+++ b/core.py
@@ -6,9 +6,49 @@
 
     N may be as large as 10**18.
     """
-    count = 0
-    for x in range(1, N + 1):
-        digit_sum = sum(int(c) for c in str(x))
-        if digit_sum != 0 and x % digit_sum == 0:
-            count += 1
-    return count
+    if N < 1:
+        return 0
+    digits = [int(c) for c in str(N)]
+    length = len(digits)
+    total = 0
+    for s in range(1, 9 * length + 1):
+        # Count x in [1, N] with digitsum(x) == s and x % s == 0 via a digit DP.
+        # States are keyed by sum_so_far * s + (value mod s); leading zeros are
+        # harmless because they change neither the digit sum nor the remainder.
+        # Prune sums that already exceed s or can no longer reach it.
+        loose = {}
+        tight_sum = 0
+        tight_rem = 0
+        tight_alive = True
+        for pos, d in enumerate(digits):
+            cap = 9 * (length - pos - 1)
+            new_loose = {}
+            get = new_loose.get
+            for key, cnt in loose.items():
+                sm, rm = divmod(key, s)
+                base = rm * 10
+                top = min(9, s - sm)
+                for nd in range(top + 1):
+                    ns = sm + nd
+                    if ns + cap < s:
+                        continue
+                    nk = ns * s + (base + nd) % s
+                    new_loose[nk] = get(nk, 0) + cnt
+            if tight_alive:
+                base = tight_rem * 10
+                top = min(d - 1, s - tight_sum)
+                for nd in range(top + 1):
+                    ns = tight_sum + nd
+                    if ns + cap < s:
+                        continue
+                    nk = ns * s + (base + nd) % s
+                    new_loose[nk] = get(nk, 0) + 1
+                tight_sum += d
+                tight_rem = (base + d) % s
+                if tight_sum > s:
+                    tight_alive = False
+            loose = new_loose
+        total += loose.get(s * s, 0)
+        if tight_alive and tight_sum == s and tight_rem == 0:
+            total += 1
+    return total