구조체배열과 같이 여러 개의 데이터를 그룹으로 묶어 사용하는 자료형이다. 같은 자료형만을 그룹으로 사용할 수 있는 배열과는 달리, 구조체는 서로 다른 자료형을 그룹으로 묶을 수 있으므로 복잡한 자료 형태를 정의하는 데 유용하다. 이때, 구조체의 선언은 구조체이름, 자료형, 항목으로 구성되며, 항목별로 이름과 자료형을 선언해야 한다는 번거로움이 있다. 이는 배열과는 달리 각기 다른 자료형을 그룹으로 묶을 수 있기에 구별해주어야 하기 때문이다. 구조체 선언의 예시는 다음과 같다: 

struct person {
    char name[20]; // 글자수에 따른 메모리 할당
    int age;
    float height;
}; // 이곳에 바로 구조체 변수를 선언할 수 있다.


 구조체 선언을 한 후 따로 struct person Lee; 라는 코드를 통해 구조체 변수를 선언 할 수 있으며, 구조체를 선언한 직후 변수의 이름을 기입하여 변수 선언을 하는 것도 가능하다. 구조체 변수 초기화는 중괄호를 통해 이루어진다. struct person Lee = { “Ann”, “22”, “170” }; 처럼 초기화하면 된다. 여기서 구조체와 포인터를 함께 사용할 경우나, 데이터 항목을 참조할 경우 중요하게 사용되는 구조체 연산자를 알아두어야 한다. 

 먼저, 점 연산자(.)는 데이터 항목을 개별적으로 지정할 때 사용된다. 간단히 '변수 이름.항목 이름'과 같은 형식으로 나타내면 된다. 예시로는 printf 함수에서 printf(“이름: %s\n”, Lee.name); 처럼 쓸 수 있다.

 화살표 연산자(->)를 사용할 경우, 구조체의 주소를 저장한 포인터가 필수적이다. 따라서, 연산자를 사용하기 전 무조건 주소를 저장하는 코드를 추가해야만 연산자의 기능을 제대로 쓸 수 있다. 이 또한 예시로는 struct person *ptr = &Lee; printf("이름: %s\n", ptr->name); 로 쓰임을 설명할 수 있겠다. 포인터에 구조체 변수 ‘Lee’의 주소가 저장되므로, 화살표 연산자만을 사용해도 정상 출력되는 것이다.

 그렇다면 재귀호출은 무엇일까? 재귀호출은 간단히 말해 자기 자신을 호출하여 순환이 수행되는 방식이라 볼 수 있다. 재귀호출에서 가장 중요한 개념은 베이스 케이스인데, 재귀호출을 실행할 때 종료 시점이 명확하지 않으면 무한으로 반복 호출되어 프로그램이 정상 작동하지 않기 때문이다. 베이스 케이스는 함수가 반복을 중지하는, 말 그대로 기초, 즉 바닥이 되는 케이스라 이해하면 된다. (마트료시카 인형을 떠올리면 보다 쉽게 이해할 수 있다!)

 재귀함수는 항상 세 가지의 기본 틀을 이룬다. 함수 선언, 베이스 케이스, 자기 자신 호출이다. 재귀함수의 대표적인 예시는 팩토리얼, 피보나치 수열 등이 있고, 재귀호출이 사용된 예시 코드를 작성한다면 다음과 같다:

int sum(int n) {
    if (n == 1) return 1; // 베이스 케이스 
    return n + sum(n - 1);       
}
int main() {
    int result = sum(5);         
    printf("합계: %d\n", result);
    return 0;
}


 이 예시는 1부터 5까지를 더하는 재귀함수고 앞서 말한 세 가지의 기본 틀을 이루고 있음을 알 수 있다. (혼자 재귀함수 코드를 작성하려면 굉장히 헷갈린다. 공부가 필요할 듯하다.) 재귀를 좀 더 수월하게 받아들이기 위해서 재귀를 반복문처럼 생각하는 것이 도움이 된다. 

 

// for 문으로 1부터 5까지 출력하기
for (int i = 1; i <= 5; i++) {
    printf("%d", i);
}

// 재귀 함수를 사용하기
void print_sum(int i) {
    if (i > 5) return; //베이스 케이스 5까지 출력, void로 return만 작성
    printf("%d", i);
    print_sum(i + 1);
}

 

 재귀함수에 익숙해지기 위해 간단한 코딩 테스트 문제를 하나 풀고 가겠다.  재귀를 써서 1부터 n까지의 합을 구하는 함수를 만들어보자.

 

#include <stdio.h>

int sum(int i, int n) {
    if (i > n) return 0;
    return i + sum (i + 1, n);
}

int main() {
    int n;
    printf("정수를 입력하시오.\n");
    scanf("%d", &n);
    
    int result = sum (1, n);
    printf("합은: %d\n", result);
    
    return 0;
}

 

 재귀 로직을 살펴보면 이렇다. 값을 반환하는 재귀이므로 return을 사용한다. 출력과 같은 동작만 하는 재귀의 경우 return을 사용하지 않아도 된다. return i + sum(i + 1, n); 은 쉽게 말해 선수행 후수행을 분리한 식이라 볼 수 있다. 가장 먼저 i를 두고, 즉 지금 차례를 기록하고 그 후 i + 1부터 나머지를 재귀 호출하여 더하는 것. i는 지금 해야 할 일이고, i + 1은 그 이후에 할 일인 것이다. 여기서 내가 헷갈렸던 점은 이때 i 하나가 중첩되어 더해지는 것인가였는데, 이에 대한 답은 '아니다' 였다. 굳이 처음의 i와 나머지를 떼어놓는 이유는 각 재귀호출이 자기 자신의 차례를 직접 해결하고 다음 문제를 다음 호출에게 넘기기 위해서였다. 단지 호출할 때 스택처럼 쌓일뿐이다. 이를 나누지 않을 경우 재귀의 의미가 없어지고, 오히려 복잡해진다. 앞 함수의 sum에서 sum(1, 3)을한다 가정하면 후술할 구조로 반복된다.

 

sum(1, 4)

→ 1 + sum(2, 4)

        → 2 + sum(3, 4)

             → 3 + sum(4, 4) 

 

이렇듯 문제를 작게 쪼개어 처리하는 것, 그것이 재귀의 핵심이다. 마지막 연습으로 n!(n 팩토리얼)을 구하는 코드를 작성해보겠다.

 

#include <stdio.h>

int factorial(int n) {
    if (n == 0) return 1; //곱셈의 항등원은 1
    return n * factorial(n - 1);
}

int main() {
    int n;
    printf("정수를 입력하시오.\n");
    scanf("%d", &n);

    int result = factorial(n);
    printf("%d! = %d\n", n, result);

    return 0;
}

 

 이때 덧셈은 0, 곱셈은 1을 베이스 케이스로 반환한다는 것을 알아두면 좋다.

 

 

 배열 프로그램을 이용하여 구현할 자료들을 논리적인 순서로 메모리에 연속 저장하는 방식의 순차 자료구조를 구현할 수 있다. 앞서 학습한 포인터는 이와 반대되는 연결 자료구조에서 쓰이며, 순차 자료구조와는 다른 양상을 띈다. 순차 자료구조는 메모레의 저장 시작 위치부터 빈 공간을 만들지 않은 채로 자료를 순서대로 연속해 저장한다. 논리적인 순서와 물리적 순서가 일치하는 것이다. 또한, 삽입이나 삭제를 하여도 빈자리는 생기지 않는다. 

 

 자료들 간에 순서를 갖는 리스트인 '선형 리스트'라는 개념은 순차 자료구조와 연결 자료구조 둘 다로 구현할 수 있는데, 순차 자료구조로는 이렇게 나타낼 수 있다. int arr[100]; 이 그 예이다. 다음으로, 삽입을 하는 방법은 이렇다. 먼저 삽입 로직을 짠 후 배열에서 어떤 방식으로 삽입할 것인지를 생각하여 코드를 구성한다. 가장 많이 쓰는 방법은 한 칸씩 뒤로 자리를 이동하는 것이다. 그 뒤에 밀려 준비된 빈 자리에 원소를 삽입한다. 이것이 하나의 알고리즘을 이룬다. 

 

#include <stdio.h>
#include <stdlib.h>
#define MAX_SIZE 100

void insert(int arr[], int *n, int index, int value) {
	/* 리스트 크기: n
	삽입할 위치: index
	삽입할 값: value */

	for (int i = *n - 1; i >= index; i--) {
		arr[i + 1] = arr[i];
	}

	arr[index] = value;
	(*n)++;
}

	int main() {
		int n, index, value;
		printf("리스트의 크기를 입력하시오.");
		scanf("%d", &n);
		int *arr = (int*)malloc(sizeof(int) * (n + 1));


		if (n > MAX_SIZE) {
			printf("최대 크기를 초과합니다.");
			return -1;
		}

		printf("%d개의 값을 입력하시오.");
		for (int i = 0; i < n; i++) {
			scanf("%d", &arr[i]);
		}

		printf("삽입할 위치를 입력하시오.");
		scanf("%d", &index);

		printf("삽입할 값을 입력하시오.");
		scanf("%d", &value);

		insert(arr, &n, index, value);

		printf("삽입 결과:");
		for (int i = 0; i < n; i++) {
			printf("%d ", arr[i]);
		}

		return 0;
	}

 

 처음 이 순차 선형 리스트에 어떻게 삽입할 수 있는지를 맞닥뜨렸을 땐 정말 머리가 아팠다. 그러나 한 번 이해하고 나면 생각보다 별 거 아니었다는 것을 깨달을 수 있다. 위 코드는 리스트의 중간에 삽입할 값인 value를 입력받고, 그 뒤의 자리한 원소들을 한 칸씩 미뤄 자리를 만들어주는 형태의 알고리즘을 구성한다. 삽입 로직의 구성은 이러하다.

 

 void insert(int arr[], int *n, int index, int value) {

    for (int i = *n - 1; i >= index; i--) {

        arr[i + 1] = arr[i];

    }

    arr[index] = value;

    (*n)++;

}

 

 맨 처음 insert 함수를 선언하여 삽입에 사용될 배열, 크기, 삽입 위치, 삽입 값을 지정한다. 이후 반복문을 통해 삽입될 위치부터 끝까지 있던 원소들을 한 칸씩 뒤로 이동시켜 빈 공간을 만든다. 이 반복이 끝나면, 새로 삽입할 값(value)을 지정한 위치(index)에 저장한다. 마지막으로 리스트의 크기를 하나 증가시켜 삽입된 데이터까지 포함되도록 관리한다. 이때 MAX_SIZE는 예외처리를 위한 것이다. 따라서 return -1;로 확실한 오류임을 나타낸다.

 

 반복문은 왜 저렇게 짜여졌는지 뜯어보자. 지정한 위치 이후의 데이터를 뒤로 이동시켜야 하는 것이기 때문에, *n - 1, 즉 마지막부터로 i를 초기화한다. 이때 그냥 n이라고 쓰지 않는 것은 n이 포인터 변수이므로 단순히 n으로 둔다면 주소값을 가리키는 꼴이 된다. 이것만 알면 뒤의 구성은 바로 이해가 된다. 리스트의 크기를 증가시킬 때 괄호로 감싸는 이유가 연산자 우선순위로 인한 것임만 기억해두자.

 

 삽입이 아닌 삭제의 경우 위의 삽입 로직을 삭제 로직으로 바꾸면 된다. 예시는 이렇다.

 

 void delete(int arr[], int *n, int index) {

    for (int i = index; i < *n - 1; i++) {
        arr[i + 1] = arr[i];
    }
    (*n)--;
}

 

 삽입문과 거의 비슷하지만, 반복문의 구성이 달라지고 배열의 크기를 늘렸던 삽입과는 달리 줄이는 것을 볼 수 있다. 삭제할 인덱스 번호부터 시작해서 이번엔 왼쪽으로 한 칸씩 원소들을 옮긴다. 이러한 배열의 삽입과 삭제는 자료구조의 기본이다. 

 

 또한, 행렬을 선형 리스트로 표현할 수 있는데, 선형대수학을 접한 사람이라면 이부분을 크게 무리없이 이해할 수 있을 것이라 추측한다. 물론 쉬운 개념은 아니라 생각한다. 2차원 논리 순서를 1차원 물리 순서로 변환할 때, 행 우선 순서 방법열 우선 순서 방법이 있다. 대부분의 경우 행 우선 순서 방법을 훨씬 더 자주 쓰게 된다. 왜냐하면, C언어나 파이썬같은 현대 프로그래밍 언어의 태반이 기본적으로 행 우선 방법을 사용하기 때문이다. 

 

 행 우선 순서 방법은 말 그대로 2차원 배열의 첫 번째 인덱스인 행 번호를 기준으로 변환한다. 이때 원소의 위치를 편리하게 계산하는 관계식이 있다. m x n 행렬인 2차원 배열 A[m][n]에서 시작주소가 a고 원소의 길이가 l일 때, i행 j열 원소, 즉 a[i][j]의 위치를 구힌다면 계산식은 이렇게 작성된다. a + ( i * n + j ) * l 우리는 이 계산식을 통하여 원소의 위치를 쉽게 구해낼 수 있다. 덧붙여 희소 행렬에 대한 2차원 배열 역시 표현할 수 있는데, 이는 세 가지의 순서로 이루어진다.

 

 먼저 희소 행렬에서 0이 아닌 원소를 추출하여 <행 번호, 열 번호, 원소의 값> 의 형태로 배열에 저장한다. 그 후, 추출한 순서쌍을 2차원 배열에 행으로 저장하며, 마지막으론 원래의 행렬에 대한 정보를 0번째 행에 저장하면 된다. 예시로 5행 6열의 희소행렬을 만들어보고 이를 2차원 배열로 표현해 보겠다.

0 0 0 9 0 0
0 0 0 0 0 0
4 0 0 0 0 2
0 0 5 0 0 0
0 7 0 0 8 0

 

 여기서 0이 아닌 원소를 구해 정리하면, (배열은 0부터 시작함을 잊지 말아야 한다.)

 

<0, 3, 9>

<2, 0, 4>

<2, 5, 2>

<3, 2, 5>

<4, 1, 7>

<4, 4, 8>

 

 로 정리된다. 이를 2차원 배열에 행으로 저장할 때 가장 맨 위엔 원래의 행렬에 대한 <전체 행의 개수, 전체 열의 개수, 0이 아닌 원소의 개수>를 꼭 붙여주어야만 한다. 따라서 최종 배열 표는 이러하다. 

 

5 6 6
0 3 9
2 0 4
2 5 2
3 2 5
4 1 7
4 4 8

 

 한 발짝 더 나아가 이를 전치로도 구할 수 있다! 희소행렬의 전치는 행과 열을 뒤바꾸는 것으로, 바꾼 행과 열을 따르는 배열을 재구성할 수 있다. 값은 그대로 두면 된다. 전치한 버전은 다음과 같다:

 

<3, 0, 9>

<0, 2, 4>

<5, 2, 2>

<2, 3, 5>

<1, 4, 7>

<4, 4, 8>

 

6 5 6
0 2 4
1 4 7
2 3 5
3 0 9
4 4 8
5 2 2

 

 

 

 프로그램 실행 도중에 메모리를 직접 필요한 만큼 할당하여 사용하는 것을 '동적 메모리'라고 한다. 이와 반대되는 개념의 '정적 메모리'는 컴파일 시점에 크기가 결정되므로, 유연성이 낮고 메모리가 자동으로 반납(메모리를 사용한 후 운영체제로 회수하는 것)된다. 이때 동적 메모리는 주소를 통해 접근해야하기 때문에 주소를 저장하는 포인터가 필수가 된다. 다시 말해, 변수처럼 사용할 수 있는 어딘가의 메모리 공간을 가리킬 수 있는 주소가 필요한 것이고 그것이 포인터라는 것이다.

 

 포인터 연산자는 '*''&'를 활용하는데, '*'는 참조 연산자로서 저장된 주소에 있는 값, 즉 변수에 저장된 값을 액세스하는 연산자다. 이어지는 '&' 연산자는 주소 연산자로 변수의 주소를 얻기 위해 사용한다. 포인터를 쓰지 않아도 풀 수 있지만, 포인터를 연습하기 위해 최댓값을 구하는 문제를 풀어보자.

 

#include <stdio.h>

int main() {
    int x, y, z;
    int *a = &x, *b = &y, *c = &z;

    scanf("%d %d %d", a, b, c);

    int max = (*a > *b) ? (*a > *c ? *a : *c) : (*b > *c ? *b : *c); //삼항연산자로 최댓값 확인

    //개수 확인 (최댓값이 여럿일 때)
    int count = 0;
    if (*a == max) count++; //한줄 if문은 중괄호 생략 가능
    if (*b == max) count++;
    if (*c == max) count++;

    if (count == 1) {
        printf("가장 큰 수는 %d\n", max);
    }
    else {
        printf("가장 큰 수는 %d이며, 총 %d개 존재\n", max, count);
    }

    return 0;
}

 

 

 이는 굳이 포인터를 쓰지 않아도 짤 수 있는 코드로 포인터를 사용할 경우 이런 식으로 활용이 가능하다는 것을 연습하기 위해 만든 코드이다. 이제 포인터가 필수인 동적 할당을 위한 코드를 본 후 분석해보겠다. (우리의 친구 지피티가 고맙게도 딱 알맞은 코딩 문제를 제시해주었다. 심심할 때면 코딩 문제 풀이 놀이를 하는데, 실력향상에 정말 큰 도움이 된다.) 문제는 사용자에게 원하는 배열 크기를 입력받고, 정수를 동적으로 저장한 뒤 출력하라는 문제였다. 이때, malloc()(메모리 요청)free()(메모리 반납)함수를 사용해야 한다는 조건이 붙는다. 먼저 5개의 정수를 입력받는 예제 코드를 뜯어보고 문제를 풀어보자.

 

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

int main() {
    int *arr = (int*)malloc(sizeof(int) * 5); 

    if (arr == NULL) {
        printf("메모리 할당 실패\n");
        return 1;
    }

    printf("정수 5개 입력: ");
    for (int i = 0; i < 5; i++) {
        scanf("%d", &arr[i]); 
    }

    printf("입력한 값: ");
    for (int i = 0; i < 5; i++) {
        printf("%d ", arr[i]);
    }

    free(arr); 
    return 0;
}

 

 위 코드를 하나하나 뜯어보면 이렇다:

 

1. #include <stdlib.h> → 'malloc()'과 'free()' 함수를 사용하기 위한 헤더

 

2. int *arr = (int*)malloc(sizeof(int) * 5); → 정수 5개만큼의 메모리 공간 확보

sizeof는 n바이트만큼의 메모리를 요청하는 함수인 malloc에서의 실수를 방지하기 위해 주로 사용

 

3.     if (arr == NULL) {
        printf("메모리 할당 실패\n");
        return 1;
    }

만약 메모리 할당이 실패할 경우, 실패했다는 피드백을 주기 위하여 사용한 코드

 

4.     printf("정수 5개 입력: ");
        for (int i = 0; i < 5; i++) {
            scanf("%d", &arr[i]); 
    }

→ 5개의 정수를 하나씩 입력받아 입력값 arr라는 배열의 i번째 위치에 저장 (i의 숫자를 늘리는 반복문) 

 

5.     printf("입력한 값: ");
        for (int i = 0; i < 5; i++) {
            printf("%d ", arr[i]);
}

→ 지금까지 저장한 입력값을 확인, 입력 > 처리 > 출력의 구조 중 출력에 해당

 

6. free(arr); → 메모리 반납

 

 이제 처음으로 돌아가 제시된 문제를 풀어보면,

 

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

int main() {
    int n;
    printf("몇 개의 수를 입력하시겠습니까?");
    scanf("%d", &n);

    int* arr = (int*)malloc(sizeof(int) * n);
    if (arr == NULL) {
        printf("오류입니다.\n");
        return 1;
    }

    int sum = 0;
    for (int i = 0; i < n; i++) {
        printf("정수를 입력하시오.");
        scanf("%d", &arr[i]);
        sum += arr[i];
    }

    float avg = (float)sum / n;
    printf("평균은 %.2f입니다.\n", avg);

    free(arr);
    return 0;
}

 

 처럼 풀어낼 수 있다. 아직 포인터 연산자를 언제 어디서 어떻게 써야하는지나, 코드를 효율적으로 구성하는 법에는 굉장히 약하다. C언어에서 가장 어려운 개념이 포인터라는데... 왜 그렇게 말하는지 알 것 같다. 그럼 마지막으로 입력받은 정수 중 짝수만을 출력하는 코드를 작성해보고 넘어가겠다.

 

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

int main() {
    int n;
    printf("몇 개를 입력하시겠습니까?");
    scanf("%d", &n);

    int *arr = (int*)malloc(sizeof(int) * n);
    if (arr == NULL) {
        printf("오류입니다.\n"); //예외처리
        return 1;
    }

    for (int i = 0; i < n; i++) {
        printf("%d번째 정수를 입력하시오", i + 1); //사용자에게 보여지는 정수의 순서를 0부터가 아니게 하기 위하여
        scanf("%d", &arr[i]); 
    }

    printf("짝수 출력");
    int found = 0; //짝수가 없을 경우를 대비하여 설정
    for (int i = 0; i < n; i++) {
        if (arr[i] % 2 == 0) {
            printf("%d ", arr[i]);
            found = 1;
        }
    }

    if (!found) {
        printf("짝수가 없습니다."); //실제로 없는 경우
    }

    free(arr);  
    return 0;
}

 

 

 배열과 포인터 개념을 정리하기에 앞서 몇 개의 기본적인 코딩 테스트 문제를 풀고 시작하겠다. 

 

1. 입력받은 세 정수 중 최댓값 구하기

#include <stdio.h>

int main() {
    int a, b, c, max;

    printf("정수 3개를 입력하시오.");
    scanf("%d %d %d", &a, &b, &c);

    max = (a > b) ? a : b;
    max = (max > c) ? max : c; //삼항 연산자를 통해 코드를 간결하게 작성할 수 있다.

    printf("최댓값: %d\n", max);

    return 0;
}

 

 

2. 구구단 출력하기

 

#include <stdio.h>

int main() {
    int dan;
    printf("2 ~ 9까지의 숫자 중 입력하시오.");
    scanf("%d", &dan);

    for (int i = 1; i <= 9; i++) {
        printf("%d * %d = %d\n", dan, i, dan * i);
    }

    return 0;
}

 

 while문으로 작성하면 이렇게 될 수 있겠다:

#include <stdio.h>

int main() {
    int dan;
    printf("2 ~ 9까지의 숫자 중 입력하시오.");
    scanf("%d", &dan);

    int i = 1;
    while (i <= 9) {
        printf("%d * %d = %d\n", dan, i, dan * i);
        i++;
    }

    return 0;
}

 

 

3. 1부터 n까지의 합 구하기

 

#include <stdio.h>
//for문 버전

int main() {
    int n, sum = 0;
    printf("정수를 입력하시오.");
    scanf("%d", &n);

    for (int i = 1; i <= n; i++) {
        sum += i;
    }

    printf("총합: %d\n", sum);
    return 0;
}
#include <stdio.h>
//while문 버전

int main() {
    int n, sum = 0;
    printf("정수를 입력하시오.");
    scanf("%d", &n);

    int i = 1;
    while (i <= n) {
        sum = sum + i; 
        i++;
    }

    printf("총합: %d\n", sum);
    return 0;
}

 

 

4. 특정 범위에서의 짝수만 출력하기

 

#include <stdio.h>

int main() {
    int a, b;
    printf("두 정수를 입력하시오. (작은 수 우선)");
    scanf("%d %d", &a, &b);

    for (int i = a; i <= b; i++) {
        if (i % 2 == 0) {
            printf("%d\n", i);
        }
    }

    return 0;
}

 

 

5. 비밀번호 맞추기

 

#include <stdio.h>

int main() {
    int pw;

    do {
        printf("비밀번호를 입력하세요.");
        scanf("%d", &pw);

        if (pw != 1234) {
            printf("비밀번호가 틀렸습니다.\n");
        }

    } while (pw != 1234);

    printf("잠금이 해제됩니다.\n");

    return 0;
}

 

 

 C에서의 배열은 같은 타입의 값들을 순차적으로 저장하는 공간으로 자료구조에서 아주 중요한 역할을 한다. C언어에선 자바스크립트와는 달리 배열의 크기를 미리 정해주어야하며, 타입은 서로 같아야만 한다.

 

 여기서 인덱스란, 배열의 요소를 간단히 구별하기 위해 사용하는 번호의 개념이다. 늘 그렇듯 0으로 시작한다. 우리는 배열을 차원에 따라 구분할 수 있다. 1차원 배열과 2차원 이상의 다차원 배열로 구분하고, 행과 열로 이해하면 쉽다.

 

 1차원 배열은 일렬로 나열된 값들의 모음으로 가장 간단한 배열 구조이다. 예를 들면 int arr[5] = {1, 2, 3, 4, 5}; 와 같은 코드가 있겠다. C언어의 배열에선 크기를 생략할 수 있는 경우가 있는데, 이 코드에서도 가능하다. 다차원 배열로 가면 선택적으로 생략할 수 있지만, 1차원 배열에서의 크기 생략은 오히려 실수 방지를 위해 선호되기도 한다. 앞선 코드를 이와 같이 바꾼다면 int arr[ ] = {1, 2, 3, 4, 5}; 처럼 쓸 수 있다. 이렇게 공백을 두어도 컴파일러는 알아서 크기를 할당한다. 이는 숫자뿐만아니라, 문자 배열에서도 역시 적용된다.

 다차원 배열은 어떻게 구성될까? 2차원 배열은 단순한 줄 형태가 아닌 로 구성된다. 논리적 구조에서의 표현은 대강 '자료형 배열이름 [행 번호/크기/개수] [열 번호/크기/개수]' 가 되겠다. 3차원 배열은 2차원 배열에서 ‘면’의 개념이 도입되어, 맨 앞에 면의 개수, 즉 배열의 크기를 추가하여 선언한다. 다차원 배열의 초기화는 배열의 배열이라는 것을 염두에 두고 초기값을 구분하여 지정하거나, 초기값 리스트를 지정하여 순서대로 설정할 수도 있다. 

 2차원 배열의 초기화와 논리적 구조를 살펴보자.
int arr[2][3] = {{1, 2, 3}, {4, 5, 6}};  
또는 int arr[2][3] = {1, 2, 3, 4, 5, 6}; 

 

1 2 3
4 5 6



 3차원 배열의 초기화와 논리적 구조를 살펴보자. 
int arr[2][3][3] = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}, {10, 11, 12}, {13, 14, 15}, {16, 17, 18}}; 
이를 한눈에 보기 쉽게 정리하면,
int arr[2][3][3] = {
    { //면0
        {1, 2, 3},
        {4, 5, 6},
        {7, 8, 9}
    },
    { //면1
        {10, 11, 12},
        {13, 14, 15},
        {16, 17, 18}
    }
};

< 면 0 >

1 2 3
4 5 6
7 8 9

 

 

< 면 1 >

10 11 12
13 14 15
16 17 18

 

 

 3차원 배열의 면의 개념은 처음엔 와닿기가 조금 힘들 수 있다. 비유적으로 설명한다면, 책의 페이지나 포토샵의 레이어 기능이라고 보면 된다. 

 

 

 

 C언어에서의 산술연산자에 대해 정리하면 다음과 같다:

 

+ 덧셈 a + b
- 뺄셈 a - b
* 곱셈 a * b
/ 나눗셈 a / b
% 나머지 a % b

 

 지금까지 배운 언어들 중에서 연산자가 특별히 다른 점이 있는 언어는 보지 못한 것 같다. 왼쪽부터 차례대로 연산자, 기능, 예시이다. 다음은 두 값을 비교해 참이나 거짓을 반환하는 관계 연산자(비교 연산자)를 정리해 놓은 표다.

 

== 같다 a == b
!= 다르다 a != b
> 크다 (좌측 값 기준) a > b
< 작다 (좌측 값 기준) a < b
>= 크거나 같다 (좌측 값 기준) a >= b
<= 작거나 같다 (좌측 값 기준) a <= b

 

 역시 왼쪽부터 연산자, 기능, 예시를 뜻하고, 주로 if문에서 유용하게 사용된다. (지극히 내 주관적 관점에서) 이후 진행할 코딩테스트 연습의 입문 난이도 문제에선 관계 연산자와 if문이 자주 등장하는 것을 보면 대강 느낌을 알 수 있다. 애초에 프로그래밍이라는 게 기초적인 연산자와 함수를 사용하여 문제 해결의 목적을 달성하는 것이니... 다음은 여러 조건을 조합하거나 부정할 때 사용하는 논리 연산자이다.

 

&& and a && b
|| or a || b
! not ! a

 

 논리 연산자도 관계 연산자와 마찬가지로 결과는 참 또는 거짓으로 나온다. 그밖에도 증감 연산자가 있고, 전위 후위로 구분되는데, 이는 따로 기록하진 않겠다. 이제 연산자를 이용하는 입문 난이도의 코딩 테스트 연습을 해보자. 문제는 '프로그래머스 스쿨'에서 참조한다. 이미 과거에 다른 언어로 풀어놓은 문제들이 제법 있어 당황했다.;; 가장 대표적인 홀짝을 구분하는 프로그램을 만든다면, 이렇게 만들 수 있겠다.

 

#include <stdio.h>

int main(void) {
    int a;
    scanf("%d", &a);

    if (a % 2 == 0) {
        printf("%d is even\n", a);
    } else {
        printf("%d is odd\n", a);
    }

    return 0;
}

 

 앞서 이야기한 if문과 관계 연산자의 콜라보이다! 코드를 짧게 줄이려면, 삼항 연산자를 사용하여 줄일 수도 있겠다. scanf 아래 바로 printf("%d is %s", a, a%2 == 0 ? "even" : "odd"); 를 추가하고 끝내면 된다.

 

 덧붙여 C언어에는 비트 연산자라는 것이 존재하는데, 정수형 변수2진수인 0과 1을 직접 다루는 연산자로 효율성과 속도의 측면 및 메모리 절약 등 이점이 많아 유용하게 쓰인다. 예를 들어, int a = 5; 라고 변수를 선언한 후 초기화했다면, 이 숫자 '5'는 컴퓨터 내부에선 8비트 기준 '00000101'로 저장된다. 맨 처음 2진수의 변환이 어떻게 이루어졌는지를 배운 건 이런 개념을 보다 쉽게 이해하기 위해서인 듯 보인다. 이때 비트 연산자는 이러한 비트 하나하나를 직접 비교하고 조작하는 것으로, 먼저 비트 연산자의 종류를 정리해보자.

 

& 비트 and 둘 다 1일 때 1
| 비트 or 하나라도 1이면 1
^ 비트 xor 다를 때 1
~ 비트 not 비트 반전 (보수)
<< 왼쪽 시프트 비트를 왼쪽으로 이동하고 0을 채움
>> 오른쪽 시프트 비트를 오른쪽로 이동하고 0을 채움

 

 이렇게만 보면 사실 감이 잘 안 온다. 따라서 실제 값을 비트 연산자를 통해 연산해보도록 한다. '5'의 값을 가진 정수 a와, '3'의 값을 가진 정수 b가 있다고 가정하자. 컴퓨터 내부에선 각 32비트 (편의상 8비트로 표기) '00000101''00000011'로 저장되었을 것이다. 이 둘에 비트 연산자를 차례로 사용할 경우, 이런 결과가 나온다.

 

a & b = 00000101 & 00000011 → 둘 다 1일 때만 1로 도출  00000001 → 1 (정수)

a | b = 00000101 | 00000011 → 하나라도 1이면 1로 도출 00000111 → 7 

a ^ b = 00000101 ^ 00000011 → 다를 때 1로 도출  00000110 → 6 (32비트로 변환하기 위해 붙인 0은 생략한다.) 

a << 1 = 00000101 << → 비트를 우측 값만큼 왼쪽으로 이동  00001010 → 10 

a >> b = 00000101 >>→ 비트를 우측 값만큼 오른쪽으로 이동  00000010 → 2

 

 여기서 주목할만한 점은 산술 연산과 비트 연산의 결과가 완전히 다르다는 점이다. a가 양수인 2의 배수의 곱셈이나, 나눗셈같은 특정 상황에선 같을 수 있으나, 대부분의 계산에선 결과가 다르다. 산술 연산자는 일반적인 수학 계산, 혹은 배열의 인덱스 처리에서 사용되고 비트 연산자는 플래그를 조작한다던가, 속도를 최적화할 때 사용하기 때문이다.  a * 2^n보다 a << n의 계산이 훨씬 빠른 까닭이다. 특히나 C언어는 하드웨어 친화적인 언어라 비트 연산이 중요하게 사용될 경우가 잦다.  

 

 

 

 

 C언어에서는 한 프로젝트에서 여러 main함수를 사용할 수 없어서 내가 지금까지 해왔던 다른 언어들과는 달리 하나하나 속성을 바꾸어 주어야 한다. 일단 C언어의 기본 틀은 int main (void) { } 로 구성되고, 화면에 글자를 출력하기 위해선 printf("") 를 쓸 수 있겠다. 또다른 번거로운 점은, 이는 문자열을 기준으로 하기 때문에 형식지정자에 따라 출력할 데이터의 타입에 맞게 추가로 지정해주어야 한다는 것이다. 언어를 새로 배울 때면 파이썬이 얼마나 편리했는지를 체감한다. 가장 중요한 점은 C언어를 다룰 때는 모든 코드의 처음 전처리지시자를 작성해야만 기본적인 함수가 제대로 작동할 수 있다는 것. 실제 프로그램을 컴파일하기 전 전처리를 해주는 역할이라 보면 된다. 그중 하나가 #include <stido.h>이다. (프로그램을 짤 땐 맨 앞에 먼저 이를 작성하고 시작한다!)

 

 먼저, 형식지정자를 정리해보면 다음과 같다.

 

%d, %i 정수 (10진수) 10
%f, %lf 실수 3.141592
%.2f 소수점 2자리까지 3.14
%c 문자 A
%s 문자열 Hello C (공백포함)
%p 포인터 (주소) 0x7ffeebc0
%x 16진수 1A
%o 8진수 52

 

 여기서 포인터까지는 주로 쓰겠지만, 16진수나 8진수를 내가 공부하며 쓸 일이 있을까 싶기도 하다. 실무에서 어떤지를 모르겠으니... 왼쪽부터 형식 지정자, 타입, 예제이다. 문자열에서의 공백은 글자수로 계산되어 이후 배열을 다룰 때 공백 역시 크기를 차지하는 것을 알 수 있다. 

 

 C언어에서의 변수는 어떻게 사용할 수 있을까? 별다를 것은 없어보인다. 

 

int main(void) {
    int a = 10;
    int b, c;
    b = a;
    c = a + 20;
    double da;
    da = 3.5;
    char ch;
    ch = 'A';
}

 

 

 그럼 변수를 편하게 선언하기 위해 C언어의 자료형에 대해 정리해보자. 크게 정수형실수형으로 나눌 수 있다.

 

char 1 byte -128 ~ 127 0 ~ 255
short 2 byte -32,768 ~ 32,767 0 ~ 65,535
int 4 byte -2,147,483,648 ~ 2,147,483,647 0 ~ 4,294,967,295
long 4 byte  or 8 byte int와 같거나 long long과 같음 int와 같거나 long long과 같음
long long 8 byte -9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807 0 ~ 18,446,744,073,709,551,615

 

  위는 정수형 자료형이다. 왼쪽부터 순서대로 자료형, 크기, 부호가 있는 범위, 부호가 없는 범위이다. 모든 범위를 외워야 하는 것은 아니라 들었지만, 작을 때 쓰는 것과 클 때 쓰는 것 정도는 알아두어야 좋을 듯하다.  

 

float 4 byte 6 ~ 7 자리 ±3.4 × 10^(-38) ~ ±3.4 × 10^(38)
double 8 byte 15 ~ 16 자리 ±1.7 × 10^(-308) ~ ±1.7 × 10^(308)
long double 8 byte or 16 byte 19 ~ 20 자리 더 광범위함 

 

 위는 실수형 자료형이고, 왼쪽부터 순서대로 자료형, 크기, 유효 자릿수, 범위이다. 

그밖에도 자료형에는 배열, 포인터, 구조체 등 기본 자료형을 조합하여 만든 새로운 자료형(파생 자료형)이 있다.

 

 C 프로그램의 데이터 표현, 즉 자료의 표현 그중에서도 수치 자료의 표현은 10진수2진수를 다룬다. 컴퓨터 내부에서 표현할 수 있는 자료의 종류는 수치 자료와 함께 크게 문자 자료와 논리 자료, 포인터 자료 및 문자열 자료로 나누어 진다. 그렇다면 2진수의 정수 표현과 실수 표현은 어떻게 할 수 있을까?

 

 

[2진수 정수 표현]

 

 1. n비트의 부호와 절댓값 형식

 

 최상위 1비트에 부호를 표시

- 양수: 0

- 음수: 1

ex) 8비트로 양수 21과 음수 21을 표현할 경우,

  양: 00010101

  음: 10010101

 처럼 맨 앞 1비트에 정해진 부호를 표시한다. 

 

 

2. 1의 보수 형식

 

음수 표현에서 최상위 비트에 부호를 표시하는 것이 아닌 1의 보수의 형태로 변환

주어진 10진수 음수의 절댓값을 2진수로 변환

0 → 1 / 1 0 으로 모든 각 비트를 반전

 

ex) 8비트로 -21의 1의 보수를 구한다면, 

|21| 의 2진수 표현은 00010101

각 비트 반전 1110101021의 2진수 표현은 7비트에서 끝나므로, 앞에 여분의 '0'을 추가해 계산할 수 있다.

 

 

3. 2의 보수 형식

 

음수 표현에서 최상위 비트에 부호를 표시하는 것이 아닌 2의 보수의 형태로 변환

주어진 10진수 음수의 절댓값을 2진수로 변환

0 → 1 / 1  0 으로 모든 각 비트를 반전

나온 결과에 1을 덧셈 

쉽게 말해 1의 보수를 구하고, 1을 더하면 구할 수 있다.

 

ex) 8비트로 -21의 2의 보수를 구한다면, 

|21| 의 2진수 표현은 00010101

각 비트 반전 11101010

값에 1을 더하여 11101011을 만듦

 

 

[2진수 실수 표현]

 

 1. 고정 소수점 형식

 

소수점이 항상 최상위 비트 왼쪽에 고정되어 있는 것으로 취급

소수점 앞의 값을 2진수화

⑵ 소수점 뒤의 값을 2진수화 (이때, 소수 부분은 나누기가 아닌 곱하기의 형태로 구할 수 있다.)

⑶ 그대로 합치며 둘의 사이에 소수점을 추가

 

ex) 5.625의 2진수를 고정 소수점 형식으로 구한다면,

5의 2진수화는 101

 0.625의 2진수화는 101

둘을 합치면 101.101

 

 

2. 부동 소수점 형식

 

현재 컴퓨터에서 사용중인 표현 형식 (IEEE 754)

4 바이트(32비트)를 사용하는 단정도 형식과

8 바이트(64비트)를 사용하는 배정도 형식으로 구분

 

고정 소수점 형식과 동일하게 10진수를 각각 2진수화

⑵ 부호부 양수: 0 음수: 1

정규화: 소수점 왼쪽에 1 하나만 놓이도록 설정, 2의 지수를 구한다.

⑷ 정규화하며 구해놓은 2의 지수 + 127

배정도 형식이라면 + 1023 

더한 값을 2진수화하여 지수부 계산

⑸ 단정도와 배정도 형식의 남은 비트수에 따른 비트수로 정규화 후의 소수점 아래 부분을 가수부로 채택

 

ex) 5.625의 2진수를 부동 소수점 형식으로 구한다면,

 5.625의 2진수화는 101.101

 정규화: 1.01101 * 2^2 (왼쪽으로 두 칸 이동)

양수이므로 부호부는 0

⑷ 2 + 127 = 129

129의 2진수화 10000001

⑸ 01101 아래를 23비트가 될 때까지 0으로 채워넣고 부호부 + 지수부 + 가수부 형태로 만듦

따라서 0 10000001 01101000000000000000000

 

 

 이처럼 컴퓨터가 데이터를 어떻게 다루는지를 이해하면, 프로그래밍과 자료구조를 더 쉽고 근본적으로 접근할 수 있다.

+ Recent posts