알고리즘 문제를 풀 때에나 혹은 실무에서도 종종 데이터 내에서 순차적으로 대소 비교를 해야하는 경우는 있다.

이런 문제를 풀 때에는 디자인 적으로 단일 값에 대해 스트림 내에서 가장 가까운 최솟값 / 최댓값을 찾는 것 뿐 아니라 전체 데이터에서 그 다음 나오는 가장 가까운 작은값 / 큰값을 찾아야할 때가 있게 된다.

 

가령 다음과 같은 배열이 있다고 해보자.

 

이 때 스트림 내에서 각 배열의 요소들에 대해 다음에 나오는(가장 가까운) 작은 값은 다음과 같이 매핑된다

위의 배열에서는 가장 가까운 작은값이 없다면 -1 이라고 표시했다. 

배열 내에서 단일 요소에 대해서라면 가장 가까운 작은 값을 구하는건 어려운 일이 아니다.

하지만 전체 배열, 실무에서라면 전체 데이터 셋에서 가장 가까운 작은값 / 큰값을 찾는 데에는 시간 복잡도를 생각해야되게 된다.

 

가장 간단히 백트래킹으로 한다면 이는 O(n^2) 의 시간 복잡도로 해결할 수가 있다.

즉, 위의 그림에 색깔별 화살표 모양으로 나타난대로 각 요소별로 배열을 전체 순회하면서 나오는 첫번째 상대값을 찾아 배열에 저장하면 되는 것이다.

 

하지만 늘 그렇듯 O(n^2) 의 알고리즘은 데이터 사이즈가 커질수록 부담스러운 알고리즘이 된다.

 

스택을 이용한다면 이에 대해서 O(n) 의 시간복잡도를 갖는 최적 알고리즘을 설계할 수가 있다.

알고리즘은 다음과 같이 동작한다.

왼쪽에서 오른쪽으로 훑으면서 가장 가까운 작은/큰 값을 찾아야하기 때문에 반대로 오른쪽에서 왼쪽으로 스위핑하는 알고리즘을 설계하고, 각 요소를 스택에 담는다.

각 요소들을 검사하면서 스택에서 조건에 만족하지 않는 요소들은 Pop 해주고, 자기 자신을 Push 해준다.

다음 코드를 참고해본다.

public class Solution {
    private int[] nearestSmallerToRight(int[] nums) {
        // [4,5,2,10,8]
        // [2,2,-1,8,-1]
        int[] solve = new int[nums.length];
        Stack<Integer> stack = new Stack<>();
        for (int i = nums.length - 1; i >= 0; i--) {
            while (!stack.isEmpty() && stack.peek() >= nums[i]) {
                stack.pop();
            }
            if (stack.isEmpty()) {
                solve[i] = -1;
            } else {
                solve[i] = stack.peek();
            }
            stack.push(nums[i]);
        }
        return solve;
    }
}

이와 같은 알고리즘은 비단 데이터셋 내에서 가장 근사한 작은/큰 값을 찾는 것 뿐 아니라 조건에 맞는 근사값을 찾는 데에도 상당히 유용하게 쓸 수 있는 알고리즘이다.

 

실무에서도 생각 안하고 코딩을 할 뿐 데이터 셋에 따라 성능에 영향을 줄 수 있을만한 포인트이기 때문에 생각해보는 것이 좋다. :) 

특히 코딩 인터뷰를 준비하고 있다면, 국내에는 주로 BFS 나 DFS 와 같은 백트래킹 중심의 알고리즘이 면접에 나오지만, 외국계 기업들에서는 시간 복잡도와 밀접한 코드 설계를 물어보는 경우가 많다.

이 개념 역시 NSR / NSL 알고리즘 형태로 외국에서 알려진 테크닉이기 때문에 머릿 속에 담아두고 꺼내어 쓰면 좋을 것 같드.

 

 

다익스트라 알고리즘은 IT 개발자로 종사하는 사람이라면 모르는 사람이 없을 정도로 유명하면서도 기본적인 알고리즘이다.

 

이 알고리즘은 교통망이나 통신망과 같이 그래프로 나타내어질 수 있는 자료 구조에서 최단거리를 구할 수 있는 방법을 제시한다. 정확히는 한점으로부터 목적지까지의 최단경로트리를 만듦으로써 최단거리를 구한다.

 

다익스트라 알고리즘은 다음 아이디어에서 기반한다.

 

위와 같은 그림이 있고 간선 위의 숫자가 거리를 나타낸다고 하자.

위의 그림에서 1->4 까지 가는 최단 경로는 어떻게 직관적으로 계산할 수 있을까?

 

우리는 직관적으로 1->4 로 직접 향하는 1->4 경로의 비용(13) 보다 1->2->4 로 가는 경로의 비용(14) 이 더 크고, 1->3->4 로 가는 경로의 비용(12) 이 최적이라는 것을 알 수 있다.

우리가 직관적으로 파악할 수 있는 이 내용은, 바로 각 노드들까지의 경로는 "각경로의 최단거리의 합 중 가장 작은값" 이라는 걸 알 수 있다.

 

즉, A-B-C 로 연결된 리스트가 있을 때, A-C 로 가는 최단 경로는 반드시 A-B와 B-C 의 최단경로를 지나치게 된다는 점이다.

다익스트라 알고리즘은 이를 이용해서 최단경로를 저장하는 배열을 만들고, 노드들간의 최단경로를 계속해서 업데이트해주면서 최종적으로 출발지(src) 에서 목적지(dest) 까지의 최단경로를 만들어낸다.

 

다익스트라 알고리즘은 구현에 따라 Naive 하게 접근하는 방법과 Priority Queue 를 이용해 개선한 방법이 있다.

 

Naive 한 알고리즘은 노드의 갯수 N에 대하여 O(N^2) 만큼의 시간 복잡도를 가지며 다음과 같다.

 

	/**
	 * 다익스트라 알고리즘.
	 * @param dist 최단경로 배열
	 * @param n    노드의 갯수
	 * @param src  시작 노드 인덱스
	 * @param dest 도착 노드 인덱스
	 * @return
	 */
	private static int djikstra(int[][] dist, int n, int src, int dest) {
		int[] checkers = new int[n+1];
		int checkCount = 0;
		checkers[src] = 1;
		checkCount++;
		while (checkCount < n) {
			int minIdx = -1;
			int minDist = INF;
			//새로 갱신된 최단경로를 dist 배열에서 새로 읽어온다.
			for(int i=1; i<=n; i++) {
				if(checkers[i] == 0 && dist[src][i] < minDist) {
					minIdx = i;
					minDist = dist[src][i];
				}
			}
			if(minIdx < 0) {
				break;
			}
			checkers[minIdx] = 1;
			checkCount++;
			int nidx = minIdx;
			int ndist = minDist;
			//src에서 가장 가까운 점 nIdx 를 기점으로 최단경로 재갱신
			for (int i = 1; i <= n; i++) {
				if (dist[nidx][i] != INF && ndist + dist[nidx][i] < dist[src][i]) {
					dist[src][i] = ndist + dist[nidx][i];
				}
			}
		}
		return dist[src][dest];
	}

 

위의 소스를 Priority Queue 를 통해 개선한다면 노드의 갯수 N 과 Edge 의 갯수 E 에 대하여 O(E + NlogN) 의 시간 복잡도로 개선할 수 있다.

 

		private static int INF = 1000000000;
	
		private static class Node implements Comparable<Node> {
			int idx;
			int val;
	
			int getIdx() {
				return idx;
			}
	
			int getVal() {
				return val;
			}
	
			Node(int idx, int val) {
				this.idx = idx;
				this.val = val;
			}
	
			@Override
			public int compareTo(Node o) {
				return this.getVal() - o.getVal();
			}
		}
	
		/**
		 * 다익스트라 알고리즘.
		 * @param dist 최단경로 배열
		 * @param n    노드의 갯수
		 * @param src  시작 노드 인덱스
		 * @param dest 도착 노드 인덱스
		 * @return
		 */
		private static int djikstra(int[][] dist, int n, int src, int dest) {
			PriorityQueue<Node> priorityQueue = new PriorityQueue<>();
			for (int i = 1; i <= n; i++) {
				if (src != i && dist[src][i] < INF) {
					priorityQueue.offer(new Node(i, dist[src][i]));
				}
			}
			while (!priorityQueue.isEmpty()) {
				Node next = priorityQueue.poll();
				int nidx = next.getIdx();
				int ndist = next.getVal();
				for (int i = 1; i <= n; i++) {
					if (dist[nidx][i] != INF && ndist + dist[nidx][i] < dist[src][i]) {
						dist[src][i] = ndist + dist[nidx][i];
						priorityQueue.offer(new Node(i, dist[src][i]));
					}
				}
			}
			return dist[src][dest];
		}
	

 

가장 기본적이면서도 중요한 알고리즘의 하나이고, 여러가지 중요한 아이디어들이 알고리즘에 녹아있기 때문에 잘 알아두고 계속해서 연습해보는 것이 필요하다.

 

알고리즘은 다음 링크에서 연습해볼 수 있다.

 

https://www.acmicpc.net/problem/1916

 

 

다음읜 위의 문제에 대한 해답 코드이다.

 

import java.util.PriorityQueue;
import java.util.Scanner;

public class Main {

	private static int INF = 1000000000;

	private static class Node implements Comparable<Node> {
		int idx;
		int val;

		int getIdx() {
			return idx;
		}

		int getVal() {
			return val;
		}

		Node(int idx, int val) {
			this.idx = idx;
			this.val = val;
		}

		@Override
		public int compareTo(Node o) {
			return this.getVal() - o.getVal();
		}
	}

	private static int djikstra(int[][] dist, int n, int src, int dest) {
		PriorityQueue<Node> priorityQueue = new PriorityQueue<>();
		for (int i = 1; i <= n; i++) {
			if (src != i && dist[src][i] < INF) {
				priorityQueue.offer(new Node(i, dist[src][i]));
			}
		}
		while (!priorityQueue.isEmpty()) {
			Node next = priorityQueue.poll();
			int nidx = next.getIdx();
			int ndist = next.getVal();
			for (int i = 1; i <= n; i++) {
				if (dist[nidx][i] != INF && ndist + dist[nidx][i] < dist[src][i]) {
					dist[src][i] = ndist + dist[nidx][i];
					priorityQueue.offer(new Node(i, dist[src][i]));
				}
			}
		}
		return dist[src][dest];
	}

	public static void main(String[] args) {
		Scanner scanner = new Scanner(System.in);
		int n = scanner.nextInt();
		int m = scanner.nextInt();
		int[][] dist = new int[1001][1001];
		for (int i = 0; i < 1001; i++) {
			for (int j = 0; j < 1001; j++) {
				dist[i][j] = INF;
			}
		}
		for (int i = 0; i < m; i++) {
			int a = scanner.nextInt();
			int b = scanner.nextInt();
			int d = scanner.nextInt();
			dist[a][b] = Math.min(dist[a][b], d);
		}
		int src = scanner.nextInt();
		int dest = scanner.nextInt();
		System.out.println(djikstra(dist, n, src, dest));
	}
}


Counting Sort 는 O(n+k) 의 시간 복잡도와 O(n+k) 의 공간복잡도를 이용해서 정렬을 할 수 있는 알고리즘이다. 여기서 k는 가장 큰 키 값을 나타낸다.

일반적으로 "비교 기반" 의 정렬 알고리즘은 O(n log N) 이 가장 효율적인 알고리즘인 것이 수학적으로 증명되었으나, 

Counting Sort 는 비슷한 종류의 Radix Sort 와 마찬가지로 비교 기반의 알고리즘이 아니기 때문에 정렬에 있어 O(n) 의 시간 복잡도를 가질 수 있다.


단, Counting sort 는 비교 기반의 정렬 알고리즘이 아니기 때문에 다소 제약 사항이 존재한다.


Counting sort 는 간단하게 설명해서는 구별되는 키(Distinct Key) 와 해당 키를 Counting 함으로써 이루어지기 때문에, 해당 키값(일반적으로 정수)을 저장할 수 있을 만큼의 Hash table 을 요한다.


말이 Hash table 이지, 키 값을 구별할 수 있는 index table 을 counting array 형태로 사용해도 무방하다.


아래는 Counting sort에 대한 간단한 로직과 구현이다.



(1) Element 의 빈도수(Frequency)를 카운팅(Count)한다.


(2) 카운팅한 빈도수들을 누적합 형태로 정리한다.


(3) Input array 를 순회하며 해당 요소들의 카운트를 차감시키면서 Index 에 배치한다.



    public static int[] countingSort(int[] arr, int noMax) {
        int n = arr.length;
        int[] counts = new int[noMax + 1];
        int[] results = new int[n];
        for (int i = 0; i < n; i++) {
            counts[arr[i]]++;
        }
        for (int i = 1; i <= noMax; i++) {
            counts[i] += counts[i - 1];
        }
        for (int i = n - 1; i >= 0; i--) {
            results[counts[arr[i]] - 1] = arr[i];
            --counts[arr[i]];
        }
        return results;
    }



Counting Sort 는 위와 같이 Element 의 크기에 영향을 받으며, 만약 정렬하고자 하는 요소들 간에 데이터 간격이 Sparse 하다면 불필요한 메모리 사용을 야기한다.


만약 n이 작은 상황이지만 k값이 크다면 당연히 일반 비교 기반 정렬 알고리즘 보다 느리게 된다.


데이터를 잘 분석해서 사용할 만한 경우에 사용하는 것이 필요하다.




LIS는 알고리즘 문제로써도, 코드 구현으로써도 너무나도 유명한 문제이지만 최적화된 해법을 찾는 과정은 문제 해결능력에 있어 큰 도움을 준다.

개인적으로 매력있게 느끼는점은 인간이 직관적으로 쉽게 찾을 수 있는 법을 논리적으로 찾기가 쉽지 않으며, 그를 위한 수학적 방법들에 있었다.


LIS 알고리즘은 주어진 수열 내에서 가장 긴 부분 수열을 찾아내는 알고리즘이다. 

가령 다음과 같은 수열이 주어져있을 때를 생각해보자.



위와 같이 주어진 수열에서, LIS 는 10, 20, 50, 90 또는 10, 20, 40, 60 등으로 구해낼 수 있으며 가장 긴 증가하는 부분 수열의 길이는 4라는 걸 직관적으로 구할 수 있다.


하지만, 알고리즘으로 옮기려고 한다면 이 직관을 표현하는 건 쉽지 않다. 

또한 우리는 구현에 있어서 매 Index에 대해 다른 전체 Index를 검색해야하는 어마어마한 시간 복잡도에 마주하게 된다.


이는 최적화 알고리즘 기법의 한종류인 Dynamic Programming 을 이용해도 마찬가지인데, DP로 구현할 시 알고리즘은 다음과 같다.



for (int i = 0; i < n; i++) {
if (dp[i] == 0) {
dp[i] = 1;
}
for (int j = 0; j < i; j++) {
if (num[i] > num[j]) {
dp[i] = Math.max(dp[i], dp[j]+1);
}
}
}


위의 알고리즘의 경우에도 역시 시간 복잡도는 O(N^2) 이 되고, N이 커질수록 부담스러운 알고리즘이 된다.

만약 N이 10000이 넘어간다면? 그때는 눈으로 보일만큼 성능에 영향을 주는 모듈이 되게 된다.


최장 부분 수열의 길이를 구하는 데 있어서 최적의 방법은 일반적으로 O(N logN) 알고리즘으로 알려져있고, Trace 배열을 정의해서 같은 시간복잡도에 최장 부분 수열까지도 알아낼 수 있다.


공부할 당시 최장 부분 수열의 길이를 구하는 부분은 간단했으나, 최장 부분 수열 자체를 O(N logN) 시간 안에 구하는게 쉽지 않았던 기억이 있어 정리해보았다.

최적화의 핵심은 Binary Search 이고, lis 라는 별도의 배열에 내용을 담는다. 과정은 다음과 같다.


먼저, 배열의 각 요소를 순회한다. 이때, 배열의 각 요소를 lis 배열에 담는데, lis 배열에는 작은 순서대로만 들어가야한다.

그를 위해서는, lis 배열의 가장 마지막 요소(우리는 lis 배열을 증가하는 순서로 담았기 때문에 lis 배열의 최댓값이다.)와 배열의 요소를 비교하면 된다.

만약 lis 배열의 최댓값보다 크다면, 해당 요소를 lis 배열의 마지막에 추가시켜주면 된다.



하지만, 다음 요소는 lis 배열의 마지막 요소보다 작다. 이 때 Binary Search 가 필요하다. 우리는 Binary Search 를 통해 

"정렬된" LIS 배열에서 arr 요소가 들어가야할 위치를 찾을 수 있다.



예시에서 이제 LIS 배열은 다음과 같이 변한다.


이런식으로, LIS 배열을 증가하는 부분수열로 만들어 놓으면 쉽게 최장 부분수열의 길이를 O(N logN) 시간복잡도로 구할 수 있다.

하지만, 이런 구현만으로는 어떤 요소가 최장 부분 수열을 구성하는지 알 수 없다. 

가령, 다음과 같이 배열의 마지막 요소가 첫번째 요소보다 작을 경우, 이 구현을 통해 수열 자체를 구하는건 불가능하다.



따라서, lis 배열을 채울때, 어떤 항목이 참조되는지 Reference 배열을 정의하여 요소들을 Tracing 하는 테크닉을 사용한다.


-- LiS 구현(C++) --


/* Find LIS NLogN */
int arr[100001] = {0, };
int lis[100001] = {0, };
int lisCnt = 0;
int trace[100001] = {0, };
int findLIS(int n) {
for(int i=0; i<n; i++) {
if(i == 0 || arr[i] > lis[lisCnt-1]) {
trace[arr[i]] = lisCnt;
lis[lisCnt++] = arr[i];
} else {
int start = 0, end = lisCnt;
int idx = lisCnt;
while(start<end) {
int mid = (start+end)/2;
if(lis[mid] >= arr[i]) {
idx = min(idx, mid);
end = mid;
} else{
start = mid+1;
}
}
lis[idx] = arr[i];
trace[arr[i]] = idx;
}
}
//trace 배열에서 가장 나중을 꺼내면 됨.
int cur = lisCnt-1;
for(int i=n-1; i>=0; i--) {
if(trace[arr[i]] == cur) {
lis[cur] = arr[i];
cur--;
}
}
return lisCnt;
}


Trace 배열을 사용하면 위와 같이 LIS 알고리즘 사용 중 Refenrence를 저장하는 방식으로 최장 부분 수열의 추적도 가능하다.

Trace 배열에 저장된 인덱스를 끝에서부터 가져오면 최장부분순열 자체를 역순으로 구해낼 수 있다.


알고리즘의 시간복잡도는 O(N logN) 으로 DP 구현보다 효율적인 시간복잡도로 작동하게 된다.

이 방법 외에도 Segment Tree 등을 이용해 최적화된 구현이 가능하다.



자료구조 Disjoint Set 을 구현하기 위한 알고리즘 혹은 Union Find 알고리즘이라 부르며, 원소들간의 집합을 분류하는 데 사용하는 자료구조이다. 흔히 Minimum Spanning Tree 를 구하는 데 있어서 핵심 자료구조로 많이 사용된다.


우리나라 말로는 서로소 집합 알고리즘 혹은 합집합 알고리즘으로 번역이 되는데, 어떤 의미로든 각 요소들에 대한 집합을 만드는 알고리즘으로 이해하면 쉽다.


주로 알고리즘 문제를 해결할 시, 일정한 기준이 주어지면 그 기준을 토대로 노드들에 대한 집합을 구성해서, 정보를 얻어내는 데 사용된다.


자료구조의 핵심적인 연산으로 Union 연산과 Find 연산이 제공된다.


Union : 두개의 집합을 합치는 연산


Find : 원소가 속한 집합을 반환하는 연산. 함수는 원소가 포함된 집합의 대푯값을 반환해야 한다.


그 외의 연산으로 MakeSet 이라는 특정한 원소를 갖는 집합을 만드는 연산이 있다.


위의 연산들을 이용해서 데이터 간의 파티션을 분류하고, 나뉘어진 파티션을 이용해 정보를 유도해내거나 최적화된 솔루션을 찾는데 이용된다.


연산은 배열 혹은 트리로 구현될 수 있다.


먼저 배열을 통해 구현할 경우 알고리즘은 다음과 같이 동작한다.


1. Find(i) : Array[i] 라는 방식으로 직접 i번째 요소의 메모리에 접근할 수 있다. i번째 요소가 대푯값을 갖고 있으면 O(1) 의 시간복잡도로 구할 수 있다.


2. Union(i, j) : i번째 요소와 j번째 요소를 합친다. 편의상 j번째 요소를 i번째 요소에 합칠 경우, 배열을 전부 순회하여 j번째 요소의 대푯값 J를 대푯값으로 갖는 요소들의 대푯값을 i번째 요소의 대푯값 I로 바꾸어준다. 

O(N) 의 시간복잡도가 생긴다.


따라서 이런 배열식 구현의 경우 N개의 요소에 대해 Union 할 경우 일반적으로 복잡도를 O(NLogN) 으로 여긴다. (각 Union 연산 시 O(N) 의 복잡도가 작업을 수행할 수록 줄어들기 때문이다.)


반면 트리로 구현할 경우, 일반적인 시간복잡도는 O(logN), 아래 기술할 경로압축까지 수행할 경우의 시간복잡도는 O( ) 이 된다. 여기서   은 극도로 빠르게 감소하는 상수에 가까운 함수이다. 

(좀 더 수학적인 자료는 다음을 참고한다. https://en.wikipedia.org/wiki/Disjoint-set_data_structure)


트리로 구현하는 점이 코드도 더 간단하고 성능도 좋기 때문에 본 포스팅에서는 트리를 통한 구현을 설명한다.


기본적인 Union Find 알고리즘은 단순히 집합 A 와 집합 B 를 합칠 뿐이지만, Rank 를 부여하면 성능을 더 향상시킬 수 있다.

가령 다음 그림을 보자.



위의 그림처럼 순차적으로 Union 시, 자식노드로 합쳐진다면 이는 배열을 통한 구현과 다를게 없다. 트리의 높이가 높아지는 효과이기 때문에 O(N) 의 시간복잡도를 갖게 된다.

이를 방지하기 위해서는, Rank 의 개념을 도입하여, Rank 가 적은 트리를 높은 트리쪽에 합쳐주는 방법이 사용된다.

이렇게 두 트리를 합칠 때, 두 트리의 Rank 가 같을 경우에만 rank 를 증가시킴으로써, 무분별하게 tree 가 선형으로 합쳐지는 걸 방지하고 트리의 높이를 관리할 수 있다.

Rank 까지 사용하도록 구현하면 최악의 경우에도 시간복잡도가 O(logN) 이 된다.


여기서 좀 더 나아가 노드끼리 서로 합칠 때 포인터를 바로 루트 노드를 참조하도록 하는 방법이 있다. 이를 경로압축(Path Compression) 이라 하며, 최종적으로 생성된 트리는 평평한 구조가 된다.

그리고 마침내 최적화된 O( ) 의 시간복잡도를 가진 알고리즘이 만들어진다.




위의 이론을 바탕으로 한 소스이다. 이론이 다소 어려울 수 있음에도 불구하고, C++로 작성된 다음 소스는 놀랍도록 단순하다.



struct subset{
int parent, rank;
};

subset subsets[1001];
int find(subset* set, int u)
{
if(set[u].parent != u)
set[u].parent = find(set, set[u].parent);
return set[u].parent;
}
void unite(subset* set, int u, int v)
{
u = find(set, u), v = find(set, v);
if(set[u].rank < set[v].rank)
set[u].parent = v;
else if(set[u].rank > set[v].rank)
set[v].parent = u;
else
set[v].parent = u, set[u].rank++;
}

int main() {
for(int i=0; i<5; i++) {
subsets[i].parent = i, subsets[i].rank = 0;
cout<<subsets[i].parent<<" "<<subsets[i].rank<<endl;
}
unite(subsets, 0, 1);
cout<<endl<<"After Unite 0, 1"<<endl;
for(int i=0; i<5; i++) {
cout<<subsets[i].parent<<" "<<subsets[i].rank<<endl;
}
unite(subsets, 1, 3);
cout<<endl<<"After Unite 1, 3"<<endl;
for(int i=0; i<5; i++) {
cout<<subsets[i].parent<<" "<<subsets[i].rank<<endl;
}
unite(subsets, 2, 4);
cout<<endl<<"After Unite 2, 4"<<endl;
for(int i=0; i<5; i++) {
cout<<subsets[i].parent<<" "<<subsets[i].rank<<endl;
}
}



C/C++ 에서 union 은 키워드로 관리되기 때문에, find / unite 라는 네이밍을 사용하였다. 다음은 위의 코드의 출력 결과이다.



0 0
1 0
2 0
3 0
4 0

After Unite 0, 1
0 1
0 0
2 0
3 0
4 0

After Unite 1, 3
0 1
0 0
2 0
0 0
4 0

After Unite 2, 4
0 1
0 0
2 1
0 0
2 0



각 집합이 Rank 에 따라 합쳐지고, 성공적으로 대푯값을 갱신하는 것을 볼 수 있다.


앞서 언급했듯이, MST를 구하기 위해 크루스칼 알고리즘이 이용하는 자료구조로도 사용되고, 다양한 값들을 대표값을 기준으로 분류하는데 은근히 많이 사용되는 알고리즘이다. 코딩 면접이나 알고리즘 공부를 한다면 알아두면 매우 유용하게 사용할 수 있다.




순열탐색은 알고리즘 문제풀이나 실제 코딩에서도 상당히 많이 등장하는 알고리즘의 한 종류이다.

가령 명확한 기준을 갖고 일정한 순서로 전체를 탐색해야 하는 경우, 매우 유용하게 쓰일 수 있으며, 면접에서도 종종 등장하는 알고리즘 구현 문제이다.


순열 탐색을 하는 데 있어서 방법은 많은데, 포스팅에서 다룰 유명한 알고리즘이 있고, 이 방법을 모른다면 일반적으로 DFS나 BFS 를 이용해서도 구현할 수 있다. 하지만 많은 경우에 있어서 구현이 쉽지 않을 것이다.


이번에 다룰 알고리즘은 O(N) 의 시간복잡도로, 무작위 순열의 다음 순열을 구해내는 알고리즘이다.

또한 이를 바탕으로 무작위로 배치된 전체 수열의 모든 순열의 쌍을 구해보고자 한다.


모든 수열의 쌍을 구하는 알고리즘은 무작위 순열의 다음 순열을 구해낸다면 똑같이 적용하면 되므로, 다음 순열을 구하는 Next Permutation Algorithm 부터 정리해보자. 먼저 다음과 같은 수열을 예로 들어보자.



제시된 수열 N은 무작위의 5개 숫자를 갖고 있다. 이 때, 이 순열의 다음 순열을 구하고자 하면 상당히 난감하다. 전체를 탐색하는 알고리즘이 보통 먼저 떠오를텐데, 이는 개별 순열에 대해서는 굉장히 비효율적이다. 

(해당 알고리즘을 모듈화한다고 생각해보자. 함수에서 해당 모듈을 사용할 때마다 전체를 탐색할 수는 없는 일이다!)


Next Permutation 알고리즘은 다음과 같은 Greedy 방법으로 쉽게 다음 순열을 도출해낸다.


먼저, 순열의 전체를 순회하면서(O(n)) N[i] < N[i+1] 인 가장 마지막 i 를 구해낸다. 위의 예시에서 N[i] < N[i+1] 인 i 는 2개가 있지만, 가장 뒷번호의 인덱스를 가진 i 는 2이다. 만약 이때 i가 존재하지 않는다면, 해당 순열이 가장 마지막 순열이 된다.



그 다음에는 배열의 끝부터 좀전에 구해낸 i 까지 오면서 N[j] > N[i] 인 위치, 즉 N[i] 보다 큰 가장 마지막 Element 를 찾아내고, 이 위치를 J 라고 정한다. 이때, Greedy 알고리즘이 사용되는데, 좀 전 단계에서 N[i] < N[i+1] 인 위치를 찾고 i라고 정했기 때문에, J는 무조건 존재한다.  O(n)


다음에는 N[i] 와 N[j] 를 서로 스왑(Swap)해주자. O(1)


이제 남은 단계는 하나다. i+1부터 수열의 마지막까지 Reverse 해주면 된다. Reverse 한다는건, {1, 2, 3} 이라고 저장되어 있을 때 {3, 2, 1} 로 바꾸어 주면 된다는 뜻이다.


이렇게 나온 순열이 초기 순열(2, 5, 1, 9, 7)의 다음 순열이 된다. 간단하지 않은가. 이를 Java 코드로 옮겨보았다.



    /**
     * Find Next Permutation
     *
     * @param nums
     * @return
     */
    private int[] nextPermute(int[] nums) {
        int[] copies = new int[nums.length];
        for (int i = 0; i < nums.length; i++) {
            copies[i] = nums[i];
        }
        int idx = -1;
        for (int i = 0; i < copies.length - 1; i++) {
            if (copies[i] < copies[i + 1]) {
                idx = i;
            }
        }
        if (idx < 0) {
            //Last Permutation
            return null;
        }
        for (int i = copies.length - 1; i > idx; i--) {
            if (copies[idx] < copies[i]) {
                int tmp = copies[idx];
                copies[idx] = copies[i];
                copies[i] = tmp;
                break;
            }
        }
        for (int i = idx + 1; i < (copies.length + idx + 1) / 2; i++) {
            int tmp = copies[i];
            copies[i] = copies[copies.length - (i - idx)];
            copies[copies.length - (i - idx)] = tmp;
        }
        return copies;
    }


위의 코드에서 idx가 예시의 i를 의미한다. 이를 이용하면 다음 순열을 O(N) 의 복잡도로 구할 수 있다. 이를 전체 순열 탐색에도 적용해보자.



    /**
     * Find Next Permutation
     *
     * @param nums
     * @return
     */
    private int[] nextPermute(int[] nums) {
        int[] copies = new int[nums.length];
        for (int i = 0; i < nums.length; i++) {
            copies[i] = nums[i];
        }
        int idx = -1;
        for (int i = 0; i < copies.length - 1; i++) {
            if (copies[i] < copies[i + 1]) {
                idx = i;
            }
        }
        if (idx < 0) {
            //Last Permutation
            return null;
        }
        for (int i = copies.length - 1; i > idx; i--) {
            if (copies[idx] < copies[i]) {
                int tmp = copies[idx];
                copies[idx] = copies[i];
                copies[i] = tmp;
                break;
            }
        }
        for (int i = idx + 1; i < (copies.length + idx + 1) / 2; i++) {
            int tmp = copies[i];
            copies[i] = copies[copies.length - (i - idx)];
            copies[copies.length - (i - idx)] = tmp;
        }
        return copies;
    }

    /**
     * Find All Permutation
     *
     * @param nums
     * @return
     */
    public List<List<Integer>> permute(int[] nums) {
        List<List<Integer>> permutations = new ArrayList<>();
        Arrays.sort(nums);
        int[] curArray = nums;
        while (true) {
            List<Integer> integerList = new ArrayList<>();
            for (int i = 0; i < curArray.length; i++) {
                integerList.add(curArray[i]);
            }
            permutations.add(integerList);
            int[] nextPermutation = nextPermute(curArray);
            if (nextPermutation == null) {
                break;
            }
            curArray = nextPermutation;
        }
        return permutations;
    }



이제, permute 함수를 이용하면 무작위로 전달된 수열의 전체 순열을 순서대로 뽑아낼 수 있다. 결과가 많기 때문에 예시의 결과만 확인해보자.



Permute 함수의 결과로 나온 2차원 List 를 출력한 모습이다. 초기에 제시된 순열의 다음 순열을 성공적으로 구해내는 것을 확인할 수 있다.

Permute 함수는 아니겠으나, nextPermute 함수의 Next Permutation 알고리즘이 사용하는 방식은 C++ 의 STL 이 사용하는 수도코드와 같다. 최적화된 Greedy 알고리즘으로 생각된다. 좀 더 연구가 하고 싶다면 다음 자료를 참조하자.

(https://en.wikipedia.org/wiki/Permutation#Generation_in_lexicographic_order)


순열의 순차탐색은 많이 사용하는 알고리즘이지만, 면접이나 코딩테스트와 같은 제한된 환경에서 최적화된 시간 복잡도를 요하는 바로 다음 순열을 구하기는 쉽지 않다.

어려운 알고리즘이 아니기 때문에 이해하고 외워두는 것도 좋다.




LCS 알고리즘은 흔히 알려진 DP 를 이용한 매칭 알고리즘이다.

두 수열 혹은 문자열 사이에 공통으로 존재하는 부분 문자열을 찾는 알고리즘으로 가령, 다음과 같은 예시를 봐보자.


SETAPPLE 


EATMANY


위의 예시에서 공통으로 존재하는 부분 문자열은 E-T-A 이다. 순서가 보장되면서 양쪽에 모두 존재하는 똑같은 패턴의 공통 문자열이다.

문제를 해결하기 위해서 가장 쉬운 방법은 두 문자열에 대한 Nested Loop 를 돌면서 완전탐색으로 순서를 기록하면 되지만, 이렇게 하면 매우 복잡하면서도

비효율적인 알고리즘이 된다.


그래서 흔히 알려진 알고리즘은 다음과 같은 DP 의 Memoization 테크닉을 사용한 알고리즘이다.


    public int findLCS(String a, String b) {
        int[][] dp = new int[a.length()+1][b.length()+1];
        int result = Integer.MIN_VALUE;
        for(int i=1; i<=a.length(); i++) {
            for(int j=1; j<=b.length(); j++) {
                if(a.charAt(i-1) == b.charAt(j-1)) {
                    dp[i][j] = dp[i-1][j-1] + 1;
                } else {
                    dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
                }
                result = Math.max(result, dp[i][j]);
            }
        }
        return result;
    }


위의 코드를 해석해보자.

DP에 친숙하다면 위의 코드는 그다지 어렵지 않다. 2차원배열 dp 는 두 문자열에 대한 배열을 저장한다.

즉, 다음과 같이 정의된다.


dp[i][j] => 문자열1의 i 인덱스 까지에 대한 부분문자열과 문자열2의 j 인덱스 까지에 대한 부분문자열 간의 최장 공통 부분 수열 길이를 저장.


이렇게 하면, 다음과 같은 관계가 도출된다.


문자열1의 i번째 문자와 문자열2의 j번째 문자가 같다면, 해당 위치에서의 LCS 길이는 그 전길이까지의 최장 공통 부분 수열의 길이보다 1만큼 증가한 값이 된다.


dp[i][j] = dp[i-1][j-1] + 1


반면 같지 않다면, 다음과 같이 문자열1의 i-1 번째까지의 부분 문자열과 문자열2의 j-1 번째의 부분 문자열 간의 최장 부분 길이 중 더 긴쪽이 기록 된다.


dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);


이와 같은 식을 이용해 만든 dp 배열을 표로 기록해보자.

이제 dp 배열은 부분 문자열의 길이에 대한 모든 정보를 포함하고 있다.


E A T M A N Y
S 0 0 0 0 0 0 0
E 1 1 1 1 1 1 1
T 1 1 2 2 2 2 2
A 1 2 2 2 3 3 3
P 1 2 2 2 3 3 3
P 1 2 2 2 3 3 3
L 1 2 2 2 3 3 3
E 1 2 2 2 3 3 3


오른쪽 아래에서부터 왼쪽 위까지 (두 문자열의 끝부분에서부터 시작부분까지) 반복하면서 숫자가 처음으로 작아지는 시점(역으로 생각하면 처음으로 부분 문자열 길이기 증가하는 시점이다.) 을 찾으면,

어떤 문자열이 공통 부분 문자열인지도 알 수 있다. 다음 굵게 표시된 요소들이 부분 문자열이 된다.


물론 표의 최댓값을 찾는다면 LCS 의 길이 또한 쉽게 구할 수 있다.





숫자에 대해 2의 승수(2의 제곱수)인지 확인하는 문제는 면접 시장에서 전설적으로 알려지는 유명한 문제 중 하나이다.


문제 해결에 있어서 초보자이거나, 개발 경험이 부족할 경우 의외로 코드로 옮기는데 힘들어 하는 경우도 많고,

단순한 문제임에도 불구하고 Geek 한 발상이 깃든 최적화된 코드를 유추해낼 수도 있기 때문이다.


숫자가 작을 때에는 직관적으로 숫자가 2의 제곱수인지 판단하기가 쉽지만 숫자가 커지면 쉽지 않다.

다음 수가 2의 제곱수인지 확인해보자


2097152


가장 먼저 짚어내야할 문제의 핵심은 2의 제곱수라면 2의 제곱수가 아닌 다른 수로 이루어져있지 말아야 한다.

그렇다면, 이 성질을 이용해서 숫자를 분해해보면 간단하다. 다음 알고리즘을 생각해볼 수 있다.



bool isMultipleOfTwo(int val) {
//Time Complexity : O(logN)
bool check = true;
while(val > 1) {
int rem = val % 2;
if(val % 2) {
check = false;
break;
}
val /= 2;
}
return check;
}


위의 알고리즘은 입력받은 숫자를 1이 될때까지 2로 나누어본다. 물론 중간에 2로 나누어떨어지지 않으면 숫자는 2의 제곱수가 아니기 때문에 Loop 를 벗어난다.

문제를 푸는데 익숙치 않은 일부 개발자를 제외하고는 거의 대부분의 개발자가 생각하는 알고리즘이며, 시간 복잡도는 O(logN) 이다.

그렇다면 좀 더 특별하게 풀 수 있는 방법은 있을까


컴퓨터가 숫자를 다루는 방식이 2진수라는 걸 고려하면 손쉽게 풀 수 있다. 즉, 2진수에서 2의 제곱수는 수를 구성하는 Bit가 단 한개의 1로 구성되어 있어야 한다. 따라서 다음과 같은 코드가 Optimal 이다.



bool isMultipleOfTwoOptimal(int val) {
//Time Complexity : O(1)
return val && (!(val&(val-1)));
}


이제 처음에 제시한 수의 결과를 확인해보자.



#include <iostream>

using namespace std;

bool isMultipleOfTwo(int val) {
//Time Complexity : O(logN)
bool check = true;
while(val > 1) {
int rem = val % 2;
if(val % 2) {
check = false;
break;
}
val /= 2;
}
return check;
}

bool isMultipleOfTwoOptimal(int val) {
//Time Complexity : O(1)
return val && (!(val&(val-1)));
}

void printCheck(bool check) {
if(check) {
cout<<"Multiples of Two"<<endl;
} else {
cout<<"Not multiples of Two"<<endl;
}
}

int main()
{
int val = 2097152;
bool check = isMultipleOfTwo(val);
bool checkOptimal = isMultipleOfTwoOptimal(val);
printCheck(check);
printCheck(checkOptimal);
return 0;
}


결과는 두 방식 모두 2의 제곱수임을 출력한다.



Multiples of Two
Multiples of Two


위의 문제는 Find whether the number is power of two 라는 실리콘밸리에서 흔히 알려진 면접문제이다. 고로 면접장에서 실제 볼 확률은 드물지만, 문제를 접근하는 참신한 방법은 배울 점이 많다.






 Binary Indexed Tree(이하 BIT) 는 알고리즘이라기 보다는 자료구조에 가깝지만, 흔히 기본 자료 구조라고 할 수 있는 스택, 큐와 같은 자료구조보다는 고급의 자료구조이다. 사촌 격인 Segment Tree 도 있으나 조금 더 설명하기가 어렵기 때문에 본 포스팅에서는 Binary Indexed Tree 만 소개한다.


BIT의 원래 명칭은 Fenwick Tree 이며, 구현 상 Binary Tree 를 이용하기 때문에 BIT(Binary Indexed Tree) 라고 부른다. 


이 이진트리 형태의 자료구조는 효과적으로 부분 요소의 갱신(Update) 와 조회(Select) 를 하기 위하여 만들어진 자료구조로, 많은 변경과 많은 데이터의 조회가 빈번하게 일어날 때 한번에 처리할 수 있는 간단하면서도 유용한 자료구조이다.


특히, 데이터의 일부분이 갱신되었을 때 데이터 전체의 값이 변하는 경우, 가령 데이터 전체의 총합을 구하거나 최댓값을 구하는 일 등을 가장 효과적으로 수행할 수 있는 자료구조이다.


설명을 위해 다음 배열을 준비해보았다.



준비한 배열은 음... 온라인 게임의 유저들의 점수를 나타낸 랭킹 Database라고 해보자. 6명의 유저들이지만 엎치락 뒤치락하고 있는 것을 볼 수 있다. 

이 데이터베이스에서 가장 점수가 높은 유저는 맨 앞에 있는 10점을 획득한 유저이고, 최고점은 10점이다. 최고점을 우리는 DB를 모두 뒤져 시간복잡도 O(N) 만에 최댓값을 찾아내었다. 그런데 10분뒤 상황이 바뀌었다.


갑자기 몇몇 유저의 약진에 힘입어 10점을 획득한 유저는 3등이 되었다. 이런.. 당신은 다시 최고점을 구하기 위하여 DB를 모두 뒤져 최댓값 15를 찾아내기에 이른다. 시간복잡도는 O(N)


이 구조는 효과적일까? 전혀 그렇지 않다. 심지어 실시간 게임이라면 이런식으로 관리했다간 100만명의 유저에 대해 매 Tick 마다 100만명을 전부 검사해야 한다. 물론 알고리즘의 적용 대상을 이런 종류의 실시간 서비스를 예시로 드는건 적절하지 않아보이지만, 더 최적화할 수 있는 방안은 분명히 있다.


<BIT 가 적용된 새로운 자료구조>


위의 그림은 BIT 가 적용된 새로운 자료구조를 보여준다. 위에 보이는 것 처럼 Binary Tree 를 이용한 구조는 추가적인 메모리를 사용하지만, 토너먼트 형태로 최댓값을 구해내고 있다. 또한 보면 알 수 있겠지만 범위별로 최댓값을 구하는 것도 가능하다. 토너먼트를 해당 범위에 대해서만 열면 그만이다.


 눈치챌 수 있겠지만 이 자료구조는 토너먼트 형식을 이용해서 비교해야할 대상과만 비교한다. 가령, A가 B보다 크다는게 보장이 되었을 때, C가 B보다 크다면 A와 C는 서로 비교할 필요가 없다. 전부 비교할 필요 없이 두번만에 최댓값 C를 구할 수 있는 것이다.


다음 코드는 이를 좀 더 명확히 나타내며 C로 작성된 코드지만 이해하기 어렵지 않을 것이다.



#define max(a,b) ((a)>(b)) ? (a):(b)

const int BMAX = 100000;

int bit[2*BMAX+1] = {0, };
void set(int a, int v)
{
a+=BMAX, bit[a] = v;
while(a/=2)
bit[a] = max(bit[2*a], bit[2*a+1]);
}
int get(int l, int r)
{
l+=BMAX, r+=BMAX;
int ans = 0;
while(l<=r)
{
if(l%2==1)
ans = max(ans, bit[l]), l++;
if(r%2==0)
ans = max(ans, bit[r]), r--;
l/=2, r/=2;
}
return ans;
}



 코드를 보면 추가적인 공간을 할당해, 그부분 부터 값을 채우면서 루트 노드에 최댓값을 갱신하고 있는 모습이다.

위의 코드를 이용하면 set 을 통해 인덱스 a에 값 v를 저장하여 펜윅트리를 구성할 수 있고, get 함수를 통해 l 부터 r 까지 범위 내에 있는 최댓값을 어렵지 않게 구해낼 수 있다.


물론 시간복잡도는 set 하는 부분에서 O(logN) 이 소모되지만 get 하는 부분에서 O(logN) 의 효율성을 보인다.

기존에 최댓값을 구하려면 범위 내 모든 요소를 검사해야 했던 O(N) 의 자료구조보다 향상된 범위 검색(Select) 퍼포먼스를 낼 수 있는 것이다. 물론 이를 위해 값의 갱신(Update) 에 O(1) 이 아닌 O(logN) 의 비용을 지불하지만 이는 어떤 종류의 데이터들에 대해 훨씬 효율적일 수 있다.


이번에 포스팅 한 BIT의 경우 Single Update (포인트 갱신) & Range Query (범위 검색) 이지만, 이를 좀 더 수학적으로 개선하면 Range Update (범위 갱신) & Range Query (범위 조회) 에 있어서도 구현이 가능한 막강한 자료구조를 만들어 낼 수도 있다. 이는 Segment Tree 포스팅 이후에 별도로 포스팅을 할 예정이다. (내용이 어렵다 ㅜ)




+ Recent posts