手撕sort和splice

594 阅读7分钟

好学的大学生

前言

今天我们来撕一下前端两大函数 -> 「sort」和「splice」,了解这些底层函数逻辑能够让你透过函数看到本质,增加程序员的素养, 如果你觉得这些底层函数对于前端离的很远,那么请出门左拐,本文章会在手撕过程中穿插知识点,拓展知识面广度.

手撕sort

初步了解

1、首先sort函数是js内置的高阶函数「函数作为js的第一公民,被当作参数传入的函数被称为高阶函数」,并且当参数为空或者参数不合法时,参数会被初始化值.

2、在使用默认函数作为排序方式时,会以字符串unicode的顺序进行排序

3、如果你没有插入排序或者三向切分快排基础,那么可以先看提供的相关算法链接: - 插入排序 - 三向切分快排

手撕环节

我们手撕的sort以Google chorme的V8引擎作为标准进行实现.而V8引擎是采用插入排序+三向切分快排实现

1、V8引擎下排序机制

当数组长度小于等于10时,采用的是插入排序.比起时间复杂度为n^2的插入排序为什么不直接使用nlogn的快速排序呢,那是因为 n^2 > nlogn成立的条件是是在n足够大的情况下,大到把系数忽略,但是当n越来越小时,nlogn的优越性就会越来越差.

// In-place QuickSort algorithm.
// For short (length <= 10) arrays, insertion sort is used for efficiency.

2、标准化传入参数.

  var array = Object(this);
   var length = array.length >>> 0;
   if(Object.prototype.toString(compareFn) !== '[Object function]' {
       compareFn = function (x, y) {
          if(x === y) return 0;
          x = String(x);
          y = String(y);
          if(x === y) return 0;
          else return x < y ? -1 : 1;
       }
   }

2.1、这里我们可以看到我们将this转换为object,这样做的目的是拦截使用Array.prototype.sort.call()的方式使传入「this」为原始类型从而引起错误.

2.2、我们还用到了>>>无符号右移运算符,它的作用是将数右移n位,并且符号位用0补,目的是保证length为非负数.

3、插入排序

  function InsertSort(a, from, to) {
    for(var i = from;i < to;i++) {
      let ele = a[i];
      for(var j = i-1;j >= from && compareFn(ele, a[j]) > 0; j--) a[j+1] = a[j];
      a[j+1] = ele;
    }
   }

3.1、这里对于传统的插入排序进行了优化,以最少的比较次数以及填坑法代替交换.

3.2、这里结合compareFn需要注意,我们顺序的将数组值作为「第二个参数」和第二个参数之前的参数放入compareFn中进行比较,当返回值小于0时,确定值的位置,停止交换

1.gif

图片来源: blog.csdn.net/xiaoxiaojie…

4、快速排序

  getThirdIndex(a, from , to) {
      var increment = 200 + ((to - from) & 15); // 随机递增量
      from++;
      to--;
      var tmpArr = [];
      var index = 0;
      for(var i = from;i < to;i += increment) {
         tmpArr[index++]= [i,a[i]];
         // 填入key-value,value用于再次排序,key用于记录ThirdIndex;
      }
      tmpArr.sort(function (a,b) {
          return compareFn(a[1],b[1]);
      });
      return tmpArr[tmpArr.length >> 1][0];
   }

4.1、这个函数是在数组长度大于1000时,随机获取进行三向切分排序时的中间t值.

4.2、V8所做这些的目的是使t值尽量为中位数, 保证nlogn的时间复杂度,因为排序中如果每次都是最差时间复杂度就会退化成n^2

4.3、这里对from和to的操作是为了让出两端,防止和三向切分的两端重合.

4.4、递归调用中间数组也是为了保证中间值的随机性,而且可以看到我们的递增量是非常大的,也就意味着由于递归造成的浏览器栈溢出的可能是很低的.

5、正式进入快排

  var v0 = a[from];
   var v1 = a[to-1];
   var v2 = a[third_index];
   var temArr = [v0,v1,v2];
   tmpArr.sort(compareFn);
   [v0,v1,v2] = tmpArr;
   

5.1、这里我们用到了结构赋值的方式,但是v8源码为了保证兼容性,并没有用到这样的方式,而是 「通过依次比较的方式对三个随机数进行comparenFn排序赋值」,

 partition: for (var i = low_end + 1; i < high_start; i++) {
      var element = a[i];
      var order = comparefn(element, pivot);
      if (order < 0) {
        [a[i],a[low_end]] = [a[low_end++],a[i]];
      } else if (order > 0) {
        do {
          high_start--;
          if (high_start == i) break partition;
          var top_elem = a[high_start];
          order = comparefn(top_elem, pivot);
        } while (order > 0);
        a[i] = a[high_start];
        a[high_start] = element;
        if (order < 0) {
          [a[i],a[low_end]] = [a[low_end++],a[i]];
        }
      }
    }
    if (to - high_start < low_end - from) {
      QuickSort(a, high_start, to);
      to = low_end;
    } else {
      QuickSort(a, from, low_end);
      from = high_start;
    }
  }

5.2、首先我们在快排中再次让出了两端的值.因为在前一步我们已经确定了from和to的值与快排目标值的大小关系,所以是不需要进行排序的.然后我们就进行了常规的三向快速排序了,最后我们可以看到它每次都只切分小区间,这里思考了一下🤔,猜测目的是减少函数调用,防止栈溢出.

附上手撕:

 Array.prototype.sort = function(compareFn) {
      let arr = Object(this);
      let length = this.length >>> 0;
      return _InnerArray(arr,length, compareFn);
      // 主函数入口
      function _InnerArray(arr,length, compareFn) {
        if(Object.prototype.toString.call(compareFn) !== '[Object function]') {
          compareFn = function(x, y) {
            if(x === y) return 0;
            x = String(x);
            y = String(y);
            if(x === y) return 0;
            else return x < y ? -1 : 1;
          }
        }
        QuickSort(arr,0,length);
        // 插入排序
        function InsertionSort(a, from, to) {
          for(var i = from+1;i < to;i++) {
            let ele = a[i];
            for(var j = i-1;j >= from && compareFn(a[j],ele) > 0; j--) a[j+1] = a[j];
            a[j+1] = ele;
          }
        }

        function getThirdIndex(a, from , to) {
            var increment = 200 + ((to - from) & 15); // 随机递增量
            from++;
            to--;
            var tmpArr = [];
            var index = 0;
            for(var i = from;i < to;i += increment) {
              tmpArr[index++]= [i,a[i]];
              // 填入key-value,value用于再次排序,key用于记录ThirdIndex;
            }
            tmpArr.sort(function (a,b) {
                return compareFn(a[1],b[1]);
            });
            return tmpArr[tmpArr.length >> 1][0];
        }

        function QuickSort(a, from, to) {
          var third_index = 0;
          while(true) {
            if(to - from <= 10) {
              InsertionSort(a, from, to);
              return;
            }
            else
            if(to - from > 1000) {
              third_index = getThirdIndex(a, from , to);
            } else {
              third_index = from + ((to - from) >> 1); //这里这么写的目的是防止值溢出.
            }
            var tmpArr = [a[from], a[to-1], a[third_index]];
            tmpArr.sort(compareFn);
            var [v0,v1,v2] = tmpArr;
            a[from] = v0;
            a[to - 1] = v2;
            var pivot = v1;
            var low_end = from + 1;
            var high_start = to - 1;
            a[third_index] = a[low_end];
            a[low_end] = pivot;

            partition: for(var i = low_end+1;i < high_start;i++) {
              var element = a[i];
              var order = compareFn(element, pivot);
              if(order < 0) {
                [a[i],a[low_end++]] = [a[low_end],a[i]];
              } else if(order > 0) {
                do {
                  high_start--;
                  if(high_start == i) break partition;
                  var top_element = a[high_start];
                  order = compareFn(top_element, pivot);
                } while (order > 0);
                a[i] = a[high_start];
                a[high_start] = element;
                if(order < 0) {
                  [a[i],a[low_end++]] = [a[low_end],a[i]];
                }
              }
            }
            if (to - high_start < low_end - from) {
              QuickSort(a, high_start, to);
              to = low_end;
            } else {
              QuickSort(a, from, low_end);
              from = high_start;
            }
          }
        }
      }
  }
  
  

6、总结.

看过sort源码后,会发现,V8的sort其实是在递归的削减数组长度,并且当数组长度<10时,就直接使用效率较高的插入排序.这么做不仅减少了数据交换的次数,而且避开了用三向切分为了保证n^logn而获取Third_value的复杂度和低效性.

附上源码: (V8—sort).

手撕splice

splice相比于sort,对新手更加友好,它比起sort需要的数据结构与算法知识,更大的难点是在它的边界考虑性.

1、基础了解

1.1、splice是一种能够改变原数组的方法,并且它的返回值不是数组本身,而是又被删除元素组成的数组.

1.2、splice有两个+n个参数,分别对应删除元素索引起始位置删除元素个数被删除后填入的n个数据.

2、手撕环节.

初始化

  let O = Object(this);
   let len = this.length >>> 0;
   if(len - deleteCount + items.length > 2 ** 32 - 1)
       throw RangeError('Invalid array length')
       

第一二步我们就不解释了(sort中有讲到),来看下三步的判断,根据ECMA-262规范,数组的长度与一个无符号整型数绑定,而无符号整型数代表4个字节,也就是32位,所以数组的长度是不能超过 2^32 - 1的.

多情况考虑

1、初始索引为负数时,从尾部计算.
 if(start < 0)
      start = start+len >= 0 ? start+len : 0;
  else {
      start = start >= len ? len : start;
  }
  

这里的多行判断是为了代码的可读性,这也是程序员素养的一部分.

2、初始化删除数据长度.
 if(arguments.length == 1) deleteCount = len-start;
  else if(deleteCount < 0) deleteCount = 0;
  else if(deleteCount+start > len) deleteCount = len-start;
  
3、判断数组的property.
  if(Object.isSealed(O) && items.length !== deleteCount) {
      if(item.length > deleteCount)
       throw new TypeError('Cannot add property, object is not extensible');
      else
       throw new TypeError('Cannot delete property of [object Array]');
  }
  else if(Object.isFrozen(O) && (items.length > 0 || deleteCount > 0))
      throw new TypeError('...'); // 偷懒了~~~
      
  • 这里需要注意的是,数组作为一种特殊的对象,是可能会被Object.defineProperty定义的,而在Object.defineProperty有「writable」,「configurable」,「enumerable」,这里我们重点讨论一下writableconfigurable.

  • writable是用来定义对象的属性的是否可被重写的

  • configurable是用来定义属性是否可以脱离对象的,说起configurable你可能就需要去了解一下delete关键字,这里不多做展开.

  • Object.seal(obj)表示obj身上的所有属性都不可被脱离,而数组身上的属性名以字符串数组成,所以当增加的属性和删除的属性长度不一致时,就意味对象身上的属性名发生了变化,从而throw Error.

4、数据填充

分三种情况判断.

1、填充数 === 删除数

2、填充数 > 删除数

3、填充数 < 删除数

   if(deleteCount <= items.length)
    {
        for(let i = start;i < start+deleteCount;i++)
        {
            retArr.push(O[i]);
            O[i] = items[i-start];
        }
    } // 补满之后还有数据没有添加 // 注意移动的起始位置,如果放方向开始移动会出现覆盖.
    if(deleteCount < items.length)
    {
        let add = items.length-deleteCount;
        this.length += add;
        for(let i = len-1;i >=start+deleteCount;i--)
        {
            O[i+add] = O[i];
        }
        for(let i = 0;i < add;i++)
        {
            O[start+deleteCount+i] = items[i+deleteCount];
        }
    }
    
  • 当数据插入长度比删除长度大时,表明删除数据后面部分需要后移,为填入长度空出位置并增加数组长度.从而实现填充.
    else if(deleteCount > items.length)
     {
         for(let i = 0;i < deleteCount;i++)
         {
             retArr.push(O[i+start]);
         }
         for(let i = 0; i < items.length;i++)
         {
             O[i+start] = items[i];
         }
         for(let i = 0;i < len - start - deleteCount;i++)
         {
             O[start+items.length+i] = O[i+start+deleteCount];
         }
         this.length -= deleteCount-items.length;
     }
  • 当数据插入长度小于删除长度时,后部分需要前移,同时减小数组长度.

附上手撕:

 Array.prototype.splice = function(start,deleteCount,...items) {
      let O = Object(this);
      let len = this.length >>> 0;
      if(len - deleteCount + items.length > 2 ** 32 - 1)
      {
        throw new TypeError('...');
      }

      let retArr = []; //用于return的数组容器.

      // 处理start
      if(start < 0)
      {
        start = start+len >= 0 ? start+len : 0;
      }
      else
      {
        start = start >= len ? len : start;
      }

      // 处理deleteCount 这里deleteCount依赖start,所以处理顺序不能变
      if(arguments.length == 1) deleteCount = len-start;
      else if(deleteCount < 0) deleteCount = 0;
      else if(deleteCount+start > len) deleteCount = len-start;

      // 判断sealed对象、处理configurable为false的,(不可添加、删除属性,但是可以改变属性值)
      // 判断冻结对象、冻结对象不可以增删改属性值.
      if(Object.isSealed(O) && items.length !== deleteCount)
        throw new TypeError('...');
      else if(Object.isFrozen(O) && (items.length > 0 || deleteCount > 0))
        throw new TypeError('...');

      // 判断是否是需要补满删除的属性值.
      if(deleteCount <= items.length)
      {
        for(let i = start;i < start+deleteCount;i++)
        {
          retArr.push(O[i]);
          O[i] = items[i-start];
        }
      }

      // 补满之后还有数据没有添加
      // 注意移动的起始位置,如果放方向开始移动会出现覆盖.
      if(deleteCount < items.length)
      {
        let add = items.length-deleteCount;
        this.length += add;
        for(let i = len-1;i >=start+deleteCount;i--)
        {
          O[i+add] = O[i];
        }
        for(let i = 0;i < add;i++)
        {
          O[start+deleteCount+i] = items[i+deleteCount];
        }
      }

      // 删除数据过多 需要将后面的数据前移.
      else if(deleteCount > items.length)
        {
          for(let i = 0;i < deleteCount;i++)
          {
            retArr.push(O[i+start]);
          }
          for(let i = 0; i < items.length;i++)
          {
            O[i+start] = items[i];
          }
          for(let i = 0;i < len - start - deleteCount;i++)
          {
            O[start+items.length+i] = O[i+start+deleteCount];
          }
          this.length -= deleteCount-items.length;
        }
      return retArr;
  }
  

源码被放在了上述的源码地址内,不过splice的源码可读性较差.

总结.

splice对于数组长度的移动处理以及边界情况的处理的难度是较高的,也是非常有深度的一道题目.

小结: 手撕这样的题是非常考验程序员素养的,但是如果能够把这些题了解清楚,那么也是非常拉开程序员差距的.希望所以读者都能深入理解这两道题目

招贤纳士

甜有一百种方式,最重要的一种,就是每天在公司看到你。慕锐前端团队,隶属于杭州慕锐科技研发部,办公位坐拥一线江景。技术团队现有人员40余人,他们来自浙江大学、北京大学、Carleton、NUS、The George Washington University等多所知名院校;同时,公司在业务上也与中国美术学院、浙江大学有深度合作关系。团队建有完善的研发流程闭环、规范的规范制度及文档服务,除了日常业务对接,在可视化、物料库、工程化系统、前后端协同、数据埋点、自动化测试、Web性能、合规检测、搭建能力及构建部署等方向进行技术探索及创新实战,实现了一系列内部产品技术。

如果你正在为怀才不遇而烦恼,正好,我们老板怀财不遇;如果你正在为无法突破自己而苦恼,正好,我们这里有诸多让你实践的机会。望远镜最远看到137亿光年,你的潜力,测算不完。任何时间,我们来聊聊chenwanjun@meprint.com