[Algorithm] 2. Brute Force


완전탐색 알고리즘(Brute Force Algorithm)

Untitled

  • 완전탐색은 가능한 경우의 수를 모두 검사해보는 탐색 방법이다.
    • 모든 경우의 수를 고려하지 않고 더 이상 탐색이 필요 없는 경우 그 루트를 끊어버리는 것은 미완전 탐색이다.
    • 예를 들어서 재귀함수가 모든 경우의 수를 탐색하다 결과를 찾아서 탐색을 중지하는 경우처럼 루트를 끊는 경우를 가지치기(Pruning)라고 한다. 이를 수행하면 불필요한 탐색을 줄일 수 있다.
  • 상대적으로 구현이 간단하고, 해가 존재하면 항상 찾게 된다.
  • 경우의 수에 따라 실행 시간이 비례하기 때문에 입력 값의 범위가 작은 경우 유용하다.
  • 하나도 빠짐없이 모든 경우의 수를 확인하고 있는지만 확인하면 된다.

단순 Brute Force

  • 단순히 반복문과 조건문으로 모든 경우를 만들어 답을 구하는 방법.
  • 이 방법만을 사용하는 문제는 거의 나오지 않는다.
  • 배열 탐색이나 문자열 비교가 해당될 수 있다.
      # 배열 탐색
      def find_index(arr, target):
          for i in range(len(arr)):
              if arr[i] == target:
                  return i
          return -1
    
      # 문자열 비교
      def str_compare(s1, s2):
          length_s1 = len(s1)
          length_s2 = len(s2)
    
          small_length = min(length_s1, length_s2)
    
          for i in range(small_length):
              if s1[i] != s2[i]:
                  return ord(s1[i]) - ord(s2[i])
    
          if (length_s1 == length_s2):
              return 0
          elif length_s1 > length_s2:
              return s1[small_length]
          else:
              return s2[small_length]
    

비트마스크 (Bitmask)

  • 나올 수 있는 모든 경우의 수 각각에서 원소가 포함되거나, 포함되지 않는 두 가지 선택으로 구성되는 경우 유용하게 사용될 수 있다.
  • 즉 이진수를 활용한 비트 연산을 통해 경우의 수를 줄여가며 탐색하는 방식이다.
  • 예를 들어 ‘원소가 n 개인 집합의 모든 부분 집합’을 구한다면, 각 원소가 포함되는지 포함되지 않는지를 0, 1로 구분하여 배열에 저장해둘 수 있다.
  • 이와 같은 특성을 이용하여 정수의 이진수 표현을 자료구조로 쓰는 기법을 비트마스크 라고 한다.
  • 비트 연산을 사용하기 때문에 계산이 빨라서 경우의 수가 많은 경우 유용하다.
  • 10개의 방문을 체크해야 한다면 보통 check = [False] * 10와 같이 리스트를 많이 사용한다. 비트 마스킹은 check = 0b0000000000 와 같이 표현해서 하위 주소인 오른쪽부터 0인지 1인지 확인하면 된다.

비트 연산자

  • AND(&) : 대응하는 두 비트가 모두 1일 때, 1 반환
  • OR(|) : 대응하는 두 비트가 하나라도 1일 때, 1 반환
  • XOR(^) : 대응하는 두 비트가 서로 다르면, 1 반환
  • NOT(~) : 비트의 값을 반전하여 반환
  • Shift
    • << : 왼쪽으로 비트를 옮긴다. A << B 는 A * 2^B 와 같다.
    • >> : 오른쪽으로 비트를 옮긴다. A >> B 는 A / 2^B 와 같다.
      1010 & 1111 = 1010
      1010 | 1111 = 1111
      1010 ^ 1111 = 0101
      ~1010 = 0101
      1010 << 2  = 101000
      1010 >> 2 = 10
      

비트 마스크와 집합

  • 비트 마스크는 비트의 특징을 살려 집합 개념의 문제에서 활용 가능하다. 즉 비트열을 리스트처럼 활용하는 것이다. 이 때 인덱스는 오른쪽부터 시작하고, 맨 오른쪽 인덱스는 0이다.
  • 이 비트를 10진수로 바꾸어서 생각하는 것이 아니라, 각 자리의 비트가 1이라면 해당 자리수의 숫자가 집합에 속하는 것이다.
  • 예를 들어 집합 S가 0b1000 일 때 집합 S 의 원소는 3 뿐이다. 마찬가지로 0b1001 라면 원소는 0과 3이다.
  • 원소 추가
    • 집합(S)에 특정 숫자(n)를 추가한다면, S 의 n번 비트만 1로 만들어주면 된다.
      S = 0
      # S |= (1 << n)
      S |= (1 << 0)
      S |= (1 << 3)
      print(bin(S)) # 출력: 0b1001
      
    • 여기서 1 << n 은 비트열의 n 번째를 1로 만들어주는 거라고 생각하면 쉽다. 이를 기존 집합과 OR 연산을 통해 n 번 비트를 추가한다.
  • 원소 삭제
    • S 에서 n 을 삭제한다면 S 의 n 번 비트를 0으로 만들어주면 된다.
      S = 0
      S |= (1 << 0)
      S |= (1 << 3)
      S &= ~(1 << 3)
      print(bin(S)) # 출력: 0b1
      
    • 여기서 ~(1 << n) 은 n 번 비트만 0으로 만들고 나머지는 1로 만들어주는 것이다. 이를 기존 집합과 AND 연산을 통해 n 번 비트를 삭제한다.
  • 원소 토글
    • S 에 n 이 있다면 삭제, 없다면 추가하는 연산이다. XOR 연산은 두 비트가 서로 다를 때 1을 반환하므로 토글 개념을 구현할 수 있다.
      S ^= (1 << n)
      
  • 원소 체크
    • S 에 n 이 있으면 1, 없으면 0을 출력하는 연산이다. AND 연산의 두 비트가 모두 1이어야 1을 반환하는 성질을 이용한다.
      print(1 if S & (1 << n) else 0)
      
  • 원소 비우기 및 채우기
    • 원소를 비우기 위해선 S 의 모든 비트를 0으로 만들어준다. 그리고 채우기 위해서는 S 의 모든 비트를 1로 만들어준다.
    • S 의 길이보다 1큰 만큼 비트열을 만들어주고, 1을 빼주면 비트열의 길이를 한자릿수 줄이면서 모두 1로 만들어준다.
      S = 0 # 비우기
      S = (1 << (len(S) + 1)) - 1 # 채우기
      
  • 집합 끼리의 연산
    A | B # 합집합
    A & B # 교집합
    A & ~B # 차집합
    A ^ B # A 와 B 중 하나에만 포함된 원소들의 집합
    
  • 집합의 크기
    • 비트에서 1인 비트 수를 세주면 된다.
    • x % 2 는 마지막 비트(맨 오른쪽)를 의미하고, x // 2 는 마지막 비트(맨 오른쪽)의 삭제를 의미한다.
      def bit_count(x):
        if x == 0:
            return 0
        return x % 2 + bit_count(x//2)
      
  • 모든 부분집합 순회하기
    def generate_subsets(elements):
        n = len(elements)
        subsets = []
        
        # 총 2^n개의 부분집합을 생성. -> 비트열은 i+1 개가 생긴다.
        for i in range(1 << n):
            subset = []
            for j in range(n):
                # i의 j번째 비트가 1인지 확인
                if i & (1 << j):
                    subset.append(elements[j])
            subsets.append(subset)
        
        return subsets
    
    # 예시 테스트
    elements = ['a', 'b', 'c']
    all_subsets = generate_subsets(elements)
    for subset in all_subsets:
        print(subset)
    
    # Output:
    # []
    # ['a']
    # ['b']
    # ['a', 'b']
    # ['c']
    # ['a', 'c']
    # ['b', 'c']
    # ['a', 'b', 'c']
    
  • 0xFFFFFFFF는 16진수 표기법으로, 32비트 정수에서 모든 비트가 1인 값을 나타낸다.
    • 0x는 숫자가 16진수로 표기된다는 것을 의미한다.
    • F는 16진수에서 10진수 15를 나타낸다.
    • FFFFFFFF는 16진수 F가 8개 있는 형태로, 32비트를 나타낸다. 즉 각각의 F는 4비트를 나타내므로, F 8개는 총 32비트를 나타내는 것이다.
    • 16진수 F는 이진수로 1111 이다.
    • FFFFFFFF는 16진수 F가 8개 있으므로, 이진수로 변환하면 1111 1111 1111 1111 1111 1111 1111 1111가 된다. 즉, 32비트 모두가 1인 값을 나타내며, 이는 10진수로 4294967295 다.
  • 0xFFFFFFFF는 비트 연산에서 자주 사용된다. 모든 비트를 1로 설정하여 특정 비트를 마스킹하는 데 사용된다.

재귀함수

  • 재귀함수는 자기 자신을 다시 호출하는 함수를 말한다.
  • DFS 를 구현할 때 자주 사용되는 방법 중 하나이다.
  • 파이썬에서는 기본적으로 최대 재귀 깊이가 있기 때문에 별다른 설정을 하지 않고 함수를 재귀적으로 호출하면 오류가 발생한다.
  • 실제로 컴퓨터 시스템 상에서 함수가 재귀적으로 호출이 되면 컴퓨터 시스템의 스택 프레임에 함수가 반복적으로 쌓여서, 가장 마지막에 호출된 함수가 처리가 된 이후에 그 함수를 불렀던 함수를 처리하는 방식이다. 즉 일종의 스택 자료 구조 안에 함수에 대한 정보가 차례대로 담겨서 컴퓨터 메모리에 올라가게 된다고 이해할 수 있다.
  • 재귀 함수를 호출하면 마치 스택에 데이터를 넣었다가 꺼내는 것과 마찬가지로 각각의 함수가 스택 프레임에 담겨서 차례대로 호출되었다가 가장 마지막에 호출된 함수부터 차례대로 종료가 되어 맨 처음 호출했던 함수까지 종료된다.
  • 재귀함수를 쓰면 while 이나 for 문을 사용하지 않고도 반복적 작업을 수행할 수 있다.
  • 일반적인 경우, 재귀 함수를 문제풀이에서 사용할 때에는 재귀 함수의 종료 조건을 반드시 명시해야 한다. 종료 조건을 제대로 명시하지 않으면 함수가 무한히 호출될 수 있기 때문이다. 따라서 언젠가는 프로그램이 정해진 값을 반환할 수 있도록 만들어줘야 한다. 따라서 재귀 함수의 시작 부분에 종료 조건을 명시해서 종료 조건을 만족한다면 함수가 종료되도록 만들어준다.
  • 재귀 함수의 예시로 유클리드 호제법이 있다. 유클리드 호제법은 두 개의 자연수에 대한 최대공약수를 구하는 대표적인 알고리즘이다.
    • 유클리드 호제법은 두 자연수 A, B 에 대하여(A > B) A 를 B 로 나눈 나머지를 R 이라고 할 때, A 와 B 의 최대공약수는 B 와 R 의 최대공약수와 같다는 것이다.
    • 아래와 같이 구현할 수 있다.
      def gcd(a, b):
          if a % b == 0:
              return b
          else:
              return gcd(b, a % b)
          
      print(gcd(192, 162))
      
  • 이처럼 재귀 함수는 수학적으로 정의된 점화식 같은 형태를 간단히 코드로 옮길 수 있게 해준다. 단, 오히려 다른 사람이 이해하기 어려운 형태의 코드가 될 수도 있으므로 신중하게 사용해야 한다.
  • 또한 모든 재귀 함수는 반복문을 이용하여 동일한 기능을 구현할 수 있다. 반대도 가능하다. 다만, 재귀 함수가 반복문보다 유리한 경우도 있고 불리한 경우도 있다.
  • 컴퓨터가 함수를 연속적으로 호출하면 컴퓨터 메모리 내부의 스택 프레임에 쌓인다. 그래서 스택을 사용해야 할 때 구현상 스택 라이브러리 대신에 재귀 함수를 이용하는 경우가 많다. 대표적으로 DFS 를 간결하게 작성하기 위해서 재귀 함수로 DFS 를 활용하기도 한다.
  • 비트마스크와 마찬가지로 각 원소가 두 가지 선택지를 가질 때 유용하게 사용할 수 있다. 포함이 되면 해당 원소를 넣어 함수를 호출하고, 포함되지 않으면 그 상태에서 함수를 호출하는 방식이다.
  • 피보나치 수열 또한 재귀함수의 형태로 풀릴 수 있다.
    def fibo(x):
        # 종료 조건(1 혹은 2일 때 1을 반환)
        if x == 1 or x == 2:
            return 1
    
        return fibo(x-1) + fibo(x-2)
    

백트래킹(Back Tracking)

  • 해결책으로 가는 도중에 막히게 되면 상위 지점으로 다시 돌아가서 다른 경로를 탐색하는 방식이다. 이를 통해 모든 가능한 경우의 수를 탐색하여 해결책을 찾는다.
  • 백트래킹은 완전탐색과 유사하지만, 불필요한 탐색을 줄이기 위해 가능성이 없는 노드를 더 이상 탐색하지 않는다(가지치기, pruning). 이러한 방법으로 백트래킹은 대규모 문제를 효과적으로 해결할 수 있다.
  • 백트래킹은 다음과 같은 형태를 띈다.
    def 백트래킹(n):
        if 정답이면 :
            출력 or 저장
        else :
            for 모든 자식 노드에 대해서:
                if 유망조건(m):
                    자식노드로 이동
                    백트래킹(n+1)
                    부모노드로 이동
    def 유망조건(m):
        if 조건에 안맞으면:
            return False
        return True
    
  • 해당 알고리즘을 사용할 때 주로 재귀함수를 사용하여 구현하며 스택으로 구현하는 경우도 있다.
  • 재귀함수를 이용하는 경우, 해결책을 찾기 위해서 생성, 검사, 선택, 이동, 되돌아가기 등 과정이 재귀적으로 수행된다.
  • N Queens 문제가 대표적이다.

순열, 조합

  • 순열(permutation)은 서로 다른 N개를 일렬로 나열하는 방법(경우의 수)을 말한다.
  • 파이썬의 경우 itertoolspermutations 로 쉽게 구현 가능하다.
  • 조합(combination)은 서로 다른 N개를 조합의 수 만큼 뽑는 경우의 수를 말한다.
  • 파이썬의 경우 itertoolscombinations 로 쉽게 구현 가능하다.
  • 이 외에 중복 순열(product), 중복 조합(combinations_with_replacement) 도 있다.
  • 시간복잡도 측면에서 총 경우의 수가 1억번이 넘어가지 않는 한도에서 쓰는 것이 좋다.

너비 우선 탐색(BFS), 깊이 우선 탐색(DFS)

  • 그래프를 이용한 비선형 탐색이다.
  • 너비우선탐색(Breadth-First Search, BFS)는 하나의 요소를 방문하고 그 요소에 인접한 모든 요소를 우선 방문하는 방식이다.
  • 깊이우선탐색(Depth-First Search, DFS)는 트리의 한 요소(노드)와 다음 수준(level)의 자식 노드를 따라가는 방향으로 탐색하는 방식이다.
  • 길 찾기 등에 주로 쓰이는 알고리즘이다. 단순 길찾기에는 BFS/DFS만 써도 무방하지만, 장애물이 존재하는 등 추가적 연산이 필요할 때 완전탐색을 병용하기도 한다.
  • 지구 상에 존재하는 모든 친구 관계를 그래프로 표현하고 A와 B 사이에 존재하는 경로 찾을 때,
    • DFS : 모든 친구 관계 다 살펴야한다.
    • BFS : A와 가까운 관계부터 탐색한다.
  • 너비 우선 탐색(BFS, Breadth-First Search)
    • 재귀적으로 동작하지 않는다.
    • 그래프 탐색의 경우, 어떤 노드를 방문했었는지 여부를 반드시 검사(검사하지 않으면 무한루프)해야 한다.
    • BFS 는 방문한 노드들을 차례로 저장하고 꺼낼 수 있는 큐(Queue) 자료구조(FIFO)를 사용한다.
    • 넓게 탐색한다.
    • 두 노드 사이의 최단 경로 혹은 임의의 경로를 찾고 싶을 때 이 방법을 사용한다.
  • 깊이 우선 탐색(DFS, Depth-First Search)
    • 재귀적으로 동작한다. 따라서 재귀 함수나 스택(Stack)을 활용한다.
    • 그래프 탐색의 경우, 어떤 노드를 방문했었는지 여부를 반드시 검사(검사하지 않으면 무한루프)해야 한다.
    • 모든 노드 방문하고자 할 때 사용한다.
    • BFS 보다 간단하지만, BFS 비해서 검색 속도가 느리다.
맨 위로 이동 ↑

댓글 남기기