자바로 알고리즘 시작하기 5 - 완전탐색 (순열, 조합, 부분집합)
이번에는 자바를 이용한 완전탐색의 기본인 순열, 조합, 그리고 부분집합을 구현하는 기본 알고리즘에 대하여 알아보겠습니다. 이를 사용하여 기본 완전탐색을 수행할 수 있습니다.
순열
순열은 서로 다른 것 중 순서를 고려하여 나열하는 것입니다. 우리가 알고리즘에서 순열을 사용하는 경우로는, 순서를 고려하여 경우의 수를 완전 탐색할 때 사용합니다.
이는 서로 다른 것 중 선택을 할 때 중복을 허용하냐 안하냐를 먼저 확인해야 하는데, 이는 중복 검사의 유무만 체크하여 구분해주면 되니 지금은 중복이 없는 경우를 기준으로 하나하나 차근차근 코드와 함께 보겠습니다.
public class Permutation {
static int[] data;
static int N; // 총 개수
static int R; // 구하고자 하는 개수
static int[] numbers; // 순서를 고려하여 선택한 경우
public static void main(String[] args) {
/**
* given
*/
data = new int[]{1,2,3,4,5,6,7,8,9,10};
N = data.length; // 10
R = 5;
numbers = new int[R];
/**
* then
*/
permutation(0);
}
}
위 코드를 기준으로 permutation
함수를 구현해 봅시다. permutation
은 재귀를 이용한 구현이 가장 일반적입니다. (반복문이 아닌 재귀를 사용하는 이유는, 반복 횟수를 다이나믹하게 가져가면서 모든 경우를 항상 온전하게 탐색하는 걸 보장하기 위해서입니다.)
public static void permutation(int idx) {
if (idx == R) {
// 필요한 로직 수행
return;
}
top:
for (int i = 0; i < N; i++) {
// 중복 검사
for (int j = 0; j < N; j++) {
if (numbers[j] == data[i]) {
continue top; // 레이블을 사용하여 탈출
}
numbers[idx] = data[i];
permutation(idx + 1);
}
}
}
위처럼 탐색하면 가장 기본적으로 순열 완전탐색이 가능합니다. 하지만 위 로직에선 중복 검사 로직이 반복문으로 되어 있어서 매번 n번의 연산을 수행하는 것이 부담스럽습니다. 따라서 이를 visited
라는 배열을 활용하여 입력하려는 값에 대하여 중복 여부를 사전에 연산할 때 저장해두고 이를 조회하는 형식으로 하면 시간을 줄일 수 있을 겁니다. 한번 해 봅시다.
public static void permutation(int idx) {
if (idx == R) {
return;
}
for (int i = 0; i < N; i++) {
// 중복 검사
if (visited[i]) {
continue;
}
numbers[idx] = data[i];
visited[i] = true;
permutation(idx + 1);
visited[i] = false;
}
}
위 방식은 가장 일반적으로 사용되는 순열 로직이며, 대부분의 경우 위처럼 순열 로직을 구성했을 때 문제 없이 풀 수 있습니다. "배열"이라는 공간을 활용하여 수행 시간을 줄인 거고, 위에서 중복 검사를 위해 매번 모든 숫자를 돌아보는 O(N)의 수행 로직을 사용한 것보다 효율적입니다. 다만, 이 로직에서는 visited[]
배열을 관리해줘야 하며, 이는 data
의 i번째 숫자에 대하여 있고 없는 경우를 모두 탐색해야 하므로 visited[i]
를 true
해줬다가 재귀 이후에 false
해주어 모든 경우를 탐색할 수 있게끔 해줘야 합니다.
잠깐 참고로 보자면, visited
배열은 비트마스킹을 이용하면 메모리를 효율적으로 활용하면서 더욱 빠르게 수행할 수 있습니다. 이는 비트 단위 연산이 기존 원시타입을 그대로 연산하는 것 보다 더욱 빠르기 때문입니다. 메모리 활용도 또한 boolean이 1byte * N인 것에 비해 int
하나 쓰면 32bit를 각각 visited로 사용할 수 있으니 일반적인 경우에 메모리 활용도가 더 뛰어나다는 것에선 더 설명할 것도 없죠. 한번 봅시다.
public static void permutation(int idx, int visited) {
if (idx == R) {
return;
}
for (int i = 0; i < N; i++) {
if ((visited & 1 << i) != 0) {
continue;
}
numbers[idx] = data[i];
permutation(idx + 1, visited | 1 << i);
}
}
처음에는 방문한 data가 없으니 main 함수에서 permutation(0, 0); 으로 호출해서 사용하면 됩니다.
그러면 이제 swap을 이용한 방법을 알아봅시다. 이는 nPn
인 경우를 탐색할 때 가장 유용하며, 자신이 문자열 파싱만 잘 해주면 nPr
도 충분히 가능합니다. 지금까지의 방식 중에서는 가장 빠른 알고리즘입니다.
public static void permutation(int depth) {
if (depth = R) {
return;
}
for (int i = depth; i < N; i++) {
swap(depth, i, arr);
permutation(depth+1);
swap(depth, i, arr);
}
}
public static void swap(int i, int j, int[] arr) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
대체로 nPn인 경우에 사용하는 스킬인데, 이는 순서를 보장하지 않아서 순열 경우의 수를 순서 관계 없이 모두 훑는 경우에 편하게 사용 가능합니다.
그럼 딱 하나만 더 나아가서, swap을 이용한 로직 중에 next permutation이라는 알고리즘이 있습니다. 이는 nPn의 경우에 다음 순서의 순열을 알아내는 알고리즘인데, 이를 활용하여 모든 순열을 탐색할 수 있습니다. 코드로 봅시다.
public void main(int[] arr) {
Arrays.sort(arr); // 오름차순 정렬
// 정렬 이후 가장 최초의 순열 출력
System.out.println(Arrays.toString(arr));
while(!np(arr)) {
break;
}
}
public static void np(int[] arr) { // next permutation
// 배열은 반드시 오름차순으로 정렬된 상태여야 하고,
// 오름차순으로 처음 정렬했을 때의 순열은 np 사용 전에 알아서 써야 함
final int LAST_INDEX = arr.length - 1;
// 맨 뒤에서부터 배열을 거꾸로 순회하면서 내림차순이 되는 순간의 인덱스 확인
int i = LAST_INDEX;
while (i > 0 && arr[i-1] >= arr[i]) {
--i;
}
// 만약 i 인덱스가 끝까지 같으면 다음 순열 없음
if (i == 0) {
return false;
}
// 다시 뒤에서부터 배열을 거꾸로 순회하면서 내림차순 인덱스보다 큰 첫번째 수 확인
int j = LAST_INDEX;
while (arr[i-1] >= arr[j]) {
--j;
}
// 숫자 바꿔주고
swap(arr, i, j);
// 스왑한 인덱스 이후 요소들을 역정렬
int k = LAST_INDEX;
while(i <= k) {
swap(arr, i++, k--);
}
// 필요시 여기서 순열이 완성되어 있으니 로직 수행
System.out.println(Arrays.toString(arr));
return true;
}
public static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
위 알고리즘은 조금 코드가 길어보이긴 하지만, 원리를 이해하면 그다지 구성하는 게 어렵지 않고, 단순히 swap을 사용하는 것 보다 함수를 수행하는 반복수가 적어서 더 빠른 알고리즘입니다. 알고 있어서 나쁠 건 없어 보입니다.
조합
조합은 위 순열에서 순서를 고려하지 않는 경우의 수를 탐색하는 방법입니다. 그럼 바로 코드로 봐 보겠습니다.
public class Permutation {
static int[] data;
static int N; // 총 개수
static int R; // 구하고자 하는 개수
static int[] numbers; // 조합
public static void main(String[] args) {
/**
* given
*/
data = new int[]{1,2,3,4,5,6,7,8,9,10};
N = data.length; // 10
R = 5;
numbers = new int[R];
/**
* then
*/
combination(0, 0);
}
}
위 코드를 기준으로 combination()
메서드를 짜 봅시다.
public static void combination(int start, int cnt) {
if (cnt == R) {
return;
}
for (int i = start; i < N; i++) {
numbers[cnt] = data[i];
combination(i + 1, cnt + 1);
}
}
고르기 시작하는 숫자의 인덱스를 start
, 그리고 현재 몇 개나 골랐는지를 cnt
라는 변수로 관리하여 중복 없이 숫자를 모두 담아낼 수 있습니다. 조합도 여러 방법이 있겠지만 이게 범용적으로, 그리고 효율적으로 사용되는 알고리즘이니 이 로직 하나만 잘 기억해두면 됩니다.
부분집합
부분집합은 일종의 조합을 단순화한 로직입니다. 조합을 이용한 숫자를 그대로 활용하는 게 아니라 포함 여부만 체크할 경우에는 데이터 조합을 구성해둘 필요가 없습니다. 따라서 포함 여부만 체크해둔 뒤 활용할 거라면 조합을 저장해두기보단 부분집합인지를 체크해두고 사용할 수 있습니다. 이를 구현하면 아래와 같습니다.
public static void powerSet(int cnt) {
if (cnt == N) {
// 필요 작업 수행 (예시 : 출력)
for (int i = 0; i < n; i++) {
if (visited[i] == true)
System.out.print(arr[i] + " ");
}
System.out.println();
return;
}
visited[cnt] = true;
powerSet(cnt + 1);
visited[cnt] = false;
powerSet(cnt + 1);
}
이는 비트로 구성하면 아래와 같이 더 단순화할 수 있습니다.
public static void bit(int n) {
int end = 1 << n;
for (int i = 0; i < end; i++) {
for (int j = 0; j < n; j++) {
if ((i & 1 << j) != 0) {
// 필요 작업 수행 (예시 : 출력)
System.out.print(arr[j] + " ");
}
}
System.out.println();
}
}
이렇게 자바를 이용한 순열, 조합, 부분집합을 이용한 완전탐색에 사용되는 알고리즘의 기본을 이해해 봤습니다. 관련된 추천 문제를 몇개 남기면서 글을 마무리 하겠습니다.