순차 자료구조는 삽입 연산이나 삭제 연산 후 연속적인 물리 주소를 유지하기 위해 원소들을 이동시키는 추가 작업 및 시간이 소요되는 문제점이 있다. 또한, 배열을 이용하는 순차 자료구조는 배열이 가진 메모리 사용의 비효율성 문제까지 동반하기 때문에, 이를 개선한 자료 표현 방법인 연결 자료구조가 필요하다. 

 

 연결 자료구조는 자료의 논리적 순서와 물리적 순서가 불일치하고, 각 원소에 저장되어 있는 다음 원소의 주소에 의해 순서가 연결되어 있는 방식을 이룬다. 즉, 물리적인 순서를 맞추기 위한 오버헤드가 발생하지 않는다. 덧붙여 크기 변경이 유연하며, 메모리 사용에 효율적이다. 리스트의 연결 자료구조 표현을 '연결 리스트'라고 부를 수 있다. 연결 리스트엔 단순 연결 리스트, 원형 연결 리스트, 이중 연결 리스트, 이중 원형 연결 리스트의 방식이 존재하며, 먼저 단순 연결 리스트에 대해 알아보도록 하겠다.

 

 연결 리스트의 원소 표현 단위 구조는 '노드'로 정의된다. 노드 데이터와 다음 노드의 포인터가 저장되어 있으며, 이러한 노드가 사슬처럼 이어진 구조가 연결 리스트이다. 다음과 같은 구조라 이해하면 쉽다.

 

[데이터 | 다음 노드 포인터] → [데이터 | 다음 노드 포인터] [데이터 | NULL]

 

 이때 노드의 데이터가 위치한 곳을 데이터 필드, 다음 노드의 주소가 저장된 곳을 링크 필드라 부른다. NULL은 아무것도 가리키지 않는 값이다.

 

 연결 리스트는 순차 리스트에 비해 삽입삭제가 자유롭다. 그렇다면 노드를 어떻게 삽입할 수 있을까? 주소, 다시 말해 포인터를 고쳐서 노드를 끼워 넣으면 된다. 단순히 새로운 노드를 만들고, 앞 노드의 포인터를 만든 새 노드를 가리키게 바꾼 다음 새 노드가 뒷 노드를 가리키게 만들면 되는 구조다. 포인터 순서를 바꿔주는 것이 전부인 것이다. 이를 코드로 보면 다음과 같다. [A] [B] [C] 라는 리스트에서 B와 C 사이에 새로운 노드를 끼운다 가정하자.

 

Node *newNode = (Node*)malloc(sizeof(Node)); // 새 노드 생성 
newNode->data = 'X';                         // 데이터 할당
newNode->next = B->next;                     // 새 노드가 B의 다음을 가리킴
B->next = newNode;                           // B가 새 노드를 가리킴

 

 next는 다음 노드의 위치를 저장하는 포인터로서 기능한다. 여기서 C나 A는 전혀 건들 필요 없고, B와 새로이 만든 노드만 조작해도 삽입이 가능하다. 위의 코드를 뜯어보면, 새 노드가 B->next, 즉 C를 기억하게 만들고 B가 새 노드를 기억하게 만든 것이 다다. 

 

A->next = B->next;  // A가 B를 건너뛰고 C를 가리킴
free(B);            // B를 메모리에서 제거

 

 위는 같은 [A]  [B]  [C] 구조에서 B를 삭제하기 위한 코드로 구현한 것이다. 연결 리스트에서 노드의 삭제를 위해선 삭제할 노드의 앞 노드를 찾고, 그 앞 노드에 삭제할 노드의 링크 필드값을 저장한다. 그 후 앞 뒤를 연결한다. B를 메모리에서 해제하는 것이다. 지금까진 중간 지점에서의 삽입과 삭제를 알아봤다면, 맨 앞이나 맨 뒤의 특수 경우에서의 삽입과 삭제에 대해서도 알아보자.

 

 리스트에는 head라는 리스트의 시작을 가리키는 포인터가 존재한다. 연결 리스트는 배열처럼 인덱스로의 접근이 불가하므로 head를 통해 리스트의 시작 지점을 건드릴 수 있다. head는 리스트의 출력 대상이 아니며, 입구의 참조점이다. 가시적인 데이터가 아닌 리스트를 건들기 위한 시작 지점이라 이해할 수 있겠다. 따라서 [A]  [B]  [C]의 맨 앞에 X를 삽입한다고 하면,

 

Node *newNode = (Node*)malloc(sizeof(Node));
newNode->data = 100;
newNode->next = head;  // head를 가리킴
head = newNode;        // head가 newNode를 가리키도록 변경

 

 처럼 된다. 중간 삽입과 형식은 일치하되 head인 것만 다르다. 만약 A를 삭제하는 경우엔, A는 맨 앞에 위치한 노드로 임시로 A를 저장하게 한 뒤 삭제해야 한다. 따라서 이런 구조가 된다.

 

Node *temp = head;
head = head->next;  // head를 다음 노드로 갱신
free(temp);         // 원래의 첫 노드 제거

 

 이는 첫 노드를 제거하고 시작점을 옮기기 위함이다. 맨 뒤에서의 삽입과 삭제는 head와 비슷하게 NULL이라는 값을 통해 진행할 수 있다. 삽입에선 새 노드가 NULL을 가리키도록 하고, 기존의 마지막 노드가 새로운 노드를 가리킨다. 삭제에선 마지막에서 두 번째 노드가 연결을 끊는 방식으로 삭제를 진행한다. 코드로 보면 이러하다.

 

// 마지막 노드 삽입 
Node *newNode = (Node*)malloc(sizeof(Node));
newNode->data = value;
newNode->next = NULL;
// 마지막 노드 삭제
temp = head;
while (temp->next->next != NULL)
    temp = temp->next;
free(temp->next);
temp->next = NULL;

 

 마지막 노드의 삭제가 이렇게 번거로운 이유는 연결 리스트가 배열이 아니기 때문에 인덱스로 접근할 수 없어 직접 몇 번째 노드인지 지정하지 못하기 때문이다. 따라서 무조건 처음(head)부터 시작해서 반복문을 통해 temp 포인터로 노드를 찾아주어야만 한다. 여기서 'temp'란 연결 리스트 안을 이동하며 작업하는 '작업 포인터'라고 이해하면 된다. 위 코드를 분석해본다면, if문들은 예외처리가 되고, temp->next->next != NULL 조건이 참인 동안 반복하여 마지막 앞 노드에 도달하게 만드는 방식이다. 이후로는 temp를 통하여 마지막 노드를 삭제할 수 있다. 노드의 next NULL로 바꾸어 끝을 재설정하는 것까지가 삭제 코드가 된다.

 

 실제 연결 리스트에선 노드 구조체를 만들어야 한다. 이는 연결 리스트에서 가장 핵심이 되는 부분이다!

typedef struct Node {
    int data;
    struct Node* next;
} Node;

 

 Node는 구조체 이름이고, struct는 구조체를 선언하겠다는 의미이며 typedef는 구조체를 짧게 줄여 부를 수 있도록, 즉 별명을 붙여줄 수 있도록 하는 키워드이다. 이로인해 우리는 struct Node가 아닌 Node라 간결히 정의한 구조체를 부를 수 있다. 다음의 int data;는 이 구조체 안에 정수형 데이터를 저장하겠다는 뜻으로, 한 노드가 담고 있는 실제 값을 의미한다. 데이터필드에 정수형 데이터를 받을 준비를 하고 있다는 선언인 것이다. struct Node* next;는 다음 노드를 카리키는 포인터인 next를 이용하여 이 노드가 어떤 대상과 이어질지를 나타내는 의미이다. 마지막 줄의 } Node;는 구조체를 Node라 칭하겠다는 뜻이다.

 

 이렇게 노드 구조체를 정의하는 이유라 함은, 순차 리스트의 배열과는 달리 연결 리스트의 노드는 사용자가 직접 만들어야하는 개념이기에 그러하다. C언어는 연결 리스트를 제공하지 않는다. 그래서 우리는 연결 리스트를 사용하기 위해 맨 처음 노드라는 추상적 개념을 구조체로 정의하여 C언어에서의 프로그래밍을 진행하게 된다. 

 

 

 앞서 재귀호출을 정리하면서, 대표적인 예시 중 하나인 팩토리얼을 재귀함수로 표현하는 코딩은 연습해봤다. 다른 예시로는 피보나치 수열하노이 탑이 존재하는데, 둘 다 보다 복잡하고 어려운 재귀 구조 문제이므로 이해를 위한 정리가 필요하다. 

 

 피보나치 수열은 0번째 수가 0, 첫 번째 수가 1로 정의되고 그 이후론 직전의 두 수 의 합의 값이 계속해서 나열되는 수열이다. 0, 1, 1, 2, 3, 5, 8, 13, 21, 34 … 처럼 나열된다고 보면 된다. 이 규칙 자체가 이전 두 수를 기반으로 정의되어있으므로, 이를 재귀 로직으로 구성하여 코드로 표현해볼 수 있는 것이다. 팩토리얼이 n * factorial(n - 1)로 정리되었던 것과 유사하게 피보나치 수열은 fibonacci(n-1) + fibonacci(n-2)로 정리된다. 이때 베이스 케이스는 두 개가 된다. 이는 피보나치 수열이 두 갈래로 나누어져 진행되기에 f(0)과 f(1)의 두 베이스케이스를 모두 필요로하기 때문이다. 코드를 구성해보면 이렇다.

 

int fibonacci(int n) {
    if (n == 0) return 0;       
    if (n == 1) return 1;       
    return fibonacci(n-1) + fibonacci(n-2);  
}

 

 베이스 케이스가 0과 1로 규정되는 것은 피보나치 수열의 초반 값이 0과 1로 정해져있는 탓이다. retrun값은 main함수가 아니므로 값을 내놓는 동작으로 작동한다.

 

 하노이 탑은 훨씬 복잡하다. 하나의 축의 크기가 다른 원반이 쌓여있고, 작은 원반 위에 큰 원반이 놓여지지 않도록 하는 규칙에 따라 제 3의 축을 이용하여 한 번에 한 장씩 다른 축으로 움직여 원반을 이동시키는 문제이다. 즉, 작은 것 위에 큰 것이 놓이면 안 된다는 규칙을 지키면서 모든 원반을 이동시켜야 하는 것이다. 이런 하노이 탑 퍼즐을 일반화한다면 3단계로 나눌 수 있다. 

 

hanoi(n, start, target, work)

n: 옮길 원반 개수

start: 현재 원반이 있는 기둥 (시작봉)

target: 옮길 목표 기둥 (목적봉)

work: 거쳐가는 보조 기둥 (작업봉)

 

 1단계: 시작봉(start)에 있는 원반 1 ~ n-1개를 목적봉(target)을 이용하여 작업봉(work)으로 옮긴다. 

목적봉(target)은 보조 역할만 한다.

예를 들어, 원반이 3개이면 원반 1, 2만 임시로 작업봉에 옮기는 것

 → hanoi(n-1, start, work, target);

 

2단계: 시작봉(start)에 있는 가장 큰 마지막 원반(n)을 목적봉(target)으로 옮긴다. 

더이상 쪼갤 수 없으니 직접 실행

hanoi(1, start, work, target);

 

3단계: 작업봉(work)에 있는 원반 1 ~ n-1개를 시작봉(start)을 이용하여 목적봉(target)으로 옮긴다.

시작봉(start)은 보조 역할이다.

1단계에서 잠시 옮겨둔 원반들을 목적지까지 옮겨주는 마지막 작업

 hanoi(n-1, work, target, start);

 

 

void hanoi(int n, char start, char target, char work) {
    if (n == 1) {
        printf("원판 1을 %c에서 %c로 옮긴다.\n", start, target);
        return;
    }

    // 1단계
    hanoi(n-1, start, work, target);

    // 2단계
    printf("원판 %d을 %c에서 %c로 옮긴다.\n", n, start, target);

    // 3단계
    hanoi(n-1, work, target, start);
}

 

 일반화한 단계로 정리한 하노이 함수 코드는 이렇게 정리된다. n이 3일 때의 완전한 코드 예시는 다음과 같다:

 

#include <stdio.h>

void hanoi(int n, char start, char target, char work) {
    if (n == 1) {
        printf("원판 1을 %c에서 %c로 옮긴다.\n", start, target);
        return;
    }

    // 1단계
    hanoi(n - 1, start, work, target);

    // 2단계
    printf("원판 %d을 %c에서 %c로 옮긴다\n", n, start, target);

    // 3단계
    hanoi(n - 1, work, target, start);
}

int main() {
    int n = 3; 

    printf("하노이의 탑: 원반 %d개\n\n", n);
    hanoi(n, 'A', 'C', 'B');  

    return 0;
}

 

 실행 결과는 다음과 같다:

 

하노이의 탑: 원반 3개

원반 1을 A에서 C로 옮긴다.
원반 2을 A에서 B로 옮긴다.
원반 1을 C에서 B로 옮긴다.
원반 3을 A에서 C로 옮긴다.
원반 1을 B에서 A로 옮긴다.
원반 2을 B에서 C로 옮긴다.
원반 1을 A에서 C로 옮긴다.

 

 

 이제 알고리즘의 단계 중 하나인 가상코드에 대해 알아보자.

 

 가상코드(Pseudo-code, 알고리즘 기술언어ADL)란 프로그래밍 언어와 비슷하지만, 실제로 실행되진 않는 알고리즘 표현 언어이다. 가상코드는 다른 프로그래밍 언어와는 달리 문법이 자유롭고 핵심을 간결하게 표현 가능하다. 알고리즘의 다섯 요소가 입력, 출력, 명확성, 유한성, 수행 가능성임을 염두에 두고 가상코드를 이용해 알고리즘을 추상화할 수 있다. 가상코드의 형식은 기호, 자료형, 연산자로 정리된다. 프로그래밍 언어와 기본적인 문법은 거의 동일하다. 

먼저 지정문의 형식은 이러하다.
변수 ← 값 (대입)
프로그래밍 언어에선 '='처럼 쓰이고, 의미는 ‘우측 값을 좌측에 저장’이다. 예를들어, 가상코드로 x ← 0로 표현한 식은 추후 C로는 int x = 0;이라 구체화할 수 있다.

다음은 주로 쓰는 기본 문법을 정리한 것이다.

 

1. 조건문

if (조건식) then (명령문)
else (명령문2)
조건에 따라 실행할 명령문이 결정되는 선택적 제어구조
조건식은 어떠한 참 거짓 여부를 판별하는 문장이 들어간다. 따라서 명령문은 조건이 참일 때 실행할 내용이 된다. 프로그래밍 언어의 if문과 구조가 완전히 일치한다.

중첩 if문은,
if (조건식1) then (명령문1);
else if (조건식2) then (명령문2);
else (명령문3);
다단계 조건문의 제어 흐름은 조건식1의 참과 거짓에서 한 번 분리되고, 조건식1이 거짓일 경우 조건식2로 참 거짓을 판별한 뒤 또 한 번 판별값에 따라 명령문2와 명령문3 중 특정 명령문이 실행된다. 

case문은,
case {
    조건식1 : 명령문1;
    조건식2 : 명령문2;
   
    조건식n : 명령문n;
    else : 명령문 n+1;
}
로 표현된다. 이는 여러 경우로 분류되는 경우에 사용된다. 변수가 없는 case문은 다중 if-else문을 case문으로 표현한 것이라 보면 쉽다.


2. 반복문

for (초깃값; 조건식; 증감값) do 명령문
이는 일정한 명령을 반복 수행하는 루프 형태의 제어구조로, for i ← 1 to n do 처럼 쓰일 수 있다. 이 문장을 뜯어보자. 앞서 정리한 지정문에서의 변수 표현처럼 반복에 사용하는 변수인 i에 1이라는 값을 대입한다. 즉, 1부터 시작하겠다는 뜻이다. to nn까지 반복하겠다는 의미이며, do 이후는 지정된 명령문으로 실행된다. 

 

while문은,
while (조건식) do (명령문)

어떤 조건이 참인 동안 계속해서 명령문을 반복 실행한다.

 

do-while문은,

do (명령문);

while (조건식);

일단 명령문을 한 번 실행한 후에 조건식을 검사하여 조건식이 참인 동안 명령문을 반복 수행하는 구조로 이루어져있다.

 

 

3. 함수문

 

function 이름(매개변수들)

    명령문;

   

    return 결과값;

동작을 묶은 것으로, 어떤 입력을 받아 어떤 출력을 만들어주는 도구이다. 즉, 처리작업 별로 모듈화하여 만든 부 프로그램인 것이다. 매개변수들은 함수가 필요로하는 입력값들이고, return 값은 함수에서 결과값을 내보낼 때 사용한다. 함수 가상코드의 예시는 이렇다.

 

function add(a, b)
    return a + b

 

이는 a, b에 따른 a + b의 출력값을 돌려주는 간단한 덧셈 함수이다. 다음은 반복문을 사용하는 함수의 예시로, 가상코드가 어떻게 쓰여지는지 잘 나타낸 예시이다. 

 

function sumS(n)
    sum ← 0
    for i ← 1 to n do
        sum ← sum + i
    return sum

 

이 가상코드를 뜯어보면, 함수 이름은 sumS이고, 매개변수 n을 받는다. 또한 변수 sum에 0을 대입하고, 반복문을 통해 1부터 n까지의 숫자를 더하여 sum에 저장하는 구조를 만들었다. 이후 모든 연산을 마쳐 숫자가 더해진 sum을 결과로 돌려준다. 이러한 가상코드를 재귀호출 구조로 구체화하면,

 

#include <stdio.h>

int sumS(int n) {
    if (n == 1) {
        return 1;  
    }
    return n + sumS(n - 1); 
}

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

    int result = sumS(n);
    printf("1부터 %d까지의 합은 %d입니다.\n", n, result);

    return 0;
}

 

 이처럼 정리될 수 있다.

 

 

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

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언어는 하드웨어 친화적인 언어라 비트 연산이 중요하게 사용될 경우가 잦다.  

 

 


가볍게 토론주제를 하나씩 던져

오늘의 토론을 했던 것들의 기록 (정제되지 않은 글)

같이 떠들어준 모두에게 감사하다.

 

 

[8]

 

 

 

 

ㅎ:  그런 맥락에서 동물실험은 어떻게 생각하시나요 그리고 조금 더 나아가서 살아있는 생물을 "고문"하듯이 요리하는 것은 법적으로 금지되어야 하는게 맞다고 생각하시나요?

 

  •  

ㅅ:  동물실험 얘기 하니까 동물권 얘기도 해야할거같은데 흠 일단 고문하듯 요리하는 건... 굳이? 의 감상이고 저는 동물학대를 일삼는 사람은 분명 인간에게도 해악을 끼칠거라는 생각이 있거든요 이와 같은 맥락에서 날 것으로 먹는 음식이 아닌데도 굳~이 빨리 죽이지 않고 오락의 목적으로 그런식의 요리를 한다면... 그 사람의 인간성이 의심되는 동시에 스스로의 인간성을 해친다고 느껴집니다. 법적으로 금지까지는 잘 모르겠어요 유럽권에선 이게 시행된 나라가 있을 거 같긴 한데 하나하나 잡아낼 수가 있나? 싶어가지고 (법을 반대한다기보단 실질적으로 도움이 되나 싶음...) 저는 개인적으로 동물을 정말 좋아하거든요 거의 지구상 대부분의 동물을 좋아하는 거 같아요 그래서 개인적으론 뭐 일정선상의 지능이 있고 고통을 느낄 줄 아는 동물이라면 마땅히 어느정도의 존중는 해야할 필요가...? 있지 않을까... 걔네를 인간보다 우선해야한다(x) 얻을 게 없는데 괴롭히진 말아라(o) 식물계 생물들까진 잘 모르겠어요 또 이 주제에선 비거니즘을 빼놓을 수가 없는데 전 인간은 잡식으로 태어난 이유가 있다 생각하지만 기후문제의 측면에선 납득하게 됩니다 ...

 

ㅎ:  흠 근데 제가 생각했던건.. 오로지 음식용 재료가 되기 위해 살아있는 동안부터 과하게 살찌우거나 특정 음식만 계속 먹게 하던가, 꼭 포유류가 아니더라도 생낙지나 추어탕에 미꾸라지를 생으로 넣는다거나.. 그런? 육류를 제공하는 동물에 대한 동물권은 꽤 높아지긴 했지만 해산물이나 다른 것들까지도 유럽같은 곳에선 법적으로 금하기 시작했다는 얘기를 들은 적이 있어서 어느 지점에서 허용되고 허용이 안되는지 구분선을 어떻게 정해야 할 지에 대한 고민이 좀 있는 것 같아요 저도 실제로 제대로 시행되고 있는지도 잘 모르기도 하지만..ㅎㅎ..
인간은 잡식성이 맞아서 어떻게든 단백질을 섭취해야 제대로 된 성장이나 건강을 유지할 수 있어서.. 근데 콩이라던지 채식류에서 얻을 수 있는 단백질은 한계가 있고 진짜 육류에 비해 너무나 터무니 없이 적어서 따로 보충제를 먹는다는 얘기를 들었던 것 같아요 실제로 영양실조같은 증상을 겪는 채식주의자가 있다는 얘기도 몇 번 들었었고요 그래서 절에 있는 아이들에겐 고기반찬을 제공한다는 얘기도 들었었는데 확실히 음.. 비거니즘을 하는 사람들을 존중하긴 하지만 그걸 남한테 강요하는 순간 문제가 되는게 아닐까 라는 생각을 하게 되는 것 같아요
동물실험 면에서는 약이나 화장품에서 특히.. 제가 봐왔던 것들은 가둬두고 실험만 계속 하는.. 동물을 배려하지 않는? 그런 환경 뿐이었는데 그렇지 않은 곳이 과연 존재할지도 좀 궁금하네요 동물실험으로 많은 이득과 결과를 보는 것도 사실이긴 하지만 과하다는 생각에도 동의하는 편이라.. 근데 그렇다고 모든 실험체에게 좋은 환경을 제공하면서 실험을 계속하면 제품의 가격도 당연히 올라갈 수 밖에 없어서 어떤게 답인지 잘 모르겠다는 생각이 드네요
약간 얕은 지식만을 가진 분야여서 뭔가 확신하면서 대답하기가 애매하네요(...)

 

 

ㅅ:  해산물까지 법적으로 금하는 나라가 있다니 처음 듣는 얘기고 너무 신기한데. 맞아요 채식만 한다면 인간의 필수 영양분을 제대로 채우지 못 해서 건강치 못하게 되더라구요 제가 동물을 좋아한다고 말은 했지만 어쨌거나 자연에선 육식동물이 채식동물을 잡아먹고 잡식동물은 풀과 동물 둘 다 먹고 채식동물은 풀을 먹고... 당연한 거잖아요 저희 인간도 똑같거든요 (물론 언급했듯 과도한 도축은 지양함) 이런 맥락보단 전 환경 문제로 비건을 바라보게 되는 것 같아요 제가 자주 보는 유튜브 채널중에 독일에서 만든 쿠르츠게작트라는 채널이 있어요 거기에 육류에대한 비판 영상이 있는데, 인간이 배출하는 온실가스의 15퍼센트 정도가 육류 업계에서 나온다는 얘기를 해줘요. 인류의 미래지향적인 삶을 위해서라도 고기 소비를 줄이는 게 맞죠... 말하다보니 이건 씨가 물어본 것과는 결이 다른 말이긴 한데 아무튼 동물실험은 저도 완전히 금지할 수 없다고 생각하는 쪽이고... 그렇다고 인간에게 실험을 행하는 건 좀 더 큰 사회적 반발을 야기할 게 분명하거든요 사실 과학의 발전;;이 이루어지려면 가설설정과 실험은 빼놓기 힘들지 않나. 당장 동물실험을 막는다면 개발될 수 있는 제품들이 한정될 거 같네요 무조건 금지해라!! 라기엔 확실한 대안을 제시하고 금지하라 해야될 것 같습니다. (그 대안이 현재로서 없는 게 문제지만)

 

 

ㅎ:   제가 말을 잘못. 했.. 는데 법적으로 금하는 나라가 있는지는 잘 모르고 살아있는 생물을 생으로 익히거나 삶는건 비인도적이다 라는 목소리가 나왔던게 10년?정도 된 걸로 기억하는데 그 이후로 발전이 좀 있었을지 어떻게 됐는진 정확히 모르네요 .. 맞아요 전에 어떤.. 영화?라기엔 좀 더 다큐?같은 걸 봤는데 그런 온실가스로 인해 생기는 지구온난화로 중동쪽의 농사가 어려워지자 농업을 생계로 하던 사람들이 ISIS에 돈을 받고 가입하며 점점 조직을 키우고 더 큰 나비효과로 사회적으로도 문제를 일으킨다는 내용이었거든요 그런 연결점이 있는건 좀 새로웠다고 해야하나 보통 환경문제라고 하면 건강이나 자연쪽으로만 생각하기 마련인데 그렇게 사회로 이어지는 것도 되게 놀라운 관점이었던 기억이 있네요 소가 특히 온실가스도 배출하지만 방목형으로 키우게 되면 소가 돌아다니며 무게에 밟힌 땅은 몇년간 농사를 할 수 없는 땅이 되어버려서 식량의 양으로 봐도 굉장히 손해라는 얘기도 들은 적 있고요 동물실험에 대해서는 저도 서헌씨랑 같은 의견인 것 같습니다.. 얼른 새로운 대안이 나왔으면 좋겠네요😢 하 오토주 생각나서 달려온건데 막상 답하다보니 오토주가 뭐였는지를 까먹어 버렸어요 . . 동물관련이었던 것 같은데. .

 

 

 

 이밖에도 정말 다양한 사람들과 다양한 주제로 수많은 토론을 했었는데, 텍스트의 형식으로 정리하기 번거로워 아쉽다. 언제 한 번 날잡고 가볍게나마 몇 개라도 정리할 수 있는 날을 만들어야겠다.

 

 

 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

 

 

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

 

 코딩을 배우며 항상 조건문 파트부터 진짜 코딩이 시작되는 듯한 기분을 느낀다. 다른 사람은 어떨지 모르겠지만 난 그렇다. 이번 섹션은 조건문이고, 늘 그래왔듯 if문과 else를 배우지 않을까 싶다. 

 

 조건문이란 특정 조건에 따라서 다른 코드를 실행하는 것을 일컫는다. 자바의 조건문엔 if문, switch문이 있다고 한다. 먼저 if문부터 알아보자.

 

 본 코드를 분석해보면, age의 값은 20이기 때문에 "성인입니다."가 출력되는 것을 알 수 있다. 이때 age의 값은 18보다 작지 않으므로 "미성년자입니다."는 출력되지 않는다. 이는 if (false) { "미성년자입니다." } 와 같고, 조건이 거짓으로 판명되기에 해당 코드블록은 실행되지 않는다.(18보다 작지 않기에 false가 되는 것) 이때 코드를 else를 사용해서 좀 더 간단히 바꿀 수 있다.

 

else는 '그렇지 않으면'으로, 이 코드는 위의 코드와 동일하다고 봐도 무방하다.

 

 

 else if문을 사용하면 좋은 점을 알아보기 위해 본 코드를 먼저 작성해보았다. 이 코드는 작동은 원활하게 되지만, 분명한 단점이 있다. 이미 조건을 만족해도 불필요한 다음 조건을 계속해서 검사한다는 것이다. 이미 앞서 조건이 성립되어 결과가 출력되어도, 나머지 if문을 통한 조건 검사를 모두 실행해야만 한다. 또한, 예시로 나이가 8살인 초등학생이라면 미취학 아동임을 체크하는 조건인 'age <= 7'을 통하여 이미 나이가 8살이 넘었다는 사실을 알 수 있다. 그러나 바로 다음 초등학생을 체크하는 조건에서 'age >= 8 && age <= 13'이라는 2가지 조건을 또다시 모두 수행한다. 조건을 중복 체크하게 되는 것이다. 이 코드에서 else if문을 사용하면 이러한 문제점을 해결할 수 있다! else if문은 앞선 if문의 조건이 거짓일 때 다음으로 나오는 조건을 검사하고, 앞선 if문이 참일 경우 실행되지 않는다. 따라서 코드 효율성을 높일 수 있다.

 

 

 else if문으로 코드를 바꾸어 보았다. 훨씬 보기 간편하고, 중복 체크가 발생하지 않는다. 그러나 모든 코드에서 둘을 혼합해 사용할 수 있는 것은 아니다. if문에 else if를 함께 사용하는 것은 서로 연관된 조건일 때 사용한다. 서로 관련이 없는 독립 조건에선 if문을 각각 따로 사용해야 한다. 

 

if문을 따로 쓰는 예제

 

 만약 이 예제에서 else if문을 추가해 코드를 병합해 버린다면, 앞선 price >= 10000의 조건이 참이되어 수행되었기 때문에 다음으로 넘어갈 수 없이 중간에 빠져나와 출력값이 다르게 된다. 때문에 각각 달리 풀어서 써야하는 것이다.

 

 조건문에는 if문, else if문 말고도 switch문이 존재한다. 

 

 

 이때, break문이 없다면 아래에 있는 코드까지 수행하기 때문에 break문을 넣어 멈추게 해주어야 한다. switch문은 break문과 함께해야 코드를 수월하게 짤 수 있다. if문과 달리, switch문은 참 거짓의 결과가 나오는 조건이 아닌 단순히 값만 넣을 수 있다. 조건식이 특정 case와 같은지만 체크할 수 있는 것이다. 쉽게 말해 값이 같은지 확인하는 연산(문자)만 가능하므로, 사실상 if문만 사용할지라도 조건식을 구성하는 데엔 문제가 없다. 다만 특정 값에 따라 코드를 실행할 때 switch문을 사용한다면 if문 보다 간결한 코드를 작성할 수 있을 것이다. 하지만 이는 이론적인 이야기고... 둘의 차이는 미미하기 때문에, 자바14에서 기존과는 다른 switch문이 등장했다고 한다.

 

 

 훨씬 깔끔해졌다! 자바 스크립트의 화살표 함수가 생각나기도 한다. 한 땐 코딩 테스트 연습을 할 때 화살표 함수를 이용하여 최대한 짧은 코드를 지어내는 데 꽂혔었는데, 이젠 다 까먹어 버린 듯. 꾸준히 할 수 있다면 참 좋을텐데.

 

이 예제는 참과 거짓에 따라 status 변수의 값이 달라지는데, 이를 삼항연산자를 통해 단순화할 수 있다.

 

매우 짧고 간결하다. 자바에서 유일하게 항이 3개이므로 삼항연산자라 부르는 것이다. 이제 문제를 풀어보자.

 

문제1: 학생의 점수를 기반으로 학점을 출력하는 자바 프로그램을 작성하자. 다음과 같은 기준을 따른다.

 

90점 이상: "A"

80 - 90점: "B"

70 - 80점: "C"

60 - 70점: "D"

60점 미만: "F"

 

내 풀이다. 통과. 문제2는 거의 비슷하기에 패스하겠다.

 

문제2: 특정 금액을 미국 달러에서 한국 원으로 변환하는 프로그램을 작성하자. 환율은 1달러당 1,300원이라 가정한다.

 

달러가 0 미만: "잘못된 금액입니다."

달러가 0일 때: "환전할 금액이 없습니다."

달러가 0 초과: "환전 금액은 ( ) 입니다."

 

내 풀이다. 일단은 통과지만, 환율 계산을 dollar값이 0 초과일 때만 되도록 else 뒤에 넣었으면 논리적 오류도 없었을 것이다.

 

문제3: 요청한 평점 이상의 영화를 찾아 추천하는 프로그램을 작성하자.

 

어바웃타임: 9점

토이스토리: 8점

고질라: 7점

 

내 풀이다. 당연히 틀린 답변.

 

강사님의 풀이에 따라 수정한 코드

 

 중첩되어 출력되려면 어떻게 해야하는지 헷갈렸는데, else if문이 아닌 if문을 써야 했으며 범위 또한 수정했다. 이 문제에선 조건이 중복 체크되어야 하므로 if문을 써야하는 듯하다. 

 

문제4: 학점에 따른 성취도를 출력하는 프로그램을 작성하자.

 

A: "탁월한 성과입니다."

B: "좋은 성과입니다."

C: "준수한 성과입니다."

D: "향상이 필요합니다."

F: "불합격입니다."

나머지: "잘못된 학점입니다."

 

내 풀이다. 통과.

 

문제5: 두 개의 정수 a와 b를 가지고 있을 때 a의 값은 10, b의 값은 20이다. 삼항 연산자를 사용하여 두 숫자 중 더 큰 숫자를 출력하는 코드를 작성하자.

 

내 풀이다. 통과. 문제 6 역시 따로 적지 않겠다.

 

 이렇게 정리를 마무리로 섹션 5의 조건문이 끝났다. 그런데 자바를 좀 배우나 싶더니, 내가 이번에 배워야할 자료구조 프로그래밍 언어는 C언어라는 것을 알게되었고... 참 곤란해졌다.;; 아무래도 C언어를 시작하는 게 급선무일 듯한데. 정말 아무것도 몰라서 걱정이다. 일단은 자바 또한 틈틈이 건드려보는 것이 좋겠다.

+ Recent posts