🎁 모든 강의 30% + 무료 강의 선물🎁

인프런 워밍업 클럽 스터디 3기 - DS/AL <3주 발자국>

알고리즘

재귀(Recursion)

어떠한 것을 정의할 때 자기 자신을 참조하는 것. 복잡한 문제를 간단한 하위 문제로 나누어 해결할 수 있다.

재귀적으로 정의한 함수를 재귀함수라고 한다.

function func(number) {
  console.log(number)
  return number+1;
}
func(number)

콜스택의 메모리 공간만큼 찬 뒤에 종료된다.

기저 조건이 없기 때문에 언제 종료될 지 모른다.

재귀 함수는 기저 조건이 반드시 있어야 한다.

function func(number) {
  if (number >= 11) {
    return ;
  }
  console.log(number)
  return number+1
}

콜스택은 함수가 호출되며 올라가는 메모리 영역이다.

콜스택은 filo 구조이다. 함수가 호출 될 때마다 콜스택에 쌓이고 함수가 종료되면 콜 스택에서 제거된다.

콜스택의 메모리가 가득 차면 더 이상 쌓일 스택이 없어서 제거된다.

재귀적으로 생각하기

패턴 1

단순히 반복 실행. 이 방법은 콜스택의 공간을 많이 차지 하여 단순 반복문에 비해 좋지 않다.

패턴 2

하위 문제의 결과를 기반으로 현재 문제를 계산(하향식 계산)

하향식 계산은 주로 재귀 함수를 통해 구현가능하다.

// 주어진 배열의 합을 구하는 문제
// 부분 배열에 마지막 원소를 더해 답을 도출할 것임
function sumArray(arr) {
  if (arr.length == 0) { return null; }
  if (arr.length == 1) { return arr[0]; }
  return sumArray(arrs.slice(0, -1)) + arr[arr.length - 1]
}
// 문자열의 길이를 계산하는 문제
function strLength(arr) {
  if (arr.length == 0) { return 0; }
  return strLength(arr.slice(0, -1)) + 1;
}
function power(x, n) {
  if (n <= 0) { return 1; }
  return power(x, n - 1) * x;
}

하노이의 탑

재귀 함수의 하향식 계산을 보여줄 수 있는 방법

function hanoi(count, from, to, temp) {
  if (count == 0) {
    return ;
  }
  hanoi(count-1, from, temp, to)
  console.log(`원판 ${count}를 ${from}에서 ${to}로 이동`)
  hanoi(count-1, temp, to, from)
}
​
hanoi(3, "A", "C", "B")

정렬 - 버블정렬

정렬 알고리즘은 여러 가지가 존재한다.

버블정렬은 현재 숫자와 비교하는 숫자의 비교하여 위치를 바꾸는 방법이다.

현재 숫자가 크다면 비교하는 숫자와 위치를 바꾸어서 제일 마지막에 가장 큰 값을 위치시킨다.

이런 식으로 제일 마지막에 있는 숫자는 정렬에 초기화 하지 않고 하나 씩 줄여서 정렬을 계산시킨다.

function BubbleSort(arr) {
  for (let i =0; i < arr.length; i++) {
    for (let j = 0; j < arr.length -i -1; j++) {
      if (arr[j] > arr[j + 1]) {
        let temp = arr[j]
        arr[j] = arr[j+1]
        arr[j+1] = temp
      } 
    }
  }
}

n을 거칠때마다 마지막의 원소가 하나 씩 정렬된다.

O(n^2 / 2)이 되는데 자연수는 제거한다. 따라서 O(n^2)이 된다.

장점 : 가장 쉽게 생각할 수 있고 이해와 구현이 간단함.

단점 : O(N^2)이므로 성능이 좋지 못하다.

정렬-선택정렬

선택정렬은 정렬 되지 않은 원소와 가장 작은값을 첫 번 째 원소로 가져온 뒤 그 부분을 치환하고 계속해서 정렬해 나간다.

function SelectSort(arr) {
  for (let i= 0; i < arr.length - 1; i++) {
    let minValueIndex = i
    for (let j = i+1; j < arr.length; j++) {
      if (arr[j] < arr[minValueIndex]) {
        minValueIndex = j
      }
    }
    let temp = arr[i]
    arr[i] = arr[minValueIndex]
    arr[minValueIndex] = temp
  }
}

성능은 버블 정렬과 같이 O(n^2)이 된다.

장점 : 이해와 구현이 쉽다.

단점 : 성능이 좋지 못하다.

정렬 - 삽입정렬

삽입정렬은 정렬되지 않은 영역의 가장 앞에 있는 숫자를 하나씩 정렬된 영역에 삽입하며 진행한다.

function InsertionSort(arr) {
  for (let i = 1; i < arr.length; i++) {
    let insertingData = arr[i]
    let j
    for (j = i - 1; j >= 0; j--) {
      if (arr[j] > insertingData) {
        arr[j + 1] = arr[j]
      } else {
        break;
      }
    }
    arr[j + 1] = inseringData
  }
}

성능은 선택정렬과 같이 O(n^2)이 된다.

장점 : 이해와 구현이 쉽다.

단점 : 성능이 좋지 못하다.

정렬 - 병합정렬

분할정복을 이용한 정렬이다.

배열을 반으로 줄이고 이걸 계속 반복하여 1이 나올때까지 분할한 뒤 두 배열을 정렬하여 하나의 배열로 병합하는 것이다.

function MergeSort(arr, leftIndex, rightIndex) {
  if (leftIndex < rightIndex) {
    let midIndex = parseInt((leftIndex + rightIndex) / 2)
    MergeSort(arr, leftIndex, midIndex)
    MergeSort(arr, midIndex+1, rightIndex)
    Merge(arr, leftIndex, midIndex, rightIndex)
  }
  
}
​
function Merge(arr, leftIndex, midIndex, rightIndex) {
  let leftAreaIndex = leftIndex
  let rightAreaIndex = midIndex + 1
  
  let tempArr = []
  tempArr.length = rightIndex + 1
  tempArr.fill(0, 0, rightIndex+1)
  
  let tempArrIndex = leftIndex
  while(leftAreaIndex <= midIndex && rightAreaIndex <= rightIndex) {
    if (arr[leftAreaIndex] <= arr[rightAreaIndex]) {
      tempArr[tempArrIndex] = arr[leftAreaIndex++]
     
    } else {
      tempArr[tempArrIndex] = arr[rightAreaIndex++]
    }
    tempArrIndex++
  }
  if(leftAreaIndex > midIndex) {
    for (let i = rightAreaIndex; i <= rightIndex; i++) {
      tempArr[tempArrIndex++] = arr[i]
    } 
  } else {
    for (let i = leftAreaIndex; i<= midIndex; i++) {
      tempArr[tempArrIndex++] = arr[i]
    }
  }
  for (let i = leftIndex; i<= rightIndex; i++) {
    arr[i] = tempArr[i]
  }
}

장점 : O(nlogn)으로 성능이 좋음

단점 : 이해와 구현이 어려움

정렬 - 퀵정렬

pivot을 설정하여 피봇을 기준으로 정렬하는 정렬방법이다.

function quickSort(arr, left, right) {
  if (left <= right) {
    // 정렬된 pivot의 위치를 정렬
    let pivot = devide(arr, left, right)
    quickSort(arr, left, pivot - 1)
    quickSort(arr, pivot+1, right)
  }
}
​
function devide(arr, left, right) {
  let pivot = arr[left]
  let leftStartIndex = left + 1
  let rightStartIndex = right
  
  while (leftStartIndex <= rightStartIndex) {
    while (leftStartIndex <= right && pivot >= arr[leftStartIndex]) {
      leftStartIndex+= 1
    }
    while (rightStartIndex >= left + 1 && pivot <= arr[rightStartIndex]) {
      rightStartIndex -= 1
    }
    if (leftStartIndex <= rightStartIndex) {
      swap(arr, leftStartIndex, rightStartIndex)
    }
  }
  swap(arr, left, rightStartIndex)
  return rightStartIndex
}
​
function swap(arr, left, rightStartIndex) {
  let temp = arr[left]
  arr[left] = arr[rightStartIndex]
  arr[rightStartIndex] = temp
}

퀵 정렬이 더 적은 비교와 더 나은 메모리를 사용하기 때문에 퀵 정렬이 더 좋은 알고리즘으로 평가받는다.

동적 프로그래밍 - 메모이제이션

분할 정복을 통해 문제를 해결할 경우 저장 장치를 사용하여 시간 복잡도를 많이 줄일 수 있다.

// 메모이제이션이 없을 경우
function fibo(n) {
  if (n == 0 || n == 1) {
    return n
  }
  return fibo(n-1) + fibo(n-2)
}

위의 코드는 중복으로 계산되는 식이 매우 많다.

메모이제이션은 계산결과를 저장하여 사용하는 것이다.해시테이블을 사용하여 메모이제이션을 구현해보자.

function fibo(n, memo) {
  if (n == 0 || n == 1) return n
  if (memo[n] == null) {
    memo[n] = fibo(n-2,memo) + fibo(n-1, memo)
  }
  return memo[n]
}

메모이제이션을 사용하지 않는다면 O(n^2)이고 메모이제이션을 사용하면 O(n)이 소모된다.

동적 프로그래밍 - 타뷸레이션

타뷸레이션은 상향식 계산방식으로 계산에 필요한 모든 값을 계산 후 테이블에 저장해둔다.

function fibo(n) {
  if (n == 0 || n == 1) {return n}
  let table = [0,1]
  for (let i = 2; i <= n; i++) {
    table[i] = table[i - 2] + table[i - 1]
  }
  return table[n]
}

메모이제이션은 공간을 차지하고 콜 스택으로 인한 함수 호출이 걸리지만 타뷸레이션은 콜 스택 메모리를 사용하지 않는다.

메모이제이션은 재귀적으로 문제를 하위문제로 나눈다. 어떤 문제의 경우에는 재귀가 직관적이지 않다. 따라서 타뷸레이션을 사용하는 것이 더 좋을 수도 있다.

댓글을 작성해보세요.


채널톡 아이콘