並べ替えられた数値の配列に数値を挿入する効率的な方法?


142

並べ替えられたJavaScript配列があり、結果の配列が並べ替えられたままになるように、配列に1つ以上の項目を挿入します。私は確かに単純なクイックソートスタイルの挿入関数を実装できました。

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])が正しくない[3、2、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));

2番目の実装よりも最初の実装を選択する十分な理由はありますか?

編集:一般的なケースでは、O(log(n))挿入(最初の例で実装されている)は、一般的な並べ替えアルゴリズムよりも高速です。ただし、これは特にJavaScriptの場合には必ずしも当てはまりません。ご了承ください:

  • いくつかの挿入アルゴリズムの最良のケースは、O(n)です。これは、O(log(n))とはかなり異なりますが、以下で説明するように、O(n log(n))ほど悪くはありません。それは使用される特定のソートアルゴリズムに帰着しますJavascript Array.sort実装を参照してください
  • JavaScriptのsortメソッドはネイティブ関数であるため、大きなメリットを実現できる可能性があります-巨大な係数を持つO(log(n))は、適度なサイズのデータ​​セットの場合、O(n)よりもはるかに悪い可能性があります。

2番目の実装でスプライスを使用することは少し無駄です。なぜプッシュを使用しないのですか?
ブルトン

良い点は、最初からコピーしたことです。
Elliot Kroo

4
を含むものsplice()(たとえば、最初の例)はすでにO(n)です。配列全体の新しいコピーを内部で作成しなくても、要素を位置0に挿入する場合、n項目すべてを1ポジション分戻さなければならない可能性があります。ネイティブ関数であり、定数が低いですが、それでもO(n)です。
j_random_hacker

6
また、このコードを使用する人々が将来参照できるように、配列の先頭に挿入しようとすると、コードにバグがあります。修正されたコードをさらに調べます。
ピノキオ

3
代わりにuseをparseInt使用しないでくださいMath.floorMath.floorはるかに高速よりparseIntjsperf.com/test-parseint-and-math-floor
ヒューバートSchölnast

回答:


58

単一のデータポイントと同じように、キックのために、Windows 7でChromeを使用する2つの方法を使用して、事前にソートされた100,000の配列に1000個のランダム要素を挿入することをテストしました。

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

したがって、少なくともこの設定では、ネイティブメソッドはそれを補うことができません。これは、1000の配列に100個の要素を挿入する小さなデータセットにも当てはまります。

First Method:
1 milliseconds
Second Method:
34 milliseconds

1
array.sortは非常にひどいようです
njzk2

2
array.spliceは、54マイクロ秒以内に単一の要素を挿入するために、本当に賢いことをしている必要があるようです。
gnasher729 2015

@ gnasher729-Javascript配列は、Cのように物理的に連続した配列と実際には同じではないと思います。JSエンジンは、ハッシュ挿入/ハッシュとしてそれらを実装して、迅速な挿入を可能にすると思います。
Ian

1
でコンパレーター関数を使用するArray.prototype.sortと、JS関数が非常に呼び出されるため、C ++の利点が失われます。
aleclarson

ChromeがTimSortを使用するようになった今、最初の方法はどのように比較されますか?TimSort Wikipediaから:「入力が既にソートされているときに発生する最良のケースでは、[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
いい感じです。ビットごとの演算子を使用して2つの数値の中間値を見つけることは聞いたことがありません。通常は0.5を掛けるだけです。このようにすると、パフォーマンスが大幅に向上しますか?
ジャクソン

2
@Jacksonがx >>> 1効果的に11 2.等によりわずか部門である1つの位置、によってバイナリ右シフトである:1011- > 1015に結果
QWERTY配列

3
すでにこのトラック上@Qwerty @Web_Designerビーイングは、あなたは違い説明できた>>> 1(と見られ 、ここ そこを>> 1
yckart 2016

4
>>>符号なし右シフトであるのに対し、符号>>拡張です-負の数のメモリ内表現に要約され、負の場合、上位ビットが設定されます。したがって、0b1000右に1桁シフトする>>とが得られ0b1100、代わりに使用する>>>とが得られ0b0100ます。答えで与えられたケースではそれは本当に問題ではありません(シフトされた数は、符号付き32ビット正整数の最大値よりも大きくなく、負でもありません)、これら2つのケースで正しいものを使用することが重要です(処理する必要があるケースを選択する必要があります)。
asherkin

2
@asherkin-これは正しくありません:「0b1000右に1桁シフトすると>>、結果が得られます0b1100」。いいえ、取得し0b0100ます。異なる右シフト演算子の結果は、負の数と2 ^ 31より大きい数(つまり、最初のビットが1の数)を除くすべての値で同じになります。
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;、正しいインデックスを返すために、3進数をに変更する必要がありました。そうでない場合は長さ1の機能を有する配列を返す-1または0になるため
ニール

3
@James:パラメータstartおよびendは、再帰呼び出しでのみ使用され、初期呼び出しでは使用されません。これらは配列のインデックス値であるため、整数型である必要があり、再帰呼び出しではこれが暗黙的に指定されます。
kwrl

1
@TheRedPea:いいえ、私は>> 1より速い(または遅くない)べきであることを意味しました/ 2
kwrl

1
comparer関数の結果に潜在的な問題があることがわかります。このアルゴリズムでは、それは比較されます+-1が、任意の値である可能性があります<0/ >0比較機能を参照してください。問題のある部分はswitchステートメントだけでなく、次の行でもあります: if (end - start <= 1) return c == -1 ? pivot - 1 : pivot;where cも比較さ-1れます。
eXavier 2018

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でintをor-ingしていますか?つまり、何が始まるのですか?0か?
ピノキオ

3
@Pinocchio:開始|| 0は以下と同じです:if(!start)start = 0; -ただし、「より長い」バージョンは、それ自体に変数を割り当てないため、より効率的です。
SuperNova 2014

11

私はこれがすでに答えを持っている古い質問であることを知っています、そして他の多くのまともな答えがあります。O(log n)で正しい挿入インデックスを検索することでこの問題を解決できることを提案するいくつかの回答が見られます-可能ですが、配列を部分的にコピーして作成する必要があるため、その時点では挿入できませんスペース。

結論:ソートされた配列へのO(log n)の挿入と削除が本当に必要な場合は、配列ではなく、別のデータ構造が必要です。Bツリーを使用する必要があります。大規模なデータセットに対してBツリーを使用することで得られるパフォーマンスの向上は、ここで提供される改善のいずれよりも小さくなります。

配列を使用する必要がある場合。配列が既にソートされている場合にのみ機能する挿入ソートに基づいて、次のコードを提供します。これは、挿入するたびに再ソートする必要がある場合に役立ちます。

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の実装が高速であるため、提案する挿入ソートは、バイナリ検索およびスプライスメソッドよりも遅くなります。
トリンコット

JavaScriptが何らかの形で時間の複雑さの法則を破ることができない限り、私は懐疑的です。バイナリサーチとスプライスメソッドがどのように高速であるかについての実行可能な例はありますか?
domoarigato

2番目のコメントを取り戻します;-)確かに、それを超えるとBツリーソリューションがスプライスソリューションよりも優れた配列サイズになります。
トリンコット

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ループを。2つの潜在的な欠点は、1)パフォーマンスが私の方法ほど良くない(それがどれほど悪いかわからない)と、2)カスタムコンパレーター関数を受け入れず、比較する値を取得する方法のみです。 (デフォルトのコンパレータを使用すると思います)。


への呼び出しarr.splice()は確かにO(n)時間の複雑さです。
domoarigato

4

ここにいくつかの考えがあります:最初に、コードの実行時間について本当に心配している場合は、組み込み関数を呼び出すときに何が起こるかを確認してください!私はjavascriptで上から下まではわかりませんが、splice関数の簡単なgoogleがこれを返しました。これは、呼び出しごとにまったく新しい配列を作成していることを示しているようです。それが実際に重要かどうかはわかりませんが、確かに効率に関係しています。コメントの中で、ブルトンはすでにこれを指摘しているようですが、選択した配列操作関数には確実に当てはまります。

とにかく、実際に問題を解決することに。

あなたがソートしたかったことを読んだとき、私の最初の考えは挿入ソートを使用することです!並べ替えられたリストまたはほぼ並べ替えられたリストで線形時間で実行されるので便利です。配列の要素の順序は1つだけなので、並べ替えはほぼソートされたものと見なされます(ただし、サイズ2または3の配列などは除きますが、その時点ではc'mon)。さて、並べ替えの実装はそれほど悪くはありませんが、扱いたくないかもしれない面倒であり、繰り返しになりますが、JavaScriptについてのことや、それが簡単か難しいかどうかはわかりません。これにより、検索機能が不要になり、プッシュするだけです(Bretonが示唆したとおり)。

次に、「quicksort-esque」ルックアップ関数はバイナリ検索アルゴリズムのようです!これは非常に優れたアルゴリズムであり、直感的で高速ですが、1つの欠点があります。正しく実装することは非常に困難です。私はあなたが正しいかどうかを敢えて言うつもりはありませんが(もちろんそれが正しいことを願っています!:))、それを使用する場合は注意してください。

とにかく、要約:挿入ソートで「プッシュ」を使用すると、線形時間で動作し(配列の残りの部分がソートされていると想定)、乱雑なバイナリ検索アルゴリズムの要件を回避できます。これが最善の方法であるかどうかはわかりませんが(配列の基本的な実装、多分、クレイジーな組み込み関数の方が優れています)。:)-Agor。


1
+1を含むものsplice()はすでにO(n)であるため。それは内部的に、アレイ全体の新しいコピーを作成しない場合でも、要素が位置0に挿入される場合、それは潜在的にバック1点の位置をn個すべてのアイテムを分流しなければならない
j_random_hacker

挿入ソートもO(n)の最良のケースであり、O(n ^ 2)の最悪のケースであると思います(ただし、OPのユースケースがおそらく最良のケースです)。
domoarigato

OPに話しかけるためのマイナス1。最初の段落は、スプライスが内部でどのように機能するかを知らないことに対する不必要な警告のように感じました
Matt Zera

2

これを実現するための4つの異なるアルゴリズムの比較を次に示します。https//jsperf.com/sorted-array-insert-comparison/1

アルゴリズム

  • 素朴:後で単にpushおよびsort()する
  • 線形:配列を反復処理し、必要に応じて挿入します
  • バイナリ検索:https ://stackoverflow.com/a/20352387/154329から取得
  • 「Quick Sort Like」:syntheticzeroからの洗練されたソリューション(https://stackoverflow.com/a/18341744/154329

世間知らずは常に恐ろしいです。配列サイズが小さい場合、他の3つはそれほど大きくは違いませんが、大きい配列の場合、最後の2つは単純な線形アプローチよりも優れています。


高速な挿入と検索を実装するように設計されたデータ構造をテストしてみませんか?例 リストとBSTをスキップします。stackoverflow.com/a/59870937/3163618
qwr

ChromeがTimSortを使用するようになった今、ネイティブはどのように比較しますか?TimSort Wikipediaから:「入力が既にソートされているときに発生する最良のケースでは、線形時間で実行されます」。
最高級の

2

これはlodashを使用するバージョンです。

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

注:SortedIndexはバイナリ検索を実行します。


1

私が考えることができる最良のデータ構造は、リンクされたリストの挿入プロパティをログ時間操作を可能にする階層構造で維持するインデックス付きスキップリストです。平均して、検索、挿入、ランダムアクセスルックアップはO(log n)時間で実行できます。

順序統計ツリーがランク機能を備えたログ時刻のインデックスを可能にします。

ランダムアクセスは必要ないがO(log n)挿入とキーの検索が必要な場合は、配列構造を破棄して、任意の種類のバイナリ検索ツリーを使用できます

array.splice()これは平均O(n)時間であるため、使用する回答はどれも効率的ではありません。Google Chromeのarray.splice()の時間の複雑さはどれくらいですか?


どのようにこの答えはないIs there a good reason to choose [splice into location found] over [push & sort]?
老い

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

すべてのアイテムの後に並べ替えをしないでください。

挿入するアイテムが1つしかない場合は、バイナリ検索を使用して挿入する場所を見つけることができます。次に、memcpyなどを使用して残りのアイテムを一括コピーし、挿入されたアイテム用のスペースを作成します。バイナリ検索はO(log n)で、コピーはO(n)であり、合計O(n + log n)になります。上記の方法を使用して、O(n log n)である挿入ごとに再ソートを実行しています。

それは重要ですか?k要素をランダムに挿入しているとします(k = 1000)。ソートされたリストは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個のアイテムが常に到着する場合は、search + moveを実行する必要があります。ただし、並べ替えられた配列に挿入する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.