插入一个数字到已排序的数组中最有效的方法是什么?

197

我有一个已排序的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])会产生不正确的结果[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 implementation?)。
  • JavaScript中的sort方法是本地函数,因此可能会产生巨大的好处--具有巨大系数的O(log(n))可能仍然比适度大小的数据集的O(n)要糟糕得多。

好的,我只是从第一个复制了它。 - Elliot Kroo
5
任何包含 splice() 的内容(例如您的第一个示例)已经是O(n)。 即使它在内部不创建整个数组的新副本,如果要将元素插入到位置0,则可能必须将所有n个项都向后移动1个位置。 也许它之所以快速是因为它是原生函数且常量很低,但无论如何它仍然是O(n)。 - j_random_hacker
start||0 应该做什么? - Charlie Parker
6
同时,对于以后使用这段代码的人,需要注意:当尝试将元素插入到数组开头时,该代码存在缺陷。请往下查看已经修正的代码。 - Charlie Parker
3
不要使用parseInt,应该使用Math.floor代替。Math.floorparseInt更快:https://jsperf.com/test-parseint-and-math-floor - Hubert Schölnast
显示剩余10条评论
18个回答

91

简单 (演示):

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

7
不错的想法。我从未听说过使用位运算符找到两个数字的中间值。通常情况下,我只会乘以0.5。这种方法是否会显著提高性能? - Jackson
7
@Jackson x >>> 1 进行二进制右移一位,实际上相当于除以2。例如对于数值11:1011 右移一位得到 101,结果为5。 - Qwerty
4
已经在这个轨道上了,你能解释一下 >>> 1>> 1 的区别吗?这些内容在这里(https://github.com/Olical/binary-search/blob/c4911653211990e4b858225d9df4bac511820445/src/binarySearch.js)和这里(https://dev59.com/g3M_5IYBdhLWcg3wgzhl#20261974)以及这里(http://googleresearch.blogspot.de/2006/06/extra-extra-read-all-about-it-nearly.html)都有提到。 - yckart
6
>>> 是无符号右移运算符,而 >> 是带符号扩展的右移运算符 - 这归结于负数在内存中的表示方式,其中高位如果为 1 则表示负数。因此,如果你用 >>0b1000 右移 1 位,会得到 0b1100;如果你改为使用 >>>,则会得到 0b0100。虽然在答案中给出的这个例子中区别并不重要(被移位的数既不会比有符号 32 位整数的最大值大,也不会是负数),但在这两种情况下使用正确的运算符非常重要(你需要选择要处理的情况)。 - asherkin
3
@asherkin说的不对:“如果你用>>0b1000向右移动1位,你将得到0b1100”是错误的。实际上,你会得到0b0100。对于除了负数和大于2^31(即第一位为1的数字)之外的所有值,不同的右移运算符的结果将相同。 - gilly3
显示剩余9条评论

69

仅作为一个数据点,出于好奇我测试了在Windows 7上使用Chrome将1000个随机元素插入到100,000个预排序数字数组中使用这两种方法:

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

所以,至少对于这个设置而言,原生方法并不能弥补它的不足。即使是针对小数据集,在一个包含1000个元素的数组中插入100个元素也是如此:

First Method:
1 milliseconds
Second Method:
34 milliseconds

2
arrays.sort 听起来相当糟糕 - njzk2
2
看起来array.splice一定做了一些非常聪明的事情,才能在54微秒内插入单个元素。 - gnasher729
1
当您在Array.prototype.sort中使用比较函数时,由于JS函数被频繁调用,您会失去C++的优势。 - aleclarson
我很难看出这里的答案。 "两种方法"是什么意思?答案读起来像是对另一个答案的评论,因为它已经得到了数十个赞,所以我一定错过了非常明显的东西。 - Armen Michaeli
@amn 这篇文章中有两个例子,所以他们一定测试过了。 - l3l_aze
显示剩余2条评论

32

非常好的和出色的问题,带有非常有趣的讨论!在将含有数千个对象的数组中推入单个元素后,我也使用了Array.sort()函数。

由于存在复杂的对象并且需要类似于Array.sort()中的比较函数,因此我不得不扩展您的locationOf函数以满足我的需求:

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:参数start和end仅在递归调用中使用,并且不会在初始调用中使用。由于它们是数组的索引值,因此必须为整数类型,并且在递归调用中这是隐含的。 - kwrl
1
@TheRedPea:不,我的意思是>> 1应该比/ 2更快(或者不会更慢)。 - kwrl
1
我可以看到comparer函数的结果存在潜在问题。在这个算法中,它与+-1进行比较,但它可以是任意值<0 / >0。请参见compare function。有问题的部分不仅是switch语句,还包括以下行:if (end - start <= 1) return c == -1 ? pivot - 1 : pivot;其中c也被与-1进行比较。 - eXavier
显示剩余8条评论

22

你的代码中有一个错误。应该改为:

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

如果没有这个修复,代码将永远无法在数组开头插入元素。


1
为什么要将int与0进行或运算?即,start || 0是做什么用的? - Charlie Parker
3
@Pinocchio: start || 0 的简短版本等同于 if(!start) start = 0; - 然而,“更长”的版本更有效率,因为它不会将变量赋值给自身。 - SuperNova

18

我知道这是一个已经有答案的老问题,也有其他不少不错的答案。有些回答提出可以在O(log n)时间内查找正确的插入索引来解决这个问题-确实可以,但你不能在那个时间内进行插入,因为需要部分复制数组以腾出空间。

底线是:如果你真的需要对一个排序数组进行O(log n)级别的插入和删除操作,你需要使用另一种数据结构,而不是数组。你应该使用B-Tree。使用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)的复杂度运行,我认为这是你能做到的最好的。如果JavaScript支持多重赋值,那就更好了。 这里有一个可以玩耍的示例:

更新:

这可能会更快:

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

更新 2

@terrymorse 在评论中指出,JavaScript 的 Array.splice 方法非常快,并且它不仅仅是时间复杂度的恒定改进。它似乎使用了一些链表魔法。这意味着你仍然需要一个不同于普通数组的数据结构,只是 JavaScript 数组可能本地提供了这种不同的数据结构。

更新的 JS Bin 链接


这种“array.push,bubble-swap”方法的时间复杂度为O(n)。它比“二分查找,Array#splice”方法慢得多,后者的时间复杂度为O(log n)(splice几乎不需要时间)。在大型数组上,它快了100倍以上。请参见速度基准测试 - terrymorse
很遗憾,@terrymorse,Array.splice不是O(log n),它也是O(n),这是我回答的主要观点。 - domoarigato
我提供的代码确实比使用 splice 的实现慢,因为原生代码中有一些优化,然而,这种差异是一个恒定的差异,即 O(n/k),而不是基本复杂度顺序上的差异。 - domoarigato
1
@domoarigato 性能测试表明,使用Array.splice进行插入的时间复杂度远小于O(N)。在100到100,000之间的每个N增加,时间/ N都会减少。 - terrymorse
1
嗯,似乎JavaScript在底层实现了一些链表类型的魔法:https://dev59.com/m2435IYBdhLWcg3w2z98。这真的很酷 - 但它也意味着JavaScript数组不仅仅是一个数组,从而强调了我的答案的基本观点,即您需要另一个数据结构。感谢@terrymorse指出这一点。 - domoarigato
显示剩余4条评论

11

你的插入函数假定给定的数组是已排序的,它直接搜索新元素可以插入的位置,通常只需查看数组中几个元素即可。

一般的排序函数不能采用这些捷径。显然,它至少必须检查数组中的所有元素,以确定它们是否已正确排序。仅仅这一点就使得通用排序比插入函数慢。

通用排序算法通常平均为O(n ⋅ log(n)),具体取决于实现方式,如果数组已经排序,则最坏情况可能是O(n2)。而直接搜索插入位置的复杂度仅为O(log(n)),因此始终更快。


值得注意的是,将元素插入到数组中的复杂度为O(n),因此最终结果应该大致相同。 - NemPlayer

9

这里有一个使用lodash的版本。

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

注意:sortedIndex 使用二分查找算法。

8
对于少量的项目,差异相当微不足道。然而,如果您正在插入大量项目或使用非常大的数组,则在每次插入后调用 .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

5
这里有一些想法: 首先,如果您真正关心代码的运行时间,请确保知道调用内置函数时会发生什么!我不知道 JavaScript 的情况,但快速搜索 splice 函数返回 this,似乎表明您每次调用都创建了一个全新的数组!我不知道它是否真的很重要,但它肯定与效率有关。我看到 Breton 在评论中已经指出了这一点,但它肯定适用于您选择的任何操作数组的函数。
无论如何,接下来是解决问题的实际步骤。
当我看到你想要进行排序时,我的第一个想法是使用插入排序。它非常方便,因为它在已排序或几乎已排序的列表上以线性时间运行。由于你的数组只有一个元素不按顺序排列,这就算是几乎已排序(除了大小为2或3等的数组,但那时候...)。现在,实现排序并不是太难,但这可能是你不想处理的麻烦事情,而且我对JavaScript一无所知,不知道它是否容易或困难之类的。这样可以省去您查找函数的需要,您只需推送(如Breton所建议的)。
其次,您的“快速排序式”的查找函数似乎是二分搜索算法!这是一个非常好的算法,直观且快速,但有一个问题:它很难正确实现。我不敢说你的实现是否正确(当然希望是正确的! :)),但如果你想使用它,要小心。

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


1
+1 是因为任何包含 splice() 的操作都已经是 O(n) 了。即使它不会在内部创建整个数组的新副本,但如果要将元素插入到位置0,则可能需要将所有n个项目向后移动1个位置。 - j_random_hacker
我相信插入排序在最好情况下是O(n),最坏情况下是O(n^2)(尽管OP的用例可能是最好情况)。 - domoarigato
2
对于对楼主的贬低,扣一分。第一段感觉像是在不必要地责备他不知道splice的内部工作原理。 - Matt Zera

3
这里是实现此目标的四种不同算法的比较: https://jsperf.com/sorted-array-insert-comparison/1 算法
- Naive: 先插入再排序 - Linear: 遍历数组并在适当位置插入 - 二分查找: 取自 https://dev59.com/qHnZa4cB1Zd3GeqPu9Ka#20352387 - "快排式": syntheticzero 的改进解决方案 (https://dev59.com/g3M_5IYBdhLWcg3wgzhl#18341744)
Naive 总是很恶劣的。对于小型数组,似乎其他三种方法并没有太大差异,但对于较大的数组,后两种方法优于简单的线性方法。

为什么不测试旨在实现快速插入和搜索的数据结构?例如跳表和二叉搜索树。 https://dev59.com/g3M_5IYBdhLWcg3wgzhl#59870937 - qwr
1
现在Chrome使用TimSort,Native的表现如何?根据TimSort维基百科:“在最好的情况下,也就是输入已经排序的情况下,它可以在线性时间内运行”。 - poshest

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接