리트코드 78: 재귀와 백트래킹을 활용한 부분 집합 문제 풀이

부분 집합(Subsets) 문제는 주어진 정수 배열의 모든 가능한 조합(멱집합, Power Set)을 찾는 알고리즘 문제입니다. 재귀적 사고를 통해 문제를 작은 단위로 분해하고, 결정 트리(Decision Tree)를 구축하여 해결하는 과정은 백트래킹의 기초를 다지는 데 매우 효과적입니다.

1. 문제 핵심 이해하기

리트코드 78번 문제는 정수로 이루어진 배열 nums가 주어졌을 때, 해당 배열로 만들 수 있는 모든 부분 집합을 반환할 것을 요구합니다. 결과에는 중복된 부분 집합이 없어야 하며, 공집합을 포함해야 합니다. 예를 들어, nums = [1, 2, 3]인 경우 결과는 다음과 같습니다. [[], [1], [2], [3], [1, 2], [1, 3], [2, 3], [1, 2, 3]]

2. 결정 트리를 통한 접근 방식

각 원소에 대해 우리는 두 가지 선택을 할 수 있습니다.
  • 해당 원소를 부분 집합에 포함시킨다.
  • 해당 원소를 부분 집합에 포함시키지 않는다.
이 과정을 트라이(Tree) 구조로 시각화하면, 배열의 각 인덱스마다 두 개의 가지가 뻗어나가는 이진 트리 형태가 됩니다. 트리의 높이는 배열의 길이와 같으며, 모든 리프 노드가 하나의 유효한 부분 집합이 됩니다.
                [ ] (시작)
              /     \
          [1] 추가    [1] 제외
           /   \        /   \
        [1,2]  [1]    [2]   [ ]

3. 구현 방법 1: 이진 선택 DFS

이 방식은 배열의 각 인덱스를 순차적으로 방문하면서, 현재 요소를 선택하는 경로와 선택하지 않는 경로를 각각 재귀적으로 탐색합니다.

class Solution {
private:
    vector result;
    vector<int> currentSubset;

public:
    vector subsets(vector<int>& nums) {
        explore(nums, 0);
        return result;
    }

    void explore(vector<int>& nums, int index) {
        // 기저 사례: 모든 원소를 검토했을 때
        if (index == nums.size()) {
            result.push_back(currentSubset);
            return;
        }

        // 1. 현재 원소를 포함하는 경우
        currentSubset.push_back(nums[index]);
        explore(nums, index + 1);

        // 2. 백트래킹: 현재 원소를 제거하고 포함하지 않는 경우 탐색
        currentSubset.pop_back();
        explore(nums, index + 1);
    }
};
이 코드는 명시적으로 '선택'과 '비선택'을 나누어 처리하므로 논리 구조가 매우 직관적입니다.

4. 구현 방법 2: 루프 기반의 조합 백트래킹

두 번째 방식은 일반적인 조합(Combination) 문제를 풀 때 자주 사용되는 템플릿입니다. 재귀 함수가 호출될 때마다 현재 상태의 path를 결과 리스트에 추가하고, 반복문을 통해 현재 인덱스 이후의 요소들을 하나씩 추가하며 깊게 탐색합니다.

class Solution {
public:
    vector subsets(vector<int>& nums) {
        vector allSubsets;
        vector<int> track;
        backtrack(nums, 0, track, allSubsets);
        return allSubsets;
    }

    void backtrack(vector<int>& nums, int start, vector<int>& track, vector& allSubsets) {
        // 매 호출마다 현재까지 구성된 track은 유효한 부분 집합임
        allSubsets.push_back(track);

        for (int i = start; i < nums.size(); i++) {
            // 원소 선택
            track.push_back(nums[i]);
            // 다음 인덱스부터 다시 탐색 시작
            backtrack(nums, i + 1, track, allSubsets);
            // 원소 제거 (상태 복구)
            track.pop_back();
        }
    }
};
이 방식의 특징은 다음과 같습니다.
  • 별도의 기저 사례(Base Case) 조건문 없이 루프의 종료 조건에 의해 재귀가 자연스럽게 멈춥니다.
  • i + 1을 넘겨줌으로써 이전에 선택한 원소는 다시 고려하지 않아 중복을 방지합니다.
  • 코드가 간결하며 다양한 조합 및 순열 문제로 확장하기 용이합니다.

5. 두 방식의 비교

구분 이진 선택 DFS 루프 기반 백트래킹
핵심 논리 포함 여부에 따른 두 갈래 분기 현재 지점 이후의 모든 시작점 탐색
결과 추가 시점 재귀의 끝(리프 노드)에서 추가 매 재귀 호출 직후 추가
장점 상태 공간 트리가 명확함 조합론적 문제에 최적화된 구조
부분 집합 문제는 시간 복잡도가 O(2^n)에 해당합니다. 이는 각 원소가 포함되거나 포함되지 않는 두 가지 경우의 수를 갖기 때문입니다. 배열의 크기가 커질수록 연산량이 기하급수적으로 늘어나므로, 백트래킹을 사용할 때는 항상 데이터의 범위를 먼저 확인하는 것이 중요합니다.

태그: LeetCode backtracking dfs recursion Subsets

7월 6일 17:54에 게시됨