정렬 된 숫자 배열에 숫자를 삽입하는 효율적인 방법?


143

정렬 된 JavaScript 배열이 있고 배열에 하나 이상의 항목을 삽입하여 결과 배열이 정렬 된 상태로 유지하려고합니다. 간단한 퀵 정렬 스타일 삽입 기능을 확실히 구현할 수있었습니다.

var array = [1,2,3,4,5,6,7,8,9];
var element = 3.5;
function insert(element, array) {
  array.splice(locationOf(element, array) + 1, 0, element);
  return array;
}

function locationOf(element, array, start, end) {
  start = start || 0;
  end = end || array.length;
  var pivot = parseInt(start + (end - start) / 2, 10);
  if (end-start <= 1 || array[pivot] === element) return pivot;
  if (array[pivot] < element) {
    return locationOf(element, array, pivot, end);
  } else {
    return locationOf(element, array, start, pivot);
  }
}

console.log(insert(element, array));

[경고] 이 코드는 배열의 시작 부분에 삽입하려고 할 때 버그가 insert(2, [3, 7 ,9]있습니다.

그러나 Array.sort 함수를 구현하면 잠재적 으로이 작업을 수행 할 수 있음을 알았습니다.

var array = [1,2,3,4,5,6,7,8,9];
var element = 3.5;
function insert(element, array) {
  array.push(element);
  array.sort(function(a, b) {
    return a - b;
  });
  return array;
}

console.log(insert(element, array));

두 번째 구현에서 첫 번째 구현을 선택해야 할 이유가 있습니까?

편집 : 일반적인 경우 O (log (n)) 삽입 (첫 번째 예에서 구현 된)은 일반 정렬 알고리즘보다 빠릅니다. 그러나 이것이 반드시 JavaScript의 경우는 아닙니다. 참고 :

  • 여러 삽입 알고리즘의 가장 좋은 경우는 O (n)이며, 이는 여전히 O (log (n))와 크게 다르지만 아래 언급 된대로 O (n log (n))만큼 나쁘지는 않습니다. 사용 된 특정 정렬 알고리즘으로 넘어갑니다 ( Javascript Array.sort 구현? 참조 )
  • JavaScript의 정렬 메소드는 기본 함수이므로 잠재적으로 큰 이점을 얻는 잠재적 인 큰 이점을 실현할 수 있습니다. 합당한 크기의 데이터 세트의 경우 O (n)보다 훨씬 큰 계수를 갖는 O (log (n))가 여전히 더 나쁠 수 있습니다.

두 번째 구현에서 스플 라이스를 사용하는 것은 약간 낭비입니다. 푸시를 사용하지 않는 이유는 무엇입니까?
Breton

좋은 지적은 방금 처음부터 복사 한 것입니다.
Elliot Kroo

4
포함 아무거나 splice()(예를 들어, 당신의 1 예) O (n)이 이미 있습니다. 내부적으로 전체 배열의 새 복사본을 만들지 않더라도 요소를 위치 0에 삽입하려면 n 항목을 모두 1 위치 뒤로 분류해야 할 수 있습니다. 기본 함수이므로 상수가 빠릅니다. 그럼에도 불구하고 O (n)입니다.
j_random_hacker

6
또한이 코드를 사용하는 사람들을 위해 나중에 참조 할 수 있도록 코드는 배열의 시작 부분에 삽입하려고 할 때 버그가 있습니다. 수정 된 코드를 자세히 살펴보십시오.
피노키오

3
사용하지 마십시오 parseInt사용을 Math.floor대신. Math.floor보다 빠릅니다 parseInt: jsperf.com/test-parseint-and-math-floor
Hubert Schölnast

답변:


58

단일 데이터 포인트와 마찬가지로, 킥을 위해 Windows 7에서 Chrome을 사용하는 두 가지 방법을 사용하여 1000 개의 임의 요소를 10 개의 사전 정렬 된 숫자 배열에 삽입하여 이것을 테스트했습니다.

First Method:
~54 milliseconds
Second Method:
~57 seconds

따라서 적어도이 설정에서 기본 방법은 그것을 보완하지 못합니다. 작은 데이터 세트의 경우에도 1000 개의 배열에 100 개의 요소를 삽입합니다.

First Method:
1 milliseconds
Second Method:
34 milliseconds

1
arrays.sort 아주 끔찍한 소리
njzk2

2
54 microseconds 내에 단일 요소를 삽입하려면 array.splice가 실제로 영리한 작업을 수행해야합니다.
gnasher729

@ gnasher729-Javascript 배열이 실제로 C에서와 같이 물리적으로 연속적인 배열과 동일하다고 생각하지 않습니다. JS 엔진이 빠른 삽입을 가능하게하는 해시 맵 / 사전으로 구현할 수 있다고 생각합니다.
Ian

1
와 비교기 함수를 사용 Array.prototype.sort하면 JS 함수가 너무 많이 호출되므로 C ++의 이점을 잃게됩니다.
aleclarson

Chrome에서 TimSort를 사용 한다는 점에서 첫 번째 방법은 어떻게 비교 됩니까 ? 에서 TimSort 위키 백과 : "입력이 이미 [TimSort] 선형 시간에 실행 정렬 할 때 발생하는 가장 좋은 경우에,".
호화 로울지도 모르는

47

단순 ( 데모 ) :

function sortedIndex(array, value) {
    var low = 0,
        high = array.length;

    while (low < high) {
        var mid = (low + high) >>> 1;
        if (array[mid] < value) low = mid + 1;
        else high = mid;
    }
    return low;
}

4
좋은 터치. 비트 연산자를 사용하여 두 숫자의 중간 값을 찾는 것에 대해 들어 본 적이 없습니다. 일반적으로 0.5를 곱합니다. 이런 식으로 성능이 크게 향상됩니까?
Jackson

2
@Jackson은 x >>> 1효과적으로 2 예 (11)에 의해 분할 단지 1 개 위치에 의해 이진 우측 시프트이다 : 1011-> 101(5)에 결과
쿼티

3
이미이 트랙에 @Qwerty @Web_Designer 존재, 당신은 차이를 설명 할 수 >>> 1와 (은 볼 수 여기 거기에 ) >> 1?
yckart

4
>>>부호없는 오른쪽 이동 인 반면 >>부호 확장하는 것은 음수의 메모리 내 표현으로 귀결되며, 높은 비트는 음수로 설정됩니다. 따라서 한 0b1000곳으로 오른쪽으로 이동 >>하면 얻을 것이고 0b1100, 대신 사용 >>>하면 얻을 수 0b0100있습니다. 대답에 주어진 경우 실제로 문제가되지 않지만 (숫자가 부호있는 32 비트 양의 정수의 최대 값 또는 음수보다 크지 않은 숫자로 변경되지는 않지만) 두 경우에 올바른 것을 사용해야합니다 (당신은 처리해야 할 사례를 선택해야합니다).
asherkin

2
@asherkin-이것은 옳지 않다 : " 당신이 0b10001 곳을 오른쪽으로 옮기면"이 나옵니다 . 아니요 . 다른 오른쪽 시프트 연산자의 결과는 음수와 2 ^ 31보다 큰 수 (즉, 첫 번째 비트에 1이있는 숫자)를 제외한 모든 값에 대해 동일합니다. >>0b11000b0100
gilly3

29

매우 흥미로운 토론으로 매우 좋고 놀라운 질문입니다! 또한 Array.sort()수천 개의 객체가있는 배열에서 단일 요소를 푸시 한 후 함수를 사용하고있었습니다 .

locationOf복잡한 객체가 있기 때문에 목적에 따라 함수 를 확장해야 하므로 다음과 같은 비교 함수가 필요합니다 Array.sort().

function locationOf(element, array, comparer, start, end) {
    if (array.length === 0)
        return -1;

    start = start || 0;
    end = end || array.length;
    var pivot = (start + end) >> 1;  // should be faster than dividing by 2

    var c = comparer(element, array[pivot]);
    if (end - start <= 1) return c == -1 ? pivot - 1 : pivot;

    switch (c) {
        case -1: return locationOf(element, array, comparer, start, pivot);
        case 0: return pivot;
        case 1: return locationOf(element, array, comparer, pivot, end);
    };
};

// sample for objects like {lastName: 'Miller', ...}
var patientCompare = function (a, b) {
    if (a.lastName < b.lastName) return -1;
    if (a.lastName > b.lastName) return 1;
    return 0;
};

7
기록을 위해이 버전은 어레이의 시작 부분에 삽입하려고 할 때 올바르게 작동한다는 점에 주목할 필요가 있습니다. (원래 질문의 버전에 버그가 있고 해당 경우 제대로 작동하지 않기 때문에 언급 할 가치가 있습니다.)
garyrob

3
구현이 다른지 확실하지 않지만 return c == -1 ? pivot : pivot + 1;올바른 인덱스를 반환하려면 삼항을 변경해야했습니다 . 그렇지 않으면 길이가 1 인 배열의 경우 함수는 -1 또는 0을 반환합니다.
Niel

3
@James : 시작 및 종료 매개 변수는 재귀 호출에만 사용되며 초기 호출에는 사용되지 않습니다. 이들은 배열의 색인 값이므로 정수 유형이어야하며 재귀 호출시 내재적으로 제공됩니다.
kwrl 2016

1
@TheRedPea : 아니오, 나는>> 1/ 2
kwrl

1
comparer기능 결과에 잠재적 인 문제가 있음을 알 수 있습니다. 이 알고리즘에서는 비교 +-1되지만 임의의 값 <0/ 일 수 있습니다 >0. 비교 기능을 참조하십시오 . 문제가있는 부분은뿐만 아니라 switch: 문뿐만 아니라 라인 과 비교 뿐만 아니라. if (end - start <= 1) return c == -1 ? pivot - 1 : pivot;c-1
eXavier

19

코드에 버그가 있습니다. 읽어야합니다.

function locationOf(element, array, start, end) {
  start = start || 0;
  end = end || array.length;
  var pivot = parseInt(start + (end - start) / 2, 10);
  if (array[pivot] === element) return pivot;
  if (end - start <= 1)
    return array[pivot] > element ? pivot - 1 : pivot;
  if (array[pivot] < element) {
    return locationOf(element, array, pivot, end);
  } else {
    return locationOf(element, array, start, pivot);
  }
}

이 수정이 없으면 코드는 배열의 시작 부분에 요소를 삽입 할 수 없습니다.


왜 0으로 정수를 작성합니까? 즉 시작하는 것 || 0?
피노키오

3
@Pinocchio : 시작 || 0은 다음과 같습니다. if (! start) start = 0; 그러나 "더 긴"버전은 변수를 자신에게 할당하지 않기 때문에 더 효율적입니다.
SuperNova

11

나는 이것이 이미 답을 가지고있는 오래된 질문이라는 것을 알고 있으며, 다른 괜찮은 대답이 많이 있습니다. O (log n)에서 올바른 삽입 색인을 찾아서이 문제를 해결할 수 있다고 제안하는 답변이 있습니다.하지만 할 수는 있지만 배열을 부분적으로 복사해야하기 때문에 그 시간에 삽입 할 수 없습니다 우주.

결론 : 실제로 정렬 된 배열에 O (log n) 삽입 및 삭제가 필요한 경우 배열이 아닌 다른 데이터 구조가 필요합니다. B- 트리를 사용해야합니다 . 대용량 데이터 세트에 B-Tree를 사용하면 얻을 수있는 성능 향상으로 인해 여기에서 제공하는 개선 사항이 뒤 떨어질 수 있습니다.

배열을 사용해야하는 경우 나는 배열이 이미 정렬 된 경우에만 작동하는 삽입 정렬을 기반으로 다음 코드를 제공합니다 . 이것은 모든 인서트 후에 의지해야 할 경우에 유용합니다.

function addAndSort(arr, val) {
    arr.push(val);
    for (i = arr.length - 1; i > 0 && arr[i] < arr[i-1]; i--) {
        var tmp = arr[i];
        arr[i] = arr[i-1];
        arr[i-1] = tmp;
    }
    return arr;
}

O (n)에서 작동해야합니다. 당신이 할 수있는 최선이라고 생각합니다. js가 다중 할당을 지원하면 더 좋을 것입니다. 다음은 함께 연주하는 예입니다.

최신 정보:

이것은 더 빠를 수 있습니다.

function addAndSort2(arr, val) {
    arr.push(val);
    i = arr.length - 1;
    item = arr[i];
    while (i > 0 && item < arr[i-1]) {
        arr[i] = arr[i-1];
        i -= 1;
    }
    arr[i] = item;
    return arr;
}

JS Bin 링크 업데이트


JavaScript에서는 splice가 빠른 구현을 갖기 때문에 제안한 삽입 정렬이 이진 검색 및 스플 라이스 방법보다 느립니다.
trincot

자바 스크립트가 어떻게 든 시간 복잡성의 법칙을 어길 수 없다면, 나는 회의적입니다. 이진 검색 및 스플 라이스 방법이 더 빠른 방법에 대한 실행 가능한 예가 있습니까?
domoarigato

나는 두 번째 의견을 되풀이한다 ;-) 실제로, B- 트리 솔루션이 스플 라이스 솔루션을 능가하는 배열 크기가있을 것입니다.
trincot

9

삽입 함수는 주어진 배열이 정렬되어 있다고 가정하고, 일반적으로 배열의 몇 가지 요소 만보고 새 요소를 삽입 할 수있는 위치를 직접 검색합니다.

배열의 일반적인 정렬 기능은 이러한 단축키를 사용할 수 없습니다. 분명히 배열의 모든 요소를 ​​검사하여 이미 올바르게 정렬되어 있는지 확인해야합니다. 이 사실만으로도 삽입 기능보다 일반 정렬 속도가 느려집니다.

일반적인 정렬 알고리즘은 일반적으로 평균 O (n ⋅ log (n)) 이며 구현에 따라 배열이 이미 정렬 된 경우 실제로 최악의 경우 일 수 있으며 O (n 2 )의 복잡성을 초래 합니다. 삽입 위치를 직접 검색하는 대신 O (log (n)) 의 복잡성이 있으므로 항상 훨씬 빠릅니다.


배열에 요소를 삽입하는 것은 O (n)의 복잡성을 가지므로 최종 결과는 거의 같아야합니다.
NemPlayer

5

소수의 항목의 경우 그 차이는 매우 사소합니다. 그러나 많은 항목을 삽입하거나 매우 큰 배열을 사용하는 경우 삽입 할 때마다 .sort ()를 호출하면 엄청난 오버 헤드가 발생합니다.

나는이 정확한 목적을 위해 꽤 매끄러운 바이너리 검색 / 삽입 기능을 작성하여 공유 할 것이라고 생각했습니다. while재귀 대신 루프를 사용하기 때문에 추가 함수 호출에 대해 들리지 않았으므로 원래 게시 된 메소드 중 어느 것보다 성능이 더 좋을 것이라고 생각합니다. 그리고 기본적으로 기본 Array.sort()비교기를 에뮬레이트 하지만 원하는 경우 사용자 정의 비교기 기능을 허용합니다.

function insertSorted(arr, item, comparator) {
    if (comparator == null) {
        // emulate the default Array.sort() comparator
        comparator = function(a, b) {
            if (typeof a !== 'string') a = String(a);
            if (typeof b !== 'string') b = String(b);
            return (a > b ? 1 : (a < b ? -1 : 0));
        };
    }

    // get the index we need to insert the item at
    var min = 0;
    var max = arr.length;
    var index = Math.floor((min + max) / 2);
    while (max > min) {
        if (comparator(item, arr[index]) < 0) {
            max = index;
        } else {
            min = index + 1;
        }
        index = Math.floor((min + max) / 2);
    }

    // insert the item
    arr.splice(index, 0, item);
};

다른 라이브러리를 사용할 수있는 경우 lodash는 루프 대신 사용할 수있는 sortedIndexsortedLastIndex 함수를 제공합니다 while. 두 가지 잠재적 단점은 1) 성능이 내 방법만큼 좋지 않다는 것입니다 (얼마나 나빠지는지는 잘 모르겠습니다) .2) 사용자 정의 비교기 기능을 받아들이지 않고 비교할 가치를 얻는 방법 만 (기본 비교기를 사용하여 가정합니다).


호출 arr.splice()은 반드시 O (n) 시간 복잡성입니다.
domoarigato '

4

다음은 몇 가지 생각입니다. 첫째, 코드 런타임에 대해 정말로 염려되는 경우 내장 함수를 호출 할 때 어떤 일이 발생하는지 알아야합니다! 나는 자바 스크립트에서 아는 것이 없지만 splice 함수의 빠른 구글 이 이것을 반환 했다. 매 호출마다 완전히 새로운 배열을 생성하고 있음을 나타내는 것 같다! 실제로 중요한지 모르겠지만 효율성과 관련이 있습니다. 나는 주석에서 Breton이 이미 이것을 지적했지만, 당신이 선택한 배열 조작 기능을 확실히 가지고 있습니다.

어쨌든 실제로 문제를 해결합니다.

정렬하고 싶다는 내용을 읽었을 때 가장 먼저 생각하는 것은 삽입 정렬입니다. . 정렬 된 목록 또는 거의 정렬 된 목록에서 선형 시간으로 실행 되므로 편리 합니다. . 배열의 순서가 1 개 밖에 없으므로 배열이 거의 정렬 된 것으로 간주됩니다 (크기가 2 또는 3 인 배열 또는 그 시점에서는 c'mon 제외). 이제 정렬을 구현하는 것은 그리 나쁘지 않지만 다루기 싫은 번거 로움입니다. 다시 한 번 자바 스크립트에 대해 알지 못하고 쉬운 지 또는 어려운지 여부를 알지 못합니다. 이렇게하면 조회 기능이 필요하지 않으며 Breton이 제안한대로 푸시하면됩니다.

둘째, "quicksort-esque"조회 기능은 이진 검색 알고리즘 인 것 같습니다 ! 직관적이고 빠르지 만 매우 유용한 알고리즘이지만 한 가지 문제점이 있습니다. 올바르게 구현하기는 매우 어렵다는 것입니다. 나는 당신의 것이 옳은지 아닌지를 감히 말하지 않을 것입니다 (물론 그것이 좋기를 바랍니다! :)) 그것을 사용하고 싶다면 조심하십시오.

어쨌든 요약 : 삽입 정렬과 함께 "푸시"를 사용하면 선형 시간으로 작동하고 (배열의 나머지가 정렬되었다고 가정) 지저분한 바이너리 검색 알고리즘 요구 사항을 피하십시오. 이것이 최선의 방법인지 알지 못합니다 (배열의 기본 구현, 미친 내장 함수가 더 잘 수행 할 수 있습니다). 그러나 그것은 나에게 합리적입니다. :)-아 고르


1
포함하는 splice()것이 이미 O (n) 이므로 +1 입니다. 내부적으로 전체 어레이의 새로운 복사본을 생성하지 않더라도, 요소가 0 위치에 삽입되는 경우 n 개의 모든 항목을 1 위치로 다시 분류해야합니다.
j_random_hacker

삽입 정렬도 O (n) 최상의 경우이고 O (n ^ 2) 최악의 경우라고 생각합니다 (OP의 사용 사례는 아마도 가장 좋은 경우입니다).
domoarigato '

OP와 대화를 나누기위한 하나 빼기. 첫 단락은 후드에서 스플 라이스가 어떻게 작동하는지 모르는 것에 대한 불필요한 훈계처럼 느껴졌다
Matt Zera

2

이를 달성하기위한 4 가지 알고리즘의 비교는 다음과 같습니다. https://jsperf.com/sorted-array-insert-comparison/1

알고리즘

순진은 항상 끔찍합니다. 작은 배열 크기의 경우 다른 세 가지가 크게 다르지 않지만 큰 배열의 경우 마지막 2가 간단한 선형 접근 방식보다 성능이 뛰어납니다.


빠른 삽입 및 검색을 구현하도록 설계된 데이터 구조를 테스트하지 않겠습니까? 전의. 목록과 BST를 건너 뜁니다. stackoverflow.com/a/59870937/3163618
qwr

Chrome이 TimSort를 사용 한다는 것을 Native는 어떻게 비교 합니까? 에서 TimSort 위키 백과 : "입력이 이미 정렬 할 때 발생하는 가장 좋은 경우, 그것은 선형 시간에 실행됩니다."
호화 로울지도 모르는

2

lodash를 사용하는 버전이 있습니다.

const _ = require('lodash');
sortedArr.splice(_.sortedIndex(sortedArr,valueToInsert) ,0,valueToInsert);

참고 : sortedIndex는 이진 검색을 수행합니다.


1

내가 생각할 수있는 가장 좋은 데이터 구조 는 로그 시간 작업을 가능하게하는 계층 구조로 연결된 목록의 삽입 속성을 유지 관리 하는 인덱싱 된 건너 뛰기 목록 입니다. 평균적으로 검색, 삽입 및 임의 액세스 조회는 O (log n) 시간 내에 수행 할 수 있습니다.

주문 통계 트리 순위 기능 로그 시간 인덱싱을 할 수 있습니다.

임의 액세스가 필요하지 않지만 O (log n) 삽입 및 키 검색이 필요한 경우 배열 구조를 버리고 모든 종류의 이진 검색 트리를 사용할 수 있습니다.

array.splice()평균 O (n) 시간이므로 사용하는 답이 전혀 효율적이지는 않습니다. Chrome에서 array.splice ()의 시간 복잡성은 무엇입니까?


이 답변은 어떻습니까Is there a good reason to choose [splice into location found] over [push & sort]?
greybeard

1
@greybeard 제목에 답합니다. 냉소적으로 어떤 선택도 효율적이지 않습니다.
qwr

배열의 많은 요소를 복사 해야하는 경우 두 옵션 모두 효율적이지 않을 수 있습니다.
qwr

1

다음은 이진 검색을 사용하여 항목을 찾은 다음 적절하게 삽입하는 함수입니다.

function binaryInsert(val, arr){
    let mid, 
    len=arr.length,
    start=0,
    end=len-1;
    while(start <= end){
        mid = Math.floor((end + start)/2);
        if(val <= arr[mid]){
            if(val >= arr[mid-1]){
                arr.splice(mid,0,val);
                break;
            }
            end = mid-1;
        }else{
            if(val <= arr[mid+1]){
                arr.splice(mid+1,0,val);
                break;
            }
            start = mid+1;
        }
    }
    return arr;
}

console.log(binaryInsert(16, [
    5,   6,  14,  19, 23, 44,
   35,  51,  86,  68, 63, 71,
   87, 117
 ]));


0

모든 항목을 다시 정렬하지 마십시오.

삽입 할 항목이 하나만 있으면 이진 검색을 사용하여 삽입 할 위치를 찾을 수 있습니다. 그런 다음 memcpy 또는 유사 항목을 사용하여 나머지 항목을 대량 복사하여 삽입 된 항목을위한 공간을 확보하십시오. 이진 검색은 O (log n)이고 복사본은 O (n)이며 O (n + log n)는 합계입니다. 위의 방법을 사용하면 모든 삽입 후 다시 정렬을 수행합니다 (O (n log n)).

그게 그렇게 중요한 건가? k = 1000 인 k 요소를 임의로 삽입한다고 가정합니다. 정렬 된 목록은 5000 개의 항목입니다.

  • Binary search + Move = k*(n + log n) = 1000*(5000 + 12) = 5,000,012 = ~5 million ops
  • Re-sort on each = k*(n log n) = ~60 million ops

삽입 할 k 개의 항목이 도착할 때마다 검색 + 이동을 수행해야합니다. 그러나 정렬 된 배열에 삽입 할 k 개의 항목 목록이 미리 제공되면 훨씬 더 잘 수행 할 수 있습니다. 이미 정렬 된 n 배열과 별도로 k 개의 항목을 정렬합니다. 그런 다음 스캔 정렬을 수행하면 정렬 된 두 어레이를 동시에 아래로 이동하여 하나를 다른 어레이로 병합합니다. -원스텝 병합 정렬 = k log k + n = 9965 + 5000 = ~ 15,000 ops

업데이트 : 귀하의 질문에 대하여.
First method = binary search+move = O(n + log n). Second method = re-sort = O(n log n)당신이 얻는 타이밍을 정확하게 설명하십시오.


예, 그러나 아니요, 정렬 알고리즘에 따라 다릅니다. 거품 정렬을 역순으로 사용하면 마지막 요소가 정렬되지 않은 경우 정렬은 항상 o (n)입니다.
njzk2

-1
function insertOrdered(array, elem) {
    let _array = array;
    let i = 0;
    while ( i < array.length && array[i] < elem ) {i ++};
    _array.splice(i, 0, elem);
    return _array;
}
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.