10
\$\begingroup\$

(See the next iteration/follow-up here.)

I have this short function is_permutation_palindrome, which returns true only if the input string may be rearranged to form a palindrome:

#include <iostream>
#include <string>
#include <unordered_map>

bool is_permutation_palindrome(const std::string& text)
{
    std::unordered_map<char, size_t> counter_map;

    for (const auto ch : text)
        ++counter_map[ch];

    size_t number_of_odd_chars = 0;

    for (const auto &pair: counter_map)
        if (counter_map[pair.first] % 2 == 1)
        {
            ++number_of_odd_chars;

            if (number_of_odd_chars > 1)
                return false;
        }

    return true;
}

int main() {
    std::cout << std::boolalpha;
    std::cout << is_permutation_palindrome("yash") << "\n";
    std::cout << is_permutation_palindrome("vcciv") << "\n";
    std::cout << is_permutation_palindrome("abnnab") << "\n";   
}

Critique request

I believe that there must be a more idiomatic way of implementing that function, and would like to hear about it.

\$\endgroup\$
7
  • 1
    \$\begingroup\$ What is the encoding of the input string? I would assume ASCII, given the use of char doesn't deal with any Unicode flavor correctly, but it'd be great to have that specified. \$\endgroup\$ Commented Dec 19, 2021 at 16:57
  • \$\begingroup\$ @MatthieuM. it doesn't matter what the encoding is, as long as it consists of single-byte characters. \$\endgroup\$
    – JDługosz
    Commented Dec 20, 2021 at 21:47
  • \$\begingroup\$ @JDługosz: The "as long as" is a fairly important restriction though, UCS-2, UTF-16, UTF-32, Shift-JIS, etc... all fail here. Although I was asking more with an eye to optimization: ASCII only requires 7 bits, not 8, which reduces the size of arrays/bitsets. \$\endgroup\$ Commented Dec 21, 2021 at 8:18
  • \$\begingroup\$ @MatthieuM - UCS-2 should be fine, as well as UTF-32 (which is exactly equal to UCS-4), for platforms that support one of those as char. It's only the encodings where multiple char are needed to represent a single code-point that fail (and even then, only as false-positives, never as false-negatives). \$\endgroup\$ Commented Dec 21, 2021 at 8:24
  • 2
    \$\begingroup\$ @TobySpeight still no good with 32-bit characters, because the algorithm would be operating on code points not complete characters, which can consist of a base plus multiple combining characters. Using a "precomposed" normalized form will work if all needed characters have precomposed code points. \$\endgroup\$
    – JDługosz
    Commented Dec 22, 2021 at 14:46

4 Answers 4

9
\$\begingroup\$

While your algorithm is \$O(n)\$, the constant factors are considerable. Specifically, you are suffering due to all the nodes the map needs.

Better options are:

  1. Just std::sort() and then do a single pass. While the order is \$O(n * log(n))\$, it will probably still be far more efficient.

  2. Use an array for a histogram. 256 elements (wrap-around of an unsigned type would be safe, so unsigned char suffices, or even flipping a bool) should fit comfortably.
    Even if you are on a rare platform with bigger bytes, it ought to fit or you have bigger problems with your current implementation.
    While the order is the same, the constants are far smaller.

Regarding your implementation:

  1. Unless you need a null-terminated read-only string, passing by std::string const& is very sub-optimal. Considering your chosen algorithm, std::string_view would be best.

  2. You could mark your algorithm constexpr.
    noexcept unfortunately eludes you due to all the allocations.

constexpr bool is_permutation_palindrome(std::string_view s) noexcept {
    unsigned char counts[1u + (unsigned char)-1] {};
    for (unsigned char c : s)
        ++counts[c];
    return std::ranges::count_if(counts, [](auto a){ return a % 2; }) < 2;
}
\$\endgroup\$
4
  • 1
    \$\begingroup\$ Option 2 is only valid if you define permutation as being of bytes rather than of characters. \$\endgroup\$ Commented Dec 19, 2021 at 15:28
  • \$\begingroup\$ If unsigned char wouldn't have overflow behavior this would break or if the result wouldn't be depending on value being even (if it would be divisibility by 3 for example it would break due to overflow). \$\endgroup\$ Commented Dec 19, 2021 at 18:35
  • \$\begingroup\$ @Incomputable Technically, unsigned types cannot overflow in C or C++, as operations are defined to wrap around, which as you said is fine here. Only signed types can overflow. \$\endgroup\$ Commented Dec 19, 2021 at 19:24
  • 3
    \$\begingroup\$ @R..GitHubSTOPHELPINGICE If you allow for multi-byte characters (like unicode codepoints) or even graphemes, things get far more involved, true. But that's beyond the scope of the question, imho. \$\endgroup\$ Commented Dec 19, 2021 at 19:26
8
\$\begingroup\$

Using a std::unordered_map is overkill here, since the number of possible characters is known beforehand. So you could just use a std::array<std::size_t, 256>. But you can do even better than that: you don't need to know the exact number of occurences of a given character, you only need to know if it's odd or even. You just need one bit to keep track of that. So consider using a std::bitset. The solution then becomes:

bool is_permutation_palindrome(const std::string& text)
{
    std::bitset<256> odd_characters;

    for (const auto ch : text)
        odd_characters.flip(static_cast<std::uint8_t>(ch));

    return odd_characters.count() <= 1;
}

Note that the above assumes 8 bits in a char, which might not be completely portable. You might want to check the value of CHAR_BIT.

\$\endgroup\$
14
  • 1
    \$\begingroup\$ For other size chars, I think it's sufficient to have std::bitset<UCHAR_MAX> (and change the cast, to unsigned char). I don't see any need to use CHAR_BIT. \$\endgroup\$ Commented Dec 18, 2021 at 15:21
  • 3
    \$\begingroup\$ I don't think there is any mainstream CPU architecture that can access individual bits as quickly as it can access bytes. The compiler is going to generate bit-shifting and -making instructions implicitly. Certainly, that is not true of either x86 or ARM. A byte-array will therefore give you vastly more efficient performance than trying to pack everything into a bit-field. Of course, memory usage will be higher, but that's generally not an issue (classic trade-off), and definitely not in this case where you are only talking about a maximum size of 256 bytes (versus 256 bits == 32 bytes). \$\endgroup\$
    – Cody Gray
    Commented Dec 19, 2021 at 11:29
  • 2
    \$\begingroup\$ @CodyGray I'm not saying this is the fastest way, you definitely should benchmark it. But even x86 has instructions for setting individual bits, and besides that, consider that a lot of processors nowadays have 256 bit registers (think AVX) so you can avoid any memory access (since even L1 cache has some latency, and 256 bytes is still more than one cache line). \$\endgroup\$
    – G. Sliepen
    Commented Dec 19, 2021 at 12:19
  • 2
    \$\begingroup\$ +1 for noticing that only parity is necessary. For a larger space, it may be interesting to keep a counter of odd values as you go, to avoid the O(space-size) count at the end, but for 128 or 256 bits, simplifying the inner-loop as you did is probably better. \$\endgroup\$ Commented Dec 19, 2021 at 16:58
  • 3
    \$\begingroup\$ @PeterCordes Nice analysis. I'm shocked by the assembly produced by both OP's and my version. Deduplicator's version looks better, and it's indeed not surprising it performs better in Incomputable's benchmarks. \$\endgroup\$
    – G. Sliepen
    Commented Dec 19, 2021 at 23:45
5
\$\begingroup\$

Code Review

I have some small nitpicks, but otherwise looks good.

Use string_view

As Deduplicator mentioned, it is better to clarify that the function does not expect exactly std::string, but just a continuous sequence of chars with a size.

Structured binding

Although in this case the map is not useful, it would be better to name the .first and .second variables:

    for (const auto& [value, occurence_count]: counter_map)

Misspelling std::size_t

This is even more minor one, but perhaps with advent of modules this might change.

Consumption

Although this is not intended to be consumed by somebody as a library, it would be great to write basic CMakeLists file to specify build requirements.

Benchmarks

Without too much talking, I will dump my benchmark code (there are some slight modifications):

#include <iostream>
#include <string>
#include <unordered_map>
#include <algorithm>
#include <bitset>
#include <memory_resource>

#include "input_gen.h"

// Based on https://codereview.stackexchange.com/q/272128
// from https://codereview.stackexchange.com/users/58360/coderodde
bool is_permutation_palindrome_original(const std::string& text)
{
    const std::size_t buffer_size = 8 * 1024;
    std::array<std::byte, buffer_size> scratch;
    std::pmr::monotonic_buffer_resource resource(scratch.data(), buffer_size);
    std::pmr::unordered_map<char, size_t> counter_map(&resource);
//    counter_map.reserve(256);

    for (const auto ch : text)
        ++counter_map[ch];

    size_t number_of_odd_chars = 0;

    for (const auto &pair: counter_map)
        if (counter_map[pair.first] % 2 == 1)
        {
            ++number_of_odd_chars;

            if (number_of_odd_chars > 1)
                return false;
        }

    return true;
}

// based on https://codereview.stackexchange.com/a/272130
// from https://codereview.stackexchange.com/users/42409/deduplicator
bool is_permutation_palindrome_array(std::string_view s) noexcept {
    unsigned char counts[1u + std::numeric_limits<unsigned char>::max()] {};
    for (unsigned char c : s)
        ++counts[c];
    return std::count_if(std::begin(counts), std::end(counts), [](auto a){ return a % 2; }) < 2;
}

// https://codereview.stackexchange.com/a/272129
// from https://codereview.stackexchange.com/users/129343/g-sliepen
bool is_permutation_palindrome_bitset(const std::string& text)
{
    std::bitset<256> odd_characters;

    for (const auto ch : text)
        odd_characters.flip(static_cast<std::uint8_t>(ch));

    return odd_characters.count() <= 1;
}

#include <chrono>
namespace chrono = std::chrono;

struct sample_t {
    chrono::nanoseconds original_time;
    chrono::nanoseconds array_time;
    chrono::nanoseconds bitset_time;
};

sample_t measure(const std::string& input) {
    sample_t sample{};
    {
        auto start_time = chrono::steady_clock::now();
        volatile bool is_pal = is_permutation_palindrome_original(input);
        auto end_time = chrono::steady_clock::now();
        sample.original_time = chrono::duration_cast<chrono::nanoseconds>(end_time - start_time);
    }


    {
        auto start_time = chrono::steady_clock::now();
        volatile bool is_pal = is_permutation_palindrome_array(input);
        auto end_time = chrono::steady_clock::now();
        sample.array_time = chrono::duration_cast<chrono::nanoseconds>(end_time - start_time);
    }

    {
        auto start_time = chrono::steady_clock::now();
        volatile bool is_pal = is_permutation_palindrome_bitset(input);
        auto end_time = chrono::steady_clock::now();
        sample.bitset_time = chrono::duration_cast<chrono::nanoseconds>(end_time - start_time);
    }

    return sample;
}

struct metric_t {
    chrono::nanoseconds min = chrono::nanoseconds(std::numeric_limits<std::int64_t>::max());
    chrono::nanoseconds max = chrono::nanoseconds(0);
    chrono::nanoseconds sum = chrono::nanoseconds(0);

    void update(chrono::nanoseconds new_sample) {
        if (min > new_sample) {
            min = new_sample;
        }

        if (max < new_sample) {
            max = new_sample;
        }

        sum += new_sample;
    }
};


#include <sstream>
#include <fstream>

int main(int argc, char* argv[]) {
    if (argc != 4) {
        std::cerr << "usage: " << argv[0] << " <input-size> <run-count> <output-file>\n";
        return EXIT_FAILURE;
    }

    const std::size_t size = std::stoull(argv[1]);
    const std::size_t target_amount_of_runs = std::stoull(argv[2]);

    std::vector<sample_t> samples;
    samples.reserve(target_amount_of_runs);
    for (std::size_t i = 0; i < target_amount_of_runs; ++i) {
        auto input = shino::generate_random_input(size);
        auto sample = measure(input);
        samples.push_back(sample);
    }

    metric_t metrics_for_og;
    metric_t metrics_for_array;
    metric_t metrics_for_bitset;
    for (const auto& sample: samples) {
        metrics_for_og.update(sample.original_time);
        metrics_for_array.update(sample.array_time);
        metrics_for_bitset.update(sample.bitset_time);
    }

    chrono::nanoseconds avg_time_og = metrics_for_og.sum / target_amount_of_runs;
    chrono::nanoseconds avg_time_array = metrics_for_array.sum / target_amount_of_runs;
    chrono::nanoseconds avg_time_bitset = metrics_for_bitset.sum / target_amount_of_runs;

    std::vector<chrono::nanoseconds> og_samples;
    og_samples.reserve(target_amount_of_runs);
    std::vector<chrono::nanoseconds> array_samples;
    array_samples.reserve(target_amount_of_runs);
    std::vector<chrono::nanoseconds> bitset_samples;
    bitset_samples.reserve(target_amount_of_runs);
    for (const auto& sample: samples) {
        og_samples.push_back(sample.original_time);
        array_samples.push_back(sample.array_time);
        bitset_samples.push_back(sample.bitset_time);
    }

    std::sort(og_samples.begin(), og_samples.end());
    std::sort(array_samples.begin(), array_samples.end());
    std::sort(bitset_samples.begin(), bitset_samples.end());

    std::ostringstream oss;
    oss << "original function metrics (ns):\n"
        << "\tmin: " << metrics_for_og.min.count() << '\n'
        << "\tmax: " << metrics_for_og.max.count() << '\n'
        << "\tavg: " << avg_time_og.count() << '\n'
        << "\t98th percentile: " << og_samples[target_amount_of_runs * 0.98].count()
              << "\n\n";

    oss << "array based function metrics (ns):\n"
        << "\tmin: " << metrics_for_array.min.count() << '\n'
        << "\tmax: " << metrics_for_array.max.count() << '\n'
        << "\tavg: " << avg_time_array.count() << '\n'
        << "\t98th percentile: " << array_samples[target_amount_of_runs * 0.98].count()
              << "\n\n";

    oss << "bitset based function metrics (ns):\n"
        << "\tmin: " << metrics_for_bitset.min.count() << '\n'
        << "\tmax: " << metrics_for_bitset.max.count() << '\n'
        << "\tavg: " << avg_time_bitset.count() << '\n'
        << "\t98th percentile: " << bitset_samples[target_amount_of_runs * 0.98].count()
              << "\n\n";

    std::ofstream output_file(argv[3]);
    if (!output_file) {
        std::cerr << "opening output file failed, printing results to stderr:\n"
                  << oss.str();
        return EXIT_FAILURE;
    }

    output_file << oss.str();
}

And input_gen.h:

#ifndef IS_PAL_INPUT_GEN_H
#define IS_PAL_INPUT_GEN_H

#include <string>
#include <random>
#include <algorithm>

namespace shino {

// the input is under 1MiB
// only request at a time
inline std::string generate_random_input(std::size_t size) {
    std::minstd_rand0 generator;
    std::uniform_int_distribution<char> dist;
    std::string result(size, '\0');
    std::generate(result.begin(), result.end(),
                  [&dist, &generator]() {
        return dist(generator);
    });

    return result;
}

}

#endif //IS_PAL_INPUT_GEN_H

Alright, with that out of the way, here is how and why I benchmarked the code:

Benchmark metric: I used latency as the metric. Of course I also got OS scheduling, I/O interrupts and other stuff in there, but I also did 98th percentile test to see if the deviation is too big. All of the three versions I benchmarked were pretty close to average on 98th percentile, albeit being slower than average sample.

Sampling: I decided to do the basic run and measure, using std::chrono::steady_clock, because both std::chrono::high_resolution_clock and system version failed me once (They readjusted to invalidate my results). I did not use any benchmarking library because they don't really measure latency, but throughput.

Metrics list:

  • Fastest sample

  • Slowest sample

  • Average sample

  • 98th percentile sample

Results:

The benchmark was run with input size 1024 and run count 1048576.

I'm gonna separate the answers' versions from the changes I made, so here are the results from Deduplicator and G. Sliepen:

array based function metrics (ns):
        min: 402
        max: 30785
        avg: 418
        98th percentile: 460

bitset based function metrics (ns):
        min: 705
        max: 41427
        avg: 768
        98th percentile: 916

Verbatim version (perhaps there are small changes, I do not remember correctly):

original function metrics (ns):
        min: 4055
        max: 83646
        avg: 4169
        98th percentile: 4303

Reserve:

original function metrics (ns):
        min: 4350
        max: 69625
        avg: 4546
        98th percentile: 4678

Reserve seems to give negative effect.

PMR version with no reserve:

original function metrics (ns):
        min: 4814
        max: 91582
        avg: 5129
        98th percentile: 5539

PMR version seems to be slower.

The original version of PMR, with oversized buffer on the stack:

original function metrics (ns):
        min: 5111
        max: 582302
        avg: 5345
        98th percentile: 5489

Even worse.

PMR with 8kb buffer and reserve:

        min: 5160
        max: 74025
        avg: 5416
        98th percentile: 5572

Even more worse.


I have no idea why anything I do with std::unordered_map makes it perform worse. It is clear that the array based version is faster, a lot faster than map version and considerably faster than bitset version.

I also managed to stumble upon the functions being optimized out, but volatile solved that problem.


Repo: https://github.com/simmplecoder/is-palindrome-benchmark


Specific updates: array vs bitset.

I separated input generation into three functions:


inline std::string generate_homogenous_input(std::size_t size) {
    std::string result(size, '\0');
    std::fill(result.begin(), result.end(), 'a');
    return result;
}

inline std::string generate_spread_input(std::size_t size) {
    std::string result(size, '\0');

    unsigned char start_char = 0;
    for (auto& c: result) {
        c = static_cast<char>(start_char);
        start_char += sizeof(std::size_t) * CHAR_BIT;
    }

    return result;
}

// the input is under 1MiB
// only request at a time
inline std::string generate_random_input(std::size_t size) {
    std::minstd_rand0 generator;
    std::uniform_int_distribution<char> dist;
    std::string result(size, '\0');
    std::generate(result.begin(), result.end(),
                  [&dist, &generator]() {
                      return dist(generator);
                  });

    return result;
}

This is in response to @PeterCordes about performance of bitset when there is a data dependency (the next iteration flip bit from the same word as the current).

I guess I fixed something and now the average is actually above 98th percentile, which is correct. It means that more often than not the benchmarks stayed in the same range. Here are the results:

input size: 1024, run count: 1048576 homogenous input (all 'a's)

array version:

metrics for array based version(ns):
        min time: 1330
        max time: 11382
        avg time: 3033
        98th percentile time: 1750

bitset version:

metrics for bitset based version:
        min time: 1248
        max time: 27424
        avg time: 2826
        98th percentile time: 1761

It can be seen that Peter was correct. When there is a dependency on the previous iteration, the execution pipeline will stall. It can seen in perf with very high backend stall.

input size: 1024, run count: 1048576, input generation type: spread (64 values)

Array version:

metrics for array based version(ns):
        min time: 405
        max time: 14208
        avg time: 859
        98th percentile time: 525

Bitset version:

metrics for bitset based version:
        min time: 515
        max time: 9464
        avg time: 1161
        98th percentile time: 617

This is the best case scenario for each version because now they do not have dependency. The only problem with bitset is that it probably compiles down to more instructions.

input size: 1024, run count: 1048576, input generation type: random

The values did not change for this one (as they should) and they are written above.


Runtime environment (gaming rig):

CPU: intel i7 11700k, no overclocking

RAM: 3200MHz, don't remember the timings.

OS: Ubuntu 21.10

g++: 11.2.0

No demanding software was active, but then this is not RTOS, it will be far from perfect anyway.


I recorded some videos with the process. The first one turned out quite horrible. The next part I believe got quite better because it got more focus and less of me making basic errors. The last part is just a comparison with some analysis (and confusion).

\$\endgroup\$
24
  • 2
    \$\begingroup\$ I also did a video with entirety of the process (and me endlessly stumbling on basic mistakes xD ) but YouTube is very slow on the processing side. Please comment if you would like to watch it, it is more than an hour long and honestly boring, but it does show the methodology in its entirety, which I believe is important. \$\endgroup\$ Commented Dec 19, 2021 at 18:34
  • \$\begingroup\$ Nice testing. I assume it was on a barely loaded modern desktop, but I would have expected it spelled out. \$\endgroup\$ Commented Dec 19, 2021 at 19:30
  • \$\begingroup\$ @Deduplicator, nice catch. The hardware specs are in the video, but I've forgotten to include it here. Fixed. \$\endgroup\$ Commented Dec 19, 2021 at 19:34
  • 1
    \$\begingroup\$ I think that UCHAR_MAX would be clearer to the reader than (unsigned char)-1. \$\endgroup\$ Commented Dec 21, 2021 at 8:19
  • 1
    \$\begingroup\$ @G.Sliepen, I put the run count and input size values, sorry for forgetting those! By the way, check out the updates. The processing on the video finally completed for FullHD: youtu.be/JpbmOLYv9KQ . I also did another round of videos on what Peter suggested, here is part 1 but it is far from being processed: youtu.be/9usa5psxdq0 . The latter ones are better in quality from my POV. They are more concise and have less of me messing up my own code :) \$\endgroup\$ Commented Dec 21, 2021 at 17:08
3
\$\begingroup\$

The other comments so far assume 8-bit characters. If this is not the case, you would want to convert your multi-byte string to char32_t, normalize (for example, by converting all characters to canonical NFD form and discarding all combining characters, counting only the base characters) and then keeping a count of each characters in a std::unordered_map with char32_t as the key type and size_t as the value type. To increment the count, you would extract the key in constant time, increment the count or create a new key-value pair if it is not present, then insert it with an incremented count.

Unfortunately, you need char32_t because Windows uses 16-bit wchar_t, which does not conform to the language Standard, because of surrogate pairs. (The original sin of Unicode was thinking that 16 bits would be enough. When the C language committee decreed that wchar_t must be a fixed-width encoding, Microsoft said, no, they were not breaking the entire Windows API.) A hashed canonical representation of a multi-byte substring could work too.

An implementation that conforms to the C++ language standard must also honor the canonical equivalence of different representations. In most languages, you would want to canonically decompose and ignore accents, but if you want to recognize only characters with the same accents as palindromes, you would need to canonically compose them instead, and possibly even use hashed strings as your keys. You probably also want to ignore all non-alphanumeric characters, and normalize all characters to the same case, to recognize “Madam in Eden, I’m Adam,” as a palindrome.

In short, the C++ Standard Library does not have the features you would need to do this correctly in Unicode, and you would need a third-party library such as ICU.

G. Sleipen is, however, correct that you merely need to check that there is at most one character with an odd-numbered count.

\$\endgroup\$
2
  • 2
    \$\begingroup\$ Going full unicode or at least catering to mbcs is interesting, especially deciding to throw away combining characters changes things. BTW: My answer doesn't assume minimal size bytes anywhere, though I do mention that the table could become infeasibly large if a byte is too big. \$\endgroup\$ Commented Dec 20, 2021 at 17:45
  • \$\begingroup\$ @Deduplicator I think an unordered_map on char32_t as the key type (or a hashed canonical representation of a multi-byte string could work too), with a count as the value type, would be the way to go. \$\endgroup\$
    – Davislor
    Commented Dec 20, 2021 at 17:53

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.