基于[时间复杂度]和[空间复杂度]分析练习题[数组旋转]的两个算法

251 阅读6分钟

此为原创文章, 分享请声明出处

题目: 数组旋转
要求:
     定义一个函数,
     传入一个数组 arr 和 需要旋转的步数 k,
     把数组arr的后 k 位依次移动到数组前面
例子:
     传入 arr = [1, 2, 3, 4, 5, 6, 7, 8], k = 3
     得到的结果是: [6, 7, 8, 1, 2, 3, 4, 5]

相关的知识点引入
  • 时间复杂度
  • 空间复杂度
  • 数组元素的存放方式
  • Array.unshift() 方法的执行步骤

关于时间复杂度和空间复杂度的相关知识点解释起来比较麻烦, 本文就不在这里作过多的解释了, 请自行查阅资料或者参考以下文章

算法之空间复杂度、时间复杂度
十分钟弄懂:数据结构与算法之美 - 时间和空间复杂度

特别说明:
相比于空间复杂度, 前端开发的算法是极度重视时间复杂度
也就是大佬们常说的 重时间轻空间

为了方便本文理解, 在此放一张复杂度的图

横轴代表的是原始的数据量

纵轴代表的是复杂度的计算结果

snipaste20230616_141552.png

数组元素的存放方式

JS中只有一种数组, 那就是索引数组(下标数组)

首先, 数组是一个有序结构, 数组在内存空间中开辟的是一个连续的内存空间

索引数组: 数组索引始终是数字 0 开始且添加到数组中的每个后续元素的索引以 1 为增量递增的数组。

索引数组的下标是有规律的: 从 0 开始, 依次递增 1

由此就可以看出来数组元素的存放方式:从第一个位置(下标)开始有序的往后排列

就好像我们排队的时候一样, 一个接着一个的排。

Array.unshift() 方法的执行步骤 - 描述

那么, 知道了数组元素是有序排列了之后, 不妨思考一下像 Array.unshift() 这类对数组最前面执行操作的方法会引发什么现象?

我举个例子帮助大家消化一下:

假设某个超市还没开门, 就有一队人在排队买东西了。排队的人, 是一个接着一个的排的, 然后突然在第一位的那里有个人插队进来。那么原本在排队的那些人, 是不是要一个一个依次往后移动一个位置呢?
这么一说就好理解了吧?

Array.unshift() 这类对数组最前面执行操作的方法, 就相当于刚刚例子中所说的插队行为。
在数组的最前面插入一个元素, 会导致从原本下标 0 位置开始一直到最末尾的元素,一个一个依次往后移动.

同理, 在数组的最前面删除一个元素, 会导致从原本下标 1 位置开始一直到最末尾的元素,一个一个依次往前移动.

这属于什么情况呢? 就是你看上去只操作了一个元素, 实际上整个数组都做了运算. 数组长度是多少, 就做了多少次运算。

Array.unshift() 方法的执行步骤 - 演示图

222.gif


好了, 其他相关联的知识点都理解了之后, 我们该进入主题了。先上算法:

算法一 : 使用 pop 和 unshift

/**
 * 时间复杂度: O(n²)
 * 空间复杂度: O(1)
 * 旋转数组 k 步 - 使用 pop 和 unshift
 * @param arr array    需要旋转的数组
 * @param k   number   旋转的步数
 * @returns   array    处理后的数组
 */
export function rotate1(arr: number[], k: number):number[]{
  const length = arr.length
  if(!k || length === 0) return arr

  const step = Math.abs(k % length)

  for(let i = 0; i < step; i++){
    const n = arr.pop()
    if(n){
      arr.unshift(n)
    }
  }

  return arr
}

算法一 : 使用 pop 和 unshift - 分析

首先呢, 上述算法一的代码中的 18 行, arr.unshift(n), 每一次执行的时间复杂度就是 O(n) 了 数组长度是多少, 每次执行这句代码就运行了多少次

接着就是 15 行开始的 for 循环了, 这个循环的时间复杂度也是 O(n), 每次循环里面的 arr.unshift(n) 也是O(n)

所以算法一的时间复杂度是 O(n²)

由于算法一中, 并没有新生成或者定义和 arr 相关的变量, 所以算法一的空间复杂度是 O(1)

所以算法一中的时间复杂度是 O(n²), 空间复杂度是 O(1)

算法二 : 使用 concat 和 slice

/**
 * 时间复杂度: O(1)
 * 空间复杂度: O(n)
 * 旋转数组 k 步 - 使用 concat 和 slice
 * @param arr array    需要旋转的数组
 * @param k   number   旋转的步数
 * @returns   array    处理后的数组
 */
export function rotate2(arr: number[], k: number):number[]{
  const length = arr.length
  if(!k || length === 0) return arr

  const step = Math.abs(k % length)

  const part1 = arr.slice(-step)
  const part2 = arr.slice(0, length - step)
  const part3 = part1.concat(part2)  

  return part3
}

算法二 : 使用 concat 和 slice - 分析

首先呢, 算法二中没有循环, 也没有调用那些会造成执行次数较多的一些方法, 所以算法二中的时间复杂度是O(1)

我们再来看看算法二的空间复杂度 可以看到, 15,16,17 行代码,是基于原数组生成了三个新的数组, 我们就根据这个去进行分析。

假设我们现在arr的数据量是100,
那么 15, 16 行的数据量加起来就是 100,
然后 17 行的 part3 的数据量也是100,
那么 15,16,17 行加起来的数据量就是 200 ,
因为 100 和 200 实际上是同一个数量级的, 所以说算法二的空间复杂度是O(n)

所以算法一中的时间复杂度是 O(1), 空间复杂度是 O(n)

在前端开发过程中, 基于时间复杂度空间复杂度我们要记住一点, 那就是重时间轻空间,
通过分析可以知道, 算法一的时间复杂度是根据原始数据量的大小,成一个很急的趋势在上涨的。
所以说,其实算法二要比算法一是要更加优秀的,
我们使用同样的原始数据量和相同的一个k,来看看这两个算法执行的时间差距有多大

测试示例:

/**
 * 使用 20万 数据测试
 * 两个算法的运行时间
 */
let dataArr:number[] = []

for(let i = 1; i <= 20 * 10000; i++){
  dataArr.push(i)
}

console.time('rotate1')
rotate1(dataArr, 9 * 10000)
console.timeEnd('rotate1')

console.time('rotate2')
rotate2(dataArr, 9 * 10000)
console.timeEnd('rotate2')

执行结果:

1.png

2.png

3.png

4.png

5.png


执行了5次, 算法一每次的执行结果都需要 1190 毫秒左右

算法二每次的执行时间都不超过5毫秒

同样的操作, 同样的数据量, 算法的不同, 程序运行时间的差距上可以说是天壤之别

我是前端开发者-罗公子
到此, 我的 这篇 基于 [时间复杂度] 和 [空间复杂度] 分析 练习题 [数组旋转] 的两个算法 已经完毕, 感谢您的耐心阅读.
如果您觉得我这篇文章写得不错, 可以关注一下和把文章分享出去哦.
再次声明, 此为原创文章, 分享请注明出处.