Week-1: 작은 문제: 압축 알고리즘
DSA-알고리즘 스터디

압축 알고리즘

저장 공간이 제한될 경우 파일을 압축할 필요가 있으며, 이 때 압축 알고리즘이 사용된다.

다양한 압축 알고리즘이 존재하지만, 궁극적으로는 다음 기능을 구현해야 한다.

  • 압축(compress)

    부호화(encoding) 과정을 거쳐 원본 파일을 압축 파일로 변환한다.

  • 압축 풀기(decomporess)

    복호화(decoding) 과정을 거쳐 압축 파일을 원본 파일로 변환한다.


유전 정보 압축

DNA는 4가지 종류(A, C, G, T)의 뉴클레오타이드(nucleotide)로 구성된다.

만약 n개의 뉴클레오타이드로 구성된 염기서열을 문자열로 작성할 경우, 각 문자의 크기는 8비트이므로 총 8n비트의 저장공간이 필요하다. (UTF-8 기준)

문자 8bit
A 01000001
C 01000011
G 01000111
T 01010100

하지만 4가지 종류의 문자를 나타내는 데에는 2개의 비트만 있어도 충분하다.

각 문자의 크기를 2비트로 압축한다면 필요한 저장공간은2n비트로 감소하며, 최대 75%의 저장 공간을 절약할 수 있다.

문자 2bit
A 00
C 01
G 10
T 11

이 아이디어로 유전 정보를 압축하는 알고리즘을 작성한다.

1. compress

뉴클레오타이드의 수만큼 2비트 문자열을 추가하며, 추가할 때마다 쉬프트 연산자 <<를 사용해 2비트씩 왼쪽으로 이동한다.

예를 들어 문자열 ATGC을 인수로 넘기면, 대응하는 2비트 문자열을 초기값 1 뒤에 차례대로 추가한다.

비트 단위 연산에서 비트 추가는 |= 연산자를 사용한다.

압축 과정을 파이썬으로 구현하면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
def compress(gene:str) -> str:
    bit_string:int = 1
    for nucleotide in gene.upper():
        bit_string <<= 2
        if   nucleotide == "A": bit_string |= 0b00
        elif nucleotide == "C": bit_string |= 0b01
        elif nucleotide == "G": bit_string |= 0b10
        elif nucleotide == "T": bit_string |= 0b11
        else: 
            raise ValueError(f"Invalid nucleotide: { nucleotide }")
    return bit_string
1
2
3
4
5
6
from sys import getsizeof

original = "ATGC"
compressed = compress(original)
print(f"{ original }: { getsizeof(original) }바이트")
print(f"{ bin(compressed) }: { getsizeof(compressed) }바이트")
1
2
ATGC: 53바이트
0b100111001: 28바이트

sys.getsizeof() 메서드를 사용해 바이트를 확인할 수 있다.

파이썬 객체 시스템의 내제된 오버헤드 때문에 저장공간의 크기를 28바이트 미만으로 줄일 수는 없다.

bit_string의 초기값을 0으로 설정해도 괜찮지 않을까? 단순히 첫 번째 자리만 1에서 0으로 바뀐 값인 000111001을 반환할 것이라고 예측할 수도 있다. 하지만 반환되는 값은 111001이다.

이유는 간단하다. bit_string은 정수형이기 때문에 0으로 시작하는 앞 자릿수를 모두 제거해 반환하기 때문이다. 따라서 변환 오류를 방지하기 위해서 bit_string의 초기값은 반드시 1로 설정해야 한다.

2. decompress

bit_string의 끝 부분부터 2개씩 비트 문자열을 읽은 뒤, 뉴클레오타이드로 변환한 값을 gene에 추가한다.

처음 bit_string을 만들 때 비트 1로 시작했기 때문에 해당 비트를 제외해야 하므로, 전체 길이에서 1을 뺀 값만큼 반복을 시행한다.

복호화 과정에서 역순으로 읽었기 때문에 gene에는 뒤집힌 값이 저장되어 있다. 따라서 [::1]을 사용해 원래 값으로 변환한 뒤 반환해야 한다.

압축 해제 과정을 파이썬으로 구현하면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
def decompress(bit_string:str) -> str:
    gene:str = ""
    for i in range(0, bit_string.bit_length() - 1, 2):
        bits:int = bit_string >> i & 0b11
        if   bits == 0b00: gene += "A"
        elif bits == 0b01: gene += "C"
        elif bits == 0b10: gene += "G"
        elif bits == 0b11: gene += "T"
        else: 
            raise ValueError(f"Invalid bits: { bits }")
    return gene[::-1] # Reverse
1
2
decompressed = decompress(compressed)
print(f"{ decompressed }: { getsizeof(decompressed) }")
1
ATGC: 53

원본 데이터 original와, 압축 후 해제 과정을 거친 데이터 decompressed가 같음을 확인할 수 있다.

3. 시간 복잡도 분석

n: 부호화 및 복호화 과정에서의 입력값의 길이

메서드 시간복잡도
compress O(n)
decompress O(n)

4. Dictionary vs If statement

조건문 대신 파이썬의 사전 자료형를 사용해 구현할 수도 있다. 이 때 Dictionary는 뉴클레오타이드와 그에 해당하는 비트를 각각 Key와 Value로 갖는다.

조건문을 사용하면 각 반복 당 최대 4번 비교해야 하며, 사전 자료형을 사용하면 해시 충돌이 발생할 수 있다.

하지만 결과적으로는 두 방법 모두 O(n)의 시간 복잡도를 가진다.


허프만 부호화

유전 데이터는 4가지 종류의 문자로만 구성된 고정 길이 코드(fixed length code)이다. 압축 과정이 어렵지 않지만 문자의 종류가 많아질수록 이 방법은 한계에 부딪힌다. 예를 들어 ASCII 문서는 최대 128가지의 문자로 구성되며, 유니코드의 경우 한글 수만 해도 11,172개이다. 각 문자에 해당하는 비트 문자열을 일일이 지정하는 것은 비효율적이며 높은 압축률을 기대할 수 없다.

가변 길이 코드(variable length code)를 사용하면 고정 길이 코드의 문제점을 해결할 수 있다. 대표적인 알고리즘인 허프만 부호화(Huffman coding) 과정에서는 각 문자의 등장 빈도를 바탕으로 트리를 만든 뒤, 노드의 위치에 따라 서로 다른 비트 문자열을 부여한다. 이 때 부여되는 가변 길이 코드는 어떤 비트 문자열이 다른 비트 문자열의 접두어가 될 수 없다는 규칙을 갖는 접두어 코드(prefix code)이다.

예를 들어 문자 A와 B에 다음과 같은 비트 문자열을 부여한다. 이 때 11은 110의 접두어이므로 접두어 코드의 조건을 만족하지 못한다.

1
2
A: 11
B: 110

접두어 코드를 사용하면 복호화 과정에서 빠른 해석을 할 수 있다.

1. 과정

허프만 부호화 과정을 통해 문자열 GOOGOLPLEX를 압축한다.

우선 주어진 문자열을 문자 단위로 분해한 다음, 각 문자가 등장하는 빈도수를 기준으로 오름차순으로 정렬한다.

데이터 빈도수
E 1
P 1
X 1
G 2
L 2
O 3

가장 작은 빈도수를 가진 두 문자 E, P를 자식 노드로 갖는 서브트리를 만든다. 이후 빈도수를 기준으로 다시 오름차순으로 정렬한다. 트리가 완성될 때까지 이 과정을 반복한다.

완성된 허프만 트리에서 데이터 노드는 항상 리프에 위치한다.

특정 노드에 해당하는 비트 문자열을 구하기 위해서는 루트에서 노드까지의 경로를 모두 계산해야 한다. 이 때 루트를 기준으로 왼쪽 노드는 0, 오른쪽 노드는 1의 비트를 갖는다. 예를 들어 데이터 E가 위치한 노드의 비트 문자열은 루트에서 출발해 오른쪽(1) -> 왼쪽(0) -> 오른쪽(1) -> 왼쪽(0)의 순서로 이동 후 각각의 비트를 모두 합친 값인 1010이다.

데이터 비트 문자열
E 1010
P 1011
X 100
G 00
L 01
O 11

데이터의 빈도수가 높을수록 짧은 비트 문자열이 부여된 것을 확인할 수 있다. 또한 각 비트 문자열은 접두어 코드의 조건을 만족한다.

2. 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
from copy import deepcopy
from sys import getsizeof

def find_key(dict, val):
  return next(key for key, value in dict.items() if value == val)

class TreeNode:
    def __init__(self, key, value, left=None, right=None, parent=None):
        self.key = key
        self.value = value
        self.left   = left   if (left   == None) else left.key 
        self.right  = right  if (right  == None) else right.key
        self.parent = parent if (parent == None) else parent.key

    def __repr__(self):
        return f'({self.key}:{self.value} left<{self.left}>-right<{self.right}>-parent<{self.parent}>)\n'

    def set_parent(self, parent):
        self.parent = parent.key

    def get_left(self):
        return self.left

    def get_right(self):
        return self.right

class HuffTree:
    def __init__(self, text:str):
        self.text = text
        self.frequency_map = self.get_frequency_map()
        self.huffTree = self.get_huffTree()
        self.binary_map = self.get_binary_map()
        self.compressed = self.compress()
        self.compressed_bin = bin(self.compressed)
        
    def get_frequency_map(self) -> dict[str:int]:
        frequency_map:dict[str:int] = {}
        for i in self.text:
            if i not in frequency_map:
                frequency_map[i] = 1
            else:
                frequency_map[i] += 1
        return frequency_map
        
    def get_huffTree(self) -> dict[str:TreeNode]:
        frequency_map:dict = self.frequency_map
        x_list:list = list(frequency_map.keys())
        LENGTH = deepcopy(len(frequency_map))
        # -------------------------------------------------- 허프만 트리 생성
        hTree:dict[str:TreeNode] = {}
        for i in range(len(frequency_map)):
            ky = x_list[i]
            vl = frequency_map[ky]
            hTree[ky] = TreeNode(ky, vl)
        # -------------------------------------------------- 갱신 루프
        for x in range(LENGTH - 1):
            print('-'*40,f'\n루프 {x}')
            # -------------------------------------------------- 1. 작업 목록 정렬
            print(f'정렬 전 : {frequency_map}')
            frequency_map = dict(sorted(frequency_map.items(), key=lambda item: item[1])) # ?
            x_list = list(frequency_map.keys())
            print(f'정렬 후 : {frequency_map}')
            # -------------------------------------------------- 2. 노드 병합
            first, second = x_list[0], x_list[1]
            newNodeKey = f'*{x+1000}'
            newNodeValue = frequency_map[first] + frequency_map[second]
            print(f'노드 병합 : {first} + {second} -> {newNodeKey}')
            # -------------------------------------------------- 3. 허프만 트리 갱신
            hTree[newNodeKey] = TreeNode(newNodeKey, newNodeValue, hTree[first], hTree[second])
            hTree[first].set_parent(hTree[newNodeKey])
            hTree[second].set_parent(hTree[newNodeKey])
            print(f'갱신된 허프만 트리 :\n {hTree}')
            # -------------------------------------------------- 4. 작업 목록 갱신
            del frequency_map[first]
            del frequency_map[second]
            frequency_map[newNodeKey] = newNodeValue
        return hTree

    def get_binary_map(self) -> dict[str:int]:
        binary_map:dict[str:int] = {}
        for data in self.frequency_map:
            data_key = deepcopy(data)
            nes = ""
            while True:
                temp = self.huffTree[data].parent
                if temp:
                    if data == self.huffTree[temp].left:
                        nes += "0"
                    else:
                        nes += "1"
                    data = temp
                else:
                    binary_map[data_key] = nes[::-1]
                    break
        return binary_map

    def compress(self) -> int:
        compressed:int = 1
        for char in self.text:
            bit = self.binary_map[char]
            compressed <<= len(bit)
            compressed |= int(bit, 2)
        return compressed

    def decompress(self) -> str:
        decompressed:str = ""
        bit_list = list(self.binary_map.values())
        check_list = self.compressed_bin[3:]
        while len(check_list) >= 1:
            temp = ""
            for i in check_list:
                temp += i
                if temp in bit_list:
                    check_list = check_list[len(temp):]
                    break
                continue
            decompressed += find_key(self.binary_map, temp)
        return decompressed

def find_key(dict, val):
  return next(key for key, value in dict.items() if value == val)

3. 실행

1
2
3
4
5
6
7
8
9
10
11
12
13
if __name__ == "__main__":
    original = "GOOGOLPLEX"
    # original = 'In the beginning God created the heavens and the earth.Now the earth was formless and empty, darkness was over the surface of the deep, and the Spirit of God was hovering over the waters.And God said, "Let there be light," and there was light.God saw that the light was good, and He separated the light from the darkness.God called the light "day," and the darkness he called "night." And there was evening, and there was morning--the first day.'
    machine = HuffTree(original)

    compressed= machine.compress()
    decompressed = machine.decompress()

    print(f'    original: <{ getsizeof(original)     }바이트> { original     } ')
    print(f'  compressed: <{ getsizeof(compressed)   }바이트> { compressed   } ')
    print(f'decompressed: <{ getsizeof(decompressed) }바이트> { decompressed } ')

    print(original == decompressed)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
루프 0
정렬 전 : {'G': 2, 'O': 3, 'L': 2, 'P': 1, 'E': 1, 'X': 1}
정렬 후 : {'P': 1, 'E': 1, 'X': 1, 'G': 2, 'L': 2, 'O': 3}
노드 병합 : P + E -> *1000
갱신된 허프만 트리 :
 {'G': (G:2 left<None>-right<None>-parent<None>)
, 'O': (O:3 left<None>-right<None>-parent<None>)
, 'L': (L:2 left<None>-right<None>-parent<None>)
, 'P': (P:1 left<None>-right<None>-parent<*1000>)
, 'E': (E:1 left<None>-right<None>-parent<*1000>)
, 'X': (X:1 left<None>-right<None>-parent<None>)
, '*1000': (*1000:2 left<P>-right<E>-parent<None>)        
}
---------------------------------------- 
루프 1
정렬 전 : {'X': 1, 'G': 2, 'L': 2, 'O': 3, '*1000': 2}    
정렬 후 : {'X': 1, 'G': 2, 'L': 2, '*1000': 2, 'O': 3}    
노드 병합 : X + G -> *1001
갱신된 허프만 트리 :
 {'G': (G:2 left<None>-right<None>-parent<*1001>)
, 'O': (O:3 left<None>-right<None>-parent<None>)
, 'L': (L:2 left<None>-right<None>-parent<None>)
, 'P': (P:1 left<None>-right<None>-parent<*1000>)
, 'E': (E:1 left<None>-right<None>-parent<*1000>)
, 'X': (X:1 left<None>-right<None>-parent<*1001>)
, '*1000': (*1000:2 left<P>-right<E>-parent<None>)        
, '*1001': (*1001:3 left<X>-right<G>-parent<None>)        
}
----------------------------------------
루프 2
정렬 전 : {'L': 2, '*1000': 2, 'O': 3, '*1001': 3}
정렬 후 : {'L': 2, '*1000': 2, 'O': 3, '*1001': 3}
노드 병합 : L + *1000 -> *1002
갱신된 허프만 트리 :
 {'G': (G:2 left<None>-right<None>-parent<*1001>)
, 'O': (O:3 left<None>-right<None>-parent<None>)
, 'L': (L:2 left<None>-right<None>-parent<*1002>)
, 'P': (P:1 left<None>-right<None>-parent<*1000>)
, 'E': (E:1 left<None>-right<None>-parent<*1000>)
, 'X': (X:1 left<None>-right<None>-parent<*1001>)
, '*1000': (*1000:2 left<P>-right<E>-parent<*1002>)
, '*1001': (*1001:3 left<X>-right<G>-parent<None>)
, '*1002': (*1002:4 left<L>-right<*1000>-parent<None>)      
}
----------------------------------------
루프 3
정렬 전 : {'O': 3, '*1001': 3, '*1002': 4}
정렬 후 : {'O': 3, '*1001': 3, '*1002': 4}
노드 병합 : O + *1001 -> *1003
갱신된 허프만 트리 :
 {'G': (G:2 left<None>-right<None>-parent<*1001>)
, 'O': (O:3 left<None>-right<None>-parent<*1003>)
, 'L': (L:2 left<None>-right<None>-parent<*1002>)
, 'P': (P:1 left<None>-right<None>-parent<*1000>)
, 'E': (E:1 left<None>-right<None>-parent<*1000>)
, 'X': (X:1 left<None>-right<None>-parent<*1001>)
, '*1000': (*1000:2 left<P>-right<E>-parent<*1002>)
, '*1001': (*1001:3 left<X>-right<G>-parent<*1003>)
, '*1002': (*1002:4 left<L>-right<*1000>-parent<None>)      
, '*1003': (*1003:6 left<O>-right<*1001>-parent<None>)      
}
----------------------------------------
루프 4
정렬 전 : {'*1002': 4, '*1003': 6}
정렬 후 : {'*1002': 4, '*1003': 6}
노드 병합 : *1002 + *1003 -> *1004
갱신된 허프만 트리 :
 {'G': (G:2 left<None>-right<None>-parent<*1001>)
, 'O': (O:3 left<None>-right<None>-parent<*1003>)
, 'L': (L:2 left<None>-right<None>-parent<*1002>)
, 'P': (P:1 left<None>-right<None>-parent<*1000>)
, 'E': (E:1 left<None>-right<None>-parent<*1000>)
, 'X': (X:1 left<None>-right<None>-parent<*1001>)
, '*1000': (*1000:2 left<P>-right<E>-parent<*1002>)
, '*1001': (*1001:3 left<X>-right<G>-parent<*1003>)
, '*1002': (*1002:4 left<L>-right<*1000>-parent<*1004>)     
, '*1003': (*1003:6 left<O>-right<*1001>-parent<*1004>)     
, '*1004': (*1004:10 left<*1002>-right<*1003>-parent<None>) 
}
1
2
3
4
    original: <59바이트> GOOGOLPLEX
  compressed: <28바이트> 65782302
decompressed: <59바이트> GOOGOLPLEX
True

허프만 트리를 만드는 과정을 출력한다. 이후 원본 데이터를 압축하고 해제하기까지의 각각의 과정의 바이트를 표시하고 원본과 결과물이 같음을 증명한다.


Reference