이분탐색의 구조

이 문제는 정수 범위 위의 단조 불리언 함수(monotonic boolean function)를 기반으로 한다.
수학적으로 이분탐색이 정확히 작동할 수 있는 구조 위에 놓여 있다.

  1. 문제 정의
  • 심사관은 여러 명 존재한다
  • 각 심사관은 time[i]분마다 1명씩 심사 가능하다
  • 사람 수는 n명이다
  • 목표: 모든 사람이 심사를 마치기 위한 최소 시간 t를 구하라
  1. 함수 정의

시간 t가 주어졌을 때, 각 심사관이 처리할 수 있는 인원을 모두 더하면
다음과 같은 함수 f(t)를 얻는다.

$$
f(t) = \sum_{i=1}^{m} \left\lfloor \frac{t}{\text{time}[i]} \right\rfloor
$$

이 함수는 단조 증가 함수이다.
즉, t가 커질수록 처리 인원은 절대 줄지 않는다.

  1. 명제 정의

우리는 다음과 같은 참/거짓 명제를 생각할 수 있다.

$$
P(t) := (f(t) \geq n)
$$

이 명제는 t가 증가할수록 False → True로 바뀐다.
즉, 단조 불리언 함수이며, 한 번만 상태가 바뀐다.

목표는 P(t)가 처음으로 True가 되는 가장 작은 t를 찾는 것이다.

  1. 이분탐색이 가능한 이유

이분탐색은 다음 조건을 만족할 때 사용할 수 있다:

  • 정수 범위 t ∈ ℕ
  • P(t)는 단조 불리언 함수 (False → True)
  • 우리는 min { t | P(t) = True } 를 찾고자 한다

즉, "논리 명제의 최소 참 구간"을 찾는 탐색이 된다.

아래는 이를 구현한 이분탐색 알고리즘이다.

def solution(n, times):
    left, right = 1, max(times) * n
    while left <= right:
        mid = (left + right) // 2
        people = sum(mid // t for t in times)
        if people >= n:
            right = mid - 1
        else:
            left = mid + 1
    return left

언리얼 엔진에 NevarokML 설치하는 것이 생각보다 오래 걸렸다. 
언리얼 엔진으로 강화학습 환경 구성하려던 게 몇시간짜리 트러블슈팅이 됐다
처음엔 UE5.5 C++ 프로젝트로 빌드했는데 C++ 컴파일러 버전이 안 맞는다는 오류가 떴다
Visual Studio 설치부터 다시 하고 맞는 버전 찾는 데 시간 쓰고 드디어 빌드 환경 맞췄다
NevarokML 플러그인을 깃헙에서 클론해서 Plugins 폴더에 넣었는데 또 NNE 플러그인이 없다고 했다
언리얼 에디터에서 플러그인 설정 가서 NNE 활성화했지만 여전히 같은 오류
혹시나 해서 UE5.4로 버전 바꾸고 컴파일러 또 바꾸고 같은 작업 반복
그래도 안 됐다

이쯤 되면 누구나 고민한다 다른 플러그인 써야 하나 내가 뭐 놓쳤나
그때 NevarokML 공식 문서를 다시 봤다
거기엔 분명히 UE5.2까지만 지원한다고 적혀 있었다
UE5.2로 다시 설치하고 세팅했더니 아무 문제 없이 플러그인이 돌아갔다


이 과정에서 LLM의 약점을 명확히 느꼈다
코드 설명 문법 오류 알고리즘 이런 건 정말 빠르고 정확하다
근데 환경 세팅 버전 문제 플러그인 호환성 같은 건 기대 이하였다
왜냐면 LLM은 내가 주는 정보만으로 답을 만들어야 하는데 문제는 내가 뭘 모르는지조차 모를 때다
환경 설정에서 오류가 날 때는 내가 정확히 어떤 설정을 했고 어떤 구조에서 오류가 발생하는지 전체 상황을 공유하지 않으면 LLM도 그냥 이럴 수도 있고 저럴 수도 있다 수준에서 끝난다

게다가 실시간으로 상태를 점검할 수 없다는 한계도 크다
LLM은 내가 지금 어떤 파일을 열었고 어떤 버전을 설치했고 어떤 경로에 어떤 플러그인이 들어있는지 모른다
그걸 일일이 설명해줘야 하는데 정작 설명하는 나도 헷갈리는 경우가 많다
이게 LLM이 코드를 이해하지 못한다는 말과는 좀 다르다
LLM은 상태를 추적하지 못한다
시스템에서 벌어지는 일련의 흐름을 보는 눈이 없다


이번에 느낀 건 하나다
LLM은 결국 상황이 명확할 때만 제대로 작동한다
초기 설정이 꼬였거나 내가 뭘 놓쳤는지 감이 안 올 때는 공식 문서를 보고 하나하나 짚어보는 게 더 빠르다
LLM이 해줄 수 없는 건 상황 판단이다
지금 이 문제의 본질이 뭔지 전체 그림에서 뭐가 틀렸는지 찾는 건 여전히 사람 몫이다

그리고 이건 앞으로 어떤 프로젝트를 하든 꼭 기억해야 할 부분 같다
LLM은 강력한 도구지만 모든 걸 맡길 수는 없다
특히 환경 세팅 같은 거 직접 확인하고 체크하는 습관이 없으면 LLM이 아니라 누구도 답 못 준다

세상을 분류한다는 것

우리는 세상을 이해하기 위해 그것을 분류한다.
철학자는 존재를 분류했고 프로그래머는 객체를 분류한다.
각자의 언어는 다르지만 그들이 하는 일은 닮아 있다.

세계를 잘게 나누고 공통과 차이를 찾아내며 구조화된 틀 안에서 의미를 재구성한다.

철학에서 분류는 존재론의 시작이다.
어떤 것이 “무엇”인지 말하려면, 그것이 무엇과 같고 무엇과 다른지를 먼저 설명해야 한다.
그래서 아리스토텔레스는 “류”와 “종차”라는 개념을 통해 사물을 나눴다.

 

프로그래밍에서도 분류는 설계의 시작이다.
우리는 객체지향에서 “클래스”를 만들고, 그것을 상속하며 기능을 나누고 확장한다.
어떤 객체가 어떤 동작을 하는지 어떤 속성을 가지는지 정의하기 위해 먼저 그것이 속한 구조를 정한다.

분류는 단순히 정리의 기술이 아니다.
그것은 생각하는 방식, 세계를 틀에 담기 위한 노력이다.

 

 

철학의 분류 원리는 류와 종차이다.

아리스토텔레스는 존재를 분류하기 위해 두 개념을 도입했다.
바로 류(類, Genus)종차(種差, Differentia)다.

류는 공통적인 범주다. 여러 사물 사이의 유사성을 모아 상위 개념으로 묶은 것.
예를 들어, 인간과 고양이는 모두 ‘동물’이라는 류에 속한다.

하지만 같은 류에 속한다고 해서 모두 같은 것은 아니다.
무엇이 인간을 고양이와 다르게 만드는가?
그 고유한 차이를 아리스토텔레스는 종차라 불렀다.

그래서 인간은
“동물이라는 류”에,
“이성적이라는 종차”가 더해져 정의된다.
인간 = 동물(류) + 이성적(종차)

류는 공통을 말하고, 종차는 차이를 말한다.
아리스토텔레스는 이렇게 어떤 존재를 특정지었다.

 

객체지향 프로그래밍의 분류 방식 : 클래스와 상속

객체지향 프로그래밍도 세상을 분류한다.
우리는 비슷한 객체들을 묶어 클래스를 만들고,
그 클래스를 기반으로 객체를 생성하며,
필요할 땐 기존 클래스를 상속해 새로운 개념을 정의한다.

이때 상위 클래스는 류에 해당한다.
공통된 속성과 동작을 정의하고,
하위 클래스는 이를 물려받아 자신의 고유한 차이를 추가한다.

 

예를 들어보자.

Animal이라는 클래스가 있다면
그 안에는 모든 동물이 공통적으로 가지는 특징이 들어간다.
Human 클래스는 Animal을 상속받고,
여기에 “말한다”는 메서드를 더한다.

class Animal:        # 류
    def eat(self):
        return "먹는다"

class Human(Animal): # 종 = Animal + 고유 기능
    def speak(self):
        return "말한다"
 

상속은 공통과 차이를 분리하는 구조다.
류는 클래스가 되고 종차는 고유한 메서드가 된다.

 

코드에서 드러나는 류와 종차의 개념

객체지향에서 클래스는 본질을 담고,
상속은 그 본질을 계승하며 오버라이딩은 차이를 말한다.

이 구조는 철학자가 존재를 분류하던 방식과 닮아 있다.

아리스토텔레스는 "무엇인가를 정의하려면 그것이 속한 류를 말하고,
그 안에서의 차이를 밝혀야 한다"고 했다.

 

OOP에서도 객체는 같은 구조를 따르되 서로 다르게 행동한다.
그 차이는 오버라이딩을 통해 표현된다.

class Animal:
    def speak(self):
        return "소리를 낸다"

class Dog(Animal):
    def speak(self):  # 종차
        return "멍멍"

class Human(Animal):
    def speak(self):  # 종차
        return "말한다"

여기서 def speak()는 Dog와 Human에서 같은 메서드지만 다른 동작을 한다.
이것이 바로 철학에서 말하는 종차다.

그래서, 오버라이딩은
공통된 형상 위에 차이를 선언하는 일이다.

철학에서 존재는 "공통 + 차이"로 정의되었고,
객체지향도 마찬가지로 "상속 + 구현"으로 객체를 구성한다.

객체지향은 결국
존재를 분류하고 형식화하는 철학의 사유 방식을
하나의 코드 구조로 옮겨온 것이다.

 

 

철학과 OOP 개념
class Animal:              # 류
    def move(self):
        return "움직인다"

class Bird(Animal):        # 종 = Animal + 고유 기능
    def fly(self):         # 종차
        return "난다"
류 (Genus) 슈퍼클래스 (Superclass) 여러 하위 존재(종)를 포괄하는 공통 구조. 상속의 기반이 되는 클래스
종차 (Differentia) 서브클래스의 고유 기능/오버라이딩 같은 류에 속하지만 구분되게 만드는 고유 특성. 메서드 오버라이딩, 추가 기능 등
종 (Species) 서브클래스 (Subclass) 상위 구조(류)를 물려받고, 고유한 종차로 구체화된 존재. 하나의 구체적 객체 유형

 

 

추상화는 공통과 차이의 경계에서

추상화는 본질을 남기고 나머지를 덜어내는 일이다.
철학에서 그것은 ‘개념’을 만드는 방식이고
프로그래밍에서는 ‘클래스’를 만드는 방식이다.

 

철학자에게 추상화란
수많은 구체적 사물들 속에서 공통된 속성을 끌어올리는 일이다.
사람, 개, 고양이를 보며 "이들은 모두 동물이다"라고 말하는 순간
그는 이미 ‘류’라는 추상화를 수행한 것이다.

 

프로그래머도 마찬가지다.
여러 객체들에서 반복되는 구조를 뽑아내고
그것을 클래스라는 틀로 만든다.
이 틀은 구체가 아니지만
수많은 구체를 만들어내는 시작점이 된다.

 

그런데 추상화는 단지 공통만을 말하지 않는다.
차이를 남기는 방식으로 공통을 정의한다.
바로 그 경계에서 류와 종차가 나뉘고,
슈퍼클래스와 오버라이딩이 갈라진다.

 

객체지향에서 추상화란
공통과 차이 사이의 균형을 설계하는 일이다.
너무 많은 것을 끌어올리면 본질을 잃고
너무 적게 정의하면 확장할 수 없다.

그래서 추상화는 단순한 구조화가 아니다.
그것은 생각하는 기술이며
존재를 어떻게 바라보고 나눌 것인가에 대한 깊은 태도다.

 

 

 

파도반 수열 정리

파도반 수열은 다음 점화식을 만족하는 수열이다.
$P(n) = P(n-2) + P(n-3)$ 형태로 주로 알려져 있으며,
초기값은 $P(1) = 1, P(2) = 1, P(3) = 1, P(4) = 2, P(5) = 2$로 정의한다.
또 다른 형태인 $P(n) = P(n-1) + P(n-5)$ 점화식으로도 같은 값이 나온다.
초기값만 동일하게 맞추면 어느 쪽을 써도 같은 수열이 된다는 점이 재미있다.

아래에서는 $P(n) = P(n-2) + P(n-3)$를 기준으로,
DP(동적 계획법) 방식과 전이 행렬(행렬 거듭제곱) 방식을 함께 보여준다.

1. DP 방식

DP는 배열을 이용해 차례대로 값을 채우는 직관적 방식이다.
시간 복잡도는 $O(n)$이지만, 구현이 간단하여 보통 문제 풀이에 적합하다.

def pado_dp(n):
    """
    P(n) = P(n-2) + P(n-3) 형태의 파도반 수열을
    동적 계획법으로 구하는 예시이다.
    초기값은 P(1)=1, P(2)=1, P(3)=1, P(4)=2, P(5)=2로 둔다.
    """
    dp = [0] * max(6, n+1)
    dp[1], dp[2], dp[3], dp[4], dp[5] = 1, 1, 1, 2, 2
    for i in range(6, n+1):
        dp[i] = dp[i-2] + dp[i-3]
    return dp[n]

if __name__ == "__main__":
    for i in range(1, 11):
        print(f"P({i}) = {pado_dp(i)}")

2. 전이 행렬 방식

선형 점화식은 상태 벡터와 전이 행렬로도 표현할 수 있다.
$P(n) = P(n-2) + P(n-3)$일 때, 상태 벡터를

$V(n) = \begin{bmatrix} P(n) \ P(n-1) \ P(n-2) \end{bmatrix}$

라 두면, 전이 행렬 $M$은

$$
\begin{bmatrix}
0 & 1 & 1 \cr
1 & 0 & 0 \cr
0 & 1 & 0
\end{bmatrix}
$$

이 된다.
이는 $V(n+1) = M \cdot V(n)$ 형태로 이어지며,
결국 $V(n) = M^{n-3} \cdot V(3)$로 일반화할 수 있다.
이를 코드로 구현한 예시는 다음과 같다.

def matmul(A, B):
    rA, cA = len(A), len(A[0])
    rB, cB = len(B), len(B[0])
    result = [[0]*cB for _ in range(rA)]
    for i in range(rA):
        for j in range(cB):
            s = 0
            for k in range(cA):
                s += A[i][k] * B[k][j]
            result[i][j] = s
    return result

def matpow(M, exp):
    size = len(M)
    I = [[int(i==j) for j in range(size)] for i in range(size)]
    result = I
    while exp > 0:
        if exp % 2 == 1:
            result = matmul(result, M)
        M = matmul(M, M)
        exp //= 2
    return result

def pado_matrix(n):
    if n in (1,2,3):
        return 1
    elif n in (4,5):
        return 2
    M = [
        [0,1,1],
        [1,0,0],
        [0,1,0]
    ]
    V3 = [
        [1],
        [1],
        [1]
    ]
    Mn = matpow(M, n-3)
    Vn = matmul(Mn, V3)
    return Vn[0][0]

if __name__ == "__main__":
    for i in range(1, 11):
        print(f"P({i}) = {pado_matrix(i)}")

3. 두 방식의 비교하면..

방식 시간 복잡도 구현 난이도 특징
DP O(n) 낮음 간단명료, 공간 많이 사용
행렬 O(log n) 조금 높음 큰 n에 유리, 수학적 의미

DP는 배열 하나로 간단히 해결할 수 있으며, 문제에서 요구하는 범위가 그리 크지 않으면 편리하다.
행렬 거듭제곱은 지수 시간 단축이 가능하고, $n$이 매우 클 때 더 빠르게 계산할 수 있다.

반복문 한 줄에서 시작하여, 그 안에 담긴 수학적, 기하학적 원리들이 어떻게 서로 얽혀 있는지를 깊이 있게 서술한다. 여기서는 1부터 $n$까지의 정수 중 서로 다른 세 개의 수 $i$, $j$, $k$를 선택하는 삼중 반복문의 예제를 들어 설명한다.

for i in range(1, n-1):
    for j in range(i+1, n):
        for k in range(j+1, n+1):
            print(i, j, k)

이 코드의 구조는 단순해 보이지만, 사실 이는 $i < j < k$인 모든 경우의 수를 열거하는 과정이며, 이는 $n$개의 원소 중에서 순서를 고려하지 않고 3개를 선택하는 조합, 즉 이항 계수 $\binom{n}{3}$의 결과와 정확히 일치한다. 이항 계수는 다음과 같이 정의된다.

$$
\binom{n}{3} = \frac{n(n-1)(n-2)}{6}.
$$

여기서 단순한 산술 계산 이상의 의미가 드러난다. 반복문이 구현하는 모든 선택의 경우의 수는, 우리가 수학에서 다루는 조합의 본질을 그대로 반영한다. 반복문 속에 내재한 이러한 구조는 우리가 코드 한 줄을 볼 때, 단순한 순회 이상의 깊은 원리를 느낄 수 있게 한다.

이와 같은 조합의 원리는 파스칼 삼각형에서 더욱 명확하게 나타난다. 파스칼 삼각형은 각 항이 바로 위의 두 항의 합으로 구성되는 배열로, 각 숫자는 $\binom{n}{r}$의 값을 나타낸다. 예를 들어, 다음과 같은 배열을 생각해 보자.

           1
         1   1
       1   2   1
     1   3   3   1
  1   4    6   4   1
1   5   10  10   5   1

이 배열은 단순한 덧셈의 반복으로 만들어지며, 이는 코드에서 반복문이 수행하는 누적 합의 원리와 동일하다. 삼중 반복문에서 한 값 $i$가 정해지면, 그 다음에 등장하는 $j$와 $k$의 조합은 이중 반복문으로 $\binom{n-i}{2}$를 산출하는 과정과 같다. 이처럼 파스칼 삼각형은 단순한 수학적 도구가 아니라, 코드의 반복적 구조와 자연스럽게 연결되어 모든 선택의 경우를 한눈에 보여 주는 창과 같다.

 

 

또한, 파스칼 삼각형 속에는 “하키스틱 규칙”이라 불리는 특별한 성질이 있다. 이 규칙은 파스칼 삼각형에서 한쪽 대각선을 따라 이어진 항들을 모두 더하면, 그 누적 합이 바로 그 아래쪽에 위치한 항과 같아지는 원리를 나타낸다. 수식으로 표현하면 다음과 같다.

 

$$
\sum_{k=r}^{n} \binom{k}{r} = \binom{n+1}{r+1}.
$$

이 항등식은 우리가 반복문에서 각 단계마다 누적되는 값을 생각할 때, 그 결과가 새로운 조합 값으로 자연스럽게 연결되는 과정을 설명해준다. 코드에서 각 반복의 결과들이 서로 더해져 하나의 최종 값을 도출하는 모습은, 이 하키스틱 규칙이 보여주는 누적 합의 원리와 닮아 있다.

 

 

더 나아가, 조합은 본질적으로 대칭성을 지닌다. 예를 들어, 아래와 같은 항등식은 조합의 대칭적 성질을 여실히 드러낸다.

$$
\binom{n}{0}\binom{n}{n} + \binom{n}{1}\binom{n}{n-1} + \cdots + \binom{n}{n}\binom{n}{0}.
$$

이 식은 파스칼 삼각형의 좌우 대칭을 그대로 반영하며, 반복문을 통한 조합 계산 속 구조를 보여준다.

반복문과 조합의 관계를 단순한 산술 계산 이상의 것으로 이해하기 위해서는, 이 모든 개념들을 기하학적으로도 해석할 필요가 있다. 이중 반복문을 통해 선택된 $(i, j)$의 조합은, 2차원 평면상의 격자에서 한쪽 삼각형 모양을 형성하는 데, 그 전체 경우의 수는 $\binom{n}{2}$와 같다. 이 삼각형은 평면상의 영역과 같이, 모든 가능한 쌍을 시각적으로 보여준다.

 

 

반면, 삼중 반복문을 통해 선택된 $(i, j, k)$의 조합은 3차원 공간에서 정사면체(테트라헤드론)의 꼭짓점을 이루며, 이 때의 경우의 수는 $\binom{n}{3}$로 표현된다. 정사면체는 3차원에서 가장 단순한 다면체로서, 그 꼭짓점들이 서로 유기적으로 연결되어 있는 방식은 단순한 조합 수식이 아니라, 기하학적 구조로서의 아름다움을 드러낸다.

 

 

직접 코드를 작성하여 실행해 보고, 작은 $n$의 값으로 $(i, j, k)$ 조합이 어떻게 출력되는지 눈으로 확인하면, 그 총 경우의 수가 $\binom{n}{3}$임을 자연스럽게 깨달을 수 있다. 또한, 종이나 디지털 도구를 사용해 파스칼 삼각형을 직접 그려보고, 그 속에 담긴 누적과 대칭의 원리를 이해하는 과정은, 코드 한 줄에 담긴 수학적 선택의 깊이를 체험하게 해준다. 3차원 공간에서 $(i, j, k)$ 조합을 시각화하는 작업은, 단순한 산술 계산을 넘어 기하학적 정사면체의 아름다움을 직접 눈으로 확인하게 해 주며, 이 모든 개념들이 하나의 통일된 세계를 이루고 있음을 분명히 보여준다.

 

 

결과적으로, 반복문은 단순히 코드를 반복하는 도구가 아니라, 그 자체로 수학적 조합의 원리와 기하학적 구조를 구현하는 매개체이다. 이중 반복문은 2차원에서 삼각형을 이루며 $\binom{n}{2}$로 나타나고, 삼중 반복문은 3차원에서 정사면체를 이루며 $\binom{n}{3}$로 표현된다. 파스칼 삼각형은 이러한 모든 조합 계산의 시각적, 구조적 기반을 제공하며, 하키스틱 규칙은 누적 합의 과정을 통해 새로운 조합 값을 만들어내는 원리를 설명한다. 더불어, 조합의 대칭성을 보여주는 항등식들은 코드와 수식, 그리고 기하학적 구조가 서로 유기적으로 연결되어 있음을 확인시켜 준다.

KMP 알고리즘

  • KMP(Knuth-Morris-Pratt) 알고리즘은 문자열에서 특정한 패턴을 빠르게 찾아주는 방법이다. 글자를 하나하나 비교하는 방식(브루트 포스)과 달리, 이전에 비교한 정보를 기억해서 불필요한 비교를 하지 않는다.이렇게 할 수 있는 이유는 LPS(Longest Prefix Suffix) 배열 덕분이다. LPS 배열은 패턴 안에서 반복되는 부분을 미리 계산해두는 배열이다. 이 정보를 사용하면, 패턴이 틀렸을 때 어디서부터 다시 시작하면 될지 빠르게 알 수 있다.
  • 즉, KMP 알고리즘은 패턴을 미리 분석해서 필요한 부분만 비교하도록 도와주는 방법이다. 덕분에 긴 문자열에서도 빠르게 패턴을 찾을 수 있다.
  • 예를 들어, "ABABABCA"라는 문자열에서 "ABABAC"이라는 패턴을 찾는다고 해보자. 보통은 첫 글자부터 하나씩 비교하지만, KMP 알고리즘은 이미 맞았던 부분을 활용해서 불필요한 검사를 줄인다.
  • 시간 복잡도 : O(M+N) 

KMP vs Brute Force 비교

알고리즘 시간 복잡도 비교 방식 최적화 방식

Brute Force O(MN) 불일치 시 패턴을 처음부터 다시 비교 없음
KMP O(M + N) LPS 테이블을 활용하여 불필요한 비교 건너뜀 접두사-접미사 정보 활용

M: 텍스트 길이, N: 패턴 길이

LPS(Longest Prefix Suffix) 테이블 개념

LPS 테이블은 패턴 내에서 **접두사(prefix)**와 **접미사(suffix)**가 같은 부분 문자열의 최대 길이를 기록하는 테이블이다. 이를 활용하면 패턴이 불일치할 경우, 이미 일치했던 부분을 다시 비교하지 않고 점프할 수 있다.

  • 가장 쉽게는 aaaaa 의 가장 큰 LPS값인 LPS[4]는 4이다.
    • 접두사는 aaaa, 접미사는 aaaa 이므로
  • abab의 가장 큰 LPS값인 LPS[3]는 2이다.
    • 접두사 aa, 접미사 aa
  • ababc는 LPS는 0이다.
    • 겹치는 부분이 없다.
  • A A B A A B의 LPS 배열은 아래와 같다.
  • [0, 1, 0, 1, 2, 3]

예제 패턴: A B C D A B C

패턴(P) 인덱스 0 1 2 3 4 5 6

문자(P[i]) A B C D A B C
LPS[i] 0 0 0 0 1 2 3

🔹 LPS 테이블 생성 과정 (단계별 분석)

패턴: A B C D A B C

초기 상태:

  • LPS = [0, 0, 0, 0, 0, 0, 0]
  • length = 0 (현재까지 접두사=접미사 길이)
  • i = 1 (두 번째 문자부터 비교 시작)

📌 1단계: i = 1

현재 인덱스(i) 현재 문자열 (P[0:i]) 현재 문자 (P[i]) 비교 대상 (P[length]) 비교 결과 LPS[i] 값 length 값
1 A B B A ❌ 불일치 0 0

불일치 발생 → LPS[1] = 0, length 그대로

i++ 후 다음 비교 진행


📌 2단계: i = 2

현재 인덱스(i) 현재 문자열 (P[0:i]) 현재 문자 (P[i]) 비교 대상 (P[length]) 비교 결과 LPS[i] 값 length 값
2 A B C C A ❌ 불일치 0 0

불일치 발생 → LPS[2] = 0, length 그대로

i++ 후 다음 비교 진행


📌 3단계: i = 3

현재 인덱스(i) 현재 문자열 (P[0:i]) 현재 문자 (P[i]) 비교 대상 (P[length]) 비교 결과 LPS[i] 값 length 값
3 A B C D D A ❌ 불일치 0 0

불일치 발생 → LPS[3] = 0, length 그대로

i++ 후 다음 비교 진행


📌 4단계: i = 4

현재 인덱스(i) 현재 문자열 (P[0:i]) 현재 문자 (P[i]) 비교 대상 (P[length]) 비교 결과 LPS[i] 값 length 값
4 A B C D A A A ✅ 일치 1 1

일치 발생 → LPS[4] = 1, length 증가

i++ 후 다음 비교 진행


📌 5단계: i = 5

현재 인덱스(i) 현재 문자열 (P[0:i]) 현재 문자 (P[i]) 비교 대상 (P[length]) 비교 결과 LPS[i] 값 length 값
5 A B C D A B B B ✅ 일치 2 2

일치 발생 → LPS[5] = 2, length 증가

i++ 후 다음 비교 진행


📌 6단계: i = 6

현재 인덱스(i) 현재 문자열 (P[0:i]) 현재 문자 (P[i]) 비교 대상 (P[length]) 비교 결과 LPS[i] 값 length 값
6 A B C D A B C C C ✅ 일치 3 3

일치 발생 → LPS[6] = 3, length 증가

i++, 반복 종료


📌 최종 LPS 테이블 결과

패턴(P) 인덱스 0 1 2 3 4 5 6
문자(P[i]) A B C D A B C
LPS[i] 0 0 0 0 1 2 3

🔹 KMP 검색 과정 (텍스트와 패턴 비교)

텍스트: A A A B C D A B C A B C D E

패턴: A B C D A B C

📌 초기 비교 상태

텍스트(T) i 현재까지 비교된 텍스트(T[0:i]) 문자(T[i]) 패턴(P) j 문자(P[j]) 비교 결과
0 A A 0 A ✅ 일치
1 A A A 1 B ❌ 불일치

불일치 발생 → j = LPS[0] = 0

패턴 인덱스를 0으로 이동 후, 텍스트 인덱스 증가 (i++)


📌 재비교 과정

텍스트(T) i 현재까지 비교된 텍스트(T[0:i]) 문자(T[i]) 패턴(P) j 문자(P[j]) 비교 결과
2 A A A A 0 A ✅ 일치
3 A A A B B 1 B ✅ 일치
4 A A A B C C 2 C ✅ 일치
5 A A A B C D D 3 D ✅ 일치
6 A A A B C D A A 4 A ✅ 일치
7 A A A B C D A B B 5 B ✅ 일치
8 A A A B C D A B C C 6 C ✅ 일치
9 - - 7 - 🎯 패턴 발견 (i - j = 2)

<aside> 💡

“i”와 “j”의 움직임 이해 KMP가 ‘점프’한다는 표현 때문에, 텍스트 인덱스 i까지도 확 뛰어넘는다고 오해하는 경우가 많다.

  • 하지만 실제로는 텍스트 인덱스 i는 대체로 1씩 증가하거나 ‘찾았다’ 직후에 동일한 위치에서 다시 비교하는 정도
  • **주요 점프 대상은 j(패턴 인덱스)**이고, 그 점프 폭을 미리 계산해 둔 테이블이 LPS </aside>

📌 패턴을 찾음: 패턴 발견 위치는 i - j = 2

  • 패턴이 완전히 일치했을 때: j == 패턴 길이 = 7
  • LPS를 활용해서 점프!
    • j = LPS[6] = 3 (패턴의 7번째 위치에서 LPS 테이블 값 사용)
    • 즉, j = 3에서부터 다시 비교 시작!
    • i는 그대로 (i = 9) → 텍스트 인덱스는 그대로 유지!

📌 LPS 적용 후, 다음 비교 과정

텍스트(T) i 현재까지 비교된 텍스트(T[0:i]) 문자(T[i]) 패턴(P) j 문자(P[j]) 비교 결과
9 A A A B C D A B C A A 3 D ❌ 불일치
9 A A A B C D A B C A A LPS[2] = 0 A LPS 점프
9 A A A B C D A B C A A 0 A ✅ 일치
10 A A A B C D A B C A B B 1 B ✅ 일치
11 A A A B C D A B C A B C C 2 C ✅ 일치
12 A A A B C D A B C A B C D D 3 D ✅ 일치
13 A A A B C D A B C A B C D E E 4 A ❌ 불일치
13 A A A B C D A B C A B C D E E LPS[3] = 0 A LPS 점프
13 A A A B C D A B C A B C D E E 0 A ❌ 불일치

<aside> 💡

불일치가 발생했으면 바로 이후 텍스트로 건너 뛰어도 될 것 같다 그렇지만 만약 찾고자 하는 패턴 안에(패턴 자체에) 반복되는 부분 문자열이 있다면 그렇게 했을 때, 예외상황이 생긴다. 예시를 통해서 이해해보자.

</aside>

패턴: A A A B A 텍스트: A A A A B A A A B A 일 때, 패턴의 의 LPS는 다음과 같다.

  • i = 0
    • 아무것도 비교할 수 없으므로 LPS[0] = 0
  • i = 1 (문자열: A A)
    • 앞선 접두사 "A"와 현재 문자 "A"가 일치
    • length = 1 → LPS[1] = 1
  • i = 2 (문자열: A A A)
    • 직전까지 일치 길이가 1(length = 1), 비교 문자: P[length] = P[1] = 'A'
    • 현재 P[i] = 'A'와 일치
    • length = 2 → LPS[2] = 2
  • i = 3 (문자열: A A A B)
    • 직전까지 일치 길이가 2(length = 2), 비교 문자: P[length] = P[2] = 'A'
    • 현재 P[i] = 'B'와 불일치
    • 불일치 시 처리
      • 우선 length = LPS[length - 1] = LPS[1] = 1로 줄여 다시 비교
      • P[length] = P[1] = 'A' vs 현재 P[i] = 'B' 여전히 불일치
      • 다시 length = LPS[1 - 1] = LPS[0] = 0
    • 결국 일치 길이를 0으로 만들고 더 이상 비교할 접두사가 없으므로 LPS[3] = 0
  • i = 4 (문자열: A A A B A)
    • length = 0, 비교 문자 P[length] = P[0] = 'A'
    • 현재 P[i] = 'A'와 일치
    • length = 1 → LPS[4] = 1

최종 LPS 테이블은 다음과 같다.

인덱스(i) 0 1 2 3 4
패턴(P[i]) A A A B A
LPS[i] 0 1 2 0 1

이제 텍스트를 인덱스로 나열하면 다음과 같다(인덱스는 0부터 시작):

T의 인덱스 0 1 2 3 4 5 6 7 8 9
텍스트(T) A A A A B A A A B A

(1) 초반 연속 일치 예시

  1. i=0, j=0
    • T[0] = 'A', P[0] = 'A' → 일치
    • → i=1, j=1
  2. i=1, j=1
    • T[1] = 'A', P[1] = 'A' → 일치
    • → i=2, j=2
  3. i=2, j=2
    • T[2] = 'A', P[2] = 'A' → 일치
    • → i=3, j=3
  4. i=3, j=3
    • T[3] = 'A', P[3] = 'B' → 불일치 발생!

(2) 불일치 시 LPS를 이용한 j 조정

  • 현재 j=3이므로, 불일치 발생 시 j를 LPS[j-1]로 이동한다.
  • LPS[3-1] = LPS[2] = 2
    • 즉, j를 2로 돌려놓고 T[3]과 P[2]를 다시 비교하자.(텍스트 i는 그대로).
  1. 재비교: i=3, j=2
    • T[3] = 'A', P[2] = 'A' → 일치
    • → i=4, j=3

<aside> 💡

여기서 보듯이, j가 0으로 떨어지지 않고(2로 세팅 후) 바로 이어서 비교를 진행 만약 LPS가 없었다면 보통 패턴 전체를 0으로 초기화한 뒤 텍스트 인덱스도 적잖이 뒤로 돌려 재비교를 할 것이다.

</aside>

  1. i=4, j=3
  • T[4] = 'B', P[3] = 'B' → 일치
  • → i=5, j=4
  1. i=5, j=4
  • T[5] = 'A', P[4] = 'A' → 일치
  • → i=6, j=5

이때 j == 패턴 길이(5)가 되었으므로, 패턴 발견!

  • “발견 위치”는 i - j = 6 - 5 = 1
    • 즉, 텍스트의 인덱스 1부터 5까지가 패턴과 일치한다.

(3) 패턴 발견 후 LPS로 또 j 이동

  • 일반적으로 KMP에서는 패턴을 찾은 뒤에도 바로 j = LPS[j-1]로 이동하여,텍스트 인덱스 i는 그대로 두고 연속 비교를 이어갈 수 있다.

➡️

위의 예시를 곰곰히 살펴보면 패턴에 반복 접두사 또는 접미사가 없을 경우에는 LPS 배열의 대부분이 0이라는 뜻이며 그러므로 LPS[j-1]로 조정해도 바로 0이 되므로 효율적으로 점프할 여지가 없는 것이다. 즉, 패턴 안에 패턴이 없다면 KMP는 통하지 않는다.

 

이제 다음 예시를 생각해보자.

패턴이 a b c d a b f 이고 텍스트가 a b c d a b c d a b f 일 때, 인간은 쉽게 찾을 수 있겠지만 컴퓨터가 이를 Brute force로 할 때, 매우 비효율적인 연산을 겪게 될 것이다. LPS[5] = 2를 이용해서 점프하면 쉽게 패턴을 찾을 수 있다.

👉

즉, 찾고자하는 패턴에 접두사 접미사 또는 패턴속의 패턴이 없다면 브루트 포스 보다 좋은건 만약 패턴 매칭이 실패했을 떄, 바로 다음 항으로 건너뛰는게 아니라 실패한 항에서 시작하는 점 뿐이다.

 

예제

🔹 예제 1: 패턴 내 중첩된 부분 문자열이 있는 경우

📌 패턴: A B C A B D A B C A B

📌 LPS 테이블 생성

이 패턴에서는 A B C가 여러 번 반복되므로, LPS 값이 점점 커지는 특징이 있다.

이러한 패턴이 포함된 경우, KMP 알고리즘이 불필요한 재비교를 얼마나 줄이는지 확인할 수 있다.

🔍 1단계: i = 1 (문자: B)

현재 인덱스(i) 현재 문자열 (P[0:i]) 현재 문자 (P[i]) 비교 대상 (P[length]) 비교 결과 LPS[i] 값 length 값
1 A B B A ❌ 불일치 0 0

불일치 발생 → LPS[1] = 0, length 그대로

i++ 후 다음 비교 진행


🔍 2단계: i = 2 (문자: C)

현재 인덱스(i) 현재 문자열 (P[0:i]) 현재 문자 (P[i]) 비교 대상 (P[length]) 비교 결과 LPS[i] 값 length 값
2 A B C C A ❌ 불일치 0 0

불일치 발생 → LPS[2] = 0, length 그대로

i++ 후 다음 비교 진행


🔍 3단계: i = 3 (문자: A)

현재 인덱스(i) 현재 문자열 (P[0:i]) 현재 문자 (P[i]) 비교 대상 (P[length]) 비교 결과 LPS[i] 값 length 값
3 A B C A A A ✅ 일치 1 1

일치 발생 → LPS[3] = 1, length 증가

i++ 후 다음 비교 진행


🔍 4단계: i = 4 (문자: B)

현재 인덱스(i) 현재 문자열 (P[0:i]) 현재 문자 (P[i]) 비교 대상 (P[length]) 비교 결과 LPS[i] 값 length 값
4 A B C A B B B ✅ 일치 2 2

일치 발생 → LPS[4] = 2, length 증가

i++ 후 다음 비교 진행


🔍 5단계: i = 5 (문자: D)

현재 인덱스(i) 현재 문자열 (P[0:i]) 현재 문자 (P[i]) 비교 대상 (P[length]) 비교 결과 LPS[i] 값 length 값
5 A B C A B D D C ❌ 불일치 0 0

불일치 발생 → LPS[5] = 0, length 초기화

i++ 후 다음 비교 진행


🔍 6단계: i = 6 (문자: A)

현재 인덱스(i) 현재 문자열 (P[0:i]) 현재 문자 (P[i]) 비교 대상 (P[length]) 비교 결과 LPS[i] 값 length 값
6 A B C A B D A A A ✅ 일치 1 1

일치 발생 → LPS[6] = 1, length 증가

i++ 후 다음 비교 진행


📌 최종 LPS 테이블 결과

패턴(P) 인덱스 0 1 2 3 4 5 6 7 8 9 10
문자(P[i]) A B C A B D A B C A B
LPS[i] 0 0 0 1 2 0 1 2 3 4 5

🔹 예제 2: 패턴이 완전히 반복되는 경우

📌 패턴: A B A B A B A B

📌 LPS 테이블이 점진적으로 증가하는 경우

📌 텍스트: A B A B A B A B A B A B A B

📌 이 경우, KMP는 처음 몇 번만 비교한 후 LPS 테이블을 활용하여 매우 빠르게 검색을 수행한다.

📌 LPS 테이블 결과

패턴(P) 인덱스 0 1 2 3 4 5 6 7
문자(P[i]) A B A B A B A B
LPS[i] 0 0 1 2 3 4 5 6
                 

패턴이 반복되는 경우, KMP는 처음 몇 번 비교한 후

LPS 테이블을 활용하여 불필요한 비교를 완전히 건너뛴다.

🔹 예제 3: 패턴이 거의 일치하지만 중간에 하나만 다른 경우

📌 텍스트: A B A C A B A B A C A B A B

📌 패턴: A B A B A C A B

📌 중간에 하나만 다르면, KMP는 LPS 테이블을 활용하여 빠르게 점프한다.

📌 KMP 검색 과정 중 불일치 발생 시 점프 과정

텍스트(T) i 현재까지 비교된 텍스트(T[0:i]) 문자(T[i]) 패턴(P) j 문자(P[j]) 비교 결과 LPS[j-1] 값 점프 후 j 값
6 A B A C A B A A 6 C ❌ 불일치 LPS[5]=2 2
6 A B A C A B A A 2 A ✅ 일치 - -

📌 중간에 하나만 다른 경우에도, KMP는 전체를 다시 비교하지 않고, 이미 맞았던 부분만큼 건너뛴다.

LPS 테이블 생성 함수 (Python)

def compute_lps(pattern):
    M = len(pattern)
    lps = [0] * M
    length = 0
    i = 1
    while i < M:
        if pattern[i] == pattern[length]:
            length += 1
            lps[i] = length
            i += 1
        else:
            if length != 0:
                length = lps[length - 1]
            else:
                lps[i] = 0
                i += 1
    return lps

KMP 검색 함수 (Python)

def kmp_search(pattern, text):
    M = len(pattern)
    N = len(text)
    lps = compute_lps(pattern)
    i = 0  # 텍스트 인덱스
    j = 0  # 패턴 인덱스
    while i < N:
        if text[i] == pattern[j]:
            i += 1
            j += 1
        if j == M:
            return i - j  # 패턴 발견 위치
        elif i < N and text[i] != pattern[j]:
            if j != 0:
                j = lps[j - 1]
            else:
                i += 1
    return -1  # 패턴을 찾지 못한 경우

문제출처 : 백준 2869번 - 달팽이는 올라가고 싶다

이 문제를 푸는 것은 어렵지 않다.
그렇지만 이를 함수를 쓰지 않고(예: math.ceil() 없이) 나타낼 수 있을까?

이에 대한 실마리를 찾기 위해 다음과 같은 상황을 생각해보자.


1. 가우스 함수로 올림 함수 표현하기

우리는 올림 함수를 가우스 함수 $[ ~~ ]$를 이용해 표현하는 방법을 두 가지로 나눌 수 있다.

정수일 때 적용할 수 있는 변형

$$
\lceil x \rceil = [ x - 1 ] + 1, \quad \text{(단, $x$가 정수일 때)}
$$

  • 이 식은 $x$가 정수일 때만 성립한다.
  • 예를 들어, $x = 3$이면 $\lceil 3 \rceil = [3 - 1] + 1 = [2] + 1 = 3$ (정확)
  • 하지만 $x = 3.4$이면 $\lceil 3.4 \rceil = 4$인데, $\left[ 3.4 - 1 \right] + 1 = [2.4] + 1 = 2 + 1 = 3$ (틀림)
  • 따라서 이 방식은 정수에 대해서만 유효하며, 소수에는 적용 불가하다.

** 분수일 때 적용할 수 있는 변형**

$$
\lceil \frac{p}{q} \rceil = \Big[ \frac{p - 1}{q} \Big] + 1, \quad \text{(단, $p, q$는 양의 정수)}
$$

  • 이 방식은 $p, q$가 양의 정수일 때 항상 성립한다.
  • 예를 들어,
    • $p = 10, q = 3$일 때:
      $$
      \lceil \frac{10}{3} \rceil = 4
      $$
      $$
      \Big[ \frac{10-1}{3} \Big] + 1 = \Big[ \frac{9}{3} \Big] + 1 = [3] + 1 = 4
      $$ (정확)
    • $p = 7, q = 2$일 때:
      $$
      \lceil \frac{7}{2} \rceil = 4
      $$
      $$
      \Big[ \frac{7-1}{2} \Big] + 1 = \Big[ \frac{6}{2} \Big] + 1 = [3] + 1 = 4
      $$ (정확)
  • 이 방식은 항상 성립하며, 특히 "분수" 형태의 값에 적용할 때 유용하다.

특수하게 정수를 논하는 문제에서만 첫번째 공식을 쓸 수 있고
사실은 두번째 공식은 정수이든 분수이든 성립한다 따라서, 두번째 공식을 쓰면 된다.
이 내용을 가지고 달팽이 문제를 풀며 된다.


2. 달팽이 문제 요약 및 식 유도

  • 낮에 $A$미터 올라가고, 밤에 $B$미터 미끄러진다.
  • 막대기의 높이가 $V$미터일 때, 달팽이가 며칠 만에 정상에 도달하는지 구해야 한다.
  • 마지막 날에는 밤에 미끄러지지 않는다.

이를 올림 함수를 직접 쓰지 않고 정수 연산만으로 풀어내려면, 다음 공식을 떠올릴 수 있다.

$$
\left\lceil \frac{V - B}{A - B} \right\rceil = \Big[ \frac{(V - B) - 1}{A - B} \Big] + 1
$$

여기서 우리는 분수 형태의 올림 연산이 필요하므로, 앞서 소개한 두 번째 방식을 적용할 수 있다.

따라서 파이썬 코드는 이렇게 작성된다.

A, B, V = map(int, input().split())
cnt = (V - B - 1) // (A - B) + 1
print(cnt)

또한, 다른 유도 방식으로는 다음과 같이 시작한다.
낮에 $A$미터, 밤에 $B$미터 이동하고 마지막 날 밤에는 미끄러지지 않으므로,

$$
A + (n-1)(A-B) \ge V
$$

에서 출발하면

$$
n \ge \frac{V-A}{A-B} + 1,
$$

즉,

$$
n = \left\lceil \frac{V-A}{A-B} \right\rceil + 1.
$$

여기서
$$
\frac{V-A}{A-B} + 1 = \frac{V-A+(A-B)}{A-B} = \frac{V-B}{A-B}
$$
이므로,
$$
n = \left\lceil \frac{V-B}{A-B} \right\rceil
$$
와 같이 표현할 수 있고, 위의 정수 연산 식과 대수적으로 동치이다.

이제 우리는 분수의 올림을 정수 연산으로 변환하는 두 번째 공식을 활용하여 문제를 해결할 수 있다.

이와 같이, 정수 연산만으로 올림 기능을 구현하는 방법을 통해
math.ceil() 없이도 달팽이 문제를 정확하게 풀 수 있음을 보였다.
두 가지 방식

  • 분자에서 바로 V - B를 사용하는 방식과
  • V - A를 사용하여 마지막에 1을 더하는 방식
    은 대수적으로 동일하므로, 상황에 맞게 편리한 쪽을 선택하면 된다.

+ Recent posts