모두를 위한 컴퓨터 과학 (CS50 2019) 4강. 알고리즘

Daisy 🌼·2022년 7월 11일
0

Computer science

목록 보기
4/6
post-thumbnail

1. 검색 알고리즘

배열은 한 자료형의 여러 값들이 메모리상에 모여 있는 구조로, 컴퓨터는 이 값들에 접근할 때 배열의 인덱스 하나하나를 접근한다. 만약 어떤 값이 배열 안에 속해 있는지를 찾아 보기 위해서는 배열이 정렬되어 있는지 여부에 따라 아래와 같은 방법을 사용할 수 있다.

  • 선형 검색

배열의 인덱스를 처음부터 끝까지 하나씩 증가시키면서 방문하여 그 값이 속하는지를 검사합니다. 아래 의사코드와 같이 나타낼 수 있다.

For i from 0 to n–1

    If i'th element is 50

        Return true

Return false
  • 이진 검색

만약 배열이 정렬되어 있다면, 배열 중간 인덱스부터 시작하여 찾고자 하는 값과 비교하며 그보다 작은(작은 값이 저장되어 있는) 인덱스 또는 큰 (큰 값이 저장되어 있는) 인덱스로 이동을 반복하면 된다. 아래 의사코드와 같이 나타낼 수 있다.

If no items

    Return false

If middle item is 50

    Return true

Else if 50 < middle item

    Search left half

Else if 50 > middle item

    Search right half

2. 알고리즘 표기법

1주차에 아래 그림과 같이 알고리즘을 실행하는데 걸리는 시간을 표현해 봤었다.

위와 같은 그림을 공식으로 표기한 것이 Big O 표기법으로,
여기서 O는 “on the order of”의 약자로, 쉽게 생각하면 ~만큼의 정도로 커지는 것이라고 볼 수 있다.

O(n) 은 n만큼 커지는 것이므로 n이 늘어날수록 선형적으로 증가하게 된다.
O(n/2)도 결국 n이 매우 커지면 1/2은 큰 의미가 없어지므로 O(n)이라고 볼 수 있다.

주로 아래 목록과 같은 Big O 표기가 **실행 시간**을 나타내기 위해 많이 사용된다.

  • O(n^2)
  • O(n log n)
  • O(n) - 선형 검색
  • O(log n) - 이진 검색
  • O(1)

Big O가 알고리즘 실행 시간의 상한을 나타낸 것이라면, 반대로 Big Ω는 알고리즘 실행 시간의 하한을 나타내는 것이다.

예를 들어 선형 검색에서는 n개의 항목이 있을때 최대 n번의 검색을 해야 하므로 상한이 O(n)이 되지만 운이 좋다면 한 번만에 검색을 끝낼수도 있으므로 하한은 Ω(1)이 된다.

역시 아래 목록과 같은 Big Ω 표기가 많이 사용된다.

  • Ω(n^2)
  • Ω(n log n)
  • Ω(n) - 배열 안에 존재하는 값의 개수 세기
  • Ω(log n)
  • Ω(1) - 선형 검색, 이진 검색

3. 선형 검색

  • 선형 검색

찾고자 하는 자료를 검색하는 데 사용되는 다양한 알고리즘이 있다. 그 중 하나가 선형 검색으로, 원하는 원소가 발견될 때까지 처음부터 마지막 자료까지 차례대로 검색한다.

이렇게 하여 선형 검색은 찾고자 하는 자료를 찾을 때까지 모든 자료를 확인해야 한다.

  • 효율성 그리고 비효율성
    선형 검색 알고리즘은 정확하지만 아주 효율적이지 못한 방법이다.

리스트의 길이가 n이라고 했을 때, 최악의 경우 리스트의 모든 원소를 확인해야 하므로 n번만큼 실행된다. 여기서 최악의 상황은 찾고자 하는 자료가 맨 마지막에 있거나 리스트 안에 없는 경우를 말한다. 만약 100만 개의 원소가 있는 리스트라고 가정해본다면 효율성이 매우 떨어짐을 느낄 수 있다. 반대로 최선의 상황은 처음 시도했을 때 찾고자 하는 값이 있는 경우이다.

평균적으로 선형 검색이 최악의 상황에서 종료되는 것에 가깝다고 가정할 수 있다.

선형 검색은 자료가 정렬되어 있지 않거나 그 어떤 정보도 없어 하나씩 찾아야 하는 경우유용하다.

이러한 경우 무작위로 탐색하는 것보다 순서대로 탐색하는 것이 더 효율적이다.
이제 왜 검색 이전에 정렬해줘야 하는지 알 수 있을 것이다.

정렬은 시간이 오래 걸리고 공간을 더 차지한다.
하지만 이 추가적인 과정을 진행하면 여러분이 여러 번 리스트를 검색해야 하거나 매우 큰 리스트를 검색해야 할 경우 시간을 단축할 수 있을 것이다.

주어진 배열에서 특정 값을 찾기 위해서 선형 검색을 사용한다면, 아래와 같은 코드를 작성할 수 있다.

#include <cs50.h>
#include <stdio.h>

int main(void)
{
    // numbers 배열 정의 및 값 입력
    int numbers[] = {4, 8, 15, 16, 23, 42};

    // 값 50 검색
    for (int i = 0; i < 6; i++)
    {
        if (numbers[i] == 50)
        {
            printf("Found\n");
            return 0; // True
        }
    }
    printf("Not found\n");
    return 1; // False
}

배열의 크기만큼 for 루프를 돌면서 배열의 인덱스를 차례대로 방문하며 찾는 값이 있는지를 검사하면 된다. 문자열로 이루어진 배열도 비슷한 방식으로 검색할 수 있다.

만약 전화번호부에서 특정 이름을 찾아 해당하는 전화번호를 출력하는 프로그램을 작성하려면 어떻게 할 수 있을까?

가장 간단한 예는 아래와 같은 프로그램이 될 것이다.

#include <cs50.h>
#include <stdio.h>
#include <string.h>

int main(void)
{
    string names[] = {"EMMA", "RODRIGO", "BRIAN", "DAVID"};
    string numbers[] = {"617–555–0100", "617–555–0101", "617–555–0102", "617–555–0103"};

    for (int i = 0; i < 4; i++)
    {
        if (strcmp(names[i], "EMMA") == 0)
        {
            printf("Found %s\n", numbers[i]);
            return 0;
        }
    }
    printf("Not found\n");
    return 1;
}

names 배열과 numbers 배열을 따로 정의하고 names 배열에서 검색을 해서 해당하는 인덱스의 numbers 배열 값을 출력하는 것이다. 하지만 이 경우에는 names 배열과 numbers 배열이 서로 같은 인덱스를 가져야 한다는 한계가 있다.

더 좋은 방법은 아래 코드와 같이 새로운 자료형으로 **구조체**를 정의해서 이름과 번호를 묶어주는 것이다.

#include <cs50.h>
#include <stdio.h>
#include <string.h>

typedef struct
{
    string name;
    string number;
}
person;

int main(void)
{
    person people[4];

    people[0].name = "EMMA";
    people[0].number = "617–555–0100";
    people[1].name = "RODRIGO";
    people[1].number = "617–555–0101";
    people[2].name = "BRIAN";
    people[2].number = "617–555–0102";
    people[3].name = "DAVID";
    people[3].number = "617–555–0103";

    // EMMA 검색
    for (int i = 0; i < 4; i++)
    {
        if (strcmp(people[i].name, "EMMA") == 0)
        {
            printf("Found %s\n", people[i].number);
            return 0;
        }
    }
    printf("Not found\n");
    return 1;
}

person 이라는 이름의 구조체를 자료형으로 정의하고 person 자료형의 배열을 선언하면 그 안에 포함된 속성값은 ‘.’으로 연결해서 접근할 수 있다.

person a; 라는 변수가 있다면, a.name 또는 a.number 이 각각 이름과 전화번호를 저장하는 변수가 된다. 이렇게 함으로써 더욱 확장성 있는 전화번호부 검색 프로그램을 만들 수 있다.

4. 버블 정렬

  • 버블 정렬
    정렬되지 않은 리스트를 탐색하는 것 보다 정렬한 뒤 탐색하는 것이 더 효율적이다.
    정렬 알고리즘 중 하나는 버블 정렬이다.

버블 정렬은 두 개의 인접한 자료 값을 비교하면서 위치를 교환하는 방식으로 정렬하는 방법을 말한다. 버블 정렬은 단 두 개의 요소만 정렬해주는 좁은 범위의 정렬에 집중한다.

이 접근법은 간단하지만 단 하나의 요소를 정렬하기 위해 너무 많이 교환하는 낭비가 발생할 수도 있다.

아래와 같은 8개의 숫자가 임의의 순서로 나열되어 있다.
이 숫자들을 오름차순으로 정렬하기 위해 바로 옆의 있는 숫자들과 비교하는 방법을 사용해 보겠다.

6 3 8 5 2 7 4 1

먼저 가장 앞의 6과 3을 비교해서 순서를 바꾼다.

  • 교환 전: 6 3 8 5 2 7 4 1
  • 교환 후: 3 6 8 5 2 7 4 1

다음 쌍인 6과 8을 비교해보면 교환할 필요가 없으므로 그대로 둔다.
바로 다음에 있는 쌍인 8과 5를 비교해서 순서를 바꾼다.

  • 교환 전: 3 6 8 5 2 7 4 1
  • 교환 후: 3 6 5 8 2 7 4 1

이런 식으로 숫자 끝까지 진행하면 아래와 같이 정렬이 된다.
3 6 5 2 7 4 1 8

하지만 아직 오름차순으로 정렬이 되지 않았기 때문에, 다시 처음부터 동일한 작업을 반복한다.

3 6 5 2 7 4 1 8
6 5 2 7 4 1 8 (교환)
3 5 6 2 7 4 1 8 (교환)
3 5 2 6 7 4 1 8
3 5 2 6 7 4 1 8 (교환)
3 5 2 6 4 7 1 8 (교환)
3 5 2 6 4 1 7 8

조금 더 잘 정렬이 되었다. 이 과정을 끝까지 반복하면 최종적으로 아래와 같이 오름차순 정렬이 될 것이다.
1 2 4 3 5 6 7 8

이러한 정렬 방식을 ‘버블 정렬’이라고 한다.
마치 거품이(비교 및 교환이) 터지면서 위로 올라오는 (배열의 옆으로 이동하는) 방식이기 때문이다.

아래와 같이 의사 코드로 나타낼 수 있다.

Repeat n–1 times

    For i from 0 to n–2

        If i'th and i+1'th elements out of order

            Swap them

중첩 루프를 돌아야 하고, n개의 값이 주어졌을 때 각 루프는 각각 n-1번, n-2번 반복되므로  (n-1)(n-2) = n^2-3n+2(n−1)∗(n−2)=n2−3n*+2  번의 비교 및 교환이 필요하다.

여기서 가장 크기가 큰 요소는 n^2 이므로 위와 같은 코드로 작성한 버블 정렬 실행 시간의 상한은 O(n^2)이라고 말할 수 있다.

정렬이 되어 있는지 여부에 관계 없이 루프를 돌며 비교를 해야 하므로 위와 같은 코드로 작성한 버블 정렬의 실행 시간의 하한도 여전히 Ω(n^2)이 된다.

Q. 버블정렬이 효율적인 경우
주어진 숫자의 배열을 정렬하는 관점에서 볼 때는 당연히 비효율적이다. MergeSort, QuickSort 와 같은 O(nlogn)에 동작하는 정렬방법도 있고 제한적인 조건에서 O(n)으로 실행가능한 CountingSort도 있기 때문이다.

하지만 BubbleSort의 내부동작의 특징을 살린다면 효율적인 경우는 최댓값 또는 최솟값을 구할 때 다. 강의에서도 보았듯이 BubbleSort의 외부 루프를 한차례 진행하게 되면 오름차순 정렬일 경우 주어진 배열의 최댓값은 항상 가장 오른쪽으로 간다. 이를 이용한다면 주어진 배열의 최솟값 또는 최댓값을 버블 정렬을의 메커니즘을 이용해 O(n)의 시간으로 구할 수 있다. 하지만 MergeSort, QuickSort 등의 경우 O(nlogn)의 모든 정렬과정이 끝나야만 최댓값 또는 최솟값을 얻을 수 있다.

5. 선택 정렬

  • 선택 정렬
    보통 배열이 정렬되어 있으면 정렬되지 않은 배열보다 더 쉽게 탐색할 수 있다.
    정렬을 위한 알고리즘 중 선택정렬을 배열 안의 자료 중 가장 작은 수(혹은 가장 큰 수)를 찾아 첫 번째 위치(혹은 가장 마지막 위치)의 수와 교환해주는 방식의 정렬이다.

선택 정렬은 교환 횟수를 최소화하는 반면 각 자료를 비교하는 횟수는 증가한다.

다음과 같은 정렬되지 않은 숫자들을 오름차순 정렬해보자.
6 3 8 5 2 7 4 1

먼저 아래 숫자들 중에서 가장 작은 값을 찾는다.
6 3 8 5 2 7 4 1

가장 작은 값인 1은 가장 앞에 있어야 하므로 현재 리스트의 첫 번째 값인 6과 교환한다.
1 3 8 5 2 7 4 6

그리고 정렬되어 있는 1은 제외하고, 두 번째 숫자부터 시작해서 또 가장 작은 값을 찾는다.
1 3 8 5 2 7 4 6

가장 작은 값인 2는 정렬되지 않는 숫자들 중에서 가장 앞에 있어야 하므로 3과 교환한다.
2 8 5 3 7 4 6

이 과정을 더 이상 교환이 일어나지 않을때까지 반복하면, 아래와 같이 오름차순 정렬이 완료된다.
1 2 3 4 5 6 7 8

이러한 정렬 방법을 ‘선택 정렬’ 이라고 하며, 의사 코드로 아래와 같이 표현할 수 있다.

For i from 0 to n–1

    Find smallest item between i'th item and last item

    Swap smallest item with i'th item

여기서도 두 번의 루프를 돌아야 한다.
바깥 루프에서는 숫자들을 처음부터 순서대로 방문하고, 안쪽 루프에서는 가장 작은 값을 찾아야 한다. 따라서 소요 시간의 상한은 O(n^2)이 된다. 하한도 마찬가지 Ω(n^2) 로, 버블 정렬과 동일하다.

Q. 선택정렬을 좀 더 효율적으로 어떻게 바꿀 수 있을까?
탐색할 때 최소값최대값을 둘다 찾는다면 더 빠르게 동작할 것이다.

6. 정렬 알고리즘의 실행시간

여태까지 배운 선형 검색, 이진 검색, 버블 정렬, 선택 정렬의 실행시간은 각각 어떻게 되는지 정리해보자.

실행시간의 상한

  • O(n^2): 선택 정렬, 버블 정렬
  • O(n log n)
  • O(n): 선형 검색
  • O(log n): 이진 검색
  • O(1)

실행시간의 하한

  • Ω(n^2): 선택 정렬, 버블 정렬
  • Ω(n log n)
  • Ω(n)
  • Ω(log n)
  • Ω(1): 선형 검색, 이진 검색

여기서 버블 정렬을 좀 더 잘 할 수 있는 방법을 알아보자.
만약 정렬이 모두 되어 있는 숫자 리스트가 주어진다면 어떨까?

원래 의사 코드는 아래와 같다.

Repeat n–1 times

    For i from 0 to n–2

        If i'th and i+1'th elements out of order

            Swap them

여기서 안쪽 루프에서 만약 교환이 하나도 일어나지 않는다면 이미 정렬이 잘 되어 있는 상황일 것이다.
따라서 바깥쪽 루프를 ‘교환이 일어나지 않을때’까지만 수행하도록 다음과 같이 바꿀 수 있다.

Repeat until no swaps

    For i from 0 to n–2

        If i'th and i+1'th elements out of order

            Swap them

따라서 최종적으로 버블 정렬의 하한은 Ω(n)이 된다.
상황에 따라서는 선택 정렬보다 더 빠른 방법이 되는 것이다.

실행시간의 하한

  • Ω(n^2): 선택 정렬
  • Ω(n log n)
  • Ω(n): 버블 정렬
  • Ω(log n)
  • Ω(1): 선형 검색, 이진 검색

Q.선택 정렬의 실행 시간의 하한도 버블 정렬처럼 더 단축시킬 수 있을까?
선택 정렬은 버블 정렬과 달리 컴퓨터가 모든 배열의 값을 훑고 가장 작은(또는 큰) 값이라는 판단을 내려야 하기 때문에 실행 시간을 줄일 수는 없을 것 같다.

7. 재귀

  • 재귀
    함수를 사용할 때 어디에서 호출했을까? main 안에서 프로그램을 작성하면서 필요한 순간에 호출하여 사용한다. 그런데 main 역시 함수로, main이라는 함수 안에서 또 다른 함수를 사용한 것이다.

이 사실을 알게 되었을 때, 우리는 함수가 본인 스스로를 호출해서 사용할 수 있는지에 대해 의문을 가질 수 있다. 이에 대한 대답은 할 수 있다 이며, 이러한 것을 재귀(Recursion)라고 부른다.

아래와 같이 피라미드 모양을 출력하기 위해 다음과 같은 코드를 작성할 수 있다.

@
@@
@@@
@@@@


#include <cs50.h>
#include <stdio.h>

void draw(int h);

int main(void)
{
    // 사용자로부터 피라미드의 높이를 입력 받아 저장
    int height = get_int("Height: ");

    // 피라미드 그리기
    draw(height);
}

void draw(int h)
{
    // 높이가 h인 피라미드 그리기
    for (int i = 1; i <= h; i++)
    {
        for (int j = 1; j <= i; j++)
        {
            printf("#");
        }
        printf("\n");
    }
}

높이를 입력 받아 중첩 루프를 통해 피라미드를 출력해주는 draw 함수를 정의한 것이다.
여기서 꼭 중첩 루프를 써야만 할까? 사실 바깥 쪽 루프는 안 쪽 루프에서 수행하는 내용을 반복하도록 하는 것일 뿐이다. 따라서 바깥 쪽 루프를 없앤 draw함수를 만들고, 이를 ‘재귀적으로’ 호출하도록 해서 똑같은 작업을 수행할 수 있다.
즉, draw 함수 안에서 draw 함수를 호출 하는 것으로, 아래 코드와 같이 수정할 수 있다.

#include <cs50.h>
#include <stdio.h>

void draw(int h); //초기 함수지정

int main(void)
{
    int height = get_int("Height: ");

    draw(height);
}

void draw(int h)
{
    // 높이가 0이라면 (그릴 필요가 없다면)
    if (h == 0)
    {
        return;
    }
// 높이가 h-1인 피라미드 그리기
draw(h - 1);
// 피라미드에서 폭이 h인 한 층 그리기
for (int i = 0; i < h; i++)
{
    printf("#");
}
printf("\n");

}

draw 함수 안에서 draw 함수를 다시 호출 하는 부분을 유의하자.

h라는 높이를 받았을 때, h-1 높이로 draw 함수를 먼저 호출하고, 그 후에 h 만큼의 #을 출력한다. 여기서 내부적으로 호출된 draw 함수를 따라가다 보면 `h = 0인 상황이` 오게 된다.

따라서 그 때는 아무것도 출력을 하지 않도록 하는 조건문을 추가해줘야 한다.

이렇게 재귀를 사용하면 중첩 루프를 사용하지 않고도 하나의 함수로 동일한 작업을 수행할 수 있다.

8. 병합정렬

  • 병합 정렬
    전화번호부의 분할 정복 탐색처럼 데이터를 반으로 나누어간다는 것과 공통점이 있는 방법인 병합 정렬(합병 정렬)이 있다.

병합 정렬은 원소가 한 개가 될 때까지 계속해서 반으로 나누다가 다시 합쳐나가며 정렬을 하는 방식이다. 그리고 이 과정은 재귀적으로 구현되기 때문에 나중에 재귀를 학습하면 더 이해하기 쉽다.

마찬가지로 다음 숫자들을 오름차순으로 정렬해 보자.
7 4 5 2 6 3 8 1

  1. 먼저 숫자들을 반으로 나눈다.
    7 4 5 2 | 6 3 8 1
  1. 그리고 나눠진 부분 중 첫번째를 한번 더 반으로 나누자.
    7 4 | 5 2 | 6 3 8 1
  1. 마지막으로 한 번 더 나누자.
    7 | 4 | 5 2 | 6 3 8 1
  1. 이제 숫자가 두 개 밖에 남지 않았으므로 더 이상 나누지 않고, 두 숫자를 다시 병합한다.
    단, 이 때 작은 숫자가 먼저 오도록 한다. 4와 7의 순서를 바꿔서 병합하는 것이다.
    4 7 | 5 2 | 6 3 8 1
  1. 마찬가지로 5 2 부분도 반으로 나눈 후에 작은 숫자가 먼저 오도록 다시 병합할 수 있다.
    4 7 | 2 5 | 6 3 8 1
  1. 그럼 이제 4 7과 2 5 두 개의 부분들을 병합하자.
    각 부분들의 숫자들을 앞에서 부터 순서대로 읽어들여 비교하여 더 작은 숫자를 병합되는 부분에 가져온다. 즉, 4와 2를 먼저 비교해서 2를 가져온다. 그 후에 4와 5를 비교해서 4를 가져온다. 그리고 7과 5를 비교해서 5를 가져오고, 남은 7을 가져온다.
    2 4 5 7 | 6 3 8 1
  1. 이제 남은 오른쪽 4개의 숫자들도 위와 동일한 과정을 거친다.
    2 4 5 7 | 1 3 6 8
  1. 마지막으로 각각 정렬된 왼쪽 4개와 오른쪽 4개의 두 부분을 병합한다.
    2와 1을 비교하고, 1을 가져옵니다. 2와 3을 비교하고, 2를 가져온다. 4와 3을 비교하고, 3을 가져온다. 4와 6을 비교하고… 이 과정을 병합이 끝날때까지 진행하면 아래와 같이 정렬이 완료된다.
    1 2 3 4 5 6 7 8
  1. 전체 과정을 요약해서 도식화해보면 아래와 같다.
    7 | 4 | 5 | 2 | 6 | 3 | 8 | 1 → 가장 작은 부분 (숫자 1개)으로 나눠진 결과입니다.
    4   7 | 2   5 | 3   6 | 1   8 → 숫자 1개씩을 정렬하여 병합한 결과입니다.
    2   4   5   7 | 1   3   6   8 → 숫자 2개씩을 정렬하여 병합한 결과입니다.
    1   2   3   4   5   6   7   8 → 마지막으로 숫자 4개씩을 정렬하여 병합한 결과입니다.

이러한 방법을 ‘병합 정렬’ 이라고 한다.
병합 정렬 실행 시간의 상한은 O(n log n) 이다.

숫자들을 반으로 나누는 데는 O(log n)의 시간이 들고, 각 반으로 나눈 부분들을 다시 정렬해서 병합하는 데 각각 O(n)의 시간이 걸리기 때문이다.

실행 시간의 하한도 역시 Ω(n log n) 이다. 숫자들이 이미 정렬되었는지 여부에 관계 없이 나누고 병합하는 과정이 필요하기 때문이다.

Q. 병합 정렬을 선택 정렬이나 버블 정렬과 비교했을 때 장점과 단점은 무엇이 있을까?
병합 정렬의 장점은 상한이 O(nlogn)으로 선택정렬과 버블정렬의 O(n^2)에 비해 빠른 장점이 있다. 단점으로는 함수를 재귀적으로 호출하여 메모리 stack에 쌓이게 되는데 depth가 깊어지면 메모리를 초과할 수 있는 문제가 있다.

profile
세상을 이롭게하는 AI Engineer

0개의 댓글