들어가며

 사실 단일 연결 리스트는 운좋게 적합한 상황이 나와주지 않는 이상 사용될 일이 잘 없고 데이터 순회가 양방향으로 가능한 이중 연결 리스트가 더 많이 사용된다. 그럼에도 불구하고 단일 연결 리스트는 한 번 쯤 구현해보는 것이 좋다고 생각한다. 자료구조에서 ArrayList, Stack과 같은 배열 기반의 자료구조들을 제외하면 대부분의 자료구조가 노드와 같이 데이터와 참조를 가진 형태로 구현되는데 그러한 자료구조 중 단일 연결 리스트의 구현 난이도가 가장 낮다고 생각하기 때문이다.  

 

 

 연결 리스트(Linked List)

 https://ku-develog.tistory.com/25

 

 

 

 단일 연결 리스트(Singly Linked List)

Singly Linked List

 단일 연결 리스트를 그림으로 표현하면 위와 같다. 가장 기본적이고 심플한 연결 리스트 구조로 head에서 시작해서 노드의 참조가 null일때까지 순방향으로 조회한다.

 

 

 

연결 리스트 구현하기

LinkedList 클래스

public class LinkedList<E> {
	private Node<E> head;
	private int size;
	..
}

 

 연결 리스트의 메인이 되는 클래스다. 여기서 연결 리스트의 동작(Method)을 작성한 뒤 메인 메서드에서 객체화하여 사용할 것이다. 간단하게 멤버 변수를 보면 앞서 설명했던 첫 노드의 참조를 가지는 head가 존재하는 것을 볼 수 있다.

 

 두 번째로 size는 이름과 자료형에서 추측할 수 있듯 리스트에 추가된 요소들의 개수를 의미한다. 연결 리스트의 특성상 데이터의 총 개수를 확인하려면 모든 데이터를 순회하며 카운팅해야 하는 번거로움이 있기 때문에 이렇게 변수를 두고 사용하는 것이 일반적이다. 데이터가 추가/삭제될 때마다 값을 증감시켜서 리스트의 총 개수 정보를 유지할 것이다. 

 

 

Node 클래스

class Node<E> {
	private E element;
	private Node<E> next;
	
	public Node(E element) {
		this.element = element;
	}
	
	public Node(Node<E> next, E element) {
		this.next = next;
		this.element = element;
	}
}

 노드 클래스는 저장될 데이터의 타입을 아직 모르기 때문에 제네릭 타입으로 지정하였다. 간단하게 멤버 변수를 살펴보면 다음 노드에 대한 참조를 저장하는 next 변수, 실질적인 데이터가 저장될 element 변수를 가지고 있다. 예제인 만큼 코드 길이를 줄이기 위해서 이 클래스는 LinkedList의 내부 클래스로 사용한다.

 

 

범위 확인 메서드

private void rangeCheck(int index) {
	if(index >= size || index < 0)
		throw new IndexOutOfBoundsException(
				"Index " + index + " out of bounds for length " + size);
}

 특정 위치의 노드 삭제, 탐색 시에 사용되는 범위 체크 메서드다. 예를 들어 특정 위치의 노드를 삭제하려고 했으나 범위에서 벗어난 값인 경우 예외를 발생시킨다.

 

 이렇게 여러 메서드에서 공통으로 사용되는 코드는 하나의 메서드로 만들어서 공통으로 사용하면 전체 코드의 길이를 줄일 수 있고 코드 수정이 발생해도 이 코드만 수정하면 되는 장점이 있다.

 

 

 

삽입, 삭제, 탐색

삽입

 노드를 삽입은 다음과 같이 여러 가지 방법이 존재하는데 여기서는 간단하게 처음과 끝에 삽입하는 것을 구현해 볼 것이다.

  • 첫 번째에 노드 삽입
  • 특정 위치에 노드를 삽입
  • 마지막에 삽입
  • 리스트를 처음부터 순회하여 특정 데이터를 찾아 삭제

 

 

첫 번째에 노드 삽입

public void addFirst(E e) {
	Node<E> newNode = new Node<>(e);
	newNode.next = head;
	head = newNode;
	size++;
}

 리스트의 처음 위치 즉, head의 앞에 노드를 추가하는 메서드다. 이 작업은 연결 리스트 기초 이론과 객체 참조에 대한 개념만 제대로 잡혀 있다면 굉장히 쉽다. 새로운 Node 객체를 생성하면서 파라미터로 받은 데이터를 초기화한다. 그리고 다음 새로운 노드의 참조를 head 노드로 지정한 뒤 연결 리스트가 가진 head를 새로운 노드로 교체해주면 된다.

 

 

마지막에 삽입

public void addLast(E e) {
	if(size == 0) {
		addFirst(e);
		return;
	}
	
	Node<E> newNode = new Node<>(e);
	Node<E> preNode = head;
	
	while(preNode.next != null)
		preNode = preNode.next;
	
	preNode.next = newNode;
	size++;
}

 리스트의 마지막 위치에 노드를 추가하는 메서드다. 연결 리스트 객체는 첫 번째 노드의 위치만 알고 있기 때문에 마지막 노드에 도달하기 위해서는 모든 노드를 순회해야만 한다.

 

 새롭게 추가될 노드 객체를 초기화하고 순회를 하는데 사용할 참조 변수 preNode에 head의 위치를 초기화한다. preNode는 반복문을 통해 다음 참조(next)가 null인 노드, 즉 마지막 노드를 찾을 때 까지 순회한다. 마지막 노드에 도달했을 때 반복문을 종료하고 마지막 노드의 다음 참조에 새로운 노드를 초기화해주는 것으로 데이터를 연결해줄 수 있다.

 

 

삭제

 노드를 삭제하는 방법은 다음과 같이 여러 가지 방법이 존재하는데 삽입과는 반대로 특정 위치에 노드를 삭제하는 것을 구현해본다.

  • 첫 번째 노드 삭제
  • 특정 위치의 노드를 삭제
  • 마지막 노드 삭제
  • 리스트를 처음부터 순회하며 특정 데이터를 찾아 삭제

 

 

특정 위치의 노드를 삭제

public E remove(int index) {
	rangeCheck(index);
	
	if(index == 0) { // 삭제 대상이 첫 번째 노드일 경우
		Node<E> removeNode = head;
		head = head.next;
		
		E removeElement = removeNode.element;
		removeNode.element = null;
		removeNode.next = null;
			
		if(--size == 0)
			head = null;
		
		return removeElement;
	}
   
	// 삭제 대상이 첫 번째 노드가 아닐 경우
	Node<E> preNode = head;
	
	for(int i = 1; i < index -1; i++)
		preNode = preNode.next;
		
	Node<E> removeNode = preNode.next;
	E removeElement = removeNode.element;
	preNode.next = removeNode.next;
	removeNode.element = null;
	removeNode.next = null;
	
	size--;
	
	return removeElement;
}

 코드를 보면 먼저 범위 체크를 통해 삭제하려는 노드의 위치가 범위를 벗어났는지 체크한다. 범위에 문제가 없을 경우 삭제 위치가 head 즉, 0인지 확인한다. 삭제 위치가 첫 번째 노드라면 작업이 매우 간단해지는데, head의 참조를 다음 노드로 변경시켜주기만 하면 된다. 이 때 삭제하는 노드의 값들을 null로 초기화해주면서 GC에 의해 수거될 수 있도록 한다.

 

 삭제 대상이 head가 아닐 경우 head 노드를 시작으로 삭제 노드 이전까지 순회한다. 그리고 다음 노드(삭제 대상)의 next값을 가져와 현재 노드의 next로 초기화하는 것으로 삭제 노드 앞과 뒤의 노드가 연결하면 특정 위치에 있는 노드를 삭제할 수 있다.

 

 특정 위치의 데이터를 삭제하는 방식의 핵심은 삭제 노드 이전까지만 리스트를 순회를 하는 것이다. 이렇게 하는 이유는 각 노드가 다음 노드에 대한 참조만 알고 있고 이전 노드의 존재는 모르고 있기 때문에 특정 노드를 삭제하고 앞뒤의 노드를 연결해주는 작업을 하기 위해서는 삭제 바로 앞의 노드가 필요한 것이다.

 

 마무리 작업으로 size 변수를 감소시켜 리스트의 총 요소 수를 유지해준다. 또한 삭제 대상이 된 노드 객체가 GC에 수거될 수 있도록 참조를 없애준다.

 

 실제로 노드 삭제를 구현할 땐 좀 더 세세하게 메서드를 나누지만 여러 메서드를 나누어서 구현한 걸 올리려다 보니 글이 너무 길어지고 설명할 것이 많아져 하나로 통합했다.

 

 

탐색

public E get(int index) {
	rangeCheck(index);
	Node<E> node = head;
	for(int i = 0; i < index; i++)
		node = node.next;
	return node.element;
}

 삭제 파트에서 노드 순회를 구현해봤으니 탐색은 간단하게 구현할 수 있을 것이다. 특정 위치의 노드까지 순회하고 노드가 가진 데이터를 반환한다.

 

 


 

 

이중 연결 리스트보다 단일 연결 리스트가 더 나은 경우

 포스팅을 하면서 단일 연결 리스트가 나은 경우가 과연 얼마나 있을까하는 생각이 들었다. 이중 연결 리스트의 구현 난이도는 단일 연결 리스트보다 상대적으로  복잡할 뿐 사실 둘 다 굉장히 쉬운 편에 속한다. 구현 난이도는 비슷하지만 장단점을 생각해봤을 때 이중 연결리스트가 훨씬 나은데, 과연 단일 연결 리스트를 사용할 상황이 있을까?

 

 개인적으로 생각해봐도 구현 난이도가 상대적으로 낮아진다는 것 외에는 딱히 떠오르는 것이 없어서 Stackflow를 뒤져봤다.

  • 데이터 탐색이 반드시 순방향으로만 이루어질 경우
  • 사용할 수 있는 메모리의 양이 극한으로 제한적인 경우
  • 기초에 충실한 기본적인 스택을 구현할 경우

연결 리스트(Linked List)

 연결 리스트는 노드(Node)라는 단위로 데이터를 저장하는 자료구조다. 배열처럼 일정한 크기의 메모리 공간을 할당받아 데이터를 저장하는 것이 아닌 노드(데이터)가 삽입될 때마다 메모리를 할당받고 마지막 노드가 새로운 노드에 대한 참조를 저장해 나가는 방식으로 리스트를 구성한다.

 

 

 노드(Node)

 연결 리스트는 데이터를 저장할 때 노드(Node) 단위로 데이터를 저장하는데 노드는 데이터와 또 다른 노드가 있는 곳을 가리키는 참조 변수로 구성된다. 여기서 참조 변수는 리스트의 구현 방식에 따라 1개가 될 수도 있고 2개 이상이 될 수도 있다. 

 

Node Class of Singly Linked List

 Node를 클래스로 표현하면 위와 같다. 이 노드는 단일 연결 리스트의 노드 클래스로 연결 리스트 중에서는 가장 최소화된 구조라고 할 수 있다.(element가 데이터, next가 link) 노드 클래스는 연결 리스트의 종류에 따라 링크의 개수, 변수 이름(역할에 따른 직관성)정도가 달라질 뿐 같은 형태를 취하고 있다.

 

 

 

배열과 연결 리스트

배열, 연결 리스트가 할당된 메모리 상태

 

 Array

 배열은 초기화할 때 할당받은 크기로 메모리 길이가 고정되며, 할당받은 공간만큼의 메모리만 사용할 수 있다는 특징이 있다. 이러한 특징은 저장할 데이터의 개수가 정해져 있을 때에는 메모리 공간 낭비가 없어 효율적이지만 저장할 데이터의 개수를 모르는 상황에서는 다르다. 배열의 특성상 메모리 공간을 할당받으려면 크기가 명확하게 정해져야 하기 때문에 저장될 데이터 수를 정확히 모르더라도 예상해서 공간을 할당받을 수밖에 없다. 이 과정에서 너무 많은 공간을 할당받는 것은 메모리 누수를 초래할 수 있고 반대로 너무 적은 공간을 할당받으면 오버 플로우를 초래하게 된다.

 

 

 Array, LinkedList

 반면 연결 리스트는 배열에 비해 공간 확장의 제한을 받지 않는다. 왜냐하면 배열처럼 메모리 공간을 미리 할당받지 않고 데이터가 삽입될 때마다 메모리를 할당받은 뒤 노드끼리는 참조로 연결되기 때문이다. 이러한 구조는 고정적인 크기의 배열과 달리 메모리 공간이 허용하는 한 계속해서 노드를 늘려갈 수 있다.

 

 

 

연결 리스트 노드 삽입

 연결 리스트를 조작하는 방법 중 삽입에 대해서만 알아보자. 

단일 연결 리스트 데이터 삽입 과정

 연결 리스트 객체는 Node 타입의 head라는 멤버 변수를 갖는다. head는 null로 초기화되어 있다가 처음으로 노드가 추가될 때 비로소 초기화되며 이 때 link는 null 값을 가진다. link는 다른 노드의 참조값이나 null 중 하나가 초기화되는데 참조값을 가진 경우 연결된 노드가 있다고 보고 null일 경우 해당 노드가 마지막이라고 본다. 따라서 첫 노드가 head에 초기화되었고 link에 null을 초기화하고 있다는 것은 이 노드가 처음이자 마지막이라는 뜻이 된다.

 

 두 번째 노드부터는 연결 리스트의 끝, 즉 마지막 노드의 뒤에 노드를 추가한다. 마지막 노드를 찾기 위해선 연결 리스트의 head 노드가 가진 링크를 시작으로 노드를 계속해서 참조해나가나다 link가 null인 노드를 찾으면 순회를 멈추고 다시 노드를 연결한다.

 

 

 

연결 리스트 종류

  연결 리스트는 크게 3가지 종류가 있다. 

  • 단일 연결 리스트(Singly Linked List)
  • 이중 연결 리스트(Doubly Linked List)
  • 원형 연결 리스트(Circular Linked List)

 이 외에 청크 연결 리스트(Chunked Linked List), 다중 연결 리스트(Multi Linked List) 등이 있다.

 

 

단일 연결 리스트(Singly Linked List)

Singly Linked List

 단순 연결 리스트라고도 하며 연결 리스트들 중에서 가장 기본이 되는 연결 리스트로, 각 노드는 다음 노드에 대한 정보만을 가지고 노드끼리는 서로 인식하지 못하는 기본적인 구조를 띄고 있다. 구현 난이도는 ArrayList보다 약간 어려운 정도다. 

 

 

 이중 연결 리스트(Doubly Linked List)

Doubly Linked List

 양방향 연결 리스트라고도 하며 단일 연결 리스트와 다르게 이전 노드의 링크를 저장하고 있다는 것이 특징이다. 노드는 자신의 앞뒤 노드를 인식하고 있기 때문에 노드를 순회하다가 앞으로 돌아갈 일이 생기면 링크를 따라서 이전 노드로 돌아갈 수 있다. 노드를 연결/삭제할 때마다 이전 노드까지 신경 써줘야 하지만 단일 연결 리스트를 구현할 수 있다면 이 또한 어렵지 않게 구현할 수 있다.

 

 

 원형 연결 리스트(Circular Linked List)

Circular Linked List

 환형 연결 리스트라고도 하며 특징은 그럼처럼 리스트의 마지막 노드가 null이 아닌 첫 번째 노드를 가리키고 있다는 점이다. 앞서 나온 두 리스트와는 달리 노드의 끝에 도달해도 끝나지 않고 head로 돌아가는 것이 특징이다.

 

 

 

연결 리스트의 head, tail

 연결 리스트의 끝을 가리키는 tail

 연결 리스트의 처음 위치에 데이터를 삽입한다고 생각해보자. 첫 번째 노드 즉, head의 앞에 삽입하면 되는 매우 간단한 작업이다. 새로운 노드의 다음 링크를 head로 지정한 뒤 연결 리스트가 가리키는 head의 위치를 새로운 노드로 변경해 주면 된다.

 

 반대로 연결 리스트의 끝에 노드를 삽입한다고 생각해보자. 데이터를 삽입하려면 마지막 노드를 알아야만 한다. 새로 생성한 노드를 마지막 노드로 만들기 위해서는 현재 마지막 노드의 link를 수정해야 하기 때문이다. 마지막 노드를 찾는 것은 매우 간단하다. 그냥 head부터 연결된 모든 노드를 거치면 된다. 구현도, 이론도 매우 간단하지만 데이터가 많을 수록 거쳐야 하는 노드가 늘어나는 문제가 있다. 

 

 여기서 tail이 등장한다. 말 그대로 꼬리, 연결 리스트가 헤드와 더불어 마지막 노드의 참조를 가지는 것이다. 첫 번째 노드(head)와 더불어 마지막 노드(tail)에 대한 참조를 가지고 있으면 리스트 양 끝에 한 번에 접근이 가능하다.

 

 tail을 무조건 구현해야 하는 것은 아니다. 상황에 따라 head만 필요한 경우도 있기 때문에 상황에 맞게 구현하면 된다.

 ArrayList는 배열을 사용하는 스택, 큐와 더불어 가장 구현하기 쉬운 자료구조가 아닐까 생각헌다. 배열과 메모리에 대한 이해가 어느정도 되어 있다면 그리 어렵지 않을 것이라 생각하고, 추가적으로 인터페이스와 제네릭, 오버로딩에 대한 지식이 필요하다.

 

 iterator는 구현하지 않았기 때문에 컬렉션에서 제공하는 ArrayList처럼 for each로 순회하거나 iterator()를 호출할 수 없다. 반복자까지 구현하면 포스팅이 너무 커져서 생략했다.

 

 구현하면서 멤버 변수, 메서드의 이름, 파라미터 이름 등은 ArrayList API Documentation를 보고 작성했다. 또한 배열 인덱스 범위를 확인하는데 있어 ArrayList 클래스에서 사용하는 방법이 너무 마음에 들어 나도 적용해보았다. 내가 작성한 코드는 add, remove 등 배열에 접근해야 하는 메서드를 작성할 때 메서드 초기에 계속 범위 체크를 해주고 있었다. 그런데 ArrayList 클래스는 "rangeCheck.." 같은 범위 체크 메서드를 만들어놓고 단 한 줄로 호출하여 검사하니 코드가 눈에 띄게 줄어들었고 중복이 상당히 줄어들어 있었다.

 

 ArrayList를 다루는 메서드들(add, remove, etc)에 대한 구현은 그다지 어렵지 않았다. 하지만 구현하고 난 뒤 ArrayList에서 범위 체크 메서드를 따라하니 거의 같은 코드가 되어 버린 것 같다.

 

 목차 겸 바로가기

 메서드 요약

 멤버 변수

 생성자

 데이터 삽입

 데이터 삭제

 데이터 검색

 데이터 수정

 배열(메모리) 확장 및 축소

 

 

 

 

 아래 "전체 코드 보기"를 누르면 전체 코드를 볼 수 있다.

 

더보기
public class ArrayList<E> {
	private int size;
	private final int DEFAULT_CAPACITY = 10;
	
	private final Object[] EMPTY_ELEMENT_DATA = {};
	private Object[] elementData;
	
	public ArrayList() {
		elementData = EMPTY_ELEMENT_DATA;
	}
	
	public ArrayList(int initialCapacity) {
		if(initialCapacity > 0)
			elementData = new Object[initialCapacity];
		else if(initialCapacity == 0)
			elementData = EMPTY_ELEMENT_DATA;
		else if (initialCapacity < 0)
			throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity);
	}
	
	private void rangeCheckForAdd(int index) {
		if(index > size || index < 0)
			throw new IndexOutOfBoundsException(
					"Index " + index + " out of bounds for length " + size);
	}
	
	private void rangeCheck(int index) {
		if(index >= size || index < 0)
			throw new IndexOutOfBoundsException(
					"Index " + index + " out of bounds for length " + size);
	}
	
	private void reallocate() {
		if(elementData == EMPTY_ELEMENT_DATA) {
			elementData = new Object[DEFAULT_CAPACITY];
			return;
		}
		
		int capacity = elementData.length + (elementData.length >> 1);
		reallocate(capacity);
	}
	
	private void reallocate(int capacity) {
		Object[] newArray = new Object[capacity];
		
		for(int i = 0; i < size; i++) {
			newArray[i] = elementData[i];
			elementData[i] = null;
		}
		
		elementData = newArray;
	}
	
	public void ensureCapacity(int capacity) {
		if(capacity > elementData.length
				&& capacity >= DEFAULT_CAPACITY)
			reallocate(capacity);
	}
	
	public void trimToSize() {
		if(size < elementData.length) {
			if(size == 0)
				elementData = EMPTY_ELEMENT_DATA;
			else
				reallocate(size);
		}
	}
	
	public boolean add(E value) {
		add(size, value);
		return true;
	}

	public void add(int index, E e) {
		rangeCheckForAdd(index);
		if(elementData.length == size)
			reallocate();
		
		if(index == size) { 
			elementData[size] = e;
		} else {
			for(int i = index; i < size; i++)
				elementData[i+1] = elementData[i];
			elementData[index] = e;
		}

		size++;
	}

	@SuppressWarnings("unchecked")
	public E remove(int index) {
		rangeCheck(index);
		
		E removeElement = (E)elementData[index];
		
		if(index != size-1)
			for(int i = index; i < size-1; i++)
				elementData[i] = elementData[i+1];
		
		elementData[--size] = null;
		return removeElement;
	}

	public boolean remove(Object o) {
		int idx = indexOf(o);
		
		if(idx != -1) {
			for(int k = idx; k < size-1; k++)
				elementData[k] = elementData[k+1];
			elementData[--size] = null;
			return true;
		}
		
		return false;
	}
	
	@SuppressWarnings("unchecked")
	public E get(int index) {
		rangeCheck(index);
		return (E)elementData[index];
	}

	@SuppressWarnings("unchecked")
	public E set(int index, E e) {
		rangeCheck(index);
		
		Object oldElement = elementData[index];
		elementData[index] = e;
		return (E)oldElement;
	}

	public boolean contains(Object o) {
		return indexOf(o) == -1 ? true : false;
	}

	public int indexOf(Object o) {
		for(int i = 0; i < size; i++)
			if(elementData[i].equals(o))
				return i;
		
		return -1;
	}
	
	public int size() {
		return size;
	}

	public boolean isEmpty() {
		return size == 0;
	}

	public void clear() {
		for(int i = 0; i < size; i++)
			elementData[i] = null;
		size = 0;
	}
}

 

 


 

Method Summary (public)

Method Description
boolean add(E)  리스트 끝에 데이터 삽입
void add(int, E)  리스트 index 위치에 전달받은 요소 추가
E remove(int)  리스트 index 위치에 존재하는 요소 삭제하고 삭제된 요소 반환
boolean remove(Object)  리스트에서 특정 요소를 0부터 탐색하여 처음 발견하는 요소를 삭제
E get(int)  리스트 index 위치에 있는 요소를 반환
E set(int, E)  리스트 index 위치에 있는 요소를 수정하고 수정하기 전의 값을 반환
void ensureCapacity(int)
 배열의 크기를 capacity로 축소 혹은 확장
void trimToSize()  배열의 크기를 현재 리스트가 가진 요소의 수에 맞게 축소
boolean contains(Object)  리스트에서 특정 요소에 대한 존재여부를 반환
int indexOf(Object);  리스트에서 특정 요소가 존재하는 index를 반환
int size();  리스트가 가진 요소 수를 반환
boolean isEmpty()  리스트가 비었는지에 대한 여부를 반환
void clear()  리스트를 초기화

 

Method Summary (private)

Method Description
void rangeCheckForAdd(int) 데이터 삽입에 있어서의 index에 대한 유효 범위 확인
void rangeCheck(int) 데이터 삽입을 제외하고 index를 사용하는 상황에서의 유효 범위 확인
reallocate() 배열의 크기를 일정한 배율(1.5)로 확장
reallocate(int) 배열의 크기를 capacity에 따라 축소 혹은 확장

 

 


 

 

Variable

private int size;
private final int DEFAULT_CAPACITY = 10;
	
private final Object[] EMPTY_ELEMENT_DATA = {};
private Object[] elementData;
  • size : 현재 배열에 담긴 요소의 개수를 카운팅하는 변수.
  • DEFAULT_CAPACITY : 사용자가 배열 크기를 지정해주지 않았을 경우의 배열 크기 기본 값.
  • EMPTY_ELEMENT_DATA : 사용자가 배열 크기를 할당하지 않았을 경우 elementData가 가리키게 되는 빈 배열로, length가 0인 것과는 달리 할당되지 않은 배열이라는 의미.
  • elementData : 실제 데이터가 저장될 Object 타입 배열.(모든 클래스는 Object 클래스를 상속받기 때문에 타입이 Object인 배열은 어떠한 데이터도 담을 수 있음을 의미한다.)

 

 

 생성자

public ArrayList() {
	elementData = EMPTY_ELEMENT_DATA;
}
	
public ArrayList(int initialCapacity) {
	if(initialCapacity > 0)
		elementData = new Object[initialCapacity];
	else if(initialCapacity == 0)
		elementData = EMPTY_ELEMENT_DATA;
	else if (initialCapacity < 0)
		throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity);
}

 

 파라미터가 없는 생성자와 정수형 변수 1개를 파라미터로 받는 생성자가 있다. 파라미터가 없는 생성자를 호출할 경우 elementData는 크기가 0인 EMPTY_ELEMENT_DATA를 가리키게 되는데, 이는 아직 메모리가 할당된 적이 없다는 것을 나타낸다. 

 

 파라미터가 1개인 생성자는 파라미터로 배열의 크기를 정할 수 있다. 받는 값이 정수이기 때문에 음수, 0, 정수 등이 입력될 수 있는데 배열을 할당받을 때 음수를 입력할 경우 예외를 발생시키고 0을 입력할 경우 의미없는 코드가 된다. 때문에 0을 입력할 경우 파라미터가 없는 생성자를 호출하였을 때와 같은 동작을 하게 하고 음수를 입력할 경우 인위적으로 예외를 발생시킨다. 파라미터로 1 이상의 값이 들어올 경우 비로서 정상적으로 배열을 할당한다.

 

 

 데이터 삽입

public boolean add(E value) {
	add(size, value);
	return true;
}

public void add(int index, E e) {
	rangeCheckForAdd(index);
	if(elementData.length == size)
		reallocate();
	
	if(index == size) { 
		elementData[size] = e;
	} else {
		for(int i = index; i < size; i++)
			elementData[i+1] = elementData[i];
		elementData[index] = e;
	}
	size++;
}

 

 데이터를 삽입할 때 호출되는 메서드들이다. 데이터만 삽입하는 메서드 add(E)와 특정 위치를 지정하여 데이터를 삽입하는 메서드 add(int, E)가 있다. 데이터만 삽입하는 경우 elementData의 마지막 위치인 index에 삽입되고, 인덱스를 지정하여 삽입하는 경우 해당 위치에 있는 데이터부터 마지막에 있는 데이터까지 모든 데이터를 한칸 씩 뒤로 미루고 해당 위치에 삽입한다.

 

 단 데이터를 삽입하기 전에 index가 정상 범주에 속하는지 rangeCheckForAdd 메서드를 통해 체크한다. 문제가 없다면 배열의 현재 크기와 데이터 수를 비교하여 데이터가 다 찼을 경우 배열의 크기를 재할당한다. 

 

 

 데이터 삭제

@SuppressWarnings("unchecked")
public E remove(int index) {
	rangeCheck(index);
	
	E removeElement = (E)elementData[index];
	
	if(index != size-1)
		for(int i = index; i < size-1; i++)
			elementData[i] = elementData[i+1];
	
	elementData[--size] = null;
	return removeElement;
}

public boolean remove(Object o) {
	int idx = indexOf(o);
	
	if(idx != -1) {
		for(int k = idx; k < size-1; k++)
			elementData[k] = elementData[k+1];
		elementData[--size] = null;
		return true;
	}
	
	return false;
}

 

 데이터를 삭제할 때 호출되는 메서드들이다. 특정 위치에 있는 데이터를 삭제하는 메서드 remove(int)와 특정 데이터를 삭제하는 메서드 remove(Object)가 있다. remove(int)는 먼저 인덱스가 정삼 범주 내인지 확인한 후 정상 범주라면 해당 위치에 있는 데이터를 반환하기 위해 미리 저장해둔다. 삭제 위치가 마지막이 아닐 경우 반복문을 구성하여 삭제할 위치의 데이터를 기준으로 뒤의 데이터를 하나씩 앞으로 당기는 작업을 수행한다. 마지막으로 배열의 마지막 요소에 null을 저장하고 이전에 저장해둔 값을 리턴한다.

 

 remove(Object)는 내부의 indexOf(Object) 메서드를 호출하여 파라미터로 들어온 값이 elementData에 있는지 체크한다. 존재할 경우 0이상의 값을, 존재하지 않을 경우 -1을 리턴한다. -1이 아닐 경우 루프를 구성하여 삭제할 위치의 데이터를 기준으로 뒤의 데이터를 하나씩 앞으로 당기는 작업을 수행하고 elementData의 마지막에 null을 저장한 후 true를 반환한다. 반대로 -1일 경우 false를 반환한다.

 

 

 데이터 검색

@SuppressWarnings("unchecked")
public E get(int index) {
	rangeCheck(index);
	return (E)elementData[index];
}
    
public boolean contains(Object o) {
	return indexOf(o) == -1 ? true : false;
}

public int indexOf(Object o) {
	for(int i = 0; i < size; i++)
		if(elementData[i].equals(o))
			return i;
	
	return -1;
}

 

 주로 특정 데이터를 찾는데 쓰이는 메서드들이다. get(int)는 파라미터로 받은 index로 elementData를 참조하여 데이터를 반환한다.

 

 contains(Object)는 파라미터로 받은 값이 elementData에 존재하는지 탐색하여 존재하는지 존재하지 않는지에 대한 판단만 하여 boolean 값으로 반환한다.

 

  indexOf(Object)는 파라미터로 받은 값이 elementData에 존재하는지 탐색하여 그 위치를 정수형으로 반환한다. 존재한다면 0이상의 값을, 존재하지 않을 경우 -1을 반환한다.

 

 

 데이터 수정

@SuppressWarnings("unchecked")
public E set(int index, E e) {
	rangeCheck(index);
	
	Object oldElement = elementData[index];
	elementData[index] = e;
	return (E)oldElement;
}

 

 데이터를 수정하는 메서드로 특정 인덱스를 지정하여 해당 위치에 있는 값을 수정할 수 있다. 먼저 수정될 위치에 있는 값을 복사한 후 elementData에 있는 값을 수정한다. 마지막으로 처음에 복사해둔 값을 반환한다.

 

 

 내부에서의 배열 관리

private void reallocate() {
	if(elementData == EMPTY_ELEMENT_DATA) {
		elementData = new Object[DEFAULT_CAPACITY];
		return;
	}
	
	int capacity = elementData.length + (elementData.length >> 1);
	reallocate(capacity);
}

private void reallocate(int capacity) {
	Object[] newArray = new Object[capacity];
	
	for(int i = 0; i < size; i++) {
		newArray[i] = elementData[i];
		elementData[i] = null;
	}
	
	elementData = newArray;
}

 

 ArrayList의 핵심은 유동적인 배열의 크기라고 할 수 있다. 파라미터가 없는 메서드가 호출되면 elementData가 할당되어있는 상태인지, 아닌지를 판단한 후 할당되어 있지 않다면 기본값인 10으로 배열을 할당받는다. 이에 해당하지 않는 경우는 현재 배열이 "element.length == size"인 경우 뿐이다. 따라서 배열의 크기를 증가시켜줘야 하는데 이때 새 배열의 크기를 현재 배열 크기에서 1.5배 곱한 크기로 결정하였다. 이 메서드에서는 오로지 생성할 배열의 크기만 결정하고 그 크기를 아래 메서드로 넘겨 실제로 배열 크기를 증설한다.

 

 파라미터가 있는 메서드의 경우 파라미터에 들어온 값으로 배열 크기를 증설한다. 파라미터로 들어온 값으로 배열을 생성하고 기존 배열에 있는 값을 복사한다. 마지막으로 elementData가 가리키는 주소를 새로 만든 배열의 주소로 변경해준다.

 

 

 외부에서의 배열 관리

public void ensureCapacity(int capacity) {
	if(capacity > elementData.length
			&& capacity >= DEFAULT_CAPACITY)
		reallocate(capacity);
}

public void trimToSize() {
	if(size < elementData.length) {
		if(size == 0)
			elementData = EMPTY_ELEMENT_DATA;
		else
			reallocate(size);
	}
}

 

 ensureCapacity 메서드는 파라미터로 들어온 값이 elementData의 크기보다 크면서 DEFAULT_CAPACITY보다 클 경우 파라미터 크기로 배열을 재할당한다. 해당 메서드는 접근제한자가 private인 reallocate와는 달리 public이다. 즉 외부에서 원하는 타이밍에 호출할 수 있는 배열 재할당 메서드라는 뜻이다.

 

 데이터가 계속 삽입되면 증설이 필요하지만 반대로 데이터를 계속 삭제하면 남는 공간이 점점 늘어나게 되는데 이는 메모리 공간 낭비로 이어지게 된다. reallocate가 내부에서 증설을 담당했다면 trimToSize 메서드는 elementData의 크기를 현재 데이터 개수인 size로 알맞게 재할당하는 메서드라고 볼 수 있다. 이 메서드 또한 접근 제한자가 public인 만큼 외부에서 호출가능하다.

 ArrayList는 자바를 공부하면서 가장 많이 사용되는 자료구조 중 하나다. 본 포스팅에서는 ArrayList가 왜 필요한지, 내부적으로 어떻게 동작되는지에 대한 이론을 다룬다. ArrayList는 배열을 기반으로 하기 때문에 먼저 배열에 대한 이해를 필요로 하기 때문에 배열에 대해 간단히 리뷰한 뒤 ArrayList에 대해 알아보고 다음 포스팅인 구현으로 넘어 가겠다.

 

 

 

 배열(Array), 그리고 문제점

 프로그래밍을 공부하다 보면 초기에 배열(Array)이라는 것을 배우게 된다. 변수가 여러 개 필요할 때 일일이 "int a, b, c.. etc"와 같이 선언해야 한다면 접근하기도 관리하기도 쉽지 않다. 변수가 2, 3개라면 모를까 10개 20개, 몇 백 개가 된다사실상 관리가 불가능하기 때문에 같은 성질의 변수가 여러 개일 경우 배열을 사용하게 된다.

 

 변수를 할당받으면 메모리 영역 중 스택에 할당받게 되는데, 변수는 변수명으로만 접근할 수 있기 하기 때문에 변수의 수가 많을 수록 유기적으로 접근하기가 어렵다. 특히 반복문을 통해 같은 성질의 데이터들 탐색하는 것은 엄청난 노가다가 될 것이다. 하지만 배열의 경우 Heap 영역에 메모리를 연속적으로 할당받은 뒤 배열의 시작 주소를 스택에 저장해서 접근할 수 있다. 메모리 상에 데이터가 연속적으로 할당되어 있다는 것은 가장 앞 주소만 기억하고 있다면 다음 데이터가 있는 위치는 배열의 자료형 크기를 더해주는 것으로 주소를 알아낼 수 있다는 것을 의미한다. 이러한 구조는 성격이 비슷한 변수들의 관리를 용이하게 해주고, 간단한 주소 계산을 통해 배열의 어느 요소든 빠른 접근을 가능하게 한다.

 

 하지만 장점이 있으면 단점도 존재한다. 배열은 한 번 할당받으면 크기를 줄이거나 늘일 수 없고, 저장된 데이터가 정렬되어 있다고 할 때 중간의 데이터를 삽입 및 삭제하려고 하면 해당 인덱스를 기준으로 데이터를 밀거나 당기는 작업이 필요해진다. 또한 배열을 초기화할 때 할당받은 메모리를 전부 사용한 상황에서 새로운 데이터를 삽입하려고 하면 공간 부족의 문제가 발생한다. 공간이 부족할 수 있으니 미리 넉넉하게 할당받아 놓는다는 생각을 할 수 있지만 추후 데이터 삽입 여부에 따라 공간 낭비로 이어질 수 있다.

 

 데이터의 개수가 정해져 있고 삭제/삽입이 일어나지 않는 환경이라고 한다면 접근이 빠른 배열이 유리하지만 데이터 삭제/삽입이 빈번하고 데이터의 개수가 유동적인 상황이라면 배열은 결코 좋은 해결 방법이 될 수 없다.

 

 그렇다면 빠른 접근이라는 장점을 유지한 채로 정적인 배열을 동적으로 사용할 수 없을까? 

 

 


 

 

 ArrayList

 위에서 배열의 개념과 장단점을 살펴봤다. 배열은 데이터 삽입/삭제가 잦고 데이터 개수가 유동적인 환경에 적합하지 않다.

 

 ArrayList를 한마디로 표현하면 "사이즈 변경이 가능한 배열"이라고 할 수 있다. ArrayList는 배열의 요소에 접근할 때 대괄호([n])를 사용하지 않고 메서드를 통해서 보다 직관적인 데이터 삽입/삭제/변경을 할 수 있다. 또한 배열에 있는 데이터를 초기화하거나 배열 내 데이터 검색 등 사용자에게 편리한 메서드를 제공한다. 이러한 메서드들의 존재는 사용자가 직접 작성하면서 발생할 수 있는 자잘한 오류를 방지해주는 효과도 있다. 하지만 처음에 언급했듯이 가장 큰 장점은 내부적으로 배열을 사용함에도 불구하고 데이터 개수 제한에 구애받지 않고 얼마든지 데이터를 삽입할 수 있다는 점이다.(여기서 메모리 크기의 한계는 생각하지 않는다.) ArrayList는 이름에서 유추할 수 있듯 내부를 들여다보면 배열을 사용하고 있다. 내부적으로 배열을 사용함에도 불구하고 어떻게 데이터를 무한히 삽입할 수 있는 것일까?

 

 아래 소스코드는 실제 ArrayList 클래스의 일부분을 가져온 것인데 Object형 배열 elementData가 실제로 우리가 add를 호출해서 데이터를 삽입하면 데이터가 저장된다. 소스를 이해하라는 것이 아닌 내부에 배열을 쓰고 있다는 것만을 보고 가면 되겠다.

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
    @java.io.Serial
    private static final long serialVersionUID = 8683452581122892189L;

    private static final int DEFAULT_CAPACITY = 10;
    private static final Object[] EMPTY_ELEMENTDATA = {};
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
    transient Object[] elementData;
    ...
}

 

 


 

 

 ArrayList에서 배열 크기 문제를 해결하는 방법 

final int DEFAULT_CAPACITY = 5;
int[] array = new int[DEFAULT_CAPACITY];

 크기 5의 배열 array를 선언하였다. 만약 위 배열에 데이터가 5개 삽입되었다고 가정한다면 배열의 최대 크기만큼 데이터가 삽입되었기 때문에 더 이상 데이터가 삽입될 수 없는 상태가 된다. 일반적인 배열이라면 여기서 기존 데이터를 유지한 채 새로운 데이터를 삽입하는 방법은 없다.

 

 

 

Memory copy

 ArrayList의 경우를 보자. ArrayList는 초기에 일정한 크기의 배열을 할당받아 놓는다. 사용자가 초기 배열의 크기를 지정해 줄 수도 있지만 지정해주지 않아도 기본값으로 배열이 생성된다. 예를 들어 기본값이 5라고 하면 크기 5의 배열이 생성될 것이다. 그리고 그 배열에 5개의 데이터가 삽입되었다고 하면 더 이상 데이터를 추가할 수 없는 상태가 될 것이다. 이것은 배열의 본질이기 때문에 변하지 않는다. 하지만 ArrayList는 가지고 있는 배열에 데이터를 한계까지 삽입한 경우 더 큰 크기의 새로운 배열을 만드는 것으로 문제점을 극복했다. 현재 배열의 크기보다 더 큰 배열을 만들어 현재 데이터를 모두 이동시킨 후 이제부터 새로 만든 배열을 사용하는 것이다.(이전 배열은 Unreachable Object가 되어 언젠가 가비지 콜렉터에게 수거당한다.) 소스코드로 보면 아래와 같다.

 

 

Object[] newArray = new Object[array.length * 2];
		
for(int i = 0; i < array.length; i++) {
	newArray[i] = array[i];
	array[i] = null;
}
		
array = newArray;

 새로운 배열을 생성하고 기존 데이터를 새로운 배열로 이동시키는 코드다. 데이터를 옮기는 작업이 끝나면 멤버 변수로 가지고 있던 메인 배열의 주소를 새로운 배열로 바꿔준다.

 

 

 


 

 

 

 ArrayList 삽입, 삭제

 그 외에 데이터 삽입, 삭제, 수정 등의 메서드도 지원한다. 배열을 사용하기 때문에 삽입, 삭제는 우리가 흔히 알고 있는 데이터를 뒤로 밀거나 앞으로 당기는 것으로 이루어진다. 메서드의 종류가 너무 많기에 간단히 삽입, 삭제에 대한 메서드만 소개하겠다.

 

 

 add(Object value)

 명실상부 ArrayList에서 가장 많이 사용되는 메서드로, 데이터를 삽입할 때 호출하며 파라미터로 전달한 값을 배열의 가장 마지막 위치에 삽입한다.

 

 


 

 

 add(int index, Object value)

 데이터를 삽입할 때 배열의 끝이 아니라 특정 위치에 데이터를 삽입해야 할 때가 있다. 만약 "ArrayList.add(2, 12)"라는 코드를 호출하게 되면 배열의 2번째 index에 12라는 값을 삽입하라는 뜻인데 해당 메서드가 호출될 경우 index 위치로부터 배열의 끝까지 데이터를 모두 뒤로 한칸씩 미루고 파라미터로 받은 값을 index 위치에 삽입한다.

 

 


 

 

 remove(int index)

 remove(Object value)

 데이터를 삭제하는 방법은 크게 두 가지가 있다. 먼저 첫 번째 방법은 값으로 삭제할 데이터를 찾는 방법이다. remove 메서드의 파라미터로 Object를 넘긴 뒤 ArrayList가 가진 배열의 첫 번째 요소부터 마지막 데이터까지 비교해가며 해당 파라미터와 일치하는 값을 찾는다. 일치하는 값이 있을 경우 해당 위치를 기준으로 뒤에 있는 데이터를 한 칸씩 앞으로 당긴다. 

 

 두 번째 방법은 파라미터로 삭제할 데이터의 위치를 특정한 index를 받는 것이다. 삭제할 위치를 알고 있다면 배열의 장점인 index에 의한 접근으로 빠르게 데이터를 삭제할 수 있다. 값으로 데이터를 찾는 방법에 비해 탐색 시간은 없지만 뒤에 있는 값들을 하나씩 앞으로 당긴다는 점은 동일하기 때문에 어느 정도 부담이 가는 작업이다. 

 

 또한 값으로 데이터를 삭제하려할 때 배열에 해당하는 값이 없다면 null을 반환하지만 인덱스로 접근하는 경우 배열의 크기와 벗어난 경우 예외(Exception)이 발생하니 주의가 필요하다.

 

 

 

 

 


 

 

 

 이 외에 데이터 수정(set), 배열 비우기(clear), 데이터 찾기 등 여러 기능을 지원하지만 여기서는 다루지 않고 다음 포스팅인 구현부에서 소스 코드와 함께 설명하겠다.

 

 

 ArrayList

 

[자바/자료구조] ArrayList 직접 구현하기

 본 포스팅에서는 ArrayList에 대한 보다 깊은 이해와 코딩 능력 상승을 위해 ArrayList를 구현합니다. ArrayList가 내부적으로 어떻게 동작하는지 모르신다면 코드를 보기 전에 ArrayList에 대한 이론을

ku-develog.tistory.com

 

+ Recent posts