발전된 하드웨어 스펙으로인해 최근에는 공간복잡도에 대한 최적화가 시간복잡도보다는 덜 이루어진다.
여기서는 시간복잡도에 대한 접근 방식을 살펴보겠다.
2. 시간복잡도
시간복잡도는 쉽게 말해 코드의 실행 횟수를 계산한것이다.
여기서 말하는 코드란 반복문(for), 조건문(if) 등의 프로그램 코드를 말한다.
그렇다면 코드의 실행횟수가 어떻게 되어야 좋은 알고리즘일까? 당연히 적게 실행된것, 적게 실행되어 결과를 빨리 출력하는 알고리즘이 좋은 알고리즘이다.
시간복잡도에 대한 자세한 사항은 위키피디아를 참조하기를 바란다. 여기서는 아주 간략한 설명만 진행한다.
위 그림은 위키피디아 페이지에 존재하는 그림이다. 가로축은 입력 데이터 수이고 세로축은 실행 횟수이다. 각각의 측정치에는 n!, 2^n, n^2.. 등이 보인다.
즉 n개의 데이터를 입력할 경우 연산이 얼마나 이루어지나를 나타낸것이다. 위 그래프에서는 n!이 최악의 성능을 나타내고 1이 최고의 성능을 나타낸다. 이것은 조금만 생각해보면 당연히 알수 있는 내용이다.
이러한 표기법을 Big-O 표기법이라한다. 이 표기법은 다항식에서 가장 높은 차수의 항만을 표기한다.ex) 4n^3 + 12n^2 + 3 = O(n^3), 3nlogn + 4 = nlogn
그럼 문제를 통해 Big-O 표기법과 이를 최적화(최소화)하는 방법을 살펴보자.
3. 실전 문제
그럼 간단한 문제를 풀어보자. 다음과 같이 중복된 값이 오직 한개만 존재하는 배열이 존재 할 때 중복된 요소는 어떻게 찾을 수 있을까?
[6, 23, 5, 12, 46, 71, 9, 23]
먼저 시간복잡도를 고려하지 않고 가장 쉽게 생각하면 다음과 같지 않을까?
먼저 배열의 첫 번째 요소를 나머지 요소 전부와 비교해서 똑같은 값이 있는지 검사한다. 다음은 두 번째 요소와 나머지 요소 전부와 비교해서 똑같은 값이 있는지 검사한다. … 마지막 요소를 나머지 요소와 비교해서 똑같은 값이 있는지 검사한다.
위와 같은 알고리즘을 파이썬으로 표현하면 다음과 같다.
def checkDuplcation(arr): length = len(arr) for i in range(length): for j in range(length): if i == j: continue elif arr[i] == arr[j]: return arr[i] return -1def main(): arr = [6, 23, 5, 12, 46, 71, 9, 23] print(checkDuplcation(arr))if __name__ == "__main__": main()
위의 알고리즘을 설명하면 다음과 같다.
반복문 두개가 중첩되어 존재한다.
같은 인덱스의 요소를 비교하는 것을 조건분으로 피한다.
중복된 요소가 존재하면 중복된 값을 반환하고 존재하지 않으면 -1을 반환한다.
위의 알고리즘은 어떠한 배열(문제에서 제시한 특성을 가지는)이 와도 중복된 요소를 찾을 수 있다.
그렇다면 성능적인 면에서 봤을 때 효율적일까? 조금만 생각해보면 다음과 같이 중복된 연산이 일어나는 것을 알 수있다.바깥 i가 0일때 arr[0]과 arr[1]을 비교했는데 i가 1일때 arr[1]과 arr[0]의 비교가 또 이루어진다. 즉 중복된 연산이 이루어진다.
그렇다면 가장중요한 Big-O 표기법의 결과는 어떻게 될까? for문을 가지고 계산해보자.
j가 i만큼 반복된다. j와 i는 모두 length만큼 진행하므로 O(n^2)이라는 것을 알 수있다!
4. 개선1 - 중복연산제거
여기서는 앞에서 설명한 중복된 연산을 제거해보자.
어떻게 중복된 연산을 제거할 수 있을까… 계속 생각해보자…첫 번째 요소와 나머지 요소를 연산한 후 다음 요소부터는 첫번째 요소와 검사 할 필요가 없다. 즉, 두번째 요소를 검사할때는 첫번째가 아닌 두번째 부터 중복여부를 검사하면된다. 하지만 두번째 요소또한 같은 요소를 비교하는 것이기 때문에 검사할 필요가 없다.
[6, 23, 5, 12, 46, 71, 9, 23]
첫 번째 값을 두번째 값부터 비교하며 검사
두 번째 값을 세번째 값부터 비교하며 검사
코드는 다음과 같다.
def checkDuplcation(arr): length = len(arr) for i in range(length): for j in range(i+1, length): if arr[i] == arr[j]: return arr[i] return -1def main(): arr = [6, 23, 5, 12, 46, 71, 9, 23] print(checkDuplcation(arr))if __name__ == "__main__": main()
앞코드에서 발생한 중복된 연산을 깔끔히 제거하였다. 그렇다면 가장 중요한 시간복잡도는 어떻게 될까?
실제 반복수는 n + (n-1) + (n-2) + … + 1 이다. 즉 n^2-(1+2+..+(n-1)) 이다.
안탑깝지만 이경우도 Big-O 표기법에서는 O(n^2)이다.
중복연산을 제거했다고 생각했는데 정말 같은 시간복잡도를 가지는 알고리즘이라고 판단해야되는 것일까? 같은 알고리즘이라고 판단해도 좋다. 이유는 n이 커지면 커질수록 O(n^2)의 특성을 가지기 때문이다.
5. 개선2 - 자료구조 이용하기
이번에는 자료구조를 사용해보자.
좀더 개선하기 위해서는 어떤 자료구조를 사용하면 좋을까?
이전의 개선과 같이 계속 생각해보자… 계속 생각해보자…
답을 먼저 말하면 해시(hash)를 이용하는 것이다.
코드는 다음과 같다.
def checkDuplcation(arr): dic = {} length = len(arr) for i in range(length): if arr[i] in dic: return arr[i] else: dic[arr[i]] = 1 return -1def main(): arr = [6, 23, 5, 12, 46, 71, 9, 23] print(checkDuplcation(arr))if __name__ == "__main__": main()
여기서 중요한 사실은 파이썬의 딕셔너리는 해쉬로 작동한다는 점이다. 해시는 O(1)을 가지는 자료구조이다.
위와 같은 상황에서 반복문은 최대 n번만 수행된다. 즉 위의 알고리즘음 O(n)이다!
이번 설명은 사실 이 부분을 강조하기 위한 것 이였다.
5. 마무리
위 문제를 개선하던 중 코드레벨의 가장 큰 변화는 무엇일까?
그것은 2중 반복문을 한개로 축소시킨것이다. 이것은 알고리즘의 시간복잡도를 줄이는 가장큰 역활을 한다.
또한 언어의 스펙을 알면 좀더 성능이 좋은 코드를 작성 할 수 있다는 것을 알 수 있었다.
최신 기술의 프레임워크, 라이브러리도 좋지만 프로그램의 본질인 자료구조와 알고리즘을 꾸준히 학습해서 좋은 개발자가 되자.
여러가지 언어보다는 한 가지 언어의 스펙을 상세히 공부하면 좀더 깊이있는 코드를 작성할 수 있다.