2024. 3. 17. 16:18ㆍProgramming Language/C++
연결 리스트 (Linked List)
• STL(Standard Template Library, 표준 템플릿 라이브러리)의 컨테이너 중 하나로, 각 노드들이 포인터로 연결되어 있는 데이터 구조이다.
• 노드(Node, 마디) : 데이터 하나를 저장하는 단위
• 노드에는 저장하고자 하는 데이터와 다음 노드를 가리키는 주소가 존재한다.
• 데이터를 하나 추가할 때마다 힙 영역에 데이터 하나를 저장할 만큼 메모리 공간을 만든다.
노드는 프로그램이 진행되는 동안 존재해야 되기 때문에 힙 영역에 만들어야 한다.
즉, 동적할당을 사용해야 한다.
• 시작 데이터만 알면 계속해서 다음 데이터를 찾아갈 수 있다.
시간 복잡도(Big-O)
• 최악의 경우 배열의 길이만큼 걸릴 때를 대비하여 시간 복잡도를 확인할 때 빅오 표기법을 사용한다.
1) O(n) : 선형 시간 (n : 데이터 개수)
• 알고리즘의 실행 속도를 n을 기준으로 얼마나 걸리는지 결정된다.
• 알고리즘의 실행 시간이 입력 크기에 정비례한다.
- 배열에서 검색, 최소/최댓값 찾기 등 연산
- 연결 리스트에서 순회, 최소 / 최대값 찾기 등 연산
ex) 만약 데이터가 1000개일 때 맨 앞에 데이터를 넣어야 된다면 1000개만큼의 비용과 시간이 도는데, 이는 최악의 경우에 해당한다.
2) O(1) : 상수 시간
• 데이터가 아무리 많아도 과정은 바뀌지 않는다.
• 이처럼 n과 무관하게 항상 고정 시간일 때 사용한다.
• O(1)에서 1은 없는 취급한다.
• 입력 데이터 개수에 영향을 받지 않고 절댓값이기 때문에 알고리즘을 수행하는 데 있어서 가장 속도가 빠르다.
가변 배열 vs. 리스트
리스트도 가변 배열처럼 데이터를 계속 추가할 수 있지만, 데이터를 관리하는 방식은 가변 배열과 다르다.
가변 배열은 최종적으로 만들어진 메모리가 연속적인 구조를 가지고 있는 반면, 연결 리스트는 데이터가 띄엄띄엄 띄워져 있다.
1) 가변 배열
• 가변 배열의 시작 주소를 안다.
• 특정 인덱스는 주소 연산으로 접근할 수 있다.
• 데이터 개수의 영향을 받지 않고 데이터가 많아도 시간이 더 오래 걸리지는 않는다.
2) 리스트
• 특정 위치로 접근하려면 서로 연결되어 있기 때문에 계속해서 주소 연산을 해야 한다.
• 리스트가 헤드를 알고 있을 때 꼬리에 접근할 때가 최악의 경우이다.
① 데이터가 n개라면 데이터 개수만큼의 접근이 필요하다. = n
헤드에 접근, 역참조, 다음 노드의 주소로 접근하여 다음 노드에 접근하는 등의 작업을 n번 해야 된다는 의미이다.
② 리스트가 꼬리 노드를 포인터로 가리키고 있다고 하면 헤드 노드를 접근할 때가 최악의 경우이다. = n
③ 리스트가 헤드와 꼬리를 동시에 알고 있을 땐 가운데 노드에 접근할 때가 최악의 경우이다. = (1/2)n
• 빅오 표기법에서 계수는 무시하기 때문에 1이든 1/2이든 O(n)으로 본다. (n에 관한 1차식)
연결 리스트 - C
linkedList.h
#pragma once
// 노드 => 저장하고자 하는 데이터 + 다음 노드의 주소
// 리스트에 데이터를 넣기 위해서는 노드가 필요하다.
typedef struct _tagNode
{
int iData;
//tNode* pNextNode; // 오류 => 노드를 정의하기도 전에 노드명을 사용했기 때문이다.
struct _tagNode* pNextNode; // 본래 이름인 struct _tagNode를 이용하여 선언한다.
} tNode;
// 연결 리스트
typedef struct _tagList
{
tNode* pHeadNode; // 노드 주소
int iCount; // 데이터 개수
//int iMaxCount;
// 리스트는 최대치(MaxCount)가 필요없다.
// 데이터 추가할 때마다 매번 동적할당을 따로 하기 때문에 Max라는 개념없이 항상 1개로 똑같고, 꽉 찼다는 개념이 없다.
// 대신에 리스트는 노드들 중에 **첫 번째 노드의 주소값**을 알아야 한다.
} tLinkedList;
// 연결 리스트 초기화 함수
void InitList(tLinkedList* _pList);
// 연결 리스트 데이터 추가
void PushBack(tLinkedList* _pList, int _iData); // 뒤로 데이터 추가
void PushFront(tLinkedList* _pList, int _iData); // 앞으로 데이터 추가
// 연결 리스트 메모리 해제
void ReleaseList(tLinkedList* _pList);
linkdedList.cpp
#include <iostream>
#include "linkedList.h"
void InitList(tLinkedList* _pList)
{
_pList->pHeadNode = nullptr;
_pList->iCount = 0;
}
void PushBack(tLinkedList* _pList, int _iData)
{
tNode* pNode = (tNode*)malloc(sizeof(tNode)); // 노드 사이즈만큼 만들고 주소는 노드 단위로 데이터를 사용할 것이므로 tNode*로 받아온다.
pNode->iData = _iData;
pNode->pNextNode = nullptr;
// 지금 들어온 데이터(추가한 데이터)가 처음인지 아닌지를 따져봐야 한다.
// 만약 현재 들어온 데이터의 개수가 0(또는 pHeadNode(첫 번째 노드)가 nullptr)이라면
if (0 == _pList->iCount)
{
_pList->pHeadNode = pNode; // 노드의 주소를 저장한다.
}
// 노드 안에 데이터가 1개 이상이면 else에 걸린다.
else
{
// 현재 가장 마지막 노드 즉, 바로 앞전의 노드를 찾아서 해당 노드의 pNextNode를 생성시킨 노드의 주소로 채운다.
// 단방향에서 양방향으로 하려면 Head뿐만 아니라 제일 마지막 노드인 Tail도 알아야 한다.
tNode* pCurFinalNode = _pList->pHeadNode;
// ---------- while문 수정 전 ---------- //
//while (true)
//{
// // pNextNode가 nullptr라면 시작하자마자 현재 기준으로 가장 마지막 노드가 처음 노드이기 때문에 while문을 탈출한다.
// if (nullptr == pCurFinalNode->pNextNode)
// {
// break; // 보통 한 줄일 때는 괄호를 생략한다.
// }
// // 헤드 노드의 다음이 있다면, 주소 변수를 본인이 가리키고 있는 pNextNode로 갱신시킨다.
// pCurFinalNode = pCurFinalNode->pNextNode;
//}
// ↓
// ---------- while문 수정 후 ---------- //
// pNextNode가 nullptr이면 0이고, 이는 false를 뜻하므로 반복문이 끝나게 된다.
// 반대로 0이 아닌 모든 수는 true이기 때문에 pNextNode가 존재한다면 반복문이 실행된다.
while (pCurFinalNode->pNextNode)
{
// 헤드 노드의 다음이 있다면, 주소 변수를 본인이 가리키고 있는 pNextNode로 갱신시킨다.
pCurFinalNode = pCurFinalNode->pNextNode;
}
pCurFinalNode->pNextNode = pNode; // pNextNode를 지금 생성시킨 노드의 주소로 넣는다.
}
++_pList->iCount; // 데이터 하나가 들어왔으니 카운트 하나 증가한다.
}
// 효율이 좋지 않아서 가변 배열에서는 PushFront()를 구현하지 않았다.
// 각각의 메모리를 옆으로 한 칸씩 복사시키면서 이동시키면 가능은 하다.
// 하지만 데이터의 개수가 많아지면 효율성이 너무 떨어진다.
void PushFront(tLinkedList* _pList, int _iData)
{
// 새로 생성시킨 노드의 다음을 기존의 헤드로 지정한다.
tNode* pNewNode = (tNode*)malloc(sizeof(tNode));
pNewNode->iData = _iData;
pNewNode->pNextNode = _pList->pHeadNode; // 원래 리스트가 가리키고 있던 헤드가 다음 노드가 되어야 들어올 노드가 그 앞에 들어와서 리스트가 이를 헤드로 지정한다.
// 리스트의 헤드 노드 포인터를 갱신한다.
_pList->pHeadNode = pNewNode;
// 데이터가 들어왔으니 카운트를 증가시킨다.
++_pList->iCount;
}
// 연결형 리스트 메모리 해제 방법 2가지
// 1. 재귀함수(효율은 좋지 않다.)
// 함수가 데이터의 개수만큼 호출되기 때문에 데이터의 양이 많아지면 효율이 좋지 않기 때문에 구현 방법은 간단하지만 좋은 방법은 아니다.
void Release(tNode* _pNode)
{
// 탈출 조건
// 들어온 포인터가 null일 경우에 아무것도 하지 않고 리턴한다.
if (nullptr == _pNode)
return;
//free(_pNode); // 받자마자 해제하면 데이터가 다 날라가버려서 다음 노드로 갈 수가 없다.
// Release로 먼저 호출하여 먼저 pNextNode의 주소를 받아서 그 다음 노드를 지운다.
// 그럼 맨 마지막 노드부터 차례대로 지워진다.
Release(_pNode->pNextNode);
free(_pNode); // 그렇게 메모리가 해제되다가 첫 번째 노드까지 돌아오고 나면 free()로 인해 메모리가 해제된다.
}
// 2. 반복문
void ReleaseList(tLinkedList* _pList)
{
// tLinkedList의 입장에서 알고 있는 것은 pHeadNode뿐이다.
tNode* pDeleteNode = _pList->pHeadNode; // 첫 번째 노드를 받아온다.
// ---------- while문 수정 전 ---------- //
//while (true)
//{
// tNode* pNext = pDeleteNode->pNextNode; // 삭제할 노드(pDeleteNode)의 다음 노드(pNextNode)를 pNext에 넣는다.
// free(pDeleteNode); // 해당 메모리 해제
// pDeleteNode = pNext; // 삭제할 노드를 다음 노드로 갱신시킨다.
//}
// ↓
// ---------- while문 수정 후 ---------- //
// pDeleteNode가 null이 될 때까지 반복문은 반복된다.
while (pDeleteNode)
{
// 다음 노드의 다음 꺼를 기억해두고 지우는 것을 반복한다.
tNode* pNext = pDeleteNode->pNextNode; // 삭제할 노드(pDeleteNode)의 다음 노드(pNextNode)를 pNext에 넣는다.
free(pDeleteNode); // 해당 메모리 해제
pDeleteNode = pNext; // 삭제할 노드를 다음 노드로 갱신시킨다.
}
}
main.cpp
#include <cstdlib>
#include <iostream>
#include "linkedList.h"
#include "Arr.h"
using namespace std;
int main()
{
// 리스트는 가변 배열처럼 초반에 할당할 필요가 없다.
// 아직 데이터가 들어오지 않았는데 노드를 미리 만들 필요가 없기 때문이다.
// 게다가 노드는 데이터가 들어올 때마다 만들어진다.
tLinkedList list = {};
InitList(&list);
// 뒤로 데이터 넣기
//PushBack(&list, 100);
//PushBack(&list, 200);
//PushBack(&list, 300);
// 앞으로 데이터 넣기
PushFront(&list, 100);
PushFront(&list, 200);
PushFront(&list, 300);
// 데이터 출력
tNode* pNode = list.pHeadNode;
for (int i = 0; i < list.iCount; ++i)
{
cout << pNode->iData << endl;
pNode = pNode->pNextNode;
}
// 힙 영역 메모리 해제
ReleaseList(&list);
return 0;
}
▽ 출력 결과
300
200
100
`PushBack()`으로 데이터를 넣은 뒤, 메모리 해제 하기 전 `list` 메모리 상황
연결 리스트 - C++
나머지 코드는 수정 사항 없고, `linkedList.cpp`만 수정하면 된다.
`malloc()` → `new`
`free()` → `delete`
linkedList.cpp
#include <iostream>
#include "linkedList.h"
// 연결 리스트 초기화 함수
void InitList(tLinkedList* _pList) {
_pList->pHeadNode = nullptr;
_pList->iCount = 0;
}
// 뒤에 데이터 추가
void PushBack(tLinkedList* _pList, int _iData) {
tNode* pNode = new tNode; // new를 사용하여 메모리 할당
pNode->iData = _iData;
pNode->pNextNode = nullptr;
if (_pList->iCount == 0) {
_pList->pHeadNode = pNode; // 첫 번째 노드로 설정
} else {
tNode* pCurFinalNode = _pList->pHeadNode;
while (pCurFinalNode->pNextNode) {
pCurFinalNode = pCurFinalNode->pNextNode;
}
pCurFinalNode->pNextNode = pNode; // 마지막 노드의 pNextNode에 새 노드를 추가
}
++_pList->iCount;
}
// 앞으로 데이터 추가
void PushFront(tLinkedList* _pList, int _iData) {
tNode* pNewNode = new tNode; // new를 사용하여 메모리 할당
pNewNode->iData = _iData;
pNewNode->pNextNode = _pList->pHeadNode; // 기존 헤드를 새 노드 뒤에 연결
_pList->pHeadNode = pNewNode; // 새 노드를 헤드로 설정
++_pList->iCount;
}
// 연결 리스트 메모리 해제 (재귀적으로 메모리 해제)
void Release(tNode* _pNode) {
if (_pNode == nullptr) return; // 노드가 nullptr이면 종료
Release(_pNode->pNextNode); // 다음 노드부터 메모리 해제
delete _pNode; // 현재 노드 메모리 해제
}
// 연결 리스트 메모리 해제 (반복문 사용)
void ReleaseList(tLinkedList* _pList) {
tNode* pDeleteNode = _pList->pHeadNode;
while (pDeleteNode) {
tNode* pNext = pDeleteNode->pNextNode; // 다음 노드 저장
delete pDeleteNode; // 현재 노드 메모리 해제
pDeleteNode = pNext; // 다음 노드로 이동
}
}
'Programming Language > C++' 카테고리의 다른 글
[C++] 클래스 (+ 멤버 함수, this 포인터) (0) | 2024.03.18 |
---|---|
[C++] 정렬과 버블 정렬 구현, 함수 포인터 (0) | 2024.03.17 |
[C++] 가변 배열 (0) | 2024.03.17 |
[C++] 동적 할당, malloc()/free() vs. new/delete (0) | 2024.03.15 |
[C++] 아스키(ASCII) 코드 (0) | 2024.03.14 |