배열과 연결 리스트

자료구조 · 배열·연결 리스트

이 페이지의 목차

핵심 요약

배열과 연결 리스트의 저장 방식, 주소 계산, 삽입·삭제·탐색 비용을 비교한다.

본문

자료구조를 선택하는 기준

자료구조는 데이터를 저장하는 모양뿐 아니라 접근, 탐색, 삽입, 삭제 비용을 결정한다.

  • 배열: 연속된 메모리 공간에 같은 자료형의 원소를 저장한다.
  • 연결 리스트: 떨어진 노드를 포인터 또는 참조로 연결한다.
  • 선택 기준은 임의 접근 필요성, 삽입·삭제 위치, 원소 수의 변화, 추가 메모리 사용량이다.

배열

배열은 인덱스를 주소로 계산할 수 있어 임의 접근이 빠르다. 원소 하나의 크기를 w, 시작 주소를 base, 배열의 하한을 L이라 하면 1차원 배열의 주소는 다음과 같다.

예시·텍스트
address(A[i]) = base + (i - L) × w

예를 들어 A[1..10], 시작 주소 1000, 원소 크기 4바이트일 때 A[7]의 주소는 1000 + (7-1)×4 = 1024이다.

2차원 배열 A[L1..U1][L2..U2]에서 행 수를 N1, 열 수를 N2라고 하면 다음과 같다.

예시·텍스트
행 우선: base + ((i-L1)×N2 + (j-L2))×w
열 우선: base + ((j-L2)×N1 + (i-L1))×w

배열의 연산 비용

연산일반적인 비용이유
인덱스 접근O(1)주소를 바로 계산한다.
값 탐색O(n)정렬되지 않았다면 순서대로 확인한다.
중간 삽입·삭제O(n)뒤쪽 원소를 이동해야 한다.
끝 삽입고정 배열은 조건부, 동적 배열은 분할 상환 O(1)공간이 부족하면 더 큰 배열로 재할당한다.

연결 리스트

연결 리스트의 노드는 데이터와 다음 노드를 가리키는 링크로 구성된다. 형태에 따라 다음과 같이 나뉜다.

  • 단일 연결 리스트: 다음 노드만 가리킨다.
  • 이중 연결 리스트: 이전·다음 노드를 모두 가리킨다.
  • 원형 연결 리스트: 마지막 노드가 첫 노드를 가리킨다.

인덱스로 즉시 접근할 수 없으므로 k번째 노드를 찾으려면 앞에서부터 이동해야 한다. 삽입·삭제에 필요한 앞·뒤 링크를 이미 알고 있다면 링크 몇 개만 바꾸면 된다. 단일 연결 리스트에서 특정 노드 앞에 삽입하거나 그 노드를 삭제하려면 이전 노드를 알아야 한다.

비교 항목배열연결 리스트
저장 위치연속비연속 가능
임의 접근O(1)O(n)
중간 삽입·삭제O(n)필요한 링크를 이미 알면 O(1)
추가 공간적음링크 공간 필요
크기 변경고정 배열은 불리유연함

메모리 배치와 캐시 지역성

배열의 장점은 주소 계산만이 아니다. 원소가 연속되어 있어 인접 원소를 순서대로 읽을 때 공간 지역성이 좋다. 연결 리스트는 노드가 흩어질 수 있어 같은 O(n) 순회라도 실제 실행시간은 배열보다 불리할 수 있다.

도식
배열:        [10][20][30][40]        ← 연속 주소
단일 리스트: [10|●] → [20|●] → [30|●] → [40|null]

점근 복잡도가 같아도 실제 성능이 같지는 않다. 점근 복잡도는 증가율을 나타내며 캐시 적중률, 할당 비용, 포인터 추적 비용까지 같다는 뜻이 아니다.

동적 배열과 분할 상환 분석

초기 용량이 1인 동적 배열에서 공간이 가득 찰 때마다 용량을 2배로 늘린다고 하자. 확장할 때는 기존 원소를 새 배열로 복사한다. 한 번의 확장은 O(n)이지만, n번의 끝 삽입 동안 발생하는 전체 복사량은 1+2+4+...<2n이므로 삽입 1회의 분할 상환 비용은 O(1)이다.

PYTHON
def append(dynamic_array, value):
    if dynamic_array.size == dynamic_array.capacity:
        dynamic_array.resize(dynamic_array.capacity * 2)
    dynamic_array.data[dynamic_array.size] = value
    dynamic_array.size += 1

분할 상환은 연속된 연산의 총비용을 연산 수로 나누어 계산한다. 입력을 무작위로 가정하는 평균 시간 복잡도와 구분한다.

연결 리스트 삽입·삭제 추적

단일 연결 리스트에서 prev 뒤에 새 노드 x를 삽입할 때는 링크를 잃지 않도록 순서를 지킨다.

도식
변경 전: prev ─────→ next
1단계 : x.next = prev.next
2단계 : prev.next = x
변경 후: prev → x → next
C
Node *x = malloc(sizeof(Node));
x->data = value;
x->next = prev->next;
prev->next = x;

삭제 대상 target의 이전 노드 prev를 알고 있다면 prev->next = target->next로 연결을 우회한다. 이중 연결 리스트에서는 앞·뒤 링크를 모두 수정해야 한다.

원형·더미 노드와 경계 조건

  • 원형 리스트는 마지막 노드의 next가 첫 노드를 가리켜 순환 작업에 유리하다.
  • 더미 헤드(sentinel)를 두면 첫 노드 삽입·삭제도 일반 노드와 같은 코드로 처리할 수 있다.
  • 빈 리스트, 원소 1개, 첫·마지막 노드 변경은 포인터 문제의 대표 경계 조건이다.

계산형 예제

B[2..5][1..6]이 행 우선으로 저장되고 시작 주소가 2000, 원소 크기가 4바이트라 하자. 열 수는 6이므로 B[4][5] 주소는 다음과 같다.

예시·텍스트
2000 + ((4-2)×6 + (5-1))×4
= 2000 + 16×4
= 2064

열 우선이라면 행 수 4를 사용하여 2000 + ((5-1)×4 + (4-2))×4 = 2072가 된다.

선택 기준 정리

요구사항더 적합한 구조근거
인덱스로 반복 접근배열주소 계산 O(1), 지역성 우수
중간 삽입·삭제가 많고 필요한 링크를 이미 보유연결 리스트링크 수정 O(1)
원소 수가 자주 늘지만 임의 접근도 필요동적 배열분할 상환 O(1) 끝 삽입과 O(1) 접근
양방향 이동·현재 노드 삭제이중 연결 리스트이전 링크를 직접 사용

복습 문제

답을 생각한 뒤 정답과 근거를 펼쳐 보세요.

01

행 우선 배열 A[1..4][0..5]의 시작 주소가 100, 원소 크기가 2바이트일 때 A[3][4]의 주소를 구하시오.

정답과 근거

정답: 132

열 인덱스가 0..5이므로 한 행에는 원소가 6개 있다. A[3][4] 앞에는 1행과 2행의 원소 12개, 3행의 A[3][0]부터 A[3][3]까지 4개가 놓인다.

예시·텍스트
앞에 놓인 원소 수 = (3 − 1) × 6 + (4 − 0)
                  = 12 + 4
                  = 16
A[3][4]의 주소 = 100 + 16 × 2 = 132

3행의 주소를 차례로 적어도 확인할 수 있다.

원소A[3][0]A[3][1]A[3][2]A[3][3]A[3][4]A[3][5]
주소124126128130132134

전체 행 수 4를 곱하면 열 우선 계산과 섞인다. 행 우선에서는 앞선 행 하나가 차지하는 열 수 6을 사용한다.

본문 예제

예를 들어 A[1..10], 시작 주소 1000, 원소 크기 4바이트일 때 A[7]의 주소는 1000 + (7-1)×4 = 1024이다.

02

동적 배열의 끝 삽입이 최악 O(n)이면서 분할 상환 O(1)인 이유를 설명하시오.

정답과 근거

정답: 한 번의 확장은 O(n)이지만, n번 끝 삽입의 총비용은 O(n)이기 때문이다.

초기 용량이 1이고 가득 차면 2배로 늘린다고 하자. 원소 하나를 복사하거나 새로 쓰는 비용을 각각 1로 센다.

삽입을 마친 원소 수지금까지 복사한 원소 수새 원소 쓰기합계
8개1 + 2 + 4 = 7815
9개1 + 2 + 4 + 8 = 15924
16개1 + 2 + 4 + 8 = 151631

9번째 삽입은 기존 원소 8개를 복사하고 새 원소를 쓰므로 비용이 9다. 이후 16번째 삽입까지는 추가 복사가 없다. 비싼 확장이 모든 삽입에서 반복되는 것이 아니다.

n번 끝 삽입의 전체 복사량은 2n보다 작다. 새 원소를 쓰는 n번까지 합쳐도 총비용은 3n보다 작으므로, 연산 하나에 나누어 부담시키는 분할 상환 비용은 O(1)이다. 이 계산에는 입력값이 무작위라는 가정이 필요하지 않다.

본문 예제

예를 들어 A[1..10], 시작 주소 1000, 원소 크기 4바이트일 때 A[7]의 주소는 1000 + (7-1)×4 = 1024이다.

03

단일 연결 리스트에서 prev 뒤에 x를 삽입할 때 링크 변경 순서를 쓰시오.

정답과 근거

정답: x.next = prev.next를 먼저 실행하고, prev.next = x를 실행한다.

prev가 유효한 노드이고 x가 새 노드일 때의 순서다.

C
x->next = prev->next;
prev->next = x;

prev 뒤에 C가 있던 경우를 따라가 보자.

시점prev.nextx.next
처음C아직 연결하지 않음
첫 대입 후CC
두 번째 대입 후xC

최종 연결은 prev → x → C다. 순서를 바꾸면 첫 대입에서 prev->next가 이미 x가 된다. 그 뒤 x->next = prev->next를 실행하면 x가 자기 자신을 가리키고, prev에서 기존 C로 가던 연결은 끊긴다.

마지막 노드 뒤에 삽입할 때는 기존 prev->next가 NULL이다. 올바른 순서로 실행하면 새 마지막 노드의 next에도 NULL이 들어간다.

04

배열 순회와 연결 리스트 순회가 모두 O(n)이어도 배열이 실제로 빠를 수 있는 이유는 무엇인가?

정답과 근거

정답: 연속된 메모리 배치가 캐시의 공간 지역성에 유리하기 때문이다.

배열을 앞에서부터 읽으면 다음 원소가 바로 옆 주소에 있다. CPU가 메모리를 캐시 라인 단위로 가져올 때 인접 원소도 함께 들어와, 다음 접근을 캐시에서 처리할 가능성이 높다.

연결 리스트의 다음 노드는 멀리 떨어져 있을 수 있다. 다음 노드의 주소를 읽은 뒤 그 주소로 다시 접근해야 하며, 이 과정에서 캐시 미스와 포인터 추적 비용이 생길 수 있다.

두 구조 모두 원소 n개를 방문하지만, 방문 한 번에 드는 비용까지 같지는 않다. 실제 차이는 원소 크기, 노드 배치, 접근 순서와 하드웨어에 따라 달라진다.

본문 예제

예를 들어 A[1..10], 시작 주소 1000, 원소 크기 4바이트일 때 A[7]의 주소는 1000 + (7-1)×4 = 1024이다.

05

연결 리스트 삽입을 무조건 O(1)이라고 하면 안 되는 조건을 설명하시오.

정답과 근거

정답: 삽입 위치를 찾는 탐색까지 포함하면 최악 O(n)이 될 수 있다.

“500번째 위치에 삽입하라”는 요청만 받았다면 단일 연결 리스트는 앞에서부터 링크를 따라가며 그 앞 노드를 찾아야 한다. 찾은 뒤 링크 두 개를 바꾸는 비용은 O(1)이지만, 탐색을 포함한 전체 삽입은 O(n)이 될 수 있다.

주어진 정보필요한 작업비용
삽입할 위치 바로 앞의 prev를 이미 알고 있음링크 변경O(1)
위치 번호나 찾을 값만 알고 있음위치 탐색 후 링크 변경최악 O(n)

단일 연결 리스트에서 어떤 노드 앞에 삽입하려면 그 노드의 이전 노드가 필요하다. “노드를 알고 있다”는 조건에서는 어느 노드를 가리키는지도 확인해야 한다.