时间/空间复杂度
1,时间复杂度
数组在内存的空间是连续的,链表的空间是不连续的;所以数组arr[i]的时间复杂度是O(1),想要找第n个数据,直接根据偏移量一下子找到了,不需要遍历;而链表由于不是连续的存储空间,想要找到第n个元素只能一个一个去遍历,跟链表中数据的体量有关系,所以时间复杂度是O(n)
加减乘除,位运算都是一个常数操作,时间复杂度都是O(1);
2,如何计算时间复杂度
根据数据体量得出一个(最差)算术操作的次数方程式,取这个方程式的最高阶项,并去掉它的系数,就是它的时间复杂度。
例如,将一个无序的数组排列成有序的数组,需要查询+比较+替换的总次数 =
所以 这个算法的时间复杂度=O()
3,如何选取时间复杂度
选取最高阶项简单的算法,如果两个最高阶项相同,不能直接通过比较常数项大小做取舍,因为算法中还包括其他的加减乘除不相同,只能实际去运行代码去测试哪个时间更短。
4,常见算法时间复杂度
选择排序:后面的数据与每一轮的第一个数据挨个比较。 O()
冒泡排序:相邻的数据互相比较。 O()
插入排序:后一个数据和所有前面的数据比较。O()
插入排序最优时间复杂度:O(N),所以插入比选择和冒泡速度快
二分法:O(),即简写 O(
)
二分法不一定使用在有序数组中,无序数组也可以二分,比如寻找小部分区间。
递归算法:时间复杂度需要通过master公式计算
// 常见递归格式
process()
function process(){
const a = process();
return a
}
master公式:指递归公式中,所有子递归的规模相等的通用公式。
如果
< d:时间复杂度=
如果
> d:时间复杂度=
如果
== d:时间复杂度=
比如二分法递归:
归并排序:将一个数组排序,先二分法将左侧右侧分别排序,然后创建辅助空间➕两个指针,左边比右边小就将左边push到辅助空间,同时指针后移,以此类推。
由于归并排序使用到递归方法,其master公式得出 == d,所以时间复杂度 =
快速排序1.0:将一个数组的最后一个数作为中间值,大于该值划分到右侧,小于该值划分到左侧。然后左右再分别递归划分。
快速排序2.0:将一个数组的最后一个数作为中间值,大于该值划分到右侧,小于该值划分到左侧,等于该值划分到中间。然后左右再分别递归划分。(荷兰🇳🇱国旗法)
快速排序1.0和2.0的时间复杂度都是 O()
快速排序3.0:随机选择一个数字做划分,由于这个数可能刚好是最大值,最小值,或者中位数,所以(二分法递归)快速排序的公式有很多种可能,比如:
.......
将所有公式等权重相加,得到时间复杂度:
堆排序:将数组排序成一个大根堆,然后将最后一个值和第一个值做交换,然后堆长度(heapsize)减一。
将剩下的堆排序成一个大根堆,将最后一个值和第一个值做交换,然后堆长度(heapsize)减一。
周而复始,直到heapsize=0。
堆排序时间复杂度是:
代码:
计数排序:遍历数组,准备一个容器,统计每个数据出现的次数,然后将数据push到新数组,时间复杂度O(N),局限性:仅限小数据量。
基数排序:计数排序的升级版。因为计数排序统计容器(桶)的个数不确定,根据数据量而定。而基数排序桶的个数只与所排序数据的进制(2进制2个桶,10进制10个桶)有关。
4,空间复杂度
计算过程中,额外开辟的空间数。
比如选择排序,将一个无序的数组排列成有序的数组:
for(let i=0;i<len;i++){
let min = 0;
for(let j=i;j<len;j++){
min = min>arr[j]?arr[j]:min
}
}
在这个计算过程中,产生了一个额外的i,j,min变量,是有限个常数量的空间,空间复杂度=O(1)
运算符号
1,异或^
位运算,相同为0,不同为1;1001^0111 = 1110;异或运算也叫相加不进位运算。
异或的使用场景1:可以作为变量交换而不开辟一个新空间的操作
比如,a与b交换值:
a = a^b;
b = a^b;
a = a^b;
解析:
a = 23;b = 6;
a = a ^ b; // 23 ^ 6
b = a ^ b; // 23 ^ 6 ^ 6 = 23
a = a ^ b; // 23 ^ 6 ^ 23 = 23 ^ 23 ^ 6 = 6
使用条件:
只允许交换不同内存空间的两个数,不允许交换同一个内存空间的一个数。
正确使用:a = arr[1],b= arr[2]
错误使用:a = arr[1], b = arr[1];结果会导致该空间变量抹成0。
异或的使用场景2:可以从数组中找出唯一一个出现奇数次的数,时间复杂度O(n),空间复杂度O(1)
const arr = [1,1,2,2,3]
let res = 0;
for(let i=0;i<arr.length;i++){
res ^ = arr[i];
}
return res;
异或的使用场景3: 可以从数组中找出两个出现奇数次的数,时间复杂度O(n),空间复杂度O(1)
// 假如结果是res_one和res_two
const arr = [1,2,2,3];
let two = 0;
let res_one = 0;
// 先得到res_one ^ res_two
for(let i = 0;i<arr.length;i++){
two ^ = arr[i]; // two = res_one ^ res_two
}
// 再得到res_one ^ res_two最右侧的1
let only = two & (~two +1);
// 最右侧的1和数组所有的值与,一定能得到两个结果中的一个
for(let i = 0;i<arr.length;i++){
if( (only & arr[i]) === only ){
res_one ^ = arr[i];
}
}
// 这是另外一个结果。
let res_two = two ^ res_one;
2,取反 ~
~10010 = 01101
3,与 &
两个值都是真结果为真,其余都会假。1 & 1 = 1,1 & 0 = 0, 0 & 0=0
使用场景:取一个数最右侧的1,比如10110100最右侧的1是100110100
let. a = 10010;
// 取a最右侧的1;
let b = a & (~a + 1); // 10010 & (01101 + 1) = 10010 & 01110 = 00010
4,移位>>
二进制位运算
右移:表示除2
左移:表示乘2求数组中点防止溢出:
const mid = (arr[j] + arr[i] ) / 2
//可以修改成:
const mid = arr[i] + ( (arr[j]- arr[i])>>1 )
测试方法
1,对数器
解决一个问题有两种方法,方法a和方法b,其中方法a更优秀,想要测试一下是否编写正确,可以输入相同的测试用例,同时运行方法a和方法b,假如二者得出的结果始终一致,就是对的。
2,比较器(重载运算符)
编程语言提供的排序函数sort,有正排序(从小到大)也有倒排序。其中第一个参数是需要排序的数组,第二个参数就是比较器。
即,人为的给出一个条件,用于判断哪个数据该放在前面。
比较器返回负数则第一个数放前面,返回正数第二个数放前面。
function student(name,age){
this.name = name;
this.age = age;
}
let arr = [new student('a',12), new student('b',20)]
const sortFunc= Array.sort(arr, new condition());
function condition(a,b){ //比较策略
return a.age-b.age
}
数据结构
1,链表
根据1可以得出,如果只涉及改、查数据,使用数组速度快; 如果涉及增、删使用链表更快。
链表和数组一样,都是地址引用。所以以下代码会导致原来链表改变:
// 已知一个链表node,头节点是head
const newNode = new ListNode(0,head);
newNode.next = newNode.next.next; //该行代码将node链表的第二个节点给删除了
2,堆
堆也是完全二叉树。堆分为大根堆(每个小树里面最大的值是根),小根堆(每个小树里面最小的值是根)。
堆在硬件存储中是以数组的形式存储的,数组是一个连续的存储空间,连续的存储空间如何形成二叉树结构?实际上二叉树的父子节点之间使用数组下标来表示,有这样的对应关系:
数组下标:0,1,2,3,4,5,6;
树结构:
那么父子节点之间有这样的对应关系,一个节点的下标是i,则其左子节点下标是2*i+1,其右子节点下标是2*i+2。同理i位置的父节点=(i-1)/2。比如1位置的左子节点=2*1+1=3
即,堆是以完全二叉树的形式在数组中存储的结构。
堆相关的三种题型:
向大根堆中push一个数字,重新排序成大根堆。(heapinsert)
修改大根堆中某个下标的数字,重新排序成大根堆。(heapify)
删除大根堆中某个下表的数字,重新排序成大根堆。(heapify)
由于二叉树的高度 与数字长度N的关系:H = logN,以上三种题型算法事件复杂度就是logN
堆排序就是heapinsert加heapify的过程。
3,二叉树
种类:满二叉树,完全二叉树,二叉搜索树,平衡二叉搜索树(map,set数据结构的底层原理)
存储方式:链式存储,线性存储
构造一个二叉树:
function Node(val,left,right){
this.val=val;
this.left=left;
this.right=right;
}
const a = new Node(1,null,null);
a.left = new Node(2);
a.right = new Node(3);
搜索方式:
深度优先搜索(递归):前,中,后序遍历
广度优先搜索
算法思维基础
1,回溯算法
回溯一般伴随着递归。
回溯能解决的问题:组合,切割,子集,排列,棋盘
所有的回溯都可以抽象为二叉树的结构:树的宽度是for循环的次数,树的深度是递归次数
模板:
function loop(){
if(终止条件){
收集结果;
return
}
for(集合数组){
处理单层节点;
递归操作;
回溯操作;
}
return;
}
回溯三部曲:确定递归的参数和返回值、确定终止条件,单层搜索
注意点:
1,回溯法的递归值一般是没有返回值的(void)
2,递归必须有个终止条件,在终止条件中一般回收结果,然后return。(一般的在子集问题中在每个子节点中回收结果,除了子集问题,其余4个问题是在终止条件中回收结果。)
3, 接下来一般是for循环。(for循环用于遍历数组的每个元素)
4,其中的递归操作用于实现嵌套for循环。应该嵌套多少层for循环,要在if结束条件中去做决定
5,最后结束for循环执行return的操作。
经典例题:leetcode第77题组合,leetcode第46题全排列
回溯算法解决组合问题的关键点是for循环的开始位置,一般取一个动态的startIndex,因为组合不包含顺序,顺序不同结果是一样的。[1,2]=[2,1]
回溯算法解决排列问题的关键点是for循环的开始位置始终是0,因为排列是包含顺序的,顺序不同算不同的结果([1,2]不等于[2,1])。另外排列需要一个辅助used数组用来记录原数组哪些数字是被使用过的,used=[1,0,1]就表示1和3被使用了,还剩下2可以用。
2,动态规划
解决的题型:斐波那契数列,爬楼梯,背包问题,打家劫舍,股票买卖,子序列问题,编辑距离问题
动态规划解题核心步骤:
确定dp[i] 数组以及下标的含义(dp[i]表示第i个台阶有dp[i]种方法)
确定递推公式 (dp[i]=dp[i-1]+dp[i-2])
dp数组如何初始化(dp[1]=1,dp[2]=1)
遍历顺序(从前往后,还是从后往前遍历)
打印dp数组(为了检查代码到底哪里写错了)
3,递归:
解决题型:括号生成
递归三部曲:
- 参数和终止条件
- 返回值
- 单层逻辑