前端面试拼图-数据结构与算法(一)

摘要:总结一些前端算法题,持续更新!

一、数据结构与算法

时间复杂度-程序执行时需要的计算量(CPU)

空间复杂度-程序执行时需要的内存空间

前端开发:重时间,轻空间

1.把一个数组旋转k步

array = [1, 2, 3, 4, 5, 6, 7] 旋转数组k=3, 结果[5, 6, 7, 1, 2, 3, 4]

思路1:把末尾的元素挨个pop,然后unshift到数组前面;

思路2:把数组拆分,最后concat拼接到一起

/**
* 旋转数组k步使用pop和unshift
*/
function rotate1(arr: number[], k: number): number[] {
  const length = arr.length
  if (!k || length === 0) return
  const step = Math.abs( k%length)   // abs 取绝对值,k不是数值是返回NaN
  // 时间复杂度o(n^2), 空间复杂度o(1)
  for (let i = 0; i<step; i++) {    // 任何值与NaN做计算返回false
    const n = arr.pop()
    if (n != null  ) {
      arr.unshift(n)  //数组是一个有序结构,unshift操作会非常慢!!!O(n);splice和shift也很慢
    }
  }
  return arr
}
/**
* 旋转数组k步使用concat
*/
function rotate2(arr: number[], k: number): number[] {
  const length = arr.length
  if (!k || length === 0) return
  const step = Math.abs( k%length)   // abs 取绝对值
  const part1 = arr.slice(-step)  // O(1)
  const part2 = arr.slice(0,length-step)
  // 时间复杂度o(1), 空间复杂度O(n)
  return part1.concat(part2)
}

常见内置API中的复杂度:

  • unshift: unshift 方法将给定的值插入到类数组对象的开头,并返回新的数组长度。时间复杂度为 O(n),其中 n 是数组的长度,因为在插入时需要将原有的元素逐一往后移动一位;空间复杂度为 O(1)。
  • splice: splice 方法用于从数组中添加或删除元素,并返回被删除的元素组成的新数组。splice 的时间复杂度为 O(n),其中 n 是数组的长度,因为在删除或插入元素后,需要移动数组中的其他元素以保持连续性;空间复杂度为 O(n),因为需要创建一个新的数组。
  • shift: shift 方法用于从数组的开头删除一个元素,并返回被删除的元素。shift 的时间复杂度为 O(n),其中 n 是数组的长度,因为在删除元素后,需要将数组中的其他元素往前移动一位以保持连续性;空间复杂度为 O(1),因为不需要额外的空间来存储。
  • concat: concat 方法用于将两个或多个数组合并成一个新数组。时间复杂度为 O(1),数组末尾操作;空间复杂度为 O(n+m),m、n是原数组长度,因为新的数组需要存储。
  • slice: slice 方法用于从数组中提取出指定范围的元素,并返回一个新数组(不改变原数组)。时间复杂度为 O(1);空间复杂度为 O(n),因为需要创建一个新的数组来存储提取的元素。

2.判断字符串是否为括号匹配

一个字符串s可能包括{}()[]三种括号,判断s是否是括号匹配

考察的数据结构是栈,先进后出;ApI: push pop length

栈 VS数组区别

栈:逻辑结构;理论模型,不管如何实现,不受任何语言限制

数组:物理结构;真实功能实现,受限于编程语言

/**
* 判断是否括号匹配
*/
function matchBracket(str: string): boolean {
  const length = str.length
  if(length === 0) return true
  
  const stack = []
  const leftSymbols = '{[('
  const rightSymbols = '}])'
  
  for (let i = 0; i <length; i++) {
    const s = str[i]
    if (leftSymbols.includes(s)) {
      stack.push(s)  // 左括号,压栈
    } else if (rightSymbols.includes(s)) {
      // 左括号,判断栈顶(是否出栈)
      const top = stack[stack.length-1]
      if (isMatch(top, s)) {
        stack.pop
      } else {
        return false
      }
    }
  }
  return stack.length === 0
}
/**
* 判断左右括号是否匹配
*/
functionn isMatch(left: string, right: string): boolean {
  if (left === '{' && right === '}') return true
  if (left === '[' && right === ']') return true
  if (left === '(' && right === ')') return true
  return false
}

        时间复杂度O(n); 空间复杂度O(n)

3.定义一个JS函数,反转单向链表

        链表

        链表是一种物理结构(非逻辑结构), 类似于数组

        数组需要一段连续的内存空间,而链表是零散

        链表节点的数据结构{value, next?, prev?}

        链表 VS 数组

        都是有序结构(Set是无序的)

  • 链表:查询需要遍历元素慢O(n), 新增和删除不需要移动其他元素很快快O(1);
  • 数组:按照索引查询快时间复杂度O(1), 新增和删除需要移动其他元素比较慢慢O(n);
  • 数组适合随机访问元素、大小固定的情况,而链表适合频繁的插入或删除操作、大小不确定的情况
/**
*  反转单项链表
*/
interface ILinkListNode {   // 定义类型
  value: number    // 类型结构,value、next?
  next?: ILinkListNode   //?表示next是可选的
}

/**
*  反转单向链表,并返回反转之后的head node
*/
fucntion reserveLinkList(listNode: ILinkListNode): ILinkListNode {
  // 定义三个指针
  let prevNode: ILinkListNode | undefined = undefined
  let curNode: ILinkListNode | undefined = undefined
  let nextNode: ILinkListNode | undefined = listNode
  
  // 以nextNode为主,遍历链表
  while(nextNode) {
    // 第一个元素,删掉next,防止循环引用
    if (curNode && !prevNode) {
      delete curNode.next
    }
    // 反转指针
    if (curNode && prevNode) {  中间状态,指针都有值
      curNode.next = prevNode
    }
    // 指针后移
    prevNode = curNode
    curNode = nextNode
    nextNode = nextNode?.next   //有nextNode.next则返回,否则返回空    
  } 
  // 最后一个元素:当nextNode空时, 此时curNode尚未设置next
  curNode!.next = prevNode
  return curNode!
}

/**
*  根据数组创建单项链表
*/
function createLinkList(arr: number): ILinkListNode {
  const length = arr.length
  if (length === 0) throw new Error('array is Empty')
  
  let curNode: ILinkListNode = {
    value: arr[length-1]
  }
  if (length == 1) return curNode
  for ( let i = length-2; i >=0; i--) {
    curNode = {
      curNode = {
        value: arr[i],
        next: curNode
      }
    }
  }
  reurn curNode 
}

        链表在前端应用不多,例如React Fiber使用链表,通过将渲染树转换成链表表示,更灵活地控制渲染:

        在 React 16 中引入的 Fiber 架构使用链表数据结构来表示组件树,这样可以更好地控制组件树的遍历和更新过程。每个 Fiber 节点都包含了对应组件的信息以及与其他 Fiber 节点的关联关系,通过链表将这些 Fiber 节点连接起来形成一个虚拟的组件树。这种链表的结构使得 React 能够更灵活地控制组件更新的顺序,实现异步渲染和优先级调度等特性。

        使用链表而不是传统的递归方式遍历组件树,使得 React 能够实现更细粒度的控制,例如中断和恢复更新过程、优先级调度等。这种设计可以提高 React 应用的响应速度和用户体验,并且更好地支持 Suspense 和并发模式等新特性的引入。

4. 链表和数组,那个实现队列更快?

        数组是连续存储,push很快,shift很慢

        链表是非连续存储,add和delete都很快(但查找很慢)

        结论:链表实现队列更快

        链表实现队列

  • 单向链表,但要同时记录head和tail
  • 要从tail入队,从head出队,否则出队时tail不好定位
  • length要实时记录,不可遍历链表获取(慢)
/**
* 用链表实现队列
*/
interface IListNode {
  value: number
  next: IListNode | null
}
class MyQueue {
  private head: IListNode | null = null
  private tail: IListNode | null = null
  private len = 0
  /**
  * 入队,在tail位置
  */
  add(n: number) {
    const newNode: IListNode = {
      value: n,
      next: null,   // tail入队,结尾节点没next
    }
    // 处理head
    if (this.head == null) {
      this.head = newNode
    }
    // 处理tail
    const tailNode = this.tail
    if (tailNode) {
      tailNode.next = nextNode
    }
    this.tail = newNode
    this.len++
  }
  /**
  * 出队,在head的位置
  */
  delete(): number | null {
    const headNode = this.head
    if (headNode = null) return null
    if (this.len <= 0) return null
    // 取值
    const value = headNode.value
    // 处理head
    this.head = headNode.next
    // 记录长度
    len--
    return value
  }
  
  get length(): number {
    return this.len  // length要单独存储,不能遍历链表来获取
  }
}

        链表和数组实现队列的性能对比

  • 空间复杂度都是O(n)
  • add时间复杂度:链表O(1),数组O(1);
  • delete时间复杂度:链表O(1),数组O(n)

数据结构的选择,要比算法优化更重要

5. 用JS实现二分查找,并说明时间复杂度

        二分查找(Binary Search)是一种高效的查找算法,通常用于在有序数组中查找特定元素的位置。其基本原理和步骤:

  1. 前提条件: 二分查找要求目标数组是有序的。
  2. 步骤:
    • 首先,确定要查找的目标元素。
    • 指定两个指针,一个指向数组的起始位置(left),另一个指向数组的末尾位置(right)。
    • 计算中间位置的索引:mid = (left + right) / 2。
    • 将中间位置的元素与目标元素进行比较:
      • 如果中间元素等于目标元素,则返回中间位置的索引。
      • 如果中间元素大于目标元素,则将右指针指向 mid-1,缩小查找范围到左半部分。
      • 如果中间元素小于目标元素,则将左指针指向 mid+1,缩小查找范围到右半部分。
    • 重复以上步骤,直到找到目标元素或左指针超过右指针(查找失败)为止。
  3. 时间复杂度: 二分查找的时间复杂度是 O(log n),其中 n 是数组的大小。由于每次查找都将查找范围减半,因此它的效率非常高。

实现思路:

        递归-代码逻辑更清楚

        非递归-性能更好

/**
* 二分查找 (循环)
*/
function binarySearch1(arr: number[], target: number):number {
  const length = arr.length
  if (length === 0) return -1
  
  let startIndex = 0 // 开始位置
  let endIndex = length -1 // 结束为止
  
  while(startIndex <= endIndex) {
    const midIndex = Match.floor((startIndex + endIndex) / 2)
    const midValue = arr[midIndex]
    if (target < midValue) {
      // 目标较小,则继续在左侧寻找
      endIndex = midIndex -1
    } else if (target > midValue) {
      // 目标较大,则继续在右侧寻找
      startIndex = midIndex + 1
    } else {
      // 相等,返回索引
      return midIndex
    }
  }
  return -1
}

/**
* 二分查找 (递归)
*/
// startIndex?:number 中?表示参数可传可不传
function binarySearch2(arr: number[], target: number, startIndex?:number, endIndex?:number):number {
  const length = arr.length
  if (length === 0) return -1
  
  // 开始和结束的范围
  if( startIndex === null ) startIndex = 0
  if( endIndex === null ) endIndex = length-1
  
  // 如果startIndex和endIndex相遇,则结束
  if( startIndex > endIndex) return -1
  
  // 中间位置
  const midIndex = Match.floor((startIndex + endIndex) / 2)
  const midValue = arr[midIndex]
  if (target < midValue) {
    // 目标较小,则继续在左侧寻找
    return binarySearch2(arr, target, startIndex, midIndex -1)
  } else if (target > midValue) {
    // 目标较大,则继续在右侧寻找
    return binarySearch2(arr, target, midIndex + 1, endIndex)
  } else {
    // 相等,返回索引
    return midIndex
  }
}

        二分查找的时间复杂度是 O(log n),相对而言循环更快(递归频繁调用函数开销更大),但差距不大,没有达到相差数量级的程度。

6. 给一个数组,找出其中和为n的两个元素

        例子,有一个递增的数组[1,2,4,7,11,15] 和一个n=15,数组中两个数的和为n, 即4+11=15

        常规思路:

        嵌套循环,找出一个数,然后遍历下一个数,求和判断;时间复杂度O(n^2), 不可用

function findTwoNumbers1(arr:number[], n: number): number[] {
  const res: number[] = []
  const length = arr.length
  if (length === 0) return res
  for(let i = 0, i< length, i++) {
    const n1 = arr[i]
    let flag = false   // 是否等待结果
    // O(n^2)
    for(let j = i+1, j < length, j++) {
      const n2 = arr[j]
      if (n1 + n2 === n) {
        res.push(n1)
        res.push(n2)
        flag = true
        break
      }
    }
    if(flag) break
  }
  return res
}

        思路二:利用递增的特性,随便找两个数(可选择首尾两个数),如果和大于n, 则需要向前查找(尾数向前);如果和小于n,则需要向后查找(头部数向后移动)

        优化嵌套循环可以考虑“双指针”,双指针的思想,时间复杂度降到O(n)

function findTwoNumbers2(arr:number[], n: number): number[] {
  const res: number[] = []
  const length = arr.length
  if (length === 0) return res
  
  let i = 0  // 头
  let j = length -1 // 尾
  while(i<j) {
    const n1 = arr[i]
    const n2 = arr[j]
    const sum = n1 + n2
    
    if(sum > n) {
      // sum大于n,则j向前移动
      j--
    } else if () {
      // sum小于n,则i向后移动
      i++
    } else {
      // 相等
       res.push(n1)
       res.push(n2)
    }
  }
  
  return res
}

        此题也可借助map完成,时间复杂度同样为O(n)

function findTwoElementsWithSum(arr: number[], target: number): [number, number] | null {
  const map = new Map<number, number>(); // 空间复杂度 O(n)

  for (let i = 0; i < arr.length; i++) { // 时间复杂度 O(n)
    const complement = target - arr[i]; // 常数级时间复杂度

    if (map.has(complement)) { // 常数级时间复杂度
      return [complement, arr[i]]; // 返回找到的两个元素
    }

    map.set(arr[i], i); // 常数级时间复杂度,空间复杂度 O(n)
  }

  return null; // 如果没有找到符合条件的两个元素,则返回null或其他指定的值
}
/**
* 遍历数组的时间复杂度为O(n),哈希表中查找和插入元素的时间复杂度为 O(1), 整个算法的时间复杂度为 O(n);
* 哈希表来存储元素及其索引,因此空间复杂度为 O(n)
*/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值