Coder Social home page Coder Social logo

cs-study's Introduction

CS-study

cs지식을 정리하는 공간

Reopository Rules

Table of Contents

개발상식 🔍

  • 클린코드 & 리팩토링 & 시큐어코딩
  • 애자일
  • TDD
  • DDD
  • MSA
  • OOP
  • OOP의 5가지 설계 원칙
  • 함수형 프로그래밍
  • DevOps
  • 3rd Party
  • Git , Github, Gitlab
  • REST API
  • Parameter vs Argument
  • Sync vs Async
  • XSS
  • 도커와 쿠버네티스

알고리즘 🔍

프로그래밍 대회에서 배우는 알고리즘 문제 해결 전략(a.k.a 종만북)을 기반으로 작성한 목차입니다.

  1. 알고리즘 분석
    1. 시간 복잡도와 공간복잡도
    2. 알고리즘의 정당성 증명
  2. 알고리즘 설계 패러다임
    1. 완전 탐색
    2. 분할 정복
    3. 동적 계획법
    4. 탐욕법
    5. 조합 탐색
    6. 파라메트릭 서치
  3. 유명한 알고리즘
    1. 정수론
      1. 소수
      2. 유클리드 알고리즘
      3. 모듈라 연산
      4. 이항 계수
    2. 계산 기하
  4. 기초 자료구조
    1. 비트마스크
    2. 부분 합
    3. 선형 자료구조
    4. 큐와 스택, 데크
    5. 문자열
      1. KMP 알고리즘
    6. 해시
    7. B-Tree & B+Tree
  5. 트리
    1. 트리의 구현과 순회
    2. 이진 탐색트리
    3. 우선순위 큐와 힙
    4. 구간 트리
    5. 상호 배타적 집합
    6. 트라이
  6. 그래프
    1. 그래프의 표현과 정의
    2. [DFS
    3. BFS
    4. 최단 경로 알고리즘
      1. 다익스트라
      2. 벨만-포드
      3. 플로이드의 모든 쌍 최단 거리 알고리즘
    5. 최소 스패닝 트리
      1. 크루스칼의 최소 스패닝 트리 알고리즘
      2. 프림의 최소 스패닝 트리 알고리즘
    6. 네트워크 유량
      1. 포드-풀커슨 알고리즘
      2. 에드몬드-카프 알고리즘
      3. 이분 매칭
  7. 정렬
    1. 삽입 정렬
    2. 선택 정렬
    3. 버블 정렬
    4. 병합 정렬
    5. 힙 정렬
    6. 퀵 정렬
    7. 기수 정렬
    8. 계수 정렬
    9. 셸 정렬

데이터베이스 🔍

  1. SQL - 기초 Query
  2. SQL - JOIN
  3. SQL Injection
  4. SQL vs NoSQL
  5. Anomaly
  6. 인덱스
  7. 트랜잭션(Transaction)
  8. 트랜잭션 격리 수준
  9. 레디스
  10. 이상 현상의 종류
  11. Hint
  12. 클러스터링
  13. 리플리케이션
  14. DB 튜닝

네트워크 🔍

  1. OSI 7계층
  2. IP
    1. IPv4
    2. IPv6
  3. TCP/IP
  4. UDP
  5. 대칭키 & 공개키
  6. HTTP & HTTPS
  7. Load Balancing
  8. Blocking & Non-Blocking I/O

운영체제 🔍

  1. 운영체제란?
  2. 프로세스와 쓰레드
  3. 인터럽트
  4. 시스템 콜
  5. PCB와 Context Switching
  6. IPC
  7. CPU 스케줄링
  8. Deadlocks
  9. Race Condition
  10. 세마포어 & 뮤텍스
  11. 메모리 & 가상 메모리
  12. 파일 시스템

디자인 패턴 🔍

  • 디자인 패턴이란?
  1. 생성 패턴

    • Builder
    • Prototype
    • Factory Method
    • Abstract Factory
    • Singleton
  2. 구조 패턴

    • Bridge
    • Decorator
    • Facade
    • Flyweight
    • Proxy
    • Composite
    • Adapter
  3. 행위 패턴

    • Interpreter
    • Template Method
    • Chain of Responsibillity
    • Command
    • Iterator
    • Mediator
    • Memento
    • Observer
    • State
    • Strategy
    • Visitor

cs-study's People

Contributors

0xe82de avatar baemung avatar capo-yoonju avatar heeyoung2da avatar hongcheol avatar jslee7420 avatar khyunjiee avatar kimdabin avatar lksa4e avatar nayoon-kim avatar psi1104 avatar rhddbsghks avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar

cs-study's Issues

삽입/선택/버블 정렬 정리했습니다!

삽입 정렬

삽입 정렬(insertion sort)는 자료 배열의 모든 요소를 앞에서부터 차례대로 이미 정렬된 배열 부분과 비교하여, 자신의 위치를 찾아 삽입함으로써 정렬을 완성하는 알고리즘이다.

k 번째 반복 후 결과 배열은, 앞쪽 k+1 항목이 정렬된 상태이다.

배열이 길어질수록 효율이 떨어지지만, 구현이 간단한 정렬 방법이다.

과정

  • 31 25 12 22 11
  • 31 [25] 12 22 11
  • 25 31 [12] 22 11
  • 12 25 31 [22] 11
  • 12 22 25 31 [11]
  • 11 12 22 25 31

코드

void insertionSort(int[] arr) {
	for (int i = 1; i < arr.length; i++) {
		int temp = arr[i];
		int loc = i - 1;
		while ((loc >= 0) && (arr[loc] > temp)) {
			arr[loc+1] = arr[loc];
			loc--;
		}
		arr[loc+1] = temp;
	}
}

시간복잡도

n개의 데이터가 있을 때, 최악의 경우 (n*(n-1))/2 번 비교하므로 시간복잡도는 O(n^2)

선택 정렬

선택 정렬(selection sort)은 제자리 정렬 알고리즘의 하나로, 다음과 같은 순서로 이루어진다.

  1. 주어진 리스트 중에 최소값을 찾음
  2. 그 값을 맨 앞에 위치한 값과 교체
  3. 맨 처음 위치를 뺀 나머지 리스트를 같은 방법으로 교체

선택 정렬은 알고리즘이 단순하며 사용할 수 있는 메모리가 제한적인 경우에 사용시 성능 상의 이점이 있다.

과정

  • 9 1 6 8 4 3 2 [0]
  • 0 [1] 6 8 4 3 2 9
  • 0 1 6 8 4 3 [2] 9
  • 0 1 2 8 4 [3] 6 9
  • 0 1 2 3 [4] 8 6 9
  • 0 1 2 3 4 8 [6] 9
  • 0 1 2 3 4 6 [8] 9

코드

void selectionSort(int[] arr) {
	int indexMin, temp;

	for (int i = 0; i < arr.length-1; i++) {
		indexMin = i;
		for (int j = i+1; j < arr.length; j++) {
			if (arr[j] < arr[indexMin]) indexMin = j;
		}
		temp = arr[indexMin];
		arr[indexMin] = arr[i];
		arr[i] = temp;
	}
}

시간복잡도

최선, 평균, 최악의 경우에서 선택 정렬에 소요되는 비교 회수는 (n*(n-1))/2 이므로 O(n^2) 이다.

#버블 정렬

버블 정렬(bubble sort)은 두 인접한 원소를 검사하여 정렬하는 방법이다.

상당히 느리지만 코드가 단순하기 때문에 자주 사용된다.

양방향으로 번갈아 수행하면 칵테일 정렬이 된다.

과정

  • [55] [07] 78 12 42
  • 07 [55] [78] 12 42 → 처음 패스
  • 07 55 [78] [12] 42
  • 07 55 12 [78] [42]
  • 07 55 12 42 78
  • [07] [55] 12 42 78 → 두번째 패스
  • 07 [55] [12] 42 78
  • 07 12 [55] [42] 78
  • 07 12 42 [55] [78]
  • [07] [12] 42 55 78 → 세번째 패스
  • 07 [12] [42] 55 78 → 네번째 패스
  • 07 12 [42] [55] 78 → 다섯번째 패스
  • 07 12 42 55 78 → 정렬 완료

코드

void bubleSort(int[] arr) {
	int temp = 0;
	for (int i = 0; i < arr.length-1; i++) {
		for (int j = 1; j < arr.length-i; j++) {
			if (arr[j] < arr[j-1]) {
				temp = arr[j-1];
				arr[j-1] = arr[j];
				arr[j] = temp;
			}
		}
	}
}

시간복잡도

정렬의 여부와 상관없이 모든 배열의 요소를 계속 비교하기 때문에 O(n^2) 의 시간 복잡도를 갖는다.

파라메트릭 서치, 정수론

알고리즘 설계 패러다임

파라메트릭 서치

결정 문제란 예 아니오 형태의 답만이 나오는 문제들을 가리킨다.

어떤 문제에서 최대가 되는 k를 구하려는 최적화 문제를 결정 문제로 바꾸는 것은 다음과 같다.

1. k가 x이상일때 문제가 해결되는지?

2. 문제 해결이 안된다면 y값을 조절시켜 변경된 x이상일 때는 문제가 해결되는지?

3. x값 조절은 이분법을 사용

위 과정을 반복하면 x는 문제가 해결되는 최적의 값으로 수렴하게 된다.

이렇게 결국 원래 문제인 최대값 k를 구할 수 있다. 같은 매커니즘으로 최솟값도 찾을 수 있는데, 이러한 알고리즘을 일명 파라메트릭 서치라고 부른다.

x값 조절을 이분법으로 하기 때문에, 바이너리 서치와 매우 유사하다.

하지만 차이점이 있는데, 바이너리 서치는 찾고자하는 특정한 값과 정렬된 검색 공간의 가운데에 있는 원소를 비교하여 해당 값을 찾으면 리턴, 못 찾으면 -1을 리턴해준다.

파라메트릭 서치는 특정한 값을 찾는것이 아니라 검색 공간의 가운데 값이 해당 값이 문제를 해결할 수 있는지 판단하고 범위를 좁히는데, 파라메트릭 서치는 바이너리 서치와 다르게 특정한 목표값이 없기 때문에 중간에 리턴을 할 수가 없고 반드시 값이 수렴하기 때문에 -1을 리턴하지 않는다는 차이가 있다.

아래 문제는 파라메트릭 서치로 해결할 수 있는 가장 간단한 문제들 중 하나이다.

n개의 줄을 잘라서 길이(k)가 모두 동일한 m개의 줄로 만들 때, 잘라진 줄의 길이 k의 최대값을 구하라.

항상 n ≦ m 이며, n는 1이상 10,000이하의 정수이고, m은 1이상 1,000,000이하의 정수이다.

그리고 주어진 n개의 줄의 최대 길이는 2^31 - 1보다 작거나 같은 자연수이다.

테스트 케이스로 n = 4, m = 11

n개의 줄의 길이는 각각 802, 743, 457, 539가 주어졌다.

최대의 k을 찾는 위 최적화 문제를 k가 x라면 줄을 m개 만들 수 있나? 라는 결정문제로 바꾸어서 풀 수 있다.

1. k가 x라면 줄을 m개 만들 수 있나?

2. m개를 만들 수 없을때, x값을 줄임.

3. m개를 만들 수 있을때, x값을 늘림.

4. y+1이 m개를 만들 수 없으면 y가 k가 됨

파라메트릭 서치 문제를 풀 때 초기 정의역x를 반드시 유효한 답을 도출할 수 있도록 잡아야 한다.

parametric_search(int min, int max) 에서 일반적으로 min은 0으로 잡으면 되고 max값을 넉넉히 잡으면 좋다.

위 문제 같은 경우는 만약 n = 4, m = 4이고, n개의 줄이 800, 1, 1, 1같은 테스트 케이스를 고려하면,

k = 200이 되므로 일반적으로 max범위는 n의 줄 길이 중 최대 길이인 800로 잡으면 된다.

// 수도코드
parametric_search(min, max){
	if(min > max) return max; // 기저조건, 값이 수렴

	x = (min + max)/2; // 범위 조절, 판단을 위한 x값을 정의
	
	// 있다 -> x값을 늘림 (최적값 수렴)
	// 없다 -> x값을 줄임
	if(isPossible(x)) return parametric_search(x+1, max);
	else return parametric_search(min, x-1);
}

isPossible(x){
	// 일반적으로 결정 판단을 위한 값을 구할때, 이 문제보다 훨씬 난이도가 높고 복잡하다.
	count = 0; // 결정 판단을 위한 값
	for(rope_length : rope_list) 
		count += rope_length / x; // x로 몇개의 줄을 만들 수 있는지
	
	// m개를 만들 수 있다, 없다
	if(count >= m) retrun true;
	else return false;
}

재귀의 기저조건인 if(min > max) return max;는 문제마다 차이가 있다.

어떤 문제는 if(min > max) return min; 일수도 있기 때문에 무조건 위 조건을 따라선 안되고 해당 문제를 이해하고 알맞은 조건을 기저조건으로 설정하여야 한다.

그리고 일반적으로 min은 left 또는 start, max는 right 또는 edn, x는 pivot 또는 mid 라는 변수명을 사용한다.

지금 까지 본 문제는 파라메트릭 서치의 개념을 설명하기 위한 기초적인 문제였다면, 아래문제는 파라메트릭 서치의 응용 문제라고 할 수 있다.

응용 문제같은 경우는 애초에 파라메트릭 서치 알고리즘을 이용해서 문제를 풀어야겠다는 아이디어 자체를 떠올리기가 쉽지않다...

백준 1300번 : K번째 수

백준 12015번 : 가장 긴 증가하는 부분 수열 2

유명한 알고리즘

정수론

정수론(Number Theory)은 각종 수의 성질을 대상으로 하며 기하학, 대수학, 해석학과 함께 수학의 주요한 분야들 중 하나이다.

정수론의 현실 세계에서의 쓰임새는 다른 수학 분야에 비해 적지만, 컴퓨터가 발달되면서 사용빈도가 늘었다. 암호학의 기본 이론도 이 정수론을 기본으로 하고 있으며, 정보와 관련된 이론들도 상당 부분 정수론을 기본으로 한다.

그 이유는 컴퓨터에서 정수는 정확한 값을 가질 수 있기 때문이다. 실수형 타입의 경우에는 round off error 때문에 오차가 생기고, 이 오차는 계산을 거듭할수록 걷잡을 수 없이 커지기 때문이다.
게다가 컴퓨터에서의 수 표현은 수를 표현할 저장공간의 한계상 정수론에서 말하는 시계 산술을 사용한다.

이름은 '정수론'이지만 기초 수준에서는 정수보다는 자연수, 그중에서도 소수를 중점적으로 다룬다. 자연수는 1과 소수, 그리고 합성수로 이루어져 있는데, 합성수들은 소수의 곱으로 생각할 수 있기 때문에 결국 소수가 다른 정수들보다 더 중요한 대우를 받게 된다.

음의 정수는 잘 다뤄지지 않는데, 대부분의 곱셈에 관련된 문제에서 양의 정수에 -1을 곱하는 것으로 음의 정수를 다룰 수 있기 때문이다.

소수

소수를 한마디로 설명하면, 1보다 큰 자연수 중 1과 자기 자신만을 약수로 가지는 수라고 할 수 있다.

애초에 전제조건이 1보다 큰 자연수 중이기 때문에, 당연히 1은 소수가 아니다.

산술의 기본 정리(모든 양의 정수는 유일한 소인수 분해를 갖는다.)의 '1보다 큰 모든 자연수는 그 자체가 소수이거나, 순서를 무시하고 유일한 소인수의 조합을 갖는다'는 내용을 바탕으로 자연수는 1과 소수, 그리고 합성수로 구분된다.

이 합성수들은 소수의 곱으로 생각할 수 있기 때문에 결국 2이상의 모든 수들은 소수들로 구성되어 있다고 볼 수 있다.

Problem Solving을 하다 보면, 소수와 관련된 문제들을 자주 접할 수 있다. 소수는 경우에 따라 변하지 않으므로 N 이하의 소수는 이미 정해져 있기 때문에, 이 이미 정해진 대량의 소수들을 빠르게 구할 수 있는 방법들이 존재한다.

그 중 대표적인 방법으로 에라토스테네스의 체가 있다. 여기서 체는 대수학에서 사용하는 유리수의 집합, 실수의 집합, 복소수의 집합을 유리수체(體), 실수체(體), 복소수체(體)라고 부르는 Field가 아닌 가루나 액체를 거를 때 사용되는 도구를 뜻한다.

지구의 크기를 처음으로 계산해 낸 수학자로도 유명한 고대 그리스의 수학자 에라토스테네스가 만들어 낸 특정 범위 내의 소수를 구하는 방법으로, 소수가 아닌 수를 거르는 방법이 마치 체로 치듯이 수를 걸러낸다고 하여 '에라토스테네스의 체'라고 부른다고 한다.

에라토스테네스의 체로 소수를 찾는 방법은 아래와 같다.

만약 100만 이하의 소수들을 모두 구한다고 하자.

  1. 2를 제외한 2의 배수들은 모두 제거한다.
  2. 3을 제외한 3의 배수들은 모두 제거한다.
  3. 제거 되지 않은 가장 작은 수를 제외한 해당 수의 배수를 제거한다.
  4. 100만의 제곱근인 1000까지만 체크하여 해당 수들의 배수들을 모두 제거한다.

위 과정이 끝나고 제거되지 않은 수들이 100만 이하의 소수가 된다.

여기서 중요한 점은 에라토스테네스의 체를 이용해 N 이하의 소수를 구하고 싶다면, N까지 배수들을 찾아 볼 필요는 없이 N의 제곱근까지만 체크하면 된다.

만약 N보다 작은 합성수 M이 있을 때, M = A * B 라면 A와 B 중 적어도 하나는 N의 제곱근 보다 작다. 즉, M은 N의 제곱근 이하에서 이미 배수체크가 가능해지고 소수가 걸러진다.

같은 논리로 결국 N 또한 N의 제곱근이하에서 이미 배수체크가 완료되어 소수가 다 걸러지게 된다.

// 수도코드
eratosthenes(N){
	isPrime = boolean[N+1]; // 0 ~ N 까지 논리형 배열
	isPrime.fill(true); // 전부 true로 초기화 (그냥 이렇게 초기화가 가능하다고 가정)
	isPrime[0] = isPrime[1] = false; // 0과 1은 소수가 아님
	
	for(i = 2; i <= sqrt(N); i++){ // N의 제곱근 까지만 체크
		if(!isPrime[i]) continue; // 소수가 아니면 continue
		for(j = i*i; j <= N; j += i){
			isPrime[j] = false; // i의 제곱부터 시작해서 i의 배수는 모두 소수가 아님.
		}			    // i * 2부터 시작해도 되지만 어차피 i가 sqrt(N)까지 접근하기 때문에 동일함
	}
	
	return isPrime;
}

main(){
	N = 1000000;
	isPrime = eratosthenes(N);
	for(i = 0; i <= N; i++){
		if(isPrime[i]) print(i);
	}
}

다만 에라토스테네스의 체는 '특정 범위 내의 소수'를 구하는 데에만 효율적이다.

만약 주어진 수 하나가 소수인가? 만을 따지는 상황이라면 에라토스테네스의 체 보다 비교도 안되게 빠른방법이 넘쳐난다.

아래 문제는 에라토스테네스의 체를 이용해서 해결할 수 있는 기본적인 소수를 구하는 문제와 약간 응용한 문제들이다.

백준 1929번 : 소수 구하기

백준 4948번 : 베르트랑 공준

백준 9020번 : 골드바흐의 추측

유클리드 알고리즘

유클리드 알고리즘은 우리에게 유클리드 호제법이라고 더 알려져있다.

수학자 유클리드에 의해 기원전 300년경에 발견된 이 알고리즘은 주어진 두 수 사이에 존재하는 최대공약수를 구하는 알고리즘이다.

이 알고리즘의 원리는 아래와 같다.

만약 임의의 두 자연수 A와 B의 최대공약수를 구한다고 하자.

  1. A를 B로 나눈 나머지 M을 구한다. M = A % B
  2. 이 때 A가 B보다 작으면 M은 A가 된다. (굳이 A와 B의 대소 반별이 필요없다는 의미)
  3. 만약 A가 B보다 커서 M == 0 가 된다면 B가 최대 공약수가 된다.
  4. 만약 M이 0이 아니면, A에 B값을 넣고, B에 M값을 넣어서 다시 1.의 M = A % B 연산을 하여 M이 0이 될 때 까지 반복한다.
// 수도코드
// 재귀
euclid(A, B){	
 	return B == 0 ? A : euclid(B, A % B);
}

// 반복문
euclid(A, B){ 
	while(B != 0){
		A = B;
		B = A % B;
	}
	return A;
}

A와 B의 최대공약수를 구했으면, A와 B의 최소공배수는 A * B / (A와B의 최대공약수)로 구할 수 있다

아래 문제는 유클리드 호제법을 이용해서 해결할 수 있는 기본적인 문제이다.

백준 1934번 : 최소공배수

모듈라 연산

몇 가지 중요한 암호 시스템은 계산 결과가 항상 0 - (M-1) 범위에있는 경우 모듈라 연산을 사용한다고 한다.

이때 M이 우리가 %를 하고자 하는 모듈라 값이다.

아래는 modular를 mod를 표현한 우리가 기본적으로 알고있는 모듈라 연산이다

43 mod 6 = 1

27 mod 9 = 0

3 mod 20 = 3

50 mod 17 = 16

그리고 음수의 경우에도 모듈러 연산이 가능하다.

-13 mod 11 = 9

-10 mod 11 = 1

일반적으로 수학적으로 나머지는 양수라고 약속했기 때문에 음수를 mod 할 경우에는 양수라 생각하고 mod를 한 값의 음수에서 + m을 해주면 된다.

예를 들어 -13 mod 11이면 13 mod 11 = 2 에서 -2 + 11 = 9와 같다.

하지만 프로그래밍을 할 때 A % B 에서 A 또는 B가 음수가 되면 결과는 어떻게 될까?

놀랍게도 답은 "구현마다 다르다(Implementation-defined)"

당장 파이썬에서 -10 % 4는 2가 출력되고 10 % -4는 -2가 출력된다.

그리고 C++17 -10 % 4는 -2가 출력된다. 그렇기 때문에 음수 모듈라 연산을 할 때는 언어별로 다르다는 점을 미리 고려해야 할 것 같다.

모듈라 합동

모듈라 연산에 이해했다면 모듈라 합동에 대해서도 알면 좋을것 같다.

(A mod M) = (B mod M) => A ≡ B (mod M)

어떤 값 A와 B가 M으로 나누었을 때 나머지가 같다면 A와 B는 모듈라 M에 대한 합동 관계라고 표현한다.

여기서 A와 B는 A - B를 하였을 때, M의 배수가 된다.

다시 말해 A - B = K * M (K는 임의의 정수)이다.

예를 들어 13 % 6 = 1이고, 25 % 6 = 1이므로, 13과25는 모듈라 6에 대한 합동이라고 말할 수 있다.

아래 문제는 이 모듈라 합동에 관련된 문제이다.

백준 2981번 : 검문

모듈라 연산의 속성

모듈라 연산에는 재밌는 속성들이 존재한다.

먼저 (A + B) mod M = ((A mod M) + (B mod M)) mod M 이 성립한다.

그리고 (A - B) mod M = ((A mod M) - (B mod M)) mod M 이 성립하며

놀랍게도 (A * B) mod M = ((A mod M) * (B mod M)) mod M 또한 성립한다.

우리는 수학자가 아니라 공학자이므로 증명은 생략하고 위 공식을 잘 써먹기만 하면 된다.

이 공식을 잘 이용한다면 아래와 같은 문제를 풀 수 있다.

2^50이상은 계산할 수 없는 계산기가 존재한다.

이 계산기는 mod 연산을 할 수 있는 기능이 탑재되어있다.

이 때 2^90 mod 13을 구하라.

이 문제는 거듭제곱을 가지는 값을 모듈라 곱셈 속성을 이용해서 분할 정복으로 해결할 수 있다.

2^90은 2^50 * 2^40이다. 그러면 2^90 mod 13은

(A * B) mod M = ((A mod M) * (B mod M)) mod M 를 이용하여

2^90 mod 13 = (2^50 * 2^40) mod 13 = ((2^50 mod 13) * (2^40 mod 13)) mod 13으로 변형시킬 수 있다.

계산기를 통해서 2^50 mod 13 = 4, 2^40 mod 13 = 3이라는 값은 바로 구할 수 있다고 했을 때,

결국 2^90 mod 13 = 12 mod 13 = 12라는 사실을 알 수 있다.

비트마스크, 부분 합

비트마스크 (bitmask)

정수의 이진수 표현을 자료 구조로 쓰는 기법

장점

  • 빠른 수행 시간
    비트마스크 연산은 **O(1)**에 구현되는 것이 많다.
    비트마스크를 사용할 수 있다는 말은 원소의 수가 많지 않다는 뜻이어서 큰 속도 향상은 없지만, 연산을 여러 번 수행해야하는 경우에는 이러한 최적화로 큰 속도 향상을 가져올 수 있다.
  • 간결한 코드
    집합 연산들을 반복문 없이 비트 연산으로 처리하기 때문에 코드의 길이가 간결해진다.
  • 더 작은 메모리 사용량
    비트마스크를 사용하면 같은 데이터를 더 적은 메모리를 사용해 표현할 수 있다.
  • 배열을 비트마스크로 대체
    불린 값을 갖는 배열을 비트마스크를 사용해 같은 정보를 정수 변수로 나타낼 수 있다.

비트연산 시 유의점

연산자 우선순위
Java 에서는 & , | , ^ 등의 비트 연산자의 우선순위는 == , != 등의 비교 연산자보다 낮다.

int c = (6 & 4 == 4);

위의 코드는 4==4 가 먼저 계산되고 이 결과인 16 과 비트 연산이 되어서 6 & 1 이 된다.

int c = ((6 & 4) == 4);

그래서 괄호로 감싸줘야 하며, 비트마스크를 사용할 때는 괄호 사용을 습관화하는 것이 좋다.

부호 있는 정수형의 사용
부호 있는 정수형에서는 최상위 비트가 음수/양수를 표현한다.
32비트 정수형에서 하위 비트로만 사용할 경우는 문제가 되지 않지만, 32비트를 전부 사용하고 싶다면 음수의 경우 버그가 생길 수 있다.
따라서 변수의 모든 비트를 사용해 비트마스킹을 하고싶다면 부호 없는 정수형을 쓰거나 크기가 더 큰 정수를 사용하는 것이 좋다.
Java의 경우 unsigned int 가 없기 때문에 long 을 사용해야 한다.

비트마스크를 활용한 집합

비트마스크로 표현하면 N비트 정수 변수는 0부터 N-1까지의 정수 원소를 가질 수 있는 집합이 된다.
이때 원소 i가 집합에 속해 있는지 여부는 정수 변수의 i번째 비트가 1인지 0인지로 나타낸다.


공집합과 꽉 찬 집합

비트마스크에서 집합을 표현할 때, 0이 공집합을 나타낸다.
꽉찬 집합은 마지막 N개의 비트가 모두 1인 것인 것인데, 이것은 (1 << N) - 1 로 표현 가능하다.
1 << N 은 1뒤에 N개의 0이 있는 정수인데, 여기서 1을 뺀다면 N자리부터 끝까지 모두 1이 된다.

예시

8비트 정수 변수와 [0, 1, 2, 3, 4 , 5] 의 6개의 원소가 있다.

  • 공집합
0 0 0 0 0 0 0 0
  • 꽉 찬 집합
0 0 0 0 0 0 0 1
0 1 0 0 0 0 0 0
0 0 1 1 1 1 1 1

[0, 1, 2, 3, 4, 5] 의 꽉 찬 집합


집합에 원소 추가

비트마스크에서 원소를 추가한다는 것은 해당 자리의 비트를 1로 만드는 것이다.
기존 값이 result 라고 했을 때, 원소 p 를 추가하는 것은 result |= (1 << p) 로 나타낼 수 있다.
1을 왼쪽으로 p 비트 시프트하면 p 번 비트만 1인 정수가 되므로 이것을 result 와 비트 OR 연산을 한다면 p 번 비트를 0에서 1로 변경할 수 있다.

예시

[1, 3, 4] 에 2 추가

  • [1, 3, 4] 집합을 뜻하는 변수의 이진법 상태
0 0 0 1 1 0 1 0
  • 1 << 2
0 0 0 0 0 1 0 0
  • [1, 3, 4] OR (1 << 2)
0 0 0 1 1 1 1 0

[1, 2, 3, 4] 집합을 뜻하는 변수 상태 완성


원소 포함 여부 확인

resultp 번 원소가 포함되어있는지 확인하려면 result & (1 << p) != 0 의 조건으로 확인하면 된다.
resultp 번째 비트가 0이라면 & 연산에 의해 0이 도출되므로, 0이 아니라면 p 번 원소가 포함되어있는 것이다.
여기서 주의할 점은 & 의 연산 결과는 1이 아닌 0 또는 1 << p 값이다.

예시

[1, 3, 4] 집합에 3이 포함되어있는지 여부

  • [1, 3, 4] 집합을 뜻하는 변수의 이진법 상태
0 0 0 1 1 0 1 0
  • 1 << 3
0 0 0 0 1 0 0 0
  • [1, 3, 4] AND (1 << 3)
0 0 0 0 1 0 0 0

결과가 0이 아니므로 해당 원소는 집합에 포함된 상태


원소의 삭제

삭제하는 방법 중 단순하게 result -= (1 << p) 로 할 수도 있다.
하지만 위의 경우는 이미 resultp 번째 원소가 포함되어 있는 경우에만 유효하고, 만약 resultp 번째 원소가 포함되어있지 않은 경우에는 버그가 발생한다.

정상 동작하도록 할거라면, result &= ~(1 << p) 로 해야한다.
~ 연산자는 비트별 NOT 연산을 수행하므로 ~(1 << p)p 번째 비트만 0이고 나머지는 모두 1이 된다.
따라서 result 에서 p 번째 원소만 0으로 만들고, 나머지는 그대로 유지할 수 있게 된다.

예시

[1, 3, 4] 집합에서 언소 4 삭제

  • [1, 3, 4] 집합을 뜻하는 변수의 이진법 상태
0 0 0 1 1 0 1 0
  • 1 << 4
0 0 0 1 0 0 0 0
  • ~(1 << 4)
1 1 1 0 1 1 1 1
  • [1, 3, 4] AND ~(1 << 4)
0 0 0 0 1 0 1 0

[1, 3] 만 포함하는 변수 상태 완성


원소의 토글

비트 토글은 XOR 연산을 활용한다.
만약 p 번이 포함된 경우는 제외하고, 제외된 경우는 포함하고 싶다면 result ^= (1 << p) 를 사용하면 된다.

예시

[1, 3, 4] 집합에서 원소 4의 토글

  • [1, 3, 4] 집합을 뜻하는 변수의 이진법 상태
0 0 0 1 1 0 1 0
  • 1 << 4
0 0 0 1 0 0 0 0
  • [1, 3, 4] XOR (1 << 4)
0 0 0 0 1 0 1 0

[1, 3] 만 포함하는 변수 상태 완성


두 집합 연산

  • 합집합 : a | b
  • 교집합 : a & b
  • a에서 b를 뺀 차집합 : a & ~b
  • 합집합에서 교집합을 뺀 집합 : a ^ b

위 연산은 원소 하나에 대해 수행하는 것과 다를 것이 없다.
즉, 집합 간의 연산 속도가 굉장히 빨라진다.

집합 크기 구하기

비트마스크를 이용할 때 집합에 포함된 원소의 수를 구하는 방법은 딱히 없다.
가장 간단한 방법은 각 비트를 순회하면서 켜져 있는 비트의 수를 직접 세는 수밖에 없다.

int bitCount(int x) {
	if (x == 0) return 0;
	return x % 2 + bitCount(x/2);
}

위의 방법 말고도, Java는 Integer.bitCount(result) 를 통해 1인 비트의 수를 셀 수 있다.


최소 원소 찾기

최소 원소를 찾는다는 것은, 1비트인 최하위 비트의 위치를 구하는 방법이다.
Java의 Integer.numberOfTrailingZeros(result) 를 활용하는 방법도 있다.

만약 최하위 비트의 번호 대신 해당 비트를 직접 구하고 싶다면 result & -result 를 사용하면 된다.
대부분의 컴퓨터가 음수를 표현하는 것에 2의 보수를 사용한다는 점을 이용한 방법이다.
컴퓨터는 -result 를 표현하기 위해서 result 에 비트별로 NOT 연산을 적용한 결과에 1을 더한다.
만약 result 에서 0비트가 아닌 최하위 비트가 2^i 라면, result 의 마지막 i+1 자리는 1 뒤에 i개의 0이 있는 형태여야 한다.
result 에 비트별 NOT 연산을 적용하면 i+1 자리는 0이되고 0 뒤에 i개의 1이 있는 형태가 되고, 여기에 1을 더하면 다시 1과 i개의 0이 있는 형태가 된다.
2^i보다 상위 비트들에는 NOT 연산이 적용된 상태이므로 두 수를 AND 연산하면 항상 최하위 비트만 얻을 수 있다.

예시

[2, 3, 4] 원소들을 포함하는 집합을 뜻하는 8비트 정수 변수에서 최소 원소값을 찾기

  • reseult
0 0 0 1 1 1 0 0
  • -result
1 1 1 0 0 0 1 1
1 1 1 0 0 1 0 0
  • result & -result
0 0 0 0 0 1 0 0

2를 뜻하는 결과값 도출


최소 원소 지우기

만약 최소 원소가 무엇인지는 궁금하지 않고 무조건 최소 원소를 지우는 연산은 result &= (result -1) 을 활용한다.
최소원소를 얻은 후 그 원소를 지우는 것보다 훨씬 간결하다.
result-1 의 이진수 표현은 최하위 비트를 0으로 만들고 하위 비트들을 모두 1로 만든 것이다.
따라서 두 값을 AND 연산한다면 최하위 비트와 그 이하의 비트들은 전부 0이 되므로 최소 원소를 지우는 것과 같은 효과가 나타난다.

예시

[2, 3, 4] 원소들을 포함하는 집합을 뜻하는 8비트 정수 변수에서 최소 원소 지우기

  • result
0 0 0 1 1 1 0 0
  • result-1
0 0 0 1 1 0 1 1
  • result & (result-1)
0 0 0 1 1 0 0 0

2 원소를 삭제한 결과값 도출


모든 부분 집합 순회하기

for(int subset = result; subset != 0; subset = ((subset-1) & result)) {
	// subset은 부분집합
}

위와 같은 반복문으로 부분 집합을 순회할 수 있다.
subset-1 은 최하위 비트가 꺼지고 그 밑의 비트들은 모두 켜진다.
이 값에 & result 를 하게되면 그 중 result 에 속하지 않는 비트들은 모두 0이 된다.

이 연산을 반복하면 result 의 모든 부분 집합을 방문할 수 있다.
이 때, for문의 종료 조건이 subset 이 0이 아닐때까지이므로 공집합은 따로 체크해야한다는 점을 유의해야 한다.

예시

[2, 3, 4] 원소들을 포함하는 집합을 뜻하는 8비트 정수 변수에서 부분집합 구하기

  • result = 처음 subset
0 0 0 1 1 1 0 0
  • subset-1
0 0 0 1 1 0 1 1
  • (subset-1) & result = 다음 subset
0 0 0 1 1 0 0 0

[3, 4] 원소들을 포함하는 집합

  • subset-1
0 0 0 1 0 1 1 0
  • (subset-1) & result = 세번째 subset
0 0 0 1 0 1 0 0

[2, 4] 원소들을 포함하는 집합

...


비트마스크 활용 방법

  • 메모이제이션 또는 visit 배열
  • 2의 제곱들을 활용하는 문제

백준에서는..

실버1 물병
골드3 중복제거
골드3 IP 주소


부분합 (누적합)

부분합이란 배열의 각 위치에 대해서 배열의 시작부터 현재 위치까지의 원소의 합을 구해 둔 배열이다.
만약 부분합을 미리 계산해둔다면, 특정 구간의 합을 **O(1)**에 구할 수 있다.

부분 합 계산하기

구간 합을 빠르게 계산하기 위해 부분 합을 미리 계산해 둘 필요가 있다.
부분 합을 계산하는데 드는 시간은 수열의 길이에 따라 선형으로 증가한다는 것에 유의해야 한다.
반복문을 통해 구간 합을 구하기 위해 최대 O(N)의 시간이 걸린다는 것은 구간 합을 두 번 이상 구할 때는 대부분의 경우 부분 합을 미리 계산해놓고 사용하는 쪽이 효율적이다.

2차원으로의 확장

image

sum[i+1][j+1] = sum[i][j+1] + sum[i+1][j] - sum[i][j] + arr[i][j]

2차원 배열에서 구간 합 배열을 구하는 식은 위와 같다.
위와 같이 구한다면, 배열의 (0,0) 위치부터 (i+1,j+1) 위치까지의 합을 저장할 수 있다.

만약 (x1, y1) 부터 (x2, y2) 구간의 합을 구하는 방법은 아래와 같다.

arr[x2][y2] - arr[x1-1][y2] - arr[x2][y1-1] + arr[x1-1][y1-1]

위의 코드로 해당 영역의 합을 구할 수 있다.

백준에서는...

골드1 구간 합 구하기
골드2 구간 합 최대

계수 정렬

계수 정렬

  • Counting Sort
  • 배열 내에 원소 값들의 개수를 저장하는 방식을 사용

특징

  • 비교없이 정렬 가능
  • 시간복잡도는 O(n)으로 O(nlogn)인 Quick Sort보다 빠르다.
  • 배열에 포함된 숫자의 최댓값만큼 메모리를 할당해야 하기 때문에 메모리 낭비가 발생할 수 있다.
  • 배열 내의 숫자가 특정 범위로 한정되어 있을 경우 사용하면 좋다.

구현 방법

  1. 배열 내에 원소 값들의 개수를 저장하는 Counting Array를 만든다.

  1. Counting Array의 요소들에 대해서 직전 요소들의 값을 더해준다.

  1. 입력 배열과 동일한 크기의 출력 배열을 생성하고 입력 배열 순서대로 출력 배열에 요소들을 채워넣는다.

(1) arr의 index 0부터 시작한다. sorted_arr[count_arr[arr[i]] - 1]에 arr[i]삽입한다. count_arr[arr[i]]에서 1을 뺀다.


(2) arr의 index 1로 이동한다.


(3) arr의 index 2로 이동한다.


(4) arr의 index 3로 이동한다.


(5) arr의 index 4로 이동한다.


(6) arr의 index 5로 이동한다.



(7) arr의 index 6로 이동한다.

코드

public static void main(String[] args) {
        // 배열 내 최대 크기의 수
        final int MAX = 3;
        int[] arr = {1, 0, 2, 1, 2, 1, 3};

        // counting array
        int[] count_arr = new int[MAX + 1];
        // 입력 배열과 동일한 크기의 출력 배열
        int[] sorted_arr = new int[arr.length];

        // 1. counting array를 만든다.
        for(int i = 0, len = arr.length; i < len; i++)
            count_arr[arr[i]]++;

        // 2. 누적합을 만들어준다.
        for(int i = 1; i < MAX + 1; i++)
            count_arr[i] += count_arr[i - 1];

        // 3. 입력 배열의 역순으로 출력 배열에 요소들을 채워 넣어준다.
        for(int i = 0; i < arr.length; i++)
            sorted_arr[--count_arr[arr[i]]] = arr[i];
}

문제 추천

수 정렬하기3

알고리즘 정당성 증명

정당성 증명

용어
어떤 - 상황에 따라 a가 되기도 any가 되기도 한다.

간단한 문제의 경우에는 직관적으로 알고리즘을 설계할 수 있지만, 문제가 복잡해지면 알고리즘이 과연 문제를 제대로 해결하는지를 파악하기 어려워집니다.

테스트 케이스를 이용해 단위 테스트를 해서 프로그램을 실행하고 답을 점검해 볼 수 있지만, 이런 방식은 잘못된 예외 케이스를 찾았을 때, 알고리즘에 문제가 있음은 증명할 수 있어도 알고리즘이 문제가 없음을 증명하기는 어렵습니다.

모든 입력에대해서 문제가 없음을 정확하게 증명하기 위해서는 다양한 수학적 기법을 사용해야합니다.

수학적 귀납법과 반복문 불변식

수학적 귀납법은 반복적인 구조를 갖는 명제들을 증명하는데 사용되는 증명 기법입니다.
수학적 귀납법은 다음 3단계로 나눠서 증명이 진행됩니다.

  1. 단계 나누기
    증명하고 싶은 것을 여러 단계로 나눕니다. (수학적 귀납법을 이용한 수열의 증명에서 1번째항, 2번째항,…k번째항,… n번째항으로 나누는 것이 단계 나누기 입니다.)
  2. 첫 단계 증명
    나눈 단계 중 첫번째 단계에서 증명하고싶은 내용이 성립함을 보입니다.
  3. 귀납 증명
    k번째 단계에서 증명하고 싶은 내용이 성립한다면, k+1번재 단계에서도 증명하고 싶은 내용이 성립함을 보이면 됩니다.

수학적 귀납법을 소개할 때 가장 많이 나오는 예시가 도미노입니다.
도미노는 첫번째 도미노를 손으로 밀어서 쓰러뜨리면, 다음 도미노도 넘어집니다.
즉, 한 도미노가 스러지면 다음 도미노 역시 쓰러지기 때문에, 중간에 k번째 도미노가 쓰러지면 k+1번째 도미노 역시 쓰러질 것이고 이 과정이 반복되면 마지막 도미노까지 쓰러질 것입니다.

이처럼 도미노가 쓰러지는 것으로부터 얻은 직관으로 이진 탐색을 수학적 귀납법으로 증명해보겠습니다.

이진 탐색과 반복문 불변식

반복문 불변식

이진 탐색의 알고리즘 정당성을 증명하기 전에 반복문 불변식이라는 개념을 먼저 알아보겠습니다.

대부분의 알고리즘은 어떤 형태로든 반복적인 요소를 가지게 됩니다.
이 반복적인 요소를 귀납법을 이용할 때 사용하는 것이 바로 반복문 불변식입니다.
반복문 불변식은 반복문의 내용이 한 번 실행될 때마다 중간 결과가 우리가 원하는 답으로 가는 길 위에 있는지를 명시하는 조건 입니다.

반복문의 마지막에 정답을 계산하기 위해서는 중간 단계에서 항상 식이 변하지않고 성립해야합니다.

불변식을 이용해서 반복문의 정당성을 증명하면 다음과 같습니다.

  1. 반복문 진입시 불변식이 성립함을 증명
  2. 반복문 내용이 불변식을 깨뜨리지 않음을 증명
  3. 반복문 종료시 불변식이 성립하는지 확인(확인되면 정답을 찾은 것)

이진 탐색 알고리즘의 정당성 증명

//필수 조건 : arr은 오름차순으로 정렬되어 있다.
public int binsearch(int[] arr,int target){
	int n = arr.length;
	int left = -1, right = n;
	//반복문 불변식 1: left<right
	//반복문 불변식 2: arr[left]<target<=arr[right] 
	while(left + 1 < right){
		int mid = (left + right)/2;
		if(arr[mid]<target) left = mid;
		else right = mid;
	}
	return right;
}

반복문 불변식 1: left<right
while 문의 조건에 걸려서 종료됐다면 반드시 left+1 >= right 입니다.
불변식에 의하면 left < right 이므로 left + 1 = right 가 됩니다.
반복문 불변식 2: arr[left]<target<=arr[right]
이 불변식에 의해서 종료조건을 만난 후에 우리가 구하려 했던 값은 right가 됨을 알 수 있습니다.
이처럼 불변식이 while문 종료시에 항상 성립한다는 것을 보였기 때문에 이 알고리즘의 정당성은 증명되었습니다.

불변식을 이용해서 정당성을 증명하는 과정은 귀납법과 다를 것이 없어보이지만 전체 작업을 각 단계로 나누는 과정이 이미 반복문으로 만들어져있기 때문에 더 간단합니다. 반복문이 처음 시작될 때 불변식이 만족함을 보이고, 반복문 내용이 한 번 지나가도 이 조건이 다시 유지됨을 보여주면 쉽게 증명할 수 있습니다.

귀류법

우리가 원하는 결론과 반대되는 상황을 가정하고 논리를 전개해서 결론이 잘못됐음을 찾아내는 증명 기법입니다.
귀류법은 보통 최선의 선택인지를 증명해야하는 그리디 알고리즘의 정당성을 증명할 때 많이 사용합니다.

귀류법을 이용한 증명

홀수 + 짝수는 홀수 임을 증명해보겠습니다.

  1. 홀수 + 짝수는 짝수라고 가정
  2. 홀수는 2n-1, 짝수는 2m(n,m은 자연수)
  3. 2n-1 + 2m = 2(n-m)-1
  4. 2(n-m)-1은 홀수(2의 배수는 짝수)
  5. 이는 1에서의 홀수+짝수 = 짝수 가정과 모순
  6. 따라서 홀수 + 짝수 = 홀수

귀류법의 활용

귀류법은 알고리즘의 결과가 최선(최단 경로, 최소 비용 등등)임을 보이기 위한 절차는 다음과 같습니다.

  1. 각 단계에서 최선의 선택을 함을 귀류법으로 증명
  2. 귀류법으로 최선의 선택함이 증명된다면 다음 단계에서도 최선의 선택을 함을 귀납법으로 증명

비둘기집의 원리

n마리 비둘기 n-1개의 비둘기 집에 넣으면 비둘기가 2마리 이상의 비둘기 집이 적어도 하나 존재한다

구성적 증명

우리가 원하는 어떤 답이 존재한다는 사실을 증명하기위해 사용하는 증명법입니다. 귀류법과 귀납법이 답이 존재한다는 사실을 논증하는 것이라면, 구성적 증명은 답의 실제 예를 들어서 보여주거나 답을 만드는 방법을 실제로 제시하는 증명법입니다.
ex) 코드의 정당성 증명없이 온라인 저지 사이트에 올려서 채점해서 맞으면 그 문제에 한해서는 이상없이 작동하는 코드임을 생각해봅시다.

안정적 결혼 문제

안정적 결혼 문제는 다음과 같은 상황을 이야기합니다.
n명의 남성과 여성이 단체 미팅에서 만났습니다. 여러 게임을 진행하는 동안 모든 사람은 자신이 원하는 상대방의 우선쉰위를 맘 속에 정했고, 이제 시간이 되어 남자 1호와 여자 1호가, 남자 2호와 여자 2호가 각각 짝이 되었습니다. 그런데 남자 1호와 여자 2호는 자신들의 짝보다, 서로를 더 선호한다는 사실을 알게되었습니다. 이런 일이 일어나지않도록 짝을 지어줄 수 있는 방법이 항상 있을까요? 아니면 불가능한 경우가 있을까요?

이 알고리즘은 실제로 어느 대학의 석사 과정 신입생의 지도 교수 배정 과정에 도입된 알고리즘이기도 합니다.

그렇다면 이 알고리즘은 어떻게 해결할 수 있을까요??

  1. 처음에는 남성들이 모두 자신이 가장 선호하는 여성의 앞에가서 프로포즈를 합니다. 여성이 그 중 제일 마음에드는 남성을 고르면 나머지는 거절을 당하고 자기 자리로 돌아갑니다.
  2. 거절당한 남자들은 상대에게 짝이 있는지 없는지와 관계없이 다음으로 마음에 드는 여성에게 다가가 프로포즈합니다. 여성들은 현재 자신의 짝보다 더 마음에 드는 남성이 다가왔다면, 지금의 짝 대신 새로운 남성을 선택합니다.
  3. 짝이 없는 남성이 없을 때까지 2번 항목을 반복합니다.

위와 같이 진행한다면 안정적으로 모든 쌍을 구할 수 있습니다.
모든 여성이 1번씩 알고리즘에 참여하기 때문에 O(N)만큼의 시간이 소모되고 각각의 여성은 자신 선호도를 확인해야하기 때문에 이 과정에서 O(N), 프로포즈한 남성이 선호도 순서를 확인해 여성을 선택하는데 걸리는 시간 O(1)이 소모 됩니다.
그렇기 때문에 이 알고리즘의 시간복잡도는 O(N^2)입니다.

그렇다면 이 알고리즘이 항상 종료된다는 것은 어떻게 알 수 있을까요? 그리고 모든 사람이 짝을 찾고 그 짝이 항상 안정적인 것은 어떻게 알 수 있을까요?

  • 종료 증명
    각 남성은 거절당할 때마다 지금까지 프로포즈했던 여성들보다 우선순위가 낮은 여성에게 프로포즈합니다. 따라서 각 남성이 최대 n명의 여성들에게 순서대로 프로포즈한 이후에는 더 이상 프로포즈할 수 있는 여성이 없으므로 반드시 종료됩니다.
  • 모든 사람이 짝을 찾음 증명
    프로포즈 받은 여성은 프로포즈한 남성들 중 한 명을 반드시 선택하고, 더 우선순위가 높은 남성이 프로포즈해야지만 짝을 바꾸기 때문에, 한 번이라도 프로포즈를 받은 여성은 항상 작이 있습니다.
    이에 귀류법을 적용해보겠습니다.
  1. 남녀 한 사람씩 짝을 찾지 못하고 남았다고 가정
  2. 모든 남성은 우선순위가 높은 순서대로 모두에게 한 번씩 프로포즈하기 대문에, 지금 짝을 찾지 못한 여성에게도 한 번은 프로포즈를 했습니다.
  3. 우선순위에 따라서 남은 여성은 지금 받은 프로포즈를 받아들여야하기 때문에 짝을 찾지 못하는 사람이 존재할 수 없습니다.
  • 짝의 안정성 증명
    귀류법을 사용하면 증명할 수 있습니다.
  1. 짝을 지었는데 결과적으로 짝이 아닌 두 념녀가 서로 자신의 짝보다 상대방을 더 선호한다고 가정합니다.
  2. 더 선호하는 여성이 있다면 남성은 지금 자신의 짝 이전에 그 여성에게 반드시 프로포즈를 했어야합니다.
  3. 여성은 더 마음에 드는 남성이 나타났을 대만 짝을 바꾸기 때문에, 둘이 짝이 아니라는 것은 지금 프로포즈한 남성보다 더 선호하는 남성과 짝이 됐다는 뜻입니다.
  4. 그러므로 프로포즈 받았던 남성보다 마음에 들지않는 남성과 최종적으로 짝이 될 수 없습니다.

이처럼 귀류법과 귀납법 그리고 구성적 증명을 이용하면 복잡해보이는 알고리즘의 정당성을 확인하는데 도움이 됩니다.

크루스칼, 프림, 다익스트라, 벨만-포드, 플로이드

최소 스패닝 트리(MST)

최소 스패닝 트리는 그래프에서 만날 수 있는 최소 비용 문제 중 모든 정점을 연결하는 간선들의 가중치의 합이 최소가 되는 트리를 의미합니다.

그렇다면 스패닝 트리는 무엇일까요??

스패닝 트리

n개의 정점으로 이루어진 무향 그래프에서 n개의 정점과 n-1개의 간선으로 이루어진 트리를 신장트리라 합니다. 다른 말로, 원래 그래프의 정점 전부와 간선의 부분 집합으로 구성된 부분 그래프 입니다. 이 때, 스패닝 트리에 포함된 간선들은 정점들을 트리 형태로 전부 연결해야 합니다.
이런 특징들로부터 우리는 스패닝 트리가 유일하지 않고 여러개가 존재합니다.
가중치 그래프의 여러 개의 스패닝 트리 중 가중치의 합이 가장 작은 트리를 찾는 문제입니다.

크루스칼

크루스칼의 알고리즘은 상호 배타적 집합 자료 구조를 사용하는 좋은 예입니다.
크루스칼 알고리즘을 접근하기 전에 다음 질문의 답을 생각해보겠습니다.

가중치가 가장 작은 간선과 가중치가 가장 큰 간선 중 어느 쪽이 최소 스패닝 트리에 포함될 가능성이 높을까?

대부분 가중치가 가장 작은 간선일 것입니다. 크루스칼의 알고리즘은 여기서 출발합니다.

그래프의 모든 간선을 가중치의 오름차순으로 정렬합니다. 그 후, 스패닝트리에 하나씩 추가합니다. 이때 주의할 점은, 간선들이 사이클을 이루지 않게 해야하는 것입니다. 그렇기 때문에, 가중치가 작다고 무조건 간선을 트리에 더하는 것이 아닌, 결과적으로 사이클이 생기는 간선을 제외한 간선 중 가중치가 가장 작은 간선들을 트리에 추가합니다.

이처럼 크루스칼 알고리즘은 모든 간선을 한 번씩 감사한 뒤 종료합니다.
다음은 크루스칼 알고리즘이 최소 스패닝 트리를 만드는 과정을 표현한 그림입니다.

크루스칼 알고리즘의 구현

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.Comparator;
import java.util.StringTokenizer;

public class KuruskalTest {

    static class Edge implements Comparable<Edge> {
        int start, end, weight;
        public Edge(int start,int end, int weight){
            this.start = start;
            this.end = end;
            this.weight = weight;
        }
        @Override
        public int compareTo(Edge o){
            return Integer.compare(this.weight,o.weight);
        }
    }
    static int V;
    static int E;
    static BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
    static StringTokenizer st;
    static Edge[] edgeList;
    public static void main(String[] args) throws IOException {
        st = new StringTokenizer(br.readLine()," ");
        V = Integer.parseInt(st.nextToken());
        E = Integer.parseInt(st.nextToken());

        edgeList = new Edge[E];
        for(int i = 0;i<E;i++){
            st = new StringTokenizer(br.readLine()," ");
            int start = Integer.parseInt(st.nextToken());
            int end = Integer.parseInt(st.nextToken());
            int weight = Integer.parseInt(st.nextToken());
            edgeList[i] = new Edge(start,end,weight);
        }
        Arrays.sort(edgeList);//오름차순
        make();// 모든 정점을 각각 집합으로 만들고 출발한다.
        //간선 하나씩 시도하며 트리를 만든다.
        int cnt = 0,result = 0;
        for(Edge edge : edgeList){
            if(merge(edge.start,edge.end)){
                result += edge.weight;
                if(++cnt == V-1) break;// 신장트리 완성.
            }
        }
        System.out.println(result);

    }
    static int[] parents;
    static void make(){
        parents = new int[V];
        for(int i = 0;i<V;i++){
            parents[i] = i;
        }
    }
    //u가 속한 트리의 루트 번호를 반환한다.
    static int find(int u){
        if(u == parents[u]) return u;
        //return find(parent[u]); --- 기울어진 트리의 경우 비효율적
        //최적화(Path Compression)
        return parents[u] = find(parents[u]);
    }
    //u가 속한 트리와 v가 속한 트리를 합친다..
    static boolean merge(int u, int v){
        u = find(u);
        v = find(v);
        //u와 v가 이미 같은 트리에 속하는 경우는 걸러낸다.
        if(u ==v) return false;
        parents[u] = v;
        return true;
    }
}

정당성 증명

  1. 크루스칼 알고리즘이 선택하는 간선 중 그래프의 최소 스패닝 트리 T에 포함되지않는 간선이 있다고 가정
  2. 이 중 첫번째로 선택되는 간선을 (u,v)라 하자. T는 이 간선을 포함하지않기 때문에, u와 v는 T에서 다른 경로로 연결되어 있을 것이다.
  3. 이 경로를 이루는 간선 중 하나는 반드시 (u,v)와 가중치가 크거나 같아야한다.(그 이유는 모두 (u,v)보다 가중치가 작다면 크루스칼 알고리즘이 이미 이 간선들을 모두 선택해서 u와 v를 연결했을 것이기 때문에 (u,v)가 선택됐을리 없다.
  4. 따라서 이 경로 상에서 (u,v) 이상의 가중치를 갖는 간선을 하나 골라서 T에서 지워버리고 (u,v)를 추가해도 스패닝 트리는 유지되면서 가중치의 총합은 줄거나 같을 것입니다.
  5. 하지만 T가 이미 최소 스패닝 트리라고 가정했기 때문에, (u,v)를 포함하면서 최소 스패닝 트리가 되어야합니다.
  6. 따라서 (u,v)를 선택한다고 하더라도 남은 간선들을 잘 선택하면 항상 최소 스패닝 트리를 얻을 수 있습니다.
    1~6의 성질은 마지막 간선을 추가해 스패닝 트리가 완성될 때까지 성립하기 때문에, 마지막에 얻은 트리는 항상 최소 스패닝 트리가 됩니다.

시간복잡도

DisJointSet에대한 연산은 실질적으로 상수기간이기 때문에,실제 트리를 만드는 for문의 시간복잡도 O(|E|)입니다. 따라서 크루스칼 알고리즘의 전체 시간복잡도는 간선 목록의 정렬에 걸리는 시간 O(|E|log|E|)가 됩니다. 간선 목록의 정렬하는 시간이 알고리즘 전체 시간 중에 지배적으로 크기 때문에, 간선의 수가 많아지면 크루스칼 알고리즘은 효율이 떨어집니다.

프림

프림의 알고리즘은 다익스트라 알고리즘과 거의 같은 형태를 띠고 있습니다.
크루스칼 알고리즘이 여기저기서 산발적으로 만들어진 트리의 조각들을 합쳐서 스패닝 트리를 만든다면, 프림 알고리즘은 하나의 시작점으로 구성된 트리에 간선을 하나씩 추가하는 방식으로 진행됩니다. 그렇기 때문에, 항상 선택된 간선들은 중간 과정에서도 연결된 트리를 만듭니다.

프림 알고리즘은 선택할 수 있는 간선들 중 가중치가 가장 작은 간선을 선택하는 과정을 반복합니다.
아래는 프림 알고리즘을 이용해서 최소 스패닝 트리를 만드는 과정을 표현한 그림입니다.

파란색으로 표현된 선은 이번 단계에서 고려할 간선이고, 그 중 선택된 간선을 하늘색으로 표현했습니다. 초록색 선은 이미 선택된 간선들을 의미합니다.

프림 알고리즘의 구현

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.StringTokenizer;

public class PrimTest {
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int N = Integer.parseInt(br.readLine());
        int[][] adjMatrix = new int[N][N];
        boolean[] visited = new boolean[N];
        int[] minEdge = new int[N];

        StringTokenizer st = null;
        for(int i = 0;i<N;i++){
            st = new StringTokenizer(br.readLine()," ");
            for(int j = 0;j<N;j++){
                adjMatrix[i][j] = Integer.parseInt(st.nextToken());
            }
            minEdge[i] = Integer.MAX_VALUE;
        }

        int result = 0;//최소 신장 트리 비용
        minEdge[0] = 0;//임의의 시작점 0의 간선 비용을 0으로 세팅 index는 아무거나 상관없다.
        for(int i = 0;i<N;i++){
            // 1. 신장 트리에 포함되지않은 정점 중 최소간선비용의 정점 찾기
            int min = Integer.MAX_VALUE;
            int minVertex = -1;//최소간선비용의 정점번호
            for(int j = 0;j<N;j++){
                if(!visited[j] && min>minEdge[j]){
                    min = minEdge[j];
                    minVertex = j;
                }
            }
            visited[minVertex] = true;//신장트리에 포함시킴.
            result += min;//간선비용 누적.

            //2. 선택된 정점 기준으로 신장트리에 연결되지않은 타 정점과의 간선 비용 최소로 업데이트
            for (int j = 0; j < N; j++) {
                //인접 안해있으면 인풋이 0이므로 걔가 최소가 되어버림.
                if(!visited[j] && adjMatrix[minVertex][j]!=0 && minEdge[j] > adjMatrix[minVertex][j]){
                    minEdge[j] = adjMatrix[minVertex][j];
                }
            }
        }
        System.out.println(result);
    }
}

다익스트라 알고리즘의 구현과 비슷한 코드입니다.
각 정점에대해서 지금까지 알려진 최단 거리를 저장하는 것이 아닌, 마지막 간선의 가중치를 저장하는 방식으로 구현했습니다.
우선 순위 큐를 이용해서 최소 간선 비용의 정점을 찾으면 코드를 최적화 할 수 있습니다. 이렇게 구현을 하면, 우선순위 큐는 minEdge[ ]가 증가하는 순서로 정렬해서 담고있게 됩니다.

정당성 증명

크루스칼 알고리즘의 증명과 똑같이 증명할 수 있습니다.

크루스칼 vs 프림

크루스칼이 간선 위주였다면 프림은 정점을 위주로 풀어나가는 알고리즘입니다.
둘은 이미 만들어진 트리에 인접한 간선을 고려하는지의 여부를 제외하면 완전히 똑같은 알고리즘입니다.

다익스트라

다익스트라는 그래프의 한 시작 정점이 주어졌을 때, 그 정점에서 다른 정점으로의 최단 경로를 구하는 알고리즘입니다.
다음은 다익스트라 알고리즘의 동작 방식을 표현한 그림입니다.

출처

Basic idea

//init
S = {v0}; distance[v0] = 0; 
for (w  V - S에 속한다면)//V-S는 전체 정점에서 이미 방문한 정점을 뺀 차집합.
	if ((v0, w) E에 속한다면) distance[w] = cost(v0,w); 
	else distance[w] = inf;

//algorithm
S = {v0};
while VS != empty {
  //아래의 코드는 지금까지 최단 경로가 구해지지않은 정점 중 가장 가까운 거리를 반복한다는 의미
	u = min{distance[w], wV-S의 원소};//---1
	
  S = S U {u}; 
  VS = (VS)–{u};
	for(vertex w : VS)
		distance[w] = min{distance[w],distance[u]+cost(u,w)}//update distance[w];---2
}

위의 코드에서 1로 마킹한 부분에대한 설명을 하겠습니다.

먼저 length[v]는 최단 경로의 길이를 의미하고 distance[w]는 시작점에서부터의 최단 경로의 길이를 의미합니다.

S는 시작점을 포함해서 현재까지 최단 경로를 발견한 점들의 집합을 의미합니다.

정당성 증명

u = min{distance[w], w 는 V-S의 원소}의 의미는 u까지의 최단 거리의 길이는 distance[u] 와 같다는 의미입니다.

둘이 같다는 것은 다음처럼 증명할 수 있습니다.

  1. distance[u] > length[u]라고 가정해보겠습니다.
  2. P를 시작점부터 u까지의 최단 경로라고 하면
  3. P의 길이 = length[u]입니다.
  4. P는 S에 속하지않는 최소 1개의 정점을 가집니다. 그렇지않다면 P의 길이 = distance[u]가 됩니다.
  5. P의 경로에 속하면서 S에 속하지않는 시작점에서 가장 가까운 점을 w라 하겠습니다.
  6. 그렇게 된다면 distance[u] > length[u] >= length[w] 가 성립하고, length[w] = distance[w]이기 때문에 이로부터
    **distance[u] > distance[w]**를 유추해낼 수 있습니다.
  7. 이는 다익스트라 알고리즘은 아직 최단경로가 찾아지지않은 정점들만 선택한다는 정의에 어긋납니다.(지금 보는 점보다 가까운 경로가 존재한다면 이전 탐색에서 걸러져서 S에 포함되어있기 때문입니다.)

그렇기 때문에 distance[u] = length[u]입니다.

다음으로는 distance[w]를 업데이트하는 부분입니다.

위의 그림에서와 같이 시작점 v0에서 u를 거쳐 w로 가는 경로와 v0에서 w로 가는 경로의 길이 중 가까운 경로를 distance[w]로 설정하고 S와 V-S를 최신화해줍니다.

distance[ ]를 업데이트할 때 각각의 간선들이 2번씩 체크되는데 그 이유는 다음과 같습니다.

아직 최단 경로를 찾지 못한 정점 중 u에 인접한 정점들의 거리를 업데이트 할 때 u와 w를 연결하는 간선이 체크가 됩니다.

정점 w의 최단 경로가 찾아졌기 때문에, w는 S에 속하게됩니다. 아직 최단 경로를 찾기 못한 정점들에서 w까지의 거리를 업데이트하는 과정에서 u에서 w로의 간선이 다시 한 번 체크됩니다.

이런 방식으로 코드를 작성하면 각 정접들에대해서 u에 인접한 모든 간선을 살펴보는 연산이 추가되기 때문에 최악의 경우 시간복잡도가 O(𝑛^2)가 되버립니다. 이를 개선하는 방법으로는 대표적으로 우선순위 큐를 사용하는 방법(O( 𝑛 + 𝑚 log 𝑛))과 피보나치 힙을 사용하는 방법(O(𝑛 log 𝑛 + 𝑚))이 있습니다.

피보나치 힙에대한 내용은 피보나치 힙 을 확인해주시고, 지금은 우선순위 큐에대한 내용을 다루겠습니다.

다익스트라 알고리즘 로직(우선순위 큐)

  • 첫 번째 정점을 기준으로 연결되어있는 정점들을 추가해가면서 최단 거리를 갱신합니다.

    첫 정점부터 각 노드 사이의 거리를 저장하는 배열을 만든 후에 첫 정점의 인접 노드 간의 거리부터 먼저 계산하면서, 첫 정점부터 해당 노드 사이의 가장 짧은 거리를 해당 배열에 업데이트 합니다. 이런 로직은 현재의 정점에서 갈 수 있는 정점들부터 처리한다는 점에서 BFS와 비슷합니다.

    다양한 다익스트라 알고리즘이 있지만 가장 개선된 형태인 우선순위 큐를 사용하는 방식을 다뤄보겠습니다.

    먼저 우선 순위 큐를 간단하게 설명하면 MinHeap 방식을 사용해서 현재 가장 짧은 거리를 가진 노드 정보를 먼저 꺼냅니다.

    꺼낸 노드는 다음의 과정을 반복합니다.

    1. 첫 정점을 기준으로 배열을 선언핸 첫 정점에서 각 정점까지의 거리를 저장합니다.
      • 초기에는 첫 정점의 거리를 0, 나머지는 무한대(inf)로 저장합니다.
      • 우선순위 큐에 순서쌍 (첫 정점, 거리 0) 만 먼저 넣어줍니다.
    2. 우선순위 큐에서 노드를 꺼냅니다.
      • 처음에는 첫 정점만 저장된 상태이기때문에 첫번째 정점만 꺼내집니다.
      • 첫 정점에 인접한 노드들 각각에대해서 첫 정점에서 각 노드로 가는 거리와 현재 배열에 저장되어있는 첫 정점에서 각 정점까지의 거리를 비교합니다.
      • 배열에 저장되어 있는 거리보다, 첫 정점에서 해당 노드로 가는 거리가 더 짧은 경우, 배열에 해당 노드의 거리를 업데이트 합니다.
      • 배열에 해당 노드의 거리가 업데이트된 경우, 우선순위 큐에 해당 노드를 넣어줍니다.
    3. 2번의 과정을 우선순위 큐에서 꺼낼 노드가 없을 때까지 반복합니다.
  • 우선순위 큐를 사용하면 지금까지 발견된 가장 짧은 거리의 노드에대해서 먼저 계산을 해서 더 긴 거리로 계산된 루트에 대해서는 계산을 스킵할 수 있다는 장점이 있습니다.

pseudo 코드는 다음과 같습니다.

found[], distance[]를 초기화
construct min_heap(V-{s});
for(i = 0; i < n-2; i++) { //𝑛−1iterations(=Θ(𝑛))---(1) 
  distance[u] 최소인 정점 u를 선택합니다. //Θ(1) found[u] = T;로 바로 배열로 접근 가능
  min_heap에서 정점 u를 제거; //O(log𝑛)---(2)
  for(every vertex w adjacent to u) //Θ(𝑚)total---(a)
      if(found[w] == F && distance[u] + cost(u,w) < distance[w]){
        distance[w] = distance[u] + cost(u,w);
        adjust heap(w); //𝑂(log𝑛)foreachedgecheck---(b) 
  } // distance[w]가 수정됐기 때문에 heap을 조정해주는 for문
}

시간 복잡도

위의 pseudo code에서 다음 2가지 과정을 거칩니다.

(1),(a) - 각 정점마다 인접한 간선들을 모두 검사하는 과정 -> 𝑂(𝑛)

(2),(b) - 우선순위 큐에 정점/거리 정보를 넣고 삭제하는 과정 -> 𝑂(log𝑛)

따라서 전체 알고리즘은 O((𝑛+𝑚)log𝑛)입니다.

벨만-포드

다익스트라 알고리즘이 한 시작점에서 다른 모든 정점까지의 최단 거리를 구하는 유용한 알고리즘이지만, 음수 간선이 있는 그래프의 경우에는 그 정당성이 보장되지 않습니다. 벨만-포드 알고리즘은 이런 문제점을 해결하는 알고리즘입니다.
벨만-포드 알고리즘은 다익스트라 알고리즘과 똑같은 단일 시작점 최단 경로 알고리즘이지만, 음수 간선이 있는 그래프에 대해서도 최단 경로를 찾을 수 있습니다. 또한 그래프에 음수 사이클이 있어서 최단 거리가 제대로 정의 되지않을 경우도 알려줍니다.

벨만-포드 알고리즘은 시작점에서 각 정점까지 가는 최단 거리의 상한선을 적당하게 예측한 뒤에 예측 값과 실제 최단 거리 사이의 오차를 반복적으로 줄여가는 방식으로 동작합니다.
벨만-포드 알고리즘은 너비 우선 탐색을 기반으로 작동합니다.
수행 과정에서 각 정점까지의 최단 거리의 상한을 담은 배열 upper[ ]을 유지합니다.
이 값은 알고리즘이 진행되면서 점점 줄어들며, 알고리즘이 종료되는 시점에는 실제 최단 거리를 담게 됩니다.

벨만-포드의 동작 과정

  1. 알고리즘이 시작되는 시점에는 그래프의 구조에 대해서 아는 것은 시작점에서 시작점까지의 최단 거리가 0이라는 것 뿐입니다. 그렇기 때문에 upper[s] = 0으로 초기화하고, 나머지 원소들은 모두 아주 큰 수인 INF = Integer.MAX_VALUE 와 같이 초기화를 합니다.
  2. 벨만-포드 알고리즘은 이 예측값을 실제 최단 거리에 더 가깝게 갱신하기 위해서 다음과 같은 최단 거리의 특성을 이용합니다.

시작점에서 u와 v까지의 최단 거리 dist[u]와 dist[v]라 할 때 다음 조건은 항상 참입니다. w(u,v)는 u에서 v까지의 거리를 의미합니다.
dist[v] <= dist[u] + w(u,v)

이 속성을 이용하면 upper의 값을 실제 최단 거리에 가깝게 보정할 수 있습니다.
upper[u] + w(u,v) < upper[v]인 상황을 통해서 예를 들어보겠습니다.
u까지 가는 최단 거리는 항상 upper[u]이거나 upper[u]보다 짧습니다. 그 뒤에 (u,v)를 붙인 경로의 길이는 최대 upper[u]+w(u,v)이기 때문에, upper[v]를 upper[u]+w(u,v)로 줄이는 것이 가능합니다.

  1. 벨만-포드 알고리즘은 위와 같은 과정을 모든 간선에 대해서 반복적으로 실행하면서, 최종적으로 실제 최단 거리를 구할 수 있게됩니다.

벨만-포드의 종료 조건과 정당성 증명

하지만 위와 같은 방식으로는 몇 번이나 어떤 순서로 완화를 해야할지가 명확하지 않습니다.
또한 , upper가 실제 최단 거리와 같아진 다는 것을 어떻게 알 수 있을까요? 그리고 어떤 정점을 택하더라도 upper[u] = dist[u]가 되는 것이 확실할까요?

모든 간선에대해서 완화를 시도하는 작업을 x번 반복하면 x개 이하의 간선을 사용하는 최단 경로들을 전부 찾을 수 있습니다.

따라서 모든 간선이 전부 완화가 실패할 때까지 반복하면 모든 최단 경로를 찾을 수 있습니다.
그렇다면 몇 번을 반복해야 최단 경로를 구할 수 있을지 미리 알 수 있는 방법은 없을까요??
음수 사이클이 없는 그래프에서 최단 경로가 한 정점을 2번 지나는 일이 없다는 특징을 이용하면, 최단 경로가 포함하는 간선의 상한선을 쉽게 알 수 있습니다.

최단 경로는 최대 |V|개의 정점을 갖기 때문에 최대 |V|-1개의 간선을 가질 수 있습니다.
따라서 모든 간선에 대한 완화 과정은 전체 |V|-1번이면 충분합니다.

그렇다면 음수 간선이 존재하는 경우에는 최단 거리를 어떻게 구할 수 있을까요?

벨만-포드의 음수 사이클의 판정

그래프에 음수 사이클이 존재할 경우 벨만-포드 알고리즘도 의미없는 값을 반환하게됩니다. 하지만 간단한 변형을 통해서 벨만-포드 알고리즘이 음수 사이클의 존재 여부를 판정하게 만들 수 있습니다.
벨만-포드 알고리즘은 그래프가 음수 사이클이 존재하면 의미없는 값을 반환하는 것이 아니라 음수 사이클이 존재한다는 오류를 반환하게 합니다.

음수 사이클의 존재 여부를 판정하려면 |V|-1번 모든 간선에 대한 완화를 시도하는 대신 1번 더 해서 |V|번 완화를 시도하면 됩니다. 그래프에 음수 사이클이 없다면 |V|-1번만 반복해도 모든 최단 거리를 찾을 수 있기 때문에, 마지막 반복의 완화는 전부 실패할 것이기 때문입니다. 반면, 음수 사이클이 있는 경우에는 |V|번째 반복에도 항상 완화가 한 번은 성공합니다.

구현

int V;//그래프의 정점의 개수
ArrayList<Edge> adj = new ArrayList<Edge>();

int[] d = new int[V+1];
void bellmanford(int start){
	for(int i = 1;i<=n;i++){
		d[i] = Integer.MAX_VALUE;
	d[start] = 0;
	for(int i = 1;i<=n-1;i++){
		for(int j = 0;j<adj.size();j++){
			Edge temp = adj.get(j);
			if(d[temp.end] > d[temp.start] + temp.weight){
				d[temp.end] = d[temp.start] + temp.weight;
			}
		}
	}
}
class Edge{
    int start;
    int end;
    int weight;

    public Edge(int start, int end, int weight) {
        this.start = start;
        this.end = end;
        this.weight = weight;
    }
}

실제 경로 계산하기

벨만-포드 알고리즘을 수행하는 과정에서 각 정점을 마지막으로 완화시킨 간선들을 모으면 스패닝 트리를 얻을 수 있습니다. 각 정점을 마지막으로 완화시킨 간선들은 항상 최단 경로 위에 있기 때문에, 각 정점에서부터 스패닝 트리의 루트인 시작점까지 거슬러 올라가는 경로는 항상 시작점에서 해당 경로까지의 최단경로가 됩니다.
이는 너비 우선 탐색이나 다익스트라 알고리즘과 비슷한 방식으로 실제 정점의 목록을 계산할 수 있습니다.

플로이드의 모든 쌍 최단 거리 알고리즘

다익스트라 알고리즘과 벨만-포드 알고리즘은 시작점을 기준으로 다른 정점들까지의 최단 경로를 구하는 알고리즘입니다.
하지만 문제에 따라서는 한 개의 시작점이 아닌 모든 정점 쌍에 대해서 둘 사이의 최단 거리를 구해야 할 때도 있습니다.
이런 문제를 다익스트라나 벨만-포드 알고리즘을 이용해서 그래프의 존재하는 모든 쌍의 최단 거리를 구하면 시간 복잡도는 다음과 같습니다.

  • 다익스트라
    • Linear Array 를 사용한 경우 0(V^3+VE) = 0(V^3)
    • 우선 순위 큐(min-heap)을 사용한 경우 O((V^2)*logV+VE)
  • 벨만-포드
    • O(V^2E) = O(V^4)
      이보다 조금 더 빠르고 간단한 방법으로 모든 쌍 간의 최단 거리를 구하는 방법이 플로이드의 모든 쌍 최단 거리 알고리즘입니다.

플로이드 알고리즘은 그래프의 모든 정점 쌍의 최단 거리를 저장하는 2차원 배열 dist[ ][ ]를 계산하는 방식으로 동작합니다. 이 때, dist[u][v]는 u에서 v로 가는 최단 거리를 의미합니다.

플로이드 알고리즘은 경로의 경유점이라는 개념을 이용해서 동작합니다.

정점의 경유점
두 정점 u,v를 잇는 어떤 경로가 있고 그 경로는 시작점u와 끝점 v를 항상 지난다고 가정하겠습니다.
이 경로는 다른 정점들을 지나쳐 갈 수 있습니다. 그 이유는 u와 v를 직접 연결하는 간선이 없거나, 다른 정점을 경유해서 가는 경로가 전체 경로가 더 짧을 수 있기 때문입니다. 이 때 경로가 거쳐가는 정점들을 경유점이라고 합니다.
정점 집합 S에 포함된 정점만을 경유점으로 사용해서 u에서 v로 가는 최단 경로의 길이를 Ds(u,v)라고 하겠습니다.

S에 포함된 정점만을 경유점으로 사용해 u에서 v로 가는 최단 경로를 알고있다고 가정하겠습니다. S 중에 정점을 하나 골라서 x라고 하면, 최단 경로는 x를 경유할 수도 있고 경유하지 않을수도 있습니다.

  1. 경로가 x를 경유하지 않는다 : 이 경로는 S- {x} 에 포함된 정점들만을 경유점으로 사용합니다.
  2. 경로가 x를 경유한다 : 이 경로는 u에서 x로 가는 구간과 x에서 v로 가는 구간으로 나눌 수 있습니다. 이 2개의 부분 경로들은 각각 u와 x, x와 v를 잇는 최단 경로들이어야 합니다.
    당연하게도 두 개의 부분 경로들은 x를 경유하지않으며, 따라서 S-{x}에 포함된 정점들만을 경유점으로 사용합니다.

S를 경유점으로 사용해 u에서 v로 가는 최단 경로는 위 2가지 중 더 짧은 경로가 될 것입니다.
Ds(u,v)를 다음과 같이 재귀적으로 정의할 수 있습니다.

KakaoTalk_Photo_2021-08-28-22-51-13
위의 점화식을 살짝만 수정하면 모든 쌍에대한 최단 거리 문제를 동적 계획법으로 해결할 수 있습니다.

표기법을 살짝 고쳐서 Ck = D_s_k라 하면 다음과 같이 표현할 수 있습니다.
KakaoTalk_Photo_2021-08-28-23-13-25
이 점화식은 C_k의 모든 값은 C_(k-1)에만 의존하기 때문에 동적 계획법을 이용할 수 있습니다.

구현

구체적인 구현에 앞서 플로이드 알고리즘의 프로토타입은 다음과 같습니다.
d[k,i,j] = set{1,2,...,k} 에 포함되는 i에서 j 로 가는 최단 경로
k가 0인 경우에는 중간 경로 없는 vertex i에서 vertex j로 바로 가는 경로이기 때문에 d[0,i,j] = w[i,j] 입니다.

for(int k = 0;k<n;k++){
	for(int i = 0;i<n;i++){
		for(int j = 0;j<n;j++){
			if(k == 0) d[k][i][j] = w[i,j];
			else d[k][i][j] = min(d[k-1][i][j],d[k-1][i][k] + d[k-1][k][j]);
		}
	}
}

위의 코드에서 볼 수 있듯 플로이드 알고리즘의 시간복잡도는 3중 for문을 돌기 때문에 O(|V|^3)입니다. 공간복잡도 역시 3차원 배열을 사용하기 때문에 (|V|^3) 입니다.
여기서 공간 복잡도를 줄일 수 있는 방법이 있습니다.
k번째 case를 계산할 때 k-1번째의 연산으로 부터 저장된 정보가 overwrite 될 수 있습니다. 그 이유는 출발점이나 도착점이 k번 정점일 때 사용 가능한 경유점의 목록에 k가 추가되는 것은 아무 의미가 없기 때문에, 이를 구분하지 않고 써도 되기 때문입니다.
예를 들면, 지하철 역에 들러 학교로 가는 최단 경로지하철역과 학교를 들러 학교로 가는 최단 경로는 똑같기 때문입니다.
이런 이유로 우리는 더이상 3차원 배열을 사용해서 k번째 연산과 k-1번째 연산을 구분할 필요없이 한 개의 2차원 배열을 이용해서 코드를 짤 수 있습니다.

for(int k = 0;k<n;k++){
	for(int i = 0;i<n;i++){
		for(int j = 0;j<n;j++){
			d[k][i][j] = min(d[k-1][i][j],d[k-1][i][k] + d[k-1][k][j]);
		}
	}
}

2차원 배열을 사용하면 시간 복잡도는 그대로지만 공간 복잡도는 O(|V|^3)에서 O(|V|^2)로 줄일 수 있습니다.

문제 추천

다익스트라
MST
플로이드
벨만-포드

SQL Injection

SQL Injection

SQL 인젝션이란?

SQL 인젝션은 웹 사이트의 보안상 허점을 이용해 특정 SQL 쿼리문을 전송해 공격자가 원하는 데이터베이스의 중요한 정보를 가져오는 해킹 기법이다.

대부분 클라이언트가 입력한 데이터를 제대로 필터링하지 못하는 경우에 발생한다.

공격의 쉬운 난이도에 비해 피해가 상당하기 때문에 보안 위협 1순위로 불릴만큼 중요한 기법이다.

SQL 인젝션의 종류와 공격 방법

Error based SQL Injection

논리적 에러를 이용한 SQL 인젝션

가장 많이 쓰이고, 대중적인 공격 기법이다.

SELECT * FROM Users WHERE id = 'INPUT1' AND password = 'INPUT2'
SELECT * FROM Users WHERE id = ' ' OR 1=1 -- 'AND password = 'INPUT2'
--> SELECT * FROM Users

첫번째 쿼리문은 일반적으로 로그인 시 많이 사용되는 SQL 구문이다.

해당 구문에서 입력값에 대한 검증이 없음을 확인하고, 악의적인 사용자가 임의의 SQL 구문을 주입했다.

주인된 내용은 ' OR 1=1-- 으로 WHERE 절에 있는 ' 를 닫아주기 위한 ' 와 OR 1=1 라는 구문을 이용해 WHERE절을 모두 참으로 만들고, —를 넣어주어서 뒤의 구문을 주석 처리를 해준 것이다.

매우 간단한 구문이지만 결론적으로 Users 테이블에 있는 모든 정보를 조회하게 됨으로써 가장 먼저 만들어진 계정으로 로그인에 성공하게 된다.

보통 관리자 계정을 맨 처음으로 만들기 때문에 관리자 계정으로 로그인할 수 있게 되며, 악의적인 사용자는 관리자의 권한을 이용해 또 다른 2차피해를 발생시킬 수 있게 된다.

UNION based SQL Injection

UNION 명령어를 이용한 SQL 인젝션

UNION은 여러개의 SQL문을 합쳐 하나의 SQL 문으로 만들어주는 방법이다.

정상적인 쿼리문에 Union 키워드를 사용해 인젝션에 성공하면, 원하는 쿼리문을 실행할 수 잇게 된다.

Union Injection을 성공하기 위해서는 두 테이블의 컬럼 수가 같아야 하고, 데이터 형이 같아야 한다는 두 가지의 조건이 있다.

SELECT * FROM Board WHERE title like '%INPUT%' OR contents like '%INPUT%'
SELECT * FROM Board WHERE title LIKE '% ' 
UNION SELECT null, id, password FROM Users 
-- %' AND contents '% UNION SELECT null, id, password FROM Users -- %'

첫번째 쿼리문은 Board라는 테이블에서 게시글을 검색하는 쿼리문이다.

입력값을 title과 content 컬럼의 데이터랑 비교한 뒤 비슷한 글자가 있는 게시글을 출력한다.

여기서 입력값으로 Union 키워드와 함께 컬럼 수를 맞춰서 SELECT 구문을 넣어주게 되면 두 쿼리문이 합쳐져서 하나의 테이블로 보여지게 된다.

현재 인젝션한 구문은 사용자의 id와 password를 요청하는 쿼리문이다.

인젝션이 성공하게 되면, 사용자의 개인정보가 게시글과 함께 화면에 보여지게 된다.

password를 그대로 DB에 저장하지는 않겠지만 인젝션이 가능하다는 점에서 이미 그 이상의 보안위험에 노출되어 있다.

이 공격도 역시 입력값에 대한 검증이 없기 때문에 발생한다.

Blind SQL Injection

Boolean based Blind SQL 인젝션

Blind SQL Injection은 데이터베이스로부터 특정한 값이나 데이터를 전달받지 않고 단순히 참과 거짓의 정보만 알 수 있을 때 사용한다.

로그인 폼에 SQL Injection이 가능하다고 가정했을 때, 서버가 응답하는 로그인 성공과 로그인 실패 메시지를 이용해 DB의 테이블 정보 등을 추출한다.

즉, 쿼리를 삽입하였을 때 쿼리의 참과 거짓에 대한 반응을 구분할 수 있을 때 사용되는 기술이다.

SELECT * FROM Users WHERE id = 'INPUT1' ANS password = 'INPUT2'
SELECT * FROM Users WHERE id = ' abc123' and 
ASCII(SUBSTR((SELECT name FROM information_schema.tables 
WHERE table_type='base table' limit 0,1),1,1)) > 100 --

위 쿼리는 Blind Injection을 이용해 DB의 테이블명을 알아내는 방법이다.

인젝션이 가능한 로그인 폼을 통해 악의적인 사용자는 임의로 가입한 abc123이라는 아이디와 함께 뒤의 구문을 주입한다.

해당 구문은 MySQL에서 테이블 명을 조회하는 구문으로 limit 키워드를 통해 하나의 테이블만 조회하고, SUBSTR 함수로 첫 글자만, 그리고 마지막으로 ASCII를 통해 ascii 값으로 변환한다.

만약 조회되는 테이블 명이 Users 라면 'U' 가 조회될 것이고, 뒤의 100이라는 숫자와 값을 비교하게 된다.

거짓이면 로그인 실패, 참이 될 때까지 뒤의 100이라는 숫자를 변경해가면서 비교한다.

참이 나오는 것을 통해 단기간 내에 테이블 명을 알아낼 수 있다.

Time based SQL

어떤 경우에는 응답의 결과가 항상 동일해 해당 결과만으로 참과 거짓을 판별할 수 없는 경우가 있을 수 있다.

이런 경우 시간을 지연시키는 쿼리를 주입(Injection)하여 응답 시간의 차이로 참과 거짓 여부를 판별할 수 있다.

사용되는 함수는 MySQL 기준 SLEEP 과 BENCHMARK 이다.

SELECT * FROM Users WHERE id = 'INPUT1' AND password = 'INPUT2'
SELECT * FROM Users WHERE id = ' abc123' OR 
(LENGTH(DATABASE())=1 AND SLEEP(2)) 
-- ' AND password = 'INPUT2'

위 쿼리문은 Time based SQL Injection을 사용해 현재 사용하고 있는 데이터베이스의 길이를 알아내는 방법이다.

로그인 폼에 주입되었으며 임의로 abc123이라는 계정을 생성한다.

악의적인 사용자가 해당 구문을 주입하면, LENGH 함수는 문자열의 길이를 반환하고, DATABASE 함수는 데이터베이스의 이름을 반환한다.

주입된 구문에서, LENGTH(DATABASE()) = 1 가 참이면 SLEEP(2) 가 동작하고, 거짓이면 동작하지 않는다.

이를 통해 숫자 1 부분을 조작해 데이터베이스의 길이를 알 수 있다.

만약 SLEEP 이라는 단어가 치환 처리되었다면, 다른 방법으로 BENCHMARK 나 WAIT 함수를 사용할 수 있다.

Stored Procedure SQL Injection

저장된 프로시저에서의 SQL Injection

저장 프로시저(Stored Procedure)은 일련의 쿼리들을 모아 하나의 함수처럼 사용하기 위한 것이다.

공격에 사용되는 대표적인 저장 프로시저는 MS-SQL에 있는 xp_cmdshell로 윈도우 명령어를 사용할 수 있게 된다.

단, 공격자가 시스템 권한을 획득해야 하므로 공격난이도가 높으나 공격에 성공한다면 서버에 직접적인 피해를 입힐 수 있는 공격이다.

Mass SQL Injection

다량의 SQL Injection 공격

기존 SQL Injection과 달리 한번의 공격으로 다량의 데이터베이스가 조작되어 큰 피해를 입히는 것을 의미한다.

보통 MS-SQL을 사용하는 ASP 기반 웹 애플리케이션에서 많이 사용되며, 쿼리문은 HEX 인코딩 방식으로 인코딩해 공격한다.

보통 데이터베이스 값을 변조해 데이터베이스에 악성스크립트를 삽입하고, 사용자들이 변조된 사이트에 접속 시 좀비 PC로 감염되게 한다.

이렇게 감염된 좀비 PC들은 DDos 공격에 사용된다.

SQL 인젝션 대응방안

입력 값에 대한 검증

SQL Injection에 사용되는 기법과 키워드는 엄청 많다.

따라서 사용자의 입력 값에 대한 검증이 필요하다.

서버 단에서 화이트리스트를 기반으로 검증해야 하며, 블랙리스트를 기반으로 검증하게 되면 차단리스트를 많이 등록해야 하고, 하나라도 빠지면 공격에 성공하게 된다.

공백으로 치환하는 방법도 많이 쓰이는데, 이 방법도 취약한 방법이다.

예시로 SESELECTLECT 라고 입력 시 중간의 SELECT가 공백으로 치환되면 SELECT 라는 키워드가 완성된다.

공백 대신 공격 키워드와를 의미 없는 단어로 치환되어야 한다.

Prepared Statement 구문 사용

Prepared Statement 구문을 사용하게 되면, 사용자의 입력 값이 데이터베이스의 파라미터로 들어가기 전 DBMS가 미리 컴파일하여 실행하지 않고 대기한다.

그 후 사용자의 입력 값을 문자열로 인식하여 공격쿼리가 들어간다고 해도, 사용자의 입력은 이미 의미 없는 단순 문자열이기 때문에 전체 쿼리문도 공격자의 의도대로 작동하지 않는다.

Error Message 노출 금지

공격자가 SQL Injection을 수행하기 위해서는 데이터베이스의 정보(테이블, 컬럼 명)가 필요하다.

데이터베이스 에러 발생 시 따로 처리하지 않는다면, 에러가 발생한 쿼리문과 함께 에러에 관한 내용을 반환해주기 때문에 공격자에게는 힌트가 될 수 있다.

따라서 데이터베이스에 대한 오류가 발생할 때는 사용자에게 보여줄 수 있는 페이지를 제작하거나 메시지박스를 띄우도록 해야 한다.

웹 방화벽 사용

웹 공격 방어에 특화되어있는 웹 방화벽을 사용하는 것도 하나의 방법이다.

웹 방화벽은 소프트웨어형, 하드웨어형, 프록시형 세 가지 종류로 나눌 수 있다.

소프트웨어형은 서버 내에 직접 설치하는 방법이고, 하드웨어형은 네트워크 상에서 서버 앞 단에 직접 하드웨어 장비로 구성하는 것이며 마지막으로 프록시형은 DNS 서버 주소를 웹 방화벽으로 바꾸고 서버로 가는 트래픽이 웹 방화벽을 먼저 거치도록 하는 방법이다.

OS-PCB와 Context Switching 정리입니다.

PCB와 Context Switching

PCB(Process Controll Block)

PCB는 OS에서 프로세스에 대한 중요 정보를 저장하고 있는 자료구조입니다. OS는 프로세스를 관리하기 위해 프로세스의 생성과 동시에 고유한 PCB 를 생성합니다.

프로그램 실행 -> 프로세스 생성 -> 프로세스 주소 공간에 (stack, data, stack) 생성 -> 이 프로세스의 메타데이터들이 PCB에 저장

CPU에서 프로세스 수행 중에 작업을 멈추고 다른 프로세스를 처리해야 하는 경우가 생깁니다. 그 때, 기존에 수행하고 있던 프로세스에 프로세스의 정보를 저장하는 곳이 PCB입니다.

OS는 PCB에 현재까지 수행한 프로세스의 상태를 저장하고 CPU를 반납합니다. 그래서 PCB에는 이전까지 수행하고 있던 프로세스가 다음에 수행해야 할 상태값이 저장됩니다.

프로세스가 종료되면 OS는 해당 프로세스의 PCB를 제거합니다.

PCB에 저장되는 정보

  • 프로세스 식별자(Process ID, PID) : 프로세스 식별번호
  • 프로세스 상태 : new, ready, running, waiting, terminated 등의 상태를 저장
  • 프로그램 카운터 : 프로세스가 다음에 실행할 명령어의 주소
  • CPU Register 정보
  • CPU 스케쥴링 정보 : 프로세스의 우선순위, 스케줄 큐에 대한 포인터 등
  • 메모리 관리 정보 : 페이지 테이블 또는 세그먼트 테이블 등과 같은 정보를 포함
  • Accounting 정보 : 사용된 CPU 시간, 시간제한, 계정번호 등
  • 입출력 상태 정보 : 프로세스에 할당된 입출력 장치들과 열린 파일 목록

Context Switching

Context Switching은 CPU가 현재 수행하고 있는 작업(Process, Thread)의 상태를 저장하고 다음 진행할 작업의 상태 및 Register 값들에 대한 정보(Context)를 읽어 새로운 작업의 Context 정보로 교체하는 과정을 말합니다. 여기서 Context란 CPU가 다루는 작업에 대한 정보를 말하고, 대부분의 정보는 Register에 저장되고 PCB로 관리됩니다. 그래서 이를 위의 PCB의 역할에 맞추어 말하면 CPU가 이전의 프로세스 상태를 PCB에 보관하고, 또 다른 프로세스의 정보를 PCB에서 읽어서 레지스터에 적재하는 과정을 말합니다.

Context Switching은 Interrupt가 발생하거나, 실행 중인 CPU 사용 시간을 모두 소모하거나, 입출력을 위해 대기해야 하는 경우 발생합니다.

즉, Context Switching은 프로세스가 Ready -> Running , Running -> Ready , Running -> Block 처럼 상태 변경 시에 발생합니다. 그러므로 Context Switching을 하는 주체는 CPU 스케쥴러입니다.

Context Switching 수행 과정

프로세스 P0과 P1이 존재하고 P0이 CPU를 점유 중이고, P1이 대기 중일 때 Interrupt나 System Call이 발생하여 P1이 CPU를 점유하게 된다면 위와 같은 Context Switching 과정이 수행 됩니다.

Context Switching Overhead

Context Switching이 발생하게 되면 다음과 같은 과정이 필요합니다.

  • Cache 초기화

  • Memory Mapping 초기화

  • 메모리의 접근을 위해서 Kernel은 항상 실행되어야 합니다.

이 과정에서 소요되는 시간을 Cost라고 표현합니다. Cost는 낭비되는 시간이라고 생각할 수 있습니다. 이렇게 어떤 과정을 할 때 소모되는 Cost들을 Overhead라고 합니다. 그러므로 어떤 작업을 할 때 Overhead가 높다는 것은 그 과정을 수행하기 위해 필요한 다른 작업들의 Cost가 높다고 할 수 있습니다.

따라서 Context Switching은 Overhead가 높은 작업이고 잦은 Context Switching 는 성능 저하를 가져옵니다.

Context Switching이 높은 Overhead를 갖음에도 수행하는 이유는 그것을 감안해도 더 이득이기 때문입니다. 예를들어 프로세스를 수행하다가 I/O event가 발생하여 BLOCK 상태로 전환시켰을 때, CPU가 그냥 놀게 놔두는 것보다 다른 프로세스를 수행시키는 것이 효율적이므로, Context Switching을 수행하여 CPU로 다른 프로세스를 실행시킵니다.

Context Switching과 Interrupt

CPU는 하나의 프로세스 정보만을 기억합니다. 여러 개의 프로세스가 실행되는 다중 프로그래밍 환경에서 CPU는 각각의 프로세스의 정보를 저장했다 복귀하고 다시 저장했다 복귀하는 일을 반복합니다. 프로세스의 저장과 복귀는 프로세스의 중단과 실행을 의미합니다. 프로세스의 중단과 실행 시 Interrupt가 발생하므로, Context Switching이 많이 일어난다는 것은 Interrupt가 많이 발생한다는 것을 의미합니다.

Context Switching과 시간 할당량

프로세스들의 시간 할당량은 시스템 성능의 중요한 역할을 합니다. 시간 할당량이 적을수록 사용자 입장에서는 여러 개의 프로세스가 거의 동시에 수행되는 느낌을 갖지만 Interrupt의 수와 Context Switching의 수가 늘어납니다.

클러스터링

클러스터링(Clustering)

클러스터링은 여러 개의 DB 서버를 구축하여 DB 서버를 다중화 하는 것입니다.

클러스터링 구조는 DB 서버들 간의 데이터 무결성을 유지하기 위해 데이터를 동기화하여 처리합니다.

그래서 동기화하는 시간이 필요하므로 비동기 방식의 Replciation에 비해 쓰기 성능이 떨어질 수 있습니다.

데이터를 동기화 하는 방식은 클러스터링 방식과 DB 서비스에 따라 다소 차이가 있습니다.

클러스터링은 Fail Over 기능으로 고가용성(High Availability)을 유지하여 Single point of failure와 같은 문제를 해결할 수 있습니다.

Fail Over : 시스템에 문제가 생기면 예비 시스템으로 자동 전환되는 기능

Single point of failure(SPOF) : 부분의 문제가 전체 시스템을 정지시켜 버리는 것

구성 방식

Active - Active Clustering

  • Cluster을 이루는 DB 서버들을 모두 Active 상태로 구성합니다.
  • 장점
  • 서버 하나가 중단되도 다른 서버들이 역할을 바로 수행하여 서비스 중단이 거의 없습니다.
  • 동시에 사용되는 CPU와 메모리가 증가하므로 성능을 향상시킬 수 있습니다.
  • load balance를 이용한 병렬 처리가 가능합니다.

  • 단점
  • DB 서버들의 데이터 동기화 구현이 복잡합니다.

  • 저장소 하나를 공유하면 병목현상이 발생할 수 있습니다.

  • 여러 대의 서버를 동시에 운영하므로 비용이 크게 증가합니다.

병목 현상 : 전체 시스템의 성능이나 용량이 하나의 구성요소로 인해 제한을 받는 현상.

  • Cluster들의 상태를 모니터링하는 서버를 따로 둘 수도 있습니다.

  • 대표적인 서비스: Oracle의 RAC(Real Application Cluster) 참고사이트

Active - Standby Clustering

  • 하나의 DB 서버는 Active 상태, 나머지 서버는 Standby 상태로 구성합니다.

  • Active DB 서버가 다운됐을 때 Standby DB 서버를 Active상태로 전환합니다.

  • Standby DB서버에서 Active DB서버에 신호(Heatbeat 등)를 주기적으로 보내며 시스템 상태를 확인합니다.

  • 장점

  • Active - Active에 비해 적은 비용이 듭니다.
  • 단점
  • Active 서버가 다운되었을 때 다른 서버가 Active로 전환되는데 시간이 들어서 서버가 중단되는 시간이 있습니다.
  • Hot-Standby : Active DB 서버가 다운되기 전에도 DB가 작동되고 있도록 구성합니다.

  • Cold-Standby : 초기 세팅 후에 작동하지 않다가 Active DB 서버가 다운된 시점에 작동하는 구성합니다.

  • Hot-Standby는 Cold-Standby 보다 서버 가동 시간이 짧지만 비용이 더 많이 듭니다.

퀵 정렬, 구간 트리(세그먼트 트리)

퀵 정렬(Quick Sort)

퀵 정렬의 개념

임의의 피봇(pivot)을 기준으로 해당 피봇 값보다 작은 데이터는 피봇의 왼쪽에, 큰 데이터는 피봇의 오른쪽에 배치한 뒤, 왼쪽 부분과 오른쪽 부분을 다시 퀵 정렬 방법으로 정렬하는 알고리즘

전체 데이터를 2개의 부분으로 분할한 뒤, 각각의 부분을 다시 퀵정렬하는 전형적인 분할-정복 알고리즘

중복적인 데이터에 대해 정렬 후에 정렬 전의 순서가 유지되지 않는 불안정 정렬이다.

접근법

quick_sort(int[] list, int left, int right) {
    if (left < right) {
        int pivot = partition(list, left, right);
        quick_sort(list, left, pivot-1);
        quick_sort(list, pivot+1, right);
    }
}

피봇을 기준으로 분할하기

퀵 정렬 구현의 핵심은 위 알고리즘의 partition()부분인 전체 배열을 피봇을 기준으로 두 부분으로 분할하는 것이다. 분할의 원리는 다음과 같다.

QuickSort

  1. 배열의 임의의 원소를 pivot, 가장 첫번째 원소를 low, 가장 마지막 원소를 high이라고 지정한다.
  2. low 값이 pivot 보다 작을 동안 low 인덱스를 증가시킨다. 반복문이 계속될 때까지 조건에 부합하는 원소들은 pivot의 왼쪽 부분 리스트가 된다.
  3. high 값이 pivot 보다 클 동안 high 인덱스를 감소시킨다. 반복문이 계속될 때까지 조건에 부합하는 원소들은 pivot의 오른쪽 부분 리스트가 된다.
  4. 2와 3의 반복문을 탈출하여 도달한 위치는 각각 low 값이 pivot 보다 크고, high 값이 pivot 보다 작은 경우이므로, 이 때 멈춘 두 원소 자리를 교환한다.
  5. lowhigh의 위치가 엇갈리지 않을 때까지 2~3의 과정을 반복한다.
  6. lowhigh의 위치가 엇갈리는 때 highpivot의 자리를 교환한다. 최종적으로 pivot의 위치를 기준으로 왼쪽에는 pivot보다 작은 원소들이, 오른쪽에는 pivot보다 큰 원소들이 위치하게 된다.

퀵 정렬 시간 복잡도

  • 분할
    n개의 원소를 가진 배열을 정렬한다고 하면, 배열은 각 단계에서 partition()을 거칠 때마다 n/2, n/4, n/8 ..., n/(2^k) 의 크기로 분할된다. 이 과정은 배열의 원소 개수가 1개가 될 때까지 반복되므로 n/(2^k) = 1일 때까지 반복된다. 따라서 k = log n만큼의 연산이 수행된다.
  • 원소 교환
    각 왼쪽 부분와 오른쪽 부분에서는 교환을 위해 거의 부분의 모든 원소들을 확인해야 하므로 각 부분의 원소 개수만큼(n) 연산이 수행된다.

위 계산 결과 최종적으로 평균 O(n log n)의 시간복잡도를 보이게 된다.

하지만 매번 단 하나의 원소를 가진 부분와 나머지 원소들을 가진 부분으로 나뉘는 경우 등, 배열의 초기값이나 피봇의 선택 방법에 따라 최악의 경우 O(n^2)의 시간 복잡도를 보일 수 있다.

구현

class QuickSort {
    static int low;       // 왼쪽 커서
    static int high;     // 오른쪽 커서
    static int pivot;    // 피봇

    // 원소 교환
    static void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }

    // 분할
    static void partition(int[] arr, int left, int right) {
        low = left;                               // 왼쪽 커서
        high = right;                           // 오른쪽 커서
        pivot = arr[(low+high)/2];     // 피봇

        do {
            while(arr[low] < pivot) low++;
            while(arr[high] > pivot) high--;
            if (low <= high) swap(arr, low++, high--);
        } while(low <= high);
    }

    // 퀵 정렬
    static void quickSort(int[] arr, int left, int right) {
        partition(arr, left, right);
        if (left < high) quickSort(arr, left, high);
        if (low < right) quickSort(arr, low, right);
    }
}

듀얼 피봇 퀵 정렬(Dual Pivot Quick Sort)

피봇 1개를 기준으로 삼아 정렬하는 퀵 정렬에서 나아가 피봇 2개를 기준으로 정렬하는 알고리즘

임의의 피봇 2개를 기준으로 pivot1 보다 작은 부분, pivot1 ~ pivot2 사이의 부분, pivot2 보다 큰 부분으로 나눈 뒤 각 부분을 다시 듀얼 피봇 퀵 정렬 방법으로 정렬한다.

pivot2는 항상 pivot1보다 크도록 설정해야함을 주의한다.

Dual Pivot QuickSort

듀얼 피봇 퀵 정렬 시간 복잡도

퀵 정렬과 달리 3부분으로 나누기때문에 Θ(nlog3n)정도의 연산이 기대된다. 하지만 최악의 경우에는 퀵 정렬과 같이 O(n^2)의 시간 복잡도를 피할 수 없다.


구간 트리(Segment Tree)

일차원 배열의 특정 구간에 대한 질문을 빠르게 대답하는 데 활용되는 자료구조

대표적으로 구간 원소들의 합, 구간 원소들의 최솟값(RMQ, Range Minimum Query) 등에 대한 질의를 지원한다.

구간 트리 초기화

구간 트리 구간 범위

  • 배열의 각 구간을 표현하는 이진 트리 형태
  • 구간 트리의 루트는 배열의 전체 구간([0, n-1)을 표현
  • 각 트리(i)의 왼쪽 자식(2i)과 오른쪽 자식(2i+1)은 해당 구간을 반으로 나눈 왼쪽 부분와 오른쪽 부분을 표현
  • 리프 노드는 구간의 크기가 1인 경우
  • 전체 구간 크기가 2의 제곱꼴이라면 노드가 2*(h+1)-1개인 완전 이진 트리가 완성되겠지만, 2의 제곱꼴이 아니더라도 남는 노드는 빈 공간으로 남겨두고 2*(h+1)-1 크기의 배열로 표현하는 것이 편함.
  • 구간 트리의 각 노드는 해당하는 구간의 계산 결과를 저장해둔다. 따라서 최소 구간 문제의 경우 각 구간의 최솟값을, 구간의 합 문제의 경우 각 구간의 원소들의 합을 노드에 저장해둔다.
  • 어떤 구간이 주어지더라도 해당 구간은 구간트리 노드에 포함된 구간들의 합집합으로 표현할 수 있다.

구간 트리 질의

구간 트리 구간 최소, 구간 합

구간 트리로 해결하는 가장 대표적인 문제인 구간 최소 문제(RMQ, Range Minimum Query)를 보자.

원리

원하는 구간을 포함하는 구간 집합 중 집합의 크기가 최소인 구간을 찾는다.

  1. 만약 현재 구간이 원하는 구간을 전혀 포함하지 않는다면 무한대를 반환한다.
  2. 만약 현재 구간이 원하는 구간에 완전히 포함된다면 현재 노드(구간의 최솟값을 저장한 상태)를 반환한다.
  3. 위의 두 경우가 아닌 경우(즉, 현재 구간이 원하는 구간을 포함하되 전부를 포함하지 않거나 아니면 최소 크기가 보장되지 않은 경우)에는 현재 구간을 반으로 나눠 위의 과정을 다시 반복하고 난 뒤 결과 중 최솟값을 반환한다.

시간 복잡도

구간 트리의 레벨은 O(log n)이며 질의를 위해 최대 구간 트리의 레벨 만큼 확인하면 되므로 O(log n)의 연산이 필요하다.

구현

class SegmentTreeRMQ
{
    static int sTree[];             // 세그먼트 트리

        // 두 값을 비교해 더 작을 값을 반환하는 함수
	static int minVal(int x, int y) {
		return (x < y) ? x : y;
	}

        // 구간의 중간 위치를 인덱스로 구하는 함수
	static int getMid(int s, int e) { 
		return s + (e - s) / 2;
	}

	// 주어진 배열(전체 구간)에 맞는 완전 이진 트리 만들기
	static void treeInit(int arr[], int n)
	{
		// 트리의 높이
		int x = (int) (Math.ceil(Math.log(n) / Math.log(2)));
		// 트리 최대 크기 (이렇게 직접 계산하지 않고 n*4를 하게되면 쉽게 모든 배열 원소를 포함하는 트리를 만들 수 있지만 메모리 낭비가 발생할 수 있음)
		int max_size = 2 * (int) Math.pow(2, x) - 1;
		sTree = new int[max_size];

		// 생성한 트리에 배열의 원소 삽입
		nodeInit(arr, 0, n-1, 0);
	}

        // 세그먼트 트리 초기화(각 노드에 각 구간의 최솟값을 저장)
	static int nodeInit(int arr[], int treeStart, int treeEnd, int node)
	{
		// 리프노드 혹은 자식 노드들이 이미 각자 구간의 최솟값을 계산하여 저장하고 있는 경우 
		if (treeStart == treeEnd) {
			return sTree[node] = arr[treeStart];
		}

		// 구간을 반으로 나눠가며 재귀적으로 자식 노드들에 각 노드가 포함하는 구간의 최솟값을 저장
		int mid = getMid(treeStart, treeEnd);
		return sTree[node] 
                = minVal(nodeInit(arr, treeStart, mid, node*2),
				         nodeInit(arr, mid+1, treeEnd, node*2+1));
	}

        // 구간 최소를 구하는 메서드
        static int RMQ(int treeStart, int treeEnd, int queryStart, int queryEnd, int node) {
		// 현재 노드에 표현된 구간이 탐색을 원하는 구간을 완전히 배제한다면 무한대 반환
		if (treeStart > queryEnd || treeEnd < queryStart)
			return Integer.MAX_VALUE;

                // 현재 노드에 표현된 구간이 탐색을 원하는 구간에 포함된다면 노드(구간의 최솟값)에 저장된 값 반환
		if (queryStart <= treeStart && queryEnd >= treeEnd)
			return sTree[node];

		// 현재 노드에 표현된 구간이 탐색을 원하는 구간을 일부 포함할 경우 현재 구간을 왼쪽 부분와 오른쪽 부분으로 나눠 다시 질의
		int mid = getMid(treeStart, treeEnd);      // 현재 노드의 구간을 나눔
                // 왼쪽 구간에 대해 질의한 결과와 오른쪾 구간에 대해 질의한 결과 중 최솟값을 채택
		return minVal(RMQ(treeStart, mid, queryStart, queryEnd, 2*node), RMQ(mid+1, treeEnd, queryStart, queryEnd, 2*node+1));
	}

	public static void main(String args[])
	{
		int arr[] = {1, 3, 2, 7, 9, 11};

		// 주어진 배열에 맞는 트리 생성
		initTree(arr, arr.length);

		int queryStart = 1;    // 탐색 원하는 구간 시작 지점
		int queryEnd = 5;      // 탐색 원하는 구간 종료 지점

		System.out.println(
            "Minimum of values in range [" + queryStart + ", " + queryEnd + "] is = " 
            + 
            RMQ(0, n-1, queryStart, queryEnd, 0));
	}
}

만약 구간 합을 구하는 문제로 바꾸어 풀고싶다면 nodeInit() 메서드에서 각 노드에 구간의 최솟값을 저장하지 말고 구간합을 계산해서 저장하면 된다.

구간 트리 갱신

구간 트리 초기화 단계에서 주어진 배열대로 트리를 만들었다. 하지만 이후 배열의 원소를 바꾸어 트리에 다시 반영해야할 수 있다. 구간 트리를 갱신하는 방법에 대해 보자.

원리

원하는 구간을 포함하는 구간 집합 중 집합의 크기가 최소인 구간을 찾는다.

  1. 만약 현재 구간이 원하는 구간을 전혀 포함하지 않는다면 pass
  2. 만약 현재 구간이 원하는 구간에 완전히 포함된다면 현재 노드의 값을 갱신한다.
  3. 위의 두 경우가 아닌 경우(즉, 현재 구간이 원하는 구간을 포함하되 전부를 포함하지 않거나 아니면 최소 크기가 보장되지 않은 경우)에는 현재 구간을 반으로 나눠 위의 과정을 다시 반복하며 값을 갱신한다.

시간 복잡도

변경을 원하는 원소를 포함하는 구간은 구간 트리에 O(log n)개 존재하므로 특정 원소를 변경할 때도 O(log n)만큼의 연산만 수행하면 된다.

구현

void updateTree(int treeStart, int treeEnd, int node, int newValue) {
	// 현재 노드에 표현된 구간이 탐색을 원하는 구간을 완전히 배제한다면 pass
	if (treeStart > node || treeEnd < node) return;

        // 현재 노드에 표현된 구간이 탐색을 원하는 구간에 포함된다면 노드에 저장된 값 갱신
	if (queryStart <= treeStart && queryEnd >= treeEnd) return sTree[node] = newValue;

	// 현재 노드에 표현된 구간이 탐색을 원하는 구간을 일부 포함할 경우 현재 구간을 왼쪽 부분와 오른쪽 부분으로 나눠 다시 갱신 시도
	int mid = getMid(treeStart, treeEnd);      // 현재 노드의 구간을 나눔
        // 값의 변경을 원하는 원소가 포함된 구간의 최솟값을 모두 바꿔줌
	return minVal(updateTree(treeStart, mid, 2*node, newValue), updateTree(mid+1, treeEnd, 2*node+1, newValue));
}

PS 문제 추천

Baekjoon Online Judge > 2042-구간 합 구하기
2021 카카오 채용연계형 인턴십 > 표 편집

데이터 이상(Anomaly)과 정규화

Anomaly와 정규화


데이터 이상(Anomaly)

잘못된 데이터베이스 설계로 인해 데이터들의 불필요한 중복이 발생할 수 있다. 테이블에서 일부 속성들이 종속으로 인해 데이터의 중복이 발생하고, 테이블 조작 시 문제가 발생하는 현상을 데이터 이상(Anomaly) 이라고 한다. 이상의 종류에는 3가지가 있다.

  • 삽입 이상(Insertion Anomaly)

  • 삭제 이상(Deletion Anomaly)

  • 갱신 이상(Update Anomaly)




삽입 이상(Insertion Anomaly)

테이블에 새로운 데이터를 삽입할 때 불필요한 데이터도 함께 삽입해야 하는 이상. 즉, 일부 데이터만 삽입하고 싶어도 입력을 원치 않는 다른 데이터까지 함께 삽입해야 하는 현상을 뜻한다.


위와 같이 (학번, 수강과목)이 PK인 테이블을 가정해보자.

이때 수강과목은 정하지 않고 '엘싸'라는 학생 정보만 저장하고 싶어도 (학번, 수강과목)이 PK이기 때문에 수강과목 데이터를 비워둘 수 없다. 따라서 학생 정보만 삽입하기를 원해도 수강과목이라는 원치 않는 데이터까지 함께 삽입해야 한다.



삭제 이상(Insertion Anomaly)

테이블에서 한 튜플을 삭제할 때 연쇄적으로 삭제가 발생하는 이상. 즉, 삭제를 원치 않는 데이터들까지 함께 삭제되는 현상을 뜻한다.


위 테이블에서 학번이 400, 수강과목 6번인 6행 데이터는 삭제하더라도 큰 문제가 없다. 하지만 학번이 200, 수강과목 2번인 2행 데이터를 삭제하게 되면 '다빈슨'이라는 학생의 정보까지 테이블 상에서 모두 사라지게 된다.



갱신 이상(Insertion Anomaly)

튜플의 속성 값을 갱신할 때 일부 튜플들만 수정하여 데이터들 간에 불일치가 발생하는 이상.


'하로롱'이라는 학생의 학부가 오락부로 바뀌었다고 가정해보자. 이때 미처 다른 튜플에 있는 정보를 수정하지 못한다면 '하로롱' 학생의 학부 정보 간의 불일치가 발생하게 된다.






정규화(Normalization)

데이터 이상 방지를 위해 테이블 속성들 사이의 종속적인 관계를 고려하여 테이블을 무손실 분해하는 과정이다.

1NF, 2NF, 3NF, BCNF, 4NF, 5NF, 6NF까지 총 7개의 정규형이 있다.

비공식적으로는 3NF가 되었으면 정규화되었다고 말할 수 있으며, 실제 현업에서도 제 3NF 정도까지만 수행하고 속도, 튜딩 등 필요에 따라 비정규화(Denormalization) 과정을 수행하기도 한다.



함수적 종속(Functional Dependency)

어떤 릴레이션 R이 있고 X, Y는 각각 그 부분집합이다. 이 때 X의 값을 알면 Y를 바로 식별할 수 있다면 Y는 X에 함수적 종속이고 X를 결정자, Y를 종속자라고 부른다.

ex) X : (학번, 수강과목) -> Y : (성적, 이름, 학부, 등록금), X : (학번) -> Y : (이름, 학부, 등록금)


  1. 완전 함수적 종속

    결정자 X, 종속자 Y가 있을 때 Y가 X의 일부에는 함수적 종속이지 않은 관계

    ex) X : (학번, 수강과목) -> Y1 : (성적), Y2: (이름, 학부)

    Y1 성적은 X의 일부인 학번, 수강과목 각각에 대해서는 종속적이지 않다. 완전함수종속 O

    반면, Y2 (이름, 학부)는 X의 일부인 (학번)에 대해서도 종속적이다. 완전함수종속 X

  2. 부분 함수적 종속

    Y가 X에 종속이면서 X의 일부에도 함수적 종속인 관계

    위 예에서 Y2는 X에 부분 함수적 종속이다.

  3. 이행적 함수 종속

    X, Y, Z 3개의 속성 집합이 있다. Y가 X에 종속이고 Z가 Y에 종속 즉, X -> Y이고 Y -> Z인 관계를 가질 때 X -> Z인 관계도 성립한다. 이 때 Z는 X에 이행 함수적 종속이다.

    ex) X : (학번), Y : (학부), Z : (등록금) 이 때 (학번) -> (학부) -> (등록금)의 관계를 가지므로 Z는 X에 이행 함수적 종속이다.



제 1정규형(1NF)

모든 속성의 도메인이 원자값만으로 되어 있는 정규형이다. 테이블의 모든 속성값들은 반드시 하나의 값만 가져야 한다.


위 테이블에서 수강과목 값이 여러 개를 포함하는 튜플이 존재하므로 이를 각각 분리하여 저장한다.

1NF를 만족하더라도 데이터 이상은 여전히 발생할 수 있다.



제 2정규형(2NF)

테이블이 1NF를 만족하고, 기본키(Primary Key)를 제외한 모든 속성이 기본키에 대해 완전 함수적 종속을 만족하는 정규형이다.


위 테이블에서 (이름, 학부, 등록금) 속성은 기본키 (학번, 수강과목)의 일부인 (학번)에도 종속이므로 부분 함수적 종속인 관계이다. 이때 테이블을 쪼갬으로써 부분 함수적 종속을 제거할 수 있다.

2NF를 만족하더라도 데이터 이상은 여전히 발생할 수 있다.



제 3정규형(3NF)

테이블이 2NF를 만족하고 기본키(Primary Key)를 제외한 모든 속성이 기본키에 대해 이행 함수적 종속을 만족하지 않는 정규형이다.


위 테이블에서 (학번) -> (이름, 학부), (학부) -> (등록금) 관계를 만족하므로 (등록금) 속성은 기본키에 대해 이행 함수적 종속을 만족한다. 이때 테이블을 쪼갬으로써 이행 함수적 종속을 제거할 수 있다.

계산 기하

계산 기하

계산 기하 알고리즘은 점,선, 다각형 등 각종 기하학적인 도형을 다루는 알고리즘을 의미합니다. 3차원의 입체 도형까지 다루는 내용이지만 이번 설명에서는 2차원에대한 내용만 다루겠습니다.

벡터

벡터란 무엇인가?

벡터는 두 점 사이의 상대적인 위치를 표현하는 방향과 거리의 쌍입니다. 시작점과 끝점 사이의 상대적인 위치를 표시하기 때문에, 벡터의 시작점을 바꿔도 벡터는 변하지않습니다. 그렇기 때문에, 벡터의 시작점을 항상 좌표 공간의 원점으로 설정합니다.

벡터의 연산

  • 벡터의 덧셈과 뺄셈

    201484-93026858-7695-vectoraddition-a

  • 벡터의 대소 비교

    끝 점의 x좌표가 작은 벡터일수록 작은 벡터이고, x좌표가 같은 경우 y좌표가 작은 벡터일수록 작은 벡터입니다.

  • 벡터의 극 각도 계산

    벡터의 방향이 x축의 양의 방향으로부터 반시계 방향으로 얼마나 차이 나는지를 나타냅니다.

  • 벡터를 이용한 점, 직선, 선분의 표현

    사진 2021  9  5  오전 91317

  • 벡터의 내적

    내적은 벡터를 수처럼 곱하는 개념입니다. 벡터의 내적이 의미있는 이유는 벡터의 내적을 통해서 두 벡터 사이의 각도를 알 수 있기 때문입니다.

    inner_product

    내적

    • 벡터의 사이각

      사이각

    • 벡터의 직각 여부

      cos_is_zero 가 0이면 벡터의 사이각이 항상 90도 또는 270도입니다. 그렇기 때문에 길이가 0이 아닌 두 벡터의 내적이 0이면 두 벡터의 사이각은 직각입니다.

    • 벡터의 사영

      사진 2021  9  5  오전 91258

      벡터 b에 수직으로 빛을 쐈을 때, 벡터 a가 b위에 만드는 그림자를 a의 벡터 사영(vector projection)이라고 합니다.

  • 벡터의 외적

    outer_product

    외적

    벡터의 외적은 3차원 벡터에대해서 정의되는 연산으로 3차원 벡터 a,b가 주어졌을 때, 두 벡터에 모두 수직인 다른 벡터를 반환을 합니다.

    외적에서 의미있는 것은 반환되는 벡터의 길이와 방향입니다.

    • 면적 계산

      외적의 절대값은 a,b를 두 변으로 하는 평행사변형의 넓이 입니다. 위의 그림에서 회색으로 색칠된 영역의 면적이 외적의 절대값과 같습니다.

    • 방향 판별

      외적의 정의(axb)에서 사이각 세타는 a에서 b까지 반시계 방향으로 얼마나 회전해야 하는가를 나타냅니다.

      사진 2021  9  5  오전 91309

      방향판별

      이기 때문에 외적이 음수인지 양수인지를 알면 세타가 양수인지 음수인지 쉽게 알 수 있습니다.

      다음과 같이 손을 이용해 벡터의 외적을 통한 방향을 알아낼 수 있습니다.

      download

교차

  • 직선과 직선의 교차

  • 두 직선의 교차 여부를 확인하고 교차점을 구하는 것은 계산 기하에서 정말 많이 나오는 주제라고 합니다.

    흔히 알고있는 연립 방정식을 이용한 풀이를 이용해서는 0으로 나누는 경우 등의 예외 없이 작동하는 코드를 작성하는 것이 정말 어렵습니다.

    하지만 벡터를 이용한 직선의 표기법을 이용하면, 간단하게 작성할 수 있습니다.

    한점 a를 지나고 방향 벡터가 b인 직선은 a + t*b(t는 실수)로 나타낼 수 있습니다.

    이런 표기법을 이용해 a + t*b와 c+ p*d의 교점을 구할 수 있습니다.

    이때, 교점 구하기 입니다. 이 t를 직선의 t위치에 대입을 하면 원하는 점의 좌표를 구할 수 있습니다.

  • 선분과 선분의 교차

    사진 2021  9  5  오전 91321

    선분과 선분의 교차는 직선과 직선의 교차점을 구한 후 이 교차점이 모두 두 선분 위에 있는지 확인하면 알 수 있습니다.

    이 때, 두 방향벡터가 같은 경우에는 벡터의 비교연산을 통해서 일치와 평행을 알 수 있습니다.

    두 벡터의 교차점을 구하지 않고 교차 여부를 판단하는 방법도 있습니다.

    사진 2021  9  5  오전 91304

    위의 그림에서 a를 시작점으로 하고 c를 끝점으로하는 벡터를 벡터 c, a 를 시작점으로 하고 d를 끝점으로 하는 벡터를 벡터 d, a를 시작점으로 하고 b를 끝점으로 하는 벡터를 벡터 b라고 하겠습니다.

    그 때, b x c와 b x d의 부호가 반대면 선분 cd와 선분 ab는 교차합니다.

    이 때, 두 선분이 한 직선 위에 있거나 끝점이 겹치는 경우는 구현 과정에서 별도의 예외처리가 필요합니다.

거리

  • 점과 선 사이의 거리

    점과 선 사이의 거리는 점에서 직선에 내린 수선의 발의 길이를 통해서 구할 수 있습니다.

    download

    점과 선 사이의 거리를 계산하면, 점과 선분 사이의 거리, 선분과 선분 사이의 거리(선분 위의 한 점에서 다른 선분을 포함하는 직선에 내린 수선의 발의 길이)를 구할 수 있습니다.

다각형

  • 다각형의 종류

    볼록 다각형 - 모든 내각이 180도 미만인 다각형

    오목 다각형 - 180도를 넘는 내각을 갖는 다각형

    단순 다각형 - 다각형의 경계가 스스로 교차하지않는 다각형

    img1 daumcdn-1

  • 면적 구하기

    면적은 다각형을 삼각형으로 분할하고 각 삼각형의 넓이를 벡터의 외적을 이용해서 구할 수 있습니다.

    사진 2021  9  5  오전 91254

  • 내부와 외부 판별

    사진 2021  9  5  오전 91249

    내부에 있는 점에서 그은 반직선은 다각형의 모서리와 항상 홀수번 교차하고

    외부에 있는 점에서 그은 반직선은 다각형의 모서리와 항상 짝수번 교차합니다.

    그림 (b)에서와 같이 꼭지점을 지나거나 모서리와 겹치는 경우는 1번 교차하는 것으로 카운팅 해야합니다.

블록 껍질

볼록 껍질은 2차원 평면에서 주어진 점들을 모두 포함하는 최소 크기의 다각형을 의미합니다. 크기가 최소이기 때문에 블록 껍질의 꼭지점은 모두 입력으로 주어진 점이 되어야합니다.
사진 2021  9  5  오후 20227
볼록 껍질은 잘 알려진 기하문제로 다양한 알고리즘이 있습니다.

그 중 선물 포장 알고리즘을 통해서 볼록 껍질을 찾는 방법을 알아보겠습니다.

제일 먼저 볼록 껍질에 속할 수 밖에 없는 점(보통 가장 왼쪽 점)을 하나 찾습니다. 그리고 그 점에서 가상의 반직선을 만들어 붙인 뒤에 이 반직선을 돌리면서 다른 점들을 감싸 나갑니다.
이를 그림으로 나타내면 다음과 같습니다.
사진 2021  9  5  오후 20905
동작 원리는 간단하지만 구현은 생각보다 어렵습니다.

그래서 구현을 할 때는 반직선의 개념보다는 반직선이 닿은 점 p가 주어질 때 모든 점을 확인하는 방식으로 작동합니다. p에서 각 점으로 가는 벡터들 중에서 가장 왼쪽에 있는 벡터에 대응되는 점이 다음으로 반직선이 닿을 점이 됩니다. 그 이유는 p가 볼록 껍질 상의 점이기 때문에 다른 점까지 벡터를 그리면 그 벡터는 p를 기준으로 180도 범위내에 반드시 존재하기 때문에 가장 왼쪽임을 정의할 수 있습니다.

사진 2021  9  5  오후 20909

위의 그림에서 확인할 수 있듯, 볼록 껍질 위의 점을 기준으로 다른 점까지 그린 벡터는 시작점 기준으로 180도 범위내에 존재합니다.

이 과정을 처음 시작점으로 돌아올 때까지 반복하면 볼록 껍질을 구할 수 있습니다.

cf) (b)에서처럼 볼록껍질 위의 점이 아니면 360도 범위를 다 확인해야하기 때문에, 불필요한 연산이 증가합니다.

이 알고리즘은 껍질에 포함된 점의 수가 H라면 O(NH) 시간이 걸립니다. 최악의 경우에는 O(N^2)이 됩니다.

볼록 껍질

계산 기하 알고리즘 디자인 패턴

  • 평면 스위핑

    평면 스위핑은 수평선 또는 수직선을 이용해 주어진 평면을 쓸고 지나가면서 문제를 해결합니다.

  • 직사각형 합집합의 면적

    포함-배제의 원칙을 이용해서 겹쳐져있는 직사각형의 넓이를 구할 수 있습니다.

    일반적으로 전체 면적을 구한 후에 겹치는 면적을 빼버리는 방법을 생각하기 쉽습니다. 하지만, 이 방식은 여러번 겹치는 부분이 존재하면 구현하기 상당히 어려워집니다.

    그래서 아래와 같이 위치에 놓인 사각형의 개수를 count 해준 다음, 처음부터 끝까지 다시 한 번 탐색하면서 count가 1 이상인 영역을 구하면 면적을 구할 수 있습니다.

    사진 2021  9  5  오전 91240

    문제 추천

    색종이

    직각다각형의 면적 구하기

  • 다각형 교집합의 넓이 구하기

    사진 2021  9  5  오전 91235

    입력을 특정 축으로 자르면 각 조각을 처리하기 더 쉬워집니다.

    위의 그림처럼 두 도형이 만나는 점을 기준으로 자르면 두 다각형의 교집합은 항상 사다리꼴 모양이 됩니다. 이 사다리꼴의 넓이를 구해주면 다각형의 교집합의 면적을 구할 수 있습니다.

  • 교차하는 선분들

    서로 교차하는 선분이 있는지 찾아내는 문제의 해결 방법은 쉽게 생각하면 모든 선분의 쌍에대해서 이들이 교차하는지 확인하는 것입니다.

    하지만 이렇게 확인하면 O(n^2)의 시간이 걸리기 때문에 시간초과가 날 수 있습니다.

    이 때, 평면 스위핑 알고리즘을 이용하면 O(NlogN) 시간에 문제를 해결할 수 있습니다.

    그 방법은 다음과 같습니다.

    평면을 왼쪽에서 오른쪽까지 쭉 훑어가는 수직선이 있는 상황을 가정하겠습니다.

    이 수직선이 어떤 선분의 왼쪽 끝 점과 오른쪽 끝점을 만나는 위치들을 기준으로 평면을 분할한 것을 이벤트라고 하겠습니다.

    이벤트들은 다음과 같이 표현할 수 있습니다.

    사진 2021  9  5  오전 91231

    이 때 별표로 표시된 부분은 선분이 교차하는 지점을 의미합니다.

    그림에서 보면 별표 전까지는 어떤 선분도 교차하지 않음을 알 수 있습니다.

    이런 특징에 기반해서 집합을 이용해 교차여부를 확인합니다.

    왼쪽에서부터 각 이벤트를 방문하면서, 현재 수직선과 겹쳐있는 선분들을 수직선과 만나는 점의 y좌표가 증가하는 순서로 정렬한 상태로 유지합니다.

    수직선이 선분의 왼쪽 끝 점을 만나면 선분이 집합에 추가되고, 오른쪽 끝점을 만나면 집합에서 제거합니다.

    이 때 다음 두 과정을 통해 교차 여부를 확인합니다.

    1. 집합에 새로운 선분이 추가될 때마다 집합 상에서 새 선분과 인접한 두 선분의 교차 여부를 확인합니다.
    2. 집합에서 선분이 삭제될 때마다 집합 상에서 삭제될 선분과인접한 두 선분의 교차 여부를 확인합니다.

    이 알고리즘은 교차하는 선분들이 한 쌍이라도 나오는 시점에서 바로 종료합니다.

    이 알고리즘을 샤모스-호이 알고리즘이라고 합니다.

    샤모스-호이 알고리즘은 각 직선의 양 끝점에서만 집합에 변화가 생기기 때문에항상 선분들의 상대적인 순서가 유지됩니다.

    그리고 이 알고리즘을 효율적으로 O(NlogN)으로구현하기 위해서는 선분 집합에서의 추가, 삭제, 삽입위치를 찾는 연산이 모두 O(logN)에 이루어져야합니다. 그 이유는 이미 이벤트의 갯수가 2n 개이기 때문에, 모든 연산이 logN 시간에 처리되어야 하기 때문입니다. 그렇기 때문에 이진 검색 트리를 이용해서 선분 집합에대한 연산을 처리해야합니다.

    이 알고리즘은 교차점을 찾으면 바로 종료하지만, 모든 교차점을 발견하고 싶다면, 벤틀리-오트만 알고리즘 을 사용해야합니다.

    선분 교차 4

  • 회전하는 캘리퍼스

    download-1

    캘리퍼스는 작은 물건의 지름, 너비 등을 측정할 때 쓰는 공구로, 두 개의 평행한 변 사이에 물체를 끼우고 변 사이의 길이를 재는 도구입니다.

    이런 특징을 이용해 효율적으로 푸는 알고리즘 패턴이 바로 회전하는 캘리퍼스 패턴입니다.

    볼록 다각형의 지름

    볼록 다각형의 지름은 볼록 다각형에 완전히 포함되는 가장 긴 선분의 길이입니다. 볼록 다각형의 지름을 찾는 가장 단순한 방법은 모든 꼭지점의 쌍을 순회하면서 이 중 서로 가장 멀리 떨어져있는 쌍을 찾는 것입니다. 하지만 이런 식으로 접근하면 O(N^2) 시간이 걸립니다.

    실제로 캘리퍼스를 이용하면 우리는 모든 꼭지점의 쌍을 순회하는 것이 아닌 캘리퍼스를 돌리면서 측정된 값 중 가장 큰 값을 찾을 것입니다.

    다음은 회전하는 캘리퍼스 패턴을 이용해 지름을 찾는 과정을 나타낸 그림입니다.

    사진 2021  9  5  오전 91138

    그림에서 확인할 수 있듯 현재 수직선의 방향과 다음 점까지 방향 사이의 각도를 계산해서 더 작은 각도를 택해서 두 직선을 회전하고 다음 거리를 측정하는 식으로 진행됩니다. 이 때, 각도의 정확한 값보다는 내적값을 이용해서 각도의 대소관계(cos은 0~PI까지 감소)를 알아내서 이용합니다.

유의사항

계산 기하 문제는 본질적으로 예외가 아주 많기 때문에 유의해야할 사항이 많습니다.

  • 퇴화 도형

    다음과 같은 경우를 퇴화 도형이라고 합니다.

    • 일직선 상에 있는 세 개 이상의 점들
    • 서로 평행이거나 겹치는 직선/선분들
    • 넓이가 0인 다각형들
    • 다각형의 변들이 서로 겹치는 경우

    계산 기하 알고리즘을 구현할 때 이런 퇴화 도형에대한 예외 처리를 해줘야합니다. 이런 경우들을 입력 데이터에 포함시키지 않는 경우도 많은데, 명시적으로 적혀있지 않은 경우는 퇴화 도형들을 다 처리하는지 확인해야합니다.

  • 직교 좌표계와 스크린 좌표계

    주어지는 좌표의 체계가 일반적으로 수학에서 사용하는 데카르트 좌표계가 아닌 경우에는 같은 표현식이여도 의미하는 위치가 다르게됩니다. 그렇기 때문에, 반드시 좌표계가 어떤 좌표계인지를 확인해야합니다.

  • 불안정성 해결

    많은 기하 문제들은 정수만을 사용해서 해결할 수 있습니다. double 과 같은 실수 자료형을 사용하는 경우에는 수치적으로 부동소수점 연산으로 인해 수치적으로 불안정할 수 있습니다. 이를 해결 하기 위해서 정수나 분수 연산만을 이용해서 정확하게 해결하는 방법을 고려해볼 필요가 있습니다.

    또한 내장 함수를 이용하는 경우 오차가 발생할 수 있기 때문에 오차에대한 대비도 해야합니다. 예를 들면 -1~1의 범위의 값만 받아들인다면 입력의 범위를

    max(-1.0,min(1.0,x)) 와 같은 방식으로 제한해주는 것이 좋습니다.

트랜잭션(Transaction)

트랜잭션(Transaction)

트랜잭션이란?

데이터 베이스의 상태를 변경하는 하나의 논리적 기능을 수행하기 위한 작업의 단위입니다. 쉽게 말해 한꺼번에 수행되어야 할 연산을 모아놓은 것이라고 할 수 있습니다. 데이터 베이스는 항상 정확하고 일관된 데이터를 유지해야하는데 트랜잭션은 이러한 성질을 유지할 수 있게 도와주는 역할을 합니다.

트랜잭션은 DB 서버에 여러 개의 클라이언트가 동시에 액세스 하거나 응용프로그램이 갱신을 처리하는 과정에서 중단될 수 있는 경우 등 데이터 부정합을 방지하고자 할 때 사용합니다. 부정합이 발생하지 않으려면 프로세스를 병렬로 처리하지 않도록 하여 한 번에 하나의 프로세스만 처리하도록 하면 되는데, 이는 효율이 너무 떨어집니다. 즉, 병렬로 처리할 수 밖에 없는 현실적인 상황으로 인해 부정합을 방지하고자 트랜잭션을 사용하는 것입니다.

트랜잭션의 중요성은 금융 소프트웨어에서 두드러지게 나타납니다. 예를 들어 A가 B에게 100만원을 송금한다고 합시다. 이러한 작업은 다음과 같은 순서로 이루어 집니다.

  1. A의 계좌에서 100만원을 뺀다.
  2. B의 계좌에 100만원을 더한다.

이때 1번 작업이 완료된 후 2번작업이 시작되기 전에 오류가 발생하여 시스템이 멈추게 된다면 100만원이 증발해버리는 일이 발생합니다. 따라서 이러한 작업을 수행할때는 1,2번 작업을 트랜잭션으로 묶어서 한번에 1,2번 작업이 처리되거나 오류가 발생하면 진행했던 모든 작업을 되돌려야 합니다.

트랜잭션의 특성

트랜잭션이 성공적으로 처리되어 데이터베이스의 무결성과 일관성을 보장하려면 4가지 특성을 만족해야 합니다. 트랜잭션의 특성을 흔히 ACID라고 합니다.

  • Atomicity (원자성)
  • Consistency (일관성)
  • Isolation (격리성)
  • Durability (지속성)
  1. Atomic (원자성)
    • 트랜잭션을 구성하는 연산들은 데이터베이스에 모두 반영되거나 반영되지 않아야함을 의미.(all or nothing)
    • 트랜잭션 수행과정에서 오류가 발생하여 작업이 완료되지 못하면 트랜잭션 시작전 상태로 데이터베이스를 되돌려야 함.
  2. Consistency (일관성)
    • 트랜잭션이 성공적으로 수행된 후에도 데이터베이스가 일관성 있는 상태를 유지해야함을 의미
    • 시스템이 가지고 있는 고정요소는 트랜잭션 수행 전과 트랜잭션 수행 완료 후의 상태가 같아야 함.
    • 데이터베이스의 제약조건을 위배하는 작업을 트랜잭션과정에서 수행할 수 없음을 나타냄
    • 예시: 송금 예제에서 금액의 데이터 타입을 정수형(integer)에서 문자열(string)로 변경할 수 없음
  3. Isolation (격리성)
    • 둘 이상의 트랜잭션이 동시에 병행 실행되는 경우 현재 수행중인 트랜잭션 실행 도중 다른 트랜잭션의 연산이 끼어들 수 없음을 의미
    • 예시: A->B로 송금 과정에서 다른 트랜잭션이 B에게로 송금할 수 없음
  4. Durability (지속성)
    • 트랜잭션이 성공적으로 완료된 이후 데이터베이스에 반영한 수행 결과는 손실되지 않고 영구적으로 유지되어야 함을 의미
  • +추가: ACID는 이론적으로 트랜잭션이 보장해야할 특성입니다. 실제로는 모든 규칙을 지킬 경우 성능상 속도 저하가 생길 수 있어 트랜잭션 격리 수준을 조정합니다.

트랜잭션의 연산

1. COMMIT 연산

  • 트랜잭션이 성공적으로 수행되었음을 선언하는 연산
  • commit 연산의 실행을 통해 트랜잭션의 수행이 성공적으로 완료되었음을 선언하고 결과를 최종 데이터베이스에 반영.

2. ROLLBACK 연산

  • 트랜잭션이 수행을 실패했음을 선언하고 작업울 취소하는 연산
  • 트랜잭션 수행되는 도중 일부 연산이 처리되지 못한 상황에서는 rollback 연산을 실행하여 트랜잭션의 수행이 실패했음을 선언하고, 데이터베이스를 트랜잭션 수행 전의 일관된 상태로 되돌려야 함.

트랜잭션의 상태

image

  • 활동 상태
    • 트랜잭션이 수행을 시작하여 현재 수행 중인 상태.
  • 부분 완료 상태
    • 트랜잭션의 마지막 연산까지 실행했지만, Commit 연산이 실행되기 직전의 상태
  • 완료 상태
    • 트랜잭션이 성공적으로 완료되어 commit 연산을 실행한 완료 상태.
  • 실패 상태
    • 장애가 발생하여 트랜잭션의 수행이 중단된 상태.
  • 철회 상태
    • 수행이 실패하여 rollback 연산을 실행한 상태.

병행제어

병행제어란 여러개의 트랜잭션이 실행될 때 트랜잭션들이 데이터베이스의 일관성을 파괴하지 않고 다른 트랜잭션에 영향을 주지 않으면서 트랜잭션을 제어하는 것을 의미합니다.

병행 실행시 발생 가능한 문제들

  • 분실된 갱신: 두 개의 트랜잭션이 같은 데이터에 대해서 동시에 갱신 작업을 하면 하나의 갱신 작업이 분실되는 경
  • 모순성: 한 개의 트랜잭션 작업이 갱신 작업을 하고 있는 상태에서 또 하나의 트랜잭션이 같은 작업 구역에 침범하여 작업하게 되어 데이터베이스의 일관성을 해치는 경우
  • 연쇄복귀: 같은 자원을 사용하는 두개의 트랜잭션 중 한 개의 트랜잭션이 성공적으로 일을 수행하였다 하더라도 다른 트랜잭션이 처리하는 과정에서 실패하게 되면 두 개의 트랜잭션 모두가 복귀되는 현상
  • 비완료 의존성: 한 개의 트랜잭션이 수행과정에서 실패하였을 때, 이 트랜잭션이 회복되기 전에 다른 트랜잭션이 수행 결과를 참조하는 현상

병행제어 기법

  1. 로킹(Locking)

트랜잭션이 어떤 데이터에 접근하고자 할 때 로킹을 수행하며 로킹을 한 트랜잭션만이 로킹을 해제할 수 있음. 트랜잭션은 로킹이 된 데이터에 대해서만 연산을 수행할 수 있으며 로킹의 단위에는 필드, 레코드, 파일, 데이터베이스 모두 로킹이 될 수 있음

  • 로킹 단위가 크면 : 관리하기가 용이(로킹 오버헤드 감소), 하지만 동시성 수준이 낮아짐

  • 로킹 단위가 작으면 : 동시성 수준이 높아지지만 관리가 까다로움(로킹 오버헤드 증가)

  • 2단계 로킹 규약(Two-Phase Locking Protocol)
    Lock과 Unlock이 동시에 이루어지면 일관성이 보장되지 않으므로 Lock만 가능한 단계와 Unlock만 가능한 단계를 구분하며 직렬가능성을 보장함, 하지만 교착상태가 발생할 수 있음.

    • 확장단계 : 트랜잭션이 Lock만 할 수 있고 Unlock은 할 수 없음
    • 축소단계 : 트랜잭션이 Unlock만 할 수 있고 Lock은 할 수 없음
    • 예시) T1 : write(A) read(B), T2 : read(B) write(A) => dead lock 발생!
  • 로킹의 종류

    • S-lock(공유잠금)
      • 공유잠금을 설정한 트랜잭션은 데이터 항목에 대해 읽기 연산(read)만 가능
      • 하나의 데이터 항목에 대해 여러 개의 공유잠금이(S-lock) 가능
      • 다른 트랜잭션도 읽기 연산(read) 만을 실행
    • X-lock(배타잠금):
      • 배타잠금을 설정한 트랜잭션은 데이터 항목에 대해서 읽기 연산(read)과 쓰기 연산(write) 모두 가능
      • 하나의 데이터 항목에 대해서는 하나의 배타잠금(X-lock)만 가능
      • 다른 트랜잭션은 읽기 연산(read)와 쓰기 연산(write) 모두 불가능
  1. 타임스탬프(Time Stamp)
    데이터에 접근하는 시간을 미리 정하여 정해진 시간의 순서대로 데이터에 접근하며 수행함, 직렬가능성을 보장하며 시간을 나눠 사용하기 때문에교착상태가 발생하지 않음, 하지만 연쇄복귀를 초래할 수 있음.

  2. 낙관적 병행제어(Optimistic Concurrency Control)
    트랜잭션 수행 동안은 어떠한 검사도 하지 않고, 트랜잭션 종료 시에 일괄적으로 검사함, 트랜잭션 수행 동안 그 트랜잭션을 위해 유지되는 데이터 항복의 지역사본에 대해서만 갱신하며 트랜잭션 종료 시에 동시성을 위한 트랜잭션 직렬화가 검증되면 일시에 DB로 반영함

  3. 다중 버전 병행제어(Multi-version Concurrency Control)
    하나의 데이터 아이템에 대해 여러 버전의 값을 유지하며 조회성능을 최대한 유지하기 위한 기법, 트랜잭션 간의 충돌 문제는 대기가 아니라 복귀처리 함으로 연쇄복귀초래 발생 가능성이 있음

그리디 알고리즘, 셸 정렬

알고리즘

탐욕법

그리디 알고리즘은 최적화 문제를 해결하는 알고리즘이라고 할 수 있다.

최적화(optimization) 문제란 가능한 해들 중에서 가장 좋은 (최대 또는 최소) 해를 찾는 문제이다.

욕심쟁이 방법, 탐욕적 방법, 탐욕 알고리즘 등으로 불린다.

그리디 알고리즘은 수행 과정에서 탐욕적으로 일단 한 번 선택하면, 이를 절대로 번복하지 않는다.

즉, 선택한 데이터를 버리고 다른 것을 취하지 않는다.

이러한 선택을 근시안적인 선택이라고 말하기도 한다. 이 근시안적인 선택으로 부분적인 최적해를 찾고, 이들을 모아서 최종적으로 문제의 최적해를 얻는 것이다.

이러한 특성 때문에 대부분의 그리디 알고리즘들은 매우 단순하며, 또한 제한적인 문제들만이 그리디 알고리즘으로 해결된다.

대표적으로 알려진 그리디 알고리즘의 예로는

  1. 동전 거스름돈 문제
  2. 최소 스패닝 트리 (MST)
  3. 최단 경로 찾기
  4. 부분 배낭 문제
  5. 집합 커버 문제
  6. 작업 스케줄링 문제

등이 존재한다.

1. 동전 거스름돈 문제

주어진 동전 단위와 거스름돈을 가지고 최소 동전 수를 찾는 가장 간단하고 효율적인 방법이다.

간단한 문제이므로 간략히 설명하자면, 남아 있는 거스름돈에 대해 가장 높은 액면의 동전을 거스르며 최소 동전 수를 추가하는 방법이다.

큰 화폐를 처리할 수 있을 때, 작은 화폐에 대해서는 전혀 고려하지 않기 때문에 그리디 알고리즘이라고 할 수 있다.

이 동전 거스름돈 문제에서 주의 할 점은, 모든 화폐 단위에서 이 그리디 알고리즘을 적용할 수 없다는 것이다.

만약 200원을 거스르는 문제에서 160원짜리 동전이 존재한다면 어떻게 될까?

그리디 알고리즘에서는 100원보다 160원이 더 크니까, 160원을 거스르고 그 다음 남은 10원짜리 4개를 거스르면 총 5개가 된다.

하지만 100원 짜리 2개가 최적의 해이므로, 이와 같은 경우가 존재할 수 있기 떄문에 그리디한 방법이 항상 최적의 답을 주지는 못한다.

그래서 실제로 거스름돈에 대한 그리디 알고리즘이 적용되도록 화폐 단위가 발행된다고 한다.

위 같이 160원 동전같은 문제는 DP로 해결이 가능하다.

2. 최소 스패닝 트리 (MST)

최소 스패닝 트리는 주어진 가중치 그래프에서 사이클이 없이 모든 Vertex들을 연결시킨 트리들 중 Edge들의 가중치 합이 최소인 트리를 말한다.

주어진 그래프의 스패닝 트리를 찾는 방법은 사이클이 없도록 모든 Vertex들을 연결시키는 것이다.

그래프의 Vertex의 수가 V개가 있다면, 스패닝 트리에는 반드시 V-1개의 Edge가 존재한다.

위 이미지는 6개의 Vertex를 5개의 Edge로 연결시킨 스패닝 트리가 있고,

스패닝 트리에 Edge를 하나 더 추가시킨다면, 반드시 사이클이 만들어 진다는 것을 보여준다.

최소 스패닝 트리를 찾는 그리디 알고리즘으로는 2가지가 존재한다.

  1. 크루스칼의 최소 스패닝 트리 알고리즘
  2. 프림의 최소 스패닝 트리 알고리즘

위 최소 스패닝 트리 알고리즘들은 최소 비용으로 선로 또는 파이프 네트워크 (인터넷 광 케이블 선로, 케이블 TV선로, 전화선로, 송유관로, 가스관로, 배수로 등)를 설치하는데 활용된다.

3. 최단 경로 찾기

최단 경로 문제는 주어진 가중치 그래프에서 어느 한 출발점에서 또 다른 도착점까지의 최단 경로를 찾는 문제이다.

최단 경로 알고리즘를 찾는 그리디 알고리즘으로는 2가지가 존재한다.

  1. 다익스트라
  2. 벨만-포드

최단 경로를 찾는 알고리즘은 위 2가지 말고도 플로이드의 모든 쌍 최단 거리 알고리즘이 존재하지만,

위 알고리즘은 그리디 알고리즘이 아니라, DP 알고리즘 문제이다.

4. 부분 배낭 문제

부분 배낭 문제에 대해 알아보기 위해서는, 먼저 배낭(Knapsack) 문제를 알아야 한다.

배낭 문제란,

  • N개의 물건이 있다.
  • 각 물건은 무게와 가치를 가지고 있다.
  • 배낭이 한정된 무게의 물건들을 담을 수 있다.

이 때, 최대의 가치를 갖도록 배낭에 넣을 물건들을 정하는 문제이다.

이 배낭 문제는 2가지로 구분 할 수 있다.

  1. 부분 배낭 문제
  2. 0/1 배낭 문제

먼저 1. 부분 배낭(Fractional Knapsack)문제는 물건을 부분적으로 담는 것을 허용한다.

이 문제는 그리디 알고리즘으로 해결가능하다.

그 다음 2. 0/1 배낭 문제는 부분 배낭 문제의 원형으로 물건을 부분적으로 담는 것을 허용하지 않고, 물건을 통째로 배낭에 넣어야 한다.

이 문제는 DP 알고리즘, 백트래킹, 분기 한정 기법으로 해결 가능하므로 지금 여기서는 다루지 않도록 하겠다.

부분 배낭 문제에서는 물건을 부분적으로 배낭에 담을 수 있으므로,

최적해를 위해서 그리디하게 단위 무게 당 가장 값나가는 물건을 배낭에 넣고, 계속해서 그 다음으로 값나가는 물건을 넣는다.

그런데 만일 그 다음으로 값나가는 물건을 통째로 배낭에 넣을 수 없게 되면, 배낭에 넣을 수 있을 만큼만 물건을 부분적으로 배낭에 담는다.

부분 배낭 문제의 대표적인 문제는 아래와 같다.

40그램의 용량을 담을 수 있는 주머니가 존재하고, 4개의 금속 분말과 각 물건의 총 무게와 총 가격이 주어진다.

이 때, 총 가격이 가장 많이 나갈 수 있도록 주머니에 금속을 담아라.

물건   총 무게   총 가격   |  단위 그램당 가격

백금   10그램    60만원  |  6만원

금    15그램    75만원  |  5만원

은    25그램    10만원  |  5천원

주석   50그램    5만원   |   1천원

이 문제도 간단하기 때문에 간략하게 설명하자면, 가장 먼저 그리디 알고리즘을 적용시킬 수 있게

세팅이 필요한데, 각 물건의 단위 그램당 가격을 각각 산출하는 것이다.

단위 그램당 가격을 구하면 해당 가격으로 내림차순으로 정렬하여, 단위 그램당 가격이 제일 많이 나가는 백금을 주머니에 담는다.

이 때, 백금보다 단위 그램당 가격이 작은 다른 물건들은 현재 상황에서 전혀 고려하지 않기 때문에 그리디 알고리즘이라고 할 수 있다.

이제 40-10 = 30 그램을 주머니에 더 담을 수 있고, 주머니의 가치는 6*10 = 60만원이다.

그 다음 가격이 가장 많이 나가는 금을 주머니에 담는다.

이제는 30-15 = 15 그램을 주머니에 더 담을 수 있고, 주머니의 가치는 60 + 5*15 = 135만원이다.

그 다음 마지막으로 배낭에 넣을 수 있는 15그램을 모두 은으로 주머니를 채운다. 이 때, 주머니의 총 가치는 135 + 0.4*15 = 141만원이 된다.

n개의 물건이 존재할 때 부분 배낭 알고리즘의 시간복잡도는,

  • O(n) (무게당 가격계산)
  • O(n log n) (내림차순 정렬)
  • O(n) (물건 개수 반복)
  • O(1) (물건넣기)

로 시간 복잡도를 구분할 수 있다.

이 중 내림차순 정렬이 가장 시간이 오래걸리므로 이 알고리즘의 시간복잡도는 O(n log n) 이라고 할 수 있다.


5. 집합 커버 문제

집합 커버 문제는 아래와 같은 문제이다.

n개의 원소를 가진 집합 U가 있다.

U의 부분집합들을 원소로 하는 집합 F가 주어진다.

F의 원소들인 집합들 중에서 어떤 집합들을 선택하여 합집합하면 U와 같게 되는가?

**된다면, 집합 F에서 선택하는 집합들의 수의 최솟값은?

집합 커버 문제의 최적해는 어떻게 찾아야 할까?

F에 n개의 집합들이 있다고 가정해보자.

가장 단순한 방법으로는 F에 있는 집합들의 모든 조합을 1개씩 합집합하여 U가 되는지 확인하고, U가 되는 조합의 집합 수가 최소인 것을 찾는 것이다.

그러면 F={S1, S2, S3}일 경우 모든 조합을 구해야 한다.

  • 집합이 1개인 경우 3개 = 3C1
  • 집합이 2개인 경우 3개 = 3C2
  • 집합이 3개인 경우 1개 = 3C3
  • 총합은 3+3+1= 7 == 2^3-1 개

즉, O(2^n)의 시간복잡도가 걸리고 이는 exponential time이므로, NP문제가 된다.

n이 커지면 최적해를 찾는 것은 실질적으로 불가능하다는 뜻이고 그럼에도 불구하고 이런 문제가 주어지고 최적해를 찾으라고 한다면

아마 n이 최대 30을 넘진 않을 것이다.

그래서 이를 극복하기 위한 방법으로는 최적해를 찾는 대신에 최적해에 근접한 근사해를 그리디 알고리즘으로 찾는 것이다.

// 수도코드

Set U = U; // 커버를 해야하는 전체 집합 U
Set F = {S1, S2,,,Sn}; // U의 부분집합들을 원소로 하는 집합 F

setCover(U, F){
	Set C = (); // 빈 집합
	
	while(!U.isEmpty()){
		Set temp;
		for(Set s : F){
			// 남은 것 중가장 많이 커버하기만 하면 일단 선택하기 때문에 그리디 알고리즘이다.
			if(s가 U의 남은 원소를 가장 많이 커버하면){ 
				temp = s;
			}
		}
		U.remove(temp); // U에서 제거 
		C.add(temp); // C에 추가
	}
	
	return C;
}

실제로 위 처럼 짜면 remove에서 런타임 에러가 발생하겠지만 대략적인 논리의 수도코드를 적어보았다.

위 집합 커버 문제의 근사해 알고리즘의 시간복잡도는 전체 집합 U의 사이즈가 n이라고 할 때

루프가 n번 반복되고, 각 루프당 F의 원소 Si의 수가 최대 n이라면 F와 U의 비교는 총 n^2이 걸리므로

시간복잡도는 O(n^3) 이라고 할 수 있다.


6. 작업 스케줄링 문제

보통 작업 스케줄링 문제는 2가지 유형이 있다.

  1. 모든 작업을 최소 기계로 완료하는 문제
  2. 기계 1대로 최대한 많은 작업하는 문제

일단 1. 모든 작업을 최소 기계로 완료하는 문제부터 설명하자면,

이 문제는 작업의 수행 시간이 중복되지 않도록 모든 작업을 가장 적은 수의 기계에 배정하는 문제라고 할 수 있다.

작업 스케줄링 문제에는 여러가지 문제 요소들이 주어지는데,

  1. 작업 수 // 그냥 입력의 크기라 중요한 요소는 아님
  2. 각 작업의 시작과 종료시간

이 문제를 해결할 수 있는 작업 스케줄링 알고리즘은 4가지로 구분할 수 있다.

  1. 빠른 시작시간 작업 우선 배정
  2. 빠른 종료시간 작업 우선 배정
  3. 짧은 작업 우선 배정
  4. 긴 작업 우선 배정

위 4가지 알고리즘 중 1. 빠른 시작시간 작업 우선 배정 알고리즘을 제외하고 나머지 3가지 알고리즘은 항상 최적해를 찾아주는 것은 아니다.

빠른 시작시간 작업 우선 배정 은 작업을 시작할 때, 기계를 사용 가능하면 빈기계에서 작업을 수행하고 만약 빈기계가 없다면 기계를 새로 추가함으로 써 문제를 해결할 수 있다.

// 수도코드

job[N]; // N개의 작업 j1, j2, j...
pq = pq(job); // 이른 시작시간을 기준으로 한 minHeap
result = job_Scheduling(pq);

job_Scheduling(pq){
	cnt = 0;
	while(!pq.isEmpty()){
		j = pq.dequeue();
		if(j를 수행할 기계가 존재){
			기계에 작업 배정;	
		}
		else{
			cnt;
		}
			
	}
	return cnt;
}

위 수도코드에서 작업을 수행할 기계가 존재하는지, 그리고 존재하면 기계에 작업배정하여 스케줄링하는 부분은 간단히 표현하였다.

전체 작업 시간의 사이즈의 boolean배열의 리스트를 선언하고, 기계가 추가되면 리스트에 배열을 추가하는 식으로 구현하면 된다.

위 알고리즘의 시간 복잡도는 먼저 n개의 작업을 정렬하는데 O(n log n) 이 걸린다.

그리고 n번의 while 루프에서 작업 수행이 가능 한 기계를 탐색하는데 O(nm) 이 걸린다. // m은 사용된 기계의 수

m이 log n보다 큰지 작은지 구분이 안되기 때문에, O(n log n)O(nm) 중 어느것이 해당 알고리즘의 시간 복잡도인지 알 수 없기 때문에,

해당 알고리즘의 시간복잡도는 O(n log n) + O(nm) 이다.

그 다음 2. 기계 1대로 최대한 많은 작업하는 문제도 마찬가지로 작업의 수가 주어지고, 각 작업의 시작시간과 종료시간이 주어진다.

여기서 1개의 기계로 최대한 많은 작업을 스케줄링하는 문제인데, 이 문제는 시작시간을 기준으로 정렬해도 되고, 종료시간을 기준으로 정렬해도 된다.

만약 종료시간을 우선적으로 기준으로 정렬을 했을 때는 종료시간이 같다면 일찍 시작하는 작업순으로 정렬하면 되고,

만약 시작시간을 우선적으로 기준으로 정렬을 했을 때는 시작시간이 같다면 일찍 종료하는 작업순으로 정렬하면 된다.

이른 종료시간을 우선적으로 정렬하였을 때의 수도코드는 이래와 같다.

// 수도코드

job[N]; // N개의 작업 j1, j2, j...
pq = pq(job); // 이른 종료시간을 기준으로 한 minHeap
result = job_Scheduling(pq);

job_Scheduling(pq){
	start = 0;
	cnt = 0;
	
	while(!pq.isEmpty){
		j = pq.dequeue();
		if(start <= j.start){
			start = j.end;
			cnt++
		}
	}
	
	return cnt;
}

모든 작업을 수행하기 위해 최소 기계수를 카운트하는 1번 문제와 그리디하게 접근하는 것이 비슷해 보이지만,

이 문제는 1대의 기계로 최대의 작업수를 카운트한다는 것에서 다르다고 할 수 있다.

위 알고리즘의 시간 복잡도는 n개의 작업을 종료시간 기준으로 오름차순 정렬하는 O(n log n) 이다.

관련문제

백준 1931: 회의실 배정

마지막으로 그리디 알고리즘을 요약하자면, 그리디 알고리즘은 수행 과정에서 탐욕적으로 일단 한 번 선택하면, 이를 절대로 번복하지 않는다는 것이다.


정렬

셸 정렬

셸 정렬은 우선적으로 버블 정렬과 삽입 정렬에 대한 이야기가 필요하다.

버블 정렬이나 삽입 정렬이 수행되는 과정은 이웃하는 원소끼리의 자리 이동으로 원소들이 정렬된다.

버블 정렬이 오름차순으로 정렬하는 과정에서 작은 숫자가 배열의 앞부분으로 매우 느리게 이동한다.

그리고 삽입 정렬의 경우는 만일 배열의 마지막 원소가 가장 작은 숫자일 경우 그 숫자가 배열의 맨 앞으로 이동해야 하므로, 모든 다른 숫자들이 1칸씩 뒤로 이동해야한다.

셸 정렬은 이러한 단점을 보완하기 위해서 삽입 정렬을 이용하여 배열 뒷부분의 작은 숫자들을 앞부분으로 빠르게 이동시키고,

동시에 앞부분의 큰 숫자들은 뒷부분으로 이동시키고, 그리고 가장 마지막에는 삽입 정렬을 수행하는 알고리즘이다.


셸 정렬 아이디어

만약 아래와 같이 값이 저장된 배열이 존재한다고 하자.

30 60 90 10 40 80 40 20 10 60 50 30 40 90 80

먼저 간격 (gap)이 5가 되는 숫자끼리 그룹을 만든다.

각 그룹 별로 삽입 정렬을 수행한 결과를 1줄에 나열해 보면 다음과 같다.

30 30 20 10 40 / 50 40 40 10 60 / 80 60 90 90 80

간격이 5인 그룹 별로 정렬한 결과

  • 80과 90같은 큰 숫자가 뒷부분으로 이동하였고,
  • 20과 30같은 작은 숫자가 앞부분으로 이동하였다.

그 다음엔 간격을 5보다 작게 하여, 예를 들어, 3으로 하여, 3개의 그룹으로 나누어 각 그룹별로 삽입 정렬을 수행한다.

  • 이때에는 각 그룹에 5개의 숫자가 있다.

최종적으로 마지막에는 반드시 간격을 1로 놓고 수행해야 한다.

  • 그 이유는, 다른 그룹에 속해 서로 비교되지 않은 숫자가 있을 수 있기 때문이다.
  • 즉, 모든 원소를 1개의 그룹으로 여기는 것이고, 이는 삽입 정렬 그 자체이다.
  • 삽입 정렬은 대강 정렬이 되어있는 상태에서 좋은 성능을 발휘한다.
// 수도코드
A[N]; // 크기가 N인 정렬되지 않은 배열
shell_Sort(A);

shell_Sort(A){
	gap = [ g0 > g1 > ... > gk = 1 ]; // 큰 gap부터 차례로 저장된 gap배열, 마지막 gap은 반드시 1
	for(g : gap){
		for(i = 0; i < g; i++) { // gap 만큼 반복한다
			insertion_Sort(i, gap); // 삽입 정렬
		}
	}
}

insertion_Sort(i, gap){
	for(j = i + gap; j < N; j += gap){ // gap만큼 점프하며 삽입 정렬 수행
		for(x = i; x < j; x += gap){
			if(A[x] > A[j]){
				swap(A, x, j);
			}
		}
	}
}

swap(A, i, j){
	temp = A[i];
	A[i] = A[j];
	A[j] = temp;
}

만약 gap의 5라서 5개의 그룹이 구분되면, 각 그룹별로 삽입 정렬을 수행한다.

이 과정으로 인해 앞에 있는 큰 수가 빠르게 뒤로 가고, 뒤에 있는 작은 수가 빠르게 앞으로 올 수 있게 된다.

gap을 차차 줄여가며, 최종적으로 gap을 1을 둔 삽입 정렬을 시행한다.

삽입 정렬은 이미 어느정도 정렬이 된 상태일수록 효율이 O(n) 에 가까워 진다.

시간복잡도

셸 정렬의 최악 경우의 시간복잡도는 O(n^2) 이며, 셸 정렬의 수행 속도는 간격 선정에 따라 좌우된다.

셸 정렬은 1959년에 발표될 정도로 역사가 오래된 정렬 알고리즘인 만큼, 이 알고리즘에 대한 많은 실험들이 진행되어왔다.

그 중 가장 유명한 gap 설정 방법인 히바드(Hibbard) 간격을 사용하면 O(n^(1.5)) 라고 한다.

히바드 간격은 시작 간격을 2^k - 1로 두고 k를 1씩 줄여서 2^k -1, ... , 15, 7, 3, 1로 간격을 설정하는 방법이다.

이 후 많은 실험을 통한 현재까지의 셸 정렬의 최적의 시간복잡도는 O(n^(1.25)) 으로 알려지고 있다.

아직까지는 가장 좋은 간격이 무엇인지 밝혀지지 않기 때문에, 셸 정렬의 시간 복잡도는 아직 풀리지 않은 문제 이다.

지금까지 알려진 가장 좋은 성능을 보인 간격은 Marcin Ciura이 밝혀낸 1750, 701, 301, 132, 57, 23, 10, 4, 1 이라고 한다.

마지막으로 정리하자면, 셸 정렬은 입력 크기가 매우 크지 않은 경우에 매우 좋은 성능을 보인다.

셸 정렬은 임베디드(Embedded) 시스템에서 주로 사용되는데, 셸 정렬의 특징인 간격에 따른 그룹 별 정렬 방식이 H/W로 정렬 알고리즘을 구현하는데 매우 적합하기 때문이라고 한다.

분할 정복, 동적계획법

분할 정복

분할 정복(Divide and Conquer)이란?

한 문제를 둘 이상의 부분 문제(sub-problem) 로 나누어 해결하고 이를 합쳐 원래 문제를 해결하는 기법

분할 정복 알고리즘은 다음과 같이 세 부분으로 나누어서 생각해볼 수 있다.

  1. 분할(Divide) : 원래 문제를 분할하여 더 작은 하위 문제들 나눈다.

  2. 정복(Conquer) : 하위 문제 각각을 재귀적으로 해결

  3. 병합(merge) : 하위 문제들의 답을 합쳐서 원래 문제를 해결


분할 정복을 적용하기 위해서는 문제에 다음과 같은 몇 가지 특성이 성립해야 한다.

  1. 부분 문제로 나누는 자연스러운 방법이 있어야 한다.

  2. 부분 문제의 답을 조합해 원래 문제의 답을 계산하는 효율적인 방법이 있어야 한다.

    (분할 정복을 사용한다고 무작정 효율이 좋아지는 것은 아니다.)


분할 정복의 장/단점

  • 장점 👍

    • 문제를 나눔으로써 어려운 문제를 해결할 수 있다는 장점이 있다. 그리고 이 방식이 그대로 사용되는 효율적인 알고리즘들도 여럿 있으며, 문제를 나누어 해결한다는 특징상 병렬적으로 문제를 해결하는 데 큰 강점이 있다.
    • 보통, 분할 정복의 경우 작은 문제로 분할함으로써 같은 작업을 더 빠르게 처리할 수 있게 해준다. (수행 시간 감소)
  • 단점 👎

    • 함수를 재귀적으로 호출한다는 점에서 함수 호출로 인한 오버헤드가 발생하며, 스택에 다양한 데이터를 보관하고 있어야 하므로 스택 오버플로우가 발생하거나 과도한 메모리 사용을 하게 되는 단점이 있다.

일반적인 재귀 호출과 다른 점

  • 분할 정복이 일반적인 재귀 호출과 다른 점은 문제를 한 조각과 전체를 나누는 대신 거의 같은 크기의 부분 문제로 나누는 것 이다.

  • 보통 재귀 함수를 사용해서 분할 정복 알고리즘을 구현하지만, 분할 정복이라고 해서 반드시 재귀 함수를 이용하는 것은 아니다. 함수 호출시 발생하는 오버헤드를 없애기 위해서 스택이나 큐 등을 이용하는 경우도 있다.


분할 정복 알고리즘 활용 예시

분할 정복이 쓰이는 예는 이분검색, 병합정렬, 퀵정렬, 최대값 찾기, 임계값의 결정, 쉬트라센 행렬곱셈 알고리즘 등이 있다.


병합 정렬과 퀵정렬 (같은 문제를 어느 단계에서 해결하느냐에 따른 구분)

병합 정렬(merge sort)과 퀵 정렬(quick sort)은 분할 정복 패러다임을 기반으로 해서 만들어진 대표적인 정렬 알고리즘이다.

이 두 알고리즘은 같은 아이디어로 정렬을 수행하지만 시간이 많이 걸리는 작업을 분할 단계에서 하느냐, 병합 단계에서 하느냐가 다르다.

이렇게 같은 문제를 해결하는 알고리즘이더라도 어떤 식으로(어느 단계에서) 분할하느냐에 따라 다른 알고리즘이 될 수 있다.


병합 정렬

  • 전체 수행 시간은 병합 과정에 의해 지배된다.

  • O(n) 시간이 걸리는 과정을 재귀 호출 후에 진행 (병합 과정)

    문제의 수는 항상 절반으로 나눠지기 때문에 필요한 단계 수는 O(logn)

  • 시간 복잡도 : 항상 O(nlogn) 으로 일정


퀵 정렬

  • 전체 수행 시간은 두개 부분 문제로 나누는 파티션(partition) 과정에 의해 지배된다. 분할된 두 부분 문제가 비슷한 크기로 비슷한 크기로 나눠진다는 보장이 없기 때문에, 이를 비슷한 크기로 나누는 좋은 기준을 선택하는 것은 퀵정렬에서 중요한 요소이다.

  • O(n) 시간이 걸리는 과정을 재귀 호출 전에 진행 (분할)

    문제의 수가 항상 절반으로 나누어 진다는 보장이 없기 때문에 필요한 단계수를 정확히 계산하기 힘들다.

    최악의 경우 n, 평균적인 경우 logn만큼의 단계가 필요하다.

  • 시간 복잡도 : 최악 = O(n^2), 평균 = O(nlogn)


관련 문제

백준 1629번 곱셈

백준 10830번 행렬 제곱


동적 계획법

동적 계획법(Dynamic Programming, DP)이란?

동적 계획법은 주어진 문제를 풀기 위해서, 문제를 여러 개의 하위 문제(subproblem) 로 나누어 푼 다음, 그것을 결합하여 해결하는 방식이다.


동적 계획법은 처음 주어진 문제를 더 작은 문제들로 나눈 뒤 각 조각의 답을 계산하고, 이 답들로부터 원래 문제에 대한 답을 계산해 낸다는 점에서 분할 정복(Divide and Conquer)과 비슷하다. 하지만 가장 큰 차이점은 동적 계획법에서는 쪼개진 작은 문제가 중복되지만, 분할 정복은 절대로 중복될수가 없다는 점이다.

다시 말하면, 동적 계획법과 분할 정복의 차이는 문제를 나누는 방식이다. 동적 계획법에서는 어떤 부분 문제는 두 개 이상의 문제를 푸는데 사용될 수 있기 때문에, 이 문제의 답을 여러 번 계산하는 대신 한 번만 계산하고 그 결과를 재활용함으로써 속도를 향상시킬 수 있다. 이때 이미 계산한 값을 저장해 두는 메모리를 캐시(cache)라고 부르며, 두 번 이상 계산되는 부분 문제를 중복되는 부분 문제(overlapping subproblems) 라고 부른다.


  • 동적 계획법의 조건

    두 가지 속성을 만족해야 동적 계획법으로 문제를 풀 수 있다.

  1. Overlapping Subproblem
    : 중복되는 부분 문제(overlapping subproblem) 는 어떤 문제가 여러 개의 부분 문제(subproblem)으로 쪼개질 수 있을 때 사용하는 용어이다. 이때 '부분 문제'란, 항상 새로운 부분 문제를 생성해내기 보다는 계속해서 같은 부분 문제가 여러 번 재사용되거나 재귀 알고리즘을 통해 해결되는 문제를 가리킨다.

  2. Optimal Substructure
    : 최적 부분구조(optimal substructure)는 어떤 문제의 최적의 해결책이 그 부분 문제의 최적의 해결책으로 부터 설계될 수 있는 경우를 말한다. 즉, 최적 부분구조 일때 문제의 정답을 작은 문제의 정답에서부터 구할 수 있다. 이 속성은 동적 계획법이나 그리디 알고리즘의 유용성을 판별하는데 사용되기도 한다.


  • 메모리제이션(Memorization)

메모이제이션은 컴퓨터 프로그램이 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로써 동일한 계산의 반복 수행을 제거하여 프로그램 실행 속도를 빠르게 하는 기술이다. 동적 계획법의 핵심이 되는 기술이다.

동적 계획법에서 각 문제는 한 번만 풀어야 한다. (중복되는 부분 문제를 여러번 풀지 않는다는 뜻) Optimal Substructure를 만족하기 때문에 같은 문제는 구할 때마다 정답이 같다. 따라서 정답을 한 번 구했으면 그 정답을 캐시에 메모해놓는다. 이렇게 메모하는 것을 코드의 구현에서는 배열에 저장하는 것으로 할 수 있다. 이를 메모리제이션이라고 한다.


동적 계획법의 장/단점

  • 장점 👍

    • 필요한 모든 가능성을 고려해서 구현하므로 항상 최적의 결과를 얻을 수 있다.

    • 메모리에 저장된 값을 사용하므로 큰 문제를 빠른 속도로 해결하여 최적의 해를 찾아낼 수 있다.

  • 단점 👎

    • 모든 가능성에 대한 고려가 불충분할 경우 최적의 결과를 보장할 수 없다.

    • 다른 방법론에 비해 많은 메모리 공간을 요구한다.


동적 계획법의 구현 방법

동적 계획법의 구현 방식에는 두 가지 방법이 있다.

  1. Top-down : 큰 문제를 작은 문제로 쪼개면서 푼다. 재귀로 구현
  2. Bottom-up : 작은 문제부터 차례대로 푼다. 반복문으로 구현

Top-down과 Botton-up의 시간복잡도 차이는 문제에 따라 다를 수 있으므로 정확히 알 수는 없다. Top-down은 재귀 호출을 하기때문에 스택의 사용으로 시간이 더 걸릴 것이라고 생각할 수 있겠지만, 실제로 그 차이는 크지 않다.

(다만, 파이썬의 경우 재귀 호출 시 스택 오버 플로우(stack overflow)가 발생할 수 있기 때문에, Bottom-up으로 구현하는 것이 좋다. C++과 Java에서는 재귀로 구현하는 것이 크게 문제가 되지 않는다.)

💡 Top-down으로만 해결가능하거나 Bottom-up으로만 해결가능한 문제는 극히 드문 경우이므로, 아무거나 선택해서 사용하면 된다.

피보나치 수열을 예로 들면 다음과 같다.

  • Top-down 방식
f (int n) {
  if n == 0 : return 0
  elif n == 1: return 1
  if dp[n] has value : return dp[n]
  else : dp[n] = f(n-2) + f(n-1)
         return dp[n]
}
  • Bottom-up 방식
f (int n){
  f[0] = 0
  f[1] = 1
  for (i = 2; i <= n; i++) {
   f[i] = f[i-2] + f[i-1]
  }
  return f[n]
}

동적 계획법의 활용 예시

동적 계획법의 예시로는 피보나치 수열 구하기, 이항계수 구하기, 최단경로의 플로이드 알고리즘, 최적화 문제, 외판원 문제 등이 있다.


관련 문제

백준 2294번 동전2

백준 1463번 1로 만들기

SQL - 기초 Query 정리했습니다!

SQL - 기초 Query

SQL이란?

SQL이란 Structed Query Language(구조적 질의 언어)의 줄임말로, 관계형 데이터베이스 관리 시스템(RDBMS) 의 데이터를 관리하기 위해 설계된 특수 목적의 프로그래밍 언어이다.

관계형 데이터베이스 관리 시스템에서 자료의 검색과 관리, 데이터베이스 스키마 생성과 수정, 데이터베이스 객체 접근 조정 관리를 위해 고안되었다.

많은 데이터베이스 관련 프로그램들이 SQL을 표준으로 채택하고 있다.

SQL의 구성요소로는 크게 3가지 데이터 정의어(DDL), 데이터 조작어(DML), 데이터 제어어(DCL) 으로 구성된다.


+) 테이블이란?

테이블이란 항상 이름을 가지고 있는 리스트로, 데이터가 저장되어있는 공간을 의미한다.

테이블은 행(ROW)열(COLUMN) 그리고 거기에 대응하는 값(FIELD) 으로 구성되어 있다.


SQL의 언어적 특성

  1. SQL은 대소문자를 가리지 않는다. (단, 서버 환경이나 DBMS 종류에 따라 데이터베이스 또는 필드명에 대해 대소문자를 구분하기도 한다)

  2. SQL 명령은 반드시 세미콜론(;)으로 끝나야 한다.

  3. 고유의 값은 따옴표(")로 감싸준다.

    SELETE * FROM EMP WHERE NAME = 'James';
  4. SQL에서 객체를 나타낼 때는 백틱(``)으로 감싸준다.

    SELETE \`COST\`, \`TYPE\` FROM \`INVOIVE\`;
  5. 주석은 일종의 도움말로, 주석 처리된 문장은 프로그램에서 동작하지 않는다. 한 줄 주석은 문장 앞에 --를 붙여서 사용한다.

    -- SELETE * FROM EMP;
  6. 여러 줄 주석은 /* */으로 감싸준다.


SQL과 일반 프로그래밍 언어의 차이점


SQL 데이터 종류

  1. int

    정수 자료형으로, 예를 들어 물건의 가격, 수량 등을 저장하는 데 사용된다.

    해당 자료형을 통해 -2147483648 ~ 2144483647의 값을 저장할 수 있다.

  2. float

    실수 자료형으로, int는 소수점 이하의 부분이 없지만 float는 3.14와 같이 소숫점 이하의 부분까지 저장한다.

    따라서 사람들의 키나 몸무게 등처럼 소숫점 아래까지 저장해야 하는 경우에 사용한다.

  3. char

    문자열 자료형으로, char(nn)과 같은 방식으로 사용한다. 이 경우 nn글자를 저장하게 된다.

    예를 들어 char(5) 일 경우, 5글자를 저장하며 5글자가 되지 않을 경우 공백을 추가하여 5글자를 맞춘다.

    'elice'와 같이 작은따옴표를 이용해 문자열인 것을 표시한다.

  4. varchar

    문자열 자료형으로, char과 다른 점은 char의 경우 정해진 글자보다 짧으면 공백을 추가하지만 varchar은 공백을 추가하지 않고 그대로 저장한다.

    varchar2와 동의어이다.

  5. date

    년, 월, 일을 저장하는 날짜 자료형으로, 예를 들어 1945년 8월 15일을 나타내기 위해서는 '1945-08-15'라고 작성한다.

    해당 자료형을 통해 '1001-01-01'~ '9999-12-31'까지 저장할 수 있다.

  6. datetime

    년, 월, 일, 시, 분, 초까지 저장하는 날짜와 시각 자료형이다.

    예를 들어 1945년 8월 15일 12시 0분 0초를 나타내기 위해서는 '1945-08-15 12:00:00'라고 작성한다.

    해당 자료형을 통해 '1001-01-01 00:00:00'~ '9999-12-31 23:59:59'까지 저장할 수 있다.

  7. time

    시간, 분, 초를 저장하는 시간 자료형이다.

    예를 들어 10시간 13분 35초를 나타내기 위해서는 '10:13:35'라고 작성한다.

    해당 자료형을 통해 '-838:59:59' ~ '838:59:59'까지 저장할 수 있다.


데이터 정의어(DDL) - CREATE, ALTER, DROP, TRUNCATE문

테이블이나 관계의 구조를 생성하는데 사용하며 CREATE, ALTER, DROP, TRUNCATE문 등이 있다.

CREATE 문

  • 테이블을 구성하고, 속성과 속성에 관한 제약을 정의하며, 기본키 및 외래키를 정의하는 명령이다.

  • PRIMARY KEY 는 기본키를 정할 때 사용하고, FOREIGN KEY 는 외래키를 지정할 때 사용하며, ON UPDATEON DELETE 는 외래키 속성의 수정과 튜플 삭제 시 동작을 나타낸다.

  • NOT NULL (NULL 값을 가질 수 없음), UNIQUE (같은 값이 있으면 안됨), DEFAULT num(값이 입력되지 않은 경우 기본값 num을 저장) 등 속성에 제약사항을 추가할 수 있다.

CREATE TABLE NewBook (
bookname    VARCHAR2(20)   NOT NULL,
publisher    VARCHAR2(20)   UNIQUE,
price    NUMBER   DEFAULT 10000,
PRIMARY KEY (bookname, publisher));

ALTER 문

  • ALTER문은 생성된 테이블의 속성과 속성에 관한 제약을 변경하며, 기본키 및 외래키를 변경한다.

  • ADD, DROP 은 속성을 추가하거나 제거할 때 사용한다.

  • MODIFY 는 속성의 기본값을 설정하거나 삭제할 때 이용한다.

-- NewBook 테이블에 VARCHAR2(13)의 자료형을 가진 isbn 속성 추가
ALTER TABLE NewBook ADD isbn VARCHAR2(13);

-- NewBook 테이블의 isbn 속성의 데이터 타입을 NUMBER형으로 변경
ALTER TABLE NewBook MODIFY isbn NUMBER;

-- NewBook 테이블의 isbn 속성을 삭제
ALTER TABLE NewBook DROP COLUMN isbn;

DROP 문

  • DROP문은 테이블 자체를 삭제하는 명령이다.

  • 테이블의 구조와 데이터를 모두 삭제하므로 사용에 주의해야 함(데이터만 삭제하려면 DELETE)

DROP TABLE NewBook;

TRUNCATE 문

  • 테이블에 있는 데이터를 모두 제거하는 명령이다. (한번 삭제시 돌이킬 수 없음.)
TRUNCATE TABLE NewBook;

데이터 조작어(DML) - SELECT, INSERT, DELETE, UPDATE문

테이블에 데이터를 검색, 삽입, 수정, 삭제하는데 사용하며 SELECT, INSERT, DELETE, UPDATE문 등이 있다.

여기서 SELECT 문은 특별히 Query문(질의어)라고도 한다.

CRUD란?

Create(생성), Retrieve(검색), Update(수정), Delete(삭제)의 첫 자를 따서 만든 단어이다.

  • Create : 데이터베이스 객체 생성

    : INSERT INTO (새로운 레코드 추가)

  • Update : 데이터베이스 객체 안의 데이터 수정

    : UPDATE (특정 조건의 레코드의 컬럼 값 수정)

  • Delete : 데이터베이스 객체의 데이터 삭제

    : DELETE (특정 조건의 레코드 삭제)

  • Retrieve : 데이터베이스 객체 안의 데이터 검색

    : SELETE (조건을 만족하는 레코드를 찾아 특정 컬럼 값(모두 표시하려면 * )을 표시)


INSERT 문

  • 테이블에 새로운 튜플을 삽입하는 명령으로, 대량삽입(Bulk Insert)란 한번에 여러 개의 튜플을 삽입하는 방법이다.

  • INSERT INTO 테이블(필드이름1, 필드이름2) VALUES (값1, 값2);

-- Book 테이블에 새로운 도서 '스포츠 의학' 삽입
INSERT INTO Book(bookid, bookname, publisher)
       VALUES (14, '스포츠 의학', '한솔의학서적');

UPDATE 문

  • UPDATE문은 특정 속성 값을 수정하는 명령이다.

  • UPDATE 테이블 SET 필드이름1=값1, 필드이름2=값2 WHERE 조건문;

-- Customer 테이블에서 고객번호가 5인 고객의 주소를 '대한민국 부산'으로 변경
UPDATE Customer
SET address='대한민국 부산'
WHERE custid = 5;

DELETE 문

  • DELETE문은 테이블에 있는 기존 튜플을 삭제하는 명령이다.

  • DELETE FROM 테이블 WHERE 조건문;

-- Customer 테이블에서 고객번호가 5인 고객 삭제
DELETE FROM Customer
WHERE custid=5;

-- 모든 고객 삭제
DELETE FROM Customer;

SELETE 문

  • 테이블에 저장된 데이터를 검색하는 명령어이다.

  • SELETE 컬럼명 FROM 테이블명 WHERE 조건문;

  • WHERE절에 조건으로 사용할 수 있는 술어

  • 집계 함수의 종류


-- 가격이 10,000원 이상 20,000원 이하인 도서 검색
SELETE *
FROM Book
WHERE price BETWEEN 10000 AND 20000;

-- 도서 이름에 '축구가 포함된 출판사 검색
SELETE bookname, publisher
FROM Book
WHERE bookname LIKE '%축구%';

-- 도서를 가격의 내림차순으로 검색, 만약 가격이 같다면 출판사의 오름차순으로 검색
SELETE *
FROM Book
ORDER BY price DESC, publisher ASC;

-- 2번 김연아 고객이 주문한 도서의 총 판매액 구하기
SELETE SUM(saleprice) AS 총매출
FROM Orders
WHERE custid=2;

-- 서점의 도서 판매 건수 구하기
SELECT COUNT(*)
FROM Orders;

데이터 제어어(DCL) - GRANT, REVOKE문

데이터 접근을 통제하기 위해 데이터의 사용 권한을 관리하는데 사용하며, GRANT, REVOKE문 등이 있다.

  • 권한의 유형과 종류
  1. 시스템 권한

    • CREATE USER : 계정 생성 권한

    • DROP USER : 계정 삭제 권한

    • DROP ANY TABLE : 테이블 삭제 권한

    • CREATE SESSION : 데이터베이스 접속 권한

    • CREATE TABLE : 테이블 생성 권한

    • CREATE VIEW : 뷰 생성 권한

    • CREATE SEQUENCE : 시퀀스 생성 권한

    • CREATE PROCEDURE : 함수 생성 권한

  2. 객체 권한

    • ALTER : 테이블 변경 권한

    • INSERT : 데이터 조작 권한

    • DELETE : 데이터 조작 권한

    • SELECT : 데이터 조작 권한

    • UPDATE : 데이터 조작 권한

    • EXECUTE : PROCEDURE 실행 권한


GRANT 문

  • 특정 데이터베이스 사용자에게 특정 작업에 대한 수행 권한을 부여한다.
GRANT 권한1, 권한2 TO 사용자계정;

GRANT 권한1, 권한2 ON 객체명 TO 사용자계정;

REVOKE 문

  • 특정 데이터베이스 사용자에게 특정 작업에 대한 수행 권한을 박탈하거나 회수 한다.
REVOKE 권한1, 권한2 FROM 사용자계정;

REVOKE 권한1, 권한2 ON 객체명 FROM 사용자계정;

트랜잭션 제어 (COMMIT, ROLLBACK, CHECKPOINT문)

안전한 거래를 보장하기 위해, 즉 동시에 다수의 작업을 안전하게 처리하기 위해 사용한다.

  • COMMIT : 트랜잭션 확정
  • ROLLBACK : 트랜잭션 취소
  • CHECKPOINT : 복귀지점 설정

선형 자료구조/ 큐와 스택, 데크

자료구조

출처: https://lee-mandu.tistory.com/462

선형 자료구조

  • 자료들간의 관계가 1:1 선형 관계를 가진다.
  • 즉, 하나의 자료 뒤에 다른 하나의 자료가 존재한다.
  • 선형 리스트, 연결 리스트, 큐, 스택, 데크

선형 리스트

image

특징

  • 배열 기반으로 구현되어 연속되는 메모리 공간에 저장되는 리스트
  • 인덱스 번호를 이용해서 매우 빠르게 접근할 수 있다.
  • 메모리 공간으로 연속적으로 배정받기 때문에 메모리 공간 사용 효율이 좋다.
  • 삽입/삭제 연산 과정에서 연속되는 메모리 배열을 위해 원소들의 이동이 필요하기 때문에 작업이 번거롭다.
  • 원소의 개수가 많고 삽입/삭제 연산이 빈번하게 일어날수록 작업에 소요되는 시간이 크게 증가한다.

연결 리스트

image
출처: https://lipcoder.tistory.com/entry/%EB%A6%AC%EC%8A%A4%ED%8A%B8

특징

  • 메모리의 동적할당을 기반으로 구현되었고, 노드의 링크 필드 속성에 다음 노드에 대한 참조값을 저장함으로써 노드와 노드가 연결 구조를 가지는 리스트
  • 순차 리스트와 달리 연속적인 메모리 공간에 원소가 저장되지 않는다.
  • 따라서 자료의 논리적 순서와 메모리 상의 물리적인 순서가 일치하지 않으며 물리적인 순서를 맞추기 위한 작업이 필요하지 않다.

시간복잡도

탐색 O(n)

  • 특정 원소의 검색/수정 연산 시 데이터를 찾기 위한 탐색 작업이 필요하다. ( 시간복잡도 -> O(n) )

삽입/삭제 O(1)

  • 삽입/삭제 연산 자체는 O(1)이다.
  • 맨 처음에 원소를 삽입/삭제하는 경우 탐색 작업이 불필요하기 때문에 시간복잡도는 O(1)이다.
  • 리스트 중간에 원소를 삽입하거나 원소를 삭제하는 경우 탐색 작업이 추가된다.

기본 구조

노드

  • 데이터 필드
    원소값을 저장한다.
    저장하고자 하는 데이터의 특징에 따라 정의해서 사용한다.
  • 링크 필드
    연결 리스트의 첫 노드에 대한 참조값을 가지고 있다.

종류

링크의 개수에 따라 연결 리스트를 나눌 수 있다.

단일 연결 리스트 ( Singly Linked List )

  • 링크 개수: 1개

양방향 연결 리스트 ( Doubly Linked List )

  • 링크 개수: 2개
  • 링크가 2개이기 때문에 양쪽 방향으로 순회할 수 있다.

원형 연결 리스트 ( Circular Linked List )

  • 링크 개수: 1개
  • 단일 연결 리스트에서 마지막 노드와 처음 노드를 연결시켜 원형으로 만든 구조
  • 어떤 노드에서 출발해도 모든 노드로 접근할 수 있다.

링크 1개와 링크 2개의 장단점

링크 1개 ( 단일 연결 리스트 )

  • 장점
    링크가 1개이기 때문에 연결 리스트의 관리가 수월하다.
  • 단점
    삭제 연산 시 삭제하고자 하는 노드의 이전 노드를 기억해야 한다.
    따라서 head에서부터 삭제할 노드와 그 이전 노드를 찾아야 한다.

링크가 2개 ( 양방향 연결 리스트 )

  • 장점
    2개의 링크가 이전 노드와 다음 노드의 참조값을 기억하고 있기 때문에 단일 연결 리스트의 단점을 극복할 수 있다.
    삭제 연산 시 삭제할 노드만 찾으면 삭제할 수 있다.
  • 단점
    링크가 2개이기 때문에 연결 리스트의 관리가 복잡하다.

스택, 큐, 데크

출처: https://galid1.tistory.com/483

특징

  • 한쪽에서는 삽입, 다른 한쪽에서는 삭제 작업이 이루어지도록 구성한 자료구조
  • FIFO ( First-In-First-Out): 가장 먼저 삽입된 자료가 가장 먼저 삭제된다.

연산

  • enqueue ( 삽입 )
    자료를 큐에 삽입한다.
  • dequeue ( 삭제 )
    큐에서 자료를 삭제한다.
    FIFO에 따라 제일 먼저 삽입했던 자료가 삭제된다.

스택

특징

  • 리스트의 한쪽 끝으로만 자료의 삽입, 삭제가 이루어진다.
  • LIFO ( Last-In-First-Out): 가장 나중에 삽입된 자료가 가장 먼저 삭제된다.

연산

  • push ( 삽입 )
    자료를 스택에 삽입한다.
  • pop ( 삭제 )
    스택에서 자료를 꺼낸다.
    LIFO에 따라 제일 최근에 삽입된 자료가 가장 먼저 삭제된다.
  • peek ( 맨 위의 자료 반환 )
    스택의 top에 있는 자료를 반환한다.

데크

image
출처: https://jjudrgn.tistory.com/15

특징

  • 삽입과 삭제가 리스트의 양쪽 끝에서 모두 발생할 수 있는 자료구조
  • 큐와 스택의 특징이 모두 포함되어 있다.

트리의 구현과 순회, 트라이

트리

트리의 구현과 순회

트리(Tree)

계층적인 구조의 자료를 표현하는 비선형 자료구조

뿌리부터 잎까지의 나무를 거꾸로 뒤집어 놓은 형태를 띄며, 회사의 조직도, 파일 디렉토리 구조, 기계 학습에서의 결정 트리(decision tree) 등이 트리 구조로 표현될 수 있다.

트리 관련 용어

  • 노드(node)와 간선(edge) : 트리의 구성 요소, 트리의 각 노드에는 데이터가 저장되고 이러한 데이터들의 연결 관계는 간선으로 나타낸다.
    • 루트(root) : 계층적인 구조에서 가장 높은 곳에 있는 노드. 한 트리에는 하나의 루트만이 존재한다.
    • 리프(leaf, 단말 노드) : 자식 노드가 존재하지 않는, 더 이상 아래로 뻗어나갈 수 없는 노드
    • 부모(parent) : 현재 노드에서 간선으로 연결된 위쪽에 있는 노드. 트리의 각 노드는 무조건 1개의 부모 노드만 가질 수 있다.
    • 자식(child) : 현재 노드에서 가지로 연결된 아래쪽 노드. 트리의 각 노드는 자식 노드를 가지지 않을 수도, 여러개의 자식 노드를 가질 수도 있다.
    • 형제(sibling) : 부모 노드가 동일한 노드들
    • 조상(ancestor) : 현재 노드에서 간선을 따라 루트노드까지 올라갈 때 연결된 모든 노드
    • 자손(descendant) : 서브 트리에 있는 하위 레벨의 노드들
  • 서브 트리(subtree) : 부모 노드와 연결된 간선을 끊으면 새롭게 생성되는 트리. 하나의 트리는 여러개의 서브 트리를 포함하고 있다.

Tree degree and level

  • 레벨(level)과 높이(height) : 루트 노드로부터 현재 노드에 이르기까지 연결된 간선의 수. 트리의 레벨의 최댓값이 트리의 높이가 된다.
  • 차수(degree) : 현재 노드에 연결된 자식 노드의 수. 트리의 차수는 트리의 차수의 최댓값이다.

이진 트리(Binary Tree)

모든 노드가 2개의 서브 트리를 가지고 있는 트리.

이진 트리의 특징

  • 모든 노드는 왼쪽 자식 노드와 오른쪽 자식 노드를 각각 1개씩, 최대 2개의 노드만을 자식 노드로 가질 수 있다.(최대 차수 2)
  • 공집합도 이진트리이다.
  • 서브 트리 간 순서(왼쪽, 오른쪽)가 존재한다.
  • 노드의 개수가 n개인 이진트리의 간선 개수는 n-1
  • 높이가 h인 이진트리의 최소 노드 개수는 h+1개, 최대 노드 개수는 2^(h+1) - 1

이진 트리의 종류

Binary Tree

포화 이진 트리(Full Binary Tree)

  • 모든 높이에 노드가 최대로 차 있는 이진 트리
  • 따라서 노드 개수는 이진 트리의 최대 노드 개수인 2^(h+1) - 1
  • 루트 노드를 1번으로 하여 모든 노드가 순서대로 노드 번호를 가진다.

완전 이진 트리(Complete Binary Tree)

  • 루트 노드를 1번으로 하여 n개의 노드를 갖는 이진 트리에서 1번부터 n번까지 빈 자리가 없는 이진 트리
  • 낮은 높이에서 시작하여 왼쪽 자식 노드부터 채워야 한다.

편향 이진 트리(Skewed Binary Tree)

  • 높이 h에 대한 최소 개수의 노드를 가지며 한 쪽 방향의 자식 노드만을 가진 이진 트리
  • 따라서 노드 개수는 이진 트리의 최소 노드 개수인 h+1

이진 트리 표현

배열을 이용한 표현

n개의 노드를 갖는 이진 트리의 경우 루트 노드부터 마지막 노드까지 1번 ~ n번 번호를 매긴 뒤, 배열의 1번 인덱스부터 n번 인덱스까지 순서대로 노드를 삽입하여 구현

  • 높이가 h인 이진 트리를 구현하기 위해서는 배열의 크기가 2^(h+1)가 되어야 한다.
    • 배열의 1번 인덱스부터 사용하므로 이진 트리의 최대 노드 개수인 2^(h+1) - 1개보다 1개 많은 크기
  • 배열을 이용한 이진 트리는 구현이 쉽지만, 배열 원소에 대한 메모리 공간 낭비가 발생할 수 있다.
    • 편향 이진 트리의 경우 사용하지 않는 배열의 영역이 많아진다.
  • 트리의 중간에 새로운 노드를 삽입하거나 삭제할 경우 배열의 크기 변경이 어려워 비효율적이다.

Java를 이용한 완전 이진 트리 구현 - 배열 표현법

// 완전 이진 트리
class CompleteBinaryTreeArray {
    char[] nodes;         // 트리의 노드를 저장한 배열
    final int SIZE;             // 노드 개수 + 1
    int lastIndex;        // 마지막에 추가된 노드의 인덱스

    public CompleteBinaryTreeArray(int size) {
        this.SIZE = size;
        nodes = new char[SIZE+1];          // 1 ~ SIZE 번의 노드들을 저장하는 배열
    }

    public void add(char c) {
        if (lastIndex == SIZE) return;     // 배열 포화 상태
        nodes[++latIndex] = c;             // 배열에 노드 추가
    }
}

링크를 이용한 표현

이진 트리 노드 번호 성질을 이용해 각 부모 노드에 왼쪽 자식 노드와 오른쪽 자식 노드를 연결하여 표현한다. 한 노드가 두 개의 링크 필드를 갖는 형태이다.

💡 이진 트리 노드 번호의 성질

  • 노드 번호가 i인 노드의 부모 노드 번호 : i/2
  • 노드 번호가 i인 노드의 왼쪽 자식 노드 번호 : 2*i
  • 노드 번호가 i인 노드의 오른쪽 자식 노드 번호 : 2*i + 1
  • 레벨 n인 노드의 시작 번호 : 2^n
  • 배열 표현법의 한계인 메모리 낭비를 막을 수 있다. 이진 트리의 노드 개수만큼 노드를 생성하면 된다.
  • 특정 노드를 탐색하기 위해 루트 노드부터 탐색을 시작해야 하므로 탐색이 비효율적이다.

Java를 이용한 완전 이진 트리 구현 - 링크 표현법

// 트리의 노드
class Node {
    char data;         // 데이터 필드
    Node left;         // 왼쪽 자식 노드 링크 필드
    Node right;        // 오른쪽 자식 노드 링크 필드

    public Node(char data) {
        this.data = data;
        left = null;             // 리프 노드를 위한 초기화
        right = null;            // 리프 노드를 위한 초기화
    }
}

// 완전 이진 트리
class LinkedCompleteBinaryTree {
    Node[] binaryTree;

    public LinkedCompleteBinaryTree(int nodeCnt) {
        binaryTree = new Node[nodeCnt+1];                 // 노드 개수 + 1
    }

    public void add(int nodeCnt) {
        for (int i=1; i<=nodeCnt; i++) {
            binaryTree[i] = new Node(i);                  // 노드 생성 및 데이터 삽입
        }

        for (int i=1; i<=nodeCnt/2; i++) {
            binaryTree[i].left = binaryTree[2*i];         // 왼쪽 자식 노드 연결
            binaryTree[i].right = binaryTree[2*i+1];      // 오늘쪽 자식 노드 연결
        }
    }
}

트리의 탐색

비선형 자료구조인 트리에서 각 노드를 중복되지 않게 완전 탐색하기 위한 기법

너비 우선 탐색(BFS, Breadth First Search)

  • 루트 노드의 자식 노드들을 우선적으로 모두 방문한 뒤, 방문했던 자식 노드들의 자식노드들을 또 다시 차례대로 방문하는 방식
  • 인접한 노드에 대한 탐색이 끝나야 해당 노드들의 인접한 노드를 방문할 수 있으므로 **선입 선출 형태의 자료구조인 큐(Queue)**를 사용하여 구현할 수 있음
public void bfs() {
    int lastIndex;                              // 노드의 개수 + 1
    Queue<Integer> q = new LinkedList<>();      // 탐색을 기다리는 노드를 저장할 큐
    q.offer(1);                 // 루트 노드의 인덱스

    int level = 0, size = 0;

    while(!q.isEmpty()) {
        size = q.size();        // 현재 높이(너비)에서의 모든 노드 개수

        while(size-->0) {       // 높이 별 노드들을 단계적으로 탐색
            int current = q.poll();
            
            System.out.println(current + " ");

            // 왼쪽 자식 노드 유효성 검사 후 큐에 삽입
            if (current*2 <= lastIndex) q.offer(current*2);
            // 오른쪽 자식 노드 유효성 검사 후 큐에 삽입
            if (current*2+1 <= lastIndex) q.offer(current*2+1);
        }

        level++;     // 현재 높이, 한 높이(너비) 별 탐색이 끝나면 크기 하나씩 증가
    }
}

깊이 우선 탐색(DFS, Depth First Search)

  • 루트 노드에서 출발하여 한 방향으로 갈 수 있는 경로가 존재할 때까지 계속해서 깊이 탐색. 더 이상 방문할 수 있는 경로가 없으면 마지막으로 만났던 갈림길이 있던 노드로 돌아와 다른 방향의 노드를 같은 방식으로 계속해서 깊게 탐색하는 방식
  • 자식의 자식의 자식 노드까지 깊게 탐색했다가 방문할 노드가 없을 경우 돌아와서 다른 자식 노드로 깊게 들어가야 하므로 재귀적으로 구현하거나, **후입 선출 형태의 자료구조인 스택(Stack)**을 사용하여 구현할 수 있음
public void dfs(int current) {
    System.out.println(current + " ");      // 전위 순회 dfs로, 중위, 후위 순위의 경우 현재 라인의 위치만 자식 노드 중간과 마지막으로 바꿔주면 됨

    // 왼쪽 자식 노드 유효성 검사 후 재귀적 탐색
    if (current*2 <= lastIndex) dfs(current*2);
    // 오른쪽 자식 노드 유효성 검사 후 재귀적 탐색
    if (current*2+1 <= lastIndex) dfs(current*2+1);
}

트리의 순회

깊이 우선 탐색 시 트리의 노드를 순회하는 순서

전위 순회(preorder traversal) : VLR

노드 방문 -> 왼쪽 자식 -> 오른쪽 자식

public void dfsByPreOrder() {
    System.out.print("Preorder : ");
    dfsByPreOrder(1);
    System.out.println();
}
	
private void dfsByPreOrder(int current) {
    // 현재 노드 처리
    System.out.print(nodes[current] + " ");
    // 왼쪽 자식 노드 방문
    if (current*2<=lastIndex) dfsByPreOrder(current*2);
    // 오른쪽 자식 노드 방문
    if (current*2+1<=lastIndex) dfsByPreOrder(current*2+1);
}

중위 순회(inorder traversal) : LVR

왼쪽 자식 -> 노드 방문 -> 오른쪽 자식

public void dfsByInOrder() {
    System.out.print("Inorder : ");
    dfsByInOrder(1);
    System.out.println();
}

private void dfsByInOrder(int current) {
    // 왼쪽 자식 노드 방문
    if (current*2<=lastIndex) dfsByInOrder(current*2);
    // 현재 노드 처리
    System.out.print(nodes[current] + " ");
    // 오른쪽 자식 노드 방문
    if (current*2+1<=lastIndex) dfsByInOrder(current*2+1);
}

후위 순회(postorder traversal) :LRV

왼쪽 자식 -> 오른쪽 자식 -> 노드 방문

public void dfsByPostOrder() {
    System.out.print("Postorder : ");
    dfsByPostOrder(1);
    System.out.println();
}

private void dfsByPostOrder(int current) {
    // 왼쪽 자식 노드 방문
    if (current*2<=lastIndex) dfsByPostOrder(current*2);
    // 오른쪽 자식 노드 방문
    if (current*2+1<=lastIndex) dfsByPostOrder(current*2+1);
    // 현재 노드 처리
    System.out.print(nodes[current] + " ");
}

PS 문제 추천

Baekjoon Online Judge > 트리의 부모 찾기
2019 KAKAO BLIND RECRUITMENT > 길 찾기 게임


트라이(Trie)

키와 값을 쌍으로 갖는 연관 배열 데이터를 저장하는 트리 자료 구조

주로 자연어 처리(NLP) 분야에서 문자열 탐색을 위해 사용한다. 문자열의 각각의 문자 단위로 색인을 구축한 형태이다.

트라이 원리

트라이를 이용한 문자열 저장

Trie Principle

Trie Search

  1. 각 노드가 배열로 구성된 트리를 생성한다.
  2. 저장하려는 문자열의 모든 문자들을 확인하며 아래 과정을 시행한다.
  3. 루트 노드(문자 배열)에서 문자열의 첫번째 문자에 해당하는 인덱스로 이동한다.
  4. 해당 인덱스에 연결된 자식 노드가 존재하지 않는다면 새로운 노드(문자 배열)를 할당한다. 이후 새로운 노드에서 두번째 문자에 해당하는 인덱스로 이동한다.
  5. 해당 인덱스에 연결된 자식 노드가 존재한다면 해당 노드의 두번째 문자에 해당하는 인덱스로 이동한다.
  6. 문자열의 모든 문자를 다 저장할 때까지(즉, 문자열 길이만큼) 위의 과정을 반복한다. 마지막 문자를 저장하고 나서는 배열 값을 true로 설정하여 하나의 문자열이 완전히 저장됨을 표시한다.

위의 과정에서 주목할 점은 접두사가 동일한 문자열을 저장할 경우 최소 하나 이상의 노드를 공유한다는 것이다!

트라이의 시간 복잡도

위의 방식대로 문자열을 저장할 경우 한 문자열을 탐색할 때 고작 O(1)의 연산만 필요하게 된다.

아무리 트리 노드가 많이 존재하더라도, 심지어는 전세계 모든 인구인 80억명의 이름이 저장된 트라이라고 할지라도, 오직 O(1)의 시간만이 걸린다. 그 이유는 한 문자열을 탐색하기 위해서는 해당 문자열이 갖고 있는 문자 노드만을 탐색하기 때문이다. 루트 노드에서부터 해당 문자열의 길이 만큼의 자식 노드만 타고 들어가기 때문에, 다른 탐색 알고리즘과 달리 저장된 문자열의 개수에 영향을 받지 않는다는 점이 트라이의 큰 특장점이다.

트라이의 공간 복잡도

우수한 시간적 성능을 자랑하는 트라이의 치명적인 한계는 바로 메모리를 많이 사용한다는 것이다.

트라이를 이용해 한국의 5천만 인구의 이름을 저장한다고 생각해보자. 한국에서 가장 긴 이름은 "박하늘별님구름햇님보다사랑스러우리"로 17자이다. 이 이름을 저장하기 위해서는 한글의 모든 음절을 배열로 담은, 길이가 11,172인 배열17개가 필요하고 총 189,924만큼의 메모리(1음절을 1byte로 가정)가 필요하다. 이름의 길이가 길어서 생긴 문제라고 말할 수도 있겠지만, 가장 보편적인 3자 이름 역시 11,172인 배열3개가 필요하고 총 33,516만큼의 메모리가 필요하다. 한 명의 이름을 저장하는데도 이렇게 많은 메모리를 사용하는데 이런 방식으로 5천만명의 이름을 저장한다면 어마어마한 크기의 메모리가 필요하게 될 것이다.(물론 같은 성을 사용하거나 돌림자를 사용하는 이름은 노드를 같이 사용할 수 있어 조금은 효율적이라고 말할 수 있다.)

트라이의 공간 복잡도는 대략 O(포인터 크기 * 포인터 배열의 길이 * 전체 노드 개수)가 된다. 따라서 트라이는 시간적 성능과 공간적 성능을 맞바꾼 대표적인 예로 볼 수 있다.

Java를 이용한 트라이 구현

class Trie {
    final int ALPHABET_SIZE = 26;         // 포인터 배열의 길이(표현할 수 있는 문자 개수)

    class Node {
        Node[] children = new Node[ALPHABET_SIZE];     
        boolean isEndOfWord;              // 저장하려는 문자열의 마지막 문자 여부

        public Node() {
            for (int i=0; i<ALPHABET_SIZE; i++) {
                children[i] = null;       // 포인터 배열 초기화
            }

            isEndOfWord = false;          // 마지막 문자 여부 초기화
        }
    }

    Node root;                            // 문자열의 첫번째 문자

    public void insert(String key) {
        int length = key.length();        // 탐색하려는 문자열 길이
        int alphabetIdx;                  // 문자열의 각 문자의 인덱스
        Node curAlphabet = root;          // 현재 탐색중인 문자열의 문자

        for (int level=0; level<length; level++) {
            alphabetIdx = key.charAt(level) - 'a';                    // 문자열의 각 문자의 인덱스 구하기
            Node nextAlphabet = curAlphabet.children[alphabetIdx];    // 문자열의 다음 문자

            if (nextAlphabet == null) {         // 찾으려는 문자에 연결된 자식 노드가 없을 경우
                nextAlphabet = new Node();      // 새로운 노드 생성 뒤 자식 노드로 연결
            }

            curAlphabet = nextAlphabet;         // 찾으려는 문자의 자식 노드를 현재 노드로
        }

        curAlphabet.isEndOfWord = true;         // 문자열의 모든 문자에 대해 삽입이 끝났다면 마지막 문자의 노드는 true로 변경하여 하나의 문자열이 저장됐음을 표시
    }

    public boolean search(String key) {
        int length = key.length();        // 탐색하려는 문자열 길이
        int alphabetIdx;                  // 문자열의 각 문자의 인덱스
        Node curAlphabet = root;          // 현재 탐색중인 문자열의 문자

        for (int level=0; level<length; level++) {
            alphabetIdx = key.charAt(level) - 'a';
            Node nextAlphabet = curAlphabet.children[alphabetIdx];

            if (nextAlphabet == null) {         // 탐색하려는 문자열이 존재하지 않는 경우
                return false;
            }

            curAlphabet = nextAlphabet;         // 찾으려는 문자의 자식 노드를 현재 노드로
        }

        return (curAlphabet.isEndOfWord);       // 탐색하려는 문자열이 존재하는 경우
    }
}

PS 문제 추천

2020 KAKAO BLIND RECRUITMENT > 가사검색

TCP/IP 정리했습니다!

TCP/IP

TCP/IP 는 하나의 프로토콜이 아닌 TCP 프로토콜과 IP 프로토콜을 합쳐서 부르는 말입니다.

패킷 통신 방식의 인터넷 프로토콜인 IP전송 조절 프로토콜인 TCP로 이루어져 있습니다.

IP는 패킷 전달 여부는 보증하지 않고 논리적인 주소만을 제공하며, 패킷을 보낸 순서와 받는 순서가 보장되지 않습니다.

TCP는 IP 위에서 동작하는 프로토콜로 데이터의 전달을 보장하고 데이터를 보낸 순서와 받는 순서를 보장해줍니다.

HTTP, FTP, SMTP 등의 프로토콜은 TCP를 기반으로 하고 있으며 이 프로토콜들은 IP 위에서 동작하기 때문에 묶어서 TCP/IP 로 부릅니다.


또한 TCP/IP를 사용한다는 것은 IP 주소 체계를 따르면서 TCP의 특성을 활용해 송신자와 수신자의 논리적 연결을 생성하고 신뢰성을 유지할 수 있도록 하겠다는 의미입니다.

즉 TCP/IP 를 말한다는 것은 송신자가 수신자에게 IP 주소를 사용해 데이터를 전달하고 그 데이터가 제대로 갔는지, 너무 빠르지는 않는지, 제대로 받았다고 연락은 오는지에 대한 이야기를 하고 있는 것입니다.


인터넷에서 무언가를 다운로드할 때 중간에 끊기거나 빠지는 부분 없이 완벽하게 받을 수 있는 이유도 TCP의 이러한 특성 때문입니다.

그렇기 때문에 HTTP, HTTPS, FTP, SMTP 등과 같이 데이터를 안정적으로 모두 보내는 것을 중요시하는 프로토콜들의 기반이 됩니다.


TCP (Transmission Control Protocol)

TCP 프로토콜OSI 7 Layer 에서 4계층인 전송 계층에 위치합니다.

TCP는 네트워크 정보 전달을 통제하는 프로토콜이자 인터넷을 이루는 핵심 프로토콜 중 하나입니다.

근거리 통신망이나 인트라넷, 인터넷에 연결된 컴퓨터에서 실행되는 프로그램간에 데이터 전송을 안정적으로, 순서대로, 에러 없이 교환할 수 있게 해줍니다.

웹 브라우저들이 www를 통해 서버에 연결할 때 사용되며, 이메일 전송이나 파일 전송에도 사용됩니다.


IP가 패킷들의 관계를 이해하지 못하고 그저 목적지를 제대로 찾아가는 것에 중점을 둔다면 TCP는 통신하고자 하는 양쪽 단말이 통신할 준비가 되었는지, 데이터가 제대로 전송되었는지, 데이터가 가는 도중 변질되지는 않았는지, 수신자가 얼마나 받았고 빠진 부분은 없는지 등을 점검합니다.


image

이런 정보들은 TCP Header에 담겨 있으며 Source Port, Destination Port, Sequence Number, Window size, Checksum과 같은 신뢰성 보장흐름 제어, 혼잡 제어에 관여할 수 있는 요소들도 포함되어 있습니다.

또한 IP Header와 TCP Header를 제외한 TCP가 실을 수 있는 데이터의 크기를 세그먼트(Segment) 라고 부릅니다.


TCP는 IP의 정보뿐만 아니라 Port를 이용하여 연결합니다.

한쪽 단말에 도착한 데이터가 어느 입구(Port)로 들어가야 하는지 알아야 연결을 시도할 수 있기 때문입니다.

위의 TCP Header를 보면 발신지 포트주소 (Source Port)와 목적지 포트주소 (Destination Port)를 확인할 수 있습니다.

(예시로, 양쪽 단말에서 HTTP로 이루어진 문서를 주고받고자 할 경우 데이터 통신을 하려면 단말의 Port가 80이어야만 가능합니다.)


3-way handshake

TCP를 사용하는 송신자와 수신자는 데이터를 전송하기 전 먼저 서로 통신이 가능한지 의사를 묻고 한 번에 얼마나 받을 수 있는지 등의 정보를 확인합니다.

이는 데이터를 안전하고 빠지는 부분 없이 보내기 위한 신뢰성 있는 통신을 하기 위해서입니다.


image

TCP는 TCP Header 내의 SYN, SYN/ACK, ACK Flag 를 사용해 통신을 시도합니다.

  1. 송신자가 수신자에게 SYN를 보내 통신이 가능한지 확인 -> Port는 열려 있어야 함
  2. 수신자가 송신자로부터 SYN를 맏고 SYN/ACK 를 송신자에게 보내 통신 준비가 되었다고 함
  3. 송신자가 수신자의 SYN/ACK 를 받고 ACK를 날려 전송을 시작함을 알림

위 과정을 3-way handshake 라고 부릅니다.


TCP로 이루어진 모든 통신은 반드시 3-way handshake를 통해 시작합니다.

수신자가 받을 생각이 있는지 준비가 되어있는지 송신자가 보낼 준비가 되어있는지를 미리 확인한 후 통신을 시작해 데이터를 안전하게 보내는 것입니다.

그리고 데이터를 받았을 때 ACK를 송신자에게 보내 데이터가 온전히 도착했음을 알립니다.

송신자는 ACK를 받으면 수신자에게 데이터가 잘 보내졌음을 확인하고 다음 데이터를 전달할 준비를 합니다.


특징

  1. 흐름 제어

    송신자는 자신이 한 번에 얼마를 보낼 수 있는지, 수신자는 자신이 데이터를 어디까지 받았는지 계속 확인하고 TCP Header 내의 Window size 를 이용해 한번에 받고, 보낼 수 있는 데이터의 양을 정합니다.

    (여기서 window 는 일정량의 데이터를 말합니다.)

    수신자가 데이터를 받을 Window size를 정하고 (3-way handshake 때 정한다.) 자신의 상황에 따라 Window size 를 조절합니다.

    그리고 자신이 지금까지 받은 데이터의 양을 확인해 송신자에게 보내는데 이를 Acknowledgment Number 라고 합니다.

    만약 수신자가 130번째 데이터를 받았으면, Acknowledgment Number에 1을 더해서 131을 보냅니다.

    이것은 현재 130번까지 받았으니 131번부터 보내면 된다는 의미입니다.

    그리고 이러한 순서 번호를 표기한 것이 Sequence Number 입니다.


  2. 혼잡 제어

    데이터가 지나가는 네트워크망의 혼잡을 제어하는 것입니다.

    **Slow Start**는 연결 초기에 송신자와 수신자가 데이터를 주고받을 준비가 되어있더라도 중간 경로인 네트워크가 혼잡하다면 데이터가 안전하게 전송되지 못할 것에 대비하는 것입니다.

    송신자는 연결 초기에 데이터 송출량을 낮게 잡고 보내면서 수신자의 수신을 확인해가며 데이터 송출량을 조금씩 늘립니다.

    그러다보면 현재 네트워크에서 가장 적합한 데이터 송출량을 확인할 수 있게 됩니다.

    이것이 Slow Start 입니다.


IP (Internet Protocol)

IP 프로토콜OSI 7 Layer 중 3 계층인 네트워크 계층에 해당합니다.

IP는 TCP와는 달리 데이터의 재조합이나 손실여부 확인이 불가능하며, 단지 데이터를 전달하는 역할만을 담당합니다.


컴퓨터와 컴퓨터 간에 데이터를 전송하기 위해서는 각 컴퓨터의 주소가 필요합니다.

IP는 4바이트로 이루어진 컴퓨터 주소이며 3개의 마침표로 나누어진 숫자로 표시됩니다.

(예시 127.0.0.1)

IP 주소는 하드웨어 고유의 식별 번호인 MAC 주소와 다르게 임시적으로 다른 주체(대부분 통신사)에게 받는 주소이므로 바뀔 수 있습니다.


UDP 정리했습니다!

UDP

UDPUser Datagram Protocol 의 약자로 OSI 7 Layer 에서 4계층인 전송 계층에 위치합니다.

TCP 에 대비되는 프로토콜로 신뢰성이 낮고 완전성을 보장하지 않습니다. 하지만 속도가 빠르다는 장점이 있습니다.


특징

UDP비연결성이고, 신뢰성이 없으며, 순서화되지 않은 Datagram 서비스를 제공합니다.

데이터 전송 단위는 메세지 입니다. (TCP의 데이터 전송 단위는 세그먼트)


UDP가 하지 않는 것

  • 연결 셋업과 종료 (Connection setup/teardown)

    TCP의 3-way Hankshaking 과 같은 연결이 필요없는 비연결성 프로토콜입니다.

    그래서 데이터그램 지향의 전송계층용 프로토콜이라고도 많이 불립니다.

  • 수신 완료했다는 알림 (Acknoledgement)

    메세지가 제대로 도착했는지 확인하는 확인응답 작업이 없습니다.

  • 재전송 (Congestion Control)

  • 혼잡 제어 (In-order delivery)

    흐름 제어를 위한 피드백을 제공하지 않고, 특별한 오류 검출 및 제어가 없어 UDP를 사용하는 프로그램 쪽에서 오류제어 기능을 스스로 갖춰야 합니다.

  • 순서대로 보내기

    수신된 메세지의 순서를 맞추는 순서제어가 없어 TCP 헤더와 달리 순서번호 필드가 없습니다.

  • Fragmentation


UDP의 주요 기능

  • 실시간 (Real-time)

    UDP는 빠른 요청과 응답이 필요한 실시간 응용에 적합합니다.

    제약 조건이 거의 없고 TCP에 비해 매우 빠르기 때문입니다.

    (인터넷 전화, 스트리밍 등)

  • 간단한 트랜잭션 (Simple transactions)

    TCP는 Setup과 종료, ACK를 모두 체크해야해 복잡한 transaction이 요구됩니다.

    하지만 UDP는 위의 과정을 체크하지 않기 때문에 transaction이 간단합니다.

    (DNS, DHCP, SNMP 등)

  • 멀티캐스트 / 브로드캐스트

    TCP는 전송측과 수신측이 서로 확인되어야 데이터를 주고받습니다.

    Point-to-point 방식으로 동작하는 TCP는 멀티캐스트와 브로드캐스트 전송이 모두 불가능합니다.

    UDP만 가능한 기능이며 전송 속도에 제한이 없습니다.


UDP 패킷 헤더 구조

image

TCP 헤더에 비해 매우 단순합니다.

발신/수신 포트번호가 존재하고 바이트 단위의 길이가 있습니다.

체크섬은 선택 항목이며 체크섬 값이 0이면 수신측은 체크섬 계산을 하지 않습니다.

기본적으로 UDP 헤더는 고정 크기의 8 바이트만 사용하며 헤더 처리에 많은 시간이 들지 않습니다.


DB 튜닝

데이터베이스 튜닝

데이터베이스 튜닝이란?

  • DB 성능 향상을 위해 필요한 작업을 변경하는 작업
  • 하드웨어의 성능을 향상시키는 것이 아닌 운영체제나 데이터베이스 구조를 이해하고 시스템 모니터링 결과 등을 참고해서 성능 향상을 위해 데이터베이스 설계, 데이터베이스 환경설정, SQL 문장을 변경한다.
  • DB의 성능은 데이터의 저장, 수정을 위해서만이 아닌 실제 운영에 있어서 매우 중요하다.
  • 시스템 개발 계획, 시스템 설계, 개발 및 검수, 운영 전 단계에서 데이터베이스 튜닝을 할 수 있다.
  • 설계 단계에서부터 적극적으로 성능에 대해 고려해야 이후 성능에 대한 이슈가 적게 발생한다. 즉, 설계 단계에서 어떻게 하면 효율적인 성능을 가진 시스템을 개발할 수 있을 지에 대한 고민을 하는 것이 매우 중요하다.

데이터베이스 튜닝 목적

  • 주어진 하드웨어 환경에서 처리량과 응답 속도를 개선하기 위함이다.
  • 서비스를 운영하다 보면 새로운 기능을 추가하거나 서비스를 새로 만들 수 있고 이로 인해 운영 환경이나 데이터베이스 테이블 구조 수정, 데이터량의 증가가 발생할 수 있다. 처음엔 서비스를 운영하기에 DB 성능에 문제가 없었지만 지속적인 수정 등으로 인해 성능 저하가 발생할 수 있다. 따라서 이러한 성능 저하를 막고 원활한 운영을 위해서는 DB 튜닝이 필요하다.

데이터베이스 튜닝 방법

  1. 시스템 현황 분석
  2. 문제점 탐지 및 원인 분석
  3. 목표 설정
  4. 튜닝 실시
  5. 결과 분석
  • 데이터베이스 튜닝을 위해 운영체제나 미들웨어(WAS, DBMS) 등의 영역에서 시스템 모니터링 결과를 참고하는데, 이를 통해 성능이 떨어지는 프로그램 혹은 특정 시점이나 환경에서 성능이 저하되는 상황을 확인한다.
    (미들웨어는 운영체제와 응용 소프트웨어의 중간에서 조정과 중개의 역할을 수행하는 소프트웨어이다(개발과 인프라의 중간 다리 역할). 데이터베이스 시스템, 전자 통신 소프트웨어, 메시지 및 쿼리 처리 소프트웨어가 미들웨어에 속한다.)
  • 문제 상황을 정확히 분석한 후 다양한 해결 방법을 시도한다. 이때, 문제의 해결이 시스템 전체 영역에 영향을 미쳐 시스템 운영에 방해가 될 수 있기 때문에 파급 효과에 대해 고려해야 한다.

데이터베이스 튜닝 3단계

  • DB 설계 튜닝
튜닝 단계 튜닝 방안 튜닝 사례
DB 설계 튜닝 DB 설계 단계에서 성능을 고려해서 설계한다.
  • 데이터베이스 모델링, 인덱스 설계
  • 데이터베이스 용량 산정
  • 데이터 파일, 테이블 스페이스 설계
    (테이블 스페이스: 데이터베이스의 논리적 저장 영역 단위)
반정규화/분산파일배치
DBMS 튜닝

성능을 고려해서 메모리나 블록의 크기를 지정한다.

  • CPU, 메모리 I/O를 DB 성능을 고려해서 지정
Buffer 크기, Cache 크기
SQL 튜닝

성능을 고려해서 SQL 작성, 쿼리 문장 수정

  • Join, Indexing, SQL Execution Plan(실행 계획)
Hash/Join

데이터베이스 튜닝 영역 별 세부 기법

튜닝 영역 기법 기법 설명
DB 설계 튜닝 영역 테이블 분할 및 통합 파티션 기능, 테이블 수평/수직 분할
식별자 지정/Key 설정 본질/인조 식별자 정의, 클러스터링
효율적 인덱스 설정 인덱스 분포도 고려 10~15%
정규화/반정규화 테이블, 컬럼, 관계 정규화/반정규화
적절한 데이터 타입 선정 조인 시 연결되는 데이터 타입 일치
데이터 모델링 슈퍼/서브 타입, PK, 파티셔닝, 데이터 통합
DBMS 튜닝 영역 I/O 최소화 실제 필요한 데이터만 Read, Query off-loading
Buffer Pool 튜닝 지역성 관점 데이터 관리, Keep Buffer Cache
Commit/Check Point Check Point 수해주기 조절, Commit 주기 조정
Thread/Reuse Middleware 기능과 연동
SQL 튜닝 영역 Undo Segment 설정 Undo 영역 크기 조정
옵티마이저 RBO/CBO 이해, 통계정보 최신화
힌트 사용 지원되는 힌트 기반 실행계획 유도
부분범위 처리 일부만 Access, 옵티마이저 정보 제공
인덱스 활용 인덱스 기반 조회 속도 향상, Sort 연산 대체
조인 방식/순서 실행 계획 확인 후 조정
동적 SQL 지양 파싱(Parsing) 부하 감소를 위한 Static SQL 사용
다중 처리 한 번의 DBMS 호출로 여러 건 동시 처리
병렬 처리 하나의 SQL을 여러 개의 CPU가 분할 처리
SORT 튜닝 수행 인덱스 기반 MIN, MAX 구하기, TOP-N 쿼리

참고

한국데이터산업진흥원, “SQL 전문가 가이드”
한국데이터산업진흥원, “DAP 전문가 가이드”

상호 배타적 집합

상호 배타적 집합

상호 배타적 집합(disjoint set)은 서로소 집합 또는 Union-Find라고도 합니다. 전체 집합에서 공통 원소를 가지지 않는 여러 부분 집합들을 저장하고 조작하는 자료구조입니다.

상호 배타적 집합 연산

  • Make(initialize): n개의 원소가 각각의 집합에 포함되어 있도록 초기화 한다.

    	private static void make() {
    		for (int i = 0; i < N; i++) {
    			parents[i] = i;
    		}
    	}
  • find: 어떤 원소 a가 속한 집합을 반환한다.

    • find 연산의 결과 값 비교로 두 원소가 현재 같은 집합에 속하는지 알 수 있다.
    	private static int find(int a) {
    		if(a==parents[a]) return a;
    		return find(parents[a]);
    	}
  • union: 두 원소 a,b 가 주어질 때 이들이 속한 두 집합을 하나로 합친다.

    	private static void union(int a , int b) {
    		int aRoot = find(a);
    		int bRoot = find(b);
    		if(aRoot == bRoot) return; // 둘은 같은 집합
    		
    		parents[bRoot] = aRoot;
    	}

상호 배타적 집합 표현

  1. 배열

    • 1차원 배열 belongsTo[i] = i번 원소가 속하는 집합의 번호라고 둔다.

    • make 연산을 하면 belongsTo[i] = i가 될 것이다.

    • find 연산을 하면 i가 속하는 집합의 번호를 O(1)로 구할 수 있다.

    • 하지만 union 연산을 했을 때 배열을 순회하면서 i가 속하는 집합을 갱신해주어야 하므로 O(N)이 걸린다.

  2. 트리

    • 한 집합에 속하는 원소들을 하나의 트리로 묶어 주기 때문에 트리들의 집합으로 표현한다.

    • make 연산을 하면 각 노드들이 자기 자신을 root로 가리킬 것이다.

    • find 연산을 하면 노드가 가리키는 값들을 루트가 나올 때 까지 타고 가서 root값을 가져오게 된다.
      트리의 높이만큼의 시간이 걸리므로 일반적으로 O(logN)로 구할 수 있다.

    • union 연산을 하면 노드 중 한 쪽이 가리키는 root노드가 다른 한쪽의 root 노드를 가리키게 하면 된다.
      이 연산 자체는 O(1)이지만 내부적으로 find를 사용하기 때문에 O(logN)으로 구할 수 있다.

    • 그래서 상호 배타적 집합은 트리로 표현한다.

최적화

하지만 트리로 표현했을 때 union연산의 결과로 트리의 구조가 한 쪽으로 기울어지는 문제가 생길 수 있습니다. 이 때 트리의 높이가 n이라면 find와 union 모두 O(n)의 시간 복잡도를 가지게 됩니다.

이 문제를 해결하는 방법은 크게 두 가지 입니다.

  1. 랭크에 의한 합치기(Union by rank)

    • 항상 높이가 더 낮은 트리를 높은 트리에 붙이는 방법입니다. 그러면 트리의 높이가 계속해서 높아지는 것을 막을 수 있습니다.

    • 구현할 때 높이 정보를 저장하는 rank 배열을 만들어서 해당 노드가 한 트리의 root인 경우 해당 트리의 높이를 저장하도록 합니다.

    • 높이가 같은 트리끼리 합치면 트리의 높이를 1 높여 줍니다.

    • (a)에서 6과 1를 union 할 때 6를 1에 연결합니다. 만약 반대가 된다면 트리의 rank가 증가했을 것입니다.

    	private static void union(int a , int b) {
    		int aRoot = find(a);
    		int bRoot = find(b);
    		if(aRoot == bRoot) return; // 둘은 같은 집합
            
            //a와 b의 rank를 비교하여 더 낮은 값이 a로 오게 한다.
            if(rank[a] > rank[b]) swap(a, b); 
            
            parent[a] = b; //a를 b에 연결한다.
            //a와 b의 rank가 같다면 b의 rank를 증가시킨다.
            if(rank[a] == rank[b]) rank[b]++; 
        }
  2. 경로 압축(path compression)

    • find 연산을 할 때 해당 노드가 연결되어 있는 경로를 따라 root를 찾아갑니다.
    • 이 때 연산으로 얻어낸 root를 해당 노드에 바로 연결한다면 다음에 find 연산을 할 때 해당 노드의 root를 한 번에 찾을 수 있게 됩니다.
    • (a)에서 find(0)을 수행하면 트리의 형태가 (b)로 바뀝니다.

    private static int find(int a) {
    	if(a==parents[a]) return a;
    	return parents[a] = find(parents[a]);
    }

사용 예

  • 그래프의 연결성 확인하기

  • 가장 큰 집합 찾기

    • 루트 노드에 현재 트리에 속한 노드의 개수를 저장한다면 가장 큰 집합을 찾을 수 있다.

          private static void make() {
          	parents = new int[V+1];
          	for (int i = 1; i < V+1; i++) {
                  //root가 음수이면 음수의 크기 만큼 노드를 갖고 있다고 표현 할 수 있다. 
      			parents[i] = -1; 
      		}
          }
          
          private static int find(int a) {
              //parents[a]가 음수이면 root이므로 그 값을 리턴한다.
          	if(parents[a] < 0) return a;
          	return parents[a] = find(parents[a]);
          }
          
          private static void union(int a, int b) {
          	int aRoot = find(a);
          	int bRoot = find(b);
          	
               // -1이면 root 노드 혼자이므로 같은 집합이 아니다.
          	if(aRoot != -1 && aRoot == bRoot) return;
          	
              //연결하는 root의 parents값을 더해서 새로운 트리의 크기를 저장할 수 있다.
          	parents[aRoot] += parents[bRoot];
          	parents[bRoot] = aRoot;
          	return;
          }

관련 문제

백준 1717 집합의 표현

백준 10775 공항

리플리케이션(Replication)

리플리케이션(Replication)

리플리케이션은 여러 개의 DB를 권한에 따라 수직적인(Master-Slave) 구조로 구축하는 방식입니다.

리플리케이션 구조는 데이터를 변경하는 쓰기(Insert, Update, Delete 등) 작업은 Master 가 처리하고 읽기(Select) 작업은 Slave가 처리하도록 합니다.

리플리케이션 구조는 클러스터와 달리 비동기적으로 DB 간의 데이터를 동기화합니다. 그래서 클러스터 구조 보다 데이터의 쓰기 작업의 속도가 빠릅니다. 하지만 비동기적이므로 딜레이 차이로 어떤 DB에는 데이터가 동기화 되어 있지만 그렇지 않은 DB가 존재할 수 있습니다.

동작 방식

  1. Master에 쓰기 트랜잭션이 수행됩니다.
  2. Master는 데이터를 저장하고 트랜잭션에 대한 Binary log를 만들어 기록합니다.
  3. Slave는 IO thread를 통해서 Master에 Binary log를 요청합니다.
  4. Master는 Binary log를 요청 받으면 binlog dump thread를 통해서 로그를 전송합니다.
  5. IO thread는 전송받은 로그로 Relay log를 만듭니다.
  6. SQL thread는 Relay log를 읽어서 이벤트를 다시 실행하여 Slave에 데이터를 저장합니다.

구성

  • Master

    • Binary log

      MySQL은 데이터 또는 스키마를 변경하는 이벤트들을 저장할 수 있으며, Binary log 이 이벤트들을 저장한 것입니다. Binary log는 DB를 변경하는 모든 이벤트가 저장되어 있으므로 원하는 시점으로 데이터를 복구할 수 있습니다. 그래서 Slave에서 이를 다시 실행하면 DB를 동기화 할 수 있습니다.

    • Binlog dump thread

      Binlog dump thread는 Slave의 I/O thread로 binary log를 요청했을 때 Master에서 이를 처리하기 위해 만든 쓰레드 입니다. Binlog dump thread는 Slave가 로그를 요청하면 binary log에 lock을 걸고, Slave로 로그를 전송합니다. 이때, binary log를 너무 긴 시간 lock하지 않기 위해서 Slave로 전송하기 전에 binary log를 읽고 바로 락을 해제합니다. binlog dump thread는 Slave가 Master에 connect 할 때 생성되지만, 여러 개의 Slave가 붙어도 단 하나의 스레드만 생성됩니다.

  • Slave

    • I/O thread

      I/O thread는 Slave가 마지막으로 읽었던 이벤트를 기억하고 있다가 Master에게 다음 binary log 로그를 전송해달라고 요청합니다. 그리고 Master의 binlog dump thread가 로그를 보내주면 이것을 Relay log로 저장합니다. 백업을 위해서 I/O thread를 잠시 정지할 수도 있습니다. 하지만, 정지된 상황에서 Master의 binary log가 지워지면 Slave는 Master의 데이터를 복제할 수 없습니다.

    • Relay log

      Relay log는 Slave I/O thread를 통해서 받은 로그를 저장한 것 입니다. 보통 relay log는 SQL thread가 읽고 나면 지웁니다. 따라서 어느 정도 이상의 크기가 되지 않습니다. 하지만 SQL thread가 멈추어 있으면 relay log는 계속해서 크기가 커지게 됩니다. 그렇게 되면 I/O thread는 자동으로 새 relay log 파일을 만들어, 파일이 너무 커지는 것을 막습니다.

    • SQL thread

      SQL thread는 I/O thread가 만든 relay log를 읽어 실행을 시키고, relay log를 지웁니다.

장점 및 단점

  • 장점

    • 데이터 동기화가 비동기 방식이기 때문에 지연시간이 거의 없습니다.
    • DB로 요청하는 Query의 대부분은 읽기 작업입니다. 리플리케이션 구조는 읽기 요청을 Slave에서 전담하여 처리할 수 있으므로 Slave의 Scale을 늘림으로써 부하를 분산시키고 DB의 읽기 속도를 향상 시킬 수 있습니다.
    • Slave에 데이터가 복제되며 Slave는 복제 프로세스를 일시적으로 중지할 수 있기 때문에 특정 시점의 원본 소스 데이터를 손상시키지 않고 백업이 가능합니다.
    • Master에서 현재 데이터를 동기화 한 뒤 Slave에서 서비스에 영향을 주지 않고 데이터의 분석을 실행할 수 있습니다.
    • 여러 지역 혹은 local에 Slave를 복제할 수 있으므로 지리적으로 빠른 응답속도를 보일 수 있으며 문제가 생겼을때 DB의 데이터를 복구하기 수월합니다.
  • 단점

    • 데이터 동기화가 비동기 방식이기 때문에 시간차가 생겨서 Slave에 최신 데이터가 반영되지 않았을 수 있습니다.
    • Master와 Slave의 기능 및 구성의 차이로 인해 Master가 다운되었을 때 클러스터 구조 처럼 Fail over 한 시스템을 만들 수 없습니다.

리플리케이션 vs 클러스터

  • 리플리케이션

    • 여러 개의 DB서버와 DB를 권한에 따라 수직적인 구조(master-Slave)로 구축하는 방식이다.
    • 비동기 방식으로 데이터를 동기화 한다.
    • 데이터 무결성 검사를 하지 않기 때문에 데이터가 동기화로 인한 지연시간이 거의 없다.
    • DB 간에 일관성 있는 데이터를 얻지 못할 수 있다.
  • 클러스터

    • 여러 개의 DB 서버를 수평적인 구조로 구축하는 방식이다.
    • 동기 방식으로 노드들 간의 데이터를 동기화 한다.
    • 데이터를 동기화하는 시간이 필요하므로 리플리케이션에 비해 쓰기 성능이 떨어진다.
    • 1개의 DB 서버가 다운되어도 시스템 장애를 최소화 하도록 Fail Over 하게 운영할 수 있다.

이항 계수

모듈라 연산

몇 가지 중요한 암호 시스템은 계산 결과가 항상 0 - (M-1) 범위에있는 경우 모듈라 연산을 사용한다고 한다.

이때 M이 우리가 %를 하고자 하는 모듈라 값이다.

아래는 modular를 mod를 표현한 우리가 기본적으로 알고있는 모듈라 연산이다

43 mod 6 = 1

27 mod 9 = 0

3 mod 20 = 3

50 mod 17 = 16

그리고 음수의 경우에도 모듈러 연산이 가능하다.

-13 mod 11 = 9

-10 mod 11 = 1

일반적으로 수학적으로 나머지는 양수라고 약속했기 때문에 음수를 mod 할 경우에는 양수라 생각하고 mod를 한 값의 음수에서 + m을 해주면 된다.

예를 들어 -13 mod 11이면 13 mod 11 = 2 에서 -2 + 11 = 9와 같다.

하지만 프로그래밍을 할 때 A % B 에서 A 또는 B가 음수가 되면 결과는 어떻게 될까?

놀랍게도 답은 "구현마다 다르다(Implementation-defined)"

당장 파이썬에서 -10 % 4는 2가 출력되고 10 % -4는 -2가 출력된다.

그리고 C++17 -10 % 4는 -2가 출력된다. 그렇기 때문에 음수 모듈라 연산을 할 때는 언어별로 다르다는 점을 미리 고려해야 할 것 같다.

모듈라 합동

모듈라 연산에 이해했다면 모듈라 합동에 대해서도 알면 좋을것 같다.

(A mod M) = (B mod M) => A ≡ B (mod M)

어떤 값 A와 B가 M으로 나누었을 때 나머지가 같다면 A와 B는 모듈라 M에 대한 합동 관계라고 표현한다.

여기서 A와 B는 A - B를 하였을 때, M의 배수가 된다.

다시 말해 A - B = K * M (K는 임의의 정수)이다.

예를 들어 13 % 6 = 1이고, 25 % 6 = 1이므로, 13과25는 모듈라 6에 대한 합동이라고 말할 수 있다.

아래 문제는 이 모듈라 합동에 관련된 문제이다.

백준 2981번 : 검문

모듈라 연산의 속성

모듈라 연산에는 재밌는 속성들이 존재한다.

먼저 (A + B) mod M = ((A mod M) + (B mod M)) mod M 이 성립한다.

그리고 (A - B) mod M = ((A mod M) - (B mod M)) mod M 이 성립하며

놀랍게도 (A * B) mod M = ((A mod M) * (B mod M)) mod M 또한 성립한다.

우리는 수학자가 아니라 공학자이므로 증명은 생략하고 위 공식을 잘 써먹기만 하면 된다.

이 공식을 잘 이용한다면 아래와 같은 문제를 풀 수 있다.

2^50이상은 계산할 수 없는 계산기가 존재한다.

이 계산기는 mod 연산을 할 수 있는 기능이 탑재되어있다.

이 때 2^90 mod 13을 구하라.

이 문제는 거듭제곱을 가지는 값을 모듈라 곱셈 속성을 이용해서 분할 정복으로 해결할 수 있다.

2^90은 2^50 * 2^40이다. 그러면 2^90 mod 13은

(A * B) mod M = ((A mod M) * (B mod M)) mod M 를 이용하여

2^90 mod 13 = (2^50 * 2^40) mod 13 = ((2^50 mod 13) * (2^40 mod 13)) mod 13으로 변형시킬 수 있다.

계산기를 통해서 2^50 mod 13 = 4, 2^40 mod 13 = 3이라는 값은 바로 구할 수 있다고 했을 때,

결국 2^90 mod 13 = 12 mod 13 = 12라는 사실을 알 수 있다.

이항 계수

우선 이항 계수를 다루기전에 근본적으로 이항 정리에 대해 알아야 한다.

화면 캡처 2021-09-01 230923

이항 정리는 (A + B)^n (여기서 n은 음이 아닌 정수)라는 다항식을 전개할 때 쓰는 정리이며, 여기서 '이항'이라는 단어는 '두 개의 항'이라는 뜻이다.

이항 계수는 (A + B)^n 라는 다항식을 전개했을 때, A^r*B^(n-r) (0 <= r <= n인 정수)의 계수를 의미한다.

A^r*B^(n-r)의 계수는 총 n개의 문자를 순서없이 배열하는 경우의 수와 같으며, 이는 조합과 같다.

그래서 A^r*B^(n-r)의 계수는 nCr과 같다.

우리는 이항 계수의 수학적인 증명이나 성질을 자세하게 다루기보다는 PS에 이항 계수 관련 문제가 나오면

어떻게 이 이항 계수를 빠르고 효율적으로 구할 수 있느냐를 중점적으로 알아보려고 한다.

2가지 문제를 통해서 이항 계수 PS을 알아보도록 하자. 먼저 가장 기초적인 이항 계수를 구하는 문제이다.

자연수 N과 정수 K가 주어졌을 때, 이항 계수를 10,007으로 모듈라 연산한 값을 구하시오. (1 <= N <= 1,000 / 0 <= K <= N)

binomial_coefficient(N, K){
	K = Max(K, N-K); // K와 N-K 중 큰 수를 고른다. return으로  K를 나눈 값을 return하는데 K가 0일 수 있기 때문에
	A = N;
	
	for(i = K-1; i > 0; i--){
		A = A * (N - i); // nPk
		K = K * i;	// k!
	}
	
	return A/K; //nPk/k! == nCk
}

보통 위 같은 문제는 겉으로 보기에는 return 값이 엄청나게 커져 오버플로우가 발생할 수 있기 때문에 보기 편하게 10,007을 나눈 나머지 값을 구하라는 문제라고 생각할 수 있다.

그리고 실제로 N의 범위가 1,000까지기 때문에 정석적인 방식으로 이항계수를 구하고 모듈라 연산을 그냥 리턴할 때 하면 된다.

만약 N의 범위가 4,000,000이라면 위처럼 구할 수 있을까? 동일하게 자연수 N과 정수 K가 주어졌을 때, 이항 계수를 구하는 문제인데,

N의 범위를 4,000,000까지 확장하여 이항 계수를 1,000,000,007으로 모듈라 연산한 값을 구하여라.

이 땐 처음 문제와 다르게 모듈라 연산의 존재 유무가 중요해졌다.

이 문제를 풀기 위해서는 우리가 앞서 배운 모듈라 연산의 분배법칙 속성을 활용해야 한다.

N과 K의 이항계수는 nCk = N!/(K!*(N-K)!) 로 구할 수 있다.

그렇다면 이 이항계수를 모듈라 연산으로 분할정복해서 구할 순 없을까?

하지만 위에서 봤을 때, 덧셈, 뺄셈, 곱셈에 대해서는 분배법칙이 존재했지만, 나눗셈은 존재하지 않았다.

즉, (A / B) mod M != ((A mod M) / (B mod M)) mod M 이라는 것이다.

여기서 A를 N!, B를 K!*(N-K)! 이라고 대입해보자.

(N! / K!(N-K)!) mod M != ((N! mod M) / (K!(N-K)! mod M)) mod M 으로 바꿀 수 있다.

왜 성립하지도 않는데 귀찮게 대입을 해서 식을 직접 눈으로 확인해 본 이유가 있다.

우리가 이항 계수의 분수가 나눗셈이기 때문에 분배법칙이 적용되지 않았는데, 이 분수를 비틀어서 곱셈으로 만들어 버린다면 이항계수의 분배법칙을 가능하게 할 수 있다.

그렇다면 곱셈꼴로는 어떻게 만들까? 분수를 곱셈꼴로 만드는 방법은 역원을 이용하면 쉽게 만들 수 있다.

만약 A / B = C 라면, 이는 A * B^(-1) = C라는 의미이다. 즉 A 나누기 B는 A 곱하기 B의 역원과 같다.

그렇기 때문에, (N! / K!(N-K)!) mod M는 (N! * (K!(N-K)!)^(-1)) mod M 로 표현 가능하다.

역원을 이용해 나눗셈을 곱셈까지는 표현하는데는 성공하였다. 그런데 나눗셈이 존재했던 이유는 분수때문이고, 분수의 역원은 어쨌든 분수가 아닌가?

이 문제를 해결하기 위해서 필요한 공식이 '페르마의 소정리'이다.

페르마의 소정리는 다음과 같다.

A는 정수, P는 소수이며 A가 P로 나눠지지 않을 때, (A는 P의 배수가 아니라는 뜻)

A^P ≡ A (mod P)이다. (P에 대해 모듈라 합동이다 : P를 나눈 나머지가 같다.)

이는 이렇게 표현이 가능하다 -> A^P mod M ≡ A mod M

다시 말하지만 우리는 위 공식이 어떻게 증명되는지에 대해서는 관심없고 증명된 공식들을 잘 써먹는데에 관심이 있는 공학자들이다.

위 표현식을 다시 응용하면, A^(P-1) ≡ 1 (mod P) => A * A^(P-2) ≡ 1 (mod P)로 변형이 가능하다.

놀랍게도, A (mod P)에 대한 역원은 A^(P-2) (mod P)라는 것이다.

그렇다면 다시 문제로 돌아와서 해당 분수의 역원을 페르마의 소정리로 구해보면,

A는 (K! * (N-K)!) , P는 1,000,000,007 로 대입할 수 있다.

A^(-1) = A^(P-2) = (K! * (N-K)!)^(-1) = (K! * (N-K)!)^1,000,000,005 가 된다.

이제는 더 이상 역원이 분수가 아닌 정수로 표현되니, 모듈라 곱셈 분배 법칙 적용할 수 있게 되었다.

최종적으로 도출되는 식은 아래와 같다.

N! / (K!(N-K)!) mod M
= (N! * (K!
(N-K)!)^(-1)) mod M
= (N! * (K!*(N-K)!)^(M-2)) mod M
= ((N! mod M) * (K! * (N-K)!)^(M-2) mod M) mod M

이렇게 정리가 된다.

이제 곱셈 분배법칙이 적용되니 분할 정복을 하여야 한다.

분할 정복은 (K! * (N-K)!)^(M-2)에서 지수 M-2를 계속 절반씩 나눠서 지수가 짝수일 때와 홀수일 때를 구분하여 리턴해주면 된다.

아래는 위 문제의 수도코드이다.

// 수도코드
M = 1,000,000,007
A = factorial(N); // N!
B = factorial(K) * factorial(N-K) % M; // K!*(N-K)!

print(A * divide_conquer(B, M-2) % M); // (N! * (K!*(N-K)!)^(M-2)) mod M  

// 팩토리얼 구하면서 mod M을 계속 해줌
factorial(num){
	result = 1;
	while(num > 1){
		result = (result * num--) % M
	}
	
	return result;
}

// num : 밑수, exp : 지수
divide_conquer(num, exp){
    	if(exp == 1) return num % M; // 지수가 1일 경우 num^1 이므로 num % M 리턴
    	
	temp = divide_conquer(num, exp/2); // 모듈라 연산 곱셈 분배법칙을 이용한 분할 정복
	
	if(exp % 2 == 1) return (temp * temp) * num % M; // 분할 정복이 끝나고 지수가 홀수가 남으면 ex)A^5 = A^2 * A^2 * A
	else return temp * temp % M; // 지수가 짝수면 A^4 = A^2 * A^2
}

조합탐색 정리했습니다!

조합 탐색

완전 탐색을 포함해서, 유한한 크기의 탐색 공간을 뒤지면서 답을 찾아내는 알고리즘들을 조합 탐색(combinatorial search)이라고 부른다.

조합 탐색에는 다양한 최적화 기법이 있으며, 이것들의 접근 방법은 다르지만 기본적으로 모두 최적해가 될 가능성이 없는 답들을 탐색하는 것을 방지하여 연산 횟수를 줄이는 것을 목표로 한다.

가지치기

가지치기(pruning) 기법은 탐색 과정에서 최적해로 연결될 가능성이 없는 부분들을 잘라낸다.

현재 상태에서 답의 나머지를 완성했을 때 얻을 수 있는 가장 좋은 답이 지금까지 우리가 알고 있는 최적해보다 나쁘다면 탐색을 더 진행할 필요가 없다.


가지치기 기법들은 여러 방법을 이용해 현재 상태에서 얻을 수 있는 가장 좋은 답의 상한을 찾아낸다.

  • 가지치기의 가장 기초적인 예

    지금까지 찾아낸 최적해보다 부분해가 이미 더 나빠졌다면 현재 상태를 마저 탐색하지 않고 종료

    현재까지 최단 경로는 10인데, 재귀 호출 도중 현재까지의 부분 해가 10이 넘는다면 탐색 중단

if (best <= currentLength) return;

휴리스틱을 이용해 가지치기

최적해보다 나빠지면 그만두는 가지치기는 나름 유용하지만, 동적 계획법에 비하면 아직도 연산 횟수가 많다.

휴리스틱을 이용해 답의 남은 부분을 어림짐작하는 가지치기를 이용하면 좀더 똑똑한 가지치기를 수행할 수 있다.

조합 탐색에서 방문하는 상태의 수는 탐색의 깊이가 깊어질수록 증가하기 때문에 탐색 중 '이 부분에서는 최적해가 나올 수 없다'는 것을 가능한 일찍 알아내는 것이 유리하다.

외판원 순회 문제의 경우 방문할 도시가 다섯 개 남은 시점에서 탐색을 중단했다면 5!(120) 개의 경로를 만들어보는 시간을 절약할 수 있다.

반면 방문할 도시 10개가 남은 시점에서 더이상 가능성이 없음을 알아채고 탐색을 중단한다면 10!(3,628,800)개의 경로를 만드는 시간을 절약할 수 있다.

따라서 우리는 탐색의 현재 가지가 최적회를 찾을 가능성이 없는지를 가능한 빨리 알아내는 것이 유리하다.


휴리스틱(heuristic)을 이용한 가지치기남은 조각들을 푸는 최적해를 찾기는 오래 걸리더라도, 이 값을 적당히 어림짐작하기는 훨씬 빠르게 할 수 있다는 점을 이용해 가지치기를 수행한다.

휴리스틱의 반환 값은 항상 정확한 답일 필요는 없고 그럴 수도 없다.

우리가 기대하는 바는 휴리스틱이 어디까지나 '적당히 그럴듯한' 짐작을 돌려주는 것이다.

탐색 과정에서 찾은 가장 좋은 답의 길이가 best 이고, 현재 부분 경로의 길이를 length 라고 한다.

현재 상태에서 탐색을 계속해 최적해를 갱신할 수 있으려면 앞으로 길이가 best-length 미만인 경로로 남은 도시들을 모두 방문하고 시작점으로 돌아갈 수 있어야 한다.

이때 휴리스틱 함수가 답을 찾기 위해 그보다 긴 경로가 필요하다고 말한다면 탐색을 중단할 수 있다.


하지만 이 알고리즘에는 큰 문제가 있다.

휴리스틱 함수의 반환값은 어디까지나 어림짐작이기 때문에 그 값이 맞지 않는다.

만약 휴리스틱이 실제 필요한 경로보다 더 긴 경로가 필요하다고 잘못 짐작할 경우에는 최적해를 찾을 수 있는 상태를 걸러내 버려 최적해를 찾을 수 없게 된다.

이런 일이 일어나지 않기 위해 휴리스틱의 반환 값이 항상 남은 최단 경로의 길이보다 작거나 같아야 한다.

→ 이런 방법들을 과소평가(understimate)하는 휴리스틱, 혹은 낙관적인 휴리스틱(optimistic heuristic) 이라고 말한다.

휴리스틱 함수 작성하기

항상 답을 과소평가하는 휴리스틱 함수를 만들기는 쉽다.

항상 0을 반환하는 함수를 만들면 된다.

하지만 이렇게는 탐색에 아무런 도움이 되지 않는다.

우리는 항상 실제 답 이하이면서도 가능한 큰 값을 구하기를 원한다.

휴리스틱이 큰 값을 반환할수록 더 많은 가지를 칠 수 있기 때문이다.


단순한 휴리스틱 함수 구현

휴리스틱 함수를 만드는 과정은 문제마다 다르기 때문에 정해진 방법이 존재하진 않지만, 좋은 방법 중 하나는 문제의 제약 조건을 일부 없앤 더 단순한 형태의 문제를 푸는 것이다.

외판원 순회 문제를 예시로 들어보면,

  • 남은 도시를 모두 방문할 필요 없이, 가장 멀리 있는 도시 하나만 방문했다가 시작점으로 돌아간다.
  • 남은 도시들을 방문하는 방법이 꼭 일렬로 연결된 형태가 아니어도 된다.

위의 예시처럼 조건들을 없애고 단순하게 휴리스틱을 구현한다.


처음 사용할 휴리스틱 함수는 아직 방문하지 않은 도시들에 대해 인접한 간선 중 가장 짧은 간선의 길이를 더하는 것이다.

아직 방문하지 않은 도시를 방문하려면 인접한 간선 중 하나를 거쳐야 하므로 이들 중 가장 짧은 간선의 길이만을 모으면 실제 최단 경로 이하의 값이 될 수밖에 없다.

double simpleHeuristic(boolean[] visited) {
	double edge = minEdge[0];  // 마지막에 시작점으로 돌아갈 때 사용할 간선
	for (int i = 0; i < n; ++i) {
		if (!visited[i]) edge += minEdge[i];
	}
	return edge;
}

void search(int[] path, boolean[] visited, double currentLength) {
	if (best <= currentLength + simpleHeuristic(visited)) return;
}

위의 알고리즘은 최적해보다 나빠지면 가지치는 알고리즘에 비해 약 열배 넘게 빨라진다.


가까운 도시부터 방문하기

외판원 순회 문제를 해결하는 조합 탐색은 각 재귀 호출마다 다음에는 어느 도시를 방문할지를 결정하는 방식으로 구현된다.

이때 도시를 번호 순서대로 방문하는 대신, 더 가까운 것부터 방문하면 좋은 답을 더 빨리 찾아낼 수 있는 경우가 있다.

예를 들어서 도시들이 몇 개씩 뭉쳐 있는 경우, 멀리 있는 도시 대신에 바로 옆에 있는 도시를 방문하는 것이 유리한 경우가 많다.

이와 같은 전략이 항상 최적해를 가져다 주는 것은 아니지만, 어느 정도 좋은 답을 좀더 일찍 발견할 확률은 올라간다.

좋은 답을 일찍 찾을수록 가지치기를 더 많이 할 수 있다.


이때 **주의할 점**은 각 도시마다 다른 모든 도시들을 거리순으로 미리 정렬해서 저장해둬야 한다.

정렬된 도시 목록을 자주 사용해야 하기 때문에 매번 정렬하기보다 미리 계산해 두는 편이 유리하다.

각 도시마다 다른 도시들을 까운 순서대로 정렬한다.
double[][] nearest;

search(int[] path, boolean[] visited, double currentLength) {
	// 다음 방문할 도시를 전부 시도
	for (int i = 0; i < nearest[here].size; ++i) {
		int next = nerest[here][i];
	}
}

double solve() {
	// nearest 초기화
	for (int i = 0; i < n; ++i) {
		for (int j = 0; j < n; ++j) {
			if (i != j) nearest[i][j] = dist[i][j];
		}
		Arrays.sort(nearest, (o1, o2) -> o1[1]-o2[1]);
	}
}

이와 같은 순서로 조합 탐색을 수행하는 것은 탐색을 시작하기 전에 탐욕적 알고리즘으로 초기해를 구하는 것과 같은 효과가 있다.


지나온 경로를 이용한 가지치기

앞으로 남은 부분들의 비용을 휴리스틱을 이용해 예측하는 것 뿐만 아니라 지금까지 만든 부분의 답을 검사해서 가지치기를 할 수도 있다.

지금까지 만든 경로가 시작 상태에서 현재 상태까지 도달하는 최적해가 아니라고 했을 때, 앞으로 남은 조각들에서 아무리 선택을 해도 최적해를 찾지 못한다.

물론 현재 상태까지 오기 위해 택한 경로가 최적해인지를 알기 쉽지 않다.


따라서 대개 지나간 길을 돌아보는 가지치기는 탐색의 각 단계에서 현재까지 만든 부분해에 간단한 조작을 해서 결과적으로 답이 더 좋아시면 탐색을 중단하는 식으로 구현된다.

외판원 순회 문제에서 두 개의 인접한 도시를 골라서 이 둘의 순서를 바꿔본 뒤, 경로가 더 짧아지면 탐색을 중단하는 가지치기를 구현할 수 있다.

현재까지의 해가 (.., .., p, a, b, q, .., here) 일 때, ab 의 순서를 바꿔보고, 이 때 p-q 구간의 거리가 더 짧아진다면 현재까지의 해가 절대로 최적해가 될 가능성이 없다.

// path의 마지막 네 개의 도시 중 가운데 있는 두 도시의 순서를 바꿨을 때
// 경로가 더 짧아지는지 여부를 반환
boolean pathSwapPruning(int[] path) {
	if (path.size() < 4) return false;
	int p = path[path.size()-4];
	int a = path[path.size()-3];
	int b = path[path.size()-2];
	int q = path[path.size()-1];
	return dist[p][a] + dist[b][q] > dist[p][b] + dist[a][q];
}

// 시작 도시와 현재 도시를 제외한 path의 부분 경로를 뒤집어보고 더 짧아지는지 확인
// pathSwapPruning 의 일반화 방법
// (...,p,a,b,c,d,e,q,...,here) -> (...,p.e,d,c,b,a,q,...,here)
boolean pathReversePruning(int[[ path) {
	if (path.size() < 4) return false;
	int b = path[path.size()-2];
	int q = path[path.size()-1];
	for (int i = 0; i+3 < path.size(); ++i) {
		int p = path[i];
		int a = path[i+1];
		if (dist[p][a]+dist[b][q] > dist[p][b]+dist[a][q]) return true;
	}
	return false;
}

이 가지치기는 입력이 많을 때 시간 내에 풀 수 있도록 해준다.

부분 경로를 뒤집는 pathReversePruning 은 거의 동적 계획법과 비슷한 수준의 속도이다.


MST 휴리스틱을 이용한 가지치기

앞쪽의 단순한 휴리스틱도 나름대로 그럴듯한 답을 반환하지만, 간선들이 하나로 연결되지 않고 따로 따로 떨어져 있을 가능성이 있다.

그래서 좀더 현실에 가까운 답을 계산하기 위해 조금 더 제약이 있는 문제를 만들어본다.

  • 한 간선은 최대 한 번만 선택할 수 있다.
  • 선택하지 않은 간선을 모두 지웠을 때 그래프가 둘 이상으로 쪼개지면 안된다.

위와 같은 제약을 추가한다.

간단하게 말한다면 선택한 간선들만 남겼을 때 아직 방문하지 않은 정점들과 현재 정점이 연결되어있어야 한다는 말이다.


이 방법은 최소 스패닝 트리 방법이다.

현재 위치에서 시작해 아직 방문하지 않은 정점들을 모두 방문하고, 시작점으로 돌아오는 최단 경 또한 이 정점들을 모두 연결하는 스패닝 트리이다.

따라서 최소 스패닝 트리의 가중치의 합은 항상 최단 경로보다 작음을 알 수 있다.

// 모든 도시 간의 도로를 길이 순으로 정렬해 저장
Edge[] edgeList;

// 상호 배타적 집합을 만드는 메소드들
void make();
void find(int a);
boolean union(int a, int b);

int mstHeuristic() {
	make();
	int cnt = 0; result = 0;
	for(Edge edge: edgeList) {
		if (union(edge.start, edge.end)) {
			result += edge.weight;
			if (++cnt == V-1) break;	// 신장트리 완성
		}
	}

	return result;
}

MST 휴리스틱은 단순한 휴리스틱보다 훨씬 정확한 값을 찾아준다는 것을 알 수 있다.

한 가지 유의할 점은 MST 휴리스틱이 단순한 휴리스틱보다 시간적으로 불리다는 것이다.

하지만 결과적으로 연산 횟수가 더 줄어드는 것은 명확하다.


메모이제이션

조합 탐색 과정에서 같은 상태를 두 번 이상 마주치는 것은 흔한 일이다.

이런 비효율은 메모이제이션으로 제거할 수 있다.

힙 정렬

힙 정렬

힙 정렬(Heap Sorting)은 최대 힙 트리나 최소 힙 트리를 구성해 힙의 특성을 이용하여 정렬 하는 알고리즘입니다.

최대 힙과 최소 힙에 대한 자세한 설명은 [힙](## 힙)을 참고하세요.

특징

힙 정렬의 가장 큰 특징은 힙 트리 구조를 만들어야 한다는 점입니다.

힙 트리를 만드는 알고리즘(Heapify Algorithm)은 하나의 노드를 선택해서 두 자식 중에서 더 큰 자식과 자신의 위치를 바꾸는 과정을 힙 구조를 만족할 때 까지 전체 노드에 대해 반복하는 작업입니다.

과정

  1. 정렬해야 할 n개의 요소들로 최대 또는 최소 힙(완전 이진 트리 형태)을 만든다.

  2. 힙의 루트 값과 마지막 index 값을 교환한다.

  3. 정렬이 필요한 index를 1 줄인다.

  4. index가 0이 될 때까지 2~3을 반복한다.

예를 들어 {7, 6, 5, 8, 3, 5, 9, 1, 6}을 최대 힙으로 오름차순으로 정렬한다면 다음과 같은 과정을 거칩니다.

  • 처음 최대 힙 구조를 만들면 9가 root로 오게 됩니다.

  • 그러므로 root와 가장 마지막 노드의 값을 교환하면 다음과 같아집니다. 이 때 9는 정렬 된 index이므로 heap의 마지막 index는 1의 값이 있는 곳으로 감소합니다.

  • 다시 최대 힙 구조를 만들면 root에 8이 오게 됩니다.

  • 위의 과정을 반복합니다.

구현

//최대 힙을 이용한 오름차순 정렬 구현


public void sort(int arrA[]) {
    int size = arrA.length;

    // 힙 트리 생성
    // i: 각 서브 트리의 루트 노드
    for (int i = size / 2 - 1; i >= 0; i--)
        heapify(arrA, size, i);

    // heap의 root값(최댓값)을 배열의 마지막 값과 swap. 
    // i값을 뒤에서부터 0까지 반복하여 뒤에 값부터 최댓값이 채워진다.
    for (int i=size-1; i>=0; i--) {

        //arrA[0]이 root, arrA[i]가 배열의 마지막 값
        int x = arrA[0];
        arrA[0] = arrA[i];
        arrA[i] = x;

        // 힙 트리 생성
        heapify(arrA, i, 0);
    }
}

// node i의 서브트리 간에 최대 힙의 조건을 만족하도록 트리를 생성한다.
// heapSize는 배열의 아직 정렬되지 않은 index 범위를 의미합니다.
void heapify(int arrA[], int heapSize, int i) {
    int largest = i; // node i가 최대 값이라고 가정
    int leftChildIdx  = 2*i + 1; // left = 2*i + 1
    int rightChildIdx  = 2*i + 2; // right = 2*i + 2

    // 왼쪽 자식 노드가 루트 노드보다 크다면 자식 노드의 index를 저장한다.
    if (leftChildIdx  < heapSize && arrA[leftChildIdx ] > arrA[largest])
        largest = leftChildIdx ;

    // 오른쪽 자식 노드가 루트 노드보다 크다면  자식 노드의 index를 저장한다.
    if (rightChildIdx  < heapSize && arrA[rightChildIdx ] > arrA[largest])
        largest = rightChildIdx ;

    // 최대 값 노드의 index가 변경되었다면 값을 swap한다.
    if (largest != i) {
        int swap = arrA[i];
        arrA[i] = arrA[largest];
        arrA[largest] = swap;

        // swap한 구조가 최대 힙 트리를 만족할 때 까지 위의 과정을 반복한다.
        heapify(arrA, heapSize, largest);
    }
}

시간 복잡도

데이터 갯수가 n개이고, 힙 구조를 만들 때 완전 이진 트리에 데이터를 삽입하는데 O(log n) 이므로 O(N*logN)의 시간 복잡도를 가집니다.

SQL vs NoSQL

SQL vs NoSQL

[SQL (Structured Query Language) ]

SQL은 RDBMS (관계형 데이터베이스 관리 시스템)의 데이터를 관리하기 위해 설계된 프로그래밍 언어입니다. SQL의 예시로는 MySQL, PostgreSQL 등이 있습니다.

이 글에서는 SQL을 곧 RDBMS (관계형 데이터베이스) 로 사용함

[NoSQL (Not Only Structured Query Language) ]

NoSQL은 앞서 말한 SQL보다 덜 제한적인 모델을 이용해 데이터의 저장 및 검색 메커니즘을 제공합니다. NoSQL의 예시로는 mongoDB, redis 등이 있습니다.

데이터의 구조 (Structure)

[SQL]

image

출처 : https://siyoon210.tistory.com/130

SQL에서는 엄격한 스키마(데이터 저장 구조)를 원칙으로 하기 때문에 스키마를 준수하지 않는 형식의 데이터는 저장할 수 없습니다.

[NoSQL]

image

출처 : https://siyoon210.tistory.com/130

NoSQL에서는 정해진 스키마가 없습니다. SQL에서는 정해진 스키마를 따르지 않는다면 데이터를 추가할 수 없었지만, NoSQL에서는 다른 구조의 데이터를 같은 컬렉션에 추가할 수 있습니다.

NoSQL에서는 레코드를 document, 테이블을 Collection이라고 부릅니다.

데이터의 관계 (Relationship)

[ SQL ]

image

출처 : https://mjmjmj98.tistory.com/43

데이터들을 여러개의 테이블에 나누어서, 데이터들의 중복을 피할 수 있습니다.

만약 사용자가 구입한 상품들을 나타내기 위해서는, Customers(사용자), Products(상품), Orders(주문한 상품) 여러 테이블을 만들어야 하지만, 각각의 테이블들은 다른 테이블에 저장되지 않은 데이터를 가지고 있습니다. (중복된 데이터가 없습니다.)

이런 명확한 구조는 장점이 있습니다. 하나의 테이블에서 중복없이 하나의 데이터만을 관리하기 때문에, 다른 테이블에서 부정확한 데이터를 다룰 위험이 없습니다.

[ NoSQL ]

image

출처 : https://mjmjmj98.tistory.com/43

반면 NoSQL에서는 보통 하나의 컬렉션(SQL에서의 테이블)에 관련 데이터를 모두 작성합니다.

일반적으로 관련 데이터를 동일한 컬렉션에 넣습니다. (RDBMS처럼 여러 테이블에 나누어 담지 않습니다.) 많은 Order(주문한 상품)이 있는 경우, 일반적인 정보를 모두 포함한 데이터를 Orders 컬렉션에 저장합니다. (즉, 관계형데이터 베이스에서 사용했던 Users나 Products 정보 또한 Orders에 포함해서 한꺼번에 저장됩니다.)

따라서 여러 테이블 / 컬렉션에 조인할 필요없이 이미 필요한 모든 것을 갖춘 문서를 작성하게 됩니다. (실제로 NoSQL DB에는 조인이라는 개념이 존재하지 않습니다.) 대신 컬렉션을 통해 데이터를 복제하여 각 컬렉션 일부분에 속하는 데이터를 정확하게 산출하도록 합니다.

이런 방식은 SQL과 다르게 중복된 데이터가 생기게 됩니다. 그래서 데이터를 업데이트 할 때마다 주의해야 합니다. (예를 들어 Customer에 John의 email을 추가했는데, Orders에는 이를 업데이트하지 않으면 문제가 발생합니다.)

데이터의 확장성 (Scalability)

두 종류의 데이터베이스를 비교할 때 살펴 봐야할 또 하나의 중요한 개념은 확장(Scaling) 입니다. (데이터베이스 서버의 확장성)

확장은 수직적(vertical) 확장수평적(horizontal) 확장으로 구별할 수 있습니다.

  • 수직적 확장이란 단순히 데이터베이스 서버의 성능을 향상시키는 것입니다.
  • 반면에 수평적 확장은 더 많은 서버가 추가되고 데이터베이스가 전체적으로 분산됨을 의미합니다.

[ SQL(vertical scale)]

데이터가 저장되는 방식 때문에 SQL은 일반적으로 수직적 확장만을 지원합니다.

[NoSQL(horizontal scale)]

반면에 NoSQL에서는 수평적 확장이 가능합니다.

SQL vs NoSQL 장단점

[SQL]

장점

  • 명확하게 정의된 스키마, 데이터의 무결성 보장
  • 데이터의 관계 덕분에 데이터는 중복없이 한 번만 저장됩니다.

단점

  • 상대적으로 덜 유연합니다. 데이터 스키마는 사전에 계획되어야 합니다. (나중에 수정하기가 번거롭거나 불가능 할 수도 있음)
  • 관계를 맺고 있기 때문에 JOIN문이 많은 매우 복잡한 쿼리가 만들어 질 수 있습니다.
  • 수평적 확장이 어렵고, 대체로 수직적 확장만 가능합니다. 즉 어떤 시점에서 (처리할 수 있는 처리량 관련하여) 성장 한계에 직면하게 됩니다.

[NoSQL]

장점

  • 스키마가 없기때문에, 훨씬 더 유연합니다. 즉, 언제든지 저장된 데이터를 조정하고 새로운 "필드"를 추가 할 수 있습니다.
  • 데이터는 애플리케이션이 필요로 하는 형식으로 저장됩니다. 이렇게 하면 데이터를 읽어오는 속도가 빨라집니다.
  • 수직 및 수평 확장이 가능하므로 데이터베이스가 애플리케이션에서 발생시키는 모든 읽기 / 쓰기 요청을 처리 할 수 있습니다.

단점

  • 유연성 때문에, 데이터 구조 결정을 하지 못하고 미루게 될 수 있습니다.
  • 일반적인 정보를 모두 포함한 데이터를 컬렉션에(SQL 처럼 하나의 테이블에 하나의 레코드가 아니라) 넣기 때문에 데이터 중복이 발생할 수 있습니다.
  • 데이터가 여러 컬렉션에 중복되어 있기 때문에, 수정(update)를 해야 하는 경우 모든 컬렉션에서 수행해야 합니다. (SQL에서는 중복된 데이터가 없기 때문에 한번만 수행하면 됩니다.)

SQL vs NoSQL의 선택

SQL과 NoSQL을 적당하게 선택하기 위해서는 어떤 데이터를 다루는지, 어떤 어플리케이션에 사용되는지를 고려해야 합니다.

[SQL]

  • 관계를 맺고 있는 데이터가 자주 변경(수정) 되는 애플리케이션일 경우
  • 변경될 여지가 없고, 명확한 스키마가 사용자와 데이터에게 중요한 경우

[NoSQL]

  • 정확한 데이터 구조를 알 수 없거나 변경 / 확장 될 수 있는 경우
  • 읽기(read)처리를 자주하지만, 데이터를 자주 변경(update)하지 않는 경우 (즉, 한번의 변경으로 수십 개의 문서를 업데이트 할 필요가 없는 경우)
  • 데이터베이스를 수평으로 확장해야 하는 경우 ( 즉, 막대한 양의 데이터를 다뤄야 하는 경우)

JOIN

JOIN

여러 테이블을 하나의 테이블처럼 논리적으로 연결하여 사용하는 방법

일반적으로, 둘 이상 테이블에서 데이터가 필요한 경우 JOIN을 시도한다.

DataBase SQL JOINS

JOIN과 집합 연산자의 차이

  • 집합 연산자 : 두 개 이상의 SELECT문의 결과 값을 세로로 연결한 것
  • JOIN : 두 개 이상의 테이블 데이터를 가로로 연결한 것

EQUI JOIN

두 테이블의 특정 열의 값들이 정확하게 일치할 때 이를 기준으로 데이터를 연결하는 방법

  • Default Join 방법
  • 일반적으로 PK, FK 관계에 의해 JOIN을 시도하지만 일반 컬럼을 기준으로 JOIN을 시도하는 것도 가능
  • JOIN하려는 두개 이상 테이블이 동일한 이름의 컬럼을 사용한다면 SELECT 시 반드시 테이블 명을 밝혀야함
  • N개의 테이블을 JOIN할 경우 최소 N-1개의 JOIN 조건이 필요함

SQL 형태

Q. 회사 직원들의 사번, 이름과 함께 해당 직원들이 속한 부서번호와 부서명을 조회한다.

  • 직원 테이블의 직원이 속한 부서번호 정보가 담긴 열과 부서 테이블의 부서번호 열을 기준으로 JOIN하면 두 테이블의 부서번호가 동일할 때 직원 정보와 각 부서명을 바로 연결할 수 있다.
SELECT e.empno, e.ename, e.deptno, d.dname
FROM emp e, dept d
WHERE e.deptno = d.deptno;
  • 위의 쿼리문과 같이 = 연산자를 이용하여 완전히 일치하는 특정 열을 기준으로 조인
  • JOIN 조건을 ON 절이나 WHERE 절에 부여할 수 있음
    • 단, OUTER JOIN을 시도할 경우 여러 조건과 함께 JOIN 조건을 ON 절로 부여하는 것과 WHERE 절로 부여할 때 결과가 달라지므로 주의

INNER JOIN

  • EQUI JOIN과 함께 Default Join 방법으로 사용됨
  • SQL 형태는 EQUI JOIN과 같음

INNER JOIN과 EQUI JOIN

  • EQUI JOIN : = 연산자를 이용하여 ON 절이나 WHERE 절에 조건을 부여하여 JOIN하는 방법
  • INNER JOIN : JOIN 조건을 만족하는 행에 대해서만 결과 값이 나오는 JOIN

INNER JOIN과 OUTER JOIN

  • INNER JOIN : 두 테이블의 교집합으로 JOIN하는 개념
  • OUTER JOIN : 두 테이블의 합집합으로 JOIN하는 개념
for x in A : 
    for x in B : 
        if (A.x = B.x) JOIN

위와 같이 A 테이블과 B 테이블의 x 열을 기준으로 JOIN을 시도하는 경우

INNER JOIN은 아무리 A 테이블에 x 열이 존재한다고 하더라도 B 테이블의 x열의 값과 같지 않다면 그 값은 결과에 포함되지 않는다. 따라서 내부 for문에 해당하는 B 테이블 조건이 결과의 핵심이다.

반면 OUTER JOIN은 A 테이블과 B 테이블의 각각의 x 열의 값이 같지 않다고 할지라도 외부 for문에 해당하는 A 테이블에 값이 존재한다면 결과에 포함시킨다. 따라서 외부 for문에 해당하는 A 테이블 조건이 결과의 핵심이다. 만약 외부 for문에 B 테이블을, 내부 for문에 A 테이블을 조건으로 둔다면 결과는 위 경우와 반대로 외부 for문에 해당하는 B 테이블에 존재하는 값을 기준으로 결과에 포함시킨다.


NON-EQUI JOIN

한 테이블의 열의 값이 다른 테이블 열의 값과 정확히 일치하지 않지만 JOIN을 시도해야하는 경우 사용하는 방법

  • EQUI JOIN과 달리 = 연산자는 사용할 수 없고(값이 정확히 일치하지 않음), Between, >, >=, <, <= 등 다른 연산자를 이용해 JOIN 조건을 부여해야 함.

SQL 형태

Q. 회사 직원들의 사번, 이름과 함께 해당 직원들의 급여 등급을 조회한다.

  • 직원 테이블의 직원 급여 열과 급여등급 테이블의 급여 하한선과 상한선 열들을 비교하여 JOIN하면 직원의 급여와 급여가 속하는 범위의 등급을 연결할 수 있다.
SELECT e.empno, e.ename, e.salary, s.grade
FROM emp e, salgrade s
WHERE e.salary BETWEEN s.losal AND s.hisal;
  • 위의 쿼리문과 같이 =가 아닌 다른 연산자를 이용하여 연산자 결과 범위에 포함되는 값을 결과로 도출

OUTER JOIN

JOIN 조건에 만족하지 않는 레코드를 결과에 포함하고자 할 때 사용하는 JOIN 방법

어느 한쪽 테이블에는 데이터가 존재하는데 다른쪽 테이블에는 해당 데이터가 존재하지 않는 경우 INNER JOIN을 한다면 검색 결과에 누락이 발생할 수 있음

따라서 합집합을 구하고자 할 때 사용할 수 있음

OUTER JOIN 방법

  • LEFT [OUTER] JOIN : JOIN 키워드의 왼쪽 테이블을 기준으로 해당 테이블에 속한 데이터는 모두 포함
  • RIGHT [OUTER] JOIN : JOIN 키워드의 오른쪽 테이블을 기준으로 해당 테이블에 속한 데이터는 모두 포함
  • FULL [OUTER] JOIN : JOIN 키워드의 양쪽 테이블을 기준으로 모든 테이블에 속한 데이터를 포함

SQL 형태

Q. 회사 모든 직원들의 사번, 이름과 함께 해당 직원들의 부서명을 조회한다. 단, 아직 부서를 배치받지 못한 직원까지 포함하여 회사의 모든 직원들이 조회되어야 한다.

  • 직원 테이블의 부서 번호 열과 부서 테이블의 부서 번호 열의 값이 일치하는 경우를 JOIN한다. 단, 직원 누락이 발생하면 안되므로 직원 테이블의 모든 데이터가 포함되는 OUTER JOIN을 한다.
SELECT e.employee_id, e.last_name, d.department_name
FROM employees e LEFT JOIN departments d
ON e.department_id = d.department_id;

outer join

  • 모든 데이터가 포함되어야하는 테이블의 위치에 따라 LEFT와 RIGHT 조건을 부여하여 사용한다.

SELF JOIN

한 테이블의 열을 같은 테이블 내 다른 열과 연결하는 방법으로, 같은 테이블을 2개 이상의 테이블인 것처럼 사용할 수 있음

  • 주로 데이터 간 계층형 관계를 표현할 수 있을 때 사용
    • ex) 선후배 관계, 상사와 부하 직원 관계, 게시글과 답글 관계 등
  • FROM 절 뒤에 동일한 테이블 명을 2번 표현하되, 둘을 구분하기 위해 반드시 별칭을 기재해야 함
  • 컬럼 역시 어떤 기준 테이블인지 테이블 별칭에 연결하여 표현해야 함

SQL 형태

Q. 회사 직원들의 이름과 함께 자신의 상사 번호, 상사 이름, 상사의 직원 번호를 조회한다.

  • 직원 테이블의 상사 번호 열과 직원 테이블의 직원 번호 열의 값이 동일한 경우를 JOIN한다.
SELECT e1.last_name, e1.manager_id, e2.last_name, e2.employee_id
FROM employees e1, employees e2
WHERE e1.manager_id = e2.employee_id;

self join

  • 동일하지만 서로 다른 목적을 위해 중복된 두 테이블을 구분해야 함. 따라서 별칭을 꼭 지어줘야 함.

Cartesian JOIN(Cross JOIN)

  • JOIN 조건의 오류로 인해 한 테이블에 있는 모든 레코드가 다른 테이블의 레코드와 JOIN이 되는 경우
  • A 테이블 레코드 수가 a개, A 테이블 레코드 수가 b개라면 카티시안 조인 결과 a*b개의 결과가 도출됨.
  • 서로 관계가 없는 레코드도 함께 묶여 결과로 출력되므로 필요없는 결과가 중복되거나 지나치게 많은 결과가 출력될 수 있음

SQL 예제

Q. 도시명과 나라명을 조회한다.

  • 두 테이블의 JOIN 기준 없이 JOIN하므로 레코드의 컬럼 간 의미가 없는 레코드가 저장됨.
SELECT country_name, city
FROM countries, locations;

cartesian join

cartesian join rows

  • 위의 결과 23개의 city 레코드 수와 25개의 country_name 레코드 수가 곱해져 총 575개의 결과가 도출됨.
  • 의도적으로 위와 같은 결과를 도출하려는 것이 아니라면, WHERE절 혹은 ON 절에 JOIN 조건을 명시해야함.

Q. 각 나라 별 도시를 조회한다.

SELECT c.country_name, l.city
FROM countries c, locations l
WHERE c.country_id = l.country_id;

cartesian join sol1

cartesian join sol1

Q. 모든 나라의 도시를 조회한다.

SELECT c.country_name, l.city
FROM locations l RIGHT JOIN countries c
ON c.country_id = l.country_id;

cartesian join sol2

cartesian join sol2


ANSI JOIN

미국 국립 표준 협회(American National Standards Institute,ANSI)에서 지정한 SQL 문법

NATURAL JOIN

두 테이블의 JOIN할 열들이 완전히 동일한 필드명(컬럼명)을 가질 경우 해당 열들을 JOIN

  • 단, 두 테이블의 열이 같은 필드명을 가지고 있다고 할지라도, 서로 다른 데이터를 포함한 채로 필드명만 동일할 수도 있으므로 주의하여 사용해야 한다.

SQL 형태

Q. 회사 직원 별 직무를 조회한다.

  • 직원 테이블의 직무 번호와 직무 테이블의 직무 번호가 일치하는 경우 JOIN
SELECT last_name, job_title
FROM employees NATURAL JOIN jobs;

JOIN ~ USING

두 테이블의 JOIN 기준 열을 USING에 명시하여 JOIN하는 방법

SQL 형태

Q. 회사 직원 별 직무를 조회한다.

  • 직원 테이블의 직무 번호와 직무 테이블의 직무 번호가 일치하는 경우 JOIN
SELECT last_name, job_title
FROM employees JOIN jobs
USING(job_id);

JOIN ~ ON

두 테이블 간 공통된 이름의 열이 존재하지 않거나, 일반 쿼리 조건인 WHERE 절과 구분하기 위해 ON절에 기준을 명시하여 JOIN하는 방법

OUTER JOIN에서의 WHERE과 ON

Q. IT 부서에서 일하는 회사 직원들의 이름을 조회한다.

  • 직원 테이블의 부서 번호 열과 부서 테이블의 부서 번호 열의 값이 일치하는 경우를 JOIN한다. 단, 직원 누락이 발생하면 안되므로 직원 테이블의 모든 데이터가 포함되는 OUTER JOIN을 한다.
SELECT e.last_name, d.department_name
FROM employees e LEFT JOIN departments d
ON e.department_id = d.department_id
WHERE d.department_name = 'IT';

outer join on where

  • 위의 쿼리 결과 WHERE절로 IT부서 직원들을 필터링 한 뒤, JOIN을 한다.
  • 따라서 IT부서 직원들이 아니라면 결과에 포함되지 않는다.
SELECT e.last_name, d.department_name
FROM employees e LEFT JOIN departments d
ON e.department_id = d.department_id
AND d.department_name = 'IT';

outer join on and

  • 위의 쿼리 결과 ON절을 통째로 기준으로 삼아 JOIN을 한다.
  • 따라서 바깥 for문에 해당하는 employees 테이블의 데이터가 안쪽 for문에 해당하는 departments 테이블의 데이터를 하나씩 확인하며 부서 번호가 동일하고 IT부서라면 JOIN으로 관계를 표현하지만, 그렇지 않을 경우에도 employees 테이블에 속한 데이터면 결과에 포함한다.

OUTER JOIN 시 ONWHERE 사용에 따라 다른 결과가 나올 수 있으므로 주의해야 한다. 만약 필터링으로 결과 데이터를 축소해야 한다면, 1번 예시처럼 JOIN 조건은 ON에, 필터링 조건은 WHERE에 표현하는 것이 좋다.

문자열

문자열

문장으로 된 자료형을 이야기하고 String으로 나타낸다.

문자열을 생성하는 방법은 아래의 두 가지이다.

String a = new String("Happy Java");
String b = "Happy Java";

이때, 두 개의 문자열 객체의 메모리에서의 형태는 아래와 같다.

image

String Constant Pool

Heap 영역 내부에서 String 객체를 위해 별도로 관리하는 저장소

new 연산자가 아닌 리터럴("")로 String 객체를 생성하면 다음과 같은 동작이 일어난다.

  1. JVM은 String Constant Pool에서 생성하려고 하는 값과 같은 값을 가진 String 객체를 찾는다.
  2. 값을 찾으면 그 객체의 주소 값을 반환해서 String 객체가 해당 주소 값을 참조하도록 한다.
  3. 값을 찾지 못하면 String Constant Pool에 String 객체를 생성하고 그 주소 값을 참조하도록 한다.

예시)
(1) 리터럴("")로 String 객체를 생성하면 String Constant Pool에 가서 확인하고 없으면 생성한다.
image

(2) 있으면 String Constant Pool에 있는 해당 값의 주소 값을 String 객체가 참조하도록 한다.
image

intern() 메소드

리터럴("")로 String 객체를 생성하면 String의 intern() 메소드가 호출된다.

    /**
     * Returns a canonical representation for the string object.
     * <p>
     * A pool of strings, initially empty, is maintained privately by the
     * class {@code String}.
     * <p>
     * When the intern method is invoked, if the pool already contains a
     * string equal to this {@code String} object as determined by
     * the {@link #equals(Object)} method, then the string from the pool is
     * returned. Otherwise, this {@code String} object is added to the
     * pool and a reference to this {@code String} object is returned.
     * <p>
     * It follows that for any two strings {@code s} and {@code t},
     * {@code s.intern() == t.intern()} is {@code true}
     * if and only if {@code s.equals(t)} is {@code true}.
     * <p>
     * All literal strings and string-valued constant expressions are
     * interned. String literals are defined in section 3.10.5 of the
     * <cite>The Java&trade; Language Specification</cite>.
     *
     * @return  a string that has the same contents as this string, but is
     *          guaranteed to be from a pool of unique strings.
     */
    public native String intern();

intern() 함수에 대한 설명을 보면 문자열 개체에 대한 표준 표현을 반환한다고 되어있다.

pool을 체크해서 같은 값을 가지는 String 객체가 있으면 반환하고, 같은 값을 가지는 String 객체가 없으면 pool에 생성해서 반환한다.

String 함수

  1. length
  • int length()

문자열의 길이를 반환한다.

String str = new String("");
System.out.println(str.length()); // str 길이 : 0
		
str = "absc";
System.out.println(str.length()); // str 길이 : 4

str = null;
System.out.println(str.length()); // java.lang.NullPointerException
  1. compareTo
  • int compareTo(String anotherString)
    사전 순으로 문자열의 대소를 비교한다.

  • int compareToIgnoreCase(String str)
    대소문자를 무시하고 사전 순으로 비교한다.

String happy = "Happy";
String java = "Java";
		
System.out.println(happy.compareTo(java));
// -2, 이유: happy.charAt(0) - java.charAt(0)
		
String happylife = "HappyLife";
System.out.println(happy.compareTo(happylife));
// -4, 이유: happy.length() - happylife.length();
// index에서 차이가 나지 않으면 length를 통해서 비교한다.
		
String hello = "HELLO";
System.out.println(hello.compareToIgnoreCase(happy));
// 4, 이유: hello.charAt(1) - happy.charAt(1);
  1. charAt
  • char charAt(int index)
    문자열의 index에 해당하는 문자가 반환된다.
String str = "Happy Java Hello World";

char strAt6 = str.charAt(6);
// J
  1. getChars
  • void getChars(int srcBegin, int srcEnd, char[] dst, int dstBegin)

문자열을 문자 배열로 복사한다.

String str = "Happy Java Hello World Happy";
		
char[] get_chars = new char[str.length()];
		
str.getChars(0, str.length(), get_chars, 0);
// [H, a, p, p, y,  , J, a, v, a,  , H, e, l, l, o,  , W, o, r, l, d,  , H, a, p, p, y]
  1. indexOf
    해당 문자열이 위치하는 인덱스를 반환한다.
  • int indexOf(int ch)

  • int indexOf(int ch, int fromIndex)
    fromIndex에서부터 문자열 끝까지 중에서 해당 문자열이 위치하는 인덱스를 반환한다.

  • int indexOf(String str)

  • int indexOf(String str, int fromIndex)

String str = "Happy Java Hello World Happy";
		
int index_of_str_W = str.indexOf('W');
		
// index_of_str_W: 18
		
int index_of_str_W_from_18 = str.indexOf('W', 18);
		
// index_of_str_W_from_18: -1

int index_of_str_Java = str.indexOf("Java");
		
// index_of_str_Java: 6

int index_of_str_Java_from_7 = str.indexOf("Java", 7);
			
// index_of_str_Java_from_7: -1
  1. replace
    해당 문자를 찾아 입력받은 문자로 변경한다.
  • String replace(char old, char new)

  • String replace(CharSequence old, CharSequence new)
    (CharSequence는 인터페이스이기 때문에 이것을 구현하는 String, StringBuffer, StringBuilder를 사용하면 된다.)

String str = "Happy Cava Hello World";

String str1 = str.replace('C', 'J');
//Happy Java Hello World

String str2 = str.replace("Cava", "Cpp");
// Happy Cpp Hello World
  1. substring
    해당 문자열의 인덱스부터 끝까지(endIndex가 주어졌다면 endIndex - 1까지)의 부분 문자열을 반환한다.
  • String substring(int beginIndex)

  • String substring(int beginIndex, int endIndex)

String str = "Happy Java Hello World";

String substring_str = str.substring(6);
// Java Hello World
substring_str = str.substring(6, 10);
// Java
  1. concat
  • String concat(String str)
    문자열의 맨 끝에 str을 연결한다.
String str = "Happy Java Hello";
		
String concat_str = str.concat(" World");
// Happy Java Hello World
  1. split
    주어진 regex(표현식)으로 분리한다. limit은 분리되어지는 개수에 제한을 둔 것이다.
  • String[] split(String regex)

  • String[] split(String regex, int limit)

String str = "Happy Java Hello World";
		
String[] split = str.split(" ");
// [Happy, Java, Hello, World]

split = str.split(" ", 3);
// [Happy, Java, Hello World] 분리되어지는 개수를 limit 만큼으로 제한한다.

String, StringBuffer, StringBuilder

클래스 속성 특징
String 불변(immutable), 동기화 O
  • 불변성으로 인해 문자열 추가, 수정, 삭제 등의 연산에서 불리하게 작용한다.
StringBuffer 가변(mutable). 동기화 O
  • 가변적이기 때문에 문자열 추가, 수정, 삭제 연산 시 사용된다.
  • 동기화를 지원해서 멀티쓰레드 환경에서 안전하다. (thread-safe)
  • StringBuilder에 비해 성능이 좋지 않다.
StringBuilder 가변(mutable), 동기화 X
  • 가변적이기 때문에 문자열 추가, 수정, 삭제 연산 시 사용된다.
  • 동기화를 지원하지 않기 때문에 멀티쓰레드 환경에서 사용하는 것은 적합하지 않다.
  • 단일쓰레드에서의 성능은 StringBuffer보다 좋다.

Interrupt 정리했습니다.

인터럽트 (Interrupt)

인터럽트란 프로세서가 프로그램을 실행하고 있을 때, 입출력 장치나 예외상황의 핸들링이 필요할 경우 프로세서에서 실행 중인 프로그램을 멈추고 상황을 처리하도록 하는 명령입니다.

주요 인터럽트로는 CPU 선점형 스케쥴러의 타이머 인터럽트, 입출력 인터럽트, 1/0 연산 인터럽트(Divide-by-Zero Interrupt)과 등이 있습니다.

인터럽트 종류

  • 하드웨어 인터럽트(=외부 인터럽트)

    • 입출력장치, 타이밍 장치, 전원 등 외부적인 요인에 의해 발생합니다.
    • ISR 종료 후 대기합니다.
  • 소프트웨어 인터럽트(=내부 인터럽트)

    • 소프트웨어가 OS 서비스를 요청하거나 에러를 일으켰을 때 발생합니다.

    • 파일 읽기/쓰기, 0으로 나누기, Overflow 등이 있습니다.

    • ISR 종료 후 다시 프로그램으로 돌아거나 프로그램을 강제로 종료합니다.

    • 시스템 콜도 의도적으로 일으킨 예외 인터럽트입니다.

인터럽트 처리 과정

프로그램 실행 중 인터럽트가 발생하였다면,

  • CPU는 실행 중이던 명령어를 마치고 인터럽트 라인을 통해 인터럽트가 걸렸음을 인지합니다.

  • 인터럽트 벡터를 읽고 ISR 주소값을 얻어 **ISR(Interrupt Service Routine)**로 점프하여 루틴을 실행합니다.

    • 인터럽트 벡터는 인터럽트 발생시 처리해야 할 ISR의 주소를 인터럽트 별로 보관하고 있는 테이블입니다.

    • ISR은 인터럽트 핸들러라고도 하며 인터럽트를 처리하는 프로그램이며 OS에서 지원합니다. 인터럽트별로 처리해야할 내용이 있습니다.

  • ISR에서 동기화를 막기 위해 인터럽트를 금지합니다.

  • 프로세서는 현재까지 수행중이었던 상태를 해당 process의 **PCB(Process Control Block)**에 저장합니다.

  • PC(Program Counter, IP)에 다음에 실행할 명령의 주소를 저장합니다.

  • 해당 인터럽트를 처리합니다.

  • 다 처리하면, 대피시킨 프로세서의 상태를 복원합니다.

  • ISR의 끝에 IRET 명령어에 의해 인터럽트가 해제됩니다.

  • IRET 명령어가 실행되면, 대피시킨 PC 값을 복원하여 이전 실행 위치로 복원합니다.

인터럽트가 없다면?

입출력 연산은 프로세서의 명령 수행 연산보다 훨씬 느립니다.
예를 들어 프로세서가 입력 장치를 주기적으로 검사하며 신호를 기다린다면(Polling 방식) 그 때 마다 프로세서는 다른 작업을 수행할 수 없기 때문에 프로세서의 오버헤드가 증가하여 시간이 낭비될 것입니다.
하지만, 인터럽트가 입출력 장치의 처리 신호를 보내준다면 프로세서가 다른 작업을 하고 있다가 그 작업을 처리할 수 있게 됩니다.

애자일 파트 업로드합니다😄

애자일

애자일 이란?

  • ‘Agile = 기민한, 날렵한’ 이란 뜻으로 좋은 것을 빠르게 취하고, 낭비 없게 만드는 다양한 방법론을 통칭해 일컫는 말

  • 절차보다는 사람이 중심이 되어 변화에 유연하고 신속하게 적응하며, 효율적인 시스템을 개발하는 방법론

  • 반복적/점증적인 짧은 개발주기, 위험도를 감소시키고 고객의 요구사항 수용의 민첩성이 강조된 개발 방법론


애자일 방법론의 진행 과정

  • 애자일 방법론은 계획 → 설계(디자인) → 개발(발전) → 테스트 → 검토(피드백) 순으로 반복적으로 진행됩니다. 계획을 세운 후 다음 단계까지 기다려서 절차대로 진행하는 워터폴 모델과 달리 먼저 진행 후 분석, 시험, 피드백을 통하여 개선하여 나가는 진행 모델입니다.

  1. 계획 및 분석 : 고객과 사용자가 원하는 바를 파악하여 타당성을 조사하고 SW 기능과 제약조건을 정의하는 명세서 작성, 대상이 되는 문제 영역과 사용자가 원하는 task를 이해하는 단계
  2. 설계(디자인) : 기획의도에 맞는 설계 및 디자인 추가 및 수정하는 단계
  3. 개발(발전) : 설계단계에서 만들어진 설계서를 바탕으로 프로그램을 작성, 코딩, 디버깅, 단위/통합테스트 수행
  4. 테스트 : 발생 가능한 실행 프로그램 오류를 발견, 수정하는 단계
  5. 검토(피드백) : 기획의도를 파악하고 시험결과와 기획의 따라 수정할 부분을 제시하는 단계

애자일 방법론의 특징

  • 고객과 개발자의 지속적인 소통을 통하여 변화하는 요구사항을 신속하게 수용한다.
  • 개발자 개인의 가치보다는 팀의 목적을 우선시하며 고객의 의견을 가장 우선시한다.
  • 팀원들과의 주기적인 회의 및 제품 시현을 통한 방지를 점검한다.
  • 진행하면서 프로그램을 시행해보고 고객으로부터 피드백을 받는다.
  • 내부 구조 형성을 통한 비용절감에 힘쓰는 동시에 프로그램 품질 향상을 위해 노력한다.

애자일 방법론의 장/단점

장점 👍

  • 프로젝트 계획에 걸리는 시간을 최소화할 수 있다.
  • 점진적으로 테스트할 수 있어서 버그를 쉽고 빠르게 발견할 수 있다.
  • 계획 혹은 기능에 대한 수정과 변경에 유연하다.
  • 고객 요구사항에 대한 즉각적인 피드백에 유연하며 프로토타입 모델을 빠르게 출시할 수 있다.
  • 빠듯한 기한의 프로젝트를 빠르게 출시할 수 있다.

단점 👎

  • 확정되지 않은 계획 및 요구사항으로 인한 반복적인 유지보수 작업이 많다.
  • 고객의 요구사항 및 계획이 크게 변경될 경우 모델이 무너질 수 있다.
  • 개인이 아닌 팀이 중심이 되다 보니 공통으로 해야 할 작업들이 많을 수 있다. (회의, 로그 등)
  • 반복적인 업무로 속도는 빠를 수 있으나 미흡한 기능들에 대한 대처가 필요하다.
  • 확정되지 않은 계획으로 개발 진행 시 이해하지 못하고 진행하는 부분이 많을 수 있다.

애자일 방법론의 종류

익스트림 프로그래밍(Extreme Programming, XP)

  • 문서를 강조하지 않고 테스트를 우선하는 개발 방식, 개발 초기부터 테스트를 병행하는 개발 방법론
  • 고객과 함께 2주 정도의 반복개발
  • 의사소통, 피드백, 단순성, 용기, 존중 강조

  1. 유저스토리 : 사용자 요구사항 수집, 의사소통 도구
  2. 구조적 스파이크 : 설계상,기술상 잠재적 위험을 탐지하기 위한 간단한 프로그램, 기술적인 문제를 줄이고 유저 스토리 기반 개발일정에 대한 신뢰도 상승
  3. 릴리즈 계획 : 전체 프로젝트 배포계획 확립
  4. 주기 : XP핵심, 상황에 따른 릴리즈 및 계획수정
  5. 승인 테스트 : 릴리즈 전의 인수 테스트(블랙박스 테스트), 고객수행
  6. 작은 릴리즈 : 주기의 마지막 단계, 빠른 피드백

스크럼(Scrum)

  • 30일마다 동작 가능한 제품을 제공하는 스플린트를 중심, 팀을 중심으로 한 반복적이고 점진적인 개발 방법론
  • 제품 백로그를 바탕으로 기술적으로 분할되고 재해석된 스프린트를 통해 구현하는 개발 방법론
  • XP는 변화수용, 스크럼은 선감지 처리 관점

구성요소

  • Sprint : 반복주기 (Iteration, 30days)
  • Product Backlog : 제품의 요구사항 목록
  • Sprint Backlog : 해당 Sprint기간에 수행해야하는 Task 목록
  • Daily Scrum Meeting : 매일 15분 정도 짧게 진행하는 미팅 (계획, 실적, 위험 공유)
  • Review : Sprint 완료 시 고객 검토, Feedback을 받아 다음 Product Backlog에 적용
  • Retrospective Meeting : Scrum Team에서 운영중인 시험 리뷰 및 개선 미팅
  • Burn Down/Up Chart : 하나의 스프린트에 대한 소멸/완성 그래프

역할자

  • Product Owner : 요구사항을 정의하고 Product Backlog 업데이트
  • Scrum Team : Product Backlog 구현
  • Scrum Master : 제 3자 입장에서 Product Owner와 Scrum Team이 Scrum방법론을 제대로 진행 할 수 있도록 지원

따라서 Scrum을 적용하려면 조직의 역할/구성/감리/산출물 등 다양한 영역에서 변화를 주어야 함


KANBAN (칸반)

  • 연속적 흐름 처리 방식. 칸반 보드로 시각화되고 각각 단계는 열로 표시

구성요소

  • Kanban Board : 프로세스를 가진 Board와 스토리카드를 이용하여 업무 흐름제어
  • Process : 실제 업무가 이루어지는 단계 및 업무 수행을 통한 산출물 작성
  • Work Queue : 대기형렬, 개발 대기, 테스트 대기, 배포/릴리즈 대기 과정
  • Total Work Time (총 주기 시간) : 총 작업의 수행시간, 개별 업무의 Cycle Time의 합으로 구성

트랜잭션 격리수준

트랜잭션 격리수준

트랜잭션 격리수준(isolation level)이란?

트랜잭션의 네 가지 주요 성질인 원자성, 일관성, 고립성, 내구성 (ACID) 중 고립성(isolation) 을 구현하는 개념이다.

  • 고립성은 한 트랜잭션에서 데이터가 수정되는 과정이 다른 트랜잭션과는 독립적으로 진행되어야 한다는 특성이다.

트랜잭션 격리수준(isolation level) 이란 동시에 여러 트랜잭션이 처리될 때, 트랜잭션끼리 얼마나 서로 고립되어 있는지를 나타내는 것이다.

즉, 간단하게 말해 특정 트랜잭션이 다른 트랜잭션에 변경한 데이터를 볼 수 있도록 허용할지 말지를 결정하는 것이다.


격리수준은 크게 아래의 4개로 나뉜다.

  • READ UNCOMMITTED
  • READ COMMITTED
  • REPEATABLE READ
  • SERIALIZABLE

아래로 내려갈수록 트랜잭션 간 고립 정도가 높아지며, 성능이 떨어지는 것이 일반적이다.

일반적인 온라인 서비스에서는 READ COMMITTEDREPEATABLE READ 중 하나를 사용한다.

(oracle의 기본 격리수준 = READ COMMITTED, mysql의 기본 격리수준 = REPEATABLE READ)


트랜잭션 격리수준의 종류

READ UNCOMMITTED (레벨 0) - 커밋되지 않는 읽기

  • 각 트랜잭션에서의 변경 내용이 COMMIT이나 ROLLBACK 여부에 상관 없이 다른 트랜잭션에서 값을 읽을 수 있다.
  • 정합성에 문제가 많은 격리 수준이기 때문에 사용하지 않는 것을 권장한다.
  • 아래의 그림과 같이 Commit이 되지 않는 상태지만 Update된 값을 다른 트랜잭션에서 읽을 수 있다.

  • DIRTY READ 현상 발생
    • 트랜잭션이 작업이 완료되지 않았는데도 다른 트랜잭션에서 볼 수 있게 되는 현상

READ COMMITTED (레벨 1) - 커밋된 읽기

  • RDB에서 대부분 기본적으로 사용되고 있는 격리 수준이다.
  • Dirty Read와 같은 현상은 발생하지 않는다.
  • 실제 테이블 값을 가져오는 것이 아니라 Undo 영역에 백업된 레코드에서 값을 가져온다.


  • Non-repeatable read 현상 발생
    • 한 트랜잭션에서 같은 쿼리를 두 번 수행할 때 그 사이에 다른 트랜잭션 값을 수정 또는 삭제하면서 두 쿼리의 결과가 상이하게 나타나는 일관성이 깨진 현상


REPEATABLE READ (레벨 2) - 반복가능한 읽기

  • MySQL에서는 트랜잭션마다 트랜잭션 ID를 부여하여 트랜잭션 ID보다 작은 트랜잭션 번호에서 변경한 것만 읽게 된다.
  • Undo 공간에 백업해두고 실제 레코드 값을 변경한다.
  • 백업된 데이터는 불필요하다고 판단하는 시점에 주기적으로 삭제한다.
  • Undo에 백업된 레코드가 많아지면 MySQL 서버의 처리 성능이 떨어질 수 있다.
  • 이러한 변경방식은 MVCC(Multi Version Concurrency Control)라고 부른다.

  • PHANTOM READ 현상 발생
    • 다른 트랜잭션에서 수행한 변경 작업에 의해 레코드가 보였다가 안 보였다가 하는 현상
    • REPETABLE READ에 의하면 원래 출력되지 않아야 하는데 UPDATE 문의 영향을 받은 후 부터 출력된다.
    • 이를 방지하기 위해서는 쓰기 잠금을 걸어야 한다.


SERIALIZABLE (레벨 3) - 직렬화 가능

  • 가장 단순한 격리 수준이지만 가장 엄격한 격리 수준으로 완벽한 읽기 일관성 모드를 제공한다.
  • 다른 사용자는 트랜잭션 영역에 해당되는 데이터에 대한 수정 및 입력 불가능
  • 성능 측면에서는 동시 처리성능이 가장 낮다.
  • SERIALIZABLE에서는 PHANTOM READ가 발생하지 않는다. 하지만 데이터베이스에서 거의 사용되지 않는다.

낮은 격리수준을 활용했을 때 발생하는 현상들

이 현상들은 트랜잭션의 고립성과 데이터의 무결성의 지표로 사용된다.

  1. 더티 리드 (Dirty read) : 생성, 갱신, 혹은 삭제 중에 커밋 되지 않은 데이터 조회를 허용함으로써, 트랜잭션이 종료되면 더 이상 존재하지 않거나, 롤백되었거나, 저장 위치가 바뀌었을 수도 있는 데이터를 읽어들이는 현상이다.
  2. 반복 가능하지 않은 조회 (Non-repeatable read) : 한 트랜잭션 내에서 같은 행이 두 번 이상 조회됐는데 그 값이 다른 경우를 가리킨다. (A와 B가 마지막 남은 영화표를 예매하는데 A가 고민하는 중에 B가 표를 구매하여 A는 상반된 정보를 받게 되는 경우 등)
  3. 팬텀 리드 (Phantom read) : 한 트랜잭션 내에서 같은 쿼리문이 실행되었음에도 불구하고 조회 결과가 다른 경우를 뜻한다.

OS 가상 메모리 정리입니다~!

가상 메모리(Virtual Momory)

현재 실행중인 코드는 반드시 물리 메모리에 존재해야한다고 생각할 수 있습니다. 하지만 다음과 같은 상황을 생각해봅시다.

  • 프로그램에서 가끔 발생하는 예외를 처리하기 위한 코드
  • 크기가 제한된 형태인 배열(array)을 이용하기 위해 필요한 크기보다 훨씬 큰 크기로 선언한 경우
  • 정말 가끔 사용하는 옵션
  • 위와 같이 당장 필요하지 않은 코드들

위와 같은 경우를 고려했을 때 모든 코드를 늘 물리 메모리에 올려놓을 필요는 없으며, 그렇게 한다고 할지라도 물리 메모리 크기는 제한적이기 때문에 불가능할 수도 있습니다.

이를 해결하기 위해 가상 메모리를 사용합니다. 가상 메모리는 실제의 물리 메모리 개념과 사용자의 논리 메모리 개념을 분리한 것입니다. 프로그램 중 당장 필요한 일부만을 물리 메모리에 올려놓고 실행하는 것이죠.

이점

  • 프로그램은 물리 메모리 크기에 제약받지 않을 수 있습니다. 프로그래머들은 매우 큰 가상 주소 공간을 가정하고 프로그래밍을 할 수 있습니다.
  • 각 프로그램의 코드 중 일부만이 메모리를 차지하므로 더 많은 프로그램이 동시에 수행될 수 있습니다. 이는 응답시간의 감소와 CPU 이용률 및 처리율을 향상시킵니다.
  • 프로그램을 메모리에 올리고 스왑할 때 발생하는 입/출력 비용이 감소합니다
  • 페이지 공유를 통해 파일이나 메모리가 둘 이상의 프로세스들에 의해 공유되는 것이 가능합니다.

Virtual-Memory


요구 페이징

일반적인 가상 메모리는 보통 요구 페이징으로 구현합니다. 요구 페이징은 프로그램 실행 시 모든 부분을 메모리에 적재하는 것이 아니라, 실행 과정에서 페이지를 요구할 때마다, 즉 페이지들이 필요해질 때마다 적재하는 전략입니다.

요구 페이징은 스와핑과 개념이 유사합니다. 스와핑에서는 보조 메모리의 프로세스를 실행하고 싶을 때에 주 메모리로 읽어옵니다(swap in). 이와 같이 프로세스 내 페이지를 관리하는 페이저(pager)는 프로세스가 스왑 아웃(swap out)되기 전에 실제로 사용될 페이지가 어떤 것일지 추측하고, 이후에는 프로세스 전체를 스왑인 하는 대신 실제 필요한 페이지만 메모리로 읽어옵니다.

이를 위해서는 하드웨어 적인 기술이 필요한데, 유효/무효 비트를 사용하여 페이지가 메모리에 존재할 경우 유효(valid)하다고 표시하고, 페이지가 가상 주소 공간에 정의되지 않았거나 디스크에 존재할 경우 무효(invalid)하다고 표시하거나 페이지가 현재 저장된 디스크 주소를 기록합니다.

순수 요구 페이징

특정 페이지가 필요해 실제로 참조하기 전에는 절대 그 페이지를 메모리로 적재하지 않는 방법을 말합니다. 이를 통해 전체 프로세스가 주 메모리에 올라와있지 않아도 프로세스를 실행할 수 있습니다.

메커니즘은 다음과 같습니다.

  1. 실제로 참조하지 않는 페이지는 초기에 메모리에 올리지 않으므로 어떤 페이지도 메모리에 올라가 있지 않음
  2. 특정 페이지에 대한 참조가 발생
  3. 페이지 부재(page fault) 발생
  4. 운영체제는 내부 테이블을 통해 해당 페이지가 디스크 내부 어디에 위치했는지 파악
  5. 자유 프레임을 탐색하여 디스크로부터 해당 페이지를 읽어옴
  6. 페이지 테이블 내애 페이지에 변화가 생겼음을 표시
  7. 3에서 페이지 부재로 중지되었던 명령이 다시 실행됨

위에서 볼 수 있듯이 요구 페이징의 성능은 페이지 부재에 의해 좌우됩니다. 따라서 페이지 부재율을 낮게 유지하는 것이 중요하겠죠.


페이지 교체

요구 페이징에서 주요 관건은 페이지 교체와 프레임 할당 문제를 해결하는 것입니다. 디스크 입/출력 비용은 결코 만만하게 볼 수 없기때문이죠.

먼저 페이지 교체부터 봅시다. 실제 시스템이 보유한 페이지보다 더 많은 프로세스가 더 많은 페이지를 요구할 경우, 페이지 간 교체를 해야하는 문제가 발생합니다. 이렇게 희생될 페이지를 골라야 하는 순간에서 고려할 수 있는 페이지 교체 알고리즘에 대해 알아봅시다.


FIFO 페이지 교체(First-In-First-Out)

FIFO-page-replacement

  • 메모리에 올라온 페이지 중 가장 오래된 페이지를 교체하는 방법
  • 페이지가 올라온 시간을 기록하거나, 페이지가 올라온 순서대로 큐(queue)를 생성하여 관리
  • 장점 : 가장 직관적이고 단순한 알고리즘
  • 단점 :
    • 성능이 좋지 않음(페이지 교체 직후 바로 해당 페이지를 필요로 하는 경우 등)
    • 더 많은 프레임을 할당했음에도 불구하고 페이지 부재율이 높을 수 있는 Belady의 모순이 발생함

최적 페이지 교체(OPT, Optimal page replacement)

OPT-page-replacement

  • 앞으로 가장 오랜 동안 사용되지 않을 페이지를 찾아 교체하는 방법
  • 장점 : 가장 낮은 페이지 부재율을 보장하는 최적의 알고리즘
  • 단점 : 실제 구현이 거의 불가능하므로(미래의 프로세스 참조를 예측하기란 쉽지 않음) 비교 연구 목적으로 사용됨

LRU 페이지 교체(Least-Recently-Used)

LRU-page-replacement

  • 가장 오랜 기간 동안 사용되지 않은 페이지를 교체하는 방법
  • 최적 알고리즘의 근사 알고리즘으로 최근의 과거를 가까운 미래의 근사치로 생각함
  • 각 페이지마다 마지막 사용 시간을 기록하여 구현
    • 카운터(counter) : 각 페이지 별 사용 시간 필드와 카운터를 추가하여 메모리 접근 시마다 시간을 증가시킴. 페이지 테이블이 변경될 때마다 시간 값을 관리해야 함.
    • 스택(stack) : 페이지가 참조될 때마다 페이지 번홀르 스택 중간에서 빼내어 스택 top에 삽입. 스택의 꼭대기에는 항상 가장 최근에 사용된 페이지가, 스택 밑바닥에는 항상 가장 이전에 사용된 페이지가 위치함. 스택의 중간에서 꺼내고 다시 top으로 삽입하는 과정에서 오버헤드가 발생하긴 하지만 페이지 교체 시 페이지를 탐색할 필요 없이 top만 보면 됨.
  • 장점 : OPT와 근사하게 좋은 성능을 보장
  • 단점 : 카운터나 스택과 같은 구현을 위해 하드웨어 지원이 필요함

LRU 근사 페이지 교체(LRU Approximation)

하드웨어의 지원을 필요로하는 LRU 페이지 교체 알고리즘의 한계를 극복하기 위한 알고리즘입니다. 주로 페이지 별 참조 비트를 설정하여 사용합니다.

부가적 참조 비트 알고리즘

  • 일정한 간격마타 참조 비트를 기록함으로써 페이지 사용 선후 관계를 기록

이차 기회 알고리즘(클럭 알고리즘)

  • FIFO 교체 알고리즘을 기반으로 함.
  • 페이지를 순환 큐에 저장하고, 페이지가 선택될 때마다 참조 비트를 확인하여 값이 0인 경우에는 페이지를 교체하고 1인 경우에는 한번 더 기회를 줌
  • 자주 사용되는 페이지의 경우 교체될 가능성이 낮음

계수 기반 페이지 교체(Counting-Based)

LFU 알고리즘(Least-Frequently-Used)

  • 참조 횟수가 가장 적은 페이지를 교체하는 방법
  • 장점 : 자주 사용되는 페이지는 참조 횟수가 크므로 교체가 적을 수 있음
  • 단점
    • 한 프로세스의 초기에만 자주 사용된 페이지의 경우 이후에 더 이상 자주 사용되지 않는다고 할지라도 페이지가 교체되지 않아 오히려 페이지 부재가 발생할 수 있음
    • 이를 극복하기 위해 참조 횟수 비트를 일정 시간마다 오른쪽으로 shift 연산하여 지수적으로 영향력을 감소시키는 방법이 있음

MFU 알고리즘(Most-Frequently-Used)

  • 가장 적은 참조 횟수를 가진 페이지가 가장 최근에 사용된 페이지이며, 또 앞으로 사용될 것이라 판단하여 교체하지 않는 방법

프레임 할당

요구 페이징의 두번째 관건은 프레임 할당입니다. 여러 프로세스에게 각각 얼마나 많은 프레임을 할당해야할지 결정하는 문제이죠. 프레임 할당에 따라 페이지 부재율이 달라질 수 있기때문에 프레임 할당은 페이지 교체와 더불어 중요하게 작용합니다. 다양한 프레임 할당 알고리즘에 대해 소개합니다.

균등 할당

  • 모든 프로세스에게 프레임을 똑같이 할당
  • 가장 단순무식한 방법으로 작은 크기의 메모리를 요구하는 프로세스는 메모리를 낭비한다는 문제가 있음

비례 할당

  • 각 프로세스의 크기에 맞춰 비율대로 할당
  • 프로세스가 요구하는 크기를 고려할 수 있어 균등 할당에서 발생하는 문제를 어느정도 보완할 수 있음
  • 프로세스 크기 대신 프로세스의 우선순위를 기준으로 삼아 비율을 나눠 할당한다면 우선순위가 높은 프로세스에게 더 많은 메모리를 할당하여 프로세스를 빠르게 수행할 수 있음

전역 대 지역 할당

이제서야 밝히지만 프레임 할당에는 또 다른 중요한 요인이 있습니다. 바로 페이지 교체를 고려하는 것인데, 구체적으로 전역 교체지역 교체 라는 두 가지 범주로 나눌 수 있다는 것입니다.

  • 전역 교체 : 프로세스가 교체할 프레임을 다른 프로세스에 속한 프레임을 포함한 모든 프레임을 대상으로 찾음
    • 우선 순위가 높은 프로세스가 낮은 프로세스로부터 프레임을 빼앗아오는 경우
    • 다른 프로세스로부터 프레임을 할당받게 된다면 프로세스에게 할당된 페이지 수도 증가함
    • 일반적으로 지역 교체 알고리즘보다 우수한 성능을 자랑하여 일반적으로 사용됨
    • 하지만 프로세스의 페이지 부재율을 컨트롤하기 어렵다는 단점이 존재함(페이지 부재율은 다른 프로세스에게 영향을 받는데 전역 교체는 프로세스 간 프레임과 페이지가 교환되기 때문에)
  • 지역 교체 : 각 프로세스가 자신에게 할당된 프레임 내부에서만 교체될 희생자를 찾음
    • 프로세스 개별에게 할당된 프레임에는 변화가 없음
    • 페이지 부재율이 다른 프로세스에 의해 변하거나 조절하기 어려운 문제가 발생하지 않음
    • 다만, 전역 교체보다 성능이 좋지 않음

쓰레싱(Thrashing)

만약 한 프로세스가 프레임을 부족하게 할당받는다면 페이지 집합도 부족하게 할당받게 될 것이고, 이는 페이지 부재를 발생시킬 것입니다. 하지만 만약 다른 프로세스들도 모두 열심히 일하고 있는 상황이라 페이지를 내어줄 수 없다면 어떻게 될까요? 다른 프로세스로부터 억지로 페이지를 뺏어오더라도 바로 다시 해당 프로세스가 페이지 부재를 발생시켜 결국 페이지를 되돌려줘야하는 문제가 발생할 수 있습니다.

이와 같은 치열한 눈치싸움, 과도한 페이징 작업을 쓰레싱이라 합니다. 쓰레싱은 어떤 프로세스가 실제 실행보다 더 많은 시간을 페이징에 사용하고 있는 것을 말합니다. 쓰레싱이 발생하면 성능은 심각하게 저하됩니다.

쓰레싱은 전역 페이지 교체 알고리즘을 이용하여 다중 프로그래밍 정도를 높일 때 발생할 수 있습니다. 운영체제는 다중 프로그래밍 정도를 높이기 위해 CPU 이용률이 낮아지면 새로운 프로세스를 추가합니다. 이때 다른 프로세스의 프레임을 할당받는 전역 페이지 교체 알고리즘을 적용한다면 다른 프로세스도 프레임이 부족한 상황에서는 서로의 프레임을 자꾸 할당받으려 할 것입니다. 이 과정에서 CPU는 다시 놀게되고, 운영체제는 더 많은 프로세스를 추가시켜버리는 것이죠. 이러한 악순환이 반복되어 쓰레싱이 발생합니다.

쓰레싱은 지역 교환 알고리즘 혹은 우선순위 교환 알고리즘을 적용하여 막을 수 있지만, 좀 더 근본적인 해결책은 각 프로세스가 필요로 하는 최소한의 프레임 수를 보장하는 것입니다. 이를 위해서는 작업 집합 모델에 대해 이해해야 합니다.


작업 집합 모델(Working-Set Model)

작업 집합 모델은 프로세스가 필요로 하는 최소한의 프레임 수를 알 수 있는 방법으로, 쓰레싱을 조절하는 방법 중 하나입니다. 작업 집합 모델은 지역성 개념을 기반으로 합니다.

지역성 모델이란 프로세스가 실행될 때는 항상 어떤 특정 지역에서만 메모리를 집중적으로 참조한다는 개념입니다. 이에 따라 지역성은 집중적으로 함께 참조되는 페이지들의 집합을 의미합니다.

지역성을 고려하여 작업 집합을 구성하는 방법은 다음과 같습니다. 특정 프로세스가 최근 참조한 페이지들 중 Δ개의 페이지를 작업 집합으로 구성합니다. 이후 한 페이지가 더이상 사용되지 않는다면 해당 페이지의 마지막 참조로부터 Δ만큼의 새로운 페이지가 작업 집합으로 구성될 것이고, 사용되지 않는 페이지는 집합에서 제외됩니다.

작업 집합의 정확도에서 중요한 것은 Δ값, 즉 집합의 크기겠죠. Δ값이 너무 커지면 지역성을 과도하게 수용할 것이고, Δ값이 너무 작아지면 지역을 포함하지 못할 것입니다.

작업 집합을 설정하게 된다면 운영체제는 각 프로세스의 작업 집합을 통해 프로세스에게 맞는 크기의 프레임을 할당할 수 있습니다. 따라서 가능한 최대의 다중 프로그래밍 정도를 유지하면서도 쓰레싱을 방지할 수 있게됩니다.

하지만 작업 집합을 추적하는 것이 쉬운 일은 아닙니다. 여기서도 참조 비트를 활용하여 구현해야 합니다. 타이머 인터럽트를 발생시켜 현재 작업 집합의 참조 비트와 이전 단위의 작업 집합의 참조 비트를 비교하는 방식으로 구현할 수 있지만, 이런 방식은 정확도가 낮고 오버헤드가 크다는 문제가 있습니다.


페이지 부재 빈도(PFF, Page-Fault Frequency)

쓰레싱을 조절하는 또 다른 방법은 페이지 부재 빈도 모델입니다.

쓰레싱의 개념에 대해 다시 생각해봅시다. 쓰레싱이 발생했다는 것은 페이지 부재율이 높다는 것을 의미합니다. 페이지 부재율이 높으면 더 많은 프레임이 할당되어야하고, 페이지 부재율이 낮으면 너무 많은 프레임을 할당해 낭비가 될 수 있습니다.

따라서 페이지 부재율을 조절해야할텐데, 단순하게 페이지 부재율의 상한과 하한을 설정한다고 생각해봅시다. 페이지 부재율이 상한을 넘어가게 되면 해당 프로세스에게 더 많은 프레임을 할당해주면 됩니다. 또한 하한보다 낮아지게 된다면 해당 프로세스로부터 프레임을 빼앗아 다른 프로세스에게 할당해주면 됩니다.

페이지 부재 빈도 모델을 통해 직접적으로 페이지 부재율을 관리할 수 있고 쓰레싱도 막을 수 있습니다.

[Design Pattern] Abstract Factory 패턴

Abstract Factory

추상 팩토리 패턴(Abstract-Factory Pattern)이란 인터페이스를 이용하여 서로 연관된, 또는 의존하는 객체를 구현 클래스를 지정 하지 않고도 생성할 수 있는 패턴이다.

바로 위 팩토리 메소드 편에 보았던 JPStyleBrownShoes, FRStyleRedShoes와 같이 추상 클래스에 의존 하는 구현 클래스를 만들지 않고도 생성할 수 있다.

class DependentShoesStore {
 
    public Shoes makeShoes(String style, String name) {
        Shoes shoes = null;
        if (style.equals("Japan")) {
            if (name.equals("blackShoes")) shoes = new JPStyleBlackShoes();
            else if (name.equals("brownShoes")) shoes = new JPStyleBrownShoes();
            else if(name.equals("redShoes")) shoes = new JPStyleRedShoes();
        }
        else if(style.equals("france")) {  
            if (name.equals("blackShoes")) shoes = new FRStyleBlackShoes();
            else if (name.equals("brownShoes")) shoes = new FRStyleBrownShoes();
            else if(name.equals("redShoes")) shoes = new FRStyleRedShoes(); 
        }
        shoes.prepare();
        shoes.packing();
        return shoes;
    }

}

만약 위와 같이 스타일과 신발 이름을 입력받아 해당 신발을 제작하고 준비, 포장해서 돌려주는 클래스가 있다고 하자.

직전 팩토리 메소드 패턴을 정리할 때 이미 한번 생각해본 적이 있었는데,

지금 코드는 몇 줄 되지 않는데도 이와 같이 복잡하고 관리 하기 힘든 모습인데, 만약 나라가 수십개국에 신발종류도 각 나라마다 무수히 많다면 어떻게 될까??

그리고 나중에 이것들을 수정해야 할 일이 생긴다면 정말 생각만 해도 끔찍하다...

구두를 만드는 스토어 객체는 스토어 객체는 구두 객체들을 가지고 있으면서, 이 객체들을 사용해서 구두를 준비하고, 포장하게 된다.

이때 스토어 객체는 고수준 컴포넌트라고 하고, 구두 객체들을 저수준 컴포넌트라고 한다. 고수준 컴포넌트(스토어)는 저수준 컴포넌트(구두들)를 가지고 사용 할 수 있다.

그래서 위에 있는 다이어그램을 보면, 고수준의 컴포넌트가 저수준의 컴포넌트에 심하게 의존한다는 것을 볼 수 있다.

의존한다는 것은 나중에 새로운 구두가 추가 되면, 스토어 객체까지 손봐야 할 일이 생긴다는 의미라서 이 의존관계를 뒤집을 필요가 있다.

그래서 위 설계는 객체지향 설계 5대 원칙 SOLID중, 5번째 DIP를 따르는 설계가 필요하다.

DIP (Dependency-Inversion Principle) : 구상 클래스에 의존하도록 만들지 않고, 추상화 된 것에 의존하도록 만들어야 한다.

이 원칙을 제대로 적용하려면, 구현 클래스처럼 구체적인 것이 아니라 추상 클래스나 인터페이스같이 추상적인 것에 의존 하는 코드를 만들어서 고수준 컴포넌트와 저수준 컴포넌트 모두에 적용하여야 한다.

그래서 방금 말한 의존관계 역전 원칙을 구두 가게에 다시 적용해보자면 아래와 같은 UML처럼 설계가 가능하다.

이렇게 하면 고수준 컴포넌트 ShoesStore와 저수준 컴포넌트인 각종 구두 객체들 모두 추상클래스인 Shoes에 의존 하게 된다.

하지만 설계를 하다보면 의존관계 역전 원칙을 지키도록 설계하기가 쉽지 않다.

그래서 의존 관계 역전원칙을 지키는데 도움이 될만한 가이드 라인을 가져와봤다.

  1. 어떤 변수라도 구상 클래스에 대한 레퍼런스를 저장하지 말것
  • new 연산자 사용 하면 구상 클래스 레퍼런스를 저장하는 것, 이것 대신 팩토리를 사용하라!
  1. 구상 클래스에서 유도된 클래스를 만들지 말 것.
  • 구상 클래스에서 유도 된 클래스를 만들면 특정 구상 클래스에 의존 하게 된다.
  1. 베이스 클래스에 이미 구현되어 있던 메소드를 오버라이드 하지 말 것.
  • 이미 구현되어 있는 메소드를 오버라이드 하는 것은, 애초부터 베이스 클래스가 잘 추상화 되어 있는 것이 아니다!
  • 베이스 클래스에서 메소드를 정의 할때는 모든 서브클래스에서 공유할 수 있는 것들만 정의 해야 함.

하지만 위 가이드 라인들은 지향하면 좋다는 것이고, 꼭 지켜져야 하는 것은 아니라고 한다.

실제로 자바 프로그램 가운데 이것을 완벽하게 지키는 것은 거의 없다.

위와 같이 설계하는 것이 바람직하다는 것을 알고 넘어가면 좋을 것 같다.


다시 구두 가게로 돌아와서, 우려했던 대로 신발매장이 더 많은 나라에 진출해서 인도, 이태리, 중국 등등 많은 곳에 매장이 생겼다고 가정해보자.

일단 팩토리 메소드 패턴을 이용해 프레임워크를 잘 잡아 놓았기 때문에, 나라별로 같은 서비스를 제공 할 수는 있다.

하지만 몇몇 분점에서는 각 현지 공장에서 싸구려 재료들을 몰래 사용해서 본사에서 의도하지 않은 마진을 몰해 올리고 있다는 소식을 듣고 무언가 조치를 취하려고 한다.

그래서 본사에서 원재료를 사용해서 신발을 만들어서 분점으로 배송하려고 했는데 지난 팩토리 메소드를 정리한 부분에서 먼저 보았듯이, 같은 검은 구두라고 하더라도 일본매장의 검은 신발과 프랑스 매장의 검은 신발의 밑창은 서로 다르게 만들어야하고 따라서 재료들도 달라져서 문제가 생긴다.

그래서 다시 생각해낸 해결 방법이 지역 별로 소규모 신발재료 공장을 나누어서 신발을 만드는 것이다.

interface ShoesIngredientFactory {
 
    public Bottom makeBottom();
    
    public Leather makeLeather();
 
    public boolean hasPattern();
 
}

그래서 위와 같은 공통 기능을 제공할 신발재료 공장 인터페이스를 만들어 주었다.

JPShoesIngredientFactory.class

class JPShoesIngredientFactory implements ShoesIngredientFactory {
 
    @Override
    public Bottom makeBottom() return new RubberBottom();
 
    @Override
    public Leather makeLeather() return new LeatherOfCows();
    
    @Override
    public boolean hasPattern() return false;
 
}

FRShoesIngredientFactory.class

class FRShoesIngredientFactory implements ShoesIngredientFactory {
 
    @Override
    public Bottom makeBottom() return new PlasticAndRubberBottom();
 
    @Override
    public Leather makeLeather() return new LeatherOfSheeps();
 
    @Override
    public boolean hasPattern() return true;
 
}

이번에도 지난 팩토리 메소드때와 동일하게 일본과 프랑스를 기준으로 설명하려고 한다.

일본 매장으로 가는 신발재료 공장 클래스와 프랑스 매장으로 가는 신발재료 공장 클래스 처럼 재료 공장 인터페이스를 구현하는 클래스를 만들었다.

그리고 공장에서 각 메소드들이 return 해주는 각가의 신발 재료들이 구현해야하는 인터페이스는 아래와 같다.

interface Bottom {
 
    public String getName();
 
}
interface Leather {
 
    public String getName();
 
}

위 재료 인터페이스를 구현한 클래는 아래와 같다.

RubberBottom.class

// 고무 밑창
class RubberBottom implements Bottom {
 
    @Override
    public String getName() return "고무";
 
}

PlasticAndRubberBottom.class

// 플라스틱과 고무 혼한 밑창
class PlasticAndRubberBottom implements Bottom {
 
    @Override
    public String getName() return "플라스틱, 고무";
 
}

LeatherOfCows.class

// 소가죽
class LeatherOfCows implements Leather {
 
    @Override
    public String getName() return "소죽";
 
}

LeatherOfSheeps.class

// 양가죽
class LeatherOfSheeps implements Leather {
 
    @Override
    public String getName() return "양죽";
 
}

이제는 공장은 완성됐고, 공장에서 만드는 신발 클래스를 살펴보도록 하자.

Shoes.class

abstract class Shoes {
 
    String name;
    Bottom bottom;
    Leather leather;
    boolean hasPattern;
 
    abstract void assembling(); // 신발을 조립하는 추상 메소드
 
    void prepare() {
        System.out.println("완성된 신발을 준비 중입니다.");
    }
 
    void packing() {
        System.out.println("준비된 신발을 포장 중입니다.");
    }
 
    public String getName(){
        return name;
    }
 
    public void setName(String name) {
        this.name = name;
    }
 
}

위 Shoes 클래스에서 주목할 점은, 원재료 들을 조립하는 assembling 이라는 추상 메소드이다.

abstract class Shoes {
 
    String name;
    String bottom;
    String leather;
    boolean hasPattern;
 
    void prepare() {
        System.out.println("주문하신 신발을 준비중입니다.");
    }
 
    void packing() {
        System.out.println("준비 완료된 신발을 포장중입니다.");
    }
 
    public String getName() {
        return name;
    }
 
}

지난번 팩토리 메소드에서 사용했던 Shoes.class에서는 존재하지 않는 메소드이다.

BlackShoes.class

class BlackShoes extends Shoes {
 
    ShoesIngredientFactory shoesIngredientFactory;
 
    public BlackShoes(factory_abstract_factory.ShoesIngredientFactory shoesIngredientFactory) {
        this.shoesIngredientFactory = shoesIngredientFactory;
    }
 
    @Override
    void assembling() { 
        System.out.println("신발을 제작중입니다. " + name);
        leather = shoesIngredientFactory.makeLeather();
        bottom = shoesIngredientFactory.makeBottom();
        System.out.println("신발 정보 : 밑창은 " + bottom.getName() + " 사용 하였으며, 가죽은 " + leather.getName() + " 사용하였습니다.");
    }
 
}

BrownShoes.class

class BrownShoes extends Shoes {
 
    ShoesIngredientFactory shoesIngredientFactory;
 
    public BrownShoes(factory_abstract_factory.ShoesIngredientFactory shoesIngredientFactory) {
        this.shoesIngredientFactory = shoesIngredientFactory;
    }
 
    @Override
    void assembling() {
        System.out.println("신발을 제작중입니다. " + name);
        leather = shoesIngredientFactory.makeLeather();
        bottom = shoesIngredientFactory.makeBottom();
        System.out.println("신발 정보 : 밑창은 " + bottom.getName() + " 사용 하였으며, 가죽은 " + leather.getName() + " 사용하였습니다.");
    }
 
}

RedShoes.class

class RedShoes extends Shoes {
 
    ShoesIngredientFactory shoesIngredientFactory;
 
    public RedShoes(factory_abstract_factory.ShoesIngredientFactory shoesIngredientFactory) {
        this.shoesIngredientFactory = shoesIngredientFactory;
    }
 
    @Override
    void assembling() { 
        System.out.println("신발을 제작중입니다. " + name);
        leather = shoesIngredientFactory.makeLeather();
        bottom = shoesIngredientFactory.makeBottom();
        System.out.println("신발 정보 : 밑창은 " + bottom.getName() + " 사용 하였으며, 가죽은 " + leather.getName() + " 사용하였습니다.");
    }
 
}

위 클래스들은 보다시피 Shoes 추상 클래스를 구현한 각 컬러들의 Shoes 클래스이다.

이제는 더 이상 각 국가별로 BlackShoes, BrownShoes, RedShoes 각각 다 만들어주지 않아도 된다.

이 클래스들은 ShoesIngredientFactory 인스턴스를 생성자로 받아서 이 인스턴스로부터 원재료를 직접 받게된다.

추상 메소드여서 오버라이딩하여 구현해준 assembling 메소드를 보면 가죽과 밑창을 각각 공장 인스턴스에서 받아 조립하고 있음을 볼 수 있다.

여기에서 주목할점은 Shoes 클래스는 그냥 공장에서 건네주는 재료로 신발을 조립만하기 때문에, 어떤 지역의 팩토리를 사용하든 Shoes 클래스는 언제든 재활용 할 수 있다는 것이다.


ShoesStore.class

abstract class ShoesStore {
 
    public Shoes orderShoes(String name) {
 
        Shoes shoes;
 
        shoes = makeShoes(name);
        shoes.assembling();
        shoes.prepare();
        shoes.packing();
 
        return shoes;
 
    }
 
    abstract Shoes makeShoes(String name);
 
}

고객에게 주문을 받을 수 있는 Store 클래스를 만들어 보았다.

각 나라의 스토어들은 이 Store 추상 클래스를 상속받아 추상 메소드인 makeShoes 메소드를 각 나라에 맞게 오버라이드하여 구현해주면 된다.

orderShoes는 전세계 공통 프레임워크이고, orderShoes안에 있는 makeShoes 단계만 각 나라의 특징에 맞게 바뀌는 것 뿐이다.

JPShoesStore.class

class JPShoesStore extends ShoesStore {
 
    @Override
    Shoes makeShoes(String name) { 
        Shoes shoes = null;
        ShoesIngredientFactory shoesIngredientFactory = new JPShoesIngredientFactory();
        
        if(name.equals("blackShoes")) {
            shoes = new BlackShoes(shoesIngredientFactory);
            shoes.setName("일본 스타일의 검은 신발");
        }
        else if(name.equals("brownShoes")) {
            shoes = new BrownShoes(shoesIngredientFactory);
            shoes.setName("일본 스타일의 갈색 신발");
        }
        else if (name.equals("redShoes")) {
            shoes = new RedShoes(shoesIngredientFactory);
            shoes.setName("일본 스타일의 빨간 신발");
        }
 
        return shoes;
    }
 
}

FRShoesStore.class

class FRShoesStore extends ShoesStore {
 
    @Override
    Shoes makeShoes(String name) { 
        Shoes shoes = null;
        ShoesIngredientFactory shoesIngredientFactory = new FRShoesIngredientFactory();
        
        if(name.equals("blackShoes")) {
            shoes = new BlackShoes(shoesIngredientFactory);
            shoes.setName("프랑스 스타일의 검은 신발");
        }
        else if(name.equals("brownShoes")) {
            shoes = new BrownShoes(shoesIngredientFactory);
            shoes.setName("프랑스 스타일의 갈색 신발");
        }
        else if(name.equals("redShoes")) {
            shoes = new RedShoes(shoesIngredientFactory);
            shoes.setName("프랑스 스타일의 빨간 신발");
        }
 
        return shoes;
    }
 
}

일본과 프랑스 신발 매장을 ShoesStore클래스를 상속받아 만들어 주었다.

각 매장은 makeShoes를 오버라이딩 하여 현지 상황에 맞게 재정의한다.

makeShoes내부 프로세스를 살펴보면, 먼저 매장으로 신발 주문이 들어오면 현지 공장 인스턴스를 생성한다.

그리고 매장에서 신발 재료 팩토리 인스턴스를 보내서 원재료 공장으로부터 재료들를 모두 받아오는 것이다.

makeShoes 메소드에서 재료를 공장에서 모두 받아왔다면, 이제는 매장에서 받은 재료를 가지고 조립하고, 준비, 포장하여 작업을 마무리 하는 것이다.

위 과정이 전세계 공통 orderShoes 프레임워크이다.

이제는 신발공장과 매장, 신발까지 모든 설계가 마무리 되었다.

실제 주문을 하는 과정을 살펴 보며 이번 추상 팩토리 패턴을 마무리하려고 한다.

Main.class

public class Main {
 
    public static void main(String[] args) {
 
        JPShoesStore jpStore = new JPShoesStore();
        jpStore.orderShoes("blackShoes");
 
        FRShoesStore frStore = new FRShoesStore();
        frStore.orderShoes("redShoes");
 
    }
 
}

위 주문 코드를 실행해보면 출력은 아래와 같을 것이다.

> 신발을 제작중입니다. 일본 스타일의 검은 신발
> 신발 정보 : 밑창은 고무 사용 하였으며, 가죽은 소가죽 사용하였습니다.
> 완성된 신발을 준비 중입니다.
> 준비된 신발을 포장 중입니다.
> 신발을 제작중입니다. 프랑스 스타일의 검은 신발
> 신발 정보 : 밑창은 플라스틱, 고무 사용 하였으며, 가죽은 양가죽 사용하였습니다.
> 완성된 신발을 준비 중입니다.
> 준비된 신발을 포장 중입니다.

일본 매장과 프랑스 매장으로 가서 신발를 주문을 한다고 하자.

  1. 먼저 주문하는 일본 매장에서 검은 신발을 주문하면, 매장에서는 주문을 받고 (orderShoes)

  2. 주문을 받은 직원은 일본 매장을 담당하는 원재료 공장에 해당 신발에 알맞는 재료를 요청한다. (makingShoes)

  3. 원재료 공장이 가동되고, 구두의 재료를 제작하여 보내준다.

  4. 매장에서 재료들을 받아서 조립을 해서 구두를 만든다.

  5. 준비, 포장을 해서 고객들에게 제공한다.

  6. 프랑스 매장도 마찬가지로 일본 매장과 완전히 동일한 프로세스를 거쳐 고객에게 신발을 제공한다.

마지막으로 위 이미지를 그동안 예시를 들었던 신발 매장 패턴을 대입하여 추상 팩토리 패턴을 이해하고 정리하면 좋을 것같다.

결과적으로, 추상 팩토리 패턴을 사용하면 DIP 원칙을 준수하게되어 객체들 간의 결합도 낮아져서 유지 보수가 아주 용이해진다.

BFS, Dijkstra 정리했습니다!

BFS

BFS(너비 우선 탐색)은 그래프를 탐색하는 방법 중 하나입니다.

정점들과 같은 레벨에 있는 노드(형제 노드)들을 먼저 탐색하는 방법으로 다음의 순서를 따릅니다.

  1. 현재 정점과 인접한 간선들을 하나씩 검사합니다.

  2. 현재 노드에서 방문할 수 있는 노드를 전부 방문합니다.

  3. 전부 방문한 후 그 다음 레벨의 노드를 방문합니다.

  4. 더 이상 방문할 곳이 없다면 탐색을 종료합니다.

그래프_표
graph_BFS

BFS 알고리즘 구현

queue와 배열을 이용해서 구현할 수 있습니다.

//code
static void bfs(Map graph,int start_node){
  Queue<Integer> need_visit = new LinkedList<>();
  Queue<Integer> visited = new LinkedList<>();
  need_visit.add(start_node);

  while(need_visit.isEmpty()==false){
    int node = need_visit.poll();
    if(!visited.contains(node)){
      visited.add(node);
      ArrayList<Integer> temp = (ArrayList<Integer>) graph.get(node);
      for(int data : temp) need_visit.add(data);
    }
  }
  for(int data : visited)System.out.print(data+" ");
}

시간복잡도

일반적으로 BFS의 시간복잡도는 정점의 수를 V, 간선의 수를 E라고 할 때 O(V+E) 입니다.

최단 경로 알고리즘

최단 경로 문제란 무엇일까요?

최단 경로 문제란 두 노드를 잇난 가장 짧은 경로를 찾는 문제입니다. 즉, 가중치 그래프에서 간선의 가중치의 합이 최소가 되도록하는 경로를 찾는 것입니다.

그렇다면 최단 경로 문제는 어떤 종류가 있을까요??

  1. 단일 출발 및 단일 도착 최단 경로 문제
  2. 단일 출발 최단 경로 문제
  3. 전체 쌍 최단 경로 문제

다익스트라

다익스트라 알고리즘은 최단 경로의 문제 종류 중 단일 출발 최단 경로 문제에 속합니다.

그 이유는 다익스트라 알고리즘이 하나의 정점에서 다른 모든 정점 간의 가장 짧은 거리를 구하는 알고리즘이기 때문입니다.

Basic idea

//init
S = {v0}; distance[v0] = 0; 
for (w  V - S에 속한다면)//V-S는 전체 정점에서 이미 방문한 정점을 뺀 차집합.
	if ((v0, w) E에 속한다면) distance[w] = cost(v0,w); 
	else distance[w] = inf;

//algorithm
S = {v0};
while VS != empty {
  //아래의 코드는 지금까지 최단 경로가 구해지지않은 정점 중 가장 가까운 거리를 반복한다는 의미
	u = min{distance[w], wV-S의 원소};//---1
	
  S = S U {u}; 
  VS = (VS)–{u};
	for(vertex w : VS)
		distance[w] = min{distance[w],distance[u]+cost(u,w)}//update distance[w];---2
}

위의 코드에서 1로 마킹한 부분에대한 설명을 하겠습니다.

먼저 length[v]는 최단 경로의 길이를 의미하고 distance[w]는 시작점에서부터의 최단 경로의 길이를 의미합니다.

S는 시작점을 포함해서 현재까지 최단 경로를 발견한 점들의 집합을 의미합니다.

Dijkstra_0

u = min{distance[w], w 는 V-S의 원소}의 의미는 u까지의 최단 거리의 길이는 distance[u] 와 같다는 의미입니다.

둘이 같다는 것은 다음처럼 증명할 수 있습니다.

Dijkstra_1

  1. distance[u] > length[u]라고 가정해보겠습니다.
  2. P를 시작점부터 u까지의 최단 경로라고 하면
  3. P의 길이 = length[u]입니다.
  4. P는 S에 속하지않는 최소 1개의 정점을 가집니다. 그렇지않다면 P의 길이 = distance[u]가 됩니다.
  5. P의 경로에 속하면서 S에 속하지않는 시작점에서 가장 가까운 점을 w라 하겠습니다.
  6. 그렇게 된다면 distance[u] > length[u] >= length[w] 가 성립하고, length[w] = distance[w]이기 때문에 이로부터
    **distance[u] > distance[w]**를 유추해낼 수 있습니다.
  7. 이는 다익스트라 알고리즘은 아직 최단경로가 찾아지지않은 정점들만 선택한다는 정의에 어긋납니다.(지금 보는 점보다 가까운 경로가 존재한다면 이전 탐색에서 걸러져서 S에 포함되어있기 때문입니다.)

그렇기 때문에 distance[u] = length[u]입니다.

다음으로는 distance[w]를 업데이트하는 부분입니다.

Dijkstra_2

위의 그림에서와 같이 시작점 v0에서 u를 거쳐 w로 가는 경로와 v0에서 w로 가는 경로의 길이 중 가까운 경로를 distance[w]로 설정하고 S와 V-S를 최신화해줍니다.

distance[ ]를 업데이트할 때 각각의 간선들이 2번씩 체크되는데 그 이유는 다음과 같습니다.

Dijkstra_3

아직 최단 경로를 찾지 못한 정점 중 u에 인접한 정점들의 거리를 업데이트 할 때 u와 w를 연결하는 간선이 체크가 됩니다.

Dijkstra_4

정점 w의 최단 경로가 찾아졌기 때문에, w는 S에 속하게됩니다. 아직 최단 경로를 찾기 못한 정점들에서 w까지의 거리를 업데이트하는 과정에서 u에서 w로의 간선이 다시 한 번 체크됩니다.

이런 방식으로 코드를 작성하면 각 정접들에대해서 u에 인접한 모든 간선을 살펴보는 연산이 추가되기 때문에 최악의 경우 시간복잡도가 O(𝑛^2)가 되버립니다. 이를 개선하는 방법으로는 대표적으로 우선순위 큐를 사용하는 방법(O( 𝑛 + 𝑚 log 𝑛))과 피보나치 힙을 사용하는 방법(O(𝑛 log 𝑛 + 𝑚))이 있습니다.

피보나치 힙에대한 내용은 피보나치 힙 을 확인해주시고, 지금은 우선순위 큐에대한 내용을 다루겠습니다.

다익스트라 알고리즘 로직(우선순위 큐)

  • 첫 번째 정점을 기준으로 연결되어있는 정점들을 추가해가면서 최단 거리를 갱신합니다.

    첫 정점부터 각 노드 사이의 거리를 저장하는 배열을 만든 후에 첫 정점의 인접 노드 간의 거리부터 먼저 계산하면서, 첫 정점부터 해당 노드 사이의 가장 짧은 거리를 해당 배열에 업데이트 합니다. 이런 로직은 현재의 정점에서 갈 수 있는 정점들부터 처리한다는 점에서 BFS와 비슷합니다.

    다양한 다익스트라 알고리즘이 있지만 가장 개선된 형태인 우선순위 큐를 사용하는 방식을 다뤄보겠습니다.

    먼저 우선 순위 큐를 간단하게 설명하면 MinHeap 방식을 사용해서 현재 가장 짧은 거리를 가진 노드 정보를 먼저 꺼냅니다.

    꺼낸 노드는 다음의 과정을 반복합니다.

    1. 첫 정점을 기준으로 배열을 선언핸 첫 정점에서 각 정점까지의 거리를 저장합니다.
      • 초기에는 첫 정점의 거리를 0, 나머지는 무한대(inf)로 저장합니다.
      • 우선순위 큐에 순서쌍 (첫 정점, 거리 0) 만 먼저 넣어줍니다.
    2. 우선순위 큐에서 노드를 꺼냅니다.
      • 처음에는 첫 정점만 저장된 상태이기때문에 첫번째 정점만 꺼내집니다.
      • 첫 정점에 인접한 노드들 각각에대해서 첫 정점에서 각 노드로 가는 거리와 현재 배열에 저장되어있는 첫 정점에서 각 정점까지의 거리를 비교합니다.
      • 배열에 저장되어 있는 거리보다, 첫 정점에서 해당 노드로 가는 거리가 더 짧은 경우, 배열에 해당 노드의 거리를 업데이트 합니다.
      • 배열에 해당 노드의 거리가 업데이트된 경우, 우선순위 큐에 해당 노드를 넣어줍니다.
    3. 2번의 과정을 우선순위 큐에서 꺼낼 노드가 없을 때까지 반복합니다.
  • 우선순위 큐를 사용하면 지금까지 발견된 가장 짧은 거리의 노드에대해서 먼저 계산을 해서 더 긴 거리로 계산된 루트에 대해서는 계산을 스킵할 수 있다는 장점이 있습니다.

pseudo 코드는 다음과 같습니다.

found[], distance[]를 초기화
construct min_heap(V-{s});
for(i = 0; i < n-2; i++) { //𝑛−1iterations(=Θ(𝑛))---(1) 
  distance[u] 최소인 정점 u를 선택합니다. //Θ(1) found[u] = T;로 바로 배열로 접근 가능
  min_heap에서 정점 u를 제거; //O(log𝑛)---(2)
  for(every vertex w adjacent to u) //Θ(𝑚)total---(a)
      if(found[w] == F && distance[u] + cost(u,w) < distance[w]){
        distance[w] = distance[u] + cost(u,w);
        adjust heap(w); //𝑂(log𝑛)foreachedgecheck---(b) 
  } // distance[w]가 수정됐기 때문에 heap을 조정해주는 for문
}

시간 복잡도

위의 pseudo code에서 다음 2가지 과정을 거칩니다.

(1),(a) - 각 정점마다 인접한 간선들을 모두 검사하는 과정 -> 𝑂(𝑛)

(2),(b) - 우선순위 큐에 정점/거리 정보를 넣고 삭제하는 과정 -> 𝑂(log𝑛)

따라서 전체 알고리즘은 O((𝑛+𝑚)log𝑛)입니다.

디자인 패턴 Factory Method, Adapter Pattern 정리했습니다

Factory Method

팩토리 메소드 패턴(Factory Method Pattern)이란 상위 클래스에 알려지지 않은 구체 클래스를 생성하는 패턴이다.

또한 하위 클래스가 어떤 객체를 생성할지 결정하도록 하는 패턴이기도 하다. 그리고 상위 클래스 코드에 구체적인 클래스 이름을 감추기 위한 방법으로도 사용한다.

팩토리는 공장이란 뜻이다. 따라서 팩토리 메소드 패턴도 무언가를 위한 공장이라고 보면 된다.

팩토리 메소드 패턴을 이해하기 위해서는 먼저 아래와 코드와 같은 신발 매장 예시를 살펴보자.

// 이름을 통해 신발을 주문 받음
Shoes orderShoes(String name) {
    // 해당 이름의 신발을 찾아서 특정 구상 객체 생성
    Shoes shoes;
    
    if (name.equals("blackShoes"))     shoes = new BlackShoes();
    else if (name.equals("brownShoes"))shoes = new BrownShoes();
    else if (name.equals("redShoes"))  shoes = new RedShoes();
    
    // 신발을 준비하고 포장하는 메소드
    // 모든 신발 공용 메소드
    shoes.prepare(); 
    shoes.packing(); 
 
    return shoes;
}

현재 이 신발 매장에는 3개의 신발만 팔고 있다. 그리고 앞으로 판매되는 제품이 늘어나거나 지금 있는 제품이 더 이상 판매 되지 않을 수도 있다. 이 부분은 언제나 변경이 가능 한 부분이다.

그러나 밑에 있는 prepare()과 packing() 두 메소드는 제품에 변화가 생기더라도 변하지 않는 부분이다.

위 코드를 간단하게 캡슐화하여 ShoesFactory라는 클래스로 만들면

ShoesFactory.java

public class ShoesFactory {
 
    public Shoes makeShoes(String name) {
 
       Shoes shoes = null;
       if (name.equals("blackShoes"))     shoes = new BlackShoes();
       else if (name.equals("brownShoes"))shoes = new BrownShoes();
       else if (name.equals("redShoes"))  shoes = new RedShoes();
       
       return shoes;
    }
 
}

위 코드처럼 만들 수 있다. 그리고 사용은 아래처럼 할 수 있다.

ShoesStore.java

public class ShoesStore {
     
    ShoesFactory factory;
 
    public ShoesStore(ShoesFactory factory) {
        this.factory = factory;
    }
   
    Shoes orderShoes(String name) {
        Shoes shoes = factory.makeShoes(name);
        shoes.prepare(); 
        shoes.packing(); 
 
        return shoes;
    }
}

고객에게 특정 신발에 대한 주문이 들어 왔을 때 매장에서는 공장에 해당 신발 오더를 넣고 받으면 되고, 판매하는 신발이 늘어나거나 단종되면 신발 매장이 아닌 신발 공장에서 그 변화를 처리할 수 있다.

위의 예시 코드는, 디자인 패턴까지는 아니고 프로그래밍에 사용하는 관용구정도로 보면 된다.

그렇다면 이제 본격적으로 팩토리 메소드 패턴을 살펴보자.

위에서 봤던 신반 매장은 점점 성장하여 다른 나라로 진출하기 시작했다. 일본과 프랑스에도 진출을 하여 매장을 지었다고 하자.

   JapanShoesStore jpStore= new JapanShoeStore (new JapanShoesFactory());
   jpStore.order("blackShoes");
    
   FranceShoesStore  frStore = new FranceShoesStore (new FranceShoesFactory());
   frStore.order("blackShoes");

그런데 이 해외 매장들이 본사에서 준 가이드라인 그대로 똑같이 만들지 않고, 현지 상황에 맞춰 일본에서는 약간 굽을 높게 만들고 프랑스에서는 신발 옆에 패턴을 넣기 시작했다.
뿐 만 아니라 포장까지도 자기 마음대로 하였다.

그래서 본사는 매장과 신발 생산 과정 전체를 묶어주는 아래와 같은 프레임 워크를 만들어 모든 매장에서 이를 따르게 하였다.

ShoesStore.java

   public abstract class ShoesStore {
 
    public ShoesStore orderShoes(String name) {
        Shoes shoes = makeShoes(name);
        shoes.prepare();
        shoes.packing();
 
        return shoes;
 
    }
 
    abstract Shoes makeShoes(String name);
 
}

ShoesStore 추상 클래스를 선언하면, 달라지는 부분은 추상메소드인 신발 제작 뿐이다.

각 현지 상황메 맞춰 makeShoes 메소드를 오버라이드하여 일본에서는 약간 굽을 높게 만들고, 프랑스에서는 패턴을 넣어 신발을 제작하면 된다.

그대신 제작, 준비, 포장하는 공정은 ShoesStore를 상속하는 전 세계 모든 매장들에서 똑같은 시스템이 적용 될 수 있다.

JapanShoesStore.java

class JapanShoesStore extends ShoesStore {
 
    @Override
    Shoes makeShoes(String name) {
        if (name.equals("blackShoes")) return new JPStyleBlackShoes();  
        else if (name.equals("brownShoes")) return new JPStyleBrownShoes();
        else if (name.equals("redShoes")) return new JPStyleRedShoes();
        else return null;
   }

}

FranceShoesStore.java

class FranceShoesStore extends ShoesStore {
 
    @Override
    Shoes makeShoes(String name) {
        if (name.equals("blackShoes")) return new FRStyleBlackShoes();  
        else if (name.equals("brownShoes")) return new FRStyleBrownShoes();
        else if (name.equals("redShoes")) return new FRStyleRedShoes();
        else return null;
   }

}

여기서 가장 중요한 점은 하위 클래스에서 메소드를 오버라이딩 하였기 때문에, 슈퍼클래스에 있는 orderShoes 메소드에서는 어떤 신발이 만들어 지는지 전혀 모르고 있다는 것이다.
동적 바인딩되는 그 메소드에서 주는 신발을 받아서 준비하고 포장할 뿐 이다.

신발을 주문받고 생산하는 생산자 클래스

생산자 클래스에서 생산되는 제품 클래스

팩토리 메소드 패턴의 클래스들은 크게 생산자 클래스와 제품 클래스로 구분할 수 있다.

이제 생산자 클래스인 ShoesStore 클래스에서 사용될 제품 클래스 Shoes 클래스를 작성해 보자.

Shoes.java

abstract class Shoes {
 
    String name;
    String bottom;
    String leather;
    boolean hasPattern;
 
    void prepare() {
        System.out.println("주문하신 신발을 준비중입니다.");
    }
 
    void packing() {
        System.out.println("준비 완료된 신발을 포장중입니다.");
    }
 
    public String getName() {
        return name;
    }
 
}

JPStyleBlackShoes.java

class JPStyleBlackShoes extends Shoes {
 
    public JPStyleBlackShoes() { 
        name = "일본 스타일의 검은 구두";
        bottom = "굽이 높은 밑창";
        leather = "스웨이드";
        hasPattern = false;
    }
 
}

FRStyleBlackShoes.java

class FRStyleBlackShoes extends Shoes {
 
    public FRStyleBlackShoes() { 
        name = "프랑스 스타일의 검은 구두";
        bottom = "일반 굽높이 밑창";
        leather = "스웨이드";
        hasPattern = true;
    }
 
}

추상 클래스로 Shoes 클래스를 설계하고, JPStyleBlackShoes, FRStyleBlackShoes에서 멤버변수를 초기화하며 구현해주었다.

추상 메소드가 없는 추상 클래스가 여기서 등장한다.
Shoes 클래스는 추상 메소드가 존재하지 않지만 추상 클래스로 선언되었기 때문에 new Shoes();로 직접 객체 생성은 불가능하고,
Shoes 클래스를 상속받는 클래스에서 Shoes를 참조변수로하여 객체 생성을 할 수 있다.
추상 메소드 존재 O -> 무조건 클래스도 추상 클래스로 선언
추상 메소드 존재 X -> 현재 클래스로 다이렉트 객체 생성을 막고 싶을 때, 추상 클래스로 선언

여기까지 UML에 설계된 클래스들의 구현이 마무리 되었다.
이제 메인 클래스에서 위에서 설계한 팩토리 메소드 패턴이 어떻게 사용되는지 살펴 보자.

Main.java

public class Main {
 
    public static void main(String[] args) {
        
        // 일본과 프랑스에 현지 트렌드에 맞춰 매장을 열었음
        ShoesStore jpStore = new JapanShoesStore();
        ShoesStore frStore = new FranceShoesStore();
      
        // 일본 매장에서 검은 신발 주문
        Shoes shoes = jpStore.orderShoes("blackShoes");
        System.out.println("일본 매장에서 주문한 검은 신발 : " + shoes.getName());
        
        System.out.println();
        
        // 프랑스 매장에서 검은 신발 주문
        shoes = frStore.orderShoes("blackShoes");
        System.out.println("프랑스 매장에서 주문한 검은 신발  : " + shoes.getName());
 
    }
 
}

위 코드를 실행해보면 아래와 같이 출력될 것이다.

> 주문하신 신발을 준비중입니다.
> 준비 완료된 신발을 포장중입니다.
> 일본 매장에서 주문한 검은 신발 : 일본 스타일의 검은 구두
>
> 주문하신 신발을 준비중입니다.
> 준비 완료된 신발을 포장중입니다.
> 프랑스 매장에서 주문한 검은 신발 : 프랑스 스타일의 검은 구두

위 메인 메소드의 프로세스는 아래와 같다. 참고로 일본 매장과 프랑스 매장에서의 프로세스는 동일하니 공통적인 프로세스로 묶어서 설명하겠다.

  1. 현지 신발 매장이 문을 열었음. (ShoesStore를 참조변수로 하는 현지 ShoesStore 객체 생성)
  2. 매장에 신발 종류를 통해 신발을 주문함. (ShoesStore의 orderShoes메소드)
  3. ShoesStore의 orderShoes 내부에서 생성된 객체에 맞게 동적바인딩되어 오버라이딩된 makeShoes 메소드가 실행됨
  4. 오버라이딩된 makeShoes 메소드에서 주문에 맞는 신발객체를 호출된 orderShoes메소드로 리턴함
  5. prepare(), packing() 메소드가 실행됨
  6. make, prepare, packing이 완료된 Shoes 객체를 리턴함
  7. 주문한 신발이 어떤 객체인지 출력하여 확인

마무리로 다시 한번 팩토리 메소드 패턴을 정리하자면, 팩토리 메소드 패턴은 객체를 만들어내는 부분을 자식 클래스에 위임하는 패턴이다.

new 키워드를 호출하는 부분을 서브 클래스에 위임하였기 때문에, 상위 클래스인 ShoesStore 클래스 내부에는 new 라는 키워드가 존재하지 않는다.

즉, 상위 클래스가 아닌 하위 클래스에서 어떤 클래스를 만들지 결정하게 하도록 하는 것이다.

하위 클래스에서 추상 메소드인 makeShoes메소드를 오버라이딩 하였기 때문에, 상위 클래스에 있는 orderShoes 메소드에서는 어떤 신발이 만들어 지는지 전혀 모르고 있다.

동적 바인딩된 그 메소드에서 주는 신발을 받아서 준비하고 포장해서 내놓을 뿐 이다.

객체 지향 프로그래밍 세계에서 자식은 부모를 알아도, 부모는 자식을 모른다.


Adapter

어댑터 패턴(Decorator Pattern)이란 한 클래스의 인터페이스를 클라이언트에서 사용하고자 할 때, 다른 인터페이스로 변환시켜 사용하는 패턴이다.

어댑터를 이용하면 인터페이스 호환성 문제 때문에 같이 쓸 수 없는 클래스들을 연결해서 쓸 수 있다.

어댑터 패턴은 우리가 여행용 전원 어댑터를 생각해보면 이해가 쉽다.

우리가 사용하는 휴대폰, 노트북 충전기는 220V 동그란 돼지코 한국의 표준 플러그를 사용하지만, 전세계별로 이 플러그 표준이 각기 다 다르다.

일본은 동그란 모양이 아닌 || 모양을 표준으로 사용하고, 호주는 ∴ 모양을 표준으로 사용한다.

그렇기 때문에 우리가 해당 나라에서 여행을 가서 콘센트를 사용해 충전을 하기 위해서는 아래 사진과 같은 전원 어댑터가 필요하다.

이와 같이 어댑터는 소켓의 인터페이스를 플러그에서 필요로 하는 인터페이스로 바꿔준다고 할 수 있다.

객체 지향 프로그램에서의 어댑터도 마찬가지로 일상 생활에서와 동일하게 어떤 인터페이스를 클라이언트에서 요구하는 형태의 인터페이스로 맞춰주기 위해 중간에서 연결시켜주는 역할을 한다.

아래 어댑터의 기능을 잘 표현하는 UML이 있어서 가져와 보았다.
약간의 이해를 더 돕기 위해 MediaPackage라는 이름을 VideoPlayer으로,
Media Player는 AudioPlayer라는 이름으로 변경하여 구현하였다.

아래는 AudioPlayer 인터페이스와 AudioPlayer 인터페이스를 구현하는 MP3 클래스이다.

AudioPlayer.java

public interface AudioPlayer{
   
   void play(String filename);
   
}

MP3.java

public class MP3 implements AudioPlayer{
   
   @Override
   void play(String filename){
      System.out.println("Playing MP3 File ♪ : "filename);
   }
   
}

아래는 VideoPlayer 인터페이스와 VideoPlayerr 인터페이스를 구현하는 MP4, MKV 클래스이다.

VideoPlayer.java

public interface VideoPlayer{
   
   void play(String filename);
   
}

MP4.java

public class MP3 implements VideoPlayer{
   
   @Override
   void play(String filename){
      System.out.println("Playing MP4 File ▶ : "filename);
   }
   
}

MKV.java

public class MKV implements VideoPlayer{
   
   @Override
   void play(String filename){
      System.out.println("Playing MKV File ▶ : "filename);
   }
   
}

아래는 VideoPlayer포맷을 AudioPlayer포맷에서도 사용할 수 있게 도와주는 FormatAdapter Class이다.
FormatAdapter Class는 AudioPlayer 인터페이스를 상속받고, 멤버 변수로 VideoPlayer를 사용한다.
생성자로 VideoPlayer를 입력받아 해당 Video 포맷을 사용하는 것이다.

FormatAdapter.java

public class FormatAdapter implements AudioPlayer{
   
   private VideoPlayer media;
   
   public FormatAdapter(VideoPlayer video){
      this.media = video;
   }
   
   @Override
   void play(String filename){
      System.out.println("Using Adapter : ");
      media.playFile(filename);
   }
   
}

아래 Main Class는 어댑터 패턴의 사용 예시이다.

MP3 인스턴스를 AudioPlayer 참조변수로 mp3Player 객체를 생성하였는데,

MP4 인스턴스에 어댑터를 사용하면 MP4도 mp3Player에서도 사용할 수 있게된다.

Main.java

public class Main{

   public static void main(String[] args){
   
   AudioPlayer mp3Player = new MP3();
   mp3Player.play("file.mp3");
   
   mp3Player = new FormatAdapter(new MP4());
   mp3Player.play("file.mp4");
   
   mp3Player = new FormatAdapter(new MKV());
   mp3Player.play("file.mkv");
   
   }
   
}

위 코드를 실행시켜보면 아래와 같이 출력이 됨을 알 수 있다.

> Playing MP3 File ♪ : file.mp3
> Using Adapter : Playing MP4 File ▶ : file.mp4
> Using Adapter : Playing MKV File ▶ : file.mkv

이렇게 어댑터 패턴을 통해 mp3Player 에서도 video 포맷의 파일을 재생시킬 수 있다. 물론 영상은 못보고 소리만 나오겠지만..

HTTP & HTTPS

HTTP와 HTTPS


HTTP(Hyper Text Transfer Protocol)

WWW(World-Wide-Web)기반 서비스에서 웹 서버와 WWW 클라이언트(웹 브라우저) 간 통신을 위해 사용하는 네트워크 프로토콜

일반적으로 80번 포트를 사용한다. 클라이언트가 80번 포트를 이용해 데이터를 요청(Request)하면 웹 서버는 80번 포트로 들어온 요청에 응답(Response)하는 방식으로 통신한다.

HTTP 요청(Request) 메시지와 응답(Response) 메시지는 크게 Start line, Headers, Body 부분으로 구성되어 있다.

  • Start line : Method 방식, 타겟 URL, HTTP 버전 등의 정보
  • Headers : Request와 Response 메시지 메타 정보
  • Body : 실제 Request와 및 Response 메시지 내용

HTTP는 직관적이다. 요청 및 응답 메시지(HTML 파일, 텍스트 데이터, 이미지 및 파일 데이터 등)를 Body에 평문으로 붙여 전달한다. 따라서 데이터를 빠르고 쉽게 주고 받을 수 있다.


HTTPS(Hyper Text Transfer Protocol over Secure-Socket-Layer)

HTTP 프로토콜에 대해 SSL 암호화 통신 기능을 추가한 네트워크 프로토콜

HTTP는 웹 서버와 클라이언트(브라우저) 간 통신을 제약 없이 자유롭게 진행했다면, HTTPS는 웹 서버와 클라이언트(브라우저) 간 통신이 암호화되므로 제 3자의 개입이나 위, 변조를 막을 수 있다.

일반적으로 443번 포트를 사용한다. 또한 URL이 http://로 시작하는 HTTP 프로토콜과 달리, HTTPS 프로토콜은 https://로 시작하므로 URL을 확인하면 어떤 웹 사이트에서 HTTPS 프로토콜을 사용하는지 알 수 있다.


SSL(Secure Socket Layer)

웹 서버와 클라이언트 간 통신을 공인된 제 3자 인증 기관 CA(Certificate Authority)에서 인증하여 보안을 강화하는 방법

  • SSL은 OSI 7계층의 응용 계층표현 계층 중간에 독립적으로 존재한다. 응용계층이 SSL로 데이터를 전송하면 SSL은 이를 암호화하여 TCP로 보낸 뒤 이것이 외부 인터넷으로 전달된다. 수신 역시 TCP로부터 데이터를 수신받은 SSL은 이를 복호화하여 응용계층으로 전달한다.
  • 전송 계층 보안(Transport Layer Security, TLS)은 조금 더 진화된 버전의 SSL이다. 오늘날 대부분 TLS 방식을 이용하지만 SSL이 더 많이 알려져있기때문에 실제로는 TLS를 쓰고 용어만 SSL이라고 한다.

공개키 암호화 방식

  • 데이터를 암호화(Encrypt)하고 암호화된 데이터를 다시 평문으로 원상복구하는 복호화(Decrypt) 과정에서 각각 서로 다른 키(Key)를 사용한다.
  • A키를 이용해 암호화한 데이터는 B키가 있어야 복호화하여 해석할 수 있다. 두 키는 쌍을 이룬다.
  • 암호화 키(공개키)는 모두에게 공개되어 있기 때문에 누구나 암호화할 수 있다.
  • 다만 복호화 키(비밀키)는 해당 데이터를 열람하고 수정할 권한이 있는 호스트만 각자 개인의 비밀키를 갖고 있다. 따라서 권한이 있는 자만 데이터를 읽을 수 있다.

공개키 암호화의 특성을 이용한 전자 서명

위의 공개키 암호화 방식을 응용하여 정보를 제공한 자의 신원을 보장할 수 있는 방법이 있다.

  • 비밀키 소유가 자신의 비밀키를 이용해 데이터를 암호화하고, 이 데이터를 공개키와 함께 배포한다.
  • 데이터를 수신한 자는 함께 수신한 공개키를 이용해 데이터를 복호화하여 접근할 수 있다.

기존의 공개키 암호화 방식에서는 공개키로 암호화한 뒤 비밀키로 복호화했다면 전자 서명에서는 반대로 비밀키로 암호화한 뒤 공개키로 복호화한다. 이 방법은 언뜻 보면 누구나 공개키를 이용해 데이터를 읽어 위, 변조할 수 있어 위험해 보인다. 하지만 암호화된 데이터를 특정 공개키로 복호화할 수 있다는 것은 해당 데이터가 특정 공개키와 쌍을 이루는 특정 비밀키로 암호화되어있다는 것을 의미한다. 따라서 데이터를 제공한 자의 신원을 보장할 수 있다.

SSL을 이용한 통신 과정

  1. Handshake
    1. 클라이언트가 서버에 접속한다.
      • 전송하는 정보 : 클라이언트가 생성한 랜덤 데이터, 암호화 방식 협상을 위한 제안, 세션 아이디
      • 랜덤 데이터는 실질적인 요청 및 응답 메시지 암호화, 복호화를 위해 사용됨
      • 클라이언트 측에서 가능한 암호화 방식의 종류를 서버 측에 제시
      • 이후 같은 세션에서는 SSL 핸드 쉐이킹을 생략하기 위해 세션 식별자를 함께 보냄
    2. 서버는 클라이언트의 요청에 응답한다.
      • 전송하는 정보 : 서버가 생성한 랜덤 데이터, 클라이언트의 암호화 방식 중 서버가 선택한 암호화 방식 명시, 인증서
      • 랜덤 데이터는 실질적인 요청 및 응답 메시지 암호화, 복호화를 위해 사용됨
      • 클라이언트가 제시한 암호화 방식 중 가능한 암호화 방식을 선택함
      • 서버가 자기 자신임을 인증하기 위한 인증서
    3. 클라이언트는 서버의 인증서를 통해 서버의 신원을 판별한다.
      • 클라이언트는 서버의 인증서가 신뢰할 수 있는 CA에서 인증된 것인지 확인한다.
      • 신뢰할 수 있다면 해당 CA의 공개키로 서버의 인증서를 복호화한다.
      • 복호화가 성공했다는 것은 CA의 공개키와 쌍을 이룬 해당 CA의 비밀키로 암호화가 되었다는 것을 의미하므로 서버의 신원을 확실하게 믿을 수 있다.
    4. 실제 HTTPS 통신 메시지를 암호화하기 위해 클라이언트와 서버의 랜덤 메시지를 조합하여 pre master secret 키를 생성한다.
      • pre master secret 키는 대칭키이므로 이를 주고받기 위해 다시 공개키 방식을 이용한다. 서버의 공개키로 pre master secret 키를 암호화하여 보내면 클라이언트는 이를 자신의 비밀키로 복호화하여 메시지를 읽을 키(session key)를 획득한다.
  2. 세션
    • 클라이언트와 서버가 실제 데이터를 주고 받는다.
    • handshake 단계에서 공유한 session key(대칭키)를 이용해 메시지를 암호화 및 복호화한다.
    • 대칭키와 공개키를 혼용하는 이유 : 공개키 방식이 상대적으로 많은 연산을 필요로하기 때문에 연산을 최소화하기위해 실제 데이터에 접근하기 위한 대칭키만 공개키로 암호화하는 것!
  3. 세션 종료
    • SSL 통신이 끝났음을 서로에게 통지한다.
    • session key는 폐기한다.

HTTP Vs HTTPS

  1. HTTPS 프로토콜과 HTTP 프로토콜의 가장 큰 차이는 보안이다.

    HTTPS 프로토콜은 SSL 암호화 방식을 이용해 요청과 응답 주체를 인증할 수 있고, 전송하는 데이터도 암호화 되어있으므로 해독하여 위, 변조할 수 없다. 즉, 데이터의 무결성을 보장한다.

    사용자의 개인정보나 결제 정보 등과 같이 민감하고 중요한 정보를 전송해야할 경우 HTTPS 프로토콜을 이용해야 한다.

  2. 부가적인 차이는 SEO 품질이다.

    • SEO(Search Engine Optimization, 검색 엔진 최적화) : 구글 등의 검색 엔진에서 웹사이트가 잘 검색되고 노출될 수 있도록 검색을 최적화 하는 전략

    검색 엔진 구글은 HTTPS 프로토콜을 이용하는 웹 사이트에 대해 SEO 가산점을 제공하여 웹 사이트들이 HTTPS 프로토콜을 이용하도록 유도하고 있다.

    나아가 크롬과 같은 브라우저에서는 HTTP 프로토콜을 이용하거나 신뢰할 수 없는 인증서를 이용해 통신할 경우 ‘안전하지 않음’ 이라는 메시지를 출력함으로써 사용자가 HTTP 웹 사이트를 접속하는 것을 간접적으로 막고 있다.

    또한 구글에서 지원하는 AMP(Accelerated Mobile Pages, 가속화된 모바일 페이지) 혜택을 받기 위해서도 HTTPS 프로토콜을 사용해야 한다.

  3. 미묘한 성능 차이가 존재할 수 있으나 기술의 발달로 체감할 수 없는 수준이 되거나 오히려 HTTPS가 더 좋은 성능을 보이기도 하므로 문제가 없는 수준이다.


TDD 정리

TDD

TDD(Test Driven Development)는 테스트 주도 개발이라는 뜻으로 만들고자하는 기능의 내용을 담고 있으면서 만들어진 코드를 검증까지 해줄 수 있도록 테스트 코드를 먼저 만들고 테스트를 성공하게 해주는 코드를 작성하는 방식의 개발 방법입니다.

그렇다면 테스트는 과연 무엇일까요?

테스트는 무엇인가

테스트는 만든 코드에 확신을 가질 수 있게 해주는 도구입니다. 테스트를 통해 검증한 코드는 정상적으로 작동한다는 확신을 가질 수 있으며, 변화한 부분 이외의 부분은 검증이 완료된 상태이기 때문에, 변경한 부분으로 인해 발생하는 변화만 신경쓰면 되기 때문에 변화에 유연하게 대처할 수 있게 해줍니다.

테스트는 개발자가 의도한대로 코드가 정확히 작동하는지 확인하는 것이기 때문에, 테스트하려는 대상에 집중할 수 있는 사이즈로 만드는 것이 중요합니다. 그렇기 때문에 관심사의 분리를 통해서 적당한 사이즈로 단위 테스트를 만들어야합니다.

단위 테스트를 하는 이유는 웹 개발을 예시로 설명을 해보겠습니다.

웹 개발 초기에 회원 가입 기능을 개발하고 있는 상황을 생각해보면, DB에 데이터가 잘 들어가는지 확인을 하려고 할 때, 단위 테스트를 사용하지않으면, 프론트 코드까지 작성을 해서 데이터를 넘겨받아와서 저장하는 기능까지 구현을 해놓은 다음에 제대로 작동하는지 확인해야합니다. 이런 식으로 개발을 하면, 문제가 발생했을 때, 어디서 발생했는지 확인이 힘들 뿐만 아니라 초기에 간단하게 대응할 수 있었던 문제를 엄청 복잡한 과정을 통해 해결해야하는 일이 생길 수 있습니다. 그렇기 때문에 관심사에 맞는 크기의 단위 테스트를 만들어 테스트 하는 것이 중요합니다.

그렇다면 테스트를 작성할 때 어떤 점을 유의해야하는지 알아보겠습니다.

1. 테스트는 자동으로 수행되도록 코드로 만드는 것이 중요하다.

테스트를 진행할 때, 입력에 실수가 있어서 오류가 나면 다시 테스트를 반복해야하고, 테스트를 실행하기위해서 서버를 띄우고 프로그램을 배치한 후, 테스트 용으로 브라우저를 띄우고 주소를 입력해야하는 귀찮은 작업도 필요합니다. 하지만 자동으로 수행되도록 코드를 만들어놓으면 이런 시간을 단축할 수 있으며 자주 반복이 가능합니다.

2. 지속적인 개선과 점진적인 개발을 위한 테스트를 해야한다.

작성한 코드를 만든 후에 이를 검증하는 테스트 코드를 만들어두면, 코드를 개선하는 작업을 할 때 유리합니다. 혹시 개선 과정 중간에 설계를 잘못하거나, 수정에 실수가 있었다면 테스트를 통해서 바로 확인을 할 수 있기 때문입니다.

그렇기 때문에 이전까지 기능해놓았던 구현들이 문제 없음을 검증한다면, 조금씩 기능을 추가하면서 테스트트하는 점진적인 개발이 가능해집니다. 테스트를 통해 기존에 만들어둔 기능들이 새로운 기능을 추가하느라 수정한 코드에 영향을 받지않고 잘 작동하는지까지도 확인할 수 있기 때문에, 개발 전단계에 걸쳐 테스트는 필수적입니다.

3. 테스트 결과는 일관성이 있어야한다.

코드에 변경사항이 없다면 테스트는 항상 동일한 결과를 내야합니다.
테스트가 외부 상태에 따라서 성공하기도 하고, 실패하기도 한다면 그 테스트는 좋은 테스트라고 할 수 없습니다.

4. 포괄적인 테스트를 해야한다.

2번에서 관심사에 따른 단위 테스트를 진행해야한다고 말했는데, 갑자기 포괄적으로 테스트를 해야한다는 말이 나와서 의아할 수 있습니다.

하지만 여기서 말하는 포괄적인 테스트라는 것은 성공하는 테스트만 골라서 만들면 안된다는 뜻입니다.

평소에는 정상적으로 잘 동작하는 것처럼 보이지만 막상 특별한 상황이 되면 엉뚱하게 동작한다면, 이는 추후에 큰 문제를 만들 수 있고 원인을 찾기 힘들어서 고생할 수 있습니다.
"항상 네거티브 테스트를 먼저 만들어라"라는 말이 있을만큼 부정적인 케이스를 먼저 만드는 테스트 습관을 들이는 것이 중요합니다.

테스트가 이끄는 개발

앞서 설명했던 것처럼 테스트 주도 개발은 만들고자하는 기능의 내용을 담으면서 만들어진 코드를 검증할 수 있는 코드를 먼저 만든 후에, 그 테스트를 성공하게 해주는 코드를 작성하는 방식의 개발방법입니다.

이는 테스트를 만들어가면서 개발하는 방법이 주는 극대화한 방법입니다.

실패한 테스트를 성공시키기위한 목적이 아닌 코드는 만들지 않는다

는 TDD의 기본 원칙입니다.

TDD는 테스트를 먼저 만들고 그 테스트가 성공하도록 하는 코드만 만드는 방식으로 진행하기 때문에, 테스트를 빼먹지 않고 만들 수 있습니다. 그리고 테스트를 작성하는 시간과 애플리케이션 코드를 작성하는 시간의 간격이 짧아져 전체적인 개발 시간이 짧아집니다.

테스트를 만들어뒀기 때문에, 코드를 작성하면 바로바로 테스트를 실행해볼 수 있기 때문입니다.

TDD에서는 테스트를 작성하고 이를 성공시키는 코드를 만드는 작업의 주기를 가능한 짧게 가져가도록 권장됩니다. 그래서 TDD 방식은 애자일 을 통한 개발을 할 때 필수적입니다.

TDD의 장점

  1. 코드를 만들어 테스트를 실행하는 그 간격이 매우 짧기 때문에 개발한 코드의 오류를 빠르게 발견할 수 있습니다. 빨리 발견된 오류는 쉽게 대응이 가능하기 때문에 TDD를 통해서 개발을 하면 개발시간을 단축할 수 있습니다.

  2. 테스트 코드는 애플리케이션 코드보다 상대적으로 작성하기 쉽고 각 테스트가 독립적이기 때문에, 코드의 양에 비해서 작성하는 시간이 얼마 걸리지않습니다.

  3. 개발하고 싶은 기능을 일반 언어가 아닌 테스트 코드로 표현해서, 마치 코드로 된 설계문서처럼 만들어놓을 수 있습니다. 이렇게 설계 문서처럼 만들어 놓은 후에 실제 기능을 가진 애플리케이션 코드를 만들고 나면, 그 테스트를 바로 실행해서 설계한대로 코드가 작동하는지 확인할 수 있습니다.

    이때 테스트가 실패하면 설계한 대로 코드가 만들어지지 않았다는 것을 바로 알 수 있습니다. 이 과정에서 문제가되는 부분을 알 수 있고, 다시 코드를 수정하고 테스트를 수행해서 테스트가 성공하도록 코드를 계속 다듬어가면서 테스트를 끝내면 코드의 구현과 테스트라는 두가지 작업이 동시에 끝나기 때문에 정말 효율적입니다.

대칭키 & 비대칭키

대칭키 & 비대칭키

네트워크 보안

secure communication을 위해서는 다음의 원칙들을 고려해야합니다.

  1. 기밀성
    송신자와 정해진 수신자만이 전송된 메세지의 내용을 이해할 수 있어야한다.
  2. 메세지 무결성
    송신자와 수신자 사이의 메세지는 중간에 변형되면 안된다.
  3. 엔드포인트 인증
    발신자와 수신자 모두 통신에 관련된 상대방(End-point)의 신원을 확인할 수 있어야한다.
  4. 운영 보안
    공격자가 바이러스를 심거나 보안사항을 얻으려는 시도를 하거나, DoS 공격을 시도하는 등을 할 수 없게 안전하게 운영되어야한다.

이 중 대칭키와 비대칭키는 1번 원칙인 기밀성을 위해 사용되는 도구입니다.

암호화의 원칙

다음은 암호화의 요소에대한 그림입니다.
사진 2021  9  18  오후 55150
Alice는 암호화 알고리즘에 input으로 들어갈 key를 제공합니다. 이 때, key는 숫자와 문자로 구성된 문자열입니다.
이 암호화 알고리즘은 송신자의 키와 plaintext 메세지를 받아서 output으로 암호문을 만들어냅니다.
복호화 알고리즘은 암호화 알고리즘을 통해 만들어진 암호문을 수신자의 키를 이용해서 plaintext 메세지로 바꿔줍니다.
이런 과정에서 사용되는 키는 대칭키와 비대칭키가 있습니다.

대칭키 : 수신자와 송신자의 키가 동일하다.
공개키 : 수신자와 송신자의 키가 쌍으로 사용됩니다.

대칭키

stream 암호

plaintext와 같은 길이의 키 스트림을 생성해서 비트단위로 XOR연산을 수행합니다.
오류 확산의 위험이 없고, 이동통신 환경에서 구현이 용이해서 무선 데이터 보호에 많이 사용됩니다. 실시간성이 중요한 음성, 영상, 스트리밍 전송에 사용됩니다.
비트단위로 암호화를 진행해서 시간이 많이 걸리고, 데이터 흐름에 따라서 비트 단위로 순차적으로 처리해서 내부 상태를 저장하고 있어야한다는 단점이 있습니다.
New-Project-18

download

RC4

SSL/TLS나 네트워크 프로토콜에서 자주 사용되는 스트림 암호 기법으로 plaintext와 XOR 연산을 진행할 pseudorandom stream을 만듭니다.

256 바이트로 구성된 상태의 한 바이트는 암호화 키로서 사용되기 위해서 랜덤하게 선택이 됩니다.

초기화는 256바이트의 state vector를 0,1,2,…,254,255로 초기화합니다.
초기화 한 후에 키 배열을 이용해서 상태배열에대해 다음과 같이 swap을 해줍니다.
1280px-RC4 svg

이 swap 과정 중 S[i] + S[j] 의 값에 해당하는 상태 배열의 인덱스의 원소를 키로 사용합니다.

block 암호

다양한 보안이 적용된 인터넷 프로토콜(ex. PGP,SSL,IPsec)에서 사용되는 암호입니다.
블록 암호문에서는 암호화될 메세지는 k bit의 블록으로 처리됩니다.
예를들면, k = 64인 경우에는 메세지는 64 비트의 블록 단위로 나눠지고, 각각의 블록들은 독립적으로 암호화됩니다.

블록을 암호화 하기위해서는 암호는 일대일 매핑을 사용해서 k비트의 일반 텍스트 블록을 암호문의 k비트 블록에 매핑합니다.

k = 3 인 경우 아래와 같이 매핑할 수 있습니다.
이는 가능한 8!(=40320)가지 방법 중 한가지를 표시한 것입니다.

input output input output
000 110 100 011
001 111 101 010
010 101 110 000
011 100 111 001

우리는 이런 각각의 매핑들을 키로 생각할 수 있습니다.
하지만 이처럼 작은 비트로 매핑을하면 모든 경우의 수를 완전탐색으로 금방 찾아낼 수 있습니다. 이런 공격을 방지하기 위해서 k bit를 64비트나 그 이상의 비트수로 구성합니다.

비록 이런 적절한 k를 이용한 full-table block 암호가 강력한 대칭키 암호 스키마를 만들 수 있지만, 구현하는 것이 까다롭습니다. k bit 를 사용하면 송신자와 수신자 모두 (2^k)!가지의 경우의 수로 구성된 테이블을 유지해야하는데, 이는 실행이 불가능합니다.
어찌어찌해서 다 만들었다하더라도, 키를 바꿔버리면 다시 테이블을 생성해야합니다.

그래서 full-table 암호는 모든 입력과 출력 간에 미리 결정된 mapping을 제공하는 경우에만 사용합니다.

이런 점을 해결하기 위해서 블록 암호는 랜덤하게 조합된 테이블을 만드는 함수들을 사용합니다.

사진 2021  9  18  오후 64308

위의 그림처럼 input을 다루기 쉬운 사이즈의 bit로 나눠서 매핑합니다.
이렇게 나눠진 chunk들은 64비트의 블록으로 합쳐진 후에, 다시 한 번 섞여서 새로운 64-bit output을 만듭니다.
이렇게 만들어진 결과물은 64-bit input으로 사용되어 위 과정을 반복하고, 이 반복을 총 n번 반복합니다.
이 때, n번의 싸이클을 돌리는 목적은 각각의 input bit가 출력 비트의 대부분에 영향을 미치게하기 위함입니다.(chunk 안에 있는 bit는 바뀌는 것이 없기 때문)

AES

AES는 128 비트 블록을 사용하고 128,192,256비트 길이의 키를 사용할 수 있습니다.

위 그림은 다음 4가지 단계를 표현한 것입니다.

  1. SubByte : 바이트 단위 형태로 블록을 교환
    cf) S-box
    에스박스-1

  2. Shift rows : 행과 행을 치환

  3. Mix columns : 열에 속한 모든 바이트를 순환 행렬을 사용해서 함수로 열에 있는 각 바이트를 대체해서 변화시킵니다.

  4. Add round key : 확장된 키의 일부와 현재 블록을 비트별로 XOR 연산해줍니다.

암호화와 복호화를 위해서 라운드 키 더하기 단계에서 시작해 10라운드를 수행합니다. 9라운드 동안은 4단계를 모두 포함하는 반복을 수행한 후에 10번째에는 3단계(mix columns제외)로 구성된 반복을 수행합니다.

라운드 키를 더하는 단계에서만 키를 사용하기 때문에 각 라운드의 시작과 끝은 라운드 키를 더하는 단계가 됩니다.

위의 4가지 단계는 비트를 뒤섞고 XOR 암호화 하는 것을 번갈아서 적용함으로서 효과적으로 보안성을 강화시킵니다.

암호화된 암호문은 암호의 역순으로 해독하면 평문을 얻어낼 수 있습니다.

AES 진행방식

공개키(Public Key)

대칭키를 이용한 암호화 통신은 두 communicating 당사자들이 공통된 키를 공유합니다. 이런 방법의 한가지 어려운 점은 두 참가자가 반드시 공유키에대한 동의를 해야한다는 것입니다. 참가자들이 처음 만나서 동의한 다음 그 후에 암호화로 통신이 가능할 것입니다.
그러나 지금 세상에는 네트워크 없이 통신의 참여자가 직접 만나거나 소통을 할 수 없습니다.
그렇다면 두 참여자가 사전에 이미 알고있는 비밀키 공유 없이 암호화 통신을 할 수 있는 방법은 무엇일까요?

사진 2021  9  18  오후 110602

그 때 사용하는 것이 공개키(Public Key)입니다.
공개키 구조는 네트워크에 참여하는 모두가 볼 수 있는 공개키와 수신자만 가지고 있는 개인키로 되어있습니다.
공개키로 암호화되어진 암호문을 수신자의 개인키를 이용해서 복호화합니다.
이런 특징 때문에, 공격자가 암호 알고리즘과 암호키를 알아도 복호키 계산이 불가능합니다.
공개키를 이용해서 암호화를 할 때, 주로 RSA 알고리즘을 이용해서 암호화합니다.

RSA
RSA는 소인수분해 연산을 이용합니다.

  1. p와 q라고하는 2개의 서로 다른 충분히 큰 소수를 고릅니다.
  2. n = pq, z = (p-1)(q-1)을 계산합니다.
  3. n보다 작으면서 서로소인 정수 e를 찾습니다.
  4. 그 후 d*e를 z로 나눴을 대 나머지가 1인 정수 d(ed mod z = 1)를 구합니다.
  5. (n,e)는 공개키로 사용, (n,d)는 개인키로 사용
    이 때, p와q를 이용해서 d,e를 계산하는 것이 가능하기 때문에, 보안상의 이유로 공개키와 개인키를 생성한 이후에는 p와 q를 지워버리는 것이 안전합니다.

이 때, e는 공개키에 이용되고, d는 개인키에 사용됩니다.

암호화
C = (M^e)(mod N)
M 은 송신자가 보내는 메세지이고, C는 암호화 값입니다.
이때 C의 비트 패턴은 수신자에게 보내는 암호문과 관련이 있습니다.
복호화
M = (C^d)(mod N)

다음은 love라는 단어를 암호화하고 복호화하는 과정을 나타낸 그림입니다.

사진 2021  9  19  오후 13909
사진 2021  9  19  오후 13900

그렇다면 RSA는 어떻게 암호화 알고리즘으로서 역할을 할 수 있을까요??

  1. c = (m^e) mod n
  2. [{(m^e) mod n}^d] mod n = (m^ed) mod n
  3. (m^ed) mod n = (m^(ed mod z) mod n) = m mod n = m
  4. (((m^d)mod n)^e) mod n = m^de mod n = m^ed mod n = (m^e mod n)^d mod n
    위와 같이 (m^d mod n)^e와 (m^e mod n)^d 가 n에 대해서 합동이고, d와 n을 이용해서 e를 구할 수 있기 때문에 암호화 알고리즘으로서의 역할을 할 수 있습니다.

HTTP 프로토콜

HTTP 프로토콜

HTTP란?

  • HTTP(Hypertext Transfer Protocol)는 인터넷 상에서 데이터를 주고 받기 위한 서버/클라이언트 모델을 따르는 프로토콜이다.

  • 애플리케이션(응용) 레벨의 프로토콜로 TCP/IP위에서 작동한다.

  • HTTP로 보낼 수 있는 데이터는 HTML문서, 이미지, 동영상, 오디오, 텍스트 문서 등 여러 종류가 있다.

  • "하이퍼텍스트 기반으로(Hypertext) 데이터를 전송하겠다(Transfer)" = "링크 기반으로 데이터에 접속하겠다"는 의미이다.


HTTP 작동방식

HTTP는 기본적으로 요청/응답 (request/response) 구조로 되어 있다.

클라이언트가 HTTP Request를 서버에 보내면 서버는 HTTP Response를 보내는 구조.

클라이언트와 서버의 모든 통신이 요청과 응답으로 이루어 진다.


HTTP의 특징

비연결성 (Connectionless)

클라이언트에서 서버에 요청을 하고 서버가 요청을 받아 응답하게 되면 연결을 끊어버리는 특징

  • 지속적인 연결로 인한 서버 부담이 줄어드는 장점이 있으나 클라이언트의 이전 상태를 알 수가 없게 된다.

  • 이전 상태 정보를 알 수 없게 되면 로그인을 성공하더라도 로그 정보를 유지할 수 없게 된다.


HTTP 1.1 부턴 지속적 연결 상태가 기본이며 이를 해제하기 위해선 명시적으로 요청 헤더를 수정해야 한다.


(참고) Keep-Alive

HTTP 프로토콜의 Keep-Alive는 Http의 Header의 일종으로, HTTP/1.0에서 지원하지 않던 지속 커넥션을 가능하게 하기 위해서 사용되었다.

  • HTTP 요청시
Connection: Keep-Alive
  • HTTP 응답시
Connection: Keep-Alive
Keep-Alive: max=5, timeout=120 

Keep-Alive 헤더를 추가적으로 보낼 수 있다.

  • Keep-Alive의 max 파라미터는 커넥션이 몇 개의 HTTP 트랜젝션을 처리할 때까지 유지될 것인지를 의미한다.
  • timeout 파라미터는 커넥션이 얼마동안 유지될 것인가를 나타내고, 위의 예시에서는 2분동안 커넥션을 유지하라는 내용이다.

무상태성 (Stateless)

비연결성(Connectionless)에서 파생되는 특징으로 각각의 요청이 독립적으로 여겨지게 되는 특징

  • 서버는 클라이언트의 상태(State)를 유지하지 않으므로 Cookie, Session 등을 이용하여 클라이언트 인증, 인식을 한다.

Cookie(쿠키) : 서버에 저장하지 않고 클라이언트에 저장되는 방식으로, 개별 클라이언트 상태정보를 HTTP 헤더에 담아 전달하는 데이터

Session(세션) : 쿠키와 반대로 클라이언트에 저장되는 것이 아니라 서버에 데이터를 저장하는 방법


URI

URI는 HTTP와는 독립된 다른 체계다.

HTTP는 전송 프로토콜이고, URI는 자원의 위치를 알려주기 위한 프로토콜이다.

Uniform Resource Identifiers(URL)의 줄임로, World Wide Web(www)상에서 접근하고자 하는 자원의 위치를 나타내기 위해서 사용한다.

자원은 HTML문서, 이미지, 동영상, 오디오, 텍스트 문서 등 모든 것이 될 수 있다.


웹페이지의 위치를 나타내기 위해서 사용하는 https://www.naver.com/index.html 등이 URI의 예다.

https : 자원에 접근하기 위해서 https 프로토콜을 사용한다.

www.naver.com : 자원의 인터넷 상에서의 위치는 www.naver.com 이다. 
                도메인은 ip 주소로 변환되므로, ip 주소로 서버의 위치를 찾을 수 있다.

index.html : 요청할 자원의 이름이다.

이렇게 "프로토콜", "위치", "자원명" 으로 어디에 있던지 자원에 접근할 수 있다.


HTTP Request(요청) 구조

HTTP Request 메세지는 크게 3부분으로 구성된다.

  • Start Line (Status Line)
  • Headers
  • Body

Start Line (Status Line)

말 그대로 HTTP request의 첫 라인으로, start line 또한 3부분으로 구성되어 있다.


GET /search HTTP/1.1
  1. HTTP Method

    • 해당 request가 의도한 action을 정의하는 부분
    • HTTP Methods에는 GET, POST, PUT, DELETE, OPTIONS 등이 있다.
    • 주로 GET 과 POST과 쓰인다.
  2. Request target

    • 해당 request가 전송되는 목표 URL
  3. HTTP Version

    • 말 그대로 사용되는 HTTP 버전으로, 1.0, 1.1, 2.0 등이 있다.

Headers

  • 해당 request에 대한 추가 정보를 담고 있는 부분

    예를 들어, request 메세지 body의 총 길이 (Content-Length) 등

  • Key:Value 값으로 되어 있다. (: 이 사용됨)

    HOST: google.com => Key = HOST, Value = google.com

  • Headers도 크게 general headers, request headers, entity headers 3부분으로 구성되어 있다.


Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Type: application/json
Content-Length: 257
Host: google.com
User-Agent: HTTPie/0.9.3
  • Host : 요청이 전송되는 target의 host url (예를 들어, google.com)

  • User-Agent : 요청을 보내는 클라이언트의 대한 정보 (예를 들어, 웹브라우저에 대한 정보)

  • Accept : 해당 요청이 받을 수 있는 응답(response) 타입

  • Connection : 해당 요청이 끝난 후에 클라이언트와 서버가 계속해서 네트워크 컨넥션을 유지할 것인지 아니면 끊을 것인지에 대해 지시하는 부분

  • Content-Type : 해당 요청이 보내는 메세지 body의 타입 (예를 들어, JSON을 보내면 application/json)

  • Content-Length : 메세지 body의 길이


Body

해당 reqeust의 실제 메세지/내용으로, Body가 없는 request도 많다.

예를 들어, GET request들은 대부분 body가 없는 경우가 많음.

POST /payment-sync HTTP/1.1
Accept: application/json
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 83
Content-Type: application/json
Host: intropython.com
User-Agent: HTTPie/0.9.3

{
    "imp_uid": "imp_1234567890",
    "merchant_uid": "order_id_8237352",
    "status": "paid"
}

HTTP Response(응답) 구조

Response도 request와 마찬가지로 크게 3부분으로 구성되어 있다.

  • Start Line (Status Line)
  • Headers
  • Body

Start Line (Status Line)

Response의 상태를 간략하게 나타내주는 부분으로, 3부분으로 구성되어 있다.

HTTP/1.1 404 Not Found
  1. HTTP 버전

  2. Status code: 응답 상태를 나타내는 코드 (예를 들어, 200)

  3. Status text: 응답 상태를 간략하게 설명해주는 부분 (예를 들어, "Not Found")


Headers

Request의 headers와 동일하다.

다만 response에서만 사용되는 header 값들이 있다.

  • 예를 들어, User-Agent 대신에 Server 헤더가 사용된다.

Body

Request의 body와 일반적으로 동일하다.

Request와 마찬가지로 모든 response가 body가 있지는 않다. 데이터를 전송할 필요가 없을 경우 body가 비어있게 된다.

HTTP/1.1 200 OK
Date: Sat, 17 Oct 2020 07:32:39 GMT
Content-Type: application/json
Content-Length: 332
Connection: close
Server: gunicorn/19.9.0
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

{
  "args": {}, 
  "data": "Hello", 
  "files": {}, 
  "form": {}, 
  "headers": {
    "Content-Length": "5", 
    "Content-Type": "text/plain", 
    "Host": "httpbin.org", 
    "X-Amzn-Trace-Id": "Root=1-5f8a9e17-1755758a691e5b0051977312"
  }, 
  "json": null, 
  "origin": "121.131.70.240", 
  "url": "https://httpbin.org/post"
}

HTML 버전

현재는 주로 HTML/1.1, HTML/2.0을 사용한다.

  1. HTTP/1.0

    처음으로 널리 사용하기 시작한 버전이다.

  2. HTTP/1.0+

    keep alive 커넥션, 프락시 연결 지원 등의 기능이 추가 되었다.

  3. HTTP/1.1

    1.0에서는 연결하고 끊고를 반복하면 서버나 클라이언트 모두에 부담이 된다. 그래서 1.1은 pipeline(파이프라인) 기능을 추가하여 매번 연결을 맺고 끊고 하는 과정을 줄여서 속도를 높였다.

    PUT, DELETE 등의 새로운 Method가 추가 되었다.

  4. SPDY

    구글이 만든 프로토콜로, HTTP의 속도를 개선시키기 위해 만들었다.

    대표적인 기능으로 헤더를 압축하는 기능과 하나의 TCP커넥션에 여러 요청을 동시에 보내는 기능, 클라이언트가 요청을 보내지 않아도 서버가 리소스를 푸시하는 기능 등을 갖추고 있다.

  5. HTTP/2.0

    2012년 구글이 만든 SPDY 프로토콜을 기반으로 만들어진 프로토콜이다.

    1.1은 컨넥션 하나에서 여러개의 파일을 전송할 수 있는 1개의 파이프라인을 연결하는데 2.0은 연결이 되면 여러개의 파이프라인을 꽂는다고 생각하면 된다.

    이걸 stream(스트림) 이라고 하는데 1번 파이프라인으로 전송되던 파일이 늦어지면 2번, 3번 또는 다른 파이프라인으로 보내기 때문에 늦어지는 현상이 1.1에 비해서 짧다는 장점이 있다.

    더 많은 차이점은 https://developers.google.com/web/fundamentals/performance/http2?hl=ko 참고.


기수 정렬

기수 정렬(radix sort)

기수 정렬은 흔히 말하는 n진법으로 나타낸 수에서 각 자릿수별로 비교 없이 수행하는 정렬 알고리즘입니다.

비교를 하는 대신 버킷이라 부르는 배열 이용해서 정렬을 합니다.

시작하는 자리수에 따라서 다음과 같이 분류합니다.

LSD - 낮은 자리수부터 높은 자리수 순서로 진행

MSD - 높은 자리수부터 낮은 자리수 순서로 진행

MSD 정렬은 문자열이나 고정된 길이의 정수 표현의 정렬을 할 때 적합하고, 세분화하거나 재귀를 사용하는 경우에 사용하기 좋습니다.
또한, 가장 큰 자리수부터 시작해서 점진적으로 정렬을 완성해나가기 때문에 끝까지 탐색을 안하고도 정렬이 완료될 수 있다는 장점이 있습니다.
하지만 정렬 과정에서 길이가 짧은 수를 가장 긴 길이의 숫자로 확장하고서 정렬하기 때문에, 구현이 복잡해질 수 있습니다.

둘의 성능적인 차이는 없지만 구현상의 편의성 때문에 LSD에 관한 설명으로 이후 설명을 하겠습니다.

동작하는 방식을 나타내면 다음과 같습니다.
스크린샷 2021-09-04 오후 8 03 25

구현

import java.util.Arrays;

public class radixSort {

    public static void main(String[] args) {
        int[] data = new int[]{170, 45, 75, 90, 2, 24, 802, 66};
        data = radixSort(data,3,10);
        System.out.println(Arrays.toString(data));
    }

    /**
     *
     * @param data 정수 배열
     * @param numLen 숫자의 최대 자리수
     * @param radix 기수(10진법인 경우 10)
     */
    public static int[] radixSort(int[] data, int numLen,int radix){
        int[] count = new int[radix];//특정 자리에서 숫자들을 카운트하는 배열
        int[] temp = new int[data.length];//정렬된 배열을 담을 임시 공간(bucket)
        int idx, nowRadix;//idx : index, nowRadix : 현재 확인하는 자리수에 해당하는 숫자
        for(int i = 0;i<numLen;i++){
            for(int j = 0;j<radix;j++){
                count[j] = 0;
            }//자리수별로 정렬하기 때문에 초기화를 진행해야한다.
            System.out.println(Arrays.toString(count));
            nowRadix = (int)Math.pow((double)radix,(double)i);
            for(int j = 0;j<data.length;j++){
                idx = (int)(data[j]/nowRadix)%radix;
                count[idx] = count[idx]+1;
            }//각 숫자가 몇 번 나오는지 count
            for(int j = 1;j<radix;j++){
                count[j] = count[j] + count[j-1];
            }//계수 정렬을 위한 카운트의 누적합을 구한다.
            for(int j = data.length-1;j>=0;j--){
                idx = (int)(data[j]/nowRadix)%radix;
                temp[count[idx]-1] = data[j];
                count[idx] = count[idx]-1;
            }//count 배열을 통해서 각 항목의 위치를 결정
            data = Arrays.copyOf(temp,temp.length);
        }
        return data;

    }

}

시간복잡도

정렬할 숫자의 자리수를 d라고 하면 O(dN)이 됩니다.

장점

문자열, 정수에대한 정렬이 가능하며 퀵소트 등의 O(NlogN) 알고리즘보다 빠르게 동작할 수 있습니다.

단점

부동 소수점은 정렬을 할 수 없습니다.

중간 결과를 저장하는 bucket 공간이 필요합니다.
숫자의 길이가 길어지면 메모리를 많이 사용하기 때문에 사용이 불가능해집니다.

완전탐색, 병합정렬

  1. 완전탐색
  2. 병합정렬

완전탐색

완전탐색이란?

컴퓨터의 빠른 계산 능력을 이용하여 가능한 경우의 수를 일일이 나열하면서 답을 찾는 방법

'무식하게 푼다'라는 의미인 Brute-Force(브루트 포스) 라고도 부르며, 직관적이어서 이해하기 쉽고 문제의 정확한 값을 얻어낼 수 있는 가장 확실하며 기초적인 방법이다.

예를 들어, 4자리의 암호로 구성된 자물쇠를 풀려고 시도한다고 생각해보자. 이 문제를 반드시 해결할 수 있는 가장 확실한 방법은 0000 ~ 9999까지 모두 시도해보는 것이다. (최대 10,000번의 시도로 해결 가능)

하지만 완전탐색은 답으로 가능한 경우의 수가 많은 경우에는 사용하기 어려우므로 완전탐색을 이용할 수 있는 경우인지 잘 파악하는게 중요하다.


따라서 완전탐색 기법으로 문제를 풀기 위해서는 다음과 같은 조건을 고려해야 한다.

  1. 해결하고자 하는 문제의 가능한 경우의 수를 대략적으로 계산한다.
  2. 가능한 모든 방법을 다 고려한다.
  3. 실제 답을 구할 수 있는지 적용한다.

완전탐색 기법

완전탐색 자체가 알고리즘은 아니기 때문에 완전탐색 방법을 이용하기 위해서 여러 알고리즘 기법이 이용된다.
주로 이용되는 기법들은 다음과 같다.

  • 단순 Brute-Force
  • 비트마스크 (Bitmask)
  • 재귀 함수
  • 순열 (Permutation)
  • BFS, DFS

단순 Brute-Force

어느 기법을 사용하지 않고 단순히 for문과 if문 등으로 모든 case들을 만들어 답을 구하는 방법이다.
이는 아주 기초적인 문제에서 주로 이용되거나, 전체 풀이의 일부분으로 이용된다.


비트마스크 (Bitmask)

2진수를 이용하는 컴퓨터의 연산을 이용하는 방식이다.
완전탐색에서 비트마스크는 문제에서 나올 수 있는 모든 경우의 수가 각각의 원소에 포함되거나, 포함되지 않는 두 가지 선택으로 구성되는 경우에 유용하게 사용 가능하다.

간단한 예시로 원소가 5개인 집합의 모든 부분 집합을 구하는 경우를 생각해보자.
어떤 집합의 부분집합은 집합의 각 원소가 해당 부분집합에 포함되거나, 포함되지 않는 두가지 경우만 존재한다.

따라서 5가지 이진수(0~31)를 이용하여 각 원소의 포함 여부를 체크할 수 있다.

위 그림과 같이 0부터 31까지의 각 숫자들이 하나의 부분집합에 일대일로 대응된다.


재귀 함수

재귀 함수를 통해서 문제를 만족하는 경우들을 만들어가는 방식이다.

위에 언급한 부분집합 문제를 예로 들면, 만들고자 하는 부분집합을 S라고 할 때, S = {}부터 시작해서 각 원소에 대해 해당 원소가 포함이 되면 S에 넣고 재귀함수를 돌려주고, 포함되지 않으면 S를 그대로 재귀함수에 넣어주는 방식이다.

비트마스크와 마찬가지로 주로 각 원소가 포함되거나, 포함되지 않는 두 가지 선택을 가질 때 이용된다.


순열

완전 탐색의 대표적인 유형이다.
서로 다른 N개를 일렬로 나열하는 순열의 경우의 수는 N! 이므로 완전 탐색을 이용하기 위해서는 N이 한자리 수 정도는 되어야 한다.

순열에 원소를 하나씩 채워가는 방식이며, 반복문, 재귀 함수, next_permutation 등을 이용하여 구현할 수 있다.


BFS, DFS

약간의 난이도가 있는 문제로 완전 탐색 + BFS/DFS 문제가 많이 나온다.

대표적인 유형으로 길 찾기 문제가 있다. 단순히 길을 찾는 문제라면 BFS/DFS만 이용해도 충분하지만, 주어진 도로에 장애물을 설치하거나, 목적지를 추가하는 등의 추가적인 작업이 필요한 경우에 이를 완전 탐색으로 해결하고 나서, BFS/DFS를 이용하는 방식이다.


관련 문제

백준 1062번 가르침

백준 10819번 차이를 최대로



병합정렬

병합정렬(Merge Sort)이란?

하나의 리스트를 두 개의 균등한 크기로 분할하고 분할된 부분 리스트를 정렬한 다음, 두 개의 정렬된 부분 리스트를 합하여 전체가 정렬된 리스트가 되게 하는 방법이다.

일반적인 방법으로 구현했을 때 이 정렬은 안정 정렬에 속하며, 분할 정복 알고리즘을 활용한 알고리즘이다.

최선/최악의 경우 일정하게 O(nlogn)의 시간복잡도를 보장


안정 정렬(Stable Sort)이란?

중복된 값을 입력 순서와 동일하게 정렬하는 정렬 알고리즘의 특성
ex) 삽입정렬, 병합정렬, 버블정렬


병합정렬의 과정

병합정렬은 크게 3가지 스텝으로 이루어져 있다.

  1. 분할(Divide) : 입력 배열을 같은 크기의 2개 부분 배열로 분할한다.
  2. 정복(Conquer) : 부분 배열을 정렬한다. 부분 배열의 크기가 충분히 작지 않으면 순환 호출을 이용하여 다시 분할 정복 방법을 적용한다.
  3. 결합(Combine) : 정렬된 부분 배열들을 하나의 배열에 합병한다.


  • 2개의 정렬된 리스트를 합병(merge)하는 과정
  1. 2개의 리스트의 값들을 처음부터 하나씩 비교하여 두 개의 리스트의 값 중에서 더 작은 값을 새로운 리스트(sorted)로 옮긴다.
  2. 둘 중에서 하나가 끝날 때까지 이 과정을 되풀이한다.
  3. 만약 둘 중에서 하나의 리스트가 먼저 끝나게 되면 나머지 리스트의 값들을 전부 새로운 리스트(sorted)로 복사한다.
  4. 새로운 리스트(sorted)를 원래의 리스트(list)로 옮긴다.

병합정렬 구현

public static int[] list; // 정렬할 배열
public static int[] sorted; // 정렬된 결과를 저장할 임시 배열

public static void main(String[] args){
  list = new int[]{};  // 정렬할 배열 입력
  sorted = new int[list.length];  // 임시배열 선언
  
  // end 지점의 원소까지 검사하기 때문에 list.length-1! (list.length로 실행하면 ArrayIndexOutOfBoundsException 에러 발생)
  mergeSort(0, list.length-1);
}

public static void mergeSort(int start, int end) { 
  if (start < end) { 
    // 중간 위치를 계산하여 리스트를 균등 분할 - 분할(Divide)
    int mid = (start + end) / 2;
    
    mergeSort(start, mid);  // 앞쪽 부분 리스트 정렬 - 정복(Conquer)
    mergeSort(mid+1, end);  // 뒤쪽 부분 리스트 정렬 - 정복(Conquer)
    
    // 정렬된 2개의 부분 배열을 합병하는 과정 - 결합(Combine)
    int left = start;
    int right = mid + 1;
    int idx = left;
    
    while (left <= mid || right <= end) { // 안정 정렬이기 때문에 index를 유지 
      // 첫번째 분할에서 원소를 가져오는 경우
      // 1. 두번째 분할의 원소를 이미 다 가져온 경우
      // 2. 첫번째 분할에서 가져오지 않은 원소가 있고, 첫번째 분할의 첫 원소 값이 두번째 분할의 첫 원소 값보다 작은 경우
      if (right > end || (left <= mid && list[left] <= list[right])) {  
        sorted[idx++] = list[left++]; 
      } else { // 두번째 분할에서 원소를 가져오는 경우
        sorted[idx++] = list[right++]; 
      } 
    } 
    
    // 정렬을 마친 후 다시 원래 배열에 저장
    for (int i=start;i<=end;i++) { 
      list[i] = sorted[i]; 
    } 
  } 
}

병합정렬의 특징

장점 👍

  • 안정적인 정렬방법
    • 데이터의 분포에 영향을 덜 받는다. 즉, 입력데이터가 무엇이든 정렬되는 시간은 동일하다.(O(nlogn)으로 동일)
  • 만약 레코드를 연결 리스트(Linked List) 로 구성하면, 링크 인덱스만 변경되므로 데이터의 이동은 무시할 수 있을 정도로 작아진다.
    • 제자리 정렬(in-place sorting)로 구현할 수 있다.
  • 따라서 크기가 큰 레코드를 정렬할 경우 연결 리스트를 사용한다면, 병합정렬은 다른 어떤 정렬 방법보다 효율적이다.

단점 👎

  • 만약 레코드를 배열(Array) 로 구성하면, 임시 배열이 필요하다.
    • 제자리 정렬(in-place sorting)이 아니다.
  • 레코드들의 크기가 큰 경우에는 이동 횟수가 많으므로 매우 큰 시간적 낭비를 초래한다.

제자리 정렬(in-place sorting)이란?

주어진 메모리 공간 외에 추가적인 공간을 필요로 하지 않는 정렬 방식


연결 리스트로 병합정렬을 구현하는 경우

1. 쪼갠다(재귀적으로)

  • 각 단계에서 연결 리스트를 2개의 연결 리스트로 분리시켜준다.
    head -> Node1 -> Node2 -> Node3 -> Node4 이면
    head -> Node1 -> Node2, head2 -> Node3 -> Node4 으로.

2. 정렬하면서 합친다.

  • 노드의 Next를 변경해주는 것을 통해 정렬하면서 합친다.
  • 합치는 과정은 head가 2개였던 것을 1개로 합쳐주며 이뤄짐.

관련 문제

백준 1517번 버블 소트


이진 탐색트리, 우선순위 큐와 힙

이진 탐색트리

이진 탐색 트리(BST, Binary Search Tree)는 이진 트리 기반의 탐색을 위해 특정한 조건들로 이루어진 이진 트리입니다.

  1. 모든 노드의 키(Key)는 유일하다

    • Key는 노드에 기록한 데이터 값을 말합니다. 중복된 데이터 값을 갖는 노드가 없어야 합니다.
  2. 왼쪽 서브 트리의 키들은 루트의 키보다 작다.

    • 루트 노드의 데이터가 10이라면, 왼쪽 서브트리에는 10보다 작은 값들만 존재해야 합니다.
  3. 오른쪽 서브 트리의 키들은 루트의 키보다 크다.

    • 마찬가지로 오른쪽 서브트리에는 10보다 큰 값들만 존재해야 합니다.
  4. 왼쪽과 오른쪽 서브 트리도 이진 탐색 트리이다.

    • 1~3의 조건들이 각 서브트리에 순환적으로 적용되어야 합니다.

이진 탐색 트리는 이진 암호화, 파일 시스템에 주로 쓰입니다.

이진 탐색트리 구현

검색

이진 탐색트리에서 60을 찾는 과정은 다음과 같습니다.

  1. 루트부터 탐색을 시작합니다.
  2. 목표 값과 현재 루트의 데이터를 비교합니다. 목표 값 < 현재 값이면 왼쪽, 반대면 오른쪽으로 이동합니다.
  3. 일치하는 값을 찾으면 탐색을 멈춥니다.
  4. 만약 목표 값이 없다면 null을 리턴합니다.

public Node findNode(int key) {
    // 트리가 비었을 때
    if (root == null) return null;

    Node focusNode = root;

    while (focusNode.key != key) {
        if (key < focusNode.key) {              // 현재노드보다 작으면
            focusNode = focusNode.leftChild;    // 왼쪽으로
        } else {                                // 크면
            focusNode = focusNode.rightChild;   // 오른쪽으로
        }

        // 찾으려는 노드가 없을 때
        if (focusNode == null)
            return null;
    }

    return focusNode;
}

삽입

이진 탐색트리에 값을 삽입할 때 삽입할 위치를 찾는 과정은 값을 검색하는 과정과 유사하게 진행됩니다. 10을 삽입하는 과정은 다음과 같습니다.

  1. 루트에서 시작합니다.
  2. 삽입 값을 현재 루트의 값과 비교합니다. 삽입 값 < 현재 값이면 왼쪽, 반대면 오른쪽으로 이동합니다.
  3. null을 만나면 그 이전 루트에 연결하여 값을 삽입합니다.

값을 검색하는 과정에서 null값을 만났을 때 null을 리턴 했다면, 삽입할 때는 그 곳에 값을 삽입하게됩니다.

public void addNode(int key) {
    if (findNode(key) != null) return;  // 이미 존재하면 그냥 리턴

    Node newNode = new Node(key);

    if (root == null) {
        root = newNode; // 트리가 비어있으면 root 에 삽입
    } else {
        Node focusNode = root;  //  탐색용 노드
        Node parent;            //  탐색용 노드의 부모 노드

        while(true) {
            parent = focusNode; //  이동

            if (key < parent.key) {             //  삽입하려는 키가 현재 노드보다 작으면
                focusNode = parent.leftChild;   //  왼쪽으로 이동

                if (focusNode == null) {        //  왼쪽 노드가 비어있으면
                    parent.leftChild = newNode; //  왼쪽 노드에 삽입
                    return;
                }
            } else {                            //  삽입하려는 키가 현재 노드와 같거나 크다면
                focusNode = parent.rightChild;  //  오른쪽으로 이동

                if (focusNode == null) {        //  오른쪽 노드가 비어있으면
                    parent.rightChild = newNode;//  오른쪽 노드에 삽입
                    return;
                }
            }
        }
    }
}

삭제

이진 탐색 트리에서 값을 삭제할 때에도 삭제할 값을 검색하는 과정을 거칩니다. 그리고 삭제할 값을 찾았을 때 3가지 경우를 고려해야 합니다.

  1. 삭제할 노드가 leaf 노드인 경우

    이 경우에는 해당 노드를 삭제하면 됩니다.

  2. 삭제할 노드에 자식이 하나만 있는 경우

    이 경우에는 삭제할 노드의 자식 노드를 삭제할 노드의 부모 노드에 연결한 뒤 삭제하면 됩니다.

  1. 삭제할 노드에 자식이 둘 있는 경우

    이 경우에는 이전보다 다소 복잡해집니다. 이 때는 삭제할 노드의 왼쪽 서브 트리에 있는 값 중 가장 큰 값, 또는 오른쪽 서브 트리에 있는 값 중 가장 작은 값 중 하나를 삭제할 노드의 부모 노드에 연결해야 합니다.

​ 이렇게 하는 이유는 이진 탐색 트리의 규칙을 지킬 수 있는 최선의 방법이기 때문입니다.

​ 루트의 왼쪽 서브트리의 가장 오른쪽 값은 루트보다 작은 가장 가까운 수이며, 오른쪽 서브트리 의 가장 왼쪽 값은 루트보다 큰 가장 가까운 수입니다.

​ 50을 삭제하는 경우를 봅시다. 60은 루트 노드의 오른쪽 서브 트리의 가장 왼쪽 값입니다. 이 값 을 루트의 자리에 놓고 50을 60이 있던 자리에 넣은 뒤 자식이 없는 노드를 삭제할 때 처럼 삭제 하면 됩니다.

public boolean deleteNode(int key) {
    // focusNode 와 parent 가 같을 수 있는 경우는 찾으려는 key 가 root 인 경우
    Node focusNode = root;
    Node parent = root;

    boolean isLeftChild = true;

    // while 문이 끝나고 나면 focusNode 는 삭제될 노드를 가리키고, parent 는 삭제될 노드의 부모노드를 가리키게 되고, 삭제될 노드가 부모노드의 left 인지 right 인지에 대한 정보를 가지게 된다
    while(focusNode.key != key) {
        parent = focusNode; // 삭제할 노드를 찾는 과정중(while문)에서 focusNode 는 계속해서 바뀌고 parent 노드는 여기서 기억해둔다

        if(key < focusNode.key) {
            isLeftChild = true;             // 지우려는 노드가 왼쪽에 있는 노드냐 기록용
            focusNode = parent.leftChild;
        } else {
            isLeftChild = false;            // 지우려는 노드가 오른쪽에 있는 노드냐 기록용
            focusNode = parent.rightChild;
        }

        // 찾으려는 노드가 없는 경우
        if(focusNode == null) {
            return false;
        }
    }


    Node replacementNode;
    // 지우려는 노드의 자식 노드가 없는 경우
    if(focusNode.leftChild == null && focusNode.rightChild == null) {
        if (focusNode == root)
            root = null;
        else if (isLeftChild)
            parent.leftChild = null;
        else
            parent.rightChild = null;
    }
    // 지우려는 노드의 오른쪽 자식노드가 없는 경우 (왼쪽 자식 노드만 있는 경우)
    else if(focusNode.rightChild == null) {
        replacementNode = focusNode.leftChild;

        if (focusNode == root)
            root = replacementNode;
        else if (isLeftChild)
            parent.leftChild = replacementNode;
        else
            parent.rightChild = replacementNode;
    }
    // 지우려는 노드의 왼쪽 자식노드가 없는 경우 (오른쪽 자식 노드만 있는 경우)
    else if (focusNode.leftChild == null) {
        replacementNode = focusNode.rightChild;
        if (focusNode == root)
            root = replacementNode;
        else if (isLeftChild)
            parent.leftChild = replacementNode;
        else
            parent.rightChild = replacementNode;
    }
    // 지우려는 노드의 양쪽 자식노드가 모두 있는 경우
    // 오른쪽 자식 노드의 sub tree 에서 가장 작은 노드를 찾아서 지우려는 노드가 있던 자리에 위치시킨다
    else {
        // 삭제될 노드의 오른쪽 sub tree 를 저장해둔다
        Node rightSubTree = focusNode.rightChild;

        // 삭제될 노드 자리에 오게 될 새로운 노드 (오른쪽 sub tree 에서 가장 작은 값을 가진 노드)
        // 이 노드는 왼쪽 child 가 없어야 한다 (가장 작은 값이기 때문에)
        replacementNode = getRightMinNode(focusNode.rightChild);

        if (focusNode == root)
            root = replacementNode;
        else if (isLeftChild)
            parent.leftChild = replacementNode;
        else
            parent.rightChild = replacementNode;

        replacementNode.rightChild = rightSubTree;
        // 지우려는 노드의 오른쪽 sub tree 에 노드가 하나밖에 없는 경우
        if (replacementNode == rightSubTree) 
            replacementNode.rightChild = null;

        replacementNode.leftChild = focusNode.leftChild; // 지우려는 노드의 왼쪽 sub tree 를 연결시킨다
    }

    return true;
}

private Node getRightMinNode(Node rightChildRoot) {
    Node parent = rightChildRoot;
    Node focusNode = rightChildRoot;

    while (focusNode.leftChild != null) {
        parent = focusNode;
        focusNode = focusNode.leftChild;
    }

    parent.leftChild = null;
    return focusNode;
}

시간 복잡도

이진 탐색 트리의 삽입, 검색, 삭제에 대한 시간 복잡도는 균형 상태이면 O(logN), 불균형 상태라면 최대 O(N) 입니다.

이진 탐색트리가 중복된 데이터를 갖지 않는 이유

이진 탐색 트리는 목표 데이터 값의 빠른 탐색을 위한 자료구조입니다. 만약 이진 탐색 트리가 중복된 데이터 값을 가진다면 이진 탐색 트리에서 데이터를 탐색할 때 두가지 경우를 생각해야 할 것입니다.

  1. 처음으로 발견되는 노드만 찾고, 중복 노드가 존재할 가능성을 무시한다.
    • 그렇다면 이진 탐색트리는 전혀 사용되지 않을 노드를 갖게 될 것입니다. 이는 메모리 낭비에 불과하며 잘못된 자료구조입니다.
  2. 중복 노드가 존재할 가능성이 있으므로 중복 노드를 모두 탐색한다.
    • 그렇다면 일치하는 데이터를 찾았지만 중복 노드를 찾기 위해서 계속 탐색을 진행할 것입니다. 이는 시간의 낭비이며 빠른 탐색에 맞지 않는 자료구조입니다.

관련 문제

백준 5639 이진 검색 트리

백준 18240 이진 탐색 트리 복원하기

우선순위 큐와 힙

우선순위 큐

일반적으로 큐(Queue)는 먼저 들어온 데이터가 먼저 나가는 선입선출(First in-First out) 구조입니다. 하지만, 우선순위 큐는 데이터가 들어온 순서가 아닌 데이터의 우선 순위가 높은 데이터가 먼저 나가는 큐를 말합니다. 우선순위 큐는 주로 힙(Heap)이라는 자료구조로 구현합니다. 또한 우선순위 큐는 데이터의 우선 순위를 비교해야 하므로 그 데이터는 Comparable 하거나 comparator를 생성자에 넣어서 비교 연산이 가능하도록 해야 합니다.

힙(Heap)은 완전 이진 트리에 있는 노드 중에서 키 값이 가장 큰 노드나 가장 작은 노드를 찾게 만든 자료구조입니다. 힙은 이진탐색트리와 달리 중복된 값이 허용됩니다.

  • 최대 힙
    • 키 값이 가장 큰 노드를 찾기 위한 완전 이진 트리
    • 부모 노드의 키 값 >= 자식 노드의 키 값
    • 루트 노드: 키 값이 가장 큰 노드

  • 최소 힙
    • 키 값이 가장 작은 노드를 찾기 위한 완전 이진 트리
    • 부모 노드의 키 값 < 자식 노드의 키 값
    • 루트 노드: 키 값이 가장 작은 노드
  • 힙에서는 루트 노드의 원소만을 삭제 할 수 있습니다.
    • 루트 노드의 원소를 삭제하여 반환한다.
    • 루트 노드를 다시 구한다.

힙 구현

힙은 일반적으로 배열을 이용하여 구현합니다. 힙은 완전 이진 트리이므로 중간에 비어있는 요소가 없기 때문에 배열의 공간을 모두 사용하기 때문입니다.

자식노드를 구하고 싶을 때

  • 왼쪽 자식노드 index = (부모 노드 index) * 2
  • 오른쪽 자식노드 index = (부모 노드 index) * 2 + 1

부모노드를 구하고 싶을 때

  • 부모 노드 index = (자식노드 index) / 2

삽입

힙에 데이터를 삽입하는 방법은 다음과 같습니다.

  1. 완전 이진트리의 마지막 노드에 이어서 새로운 노드를 추가한다.
  2. 추가된 새로운 노드를 부모의 노드와 비교하여 교환한다.
  3. 힙 구조를 만족할 때 까지 2를 반복한다.

힙에 3을 삽입하는 과정은 다음과 같습니다.

삭제

힙에서 데이터를 삭제하는 방법은 다음과 같습니다.

  1. 루트 노드가 가장 우선 순위가 높으므로 루트 노드를 삭제한다.
  2. 루트 노드가 삭제된 빈자리에 완전 이진트리의 마지막 노드를 가져온다.
  3. 루트 자리에 위치한 새로운 노드를 자식 노드와 비교하여 교환한다.
  4. 힙 구조를 만족할 때 까지 3을 반복한다.

힙의 시간 복잡도와 우선순위 큐를 힙으로 구현하는 이유

우선순위 큐 == 힙이 아닙니다. 힙은 우선순위 큐를 구현할 수 있는 여러 자료구조 중 하나입니다. 하지만 우선순위 큐는 보통 힙으로 구현합니다. 그 이유는 시간 복잡도에서 가장 유리하기 때문입니다.

만약 배열로 구현한다면 우선 순위가 높은 순서대로 배열의 가장 앞부분부터 넣을 때 우선 순위가 높은 데이터는 맨 앞의 index를 이용하는 것으로 쉽게 찾을 수 있습니다. 하지만 우선 순위가 중간인 데이터를 삽입한다면 삽입하는 위치를 찾고, 뒤의 데이터를 모두 한 칸 씩 밀어야 합니다. 그러므로 정렬된 배열에서는 삽입은 O(n), 삭제는 O(1)의 시간 복잡도를 가집니다. 정렬되지 않은 배열이라면 삽입은 O(1)이고, 삭제할 때 삭제할 값을 찾아야 하므로 O(n)의 시간 복잡도를 가집니다.

연결리스트로 구현할 때도 배열과 크게 다르지 않습니다. 정렬 유무에 따라 배열과 같은 시간 복잡도를 가집니다.

하지만 힙으로 구현한다면 삽입과 삭제과정에서 모두 부모 노드와 자식 노드 간의 비교만 이뤄지므로 O(log2n)의 시간 복잡도를 가집니다.

정리하면 배열과 연결리스트는 정렬 여부에 따라 삽입과 삭제에서 O(n)과 O(1)의 시간 복잡도를 가지게 되고, 힙은 일관적으로 O(log2n)의 시간복잡도를 가집니다. 그래서 편차가 심한 배열과 연결리스트 보다는 힙으로 구현을 합니다.

관련 문제

백준 11279 최대 힙

백준 1655 가운데를 말해요

백준 11000 강의실 배정

OS 주 메모리 정리했습니다!

주 메모리와 가상 메모리

주 메모리(Main Memory)

메모리는 주소가 할당된 일련의 워드 또는 바이트로 구성됩니다. CPU는 메모리로부터 다음 수행할 명령어를 가져오기때문에 메모리를 관리하는 것은 매우 중요한 일입니다.

주 메모리와 관련된 배경 지식

기본 하드웨어

  • 주 메모리 및 레지스터 : CPU가 접근할 수 있는 유일한 저장장치입니다. 따라서 모든 명령어와 자료들은 CPU가 직접 접근할 수 있는 주 메모리와 레지스터에 존재해야 합니다.
  • 베이스(base) 레지스터 : 각 프로세스는 독립된 메모리 공간을 갖습니다. 따라서 특정 메모리 공간에는 특정 프로세스만 접근할 수 있도록 합법적인 메모리 주소 영역을 설정해야 합니다. 가장 작은 합법적인 물리 메모리 주소 값을 저장하는 것이 베이스 레지스터입니다.
  • 상한(limit) 레지스터 : 주어진 영역의 크기를 저장합니다
  • 만약 베이스 레지스터 값이 300040 이고 상한 레지스터 값이 120900이라면 프로그램은 300040에서 420940까지의 모든 주소에 접근할 수 있습니다.

주소의 할당(Address Binding)

이진 실행 파일 형태의 프로그램디스크에 존재하고 있다가 프로그램이 실행되면 주 메모리로 올라와 프로세스가 됩니다. 프로세스는 실행 동안 디스크와 주 메모리 사이를 이동할 수 있는데 이를 위해 프로세스가 사용했던 기억 공간(주소) 할당이 필요합니다.

메모리 주소 공간에서 명령어와 자료의 할당 시점은 다음과 같이 분류됩니다.

컴파일 시간 할당(Compile Time Binding)

컴파일 시 프로세스가 메모리 내 진입할 위치를 알 수 있습니다.

적재 시간 할당(Load Time Binding)

컴파일 시 위치를 알 수 없다면 컴파일러는 일단 이진 코드를 재배치 가능 코드로 만들고, 심볼과 번지수 할당은 프로그램이 주 메모리에 적재되는 시간에 이루어집니다.

실행 시간 할당(Execution Time Binding)

프로세스가 실행하는 중간에도 메모리 내의 한 세그먼트에서 다른 세그먼트로 이동할 수 있는 경우를 말합니다.

논리 주소 공간과 물리 주소 공간

CPU가 생성하는 주소를 논리 주소(Logical Address), 메모리가 취급하는 주소(메모리 주소 레지스터에 주어지는 주소)를 **물리 주소(Physical Address)**라고 합니다.

컴파일 시간 바인딩과 적재 시간 바인딩은 논리 주소와 물리 주소가 같지만, 실행 시간 바인딩은 다릅니다. 따라서 프로그램 실행 중에는 가상 주소(논리 주소)를 물리 주소로 바꿔줘야 하고, 이 변환을 위한 기법에는 재배치(relocation) 레지스터가 있습니다.

사용자 프로그램의 경우 실제적인 물리 주소를 절대 알 수 없습니다. 사용자 프로그램은 오직 논리 주소를 사용한 것이고, 메모리 하드웨어 상 주소는 논리 주소를 물리 주소로 바꾼 것입니다.

동적 적재(Dynamic Loading)

프로세스는 실행되기 전 모든 자료가 미리 메모리에 올라가 있어야하는데, 만약 메모리 크기가 프로세스 크기보다 작다면 이는 불가능합니다. 이를 해결하기 위한 것이 동적 적재입니다.

동적 적재는 각 루틴을 재배치 가능 상태로 만들어 디스크에서 대기시킨 뒤, 메모리에 올라가 있는 주 프로그램이 해당 루틴을 필요로 해 호출한 시기에 재배치 가능 연결 적재기(relocatable linking loader)를 이용해 주 메모리로 올립니다.

따라서 사용되지 않는 루틴은 결코 미리 적재되지 않기때문에 메모리를 절약할 수 있습니다.

동적 연결과 공유 라이브러리(Dynamic Linking & Shared Library)

시스템 라이브러리를 실제 해당 라이브러리를 실행할 때에 동적으로 연결하는 것을 말합니다.

프로그램 실행 시 시스템 라이브러리를 부르면 스텁(stub)이라는 작은 코드 조각이 생깁니다. 이 스텁을 이용해 필요로 하는 라이브러리가 디스크와 메모리 중 어디에 존재하는지 번지수를 찾고, 다음번에는 기억했던 번지수를 이용해 쉽게 라이브러리 루틴을 수행할 수 있습니다.

주 메모리 할당 기법

스와핑(Swapping)

프로세스는 실행 시 주 메모리에 올라와 있어야 하지만 라운드 로빈과 같은 CPU 스케줄링 시 잠시 보조 메모리로 보내졌다가 돌아오게될 수도 있습니다. 이렇게 프로세스를 주 메모리와 예비 저장 장치 사이에서 이동시키는 것을 스와핑이라고 합니다. 보조 메모리는 주로 디스크를 사용합니다.

주 메모리에서 보조 메모리로 이동하는 것을 스왑 아웃(swap out), 보조 메모리에서 주 메모리로 이동하는 것을 스왑 인(swap in)이라고 합니다.

어셈블리 혹은 적재 시간 바인딩 프로세스의 경우 동일한 주소로 스왑되어야 하지만 실행 시간 바인딩 프로세스의 경우 빈 메모리의 어떤 공간으로도 스왑될 수 있습니다.

스와핑은 문맥 교환 시간(context-switch time) 상당히 걸리므로, 프로세스를 라운드 로빈 스케줄링 기법으로 관리한다면 시간 할당량은 문맥 교환 시간보다 커야겠죠.

스왑을 할 프로세스는 반드시 유휴 상태여야 합니다. 만약 입/출력 장치와 신호를 주고받던 프로세스를 스왑하게 된다면 잘못된 입/출력 내용이 저장되거나 다른 프로세스에게 저장될 수도 있습니다.

일반적으로 디스크 내의 스왑공간은 파일 시스템과 별도로 할당되도록 하여 스왑 시간을 단축할 수 있습니다.

다중 분할 할당

메모리는 일반적으로 두 부분으로 나눌 수 있습니다. 하나는 메모리에 상주하는 운영체제를 위한 것이고, 나머지는 사용자 프로세스를 위한 것입니다. 운영체제를 위한 메모리는 주로 인터럽트 벡터가 위치한 0번지 근처(하위 메모리)에 위치시킵니다.

사용자 프로세스를 위한 메모리 영역은 다시 여러 프로세스를 위해 다중으로 분할되어 할당되어야 합니다. 이를 위한 기법으로는 고정 분할 할당(Multiple Contiguous Fixed Partition Aloocation, MFT)과 가변 분할 할당(Multiple Contiguous Variable Partition Aloocation, MVT) 기법이 있습니다.

  1. 고정 분할 할당 기법(정적 분할(Static Allocation) 기법)
  • 메모리를 똑같은 고정된 크기로 분할하여 각 분할을 하나의 프로세스에게만 할당하는 방법
  • 각 분할 개수는 다중 프로그래밍 정도
  • 한 분할이 비게 되면 입력 큐의 다른 프로세스가 해당 분할을 차지
  • 주로 초기 운영체제에 사용됨
  1. 가변 분할 할당 기법(동적 분할(Dynamic Allocation) 기법)
  • 미리 주 메모리를 분할하는 것이 아니라, 적재 시 필요한 만큼 크기로 영역을 분할하는 방법
  • 주 메모리의 어떤 부분이 얼마나 사용되고 있는지 파악하는 테이블을 관리
  • 메모리 내에 여러 크기 공간이 산재되어 있으므로 이 공간을 쪼개어 사용하거나 인근 공간을 합쳐 큰 공간으로 만드는 등의 과정을 고려해야 함

동적 공간 할당 문제

위의 가변 분할 할당 기법에서는 프로세스의 메모리 요구량에 따라 산재되어있는 주 메모리를 쪼개어 사용할 지, 아니면 합쳐 사용할 지 결정해야 합니다. 즉, 일련의 메모리 공간과 프로세스 리스트를 어떻게 할당할 지 결정하는 문제를 동적 공간 할당 문제라고 합니다.

동적 공간 할당 문제를 해결하는 방법에는 다음과 같은 세 가지 해결책이 존재합니다.

  1. 최초 적합
  • 첫번쨰 사용 가능한 공간을 할당
  • 메모리 검색 시 할당이 가능한 충분히 큰 자유 공간을 찾으면 검색 종료
  1. 최적 적합
  • 사용 가능한 공간 중 가장 작은 것 선택
  • 리스트가 오름차순으로 정렬되어 있어야 함
  • 아주 작은 나머지 공간 발견
  1. 최악 적합
  • 사용 가능한 공간 중 가장 큰 것 선택
  • 역시 리스트가 정렬되어 있어야 함

일반적으로는 최초 적합과 최적 적합이 최악 적합에 비해 시간, 메모리 효율이 뛰어나다고 알려져 있습니다.

단편화(Fragmentation)

메모리 할당 시 메모리 공간 중 일부는 사용자에 의해 사용될 수 없는 빈 공간이 존재하는 문제가 발생할 수 있습니다. 이런 빈 공간을 단편화라고 합니다.

단편화는 전체 주 메모리 중 듬성 듬성 비어있는 공간인 외부 단편화와, 사용자에게 할당된 주 메모리 공간 중 넉넉하게 할당되어 사용되지 않는 공간인 내부 단편화로 나눌 수 있습니다.

  1. 외부 단편화
  • 주 메모리 중 프로세스에게 할당되지 않은 프로세스와 프로세스 사이 틈새의 작은 자유 공간
  • 이 자유 공간을 합쳐 하나의 큰 자유 공간을 만든다면 더 많은 프로세스를 할당할 수 있음
  • 단편화의 크기와 메모리의 빈 공간 위치에 따라 최초 적합과 최적 적합 기법을 적용할 수 있음
  • 또한 압축을 통해 메모리의 모든 내용을 한 부분으로 몰고 나머지 자유 공간을 여유롭게 사용할 수도 있음
  • 하지만 이를 위해서는 프로세스 내의 모든 주소들이 동적으로 재배치되어야 하고, 실행시간에 동적으로 바인딩 되는 경우에만 적용 가능함
  1. 내부 단편화
  • 프로세스가 요구하는 공간보다 할당된 공간이 약간 더 클 때 생기는 자유 공간
  • 프로세스가 요구하는 공간을 정확히 할당해주는 것보다 약간 더 크게 할당해주고 공간을 낭비하는 것이 어떤 면에서는 비용적으로 더 효율적일 수 있음

페이징

주 메모리와 같이 디스크(보조 메모리) 역시 논리 주소의 할당 문제, 단편화 문제가 발생합니다. 하지만 디스크는 주 메모리보다 속도가 느리기때문에 압축과 같은 방법으로 주소 할당 문제를 해결하기 어렵습니다. 이를 해결하기 위한 방법이 바로 페이징입니다.

페이징은 논리 주소 공간이 한 연속적인 공간에 모여있어야 한다는 제약을 없앴습니다. 따라서 프로세스를 주 메모리에서 디스크로 넘길 때 프로세스의 크기에 맞는 연속된 공간을 찾지 않아도 됩니다.

페이징 원리

물리 메모리는 프레임(frame), 논리 메모리는 페이지(page)라 불리는 동일한 크기의 여러 블록으로 나누어져 있습니다.

CPU에서 나오는 모든 주소는 **페이지 번호(p)**와 **페이지 변위(d: offset)**로 나뉩니다. 페이지 테이블은 주 메모리 내의 페이지 점유 주소를 표현하고 있는데, 페이지 번호로 이 테이블에 엑세스하여 페이지 주소를 파악할 수 있습니다. 페이지 주소에 페이지 변위를 더한 것이 물리주소가 됩니다.

paging

페이징 역시 동적 재배치의 한 형태입니다. 페이징을 사용할 경우 모든 유휴 상태 프레임이 프로세스에 할당될 수 있으므로 외부 단편화는 발생하지 않습니다. 하지만 내부 단편화를 피할 수는 없습니다. 공간 할당이 늘 프레임의 정수 배로 할당되기 때문입니다. 이를 고려했을 때는 작은 페이지 크기가 바람직하지만, 이에 반비례하여 페이지 테이블의 크기가 커지는 문제가 발생하므로 디스크 입장에서는 페이지 크기가 클 수록 효율적입니다.

프로세스가 실행을 위해 큐에 도착하면 몇 개의 페이지를 필요로 하는지 확인합니다. 한 페이지는 한 개의 프레임을 필요로 하므로, 프로세스가 n개의 페이지를 요구하면 메모리 내에 n개의 프레임이 존재해야 합니다. 프로세스는 페이지가 할당된 프레임 중 하나에 적재되고 이 프레임 번호는 페이지 테이블에 기록되는 방식으로 할당합니다.

페이징에서 중요한 것은 일반적인 사용자의 인식과 달리, 논리 주소와 실제 물리 주소에는 차이가 존재한다는 것입니다. 언뜻 보면 메모리는 하나의 연속적인 공간이고 하나의 프로그램이 메모리를 점유하는 것처럼 보이지만, 실제로는 다양한 프로그램들이 메모리의 여러 곳에 프레임 단위로 분산되어 있습니다. 따라서 운영체제는 논리 주소를 매핑하여 물리 주소로 스스로 연결하고, 각 프로세스는 페이지 테이블 내의 주소만 접근하므로 자신의 메모리가 아닌 다른 프로세스의 메모리에 접근할 수 없습니다. 페이지 테이블과 같이 운영체제가 물리 메모리 할당에 대한 파악을하기 위한 목적으로 프레임 테이블을 구성하고 있습니다.

페이지 테이블 저장 방법

  • 레지스터 집합 구현 : 효율적인 페이징 주소 변환. 단, 페이지 테이블이 작을 경우 적합.
  • 페이지 테이블 기준 레지스터(PTBR) : 주 메모리에 저장한 페이지 테이블을 가리키도록 하는 레지스터. 페이지 테이블 교환 시 레지스터만 변경하면 되므로 문맥 교환 시간 단축 가능. 단, 메모리 접근 시간이 많이 걸림.

페이지 테이블에 의한 메모리 보호

페이지 테이블에는 메모리를 보호하기 위한 보호 비트가 구현되어 있습니다. 보호 비트는 해당 페이지가 읽기 전용 페이지인지, 아니면 읽기 쓰기가 가능한 페이지인지를 정의하고 있습니다. 따라서 부적잘한 접근을 막을 수 있습니다.

또한 프로세스의 합법적인 페이지인지 여부를 나타내는 유효/무효(valid/invalid) 비트가 존재하여 메모리를 보호합니다.

공유 페이지

페이지의 큰 장점 중 하나는 코드를 쉽게 공유할 수 있다는 것입니다. 재진입 가능 코드로 구현한다면 여러 프로세스에 의해 접근될 수 있습니다.

공유 페이지는 쓰레드 주소 공간 공유와 유사하며, 프로세스 간 상호 통신의 방법으로 공유됩니다.

페이지 테이블의 구조

계층적 페이징

  • 페이지 테이블 자체가 다시 페이지화되는 것(2단계 페이징 기법)
  • 주소 변환이 바깥 페이지 테이블에서 시작하여 안쪽으로 들어오는 형식(포워드 매핑 페이지 테이블)
  • 페이지 테이블을 나눔으로써 운영체제는 프로세스가 각 분할들을 실제로 필요로 할 때까지 사용하지 않은 채 남겨둘 수 있음
  • 단, 논리 주소 사상 시 너무 많은 메모리를 접근하게 되므로 비현실적인 상황에 도달할 수 있음

해시형 페이지 테이블

  • 해시 값이 가상 페이지 번호가 됨
  • 가상 주소 공간으로부터 페이지 번호를 받으면 해싱 시도
  • 해시형 페이지 테이블에서 연결 리스트를 따라가며 첫번째 원소와 가상 페이지 번호를 비교. 일치할 경우 페이지 프레임 번호를 가져와 물리 주소를 얻고, 아닐 경우 연결리스트 다음 원소로 탐색
  • 클러스터형 페이지 테이블은 해시형에서 조금 발전하여 각 항목이 여러개 페이지를 가리키도록 함
  • 클러스터형은 메모리 접근이 불연속적이고 전 주소공간으로 넓게 퍼져 나오는 경우에 유리

역 페이지 테이블

  • 메모리 프레임마다 한 항목씩을 할당(페이지마다 하나의 항목을 가지는 것을 반대로 함)
  • 각 항목 별로 해당 프레임에 올라와 있은 페이지 주소, 페이지를 소유하고 있는 프레르스의 ID가 표시되어 있음
  • 따라서 시스템에는 단 하나의 페이지 테이블이 존재하고, 테이블 내 각 항목은 메모리 프레임을 하나씩 가리키게 됨
  • 논리 페이지마다 항목을 가지는 대신 물리 프레임에 대응되는 항목만 테이블에 저장하므로 메모리 공간을 절약할 수 있음
  • 단, 반대로 주소 변환 비용이 많이 듦. 테이블에서 가상 페이지 주소를 탐색하기 위해서는 테이블 전체를 탐색해야 함.
  • 하나의 물리영역에 여러 개의 가상 주소를 매핑할 수 없으므로 메모리 공유가 어려움

세그먼테이션(Segmentation)

앞서 언급했듯이 사용자의 메모리 관점과 실제 물리 메모리 관점은 다릅니다. 사용자(프로그래머)는 종종 프로그램을 작성하면서 함수나 모듈, 자료구조에 대해 메모리의 몇번째 주소로부터 몇 개의 바이트를 이용한다는 식으로 생각합니다. 이러한 사용자 관점에서의 메모리 관리를 가능하게 하는 메모리 관리 기법을 세그먼테이션이라고 합니다.

구현

세그먼테이션에서는 논리 주소 공간을 세그먼트 집합으로 정의합니다. 각 세그먼트는 이름과 길이를 가집니다. 단, 세그먼트는 구현을 쉽게 하기 위해 내부에서 번호로 불립니다. 따라서 논리 주소는 (segment number, offset(변위))로 구성됩니다.

예를 들어 봅시다.
C언어 프로그램을 컴파일할 경우 컴파일러는 자동적으로 다음과 같은 세그먼트들을 만들어낼 것입니다.

  • 코드
  • 전역변수
  • 메모리 할당을 위한 힙(heap)
  • 각 쓰레드를 위한 스택(stack)
  • 표준 C 라이브러리
    이때 적재기는 위의 세그먼트들을 받아 각각 번호를 매겨줍니다.

사용자가 정의한 각 세그먼트의 2차원 주소(ex. (3번째 세그먼트, 18번째 바이트))는 실제 메모리 상에서 바이트의 1차원 구조로 구성되어 있으므로 이 둘을 올바르게 매핑하기 위해 세그먼트 테이블이 존재합니다.

세그먼트는 세그먼트 이름에 해당하는 세그먼트 번호와 세그먼트 내에서 몇 번째 인지 위치에 해당하는 **세그먼트 변위(offset)**로 표현됩니다. 이를 물리 주소로 매핑하기 위해 세그먼트 테이블은 세그먼트의 시작 주소를 나타내는 **세그먼트의 기준(base)**과 세그먼트의 길이를 나타내는 **세그먼트 한계(limit)**을 가지고 있습니다.

세그먼트 번호를 통해 세그먼트 테이블에 색인을할 수 있는데, 이때 변위가 0과 세그먼트 크기 사이의 값이 아니라면 오류로 인식되어 트랩(trap)이 발생할 수 있습니다.

힌트

Hint

아래 Hint에 대한 모든 내용들은 MySQL을 기준으로 작성되었음.

힌트는 옵티마이저의 실행 계획을 원하는대로 바꿀 수 있게 해준다.

옵티마이저라고 반드시 최선의 실행계획을 수립할 수는 없기 때문에, 조인이나 인덱스의 잘못된 실행 계획을 개발자가 직접 바꿀 수 있도록 도와주는 것이 힌트이다.

힌트의 문법이 올바르더라도 힌트가 반드시 받아 들여지는 것은 아니며, 옵티마이저에 의해 선택되지 않을 수도 있고 선택될 수도 있다.

Hint는 크게 2가지로 구분할 수 있다.

  1. 옵티마이저 힌트
  2. 인덱스 힌트

옵티마이저 힌트인덱스 힌트는 서로 다르며, 함께 사용할 수도 있고 별도로 사용할 수도 있다.

옵티마이저 힌트

옵티마이저를 제어하는 방법 중 하나는, optimizer_switch 시스템 변수를 설정하는 것 이다. 이는 모든 후속 쿼리 실행에 영향을 주기 때문에, 일반적인 사용자들에게는 권장되지 않은 방법이다.

그래서 옵티마이저를 더 세밀하게 선택적으로 제어해야 할 땐, 옵티마이저 제어를 원하는 부분을 지정할 수 있는 옵티마이저 힌트를 사용하는 것이다.

즉, 명령문의 한 테이블에 대한 최적화를 활성화하고 다른 테이블에 대한 최적화를 비활성화할 수 있다.

명령문 내의 옵티마이저 힌트는 optimizer_switch 보다 우선시 되어 적용된다.

옵티마이저 힌트는 다양한 범위 수준에서 적용된다.

  • 전역: 힌트가 전체 문에 영향을 줌
  • 쿼리 블록: 힌트가 명령문 내의 특정 쿼리 블록에만 영향을 줌
  • 테이블: 힌트가 쿼리 블록 내의 특정 테이블에민 영향을 줌
  • 인덱스: 힌트가 테이블 내의 특정 인덱스에만 영향을 줌

사용 방법

옵티마이저 힌트는 /*+ .... */주석 내에 지정해야 한다.

힌트 명 힌트 설명 적용 범위 수준
BKA, NO_BKA 일괄 처리된 키 액세스 조인 처리에 영향 쿼리 블록, 테이블
BNL, NO_BNL MySQL 8.0.20 이전: 블록 중첩-루프 조인 처리, MySQL 8.0.18 이상: 해시 조인 최적화, MySQL 8.0.20 이상: 해시 조인 최적화에만 영향 쿼리 블록, 테이블
DERIVED_CONDITION_PUSHDOWN, NO_DERIVED_CONDITION_PUSHDOWN 구체화된 파생 테이블에 대한 파생 조건 푸시다운 최적화 사용 또는 무시(MySQL 8.0.22에 추가) 쿼리 블록, 테이블
GROUP_INDEX, NO_GROUP_INDEX GROUP BY 작업(MySQL 8.0.20에 추가)에서 인덱스 검색에 대해 지정된 인덱스를 사용하거나 무시 인덱스
HASH_JOIN, NO_HASH_JOIN 해시 조인 최적화에 영향 (MySQL8.0.18만 해당) 쿼리 블록, 테이블
INDEX, NO_INDEX JOIN_INDEX, GROUP_INDEX 및 ORDER_INDEX의 조합으로 작동하거나 NO_JOIN_INDEX, NO_GROUP_INDEX 및 NO_ORDER_INDEX(MySQL 8.0.20에 추가)의 조합으로 작동 인덱스
INDEX_MERGE, NO_INDEX_MERGE 인덱스 병합 최적화에 영향 테이블, 인덱스
JOIN_INDEX, NO_JOIN_INDEX 모든 액세스 방법에 대해 지정된 인덱스 또는 인덱스를 사용하거나 무시(MySQL 8.0.20에 추가) 인덱스
JOIN_FIXED_ORDER FROM절에 지정된 순서대로(FIXED) 테이블을 조인하도록 지시 쿼리 블록
JOIN_ORDER 가능하다면 힌트에 지정된 순서대로 조인하도록 지시 쿼리 블록
JOIN_PREFIX 가장 먼저 조인을 시작 할 테이블 지정 쿼리 블록
JOIN_SUFFIX 가장 마지막으로 조인 할 테이블 지정 쿼리 블록
MAX_EXECUTION_TIME 구문 실행 시간 제한 전역 범위
MERGE, NO_MERGE 외부 쿼리 블록으로 병합되는 파생 테이블/뷰에 영향 테이블
MRR, NO_MRR 다중 범위 읽기 최적화에 영향 테이블, 인덱스
NO_ICP 인덱스 조건 푸시다운 최적화에 영향 테이블, 인덱스
NO_RANGE_OPTIMIZATION 범위 최적화에 영향 테이블, 인덱스
ORDER_INDEX, NO_ORDER_INDEX 행을 정렬하기 위해 지정된 인덱스 또는 인덱스 사용하거나 또는 무시(MySQL 8.0.20에 추가) 인덱스
QB_NAME 쿼리 블록에 이름 할당 쿼리 블록
RESOURCE_GROUP 구문을 실행하는 동안 리소스 그룹 설정 전역 범위
SEMIJOIN, NO_SEMIJOIN Semijoin 전략에 영향, MySQL 8.0.17부터는 anti조인에도 적용 쿼리 블록
SKIP_SCAN, NO_SKIP_SCAN 스킵 검색 최적화에 영향 테이블, 인덱스
SET_VAR 구문을 실행하는 동안 변수 설정 전역 범위
SUBQUERY 구체화에 영향, IN-to-EXISTS 하위 쿼리 전략 쿼리 블록
/*+ BKA(table1) */ 

/*+ BNL(table1, table2) */

/*+ NO_RANGE_OPTIMIZATION(table3 PRIMARY) */

/*+ QB_NAME(queryblock1) */

SELECT /*+ BNL(t1) BKA(t2) */ ...
// 하나의 쿼리 블록에서 여러 힌트를 사용할 땐, 하나의 힌트 주석안에 여러개의 힌트를 선언하여 사용해야 한다.
// 즉, SELECT /*+ BNL(t1) */ /* BKA(t2) */ ... 는 안됨.

옵티마이저 힌트는 SELECT, UPDATE, INSERT, REPLACE, DELETE문에서 아래와 같이 사용할 수 있다.

SELECT /*+ HINT */ ...
INSERT /*+ HINT */ ...
REPLACE /*+ HINT */ ...
UPDATE /*+ HINT */ ...
DELETE /*+ HINT */ ...

그리고 아래와 같이 쿼리 블록으로 구분하여 사용이 가능하다.

(SELECT /*+ ... */ ... )
(SELECT ... ) UNION (SELECT /*+ ... */ ... )
(SELECT /*+ ... */ ... ) UNION (SELECT /*+ ... */ ... )
UPDATE ... WHERE x IN (SELECT /*+ ... */ ...)
INSERT ... SELECT /*+ ... */ ...

조인 순서 최적화 힌트

MySQL 8.0은 이전 버전보다 훨씬 강력하고 편의성이 강한 Optimizer hint를 제공한다.

그 중 하나가 조인 순서 최적화 힌트이다.

조인 순서 최적화 힌트는 옵티마이저가 테이블을 조인하는 순서에 영향을 준다.

기존의 join 순서를 제어하던 STRAIGHT_JOIN 구문등은 사용상의 여러 문제를 만들어 냈지만, 조인 순서 최적화 힌트를통해 그러한 문제를 해결하게 되었다.

사용 방법

HINT_NAME([@query_block_name])
HINT_NAME([@query_block_name] TABLE_NAME [, tbl_name] ...)
HINT_NAME(TABLE_NAME[@query_block_name] [, TABLE_NAME[@query_block_name]] ...)

HINT_NAME에 올 수 있는 조인 순서 최적화 힌트는 4가지가 있다.

  • JOIN_FIXED_ORDER: FROM절에 지정된 순서대로(FIXED) 테이블을 조인하도록 지시 (STRAIGHT_JOIN의 힌트화)
  • JOIN_ORDER: 가능하다면 힌트에 지정된 순서대로 조인하도록 지시
  • JOIN_PREFIX: 가장 먼저 조인을 시작 할 테이블 지정
  • JOIN_SUFFIX: 가장 마지막으로 조인 할 테이블 지정

지정한 TABLE_NAME의 모든 테이블에 힌트가 적용되며, TABLE_NAME은 스키마 이름으로 한정할 수 없다.

TABLE_NAME에 별칭이 있는 경우 힌트는 테이블 이름이 아니라 별칭을 참조해야 한다.

SELECT
/*+ JOIN_PREFIX(t2, t5@subq2, t4@subq1)
    JOIN_ORDER(t4@subq1, t3)
    JOIN_SUFFIX(t1) */
COUNT(*) 
FROM t1 JOIN t2 JOIN t3
WHERE t1.f1 IN (SELECT /*+ QB_NAME(subq1) */ f1 FROM t4)
  AND t2.f1 IN (SELECT /*+ QB_NAME(subq2) */ f1 FROM t5);

(SELECT /*+ QB_NAME(subq1) */ f1 FROM t4) : 쿼리 블록의 이름을 subq1로 지정
t4@subq1 : 쿼리 블록 subq1의 테이블 t4를 지정

/*+ JOIN_PREFIX(t2, t5@subq2, t4@subq1)
JOIN_ORDER(t4@subq1, t3)
 JOIN_SUFFIX(t1) */

t2, t5@subq2, t4@subq1, t3, t1 순서대로 조인

인덱스 힌트

Mysql를 사용을 하다보면 복잡한 쿼리의 경우 서로의 인덱스가 물리고 물려서 필요한 인덱스를 안타고 엉뚱한 인덱스를 사용하는 경우가 있다.

예를 들어서 A, B, C의 인덱스가 순서대로 사용되어야 하는데 옵티마이저가 B, C, A 순으로 처리를 하여서 속도가 느려지는 경우에 이런 순서를 잡기 위해서 인덱스 힌트를 사용한다.

즉, Mysql에서 제공하는 인덱스 힌트를 쓰면 강제적으로 할당한 Index를 이용하여 쿼리가 실행이 된다.

하지만 JPA(hibernate)에서 사용이 불가능하기 때문에 JdbcTemplate 등을 이용하여 Native Query로 활용해야 된다.

사용 방법

TABLE_NAME [[AS] ALIAS] INDEX_HINT INDEX_LIST

INDEX_LIST:
    USE {INDEX|KEY}
      [FOR {JOIN|ORDER BY|GROUP BY}] (INDEX_LIST)
  | IGNORE {INDEX|KEY}
      [FOR {JOIN|ORDER BY|GROUP BY}] (INDEX_LIST)
  | FORCE {INDEX|KEY}
      [FOR {JOIN|ORDER BY|GROUP BY}] (INDEX_LIST)

INDEX_LIST:
    INDEX_NAME , INDEX_NAME ...
  • USE 키워드 : 특정 인덱스를 사용하도록 권장
  • IGNORE 키워드 : 특정 인덱스를 사용하지 않도록 지정
  • FORCE 키워드 : USE 키워드와 동일한 기능을 하지만, 옵티마이저에게 보다 강하게 해당 인덱스를 사용하도록 권장

  • USE INDEX FOR JOIN : JOIN 키워드는 테이블간 조인뿐 아니라 레코드 검색하는 용도까지 포함
  • USE INDEX FOR ORDER BY : 명시된 인덱스를 ORDER BY 용도로만 사용하도록 제한
  • USE INDEX FOR GROUP BY : 명시된 인덱스를 GROUP BY 용도로만 사용하도록 제한
SELECT * 
FROM TABLE1 
  USE INDEX (COL1_INDEX, COL2_INDEX)
WHERE COL1=1 AND COL2=2 AND COL3=3;

SELECT * 
FROM TABLE2 
  IGNORE INDEX (COL1_INDEX)
WHERE COL1=1 AND COL2=2 AND COL3=3;

SELECT * 
FROM TABLE3
  USE INDEX (COL1_INDEX)
  IGNORE INDEX (COL2_INDEX) FOR ORDER BY
  IGNORE INDEX (COL3_INDEX) FOR GROUP BY
WHERE COL1=1 AND COL2=2 AND COL3=3;

공인 IP, 사설 IP

IP 주소

  • Internet Protocol address의 약자, 인터넷 규약 주소
  • 컴퓨터 네트워크에서 장치들이 서로를 인식하고 통신하기 위해서 사용되는 특수 번호

네트워크에 연결된 장치가 라우터이든 일반 서버이든, 모든 기계는 이 특수한 번호를 가지고 있어야 한다.
이 번호를 이용해서 발신자와 수신자 간의 통신이 이루어지게 되는데, 발신자를 대신해서 수신자에게 메시지를 전송한다.

IP 주소를 줄여서 IP라고도 하지만 IP는 인터넷 규약 자체를 가리키는 단어로 구별해서 사용해야 한다.

IP 주소 체계

  • IPv4 (IP version 4) 규약은 IP 주소를 부여하는 방식으로 현재 사용되고 있는 방식이다.
  • 32비트로 구성된 주소체계로 0 ~ 255 사이의 십진수 네 개를 구분해서 부여한다.

127.0.0.1 역시 0 ~ 255 사이의 십진수 네 개를 이용해서 만들어진 주소이다.

IPv4 주소 체계에서 이론적으로 부여할 수 있는 주소의 총 개수는 0.0.0.0 ~ 255.255.255.255로 256 * 256 * 256 * 256 = 약 42억개 정도이다.

IPv4

  • IPv4 주소는 임의로 부여할 수 없고 전세계적으로 ICANN이라는 기관이 국가별로 사용할 IP 대역을 고나리하고 우리 나라는 인터넷 진흥원(KISA)에서 우리나라 내에서 사용할 주소를 관리하고 있다.
  • IP 주소에는 4자리의 대역에 따라 아래와 같이 분류할 수 있다.
구분 설명 범위
A 클래스 네 자리의 IP 주소 대역 중에서 두번째, 세번째, 네번째 주소를 마음대로 부여할 수 있는 최상위 클래스 1.0.0.1 ~ 126.255.255.254
B 클래스 네 자리의 IP 주소 대역 중에서 세번째, 네번째 주소를 마음대로 부여할 수 있는 두 번째로 높은 클래스 128.0.0.1 ~ 191.255.255.254
C 클래스 네 자리의 IP 주소 대역 중에서 네번째 주소를 마음대로 부여할 수 있는 최하위 클래스 192.0.0.1 ~ 223.255.255.254

그 밖에 D와 E 클래스가 있지만 연구용 등으로 사용된다.

공인 IP

  • 인터넷 서비스 공급자(ISP)가 제공하는 IP 주소로 공용 IP라고도 불리며 외부에 공개되어 있는 IP
  • 전세계에서 유일한 IP를 가진다.
  • 공인 IP 주소가 외부에 공개되어 있기 때문에 인터넷에 연결된 다른 PC로부터의 접근이 가능하다.
    따라서 공인 IP 주소를 사용하는 경우에는 방화벽이나 보안프로그램을 설치할 필요가 있다.

사설 IP

  • 일반 가정이나 회사 내 등에 할당된 네트워크의 IP 주소로 로컬 IP, 가상 IP라고도 한다.
  • IPv4의 주소 부족으로 인해 서브넷팅된 IP이기 때문에 라우터에 의해 로컬 네트워크 상의 PC나 장치에 할당된다.

전체 IP 대역 중에서 다음의 대역은 사설 IP 대역으로 설정되어 있기 때문에 사용자가 임의로 부여하고 사용할 수 있으며 인터넷 상에서 서로 연결되지 않도록 되어 있다.

구분 범위
A 클래스 10.0.0.1 ~ 10.255.255.255
B 클래스 172.16.0.1 ~ 172.31.255.255
C 클래스 192.168.0.1 ~ 192.168.255.255

사설 IP로 회사나 가정 내의 IP 주소를 부여하고 공유기 등에 고정 IP를 부여한 다음에 인터넷에 접속하는 방식이 널리 퍼지게 되었고, 대부분의 장비가 현재는 사설 IP를 부여하고 공유기를 통해 인터넷에 접속하게 된다.

  • 공유기는 공인 IP를 가진다.
  • 컴퓨터나 노트북는 사설 IP를 가지고 공유기를 통해 인터넷에 접근할 수 있다.

사설 IP와 공인 IP의 차이

공인 IP 사설 IP
할당 주체 ISP(인터넷 서비스 공급자) 라우터(공유기)
할당 대상 개인 또는 회사의 서버(라우터) 개인 또는 회사의 기기
고유성 인터넷 상에서 유일한 주소 하나의 네트워크 안에서 유일
공개 여부 내/외부 접근 가능 외부 접근 불가능

NAT

  • Network Address Translation의 약자로 OSI 3계층인 네트워크 계층에서 공인 IP와 사설 IP르 변환하는 역할을 한다.
  • IPv4의 주소 부족으로 인해 공인 IP 주소를 절약하고 외부로의 침입을 차단하기 위해 사용한다.

NAT에는 Basic NAT와 NAPT 2 종류가 있고 대개 NAT라고 하면 NAPT 방식을 말한다.

NAPT

  • Network Address Port Translation의 약자로 사설 IP 주소를 가지는 여러 대의 단말이 하나의 공인 IP 주소를 통해 인터넷과 연결되는 방식
  • 공인 IP 주소 절약을 목적으로 사용되며 1:N Translation 규칙을 사용한다. (1 = 공인 IP, N = 사설 IP)
  • NAPT 방식을 이용하면 간략하게 아래와 같이 인터넷과 연결된다.

image
출처

(사설 IP -> 공인 IP)

  1. 사설 IP 주소를 가진 단말이 인터넷으로 전송하고자 하면 사설 IP와 Port에 대한 공인 IP와 Port를 할당하고 NAT Binding Table에 해당 정보를 생성한다.
  2. NAT Binding Table을 참조해서 사설 IP 주소를 공인 IP 주소로 변환하고 인터넷으로 전송한다.

(공인 IP -> 사설 IP)

  1. NAT Binding Table을 참조해서 공인 IP 주소를 사설 IP 주소로 변환해서 단말로 전송한다.

고정 IP

  • 컴퓨터에 고정적으로 부여된 IP
  • 한번 부여되면 반납하기 전까지 다른 장비에 부여할 수 없는 IP 주소
  • 인터넷에서 서버를 운영하고자 할 때는 공인 IP를 고정 IP로 부여해야 한다
    공인 IP를 부여받지 못하면 다른 사람이 내 서버에 접속할 수 없고, 고정 IP를 부여하지 않으면 내 서버가 아닌 다른 사람의 서버로 접속할 수 있다.

유동 IP

  • 장비에 고정적으로 IP를 부여하지 않고 컴퓨터를 사용할 때 남아 있는 IP 중에서 돌아가면서 부여하는 IP

HTTP 상태코드

HTTP 상태코드

HTTP 상태 코드는 3자리 숫자로 만들어져 있으며, 첫번째 자리는 1에서 5까지 제공된다.

첫번째 자리가 4와 5인 경우는 정상적인 상황이 아니기 때문에 관리자가 즉시 알아야 하는 정보이다.


1XX: Information response

상태 코드가 1로 시작하는 경우는 서버가 요청을 받았으며, 서버에 연결된 클라이언트는 작업을 계속 진행하라는 의미이다.

상태코드 설명
100 Continue 진행 중임을 의미하는 응답코드.
현재까지의 진행상태에 문제가 없으며, 클라이언트가 계속해서 요청을 하거나 이미 요청을 완료한 경우에는 무시해도 되는 것을 알려준다.
클라이언트가 서버에 리소스 본문을 전송하기 전에 그 리소스 본문을 서버가 받아들일 것인지 확인하려고 할 때 그 확인 작업을 최적화하기 위한 의도로 도입된 것.
101 Switching Protocol 101은 클라이언트에 의해 보낸 업그레이드 요청 헤더에 대한 응답으로 보내진다.
이 응답 코드는 클라이언트가 upgrade 헤더에 나열한 것 중 하나로 서버가 프로토콜을 바꾸었음을 의미한다.
해당 코드는 Websocket 프로토콜 전환 시에도 사용된다.
102 Processing 102는 서버가 요청을 수신하였으며 이를 처리하고 있지만, 아직 제대로 된 응답을 알려줄 수 없음을 알려준다.

2XX: Successful response

상태 코드가 2로 시작하는 경우는 서버가 요청을 성공적으로 받았으며, 수용했다는 의미이다.

상태코드 설명
200 OK 요청이 성공적으로 처리되었다는 것을 의미한다.
response는 요청에 따른 응답으로 변환된다.
200 응답은 캐시될 수 있다.
201 Created 요청이 성공적이었으며 그 결과로 새로운 리소스가 생성된 것을 의미한다.
이 응답은 일반적으로 POST 요청 또는 일부 PUT 요청 이후에 따라온다.
202 Accepted 요청은 받아들여졌으나, 서버는 아직 그에 대한 어떤 동작도 수행하지 않았다는 뜻이다.
202 상태코드는 다른 프로세스에서 처리 또는 서버가 요청을 다루고 있거나 배치 프로세스를 하고 있는 경우를 위해 만들어졌다.
203 Non-Authoritative Information 203 응답 코드는 받은 정보가 origin 서버의 정보와 일치하지 않아 리소스에 접근할 수 없음을 의미한다.
origin 서버가 아닌 백업 서버(사본)에서 정보가 요청되었을 때 203 코드를 사용한다.
204 No Content 요청에 대해 보내줄 수 있는 콘텐츠가 없지만, 헤더와 상태는 있는 경우 사용한다.
기본적으로 PUT 요청일 때 리소스 업데이트의 경우 204, 리소스 생성의 경우 201을 반환한다.
205 Reset Content 브라우저에서 사용되는 상태코드이다.
브라우저에게 현재 페이지에 있는 HTML 폼에 채워진 모든 값을 비우거나, 캔버스 상태를 재설정하거나, UI를 새로 고치라고 지시한다.
206 Partial Content 부분 혹은 범위 요청이 성공했다.
나중에 클라이언트가 특별한 헤더를 사용해 문서의 부분 혹은 특정 범위를 요청할 수 있다.
클라이언트가 이어받기를 시도하면 웹서버가 이에 대한 응답 코드로 206 코드와 함께 Range 헤더에 명시된 데이터의 부분(byte)부터 전송을 시작한다.
207 Multi-Status 멀티-상태 응답은 여러 리소스가 여러 상태 코드인 상황이 적절한 경우에 해당되는 정보를 전달한다.
226 IM Used 서버가 GET 요청에 대한 리소스의 의무를 다했고, 응답이 하나 또는 그 이상의 인스턴스 조작이 현재 인스턴스에 적용되었음을 알려준다.

3XX: Redirection messages

상태 코드가 3으로 시작하는 경우는 요청 완료를 위해 추가 작업 조치가 필요함을 의미한다.

클라이언트가 관심 있어 하는 리소스에 대해 다른 위치를 사용하라고 말해주거나, 그 리소스의 내용 대신 다른 대안 응답을 제공한다.

상태코드 설명
300 Multiple Choice 클라이언트가 동시에 여러 리소스를 가리키는 URL을 요청한 경우, 그 리소스의 목록과 함께 반환한다.
요청에 둘 이상의 가능한 응답이 있음을 나타낸다.
응답 중 하나를 선택하는 표준화된 방법이 없기 때문에, 이 응답 코드는 거의 사용되지 않는다.
301 Moved Permanently 요청된 리소스가 Location 헤더가 지정한 URL로 확실하게 이동하였음을 나타낸다.
브라우저는 이 페이지로 리디렉션되고 검색 엔진은 리소스의 링크를 업데이트한다.
새로운 URL이 응답에서 주어질 수 있다.
302 Found 요청된 리소스가 Location 헤더가 지정한 URL로 일시적으로 이동하였음을 나타낸다.
브라우저는 이 페이지로 리디렉션되지만 검색 엔진은 리소스에 대한 링크를 업데이트하지 않는다.
따라서 클라이언트는 향후 요청도 반드시 동일한 URL로 해야 한다.
303 See Other 클라이언트가 요청한 리소스를 다른 URI에서 GET 요청을 통해 얻어야할 때, 서버가 클라이언트로 직접 보내는 응답이다.
304 Not Modified 304 코드는 캐시의 목적으로 사용된다.
이 코드는 클라이언트에게 응답이 수정되지 않았음을 알려주며, 클라이언트는 계속 응답의 캐시된 버전을 사용할 수 있다.
307 Temporary Redirect Location 헤더로 주어진 URL로 리소스를 임시적으로 가리키기 위한 목적으로 사용한다.

4XX: Client error response

상태 코드가 4로 시작하는 경우는 클라이언트 요청의 문법이 잘못되었거나 요청을 처리할 수 없음을 의미한다.

즉 클라이언트가 서버가 다룰 수 없는 무엇인가를 보낸 것이다.

상태코드 설명
400 Bad Request 클라이언트의 잘못된 문법으로 서버가 요청을 이해할 수 없음을 의미한다.
401 Unauthorized 비인증을 의미한다. 클라이언트는 요청한 응답을 받기 위해 반드시 스스로를 인증해야 한다. (ex. 로그인)
403 Forbidden 요청이 서버에 의해 거부되었음을 알려주기 위해 사용한다.
왜 요청이 거부되었는지 서버가 알려주고자 한다면, 그 이유를 설명하는 본문을 포함할 수 있다.
이 코드는 보통 서버가 거절의 이유를 숨기고 싶을 때 사용한다.
401과 비슷하지만, 로그인 로직처럼 반응하여 재인증을 하더라도 지속적으로 접속을 거절한다.
404 Not Found 서버에서 요청받은 리소스를 찾을 수 없다는 것을 의미한다.
브라우저에서는 알려지지 않은 URL을 의미한다.
서버에서는 인증받지 않은 클라이언트로부터 리소스를 숨기기 위해 이 응답을 403 대신 전송할 수도 있다.
405 Method Not Allowed 요청 URL에 대해, 지원하지 않은 메서드로 요청받았을 때 사용한다.
요청한 리소스에 대해 어떤 메서드가 사용 가능한지 클라이언트에게 알려주기 위해 요청에 Allow 헤더가 포함되어야 한다.
408 Request Timeout 클라이언트의 요청을 완수하기에 시간이 너무 많이 걸리는 경우, 서버는 이 상태 코드로 응답하고 연결을 끊을 수 있다.
408은 서버가 계속 대기하지 않고 연결을 닫기로 결정했음을 의미하므로 서버는 응답에 있는 '닫기' 연결 헤더 필드를 전송해야 한다.
409 Conflict 현재 서버의 상태와 충돌될 때 보낸다.
응답은 충돌에 대해 설명하는 본문을 포함해야 한다.
410 Gone 410 응답은 요청한 콘텐츠가 서버에서 영구적으로 삭제되었으며, 전달해 줄 수 있는 주소 역시 존재하지 않을 때 보낸다.
클라이언트가 그들의 캐시와 리소스에 대한 링크를 지우기를 기대한다.
411 Length Required 서버에서 필요로 하는 Content-Length 헤더 필드가 정의되지 않은 요청이 들어올 때 서버가 요청을 거절한다.
즉 Content-Length 헤더가 있을 것을 요구한다.
412 Precondition Failed 클라이언트가 조건부 요청을 했는데, 그 중 하나가 실패했을 때 사용한다.
조건부 요청은 클라이언트가 Expect 헤더를 포함했을 때 발생한다.
414 URI Too Long 클라이언트가 요청한 URI는 서버에서 처리하기로 한 길이보다 길다.
415 Unsupported Media Type 클라이언트가 요청한 미디어 포맷을 서버에서 지원하지 않는다.
418 I'm a teapot 서버가 찻주전자이기 때문에 커피 내리기를 거절하는 것을 뜻한다.
만우절 농담이었던 Hyper Text Coffee Pot Control Protocol 에서 처음 나왔다.
421 Misdirected Request 서버로 유도된 요청은 응답을 생성할 수 없다.
429 Too Many Request 사용자가 지정된 시간에 너무 많은 요청을 보냈다.
431 Request Header Fields Too Large 요청한 헤더 필드가 너무 크기 때문에 서버는 요청을 처리하지 않는다.
요청은 크기를 줄인 다음에 다시 전송해야 한다.
451 Unavailable For Legal Reasons 클라이언트가 요청한 것은 정부에 의해 검열된 웹페이지와 같은 불법적인 리소스라는 것을 뜻한다.

5XX: Server error response

상태 코드가 5로 시작하는 경우는 서버가 명백히 유효한 요청에 대한 처리를 실패했음을 뜻한다.

상태코드 설명
500 Internal Server Error 서버가 요청을 처리할 수 없게 만드는 에러를 만났을 때 사용한다.
501 Not Implemented 서버가 요청을 수행하는데 필요한 기능을 지원하지 않는다.
502 Bad Gateway 서버가 게이트웨이로부터 잘못된 응답을 수신했음을 의미한다.
인터넷상의 서버가 다른 서버로부터 유효하지 않은 응답을 받은 경우 발생한다.
503 Service Unavailable 서버가 요청을 처리할 준비가 되지 않았다.
나중에는 요청 처리가 가능함을 의미한다.
일반적으로 유지보수를 위해 작동이 중단되거나 과부하가 걸린 서버에서 많이 발생한다.
504 Gateway Timeout 웹페이지를 로드하거나 브라우저에서 다른 요청을 채우려는 동안 한 서버가 액세스하고 있는 다른 서버에서 적절한 시기에 응답을 받지 못했음을 의미한다.
이 오류 응답은 서버가 게이트웨이 역할을 하고 있으며 시간내에 응답을 받을 수 없는 경우 주어진다.
이 오류는 대게 인터넷상의 서버 간의 네트워크 오류이거나 실제 서버의 문제이다.
505 Http Version Not Supported 서버에서 지원되지 않는 HTTP 버전을 클라이언트가 요청한 것
511 Network Authentication Required 511 코드는 클라이언트가 네트워크 액세스를 얻기 위한 인증이 필요함을 뜻한다.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.