def f(s,t=118,u=-10340):
for x in s:t-=x-96;u-=t
*l,=s;l[u//t]+=t;return bytes(l)
Try it online!
We compute a custom numerical checksum that let us locate and fix the error. This means don't need to try possible changes until we find one that works. As a result, the code run very fast, letting us test all 23,001 possible inputs in under 2 seconds in the linked TIO.
Input and output are as bytestrings, which requires Python 3.
The checksum
It's a bit easier to understand the idea using the code below.
def f(s):
t=50998-sum(s)
k=13447420-sum(i*x for i,x in enumerate(s))
i=k//t
l=list(s)
l[i]+=t
return bytes(l)
Try it online!
The core is the checksum, which gives two values. The correct string (and only it) gives the values (50998, 13447420)
, which are used in the code above.
def checksum(s):
t=sum(s)
u=sum(i*x for i,x in enumerate(s))
return(t,u)
Try it online!
The checksum not only lets us know whether the string is correct, but the difference from the checksum to the real string lets us deduce what the error is and fix it. This doesn't rely on the typoes being with letters.
How does it work?
The checksum consists of two parts, each of which is a number.
The part t=sum(s)
is simply the sum of the (ASCII) values in the string. Given a string with a single typo from the original, we can figure out the numerical difference between the original and typoed character by comparing the sum to the original's sum of 50998. For example, if we find a sum of 51001, we know that the typo increased a character by 3, such as a->d
or M->P
.
So, we know the amount that the character is off, but not which one. That is, we don't know the index. For that, we use the additional information given in the second value u
of the checksum,
u=sum(i*x for i,x in enumerate(s))
Here, we add up all the characters, but each one is given a multiplier equal to its index, so zero times the initial character plus one times the next one, up to 529 times the last one.
The idea is similar to a classic puzzle:
You have 10 bags of coins. Each bag has 10 gold coins that each weigh 10 grams, except one bag is filled with lighter counterfeits that each weigh 9 grams but otherwise look normal. You have a digital scale that displays exact numerical weight. How can you determine which bag has the counterfeits, using only a single weighing?
The solution is to number the bags 1 to 10, and put 1 coin from bag 1 on the scale, 2 coins from bag 2, up to 10 coins from bag 10. That's 55 coins total, so if all the coins had been real, they'd weigh 550 grams. But, each counterfeit coins lowers the total weight by 1 gram. So, since the fake bag will contribute its index-number of underweight coins, which is however many grams under 550 we see. This tell uses which bag is fake. (We could also have zero-indexed 0-9 and used 45 coins.)
The checksum u
works just like this. Suppose that the typo is index j
, and it's off by an amount d
as determined by the first checksum value t
. Then, the index j
will contribute to the error in u
with a multiplier equal to j
, so it will be off by j*d
. Knowing both d
and j*d
lets us find the typoed index j
by dividing.
Fixing the error
The error is fixed as *l,=s;l[u//t]+=t;return bytes(l)
. This converts the string to a list, modifies the indexed value in place with +=
, and then converts back a bytestring. This is pretty ugly. Note that strings and bytestring are immutable in Python, unlike lists, so we couldn't just modify the string directly.
I'm still not really clear what default string I/O permits, despite having given it the go-ahead in the challenge. If we can do input and output on lists of ASCII values, then we can save a lot of code, especially by modifying the input list in place.
61 bytes
def f(l,t=118,u=-10340):
for x in l:t-=x-96;u-=t
l[u//t]+=t
Try it online!
Optimizations
def f(s,t=118,u=-10340):
for x in s:t-=x-96;u-=t
*l,=s;l[u//t]+=t;return bytes(l)
We save some characters in the above code with a few optimizations.
Instead of computing the checksum values (t,u)
and subtracting the target values, we get the differences directly by initializing t
and u
to to their target values and subtracting the contributions for each character iteratively. Passing in the original string would make them end up both as zero, giving a divide-by-zero error in the code.
Instead of using enumerate
to compute the weighted sum u
, we repeatedly subtract the running sum t
, which subtracts each character value successively more times. This effectively computes the weighted sum for the reversed string, that is with the biggest weights at the front. But we end up with a negative index that goes from the back, so it works out.
Finally, we are able to obtain shorter-to-write checksum values (t,u) = (118,-10340)
instead of (50998, 13447420)
by using a slightly different checksum that first subtracts 96 from each ASCII value. This is the rounded average ASCII value for the text, so subtracting it makes the string values average out to roughly zero. This make the checksums have both positive and negative summands that make them close to zero. These values of (118,-10340)
are tantalizingly close to being one digit shorter, and one is negative, but I didn't see a way to shorten them.
I was originally planning to do all checksums computations modulo some prime to make the resulting values shorter to write, but they are short enough anyway that it's not nearly worth it, even without the optimization above.