외판원 문제는 NP문제로 유명한 문제이다. 


여러 도시들이 주어져 있고, 모든 도시들에 대한 가중치가 주어졌을때, 단일 시작점부터 시작해서 모든 도시를 단 한번씩만 방문하여 다시 시작점으로 돌아오는데 드는 최단 거리를 구하는 문제이며, 말은 그냥 일반 그래프 문제인거 같아 그리 어려워 보이지 않지만, 무려 NP-hard 문제에 속한다.


이 문제가 어려운게 완전 연결 그래프라는 점인데, 단순히 완전 탐색으로 진행을 하면 무려 O(N!)라는 시간복잡도가 나온다.


이는 10개만 탐색해도 3,628,800이라는 값이 나오며,  20개는 2,432,902,008,176,640,000개라는 상상하기도 힘든 값이 나온다. 

완전 탐색으로도 풀이는 가능하지만 왠만하면 지양해야 하는 방법이다.

DP로도 생각보다 빠른 시간으로 탐색이 안되서, 실제로는 그리디로 '최단 거리'는 못구해도 '그나마 괜찮은 거리'를 구하는 정도로 사용하는 듯 하다.



<C++ 완전 탐색 TSP>

0번 도시부터 시작해서 모든 도시를 순회한 후 최종적으로 다시 0번으로 돌아오는 코드이다.

무식하게 모든 경우의 수를 일일히 다 계산하는 방법이다.

#include <iostream> #include <vector> #include <cmath> #include <algorithm> using namespace std; int n; //가중치를 저장하기 위핸 배열 int dist[15][15]; int TSP(vector<int> path, vector<bool> visited, int len){ //모든 도시 다 방문했을 경우 if(path.size() == n) return len+dist[path.back()][0]; int ret = 987654321; for(int next=0; next<n;next++){ //방문 했다면 패스 if(visited[next]==true) continue; int cur = path.back(); path.push_back(next); visited[next] = true; ret = min(ret,TSP(path,visited,len+dist[cur][next])); visited[next] = false; path.pop_back(); } return ret; } int main(){ cin >> n; for (int i = 0; i < n; i++){ for (int j = 0; j < n; j++){ cin >> dist[i][j]; } } vector<int> path(1, 0); // 경로를 저장할 벡터, 시작 도시 0번도시 선택. vector<bool> visited(n, false); // 방문 여부를 저장할 벡터. false로 초기화. visited[0] = true; // 출발 도시 방문여부 체크. double ret = TSP(path, visited, 0); cout << ret << endl; }


하지만 앞서 말했듯이 이 코드는 15개 이상부터는 어마어마한 시간이 걸린다.


그래서 완전 탐색 말고 DP를 사용하는 방법을 보도록 하자.




<C++ TSP 동적계획법>


동적계획법으로 구현하려면 이를 부분 문제로 쪼개야 한다.

완전 탐색으로 구현했을 때 쓰는 값들은 지금까지의 경로를 저장한 벡터, 방문여부를 저장한 벡터, 지금까지 연결된 거리이다.

동적계획법은 기본적으로 각각 독립적인 부분 문제로 쪼개지므로 지금까지 연결된 거리는 필요가 없고, 현재점을 기준으로 연산을 해야한다.


즉 


라는 점화식으로 풀 수 있다.(여기서 n은 0부터 next까지로, visited를 검사해서 방문한 적이 없는 값으로만 조사를 진행해야된다.)


시작점과, 현재 방문한 도시를 기준으로 모든 도시를 순회해서 얻을 수 있는 최소 거리를 출력하는 식이다.


그럼 거리에 대한 정보를 어떻게 메모이제이션을 할까? 해당 시작점으로 출발하는 경로는 메모이제이션 할때 어떻게 표현할까?


이에 대한 답은 비트마스킹이다. 비트로 도시의 방문 여부를 체크하는 것이다.

예를 들어 0번 도시와 2번 도시를 방문했다면, 00000101 = 3으로 체크가 될 것이고,  0번,3번,5번 도시를 방문했다면 00101001 = 41이 될것이다.

즉 1번 건물에서 1,2,3번 건물을 방문한 것은 cache[1][7]에 메모이제이션 하면 될 것이다.


int형은 4바이트, 총 32비트이기 때문에 int형 변수만으로도 32개 도시를 표현 할 수 있다.


코드를 작성하기전에 비트 연산을 간단하게 보도록 하자.

1<<n : 1을 n만큼 왼쪽으로 시프트한다. 즉 1<<4이면 10000이다.

a = a | (1<<n) : a라는 변수의 n번째 비트를 켠다.

a & (1<<n) : a라는 변수의 n번째 비트가 켜있으면 1<<n을, 꺼있으면 0을 반환한다.

a -=(1<<n) : a라는 변수의 n번째 비트를 끈다(1->0). 단 무조건 켜져 있을 때만 사용해야한다.

a &=~(1<<n) : a라는 변수의 n번째 비트를 끈다. 꺼져 있으면 꺼진 상태로 유지한다.

a ^=(1<<n) : a변수의 n번째 비트를 토글한다. 켜있으면 끄고, 꺼있으면 킨다.


즉 위 비트연산을 이용하면 visited&(1 << next)는 next번째 도시의 방문 여부를 확인하는 것이고.

또한 재귀 호출의 visited인자로 visited | (1 << next)를 전달 하는 것은, 해당 도시의 방문 여부를 체크해서 전달하는 것이다.


밑은 비트마스킹 DP를 이용해 구현한 코드이다.

#include <cstdio> #include <cmath> #include <algorithm> #define MAX_VALUE 987654321.0 using namespace std; int n; //경로를 저장할 dp int cache[17][65536], dist[17][17]; int TSP(int cur, int visited) { //점이 10개라면, 100000000000-1 =011111111111; if (visited == (1 << n)-1) return dist[cur][0]; int& ret = cache[cur][visited]; if (ret != 0) return ret; ret = MAX_VALUE; for (int next = 0; next <= n; next++) { if (visited&(1 << next))continue; if (dist[cur][next]==0) continue; ret = min(ret,TSP(next, visited | (1 << next)) + dist[cur][next]); } return ret; } int main(){ scanf("%d", &n); for(int i=0;i<n;i++){ for(int j=0;j<n;j++){ scanf("%d",&dist[i][j]); } } printf("%d",TSP(0,1)); }


완전 탐색으로 푸는 TSP는 O(N!)인것에 반해 DP는 O(2^N*N^2)이다. DP로 풀어도 빠른 속도는 아니지만 완전 탐색에 비해 현저하게 빨라지긴 했다.



<경로 추적하기>


위 소스 코드에서 메모이제이션한 값으로 경로를 추적해보았다.

방법은 간단하다. 이 소스코드는 0이라는 위치부터 시작하므로,

 전체 거리 - 0부터 다른 경로까지의 거리 = cache[k][masking + (1 << k)](k에서 masking에 켜져있는 비트의 도시들과 k번째 도시를 방문한 값)을 만족하는 것을 찾으면, k가 바로 다음 경로가 되는 것이다.

이 k를 경로를 저장하기 위한 벡터에 저장하자.  그 후k를 다음 경로를 찾기 위해 비교하기 위한 변수 piv에 넣는다.

그리고 비교할 거리를 masking에 켜진 도시들과 k를 방문했으며, k부터 시작하는 거리인 cache[k][masking + (1 << k)]으로 바꾼 후, k번째 비트를 방문했다는 표시로 켜준뒤 전과 같은 연산을 반복적으로 진행하면 된다.


void printPath(long double distance){ int piv = 0, masking = 1; //cache 배열을 탐색해가며 다음 경로를 찾는다. for(int j = 0; j<=n;j++){ for(int k = 0; k <= n; k++){     if(masking&(1 << k)) continue;      if (distance - dist[piv][k] == cache[k][masking + (1 << k)]) {     //다음 경로 저장      path.push_back(k);     distance = cache[k][masking + (1 << k)];      piv = k; masking += (1 << k); } } } //경로 출력 for(int i=0; i<path.size();i++) printf("(%d)->",path[i]); printf("(0)"); }



[문제 링크]



LCS2는 다음 포스트에 있는 내용과 같다.


http://hsp1116.tistory.com/37



#include <cstdio>
#include <algorithm>
#include <vector>
#include <cstring>
using namespace std;
vector<char> output;
int cache[1001][1001];
char input[1001],compare[1000];
int LCS(int m,int n){
    memset(cache,0,sizeof(cache));
    for(int i=1;i<=m;i++){
        for(int j=1; j<=n;j++){
            if(compare[i-1] == input[j-1]){
                cache[i][j] = cache[i-1][j-1] +1;
                }
            else{
                cache[i][j] = std::max(cache[i-1][j], cache[i][j-1]);
            }
        }
    }
    return cache[m][n];
}
void backTracking(int m, int n){
    if(m==0 || n ==0) return;
    if(cache[m][n] > cache[m-1][n-1] && cache[m][n] > cache[m][n-1] && cache[m][n] > cache[m-1][n]){
        output.push_back(input[n-1]);
        backTracking(m-1, n-1);
    }else if(cache[m][n] > cache[m-1][n] && cache[m][n] == cache[m][n-1]){
        backTracking(m, n-1);
    }else{
          backTracking(m-1, n);
    }
}
int main(){
    scanf("%s%s",input,compare);
    int m = strlen(compare), n = strlen(input);
    printf("%d\n",LCS(m,n));
    backTracking(m,n);
    for(int i=output.size()-1;i>=0;i--)
        printf("%c",output[i]);
}


'Algorithm > Problems' 카테고리의 다른 글

백준 - 1238 파티  (0) 2016.04.22
백준 - 1753 최단경로  (1) 2016.04.22
백준 - 9251 LCS  (0) 2016.04.17
알고스팟 - KOOGLE  (0) 2016.04.14
백준 - 1725, 6549 히스토그램 / 알고스팟 - FENCE  (0) 2016.03.28


[문제 링크]



LCS는 다음 포스트에 있는 내용과 같다.


http://hsp1116.tistory.com/37




#include <cstdio>
#include <algorithm>
#include <cstring>
int cache[1001][1001];
char input[1001],compare[1000];
int LCS(){
    int n = strlen(compare), m = strlen(input);
    memset(cache,0,sizeof(cache));
    for(int i=1;i<=n;i++){
        for(int j=1; j<=m;j++){
            if(compare[i-1] == input[j-1]){
                cache[i][j] = cache[i-1][j-1] +1;
                }
            else{
                cache[i][j] = std::max(cache[i-1][j], cache[i][j-1]);
            }
        }
    }
    return cache[n][m];
}
int main(){
    scanf("%s %s",input,compare);
    printf("%d\n",LCS());
}

'Algorithm > Problems' 카테고리의 다른 글

백준 - 1753 최단경로  (1) 2016.04.22
백준 - 9252 LCS 2  (0) 2016.04.17
알고스팟 - KOOGLE  (0) 2016.04.14
백준 - 1725, 6549 히스토그램 / 알고스팟 - FENCE  (0) 2016.03.28
백준 - 2493 탑  (1) 2016.03.28

공통 부분 수열이란, 두 문자열이 공통으로 가지고 있는 부분 수열을 말한다.


예를 들어 문자열


문자열 A : CDABE

문자열 B : CDEGT

가 있다면,


공통 부분 수열은

{},{C},{D},{E},{C,D},{D,E},{C,E},{C,D,E}

일 것이다.





1. 최장 공통 부분 수열 길이 구하기

최장 공통 부분 수열은 공통 부분 수열 중에서 길이가 가장 긴 부분 수열을 말한다.

위 예제 최장 공통 부분 수열은{C,D,E}이고, 길이가 3이다.


최장 증가 부분 수열은 또한 다음과 같은 정의를 따른다. 각 점화식을 에제를 들어 확인해보자.





(1) Ai != Bj

두 문자열 CDABE,CDEGT의 최장 공통 부분 수열을 구한다고 해보자.

여기서 맨 마지막 문자인 E와 T가 각각 다르므로 다음과 같은 경우를 고려 할 수 있다.

1. 최장공통부분 수열이 E로 끝나는 경우.

이 경우 K는 최장 공통 부분 수열에 아무련 영향을 끼치지 않으므로 없어도 상관 없다.

즉 LCS(i,j) = LCS(i,j-1)이다.


2. 최장 공통 부분 수열이 T로 끝나는경우

이 경우 E가 최장 공통 부분 수열에 아무런 영향을 끼치지 않으므로 지워도 상관없다.

이 경우엔 LCXS(i,j) = LCS(i-1,j)이다 


3. 둘 다 해당이 되지 않는 경우.

둘 다 지워도, 하나만 지워도 최장 증가 부분 수열에 영향을 끼치지 않는다. 따라서 고려 할 필요가 없다.


두 문자가 일치하지 않을 때, 최소 둘 중 하나는 최장 공통 부분 수열에 영향을 끼치지 않으므로 둘 중 하나를 지워도 상관없다.

즉 둘 중 하나를 지웠을 때의 더 큰 LCS가 LCS(i,j)이다.

따라서 LCX(i,j) = max(LCS(i-1,j),LCS(i,j-1))임을 알 수 있다.



(2) Ai == Bj

두 문자열 CDABE CDE라고 가정해보자. 맨 뒤의 문자 E가 설 일치하는데, 공통 문자인 E는 최장 공통 부분 수열에 반드시 포함될 것이다.

따라서 이 두 문자열을 지운다면, 최장 공통 부분 수열에서 또한 E가 사라질 것이라는 것을 알 수 있다.

즉 최장 증가 부분 수열의 길이 또한 1 감소하므로, LCX(i,j) = LCS(i-1,j-1) + 1이다.





이제 위 공식을 이용하여 구현하면 된다. 각 인덱스는 현재 문자열의 길이라고 생각하면 된다.


 

 

 C (j=1)

 D (j=2)

 E (j=3)

 G (j=4)

 T (j=5)

 

 0

 0

 0

 0

 0

 0

 C (i=1)

 0

 

 

 

 

 

 D (i=2)

 0

 

 

 

 

 

 A (i=3)

 0

 

 

 

 

 

 B (i=4)

 0

 

 

 

 

 

 E (i=5)

 0

 

 

 

 




맨첨 1행, 1열들은 모두 0으로 치워지는데 이는 빈 수열을 뜻한다. 빈 수열과 문자열의 최장 증가 부분 수열은 0이기 때문이다.



밑은 앞서 확인한 공식으로 작성한 테이블이다.


 

 

 C (j=1)

 D (j=2)

 E (j=3)

 G (j=4)

 T (j=5)

 

 0

 0

 0

 0

 0

 0

 C (i=1)

 0

 1

 1

 1

 1

 1

 D (i=2)

 0

 1

 2

 2

 2

 2

 A (i=3)

 0

 1

 2

 2

 2

 2

 B (i=4)

 0

 1

 2

 2

 2

 2

 E (i=5)

 0

 1

 2

3

 3

 3


LCS(5,5)=3으로, 최장 공통 부분 수열의 길이는 3임을 알 수 있다.


int LCS(string& input, string& compare){
    int cache[1001][1001];
    memset(cache,0,sizeof(cache));
    for(int i=1;i<=compare.length();i++){
        for(int j=1; j<=input.length();j++){
            if(compare[i-1] == input[j-1]){
                cache[i][j] = cache[i-1][j-1] +1;
                }
            else{
                cache[i][j] = max(cache[i-1][j], cache[i][j-1]);
            }
        }
    }
    return cache[compare.length()][input.length()];
}



1. 최장 공통 부분 수열 구하기


이는 우리가 구한 2차원 배열을 역추적 하여 구할 수 있다.


 

 

 C (j=1)

 D (j=2)

 E (j=3)

 G (j=4)

 T (j=5)

 

 0

 0

 0

 0

 0

 0

 C (i=1)

 0

 1

 1

 1

 1

 1

 D (i=2)

 0

 1

 2

 2

 2

 2

 A (i=3)

 0

 1

 2

 2

 2

 2

 B (i=4)

 0

 1

 2

 2

 2

 2

 E (i=5)

 0

 1

 2

3

 3

 3



최장 증가 부분 수열을 찾는 공식을 잘 고려해 보자.


우리가 찾아야 하는건 Ai == Bi인 문자이다.


Ai == Bj일 때 LCS(i,j) = LCS(i-1,j-1) + 1였는데. 이를 고려해 역추적 해나가면 된다.

다만 또 고려할 것이 있는데, LCX(i,j) = max(LCS(i-1,j),LCS(i,j-1))의 경우이다.

Ai != Bj일 지라도, LCS(i,j) = max(LCS(i-1,j),LCS(i,j-1))를 통해 LCS(i-1,j-1) + 1가 도출 될 수 있다.

따라서 LCS(i-1,j), LCS(i,j-1)이 둘 다 LCS(i,j)보다 작으면서, LCS(i-1,j-1)이 LCS(i,j)보다 작은 경우가 Ai==Bj일 때이다. 

즉 LCS[i][j] > LCS[i-1][j-1] && LCS[i][j] > LCS[i][j-1] && LCS[i][j] > LCS[i-1][j])를 조건으로 코드를 구현하면 된다.


밑은 해당 코드이며, output에 최장 공통 부분 수열 문자열이 저장된다.


string output;
void backTracking(int m, int n){
    if(m==0 || n ==0) return;
    if(cache[m][n] > cache[m-1][n-1] && cache[m][n] > cache[m][n-1] && cache[m][n] > cache[m-1][n]){
        //문자열 인덱스는 캐시 인덱스보다 1씩 더 작다. 
        output = input[n-1] + output;
        backTracking(m-1, n-1);
    }else if(cache[m][n] > cache[m-1][n] && cache[m][n] == cache[m][n-1]){
        backTracking(m, n-1);
    }else{
          backTracking(m-1, n);
    }
}


이 테스트를 위해 먼저 0부터 n까지 중복되지 않는 섞여있는 배열을 만들어내는 함수를 작성하였다.

이는 0부터 n까지 배열에 숫자를 삽입 후, 이를 셔플로 섞는 방법으로 구현하였다. 

그 코드는 다음과 같다.


//난수 배열 생성기 
int myrandom (int i) { return rand()%i;}
vector<int> randGenerator(int n){
    vector<int> v;
    srand(time(NULL));
    for(int i=0;i<=n;i++){
        v.push_back(i);
    } 
    random_shuffle(v.begin(),v.end(),myrandom);
    return v;
}


입력으로 배열의 크기를 받고, 벡터를 반환한다.



먼저 바로 전 포스트에서 작성한 알고리즘이 정상적으로 작동하는지 확인하겠다.




5개의 정렬이 모두 정상적으로 작동함을 확인 할 수 있었다.


모두 정상적으로 작동하는것을 확인 했으니 이제 모든 정렬의 성능을 테스트 해보도록 하겠다.


이 테스트는 10부터 시작하여 배열 범위를 10배씩 증가시키며 진행하였다.



1. n = 10, n = 100



둘 다 수행 시간이 0으로 측정되었다. 100까지 정도의 범위로는 어떤 정렬을 사용해도 크게 상관 없다는 것을 알 수 있다.



2. n = 10,00



1000개 부터는 속도 차이가 눈에 보이기 시작한다.

선택 정렬, 삽입 정렬, 버블 정렬은 모두 시간 복잡도가 n^2이었다. 그에 따라 셋의 시간이 유사하게 나올줄 알았으나.

삽입 정렬은 삽입 하고자 하는 위치를 찾았을 경우 더이상 비교하지 않으며, 최선의 경우 O(n)에 수행되기 때문에 더 빠른 속도를 보였다.

선택 정렬과 버블 정렬은 둘 다 모든 배열을 비교하는 정렬 알고리즘으로 시간이 유사하게 나옴을 알 수 있다.


그리고 합병 정렬과 퀵 정렬은 둘 다 nlgn에 수행되지만, 앞서 말했다 싶이 퀵 정렬이 조금 더 빠른 성능을 보인다.



3. n = 10,000


각 정렬 별로의 시간은 2번에서 설명한 바와 같다.

그리고 하나 더 알수 있는게, t선택,삽입,버블 정렬의 수행 시간이 이전보다 약 100배정도 증가했다는 사실이다.

 이로 시간 복잡도가 O(n^2)이므로 n이 10배 증가하면 100배한다는 것으로 시간 복잡도의 타당성을 알 수 있다.


또한 합병 정렬의 시간복잡도가 nlgn이다. 1000과 10000는 약 13배 정도 차이 나는데 미세한 값으로 변수가 발생하는 부분이 있기 때문에

정확한 증명을 위해서는 더욱 큰 값을 적용할 필요가 있어보인다.


이 와중에도 퀵 정렬은 시간 측정이 안될 정도로 정말 빠른 성능을 보여주었다. 



3. n = 100,000


선택,삽입,버블 정렬은 앞과 같다.



합병 정렬은 숫자가 커질수록 nlgn의 증가율과 점점 가까워지고 있음을 확인하였다. 

(시간 측정은 어느정도 변수가 있기 때문에 정확한 측정값은 확인하기 힘들었다)

퀵 정렬은 정렬 갯수가 10만이 넘어서야 측정이 되었다.


4. n = 1,000,000



선택,삽입,버블 정렬의 시간이 말도안되게 길어지기 시작했기 때문에 저 셋은 테스트하지 않았다.

사실은 시도는 하였지만 지금까지 측정 시간으로 볼 때 이론상 선택, 버블은 9000초, 삽입은 4000초로 약 2시간 반, 1시간 정도 걸리기 때문에 포기하였다.




5. n = 10,000,000



합병정렬은 유사한 증가율을 보이는데 퀵 정렬은 왜인지 갑자기 30배 가까이 증가했다.

그래도 여전히 합병 정렬보다 빠른 속도를 가지고 있다.




1억개부터는 저 둘로도 마냥 빠르게 측정되지 않기 때문에 여기까지만 테스트하였다. 

지금까지 테스트로 퀵 정렬이 상대적으로 높은 성능을 보임을 알 수 있었고, 합병 정렬은 퀵 정렬에 미치지는 못하지만 그래도 나머지 정렬에 비해 상당한 성능임을 확인 할 수 있었다.


또한 버블 정렬과 선택 정렬, 삽입 정렬은 데이터 10만개 이상부터는 실제로 쓰기엔 어려운 성능을 보였다. 그 이하의 데이터에선 합병, 퀵 정렬보단 느리지만 사용해도 크게 무방할것 같다는 생각이 든다.


밑은 이 테스트를 위해 작성한 코드이다.





#include <cstdio>
#include <algorithm>
#include <vector>
#include <ctime>
using namespace std; 

//난수 생성기 
int myrandom (int i) { return rand()%i;}
vector<int> randGenerator(int n){
    vector<int> v;
    srand(time(NULL));
    for(int i=0;i<=n;i++){
        v.push_back(i);
    } 
    random_shuffle(v.begin(),v.end(),myrandom);
    return v;
}

//선택 정렬 
void selectionSort(vector<int> v){
    for(int i=0;i<v.size()-1;i++){
        for(int j=i+1;j<v.size();j++)
            if(v[i]>=v[j])
                swap(v[i],v[j]);
    }
//  printf("--선택 정렬 결과--\n");
//  for(int i = 0; i<v.size();i++)
//      printf("%d ",v[i]);
//  printf("\n\n");
}

// 삽입 정렬 
void insertionSort(vector<int> v){
    for(int i=1;i<v.size();i++){
        int key = v[i], j = i-1;
            while(j>=0 && key <v[j]){
                swap(v[j], v[j+1]);
                j--;
            }
            v[j+1] = key;
    }
//  printf("--삽입 정렬 결과--\n");
//  for(int i = 0; i<v.size();i++)
//      printf("%d ",v[i]); 
//  printf("\n\n");
}

//버블 정렬 
void bubbleSort(vector<int> v){
    for(int i=0;i<v.size()-1;i++){
        for(int j=1; j<v.size()-i;j++)
            if(v[j-1] > v[j])
                swap(v[j-1],v[j]);
    }
//  printf("--버블 정렬 결과--\n");
//  for(int i = 0; i<v.size();i++)
//      printf("%d ",v[i]);
//  printf("\n\n");
}


//병합 정렬 
void merge(vector<int>& v, int s, int e, int m) {
    vector<int> ret;
    int i = s, j = m + 1, copy = 0;
    
    //결과를 저장할 배열에 하나씩 비교하여 저장한다. 
    while (i <= m && j <= e) {
        if (v[i] < v[j])ret.push_back(v[i++]);
        else if (v[i] > v[j])ret.push_back(v[j++]);
    }

    //남은 값들을 뒤에 채워넣어준다. 
    while (i <= m)  ret.push_back(v[i++]);
    while (j <= e)  ret.push_back(v[j++]);
    
    //원래 배열에 복사해준다. 
    for (int k = s; k <= e; k++) {
        v[k] = ret[copy++];
    }
}

void mergeSort(vector<int>& v, int s, int e){
    if(s<e){
        int m = (s+e)/2;
        /*divide, 분할*/
        mergeSort(v,s,m);//s부터 m까지
        mergeSort(v,m+1,e); //m+1부터 e까지 
        /*conquer, 병합*/
        merge(v,s,e,m);
    }
}



//퀵 정렬 
void qsort(vector<int>& v, int s, int e) {
    int pivot = v[s];
    int bs = s, be = e;
    while (s<e) {   
        while (pivot <= v[e]&&s<e) e--;
        if (s>e) break;
        while (pivot >= v[s]&&s<e) s++;
        if (s>e) break;
        std::swap(v[s], v[e]);
    }
    std::swap(v[bs], v[s]);
    if(bs<s)
        qsort(v,bs, s-1);
    if(be>e)
        qsort(v,s+1, be);

}


int main(){
    clock_t start,end;
    int n;// 숫자 갯수
    printf("랜덤 숫자 범위(1~n) : ");
    scanf("%d",&n);
    vector<int> v = randGenerator(n);
    
//  printf("정렬 전 : ");
//  for(int i=0;i<n;i++)
//      printf("%d ",v[i]);
    printf("\n\n");
    start = clock();    
    selectionSort(v);
    end = clock();
    printf("선택 정렬 수행시간 : %lf\n",(double)(end-start)/CLOCKS_PER_SEC);
    
    start = clock();
    insertionSort(v);
    end = clock();
    printf("삽입 정렬 수행시간 : %lf\n",(double)(end-start)/CLOCKS_PER_SEC);
    
    start = clock();
    bubbleSort(v);
    end = clock();
    printf("버블 정렬 수행시간 : %lf\n",(double)(end-start)/CLOCKS_PER_SEC);
    
    vector<int> v2 = v; 
    
    start = clock();
    mergeSort(v2,0,v.size()-1);  
    end = clock();
//  printf("--병합 정렬 결과--\n");
//  for(int i=0;i<v2.size();i++)
//      printf("%d ",v2[i]);
//  printf("\n\n"); 
    printf("병합 정렬 수행시간 : %lf\n",(double)(end-start)/CLOCKS_PER_SEC);


    start = clock();
    qsort(v,0,v.size()-1); 
    end = clock();
//  printf("--퀵 정렬 결과--\n");
//  for(int i=0;i<v.size();i++)
//      printf("%d ",v[i]);
//  printf("\n"); 
    printf("퀵 정렬 수행시간   : %lf\n",(double)(end-start)/CLOCKS_PER_SEC);
    
}


정렬 알고리즘은 n개의 숫자가 입력으로 주어졌을 때, 이를 사용자가 지정한 기준에 맞게 정렬하여 출력하는 알고리즘이다.

예를 들어 n개의 숫자가 저장되어있는 배열을, 오름차순의 조건으로 작성하여 입력하면 오름차순으로 정렬된 배열을 출력으로 구할 수 있다.

정렬 알고리즘은 정말 다양한데, 이에 따라 각각의 수행시간도 천차 만별이다.

여기서는 우선 O(N^2), O(NlgN)인 알고리즘을 보려고 한다.


1. 선택 정렬(Selection Sort)


선택 정렬은 이름에 맞게 현재 위치에 들어갈 값을 찾아 정렬하는 배열이다. 현재 위치에 저장 될 값의 크기가 작냐, 크냐에 따라 최소 선택 정렬(Min-Selection Sort)와 최대 선택 정렬(Max-Selection Sort)로 구분 할 수 있다.

최소 선택 정렬은 오름차순으로 정렬될 것이고, 최대 선택 정렬은 내림차순으로 정렬될 것이다.


기본 로직은 다음과 같다.

① 정렬 되지 않은 인덱스의 맨 앞에서 부터, 이를 포함한 그 이후의 배열값 중 가장 작은 값을 찾아간다.

(정렬되지 않은 인덱스의 맨 앞은, 초기 입력에서는 배열의 시작위치일 것이다.)

② 가장 작은 값을 찾으면, 그 값을 현재 인덱스의 값과 바꿔준다.

③ 다음 인덱스에서 위 과정을 반복해준다.


이 정렬 알고리즘은 n-1개,n-2개,..,1개씩 비교를 반복한다.

배열이 어떻게 되어있던지간에 전체 비교를 진행하므로 시간복잡도는 O(n^2)이다.


공간복잡도는 단 하나의 배열에서만 진행하므로 O(n)이다.



<선택 정렬>


선택 정렬 C++ 소스코드


(이전에는 temporary 변수에 해당 키 값을 저장하지 않고 그냥 그때그때 스왑하도록 하였으나.

다른 분들의 피드백으로 수정하였습니다)

void selectionSort(vector<int> v){
    for(int i=0;i<v.size()-1;i++){
        int tmp = i;
        for(int j=i+1;j<v.size();j++){
            if(v[tmp]>=v[j]) tmp = j;
        }
        swap(v[i], v[tmp]);
    }
}



2. 삽입 정렬(Insertion Sort)


삽입 정렬은 현재 위치에서, 그 이하의 배열들을 비교하여 자신이 들어갈 위치를 찾아, 그 위치에 삽입하는 배열 알고리즘이다.


기본 로직은 다음과 같다.

① 삽입 정렬은 두 번째 인덱스부터 시작한다. 현재 인덱스는 별도의 변수에 저장해주고, 비교 인덱스를 현재 인덱스 -1로 잡는다.

② 별도로 저장해 둔 삽입을 위한 변수와, 비교 인덱스의 배열 값을 비교한다. 

③ 삽입 변수의 값이 더 작으면 현재 인덱스로 비교 인덱스의 값을 저장해주고, 비교 인덱스를 -1하여 비교를 반복한다.

④ 만약 삽입 변수가 더 크면, 비교 인덱스+1에 삽입 변수를 저장한다.


이 정렬 알고리즘 또한 최악의 경우(역으로 정렬되어있을 경우)엔 n-1개,n-2개,..,1개씩 비교를 반복하여 시간복잡도는 O(n^2)이지만.

이미 정렬되어 있는 경우에는 한번씩 밖에 비교를 하지 않아 시간 복잡도는 O(n)이다.
하지만 상한을 기준으로 하는 Big-O notation은 최악의 경우를 기준으로 평가하므로 삽입 정렬의 시간복잡도는 O(n^2)이다.

공간복잡도는 단 하나의 배열에서만 진행하므로 O(n)이다.


<삽입 정렬>

삽입 정렬 C++ 소스코드

//삽입 정렬
void insertionSort(vector<int> v){
    for(int i=1;i<v.size();i++){
        int key = v[i], j = i-1;
            while(j>=0 && key <v[j]){
                swap(v[j], v[j+1]);
                j--;
            }
            v[j+1] = key;
        }   
}


위 코드는 위키피디아 이미지를 통해 좀 더 이해를 쉽게하고자 swap 함수를 이용하였으나, 사실 swap이 아닌 대입연산을 사용하는 것이 조금 더 적합하다.

swap(v[j], v[j+1]) => v[j+1] = v[j]


3. 버블 정렬(Bubble Sort)


버블 정렬은 매번 연속된 두개 인덱스를 비교하여, 정한 기준의 값을 뒤로 넘겨 정렬하는 방법이다. 

오름차순으로 정렬하고자 할 경우, 비교시마다 큰 값이 뒤로 이동하여, 1바퀴 돌 시 가장 큰 값이 맨 뒤에 저장된다.

맨 마지막에는 비교하는 수들 중 가장 큰 값이 저장 되기 때문에, (전체 배열의 크기 - 현재까지 순환한 바퀴 수)만큼만 반복해 주면 된다.


기본 로직은 다음과 같다.

① 삽입 정렬은 두 번째 인덱스부터 시작한다. 현재 인덱스 값과, 바로 이전의 인덱스 값을 비교한다.

② 만약 이전 인덱스가 더 크면, 현재 인덱스와 바꿔준다. 

③ 현재 인덱스가 더 크면, 교환하지 않고 다음 두 연속된 배열값을 비교한다.

④ 이를 (전체 배열의 크기 - 현재까지 순환한 바퀴 수)만큼 반복한다.


이 정렬 알고리즘은 1부터 비교를 시작하여, n-1개,n-2개,..,1개씩 비교를 반복하며,

선택 정렬과 같이 배열이 어떻게 되어있던지간에 전체 비교를 진행하므로 시간복잡도는 O(n^2)이다.

공간복잡도도 이 또한 단 하나의 배열에서만 진행하므로 O(n)이다.


<버블 정렬>


버블 정렬 C++ 소스코드

//버블 정렬 
void bubbleSort(vector<int> v){
    for(int i=0;i<v.size()-1;i++){
        for(int j=1; j<v.size()-i;j++)
            if(v[j-1] > v[j])
                swap(v[j-1],v[j]);
        
    }
}




4. 합병 정렬(Merge Sort)


합병 정렬은 분할 정복(Divide and conquer) 방식으로 설계된 알고리즘이다. 분할 정복은 큰 문제를 반으로 쪼개 문제를 해결해 나가는 방식으로,

분할은 배열의 크기가 1보다 작거나 같을 때 까지 반복한다.


입력으로 하나의 배열을 받고, 연산 중에 두개의 배열로 계속 쪼게 나간 뒤, 합치면서 정렬해 최후에는 하나의 정렬을 출력한다.


합병은 두 개의 배열을 비교하여, 기준에 맞는 값을 다른 배열에 저장해 나간다.

오름차순의 경우 배열 A,배열 B를 비교하여 A에 있는 값이 더 작다면 새 배열에 저장해주고, A인덱스를 증가시킨 후 A,B의 반복을 진행한다.

이는 A나 B중 하나가 모든 배열값들을 새 배열에 저장할 때 까지 반복하며, 전부 다 저장하지 못한 배열의 값들은 모두 새 배열의 값에 저장해준다.


분할 과정의 기본 로직은 다음과 같다.

① 현재 배열을 반으로 쪼깬다. 배열의 시작 위치와, 종료 위치를 입력받아 둘을 더한 후 2를 나눠 그 위치를 기준으로 나눈다.

② 이를 쪼갠 배열의 크기가 0이거나 1일때 까지 반복한다.


합병 과정의 기본 로직은 다음과 같다.

① 두 배열 A,B의 크기를 비교한다. 각각의 배열의 현재 인덱스를 i,j로 가정하자.

② i에는 A배열의 시작 인덱스를 저장하고, j에는 B배열의 시작 주소를 저장한다.

③ A[i]와 B[j]를 비교한다. 오름차순의 경우 이중에 작은 값을 새 배열 C에 저장한다. 

    A[i]가 더 컸다면 A[i]의 값을 배열 C에 저장해주고, i의 값을 하나 증가시켜준다.

④ 이를 i나 j 둘중 하나가 각자 배열의 끝에 도달할 때 까지 반복한다.

⑤ 끝까지 저장을 못한 배열의 값을, 순서대로 전부 다 C에 저장한다.

⑥ C 배열을 원래의 배열에 저장해준다.


이 정렬 알고리즘은 분할 과정과 합병 과정이 나뉘어진다.


합병 과정은 두 배열 A,B를 정렬하기 때문에, A배열의 크기를 N1, B배열의 크기를 N2라고 할 경우

O(n1+n2)와 같다. 배열 A와 배열 B는 하나의 배열을 나눈 배열들이기 때문에 전체 배열의 길이가 N이라고 할 경우

N = N1 + N2이므로 O(N)이라고 할 수 있다.


분할 과정은 lgN만큼 일어나는데, 이 이유는 간단하다. 크기가 N인 배열을 분할하면, 한번 분할하면 N/2,N/2 2개, 그다음 분할하면 N/4,N/4,N/4,N/4 4개.

즉 분할 과정은 매번 반씩 감소하므로 lgN만큼 반복해야 크기가 1인 배열로 분할 할 수 있다.


각 분할별로 합병을 진행하므로, 합병정렬의 시간 복잡도는 O(NlgN) 이다.

사용하는 공간은 정렬을 위한 배열을 하나 더 생성하므로 2N개 사용한다. 


<합병 정렬>

합병 정렬 C++ 소스코드

//합병 void merge(vector<int>& v, int s, int e, int m) { vector<int> ret; int i = s, j = m + 1, copy = 0; //결과를 저장할 배열에 하나씩 비교하여 저장한다. while (i <= m && j <= e) { if (v[i] < v[j])ret.push_back(v[i++]); else if (v[i] > v[j])ret.push_back(v[j++]); } //남은 값들을 뒤에 채워넣어준다. while (i <= m) ret.push_back(v[i++]); while (j <= e) ret.push_back(v[j++]); //원래 배열에 복사해준다. for (int k = s; k <= e; k++) { v[k] = ret[copy++]; } } //합병 정렬 void mergeSort(vector<int>& v, int s, int e){ if(s<e){ int m = (s+e)/2; /*divide, 분할*/ mergeSort(v,s,m); //s부터 m까지 mergeSort(v,m+1,e); //m+1부터 e까지 /*conquer, 합병*/ merge(v,s,e,m); } }




5. 퀵 정렬(Quick Sort)


퀵 정렬 또한 분할 정복(Divide and conquer)을 이용하여 정렬을 수행하는 알고리즘이다. 

pivot point라고 기준이 되는 값을 하나 설정 하는데, 이 값을 기준으로 작은 값은 왼쪽, 큰 값은 오른쪽으로 옮기는 방식으로 정렬을 진행한다.

이를 반복하여 분할된 배열의 크기가 1이되면 배열이 모두 정렬 된 것이다.


기본 로직은 다음과 같다.

① pivot point로 잡을 배열의 값 하나를 정한다. 보통 맨 앞이나 맨 뒤, 혹은 전체 배열 값 중 중간값이나나 랜덤 값으로 정한다.

② 분할을 진행하기에 앞서, 비교를 진행하기 위해 가장 왼쪽 배열의 인덱스를 저장하는 left 변수, 가장 오른쪽 배열의 인덱스를 저장한 right변수를 생성한다. 

③ right부터 비교를 진행한다. 비교는 right가 left보다 클 때만 반복하며. 비교한 배열값이 pivot point보다 크면 right를 하나 감소시키고 비교를 반복한다.

    pivot point보다 작은 배열 값을 찾으면, 반복을 중지한다.

④ 그 다음 left부터 비교를 진행한다. 비교는 right가 left보다 클 때만 반복하며. 비교한 배열값이 pivot point보다 작으면 left를 하나 증가시키고 비교를 반복한다.

    pivot point보다 큰 배열 값을 찾으면, 반복을 중지한다.

⑤ left 인덱스의 값과 right 인덱스의 값을 바꿔준다.

⑥ 3,4,5 과정을 left<right가 만족 할 때 까지 반복한다.

⑦ 위 과정이 끝나면 left의 값과 pivot point를 바꿔준다.

⑧ 맨 왼쪽부터 left - 1까지, left+1부터 맨 오른쪽까지로 나눠 퀵 정렬을 반복한다.


퀵 정렬은 분할과 동시에 정렬을 진행하는 알고리즘이다.

각 정렬은 배열의 크기 N만큼 비교하며, 이를 총 분할 깊이인 lgN만큼 진행하므로 총 비교횟수는 NlgN, 즉 시간 복잡도는 O(NlgN)이다.

다만 퀵 정렬에는 최악의 경우가 존재하는데, 이는 배열이 이미 정렬이 되어있는 경우이다. 이 경우 분할이 N만큼 일어나므로 시간 복잡도는 O(N^2)이다.

이를 방지 하기 위해 앞서 언급했던 전체 배열 값 중 중간값이나나 랜덤 값으로 pivot point를 정하는 방법을 쓰기도 한다.


최악의 경우 때문에 합병 정렬보다 느린 알고리즘이라 생각하기 쉽지만, 발생하기 쉽지 않은 경우이고, 일반적으로 퀵 정렬이 합병정렬보다 20%이상 빠르다고 한다.

이는 바로 다음에 포스트할 정렬의 성능 측정 부분에서 보도록 하겠다.


<퀵 정렬>


퀵 정렬 C++ 소스코드

//퀵 정렬 
void qsort(vector<int>& v, int s, int e) {
    int pivot = v[s];
    int bs = s, be = e;
    while (s<e) {   
        while (pivot <= v[e]&&s<e) e--;
        if (s>e) break;
        while (pivot >= v[s]&&s<e) s++;
        if (s>e) break;
        std::swap(v[s], v[e]);
    }
    std::swap(v[bs], v[s]);
    if(bs<s)
        qsort(v,bs, s-1);
    if(be>e)
        qsort(v,s+1, be);

}



그 외 똑같이 NlgN에 작동하는 힙 정렬, 선형 시간에 작동하는 Count 정렬, Radix 정렬 등등이 있다. 이것 또한 나중에 정리해보도록 하겠다.




<이미지 출처 : 위키피디아>


[문제 링크 : 1725 히스토그램]

[문제 링크 : 6549 히스토그램에서 가장 큰 직사각형]

[문제 링크 : FENCE]


세 문제가 모두 같은 문제이다. 이 정도로 같은 문제가 있는 것 보면 정말 유명한 문제인 것 같다. 이 문제를 풀 수 있는 방법은 여러개이다.


예전에 내가 풀었던 방식은 분할정복이었는데.


스택 문제 복습 겸 스택으로 풀도록 노력하였다.


스택을 통해 스위핑 알고리즘으로 푼다는데 이에 대한 설명은 구종만님의 책에도 있지만 개인적으로 너무 이해가 힘들었다.



스택을 이용해서 풀 때, 가장 왼쪽부터 차례대로 스택에 삽입하여 문제를 해결해간다.


스택에 막대를 삽입하는 조건은 '삽입하려고 하는 막대가, TOP에 있는 막대의 높이보다 클 때'이다.


만약 다음에 삽입하려는 막대가 현재 스택 TOP에 있는 막대보다 작으면, TOP에 있는 막대 높이로 만들 수 있는 직사각형의 오른쪽 끝이 된다.


그 경우 top을 pop하여 너비를 구한다. pop하기 전에 있던 top의 값을 tmp에 저장한다고 해보자. pop후의 스택의 top값은 tmp 왼쪽의 tmp보다 작은 막대이므로, 이 직사각형의 왼쪽 끝이 된다.


즉 tmp의 높이로 만들 수 있는 직사각형의 크기 = h[tmp] * (right - stack.top - 1)


위의 로직을 이해했으면 같을 경우에 굳이 스택에 인덱스를 삽입하지 않는 이유도 이해가 갈 것이다.


반복문을 다 돌았을 때 스택에 값이 남아있으면 이는 최대 직사각형에서 오른쪽 끝이, 전체의 오른쪽 끝인 직사각형들이다. 위에서 구한 공식대로 그 값들을 구해 최대값을 찾으면 된다. 


이를 구현한 코드는 다음과 같다.



#include <cstdio>
#include <stack>
#include <algorithm>
using namespace std;
long long n, arr[100001];
long long histogram(){
    stack<long long> st;
    long long i,ret = 0;
    //스택에 왼쪽 끝 인덱스값을 미리 삽입해둔다. 
    st.push(-1);
    for(i=0; i<n;i++){
        //스택이 비어있지 않고,  
        while(!st.empty()&&arr[i]<arr[st.top()]){
            //right는 i 
            int tmp = st.top(); st.pop();
            if(!st.empty())
                //left는 st.top
                //left-right-1 너비, 높이 arr[tmp]의 직사각형 넓이 
                ret = max(ret, arr[tmp]*(i-st.top()-1));
        }
        //너비를 다 구하고 다음 판자를 스택에 삽입.
        st.push(i); 
    }
    //히스토그램을 끝까지 스택에 넣었는데도 안끝났다면
    //스택에 남아있는 판자들에서의 최대값을 구한다. 
    while(!st.empty()) {
        int tmp = st.top(); st.pop();
        if(!st.empty())
            ret = max(ret, arr[tmp]*(i-st.top()-1));
    }
    return ret;
}
int main(){
    scanf("%lld",&n);
    for(int i=0;i<n;i++)
        scanf("%lld",&arr[i]);
    printf("%lld\n",histogram());
    
}   


참고 : https://www.acmicpc.net/blog/view/12


'Algorithm > Problems' 카테고리의 다른 글

백준 - 9251 LCS  (0) 2016.04.17
알고스팟 - KOOGLE  (0) 2016.04.14
백준 - 2493 탑  (1) 2016.03.28
백준 - 10799 쇠막대기  (2) 2016.03.28
백준 - 1520 내리막 길  (0) 2016.03.15

[문제 링크]


스택을 이용하면 쉽게 푸는 문제이다.

나는 입력 받으면서 스택에서 체크하여 값을 출력하는 방식으로 풀었다.


값을 입력받으면, 스택을 체크한다. 스택을 체크해서 스택이 비었으면 그 탑 왼쪽에는 탑이 없는 것이므로 0을 출력한다.


탑이 있을 경우, 현재 입력받은 탑의 높이와, 스택의 top에 있는 탑의 높이를 비교한다. 만약 값의 크기가 현재 탑보다 작으면, 스택을 pop하여 다음 값을 다시 비교한다.


top에 저장된 높이 값이 현재 입력받은 높이값보다 같거나 클경우, 혹은 스택이 비었을 경우까지 이를 반복한다. top의 값이 같거나 클 경우에는 top의 인덱스를 출력하고, 비었으면 0을 출력한다.


그 다음에 현재 입력받은 값을 탑의 인덱스와 함께 스택에 push한다.


문제에 제시된 예제를 통해 예를 들어보겠다.


5
6 9 5 7 4


현재 스택은 비어있는 상태이다.


1. 먼저 6을 입력 받는다. 스택을 체크한다. 현재 스택에 삽입된 값은 없다, 콘솔에 '0'을 출력하고, 스택에 탑의 인덱스와 크기를 pair로 묶어 삽입한다.

출력 : 0

스택 : (1,6)


2. 그 다음 9를 입력받는다. 스택을 체크한다. top에 있는 값의 크기는 6이다. 현재 입력받는 9보다 작으므로 스택을 pop한다. 

출력 : 0

스택 :

그리고 다시 스택을 체크한다. 스택이 비어있다. 콘솔에 '0'을 출력하고, 입력받은 값을 삽입한다.

출력 : 0  0

스택 : (2,9)


3. 다음은 5이다. 스택을 체크한다. top에 있는 값은 9이고 현재 입력값보다 크다. top에 있는 인덱스를 출력하고 스택에 입력값을 push한다.

출력 : 0  0  2

스택 : (2,9)  (3,5)


4. 다음은 7이다. 스택을 체크한다. top에 있는 값은 5이고 현재 입력값보다 작다. 스택을 pop한다.

출력 : 0  0  2

스택 : (2,9)

그리고 다시 스택을 체크한다. top에 있는 높이 값은 9이다. 입력값인 7보다 크므로 top의 인덱스를 출력하고 스택에 push한다.

출력 : 0  0  2  2

스택 : (2,9)  (4,7)


5. 마지막으로 4이다. 스택 top을 체크하는데 top의 값은 7이고 입력값보다 크다. 인덱스를 출력하고 스택에 push한다.

출력 : 0  0  2  2  4

스택 : (2,9)  (4,7)  (5,4)


5번 반복했으므로 반복문이 끝나고 프로그램을 종료한다.


즉 스택에는 이전값중의 가장 큰 값과, 현재 값 사이에 있는 값들은 전부 없앤다고 보면 된다.


이를 구현한 코드는 다음과 같다.




#include <cstdio>
#include <stack>
#include <utility>
using namespace std;
int n, k;
stack<pair<int, int> > st;
int main() {
    scanf("%d", &n);
    for (int i = 1; i<=n; i++) {
        scanf("%d", &k);
        while (!st.empty()) {
            //스택의 top이 현재 입력값보다 크다면 신호 수신 가능이므로 
            if (st.top().second > k){
                //top의 위치를 출력하고 반복문을 탈출한다. 
                printf("%d ", st.top().first);
                break;
            }
            st.pop();
        }
        //스택이 비었으면 0을 출력한다. 
        if(st.empty()) printf("0 ");
        //그리고 현재 탑을 스택에 push 
        st.push(make_pair(i, k));
    }
}


'Algorithm > Problems' 카테고리의 다른 글

알고스팟 - KOOGLE  (0) 2016.04.14
백준 - 1725, 6549 히스토그램 / 알고스팟 - FENCE  (0) 2016.03.28
백준 - 10799 쇠막대기  (2) 2016.03.28
백준 - 1520 내리막 길  (0) 2016.03.15
백준 - 9465 스티커  (0) 2016.03.15

+ Recent posts