数据结构和算法小结

183 阅读11分钟

数据结构和算法小结

算法

双指针

双指针法基本用于处理字符串、数组和链表相关问题。

两个指针起始位置可以处在不同的位置,移动的规则也不同。

  • 一个指向开头一个指向结尾。可以解决反转字符串类似的问题。

  • 都指向开头,但是移动的速度不同,一个每次前进一步,另一个每次前进两步(快慢指针)。

    示例

    let slow = 0;
    let fast = 0;
    
    while (fast < len) {
      ...
    
      fast += 2;
      slow++;
    }
    
  • 都指向开头,一个每次前进一步,另一个按照判断逻辑是否移动指针(快慢指针)。

    示例

    let slow = 0;
    let fast = 0;
    
    while (i < len) {
      if (xxx) {
        ...
        slow++;
      }
    
      fast++;
    }
    
  • 一个指向开头,一个指向后面的节点,根据判断逻辑移动指针(滑动窗口)。

    示例

    let left = 0;
    let right = 1;
    
    while (right < length) {
      // 增大窗口
      right++;
    
      while (xx) {
        // 缩小窗口
        left++;
      }
    }
    

递归

递归一般处理重复性的问题。

将处理的问题的逻辑封装好,然后进行重复的调用。

示例

function recursion() {
  // 重复性逻辑
  ...

  // 避免死循环
  if (xxx) {
    recursion();
  }
}

回溯

回溯是递归的过程中增加一步回撤的逻辑。

可以用于处理类似二叉树所有路径排列组合等问题。

示例

const paths = []
function recursion() {
  // 重复性逻辑
  ...

  // 避免死循环
  if (xxx) {
    recursion();
  }
  // 回撤逻辑
  ...
}

贪心

以局部最优推算全局最优。难点在于找出什么是局部最优

同时要考虑后面的局部决策,不会被前面的影响。

可以用于处理类似分发饼干等问题。

动态规划

动态规划每一步的计算逻辑,依赖于上一步的计算逻辑,即最终的问题,是由 N 多个重叠的子问题构成。

动态规划问题的解题步骤

  1. 先建立一个 dp (Dynamic Programming)数组。一般为二维数组,有些问题可以优化空间至一维数组。
  2. 建立推导公。即如何根据上一步的状态,计算下一步的状态的逻辑。
  3. 初始化 dp 数组。这样才能开始进行初始的推导。
  4. 确定循环顺序。 4.1 有些问题可以优化二维数组为一维数组。就需要从后往前遍历,因为从后往前可以拿到上一次的运算结果,如果从前往后就会覆盖之前的运算结果。 4.2 如果循环是选择物品,从后往前遍历,可以保证物品被使用一次。如果从前往后,则前面的计算结果可能是已经使用过该商品,后续的计算如果用到了这个计算结果,则该物品就会被多次使用。

可以用于处理类似打家劫舍买卖股票的最佳时机等问题。

KMP

最长公共前后缀。前缀不包含最后一个字符,后缀不包含第一个字符。

例如 aabaa

字符最长公共前后缀最长公共前后缀长度
aaa1
aab''0
aabaa1
aabaaaa2

可以用于处理类似 模式串匹配 等问题。

求字符串最长公共前后缀

function getPrefixSuffixMaxResults(str) {
  const results = [0];
  const len = str.length;
  let i = 1;
  let j = 0;

  while (i < len) {
    if (&& j > 0 && str[i] !== str[j]) {
      // results 表示最长公共前后缀个数
      // 如果 str[i] !== str[j], 则找 第 j - 1 个字符的最长公共前后缀个数 results[j - 1]
      // 因为 results 表示最长公共前后缀个数,所以直接取 results[j - 1] 就是需要再次比较的下标
      // 可得逻辑  j = results[j - 1];
      j = results[j - 1];
      continue;
    }

    if (str[i] === str[j]) {
      j++;
    }

    i++;
    results.push(j);
  }

  return results;
}

小顶堆

小顶堆的定义

  • 一个完全二叉树
  • 二叉树上的任意节点值都必须小于等于其左右子节点值(大顶堆是大于等于其左右子节点值)

因为是完全二叉树,使用数组表示小顶堆,这样处理起来更方便。

graph TB

1((1))
2((2))
3((3))
8((8))
7((7))
16((16))
4((4))

1--left-->2
1--right-->3
2--left-->8
2--right-->7
3--left-->16
3--right-->4

[1, 2, 3, 8, 7, 16, 4]

可以用于处理类似 前 K 个高频元素 等问题。

小顶堆实现

class Heap {
  // 完全二叉树在数组中满足以下特点
  // 某节点下标 i, left 节点下标为 i *  2,right 节点下标为 i *  2 + 1
  heap = [null]; // 初始为 [null] 是为了方便计算

  push(v) {
    const heap = this.heap;

    heap.push(v);

    // 上浮元素
    this.up();
  }

  pop() {
    const heap = this.heap;
    const top = heap[1];

    // 下沉元素
    this.down();

    return top;
  }

  size() {
    return this.heap.length - 1;
  }

  up() {
    const heap = this.heap;
    const len = this.heap.length;

    let idx = len - 1;
    let parentIdx = Math.floor(idx / 2);

    while (parentIdx > 0 && heap[parentIdx] > heap[idx]) {
      this.swap(idx, parentIdx);

      idx = parentIdx;
      parentIdx = Math.floor(idx / 2);
    }
  }

  down() {
    const heap = this.heap;
    heap[1] = heap.pop();
    const len = this.heap.length;

    let parentIdx = 1;
    let leftIdx = parentIdx * 2;
    let rightIdx = leftIdx + 1;
    let swapIdx = this.getDownSwapIdx(leftIdx, rightIdx);

    while (swapIdx < len && heap[swapIdx] && heap[parentIdx] > heap[swapIdx]) {
      this.swap(parentIdx, swapIdx);

      parentIdx = swapIdx;
      leftIdx = swapIdx * 2;
      rightIdx = leftIdx + 1;
      swapIdx = this.getDownSwapIdx(leftIdx, rightIdx);
    }
  }

  getDownSwapIdx(leftIdx, rightIdx) {
    const heap = this.heap;

    // right 节点不存在则尝试取 left 节点
    if (heap[rightIdx] === undefined) return leftIdx;

    return heap[leftIdx] > heap[rightIdx] ? rightIdx : leftIdx; // 让较小的上浮
  }

  swap(a, b) {
    const heap = this.heap;

    [heap[a], heap[b]] = [heap[b], heap[a]];
  }
}

单调栈

单调栈用于查找数组元素中下一个比它大或小的元素的场景。

以查找数组元素中下一个比它大的元素为例:

栈维护的是未找到比它大的元素的下标,维护下标是为了方便查找元素。

栈的元素顺序是从大到小的顺序,因为在遍历过程中,如果找到了比栈尾大的元素,则让栈中的元素依次出栈。

可以用于处理类似 每日温度 等问题。

求数组元素中下一个比它大的元素

function monotonicStack(array) {
  const len = array.length;
  const results = Array(len).fill(0);
  const stack = [0]; // 单调栈
  let i = 1;

  while (i < len) {
    // 遍历过程中的元素如果大于栈尾的元素,则让栈中的元素依次出栈
    while (stack.length !== 0 && array[stack[stack.length - 1]] < array[i]) {
      const v = stack.pop();
      results[v] = i - v;
    }

    // 将当前元素推入到栈中
    stack.push(i);
    i++;
  }

  return results;
}

数据结构

字符串

示例

const string = "abcd";

[String]((developer.mozilla.org/zh-CN/docs/…) 对象用于表示和操作字符序列。

字符串是一串连续的字符,和数组类似。

可以通过Array.prototype.join()String.prototype.split() 互相转换。

解题技巧

  • 由于字符是有限的,可以建立一个枚举字母数组去辅助解题。
  • 模式串匹配使用KMP 算法
  • 翻转字符串和替换字符串空格使用双指针
  • 找所有字母异位词使用散列表滑动窗口的组合

leetcode 题目

344. 反转字符串 - 双指针

1002. 查找共用字符 - 可以借助枚举字符解决

拓展

枚举字母数组

// 这里储存的是字符, 下标和具体字符是可以互相转换的。
xdcArray.from(
  {
    length: 26,
  },
  (_, idx) => String.fromCharCode(idx + 97)
);

数组

示例

const array = ["a", "b", "c", "d"];

Array 对象支持在单个变量名下存储多个元素,并具有执行常见数组操作的成员。

在进行数组操作时,应尽量避免使用Array.prototype.shift()Array.prototype.unshift()

因为数组是一段连续的空间,所以操作数组前面的元素,整体的元素都会向前或向后移动。

另外就是尽量全填充数组。否则访问未初始化的元素,会访问原型链,带来额外的访问消耗。或者形成慢数组。(参考)

解题技巧

  • 可以对数组进行排序去辅助解题
  • 前 K 个高频数字使用小顶堆
  • 长度最小的子数组使用滑动窗口
  • 数组内每个元素的右侧或左侧第一个比它大或小的元素使用单调栈

leetcode 题目

704. 二分查找 - 二分法

209. 长度最小的子数组 - 滑动窗口

739. 每日温度 - 单调栈

拓展

数组初始化

// 无法遍历
const a1 = Array(10);
// 可以遍历
const a2 = Array(10).fill();
const a3 = Array.from({ length: 10 });

// [] 为同一个数组引用,应避免使用
const a4 = Array(10).fill([]);
// 推荐使用
const a5 = Array.from({ length: 10 }, () => []);

链表

示例

const a = {
  val: "a",
  next: null,
};

const b = {
  val: "a",
  next: null,
};

const c = {
  val: "a",
  next: null,
};

a.next = b;
b.next = c;

const links = a;
graph LR

a((a))
b((b))
c((c))
d((d))

a--next-->b
b--next-->c
c--next-->d

链表 是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针(Pointer)。

链表不同于数组的是,数组使用的是连续的空间。链表使用 next 指针指向下一个节点。

因此链表在对节点的增删的性能,优于数组。更适用于做频繁删除插入的场景。

解题技巧

  • 一般问题,可以先声明三个变量辅助解决问题。 precurrentnext,然后通过循环,不断地更新这三个节点。
  • 两个链表比较 A 、 B,可以对他们做拼接(A-> B->A),辅助解题。
  • 判断链表是否有环可以使用快慢指针。

leetcode 题目

206. 反转链表

106. 链表相交

142. 环形链表 II - 快慢指针

拓展

链表有环推导过程

// 两个指针去遍历链表,一个一次走一步(a = node?.next),一个一次走两步(b = node?.next?.next)
let x; // 链表头到环的起点节点距离
let y; // 环的起点到相遇的节点距离
let z; // 相遇节点到环的起点的距离(向后的距离)

// 快指针是慢指针速度的两倍,则走的距离同样是两倍。可得下面的等式
2(x + y) = x + y + z + y // 下面一步一步简化
2x + 2y = x + 2y + z
2x = x + z
x = z // 所以  链表头到环的起点节点距离 === 相遇节点到环的起点的距离(向后的距离)

散列表

示例

const hashTable = {
  a: "a",
  b: "b",
  c: "c",
};

散列表(Hash table,也叫哈希表),是根据键(Key)而直接访问在内存储存位置的数据结构。也就是说,它通过计算出一个键值的函数,将所需查询的数据映射到表中一个位置来让人访问,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表。

散列表相对于数组、链表、树等数据结构,它对于节点的插入和删除做到了O1的程度。

散列表储存数据是以 keyvalue的形式。底层依赖还是数组,具体的储存位置,会有一个散列函数,将 key 转化为数组中具体的储存位置。

散列表的储存空间被填满时或遇到散列函数计算的值冲突时,性能就会下降很多。

解题技巧

一般问题给出的数据结构不是散列表,这种数据结构可以进行辅助解题。例如在遍历过程中,记录每一个节点的信息。

leetcode 题目

1. 两数之和

349. 两个数组的交集

二叉树

示例

const tree = {
  val: "a",
  left: {
    val: "b",
    left: {
      val: "c",
      left: null,
      right: null,
    },
    right: null,
  },
  right: {
    val: "c",
    left: null,
    right: null,
  },
};
graph TB

a((a))
b((b))
c((c))

a--left-->b
a--right-->c

二叉树 是每个节点最多只有两个分支(即不存在分支度大于 2 的节点)的树结构。通常分支被称作“左子树”或“右子树”。

数组和链表数据结构,都有各自的优点。数组便于搜索,链表便于插入和删除。二叉树是为了即便于搜索,又便于插入、删除,而建立的一种数据结构。

但并不是所有二叉树都满足上述的优点,需要二叉树的节点满足某种规则才能具备。例如平衡状态(左右两个子树的高度差的绝对值不超过 1)下的二叉树搜索树(二叉树的一种形式,其左侧节点均小于根节点,右侧节点均大于根节点)。

解题技巧

  • 遍历二叉树的方式有很多种,前中后序遍历、层级有限、深度遍历,根据需要选择合适的方式。
  • 后序遍历,可以根据前序遍历稍作变化(中左右 改为 中右左)反转得到(中右左 反转为 左右中)。
  • 二叉树路径所有路径可以使用回溯。
  • 根据二叉树的后序和中序遍历结果,构造二叉树。可以根据后序最后一个节点(root 节点)将中序数组切割成左右两个数组,再根据中序数组将后序数组切割成左右两个数组。反复上述过程。
  • 根据二叉树的前序和中序遍历结果,构造二叉树。可以根据前序第一个节点(root 节点)将中序数组切割成左右两个数组,再根据中序数组将前序数组切割成左右两个数组。反复上述过程。

leetcode 题目

144. 二叉树的前序遍历

94.二叉树的中序遍历

145. 二叉树的后序遍历

拓展

遍历二叉树模板

let pre = null;

function recursionTree(root) {
  if (!root) return root;

  const left = recursionTree(root.left);

  if (pre !== null) {
    // 在前序遍历过程中
    // 这里的逻辑可以拿到 上一个节点pre 和 当前节点root 执行具体逻辑
    xxx;
  }

  pre = root;

  const right = recursionTree(root.right);

  // 这里可以拿到左右两侧遍历结果, 根据逻辑需要返回需要的节点
  return xxxxx ? left : right;
}

左叶子节点

左叶子节点指的是符合 node.left && node.left.left === null && node.left.right === null 的节点。