那些关于前端数据结构与算法

本文来自作者 张振 在 GitChat 上分享「前端数据结构与算法的奥秘」,阅读原文」查看交流实录

文末高能

更多同类话题

查看全部话题

编辑 | 嘉仔

目前前端的在互联网行业比重越来越加深,前端不仅限于网页制作,还包括移动端及其服务端的搭建。

所以对从业人员要求越来越高,如果自身不重视数据结构与算法这样基础知识,很有可能数年来从事单一的毫无成长的职业发展,只有基础牢固,算法精通的人 才能未来的道路上越走越远,就像盖高楼大厦,我们不是代码的搬运工,而是设计的领航人。

什么是数据结构

面试官一问到什么是数据结构。 很多人都蒙圈了 答不上来。

答案:数据元素相互之间存在的一种和多种特定的关系集合 包括二个部分组成逻辑结构,存储结构。

逻辑结构

简单的来说 逻辑结构就是数据之间的关系,逻辑结构大概统一的可以分成两种 一种是线性结构,非线性结构 。

线性结构

是一个有序数据元素的集合。 其中数据元素之间的关系是一对一的关系,即除了第一个和最后一个数据元素之外,其它数据元素都是首尾相接的。

常用的线性结构有: 列表,栈,队列,链表,线性表,集合。

非线性结构

各个数据元素不再保持在一个线性序列中,每个数据元素可能与零个或者多个其他数据元素发生联系。

常见的线性结构有 二维数组,多维数组,广义表,树(二叉树等),图(网)等。

存储结构

逻辑结构指的是数据间的关系,而存储结构是逻辑结构用计算机语言的实现。 常见的存储结构有顺序存储、链式存储、索引存储以及散列存储(哈希表)。

数据结构-线性结构-数组

作为前端大家估计用的最多的就是数组 其实数组是一个存储元素的线性集合 元素可以通过索引来任意存取 索引用来计算存储位置的偏移量,几乎所有的编程语言都有类似的数据结构。

然而 JS 中的数组是一种特殊的对象, 用来表示偏移量的索引是该对象的属性,索引为整数的时候,这个索引在内部被强转换为字符串类型,这也是 JS 对象中数据名必须是字符串的原因了,JS中的数组严格来说应该称为对象,是特殊的 JS 对象。

以下是简单的数组操作 :

  1. 创建数组:2种方式

    1.操作符
    var ary = []   
    
    2.array的构造函数方式
    var ary = new Array()

    这两方式都是创建数组 大多数JS专家推荐使用[]操作符,效率更高。

  2. 由字符串生成数组 split()

  3. 查找元素 indexOf()

  4. 数组转化成字符串 join() toString()

  5. 由已有的数组创建新数组 concat() splice()

  6. 为数组增加元素 push() unshift()

  7. 为数组删除元素  pop() shift() splice()

  8. 为数组排序 sort()

  9. 数组迭代器 forEach() map()  filter()

数据结构-线性结构-列表

列表是一组有序的的数据,如果数据结构非常复杂,那么列表的作用就没有那么大了。

学习如何来设计我们自己的抽象类,主要去学习设计思想及编写代码的方式。

如何实验列表类  (构造函数 + 原型的方式)。

function List() {    // 列表的元素个数    this.listSize = 0;    // 列表的当前位置 是第几个    this.pos = 0;    // 初始化一个空数组来保存列表元素    this.dataStore = []; }

 List.prototype = (function () {      return {         clear: clear,         find: find,         toString: toString,         insert: insert,         append: append,         remove: remove,         front: front,         end: end,         prev: prev,         next: next,         hasNext: hasNext,         hasPrev: hasPrev,         length: length,         currPos: currPos,         moveTo: moveTo,         getElement: getElement     };     /**       * 给列表最后添加元素的时候,列表元素个数+1       * @param element       */     function append(element) {          this.listSize++;          this.dataSource.push(element);      }     /**      * @param element 如果传入的是对象,需要判断是否是对象以及两个对象是否相等      * @returns {number} 如果找到,返回位置,否则-1      */      function find(element) {          for (var i = 0; i < this.dataSource.length; i++) {             if (this.dataSource[i] === element) {                  return i;              }         }         return -1;      }     /**      * 返回列表元素的个数      * @returns {number}      */     function length() {          return this.listSize;      }     /**      * 删除元素成功,元素个数-1      * @param element      * @returns {boolean}      */      function remove(element) {          var removeIndex = this.find(element);          if (removeIndex !== -1) {              this.dataSource.splice(removeIndex, 1);              this.listSize--;              return true;          }          return false;     }      /**       * 返回要展示的列表       * @returns {string}       */     function toString() {          return this.dataSource.toString();      }      /**       * 插入某个元素       * @param element 要插入的元素       * @param afterElement 列表中的元素之后       * @returns {boolean}       */      function insert(element, afterElement) {          var insertIndex = this.find(afterElement);          if (insertIndex !== -1) {              this.dataSource.splice(insertIndex + 1, 0, element);              this.listSize++;              return true;          }          return false;      }      /**       * 清空列表中的所有元素      */     function clear() {         delete this.dataSource;         this.dataSource = [];         this.listSize = this.pos = 0;      }     /**     * 将列表的当前位置移动到第一个元素     */     function front() {         this.pos = 0;      }     /**     * 将列表的当前位置移动到最后一个元素     */     function end() {         this.pos = this.listSize - 1;      }     /**      * 返回当前位置的元素      * @returns {*}      */       function getElement() {         return this.dataSource[this.pos];      }     /**      * 将当前位置向前移动一位      */     function prev() {         --this.pos;     }     /**      * 将当前位置向后移动一位      */     function next() {         ++this.pos;     }     /**      * 返回列表的当前位置      * @returns {number|*}      */     function currPos() {         return this.pos;     }     /**      * 移动到指定位置      * @param position      */     function moveTo(position) {         this.pos = position;    }     /**     * 判断是否有后一位      * @returns {boolean}      */     function hasNext() {         return this.pos < this.listSize;     }     /**      * 判断是否有前一位      * @returns {boolean}      */     function hasPrev() {         return this.pos >= 0;     }   }());

需求 假如有20部影碟 属于一个TXT文件 记录当前用户都拿走了哪个碟。

 var fs = require('fs');  var movies = createMovies('films.txt');  /**   * 读取数据,返回数组   * @param file   * @returns {Array|*}   */  function createMovies(file) {      var arr = fs.readFileSync(file, 'utf-8').split("\n");     for (var i = 0; i < arr.length; i++) {         arr[i] = arr[i].trim();     }     return arr; } var movieList = new List();    for (var i = 0; i < movies.length; i++) {     movieList.append(movies[i]); } var customers = new List(); /**  * 用户对象  * @param name 用户姓名  * @param movie 用户拿走的影碟  * @constructor  */ var Customer = function (name, movie) {      this.name = name;    this.movie = movie; }; /**  * 用户拿走影碟  * @param name 用户的名字  * @param movie 影碟的名字  * @param movieList 所有影碟列表  * @param customerList 用户列表  */ function checkOut(name, movie, movieList, customerList) {     if (movieList.find(movie) !== -1) {         var user = new Customer(name, movie);         customerList.append(user);//用户拿掉影碟,讲用户加入customerList         movieList.remove(movie);//从movieList中删除掉被拿掉的影碟     } else {         console.log('没有该电影');     } }

数据结构-线性结构-栈

栈:限定仅在表尾进行插入和删除操作的线性表,允许插入和删除的一端成为栈顶,另一端称为栈底,不含任何元素的栈称为空栈。  基本用法 就是 push pop  (JS已经帮我们实现了)。

栈具有FILO(first in last out)即先进后出的特性 。例如数制间的相互转化(可以利用栈将一个数字从一个数制转换成另一个数制)。

讲数字转换为二进制或者八进制 function mulbase(num , base) {    var s = new Stack()    do {        s.push(num % base)        num = Math.floor (num /= base)        } while (num > 0);    var content = " ";    while (s.length() > 0) {      content  += s.pop()    }    return content }

数据结构-线性结构-队列

队列 (Queue)是一种先进先出 (First-In-First-Out, FIFO) 的数据结构,与栈不同的是,它操作的元素是在两端,而且进行的是不一样的操作。

向队列的队尾加入一个元素叫做入队 (enQueue),向队列的队首删除一个元素叫做出队列。

列表具有即先进先出的特性 , 由于JS是单线程的, 所以导致了我们代码在执行的时候 只能执行一个任务,在实际的开发当中 我们已应该遇到过很多的队列问题,看源码是如何巧妙的运用队列的。

在我们执行动画的时候其实就是对队列的应用。

(function($) {  window.$ = $; }) (function() {  var rquickExpr = /^(?:#([\w-]*))$/;  function aQuery(selector) {    return new aQuery.fn.init(selector);  }  /**   * 动画   * @return {[type]} [description]   */  var animation = function() {    var self = {};    var Queue = []; //队列的位置    var open = false //动画状态    var first = true; //通过add接口触发    var makeAnim = function(element, options, cb) {      var width = options.width      element.style.webkitTransitionDuration = '3000ms';      element.style.webkitTransform = 'translate3d(' + width + 'px,0,0)';      //监听动画完结      element.addEventListener('webkitTransitionEnd', function() {        cb()      });    }    var _fire = function() {      //加入动画正在触发      if (!open) {        var onceanimate = Queue.shift();        if (onceanimate) {          open = true;          //next          onceanimate(function() {            open = false;            _fire();          });        } else {          open = true;        }      }    }    return self = {      //增加队列      add: function(element, options) {        Queue.push(function(cb) {          makeAnim(element, options, cb);        });        //如果有一个队列立刻触发动画        if (first && Queue.length) {          first = false;          self.fire();        }      },      //触发      fire: function() {        _fire();      }    }  }();  aQuery.fn = aQuery.prototype = {     animate: function(options) {       animation.add(this.element, options);       return this;     }   }     var init = aQuery.fn.init = function(selector) {     var match = rquickExpr.exec(selector);     var element = document.getElementById(match[1])     this.element = element;     return this;   }   init.prototype = aQuery.fn;   return aQuery; }()); //dom var oDiv = document.getElementById('div1'); //调用 oDiv.onclick = function() {   $('#div1').animate({     'width': '500'   }).animate({     'width': '300'   }).animate({     'width': '1000'   }); };

面试题 : JS如何实现一个异步队列来按顺序执行 ?

数据结构-线性结构-链表

在JS当中 队列与栈都是一种特殊的线性结构, 也是一种简单的基于数组顺序的存储结构, 由于JS的解析器的原因  不存在其他语言编程中出现的 数组固定长度一说。

线性表的顺序存储结构,最大的缺点就是改变其中一个元素的排列时都会引起整个合集的变化,其原因就是在内存中的存储本来就是连贯没有间隙的,删除一个自然就要补上。针对这种结构的优化之后就出现了链式存储结构。

链表是由一个组节点组成的集合,每一个节点都使用一个对象的引用指向它的后继, 指向另一个节点的引用叫链。

链表包括: 单向链表,双向链表,循环链表。

我们需要先定义节点对象是什么样子。按照 Codewars 上的设定,一个节点对象有两个属性 data 和 next 。data 是这个节点的值,next 是下一个节点的引用。这是默认的类模板。

设计一个单项链表:

function LinkedList () {  var Node = function (element) {    this.element = element    // 保存指向下个元素的引用,默认为null    this.next = null  }  // 链表长度  var length = 0  // head保存指向第一个元素的引用  var head = null }

链表需要实现以下方法:

append(element):向链表尾部添加元素

insert(position, element):向链表特定位置插入元素

removeAt(position):从链表特定位置移除一项

remove(element):在链表中移除某元素

indexOf(element):返回元素在链表中的索引,若不存在则返回-1

isEmpty():如果链表不包含任何元素就返回true,否则为false

size():返回链表长度

toString():返回元素的值转成字符串

实现 append

类似数组的 push 方法,但是只能添加一个元素。

实现方法的时候分两种情况考虑:1. 链表为空时添加第一个元素;2. 链表不为空时在尾部添加元素。

this.append = function (element) {  var node = new Node(element),      current  if (head === null) { // 当链表为空时    // 将head指向新增的元素    head = node  } else { // 链表不为空    // 使用一个current变量从head开始迭代链表    current = head    // 迭代链表,直到找到最后一项    while (current.next) {      current = current.next    }    // 找到最后一项,将其next赋为node,建立链接    current.next = node  }  // 更新列表长度  length++ }

实现 removeAt

在链表中特定位置移除元素,实现时也需要考虑两种情况:1. 移除第一个元素;2. 移除其他元素(包括最后一个)。

this.removeAt = function (position) {  // 判断位置是否越界  if (position > -1 && position < length) {    var current = head,        previous,        index = 0    // 如果删除了第一个元素,把head指向下一个元素就行了    if (position === 0) {      head = current.next    } else {      // 根据输入的位置查找要删除的元素      while (index++ < position) {        previous = current        current = current.next      }      // 将上一个元素的next指向current的下一项,跳过current,实现移除current      previous.next = current.next    }    // 更新列表长度    length--    // 返回删除的元素    return current.element  } else {    return null  } }

实现insert

与removeAt类似的实现,大家可以先不看源码,自己按着removeAt的思路实现一遍。

this.insert = function (position, element) {  // 检查位置是否越界  if (position >= 0 && position <= length) {    var node = new Node(element),        index = 0,        previous,        current = head    // 在第一个位置添加    if (position === 0) {      node.next = current      head = node    } else {      while (index++ < position) {        previous = current        current = current.next      }      node.next = current      previous.next = node    }    // 更新列表长度    length++    return true } else {  return false } }

实现 indexOf

根据元素查找在链表中的位置,没找到就返回-1。

this.indexOf = function (element) {  var current = head,      index = 0  while (current) {    if (element === current.element) {      return index    }    index++    current = current.next  }  return -1 }

实现其他方法

// 返回所有元素的值转成字符串 this.toString = function () {  var current = head,      string = ''  while (current) {    string += current.element    current = current.next  }  return string } // 移除特定元素 this.remove = function (element) {  var index = this.indexOf(element)  return this.removeAt(index) } // 判断链表是否为空 this.isEmpty = function () {  return length === 0 } // 返回链表长度 this.size = function () {  return length } // 返回第一个元素 this.getHead = function () {  return head }

双向链表

双向链表和单向链表的区别就是每一个元素是双向的,一个元素中包含两个引用:一个指向前一个元素;一个指向下一个元素。除此之外,双向链表还有一个指向最后一个元素的tail指针,这使得双向链表可以从头尾两个方向迭代链表 。

设计一个简单的双向链表 function DoubleLinkedList () {  var Node = function (element) {    this.element = element    this.prev = null // 新增了一个指向前一个元素的引用    this.next = null  }  var length = 0  var head = null  var tail = null //新增了tail指向最后一个元素 }

append(element):向链表尾部添加元素

insert(position, element):向链表特定位置插入元素

removeAt(position):从链表特定位置移除一项

showHead():获取双向链表的头部

showLength():获取双向链表长度

showTail():获取双向链表尾部

实现append

和单向链表的一样,只不过多了tail有一些不同 。

this.append = function (element) {  var node = new Node(element),      current = tail  if (head === null) {    head = node    tail = node  } else {    node.prev = current    current.next = node    tail = node  }  length++ }

实现 insert

同单向链表类似,只不过情况更复杂了,你不仅需要额外考虑在第一个元素的位置插入新元素,还要考虑在最后一个元素之后插入新元素的情况。

此外如果在第一个元素插入时,链表为空的情况也需要考虑。

this.insert = function (position, element) {  // 检查是否越界  if (position >= 0 && position <= length) {    var node = new Node(element),        current = head,        previous,        index = 0    if (position === 0) { // 第一个元素的位置插入      // 如果链表为空      if (!head) {        head = node        tail = node      } else {        node.next = current        current.prev = node        head = node      }    } else if (position === length) { // 在最后一个元素之后插入      current = tail      node.prev = current      current.next = node      tail = node    } else { // 在中间插入      while (index++ < position) {        previous = current        current = current.next      }      node.next = current      previous.next = node      current.prev = node      node.prev = previous    }    length++    return true  } else {    return false  } }

实现 removeAt

this.removeAt = function (position) {  // 检查是否越界  if (position > -1 && position < length) {    var current = head,        previous,        index = 0    if (position === 0) { // 第一个元素      head = current.next      // 如果只有一个元素      if (length === 1) {        tail = null      } else {        head.prev = null      }    } else if (position === length - 1) { // 最后一个元素      current = tail      tail = current.prev      tail.next = null    } else {      while (index++ < position) {        previous = current        current = current.next      }      previous.next = current.next      current.next.prev = previous    }    length--    return current.element  } else {    return null  } }

数据结构  线性结构-集合

项目中我们会经常的跟集合打交道, 如果有两个数组,你还每一次都循环的代码来判断是否重复 。。 那么只能说 你的代码有点跟不上时代的节奏了。

集合中包括:并集,交集,差集

let set1 = new Set([1,2,3]); let set2 = new Set([2,3,4]); 并集 let union = new Set([...set1, ...set2]); 交集 let intersect = new Set([...set1].filter( x => set2.has(x))); 差集 let difference = new Set([...set1].filter(x => !set2.has(x)));

面试题: 如何高效的处理两个数组中获取数组中相同项。

数据结构 -非线性结构 -二叉树

树是一种非线性的数据结构 。而二叉树是一个特殊的数据结构,它每个节点的子节点不允许超过两个。3种遍历二叉树的方式有:中序,先序,后序 。

二叉树的原理:

  1. 第一次访问的时候 设根节点为当前节点

  2. 如果插入的值小于当前节点, 设置该插入节点为原节点的左节点 ,反之 执行第4部

  3. 如果当前节点的左节点为null ,就将新的节点插入这个位置,退出循环 反之 继续执行下一次循环

  4. 设新的当前节点为原节点的右节点

  5. 如果当前节点的右节点为null 就将新的节点插入这个位置,退出循环 反之继续执行下一次循环

二叉树是由节点组成的,所以我们需要定义一个对象node,可以保存数据,也可以保存其他节点的链接(left 和 right),show()方法用来显示保存在节点中的数据。Node代码如下:

function Node(data,left,right) {    this.data = data;    this.left = left;    this.right = right;    this.show = show; }

function BST() {    this.root = null;    this.insert = insert;    this.inOrder = inOrder; } function insert(data) {    var n = new Node(data,null,null);    if(this.root == null) {        this.root = n;    }else {        var current = this.root;        var parent;        while(current) {            parent = current;            if(data <  current.data) {                current = current.left;                if(current == null) {                    parent.left = n;                    break;                }            }else {                current = current.right;                if(current == null) {                    parent.right = n;                    break;                }            }        }    } } //初始代码如下: var nums = new BST(); nums.insert(23); nums.insert(45); nums.insert(16); nums.insert(37); nums.insert(3); nums.insert(99); nums.insert(22)

遍历二叉查找树。更多详细内容请跳转(https://www.cnblogs.com/tugenhua0707/p/4361051.htm)

高级算法

基础算法(冒泡,插入, 选择) 就不浪费大家时间了。高级排序算法是处理大型数据集的最高效排序算法(递归执行效率底下),它是处理的数据集可以达到上百万个元素,而不仅仅是几百个或者几千个。

高级排序算法 -希尔排序

希尔排序的核心理念是:首先比较距离较远的元素,而非相邻的元素。

基本原理:通过定义一个间隔序列来表示在排序过程中进行比较的元素之间有多远的间隔。 对于大部门实际应用场景,算法要到的间隔序列可以提前定义好,有一些公开定义的间隔序列是 701,301,132,57,23,10,4,1。

var ary = [0,91,11,83,72,61,12,3,35,44] 已知一个数组

间隔3时候 排序结果 0 83 12 44  对这个数组进行排序结果0 12 44 83

间隔3的时候 排序结果 91 72 3  对这个数组进行排序结果 3 72 91

间隔3的时候 排序结果 11 61 35 结果是 11 35 61

执行以上结果就是 [0,3,11,12,72,35,44,91,61,83]

间隔2的时候  结果排序 0 11 72 44 61   对这个数组进行排序结果 0 11 44 61 72

间隔2的时候  结果排序 3 12 35 91 83  结果3 12 35 83 91

执行以上结果就是 [0,3,11,12,44,35,61,91,72,83]

间隔1的时候  结果排序 0 3 11 12 35 44 61 73 83 91

function CArray(numElements,gaps) {    this.dataStore = [];    this.pos = 0;    this.numElements = numElements;    this.gaps = gaps;  //间隔序列    this.insert = insert;    this.shellSort = shellSort;    for(var i = 0; i < numElements.length; i++) {        this.dataStore[i] = numElements[i];    }             } function shellSort() {    for(var g = 0; g < this.gaps.length; ++g) {        for(var i = this.gaps[g]; i < this.dataStore.length; ++i) {            var temp = this.dataStore[i];            for(var j = i; j >= this.gaps[g] && this.dataStore[j - this.gaps[g]] > temp; j -= this.gaps[g]) {                this.dataStore[j] = this.dataStore[j - this.gaps[g]];            }            this.dataStore[j] = temp;        }    } } // 希尔排序测试代码 var numElements = [0,91,11,83,72,61,12,3,35,44]; var gaps = [3,2,1]; var myNums = new CArray(numElements,gaps); myNums.shellSort(); console.log(myNums.toString());

高级排序算法 -快速排序

快速排序是处理大数据最快的排序算法之一,通过递归的方式将数据一次分解成包含大小原色的不同子序列 通过不断重复这个步骤来获取数据。

// 快速排序 function qSort(list) {    if(list.length == 0) {        return [];    }    // 存储小于基准值的值    var left = [];    // 存储大于基准值的值    var right = [];    var pivot = list[0];    for(var i = 1; i < list.length; i++) {        if(list[i] < pivot) {            left.push(list[i]);        }else {            right.push(list[i])        }    }    return qSort(left).concat(pivot,qSort(right)); } var numElements = [44,75,23,43,55,12,64,77,33]; var list = qSort(numElements); console.log(list);  // [12, 23, 33, 43, 44, 55, 64, 75, 77

高级算法 —动态规则

动态规则有时被认为是一种与递归相反的技术,递归是从顶部开始将问题解决,通过解决掉所有分析出的小问题方式,来解决问题 。 

而动态规则的解决方式 正好相反 先解决小的问题 然后解决大的问题。

斐波那契数列指的是这样一个数列 0 1 1 2 3 5 8 13 21 34 55 可以看出序列的前两项数值相加而成的

1. 递归方式

 function result (n) {         if (n < 2) {                 reutrn n         }         else {            return result(n-1) + result(n-2)         }  }  这个函数执行效率比较低

2. 动态规则方法

  function result (n) {      let val = [];        for(let i = 0; i <= n; ++i){            val[i]=0;        }        if(n ===1 || n  === 2){            return 1;        }        else {            val[1] =1;            val[2] = 2;            for(let i = 3; i <= n; ++i){                val[i] = val  [i-1] +val[i-2] ;            }        }        return val[n-1]   }

通过数组 val 中保存了中间结果, 如果要计算的斐波那契数是 1 或者 2, 那么 if 语句会返回 1。 否则,数值 1 和 2 将被保存在 val 数组中 1 和 2 的位置。

循环将会从 3 到输入的参数之间进行遍历, 将数组的每个元素赋值为前两个元素之和, 循环结束, 数组的最后一个元素值即为最终计算得到的斐波那契数值, 这个数值也将作为函数的返回值。

高级算法 - 贪心算法

贪心算法的基本思路:

  1. 建立数学模型来描述问题。

  2. 把求解的问题分成若干个子问题。

  3. 对每一子问题求解,得到子问题的局部最优解。

  4. 把子问题的解局部最优解合成原来解问题的一个解

贪心算法适用的问题:寻找最优解的过程,目的是得到当前最优解、可惜的是,它需要证明后才能真正运用到题目的算法中。

部分背包问题:固定容积的背包能放入物品的总最大价值 物品 A B C D 价格 50 220 60 60 尺寸 5 20 10 12 比率 10 11 6 5

//按比例降序尽可能多放入物品 function greedy(values, weights, capacity){    var returnValue = 0    var remainCapacity = capacity    var sortArray = []    values.map((cur, index) =>{        sortArray.push({            'value': values[index],            'weight': weights[index],            'ratio': values[index]/weights[index]        })    })    sortArray.sort(function(a, b){        return b.ratio > a.ratio    })    console.log(sortArray)    sortArray.map((cur,index) => {        var num = parseInt(remainCapacity/cur.weight)        console.log(num)        remainCapacity -= num*cur.weight        returnValue += num*cur.value    })    return returnValue } var items = ['A','B','C','D'] var values = [50,220,60,60] var weights = [5,20,10,12] var capacity = 32 //背包容积 greedy(values, weights, capacity) // 320


近期热文

TensorFlow 计算与智能基础

突破技术发展瓶颈、成功转型的重要因素

Selenium 爬取评论数据,就是这么简单!

这种稀缺的测试人,好多公司都抢着要

Node 企业项目大规模实践


GitChat 与 CSDN 联合推出

《GitChat 达人课:AI 工程师职业指南》

「阅读原文」看交流实录,你想知道的都在这里

  • 4
    点赞
  • 47
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值