no image
[Java] 리스트 구현(SLL, DLL)
단일 연결 리스트(Singly Linked List) 직접 구현 노드 class Node { E data; Node next; Node(E data) { this.data = data; this.next = null; } } 노드 추가 //리스트의 가장 뒷쪽에 데이터 추가 public void add(E data) { Node newNode = new Node(data); if (head == null) head = newNode; else { Node currentHead = head; while (currentHead.next != null) currentHead = currentHead.next; currentHead.next = newNode; } } 노드 삽입 //리스트의 원하는 인덱스에 데이터..
2024.01.21
no image
[Java] 문자열 탐색(브루트 포스, KMP, 보이어 무어)
Do it! 자료구조와 함께 배우는 알고리즘 입문[자바편] 을 개인적으로 정리하는 글임을 알립니다. 문자열 탐색 문자열 탐색이란 어떤 문자열 안에 특정 문자열이 들어 있는지를 조사하고, 들어있다면 그 위치를 찾는 것을 말한다. 이 글에서는 검색할 문자열을 패턴이라 하고 문자열 원본을 텍스트라고 하겠다. 문자열 탐색 알고리즘에는 아래와 같은 방법이 있다. 브루트 포스 KMP 보이어-무어 브루트 포스 브루트 포스(Brute Force)는 가장 간단하고 직접적인 문자열 탐색 방법 중 하나이다. 이 방법은 텍스트에서 패턴을 한 글자씩 비교하면서 탐색하는 단순한 방법이다. 각 가능한 위치에서 비교를 수행하고, 일치하지 않으면 다음 위치로 이동하여 계속 비교를 수행한다. 브루트 포스 문자열 탐색 알고리즘의 주요 특..
2024.01.15
no image
[Java] 정렬 알고리즘(Sorting Algorithm)
Do it! 자료구조와 함께 배우는 알고리즘 입문[자바편] 을 개인적으로 정리하는 글임을 알립니다. 정렬 정렬은 이름, 학번, 키 등 핵심 항목(key)의 대소 관계에 따라 데이터 집합을 일정한 순서로 나열하는 작업을 말한다. 정렬 알고리즘의 안정성 동일한 키 값을 가진 요소들의 상대적인 순서가 정렬 전과 정렬 후에도 유지되는 정렬 특성을 나타낸다. 안정적인 정렬 알고리즘은 동일한 키 값을 가진 요소들 간의 순서를 보존하는 특성을 갖는다. 위 그림의 왼쪽과 같이 학번, 점수가 학번 순으로 나열되어있다. 이때 점수를 기준으로 오름차순 정렬을 하면 오른쪽 그림과 같다. 점수가 같을 때는 학번이 작은 사람을 앞쪽에 배치한다. 안정된 정렬이란 이렇게 키값이 같은 요소의 순서가 정렬 전후에도 유지되는 것을 말한다..
2024.01.15
no image
[알고리즘] 다익스트라(Dijkstra) 알고리즘
이 글은 이것이 자료구조+알고리즘이다 with C 언어(저자:박상현) 책 내용을 개인적으로 정리하는 글임을 알립니다. 다익스트라 알고리즘의 개념 다익스트라 알고리즘은 여러가지 경로중에 목적지에 도착하기 위해 가장 빠른 경로를 찾아주는 알고리즘이다. 프림 알고리즘이 단순히 간선의 길이를 이용하여 어떤 간선을 먼저 연결할지 결정하는 데 비해 데이크스트라 알고리즘은 경로의 길이를 감안해서 간선을 연결, 데이크스트라 알고리즘의 경우 사이클이 없는 방향성 그래프에 한해서만 사용 가능 다익스트라 알고리즘 동작 방식 ❶ : 각 정점에는 시작점으로부터 자신에게 이르는 경로의 길이를 저장할 곳을 준비하고 각 정점에 대한 경로의 길이를 ∞(무한대)로 초기화 ❷ : 시작 정점의 경로 길이를 0으로 초기화하고(시작 정점에서 ..
2023.12.19
no image
[알고리즘] 최소 신장 트리(Minimum Spanning Tree)
이 글은 이것이 자료구조+알고리즘이다 with C 언어(저자:박상현) 책 내용을 개인적으로 정리하는 글임을 알립니다. 최소 신장 트리 가중치 그래프 가중치 그래프 : 그래프에서 정점과 정점을 잇는 간선을 지나기 위해 가중치라는 새로운 속성을 부여한 그래프 신장 트리(Spanning Tree) 모든 정점을 연결하는 트리 신장 트리는 그래프의 하위 개념 모든 정점을 연결하는 그래프(네트워크 그래프)에서 사이클이 되는 간선을 제거하면 신장 트리가 된다. 따라서 n개의 정점을 정확히 (n-1)개의 간선으로 연결한다. 최소 신장 트리 최소 신장 트리는 최소 가중치 신장 트리라고 부르기도 한다. 최소 신장 트리는 그래프의 모든 정점을 최소 비용으로 연결하는 부분 그래프 또는 트리의 모든 노드를 최소 비용으로 연결하..
2023.12.18
no image
[알고리즘] 그래프 위상 정렬(Topological sorting)
이 글은 이것이 자료구조+알고리즘이다 with C 언어(저자:박상현) 책 내용을 개인적으로 정리하는 글임을 알립니다. 위상 정렬 위상 : 어떤 정점이 다른 정점과의 관계 속에서 가지는 위치 이 말은 그래프 내 서로 인접한 정점 사이의 관계에 위치라는 속성이 존재한다는 뜻이다. 이 위치는 앞/뒤일 수도 있고, 위/아래일 수도 있다. 이 글에서는 앞/뒤 관계라고 가정한다. 앞:간선을 뻗어내는 정점 뒤: 간선을 받아들이는 정점 이 앞/뒤를 차근차근 정렬하는 작업이 위상 정렬이다. 위상 정렬은 순서가 정해져 있는 작업을 차례로 수행해야 할 때, 그 순서를 결정해 주기 위해 사용한다. 위상 정렬은 여러 개의 답이 존재할 수 있다. 위상 정렬의 시간복잡도 V = 정점의 개수, E = 간선의 개수 O(V + E) 위..
2023.12.17
no image
[알고리즘]보이어-무어(Boyer-Moore) 문자열 탐색 알고리즘
보이어 무어 알고리즘은 패턴의 오른쪽부터 비교하여 텍스트 문자를 모두 비교하지 않아도 탐색이 가능한 알고리즘이다. 전체 문자열과 찾고 싶은 문자열(패턴) 비교 시 문자열의 가장 뒷부분 위치를 비교하고, 다르면 일정 길이만큼 이동하여 비교를 계속하는 방법이다. 보이어무어 알고리즘은 두 가지의 이동으로 나뉜다. 나쁜 문자 이동(Bad Character Shift) 착한 접미부 이동(Good Suffix Shift) 두 가지의 이동을 적절히 이용하여 문자열 탐색을 할 때 최고의 시너지가 나온다. 즉, 전체 문자열과 찾고 싶은 문자열을 비교했을 때 불일치가 발생하면 나쁜 문자 이동과 착한 접미부 이동을 모두 검토해서 더 멀리 옮겨가는 이동 방법을 선택하게 된다. Prefix(접두사), Suffix(접미사) Pr..
2023.11.29
no image
[Java]KMP 문자열 탐색 알고리즘
KMP 알고리즘이란 이 알고리즘을 만든 Knuth, Morris, Prett 이렇게 3명의 앞 글자를 하나씩 따서 명명하여 KMP 알고리즘이라고 한다. KMP 문자열 탐색 알고리즘의 시간 복잡도는 O(N+M)이다.(전체 문자열 길이 = N, 찾는 문자열 길이 = M) LPS 배열 계산 O(M) + 매칭 O(N) KMP 알고리즘의 핵심 아이디어 IDX 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Text A B C D A B D A B C D A B E A B C D Pattern A B C D A B E 위 표에서 text와 pattern 모두 0, 1번째 인덱스와 4, 5번째 인덱스의 값이 동일하다는 것을 알 수 있다. 즉, 앞에서 2개 뒤에서 2개가 같다는 것을 알 ..
2023.11.28

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

 

직접 구현

노드

class Node<E>
{
    E data;
    Node<E> next;

    Node(E data)
    {
        this.data = data;
        this.next = null;
    }
}

 

노드 추가

//리스트의 가장 뒷쪽에 데이터 추가
public void add(E data)
{
    Node<E> newNode = new Node<>(data);
    if (head == null) head = newNode;
    else
    {
        Node<E> currentHead = head;
        while (currentHead.next != null) currentHead = currentHead.next;
        currentHead.next = newNode;
    }
}

 

노드 삽입

//리스트의 원하는 인덱스에 데이터 삽입
public void insert(E data, int idx)
{
    if (head == null)
    {
        add(data);
        return;
    }
    Node<E> newNode = new Node<>(data);
    Node<E> currentNode = head;
    if (currentNode != null && idx == 0)
    {
        newNode.next = head;
        head = newNode;
        return;
    }
    for (int i = 0; i < idx - 1; ++i)
    {
        if (currentNode == null)
        {
            System.out.println("Error");
            return;
        }
        currentNode = currentNode.next;
    }
    if (currentNode == null)
    {
        System.out.println("Invalid index");
        return;
    }
    newNode.next = currentNode.next;
    currentNode.next = newNode;
}

 

노드 삭제

//앞쪽의 데이터 삭제
public void delete()
{
    if (head == null) System.out.println("Empty");
    else head = head.next;
}

//특정 데이터의 내용을 포함하는 노드 삭제
public void delete(E data)
{
    if (head == null) System.out.println("Empty");
    else if (head.data.equals(data)) head = head.next;
    else
    {
        Node<E> currentHead = head;
        while (currentHead.next != null && !currentHead.next.data.equals(data))
        {
            currentHead = currentHead.next;
        }
        if (currentHead.next != null) currentHead.next = currentHead.next.next;
        else currentHead.next = null;
    }
}

 

노드 검색

//해당 데이터가 존재하는지 아닌지 확인
public void search(E data)
{
    if (head == null)
    {
        System.out.println("Invalid");
        return;
    }
    else
    {
        Node<E> currentHead = head;
        while (currentHead != null && !currentHead.data.equals(data))
        {
            currentHead = currentHead.next;
        }
        if (currentHead == null)  System.out.println("Invalid");
        else System.out.println("Valid");
    }
}

 

노드 출력

public void printList()
{
    Node<E> currentNode = head;
    if (currentNode == null)
    {
        System.out.println("Empty");
        return;
    }
    while (currentNode != null)
    {
        System.out.print(currentNode.data + " ");
        currentNode = currentNode.next;
    }
    System.out.println();
}

 

전체 소스코드

public class SinglyLinkedList<E>
{
    class Node<E>
    {
        E data;
        Node<E> next;

        Node(E data)
        {
            this.data = data;
            this.next = null;
        }
    }

    private Node<E> head;

    SinglyLinkedList() { this.head = null; }

    public void printList()
    {
        Node<E> currentNode = head;
        if (currentNode == null)
        {
            System.out.println("Empty");
            return;
        }
        while (currentNode != null)
        {
            System.out.print(currentNode.data + " ");
            currentNode = currentNode.next;
        }
        System.out.println();
    }

    //리스트의 가장 뒷쪽에 데이터 추가
    public void add(E data)
    {
        Node<E> newNode = new Node<>(data);
        if (head == null) head = newNode;
        else
        {
            Node<E> currentHead = head;
            while (currentHead.next != null) currentHead = currentHead.next;
            currentHead.next = newNode;
        }
    }

    //리스트의 원하는 인덱스에 데이터 삽입
    public void insert(E data, int idx)
    {
        if (head == null)
        {
            add(data);
            return;
        }
        Node<E> newNode = new Node<>(data);
        Node<E> currentNode = head;
        if (currentNode != null && idx == 0)
        {
            newNode.next = head;
            head = newNode;
            return;
        }
        for (int i = 0; i < idx - 1; ++i)
        {
            if (currentNode == null)
            {
                System.out.println("Error");
                return;
            }
            currentNode = currentNode.next;
        }
        if (currentNode == null)
        {
            System.out.println("Invalid index");
            return;
        }
        newNode.next = currentNode.next;
        currentNode.next = newNode;
    }

    //앞쪽의 데이터 삭제
    public void delete()
    {
        if (head == null) System.out.println("Empty");
        else head = head.next;
    }

    //특정 데이터의 내용을 포함하는 노드 삭제
    public void delete(E data)
    {
        if (head == null) System.out.println("Empty");
        else if (head.data.equals(data)) head = head.next;
        else
        {
            Node<E> currentHead = head;
            while (currentHead.next != null && !currentHead.next.data.equals(data))
            {
                currentHead = currentHead.next;
            }
            if (currentHead.next != null) currentHead.next = currentHead.next.next;
            else currentHead.next = null;
        }
    }

    //해당 데이터가 존재하는지 아닌지 확인
    public void search(E data)
    {
        if (head == null)
        {
            System.out.println("Invalid");
            return;
        }
        else
        {
            Node<E> currentHead = head;
            while (currentHead != null && !currentHead.data.equals(data))
            {
                currentHead = currentHead.next;
            }
            if (currentHead == null)  System.out.println("Invalid");
            else System.out.println("Valid");
        }
    }

    public static void main(String args[])
    {
        SinglyLinkedList sll = new SinglyLinkedList();
        sll.add(1);
        sll.add(2);
        sll.add(3);
        sll.add(4);
        sll.insert(0, 0);
        sll.insert(0, 2);
        sll.insert(0, 4);
        sll.delete();
        sll.delete(1);
        sll.delete(0);
        sll.printList();
    }
}

 

책 코드

책 : Do it! 자료구조와 알고리즘 입문 자바편

 

ArrayLinkedList.java

// 연결 리스트 클래스(배열 커서 버전)

import java.util.Comparator;

public class ArrayLinkedList<E> {

    //--- 노드 ---//
    class Node<E> {
        private E data;          // 데이터
        private int next;        // 리스트의 뒤쪽포인터
        private int dnext;       // 프리 리스트의 뒤쪽포인터

        //--- data와 next를 설정 ---//
        void set(E data, int next) {
            this.data = data;
            this.next = next;
        }
    }

    private Node<E>[] n;        // 리스트 본체
    private int size;           // 리스트 크기(최대 데이터 개수)
    private int max;            // 사용 중인 꼬리 record
    private int head;           // 머리노드
    private int crnt;           // 선택 노드
    private int deleted;        // 프리 리스트의 머리노드
    private static final int NULL = -1;    // 뒤쪽노드가 없음 / 리스트가 가득 참

    //--- 생성자(constructor) ---//
    public ArrayLinkedList(int capacity) {
        head = crnt = max = deleted = NULL;
        try {
            n = new Node[capacity];
            for (int i = 0; i < capacity; i++)
                n[i] = new Node<E>();
            size = capacity;
        }
        catch (OutOfMemoryError e) {        // 배열 생성에 실패
            size = 0;
        }
    }

    //--- 다음에 삽입하는 record의 인덱스를 구함 ---//
    private int getInsertIndex() {
        if (deleted == NULL) {                    // 삭제 record가 존재하지 않음
            if (max < size)
                return ++max;                     // 새 record를 사용
            else
                return NULL;                      // 크기 넘침(over)
        } else {
            int rec = deleted;                    // 프리 리스트에서
            deleted = n[rec].dnext;               // 머리 rec을 꺼냄
            return rec;
        }
    }

    //--- record idx를 프리 리스트에 등록 ---//
    private void deleteIndex(int idx) {
        if (deleted == NULL) {                    // 삭제 record가 존재하지 않음
            deleted = idx;                        // idx를 프리 리스트의
            n[idx].dnext = NULL;                  // 머리에 등록
        } else {
            int rec = deleted;                    // idx를 프리 리스트의
            deleted = idx;                        // 머리에 삽입
            n[rec].dnext = rec;
        }
    }

    //--- 노드를 검색 ---//
    public E search(E obj, Comparator<? super E> c) {
        int ptr = head;                                    // 현재 스캔 중인 노드

        while (ptr != NULL) {
            if (c.compare(obj, n[ptr].data) == 0) {
                crnt = ptr;
                return n[ptr].data;                   // 검색 성공
            }
            ptr = n[ptr].next;                        // 뒤쪽 노드 선택
        }
        return null;                                  // 검색 실패
    }

    //--- 머리노드 삽입 ---//
    public void addFirst(E obj) {
        int ptr = head;                                // 삽입 전의 머리노드
        int rec = getInsertIndex();
        if (rec != NULL) {
            head = crnt = rec;                         // 제 rec 번째 레코드에 삽입
            n[head].set(obj, ptr);
        }
    }

    //--- 꼬리노드 삽입 ---//
    public void addLast(E obj) {
        if (head == NULL)                                // 리스트가 비어있으면
            addFirst(obj);                               // 머리에 삽입
        else {
            int ptr = head;
            while (n[ptr].next != NULL)
                ptr = n[ptr].next;
            int rec = getInsertIndex();
            if (rec != NULL) {                        // 제 rec 번째 레코드에 삽입
                n[ptr].next = crnt = rec;
                n[rec].set(obj, NULL);
            }
        }
    }

    //--- 머리노드 삭제 ---//
    public void removeFirst() {
        if (head != NULL) {                            // 리스트가 비어있지 않으면
            int ptr = n[head].next;
            deleteIndex(head);
            head = crnt = ptr;
        }
    }

    //--- 꼬리노드 삭제 ---//
    public void removeLast() {
        if (head != NULL) {
            if (n[head].next == NULL)            // 노드가 하나만 있으면
                removeFirst();                   // 머리노드 삭제
            else {
                int ptr = head;                  // 스캔 중인 노드
                int pre = head;                  // 스캔 중인 노드의 앞쪽노드

                while (n[ptr].next != NULL) {
                    pre = ptr;
                    ptr = n[ptr].next;
                }
                n[pre].next = NULL;                    // pre는 삭제 뒤의 꼬리 노드
                deleteIndex(pre);
                crnt = pre;
            }
        }
    }

    //--- 레코드 p를 삭제 ---//
    public void remove(int p) {
        if (head != NULL) {
            if (p == head)                                // p가 머리 노드이면
                removeFirst();                            // 머리노드 삭제
            else {
                int ptr = head;

                while (n[ptr].next != p) {
                    ptr = n[ptr].next;
                    if (ptr == NULL) return;    // p가 리스트에 없음
                }
                n[ptr].next = NULL;
                deleteIndex(ptr);
                n[ptr].next = n[p].next;
                crnt = ptr;
            }
        }
    }

    //--- 선택 노드 삭제 ---//
    public void removeCurrentNode() {
        remove(crnt);
    }

    //--- 전체 노드 삭제 ---//
    public void clear() {
        while (head != NULL)                        // 비게 될 때까지
            removeFirst();                          // 머리 노드 삭제
        crnt = NULL;
    }

    //--- 선택 노드를 하나 뒤쪽으로 진행 ---//
    public boolean next() {
        if (crnt == NULL || n[crnt].next == NULL)
            return false;                                    // 나아갈 수 없음
        crnt = n[crnt].next;
        return true;
    }

    //--- 선택 노드 표시 ---//
    public void printCurrentNode() {
        if (crnt == NULL)
            System.out.println("선택 노드가 없습니다.");
        else
            System.out.println(n[crnt].data);
    }

    //--- 전체 노드 표시 ---//
    public void dump() {
        int ptr = head;

        while (ptr != NULL) {
            System.out.println(n[ptr].data);
            ptr = n[ptr].next;
        }
    }
}

 

ArrayLinkedListTester.java

// 선형리스트 클래스 ArrayLinkedList<E>의 사용 예

import java.util.Scanner;
import java.util.Comparator;

class ArrayLinkedListTester {
    static Scanner stdIn = new Scanner(System.in);

    //--- 데이터(회원번호+이름) ---//
    static class Data {
        static final int NO   = 1;        // 번호를 읽어 들일까요?
        static final int NAME = 2;        // 이름을 읽어 들일까요?

        private Integer no;                // 회원번호
        private String  name;              // 이름

        //--- 문자열 표현을 반환 ---//
        public String toString() {
            return "(" + no + ") " + name;
        }

        //--- 데이터를 읽어 들임 ---//
        void scanData(String guide, int sw) {
            System.out.println(guide + "할 데이터를 입력하세요.");

            if ((sw & NO) == NO) {
                System.out.print("번호: ");
                no = stdIn.nextInt();
            }
            if ((sw & NAME) == NAME) {
                System.out.print("이름: ");
                name = stdIn.next();
            }
        }

        //--- 회원번호로 순서를 매기는 comparator  ---//
        public static final Comparator<Data> NO_ORDER =
                                                                                    new NoOrderComparator();

        private static class NoOrderComparator implements Comparator<Data> {
            public int compare(Data d1, Data d2) {
                return (d1.no > d2.no) ? 1 : (d1.no < d2.no) ? -1 : 0;
            }
        }

        //--- 이름으로 순서를 매기는 comparator  ---//
        public static final Comparator<Data> NAME_ORDER =
                                                                                    new NameOrderComparator();

        private static class NameOrderComparator implements Comparator<Data> {
            public int compare(Data d1, Data d2) {
                return d1.name.compareTo(d2.name);
            }
        }
    }

    //--- 메뉴 열거형 ---//
    enum Menu {
        ADD_FIRST(  "머리 노드 삽입"),
        ADD_LAST(   "꼬리 노드 삽입"),
        RMV_FIRST(  "머리 노드 삭제"),
        RMV_LAST(   "꼬리 노드 삭제"),
        RMV_CRNT(   "선택 노드 삭제"),
        CLEAR(      "전체 노드 삭제"),
        SEARCH_NO(  "번호 검색"),
        SEARCH_NAME("이름 검색"),
        NEXT(       "선택 노드 진행"),
        PRINT_CRNT( "선택 노드 표시"),
        DUMP(       "전체 노드 표시"),
        TERMINATE(  "종료");

        private final String message;                // 표시할 문자열

        static Menu MenuAt(int idx) {                // 순서가 idx번째인 열거를 반환
            for (Menu m : Menu.values())
                if (m.ordinal() == idx)
                    return m;
            return null;
        }

        Menu(String string) {                        // 생성자(constructor)
            message = string;
        }

        String getMessage() {                        // 표시할 문자열을 반환
            return message;
        }
    }

    //--- 메뉴 선택 ---//
    static Menu SelectMenu() {
        int key;
        do {
            for (Menu m : Menu.values()) {
                System.out.printf("(%d) %s  ", m.ordinal(), m.getMessage());
                if ((m.ordinal() % 3) == 2 &&
                      m.ordinal() != Menu.TERMINATE.ordinal())
                    System.out.println();
            }
            System.out.print(" : ");
            key = stdIn.nextInt();
        } while (key < Menu.ADD_FIRST.ordinal() || 
                                            key > Menu.TERMINATE.ordinal());
        return Menu.MenuAt(key);
    }

    public static void main(String[] args) {
        Menu menu;                // 메뉴 
        Data data;                // 추가용 데이터 참조
        Data ptr;                 // 검색용 데이터 참조
        Data temp = new Data();   // 읽어 들일 데이터

        ArrayLinkedList<Data> list = new ArrayLinkedList<Data>(100);

        do {
            switch (menu = SelectMenu()) {

             case ADD_FIRST :                           // 머리 노드 삽입
                    data = new Data();
                     data.scanData("머리에 삽입", Data.NO | Data.NAME);
                    list.addFirst(data);
                     break;

             case ADD_LAST :                           // 꼬리 노드 삽입
                    data = new Data();
                     data.scanData("꼬리에 삽입", Data.NO | Data.NAME);
                     list.addLast(data);
                     break;

             case RMV_FIRST :                          // 머리 노드 삭제
                    list.removeFirst();
                    break;

             case RMV_LAST :                           // 꼬리 노드 삭제
                    list.removeLast();
                    break;

             case RMV_CRNT :                           // 선택 노드 삭제
                    list.removeCurrentNode();
                    break;

             case SEARCH_NO :                          // 회원번호 검색
                     temp.scanData("검색", Data.NO);
                    ptr = list.search(temp, Data.NO_ORDER);
                    if (ptr == null)
                        System.out.println("그 번호의 데이터가 없습니다.");
                    else
                        System.out.println("검색 성공 : " + ptr);
                     break;

             case SEARCH_NAME :                       // 이름 검색
                     temp.scanData("검색", Data.NAME);
                    ptr = list.search(temp, Data.NAME_ORDER);
                    if (ptr == null)
                        System.out.println("그 이름의 데이터가 없습니다.");
                    else
                        System.out.println("검색 성공 : " + ptr);
                     break;

             case NEXT :                                   // 선택 노드를 뒤쪽으로 진행
                    list.next();
                     break;

             case PRINT_CRNT :                            // 선택 노드의 데이터를 표시
                    list.printCurrentNode();
                     break;

             case DUMP :                                  // 모든 데이터를 리스트 순서로 표시
                    list.dump();
                     break;

             case CLEAR :                                 // 전체노드 삭제
                    list.clear();
                     break;
            }
        } while (menu != Menu.TERMINATE);
    }
}

 

 

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

 

노드

class Node<E>
{
    E data;
    Node<E> prev;
    Node<E> next;

    Node(E data)
    {
        this.data = data;
        this.prev = null;
        this.next = null;
    }
}

 

노드 추가

//리스트 앞쪽에 추가
public void add(E data)
{
    Node<E> newNode = new Node(data);
    Node<E> currentNode = head;
    if(currentNode == null)
    {
        head = newNode;
        tail = newNode;
    }
    else
    {
        tail.next = newNode;
        newNode.prev = tail;
        tail = newNode;
    }
}

//리스트 뒷쪽에 추가
public void add(E data, boolean back)
{
    if(back == false) add(data);
    else
    {
        Node<E> newNode = new Node(data);
        Node<E> currentNode = tail;
        if(currentNode == null)
        {
            head = newNode;
            tail = newNode;
        }
        else
        {
            head.prev = newNode;
            newNode.prev = null;
            head = newNode;
        }
    }
}

 

노드 삽입

//원하는 인덱스에 노드 삽입
public void insert(E data, int idx)
{
    Node<E> newNode = new Node(data);
    if(head == null) add(data);
    if (head == null || idx == 0)
    {
        newNode.next = head;
        if (head != null) head.prev = newNode;
        head = newNode;
        if (tail == null) tail = newNode;
    }
    else
    {
        Node<E> currentNode = head;
        for(int i = 0; i < idx - 1; ++i)
        {
            if(currentNode == null)
            {
                System.out.println("Error");
                return;
            }
            currentNode = currentNode.next;
        }
        if (currentNode == null)
        {
            System.out.println("Invalid index");
            return;
        }
        newNode.next = currentNode.next;
        if (currentNode.next != null) currentNode.next.prev = newNode;
        newNode.prev = currentNode;
        currentNode.next = newNode;
        if(newNode.next == null) tail = newNode;
    }
}

 

노드 삭제

//노드를 앞 또는 뒷부분 삭제
public void delete(boolean back)
{
    if (head == null) System.out.println("Empty");
    else if(back == false) //앞부분 삭제
    {
        head = head.next;
        if (head != null) head.prev = null;
        else tail = null; // 리스트에 하나의 노드만 있었고 삭제한 경우
    }
    else //뒷부분 삭제
    {
        tail = tail.prev;
        if (tail != null) tail.next = null;
        else head = null; // 리스트에 하나의 노드만 있었고 삭제한 경우
    }
}

//특정 데이터의 내용을 포함하는 노드 삭제
public void delete(E data)
{
    if (head == null) System.out.println("Empty");
    else
    {
        Node<E> currentNode = head;
        while(currentNode.next != null && !currentNode.next.data.equals(data))
        {
            currentNode = currentNode.next;
        }
        if(currentNode.next != null)
        {
            currentNode.next = currentNode.next.next;
            currentNode.next.next.prev = currentNode;
        }
    }
}

 

노드 검색

//해당 데이터가 존재하는지 아닌지 확인
public void search(E data)
{
    if (head == null)
    {
        System.out.println("Invalid");
        return;
    }
    else
    {
        Node<E> currentHead = head;
        while (currentHead != null && !currentHead.data.equals(data))
        {
            currentHead = currentHead.next;
        }
        if (currentHead == null)  System.out.println("Invalid");
        else System.out.println("Valid");
    }
}

 

노드 출력

//리스트를 앞쪽부터 출력
public void printList()
{
    Node<E> currentNode = head;
    while(currentNode != null)
    {
        System.out.print(currentNode.data + " ");
        currentNode = currentNode.next;
    }
    System.out.println();
}

//리스트를 뒷쪽부터 출력
public void printList(boolean back)
{
    if(back == false) printList();
    else
    {
        Node<E> currentNode = tail;
        while(currentNode != null)
        {
            System.out.print(currentNode.data + " ");
            currentNode = currentNode.prev;
        }
        System.out.println();
    }
}

 

전체 코드

public class DoublyLinkedList<E>
{
    class Node<E>
    {
        E data;
        Node<E> prev;
        Node<E> next;

        Node(E data)
        {
            this.data = data;
            this.prev = null;
            this.next = null;
        }
    }

    private Node<E> head;
    private Node<E> tail;
    DoublyLinkedList()
    {
        this.head = null;
        this.tail = null;
    }

    //리스트를 앞쪽부터 출력
    public void printList()
    {
        Node<E> currentNode = head;
        while(currentNode != null)
        {
            System.out.print(currentNode.data + " ");
            currentNode = currentNode.next;
        }
        System.out.println();
    }

    //리스트를 뒷쪽부터 출력
    public void printList(boolean back)
    {
        if(back == false) printList();
        else
        {
            Node<E> currentNode = tail;
            while(currentNode != null)
            {
                System.out.print(currentNode.data + " ");
                currentNode = currentNode.prev;
            }
            System.out.println();
        }
    }

    //리스트 앞쪽에 추가
    public void add(E data)
    {
        Node<E> newNode = new Node(data);
        Node<E> currentNode = head;
        if(currentNode == null)
        {
            head = newNode;
            tail = newNode;
        }
        else
        {
            tail.next = newNode;
            newNode.prev = tail;
            tail = newNode;
        }
    }

    //리스트 뒷쪽에 추가
    public void add(E data, boolean back)
    {
        if(back == false) add(data);
        else
        {
            Node<E> newNode = new Node(data);
            Node<E> currentNode = tail;
            if(currentNode == null)
            {
                head = newNode;
                tail = newNode;
            }
            else
            {
                head.prev = newNode;
                newNode.prev = null;
                head = newNode;
            }
        }
    }

    //원하는 인덱스에 노드 삽입
    public void insert(E data, int idx)
    {
        Node<E> newNode = new Node(data);
        if(head == null) add(data);
        if (head == null || idx == 0)
        {
            newNode.next = head;
            if (head != null) head.prev = newNode;
            head = newNode;
            if (tail == null) tail = newNode;
        }
        else
        {
            Node<E> currentNode = head;
            for(int i = 0; i < idx - 1; ++i)
            {
                if(currentNode == null)
                {
                    System.out.println("Error");
                    return;
                }
                currentNode = currentNode.next;
            }
            if (currentNode == null)
            {
                System.out.println("Invalid index");
                return;
            }
            newNode.next = currentNode.next;
            if (currentNode.next != null) currentNode.next.prev = newNode;
            newNode.prev = currentNode;
            currentNode.next = newNode;
            if(newNode.next == null) tail = newNode;
        }
    }

    //노드를 앞 또는 뒷부분 삭제
    public void delete(boolean back)
    {
        if (head == null) System.out.println("Empty");
        else if(back == false) //앞부분 삭제
        {
            head = head.next;
            if (head != null) head.prev = null;
            else tail = null; // 리스트에 하나의 노드만 있었고 삭제한 경우
        }
        else //뒷부분 삭제
        {
            tail = tail.prev;
            if (tail != null) tail.next = null;
            else head = null; // 리스트에 하나의 노드만 있었고 삭제한 경우
        }
    }

    //특정 데이터의 내용을 포함하는 노드 삭제
    public void delete(E data)
    {
        if (head == null) System.out.println("Empty");
        else
        {
            Node<E> currentNode = head;
            while(currentNode.next != null && !currentNode.next.data.equals(data))
            {
                currentNode = currentNode.next;
            }
            if(currentNode.next != null)
            {
                currentNode.next = currentNode.next.next;
                currentNode.next.next.prev = currentNode;
            }
        }
    }

    //해당 데이터가 존재하는지 아닌지 확인
    public void search(E data)
    {
        if (head == null)
        {
            System.out.println("Invalid");
            return;
        }
        else
        {
            Node<E> currentHead = head;
            while (currentHead != null && !currentHead.data.equals(data))
            {
                currentHead = currentHead.next;
            }
            if (currentHead == null)  System.out.println("Invalid");
            else System.out.println("Valid");
        }
    }

    public static void main(String args[])
    {
        DoublyLinkedList dll = new DoublyLinkedList<>();
        dll.add(1);
        dll.add(2);
        dll.add(3);
        dll.add(4);
        dll.insert(0,0);
        dll.insert(0,2);
        dll.insert(0,4);
        dll.insert(0,6);
        dll.printList();
        dll.delete(false);
        dll.printList();
        dll.delete(true);
        dll.printList();
        dll.search(9);
    }
}

Do it! 자료구조와 함께 배우는 알고리즘 입문[자바편] 을 개인적으로 정리하는 글임을 알립니다.


문자열 탐색

문자열 탐색이란 어떤 문자열 안에 특정 문자열이 들어 있는지를 조사하고, 들어있다면 그 위치를 찾는 것을 말한다.

이 글에서는 검색할 문자열을 패턴이라 하고 문자열 원본을 텍스트라고 하겠다.

문자열 탐색 알고리즘에는 아래와 같은 방법이 있다.

  • 브루트 포스
  • KMP
  • 보이어-무어

 

브루트 포스

브루트 포스(Brute Force)는 가장 간단하고 직접적인 문자열 탐색 방법 중 하나이다.

이 방법은 텍스트에서 패턴을 한 글자씩 비교하면서 탐색하는 단순한 방법이다.

각 가능한 위치에서 비교를 수행하고, 일치하지 않으면 다음 위치로 이동하여 계속 비교를 수행한다.

브루트 포스 문자열 탐색 알고리즘의 주요 특징은 다음과 같다

  1. 비교적 간단한 구현: 브루트 포스는 간단하게 구현할 수 있는 장점이 있다.
    각 위치에서 패턴의 길이만큼의 문자를 비교하면서 이동한다.
  2. 모든 가능한 위치에서 비교: 브루트 포스는 텍스트와 패턴을 모든 가능한 위치에서 비교하기 때문에, 패턴이 텍스트에서 나타날 수 있는 모든 위치를 찾을 수 있다.
  3. 시간 복잡도: 최악의 경우 시간 복잡도는 O(n * m)이며, 여기서 n은 텍스트의 길이, m은 패턴의 길이이다.

브루트 포스는 간단하고 직관적이지만, 텍스트와 패턴의 길이가 큰 경우에는 비효율적일 수 있다.

따라서 브루트 포스는 간단한 문제나 작은 데이터셋에서 사용될 수 있으며, 더 효율적인 문자열 탐색 알고리즘들이 선호되는 경우도 있다.

public class Main
{
    public static int bruteForceSearch(String text, String pattern)
    {
        for (int i = 0; i <= text.length() - pattern.length(); i++)
        {
            int j;
            for (j = 0; j < pattern.length(); j++)
            {
                if (text.charAt(i + j) != pattern.charAt(j)) break;
            }
            if (j == pattern.length())  return i; // 패턴이 발견된 위치 반환
        }
        return -1; // 패턴이 발견되지 않음
    }

    public static void main(String[] args)
    {
        String text = "Hello, World!";
        String pattern = "World";

        int result = bruteForceSearch(text, pattern);

        if (result != -1) System.out.println("패턴이 발견된 위치: " + result);
        else System.out.println("패턴이 발견되지 않음");
    }
}

 

String.indexOf(), String.lastIndexOf() 와 String.contain()
Java에서 문자열 내에서 특정 문자열 또는 문자의 인덱스를 찾는 데 사용되는 메서드이다.

1. String.indexOf(String str) 메서드
- 주어진 문자열이 처음으로 등장하는 위치의 인덱스를 반환한다.
- 만약 주어진 문자열이 포함되어 있지 않다면 -1을 반환한다.
public class IndexOfExample {
    public static void main(String[] args) {
        String text = "Hello, World! Hello";
        String pattern = "World";

        int firstIndex = text.indexOf(pattern);
        System.out.println("처음으로 발견된 위치: " + firstIndex);

        // 두 번째로 발견된 위치를 찾기
        int secondIndex = text.indexOf(pattern, firstIndex + 1);
        System.out.println("두 번째로 발견된 위치: " + secondIndex);
    }
}​


2. String.lastIndexOf(String str) 메서드
- 주어진 문자열이 마지막으로 등장하는 위치의 인덱스를 반환한다.
- 만약 주어진 문자열이 포함되어 있지 않다면 -1을 반환한다.

public class LastIndexOfExample {
    public static void main(String[] args) {
        String text = "Hello, World! Hello";
        String pattern = "Hello";

        int lastIndex = text.lastIndexOf(pattern);
        System.out.println("마지막으로 발견된 위치: " + lastIndex);

        // 두 번째로 마지막으로 발견된 위치를 찾기
        int secondLastIndex = text.lastIndexOf(pattern, lastIndex - 1);
        System.out.println("두 번째로 마지막으로 발견된 위치: " + secondLastIndex);
    }
}


3. String.contain(String str) 메서드
- 이 메서드는 특정 문자열이 다른 문자열에 포함되어 있는지 여부를 확인한다.
- contains 메서드는 boolean 값을 반환하며, 포함되어 있다면 true, 그렇지 않으면 false를 반환한다.

public class ContainsExample {
    public static void main(String[] args) {
        String text = "Hello, World!";

        // "World" 문자열이 포함되어 있는지 확인
        boolean containsWorld = text.contains("World");
        System.out.println("Contains 'World': " + containsWorld);

        // "Java" 문자열이 포함되어 있는지 확인
        boolean containsJava = text.contains("Java");
        System.out.println("Contains 'Java': " + containsJava);
    }
}

 

 

KMP

브루트 포스 방법은 일치하지 않는 문자를 만난 단계에서 그때까지 검사한 결과를 버리고 패턴을 텍스트의 다음 칸으로 옮긴 다음 패턴의 첫 번째 문자부터 다시 검사한다.

하지만 KMP 알고리즘은 검사한 결과를 버리지 않고 이를 효과적으로 활용하는 알고리즘이다.

이 알고리즘의 시간 복잡도는 O(n)이다.

주로 파일에서 순서대로 읽어들이면서 검색하는 경우 사용한다.

반면 처리가 복잡하다는 점과 패턴 안에 반복하는 요소가 없으면 효율이 떨어진다는 단점이 있다.

좀 더 자세한 설명은 아래의 포스트를 참고해주세요

2023.11.28 - [자료구조 & 알고리즘/알고리즘] - [Java]KMP 문자열 탐색 알고리즘

 

[Java]KMP 문자열 탐색 알고리즘

KMP 알고리즘이란 이 알고리즘을 만든 Knuth, Morris, Prett 이렇게 3명의 앞 글자를 하나씩 따서 명명하여 KMP 알고리즘이라고 한다. KMP 문자열 탐색 알고리즘의 시간 복잡도는 O(N+M)이다.(전체 문자열

rebugs.tistory.com

 

아래의 그림과 같이 텍스트, 패턴의 1번째 문자부터 순서대로 검사를 한다.

텍스트 1번째 문자 Z는 패턴에 없으므로 바로 일치하지 않는다고 판단한다.

 

이제 패턴을 1킨 뒤로 옮긴다. 이때 패턴의 처음부터 순서대로 검사하면 패턴의 마지막 문자 D가 텍스트의 X와 일치하지 않는다.

여기서 텍스트의 주황색 문자 AB와 패턴의 AB가 일치하고 있는 것에 주목해야한다.

이 부분을 이미 검사를 마친 부분으로 보면, 텍스트의 X 다음 문자부터 패턴의 CABD가 일치하는지만 검사하면 된다.

 

그래서 패턴을 아래와 같이 AB가 겹치도록 한 번에(3칸) 이동시키고 3번째 문자인 C부터 검사하면 된다.

 

이와 같이 KMP 알고리즘은 텍스트와 패턴 사이에 겹치는 부분을 찾아내 검사를 다시 시작할 위치를 구하여 패턴을 한번에 많이 옮기는 알고리즘이다.

하지만 패턴을 옮길 때마다 몇 번째 문자부터 다시 검색을 시작할지 계산해야 한다면 높은 효율을 기대할 수 없다.

그래서 몇 번째 문자부터 다시 검색할지에 대한 값을 미리 표로 만들어 이 문제를 해결한다.

 

표를 작성하려면 패턴 안에서 중복되는 문자열을 찾아야 한다.

패턴 안에서 중복되는 문자열을 찾기 위해 패턴끼리 겹쳐 놓고 생각해보자.

텍스트와 패턴의 1번째 문자가 서로 다른 경우 패턴을 1칸 뒤로 옮기고 패턴의 1번째 문자부터 다시 검사해야 하는 것은 분명하므로 패턴의 2번째 문자부터 생각한다.

패턴 "ABCABD"를 두 줄로 겹쳐 놓고 아랫줄을 1칸 뒤로 옮긴다.

아래의 그림을 보면 주황색 부분이 겹치지 않으므로 패턴을 다시 1칸 옮길 경우 1번째 문자부터 검사를 다시 시작해야 함을 알 수 있다.

그러므로 2번째 문자(B)의 다시 시작하는 값은 0이다.

이는 패턴의 1번째 문자(인덱스 0)부터 검사를 다시 시작한다는 의미이다.

다시 패턴을 1칸 뒤로 옮긴다.

문자가 일치하지 않으므로 표에서 3번째 문자(C)의 값을 0으로 한다.

다시 패턴을 1칸 뒤로 옮기면 "AB"가 일치한다.

여기서 아래와 같은 사실을 알 수 있다.

따라서 표에서 4번째와 5번째 문자의 경우 다시 시작하는 값을 1, 2로 한다.

 

이어서 패턴을 2칸 뒤로 옮기면 문자가 일치하지 않는다.

표에서 패턴의 마지막문자 'D'의 값을 0으로 한다.

이제 표 만들기가 끝이 났다.

public class Main
{
    public static int[] makeTable(String pattern)
    {
        int[] failure = new int[pattern.length()];

        int j = 0;
        for (int i = 1; i < pattern.length(); i++)
        {
            while (j > 0 && pattern.charAt(i) != pattern.charAt(j)) j = failure[j - 1];
            if (pattern.charAt(i) == pattern.charAt(j))
            {
                failure[i] = j + 1;
                j++;
            }
        }
        return failure;
    }

    public static int kmpSearch(String text, String pattern)
    {
        int[] failure = makeTable(pattern);

        int i = 0, j = 0;
        while (i < text.length())
        {
            if (text.charAt(i) == pattern.charAt(j))
            {
                if (j == pattern.length() - 1) return i - j;  // 패턴이 발견된 위치 반환
                i++;
                j++;
            }
            else if (j > 0)  j = failure[j - 1];
            else i++;
        }

        return -1;  // 패턴이 발견되지 않음
    }

    public static void main(String[] args) {
        String text = "ABABDABACDABABCABAB";
        String pattern = "CD";

        int result = kmpSearch(text, pattern);

        if (result != -1) System.out.println("패턴이 발견된 위치: " + result);
        else System.out.println("패턴이 발견되지 않음");
    }
}

 

보이어 무어

보이어 무어 알고리즘은 KMP 알고리즘보다 이론과 실제 모두 더 우수한 알고리즘이다.

이 알고리즘은 패턴의 마지막 문자부터 앞쪽으로 검사를 진행하면서 일치하지 않는 문자가 있으면 미리 준비한 표에 따라 패턴을 옮길 크기를 정한다.

아래의 그림처럼 비교 범위의 텍스트에 패턴이 없는 문자가 있다면 그 위치까지 건너뛸 수 있다.

 

그러므로 a~d까지의 비교를 생략하고 패턴을 단숨에 4칸 뒤로 옮겨 아래와 같은 위치까지 건너 뛸 수 있다.

 

패턴의 A는 텍스트의 Z와 다르다.

그리고 Z는 패턴에 없는 문자이다. 따라서 패턴을 한꺼번에 3칸 옮겨도 된다.

 

패턴의 길이를 n이라 하면, 텍스트에서 패턴에 들어 있지 않은 문자를 만날 경우 패턴 자체를 n만큼 옮기는 것이 아니다.

 (인덱스 4에서 n만큼 옮기는 것이 아니라는 뜻)

현재 검사하고 있는 텍스트 안의 문자 위치(인덱스 6)로부터 패턴이 n만큼 멀어지도록 패턴을 옮기는 것이다.

패턴의 길이는 4이므로 인덱스 6에서 4를 더한 10에서 다시 비교를 시작한다.

이때 문자 A는 패턴의 1, 3번째 인덱스에 들어있다.

이런 경우 [b]와 같이 패턴의 뒤쪽에 위치한 A가 텍스트와 겹치도록 패턴을 1칸만 옮긴다. ( 4 - 2 - 1  = 1)

이때 [d]처럼 패턴의 앞쪽에 위치한 A와 겹치도록 3칸을 옮기면 안된다.

[b]와 같이 한 칸만 옮기면 아래와 같은 결과가 나타난다.

보이어 무어 알고리즘에서도 각각의 문자를 만났을 때 패턴을 옮길 크기를 알려주는 표를 미리 만들어 두어야 한다.

패턴 문자열의 길이가 n일 때 옮긴 크기는 아래와 같은 방법으로 결정된다.

 

패턴에 들어 있지 않은 문자를 만난 경우

  1. 패턴을 옮길 크기는 n이다. 따라서 비교하고 있는 텍스트의 인덱스로부터 n만큼 이동한다.

 

패턴에 들어 있는 문자를 만난 경우

  1. 해당 문자의 마지막 인덱스가 k이면 패턴을 옮길 크기는 n - k - 1이다.
    해당 문자가 패턴의 두 곳에 들어있으면 마지막 인덱스를 선택하여 패턴을 옮긴다.
  2. 패턴의 마지막 문자가 패턴 안에 중복해서 들어 있지 않은 경우(예를 들어 ABAC의 C는 패턴 안에 1개만 있다)
    패턴의 옮길 크기를 n으로 한다. 텍스트에서 이런 문자를 만날 경우 이동하지 않고 바로 앞 문자를 비교한다.

 

위와 같은 설명은 다른 관점에서 보이어 무어 알고리즘을 설명하는 느낌을 받았다.

일반적인 설명은 아래의 포스팅을 참고하면 좋을 것 같다.

2023.11.29 - [자료구조 & 알고리즘/알고리즘] - [알고리즘]보이어-무어(Boyer-Moore) 문자열 탐색 알고리즘

 

[알고리즘]보이어-무어(Boyer-Moore) 문자열 탐색 알고리즘

보이어 무어 알고리즘은 패턴의 오른쪽부터 비교하여 텍스트 문자를 모두 비교하지 않아도 탐색이 가능한 알고리즘이다. 전체 문자열과 찾고 싶은 문자열(패턴) 비교 시 문자열의 가장 뒷부분

rebugs.tistory.com

public class Main
{
    static int bmMatch(String txt, String pat)
    {
        int pt;                           // txt를 따라가는 커서
        int pp;                           // pat를 따라가는 커서
        int txtLen = txt.length();        // txt의 문자 개수
        int patLen = pat.length();        // pat의 문자 개수
        int[] skip = new int[Character.MAX_VALUE + 1];    // 건너뛰기 표(skip 테이블)

        // skip 테이블 작성
        for (pt = 0; pt <= Character.MAX_VALUE; pt++) skip[pt] = patLen;
        for (pt = 0; pt < patLen - 1; pt++) skip[pat.charAt(pt)] = patLen - pt - 1;  // pt == patLen - 1
        // 검색
        while (pt < txtLen)
        {
            pp = patLen - 1;                         // pat의 마지막 문자에 주목

            while (txt.charAt(pt) == pat.charAt(pp))
            {
                if (pp == 0) return pt;                  // 검색 성공
                pp--;
                pt--;
            }
            pt += (skip[txt.charAt(pt)] > patLen - pp) ? skip[txt.charAt(pt)] : patLen - pp;
        }
        return -1;                             // 검색 실패
    }

    public static void main(String[] args) {

        String s1 = "ABASDASD";                   // 텍스트용 문자열
        String s2 = "ASD";                    // 패턴용 문자열

        int idx = bmMatch(s1, s2);                    // 문자열 s1에서 문자열 s2를 BM법으로 검색

        if (idx == -1) System.out.println(-1);
        else  System.out.println(idx);
    }
}

Do it! 자료구조와 함께 배우는 알고리즘 입문[자바편] 을 개인적으로 정리하는 글임을 알립니다.


정렬

정렬은 이름, 학번, 키 등 핵심 항목(key)의 대소 관계에 따라 데이터 집합을 일정한 순서로 나열하는 작업을 말한다.

 

정렬 알고리즘의 안정성
동일한 키 값을 가진 요소들의 상대적인 순서가 정렬 전과 정렬 후에도 유지되는 정렬 특성을 나타낸다.
안정적인 정렬 알고리즘은 동일한 키 값을 가진 요소들 간의 순서를 보존하는 특성을 갖는다.

위 그림의 왼쪽과 같이 학번, 점수가 학번 순으로 나열되어있다.
이때 점수를 기준으로 오름차순 정렬을 하면 오른쪽 그림과 같다.
점수가 같을 때는 학번이 작은 사람을 앞쪽에 배치한다.
안정된 정렬이란 이렇게 키값이 같은 요소의 순서가 정렬 전후에도 유지되는 것을 말한다.
안정되지 않은 알고리즘은 점수가 같을 때 반드시 학번 순서대로 정렬되지는 않는다.


안정적인 정렬 알고리즘의 예로는 버블 정렬, 삽입 정렬, 병합 정렬이 있다.
반면에 선택 정렬과 퀵 정렬은 일반적으로 안정적이지 않는다.

 

내부 정렬과 외부 정렬
하나의 배열에서 작업할 수 있을 때에는 내부정렬을 사용하고, 하나의 배열에서 작업할 수 없을 때에는 외부 정렬을 사용한다.

-내부 정렬 : 정렬할 모든 데이터를 하나의 배열에 저장할 수 있을 때에 사용하는 알고리즘
-외부 정렬 : 정렬할 데이터가 너무 많아서 하나의 배열에 저장할 수 없을 때에 사용하는 알고리즘

 

버블 정렬(Bubble Sort)

버블 정렬(Bubble Sort)은 간단하지만 비효율적인 정렬 알고리즘 중 하나이다.

이 알고리즘은 인접한 두 요소를 비교하여 순서가 맞지 않은 경우에 교환하는 방식으로 동작한다.

이 과정을 배열의 끝까지 반복하면서 정렬을 수행한다.

알고리즘의 이름은 정렬 과정에서 작은 요소가 배열의 뒤로 "거품처럼" 올라가는 듯한 모습에서 유래되었다.

버블 정렬의 기본 아이디어는 다음과 같다.

  1. 배열의 첫 번째 요소부터 마지막 요소까지 반복
  2. 현재 요소와 다음 요소를 비교하여 순서가 맞지 않으면 두 요소를 교환
  3. 배열의 끝까지 도달할 때까지 1과 2의 과정을 반복
  4. 1회전이 끝나면 가장 큰 요소가 배열의 마지막 자리로 이동
  5. 배열의 마지막 요소는 정렬이 완료된 것으로 간주하고, 다시 처음부터 반복
  6. 정렬이 완료될 때까지 반복

https://javaplant.tistory.com/16

이 알고리즘의 시간 복잡도는 최악과 평균 모두 O(n^2)이다.

최선의 경우에도 O(n)의 시간이 걸릴 수 있다.

따라서 대부분의 실제 상황에서는 효율적인 정렬 알고리즘들이 사용되는 편이다.

버블 알고리즘은 안정된 정렬 알고리즘이다.

public class Main {
    static void bubbleSort(int[] arr)
    {
        for (int i = 0; i < arr.length - 1; i++)
        {
            for (int j = 0; j < arr.length - i - 1; j++)
            {
                if (arr[j] > arr[j + 1])
                {
                    // 두 요소 교환
                    int temp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = temp;
                }
            }
        }
    }

    static void printArray(int[] arr)
    {
        for (int i : arr) System.out.print(i + " ");
        System.out.println();
    }

    public static void main(String[] args) {
        int[] myArray = {64, 34, 25, 12, 22, 11, 90};

        System.out.println("정렬 전 배열:");
        printArray(myArray);

        // 버블 정렬 수행
        bubbleSort(myArray);

        System.out.println("\n정렬 후 배열:");
        printArray(myArray);
    }
}

 

단순 선택 정렬(Simple Selection Sort)

단순 선택 정렬(Simple Selection Sort)은 배열을 정렬하는 간단하면서도 비효율적인 정렬 알고리즘 중 하나이다.

이 알고리즘은 선택 정렬(Selection Sort)의 한 형태로, 배열에서 가장 작은 원소를 찾아 첫 번째 위치에 배치한 후, 그 다음으로 작은 원소를 찾아 두 번째 위치에 배치하는 과정을 반복하여 정렬을 수행한다.

단순 선택 정렬의 기본 아이디어는 다음과 같다.

  1. 배열의 처음부터 끝까지를 반복하면서 최소값을 찾는다.
  2. 최소값을 현재 검사 중인 요소와 교환.
  3. 다음 위치로 이동하여 나머지 부분에 대해서 위의 과정을 반복.

이 과정을 전체 배열에 대해 반복하면서 정렬을 완료.

단순 선택 정렬의 시간 복잡도는 항상 O(n^2)이다.

알고리즘은 비교 연산을 통해 최소값을 찾고, 교환을 통해 정렬을 진행하므로 비효율적인 정렬 방법 중 하나이다.

단순 선택 정렬은 일반적으로 안정적인 정렬 알고리즘은 아니다.

public class Main
{
    public static void main(String[] args)
    {
        int[] myArray = {64, 25, 12, 22, 11};

        System.out.println("정렬 전 배열:");
        printArray(myArray);

        // 단순 선택 정렬 수행
        selectionSort(myArray);

        System.out.println("\n정렬 후 배열:");
        printArray(myArray);
    }

    static void selectionSort(int[] arr)
    {
        for (int i = 0; i < arr.length - 1; i++)
        {
            // 최소값의 인덱스를 찾기
            int minIndex = i;
            for (int j = i+1; j < arr.length; j++)
            {
                if (arr[j] < arr[minIndex]) minIndex = j;
            }

            // 최소값과 현재 요소 교환
            int temp = arr[minIndex];
            arr[minIndex] = arr[i];
            arr[i] = temp;
        }
    }

    static void printArray(int[] arr)
    {
        for (int i : arr) System.out.print(i + " ");
        System.out.println();
    }
}

 

단순 삽입 정렬(Simple Insertion Sort)

단순 삽입 정렬(Simple Insertion Sort)은 배열을 정렬하는 간단하면서도 효율적인 정렬 알고리즘 중 하나이다.

이 알고리즘은 현재 위치에서 그보다 앞에 있는 원소들과 비교하여 자신이 들어갈 위치를 찾아 삽입하는 방식으로 동작한다.

배열의 각 요소를 이미 정렬된 부분과 비교하면서 적절한 위치에 삽입하면서 전체 배열을 정렬한다.

단순 삽입 정렬의 기본 아이디어는 다음과 같다.

  1. 배열의 두 번째 요소부터 시작하여 현재 위치에서 그보다 앞에 있는 원소들과 비교.
  2. 현재 위치의 원소가 더 작은 원소를 만날 때까지 계속하여 비교하며, 자신이 들어갈 위치를 찾는다.
  3. 찾은 적절한 위치에 현재 위치의 원소를 삽입.
  4. 배열의 모든 요소에 대해 위의 과정을 반복하여 정렬을 완료.

단순 삽입 정렬의 시간 복잡도는 최선의 경우에는 O(n), 평균 및 최악의 경우에는 O(n^2)이다.

이는 배열이 이미 정렬되어 있는 경우에는 삽입 과정이 매우 효율적으로 이뤄지기 때문이다.

단순 삽입 정렬은 안정적인 정렬 알고리즘이다.

public class Main
{
    public static void main(String[] args)
    {
        int[] myArray = {64, 25, 12, 22, 11};

        System.out.println("정렬 전 배열:");
        printArray(myArray);

        // 단순 삽입 정렬 수행
        insertionSort(myArray);

        System.out.println("\n정렬 후 배열:");
        printArray(myArray);
    }

    static void insertionSort(int[] arr)
    {
        for (int i = 1; i < arr.length; i++) 
        {
            int key = arr[i];
            int j = i - 1;
            // key보다 큰 원소들을 오른쪽으로 이동
            while (j >= 0 && arr[j] > key)
            {
                arr[j + 1] = arr[j];
                j = j - 1;
            }
            // key를 적절한 위치에 삽입
            arr[j + 1] = key;
        }
    }

    static void printArray(int[] arr)
    {
        for (int i : arr) System.out.print(i + " ");
        System.out.println();
    }
}

 

셸 정렬(Shell Sort)

셸 정렬(Shell Sort)은 단순 삽입 정렬의 장점을 살리고 단점을 보완하여 좀 더 빠르게 정렬하는 알고리즘이다.

단순 삽입 정렬의 장점과 단점
아래의 배열에서 단순 삽입 정렬을 수행한다고 가정해보자.

2, 3, 4, 5 순서로 선택하여 정렬한다.
여기까지는 이미 정렬이 되어 있는 상태이기 때문에 요소를 이동(값의 대입)하지 않는다.
그러므로 5까지는 빨리 정렬할 수 있다.
그러나 0을 삽입하려면, 아래와 같이 총 6회에 걸쳐 요소를 이동(대입)해야 한다.
장점 : 정렬이 되었거나 또는 그 상태에 가까우면 정렬 속도가 아주 빠르다.
단점 : 삽입할 곳이 멀리 떨어지면 이동(대입)하는 횟수가 많다.

이 알고리즘은 삽입 정렬의 장점을 살리면서 요소들 간의 더 멀리 떨어진 요소들도 비교하여 정렬하려는 시도에서 비롯되었다.

셸 정렬은 다음과 같은 방식으로 동작한다.

  1. 먼저 배열을 일정한 간격(셸 정렬에서는 "간격" 또는 "h"라고 함)에 따라 여러 부분 배열로 나눈다.
  2. 각 부분 배열에 대해 삽입 정렬을 수행한다. 이때 간격에 따라 떨어진 요소들끼리 삽입 정렬을 수행한다.
  3. 간격을 줄여가면서 위의 과정을 반복한다.
  4. 간격이 1이 될 때까지 반복. 이때는 일반적인 삽입 정렬이 수행된다.

간격의 선택은 여러 방식이 있으며, 셸 정렬의 성능에 영향을 미친다.

일반적으로는 간격을 반으로 나누어가는 방식이나 Hibbard 간격 등이 사용된다.

이 글에서는 간격을 반으로 나누어가는 방식을 다룬다.

위의 그림은 요솟수가 8개이기 때문에 요솟수의 절반인 4를 첫 번째 간격으로 정한 예제이다.

->이 때 4개의 그룹({8, 7}, {1, 6}, {4, 3}, {2, 5})이 생성되고 각 그룹별로 정렬한다.(4-정렬)

두 번째 간격은 이전의 간격의 절반인 2로 정한다.

->이 때 2개의 그룹({7, 3, 8, 4}, {1, 2, 6, 5})이 생성되고 각 그룹별로 정렬한다.(2-정렬)

이렇게 해서 얻은 배열은 좀 더 정렬된 상태에 가까워진다.

 

세 번째 간격은 이전의 간격의 절반인 1로 정한다.

마지막으로 간격이 1인 정렬을 적용하면 정렬을 마치게 된다.(1-정렬)

셸 정렬 과정에서 수행하는 각각의 정렬은 h-정렬이라고 한다.

아래의 그림은 h값을 4, 2, 1로 감소시키면서 7회로 정렬을 마치게 된다.

셸 정렬은 비교적 간단하면서도 효과적인 정렬 알고리즘이지만, 정확한 최적 간격을 찾는 것이 어려우며 여러 실험적인 연구가 진행되어왔다.

평균 시간 복잡도는 O(n log^2 n)이지만 최악의 경우에도 O(n^2)으로 다소 비효율적일 수 있다.

셸 정렬은 일반적으로 안정적이지 않다.

public class Main
{
    public static void main(String[] args)
    {
        int[] myArray = {64, 25, 12, 22, 11};

        System.out.println("정렬 전 배열:");
        printArray(myArray);

        // 셸 정렬 수행
        shellSort(myArray);

        System.out.println("\n정렬 후 배열:");
        printArray(myArray);
    }

    static void shellSort(int[] arr)
    {
        // 간격(h) 초기화
        for (int gap = arr.length / 2; gap > 0; gap /= 2)
        {
            // 각 부분 배열에 대해 삽입 정렬 수행
            for (int i = gap; i < arr.length; i++)
            {
                int temp = arr[i];
                int j;
                for (j = i; j >= gap && arr[j - gap] > temp; j -= gap) arr[j] = arr[j - gap];
                arr[j] = temp;
            }
        }
    }

    static void printArray(int[] arr) {
        for (int i : arr) System.out.print(i + " ");
        System.out.println();
    }
}

 

퀵 정렬(Quick Sort)

퀵 정렬(Quick Sort)은 불안정하면서도 효율적인 정렬 알고리즘 중 하나이다.

퀵 정렬은 분할 정복(Divide and Conquer) 방법을 기반으로 하며, 배열을 기준 원소(pivot)를 기준으로 두 개의 부분 배열로 분할하고 각 부분 배열을 재귀적으로 정렬하는 방식으로 동작한다.

퀵 정렬의 기본 동작은 다음과 같다.

  1. 피벗 선택: 배열에서 하나의 원소를 피벗으로 선택.
  2. 파티션(Partitioning): 피벗을 기준으로 작은 요소는 피벗의 왼쪽에, 큰 요소는 피벗의 오른쪽에 배치.
    이 과정을 피벗을 중심으로 파티션(분할)이라고 한다.
  3. 재귀적으로 정렬: 피벗을 기준으로 파티션된 두 부분 배열에 대해 재귀적으로 퀵 정렬을 수행.
  4. 정렬된 부분 배열 병합: 재귀 호출을 통해 얻은 정렬된 부분 배열을 결합하여 전체 배열을 정렬.

 

배열을 두 그룹으로 나누는 과정은 아래의 그림과 같다.

피벗 이하의 값은 왼쪽으로 이동하고, 피벗 이상의 값은 오른쪽으로 이동한다.

 

이러한 방식으로 왼쪽 그룹과 오른쪽 그룹에 대해서 재귀적으로 퀵 정렬을 수행하게 된다.

 

퀵 정렬에서 배열을 나누고 정렬하는 과정을 그림으로 표현하면 아래와 같다.

 

퀵 정렬은 평균적으로 매우 빠른 성능을 보이며, 평균 시간 복잡도는 O(n log n)이다.

그러나 최악의 경우 시간 복잡도는 O(n^2)이 될 수 있다.

이러한 최악의 경우를 피하기 위해 여러 피벗 선택 전략이나 최적화 기법이 사용될 수 있다.

맨 처음에 언급했듯이 퀵 정렬은 불안정한 정렬 알고리즘이다.

참고로 Arrays.sort() 메소드는 배열에서만 사용 가능하며, 듀얼 피벗 퀵정렬이다.

public class Main
{
    public static void main(String[] args)
    {
        int[] myArray = {64, 25, 12, 22, 11};

        System.out.println("정렬 전 배열:");
        printArray(myArray);

        // 퀵 정렬 수행
        quickSort(myArray, 0, myArray.length - 1);

        System.out.println("\n정렬 후 배열:");
        printArray(myArray);
    }

    static void quickSort(int[] arr, int low, int high)
    {
        if (low < high)
        {
            // 피벗을 기준으로 파티션
            int pi = partition(arr, low, high);

            // 피벗을 제외한 왼쪽 부분 배열 정렬
            quickSort(arr, low, pi - 1);

            // 피벗을 제외한 오른쪽 부분 배열 정렬
            quickSort(arr, pi + 1, high);
        }
    }

    static int partition(int[] arr, int low, int high)
    {
        int pivot = arr[high];
        int i = low - 1;

        for (int j = low; j < high; j++)
        {
            if (arr[j] < pivot)
            {
                i++;
                // 현재 요소를 피벗의 왼쪽으로 이동
                int temp = arr[i];
                arr[i] = arr[j];
                arr[j] = temp;
            }
        }

        // 피벗을 올바른 위치로 이동
        int temp = arr[i + 1];
        arr[i + 1] = arr[high];
        arr[high] = temp;

        return i + 1;
    }

    static void printArray(int[] arr)
    {
        for (int i : arr) System.out.print(i + " ");
        System.out.println();
    }
}

 

병합 정렬(Merge Sort)

병합 정렬(Merge Sort)은 분할 정복(Divide and Conquer) 방법을 사용하여 배열을 정렬하는 효율적인 알고리즘 중 하나이다.

이 알고리즘은 배열을 반으로 나눈 후 각각을 재귀적으로 정렬하고, 정렬된 부분 배열을 합치는 방식으로 동작한다.

병합 정렬의 주요 단계는 다음과 같다.

  1. 분할(Divide): 정렬할 배열을 반으로 나눈다. 이 때 중간 지점을 찾아 배열을 두 부분으로 나누게 된다.
  2. 정복(Conquer): 각 부분 배열에 대해 재귀적으로 병합 정렬을 수행한다.
  3. 병합(Merge): 정렬된 부분 배열을 합쳐서 최종적으로 정렬된 배열을 만든다.

 

병합 정렬을 좀 더 전체적인 과정으로 나타내면 아래의 그림과 같다.

https://gmlwjd9405.github.io/2018/05/08/algorithm-merge-sort.html
https://gmlwjd9405.github.io/2018/05/08/algorithm-merge-sort.html

병합 정렬은 안정적이며, 항상 일정한 시간 복잡도를 보장한다.

최악, 평균, 최선의 경우 모두 O(n log n)의 시간 복잡도를 가지고 있다.

그러나 추가적인 메모리 공간이 필요하다는 단점이 있다.

public class Main
{
    public static void main(String[] args)
    {
        int[] myArray = {64, 25, 12, 22, 11};

        System.out.println("정렬 전 배열:");
        printArray(myArray);

        // 병합 정렬 수행
        mergeSort(myArray, 0, myArray.length - 1);

        System.out.println("\n정렬 후 배열:");
        printArray(myArray);
    }

    static void mergeSort(int[] arr, int left, int right)
    {
        if (left < right)
        {
            // 중간 지점 계산
            int mid = (left + right) / 2;

            // 왼쪽 부분 배열 정렬
            mergeSort(arr, left, mid);

            // 오른쪽 부분 배열 정렬
            mergeSort(arr, mid + 1, right);

            // 정렬된 부분 배열을 병합
            merge(arr, left, mid, right);
        }
    }

    static void merge(int[] arr, int left, int mid, int right)
    {
        int n1 = mid - left + 1;
        int n2 = right - mid;

        // 임시 배열 생성
        int[] leftArray = new int[n1];
        int[] rightArray = new int[n2];

        // 임시 배열에 데이터 복사
        for (int i = 0; i < n1; i++) leftArray[i] = arr[left + i];
        for (int j = 0; j < n2; j++) rightArray[j] = arr[mid + 1 + j];

        // 두 부분 배열을 합치기
        int i = 0, j = 0, k = left;
        while (i < n1 && j < n2)
        {
            if (leftArray[i] <= rightArray[j])
            {
                arr[k] = leftArray[i];
                i++;
            }
            else
            {
                arr[k] = rightArray[j];
                j++;
            }
            k++;
        }

        // 남은 요소들 복사
        while (i < n1)
        {
            arr[k] = leftArray[i];
            i++;
            k++;
        }
        while (j < n2)
        {
            arr[k] = rightArray[j];
            j++;
            k++;
        }
    }

    static void printArray(int[] arr)
    {
        for (int i : arr) System.out.print(i + " ");
        System.out.println();
    }
}

 

퀵 정렬과 병합 정렬 차이점

퀵 정렬과 병합 정렬은 둘 다 효율적인 정렬 알고리즘으로, 각각의 장단점이 있다.

  1. 분할 정복 방법
    • 퀵 정렬: 피벗을 기준으로 배열을 두 부분으로 분할하고, 각 부분에 대해 재귀적으로 정렬한다.
    • 병합 정렬: 배열을 반으로 나누고, 각 부분에 대해 재귀적으로 정렬한 후 정렬된 부분 배열을 합병한다.
  2. 분할 과정의 특징
    • 퀵 정렬: 부분 배열을 피벗을 중심으로 나누어 정렬한다.
      부분 배열의 분할은 피벗을 기준으로 작은 값과 큰 값으로 나누는 방식이다.
    • 병합 정렬: 부분 배열을 반으로 나누어 정렬한다.
      부분 배열의 분할은 단순히 중간 지점을 선택하여 두 개의 부분으로 나누는 방식이다.
  3. 안정성
    • 퀵 정렬: 보통 불안정 정렬에 속한다. 피벗에 의해 상대적인 순서가 변경될 수 있다.
    • 병합 정렬: 안정 정렬에 속한다. 정렬된 부분 배열을 합칠 때 상대적인 순서가 유지된다.
  4. 최악의 경우 시간 복잡도:
    • 퀵 정렬: 최악의 경우 시간 복잡도는 O(n^2)이지만, 피벗 선택에 대한 최적화 기법을 사용하면 대부분의 경우에 O(n log n)을 보장한다.
    • 병합 정렬: 항상 O(n log n)이다. 최악의 경우에도 합병 과정에서 추가적인 메모리를 사용하면서 안정적인 성능을 유지한다.
  5. 공간 복잡도
    • 퀵 정렬: 일반적으로 추가적인 메모리를 거의 사용하지 않는다. 퀵 정렬은 일반적으로 메모리 효율적이다.
    • 병합 정렬: 추가적인 메모리를 사용하여 병합을 수행한다. 따라서 메모리 사용량이 더 많을 수 있다.
  6. 적용 분야
    • 퀵 정렬: 대부분의 실제 상황에서 사용되는 효율적인 정렬 알고리즘 중 하나이다. 특히 데이터의 크기가 큰 경우에 유용하게 적용된다.
    • 병합 정렬: 안정적이면서 일관된 성능을 제공하며, 메모리 사용량이 크게 신경쓰이지 않는 경우에 사용된다. 주로 외부 정렬에 적합하다.

이러한 차이점들을 고려하여 선택해야 하는데, 각 알고리즘은 특정 상황에서 더 유리한 경우가 있다.

 

힙 정렬(Heap Sort)

힙 정렬(Heap Sort)은 힙(Heap) 자료구조를 기반으로 하는 정렬 알고리즘이다.

힙은 완전 이진 트리의 일종으로, 부모 노드의 값이 자식 노드의 값보다 항상 큰(또는 작은) 특성을 갖는 자료구조이다.

힙 정렬은 이러한 힙 자료구조의 특성을 이용하여 정렬을 수행한다.

힙 정렬의 주요 단계는 다음과 같다.

  1. 힙 구성(Build Heap): 주어진 배열을 힙으로 만든다.
    이를 위해 배열의 요소들을 힙의 형태에 맞게 재배치한다.
    주로 최대 힙(Max Heap)을 사용하며, 각 노드는 자식 노드보다 큰 값을 가지게 된다.
  2. 힙에서 최대값 추출 및 재배치(Extract Max): 최대 힙에서 루트 노드를 추출하여 배열의 뒷부분에 위치시킨다.
    그 후 힙의 크기를 감소시키고, 루트 노드에 위치한 값이 힙 특성을 만족하도록 재배치한다.
    이 과정을 반복하면서 최대값부터 차례대로 추출한다.
  3. 정렬된 배열 구성: 최대값을 추출하면서 배열의 뒷부분에 순서대로 채워나가므로, 최종적으로는 정렬된 배열이 형성된다.

아래의 그림은 힙의 요소를 배열에 저장하는 과정을 나타낸 것이다.

 

중요한 것은 주어진 배열(정렬할 배열)은 힙이 아닐 가능성이 크다. 우선 주어진 배열을 힙으로 만들어야 한다.

위와 같은 이진 트리가 있다고 가정해보자.

4를 루트로 하는 부분트리(a)는 힙이 아니다.

그러나 왼쪽 자식 8을 루트로 하는 부분트리(b)와 오른쪽 자식 5를 루트로 하는 부분트리(c)는 모두 힙이다.

루트 4를 알맞은 위치로 내려보내면서 부분트리(a)를 힙으로 만들 수 있다.

 

아래와 같은 방법을 사용하면 아래부분의 작은 부분트리부터 시작해 올라가는 방식(bottom-up)으로 전체 배열을 힙으로 만들 수 있다.

 

힙 정렬은 '가장 큰 값이 루트에 위치'하는 특징을 이용하는 정렬 알고리즘이다.

구체적으로 살펴보면 아래와 같은 작업을 반복한다.

  • 힙에서 가장 큰 값인 루트를 꺼낸다.
  • 루트 이외의 부분을 힙으로 만든다.

 

아래의 내용은 힙에서 가장 큰 값인 루트를 꺼내고 힙 상태를 유지하는 방법을 나타낸다.

 

아래의 그림은 힙 정렬 알고리즘의 흐름을 나타낸다.

 

힙 정렬은 평균 및 최악의 경우에도 O(n log n)의 시간 복잡도를 가진다.

이는 힙 구성 단계와 최대값 추출 단계 모두에서 log n의 시간 복잡도가 소요되기 때문이다.

힙 정렬은 추가적인 메모리를 필요로하지 않는 인플레이스(in-place) 정렬 알고리즘 중 하나다.

또한 힙 정렬은 일반적으로 불안정한 정렬 알고리즘이다.

public class Main
{
    public static void main(String[] args)
    {
        int[] myArray = {64, 25, 12, 22, 11};

        System.out.println("정렬 전 배열:");
        printArray(myArray);

        // 힙 정렬 수행
        heapSort(myArray);

        System.out.println("\n정렬 후 배열:");
        printArray(myArray);
    }

    static void heapSort(int[] arr)
    {
        // 초기 힙 구성
        for (int i = arr.length / 2 - 1; i >= 0; i--) heapify(arr, arr.length, i);

        // 최대값을 배열의 뒤로 이동하면서 정렬
        for (int i = arr.length - 1; i > 0; i--)
        {
            int temp = arr[0];
            arr[0] = arr[i];
            arr[i] = temp;

            // 힙 속성 유지
            heapify(arr, i, 0);
        }
    }

    static void heapify(int[] arr, int n, int i)
    {
        int largest = i;
        int left = 2 * i + 1;
        int right = 2 * i + 2;

        if (left < n && arr[left] > arr[largest]) largest = left;

        if (right < n && arr[right] > arr[largest]) largest = right;

        if (largest != i)
        {
            int temp = arr[i];
            arr[i] = arr[largest];
            arr[largest] = temp;

            // 재귀적으로 힙 속성 유지
            heapify(arr, n, largest);
        }
    }

    static void printArray(int[] arr)
    {
        for (int i : arr) System.out.print(i + " ");
        System.out.println();
    }
}

 

최소 힙과 최대 힙에 대해 더 알고싶다면 아래의 포스트를 참고하면 좋을 것 같다.

2023.10.22 - [자료구조 & 알고리즘/자료구조] - [Java] Heap(힙)과 Priority Queue(우선순위 큐)

 

[Java] Heap(힙)과 Priority Queue(우선순위 큐)

힙과 우선순위 큐 결론부터 말하자면, 우선순위 큐는 ADT(Abstract Data Type) 이고, 힙은 우선순위 큐의 개념을 구현한 것이다. ADT 구현하고자 하는 구조에 대해 구현 방법은 명시하지 않고 자료구조

rebugs.tistory.com

 

 

계수 정렬(Counting Sort)

Counting Sort(계수 정렬)은 정수나 정수 형태로 표현할 수 있는 자료에 대해 매우 빠르게 동작하는 안정적인 선형 시간 정렬 알고리즘 중 하나이다.

특히, 입력 값의 범위가 제한적일 때 효과적으로 동작한다.

Counting Sort의 주요 아이디어는 입력 배열의 각 원소들의 빈도를 세어, 누적 빈도를 이용하여 각 원소가 정렬된 위치를 찾아내는 것이다.

이때, 입력 배열의 원소 값은 정수이며, 각 값은 비교 가능해야 한다.

Counting Sort의 단계는 아래와 같다

  1. 빈도수 계산: 입력 배열의 각 원소들의 빈도수(또는 출현 횟수)를 계산.
  2. 빈도수 누적: 빈도수를 누적하여, 각 원소의 마지막 위치(또는 정렬된 위치)를 결정.
  3. 정렬된 배열 생성: 누적 빈도를 이용하여 입력 배열을 정렬된 배열에 배치.

 

1. 빈도수 계산

배열 a를 바탕으로 빈도수를 저장하기 위해 배열 f를 사용한다고 가정하자

먼저 배열 f의 모든 요소값을 0으로 초기화한다(0)

그런 다음 배열 a를 처음부터 스캔하면서 빈도수를 계산한다. a[0]은 5이므로 f[5]를 1 증가시켜 1로 만든다.(1)

a[1]은 7이므로 f[7]을 1 증가시켜 1로 만든다(2)

이 작업을 배열 마지막 요소까지 반복하면 빈도수 계산이 완료된다.

f 배열의 인덱스는 a 배열의 요소의 빈도 수를 나타낸다.

 

2. 빈도수 누적

배열 f의 두 번째 요소부터 바로 앞의 요솟값을 더하는 과정을 진행한다.

가장 마지막에 빈도수 누적이 완료된다.

이 과정을 그림으로 나타내면 아래와 같다.

 

3. 정렬된 배열 생성

남은 작업은 배열 a의 각 요솟값과 f를 대조하여 정렬을 마친 배열을 마친 배열을 만드는 일이다.

이제 배열 a의 요소를 마지막 요소부터 처음 요소까지 스캔하면서 배열  f와 대조하는 작업을 수행한다.

a[8]의 값은 3이다. f[3]의 값이 5이다. 이때 중요한 작업이 하나있다.

바로 5에서 1을 감소시킨 4를 임시 배열 b의 인덱스로 사용하는 것이다.

즉, 임시 배열인 b[5 - 1]에 a[8]의 값인 3을 저장하는 것이다.

 

이해가 안간다면, a[7]보자

a[7]의 값은 1이다. f[1]의 값은 2이다.

b[2 - 1]에 a[7]의 값인 1을 저장한다.

 

a[6]의 과정도 마찬가지이다.

a[6]의 값은 3이다. f[3]의 값은 4이다.

b[4 - 1]에 a[6]의 값인 3을 저장한다.

 

마지막으로 임시 배열인 b를 a로 복사하면 정렬과정이 끝이 난다.

 

Counting Sort는 다음과 같은 특징을 가진다.

  • 안정적인 정렬 알고리즘
  • 입력 값의 범위가 작을 때에 가장 효과적으로 동작
  • 입력 배열의 크기가 매우 클지라도 입력 값의 범위에 비례하는 공간 복잡도를 가지므로, 메모리 효율적

 

public class Main
{
    public static void main(String[] args)
    {
        int[] myArray = {4, 2, 7, 1, 5, 3, 6};

        System.out.println("정렬 전 배열:");
        printArray(myArray);

        // Counting Sort 수행
        countingSort(myArray);

        System.out.println("\n정렬 후 배열:");
        printArray(myArray);
    }

    static void countingSort(int[] arr)
    {
        int max = arr[0];
        for (int num : arr)
        {
            if (num > max)  max = num;
        }

        // 빈도수 계산
        int[] count = new int[max + 1];
        for (int num : arr) count[num]++;

        // 빈도수 누적
        for (int i = 1; i <= max; i++) count[i] += count[i - 1];

        // 정렬된 배열 생성
        int[] result = new int[arr.length];
        for (int i = arr.length - 1; i >= 0; i--)
        {
            result[count[arr[i]] - 1] = arr[i];
            count[arr[i]]--;
        }

        // 원래 배열에 복사
        System.arraycopy(result, 0, arr, 0, arr.length);
    }

    static void printArray(int[] arr)
    {
        for (int i : arr) System.out.print(i + " ");
        System.out.println();
    }
}

이 글은 이것이 자료구조+알고리즘이다 with C 언어(저자:박상현) 책 내용을 개인적으로 정리하는 글임을 알립니다.


다익스트라 알고리즘의 개념

다익스트라 알고리즘은 여러가지 경로중에 목적지에 도착하기 위해 가장 빠른 경로를 찾아주는 알고리즘이다.

프림 알고리즘이 단순히 간선의 길이를 이용하여 어떤 간선을 먼저 연결할지 결정하는 데 비해 데이크스트라 알고리즘은 경로의 길이를 감안해서 간선을 연결, 데이크스트라 알고리즘의 경우 사이클이 없는 방향성 그래프에 한해서만 사용 가능

 

다익스트라 알고리즘 동작 방식

❶ : 각 정점에는 시작점으로부터 자신에게 이르는 경로의 길이를 저장할 곳을 준비하고 각 정점에 대한 경로의 길이를 ∞(무한대)로 초기화
❷ : 시작 정점의 경로 길이를 0으로 초기화하고(시작 정점에서 시작 정점까지의 거리는 0이므로) 최단 경로에 추가
❸ : 최단 경로에 새로 추가된 정점의 인접 정점에 대해 경로 길이를 갱신하고 이들을 최단 경로에 추가
- 만약 추가하려는 인접 정점이 이미 최단 경로 안에 있다면, 갱신되기 이전의 경로 길이가 새로운 경로 길이보다 더 큰 경우에 한해 다른 선행 정점을 지나던 기존 경로가 현재 정점을 경유하도록 수정
❹ : 그래프 내의 모든 정점이 최단 경로에 소속될 때까지 단계 ❸의 과정을 반복

 

예제

B를 시작점으로 설정하고 ‘나머지 모든 정점’을 도착점으로 지정


각 정점별로 경로 길이를 정점 좌측 상단의 상자 안에 표시하고 초깃값을 무한대로 설정

시작 정점 B의 경로 길이는 0으로 수정(B에서 B로 가는 비용은 0이므로)


시작 정점인 B에 인접한 정점들을 찾고 간선의 가중치를 조사
현재 경로 B-A 사이의 가중치는 경로 길이인 ∞보다 작으므로 A의 경로 길이를 35로 수정
같은 방법으로 경로 B-C의 경로 길이는 126, B-F의 경로 길이는 150으로 수정


A의 경로 길이 35와 간선 A-E의 비용 247을 더한 값 282를 경로 길이로 입력


C의 인접 정점은 D, F, G
현재 D의 경로 길이는 무한대이므로 B-C-D 경로의 비용(126+117=243)을 그대로 입력
G 역시 경로 길이가 무한대이므로 B-C-G 경로의 비용(126+220=346)을 입력
새로 발견한 경로 B-C-F는 B-F보다 비용이 크기 때문에 채택할 수 없음


F에 인접한 정점은 E, G, H

E정점으로 가는 기존 경로인 B-A-E는 경로 길이가 282인데 새로운 경로인 B-F-E의 비용이 232로 더 작으므로 기존의 경로 B-A-E는 폐기하고 새로운 경로 B-F-E를 채택


G도 마찬가지로 기존 B-C-G(비용 346)보다 새 경로 B-F-G(비용 304)가 저렴하므로 예전 것을 폐기하고 새 경로를 채택

H의 경로 길이는 현재 무한대이므로 경로 B-F-H의 비용 270을 H의 경로 길이로 입력


E에 인접한 정점 H가 있기는 하지만 경로 B-F-E-H의 비용이 330이고 현재 경로(B-F-H) 길이 270보다 크므로 무시한다.

정점 G만 유일하게 방문하지 않은 정점 I를 가지고 있다.

I의 현재 경로 길이는 무한대이므로 경로 B-F-G-I의 비용 410을 입력한다.


이제 더 탐사할 노드가 없으므로 최단 경로 탐색을 종료한다.

각 정점의 상단에 있는 상자 속 값들은 B에서부터 해당 정점에 이르는 경로상 모든 간선의 가중치이다.

이 글은 이것이 자료구조+알고리즘이다 with C 언어(저자:박상현) 책 내용을 개인적으로 정리하는 글임을 알립니다.


최소 신장 트리

가중치 그래프

  • 가중치 그래프 : 그래프에서 정점과 정점을 잇는 간선을 지나기 위해 가중치라는 새로운 속성을 부여한 그래프

신장 트리(Spanning Tree)

모든 정점을 연결하는 트리

신장 트리는 그래프의 하위 개념

모든 정점을 연결하는 그래프(네트워크 그래프)에서 사이클이 되는 간선을 제거하면 신장 트리가 된다.

따라서 n개의 정점을 정확히 (n-1)개의 간선으로 연결한다.

여러 신장 트리 중 하나의 예시

 

최소 신장 트리

최소 신장 트리는 최소 가중치 신장 트리라고 부르기도 한다.

최소 신장 트리는 그래프의 모든 정점을 최소 비용으로 연결하는 부분 그래프 또는 트리의 모든 노드를 최소 비용으로 연결하는 트리라고 말할 수 있다.

 

최소 신장 트리 알고리즘에는 아래와 같은 종류가 있다.

  • 프림(Prim) 알고리즘
  • 크루스칼(Kruskal) 알고리즘

 

 

프림 알고리즘

프림 알고리즘의 최소 신장 트리 생성 과정

❶ : 그래프와 최소 신장 트리를 준비 – 이때의 최소 신장 트리는 노드가 하나도 없는 상태
❷ : 그래프에서 임의의 정점을 시작 정점으로 선택하여 최소 신장 트리의 뿌리 노드로 삽입
❸ : 최소 신장 트리에 삽입된 정점들과 이 정점들의 모든 인접 정점 사이에 있는 간선의 가중치를 조사
  - 간선 중에 가장 가중치가 작은 것을 골라 이 간선에 연결된 인접 정점을 최소 신장 트리에 삽입
  - 단, 새로 삽입되는 정점은 최소 신장 트리에 삽입된 기존 노드와 사이클을 형성해서는 안 됨
❹ : ❸의 과정을 반복하다가 최소 신장 트리가 그래프의 모든 정점을 연결하게 되면 알고리즘을 종료

 

예시

시작 정점을 정해야 한다.

어느 것을 골라도 무관하지만 B를 시작 정점으로 선택

B는 시작 정점으로 선택됨과 동시에 최소 신장 트리의 뿌리노드가 된다.


정점 B에 연결된 간선은 B-A, B-C, B-F 3개임
가장 가중치가 작은 간선은 가중치가 35인 B-A이므로 A를 최소 신장 트리에 추가


최소 신장 트리에 연결된 노드는B, A
B, A에 연결된 간선은 B-C, B-F, A-E
B-C의 가중치가 126으로 가장 작기 때문에 C를 최소 신장 트리에 추가

최소 신장 트리의 노드는 모두 3개가 되었고, 조사해야 할 간선이 4개가 되었음

C-F간선은 사이클을 형성하기 때문에 조사해야 할 간선에서 제외 (B-F와 C-F 둘 중 하나를 제외하면 되지만, 가중치가 큰 간선 제외함)


가중치를 조사할 간선은 모두 4개(C-D, B-F, C-G, A-E)
이 중 가장 가중치가 작은 간선은 C-D(117)이므로 D를 최소 신장 트리에 추가


간선 B-F, C-G, A-E 중 가중치가 가장 작은 간선은 B-F(150)
F를 최소 신장 트리에 추가


F가 최소 신장 트리에 추가됨으로써 조사 대상 간선에 변화가 생김

A-E간선은 가중치가 더 작은 F-E간선에 의해, C-G간선은 F-G간선에 의해 조사 대상에서 제거됨

F-H 간선이 조사 대상으로 새로 추가됨

간선 3개(F-E, F-G, F-H) 중 가장 작은 가중치를 갖는 간선은 F-E(82)이므로 이를 최소 신장 트리에 추가


F-H 간선이 E-H 간선에 의해 조사 대상에서 제거

조사 대상인 2개 (E-H, F-G)의 간선 중 가중치가 가장 적은 간선은 E-H(98)

최소 신장 트리에 H를 추가


조사 대상 간선이 F-G 하나만 남았으므로 이 간선을 선택하고 G를 최소 신장 트리에 추가


조사 대상 간선이 G-I 하나임, 정점 I를 최소 신장 트리에 추가

 

프림 알고리즘 구현 시 고려할 사항

어떤 자료구조를 최소 신장 트리에 사용할 것인 지의 판단

  • 자료구조는 배열, 링크드 리스트, 트리, 그래프 등 다양

 

조사 대상 간선 중 최소 가중치를 가진 것을 골라내는 과정에서 발생하는 성능 문제

  • 최소 신장 트리에 정점이 하나 추가될 때마다 그 수가 늘어나거나 줄어드는 조사 대상 간선 집합 속에서 ‘최소 가중치’를 가진 간선을 찾는 과정에서 성능 문제 발생
  • 그래프에 정점이 N개 존재한다고 가정하면 정점 추가 작업을 N회 해야 하고, 정점을 추가할 때마다 그래프 내
    정점 N개를 순회해야 하므로 N×N회, 즉 N²회 반복 처리가 필요
    삽입과 삭제가 빠르고 최솟값을 찾는 데 비용도 거의 들지 않는 우선순위 큐를 이용하는 것이 가장 적절

 

크루스칼 알고리즘

크루스칼 알고리즘은 그래프 내 모든 간선의 가중치 정보를 사전에 파악하고, 이 정보를 토대로 최소 신장 트리를 구축해 나간다.

https://lemidia.github.io/

파란색 간선은 현재 집합 S에서 선택한 간선
빨간색 간선은 집합 S에서 선택한 간선을 숲 F에 추가한 간선이다.

 

크루스칼 알고리즘 작동 순서

❶ : 그래프 내의 모든 간선을 가중치의 오름차순으로 정렬하여 목록을 생성
❷ : ❶에서 만든 간선의 목록을 차례대로 순회하면서 간선을 최소 신장 트리에 추가
     - 단, 이때 추가된 간선으로 인해 최소 신장 트리 내에 사이클이 형성되면 안 됨

 

사이클 형성 방지

크루스칼 알고리즘에서 중요한 점은 '사이클 형성을 어떻게 방지할 것인가?'이다.

DFS를 통해 사이클을 방지할 수도 있다.

이미 방문했던 노드를 또 만난다면 사이클이 있다는 뜻이기 때문이다.

DFS를 이용한 방법은 탐색 비용이 크다는 단점이 있다.

이 방법의 대안으로 분리 집합을 이용하는 방법이 있다.

  • 각 정점별로 각각의 분리 집합을 만들고, 간선으로 연결된 정점들에 대해서는 합집합 수행.
  • 간선으로 연결할 두 정점이 같은 집합에 속해 있다면 이 연결은 사이클을 이루게 되기에 연결하지 않음

 

예를 들어, A와 D를 연결한다고 가정하자

아래의 왼쪽은 정점 A와 D가 별개의 집합 속에 있기 때문에 사이클을 이루지 않는다.

오른쪽은 A와 D가 같은 집합에 속해있고, A와 D를 이으면 사이클이 생긴다.

따라서 서로 다른 집합에 속해있는 정점들만 연결하면 사이클이 생기지 않는다.

 

예제

그래프의 정점 사이에 있는 모든 간선의 가중치를 오름차순으로 정렬한다.

 이후 각 정점 별로 분리 집합을 만든다.

간선을 가중치 순으로 정렬하고 각 정점별로 분리 집합을 만들었다면 준비가 끝났다.


가장 작은 가중치를 가진 간선은 가중치 35인 A-B
정점 A와 B는 별개의 분리 집합 원소이므로 이 둘을 최소 신장 트리에 추가하고 간선으로 연결
분리 집합 {A}와 {B}에 대해 합집합을 수행하여 하나의 분리 집합으로 생성


두 번째로 작은 가중치를 가진 간선은 E-F(82)
E-F를 최소 신장 트리에 추가하고 분리 집합 {E}와 {F}를 합하여 하나의 분리 집합으로 생성


간선 E-H를 최소 신장 트리에 추가
{E, F}와 {H}에 대해 합집합을 수행


간선 G-I를 최소 신장 트리에 입력
{G}와 {I}에 대해서는 합집합 연산을 수행해서 {G, I}을 생성


간선 F-H는 같은 집합이므로(E-F-H에 이르는 사이클이 형성되어 있으므로), 간선 F-H는 무시한다.

따라서 다음 순위의 간선을 채택 다음 순위의 간선은 가중치 117을 가진 C-D인데, 이 둘은 서로 다른 집합에 소속되어 있으므로 최소 신장 트리에 간선을 추가하고 두 정점을 하나의 집합으로 생성


B와 C는 서로 다른 집합에 속해 있으므로 이 간선을 최소 신장 트리에 추가
B가 속한 {A, B}와 C가 속한 {C, D}에 대해 합집합을 수행


B와 F는 서로 다른 집합에 속해 있으므로 간선 B-F를 최소 신장 트리에 추가
B가 소속된 {A, B, C, D}와 F가 소속된 {E, F, H}에 대해 합집합을 수행


F와 G는 서로 다른 집합에 속해 있으므로 간선 F-G를 최소 신장 트리에 추가
{A, B, C, D, E, F, H}와 {G, I}를 하나로 만들기 위해 합집합을 수행

모든 정점이 하나의 집합 안에 모여서 최소 신장 트리가 되었다.

남은 간선들은 사이클을 형성하는 것들 뿐이다.

이 글은 이것이 자료구조+알고리즘이다 with C 언어(저자:박상현) 책 내용을 개인적으로 정리하는 글임을 알립니다.


위상 정렬

  • 위상 : 어떤 정점이 다른 정점과의 관계 속에서 가지는 위치

이 말은 그래프 내 서로 인접한 정점 사이의 관계에 위치라는 속성이 존재한다는 뜻이다.

이 위치는 앞/뒤일 수도 있고, 위/아래일 수도 있다.

이 글에서는 앞/뒤 관계라고 가정한다.

  • 앞:간선을 뻗어내는 정점
  • 뒤: 간선을 받아들이는 정점

앞/뒤를 차근차근 정렬하는 작업이 위상 정렬이다.

 

위상 정렬은 순서가 정해져 있는 작업을 차례로 수행해야 할 때, 그 순서를 결정해 주기 위해 사용한다.

위상 정렬은 여러 개의 답이 존재할 수 있다.

 

위상 정렬의 시간복잡도
V = 정점의 개수, E = 간선의 개수
O(V + E)

 

위상 정렬을 하려면 아래와 같은 조건이 있다.

  • 그래프에 방향성이 있음
  • 그래프 내에 사이클이 없음

이러한 그래프를 DAG(Directed Acyclic Graph)라고 한다.

DAG 예시

 

위상 정렬의 동작 방식

  • 방향성 간선 : 두 정점이 이웃 또는 인접 관계에 있다는 사실 뿐만 아니라 어느 정점이 앞이고 어느 정점이 뒤인지도 설명
  • 진입 간선(Incoming Edge) : 정점으로 들어가는 간선
  • 진출 간선(Outgoing Edge) : 정점에서 나가는 간선

 

위상 정렬의 과정

: 리스트(또는 큐)를 하나 준비
: 그래프에서 진입 간선이 없는 정점을 리스트에 추가하고 해당 정점 자신과 진출 간선을 제거
: 모든 정점에 대해 단계 를 반복하고 그래프 내에 정점이 남아 있지 않으면 정렬을 종료, 이때 리스트에는 위상 정렬된 그래프가 저장

 

진입 간선이 없는 정점 A, B가 있다.

  • 여기서 A와 B중 아무거나 상관없지만, B를 선택
  • B를 리스트에 추가하고 B와 진출 간선을 모두 제거


 B를 제거한 후 진입 간선이 없는 정점으로 A와 E가 남음

  • 어느 쪽을 먼저 제거해도 상관없지만 E를 선택
  • E를 리스트에 추가하고 E와 진출 간선을 제거


진입 간선이 없는 정점이 A 하나뿐이다.

  • A를 리스트에 추가하고 A와 진출 간선을 모두 제거한다.


진입 간선이 없는 정점은 C, D

  • 어느쪽을 먼저 제거해도 상관없지만 D를 제거
  • D를 리스트에 추가하고 진출 간선을 제거


진입 간선이 없는 정점은 C,G

  • 어느 쪽을 먼저 제거해도 상관 없지만 G를 제거
  • G를 리스트에 추가하고 진출 간선을 제거


진입 간선이 없는 정점은 C

  • C를 리스트에 추가하고 진출 간선을 제거


진입 간선이 없는 정점은 F

  • F를 리스트에 추가하고 진출 간선을 제거


마지막으로 남은 정점 H를 리스트에 추가

  • 완성된 리스트가 바로 그래프를 위상 정렬한 결과

 

DFS를 이용한 위상 정렬

❶  : 리스트(또는 스택)를 하나 준비
❷  : 그래프에서 진입 간선이 없는 정점에 대해 깊이 우선 탐색을 시행하고, 탐색 중에 더 이상 옮겨갈 수 있는 인접 정점이 없는 정점을 만나면 이 정점을 리스트의 새로운 ‘헤드’로 입력
❸ : ❷를 반복하다가 더 이상 방문할 정점이 없으면 깊이 우선 탐색을 종료 깊이 우선 탐색이 끝나면 리스트에 위상 정렬된 그래프가 남음

 

리스트를 하나 준비

진입 간선이 없는 정점을 찾아 DFS를 시작

진입 간선이 없는 정점은 A, B

어느쪽을 먼저 해도 상관없지만 A부터 탐색 시작

H에서 옮겨갈 수 있는 인접 정점이 더 이상 없으므로 H를 리스트의 ‘헤드’로 삽입


H에서 뒤로 돌아오면 정점 F를 만난다.

F의 유일한 인접 정점은 H인데, H는 이미 방문했으므로 F를 리스트의 헤드로 삽입하고 뒤로 돌아간다.


유일한 인접 정점이 F였는데, F는 이미 방문했으므로 C를 리스트의 헤드로 삽입하고 뒤로 돌아간다.


정점 A의 인접 정점은 C, D

C는 이미 방문했고, D는 아직 방문하지 않았으므로 D를 타고 DFS 수행

D를 거쳐 G에 도착하면 G의 유일한 인접 정점이었던 H가 이미 방문한 상태

따라서 G는 방문할 수 있는 인접 정점이 없는 상태이므로 이 정점을 리스트의 헤드로 삽입하고 뒤로 돌아감

 


유일한 인접 정점이 G였는데, G를 이미 방문했으므로 D를 리스트 헤드에 추가하고 뒤로 돌아감


다시 A로 돌아왔다.

A도 방문할 인접 정점이 없으므로 리스트의 헤드로 삽입


B를 타고 DFS 수행

B의 인접 정점 C와 E 중 C는 이미 방문한 상태이므로 E를 방문

E의 유일한 정점이었던 G는 이미 방문한 상태이므로 E를 리스트 헤드에 삽입하고 뒤로 돌아감


더 이상 방문할 수 있는 인접 정점이 없으므로 B를 리스트의 헤드로 삽입

DFS와 위상정렬이 모두 종료됨

여기서의 리스트 순서는 위상 정렬의 결과물

보이어 무어 알고리즘은 패턴의 오른쪽부터 비교하여 텍스트 문자를 모두 비교하지 않아도 탐색이 가능한 알고리즘이다.

전체 문자열과 찾고 싶은 문자열(패턴) 비교 시 문자열의 가장 뒷부분 위치를 비교하고,  다르면 일정 길이만큼 이동하여 비교를 계속하는 방법이다.

보이어무어 알고리즘은 두 가지의 이동으로 나뉜다.

  • 나쁜 문자 이동(Bad Character Shift)
  • 착한 접미부 이동(Good Suffix Shift)

두 가지의 이동을 적절히 이용하여 문자열 탐색을 할 때 최고의 시너지가 나온다.

즉, 전체 문자열과 찾고 싶은 문자열을 비교했을 때 불일치가 발생하면 나쁜 문자 이동과 착한 접미부 이동을 모두 검토해서 더 멀리 옮겨가는 이동 방법을 선택하게 된다.

 

Prefix(접두사), Suffix(접미사)
Prefix(접두사)와 Suffix(접미사)의 개념은 아래의 표를 보면 이해가 될 것이다.
BAABABAA 문자열에서 얻을 수 있는 접두사와 접미사는 아래와 같다.


 

문자열을 오른쪽에서 왼쪽으로 비교, 이동은 왼쪽에서 오른쪽으로

  • 패턴의 오른쪽 끝에 있는 문자와 본문의 문자가 불일치하고 그 본문의 문자가 패턴내에 존재하지 않는 경우 이동 거리는 패턴의 길이와 같음

 

나쁜 문자 이동

나쁜 문자 이동의 단계

나쁜 문자 : 본문의 문자열과 패턴이 불일치하도록 만드는 문자

  1. 본문과 패턴의 불일치가 발생하면 본문의 불일치한 문자와 같은 불일치 문자를 패턴에서 탐색.
  2. 찾아낸 패턴의 불일치 문자 위치가 본문의 불일치 문자 위치와 일치하도록 패턴을 이동

불일치 문자와 동일한 문자가 패턴 내에서 2개 이상 나오는 경우, 발견된 문자 중 가장 오른쪽에 있는 것을 본문의 불일치 문자에 맞춤

문자열 B A A B A B B A C
과정1 B B A C          
과정2     B B A C      
과정3         B B A C  
과정4           B B A C

 

착한 접미부 이동을 고려해야 하는 경우

패턴(찾는 문자열)이 오른쪽으로 이동하는 것이 아니라, 왼쪽으로 이동한다면 나쁜 문자 이동을 사용하는 것이 아니라 착한 접미부 이동을 고려해야 한다.

 

착한 접미부 이동

착한 접미부 이동은 두 가지 경우로 나뉜다.

  1. 불일치가 발생한 상황에서 본문의 착한 접미부와 동일한 문자열이 패턴의 나머지 부분에 존재할 때
  2. 불일치가 발생한 상황에서 본문의 착한 접미부와 동일한 문자열이 패턴의 나머지 부분에 존재하지 않을 때

 

첫 번째 경우

불일치가 발생한 상황에서 본문의 착한 접미부와 동일한 문자열이 패턴의 나머지 부분에 존재할 때

 

  • 본문과 패턴 비교를 오른쪽에서부터 시작했는데 3번에서 본문의 B와 패턴의 A가 불일치
  • 그래서 착한(동일한) 접미부 AB를 패턴의 나머지 부분에서 찾고 이렇게 찾아낸 부분이 본문의 착한 접미부 위치와 일치하도록 패턴을 이동

 

 

두 번째 경우

불일치가 발생한 상황에서 본문의 착한 접미부와 동일한 문자열이 패턴의 나머지 부분에 존재하지 않을 때

 

  • 착한 접미부의 문자열을 왼쪽부터 하나씩 줄여가면서 반복해서 조사
  • 완전 불일치의 경우, 패턴의 길이만큼 이동한 후 비교

 

  • ‘착한 접미부의 접미부’가 패턴의 접두부와 일치하는지 확인
  • 접미부와 일치하는 접두부는 착한 접미부 전체가 아닌 ‘착한 접미부의 접미부’와 일치하는 패턴의 접두부가 동일한 위치에놓이도록 패턴을 이동
  • 착한 접미부가 AAB인데 패턴의 나머지 부분에서 이와 같은 문자열을 찾을 수 없음
  • 착한 접미부의 문자열을 왼쪽부터 하나씩 줄여봄

 

  • 착한 접미부의 접미부 AB는 패턴의 접두부와 일치(AB도 맞지 않을 경우 B가 일치하는지 확인)
  • 이제 패턴의 접두부가 착한 접미부의 접미부에 일치하도록 다음과 같이 패턴을 이동
  • 완전 불일치의 경우, 패턴의 길이만큼 이동한 후 비교

KMP 알고리즘이란

이 알고리즘을 만든 Knuth, Morris, Prett 이렇게 3명의 앞 글자를 하나씩 따서 명명하여 KMP 알고리즘이라고 한다.

KMP 문자열 탐색 알고리즘의 시간 복잡도는 O(N+M)이다.(전체 문자열 길이 = N, 찾는 문자열 길이 = M)

LPS 배열 계산 O(M) + 매칭 O(N)

 

KMP 알고리즘의 핵심 아이디어

IDX 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
Text A B C D A B D A B C D A B E A B C D
Pattern A B C D A B E                      

위 표에서 text와 pattern 모두 0, 1번째 인덱스와 4, 5번째 인덱스의 값이 동일하다는 것을 알 수 있다.

즉, 앞에서 2개 뒤에서 2개가 같다는 것을 알 수 있다.

또한 6번째 인덱스부터 text와 pattern이 다르다는 것을 알 수 있다.

text와 pattern 모두 0, 1번째 인덱스와 4, 5번째 인덱스의 값이 동일하고, 6번째 인덱스부터 text와 pattern이 다르기 때문에, 다음 문자열 비교를 할 때, pattern의 첫 시작을 text와 pattern이 다른 곳(IDX 6)으로부터 앞에서 2번째인 인덱스 4부터 시작한다는 개념이다.

문자열이 불일치 할 때, 탐색을 시작했던 위치의 다음 문자 부터가 아닌 일정 부분을 건너 뛸 수 있다는 것이 핵심이다.

아래의 표를 보면 이해가 될 것이다. 

IDX 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
Text A B C D A B D A B C D A B E A B C D
Pattern         A B C D A B E              

여기서는 앞에서 몇개 뒤에서 몇개가 같은게 없다.

그리고 6번째 인덱스부터 text와 pattern이 다르다.

그러면 다음 문자열 비교를 할 때, pattern의 첫 시작을 text와 pattern이 다른 곳(IDX 6)부터 다시 비교를 하는 것이다.

IDX 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
Text A B C D A B D A B C D A B E A B C D
Pattern             A B C D A B E          

IDX 6부터 pattern의 길이(6)를 더한 IDX 12까지 일치하는 것이 없으므로 한 칸 옆으로 pattern을 이동시킨다.

IDX 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
Text A B C D A B D A B C D A B E A B C D
Pattern               A B C D A B E        

Text의 IDX7부터 찾는 문자열이 있음을 알 수 있다.

 

Prefix(접두사), Suffix(접미사), LPS배열

Prefix(접두사)와 Suffix(접미사)의 개념은 아래의 표를 보면 이해가 될 것이다.

BAABABAA 문자열에서 얻을 수 있는 접두사와 접미사는 아래와 같다.

BAABABAA
길이 접두사 접미사
1 B A
2 BA AA
3 BAA BAA
4 BAAB ABAA
5 BAABA BABAA
6 BAABAB ABABAA
7 BAABABA AABABAA
8 BAABABAA BAABABAA

 

LPS : Longest proper Prefix which is also Suffix

LPS의 길이를 담는 LPS 배열은 KMP 알고리즘에서 중요한 역할을 하는 배열이다.

Pattern "ABAABAB"에 대한 LPS 배열은 아래와 같다.

IDX index까지의 패턴 LPS의 길이
0 A 0
1 AB 0
2 ABA 1
3 ABAA 1
4 ABAAB 2
5 ABAABA 3
6 ABAABAB 2

 

위 표에서 IDX 3인 경우를 살펴보면

IDX index까지의 패턴 LPS의 길이
3 ABAA 1

왜 LPS의 길이가 1인지는 아래와 같다.

길이 접두사 접미사
1 A A
2 AB AA
3 ABA BAA

접두사와 접미사가 일치하는 최대 길이는 1이다

즉, 접두사와 접미사가 같을때, 그 최대 길이는 1이다.

이와 같이 접두사와 접미사가 같을 때, 그 최대 길이를 가지고 있는 배열이 바로 LPS배열이다.

 

LPS 배열이 의미하는 것

IDX index까지의 패턴 LPS의 길이
0 A 0
1 AB 0
2 ABA 1
3 ABAA 1
4 ABAAB 2
5 ABAABA 3
6 ABAABAB 2
IDX index까지의 패턴 LPS의 길이
5 ABAABA 3
IDX 0 1 2 3 4 5 6 7 8
Text A B A A B A Z    
Pattern A B A A B A B    

전체 패턴 "ABAABAB" 중에서 일부인 "ABAABA" 까지 일치하고 그 다음 글자에서 불일치 했다면

다음 번 비교는 접두사가 다시 나타나는 IDX3 부터 시작하면 효율적이다.

즉, 틀린 위치인 IDX6에서 LPS의 길이인 3을 빼서 IDX3부터 다음 번 비교를 시작하면 되는 것이다.

 

이후 비교에서 IDX3부터 IDX5까지는 이미 일치하다는 것을 알고 있기 때문에, IDX6부터 비교를 하면 되는 것이다.

IDX 0 1 2 3 4 5 6 7 8
Text A B A A B A Z .. ..
Pattern       A B A A B A

 

구현

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.ArrayList;
public class Main
{
    static public ArrayList<Integer> KMP(String str, String pattern)
    {
        ArrayList<Integer> idxList = new ArrayList<>(); //찾는 문자열을 발견시 해당 문자열의 시작 인덱스를 저장하는 리스트
        int LPS[] = new int[pattern.length()]; //LPS 배열 생성
        int index = 0; //IDX, 찾을 문자열의 비교 인덱스를 뜻하기도 하며, 접두사와 접미사가 같을 때 최대 길이를 뜻하기도 함
        for (int i = 1; i < pattern.length(); i++) //LPS배열의 값을 입력
        {
            if (pattern.charAt(i) == pattern.charAt(index)) LPS[i] = ++index; //접두사와 접미사가 같을 때, index를 1 증가
            else  //접두사와 접미사가 같지 않을 때
            {
                if (index != 0) //0이면 더 이상 돌아갈 위치가 없음
                {
                    index = LPS[index - 1]; //LPS[index - 1] : 이전 위치로 돌아가야 할 위치를 나타냄, 이 위치에서부터 비교를 다시 시작하여 일치하는 부분을 찾음
                    --i; // 현재 위치에서부터 다시 패턴 매칭을 시도(여기서 --i하고 이후 루프에서 ++i가 될테니)
                }
            }
        }
        index = 0; //0으로 초기화
        for(int i = 0; i < str.length(); i++) //문자열 탐색 시작
        {
            while(index > 0 && str.charAt(i) != pattern.charAt(index)) index = LPS[index - 1]; //LPS[index - 1] : 이전으로 돌아가야할 위치
            if(str.charAt(i) == pattern.charAt(index)) //접두사와 접미사가 같을 때
            {
                if(index == pattern.length() - 1) //IDX는 접두사와 접미사가 같을 때 최대 길이를 뜻하기도 하는데, 이것이 pattern의 길이와 같다면 탐색 성공
                {
                    idxList.add(i - (index - 1)); //일치하는 첫 번째 인덱스를 추가
                    index = LPS[index]; //LPS[index] : 현재 일치하는 부분의 최대 길이를 나타내며, 이 길이만큼은 이미 패턴과 일치함이 보장
                }
                else ++index; //길이가 같지 않다면 index를 1증가
            }
        }
        return idxList;
    }

    public static void main(String[] args) throws Exception
    {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        String str = br.readLine();
        String pattern = br.readLine();
        ArrayList<Integer> list = KMP(str, pattern); //시작 인덱스들을 받아옴
        System.out.println(list.size() + "개 발견");
        System.out.print("인덱스 : ");
        for (Integer i : list) System.out.print(i + " "); //시작 인덱스들을 출력
    }
}