C++ min·max·minmax_element와 std::clamp: 값 비교와 범위 제한
이 글의 핵심
std::min이 참조를 반환한다는 사실을 모르고 임시 객체에 쓰면 댕글링 참조가 생기고, 빈 범위에 min_element를 쓰면 end 반복자를 역참조하게 됩니다. clamp의 인자 순서 실수, 여러 값을 initializer_list로 비교하는 방법, minmax_element로 순회를 한 번으로 줄이는 방법까지 함께 다룹니다.
min·max 계열 알고리즘
<algorithm>에는 최솟값과 최댓값을 구하는 함수가 두 갈래로 있습니다. 하나는 몇 개의 값을 직접 비교하는 std::min, std::max, std::minmax이고, 다른 하나는 반복자 범위를 훑는 std::min_element, std::max_element, std::minmax_element입니다. 여기에 C++17의 std::clamp가 값을 구간 안으로 제한하는 역할을 맡습니다.
#include <algorithm>
int a = 3, b = 5;
int minVal = std::min(a, b); // 3
int maxVal = std::max(a, b); // 5
auto [minV, maxV] = std::minmax(a, b); // 3, 5
if문으로 같은 일을 할 수도 있지만, 함수 이름이 의도를 그대로 드러내고 변수를 미리 선언해 두는 코드가 사라집니다.
// if문으로 쓰면
int minVal;
if (a < b) {
minVal = a;
} else {
minVal = b;
}
// std::min
int minVal2 = std::min(a, b);
| 알고리즘 | 입력 | 반환 | 시간 복잡도 |
|---|---|---|---|
min(a, b) | 2개 값 | 최솟값의 const 참조 | O(1) |
max(a, b) | 2개 값 | 최댓값의 const 참조 | O(1) |
minmax(a, b) | 2개 값 | pair<const T&, const T&> | O(1) |
min({...}), max({...}), minmax({...}) | initializer_list | 값 (minmax는 pair<T, T>) | O(n) |
min_element(first, last) | 범위 | 최소 원소 반복자 | O(n) |
max_element(first, last) | 범위 | 최대 원소 반복자 | O(n) |
minmax_element(first, last) | 범위 | 반복자 pair | O(n) |
clamp(v, lo, hi) | 값, 경계 | 제한된 값의 const 참조 | O(1) |
// 값 비교
int minVal = std::min(3, 5);
int maxVal = std::max(3, 5);
auto [minV, maxV] = std::minmax({3, 5}); // 리터럴에는 initializer_list 버전(값 반환)
// 범위 검색
std::vector<int> v = {3, 1, 4, 1, 5};
auto minIt = std::min_element(v.begin(), v.end());
auto maxIt = std::max_element(v.begin(), v.end());
auto [minIt2, maxIt2] = std::minmax_element(v.begin(), v.end());
// 범위 제한
int clamped = std::clamp(150, 0, 100); // 100
표에서 눈여겨볼 차이는 반환 타입입니다. 두 값 버전은 값에 대한 참조를, 범위 버전은 반복자를 돌려줍니다. 반복자를 돌려주는 이유는 최솟값이 무엇인지뿐 아니라 어디 있는지가 필요한 경우가 많기 때문입니다. 반복자가 있으면 std::distance(v.begin(), minIt)로 인덱스를 구하거나, 그 원소를 지우거나(v.erase(minIt)), 구조체의 다른 필드를 읽을 수 있습니다. 반대로 빈 범위에서는 가리킬 원소가 없으므로 last를 돌려주는데, 이 부분이 뒤에서 다룰 가장 흔한 버그의 출처입니다.
std::min, std::max, std::minmax
std::min(a, b)와 std::max(a, b)는 두 인자를 비교해 작은 값과 큰 값을 참조로 돌려줍니다. 임시 객체와 조합하면 댕글링 참조가 생길 수 있는데, 아래 “자주 발생하는 문제”에서 자세히 다룹니다. std::minmax(a, b)는 비교 한 번으로 최솟값과 최댓값을 std::pair에 담아 돌려주므로 min과 max를 따로 부르는 것보다 의도가 분명합니다. 값이 셋 이상이면 std::min({a, b, c})처럼 initializer_list 오버로드를 씁니다.
auto [lo, hi] = std::minmax({10, 20}); // lo=10, hi=20 (값으로 반환)
int m = std::min({3, 1, 4, 1, 5}); // 1
std::minmax(a, b)의 반환 타입은 std::pair<const T&, const T&>, 즉 참조 두 개를 담은 pair입니다. 그래서 auto [lo, hi] = std::minmax(10, 20);처럼 리터럴이나 임시 값을 넘기면, pair 자체는 복사되어도 그 안의 참조가 가리키는 10, 20 임시 객체는 문장이 끝나는 순간 사라져 lo, hi가 댕글링 참조가 됩니다. 컴파일은 문제없이 되고 디버그 빌드에서는 우연히 맞는 값이 나오기도 해서 알아채기 어렵습니다. 위 예제처럼 initializer_list 버전(std::minmax({10, 20}))은 std::pair<T, T>를 값으로 반환하므로 안전합니다. 변수 두 개를 비교할 때는 std::minmax(a, b)를 써도 a, b가 살아 있는 동안 문제없습니다.
같은 값이 들어왔을 때의 규칙도 정해져 있습니다. std::min(a, b)는 둘이 같으면 첫 번째 인자를, std::max(a, b)도 같으면 첫 번째 인자를 돌려줍니다. std::minmax(a, b)는 같을 때 {a, b} 순서를 유지합니다. 정수에서는 차이가 없지만, 비교 기준이 일부 필드뿐인 구조체라면 어느 객체가 선택되는지가 결과를 바꿉니다.
두 인자의 타입이 정확히 같아야 한다는 점도 자주 부딪히는 제약입니다. std::min(x, 0)에서 x가 long이거나 size_t면 템플릿 인자 T를 하나로 추론할 수 없어 “no matching function for call to ‘min(long int&, int)’” 오류가 납니다. std::min<long>(x, 0)처럼 타입을 명시하거나 0L, size_t{0}처럼 리터럴 타입을 맞추면 해결됩니다. C 매크로 MIN(a, b)와 달리 std::min은 함수라서 std::min(i++, j)의 i++가 두 번 평가되는 문제는 없습니다. Windows 헤더를 포함한 코드에서 std::min이 이상한 오류를 낸다면 <windows.h>의 min/max 매크로 때문이며, NOMINMAX를 정의하거나 (std::min)(a, b)처럼 괄호로 감싸 피합니다.
min_element, max_element, minmax_element
범위 버전은 값이 아니라 반복자를 돌려주므로 최소·최대 원소의 위치까지 알 수 있습니다. 같은 값이 여러 개면 min_element와 max_element는 가장 앞쪽 원소를 가리킵니다.
min_element와 max_element는 정렬 없이 범위를 한 번 선형으로 훑으며, 원소가 N개일 때 비교는 정확히 N−1번입니다. minmax_element는 최소와 최대 반복자를 한 번의 순회로 구합니다. 원소를 두 개씩 짝지어 먼저 서로 비교한 뒤 작은 쪽만 현재 최솟값과, 큰 쪽만 현재 최댓값과 비교하므로 비교 횟수가 최대 약 1.5N번이고, 두 함수를 따로 부를 때의 약 2N번보다 적습니다.
std::vector<int> v = {3, 1, 4, 1, 5};
auto [minIt, maxIt] = std::minmax_element(v.begin(), v.end());
// *minIt == 1 (첫 번째 1), *maxIt == 5
주의할 비대칭이 하나 있습니다. minmax_element의 최솟값은 가장 앞쪽 원소를 가리키지만, 최댓값은 가장 뒤쪽 원소를 가리킵니다. {5, 1, 5}에 max_element를 쓰면 인덱스 0을, minmax_element(...).second는 인덱스 2를 돌려줍니다. 값만 쓸 때는 상관없지만, 반환된 위치로 원소를 지우거나 인덱스를 기록하는 코드에서 두 함수를 바꿔 끼우면 결과가 달라집니다.
빈 범위에서 min_element를 호출하는 것 자체는 문제가 없고 last(보통 end())를 돌려줍니다. 정의되지 않은 동작은 그 반환값을 역참조할 때 생깁니다. 반환값을 end()와 비교하거나 미리 empty()를 검사한 뒤에 *it를 써야 합니다.
커스텀 비교자
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
struct Person {
std::string name;
int age;
};
int main() {
std::vector<Person> people = {
{"Alice", 25},
{"Bob", 30},
{"Charlie", 20}
};
auto youngest = std::min_element(people.begin(), people.end(),
[](const Person& a, const Person& b) {
return a.age < b.age;
});
std::cout << "최연소: " << youngest->name << " (" << youngest->age << ")\n";
}
비교자는 “a가 b보다 앞서야 하면 true”를 돌려주는 엄격한 약순서(strict weak ordering)여야 합니다. a.age <= b.age처럼 등호를 넣으면 같은 나이에서 결과가 달라지고, 정렬 알고리즘에 같은 비교자를 쓰면 정의되지 않은 동작이 됩니다. max_element에도 “작다”를 뜻하는 같은 비교자를 넘겨야 하며, 최댓값을 찾겠다고 >를 넘기면 오히려 최솟값이 나옵니다. C++20에서는 std::ranges::min_element(people, {}, &Person::age)처럼 멤버 포인터를 projection으로 넘겨 람다 없이 쓸 수 있습니다.
std::clamp (C++17)
std::clamp(v, lo, hi)는 v < lo면 lo를, hi < v면 hi를, 그 밖에는 v를 돌려주며, 결과는 std::min(std::max(v, lo), hi)와 같습니다. GUI 슬라이더 값, 속도 상한, 색상·알파 채널처럼 값을 구간으로 묶을 때 코드가 짧아집니다.
double speed = 120.0;
double capped = std::clamp(speed, 0.0, 100.0); // 100.0
hi < lo이면 정의되지 않은 동작입니다. 부동소수점에서 v가 NaN이면 두 비교가 모두 false가 되어 NaN이 그대로 반환되고, 경계에 NaN을 넘기는 것은 위 전제 조건을 확인할 수 없으므로 피해야 합니다.
clamp도 min/max처럼 참조를 반환하고 세 인자의 타입이 같아야 합니다. double speed에 std::clamp(speed, 0, 100)처럼 정수 리터럴을 섞으면 타입 추론이 실패하므로 예제처럼 0.0, 100.0으로 맞춥니다. 경계가 런타임에 계산되는 값이라면 lo <= hi가 항상 성립하는지 따져 봐야 합니다. 예를 들어 std::clamp(x, 0, width - 1)은 창이 최소화되어 width가 0이 되는 순간 hi가 −1이 되어 정의되지 않은 동작이 됩니다. 디버그 모드의 MSVC STL이나 libstdc++의 _GLIBCXX_ASSERTIONS는 이 조건을 검사해 assert로 멈춰 주지만, 릴리스 빌드에서는 조용히 이상한 값이 나옵니다.
마우스 좌표를 화면 안으로
int x = std::clamp(raw_x, 0, screen_width - 1);
화면 폭이 800이면 유효한 x 좌표는 0~799이므로 상한을 screen_width - 1로 둡니다. 앞에서 말한 것처럼 screen_width가 0일 수 있는 환경(최소화된 창, 초기화 전)에서는 이 호출 전에 폭을 검사해야 합니다.
자주 발생하는 문제
그림: 실제 실행 결과 — 원소 1000개에서 min_element·max_element는 999번, minmax_element는 1498번 비교하고, {5, 1, 5}에서 max_element는 인덱스 0, minmax_element의 최댓값은 2를 가리킨다. minmax(10, 20)과 min(3, 5)의 결과를 받으면 clang이 경고하고 AddressSanitizer가 stack-use-after-scope를 잡는다. long과 0, double과 정수 경계, 타입이 섞인 initializer_list는 컴파일되지 않는다. (환경: macOS 27.0.1 · Apple clang 21.0.0 · AddressSanitizer)
임시 객체에 대한 참조
int a = 3, b = 5;
// min/max는 const 참조 반환
const int& minRef = std::min(a, b); // a, b가 살아 있으므로 안전
// const int& bad = std::min(3, 5); // 댕글링
int minVal = std::min(3, 5); // 값으로 받으면 안전
const int&에 임시 객체를 바로 묶으면 수명이 연장되지만, 여기서는 임시 객체가 함수 인자로 먼저 전달되고 함수가 돌려준 참조를 묶는 것이라 수명 연장 규칙이 적용되지 않습니다. std::min(3, 5)가 반환한 참조는 문장이 끝나며 사라지는 3을 가리킵니다. auto m = std::min(...)이나 int m = ...처럼 값으로 받으면 복사가 일어나 안전합니다. 문자열처럼 복사 비용이 큰 타입도 std::string s = std::min(a, b);로 받는 것이 기본이고, 참조로 받아야 한다면 두 인자가 모두 호출 이후에도 살아 있는 변수인지 확인하세요. GCC 13의 -Wdangling-reference와 clang의 -Wdangling이 리터럴을 직접 넘긴 이런 코드를 경고로 잡아 줍니다.
빈 범위
std::vector<int> v;
// 빈 범위: 호출은 end()를 반환하고, 역참조하면 정의되지 않은 동작
// int m = *std::min_element(v.begin(), v.end());
if (!v.empty()) {
auto minIt = std::min_element(v.begin(), v.end());
}
이 버그는 테스트 데이터에는 항상 원소가 있다가, 필터링 결과가 비는 드문 입력에서 터지는 형태로 나타나곤 합니다. 역참조한 end()는 할당된 버퍼 뒤쪽의 쓰레기 값을 읽거나, 버퍼가 아예 없으면 널 포인터 접근으로 크래시하는 등 상황마다 증상이 달라 재현하기 어렵습니다. AddressSanitizer나 MSVC 디버그 반복자, libstdc++의 _GLIBCXX_ASSERTIONS를 테스트 빌드에서 켜 두면 이런 접근을 더 일찍 잡을 수 있습니다.
여러 값 비교
int minVal = std::min(std::min(a, b), c); // 동작하지만 값이 늘수록 읽기 어렵다
int minVal2 = std::min({a, b, c});
중첩 호출이 틀린 것은 아니지만 값이 늘어날수록 괄호가 깊어집니다. initializer_list 버전은 참조가 아니라 값을 반환하므로 댕글링 걱정도 없습니다. 대신 리스트를 만들 때 원소가 복사되므로 큰 객체 여러 개를 비교할 때는 비용이 생기고, 원소 타입이 모두 같아야 해서 std::min({a, b, 0L})처럼 타입이 섞이면 컴파일 오류가 납니다.
clamp 경계 순서
int value = 50;
// int clamped = std::clamp(value, 100, 0); // lo > hi: 정의되지 않은 동작
int clamped = std::clamp(value, 0, 100);
성능
두 값 버전 std::min/std::max는 대부분 인라인되며, 정수·실수에 대해서는 최적화 빌드에서 조건부 이동(cmov)이나 minsd/pminsd 같은 명령으로 바뀌어 분기가 사라지는 경우가 많습니다. 복잡한 비교자를 넘긴 min_element는 분기가 남을 수 있으니, 핫 루프라면 추측하지 말고 프로파일러와 생성된 어셈블리로 확인하세요.
최솟값 하나만 필요할 때 정렬한 뒤 front()를 꺼내는 코드도 종종 보이는데, 정렬은 O(n log n)이고 min_element는 O(n)입니다. 전체 순서가 필요할 때만 정렬하고, 상위 k개가 필요하면 std::nth_element나 std::partial_sort를 검토합니다. 최솟값과 최댓값이 모두 필요하면 두 번 훑지 말고 minmax_element 한 번으로 묶습니다.
실무 패턴
통계 계산
#include <algorithm>
#include <iostream>
#include <numeric>
#include <vector>
struct Statistics {
int min;
int max;
double average;
};
Statistics calculateStats(const std::vector<int>& data) {
if (data.empty()) {
return {0, 0, 0.0};
}
auto [minIt, maxIt] = std::minmax_element(data.begin(), data.end());
long long sum = std::accumulate(data.begin(), data.end(), 0LL);
return {
*minIt,
*maxIt,
static_cast<double>(sum) / data.size()
};
}
// 사용
std::vector<int> scores = {85, 92, 78, 95, 88};
auto stats = calculateStats(scores);
std::cout << "최소: " << stats.min << '\n'; // 78
std::cout << "최대: " << stats.max << '\n'; // 95
std::cout << "평균: " << stats.average << '\n'; // 87.6
std::accumulate의 누적 타입은 세 번째 인자의 타입을 따릅니다. 0을 넘기면 합계도 int로 계산되어 원소가 많거나 값이 크면 오버플로가 나므로 위 코드는 0LL을 넘겼습니다. 빈 입력에서 {0, 0, 0.0}을 돌려주는 설계는 편하지만, “데이터가 없음”과 “모든 값이 0”을 호출하는 쪽에서 구분할 수 없습니다. 이 구분이 중요하다면 std::optional<Statistics>를 반환하는 편이 의도가 분명합니다.
최솟값과 최댓값의 차이
auto [minIt, maxIt] = std::minmax_element(data.begin(), data.end());
if (minIt != data.end()) {
long long span = static_cast<long long>(*maxIt) - *minIt;
}
empty() 대신 반환된 반복자를 end()와 비교했습니다. 빈 범위면 두 반복자가 모두 end()이므로 한 번의 비교로 충분하고, 알고리즘 결과를 바로 검증하는 습관은 find_if 같은 조건 검색에서도 그대로 통합니다. int 범위 끝에 가까운 값이 섞여 있으면 *maxIt - *minIt가 오버플로할 수 있어 차이는 더 넓은 타입으로 계산했습니다.
정규화
#include <algorithm>
#include <vector>
std::vector<double> normalize(const std::vector<double>& data) {
if (data.empty()) {
return {};
}
auto [minIt, maxIt] = std::minmax_element(data.begin(), data.end());
double minVal = *minIt;
double maxVal = *maxIt;
double range = maxVal - minVal;
if (range == 0.0) {
return std::vector<double>(data.size(), 0.5);
}
std::vector<double> result;
result.reserve(data.size());
for (double value : data) {
result.push_back((value - minVal) / range);
}
return result;
}
// 사용
std::vector<double> data = {10.0, 20.0, 30.0, 40.0, 50.0};
auto normalized = normalize(data);
// 결과: {0.0, 0.25, 0.5, 0.75, 1.0}
모든 값이 같아 range가 0이면 0으로 나누게 되므로 따로 처리했습니다. 부동소수점에서 0으로 나누면 예외 대신 inf나 NaN이 조용히 퍼지기 때문에 이런 검사가 없으면 결과 전체가 오염됩니다. 입력에 NaN이 섞여 있을 때도 주의해야 합니다. NaN과의 비교는 항상 false라서 <가 엄격한 약순서 조건을 만족하지 못하고, minmax_element의 결과가 NaN의 위치에 따라 달라지며 정규화 값도 NaN이 됩니다. 센서 데이터처럼 결측이 NaN으로 표현되는 입력이라면 먼저 걸러 내고 계산합니다.
참고 자료로는 cppreference의 std::min 문서와 같은 페이지에서 연결되는 minmax_element, clamp 문서가 가장 정확합니다.