-
Dynamic Programming (with Fibonacci sequence(using Swift and Cache))DataStructure + Algorithm/Basic Theories 2021. 10. 19. 17:44
안녕하세요, 오늘은 Dynamic Programming 에 대해서 다루고자 합니다. Dynamic Programming 에서는 Divide & Conquer 과 같이 덩어리를 조각조각 내어 해를 구합니다. 하지만 둘 사이 가장 다른 점은 그 조각조각의 연관성이라 할 수 있겠습니다. 나눈 것들이 서로 관련이 전혀 없을 때 Divide & Conquer 을, 관련이 있어서 이전에 구한 값을 활용하면서 해를 구할 때 Dynamic Programming 을 활용한다고 할 수 있겠습니다. 나눈 부분들이 서로 관련이 있는데도 Divide & Conquer 방식으로 해결하고자 하면 Dynamic Programming 방식보다 더 비효율적일 가능성이 매우 높겠죠 ? 이번 글에서는 Fibonacci Sequence 를 여러가지 방식으로 해결하면서 이번 주제를 더 상세히 설명하고자 합니다.
Fibonacci Sequence
Dynamic Programming 을 다루기 전에, Fibonacci Sequence 는 n 번째 수가 n-1, n-2 번째 수의 합이 되는 수를 나열한 것입니다.
수식으로 하면 아래와 같습니다.
f(n) = f(n-1) + f(n-2) (for n >= 2, n : Int )
f(n) = n ( for n == 0 || n == 1 )0, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, ...
Divide & Conquer 방식을 이용할 때 다음과 같이해결할 수 있습니다.
func fibonacciRecursion(_ n: Int) -> Int { return n < 2 ? n : fibonacciRecursion(n-1) + fibonacciRecursion(n-2) }
생각보다 귀엽고 간단하죠? 하지만 Time Complexity 는 그리 귀엽지 않습니다. 너무 너무 느려요.
예를 들어 10 번째 element (f(10)) 를 구할 때는 f(9), f(8) 이 필요합니다.
f(9) 를 구할 때 f(8) 과 f(7) 이 필요한데, 이 때에 이전에 구했던 값을 이용하지 않고 다시 구하기 때문에 불필요한 과정을 많이 포함하게 되는거죠.
실제로 TimeComplexity 를 구해보면 O(2^n ) 임을 알 수 있어요.
2 -> 1 + 0 2번
3 -> 2 ( 1+0 ) + 1 4번
4 -> 4 ( 2+ 3 ) + 3 -> 1 + 2 + 4 + 4 11 번
5 -> 4 + 3 -> 15번Divide And Conquer 은 쉽게 말하면 작게 나누어서 푸는 방법을 말합니다.
작게 나눈 부분부분이 연관성이 있어도 반복해서 계산하기 때문에, 이 경우 매우 비효율적입니다. ( 서로 연관 없는 곳에 쓰세요..)이런 경우, 문제를 어떻게 해결하면 좋을까요 ?? 반복되는 계산을 미리 다른 곳에 저장하거나 다른 방법으로 이용해서 더 빠르게 할 수는 없을까요 ??
Dynamic Programming
위 Fibonacci Sequence 는 Divide & Conquer 방식의 Recursion 을 사용하여 풀 수 있으나, 그렇게 하는 경우 O( 2^n ) 이 나오게 되었죠?. 반복되는 값들을 이용하지 않고 계속해서 새로 계산해주었기 때문에 비효율적이라는 것을 알 수 있었습니다. 하지만, Dynamic Programming 을 이용해 푸는 경우 O(n) 까지 빨라질 수 있습니다. Dynamic Programming 에서는 서로 연관된, 반복되는 instance 를 활용하기 때문에, 방식의 차이가 Time Complexity 의 차이를 낳았습니다.
위에서 잠깐 언급했지만, Dynamic Programming 은 Divide & Conquer 방식과 사용하는 경우가 다릅니다. Divide & Conquer 의 경우 나눈 후 나온 데이터가 서로 연관이 없지만, Dynamic Programming 의 경우 그렇지 않습니다. 즉, 나눈 부분부분이 서로 연관성이 있고 활용할 수 있을 때 Dynamic Programming 을 활용하고, 그렇지 않은 경우 Divide & Conquer 방식을 사용하면 좋습니다. 따라서, Divide & Conquer 은 top to bottom 으로 비유되는 데 반해 Dynamic Programming 은 bottom to top 으로 볼 수 있습니다. n+1 번째 element 를 구하기 위해 이미 구했던 n, n-1 번째 element 를 활용하기 때문이죠.
이제 Dynamic Programming 을 이용하여 더 빠르고 효율적으로 풀어보겠습니다.
이번 예제에서는 'Memoization' 을 활용합니다.
Cache Memory 를 사용하는 방식인데, 해당 방식을 사용할 경우 O(n) 으로 줄일 수 있습니다.
memoization 은 주로 컴퓨터 프로그램의 속도를 향상시킬 목적으로 쓰이는 최적화 테크닉을 말합니다.
부담되는 function 이 내놓은 결과값을 저장하고(cache) 같은 값이 필요할 때 돌려주는 방식입니다.더보기( Memoization 과 Memorization 생긴게 많이 비슷하죠 ? 둘다 'to remember' 를 뜻하는 라틴어 'memoro' 에서 왔대요)
방식은 다음과 같아요
var fibonacciCache = [Int: Int]() func fibWithMemo(_ number: Int) -> Int { if let value = fibonacciCache[number] { return value } let newValue = number < 2 ? number : fibWithMemo(number - 1) + fibWithMemo(number - 2) fibonacciCache[number] = newValue return newValue }
먼저, global variable 로 fibonacciCache 를 저장합니다. 그 후, fibonacci(_:) function 이 호출 되었을 때 해당 값을 바꿔주면서 작업을 빠른속도로 할 수 있습니다.
실제 fibonacci function 의 parameter 로 값을 다르게 넣어주면서 함수 호출횟수를 조회해보면, 아래와 같습니다.
parameter fibonacci funtion 이 호출된 수 1 1 2 3 3 5 4 7 5 9 n 2(n-1) 이때, 함수 호출이 끝나고 나서는 global variable fibonacciCache 의 값이 변화된 상태입니다.
추가로 Cache 를 이용한 fibonacci function 을 몇개 더 보도록 할게요! (Cache 는 헷갈리고 중요하니까..)
func fibWithMemo2(_ n: Int) -> Int { var fibonacciCache2 = [Int: Int]() func fibonacci(_ number: Int) -> Int { if let value = fibonacciCache2[number] { return value } let newValue = number < 2 ? number : fibonacci(number - 1) + fibonacci(number - 2) fibonacciCache2[number] = newValue return newValue } fibonacci(n) return fibonacciCache2[n]! }
해당 fibWithMemo2 는, global variable 을 사용하지 않고, function 내에서 Cache 를 선언한 후 내부에서 빠르게 처리합니다.
global variable 을 선언하지 않기 때문에 만약 해당 기능이 이 매우 빈번하게 사용되지는 않는다고 한다면 이 함수를 더 사용할 것 같네요.
(경우에 따라 당연히 다른 방법을 사용하는게 맞겠죠?? )
func fibWithMemo3(_ n: Int) -> Int { if n <= 1 { return n} var fibonacciCache3 : [Int : Int] = [0:0, 1:1] func realFib(_ input: Int) { // calculate the result of fibonacci sequence fibonacciCache3[input] = fibonacciCache3[input - 1]! + fibonacciCache3[input - 2]! } for index in 2 ... n { realFib(index) } return fibonacciCache3[n]! }
이번 함수는, fibWithMemo2 와 전반적으로 같고 fibonacci sequence 를 구하는 방법만 약간 다릅니다. Recursion 을 사용하지 않고 차례대로 for 문을 이용해서 구해요.
Dynamic Programming 에서는 작은 instances 를 먼저 해결하고 결과를 저장한 후, 그 값이 필요할 때면 (다시 계산하는 대신) 언제든 활용합니다. 여기서 "programming" 은 solution 들이 형성되는 array (table) 의 사용을 의미합니다. Dynamic programming algorithm 에서는 array 내에서 바닥부터 (0번째 element) solution 을 형성해 나갑니다. (bottom-up approach)
dynamic programming algorithm 개발에서의 순서는 다음과 같습니다.
1. Establish a recursive property that gives the solution to an instance of the problem.
2. Bottom-up 방식, 더 작은 문제들을 먼저 해결하는 방식으로 진행합니다.
마지막으로 정리를 하자면, Dynamic Programming 은 Divide & Conquer 방식과 사용하는 경우가 다릅니다. Divide & Conquer 의 경우 나눈 후 나온 데이터가 서로 연관이 없지만, Dynamic Programming 의 경우 그렇지 않아요. 다시 말하면, Dynamic Programming 에서는 나눈 부분부분이 서로 연관성이 있고 활용할 수 있습니다. (이 글에서는 활용을 위해 데이터를 Cache 에 잠시 저장해서 사용하였습니다.) 따라서, Divide & Conquer 은 top to bottom 으로 비유되는 데 반해 Dynamic Programming 은 bottom to top 으로 볼 수 있습니다. (Divide & Conquer 의 대표적인 예시로는 Merge Sort 가 있지요) 아래 마지막 fibonacci function 을 끝으로 글을 마치겠습니다. 읽어주셔서 감사합니다.
func fibonacciWithArray(_ n: Int) -> Int { var array = [1,1] if n > 1 { while (array.count <= n) { count += 1 array.append(array[array.count-1] + array[array.count-2]) } return array[array.count - 1] } else { return array[n] } }
사실.. fibonacci 의 경우 Recursion 대신 Array 를 통해 풀면 Cache 의 도움을 받지 않고도 아주 빠르게 해결할 수 있습니다.
(해당 글은 Dynamic Programming, Cache 를 알아보기 위해 Fibonacci 를 이용했지만.. )
출처: Richard Neapolitan. “Foundations of Algorithms." (5th) 에서 일부를 발췌했습니다.'DataStructure + Algorithm > Basic Theories' 카테고리의 다른 글
Quick Sort with Swift (0) 2021.10.21 MergeSort (with Swift) (0) 2021.10.20 LinkedList (0) 2021.10.13 Stack, Queue With Swift (0) 2021.10.12 Insertion Sort with Swift (0) 2021.10.09