数据结构

数据结构:

  是计算机存,组织数据的方式.

算法

算法复杂度:

常见数据结构

  1. 数组(Arrary)
  2. 栈(Stack)
  3. 链表(Linked List)
  4. 图(Graphn)
  5. 散列表(Hash)
  6. 队列(Queue)
  7. 树(Tree)
  8. 堆(Heap)

栈结构:

是一种受限的线性表, 特点是 后进先出(LIFO)

  • 受限制之处在于只能在栈顶进行出栈入栈操

 函数调用栈:

  A(B(C(D()))):即A函数中调用B,B调用C,C调用D;在A执行的过程中会将A压入栈,随后B执行时B也被压入栈,函数C和D执行时也会被压入栈。所以当前栈的顺序为:A->B->C->D(栈顶);函数D执行完之后,会弹出栈被释放,弹出栈的顺序为D->C->B->A;

栈类的实现方案有:

  • 基于数组实现

  • 基于链表实现

栈常见的操作:

  • push(element):添加一个新元素到栈顶位置;
  • pop():移除栈顶的元素,同时返回被移除的元素;
  • peek():返回栈顶的元素,不对栈做任何修改(该方法不会移除栈顶的元素,仅仅返回它);
  • isEmpty():如果栈里没有任何元素就返回true,否则返回false;
  • size():返回栈里的元素个数。这个方法和数组的length属性类似;
  • toString():将栈结构的内容以字符串的形式返回。

封装栈类:

stack.js:

export class Stack{
  constructor() {
    this.items = [];
  }

  // push(element):添加一个新元素到栈顶位置;
  push(element) {
    this.items.push(element);
  }

  // pop():移除栈顶的元素,同时返回被移除的元素;
  pop() {
    return this.items.pop();
  }

  // peek():返回栈顶的元素,不对栈做任何修改(该方法不会移除栈顶的元素,仅仅返回它);
  peek() {
    if(this.items.length === 0) return null;
return this.items[this.items.length - 1];
  }

  // isEmpty():如果栈里没有任何元素就返回true,否则返回false;
  isEmpty() {
    return this.items.length === 0;
  }

  // size():返回栈里的元素个数。这个方法和数组的length属性类似;
  size() {
    return this.items.length;
  }

  // toString():将栈结构的内容以字符串的形式返回。
  toString() {
    let resultString = '';
    for(let i of this.items) {
      resultString += i + ' ';
    }
    return resultString;
  }
}
index.js

import {Stack} from "./stack" import {dec2bin} from "./untils" const stack = new Stack(); stack.push("abc"); stack.push("efg"); stack.push("hij"); console.log(stack.items);//["abc", "efg", "hij"] console.log(stack.pop());//hij console.log(stack.items);//["abc", "efg"] console.log(stack.peek());//efg console.log(stack.isEmpty());//false console.log(stack.size());//2 console.log(stack.toString());//abc efg

 

利用栈 实现   十进制转换为二进制:

import {Stack} from "./stack"

/*
  十进制转换为二进制
*/
export function dec2bin(num) {
  // 1. 创建stack
  const stack = new Stack();
  // 2.循环取余数
  while(num > 0) {
    let remainder = num % 2;
    //  Math.floor()向下取整
    num = Math.floor(num / 2);
    stack.push(remainder);
  }
  // 3. 拼接字符串
  let binString = "";
  while (!stack.isEmpty()) {
    binString += stack.pop();
  }
  return binString;
}

 队列(Queue)

  队列是是一种受限的线性表,特点为先进先出(FIFO:first in first out)

  • 受限在于它允许在队头进行出队(删除)操作,在队尾进行入队(插入)操作

队列类的实现方案:

  • 基于数组实现
  • 基于链表实现

常见操作:

  • enqueue(element):向队列尾部添加一个(或多个)新的项;
  • dequeue():移除队列的第一(即排在队列最前面的)项,并返回被移除的元素;
  • front():返回队列中的第一个元素——最先被添加,也将是最先被移除的元素。队列不做任何变动(不移除元素,只返回元素信息与Stack类的peek方法非常类似);
  • isEmpty():如果队列中不包含任何元素,返回true,否则返回false;
  • size():返回队列包含的元素个数,与数组的length属性类似;
  • toString():将队列中的内容,转成字符串形式;

实现普通队列封装类

  queue.js

export class Queue {
  constructor() {
    this.items = [];
  }

  // enqueue(element):向队列尾部添加一个(或多个)新的项;
  enqueue(element) {
    this.items.push(element);
  }

  // dequeue():移除队列的第一(即排在队列最前面的)项,并返回被移除的元素;
  dequeue() {
    return this.items.shift();
  }

  // front():返回队列中的第一个元素——最先被添加,也将是最先被移除的元素。队列不做任何变动(不移除元素,只返回元素信息与Stack类的peek方法非常类似);
  front() {
    if(this.items.length === 0) return null;
    return this.items[0];
  }

  // isEmpty():如果队列中不包含任何元素,返回true,否则返回false;
  isEmpty() {
    return this.items.length === 0;
  }

  // size():返回队列包含的元素个数,与数组的length属性类似;
  size() {
    return this.items.length;
  }

  // toString():将队列中的内容,转成字符串形式;
  toString() {
    let resultString = '';
    for(let i of this.items) {
      resultString += i + ' ' ;
    }
    return resultString;
  }
}
import {Queue} from "./queue"
import {passGame} from "./击鼓传花"

const queue = new Queue();

queue.enqueue("abc");
queue.enqueue("efg");
queue.enqueue("hij");

console.log(queue);//Queue {items: Array(3)}
console.log(queue.dequeue());//abc
console.log(queue.items);// ["efg", "hij"]
console.log(queue.front());//efg
console.log(queue.isEmpty());//false
console.log(queue.size());//2
console.log(queue.toString());//efg hij 

// 击鼓传花
console.log(passGame(["zpl","zs","yz","aa","bb"], 3));//aa

利用队列实现 击鼓传花:

import {Queue} from "./queue"

// 击鼓传花

export function passGame(nameList, num) {
  // 1. 创建队列
  const queue = new Queue();

  // 2. 让所有人进入队列
  for(let i of nameList) {
    queue.enqueue(i);
  }

  // 3.开始数数
  while(queue.size() > 1) { //剩余最后一个人时停止
    //不是num的时候,重新加入队列末尾
    //是num的时候,将其从队列中删除
    for(let i = 0; i < num -1; i++) {
      queue.enqueue(queue.dequeue());
    }
    // 4. 删除指定的那个人
    queue.dequeue();
  }

  // 5. 剩余最后一个人
  return queue.front();
}

优先队列:

优先级队列主要考虑的问题为:

  • 每个元素不再只是一个数据,还包含数据的优先级;
  • 在添加数据过程中,根据优先级放入到正确位置;

优先级队列的实现:

/* 
  优先级队列
*/
// 优先级
class QueueElement {
  constructor(element, priority) {
    this.element = element;
    this.priority = priority;
  }
}

// 优先级队列
export class PriorityQueue extends Queue {
  // 1. 创建QueueElement 对象
  enqueue(element, priority) {
    const queueElement = new QueueElement(element, priority);
    // 2. 实现插入
    if(this.isEmpty()) {
      this.items.push(queueElement);
    }else {
      //定义一个变量记录是否成功添加了新元素
      let addTag = false;
      for(let i in this.items) {
         // 让新插入的元素与原有元素进行优先级比较(priority越小,优先级越大)
        if(queueElement.priority < this.items[i].priority) {
          this.items.splice(i, 0, queueElement);
          addTag = true;
          break;
        }
      }

      // 新元素没有成功插入,就把它放在队列的最前面
      if(!addTag) {
        this.items.push(queueElement);
      }
    }
  }
测试:

//
优先级队列 const pqueue = new PriorityQueue(); pqueue.enqueue("a",10); pqueue.enqueue("b",1); pqueue.enqueue("c",12); pqueue.enqueue("d",100); console.log(pqueue.items) pqueue.items.forEach(items => { console.log(items.element +" "+ items.priority); });

 链表(Linked List)

链表和数组一样,可以用于存储一系列的元素,但是链表和数组的实现机制完全不同。链表的每个元素由一个存储元素本身的节点和一个指向下一个元素的引用(有的语言称为指针或连接)组成。类似于火车头,一节车厢载着乘客(数据),通过节点连接另一节车厢。

  • head属性指向链表的第一个节点;
  • 链表中的最后一个节点指向null;
  • 当链表中一个节点也没有的时候,head直接指向null;

数组存在的缺点:

  • 数组的创建通常需要申请一段连续的内存空间(一整块内存),并且大小是固定的。所以当原数组不能满足容量需求时,需要扩容(一般情况下是申请一个更大的数组,比如2倍,然后将原数组中的元素复制过去)。
  • 在数组的开头或中间位置插入数据的成本很高,需要进行大量元素的位移。

链表的优势:

  • 链表中的元素在内存中不必是连续的空间,可以充分利用计算机的内存,实现灵活的内存动态管理

  • 链表不必在创建时就确定大小,并且大小可以无限地延伸下去

  • 链表在插入和删除数据时,时间复杂度可以达到O(1),相对数组效率高很多

链表的缺点:

  • 链表访问任何一个位置的元素时,都需要从头开始访问(无法跳过第一个元素访问任何一个元素)。
  • 无法通过下标值直接访问元素,需要从头开始一个个访问,直到找到对应的元素。
  • 虽然可以轻松地到达下一个节点,但是回到前一个节点是很难的。

链表中的常见操作:

  • append(element):向链表尾部添加一个新的项;
  • insert(position,element):向链表的特定位置插入一个新的项;
  • get(position):获取对应位置的元素;
  • indexOf(element):返回元素在链表中的索引。如果链表中没有该元素就返回-1
  • update(position,element):修改某个位置的元素;
  • removeAt(position):从链表的特定位置移除一项;
  • remove(element):从链表中移除一项;
  • isEmpty():如果链表中不包含任何元素,返回trun,如果链表长度大于0则返回false;
  • size():返回链表包含的元素个数,与数组的length属性类似;
  • toString():由于链表项使用了Node类,就需要重写继承自JavaScript对象默认的toString方法,让其只输出元素的值;

首先需要弄清楚:下文中的position指的是两个节点之间,并且与index的关系如下图所示:

image-20200306101534508

position的值一般表示position所指位置的下一个节点。当position的值与index的值相等时,比如position = index = 1,那么它们都表示Node2。

封装单向链表类

linked-list.js

/* 
  创建节点
*/
class Node {
  constructor(element) {
    //保持原素
    this.element = element;
    // 指向下一个节点
    this.next = null;
  }
}

/* 
  封装单向链表
*/
export class LinkedList {
  constructor() {
    this.head = null;
    this.length = 0;
  }

  // append(element):向链表尾部添加一个新的项;
  append(element) {
    // 1. 根据element常见node节点
    const newNode = new Node(element);
    // 2. 如果链表没有东西
    if(!this.head) {
      this.head = newNode;
    }else {
      // 链表有节点
      let current = this.head;
      while(current.next) {
        current = current.next;
      }
      // 3. 最后的节点的next指向新的节点
      current.next = newNode;
    }
    // 4. 节点长度加一
    this.length += 1;
  }
  
  // insert(position,element):向链表的特定位置插入一个新的项;
  insert(position, element) {
    // 1. 越界问题
    if(position < 0 || position > this.length) return false;
    // 2. 创建节点
    const newNode = new Node(element);
    // 3. 插入元素
    if(position == 0) {
      this.head = newNode;
    }else {
      let current = this.head;
      let previous = null;
      let index = 0;
      while(index++ < position) {
        previous = current;
        current = current.next;
      }
      newNode.next = current;
      previous.next = newNode;
    }
    this.length += 1;
    return true;
  }
  
  // get(position):获取对应位置的元素;
  get(position) {
    if(position < 0 || position > this.length -1) return false;
    let index = 0;
    let current = this.head;
    while(index++ < position) {
      current = current.next;
    }
    return current.element;
  }
  
  // indexOf(element):返回元素在链表中的索引。如果链表中没有该元素就返回-1;
  indexOf(element) {
    let index = 0;
    let current = this.head;
    while(current) {
      if(current.element === element) {
        return index;
      }else {
        index++;
        current = current.next;
      }
    }
    return -1;
  }
  
  // update(position,element):修改某个位置的元素,并返回修改前的元素;
  update(position, element) {
    if(position < 0 || position > this.length) return false;
    // 1. 删除指定位置的元素
    let result = this.removeAt(position);
    // 2. 在指定位置插入指定元素
    this.insert(position, element);
    return result;
  }
  
  // removeAt(position):从链表的特定位置移除一项,并返回这一项元素;
  removeAt(position) {
    // 1. 判断越界
    if(position < 0 || position > this.length -1) return false;
    // 2. 删除元素
    let current = this.head;
    let previous = null;
    let index = 0;
    if(position === 0) {
      this.head = current.next;
    } else {
      while(index++ < position) {
        previous = current;
        current = current.next;
      }
      previous.next = current.next;
    }

    this.length --;
    return current.element;
  }
  
  // remove(element):从链表中移除一项;
  remove(element) {
    // 1. 查找元素的位置
    let index = this.indexOf(element);
    if(index == -1) return;
    // 2. 删除位置的元素
    this.removeAt(index);
  }
  
  // isEmpty():如果链表中不包含任何元素,返回trun,如果链表长度大于0则返回false;
  isEmpty() {
    return this.length === 0 ? true : false;
  }
  
  // size():返回链表包含的元素个数,与数组的length属性类似;
  size() {
    return this.length;
  }
  
  // toString():由于链表项使用了Node类,就需要重写继承自JavaScript对象默认的toString方法,让其只输出元素的值;
  toString() {
    let current = this.head;
    let listString = '';
    while(current) {
      listString += current.element + ' ';
      current = current.next;
    }
    return listString;
  }
}
测试.js

import {LinkedList} from "./linked-list" const linkedList = new LinkedList(); linkedList.append("aaa"); linkedList.append("bbb"); linkedList.append("ccc"); console.log(linkedList);//LinkedList {head: Node, length: 3} linkedList.insert(2,"ddd"); console.log(linkedList); console.log(linkedList.get(0));//aaa console.log(linkedList.indexOf("bbb"));//1 console.log(linkedList.indexOf("bb"));//-1 console.log(linkedList.removeAt(2));//ddd console.log(linkedList);//LinkedList {head: Node, length: 3} console.log(linkedList.update(2,"eee"));//ccc linkedList.remove("eee"); console.log(linkedList);//LinkedList {head: Node, length: 2} console.log(linkedList.isEmpty());//false console.log(linkedList.size());//2 console.log(linkedList.toString());//aaa bbb

双向链表

  双向链表:既可以从头遍历到尾,又可以从尾遍历到头。也就是说链表连接的过程是双向的,它的实现原理是:一个节点既有向前连接的引用,也有一个向后连接的引用

双向链表的缺点:

  • 每次在插入或删除某个节点时,都需要处理四个引用,而不是两个,实现起来会困难些;
  • 相对于单向链表,所占内存空间更大一些
  • 但是,相对于双向链表的便利性而言,这些缺点微不足道。

双向链表的结构:

image-20200227204728456 

  • 双向链表不仅有head指针指向第一个节点,而且有tail指针指向最后一个节点;
  • 每一个节点由三部分组成:item储存数据、prev指向前一个节点、next指向后一个节点
  • 双向链表的第一个节点的prev指向null
  • 双向链表的最后一个节点的next指向null

双向链表常见的操作(方法):

  • append(element):向链表尾部添加一个新的项;
  • insert(position,element):向链表的特定位置插入一个新的项;
  • get(position):获取对应位置的元素;
  • indexOf(element):返回元素在链表中的索引,如果链表中没有元素就返回-1;
  • update(position,element):修改某个位置的元素;
  • removeAt(position):从链表的特定位置移除一项;
  • remove(element):从链表中移除一项
  • isEmpty():如果链表中不包含任何元素,返回trun,如果链表长度大于0则返回false;
  • size():返回链表包含的元素个数,与数组的length属性类似;
  • toString():由于链表项使用了Node类,就需要重写继承自JavaScript对象默认的toString方法,让其只输出元素的值;
  • forwardString():返回正向遍历节点字符串形式
  • backwordString():返回反向遍历的节点的字符串形式

二、封装双向链表类

封装双向链表类

double-linked-list.js
import {LinkedList,Node} from "./linked-list"

class DoubleNode extends Node {
  constructor(element) {
   super(element);
    this.prev = null;
  }
}

export class DoubleLinkedList extends LinkedList{
  constructor() {
    super();
    this.tail = null;
  }
  // append(element):向链表尾部添加一个新的项;
  append(element) {
    const newNode = new DoubleNode(element);
    if(!this.head) {
      this.head = newNode;
      this.tail = newNode;
    }else {
     this.tail.next = newNode;
     newNode.prev = this.tail;
     this.tail = newNode;
    }
    this.length += 1;
  }

  // insert(position,element):向链表的特定位置插入一个新的项;
  insert(position, element) {
    if(position < 0 || position > this.length) return false;
    const newNode = new DoubleNode(element);
    if(position == 0) {
      if(this.head == null) {
        this.head = newNode;
        this.tail = newNode;
      }else {
        this.head.prev = newNode;
        newNode.next = this.head;
        this.head = newNode;
      }
    }else if(position == this.length) {
      this.tail.next = newNode;
      newNode.prev = this.tail;
      this.tail = newNode;
    }else {
      let index = 0;
      let previous = null;
      let current = this.head;
      while(index++ < position) {
        previous = current;
        current = current.next;
        index++;
      }
      previous.next = newNode;
      newNode.prev = previous;
      current.prev = newNode;
      newNode.next = current;
    }
    this.length += 1;
  }
  
  // get(position):获取对应位置的元素;
  // 不用重写了,父类已经有这个方法了

  // get(position) {
  //   if(position < 0 || position > this.length - 1) return false;
  //   let index = 0;     
  //   let current = this.head;
  //   while(index++ < position) {
  //     current = current.next;
  //   }
  //   return current.element;
  // }
  
  // indexOf(element):返回元素在链表中的索引,如果链表中没有元素就返回-1;
  // 不用重写了,父类已经有这个方法了
  
  // update(position,element):修改某个位置的元素;
 // 不用重写了,父类已经有这个方法了

 
  // removeAt(position):从链表的特定位置移除一项;
  removeAt(position) {
    if(position < 0 || position > this.length - 1) return false;
    
    let index = 0;
    let result = this.head;
    let previous = null;
    let current = this.head;
    if(position === 0) {
      if(this.length === 1) {
        this.head = null;
        this.tail = null;
      }else {
        this.head = this.head.next;
        this.head.prev = null;
      }
    }else if(position == this.length - 1) {
      this.tail = this.tail.prev;
      this.tail.next = null;
    }else {
      while(index++ < position) {
        previous = current;
        current = current.next;
      }
      result = current;
      previous.next = current.next;
      current = current.next;
      current.prev = previous;
    }
    this.length -= 1;
    return result.element;
  }
 
   // remove(element):从链表中移除一项
  //  不用重写了,父类已经有这个方法了
  
  // isEmpty():如果链表中不包含任何元素,返回trun,如果链表长度大于0则返回false;
  // 不用重写了,父类已经有这个方法了
  
  // size():返回链表包含的元素个数,与数组的length属性类似;
  // 不用重写了,父类已经有这个方法了
  
  // toString():由于链表项使用了Node类,就需要重写继承自JavaScript对象默认的toString方法,让其只输出元素的值;
  // 不用重写了,父类已经有这个方法了
  
  // forwardString():返回正向遍历节点字符串形式;
  
  
  // backwordString():返回反向遍历的节点的字符串形式;
}
//双向链表
import {DoubleLinkedList} from "./double-linked-list"
let douBLList = new DoubleLinkedList();
douBLList.append("111");
douBLList.append("222");
douBLList.append("333");

console.log(douBLList);
douBLList.insert(3, "hhh");
console.log(douBLList);

console.log(douBLList.get(0));//111
console.log(douBLList.indexOf("222"));//1
console.log(douBLList.isEmpty());//false
console.log(douBLList.size());//4
console.log(douBLList.toString());//111 222 333 hhh
// console.log(douBLList.removeAt(1));//222
douBLList.remove("222")
console.log(douBLList.update(0,"000"));//222
console.log(douBLList.toString());//000 333 hhh 

 图可借鉴:  https://www.cnblogs.com/AhuntSun-blog/p/12441095.html

注意点

  • 在链表中current = current.next 可以从左往右看,看成是current --> current.next,即current指向current的下一个节点。
  • 删除节点的原理:只要没有引用指向该对象,无论该对象是否有引用指向其他对象,该对象都会被回收(删除)。
  • 参数中凡是有position的都要进行越界判断。

集合结构

集合比较常见的实现方式是哈希表这里使用JavaScript的Object类进行封装

集合通常是由一组无序的、不能重复的元素构成。

  • 数学中常指的集合中的元素是可以重复的,但是计算机中集合的元素不能重复。

集合是特殊的数组:

  • 特殊之处在于里面的元素没有顺序,也不能重复
  • 没有顺序意味着不能通过下标值进行访问,不能重复意味着相同的对象在集合中只会存在一份。

实现集合类:

  • 在ES6中的Set类就是一个集合类,这里我们重新封装一个Set类,了解集合的底层实现。

  • JavaScript中的Object类中的key就是一个集合,可以使用它来封装集合类Set。

集合常见的操作:

  • add(value):向集合添加一个新的项;

  • remove(value):从集合中移除一个值;

  • has(value):如果值在集合中,返回true,否则返回false

  • clear():移除集合中的所有项;

  • size():返回集合所包含元素的数量,与数组的length属性相似;

  • values():返回一个包含集合中所有值的数组;

还有其他的方法,用的不多这里不做封装;

集合间操作:

  • 并集:对于给定的两个集合,返回一个包含两个集合中所有元素的新集合;
  • 交集:对于给定的两个集合,返回一个包含两个集合中共有元素的新集合;
  • 差集:对于给定的两个集合,返回一个包含所有存在于第一个集合且不存在于第二个集合的元素的新集合
  • 子集:验证一个给定集合是否是另一个集合的子集;

image-20200228210239984

 

代码实现

//封装集合类
export function Set() {
  // 属性
  this.items = {};

  // 方法
  // add(value):向集合添加一个新的项;
  Set.prototype.add = function (value) {
    // 判断是否已经包含该元素
    if(this.has(value)) {
      return false;
    }
    // 添加元素
    this.items[value] = value;
    return true;
  }

  // remove(value):从集合中移除一个值;
  Set.prototype.remove = function(value) {
    //  1. 判断是否含有该元素
    if(!this.has(value)) {
      return false;
    }
    // 2. 删除元素
    delete this.items[value];
    return true;
 }

  // has(value):如果值在集合中,返回true,否则返回false;
  Set.prototype.has = function(value) {  
    return this.items.hasOwnProperty(value);
  }

  // clear():移除集合中的所有项;
  Set.prototype.clear = function() {
    this.items = {};
  }

  // size():返回集合所包含元素的数量,与数组的length属性相似;
  Set.prototype.size = function() {
    return Object.keys(this.items).length;
  }

  // values():返回一个包含集合中所有值的数组;
  Set.prototype.values = function() {
    return Object.keys(this.items);
  }

  /* 
    集合
  */
  // 并集
  Set.prototype.union = function(otherSet) {
    // this 集合对象A
    // otherSet 集合对象B
    // 1. 创建新的集合
    let unionSet = new Set();
    // 2. 将集合B的所有元素添加到新集合中
    let values = this.values();
    for(let i = 0; i < values.length; i++) {
      unionSet.add(values[i]);
    }
    
    // 3. 取出集合B中的元素,判断是否添加到新的集合中
    values = otherSet.values();
    for(let i = 0; i < values.length; i++) {
      unionSet.add(values[i]);
    }
    
    return unionSet;
  }

  // 交集
  Set.prototype.intersection = function(otherSet) {
    // this 集合对象A
    // otherSet 集合对象B
    // 1. 创建新的集合
    var intersectionSet = new Set();

    // 2.从A中取出一个元素,判断是否同时包含在集合B中,存在则添加到新集合中
    let values = this.values();
    for(let i = 0; i < values.length; i++) {
      let item = values[i];
      if(otherSet.has(item)) {
        intersectionSet.add(item);
      }
    }
    return intersectionSet;
  }

  // 差集
  Set.prototype.difference = function(otherSet) {
     // this 集合对象A
    // otherSet 集合对象B
    // 1. 创建新的集合
    var differenceSet = new Set();

    // 2.从A中取出一个元素,判断是否同时包含在集合B中,不存在则添加到新集合中
    let values = this.values();
    for(let i = 0; i < values.length; i++) {
      let item = values[i];
      if(!otherSet.has(item)) {
        differenceSet.add(item);
      }
    }
    return differenceSet;
  }

  // 子集
  Set.prototype.subset = function(otherSet) {
    // this 集合对象A
    // otherSet 集合对象B

    // 遍历A中的元素,如果发现集合A中的元素都可以在B中找到,返回true,发现一项不存在,返回false
    let values = this.values();
    for(let i = 0; i < values.length; i++) {
      let item = values[i];
      if(!otherSet.has(item)) {
        return false;
      }
    }
    return true;
  }
}
import {Set} from "./set"

const set = new Set();
const set2 = new Set();

set.add("a");
set.add("b");
set.add("c");

set2.add("aa");
set2.add("bb");
set2.add("c");

console.log(set);//items: {a: "a", b: "b", c: "c"}
console.log(set.has("a"));//true
console.log(set.remove("a"));//true
console.log(set.size());//2
console.log(set.values());// ["b", "c"]

// set.clear();

const unionSst = set.union(set2);
const intersectionSet = set.intersection(set2);
const differenceSet = set.difference(set2);
console.log(set.values());// ["b", "c"]
console.log(set2.values());// ["aa", "bb", "c"]
console.log(unionSst.values());//["b", "c", "aa", "bb"]
console.log(intersectionSet.values());//["c"]
console.log(differenceSet.values());//["b"]
console.log(set.subset(set2));//false

 

 

 

字典结构

字典的特点:

  • 字典存储的是键值对,主要特点是一一对应
  • 比如保存一个人的信息:数组形式:[19,‘Tom’,1.65],可通过下标值取出信息;字典形式:{"age":19,"name":"Tom","height":165},可以通过key取出value。
  • 此外,在字典中key是不能重复且无序的,而Value可以重复

字典和映射的关系:

  • 有些编程语言中称这种映射关系为字典,如Swift中的Dictonary,Python中的dict;
  • 有些编程语言中称这种映射关系为Map,比如Java中的HashMap&TreeMap等;

字典类常见的操作:

  • set(key,value):向字典中添加新元素。
  • remove(key):通过使用键值来从字典中移除键值对应的数据值。
  • has(key):如果某个键值存在于这个字典中,则返回true,反之则返回false
  • get(key):通过键值查找特定的数值并返回。
  • clear():将这个字典中的所有元素全部删除。
  • size():返回字典所包含元素的数量。与数组的length属性类似。
  • keys():将字典所包含的所有键名以数组形式返回。
  • values():将字典所包含的所有数值以数组形式返回。

封装字典

字典类可以基于JavaScript中的对象结构来实现,比较简单,这里直接实现字典类中的常用方法。

// 封装字典
export function Dictionary() {
  // 字典属性
  this.items = {};
  // 字典操作方法
  //set(key,value):向字典中添加新元素。
  Dictionary.prototype.set = function(key, value) {
    this.items[key] = value;
  }
  
  // remove(key):通过使用键值来从字典中移除键值对应的数据值。
  Dictionary.prototype.remove = function(key){
    //1.判断字典中是否有这个key
    if(!this.has(key)) return false;

    //2.从字典中删除key
    delete this.items[key];
    return true;
  }
  
  // has(key):如果某个键值存在于这个字典中,则返回true,反之则返回false。
  Dictionary.prototype.has = function(key){
    return this.items.hasOwnProperty(key);
  }
  
  // get(key):通过键值查找特定的数值并返回。
  Dictionary.prototype.get = function(key){
    //1.判断字典中是否有这个key
    if(!this.has(key)) return false;

    return this.items[key];
  }

  // clear():将这个字典中的所有元素全部删除。
  Dictionary.prototype.clear = function(){
    this.items = {};
  }
  
  // size():返回字典所包含元素的数量。与数组的length属性类似。
  Dictionary.prototype.size = function(){
    return Object.keys(this.items).length;
  }
  
  // keys():将字典所包含的所有键名以数组形式返回。
  Dictionary.prototype.keys = function(){
    return Object.keys(this.items);
  }
  
  // values():将字典所包含的所有数值以数组形式返回。
  Dictionary.prototype.values = function() {
    return Object.values(this.items);
  }
  }
import {Dictionary} from "./dictionary"

const dictionary = new Dictionary();

dictionary.set("name","zpl");
dictionary.set("age",22);
dictionary.set("height",1.81);
dictionary.set("height",1.8);

console.log(dictionary);
console.log(dictionary.has("height"));//true
console.log(dictionary.remove("height"));//true
console.log(dictionary.get("name"));//zpl
console.log(dictionary.size());//2
// dictionary.clear();
console.log(dictionary.keys());// ["name", "age"]
console.log(dictionary.values());// ["zpl", 22]

 

 

 

哈希表(Hash)

认识哈希表

哈希表通常是基于数组实现的

但是相对于数组,它存在更多优势:

  • 哈希表可以提供非常快速的插入-删除-查找操作
  • 无论多少数据,插入和删除值都只需要非常短的时间,即O(1)的时间级。实际上,只需要几个机器指令即可完成;
  • 哈希表的速度比树还要快,基本可以瞬间查找到想要的元素。但是相对于树来说编码要简单得多。

哈希表同样存在不足之处:

  • 哈希表中的数据是没有顺序的,所以不能以一种固定的方式(比如从小到大 )来遍历其中的元素。
  • 通常情况下,哈希表中的key是不允许重复的,不能放置相同的key,用于保存不同的元素。

哈希表是什么?

  • 哈希表并不好理解,不像数组、链表和树等可通过图形的形式表示其结构和原理。
  • 哈希表的结构就是数组,但它神奇之处在于对下标值的一种变换,这种变换我们可以称之为哈希函数,通过哈希函数可以获取HashCode

哈希表的一些概念:

  • 哈希化:将大数字转化成数组范围内下标的过程,称之为哈希化;
  • 哈希函数:我们通常会将单词转化成大数字,把大数字进行哈希化的代码实现放在一个函数中,该函数就称为哈希函数;
  • 哈希表:对最终数据插入的数组进行整个结构的封装,得到的就是哈希表。

仍然需要解决的问题:

  • 哈希化过后的下标依然可能重复,如何解决这个问题呢?这种情况称为冲突,冲突是不可避免的,我们只能解决冲突。

解决冲突的方法

解决冲突常见的两种方案:

  • 方案一:链地址法(拉链法);

如下图所示,我们将每一个数字都对10进行取余操作,则余数的范围0~9作为数组的下标值。并且,数组每一个下标值对应的位置存储的不再是一个数字了,而是存储由经过取余操作后得到相同余数的数字组成的数组或链表

image-20200229002550499

这样可以根据下标值获取到整个数组或链表,之后继续在数组或链表中查找就可以了。而且,产生冲突的元素一般不会太多。

总结:链地址法解决冲突的办法是每个数组单元中存储的不再是单个数据,而是一条链条,这条链条常使用的数据结构为数组或链表,两种数据结构查找的效率相当(因为链条的元素一般不会太多)。

  • 方案二:开放地址法;

开放地址法的主要工作方式是寻找空白的单元格来放置冲突的数据项

image-20200229083553133

根据探测空白单元格位置方式的不同,可分为三种方法:

  • 线性探测
  • 二次探测
  • 再哈希法

寻找空白单元格的方式

  1. 线性探测

    当插入13时:

    • 经过哈希化(对10取余)之后得到的下标值index=3,但是该位置已经放置了数据33。而线性探测就是从index位置+1开始向后一个一个来查找合适的位置来放置13,所谓合适的位置指的是空的位置,如上图中index=4的位置就是合适的位置。

    当查询13时:

    • 首先13经过哈希化得到index=3,如果index=3的位置存放的数据与需要查询的数据13相同,就直接返回;
    • 不相同时,则线性查找,从index+1位置开始一个一个位置地查找数据13;
    • 查询过程中不会遍历整个哈希表,只要查询到空位置,就停止,因为插入13时不会跳过空位置去插入其他位置。

    当删除13时:

    • 删除操作和上述两种情况类似,但需要注意的是,删除一个数据项时,不能将该位置下标的内容设置为null,否则会影响到之后其他的查询操作,因为一遇到为null的位置就会停止查找。
    • 通常删除一个位置的数据项时,我们可以将它进行特殊处理(比如设置为-1),这样在查找时遇到-1就知道要继续查找。

    线性探测存在的问题:

    • 线性探测存在一个比较严重的问题,就是聚集;

    • 如哈希表中还没插入任何元素时,插入23、24、25、26、27,这就意味着下标值为3、4、5、6、7的位置都放置了数据,这种一连串填充单元就称为聚集;

    • 聚集会影响哈希表的性能,无论是插入/查询/删除都会影响;

    • 比如插入13时就会发现,连续的单元3~7都不允许插入数据,并且在插入的过程中需要经历多次这种情况。二次探测法可以解决该问题。

  2. 二次探测

    上文所说的线性探测存在的问题:

    • 如果之前的数据是连续插入的,那么新插入的一个数据可能需要探测很长的距离;

      二次探测是在线性探测的基础上进行了优化:

    • 线性探测:我们可以看成是步长为1的探测,比如从下表值x开始,那么线性探测就是按照下标值:x+1、x+2、x+3等依次探测;

    • 二次探测:对步长进行了优化,比如从下标值x开始探测:x+1^2^、x+2^2^、x+3^3^ 。这样一次性探测比较长的距离,避免了数据聚集带来的影响。

    • 二次探测存在的问题:

    1. 当插入数据分布性较大的一组数据时,比如:13-163-63-3-213,这种情况会造成步长不一的一种聚集(虽然这种情况出现的概率较线性探测的聚集要小),同样会影响性能。
  3. 再哈希化

    在开放地址法中寻找空白单元格的最好的解决方式为再哈希化:

    • 二次探测的步长是固定的:1,4,9,16依次类推;
    • 现在需要一种方法:产生一种依赖关键字(数据)的探测序列,而不是每个关键字探测步长都一样;
    • 这样,不同的关键字即使映射到相同的数组下标,也可以使用不同的探测序列;
    • 再哈希法的做法为:把关键字用另一个哈希函数,再做一次哈希化,用这次哈希化的结果作为该关键字的步长;

    第二次哈希化需要满足以下两点:

    • 和第一个哈希函数不同,不然哈希化后的结果仍是原来位置;
    • 不能输出为0,否则每次探测都是原地踏步的死循环;

    优秀的哈希函数:

    • stepSize = constant - (key % constant);
    • 其中constant是质数,且小于数组的容量;
    • 例如:stepSize = 5 - (key % 5),满足需求,并且结果不可能为0;

    哈希化的效率

    哈希表中执行插入和搜索操作效率是非常高的。

    • 如果没有发生冲突,那么效率就会更高;
    • 如果发生冲突,存取时间就依赖后来的探测长度;
    • 平均探测长度以及平均存取时间,取决于填装因子,随着填装因子变大,探测长度会越来越长。

    理解概念装填因子

    • 装填因子表示当前哈希表中已经包含的数据项和整个哈希表长度的比值;
    • 装填因子 = 总数据项 / 哈希表长度;
    • 开放地址法的装填因子最大为1,因为只有空白的单元才能放入元素
    • 链地址法的装填因子可以大于1,因为只要愿意,拉链法可以无限延伸下去

    优秀的哈希函数

    哈希表的优势在于它的速度,所以哈希函数不能采用消耗性能较高的复杂算法。提高速度的一个方法是在哈希函数中尽量减少乘法和除法。

    性能高的哈希函数应具备以下两个优点:

    • 快速的计算;
    • 均匀的分布
    1. 快速计算

      霍纳法则:在中国霍纳法则也叫做秦久韶算法,具体算法为:

      image-20200229105546884

      求多项式的值时,首先计算最内层括号内一次多项式的值,然后由内向外逐层计算一次多项式的值。这种算法把求n次多项式f(x)的值就转化为求n个一次多项式的值。

      变换之前:

      • 乘法次数:n(n+1)/2次;
      • 加法次数:n次;

      变换之后:

      • 乘法次数:n次;
      • 加法次数:n次;

      如果使用大O表示时间复杂度的话,直接从变换前的O(N^2^)降到了O(N)。

    • /* 
        实现hash函数
      */
      function hashFunc(str, max) {
        // 1. 定义hashCode
        let hashCode = 0;
        // 2. 霍纳算法
        for(let i = 0; i < str.length; i++) {
          // str.charCodeAt(i)//获取某个字符对应的unicode编码
          hashCode = 31*hashCode + str.charCodeAt(i);
        }
        // 3. 取余操作
        hashCode = hashCode % max;
        return hashCode;
      }
    1.  均匀分布

      为了保证数据在哈希表中均匀分布,当我们需要使用常量的地方,尽量使用质数;比如:哈希表的长度、N次幂的底数等。

      Java中的HashMap采用的是链地址法,哈希化采用的是公式为:index = HashCode(key)&(Length-1)

      即将数据化为二进制进行与运算,而不是取余运算。这样计算机直接运算二进制数据,效率更高。但是JavaScript在进行叫大数据的与运算时会出现问题,所以以下使用JavaScript实现哈希化时还是采用取余运算。

哈希表的常见操作为:

  • put(key,value):插入或修改操作;
  • get(key):获取哈希表中特定位置的元素;
  • remove(key):删除哈希表中特定位置的元素;
  • isEmpty():如果哈希表中不包含任何元素,返回trun,如果哈希表长度大于0则返回false;
  • size():返回哈希表包含的元素个数;
  • resize(value):对哈希表进行扩容操作;

哈希表的扩容

扩容与压缩

为什么需要扩容?

  • 前面我们在哈希表中使用的是长度为7的数组,由于使用的是链地址法,装填因子(loadFactor)可以大于1,所以这个哈希表可以无限制地插入新数据。
  • 但是,随着数据量的增多,storage中每一个index对应的bucket数组(链表)就会越来越长,这就会造成哈希表效率的降低

什么情况下需要扩容?

  • 常见的情况是loadFactor > 0.75的时候进行扩容;

如何进行扩容?

  • 简单的扩容可以直接扩大两倍(关于质数,之后讨论);
  • 扩容之后所有的数据项都要进行同步修改;

实现思路:

  • 首先,定义一个变量,比如oldStorage指向原来的storage;
  • 然后,创建一个新的容量更大的数组,让this.storage指向它;
  • 最后,将oldStorage中的每一个bucket中的每一个数据取出来依次添加到this.storage指向的新数组中;

哈希表的resize方法,既可以实现哈希表的扩容,也可以实现哈希表容量的压缩。

装填因子 = 哈希表中数据 / 哈希表长度,即 loadFactor = count / HashTable.length。

  • 通常情况下当装填因子laodFactor > 0.75时,对哈希表进行扩容。
  •    //判断是否需要扩容操作
          if(this.count > this.limit * 0.75){
            this.resize(this.limit * 2)
          }
  • 当装填因子laodFactor < 0.25时,对哈希表容量进行压缩。
  •    //缩小容量
        if (this.limit > 7 && this.count < this.limit * 0.25) {
          this.resize(Math.floor(this.limit / 2))
        }

质数的判断

/* 
  判断是否是质数
*/
// export function isPrime(num) {
//   for(let i = 2; i < num; i++) {
//     if(num % i === 0) {
//       return false;
//     }
//   }
//   return true;
// }

// 更高效率判断质数
export function isPrime(num) {
  // 1. 获取平分跟
  let temp = Math.ceil(Math.sqrt(num));
  // 2. 循环判断
  for(let i = 2; i < temp; i++) {
    if(num % i === 0) {
      return false;
    }
  }
  return true;
}

 /* 
    获取质数
  */ 
function getPrime(num) {
    while(!isPrime(num)) {
      num++;
    }
    return num;
  }

 

封装哈希表

HashTable.js
/* 
  实现hash函数
*/
// export function hashFunc(str, max) {
//   // 1. 定义hashCode
//   let hashCode = 0;
//   // 2. 霍纳算法
//   for(let i = 0; i < str.length; i++) {
//     // str.charCodeAt(i)//获取某个字符对应的unicode编码
//     hashCode = 31*hashCode + str.charCodeAt(i);
//   }
//   // 3. 取余操作
//   hashCode = hashCode % max;
//   return hashCode;
// }

/* 
  判断是否是质数
*/
// export function isPrime(num) {
//   for(let i = 2; i < num; i++) {
//     if(num % i === 0) {
//       return false;
//     }
//   }
//   return true;
// }

// 更高效率判断质数
// export function isPrime(num) {
//   // 1. 获取平分跟
//   let temp = Math.ceil(Math.sqrt(num));
//   // 2. 循环判断
//   for(let i = 2; i < temp; i++) {
//     if(num % i === 0) {
//       return false;
//     }
//   }
//   return true;
// }

const MAX_LOAD_FACTOR = 0.75;
const MIN_LOAD_FACTOR = 0.25;
/* 
  hash实现类
*/
export class HashTable {
  constructor() {
    this.storage = [];//数组存储元素
    this.count = 0;//当前存储的元素个数
    this.limit = 8; //总个数
  }
  
  /* 
    实现hash函数
  */
  hashFunc(str, max) {
    // 1. 定义hashCode
    let hashCode = 0;
    // 2. 霍纳算法
    for(let i = 0; i < str.length; i++) {
     // str.charCodeAt(i)//获取某个字符对应的unicode编码
      hashCode = 31*hashCode + str.charCodeAt(i);
    }
    // 3. 取余操作
    hashCode = hashCode % max;
    return hashCode;
  }
  
  /* 
    判断质数
  */
  isPrime(num) {
    // 1. 获取平分跟
    let temp = Math.ceil(Math.sqrt(num));
    // 2. 循环判断
    for(let i = 2; i < temp; i++) {
      if(num % i === 0) {
        return false;
      }
    }
    return true;
  }

  /* 
    获取质数
  */
  getPrime(num) {
    while(!isPrime(num)) {
      num++;
    }
    return num;
  }

  // put(key,value):插入或修改操作;
  put(key, value) {
    // 1. 根据key映射到index
    const index = this.hashFunc(key, this.limit);
    // 2. 取数数组
    let bucket = this.storage[index];
    if(bucket === undefined) {
      bucket = [];
      this.storage[index] = bucket;
    }
    // 3. 判断是修改还是插入操作
    let overide = false;
    for(let i = 0; i < bucket.length; i++) {
      // 生成的下标值相同的话,生成一个数组存储
      let tuple = bucket[i];
      if(tuple[0] === key) {//tuple[0]存储key,tuple[1]存储value
        tuple[1] = value;
        overide = true;
      }
    }
    // 4. 如果没有覆盖,那么就是新增
    if(!overide) {
      // 扩容
      if(this.count > this.limit * MAX_LOAD_FACTOR) {
          let newLimit = this.limit * 2;
          newLimit = this.getPrime(newLimit);
          this.resize(newLimit)
      }

      bucket.push([key, value]);
      this.count += 1;
      
    }
  }
  
  // get(key):获取哈希表中特定位置的元素;
  get(key) {
    // 1. 根据key获取index
    const index = this.hashFunc(key, this.limit);
    // 2.获取 bucket
    const bucket = this.storage[index];

    if(bucket === undefined) {
      return null;
    }
    // 3. 遍历bucket ,一个个查找
    for(let i = 0; i < bucket.length; i++) {
      let tuple = bucket[i];
      if(tuple[0] === key) {//tuple[0]存储key,tuple[1]存储value
        return tuple[1];
      }
    }
    // 4. 没有找到
    return null;
  }
  
  // remove(key):删除哈希表中特定位置的元素;
  remove(key) {
    // 1. 根据key获取index
    const index = this.hashFunc(key, this.limit);
    // 2.获取 bucket
    const bucket = this.storage[index];
    if(bucket === undefined) {
      return null;
    }
    // 3. 遍历bucket ,一个个查找
    for(let i = 0; i < bucket.length; i++) {
      let tuple = bucket[i];
      if(tuple[0] === key) {
        bucket.splice(i,1);
        this.count -- ;
        // 缩容
        if(this.limit > 8 && this.count < this.limit * MIN_LOAD_FACTOR) {
          let newLimit = this.limit / 2;
          newLimit = this.getPrime(newLimit);
          this.resize(newLimit)
        }
        return tuple[1];
      }
    }
    return null;
  }
  
  // isEmpty():如果哈希表中不包含任何元素,返回trun,如果哈希表长度大于0则返回false;
  isEmpty() {
    return this.count === 0;
  }
  
  // size():返回哈希表包含的元素个数;
  size() {
    return this.count;
  }
  
  // resize(value):对哈希表进行扩容操作;
  resize(value) {
    // 1. 保存就数组中的内容
    let oldStorage = this.storage;
    // 2. 重置属性
    this.limit = newLimit;
    this.storage = [];
    this.count = 0;
    // 3. 取出所有的元素,重新放入到storage
    oldStorage.forEach(bucket => {
      if(bucket === null) {
        return;
      }
      for(let i = 0; i < bucket.length; i++) {
        let tuple = bucket[i];
        this.put(tuple[0],tuple[1])
      }
    })

  }
}

index.js
import {HashTable} from "./hash_table" const hashTable = new HashTable(); hashTable.put("name","why"); hashTable.put("age","18"); hashTable.put("height","1.88"); hashTable.put("age","22"); console.log(hashTable.storage); console.log(hashTable.get("age"));//22 console.log(hashTable.remove("age"));//22 console.log(hashTable.isEmpty());//false console.log(hashTable.size());//2

 

 

 

树(TREE)

树的特点:

  • 树一般都有一个根,连接着根的是树干
  • 树干会发生分叉,形成许多树枝,树枝会继续分化成更小的树枝;
  • 树枝的最后是叶子

树结构对比于数组/链表/哈希表有哪些优势呢:

数组:

  • 优点:可以通过下标值访问,效率高
  • 缺点:查找数据时需要先对数据进行排序,生成有序数组,才能提高查找效率;并且在插入和删除元素时,需要大量的位移操作;

链表:

  • 优点:数据的插入和删除操作效率都很高
  • 缺点:查找效率低,需要从头开始依次查找,直到找到目标数据为止;当需要在链表中间位置插入或删除数据时,插入或删除的效率都不高。

哈希表:

  • 优点:哈希表的插入/查询/删除效率都非常高;
  • 缺点:空间利用率不高,底层使用的数组中很多单元没有被利用;并且哈希表中的元素是无序的,不能按照固定顺序遍历哈希表中的元素;而且不能快速找出哈希表中最大值或最小值这些特殊值。

树结构:

优点:树结构综合了上述三种结构的优点,同时也弥补了它们存在的缺点(虽然效率不一定都比它们高),比如树结构中数据都是有序的,查找效率高;空间利用率高;并且可以快速获取最大值和最小值等。

总的来说:每种数据结构都有自己特定的应用场景

树结构:

  • 树(Tree):由 n(n ≥ 0)个节点构成的有限集合。当 n = 0 时,称为空树

对于任一棵非空树(n > 0),它具备以下性质:

  • 数中有一个称为根(Root)的特殊节点,用 **r **表示;
  • 其余节点可分为 m(m > 0)个互不相交的有限集合 T~1~,T~2~,...,T~m~,其中每个集合本身又是一棵树,称为原来树的子树(SubTree)。

树的常用术语:

  • 节点的度(Degree):节点的子树个数,比如节点B的度为2;
  • 树的度:树的所有节点中最大的度数,如上图树的度为2;
  • 叶节点(Leaf):度为0的节点(也称为叶子节点),如上图的H,I等;
  • 父节点(Parent):度不为0的节点称为父节点,如上图节点B是节点D和E的父节点;
  • 子节点(Child):若B是D的父节点,那么D就是B的子节点;
  • 兄弟节点(Sibling):具有同一父节点的各节点彼此是兄弟节点,比如上图的B和C,D和E互为兄弟节点;
  • 路径和路径长度:路径指的是一个节点到另一节点的通道路径所包含边的个数称为路径长度,比如A->H的路径长度为3;
  • 节点的层次(Level):规定根节点在1层,其他任一节点的层数是其父节点的层数加1。如B和C节点的层次为2;
  • 树的深度(Depth):树种所有节点中的最大层次这棵树的深度,如上图树的深度为4;

 

二叉树

二叉树简介

二叉树的概念:如果树中的每一个节点最多只能由两个子节点,这样的树就称为二叉树;

二叉树十分重要,不仅仅是因为简单,更是因为几乎所有的树都可以表示成二叉树形式。

二叉树的组成:

  • 二叉树可以为空,也就是没有节点;
  • 若二叉树不为空,则它由根节点和称为其左子树TL和右子树TR的两个不相交的二叉树组成;

二叉树的五种形态:

image-20200301001718079

 上图分别表示:空的二叉树、只有一个节点的二叉树、只有左子树TL的二叉树、只有右子树TR的二叉树和有左右两个子树的二叉树。

二叉树的特性:

  • 一个二叉树的第 i 层最大节点树为:2^(i-1),i >= 1;
  • 深度为k的二叉树的最大节点总数为:2^k - 1 ,k >= 1;
  • 对任何非空二叉树,若 n~0~ 表示叶子节点的个数,n~2~表示度为2的非叶子节点个数,那么两者满足关系:n~0~ = n~2~ + 1;如下图所示:H,E,I,J,G为叶子节点,总数为5;A,B,C,F为度为2的非叶子节点,总数为4;满足n~0~ = n~2~ + 1的规律。

image-20200301092140211 

特殊的二叉树

完美二叉树

完美二叉树(Perfect Binary Tree)也成为满二叉树(Full Binary Tree),在二叉树中,除了最下一层的叶子节点外,每层节点都有2个子节点,这就构成了完美二叉树。

image-20200301093237681 

完全二叉树

完全二叉树(Complete Binary Tree):

  • 除了二叉树最后一层外,其他各层的节点数都达到了最大值
  • 并且,最后一层的叶子节点从左向右是连续存在,只缺失右侧若干叶子节点
  • 完美二叉树是特殊的完全二叉树;

image-20200301093659373

在上图中,由于H缺失了右子节点,所以它不是完全二叉树

二叉树的数据存储

常见的二叉树存储方式为数组和链表

使用数组:

  • 完全二叉树:按从上到下,从左到右的方式存储数据。

image-20200301094919588

节点ABCDEFGH
序号 1 2 3 4 5 6 7 8

使用数组存储时,取数据的时候也十分方便:左子节点的序号等于父节点序号 * 2右子节点的序号等于父节点序号 * 2 + 1 。

  • 非完全二叉树:非完全二叉树需要转换成完全二叉树才能按照上面的方案存储,这样会浪费很大的存储空间。

image-20200301100043636 

节点ABC^^F^^^^^^M
序号 1 2 3 4 5 6 7 8 9 10 11 12 13

使用链表

二叉树最常见的存储方式为链表:每一个节点封装成一个Node,Node中包含存储的数据、左节点的引用和右节点的引用。

image-20200301100616105

二叉搜索树

  1. 认识二叉搜索树

      二叉搜索树(BST,Binary Search Tree),也称为二叉排序树二叉查找树。

      二叉搜索树是一棵二叉树,可以为空;

  如果不为空,则满足以下性质:

    •   条件1:非空左子树的所有键值小于其根节点的键值。比如三中节点6的所有非空左子树的键值都小于6;
    •   条件2:非空右子树的所有键值大于其根节点的键值;比如三中节点6的所有非空右子树的键值都大于6;
    •   条件3:左、右子树本身也都是二叉搜索树

image-20200301103139916 

如上图所示,树二和树三符合3个条件属于二叉树,树一不满足条件3所以不是二叉树。

总结:二叉搜索树的特点主要是较小的值总是保存在左节点上,相对较大的值总是保存在右节点上。这种特点使得二叉搜索树的查询效率非常高,这也就是二叉搜索树中"搜索"的来源。

  1. 二叉搜索树应用举例

  下面是一个二叉搜索树:

  image-20200301111718686 

若想在其中查找数据10,只需要查找4次,查找效率非常高。

  • 第1次:将10与根节点9进行比较,由于10 > 9,所以10下一步与根节点9的右子节点13比较;
  • 第2次:由于10 < 13,所以10下一步与父节点13的左子节点11比较;
  • 第3次:由于10 < 11,所以10下一步与父节点11的左子节点10比较;
  • 第4次:由于10 = 10,最终查找到数据10 。

image-20200301111751041

同样是15个数据,在排序好的数组中查询数据10,需要查询10次:

 

image-20200301115348138

其实:如果是排序好的数组,可以通过二分查找:第一次找9,第二次找13,第三次找15...。我们发现如果把每次二分的数据拿出来以树的形式表示的话就是二叉搜索树。这就是数组二分法查找效率之所以高的原因。

二叉搜索树的封装

二叉树搜索树的基本属性:

如图所示:二叉搜索树有四个最基本的属性:指向节点的根(root),节点中的键(key)、左指针(right)、右指针(right)。

 

 

 

二叉搜索树的常见操作:

  • insert(key):向树中插入一个新的键;
  • search(key):在树中查找一个键,如果节点存在,则返回true;如果不存在,则返回false;
  • inOrderTraverse:通过中序遍历方式遍历所有节点;
  • preOrderTraverse:通过先序遍历方式遍历所有节点;
  • postOrderTraverse:通过后序遍历方式遍历所有节点;
  • min:返回树中最小的值/键;
  • max:返回树中最大的值/键;
  • remove(key):从树中移除某个键;

遍历数据

  1. 先序遍历;
    • 首先,遍历根节点;
    • 然后,遍历其左子树;
    • 最后,遍历其右子树;
    •  

       11->7->5->3->6->9->8->10->15->13->12->14->20->18->25

  2. 中序遍历;(从小到大排序)
    • 首先,遍历其左子树;
    • 然后,遍历根(父)节点;
    • 最后,遍历其右子树
    •  

       3 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10 -> 11 -> 12 -> 13 -> 14 -> 15 -> 18 -> 20 -> 25 。

  1. 后序遍历
    • 首先,遍历其左子树;
    • 然后,遍历其右子树;
    • 最后,遍历根(父)节点
    • 3 -> 6 -> 5 -> 8 -> 10 -> 9 -> 7 -> 12 -> 14 -> 13 -> 18 -> 25 -> 20 -> 15 -> 11 。

删除节点

情况三:删除节点下面有左右两个子节点

规律总结:如果要删除的节点有两个子节点,甚至子节点还有子节点,这种情况下需要从要删除节点下面的子节点中找到一个合适的节点,来替换当前的节点。

若用current表示需要删除的节点,则合适的节点指的是:

  • current左子树中比current小一点点的节点,即current左子树中的最大值
  • current右子树中比current大一点点的节点,即current右子树中的最小值

前驱&后继

在二叉搜索树中,这两个特殊的节点有特殊的名字:

  • 比current小一点点的节点,称为current节点的前驱。比如下图中的节点5就是节点7的前驱;
  • 比current大一点点的节点,称为current节点的后继。比如下图中的节点8就是节点7的后继;

image-20200302210841453

 

代码实现:

  • 查找需要被删除的节点current的后继时,需要在current的右子树中查找最小值,即在current的右子树中一直向左遍历查找

  • 查找前驱时,则需要在current的左子树中查找最大值,即在current的左子树中一直向右遍历查找

封装二叉搜索树

TREE.JS

class Node {
  constructor(key) {
    this.key = key;
    this.left = null;
    this.right = null;
  }
}


export class BinarySearchTree {
  constructor() {
    this.root = null;
  }

  // insert(key):向树中插入一个新的键;
  insert(key) {
    // 1. 创建node节点
    const newNode = new Node(key);
    // 2. 如果原来的树是空树
    if(this.root === null) {
      this.root = newNode;
    }else {
      this.insertNode(this.root, newNode);
    }
  }

  //插入节点
  insertNode(node, newNode) {
    if(newNode.key > node.key) {
      if(node.right === null) {
        node.right = newNode;
      }else {
        this.insertNode(node.right, newNode);
      }
    }else {
      if(node.left === null) {
        node.left = newNode;
      }else {
        this.insertNode(node.left, newNode);
      }
    }
  }
  
  // search(key):在树中查找一个键,如果节点存在,则返回true;如果不存在,则返回false;
  search(key) {
    return this.searchNode(this.root, key);
  }
  searchNode(node, key) {
    // 1. 判断node是否为空
    if(node === null) return false;
    // 2. 判断节点的key和传入的key的值大小
    if(key < node.key) {
      return this.searchNode(node.left, key);
    }else if(key > node.key) {
      return this.searchNode(node.right, key);
    }else {
      return true;
    }
  }

  // 利用循环实现搜索
  search2(key) {
    let node = this.root;
    if(node === null) return false;

    while(node !== null) {
      if(node.key > key) {
        node = node.left;
      }else if(node.key < key) {
        node = node.right;
      }else {
        return true
      }
    }
    return false;
  }
  
  // inOrderTraverse():通过中序遍历方式遍历所有节点;
  inOrderTraverse() {
    this.inOrderTraverseNode(this.root)
  }
  inOrderTraverseNode(node) {
    if(node === null) return;
    // 先左
    this.inOrderTraverseNode(node.left);
    //
    console.log(node.key);//直接访问
    //
    this.inOrderTraverseNode(node.right);
  }

  // preOrderTraverse():通过先序遍历方式遍历所有节点;
  preOrderTraverse() {
    this.preOrderTraverseNode(this.root);
  }
  preOrderTraverseNode(node) {
    if(node === null) return;
    //
    console.log(node.key);//直接访问
    //先左
    this.preOrderTraverseNode(node.left);
    // 再右
    this.preOrderTraverseNode(node.right);
  }
  
  // postOrderTraverse:通过后序遍历方式遍历所有节点;
  postOrderTraverse() {
    this.postOrderTraverseNode(this.root);
  }
  postOrderTraverseNode(node) {
    if(node === null) return;
    //
    this.postOrderTraverseNode(node.left);
    //
    this.postOrderTraverseNode(node.right);
    //
    console.log(node.key);
  }
  
  // min():返回树中最小的值/键;
  min() {
    let node = this.root;

    while(node.left != null) {
      node = node.left;
    }
    return node.key;
  }
  
  // max:返回树中最大的值/键;
  max() {
    let node = this.root;
    
    while(node.right != null) {
      node = node.right;
    }
    return node.key;
  }
  
  // remove(key):从树中移除某个键;
  remove(key) {
    // 1. 找到要删除的节点
    let current = this.root;
    let parent = null;
    let isLestChild = true;
    
    // 2. 开始查找
    while(current.key !== key) {
      parent = current;
      if(key < current.key) {
        isLestChild = true;
        current = current.left;
      }else {
        isLestChild =false;
        current = current.right;
      }

      if(current === null) return false;
    }
    // 3. 找到要删除的节点
    // 情况一 删除的节点是叶子节点
    if(current.left === null && current.right === null) {
      if(current === this.root) {
        this.root = null;
      }else if(isLestChild) {
        parent.left = null;
      }else {
        parent.right = null
      }
    }
    // 情况二 删除的节点只有一个节点
    else if(current.right === null) {//只有左子节点
      if(current == this.root) {
        this.root = current.left;
      }else if(isLestChild) {
        parent.left = current.left;
      }else {
        parent.right = current.left;
      }
    }else if (current.left === null) {//只有右子节点
      if(current == this.root) {
        this.root = current.right;
      }else if(isLestChild) {
        parent.left = current.right;
      }else {
        parent.right = current.right;
      }
    }
    // 情况三 删除节点有两个子节点
    else {
      // 1. 获取后继节点
      let successor = this.getSuccessor(current);
      // 2. 判断是否为根节点
      if(this.root === current) {
        this.root = successor;
      }else if(isLestChild) {
        parent.left = successor;
      }else {
        parent.right = successor;
      }
      // 将后继的左子节点改为被删除节点的左子节点
      successor.left = current.left;
    }
    return true;
  }

  // 寻找后继
  getSuccessor(delNode) {
    // 1. 定义变量,来存储临时节点
    let successorParent = delNode;
    let successor = delNode.right;
    let current = delNode.right;
    // 2. 寻找节点
    while(current != null) {
      successorParent = successor;
      successor = current;
      current = current.left;
    }
    // 3. 判断寻找的的后继节点successor不是直接删除节点的right节点
    if(successor != delNode.right) {
      successorParent.left = successor.right;
    //设置右节点 successor.right
= delNode.right; } return successor; } }
import {BinarySearchTree} from "./tree"

const bsTree = new BinarySearchTree();

bsTree.insert(11);
bsTree.insert(15);
bsTree.insert(13);
bsTree.insert(20);
bsTree.insert(7);
bsTree.insert(5);
bsTree.insert(3);
bsTree.insert(9);
bsTree.insert(6);
bsTree.insert(8);
bsTree.insert(10);
bsTree.insert(12);
bsTree.insert(14);
bsTree.insert(18);
bsTree.insert(25);

console.log(bsTree);
// 先序遍历
console.log("先序遍历");
bsTree.preOrderTraverse();
// 中序遍历 (从小到大)
console.log("中序遍历");
bsTree.inOrderTraverse();
// 后序遍历 
console.log("后序遍历");
bsTree.postOrderTraverse();
console.log("最小值");
console.log(bsTree.min());//3
console.log("最大值");
console.log(bsTree.max());//25
console.log(bsTree.search(2));//false
console.log(bsTree.search(20));//true
console.log(bsTree.remove(9));//true
//  中序遍历 ,从小到大即为删除成功
bsTree.inOrderTraverse();
 

 


 


 

平衡树

二叉搜索树的缺陷:

当插入的数据是有序的数据,就会造成二叉搜索树的深度过大。比如原二叉搜索树右 11 7 15 组成,如下图所示:

image-20200302231503639

当插入一组有序数据:6 5 4 3 2就会变成深度过大的搜索二叉树,会严重影响二叉搜索树的性能。

image-20200302231745864 

非平衡树

  • 比较好的二叉搜索树,它的数据应该是左右均匀分布的;
  • 但是插入连续数据后,二叉搜索树中的数据分布就变得不均匀了,我们称这种树为非平衡树;
  • 对于一棵平衡二叉树来说,插入/查找等操作的效率是O(logN)
  • 而对于一棵非平衡二叉树来说,相当于编写了一个链表,查找效率变成了O(N);

树的平衡性

为了能以较快的时间O(logN)来操作一棵树,我们需要保证树总是平衡的:

  • 起码大部分是平衡的,此时的时间复杂度也是接近O(logN)的;
  • 这就要求树中每个节点左边的子孙节点的个数,应该尽可能地等于右边的子孙节点的个数;

常见的平衡树

  • AVL树:是最早的一种平衡树,它通过在每个节点多存储一个额外的数据来保持树的平衡。由于AVL树是平衡树,所以它的时间复杂度也是O(logN)。但是它的整体效率不如红黑树,开发中比较少用。
  • 红黑树:同样通过一些特性来保持树的平衡,时间复杂度也是O(logN)。进行插入/删除等操作时,性能优于AVL树,所以平衡树的应用基本都是红黑树。

红黑树

红黑树的五条规则

红黑树除了符合二叉搜索树的基本规则外,还添加了以下特性:

  • 规则1:节点是红色或黑色的
  • 规则2:根节点是黑色的
  • 规则3:每个叶子节点都是黑色的空节点(NIL节点)
  • 规则4:每个红色节点的两个子节点都是黑色的从每个叶子到根的所有路径上不可能有两个连续的红色节点);
  • 规则5:从任一节点到其每个叶子节点的所有路径都包含相同数目的黑色节点

image-20200303121529068

红黑树的相对平衡

前面5条规则的约束确保了以下红黑树的关键特性:

  • 从根到叶子节点的最长路径,不会超过最短路径的两倍
  • 结果就是这棵树基本是平衡的;
  • 虽然没有做到绝对的平衡,但是可以保证在最坏的情况下,该树依然是高效的;

为什么可以做到最长路径不超过最短路径的两倍呢?

  • 性质4决定了路径上不能有两个相连的红色节点;
  • 所以,最长路径一定是红色节点和黑色节点交替而成的;
  • 由于根节点和叶子节点都是黑色的,最短路径可能都是黑色节点,并且最长路径中一定是黑色节点多于红色节点;
  • 性质5决定了所有路径上都有相同数目的黑色节点;
  • 这就表明了没有路径能多于其他任何路径两倍长。

红黑树的三种变换

插入一个新节点时,有可能树不再平衡,可以通过三种方式的变换使树保持平衡:

  • 变色;
  • 左旋转;
  • 右旋转;

2.1.变色

为了重新符合红黑树的规则,需要把红色节点变为黑色,或者把黑色节点变为红色;

插入的新节点通常都是红色节点

  • 当插入的节点为红色的时候,大多数情况不违反红黑树的任何规则;

  • 而插入黑色节点,必然会导致一条路径上多了一个黑色节点,这是很难调整的;

  • 红色节点虽然可能导致红红相连的情况,但是这种情况可以通过颜色调换和旋转来调整;

2.2.左旋转

以节点X为根逆时针旋转二叉搜索树,使得父节点原来的位置被自己的右子节点替代,左子节点的位置被父节点替代;

image-20200303132706061 

详解:

如上图所示,左旋转之后:

  • 节点X取代了节点a原来的位置;
  • 节点Y取代了节点X原来的位置;
  • 节点X的左子树 a 仍然是节点X的左子树(这里X的左子树只有一个节点,有多个节点时同样适用,以下同理);
  • 节点Y的右子树 c 仍然是节点Y的右子树;
  • 节点Y的左子树 b 向左平移成为了节点X的右子树;

除此之外,二叉搜索树左旋转之后仍为二叉搜索树:

image-20200303132617108

 

2.3.右旋转

以节点X为根顺时针旋转二叉搜索树,使得父节点原来的位置被自己的左子节点替代,右子节点的位置被父节点替代;

image-20200303132529476

 

详解:

如上图所示,右旋转之后:

  • 节点X取代了节点a原来的位置;
  • 节点Y取代了节点X原来的位置;
  • 节点X的右子树 a 仍然是节点X的右子树(这里X的右子树虽然只有一个节点,但是多个节点时同样适用,以下同理);
  • 节点Y的左子树 b 仍然是节点Y的左子树;
  • 节点Y的右子树 c 向右平移成为了节点X的左子树;

除此之外,二叉搜索树右旋转之后仍为二叉搜索树:

image-20200303132501369

 

红黑树的插入操作

首先需要明确,在保证满足红黑树5条规则的情况下,新插入的节点必然是红色节点。

为了方便说明,规定以下四个节点:新插入节点为N(Node),N的父节点为P(Parent),P的兄弟节点为U(Uncle),U的父节点为G(Grandpa),如下图所示:

image-20200303120344016

 

3.1.情况1

当插入的新节点N位于树的根上时,没有父节点。

这种情况下,只需要将红色节点变为黑色节点即可满足规则2

image-20200303132357511

 

3.2.情况2

新节点N的父节点P为黑色节点,此时不需要任何变化。

此时既满足规则4也满足规则5。尽管新节点是红色的,但是新节点N有两个黑色节点NIL,所以通向它的路径上黑色节点的个数依然相等,因此满足规则5 。

image-20200303132304098

 

3.3.情况3

节点P为红色,节点U也为红色,此时节点G必为黑色,即父红叔红祖黑

在这种情况下需要:

  • 先将父节点P变为黑色;
  • 再将叔叔节点U变为黑色;
  • 最后将祖父节点G变为红色;

即变为父黑叔黑祖红,如下图所示:

image-20200303132148128

 

可能出现的问题:

  • N的祖父节点G的父节点也可能是红色,这就违反了规则4,此时可以通过递归调整节点颜色;
  • 当递归调整到根节点时就需要旋转了,如下图节点A和节点B所示,具体情况后面会介绍;

image-20200303132050765

 

3.4.情况4

节点P是红色节点,节点U是黑色节点,并且节点N为节点P的左子节点,此时节点G一定是黑色节点,即父红叔黑祖黑

在这种情况下需要:

  • 先变色:将父节点P变为黑色,将祖父节点G变为红色;
  • 后旋转:以祖父节点G为根进行右旋转;

image-20200303131956298

 

3.5.情况5

节点P是红色节点,节点U是黑色节点,并且节点N为节点P的右子节点,此时节点G一定是黑色节点,即父红叔黑祖黑

在这种情况下需要:

  • 先以节点P为根进行左旋转,旋转后如图b所示;
  • 随后将红色节点P和黑色节点B看成一个整体的红色节点N1,将新插入的红色节点N看成红色节点P1 如图c所示。此时整体就转换为了情况4。

image-20200303140225631

 

接着可以按照情况4进行处理:

  • 先变色:将N1节点的父节点P1变为黑色,将祖父节点G变为红色;

  • 后旋转:以祖父节点G为根进行右旋转,旋转后如图 e 所示;

  • 最后将节点N1和P1变换回来,完成节点N的插入,如图 f 所示;

image-20200303131736316

 

案例

在二叉树中依次插入节点:10,9,8,7,6,5,4,3,2,1 。

如果直接采用普通的二叉搜索树,节点全部插入后是这样的:

image-20200303161149709

 

是一个严重的不平衡树,相当于一个链表,不能体现出二叉搜索树的高效率。而按照红黑树的五条规则插入节点就能最大程度保证搜索二叉树是一棵平衡树。以下为过程详解:为了方便解释省略了部分红黑树的叶子节点(NIL)

插入10

符合情况1:

  • 插入节点10;
  • 将节点10的颜色变为黑色;

image-20200303161257010

插入9

符合情况2:

  • 不需要任何变化;

image-20200303161919072

插入8

快速判断属于情况3还是情况4的方法:

从新插入的节点N出发,按图示箭头经过的四个节点,若为红红黑红3个红色节点则为情况3,若为红红黑黑两个红色节点则为情况4;

image-20200303162613413

符合情况4:

  • 父节点9变成黑,祖父节点10变为红;
  • 以祖父节点为根进行右旋转;

image-20200303163803388

插入7

符合情况3:

  • 父节点8和叔节点10变为黑,祖父节点9变为红;
  • 此时会出现问题:不符合规则2,即根节点不为黑,此时可以把以9为根节点的二叉搜索树当作一个整体作为一个新插入的节点N,而此时又符合情况1,只需要把9变回黑色即可。

image-20200303165135028

插入6

符合情况4:

  • 父节点7变为黑,祖父节点8变为红;
  • 以祖父节点8为根进行右旋转;

image-20200306170016958

插入5

符合情况3:

  • 父节点6和叔节点8变为黑,祖父节点7变为红;

image-20200303170150314

插入4

符合情况4:

  • 父节点5变为黑,祖父节点6变为红;
  • 以祖父节点6为根进行右旋转;

image-20200303171028009

插入3

第一次变换:符合情况3:

  • 父节点4和叔节点6变为黑,祖父节点5变为红;

变换之后发现5和7为相连的两个红色节点,于是把以5为根的整个子树看成一个新插入的节点N1,再进行第二次变换。

image-20200303171755035

 

第二次变换:符合情况4:

  • 父节点7变为黑,祖父节点9变为红;
  • 以祖父节点9为根进行右旋转;

image-20200303172800302

 

最后复原N1得到变换后的红黑树:

image-20200303173158141

插入2

符合情况4:

  • 父节点3变为黑,祖父节点4变为红;
  • 以祖父节点4为根进行右旋转;

image-20200303174011409

插入1

第一次变换:符合情况3:

  • 父节点2和叔节点4变为黑,祖父节点3变为红;

变换之后发现3和5为相连的两个红色节点,于是把以3为根的整个子树看成一个新插入的节点N1,再进行第二次变换。

image-20200303180603741

第二次变换:符合情况3:

  • 父节点5和叔节点9变为黑,祖父节点7变为红;即由图 b -> 图 c 。

变换之后发现根节点7为红色不符合规则2,所以把以7为根节点的红黑树看成一个新插入的节点N2,再进行第三次变换。

第三次变换:符合情况1:

  • 直接将根节点7变为黑色即可。

image-20200303181133397

由此,完成了1~10节点的插入,虽然没有遇到情况5,不过情况5经过左旋转的操作便可转换为情况4,原理一样。如下图所示,将这棵红黑树的叶子节点NIL补全之后,经检验满足红黑树的五条规则,并且基本属于平衡树,效率较高。

image-20200303182326442

 

红黑树的删除操作

红黑树的删除操作结合了复杂的二叉树的删除操作和复杂的红黑树的插入规则,整体来说难度非常大,篇幅较长,这里暂不进行探讨。

图论

1.1.图的简介

什么是图?

  • 图结构是一种与树结构有些相似的数据结构;
  • 图论是数学的一个分支,并且,在数学中,树是图的一种
  • 图论以图为研究对象,研究顶点和边组成的图形的数学理论和方法;
  • 主要的研究目的为:事物之间的联系,顶点代表事物,边代表两个事物间的关系;

图的特点:

  • 一组顶点:通常用 V (Vertex)表示顶点的集合;
  • 一组:通常用 (Edge)表示边的集合;
    • 边是顶点和顶点之间的连线;
    • 边可以是有向的,也可以是无向的。比如A----B表示无向,A ---> B 表示有向;

图的常用术语:

  • 顶点(V):表示图中的一个节点;

  • 边(E:表示顶点和顶点给之间的连线;

  • 相邻顶点:由一条边连接在一起的顶点称为相邻顶点;

  • 度:一个顶点的度是相邻顶点的数量;

  • 路径:

    • 简单路径:简单路径要求不包含重复的顶点;
    • 回路:第一个顶点和最后一个顶点相同的路径称为回路;
  • 无向图:图中的所有边都是没有方向的;

  • 有向图:图中的所有边都是有方向的;

  • 无权图:无权图中的边没有任何权重意义;

  • 带权图:带权图中的边有一定的权重含义;

1.2.图的表示

邻接矩阵

表示图的常用方式为:邻接矩阵。

  • 可以使用二维数组来表示邻接矩阵;

  • 邻接矩阵让每个节点和一个整数相关联,该整数作为数组的下标值;

  • 使用一个二维数组来表示顶点之间的连接;

image-20200303213913574

如上图所示:

  • 二维数组中的0表示没有连线,1表示有连线
  • 如:A[ 0 ] [ 3 ] = 1,表示 A 和 C 之间有连接;
  • 邻接矩阵的对角线上的值都为0,表示A - A ,B - B,等自回路都没有连接(自己与自己之间没有连接);
  • 若为无向图,则邻接矩阵应为对角线上元素全为0的对称矩阵

邻接矩阵的问题:

  • 如果图是一个稀疏图,那么邻接矩阵中将存在大量的 0,造成存储空间的浪费;

邻接表

另外一种表示图的常用方式为:邻接表。

  • 邻接表由图中每个顶点以及和顶点相邻的顶点列表组成;
  • 这个列表可用多种方式存储,比如:数组/链表/字典(哈希表)等都可以;

image-20200303215312091

如上图所示:

  • 图中可清楚看到A与B、C、D相邻,假如要表示这些与A顶点相邻的顶点(边),可以通过将它们作为A的值(value)存入到对应的数组/链表/字典中。
  • 之后,通过键(key)A可以十分方便地取出对应的数据;

邻接表的问题:

  • 邻接表可以简单地得出出度,即某一顶点指向其他顶点的个数
  • 但是,邻接表计算入度指向某一顶点的其他顶点的个数称为该顶点的入度)十分困难。此时需要构造逆邻接表才能有效计算入度;

 图论实现

1. 图的结构

创建一个数组对象vertexes存储图的顶点;创建一个字典对象edges存储图的边,其中key为顶点,value为存储key顶点相邻顶点的数组。

2. 图的遍历

图的遍历思想:

  • 图的遍历思想与树的遍历思想一样,意味着需要将图中所有的顶点都访问一遍,并且不能有重复的访问(上面的toString方法会重复访问);

遍历图的两种算法:

  • 广度优先搜索(Breadth - First Search,简称BFS);
    • 基于队列, 入队列的顶点先被探索
  • 深度优先搜索(Depth - First Search,简称DFS);
    • 基于栈或使用递归,通过将顶点存入栈中,顶点是沿着路径被探索的,存在新的相邻顶点就去访问
  • 两种遍历算法都需要指定第一个被访问的顶点;

为了记录顶点是否被访问过,使用三种颜色来表示它们的状态

  • 白色:表示该顶点还没有被访问过
  • 灰色:表示该顶点被访问过,但其相邻顶点并未完全被访问过
  • 黑色:表示该顶点被访问过,且其所有相邻顶点都被访问过;

首先封装initializeColor方法将图中的所有顶点初始化为白色,代码实现如下:

// 初始化状态颜色
  Graph.prototype.initializeColor = function() {
    let colors = [];
    for(let i = 0; i < this.vertexes.length; i++) {
      colors[this.vertexes[i] = 'while'];
    }
    return colors;
  }

广度优先搜索

广度优先搜索算法的思路:

  • 广度优先搜索算法会从指定的第一个顶点开始遍历图,先访问其所有的相邻顶点,就像一次访问图的一层;
  • 也可以说是先宽后深地遍历图中的各个顶点

image-20200303233840691

 

实现思路:

基于队列可以简单地实现广度优先搜索算法:

  • 首先创建一个队列Q(尾部进,首部出)
  • 调用封装的initializeColor方法将所有顶点初始化为白色;
  • 指定第一个顶点A,将A标注为灰色(被访问过的节点),并将A放入队列Q中;
  • 循环遍历队列中的元素,只要队列Q非空,就执行以下操作:
    • 先将灰色的A从Q的首部取出;
    • 取出A后,将A的所有未被访问过(白色)的相邻顶点依次从队列Q的尾部加入队列,并变为灰色。以此保证,灰色的相邻顶点不重复加入队列;
    • A的全部相邻节点加入Q后,A变为黑色,在下一次循环中被移除Q外;

过程详解:

下为指定的第一个顶点为A时的遍历过程:

  • 如 a 图所示,将在字典edges中取出的与A相邻的且未被访问过的白色顶点B、C、D放入队列que中并变为灰色,随后将A变为黑色并移出队列;
  • 接着,如图 b 所示,将在字典edges中取出的与B相邻的且未被访问过的白色顶点E、F放入队列que中并变为灰色,随后将B变为黑色并移出队列;

image-20200306144336380

  • 如 c 图所示,将在字典edges中取出的与C相邻的且未被访问过的白色顶点G(A,D也相邻不过已变为灰色,所以不加入队列)放入队列que中并变为灰色,随后将C变为黑色并移出队列;
  • 接着,如图 d 所示,将在字典edges中取出的与D相邻的且未被访问过的白色顶点H放入队列que中并变为灰色,随后将D变为黑色并移出队列。

image-20200306144427242

如此循环直到队列中元素为0,即所有顶点都变黑并移出队列后才停止,此时图中顶点已被全部遍历。

 

深度优先搜索

广度优先算法的思路:

  • 深度优先搜索算法将会从指定的第一个顶点开始遍历图,沿着一条路径遍历直到该路径的最后一个顶点都被访问过为止;
  • 接着沿原来路径回退并探索下一条路径,即先深后宽地遍历图中的各个顶点;

image-20200304120355088

实现思路:

  • 可以使用栈结构来实现深度优先搜索算法;
  • 深度优先搜索算法的遍历顺序与二叉搜索树中的先序遍历较为相似,同样可以使用递归来实现(递归的本质就是函数栈的调用)。

基于递归实现深度优先搜索算法:定义dfs方法用于调用递归方法dfsVisit,定义dfsVisit方法用于递归访问图中的各个顶点。

在dfs方法中:

  • 首先,调用initializeColor方法将所有顶点初始化为白色;
  • 然后,调用dfsVisit方法遍历图的顶点;

在dfsVisit方法中:

  • 首先,将传入的指定节点v标注为灰色;
  • 接着,处理顶点V;
  • 然后,访问V的相邻顶点;
  • 最后,将顶点v标注为黑色;

过程详解:

这里主要解释一下代码中的第3步操作:访问指定顶点的相邻顶点。

  • 以指定顶点A为例,先从储存顶点及其对应相邻顶点的字典对象edges中取出由顶点A的相邻顶点组成的数组:

image-20200304155916036

 
  • 第一步:A顶点变为灰色,随后进入第一个for循环,遍历A白色的相邻顶点:B、C、D;在该for循环的第1次循环中(执行B),B顶点满足:colors == "white",触发递归,重新调用该方法;
  • 第二步:B顶点变为灰色,随后进入第二个for循环,遍历B白色的相邻顶点:E、F;在该for循环的第1次循环中(执行E),E顶点满足:colors == "white",触发递归,重新调用该方法;
  • 第三步:E顶点变为灰色,随后进入第三个for循环,遍历E白色的相邻顶点:I;在该for循环的第1次循环中(执行I),I顶点满足:colors == "white",触发递归,重新调用该方法;
  • 第四步:I顶点变为灰色,随后进入第四个for循环,由于顶点I的相邻顶点E不满足:colors == "white",停止递归调用。过程如下图所示:

image-20200304160536187

 
  • 第五步:递归结束后一路向上返回,首先回到第三个for循环中继续执行其中的第2、3...次循环,每次循环的执行过程与上面的同理,直到递归再次结束后,再返回到第二个for循环中继续执行其中的第2、3...次循环....以此类推直到将图的所有顶点访问完为止。

下图为遍历图中各顶点的完整过程:

  • 发现表示访问了该顶点,状态变为灰色;
  • 探索表示既访问了该顶点,也访问了该顶点的全部相邻顶点,状态变为黑色;
  • 由于在顶点变为灰色后就调用了处理函数handler,所以handler方法的输出顺序为发现顶点的顺序即:A、B、E、I、F、C、D、G、H 。

image-20200304154745646

 实现图论

import {Dictionary} from "../集合类/dictionary"
import { Queue } from "../queue/queue"
export function Graph() {
  // 属性
  this.vertexes = []; //顶点
  this.edgs = new Dictionary();//

  // 方法
  // 添加顶点的方法
  Graph.prototype.addVertex = function(v) {
    this.vertexes.push(v);
    this.edgs.set(v,[]);
  }

  // 添加边的方法
  Graph.prototype.addEdge = function(v1, v2) {
    this.edgs.get(v1).push(v2);
    this.edgs.get(v2).push(v1);
  }

  Graph.prototype.toString = function() {
     //1.定义字符串,保存最终结果
    let resultString = "";
    //2.遍历所有的顶点以及顶点对应的边
    for(let i = 0; i < this.vertexes.length; i++) {
      resultString += this.vertexes[i] + '->';
      let vEdges = this.edgs.get(this.vertexes[i]);
      for(let j = 0; j < vEdges.length; j++) {
        resultString += vEdges[j] + ' ';
      }
      resultString += '\n';
    }
    return resultString;
  }

  // 初始化状态颜色
  Graph.prototype.initializeColor = function() {
    let colors = [];
    for(let i = 0; i < this.vertexes.length; i++) {
      colors[this.vertexes[i]] = 'white';
    }
    return colors;
  }

  // 广度优先搜索BFS
  Graph.prototype.bfs = function (initV, handler) {
    // 1. 初始化颜色
    let colors = this.initializeColor();
    // 2. 创建队列
    let queue = new Queue();
    // 3. 将顶点加入队列
    queue.enqueue(initV);
    
    // 4. 循环从队列中取出元素
    while(!queue.isEmpty()) {
      // 4.1 从队列中取出一个顶点
      let v = queue.dequeue();
      // 4.2 获取和顶点相连的另外顶点
      let vList = this.edgs.get(v);
      // 4.3 将v的颜色设置为灰色
      colors[v] = 'gray';
      // 4.4 遍历所有的顶点,并且加入到队列中
      for (let i = 0; i < vList.length; i++) {
        // 取出相邻顶点
        const e = vList[i];
        //判断相邻顶点是否被探测过,被探测过则不加入队列中;并且加入队列后变为灰色,表示被探测过
        if (colors[e] == 'white') {
          colors[e] = 'gray';
          queue.enqueue(e);
          
        }
      }
      
      // 4.5 处理顶点
      handler(v);
      //4.6.顶点v所有白色的相邻顶点都加入队列后,将顶点v设置为黑色。此时黑色顶点v位于队列最前面,进入下一次while循环时会被取出
      colors[v] = 'black';
    }
  }

   // 深度优先搜索DFS
   Graph.prototype.dfs = function (initV, handler) {
    // 1. 初始化颜色
    let colors = this.initializeColor();
    // 2. 从某个顶点开始递归访问
    this.dfsVisit(initV, colors, handler);
  }

  Graph.prototype.dfsVisit = function(v, colors, handler) {
    // 1. 将颜色设置为灰色
    colors[v] = 'gray';
    // 2. 处理v顶点
    handler(v);
    // 3. 访问v相连非顶点
    let vList = this.edgs.get(v);
     for (let i = 0; i < vList.length; i++) {
       let e = vList[i];
       if(colors[e] == 'white') {
         this.dfsVisit(e, colors, handler);
       }
     }
    //  4. 设置为灰色
     colors[v] = 'black';
  }
}
import {Graph} from './graph';

const graph = new Graph();

graph.addVertex("A");
graph.addVertex("B");
graph.addVertex("C");
graph.addVertex("D");
graph.addVertex("E");
graph.addVertex("F");
graph.addVertex("G");
graph.addVertex("H");
graph.addVertex("I");
graph.addVertex("G");
graph.addVertex("K");

graph.addEdge("A","B");
graph.addEdge("A","C");
graph.addEdge("A","D");
graph.addEdge("C","D");
graph.addEdge("C","G");
graph.addEdge("D","G");
graph.addEdge("D","H");
graph.addEdge("B","E");
graph.addEdge("B","F");
graph.addEdge("E","I");

console.log(graph.toString());
// alert(graph);

// 测试bfs
let result = '';
graph.bfs(graph.vertexes[0], function(v) {
  result += v + ' ';
})
console.log(result);//A B C D E F G H I 

// 测试dfs
let result2 = '';
graph.dfs(graph.vertexes[0], function(v) {
  result2 += v + ' ';
})
console.log(result2);//A B E I F C D G H 

邻接表

 

posted @ 2020-09-27 17:37  CHUNYIN  阅读(177)  评论(0)    收藏  举报