부분집합 · 조합 · Greedy 이번에는 완전탐색에서 자주 사용하는 부분집합, 조합 과 현재 상황에서 가장 좋은 선택을 하는 Greedy 알고리즘 을 정리한다. 1. 부분집합 주어진 집합에서 일부 원소를 선택하여 만든 집합이다. 아무것도 선택하지 않은 공집합 도 포함한다. 원소가 N 개라면 부분집합의 개수는 2^N 개이다. 예를 들어 {A, B, C} 의 부분집합은 다음과 같다. {} {A} {B} {C} {A, B} {A, C} {B, C} {A, B, C} 재귀를 이용한 부분집합 각 원소마다 선택하거나 선택하지 않는 2가지 경우 를 만든다. Branch = 2 Level = 원소의 개수 arr = ['O', 'X'] path = [] def run(level): if level == 3: print(path) return for i in range(2): path.append(arr[i]) run(level + 1) path.pop() run(0) O 는 선택, X 는 선택하지 않는 경우이다. Binary Counting 부분집합은 이진수를 이용해서도 표현할 수 있다. 000 001 010 011 100 101 110 111 0 : 선택하지 않음 1 : 선택 예를 들어 101 이라면 {A, C} 가 선택된 것이다. arr = ['A', 'B', 'C'] n = len(arr) def get_sub(target): for i in range(n): if target & 0x1: print(arr[i], end=' ') target >>= 1 target & 0x1 로 마지막 비트가 1 인지 확인하고, target >>= 1 로 다음 비트를 확인한다. 2. 조합 서로 다른 N 개의 원소 중 R 개를 순서 없이 선택한다. 순열과 다르게 선택한 순서는 고려하지 않는다. 예를 들어 A, B, C 중 2개를 선택하면 AB AC BC 가 된다. AB 와 BA 는 같은 경우이다. 순열 : 순서가 중요하다. 조합 : 순서가 중요하지 않다. 조합 구현 조합에서는 이미 확인한 원소를 다시 확인하지 않기 위해 start 를 사용한다. arr = ['A', 'B', 'C', 'D', 'E'] path = [] def recur(cnt, start): if cnt == 3: print(*path) return for i in range(start, len(arr)): path.append(arr[i]) recur(cnt + 1, i + 1) path.pop() recur(0, 0) 핵심은 다음 재귀에서 i + 1 부터 확인하는 것이다. A 선택 → B, C, D, E 확인 B 선택 → C, D, E 확인 C 선택 → D, E 확인 이렇게 하면 AB 를 선택한 뒤 BA 를 다시 만드는 중복을 막을 수 있다. 3. 탐욕 알고리즘 Greedy 현재 상황에서 가장 좋아 보이는 선택 을 반복하는 알고리즘이다. 모든 경우를 확인하는 완전탐색과 달리 하나의 기준을 정해서 선택한다. 현재의 최선이 전체의 최선이 되는 문제에서 사용할 수 있다. 동전 문제 동전이 다음과 같이 있다고 하자. 500원 100원 50원 10원 1730원 을 최소 개수의 동전으로 거슬러 준다면 큰 동전부터 사용한다. 500원 × 3 = 1500원 100원 × 2 = 200원 10원 × 3 = 30원 총 8개 coin_list = [500, 100, 50, 10] target = 1730 cnt = 0 for coin in coin_list: possible_cnt = target // coin cnt += possible_cnt target -= coin * possible_cnt print(cnt) target // coin 으로 현재 동전을 최대 몇 개 사용할 수 있는지 구한다. Greedy가 항상 정답은 아니다 동전이 다음과 같이 있다고 하자. 70원 50원 10원 100원 을 만들어야 할 때 큰 동전부터 선택하면 70 + 10 + 10 + 10 = 4개 가 필요하다. 하지만 실제 최소 동전은 50 + 50 = 2개 이다. 따라서 Greedy는 단순히 가장 큰 값이나 가장 작은 값을 선택하는 것이 아니라, 문제에 맞는 선택 기준이 성립하는지 확인하는 것이 중요하다. 4. Greedy 대표 문제 Knapsack 정해진 무게 안에서 최대 가치를 만드는 문제이다. 예를 들어 최대 30kg 까지 담을 수 있고 다음 물건들이 있다고 하자. 물건 무게 가치 물건1 5kg 50만원 물건2 10kg 60만원 물건3 20kg 140만원 일반적인 0-1 Knapsack 에서는 물건을 통째로 넣거나 넣지 않아야 한다. 따라서 단순히 가치가 높은 물건이나 kg 당 가치가 높은 물건부터 선택한다고 해서 항상 정답이 되는 것은 아니다. Fractional Knapsack Fractional Knapsack은 물건을 필요한 만큼 잘라서 넣을 수 있는 문제 이다. 이 경우에는 가치 / 무게 즉, 단위 무게당 가치가 높은 물건부터 선택하는 Greedy 방식 을 사용할 수 있다. 0-1 Knapsack → 물건을 나눌 수 없음 Fractional Knapsack → 물건을 나눌 수 있음 활동 선택 문제 여러 개의 회의가 있을 때 서로 겹치지 않으면서 최대한 많은 회의를 선택하는 문제이다. 이 문제에서는 종료 시간이 가장 빠른 회의부터 선택한다. 진행 과정은 다음과 같다. 1. 종료 시간이 빠른 순서로 정렬한다. 2. 가장 빨리 끝나는 회의를 선택한다. 3. 선택한 회의가 끝난 뒤 시작할 수 있는 회의를 찾는다. 4. 그중 다시 가장 빨리 끝나는 회의를 선택한다. 5. 반복한다. 정리 부분집합 → 각 원소를 선택 / 선택하지 않음 → 경우의 수는 2^N Binary Counting → 0과 1로 부분집합 표현 조합 → 순서 없이 선택 → start를 이용해서 중복 방지 Greedy → 현재 가장 좋아 보이는 선택을 반복 → 선택 기준이 전체 최적해로 이어지는지 확인 0-1 Knapsack → 물건을 나눌 수 없음 Fractional Knapsack → 물건을 나눌 수 있음 → 단위 무게당 가치가 높은 순서로 선택 활동 선택 문제 → 종료 시간이 가장 빠른 활동부터 선택
스택을 처음 배우면 나중에 넣은 데이터를 먼저 꺼내는 자료구조라고 설명한다. const stack: string[] = []; stack.push("A"); stack.push("B"); const value = stack.pop(); // "B" push() 로 데이터를 쌓고 pop() 으로 가장 최근 데이터를 꺼낸다. 접시를 위로 쌓았다가 위에서부터 꺼내는 모습으로 이해하면 LIFO(Last In, First Out)의 동작을 쉽게 기억할 수 있다. 입문 단계에서는 충분한 설명이지만, 이 정의만으로는 서비스에서 언제 스택을 선택해야 하는지 판단하기 어렵다. 배열의 마지막 요소를 꺼낼 수 있다고 해서 모든 데이터가 스택이 되는 것은 아니기 때문이다. 문서 편집기의 실행 취소 기능을 생각해보자. 크리스가 제목을 수정하고, 문단을 추가한 뒤, 이미지 하나를 삭제했다. 실행 취소 버튼을 누르면 어떤 작업부터 되돌려야 할까? 작업 이름만 저장하면 원래 상태를 복구할 수 있을까? 여러 번 되돌린 뒤 새로운 내용을 입력하면 기존의 다시 실행 기록은 어떻게 처리해야 할까? 실제 서비스에서 스택은 데이터를 거꾸로 꺼내기 위한 구조가 아니라, 가장 최근의 상태 변화가 먼저 해결되어야 하는 의존 관계를 표현하는 구조다. 왜 가장 최근 작업부터 되돌려야 할까? 크리스가 메모 앱에서 다음 순서로 문서를 편집했다고 해보자. 1. 제목을 "Meeting"에서 "Project Meeting"으로 변경 2. 첫 번째 문단 추가 3. 이미지 삭제 현재 문서 상태는 세 작업이 순서대로 반영된 결과다. 여기서 첫 번째 작업인 제목 변경만 먼저 취소하면, 그 이후 작업들이 어떤 상태를 기준으로 실행되었는지 설명하기 어려워진다. 반면 마지막에 적용한 이미지 삭제부터 취소하면 그 직전 상태로 자연스럽게 돌아갈 수 있다. type EditAction = | { type: "change-title"; previousTitle: string; nextTitle: string; } | { type: "insert-paragraph"; paragraphId: string; position: number; content: string; } | { type: "delete-image"; imageId: string; position: number; source: string; }; const undoStack: EditAction[] = []; 스택에는 화면에 표시할 문서 내용 자체보다 문서를 어떻게 변경했는지를 기록한다. 이미지 삭제를 되돌리려면 이미지 ID뿐 아니라 원래 위치와 이미지 주소도 필요하다. delete-image 라는 작업 이름만 남기면 무엇을 어디에 복구해야 하는지 알 수 없다. 스택이 해결하는 핵심은 최근 항목 조회가 아니다. 여러 변경이 앞선 결과 위에 차례로 적용되었을 때, 그 의존 관계를 역순으로 해제하는 것이다. 작업 이름만 저장해서는 상태를 복구할 수 없다 다음 구현은 편집 작업의 종류만 스택에 넣는다. // Bad: 되돌리는 데 필요한 이전 상태가 없다. undoStack.push({ type: "change-title" }); 이 기록을 꺼내도 이전 제목이 무엇이었는지 알 수 없다. 서버에서 변경 이력을 다시 조회하거나 현재 문서를 추측해서 복구해야 한다. 실행 취소 기록에는 작업을 식별하는 정보뿐 아니라 반대 작업을 수행하는 데 필요한 데이터가 포함되어야 한다. // Better: 변경 전후의 값을 함께 기록한다. undoStack.push({ type: "change-title", previousTitle: document.title, nextTitle: newTitle }); document.title = newTitle; 실행 취소할 때는 가장 최근 작업을 꺼내 변경 전 값을 복원한다. function undo(document: DocumentState) { const action = undoStack.pop(); if (!action) { return document; } if (action.type === "change-title") { document.title = action.previousTitle; } return document; } 여기서 previousTitle 은 사용자의 입력이 아니라 변경 직전에 애플리케이션이 확인한 상태여야 한다. 클라이언트가 이전 제목을 함께 보내더라도 그대로 믿으면 다른 문서의 값이나 오래된 값을 복원할 수 있다. 현실의 편집 작업은 다음과 같이 코드의 흐름으로 바뀐다. 입력 사용자가 요청한 새 제목과 문서 ID 상태 검증된 현재 제목과 실행 취소 작업 스택 출력 변경된 문서와 실행 취소 가능 여부 서버나 편집기는 문서 접근 권한과 현재 상태를 먼저 확인한다. 그다음 변경 직전의 값을 스택에 기록하고 새 값을 반영한다. 입력 검증, 상태 변경, 복구 정보 저장이 하나의 작업으로 이어져야 실행 취소 결과를 신뢰할 수 있다. 실행 취소와 다시 실행에는 두 개의 스택이 필요하다 사용자는 실행 취소만 누르지 않는다. 되돌린 작업을 다시 적용하기도 한다. 이를 위해 보통 실행 취소 스택과 다시 실행 스택을 따로 관리한다. const undoStack: EditAction[] = []; const redoStack: EditAction[] = []; function undo(document: DocumentState) { const action = undoStack.pop(); if (!action) { return document; } applyReverse(document, action); redoStack.push(action); return document; } function redo(document: DocumentState) { const action = redoStack.pop(); if (!action) { return document; } apply(document, action); undoStack.push(action); return document; } 크리스가 세 번 편집한 뒤 두 번 실행 취소하면, 취소된 두 작업은 다시 실행 스택에 쌓인다. 이때 크리스가 새로운 문단을 입력하면 어떻게 해야 할까? function recordNewAction(action: EditAction) { undoStack.push(action); redoStack.length = 0; } 새로운 작업은 기존 편집 흐름에서 다른 분기를 만든다. 이전에 취소했던 작업을 그대로 다시 실행하면 크리스가 방금 만든 상태와 충돌할 수 있으므로 다시 실행 스택을 비운다. 이 규칙은 push() 와 pop() 의 사용법만으로 나오지 않는다. 사용자가 기대하는 편집 흐름을 먼저 정의한 뒤, 그 흐름과 스택의 성질을 연결한 결과다. 모든 변경 이력을 스택으로 관리할 필요는 없다 실행 취소 기록과 감사 로그는 비슷해 보이지만 목적이 다르다. 실행 취소는 현재 상태에서 가장 최근 작업을 되돌리는 기능이다. 감사 로그는 누가 언제 무엇을 변경했는지 장기간 조회하기 위한 기록이다. type AuditLog = { id: string; documentId: string; userId: string; action: string; createdAt: Date; }; 감사 로그에서는 특정 날짜의 기록을 검색하거나 여러 사용자의 작업을 시간순으로 조회해야 한다. 중간 기록을 직접 찾아야 하므로 마지막 항목만 다루는 스택으로는 충분하지 않다. 데이터베이스에 저장된 변경 이벤트나 별도의 이력 테이블이 더 잘 맞는다. 실행 취소 스택의 생명주기도 결정해야 한다. 브라우저 탭이 닫히면 사라져도 되는 로컬 편집 기록이라면 메모리에 둘 수 있다. 로그인한 사용자가 다른 기기에서도 편집을 이어가야 한다면 서버에 저장하거나 문서 버전으로 관리해야 한다. 스택을 어디에 저장할지는 자료구조보다 제품 요구사항에서 결정된다. 공동 편집에서는 범위가 더 중요하다. 모든 사용자의 작업을 하나의 전역 스택에 쌓으면 크리스가 실행 취소를 눌렀을 때 다른 사용자의 최근 작업이 사라질 수 있다. 일반적인 공동 편집기라면 사용자별 작업 범위를 구분하고, 다른 변경과 충돌하지 않는 방식으로 되돌려야 한다. 이 단계에서는 단순한 스택만으로 충분하지 않을 수 있다. 스택을 선택하기 전에 확인할 질문 가장 최근에 생성된 상태부터 처리해야 하는 이유가 분명한가? 각 항목에는 작업을 되돌리는 데 필요한 이전 상태가 포함되어 있는가? 실행 취소 후 새로운 작업이 들어오면 다시 실행 기록을 어떻게 처리할 것인가? 스택이 비어 있을 때의 동작을 정의했는가? 기록은 브라우저 세션까지만 유지하면 되는가, 서버에 저장해야 하는가? 사용자별 실행 취소와 전체 변경 이력을 구분했는가? 기록이 계속 쌓이지 않도록 최대 개수나 보관 기간을 정했는가? 최근 상태부터 풀어야 하는 흐름에 스택이 맞는다 문서 편집기의 실행 취소가 스택과 잘 맞는 이유는 마지막 작업이 가장 최근 상태를 만들었고, 그 상태부터 역순으로 해제해야 이전 상태를 안전하게 복원할 수 있기 때문이다. 이때 스택 항목에는 작업 이름뿐 아니라 복구에 필요한 데이터가 있어야 하며, 다시 실행과 새 작업의 관계도 함께 정의해야 한다. 반대로 특정 시점의 기록을 검색하거나 모든 변경을 장기간 보관하려는 목적이라면 감사 로그나 버전 이력이 더 적합하다. 스택을 선택하는 기준은 데이터를 넣고 빼는 모양이 아니라, 최근 상태를 먼저 처리해야 하는 서비스 규칙이 존재하는지다. 다음 글에서는 큐를 통해 요청이 들어온 순서와 실제 처리 순서를 어떻게 통제하는지 살펴본다.
조합적 문제 부분집합 어떤 집합의 공집합과 자기 자신을 포함한 모든 부분 구하고자 하는 어떤 집합의 원소 개수가 n일 경우 부분집합의 수 = 2^n개 집합에서 부분집합을 찾아내는 구현 방법 1. 완전 탐색 재귀호출을 이용한 완전탐색으로 부분집합을 구할 수 있음 실전 보다는 안전 탐색 학습용으로 추천하는 방법 2. Binary Counting 2진수와 비트연산을 이용해 부분집합을 구할 수 있음 모든 부분집합이 필요할 때 사용 하는 추천 방법 완전 탐색으로 부분집합 구하기 # 민철이에게는 세명의 친구가 있습니다. {MIN, CO, TIM} # 함께 영화관에 갈 수 있는 멤버를 구성하고자 합니다. # 모든 경우의 수를 출력해봅시다. 완전 탐색을 이용해 구현 O, X로 집합에 포함시킬지 말지 결정 코드 구현 Branch : 2개 Level : 3개 arr = ['O', 'X'] path = [] name = ['MIN', 'CO', 'TIM'] def run(lev): if lev == 3: print(path) return for i in range(2): path.append(arr[i]) run(lev + 1) path.pop() run(0) 실행 결과 ['O', 'O', 'O'] ['O', 'O', 'X'] ['O', 'X', 'O'] ['O', 'X', 'X'] ['X', 'O', 'O'] ['X', 'O', 'X'] ['X', 'X', 'O'] ['X', 'X', 'X'] #### 완성된 소스 코드 * 이름 출력 코드 추가 ```python arr = ['O', 'X'] path = [] name = ['MIN', 'CO', 'TIM'] def run(lev): if lev == 3: print_name() return for i in range(2): path.append(arr[i]) run(lev + 1) path.pop() run(0) # 이름 출력 함수 def print_name(): print('{', end=' ') for i in range(3): if path[i] == 'O': print(name[i], end=' ') print('}') # 출력 결과 { MIN CO TIM } { MIN CO } { MIN TIM } { MIN } { CO TIM } { CO } { TIM } { } 바이너리 카운팅 (Binary Counting) 원소 수에 해당하는 N개의 비트열을 이용해 부분집합을 표시 001 이면 부분집합 {A}를 나타냄 0번 비트가 1이므로 첫 원소인 A만 포함된 부분집합을 나타냄 110이면 부분집합 {B, C}를 나타냄 1번, 2번 비트가 1이므로, 두번째와 세 번째 원소인 B, C 가 포함된 부분집합을 나타냄 10진수 이진수 {A, B. C} 0 000 {} 1 001 {A} 2 010 {B} 3 011 {A, B} 4 100 {C} 5 101 {A, C} 6 110 {B, C} 7 111 {A, B, C} 부분닙합의 총 개수 만들 수 있는 집합의 총 개수는 2^n 이며, n=3 이기기에 총 8개의 부분집합 존재 2^n은 1<<n 공식을 이용해 빠르게 구할 수 있음 print(pow(2, 3)) print(1 << 3) # 출력 결과 8 8 부분집합 {B, C} 만드는 rhkwjd 6(0b110)에서 비트 연산을 이용해 마지막 한 자리가 1인지 0인지 검사 arr = ['A', 'B', 'C'] n = len(arr) def get_sub(tar): for i in range(n): if tar & 0x1: print(arr[i], end= '') tar >>= 1 # 검사한 한 자리를 제거 get_sub(6) #### 완성된 부분집합 코드 * get_sub(0) ~ get_sub(7) 까지 호출하여 모든 부분집합 출력 ```python arr = ['A', 'B', 'C'] n = len(arr) def get_sub(tar): for i in range(n): if tar & 0x1: print(arr[i], end=' ') tar >>= 1 # 검사한 한 자리를 제거 for tar in range(1 << n): # range(0, 8) print('{', end= ' ') get_sub(tar) print('}') # 출력 결과 {} { A } { B } { A B } { C } { A C } { B C } { A B C } 조합 (Combination) 서로 다른 n개의 원소 중 r 개를 순서 없이 골라낸 것 순열과 조합 차이 탐욕 알고리즘 Greedy (탐욕 알고리즘) 결정이 필요할 때, 현재 기준으로 가장 좋아 보이는 선택지로 결정해 답을 도출하는 알고리즘 대표적인 문제해결 기법 완전 탐색(Brute-Force) 답이 될 수 있는 모든 경우를 시도해보는 알고리즘 Greedy 결정이 필요할 때 가장 좋아보이는 선택지로 결정하는 알고리즘 DP 현재에서 가장 좋아보이는 것을 선택하는 것이 아닌, 과거의 데이터를 이용해 현재의 데이터를 만들어내는 문제해결 기법 분할 정복 큰 문제를 작은 문제로 나누어 해결하는 문제해결 기법 Knapsack 문제 활동 선택 문제
전설의 탈북인의 본명은 이사진입니다. 160cm의 키를 가지고 있고 팔을 돌리며 뛰는 특이한 달리기로 100미터를 10.5초만에 달립니다. 그리고 굉장한 근육을 가지고 있습니다. 전설의 탈북인인 이유는 막화장실 미확인 구역의 완전 북쪽 끝에서부터 확인 구역의 2번 라인 위험구역까지 온전히 걸어서 왔기 때문입니다. 이는 약 우주 407.8억개 길이로 추정되고, 이 거리를 걸으려면 810,000,000,000,000,000,000,000,000,000년 정도가 걸릴 걸로 예상됩니다. 그래도 이는 막주영보다는 적은 나이이기 때문에 막화장실 나이 2-3위 정도로 추정됩니다.