(배열이 정리되어 있지 않은 경우) 배열의 인덱스를 처음부터 끝까지 하나씩 증가시키면서 방문해 그 값이 속하는지 검사
<의사코드>
For i from 0 to n-1
If i'th element is 50
Return true
Return false
(배열이 정리된 경우) 중간 인덱스부터 검색 시작
1. 찾는 값 < 찾은 값: 작은(왼쪽) 인덱스 검색
2. 찾는 값 > 찾은 값: 큰 (오른쪽) 인덱스 검색
수업 첫 시간에 예시로 들었던 전화번호부에서 Mike Smith찾기와 비슷함
<의사코드>
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
:bulb: 실행시간의 상한이 낮은 알고리즘과 하한이 낮은 알고리즘 중 뭐가 더 좋을까?
상한이 낮은 알고리즘이 좋다. 최악의 경우를 가정했을 때 가장 빠르게 실행되는 알고리즘을 써야 더 안정적이기 때문이다.
:pushpin: 키워드: 선형검색, 구조체
1. 선형검색
원하는 원소가 발견될 때까지 처음부터 마지막 자료까지 차례대로 검색한다.
2. 효율성, 비효율성
- 선형검색 알고리즘은 정확하지만 매우 비효율적이다.
- 그러나 자료가 정렬되어있지 않거나 그 어떤 정보도 없어 하나씩 찾아야 하는 경우에 유용하다.
3. 검색 이전에 정렬해야 하는 이유
- 정렬은 시간이 오래 걸리고 공간을 더 차지한다.
- 그러나 추가적으로 정렬을 하면 여러 번 리스트를 검색해야 하거나 매우 큰 리스트를 검색해야 할 경우 시간을 단축할 수 있다.
4. 한 번에 3장의 전화번호부를 살펴본다면 탐색시간은 n/3이 될 것인데, 이런 것도 선형탐색으로 간주한다.
#include <cs50.h>
#include <stdio.h>
int main(void)
{
int numbers[6] = {4, 8, 5, 16, 23, 42};
int s;
for (int i = 0; i < 6; i++) //50찾기(선형탐색)
{
if (numbers[i] == 50)
{
printf("Found\n");
return 0; //return 0(성공)을 쓰면 성공시 함수가 여기에서 멈춘다.
}
}
printf("Not found");
return 1; //실패인 경우 관습적으로 1을 반환한다.
}
C에서는 names[i] == "EMMA"
와 같이 관계연산자 ==
은 문자열(string)에 사용할 수 없다. EMMA는 여러 문자(char)의 배열이기 때문에 각 문자열이 같은지 알고싶으면 문자열 속의 문자를 하나씩 비교해야 한다. (JAVA나 Python에서는 가능하지만 C는 로우레벨 언어이기 때문에 안됨)
C에서 문자열을 비교하려면 string.h의 strcmp(string1, string2) 를 사용하면 된다.(두 문자열이 같을 때 0 반환)
#include <cs50.h>
#include <stdio.h>
#include <string.h>
int main(void)
{
string names[4] = {"EMMA", "RODRIGO", "BRIAN", "DAVID"};
string numbers[4] = {"010-5555-0100", "010-5555-0101", "010-5555-0102", "010-5555-1012"}; // 주석 1, 2 참고
for (int i = 0; i < 4; i++)
{
if (strcmp(names[i], "EMMA") == 0)
{
printf("%s\n", numbers[i]);
return 0;
}
}
printf("Not found\n");
return 1;
}
#include <cs50.h>
#include <stdio.h>
#include <string.h>
//구조체 정의하기
typedef struct //주석 1 참고
{
string name;
string number; //주석 2 참고
}
person; //주석 3,4 참고
int main(void)
{
person people[4]; //자료형이 person인 배열 people이 정의됨
people[0].name = "EMMA"; //주석 5 참고
people[0].number = "010-5555-0100";
people[1].name = "RODRIGO";
people[1].number = "010-5555-0101";
people[2].name = "BRIAN";
people[2].number = "010-5555-0102";
people[3].name = "DAVID";
people[3].number = "010-5555-0103"; // 중괄호를 사용해서 입력할 수 있지만 그럼 코드가 더 더러워진다고 함
for (int i = 0; i < 4; i++)
{
if (strcmp(people[i].name, "EMMA") == 0)
{
printf("%s\n", people[i].number);
return 0;
}
}
printf("Not found\n");
return 1;
}
:bulb: 전화번호부와 같이 구조체를 정의하여 관리 및 검색을 하면 편리한 예는 또 무엇이 있을까요?
주민등록번호, 학번, 학점 등 두 가지 이상의 정보를 묶어서 관리해야하는 정보.
정렬되지 않은 숫자를 정렬하는 방법은 어떤 것이 있을까?
버블 정렬 은 두 개의 인접한 자료 값을 비교하면서 위치를 교환하는 방식으로 정렬하는 방법이다. 간단하지만 단 하나의 요소를 정렬하기 위해 너무 많이 교환하는 낭비가 발생한다.
For i from 0 to n-2
For j from 0 to n-2-i
If j'th and j+1th elements out of order
Swap them
버블 정렬을 할 때 필요한 작업의 수는
(n-1) + (n-2) + (n+3) + ... + 2 + 1 = {1+ (n-1)}(n-1) / 2
= n(n-1)/2
= n^2 - n/2
= O(n^2)
O(log n) < O(n) < O(n^2)이므로 이진탐색이 가장 효율적인 것으로 보일 수 있다.
그러나 이진탐색을 하기 위해서는 그 전에 자료가 정렬되어야 한다. 즉, O(n^2)의 작업 수가 추가적으로 필요하다. 따라서 이진탐색이 항상 효율적인 것이 아니다.
그러나 여러번 배열을 탐색하는 경우라면 한 번은 정렬해두면 장기적인 이득을 얻을 수 있어 좋다. 따라서 상황에 따라 a. 버블정렬+이진탐색을 쓸지, b. 선형탐색을 쓸지 선택해야 한다.
자료가 이미 정리된 상태라도 버블정렬은 무식하게 루프돌면서 다 비교한다. 따라서 최적의 경우에도 Ω(n^2)이다.
만약 운좋게 자료가 다 정리되어서 첫번째 회차에 한 번도 교환이 발생하지 않았다면 정렬을 멈추는 알고리즘을 도입한다고 하자. 그렇게 되면 컴퓨터는 자료를 n-1회 검사하고 정렬을 끝낼 것이므로 이 때 버블정렬의 최적의 경우의 작업수는 Ω(n)이 된다.
선택정렬 은 가장 작은 값을 찾아 선택한 후 왼쪽의 값과 교환하는 과정을 반복해서 정렬하는 방법이다.
선택정렬은 알고리즘이 단순하며, 사용할 수 있는 메모리가 제한적인 경우에 사용시 성능상의 이점이 있다.
For i from 0 to n-1
Find smallest item between i'th item and last item
Swap smallest item with i'th item
n + n-1 + ... + 2 + 1 = n(n+1) / 2
= n^2/2 + 1/2
= O(n^2)
:pushpin: 시간복잡도 Θ(n^2)인 정렬 알고리즘 중에서 선택 정렬은 버블 정렬보다 항상 우수하다.
:bulb: 선택정렬을 더 효율적으로 바꿀 수 있을까요?
1. 이중 선택 정렬: 한 번의 탐색에서 최솟값과 최댓값을 같이 찾는 방법이다. 탐색 횟수가 절반으로 줄어든다.
2. 한 번의 탐색 때 동일한 값이 있다면 함께 정렬하는 방법: 같은 값이 많을수록 유용하다.
For i from 0 to n-2 until no swaps
For j from 0 to n-2-i
If j'th and j+1th elements out of order
Swap them
안쪽 루프에서 교환이 하나도 일어나지 않을 때(즉, 이미 정렬이 잘 된 경우) 루프를 멈추도록 하는 것이다. 따라서 최선의 경우 처음부터 정렬이 되어있다면 n번만 작업하면 정렬이 완료됨 => Ω(n)
:bulb: 선택정렬의 실행시간의 하한도 더 단축시킬 수 있을까?
불가능하다.
:pushpin: 핵심 개념: 재귀, 스택
:pushpin: 재귀는 재용이가 회사에서 자주 사용하는 알고리즘이다.
재귀 : 함수가 본인 스스로를 호출해서 사용하는 것. 또는 쉽게 설명하자면 눈에 보이는 혹은 가상의 물체의 구조를 그 물체 자체를 이용해서 설명하는 것.
ex. 3단짜리 피라미드는 2단짜리 피라미드에 한 줄을 추가해서 만든 것이다.
주의: 시작점에 대해서는(ex. 0단짜리 피라미드) 내가 직접 이건 특별한 경우이므로 아무것도 하지 말고 재귀적으로 자기 자신을 호출하지 않도록 처리해야 한다.
피라미드 모양은 이렇다
#include <cs50.h>
#include <stdio.h>
void draw(int h);
int main(void)
{
// 피라미드 높이 입력받아 저장
int height = get_int("Height: ");
// 피라미드 그리기
draw(height);
}
void draw(int h)
{
for (int i = 1; i <= h; i++)
{
for (int j = 1; j <= i; j++)
{
printf("#");
}
printf("\n");
}
}
1. 함수를 종료하는 조건
2. 함수 호출 (ex. draw(h-1))
3. 함수 동작문
// 1, 2, 3의 순서는 경우에 따라 바뀔 수 있지만 대략적으로 이렇게 생겨먹음.
#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; //return의 역할
}
// h-1층 피라미드를 그린다.
draw(h-1);
// 폭이 h인 피라미드 한 층을 밑에 그린다.
for (int i = 0; i < h; i++)
{
printf("#");
}
printf("\n");
}
이렇게 재귀를 사용하면 중첩 루프를 사용하지 않고도 하나의 함수로 동일한 작업을 수행할 수 있다.
:pushpin: 함수에서 return의 역할
4.1. 부록에 따로 정리해놨으니 꼭 보기
int sigma(int n)
{
if (n <= 0)
{
return;
}
return (n + sigma(n-1));
}
재귀함수에서 동일한 함수를 계속해서 호출할 대마다 함수를 위한 메모리가 계속해서 할당된다. 이 때 사용되는 메모리를 스택이라고 한다.
스택에는 함수에서 사용되는 변수와 같은 값들이 저장된다. 재귀함수를 이용하면 함수가 종료되지 않은 상태에서 함수가 새로 호출되는데, 이 때마다 새로운 값들이 스택에 저장된다. 따라서 잘못하면 스택 공간이 초과되어 프로그램 충돌이 발생(Stackoverflow)한다.
메모리 사용 문제 때문에 재귀는 유의해서 사용해야하지만, 특정 자료구조(트리 및 연결리스트)를 다룰 때 유용하게 사용된다.
:bulb: 반복문을 쓸 수 있는데도 재귀를 사용하는 이유는 무엇일까요?
재귀함수는 반복문보다 코드를 더 간결하게 쓸 수 있어 다른사람이 읽었을 때 이해하기 쉽고, 유지보수도 쉽기 때문이다.
:pushpin: 핵심개념: 병합 정렬, 분할 정복
병합정렬 은 원소가 한 개가 될 때까지 계속해서 반으로 나누다가 다시 합쳐나가며 정렬을 하는 방식. 이 과정은 재귀적으로 구현된다.
// Base case: hard code
If only one item
Return
// 정렬
Else
Sort left half of items
Sort right half of items
Merge sorted halves
일반화하면 병합정렬의 실행시간의 상한은 O(n log n)이다.
병합정렬의 실행시간의 하한 역시 Ω(n log n)이다. 병합정렬은 숫자들이 이미 정렬되었는지 여부에 관계 없이 나누고 병합하기 때문이다. (무식함)
어떤 알고리즘의 실행시간의 상한 = 하한일 때 Θ 사용하면 됨
cf. 버블정렬: 정렬이 다 된 상태라면 버블정렬을 중단한다는 조건문을 추가한다면 실행시간의 하한은 Ω(n^2)에서 Ω(n)으로 바뀐다.
분할 정복은 여러 알고리즘의 기본이 되는 해결방법으로, 크고 방대한 문제를 조금씩 나눠가면서 용이하게 풀 수 있는 문제 단위로 나눈 다음, 그것들을 다시 합쳐서 해결하는 개념이다. 대표적으로는 퀵소트나 병합정렬이 있다.
function F(x)
if F(x)가 간단
return
else
x를 x1, x2로 분할
F(x1), F(x2) 호출
return [F(x1)와 F(x2) 병합한 값]
"F(x)가 간단"이라는 조건을 만족할 때까지 문제를 쪼개서 값을 구하고 병합해 전체 문제를 해결하는 개념이다.
병렬적으로 문제를 해결하는데 강점이 있음
재귀적 함수 호출로 인한 오버헤드가 발생하며, 스택에 다양한 데이터를 보관하고 있어야 하므로 스택 오버플로우가 발생하거나, 과도한 메모리 사용을 하게 됨
"F(x)가 간단"이라는 조건이 무엇이냐에 따라 알고리즘의 퍼포먼스가 생각보다 꽤 차이나게 됨 (Base case를 정의하는 것의 난해함)
:bulb: 병합 정렬을 선택 정렬이나 버블 정렬과 비교했을 때 장단점은?
참고
선택정렬 | 버블정렬 | 병합정렬 | |
---|---|---|---|
공간복잡도 | O(1) | O(1) | O(n) |
최선 시간복잡도 | Θ(n^2) | Θ(n^2) | Θ(nlogn) |