前言
过去,在大学的学习中,由于本人一直把课余时间都用来追随自己喜欢的项目“音乐”上,学习的时间一少再少。像算法数据结构这种需要日积月累的东西,我可以说是没什么印象了。不过好在从小对数学是感兴趣的,以前在课上也有听讲。对自己的学习能力也是有一点信心的,所以废话不多说开干吧,一个月的学习计划。
第一天
根据我的学习风格,打牢基础是关键。
算法复杂度
算法复杂度指的是:在计算输入数据量N的情况下,算法的【时间使用】和【空间使用】情况。体现算法运行使用的时间和空间随【数据大小N】增大而增大的速度。算法复杂度主要可以从时间、空间两个角度评价:
时间
假设各个操作的运行时间为固定常数,统计算法运行的操作的数量,以代表算法运行所需时间。
空间
统计在最差情况下,算法运行所需使用的最大空间
数据大小N:指的是算法处理的输入数据量,根据不同算法,具有不同的定义:
排序算法(是一种能将一串数据依照特定顺序进行排列的一种算法)
N代表需要排序的元素数量
搜索算法(利用计算机的高性能来有目的地穷举一个问题解空间的部分或所有的可能情况,从而求出问题的解的一种方法)
N代表搜索范围的元素总数,例如数组大小、矩阵大小、二叉树节点数、图节点和边数等。
时间复杂度
时间复杂度指输入数据大小为N时,算法运行所需花费的时间。需要注意的是:统计的是代码的总执行次数而不是运行的绝对时间。计算操作数量和运行绝对时间呈正相关关系。 一段代码的总执行次数,会用T(n)表示,n是输入数据的大小或数量,T是当输入数量为n的时候,这段代码的总执行次数。例:
const fun1 = ()=>{
console.log('雀食蟀')
return 0
}
这段代码的T(n)=2
const fun2 = ()=>{
for(int i = 0;i<n;i++){
console.log('酱紫走位')
}
return 0
}
这段代码的T(n)=3n+3 但是在代码比较多的时候,使用T(n)就会非常麻烦,一条一条算数量也不切实际,因此算法一般使用T(n)简化的估算值来衡量代码执行的速度,而这个简化的估算值就叫做时间复杂度。
符号表示
根据输入数据的特点,时间复杂度具有【最差】、【平均】、【最佳】三种情况,分别使用O , Θ , Ω 三种符号表示。 例:在一个函数中,我需要遍历一个数组,如果遇到数字‘1’,就return true。 那么最佳情况自然是数组的第一个数就是‘1’,像[1,2,3]这样查询次数就是1。 最差的情况就是‘1’在数组的最后一个,那么查询次数就为数组的长度N。 常用的时间复杂度评价就是以【最差】的情况为标准。
时间复杂度的计算
首先判断T(n)是不是常数:
- 是:时间复杂度为O(1)
- 否:时间复杂度为O(保留T(n)的最高次项并且去掉最高次项的系数)→例如:T(n)=2n²+n+5,那么它的时间复杂度为O(n²)
常见时间复杂度的判断
如果代码中没有循环语句,那么时间复杂度就是O(1)。 如果代码中有a重循环,那么时间复杂度为O(n^a),如有if、else等语句存在时,只看最高重循环。
常见例子:
const func = ()=>{
for(int i = 0;i<n;i++){
for(int j = i;j<n;j++){
console.log(`执行${n-i}`次)
}
}
}
这个函数中,每当i加一,j就会少执行一次,因此T(n)=n+(n-1)+……+1=n(n/2+1) 由此可得时间复杂度为O(n²)
const func = ()=>{
for(int i = 1;i<n;i*=2){
console.log(`i为2^${i-1}`)
}
}
这个函数中,我们可以先假设循环内部的语句需要执行的次数为a带入假设值,例如当n=8的时候,a=3。当n=16的时候,a=4。由此可以看出n=2^a,换算一下就是a=log2(n)。而for循环括号中的次数为可数的即为常数,在计算时间复杂度的时候可以忽略,所以我们可以得到该函数的时间复杂度为O(log2(n)),但是log的底数和系数是一样的,也需要去掉,最后正确的时间复杂度应该是O(log n)
时间复杂度由快到慢:O(1) O(logn) O(n) O(nlogn) O(n²) O(n³) O(2^n)
空间复杂度
空间复杂度涉及的空间类型有:
- 输入空间: 存储输入数据所需的空间大小
- 暂存空间: 算法运行过程中,存储所有中间变量和对象等数据所需的空间大小
- 输出空间: 算法运行返回时,存储输出数据所需的空间大小 通常情况下,空间复杂度指在输入数据大小为N时,算法运行所使用的【暂存空间】+【输出空间】的总体大小。 而根据不同来源,算法使用的内存空间分为三类:
指令空间
编译后,程序指令所使用的内存空间。
数据空间
算法中的各项变量使用的空间,包括:声明的常量、变量、动态数组、动态对象等使用的内存空间。
栈帧空间
程序调用函数是基于栈实现的,函数在调用期间,占用常量大小的栈帧空间,直至返回后释放。 在以下例子中
const func = ()=>{
return 0
}
const doFunc = (n:number)=>{
for(int i = 0;i<n;i++){
func()
}
}
每次调用完func()之后,栈帧空间已被释放,即无累计栈帧空间使用,因此空间复杂度为O(1)。 算法中,栈帧空间的累计常用于递归调用。如下:
const doFunc = (n:number)=>{
if(n<=1) return 1
return doFunc(n-1) + 1
}
此代码,通过递归调用,会同时存在n个未返回的函数doFunc(),此时累计使用O(n)大小的栈帧空间。
const func(n:number)=>{
if(n<=0) return 0
const nums = new Array(n).fill(0)
return func(n-1)
}
小总结
通常地声明一些常量、变量、对象、元素数量与输入数据大小 n 无关的集合,他们使用的空间复杂度都为O(1)。 声明的元素数量与输入的数据n呈线性关系的任意类型集合(常见于一维数组、链表、哈希表等),皆使用的空间复杂度为O(n)。
一般情况下,常用到的空间复杂度是:O(1),O(n),O(n²),O(n²)都用的比较少。
空间复杂度由小到大是:O(1) O(n) O(n²)
一般情况下 空间和时间 只能选择一个最优
在工作当中,由于当代计算机的内存充足,通常情况下,算法设计中一般会采取「空间换时间」的做法,即牺牲部分计算机存储空间,来提升算法的运行速度。优先选择时间复杂度最优的
数据结构
常见的数据结构可分为【线性数据结构】和【非线性数据结构】。
线性数据结构: 数组,链表,栈,队列。
非线性数据结构: 数、堆、散列表、图。
数组
数组是将相同类型的元素存储于连续内存空间的数据结构。
const arr1 = new Array(5).fill(0) //定义了一个长度为5,且每一项都为0的数组名为arr1
const arr2 = [0,0,0,0,0] //定义了一个长度为5,且每一项都为0的数组名为arr2
链表
链表以节点为单位,每个元素都是一个独立对象,在内存空间的存储是非连续的。链表的节点对象具有两个成员变量:【值val】,【后继节点引用next】
还是图片最直观了。(话说直接引用别人的图片是可以的吧)
栈
栈是一种具有 「先入后出」 特点的抽象数据结构,可使用数组或链表实现。 用数组解释也比较直观,类似于一个数组只能用push() 将元素插进数组,只能用pop() 把元素取出。
很多益智游戏都会用到这一思维。
队列
队列是一种具有 「先入先出」 特点的抽象数据结构,可使用链表实现。 它就像一个漏了底的栈,进去的会从下面掉出来,所以先进先出。
树
树是一种非线性数据结构,根据子节点数量可分为二叉树和多叉树,最顶层的节点为根节点root。以二叉树为例,每个节点包含三个成员变量:【值val】,【左子节点left】,【右子节点right】。
(哇这图有点大)
图
图是一种非线性数据结构,由【节点(顶点)vertex】和【边edge】组成,每条边连接一对顶点。根据边的方向有无,图可分为【有向图】和【无向图】。
如上图所示:
顶点集合:vertexs=[v1,v2,v3,v4,v5,v6] 边集合:edges={(v1,v2),(v1,v3),(v1,v4),(v2,v5),(v3,v4),(v3,v5),(v3,v6),(v4,v6),(v5,v6)}
表示图的方法通常有两种: 1.邻接矩阵: 使用数组vertexs存储顶点,邻接矩阵edges存储边。edges[i][j]代表节点i+1和节点j+1之间是否有边。(1表示有边,0表示没边)
const vertexs = [v1,v2,v3,v4,v5,v6]
const edges = [[0,1,1,1,0,0],
[1,0,0,0,1,0],
[1,0,0,1,1,1],
[1,0,1,0,0,1],
[0,1,1,0,0,1],
[0,0,1,1,1,0]]
2.邻接表: 使用数组 vertices 存储顶点,邻接表 edgesedges 存储边。edges为一个二维容器,第一维i代表顶点索引,第二维edges[i]存储此顶点对应的边集合;举个栗子:edges[0]=[1,2,3],代表vertices[0]的边集合为[1,2,3]。 再以上图为例:
const vertexs = [v1,v2,v3,v4,v5,v6]
const edges = [[v2,v3,v4],
[v1,v5],
[v1,v4,v5,v6],
[v1,v3,v6],
[v2,v3,v6],
[v3,v4,v5]]
两种图表示法对比:
邻接矩阵的大小只与节点数量有关,即 N² ,其中 N 为节点数量。因此,当边数量明显少于节点数量时,使用邻接矩阵存储图会造成较大的内存浪费。
因此,邻接表 适合存储稀疏图(顶点较多、边较少); 邻接矩阵 适合存储稠密图(顶点较少、边较多)。
散列表
散列表是一种非线性数据结构,通过利用 Hash 函数将指定的【键 key】映射至对应的【值 value】,以实现高效的元素查找。 有点像是一个 对象数组 ,但用它来查找可以做到只用O(1)的时间复杂度。 基本用法:
const testMap = new Map()
//key value
testMap.set("双击",666) //js使用set
testMap.get("双击") //会得到666
堆
堆是一种基于【完全二叉树】的数据结构,可使用数组实现。以堆为原理的排序算法称为【堆排序】,基于堆实现的数据结构为【优先队列】。堆分为【大顶堆】和【小顶堆】,大(小)顶堆:任意节点的值不大于(小于)其父节点的值。 补充概念:
完全二叉树: 设二叉树深度为k,若二叉树除第k层外的其他各层的节点都达到最大个数,且处于第k层的节点都连续集中在最左边,则称此二叉树为完全二叉树。
做题咯
第一题
剑指 Offer 05. 替换空格
请实现一个函数,把字符串 s 中的每个空格替换成"%20"。
示例:
输入:s = "Here We Are"
输出:"Here%20We%20Are"
思路: 那,由于我一直写的是js和ts代码,因此我的解题思路就是em...操作字符串。把字符串从空格的地方split拆开成数组,然后再把数组各项以'%20'为连接值join成一个数组。
function replaceSpace(s: string): string {
return s.split(" ").join("%20")
};
第二题
剑指 Offer 06. 从尾到头打印链表
输入一个链表的头节点,从尾到头反过来返回每个节点的值(用数组返回)。 示例:
输入:head = [1,3,2]
输出:[2,3,1]
思路: 我的思路是,既然只是要返回一个数组就可以的话,那我只要把原数组反转过来不就是了吗(QAQ不知道这种思想会不会很不好)。所以我的目标是先拿到链表的所有值放到一个数组中,然后将他反转得到答案:
function reversePrint(head: ListNode | null): number[] {
let p1 = head
const result:number[] = []
while(p1!==null){
result.push(p1.val)
p1 = p1.next
}
return result.reverse()
};
第三题
剑指 Offer 09. 用两个栈实现队列
用两个栈实现一个队列。队列的声明如下,请实现它的两个函数 appendTail 和 deleteHead ,分别完成在队列尾部插入整数和在队列头部删除整数的功能。(若队列中没有元素,deleteHead 操作返回 -1 ),
示例:
输入:
["CQueue","appendTail","deleteHead","deleteHead"]
[[],[3],[],[]]
输出:
[null,null,3,-1]
思路: 栈是一种【先入后出】的数据结构,而队列需要【先入先出】,使用两个栈在我脑海里大概形状是这样的:
(这是俺自己画的嗷,那线有点不太清晰)
那么有了思路只有就可以快速敲代码啦:
class CQueue {
stackOne:number[] = []
stackTwo:number[] = []
constructor(stackOne:number[],stackTwo:number[]) {
this.stackOne = stackOne?stackOne:[]
this.stackTwo = stackTwo?stackTwo:[]
}
appendTail(value: number): void {
this.stackOne.push(value) //有新的数据传进来,都丢到stackOne里面
}
deleteHead(): number {
//删除操作,只在stackTwo里面操作
//首先判断 如果两个栈内都没有元素(即队列中没有元素),那么返回-1
if(this.stackOne.length===0&&this.stackTwo.length===0) return -1
//接着判断stackTwo中是否有元素,如果有的话优先把里面的元素按顺序抛出
//看不懂的同学可以这样写应该就能理解:
//const result = this.stackTwo.pop()
//return result 把他们合并就变成 return this.stackTwo.pop()
if(this.stackTwo.length>0) return this.stackTwo.pop()
//下面就是stackTwo中没有元素,而stackOne中有元素的情况,将One中元素都pop到Two中
while(this.stackOne.length>0){
this.stackTwo.push(this.stackOne.pop())
}
return this.stackTwo.pop()
}
}
第一次写文章,记录记录自己的过程吧。 以后努力把知识点能写得更细致一些。
第一天就先这样辽,腰酸背痛,代码敲多了一定要起来走走喝水,还有一定要温故而知新。