数据结构(二)--- 栈、队列、集合、字典
一、栈
数组是一个线性结构,并且可以在数组的任意位置插入和删除元素。而栈和队列就是比较常见的受限的线性结构。
栈的特点:先进后出,后进先出(LIFO:last in first out)。
程序中的栈结构:
- 函数调用栈: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;
- 递归:为什么没有停止条件的递归会造成栈溢出?比如函数A为递归函数,不断地调用自己(因为函数还没有执行完,不会把函数弹出栈),不停地把相同的函数A压入栈,最后造成栈溢出(Stack Overfloat)
栈常见的操作:
- push(element):添加一个新元素到栈顶位置;
- pop():移除栈顶的元素,同时返回被移除的元素;
- peek():返回栈顶的元素,不对栈做任何修改(该方法不会移除栈顶的元素,仅仅返回它);
- isEmpty():如果栈里没有任何元素就返回true,否则返回false;
- size():返回栈里的元素个数。这个方法和数组的length属性类似;
- toString():将栈结构的内容以字符串的形式返回。
1.封装栈类
class Stack {
constructor() {
// 存放栈中的元素
this.items = [];
}
//栈的相关操作
//1.push():压栈操作,添加新元素到栈顶
//在类中,方法写出来就是直接添加到该类的原型对象上
push(element) {
this.items.push(element);
}
//2.从栈中取出元素,pop在数组中是删除最后一个元素,返回该元素
pop() {
return this.items.pop();
}
//3.查看栈顶元素
peek() {
return this.items[this.items.length - 1];
}
//4.判断栈是否为空
isEmpty() {
return this.items.length === 0;
}
//5.获取栈中元素个数
size() {
return this.items.length;
}
//6.toString方法转字符串
toString() {
return this.items.join('-');
}
}
测试:
let stack = new Stack();
stack.push('zqq');
stack.push(18);
stack.push('td');
console.log(stack);
stack.pop();
console.log(stack.peek()); //18
console.log(stack.isEmpty()); //false
console.log(stack.size());//2
console.log(stack.toString()); //zqq-18
2.栈结构的简单应用
用栈封装一个十进制转二进制的算法
//思路:除以2取余,余数依次入栈,然后依次取出栈顶元素
function dec2bin(decNumber) {
//1.生成一个栈的实例
let stack = new Stack();
//2.循环取余入栈,不确定循环次数,用while
while (decNumber > 0) {
// 2.1.获取余数并放入栈中
stack.push(decNumber % 2);
// 2.2.获取整除后的结果作为下一次运算的数字(floor:向下取整)
decNumber = Math.floor(decNumber / 2);
}
//3.去除栈顶元素,拼接字符串(任何类型和字符串相加都会变成字符串)
let result = '';
while(!stack.isEmpty()) {
result += stack.pop();
}
return result;
}
console.log(dec2bin(1000));// 1111101000
console.log(dec2bin(100));// 1100100
console.log(dec2bin(10));// 1010
二、队列
队列是是一种受限的线性表,特点为先进先出(FIFO:first in first out)。
- 受限之处在于它只允许在表的前端(front)进行删除操作;
- 在表的后端(rear)进行插入操作;
队列的应用:
- 打印队列:计算机打印多个文件的时候,需要排队打印;
- 线程队列:当开启多线程时,当新开启的线程所需的资源不足时就先放入线程队列,等待CPU处理;
队列类的实现:
队列的实现和栈一样,有两种方案:
- 基于数组实现;
- 基于链表实现;
队列的常见操作:
- enqueue(element):向队列尾部添加一个(或多个)新的项;
- dequeue():移除队列的第一(即排在队列最前面的)项,并返回被移除的元素;
- front():返回队列中的第一个元素——最先被添加,也将是最先被移除的元素。队列不做任何变动(不移除元素,只返回元素信息与Stack类的peek方法非常类似);
- isEmpty():如果队列中不包含任何元素,返回true,否则返回false;
- size():返回队列包含的元素个数,与数组的length属性类似;
toString():将队列中的内容,转成字符串形式;
1.封装队列类
class Queue {
constructor() {
//属性
this.items = [];
}
//方法
//1.将元素添加到队列中
enqueue(element) {
this.items.push(element);
}
//2.从队列中删除前端元素,并返回该元素
dequeue() {
return this.items.shift();
}
//3.查看前端的元素
front() {
return this.items[0];
}
//4.查看队列是否为空
isEmpty() {
return this.items.length === 0;
}
//5.查看队列中元素的个数
size() {
return this.items.length;
}
//6.toString方法
toString() {
return this.items.join('');
}
}
测试:
let queue = new Queue();
queue.enqueue('abc');
queue.enqueue(6);
queue.enqueue(8);
console.log(queue);
queue.dequeue(); //删除abc
console.log(queue);
console.log(queue.front()); //6
console.log(queue.isEmpty()); //false
console.log(queue.size()); //2
console.log(queue.toString()); //'68'
2. 队列的简单应用
使用队列实现小游戏:击鼓传花,传入一组数据和设定的数字num,循环遍历数组内元素,遍历到的元素为指定数字num时将该元素删除,直至数组剩下一个元素。
击鼓传花规则: 围成一圈数数,比如规定数到5的人淘汰,那么淘汰的人后面的人从1开始数,然后数到5的人再淘汰,以此类推,直到剩下最后一个人,求该人的位置。
思路: 围成一圈的话,可以看成一个队列,从第一个人开始数,那么在数到num之前的人可以依次从队头删除,加入队尾(保持环形结构)。直到被数到的这个人到队头,然后把它删除就行了。以上循环依次进行,直到队列只剩一个元素,返回该元素和它的位置。
//求最后剩下的那个人的位置,传入参数(名字列表,数字)
function passGame(nameList, num) {
//1.定义一个队列,并依次把元素加入队列
let queue = new Queue();
for(let i = 0; i < nameList.length; i++) {
queue.enqueue(nameList[i]);
}
//console.log('初始队列元素:',queue.items);
//2.从第一个人开始数数
while(queue.size() > 1) {
//队列中只剩1个人就停止数数
// 不是num的时候,重新加入队列末尾
// 是num的时候,将其从队列中删除
//3.1依次把num之前的数字从队头取出,放到队尾
for(let i = 0; i < num - 1; i++) {
queue.enqueue(queue.dequeue());
}
//3.2删除第一个元素(数到num的人)
//由于队列没有像数组一样的下标值不能直接取到某一元素,所以采用,把num前面的num-1个元素先删除后添加到队列末尾,这样第num个元素就排到了队列的最前面,可以直接使用dequeue方法进行删除
queue.dequeue();
//console.log(`人数变化:`,queue.items);
}
//4.把剩下那个人的名字和位置找出来
let endName = queue.front();
let index = nameList.indexOf(endName);
console.log(`剩下的人是${endName},他的初始位置是:${index}`);
return index;
}
//5.测试击鼓传花
let names = ['lily', 'lucy', 'Tom', 'Lilei', 'Tony']
console.log(passGame(names, 3));
// 剩下的人是LiLei,他的初始位置是3
3.封装优先级队列
所谓优先级队列,其实就是有人可以插队。那么封装的时候队列中元素应该是带着一个优先级标识,标识nb的元素就可以往前靠。也就是说需要有两个参数,分别是element(元素)和priority(优先级标识)。
(1)封装的逻辑:
a、首先判断是否为空,空就直接队尾插入;
b、不为空,就要遍历当前队列元素,插入的新值依次比较,如果新值优先级较低,那么就使用splice插入到当前位置的前面(把其他的顶到后面),同时跳出循环。
c、如果比较完之后新插入元素优先级最高,那么直接插入到队尾。
(2)代码实现
//优先队列内部的元素类
class QueueElement {
constructor(element, priority) {
this.element = element;
this.priority = priority;
}
}
//封装优先级队列
class PriorityQueue extends Queue {
constructor() {
super(); //super关键字调用父类的构造函数
}
//重写enqueue方法
enqueue(element, priority) {
//1.根据传入的元素,创建一个元素对象实例
let queueElement = new QueueElement(element, priority);
//2.1判断队列是否为空,如果为空那么直接push进去
if (this.isEmpty()) {
this.items.push(queueElement);
} else {
//2.2如果不为空,那么比较优先级(这里默认小的优先)
let added = false; //定义一个标识,判断是否已经插入
for (let i = 0; i < this.items.length; i++) {
//3.如果插入的元素优先级更小,那么就在当前位置插入(其他的被挤到后面)
if (queueElement.priority < this.items[i].priority) {
this.items.splice(i, 0, queueElement);
added = true; //插入完成,修改标识
break; //如果插入完成,就跳出循环
}
}
//4.如果遍历结束后,新元素始终最大,那么就添加到队尾
if(!added) {
this.items.push(queueElement);
}
}
}
//其他方法全部继承(转字符串需要小改)
dequeue() {
return super.dequeue(); //super关键字调用父类的函数
}
front() {
return super.front();
}
isEmpty() {
return super.isEmpty();
}
size() {
return super.size();
}
toString() {
let result = "";
for(let item of this.items) {
result += `${item.element}-${item.priority} `;
}
return result;
}
}
测试:
let queue = new PriorityQueue();
queue.enqueue('zqq',10); //参数1为元素,参数2为优先级
queue.enqueue('nba',50);
queue.enqueue('cba',20);
queue.enqueue('dg',40);
queue.enqueue('hy',30);
console.log(queue.items);
console.log(queue.toString()); //zqq-10 cba-20 hy-30 dg-40 nba-50
(3)几个注意点
a、类的继承,super()直接调用相当于调用的是父类中的constructor函数,也就是说子类调用super相当于直接继承父类的属性(不调用super会报错)。如果要在子类中调用父类的方法,要使用super.方法名()
b、关于splice的用法,splice(位置,删除几个,替换的元素)
- splice(1,0,‘Tom’):表示在索引为1的元素前面插入元素’Tom‘(也可以理解为从索引为1的元素开始删除,删除0个元素,再在索引为1的元素前面添加元素’Tom’);
- splice(1,1,‘Tom’):表示从索引为1的元素开始删除(包括索引为1的元素),共删除1个元素,并添加元素’Tom’。即把索引为1的元素替换为元素’Tom’。
c、数组的push方法在数组、栈和队列中的形式: - 数组: 在数组[0,1,2]中,pop(3),结果为[0,1,2,3];
- 栈: 执行pop(0),pop(1),pop(2),pop(3),从栈底到栈顶的元素分别为:0,1,2,3;如果看成数组,可写为[0,1,2,3],但是索引为3的元素3其实是栈顶元素;所以说栈的push方法是向栈顶添加元素(但在数组的视角下为向数组尾部添加元素);
- 队列: enqueue方法可以由数组的push方法实现,与数组相同,相当于在数组尾部添加元素。
可以这样理解:栈结构是头朝下(索引值由下往上增大)的数组结构。
三、集合
集合 比较常见的实现方式是哈希表,通常是由一组无序的、不能重复的元素构成。
数学中常指的集合中的元素是可以重复的,但是计算机中集合的元素不能重复。
集合是特殊的数组:
- 特殊之处在于里面的元素没有顺序,也不能重复。
- 没有顺序意味着不能通过下标值进行访问,不能重复意味着相同的对象在集合中只会存在一份。
实现集合类:
- 在ES6中的Set类就是一个集合类,这里我们重新封装一个Set类,了解集合的底层实现。
- JavaScript中的Object类中的key就是一个集合,可以使用它来封装集合类Set。
1.封装集合类
class Set {
constructor() {
//集合使用对象存储,因为元素不能重复(对象中属性不能重复)
this.items = {};
}
//向集合添加一个新的项
add(value) {
//先判断有没有这个属性,有的话就不添加
if(this.has(value)) return false;
//使用[]的方式添加属性,这样属性名就和value一样
this.items[value] = value;
return true;
}
//检测集合(对象)中是否有这个属性
has(value) {
return this.items.hasOwnProperty(value);
}
//删除某个元素
remove(value) {
if(!this.has(value)) return false;
delete this.items[value];
}
//移除集合中所有项
clear() {
this.items = {};
}
//返回集合的长度
size() {
return Object.keys(this.items).length;
}
//获取集合中所有的值
getValues() {
return Object.values(this.items);
}
}
2.集合常见的操作
(1)并集
主要思路就是先创建一个新的集合,把集合1元素都放进去,然后遍历集合2,如果集合2中的元素没有在新集合中,就add进去。遍历集合2也可以直接将所有元素add进去,因为我们封装的add做了去重判断。
union(otherSet) {
//集合对象1:this
//集合对象2:otherSet
//1.创建一个新的集合
let unionSet = new Set();
//2.先把集合对象1中的元素全部放到新集合中
let values = this.getValues();
values.forEach(el => {
unionSet.add(el);
})
//3.遍历集合对象2,判断其中的元素在新集合中有没有
values = otherSet.getValues();
for(let i = 0; i < values.length; i++) {
//这里其实不写判断也可以,因为add方法做了判断
if(!unionSet.has(values[i])) {
unionSet.add(values[i]);
}
}
//4.返回合并之后的结果
return unionSet;
}
(2)交集
主要思路就是遍历集合1,看看每个元素是否在集合2中存在,如果存在就添加到新的集合中,最后返回新的集合。
intersection(otherSet) {
//1.定义一个交集集合
let intersectionSet = new Set();
//2.拿集合1中的元素和集合2中元素相比较
let values1 = this.getValues();
values1.forEach(el => {
//集合1有集合2也有的收起来
if(otherSet.has(el)) {
intersectionSet.add(el);
}
})
//3.返回交集集合
return intersectionSet;
}
(3)差集
差集的思路和交集相反,遍历集合1,看看每个元素是否在集合2中存在,如果不存在就add到新集合里,最后返回新集合。
//1.定义一个差集集合
let diffSet = new Set();
//2.集合1中的元素去集合2中查找
let values1 = this.getValues();
values1.forEach(el => {
//集合1有集合2没有的收起来
if(!otherSet.has(el)) {
diffSet.add(el);
}
})
//3.返回结果
return diffSet;
}
(4)子集
主要思路是遍历集合1,看每个元素是否在集合2中存在,如果存在就return,不存在就继续查找,如果所有元素都在集合2中存在,那么久返回true。
这里需要注意在forEach中写return是无效的,只能跳出当前循环继续下一轮循环,并不能跳出整个循环,更不能跳出整个函数。所以这里要用for循环。
isSubset(otherSet) {
//判断集合1是否是集合2的子集
let values = this.getValues();
//forEach的坑:return无法终止循环,无法跳出函数
// values.forEach(el => {
// if(!otherSet.has(el)) return false;
// })
for(let i = 0; i < values.length; i++) {
if(!otherSet.has(values[i])) return false;
}
return true;
}
四、字典
数组、集合、字典是任何语言都有的数据结构。其中集合和字典比较像,集合可以理解为只有值,字典可以理解为键值对。
需要注意,对象和ES6的Map数据结构是不一样的,对象的键可以是String可以是Symbol,而Map数据结构的键可以是任意数据类型。
1.字典的特点:
- 字典存储的是键值对,主要特点是一一对应;
- 比如保存一个人的信息:数组形式:[19,‘Tom’,1.65],可通过下标值取出信息;字典形式:{“age”:19,“name”:“Tom”,“height”:165},可以通过key取出value。
- 此外,在字典中key是不能重复且无序的,而Value可以重复。
字典和映射的关系:
- 有些编程语言中称这种映射关系为字典,如Swift中的Dictonary,Python中的dict;
- 有些编程语言中称这种映射关系为Map,比如Java中的HashMap&TreeMap等;
2.封装字典类
//字典和集合差不多,都可以基于对象封装
class Map {
constructor() {
this.items = {};
}
//向字典中添加新元素
add(key, value) {
if(this.has(key)) return false;
this.items[key] = value;
}
//判断字典中是否有某个key
has(key) {
return this.items.hasOwnProperty(key);
}
//从字典中移除元素
remove(key) {
//1.判断字典中是否有这个key
if(!this.has(key)) return false;
//2.从字典中删除key
delete this.items[key];
}
//根据key获取value
get(key) {
if(!this.has(key)) return undefined;
return this.items[key]
}
//获取所有keys
keys() {
return Object.keys(this.items);
}
size() {
return this.keys.length;
}
clear() {
this.items = {};
}
}