常用算法

204 阅读32分钟

第一章 如何衡量算法质量

每一位算法初学者都要掌握一个技能,即善于运用时间复杂度和空间复杂度来衡量一个算法的运行效率。

所谓算法,即解决问题的方法。同一个问题,使用不同的算法,虽然得到的结果相同,但耗费的时间和资源肯定有所差异。就比如拧一个螺母,扳手和钳子都可以胜任,但使用钳子拧螺母肯定没有扳手的效率高。

image.png

这也就意味着,如果解决问题的算法有多种,我们就需要从中选出最好的那一个。那么,如何判断哪个算法更好(或者更优)呢?

1. “好”算法的标准

解决一个问题的方法可能有很多种,但能称得上算法的,首先它必须能彻底解决这个问题(准确性),且根据其编写出的程序在任何情况下都不能崩溃(健壮性)。

注意,程序和算法是完全不同的概念。算法是解决某个问题的想法和思路;而程序是根据算法编写出的真正可以运行的代码

在满足准确性和健壮性的基础上,还有一个重要的筛选条件,即通过算法所编写出的程序的运行效率。程序的运行效率具体可以从2个方面衡量,分别为:

  • 程序的运行时间
  • 程序运行时所需内存空间的大小

当运行时间更短、运行期间占用的内存更少时,该算法的运行效率就更高,算法也就更优。

数据结构中,用时间复杂度来衡量程序运行时间的多少;用空间复杂度来衡量程序运行所需内存空间的大小。

2. 时间复杂度

通常会用一个估值来表示算法所编程序的运行时间。所谓估值,即估计的、并不准确的值。虽然估值无法准确的表示算法所编程序的运行时间,但它的得来并非凭空揣测,需要经过缜密的计算后才能得出。

也就是说,表示一个算法所编程序运行时间的多少,用的并不是准确值(事实上也无法得出),而是根据合理方法得到的预估值。

3. 空间复杂度

每一个算法所编写的程序,运行过程中都需要占用大小不等的存储空间,例如:

  • 程序代码本身所占用的存储空间
  • 程序中如果需要输入输出数据,也会占用一定的存储空间
  • 程序在运行过程中,可能还需要临时申请更多的存储空间

首先,程序自身所占用的存储空间取决于其包含的代码量,如果要压缩这部分存储空间,就要求我们在实现功能的同时,尽可能编写足够短的代码。

程序运行过程中输入输出的数据,往往由要解决的问题而定,即便所用算法不同,程序输入输出所占用的存储空间也是相近的。

事实上,对算法的空间复杂度影响最大的,往往是程序运行过程中所申请的临时存储空间。不同的算法所编写出的程序,其运行时申请的临时存储空间通常会有较大不同。

4. Big O notation

计算算法运行时间的估值方法有很多,目前最常用的有“大O”表示法。

O(频度)O(频度)

常见到大O复杂度有以下几种:

  • O(1):常数复杂度
  • O(log n):对数复杂度
  • O(n):线性复杂度
  • O(n^2):平方复杂度
  • O(n^3):立方复杂度
  • O(2^n):指数复杂度
  • O(n!):阶乘复杂度

常用的大O复杂度之间的大小关系如下:

O(1)<O(logn)<O(n)<O(n2)<O(n3)<O(2n)<O(n!)O(1) < O(logn) < O(n) < O(n^2) < O(n^3) < O(2^n) < O(n!)

举几个常用的算法的复杂度:

  • 二分查找:O(logn)
  • 二叉树的遍历:O(n)
  • 排序:O(nlogn)

image.png

要尽量控制复杂度的级别在O(1),O(logn),O(nlogn),O(n)中

第二章 算法理论基础与实战

1. 二分查找 🔍

二分查找是计算机科学中最基本、最有用的算法之一。 它描述了在有序集合中搜索特定值的过程。

  • Target:目标,要查找的值
  • Index:索引,要查找的当前位置
  • Left、Right:左、右指示符,用来维持查找空间的指标
  • Middle:中间指示符,确定应该向左查找还是向右查找的索引

在最简单的形式中,二分查找对具有指定左索引和右索引的连续序列进行操作。这就是所谓的查找空间。二分查找维护查找空间的左、右和中间指示符,并比较查找值与目标之间的关系。如果条件不满足/值不相等,则清除目标不可能存在的那一半,并在剩下的一半上继续查找,直到找到/遍历完集合为止。如果查找以空的一半结束,则无法满足条件,并且无法找到目标。

二分查找大致分为以下两个模版:

  • 模版1:左闭右闭型[left,right]

    • 初始条件:left = 0, right = nums.length - 1
    • 循环条件:left <= right
    • 向左查找:right = mid - 1
    • 向右查找:left = mid + 1
  • 模版2:左闭右开型[left,right)

    • 初始条件:left = 0, right = nums.length
    • 循环条件:left < right
    • 向左查找:right = mid
    • 向右查找:left = mid+1

使用二分查找的前提是集合是有序的,升序排列或者降序排列均可

image.png

第一种方式:左闭右闭[left,right]

🤔解题步骤:

1️⃣创建完整的查找区间[0,nums.length-1]

2️⃣确定循环条件left <= right

3️⃣取中间值进行判断Math.floor((left + right) / 2)

4️⃣中间值比目标值大,向左缩小区间right = middle - 1;中间值比目标值小,向右缩小区间left = middle + 1

完整代码如下💻:

var search = function(nums, target) {
  let left = 0,right = nums.length - 1;

  while(left <= right){
      const middle = Math.floor((left + right) / 2);
      if(nums[middle] === target){
          return middle;
      }else if(nums[middle] < target){
          left = middle + 1;
      }else{
          right = middle - 1;
      }
  }
  return -1;
};

第二种方式:左闭右开[left,right)

🤔解题步骤:

1️⃣创建完整的查找区间[0,nums.length)

2️⃣确定循环条件left < right

3️⃣取中间值进行判断Math.floor((left + right) / 2)

4️⃣中间值比目标值大,向左缩小区间right = middle;中间值比目标值小,向右缩小区间left = middle + 1

完整代码如下🔍:

var search = function(nums, target) {
  let left = 0,right = nums.length;
  
  while(left < right){
    const middle = Math.floor((left + right) / 2);
    if(nums[middle] === target){
      return middle;
    }else if(nums[middle] < target){
      left = middle + 1;
    }else{
      right = middle;
    }
  }
  return -1;
};

image.png

  • 查找target开始位置:第一个大于或等于target的位置(leftIdx)
  • 查找target结束位置:第一个大于target的位置减1(rightIdx)

定义一个binarySearch(nums, target, lower)函数表示在nums数组中二分查找target的位置,如果lower为true,则查找第一个大于等于target的下标;否则查找第一个大于target的下标。

🤔解题步骤:

1️⃣创建完整的查找区间[0,nums.length-1]

2️⃣确定循环条件left <= right

3️⃣取中间值进行判断Math.floor((left + right) / 2)

4️⃣中间值比目标值大,向左缩小区间right = middle - 1;中间值比目标值小,向右缩小区间left = middle + 1

完整代码如下🔍:

var searchRange = function (nums, target) {
    // 在数组中寻找第一个大于等于target的下标
    let leftIndex = indexSearch(nums,target,true); 
    // 在数组中寻找第一个大于target的下标,然后将下标减1
    let rightIndex = indexSearch(nums,target, false) - 1;
    if(
        leftIndex <= rightIndex &&
        rightIndex < nums.length &&
        nums[leftIndex] === nums[rightIndex]
    ){
        return [leftIndex,rightIndex];
    }else{
        return [-1,-1];
    }
};
// 辅助函数
var indexSearch = function (nums, target,lower) {
    let left = 0
    let right = nums.length - 1;
    let ans = nums.length;

    while (left <= right) {
        const middle = Math.floor((left + right) / 2);
        if (lower && nums[mid] >= target || nums[mid] > target) {
            right = mid - 1;
            ans = mid;
        } else {
            left = mid + 1;
        }
    }
    return ans;
}

适用场景

二分查找针对的是有序结合的查找算法。前提条件是有序的数据结合,分为递增、递减。

二分查找适用场景分为以下几类:

  • 有序不重复的数组中查找=== target的元素;
  • 有序重复的数组中查找=== target第一个元素;
  • 有序重复的数组中查找=== target最后一个元素;
  • 有序不重复的数组中查找>= target第一个元素;
  • 有序不重复的数组中查找<= target最后一个元素;
  • 循环有序的数组中查找=== target的元素;

时间复杂度为O(logn)

2. 模拟行为 ♦️

模拟行为一般不涉及算法,单纯考察模拟过程,但是十分考察对代码的掌控能力。

模拟行为一般分为顺时针和逆时针,其中顺时针画矩阵的过程如下:

  • 👉填充上行从左到右
  • 👇填充右列从上到下
  • 👈填充下行从右到左
  • 👆填充左列从下到上

每填充一条边都要坚持一致的左闭右开[left,right),或者左开右闭(left,right]的原则,最后由外向内逐圈循环进行。

对于圈数要根据当前矩阵的行数/列数进行判断,偶数行则不存在未循环到的单元格;奇数行则需要处理未循环到的单元格。

image.png

🤔首先确定每条边处理规则左闭右开还是左开右闭,其次确定循环圈数及是否需要处理中间单元格,然后需要定义x轴和y轴起始坐标,最后确定循环条件及每次循环结束变量如何变化

🤔解题流程:

1️⃣声明需要用的变量:x起点、y起点、单条处理规则的限制条件、填充数据初始值、存放结果的二维数组startX,startY,offset,count,res

2️⃣确定旋转圈数及中间值loop,mid

3️⃣确定循环条件loop>0及控制循环变量变化规则loop--``startX++``startY++``offset += 2

4️⃣利用for循环处理每行每列单元格

5️⃣如果为奇数行,需要处理中间单元格

时间复杂度如下⌚️🔍:

  • 时间复杂度O(n^2)
  • 空间复杂度O(1)

完整代码如下💻:

var generateMatrix = function(n) {
    // 横坐标开始位置、纵坐标开始位置、左闭右开模式,每次不处理每行最后一个单元格、从1开始计数
    let startX = 0,startY = 0,offset = 1,count = 1;
    // 旋转圈数、中间值
    let loop = Math.floor(n/2),mid = Math.floor(n/2);
    // 二维数组存放结果
    let res = Array(n).fill(0).map(()=> new Array(n).fill(0));

    while(loop--){
        let row = startX; // 行
        let clo = startY; // 列
        // 从左到右
        for(;clo < startY+n-offset;clo++){
            res[row][clo] = count ++;
        }
        // 从上到下
        for(;row < startX+n-offset;row++){
            res[row][clo] = count ++;
        }
        // 从右到左
        for(;clo > startY;clo--){
            res[row][clo] = count ++;
        }
        // 从下到上
        for(;row > startX;row--){
            res[row][clo] = count ++;
        }

        startX++;
        startY++;
        offset += 2;
    }
    if(n % 2 === 1){ // 奇数列单独处理中间单元格
        res[mid][mid] = count;
    }
    return res;
};

image.png

🤔:由于rows和columns不一定相等,所以可以改变每条边的遍历规则,从而保证不遗漏单元格,且不用特殊处理中间单元格的值。

image.png

完整代码如下🔍:

var spiralOrder = function(matrix) {
    if (!matrix.length || !matrix[0].length) {
        return [];
    }

    const rows = matrix.length, columns = matrix[0].length;
    const order = [];
    let left = 0, right = columns - 1, top = 0, bottom = rows - 1;
    while (left <= right && top <= bottom) {
        for (let column = left; column <= right; column++) {
            order.push(matrix[top][column]);
        }
        for (let row = top + 1; row <= bottom; row++) {
            order.push(matrix[row][right]);
        }
        if (left < right && top < bottom) {
            for (let column = right - 1; column > left; column--) {
                order.push(matrix[bottom][column]);
            }
            for (let row = bottom; row > top; row--) {
                order.push(matrix[row][left]);
            }
        }
    [left, right, top, bottom] = [left + 1, right - 1, top + 1, bottom - 1];
    }
    return order;
};

3. 双指针 ➡️

双指针法(快慢指针法):一个快指针和一个慢指针在一个for循环下完成两个for循环的工作。

  • 同步指针:使用两个指针进行数组的迭代,指针的变换是同步的,但是方向是相反的
  • 快慢指针:
    • 快指针:寻找新数组的元素 ,新数组就是不含有目标元素的数组
    • 慢指针:指向更新新数组下标的位置

image.png

image.png

🤔解题流程:

1️⃣定义快慢指针

  • 快指针:寻找新数组的元素,新数组就是不含有目标元素的数组fast=0
  • 慢指针:指向更新新数组下标的位置slow=0

2️⃣利用快指针循环nums数组

3️⃣如果当前快指针指向元素不是目标元素,就将快指针指向的元素值赋值给慢指针指向的元素,并将慢指针向前移动一步

4️⃣慢指针的下标即为新数组的长度

完整代码如下💻:

var removeElement = function(nums, val) {
    let slow = 0;
    for(let fast=0;fast<nums.length;fast++){
        if(nums[fast] !== val){
            nums[slow] = nums[fast];
            slow++;
        }
    }
    return slow;
};

image.png

🤔解题流程:

1️⃣定义快慢指针

  • 快指针:快指针指向待处理序列的头部fast=1
  • 慢指针:慢指针指向当前已经处理好的序列的尾部slow=0

2️⃣利用快指针循环nums数组

3️⃣如果快指针指向非0,慢指针指向0,则交换两个指针指向的数据。并且慢指针向前走一步

4️⃣如果慢指针指向非零,则慢指针向前走一步,如数组[1,0,1]

完整代码如下💻:

var moveZeroes = function(nums) {
    let slow = 0;

    for(let fast=1;fast<nums.length;fast++){
        if(nums[slow] === 0 && nums[fast] !== 0){
            [nums[slow],nums[fast]] = [nums[fast],nums[slow]];
            slow++;
        }else if(nums[slow] !== 0){
            slow++;
        }
    }
};

适用场景

  • 链表:
    • 获取链表倒数第K个元素:快慢指针
    • 获取链表中间位置元素:快慢指针
    • 判断链表是否有环:快慢指针
    • 获取两个链表相交节点:同向指针
  • 数组:
    • 原地移除数组元素:同向指针
    • 有序数组的两数之和:反向扫描
    • 合并两个数组:同向指针
  • 字符串:
    • 反转字符串:反向扫描
    • 判断回文字符串:反向扫描

时间复杂度为O(n)

4. 滑动窗口 🪟

所谓滑动窗口,就是不断的调节子序列的起始位置和终止位置,从而得到最终结果。

滑动窗口用于求解满足某种条件的某段连续区间的最短或最长子序列(一般为子数组、子字符串等)。

滑动窗口一般具有两个指针,一个指向起始位置,一个指向终止位置。用单层for循环遍历数组并不断调整两个指针的位置。

image.png

适用场景

  • 固定窗口:
    • 数组中固定长度的满足要求的子序列,如“长度为K的无重复字符子串”
  • 非固定窗口:
    • 数组中的最大或者最小子序列,如“最大连续子数组”、“最小覆盖子串”
    • 计数类问题,如“和为K的子数组个数”、“所有字母异位词”

时间复杂度为O(n)

实现滑动窗口,主要确定如下三点:

  • 窗口内是什么
  • 如何移动窗口的起始位置
  • 如何移动窗口的结束位置

滑动窗口题目一般分为两类:固定窗口大小和不固定窗口大小

  • 固定窗口大小为k:
    • 从开始位置0初始化固定窗口:数组[0,k)的值
    • for循环数组时窗口两端分别为:start=k-1,end=k
    • 窗口滑动滑动过程两端变化为:end++,start++
    • 窗口滑动过程中窗口值的变化为:去除start对应值,增加end对应的值
  • 不固定窗口大小:
    • 初始化窗口:start=0/极大值/极小值
    • for循环数组时窗口两端分别为:start=0,end=0
    • 窗口滑动滑动过程两端变化为:end++,start++
    • 窗口滑动过程中窗口值的变化为:去除start对应值,增加end对应的值

image.png

🤔解题思路:

1️⃣确定窗口起始位置和结束位置:起始位置start=0,结束位置end为for循环索引

2️⃣确定窗口:满足和sum ≥ s的长度最小的连续子数组

3️⃣确定窗口的起始位置如何移动:当前窗口的值sum > s,窗口就要向前移动(即缩小窗口求最小长度)

4️⃣确定窗口的结束位置如何移动:窗口的结束位置就是遍历数组的指针,也就是for循环里的索引

image.png

var minSubArrayLen = function(target, nums) {
    let start = 0,sum = 0,res = nums.length + 1;
    
    for(let end = 0;end<nums.length;end++){
        sum += nums[end]; 
        while(sum >= target){
            res = Math.min(res,end - start);
            sum -= nums[start];
            start++;
        }
    }
    return res > nums.length ? 0 : res;
};

image.png

🤔解题思路:

1️⃣确定窗口起始位置和结束位置:起始位置start=0,结束位置end为for循环索引

2️⃣确定需要的字符种类和数量needneedType

3️⃣确定窗口:满足needType === 0时,更新结果,取长度略短的那一个

3️⃣确定窗口的起始位置如何移动:当needType === 0时,需要将start向前移动缩小窗口(求最小子串),并且需要处理needneedType

4️⃣确定窗口的结束位置如何移动:窗口的结束位置就是遍历数组的指针,也就是for循环里的索引

var minWindow = function(s, t) {
    let start = 0;
    // 包含t字符串所有字符的哈希表
    const need = new Map(); 
    for(let c of t){
        need.set(c,(need.get(c) || 0)+1);
    }
    // 需要的字符类型数量
    let needType = need.size;
    let res = '';
    
    for(let end =0;end<s.length;end++){
        let c = s[end];
        // 当右指针指向字符满足要求时
        if(need.has(c)){
            need.set(c,need.get(c) - 1);
            if(need.get(c)===0){
                needType -= 1;
            }
        }
        // 为零表示当前(start,end+1)区间的字符串满足题目要求,需要处理start指针
        while(needType === 0){
            const newRes = s.substring(start,end+1);
            if(!res || newRes.length < res.length){
                res = newRes;
            }
            const c2 = s[start];
            // 剔除start指向的元素,更新need和needType
            if(need.has(c2)){
                need.set(c2,need.get(c2) + 1);
                if(need.get(c2) === 1){
                    needType += 1;
                }
            }
            start++;
        }
    }
    return res;
};

5. KMP算法 📈

KMP算法要解决的问题就是查找一个字符串str2是否在另一个字符串str1中出现过,即str1是否包含str2这个子串。

时间复杂度O(m+n)

KMP算法的关键是找到前缀与后缀的最大匹配长度(也就是next数组)

  • 前缀:不包含最后一个字符的所有以第一个字符开头的连续子串
  • 后缀:不包含第一个字符的所有以最后一个字符结尾的连续子串

如字符串abcabck,以k为例,最长匹配字符数量为3。

image.png

以字符串str1和str2为条件判断是否包含时,很明显下标为10的字符不匹配,根据KMP计算出的最长公共前后缀可以得知,str2需要右移5位才能和str1重新匹配。

image.png

image.png

KMP算法也可以用来判断一个字符串的最长子串。在由重复子串组成的字符串中,最长相等前后缀不包含的子串就是最小重复子串。拿字符串abababab举例,ab就是最小重复单位。

image.png

红色框即为最小子串

求取最长相等前后缀是解题的关键,可以按照如下思路求解:

1️⃣假设next[0] = -1next[1] = 0,即0位置的前缀与后缀最大匹配长度是-1,而1位置的前缀与后缀最大匹配长度是0

2️⃣假设i-1位置的前缀与后缀最大匹配长度为7next[i-1]= 7

3️⃣如果j位置的字符和i-1位置的字符相等,i位置的前缀与后缀最大匹配长度为7+1=8;不相等的话,根据j位置的前缀与后缀最大匹配长度为3,所以i位置的前缀与后缀最大匹配长度为3+1=4

image.png

 * haystack:   aabaabaafa
 * needle:     aabaaf

 * 
 * needle:
 *  前缀:不包含末尾的所有字符串
 *  后缀:不包含开头的所有字符串
 * 
 * 前缀:               后缀:
 *   a,                  f,
 *   aa,                 af,
 *   aab,                aaf,  
 *   aaba,               baaf,
 *   aabaa,              abaaf,
 *   aabaaf, ❌          aabaaf, ❌     这一行不是前缀也不是后缀
 * 
 * 最长相等的前后缀:
 *   a          0  只有一个,0
 *   aa         1  前缀:a。后缀:a
 *   aab        0  前缀:a、aa。后缀:b、ab。
 *   aaba       1  前缀:a、aa、aab。后缀:a、ba、aba。
 *   aabaa      2  前缀:a、aa、aab、aaba。后缀:a、aa、baa、abaa。
 *   aabaaf     0  ....
 *  
 *  next = 【 0,1,0,1,2,0 】,next就是needle的前缀表。
 * 
 *  1. next中的值代表着该子串的最长相等前后缀的长度,
 *  2. 因为数组是从0开始的,该值还表示子串最长相等前后缀的下一项的索引
 * 
 *  例如: next[4] = 2, 其对应的子串是aabaa,前缀和后缀相等的只有a、aa,长度为2。 
 *        needle[2] === b 恰好等于下一项的索引。

image.png

var strStr = function(haystack, needle) {
    if(needle.length === 0) return 0;
    // 前缀表
    let next = new Array(needle.length).fill(0);
    
    for(let i=1,j=0;i<needle.length;i++){
        while(j > 0 && needle[i] !== needle[j]){
            j = next[j - 1];
        }
        if(needle[i] === needle[j]){
            j++;
        }
        next[i] = j;
    }

    for(let i=0,j=0;i<haystack.length;i++){
        while(j > 0 && haystack[i] !== needle[j]){
            j = next[j - 1];
        }
        if(haystack[i] === needle[j]){
            j++;
        }
        if(j === needle.length){
            return i - needle.length + 1;
        }
    }
    return -1;
};

6. 递归&分治 🤕

递归是编程技巧,直接体现在代码上 ,即函数自己调用自己。在调用的函数执行完毕之后,程序会回到产生调用的地方,继续做一些其他事情。其中调用的过程被称作“递归”,返回的过程被称作“回溯”。

分治是一种算法设计的思想,将大问题分解成多个小问题,例如归并排序实现是将大问题“排序整个数组”分解为小问题“排序左半和右半”,绝大部分情况下分治算法是通过递归实现的。

递归函数基于自顶向下拆分问题,再自底向上逐层解决问题的思想设计而成,这是所熟知的分而治之的算法思想。

  1. 分而治之与减而治之

分而治之(Divide-and-Conquer)的思想分为如下三步:

  • 拆分:将原问题拆分成若干个子问题
  • 解决:解决这些子问题
  • 合并:合并子问题的解得到原问题的解

这样的三步恰好与递归的程序写法相吻合:

  • 拆分:即对当前的大问题进行分析,写出相应代码,分解为子问题
  • 解决:即通过递归调用解决子问题
  • 合并:即在回溯的过程中,根据递归返回的结果,对子问题进行合并,得到大问题的解

典型的分治思想的应用是:归并排序、快速排序、绝大多数树中的问题(先把原问题拆分成子树的问题,当子树中的问题解决以后,结合子树求解的结果处理当前结点)、链表中的问题。

分治思想的特例是减治思想(Decrease-and-Conquer):每一步将问题转换成为规模更小的子问题。减治思想的典型应用是二分查找、选择排序、插入排序、快速排序算法。

分治与减治的区别如下:

  • 分治思想:将一个问题拆分成若干个子问题,然后再逐个求解,根据各个子问题得到的结果得到原问题的结果;
  • 减治思想:在拆分子问题的时候,只将原问题转化成一个规模更小的子问题,每一步只解决一个规模更小的子问题,子问题的结果就是上一层原问题的结果,相比较于分治而言,它没有合并的过程
  1. 递归「自顶向下」
// 斐波那契数列
function fib(n){
  if(n === 1 || n === 2){
    return 1;
  }else{
    return fib(n-1)+ fib(n-2);
  }
}
  1. 循环「自底向上」
// 斐波那契数列
function fib(n){
  if(n === 1 || n === 2){
    return 1;
  }
  
  let a = 1,b = 1;
  for(let i = 3;i<=n;i++){
    const sum = a + b;
    a = b;
    b = sum;
  }
  return b;
}
  1. 加入缓存的斐波那契
function fibonacci(n, memo={}) { 
    if (memo[n]) { 
        return memo[n];
    } 
    if (n <= 2) { 
        return 1;
    } 
    return memo[n] = fibonacci(n-1, memo) + fibonacci(n-2, memo);
}

7. 回溯算法 🤔

回溯法也叫做回溯搜索法,它是一种搜索的方式。回溯是递归的副产品,只要有递归就会有回溯。回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案。

回溯法,一般可以解决如下几种问题:

  • 组合问题:N个数按一定规则找出k个数的集合「LeetCode-77」
  • 切割问题:字符串按一定规则有几种切割方式「LeetCode-131」
  • 子集问题:N个数的集合里有多少符合条件的子集「LeetCode-78」
  • 排列问题:N个数按一定规则全排列,有几种排列方式「LeetCode-46」

时间复杂度为O(n×2n)

回溯法解决的问题都可以抽象为树形结构,回溯法解决的都是在集合中递归查找子集,集合的大小即为树的宽度,递归的深度即为树的深度。

for循环横向遍历,递归纵向遍历,回溯不断调整结果集

image.png

回溯算法一般的模板如下几步:

  • 回溯函数模板返回值以及参数
  • 回溯函数终止条件
  • 回溯搜索的遍历过程
void backtracking(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

    for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
        处理节点;
        backtracking(路径,选择列表); // 递归
        回溯,撤销处理结果
    }
}

当题目要求不能有重复子集时,需要在for循环处理节点前增加去重的条件判断‼️

image.png

var subsets = function(nums) {
    const result = [],const path = [];

    var combinHelper = (index)=>{
        result.push([...path]);
        for(let i=index;i<nums.length;i++){
            path.push(nums[i]);
            combinHelper(i+1);
            path.pop();
        }
    }
    
    combinHelper(0);
    return result;
};

image.png

var subsetsWithDup = function(nums) {
    nums.sort((a,b) => a-b);
    const result = [],path = [];
    
    var combinHelper = (index)=>{
        result.push([...path]);
        for(let i=index;i<nums.length;i++){
            // 去重
            if(nums[i] === nums[i-1] && i > index) continue;
            path.push(nums[i]);
            combinHelper(i+1);
            path.pop();
        }
    }
    
    combinHelper(0);
    return result;
};

image.png

var combine = function(n, k) {
    const result = [],path = [];

    var combineHelper = (index)=>{
        if(path.length === k){
            result.push([...path]);
            return;
        }
        for(let i=index;i<=n;i++){
            path.push(i);
            combineHelper(i+1);
            path.pop();
        }
    }
    
    combineHelper(1);
    return result;
};

image.png

var permute = function(nums) {
    const result = [],path = [];

    var combinHelper = (used)=>{
        if(path.length === nums.length){
            result.push([...path]);
            return;
        }
        
        for(let i=0;i<nums.length;i++){
            if(used[nums[i]]){
                continue;
            }
            used[nums[i]] = true;
            path.push(nums[i]);
            combinHelper(used);
            path.pop();
            used[nums[i]] = false;
        }
    }
    combinHelper({});
    return result;
};

8. 贪心算法 🤡

贪心的本质是选择每一阶段的局部最优,从而达到全局最优。贪心算法有时候就是常识性的推导,所以会认为本应该就这么做。

贪心算法一般分为如下四步:

  • 将问题分解为若干个子问题
  • 找出适合的贪心策略
  • 求解每一个子问题的最优解
  • 将局部最优解堆叠成全局最优解

image.png

var maxProfit = function(prices) {
    let ans = 0;
    for(let i=1;i<prices.length;i++){
        if(prices[i] - prices[i-1] > 0){
            ans += prices[i] - prices[i-1]
        }
    }
    return ans;
};

9. 深度优先搜索DFS ⛰️

DFS是优先一个方向去搜,直到遇到绝境了,再换方向(换方向的过程就涉及到了回溯)

深度优先搜索三部曲:

  1. 确认递归函数及参数
  2. 确认递归终止条件
  3. 处理目前搜索节点出发的路径

image.png

🤔:二叉搜索树的中序遍历是递增序列

var isValidBST = function(root) {
    let arr = [];

    const buildArr = (root)=>{
        if(root){
            buildArr(root.left);
            arr.push(root.val);
            buildArr(root.right);
        }
    }
    
    buildArr(root);
    
    for(let i=0;i<arr.length;i++){
        if(arr[i] <= arr[i-1]){
            return false;
        }
    }
    
    return true;
};

路径搜索算法

const data = [    { text: "北京" },    { text: "河北", children: [        { text: "石家庄", children: [            { text: "A区" },            { text: "B区" },            { text: "C区" },        ]},
        { text: "廊坊", children: [
            { text: "C区" },
            { text: "D区" },
            { text: "E区" },
        ]},
    ]},
    { text: "江苏", children: [
        { text: "徐州", children: [
            { text: "F区" },
            { text: "W区" },
            { text: "C区" },
        ]},
        { text: "南京", children: [
            { text: "C区" },
            { text: "F区" },
            { text: "W区" },
        ]},
    ]},
];

function getObjPath(data, key) {
    const res = [];
    const getPathFun = (data, path = "") => {
        for (let obj of data) {
            const newPath = path === "" ? obj.text : path + "-" + obj.text;
            obj.path = newPath;
            if (obj.children) {
                getPathFun(obj.children, newPath);
            } else {
                obj.path.includes(key) && res.push(obj.path);
            }
        }
    };
    getPathFun(data);
    return res;
}
const res = getObjPath(data, "C区");
console.log(res,data);

image.png

10. 广度优先搜索BFS 🌊

BFS是先把本节点所连接的所有节点遍历一遍(同层节点),走到下一个节点的时候,再把连接节点的所有节点遍历一遍,搜索方向更像是广度,四面八方的搜索过程。

image.png

var levelOrder = function(root) {
    let res = [];
    // 队列存放所有节点
    let queue = [root];

    if(root === null){
        return res;
    }

    while(queue.length){
        // 存放每一层的节点
        let curLevel = [];
        let length = queue.length;
        for(let i=0;i < length;i++){
            let node = queue.shift();
            curLevel.push(node.val);
            if(node.left)queue.push(node.left);
            if(node.right)queue.push(node.right)
        }
        // 把每一层的结果放入数组中
        res.push(curLevel);
    }
    return res;
};

11. 设计/模拟场景 📷

image.png

var LRUCache = function(capacity) {
    this.map = new Map();
    this.capacity = capacity;
};

LRUCache.prototype.get = function(key) {
    if(this.map.has(key)){
        let value = this.map.get(key);
        this.map.delete(key);
        this.map.set(key,value);
        return value;
    }else{
        return -1;
    }
};


LRUCache.prototype.put = function(key, value) {
    if(this.map.has(key)){
        this.map.delete(key)
    }
    
    this.map.set(key,value);
    // 循环删除靠前的key-value对
    while(this.map.size > this.capacity){
        this.map.delete(this.map.keys().next().value); // next()迭代器获取指针
    }
};

12. 动态规划 🚗

动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。

将待求解的问题分解成若干个相互联系的子问题,先求解子问题,然后从这些子问题的解得到原问题的解。对于重复出现的子问题,只在第一次时求解,并把答案保存起来,再次遇到时直接引用答案,不必重新求解。

1. 动归五步曲

动态规划一般分为以下步骤:

  1. 确定dp数组及下标的含义
  2. 初始化dp数组
  3. 确定遍历顺序
  4. 确定dp数组的递推公式
  5. 获取或更新结果

2. 路径问题

例题解析

image.png image.png

📗第一步:确定dp数组含义

将路径问题转换为平面坐标系问题,根据行row和列column划分为不同的单元格

  • dp[i][j]:表示从start位置到当前位置的不同路径数量
  • i:表示行,范围为0~row-1
  • j:表示列,范围为0~column-1

📗第二步:初始化dp数组

题目限制只能向下和向右(倒推的话就是只能向左和向上),按照可选方向进行分析:

  • 当前位置只能往下移动:dp[i][0]=1
  • 当前位置只能往右移动:dp[0][j]=1

📗第三步:确定递推公式

假设路径中每一步只能从向下或者向右移动一步,因此要想走到(i,j)位置的话就有两种情况:

  • 由位置(i-1,j)即正上方向下一步
  • 由位置(i,j-1)即正左方向右一步

因此动态规划转移方程:dp[i][j] = dp[i-1][j]+dp[i][j-1]

完整代码如下所示:

var uniquePaths = function(m, n) {
    // 初始化状态变量
    const dp = new Array(m).fill(0).map(item => item = new Array(n));
    // 获取状态初始值
    for(let i=0;i<m;i++){
        dp[i][0] = 1;
    }
    for(let j=0;j<n;j++){
        dp[0][j] = 1;
    }
    // 遍历数组,并根据状态转移要求设置状态
    for(let i=1;i<m;i++){
        for(let j=1;j<n;j++){
            dp[i][j] = dp[i-1][j]+dp[i][j-1];
        }
    }
    return dp[m-1][n-1];
};

image.png image.png

var uniquePathsWithObstacles = function(obstacleGrid) {
    // 定义状态
    const m = obstacleGrid.length;
    const n = obstacleGrid[0].length;
    const dp = Array(m).fill().map(item => Array(n).fill(0));
    // 初始化状态
    for(let i=0;i<m && obstacleGrid[i][0] === 0;i++){
        dp[i][0] = 1;
    }
    for(let j=0;j<n && obstacleGrid[0][j] === 0;j++){
        dp[0][j] = 1;
    }
    // 嵌套循环转移状态
    for(let i=1;i<m;i++){
        for(let j=1;j<n;j++){
            if(obstacleGrid[i][j] === 0){
                dp[i][j] = dp[i-1][j] + dp[i][j-1]
            }else{
                dp[i][j] = 0;
            }
        }
    }
    return dp[m-1][n-1]
};

3. 单串问题

例题解析

image.png image.png

📗第一步:确定dp数组含义

  • dp[i]表示以下标i结尾的最长递增子序列的长度
  • i表示下标,取值范围为0~n-1

📗第二步:初始化dp数组

根据题目获取dp[0]的值,dp[0]= 1

📗第三步:确定递推公式

在计算dp[i]时,已经知道dp[0…i−1]的值,按照题目要求,其状态转移方程一般都会在nums[i]dp[i-1]之间取舍。

var lengthOfLIS = function(nums) {
    const n = nums.length;
    const dp = new Array(n).fill(0);
    dp[0] = 1;
    
    for(let i=1;i<n;i++){
        dp[i] = 1;
        for(let j=0;j<i;j++){
            if(nums[j]<nums[i]){
                dp[i] = Math.max(dp[i],dp[j]+1);
            }
        }
    }
    return Math.max(...dp);
};

image.png image.png

var findNumberOfLIS = function(nums) {
    let n = nums.length,maxLen = 0,ans = 0;
    const dp = new Array(n).fill(0);
    const cnt = new Array(n).fill(0);

    for(let i=0;i<n;i++){
        dp[i] = 1,cnt[i] = 1;
        for(let j=0;j<i;j++){
            if(nums[j] < nums[i]){
                if(dp[i] < dp[j] + 1){
                    dp[i] = dp[j] + 1;
                    cnt[i] = cnt[j];
                }else if(dp[i] === dp[j] + 1){
                    cnt[i] += cnt[j];
                }
            }
        }
        if(dp[i] > maxLen){
            maxLen = dp[i];
            ans = cnt[i];
        }else if(dp[i] === maxLen){
            ans += cnt[i];
        }
    }
    return ans;
};

4. 背包问题

例题解析

image.png image.png

🌲第一步:确定dp数组以及下标的含义

  • j:背包容量大小
  • dp[j]: 容量为j的背包,所背的物品重量最大可以为dp[j]

🌲第二步:确定递推公式

dp[j] = max(dp[j], dp[j - weight[i]] + value[i])

🌲第三步:dp数组初始化

下标为0的dp初始化:

  • 如果nums中没有负数:dp[0] = 0
  • 如果nums中存在负数:dp[0] = -Infinty

下标非0的dp初始化: 0,Infinty,true,''.....等等各种符合条件的值

🌲第四步:确定遍历顺序

  • 如果求组合数就是外层for循环遍历物品,内层for遍历背包(不在乎顺序,组合不能重复)
  • 如果求排列数就是外层for遍历背包,内层for循环遍历物品(在乎顺序,组合可以重复)
for(int i = 0; i < nums.size(); i++) { // 遍历物品
    for(int j = target; j >= nums[i]; j--) {  // 遍历背包
        // 每一个元素一定是不可重复放入,所以从大到小遍历
        dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
    }
}
var canPartition = function(nums) {
    const total = nums.reduce((a,b) => a+b,0);
    if(total & 1) return false;
    const dp = new Array(Math.floor(total / 2) + 1).fill(0);
    
    for(let i=0;i<nums.length;i++){
        for(let j=total/2;j>= nums[i];j--){
            dp[j] = Math.max(dp[j],dp[j-nums[i]] + nums[i]);
            if(dp[j] === total/2){
                return true;
            }
        }
    }
    return dp[total/2] === total/2
};

image.png

var findTargetSumWays = function(nums, target) {
    const sum = nums.reduce((a,b) => a+b);
    if(Math.abs(target) > sum) return 0;
    if((sum + target) % 2 !== 0) return 0;

    const halfSum = Math.floor((target + sum) / 2);
    const dp = new Array(halfSum + 1).fill(0);
    dp[0] = 1;

    for(let i=0;i<nums.length;i++){
        for(let j=halfSum;j>= nums[i];j--){
            dp[j] += dp[j-nums[i]]
        }
    }
    
    return dp[halfSum];
};
背包知识汇总
  1. 背包递推公式

能否装满背包(或者最多装可以装多少):dp[j] = max(dp[j], dp[j - nums[i]] + nums[i])

装满背包共有几种方法:dp[j] += dp[j - nums[i]]

装满背包的最大价值:dp[j] = max(dp[j], dp[j - weight[i]] + value[i])

装满背包所有物品的最小个数:dp[j] = min(dp[j - coins[i]] + 1, dp[j])

  1. 01背包遍历顺序

二维dp数组:先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历;

一维dp数组:只能先遍历物品再遍历背包容量,且第二层for循环是从大到小遍历;

  1. 完全背包遍历顺序

如果求组合数就是外层for循环遍历物品,内层for遍历背包;

如果求排列数就是外层for遍历背包,内层for循环遍历物品;

13. 排序

排序算法的稳定性定义:两个相同数值的元素,若在排序前与排序后,前后顺序保持不变,则认为算法是稳定的;反之则认为算法是不稳定的。即在原序列中,r[i]=r[j],且r[i]r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。

  • 不稳定的排序算法:快速排序、希尔排序、直接选择排序、堆排序
  • 稳定的排序算法:冒泡排序、直接插入排序、折半插入排序、归并排序、基数排序

1. 选择排序

选择排序是最基本的排序算法,O(n^2)

function selectionSort(arr) {
  const len = arr.length;

  for (let i = 0; i < len - 1; i++) {
    let minIndex = i;

    // 在未排序区间中找最小值
    for (let j = i + 1; j < len; j++) {
      if (arr[j] < arr[minIndex]) {
        minIndex = j;
      }
    }

    // 把最小值放到当前位置
    if (minIndex !== i) {
      [arr[i], arr[minIndex]] = [arr[minIndex], arr[i]];
    }
  }

  return arr;
}

2. 快速排序

快速排序底层利用了二分法,每一次排序都将数组一分为二,其平均时间复杂度是O(nlogn)。

1️⃣:从数组中选择一个元素,这个元素被称为基准值

2️⃣:找出比基准值小的元素以及比基准值大的元素,这个过程叫做分区

  • 一个由所有小于基准值的数字组成的子数组;
  • 基准值;
  • 一个由所有大于基准值的数组组成的子数组。

3️⃣:组合并对两边的数组继续分区,直到数组为空或只包含一个元素

function quickSort(arr){
  const mid = Math.floor(arr.length/2),
        left = [],
        right = [],
        num = arr.splice(mid,1)[0];
  for(let i=0;i<arr.length;i++){
    if(arr[i] < num){
      left.push(arr[i])
    }else{
      right.push(arr[i]);
    }
  }
  return quickSort(left).concat([num],quickSort(right))
}

2. 冒泡排序

冒泡排序底层实现是嵌套的for循环,依次比较每两个元素之间的大小,其时间复杂度是O(n^2)。

function sort(arr){
  if(arr.length < 1) return arr;
  for(let i=0;i<arr.elngth;i++){
    for(let j=0;j<arr.length;j++){
      if(arr[j] > arr[j+1]){
        [arr[j],arr[j+1]] = [arr[j+1],arr[j]];
      }
    }
  }
}

3. 直接插入排序

1️⃣:从第一个元素开始,该元素可以认为已经被排序

2️⃣:取出下一个元素,在已经排序的元素序列中从后向前扫描,如果该元素(已排序)大于新元素,将该元素移到下一位置

3️⃣:重复步骤2,直到找到已排序的元素小于或者等于新元素的位置

4️⃣:将新元素插入到该位置后

5️⃣:重复步骤2-4

时间复杂度为O(n^2).

const insertionSort = (arr)=>{
  for(let i=1;i<arr.length;i++){
    let key = arr[i];
    let j = i-1;
    
    while(j>=0 && arr[j] > key){
      arr[j+1] = arr[j];
      j = j-1;
    }
    arr[j+1] = key;
  }
  return arr;
}
const res = insertionSort([10, 2, 9, 3, 8, 4, 7, 5, 6]);
console.log(res,'res'); // [2, 3, 4,  5, 6,7, 8, 9, 10]

image.png image.png

4. 归并排序

1️⃣ 分解:将数组项无限细分,得到独立的单元

2️⃣ 合并:将相近的两两进行比较,按照已排序数组合并,形成(n/2)个序列,每个序列包含2个数字;再将两个序列递归合并,按照已排序数组合并,形成(n/4)个序列,每个序列包含4个数字。。。 。。。

3️⃣ 完整数组:重复步骤2,直到所有元素合并排序完毕

时间复杂度为O(nlogn).

const mergeSort = (arr)=>{
  if(arr.length < 2) return arr;
  const left = arr.slice(0,Math.floor(arr.length/2));
  const right = arr.slice(Math.floor(arr.length/2));
  
  const merge = (left,right)=>{
    const newArr = [];
    while(left.length && right.length){
      if(left[0] <= right[0]){
        newArr.push(left.shift());
      }else{
        newArr.push(right.shift());
      }
    }
    while(left.length){
      newArr.push(left.shift());
    }
    while(right.length){
      newArr.push(right.shift());
    }
    return newArr;
  }
  return merge(mergeSort(left),mergeSort(right));
}

image.png