基本数据结构(超全)

205 阅读9分钟

数据结构

1.常见线性数据结构(数组、链表)

数组

特点:需要连续的存储空间存储数据。一旦数组存储满,如果想扩容所需要的代价较高,比如当一个长度为8的数组存满之后想存储第九个数据,数组会扩容自己的长度为16,复制原来8个数据然后存储第九个数据;假如数组不会再存储别的数据的,那么就会浪费剩余个7个单位长度的空间。

**优点:**读取数据速度很快

**缺点:**可能会浪费较大的存储空间,删除数组的数据也会占用CPU的性能。

链表

特点:可以存储在零散的存储空间中,每一个链表结点都可以作为根结点。每一个结点除了存储自身的数据value外,还会存储指向下一个结点的引用next。

优点:

  • 不需要连续的存储空间,零散的存储空间就可以
  • 链表的添加和删除非常容易

缺点:

  • 查询速度非常慢
  • 每一个结点都要创建指向next的引用,浪费存储空间

image-20220318155135512

如上图:蓝色背景为存储空间,黄色为链表结构,红色为数组。链表的各个结点可以随意存储在碎片空间,只要碎片空间足够大即可。我认为它们三者的关系就是房间里怎么合理放下不同大小的家具。数组一旦占好空间了就不好移动或者扩容了;而链表不同,零碎空间>新结点空间大小即可。

2.遍历链表

声明一个创建Node结点的函数,将其的next指向定为空

function Node(value) {
    this.value = value
    this.next = null
}

var node1 = new Node(1)
var node2 = new Node(2)
var node3 = new Node(3)
var node4 = new Node(4)
var node5 = new Node(5)

node1.next = node2
node2.next = node3
node3.next = node4
node4.next = node5

循环遍历

// 循环遍历
function forNode(root){
    if(root == null) return
    while(1){
        if(root != null){
            console.log(root.value);
        }else{
            break
        }
        root = root.next
    }
}
forNode(node1) //1,2,3,4,5

递归遍历

// 递归遍历
function recursionNode(root){
    //先找递归出口
    if(root == null) return

    console.log(root.value);
    //递归调用自己
    recursionNode(root.next)
}
recursionNode(node1)//1,2,3,4,5

总结:数组和链表的循环遍历以及递归遍历模板一样,只是区别在于数组使用循环遍历性能更好(因为知晓数组的长度),而链表采用递归遍历更好。

3.链表逆置

如下图:有一条5个结点的链表

image-20220318170533163

其实第五个结点也有next指向,只不过指向为空;不管改变前面四个哪一个结点的next指向都会让链表断裂,所以需要在next指向为null的结点下手,先把该结点的next指向转向即可完成第一步逆转。

  • 第一步:逆转最后一个结点的next(递归出口)
//root为当前结点
if(root.next.next == null)//说明当前节点为倒数第二个结点
//那么就把最后一个结点的next逆转指向倒数第二个结点
root.next.next = root

image-20220318171911412

此时各结点的next指向就变为如上图所示

  • 第二步:递归逆转剩余结点(递归公式)

让当前结点的下一个结点的指向指向自己,并且将当前结点的指向赋为空(否则到最后会出现1结点指向2,2结点指向1)

//root为当前结点
root.next.next = root
root.next = null
完整逆置
//链表逆置
function reverseLink(root) {
    if(root.next.next == null){//表明当前结点为倒数第二个结点
        root.next.next = root //最后一个结点的next指向倒数第二个结点
        return root.next //root.next为最后一个结点,这就是递归出口
    }else{
        var result = reverseLink(root.next)
        //当前结点的下一个结点的next指向变为当前节点
        root.next.next = root
        //当前结点的next指向变为空
        root.next = null
        return result
    }
}

4.冒泡排序

  • 冒泡排序的本质是比较和交换位置
function compare(a, b) {
  if (a > b) return true;
  else return false;
}

function exchange(arr, a, b) {
  var temp = arr[a];
  arr[a] = arr[b];
  arr[b] = temp;
}

function sort(arr) {
  for (var j = 0; j < arr.length; j++) {
    for (var i = 0; i < arr.length - j; i++) {
      if (compare(arr[i], arr[i + 1])) {
        exchange(arr, i, i + 1);
      }
    }
  }
}
var arr = [5, 2, 3, 6, 8, 6, 9, 1, 4];
sort(arr);
console.log(arr);//[1, 2, 3, 4, 5, 6, 6, 8, 9]

冒泡排序的核心就是sort函数中第二层for循环的i < arr.length - j,每次循环都可以比上一次少比较一个数字,因为每次比较都已经把最大的数字排序到队列的最后了,当前数字可以不用和队列后每次更新的最大数字相比较了

5.选择排序

选择排序和冒泡排序的不同点就在于,选择排序在循环时从所有的数字中找出最大的那一个数字排到队伍最后。

function sort(arr) {
    for (var j = 0; j < arr.length; j++) {
        var maxIndex = 0;
        for (var i = 0; i < arr.length - j; i++) {
            if (compare(arr[i], arr[maxIndex])) {
                maxIndex = i;
            }
            exchange(arr, maxIndex, arr.length - 1 - j);
        }
    }
}

6.简单快速排序

function quickSort(arr) {
  //递归出口
  if (arr == null || arr.length == 0) return [];
  //把数组的第一个元素当做站队组长,比它大的放在一个数组,比它小的放在另一个数组
  var leader = arr[0],
    left = [],
    right = [];
  for (var i = 1; i < arr.length; i++) {
    if (leader > arr[i]) left.push(arr[i]);
    else right.push(arr[i]);
  }
  //用递归将left数组和right数组继续用快排重排
  left = quickSort(left);
  right = quickSort(right);
  //要等所有小数都排序完成了才能将这个较大的数添加到较小数数组的末尾
  left.push(leader);
  return left.concat(right);
}
var arr = [5, 6, 9, 4, 2, 8, 1, 3];
console.log(quickSort(arr));//[1, 2, 3, 4, 5, 6, 8, 9]

简单快排算法思路上比较简单,但是呢在递归的时候会创建非常多的数组会消耗CPU性能,如果是应对几十上百个数组的话性能还是不错的,但是如果数组量太大的话性能就会降低。

7.标准快速排序

标准快排更优化了性能,但是逻辑显得更加复杂了。

首先需要创建leftright两个指针

image-20220319165951828

然后将left所指的数字和第一个数字比较(前提是left<right);如果left<arr[begin],left指针就可以向前移动;同理right指针也是如此。知道两指针都停下来并且满足left < right的条件就可以将arr[left]和arr[right]互换。

img

最终比4小的数字会在一堆,比4大的数字会在一堆;此时需要将arr[left]arr[begin]进行替换。

临界点:var swapPoint = left == right ? right - 1 : right;

交换完成之后就可以使用递归继续快排直到排列完成为止

image-20220319170242561

function swap(arr, a, b) {
  var temp = arr[a];
  arr[a] = arr[b];
  arr[b] = temp;
}

function quickSort(arr, begin, end) {
  //递归出口
  if (begin >= end - 1) return;
  var left = begin;
  var right = end;
  do {
    do left++;
    while (left < right && arr[left] < arr[begin]);
    do right--;
    while (right > left && arr[right] > arr[begin]);
    if (left < right) swap(arr, left, right);
  } while (left < right);
  var swapPoint = left == right ? right - 1 : right;
  swap(arr, begin, swapPoint);
  quickSort(arr, begin, swapPoint);
  quickSort(arr, swapPoint + 1, end);
}
var arr = [5, 6, 9, 4, 2, 8, 1, 3];
quickSort(arr, 0, arr.length);
console.log(arr); //[1, 2, 3, 4, 5, 6, 8, 9]

8.树

**树形结构:有向无环图,树属于图的一种。**树形结构只有一个根节点,不会形成闭环。

image-20220319182828876

​ 如图:简单的树形结构

关于树的若干概念如下

根节点:节点之上没有其他节点(A)

叶子节点:节点下没有其他的子节点(E、F、C、D)

节点:既不是叶子结点也不是根节点的普通节点(B)

树的度:这棵树的某一节点拥有最多的叉的个数叫做度(3)

树的深度:树最深有几层,树的深度就是几(3)

二叉树

二叉树:树的度最多为2的树形结构

image-20220319183730804

​ 如图:度为2的二叉树

满二叉数

需要同时满足以下两个条件才能称为满二叉数

  • 所有叶子节点都在最底层
  • 每个节点都有两个叶子节点

其实我认为满二叉数是一种很完美的对称结构的树

image-20220319184130336

完全二叉树

完全二叉树的国内定义和国外定义有所不同

国内定义:

  • ​ 叶子节点在最后一层或者倒数第二层
  • ​ 叶子节点都向左聚拢
image-20220319185136348

国际定义:

  • 叶子节点在最后一层或者倒数第二层
  • 如果有叶子节点就必然有两个叶子节点

image-20220319185421060

子树

子树:二叉树中,每一个节点或者叶子节点都是一棵子树的根节点

image-20220319183730804

如上图中:A的左子树为B,A的右子树为C。

树的遍历

前序遍历:先遍历当前节点,再左子树,后右子树

中序遍历:先遍历左子树,再当前节点,后右子树

后序遍历:先遍历左子树,再右子树,后当前节点

img

二叉搜索树

问题:数组里有一万个数字,如果用for循环遍历该数组的话,可能需要一万次才能找到这个数字;大大降低了性能,那么二叉搜索树可以几十甚至上百倍的提高查找速度。

image-20220321163736984

基于上图构建二叉搜索树思路如下

这里有一个数组 var arr = [5,3,4,8,6,2,9,1]

arr[0]作为根节点,然后从arr[1]开始逐次和根节点进行比较,比根节点小的数放在左子树,反之放到右子树。如果节点都拥有左右节点了,那么就递归将左右节点作为根节点,将数字放在‘新’根节点的左右两侧

代码示例:

function Node(value) {
  this.value = value;
  this.left = null;
  this.right = null;
}

function addNode(root, num) {
  if (root == null) return;
  if (root == num) return;
  if (root.value < num) {
    //说明目标值比该节点的value大
    //需要判断root.right的值是否非空
    if (root.right == null) root.right = new Node(num);
    else addNode(root.right, num); //root.right不为空,就向右侧进行递归
  } else {
    //目标值比当前节点小
    if (root.left == null) root.left = new Node(num);
    else addNode(root.left, num);
  }
}
/**
 * 构建一个二叉搜索树函数
 * arr = [5,3,4,8,6,2,9,1]
 * 以arr[0]作为基点,后面的树都和它比较,比它小的作为左子树,反之作为右子树
 * 以此递归下去
 */
function buildSearchTree(arr) {
  if (arr == null || arr.length == 0) return;
  var root = new Node(arr[0]);
  //从i = 1开始是因为arr[0]作为基点了不用参与比较
  for (var i = 1; i < arr.length; i++) {
    addNode(root, arr[i]);
  }
  return root;
}

var num = 0;
function searchByTree(root, target) {
  if (root == null || root.length == 0) return false;
  num++;
  if (root.value == target) return true;
  //向左递归
  else if (root.value > target) return searchByTree(root.left, target);
  else return searchByTree(root.right, target);
}
var arr = [5, 3, 4, 8, 6, 2, 3, 1, 55, 66, 33, 12, 78];
console.log(searchByTree(buildSearchTree(arr), 78), "寻找了" + num + "次");//true 寻找了5次

平衡二叉树

平衡二叉树:

  • 左右子树的高度差 ≤ 1
  • 左右子树的左右子树的高度差 ≤ 1

同时满足以上两个条件就算作平衡二叉树

image-20220321163736984

上图就是平衡二叉树示例,左子树高度为3,右子树高度为2,高度差≤1。

二叉树单旋

当一棵二叉树节点不平衡,就需要进行单旋,单旋分为左单旋和右单旋。

左单旋

二叉树的左边浅、右边深

左单旋规律:

1.找到新根

2.找到变化分支

3.当前旋转节点的右子树为变化分支

4.新根的左孩子为旋转节点

5.返回新的根节点

image-20220321180730902

左旋代码示例:

function leftRotate(root) {
  /**
    1.找到新根
    2.找到变化分支
    3.当前旋转节点的右子树为变化分支
    4.新根的左孩子为旋转节点
    5.返回新的根节点
     */
  var newRoot = root.right;
  var changeTree = root.right.left;
  root.right = changeTree;
  newRoot.left = root;
  return newRoot;
}

右单旋

二叉树的右边浅、左边深

image-20220321203437484

右旋代码示例:

function rightRotate(root) {
  /**
    1.找到新根
    2.找到变化分支
    3.当前旋转节点的左子树为变化分支
    4.新根的右孩子为旋转节点
    5.返回新的根节点
     */
  var newRoot = root.left;
  var changeTree = root.right.left;
  root.left = changeTree;
  newRoot.right = root
  return newRoot
}

二叉树双旋

二叉树的双旋分为左右双旋、右左双旋、左左双旋以及右右双旋

右左双旋

当要对某个节点进行左单旋时,如果变化分支是唯一最深分支,那么要对新根进行右单旋然后再进行左单旋

(1)右子树右旋

image-20220322113053921

(2)右子树再左旋

img

代码运行截图:

img

左右双旋

当要对某个节点进行右单旋时,如果变化分支是唯一最深分支,那么要对新根进行左单旋然后再进行右单旋

(1)当根节点的左孩子的右子树的高度大于根节点的左孩子的左子树的高度时,需要先将根节点的左子树(root.left)进行右旋

img

(2)根节点的左子树正常进行右旋即可

image-20220322111029860

代码运行截图:

image-20220322114101570

左左双旋和右右双旋

如果变化分支的高度比旋转节点的另一侧高度差≥2,那么单旋之后依旧不平衡,需要再次单旋。

9.普利姆算法

普利姆算法也称加点法:

  1. 任选一个点作为起点
  2. 找到当前点的最短路径边
  3. 如果这个边的另一端没有被连通起来,那么就连接起点和这一点
  4. 如果这个边的另一端已经被连通起来,那么就看倒数第二短的边
  5. 重复2-4步骤,直到所有的边都连通为止

举例

image-20220321113337360

用表格记录两点之间的举例,特别的A与A点之间的举例为0,若两点无连接则记录距离为无穷大max

ABCDE
A047maxmax
B4086max
C7805max
Dmax6507
Emaxmaxmax70