knockon (16)
2024-08-13 20:47:54
  • 탐색 알고리즘이란
  • 순차 탐색
  • 이진 탐색

탐색 알고리즘

탐색 알고리즘이란 어떤 데이터셋에서 특정한 요소를 찾아내는 것이다. 여기서 말하는 데이터셋은 배열이나 리스트나, 트리나 무엇이든 될 수 있으나, 특정 알고리즘은 링크드 리스트나 이진 트리에만 사용할 수 있는 등 제약이 있을 수 있다.


순차 탐색(Linear Search)

처음부터 끝까지 모든 요소를 비교하여 원하는 데이터를 찾는 탐색 알고리즘이다. 어느 한 방향으로만 탐색할 수 있는 알고리즘이므로 선형 탐색(linear search)라고 하기도 한다.

절차는 매우 단순한데, 정리하자면
처음부터 시작해서->찾을 값과 해당 위치 값 비교->같으면 찾은 것, 아니면 이동해서 다시 찾기

예시를 들어 살펴보자.
30을 {10, 50, 30, 70, 80, 60, 20, 90, 40} 배열에서 찾을 것이다.

  • 30은 10과 같지 않다. 넘어가서,

  • 마찬가지로 50도 30과 같지 않다.

  • arr[2]의 값은 30과 같다. 찾았다.

순차 탐색의 구현

간단한 알고리즘인만큼 구현도 매우 간단한 편이다.

#include <stdio.h>

int linearSearch(int arr[], int N, int x) {
  for (int i = 0; i < N; i++)
    if (arr[i] == x)
      return i;
  return -1;
}

int main(void) {
  int arr[] = {2, 3, 4, 10, 40};
  int x = 10;
  int N = sizeof(arr) / sizeof(arr[0]);

  int result = linearSearch(arr, N, x);
  (result == -1) ? printf("찾지못함\n") : printf("arr[%d]\n", result);
  return 0;
}

순차 탐색의 분석

시간 복잡도

  • Best Case: 첫번째에서 바로 찾으면 $O(1)$
  • Worst Case: $O(N)$
  • Average: $O(N)$

장점

  • 말그대로 그냥 하나하나 처음부터 비교하는 것이므로 데이터셋이 어떤 상태이든지 상관없다. 정렬이 되어있든, 되어있지 않든

단점

  • 하나하나 처음부터 찾을 때까지 비교해가는 것이므로 비효율적이고, 데이터셋이 크면 쓰기 어렵다.

이진 탐색(Binary Search)

이진 탐색은 정렬된 배열에서 쓸 수 있는 탐색 알고리즘이다.

과정은 다음과 같다.

  • 데이터셋을 가운데 인덱스를 기준으로 반으로 나눈다.
  • 가운데 인덱스의 요소와 찾을 값(key)를 비교한다.
  • 정확히 가운데에서 찾았다면 탐색을 종료하고, 그렇지 않다면
  • 키가 앞쪽 반에 있다면 뒤쪽 반을 버리고 뒤쪽에 있다면 앞쪽 반을 버린다.
  • 키의 후보 구역에서 위 과정을 반복해서 찾는다.

예를 들어,

위 그림과 같은 배열에서 23을 찾는다고 가정해보자.
위 배열의 가운데 인덱스는 arr[4], 16이다.

23은 16보다 크다. 따라서 뒤쪽(오른쪽) 반을 탐색하자.

다시, 뒤쪽 반의 가운데 요소는 56이다. 23은 이보다 작다. 그러면 이제 앞쪽 반을 탐색하자.

구역을 좁혀나가는 방식으로 23을 찾는 데에 성공했다.

이진 탐색의 구현

#include <stdio.h>

int binarySearch(int arr[], int low, int high, int x) {
  while (low <= high) {
    int mid = low + (high - low) / 2;
    //가운데 인덱스가 키일때
    if (arr[mid] == x)
      return mid;
    //일치하지 않는 경우 값을 비교하여 구역을 좁힘
    else if (arr[mid] < x)
      low = mid + 1;
    else
      high = mid - 1;
  }
  //배열 내에 없음.
  return -1;
}

int main(void) {
  int arr[] = {2, 3, 4, 5, 6, 7, 8, 9};
  int n = sizeof(arr) / sizeof(arr[0]);
  int x = 10;
  int result = binarySearch(arr, 0, n - 1, x);
  if (result == -1)
    printf("없음\n");
  else
    printf("arr[%d]\n", result);
}

이진 탐색 분석

시간복잡도:

  • Best case: $O(1)$
  • Average case: $O(logN)$
  • Worst Case: $O(log N)$

장점

  • 순차 탐색보다 빠르다.
  • 따라서 더 큰 데이터셋에 사용하기 적합하다.

단점

  • 배열이 정렬되어있어야한다.
  • 찾으려는 값이 비교할 수 있는 값이어야한다. 순차 탐색에서는 일치 여부만 찾으면 되므로 크고 작음을 비교할 수 없어도 된다.
2024-08-13 19:10:54
  • 버블 정렬
  • 선택 정렬
  • 삽입 정렬

정렬 알고리즘

정렬(sorting)이란 기준에 따라 데이터를 순서대로, 체계적으로 정리하는 것이다. 정렬에도 여러 가지 방법들이 있는데 대표적으로 버블 정렬, 선택 정렬, 삽입 정렬이 있다.


버블 정렬(Bubble sort)

버블 정렬은 가장 간단한 정령 알고리즘 중 하나이다.
간단하게, 옆에 위치한 두 개끼리 비교하여 정렬하는 것을 반복하는 것이다.

예시를 들어

arr[] = {6,0, 3, 5}
이면

    1. 가장 큰 element가 끝으로 위치하게 된다.

    1. 두 번째 큰 element도 제 위치를 찾아가게 된다.

    1. 세 번째 큰 element도 제 자리를 찾아가므로, element개수가 총 4개인 이 배열은 정렬이 완료되었다.

버블 정렬의 구현

#include <stdbool.h>
#include <stdio.h>

void swap(int *xp, int *yp) {
  int temp = *xp;
  *xp = *yp;
  *yp = temp;
}

// 버블 정렬
void bubbleSort(int arr[], int n) {
  bool swapped;
  for (int i = 0; i < n - 1; i++) {
    swapped = false;
    for (int j = 0; j < n - i - 1; j++) {
      if (arr[j] > arr[j + 1]) {
        swap(&arr[j], &arr[j + 1]);
        swapped = true;
      }
    }
    // 이 경우 처음부터 정렬되어있었음
    if (swapped == false)
      break;
  }
}

// 배열 출력
void printArray(int arr[], int size) {
  for (int i = 0; i < size; i++)
    printf("%d ", arr[i]);
}

int main(void) {
  int arr[] = {64, 34, 25, 12, 22, 11, 99};
  int n = sizeof(arr) / sizeof(arr[0]);
  bubbleSort(arr, n);
  printArray(arr, n);
  return 0;
}

위와 같이 구현할 수 있다.
실행 시 작은 값부터 출력된다.

버블 정렬 분석

이중반복문을 돌기 때문에 시간복잡도는 $$ O(N^2) $$

장점

  • 가장 간단한 정렬 알고리즘 중 하나이다.
  • 추가적인 메모리 공간이 필요없다.
  • stable하다.

단점

  • $O(N^2)$의 시간복잡도
  • comparison operator가 요구됨.

stable

크기가 같은 값이 있을 때, 예를 들어 12가 두 개면 하나를 a1, 남은 하나를 a2라 하자.
정렬은 크기를 기준으로 하기 때문에 값이 같은 a1과 a2는 경우에 따라 서로 위치가 a2, a1처럼 바뀔 수도 있는데, 바뀌지 않는 경우를 stable하다고 한다.


선택 정렬(Selection Sort)

선택 정렬도 마찬가지로 간단하고 효과적인 정렬 알고리즘 중 하나이다. 선택 정렬은 계속해서 가장 작은 값 또는 가장 큰 값을 뽑아내서 끝의 위치로 옮겨준다.

마찬가지로 예시를 보면
arr = {64, 25, 12, 22, 11}

    1. 최솟값을 탐색한다. 가장 작은 값은 11이다.

    1. 11을 64의 자리로 옮긴다. 11은 정렬되었으니 건드리지 말고 다음 작은 값을 찾는데 이건 12이다.

    1. 과정은 계속 같다...이번에는 22가 가장 작다. 22를 25와 교환해준다.

    1. 25가 최솟값이고, 이미 4번째 자리에 있어서 교환해줄 필요는 없다.

    1. 이미 위에서 3번 시점에서 이 배열은 정렬된 상태이지만, 이러한 과정을 통해 최종적으로 가장 큰 값이 자동적으로 맨 뒤에 오게되면서 정렬이 완료된다.

선택 정렬의 구현

#include <stdio.h>

void swap(int *xp, int *yp) {
  int temp = *xp;
  *xp = *yp;
  *yp = temp;
}

void selectionSort(int arr[], int n) {
  int min_idx;
  for (int i = 0; i < n - 1; i++) {
    min_idx = i;
    for (int j = i + 1; j < n; j++)
      if (arr[j] < arr[min_idx])
        min_idx = j;
    if (min_idx != i)
      swap(&arr[min_idx], &arr[i]);
  }
}

void printArray(int arr[], int size) {
  for (int i = 0; i < size; i++)
    printf("%d ", arr[i]);
  printf("\n");
}

int main(void) {
  int arr[] = {190, 90, 120, 1, 3, 2};
  int n = sizeof(arr) / sizeof(arr[0]);
  selectionSort(arr, n);
  printArray(arr, n);
  return 0;
}

선택 정렬 분석

시간복잡도는 이중반복문을 돌게 되므로
$$O(N^2)$$

장점

  • 간단하다. 버블정렬과 마찬가지로..
  • 작은 데이터셋에서 잘 작동한다.

단점

  • 시간복잡도.
  • 큰 데이터셋에서는 좋지 못하다.
  • stable하지 않다.

삽입 정렬(Insertion Sort)

삽입 정령은 자료구조를 순차적으로 순회하면서 순서에 어긋나는 element를 찾고, 그 element를 올바른 위치에 다시 삽입해나가는 정렬 알고리즘이다.

  1. 첫 번째 요소가 정렬이 되어있다고 가정하고 두 번째 요소부터 살펴본다.
  2. 첫 번째 요소와 두 번째 요소를 비교한다. 비교 후 swap으로 정렬
  3. 세 번째 요소와 두 번째 요소를 비교하고, 다음으로 첫 번째 요소와 비교한다. 그리고 세 번째 요소를 맞는 위치에 끼워넣는다.
  4. 이런 식으로 완전히 정렬되기 전까지 반복한다.

사진의 예시를 살펴보고 위 절차대로 수행해보자.

23이 정렬되어있다고 가정하고 1과 23을 비교
-> 1을 23 앞에 끼워넣기
-> 10과 23 비교, 10과 1 비교. 1과 23 사이에 끼워넣음
-> 5도 마찬가지로 비교해서 끼워넣음.
-> 2도 순차적으로 비교해서 맞는 위치에 끼워넣음.
-> 정렬 완료

삽입 정렬의 구현

#include <stdio.h>

void insertionSort(int arr[], int n) {
  for (int i = 0; i < n; i++) {
    int key = arr[i];
    int j = i - 1;

    while (j >= 0 && arr[j] > key) {
      arr[j + 1] = arr[j];
      j--;
    }
    arr[j + 1] = key;
  }
}

void printArray(int arr[], int n) {
  for (int i = 0; i < n; i++)
    printf("%d ", arr[i]);
  printf("\n");
}

int main(void) {
  int arr[] = {12, 11, 13, 5, 6};
  int n = sizeof(arr) / sizeof(arr[0]);
  insertionSort(arr, n);
  printArray(arr, n);
  return 0;
}

삽입 정렬 분석

  • Best Case: $O(N)$
  • Average Case: $O(N^2)$
  • Worst Case: $O(N^2)$

이미 전부 정렬되어 있는 경우 O(N)이지만 랜덤하게 되어있거나, 역순으로 되어있는 최악의 경우일 때는 $O(N^2)$

장점

  • 간단하다.
  • stable
  • 작은 크기의 리스트에 적합하다.

단점

  • 큰 크기에서는 적합하지 않음
  • 위 두 알고리즘에 비교한 단점은 아니나 일반적으로 머지 소트나 퀵 소트가 더 효율적이다.
2024-08-06 21:33:43

1. 헤더파일이란?

  • 헤더파일: c프로그램에서 .h가 붙는 파일들로, 여러 소스코드 파일에 공통적으로 필요한 것을 저장하는 파일이다.

  • #include를 통해 .c파일에서 헤더파일의 내용을 가져다 쓸 수 있다.

  • 헤더파일의 필요성: 유지보수와 재사용에 용이하다. 겹치는 내용은 한 번 적어서 include해서 공통적으로 쓸 수 있고, 유지보수할 때도 보기 편하다.

  • 컴파일 시 헤더파일을 따로 처리할 필요는 없으며 .c파일을 처리하면서 처리된다.

2. 코드 살펴보기

hacker.h

//hacker.h  
#pragma once  
#include <string.h>  
#include <stdio.h>  
#include <unistd.h>  
#include <stdlib.h>  

//hacker 구조체 정의  
typedef struct hacker{  
    char name[50];  
    unsigned int age;  
    char** skill_list;  
    int skill_num;  
    int level;  
}hacker;  

int input_name(hacker*);  
int input_age(hacker*);  
int input_new_skill(hacker*);  

//new_hacker함수  
hacker* new_hacker() {  
    hacker* new_ptr = (hacker*)malloc(sizeof(hacker));  

    memset(new_ptr->name, '\0', sizeof(new_ptr->name));  
    new_ptr->skill_num = 0;  
    new_ptr->level = 1;  

    new_ptr->skill_list = (char**)malloc(sizeof(char*));  

    input_name(new_ptr);  
    input_age(new_ptr);  
    input_new_skill(new_ptr);  

    return new_ptr;  
}  

//input_name함수  
int input_name(hacker* ptr){  
    write(1,"Input the name : ",17);  
    return scanf("%s",ptr->name);  
}  

int input_age(hacker* ptr){  
    printf("Input the age : ");  
    return scanf("%u",&ptr->age);  
}  

void increase_size(char***);  
int input_new_skill(hacker* ptr){  
    write(1, "Input new Skill : ", 18);  

    char buf[50] = {0,};  
    scanf("%s",buf);  

    char* tmp = (char*)malloc(strlen(buf)+1);  
    strcpy(tmp,buf);  

    ptr->skill_num++;  
    increase_size(&(ptr->skill_list));  


    ptr->skill_list[ptr->skill_num-1] = tmp;  
    return ptr->skill_num;  
}  

void increase_size(char*** skill_list) {  
    if (*skill_list == NULL) {  
        *skill_list = (char**)malloc(sizeof(char*));  
        (*skill_list)[0] = NULL;  
        return;  
    }  
    int org_size = sizeof(*skill_list) / sizeof(char*);  

    char** new_ptr = (char**)malloc(sizeof(char*) * (org_size + 1));  

    for (int i = 0; i < org_size; i++) {  
        new_ptr[i] = (*skill_list)[i];  
    }  
    free(*skill_list);  
    *skill_list = new_ptr;  
}

헤더파일 hacker.h의 전체 코드는 위와 같다.

저 부분들을 하나하나 떼어서 보자면

header files

#pragma once
#include <string.h>
#include <stdio.h> 
#include <unistd.h> 
#include <stdlib.h>
  • #pragma once: 컴파일러에게 해당 헤더파일이 한번만 빌드되도록 하는 역할. 여러 곳에서 include되면 정의가 추가되어 중첩될 수 있는데 이를 방지한다.

hacker 구조체

typedef struct hacker{
    char name[50];
    unsigned int age;
    char** skill_list;
    int skill_num;
    int level;
}hacker;

new_hacker 함수

//new_hacker함수  
hacker* new_hacker() {  
    hacker* new_ptr = (hacker*)malloc(sizeof(hacker));  
    // 해커 구조체 크기만큼 메모리 동적 할당
    memset(new_ptr->name, '\0', sizeof(new_ptr->name));  
    //new_ptr->name배열의 모든 바이트를 널로 초기화 
    new_ptr->skill_num = 0;  
    new_ptr->level = 1;  

    new_ptr->skill_list = (char**)malloc(sizeof(char*)); 
    // 스킬 목록을 저장할 포인터 배열을 동적 할당, 초기에는 스킬이 없으므로 char* 

    input_name(new_ptr);  
    input_age(new_ptr);  
    input_new_skill(new_ptr);  
  // input함수들을 통해 사용자의 정보 입력
    return new_ptr;  
    // 초기화된 해커 구조체 포인터를 반환
} 

입력 함수들

hacekr 구조체 포인터를 받아 스킬 입력

  • input_name

  • input_age

  • input_new_skill

int input_new_skill(hacker* ptr){
    write(1, "Input new Skill : ", 18);
    // write함수를 사용해 위 메시지를 표준출력
    char buf[50] = {0,};
    // 임시 저장 버퍼를 50바이트 크기로 선언, 모든 바이트를 0으로 초기화
    scanf("%s",buf);
    // 입력을 버퍼에 저장

    char* tmp = (char*)malloc(strlen(buf)+1);
    // 입력받은 기술명의 길이만큼 메모리 동적 할당
    strcpy(tmp,buf);
    // buf에 저장된 기술명을 동적 할당된 메모리 공간 tmp에 복사

    ptr->skill_num++;
    // 기술 개수 증가
    increase_size(&(ptr->skill_list));
    // 함수 호출, 하단에 있음
    ptr->skill_list[ptr->skill_num-1] = tmp;
    // 새로운 기술명을 기술 목록 배열의 마지막 위치에 추가
    return ptr->skill_num;
    // 기술 개수 반환
}

increase_size 함수

void increase_size(char*** skill_list) {
    if (*skill_list == NULL) {
        *skill_list = (char**)malloc(sizeof(char*));
        (*skill_list)[0] = NULL;
        return;
    }

    int org_size = sizeof(*skill_list) / sizeof(char*);

    char** new_ptr = (char**)malloc(sizeof(char*) * (org_size + 1));

    for (int i = 0; i < org_size; i++) {
        new_ptr[i] = (*skill_list)[i];
    }

    free(*skill_list);
    *skill_list = new_ptr;
}

위 헤더파일의 내용을 받아서 쓰면

//다음과 같은 코드를 이해하고, 작성해봅시다.
//main.c
#include "hacker.h"

int main(){
    hacker* qwertyou = new_hacker();
    printf("%s's age is %d\n",qwertyou->name, qwertyou->age);
    printf("%s's level is %d\n",qwertyou->name, qwertyou->level);


    printf("\n\n-----qwertyou's skill list-----\n");
    for(int i=0;i<qwertyou->skill_num;i++){
        printf("%s's skill%d : %s\n",qwertyou->name, i, qwertyou->skill_list[i]);
    }
}

메인함수에 위 함수들과 다른 헤더파일들도 따로 더 적을 필요가 없다.

2024-08-06 21:22:16
  • 트리의 개념
  • 트리의 순회 방법
  • 이진 트리, 완전 이진 트리
  • 이진 탐색 트리

1. 트리의 개념

  • 나무를 닮은 자료구조

1-1. 트리의 구성 요소

 

트리의 구성 요소: 뿌리, 가지, 잎 모두 똑같은 노드이나 위치에 따라 명칭이 다르다.

구성 요소

  • 뿌리(Root): 가장 위에 있는 노드
  • 가지(Branch): 뿌리와 잎 사이
  • 잎(Leaf): 끝에 있음(단말:terminal)이라고 하기도 함.

  • 부모(parents): A는 B,C,D의 부모
  • 자식(Children): B,C,D는 A의 자식
  • 형제(Sibling): B,C,D는 형제

경로

  • 경로: 한 노드에서 다른 한 노드까지 이르는 길 사이에 있는 노드들의 순서
    example) A에서 K까지의 경로: A,B,E,K
  • 경로의 길이(length): 출발 노드에서 목적 노드까지 거쳐야 하는 노드의 개수
    ex) A-K: 3
  • 깊이(Depth): 뿌리 노드에서 해당 노드까지 이르는 경로의 길이
    ex) K: 3
  • 레벨(level): 깊이가 같은 노드의 집합
    ex) level 3: K, L, M
  • 높이(height): 가장 깊은 곳에 있는 잎 노드까지의 깊이
    ex) 3
  • 차수(Degree): 노드의 차수->그 노드의 자식 노드 개수, 트리의 차수->트리 내에 있는 노드들 가운데 자식 노드가 가장 많은 노드의 차수

2. 트리의 기본 연산

2-2. 노드 선언

Left Child, Right Sibling 형태 표현법(LCRS)에서
노드 구조체는

typedef char ElementType;
typedef struct tagLCRSNode {
  struct tagLCRSNode *LeftChild;
  struct tagLCRSNode *RightSibling;
  ElementType Data;
} LCRSNode;

와 같이 데이터 필드와 Left Child, Right Sibling 두 개 포인터로 구성된다.

2-3. 노드 생성/소멸

//노드 생성
LCRSNode *LCRS_CreateNode(ElementType NewData) {
  LCRSNode *NewNode = (LCRSNode *)malloc(sizeof(LCRSNode));
  NewNode->LeftChild = NULL;
  NewNode->RightSibling = NULL;
  return NewNode;
}
//노드 소멸
void LCRS_DestroyNode(LCRSNode *Node) { free(Node); }
  • 노드 생성: 자유 저장소에 LCRSNode 구조체의 크기만큼 메모리를 할당하고 매개변수 NewData를 Data에 저장한 후 노드 메모리 주소 반환
  • 노드 소멸: 자유 저장소에서 할당했던 메모리만 해제하면 됨

2-4. 자식 노드 연결

void LCRS_AddChildNode(LCRSNode *Parent, LCRSNode *Child) {
//자식 노드가 있는지 검사
  if (Parent->LeftChild == NULL)
  //자식 노드가 없으면 왼쪽노드에 바로 저장
    Parent->LeftChild = Child;
  else {
  //자식 노드가 있는 경우
    LCRSNode *TempNode = Parent->LeftChild;
    while (TempNode->RightSibling != NULL)
    //RightSibling을 이용해 가장 오른쪽의 자식 노드를 찾고 Child를 가장 오른쪽 자식 노드의 RightSibling노드에 대입
      TempNode = TempNode->RightSibling;
    TempNode->RightSibling = Child;
  }
}

위 함수 과정을 통해 부모 노드는 자식을 하나 추가한다.

2-5. 트리 출력

void LCRS_PrintTree(LCRSNode *Node, int Depth) {
//들여쓰기
  for (int i = 0; i < Depth - 1; i++) {
    printf("   ");
    if (Depth > 0) //자식 노드 여부
      printf("+--");
      //노드 데이터 출력
    printf("%c\n", Node->Data);
    if (Node->LeftChild != NULL)
      LCRS_PrintTree(Node->LeftChild, Depth + 1);
    if (Node->RightSibling != NULL)
      LCRS_PrintTree(Node->RightSibling, Depth);
  }
}

구성된 트리를 출력하는 코드


3. 이진 트리

앞쪽 코드에서 구현한 트리는 노드의 자식 노드 개수에 제한이 없었으나 이진트리는 하나의 노드가 자식 노드를 2개까지만 가질 수 있다.

3-1. 이진 트리의 종류

  • 포화 이진 트리
  • 완전 이진 트리
  • 이진 탐색 트리

포화 이진 트리

 

위 사진처럼 잎 노드를 제외한 모든 노드가 자식을 2개씩 갖고 있으면 포화 이진 트리라고 한다.

완전 이진 트리

모든 노드가 자식 2개를 갖고 있진 않아도 잎 노드들이 왼쪽부터 차곡차곡 채워져있으면 완전 이진 트리라고 한다.
B처럼 중간에 노드가 빈 트리는 완전 이진 트리가 아니다.

높이 균형 트리


뿌리 노드를 기준으로 왼쪽 하위 트리와 오른쪽 하위 트리의 높이가 2이상 차이 나지 않는 이진 트리

완전 높이 균형 트리


왼쪽 하위 트리와 오른쪽 하위 트리의 높이가 완전히 같은 이진 트리

  • 트리의 성능과 모양: 가능한 완전한 모습으로 유지해야 높은 성능을 낼 수 있다. 이진 트리는 컴파일러나 검색과 같은 알고리즘의 뼈대가 되는 특별한 자료구조이므로, 이러한 모양이 중요하다.

3-4. 이진 탐색 트리

  • 한마디로 탐색을 위한 이진 트리(이진 탐색)

이진 탐색은 배열에만 사용 가능하기 때문에 링크드 리스트에서 탐색을 위해서 이진 탐색 트리를 쓸 수 있다.

이진 탐색 트리의 특징

  • 부모 노드 입장에서 왼쪽 자식 노드는 나보다 작고 오른쪽 자식 노드는 나보다 크다.

위와 같은 특징을 통해 탐색이 작동한다.
중앙 요소를 찾아 좌우의 대소르 비교하여 탐색 범위를 정하고 다시 중앙 요소를 찾아 좌우의 요소를 비교하는 것을 반복하여 탐색한다.

예시) 위 트리에서 5를 찾는다고 해보자.
5는 8보다 작으니 왼쪽 노드로, 다시 5는 3보다 크니 오른쪽 노드로 이동하는 것이다.

2024-08-06 05:04:40

1. 스택

  • 입출력 창구가 한개임
  • 가장 마지막에 들어간 데이터가 가장 먼저 나오고 가장 먼저 들어가면 가장 늦게 나오는 자료구조
  • 스택의 사용 용도: 자동 메모리, 프로토콜 등이 대부분 스택을 기반으로 구성되어 있음

1-1. 스택의 연산

  • 삽입(push): 새로운노드를 쌓음
  • 제거(pop): 최상위 노드를 걷어냄

1-2. 배열로 구현한 스택

  • ArrayStack.h
#ifndef ARRAYSTACK_H
#define ARRAYSTACK_H

#include <stdio.h>
#include <stdlib.h>

typedef int ElementType;

typedef struct tagNode {
  ElementType Data;
} Node;

typedef struct tagArrayStack {
  int Capacity;
  int Top;
  Node *Nodes;
} ArrayStack;

//함수 선언
void AS_CreateStack(ArrayStack **Stack, int Capacity);
void AS_DestroyStack(ArrayStack *Stack);
void AS_Push(ArrayStack *Stack, ElementType Data);
ElementType AS_Pop(ArrayStack *Stack);
ElementType AS_Top(ArrayStack *Stack);
int AS_GetSize(ArrayStack *Stack);
int AS_IsEmpty(ArrayStack *Stack);
  • ArrayStack.c
#include "ArrayStack.h"

//스택 생성
void AS_CreateStack(ArrayStack **Stack, int Capacity) {
  //스택을 자유 저장소에 생성
  (*Stack) = (ArrayStack*)malloc(sizeof(ArrayStack));
  //입력된 capacity만큼의 노드를 자유 저장소에 생성
  (*Stack)->Nodes = (Node*)mallocc(sizeof(Node)*Capacity);

  //초기화
  (*Stack)->Capacity = Capacity;
  (*Stack)->Top = -1;
}

//제거
void AS_DestroyStack(ArrayStack *Stack) {
  free(Stack->Nodes);
  free(Stack);
}

//push
void AS_Push(ArrayStack *Stack, ElementType Data) {
//최상위 노드의 인덱스에서 1을 더한 곳에
  Stack->Top++;
  //새 노드를 입력하도록 구성
  Stack->Nodes[Stack->Top].Data = Data;
}

//pop
ElementType AS_Pop(ArrayStack *Stack) {
    //최상위 노드에서 1만큼 낮춤
  int Position = Stack->Top--;
  //pop연산은 최상위 값을 반환하므로 리턴값 존재
  return Stack->Nodes[Position].Data;
}

ElementType AS_Top(ArrayStack *Stack) {
//pop연산처럼 빼내는 것이 아니라 최상위 값만 반환
  return Stack->Nodes[Stack->Top].Data;
}

int AS_GetSize(ArrayStack *Stack) {
  return Stack->Top+1;
}

2. 큐

  • 입력, 출력 창구가 따로 존재
  • 먼저 들어간 게 먼저 나옴
  • 구성: 가장 앞 요소(전단), 가장 마지막 요소(후단)
  • 스택의 연산은 모두 Top에서만 이루어지지만 큐의 삽입 연산은 후단, 제거 연산은 전단에서 수행됨.

2-1. 큐의 연산

  • 스택과 유사하게 삽입과 제거 연산
  • Enqueue(삽입), Dequeue(제거)로 표현

2-2. 순환 큐

삽입과 제거 연산이 후단과 전단 각각 다른 위치에서 일어남 -> 전단을 제거하고 나머지 앞 요소들을 한칸식 옮겨와야 하는 비효율 발생

전단의 위치를 하나씩 밀어도 되지만, 그러면 가용 용량이 줄어듦 -> 끝과 시작을 이어버리면 해결

  • 순환 큐: 시작과 끝을 연결해서 효율적인 삽입/삭제 연산이 가능하도록 고안된 큐
  • 포화 상태: 빈칸 없이 큐가 꽉찬 상태
  • 공백 상태: 빈칸이 존재함

큐가 포화 상태가 아니라 공백 상태일때도 전단과 후단이 같은 위치에 있어서 구분할 필요가 있음.
-> 큐 배열 생성 시 1만큼 더 크게 만들어서 전단과 후단 사이를 비우면 됨

2-3. 구현

  • CircularQueue.h
#ifndef CIRCULAR_QUEUE_H
#define CIRCULAR_QUEUE_H

#include <stdio.h>
#include <stdlib.h>

typedef int ElementType;

typedef struct tagNode {
  ElementType Data;
} Node;

typedef struct tagCircularQueue {
  int Capacity;
  int Front;
  int Real;
  Node *Nodes;
} CircularQueue;

void CQ_CreateQueue(CircularQueue **Queue, int Capacity);
void CQ_DestroyQueue(CircularQueue *Queue);
void CQ_Enqueue(CircularQueue *Queue, ElementType Data);
ElementType CQ_Dequeue(CircularQueue *Queue);
int CQ_GetSize(CircularQueue *Queue);
int CQ_IsEmpty(CircularQueue *Queue);
int CQ_IsFull(CircularQueue *Queue);
  • CircularQueue.c


void CQ_CreateQueue(Circular **Queue, int Capacity) {
  //큐를 자유 저장소에 생성
  (*Queue) = (CircularQueue*)malloc(sizeof(CircularQueue));

  //입력된 capacity+1 만큼의 노드를 자유 저장소에 생성
  (*Queue)->Nodes = (Node*)malloc(sizeof(Node)*(Capacity+1));

  (*Queue)->Capacity = Capacity;
  (*Queue)->Front = 0;
  (*Queue)->Rear = 0;
}

void CQ_DestroyQueue(CircularQueue *Queue) {
  free(Queue->Nodes);
  free(Queue);
}

void CQ_Enqueue(CircularQueue *Queue, ElementType Data) {
  int Position = 0;
  if (Queue->Rear == Queue->Capacity) {
    Position = Queue->Rear;
    Queue->Rear = 0;
  }
  else Position = Queue->Rear++;
  Queue->Nodes[Position].Data = Dta;
}

ElementType CQ_Dequeue(CircularQueue *Queue) {
  int Position = Queue->Front;
  if (Queue->Front == Queue->Capacity) Queue->Front = 0;
  else Queue->Front++;
  return Queue->Nodes[Position].Data;
}

int CQ_GetSize(CircularQueue *Queue) {
  return (Queue->Front == Queue->Rear);
}

int CQ_IsFull(CircularQueue *Queue) {
  if (Queue->Front < Queue->Rear) 
    return (Queue->Rear - Queue->Front) == Queue->Capacity;
  else
   return (Queue->rear + 1) == Queue->Front;
}
2024-08-06 01:38:02
  • 연결 리스트와 배열의 차이점
  • 단일 연결 리스트
  • 이중 연결 리스트
  • 환형 연결 리스트

1. 리스트와 배열 비교

배열: 생성하는 시점에 배열의 크기를 지정해줘야하고 생성한 후 크기를 변경할 수 없다.

-> 유연하게 크기를 바꿀 수 있는 자료구조: 리스트

2. 연결 리스트(linked list)

  • 연결 리스트(링크드 리스트): 노드를 연결해서 만든 리스트라고 해서 붙여진 이름

  • 다뤄야 하는 데이터 집합의 크기를 미리 알지 못해도 데이터가 늘어날 때마다 노드를 만들어 테일에 붙이면 됨!

노드

노드의 구성: 필드(데이터 보관), 포인터(다음 노드와 연결)

//노드 표현
typedef int ElementType;

typedef struct tagNode {
  ElementType Data; //데이터
  struct Node* NextNode; // 노드
} Node;

따라서 위와 같이 노드를 표현할 수 있다.

연산

연결 리스트의 주요 연산은 다음과 같다.

  • 노드 생성(CreateNode), 소멸(DestroyNode)
  • 노드 추가(AppendNode)
  • 노드 탐색(GetNodeAt)
  • 노드 삭제(RemoveNode)
  • 노드 삽입(InsertAfter, InsertnewHead)

노드 생성/소멸

  • 자동 메모리 사용?

Node *SLL_CreateNode(ElementType NewData) {
  Node NewNode; //자동 메모리 사용
  NewNode.Data = NewData;
  NewNode.NextNode = NULL:
  return &NewNode;
}

Node *MyNode = SLL_CreateNode(117);
// 할당되지 않은 메모리를 가리킴

위와 같이 코드를 짜게 되면 문제가 생기는데, NewNode가 존재하는 메모리의 주소를 반환하는 것이 아니라 현재는 제거된 과거의 메모리 주소를 반환한다.

따라서 malloc을 이용하여 정상 작동하는 생성 함수를 만들 수 있다.

Node *SLL_CreateNode(ElementType NewData) {
  Node *NewNode = (Node*)malloc(sizeof(Node));
  NewNode->Data = NewData;
  NewNode->NextNode = NULL;
  return NewNode;
}

void SLL_DestroyNode(Node *Node) {
  free(Node);
} //소멸 함수

free함수는 메모리 주소만 정확히 알면 알아서 처리하므로 소멸 함수는 간단하게 구현 가능하다.

노드 추가

  • 연결 리스트 테일 뒤에 새로운 노드를 만들어 연결하는 것.
void SLL_AppendNode(Node **Head,Node *NewNode) {
  if ((*Head) == NULL) *Head = NewNode;
    // 헤드가 없음
  else {
    Node *Tail = (*Head);
    while (Tail->NextNode != NULL) Tail = Tail->NextNode;
    Tail->NextNode = NewNode;
    // 테일(끝)을 찾아 NewNode를 연결.
  }
}
  • 매개변수로 Node **Head를 받는 이유

위 함수에서 매개변수를 *Head를 받았다고 가정하고 아래 코드를 실행해본다고 하자.

Node *List = NULL;
Node *NewNode = NULL;
NewNode = SLL_CreateNode(117);
SLL_AppendNode(List, NewNode);

결과적으로는 NewNode가 List에 추가되지 않는다.
코드의 실행 과정을 살펴보면

  1. List와 NewNode가 아무 메모리도 가리키지 않는 상태로 초기화
  2. NewNode = SLL_Createnode(117); 실행 후 자유 저장소에 117을 가진 노드 생성. NewNode는 이 노드의 주소를 가리킨다.
  3. SLL_AppendNode()를 호출하면서 매개변수들 _Head와 _NewNode가 자동 메모리에 생성되고 List는 _Head에 NewNode는 _NewNode에 메모리의 주소를 복사해서 넣는다.
  4. List포인터가 담고 있는 주솟값만 복사되고 List포인터 변수의 주소는 전달되지않는다.
  5. 함수 실행 전과 마찬가지로 List는 NULL로 남는다.

노드 탐색

배열은 인덱스를 통해 원하는 element에 바로 접근할 수 있으나 연결 리스트는 헤드부터 시작해서 노드들을 순차적으로 하나하나 건너와야 접근이 가능하다.

Node *SLL_GetNode(Node *Head,int Location) {
  Node *Current = Head;
  while (Current != NULL && (--Location) >= 0)
    Current = Current->NextNode;
  return Current;
}

Location이 0이 될때까지 노드를 순차적으로 이동한다.
이렇게 바로 접근이 불가능한 것은 연결 리스트의 단점으로 볼 수 있다.

노드 삭제

void SLL_RemoveNode(Node **Head, Node *Remove) {
  if (*Head == Remove) *Head = Remove->NextNode;
  else {
    Node *Current = *Head;
    while (Current != NULL && Current->NextNode != Remove) 
      Current = Current->NextNode;
    if (Current != NULL) 
      Current->NextNode = Remove->NextNode;
  }
}

삭제하고자 하는 노드를 찾고 해당 노드의 다음 노드를 이전 노드의 NextNode에 연결하면 노드를 삭제할 수 있다.

노드 삽입

노드와 노드 사이에 새로운 노드를 끼워넣는다.
이전 노드의 NextNode포인터가 새 노드를 가리키고 새 노드의 NextNode포인터가 다음 노드를 가리키게 하면 될 것이다.

void SLL_InsertAfter(Node *Current,Node *NewNode) {
  NewNode->NextNode = Current->NextNode;
}

와 같이 쉽게 구현된다.

노드 개수 세기

int SLL_GetNodeCount(Node *Head) {
  int Count = 0;
  Node *Current = Head;
  while (Current != NULL) {
    Current = Current->NextNode;
    Count++;
  }
  return Count;
}

그냥 끝까지 도달할 때까지 카운트 수를 늘려주면 된다.

3. 이중 연결 리스트 (double linked list)

  • 단일 연결 리스트: head에서 tail로만 탐색 가능

  • 이중 연결 리스트: 양방향 탐색 가능

  • 노드 구조: 포인터가 데이터의 앞뒤로 모두 존재함

typedef int ElementType;

typedef struct tagNode {
  ElementType Data;
  struct tagnode *PrevNode;
  struct tagNode *NextNode;
} Node;

단일 연결 리스트와 다르게 PrevNode포인터가 추가되었다.

노드 생성/소멸


Node *DLL_CreateNode(ElementType NewData) {
  Node *NewNode = (Node*)malloc(sizeof(Node));
  NewNode->Data = NewData;
  NewNode->PrevNode = NULL;
  NewNode->NextNode = NULL;
  return NewNode;
}
void DLL_DestroyNode(Node *Node) {
  free(Node);
}

단일 연결 리스트와 크게 다를 것이 없으나 PrevNode관련만 추가되었다. 노드 삭제는 동일하다.

노드 추가


void DLL_AppedNode(Node **Head, Node *NewNode) {
  if ((*Head) == NULL) *Head = NewNode;
  else {
    Node *Tail = (*Head);
    while (Tail->NextNode != NULL) Tail = Tail->NextNode;
    Tail->NextNode = NewNode;
    NewNode->PrevNode = Tail;
  }
}

기본적인 연산은 단일 연결리스트와 유사하나 새로운 노드의 PrevNode가 기존의 tail(끝)을 가리키도록 한다.

노드 삭제

이중 연결 리스트에서 노드 삭제는 다른 연산들보다 약간 복잡하다.

삭제할 노드의 포인터가 앞뒤 노드를 모두 가리키고 있으므로 노드의 양쪽 포인터, 앞 노드의 NextNode포인터 뒤 노드의 PrevNode포인터 모두를 건드려야한다.

void DLL_DeleteNode(Node **Head, Node *Remove) {
  if (*Head == Remove) {
    *Head = Remove->NextNode;
    if ((*Head) != NULL) ((*Head)->PrevNode = NULL);
    Remove->PrevNode = NULL;
    Remove->NextNode = NULL;
  }
  else {
    Node *Temp = Remove;
    if (Remove->PrevNode != NULL)
      Remove->PrevNode->NextNode = Temp->NextNode;

    if (Remove->NextNode != NULL) 
      Remove->NextNode->PrevNode = Temp->PrevNode;

    Remove->PrevNode = NULL;
    Remove->NextNode = NULL;
  }
}

노드 삽입

void DLL_InsertAfter(Node *Current, Node *NewNode) {
  NewNode->NextNode = Current->NextNode;
  NewNode->PrevNode = Current;
  if (Current->NewNode != NULL) {
    Current->NextNode->PrevNode = NewNode;
    Current->NextNode = NewNode;
  }
}

PrevNode로 이전 노드를, NextNode로 다음 노드를 가리키게 구성하면 된다. 삭제보다는 간단하다.

4. 환형 연결 리스트

일반적인 연결리스트와 크게 다르지는 않으나 그저 헤드가 테일과 연결되어있는 형태이다.

void CDLL_RemoveNode(Node **Head, Node *Remove) {
  if (*Head == Remove) {
    (*Head)->PrevNode->NextNode = Remove->NextNode;
    (*Head)->NextNode->PrevNode = Remove->PrevNode;
    *Head = Remove->NextNode;
    Remove->PrevNode = NULL;
    Remove->NextNode = NULL;
  }
  else {
    Remove->PrevNode->NextNode = Remove->NextNode;
    Remove->NextNode->PrevNode = Remove->PrevNode;
    Remove->PrevNode = NULL;
    Remove->Nextnode = NULL;
  }
}

void CDLL_AppendNode(Node **Head, Node *NewNode) {
  if ((*Head) == NULL) {
    *Head = NewNode;
    (*Head)->NextNode = *Head;
    (*Head)->PrevNode = *Head;
  }
  else {
    Node *Tail = (*Head)->PrevNode;
    Tail->NextNode->PrevNode = NewNode;
    Tail->NextNode = NewNode;
    NewNode->NextNode = (*Head);
    NewNode->PrevNode = Tail;
  }
}

연산이 크게 다를 것은 없고 헤드와 테일이 어떤 끝이 아니라 노드를 추가할 때도 헤드와 테일 사이에 삽입하는 느낌으로 접근하면 된다.