[Lv1] 소수 찾기 - 복잡도

입력 n 이하 소수의 개수를 구하는 문제를 단계별로 최적화한 풀이를 정리했습니다. 단순 브루트 포스 방식부터 제곱근까지만 검사하는 방법, 그리고 가장 빠른 에라토스테네스의 체까지 C++ 코드와 채점 결과, 성능 비교를 한눈에 볼 수 있습니다.

[Lv1] 소수 찾기  - 복잡도
Photo by Markus Winkler / Unsplash

해당 문제와 풀이를 보시기 전에 시간 복잡도에 대해 더 자세히 알고 싶으신 분은 아래 글을 참고하시면 됩니다.

시간 복잡도 & 공간 복잡도 | Big-O 표기법 설명
시간복잡도는 “입력 크기 n이 커질 때 실행 시간이 얼마나 늘어나는가”, 공간복잡도는 “추가 메모리를 얼마나 쓰는가”를 점근적으로 평가한다. 상수/하드웨어/캐시의 영향은 실무에서 크지만, 알고리즘 비교의 1차 기준은 여전히 Big-O다.

문제


문제 설명

1부터 입력받은 숫자 n 사이에 있는 소수의 개수를 반환하는 함수, solution을 만들어 보세요.

소수는 1과 자기 자신으로만 나누어지는 수를 의미합니다.
(1은 소수가 아닙니다.)

제한 조건

  • n은 2이상 1000000이하의 자연수입니다.

입출력 예

nresult
104
53

입출력 예 설명

입출력 예 #1
1부터 10 사이의 소수는 [2,3,5,7] 4개가 존재하므로 4를 반환

입출력 예 #2
1부터 5 사이의 소수는 [2,3,5] 3개가 존재하므로 3를 반환

풀이1: 브루트 포스 약수 검사 - O(n^2)


#include <iostream>

bool isPrimeNum(int n)
{
    if (n < 2)
    {
        return false;
    }
    else if (n == 2)
    {
        return true;
    }
    else if (n % 2 == 0)
    {
        return false;
    }

    for (auto d = 3; d < n; d += 2)
    {
        if (n % d == 0)
        {
            return false;
        }
    }

    return true;
}

int solution(int n)
{
    int answer = 0;

    for (int i = 2; i <= n; ++i)
    {
        if (isPrimeNum(i))
        {
            std::cout << i << std::endl;
            ++answer;
        }
    }

    return answer;
}

가장 쉽고 직관적으로 구현할 수 있는 코드인데, 위는 당연히 문제 취지 상 시간 초과로 테스트 실패한다.

그렇다면, 여기서 시간 복잡도를 줄여 보아야 하는데 두 가지 방법이 있다.

풀이 2: √n까지만 검사 - O(n^1.5)


위 코드에서 조금 더 최적화 할 수 있는 방법은, 약수 검사를 √n까지만 시도하는 것이다. 한 정수 x가 합성수라면 x=ab인 약수 쌍 (a, b)가 존재하는데, 이 때 두 수 중 하나는 반드시 √x 이하가 된다. 따라서 소수 판정 시 2와 홀수 약수만, (d * d <= n) 범위 까지만 검사하면 충분하다.

#include <iostream>
#include <string>

bool isPrimeNum(int n)
{
    if (n < 2)
    {
        return false;
    }

    if (n == 2)
    {
        return true;
    }

    if (n % 2 == 0)
    {
        return false;
    }

    for (int d = 3; d * d <= n; d += 2)
    {
        if (n % d == 0)
        {
            return false;
        }
    }

    return true;
}

int solution(int n)
{
    int answer = 0;

    for (int i = 2; i <= n; ++i)
    {
        if (isPrimeNum(i))
        {
            ++answer;
        }
    }

    return answer;
}

심화: 에라토스테네스의 체 풀이


다른 사람의 풀이를 보던 중, 재미있는 풀이법을 확인했는데 알고보니 이 방식이 가장 정석의 풀이법이었다. 이 방법이 임의의 자연수 n에 대해 그 이하의 소수를 모두 찾는, 가장 간단하고 빠른방법이라고 한다. 결론부터 말하자면, 해당 방식의 시간 복잡도는 다음과 같다.

$$ O\!\Bigl(n \cdot \bigl(\tfrac{1}{2} + \tfrac{1}{3} + \tfrac{1}{5} + \cdots \bigr)\Bigr) = O(n \log \log n) $$

알고리즘 아이디어

  1. 불리언 테이블 준비
    크기 n+1짜리 배열을 만들고, 처음엔 전부 “소수(true)”라고 가정한다.
    단, 0과 1은 소수가 아니므로 false로 처리한다.
  2. 가장 작은 소수부터 배수 제거
    • 2부터 시작해서, 현재 수가 소수라면
    • 그 수의 배수들을 모두 지운다(false).
    • 배수 제거는 p^2부터 시작하면 된다. (그 이전 배수는 이미 더 작은 소수에서 지워졌기 때문)
  3. √n까지만 반복
    • 소수 후보 p는 √n ​까지만 검사한다.
    • 그 이후의 합성수는 이미 작은 소수들에 의해 모두 제거된다.
  4. 남은 수는 전부 소수
    테이블에 true로 남은 수들의 개수가 곧 소수의 개수다.

구현 예시

#include <vector>

int solution(int n)
{
    if (n < 2)
    {
        return 0;
    }

    std::vector<bool> is_prime(n + 1, true);

    is_prime[0] = false;
    is_prime[1] = false;

    for (auto p = 2; p * p <= n; ++p)
    {
        if (is_prime[p])
        {
            for (int i = p * p; i <= n; i += p)
            {
                is_prime[i] = false;
            }
        }
    }

    int answer = 0;

    for (const auto p : is_prime)
    {
        if (p)
        {
            ++answer;
        }
    }

    return answer;
}

체점 결과 비교

브루트 포스
시간복잡도 O(n^2) 공간복잡도 O(1) 정확성 75 효율성 0 합계 75/100

단순 전수 나눗셈. 작은 케이스는 통과하지만 큰 입력에서 시간 초과.

정확성 테스트 상세
테스트결과시간메모리
1통과0.01ms4.17MB
2통과0.01ms4.14MB
3통과0.03ms3.67MB
4통과0.09ms4.15MB
5통과0.05ms4.21MB
6통과6.79ms4.15MB
7통과0.46ms4.22MB
8통과2.49ms4.21MB
9통과6.11ms4.16MB
10통과3619.72ms4.02MB
11실패 (시간 초과)
12통과4484.43ms4.22MB
정확성 총평: 대형 케이스에서 급격한 지연
효율성 테스트 상세
테스트결과
1실패 (시간 초과)
2실패 (시간 초과)
3실패 (시간 초과)
4실패 (시간 초과)
효율성 총평: 실사용 불가
√n까지만 약수 검사
시간복잡도 O(n^{1.5}) 공간복잡도 O(1) 정확성 75 효율성 25 합계 100/100

합성수의 약수 쌍 성질을 이용해 검사 범위를 √까지 축소. 실전에서 체감 향상 큼.

정확성 테스트 상세
테스트결과시간메모리
1통과0.01ms3.70MB
2통과0.01ms3.71MB
3통과0.01ms3.64MB
4통과0.02ms4.08MB
5통과0.02ms4.21MB
6통과0.21ms3.63MB
7통과0.04ms3.66MB
8통과0.10ms4.14MB
9통과0.24ms4.19MB
10통과14.04ms4.21MB
11통과67.31ms4.21MB
12통과16.33ms4.18MB
정확성 총평: 전체 구간 안정 통과
효율성 테스트 상세
테스트결과시간메모리
1통과77.45ms3.77MB
2통과72.84ms3.89MB
3통과68.03ms3.88MB
4통과66.42ms3.89MB
효율성 총평: 대형 입력도 여유 있게 통과
에라토스테네스의 체
시간복잡도 O(n \log \log n) 공간복잡도 O(n) 정확성 75 효율성 25 합계 100/100

배수 소거 기반의 정석 풀이. 가장 빠르고, 입력이 커질수록 차이가 벌어짐.

정확성 테스트 상세
테스트결과시간메모리
1통과0.01ms4.21MB
2통과0.01ms3.63MB
3통과0.01ms4.15MB
4통과0.01ms4.02MB
5통과0.02ms4.16MB
6통과0.03ms4.21MB
7통과0.02ms4.16MB
8통과0.04ms4.20MB
9통과0.04ms3.58MB
10통과1.42ms3.65MB
11통과2.97ms4.15MB
12통과1.74ms4.23MB
정확성 총평: 전 구간 최고 속도
효율성 테스트 상세
테스트결과시간메모리
1통과3.09ms3.96MB
2통과2.91ms3.80MB
3통과3.10ms3.91MB
4통과2.96ms3.80MB
효율성 총평: 최적 (대형 입력에 특히 강함)
방법별 요약 비교
방법시간복잡도공간복잡도총평
브루트 포스O(n^2)O(1)큰 입력에서 시간 초과
√n 검사O(n^{1.5})O(1)현실적으로 충분히 빠름
에라토스테네스의 체O(n \log \log n)O(n)정석, 대형 입력 최적

※ 수식 렌더링은 KaTeX/MathJax 환경에서 자동 적용됩니다.