Search
Duplicate
📒

[Java Study] 09-1. Array & ArrayList & Linked List

상태
완료
수업
Java Study
주제
Collection
4 more properties
참고

배열(Array)

NOTE
배열은 동일한 타입의 변수들을 순차적으로 저장하는데 사용됩니다. 각 배열의 요소는 배열 내에서 유일한 인덱스를 통해 접근할 수 있으며 인덱스는 0 ~ (배열길이 -1)까지 매겨집니다.
public static void main(String[] args) { int[] arr = new int[5]; //index 입력: O(1) System.out.println("==index 입력: O(1)=="); arr[0] = 1; arr[1] = 2; arr[2] = 3; System.out.println(Arrays.toString(arr)); //index 변경: O(1) System.out.println("==index 변경: O(1)=="); arr[2] = 10; System.out.println(Arrays.toString(arr)); //index 조회: O(1) System.out.println("==index 조회: O(1)=="); System.out.println("arr[2] = " + arr[2]); //검색: O(n) System.out.println("==배열 검색: O(n)=="); System.out.println(Arrays.toString(arr)); int value = 10; for (int i = 0; i < arr.length; i++) { System.out.println("arr[" + i + "]:" + arr[i]); if (arr[i] == value) { System.out.println(value + " 찾음"); break; } } }
Java
복사
배열 접근 O(1)
배열에서 자료를 찾을 때 인덱스를 사용하면 매우 빠르게 자료를 찾을 수 있다.
인덱스를 통한 입력, 변경, 조회의 경우 한번의 계산으로 자료의 위치를 찾을 수 있다.
인덱스를 통하지 않고, 특정한 값을 찾는 검색의 경우 배열안에 있는 데이터를 하나하나 확인해야 하기 때문에, O(n)의 시간복잡도가 걸린다.

접근 & 검색 시간복잡도

접근 (Access)
인덱스 기반 접근(랜덤 접근): 메모리 상에서 연속적으로 위치하고 각 요소 주소 계산이 가능하다.O(1)
검색 (Search)
선형 검색 (Linear Search): 특정 값이 있는 인덱스를 찾는 경우 O(n)
이진 탐색 (Bineary Serach): 정렬된 배열에서 특정 값을 찾는 경우 O(log n)

배열의 추가/삭제

NOTE
데이터 추가/삭제는 기존 데이터를 유지하면서 새로운 데이터를 입력하거나 제거하는것을 의미한다. 즉 기존 데이터가 오른쪽/왼쪽 으로 한 칸 씩 이동해야 하며, 추가/삭제 마다 공간을 확보해야한다.
배열의 경우 추가/삭제 연산의 경우 시작위치에서 작업하는 경우 배열의 전체 요소를 움직여야 합니다. 반면 배열의 끝에 데이터를 추가하는 작업은 매우 빠르고 효율적입니다.
public static void main(String[] args) { int[] arr = new int[5]; arr[0] = 1; arr[1] = 2; System.out.println("Arrays.toString(arr) = " + Arrays.toString(arr)); // 처음 위치에 추가 System.out.println("배열의 1번째 위치에 3추가 O(n)"); addFirst(arr, 3); System.out.println(Arrays.toString(arr)); // 인덱스 위치에 추가 System.out.println("배열의 index(2) 위치에 4 추가 O(n)"); addAtIndex(arr, 2, 4); System.out.println(Arrays.toString(arr)); // 마지막 위치에 추가 System.out.println("배열의 마지막 위치에 5 추가"); addLast(arr, 5); System.out.println(Arrays.toString(arr)); } // 처음 위치 추가 private static void addFirst(int[] arr, int newValue) { for (int i = arr.length - 1; i > 0; i--) { arr[i] = arr[i - 1]; } arr[0] = newValue; } // 인덱스 위치에 추가 private static void addAtIndex(int[] arr, int index, int newValue) { for (int i = arr.length - 1; i > index; i--) { arr[i] = arr[i - 1]; } arr[index] = newValue; } // 마지막 위치에 추가 private static void addLast(int[] arr, int newValue) { arr[arr.length - 1] = newValue; }
Java
복사
1번째 위치에 추가
기존의 모든 데이터를 오른쪽으로 한 칸씩 이동해야 합니다. → O(n)
index위치에 추가
인덱스 위치부터 이후의 모든 데이터를 오른쪽으로 한 칸씩 이동해야 합니다. → O(n)
마지막에 추가
기존 데이터를 이동할 필요가 없으며, 단순히 배열의 끝에 새로운 요소를 추가하면 됩니다.

추가 & 삭제 시간복잡도

추가 (Insertion)
배열의 시작에 원소를 삽입하는 경우: O(n)
배열의 중간 위치에 원소를 삽입하는 경우: O(n)
배열의 위치에 원소를 삽입하는 경우: O(1)
삭제 (Deletion)
배열의 시작에 원소를 삭제하는 경우: O(n)
배열의 중간에 원소를 삭제하는 경우: O(n)
배열의 에 원소를 삭제하는 경우: O(1)

List

NOTE
자바의 배열은 크기가 고정되어 있으며, 데이터 추가 시 불편함이 있습니다. 이를 해결하기 위해 크기가 동적으로 변할 수 있는 자료구조인 List를 구현해보겠습니다.
List 인터페이스는 java.util 패키지에 있는 컬렉션 프레임워크의 일부다. List는 객체들의 순서가 있는 컬렉션을 나타내며, 같은 객체의 중복 저장을 허용한다. 이 리스트는 배열과 비슷하지만, 크기가 동적으로 변화하는 컬렉션을 다룰 때 유연하게 사용할 수 있다.
자바 List 계층도
ArrayList: 동적 배열을 기본으로 하는 리스트 구조
LinkedList: Node를 통한 연결리스트 구조
Vector: ArrayList와 유사한 동적 배열을 사용하지만. 동기화를 제공한다 최근에는 거의 쓰이지 않는다.
public static void main(String[] args) { // List 생성 List<String> list = new ArrayList<>(); // add(E e): 리스트의 끝에 지정된 요소를 추가합니다. list.add("Apple"); list.add("Banana"); list.add("Cherry"); System.out.println("After add: " + list); // addAll(Collection c): 컬렉션의 모든 요소를 추가합니다. List<String> anotherList = List.of("Peach", "Grapes"); list.addAll(anotherList); System.out.println("list = " + list); // addAll(int idx, Collection c): 지정된 위치에 컬렉션의 모든 요소를 추가합니다. List<String> anotherList2 = List.of("Orange", "Grapes"); list.addAll(2, anotherList2); System.out.println("list = " + list); // set(int idx, E element): 요소변경 list.set(3, "Kiwi"); // remove(int idx) idx 요소 제거 String isRemove1 = list.remove(4); System.out.println("isRemove1 = " + isRemove1); // remove(E element) element 제거 boolean isRemove2 = list.remove("Banana"); System.out.println("isRemove2 = " + isRemove2); // contains(E element) element 검색결과 boolean contains = list.contains("Cherry"); System.out.println("contains = " + contains); // sort(Comparator c): 비교자에 따라 정렬 list.sort(null); list.sort(Comparator.naturalOrder()); // subList(int fromIdx, int toIndex) 리스트 일부분 반환 List<String> subList = list.subList(1, 3); System.out.println("subList = " + subList); // iterator(): 리스트의 반복자 반환 Iterator<String> iterator = list.iterator(); while (iterator.hasNext()) { String next = iterator.next(); System.out.println("next = " + next); } }
Java
복사

ArrayList

NOTE
java.util.ArrayList는 동적 배열을 사용하며, 메모리 고속 복사 연산을 사용해서 데이터 추가/삭제시 요소이동 연산에 대해 System.arrayCopy()를 사용해 최적화한다.
java.util.ArrayList의 데이터복사
위와 같은 방식을 사용해 시스템 레벨에서 매우 빠르게 배열을 복사할 수 있습니다. OS나 하드웨어에 따라 성능이 다르기 때문에 정확한 측정이 어렵지만, 1칸씩 이동하는것 보다는 수 배 이상의 성능을 보장합니다.
public class MyArrayListV4<E> { // 최초 크기 private static final int DEFAULT_CAPACITY = 5; private Object[] elementData; private int size = 0; // 생성자 // 제네릭은 new를 사용할 수 없어 Object사용 public MyArrayListV4(){ elementData = new Object[DEFAULT_CAPACITY]; } public MyArrayListV4(int initialCapacity){ elementData = new Object[initialCapacity]; } // 현재 크기반환 public int size(){ return size; } // 추가 public void add(E e) { // 길이 초과시, 동적 리사이징 if (size == elementData.length) { grow(); } elementData[size] = e; size++; } // 특정 인덱스위치에 추가 public void add(int index, E e) { // 길이 초과시, 동적 리사이징 if (size == elementData.length) { grow(); } shiftRightFrom(index); elementData[index] = e; size++; } // 제거 public E remove(int index) { E oldValue = get(index); shiftLeftFrom(index); size--; elementData[size] = null; return oldValue; } // -> 1칸씩 이동 private void shiftRightFrom(int index) { for (int i = size; i > index; i--) { elementData[i] = elementData[i - 1]; } } // <- 1칸씩 이동 private void shiftLeftFrom(int index) { for (int i = index; i < size - 1; i++) { elementData[i] = elementData[i + 1]; } } // 동적배열 크기 확장(2배) private void grow() { int oldCapacity = elementData.length; int newCapacity = oldCapacity * 2; elementData = Arrays.copyOf(elementData, newCapacity); } // 특정 인덱스 값 조회 // Object -> E 변환 @SuppressWarnings("unchecked") public E get(int index) { return (E) elementData[index]; } // 특정 인덱스 수정 public E set(int index, E element) { E oldValue = get(index); elementData[index] = element; return oldValue; } // 검색 public int indexOf(E o) { for (int i = 0; i < size; i++) { if (o.equals(elementData[i])) { return i; } } return -1; } public String toString(){ return Arrays.toString(Arrays.copyOf(elementData, size)) + " size=" + size + ", capacity=" + elementData.length; } }
Java
복사
구현예시
public static void main(String[] args) { collection.array.MyArrayListV4<String> stringList = new collection.array.MyArrayListV4<>(); stringList.add("a"); stringList.add("b"); stringList.add("c"); String string = stringList.get(0); System.out.println("string = " + string); collection.array.MyArrayListV4<Integer> intList = new collection.array.MyArrayListV4<>(); intList.add(1); intList.add(2); intList.add(3); Integer integer = intList.get(0); System.out.println("integer = " + integer); }
Java
복사
사용 코드

Linked List

NOTE
java.util.LinkedList는 노드를 연결하여 구성하며, 필요한 만큼만 메모리를 사용합니다. 추가나 삭제 시에도 효율적입니다.

단일 연결 리스트

단일 연결 리스트
단일 연결 리스트는 각 Node가 다음요소만을 가리키는 단일 방향 구조입니다.
시간 복잡도
인덱스 조회: O(n)
검색: O(n)
앞에 추가/삭제: O(1)
뒤에 추가/삭제: O(n)
평균 추가/삭제: O(n)
public class MyLinkedListV3<E> { // Node 변수 private Node<E> first; private int size = 0; // 추가 public void add(E e) { Node<E> newNode = new Node(e); if (first == null) { first = newNode; }else { Node<E> lastNode = getLastNode(); lastNode.next = newNode; } size++; } // 추가 index public void add(int index, E e) { Node<E> newNode = new Node<>(e); if (index == 0) { newNode.next = first; first = newNode; }else{ Node<E> prev = getNode(index - 1); newNode.next = prev.next; prev.next = newNode; } size++; } // 제거 public E remove(int index) { Node<E> removeNode = getNode(index); E removedItem = removeNode.item; if (index == 0) { first = removeNode.next; }else{ Node<E> prev = getNode(index - 1); prev.next = removeNode.next; } removeNode.item = null; removeNode.next = null; size--; return removedItem; } // 수정 public E set(int index, E element) { Node<E> x = getNode(index); E oldValue = x.item; x.item = element; return oldValue; } // 인덱스 조회 public E get(int index) { Node<E> node = getNode(index); return node.item; } // 특정 값 검색 public int indexOf(E o) { int index = 0; for (Node<E> x = first; x != null; x = x.next) { if (o.equals(x.item)) { return index; } index++; } return -1; } public int size(){ return size; } // 마지막 노드 조회 private Node<E> getLastNode() { Node<E> x = first; while (x.next != null) { x = x.next; } return x; } // index 노드 조회 private Node<E> getNode(int index) { Node<E> x = first; for (int i = 0; i < index; i++) { x = x.next; } return x; } // 노드 중첩 클래스 private static class Node<E>{ E item; Node<E> next; public Node(E item) { this.item = item; } @Override public String toString() { StringBuilder sb = new StringBuilder(); Node<E> x = this; sb.append("["); while (x != null) { sb.append(x.item); if (x.next != null) { sb.append("->"); } x = x.next; } sb.append("]"); return sb.toString(); } } }
Java
복사
구현 예제코드

이중 연결 리스트

이중 연결 리스트
java.util.LinkedList는 이중 연결 리스트 구조를 가지고 있습니다.
시간 복잡도
인덱스 조회: O(n)
검색: O(n)
앞에 추가/삭제: O(1)
뒤에 추가/삭제: O(1)
평균 추가/삭제: O(n)
사용 사례
양방향 탐색이 필요한 경우(텍스트 데이터, 페이징 처리)
데이터의 삽입 및 삭제가 빈번한 경우(캐시 구현, 작업 큐의 관리)

연결 리스트 순환확인

LinkedList는 마지막 node의 next를 first에 설정하면 순환할 수 있습니다. 이러한 순환을 확인하는 방법중 유명한 방법은 플로이드의 사이클 검출 알고리즘이 있습니다.
포인터 2개를 사용하며 각각의 포인터는 다음과 같이 동작합니다.
빠른 포인터: 1번에 2개의 노드를 건너띈다.
느린 포인터: 1번에 1개의 노드를 건너띈다.
만약 순환되지 않는다면, fast 포인터가 먼저 null에 도착한다.
fast와 slow 포인터는 결국 n번안에 한번은 만난다.

배열 리스트 vs 연결 리스트 성능비교

NOTE
ArrayList와 LinkedList는 각각 어떠한 경우에 사용해야하는지 알기위해서 성능 비교를 위한 테스트 코드를 작성해봅시다!
대부분의 상황에서는 ArrayList가 성능상 유리합니다. 단 데이터를 앞쪽에 자주 추가하거나 삭제한다면 LinkedList를 고려하는것이 좋습니다.
실제로 알고리즘에서 자주 사용되는 자바11 환경에서도 테스트 결과가 21버전과 유사하게 나타납니다.
public static void main(String[] args) { int size = 100_000; int loop = 10_000; // ArrayList System.out.println("===ArrayList 추가==="); addFirst(new ArrayList<>(), size); addMid(new ArrayList<>(), size); ArrayList<Integer> arrayList = new ArrayList<>(); addLast(arrayList, size); System.out.println("===ArrayList 조회==="); getIndex(arrayList, loop, 0); getIndex(arrayList, loop, size /2); getIndex(arrayList, loop, size -1); System.out.println("===ArrayList 검색==="); search(arrayList, loop, 0); search(arrayList, loop, size /2); search(arrayList, loop, size -1); // LinkedList System.out.println("===LinkedList 추가==="); addFirst(new LinkedList<>(), size); addMid(new LinkedList<>(), size); LinkedList<Integer> linkedList = new LinkedList<>(); addLast(linkedList, size); System.out.println("===LinkedList 조회==="); getIndex(linkedList, loop, 0); getIndex(linkedList, loop, size /2); getIndex(linkedList, loop, size -1); System.out.println("===LinkedList 검색==="); search(linkedList, loop, 0); search(linkedList, loop, size /2); search(linkedList, loop, size -1); // List.of List<Integer> immutableList = createImmutableList(size); System.out.println("===List.of 조회==="); getIndex(immutableList, loop, 0); getIndex(immutableList, loop, size / 2); getIndex(immutableList, loop, size - 1); System.out.println("===List.of 검색==="); search(immutableList, loop, 0); search(immutableList, loop, size / 2); search(immutableList, loop, size - 1); } private static List<Integer> createImmutableList(int size) { Integer[] array = new Integer[size]; for (int i = 0; i < size; i++) { array[i] = i; } return List.of(array); } private static void addFirst(List<Integer> list, int size) { long startTime = System.currentTimeMillis(); for (int i = 0; i < size; i++) { list.add(0, i); } long endTime = System.currentTimeMillis(); System.out.println("앞에 추가 - 크기: " + size + ", 계산 시간: " + (endTime - startTime) + "ms"); } private static void addMid(List<Integer> list, int size) { long startTime = System.currentTimeMillis(); for (int i = 0; i < size; i++) { list.add(i / 2, i); } long endTime = System.currentTimeMillis(); System.out.println("중간에 추가 - 크기: " + size + ", 계산 시간: " + (endTime - startTime) + "ms"); } private static void addLast(List<Integer> list, int size) { long startTime = System.currentTimeMillis(); for (int i = 0; i < size; i++) { list.add(i); } long endTime = System.currentTimeMillis(); System.out.println("뒤에 추가 - 크기: " + size + ", 계산 시간: " + (endTime - startTime) + "ms"); } // loop 만큼 index 조회 private static void getIndex(List<Integer> list, int loop, int index) { long startTime = System.currentTimeMillis(); for (int i = 0; i < loop; i++) { list.get(index); } long endTime = System.currentTimeMillis(); System.out.println("index: " + index + ", 반복: " + loop + ", 계산 시간: " + (endTime - startTime) + "ms"); } // loop 만큼 findValue 검색 private static void search(List<Integer> list, int loop, int findValue) { long startTime = System.currentTimeMillis(); for (int i = 0; i < loop; i++) { list.indexOf(findValue); } long endTime = System.currentTimeMillis(); System.out.println("findValue: " + findValue + ", 반복: " + loop + ", 계산 시간: " + (endTime - startTime) + "ms"); }
Java
복사

추가 성능

작업
ArrayList 시간
LinkedList 시간
List.of 시간 (불변 리스트)
앞에 추가
438ms
2ms
N/A
중간에 추가
206ms
4160ms
N/A
뒤에 추가
3ms
5ms
N/A
ArrayList는 동적 배열로서, 추가할 때마다 연산이 필요합니다. 그러나 가장 앞에 추가하는 경우를 제외하면, 성능이 더 우수합니다. 실제 성능은 순차적 접근 속도, 메모리 할당 및 해제 비용, CPU 캐시 등 다양한 요소에 의해 결정됩니다.
ArrayList: 메모리상에 연속적으로 위치하여 CPU 캐시 효율이 좋고, 메모리 접근 속도가 더 빠르다.
LinkedList: 별도의 객체로 존재하며, 참조값이기에 CPU캐시 효율이 떨어지고 메모리 접근에도 영향이 있다.

조회 성능

인덱스
ArrayList 시간
LinkedList 시간
List.of 시간 (불변 리스트)
0
0ms
0ms
0ms
50000
0ms
775ms
0ms
99999
0ms
0ms
0ms
ArrayList: index 접근을 통해 조회하기 때문에 O(1)이 걸립니다.
LinkedList: index만큼 순회해서 찾아야하기 때문에 O(n)이 걸립니다.
Lis.of: index 접근을 통해 조죄하기 때문에 O(1)이 걸립니다.

검색 성능

검색 값
ArrayList 시간
LinkedList 시간
List.of 시간 (불변 리스트)
0
1ms
0ms
1ms
50000
172ms
892ms
889ms
99999
333ms
1765ms
1796ms
List.ofArrayList의 경우 구현은 비슷하나 ArrayList의 경우 최적화가 더 잘되어 있어 성능상 차이가 납니다.
ArrayList: 해당 요소를 찾을때 까지 순회하기 때문에 O(n)이 걸립니다.
LinkedList: 해당 요소를 찾을때 까지 순회하기 때문에 O(n)이 걸립니다.
Lis.of: 해당 요소를 찾을때 까지 순회하기 때문에 O(n)이 걸립니다.

컴파일 타임 vs 런타임 의존관계

NOTE
자바에서 의존관계는 크게 컴파일 타임 의존관계와 런타임 의존관계로 나눌 수 있습니다. 이를 통해 코드의 유연성과 확장성을 높일 수 있습니다.

컴파일 타임 의존관계

컴파일 타임 의존관계는 자바 컴파일러가 확인하는 의존 관계로, 클래스에 명시적으로 드러나는 모든 의존관계를 의미합니다.
public class BatchProcessor { private final MyList<Integer> list; public BatchProcessor(MyList<Integer> list) { this.list = list; // 구체타입에 의존하지 않음 } public void logic(int size) { // 로직 수행 } }
Java
복사
MyList<Integer> list는 MyList 인터페이스를 참조
BatchProcessorMyList에 의존합니다. 이 때 MyArrayListMyLinkedList와 같은 구체 클래스에 의존하지 않습니다.

런타임 의존관계

런타임 의존관계는 프로그램이 실행되는 시점에서 결정되는 의존관계이며, 실제로 생성된 인스턴스와 인스턴스를 참조하는 의존관계로, 프로그램 실행 중에 동적으로 변경될 수 있습니다.
public static void main(String[] args) { MyArrayList<Integer> list1 = new MyArrayList<>(); BatchProcessor process1 = new BatchProcessor(list1); process1.logic(100_000); MyLinkedList<Integer> list2 = new MyLinkedList<>(); BatchProcessor process2 = new BatchProcessor(list2); process2.logic(100_000); }
Java
복사
MyList<Integer> list는 MyArrayList를 사용한다.
MyList<Integer> list는 MyLinkedLIst를 사용한다.
BatchProcessor 인스턴스는 MyArrayList 또는 MyLinkedList 인스턴스를 참조하게 됩니다. 이처럼 다양한 구현체를 주입받아 사용할 수 있습니다.
런타임 의존관계를 주입하는 방법의 대표적인 방식으로 생성자 의존관계 주입입니다. 이를 통해서 컴파일 타임에 추상화된 인터페이스에 의존하고, 런타임에 구체적인 구현체를 주입받아 사용할 수 있습니다.

전략 패턴

전략 패턴은 알고리즘을 클라이언트 코드의 변경 없이 쉽게 교체할 수 있는 디자인 패턴입니다.
MyList인터페이스가 전략을 정의하는 인터페이스가 되고, 각각의 구현체인 MyArrayList와 MyLinkedList가 구체적인 전략 구현이 됩니다.
public interface MyList<T> { void add(T element); T get(int index); int size(); } public class MyArrayList<T> implements MyList<T> { // 구현 코드 } public class MyLinkedList<T> implements MyList<T> { // 구현 코드 } public class BatchProcessor { private final MyList<Integer> list; public BatchProcessor(MyList<Integer> list) { this.list = list; } public void logic(int size) { // 로직 수행 } }
Java
복사
BatchProcessor(클라이언트 코드)는 코드의 변경 없이도 다양한 전략을 사용할 수 있게됩니다.
재사용성을 늘린다 ⇒ 작업을 나중으로 미룬다.

전략 패턴

NOTE
전략 패턴은 알고리즘을 클라이언트 코드의 변경 없이 쉽게 교체할 수 있는 디자인 패턴입니다.
MyList인터페이스가 전략을 정의하는 인터페이스가 되고, 각각의 구현체인 MyArrayListMyLinkedList가 구체적인 전략 구현이 됩니다.
public interface MyList<T> { void add(T element); T get(int index); int size(); } public class MyArrayList<T> implements MyList<T> { // 구현 코드 } public class MyLinkedList<T> implements MyList<T> { // 구현 코드 } public class BatchProcessor { private final MyList<Integer> list; public BatchProcessor(MyList<Integer> list) { this.list = list; } public void logic(int size) { // 로직 수행 } }
Java
복사
BatchProcessor(클라이언트 코드)는 코드의 변경 없이도 다양한 전략을 사용할 수 있게됩니다.
재사용성을 늘린다 ⇒ 작업을 나중으로 미룬다.