본문 바로가기
cs

선형 자료구조 (배열, 연결 리스트, 스택, 큐)

자료구조 복습하기 1탄

자료구조 포스팅 하며 복습하기 📦

  1. 선형 자료구조 (배열, 연결 리스트, 스택, 큐)
  2. 해시 테이블
  3. 트리와 힙, 그리고 그래프

선형 자료구조란?

데이터를 한 줄로 나열하는 구조입니다. 각 요소는 앞뒤 관계만 가지고 갈라지지 않습니다.

이번에 볼 네 가지는 두 갈래로 나뉩니다. 배열과 연결 리스트는 그 줄을 메모리에 어떻게 놓느냐가 다르고, 스택과 큐는 그 줄의 어디로 넣고 뺄지를 제한한 것입니다.

자료구조는 실무와 멀어 보이지만, 네 가지 모두 프론트엔드에서 매일 돌아가고 있습니다. React의 훅 목록, 콜 스택, 이벤트 루프의 태스크 큐가 전부 여기 있습니다.

배열

같은 크기의 칸을 메모리에 빈틈없이 붙여 놓은 구조입니다.

칸이 붙어 있기 때문에 시작 주소와 인덱스만 알면 원하는 칸의 위치를 계산으로 바로 구할 수 있습니다.

n번째 칸의 주소 = 시작 주소 + (n * 한 칸의 크기)

앞에서부터 세어볼 필요가 없어서 몇 번째 값을 꺼내든 O(1)입니다. 정렬편에서 arr[i]를 마음껏 썼던 것도 이 덕분입니다.

대신 중간이나 앞에 값을 끼워 넣으면 뒤쪽 값들을 전부 한 칸씩 밀어야 합니다. 이건 O(n)입니다.

pop은 맨 뒤를 떼어내니 밀 것이 없지만, shift는 맨 앞을 빼기 때문에 나머지를 전부 앞으로 당겨야 합니다.

const N = 100000;
const a = Array.from({ length: N }, (_, i) => i);
const b = Array.from({ length: N }, (_, i) => i);
 
console.time('pop');
while (a.length) a.pop(); // 맨 뒤 -> 당길 것이 없다
console.timeEnd('pop'); // pop: 1.8ms
 
console.time('shift');
while (b.length) b.shift(); // 맨 앞 -> 나머지를 전부 당긴다
console.timeEnd('shift'); // shift: 952.9ms

같은 10만 개를 비우는데 500배 넘게 차이가 났습니다.

연결 리스트

값과 다음 노드의 위치를 함께 들고 있는 노드를 사슬처럼 이어 붙인 구조입니다.

배열과 달리 메모리에 붙어 있을 필요가 없습니다. 각 노드가 다음이 어디인지 알고 있으니 흩어져 있어도 따라갈 수 있습니다.

코드

class Node {
  constructor(value) {
    this.value = value;
    // 다음 노드를 가리킨다. 마지막 노드는 가리킬 곳이 없어 null 이다
    this.next = null;
  }
}
 
class LinkedList {
  constructor() {
    this.head = null; // 첫 노드
    this.tail = null; // 마지막 노드. 없으면 push 할 때마다 끝까지 걸어가야 한다
    this.size = 0;
  }
 
  // 맨 앞에 붙인다. 뒤쪽 노드는 아무것도 건드리지 않으므로 O(1)
  unshift(value) {
    const node = new Node(value);
 
    node.next = this.head;
    this.head = node;
 
    // 비어 있었다면 이 노드가 처음이자 마지막이다
    if (!this.tail) {
      this.tail = node;
    }
 
    this.size++;
 
    return this;
  }
 
  // 맨 뒤에 붙인다. tail 을 들고 있어서 끝까지 걸어갈 필요가 없다. O(1)
  push(value) {
    const node = new Node(value);
 
    if (!this.head) {
      this.head = node;
      this.tail = node;
    } else {
      this.tail.next = node;
      this.tail = node;
    }
 
    this.size++;
 
    return this;
  }
 
  // index 번째 노드를 찾는다. 주소를 계산할 수 없어 앞에서부터 세어 간다. O(n)
  nodeAt(index) {
    if (index < 0 || index >= this.size) {
      return null;
    }
 
    let current = this.head;
 
    for (let i = 0; i < index; i++) {
      current = current.next;
    }
 
    return current;
  }
 
  at(index) {
    return this.nodeAt(index)?.value;
  }
 
  // index 번째 노드를 끊어낸다
  removeAt(index) {
    if (index < 0 || index >= this.size) {
      return undefined;
    }
 
    let removed;
 
    if (index === 0) {
      removed = this.head;
      this.head = removed.next;
    } else {
      // 끊어낼 노드의 "앞" 노드를 잡아야 연결을 다시 이어줄 수 있다
      const prev = this.nodeAt(index - 1);
 
      removed = prev.next;
      prev.next = removed.next;
 
      // 마지막을 지웠다면 tail 을 앞으로 당긴다
      if (removed === this.tail) {
        this.tail = prev;
      }
    }
 
    if (this.size === 1) {
      this.tail = null;
    }
 
    this.size--;
    removed.next = null;
 
    return removed.value;
  }
 
  // head 부터 next 를 따라가며 전부 훑는다
  toArray() {
    const result = [];
 
    for (let current = this.head; current; current = current.next) {
      result.push(current.value);
    }
 
    return result;
  }
}
 
const list = new LinkedList();
 
list.push('b').push('c').unshift('a');
 
console.log(list.toArray()); // ['a', 'b', 'c']
console.log(list.at(1)); // 'b'
 
list.removeAt(1);
 
console.log(list.toArray()); // ['a', 'c']
console.log(list.size); // 2

removeAt에서 지울 노드가 아니라 그 앞 노드를 잡는 게 핵심입니다. 각 노드는 다음만 알고 이전은 모르기 때문에, 끊어낸 자리를 다시 이으려면 앞 노드가 있어야 합니다. 이전 노드도 가리키게 만든 것이 이중 연결 리스트입니다.

배열과 정확히 반대입니다. 배열은 조회가 빠르고 앞쪽 수정이 느린데, 연결 리스트는 조회가 느리고 앞쪽 수정이 빠릅니다.

주의할 점은 맨 뒤입니다. tail 을 들고 있어서 붙이는 것은 O(1)이지만, 맨 뒤를 빼는 것은 O(n)입니다. 마지막 노드를 끊으려면 그 앞 노드를 찾아야 하는데 각 노드가 다음만 알기 때문에 결국 앞에서부터 훑어야 합니다.

시간 복잡도

  • 배열의 조회는 O(1), 연결 리스트의 조회는 O(n)입니다.
  • 맨 앞에 넣고 빼는 것은 배열이 O(n), 연결 리스트가 O(1)입니다.
  • 중간에 넣고 빼는 것은 둘 다 O(n)이지만 이유가 다릅니다. 배열은 뒤쪽을 미느라, 연결 리스트는 그 자리까지 찾아가느라 그렇습니다.

훅 규칙이 존재하는 이유

React는 컴포넌트마다 훅 목록을 연결 리스트로 들고 있습니다. useState를 부를 때마다 노드가 하나씩 이어지고, 다음 렌더에서는 그 목록을 처음부터 순서대로 따라가며 짝을 맞춥니다.

여기에 이름표가 없습니다. 연결 리스트에는 인덱스도 키도 없고 "몇 번째로 불렸는지"만 있습니다. 그래서 훅을 조건문 안에서 부르면 순서가 어긋나 다른 훅의 값을 집어옵니다.

const Component = ({ withFirst }) => {
  if (withFirst) {
    useState('first'); // 조건에 따라 있었다 없었다 한다
  }
 
  const [second] = useState('second');
 
  return <span>{second}</span>;
};

withFirsttrue였다가 false가 되면 React가 이렇게 막습니다.

Rendered fewer hooks than expected.
This may be caused by an accidental early return statement.

"훅은 최상위에서만 호출하라"는 규칙이 스타일 문제가 아니라 자료구조에서 나온 제약이라는 뜻입니다. 조기 반환(early return) 뒤에 훅이 남아 있을 때도 같은 이유로 깨집니다.

스택

한쪽 끝으로만 넣고 빼는 구조입니다. 마지막에 넣은 것이 가장 먼저 나옵니다.

배열에서 pushpop만 쓰면 그대로 스택이 됩니다. 위에서 봤듯이 배열의 뒤쪽은 밀어낼 것이 없어서 둘 다 O(1)입니다.

자바스크립트를 쓰면서 매일 마주치는 스택이 콜 스택입니다. 함수를 호출하면 호출 정보가 쌓이고 함수가 끝나면 가장 위부터 걷힙니다. 재귀가 깊어질 때 나는 Maximum call stack size exceeded는 이 스택이 넘쳤다는 뜻입니다. 정렬편의 퀵 정렬병합 정렬도 재귀라서 이 스택을 씁니다.

겹친 모달을 닫는 순서

화면에 모달이 여러 개 겹칠 수 있다면 스택이 필요합니다. ESC를 눌렀을 때 닫혀야 하는 것은 항상 가장 마지막에 연 모달이기 때문입니다.

const modalStack = [];
 
const openModal = (id) => {
  modalStack.push(id);
};
 
const closeTopModal = () => {
  // 마지막에 넣은 것이 먼저 나온다
  return modalStack.pop();
};
 
document.addEventListener('keydown', (event) => {
  if (event.key === 'Escape') {
    closeTopModal();
  }
});
 
openModal('설정');
openModal('설정 > 알림');
openModal('알림 > 삭제 확인');
 
closeTopModal(); // '알림 > 삭제 확인'
closeTopModal(); // '설정 > 알림'

모달을 배열에 담고 마지막 것만 닫으면 자연스럽게 열린 역순으로 닫힙니다. 되돌리기(Undo) 기능이나 브라우저 뒤로가기도 같은 구조입니다.

한쪽으로 넣고 반대쪽으로 빼는 구조입니다. 먼저 넣은 것이 먼저 나옵니다.

배열로 만들면 pushshift를 쓰게 되는데, 앞에서 확인했듯 shift는 O(n)입니다. 요소가 많아지면 이 부분이 그대로 비용이 됩니다.

class Queue {
  constructor() {
    this.items = [];
    // 어디까지 꺼냈는지만 기록하고 실제로 지우지는 않는다
    this.head = 0;
  }
 
  get size() {
    return this.items.length - this.head;
  }
 
  enqueue(value) {
    this.items.push(value);
  }
 
  dequeue() {
    if (this.size === 0) {
      return undefined;
    }
 
    const value = this.items[this.head];
 
    // shift 대신 가리키는 위치만 한 칸 옮긴다. 뒤쪽을 당기지 않으므로 O(1)
    this.head++;
 
    // 꺼낸 자리가 절반을 넘으면 그때 한 번에 정리한다
    if (this.head * 2 >= this.items.length) {
      this.items = this.items.slice(this.head);
      this.head = 0;
    }
 
    return value;
  }
}

꺼낸 자리를 그때그때 비우지 않고 인덱스만 옮기는 방식입니다. 대신 빈 자리가 쌓이므로 절반을 넘으면 한 번에 정리합니다. 매번 당기던 것을 가끔 한 번으로 바꾼 것입니다.

큐도 브라우저에서 매일 돌아가고 있습니다. 이벤트 루프는 콜 스택이 빈 것을 확인하고 태스크 큐에서 콜백을 하나씩 꺼내오는데, 먼저 등록된 콜백이 먼저 실행되는 것이 큐이기 때문입니다.

동시 요청 수를 제한할 때도 큐를 씁니다. 이미지 100장을 한 번에 쏘아 보내는 대신 대기열에 넣고 정해진 개수만 돌리는 식입니다. 서버 부하를 줄이고, 먼저 요청한 것이 먼저 끝나도록 순서를 지켜줍니다.

정리

  • 배열은 붙어 있어서 조회가 빠르고, 앞쪽을 건드리면 뒤가 전부 밀립니다.
  • 연결 리스트는 흩어져 있어서 조회가 느리고, 앞쪽 수정이 빠릅니다. React의 훅 목록이 이 구조라 훅 규칙이 생겼습니다.
  • 스택은 마지막에 넣은 것부터 꺼냅니다. 콜 스택과 모달 닫기 순서가 그렇습니다.
  • 큐는 먼저 넣은 것부터 꺼냅니다. 이벤트 루프의 태스크 큐가 그렇습니다.
  • 스택과 큐는 배열이나 연결 리스트 위에 얹어서 만듭니다. 무엇으로 만드느냐에 따라 비용이 달라집니다.