Measuring Cost

개념 확인: Running Time and Operation Counts


실행 시간을 조건을 맞춰 재고, 코드의 연산 수를 입력 크기의 식으로 적는다.

문제 1

묶음 시간을 호출당 시간으로 바꾸기

Timer(lambda: f(5000)).repeat(repeat=5, number=20)의 결과가 다음과 같다(단위: 초).

[0.0124, 0.0118, 0.0131, 0.0120, 0.0122]
  1. 다섯 묶음의 중앙값을 구한다.
  2. 중앙값을 함수 한 번 호출당 시간으로 바꾸고 마이크로초(μs)로 나타낸다.
  3. 한 번만 실행해 재지 않고 여러 호출을 묶어 여러 번 재는 이유를 적는다.
해설 펼치기
  1. 정렬하면 0.0118, 0.0120, 0.0122, 0.0124, 0.0131이므로 중앙값은 0.0122초다.
  2. 한 묶음에 20회 호출했으므로 0.0122/20 = 0.00061초이고, 1,000,000을 곱하면 610 μs다.
  3. 한 번의 짧은 실행은 재기 어렵고, 다른 프로그램과 운영체제의 영향으로 측정값이 매번 달라진다. 호출을 묶어 시간을 키우고 여러 묶음의 중앙값을 쓰면 튀는 값의 영향이 줄어든다.
문제 2

이 비교를 믿을 수 있는가

다음 비교마다 시간 차이를 코드의 차이로 해석할 수 없는 이유와 바로잡는 방법을 적는다.

  1. 함수 A의 측정문은 매번 파일에서 숫자를 읽어 목록을 만든 뒤 합을 구했고, 함수 B는 setup에서 목록을 만든 뒤 합만 쟀다.
  2. 함수 A는 컴퓨터가 쉬고 있을 때, 함수 B는 같은 컴퓨터에서 동영상 변환 프로그램이 도는 동안 쟀다.
  3. 함수 A는 원소 50,000개인 목록, 함수 B는 원소 500개인 목록으로 쟀다.
  4. 함수 B가 A보다 빠르지만 빈 목록을 넣으면 오류가 난다.
해설 펼치기
  1. A에는 파일 읽기와 목록 생성 시간이 섞였다. 준비 작업을 두 쪽 모두 setup에 두거나 두 쪽 모두 포함해 측정 범위를 똑같이 정한다.
  2. 실행 환경의 부하가 달라 시간 차이가 코드 때문인지 알 수 없다. 같은 조건에서 두 함수를 번갈아 다시 잰다.
  3. 입력 크기가 달라 작업량 자체가 다르다. 같은 크기의 입력에서 먼저 비교한다.
  4. 올바른 결과를 내지 못하는 코드는 성능 비교 대상이 아니다. 먼저 assert 등으로 빈 입력을 포함한 여러 입력에서 결과가 같은지 확인한 뒤 시간을 잰다.
문제 3

횟수를 셀 연산을 정하고 T(n) 적기

반복 제어와 반환은 제외하고 명시적인 대입만 센다. n = len(a)다.

total = 0
squares = 0
for x in a:
    total = total + x
    squares = squares + x * x
    last = x
  1. 반복문 밖과 반복문 안의 대입 수를 나누어 T(n)을 적는다.
  2. a = [1, 2, 3, 4]와 a = []일 때 대입 수를 각각 구한다.
  3. 같은 코드에서 곱셈만 세기로 하면 T(n)이 어떻게 바뀌는지 적고, 연산 수를 적기 전에 어떤 연산을 셀지 먼저 정해야 하는 이유를 설명한다.
  4. 대입 수가 T(n) = 2 + 3n인 이 코드와 T(n) = n + 1인 다른 코드의 실행 시간 비율을 대입 수의 비로 단정할 수 있는지 적는다.
해설 펼치기
  1. 반복문 밖 대입은 total = 0, squares = 0의 2회, 반복 한 번마다 대입이 3회이므로 T(n) = 2 + 3n이다.
  2. [1, 2, 3, 4]는 n = 4라 2 + 3·4 = 14회, 빈 목록은 반복이 실행되지 않아 2회다.
  3. 곱셈은 반복마다 x * x 한 번뿐이므로 T(n) = n이다. 같은 코드도 무엇을 세느냐에 따라 식이 달라지므로 입력 크기와 횟수를 세는 연산을 먼저 정해야 T(n)이 뜻을 갖는다.
  4. 단정할 수 없다. 연산 수와 실행 시간은 다른 양이며, 연산마다 걸리는 실제 시간이 다르므로 비율은 반복 측정으로 확인해야 한다.
문제 4

반복문의 모양보다 실행 범위

각 코드에서 count += 1이 실행되는 횟수를 식으로 적고, 주어진 크기에서 값을 구한다. a의 길이는 m, b의 길이는 n이다.

# (가) m = 6, n = 4
for x in a:
    count += 1
for y in b:
    count += 1

# (나) n = 6
for i in range(n):
    for j in range(n):
        count += 1

# (다) n = 6
for i in range(n):
    for j in range(i):
        count += 1

# (라) n = 6
for i in range(n):
    for j in range(4):
        count += 1

# (마) n = 6과 n = 7
for i in range(0, n, 3):
    count += 1

(나)~(라)가 모두 두 겹 반복문인데 횟수가 다른 이유를 한 문장으로 적는다. (가)에서 a만 두 배로 늘리면 횟수가 얼마가 되는지도 적는다.

해설 펼치기
  • (가) 두 반복문이 나란히 있으므로 m + n = 6 + 4 = 10회다. mn이 아니다.
  • (나) 안쪽이 매번 처음부터 n번 돌므로 n² = 36회다.
  • (다) 안쪽이 i번 돌므로 0 + 1 + ⋯ + (n − 1) = n(n − 1)/2 = 15회다.
  • (라) 안쪽이 항상 4번이므로 4n = 24회다.
  • (마) i = 0, 3, …이므로 ⌈n/3⌉회다. n = 6이면 0, 3의 2회, n = 7이면 0, 3, 6의 3회다.

안쪽 범위가 n인지, 바깥 인덱스 i인지, 상수인지에 따라 횟수가 정해지므로 두 겹이라는 모양만으로 n²이라고 결론 내릴 수 없다. (가)에서 m만 12로 늘리면 12 + 4 = 16회다.

문제 5

Python 연산 측정 읽기

한 컴퓨터에서 없는 값 −1을 찾는 -1 in a를 같은 방법으로 쟀다. a에는 0부터 n − 1까지의 정수가 들어 있고 값은 호출당 중앙값(μs)이다.

원소 수 nlistdict
1,0009.10.021
10,000920.022
100,0009500.021
  1. n이 1,000에서 100,000으로 100배 커질 때 list와 dict의 시간이 각각 대략 몇 배가 되는지 적는다.
  2. dict의 0.021과 0.022 차이를 자료구조의 차이로 해석하기 어려운 이유를 적는다.
  3. 찾는 값을 −1 대신 0으로 바꾸면 list 측정이 왜 다른 실험이 되는지 설명한다.
  4. 원소 n개인 목록을 만드는 두 함수를 쟀다. append를 쓰는 함수는 n = 10,000에서 0.6 ms, 20,000에서 1.2 ms였고, 반복마다 a = a + [i]로 새 목록을 만드는 함수는 180 ms, 720 ms였다. n이 두 배일 때 각 시간이 몇 배가 되었는지 적고, 두 번째 함수가 더 빠르게 늘어난 이유를 적는다.
  5. 길이를 유지하며 삭제를 반복 측정했더니 pop(0)의 시간만 목록 길이에 따라 크게 늘었다. 그 이유를 적는다.
해설 펼치기
  1. list는 9.1에서 950으로 약 104배, 곧 원소 수와 비슷한 비율로 늘었다. dict는 0.021에서 0.021로 거의 변하지 않았다.
  2. 0.001 μs 차이는 같은 코드를 다시 재도 생길 수 있는 측정 변동과 구분하기 어렵다. 작은 차이는 반복 측정의 변동 범위와 비교한 뒤 해석한다.
  3. 없는 값을 찾으면 list는 모든 원소를 확인해야 결과를 낸다. 0은 첫 원소라 곧바로 찾으므로 시간이 n에 따라 늘지 않을 수 있다. 어떤 값을 찾았는지까지 맞춰야 결과를 비교할 수 있다.
  4. append는 2배, a = a + [i]는 4배가 되었다. append는 기존 목록을 바꾸지만 a + [i]는 반복마다 지금까지의 원소를 모두 담은 새 목록을 만들어, 목록이 길어질수록 한 번의 반복에 드는 일이 늘기 때문이다. 표의 값은 함수 전체 시간이며 append 한 번의 시간이 아니다.
  5. pop(0)은 맨 앞 원소를 빼고 뒤의 원소를 모두 한 칸씩 당기므로 목록이 길수록 할 일이 늘어난다. pop()은 끝에서 빼므로 옮길 원소가 없다.