나름대로 알고리즘 문제에 대한 정리와 기록을 남기며 3월 초를 맞이하게 되었다. 알고리즘 공부를 하다보면 언젠간 이 알고리즘을 써먹는 날이 꼭 올 것만 같다. 그때 당황하지 않고, 적절하게 문제를 해결하기 위해 오늘도 복습 또 복습을 가슴 속에 새기며 알고리즘 공부를 진행해야겠다.

책에서는 C++를 이용해서 코딩하지만, 앞으로 스터디를 위해 사용하기로 한 언어가 JAVA이기 때문에 코드는 JAVA로 작성되었으며, 책의 내용을 나름대로 재구성하여 작성하였다.

먼저, 시계 맞추기에 대해서 알아보자.

4x4의 시계 4X4개의 격자 형태로 배치된 열 여섯 개의 시계가 있다. 이 시계들은 모두 12시, 3시, 6시, 혹은 9시를 가리키고 있는데, 이 시계들이 모두 12시를 가리키도록 바꾸고 싶다. 왜지?

시계의 시간을 조작하는 유일한 방법은 열 개의 스위치들을 조작하는 것으로, 각 스위치들은 모두 적게는 세 개에서 많게는 다섯 개의 시계에 연결되어 있다.

한 스위치를 누를 때마다, 해당 스위치와 연결된 시계들의 시간은 3시간씩 앞으로 움직인다고 한다. (12시일때는 3시로, 3시에는 6시로, 6시에는 9시로, 9시일 때는 12시가 된다.)

예를 들어보자.

스위치들과 그들이 연결된 시계들의 목록은 다음과 같다고 가정한다.

스위치 번호 연결된 시계들 스위치 번호 연결된 시계들
0 0,1,2 5 0,2,14,15
1 3,7,9,11 6 3,14,15
2 4,10,14,15 7 4,5,7,14,15
3 0,4,5,6,7 8 1,2,3,4,5
4 6,7,8,10,12 9 3,4,5,9,13

시계 번호는 맨 윗줄부터, 왼쪽에서 오른쪽으로 순서대로 매겨져 있다. 시계들이 현재 가리키는 시간들이 주어졌을 때 모든 시계를 12시로 돌리기 위해 최소한 스위치를 몇 번이나 눌러야 할지 계산하는 프로그램을 작성한다.

  • 시간 및 메모리 제한 프로그램은 10초 안에 실행되어야 하며, 64MB 이하의 메모리를 사용해야 한다.
  • 입력 첫 줄에는 테스트 케이스의 개수 C(C<=30)가 주어진다. 각 테스트 케이스는 한 줄에는 16개의 정수로 주어지며, 각 정수는 0번부터 15번까지 각 시계가 가리키고 있는 시간을 12,3,6,9중 하나로 표현한다.
  • 출력 각 테스트 케이스당 정수 하나를 한 줄에 출력한다. 이 정수는 시계들을 모두 12시로 돌려놓기 위해 스위치를 눌러야 할 최소 횟수여야 하며, 만약 이것이 불가능할 경우 -1을 출력해야 한다.
    입력예:
    2
    12 6 6 6 6 6 12 12 12 12 12 12 12 12 12 12
    12 9 3 12 6 6 9 3 12 9 12 9 12 12 6 6
    출력예:
    2
    9
    
  • 예제 입출력 설명 첫 번째 입력 예는 8번 스위치를 두 번 눌러서 해결할 수 있다. 두 번째 입력 예를 해결하는 방법에는 여러 가지가 있지만, 다음과 같은 순서대로 누르면 된다. 0, 1, 5, 6, 5, 6, 6, 3, 7.

문제의 이해.

이 문제에서 처음 깨달아야 할 것은 예제 입출력 설명이 유도하는 방향과는 달리 스위치를 누르는 순서는 전혀 중요하지 않다는 것이다. 두 스위치를 누르는 순서를 바꾼다고 해서 그 결과가 바뀌지 않기 때문이다.

따라서 우리가 계산해야 할 것은 각 스위치를 몇 번이나 누를 것이냐 뿐이다. 이렇게 문제를 바꾼다고 하더라도 완전 탐색을 곧장 적용할 수는 없다.

완전 탐색 알고리즘을 사용하려면 스위치를 누르는 횟수의 모든 조합을 하나하나 열거할 수 있어야 하는데, 각 스위치를 몇 번 누르는지는 상관없고 따라서 그 조합의 수는 무한하기 때문이다.

시계는 12시간이 지나면 제 자리로 돌아온다는 점을 이용하면 무한한 조합의 수를 유한하게 바꿀 수 있다. 어떤 스위치를 네 번 누르면 연결된 시계는 모두 12시간씩 앞으로 이동하니 하나도 누르지 않은 것과 다름이 없다.

따라서 어떤 스위치건 간에 최대 세 번 이상 누를 일이 없다는 것과 다름이 없다. 따라서 각 스위치를 누르는 횟수는 0에서 3 사이의 정수이다. 그렇다면, 시계 맞추기 코드를 살펴보자.

package algorithm.clock;

import java.util.*;

public class Clock {
  //상수 초기화
	private static final int INF = 9999, SWITCHES = 10, CLOCKS = 16;

	//linked[i].charAt(idx) = 'x': i번 스위치와 idx번 시계가 연결되어 있다.
	//linked[i].charAt(idx) = '.': i번 스위치와 idx번 시계가 연결되어 있지 않다.

	private static final String linked[] = {
		//0123456789012345
		"xxx.............",
		"...x...x.x.x....",
		"....x.....x...xx",
		"x...xxxx........",
		"......xxx.x.x...",
		"x.x...........xx",
		"...x..........xx",
		"....xx.x......xx",
		".xxxxx..........",
		"...xxx...x...x.."
	};

	public static void main(String[] args) {
		Scanner sc = new Scanner(System.in);
		String ret ="";

		System.out.print("테스트 케이스의 개수 C(C<=30):");

		int C = sc.nextInt();
		sc.nextLine();
		for(int i = 0; i < C; ++i) {
			System.out.print("시침 입력(16개)<3,6,9,12>:");
			String clock = sc.nextLine();
			String[]strArr = clock.split(" ");

			int clocks[] = new int[CLOCKS];

			for(int j = 0; j < CLOCKS; ++j) {
				clocks[j] = Integer.parseInt(strArr[j]);
			}
			int temp = solve(clocks, 0);
			//결과 출력용 변수
			ret += temp >= INF ? String.valueOf(String.format("%d%n",-1)) : String.valueOf(String.format("%d%n", temp));
		}
		System.out.print(ret.toString());
		sc.close();
	}

	//모든 시계가 12시를 가리키고 있는지 확인한다.     
    private static boolean areAligned(int clocks[]) {
			boolean ret = false;
			  for(int i = 0; i < clocks.length; ++i) {
				if(clocks[i] == 12)ret = true;
        //하나라도 12시를 가리키지 않는다면 바로 false를 반환한다.
				else return false;
			}
	    return ret;
		}

	//swtch번 스위치를 누른다.
	private static void push(int clocks[], int swtch) {
		//시계의 개 수(16)만큼 반복한다.
		for(int clock = 0; clock < CLOCKS; ++clock) {
			//swtch번 스위치가 해당 clock와 연결 되어 있다면
			if(linked[swtch].charAt(clock) == 'x') {
				//3을 추가한다.
				clocks[clock] += 3;
				//12을 넘어가면 3으로 초기화 한다.
				if(clocks[clock] == 15) clocks[clock] = 3;
			}
		}
	}
	//clocks: 현재 시계들의 상태
	//switch: 이번에 누를 스위치의 번호
	//위 변수가 주어질 때, 남은 스위치들을 눌러서 clocks를 12시로 맞출 수 있는 최소 횟수를 반환한다.
	private static int solve(int clocks[], int swtch) {
		//만약 불가능하다면 INF 이상의 큰 수를 반환한다.
		if(swtch == SWITCHES) return areAligned(clocks) ? 0 : INF;
		int ret = INF;
		//이 스위치를 0번 누르는 경우부터 세 번 누르는 경우까지를 모두 시도한다.
		for(int cnt = 0; cnt < 4; ++cnt) {
			ret = Math.min(ret, cnt + solve(clocks, swtch + 1));
			push(clocks, swtch);
		}
		//push(clocks, swtch)가 네 번 호출되었으니 clocks는 원래와 같은 상태가 된다.
		return ret;
	}
}
출력예:
테스트 케이스의 개수 C(C<=30):2
시침 입력(16)<3,6,9,12>:12 6 6 6 6 6 12 12 12 12 12 12 12 12 12 12
시침 입력(16)<3,6,9,12>:12 9 3 12 6 6 9 3 12 9 12 9 12 12 6 6
2
9

위 코드의 순서를 간단하게 정리하면,

1. 모든 시계가 12시를 가리키고 있는지 확인한다.
2. (2번을 만족하지 않는 경우) 0번 째 스위치부터 4번씩 스위치를 눌러(push 메소드 호출) 연결된 시계의 시침을 돌린다.
3. 2번을 10번 반복할 때까지 구한 경우의 수를 반환하고 모든 시침이 12시가 되지 못했다면, INF를 반환하여 -1을 출력한다.

시간복잡도 분석.

각 스위치를 누르는 횟수는 0에서 3 사이의 정수이다.(네번 이상이 되면 처음 가리켰던 값으로 돌아오므로) 열 개의 스위치가 있으니 전체 경우의 수는 4^10 = 1,048,576개가 된다. 따라서 위와 같은 어려운? 문제도 시간 내에 완전 탐색으로 풀이가 가능하다.

드디어, 알고리즘 문제 해결 전략 책의 재귀 호출을 통한 완전 탐색 부분이 마무리 되었다. 정리는 나름 끝났으니, 이젠 복습과 새로운 알고리즘의 배움을 통한 더 좋은 알고리즘을 선택하는 연습을 계속해서 진행해야겠다. 다음은 분할 정복에 대해 알아보는 걸로~

  • 오타나 잘못된 부분을 지적 해주시면 감사히 생각하고 수정토록 하겠습니다 :)
  • 참고문헌
    구종만, 알고리즘 문제 해결 전략. pp.168~pp.172.