[10/20] 딕셔너리

글을 시작하기에 앞서 해당 시리즈는 Allen Downey, Ben Lauwens의 저서인 Think Julia: How to Think Like a Computer Scientist 를 바탕으로 작성된 글임을 알려드립니다.

이 포스트는 Dictionaries를 한글로 요약 정리한 글입니다.

딕셔너리 (Dictionaries)

이번 장에서는 줄리아에 내장된 다른 데이터 타입인 딕셔너리에 대해서 알아볼 것이다.

딕셔너리는 매핑(mapping)이다

딕셔너리는 배열 같지만 좀 더 일반적이다. 배열에서는 인덱스가 무조건 정수여야 하지만, 딕셔너리에서는 어떤 타입도 인덱스로 사용될 수 있다.

딕셔너리는 키(key)라고 불리는 인덱스들의 모음과 값들의 모음을 포함한다. 각각의 키는 하나의 값과 연결되어 있다. 키와 값의 연결을 키-값 페어(key-value pair) 또는 아이템(items)이라고 부른다.

수학적 용어에서 딕셔너리는 키에서 값으로의 매핑을 나타내므로 각 키가 값에 매핑한다고 말할 수도 있다. (여기서 매핑이란 원하는 데이터를 찾아주는 과정을 말한다) 예를 들어 영어에서 스페인어로 번역해주는 맵인 딕셔너리를 만든다면, 키와 값은 모두 문자열이어야 한다.

Dict()는 아이템 없이 빈 딕셔너리를 만든다. Dict는 내장 함수 이름이기 때문에, 변수를 만들 때는 이 이름을 사용하면 안된다.

1
2
julia> eng2sp = Dict()
Dict{Any,Any} with 0 entries

딕셔너리의 종류는 중괄호로 묶어서 표현된다. 위의 예시에서는 키와 값의 데이터 타입이 모두 Any이다.

위의 딕셔너리는 비어있다. 딕셔너리에 아이템을 추가하기 위해서는 대괄호를 사용하면 된다.

1
julia> eng2sp["one"] = "uno";

위 코드는 "one"이라는 키에 "uno"라는 값을 매핑하여 아이템을 생성한다. 이후 딕셔너리를 출력해보면 키-값 사이에 => 화살표가 나타남을 볼 수 있다.

1
2
3
julia> eng2sp
Dict{Any,Any} with 1 entry:
"one" => "uno"

위의 출력된 형태는 입력할 때도 사용할 수 있다. 예를 들어, 3개의 아이템을 딕셔너리에 추가해보자.

1
2
3
4
5
julia> eng2sp = Dict("one" => "uno", "two" => "dos", "three" => "tres")
Dict{String,String} with 3 entries:
"two" => "dos"
"one" => "uno"
"three" => "tres"

모든 키와 값은 문자열이다. 따라서 Dict{String,String}으로 생성된다.

WARNING 키-값 페어의 순서가 같지 않을 수 있다. 즉, 컴퓨터에 위의 동일한 예제를 입력해도 순서는 다르게 나타날 수 있다.

하지만 딕셔너리의 요소들은 정수 인덱스로 저장되지 않기 때문에 순서는 문제가 되지 않는다. 대신에 키를 통해서 값을 찾을 수 있다.

1
2
julia> eng2sp["two"]
"dos"

키 "two"는 "dos"와 매핑되어 있기 때문에 항상 같은 값을 제공한다.

만약 입력한 키가 딕셔너리에 없다면, 아래와 같은 오류를 만날 수 있다.

1
2
julia> eng2sp["four"]
ERROR: KeyError: key "four" not found

lenth()는 딕셔너리에서도 작동한다. 이 함수는 키-값 페어의 개수를 반환한다.

1
2
julia> length(eng2sp)
3

keys()는 딕셔너리의 키들(keys)만 보여준다.

1
2
3
4
julia> ks = keys(eng2sp);

julia> print(ks)
["two", "one", "three"]

키들의 모음을 변수로 만들었으니, 이제는 연산자를 사용하여 해당 키가 존재하는지 여부를 살펴볼 수 있다.

1
2
3
4
julia> "one" ∈ ks
true
julia> "uno" ∈ ks
false

딕셔너리에 있는 값만을 보기 위해서는 값들의 모음을 반환하는 values()를 사용하면 된다. 그리고 연산자를 사용하여 해당 값이 존재하는지 확인할 수 있다.

1
2
3
4
julia> vs = values(eng2sp);

julia> "uno" ∈ vs
true

연산자는 배열과 딕셔너리에서 다른 알고리즘을 사용한다. 배열에서는 검색파트에서와 같이 배열의 요소들을 순서대로 검색한다. 따라서 배열이 길수록, 검색시간은 더 길어질 수밖에 없다.

하지만 딕셔너리에서는 줄리아가 해시테이블(hash table)이라는 알고리즘을 사용한다. 따라서 딕셔너리에서 연산자는 어떤 아이템을 요구하던 호출하는 시간이 동일하다.

딕셔너리로 카운팅하기

만약 문자가 주어지고 각 문자가 몇 번 나타내는지를 세고 싶다고 가정해보자. 그렇다면 해결할 수 있는 방법으로는 몇 가지가 있다.

  • 알파벳마다 하나씩 26개의 변수를 만들고, 각 문자에 대해 조건문을 사용하고 문자열을 순회하여 카운터를 증가하기
  • 26개의 요소로 배열을 만든 다음 내장 함수 Int()를 사용하여 각 문자열을 숫자로 변환한 후, 인덱스로 사용하여 카운터를 증가하기
  • 문자를 키로, 카운터를 해당 값으로 사용하여 딕셔너리를 만들고 문자가 나올 때마다 값을 증가하기. 딕셔너리에 없는 문자가 나오면 키로 저장하게 설정하면 좋다.

위의 세 가지의 방법들은 똑같은 결과를 보여주지만, 구현하는 방법은 모두 다르다.

구현(implementation)은 계산을 수행하는 방법이다. 몇몇의 구현은 다른 것들보다 더 나은 경우도 있다. 예를 들어 딕셔너리 구현의 장점은 문자열에 어떤 문자가 나타나는지 미리 알 필요가 없으며, 나타나는 문자를 위한 공간만 만들면 된다는 점이다.

아래의 코드가 그렇다.

1
2
3
4
5
6
7
8
9
10
11
function histogram(s)
d = Dict()
for c in s
if c ∉ keys(d)
d[c] = 1
else
d[c] += 1
end
end
d
end

함수 이름은 histogram()으로, 빈도 모음에 대한 통계 용어이다. 함수의 첫 번째 줄은 빈 딕셔너리를 만든다. for루프는 문자열을 통과하며, 루프는 문자 c가 딕셔너리에 없다면 키 c와 초기 값 1을 새 항목으로 만든다. 만약 c가 이미 딕셔너리에 있다면 d[c]를 증가시킨다.

위 함수를 실행한 결과는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
julia> h = histogram("brontosaurus")
Dict{Any,Any} with 8 entries:
'n' => 1
's' => 2
'a' => 1
'r' => 2
't' => 1
'o' => 2
'u' => 2
'b' => 1

히스토그램은 위와 같이 문자가 사용된 개수를 정확하게 측정하였다.

딕셔너리는 키와 기본값을 사용하는 get()가 있다. 키가 딕셔너리에 나타나면 get()은 연결된 값을 보여주며, 만약 연결된 값이 없다면 기본값을 보여준다.

1
2
3
4
5
6
7
julia> h = histogram("a")
Dict{Any,Any} with 1 entry:
'a' => 1
julia> get(h, 'a', 0)
1
julia> get(h, 'b', 0)
0

루핑과 딕셔너리

for문 안에서 딕셔너리 키들을 순회할 수 있다. 예를 들어서 printhist()는 각 키에 연결된 값들을 출력한다.

1
2
3
4
5
function printhist(h)
for c in keys(h)
println(c, " ", h[c])
end
end

출력 결과는 아래와 같다.

1
2
3
4
5
6
7
8
julia> h = histogram("parrot");

julia> printhist(h)
a 1
r 2
p 1
o 1
t 1

다시 말하지만 키는 특정한 순서를 가지고 있지 않다. 키를 순서대로 순회하기 위해서는 sort()collect()를 결합해야 한다. collect()는 키들을 문자로 변경하고, sort()는 문자들을 순서대로 정렬한다.

1
2
3
4
5
6
7
8
julia> for c in sort(collect(keys(h)))
println(c, " ", h[c])
end
a 1
o 1
p 1
r 2
t 1

역방향 조회

d라는 딕셔너리와 k라는 키가 주어졌일 때, 해당된 값을 찾는 방법은 v=d[k]이다. 이런 작업을 조회(lookup)라고 부른다.

하지만 값을 가진 상태에서 키를 찾고 싶다면 어떻게 해야할까? 이 상황에서는 두 가지의 문제가 있다. 첫 번째 문제는 동일한 값을 매핑한 키가 두 개 이상일 수도 있다는 점이다. 프로그램에 따라 하나를 선택하거나 해당되는 모든 키가 포함된 배열을 만들어야 할 수도 있다. 두 번째는 역방향 조회를 수행하는 간단한 문법은 없다는 점이다. 따라서 우리가 직접 검색해야 한다.

아래의 함수는 값을 받아서 키를 돌려준다.

1
2
3
4
5
6
7
8
function reverselookup(d, v)
for k in keys(d)
if d[k] == v
return k
end
end
error("LookupError")
end

위 함수는 검색 패턴의 또 다른 예이지만 이전에는 볼 수 없었던 error()를 보여준다. error()는 정상적인 제어의 흐름을 방해하는 ErrorException을 생성하는데 사용된다. 이번 예시에서는 키가 존재하지 않음을 나타내는 "LookupError" 메시지가 표시된다.

루프의 마지막까지 작동된다면 값 v는 딕셔너리 안에 없는 것이기 때문에 오류 메시지를 전송하는 것이다.

역방향 조회를 성공한 예시는 아래와 같다.

1
2
3
4
julia> h = histogram("parrot");

julia> key = reverselookup(h, 2)
'r': ASCII/Unicode U+0072 (category Ll: Letter, lowercase)

실패한 예시도 확인해보자.

1
2
julia> key = reverselookup(h, 3)
ERROR: LookupError

예외를 알려주는 형태는 줄리아가 예외를 알려주는 방식과 동일하다. 즉, 스택트레이스와 오류 메시지를 인쇄하는 것이다.

줄리아는 역방향 조회를 수행하는 최적화된 방법인 findall(isequal(3), h)을 제공한다.

WARNING 역방향 조회는 순방향 조회보다 훨씬 느리다. 만약 이 방법을 자주 실행하거나 딕셔너리가 크다면, 프로그램이 성능이 저하된다.

딕셔너리와 배열

배열은 딕셔너리에서 값들로 나타낼 수 있다. 예를 들어, 알파벳(키)이 몇 번 쓰였는지 나타낸 빈도(값)로 매핑되는 딕셔너리가 제공된 경우 이를 반대로도 매핑할 수 있다. 즉, 빈도에서 문자를 매핑한 딕셔너리를 만드는 것이다. 사용 빈도가 동일한 알파벳이 여러 개 있을 수 있으므로 반전된 딕셔너리의 각 값은 문자 배열이어야 한다.

아래의 함수는 딕셔너리를 뒤집어준다.

1
2
3
4
5
6
7
8
9
10
11
12
function invertdict(d)
inverse = Dict()
for key in keys(d)
val = d[key]
if val ∉ keys(inverse)
inverse[val] = [key]
else
push!(inverse[val], key)
end
end
inverse
end

루프가 실행될 때마다 keysd에서 key를 얻고, val은 그 key와 매핑된 값을 얻는다. 만약에 valinverse에 없다면 아직 val과 겹치는 키가 없다는 의미이기 때문에 새 항목을 만들고 초기화한다. 그렇지 않다면 이미 동일한 키가 있는 것이기에 해당 키를 배열에 추가해준다.

예시는 아래와 같다.

1
2
3
4
5
6
julia> hist = histogram("parrot");

julia> inverse = invertdict(hist)
Dict{Any,Any} with 2 entries:
2 => ['r']
1 => ['a', 'p', 'o', 't']
state diagrams

위의 상태 다이어그램은 histinverse를 보여준다. 딕셔너리는 키-값 페어가 한 박스 안에서 보여진다. 따라서 만약 값이 정수이거나 소수, 문자열이라면 박스 안쪽에 그려야 한다. 하지만 배열의 경우에는 모두 분리된 박스로 그려야 한다. 위의 그림에서도 딕셔너리와 배열을 구분할 수 있다.

Note 앞서 해시테이블을 사용하여 딕셔너리를 구현했으며, 키를 해시할 수 있어야 함을 언급했다. 해시는 값을 취하고 정수를 반환하는 함수이다. 딕셔너리는 이런 해시 함수의 원리를 사용하여 키-값 쌍을 저장하고 조회한다.

메모

우리는 5장 연습해보기에서 피보나치 함수에 대해 살펴보았다. 위의 피보나치 함수는 인수가 클수록 함수를 실행하는 데 시간이 오래 걸린다는 걸 알 수 있다. 더욱이 런타임도 빠르게 증가한다.

왜 그런지 이해하기 위해 피보나치의 n값이 4일 때의 콜 그래프(Call graph)를 확인해보자.

call graph

콜 그래프는 함수가 호출하는 흐름을 화살표로 보여준다. 그래프 맨 위에서 n = 4인 피보나치가 n = 3, n = 2인 피보나치를 호출한다. 그 후 n = 3인 피보나치가 n = 2n = 1인 피보나치를 호출한다. 이런 흐름이 계속된다.

결국 fibonacci(0)fibonacci(1)가 몇 번 호출되는지 계산한다. 이것은 문제에 대한 비효율적인 해결방법이며, 인수인 n이 커질수록 상황은 더욱 악화된다.

한 가지 해결책은 딕셔너리에 저장하여 이미 계산된 값을 추적하는 것이다. 나중에 사용하기 위해 이전에 계산된 값을 메모라고 한다. 피보나치의 메모가 추가된 버전은 다음과 같다.

1
2
3
4
5
6
7
8
9
10
known = Dict(0=>0, 1=>1)

function fibonacci(n)
if n ∈ keys(known)
return known[n]
end
res = fibonacci(n-1) + fibonacci(n-2)
known[n] = res
res
end

known은 이미 사용된 피보나치 숫자들을 저장하는 딕셔너리이다. 두 개의 항목으로 시작하며, 00에 매핑되고 11에 매핑된다.

fibonacci()를 호출할 때마다 known을 확인한다. 만약 해당 결과가 이미 known에 있다면 즉시 결과를 출력한다. 결과가 없다면 새로운 값을 계산하여 딕셔너리에 추가한 후 결과를 출력한다.

메모가 추가된 fibonacci()와 원래 fibonacci()를 비교해보면, 무엇이 더 빠른지 확인할 수 있다.

글로벌 변수들

이전의 예시에서 known은 함수 밖에서 만들어졌기 때문에 Main이라는 특별한 프레임에 속한다. Main의 변수는 가끔 글로벌(global)이라고 불리는데, 그 이유는 그들이 어떤 함수에서 엑세스 할 수 있기 때문이다. 함수 작동이 멈추면 사라지는 로컬(local) 변수들과 달리, 글로벌 변수는 계속 유지된다.

플래그(flags)에 글로벌 변수를 사용하는 것은 일반적이다. 즉, 조건이 참인지 여부를 나타내는 불 변수이다. 예를 들어, 몇몇의 프로그램은 출력 세부 사항의 수준을 제어하기 위해서 verbose라는 플래그를 사용한다.

1
2
3
4
5
6
7
verbose = true

function example1()
if verbose
println("Running example1")
end
end

글로벌 변수를 재할당하려 하면 놀랄 수 있다. 다음 예제를 확인해보자.

1
2
3
4
5
been_called = false

function example2()
been_called = true # WRONG
end

위의 함수를 작동해도 been_called의 값은 변하지 않는다. 그 이유는 example2()been_called라는 새로운 로컬 변수를 작성하기 때문이다. 함수가 끝나면 로컬 변수는 사라지고 글로벌 변수에는 영향을 미치지 않는다.

글로벌 변수를 함수 안에서 재할당하려면 먼저 변수 앞에 global을 써서 선언해야 한다.

1
2
3
4
5
6
been_called = false

function example2()
global been_called
been_called = true
end

글로벌 명령문은 인터프리터에게 이렇게 말하는 것과 같다. "이 함수에서 사용되는 been_called는 글로벌 변수를 말하는 것이다. 그러니 로컬 변수를 만들지 말아라"

여기 글로벌 변수를 업데이트하려고 시도하는 예시가 있다.

1
2
3
4
5
count = 0

function example3()
count = count + 1 # WRONG
end

위의 예시를 실행하면 아래와 같은 오류 메시지가 출력된다.

1
2
julia> example3()
ERROR: UndefVarError: count not defined

줄리아는 count를 로컬 변수로 가정했기 때문에 문제가 발생한 것이다. 따라서 해결책은 count를 글로벌 변수로 선언하는 것이다.

1
2
3
4
5
6
count = 0

function example3()
global count
count += 1
end

만약 글로벌 함수가 변경 가능한 값으로 제공되면, global 선언 없이 수정할 수 있다.

1
2
3
4
5
known = Dict(0=>0, 1=>1)

function example4()
known[2] = 1
end

글로벌 배열이나 딕셔너리의 요소들도 추가하거나 삭제, 대체 등을 할 수 있지만, 변수를 재할당하는 것은 global을 선언해야 한다.

1
2
3
4
5
6
known = Dict(0=>0, 1=>1)

function example5()
global known
known = Dict()
end

성능상의 이유로 글로벌 변수는 const를 선언해야 한다. 더이상 변수를 재할당 할 수는 없지만 변경 가능한 값들 한해서는 값을 수정할 수 있다.

1
2
3
4
5
const known = Dict(0=>0, 1=>1)

function example4()
known[2] = 1
end

WARNING 글로벌 변수는 유용하지만, 글로벌 변수가 너무 많고 자주 수정하면 프로그램을 디버그하는데 힘들고 성능도 저하될 수 있다.

디버깅

큰 데이터셋으로 작업할 때, 직접 다 출력하여 확인하는 방식으로는 디버그하기 어려울 수 있다. 다음은 큰 데이터셋을 디버그하기 위한 몇 가지의 제안이다.

  • 입력을 축소해라 가능하다면, 데이터셋의 크기를 줄여라. 예를 들어 프로그램이 텍스트 파일을 읽은 경우 처음 10행으로 시작하는 등의 가장 작은 예를 사용하여 오류를 찾을 수 있다. 파일 자체를 편집하는 것보다 프로그램을 수정하여 첫 번째 'n'라인만 읽을 수 있도록 수정하는 것이 낫다.

  • 요약 및 데이터 타입을 확인하라 전체 테이터셋을 인쇄하고 확인하는 대신 데이터의 요약을 인쇄해라. (예로 딕셔너리에 있는 아이템 수나 배열의 숫자 등) 런타임 오류의 일반적인 원인은 올바르지 못한 데이터 타입의 값 때문이다. 이런 종류의 오류를 디버깅하기 위해선 값 데이터 타입을 출력해보는 것만으로도 충분하다.

  • 자가 점검을 작성하라 때로는 오류를 자동으로 확인하는 코드를 작성할 수 있다. 예를 들어 숫자 배열의 평균을 계산하는 경우 결과가 배열에서 가장 큰 요소보다 크지 않거나 가장 작은 요소보다 작지 않은지 확인할 수 있다. 이것을 분별 검사(sanity check)라고 한다. 다른 종류의 검사는 서로 다은 두 가지 계산 결과를 비교하여 일치하는지 확인한다. 이것을 일관성 검사(consistency check)라고 한다.

  • 출력을 형식화해라 디버깅 출력 형식을 지정하면 오류를 더 쉽게 발견할 수 있다. 출력 형식을 지정하는 것을 스캐폴딩이라고 하며, 이에 대한 자세한 설명은 해당 페이지 디버깅 파트에 있다. 다시 말하지만 스캐폴딩을 구축하는 데 소요되는 시간은 디버깅에 낭비되는 시간을 줄일 수 있다.


[10/20] 딕셔너리
https://dev-bearabbit.github.io/ko/ThinkJulia/Think-Julia-Chapter-10/
Author
Jess
Posted on
2020년 3월 10일
Licensed under