728x90

https://www.cs.usfca.edu/~galles/visualization/BTree.html

 

B-Tree Visualization

 

www.cs.usfca.edu

 

B-tree는 데이터베이스와 파일 시스템에서 널리 사용되는 자료구조로, 데이터를 정렬된 상태로 저장하고 효율적인 검색, 순차 접근, 삽입, 삭제를 지원한다. 이진 탐색 트리(BST)를 확장한 형태로, 더 많은 자식을 가질 수 있어 디스크와 같은 보조 메모리에 저장된 대량의 데이터를 관리하는 데 적합하다.

 

모든 리프 노드가 같은 레벨에 있어 균형 잡힌 트리를 유지한다.노드가 가득 찰 경우, 중간값을 기준으로 노드를 분할하여 상위 노드로 키를 올린다.루트에서 리프로 가는 경로의 길이가 모두 동일하다.각 노드는 최대 m개의 자식을 가질 수 있는데, 이때 m은 트리의 차수이다.

 

트리를 탐색하며 키 값을 비교하여 데이터 위치를 찾는다.적절한 위치에 새로운 키를 삽입하고, 필요시 노드 분할을 수행한다.키를 삭제하고, 필요시 노드 병합이나 재배치를 통해 트리의 균형을 유지한다.

 

키-값 쌍을 사용하여 레코드를 빠르게 찾을 수 있게 해준다. 파일의 메타데이터를 관리하거나, 파일 내의 특정 데이터를 찾는 데 사용된다.

def insert(node, key):
    # 적절한 리프 노드 찾기
    if node is leaf:
        if node has space:
            insert key in node
        else:
            split node
            insert key in appropriate node
    else:
        find child node that should contain key
        insert(child node, key)

 

https://www.cs.usfca.edu/~galles/visualization/BPlusTree.html

 

B+ Tree Visualization

 

www.cs.usfca.edu

 

B+트리는 데이터베이스와 파일 시스템에서 사용되는 인덱스 메커니즘의 일종이다. 이는 탐색, 삽입, 삭제, 순회 등의 동작을 로그 시간 내에 수행할 수 있는 이진 검색 트리의 확장된 형태로, 디스크 I/O 작업의 최소화를 목표로 한다.

 

B+트리는 전에 포스팅한 RB트리처럼 몇 가지 규칙이 있다.

  1. 모든 데이터는 리프 노드에 저장된다: 이로 인해 순차 탐색이 용이하다.
  2. 내부 노드는 데이터가 아닌 키를 저장한다: 이 키들은 리프 노드에 있는 데이터를 가리키는 포인터 역할을 한다.
  3. 리프 노드는 서로 연결리스트로 연결되어 있다: 이는 범위 검색을 효율적으로 만든다.
  4. 균형 트리이다: 모든 리프 노드는 같은 레벨에 위치한다. 따라서 탐색 시간이 일정하다.
  5. 분할과 병합을 통한 동적 조정이 가능하다: 삽입이나 삭제로 인해 노드의 크기가 최대 또는 최소를 초과하면, 자동으로 분할 또는 병합이 이루어진다.

B+트리는 노드의 최대 차수(또는 분기)를 M이라 할 때, 

  • 각 내부 노드는 최대 M-1개의 키를 가질 수 있으며, 최소 ceil(M/2)-1개의 키를 가져야 한다. (단, 루트 노드는 이 조건에서 예외일 수 있다.)
  • 각 리프 노드는 최대 M개의 데이터를 가질 수 있으며, 최소 ceil(M/2)개의 데이터를 가져야 한다.

https://www.cs.usfca.edu/~galles/visualization/Trie.html

 

Trie Visualization

 

www.cs.usfca.edu

 

Trie는 검색 트리의 일종으로, 문자열의 집합을 저장하는데 특화된 자료구조이다. 주로 사전과 같은 문자열을 빠르게 검색하고, 추가하고, 삭제하는 데 사용된다. Trie의 각 노드는 문자열의 한 문자를 나타내며, 루트에서부터 특정 노드까지의 경로는 그 노드에 도달하기 위한 문자열의 접두사를 형성한다.

루트 노드: Trie의 시작점. 보통 비어있거나, 특정 기준으로 시작 문자를 가질 수 있다.

 

자식 노드: 각 노드는 0개 이상의 자식을 가질 수 있으며, 각 자식 노드는 알파벳이나 문자열 내의 다음 문자를 나타낸다.

 

마지막 노드: 문자열의 끝을 나타내는 특별한 표시(예: * 또는 특정 플래그)를 갖는 노드. 이를 통해 문자열의 존재 여부를 판별할 수 있다.

 

문자열을 검색할 때, 길이가 인 문자열에 대해 최악의 경우 시간 복잡도는 이다. 하지만 실제로는 저장된 문자열의 수보다 검색할 문자열의 길이에 더 의존적이므로, 매우 효율적이다. 그리고, Trie는 접두사를 공유하는 문자열들을 효율적으로 관리할 수 있어, 자동 완성과 같은 기능 구현에 적합하다.

 

문자와 노드 매핑을 위해 추가적인 메모리 공간이 필요하며, 특히 문자의 종류가 많은 경우(예: 유니코드 문자열) 메모리 사용량이 많아질 수 있으므로 주의해야 한다.

 

Trie는 문자열 검색, 자동 완성, 사전 추천 시스템, IP 라우팅 등 다양한 분야에서 활용된다.

class TrieNode:
    def __init__(self):
        self.children = {}
        self.is_end_of_word = False

class Trie:
    def __init__(self):
        self.root = TrieNode()
    
    def insert(self, word):
        node = self.root
        for char in word:
            if char not in node.children:
                node.children[char] = TrieNode()
            node = node.children[char]
        node.is_end_of_word = True
    
    def search(self, word):
        node = self.root
        for char in word:
            if char not in node.children:
                return False
            node = node.children[char]
        return node.is_end_of_word

# Trie 사용 예제
trie = Trie()
trie.insert("apple")
print(trie.search("apple"))  # True를 출력
print(trie.search("app"))    # False를 출력, "app"은 단어의 끝이 아님

trie.insert("app")
print(trie.search("app"))    # True를 출력, "app"이 삽입되었기 때문

 

  1. TrieNode 클래스는 Trie의 각 노드를 나타내며, 자식 노드들을 딕셔너리(children)로 관리하고, 단어의 끝을 나타내는 플래그(is_end_of_word)를 갖는다.
  2. Trie 클래스는 Trie 자체를 나타내며, 루트 노드(root)를 가지고 있고, 단어를 삽입(insert)하고 검색(search)하는 메서드를 제공한다.
  3. insert 메서드는 주어진 단어를 한 글자씩 Trie에 삽입한다. 해당 글자의 노드가 존재하지 않으면 새 노드를 생성한다.
  4. search 메서드는 주어진 단어를 Trie에서 찾는다. 단어의 모든 글자가 순서대로 Trie에 존재하고, 마지막 글자가 단어의 끝을 나타내는 노드면 True를, 그렇지 않으면 False를 반환한다.

Minimum Spanning Tree(MST, 최소 신장 트리)는 가중치가 있는 그래프에서 만들 수 있는 신장 트리 중에서 가중치의 합이 최소인 트리를 말한다. 신장 트리란, 원래의 그래프의 모든 정점을 포함하며 사이클이 없는 연결 부분 그래프이다. MST는 네트워크 설계, 클러스터 분석, 그래프 분석 등 다양한 분야에서 응용된다.

 

Kruskal의 알고리즘은 간선을 가중치의 오름차순으로 정렬한 후, 사이클을 형성하지 않는 방식으로 간선을 선택한다. 이 과정에서 분리 집합 자료구조(Disjoint Set)를 사용하여 효율적으로 사이클을 확인한다.

 

Prim의 알고리즘은 시작 정점을 선택한 후, 트리에 인접한 간선 중에서 최소 가중치를 가진 간선을 선택하여 트리를 확장한다. 이 과정은 모든 정점이 트리에 포함될 때까지 반복한다.

 

Kruskal 알고리즘을 구현하기 위해 분리 집합(Disjoint Set) 자료구조를 사용하는 것이 일반적이다. 이는 각 정점을 자신만을 포함하는 집합으로 초기화하고, 간선을 하나씩 확인하면서 두 정점이 서로 다른 집합에 속해 있다면(즉, 사이클을 형성하지 않으면) 두 집합을 합치는 방식으로 작동한다.

 

Krulskal 알고리즘 구현 방법은

  1. 모든 간선을 가중치에 따라 오름차순으로 정렬한다.
  2. 간선을 순회하며, 현재 선택한 간선이 사이클을 형성하지 않으면 신장 트리에 포함시킨다.
  3. 모든 정점이 연결될 때까지 2번을 반복한다.
class DisjointSet:
    def __init__(self, vertices):
        self.vertices = vertices
        self.parent = {v: v for v in vertices}
        self.rank = {v: 0 for v in vertices}

    def find(self, item):
        if self.parent[item] == item:
            return item
        else:
            return self.find(self.parent[item])

    def union(self, set1, set2):
        root1 = self.find(set1)
        root2 = self.find(set2)
        if root1 != root2:
            if self.rank[root1] > self.rank[root2]:
                self.parent[root2] = root1
            elif self.rank[root1] < self.rank[root2]:
                self.parent[root1] = root2
            else:
                self.parent[root2] = root1
                self.rank[root1] += 1

def kruskal(graph):
    vertices, edges = graph
    disjoint_set = DisjointSet(vertices)
    mst = []
    edges.sort(key=lambda edge: edge[2])  # 간선을 가중치에 따라 오름차순으로 정렬

    for edge in edges:
        v1, v2, weight = edge
        if disjoint_set.find(v1) != disjoint_set.find(v2):
            disjoint_set.union(v1, v2)
            mst.append(edge)

    return mst

# 그래프 정의: (정점 리스트, 간선 리스트)
graph = (['A', 'B', 'C', 'D', 'E', 'F'], 
         [('A', 'B', 2), ('A', 'C', 3), ('B', 'C', 1), 
          ('B', 'D', 4), ('C', 'E', 6), ('D', 'E', 5),
          ('E', 'F', 2), ('D', 'F', 7)])

# Kruskal 알고리즘 실행
mst = kruskal(graph)
print("Minimum Spanning Tree:", mst)
  1. DisjointSet 클래스는 각 정점의 부모를 저장하고, 두 정점이 같은 집합에 속하는지 확인하고, 두 집합을 합치는 기능을 제공한다.
  2. kruskal 함수는 주어진 그래프에서 MST를 찾아 반환한다. 먼저 모든 간선을 가중치 기준으로 정렬하고, 사이클을 형성하지 않는 간선만 선택하여 MST를 구성한다.

Prim 알고리즘을 파이썬으로 구현할 때, 우선순위 큐를 사용하여 트리에 인접한 간선 중 최소 가중치를 가진 간선을 효율적으로 찾을 수 있다.

 

Prim 알고리즘 구현 방법은

  1. 임의의 정점을 선택하여 시작한다.
  2. 선택된 정점과 인접한 간선 중 가장 가중치가 낮은 간선을 선택하고, 해당 간선이 연결하는 정점을 트리에 추가한다.
  3. 모든 정점이 트리에 포함될 때까지 2번을 반복한다.
import heapq

def prim(graph, start_vertex):
    visited = set([start_vertex])
    edges = [(cost, start_vertex, to) for to, cost in graph[start_vertex].items()]
    heapq.heapify(edges)
    mst = []

    while edges:
        cost, frm, to = heapq.heappop(edges)
        if to not in visited:
            visited.add(to)
            mst.append((frm, to, cost))
            
            for to_next, cost in graph[to].items():
                if to_next not in visited:
                    heapq.heappush(edges, (cost, to, to_next))

    return mst

# 그래프 정의: 각 정점에서 다른 정점으로의 가중치가 있는 엣지를 딕셔너리로 표현
graph = {
    'A': {'B': 2, 'C': 3},
    'B': {'A': 2, 'C': 1, 'D': 4, 'E': 2},
    'C': {'A': 3, 'B': 1, 'F': 5},
    'D': {'B': 4, 'E': 3},
    'E': {'B': 2, 'D': 3, 'F': 3},
    'F': {'C': 5, 'E': 3},
}

# Prim 알고리즘을 이용해 최소 신장 트리(MST) 찾기
mst = prim(graph, 'A')
print("Minimum Spanning Tree:", mst)

 

  1. 시작 정점을 선택하고 방문한 정점 집합에 추가한다.
  2. 시작 정점에 인접한 간선을 우선순위 큐에 추가한다.
  3. 우선순위 큐에서 가중치가 가장 낮은 간선을 추출한다. 이 간선이 연결하는 정점이 이미 방문한 정점 집합에 속하지 않는다면, 이 간선을 최소 신장 트리에 추가하고, 해당 정점을 방문한 정점 집합에 추가한다.
  4. 새롭게 추가된 정점에 인접한 간선 중 아직 방문하지 않은 정점으로의 간선을 우선순위 큐에 추가한다.
  5. 모든 정점이 방문될 때까지 3번과 4번 과정을 반복한다.

MST 알고리즘의 선택은 그래프의 특성(간선의 수, 그래프의 밀집도 등)에 따라 달라질 수 있다. 예를 들어, 간선의 수가 많이 밀집된 그래프에서는 Prim의 알고리즘이, 간선의 수가 상대적으로 적은 그래프에서는 Kruskal의 알고리즘이 더 효율적일 수 있다.

728x90

'CS > 자료구조' 카테고리의 다른 글

Graph, Adjacency Matrix, Adjacency List  (0) 2024.03.31
Hash Table (collision, chaining, rehashing)  (1) 2024.03.31
Array, Linked List  (2) 2024.03.28
Red-Black Tree 구현  (0) 2024.03.22
이진탐색트리, AVL트리  (0) 2024.03.21
728x90

목표는 userprog/syscall.c 안에 시스템 콜 핸들러를 구현해야한다. 제공된 최소한의 기능은 프로세스를 종료 시키므로써 시스템 콜을 “다룬다.(handles).”


구현하는 시스템 콜 핸들러는 시스템 콜 번호를 받아오고, 어떤 시스템 콜 인자들을 받아오고, 그에 알맞은 액션을 취해야 한다.

 

%rax 는 시스템 콜 번호 이다. 4번째 인자는 %r10 이지 %rcx가 아니다. 그러므로 시스템 콜 핸들러 syscall_handler() 가 제어권을 얻으면 시스템 콜 번호는 rax에 있고, 인자는 %rdi, %rsi, %rdx, %r10, %r8, %r9 순서로 전달된다.

 

시스템 콜 핸들러를 호출한 콜러의 레지스터는 전달받은 struct intr_frame 에 접근할 수 있다. (struct intr_frame은 커널 스택에 있다.) 함수 리턴 값을 위한 x86-64의 관례는 그 값을 RAX 레지스터에 넣는 것 이다. 값을 리턴하는 시스템 콜도 struct intr_frame의 rax멤버를 수정하는 식으로 이 관례를 따를 수 있다.

 

halt()는 power_off()를 호출해서 Pintos를 종료한다. (power_off()는 src/include/threads/init.h에 선언되어 있음)이 함수는 웬만하면 사용되지 않아야 한다. deadlock 상황에 대한 정보 등등 뭔가 조금 잃어 버릴 수도 있다.

void halt(void)
{
	power_off();
}

 

exit()은 현재 동작중인 유저 프로그램을 종료한다. 커널에 상태를 리턴하면서 종료한다. 만약 부모 프로세스가 현재 유저 프로그램의 종료를 기다리던 중이라면, 그 말은 종료되면서 리턴될 그 상태를 기다린다는 것 이다. 관례적으로, 상태 = 0 은 성공을 뜻하고 0 이 아닌 값들은 에러를 뜻 한다.

void exit(int status)
{
	struct thread *curr = thread_current();
	curr->exit_status = status;
	printf("%s: exit(%d)\n", curr->name, status);
	thread_exit();
}

 

create()는 file(첫 번째 인자)를 이름으로 하고 크기가 initial_size(두 번째 인자)인 새로운 파일을 생성한다. 성공적으로 파일이 생성되었다면 true를 반환하고, 실패했다면 false를 반환한다. 새로운 파일을 생성하는 것이 그 파일을 여는 것을 의미하지는 않는다.(파일을 여는 것은 open 시스템콜의 역할로, ‘생성’과 개별적인 연산이다.)

bool create(const char *file, unsigned initial_size)
{
	check_address(file);
	return filesys_create(file, initial_size);
}

 

open()은  file(첫 번째 인자)이라는 이름을 가진 파일을 연다. 해당 파일이 성공적으로 열렸다면, 파일 식별자로 불리는 비음수 정수(0또는 양수)를 반환하고, 실패했다면 -1를 반환한다. 0번 파일식별자와 1번 파일식별자는 이미 역할이 지정되어 있다. 0번은 표준 입력(STDIN_FILENO)을 의미하고 1번은 표준 출력(STDOUT_FILENO)을 의미한다. 

open 시스템 콜은 아래에서 명시적으로 설명하는 것처럼 시스템 콜 인자로서만 유효한 파일 식별자들을 반환하지 않는다. 각각의 프로세스는 독립적인 파일 식별자들을 갖는다. 파일 식별자는 자식 프로세스들에게 상속(전달)된다. 하나의 프로세스에 의해서든 다른 여러개의 프로세스에 의해서든, 하나의 파일이 두 번 이상 열리면 그때마다 open시스템콜은 새로운 식별자를 반환한다.

하나의 파일을 위한 서로 다른 파일 식별자들은 개별적인 close 호출에 의해서 독립적으로 닫히고 그 한 파일의 위치를 공유하지 않는다. 추가적인 작업을 하기 위해서는 open 시스템 콜이 반환하는 정수(fd)가 0보다 크거나 같아야 한다는 리눅스 체계를 따라야 한다.

int open(const char *file_name)
{
	check_address(file_name);
	struct file *file = filesys_open(file_name);
	if (file == NULL)
		return -1;

	int fd = process_add_file(file);
	if (fd == -1)
		file_close(file);

	return fd;
}

 

close()는 파일 식별자 fd를 닫는다. 프로세스를 나가거나 종료하는 것은 묵시적으로 그 프로세스의 열려있는 파일 식별자들을 닫는다. 마치 각 파일 식별자에 대해 이 함수가 호출된 것과 같다. 

void close(int fd)
{
	if (fd < 2)
		return;
	struct file *file = process_get_file(fd);
	if (file == NULL)
		return;
	file_close(file);
	process_close_file(fd);
}

 

read()는 buffer 안에 fd 로 열려있는 파일로부터 size 바이트를 읽는다. 실제로 읽어낸 바이트의 수 를 반환한다.(파일 끝에서 시도하면 0) 파일이 읽어질 수 없었다면 -1을 반환한다.(파일 끝이라서가 아닌 다른 조건에 때문에 못 읽은 경우)

int read(int fd, void *buffer, unsigned size)
{
	check_address(buffer);

	char *ptr = (char *)buffer;
	int bytes_read = 0;

	if (fd == STDIN_FILENO)
	{
		for (int i = 0; i < size; i++)
		{
			char ch = input_getc();
			if (ch == '\n')
				break;
			*ptr = ch;
			ptr++;
			bytes_read++;
		}
	}
	else
	{
		if (fd < 2)
			return -1;
		struct file *file = process_get_file(fd);
		if (file == NULL)
			return -1;
		lock_acquire(&filesys_lock);
		bytes_read = file_read(file, buffer, size);
		lock_release(&filesys_lock);
	}
	return bytes_read;
}

 

write()는 buffer로부터 open file fd로 size 바이트를 적어준다. 실제로 적힌 바이트의 수를 반환해주고, 일부 바이트가 적히지 못했다면 size보다 더 작은 바이트 수가 반환될 수 있다. 파일의 끝을 넘어서 작성하는 것은 보통 파일을 확장하는 것이지만, 파일 확장은 basic file system에 의해서는 불가능하다. 

이로 인해 파일의 끝까지 최대한 많은 바이트를 적어주고 실제 적힌 수를 반환하거나, 더 이상 바이트를 적을 수 없다면 0을 반환한다. fd 1은 콘솔에 적어준다. 콘솔에 작성한 코드가 적어도 몇 백 바이트를 넘지 않는 사이즈라면, 한 번의 호출에 있는 모든 버퍼를 putbuf()에 적어주는 것 이다.(더 큰 버퍼는 분해하는 것이 합리적)그렇지 않다면, 다른 프로세스에 의해 텍스트 출력 라인들이 콘솔에 끼게 (interleaved)되고, 읽는 사람과 채점 스크립트가 헷갈릴 것 이다.

int write(int fd, const void *buffer, unsigned size)
{
	check_address(buffer);
	int bytes_write = 0;
	if (fd == STDOUT_FILENO)
	{
		putbuf(buffer, size);
		bytes_write = size;
	}
	else
	{
		if (fd < 2)
			return -1;
		struct file *file = process_get_file(fd);
		if (file == NULL)
			return -1;
		lock_acquire(&filesys_lock);
		bytes_write = file_write(file, buffer, size);
		lock_release(&filesys_lock);
	}
	return bytes_write;
}

 

fork()는 THREAD_NAME이라는 이름을 가진 현재 프로세스의 복제본인 새 프로세스를 만든다.

 

피호출자(callee) 저장 레지스터인 %RBX, %RSP, %RBP와 %R12 - %R15를 제외한 레지스터 값을 복제할 필요가 없다. 자식 프로세스의 pid를 반환해야 한다. 그렇지 않으면 유효한 pid가 아닐 수 있다.

 

자식 프로세스에서 반환 값은 0이어야 한다. 자식 프로세스에는 파일 식별자 및 가상 메모리 공간을 포함한 복제된 리소스가 있어야 한다. 부모 프로세스는 자식 프로세스가 성공적으로 복제되었는지 여부를 알 때까지 fork에서 반환해서는 안된다. 즉, 자식 프로세스가 리소스를 복제하지 못하면 부모의 fork() 호출이 TID_ERROR를 반환할 것이다.


템플릿은 `threads/mmu.c`의 `pml4_for_each`를 사용하여 해당되는 페이지 테이블 구조를 포함한 전체 사용자 메모리 공간을 복사하지만, 전달된 `pte_for_each_func`의 누락된 부분을 채워야 한다.

tid_t process_fork(const char *name, struct intr_frame *if_ UNUSED)
{
	/* Clone current thread to new thread.*/
	struct thread *cur = thread_current();
	memcpy(&cur->parent_if, if_, sizeof(struct intr_frame));

	tid_t pid = thread_create(name, PRI_DEFAULT, __do_fork, cur);
	if (pid == TID_ERROR)
		return TID_ERROR;

	struct thread *child = get_child_process(pid);

	sema_down(&child->load_sema);

	if (child->exit_status == -2)
	{
		sema_up(&child->exit_sema);
		return TID_ERROR;
	}
	return pid;
}


exec()은 현재의 프로세스가 cmd_line에서 이름이 주어지는 실행가능한 프로세스로 변경된다. 이때 주어진 인자들을 전달한다. 성공적으로 진행된다면 어떤 것도 반환하지 않는다. 만약 프로그램이 이 프로세스를 로드하지 못하거나 다른 이유로 돌리지 못하게 되면 exit state -1을 반환하며 프로세스가 종료된다.

 

exec 함수를 호출한 쓰레드의 이름은 바꾸지 않는다. file descriptor는 exec 함수 호출 시에 열린 상태로 있다는 것을 알아둬야 한다.

int exec(const char *cmd_line)
{
	check_address(cmd_line);

	char *cmd_line_copy;
	cmd_line_copy = palloc_get_page(0);
	if (cmd_line_copy == NULL)
		exit(-1);
	strlcpy(cmd_line_copy, cmd_line, PGSIZE);

	if (process_exec(cmd_line_copy) == -1)
		exit(-1);
}

 

wait()는 자식 프로세스 (pid) 를 기다려서 자식의 종료 상태(exit status)를 가져온다. 만약 pid (자식 프로세스)가 아직 살아있으면, 종료 될 때 까지 기다린다. 종료가 되면 그 프로세스가 exit 함수로 전달해준 상태(exit status)를 반환한다. 

만약 pid (자식 프로세스)가 exit() 함수를 호출하지 않고 커널에 의해서 종료된다면 (e.g exception에 의해서 죽는 경우), wait(pid) 는 -1을 반환해야 한다.

부모 프로세스가 wait 함수를 호출한 시점에서 이미 종료되어버린 자식 프로세스를 기다리도록 하는 것은 합당하지만, 커널은 부모 프로세스에게 자식의 종료 상태를 알려주든지, 커널에 의해 종료되었다는 사실을 알려주든지 해야 한다.

int process_wait (tid_t child_tid) {
	struct thread *cur = thread_current();
	struct thread *child = get_child_process(child_tid);
	if (child == NULL)
		return -1;
	sema_down(&child->wait_sema);
	list_remove(&child->child_elem);
	sema_up(&child->exit_sema);
	return child->exit_status;
}

 

https://github.com/yunsejin/PintOS_RE/tree/Project2_RE

 

GitHub - yunsejin/PintOS_RE: PintOS_Project1,2,3

PintOS_Project1,2,3. Contribute to yunsejin/PintOS_RE development by creating an account on GitHub.

github.com

 

728x90
728x90

BFS(너비 우선 탐색)는시작 정점에서 가까운 정점부터 차례대로 모든 정점을 방문하는 방식. 큐(Queue)를 사용하여 구현한다.

 

모든 노드를 레벨별로 탐색하고 최단 경로를 찾는 데 유용하다(가중치가 없는 경우). 고로최단 경로 찾기, 소셜 네트워크에서 '최단 연결 경로' 찾기 등에 유용하다.

from collections import deque

def bfs(graph, start_node):
    visited = set()  # 방문한 노드를 체크할 집합
    queue = deque([start_node])  # 탐색을 시작할 노드를 큐에 추가
    
    while queue:  # 큐가 비어있지 않는 동안
        node = queue.popleft()  # 큐에서 하나의 노드를 꺼냄
        if node not in visited:
            visited.add(node)
            queue.extend(graph[node] - visited)  # 해당 노드의 인접 노드를 큐에 추가
    return visited

 

DFS(깊이 우선 탐색)는 시작 정점에서 한 방향으로 가능한 멀리 있는 정점을 방문하고, 더 이상 방문할 정점이 없으면 마지막에 통과한 정점으로 되돌아와 다른 방향의 정점을 탐색하는 방식이다. 스택(Stack) 또는 재귀 함수를 사용하여 구현한다.

 

경로의 가능성을 탐색할 때 유용하다. 순환 구조를 갖는 그래프에서 사용될 수 있다. 미로 찾기, 퍼즐 게임, 사이클 찾기 등을 구현할 때 유용하다.

def dfs(graph, start_node, visited=None):
    if visited is None:
        visited = set()  # 방문한 노드를 체크할 집합
    visited.add(start_node)
    
    for node in graph[start_node] - visited:  # 현재 노드의 인접 노드 중 방문하지 않은 노드에 대해 탐색
        dfs(graph, node, visited)
    return visited

 

Topological Sort(위상 정렬)은 방향 그래프에서 모든 노드를 방향성에 거스르지 않도록 순서대로 나열하는 것이다. 이 알고리즘은 주로 사이클이 없는 방향 그래프(DAG, Directed Acyclic Graph)에 적용된다. 위상 정렬의 결과는 유일하지 않을 수 있으며, 하나의 그래프에 여러 위상 정렬 결과가 존재할 수 있다.

 

DAG에서는 적어도 하나의 노드가 들어오는 간선이 없는(진입 차수가 0인) 노드가 존재한다. 위상 정렬은 진입 차수가 0인 노드를 찾아 순서대로 나열하고, 해당 노드와 연결된 간선을 제거한다. 간선 제거 후 새롭게 진입 차수가 0이 된 노드를 찾아 위 과정을 반복한다.

 

모든 노드의 진입 차수를 계산한다. 진입 차수가 0인 노드를 큐에 삽입한다. 큐에서 노드를 꺼내 해당 노드를 위상 정렬의 결과에 추가하고, 해당 노드에서 나가는 간선을 그래프에서 제거한다. 이 때, 간선 제거로 인해 새롭게 진입 차수가 0이 된

 

노드를 큐에 삽입한다. 큐가 빌 때까지 3번 과정을 반복한다. 그래프에 사이클이 없다면 모든 노드가 위상 정렬 결과에 포함된다.

from collections import deque

def topological_sort(graph):
    indegree = {u: 0 for u in graph}  # 모든 노드의 진입 차수를 0으로 초기화
    for u in graph:
        for v in graph[u]:
            indegree[v] += 1  # 진입 차수 계산
    
    queue = deque([u for u in graph if indegree[u] == 0])  # 진입 차수가 0인 노드를 큐에 삽입
    
    top_order = []
    while queue:
        u = queue.popleft()
        top_order.append(u)
        for v in graph[u]:
            indegree[v] -= 1  # u에서 나가는 간선 제거
            if indegree[v] == 0:
                queue.append(v)
                
    if len(top_order) == len(graph):
        return top_order  # 모든 노드를 방문했다면 위상 정렬 성공
    else:
        return []  # 사이클이 존재한다면 위상 정렬 실패

 

위상 정렬은 의존성이 있는 여러 작업을 수행할 때 순서를 결정하는 데 유용하다. 예를 들어, 프로젝트에서 수행해야 할 작업들 사이에 선행 관계가 정의되어 있을 때, 각 작업을 시작하기 전에 반드시 완료되어야 하는 작업들의 순서를 결정할 수 있다. 또한, 컴파일러가 소스 코드 내의 의존성을 해결하기 위해 사용하기도 한다.

 

++백트래킹

 

백트래킹(Backtracking)은 해를 찾는 도중에 현재의 경로가 해가 될 가능성이 없다고 판단되면, 즉시 뒤로 돌아가(Backtrack) 다른 경로로 해를 탐색하는 기법이다. 이 방법은 주로 결정 문제(예/아니오로 답하는 문제)나 최적화 문제를 해결할 때 사용된다. 백트래킹은 브루트 포스(완전 탐색)를 기반으로 하되, 불필요한 경로를 조기에 차단함으로써 탐색의 효율을 높인다.

 

탐색 중에 해가 될 수 없는 경우를 빠르게 판단하여 탐색 범위를 줄인다. 이는 탐색 과정에서 불필요한 부분을 제거함으로써 탐색 시간을 단축시킨다.

 

가능한 모든 해를 나무 모양으로 구성한 것으로, 해를 찾기 위해 탐색한다. 노드마다 선택지를 고르면서 내려가되, 어떤 노드가 문제의 조건에 맞지 않는다면 그 노드의 부모로 돌아와 다른 선택지를 탐색한다.

 

백트래킹은 재귀적인 성질을 띤다. 해를 찾아가는 과정에서 각 단계에서 모든 가능성을 시도하다가 가능성이 없다고 판단되면, 바로 이전 상태로 돌아간다.

def backtrack(path):
    if 조건에 부합하는 해가 완성된 경우:
        해를 출력하거나 기록
        return
    
    for 선택 in 모든 가능한 선택지:
        if 선택이 유효한 경우:
            path에 선택을 추가
            backtrack(path)  # 재귀 호출
            path에서 선택을 제거  # 백트래킹
728x90
728x90

그래프(Graph)는 데이터의 집합을 정점(Vertex)과 이들 정점을 연결하는 간선(Edge)의 집합으로 표현한 자료구조다. 그래프는 다양한 종류가 있으며, 주요 분류 기준에는 방향성 여부와 가중치 존재 여부가 있다.

무방향 그래프(Undirected Graph)에서 간선은 방향성이 없어, 두 정점을 서로 연결만 시킨다는 의미를 가진다. 예를 들어, 정점 A와 B를 연결하는 간선은 "A에서 B로" 또는 "B에서 A로"의 구분 없이 양방향 모두를 의미한다. 서로간의 연결이 대칭적인 관계, 예를 들어 소셜 네트워크의 친구 관계, 도로망 등에서 사용된다.

 

방향 그래프(Directed Graph)에서 간선은 방향성을 가지며, "A에서 B로" 혹은 그 반대로의 명확한 방향이 존재한다. 이는 간선이 (A, B)로 표시될 때, A에서 B로의 한 방향 연결만을 의미한다. 방향이 중요한 관계를 모델링할 때 사용되며, 예로는 웹 페이지 간의 링크, 트위터의 팔로우 등이 있다.

 

가중치 그래프(Weighted Graph)에서 각 간선에는 가중치가 할당되어 있다. 이 가중치는 거리, 시간, 비용 등 간선을 통해 연결된 두 정점 사이의 관계의 "가중치"를 나타낸다. 가중치는 간선을 따라 이동하는 데 드는 비용이나 거리 등을 표현할 때 사용된다. 예를 들어, 도시 간의 이동 비용, 네트워크 상에서의 데이터 전송 시간 등을 모델링할 때 사용된다.

인접 행렬(Adjacency Matrix)은 그래프를 표현하는 방법 중 하나로, 그래프의 정점들 간의 연결 관계를 행렬로 나타낸 것이다. 이 방식은 그래프의 각 정점을 행렬의 행과 열에 대응시키고, 두 정점 사이에 간선이 존재하면 해당 위치의 행렬 값에 1(또는 가중치 값)을, 없으면 0을 저장한다.

 

개의 정점을 가진 그래프는 크기의 행렬로 표현된다. 비방향 그래프의 경우, 인접 행렬은 대칭 행렬이 된다. 가중치가 있는 그래프의 경우, 1 대신 간선의 가중치 값을 행렬에 저장한다. 간선이 없는 경우 일반적으로 매우 큰 값(무한대를 의미하는 값)이나 -1을 사용하여 표현할 수 있다.

 

대각선 요소는 보통 0으로 설정된다, 자기 자신으로의 연결이 없다는 것을 의미한다. 하지만 특별한 경우(예를 들어, 루프를 허용하는 그래프)에는 다른 값을 가질 수도 있다.

 

그래프의 연결 관계를 간단하고 직관적으로 표현할 수 있고, 특정 두 정점 사이의 연결 여부를 시간에 확인할 수 있다. 하지만 정점의 수에 따라 필요한 메모리 공간이 제곱으로 증가하기 때문에, 희소 그래프(Sparse Graph)에서는 메모리 낭비가 심할 수 있고, 간선의 수에 비례하지 않고, 정점의 수의 제곱에 비례하여 메모리 공간을 사용한다.

class Graph:
    def __init__(self, size):
        self.adjMatrix = [[0 for _ in range(size)] for _ in range(size)]
        self.size = size
    
    def add_edge(self, v1, v2):
        if v1 == v2:
            print("같은 정점은 연결할 수 없습니다.")
        self.adjMatrix[v1][v2] = 1
        self.adjMatrix[v2][v1] = 1  # 무방향 그래프의 경우

    def remove_edge(self, v1, v2):
        if self.adjMatrix[v1][v2] == 0:
            print("연결된 간선이 없습니다.")
        self.adjMatrix[v1][v2] = 0
        self.adjMatrix[v2][v1] = 0  # 무방향 그래프의 경우
    
    def __str__(self):
        return '\n'.join([' '.join([str(i) for i in row]) for row in self.adjMatrix])

# 그래프 생성 및 간선 추가 예시
graph = Graph(4)  # 4개의 정점을 가진 그래프
graph.add_edge(0, 1)
graph.add_edge(0, 2)
graph.add_edge(1, 2)
graph.add_edge(2, 3)

print(graph)

 

인접 리스트(Adjacency List)는 그래프를 표현하는 자료구조 중 하나로, 각 정점에 인접한 정점들을 연결 리스트로 나열하여 그래프의 연결 관계를 표현한다. 인접 리스트는 각 정점마다 해당 정점으로부터 나가는 간선의 목록을 저장한다. 이 방법은 특히 희소 그래프(Sparse Graph), 즉 정점들 사이의 간선이 상대적으로 적은 그래프를 표현하는 데 효율적이다.

 

각 정점마다 하나의 리스트를 갖고, 해당 리스트에는 그 정점에 인접한 모든 정점들이 포함된다. Python에서는 리스트의 리스트로 구현하거나, 리스트에 연결 리스트 또는 집합을 담아 구현할 수 있다. 가중치가 있는 그래프의 경우, (인접 정점, 가중치) 형태의 튜플을 리스트에 저장함으로써 간선의 가중치 정보를 함께 저장할 수 있다.

 

방향 그래프(Directed Graph)의 경우, 각 리스트에는 해당 정점에서 출발하는 간선의 정보만 저장한다. 비방향 그래프(Undirected Graph)에서는 두 정점 사이의 연결을 양쪽 정점의 리스트에 모두 표현한다.

 

실제로 연결된 간선의 정보만을 저장하기 때문에, 인접 행렬에 비해 메모리 사용이 효율적이다. 특히 간선의 수가 정점의 수보다 많지 않은 희소 그래프에서 유리하고 그래프의 정점이나 간선이 자주 추가되거나 삭제되는 동적인 상황에 유연하게 대응할 수 있다.

 

인접 리스트에서는 특정 두 정점 사이에 간선이 존재하는지 확인하기 위해 해당 정점의 리스트를 순회해야 하므로, 최악의 경우 O(V)의 시간이 소요될 수 있다.(V는 정점의 수)

class Graph:
    def __init__(self, numVertices):
        self.numVertices = numVertices
        self.adjList = [[] for _ in range(numVertices)]
    
    def add_edge(self, src, dest):
        self.adjList[src].append(dest)
        # 비방향 그래프의 경우, 아래 코드를 추가한다.
        # self.adjList[dest].append(src)
    
    def remove_edge(self, src, dest):
        if dest in self.adjList[src]:
            self.adjList[src].remove(dest)
            # 비방향 그래프의 경우, 아래 코드를 추가한다.
            # self.adjList[dest].remove(src)
    
    def print_graph(self):
        for i in range(self.numVertices):
            print(f"Vertex {i}: {self.adjList[i]}")

# 그래프 생성 및 간선 추가 예시
graph = Graph(4)  # 4개의 정점을 가진 그래프
graph.add_edge(0, 1)
graph.add_edge(0, 2)
graph.add_edge(1, 2)
graph.add_edge(2, 3)

graph.print_graph()
728x90

'CS > 자료구조' 카테고리의 다른 글

B-tree, B+tree, Trie, Minimum Spanning Tree  (1) 2024.04.01
Hash Table (collision, chaining, rehashing)  (1) 2024.03.31
Array, Linked List  (2) 2024.03.28
Red-Black Tree 구현  (0) 2024.03.22
이진탐색트리, AVL트리  (0) 2024.03.21
728x90

해싱(Hashing)은 데이터 관리를 위한 중요한 기법 중 하나이다. 해싱은 어떤 입력값을 고정된 길이의 값으로 변환하는 과정을 말하며, 이 때 사용되는 함수를 해시 함수라 한다. 해시 함수는 다양한 길이의 입력을 받아서 고정된 길이의 해시값을 출력한다. 이 해시값은 데이터의 저장 위치를 결정하는 데 사용된다.

 

해시 테이블(Hash Table)은 해싱 기법을 사용하여 데이터를 저장하는 자료구조이다. 각 데이터는 해시 함수를 통해 계산된 해시값에 따라 해시 테이블 내의 특정 위치에 저장된다. 해시 테이블은 키-값 쌍으로 데이터를 저장하며, 이때 키를 해시 함수에 입력하여 그 결과로 나온 해시값을 사용하여 값을 저장하거나 검색한다.

 

해시 테이블의 주요 장점은 데이터의 추가, 삭제, 검색 작업을 평균적으로 상수 시간(O(1)) 내에 수행할 수 있다는 점이다. 하지만 최악의 경우(모든 키가 동일한 위치에 해시되는 경우)에는 모든 데이터를 검색해야 하므로 성능이 크게 저하될 수 있다.

 

Python의 dict, Java의 HashMap 같은 프로그래밍 언어의 내장 자료구조도 내부적으로 해시 테이블을 사용한다. Python에서는 hash() 함수를 사용하여 객체의 해시값을 얻을 수 있다. 이 함수는 내장 데이터 타입(예: 정수, 문자열)에 대해 해시값을 반환한다. 하지만 사용자 정의 객체의 경우, 객체가 해시 가능하도록 __hash__() 메소드를 구현해야 한다.

 

또한, Python의 dict 타입은 내부적으로 해시 테이블을 사용하여 키-값 쌍을 저장한다. 이를 통해 키에 대한 빠른 접근과 검색, 추가, 삭제 작업을 지원한다.

# 정수의 해시값
hash_value = hash(100)
print(hash_value)

# 문자열의 해시값
hash_value = hash("Hello, World!")
print(hash_value)
class Student:
    def __init__(self, id, name):
        self.id = id
        self.name = name

    # 객체가 해시 가능하도록 __hash__ 메소드 구현
    def __hash__(self):
        return hash((self.id, self.name))

    # __eq__ 메소드도 구현하여 객체의 동등성 비교 가능하게 함
    def __eq__(self, other):
        return self.id == other.id and self.name == other.name

# 사용자 정의 객체 생성
student = Student(1, "John Doe")

# 사용자 정의 객체의 해시값
print(hash(student))

__hash__() 메소드는 객체의 해시값을 계산하여 반환하며, __eq__() 메소드는 두 객체의 동등성을 비교한다. Python에서는 두 객체가 동등하다고 판단되면, 이들의 해시값도 동일해야 한다.

 

해시 충돌(Collision)두 개 이상의 키가 해시 함수를 통해 동일한 해시값(즉, 동일한 위치)을 가리키는 경우 발생하는 현상이다. 해시 테이블의 효율성을 저하시키며, 이를 효과적으로 처리하는 것이 해시 테이블 설계의 핵심적인 부분이다. 충돌이 발생했을 때 체이닝과 재해싱은 이를 해결하기 위한 두 가지 주요 방법이다.

 

체이닝(Chaining)은 충돌이 발생했을 때 같은 해시값을 가진 요소들을 연결 리스트와 같은 자료구조를 사용하여 연결하는 방식이다. 이렇게 하면 한 버킷에 여러 요소가 저장될 수 있으며, 충돌로 인한 문제를 효율적으로 해결할 수 있다. 체이닝 방식의 장점은 해시 테이블의 크기와 무관하게 충돌을 관리할 수 있다는 점이며, 구현이 비교적 간단하다는 것도 장점이다. 다만, 체인이 길어지면 검색 성능이 저하될 수 있다.

class HashTableChaining:
    def __init__(self, size=10):
        self.size = size
        self.table = [[] for _ in range(size)]
    
    def hash_function(self, key):
        return key % self.size
    
    def insert(self, key, value):
        hash_index = self.hash_function(key)
        for i, (k, v) in enumerate(self.table[hash_index]):
            if k == key:
                self.table[hash_index][i] = (key, value)
                return
        self.table[hash_index].append((key, value))
    
    def search(self, key):
        hash_index = self.hash_function(key)
        for k, v in self.table[hash_index]:
            if k == key:
                return v
        return None
    
    def delete(self, key):
        hash_index = self.hash_function(key)
        for i, (k, v) in enumerate(self.table[hash_index]):
            if k == key:
                del self.table[hash_index][i]
                return True
        return False

 

 

재해싱(rehashing)은 해시 테이블의 크기를 조정하고, 모든 요소를 새로운 크기에 맞는 해시 함수를 사용하여 재배치하는 방법이다. 주로 해시 테이블의 사용률(로드 팩터)이 높아져 성능 저하가 예상될 때 사용된다. 재해싱은 충돌의 가능성을 줄이고, 해시 테이블의 성능을 유지하는 데 도움이 된다. 재해싱 과정은 모든 요소를 새로운 해시 테이블로 이동시켜야 하므로, 리소스와 시간이 많이 소모되는 작업이다. 그래서, 재해싱은 해시 테이블의 성능을 개선하기 위해 필요할 때만 수행되어야 한다.

class HashTableRehashing:
    def __init__(self, size=10):
        self.size = size
        self.count = 0
        self.table = [None] * size
    
    def hash_function(self, key):
        return key % self.size
    
    def rehash(self, key, step=1):
        return (key + step) % self.size
    
    def resize_and_rehash_all(self):
        old_table = self.table[:]
        self.size *= 2
        self.table = [None] * self.size
        self.count = 0
        for item in old_table:
            if item:
                key, value = item
                self.insert(key, value)
    
    def insert(self, key, value):
        if self.count / self.size >= 0.7:  # Load factor threshold
            self.resize_and_rehash_all()
        
        hash_index = self.hash_function(key)
        step = 1
        while self.table[hash_index] is not None:
            if self.table[hash_index][0] == key:
                self.table[hash_index] = (key, value)
                return
            hash_index = self.rehash(hash_index, step)
            step += 1
        
        self.table[hash_index] = (key, value)
        self.count += 1
    
    def search(self, key):
        hash_index = self.hash_function(key)
        step = 1
        while self.table[hash_index] is not None:
            if self.table[hash_index][0] == key:
                return self.table[hash_index][1]
            hash_index = self.rehash(hash_index, step)
            step += 1
        return None
    
    def delete(self, key):
        hash_index = self.hash_function(key)
        step = 1
        while self.table[hash_index] is not None:
            if self.table[hash_index][0] == key:
                self.table[hash_index] = None
                self.count -= 1
                return True
            hash_index = self.rehash(hash_index, step)
            step += 1
        return False

 

위 코드에선 해시 테이블이 일정 수준으로 채워지면(load factor >= 0.7), 해시 테이블의 크기를 늘리고 모든 요소를 새로운 크기에 맞게 재해싱하여 충돌 가능성을 줄인다.

728x90

'CS > 자료구조' 카테고리의 다른 글

B-tree, B+tree, Trie, Minimum Spanning Tree  (1) 2024.04.01
Graph, Adjacency Matrix, Adjacency List  (0) 2024.03.31
Array, Linked List  (2) 2024.03.28
Red-Black Tree 구현  (0) 2024.03.22
이진탐색트리, AVL트리  (0) 2024.03.21
728x90

완전 탐색 알고리즘(Brute-Force Search)은 문제를 해결하기 위해 가능한 모든 경우의 수를 시험해보는 방법이다. 가장 기본적이면서 직관적인 알고리즘 접근 방식 중 하나로, 복잡한 문제 해결 방법이 바로 떠오르지 않을 때 유용하게 사용될 수 있다.

 

Brute-Force Search 는 문제의 가능한 모든 해를 체계적으로 나열하고 검사하여, 조건에 맞는 해를 찾아낸다. 이 방법은 문제의 크기가 작을 때 효과적이고 특정 문제에 국한되지 않고, 어떤 종류의 문제에도 적용 가능하다.

 

가능한 해의 수가 많아지면 계산 시간이 기하급수적으로 증가하는 단점이 있다 가능한 모든 경우의 수를 탐색하기 때문에, 문제의 규모가 커질수록 비효율적이고 시간이 많이 소요되기 때문이다.

 

Brute-Force Search는 비밀번호 크래킹(가능한 모든 문자열 조합을 시도하여 비밀번호를 찾아내는 과정)과 여러 도시를 순회하는 여행자 문제(TSP, Traveling Salesman Problem)에서 가능한 모든 경로를 탐색하여 최단 경로를 찾는 경우와 스도쿠, 큐브 퍼즐 등에서 가능한 모든 숫자나 위치의 조합을 시도하여 문제를 해결할 때 사용할 수 있다.

def linear_search(arr, target):
    for i in range(len(arr)):
        if arr[i] == target:
            return i  # 원하는 값을 찾으면 그 위치를 반환
    return -1  # 값을 찾지 못하면 -1 반환

 

Brute-Force Search 방식은 문제를 해결하는 가장 확실한 방법 중 하나이지만, 대부분의 경우 더 효율적인 알고리즘을 고려하는 것이 좋다.

 

이진 탐색(Binary Search)은 정렬된 배열에서 특정한 값을 찾기 위한 효율적인 알고리즘이다. 이진 탐색은 분할 정복(Divide and Conquer) 전략을 사용하여, 탐색 과정에서 검사해야 할 요소의 수를 매 단계마다 절반으로 줄여 나간다. 이로 인해 탐색 시간을 대폭 줄일 수 있으며, 시간 복잡도는 O(log n)이다.

 

작동 원리

  1. 분할(Divide): 배열의 중간에 위치한 원소를 선택하고, 이 원소를 피벗(pivot)으로 한다.
  2. 정복(Conquer): 피벗과 탐색하고자 하는 값(target)을 비교한다.
    • 타겟 값이 피벗보다 작다면, 피벗의 왼쪽 부분에 대해서만 탐색을 계속한다.
    • 타겟 값이 피벗보다 크다면, 피벗의 오른쪽 부분에 대해서만 탐색을 계속한다.
  3. 재귀(Recursive): 선택된 부분 배열에 대해 위의 과정을 반복한다.
  4. 종료 조건:
    • 타겟 값이 피벗과 일치하는 경우, 해당 인덱스를 반환한다.
    • 탐색 범위가 더 이상 없는 경우(타겟 값을 찾지 못한 경우), -1 또는 탐색 실패를 나타내는 값이 반환된다.

이진 탐색을 사용하기 위해서는 배열이 미리 정렬되어 있어야 한다. 정렬된 데이터셋에서 특정 값의 존재 여부 확인, 값의 인덱스 찾기 등에 사용된다.

def binary_search(arr, target):
    left, right = 0, len(arr) - 1

    while left <= right:
        mid = (left + right) // 2
        if arr[mid] == target:
            return mid  # 타겟을 찾은 경우, 인덱스 반환
        elif arr[mid] < target:
            left = mid + 1  # 타겟이 더 큰 경우, 왼쪽 범위 조정
        else:
            right = mid - 1  # 타겟이 더 작은 경우, 오른쪽 범위 조정

    return -1  # 타겟을 찾지 못한 경우, -1 반환

 

최소 힙(Min Heap)과 최대 힙(Max Heap)은 완전 이진 트리(complete binary tree)를 기반으로 하는 힙(Heap) 자료구조의 두 가지 주요 형태다. 힙은 각 노드가 하위 노드보다 우선순위가 높은 반정렬 상태(semi-ordered state)를 유지하는 트리 기반의 자료구조이다. 최소 힙과 최대 힙은 우선순위의 정의에 따라 분류되며, 우선순위 큐(priority queue) 구현에 자주 사용된다.

 

최소 힙(Min Heap)에서는 부모 노드의 키(key) 값이 자식 노드의 키 값보다 항상 작거나 같다. 즉, 트리의 루트에는 힙 내의 최소값이 위치한다. 따라서, 최소 힙을 사용하면 원소들이 항상 오름차순으로 정렬된 상태를 유지할 수 있다.

  • insert: 새로운 요소를 추가할 때는, 트리의 가장 마지막에 요소를 추가한 후, 부모 노드와 비교하여 힙 속성을 만족시키도록 위치를 조정한다.
  • extract-min: 힙에서 최소값(루트 노드)을 제거하고, 트리의 가장 마지막 요소를 루트로 이동시킨 후, 힙 속성을 만족시키도록 다시 조정한다.

최대 힙 (Max Heap)에서는 부모 노드의 키 값이 자식 노드의 키 값보다 항상 크거나 같다. 즉, 트리의 루트에는 힙 내의 최대값이 위치한다. 최대 힙을 사용하면 원소들이 항상 내림차순으로 정렬된 상태를 유지할 수 있다.

  • insert: 새로운 요소를 추가할 때는, 트리의 가장 마지막에 요소를 추가한 후, 부모 노드와 비교하여 힙 속성을 만족시키도록 위치를 조정한다.
  • extract-max: 힙에서 최대값(루트 노드)을 제거하고, 트리의 가장 마지막 요소를 루트로 이동시킨 후, 힙 속성을 만족시키도록 다시 조정한다.

힙은 배열로 효율적으로 구현될 수 있다.

  • 부모 노드의 인덱스:
  • 왼쪽 자식 노드의 인덱스: 2i + 1
  • 오른쪽 자식 노드의 인덱스: 2i + 2

Priority Queue, 즉 우선순위 큐는 우선순위를 기준으로 요소들이 정렬되는 추상 자료형(abstract data type)이다. 이 자료구조에서는 각 요소가 우선순위와 연관되어 있으며, 우선순위가 가장 높은 요소가 가장 먼저 제거된다. 우선순위 큐는 다양한 방법으로 구현될 수 있지만, 힙(heap)을 사용하는 것이 가장 일반적이다.

 

우선순위 큐에서 요소의 추가나 제거는 요소의 우선순위에 따라 결정된다. 가장 높은 우선순위를 가진 요소가 가장 먼저 제거된다. 배열, 연결 리스트, 이진 힙(최소 힙 또는 최대 힙) 등 여러 가지 방법으로 구현할 수 있다. 이 중 이진 힙을 사용하는 것이 추가 및 제거 작업에 대해 O(log n)의 시간 복잡도를 제공하여 가장 효율적이다.

 

Priority Queue는 다익스트라 알고리즘, 프림 알고리즘과 같은 그래프 알고리즘, 스케줄링, 네트워크 트래픽 제어, 이벤트 관리 시스템 등 다양한 분야에서 활용된다.

  • insert(item, priority): 주어진 우선순위와 함께 요소를 큐에 추가한다.
  • pop(): 가장 높은 우선순위를 가진 요소를 큐에서 제거하고, 그 요소를 반환한다.
  • peek(): 가장 높은 우선순위를 가진 요소를 큐에서 제거하지 않고 반환한다.
  • isEmpty(): 큐가 비어 있는지 여부를 확인한다.

Python의 표준 라이브러리인 heapq 모듈은 최소 힙 기능을 제공하며, 최소 힙을 우선순위 큐로 사용할 수 있다. 최대 힙을 구현하려면 우선순위에 음수를 적용하거나 사용자 정의 객체를 사용할 수 있다.

import heapq

# 최소 힙을 사용한 우선순위 큐 예시
priority_queue = []
heapq.heappush(priority_queue, (1, 'Task 1'))  # 우선순위 1
heapq.heappush(priority_queue, (3, 'Task 3'))  # 우선순위 3
heapq.heappush(priority_queue, (2, 'Task 2'))  # 우선순위 2

# 가장 우선순위가 높은 요소를 제거하고 반환
print(heapq.heappop(priority_queue))  # 출력: (1, 'Task 1')
728x90
728x90

QuickSort는 분할 정복(divide and conquer) 전략을 사용하는 효율적인 정렬 알고리즘 중 하나다. 평균적인 시간 복잡도는 O(n log n)이며, 최악의 경우 시간 복잡도는 O(n^2)이다.

 

작동 원리

  1. 분할(Divide): 배열에서 '피벗'이라고 하는 하나의 원소를 선택한다. 피벗의 위치는 다양한 방법으로 선택할 수 있으며, 피벗 선택 방식에 따라 알고리즘의 성능이 달라질 수 있다. 이 피벗을 기준으로 배열을 두 부분으로 나눈다. 피벗보다 작은 모든 요소는 피벗의 왼쪽에, 피벗보다 큰 모든 요소는 피벗의 오른쪽에 오도록 한다.
  2. 정복(Conquer): 피벗을 제외한 왼쪽 부분 배열과 오른쪽 부분 배열을 재귀적으로 정렬한다.
  3. 결합(Combine): QuickSort는 정복 단계에서 배열을 실제로 정렬하므로, 별도의 결합 단계가 필요하지 않다.

특징

  • In-place 정렬: 추가적인 배열을 필요로 하지 않으며, 주어진 배열 내에서 정렬을 수행한다.
  • 불안정 정렬(Unstable sort): 동일한 값의 원소가 있을 경우, 원래의 순서가 유지되지 않을 수 있다.
  • 피벗 선택 중요: 피벗 선택 방법에 따라 성능이 크게 달라질 수 있다. 예를 들어, 항상 첫 번째 원소를 피벗으로 선택하면 이미 정렬된 배열에 대해 최악의 성능(O(n^2))을 보일 수 있다.
def quicksort(arr):
    if len(arr) <= 1:
        return arr
    pivot = arr[len(arr) // 2]
    left = [x for x in arr if x < pivot]
    middle = [x for x in arr if x == pivot]
    right = [x for x in arr if x > pivot]
    return quicksort(left) + middle + quicksort(right)

 

 

정렬 과정

  1. 초기 배열: [3, 6, 8, 10, 1, 2, 1]
  2. 피벗 선택: 8 (배열의 중간 요소)
  3. 분할:
    • 왼쪽(left): [3, 6, 1, 2, 1] (피벗보다 작은 요소)
    • 중간(middle): [8] (피벗과 같은 요소)
    • 오른쪽(right): [10] (피벗보다 큰 요소)
  4. 왼쪽(left) 부분 배열에 대한 재귀적 정렬:
    • 피벗 선택: 1
    • 분할:
      • 왼쪽: [1] (피벗보다 작거나 같은 요소)
      • 중간: [1] (피벗과 같은 요소)
      • 오른쪽: [3, 6, 2] (피벗보다 큰 요소)
    • 오른쪽 부분 배열에 대한 재귀적 정렬:
      • 피벗 선택: 6
      • 분할:
        • 왼쪽: [3, 2] (피벗보다 작은 요소)
        • 중간: [6] (피벗과 같은 요소)
        • 오른쪽: [] (피벗보다 큰 요소가 없음)
      • 왼쪽 부분 배열에 대한 재귀적 정렬:
        • 피벗 선택: 2
        • 분할:
          • 왼쪽: [2] (피벗보다 작은 요소)
          • 중간: [] (피벗과 같은 요소가 없음)
          • 오른쪽: [3] (피벗보다 큰 요소)
      • 재조합: [2, 3, 6]
    • 재조합: [1, 1, 2, 3, 6]
  5. 전체 배열에 대한 재조합: [1, 1, 2, 3, 6, 8, 10]

정렬된 배열

  • 최종 결과: [1, 1, 2, 3, 6, 8, 10]

 

MergeSort는 분할 정복(divide and conquer) 방법론을 기반으로 하는 효율적인 정렬 알고리즘이다. 평균적으로 O(n log n)의 시간 복잡도를 가지며, 최악의 경우에도 O(n log n)을 유지한다. 이 알고리즘은 배열을 더 이상 나눌 수 없을 때까지 반으로 나눈 다음, 나누어진 배열을 다시 정렬하면서 병합하는 방식으로 작동한다.

 

작동 원리

  1. 분할(Divide): 입력 배열을 가장 작은 단위가 될 때까지 계속 반으로 나눈다. 이 과정은 재귀적으로 수행되며, 각 부분 배열이 하나의 요소만을 갖게 될 때까지 계속된다.
  2. 정복(Conquer): 나누어진 부분 배열을 다시 병합하면서 정렬한다. 이때, 인접한 두 부분 배열을 정렬하면서 병합하는 방식으로 진행한다.
  3. 결합(Combine): 모든 부분 배열이 병합되어 하나의 정렬된 배열이 될 때까지 2단계를 반복한다.

특징

  • 안정 정렬(Stable sort): 동일한 값의 요소 사이의 상대적인 순서가 변경되지 않는다.
  • 비교적 메모리 사용량이 많음: 병합 과정에서 추가적인 배열이 필요하기 때문에, QuickSort와 같은 일부 다른 정렬 알고리즘에 비해 메모리 사용량이 많다.
  • 분할 정복 알고리즘: 데이터를 분할하고, 각각을 정복한 다음, 결과를 결합하는 전형적인 분할 정복 접근 방식을 따른다.
def mergeSort(arr):
    if len(arr) > 1:
        mid = len(arr)//2
        L = arr[:mid]
        R = arr[mid:]

        mergeSort(L)
        mergeSort(R)

        i = j = k = 0

        while i < len(L) and j < len(R):
            if L[i] < R[j]:
                arr[k] = L[i]
                i += 1
            else:
                arr[k] = R[j]
                j += 1
            k += 1

        while i < len(L):
            arr[k] = L[i]
            i += 1
            k += 1

        while j < len(R):
            arr[k] = R[j]
            j += 1
            k += 1

 

 

단계 과정 설명
1 [38, 27, 43, 3, 9, 82, 10] 초기 배열
2 [38, 27, 43] & [3, 9, 82, 10] 배열을 반으로 나눈다.
3 [38] & [27, 43] 왼쪽 부분 배열을 다시 반으로 나눈다.
4 [27] & [43] [27, 43]을 다시 반으로 나눈다.
5 [27, 43] [27]과 [43]을 병합하여 정렬한다.
6 [27, 38, 43] [38]과 [27, 43]을 병합하여 정렬한다.
7 [3] & [9, 82, 10] 오른쪽 부분 배열을 반으로 나눈다.
8 [9] & [82, 10] [9, 82, 10]을 다시 반으로 나눈다.
9 [82] & [10] [82, 10]을 다시 반으로 나눈다.
10 [10, 82] [82]와 [10]을 병합하여 정렬한다.
11 [9, 10, 82] [9]와 [10, 82]을 병합하여 정렬한다.
12 [3, 9, 10, 82] [3]와 [9, 10, 82]을 병합하여 정렬한다.
13 [3, 9, 10, 27, 38, 43, 82] 최종적으로 모든 부분 배열을 병합하여 정렬한다.

 

HeapSort는 힙(heap) 자료구조를 기반으로 하는 효율적인 정렬 알고리즘 중 하나다. 이 알고리즘은 최악, 평균, 최선의 경우 모두 O(n log n)의 시간 복잡도를 가진다. HeapSort의 기본 아이디어는 주어진 데이터를 이진 힙(binary heap) 구조로 만든 후, 큰 값들(또는 작은 값들)을 배열의 끝으로 이동시키면서 정렬하는 것이다.

 

힙(Heap) 이란?

힙은 각 노드의 값이 자식 노드의 값보다 크거나 같은(또는 작거나 같은) 완전 이진 트리(complete binary tree)이다. 힙은 크게 두 종류가 있다: 최대 힙(max heap)과 최소 힙(min heap). 최대 힙에서는 부모 노드의 값이 자식 노드의 값보다 항상 크거나 같으며, 최소 힙에서는 부모 노드의 값이 자식 노드의 값보다 항상 작거나 같다.

 

HeapSort의 작동 방식

  1. 힙 구성(Heapify): 주어진 배열을 최대 힙 형태로 구성한다. 이 과정은 배열의 중간부터 시작하여 루트 노드까지 거슬러 올라가면서, 각 노드를 올바른 위치로 이동시키는 "heapify" 과정을 반복하여 수행한다.
  2. 정렬(Sort): 최대 힙의 루트(가장 큰 값)는 항상 힙의 첫 번째 위치에 있으므로, 이를 배열의 마지막 요소와 교환한다. 배열에서 힙으로 고려되는 부분의 크기를 하나 줄이고(실제로 배열에서 요소를 제거하는 것은 아니다), 변경된 힙에 대해 다시 heapify 과정을 수행한다. 이 과정을 배열이 정렬될 때까지 반복한다.

특징

  • In-place 정렬: 추가적인 배열을 필요로 하지 않으며, 주어진 배열 내에서 정렬을 수행한다.
  • 불안정 정렬(Unstable sort): 동일한 값의 원소가 있을 경우, 원래의 순서가 유지되지 않을 수 있다.
  • 효율적인 시간 복잡도: 모든 경우에 O(n log n)의 시간 복잡도를 제공한다.
def heapify(arr, n, i):
    largest = i
    left = 2 * i + 1
    right = 2 * i + 2

    if left < n and arr[largest] < arr[left]:
        largest = left

    if right < n and arr[largest] < arr[right]:
        largest = right

    if largest != i:
        arr[i], arr[largest] = arr[largest], arr[i]
        heapify(arr, n, largest)

def heapSort(arr):
    n = len(arr)

    for i in range(n//2 - 1, -1, -1):
        heapify(arr, n, i)

    for i in range(n-1, 0, -1):
        arr[i], arr[0] = arr[0], arr[i]
        heapify(arr, i, 0)

 

힙 구성(Heapify) 단계

단계 과정 설명
1 [3, 9, 82, 10, 38, 27, 43] 초기 배열
2 [3, 9, 82, 10, 38, 27, 43] 배열을 최대 힙으로 만들기 시작 (아직 변화 없음)
3 [3, 9, 82, 10, 38, 27, 43] 자식 노드들과 비교하여 최대 힙 속성 만족 (아직 변화 없음)
4 [3, 9, 82, 10, 38, 27, 43] 더 큰 자식 노드와 부모 노드를 계속 교환
5 [82, 9, 43, 10, 38, 27, 3] 최대 힙 구성 완료

 

정렬(Sort) 단계

단계 과정 설명
6 [3, 9, 43, 10, 38, 27, 82] 최대 값(82)을 배열의 끝으로 이동 후 힙 크기 감소
7 [43, 9, 27, 10, 38, 3, 82] 새로운 최대 힙 구성
8 [9, 10, 27, 3, 38, 43, 82] 다음 최대 값(43)을 배열의 끝으로 이동 후 힙 크기 감소
9 [38, 10, 27, 3, 9, 43, 82] 계속해서 최대 힙 구성 및 최대 값을 배열 끝으로 이동
10 [10, 9, 27, 3, 38, 43, 82] 최대 힙 구성 및 최대 값을 배열 끝으로 이동
11 [27, 9, 10, 3, 38, 43, 82] 반복
12 [9, 3, 10, 27, 38, 43, 82] 최종적으로 정렬된 배열
728x90
728x90

/bin/ls -l foo bar 와 같은 명령이 주어졌을 때, 인자들을 어떻게 다뤄야 하는지 생각해보자

  1. 명령을 단어들로 쪼갠다. /bin/ls, l, foo, bar 이렇게
  2. 이 단어들을 스택의 맨 처음 부분에 놓는다. 순서는 상관 없다. 왜냐면 포인터에 의해 참조될 예정이기 때문이다.
  3. 각 문자열의 주소 + 경계조건을 위한 널포인터를 스택에 오른쪽→왼쪽 순서로 푸시한다. **이들은 argv의 원소가 된다. 널포인터 경계는 argv[argc] 가 널포인터라는 사실을 보장해준다. C언어 표준의 요구사항에 맞춰서 말이다. 그리고 이 순서는 argv[0]이 가장 낮은 가상 주소를 가진다는 사실을 보장해준다. 또한 word 크기에 정렬된 접근이 정렬되지 않은 접근보다 빠르므로, 최고의 성능을 위해서는 스택에 첫 푸시가 발생하기 전에 스택포인터를 8의 배수로 반올림하여야 한다.
  4. %rsi 가 argv 주소(argv[0]의 주소)를 가리키게 하고, %rdi 를 argc 로 설정한다.
  5. 마지막으로 가짜 “리턴 어드레스”를 푸시한다 : entry 함수는 절대 리턴되지 않겠지만, 해당 스택 프레임은 다른 스택 프레임들과 같은 구조를 가져야 한다. 

 

Address Name Data Type

0x4747fffc argv[3][...] 'bar\0' char[4]
0x4747fff8 argv[2][...] 'foo\0' char[4]
0x4747fff5 argv[1][...] '-l\0' char[3]
0x4747ffed argv[0][...] '/bin/ls\0' char[8]
0x4747ffe8 word-align 0 uint8_t[]
0x4747ffe0 argv[4] 0 char *
0x4747ffd8 argv[3] 0x4747fffc char *
0x4747ffd0 argv[2] 0x4747fff8 char *
0x4747ffc8 argv[1] 0x4747fff5 char *
0x4747ffc0 argv[0] 0x4747ffed char *
0x4747ffb8 return address 0 void (*) ()

RDI: 4 | RSI: 0x4747ffc0

 

위 표는 스택과 관련 레지스터들이 유저 프로그램이 시작되기 직전에 어떤 상태인지를 보여준다. 스택은 아래로 자란다는 것을 잊지 말자.

 *      4 kB +---------------------------------+
 *           |          kernel stack           |
 *           |                |                |
 *           |                |                |
 *           |                V                |
 *           |         grows downward          |
 *           |                                 |
 *           |                                 |
 *           |                                 |
 *           |                                 |
 *           |                                 |
 *           |                                 |
 *           |                                 |
 *           |                                 |
 *           +---------------------------------+
 *           |              magic              |
 *           |            intr_frame           |
 *           |                :                |
 *           |                :                |
 *           |               name              |
 *           |              status             |
 *      0 kB +---------------------------------+

 

 

저 위의 표의 예시에서, 스택 포인터는 위에 보이는 것처럼 0x4747ffb8로 초기화될 것이고, 코드 include/threads/vaddr.h에 정의된 USER_STACK 값에서부터 스택을 시작시켜야 한다.

 

현재, process_exec() 함수는 새로운 프로세스들에 인자를 전달하는 것을 지원하지 않는다. process_exec() 함수를 확장 구현해서, 지금처럼 단순히 프로그램 파일 이름만을 인자로 받아오게 하는 대신 공백을 기준으로 여러 단어로 나누어지게 만들어야 한다.

첫 번째 단어는 프로그램 이름이고, 두 번째 단어는 첫 번째 인자이며, 그런 식으로 계속 이어지게 만들면 된다. 따라서, 함수 process_exec("grep foo bar") 는 두 개의 인자 foo와 bar을 받아서 grep 프로그램을 실행시켜야 한다.

int
process_exec (void *f_name) {
	char *file_name = f_name;
	bool success;

	/* We cannot use the intr_frame in the thread structure.
	 * This is because when current thread rescheduled,
	 * it stores the execution information to the member. */
	struct intr_frame _if;
	_if.ds = _if.es = _if.ss = SEL_UDSEG;
	_if.cs = SEL_UCSEG;
	_if.eflags = FLAG_IF | FLAG_MBS;

	/* We first kill the current context */
	process_cleanup ();
	int argc = 0;
    char *argv[64];		//parssing한 인자를 담을 배열
    char *token, *save_ptr;
    
	for (token = strtok_r(file_name, " ", &save_ptr); token != NULL; token = strtok_r(NULL, " ", &save_ptr))
    {   
		argv[argc++] = token;
	}
	/* And then load the binary */
	success = load (file_name, &_if);

	argument_stack(argv, argc, &_if);
	
	/* If load failed, quit. */
	palloc_free_page (file_name);
	if (!success)
		return -1;

	/* Start switched process. */
	do_iret (&_if);
	NOT_REACHED ();
}

 

argument_stack 함수는 프로세스의 스택에 실행 인자를 준비하는 역할을 한다. 인자들을 스택에 복사하고, 64비트 시스템에서 요구하는 8바이트 정렬을 맞추며, 각 인자의 주소를 스택에 추가한다. 이 과정을 통해 프로그램 실행 시 필요한 인자들을 스택에 올바르게 준비한다.

 

void argument_stack(char **argv, int argc, struct intr_frame *if_)
{
	char *arg_address[128];

	for(int i = argc - 1; i >= 0; i--)
	{
		int arg_i_len = strlen(argv[i]) +1;
		if_->rsp -= arg_i_len;
		memcpy(if_->rsp, argv[i], arg_i_len);
		arg_address[i] = (char *)if_->rsp;
	}

	if(if_->rsp % 8 != 0)
	{	
		int padding = if_->rsp % 8;
		if_->rsp -= padding;
		memset(if_->rsp, 0, padding);
	}

	if_->rsp -= 8; 	
	memset(if_->rsp, 0, 8);

	for(int i = argc-1; i >= 0; i--)
	{
		if_->rsp -= 8;
		memcpy(if_->rsp, &arg_address[i], 8);
	}

	if_->rsp -= 8;
	memset(if_->rsp, 0, 8);

	if_->R.rdi = argc;
	if_->R.rsi = if_->rsp + 8;
}

 

커맨드라인에서, 여러개의 공백은 하나의 공백과 같게 처리해야 한다. 그러므로 process_exec("grep    foo   bar")는 저 위의 표(원본) 예시와 동일하게 동작해야 한다.

또한, 납득할만한 수준에서 커맨드라인 인자들의 길이 제한을 강요할 수 있다. 예를 들면, 인자들이 한 페이지 크기(4kB) 안에 들어가게끔 제한하는 것 이다. (그리고 직접적인 관계는 없지만, pintos 유틸리티가 커널에 전달할 수 있는 커맨드라인 인자 크기는 128바이트로 제한되어 있다.)

 

<stdio.h>에 선언된 비표준 함수인 hex_dump()는 인자 전달 코드를 디버깅 하는데에 유용할 것이다. argument passing을 잘 했는지 make tests/userprog/args-none.result로 체크를 하면 안된다. write가 구현되어 있지 않기 때문인데, 이 때문에, 파싱이 잘 됐는지 확인하기 위해서는 hex_dump를 써야한다.

 

int write (int fd, const void *buffer, unsigned length)
{
	int byte = 0;
	if(fd == 1)
	{
		putbuf(buffer, length);
		byte = length;
	}
	return byte;
}

아니면 syscall.c 쪽에서 write를 임의로 구현해도 된다.

 

https://github.com/yunsejin/PintOS_RE/tree/project2

 

GitHub - yunsejin/PintOS_RE: PintOS_Project1,2,3

PintOS_Project1,2,3. Contribute to yunsejin/PintOS_RE development by creating an account on GitHub.

github.com

 

728x90

'CS > 운영체제' 카테고리의 다른 글

Pint OS_Project 3 구현  (0) 2024.04.14
Pint OS_Project 2 구현 - 2(system call)  (0) 2024.04.01
Page Replacement Policy  (0) 2024.03.27
Demand-zero page, Anonymous page, File-backed page  (0) 2024.03.27
하이퍼바이저, 애뮬레이션, QEMU  (0) 2024.03.26

+ Recent posts