테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

배열·리스트·스택·큐

값을 한 줄로 늘어놓는 자료구조들이다. 어디에 넣고 어디서 꺼내는지가 서로 다르다.

목차 (6)
  1. 배열과 동적 배열
  2. 연결 리스트
  3. 스택: 마지막에 넣은 것부터
  4. 큐와 덱
  5. 원형 버퍼
  6. 연산별 비용 한눈에 보기

배열과 동적 배열

배열(array)은 같은 크기의 칸을 메모리에 빈틈없이 이어 붙인 것이다. 칸 크기와 시작 주소만 알면 i번째 칸의 주소는 시작 + i × 칸 크기로 곧장 나온다. 그래서 몇 번째 칸이든 읽고 쓰는 데 O(1)이다. 이것을 임의 접근(random access)이라 한다. 칸이 붙어 있으니 훑을 때 캐시도 잘 맞는다(복잡도와 기초 노트의 캐시 지역성 절).

배열의 메모리 배치: 4바이트 칸 여덟 개가 1000번지부터 이어져 있고 앞 다섯 칸에 값이 들어 있다. a[3]의 주소는 1000 + 3 × 4 = 1012다capacity = 8(잡아 둔 칸)size = 5(쓰는 칸)42a[0]100017a[1]10048a[2]100823a[3]10124a[4]1016a[5]1020a[6]1024a[7]1028번지a[3]의 주소 = 시작 1000 + 3 × 4바이트 = 1012몇 번째 칸이든 계산 한 번이면 된다: O(1) 접근값이 든 칸여유 칸(다음 넣기가 쓴다)
4바이트 정수 여덟 칸을 잡은 동적 배열에 값 다섯 개가 들어 있다. a[3]의 주소는 1000 + 3 × 4 = 1012로, 앞 칸을 하나도 보지 않고 계산한다. 뒤의 세 칸은 다음 넣기를 위해 비워 둔 여유 용량이다.

대가는 크기다. 고정 배열은 만들 때 크기를 정해야 하고, 가운데에 값을 넣거나 빼려면 그 뒤의 값을 모두 한 칸씩 밀거나 당겨야 해서 O(n)이 든다. 메모리를 직접 들여다보면 이 배치가 그대로 보인다(헥스 뷰어).

동적 배열(dynamic array)은 크기 문제를 푼다. 실제로 잡아 둔 칸 수(capacity)와 쓰는 칸 수(size)를 따로 들고, 가득 차면 두 배쯤 큰 배열을 새로 잡아 옮긴다. 끝에 넣기는 분할 상환 O(1)이다(증명은 복잡도와 기초 노트의 분할 상환 절). C++ vector, Java ArrayList, 파이썬 list, JavaScript 배열이 모두 이 구조다. 주의할 점은 늘어날 때 주소가 바뀐다는 것이다. 늘기 전에 받아 둔 포인터나 참조는 옛 메모리를 가리키게 된다.

연결 리스트

연결 리스트(linked list)는 값을 노드(node)에 하나씩 담고, 노드마다 다음 노드의 주소(next 포인터)를 적어 줄줄이 잇는다. 노드는 메모리 어디에 있어도 된다. 그래서 어떤 노드를 가리키고 있기만 하면 그 뒤에 넣거나 빼는 일이 포인터 한두 개를 바꾸는 O(1)이다. 다른 값은 한 칸도 옮기지 않는다.

연결 리스트 단계 그림: 3 뒤에 새 노드 5를 만들어 포인터 두 개를 바꿔 끼우고, 7의 앞 노드 포인터를 9로 돌려 7을 떼어 낸다head379∅379∅379∅5∅379∅53579∅3579∅3579∅359∅7359∅노드 = 값 | next 포인터(∅는 끝)지금 보는 노드새 노드지울 노드방금 바꾼 포인터
1 / 9

리스트 3 → 7 → 9. 노드마다 값과 다음 노드를 가리키는 포인터를 가진다.

단일 연결 리스트 3 → 7 → 9에서 3 뒤에 5를 넣고, 이어서 7을 지운다. 노드 자체는 제자리에 두고 화살표(next 포인터)만 바꾼다. 굵은 파란 화살표가 방금 바꾼 포인터다.

대가는 찾기다. i번째 노드에 가려면 head부터 i번 걸어야 하니 접근이 O(n)이다. "O(1)에 넣는다"는 말은 넣을 자리를 이미 쥐고 있을 때의 이야기이고, 값으로 자리를 찾는 데는 여전히 O(n)이 든다. 노드마다 포인터 공간이 더 들고, 노드가 흩어져 있어 훑기가 배열보다 몇 배 느리다.

단일 연결 리스트(singly linked list)는 앞으로만 갈 수 있어, 어떤 노드를 지우려면 그 앞 노드를 알아야 한다. 이중 연결 리스트(doubly linked list)는 노드에 prev 포인터를 하나 더 두어 양쪽으로 걷는다. 노드 하나만 쥐고 있어도 바로 떼어 낼 수 있고, 꼬리(tail)를 들고 있으면 끝에 넣기·빼기도 O(1)이다.

이중 연결 리스트: 노드 3, 5, 7, 9가 앞으로 가는 next와 뒤로 가는 prev 포인터로 이어져 있고, head와 tail이 양 끝을 가리킨다head ↓↓ tail3∅579∅prev | 값 | next7을 지우기: 7.prev.next = 7.next, 7.next.prev = 7.prev앞 노드를 찾아 걸을 필요가 없어 O(1). 대가는 노드마다 포인터 하나 더지울 노드위: next, 아래: prev
이중 연결 리스트. 노드마다 prev와 next가 있어 7을 지울 때 7의 양옆 노드가 서로를 가리키게 바꾸기만 하면 된다. 앞 노드를 찾으려고 처음부터 걸을 필요가 없다.

실무에서 연결 리스트가 빛나는 곳은 "노드를 쥔 채로 자주 떼었다 붙이는" 경우다. 운영체제의 작업 큐, 메모리 할당기의 빈 블록 목록, 그리고 해시 표와 이중 연결 리스트를 묶은 LRU 캐시(더 나아가기 노트)가 그렇다. 그냥 값을 모아 두고 훑는 용도라면 거의 언제나 동적 배열이 빠르다.

스택: 마지막에 넣은 것부터

스택(stack)은 한쪽 끝에서만 넣고(push) 꺼내는(pop) 자료구조다. 마지막에 넣은 것이 먼저 나오는 후입선출(LIFO, last in first out)이다. 동적 배열의 끝을 쓰면 두 연산 모두 O(1)이고, 맨 위를 꺼내지 않고 보기만 하는 peek도 O(1)이다.

"가장 최근에 열린 것을 먼저 닫는다"는 구조가 보이면 스택이다. 괄호 짝 맞추기가 대표 예다. 여는 괄호는 쌓고, 닫는 괄호를 만나면 맨 위와 짝이 맞는지 본다. 짝이 틀리거나, 꺼낼 것이 없거나, 끝에 남은 것이 있으면 틀린 괄호다. 글자를 한 번씩만 보니 O(n)이다.

괄호 짝 맞추기 단계 그림: 여덟 글자를 왼쪽부터 읽으며 여는 괄호를 스택에 쌓고 닫는 괄호에서 꺼내, 끝에 스택이 빈다읽는 글스택([]{()})(비었음)시작([]{()})↑(push: 여는 괄호를 쌓았다([]{()})↑([push: 여는 괄호를 쌓았다([]{()})↑(pop: 짝을 맞춰 꺼냈다([]{()})↑({push: 여는 괄호를 쌓았다([]{()})↑({(push: 여는 괄호를 쌓았다([]{()})↑({pop: 짝을 맞춰 꺼냈다([]{()})↑(pop: 짝을 맞춰 꺼냈다([]{()})↑(비었음)pop: 짝을 맞춰 꺼냈다([]{()})(비었음)스택이 비었다: 올바른 괄호지금 읽는 글자 · 스택 맨 위읽은 글자아직
1 / 10

빈 스택에서 시작해 왼쪽부터 한 글자씩 읽는다.

괄호 문자열 ([]{()})를 스택으로 검사한다. 여는 괄호는 위에 쌓고, 닫는 괄호는 맨 위의 여는 괄호와 짝이 맞을 때만 꺼낸다. 끝까지 읽었을 때 스택이 비어 있으면 올바른 괄호다.

같은 모양이 곳곳에 있다. 함수 호출은 호출 스택에 쌓이고(복잡도와 기초 노트의 재귀 절), 편집기의 되돌리기(undo)는 작업을 스택에 쌓았다가 꺼낸다. 브라우저의 뒤로 가기, 깊이 우선 탐색(DFS)을 반복문으로 쓸 때, 수식의 후위 표기 계산도 스택이다.

스택의 단골 응용이 단조 스택(monotonic stack)이다. "각 원소의 왼쪽에서 가장 가까운, 나보다 작은 값"을 모두 구한다고 하자. 왼쪽부터 훑으며 스택 맨 위가 지금 값 이상이면 계속 꺼내고, 남은 맨 위가 답이며, 마지막에 지금 값을 넣는다. 꺼낸 값은 지금 값에 가려 뒤의 어떤 원소의 답도 될 수 없기 때문이다. 이중 반복처럼 보여도 원소마다 한 번 넣고 많아야 한 번 꺼내므로 O(n)이다. 훑는 방향과 부등호만 바꾸면 "오른쪽에서 가장 가까운 큰 값"도 같은 방법으로 찾는다(Wikipedia의 All nearest smaller values).

큐와 덱

큐(queue)는 뒤(rear)로 넣고 앞(front)에서 꺼낸다. 먼저 온 것이 먼저 나가는 선입선출(FIFO, first in first out)이다. 차례를 지켜야 하는 곳, 곧 프린터 대기열, 네트워크 패킷 버퍼, 작업 스케줄러, 그리고 가까운 것부터 넓혀 가는 너비 우선 탐색(BFS)에 쓴다.

구현에 함정이 하나 있다. 동적 배열의 맨 앞을 지우면 나머지를 모두 당겨야 해서 O(n)이다. 파이썬에서 list.pop(0)을 큐처럼 쓰면 n개를 꺼내는 데 O(n²)이 든다. 앞 위치를 가리키는 인덱스를 따로 두거나(다음 절의 원형 버퍼), 연결 리스트로 만들면 두 연산 모두 O(1)이 된다.

면접에 자주 나오는 변형이 스택 두 개로 큐 만들기다. 넣을 때는 입력 스택에 쌓고, 꺼낼 때 출력 스택이 비어 있으면 입력 스택의 값을 모두 옮겨 담는다. 옮기면서 순서가 뒤집혀 가장 오래된 값이 출력 스택 맨 위에 온다. 원소마다 넣기·옮기기·꺼내기가 한 번씩뿐이라 분할 상환 O(1)이다. 스택마다 최솟값을 함께 들고 다니면 "최솟값을 O(1)에 아는 큐"도 된다(cp-algorithms의 Minimum stack / Minimum queue).

큐와 덱: 큐는 오른쪽 뒤로 넣고 왼쪽 앞에서 꺼내며, 덱은 양 끝 모두에서 넣고 꺼낸다큐(queue): 먼저 들어온 것이 먼저 나간다(FIFO)481516frontrear꺼내기넣기덱(deque): 양쪽 끝 모두 넣고 꺼낸다234271앞 넣기·꺼내기뒤 넣기·꺼내기덱 하나로 스택(한쪽만)과 큐(넣는 쪽과 꺼내는 쪽을 나눔)를 모두 흉내 낼 수 있다연산이 닿는 끝 칸값이 드나드는 방향(모두 O(1))
큐는 넣는 쪽과 꺼내는 쪽이 다르고, 덱은 양 끝 모두에서 넣고 꺼낸다. 덱을 한쪽 끝만 쓰면 스택, 한쪽으로 넣고 반대쪽으로 꺼내면 큐가 된다.

덱(deque, double-ended queue)은 양 끝 모두에서 O(1)로 넣고 꺼낸다. 스택과 큐를 한 자료구조로 대신할 수 있어 표준 라이브러리에 흔하다(파이썬 collections.deque, C++ std::deque, Java ArrayDeque). 구현은 보통 원형 버퍼나, 고정 크기 블록을 이어 붙인 구조를 쓴다. 미끄러지는 창에서 최댓값을 O(1)에 유지하는 단조 덱(monotonic deque) 같은 기법도 덱 위에서 돈다.

원형 버퍼

원형 버퍼(circular buffer, ring buffer)는 고정 크기 배열의 끝과 처음을 이어 붙인 것처럼 쓰는 큐다. 가장 오래된 값을 가리키는 head와 다음에 쓸 칸을 가리키는 tail 두 인덱스만 움직이고, 끝에 닿으면 mod 연산으로 0번 칸으로 돌아간다. 값은 한 번도 옮기지 않으므로 넣기와 꺼내기가 언제나 O(1)이고, 메모리를 새로 잡지도 않는다.

원형 버퍼 단계 그림: 여섯 칸을 고리처럼 돌며 tail 자리에 쓰고 head 자리에서 읽는다. 가득 차면 새 값을 거부한다012345실제 배열012345headtail↑head↑tail든 값 0 / 61112345headtail↑head↑tail든 값 1 / 611222345headtail↑head↑tail든 값 2 / 6112233345headtail↑head↑tail든 값 3 / 61122334445headtail↑head↑tail든 값 4 / 611223344555headtail↑head↑tail든 값 5 / 60223344555headtail↑head↑tail든 값 4 / 6013344555headtail↑head↑tail든 값 3 / 60133445566headtail↑head↑tail든 값 4 / 677133445566headtail↑head↑tail든 값 5 / 6778833445566headtail↑head↑tail든 값 6 / 6778833445566headtail↑head↑tail든 값 6 / 6가득 참: 넣기 거부77882445566headtail↑head↑tail든 값 5 / 6다음 칸 = (지금 칸 + 1) mod 6방금 쓴 칸방금 비운 칸값이 든 칸빈 칸(번호만 표시)
1 / 13

크기 6칸의 빈 버퍼. head와 tail이 같은 칸에서 출발한다.

크기 6칸의 원형 버퍼에 값을 넣고 꺼낸다. 왼쪽은 끝과 처음을 이어 그린 모습, 오른쪽은 실제 배열이다. tail이 5번 칸을 넘으면 0번으로 돌아가, 앞에서 꺼내 비운 칸을 다시 쓴다. 값은 한 번도 옮기지 않는다.

head와 tail이 같은 칸을 가리키는 순간은 비었을 때와 가득 찼을 때 두 경우라 구별이 필요하다. 그림처럼 든 개수(size)를 따로 세거나, 한 칸을 일부러 비워 두어 "tail 다음이 head면 가득 참"으로 정한다. 가득 찼을 때 새 값을 거부할지, 가장 오래된 값을 덮어쓸지도 용도에 따라 고른다.

크기가 정해져 있고 할당이 없다는 점이 실시간 시스템에 맞는다. 오디오 장치와 프로그램 사이의 샘플 버퍼, 키보드 입력 버퍼, 네트워크 카드의 수신 링, 최근 N개만 남기는 로그, 게임의 입력 기록 (되감기·리플레이)이 모두 원형 버퍼다. 크기를 2의 거듭제곱으로 잡으면 mod를 비트 AND 한 번으로 바꿀 수 있다. 생산자 하나·소비자 하나라면 잠금 없이도(lock-free) 안전하게 만들 수 있다.

연산별 비용 한눈에 보기

여섯 가지를 연산별로 나란히 놓으면, 어디에 넣고 어디서 꺼내는지가 자료구조를 고르는 기준이라는 것이 보인다. 표의 "가운데 넣기"는 넣을 자리를 이미 알 때의 비용이다.

자료구조i번째 읽기앞에 넣기·빼기뒤에 넣기·빼기가운데 넣기·빼기값으로 찾기
고정 배열O(1)불가(크기 고정)불가(크기 고정)O(n) 밀기O(n)
동적 배열O(1)O(n)분할 상환 O(1)O(n)O(n)
단일 연결 리스트O(n)O(1)넣기 O(1)(tail이 있을 때), 빼기 O(n)O(1)(앞 노드를 쥐었을 때)O(n)
이중 연결 리스트O(n)O(1)O(1)O(1)(그 노드를 쥐었을 때)O(n)
스택맨 위만-O(1)(한쪽 끝)--
큐·원형 버퍼앞만빼기 O(1)넣기 O(1)--
덱구현에 따라 O(1)O(1)O(1)O(n)O(n)
맨 앞에 1 넣기: 배열은 값 다섯 개를 모두 한 칸씩 뒤로 옮기고, 연결 리스트는 새 노드를 만들어 head만 바꾼다배열: 뒤에서부터 한 칸씩 민다1넣을 값3791220값 5개를 모두 옮긴 뒤에야 0번 칸이 빈다: O(n)연결 리스트: 포인터 두 개만 바꾼다1head3791220∅나머지 노드는 그대로다: O(1)옮겨야 하는 값새 값바꾼 포인터
같은 '맨 앞에 넣기'라도 배열은 뒤의 값을 모두 한 칸씩 밀어야 하고(O(n)), 연결 리스트는 새 노드가 옛 첫 노드를 가리키게 하고 head를 옮기면 끝난다(O(1)). 반대로 i번째 값을 읽을 때는 배열이 O(1), 리스트가 O(n)이다.

고르는 순서는 대개 이렇다. 먼저 동적 배열을 생각한다. 앞에서도 빼야 하면 덱이나 원형 버퍼로 바꾼다. 쥐고 있는 노드를 자주 떼었다 붙여야 할 때만 연결 리스트를 쓴다. 값으로 찾는 일이 잦으면 이 노트의 어느 것도 답이 아니다. 해시 노트나 트리 노트로 넘어간다.