大家好,我是拾七,持续给大家补给前端干货🔥
掌握常见的数据结构,对于编程初学者、学生及软件开发者来说,是提升编程技能和解决问题能力的关键一步。接下来,就让我们一起深入浅出地探索时间复杂度、空间复杂度、数组、链表、栈等常见数据结构的奥秘。
首先,上篇探秘前端JavaScript算法(一)有提到复杂度分析。数据结构和算法解决是“如何让计算机更快时间、更省空间的解决问题”。因此需从执行时间和占用空间两个维度来评估数据结构和算法的性能。分别用时间复杂度和空间复杂度两个概念来描述性能问题,二者统称为复杂度。复杂度描述的是算法执行时间(或者占用空间)与数据规模的增长关系。
为什么要进行复杂度分析?
和性能测试相比,复杂度分析有不依赖执行环境、成本低、效率高、易操作、指导性强的特点。掌握复杂度分析,将能编写出性能更优的代码,有利于降低系统开发和维护成本。
如何进行复杂度分析?(大O)
算法的执行时间与每行代码的执行次数成正比,用 T(n) = O(f(n)) 表示,其中 T(n) 表示算法执行总时间,f(n) 表示每行代码执行总次数,而 n 往往表示数据的规模。这就是大 O 时间复杂度表示法。
关于时间复杂度,测量算法的时间维度
大 O 时间复杂度表示法实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以也叫渐进时间复杂度,简称时间复杂度。
function Fun() {
console.log("Hello, World!"); // 需要执行 1 次
return true; // 需要执行 1 次
}
// 那么这个方法需要执行 2 次运算。
function Fun2(n) {
for(let i = 0; i < n; i++) { // 需要执行 (n + 1) 次
console.log("Hello, World!"); // 需要执行 n 次
}
return true; // 需要执行 1 次
}
// 那么这个方法需要执行 ( n + 1 + n + 1 ) = 2n +2 次运算。
function Fun3(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平方次
}
}
}
// 需要执行 ( n(2) + n(2) + n + n + 1 + 1 +1 ) = 2n(2) +2n + 3
以时间复杂度为例,由于 时间复杂度 描述的是算法执行时间与数据规模的 增长变化趋势,所以 常量、低阶、系数 实际上对这种增长趋势不产生决定性影响,所以在做时间复杂度分析时 忽略 这些项。
所以,上面例子1 的时间复杂度为 T(n) = O(1),例子2 的时间复杂度为 T(n) = O(n),例子3 的时间复杂度为 T(n) = O(n(2))。
关于空间复杂度,渐进空间复杂度
空间复杂度表示算法的存储空间与数据规模之间的增长关系。空间复杂度通过计算算法所需的存储空间实现,算法的空间复杂度的计算公式记作:S(n) = O(f(n)),其中,n 为问题的规模,f(n) 为语句关于 n 所占存储空间的函数。
function print(n) {
const arr = []; // 第 2 行
arr.length = n; // 第 3 行
for (let i = 0; i <n; ++i) {
arr[i] = i * i;
}
for (let j = n-1; j >= 0; --j) {
console.log(arr[i])
}
}
跟时间复杂度分析一样,我们可以看到,第 2 行代码中,我们申请了一个空间存储变量 arr ,是个空数组。第 3 行把 arr 的长度修改为 n 的长度的数组,每项的值为 undefined ,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度就是 O(n)。我们常见的空间复杂度就是 O(1)、O(n)、O(n(2)),像 O(logn)、O(nlogn) 这样的对数阶复杂度平时都用不到。
关于链表,分为单链表、双链表
链表相对于数组来说,要复杂的多,首先,链表不需要连续的内存空间,它是由一组零散的内存块透过指针连接而成,所以,每一个块中必须包含当前节点内容以及后继指针。最常见的链表类型有单链表、双链表以及循环链表。
学习链表最重要的是 多画图多练习 :
- 确定解题的数据结构:单链表、双链表或循环链表等
- 确定解题思路:如何解决问题
- 画图实现:画图可以帮助我们发现思维中的漏洞(一些思路不周的情况)
- 确定边界条件:思考解题中是否有边界问题以及如何解决
单链表的结构
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(){}
}
双链表的结构
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 }
}
关于队列
队列(Queue)是一种运算受限的线性表,特点:先进先出。(FIFO:First In First Out)
- 只允许在表的前端(front)进行删除操作。
- 只允许在表的后端(rear)进行插入操作。
生活中类似队列结构的场景:
-
排队,比如在电影院,商场,甚至是厕所排队。
-
优先排队的人,优先处理。(买票、结账、进WC)。
队列结构
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;
}
}
关于栈
数组是一个线性结构,并且可以在数组的任意位置插入和删除元素。但是有时候,我们为了实现某些功能,必须对这种任意性加以限制。栈和队列就是比较常见的受限的线性结构。
栈(stack)是一种运算受限的线性表:
LIFO(last in first out)
表示就是后进入的元素,第一个弹出栈空间。类似于自动餐托盘,最后放上的托盘,往往先被拿出去使用。- 其限制是仅允许在表的一端进行插入和删除运算。这一端被称为栈顶,相对地,把另一端称为栈底。
- 向一个栈插入新元素又称作进栈、入栈或压栈,它是把新元素放到栈顶元素的上面,使之成为新的栈顶元素;
- 从一个栈删除元素又称作出栈或退栈,它是把栈顶元素删除掉,使其相邻的元素成为新的栈顶元素。
栈的特点:先进后出, 后进先出。
栈的结构
// 栈结构的封装
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;
}
}
通过对这些数据结构的了解,我们不仅能够理解它们背后的原理,还能在实际编程中根据不同的需求选择最合适的数据结构。这样不仅能提高代码的效率,还能让问题解决起来更加得心应手。当然,真正掌握这些数据结构还需要大量的实践和应用,但希望这篇文章能为你开启高效编程的大门,助你在编程的道路上越走越远。
接下来,会给大家分享这些数据结构的用法哦✍️
文章中有疑问的请留言小七哦,欢迎探讨!