목차 

0x00 Counting Sort

0x01 Radix Sort 

0x02 STL Sort

0x03 정렬의 응용


0x00 Counting Sort

{ 1, 5, 4, 2, 3, 1, 4, 3} 

이 수열을 숫자 크기를 기준으로 오름차순으로 정렬하려고 한다. 어떻게 할지 생각해보자. 

머지 소트나 퀵 소트를 써도 된다. 

여기서 다시 짚고 넘어갈 점! 

퀵 소트는 NlogN이 보장 된다 안 된다? 

최악의 경우 O(N^2)이다. 꼭 기억하자. 

 

이 수열을 조금 변형한 다음에, 정렬하자. 아래의 표가 어떤 것을 의미할까? 

-> 수열에 숫자가 포함된 횟수를 저장했다. 

1이 2번 나왔다. 2가 1번 나왔다. 3이 2번 나왔다 ... 

이 표만 있으면 각 숫자가 차례로 나온 횟수만 적으면 정렬이 가능하다! 이런 알고리즘이 카운팅 소트다. 

 

이 때, '최솟값과 최댓값을 커버할 수 있는 크기의 배열을 선언해도 되는지' 문제의 메모리 제한을 확인해야 한다. 

512MB 제한이면, int 기준 1.2억인 배열을 잡을 수 있다. 

대략 1000만 이하일 때, 카운팅 소트를 쓸 수 있다고 생각하면 된다. 

 

백준 15688번 수 정렬하기5 문제를 카운팅 소트로 풀어보자. (내 코드, 바킹독님 코드)

// http://boj.kr/eeee207545644480a1971a2723769d93 바킹독님코드
#include <bits/stdc++.h>
using namespace std;

int freq[2000001];
int main() {
  ios_base::sync_with_stdio(0);
  cin.tie(0);
  int n;
  cin >> n;
  for(int i = 0; i < n; i++){
    int a;
    cin >> a;
    freq[a+1000000]++;
  }
  for(int i = 0; i <= 2000000; i++){
    while(freq[i]--){ // cnt[i]번 반복
      cout << i-1000000 << '\n';
    }
  }
}

입력 숫자가 음수 양수 모두를 포함하니까 애초에 모든 수에 100만을 더한 코드다. 

시간복잡도를 생각해보자. 

수의 범위가 K라고 할 때.

1) 처음에 N개의 숫자들을 배열에 넣는다. 

2) 정렬할 때 K개의 칸을 확인한다.

따라서 시간복잡도는 O(N+K)가 된다.

 

이렇게 수의 범위가 제한되어 있고 작은 경우에 카운팅 소트가 유용하다. 

0x01 Radix Sort(기수 정렬)

라딕스 소트는 자릿수를 이용해서 정렬하는 알고리즘이다. 

카운팅 소트를 응용한 것으로 이해하면 된다. 

아래 수열을 라딕스 소트로 정렬해보자

012 는 12를 의미하는데. 편의를 위해 앞에 0을 붙였다. 

1. 크기 10의 리스트를 선언한다. 

2. 숫자의 '1의 자리'에 맞춰서 리스트에 넣는다.

012는 1의 자리가 2다. 따라서 인덱스 2에 넣는다. 421은 인덱스 1에 넣자. 

3. 0번 리스트부터 보면서 수를 꺼내서 수열을 재배열한다.

이 과정을 거치면 수열이 '1의 자리'를 기준으로 재배열된다.

1의 자리 기준이고 주어진 수열의 자리가 유지되니까 421이 021보다 앞에 온다. 

4. 다음 단계는 '10의 자리'에 맞춰서 리스트에 넣는다.

100은 10의 자리가 0이니까 인덱스 0에 넣는다. 421은 인덱스 2에 넣는다. 

5. 0번 인덱스 부터 9번 인덱스를 보면서 수를 꺼내서 재배열한다. 

5. 다음 단계는 '100의 자리'에 맞춰서 리스트에 넣는다. 

100은 100의 자리가 1이니까 인덱스 1에 넣는다. 502는 인덱스 5에 넣는다. 

6. 하란대로 하니깐 일단. 1의 자리 -> 10의 자리 -> 100의 자리 순으로 재배열 하니까 정렬이 완료됬다. 

여기서 질문. 502는 왜 421보다 클까? 

왜 이럴까를 생각해보면 라딕스 소트를 이해하는데 도움이 된다. 

502와 421을 비교할 때, 먼저 100의 자리를 비교한다. 100의 자리만 보면, 5 > 4 이니까! 

즉, 수A 가 수B보다 크다는 의미는 더 큰 자릿수에서 A의 자릿수가 B의 자릿수보다 큰 경우가 먼저 생긴다는 것이다. 

이걸 인지한 상태로 라딕스 소트 과정을 보자. 

10의 자리 까지는 502가 421 앞에 위치했다. 

하지만 100의 자리 비교 단계에서는 421는 4번 인덱스에 들어가고. 502는 5번 인덱스에 들어가기 때문에.

결국 421이 502보다 앞에 오도록 올바르게 정렬된다. 

 

라딕스 소트의 시간복잡도 

1의 자리 기준으로 재배열 하면서 리스트 크기 만큼 순회했다. 

10의 자리 기준으로 재배열 할 때도 리스트 크기 만큼 순회했다. 

따라서 자릿수의 최대 개수가 D개라고 할 때, D번에 걸쳐서 카운팅 소트를 하는 것과 똑같다. 

리스트의 크기를 K개 라고 할 때, 엄밀하게 말하면 시간 복잡도는 O(D(N+K))이지만.

보통 리스트의 개수는 N에 비해 무시가 가능할 정도로 작다. 당장 지금 예시로 든 상황도 리스트 갯수 K가 10밖에 안된다.

결론적으로 라딕스 소트의 시간복잡도는 O(DN)이다. 

 

적어도 코딩 테스트에서는 라딕스 소트를 구현해야 하는 상황은 아예 없다. 개념만 이해하자. 

라딕스 소트 구현 코드

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

int n = 15;
int arr[1000001] = {12, 421, 46, 674, 103, 502, 7, 100, 21, 545, 722, 227, 62, 91, 240};

int d = 3; // 자릿수의 최대 길이.
int p10[3]; // p10[i] = 10의 i승

// x의 10^a 자리의 값을 반환하는 함수
int digitNum(int x, int a) {
  return (x / p10[a]) % 10;
}

vector<int> l[10]; // 0부터 9까지 저장.

int main(void) {
  ios::sync_with_stdio(0);
  cin.tie(0);

  p10[0] = 1;
  for(int i = 1; i < d; i++) p10[i] = p10[i-1] * 10;

  for(int i = 0; i < d; i++){ // 1의 자리 -> 10의 자리 -> 100의 자리.
    for(int j = 0; j < 10; j++) l[j].clear();
    // 각 수를 list에 대입
    for(int j = 0; j < n; j++) // 0~9
      l[digitNum(arr[j], i)].push_back(arr[j]);
    // 0번 리스트 부터 차례로 다시 arr에 넣어 재배열
    int aidx = 0;
    for(int j = 0; j < 10; j++){
      for(auto x : l[j]) arr[aidx++] = x;
    }
  }
  for(int i = 0; i < n; i++) cout << arr[i] << ' ';

  return 0;
}

 

코드를 간단히 짚고 넘어가면. n은 배열 크기, arr은 정렬할 배열이다.

d는 자릿수의 최댓값이다. (100의자리가 최대니까 여기서는 d가 3이 된다.)

배열 p10은 10의 지수를 저장할 배열이다. p10[0] == 1 , p10[1] = 10, p10[2] = 100 

// x의 10^a 자리의 값을 반환하는 함수
int digitNum(int x, int a){
  return (x / p10[a]) % 10;
}

 

digitNum함수는 x에서 10의 a자리 숫자를 추출한다. ex) digitNum(253, 1) 의 리턴값은 5다. 

vector<int> l 은 0번부터 9까지를 저장하는 리스트다. 

 

p10[i]는 10의 i승을 저장할 변수다. p10배열 초기화 코드인데. 정수 거듭제곱을 계산해야 하면 이렇게 하는 것이 정석이다. 

p10[0] = 1; // 10의 0승이니까 1이다. 
for(int i = 1; i < d; i++) p10[i] = p10[i-1] * 10;

 

pow 함수는 실수형을 반환하기 때문이다. 아래 코드 처럼 쓰면 실수 오차로 꼬일 수 있다. 주의하자. pow함수 레퍼런스

p10[i] = pow(10, i);
// double pow (double base, double exponent);

정렬 과정에 '비교'를 하는지? 

Comparision Sort Non-comparision Sort
버블 소트
머지 소트
퀵 소트 
카운팅 소트
라딕스 소트

카운팅 소트와 라딕스 소트는 정렬 과정에 '비교'를 하지 않는다.   


0x02 STL sort

algorithm 헤더에서 제공하는 sort() 함수는 기본으로 오름차순으로 정렬해준다. 

최악의 경우에도 O(NlogN)이 보장된다. 단 stable sort가 필요하다면 sort()가 아닌 stable_sort()를 쓰자. 

sort() 레퍼런스 

int a[5] = {1, 4, 5, 2, 7};
sort(a, a+5);

vector<int> b = {1, 4, 5, 2, 7};
sort(b.begin(), b.end()); // or sort(b.begin(), b.begin() + 5 );

stable_sort() 레퍼런스

두 원소의 우선순위가 같다면 원소의 위치를 변경하지 않는다. 사용법은 sort()와 똑같다. 

[중요] 비교 함수

sort() 함수의 파라미터로 비교함수를 넘길 수 있다. stable_sort()도 마찬가지다. 

예를 들어, int형을 5로 나눈 나머지 순으로, 5로 나눈 나머지가 같다면 크기 순으로 정렬하고 싶으면 아래 코드처럼 하면 된다.  

비교함수 cmp는 a가 b의 앞에 와야할 때 true를, 그렇지 않을 때 false를 반환한다. 

bool cmp(int a, int b){ // a가 b의 앞에 와야할 때 true, 아니면 false 
  if(a % 5 != b % 5) return a % 5 < b % 5;
  else a < b;
}

int a[7] = {1, 2, 3, 4, 5, 6, 7};
sort(a, a+7, cmp);
// 출력 결과: 5 1 6 2 7 3 4

 

비교함수 구현 시 자주하는 실수 

1. a가 b의 앞에 와야할 때만 true를 반환한다. a==b 라면 false를 반환한다. 

예를 들어, 수열을 크기의 내림차순으로 정렬하고 싶을 때, a와 b가 같은 경우 false를 반환하니까 런타임 에러가 발생할 수 있다. 

bool cmp(int a, int b){ // [런타임 에러 발생]
  if(a >= b) return true;
  return false; 
}

a > b 일 때 true를 반환하도록 수정하자. 

bool cmp(int a, int b){ // 올바른 형태
  return a > b;
}

2. 비교 함수의 인자로 STL 혹은 클래스 객체를 전달시 reference를 사용하자. 

문자열을 받아서 끝자리의 알파벳 순으로 정렬하고 싶어서 비교함수를 작성했다.  

아래의 비교함수는 어떻게 개선하면 좋을까? 

bool cmp(string a, string b){
  return a.back() < b.back();
}
 // 함수의 인자로 STL이나 구조체 객체를 보내면 값의 '복사'가 일어난다!

함수의 인자로 STL이나 구조체 객체를 보내면 값의 '복사'가 일어남을 기억하자!

이 경우, 굳이 복사라는 불필요한 연산이 없어도 비교가 가능하다. 

따라서 복사를 하는 대신 아래처럼 reference를 보내는 것이 더 바람직하다. 

bool cmp(const string& a, const string& b){ // 레퍼런스를 보내서 비교하는 것이 효율적.
  return a.back() < b.back();
}

 

const string& a, const string& b라고 쓰면 a와 b는 변하지 않음을 명시적으로 나타내기 때문에 가독성이 도움이 된다. 

하지만 코딩테스트에서 const를 안써도 크게 상관은 없다. 

비교함수 연습을 위해 백준 1431번 시리얼 번호 문제를 풀고 오자. 1431 내 코드 

 


0x03 정렬의 응용

정렬을 이용해서 시간복잡도를 개선할 수 있는 문제 백준 11652번 카드 문제를 확인해보자. 

O(N^2)의 풀이는 너무 직관적으로 보인다. 그런데 O(N^2)이면 시간초과이고. 수의 범위가 매우 크다. 

map은 아직 배우지 않았고. 또 이진검색트리라는 개념을 사용하는 만큼 map을 이용한 풀이는 배제하겠다. 

어떻게 해결해야 할까? 

 

일단 변수 3개가 필요하다. 

cnt : 현재 보고 있는 수의 등장 횟수. 

mxval : 현재까지 가장 많이 등장한 수의 값.

mxcnt : 현재까지 가장 많이 등장한 수의 등장 횟수. 

초기값으로 cnt = 0, mxval = -2^62-1, mxcnt = 0 을 주자. 

 

이제 2부터 읽으면서 진행하자. 

cnt 는 현재 보고 있는 수의 등장 횟수다. 

현재 보고 있는 수가 제일 앞에 있는 수이거나 현재 보고 있는 수와 바로 직전에 나온 수가 같을 때에는 cnt 를 1 증가시키면 된다. 

그래서 지금 '2'를 바라보고 있으니까 cnt를 1 증가 시키면 끝이다. 

다음 숫자도 '2'니까 직전수와 똑같다. cnt를 1 증가시킨다. 

다음 숫자도 '2'니까 cnt를 1 증가시킨다. 

이제 '3'을 바라본다. 더이상 2가 연속하지 않는다. 

이 때 mxval과 mxcnt를 업데이트해야 한다. 

여태까지 가장 많이 등장한 횟수는 '2'가 '3'번 나온 것이다. mxval은 2로, mxcnt는 3으로 업데이트 하자. 

'3'에 대한 개수를 세야 하니까 cnt=1로 변경해준다. 이제 3이 몇 개 있나 세면 된다. 

 

바킹독님 코드 11652 카드

// http://boj.kr/f3feaf22016f4c9687b84ab6be2f4389
#include <bits/stdc++.h>
using namespace std;

int n;
long long a[100005];

int main(void) {
  ios::sync_with_stdio(0);
  cin.tie(0);
  cin >> n;
  for(int i = 0; i < n; i++)
    cin >> a[i];
  sort(a, a+n);
  int cnt = 0;
  long long mxval = -(1ll << 62) - 1; // 1을 long long으로 형변환하지 않고 1 << 62로 작성시 int overflow 발생
  int mxcnt = 0;
  for(int i = 0; i < n; i++){
    if(i == 0 || a[i-1] == a[i]) cnt++; // i가 0인 경우 앞쪽 식이 true이기 때문에 a[i-1]을 참조하지 않음
    else{
      if(cnt > mxcnt){
        mxcnt = cnt;
        mxval = a[i-1];
      }
      cnt = 1;
    }
  }
  if(cnt > mxcnt) mxval = a[n-1]; // 제일 마지막 수가 몇 번 등장했는지를 별도로 확인
  cout << mxval;
}

코드 마지막 줄에 '제일 마지막 수가 몇 번 등장했는지 별도로 확인'해야 한다. 

이 처리가 빠지면 제일 마지막 수의 등장 횟수를 빠뜨리게 된다. 

이 문제에서 사용한 정렬을 하면, 같은 수는 인접하게 된다는 성질을 이용해 수열에서 중복된 원소를 제거할 수 도 있다. 


정렬2 문제집

문제를 풀 때는 퀵, 머지, 카운팅, 라딕스 소트를 직접 구현할 일은 없고 STL sort만 쓰겠지만, 이렇게 정리하고 나면 기술면접 대비에 도움이 된다. 

특히 7795번 문제를 풀고, 다른 사람의 풀이를 찾아서 비교해보자. 

백준 문제 번호 내 코드 
1431 시리얼 번호  1431 내 코드  , 1431 내 코드2
11652 카드  11652 내 코드 (다시 풀기)
5648 역원소 정렬 5648 내 코드 
1181 단어 정렬 1181 내 코드
2910 빈도 정렬 2910 내코드
10814 나이순 정렬 10814 내 코드
11656 접미사 배열  11656 내 코드
10825 국영수 10825 내 코드
7795 먹을 것인가 먹힐 것인가 7795 내 코드

 


다음 시간에는 다이나믹 프로그래밍을 배운다.

공부 내용 출처 : 바킹독 알고리즘 정렬2

728x90

'알고리즘 > 실전 알고리즘' 카테고리의 다른 글

0x10강 - 다이나믹 프로그래밍  (0) 2021.12.19
0x0E강 - 정렬1  (0) 2021.12.11
0x0C강 - 백트래킹  (0) 2021.12.10
0x0B강 - 재귀  (0) 2021.12.08
0x0A강 - DFS  (0) 2021.12.07

목차 

0x00 기초정렬

0x01 Merge Sort 

0x02 Quick Sort


0x00 기초정렬

1. 선택정렬 

책꽂이에 꽂힌 책을 높이 순으로 정렬하려면 어떻게 해야 할까? 

제일 큰 숫자를 찾아서 뒤로 보낸다.

그 다음 큰 것을 찾아서 뒤로 보낸다 ... 이렇게 이어질 테니 계산해보면 시간복잡도는 O(N^2) 이다. 

 

선택정렬 코드

int arr[10] = {3, 2, 7, 116, 62, 235, 1, 23, 55, 77};
int n = 10;
for(int i = n-1; i > 0; i--){ // 최대값을 찾으면 i위치로 보낼것이다. i:맨 마지막부터 앞에서 두번째 까지.
  int mxidx = 0;
  for(int j = 1; j <= i; j++){
    if(arr[mxidx] < arr[j]) mxidx = j;
  }
  swap(arr[mxidx], arr[i]);
}

선택 정렬은 가장 큰 수를 찾으면, 인덱스 i로 이동시킨다. 
내부 반복문에서 mxidx 인덱스와 j인덱스 자리의 숫자를 비교한다. 이 반복문이 종료되면 가장 큰 숫자의 인덱스가 mxidx에 저장된다. 
가장 큰 숫자 인덱스 mxidx와 뒤쪽 인덱스 i를 swap한다. ( == 가장 큰 숫자를 뒤로 보낸다)

max_element 함수로 줄여본 코드 

max_element(arr, arr+k) 
// arr[0], arr[1], arr[2], .. , arr[k-1] 에서 최댓값인 원소의 주소를 반환

arr[0]부터 arr[k-1] 까지 탐색한 후 최댓값인 원소 주소를 반환한다. 

arr[k]가 아니라 arr[k-1]임을 주의하자. 

int arr[10] = {3, 2, 7, 116, 62, 235, 1, 23, 55, 77};
int n = 10;
for(int i = n-1; i > 0; i--){ // 맨 마지막부터 앞에서 두번째 까지.
  swap(*max_element(arr, arr+i+1), arr[i]);
}

최댓값을 찾으면 인덱스 9와 swap, 최댓값을 찾으면 인덱스 8과 swap, 최댓값을 찾으면 인덱스 7과 swap..을 반복한다. 

max_element함수는 범위 내의 원소를 모두 살피니까 O(N^2)의 시간복잡도라는 점을 인지하자. 

2. 버블정렬 

인접한 원소가 자기보다 크면 자리를 바꾼다. 보글보글 인접한 원소끼리만 swap하는 모양 때문에 이름이 버블정렬이다. 

이중 반복문이라서 시간복잡도가 O(N^2)다. 

int arr[5] = {-2, 2, 4, 6, 13};
int n = 5;
for(int i = 0; i < n; i++){
  for(int j = 0; j < n-i-1; j++){
    if(arr[j]> arr[j+1]) swap(arr[j], arr[j+1]);
  }
}

정렬 1회전이 끝나면, 가장 큰 숫자가 맨 뒤에 위치한다. 13이 arr[4]에 가 있을 것이다. 

내부 반복문 j에서는 (인덱스 0과 1) (인덱스 1과 2) 이렇게 인접한 숫자를 비교하니까. j를 0부터 n-1까지 순회해야 한다.

그래야 없는 인덱스를 참조하는 런타임 에러가 안난다. 

내부 반복문 종료 조건을 j < n-i-1 로 해도 정상 동작하는 이유는 뭘까?

버블 정렬에서 정렬 1회전이 끝나면, 가장 큰 숫자가 맨 뒤에 위치한다.

 

정렬 1회전이 끝나면 13이 arr[4]에 있다. 인덱스 4에 있는 숫자를 이제 비교할 필요 없다.

정렬 2회전에서는 인덱스 0, 1, 2, 3 까지만 비교하면 된다! 정렬 2회전이 끝나면 6이 arr[3]에 있다. 인덱스 3부터는 비교할 필요 없다.

이렇게 이미 맨 뒤에 정렬이 끝난 숫자는 비교를 안해도 올바른 자리에 가 있기 때문이다. 


0x01 Merge Sort

재귀적으로 수열을 나눠 정렬한 후 합치는 정렬이다. 시간복잡도는 O(NlogN)이다. 

N이 10만 정도라면, O(N^2) 알고리즘의 경우, 10초~1분 소요된다. O(NlogN)알고리즘이면 눈 감았다 뜨기 전에 종료된다. 

1. 두 정렬된 리스트를 합친다는건 어떻게 할까? 

병합정렬을 배우기 전에, 길이가 N, M인 두 정렬된 리스트를 합쳐서 길이 N+M의 정렬된 리스트를 만드는 방법을 알아야 한다. 

그림을 보고 제일 앞에 와야하는 수(가장 작은 수)는 어떻게 택해야 할지 생각해보자.

빨강 리스트와 파랑리스트의 모든 원소를 확인해야 할까?

아니다. 

-> 두 리스트의 가장 앞에 있는 원소만 비교하면 된다! 

왜냐하면 이미 정렬된 리스트이기 때문에 가장 작은 수가 이미 각 리스트의 맨 앞에 있다. 

"-9와 -7만 비교하면 -9가 제일 작으니까 리스트를 합쳤을 때 -9가 제일 앞에 오게 된다" 는 것을 O(1)에 알 수 있다. 

 

그러면 -9 다음에 오는 수는 어떻게 알까?

두 리스트의 맨 앞을 비교하면 1과 -7이 있다. -7을 선택한다.  

다음은? 두 리스트의 맨 앞을 비교하면 6과 1이 있다. 1을 선택한다.  

이렇게 비교 1번 당 1개의 원소를 제자리에 보낼 수 있으니까 정렬된 두 리스트를 합치는건 O(N+M)에 수행 가능하다. 

 

혼자 힘으로 '정렬된 배열 합치기'를 구현해보자.  백준 11782번 배열 합치기 문제를 풀고 오자.

11782번 문제 내 코드  아래는 바킹독님의 코드 

// http://boj.kr/eff70b5b64974fdebbcd7d2a1fdf29a5
#include <bits/stdc++.h>
using namespace std;

int n, m;
int a[1000005], b[1000005], c[2000005];

int main(void) {
  ios::sync_with_stdio(0);
  cin.tie(0);
  
  cin >> n >> m;
  for(int i = 0; i < n; i++) cin >> a[i];
  for(int i = 0; i < m; i++) cin >> b[i];
  int aidx = 0, bidx = 0;
  for(int i = 0; i < n+m; i++){
    if(bidx == m) c[i] = a[aidx++];
    else if(aidx == n) c[i] = b[bidx++];
    else if(a[aidx] <= b[bidx]) c[i] = a[aidx++];
    else c[i] = b[bidx++]; 
  }
  for(int i = 0; i < n+m; i++) cout << c[i] << ' ';
}

2. Merge Sort 는 어떻게 구현할까? 

병합 정렬은 3단계로 요약할 수 있다.

step1. 주어진 리스트를 2개로 나눈다.
step2. 각각의 리스트를 정렬한다. 
step3. 정렬된 두 리스트를 합친다. 

"나눈 리스트는 어떤 방법으로 정렬할까?"

-> "재귀"에서 아이디어를 얻을 수 있다. 

재귀를 배울 때 도미노가 쓰러지는 것에 비유하여 귀납법을 이해했다. 

"1번 도미노가 쓰러지면 100번 도미노가 쓰러진다." 는 것을 납득시키려면? 

100번 도미노가 쓰러지려면, 99번 도미노가 쓰러져야하고, 99번 도미노가 쓰러지려면 98번 도미노가 쓰러져야 하고, ... 

2번 도미노가 쓰러지려면 1번 도미노가 쓰러져야 한다. 

일단 1번 도미노는 확실히 쓰러지게 되어 있으니까. 따라서 100번 도미노가 쓰러지면 1번 도미노가 쓰러진다.

이러한 귀납법을 병합 정렬에 적용하면. 

길이가 8인 리스트를 정렬하려면, 길이가 4인 리스트를 정렬할 수 있어야 하고. 

길이가 4인 리스트를 정렬하려면 길이가 2인 리스트를 정렬할 수 있어야 하고. 

길이가 2인 리스트를 정렬하려면 길이가 1인 리스트를 정렬할 수 있어야 하는데. 

길이가 1인 리스트는 아주 쉽게 정렬할 수 있으니까. 결국 우리는 {6, -8, 1, 12, 8, 15, 7, -7}를 정렬할 수 있다! 

3. 구현하기 전에 병합 정렬의 시간복잡도를 확인하자

병합 정렬의 전체 과정을 살펴보면, 리스트가 분할하는 과정과 합쳐나가는 과정으로 구분된다. 

1) 분할하는 과정 

분할한다는 것은 관념적으로 분할 되었다고 생각하는 것이다. 

함수 호출의 횟수는 각 줄별로 1, 2, 4, ... , 2의 k승 번이다. 이 횟수는 각 줄에 리스트가 몇 개 있는지 보면 된다. 

예를들어, 3번째 줄을 보자.

리스트가 {6, -8}, {1, 12}, {8, 15}, {7, -7} 총 4개가 있다. 각각에 대해 함수가 호출되니까 3번째 줄은 4번 호출된다. 

 

분할하는 과정에서 함수 호출은 1+2+4+ ... + 2의 k승 == 2N-1 = O(N)번 발생한다

즉, 분할하는 과정의 시간복잡도는 O(N)이다. 

 

2) 합쳐나가는 과정 

합쳐나가는 과정의 시간복잡도는 각 줄에서 모두 N번의 연산이 필요하다

아까 두 정렬된 리스트를 O(A+B)에 구하는 방법을 배웠다. 

그러면 각 줄에서 둘씩 짝지어 합쳐서 다음 단계로 넘어가려면 그냥 전체 원소의 갯수만큼 필요하다는 것을 알 수 있다. 

예를 들어, 5번째 줄을 보자. 

{-8, 6}, {1, 12}, {8,15}, {-7, 7} 길이가 N/4인 4개의 정렬된 리스트들을 2개씩 짝지어 합치는 상황이다. 

필요한 연산의 횟수는 N/4 + N/4 + N/4 + N/4 == N이 된다. 

분할이 완료됬을 때, 리스트의 원소는 1개 였다가 2의 k승 개가 될때까지 매번 2배씩 커지니까 줄은 k개다. 

(1개 -> 2개 -> 4개 -> 8개)

그렇기 때문에 합쳐나가는 과정은 O(Nk) == O(NlogN) 이다. 

분할하는 과정은 O(N)이고 합쳐나가는 과정은 O(NlogN)인데. O(N)보다 O(NlogN)이 더 크다. 

최종적으로 병합 정렬은 O(NlogN)의 시간복잡도를 가진다. 

4. [중요] 이제 직접 Merge Sort를 구현해보자. 

기본 틀 코드를 받아서 스스로 빈칸을 채워보자.

아래는 바킹독님의 정답 코드다. 

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

int n = 10;
int arr[1000001] = {15, 25, 22, 357, 16, 23, -53, 12, 46, 3};
int tmp[1000001]; // merge 함수에서 리스트 2개를 합친 결과를 임시로 저장하고 있을 변수

// mid = (st+en)/2라고 할 때 arr[st:mid], arr[mid:en]은 이미 정렬이 되어있는 상태일 때 arr[st:mid]와 arr[mid:en]을 합친다.
void merge(int st, int en){
  int mid = (st+en)/2;
  int lidx = st; // arr[st:mid]에서 값을 보기 위해 사용하는 index
  int ridx = mid; // arr[mid:en]에서 값을 보기 위해 사용하는 index
  for(int i = st; i < en; i++){
    if(ridx == en) tmp[i] = arr[lidx++];
    else if(lidx == mid) tmp[i] = arr[ridx++];
    else if(arr[lidx] <= arr[ridx]) tmp[i] = arr[lidx++];
    else tmp[i] = arr[ridx++];
  }
  for(int i = st; i < en; i++) arr[i] = tmp[i]; // tmp에 임시저장해둔 값을 a로 다시 옮김
}

// a[st:en]을 정렬하고 싶다.
void merge_sort(int st, int en){
  if(en == st+1) return; // 리스트의 길이가 1인 경우
  int mid = (st+en)/2;
  merge_sort(st, mid); // arr[st:mid]을 정렬한다.
  merge_sort(mid, en); // arr[mid:en]을 정렬한다.
  merge(st, en); // arr[st:mid]와 arr[mid:en]을 합친다.
}

int main(void){
  ios::sync_with_stdio(0);
  cin.tie(0);
  merge_sort(0, n);
  for(int i = 0; i < n; i++) cout << arr[i] << ' '; // -53 3 12 15 16 22 23 25 46 357
}

 

merge함수에서 두 리스트를 합친 결과를 임시롤 저장할 곳이 필요해서 tmp 변수가 필요하다. 

merge_sort함수에서 배열의 크기가 1일 때는 아무것도 안해도 정렬이 완료된다.

이후에는 재귀적으로 절반을 나눠 각각 정렬이 되게 한다. 

 

아직 헷갈리더라도 이건 어려운 내용인것이 맞고 시간과 반복시도가 해결해주는 문제니까 계속 가면 된다.

백준 2751번 문제도 풀어보자. (바킹독님 2751번 정답코드

5. [중요] 병합 정렬의 Stable Sort 성질 

사람들을 나이 순으로 정렬하는 상황이 있다. 

검정셔츠 입은 사람만 38살이고, 나머지 색깔의 사람들은 21살이다. 나이순으로 생각하면 3가지가 모두 올바른 정렬이 맞다.

이렇게 우선 순위가 같은 원소가 여러 개일 땐 정렬 결과가 유일하지 않다!

그런데 그 중에서 우선순위가 같은 원소들 끼리는 원래의 순서를 따라가도록 하는 정렬이 Stable Sort 이다.

즉, 정렬 전에 나이가 21살인 사람의 순서가 파랑, 빨강, 초록이니까. 정렬 후에도 이 순서를 같게 두는 것이다. 

 

병합정렬은 Stable Sort인데. 이 성질을 만족시키려면, a와 b의 우선순위가 같을 때, a를 먼저 선택해줘야 한다. 

아까 병합 정렬은 구현했을 때 Stable Sort성질이 드러난 코드다.

  for(int i = st; i < en; i++){
    else if(arr[lidx] <= arr[ridx]) tmp[i] = arr[lidx++]; 
    // <-- arr[lidx] == arr[ridx]인 경우처럼 우선순위가 같다면? 앞에 있는 lidx를 먼저 선택해준다!
  }

Stable Sort를 응용해보자.

1) 파일을 정렬하는 경우 

조건: 파일을 크기 순으로, 크기가 동일하다면 먼저 생성된 순으로 정렬하자. 

병합 정렬은 Stable Sort임을 기억하자!

따라서 생성 시간은 신경쓰지 않고, 파일을 크기를 기준으로 정렬하면 조건을 만족시킬 수 있다. 

 

2) 2차원 좌표를 정렬하는 경우 

조건: x가 작은 순으로, x가 동일하다면 y가 작은 순으로 정렬하자. 

먼저 y 크기를 기준으로 병합 정렬을 수행하고, 그 다음에 x크기를 기준으로 병합 정렬을 수행하면 된다. 

이렇게 하면 Stable Sort성질 때문에 일단 x가 작은게 앞으로 오게 되기 때문이다. 

그리고 이 상태에서 x가 같다면, x를 정렬하기 전에 앞에있었을 원소, 즉 y가 작은 원소가 앞에 온다. 

 

다만, 2차원 좌표의 정렬은 병합 정렬2번이 아니라 a[aidx]와 b[bidx]를 비교하는 부분을 살짝 바꾸면 1번 정렬로도 해결된다. 


0x02 Quick Sort

퀵 소트는 거의 모든 정렬 알고리즘 보다 빠르다. 

[참고]

만약, 코딩테스트에서 STL을 못 쓰고 직접 정렬을 구현해야 한다면, 퀵 소트를 쓰지 말고, 머지 소트를 써야 한다

1. 퀵 소트는 머지 소트 처럼 재귀적으로 구현되는 정렬이다. 

매 단계마다 pivot이라는 원소를 알맞은 자리로 보내는 작업을 반복한다. 

아래 조건을 만족하면 pivot은 올바른 자리에 있는 것이다. 
1) pivot 왼쪽은 전부 pivot 보다 작은 원소들 
2) pivot 오른쪽은 전부 pivot보다 큰 원소들 

퀵 소트의 장점은 추가적인 공간이 필요 없다는 점이다.

배열 안에서 포인트 변수2개를 이용해 자리 바꿈만으로 처리할 수 있다. (그래서 cache hit rate가 높아서 속도 빠름)

참고로 이렇게 추가적인 공간이 필요 없는 정렬을 In-Place Sort라고 부른다. 

pivot을 제외하고, 양쪽 끝에 포인터를 두고 적절하게 스왑하자. 

우선 l, r 이라는 포인터 2개를 둔다. pivot은 처음에 0번째 원소로 정한다. 

l은 1번째에서 오른쪽으로 이동하고, r은 맨 마지막 인덱스에서 왼쪽으로 이동한다. 

l은 pivot보다 큰 값이 나올때 까지 오른쪽으로 이동한다. r은 pivot보다 작은 값이 나올 때 까지 왼쪽으로 이동한다. 

그 다음 두 포인터가 가리키는 원소의 값을 스왑한다. l은 12에 도달했다. r은 -7에 도달했다. 

또 다시 l을 이동시키면 6보다 큰 8에 도달한다. r은 6보다 작은 3에 도달한다. 

그러면 3과 8을 스왑한다. 

l을 옮기려고 보니 r이 l보다 왼쪽에 와있다!  이렇게 r이 l보다 작아진 순간에는 멈춰서 pivot을 r과 스왑하면 된다. 

여기까지가 pivot하나를 올바른 자리에 두는 과정이다. 

이 방법이 왜 pivot을 제자리로 보내는지 이해하려면

"모든 순간에 l의 왼쪽에는 pivot보다 작거나 같은 원소들만 있고, r의 오른쪽에는 pivot보다 큰 원소들만 있다"는 점만 주목하면 된다. 

2. 퀵 소트를 구현한 코드 

인덱스 l 은 pivot보다 큰 원소를 찾을 때 까지 이동한다. 

인덱스 r 은 pivot보다 작은 원소를 찾을 때 까지 이동한다. 

만약 l 과 r이 타겟을 못찾으면 l > r 이 된다. 인덱스 l과 r이 역전되버린다. 이 때는 break 로 탐색을 멈춘다. 

l 과 r이 타겟을 찾으면, swap(arr[l], arr[r]) 스왑 한다! 

스왑이 종료되면, 인덱스 r과 인덱스 pivot을 스왑한다. 

그리고  pivot을 기준으로 왼쪽과 오른쪽을 보면 pivot이 올바른 자리에 있음을 확인할 수 있다. 

나머지 부분은 재귀호출해서 정렬시킨다. 

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

int n = 10;
int arr[1000001] = {15, 25, 22, 357, 16, 23, -53, 12, 46, 3};

void quick_sort(int st, int en) { // arr[st to en-1]을 정렬할 예정
  if(en <= st+1) return; // 수열의 길이가 1 이하이면 함수 종료.(base condition)
  int pivot = arr[st]; // 제일 앞의 것을 pivot으로 잡는다. 임의의 값을 잡고 arr[st]와 swap해도 상관없음.
  int l = st+1; // 포인터 l
  int r = en-1; // 포인터 r
  while(1){
    while(l <= r && arr[l] <= pivot) l++;
    while(l <= r && arr[r] >= pivot) r--;
    if(l > r) break; // l과 r이 역전되는 그 즉시 탈출
    swap(arr[l], arr[r]);
  }
  swap(arr[st], arr[r]);
  quick_sort(st, r);
  quick_sort(r+1, en);
}

int main() {
  ios_base::sync_with_stdio(0);
  cin.tie(0);
  quick_sort(0, n);
  for(int i = 0; i < n; i++) cout << arr[i] << ' ';
}

 

3. 퀵 소트의 시간복잡도 

pivot 하나를 올바른 자리에 보낼 때 마다 상수번의 연산으로 l과 r이 한 칸씩 가까워진다.

따라서 한 세트의 시간복잡도는 O(N)이다

재귀 종료 조건인 base condition은 구간의 길이가 1 이하일 때다. pivot을 제자리로 보낸 뒤에, 재귀적으로 자기 자신을 호출한다.

pivot 하나를 올바른 자리에 보내는 세트가  O(N)이 걸리는데. 

pivot 을 하나씩 제외하면 N, N-1, N-2 ... 번의 연산이 필요하다. 

4. 퀵 소트의 단점? 

만약 pivot이 매번 완벽하게 중앙에 위치한다면?

리스트를 균등하게 반으로 쪼개서 재귀를 호출하게 된다. 

이 때는 n, n/2, n/4 ... 번의 연산이 필요할 것이다. 그러면 머지 소트때와 같이 세트마다 O(logN)이 걸린다. 

한 세트의 연산횟수 N과 재귀 호출 연산횟수 logN을 곱하면 된다. 

 

물론 항상 pivot이 완벽하게 중앙에 오지 않겠지만 대략 시간복잡도는 O(NlogN)이 된다. 

그리고 앞서 말한 cache hit rate가 높아서 퀵 소트는 속도가 굉장히 빠르다.

 

{1,2,3,4,5,6,7,8} 을 퀵 소트로 정렬하면 시간 복잡도가 얼마일까? 

pivot은 항상 중앙에 있는게 아니라. 제일 왼쪽에 위치하게 된다. 

그래서 한 단계마다 longN개가 아니라 N개를 확인해야 한다. -> 시간복잡도는 O(N^2)이 된다. 

퀵 소트는 평균적으로 O(NlogN)이지만 최악의 경우 O(N^2)이다. 

단순히 제일 왼쪽 값을 pivot으로 선택해보면 지금처럼 리스트가 오름차순 이거나 내림차순일 때 O(N^2)이 된다. 

 

[참고]

STL을 못 쓰는 경우, 정렬을 직접 구현해야 한다면 퀵 소트를 쓰는게 아니라 머지 소트를 구현하라고 한 이유가 여기에 있다. 

최악의 경우 O(N^2)의 시간복잡도인 퀵 소트를 쓸 필요가 없다. 

하지만 대부분의 정렬 라이브러리는 퀵 소트를 쓰는 것도 사실이다. 

pivot을 '잘'고르는 것이 관건인데. 랜덤으로 선택하거나. 후보 3개를 정해서 중앙값을 선택하기도 한다. 

최악의 경우에도 O(NlogN)을 보장하기 위해 일정 깊이 이상의 재귀가 호출되면 힙 소트로 정렬한다.

재귀가 과도하게 호출되면 오버헤드 때문에 성능저하가 오는 데 이를 피하기 위함이다. 이러한 정렬을 Introspective sort라고 한다. 


5. 머지 소트 VS 퀵 소트 

머지소트와 퀵 소트는 둘 다 재귀적 구조로 구현된다. 

먼저 시간복잡도를 보면 머지소트는 무조건 O(NlogN)이고. 퀵 소트는 최악에 O(N^2), 평균적으로 O(NlogN)이다. 

평균적으로 (이름값하는) 퀵 소트가 빠르다. 

퀵 소트는 In-Place Sort 인 점을 기억하자. 

그리고 머지 소트는 우선 순위가 같은 원소들끼리의 원래 순서가 유지되는 Stable Sort이지만 퀵 소트는 아니다. 


정렬1 문제집

백준 문제 내 코드 
2750 수 정렬하기  2750 내 코드
2751 수 정렬하기 2 2751 내 코드(머지소트 구현)
10989 수 정렬하기 3 10989 내 코드 (메모리 제한 주의 )
11931 수 정렬하기 4 11931 내 코드(머지소트 구현)
15688 수 정렬하기 5 15688 내 코드 (머지소트 구현)
10814 나이순 정렬 10814 내 코드
11650 좌표 정렬하기 11650 내 코드
11651 좌표 정렬하기 2 11651 내 코드

 


다음 시간에는 정렬2(Counting Sort, Radix Sort)를 배운다. 

공부 자료 출처 : 바킹독 알고리즘 정렬1

728x90

'알고리즘 > 실전 알고리즘' 카테고리의 다른 글

0x10강 - 다이나믹 프로그래밍  (0) 2021.12.19
0x0F강 - 정렬2  (0) 2021.12.18
0x0C강 - 백트래킹  (0) 2021.12.10
0x0B강 - 재귀  (0) 2021.12.08
0x0A강 - DFS  (0) 2021.12.07

백준 2468번 안전 영역 문제를 풀어서 바킹독 알고리즘 강의 풀이 레포지토리에 PR했다. 

이번에 올리는 PR이 3번째라... 제발 merge되기를 빌었는데. 드디어 되서 기뻤다.

어젯밤에 반례를 못찾고 왜 틀렸는지 못찾아서 2시간이나 잡아먹은 문제라... 

그리고 앞으로 변수명은 i,j로 꼭 맞춰야겠다. 

 

유툽 강의 댓글에 이 문제에 대해 질문하고 내 코드를 달아놨는데.

우연히 바킹독님 유튜브 라이브때 접속해계셔서. 밤에 라이브때 내 코드를 띄워놓고(좀 부끄러웠지만..) 바로 반례찾아주셨다. 

감동이었고 감사했다. 

나도 바킹독님 처럼 다른사람을 도울 수 있는 능력과 마음을 가진 사람으로 성장하고 싶다는 생각을 다시금 했다. 

 

728x90

 

바킹독님 알고리즘 문제 풀이 레포지토리에 내 코드를 PR 올렸다. (두근두근)

다른 분 레포에 PR을 올려본건.. 처음인데. 

 

바킹독님으로부터 코드 리뷰를 받았다. 레포지토리의 컨벤션 지키는 것에 실수가 있고 파일명이 꼬여서라는 이유로 Close 됬지만.

덕분에 short-circuit evaluation을 다시 상기시키며 내 코드가 어떻게 나아질 수 있는지 배울 수 있어서 기뻤다. 

 

동작은 하는데, 찜찜하고 어떻게 더 낫게 고치는지 방법을 못찾겠는 상태였었는데.

이제 속시원.

 

바킹독님이 컨벤션을 고쳐주시고 (Tab -> space2개.. etc) 코드의 아쉬운점을 고쳐서 새로 올려주셨다.  

Authored by 내 닉네임이 올라가니까 설레기도 하고. 더 잘하고 싶다는 마음과 재미도 생겼다. 

앞으로도 화이팅.

728x90

목차 

0x00  정의와 성질 

0x01 기능과 구현 

0x02 STL list

0x03 연습문제 


0x00  정의와 성질 

배열과 연결리스트 비교


연결리스트의 성질 

  • k번째 원소를 조회/변경하기 위해 O(k)가 필요함 
  • 임의의 위치에 원소를 추가/ 임의 위치의 원소를 제거 할때 O(1) 필요함
  • 배열과 달리 원소들이 메모리 상에 연속해있지 않아 Cache hit rate가 낮지만 할당이 다소 쉬움

연결리스트의 종류

  • 단일 연결 리스트 : 원소가 ‘다음’원소의 주소를 알고 있다.
  • 이중 연결 리스트 :  원소가 ‘이전’원소의 주소와 ‘다음’원소의 주소를 둘 다 알고 있다. 
  • 원형 연결 리스트 : 마지막 원소가 가장 처음원소의 주소를 알고 있다. 

배열과 연결리스트 비교

배열은 원소의 값만 저장한다. 반면에 연결리스트는 '다음 원소의 주소'도 저장하므로 메모리를 더 필요로 한다. 

연결리스트를의 시간복잡도를 정리하면, 

임의의 위치에 있는 원소를 확인하거나 변경 = O(N)
임의의 위치에 원소를 추가/ 임의 위치의 원소를 제거 = O(1)
(추가할 주소를 알고 있는 경우, O(1)에 가능하다는 의미다. )

배열에 비해, ‘다음 주소’만큼의 메모리를 소모한다. 

그래서 32비트 컴퓨터면 주소값이 32비트(=4바이트) 단위이니 4N 바이트가 추가로 필요하다.
64비트 컴퓨터라면 주소값이 64비트(=8바이트) 단위이니 8N 바이트가 추가로 필요하게 된다. 
즉 N에 비례하는 만큼의 메모리를 추가로 쓰게 된다. 


0x01 기능과 구현 

기능

임의의 위치에 있는 원소를 확인하거나 변경 = O(N)
임의의 위치에 원소를 추가/ 임의 위치의 원소를 제거 = O(1)

어디에 연결리스트가 어디에 활용될까 ?

메모장! 을 예로 들 수 있다.
커서를 옮기고, 글자를 지우고 이러한 변경 연산이 많다. 
이렇게 추가 제거 연산이 자주 발생하는 경우 연결리스트 사용을 고려해보자. 

구현

STL에서 제공하는 list를 쓰자. 
STL에 연결리스트가 있는데, 이 컨테이너의 이름은 list이고  내부적으로 Double Linked List로 구현되어 있다. 
면접에서의 손코딩을 대비할 때 STL list를 이용해서 끄적이며 연습하자. 

 

정석적인 구현

NODE 구조체나 클래스를 만들어서 원소가 할당될 때마다 동적할당을 하고, 노드를 삭제하면 메모리를 해제하는 식으로 구현한다. 

struct NODE{
  struct NODE *prev, *next;
  int data;
};

야매 연결 리스트

STL의 list가 제공되지 않는 경우에 임시로 쓰는 연결리스트 형태를 배운다 .
메모리 할당 및 해제를 생략하고 배열만을 이용한다. 
메모리 누수 문제 때문에 실무에서는 절대 쓸 수 없다. 
구현 난이도가 일반적인 연결리스트보다 낮고, 시간복잡도도 동일하므로 코테에서만 애용하면 된다. 

메모리 할당 및 해제를 생략한 형태

야매 연결리스트는 원소를 배열로 관리한다. 

이전/다음 원소 주소를 가리키는 대신, 배열 인덱스를 저장하는 방식으로 구현한 연결리스트다. 

dat 배열 : 데이터 저장 
pre 배열 : 이전 노드의 주소 저장
nxt 배열 : 다음 노드의 주소 저장  
unused: 새로운 원소가 들어갈 수 있는 인덱스. 원소가 추가되면 1씩 증가한다. 
0번지: 시작점을 나타내기 위한 dummy node. 실제로 값을 저장하지 않는다. 

pre 값이나 nxt 값이 -1이면 해당 원소의 이전/다음 원소가 존재하지 않는다는 의미다. 

모든 노드를 순회하여 출력하는 traverse() 함수 

0번지 (dummy node)에서 시작한다. 

더미노드가 가리키는 nxt는 2 인덱스를 가리킨다. 

따라서 더미노드 다음 노드는 2번지 노드다. 

void traverse(){
	int cur = nxt[0];
    while(cur != -1){
    	cout << dat[cur << ' ';
        cur = nxt[cur];
    }
    cout << '\n';
}



insert() 함수 직접 구현해보기 


erase() 함수 직접 구현해보기 

 

전체 코드 바킹독 github 

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

const int MX = 1000005;
int dat[MX], pre[MX], nxt[MX];
int unused = 1; // 사용가능한 인덱스 

void insert(int addr, int num){ // 삽입하려는 인덱스와 값을 파라미터로 받는다. 
  dat[unused] = num;   // 새로운 원소를 생성 
  pre[unused] = addr;  // 이전 노드의 다음 인덱스에 현재 삽입하려는 인덱스를 대입 
  nxt[unused] = nxt[addr]; // 새 원소의 nxt값에 삽입할 위치 nxt 값을 대입
  if(nxt[addr] != -1) pre[nxt[addr]] = unused; // 맨 마지막 원소에 데이터를 추가할 때를 처리.
  nxt[addr] = unused; // 삽입한 인덱스의 다음은 unused 
  unused++; // 새로 할당할 수 있는 인덱스 증가시키기 
}

void erase(int addr){
  nxt[pre[addr]] = nxt[addr]; // 이전 노드의 nxt에 삭제할 노드의 nxt할당. 
  if(nxt[addr] != -1) pre[nxt[addr]] = pre[addr]; // 삭제할 노드가 마지막이 아닌 경우 처리. 
}

 

왜 pre[addr]이 -1인지는 체크를 하지 않아도 되는데 nxt[addr]이 -1인지는 체크를 해야 할까?

더미노드가 존재하니까 pre[addr]는 항상 -1이 아니다. 
맨 마지막 원소일 경우 nxt[addr]가   -1일 수 있기 때문이다. 


0x02 STL list

vector와 비슷한 것이 많다. 

push_back, pop_back, push_front, pop_front는 모두 O(1)의 시간복잡도다. 


iterator가 주소 역할을 한다! 매우 유용함.

int main(void) {

    ios_base::sync_with_stdio(0);
    cin.ignore(0);

    list<int> L = {1, 2}; // 1, 2  
    list<int>::iterator t = L.begin(); // t는 1을 가리킨다. 
    
    L.push_front(10); // 10, 1, 2
    cout << *t << '\n'; // t는 1을 가리키니까, 1이 출력된다. 
    
    L.push_back(5); // 10, 1, 2, 5
    L.insert(t, 6); // t가 가리키는 곳 앞에 6을 삽입한다. 10, 6, 1, 2, 5
    t++; // t를 한 칸 뒤쪽으로 보낸다. t는 2를 가리킨다. 
    
    t = L.erase(t); // t가 가리키는 값을 제거하고, 그 다음 원소의 위치를 반환한다. 
    // 2를 제거하고 t는 5를 가리키게 된다. 10, 6, 1, 5
                    
    cout << *t << '\n'; // 5 
    
    // 순회하며 출력 
    for(auto i : L) cout << i << ' ';
    cout << '\n';
    
    for(list<int>::iterator it = L.begin(); it != L.end(); it++)
        cout << *it << ' ';
    
    return 0;

}

list::iterator라는 type을 치기가 귀찮으면 C++11 이상일 때 auto t = L.begin()으로 써도 된다. 


0x03 연습문제

연습문제를 풀어보고, 손코딩 문제는 잠시 생각해보고 답해보자. 

BOJ 1406번 에디터

BOJ 5397번 키로거 

BOJ 1158번 요세푸스 문제 

 

손코딩 문제 1 
원형 연결 리스트 내의 임의의 노드 하나가 주어졌을 때, 해당 리스트의 길이를 효율적으로 구하는 방법? 

 

정답
동일한 노드가 나올때 까지 계속 다음 노드로 가면 됨. 
공간복잡도 O(1)
시간복잡도 O(N)

손코딩문제 2 
중간에 만나는 두 연결 리스트의 시작점들이 주어졌을 때, 만나는 지점을 구하는 방법은? 

 

정답 
일단 각 리스트의 길이를 구한다. 
더 긴 리스트에서 둘의 길이 차이 만큼 앞으로 이동시켜 놓는다. 
두 시작점이 만날 때 까지 두 시작점을 동시에 한 칸씩 전진 시키면 된다. 

 

손코딩문제 3 
주어진 연결 리스트 안에 사이클이 있는지 판단하라. 

 

정답 
Floyd’s cycle-finding algorithm, 공간복잡도 O(1), 시간복잡도 O(N)
한 칸씩 가는 커서와 두 칸씩 가는 커서를 동일한 시작점에서 출발시킨다. 
사이클이 없으면, 두 커서가 만나지 못하고 연결리스트의 끝에 도달한다. 
사이클이 있으면, 두 커서는 반드시 만나게 된다. 
거치는 모든 노드를 저장할 필요가 없다.  


다음 시간에는 스택을 공부한다. 

공부 내용 출처 : 바킹독 알고리즘 연결 리스트  

728x90

'알고리즘 > 실전 알고리즘' 카테고리의 다른 글

0x06강 - 큐  (0) 2021.11.23
0x05강 - 스택  (0) 2021.11.23
0x03강 - 배열  (0) 2021.11.21
0x02강 - 기초 코드 작성 요령2  (0) 2021.11.20
0x01강 - 기초 코드 작성 요령1  (0) 2021.11.19

목차

0x00 배열의 정의와 성질 

0x01 기능과 구현 

0x02 STL vector 

0x03 연습문제 


0x00 배열의 정의와 성질 

정의

메모리 상에 원소를 연속하게 배치한 자료구조 

 

성질 

1. O(1)에 k번째 원소를 확인/변경 가능 

2. 추가적으로 소모되는 메모리의 양(overhead)이 거의 없음 

3. Cache hit rate가 높음 

4. 메모리 상에 연속한 구간을 잡아야 해서 할당에 제약이 걸림  

 

참고) Cache hit rate 가 높다는 의미 

캐시 적중률이 높다 == 캐시를 참조했더니 데이터가 존재하는 확률이 높다.

캐시의 지역성에 기반하여 캐시에 존재하는 데이터의 주변 데이터도 곧 사용할 가능성이 높은 데이터다. 

배열은 메모리상에 연속한 구간에 존재하기 때문에 Cache hit rate가 높다. 


0x01 기능과 구현

조회, 수정 O(1)

인덱스로 바로 접근할 수 있으니까 O(1) 시간이 걸린다. 

맨 끝에 추가, 마지막 원소를 제거 O(1)

따로 배열을 변경할 필요 없기 때문에 O(1)이 걸린다. 

임의의 위치에 원소 추가 O(N)

평균적으로 N/2 개를 밀어야 하기 때문이다. 

책꽂이에 꽂힌 책들을 생각해보자. 중간에 책을 하나 꽂으려면, 나머지 책들을 옆으로 밀어야 한다. 

임의의 위치에 원소 제거 O(N)

이 경우도 원소를 하나 제거하고, 평균적으로  N/2 개를 밀어야 하니까 O(N)의 시간이 필요하다. 

insert 함수와 erase 함수를 직접 구현해보자

github 링크에 들어가서 코드를 받고 직접 삽입/삭제를 구현해보자. 

존재하지 않는 인덱스를 참조하지 않도록 주의해야 한다. 

배열 초기화 팁 

1. memset  실수할 여지가 많다. 0이나 -1아닌 다른 값을 넣으면 오동작한다. 

2. for  투박하지만 실수할 여지가 없어서 무난하다. 

3. algorithm헤더의 fill함수 사용을 권한다. 

실수할 여지도 별로 없고, 코드가 짧아서 가장 추천하는 방식이다. 

    int a[21];
    int b[21][21];
    
    // 1. memset
    memset(a, 0, sizeof a);
    memset(b, 0, sizeof b);
    
    // 2. for
    for(int i = 0; i < 21; i++)
        a[i] = 0;
    for(int i = 0; i < 21; i++)
        for(int j = 0; j < 21; j++)
            b[i][j] = 0;
        
    // 3. fill
    fill(a, a+21, 0);
    for(int i = 0; i< 21; i++)
        fill(b[i], b[i]+21, 0);

0x02 STL vector 

배열과 거의 동일한 기능의 STL 
배열과 달리 크기를 자유자재로 늘이고 줄일수 있는 장점이 있다. 

공식 레퍼런스 사이트에서 확인하며 메서드 정확하게 익히도록 연습하기.
iterator는 따로 공부하는 것을 추천함

 

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

int main(){
    vector<int> v1(3, 5);  // {5,5,5}
    cout << v1.size() << '\n';   // 3
    v1.push_back(7);   // {5,5,5,7}
    
    vector<int> v2(2);    // {0,0}
    v2.insert(v2.begin()+1, 3);   // {0,3,0}
    
    vector<int> v3 = {1,2,3,4};
    v3.erase(v3.begin() + 2);  // {1,2,4}
    
    vector<int> v4;  // {}
    v4 = v3;   // {1,2,4}
    cout << v4[0] << v4[1] << v4[2] << '\n';  // 1 2 4
    v4.pop_back(); // {1,2}
    v4.clear();    // {}
}

시간 복잡도 유념할 점

앞에서 넣고 빼는 popfront(), pushfront() 는 O(N)의 시간이 걸리는 것을 유의하자. 

insert(), erase()는 시간복잡도가 O(N)

뒤 쪽에 넣고 빼는 popback(), pushback()은 O(1)

벡터에서 = 를 사용하면 deep copy 가 발생한다. 

vector 순회하는 방법

vector<int> v1 = {1,2,3,4,5,6};

// 1. range-based for loop (since C++11)
for(int e: v1)
	cout << e << ' ';
    
// 2. not bad
for(int i = 0; i< v1.size(); i++)
	cout << v1[i] << ' ';
    
// 3. 주의! 
for(int i = 0; i <= v1.size()-1; i++)
	cout << v1[i] << ' ';

 

1. range-based for loop 범위 기반 반복문 (C++11 부터 지원된 기능임을 유의)

2. v1.size()를 인덱스의 끝으로 보는 것 

3. v1.size()는 기본적으로 unsized int를 반환함! 조심해야 함. 

만약, v1이 빈 벡터일 때, 
v1.size() -1 이 unsigned int 0에서 int1 을 빼는 식이 된다. 
그리고 unsiged int 와 int를 연산하면 unsigned int 로 자동 형변환이 된다.

 

unsigned int 0 - (int) 1은 -1이 아니라, 4294967295 가 되어버린다!! 
unsigned int  overflow가 발생하여 런타임 에러가 난다. 


0x03 배열 연습문제

 배열 문제집을 풀어보자. 

BOJ 10808번 알파벳 갯수 


다음시간에는 연결리스트를 배우고 연습문제를 풀어본다. 

공부 내용 출처 : 바킹독 알고리즘 0x03 배열 

728x90

'알고리즘 > 실전 알고리즘' 카테고리의 다른 글

0x05강 - 스택  (0) 2021.11.23
0x04강 - 연결리스트  (0) 2021.11.22
0x02강 - 기초 코드 작성 요령2  (0) 2021.11.20
0x01강 - 기초 코드 작성 요령1  (0) 2021.11.19
0x00강 - 오리엔테이션(환경세팅)  (0) 2021.11.19

+ Recent posts