Note that your entire strategy of assuming 26 alphabetic upper-case characters, and that they're between 'A' and 'Z', breaks if there are any high-8-bit accented characters in a non-UTF-8 8-bit character set. So the locale-aware toupper
and isalpha
are just slower for probably no benefit.
Several comments have discussed using a "better" data structure for your set of seen letters, because std::vector<bool>
is unfortunately required to be specialized as a bit-vector. (Good data structure for some things, especially large bitsets, bad choice of name to expose it in ISO C++, as Howard Hinnant argues). For this use-case of only 26 elements, it's slower than necessary.
The two good choices here for efficiency (and clean concise code) are:
std::bitset<26>
- very cheap to init and check (and convenient with bitset.all()
), relatively cheap to update on most ISAs. (Better than std::vector<bool>
because the template can specialize itself for the known size being <= one unsigned long or whatever chunk size the library uses. So the compiler will have an easier time being sure it can just keep one integer in a register and OR bits into it.)
std::bitset is so cheap to check, literally just an integer compare for a small bitset (smaller than the bitness of the machine you're compiling for, which is usually at least 32 these days), you could even consider an early-out check every iteration if you expect very long strings where all 26 letters appear far before the end of the string. (Maybe unroll by 4 so you check every 4 chars).
With an ASCII-only replacement for isalpha and toupper, some compilers (e.g. clang and ICC) can even auto-vectorize the bit shift / OR, effectively checking 2 to 4 characters in parallel for only somewhat more than the cost of one. (Or even 8 characters in parallel if you replace std::bitset with uint32_t
to avoid the silly compiler widening to 64-bit integer elements. Or even 16 with AVX-512)
std::array<bool,26>
- Unlike vector<char>
, no dynamic allocation required (it doesn't indirect to separately allocated storage: the object is the array, which is totally fine for a small fixed size like 26). Less cheap than bitset to init and check (especially if the compiler doesn't do a smart job), but even cheaper to update for each char of the string, especially on CPUs like modern x86 where a byte store is extremely cheap, not even an internal word-RMW when committing to L1d cache, although non-x86 CPUs will often do more store coalescing in their store buffer making byte stores still fairly cheap, and likely not a bottleneck unless you seriously optimize the isalpha and toupper checks. (It will hit in cache every time because the array is tiny).
In theory a smart compiler could check for all 26 byte elements of being set fairly efficiently, with two partially-overlapping SIMD 16-byte loads and a SIMD AND (then on x86 for example, pcmpeqb against 0 / pmovmskb to check for any elements that matched a zero). But in practice GCC is dumb and fully unrolls 1 byte at a time compare/branching.
The clean portable way, not optimizing for ASCII-only
Still only using narrow char
, though, not wchar_t
and not using UTF-8 aware stuff. non-UTF8 charsets other than ASCII are increasingly rare these days.
Probably a good idea to just use toupper(c) - 'A'
and manually check if that's unsigned <= 25
, instead of using isalpha: on a POSIX system for example, if LOCALE or LC_ALL aren't "C"
(the POSIX locale, pure ASCII), isalpha
can also return true for allow accented characters whose upper-case codepoint is also outside the 'A'..'Z' range. For example in a locale like ISO-8859-1 or probably also Windows-1252, somewhere from 0x80 to 0xff.
bool isPangram(const std::string &str) // note: const-ref arg
{
static_assert('Z'-'A' == 25, "we assume a charset where letters are contiguous");
std::bitset<26> present(false);
//std::array<bool, 26> present = {0};
for(unsigned char c: str){ // note: unsigned char instead of auto to work around legacy C unsigned-char value range in int arg requirements
if(isalpha(c)) { // FIXME: isalpha can return true for high-8-bit c
auto index = toupper(c) - 'A';
present[index] = true;
// std::cout << index << '\n';
}
}
return present.all(); // nice semantic meaning of "all present"
//return std::all_of(present.begin(), present.end(), [](bool v){return v;});
};
(Compiles and runs, with complete #include<>
list and an improved main, on the Godbolt compiler explorer. Along with some experiments for pure-ASCII, and a manually-vectorized function with SSE2 intrinsics to check a std::array<bool,26>
for being all non-zero. Clang's bitmap update is clever for x86-64: With EAX holding the toupper
return value: dec al
/ bts r15, rax
. 'A'
is ASCII 65, and the bts
instruction (bit test-and-set, like dst |= 1<<src
) with a reg destination masks the bit-index by &63
, like %64
, so c-'A'
is equivalent to c-1
as a shift count in x86 asm. I don't know why clang thinks using a partial register, AL, is a good idea. Other compilers do worse.)
This also shows other improvements mentioned in other answers:
const std::string &
by-reference arg, which we don't modify. Instead we do toupper
inside the loop, and only for alphabetic characters. If you're used to Python, "applying" something to a whole list can be faster than looping manually, but that's because of Python interpreter overhead, where you want to get into a compiled C loop in the Python interpreter. C++ always compiles to native machine code, so you can mostly loop as fast as any std::
template function can. (In theory template functions could use tricks like manual SIMD vectorization, but in practice that's unlikely. However, C library functions like memcmp
or strchr
are often hand-written in asm, so that's one case where you have fast building blocks that you can't replicate with portable C++, only with intrinsics like x86 _mm_cmpeq_epi8
to do 16 byte-compares in parallel.)
unsigned char
because toupper
and isalpha
expect their arg in an int
(because those functions date back to early C before function prototypes even existed). But they expect the character code to have the value-range of unsigned char
. Perhaps a good way to remember this is to imagine that they're implemented by using the int
arg as an index into a table of character attributes for the current locale (which is actually true on many libc implementations), so a negative int
from sign-extending a signed char
would be a problem. That won't happen if your characters are purely ASCII; those are always positive-valued char
s, but in general don't assume that. On many ABIs (including the mainstream x86 ones), char
is signed. (Fun fact: ARM C/C++ implementations use unsigned char
, so your code wouldn't have this problem there.)
{}
after the inner if()
so the if also controls using the index. I also moved the declaration of index
into that scope because there's no need for it outside.
static_assert to check that 'Z'-'A' == 25. Non-ASCII / non-UTF-8 character sets are possible in portable C++, but actually making your code slower because of the possibility isn't something you always want to do. But if possible, you can avoid silent failure there.
ASCII-only
Neither of isalpha
and toupper
will inline, with gcc / glibc / libstdc++. That's normal; non-ASCII locales may have accented characters. But just for fun since we're not handling modern UTF-8 anyway, lets see how fast we can go for ASCII. And because the whole strategy of 26 slots revolves around plain ASCII, not accented upper-case characters.
It only takes a couple operations to check it for being alphabetic, by turning it into an index into the alphabet and checking if that's in the 0..25 range (see this SO answer: set the lower-case bit, then one sub / unsigned compare as a range check). In our case, we actually want that index, so this is perfect. Turns out the check can even auto-vectorize decently, especially with AVX2, allowing compilers to check multiple characters at once. (You can still see asm for the scalar version in a clean-up loop after the vectorized version.)
(Instead of benchmarking, I actually just wanted to look at the asm; turns out clang has some neat tricks up its sleeve and without AVX2 for variable-shift with a per-element shift count, it adds a value into the exponent of a float 1.0
and does float->int conversion to get a per-element 1<<idx
, I think. Other compilers just give up and use scalar. Clang also seems to be doing an integer multiply as part of the vectorization, and I don't know what that's about. Haven't fully reverse engineered how it vectorized, and IDK whether it's a real speedup :P)
// I also tried unsigned int for most of these; compilers widen sooner for that
// clang isn't usefully getting more work done before widening, but GCC might be
// returns idx, valid. if valid, idx is in [0..25]
inline std::pair<unsigned char,bool> ascii_letteridx(unsigned int c)
{
// strictly ASCII, *not* other 8-bit charsets.
// the original didn't work for UTF-8 multi-byte accented characters anyway, but non-UTF8 8-bit charsets still exist
unsigned char lcase = c|0x20;
unsigned char alpha_idx = lcase - 'a'; // 0..25 for alphabetic. Will wrap for characters below 'a', or above 25 for > 'z'
return {alpha_idx, alpha_idx <= unsigned('z'-'a')};
}
// auto-vectorizes with clang. And with AVX2, also GCC and ICC.
// The if() version does very nicely with AVX-512, but compilers do worse with bool << n
bool isPangram_ascii_compilerfriendly(const std::string &str)
{
//std::bitset<26> present(false);
uint32_t bitmap = 0; // avoid 64-bit so clang auto-vectorizes without widening past 32-bit.
for(unsigned char c: str){
auto idx = ascii_letteridx(c);
//if (idx.second) // clang / ICC auto-vec even with the if
{
//present[idx.first] = true;
// shift / OR of a 0 as a no-op for non-alphabetic is better than putting a CMOV on the critical path
// and enables GCC to auto-vectorize, at least with AVX2 for variable-shift
bitmap |= uint32_t(idx.second) << idx.first; // 1UL << would be 64-bit and make auto-vectorization worse.
}
}
std::bitset<26> present = bitmap;
return present.all(); // bitmap == (1UL << 26) - 1;
};
These comments are not a recommendation for how to write code, just leftover notes after looking at how it compiles with gcc, clang, and ICC, for various x86 targets (e.g. -march=sandybridge
, -march=haswell
(includes AVX2), -march=skylake-avx512
)
using namespace std;
; You even have an appropriate use ofstd::vector<bool>
! \$\endgroup\$vector<char>
instead of 26/8 bytes to hold 0 / 1 values for a present/absent status. So althoughvector<bool>
is the obvious choice for a container here, the requirement that it be specialized as a bit-vector (isocpp.org/blog/2012/11/on-vectorbool) is actually not ideal for this use-case. Also given the small fixed size,std::array<bool, 26>
would be good here. (For a beginner's first C++ program, though, this is more than fine, and not "wrong" per-se.) \$\endgroup\$std::bitset
as it is a known length and fixed length. I would go as far to say auint32
and bit-twiddling would have been a considerable alternative. But only in rare cases where performance was warranted. \$\endgroup\$isalpha()
may check the local which opens it up to a lot of other characters not in the English language. Thus you may get letters that our outside'A' -> 'Z'
\$\endgroup\$