参考:
https://www.bilibili.com/video/BV13g41157hK?
https://blog.csdn.net/weixin_44972008/article/details/115670939
算法复杂度
时间复杂度
时间复杂度体现的是 操作数据的次数 与 输入数据N的关系
空间复杂度
空间复杂度指在输入数据大小为N时,算法运行时产生的 (储存变量空间+输出数据的空间)与N的关系
tip:内存空间有 数据空间和栈空间 ,一般递归的时候会占用栈空间
tip:力扣中输入和输出的空间是固定的,所以注重分析储存变量空间
时空权衡
由于当代计算机的内存充足,通常情况下,算法设计中一般会采取「空间换时间」的做法,即牺牲部分计算机存储空间,来提升算法的运行速度。
排序算法
排序算法主要可根据 稳定性 、就地性 、自适应性 分类。
稳定性,即相等元素的相对位置不变化;
就地性,即不使用额外的辅助空间;
自适应性,即时间复杂度受元素分布影响;
0.交换数组的两个元素
交换数组中2个元素的操作会在接下来的算法中频繁用到,就不一一声明了
function swap(arr,a,b){
let temp=arr[a]
arr[a]=arr[b]
arr[b]=temp
}
1.冒泡排序(优化版):依次比较相邻元素,直到将最大值冒泡到最后一位
var sortArray = function (nums) {
for (let i = 0; i < nums.length; i++) {
let flag = true; //优化比原版只多了flag的判断
for (let j = 0; j < nums.length - i -1; j++) {
if (nums[j] > nums[j + 1]) {
swap(nums, j, j + 1);
flag = false;
}
}
//flag仍为true,表示从这个数开始往后已经排好序了,所以退出循环
if(flag) return;
}
return nums;
};
2.选择排序:不断的选择剩余元素的最小者放到剩余元素的首位
var sortArray = function (nums) {
for (let i = 0; i < nums.length; i++) {
let min = i;
// 已排序区间 [0, i) ,未排序区间 [i+1 , len)
for (let j = i + 1; j < nums.length; j++) {
if (nums[j] < nums[min]) {
min = j;
}
}
swap(nums, i, min);
//选择排序记录的是索引,所以进行了多次比较但只进行最后的一次交换
//冒泡进行多次比较和交换
//选择排序因此丧失了稳定性
}
return nums;
};
//时间:O(N²) :N次交换和大约N²/2次比较
//空间:O(1)
3.插入排序:每次将一个数字插入一个有序的数组里,成为一个更长的有序数组
var sortArray = function (nums) {
//核心:将a[i]插入到a[0]到a[i-1]的合适位置
for (let i = 1; i < nums.length; i++) {
// 第二层for从右往左遍历
for (let j = i; j > 0 && nums[j] < nums[j - 1]; j--) {
// 只要nums[j]比前一个元素nums[j-1]小,就交换这两个元素
swap(nums, j, j - 1);
}
}
return nums;
};
//时间:O(N²) :最坏情况N²/2次交换和大约N²/2次比较 如:5 4 3 2 1 最好情况比 较N-1次,不交换 如:1 2 3 4 5
//空间:O(1)
一个简单的改进:
上一版中:nums[j]是通过一步步与nums[j-1]交换位置到达目的地的
这一版中:nums[j]一步移动到了指定目的地,比它大的元素依次右移。省去了nums[j]一步步移的步骤。
例如:[3,5,6,7,8,4]
上一版本:4与8交换,4与7交换,4与6交换,4与5交换,总共8步
这一版:我们只需要将6,7,8往右移动一位,然后将4插入空位。总共4步
var sortArray = function (nums) {
for (let i = 1; i < nums.length; i++) {
let temp = nums[i];
let j = i;
while(j > 0 && nums[j - 1]>temp) {
// 只要前一个元素nums[j-1]比nums[j]大,将nums[j-1]移动到nums[j]
nums[j] = nums[j - 1];
j--;
}
// 找到位置j,将i的值放在j上
nums[j] = temp;
}
return nums;
};
4.希尔排序
希尔排序是对插入排序的改进。它解决的是插入排序效率低的问题(特别是大数据情况下)
我们知道插入排序喜欢的是新插入的数据比较大,因为这意味着不用进行多少次比较和交换就能插入,它不喜欢的是新插入的数据小,如:2 3 4…9998 9999 1,这个1就很蛋疼,希尔排序就是避免产生这种情况进行的优化算法。
它大概是这样的流程:如一个16个数的数组(注意一下所有数字表示的都是位数而不是实际数字,如 1 9 表示的是第一位和第9位的数字)
1.先将其分为前8和后8,对第1位和9位进行插入排序,对第2位和第10位进行插入排序,最终于让 1 9 , 2 10 , 3 11 , 4 12 ,5 13 , 6 14 ,7 15 ,8 16是递增的
2.然后将其分为4组, 同样用插入排序让 1 5 9 13 ,2 6 10 14,3 7 11 15,4 8 12 16是递增的
3.再分为8组,让 1 3 5 7 9 11 13 15和2 4 6 8 10 12 14 16是递增的
4.最后进行总的插入排序
这样有什么好处?
好处在于对已经排好序的数组而言,插入排序很快!
如[2,1,4,3,6,5,8,7]
你口算就可以发现对上面这串数字用插入排序所进行的比较的次数只有8次,交换的次数只有4次。
然后到这里可以可以发现,在16个数的那个例子中,它就是先分为8组有序的,然后用这8组去组成4组有序的,再用4组有序的去组成2组有序的,再用2组组成1组。
而希尔排序的另一个好处是,在多轮的插入排序下,那些相对小的元素绝不会出现在末尾,产生2 3 4…9998 9999 1这样的情况
var shellSortArray = function (nums) {
const N = nums.length;
let h = 1;
while (h < N / 3) h = 3 * h + 1; //一般用除3,还没有数学证明证明除几是最好的,但除3的表现很优秀,例子用除2举例是为了方便理解
while (h >= 1) {
for (let i = h; i < N; i++) {
for (let j = i; j >= h && nums[j] < nums[j - h]; j -= h) {
//对当前元素的前h、前2h、前3h...进行插入排序
swap(nums, j, j - h);
}
}
h = Math.floor(h / 3)
}
};
//时间:目前证明不了希尔排序的时间复杂度
//空间:空间:O(1)
5.归并排序
视频:https://www.bilibili.com/video/BV13g41157hK?p=3
1.递归求数组最大值(可跳过)
1.这个例子有助于你理解递归的思想,从而理解归并排序
function getMax(arr){
return process(arr,0,arr.length-1)
}
function process(arr,L,R){
if(L==R) return arr[L]
let mid=L+((L+R)>>1) //>>相当于Math.floor
let leftMax=process(arr,L,mid)
let RightMax=process(arr,mid+1,R)
return Math.max(leftMax,RightMax)
}
process函数:求一个数组最大值,就像p(0,5)依赖于p(0,2)与p(3,5),p总是依赖于其他的p,这就是递归。
底层过程:
1.求p(0,5)需要p(0,2),发现没有,所以p(0,2)进栈
2 p(0,2)需要p(0,1),发现没有,p(0,1)进栈;
3.接着p(0,0)进栈,返回结果3,出栈;p(1,1)进栈,返回结果2,出栈
4.p(0,1)返回结果 max(3,2)=3,p(0,1)出栈
5.P(2,2)进栈,出栈,返回结果5
6.p(0,2)返回结果max(3,5)=5,出栈
7.P(3,5)进栈,后面类似…
2.master公式(可以根据公式来算这类递归函数的时间复杂度,包括归并排序)
1.适用范围:递归的子问题的规模一样,就可以使用master公式。
如:这题对左数组process和右数组process输入的数据规模一样,都是数组的一半
tips:规模一样就行,可以左process调用左1/3。右process调用右1/3,没有中间的process。这样它就是 2*(N/3)
2.公式:
T(N)=a*(N/b)+O(N^d)
3.解释:
a表示调用的次数,(N/b)表示子问题的规模,O(N^d)表示除递归外其他操作的时间复杂度
对于这道求最大值的问题它的T(N)=2*(N/2)+O(N)
其中2是调用了2次process ,N/2表示子问题的process调用的N是一半,O(1)是因为除了递归外只有求平均值和比大小的常数级运算.
4.在满足master公式的情况下,时间复杂度可以根据公式求出,
这题对应第二条,即a=2 b=2 d=0, 1>0,所以时间复杂度为O(N).
2.归并排序
1.例子
对一个数组[3,2,1,5,6,2]进行排序
一、先根据中点将数组分为左数组和右数组,他们是[3,2,1]和[5,6,2];
二、我们对他们进行排序,形成[1,2,3]和[2,5,6],不用纠结这里是什么排序
三、接着用指针1指向左数组第一个元素,用指针2指向右数组第一个元素。然后申请一个新的数组arr
左数组:[1,2,3] 右数组:[2,5,6]
1.1小于2,所以把1放进arr,指针1右移一位 arr:[1]
2.2=2,先将左边的2放进arr,然后将右边的2也放进arr,指针1和指针2都右移一位 arr:[1,2,2]
3.3<5, 将3放入arr,指针1右移一位,越界了,遍历右数组,将5 6 放入arr;
arr:[1,2,2,3,5,6]
2.总结
上面这个例子用来说明归并排序的某个阶段,实际上归并排序就是用递归的方法实现三的过程。也就是将递归求数组最大值例子比大小的过程换为了三过程
我们曾在二里说不用纠结是什么排序,因为它用的就是归并排序。如[3,2,1],它也会按中点分为[3,2]和[1],然后用三的方法,创建2个指针和1个数组来进行排序。
3.代码实现
// 归并排序
var reversePairs = function(nums) {
mergeSort(nums);
function mergeSort (nums) {
if(nums.length < 2) return nums;
const mid = parseInt(nums.length / 2);
let left = nums.slice(0,mid);
let right = nums.slice(mid);
return merge(mergeSort(left), mergeSort(right));
}
function merge(left, right) {
let arr=[];
let leftLen = left.length;
let rightLen = right.length;
let len = leftLen + rightLen;
for(let index = 0, i = 0, j = 0; index < len; index ++) {
if(i >= leftLen) arr[index] = right[j ++];
//左侧越界,拷贝右数组
else if (j >= rightLen) arr[index] = left[i ++];
//右侧越界,拷贝左数组
else if (left[i] <= right[j]) arr[index] = left[i ++];
else if (left[i] > right[j]){
arr[index] = right[j ++];
}
}
return arr;
}
}
/*
时间:O(NlogN)
总共有logN层,每层都进行N次比较(每层数组的长度就是比较次数)
[4,6,1,3,2,5,7,8]
[4,6,1,3] [2,5,7,8]
[4,6] [1,3] [2,5] [7,8]
也可以根据master公式:T(N)=2*(N/2)+O(N)得出时间复杂度
空间:O(N) [0,1,2,3] 需要merge[0,1],merge[2,3],merge[[0,1],[2,3]]
*/
4.相似问题(力扣困难,但解决此问题只在归并排序上增加了2行代码):
数组中的逆序对:https://leetcode-cn.com/problems/shu-zu-zhong-de-ni-xu-dui-lcof/
题目:在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。输入一个数组,求出这个数组中的逆序对的总数。
输入: [7,5,6,4]
输出: 5
// 归并排序
var reversePairs = function(nums) {
let sum = 0; //①定义全局变量sum,在递归中记录逆序对个数
mergeSort(nums);
return sum;
function mergeSort (nums) {
//最小子情况:返回长度为1的数组,因为mergeSort需要接收数组。
if(nums.length < 2) return nums;
const mid = parseInt(nums.length / 2);
let left = nums.slice(0,mid);
let right = nums.slice(mid);
return merge(mergeSort(left), mergeSort(right));
}
function merge(left, right) {
let arr=[];
let leftLen = left.length;
let rightLen = right.length;
let len = leftLen + rightLen;
for(let index = 0, i = 0, j = 0; index < len; index ++) {
if(i >= leftLen) arr[index] = right[j ++];
//左侧越界,拷贝右数组
else if (j >= rightLen) arr[index] = left[i ++];
//右侧越界,拷贝左数组
else if (left[i] <= right[j]) arr[index] = left[i ++];
else if (left[i] > right[j]){
arr[index] = right[j ++];
sum += leftLen - i; //②最重要的一行代码
}
}
return arr;
//merge需要返回数组,因为merge需要传入mergeSort的结果得是数组
}
}
sum += leftLen - i 是最重要的一行代码,它表示:
如[5] 与[4] left[0]>right[0] 及:5>4
那么,左边比5大的数都会比4大,总数为 leftLen-i = 1-0 =1
如[4,5]与[2] left[0]>right[0] 及:4>2
那么,左边比4大的数都会比2大,总数为 leftLen-i = 2-0 =2
所以对于[5,4,2]的逆序对数量我们通过全局变量累加成了3
在判断完他们内部以后,他们会被排列成[2,4,5]
内部的顺序只会影响内部的逆序对数量,如[2,4,5,3,1]与[5,4,2,3,1]他们的逆序对数量是一样的,无论2,4,5怎么排列,3,1都是在他们的右边,相对位置不会改变
6. 快速排序
1.引入(可跳过)
1.将数组根据一个数,分成小于等于和大于该数的两部分,要求时间O(N),空间O(1)
//双指针
function swap(arr,a,b){
let temp=arr[a]
arr[a]=arr[b]
arr[b]=temp
}
function half(nums,n){
let len=nums.length
let i=-1,j=0//i指针维护小于等于的数,发现新的小于等于的数,那么i的下以为数和该数互换,i++,j指针用来遍历数组
while(j<len){
if(nums[j]<=n){
swap(nums,i+1,j)
i++
j++
}else{
j++
}
}
return nums
}
2.将一个数组根据一个数字分成小于、等于、大于三段。要求时间O(N),空间O(1)
力扣原题:https://leetcode-cn.com/problems/sort-colors/
给定一个包含红色、白色和蓝色,一共 n 个元素的数组,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。此题中,我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色。
输入:nums = [2,0,2,1,1,0]
输出:[0,0,1,1,2,2]
//3指针,在分两端的基础上加了一个指针,
function swap(arr,a,b){
let temp=arr[a]
arr[a]=arr[b]
arr[b]=temp
}
function Third(nums,n){
let k=nums.length-1 //指针k维护大于
let i=-1,j=0 //指针i维护小于,指针j遍历
while(j<=k){//j=k的时候也要判断当前数
if(nums[j]<n){
swap(nums,i+1,j)
i++
j++
}else if(nums[j]==n){
j++
}else{
swap(nums,j,k)
//如果nums[j]>n,将当前元素与末尾元素交换,注意这里不进行j++
//因为还要判断交换过来的数是大于小于还是等于
k--
}
}
return nums
}
快速排序
快速排序1.0:用数组的最后一个数作为判断值,将数组分为3个区域,小于区域,等于区域和大于区域,这样数组中等于区域的所有数就不用动了,然后对于小于区域和大于区域,也分别根据最后一个数化为小、等、大三个区域,用递归一层层的确定等于区域,最后就排好序了。
快速排序2.0:把选最后一个数作为判断值变为随机取一个数,这样能避免特殊情况,使每种情况都等可能。
这道力扣题可以测试你排序算法的速度:
https://leetcode-cn.com/problems/sort-an-array/
var sortArray = function(nums) {
quickSort(nums,0,nums.length-1)
return nums
};
function swap(arr,a,b){
let temp=arr[a]
arr[a]=arr[b]
arr[b]=temp
}
function quickSort(nums,L,R){
if(L>=R) return//L<R就排序
let result=partition(nums,L,R)
quickSort(nums,L,result[0]) //往左快排
quickSort(nums,result[1],R)//往右快排
}
function partition(nums,l,r){
let j=l //指针j遍历数组
let i=j-1 //指针i维护小于区域
let k=r //指针k维护大于区域r
let p1 = nums[l+Math.floor(Math.random()*(r-l+1))]
//随机一个数用来当划分值,使用p1=nums[r]用时2500ms以上,使用p1=随机数用时120ms左右
while(j<=k){
if(nums[j]<p1){
swap(nums,i+1,j)
i++
j++
}else if(nums[j]==p1){
j++
}else{
swap(nums,j,k)
k--
}
}
return [i,k+1] //i是左数组的最后一个元素,k+1是右数组的第一个元素
}
//时间:O(NlogN) 数学证明的平均时间复杂度。因为用的随机数,数学上找不到最坏的情况。如果没用随机数,那么是(N²)。最坏的情况就是1 2 3 4 5 6这样的情况,每次拿最后一个数划分,只能确定自己本身和左数组,右数组确定不了。
//空间:O(logN),主要来自栈空间,不用随机数最差情况会是O(N)。
//如1 2 3 4 5 ,如果用最后一个数做分界,那么需要5个栈空间
//最好的情况是以中点划分,如 12 3 45 ,我们求完12可以释放空间用来求45,类似于二叉树的结构,我们只需要申请logN的空间。
7.堆排序
我们可以将数组表示成二叉树:如数组 [4,3,1,6,2,8],我们可以称这为一个堆
4
3 1
6 2 8
大根堆,及二叉树每个头节点都比子节点大,这里3<6就不是大根堆
小根堆,及二叉树每个头节点都比子节点小、
在介绍堆排序之前需要了解两个堆的操作:
堆操作一:新增节点(从本节点沿二叉树往上替换)
用这方法从头开始遍历数组可以形成一个大根堆
function heapInsert(arr,index){
while(arr[index]>arr[Math.floor((index-1)/2)]){//插入元素和父元素比较
swap(arr,index,Math.floor((index-1)/2))
index=Math.floor((index-1)/2)
}
}
堆操作二:从本节点沿二叉树往下替换
function heapify(arr,index,heapSize){
//heapSize表示堆大小,及一个数组我们可以认为它从0到heapsize是堆,往后的不管
let left=index*2+1; //左孩子下标
while(left<heapSize){
//较大的儿子把下标给largest
let largest=(left+1<heapSize)&&arr[left+1]>arr[left]?left+1:left
//如果儿子比父亲大,交换
if(arr[largest]>arr[index]){
swap(arr,largest,index)
//index为父亲交换后的位置,再去找有没有儿子比它大
index=largest
left=index*2+1
continue
}else{
break
//不加break会是死循环,如left=3,heapSize=4,arr[3]=1,arr[1]=2
}
}
}
总结:
操作一:heapInsert和操作二:heapify,使得我们可以在任意节点更改的情况将堆调整回大根堆,如果节点变大了就往上进行heapInsert,如果变小了就往下进行heapify。简单说就是变大就跟父亲比,变小就跟儿子比。
单次heapInsert和单次heapify复杂度:
时间复杂度:O(logN),进行logN次比较和logN次交换
空间复杂度:O(1)
堆排序:
1.过程:
1.先对数组的每个元素进行heapInsert,将数组转化为大根堆。N个元素,每个元素操作的次数是logN,总共为NlogN
2.将堆顶与最末尾的元素进行交换,然后打印最末尾的数字,也就是最大值,同时heapsize–,现在堆顶是最小的,对它进行heapify,调整次数为logN。
3.经过heapify堆重新变回了大根堆。依次对堆顶重复2的过程。N个元素,进行过程2调整次数为logN。所以总的时间复杂度为2NlogN,也就是NlogN。
2.编码实现:
function heapSort(arr){
if(arr==null||arr.length<2) return
for(let i=0;i<arr.length;i++){ //O(N)
heapInsert(arr,i) //O(logN)
}
let heapSize=arr.length
swap(arr,0,--heapSize) //O(1) NlogN+1
while(heapSize>0){ //O(N)
heapify(arr,0,heapSize) //O(logN)
swap(arr,0,--heapSize) //O(1) N(logN+1)
}
return arr
}
//整个堆排过程中的复杂度
//时间:O(NlogN)
//空间:O(1)
测试下吧:https://leetcode-cn.com/problems/sort-an-array/submissions/
8.桶排序(了解)
9.排序总结
1.排序算法的使用
一般用快速排序(虽然都是NlogN但是这是忽略了常数项的比较,一般情况下快排队常数项会比较低),有空间要求用堆,有稳定性要求用归并
2.目前没有时间复杂度N*logN、空间复杂度1、且稳定的算法,再难的帖子缩减了一项都势必会影响另外两项。
左神的视频截图。。。