코드가 실행되는 데 걸리는 시간을 알 수 있는 가장 쉬운 방법은 내장 되어있는 타이밍 함수를 사용하는 것
function addUpTo1(n) {
return n * (n + 1) / 2;
}
let time1 = performance.now();
addUpTo(1000000000)
let time2 = performance.now();
console.log(`Time Elapsed: ${(time2 - time1) / 1000} seconds.`)
// 1000분의 1초 단위로 되어있기 때문에 1000을 나눠 줌
그러나 코드가 실행될 때의 시간을 측정하는 것은 정확도에 문제가 있다.
🚫 기기 사양에 따라 시간이 다르게 측정되기 때문에 정확한 시간을 알 수 없다.
🚫 같은 기기에서도 코드를 실행할 때마다 조금씩 시간이 다르게 기록된다.
🚫 매우 짧은 시간 안에 처리되는 빠른 알고리즘에서는 타이밍 함수가 그런 작은 차이를 정확히 측정하지 못한다.
❗️ 대신에 컴퓨터가 처리해야하는 연산 개수를 세면 된다!
어떤 컴퓨터를 사용하든 그 연산 개수는 변하지 않기 때문에 시간을 측정하는 것보다 정확하다.
function addUpTo1(n) {
return n * (n + 1) / 2;
}
이 경우, n값과는 상관 없이 총 3번 연산한다.
(곱셈 1번 + 덧셈 1번 + 나눗셈 1번)
function addUpTo2(n) {
let total = 0;
for (let i = 1; i <= n; i++) {
total += i;
}
return total;
}
| 코드 | 연산 횟수 |
|---|---|
let total = 0 | total 선언 및 할당 (1회) |
let i = 1 | i 선언 및 할당 (1회) |
i <= n | 반복할 때마다 비교 (n회) |
i++ | 반복할 때마다 덧셈 (n회) |
i++ | 반복할 때마다 할당 (n회) |
total += i | 반복할 때마다 덧셈 (n회) |
total += i | 반복할 때마다 할당 (n회) |
5n + 2 번 연산하는 함수로, n이 커질 수록 연산의 개수도 비례적으로 증가한다.
만약 n이 10이라면 ?
n회 반복되는 연산 총 50개 + 반복문 바깥 연산 2개 = 52개
알고리즘의 효율성(시간 복잡도)를 나타낼 때 사용하는 표기법
입력의 크기와 실행 시간 사이의 관계를 뜻한다.
일반적으로 실행시간이 가질 수 있는 최대치, 즉 가장 높은 실행 시간 값을 말한다.
f(n) could be linear (f(n) = n)
➡️ 실행 시간이 선형으로 증가할 수 있다. (n과 함께 증가)
f(n) could be quadratic (f(n) = n²)
➡️ 실행 시간이 n의 제곱일 수 있다. (n과 함께 증가)
f(n) could be constant (f(n) = 1)
➡️ 실행 시간이 상수 일 수 있다. (n이 커져도 실행 시간에는 영향이 없음)
❗️ 실행 시간이 상수인 경우, 단순히 1이라고 표현한다
f(n) could be something entirely different!
➡️ 입력의 크기와 실행시간 사이의 관계가 완전히 다를 수 있다.

시간복잡도 효율 순
O(1) < O(log n) < O(n) < O(n log n) < O(n²)
O(1)function addUpTo(n) {
return n * (n + 1) / 2;
}
해당 함수의 경우 언제나 연산이 3개이기 때문에 O(1) 으로 표현할 수 있다.
O(n)function addUpTo(n) {
let total = 0;
for (let i = 1; i <= n; i++) {
total += i;
}
return total;
}
해당 함수의 경우 실행 시간이 1:1 비율로 증가하고, 연산의 갯수가 n의 곱과 연결 되어있기 때문에 O(n) 으로 표현할 수 있다.
function countUpAndDown(n) {
console.log("Going up!");
for (let i = 0; i < n; i++) { // O(n), n이 증가할 수록 반복 횟수가 증가하기 때문에
console.log(i);
}
console.log("At the top!\nGoing down...");
for (let j = n - 1; j >= 0; j--) { // O(n), n이 증가할 수록 반복 횟수가 증가하기 때문에
console.log(j);
}
console.log("Back down. Bye!");
}
반복문이 2개이기 때문에 O(2n) 이라고 생각할 수 있지만 빅오 표기법으로는 O(n) 으로 표기 한다.
❗️ 같은 비율로 증가하고 있다면 2n이든, 5n이든, 10n이든 전부 O(n) 로 표기
O(n²)function printAllPairs(n) {
for (var i = 0; i < n; i++) { // O(n)
for (var j = 0; j < n; j++) { // O(n)
console.log(i, j)
}
}
}
O(n) 연산 안에 O(n) 연산
= O(n * n)
= O(n²)
n이 커질 수록 실행 시간이 n 제곱의 값으로 증가하므로 O(n²)으로 표기한다.
이 모든 연산들을 다 세는 것은 힘들고, 사실 정확한 갯수가 중요하지 않다는 것을 알 수 있다.
중요한 것은 전체적인 추세!
5n + 2 ===> O(n)
10n이든 1000n이든 중요하지 않음
추세 즉, 실행 시간이 n의 값과 비례한다는 것이 중요하다.
O(2n) ===> O(n)
O(500) ===> O(1)
O(13n²) ===> O(n²)
대략적으로 정확한 큰 그림을 봐야 하므로, 상수는 신경쓰지 않는다.
O(n² + 5n + 8) ===> O(n²)
❗️ 추세를 나타낸 그래프를 아주 먼 우주에서 바라보고 있다고 생각하자.
작고 사소한 것에 신경쓰지 말고 큰 것에 집중해야 한다.
이 때, n제곱에 비해서 5n + 8은 아무 의미가 없다.
이렇게 상수를 없애고 나면, 단순화된 표현식들끼리 비교할 수 있다.
1️⃣ 산술 연산은 상수다. (= 항상 일정한 값을 취한다.)
(덧셈, 뺄셈, 곱셈, 나눗셈을 포함)
컴퓨터에게는 2+2를 처리하는 시간과 1000000+2를 처리하는 시간이 비슷하다.
그러므로 n의 값이 상관이 없는 것 !
2️⃣ 변수에 값을 할당하는 것은 상수다.
3️⃣ 인덱스를 사용해 배열에 접근하거나, 키를 사용해 객체에 접근하는 것 또한 상수다.
4️⃣ 반복문이 있는 경우, 복잡도 = 루프의 길이 * 루프 안에 있는 연산들
0에서 n까지 반복한다면, n이 커질 수록 반복 횟수가 증가
이중 반복문이 있다면, 실행 시간은 n²
function logAtLeast5(n) {
for (var i = 1; i <= Math.max(5, n); i++) {
console.log(i);
}
}
n이 5보다 작다면 5번 반복하고, n이 5보다 크다면 n번 반복하는 함수
n이 커질수록 연산 개수가 n에 비례하여 증가하기 때문에 O(n)
function logAtMost5(n) {
for (var i = 1; i <= Math.min(5, n); i++) {
console.log(i);
}
}
n이 5보다 작다면 n번 반복하고, n이 5보다 크다면 5번 반복하는 함수
n이 커져도 아무런 영향을 주지 않기 때문에 O(1)
입력이 커질수록 알고리즘이 얼마나 많은 공간(메모리)을 차지하는 지
입력되는 것을 제외하고 알고리즘 자체가 필요로 하는 공간
boolean, number, undefined, null 타입은 모두 불변 공간
입력의 크기와는 상관 없이 숫자가 1이든 1000이든 모두 불변 공간으로 여긴다.
string 타입은 O(n) 공간이 필요
만약 50자인 문자열이 있다면, 길이가 1자인 문자열보다 50배 더 많은 공간을 차지
array, object 타입은 O(n) 공간이 필요
만약 길이가 4인 배열이 있다면, 길이가 2인 배열보다 2배 더 많은 공간을 차지
예시
function sum(arr) {
let total = 0;
for (let i = 0; i < arr.length; i++) {
total += arr[i]
}
return total
}
공간을 차지하는 부분
let total = 0
let i = 0
total += arr[i]
새로운 변수를 만드는 것이 아닌, 이미 선언된 변수에 더하는 것
=> 공간은 이미 할당되어 있고, 시간이 더 소요됨
상수 공간이 있는 코드 ===> O(1) 공간
(입력의 크기와는 상관없이 항상 똑같은 공간을 차지)
function double(arr) {
let newArr = [];
for (let i = 0; i < arr.length; i++) {
newArr.push(2 * arr[i]);
}
return newArr;
}
각 배열의 요소가 두 배가 된 배열을 리턴하는 함수
입력된 배열의 길이가 10이라면, 새로운 배열에 저장되는 요소가 10개
=> 차지하는 공간은 입력된 배열의 크기와 비례하여 커지게 된다.
O(n) 공간을 차지하는 코드
log2(8) = 3 === 2³ = 8
: 2의 몇승의 값이 8이 되나요?
어떤 이진 로그를 대략 계산하기 위해서는 그 숫자가 1보다 작아지기 전에 2로 나눠지는 횟수를 알아내야 한다.
8 / 2 = 4
4 / 2 = 2
2 / 2 = 1
log(8) = 3
25는 정확하게 2로 나눌 수 없음
25 / 2 = 12.5
12.5 / 2 = 6.25
6.25 / 2 = 3.125
3.125 / 2 = 1.5625
1.5625 / 2 = 0.78125
log(25) ≈ 4.64
<log와 관련있는 알고리즘>
탐색 알고리즘, 효율적인 정렬 알고리즘, 재귀
재귀는 시간이 아닌, 공간 복잡도와 관련
입력 : O(1)
제거 : O(1)
탐색 : O(n) (어떤 특정한 정보가 어떤 값에 있는 지 확인하는 것)
접근 : O(1)
| 메소드 | 빅오 |
|---|---|
| Object.keys | O(n) |
| Object.values | O(n) |
| Object.entries | O(n) |
| hasOwnProperty | O(1) |
입력 : it depends
제거 : it depends
탐색 : O(n)
접근 : O(1)
❗️ 접근
JS에서 10000개의 요소가 있는 배열에서 9000번째 요소를 요청한다 가정했을 때, 0번째부터 9000번째까지 모든 엘리먼트를 하나씩 지나가는 것이 아님! 해당 인덱스로 바로 접근 가능하기 때문에 배열의 길이는 중요하지 않음
❗️ 입력
가장 끝에 추가하는 경우: O(1)
가장 앞에 추가하는 경우: O(n)
인덱스들이 1개씩 밀리기 때문에 엘리먼트마다 인덱스를 새로 배정하는 작업이 필요함
❗️ 제거
가장 끝에 있는 요소를 제거하는 경우: O(1)
가장 앞에 있는 요소를 제거하는 경우: O(n)
인덱스들이 앞으로 1개씩 당겨져야 하기 때문에 엘리먼트마다 인덱스를 새로 배정하는 작업이 필요함
효율적인 측면에서 봤을 때, 앞에서 추가/제거하는 것보다 끝에서 추가/제거하는 것이 더 효율적이다.
그러므로 효율을 생각한다면, 배열 앞에 데이터를 추가/제거하는 것은 최대한 피하는 것이 좋다.
| 메소드 | 빅오 |
|---|---|
| push | O(1) |
| pop | O(1) |
| shift | O(n) |
| unshift | O(n) |
| concat | O(n) |
| slice | O(n) |
| splice | O(n) |
| sort | O(n * log n) |
| forEach/map/filter/reduce/etc. | O(n) |
slice : 걸리는 시간은 복사하는 엘리먼트 개수만큼 증가
빅오 표기법과 관련해서 여러 궁금증이 있었는데, 덕분에 궁금증들 잘 해결하고 갑니다 ㅎㅎ 좋은 포스트 감사합니다.