본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
14장 : 선형 자료구조 구현

단방향 연결 목록

연속 배열 없이 Node를 연결하고 head부터 getNode·getLast·add까지 참조 이동 규칙을 구현합니다.

연결 목록은 원소를 연속된 칸에 복사하지 않고 각 Node가 다음 Node의 참조를 가집니다.

그래서 i번째 값을 얻으려면 head부터 i번 next를 따라가야 합니다.

순회 코드는 현재 Nodenext 참조를 분리하며 null을 목록의 끝 표식으로 사용합니다.


null 종단을 지나친 순회가 실패하는 지점

두 노드만 있는 체인에서 세 번 이동합니다.

null이 정상 종료 표식인데도 이동 횟수를 검증하지 않아 마지막 출력에서 NullPointerException이 납니다.

lab/BrokenNodeTraversalFailure.java
public final class BrokenNodeTraversalFailure {
    public static void main(String[] args) {
        Node first = new Node("node", new Node("link", null));
        Node current = first;
        for (int i = 0; i <= 2; i++) {
            current = current.next;
        }
        System.out.println(current.value);
    }

    private record Node(String value, Node next) {}
}
관찰 결과
java.lang.NullPointerException

배열의 length처럼 자동으로 알려 주는 구분이 없습니다.

목록이 size를 관리하고 getNode(index)가 유효 범위를 검사해야 합니다.

순회는 current를 한 칸씩 바꾸되 원래 head 참조를 잃지 않아야 합니다.


head·current·next의 역할 분리

  • Node는 현재 값과 다음 Node 참조를 묶어 한 연결 단위를 표현합니다.
  • head는 첫 원소를 가리키며 빈 목록에서는 null이라는 상태를 명시합니다.
  • getNode는 head에서 출발해 정확히 indexnext를 따라가므로 O(n)입니다.
  • getLastcurrent.nextnull인 노드를 반환하고 currentnull인 경우를 먼저 처리합니다.
  • 끝 추가는 마지막 Nodenext를 새 Node로 연결하며 size를 한 번만 증가시킵니다.
  • toString 순회는 구조를 변경하지 않고 null에 도달하면 반드시 종료해야 합니다.

getNode·add 기반 단방향 체인

LinkedNodeWalk는 head를 고정하고 current라는 지역 참조만 이동합니다.

add는 빈 목록과 기존 목록을 분리하고, get은 경계 검사를 통과한 뒤 getNode를 호출합니다.

size가 구조와 어긋나지 않도록 연결 성공 뒤 한 번 증가시킵니다.

src/LinkedNodeWalk.java
public final class LinkedNodeWalk {
    public static void main(String[] args) {
        NodeChain chain = new NodeChain();
        chain.add("node");
        chain.add("next");
        chain.add("head");
        System.out.println(chain.get(1));
        System.out.println(chain);
    }

    private static final class NodeChain {
        private Node head;
        private int size;

        void add(String value) {
            Node added = new Node(value);
            if (head == null) head = added;
            else getNode(size - 1).next = added;
            size++;
        }

        String get(int index) {
            check(index);
            return getNode(index).value;
        }

        private Node getNode(int index) {
            Node current = head;
            for (int i = 0; i < index; i++) {
                current = current.next;
            }
            return current;
        }

        private void check(int index) {
            if (index < 0 || index >= size) throw new IndexOutOfBoundsException(index);
        }

        public String toString() {
            StringBuilder out = new StringBuilder("[");
            for (Node current = head; current != null; current = current.next) {
                if (out.length() > 1) out.append(", ");
                out.append(current.value);
            }
            return out.append(']').toString();
        }
    }

    private static final class Node {
        String value;
        Node next;

        Node(String value) {
            this.value = value;
        }
    }
}

head 보존과 current 이동

head는 목록의 영구 시작점입니다.

조회를 위해 head 자체를 다음 Node로 바꾸면 앞부분을 다시 찾을 방법이 없습니다.

순회에는 지역 변수 current를 만들고 current = current.next만 반복합니다.

순회가 끝나도 head가 첫 Node를 가리켜야 합니다.

인덱스 0의 Nodehead이고 인덱스 i의 Nodenext를 i번 따라간 결과입니다.

따라서 연결 목록 get의 비용은 위치에 비례합니다.

마지막 원소 조회를 반복하는 코드가 있다면 각 호출마다 전체 체인을 걷는다는 점을 놓치지 않습니다.


null 종단 표식

빈 목록은 headnull이고 마지막 Nodenextnull입니다.

두 의미를 문맥으로 구분합니다.

toString은 currentnull이 되면 종료하고, getNode는 사전에 index를 검사했기 때문에 순회 중 null을 만나지 않아야 합니다.

size와 실제 Node 수가 어긋나면 경계 검사를 통과했는데 currentnull이 되는 내부 결함이 발생합니다.

add에서 연결이 성공한 뒤 size를 늘리고 remove에서 연결을 바꾼 뒤 줄이는 순서를 지키는 이유입니다.


getLast와 끝 추가의 비용

꼬리 노드 필드가 없는 첫 구현의 getLast는 head부터 nextnullNode까지 이동합니다.

끝 추가 한 번은 O(n)이며 n개를 연달아 넣으면 전체 순회가 커집니다.

이 버전은 연결 원리를 드러내는 데는 좋지만 대량 끝 추가의 최종 설계는 아닙니다.

순환 참조가 생기면 null에 도달하지 못하므로 toString도 끝나지 않습니다.

직접 구현을 확장할 때는 방문 Node 수가 size를 넘는지 진단하거나 Floyd 순환 탐지 같은 별도 검사를 고려할 수 있습니다.


연결 순서 출력

CLI 결과는 여전히 입력 순서와 합계를 보여 줍니다.

연결 저장소로 바꾸더라도 사용자는 nexthead를 알 필요가 없습니다.

이 분리는 자료 구조 내부 참조와 애플리케이션 명령을 섞지 않게 합니다.

app/PostRepositoryCliCH144.java
public final class PostRepositoryCliCH144 {
    public static void main(String[] args) {
        PostRepository repository = new PostRepository();
        repository.add("node-chain", 38);
        repository.add("linear-walk", 47);
        repository.print();
        System.out.println("total=" + repository.totalViewCount());
    }

    private static final class PostRepository {
        private Entry[] entries = new Entry[2];
        private int size;

        void add(String title, int viewCount) {
            if (title == null || title.isBlank()) throw new IllegalArgumentException("title");
            if (viewCount <= 0) throw new IllegalArgumentException("viewCount");
            ensureCapacity(size + 1);
            entries[size++] = new Entry(title, viewCount);
        }

        private void ensureCapacity(int required) {
            if (required <= entries.length) return;
            Entry[] grown = new Entry[Math.max(required, entries.length * 2)];
            System.arraycopy(entries, 0, grown, 0, size);
            entries = grown;
        }

        int totalViewCount() {
            int total = 0;
            for (int i = 0; i < size; i++) {
                total += entries[i].viewCount();
            }
            return total;
        }

        void print() {
            for (int i = 0; i < size; i++) {
                Entry entry = entries[i];
                System.out.println(i + ":" + entry.title() + "=" + entry.viewCount());
            }
        }
    }

    private record Entry(String title, int viewCount) {}
}

이 CLI는 배열을 사용하지만 출력 규칙은 연결 목록에서도 그대로 적용됩니다.

연결 저장소를 실험할 때 head부터 방문한 Entry 수가 size와 같고 마지막 nextnull인지 먼저 검사합니다.

합계 계산은 모든 Node를 정확히 한 번 방문해야 85를 냅니다.


단방향 연결이 유리한 연산

질문관찰할 값선택 또는 조치
임의 조회가 많은가평균 index배열 목록 우선
끝 참조를 저장하는가끝 추가 횟수꼬리 노드 필드 검토
노드 오버헤드가 허용되는가원소당 참조 수메모리 측정
순회 중 변경하는가구조 수정 횟수반복자 정책 필요

연결 목록은 “삽입 O(1)”로만 선택할 수 없습니다.

삽입 위치의 Node를 찾는 시간이 포함되면 전체가 O(n)입니다.

이미 Node 참조를 가진 알고리즘인지, 임의 조회가 필요한지 먼저 묻습니다.


연습 문제

Node를 직접 연결하고 head부터 순회해 개수와 마지막 값을 출력하세요.

headnull인 입력도 처리할 수 있는 메서드로 분리합니다.

정답과 해설

current를 이동하기 전에 last를 갱신하면 반복 종료 뒤 마지막 방문 값을 보존할 수 있습니다.

빈 체인의 lastnull로 남습니다.

exercise/NodeChainSummarySolution.java
public final class NodeChainSummarySolution {
    public static void main(String[] args) {
        Node head = new Node("node", new Node("next", new Node("end", null)));
        Summary result = summarize(head);
        System.out.println("count=" + result.count() + ", last=" + result.last());
    }

    private static Summary summarize(Node head) {
        int count = 0;
        String last = null;
        for (Node current = head; current != null; current = current.next()) {
            count++;
            last = current.value();
        }
        return new Summary(count, last);
    }

    private record Node(String value, Node next) {}

    private record Summary(int count, String last) {}
}

결과는 count=3, last=end입니다.

summarize는 head를 재대입하지 않아 호출자의 체인을 손상하지 않습니다.


단방향 순회 종료 기준

빈 목록의 headnull, 비어 있지 않은 목록의 마지막 nextnull입니다.

getNode(i)는 i번만 이동하고 공개 get이 먼저 범위를 검사합니다.

구조를 읽는 메서드가 headnext를 변경하지 않는지 확인하면 단방향 순회의 기반이 닫힙니다.