Language/Golang

Golang (Go언어) 자료 구조(Data Structure) 1/2

HeeWorld 2024. 12. 2. 22:05

Tucker의 Go 언어 프로그래밍 책과 유튜브를 통해 학습 중입니다.

Golang 마스 코트 Gopher(고퍼)

 

자료 구조(Data Structure)

- 자료들을 어떤 형태로 저장할 것인가를 나타냄.

- 크게 배열, 리스트, 트리, 맵 등이 있음.

 

리스트(List)

- 기본 자료 구조로서 여러 데이터를 보관할 수 있음

- 베열과 함께 가장 기본적인 선형 자료구조 중 하나(하나의 데이터에 다음 데이터가 연결되는 것, 1:1 구조)

   * 비선형 구조: 하나의 데이터에 2-3개의 데이터가 연결되는 것, ex) 트리 구조=폴더

- 배열과 가장 큰 차이점은 배열은 연속된 메모리에 데이터를 저장하지만, 리스트는 불연속된 메모리에 데이터를 저장함.

- 리스트는 데이터를 담고 있는 요소들을 포인터로 연결한 자료 구조

- 요소들이 포인터로 연결됐다고 해서 링크드 리스트(Linked list)라고 부르기도 함.

 

* https://pkg.go.dev/container/list

type Element struct {     // 구조체
    Value interface{ }    // 데이터를 저장하는 필드
    Next  *Element        // 다음 요소의 주소를 저장하는 필드    
    Prev  *Element        // 이전 요소의 주소를 저장하는 필드
}

 

✓ Next는 *Element 타입으로 다음 Element 인스턴스의 메모리 주소를 가지고 있음.

- Next를 사용해 다음 요소 인스턴스로 접근할 수 있음. = 포인터를 통해 연결했다고 말함.

 

✓ 이전 요소도 요Prev 포인터로 가지고 있음.

- 이전 요소도 접근할 수 있음, 이를 양방향 리스트(Double linked list)라고 함.

 

→ 각 Element 주소가 0x0000, 0x0200, 0x0040으로 일관성이 없음.

    리스트는 이처럼 서로 떨어진 Element 인스턴스들이 Next 포인터로 연결된 불연속 자료구조

 

package main

import (
	"container/list"
	"fmt"
)

func main() {
	v := list.New()
	e4 := v.PushBack(4)
	e1 := v.PushFront(1)
	v.InsertBefore(3, e4)
	v.InsertAfter(2, e1)

	for e := v.Front(); e != nil; e = e.Next() {
		fmt.Print(e.Value, " ")
	}

	fmt.Println()
	for e := v.Back(); e != nil; e = e.Prev() {
		fmt.Print(e.Value, " ")
	}
}

// 결과

1 2 3 4 
4 3 2 1

 

- PushBack() 메서드는 리스트 맨 뒤에 요소를 추가함.

  = Value가 4인 새 요소를 추가하고, 추가된 요소를 나타내는 Element 인스턴스를 e4변수로 나타냄.

 

- PushFront() 메서드는 리스트 맨 앞에 요소를 추가함.

   = Value가 4인 e4 요소 앞에 Value가 1인 새 요소를 추가하고, 추가된 요소를 나타내는 Element 인스턴스를 e1 변수로 나타냄.

      e1의 Next 포이너는 다음 요소인 e4 인스턴스를 가르키고 e4의 Prev 포인터는 이전 요소인 e1 인스턴스를 가리키게 됨.

리스트 맨 앞에 추가

 

- InsertBefore() 메서드는 두 번째 인수로 입력된 요소 앞에 새 요소를 추가함.

- e4를 인수로 받았으므로, e4 앞에 3이 추가 됨.

e4 앞에 추가

 

- InsertAfter() 메서드는 두 번째 인수로 입력된 요소 뒤에 값을 추가 함.

- e1 뒤에 추가 됨.

e1 뒤에 추가

 

- 그리고 for문으로 각 요소들을 순회하고, Front() 메서드는 가장 첫 번째 요소를 반환함.

- Element의 Next() 메서드는 현재 요소의 다음 요소를 반환하고, 다음 요소가 없으면 nil을 반환하여 for문 종료

 

- 마지막 for문은 각 요소들을 역순으로 순회하고, Back() 메서드는 가장 마지막 요소를 반환함.

- Element의 Prev() 메서드는 현재 요소의 이전 요소를 반환하고, 이전 요소가 없으면 nil을 반환하여 for문 종료

 

 

Big-O 표기법

- 알고리즘의 효율성(시간적, 공간적)을 나타내는 표기법 중 하나로 가장 많이 쓰임.

   * 시간적: 알고리즘의 속도(빠름/느림), 공간적: 메모리 공간

- 공간과 시간은 반비례 관계가 있다고 함 = 공간을 많이 쓰면 시간이 줄고, 공간을 적게 쓰면 시간이 늘어남(항상 그런 것은 아님)

- 상한선을 표시함.

 

✓ 배열 맨 앞에 값을 추가할 때는 맨 뒤 요소부터 하나씩 뒤로 밀기 때문에 요소 개수가 많을 수록 더 많은 시간이 걸림.

    그래서 aN+b 관계를 가짐, 이런 경우 Big-O 표기법으로 상수 a,b를 생략해 O(N)으로 표기

 

* Big-O에 대해 https://velog.io/@welloff_jj/Complexity-and-Big-O-notation 블로거님이 정리 잘해주셨다...! (👍🏻)

 

 

배열과 리스트의 차이

" 맨 앞에 데이터 추가하기"

<배열>

- 맨 앞에 요소를 삽입 하는 경우

→ 맨 뒤에서부터 각 요소를 한 칸씩 뒤로 밀어낸 뒤, 맨 앞의 값을 변경함.

= 이는 aN+b 알고리즘으로 즉, Big-O로 표기 시 O(N)알고리즘이 됨.

 

<리스트>

- 맨 앞에 요소를 삽입하는 경우

→ 각 요소를 밀어낼 필요 없이 맨 앞에 요소를 추가하고 연결만 만들어주면 됨.

= Big-O로 표기 시, O(1)로 표기되며, O(1)은 상수 시간이 걸린다는 의미.

 

★ 충분히 큰 요소 개수 N에 대해서 O(1) ≤ O(N)을 보장하기 때문에 배열보다 리스트가 맨 앞에 요소를 추가할 때 더 빠름! ★

 

" 특정 요소에 접근하기 "

<배열>

"배열에서 인덱스 이동 공식 → 배열 시작 주소 + (인덱스 x 타입 크기)"

- 배열에서는 어떤 요소를 찾아갈 때 위와 같은 공식에 따라 메모리 위치를 찾아 요소에 접근함.

  = 위 공식은 요소 개수와 상관없이 상수 시간이 걸리기 때문에 Big-O 표기법으로 O(1)이 됨.

 

<리스트>

- 리스트에서는 각 요소가 포인터로 연결되어 있기 때문에 앞 요소들을 모두 거쳐야함.

  = 특정 요소에 접근하려면 N-1번 링크를 타야하기 때문에 Big-O 표기법으로 O(N)만큼 시간이 걸린다고 표현함.

 

★ 특정 요소(랜덤)에 접근할 때는 리스트보다 배열이 더 빠름! ★

 

 

데이터 지역성(Data Locality)

- 데이터가 밀접한 정도를 말하고, 데이터 로컬리티라고도 함.

 

✓ 배열과 리스트를 선택할 때, 데이터 지역성을 고려해야함.

→ 컴퓨터는 연산할 때 읽어온 데이터를 캐시라는 임시 저장소에 저장하는데, 필요한 데이터만 가지고 오는 것이 아니고

     주변 데이터를 같이 가져옴.

= 왜냐하면, 보통 연산이 일어난 다음에 높은 확률로 주변 데이터에 대한 연산이 이어지기 때문!

 

→ 필요한 데이터가 인접해 있을 수록 처리 속도가 빨리지는데, 이를 데이터 지역성이 좋다고 말함.

- 배열은 연속된 메모리로 이루어진 자료구조이고, 리스트는 불연속이기 때문에 배열이 리스트에 비해서 데이터 지역성이 월등하게 좋음.

 

→ 삽입과 삭제가 빈번하면 리스트가 배열보다 좋고, 요소 수가 적으면 지역성 때문에 오히려 배열이 리스트보다 더 효율적!

- 삽입 삭제가 빈번할 때 요소 수에 따른 배열과 리스트 선택은 컴퓨터 성능과 프로그램 성격에 따라 다름!!

 

 

큐(Queue)

- 먼저 입력된 값이 먼저 출력되는 FIFO(First In First Out) 자료구조이며, 대기열(Waiting Queue)이 있음.

 

✓ 큐의 특징

1. 들어간 순서 그래도 빠져나오기 때문에 순서가 유지됨.

2. 새로운 요소는 항상 맨 마지막에 추가됨.

3. 출력값은 맨 앞에서 하나씩 빼내게 됨.

 

✓ 큐는 대기열 작업이나 명령 큐처럼 순서가 유지되어야 하는 경우에 자주 사용됨.

 

package main

import (
	"container/list"
	"fmt"
)

type Queue struct {			// Queue 구조체 정의
	v *list.List
}

func (q *Queue) Push(val interface{}) {		// 요소 추가
	q.v.PushBack(val)
}

func (q *Queue) Pop() interface{} {		// 요소를 반환하면서 삭제
	front := q.v.Front()
	if front != nil {
		return q.v.Remove(front)
	}
	return nil
}

func NewQueue() *Queue {
	return &Queue{list.New()}
}

func main() {
	queue := NewQueue()		// 새로운 큐 생성

	for i := 1; i < 5; i++ {	// 요소 입력
		queue.Push(i)
	}
	v := queue.Pop()
	for v != nil {			// 요소 출력
		fmt.Printf("%v -> ", v)
		v = queue.Pop()
	}
}

// 결과

1 -> 2 -> 3 -> 4 ->

 

- 리스트를 이용한 큐 구조체를 정의하고, 내부 필드로 리스트를 가지고 있어서 요소를 추가하거나 삭제할 때 리스트를 사용함.

 

- Push() 메서드는 요소를 추가하고, 리스트의 PushBack() 메서드를 이용해서 맨 뒤에서 요소를 추가 함.

   = 빈 인터페이스를 이용하여 모든 타입의 데이터를 저장할 수 있게 함.

 

- Pop() 메서드는 맨 앞의 요소를 반환하고 삭제하며, 리스트의 Front() 메서드는 맨 앞의 요소 인스턴스를 반환함.

  = 만약, 이 값이 nil이라면 리스트가 모두 비었다는 뜻으로, 리스트가 모두 비어있으면 nil을 반환하고,

     그렇지 않으면 리스트의 Remove() 메서드를 호출함, Remove() 메서드는 리스트 내에 요소를 삭제하고 그 값을 반환.

 

- NewQueue() 함수를 사용해서 새로운 큐 인스턴스를 만들고, 큐 인스턴스를 만들 때 내부 리스트 필드도 list.New() 함수를 이용해 같이 초기화.

 

- for문을 이용해서 큐에 요소를 추가하여 1~4까지 추가 되고, 리스트에서 요소를 하나씩 빼내어 출력함.

   = 리스트가 비어있지 않다면 Pop() 결과가 nil이 아니기 때문에 리스트가 모두 빌 때까지 반복하게 됨!

 

 

스택(Stack)

- 큐와 달리 FILO(First In Last Out) 자료구조로, 첫 번째 입력한 요소가 가장 마지막에 출력됨.

ex) 박스에 책을 하나씩 담으면, 맨 마지막으로 넣은 책부터 빼게 되는 것과 같음!

 

✓ 스택 특징

1. 가장 최근에 넣은 것 부터 역순으로 나오게 됨.

2. 요소는 맨 뒤로 추가함.

3. 요소를 뺄 때도 맨 뒤에서 뺌.

 

→ 스택은 순서가 반대가 되기 때문에 가장 최신 것부터 하나씩 되돌릴 때 주로 사용됨.

 

※ 큐는 새로운 요소를 맨 뒤로 추가하고, 요소를 뺄 때는 맨 앞에서 빼는 반면

   스택은 새로운 요소를 맨 뒤로 추가하고, 요소를 뺄 때도 맨 뒤에서 빼는게 다름!! ※

 

package main

import (
	"container/list"
	"fmt"
)

type Stack struct {
	v *list.List
}

func NewStack() *Stack {
	return &Stack{list.New()}
}

func (s *Stack) Push(val interface{}) {
	s.v.PushBack(val)		// 맨 뒤에 요소 추가
}

func (s *Stack) Pop() interface{} {
	back := s.v.Back()		// 맨 뒤에서 요소 반환
	if back != nil {
		return s.v.Remove(back)
	}
	return nil
}

func main() {
	stack := NewStack()
	for i := 1; i < 5; i++ {
		stack.Push(i)
	}

	val := stack.Pop()
	for val != nil {
		fmt.Printf("%v -> ", val)
		val = stack.Pop()
	}
}


// 결과

4 -> 3 -> 2 -> 1 ->

 

- 스택은 큐와 구현이 흡사하고, 다른 점은 Pop() 메서드에서 맨 앞에서 요소를 가져오는 게 아니라 맨 뒤에서 요소를 가져옴.

 

- 새로운 요소를 추가하는 Push() 메서드는 큐와 마찬가지로 PushBack() 메서드를 사용해서 맨 뒤에 요소를 추가.

 

- 요소를 반환하는 Pop() 메서드는 맨 앞에서 가져오는 큐와 달리 Back() 메서드를 사용해서 맨 뒤에서 요소를 반환.

 

✓ 스택은 요소의 추가와 삭제가 항상 맨 뒤에서 발생하기 때문에 배열로 만들어도 성능에 손해가 없음!

    보통 큐는 리스트로, 스택은 배열로 구현함!

 

 

링(Ring)

- 맨 뒤의 요소와 맨 앞의 요소가 서로 연결된 자료구조

- 리스트를 기반으로 만들어진 자료구조로, 원형으로 연결되어 있기 때문에 환형 리스트라고 부름.

- 링 자료구조에서는 시작도 없고 끝도 없으며, 현재 위치가 있을 뿐.

 

package main

import (
	"container/ring"
	"fmt"
)

func main() {
	r := ring.New(5)		// 요소가 5개인 링 생성

	n := r.Len()			// 링 길이 반환

	for i := 0; i < n; i++ {	// 순회하면 모든 요소에 값 대입
		r.Value = 'A' + i
		r = r.Next()
	}

	for j := 0; j < n; j++ {
		fmt.Printf("%c ", r.Value)		// 순회하며 값 출력
		r = r.Next()
	}

	fmt.Println()		// 한 줄 띄우기

	for j := 0; j < n; j++ {
		fmt.Printf("%c ", r.Value) 		// 역순하며 값 출력
		r = r.Prev()
	}
}


// 결과

A B C D E 
A E D C B

 

- 5개의 요소를 가진 링 자료구조를 만들고 그 첫 번째 요소 인스턴스를 반환함

  = r은 현재 위치를 나타내는 포인터로서, 현재는 첫 번째 요소를 가리킴!

 

- 요소가 5개짜리인 링을 만들었기 때문에 5를 반환

 

- 각 요솟값을 알파벳 A부터 E까지 설정하고, 링의 Next() 메서드는 다음 요소 인스턴스를 반환함.

  = 전체 요소 개수가 5이고, 5번 이동하기 때문에 각 요소를 순회하고 현재 위치 r은 다시 첫 번째 요소로 돌아옴.

 

- 각 요소를 돌면서 값을 출력하고, 현재 위치 r은 한 바퀴 돌아 다시 첫 요소로 돌아옴.

- 마지막 for문에서는 이전 요소들을 돌면서 값을 출력함.

  = 현재 위치가 A로, A부터 출력하고 E, D, C, B 순서로 출력!

 

✓ 링은 저장할 개수가 고정되고, 오래된 요소는 지워져도 되는 경우에 보통 사용

 

1. 실행 취소 기능: 문서 편집기 등에서 일정한 개수의 명령을 저장하고 실행 취소하는 경우

2. 고정 크기 버퍼 기능: 데이터에 따라 버퍼가 증가되지 않고 고정된 길이로 쓸 때

3. 리플레이 기능: 게임 등에서 최근 플레이 10초를 다시 리플레이할 때와 같이 고정된 길의의 리플레이를 제공할 때