알고리즘 - 7. 깊이 우선 탐색(DFS)

algorithmstreesgraphsrecursiondfstraversal
By sko10/6/20254 min read

깊이 우선 탐색(DFS)을 사용해야 할 때

DFS는 모든 경로를 탐색하거나 백트래킹 전에 깊이 들어가야 할 때 사용합니다. 주로 트리 순회에 사용되지만 다음과 같은 경우에도 적용할 수 있습니다:

  • 트리 순회 (클래식 사용 사례 - 중위, 전위, 후위)
  • 경로 찾기 (노드 간 경로 존재 확인)
  • 연결된 구성요소 (그리드에서 섬 찾기)
  • 사이클 감지 (그래프에서 루프 감지)
  • 위상 정렬 (의존성 순서 정하기)
  • 백트래킹 문제 (순열 생성, 퍼즐 해결)
  • 백트래킹 전에 각 브랜치를 가능한 한 깊게 탐색해야 하는 모든 문제

예제 문제

파일 시스템 탐색: 폴더 구조가 주어졌을 때, 특정 확장자를 가진 모든 파일을 찾으세요. 파일 시스템은 다음과 같습니다:

root/
├── documents/
│   ├── report.pdf
│   └── notes.txt
├── code/
│   ├── main.js
│   └── utils/
│       └── helper.js
└── images/
    └── logo.png

시스템의 모든 .js 파일을 찾으세요.

: DFS는 root → documents → report.pdf → notes.txt (백트래킹) → code → main.js (발견!) → utils → helper.js (발견!) (백트래킹) → images → logo.png을 탐색합니다. 결과: [main.js, helper.js]

가장 중요한 통찰력

DFS는 다른 경로를 시도하기 전에 하나의 경로를 완전히 탐색하는 데 전념합니다 - 각 브랜치를 재귀적으로 깊게 들어가 막다른 길에 도달한 후 백트래킹하여 대안을 탐색함으로써, 어떤 경로도 놓치지 않고 모든 노드를 정확히 한 번씩 체계적으로 방문합니다.

멘탈 노트: 이 알고리즘의 천재성은 콜 스택을 자연스러운 백트래킹 메커니즘으로 사용하는 데 있습니다. 각 노드에서 단순히 "나 자신을 처리한 다음 내 자식들을 완전히 탐색한다"고 말하면 됩니다. 재귀는 막다른 길에 도달했을 때 탐색하지 않은 브랜치로 돌아가는 것을 자동으로 처리합니다.

의사 결정 프레임워크

모든 노드에서 이 간단한 순서를 따릅니다:

  1. 현재 노드 처리 (언제 어떻게 처리할지는 순회 타입에 따라 다름)
  2. 자식들을 재귀적으로 탐색 (트리의 경우 왼쪽/오른쪽, 그래프의 경우 이웃)
  3. 재귀가 백트래킹을 처리하게 함 (콜 스택을 통해 자동)

유일한 결정은 현재 노드를 언제 처리할 것인가입니다:

  • 전위: 자식을 탐색하기 전에 처리 (루트 → 왼쪽 → 오른쪽)
  • 중위: 자식들 사이에서 처리 (왼쪽 → 루트 → 오른쪽)
  • 후위: 자식을 탐색한 후에 처리 (왼쪽 → 오른쪽 → 루트)

각 순회 타입을 사용해야 할 때 (간단한 예제)

전위 순회 (루트 → 왼쪽 → 오른쪽)

필요한 경우: 자식을 처리하기 전에 부모 노드를 처리하거나 방문해야 할 때 - 일반적으로 복사본을 만들거나, 계층 구조를 출력하거나, 부모가 자식에게 필요한 정보를 포함하고 있을 때입니다.

간단한 예제: 폴더 구조 복사하기 - 폴더를 새 위치로 복사할 때 다음이 필요합니다:

  1. 먼저 부모 폴더를 생성 (루트)
  2. 그 다음 하위 폴더와 파일을 복사/생성 (자식들)

아직 존재하지 않는 폴더에 파일을 넣을 수는 없습니다!

중위 순회 (왼쪽 → 루트 → 오른쪽)

필요한 경우: 이진 검색 트리에서 정렬된 순서로 데이터를 검색해야 하거나, 노드를 자연스러운 순서대로 처리해야 할 때 - 왼쪽 서브트리에는 더 작은 값이 있고, 오른쪽 서브트리에는 더 큰 값이 있습니다.

간단한 예제: 회사 트리에서 급여별로 모든 직원 출력하기:

  • 왼쪽 브랜치 = 더 낮은 급여를 받는 직원들
  • 현재 노드 = 이 직원의 급여
  • 오른쪽 브랜치 = 더 높은 급여를 받는 직원들

중위 순회를 하면: $50k, $60k, $70k, $80k... 정렬 알고리즘 없이 완벽하게 정렬됩니다!

후위 순회 (왼쪽 → 오른쪽 → 루트)

필요한 경우: 부모를 처리하기 전에 모든 자식으로부터 정보를 처리하거나 수집해야 할 때 - 일반적으로 삭제, 크기 계산, 또는 부모가 자식의 결과에 의존할 때입니다.

간단한 예제: 컴퓨터에서 전체 폴더 크기 계산하기:

  1. 먼저 하위 폴더 A의 모든 파일 크기 구하기 (왼쪽 자식)
  2. 그 다음 하위 폴더 B의 모든 파일 크기 구하기 (오른쪽 자식)
  3. 마지막으로 그것들을 합쳐서 부모 폴더의 전체 크기 구하기

모든 내용을 측정하기 전까지는 폴더의 전체 크기를 알 수 없습니다!

코드

폴더 구조에서 파일 찾기

실제 문제를 해결하기 위한 DFS를 구현해 봅시다: 프로젝트 폴더를 검색하여 모든 JavaScript 파일을 찾기. 이 예제는 DFS가 각 폴더 브랜치를 깊게 탐색한 후 다음으로 이동하며, 경로 스택을 사용하여 현재 위치를 추적하고 필요할 때 백트래킹하는 방법을 보여줍니다.

// 실용적인 예제: 폴더 구조에서 특정 파일 찾기
// 프로젝트에서 모든 JavaScript 파일을 검색해 봅시다

class FileNode {
  constructor(name, isFolder = false) {
    this.name = name;
    this.isFolder = isFolder;
    this.children = [];
  }

  addChild(child) {
    this.children.push(child);
    return child;
  }
}

// 메인 DFS 알고리즘 - 특정 확장자를 가진 파일 찾기
function findFilesWithExtension(rootFolder, extension) {
  const foundFiles = [];
  const pathStack = [];  // 전체 파일 경로를 위한 현재 경로 추적

  function searchFolder(node) {
    // 베이스 케이스: null이면 탐색할 것이 없음
    if (node == null) {
      return;
    }

    // 현재 항목을 경로에 추가
    pathStack.push(node.name);
    const currentPath = pathStack.join('/');

    // DECISION FRAMEWORK: 이것이 우리가 찾는 것인지 확인
    const isTargetFile = !node.isFolder && node.name.endsWith(extension);
    if (isTargetFile) {
      foundFiles.push(currentPath);
    }

    // 모든 자식을 재귀적으로 탐색 (깊게 들어가기!)
    for (const child of node.children) {
      searchFolder(child);  // 이 자식을 완전히 탐색
    }

    // 백트래킹: 탐색이 끝나면 경로에서 현재 항목 제거
    pathStack.pop();
  }

  searchFolder(rootFolder);
  return foundFiles;
}

// 예제 파일 시스템 구축
const root = new FileNode('project', true);

// JavaScript 파일이 있는 src 폴더 추가
const src = root.addChild(new FileNode('src', true));
src.addChild(new FileNode('index.js'));
src.addChild(new FileNode('app.js'));

const components = src.addChild(new FileNode('components', true));
components.addChild(new FileNode('Button.js'));
components.addChild(new FileNode('Button.css'));  // JS 파일이 아님

// 마크다운 파일이 있는 docs 폴더 추가
const docs = root.addChild(new FileNode('docs', true));
docs.addChild(new FileNode('README.md'));
docs.addChild(new FileNode('guide.md'));

// 루트에 설정 파일 추가
root.addChild(new FileNode('package.json'));
root.addChild(new FileNode('config.js'));

// 모든 JavaScript 파일 찾기
const jsFiles = findFilesWithExtension(root, '.js');
console.log('JavaScript 파일을 찾았습니다:');
console.log(jsFiles);
// 출력:
// [
//   'project/src/index.js',
//   'project/src/app.js',
//   'project/src/components/Button.js',
//   'project/config.js'
// ]

// DFS의 동작을 보기 위해 하나의 경로를 추적해 봅시다:
// 1. 'project'에서 시작
// 2. 첫 번째 자식 'src'로 이동 (깊게 들어가기!)
// 3. 'src'의 첫 번째 자식 'index.js'로 이동 (더 깊게!)
// 4. 'index.js'는 자식이 없으므로 처리하고 백트래킹
// 5. 'src'의 다음 자식 'app.js'로 이동 (형제 탐색)
// 6. 'app.js'는 자식이 없으므로 처리하고 백트래킹
// 7. 'src'의 다음 자식 'components'로 이동 (새 브랜치로 들어가기)
// ... 계속

세 가지 순회 순서 시각화

각 순회 순서가 노드를 다르게 방문하는 방법을 보기 위해 간단한 트리를 사용해 봅시다:

       A
      / \
     B   C
    / \
   D   E

이 트리 구조에서 각 순회 타입은 부모를 자식에 대해 언제 처리하는지에 따라 다른 순서로 노드를 방문합니다.

class SimpleNode {
  constructor(value) {
    this.value = value;
    this.left = null;
    this.right = null;
  }
}

// 전위 순회: 자식 전에 노드 처리 (루트 → 왼쪽 → 오른쪽)
function preOrder(node) {
  if (node == null) return [];

  const result = [];

  function traverse(current) {
    if (current == null) return;

    // DECISION FRAMEWORK: 현재를 먼저 처리
    result.push(current.value);
    traverse(current.left);   // 그 다음 왼쪽으로
    traverse(current.right);  // 그 다음 오른쪽으로
  }

  traverse(node);
  return result;
}

// 중위 순회: 자식 사이에 노드 처리 (왼쪽 → 루트 → 오른쪽)
function inOrder(node) {
  if (node == null) return [];

  const result = [];

  function traverse(current) {
    if (current == null) return;

    traverse(current.left);   // 먼저 왼쪽으로
    // DECISION FRAMEWORK: 현재를 중간에 처리
    result.push(current.value);
    traverse(current.right);  // 그 다음 오른쪽으로
  }

  traverse(node);
  return result;
}

// 후위 순회: 자식 후에 노드 처리 (왼쪽 → 오른쪽 → 루트)
function postOrder(node) {
  if (node == null) return [];

  const result = [];

  function traverse(current) {
    if (current == null) return;

    traverse(current.left);   // 먼저 왼쪽으로
    traverse(current.right);  // 그 다음 오른쪽으로
    // DECISION FRAMEWORK: 현재를 마지막에 처리
    result.push(current.value);
  }

  traverse(node);
  return result;
}

// 예제 트리 구축
const tree = new SimpleNode('A');
tree.left = new SimpleNode('B');
tree.right = new SimpleNode('C');
tree.left.left = new SimpleNode('D');
tree.left.right = new SimpleNode('E');

console.log('\n순회 순서:');
console.log('전위   (루트-왼쪽-오른쪽):', preOrder(tree));   // [A, B, D, E, C]
console.log('중위   (왼쪽-루트-오른쪽):', inOrder(tree));    // [D, B, E, A, C]
console.log('후위   (왼쪽-오른쪽-루트):', postOrder(tree));  // [D, E, B, C, A]

// 각 순회의 실제 의미:
// 전위: 트리의 복사본 만들기 (자식 전에 부모 처리)
// 중위: BST에서 정렬된 값 얻기 (왼쪽 < 루트 < 오른쪽)
// 후위: 트리를 안전하게 삭제 (부모 전에 자식 삭제)

의심 버스터

일반적인 의심: "DFS가 무한 루프에 빠지거나 노드를 다시 방문하지 않을까요? 사이클이 있는 그래프가 있거나 트리가 실제로 거대하다면 메모리가 부족하지 않을까요?"

왜 DFS가 실패할 수 있다고 생각할 수 있는가

사람들이 서로 친구가 될 수 있는 (사이클을 만드는) 소셜 네트워크 탐색을 고려해보세요:

Alice → Bob → Charlie
  ↑             ↓
  └─────────────┘

다음과 같이 걱정할 수 있습니다:

  • "Alice에서 시작해서 Bob으로 가고, Charlie로 가고, 다시 Alice로 돌아오면... 영원히 갇혀버린다!"
  • "트리가 1000 레벨 깊이라면? 콜 스택이 오버플로우되지 않을까?"
  • "같은 노드를 여러 번 처리하지 않는다는 것을 어떻게 알 수 있을까?"

왜 이런 생각이 불완전한가

핵심 깨달음: DFS는 이러한 우려에 대한 두 가지 간단한 해결책을 가지고 있습니다:

  1. 사이클 처리: 방문한 노드를 추적하기 위해 방문 세트 사용
  2. 깊이 처리: 콜 스택 공간은 O(높이)이지 O(노드)가 아님

사이클이 있는 그래프를 어떻게 처리하는지 봅시다:

function dfsGraph(startNode, graph) {
  const visited = new Set();
  const result = [];

  function explore(node) {
    // 재방문 방지 - 이것이 사이클을 끊는다!
    if (visited.has(node)) {
      return;
    }

    visited.add(node);
    result.push(node);

    // 방문하지 않은 모든 이웃 탐색
    for (const neighbor of graph[node]) {
      explore(neighbor);
    }
  }

  explore(startNode);
  return result;
}

// 사이클이 있는 그래프 예제
const socialNetwork = {
  'Alice': ['Bob', 'Charlie'],
  'Bob': ['Charlie'],
  'Charlie': ['Alice']  // 사이클 생성!
};

console.log(dfsGraph('Alice', socialNetwork));
// ['Alice', 'Bob', 'Charlie'] - 무한 루프 없음!

구체적인 예제: DFS는 모든 트리 모양을 효율적으로 처리

DFS가 견고한 이유를 보기 위해 다양한 트리 모양을 추적해 봅시다:

극도로 깊은 트리 (링크드 리스트 모양):

1 → 2 → 3 → 4 → ... → 1000
  • 공간 복잡도: 콜 스택을 위한 O(1000)
  • 하지만 여전히 1000개의 노드를 모두 정확히 한 번씩 방문
  • 현대 시스템은 수천 개의 재귀 호출을 쉽게 처리

극도로 넓은 트리:

        1
   /  /  |  \  \
  2  3   4   5  6  (1000개의 자식)
  • 공간 복잡도: O(2) - 현재 경로 깊이만!
  • 자식 2를 완전히 탐색하고 백트래킹한 후 자식 3 등
  • 모든 형제를 동시에 저장할 필요 없음

균형 잡힌 트리:

       1
      / \
     2   3
    / \ / \
   4  5 6  7
  • 균형 잡힌 트리의 공간 복잡도: O(log n)
  • 재귀적 탐색을 위한 가장 효율적인 경우

왜 O(log n)인가? n개의 노드를 가진 균형 잡힌 트리에서 높이는 log₂(n)입니다. 예를 들어, 1,000,000개의 노드를 가진 균형 잡힌 트리의 높이는 약 20 레벨밖에 되지 않습니다. DFS는 현재 경로만 메모리에 유지하므로(모든 노드가 아님), 백만 개의 노드가 있어도 콜 스택은 20 프레임을 초과하지 않습니다!

수학적 증명: 공간 복잡도가 O(log n)인 이유

균형 잡힌 이진 트리에서 n개의 노드가 높이 log₂(n)을 갖는 이유를 단계별로 증명해보겠습니다:

1단계: 균형 잡힌 이진 트리의 구조 이해

  • 각 레벨 i는 최대 2^i개의 노드를 가집니다 (레벨 0은 1개, 레벨 1은 2개, 레벨 2는 4개 등)
  • 높이 h의 완전 균형 트리는 정확히 2^(h+1) - 1개의 노드를 가집니다

2단계: 노드 수(n)와 높이(h)의 관계 도출

높이 h의 균형 잡힌 트리:
- 최대 노드 = 1 + 2 + 4 + 8 + ... + 2^h
- 이것은 기하급수 = 2^(h+1) - 1

따라서: n ≤ 2^(h+1) - 1

왜 n ≤ 2^(h+1) - 1인가? 이 부등식은 높이 h인 이진 트리의 최대 노드 수를 나타냅니다:

  • 각 레벨 i는 최대 2^i개의 노드를 가질 수 있음
  • 총 노드 = 2^0 + 2^1 + 2^2 + ... + 2^h (모든 레벨의 합)
  • 기하급수 공식 사용: 합 = a(r^n - 1)/(r - 1), 여기서 a=1, r=2, n=h+1
  • 결과: n = (2^(h+1) - 1)/1 = 2^(h+1) - 1

= (등호)가 아닌 ≤ (부등호)를 사용하는 이유는 트리가 마지막 레벨에서 완전히 채워지지 않을 수 있기 때문입니다. 예를 들어, 다음 두 높이 2인 트리 모두 유효합니다:

완전한 트리 (n = 7):      불완전한 트리 (n = 4):
      1                        1
     / \                      / \
    2   3                    2   3
   / \ / \                  /
  4  5 6  7                4

도출 계속:

       n + 1 ≤ 2^(h+1)
       log₂(n + 1) ≤ h + 1
       h ≥ log₂(n + 1) - 1

h는 최소한 log₂(n)이어야 하므로, h = O(log n)

3단계: 실제 숫자로 보는 구체적인 예시

7개 노드의 트리 (완전 균형):
      1           높이 = 2
     / \          각 레벨의 노드:
    2   3         레벨 0: 1개 노드
   /|   |\        레벨 1: 2개 노드
  4 5   6 7       레벨 2: 4개 노드
                  총: 7개 노드 = 2^3 - 1
                  높이: 2 = floor(log₂(7)) ✓

1,000,000개 노드의 트리:
log₂(1,000,000) ≈ 19.93
높이 ≈ 20 레벨

1,000,000,000개 노드의 트리 (10억):
log₂(1,000,000,000) ≈ 29.9
높이 ≈ 30 레벨

4단계: DFS 공간 복잡도에 미치는 영향

DFS는 루트에서 현재 노드까지의 현재 경로만 콜 스택에 저장합니다:

DFS 순회 중 콜 스택:
[root]                    → 1 프레임 (루트에서)
[root, left]              → 2 프레임 (왼쪽 자식에서)
[root, left, left.left]   → 3 프레임 (left.left 자식에서)
...
[root, ..., leaf]         → h+1 프레임 (가장 깊은 리프에서)

최대 스택 프레임 = 높이 + 1 = 균형 트리의 경우 O(log n)

노드 수 vs 높이의 시각적 비교:

노드 수 (n)    | 높이 (h) | 필요한 스택 프레임
------------- | -------- | -----------------
7             | 2        | 3
15            | 3        | 4
31            | 4        | 5
1,023         | 9        | 10
1,048,575     | 19       | 20
1,073,741,823 | 29       | 30

10억 개가 넘는 노드에도 불구하고 30개의 스택 프레임만 필요합니다! 이것이 로그 성장의 힘입니다 - 높이는 노드 수보다 훨씬 천천히 증가합니다.

왜 콜 스택이 우리의 친구인가

DFS의 뛰어난 점은 콜 스택이 자동으로 다음을 제공한다는 것입니다:

  1. 돌아갈 위치의 메모리 - 브랜치 탐색을 마치면 스택은 정확히 어디서 중단했는지 압니다
  2. 암시적 백트래킹 - "어디로 돌아갈지" 수동으로 추적할 필요 없음
  3. 자연스러운 깊이 제한 - 스택 오버플로우는 실제로 무한 재귀를 방지하는 기능입니다

각 재귀 호출은 스택에 하나의 프레임만 추가합니다:

explore(root)           // 스택: [root]
  explore(left)         // 스택: [root, left]
    explore(left.left)  // 스택: [root, left, left.left]
    return              // 스택: [root, left] - 자동 백트래킹!
    explore(left.right) // 스택: [root, left, left.right]
    return              // 스택: [root, left]
  return                // 스택: [root]
  explore(right)        // 스택: [root, right]

보장: 모든 노드는 정확히 한 번 방문되며, 사용되는 최대 메모리는 트리의 전체 크기가 아닌 높이에 비례합니다. 그래프의 경우, 방문 세트는 노드를 두 번 처리하지 않도록 보장하여 무한 루프를 완전히 제거합니다.