引文
从本文开始主要探讨前端的算法,这一篇主要涉及:
- 时间复杂度&空间复杂度;
- 链表;
- 队列;
- 栈;
希望通过学习能掌握好
- 具体代码时间复杂度&空间复杂度的算法;
- 链表、队列、栈的JavaScript的完整代码实现;
一、时间复杂度
复杂度分析是整个算法学习的精髓,只要掌握了它,数据结构和算法的内容基本上就掌握了一半了。复杂度分为时间复杂度和空间复杂度,由于空间复杂度很少用于计算分析,这里主要讨论时间复杂度:
1、大 O 表示法
如何进行复杂度分析 ?
大 O 表示法
算法的执行时间与每行代码的执行次数成正比
用 T(n) = O(f(n))
表示
T(n) 表示算法执行总时间
f(n) 表示每行代码执行总次数
n 往往表示数据的规模
这就是大 O 时间复杂度表示法。
大 O 时间复杂度表示法 实际上并不具体表示代码真正的执行时间,而是表示 代码执行时间随数据规模增长的变化趋势
,所以也叫 渐进时间复杂度
,简称 时间复杂度(asymptotic time complexity)。
asymptotic [æsɪmpˈtɑːtɪk] 渐进的
2、特点
以时间复杂度为例,由于 时间复杂度 描述的是算法执行时间与数据规模的 增长变化趋势,所以 常量、低阶、系数
实际上对这种增长趋势不产生决定性影响,所以在做时间复杂度分析时 忽略
这些项。
function cal(n) {
let sum = 0; // 1 次
let i = 1; // 1 次
let j = 1; // 1 次
for (; i <= n; ++i) { // n 次
j = 1; // n 次
for (; j <= n; ++j) { // n * n ,也即是 n平方次
sum = sum + i * j; // n * n ,也即是 n平方次
}
}
}
这里是二层 for 循环,所以第二层执行的是 n * n = n(2) 次,而且这里的循环是 ++i,和例子 2 的是 i++,是不同的,是先加与后加的区别。
那么这个方法需要执行 ( n(2) + n(2) + n + n + 1 + 1 +1 ) = 2n(2) +2n + 3 。
所以,上面例子的时间复杂度为 T(n) = O(n(2))。
3、时间复杂度分析方法
3.1 只关注循环执行次数最多的一段代码
单段代码看高频:比如循环。
function cal(n) {
let sum = 0;
let i = 1;
for (; i <= n; ++i) {
sum = sum + i;
}
return sum;
}
执行次数最多的是 for 循环及里面的代码,执行了 n 次,所以时间复杂度为 O(n)。
3.2 加法法则:总复杂度等于量级最大的那段代码的复杂度
多段代码取最大:比如一段代码中有单循环和多重循环,那么取多重循环的复杂度。
function cal(n) {
let sum_1 = 0;
let p = 1;
for (; p < 100; ++p) {
sum_1 = sum_1 + p;
}
let sum_2 = 0;
let q = 1;
for (; q < n; ++q) {
sum_2 = sum_2 + q;
}
let sum_3 = 0;
let i = 1;
let j = 1;
for (; i <= n; ++i) {
j = 1;
for (; j <= n; ++j) {
sum_3 = sum_3 + i * j;
}
}
return sum_1 + sum_2 + sum_3;
}
上面代码分为三部分,分别求 sum_1、sum_2、sum_3 ,主要看循环部分。
第一部分,求 sum_1 ,明确知道执行了 100 次,而和 n 的规模无关,是个常量的执行时间,不能反映增长变化趋势,所以时间复杂度为 O(1)。
第二和第三部分,求 sum_2 和 sum_3 ,时间复杂度是和 n 的规模有关的,为别为 O(n) 和 O(n(2))。
所以,取三段代码的最大量级,上面例子的最终的时间复杂度为 O(n(2))。
同理类推,如果有 3 层 for 循环,那么时间复杂度为 O(n(3)),4 层就是 O(n(4))。
所以,总的时间复杂度就等于量级最大的那段代码的时间复杂度。
2.3 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
嵌套代码求乘积:比如递归、多重循环等。
function cal(n) {
let ret = 0;
let i = 1;
for (; i < n; ++i) {
ret = ret + f(i); // 重点为 f(i)
}
}
function f(n) {
let sum = 0;
let i = 1;
for (; i < n; ++i) {
sum = sum + i;
}
return sum;
}
方法 cal 循环里面调用 f 方法,而 f 方法里面也有循环。
所以,整个 cal() 函数的时间复杂度就是,T(n) = T1(n) * T2(n) = O(n*n) = O(n(2)) 。
2.4 多个规模求加法:比如方法有两个参数控制两个循环的次数,那么这时就取二者复杂度相加
function cal(m, n) {
let sum_1 = 0;
let i = 1;
for (; i < m; ++i) {
sum_1 = sum_1 + i;
}
let sum_2 = 0;
let j = 1;
for (; j < n; ++j) {
sum_2 = sum_2 + j;
}
return sum_1 + sum_2;
}
以上代码也是求和 ,求 sum_1 的数据规模为 m、求 sum_2 的数据规模为 n,所以时间复杂度为 O(m+n)。
公式:T1(m) + T2(n) = O(f(m) + g(n)) 。
2.5 多个规模求乘法:比如方法有两个参数控制两个循环的次数,那么这时就取二者复杂度相乘
function cal(m, n) {
let sum_3 = 0;
let i = 1;
let j = 1;
for (; i <= m; ++i) {
j = 1;
for (; j <= n; ++j) {
sum_3 = sum_3 + i * j;
}
}
}
以上代码也是求和,两层 for 循环 ,求 sum_3 的数据规模为 m 和 n,所以时间复杂度为 O(m*n)。
公式:T1(m) * T2(n) = O(f(m) * g(n)) 。
4、常用的时间复杂度所耗费的时间从小到大依次是:
O(1) < O(logn) 对数< (n) 线性< O(nlogn)线性对数 < O(n(2))平方 < O(n(3)) 立方< O(2(n)) 指数< O(n!)阶乘 < O(n(n))
常见的时间复杂度:
二、链表
链表相对于数组来说,要复杂的多,首先,链表不需要连续的内存空间,它是由一组零散的内存块透过指针连接而成,所以,每一个块中必须包含当前节点内容
以及后继指针
。最常见的链表类型有单链表、双链表以及循环链表。
学习链表最重要的是 多画图多练习 :
- 确定解题的数据结构:单链表、双链表或循环链表等
- 确定解题思路:如何解决问题
- 画图实现:画图可以帮助我们发现思维中的漏洞(一些思路不周的情况)
- 确定边界条件:思考解题中是否有边界问题以及如何解决
1、单链表
function List () {
// 节点
let Node = function (element) {
this.element = element
this.next = null
}
// 初始头节点为 null
let head = null
// 链表长度
let length = 0
// 操作
this.getList = function() {return head}
this.search = function(list, element) {}
this.append = function(element) {}
this.insert = function(position, element) {}
this.remove = function(element){}
this.isEmpty = function(){}
this.size = function(){}
}
插入节点
初始化一个节点(待插入节点 node ),遍历到 position 前一个位置节点,在该节点后插入 node
确定边界条件:
- 当 position 为 0 时,直接将插入节点 node.next 指向 head , head 指向 node 即可,不需要遍历
- 当待插入位置 position < 0 或超出链表长度 position > length ,都是有问题的,不可插入,此时直接返回 null ,插入失败
// 插入 position 的后继节点
function insert (position, element) {
// 创建插入节点
let node = new createNode(element)
if (position >= 0 && position <= length) {
let prev = head,
curr = head,
index = 0
if(position === 0) {
node.next = head
head = node
} else {
while(index < position) {
prev = curr
curr = curr.next
index ++
}
prev.next = node
node.next = curr
}
length += 1
} else {
return null
}
}
// 测试
list.insert(10)
另外添加、删除、查找节点的代码实现比较简单,这里就不举例说明了。
2、双链表
顾名思义,单链表只有一个方向,从头节点到尾节点,那么双链表就有两个方向,从尾节点到头节点:
function DoublyLinkedList() {
let Node = function(element) {
this.element = element
// 前驱指针
this.prev = null
// 后继指针
this.next = null
}
// 初始头节点为 null
let head = null
// 新增尾节点
let tail = null
// 链表长度
let length = 0
// 操作
this.search = function(element) {}
this.insert = function(position, element) {}
this.removeAt = function(position){}
this.isEmpty = function(){ return length === 0 }
this.size = function(){ return length }
}
插入节点
初始化一个节点(待插入节点 node ),遍历链表到 position 前一个位置节点,在该节点位置后插入 node
当待插入位置 position < 0 或超出链表长度 position > length ,都是有问题的,不可插入,此时直接返回 null ,插入失败
// 插入 position 的后继节点
function insert (position, element) {
// 创建插入节点
let node = new Node(element)
if (position >= 0 && position < length) {
let prev = head,
curr = head,
index = 0
if(position === 0) {
// 在第一个位置添加
if(!head) { // 注意这里与单链表不同
head = node
tail = node
} else {
// 双向
node.next = head
head.prev = node
// head 指向新的头节点
head = node
}
} else if(position === length) {
// 插入到尾节点
curr = tial
curr.next = node
node.prev = curr
// tail 指向新的尾节点
tail = node
} else {
while(index < position) {
prev = curr
curr = curr.next
index ++
}
// 插入到 prev 后,curr 前
prev.next = node
node.next = curr
curr.prev = node
node.prev = prev
}
length += 1
return true
} else {
return false
}
}
// 测试
list.insert(10)
三、循环单链表
循环单链表是一种特殊的单链表,它和单链表的唯一区别是:单链表的尾节点指向的是 NULL,而循环单链表的尾节点指向的是头节点,这就形成了一个首尾相连的环:
既然有循环单链表,当然也有循环双链表,循环双链表和双链表不同的是:
- 循环双链表的 tail.next( tail 的后继指针) 为 null ,循环双链表的 tail.next 为 head
- 循环双链表的 head.prev( head 的前驱指针) 为 null ,循环双链表的 head.prev 为 tail
给定一个链表,判断链表中是否有环。
1、标记法
给每个已遍历过的节点加标志位,遍历链表,当出现下一个节点已被标志时,则证明单链表有环
let hasCycle = function(head) {
while(head) {
if(head.flag) return true
head.flag = true
head = head.next
}
return false
};
时间复杂度:O(n)
空间复杂度:O(n)
2、利用 JSON.stringify() 不能序列化含有循环引用的结构
let hasCycle = function(head) {
try{
JSON.stringify(head);
return false;
}
catch(err){
return true;
}
};
时间复杂度:O(n)
空间复杂度:O(n)
3、快慢指针(双指针法)
设置快慢两个指针,遍历单链表,快指针一次走两步,慢指针一次走一步,如果单链表中存在环,则快慢指针终会指向同一个节点,否则直到快指针指向 null 时,快慢指针都不可能相遇
let hasCycle = function(head) {
if(!head || !head.next) {
return false
}
let fast = head.next.next, slow = head.next
while(fast !== slow) {
if(!fast || !fast.next) return false
fast = fast.next.next
slow = slow.next
}
return true
};
时间复杂度:O(n)
空间复杂度:O(1)
四、队列
1、特点
队列(Queue)是一种运算受限的线性表
特点:先进先出。(FIFO:First In First Out)
- 只允许在表的前端(front)进行删除操作。
- 只允许在表的后端(rear)进行插入操作。
2、生活中类似队列结构的场景:
- 排队,比如在电影院,商场,甚至是厕所排队。
- 优先排队的人,优先处理。 (买票、结账、WC)。
3、 队列的应用
- 打印队列:计算机打印多个文件的时候,需要排队打印。
- 线程队列:当开启多线程时,当新开启的线程所需的资源不足时就先放入线程队列,等待 CPU 处理。
4、队列的实现
队列的实现和栈一样,有两种方案:
- 基于数组实现。
- 基于链表实现。
5、队列常见的操作
- enqueue(element) 向队列尾部添加一个(或多个)新的项。
- dequeue() 移除队列的第一(即排在队列最前面的)项,并返回被移除的元素。
- front() 返回队列中的第一个元素——最先被添加,也将是最先被移除的元素。队列不做任何变动(不移除元素,只返回元素信息与 Map 类的 peek 方法非常类似)。
- isEmpty() 如果队列中不包含任何元素,返回 true,否则返回 false。
- size() 返回队列包含的元素个数,与数组的 length 属性类似。
- toString() 将队列中的内容,转成字符串形式。
class Queue {
constructor() {
this.items = [];
}
// enqueue(item) 入队,将元素加入到队列中
enqueue(item) {
this.items.push(item);
}
// dequeue() 出队,从队列中删除队头元素,返回删除的那个元素
dequeue() {
return this.items.shift();
}
// front() 查看队列的队头元素
front() {
return this.items[0];
}
// isEmpty() 查看队列是否为空
isEmpty() {
return this.items.length === 0;
}
// size() 查看队列中元素的个数
size() {
return this.items.length;
}
// toString() 将队列中的元素以字符串形式返回
toString() {
let result = "";
for (let item of this.items) {
result += item + " ";
}
return result;
}
}
测试代码
const queue = new Queue();
// enqueue() 测试
queue.enqueue("a");
queue.enqueue("b");
queue.enqueue("c");
queue.enqueue("d");
console.log(queue.items); //--> ["a", "b", "c", "d"]
// dequeue() 测试
queue.dequeue();
queue.dequeue();
console.log(queue.items); //--> ["c", "d"]
// front() 测试
console.log(queue.front()); //--> c
// isEmpty() 测试
console.log(queue.isEmpty()); //--> false
// size() 测试
console.log(queue.size()); //--> 2
// toString() 测试
console.log(queue.toString()); //--> c d
6、队列的例子
使用队列实现小游戏:击鼓传花。
分析:传入一组数据集合和设定的数字 number,循环遍历数组内元素,遍历到的元素为指定数字 number 时将该元素删除,直至数组剩下一个元素。
// 利用队列结构的特点实现击鼓传花游戏求解方法的封装
function passGame(nameList, number) {
// 1、new 一个 Queue 对象
const queue = new Queue();
// 2、将 nameList 里面的每一个元素入队
for (const name of nameList) {
queue.enqueue(name);
}
// 3、开始数数
// 队列中只剩下 1 个元素时就停止数数
while (queue.size() > 1) {
// 不是 number 时,重新加入到队尾
// 是 number 时,将其删除
for (let i = 0; i < number - 1; i++) {
// number 数字之前的人重新放入到队尾(即把队头删除的元素,重新加入到队列中)
queue.enqueue(queue.dequeue());
}
// number 对应这个人,直接从队列中删除
// 由于队列没有像数组一样的下标值不能直接取到某一元素,
// 所以采用,把 number 前面的 number - 1 个元素先删除后添加到队列末尾,
// 这样第 number 个元素就排到了队列的最前面,可以直接使用 dequeue 方法进行删除
queue.dequeue();
}
// 4、获取最后剩下的那个人
const endName = queue.front();
// 5、返回这个人在原数组中对应的索引
return nameList.indexOf(endName);
}
7、优先队列
优先队列的应用
生活中类似优先队列的场景:
- 优先排队的人,优先处理。 (买票、结账、WC)。
- 排队中,有紧急情况(特殊情况)的人可优先处理。
优先级队列主要考虑的问题:
- 每个元素不再只是一个数据,还包含优先级。
- 在添加元素过程中,根据优先级放入到正确位置。
6.2.2 优先队列的实现
// 优先队列内部的元素类
class QueueElement {
constructor(element, priority) {
this.element = element;
this.priority = priority;
}
}
// 优先队列类(继承 Queue 类)
export class PriorityQueue extends Queue {
constructor() {
super();
}
// enqueue(element, priority) 入队,将元素按优先级加入到队列中
// 重写 enqueue()
enqueue(element, priority) {
// 根据传入的元素,创建 QueueElement 对象
const queueElement = new QueueElement(element, priority);
// 判断队列是否为空
if (this.isEmpty()) {
// 如果为空,不用判断优先级,直接添加
this.items.push(queueElement);
} else {
// 定义一个变量记录是否成功添加了新元素
let added = false;
for (let i = 0; i < this.items.length; i++) {
// 让新插入的元素进行优先级比较,priority 值越小,优先级越大
if (queueElement.priority < this.items[i].priority) {
// 在指定的位置插入元素
this.items.splice(i, 0, queueElement);
added = true;
break;
}
}
// 如果遍历完所有元素,优先级都大于新插入的元素,就将新插入的元素插入到最后
if (!added) {
this.items.push(queueElement);
}
}
}
// dequeue() 出队,从队列中删除前端元素,返回删除的元素
// 继承 Queue 类的 dequeue()
dequeue() {
return super.dequeue();
}
// front() 查看队列的前端元素
// 继承 Queue 类的 front()
front() {
return super.front();
}
// isEmpty() 查看队列是否为空
// 继承 Queue 类的 isEmpty()
isEmpty() {
return super.isEmpty();
}
// size() 查看队列中元素的个数
// 继承 Queue 类的 size()
size() {
return super.size();
}
// toString() 将队列中元素以字符串形式返回
// 重写 toString()
toString() {
let result = "";
for (let item of this.items) {
result += item.element + "-" + item.priority + " ";
}
return result;
}
}
五、栈
数组是一个线性结构,并且可以在数组的任意位置插入和删除元素。 但是有时候,我们为了实现某些功能,必须对这种任意性加以限制。 栈和队列就是比较常见的受限的线性结构。
1、栈的定义
栈(stack)是一种运算受限的线性表:
- LIFO(last in first out)表示就是后进入的元素,第一个弹出栈空间。类似于自动餐托盘,最后放上的托盘,往往先被拿出去使用。
- 其限制是仅允许在表的一端进行插入和删除运算。这一端被称为栈顶,相对地,把另一端称为栈底。
- 向一个栈插入新元素又称作进栈、入栈或压栈,它是把新元素放到栈顶元素的上面,使之成为新的栈顶元素;
- 从一个栈删除元
栈的特点:先进后出,后进先出。
2、栈的实现
- push() 添加一个新元素到栈顶位置。
- pop() 移除栈顶的元素,同时返回被移除的元素。
- peek() 返回栈顶的元素,不对栈做任何修改(该方法不会移除栈顶的元素,仅仅返回它)。
- isEmpty() 如果栈里没有任何元素就返回 true,否则返回 false。
- size() 返回栈里的元素个数。这个方法和数组的 length 属性类似。
- toString() 将栈结构的内容以字符串的形式返回。
// 栈结构的封装
class Stack {
constructor() {
this.items = [];
}
// push(item) 压栈操作,往栈里面添加元素
push(item) {
this.items.push(item);
}
// pop() 出栈操作,从栈中取出元素,并返回取出的那个元素
pop() {
return this.items.pop();
}
// peek() 查看栈顶元素
peek() {
return this.items[this.items.length - 1];
}
// isEmpty() 判断栈是否为空
isEmpty() {
return this.items.length === 0;
}
// size() 获取栈中元素个数
size() {
return this.items.length;
}
// toString() 返回以字符串形式的栈内元素数据
toString() {
let result = "";
for (let item of this.items) {
result += item + " ";
}
return result;
}
}
3、栈的应用
利用栈结构的特点封装实现十进制转换为二进制的方法。
十进制数转换为二进制的规则如下:
除 2 取余法
- 用十进制数除以 2,取余数。
- 然后将商继续除以 2,再取余数。
- 重复这个过程,直到商为 0。
- 最后将所有的余数从下往上排列,就得到了二进制数。
例如,将十进制数 10 转换为二进制:
- 10÷2 = 5……0
- 5÷2 = 2……1
- 2÷2 = 1……0
- 1÷2 = 0……1
从下往上排列余数,得到二进制数 1010。
function dec2bin(dec) {
// new 一个 Stack,保存余数
const stack = new Stack();
// 当不确定循环次数时,使用 while 循环
while (dec > 0) {
// 除二取余法
stack.push(dec % 2); // 获取余数,放入栈中
dec = Math.floor(dec / 2); // 除数除以二,向下取整
}
let binaryString = "";
// 不断地从栈中取出元素(0 或 1),并拼接到一起。
while (!stack.isEmpty()) {
binaryString += stack.pop();
}
return binaryString;
}