Skip to content
C++
Library
since C++98
Beginner

String Algorithms

C++ string operations — searching, splitting, trimming, case conversion, replace-all, and fast number parsing with from_chars and string_view.

std::string algorithmssince C++98

std::string provides member functions for searching, modifying, and comparing character sequences; C++20 added starts_with, ends_with, and contains; C++17 introduced std::string_view for zero-copy string references and std::from_chars/std::to_chars for locale-independent, allocation-free number parsing.

Overview

std::string (since C++98) is a mutable, owning sequence of char. Its member API covers most common operations, but several utilities — splitting, joining, trimming, replace-all — have no single built-in and require a few lines of composition. C++20 added the predicate trio (starts_with, ends_with, contains) and std::views::split. For read-only string handling, prefer std::string_view (C++17): it accepts both std::string and const char* arguments without copying.


Searching

cpp
#include <string>

std::string s = "hello world hello";

// find / rfind — return position or std::string::npos (C++98)
s.find("world")        // 6
s.find("world", 7)     // npos (start search at offset 7)
s.rfind("hello")       // 12 (last occurrence)
s.find('o')            // 4 (char overload)

// Character-set scanning (C++98)
s.find_first_of("aeiou")      // 1 ('e')
s.find_last_of("aeiou")       // 13 ('o')
s.find_first_not_of("helo ") // 6 ('w')
s.find_last_not_of(" ")      // 16

// C++20 — cleaner boolean predicates
s.starts_with("hello")   // true
s.ends_with("hello")     // true
s.contains("world")      // true
s.contains("xyz")        // false

All find_* methods are also available on std::string_view with identical semantics.


Substrings and Modification

cpp
std::string s = "hello world";

// substr — always allocates (C++98); prefer string_view::substr for read-only work
s.substr(6)     // "world"
s.substr(6, 3)  // "wor"

// replace, insert, erase (C++98)
s.replace(6, 5, "C++");     // "hello C++"
s.insert(5, ",");            // "hello, C++"
s.erase(5, 2);               // "hello C++"

// Append (C++98)
s += " rocks";
s.append(3, '!');            // append 3 '!' chars
s.push_back('?');

// In-place case transform — MUST cast through unsigned char (avoid UB)
#include <algorithm>
#include <cctype>
std::transform(s.begin(), s.end(), s.begin(),
               [](unsigned char c) { return std::toupper(c); });  // C++11 lambda

::toupper and UB: Passing a char directly to std::toupper is undefined behaviour when char is signed and the value is negative (i.e., code points above 127). Always cast through unsigned char.


Split

The standard library has no split built-in before C++20's ranges.

cpp
#include <string_view>
#include <vector>

// Manual split — O(n), no allocation per token for view version (C++17)
std::vector<std::string_view> split(std::string_view sv, char delim) {
    std::vector<std::string_view> out;
    for (;;) {
        auto pos = sv.find(delim);
        out.push_back(sv.substr(0, pos));
        if (pos == std::string_view::npos) break;
        sv.remove_prefix(pos + 1);  // string_view::remove_prefix — C++17
    }
    return out;  // views into the original buffer; don't outlive it
}

// C++20 ranges split — lazy, produces contiguous subranges
#include <ranges>
auto rng = std::views::split("one,two,three"sv, ',');
for (auto part : rng) {
    std::string_view sv{part.begin(), part.end()};  // C++20
    // C++23: std::string_view sv{part};  (range constructor)
}

// C++23: collect into vector without manual loop
// auto parts = std::views::split("a,b,c"sv, ',')
//            | std::ranges::to<std::vector<std::string>>();

The manual version is often faster in benchmarks because it avoids the lazy-range machinery overhead for small inputs and returns string_view tokens — zero allocation.


Join

cpp
#include <numeric>
#include <string>

// Accumulate-based join (C++11)
std::string join(std::span<const std::string> parts, std::string_view sep) {
    // std::span — C++20
    if (parts.empty()) return {};
    std::string out = parts[0];
    for (std::size_t i = 1; i < parts.size(); ++i) {
        out += sep;
        out += parts[i];
    }
    return out;
}

// Reserve up-front for large ranges (avoids repeated reallocations)
std::string join_reserved(std::span<const std::string> parts, std::string_view sep) {
    if (parts.empty()) return {};
    std::size_t total = sep.size() * (parts.size() - 1);
    for (auto& p : parts) total += p.size();
    std::string out;
    out.reserve(total);
    out = parts[0];
    for (std::size_t i = 1; i < parts.size(); ++i) {
        out.append(sep);
        out.append(parts[i]);
    }
    return out;
}

Trim

cpp
#include <string_view>
#include <cctype>

// Whitespace characters per the C locale
constexpr std::string_view kWS = " \t\r\n\v\f";

std::string_view ltrim(std::string_view sv) {
    auto pos = sv.find_first_not_of(kWS);
    return pos == std::string_view::npos ? "" : sv.substr(pos);
}

std::string_view rtrim(std::string_view sv) {
    auto pos = sv.find_last_not_of(kWS);
    return pos == std::string_view::npos ? "" : sv.substr(0, pos + 1);
}

std::string_view trim(std::string_view sv) {
    return ltrim(rtrim(sv));
}

find_first_not_of with an explicit whitespace set sidesteps the ::isspace UB trap and is locale-independent. The functions return string_view — zero allocation.


Replace All

cpp
// std::string has no replace-all member (C++98–C++23)
std::string replace_all(std::string s, std::string_view from, std::string_view to) {
    if (from.empty()) return s;
    std::size_t pos = 0;
    while ((pos = s.find(from, pos)) != std::string::npos) {
        s.replace(pos, from.size(), to);
        pos += to.size();  // advance past replacement — handles "aa"→"a" correctly
    }
    return s;
}

Number Conversions

cpp
#include <charconv>   // C++17
#include <format>     // C++20

// Fast, no-alloc, locale-independent (C++17)
char buf[32];
auto [ptr, ec] = std::to_chars(buf, buf + sizeof(buf), 3.14159, std::chars_format::fixed, 2);
// ptr points past the last written char; ec == std::errc{} on success
std::string_view result{buf, ptr};  // "3.14"

int n{};
auto [p2, ec2] = std::from_chars("42abc", "42abc" + 5, n);
// n == 42, p2 points to 'a', ec2 == std::errc{}

// Legacy — locale-sensitive, may throw, allocates (C++11)
int  i = std::stoi("42");
double d = std::stod("3.14");
std::string s = std::to_string(42);  // "42"

// Formatted (C++20)
std::string f = std::format("{:.2f}", 3.14159);  // "3.14"
std::string h = std::format("{:#010x}", 255);    // "0x000000ff"

Prefer std::from_chars/std::to_chars in hot paths: they are 5–10× faster than stoi/stod in benchmarks and never throw.


std::string_view Efficiency

cpp
#include <string_view>

// Accept any string-like input without allocation (C++17)
void process(std::string_view sv) {
    auto word = sv.substr(0, sv.find(' '));  // view into sv, no heap
    sv.remove_prefix(word.size());           // slide window in-place
    sv.remove_suffix(sv.size() - sv.rfind('\n') - 1);
}

process("literal");             // no allocation
process(some_std_string);       // implicit conversion, no copy
process(buf, len);              // from raw buffer

// PITFALL: string_view does not own — never return a view into a local
std::string_view bad() {
    std::string s = compute();
    return s;   // s is destroyed; returned view dangles — UB
}

// Safe: return std::string or ensure the backing string outlives the view

std::string_view also exposes remove_prefix and remove_suffix (C++17), making it ideal as a cursor when parsing tokenised input without extra allocation.


Best Practices

  • Prefer string_view parameters over const std::string& for read-only functions; it works with string, const char*, and string literals without copies.
  • Reserve before building large strings. If the final size is predictable, call s.reserve(n) before a loop of append or += to avoid repeated reallocations.
  • Use std::from_chars/std::to_chars for number serialisation in performance-sensitive or locale-sensitive contexts; stoi/stod respect the global locale and may block on locale mutex.
  • C++20 predicates first. Replace s.find(x) == 0 with s.starts_with(x) for clarity and marginally better optimiser hints.
  • Cast through unsigned char before passing to any <cctype> function (toupper, tolower, isspace, isdigit) to avoid UB on platforms where char is signed.

Common Pitfalls

  • string::npos comparisons with signed integers. s.find(...) returns std::size_t; comparing the result to -1 or int variables is always false on typical 64-bit platforms. Always compare to std::string::npos.
  • substr is O(n). On inner loops over large strings, compose string_view::substr instead; it's O(1) and allocates nothing.
  • Dangling string_view from temporaries. std::string_view sv = std::string{"temp"}; compiles and immediately dangles — the temporary is destroyed at the semicolon.
  • std::views::split in C++20 returns subranges, not string_views. The range constructor std::string_view{part} was not available until C++23. Code that compiles on C++23 may fail on C++20 without the explicit {part.begin(), part.end()} form.
  • replace_all with an empty from string. Without a guard, find("", pos) always succeeds at every position, producing an infinite loop.

String splitting (no standard split until ranges)

There is no std::split in C++98–C++17; every pre-range solution is a hand-rolled loop or a std::istringstream pass. The four idiomatic approaches trade off between allocation cost, delimiter flexibility, and standard-library version requirements.

cpp
#include <sstream>
#include <string>
#include <vector>

// istringstream — splits on any whitespace, easy but allocates (C++98)
std::vector<std::string> split_ws(const std::string& s) {
    std::istringstream iss(s);
    return {std::istream_iterator<std::string>{iss},
            std::istream_iterator<std::string>{}};
}

// Single-char delimiter, no regex (C++98)
std::vector<std::string> split_char(const std::string& s, char delim) {
    std::vector<std::string> out;
    std::string token;
    for (char c : s) {
        if (c == delim) { out.push_back(std::move(token)); token.clear(); }
        else             token += c;
    }
    out.push_back(std::move(token));
    return out;
}

// Multi-char delimiter via find (C++11)
std::vector<std::string> split_str(std::string_view s, std::string_view delim) {
    std::vector<std::string> out;
    std::size_t start = 0, pos;
    while ((pos = s.find(delim, start)) != std::string_view::npos) {
        out.emplace_back(s.substr(start, pos - start));
        start = pos + delim.size();
    }
    out.emplace_back(s.substr(start));
    return out;
}
cpp
// C++20 ranges — lazy, zero-allocation view tokens  // C++20
#include <ranges>
#include <string_view>
using namespace std::literals;

auto tokens = "one::two::three"sv
    | std::views::split("::"sv);           // produces subranges  // C++20

// Materialize to strings (C++20 form — range ctor not yet available for sv)
for (auto rng : tokens) {
    std::string_view tok{rng.begin(), rng.end()};  // C++20
    // C++23: std::string_view tok{rng};
}

// C++23: pipe directly into a vector
// auto v = "a,b,c"sv | std::views::split(',')
//        | std::ranges::to<std::vector<std::string>>();  // C++23

The string_view-based manual split (from the Split section above) is typically the fastest option for single-char delimiters on short strings because it returns non-owning tokens and avoids any heap allocation.


String trimming patterns

std::string has no trim member; the standard idiom combines find_first_not_of and find_last_not_of with an explicit whitespace set. Using <cctype> predicates like std::isspace inside a lambda requires the unsigned char cast to avoid UB on platforms where char is signed.

cpp
#include <string>
#include <algorithm>
#include <cctype>

// In-place trim (C++11) — modifies the original string
void ltrim_inplace(std::string& s) {
    s.erase(s.begin(),
            std::find_if(s.begin(), s.end(),
                         [](unsigned char c) { return !std::isspace(c); }));
}

void rtrim_inplace(std::string& s) {
    s.erase(std::find_if(s.rbegin(), s.rend(),
                         [](unsigned char c) { return !std::isspace(c); }).base(),
            s.end());
}

void trim_inplace(std::string& s) { ltrim_inplace(s); rtrim_inplace(s); }

// Value-returning trim — returns a new std::string (C++11)
std::string trimmed(std::string s) {
    trim_inplace(s);
    return s;  // NRVO — no extra copy
}
cpp
#include <string_view>

// Zero-allocation view-based trim — preferred for read-only paths (C++17)
constexpr std::string_view kWhitespace = " \t\r\n\v\f";

std::string_view trim_view(std::string_view sv) {
    sv.remove_prefix(std::min(sv.find_first_not_of(kWhitespace), sv.size()));
    auto last = sv.find_last_not_of(kWhitespace);
    if (last != std::string_view::npos) sv = sv.substr(0, last + 1);
    return sv;
}

Return std::string_view when the trimmed result is consumed immediately; return std::string (or copy into one) when the result must outlive the input buffer.


Case conversion

The C++ standard library provides only single-character case functions in <cctype>; converting an entire string requires std::transform. For Unicode-aware case folding (e.g., German ß → SS), use a library such as ICU or Boost.Locale — the standard locale approach covers only single-byte encodings correctly.

cpp
#include <string>
#include <algorithm>
#include <cctype>

// In-place to-upper / to-lower (C++11 lambda)
void to_upper(std::string& s) {
    std::transform(s.begin(), s.end(), s.begin(),
        [](unsigned char c) { return static_cast<char>(std::toupper(c)); });
}

void to_lower(std::string& s) {
    std::transform(s.begin(), s.end(), s.begin(),
        [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
}

// Returning copies (C++11)
std::string upper(std::string s) { to_upper(s); return s; }
std::string lower(std::string s) { to_lower(s); return s; }
cpp
// Case-insensitive comparison without converting (C++11)
bool iequal(std::string_view a, std::string_view b) {   // C++17
    if (a.size() != b.size()) return false;
    return std::equal(a.begin(), a.end(), b.begin(),
        [](unsigned char x, unsigned char y) {
            return std::tolower(x) == std::tolower(y);
        });
}

Locale and std::toupper: The <cctype> functions consult the C locale. For locale-aware conversion, call std::toupper(c, loc) from <locale> with an explicit std::locale object — but be aware this is significantly slower and affects only the C locale's single-byte character set.


String formatting with std::format (C++20)

std::format (C++20) brings Python/Rust-style format strings to C++ with full type safety and no printf-family undefined behaviour. It produces a std::string by default; std::format_to writes into any output iterator without allocation.

cpp
#include <format>   // C++20
#include <string>

// Basic formatting
std::string s1 = std::format("Hello, {}!", "world");        // "Hello, world!"
std::string s2 = std::format("{} + {} = {}", 1, 2, 1+2);   // "1 + 2 = 3"

// Width, fill, alignment
std::string s3 = std::format("{:>10}", "right");    // "     right"
std::string s4 = std::format("{:-<10}", "left");    // "left------"
std::string s5 = std::format("{:^11}", "center");   // "  center   "

// Numbers
std::string s6 = std::format("{:.4f}", 3.14159265); // "3.1416"
std::string s7 = std::format("{:08b}", 42);         // "00101010"
std::string s8 = std::format("{:#x}", 255);         // "0xff"
std::string s9 = std::format("{:+}", 42);           // "+42"
cpp
// format_to — write into an existing buffer / iterator (C++20)
#include <format>
#include <iterator>

char buf[64];
auto end = std::format_to(buf, "x={}, y={}", 3, 7);
std::string_view result{buf, end};  // "x=3, y=7"

// format_to with a back_insert_iterator (appends to existing string)
std::string out = "prefix: ";
std::format_to(std::back_inserter(out), "{:05d}", 42);  // "prefix: 00042"

// format_to_n — write at most n characters (C++20)
std::format_to_n(buf, sizeof(buf) - 1, "{}", "truncated if needed");
cpp
// C++23 additions
// std::print / std::println — format and write to stdout or FILE*  // C++23
#include <print>
std::println("The answer is {}", 42);           // with newline  // C++23
std::print(stderr, "Error: {}\n", "oops");      // to stderr     // C++23

// Formatting ranges (C++23)
std::vector<int> v{1, 2, 3};
std::string s = std::format("{}", v);  // "[1, 2, 3]"  // C++23

std::format does not support %n and cannot cause format-string injection vulnerabilities. Compile-time format-string validation was added as a mandatory requirement — a mismatched type or argument count is a compile error, not a runtime crash.


String contains, starts_with, ends_with (C++20/23)

C++20 added starts_with, ends_with, and contains as member functions on both std::string and std::string_view, replacing verbose find-based idioms. All three accept a string_view, const char*, or single char argument.

cpp
#include <string>
#include <string_view>

std::string url = "https://example.com/path?query=1";

// C++20 — clean boolean predicates
bool is_https  = url.starts_with("https://");  // true   // C++20
bool has_query = url.contains('?');             // true   // C++20
bool ends_htm  = url.ends_with(".html");        // false  // C++20

// Works identically on string_view (C++20)
std::string_view sv = url;
sv.starts_with("https");   // true  // C++20
sv.ends_with("query=1");   // true  // C++20
sv.contains("example");    // true  // C++20

// Pre-C++20 equivalents (avoid in new code targeting C++20+)
// starts_with:  s.find(prefix) == 0
// ends_with:    s.size() >= suffix.size() &&
//               s.compare(s.size()-suffix.size(), suffix.size(), suffix) == 0
// contains:     s.find(needle) != std::string::npos
cpp
// contains on std::string_view (C++23 — note: sv::contains is C++23)
// std::string::contains and std::string_view::contains were both added in C++23
// (originally mis-attributed to C++20 in some implementations)
// Check your compiler support before using sv.contains() in C++20 mode.
#if __cpp_lib_string_contains >= 202011L
    sv.contains("needle");  // safe to use
#endif

Portability caveat: std::string::contains and std::string_view::contains are specified in C++23 (P1679R3), not C++20, despite early proposal dates. GCC 12+ and Clang 15+ provide them in C++20 mode as an extension; MSVC 17.3+ likewise. Always test with __cpp_lib_string_contains if targeting mixed compilers.


Boyer-Moore searching (C++17)

std::boyer_moore_searcher and std::boyer_moore_horspool_searcher (C++17) bring sub-linear pattern searching to std::search, outperforming the naïve O(n·m) string::find for long patterns. The searcher object precomputes skip tables from the pattern once; reuse it across multiple haystacks.

cpp
#include <algorithm>    // std::search
#include <functional>   // std::boyer_moore_searcher  // C++17
#include <string_view>

std::string_view haystack = "the quick brown fox jumps over the lazy dog";
std::string_view needle   = "fox";

// C++17 — construct once, search many
auto searcher = std::boyer_moore_searcher(needle.begin(), needle.end());

auto it = std::search(haystack.begin(), haystack.end(), searcher);
if (it != haystack.end()) {
    std::size_t pos = static_cast<std::size_t>(it - haystack.begin());  // 16
}
cpp
// Boyer-Moore-Horspool variant — smaller skip table, similar performance  // C++17
auto bm_horspool = std::boyer_moore_horspool_searcher(
    needle.begin(), needle.end());

// Search in multiple haystacks — precomputed tables amortise cost
for (std::string_view line : lines) {
    auto found = std::search(line.begin(), line.end(), bm_horspool);
    if (found != line.end()) { /* process match */ }
}

// Default searcher — same as std::search without an explicit searcher
auto default_s = std::default_searcher(needle.begin(), needle.end());

Boyer-Moore is most beneficial when the pattern is at least 4–8 characters and the alphabet is large (e.g., ASCII text). For 1–3 character patterns or binary data with a small alphabet, string::find is often faster due to lower overhead.


Locale-aware string comparison

The global C locale affects std::strcmp-family functions, <cctype> predicates, and std::string's < operator only indirectly through std::collate. For proper locale-aware collation (e.g., sorting "ä" after "a" in German), use std::locale with std::collate or std::use_facet.

cpp
#include <locale>
#include <string>

// Locale-aware collation (C++98)
std::locale loc("de_DE.UTF-8");
auto& coll = std::use_facet<std::collate<char>>(loc);

std::string a = "ärger", b = "Brot";
int result = coll.compare(
    a.data(), a.data() + a.size(),
    b.data(), b.data() + b.size());
// result < 0 means a sorts before b under German locale rules
cpp
// Sort a vector of strings using a locale's collation order (C++11)
#include <algorithm>
#include <vector>

std::vector<std::string> words = {"Zebra", "ändern", "Apfel", "über"};
std::locale loc("de_DE.UTF-8");
auto& coll = std::use_facet<std::collate<char>>(loc);

std::sort(words.begin(), words.end(),
    [&](const std::string& x, const std::string& y) {
        return coll.compare(
            x.data(), x.data() + x.size(),
            y.data(), y.data() + y.size()) < 0;
    });
// Result respects German alphabet ordering (ä ≈ a, ö ≈ o, ü ≈ u)
cpp
// Case-insensitive comparison using locale ctype facet (C++11)
#include <locale>
bool iless_locale(const std::string& a, const std::string& b,
                  const std::locale& loc = std::locale{}) {
    const auto& ct = std::use_facet<std::ctype<char>>(loc);
    auto fold = [&](char c) { return ct.tolower(c); };
    return std::lexicographical_compare(
        a.begin(), a.end(), b.begin(), b.end(),
        [&](char x, char y) { return fold(x) < fold(y); });
}

Performance note: std::use_facet is a virtual-dispatch call and locale operations can be expensive. Cache the facet reference outside tight loops and avoid changing the global locale (std::locale::global) in multithreaded code — it is not thread-safe.


See Also