C++ MinMax Algorithms: std::min, max, minmax_element & clamp
Key takeaways
Use std::min, max, minmax, min_element, max_element, minmax_element, and C++17 std::clamp — two-value vs range APIs, iterators, and performance notes.
What are MinMax algorithms?
They find minimum and maximum values: overloads for two or more values, and range algorithms returning iterators.
Why use them?
- Concise code vs manual branches
- Type-safe comparisons
- Range algorithms for position and min/max in one pass
Overview | API | Input | Returns | Time | |-----|--------|---------|------| |min(a,b)/max| 2 values | reference | O(1) | |minmax(a,b)| 2 values | pair | O(1) | |min_element/max_element| range | iterator | O(n) | |minmax_element| range | pair of iterators | O(n) | |clamp(v, lo, hi)| value + bounds | clamped value | O(1) |
It is tempting to treat this whole family as “obvious” and skip straight to the table above, but the two-value overloads and the range algorithms behave quite differently under the hood, and mixing up their guarantees is where most real bugs come from. std::min and std::max are essentially inlined comparisons that the compiler can fold away at -O2/-O3 — there is no loop, no branch misprediction cost worth worrying about, and no allocation. min_element, max_element, and minmax_element, on the other hand, are linear scans over a range, so their cost is dictated entirely by how many elements you have and how expensive your comparator is. Knowing which category an API falls into tells you immediately whether reaching for it in a hot loop is free or something you should think twice about.
std::min and std::max
Basic usage
#include <algorithm>
int a = 10, b = 20;
int smaller = std::min(a, b); // 10
int larger = std::max(a, b); // 20
// With initializer list (C++11)
int minimum = std::min({5, 2, 8, 1, 9}); // 1
int maximum = std::max({5, 2, 8, 1, 9}); // 9
Custom comparator
struct Person {
std::string name;
int age;
};
Person p1{"Alice", 30};
Person p2{"Bob", 25};
// Compare by age
auto younger = std::min(p1, p2, [](const Person& a, const Person& b) {
return a.age < b.age;
});
// younger is Bob (25)
There are two details about std::min/std::max that the signature does not make obvious, and both of them have bitten me in real projects.
The first is that both functions return a reference, not a copy. std::min(a, b) is declared roughly as template<class T> const T& min(const T& a, const T& b) — it hands back a reference to whichever of the two input objects compares smaller. That is a deliberate design choice: for expensive-to-copy types it avoids an unnecessary copy, and for small types like int the compiler optimizes the reference away entirely. The trap is that a reference is only as long-lived as the object it points to. If either argument is a temporary — the result of a function call, an arithmetic expression, or a literal that gets implicitly converted — binding the result to a const auto& or const T& extends the lifetime of at most one temporary in very specific circumstances, and in general C++ gives no such guarantee when the reference comes back out of a function call like std::min. Something like const int& r = std::min(compute_a(), compute_b()); compiles cleanly and often “works” in debug builds where the temporary’s stack slot hasn’t been reused yet, then produces garbage in a release build once the optimizer decides the temporary’s storage can be reclaimed immediately after the call. The safe habit is to bind the result of std::min/std::max by value (int r = std::min(...)) unless you have specifically verified that both arguments are named, long-lived objects.
The second detail is that the standard does not guarantee single evaluation of either argument. A typical implementation is return (b < a) ? b : a;, which only evaluates each expression once in the two-argument overload, but the moment you use the initializer-list overload (std::min({a, b, c})) or a custom comparator that itself calls into your arguments, you can no longer assume each argument is touched exactly once. This matters a lot if an argument has a side effect — std::min(++counter, cap) is a classic way to get counter incremented an unexpected number of times, or to invoke an expensive function twice when you only meant to call it once. The rule of thumb I follow now: never pass an expression with a side effect directly into min/max/minmax; compute it into a local variable first and pass the variable.
std::minmax
Returns both min and max in one call:
#include <algorithm>
int a = 10, b = 20;
auto [minimum, maximum] = std::minmax(a, b); // C++17 structured binding
// minimum = 10, maximum = 20
// With initializer list
auto [min_val, max_val] = std::minmax({5, 2, 8, 1, 9});
// min_val = 1, max_val = 9
std::minmax returns std::pair<const T&, const T&>, so the same reference caveat from the previous section applies here too — if you pass temporaries, bind the structured binding by value semantics you actually need, and don’t hold onto the pair across statements that might invalidate the underlying temporaries. The main reason to reach for minmax instead of two separate std::min/std::max calls is not performance (both calls together are still O(1) and the constant factor barely matters for two scalars) — it’s that a single call makes the “I want both bounds from the same two values” intent explicit in the code, and it guarantees both results come from consistently evaluated arguments rather than two independent evaluations that could, in theory, see different side effects if the inputs are impure expressions.
min_element and max_element
Return iterators to the first occurrence of min/max:
#include <algorithm>
#include <vector>
std::vector<int> numbers = {3, 1, 4, 1, 5};
auto min_it = std::min_element(numbers.begin(), numbers.end());
auto max_it = std::max_element(numbers.begin(), numbers.end());
std::cout << "Min: " << *min_it << " at index "
<< std::distance(numbers.begin(), min_it) << "\n"; // Min: 1 at index 1
std::cout << "Max: " << *max_it << " at index "
<< std::distance(numbers.begin(), max_it) << "\n"; // Max: 5 at index 4
Finding both in one pass
auto [min_it, max_it] = std::minmax_element(numbers.begin(), numbers.end());
// More efficient than two separate scans
The “more efficient” claim above is worth backing up, because it is easy to assume minmax_element is a marketing name rather than an actual algorithmic improvement. Calling min_element then max_element separately does roughly 2n comparisons — each function walks the whole range once and does one comparison per element. minmax_element processes elements in pairs: it compares the two elements of each pair against each other first, then compares only the smaller one against the running minimum and only the larger one against the running maximum. That works out to about 3n/2 comparisons total instead of 2n, a real (if modest) constant-factor win of roughly 25%. For a vector<int> with a handful of elements, this difference is noise — the loop overhead and memory access pattern dominate. It starts to matter when the comparator itself is expensive (comparing strings, or a custom operator< that does real work) or when the range has millions of elements and you’re calling this in a loop, e.g. recomputing bounds every frame in a simulation or every batch in a data pipeline. If you only ever need one of the two bounds, don’t reach for minmax_element “just in case” — computing the unused half costs real comparisons for no benefit.
std::clamp (C++17)
Bounds a value to [lo, hi]:
#include <algorithm>
int value = 150;
int clamped = std::clamp(value, 0, 100); // 100
// Equivalent to:
// std::min(std::max(value, 0), 100);
// Use cases
int health = std::clamp(damage, 0, maxHealth);
float volume = std::clamp(userInput, 0.0f, 1.0f);
Requires: lo <= hi, otherwise undefined behavior.
That precondition deserves more attention than the one-line note usually gets. std::clamp is specified in terms of std::min and std::max composed together, and its contract explicitly requires lo <= hi; the standard does not say what happens if you violate it, which in C++ terms means undefined behavior. Crucially, this is not a thrown exception or an assertion failure you’ll notice — most implementations will still return some value (often just lo or hi depending on internal comparison order), so the function silently returns a value that satisfies neither bound in any meaningful sense, and your program keeps running with quietly wrong data. This is a much worse failure mode than a crash, because a crash tells you immediately that something is wrong; a swapped lo/hi pair just produces numbers that look plausible until a downstream calculation goes strange. If lo and hi come from configuration, user input, or are computed rather than hardcoded, validate lo <= hi explicitly before calling clamp, or sort the pair first with std::minmax(lo, hi).
I ran into exactly this on a UI slider that clamped a normalized value against a min/max pulled from a config struct. Someone had reordered two fields in that struct during a refactor without updating the call site, so clamp(value, cfg.max, cfg.min) was quietly passing hi < lo. Nothing crashed. The slider just stopped responding correctly near one end of its range, and because the output values still “looked like” plausible floats in [0, 1], it took a good hour of print-debugging before I thought to check the argument order instead of the clamping logic itself. Since then I’ve made it a habit to name the parameters at the call site with a comment (clamp(value, /*lo=*/cfg.min, /*hi=*/cfg.max)) whenever the bounds don’t come from adjacent literals, purely so a reordering during a later refactor is visually obvious in the diff.
Statistics, Pixel Normalization, Game Clamping, Audio Clipping
Statistics calculation
#include <algorithm>
#include <vector>
#include <numeric>
struct Stats {
double min, max, mean, range;
};
Stats calculateStats(const std::vector<double>& data) {
if (data.empty()) {
return {0, 0, 0, 0};
}
auto [min_it, max_it] = std::minmax_element(data.begin(), data.end());
double min_val = *min_it;
double max_val = *max_it;
double sum = std::accumulate(data.begin(), data.end(), 0.0);
double mean = sum / data.size();
return {min_val, max_val, mean, max_val - min_val};
}
// Usage
std::vector<double> temps = {22.5, 18.3, 25.1, 20.0, 23.7};
auto stats = calculateStats(temps);
std::cout << "Range: " << stats.min << " - " << stats.max << "\n";
Image processing: normalize pixel values
#include <algorithm>
#include <vector>
void normalizeImage(std::vector<uint8_t>& pixels) {
if (pixels.empty()) return;
auto [min_it, max_it] = std::minmax_element(pixels.begin(), pixels.end());
uint8_t min_val = *min_it;
uint8_t max_val = *max_it;
if (min_val == max_val) return; // Avoid division by zero
for (auto& pixel : pixels) {
pixel = static_cast<uint8_t>(
255.0 * (pixel - min_val) / (max_val - min_val)
);
}
}
Game development: clamp player position
struct Player {
float x, y;
float speed = 5.0f;
void update(float dx, float dy, float worldWidth, float worldHeight) {
x += dx * speed;
y += dy * speed;
// Keep player in bounds
x = std::clamp(x, 0.0f, worldWidth);
y = std::clamp(y, 0.0f, worldHeight);
}
};
Audio processing: clip samples
#include <algorithm>
#include <vector>
void clipAudio(std::vector<float>& samples, float threshold = 1.0f) {
for (auto& sample : samples) {
sample = std::clamp(sample, -threshold, threshold);
}
}
// Usage
std::vector<float> audio = {0.5f, 1.5f, -2.0f, 0.8f};
clipAudio(audio);
// Result: {0.5f, 1.0f, -1.0f, 0.8f}
Benchmark: Separate Passes vs minmax_element
The comparison counts follow directly from how each algorithm works (1M-element range):
| Operation | Passes over the data | Comparisons |
|---|---|---|
std::min(a, b) | — | 1 |
std::min_element | 1 | ~1M |
std::max_element | 1 | ~1M |
std::minmax_element | 1 | ~1.5M |
| Two separate scans | 2 | ~2M |
std::clamp | — | up to 2 |
Key insight: minmax_element saves about a quarter of the comparisons and one full pass over memory. How much faster that makes it in wall-clock time depends on the element type and compiler: for plain ints the optimizer can vectorize min_element/max_element loops well, and the pairwise logic in minmax_element is harder to vectorize, so on some compilers two separate scans are not slower at all. The saving is reliable when comparisons are expensive (strings, custom comparators) or when the range is too large for cache, so the second pass has to go back to main memory. Measure with your actual types before treating it as an optimization.
Dangling References, Empty Ranges, and Bad clamp Bounds
Beyond the reference and precondition traps already covered, there is a subtler failure mode worth calling out separately: a custom comparator that isn’t a strict weak ordering. All of these algorithms assume the comparator you hand them behaves like < — irreflexive (comp(a, a) is false), asymmetric, and transitive. If your comparator doesn’t actually satisfy that contract (a common mistake is writing <= instead of <, or comparing floating-point values with NaN in the mix), the algorithm does not throw or crash. It simply produces a result that looks like a plausible answer but is wrong for some inputs — the wrong element gets picked as the “minimum,” or minmax_element returns an inconsistent pair where the “min” iterator’s value is actually larger than the “max” iterator’s value for some edge case you didn’t test. Because the failure is silent and data-dependent, it tends to surface much later than the bug that caused it, usually as “sorting is wrong sometimes” rather than an obvious crash near the comparator itself. When you write a custom comparator for min/max/min_element/max_element, sanity-check it against the strict-weak-ordering rules the same way you would for a comparator you’d pass to std::sort — the underlying requirement is identical.
Dangling reference from min/max
// ❌ Dangling reference
const int& result = std::min(10, 20); // Temporaries destroyed
std::cout << result; // Undefined behavior
// ✅ Copy the value
int result = std::min(10, 20);
For int literals like the example above the bug is usually harmless in practice because small integers often live in registers rather than genuine temporaries on the stack, so this exact snippet may “work” by accident. The real danger shows up with larger or heap-backed types. I once had a const auto& binding to std::min(buildCandidate(), current) where buildCandidate() returned a std::string by value — in a debug build it worked every time because the temporary’s memory wasn’t reused immediately, and the bug only appeared after we shipped an -O2 release build, where the optimizer reused that stack slot for the very next call and the string silently became garbage a few lines later. It was a genuinely unpleasant one to track down because the symptom (corrupted string content) showed up nowhere near the actual std::min call. That experience is why I now treat “bind min/max/minmax results by value unless the arguments are named, long-lived variables” as a hard rule rather than a style preference.
Empty range
std::vector<int> empty;
// ❌ Undefined behavior
auto min_it = std::min_element(empty.begin(), empty.end());
std::cout << *min_it; // Crash!
// ✅ Check first
if (!empty.empty()) {
auto min_it = std::min_element(empty.begin(), empty.end());
std::cout << *min_it;
}
Wrong clamp bounds
int value = 50;
// ❌ Undefined behavior: lo > hi
int result = std::clamp(value, 100, 0);
// ✅ Correct: lo <= hi
int result = std::clamp(value, 0, 100);
Modifying the range during min_element
std::vector<int> data = {3, 1, 4, 1, 5};
// ❌ Iterator invalidation
auto min_it = std::min_element(data.begin(), data.end());
data.push_back(0); // Invalidates min_it!
std::cout << *min_it; // Undefined behavior
// ✅ Use value or recompute
int min_val = *std::min_element(data.begin(), data.end());
data.push_back(0);
std::cout << min_val; // Safe
Parallel execution (C++17)
#include <algorithm>
#include <execution>
#include <vector>
std::vector<int> data(10'000'000);
// ... fill data ...
// Sequential
auto min_it = std::min_element(data.begin(), data.end());
// Parallel
auto min_it_par = std::min_element(std::execution::par,
data.begin(), data.end());
What to expect: for a large range of cheap elements, the parallel version is limited by memory bandwidth rather than core count, so it rarely scales anywhere near linearly with cores; for small ranges the cost of dispatching work to threads can make it slower than the sequential call. With GCC’s libstdc++, std::execution::par also needs Intel TBB installed and linked (-ltbb), otherwise it silently runs sequentially or fails to link. Benchmark on your data size before switching.
Which MinMax Function to Reach For
| Algorithm | Mutates | Returns | Time |
|---|---|---|---|
min(a,b) | No | const reference | O(1) |
max(a,b) | No | const reference | O(1) |
minmax(a,b) | No | pair of references | O(1) |
min_element | No | iterator | O(n) |
max_element | No | iterator | O(n) |
minmax_element | No | pair of iterators | O(n) |
clamp(v,lo,hi) | No | clamped value | O(1) |
The table above is a good quick reference, but the practical decision usually comes down to three questions: do you need one value or the position of that value in a range, do you need one bound or both, and are your arguments cheap, pure expressions or something with side effects and lifetime concerns? Reach for min/max when you have two or a handful of values and just need the winner. Reach for min_element/max_element when you need the position, not just the value — for example to erase the largest element from a container. Reach for minmax/minmax_element whenever you’d otherwise call both single-bound versions back to back, since the combined call is never slower and is sometimes meaningfully faster. And reach for clamp any time you’re about to write std::min(std::max(v, lo), hi) by hand — it says the same thing more clearly and is exactly as fast, but only after you’ve made sure lo <= hi actually holds at the call site.
The common thread across every pitfall in this article — dangling references, double-evaluated side effects, a swapped clamp bound, a comparator that isn’t a strict weak ordering — is that none of them crash. They all produce output that looks reasonable and is subtly wrong, which is exactly why this family of algorithms deserves more care than its simple signatures suggest.