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++98std::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
#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") // falseAll find_* methods are also available on std::string_view with identical semantics.
Substrings and Modification
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
::toupperand UB: Passing achardirectly tostd::toupperis undefined behaviour whencharis signed and the value is negative (i.e., code points above 127). Always cast throughunsigned char.
Split
The standard library has no split built-in before C++20's ranges.
#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
#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
#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
// 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
#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
#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 viewstd::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_viewparameters overconst std::string&for read-only functions; it works withstring,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 ofappendor+=to avoid repeated reallocations. - Use
std::from_chars/std::to_charsfor number serialisation in performance-sensitive or locale-sensitive contexts;stoi/stodrespect the global locale and may block on locale mutex. - C++20 predicates first. Replace
s.find(x) == 0withs.starts_with(x)for clarity and marginally better optimiser hints. - Cast through
unsigned charbefore passing to any<cctype>function (toupper,tolower,isspace,isdigit) to avoid UB on platforms wherecharis signed.
Common Pitfalls
string::nposcomparisons with signed integers.s.find(...)returnsstd::size_t; comparing the result to-1orintvariables is always false on typical 64-bit platforms. Always compare tostd::string::npos.substrisO(n). On inner loops over large strings, composestring_view::substrinstead; it'sO(1)and allocates nothing.- Dangling
string_viewfrom temporaries.std::string_view sv = std::string{"temp"};compiles and immediately dangles — the temporary is destroyed at the semicolon. std::views::splitin C++20 returns subranges, notstring_views. The range constructorstd::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_allwith an emptyfromstring. 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.
#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;
}// 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++23The 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.
#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
}#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.
#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; }// 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, callstd::toupper(c, loc)from<locale>with an explicitstd::localeobject — 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.
#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"// 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");// 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++23std::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.
#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// 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
#endifPortability caveat:
std::string::containsandstd::string_view::containsare 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_containsif 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.
#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
}// 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.
#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// 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)// 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_facetis 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
std::string_view— lightweight non-owning string referencestd::format— type-safe formatted string construction (C++20)std::regex— POSIX/ECMAScript regular expressions (C++11)std::from_chars/std::to_chars— fast number ↔ string conversion (C++17)std::ranges::split_view— lazy range-based splitting (C++20)