The point of this answer is to illustrate that the most efficient way to do things in other languages can be different from Python, and in this case is. IDK if this different low-level perspective on the problem is a good code-review answer, but putting it here in case people find it interesting.
In an efficient compiled language, all that casting to string, char, and int that you find strange would be inefficient. The reason to do that in Python is to get access to an efficient compiled C loop for breaking down a number into its base 10 digits, using str()
1. Presumably this will be well optimized because it's something that gets a lot of use in real programs.
In a compiled language you would just do repeated division / modulo by 10 to get decimal digits one at a time (LSD first), and add up their low bits as you go. Or since you want the count of digits that have their low bit = 0 (even numbers), count odds and subtract from the total count outside the loop. Or since our caller gives us the total digit count for no apparent reason (we'll find that out anyway when generating the decimal digits), we can take advantage and count down from that and save 1 operation inside the loop.
e.g. in C (assuming fixed-width integers, not like Python's arbitrary-precision integers which can have more than one 32-bit chunk):
// unsigned division by a constant is more efficient, and the number is non-negative
int count_evens(unsigned x, int ndigits) {
int count = ndigits; // we don't need the caller to tell us how many decimal digits
// but take advantage if we have it.
do {
count -= (x%10) & 1; // remove odd digits from the count
x /= 10; // remove the least significant decimal digit
} while (x!=0);
return count;
}
(This is written to "optimistically" not bother checking x==0
before the first iteration, because running 1 iteration of the loop instead of zero still gives the right answer in that special case. Leaving out the check runs fewer instructions for the common(?) case, non-zero inputs. Thinking about the ideal asm you want while writing in C is optional, but I enjoy it.)
Fun fact: C compilers won't actually use an integer-division instruction, because 10
is a compile-time constant. They'll use a multiplicative inverse which is much more efficient on modern CPUs. So writing the loop this way is probably only good in C or other compiled languages, not Python.
Fun fact #2: gcc spotted an optimization I didn't think of when compiling for x86-64 and AArch64 on the Godbolt compiler explorer: x%10
is redundant. If x%10
is odd, so is x
. Because 10
is a multiple of 2. So it only needs to check the low bit of x
, not actually calculate the remainder from the quotient & divisor. (The fixed-point multiplicative-inverse trick only gives you the quotient, remainder is separate.)
Also GCC loops on x>=9
from before the division, so out-of-order execution can detect the loop-end condition sooner. Using while(--ndigits)
would maybe have helped let CPUs figure out when the loop ends and reduce / avoid a branch mispredict penalty for the last loop iteration.
But it's kind of silly because there's no way for the caller to know how many decimal digits without having either done some of the work of this function (or maybe just comparing against powers of 10), or have converted it from a decimal string.
If the caller ever had a decimal string, counting odd/even digits in that would have been much more efficient. e.g. if it was 8 digits long, in asm you could load all 8 bytes with one load, AND with 0x0101010101010101
and popcnt to horizontally sum those bytes. (For variable length, you could do a wide load and use a shift to shift out bytes whose value isn't part of the string.)
Footnote 1:
Python supports integers much larger than 2^32. It stores them in a base 2^30 format, in 32-bit chunks. (https://rushter.com/blog/python-integer-implementation/)
A base 10^9 format would allow more efficient division by (powers of) 10, and especially converting to decimal could process each 32-bit chunk separately into 9 decimal digits, doing only 32-bit divisions.
But 2^30 allows more efficient left/right shifts. And efficient conversion to/from native C 64-bit integers!
Either way, using something less than the full width of a 32-bit integer makes it much easier to implement add-with-carry (carry-in and carry-out) in pure C. But means that the optimal asm is significantly worse on machines that do have add-with-carry instructions, especially 64-bit machines which can operate on 64-bit chunks.
Anyway, actual division by 10 of the whole number has to look at all the higher bits, because 10 isn't a power of 2. I'm not sure if there are any good tricks that str()
could or does use, but it's not surprising that divmod
by 10 in a Python loop isn't very efficient.