将数字插入排序的数字数组的有效方法?


142

我有一个已排序的JavaScript数组,并且想在该数组中再插入一个项目,以使结果数组保持排序状态。我当然可以实现一个简单的quicksort样式的插入函数:

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));

是否有充分的理由选择第一个实现而不是第二个实现?

编辑:请注意,对于一般情况,O(log(n))插入(如第一个示例中所实现)将比通用排序算法快;但是对于JavaScript来说并不一定如此。注意:

  • 几种插入算法的最佳情况是O(n),它与O(log(n))仍存在很大差异,但并不像下面提到的O(n log(n))那样糟糕。这将取决于所使用的特定排序算法(请参阅Javascript Array.sort实现?
  • JavaScript中的sort方法是一个本机函数,因此潜在地实现了巨大的好处-对于合理大小的数据集,具有巨大系数的O(log(n))仍然比O(n)差很多。

在第二种实现中使用拼接是有点浪费的。为什么不使用推?
布列塔尼

好点,我只是从头开始复制了它。
Elliot Kroo,2009年

4
包含的任何内容splice()(例如您的第一个示例)已经是O(n)。即使它没有在内部创建整个数组的新副本,如果要将元素插入位置0,它也可能必须将所有n个项目分流回1位置。也许这是快速的,因为它是一个本机函数,并且常量是低,但仍为O(n)。
j_random_hacker 2011年

6
另外,为了以后使用该代码的人员参考,该代码在尝试插入数组开头时会出现错误。向下看更正的代码。
Pinocchio 2014年

3
不要使用parseIntuse Math.floor来代替。Math.floor比快得多parseIntjsperf.com/test-parseint-and-math-floor
休伯特Schölnast

Answers:


58

就像一个数据点一样,对于踢球,我测试了这一点,使用Windows 7上的Chrome使用两种方法将1000个随机元素插入100,000个预排序数字的数组中:

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

因此,至少在此设置上,本机方法无法弥补这一不足。即使对于小型数据集,也是如此(将100个元素插入1000个数组中):

First Method:
1 milliseconds
Second Method:
34 milliseconds

1
arrays.sort听起来很糟糕
njzk2

2
似乎array.splice必须做的很聪明,才能在54微秒内插入单个元素。
gnasher729

@ gnasher729-我不认为Javascript数组与C中的物理连续数组真的一样。我认为JS引擎可以将它们实现为可快速插入的哈希映射/字典。
伊恩(Ian)

1
当将比较器函数与一起使用时Array.prototype.sort,您会失去C ++的好处,因为JS函数的调用太多了。
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
很好 我从未听说过使用按位运算符来查找两个数字的中间值。通常我会乘以0.5。这样可以显着提高性能吗?
杰克逊

2
@Jackson x >>> 1是二进制右移1位,实际上是2除。例如,11:1011-> 101结果为
5。– Qwerty

3
@Qwerty @Web_Designer已经在这个轨道上了,您能解释一下>>> 1和( 这里 那里看到的)之间的区别>> 1吗?
yckart '16

4
>>>是无符号的右移,而>>有符号的扩展-都归结为负数在内存中的表示,如果为负,则将高位置位。因此,如果您向0b1000右移1个位置,>>您将得到0b1100,如果改为使用,>>>您将得到0b0100。虽然在答案中给出的情况并不重要(要移动的数字既不大于有符号的32位正整数的最大值也不为负值),但在这两种情况下使用正确的一个很重要(您可以需要选择您需要处理的情况)。
asherkin '16

2
@asherkin-这是不对的:“如果您0b1000向右移动1位,>>您将得到0b1100”。不,你得到0b0100。对于所有值,不同的右移运算符的结果将相同,除了负数和大于2 ^ 31的数(即,第一位为1的数)。
gilly3'8

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
尼尔

3
@James:参数start和end仅在递归调用中使用,而不在初始调用中使用。因为这些是数组的索引值,所以它们必须是整数类型,并且在递归调用时会隐式给出。
kwrl '16

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吗?
Pinocchio 2014年

3
@Pinocchio:开始|| 0简短等同于:if(!start)start = 0; -但是,“较长”版本更有效,因为它没有为其自身分配变量。
SuperNova 2014年

11

我知道这是一个已经有答案的老问题,并且还有许多其他不错的答案。我看到一些答案,建议您可以通过在O(log n)中查找正确的插入索引来解决此问题-可以,但是您不能在那个时候插入,因为需要部分复制数组以使空间。

底线:如果确实需要将O(log n)插入和删除到已排序的数组中,则需要一个不同的数据结构-而不是数组。您应该使用B-Tree。通过对大型数据集使用B树可获得的性能提升,将使此处提供的任何改进都相形见war。

如果必须使用数组。我基于插入排序提供以下代码,当且仅当数组已经排序时,该代码才有效。这在每次插入后都需要求助的情况下很有用:

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中,您建议的插入排序将比二进制搜索和拼接方法慢,因为拼接具有快速的实现。
燕窝

除非JavaScript能够以某种方式打破时间复杂性的定律,否则我会持怀疑态度。您是否有一个可运行的示例来说明二进制搜索和拼接方法如何更快?
domoarigato

我回想第二条评论;-)确实,将存在一个数组大小,超过该大小,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循环。潜在的两个缺点是:1)性能不如我的方法(认为我不确定它的差多少); 2)它不接受自定义比较器函数,仅接受用于比较值的方法(我假设使用默认的比较器)。


调用arr.splice()肯定是O(n)的时间复杂度。
domoarigato

4

这里有一些想法:首先,如果您真正关心代码的运行时,请确保知道调用内置函数时会发生什么!我不知道从Java语言开始,而是从splice函数的快速谷歌返回this,这似乎表明您在每次调用时都在创建一个全新的数组!我不知道这是否真的很重要,但这肯定与效率有关。我看到Breton在评论中已经指出了这一点,但是对于您选择的任何数组操作函数都肯定适用。

无论如何,要真正解决问题。

当我看到您要排序时,我的第一个想法就是使用插入排序!。它很方便,因为它以线性时间运行在已排序或几乎已排序的列表上。由于您的数组只有1个元素乱序,因此算得几乎是排序的(大小为2或3或其他大小的数组除外,但请注意)。现在,实现排序并不是太糟糕,但这是您可能不想处理的麻烦,而且我也不了解javascript,以及它是否简单,困难或其他问题。这样就无需使用查找功能,只需按一下即可(如Breton建议的那样)。

其次,您的“快速排序”查找函数似乎是一种二进制搜索算法!这是一个非常不错的算法,直观且快速,但是有一个要点:众所周知,正确实现它非常困难。我不敢说您的设置是否正确(当然,我希望是这样!:)),但是如果您要使用它,请当心。

总之,摘要:将“ push”与插入排序一起使用将在线性时间内工作(假设数组的其余部分已排序),并且避免了任何混乱的二进制搜索算法要求。我不知道这是否是最好的方法(数组的底层实现,也许是一个疯狂的内置函数可以做得更好,谁知道),但是对我来说似乎很合理。:)-Agor。


1
+1,因为包含的任何东西splice()已经是O(n)。即使它不内部创建整个阵列的一个新的副本,它潜在地具有分流所有n个项目靠背1个位置如果该元素是在位置0被插入
j_random_hacker

我相信插入排序也是O(n)的最佳情况,而O(n ^ 2)的情况最糟(尽管OP的用例可能是最佳情况)。
domoarigato

减去一则与OP交谈。第一段感觉就像对引擎盖下不知道拼接如何工作的unnessessary训诫
马特ZERA

2

这是完成此操作的四种不同算法的比较:https : //jsperf.com/sorted-array-insert-comparison/1

演算法

天真总是可怕的。对于较小的阵列,似乎其他三个差异不大,但是对于较大的阵列,最后两个优于简单的线性方法。


为什么不测试旨在实现快速插入和搜索的数据结构?例如 跳过列表和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)时间内完成。

一个顺序统计树启用日志的时间索引用RANK函数。

如果您不需要随机访问,但需要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

不要在每个项目之后都重新排序,因为它过于夸张。

如果仅要插入一项,则可以使用二进制搜索找到要插入的位置。然后使用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个要插入到排序数组中的项的列表,那么您可以做得更好。对k个项目进行排序,与已排序的n个数组分开。然后进行扫描排序,其中您同时向下移动两个排序的数组,将其中一个合并到另一个数组中。-一步合并排序= k log k + n = 9965 + 5000 =〜15,000个操作

更新:关于您的问题。
First method = binary search+move = O(n + log n)Second method = re-sort = O(n log n)确切说明您获得的时间安排。


是的,但是没有,这取决于您的排序算法。以相反的顺序使用冒泡排序,如果最后一个元素未排序,则排序始终为o(n)
njzk2 2012年

-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;
}
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.