본문 바로가기

배열 인덱스와 삽입 비용

배열의 조회·검색·삽입을 직접 실행해 size와 용량을 구분하고 위치별 이동 비용을 계산합니다.

배열은 인덱스로 한 칸을 바로 찾을 수 있어 조회가 빠릅니다.

그러나 “빠르다”는 말만 외우면 중간 삽입에서 기존 값을 덮어쓰는 실수를 놓칩니다.

이 절은 게시글 저장소 항목을 배열에 넣으며 조회 횟수와 이동 횟수를 따로 기록합니다.

끝 삽입, 첫 삽입, 중간 삽입이 왜 다른지 실제 상태 변화로 확인합니다.

이 장에서 처음 사용하는 시간 복잡도는 실제 밀리초가 아니라 입력 원소 수가 커질 때 작업 횟수가 어떤 모양으로 늘어나는지를 나타냅니다.

원소 수를 n이라고 할 때 다음 세 표현부터 사용합니다.

표현뜻배열 예
O(1)원소 수가 늘어도 핵심 작업 수가 거의 일정인덱스로 한 칸 조회
O(n)원소 수에 비례해 작업이 늘어남처음부터 끝까지 검색
상환 O(1)가끔 큰 확장이 있지만 여러 연산에 나누면 평균이 일정한 수준동적 배열의 끝 추가

Big-O는 정확한 실행 시간을 보장하지 않습니다.

먼저 실제로 읽거나 옮긴 원소 수를 세고, 그 증가 모양에 이름을 붙입니다.

평균·최악·상환 조건은 해당 구현을 배울 때 빠뜨리지 않고 함께 적습니다.


정방향 복사와 기존 값 소실

중간에 빈칸을 만들 때 낮은 인덱스부터 복사하면 방금 옮긴 값이 다음 반복의 원본이 됩니다.

실행 결과는 원래 두 번째 값인 list가 사라지는 잘못된 상태를 보여 줍니다.

lab/ArrayInsertOverwriteBug.java
import java.util.Arrays;

public final class ArrayInsertOverwriteBug {
    public static void main(String[] args) {
        String[] titles = {"array", "list", null, null};
        int size = 2;
        int index = 0;
        for (int i = index; i < size; i++) {
            titles[i + 1] = titles[i];
        }
        titles[index] = "index";
        size++;
        System.out.println(Arrays.toString(Arrays.copyOf(titles, size)));
    }
}
관찰 결과
[index, array, array]

복사 방향이 읽을 원본을 먼저 파괴했습니다.

앞에서 복사하면 덮인 값을 다시 읽는다

잘못된 앞방향 복사는 list를 먼저 덮어쓰고 덮인 값을 다시 읽어 index,array,array를 만듭니다. 연속 두 대입의 상태가 오류 원인입니다.

앞에서 복사하면 덮인 값을 다시 읽는다

ArrayInsertOverwriteBug의 index 0 삽입에서 i 0이 list를 덮고 i 1이 그 array를 다시 읽는 중간 상태를 원문으로 추적합니다. 마지막 사용 구간만 실제 출력과 연결합니다.

실패 추적 · 덮어쓴 값을 다시 읽음

앞에서 복사하면 덮인 값을 다시 읽는다

i=0에서 list가 array로 덮입니다. 다음 반복은 그 칸을 다시 읽으므로 array가 또 복사됩니다.

정방향 복사 실패의 상태 변화ArrayInsertOverwriteBug의 반복 i=0에서 원 list가 사라지고 i=1에서 덮인 array를 다시 복사합니다. 실선은 대입에 따른 다음 상태이며 최종 사용 구간은 원 출력 index, array, array입니다.
처음 · 크기 2[0] array / [1] list[2] null / [3] null
연결 관계처음 · 크기 2 · [0] array / [1] list · [2] null / [3] null → i=0 · 0번을 1번에 · [0] array / [1] array · list를 덮어씀
i=0 · 0번을 1번에[0] array / [1] arraylist를 덮어씀
연결 관계i=0 · 0번을 1번에 · [0] array / [1] array · list를 덮어씀 → i=1 · 1번을 2번에 · [1] array / [2] array · 이미 덮인 값을 다시 읽음
i=1 · 1번을 2번에[1] array / [2] array이미 덮인 값을 다시 읽음
연결 관계i=1 · 1번을 2번에 · [1] array / [2] array · 이미 덮인 값을 다시 읽음 → 0번에 index 저장 · [0] index / [1] array · [2] array / 크기 3
0번에 index 저장[0] index / [1] array[2] array / 크기 3

실선: 다음 대입의 상태 변화원 출력: index, array, array

  1. 초기

    읽는 칸 → 쓰는 칸
    size=2, index=0
    반복 전
    전체 슬롯 0→3 · 소스 추적
    [array, list, null, null]
  2. i=0

    읽는 칸 → 쓰는 칸
    titles[0] → titles[1]
    array로 원래 list를 덮음
    전체 슬롯 0→3 · 소스 추적
    [array, array, null, null]
  3. i=1

    읽는 칸 → 쓰는 칸
    titles[1] → titles[2]
    이미 덮인 array를 다시 읽음
    전체 슬롯 0→3 · 소스 추적
    [array, array, array, null]
  4. 반복 뒤 저장

    읽는 칸 → 쓰는 칸
    titles[0] = "index"
    size=3
    전체 슬롯 0→3 · 소스 추적
    [index, array, array, null]
    실제 출력: [index, array, array]

이 예의 조건

  • 잘못된 앞방향 삽입의 실제 상태이다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
4[0] array
5[1] list
6[2] null
7[3] null
9[0] array
10[1] array
13[1] array
14[2] array
17[0] index
18[1] array
19[2] array
20크기 3
원 도표의 설명과 주석
구분내용
전체 읽기 정보앞에서 복사하면 덮인 값을 다시 읽는다 앞에서 복사하면 덮인 값을 다시 읽는다 ArrayInsertOverwriteBug의 index 0 삽입에서 i 0이 list를 덮고 i 1이 그 array를 다시 읽는 중간 상태를 원문으로 추적합니다. 마지막 사용 구간만 실제 출력과 연결합니다. 실패 추적 · 덮어쓴 값을 다시 읽음 앞에서 복사하면 덮인 값을 다시 읽는다 i=0에서 list가 array로 덮입니다. 다음 반복은 그 칸을 다시 읽으므로 array가 또 복사됩니다. 초기 읽는 칸 → 쓰는 칸 size=2, index=0 반복 전 전체 슬롯 0→3 · 소스 추적 [array, list, null, null] i=0 읽는 칸 → 쓰는 칸 titles[0] → titles[1] array 로 원래 list 를 덮음 전체 슬롯 0→3 · 소스 추적 [array, array, null, null] i=1 읽는 칸 → 쓰는 칸 titles[1] → titles[2] 이미 덮인 array 를 다시 읽음 전체 슬롯 0→3 · 소스 추적 [array, array, array, null] 반복 뒤 저장 읽는 칸 → 쓰는 칸 titles[0] = "index" size=3 전체 슬롯 0→3 · 소스 추적 [index, array, array, null] 실제 출력: [index, array, array]
예의 값과 변화
관찰·상황값·보충 정보변화·이유
원본

array/list와 비어 있는 칸에서 시작합니다.

  • 슬롯: array,list,null,null
  • size: 2
  • 앞 원소부터 복사하면 뒤 읽기 입력을 덮을 수 있다.
앞방향 두 대입

0→1에서 list를 잃고 1→2가 array를 다시 읽습니다.

  • i=0 뒤: array,array,null
  • i=1 뒤: array,array,array
  • 덮인 슬롯이 다음 복사의 원본으로 재사용된다.
삽입 후 오류

0번에 index를 넣고 size 3으로 마칩니다.

  • 결과: index,array,array
  • 손실: 원 list
  • 새 값을 넣어도 이미 소실한 원소는 복구되지 않는다.

앞에서 복사하면 덮인 값을 다시 읽는다

잘못된 앞방향 복사는 list를 먼저 덮어쓰고 덮인 값을 다시 읽어 index,array,array를 만듭니다. 연속 두 대입의 상태가 오류 원인입니다.

앞에서 복사하면 덮인 값을 다시 읽는다

ArrayInsertOverwriteBug의 index 0 삽입에서 i 0이 list를 덮고 i 1이 그 array를 다시 읽는 중간 상태를 원문으로 추적합니다. 마지막 사용 구간만 실제 출력과 연결합니다.

실패 추적 · 덮어쓴 값을 다시 읽음

앞에서 복사하면 덮인 값을 다시 읽는다

i=0에서 list가 array로 덮입니다. 다음 반복은 그 칸을 다시 읽으므로 array가 또 복사됩니다.

정방향 복사 실패의 상태 변화ArrayInsertOverwriteBug의 반복 i=0에서 원 list가 사라지고 i=1에서 덮인 array를 다시 복사합니다. 실선은 대입에 따른 다음 상태이며 최종 사용 구간은 원 출력 index, array, array입니다.
처음 · 크기 2[0] array / [1] list[2] null / [3] null
연결 관계처음 · 크기 2 · [0] array / [1] list · [2] null / [3] null → i=0 · 0번을 1번에 · [0] array / [1] array · list를 덮어씀
i=0 · 0번을 1번에[0] array / [1] arraylist를 덮어씀
연결 관계i=0 · 0번을 1번에 · [0] array / [1] array · list를 덮어씀 → i=1 · 1번을 2번에 · [1] array / [2] array · 이미 덮인 값을 다시 읽음
i=1 · 1번을 2번에[1] array / [2] array이미 덮인 값을 다시 읽음
연결 관계i=1 · 1번을 2번에 · [1] array / [2] array · 이미 덮인 값을 다시 읽음 → 0번에 index 저장 · [0] index / [1] array · [2] array / 크기 3
0번에 index 저장[0] index / [1] array[2] array / 크기 3

실선: 다음 대입의 상태 변화원 출력: index, array, array

  1. 초기

    읽는 칸 → 쓰는 칸
    size=2, index=0
    반복 전
    전체 슬롯 0→3 · 소스 추적
    [array, list, null, null]
  2. i=0

    읽는 칸 → 쓰는 칸
    titles[0] → titles[1]
    array로 원래 list를 덮음
    전체 슬롯 0→3 · 소스 추적
    [array, array, null, null]
  3. i=1

    읽는 칸 → 쓰는 칸
    titles[1] → titles[2]
    이미 덮인 array를 다시 읽음
    전체 슬롯 0→3 · 소스 추적
    [array, array, array, null]
  4. 반복 뒤 저장

    읽는 칸 → 쓰는 칸
    titles[0] = "index"
    size=3
    전체 슬롯 0→3 · 소스 추적
    [index, array, array, null]
    실제 출력: [index, array, array]

이 예의 조건

  • 잘못된 앞방향 삽입의 실제 상태이다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
4[0] array
5[1] list
6[2] null
7[3] null
9[0] array
10[1] array
13[1] array
14[2] array
17[0] index
18[1] array
19[2] array
20크기 3
원 도표의 설명과 주석
구분내용
전체 읽기 정보앞에서 복사하면 덮인 값을 다시 읽는다 앞에서 복사하면 덮인 값을 다시 읽는다 ArrayInsertOverwriteBug의 index 0 삽입에서 i 0이 list를 덮고 i 1이 그 array를 다시 읽는 중간 상태를 원문으로 추적합니다. 마지막 사용 구간만 실제 출력과 연결합니다. 실패 추적 · 덮어쓴 값을 다시 읽음 앞에서 복사하면 덮인 값을 다시 읽는다 i=0에서 list가 array로 덮입니다. 다음 반복은 그 칸을 다시 읽으므로 array가 또 복사됩니다. 초기 읽는 칸 → 쓰는 칸 size=2, index=0 반복 전 전체 슬롯 0→3 · 소스 추적 [array, list, null, null] i=0 읽는 칸 → 쓰는 칸 titles[0] → titles[1] array 로 원래 list 를 덮음 전체 슬롯 0→3 · 소스 추적 [array, array, null, null] i=1 읽는 칸 → 쓰는 칸 titles[1] → titles[2] 이미 덮인 array 를 다시 읽음 전체 슬롯 0→3 · 소스 추적 [array, array, array, null] 반복 뒤 저장 읽는 칸 → 쓰는 칸 titles[0] = "index" size=3 전체 슬롯 0→3 · 소스 추적 [index, array, array, null] 실제 출력: [index, array, array]
예의 값과 변화
관찰·상황값·보충 정보변화·이유
원본

array/list와 비어 있는 칸에서 시작합니다.

  • 슬롯: array,list,null,null
  • size: 2
  • 앞 원소부터 복사하면 뒤 읽기 입력을 덮을 수 있다.
앞방향 두 대입

0→1에서 list를 잃고 1→2가 array를 다시 읽습니다.

  • i=0 뒤: array,array,null
  • i=1 뒤: array,array,array
  • 덮인 슬롯이 다음 복사의 원본으로 재사용된다.
삽입 후 오류

0번에 index를 넣고 size 3으로 마칩니다.

  • 결과: index,array,array
  • 손실: 원 list
  • 새 값을 넣어도 이미 소실한 원소는 복구되지 않는다.

오른쪽 이동은 마지막 원소부터 시작해야 하고, 왼쪽 이동은 제거 위치 다음 칸부터 시작해야 합니다.

배열 자체는 논리 크기를 모르므로 size를 별도 상태로 관리해야 합니다.


조회·검색·삽입 비용 분리

  • 유효한 조회 인덱스는 0 이상 size 미만이며 용량과 혼동하지 않습니다.
  • 삽입은 먼저 공간을 확보한 다음 뒤에서 앞으로 원소를 이동해야 덮어쓰기를 막습니다.
  • 삭제는 제거값을 보존하고 앞으로 당긴 뒤 사용하지 않는 슬롯을 null로 비웁니다.
  • size는 논리 원소 수이고 배열 길이는 물리 용량이므로 두 값의 역할이 다릅니다.
  • 실패는 공개 API에서 예외로 고정해 내부 배열 예외가 새어 나오지 않게 합니다.
  • 연산 비용은 코드 줄 수가 아니라 이동하거나 따라간 원소 수로 비교합니다.

조회·삽입 경계 분리

완성 구현은 조회용 경계와 삽입용 경계를 분리합니다.

add(size, 값)는 허용되지만 get(size)는 허용되지 않습니다.

remove 뒤의 마지막 사용 슬롯을 null로 지워 더 이상 쓰지 않는 객체가 배열 때문에 유지되는 일도 막습니다.

src/ArrayIndexOperations.java
import java.util.Arrays;

public final class ArrayIndexOperations {
    public static void main(String[] args) {
        TitleArray titles = new TitleArray(5);
        titles.add("array");
        titles.add("list");
        titles.add(1, "index");
        System.out.println(titles.get(1));
        System.out.println("found=" + titles.indexOf("list"));
        System.out.println("removed=" + titles.remove(0));
        System.out.println(titles);
    }

    private static final class TitleArray {
        private final String[] values;
        private int size;

        TitleArray(int capacity) {
            values = new String[capacity];
        }

        void add(String value) {
            add(size, value);
        }

        void add(int index, String value) {
            checkAddIndex(index);
            if (size == values.length) throw new IllegalStateException("full");
            for (int i = size - 1; i >= index; i--) {
                values[i + 1] = values[i];
            }
            values[index] = value;
            size++;
        }

        String get(int index) {
            checkElementIndex(index);
            return values[index];
        }

        int indexOf(String target) {
            for (int i = 0; i < size; i++)
                if (java.util.Objects.equals(values[i], target)) return i;
            return -1;
        }

        String remove(int index) {
            checkElementIndex(index);
            String old = values[index];
            for (int i = index + 1; i < size; i++) {
                values[i - 1] = values[i];
            }
            values[--size] = null;
            return old;
        }

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

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

        public String toString() {
            return Arrays.toString(Arrays.copyOf(values, size));
        }
    }
}

조회·삽입 범위의 차이

get(index)와 remove(index)는 이미 존재하는 원소를 가리켜야 하므로 0 <= index && index < size만 허용합니다.

반면 add(index, value)는 현재 마지막 원소 뒤에도 새 값을 붙일 수 있어 index == size가 유효합니다.

두 조건을 하나의 검사 메서드로 합치면 끝 추가가 거부되거나 존재하지 않는 원소 조회가 허용됩니다.

정상 삽입은 list를 보존한 뒤 새 값을 쓴다

뒤에서 list를 2번으로 옮기고 index를 1번에 쓴 뒤 size를 갱신합니다. 고정 배열 칸의 실제 변경 순서를 보여 줄 수 있습니다.

정상 삽입은 list를 보존한 뒤 새 값을 쓴다

ArrayIndexOperations의 용량 5 배열에서 index 1 삽입이 뒤쪽 list를 먼저 복사한 다음 index를 저장하는 중간 상태와 이후 조회 출력을 구분합니다.

배열 삽입 · 뒤에서 앞으로

정상 삽입은 list를 보존한 뒤 새 값을 쓴다

list를 오른쪽 칸에 먼저 보존한 뒤 index를 씁니다. 사용 중인 두 원소에 하나를 넣는 이 호출은 복사를 한 번 수행합니다.

뒤에서 복사하여 원소를 보존하는 삽입ArrayIndexOperations의 용량5, 크기2에서 index1에 삽입합니다. list를2번에 먼저 보존하고 index를1번에 써 크기3이 됩니다. 이어 get1/indexOf list는 상태를 바꾸지 않습니다. 실선은 해당 복사·저장·조회 순서입니다.
처음 · 크기 2[0] array / [1] list용량 5의 나머지 칸 null
연결 관계처음 · 크기 2 · [0] array / [1] list · 용량 5의 나머지 칸 null → i=1 · 1번을 2번에 · [1] list / [2] list · 아직 새 값을 쓰지 않음
i=1 · 1번을 2번에[1] list / [2] list아직 새 값을 쓰지 않음
연결 관계i=1 · 1번을 2번에 · [1] list / [2] list · 아직 새 값을 쓰지 않음 → 1번에 index 저장 · [0] array / [1] index · [2] list / 크기 3
1번에 index 저장[0] array / [1] index[2] list / 크기 3
연결 관계1번에 index 저장 · [0] array / [1] index · [2] list / 크기 3 → 조회·검색 · get(1)은 index · indexOf(list)는 2
조회·검색get(1)은 indexindexOf(list)는 2

실선: 복사·저장·조회 순서조회가 복사를 다시 하지 않음

  1. 두 끝 추가 뒤

    읽기·쓰기와 크기
    add("array"), add("list")
    size=2, capacity=5
    소스 추적 상태 또는 실제 출력
    [array, list, null, null, null]
  2. add(1, "index"): i=1

    읽기·쓰기와 크기
    values[1] → values[2]
    i=size-1에서 시작 · 이번에는 1회 복사
    소스 추적 상태 또는 실제 출력
    [array, list, list, null, null]
  3. 새 값 저장

    읽기·쓰기와 크기
    values[1] = "index"
    size=3
    소스 추적 상태 또는 실제 출력
    [array, index, list, null, null]
  4. 조회와 검색

    읽기·쓰기와 크기
    get(1) → "index"
    indexOf("list") → 2
    두 호출은 배열을 바꾸지 않음
    소스 추적 상태 또는 실제 출력
    실제 출력:
    index
    found=2

이 예의 조건

  • capacity 5에서 index 1 삽입을 추적한다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2ArrayIndexOperations의 용량 5, 크기 2에서 index 1에 삽입합니다. list를 2번에 먼저 보존하고 index를 1번에 써 크기 3이 됩니다. 이어 get(1)/indexOf(list)는 상태를 바꾸지 않습니다. 실선은 해당 복사·저장·조회 순서입니다.
4[0] array
5[1] list
8[1] list
9[2] list
12[0] array
13[1] index
14[2] list
15크기 3
원 도표의 설명과 주석
구분내용
설명ArrayIndexOperations의 용량 5, 크기 2에서 index 1에 삽입합니다. list를 2번에 먼저 보존하고 index를 1번에 써 크기 3이 됩니다. 이어 get(1)/indexOf(list)는 상태를 바꾸지 않습니다. 실선은 해당 복사·저장·조회 순서입니다.
전체 읽기 정보정상 삽입은 list를 보존한 뒤 새 값을 쓴다 정상 삽입은 list를 보존한 뒤 새 값을 쓴다 ArrayIndexOperations의 용량 5 배열에서 index 1 삽입이 뒤쪽 list를 먼저 복사한 다음 index를 저장하는 중간 상태와 이후 조회 출력을 구분합니다. 배열 삽입 · 뒤에서 앞으로 정상 삽입은 list를 보존한 뒤 새 값을 쓴다 list를 오른쪽 칸에 먼저 보존한 뒤 index를 씁니다. 사용 중인 두 원소에 하나를 넣는 이 호출은 복사를 한 번 수행합니다. 두 끝 추가 뒤 읽기·쓰기와 크기 add("array"), add("list") size=2, capacity=5 소스 추적 상태 또는 실제 출력 [array, list, null, null, null] add(1, "index"): i=1 읽기·쓰기와 크기 values[1] → values[2] i=size-1 에서 시작 · 이번에는 1회 복사 소스 추적 상태 또는 실제 출력 [array, list, list, null, null] 새 값 저장 읽기·쓰기와 크기 values[1] = "index" size=3 소스 추적 상태 또는 실제 출력 [array, index, list, null, null] 조회와 검색 읽기·쓰기와 크기 get(1) → "index" indexOf("list") → 2 두 호출은 배열을 바꾸지 않음 소스 추적 상태 또는 실제 출력 실제 출력: index found=2
예의 값과 변화
관찰·상황값·보충 정보변화·이유
삽입 전

array/list 두 값을 보관합니다.

  • 슬롯: array,list,null,null,null
  • size: 2
  • 원 list를 덮기 전에 뒤 공간에 보존해야 한다.
뒤로 복사

index 1의 list를 index 2로 먼저 복사합니다.

  • 사용 주변: array,list,list
  • 새 값 쓰기: 아직 없음
  • 뒤에서 이동하면 읽어야 할 원본 슬롯을 잃지 않는다.
새 값 확정

index를 1번에 쓰고 size 3으로 바꿉니다.

  • 사용 목록: array,index,list
  • get1: index
  • indexOf(list): 2
  • 보존한 뒤 새 값을 쓰므로 기존 순서가 유지된다.

정상 삽입은 list를 보존한 뒤 새 값을 쓴다

뒤에서 list를 2번으로 옮기고 index를 1번에 쓴 뒤 size를 갱신합니다. 고정 배열 칸의 실제 변경 순서를 보여 줄 수 있습니다.

정상 삽입은 list를 보존한 뒤 새 값을 쓴다

ArrayIndexOperations의 용량 5 배열에서 index 1 삽입이 뒤쪽 list를 먼저 복사한 다음 index를 저장하는 중간 상태와 이후 조회 출력을 구분합니다.

배열 삽입 · 뒤에서 앞으로

정상 삽입은 list를 보존한 뒤 새 값을 쓴다

list를 오른쪽 칸에 먼저 보존한 뒤 index를 씁니다. 사용 중인 두 원소에 하나를 넣는 이 호출은 복사를 한 번 수행합니다.

뒤에서 복사하여 원소를 보존하는 삽입ArrayIndexOperations의 용량5, 크기2에서 index1에 삽입합니다. list를2번에 먼저 보존하고 index를1번에 써 크기3이 됩니다. 이어 get1/indexOf list는 상태를 바꾸지 않습니다. 실선은 해당 복사·저장·조회 순서입니다.
처음 · 크기 2[0] array / [1] list용량 5의 나머지 칸 null
연결 관계처음 · 크기 2 · [0] array / [1] list · 용량 5의 나머지 칸 null → i=1 · 1번을 2번에 · [1] list / [2] list · 아직 새 값을 쓰지 않음
i=1 · 1번을 2번에[1] list / [2] list아직 새 값을 쓰지 않음
연결 관계i=1 · 1번을 2번에 · [1] list / [2] list · 아직 새 값을 쓰지 않음 → 1번에 index 저장 · [0] array / [1] index · [2] list / 크기 3
1번에 index 저장[0] array / [1] index[2] list / 크기 3
연결 관계1번에 index 저장 · [0] array / [1] index · [2] list / 크기 3 → 조회·검색 · get(1)은 index · indexOf(list)는 2
조회·검색get(1)은 indexindexOf(list)는 2

실선: 복사·저장·조회 순서조회가 복사를 다시 하지 않음

  1. 두 끝 추가 뒤

    읽기·쓰기와 크기
    add("array"), add("list")
    size=2, capacity=5
    소스 추적 상태 또는 실제 출력
    [array, list, null, null, null]
  2. add(1, "index"): i=1

    읽기·쓰기와 크기
    values[1] → values[2]
    i=size-1에서 시작 · 이번에는 1회 복사
    소스 추적 상태 또는 실제 출력
    [array, list, list, null, null]
  3. 새 값 저장

    읽기·쓰기와 크기
    values[1] = "index"
    size=3
    소스 추적 상태 또는 실제 출력
    [array, index, list, null, null]
  4. 조회와 검색

    읽기·쓰기와 크기
    get(1) → "index"
    indexOf("list") → 2
    두 호출은 배열을 바꾸지 않음
    소스 추적 상태 또는 실제 출력
    실제 출력:
    index
    found=2

이 예의 조건

  • capacity 5에서 index 1 삽입을 추적한다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2ArrayIndexOperations의 용량 5, 크기 2에서 index 1에 삽입합니다. list를 2번에 먼저 보존하고 index를 1번에 써 크기 3이 됩니다. 이어 get(1)/indexOf(list)는 상태를 바꾸지 않습니다. 실선은 해당 복사·저장·조회 순서입니다.
4[0] array
5[1] list
8[1] list
9[2] list
12[0] array
13[1] index
14[2] list
15크기 3
원 도표의 설명과 주석
구분내용
설명ArrayIndexOperations의 용량 5, 크기 2에서 index 1에 삽입합니다. list를 2번에 먼저 보존하고 index를 1번에 써 크기 3이 됩니다. 이어 get(1)/indexOf(list)는 상태를 바꾸지 않습니다. 실선은 해당 복사·저장·조회 순서입니다.
전체 읽기 정보정상 삽입은 list를 보존한 뒤 새 값을 쓴다 정상 삽입은 list를 보존한 뒤 새 값을 쓴다 ArrayIndexOperations의 용량 5 배열에서 index 1 삽입이 뒤쪽 list를 먼저 복사한 다음 index를 저장하는 중간 상태와 이후 조회 출력을 구분합니다. 배열 삽입 · 뒤에서 앞으로 정상 삽입은 list를 보존한 뒤 새 값을 쓴다 list를 오른쪽 칸에 먼저 보존한 뒤 index를 씁니다. 사용 중인 두 원소에 하나를 넣는 이 호출은 복사를 한 번 수행합니다. 두 끝 추가 뒤 읽기·쓰기와 크기 add("array"), add("list") size=2, capacity=5 소스 추적 상태 또는 실제 출력 [array, list, null, null, null] add(1, "index"): i=1 읽기·쓰기와 크기 values[1] → values[2] i=size-1 에서 시작 · 이번에는 1회 복사 소스 추적 상태 또는 실제 출력 [array, list, list, null, null] 새 값 저장 읽기·쓰기와 크기 values[1] = "index" size=3 소스 추적 상태 또는 실제 출력 [array, index, list, null, null] 조회와 검색 읽기·쓰기와 크기 get(1) → "index" indexOf("list") → 2 두 호출은 배열을 바꾸지 않음 소스 추적 상태 또는 실제 출력 실제 출력: index found=2
예의 값과 변화
관찰·상황값·보충 정보변화·이유
삽입 전

array/list 두 값을 보관합니다.

  • 슬롯: array,list,null,null,null
  • size: 2
  • 원 list를 덮기 전에 뒤 공간에 보존해야 한다.
뒤로 복사

index 1의 list를 index 2로 먼저 복사합니다.

  • 사용 주변: array,list,list
  • 새 값 쓰기: 아직 없음
  • 뒤에서 이동하면 읽어야 할 원본 슬롯을 잃지 않는다.
새 값 확정

index를 1번에 쓰고 size 3으로 바꿉니다.

  • 사용 목록: array,index,list
  • get1: index
  • indexOf(list): 2
  • 보존한 뒤 새 값을 쓰므로 기존 순서가 유지된다.

용량은 배열이 확보한 칸 수일 뿐 조회 가능한 원소 수가 아닙니다.

길이 10 배열에 세 값만 넣었다면 3부터 9까지는 저장 여유이지 목록 원소가 아닙니다.

현재 구현의 오류 메시지는 index만 담습니다. 공개 API를 개선할 때는 index와 size를 함께 제시하고 내부 배열 길이는 구현 정보로 감추는 편이 좋습니다.


이동 방향 수동 추적

[A, B, C, _]의 1번에 X를 넣을 때 먼저 C를 3번으로, 다음 B를 2번으로 옮기고 마지막에 X를 1번에 씁니다.

높은 쪽에서 낮은 쪽으로 움직이면 아직 읽지 않은 원본이 보존됩니다.

삭제는 반대로 제거 위치 다음 값부터 낮은 쪽으로 당겨야 같은 원소를 두 번 복사하지 않습니다.

복사 대입 한 번을 읽기와 쓰기로 나누어 보면 방향을 판단하기 쉽습니다. values[i + 1] = values[i]는 먼저 오른쪽 식의 원본을 읽고 목적지에 씁니다. 오른쪽 이동을 낮은 인덱스부터 시작하면 다음 반복이 읽을 칸을 먼저 덮습니다. 반대로 values[i - 1] = values[i]의 왼쪽 이동은 낮은 쪽부터 시작해야 아직 읽지 않은 원본을 보존합니다.

직접 계산하기

크기 8, 용량 10인 목록의 인덱스 4에 삽입할 때와 인덱스 4를 제거할 때를 각각 따로 생각하세요. 기존 원소를 옮기는 횟수와 새 size, 인덱스 8을 조회할 수 있는지를 먼저 적습니다. 두 연산을 한 목록에 차례로 적용하는 문제가 아닙니다.

이동량과 범위 확인

삽입은 기존 4·5·6·7번을 옮겨 4회이며 새 크기는 9입니다. 따라서 삽입 성공 뒤 8번 조회가 가능합니다. 삭제는 뒤의 5·6·7번을 옮겨 3회이고 새 크기는 7입니다. 삭제 성공 뒤 8번 조회는 범위 밖입니다. 마지막 칸을 비우는 대입은 원소 이동 3회와 따로 세며, 용량 10은 두 경우 모두 바뀌지 않습니다.

끝 추가는 이동 0회, 첫 추가는 기존 size회, 가운데 추가는 size - index회입니다.

Big-O가 모두 O(n)인 경우에도 실제 이동량은 위치에 따라 다릅니다.

게시글 저장소가 시간순 끝 추가를 주로 한다면 이 분포를 선택 근거로 기록해야 합니다.


제거 뒤 null 정리가 필요한 이유

참조 배열에서 size만 줄이고 마지막 슬롯을 비우지 않으면 논리 목록에서는 사라진 객체를 배열이 계속 가리킵니다.

GC는 도달 가능한 객체를 회수하지 않으므로 장시간 살아 있는 목록에서 보이지 않는 보유가 누적될 수 있습니다.

values[--size] = null은 메모리 관리와 디버깅 양쪽에서 삭제 완료를 표시합니다.

삭제는 왼쪽으로 당긴 뒤 마지막 칸을 비운다

remove(0)는 old를 저장하고 두 칸을 왼쪽 이동한 뒤 size를 줄이고 마지막 칸을 비웁니다. 논리 삭제와 남은 참조 정리를 단계로 구분합니다.

삭제는 왼쪽으로 당긴 뒤 마지막 칸을 비운다

ArrayIndexOperations의 remove 0에서 array를 반환용으로 보존하고 index와 list를 차례대로 왼쪽에 복사한 뒤 size를 줄여 마지막 참조 칸을 null로 만드는 소스 추적입니다.

배열 삭제 · 이동과 참조 정리

삭제는 왼쪽으로 당긴 뒤 마지막 칸을 비운다

삭제할 array를 보관한 뒤 index와 list를 왼쪽으로 옮깁니다. 줄어든 사용 구간 바깥의 마지막 참조를 null로 정리합니다.

삭제 반환값과 왼쪽 이동·null 정리remove0은 array를 old에 보관하고 두 값을 왼쪽으로 복사한 뒤 크기를2로 줄이고2번 칸을null로 정리합니다. 실선은 실제 대입 순서이며 이동2회와 마지막 정리1회는 다릅니다.
삭제 전 값을 보관old는 array / 크기 3[0] array / [1] index[2] list
연결 관계삭제 전 값을 보관 · old는 array / 크기 3 · [0] array / [1] index · [2] list → i=1 · 왼쪽 복사 · [0] index / [1] index · [2] list
i=1 · 왼쪽 복사[0] index / [1] index[2] list
연결 관계i=1 · 왼쪽 복사 · [0] index / [1] index · [2] list → i=2 · 왼쪽 복사 · [0] index / [1] list · [2] list
i=2 · 왼쪽 복사[0] index / [1] list[2] list
연결 관계i=2 · 왼쪽 복사 · [0] index / [1] list · [2] list → 크기 감소 후 2번 비움 · 크기 2 / [2] null · old인 array 반환
크기 감소 후 2번 비움크기 2 / [2] nullold인 array 반환

실선: remove(0)의 대입 순서원 출력: removed=array다음 출력: [index, list]

  1. 삭제 진입

    읽기·쓰기와 크기
    old = values[0] → "array"
    size=3
    전체 슬롯 0→4 · 소스 추적
    [array, index, list, null, null]
  2. i=1

    읽기·쓰기와 크기
    values[1] → values[0]
    index를 왼쪽으로 복사
    전체 슬롯 0→4 · 소스 추적
    [index, index, list, null, null]
  3. i=2

    읽기·쓰기와 크기
    values[2] → values[1]
    list를 왼쪽으로 복사
    전체 슬롯 0→4 · 소스 추적
    [index, list, list, null, null]
  4. 크기 감소와 정리

    읽기·쓰기와 크기
    values[--size] = null
    size=2 · old="array" 반환
    전체 슬롯 0→4 · 소스 추적
    [index, list, null, null, null]
    이어 실제 출력:
    removed=array
    [index, list]

이 예의 조건

  • 마지막 null 정리는 두 이동과 다른 대입이다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2remove(0)은 array를 old에 보관하고 두 값을 왼쪽으로 복사한 뒤 크기를 2로 줄이고 2번 칸을 null로 정리합니다. 실선은 실제 대입 순서이며 이동 2회와 마지막 정리 1회는 다릅니다.
4old는 array
5크기 3
6[0] array
7[1] index
10[0] index
11[1] index
14[0] index
15[1] list
18크기 2
19[2] null
원 도표의 설명과 주석
구분내용
설명remove(0)은 array를 old에 보관하고 두 값을 왼쪽으로 복사한 뒤 크기를 2로 줄이고 2번 칸을 null로 정리합니다. 실선은 실제 대입 순서이며 이동 2회와 마지막 정리 1회는 다릅니다.
전체 읽기 정보삭제는 왼쪽으로 당긴 뒤 마지막 칸을 비운다 삭제는 왼쪽으로 당긴 뒤 마지막 칸을 비운다 ArrayIndexOperations의 remove 0에서 array를 반환용으로 보존하고 index와 list를 차례대로 왼쪽에 복사한 뒤 size를 줄여 마지막 참조 칸을 null로 만드는 소스 추적입니다. 배열 삭제 · 이동과 참조 정리 삭제는 왼쪽으로 당긴 뒤 마지막 칸을 비운다 삭제할 array를 보관한 뒤 index와 list를 왼쪽으로 옮깁니다. 줄어든 사용 구간 바깥의 마지막 참조를 null로 정리합니다. 삭제 진입 읽기·쓰기와 크기 old = values[0] → "array" size=3 전체 슬롯 0→4 · 소스 추적 [array, index, list, null, null] i=1 읽기·쓰기와 크기 values[1] → values[0] index 를 왼쪽으로 복사 전체 슬롯 0→4 · 소스 추적 [index, index, list, null, null] i=2 읽기·쓰기와 크기 values[2] → values[1] list 를 왼쪽으로 복사 전체 슬롯 0→4 · 소스 추적 [index, list, list, null, null] 크기 감소와 정리 읽기·쓰기와 크기 values[--size] = null size=2 · old="array" 반환 전체 슬롯 0→4 · 소스 추적 [index, list, null, null, null] 이어 실제 출력: removed=array [index, list]
예의 값과 변화
관찰·상황값·보충 정보변화·이유
삭제값 보존

old에 array를 보관합니다.

  • 슬롯: array,index,list
  • size: 3
  • old: array
  • 반환할 값은 이동 전에 읽어 둔다.
왼쪽 두 이동

index와 list를 각각 앞 칸에 복사합니다.

  • 첫 이동: index,index,list
  • 둘째 이동: index,list,list
  • 사용 순서를 유지하며 삭제 빈자리를 채운다.
크기와 참조 정리

size 2 후 마지막 칸을 비우고 old를 반환합니다.

  • 슬롯: index,list,null
  • 반환: array
  • 목록 출력: [index,list]
  • 사용 구간 밖의 중복 참조를 정리한다.

삭제는 왼쪽으로 당긴 뒤 마지막 칸을 비운다

remove(0)는 old를 저장하고 두 칸을 왼쪽 이동한 뒤 size를 줄이고 마지막 칸을 비웁니다. 논리 삭제와 남은 참조 정리를 단계로 구분합니다.

삭제는 왼쪽으로 당긴 뒤 마지막 칸을 비운다

ArrayIndexOperations의 remove 0에서 array를 반환용으로 보존하고 index와 list를 차례대로 왼쪽에 복사한 뒤 size를 줄여 마지막 참조 칸을 null로 만드는 소스 추적입니다.

배열 삭제 · 이동과 참조 정리

삭제는 왼쪽으로 당긴 뒤 마지막 칸을 비운다

삭제할 array를 보관한 뒤 index와 list를 왼쪽으로 옮깁니다. 줄어든 사용 구간 바깥의 마지막 참조를 null로 정리합니다.

삭제 반환값과 왼쪽 이동·null 정리remove0은 array를 old에 보관하고 두 값을 왼쪽으로 복사한 뒤 크기를2로 줄이고2번 칸을null로 정리합니다. 실선은 실제 대입 순서이며 이동2회와 마지막 정리1회는 다릅니다.
삭제 전 값을 보관old는 array / 크기 3[0] array / [1] index[2] list
연결 관계삭제 전 값을 보관 · old는 array / 크기 3 · [0] array / [1] index · [2] list → i=1 · 왼쪽 복사 · [0] index / [1] index · [2] list
i=1 · 왼쪽 복사[0] index / [1] index[2] list
연결 관계i=1 · 왼쪽 복사 · [0] index / [1] index · [2] list → i=2 · 왼쪽 복사 · [0] index / [1] list · [2] list
i=2 · 왼쪽 복사[0] index / [1] list[2] list
연결 관계i=2 · 왼쪽 복사 · [0] index / [1] list · [2] list → 크기 감소 후 2번 비움 · 크기 2 / [2] null · old인 array 반환
크기 감소 후 2번 비움크기 2 / [2] nullold인 array 반환

실선: remove(0)의 대입 순서원 출력: removed=array다음 출력: [index, list]

  1. 삭제 진입

    읽기·쓰기와 크기
    old = values[0] → "array"
    size=3
    전체 슬롯 0→4 · 소스 추적
    [array, index, list, null, null]
  2. i=1

    읽기·쓰기와 크기
    values[1] → values[0]
    index를 왼쪽으로 복사
    전체 슬롯 0→4 · 소스 추적
    [index, index, list, null, null]
  3. i=2

    읽기·쓰기와 크기
    values[2] → values[1]
    list를 왼쪽으로 복사
    전체 슬롯 0→4 · 소스 추적
    [index, list, list, null, null]
  4. 크기 감소와 정리

    읽기·쓰기와 크기
    values[--size] = null
    size=2 · old="array" 반환
    전체 슬롯 0→4 · 소스 추적
    [index, list, null, null, null]
    이어 실제 출력:
    removed=array
    [index, list]

이 예의 조건

  • 마지막 null 정리는 두 이동과 다른 대입이다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2remove(0)은 array를 old에 보관하고 두 값을 왼쪽으로 복사한 뒤 크기를 2로 줄이고 2번 칸을 null로 정리합니다. 실선은 실제 대입 순서이며 이동 2회와 마지막 정리 1회는 다릅니다.
4old는 array
5크기 3
6[0] array
7[1] index
10[0] index
11[1] index
14[0] index
15[1] list
18크기 2
19[2] null
원 도표의 설명과 주석
구분내용
설명remove(0)은 array를 old에 보관하고 두 값을 왼쪽으로 복사한 뒤 크기를 2로 줄이고 2번 칸을 null로 정리합니다. 실선은 실제 대입 순서이며 이동 2회와 마지막 정리 1회는 다릅니다.
전체 읽기 정보삭제는 왼쪽으로 당긴 뒤 마지막 칸을 비운다 삭제는 왼쪽으로 당긴 뒤 마지막 칸을 비운다 ArrayIndexOperations의 remove 0에서 array를 반환용으로 보존하고 index와 list를 차례대로 왼쪽에 복사한 뒤 size를 줄여 마지막 참조 칸을 null로 만드는 소스 추적입니다. 배열 삭제 · 이동과 참조 정리 삭제는 왼쪽으로 당긴 뒤 마지막 칸을 비운다 삭제할 array를 보관한 뒤 index와 list를 왼쪽으로 옮깁니다. 줄어든 사용 구간 바깥의 마지막 참조를 null로 정리합니다. 삭제 진입 읽기·쓰기와 크기 old = values[0] → "array" size=3 전체 슬롯 0→4 · 소스 추적 [array, index, list, null, null] i=1 읽기·쓰기와 크기 values[1] → values[0] index 를 왼쪽으로 복사 전체 슬롯 0→4 · 소스 추적 [index, index, list, null, null] i=2 읽기·쓰기와 크기 values[2] → values[1] list 를 왼쪽으로 복사 전체 슬롯 0→4 · 소스 추적 [index, list, list, null, null] 크기 감소와 정리 읽기·쓰기와 크기 values[--size] = null size=2 · old="array" 반환 전체 슬롯 0→4 · 소스 추적 [index, list, null, null, null] 이어 실제 출력: removed=array [index, list]
예의 값과 변화
관찰·상황값·보충 정보변화·이유
삭제값 보존

old에 array를 보관합니다.

  • 슬롯: array,index,list
  • size: 3
  • old: array
  • 반환할 값은 이동 전에 읽어 둔다.
왼쪽 두 이동

index와 list를 각각 앞 칸에 복사합니다.

  • 첫 이동: index,index,list
  • 둘째 이동: index,list,list
  • 사용 순서를 유지하며 삭제 빈자리를 채운다.
크기와 참조 정리

size 2 후 마지막 칸을 비우고 old를 반환합니다.

  • 슬롯: index,list,null
  • 반환: array
  • 목록 출력: [index,list]
  • 사용 구간 밖의 중복 참조를 정리한다.

인덱스 순서 보존

누적 CLI의 첫 저장소는 작은 동적 배열입니다.

사용자는 입력 순서대로 기록을 보고 합계를 얻습니다.

이 장의 직접 구현과 분리해 애플리케이션 규칙을 먼저 고정하면 이후 LinkedList로 교체할 때 회귀 결과를 비교할 수 있습니다.

app/PostRepositoryCliCH141.java
public final class PostRepositoryCliCH141 {
    public static void main(String[] args) {
        PostRepository repository = new PostRepository();
        repository.add("array-index", 35);
        repository.add("insert-shift", 45);
        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 회귀 기준은 세 가지입니다.

첫째, 0번과 1번 출력이 입력 순서를 보존합니다.

둘째, 두 게시글의 조회수 합은 80입니다.

셋째, 내부 배열의 남은 칸은 출력에 나타나지 않습니다.

이 기준을 고정하면 이후 용량 증가나 연결 저장소로 교체해도 사용자 관점의 차이를 즉시 찾을 수 있습니다.


배열 기반 목록이 맞는 작업 부하

질문관찰할 값선택 또는 조치
인덱스 조회가 많은가get 호출 비율연속 배열 우선
중간 삽입이 많은가평균 이동 칸 수연결 구조 검토
최대 크기를 아는가용량 상한고정 배열 가능
오류 진단이 필요한가경계 예외 위치공개 메서드에서 검사

데이터가 작다면 Big-O만으로 선택하지 않습니다.

인덱스 조회의 단순성, CPU 캐시의 연속 접근, 구현 복잡도를 함께 봅니다.

반대로 앞 삽입이 연속되고 이동량이 병목으로 측정되었다면 연결 구조를 검토할 근거가 생깁니다.


연습 문제

크기 8인 배열 목록에 새 값을 넣습니다.

인덱스 0, 3, 8에서 각각 몇 칸을 오른쪽으로 옮겨야 하는지 출력하세요.

실제 배열을 복사하지 말고 이동량 공식을 코드로 표현합니다.

정답과 해설

끝 위치 8에는 기존 원소가 없으므로 0회입니다.

나머지는 삽입 인덱스부터 마지막 기존 원소까지의 개수인 size - index입니다.

잘못된 인덱스도 조용히 계산하지 않도록 먼저 검사합니다.

exercise/ArrayShiftCountSolution.java
public final class ArrayShiftCountSolution {
    public static void main(String[] args) {
        int size = 8;
        for (int index : new int[] {0, 3, 8}) {
            System.out.println(index + "=" + movesForInsert(size, index));
        }
    }

    private static int movesForInsert(int size, int index) {
        if (index < 0 || index > size) {
            throw new IndexOutOfBoundsException("index=" + index + ", size=" + size);
        }
        return size - index;
    }
}

출력은 0=8, 3=5, 8=0입니다.

이 숫자는 삽입 코드의 반복 횟수와 정확히 대응하므로 성능 설명을 추측이 아니라 실행 가능한 규칙으로 바꿉니다.


인덱스 규칙 최종 확인

조회는 index < size, 삽입은 index <= size라는 차이를 코드 없이 말할 수 있어야 합니다.

오른쪽 이동은 뒤에서 시작하고 왼쪽 이동은 앞에서 시작합니다.

remove가 끝난 뒤 마지막 사용 슬롯은 null이어야 합니다.

이 세 규칙이 유지되면 고정 배열 목록의 기능 규칙은 완성됩니다.