자료구조 기초
자료구조란?
- 컴퓨터에서 여러 자료들을 조직적, 체계적으로 저장하는 방법
- 보통 동일한 자료형을 여럿 저장하는 ㅜㄱ조를 의미
- 자료구조에 따라 요소들 사이의 관계를 정의하는 규칙이 있음
- 다음 요인에 따라 상황마다 보다 효율적인 자료구조가 존재
- 데이터에 접근하는 빈도
- 데이터에 접근하는 방법(ex. 삽입, 검색, 읽기, 지우기 등)
자료구조의 효율성
- 효율성은 주로 시간 복잡도를 말함
- 공간 복잡도를 포함하는 경우도 있음
- 따라서 주로 Big-O 표기법을 사용
- 보통 효율성을 논할 때는 하드웨어 최적화를 고려 안 한 이론이 전부
- 적은 용량의 데이터는 그렇지 않을 수 있음
배열
- 메모리 한 덩어리로 표현 가능한 가장 간단한 자료구조
- 여러 자료들을 그 메모리 덩어리 안에 줄줄이 세워놓은 구조
- 각 자료는 색인으로 접근
- 연속된 메모리나 각 요소의 실제 메모리 상의 위치를 쉽게 찾을 수 있음
배열의 삽입 살펴보기 - O(n)
enum{MAX_NUMS = 8};
int s_nums[MAX_NUMS];
size_t s_num_count = 0;
void insert_at(size_t index, int n)
{
size_t i;
assert(index <= s_num_count);
assert(s_num_count <= MAX_NUMS);
for(i = s_num_count; i>index; --i){
s_nums[i] = s_nums[i-1];
}
s_nums[index] = n;
++s_num_count;
}
배열의 삽입
- 배열 제일 뒤에 넣으면 간단히 삽입하고 끝
- 그 외의 경우는 삽입하려는 위치의 요소부터 마지막 요소를 모두 뒤로 한 칸씩 민 뒤에 삽입
- 시간 복잡도는 O(n)
배열의 삭제 예
enum{MAX_NUMS = 8};
int s_nums[MAX_NUMS];
size_t s_num_count = 0;
void remove_at(size_t index)
{
size_t i;
assert(index < s_num_count);
--s_num_count;
for(i = index; i<s_num_count; ++i){
s_nums[i] = s_nums[i+1];
}
}
배열의 삭제
- 삭제하는 색인을 개준으로 그 뒤의 값들을 한 칸씩 앞으로 땡김
- 시간 복잡도 : O(n)
배열의 검색 예
enum{MAX_NUMS = 8};
enum{INVALID_INDEX = -1};
int s_nums[MAX_NUMS];
size_t s_num_count = 0;
size_t find_index(int n)
{
size_t i;
for(i = 0; i<s_num_count; ++i){
if(s_nums[i] == n){
return i;
}
}
return INVALID_IDNEX;
}
배열의 검색
- 배열 속 요소들을 처음부터 차례대로 방문하며 찾고자 하는 값이 있는지 확인
- 있으면, 해당 색인을 반환
- 없으면, -1을 반환
- 시간 복잡도 : O(n)
배열의 접근
- 이미 색인을 알고 있다면 곧바로 접근 가능
- 시간 복잡도 : O(1)
스택
- 자료의 삽입과 삭제에 대한 규칙이 있는 자료구조 중 하나
- 가장 먼저 자료구조에 삽입된 데이터가 제일 마지막에 삭제됨
- 선입후출(FILO) or 후입선출(LIFO)
스택의 구현
- 배열로 쉽게 가능
- 그냥 배열의 앞에서부터 데이터를 추가
- 마지막 요소의 위치를 스택의 젤 위라 생각하면 됨
스택의 삽입 예
enum {MAX_NUMS = 8};
int s_nums[MAX_NUMS];
size_t s_num_count = 0;
void push(int n)
{
assert(s_num_count < MAX_NUMS);
s_nums[s_num_count++] = n;
}
스택의 삽입
- 보통 push 라고 표현
- 시간 복잡도 : O(1)
스택의 제거 예
enum {TRUE = 1, FALSE = 0};
enum {MAX_NUMS = 8};
int s_nums[MAX_NUMS];
size_t s_num_count = 0;
int is_empty(void)
{
return (s_num_count == 0);
}
void pop(void)
{
assert(is_empty() == FALSE);
return s_nums[--s_num_count];
}
스택의 제거
- pop 이라고 함
- 시간 복잡도 : O(1)
스택의 검색
- 시간 복잡도 : O(n)
- 젤 위부터 찾을 때까지 뒤져야 함
- 보통 push와 pop만 허용하므로 임의의 요소에 접근할 방법이 없음
- 모든 요소를 다 제거했다가 다시 원상복구해야 함
- 제거에 O(n) + 복구에 O(n)이 필요
- 따라서 O(2n)이지만 이건 그냥 O(n)이라고 말함
스택의 용도
- 일련의 자료들의 순서를 뒤집는데 유용
- 왜 뒤집지?
- 현재 데이터 순서대로 연산하는 것이 컴퓨터에 적합하지 않은 경우
큐
- 가장 먼저 자료구조에 삽입(enque)된 데이터가 제일 처음에 삭제(dequeue)됨
- 선입선출(FIFO)이라고 함
- 스택과는 삭제 방향이 다름에 주목!
- 언제나 젤 처음에 있는 자료만 접근 가능
큐의 비효율적인 구현
- 그냥 배열을 사용하면 큐를 구현할 수는 있다
- enqueue하면 그냥 제일 뒤에 추가 O(1)
- dequeue하면 그냥 제일 앞에서 삭제 O(n)
- 하지만, 앞에서 큐의 삭제도 O(1)이라 했는데?
큐의 삭제를 O(1)으로 만드려면...
- 내부적으로 배열을 사용하되 원형 버퍼의 개념을 이용하면 가능
- 어디든 시작과 끝이 될 수 있음
큐의 삽입 예
enum {MAX_NUMS = 8};
int s_nums[MAX_NUMS];
size_t s_front = 0;
size_t s_back = 0;
size_t s_num_count = 0;
void enque(int n)
{
assert(s_num_count < MAX_NUMS);
s_nums[s_back] = n;
s_back = (s_back + 1) % MAX_NUMS;
++s_num_count;
}
큐의 삽입
- 보통 enqueue라고 표현
- 시간 복잡도 : O(1)
큐의 삭제 예
int is_empty(void)
{
return (s_num_count == 0);
}
int dequeue(void)
{
int ret;
assert(is_empty() == FALSE);
ret = s_nums[s_front];
--s_num_count;
s_front = (s_front + 1) % MAX_NUMS;
return ret;
}
큐의 제거
- dequeue라고 표현
- 시간 복잡도 : O(1)
큐의 검색
- 시간 복잡도 : O(n)
큐의 용도
- 현실 세계에서 대기줄이 필요한 경우 다 적용 가능
- 데이터 유입 속도가 데이터 소모 속도보다 빠른 경우
- 데이터 제공자의 수가 데이터 소비자의 수와 다른 경우
- ex. multi threading
- 입출력 스트림 버퍼도 같은 개념
연결 리스트
- 여태까지 본 자료구조들은 메모리에서 연속된 저장 방법에 기초
- 연결 리스트는 그런 제약을 깸
- 자료들이 메모리에 산재해 있음
- 연결 리스트의 각 자료를 노드라고 부름
- 동적 메모리 할당으로 필요에 따라 각 노드를 할당
- 노드 사이의 선후 관계를 별도로 지정
- 어떻게? 다음에 오는 노드의 메모리 주소를 기억
- 어디에? 노드에 오는 포인터 벼수에
- 제일 마지막 노드는 다음에 올 노드가 없으니 널 포인터
연결 리스트는 어려울 수 있는 자료구조
- 연결 리스트는 매우 훌륭한 면접 문제
- 메모리 관리 능력
- 이중 포인터 사용 능력
연결 리스트의 삽입
- 이미 삽입할 위치를 알면 O(1)
연결 리스트의 제거
- 이미 제거할 위치를 알면 O(1)
연결 리스트의 검색
- O(n)
- 젤 첫 노드부터 찾을 때까지 뒤져야 함
- 보통, 이 노드를 헤드라고 부름
- 색인으로 접근 불가능
- 이렇게 찾은 뒤에 삽입을 하면 O(1)이 되는 것
연결 리스트 전체를 출력하는 코드 예
typedef struct node {
int value;
node_t* next;
} node_t;
void print_node(const node_t* head)
{
node_t* p;
p = head;
while(p != NULL){
// 출력
p = p->next;
}
}
헤드 노드
- 연결 리스트의 첫 번째 노드를 가리키는 포인터
- 처음 시작할 때 값은 NULL
- 여기다 새로운 노드를 동적 할당해서 대입해줄 것임
node_t* head = NULL;
- 여기다 새로운 노드를 동적 할당해서 대입해줄 것임
해제코드
void destroy(node_t* head)
{
node_t* p = head;
whlie(p != NULL){
node_t* next = p->next;
free(p);
p = next;
}
}
node_t* head = NULL;
// 생략
destroy(head);
head = NULL;
삽입코드
맨 앞에 삽입하는 코드 살펴보기
void insert_front(node_t** phead, int n)
{
node_t* new_node;
new_node = malloc(sizeof(node_t));
new_node->value = n;
new_node->next = *phead;
*phead = new_node;
}
node_t* head = NULL;
insert_front(&head, 3);
insert_front(&head, 5);
insert_front(&head, 2);
insert_front(&head, 0);
destroy(head);
head = NULL;
오름차순으로 삽입하는 코드
void insert_front(node_t** phead, int n)
{
node_t** pp;
node_t* new_node;
new_node = malloc(sizeof(node_t));
new_node->value = n;
pp = phead;
while(*pp != NULL){
if((*pp)->value >= n){
break;
}
pp = &(*pp)->next;
}
new_node->next = *pp;
*pp = new_node;
}
node_t* head = NULL;
insert_front(&head, 3);
insert_front(&head, 5);
insert_front(&head, 2);
insert_front(&head, 0);
destroy(head);
head = NULL;
노드 삭제
void remove(node_t** phead, int n)
{
node_t** pp;
pp = phead;
while(*pp != NULL){
if((*pp)->value == n){
node_t* tmp = *pp;
*pp = (*pp)->next;
free(tmp);
break;
}
pp = &(*pp)->next;
}
}
int main(void)
{
node_t* head = NULL;
// 리스트 순서 : 2 -> 3 -> 5
remove(&head, 2);
remove(&head, 5);
destroy(head);
head = NULL;
}
연결 리스트의 용도
-
스택/큐와 같은 특성(삽입/삭제 방향) 때문에 쓰는 자료구조는 아님
-
오히려 길이를 자유롭게 늘리거나 줄일 수 있기에 배열의 한계를 넘으려고 사용하던 자료구조
-
즉, 최대 길이를 미리 특정할 수 없고 삽입/삭제가 빈번할 경우 사용
-
오늘날 어플맄이션 프로그램에서 사용 빈도는 많이 줄음
-
기본적으로 동적 할당 배열을 더 흔히 사용
- C#에서 list도 연결 리스트가 아니라 동적 할당 배열임
- 최신 하드웨어의 특징 상 배열이 보장하는 훌륭한 메모리 지역성(인접한 메모리 사용)이 성능에 유리한 경우가 많기 때문
-
하지만 커널 모드 프로그래밍(예: 드라이버)에서는 여전히 많이 사용
- 메모리의 지역성을 해치지 않으면서도 충분히 큰 메모리(예: 4KB)를 미리 할당
- 필요에 따라 그 메모리를 쪼개 연결 리스트의 노드로 사용(예: 메모리 풀)
-
참고로 배열 다음으로 많이 사용하는 자료형은 해시 맵
참고하면 좋은 것들- 단일 연결 리스트
- 살펴본 연결 리스트의 형태
- 다음 노드를 가리키는 포인터만 저장
- 단일 연결 리스트
-
그 전의 노드를 가리키는 포인터도 저장하는 이중 연결 리스트도 있음
-
이중 연결 리스트는 보통 head 외에 tail 포인터 변수도 가지고 있음
해시 테이블
- 검색, 삽입, 삭제의 평균 시간 복잡도가 O(1)
- O(1)이 가능하려면 어떤 메모리 주소에 어떤 데이터가 저장되어 있는지 한 방에 알 수 있어야 함
무작위로 뽑은 수 10개를 저장해보자
방법 1. 입력 값의 최댓값을 배열의 크기로
- 입력 값은 총 10개 뿐
- 입력 값이 꽤 클 수 있음
- 우리가 만들어야 하는 것
- 입력: 10개의 중복이 없는 수
- 출력: 범위가 0~9인 배열의 색인
- 즉, 자료(입력)->색인(출력)으로 바꾸는 함수
- 단, 출력 값을 찾을 때, 반복문 없이 O(1)이어야 함
방법 2. 입력값 % 10으로 색인 만들기
- 동일한 색인 위치에 들어가는 애들이 생긴다
방법 3. 배열의 크기를 두 배로
- 겹치는 색인이 아까볻다는 덜 생기나 여전히 존재
방법 3의 보완. 가장 좋은 방법 -> 소수도 사용
- 흔히 다음과 같이 하는 게 좋다고 말함
1. 배열의 크기는 최소 2배
2. 크기에는 소수를 사용
- 소수로 나눠야 동일한 색인이 안 나올 가능성이 제일 높음
- 그래도 중복이 없을 순 없다!
중복 색인 문제의 해결
- 나머지 연산으로 구한 색인 위치 이후에 빈 공간을 찾아 저장
- 즉, 색인을 1씩 증가해가며 빈 색인 위치를 찾아 거기에 저장
- 따라서, 더 이상 불 값(1,0)이 아니라 실제 값을 저장해야 함
- 따라서, 배열에서 비어있는 위치를 알 수 있어야 함
- 방법 1: 불 배열을 만들어서 사용 여부를 나타냄
- 방법 2: 어떤 특정한 값을 저장해서 비어있다는 사실을 표시
- 보통 유효하지 않은 값(ex. INT_MIN)을 사용
- 우리는 방법 2를 사용
배열을 사용하는 해시 테이블 예
#include <limits.h>
#define TRUE (1)
#define FALSE (0)
#define BUCKET_SIZE (23)
int s_numbers[BUCKET_SIZE];
void init_hashtable(void)
{
size_t i;
for(i=0; i<BUCKET_SIZE; ++i){
s_sumbers[i] = INT_MIN;
}
}
int has_value(int value)
{
int i;
int start_index;
start_index = value % BUCKET_SIZE;
if(start_index < 0){
start_index += BUCKET_SIZE;
}
i = start_index;
do{
if(s_numbers[i] == value){
return TRUE;
}
else if(s_numbers[i] == INT_MIN){
return FALSE;
}
i = (i+1) % BUCKET_SIZE;
} while(i != start_index);
return FALSE;
}
int add(int value)
{
int i;
int start_index;
start_index = value % BUCKET_SIZE;
if(start_index < 0){
start_index += BUCKET_SIZE;
}
i = start_index;
do{
if(s_numbers[i] == value || s_numbers[i] == INT_MIN){
s_numbers[i] = value;
return TRUE;
}
i = (i+1) % BUCKET_SIZE;
} while(i != start_index);
return FALSE;
}
이게 바로 해시 테이블
- 색인 중복이 없으면, 읽는 것도 쓰는 것도 다 O(1)
- 색인 중복이 있으면, 최악의 경우 O(N)
또 다른 방법 : 연결 리스트를 사용
- 배열 안의 각 요소에 연결 리스트를 저장
- 여전히 색인이 겹치는 경우는 있음
- 그래도 O(N)이 되는 경우가 앞의 방법보다 적음
- O 표기상 여전히 O(N)
해시 값
- 어떤 데이터를 해시 함수에 넣어서 나온 결과
해시 함수
- 임의의 크기를 가진 데이터를 '고정 크기'의 값에 대응하게 하는 함수
- 함수니까 입력 값이 같으면 출력 값은 언제나 같다
- 입력 값이 달라도 출력 값이 같을 수 있다
해시 충돌(hash collision)
- 입력 값이 다른데 출력 값이 같은 경우
- 물론, 이런 일은 없을 수록 좋음
- 따라서, 출력 값으로부터 입력 값을 찾을 수 있다는 보장이 없다
좀 더 자세히 설명하는 해시 값
- 어떤 데이터를 대표하는 값 하나를 내놓으라고 함
- 이렇게 해서 받은 값이 해시 값
- 그래서 영어에서는 ID라고 함
- 앞의 예에선 입력 값 그 자체를 해시 값이라 볼 수도 있음
- 그 해시 값을 정해진 배열 안에 꾸겨 넣으려고 % 연산을 한 것일 뿐
해시 테이블에 문자열 저장하기
- 우리가 할 일 : 문자열을 배열 색인으로 변환
- 아까 사용한 연산자 % 는 문자열에 사용 불가
- 문자열을 정수형으로 변환하는 해시 함수가 있어야 함
- 문자열의 각 요소는 어짜피 아스키 코드 = 정수 값
- 정수 값들이 줄줄이 서 있는 걸 잘 합치면 될 것 같음
잘 쓰이지 않는 해시 함수 예
static int hash_function(const char* str)
{
int code = 0;
const char* c = str;
while(*c != '\0'){
code += *c++;
}
return code;
}
색인을 구하면 테이블에 저장이 가능
int add(const char* value, int (*hash_func)(const char*));
add("Teemo", hash_function);
add("Hana", hash_function);
하지만 O(문자열 길이)
- O(1)이 아님
- 해시 함수가 문자열 길이만큼 반복문을 도니 O(문자열 길이)
정말 O(1)으로 만들고 싶다면?
- 해시 함수를 한 번만 호출하고 그 결과를 기억해둠
int hash_id = hash_function(문자열);
- 필요할 때마다 이 변수로 해시 테이블 함수를 호출
add(hash_id, data);
- 이제 hash_id로부터 저장할 위치의 색인을 O(1)로 구할 수 있다!
// add() 함수 내부 //... int start_index = hash_id % 23; //...
지금까지 본 건 엄밀히 말하면 해시 세트
- 즉, 집합 내 어떤 데이터를 중복 없이 저장한 게 전부
- 저장할 위치를 찾으려고 해시 함수를 썼을 뿐
일반적인 해시 테이블의 형태
- 어떤 키에 대응하는 어떤 값을 같이 저장
- 이런 형태를 보통 해시 테이블(엄밀히 말하면 해시 맵)이라 함
- C# 등의 언어에서 dictionaty가 바로 이것
- key : 데이터의 위치를 의미하는 데이터
- 꼭 정수가 아니어도 됨. 문자열도 가능. 구조체도 가능
- value : 실제 저장하는 데이터
- 키를 가지고 그 위치에 가서 자물쇠를 열면 데이터가 나온다는 의미
해시 테이블에서도 충돌이 발생
- 두 가지 문제
- 해시 충돌 : 키가 다른데 같은 해시 값이 나옴
- 색인 충돌 : 해시 값이 다른데 같은 색인이 나옴
- 두 번째 문제는 이미 해결한 문제
해시 충돌 문제를 해결해야 하나요?
- 해시 충돌 문제는 사실 해결 안 해도 되긴 함
- 그냥 색인 충돌 문제의 해법으로 같이 풀림
만약 해시 충돌을 방지할 수 있다면?
- char*를 복사해서 키로 저장할 이유가 없음
- 해시 값인 정수형(int)만 저장해도 됨
- char* 저장하려고 동적 메모리 할당 안 해도 됨
- 동적 메모리 할당은 느림. 안 할 수 있다면 이득
- 훌륭한 해시 함수가 있다면 가능
훌륭한 해시 함수란?
- 필수 : 어떤 경우에도 고정된 크기의 값으로 변환 가능
- 어떤 자료형이든
- 데이터의 길이와 상관 없이
- 해시 충돌이 거의 없음
- 따라서, 충돌이 덜 나는 해시 함수가 좋음
간단하고 나쁘지 않은 해시 함수 : 65599
size_t hash_65599(const char* string, size_t len)
{
size_t i;
size_t hash;
hash = 0;
for(i = 0; i < len; ++i){
hash = 65599 * hash + string[i];
}
return hash ^ (hash >> 16);
}
완전히 충돌을 없앨 수 있을까?
- 기술적으로는 안 됨
- 허나 특정 조건 하에서는 해시 충돌을 확실히 방지 가능
- 그런 조건이 생각보다 많음
- 이게 가능하면 키 배열에 문자열 대신 정수만 저장해도 됨
- char* 동적 메모리 할당이 사라짐
- 해시 테이블에 추가하는 함수를 호출할 때 키 대신 해시 값을 바로 사용
add(hash_key, value);
해시 충돌을 완벽히 방지할 수 있는 조건이란?
- 고성능을 요하는 업계에서 많이 쓰는 방법
- 실행 중에 해시 테이블에 저장될 수 있는 데이터를 모두 알고 잇음
- 예 : 미리 만들어 놓은 데이터 파일 읽기
- 유저로부터 받는 입력이 아니라는 이야기
- 개발 도중에 해시 충돌이 없다는 걸 확인하고 보장할 수 있음
- 키 저장 단계를 없애도 됨
충돌까지 고려한 해시 맵 예
int add(const char* key, int value, size_t (*hash_func)(const char*, size_t))
{
size_t i;
size_t start_index;
size_t hash_id;
hash_id = hash_func(key, strlen(key));
start_index = hash_id % BUCKET_SIZE;
i = start_index;
do{
if(s_keys[i] == NULL){
// 새 키-값을 삽입
return TRUE;
}
if(strcmp(s_keys[i], key) == 0){
return TRUE;
}
i = (i + 1) % BUCKET_SIZE;
}while(i != start_index);
return FALSE;
}
충돌이 없을 때 해시 맵 예
int add_fast(size_t hash_key, const char* value)
{
size_t i;
size_t start_index;
start_index = hash_key % BUCKET_SIZE;
i = start_index;
do{
if(s_keys[i] == INT_MIN){
// 새 키-값을 삽입
return TRUE;
}
if(s_keys[i] == hash_key){
return TRUE;
}
i = (i + 1) % BUCKET_SIZE;
}while(i != start_index);
return FALSE;
}
다른 자료형은?
- 그냥 거저 먹으면 됨
- 문자열은 char*
- 어떤 데이터도 char 배열로 표현 가능
- 즉, 가장 범용적인 데이터
- 아래와 같은 함수 하나만 있으면 끝
size_t hash_function(const char* bytes, size_t length)
- 단, 저장하려는 데이터에 걸맞는 해시 함수를 찾아 사용할 것
베스트 프랙티스 : 언제 어떤 자료구조를 쓸까
- 기본적으로 배열
- 가장 간단함
- 캐시 메모리 덕분에 O 표기법에 상관 없이 성능이 가장 빠른 경우가 많음
- 빈번한 데이터 삽입 또는 삭제를 해야 한다면?
- 연결 리스트
- 다음과 같은 경우는 해시 기반 자료 구조들
- 데이터 양이 많은데 검색을 자주 해야 함
- 배열에 넣기 힘든 데이터(ex. 연속적, 규칙적인 색인이 나올 수 없는 경우)
여러 데이터형과 해시 함수
hash_function.c
#include <stddef.h>
#include "hash_function.h"
size_t hash_int(int value)
{
return value;
}
size_t hash_float(float value)
{
return *((size_t*)&value);
}
size_t hash_data(const void* data, size_t length)
{
const char* p;
size_t i;
size_t hash;
p = data;
hash = 0;
for(i = 0; i<length; ++i){
hash = 65599 * hash + *p++;
}
return hash ^ (hash >> 16);
}
main.c
#include <stdio.h>
#include <string.h>
#include "hash_function.h"
typedef struct{
unsigned char age;
unsigned int id;
char name[64];
} employee_t;
int main(void)
{
employee_t person;
size_t hash;
float fvalue;
hash = hash_int(10);
printf("int %u\n", hash);
hash = hash_int(-10);
printf("int %u\n", hash);
hash = hash_int('A');
printf("char %u\n", hash);
hash = hash_float(3.2f);
printf("flaot %u\n", hash);
hash = hash_data("Pope Kim", strlen("Pope Kim"));
printf("string %u\n", hash);
fvalue = 3.2f;
hash = hash_data(&fvalue, sizeof(float));
printf("float %u\n", hash);
memset(&person, 0, sizeof(employee_t));
person.age = 23;
person.id = 183213;
strcpy(person.name, "Pope Kim");
hash = hash_data(&person, sizeof(employee_t));
printf("struct %u\n", hash);
return 0;
}
'프로그래머 > C, C++' 카테고리의 다른 글
[면접 대비] C를 사용한 해시 맵 구현 (0) | 2020.11.29 |
---|---|
[면접 대비] C를 사용한 linked list 구현 (0) | 2020.11.29 |
[포프 tv 복습] 레지스터, 스택 & 힙, 동적 메모리. 다중 포인터 (0) | 2020.11.28 |
[포프 tv 복습] 가변 인자 함수, 올바른 오류 처리 방법 (0) | 2020.11.26 |
[포프 tv 복습] 구조체, 공용체, 함수 포인터 (0) | 2020.11.25 |