그리디(greedy) 알고리즘은 단어 그대로 탐욕법이라고도 한다.

단순하면서도 강력한 알고리즘으로 반드시 알아야 하는 알고리즘 중 하나이다. 

그리디 알고리즘은 뜻 그대로 바로 눈앞의 이익만을 좇는 알고리즘을 말한다. 

 

그리디 알고리즘은 최적화 문제를 대상을 하고 그리디 알고리즘은 그 문제를 해결하기 위한 최소한의 아이디어를 떠올릴 수 있는 능력을 요구한다. 

즉, 특정한 문제를 직면했을 때, 단순히 현재 상황에서 최적으로 보이는 것만을 선택해도 문제를 풀 수 있는지 파악해야 한다. 

 

또한, 그리디 알고리즘 문제는 정렬 알고리즘과 자주 짝을 이룬다. 정렬을 한 상태에서 기준을 만족시키는 경우가 많기 때문이다.


 

그리디 알고리즘에 대표적인 예시로 '거스름 돈' 문제를 이용해 그리디 알고리즘을 설명해 보겠다.

문제: 카운터에는 거스름돈으로 사용할 500원, 100원, 50원, 10원짜리 동전이 무한히 존재한다고 가정하고, 손님에게 거슬러 줘야 할 돈이 N원일 때 거슬러 줘야 할 동전의 최소 개수를 구하여라. 단, 거슬러 줘야 할 돈 N은 항상 10의 배수이다.

 

이 문제는 그리디 알고리즘을 이용해 풀 수 있는 대표적인 문제로 간단한 아이디어만 떠올릴 수 있으면 문제를 해결할 수 있다. 바로 '가장 큰 화폐 단위부터' 돈을 거슬러 주는 것이다.

 

N=int(input())
count = 0

# 큰 단위의 화폐부터 차례로 확인.
coin_types = [500 , 100, 50, 10]

for coin in coin_types:
	count += n // coin #해당 화폐로 거슬러 줄 수 있는 동전의 개수
    n %= coin
    
print(count)

 

화폐의 종류가 K라고 할 때 이 코드의 시간 복잡도는 O(K) 이다. 

 

하지만 대부분의 그리디 알고리즘 문제는 이처럼 문제 풀이를 위한 최소한의 아이디어를 떠올리고 이것이 정당한지 검토할 수 있어야 정답을 도출할 수 있다.

 

그리디 알고리즘의 핵심은 늘 '최적해'를 구하는 것이다.

이진트리 

노드가 왼쪽 자식과 오른쪽 자식만을 갖는 트리를 이진트리 (binary tree)라고 한다.

이진트리의 특징은 왼쪽 자식과 오른쪽 자식을 구분한다는 점이다. 왼쪽 자식을 루트로 하는 서브트리를 왼쪽 서브트리, 오른쪽 자식을 루트로 하는 서브트리를 오른쪽 서브트리라고 한다.

이진 트리


완전 이진 트리

루트부터 아래쪽 레벨로 노드가 가득 차 있고, 같은 레벨 안에서 왼쪽부터 오른쪽으로 노드가 채워져 있는 이진트리를 완전 이진트리라고 한다. 

- 마지막 레벨을 제외하고 모든 레벨에 노드를 가득 채운다

- 마지막 레벨에 한해서 왼쪽부터 오른쪽으로 노드를 채우되 반드시 끝까지 채우지 않아도 된다.

 

높이가 k인 완전 이진트리가 가질 수 있는 노드의 수는 최대 2**k+1  - 1 개이므로, n개의 노드를 저장할 수 있는 완전 이진트리의 높이는 log n이다.

 

이진 검색 트리는 모든 노드가 다음 조건을 만족해야 한다.

왼쪽 서브트리 노드의 키값은 자신의 노드 키값보다 작아야 한다.
오른쪽 서브트리 노드의 키값은 자신의 노드 키값보다 커야 한다.

 

이진 검색 트리의 특징

  • 구조가 단순하다.
  • 중위 순회의 깊이 우선 검색을 통하여 노드값을 오름차순으로 얻을 수 있습니다.
  • 이진 검색과 비슷한 방식으로 아주 빠르게 검색할 수 있다.
  • 노드를 삽입하기 쉽다.
#이진 검색 트리 구현

from __future__ import annotations
from typing import Any, Type

class Node:
    """이진 검색 트리의 노드"""
    def __init__(self, key, value, left, right) -> None:
        """생성자(constructor)"""
        self.key = key #key
        self.value = value # value
        self.left = left # left pointer
        self.right = right # right pointer

class BinarySearchTree:
    """이진 검색 트리"""

    def __init__(self) -> None:
        self.root = None #root

    def search(self, key):
        """키가 key인 노드를 검색"""
        p=self.root # 루트에 주목
        while True:
            if p is None:
                return None
            
            if key == p.key:
                return p.value
            elif key < p.key:
                p = p.left
            else:
                p = p.right
    
    def add(self, key, value):
        def add_none(node, key, value):
            if key== node.key:
                return False
            elif key < node.key:
                if node.left is None:
                    node.left = Node(key, value, None,None)
                else:
                    add_node(node.left, key, value)
            else:
                if node.right is None:
                    node.right = Node(key, value, None, None)
                else:
                    add_node(node.right, key, value)
            return True

'PROGRAMMING > ALGORITHM' 카테고리의 다른 글

그리디 알고리즘  (1) 2022.12.17
트리 구조  (0) 2022.12.12
셸 정렬/퀵 정렬/병합 정렬/힙 정렬  (0) 2022.12.09
정렬 알고리즘 (버블 정렬/ 선택 정렬/ 삽입 정렬)  (0) 2022.12.09
해시법 (Hashing)  (0) 2022.12.05

리스트가 순서를 매긴 데이터를 나열하는 자료구조라면 , 트리는 데이터 사이의 계층 관계를 표현하는 트리 구조이다.

 

트리에 대해 이해를 하려면 먼저 트리의 구조와 관련된 용어를 알아야 한다.


트리의 구조

 

위 사진과 같이 트리는 노드와 가지(edge)로 구성된다. 각 노드는 가지를 통해 다른 노드와 연결이 된다.

  • 루트 : 트리의 가장 위쪽에 있는 노드를 루트(root)라고 한다. 루트는 트리 하나에 1개만 존재한다.
  • 리프 : 가장 아래쪽에 있는 노드를 리프(leaf)라고 한다. 리프는 단말 노드(terminal node), 외부 노드(external node)라고도 한다. (물리적으로 가장 밑에 위치한다는 의미가 아니라 가지가 더 이상 뻗어나갈 수 없는 마지막에 노드가 있다는 뜻이다.)
  • 비단말 노드 : 리프를 제외한 노드(루트는 포함)를 비단말 노드라고 한다. 비단말 노드는 내부 노드(internal node)라고도 한다.
  • 자식 : 어떤 노드와 가지가 연결되었을 때 아래쪽 노드를 자식(child)라고 한다. 노드는 자식을 몇 개라도 가질 수 있다. 하지만 가장 아래쪽 리프는 자식을 갖지 않는다.
  • 부모 : 어떤 노드와 가지가 연결되었을 때 가장 위쪽에 있는 노드를 부모(parent)라고 한다. 노드의 부모는 하나뿐이다. 루트는 부모를 갖지 않는다.
  • 형제 노드 : 부모가 같은 노드를 형제(sibiling)라고 한다.
  • 레벨 : 루트에서 얼마나 멀리 떨어져 있는지를 나타내는 것이 레벨이다. 가장 위쪽에 있는 루트의 레벨은 0이고, 가지가 하나씩 아래로 뻗어 내려갈 때마다 레벨이 1씩 증가한다.
  • 차수 : 각 노드가 갖는 자식의 수를 차수(degree)라고 한다. 모든 노드의 차수가 n이하인 트리를 n진 트리라고 한다.

순서 트리와 무순서 트리

트리는 형제 노드의 순서 관계가 있는지에 따라 2종류로 분리된다. 형제 노드의 순서 관계가 있으면 순서 트리(ordered tree)라 하고, 구별하지 않으면 무순서 트리(unordered tree)라고 한다.

 

순서 트리의 검색

순서 트리의 노드를 스캔하는 방법은 크게 2가지이다.

1. 너비 우선 검색 (breadth-first search)

2. 깊이 우선 검색(depth-first search)


너비 우선 검색

너비 우선 검색은 폭 우선 검색, 가로 검색, 수평 검색이라고도 한다. 너비 우선 검색은 낮은 레벨부터(루트) 왼쪽에서 오른쪽으로 검색하고, 한 레벨에서 검색을 마치면 다음 레벨로 내려가는 방법이다. 

너비 우선 검색

검색 순서: A  > B > C> D > E > F > G> H > I > J > K > L 


깊이 우선 검색

깊이 우선 검색은 세로 검색, 수직 검색이라고도 한다. 깊이 우선 검색은 리프에 도달할 때까지 아래쪽으로 내려가면서 검색하는 것을 우선으로 하는 방법이다. 리프에 도달해 더 이상 검색할 곳이 없으면 일단 부모 노드로 돌아가고 그 뒤 다시 자식 노드로 내려간다.

깊이 우선 검색

 

어느 시점에서 실제로 노드를 방문하는지에 따라 깊이 우선 검색은 세 종류의 스캔 방법으로 구분한다.

1. 전위 순회 (preorder)노드 방문 -> 왼쪽 자식 -> 오른쪽 자식A > B > D > H > E > I >J > C> F > K > L >G

2. 중위 순회 (inorder)왼쪽 자식 > 노드 방문 >오른쪽 자식H> D > B > I > E> J > A > K > F> L > C > G

3. 후위 순회 (postorder)왼쪽 자식> 오른쪽 자식> 노드 방문H>D>I>J>E>B> K > L >F > G > C > A 

셸 정렬 (shell sort)

시간 복잡도 : O(n**1.25)

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

단순 삽입 정렬의 장단점
장점: 이미 정렬을 마쳤거나 정렬이 거의 끝나가는 상태에서는 속도가 아주 빠르다.
단점: 삽입할 위치가 멀리 떨어져 있으면 이동 횟수가 많아진다. 

 

셸 정렬은 먼저 정렬할 배열의 원소를 그룹으로 나눠 각 그룹별로 정렬을 수행한 뒤 정렬된 그룹을 합치는 작업을 반복하여 원소의 이동 횟수를 줄이는 방법이다. 


퀵 정렬 (quick sort)

시간 복잡도: O(nlog n) / 최악의 시간 복잡도: O(n**2)

퀵 정렬은 가장 빠른 정렬 알고리즘으로 알려져 있으며 널리 사용된다.

(퀵 정렬은 일반적으로 사용되는 아주 빠른 정렬 알고리즘이다.)

퀵 정렬은 그룹을 나눌 때 피벗을 사용한다. 


병합 정렬 (merge sort)

시간 복잡도: O(nlog n)

병합 정렬은 배열의 앞부분과 뒷부분의 두 그룹으로 나누어 각각 정렬한 후 병합하는 작업을 반복하는 알고리즘이다.

병합 정렬

# 정렬을 마친 두 배열의 병합(heapq.merge 사용)

import heapq

a=[2,4,6,8,11,13]
b=[1,2,3,4,9,16,21]
c=list(heapq.merge(a,b)) #배열 a와 b를 병합하여 c에 저장

힙정렬 (heap sort) 

시간 복잡도: O(nlog n)

힙 정렬은 힙의 특성을 이용하여 정렬 하는 알고리즘이다. 

힙은 '부모의 값이 자식노드의 값보다 항상 크다'는 조건을 만족하는 완전 이진 트리이다. 

힙은 형제의 대소 관계가 정해져 있지 않으므로 부분 순서 트리라고도 한다. 

 

힙정렬의 특징으로는 힙에서 최댓값은 루트에 위치한다는 특징이 있다. 

"부모의 값 >= 자식의 값인 관계가 항상 성립."

 

데이터를 일정한 순서로 정렬하는 정렬 알고리즘이 있다. 예를 들어, 정렬은 이름, 학번, 학점 등의 key의 항목 값의 대소관계에 따라 데이터 집합을 일정한 순서로 바꾸어 늘어놓는 작업을 말한다. 

 

데이터를 정렬하면 데이터를 더 쉽게 검색할 수 있다는 장점이 있다. 

-오름차순 ascending order 정렬 

-내림차순 descending order 정렬

 

정렬 알고리즘 가운데 대표적인 알고리즘 8가지가 있다. 정렬 알고리즘은 안정적인 알고리즘과 그렇지 않은 알고리즘으로 나눌 수 있다. 

 

또한 정렬 알고리즘은 하나의 배열에서 작업할 수 있는 경우 내부 정렬(internal sorting)을 사용하고, 그렇지 않은 경우 외부 정렬 (external sorting)을 사용한다.

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

*정렬 알고리즘의 핵심은 교환/선택/삽입 이다. 


대부분의 정렬 알고리즘은 이 3가지를 응용하며, 앞서 말한 대표적인 8가지의 정렬 알고리즘에서도 자주 쓰이는 핵심 개념이다.


버블 정렬

버블 정렬(bubble sort)은 이웃한 두 원소의 대소 관계를 비교하여 필요에 따라 교환을 반복하는 알고리즘이다.

시간 복잡도 : O(n**2)

버블 정렬 예시

def bubbleSort(x):   
	"""버블 정렬 코드 """
	length = len(x)-1
	for i in range(length):
		for j in range(length-i):
			if x[j] > x[j+1]:
				x[j], x[j+1] = x[j+1], x[j]
	return x

선택 정렬 (selection sort) 

선택 정렬은 가장 작은 원소부터 선택해 알맞은 위치로 옮기는 작업을 반복하며 정렬하는 알고리즘이다.

시간 복잡도 : O(n**2)

선택 정렬 교환 과정은 다음과 같이 이루어진다.

1. 아직 정렬하지 않은 부분에서 값이 가장 작은 원소 a[min]을 선택.

2. a[min]과 아직 정렬하지 않은 부분에서 맨 앞에 있는 원소를 교환.

 

#단순 선택 정렬 알고리즘 구현

def selection_sort(a):
    """단순선택 정렬"""
    n= len(a)
    for i in range(n-1):
        min=i #정렬한 부분에서 가장 작은 원소의 인덱스
        for j in range(i+1, n):
            if a[j] < a[min]:
                min= j
        a[i] , a[min] = a[min], a[i] #정렬할 부분에서 맨 앞의 원소와 가장 작은 원소를 교환

삽입 정렬

삽입 정렬은 주목한 원소보다 더 앞쪽에서 알맞은 위치로 삽입하며 정렬하는 알고리즘이다.

단순 선택 정렬과 비슷해 보이지만 값이 가장 작은 원소를 선택하지 않는다는 점에서 차이가 있다.

시간 복잡도 : O(n**2)

 

 

이 글에서 다룬 단순 정렬(버블, 선택, 삽입) 알고리즘의 시간 복잡도는 모두 O(n**2)으로 프로그램 효율이 좋지 않다.

검색과 더불어 데이터의 추가/삭제도 효율적으로 수행할 수 있는 해시법.

원소가 이동하는데 필요한 복잡도는 O(n)이고 그 비용은 결코 작지 않다. 데이터를 삭제하는 경우에도 똑같은 비용이 발생.

해시법이란 ? 

해시법 (hashing)은 데이터를 '저장할 위치=인덱스'를 간단한 연산으로 구하는 것을 말한다. 

이 방법은 원소의 검색뿐 아니라 추가/삭제도 효율적으로 수행할 수 있다. 

해시 테이블 구성

 

배열의 키(원소의 값)를 원소 개수인 13으로 나눈 표다. 여기서 원소 개수인 13을 해시 값(hash value)라고 하고, 해시 값은 데이터를 접근할 때 기준이 된다.

 

위 그림에서 해시값을 인덱스로 하여 원소를 새로 저장한 배열이 해시 테이블(hash table)이다.

해시 테이블을 구성하는 과정에서 키를 해시값으로 변환하는 과정을 해시 함수(hash function)이라고 한다. 

해시 테이블에서 만들어진 원소는 버킷(bucket)이라고 한다.

 

앞서 말했 듯이 해시법은 검색뿐만 아니라 추가/삭제도 효율적으로 할 수 있다고 하였는데 그 예시로 만약 원소 값 35를 추가하고자 한다면 35의 해시 값은 9이므로 인덱스 9에 다른 원소를 이동할 필요없이 바로 추가하기만 하면된다. 


해시 충돌

키와 해시 값은 일반적으로 다대 1(n:1) 관계이다. 이처럼 저장할 버킷이 중복되는 현상을 충돌(collsion)이라고 한다.

*해시 함수는 가능한 한 해시 값이 한쪽으로 치우치지 않고 고르게 분산된 값을 출력되도록 만드는 것이 가장 좋다. 

해시법에서 충돌이 발생하는 경우 대처할 수 있는 두가지 방법이 있다.
1. 체인법: 해시 값이 같은 원소를 연결 리스트로 관리한다.
2. 오픈 주소법: 빈 버킷을 찾을 때까지 해시를 반복한다.

체인법

체인법(chaining)이란 해시 값이 같은 데이터를 체인모양의 연결 리스트로 연결하는 방법을 말하며 오픈 해시법(open hashing)이라고도 한다.

 

체인법으로 해시 구현

 

체인법에서는 해시 값이 같은 데이터를 연결 리스트에 의해 체인 모양을 연결한다. 배열의 각 버킷(해시테이블)에 저장하는 것은 인덱스를 해시 값으로 하는 연결 리스트의 앞쪽 노드를 참조한 것이다.

#체인법으로 해시함수 구현

from __future__ import annotations
from typing import Any,Type
import hashlib

class Node:
    """해시를 구성하는 노드"""

    def __init__(self, key:Any,value: Any, next:Node) -> None:
        """초기화"""
        self.key = key #key
        self.value=value #value
        self.next = next #뒤쪽 노드를 참조.
    
    class ChainedHash:
    """체인법으로 해시 클래스 구현"""

    def __init__(self, capacity: int) -> None:
        """초기화"""
        self.capacity = capacity # hash table의 크기를 지정
        self.table = [None] * self.capacity # 해시 테이블 (리스트)을 선언

    def hash_value(self,key:Any) -> int:
        """해시값을 구함"""
        if isinstance(key, int):
            return key % self.capacity
        return(int(hashlib.sha256(str(key).encode()).hexdigest(),16)%self.capacity)
    
    def search(self, key: Any) -> Any:
        """키가 key인 원소를 검색하여 값을 반환"""
        hash = self.hash_value(key) #검색하는 key의 해시값
        p = self.table[hash] #노드를 주목

        while p is not None:
            if p.key== key:
                return p.value # 검색 성공
            p= p.next #뒤쪽 노드를 주목
        return None #검색 실패
    
    def add(self, key:Any, value: Any) -> bool:
        """키가 key이고 값이 value인 워소를 추가 """
        hash = self.hash_value(key) #추가하는 key의 해시 값
        p= self.table[hash] #노드 주목

        while p is not None:
            if p.key == key:
                return False  #추가 실패
            p = p.next
        
        temp = Node(key, value, self.table[hash])
        self.table[hash] =temp #노드를 추가
        return True
     
         
    def remove(self, key:Any) -> bool:
        """키가 key인 원소를 삭제"""
        hash= self.hash_value(key) #삭제할 key의 해시 값
        p = self.table[hash] #노드를 주목
        pp = None # 바로 앞의 노드를 주목

        while p is not None:
            if p.key == key: #key를 발견하면 아래를 실행 
                if pp is None:
                    self.table[hash] = p.next
                else:
                    pp.next = p.next
                return True #key 삭제 성공
            pp = p
            p = p.next #뒤쪽 노드 주목
        return False #삭제 실패 (key 가 존재하지 않음)
    
    def dump(self) -> None:
        """해시테이블을 덤프"""
        for i in range(self.capacity):
            p=self.table[i]
            print(i, end='')
            while p is not None:
                print(f'  ->{p.key} ({p.value})', end='')
                p.p.next
            print()

오픈 주소법 (open addressing)

해시 충돌이 발생할 때 해결하는 또 다른 방법으로 오픈 주소법이 있다.

오픈 주소법은 충돌이 발생했을 때 재해시(rehashing)을 수행하여 빈 버킷을 찾는 방법을 말하며 닫힌 해시법이라고도 한다.

오픈 주소법은 빈 버킷이 나올 때까지 재해시를 반복하므로 선형 탐사법이라고도 한다.

이진 검색(Binary search)

이진 검색은 일정한 규칙으로 늘어놓은 데이터 집합에서 아주 빠른 검색을 수행한다.

이진 검색 알고리즘을 사용하려면 배열의 데이터가 정렬되어 있어야 하고 이진 검색은 선형 검색보다 빠르게 검색할 수 있다는 장점이 있다. 

*이진 검색은 원소가 오름차순이나 내림차순으로 정렬된 배열에서 좀 더 효율적으로 검색할 수 있는 알고리즘이다.

이진 검색 그림 예시

 

이진 검색의 검색 범위의 맨 앞, 맨 끝, 중앙의 인덱스를 각각 pl,pr,pc라고 하고 중앙 값부터 검색 범위를 계속 좁혀가면 된다. 

# binary search 이진검색 알고리즘

from typing import Any,Sequence

def bin_search(a:Sequence, key:Any) -> int:
    """sequence a에서 key와 일치하는 원소를 이진검색"""
    pl = 0  #검색 범위 맨 앞 원소의 인덱스
    pr = len(a) - 1 # 검색 범위 맨 끝 원소의 인덱스

    while True:
        pc = (pl+pr) //2 #중앙 원소의 인덱스
        if a[pc] == key:
            return pc
        elif a[pc] < key:
            pl = pc+1 #검색 범위를 뒤쪽 절반으로 좁힘
        
        else:
            pr = pc-1 # 검색범위를 앞쪽 절반으로 좁힘
        
        if pl>pr:
            break
        return -1 # fail search

if __name__=='__main__':
    num=int(input('원소수를 입력하세요: '))
    x = [None] * num

    print('배열 데이터를 오름차순으로 입력')

    x[0] = int(input('x[0]: '))

    for i in range(1,num):
        while True:
            x[i] = int(input(f'x[{i}]: '))
            if x[i] >= x[i-1]:   #바로 직전에 입력한 원솟값보다 큰 값을 입력
                break
    
    ky=int(input('put the value: '))

    idx = bin_search(x,ky)

    if idx==-1:
        print('검색값을 갖는 원소가 존재하지 않습니다.')
    else:
        print(f'검색값은 x[{idx}]에 있습니다.')

검색 알고리즘이란 무엇인가? 

Data 집합에서 원하는 값을 가진 원소를 찾아내는 알고리즘이다. 

검색의 종류 

1. 배열 검색

2. 연결 리스트 검색

3. 이진 검색 트리 검색 

 

이 중 오늘 정리할 내용은 바로 '배열 검색'이며 구체적으로 다음과 같은 알고리즘이 있다.

 

1.선형 검색 : 무작위로 늘어놓은 데이터 집합에서 검색을 수행

2. 이진 검색: 일정한 규칙으로 늘어놓은 데이터 집합에서 아주 빠른 검색을 수행

3. 해시법: 추가, 삭제가 자주 일어나는 데이터 집합에서 아주 빠른 검색을 수행

-체인법: 같은 해시값 데이터를 연걸 리스트로 연결하는 방법

-오픈 주소법: 데이터를 위한 해시값이 충돌할 때 재해시하는 방법

 

데이터 집합이 있을 때 , 검색만 빠르게 하고싶다면 계산 시간이 짧은 알고리즘을 선택하면된다.

그러나 데이터 집합에서 검색뿐 아니라 데이터의 추가,삭제 등을 자주 수행해야 한다면 검색 이외의 작업에 들어가는 비용을 종합 평가하여 알고리즘을 선택해야 한다. 


선형 검색 (linear search)

선형 검색은 무작위로 늘어놓은 데이터 집합에서 검색을 수행하며 배열에서 검색하는 방법중 가장 기본적인 알고리즘이다.

직선 모양으로(선형)으로 늘어선 배열에서 검색하는 경우에 원하는 키 값을 가진 원소를 찾을 때까지 맨 앞부터 스캔하여 순서대로 검색한다. 

선형검색 설명 예시

 

선형 검색의 종료 조건에는 두가지가 있다.

1. 검색할 값을 찾지 못하고 배열의 맨 끝을 지나간 경우 

2. 검색할 값과 같은 원소를 찾는 경우

 

*배열 원소의 개수가 n개라면 이 조건을 판단하는 횟수는 평균 n/2번이다. 

#while 문으로 작성한 선형 검색 알고리즘 

from typing import Any,Sequence

def seq_search(a: Sequence, key: Any) -> int:
    """sequence a에서 key와 값이 같은 원소를 선형 검색(while문)"""
    i=0

    while True:
        if i==len(a):
            return -1
        if a[i]==key:
            return i  #검색에 성공하여 현재 검사한 배열의 인덱스를 반환
        
        i+=1

if __name__=='__main__':
    num = int(input('원소 수를 입력하세요: '))
    x = [None] * num

    for i in range(num):
        x[i] = int(input(f'x[{i}]'))

    ky= int(input('검색할 값: ')) #검색할 ky를 입력받음

    idx = seq_search(x,ky) # ky와 값이 같은 원소를 x에서 검색

    if idx == -1:
        print('검색값을 갖는 원소가 존재하지 않습니다.')
    else:
        print(f'검색값은 x[{idx}]에 있습니다.')
#for문으로 작성한 선형 검색 알고리즘
#while 문과 비교해 배열의 스캔을 for문으로 구현하면 코드가 더 짧고 간결해짐.

from typing import Any,Sequence

def seq_search(a:Sequence, key:Any) -> int:
    """시퀀스 a에서 key와 값이 같은 원소를 선형검색"""
    for i in range(len(a)):
        if a[i] == key:
            return i
        return -1
* 배열 맨 앞부터 순서대로 원소를 스캔하는 선형 검색은 원소의 값이 정렬되지 않은 배열에서 검색할 때 사용하는 유일한 방법이다 . 

 

'PROGRAMMING > ALGORITHM' 카테고리의 다른 글

해시법 (Hashing)  (0) 2022.12.05
검색 알고리즘-이진검색(Binary Search)  (0) 2022.12.02
브루트 포스법  (0) 2022.11.15
8퀸 문제 (8-Queen problem)  (0) 2022.11.14
재귀 알고리즘-유클리드 호제법/하노이의 탑  (0) 2022.10.28

문자열에서 부분 문자열을 검색하는 알고리즘으로 브루트 포스법이 있다.

 

문자열 검색(string searching) 은 문자열 안에 다른 문자열이 포함되어 있는지 검사하고 만약 포함되어 있다면 어디에 위치하는지 찾아내는 것을 말한다.

 

예를 들면, 문자열 'STRING' 에서 'IN'을 검색하면 검색에 성공하게 된다. 

여기서 검색되는 쪽의 문자열 즉, 'STRING'을 텍스트 (text) 라고 하고 , 찾아내는 문자열 즉 'IN'을 패턴(pattern)이라고 한다.

 

문자열 검색 알고리즘 중에서 가장 기초적이고 단순한 브루트 포스법(brute force method)은 선형 검색을 단순하게 확장한 알고리즘이라서 단순법이라고 한다.


브루트 포스법

텍스트 'ABABCDEFGHA' 에서 패턴 'ABC'를 브루트 포스법으로 검색하는 순서 알아보기

 

하지만 브루트 포스법은 검사한 위치를 기억하지 못하므로 효율이 좋지 않습니다. 

#브루트 포스법으로 문자열 검색하기

def bf_match(txt,pat):
    """브루트 포스법으로 문자열 검색"""
    pt = 0 #txt를 따라가는 커서
    pp = 0 #pat를 따라가는 커서

    while pt != len(txt) and pp != len(pat):
        if txt[pt] == pat[pp]:
            pt +=1
            pp +=1
        else:
            pt= pt - pp +1
            pp=0

    return pt-pp if pp == len(pat) else-1

if __name__ == '__main__':
    s1 = input('Put the text: ')  #텍스트용 문자열
    s2 = input('Put the pattern: ') #패턴용 문자열

    idx = bf_match(s1, s2)         #문자열 s1 ~ s2를 브루트 포스법으로 검색

    if idx == -1:
        print('텍스트 안에 패턴이 존재하지 않습니다.')
    else:
        print(f'{(idx+1)}번째 문자가 일치합니다.')

8퀸 문제 알아보기 

8퀸 문제는 재귀 알고리즘을 설명할 때 자주 나오는 예제로, 8개의 퀸이 서로 공격하여 잡을 수 없도록 8 x 8 체스판에 배치하는 문제이다. 

 

->결론부터 말하자면 이 문제는 92가지 해결 방법이 나온다. 

8퀸 문제 성공 예제

 

퀸을 배치하는 경우의 수는 총 178,462,987,637,760개로 이 조합을 모두 나열하는 것은 현실적으로 불가능하다고 생각한다.

그렇기에 8퀸 문제를 해결하기위한 두 가지 규칙이 있다.

 

더보기

규칙 1. 각 열에 퀸을 1개만 배치한다.

규칙 2. 각 행에 퀸을 1개만 배치한다. 

이 문제를 코드로 구현해보면 아래와 같이 나온다.

#8퀸 문제 알고리즘 구현하기

pos = [0] * 8  #각 열의 배치한 퀸의 위치
flag_a = [False] * 8 #각 행의 퀸을 배치했는지 체크
flag_b = [False] * 15 #각 대각선 방향(↙↗)으로 퀸을 배치했는지 체크
flag_c = [False] * 15 #각 대각선 방향(↘↖)으로 퀸을 배치했는지 체크

def put() -> None:
    """각 열에 배치한 퀸의 위치를 출력"""
    for i in range(8):
        print(f'{pos[i]:2}', end='')
    print()

def set(i: int) -> None:
    """i열의 알맞는 위치에 퀸을 배치"""
    for j in range(8):
        if(     not flag_a[j] #j행에 퀸이 배치되지 않았다면
            and not flag_b[i+j] #대각선 방향으로 퀸이 배치되지 않았다면
            and not flag_c[i-j+7]): #대각선 방향으로 퀸이 배치도지 않았다면
            pos[i] == j             #퀸을 j행에 배치
            if i ==7:               #모든 열에 퀸을 배치 완료
                put()
            else:
                flag_a[j] = flag_b[i+j] = flag_c[i-j+7] = True
                set(i+1) #다음 열에 퀸을 배치
                flag_a[j] = flag_b[i+j] = flag_c[i-j+7]=False

set(0)       # 0열에 퀸을 배치
재귀 알고리즘이란? 
어떠한 event에서 자기 자신을 포함하고 다시 자기 자신을 사용하여 정의되는 경우 재귀(recrusion)이라고 함.
재귀를 효과적으로 사용하면 이러한 정의뿐만 아니라 프로그램을 간결하고 효율성 좋게 작성 가능. 
재귀 알고리즘은 풀어야 할 문제나 계산할 함수 또는 처리할 자료구조가 재귀적으로 정의되는 경우 적용.

 

def hello(count):
    if count == 0:
        return
    print("Hello",count)
    
    count-=1
    hello(count)

hello(5)
"""재귀 대표적인 예시"""

유클리드 호제법 Euclidean Algorithm

두 정수의 최대 공약수를 재귀적으로 구하기.

두 정수 x와 y의 최대 공약수 gcd(x,y)

ex) x=az 와 y=bz를 만족하는 정수 a,b와 최대의 정수 z가 존재할 때 z는 gcd(x,y) 라고 할 수 있음

- y==0  -> x 

- y!=0 -> gcd(y,x%y) 

 

<코드>

"""유클리드 호제법으로 최대공약수 구하기"""
def gcd(x:int,y:int) -> int:
    """정숫값 x와 y의 최대 공약수 반환"""
    if y==0:
        return x
    else:
        return gcd(y,x %y)

if __name__ == '__main__':
    print('gcd 구하기')
    x=int(input())
    y=int(input())
    print(f'두 정숫값의 최대 공약수는 {gcd(x,y)}.')

하노이의 탑

Towers of Hanoi는 쌓아 놓은 원반을 최소 횟수로 옮기는 알고리즘인 하노이의 탑.

● 작은 원반이 위에, 큰 원반이 아래에 위치

● 크기가 모두 다른 원반 첫번째 기둥에 쌓여있는 상태로 시작.

● 이 상태에서 모든 원반을 세 번째 기둥에 최소 횟수로 옮기기

●  원반은 1개씩 옮길 수 있으며 큰 원반은 작은 원반 위에 쌓을 수 없음.

#하노이의 탑 구현

def move(no: int, x: int, y:int) -> None:
    """원반 no개를 x기둥에서 y기둥으로 옮김"""
    if no > 1:
        move(no-1, x , 6-x-y)

    print(f'원반 [{no}]를 {x}기둥에서 {y}기둥으로 옮깁니다.')

    if no > 1:
        move(no-1,6-x-y,y)

print('하노이의 탑 구현')
n=int(input('원반의 개수 입력: '))

move(n, 1, 3) #1기둥에 쌓인 원반 n개를 3기둥으로 옮김

 

스택(stack)은 데이터를 임시 저장할 때 사용하는  자료구조로, 데이터의 입력과 출력 순서는 후입선출 (LIFO) 방식이다.

*LIFO(last in first out)란 가장 나중에 넣은 데이터를 가장 먼저 꺼낸다는 뜻.

 

스택은 데이터를 삽입하거나 삭제 할 수 있다. 데이터를 넣는 작업을 푸시(push)라 하고 , 스택에서 데이터를 꺼내는 작업을 팝(pop)이라 함. 푸시하고 팝하는 윗부분을 꼭대기(top)이라 하고, 아랫부분을 바닥(bottom)이라고 함.

스택에서 푸시와 팝 하는 과정.


스택 크기: capacity ->len(S)와 일치 . 스택의 최대 크기를 나타내는 int형 정수

스택 포인터:ptr  스택의 쌓여 있는 데이터의 개수를 나타내는 정숫값 

stack 코드로 나타내보기

class Stack:
    def __init__(self):
        self.items= [] #데이터 저장을 위한 리스트
    def push(self , val):
        self.items.append(val)
    def pop(self):
        try:
            return self.items.pop()
        except IndexError:          #pop할 아이템이 없으면 indexerror 발생. 
            print("Stack is empty")    
    def top(self):
        try:
            return self.items[-1]
        except ImportError:
            print("Stack is empty")
    def __len__(self):          #len()로 호출하면 stack의 item수 반환 
        return len(self.items)

S=Stack()
S.push(10) #리스트에 10 추가.
S.push(2)
print(S.pop()) #2
print(S.top()) #10
print(len(S)) #1
#고정 길이 스택 클래스 FixedStack 구현
from typing import Any
class FixedStack: 
    """고정 길이 스택 클래스"""
    class Empty(Exception):
        pass 
    """비어있는 FixedStack에 팝 또는 피크할 떄 내보내는 예외처리"""

    class Full(Exception):
        """"가득 찬 FixedClass에 푸시할 때 내보내는 예외 처리"""
        pass

    def __init__(self, capacity:int = 256) ->None:
        """스택 초기화"""
        self.stk = [None] * capacity #stack 본체
        self.capacity = capacity #stack 의 크기
        self.ptr=0 # stack pointer
    
    def __len__(self) ->int:
        """스택에 쌓여 있는 데이터 개수를 반환"""
        return self.ptr
    
    def is_empty(self) -> bool:
        """스택이 비어있는지 판단"""
        return self.ptr <=0
    def is_full(self) -> bool:
        """스택이 가득 차 있는지"""
        return self.ptr >=self.capacity

예외 처리의 기본 구조 

파이썬에서는 프로그램을 실행하다가 오류가 발생하면 예외 처리 메시지를 내보낼 수 있다.

예외처리를 수행함으로써 오류를 복구하여 프로그램이 실행되다가 중단되는 것을 피할 수 있다.

예외처리는 원래 처리하는 코드와 오류가 발생할 때 대처하는 코드를 분리할 수 있다는 장점이 있다.

try statement 문을 사용.

+ Recent posts