JavaScript 数据结构基础学习

974 阅读7分钟

数据结构

数据结构的本质就在于如何将现实世界中各种各样的数据放入到内存中,并且如何在内存中操作这些数据。

内存的最基本存储单位叫做“存储单元”,把“存储单元”想象成一个盒子并且每个盒子有一个编号叫做内存地址。

数据就是一个一个的放入存储单元中,数据可以一个一个挨着放置,也可以随意放置。我们称之为数据存储方式。

数据结构分为三大类:

1、线性表

数组就是最简单粗暴的存储方法。直接拉出一大块数据存在那里。数组的快速存取其实只是一个副作用,因为所有的数据都在一起,可以直接算出来数据的地址。

链表则是为了解决可以无限增长的需求,因为找不到一大块可以连续的存入数据,甚至也不知道程序可能使用的数据总量,所以就没办法划分一块数据来使用,划小了不够用,划大了浪费。所以必须想办法解决问题。最后采用的方法就是从入口开始,每一个数据块不仅仅有数据,还会有指向下一个数据块的线索,用来寻找下一个数据。这就是链表。

所谓的双向链表,只是加了一个向前的线索的链表而已。不仅如此队列、栈,都是线性表的特殊形态,进行了操作上的限制罢了,既可以是数组也可以是链表。

2、树

树是为了解决单一入口下的非线性关联性的数据存储或者排序功能而来的。 本质上,树相对于链表,就是每个节点不止有一个后续节点但是只有一个前置节点。

3、图

图的本质其实就是把非线性表进一步扩展,每个节点会有不止一个前置和后缀节点。

它们的演变过程是:线性表 => 树 => 图 。

数组

作为最简单、最基础的数据结构,大多数的语言都天然地对数组有着原生的表达,JavaScript 亦然。这意味着我们可以对数组做到“开箱即用”,而不必自行模拟实现,非常方便。

特征

  • 数组在内存中是连续放置的(前提都是同一类型的值);
  • 数组是一种线性结构的;
  • 数组一般创建后就固定长度了,因此插入和移除元素的成本高;
  • 因为数组是一段连续的内存,并且可以通过下表直接访问到具体元素,因此查询非常高效。

创建数组

const arr = [1,2,3]; // 创建数组
const arr1 = new Array(3); // 创建长度为3的空数组
const arr2 = (new Array(3)).fill(1); // 创建[1,1,1]

访问数组

arr[0]; // 通过下标访问 

遍历数组

// 获取数组的长度
const len = arr.length
for(let i=0;i<len;i++) {
    // 输出数组的元素值,输出当前索引
    console.log(arr[i], i)
}

一维数组

const arr = [1,2,3,4,5];

二维数组

在数学中,形如这样长方阵列排列的复数或实数集合,被称为“矩阵”。因此二维数组也叫“矩阵”。

const arr = [
  [1,2,3,4,5],
  [1,2,3,4,5],
  [1,2,3,4,5],
  [1,2,3,4,5],
  [1,2,3,4,5]
]

栈(Stack)

栈是一种遵从后进先出(LIFO)原则的有序集合。新添加或待删除的元素都保存在栈的同一端,称作栈顶,另一端就叫栈底。在栈里,新元素都靠近栈顶,旧元素都接近栈底。

在 JavaScript 中,栈和队列的实现一般都要依赖于数组,大家完全可以把栈和队列都看作是“特别的数组”。两者的区别在于,它们各自对数组的增删操作有着不一样的限制。

在现实生活中也能发现很多栈的例子。例如,一摞书或者餐厅里叠放的盘子。

栈的实际应用非常广泛。在回溯问题中,它可以存储访问过的任务或路径、撤销的操作。

JavaScript 实现栈结构:

class Stack {
  constructor() {
    this.items = [];
  }
  // 添加一个(或几个)新元素到栈顶。
  push(element) {
    this.items.push(element); 
  }
  // 移除栈顶的元素,同时返回被移除的元素。
  pop() {
    return this.items.pop(); 
  }
  // 返回栈顶的元素,不对栈做任何修改
  peek() {
    return this.items[this.items.length - 1];
  }
  // 判断栈是否为空
  isEmpty() {
    return this.items.length === 0;
  }
  // 获取栈的长度
  size() {
    return this.items.length;
  }
  // 清空栈
  clear() {
    this.items = [];
  }
}

队列(Queue)

队列是遵循先进先出(FIFO,也称为先来先服务)原则的一组有序的项。队列在尾部添加新元素,并从顶部移除元素。最新添加的元素必须排在队列的末尾。

在现实中,最常见的队列的例子就是排队。

这个过程的规律也很明显:

  • 只允许从尾部添加元素
  • 只允许从头部移除元素

通过数组实现的队列:

class Queue {
    constructor() {
        this.items = [];
    }
    // 添加元素进入队列
    enqueue(element) {
        this.items.push(element);
    }
    // 从队列删除元素
    dequeue() {
        return this.items.shift();
    }
    // 获取队列中第一个元素
    front() {
        return this.items[0];
    }
    // 判断队列是否为空
    isEmpty(){
        return this.items.length == 0;
    }
    // 队列长度
    size(){
        return this.items.length;
    }
    // 清空队列
    clear(){
        this.items = [];
    }
}

链表

存储多个元素时数组可能是最常用的数据结构。然而这种数据结构有一个缺点(在大多数语言中)数组的大小是固定的,从数组的起点或中间插入或移除项的成本很高,因为需要移动元素。

链表存储有序的元素集合,但不同于数组,链表中的元素在内存中并不是连续放置的。每个元素由一个存储元素本身的节点和一个指向下一个元素的引用(也称指针或链接)组成。下图展示了一个链表的结构。

特征

  • 链表中元素是散落在内存的不同位置,通过指针使得它们互相产生联系的,因此当需要访问链表中某个元素时需要从头开始遍历链表,查询成本高。
  • 由于链表是通过next指针时前后元素产生关联,因此插入和删除一个元素只需要改变next指针的指向,并不需像数组那样挪动每个元素,因此插入和删除是非常高效的。

用代码简单表示如下:

{
    // 数据域
    val: 1,
    // 指针域,指向下一个结点
    next: {
        val:2,
        next: ...
    }
} 

数据域存储的是当前结点所存储的数据值,而指针域则代表下一个结点(后继结点)的引用。 有了 next 指针来记录后继结点的引用,每一个结点至少都能知道自己后面的同学是哪位了,原本相互独立的结点之间就有了如下的联系:

要想访问链表中的任何一个元素,我们都得从起点结点开始,逐个访问 next,一直访问到目标结点为止。为了确保起点结点是可抵达的,我们有时还会设定一个 head 指针来专门指向链表的开始位置:

创建链表:

function LinkedList() {
// Node类表示要加入列表的项。它包含一个element属性,即要添加到列表的值,以及一个next属性,即指向列表中下一个节点项的指针
  var Node = function(element){ 
    this.element = element; 
    this.next = null;
  };
  var length = 0;
  var head = null; // 我们还需要存储第一个节点的引用
  this.append = function(element){};  // 向列表尾部添加一个新的项。
  this.insert = function(position, element){};  // 向列表的特定位置插入一个新的项。
  this.removeAt = function(position){}; // 从列表的特定位置移除一项。
  this.remove = function(element){}; // 从列表中移除一项。
  this.indexOf = function(element){}; // 返回元素在列表中的索引。如果列表中没有该元素则返回-1。
  this.isEmpty = function() {}; // 如果链表中不包含任何元素,返回true,如果链表长度大于0则返回false。
  this.size = function() {};  // 返回链表包含的元素个数。与数组的length属性类似。
}

  

append

向LinkedList对象尾部添加一个元素时,可能有两种场景:列表为空,添加的是第一个元素,或者列表不为空,向其追加元素

下面是我们实现的append方法:

this.append = function(element){

  var node = new Node(element), //{1} 
      current; //{2}

  if (head === null){ //列表中第一个节点  //{3} 
    head = node;
  } else {
    current = head; //{4}
    //循环列表,直到找到最后一项 
    while(current.next){
      current = current.next;
    }
    //找到最后一项,将其next赋为node,建立链接
    current.next = node; //{5}
  }
  length++; //更新列表的长度  //{6}
};

首先需要做的是把element作为值传入,创建Node项(行{1})

先来实现第一个场景:向为空的列表添加一个元素。当我们创建一个LinkedList对象时,head会指向null:

如果head元素为null(列表为空——行{3}),就意味着在向列表添加第一个元素。因此要做的就是让head元素指向node元素。下一个node元素将会自动成为null

第二个场景是向一个不为空的列表尾部添加元素

要向列表的尾部添加一个元素,首先需要找到最后一个元素。记住,我们只有第一个元素的引用(行{4}),因此需要循环访问列表,直到找到最后一项。为此,我们需要一个指向列表中current项的变量(行{2})。循环访问列表时,当current.next元素为null时,我们就知道已经到达列表尾部了。然后要做的就是让当前(也就是最后一个)元素的next指针指向想要添加到列表的节点(行{5})。下图展示了这个行为:

var list = new LinkedList(); 
list.append(15); 
list.append(10);

remove

现在,让我们看看如何从LinkedList对象中移除元素。移除元素也有两种场景:第一种是移除第一个元素,第二种是移除第一个以外的任一元素。

this.removeAt = function(position){
  //检查越界值
  if (position > -1 && position < length){ // {1} 
    var current = head, // {2}
    previous, // {3}
    index = 0; // {4}
    //移除第一项
    if (position === 0){ // {5} 
      head = current.next;
    } else {
      while (index++ < position){ // {6}
        previous = current;    // {7} 
        current = current.next; // {8}
      }
      //将previous与current的下一项链接起来:跳过current,从而移除它 
      previous.next = current.next; // {9}
    }
    length--; // {10} 
    return current.element;
  } else {
    return null; // {11}
  }
};

该方法要得到需要移除的元素的位置,就需要验证这个位置是有效的(行{1})。从0(包括0)到列表的长度(size – 1,因为索引是从零开始的)都是有效的位置。如果不是有效的位置,就返回null(意即没有从列表中移除元素)

下面来为第一种场景编写代码:我们要从列表中移除第一个元素(position === 0——行{5})。下图展示了这个过程:

因此,如果想移除第一个元素,要做的就是让head指向列表的第二个元素。我们将用current变量创建一个对列表中第一个元素的引用(行{2})。这样 current 变量就是对列表中第一个元素的引用。如果把 head 赋为current.next,就会移除第一个元素。

insert

使用这个方法可以在任意位置插入一个元素。

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

其它方法就不一一讲解:

function LinkedList() {

    let Node = function(element){

        this.element = element;
        this.next = null;
    };

    let length = 0;
    let head = null;

    this.append = function(element){

        let node = new Node(element),
            current;

        if (head === null){ //first node on list
            head = node;
        } else {

            current = head;

            //loop the list until find last item
            while(current.next){
                current = current.next;
            }

            //get last item and assign next to added item to make the link
            current.next = node;
        }

        length++; //update size of list
    };

    this.insert = function(position, element){

        //check for out-of-bounds values
        if (position >= 0 && position <= length){

            let node = new Node(element),
                current = head,
                previous,
                index = 0;

            if (position === 0){ //add on first position

                node.next = current;
                head = node;

            } else {
                while (index++ < position){
                    previous = current;
                    current = current.next;
                }
                node.next = current;
                previous.next = node;
            }

            length++; //update size of list

            return true;

        } else {
            return false;
        }
    };

    this.removeAt = function(position){

        //check for out-of-bounds values
        if (position > -1 && position < length){

            let current = head,
                previous,
                index = 0;

            //removing first item
            if (position === 0){
                head = current.next;
            } else {

                while (index++ < position){

                    previous = current;
                    current = current.next;
                }

                previous.next = current.next;
            }

            length--;

            return current.element;

        } else {
            return null;
        }
    };

    this.remove = function(element){

        let index = this.indexOf(element);
        return this.removeAt(index);
    };

    this.indexOf = function(element){

        let current = head,
            index = 0;

        while (current) {
            if (element === current.element) {
                return index;
            }
            index++;
            current = current.next;
        }

        return -1;
    };

    this.isEmpty = function() {
        return length === 0;
    };

    this.size = function() {
        return length;
    };

    this.getHead = function(){
        return head;
    };

    this.toString = function(){

        let current = head,
            string = '';

        while (current) {
            string += current.element + (current.next ? ', ' : '');
            current = current.next;
        }
        return string;

    };

    this.print = function(){
        console.log(this.toString());
    };
}

数组与链表对比

在大多数的计算机语言中,数组都对应着一段连续的内存。如果我们想要在任意位置删除一个元素,那么该位置往后的所有元素,都需要往前挪一个位置;相应地,如果要在任意位置新增一个元素,那么该位置往后的所有元素也都要往后挪一个位置。

我们假设数组的长度是 n,那么因增加/删除操作导致需要移动的元素数量,就会随着数组长度 n 的增大而增大,呈一个线性关系。所以说数组增加/删除操作对应的复杂度就是 O(n)。

但 JS 中不一定是。 JS比较特别。如果我们在一个数组中只定义了一种类型的元素,比如:

const arr = [1,2,3,4]

它是一个纯数字数组,那么对应的确实是连续内存。

但如果我们定义了不同类型的元素:

const arr = ['haha', 1, {a:1}]

它对应的就是一段非连续的内存。此时,JS 数组不再具有数组的特征,其底层使用哈希映射分配内存空间,是由对象链表来实现的。

说起来有点绕口,但大家谨记“JS 数组未必是真正的数组”即可。

添加和删除

相对于数组来说,链表有一个明显的优点,就是添加和删除元素都不需要挪动多余的元素。

在链表中,添加和删除操作的复杂度是固定的——不管链表里面的结点个数 n 有多大,只要我们明确了要插入/删除的目标位置,那么我们需要做的都仅仅是改变目标结点及其前驱/后继结点的指针指向。

查询

正是由于数组中的元素是连续的,每个元素的内存地址可以根据其索引距离数组头部的距离来计算出来。因此对数组来说,每一个元素都可以通过数组的索引下标直接定位。

要想访问链表中间的一个元素,则需要从起点(表头)开始迭代链表直到找到所需的元素。随着链表长度的增加,我们搜索的范围也会变大、遍历其中任意元素的时间成本自然随之提高。

面试中对于链表和数组中问的最多的也就是他们之间的区别,因此请牢记它们。

树是一种分层数据的抽象模型。树可以看做是一种特殊的链表,只是链表只有一个next指向下一个节点,而树的每个节点都有两个next(左右next)指向下一个节点。

一个树结构包含一系列存在父子关系的节点。每个节点都有一个父节点(除了顶部的第一个节点)以及零个或多个子节点:

树的常见概念

  • 位于树顶部的节点叫作根节点
  • 子树由节点和它的后代构成。例如,节点13、12和14构成了上图中树的一棵子树。
  • 节点的一个属性是深度,节点的深度取决于它的祖先节点的数量。比如,节点3有3个祖先节点(5、7和11),它的深度为3。
  • 树的高度取决于所有节点深度的最大值。一棵树也可以被分解成层级。根节点在第0层,它的子节点在第1层,以此类推。上图中的树的高度为3(最大高度已在图中表示——第3层)。

二叉树

二叉树是一种特殊的树。

二叉树中的节点最多只能有两个子节点:一个是左侧子节点,另一个是右侧子节点。这个定义有助于我们写出更高效地在树中插入、查找和删除节点的算法。

二叉树是指满足以下要求的树:

  • 它可以没有根结点,作为一棵空树存在;
  • 如果它不是空树,那么必须由根结点、左子树和右子树组成,且左右子树都是二叉树。

意义

在链表中,插入、删除速度很快,但查找速度较慢。

在数组中,查找速度很快,但插入删除速度很慢。

为了解决这个问题,找寻一种能够在插入、删除、查找、遍历等操作都相对快的容器,于是人们发明了二叉树。

值得注意的是,无特征的二叉树在工业上是没啥用处的,一般都是用的(BST二叉搜索树)、(AVL自平衡树)等具有特殊特征的二叉树。

二叉树的遍历

以一定的顺序规则,逐个访问二叉树的所有结点,这个过程就是二叉树的遍历。

按照顺序规则的不同,遍历方式有以下四种:

  • 先序遍历
  • 中序遍历
  • 后序遍历
  • 层次遍历

本文只涉及先序遍历、中序遍历、后序遍历这三种遍历。

对树的遍历,就可以看做是对这三个部分的遍历。

这里就引出一个问题:三个部分中,到底先遍历哪个、后遍历哪个呢?

我们此处其实可以穷举一下,假如在保证“左子树一定先于右子树遍历”这个前提,那么遍历的可能顺序也不过三种:

  • 先序:根结点 -> 左子树 -> 右子树
  • 中序:左子树 -> 根结点 -> 右子树
  • 后序:左子树 -> 右子树 -> 根结点

所谓的“先序”、“中序”和“后序”,“先”、“中”、“后”其实就是指根结点的遍历时机。

先序遍历

先序遍历的“旅行路线”如下图红色数字 所示:

如果说有 N 多个子树,那么我们在每一棵子树内部,都要重复这个“旅行路线”,动画演示如下:

1.gif

这个“重复”,我们就用递归来实现。

const root = {
  val: "A",
  left: {
    val: "B",
    left: {
      val: "D"
    },
    right: {
      val: "E"
    }
  },
  right: {
    val: "C",
    right: {
      val: "F"
    }
  }
};

编写一个递归函数之前,大家首先要明确两样东西:

  1. 递归式
  2. 递归边界

递归式,它指的是你每一次重复的内容是什么。在这里,我们要做先序遍历,那么每一次重复的其实就是 根结点 -> 左子树 -> 右子树 这个旅行路线。

递归边界,它指的是你什么时候停下来。

在遍历的场景下,当我们发现遍历的目标树为空的时候,就意味着旅途已达终点、需要画上句号了。这个“画句号”的方式,在编码实现里对应着一个 return 语句——这就是二叉树遍历的递归边界。

// 所有遍历函数的入参都是树的根结点对象
function preorder(root) {
    // 递归边界,root 为空
    if(!root) {
        return 
    }
     
    // 输出当前遍历的结点值
    console.log('当前遍历的结点值是:', root.val)  
    // 递归遍历左子树 
    preorder(root.left)  
    // 递归遍历右子树  
    preorder(root.right)
}

中序遍历

理解了先序遍历的过程,中序遍历就不是什么难题。唯一的区别只是把遍历顺序调换了左子树 -> 根结点 -> 右子树:

若有多个子树,那么我们在每一棵子树内部,都要重复这个“旅行路线”,这个过程用动画表示如下:

2.gif

递归边界照旧,唯一发生改变的是递归式里调用递归函数的顺序——左子树的访问会优先于根结点。我们参考先序遍历的分析思路,来写中序遍历的代码:

// 所有遍历函数的入参都是树的根结点对象
function inorder(root) {
    // 递归边界,root 为空
    if(!root) {
        return 
    }
     
    // 递归遍历左子树 
    inorder(root.left)  
    // 输出当前遍历的结点值
    console.log('当前遍历的结点值是:', root.val)  
    // 递归遍历右子树  
    inorder(root.right)
}

后序遍历

在后序遍历中,我们先访问左子树,再访问右子树,最后访问根结点:

若有多个子树,那么我们在每一棵子树内部,都要重复这个“旅行路线”:

3.gif

在编码实现的时候,递归边界照旧,唯一发生改变的仍然是是递归式里调用递归函数的顺序:

function postorder(root) {
    // 递归边界,root 为空
    if(!root) {
        return 
    }
     
    // 递归遍历左子树 
    postorder(root.left)  
    // 递归遍历右子树  
    postorder(root.right)
    // 输出当前遍历的结点值
    console.log('当前遍历的结点值是:', root.val)  
}

上面我们也说了无特征的二叉树在工业上是没啥用处的,接下来我们来看看最常用的有特征的二叉树BST二叉搜索树

BST 二叉搜索树

是二叉树的一种,但是只允许你在左侧节点存储(比父节点)小的值,在右侧节点存储(比父节点)大的值。

满足上面条件的就是二叉搜索树。我们通过一道真题来加深理解:

真题LeetCode地址

题目:给定一个二叉树,判断其是否是一个有效的二叉搜索树。

假设一个二叉搜索树具有如下特征:

  • 节点的左子树只包含小于当前节点的数。
  • 节点的右子树只包含大于当前节点的数。
  • 所有左子树和右子树自身必须也是二叉搜索树。
输入:
    2
   / \
  1   3
输出: true
输入:
    5
   / \
  1   4
     / \
    3   6
输出: false
解释: 输入为: [5,1,4,null,null,3,6]。
     根节点的值为 5 ,但是其右子节点值为 4

解题思路:

题目要求输入一个二叉树判断它是不是二叉搜索树

中序遍历二叉树即:左根右的顺序,思考下输出是不是1,2,3,是的刚好是一个升序,因此我们通过中序遍历一颗树的结果是不是一个升序结果,从而判断它是不是二叉搜索树。

JavaScript 代码:

/**
 * @param {TreeNode} root
 * @return {boolean}
 */
var isValidBST = function(root) {
    var queue = []
    function inorder(root){
        if (!root) return
        if (root.left) inorder(root.left)
        if (root) queue.push(root.val)
        if (root.right) inorder(root.right)
    }

    inorder(root)
    // 判断中序遍历输出的数组是不是升序排列的
    for (let i =0; i< queue.length-1; i++){
        if (queue[i] >= queue[i+1]) return false
    }
    
    return true
};

感兴趣的朋友可以自己到LeetCode中取提交代码测试下。