[CS50] 알고리즘
업데이트:
검색 알고리즘
- 배열은 한 자료형의 여러 값들이 메모리상에 모여 있는 구조이다.
- 컴퓨터는 이 값들에 접근할 때 배열의 인덱스 하나하나를 접근한다.
선형 검색
- 배열의 인덱스를 처음부터 끝까지 하나씩 증가시키면서 방문하여 그 값이 속하는지를 검사한다.
- 의사코드
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
알고리즘 표기법
- 위와 같은 그림을 공식으로 표기한 것이
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) - 선형 검색, 이진 검색
선형 검색
- 찾고자 하는 자료를 검색하는 데 사용되는 알고리즘 중 하나
- 선형 검색은 원하는 원소가 발견될 때까지 처음부터 마지막 자료까지 차례대로 검색한다.
효율성 그리고 비효율성
- 선형 검색 알고리즘은 정확하지만 아주 효율적이지 못한 방법이다.
- 리스트의 길이가 n이라고 했을 때, 최악의 경우 리스트의 모든 원소를 확인해야 하므로 n번만큼 실행된다.
- 반대로 최선의 상황은 처음 시도했을 때 찾고자 하는 값이 있는 경우
- 평균적으로 선형 검색이 최악의 상황에서 종료되는 것에 가깝다고 가정할 수 있다.
- 선형 검색은 자료가 정렬되어 있지 않거나 그 어떤 정보도 없이 하나씩 찾아야 하는 경우에 유용하다.
- 정렬은 시간이 오래 걸리고 공간을 더 차지한다.
- 하지만 이 추가적인 과정을 진행하면 여러 번 리스트를 검색해야 하거나 매우 큰 리스트를 검색해야 할 경우 시간을 단축할 수 있을 것이다.
#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;
}
}
printf("Not found\n");
return 1;
}
- 주어진 배열에서 특정 값을 찾기 위해 선형 검색을 사용한 코드이다.
- 배열의 크기만큼 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 이 각각 이름과 전화번호를 저장하는 변수가 된다. 이렇게 함으로써 더욱 확장성 있는 전화번호부 검색 프로그램을 만들 수 있다.
버블 정렬
- 버블 정렬은 두 개의 인접한 자료 값을 비교하면서 위치를 교환하는 방식으로 정렬하는 방법을 말한다.
- 단 두 개의 요소만 정렬해주는 좁은 범위의 정렬에 집중한다.
- 접근법은 간단하지만 단 하나의 요소를 정렬하기 위해 너무 많이 교환하는 낭비가 발생할 수도 있다.
- 아래 숫자들을 오름차순으로 정렬하기 위한 과정
- 6 3 8 5 2 7 4 1
- 교환 전: 6 3 8 5 2 7 4 1
- 교환 후: 3 6 8 5 2 7 4 1
- 교환 전: 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
- 3 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)=n2−3n+2번의 비교 및 교환이 필요하다.
- 여기서 가장 크기가 큰 요소는 n^2 이므로 위와 같은 코드로 작성한 버블 정렬 실행 시간의 상한은
O(n^2)
라고 말할 수 있다. - 정렬이 되어 있는지 여부에 관계 없이 루프를 돌며 비교를 해야 하므로 위와 같은 코드로 작성한 버블 정렬의 실행 시간의 하한도 여전이
Ω(n^2)
이 된다.
선택 정렬
- 선택 정렬은 배열 안의 자료 중 가장 작은 수(혹은 가장 큰 수)를 찾아 첫 번째 위치(혹은 가장 마지막 위치)의 수와 교환해주는 방식의 정렬이다.
- 선택 정렬은 교환 횟수를 최소화하는 반면 각 자료를 비교하는 횟수는 증가한다.
- 오름차순 정렬 과정
- 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과 교환한다.
- 1 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)
이다. 버블 정렬과 동일하다.
정렬 알고리즘의 실행시간
- 여태까지 배운 선형 검색, 이진 검색, 버블 정렬, 선택 정렬의 실행시간은 각각 어떻게 되는지 정리해보자.
실행시간의 상한
- 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): 선형 검색, 이진 검색
재귀
- 함수가 본인 스스로를 호출해서 사용할 수 있는 것을
재귀
라고 한다. - 아래와 같이 피라미드 모양을 출력하기 위해 다음과 같은 코드를 작성할 수 있다.
#
##
###
####
#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 함수를 만들고 이를 ‘재귀적’으로 호출하도록 해서 똑같은 작업을 수행할 수 있다.
#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");
}
- h라는 높이를 받았을 때, h-1 높이로 draw 함수를 먼저 호출하고, 그 후에 h 만큼의 #을 출력한다. 여기서 내부적으로 호출된 draw 함수를 따라가다 보면 h = 0인 상황이 오게 된다.
- 따라서 그 때는 아무것도 출력하지 않도록 하는 조건문을 추가해야 한다.
- 이렇게 재귀를 이용하면 중첩루프를 사용하지 않고도 하나의 함수로 동일한 작업을 수행할 수 있다.
병합 정렬
- 분할 정복 탐색처럼 데이터를 반으로 나누어간다는 것과 공통점이 있는 방법인 병합 정렬(합병 정렬)이 있다.
병합 정렬
은 원소가 한 개가 될 때까지 계속해서 반으로 나누다가 다시 합쳐나가며 정렬을 하는 방식이다.- 이 과정은 재귀적으로 구현되기 때문에 재귀를 학습하면 이해하기 더 쉽다.
- 아래 숫자들을 오름차순으로 정렬하는 과정
- 7 4 5 2 6 3 8 1
- 숫자를 반으로 나눈다.
-
7 4 5 2 6 3 8 1
-
- 나눠진 부분 중 첫 번째를 한번 더 반으로 나눈다.
-
7 4 5 2 6 3 8 1
-
-
마지막으로 한 번 더 나눈다.
-
7 4 5 2 6 3 8 1
-
- 숫자가 두 개 밖에 남지 않았으므로 더이상 나누지 않고, 두 숫자를 다시 병합한다.
- 이때 작은 숫자가 먼저 오도록 한다. 4와 7의 순서를 바꿔서 병합
-
4 7 5 2 6 3 8 1
-
- 마찬가지로 5 2 부분도 반으로 나눈 후 작은 숫자가 먼저 오도록 다시 병합한다.
-
4 7 2 5 6 3 8 1
-
- 4 7과 2 5 두 개의 부분을 병합한다. 각 부분의 숫자들을 앞에서부터 순서대로 읽어들여 비교하여 더 작은 숫자를 병합되는 부분에 가져온다.
- 즉, 4와 2를 먼저 비교해서 2를 가져온다. 그 후에 4와 5를 비교해서 4를 가져온다. 그리고 7과 5를 비교해서 5를 가져오고, 남은 7을 가져온다.
-
2 4 5 7 6 3 8 1
-
- 이제 남은 오른쪽 4개의 숫자도 위와 동일한 과정을 거친다.
-
2 4 5 7 1 3 6 8
-
- 마지막으로 각각 정렬된 왼쪽 4개와 오른쪽 4개의 두 부분을 병합한다. 2와 1을 비교하고, 1을 가져온다. 2와 3을 비교하고, 2를 가져온다. 4와 3을 비교하고, 3을 가져온다… 이 과정을 병합이 끝날때까지 진행하면 아래와 같이 정렬이 완료된다.
- 1 2 3 4 5 6 7 8
- 전체 과정을 요약해서 도식화해보면 아래와 같다.
-
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)
이다. 숫자들이 이미 정렬되었는지 여부에 관계 없이 나누고 병합하는 과정이 필요하기 때문이다.
댓글남기기