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

 

1014번: 컨닝

문제 최백준은 서강대학교에서 “컨닝의 기술”이라는 과목을 가르치고 있다. 이 과목은 상당히 까다롭기로 정평이 나있기 때문에, 몇몇 학생들은 시험을 보는 도중에 다른 사람의 답지를 베끼려 한다. 시험은 N행 * M열 크기의 직사각형 교실에서 이루어진다. 교실은 1*1 크기의 단위 정사각형으로 이루어져 있는데, 각 단위 정사각형은 자리 하나를 의미한다. 최백준은 컨닝을 방지하기 위해서 다음과 같은 전략을 세웠다. 모든 학생은 자신의 왼쪽, 오른쪽, 왼쪽 대각

www.acmicpc.net

문제 소개

백준 1014번 문제 컨닝에 대한 풀이 포스팅입니다. 사실 이 문제는 컨닝2 라는 문제도 있고, 컨닝2의 경우 컨닝1과 문제는 동일하나 N,M값이 다릅니다.

 

컨닝1은 N,M이 최대 10이고, 컨닝2는 N,M이 최대 80입니다.

 

일단 이 포스팅에서는 컨닝1을 풀이하기 위한 내용을 소개합니다.

 

사실 저는 이 문제에 대한 풀이 방법(카테고리 정도)를 알고 문제를 접근했습니다.

 

약간 스포일러를 하자면 다이나믹 프로그래밍으로 풀 수 있고 거기에 추가적인 기법이 하나 들어가게 됩니다.

 

다이나믹 프로그래밍 + 비트마스킹

일반적인 다이나믹 프로그래밍은 많이 들어보셨을 것이지만, 비트마스킹을 조합해서 쓰는 것은 잘 모르시는 분들도 있을 것입니다.

 

일단 문제를 분석해보면, 다이나믹 프로그래밍으로 풀만한 최적 부분구조(Optimal substructure)와 또 하나 특성이 있는데 그것이 성립하는 것 같습니다.

 

최적 부분구조라 함은, 부분문제의 최적값이 전체 문제의 최적값을 구하는데 사용이 된다는 것이고, 다른 하나의 특성은 부분문제의 풀이가 다른 나머지 문제를 풀 때 영향을 주지 않는다는 것이지요.

 

사실 영향을 주긴 하는데, 컨닝 문제 조건 상 앞에 줄에 사람을 어떻게 배치했느냐가 바로 뒷 줄에 사람을 배치하는 것에만 영향을 줍니다. 따라서 바로 앞줄에 사람을 어떻게 배치했는지를 저장을 할 필요가 있습니다.

 

그리고 앞에서 부터 사람을 채운다고 하면 앞에서 사람을 많이 채워놓아야지 뒤에서도 전체 사람 수를 채우는 데 도움이 되므로 최적 부분구조가 성립한다고 할 수 있겠지요.

 

그런데 사람을 배치하는 것은 한 줄(row)단위로 생각하면 사람이 있다, 없다의 2진수로 표현이 가능하게 됩니다.

 

컴퓨터에서는 정수를 2진수로 표현하기 때문에 이를 이용해서 앞 줄의 사람이 앉아있는 상태를 정수 하나로 쉽게 표현할 수 있을 것 같습니다.

 

이러한 방식을 bitmasking이라고 합니다.

 

점화식 정의

일단 사람을 앞에서 부터 채워야지, 이전에 앞에 채운 사람의 형태에 따라 뒤에서 valid한 사람 배치, invalid한 사람 배치를 알 수 있으므로 for-loop를 이용한 동적계획법으로 풀이를 하려고 설계를 해 보겠습니다.

 

그러면 점화식을 정의를 해 보아야 하는데 다음과 같습니다.

//(dp[i][j]//)

여기서 i는 i번째 열까지 채웠을때의 상태를 나타내고, j는 해당 i번째 열에 사람을 어떻게 배치했느냐를 나타냅니다.

즉 점화식의 정의는 i번째 열까지 사람을 다 채우고, i번째 열에는 사람을 j의 비트 모양으로 배치했을 때 가장 많이 배치할 수 있는 사람의 수라고 보시면 됩니다.

 

그러면 //(j//)모양으로 사람을 배치한다는 것은 무슨 말이 될까요?

 

간단하게 //(j=5//)라고 한다면 5는 2진수로 표현하면 101이 됩니다. 그러면 1이 있는 자리에는 사람이 있는 것이고, 0이 있는 자리에는 사람이 없는 것입니다. 이러한 방식으로 사람을 앉힌다는 것이지요.

 

구현전략

그러면 이러한 알고리즘으로 풀이 코드를 구현해보도록 하겠습니다.

 

그런데 보면 학생은 양 옆의 자리를 컨닝할 수 있으므로, 바로 인접하여 2명의 학생이 옆으로 배열되는 경우는 없습니다.

따라서 bit 들 중에서 인접한 2개의 bit가 같이 1로 되어있는 경우는 제거를 합니다. 그리고 이 bit 집합들은 자주 쓰일 것 같으므로 한번 만든 뒤 계속 사용하도록 하겠습니다.

 

그리고 비트마스킹의 장점 중 하나가, 모든 비트 경우를 확인하는 것은 단지 for-loop하나를 돌리면 다 확인이 가능합니다.

 

이러한 전략들을 적용하여 코드를 작성해보도록 하겠습니다.

 

풀이 코드

 

#include <bits/stdc++.h>
using namespace std;
#define endl '\n'
typedef long long ll;
typedef pair<int, int> pii;
string field[15];
int dp[15][1025]; // dp[i][j]=> i번째 줄에 j비트 모양으로 학생을 배치할때, i번째까지 사람을 앉힐 수 있는 최대수
bool seat_check(string& seats, int bit) {
	for (size_t i = 0; i < seats.length(); i++) {
		if (seats[i] == 'x' && (bit & (1 << i))) return false;
	}
	return true;
}
bool adj_check(int bit, int width) {
	for (int i = 0; i < width - 1; i++) {
		int val = (3 << i);
		if ((bit & val) == val) return false;
	}
	return true;
}
bool bits_check(int bit, int fbit, int width) {
	//앞자리랑 뒷자리 관계가 제대로 성립하는지 확인
	for (int i = 0; i < width; i++) {
		if ((1 << i) & fbit) { //앞자리에 앉는 녀석이 있을 때
			if (i > 0 && ((1 << (i - 1)) & bit)) return false; //왼쪽에 뒤에 앉는 경우
			if ((1 << (i + 1) & bit)) return false; //오른쪽 뒤에 앉는 경우
		}
	}
	return true;
}
void solve() {
	int ans = 0;
	memset(dp, 0, sizeof(dp));
	int n, m; cin >> n >> m;
	for (int i = 1; i <= n; i++) {
		cin >> field[i];
	}
	vector<pii> bits_set; //인접한 자리가 아닌 비트 마스크들을 미리 만들어놓음.
	for (int bit = 0; bit < (1 << m); bit++) {
		if (adj_check(bit, m)) {
			int cnt = 0;
			for (int i = 0; i < m; i++) {
				if ((1 << i) & bit) cnt++;
			}
			bits_set.push_back(make_pair(bit, cnt));
		}
	}
	for (int i = 1; i <= n; i++) {
		//i 번째 줄을 채울 예정
		for (pii bit : bits_set) {
			if (!seat_check(field[i], bit.first)) continue; //부서진 자리에 앉으려고 하는 경우
			for (pii fbit : bits_set) { //앞자리 앉는 녀석
				if (bits_check(bit.first, fbit.first, m)) {
					dp[i][bit.first] = max(dp[i][bit.first], dp[i - 1][fbit.first] + bit.second);
					ans = max(ans, dp[i][bit.first]);
				}
			}
		}
	}
	cout << ans << endl;
	return;
}
int main() {
	int tc; cin >> tc;
	while (tc--) solve();
}

이번 포스팅에서는 인코딩과 암호화 및 해시에 대하여 설명해보고자 한다.

자주 혼동되는 개념이기도 하고 처음에 접하면서 햇갈려하는 사람들이 꽤 있어서 명확하게 구분을 지어보려고 한다.

 

인코딩(Encoding)과 디코딩(Decoding)이란?

일단 인코딩에 대하여 이야기해보겠다. 인코딩(encoding)은 code화 한다는 뜻이다. 무언가를 코드로 바꾼다는 뜻이다.

반대로 디코딩(decoding)은 코드에서 원래 모습으로 되돌리는 것이다.

 

약간 햇갈리는 개념이 될 수 있는데, 이 단락은 이해가 안되면 그냥 넘어가도 좋다.

인코딩이란 심볼을 코드로 바꾸는 것이고, 디코딩은 코드를 심볼로 다시 바꾸는 과정이다.

여기서 심볼은 뜻을 가진 무언가라고 보면 되고, 코드는 뜻을 갖지 않은 무언가이다.

즉, 인코딩은 의미(내용, 혹은 뜻)을 기호로 바꾸는 과정, 디코딩은 그 반대의 과정이다.

 

그렇다면 코드(code)는 무엇일까?

 

코드는 인코딩 방법에 따라 각자 다른데, 여기서는 무언가를 나타내는 기호라고 생각하면 되겠다.

인코딩 방법에 따라서 각각의 코드가 무엇을 나타내는지는 달라지게 된다.

 

사실 컴퓨팅, 계산과학 등 컴퓨터와 관련된 분야에서 심볼과 코드의 경우, 보통 사람이 읽을 수 있는 내용들(심볼)을 컴퓨터가 인식할 수 있는 데이터(이진 데이터, bit)로 변화하는 경우가 대부분이다.

따라서 심볼은 자연언어(영어, 한국어, 일본어 등등...)이거나 이미지(사람이 인식할 수 있는) 및 동영상, 소리 등이 되는 경우가 많고, 코드는 컴퓨터가 인식할 수 있는 바이너리 데이터가 되곤 한다.

 

모스 부호(Morse code)를 예로 들어보겠다. 모스 부호의 영어 단어도 Morse Code이다.

이 경우에는 알파뱃(A,B,C,...,Z)과 아라비안 숫자(0,1,2,3,...,9)가 심볼이 되고, 오른쪽에 점과 작대기가 코드가 된다.

점과 작대기는 사실 뜻이 없고, 알파뱃은 뜻이 있는데, 이 알파뱃들을 점과 작대기로 바꾸는 과정은 인코딩이 되고, 점과 작대기로 알파뱃으로 되돌리는 과정은 디코딩이 된다.

 

사실 이제 눈치채었겠지만, 인코딩과 디코딩 과정은 표현방식을 바꾸는 것 뿐이다. 그리고 그 과정에서 정보의 손실은 없다.

정보의 손실이 없다고 하는 것은 어떤 정보 A를 B로 인코딩 한 뒤, 다시 디코딩 했을 때 원래 정보 A에서 사라지는 정보가 없다는 것이다.

 

만약 어떤 영어 문장이 있었을 때, 이 문장에서 짝수번째 자리에 있는 알파뱃만을 모스부호 인코딩을 한 경우, 이 과정에서는 정보의 손실이 나타나게 된다.

왜냐면 다시 모스부호 디코딩을 한 경우 문장의 홀수번째 자리에 있는 알파뱃만이 남아있기 때문에, 짝수번째 자리에 있던 알파뱃의 정보는 손실이 되기 때문이다.

 

다른 유명한 인코딩은 무엇이 있을까?

다양한 인코딩들이 있다. 사실 인코딩은 심볼을 어떻게 코드화 하는지, 그리고 되돌리는지에 대한 규칙만 있으면 쉽게 만들 수 있다. 그래서 인코딩 이름에 encoding이 들어가는 경우도 있고, 그냥 무슨 무슨 코드라고 써있는 경우도 있다.

간단하게 봐서는 심볼과 코드의 단순 1대 1 대응관계라고도 볼 수 있다.

 

아스키 코드(ASCII Code)

아마 컴퓨터공학을 전공하면 처음으로 배운 코드 중 하나일 것이다. 로마자 알파뱃과 아라비아숫자, 그리고 일부 특수문자들을 1byte로 표현할 수 있는 간단한 코드이다. 이 문자들은 1byte 정수로 인코딩되며, C언어에서 문자(열)을 다룬다면 매우 친숙한 코드일 것이다.

유니코드 (UTF-8 혹은 UTF-16 등)

아스키코드의 경우 로마자 알파뱃만 표현이 가능하다는 불편한점을 개선한 코드로, 전 세계 모든 글자를 다 표현할 수 있는 문자 처리 방식이다. 사실 유니코드 자체는 인코딩이 아닌, 이런 전세계 모든 문자 각각을 1바이트 이상의 크기로 표현할 수 있게하는 방식이고 이를 실제로 구현한 인코딩이 몇 가지가 있는데 그 중 가장 유명한 녀석 중 UTF-8와 UTF-16이 있다.

Base 64 인코딩

Base 2라고 하면 2진법을 뜻하고 base 10이라고 하면 10진법을 뜻한다. 따라서 base64는 직역하면 64진법이 된다.(64진법이라고 부르는 경우는 없는데 이해를 돕기 위한 내용이다.)실제로도 64개의 서로 다른 printable한 문자들을 코드로 사용하고 있으며, 심볼로는 모든 범위의 바이너리 값을 표현할 수 있도록 고안된 인코딩 방법이다.

이미지나 동영상 등 바이너리 데이터를 깨지지 않는 출력가능한 문자로 표현하기 위해 자주 사용되는 녀석 중 하나이다.

uuencoding

요녀석은 자주 나타나지는 않는데 이 녀석도 바이너리 데이터를 printable 한 값으로 변환시킬 수 있는 인코딩방법이다.

URI Encoding(% encoding)

URI에서 일부 특수문자들 등을 나타낼 때 사용하는 인코딩 방법으로, 인코딩 방식이 % 뒤에 16진수 hex 값 2글자가 들어가는 방식의 인코딩이라서 퍼센트 인코딩이라고도 부른다. 네이버 주소창에서 아무 한글로 검색한 뒤, URL창을 보면 마지막 URI 쿼리스트링 부분에 %로 인코딩 된 부분들을 쉽게 볼 수 있다.

 

인코딩은 왜 하는 것일까?

인코딩을 하는 이유는 상황별로 조금 다를 수 있긴 하다. 하지만 일단 컴퓨터라는 것 자체가 0과 1로 이루어진 bit들이 모여서 각종 작업을 하는데, 이때 한글이나 영어와 같은 데이터들을 처리해야하는 경우가 항상 있다. 그러면 컴퓨터는 어차피 숫자만 다루기 때문에, 한글과 영어와 같은 심볼들을 숫자 Code로 맵핑해야 한다.

이런 경우 문자열 인코딩(Character encoding)이 필연적으로 필요해진다.

 

또한 어떤 값들을 네트워크나 다른 영역으로 전송해야 하거나 전송받아야 하는 경우, 서로 다른 플랫폼이라서 데이터를 표현하는 방식이 다르더라도 서로 호환되도록 공통적인 데이터 표현방식이 필요할 수 있다. 마치 세계 공용어로 영어가 쓰이면 지구 모든 사람이 영어를 할 줄 안다면 세계 누구와도 언어가 통할 수 있듯이, 그러한 표준화된 데이터 표현 방법을 정의한다고 보면 된다.

 

따라서 인코딩의 목적은 정보의 호환성이 되겠다. 

 

암호화(encrypt)와 복호화(decrypt)란?

인코딩과 암호화를 많이 햇갈려하곤 한다. 그런데 둘은 목적 자체도 다르고 성질도 다르다. 인코딩은 표현 방법을 바꾸는 것이고, 암호화는 남이 정보를 알아보지 못하게 하는 것이다.

암호화의 목적

암호화의 목적은 남들이 정보를 알아보지 못하게 만드는 것이다. 평문(plain text)을 암호화하면 암호문(cipher text)이 되고, 암호문을 복호화하면 평문이 된다.

그리고 여기서 '남'이란 암호 키가 없는 사람이다. 암호 키가 있는 사람은 복호화를 할 수 있고, 암호키가 없는 사람은 복호화를 할 수 없다. 즉, 키가 없으면 원래 평문이 어떤 값인지 알 수 없다는 것이다.

 

여기서 인코딩과 암호화의 첫번째 차이가 나왔다. 바로 '키'의 존재 유무이다.

 

암호의 종류

암호 부분은 자세히 들어가면 굉장히 복잡하다. 보통 암호를 금고에 많이 비유를 하는데, 일단 암호 시스템의 구성요소는 4가지라고 보면 된다.

평문과 암호 알고리즘 그리고 키와 암호문이 된다.

 

평문을 암호 알고리즘과 키를 통해서 암호화를 하면 암호문이 된다.

 

평문은 중요 정보라고 보면 되고, 암호 알고리즘은 금고이다. 그리고 키는 금고의 패스워드가 된다.

금고안에 들어간 중요 정보는 암호문이라고 보면 된다.

 

암호는 키의 형태에 따라 대칭키, 비밀키 암호가 있고, 대칭키는 암호화에 쓰이는 키가 복호화에도 쓰이는 것이다.

비밀키는 키가 비밀키 및 공개키로 한 쌍이 있고, 비밀키로 암호화하면 공개키로 복호화가 되고, 공개키로 암호화를 하면 비밀키로 복호화가 되는 특이한 방식의 암호이다.

 

암호 알고리즘의 종류

암호 알고리즘도 다양한 종류가 있는데, 유명한 몇 가지를 알아보도록 하겠다.

 

DES(Data Encryption Standard)

매우 오래된 대칭키 암호 알고리즘이다. 취약성하다고 알려져서 요즘은 그냥은 사용하지 않는다.

 

AES(Advanced Encryption Standard)

DES가 취약하다고 알려져서 이를 대체하기 위해 새로이 만들어진 대칭키 암호 알고리즘이다.

 

RSA(Rivest Shamir Adelman)

암호 알고리즘을 만든 3명의 이름을 본 따서 이름을 지은 공개키 암호 알고리즘이다. 지금까지도 널리 잘 쓰이고 있는 암호 알고리즘이며, 공개키 암호 알고리즘의 대표격이다.

 

암호화와 정보의 손실

아까 인코딩은 정보의 손실이 없다고 했는데, 그렇다면 암호화는 정보의 손실이 있을까?

답은 구현에 따라 다르다이다. 단방향 해시함수를 이용해서 암호화를 하는 경우가 있는데, 이 경우는 해시함수의 특성상 정보 손실이 일어나고 복호화가 불가능하다.

 이는 조금 특별한 경우인데, 보통 계정 정보의 패스워드를 이 방식을 통해서 암호화하여 저장한다.

패스워드가 동일한지는 입력한 패스워드를 단방향 해시함수로 암호화 한 뒤, 기존에 저장된 암호문과 일치하는지를 확인한다.

 

 

 

 

 

-------------------------------------------------------------------

https://ko.wikipedia.org/wiki/%EB%AA%A8%EC%8A%A4_%EB%B6%80%ED%98%B8

백준에서 문제를 풀어보다가 LIS 시리즈물들이 있길래 이 김에 공부할 겸 정리도 한번 해보려고 합니다.

PS 분야에서는 꽤나 유명한, 매우 well-known한 주제이기도 합니다.

 

백준에는 무려 6문제나 있지요. 사실 뒤에 숫자는 레벨이라고 봐도 됩니다. 고로 2번 문제 난이도 < 3번 문제 난이도 < 4번 문제 난이도... 뭐 이런식이지요.

 

백준 11053번 문제 "가장 긴 증가하는 부분 수열"

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

문제부터 한번 보겠습니다. 첫번째 문제입니다.

 

최장 증가 부분 수열

일단 Longest Increasing Subsequence가 무엇인지를 좀 알고 넘어가면 좋을 듯 합니다.

사실 부분수열이면 subsequence이지요. 그렇다면 수열은 sequence라고 보시면 됩니다.

 

subsequence라고 하는 녀석은 연속되지 않아도 됩니다. 이 부분이 간단하지만 핵심적인 내용인데, (반면 substring은 연속되어야 합니다.)

위 문제에서 예시를 보면, 10 20 30 50이 최장 증가수열입니다.

 

하지만 원본 배열에서는 20과 30 사이에 10이 들어있어서 20과 30은 연속되지 않습니다. 그래도 subsequence가 맞다는 것이지요.

 

그러면 길이가 N인 배열의 subsequence의 개수는 //(2^N//)개가 되겠네요. 각각의 원소가 부분 수열에 포함되거나, 그렇지 않거나 하니깐요.

 

그러므로 Brute force로 문제를 풀려고 한다면 당연히 시간 복잡도는 //(O(2^N)//)가 나오게 됩니다.

 

문제에서 주어진 //(N=1000//)이므로 완전탐색으로는 풀 수 없겠지요.

 

그렇다면 어떻게 풀 수 있을까요?

 

동적 계획법 풀이 (Dynamic Programming)

LIS는 유명한 동적계획법 문제 중 하나이기도 합니다. 1차원 다이나믹 프로그래밍으로 //(O(N^2)//)의 시간복잡도로 문제를 해결할 수 있습니다.

 

간단하게 점화식을 새운 뒤 부분 문제들을 풀면 됩니다.

 

헌데 문제 특성 상, 부분 문제인 이미 만든 Subsequence들을 저장을 해야 하는데, 리스트 형태로 되어 있으면 다루기가 힘들지요.

 

그래서 subsequence의 마지막 값만을 이용해서 점화식을 정합니다.

 

//(dp[i]//)는 //(i//)번째 원소를 부분 수열의 마지막으로 하는 수열 중 가장 긴 수열의 길이

로 정의합니다.

 

#include <bits/stdc++.h>
using namespace std;

int dp[1001]; //dp[i] -> i번째 값을 마지막으로 하는 subsequence중 가장 긴 녀석의 길이
int arr[1001];
int main() {
    int n;
    cin >> n;
    for (int i=0; i< n; i++) {
        cin >> arr[i];
        dp[i] = 1; //길이가 1인 경우로 초기화
    }
    for (int i=0; i < n; i++) {
        int& last_val = arr[i];
        for (int j=0; j < i; j++) {
            int& target_val = arr[j];
            if (last_val > target_val) {
                dp[i] = max(dp[i], dp[j] + 1);
            }
        }
    }
    
    int ans = 0;
    for (int i=0; i<n; i++) {
        ans = max(ans, dp[i]);
    }
    cout << ans << '\n';
	return 0;
}

 

위와 같이 구현해서 1번문제를 가볍게 풀 수 있습니다.

 

백준 12015번 문제 "가장 긴 증가하는 부분 수열 2"

그러면 다음 문제를 한번 보도록 합시다.

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

 

이전 문제와 달라진 점은 N의 크기 뿐입니다. N이 100만이 되었으니, 기존의 //(O(N^2)//)의 알고리즘으로는 시간 내에 풀이가 힘들게 되었습니다.

 

좀 더 효율적인 알고리즘이 있을까요?

 

NlgN 풀이 - 바이너리 서치

//(O(NlgN)//)의 시간복잡도를 갖는 LIS 풀이로 2가지가 있는 것으로 알고 있는데, 하나는 세그먼트 트리를 이용한 방법이고 다른 하나는 이번에 다루어볼 바이너리 서치를 활용한 방법입니다.

 

아이디어

일단 배열 A = {2, 5, 3}이라고 가정해봅시다.

그러면 LIS가 2개가 나옵니다.

{2, 5}와 {2, 3}이지요.

 

그러면 여기서 A배열 뒤에 7과 11이 추가된다고 생각해보겠습니다. 그러면 A = {2, 5, 3, 7, 11}이 됩니다.

 

그리고 이렇게 새로 추가된 7과 11 덕에 기존의 LIS도 더 길어지게 됩니다.

{2, 5} => {2, 5, 7, 11}

{2, 3} => {2, 3, 7, 11}

 

이러한 식으로 알고리즘이 전개됩니다. 여기에 A 배열 마지막에 8이 추가된다면 어떻게 될까요?

A = {2, 5, 3, 7, 11, 8}이 되는데, 요 8녀석이 기존에 알려진 LIS들 사이에 끼일 수 있을까요?

7과 11은 기존 LIS의 마지막 값 보다 명백히 큰 값이라서 그냥 뒤에다가 붙이면 된다는게 확실한데, 8은 마지막 값인 11보다 작습니다.

 

그러면 이 8이라는 녀석을 버려야할까요?

 

LIS에서 그리디한 성질

사실 이런 경우 11을 8로 바꾸는 경우가 무조건 더 좋습니다.

 

일단 수열이 여기서 끝난다고 하면, {2,3,7,11}이나 {2,3,7,8}이나 Increasing subsequence의 길이는 둘 다 같습니다.

 

이후 뒤에 어떤 수가 더 온다고 한 들, 8로 끝난 경우가 11로 끝난 경우보다 더 긴 수열을 만드는데 있어서 유리하거나 같습니다.

 

일단 당장 뒤에 오는 숫자의 범위를 3가지로 나누어 보겠습니다.

 

1) 11보다 큰 수가 오는경우, 

2) 9이상 11이하의 수가 오는 경우

3) 8이하의 수가 오는 경우.

 

1번 11보다 큰 수가 오는 경우, 8로 끝난 수열이나 11로 끝난 수열이나 둘 다 취할 수 있습니다.

예를 들어 이후 13이 온 경우

{2,3,7,11,13}과 {2,3,7,8,13}이 가능한데, 둘 다 같은 길이에 마지막 수가 13으로 끝나는 수열이 됩니다.

 

2번 9이상 11이하의 수가 오는 경우 8로 끝난 수열은 길이가 1추가되는데, 11로 끝난 수열은 길이를 추가할 수가 없습니다. 또한 마지막 수가 11이하의 수 이므로 기존 11로 끝난 수 보다 무조건 유리합니다.

 

3번 8이하의 수의 경우는 둘 다 수열을 연장할 수 없으니 두개의 수열 모두 추가 수를 취할 수 없으니, 상관이 없지요.

 

따라서 같은 길이라면 마지막 수가 작을 수록, 이후에 더 긴 수열을 만들기 위해서는 더 유리하다고 볼 수 있습니다. 이러한 점에선 LIS가 그리디한 성질을 만족한다고 할 수 있겠습니다.

 

"수열의 마지막 수가 작을수록 더 긴 LIS를 만드는데 유리하다" 고 볼 수 있겠습니다.

 

이러한 점으로 다음과 같은 알고리즘을 한번 만들어 볼 수 있습니다.

알고리즘 설계

가장 긴 증가부분수열이 될 수 있는 녀석들을 Active list라고 부르도록 하겠습니다.

Increasing subsequence들 몇개들을 가지고 있으면서, longest가 될 수 있는 녀석을 알아내는 것인데,

위에서 알아낸 Greedy한 성질로 전체를 다 만들지 않고 추려낼 수가 있습니다.

 

같은 길이의 Increasing subsequence라면, 마지막 수가 가장 작은 녀석 하나만 알고 있어도 Longest IS를 구할 수 있다는 점입니다.

물론 i값을 0부터 N-1까지 증가시켜가면서 만드는 것이기 때문에 위치에 따른 dependency도 체크를 할 수있습니다.

 

알고리즘은 다음과 같습니다.

 

1. 만약 //(A[i]//)가 모든 Active list의 마지막 수 보다 작다면, 길이 1짜리 Active list를 하나 만든다.

2. 만약 //(A[i]//)가 모든 Active list의 마지막 수 보다 크다면, 가장 긴 Active list를 하나 복붙을 한 뒤, 마지막에 //(A[i]//)를 붙인다.

3. 만약 //(A[i]//)가 Active list 마지막 수들 중 가장 작은녀석 보다는 크고, 가장 큰 녀석 보다는 작다면, Active list 마지막 수 들 중에서 //(A[i]//)보다는 작은 녀석 중 가장 큰 리스트를 골라서, 그 리스트의 복사본 뒤에 //(A[i]//)를 붙인다.

 

끝까지 진행한 뒤, Active list중 가장 긴 녀석의 길이가 LIS의 길이가 됩니다.

 

{0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15}

위에 있는 수열로 위 알고리즘을 진행해보면, 아래와 같이 나옵니다. geeksforgeeks 블로그의 값을 그대로 긁어왔습니다.

 

 

 

위와 같이 진행이 됩니다.

 

그러면 이 Active List들을 잘 가지고 있어야 하는데 어떤식으로 구현을 하면 좋을까요?

 

구현 전략

 

NlgN 풀이를 하려는 이유는 배열의 길이가 꽤 길기 때문에 시간복잡도를 줄이기 위해서입니다.

그런데 저러한 Active List를 다 가지고 있게 되면, //(O(N^2)//)의 공간 복잡도를 갖게 되는데 NlgN으로 푸는 시간복잡도를 갖는 경우 대충 N이 10만 ~ 100만 정도 될 터인데, 이러한 N값의 N 제곱의 공간 복잡도는

필요 메모리 양이 꽤 많아서 보통 모두 할당할 수 없습니다.

 

그래서 저 Active list 전체를 다 저장하는 것 외의 방법을 찾아야 합니다.

 

그런데 Active list들의 데이터 특징을 좀 살펴보면, 같은 길이의 active list는 앞서 알게 된 그리디한 성질 때문에 하나만 가지고 있습니다. 그리고 모든 active list의 길이는 1,2,3,4 와 같은 식으로 같은 길이의 리스트는 하나만 나오고

점진적으로 1만큼 차이가 나는 것을 알 수 있습니다.

 

따라서 Active list의 마지막 숫자만 가지고 있어도 될 것 같은 느낌이 듭니다.

그렇게 저장할 경우 //(O(N)//)의 공간복잡도를 갖는 배열 하나만 가지고 있어도 됩니다.

따라서 Active list의 마지막 숫자의 값만 가지고 있는 배열을 따로 만들도록 하겠습니다.

 

이 배열을 list라는 벡터로 저장하고 구현을 한 소스코드 내용입니다.

#include <bits/stdc++.h>
using namespace std;
#define endl '\n'
typedef long long ll;
typedef pair<int, int> pii;
#define MAXN 1000006
int arr[MAXN];
int main() {
	int n;
	scanf("%d", &n);
	for (int i = 0; i < n; i++) {
		scanf("%d", arr + i);
	}
	vector<int> list;
	for (int i = 0; i < n; i++) {
		auto& a = arr[i];

		if (list.size() == 0) list.push_back(a);
		else if (a < list[0]) list[0] = a;
		else if (a > list.back()) list.push_back(a);
		else {
			auto it = upper_bound(list.begin(), list.end(), a - 1);
			*it = a;
		}
	}

	printf("%u\n", list.size());

	return 0;
}

 

이렇게해서 백준 12015번 문제인 가장 긴 증가하는 부분 수열 2 문제를 풀 수 있습니다.

 

 

 

--------------------------------------------------------------------------

http://en.wikipedia.org/wiki/Longest_increasing_subsequence

https://www.crocus.co.kr/681

https://www.acmicpc.net/workbook/view/801

https://jaimemin.tistory.com/1095

https://www.geeksforgeeks.org/longest-monotonically-increasing-subsequence-size-n-log-n/

https://jason9319.tistory.com/113

+ Recent posts