【数据结构和算法】算法题解析

1.将一个数组旋转k步

输入一个数组[1,2,3,4,5,6,7], k = 3, 即旋转k步, 输出[5,6,7,1,2,3,4]

  • 如:旋转第一步,输出[7,1,2,3,4,5,6]
  • 旋转第二步,输出[6,7,1,2,3,4,5]
  • 旋转第三步,输出[5,6,7,1,2,3,4]
  • 思路一,把末尾的元素挨个pop,然后unshift到数组前面,这个算法的时间复杂度是O(n^2),空间复杂度是O(1)

这里时间复杂度为什么会是O(n ^ 2)呢?

  • 因为数组是一个有序结构,对数组进行unshift操作时,结构中的所有数据都需要往后挪一个位置,相当于对数组又进行了一次遍历,再加上外层的for循环,所以时间复杂度就是O(n ^ 2)
/**
 * 旋转数组k步 - 思路一
 * @param arr
 * @param k
 * @returns arr
 */
const rotate = (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;
};
  • 思路二,把数组拆分,最后concat拼接到一起,这个算法的时间复杂度O(1),空间复杂度O(n)
/**
 * 旋转数组k步 - 思路二
 * @param arr 
 * @param k 
 * @returns arr
 */
export const rotate = (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;
};

因为前端重时间轻空间,所以思路二更适合

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

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

  • 如:(a{b}c)就是匹配的,(a{b}c就是不匹配的,少了一个)括号

解决这个问题之前首先了解下的概念

  • 先进后出,如下图,先入栈的在栈底,后入栈的在栈顶。出栈时则是后入栈的先出,先入栈的后出。
  • API: push pop length

这是一张栈的示意图
栈和数组的区别

  • 栈是逻辑结构,理论模型,不管如何实现,不受任何语言的限制,在任何语言中都可以通过语法去实现栈。
  • 数组,物理结构,真实的功能实现,受限于编程语言。

了解完栈的概念,然后再聊聊解题思路,这个算法的时间复杂度和空间复杂度都是O(n),按理说includes其实也是遍历了一次字符串,它的时间复杂度应该也是O(n),但它遍历的是只有3个字符的常量,计算量可以忽略不计,所以在这个算法中可以忽略掉includes的时间复杂度。

  • 遇到左括号 ( { [ 就压栈(入栈)
  • 遇到右括号 ) } ] 就判断栈顶,匹配则出栈
  • 最后判断length是否为0
/**
 * 判断字符串是否括号匹配
 * @param str
 */
const matchBracket = (str: string): boolean => {
  if (str.length === 0) return false;

  const stack = [];
  const leftBracket = "([{";
  const rightBracket = ")]}";

  for (let i = 0; i < str.length; i++) {
    // 碰到左括号,就入栈
    if (leftBracket.includes(str[i])) {
      stack.push(str[i]);
    }

    // 碰到右括号,就判断栈顶,匹配则出栈
    if (rightBracket.includes(str[i])) {
      // 左括号
      const top = stack[stack.length - 1];

      // str[i]右括号,左右如果匹配,就出栈
      if (isMatch(top, str[i])) {
        stack.pop();
      } else {
        return false;
      }
    }
  }

  return stack.length === 0;
};

/**
 * 判断左右括号是否匹配
 * @param left
 * @param right
 * @returns
 */
const isMatch = (left: string, right: string): boolean => {
  if (left === "(" && right === ")") return true;
  if (left === "{" && right === "}") return true;
  if (left === "[" && right === "]") return true;

  return false;
};

// 功能测试
const str = "(a){b}[c]";
const str1 = "(a{b[c]})";
const str2 = "(a}";
const str3 = "(a{b]}c)";

console.log("str: ", matchBracket(str)); // true
console.log("str1: ", matchBracket(str1)); // true
console.log("str2: ", matchBracket(str2)); // false
console.log("str3: ", matchBracket(str3)); // false

3.用两个栈实现一个队列

栈的概念在算法2中已经了解了,这里又出现一个队列,其实队列和栈差不多,栈是先进后出,队列先进先出,比如有一群人在排队,那么先排队的人肯定先办事。

  • 先进先出
  • API: add delete length

解题思路:

1.准备两个栈,栈1和栈2
2.用pop将栈1中的数据全部压入栈2
3.这时栈2顶部的数据,就是栈1中栈底的数据
4.取出栈2顶部的数据,将剩下的数据再压入栈1
5.就用两个栈实现了先入先出的队列

class myQueue {
  // 栈1和栈2
  private stack1: number[] = [];
  private stack2: number[] = [];

  /**
   * 新增数据
   * @param n
   */
  add(n: number) {
    this.stack1.push(n);
  }

  delete(): number | null {
    const stack1 = this.stack1;
    const stack2 = this.stack2;

    // 把stack1中的数据从栈顶开始全部压入stack2中
    while (stack1.length) {
      const n = stack1.pop();

      if (n != null) {
        stack2.push(n);
      }
    }

    // stack2的栈顶数据就相当于队列中先出的数据
    let res = stack2.pop();

    // 将stack2中剩余的数据再放入stack1中,就用两个栈实现了先入先出的队列
    while (stack2.length) {
      const n = stack2.pop();

      if (n != null) {
        stack1.push(n);
      }
    }

    return res || null;
  }

  get length(): number {
    // stack2只是临时使用,最终返回的还是stack1的长度
    return this.stack1.length;
  }
}

// 功能测试,q实例现在就是一个用两个栈实现的队列
const q = new myQueue();
q.add(100);
q.add(200);
q.add(300);
console.log(q.length); // 3 符合预期
console.log(q.delete()); // 100 因为100先入,所以100先出,符合预期
console.log(q.length); // 2 符合预期

这个算法中的add方法时间复杂度是O(1),delete方法的时间复杂度是O(n),整体的空间复杂度是O(n)

4.使用js反转单向链表

链表的概念

  • 链表是一种物理结构(非逻辑结构),类似于数组
  • 数组需要一段连续的内存空间,而链表是零散的
  • 链表节点的数据结构:{ value, next?, prev? },下图就是单向链表,netx和prev后的 ?代表这个属性可有可无

在这里插入图片描述
既然要反转单向链表,首先需要根据数组来创建一个单向链表

// 创建一个链表节点类型
interface ILinkListNode {
  value: number;
  next?: ILinkListNode;
}

/**
 * 根据数组创建一个单向链表
 */
const createLinkList = (arr: number[]): ILinkListNode => {
  if (arr.length === 0) throw new Error("arr is empty");

  // 当前节点的value等于数组的最后一个元素,因为链表中要用next指向下一个元素,所以在数组中要倒着来。
  let curNode: ILinkListNode = {
    value: arr[arr.length - 1],
  };

  // 如果数组的长度正好等于1,那么直接返回这个节点
  if (arr.length === 1) return curNode;

  // 因为数组倒着来,并且第一个节点已经取出来了,所以从倒数第二个开始
  for (let i = arr.length - 2; i >= 0; i--) {
    curNode = {
      value: arr[i],
      next: curNode,
    };
  }

  return curNode;
};

const arr = [100, 200, 300, 400, 500];
const list = createLinkList(arr);

console.log(list);

链表和数组的异同

  • 都是有序结构
  • 链表:查询慢O(n),新增和删除快O(1)
  • 数组:查询快O(1),新增和删除慢O(n)

解题思路:

  • 反转,即节点next指向前一个节点
  • 但这样很容易造成nextNode的丢失
  • 所以需要三个指针 prevNode curNode nextNode
// 创建一个链表节点类型
interface ILinkListNode {
  value: number;
  next?: ILinkListNode;
}

/**
 * 反转列表
 * @param listNode
 * @returns
 */
const reverseLinkList = (listNode: ILinkListNode): ILinkListNode => {
  // 定义三个指针
  let prevNode: ILinkListNode | undefined = undefined;
  let curNode: ILinkListNode | undefined = undefined;
  let nextNode: ILinkListNode | undefined = listNode;

  // 以nextNode为主,遍历链表
  while (nextNode) {
    // 如果curNode有值,并且prevNode没值,那么说明这是第一个元素,就要删掉nextNode,防止循环引用
    if (curNode && !prevNode) {
      delete curNode.next;
    }

    // 如果curNode和prevNode都有值,那么就将指针反转
    if (curNode && prevNode) {
      curNode.next = prevNode;
    }

    // 指针整体向后移动
    prevNode = curNode;
    curNode = nextNode;
    nextNode = nextNode?.next;
  }

  // 当nextNode为空时,就会退出循环,但这个时候curNode还没有设置next
  curNode!.next = prevNode;
  return curNode!;
};

/**
 * 根据数组创建一个单向链表
 */
const createLinkList = (arr: number[]): ILinkListNode => {
  if (arr.length === 0) throw new Error("arr is empty");

  // 当前节点的value等于数组的最后一个元素,因为链表中要用next指向下一个元素,所以在数组中要倒着来。
  let curNode: ILinkListNode = {
    value: arr[arr.length - 1],
  };

  // 如果数组的长度正好等于1,那么直接返回这个节点
  if (arr.length === 1) return curNode;

  // 因为数组倒着来,并且第一个节点已经取出来了,所以从倒数第二个开始
  for (let i = arr.length - 2; i >= 0; i--) {
    curNode = {
      value: arr[i],
      next: curNode,
    };
  }

  return curNode;
};

const arr = [100, 200, 300, 400, 500];
const list = createLinkList(arr);
const list1 = reverseLinkList(list);

console.log(list);
console.log(list1);

5.链表和数组哪个实现队列更快?

  • 数组是连续存储,push很快,shift很慢
  • 链表是非连续存储,add和delete都很快(但查找很慢)
  • 结论:链表实现队列更快

链表实现队列思路:

  • 单向链表,但要同时记录head和tail
  • 要从tail入队,从head出队,否则出队时tail不好定位
  • length要实时记录,不可遍历链表获取
interface ILinkListNode {
  value: number;
  // @ts-ignore
  next: ILinkListNode | null;
}

class myQueue {
  private head: ILinkListNode | null = null;
  private tail: ILinkListNode | null = null;
  private length = 0;
  /**
   * 从tail入队,如果从tail出队,因为是单向链表,没法指向tail的上一个元素,所以需要从tail入队
   * @params n number
   */
  add(n: number) {
    const newNode = {
      value: n,
      next: null,
    };

    // 如果head节点是null,那就说明链表中只有即将要入队的元素,那么就让head指向newListNode
    if (this.head == null) {
      this.head = newNode;
    }

    // 因为是从队尾入队,那么如果tail有值,就让它指向新增的元素
    if (this.tail) {
      this.tail.next = newNode;
    }

    // 不管tail有值没值,都需要让tail指向队尾的元素,根据队列先进先出的原则,tail就是新增的元素
    this.tail = newNode;

    // 记录长度
    this.length++;
  }

  delete(): number | null {
    // 处理异常
    if (this.head == null) return null;
    if (this.length <= 0) return null;

    // 取值
    const val = this.head.value;

    // 取值之后需要让head指向下一个元素
    this.head = this.head.next;

    // 记录长度
    this.length--;

    // @ts-ignore
    return val | null;
  }

  get len(): number {
    return this.length;
  }
}

const q = new myQueue();
q.add(1);
q.add(2);
q.add(3);
console.log(q.len);
q.delete();
console.log(q.len);
q.add(4);
console.log(q.len);

性能分析:

  • 空间复杂度都是O(n)
  • add时间复杂度都是O(1)
  • delete时间复杂度:数组是O(n),数组删除时需要挪动所有元素,相当于遍历了一遍,所以是O(n)。链表是O(1)

6.用js实现二分查找

思路:

  • 用递归实现,代码逻辑更加清晰
  • 非递归,性能更好
  • 时间复杂度:O(logn)

使用循环实现(非递归):

const binarySearch = (arr: number[], target: number): number => {
  // 如果arr为空,就返回-1
  if (arr.length === 0) return -1;

  // 声明一个开始和结束的index
  let startIndex = 0;
  let endIndex = arr.length - 1;

  // 当startIndex小于等于endIndex的时候,证明还没有查完,就继续去查找。否则就是查完了,但没找到
  while (startIndex <= endIndex) {

    // 因为要从中间开始查找,所以需要拿到中间的index
    const midIndex = Math.floor((startIndex + endIndex) / 2);

    // 如果要查找的值比中间的值小,那么目标值就在左侧,让endIndex移到中间,因为中间的值已经查找过了,所以可以减一,不再去查找中间的值
    // 如果大于,说明在右侧,就让startIndex移动,+1同上
    // 再最后就是目标值等于中间值,那么就是找到了,直接返回midIndex
    if (target < arr[midIndex]) {
      endIndex = midIndex - 1;
    } else if (target > arr[midIndex]) {
      startIndex = midIndex + 1;
    } else {
      return midIndex;
    }
  }

  return -1;
};

使用递归实现

const binarySearch1 = (
  arr: number[],
  target: number,
  startIndex?: number,
  endIndex?: number
): number => {
  if (arr.length <= 0) return -1;
  if (startIndex == null) startIndex = 0;
  if (endIndex == null) endIndex = arr.length - 1;

  // 递归结束条件
  if (startIndex > endIndex) return -1;

  const midIndex = Math.floor((startIndex + endIndex) / 2);

  if (target < arr[midIndex]) {
    return binarySearch1(arr, target, startIndex, midIndex - 1);
  } else if (target > arr[midIndex]) {
    return binarySearch1(arr, target, midIndex + 1, endIndex);
  } else {
    return midIndex;
  }
};

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

  • 有一个数组[1,2,4,7,11,15]
  • 数组中有两个数,和是n,即4 + 11 = 15

常规思路

  • 嵌套循环,找到一个数,然后去遍历下一个数,求和,判断
  • 时间复杂度是O(n ^ 2),不可用
const findTwoNumbers = (arr: number[], n: number): number[] => {
  const res: number[] = [];
  if (arr.length === 0) return res;

  for (let i = 0; i < arr.length; i++) {
    const n1 = arr[i];
    let flag = false;

    for (let j = i + 1; j < arr.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)

  • 定义i指向头,j指向尾,求arr[i] + arr[j]
  • 如果大于n,则j需要向前移动
  • 如果小于n,则i需要向后移动
const findTwoNumbers1 = (arr: number[], n: number): number[] => {
  const res: number[] = [];
  if (arr.length === 0) return res;

  let i = 0;
  let j = arr.length - 1;

  while (i < j) {
    let sum = arr[i] + arr[j];

    if (sum > n) {
      j--;
    } else if (sum < n) {
      i++;
    } else {
      res.push(arr[i]);
      res.push(arr[j]);

      break;
    }
  }

  return res;
};

8.求一个二叉树的第k小值

二叉树概念

  • 是一棵树
  • 每个节点,最多只能有2个子节点
  • 树节点的数据结构{ value , left? , right? }
// 树节点的数据结构
interface ITreeNode {
  value: number;
  left: ITreeNode | null;
  right: ITreeNode | null
}

二叉树的遍历

  • 前序遍历: root → left → right
  • 中序遍历: left → root → right
  • 后序遍历: left → right → root
interface ITreeNode {
  value: number;
  left: ITreeNode | null;
  right: ITreeNode | null;
}

/**
 * 前序遍历
 * @param node
 */
const prevOrderTraverse = (node: ITreeNode | null) => {
  if (node?.value == null) return;

  // root
  console.log(node?.value);
  // left
  prevOrderTraverse(node?.left);
  // right
  prevOrderTraverse(node?.right);
};

/**
 * 中序遍历
 * @param node
 */
const inOrderTraverse = (node: ITreeNode | null) => {
  if (node?.value == null) return;

  // left
  inOrderTraverse(node?.left);
  // root
  console.log(node?.value);
  // right
  inOrderTraverse(node?.right);
};

/**
 * 后序遍历
 * @param node 
 */
const postOrderTraverse = (node: ITreeNode | null) => {
  if (node?.value == null) return;

  // left
  postOrderTraverse(node?.left);
  // right
  postOrderTraverse(node?.right);
  // root
  console.log(node?.value);
};

const tree: ITreeNode = {
  value: 5,
  left: {
    value: 3,
    left: {
      value: 2,
      left: null,
      right: null,
    },
    right: {
      value: 4,
      left: null,
      right: null,
    },
  },
  right: {
    value: 7,
    left: {
      value: 6,
      left: null,
      right: null,
    },
    right: {
      value: 8,
      left: null,
      right: null,
    },
  },
};

// prevOrderTraverse(tree);
// inOrderTraverse(tree);
postOrderTraverse(tree);

二叉搜索树BST(Binary Search Tree)

  • left(包括其后代) value <= root value
  • right(包括其后代) value >= root value
  • 这样可以使用二分查找快速查找值

了解了一些二叉树的概念后,现在看一看题目:求一个二叉树的第k小值,解题思路:

  • BST中序遍历,即从小到大的排序
  • 找到排序后的第k值即可
interface ITreeNode {
  value: number;
  left: ITreeNode | null;
  right: ITreeNode | null;
}

const res: number[] = [];
/**
 * 中序遍历
 * @param node
 */
const inOrderTraverse = (node: ITreeNode | null) => {
  if (node?.value == null) return;

  // left
  inOrderTraverse(node?.left);
  // root
  res.push(node.value);
  // right
  inOrderTraverse(node?.right);
};

/**
 * 获取二叉搜索树的第k小值
 * @param k
 * @returns value
 */
const getKthValue = (k: number): number | null => {
  inOrderTraverse(tree);
  console.log(res);
  return res[k];
};

const tree: ITreeNode = {
  value: 5,
  left: {
    value: 3,
    left: {
      value: 2,
      left: null,
      right: null,
    },
    right: {
      value: 4,
      left: null,
      right: null,
    },
  },
  right: {
    value: 7,
    left: {
      value: 6,
      left: null,
      right: null,
    },
    right: {
      value: 8,
      left: null,
      right: null,
    },
  },
};

// prevOrderTraverse(tree);
// inOrderTraverse(tree);
// postOrderTraverse(tree);

console.log(getKthValue(2));

9.堆有什么特点,和二叉树有什么关系

堆栈模型

  • js代码执行时
  • 值类型变量存储在栈
  • 引用类型变量存储在堆

  • 完全二叉树
  • 最大堆: 父节点 >= 子节点
  • 最小堆: 父节点 <= 子节点

和二叉树的关系

  • 查询比BST慢
  • 增删比BST快,维持平衡更快
  • 但整体的时间复杂度都在O(logn)级别,即树的高度

10.求斐波那契数列的第n值

  • 用js计算斐波那契数列的第n个值
  • 注意时间复杂度

思路1,递归:

直接使用公式: f(n) = f(n - 1) + f(n + 1)

const fibonacci = (n: number): number => {
  if (n <= 0) return 0;
  if (n === 1) return 1;

  return fibonacci(n - 1) + fibonacci(n - 2);
};

console.log(fibonacci(9)); // 34

但如果使用递归,会出现大量的重复计算,导致时间复杂度达到了O(2 ^ n),基本不可用,所以需要对时间复杂度进行优化:

-不用递归,用循环,记录中间结果,时间复杂度为O(n)

const fibonacci = (n: number): number => {
  if (n <= 0) return 0;
  if (n === 1) return 1;

  // 记录 n - 1的结果
  let n1 = 1;
  // 记录 n - 2的结果
  let n2 = 0;
  let res = 0;

  for (let i = 2; i <= n; i++) {
    res = n1 + n2;

    // 记录中间结果
    n2 = n1;
    n1 = res;
  }

  return res;
};

动态规划

  • 把一个大问题,拆解为多个小问题,逐级向下拆解
  • 用递归的思路去分析问题,再改为循环来实现
  • 算法三大思维:贪心,二分,动态规划

11.将数组中的0移动到末尾

  • 如输入[1, 0, 3, 0, 11, 0],输出[1, 3, 11, 0, 0, 0]
  • 只移动0,其它顺序不变
  • 必须在原数组进行操作

传统思路:

  • 遍历数组,遇到0则push到数组末尾
  • 用splice截取掉当前元素
  • 时间复杂度是O(n ^ 2),算法不可用
const arr: number[] = [1, 0, 0, 0, 3, 0, 11, 0];

const moveZero = (arr: number[]): void => {
  if (arr.length === 0) return;

  let zeroLength = 0;

  for (let i = 0; i < arr.length - zeroLength; i++) {
    if (arr[i] === 0) {
      arr.push(arr.splice(i, 1)[0]);

      i--;
      zeroLength++;
    }
  }
};
moveZero(arr);
console.log(arr);

使用双指针进行优化:

  • 定义j指向第一个0, i指向j后面的第一个非0
  • 交换i和j的值,继续向后移动
  • 只遍历一次,所以时间复杂度是O(n)
const arr: number[] = [1, 0, 0, 12, 0, 3, 0, 11, 0];

const moveZero = (arr: number[]): void => {
  if (arr.length === 0) return;

  let i: number;
  let j: number = -1;

  for (i = 0; i < arr.length; i++) {
    if (arr[i] === 0) {
      // 让j指向第一个0
      if (j < 0) {
        j = i;
      }
    }

    if (arr[i] !== 0 && j >= 0) {
      const n = arr[i];
      arr[i] = arr[j];
      arr[j] = n;

      j++;
    }
  }
};

moveZero(arr);
console.log(arr);

12.字符串中连续最多的字符以及次数

  • 如输入“abbcccddeeee1234”计算后得到连续最多的字符是e,连续出现4次

传统思路:

  • 嵌套循环,找出每个字符的连接次数,并记录
  • 看似时间复杂度是O(n ^ 2),但实际时间复杂度是O(n),因为有"跳步"
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值