ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • PART02 - CHAPTER 05. DFS/BFS
    algorithm/이것이 취업을 위한 코딩테스트다 2023. 3. 24. 12:49

    1. 꼭 필요한 자료구조 기초

    - 탐색(Search) : 많은 양의 데이터 중에서 원하는 데이터를 찾는 과정을 의미한다.

    - 프로그래밍에서는 그래프, 트리 등의 자료구조 안에서 탐색을 하는 문제를 자주 다룬다.

    - 대표적인 탐색 알고리즘으로 DFS와 BFS를 꼽을 수 있는데 이 두 알고리즘의 원리를 제대로 이해해야 코딩 테스트의 탐색 문제 유형을 풀 수 있다.

    - 그런데 DFS와 BFS를 제대로 이해하려면 기본 자료구조인 스택과 큐에 대한 이해가 전제되어야 하므로 사전 학습으로 스택과 큐, 재귀 함수를 간단히 정리하고자 한다.

    - 자료 구조(Data Structure)란 '데이터를 표현하고 관리하고 처리하기 위한 구조'를 의미한다.

    - 그중 스택과 큐는 자료구조의 기초 개념으로 다음의 두 핵심적인 함수로 구성된다.

        ㆍ삽입(Push) : 데이터를 삽입한다.

        ㆍ삭제(Pop) : 데이터를 삭제한다.

    - 물론 실제로 스택과 큐를 사용할 때는 삽입과 삭제 외에도 오버플로와 언더플로를 고민해야한다.

    - 오버플로(Overflow) : 특정한 자료 구조가 수용할 수 있는 데이터의 크기를 이미 가득찬 상태에서 삽입 연산을 수행할 때 발생한다.

        ㆍ즉, 저장 공간을 벗어나 데이터가 넘쳐 흐를 때 발생한다.

    - 언더플로(Underflow) : 특정한 자료 구조에 데이터가 전혀 들어있지 않은 상태에서 삭제 연산을 수행하면 데이터가 전혀 없는 상태

     

    스택

    - 스택(Stack)은 박스 쌓기에 비유할 수 있다.

        ㆍ흔히 박스는 아래에서부터 위로 차곡차곡 쌓는다.

        ㆍ그리고 아래에 있는 박스를 치우기 위해서는 위에 있는 박스를 먼저 내려야 한다.

    - 이러한 구조를 선입후출(First In Last Out)구조 또는 후입선출(Last In First Out) 구조라고 한다.

    stack = []
    
    # 삽입(5) - 삽입(2) - 삽입(3) - 삽입(7) - 삭제() - 삽입(1) - 삽입(4) - 삭제()
    stack.append(5)
    stack.append(2)
    stack.append(3)
    stack.append(7)
    stack.pop()
    stack.append(1)
    stack.append(4)
    stack.pop()
    
    print(stack)	# 최하단 원소부터 출력
    print(stack[::-1])	# 최상단 원소부터 출력
    
    # [5,2,3,1]
    # [1,3,2,5]

    - 파이썬에서 스택을 이용할 때에는 별도의 라이브러리를 사용할 필요가 없다.

    - 기본 리스트에서 append()와 pop() 메서드를 이용하면 스택 자료구조와 동일하게 동작한다.

    - append() 메서드는 리스트의 가장 뒤쪽에 데이터를 삽입하고, pop() 메서드는 리스트의 가장 뒤쪽에서 데이터를 꺼내기 때문이다.

     

     

    큐 

    - 큐(Queue)는 대기 줄에 비유할 수 있다.

        ㆍ우리가 흔히 놀이공원에 입장하기 위해 줄을 설 때, 먼저 온 사람이 먼저 들어가게 된다.

        ㆍ나중에  온 사람일수록 나중에 들어가기 떄문에 흔히 '공정한' 자료구조라고 비유된다.

    - 이러한 구조를 선입선출(First In First Out)구조라고 한다.

    from collections imports deque
    
    # 큐(Queue) 구현을 위해 deque 라이브러리 사용
    queue = deque()
    
    # 삽입(5) - 삽입(2) - 삽입(3) - 삽입(7) - 삭제() - 삽입(1) - 삽입(4) - 삭제()
    queue.append(5)
    queue.append(2)
    queue.append(3)
    queue.append(7)
    queue.popleft()
    queue.append(1)
    queue.append(4)
    queue.popleft()
    
    print(queue)	# 먼저 들어온 순서대로 출력
    queue.reverse()	# 다음 출력을  위해 역순으로 바꾸기
    print(queue)	# 나중에 들어온 원소부터 출력
    
    # deque([3,7,1,4])
    # deque([4,1,7,3])

    - 파이썬으로 큐를 구현할 때는 collections 모듈에서 제공하는 deque 자료구조를 활용하자.

    - deque는 스택과 큐의 장점을 모두 채택한 것인데 데이터를 넣고 빼는 속도가 리스트 자료형에 비해 효율적이며 queue 라이브러리를 이용하는 것보다 더 간단하다.

    - 더불어 대부분의 코딩 테스트에서는 collections 모듈과 같은 기본 라이브러리 사용을 허용하므로 안심하고 사용해도 괜찮다.

    - 또한 deque 객체를 리스트 자료형으로 변경하고자 한다면 list() 메서드를 이용하자

        ㆍ이 소스코드에서 list(queue)를 하면 리스트 자료형으로 반환된다.

     

    재귀 함수

    - DFS와 BFS를 구현하려면 재귀 함수도 이해하고 있어야 한다.

    - 재귀 함수(Recursive Function)란 자기 자신을 다시 호출하는 함수를 의미한다.

    def recursive_function():
        print('재귀 함수를 호출합니다.')
        recursive_function()
        
        
    recursive_function()

    - 어느 정도 출력 후 다음과 같은 오류메시지 출력

    RecursionError: maximum recursion depth exceeded while pickling an object

    - 이 오류 메시지는 재귀(Recursion)의 최대 깊이를 초과했다는 내용이다.

    - 보통 파이썬 인터프리터는 호출 횟수 제한이 있는데 이 한계를 벗어났기 때문이다.

    - 따라서 무한대로 재귀 호출을 진행할 수 없다. ( 애초에 무한한 재귀 호출을 요구하는 문제 또한 출제되지 않을 것이다. )

    - 재귀 함수는 수학 시간에 한 번씩 언급되는 프랙털(Fractal)구조와 흡사하다.

     

    * 재귀 함수의 종료 조건 *

    - 재귀 함수를 문제 풀이에서 사용할 때는 재귀 함수가 언제 끝날지, 종료 조건을 꼭 명시해야 한다.

    def recursive_function(i):
        # 100번째 출력했을 때 종료되도록 종료 조건 명시
        if i == 100:
            return
        print(i, '번째 재귀 함수에서', i+1, '번째 재귀 함수를 호출합니다.')
        recursive_function(i+1)
        print(i, '번째 재귀 함수를 종료합니다.')
    
    recursive_function(1)

    - 컴퓨터 내부에서 재귀 함수의 수행은 스택 자료구조를 이용한다.

    - 함수를 계속 호출했을 때 가장 마지막에 호출한 함수가 먼저 수행을 끝내야 그 앞의 함수 호출이 종료되기 떄문이다.

    - 컴퓨터의 구조 측면에서 보자면 연속해서 호출되는 함수는 메인 메모리의 스택 공간에 적재되므로 재귀 함수는 스택 자료구조와 같다는 말은 틀린 말이 아니다.

    - 따라서 스택 자료구조를 활용해야 하는 상당수 알고리즘은 재귀 함수를 이용해서 간편하게 구현될 수 있다.

    - DFS가 대표적인 예이다.

    - 재귀 함수를 이용하는 대표적인 예제로는 팩토리얼(Factorial) 문제가 있다.

    # 반복적으로 구현한 n!
    def factorial_iterative(n):
        result = 1
        # 1부터 n까지의 수를 차례대로 곱하기
        for i in range(1, n+1):
            result *= i
        return result
        
    # 재귀적으로 구현한 n!
    def factorial_recursive(n):
        if n <= 1:	# n이 1 이하인 경우 1을 반환
            return 1
        # n! = n * (n-1)! 를 그대로 코드로 작성하기
        return n * factorial_recursive(n - 1)
        
        
    # 각각의 방식으로 구현한 n! 출력(n=5)
    print('반복적으로 구현:', factorial_iterative(5))	# 120
    print('재귀적으로 구현:', factorial_recursive(5))	# 120

    - 실행 결과는 동일하다. 그렇다면 반복문 대신에 재귀 함수를 사용했을 때 얻을 수 있는 장점이 무엇일까?

    - 위의 코드를 비교했을 때 재귀 함수의 코드가 더 간결한 것을 알 수 있다.

    - 이렇게 간결해진 이유는 재귀 함수가 수학의 점화식(재귀식)을 그대로 소스코드로 옮겼기 때문이다.

    - 수학에서 점화식은 특정한 함수를 자신보다 더 작은 변수에 대한 함수와의 관계로 표현한 것을 의미한다.

     

    2. 탐색 알고리즘 DFS/BFS

    DFS

    - DFS는 Depth-First Search, 깊이 우선 탐색이라고도 부르며, 그래프에서 깊은 부분을 우선적으로 탐색하는 알고리즘이다.

     

    - 그래프는 노드(Node)와 간선(Edge)으로 표현되며 이때 노드를 정점(Vertex)이라고도 말한다.

    - 그래프 탐색이란 하나의 노드를 시작으로 다수의 노드를 방문하는 것을 말한다.

    - 또한 두 노드가 간선으로 연결되어 있다면 '두 노드는 인접하다(Adjacent)'라고 표현한다.

    - 프로그래밍에서 그래프는 크게 2가지 방식으로 표현할 수 있다.

        ㆍ인접 행렬(Adjacency Matrix) : 2차원 배열로 그래프의 연결관계를 표현하는 방식

        ㆍ인접 리스트(Adjacency List) : 리스트로 그래프의 연결 관계를 표현하는 방식

      0 1 2
    0 0 7 5
    1 7 0 무한
    2 5 무한 0

     

    - 인접행렬 방식은 2차원 배열에 각 노드가 연결된 형태를 기록하는 방식이다.

    - 연결이 되어 있지 않는 노드끼리는 무한(Infinity)의 비용이라고 작성한다.

    - 실제 코드에서 논리적으로 정답이 될 수 없는 큰 값 중에서 99999999, 987654321 등의 값으로 초기화하는 경우가 많다.

    INF = 999999999	# 무한의 비용 선언
    
    # 2차원 리스트를 이용해 인접 행렬 표현
    graph = [
        [0, 7, 5],
        [7, 0, INF],
        [5, INF, 0]
    ]
    
    
    print(graph)	# [[0,7,5], [7,0,999999999], [5,999999999,0]]

     

    - 인접 리스트 방식에서는 모든 노드에 연결된 노드에 대한 정보를 차례대로 연결하여 저장한다.

    - 인접 리스트는 '연결 리스트' 라는 자료구조를 이용해 구현하는데, C++나 자바와 같은 프로그래밍 언어에서는 별도로 연결 리스트 기능을 위한 표준 라이브러리를 제공한다.

    - 파이썬은 기본 자료형인 리스트 자료형인 append()와 메소드를 제공하므로, 전통적인 프로그래밍 언어에서의 배열과 연결 리스트의 기능을 모두 기본으로 제공한다.

    - 파이썬으로 인접 리스트를 이용해 그래프를 표현하고자 할 때에도 단순히 2차원 리스트를 이용하면 된다는 점만 기억하자.

    # 행(Row)이 3개인 2차원 리스트로 인접 리스트 표현
    graph = [[] for _ in range(3)]
    
    # 노드 0에 연결된 노드 정보 저장(노드, 거리)
    graph[0].append((1,7))
    graph[0].append((2,5))
    
    # 노드 1에 연결된 노드 정보 저장(노드, 거리)
    graph[1].append((0,7))
    
    # 노드 2에 연결된 노드 정보 저장(노드, 거리)
    graph[2].append((0,5))
    
    print(graph)
    # [[(1,7), (2,5)], [(0,7)], [(0,5)]]

    - 이 두 방식은 어떤 차이가 있을까? 코딩 테스트를 위해 학습하는 터라 메모리와 속도 측면에서 살펴 보겠다.

    - 메모리 측면에서 보자면 인접 행렬 방식은 모든 관계를 저장하므로 노드 개수가 많을수록 메모리가 불필요하게 낭비된다.

       반면에 인접 리스트 방식은 연결된 정보만을 저장하기 때문에 메모리를 효율적으로 사용한다.

    - 하지만 이와 같은 속성 때문에 인접 리스트 방식은 인접 행렬방식에 비해 특정한 두 노드가 연결되어 있는지에 대한 정보를 얻는 속도가 느리다.

        ㆍ인접 리스트 방식에서는 연결된 데이터를 하나씩 확인해야 하기 때문이다.

     

    - 또 다른 예시로 한 그래프에서 노드 1과 노드 7이 연결되어 있는 상황을 생각해보자.

        ㆍ인접 행렬 방식에서는 graph[1][7]만 확인하면 된다.

        ㆍ반면에 인접 리스트 방식에서는 노드 1에 대한 인접 리스트를 앞에서부터 차례대로 확인해야 한다.

    - 그러므로 특정한 노드와 연결된 모든 인접 노드를 순회해야하는 경우, 인접 리스트 방식이 인접 행렬 방식에 비해 메모리 공간의 낭비가 적다.

    - DFS는 탐색을 위해서 사용되는 탐색 알고리즘이라고 했는데 구체적으로 어떻게 동작할까?

        ㆍDFS는 깊이 우선 탐색 알고리즘이라 했다.

    - 이 알고리즘은 특정한 경로로 탐색하다가 특정한 상황에서 최대한 깊숙이 들어가서 노드를 방문한 후, 다시 돌아가 다른 경로로 탐색하는 알고리즘이다.

    - DFS는 스택 자료구조를 이용하며 구체적인 동작 과정은 다음과 같다.

        ㆍ1. 탐색 시작 노드를 스택에 삽입하고 방문 처리를 한다.

        ㆍ2. 스택의 최상단 노드에 방문하지 않은 인접 노드가 있으면 그 인접 노드를 스택에 넣고 방문 처리를 한다.

                방문하지 않은 인접 노드가 없으면 스택에서 최상단 노드를 꺼낸다.

        ㆍ3. 2번의 과정을 더 이상 수행할 수 없을 때까지 반복한다.

    * Tip : '방문 처리'는 스택에 한 번 삽입되어 처리된 노드가 다시 삽입되지 않게 체크하는 것을 의미한다.

                방문 처리를 함으로써 각 노드를 한 번씩만 처리할 수 있다.

     

    * Tip : DFS의 기능을 생각하면 순서와 상관없이 처리해도 되지만, 코딩 테스트에서는 번호가 낮은 순서부터 처리하도록 명시하는 경우가 종종 있다. 따라서 관행적으로 번호가 낮은 순서부터 처리하도록 구현하는 편이다.

     

    - 깊이 우선 탐색 알고리즘인 DFS는 스택 자료구조에 기초한다는 점에서 구현이 간단하다.

    - 실제로는 스택을 쓰지 않아도 되며 탐색을 수행함에 있어서 데이터의 개수가 N개인 경우 O(N)의 시간이 소요된다는 특징이 있다.

    - 또한 DFS는 스택을 이용하는 알고리즘이기 때문에 실제 구현은 재귀 함수를 이용했을 때 매우 간결하게 구현할 수 있다.

    # DFS 메서드 정의
    def dfs(graph, v, visited):
        # 현재 노드를 방문 처리
        visited[v] = True
        print(v, end=' ')
        # 현재 노드와 연결된 다른 노드를 재귀적으로 방문
        for i in graph[v]:
            if not visited[i]:
                dfs(graph, i, visited)
                
    # 각 노드가 연결된 정보를 리스트 자료형으로 표현(2차원 리스트)
    graph = [
        [],
        [2,3,8],
        [1,7],
        [1,4,5],
        [3,5],
        [3,4],
        [7],
        [2,6,8],
        [1,7]
    ]
    
    # 각 노드가 방문된 정보를 리스트 자료형으로 표현(1차원 리스트)
    visited = [False] * 9
    
    # 정의된 DFS 함수 호출
    dfs(graph, 1, visited)
    
    
    # 1 2 7 6 8 3 4 5

     

    BFS

    - BFS(Breadth First Search) 알고리즘은 '너비 우선 탐색' 이라는 의미를 가진다. 

    - 쉽게 말해 가까운 노드부터 탐색하는 알고리즘이다.

        ㆍDFS는 최대한 멀리 있는 노드를 우선으로 탐색하는 방식으로 동작한다고 했는데, BFS는 그 반대다.

    - 그렇다면 BFS는 실제로 어떻게 구현할 수 있을까?

        ㆍBFS 구현에서는 선입선출 방식인 큐 자료구조를 이용하는 것이 정석이다.

    - 인접한 노드를 반복적으로 큐에 넣도록 알고리즘을 작성하면 자연스럽게 먼저 들어온 것이 먼저 나가게 되어, 가까운 노드부터 탐색을 진행하게 된다.

    - 알고리즘의 정확한 동작 방식은 다음과 같다.

        ㆍ1. 탐색  시작 노드를 큐에 삽입하고 방문 처리를 한다.

        ㆍ2. 큐에서 노드를 꺼내 해당 노드의 인접 노드 중에서 방문하지 않은 노드를 모두 큐에 삽입하고 방문 처리를 한다.

        ㆍ3. 2번의 과정을 더 이상 수행할 수  없을 때까지 반복한다.

    - 너비 우선 탐색 알고리즘인 BFS는 큐 자료구조에 기초한다는 점에서 구현이 간단하다.

    - 실제로 구현함에 있어 앞서 언급한 대로 deque 라이브러리를 사용하는 것이 좋으며 탐색을 수행함에 있어 O(N)의 시간이 소요된다.

    - 일반적인 경우 실제 수행시간은 DFS보다 좋은 편이라는 점까지만 추가로 기억하자

     

    * Tip : 재귀 함수로 DFS를 구현하면 컴퓨터 시스템의 동작 특성상 실제 프로그램의 수행 시간은 느려질 수 있다.

        ㆍ따라서 스택 라이브러리를 이용해 시간 복잡도를 완화하는 테크닉이 필요할 때도 있다.

        ㆍ다만, 이 내용은 책의 범위를 벗어나므로,

            코딩 테스트에서는 보통 DFS보다는 BFS구현이 조금 더 빠르게 동작한다는 정도로 기억하자.  

    from collections import deque
    
    # BFS 메서드 정의
    def bfs(graph, start, visited):
        # 큐(Queue) 구현을 위해 deque 라이브러리 사용
        queue = deque([start])
        # 현재 노드를 방문 처리
        visited[start] = True
        # 큐가 빌 때까지 반복
        while queue:
            # 큐에서 하나의 원소를 뽑아 출력
            v = queue.popleft()
            print(v, end=' ')
            # 해당 원소와 연결된, 아직 방문하지 않은 원소들을 큐에 삽입
            for i in graph[v]:
                if not visited[i]:
                    queue.append(i)
                    visited[i] = True
                 
    # 각 노드가 연결된 정보를 리스트 자료형으로 표현(2차원 리스트)
    graph = [
        [],
        [2,3,8],
        [1,7],
        [1,4,5],
        [3,5],
        [3,4],
        [7],
        [2,6,8],
        [1,7]
    ]
    
    # 각 노드가 방문된 정보를 리스트 자료형으로 표현(1차원 리스트)
    visited = [False] * 9
    
    # 정의된 BFS 함수 호출
    bfs(graph, 1, visited)
    
    
    # 1 2 3 8 7 4 5 6

     

      DFS BFS
    동작원리 스택
    구현 방법 재귀 함수 이용 큐 자료구조 이용

    - 앞서 DFS와 BFS를 설명하는 데 전형적인 그래프 그림을 이용했는데 1차원 배열이나 2차원 배열 또한 그래프 형태로 생각하면 수월하게 문제를 풀 수 있다.

        ㆍ특히나 DFS와 BFS 문제 유형이 그러하다.

     

     

     

    댓글

Designed by Tistory.