algorithm

目录


前言

算法包括:算法:搜索、查找、排序、双指针、回溯、分治、动态规划、贪心、位运算、数学等。

算法的目标之一是:只要达到最优的时间复杂度就可以了(在此基础上再进一步优化代码的实现)。

在这里插入图片描述------------------------------------------------------ 上图取自:力扣 ------------------------------------------------------


一、数组

1、数组基础

(1)、你真的了解数组吗?

  • 数组是存放在连续内存空间上的相同类型数据的集合。这条数组的特性对于 js 中的数组来讲:有些不同
  • 数组的元素是不能删的,只能覆盖——删掉一个元素后,要将后面的元素都往前移一位。

(2)、二维数组在内存中的空间地址是连续的吗?

举例说明:

 // js 中定义一个二维数组
let arr = new Array(3).fill(0).map(() => new Array(4));
console.log(arr);

打印结果:
在这里插入图片描述
可见:二维数组在内存中并不是连续的地址空间,而是四条连续的地址空间组成

(3)、谈谈双指针(快慢指针)

双指针的宗旨是用空间换时间

跟具双指针的使用场景与特点,我将双指针分为:

  • 平行双指针:通过一个快指针和慢指针在一个 for 循环下完成两个 for 循环的工作。
  • 相向双指针:指方向相对的双指针,最终会在某一处相交。常用于数组的排序。
  • 滑动窗口双指针:不断的调节子序列的起始位置和终止位置,从而得出我们要想的结果。

2、二分查找

题目:给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。

示例:

// 示例 1:
// 输入
nums = [-1,0,3,5,9,12], target = 9     
// 输出: 4       
// 解释: 9 出现在 nums 中并且下标为 4     

// 示例 2:
// 输入
nums = [-1,0,3,5,9,12], target = 2     
// 输出: -1        
// 解释: 2 不存在 nums 中因此返回 -1    

【解答】:

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number}
 */
const search = (nums, target) => {
  let l = 0
  let r = nums.length - 1
  while(l <= r) {
    let m = Math.floor((r - l) / 2) + l
    if (nums[m] > target) {
      r = m - 1
    } else if (nums[m] < target) {
      l = m + 1
    } else {
      return m
    }
  }
  return -1
};

总结如下。

(1)、二分查找的使用场景

二分查找的使用场景:在无重复元素的有序数组中查找某个元素。

(2)、二分法的实现

二分法的实现方式取决于区间的定义。区间的定义有两种方式:

  • 左闭右闭,也就是:[left, right]。
    • while(left <= right)
    • if (nums[middle] > target) 则 right = middle - 1;if (nums[middle] < target) 则 left = middle + 1
  • 左闭右开,也就是也就是:[left, right)。
    • while(left < right)
    • if (nums[middle] > target) 则 right = middle;if (nums[middle] < target) 则 left = middle + 1

二分法之所以容易写乱,主要是因为对区间的定义没有想清楚,区间的定义就是不变量。要在二分查找的过程中,保持不变量,就是在 while 寻找中每一次边界的处理都要坚持根据区间的定义来操作,这就是循环不变量规则

3、数组移除

题目:给你一个数组 nums 和一个值 val,你需要 原地 移除所有数值等于 val 的元素,并返回移除后数组的新长度。
不要使用额外的数组空间,你必须仅使用 O(1) 额外空间并原地修改输入数组
元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素

示例:

// 示例 1: 
// 输入:
nums = [3,2,2,3], val = 3
// 输出:2, nums = [2,2]
// 解释:函数应该返回新的长度 2, 并且 nums 中的前两个元素均为 2。你不需要考虑数组中超出新长度后面的元素。例如,函数返回的新长度为 2 ,而 nums = [2,2,3,3] 或 nums = [2,2,0,0],也会被视作正确答案。

// 示例 2: 
// 输入:
nums = [0,1,2,2,3,0,4,2], val = 2
// 输出:5, nums = [0,1,4,0,3]
// 解释:函数应该返回新的长度 5, 并且 nums 中的前五个元素为 0, 1, 3, 0, 4。注意这五个元素可为任意顺序。你不需要考虑数组中超出新长度后面的元素。

【分析】:
这个题目暴力的解法就是两层 for 循环,不过时间复杂度是 O(n^2)。推荐使用 双指针法 来解这种题。

定义本题的快慢指针:

  • 快指针:指向的是原数组里所有的数据。
  • 慢指针:指向的是原数组过滤时的存储地址。

通过快指针遍历原数组中所有的数据,在原数组上移除所有等于目标值的数据:

  • 若当前快指针指向的是目标值,调过左移这一步(不移动),直接快指针加一,进入下一个循环。
  • 若当前快指针指向的不是目标值,将该数据在原数组上向左移动到当前慢指针所指向的存储地址,然后慢指针加一,快指针加一。

【解题】:

/**
 * @param {number[]} nums
 * @param {number} val
 * @return {number}
 */
 var removeElement = function(nums, val) {
    let s = 0; // 指向的是原数组过滤时的存储地址
    let f = 0; // 指向的是原数组里所有的数据
    while(f < nums.length) {
        if (nums[f] !== val) {
            nums[s] = nums[f]; // 左移
            s++;
        }
        f++;
    }
    // 注意:这里要返回的是 i 
    return i;
};

总结如下。

(1)、平行双指针

双指针法(又叫快慢指针法): 通过一个快指针和慢指针在一个 for 循环下完成两个 for 循环的工作——空间换时间。

平行双指针:指方向相同的双指针。除此之外,还有相向双指针(下面会讲)。常用于数组和链表的查询。

4、数组排序

题目:给你一个按 非递减顺序 排序的整数数组 nums,返回 每个数字的平方 组成的新数组,要求也按 非递减顺序 排序。

示例:

// 示例 1:
// 输入:
nums = [-4,-1,0,3,10]
// 输出:[0,1,9,16,100]
// 解释:平方后,数组变为 [16,1,0,9,100],排序后,数组变为 [0,1,9,16,100]

// 示例 2:
// 输入:
nums = [-7,-3,2,3,11]
// 输出:[4,9,9,49,121]

【解答】:

/**
 * @param {number[]} nums
 * @return {number[]}
 */
var sortedSquares = function(nums) {
    let result = []
    let i = 0
    let j = nums.length - 1
    while(i <= j) {
        let powNum = 0
        if (nums[i] * nums[i] > nums[j] * nums[j]) {
            powNum = Math.pow(nums[i++], 2)
        } else {
            powNum = Math.pow(nums[j--], 2)
        }
        result.unshift(powNum)
    }
    return result
};

总结如下:

(1)、相向双指针

相向双指针:指方向相对的双指针,最终会在某一处相交。常用于数组的排序。

5、长度最小的子数组

给定一个含有 n 个正整数的数组和一个正整数 target 。找出该数组中满足其和 ≥ target 的长度最小的 连续子数组 [numsl, numsl+1, …, numsr-1, numsr] ,并返回其长度。如果不存在符合条件的子数组,返回 0 。

示例:

// 示例 1:
// 输入:
target = 7, nums = [2,3,1,2,4,3]
// 输出:2
// 解释:子数组 [4,3] 是该条件下的长度最小的子数组。

// 示例 2:
// 输入:
target = 4, nums = [1,4,4]
// 输出:1

// 示例 3:
// 输入:
target = 11, nums = [1,1,1,1,1,1,1,1]
// 输出:0

【解答】:

/**
 * @param {number} target
 * @param {number[]} nums
 * @return {number}
 */
// 滑动窗口双指针
var minSubArrayLen = function(target, nums) {
    let i = 0 // 起始位置
    let j = 0 // 结束位置
    let sum = 0
    let res = nums.length + 1 // 子数组最大不会超过自身
    while (j < nums.length) {
        sum += nums[j++]
        while (sum >= target) {
            res = res > j - i ?  j - i : res
            sum -= nums[i++]
        }
    }
    return res > nums.length ? 0 : res
}

总结如下。

(1)、滑动窗口双指针

滑动窗口就是:不断的调节子序列的起始位置和终止位置,从而得出我们要想的结果。也可以理解为双指针法的一种。

滑动窗口灵魂三问:

  • 窗口内是什么?
    • 窗口就是 满足条件的 连续的子数组
  • 如何移动窗口的起始位置?
    • 如果当前窗口满足条件,窗口就要移动了(缩小窗口)。
  • 如何移动窗口的结束位置?
    • 窗口的结束位置就是遍历数组的指针,也就是 for 循环里的索引。

对于上述案例来讲,滑动窗口的精髓:根据当前子序列和大小的情况,不断调节子序列的起始位置。从而将 O(n^2)的暴力解法降为 O(n)。

另外,徐阿哟特别注意的是:不要以为:for 里放一个 while,或者两个 while 嵌套了,它们的时间复杂度就一定是 O(n^2) 。而是要具体情况具体分析,主要是看每一个元素被操作的次数。

上述代码中,每个元素在滑动窗后进来操作一次,出去操作一次,每个元素都是被操作两次,所以时间复杂度是 2 × n 也就是 O(n)。

6、螺旋矩阵——非算法,重在过程

给你一个正整数 n ,生成一个包含 1 到 n2 所有元素,且元素按顺时针顺序螺旋排列的 n x n 正方形矩阵 matrix 。

示例:
在这里插入图片描述

// 输入:n = 3
// 输出:[[1,2,3],[8,9,4],[7,6,5]]

【分析】:

本题并不涉及到什么算法,就是模拟过程,但却十分考察对代码的掌控能力。

这里的边界条件非常多,在一个循环中,如此多的边界条件,如果不按照固定规则来遍历,那就很容易出错了。

模拟顺时针画矩阵的过程:

  • 填充上行从左到右
  • 填充右列从上到下
  • 填充下行从右到左
  • 填充左列从下到上

由外向内一圈一圈这么画下去。
在这里插入图片描述
由上图可知:用每一种颜色代表一条边,每一个拐角处都是让给了新的一条边来继续画——坚持了每条边左闭右开的原则——坚持 循环不变量原则

【解答】:

/**
 * @param {number} n
 * @return {number[][]}
 */
var generateMatrix = function(n) {
    let startX = startY = 0; // 起始位置
    let loop = Math.floor(n / 2); // 旋转圈数
    let mid = Math.floor(n / 2); // 中间位置
    let offset = 1; // 控制每次填充元素个数
    let count = 1; // 更新填充数字
    let res = new Array(n).fill(0).map(() => new Array(n).fill(0)); // 定义一个二维数组
    // 一个循环是一圈
    while (loop--) {
        let row = startX, col = startY;
        // 上行从左到右(左闭右开)
        while (col < startY + n - offset) {
            res[row][col++] = count++;
        }
        // 右列从上到下(左闭右开)
        while (row < startX + n - offset) {
            res[row++][col] = count++;
        }
        // 下行从右到左(左闭右开)
        while (col > startY) {
            res[row][col--] = count++;
        }
        // 左列做下到上(左闭右开)
        while (row > startX) {
            res[row--][col] = count++;
        }

        // 更新起始位置
        startX++;
        startY++;

        // 更新offset
        offset += 2;
    }
    // 如果n为奇数的话,需要单独给矩阵最中间的位置赋值
    if (n % 2 === 1) {
        res[mid][mid] = count;
    }
    return res;
};

二、链表

1、链表基础

链表是一种通过指针串联在一起的线性结构。

链表的头结点是 head(是一个节点)。

链表重在注重边界的处理。

(1)、链表的类型

  • 单链表:每一个节点由两部分组成,一个是数据域一个是指针域(存放指向下一个节点的指针),最后一个节点的指针域指向null(空指针的意思)。
    在这里插入图片描述
  • 双链表:每一个节点有两个指针域,一个指向下一个节点,一个指向上一个节点。既可以向前查询也可以向后查询。
    在这里插入图片描述
  • 循环链表:链表首尾相连。可以用来解决约瑟夫环问题。
    在这里插入图片描述

(2)、链表的存储方式

  • 链表是通过指针域的指针链接在内存中各个节点,所以链表中的节点在内存中不是连续分布的 ,而是散乱分布在内存中的某地址上,分配机制取决于操作系统的内存管理。
    在这里插入图片描述

(3)、链表的操作和定义

链表的操作:

  • 添加节点
  • 删除节点
  • 查找节点

查找链表的 2 种方式(✨✨✨✨✨):

  • 在原链表上直接查找:需要对头节点和非头节点分别进行处理
    • 对头节点:需要单独实现其逻辑——head = head -> next
    • 对非头节点:直接查找,找到后,让其前一个节点的 next 指针指向其下一个节点就可以了。
  • 通过添加虚拟头节点来统一查找:先设置一个虚拟头结点再查找,找到后,让其前一个节点的 next 指针指向其下一个节点就可以了。

定义一个链表:

// 先定义节点
class LinkNode {
    val;
    next;
	// 节点的构造函数
    constructor(val, next) {
        this.val = val; // 节点上存储的元素
        this.next = next; // 指向下一个节点的指针
    }
}

// 再定义链表(链表包含:头指针、尾指针 和 链表长度)
class LinkList {
	// 链表的构造函数
    constructor() { 
        this._size = 0; // 索引值
        this._head = null; // 头结点
        this._tail = null; // 尾节点
    }
    // 在头节点前加入节点的方法
    addAtHead(val) => {
		let node = new LinkNode(val, this._head)    // 初始化一个节点。 
        this._head = node       // 把这个节点变成头结点
        this._size++
        if (!this._tail) { // 尾指针是否为空
            this._tail = node   // 当尾指针为 null 时,令这个节点:既是头指针,也是尾指针。
        }
    }
    // 在末尾加入节点的方法
    addAtTail(val) => {}
    // 在链表中的第 index 个节点之前添加值为 val 的节点。
    addAtIndex(val) => {}
    // 根据索引获取节点,参数是 index 这里索引是从 0 开始的
    getByIndex(val) => {}
    // 在删掉索引处的节点
    deleteAtIndex(val) => {}
}

// 初始化节点
var myLinkList = new LinkList()
// console.log(myLinkList)
// console.log(myLinkList._head.next)

(4)、链表 与 数组的对比

插入/删除时间复杂度查询时间复杂度使用场景
数组O(n)O(1)数据量固定(js 除外),频繁查询,较少增删
链表O(1)O(n)数据量不固定,频繁增删,较少查询

2、移除链表元素

给你一个链表的头节点 head 和一个整数 val ,请你删除链表中所有满足 Node.val == val 的节点,并返回 新的头节点 。

示例:

// 输入
head = [1,2,6,3,4,5,6], val = 6
// 输出:[1,2,3,4,5]

// 输入
head = [], val = 1
// 输出:[]

// 输入
head = [7,7,7,7], val = 7
// 输出:[]

【解答】:

在原链表上直接删除链表元素:

/**
 * @param {ListNode} head
 * @param {number} val
 * @return {ListNode}
 */
var removeElements = function(head, val) {
	if (head === null) return head;
    while(head && head.val === val) {
		head = head.next;
	}
    let cur = head;
    while(cur && cur.next) {
        if(cur.next.val === val) {
            cur.next =  cur.next.next;
        } else {
			cur = cur.next;
		}
    }
    return head;
};

通过添加虚拟头节点来统一删除链表元素:

/**
 * @param {ListNode} head
 * @param {number} val
 * @return {ListNode}
 */
var removeElements = function(head, val) {
    const virNode = new ListNode(0, head);
    let cur = virNode;
    while(cur.next) {
        if(cur.next.val === val) {
            cur.next =  cur.next.next;
        } else {
			cur = cur.next;
		}
    }
    return virNode.next;
};

总结如下。

(1)、移除链表元素的两种方法

按照链表的 2 种不同的查找方式,找到对应的元素节点,将其删除。

3、设计链表

设计链表的实现。您可以选择使用单链表或双链表。单链表中的节点应该具有两个属性:val 和 next。val 是当前节点的值,next 是指向下一个节点的指针/引用。如果要使用双向链表,则还需要一个属性 prev 以指示链表中的上一个节点。假设链表中的所有节点都是 0-index 的。
在链表类中实现这些功能:

  • get(index):获取链表中第 index 个节点的值。如果索引无效,则返回-1。
  • addAtHead(val):在链表的第一个元素之前添加一个值为 val 的节点。插入后,新节点将成为链表的第一个节点。
  • addAtTail(val):将值为 val 的节点追加到链表的最后一个元素。
  • addAtIndex(index,val):在链表中的第 index 个节点之前添加值为 val 的节点。如果 index 等于链表的长度,则该节点将附加到链表的末尾。如果 index 大于链表长度,则不会插入节点。如果index小于0,则在头部插入节点。
  • deleteAtIndex(index):如果索引 index 有效,则删除链表中的第 index 个节点。

示例:

MyLinkedList linkedList = new MyLinkedList();
linkedList.addAtHead(1);
linkedList.addAtTail(3);
linkedList.addAtIndex(1,2);   // 链表变为1-> 2-> 3
linkedList.get(1);            // 返回2
linkedList.deleteAtIndex(1);  // 现在链表是1-> 3
linkedList.get(1);            // 返回3

【解答】:

// 定义节点
class LinkNode {
    constructor(val, next) {
        this.val = val; // 值
        this.next = next; // 后指针:指向的是下一个节点
    }
}
// 定义联表
const MyLinkedList = function() {
    this._size = 0; // 索引值
    this._head = null; // 头结点
    this._tail = null; // 尾节点
}
// 公共方法 - 获取当前节点
MyLinkedList.prototype.getCurrentNode = function(index){
    if(index < 0 || index >= this._size) return null;
    // 创建虚拟头节点
    let cur = new LinkNode(0, this._head);
    while(index >= 0) {
        cur = cur.next;
        index--;
    }
    return cur;
};
// 获取链表中第 index 个节点的值。如果索引无效,则返回 -1
MyLinkedList.prototype.get = function(index) {
    if(index < 0 || index >= this._size) return -1;
    // 获取当前节点
    return this.getCurrentNode(index).val;
};
// 在链表的第一个元素之前添加一个值为 val 的节点。插入后,新节点将成为链表的第一个节点。
MyLinkedList.prototype.addAtHead = function(val) {
    const node = new LinkNode(val, this._head);
    this._head = node;
    this._size++;
    if(!this._tail) {
        this._tail = node;
    }
};
// 将值为 val 的节点追加到链表的最后一个元素。
MyLinkedList.prototype.addAtTail = function(val) {
    const node = new LinkNode(val, null); // 链表的最后一个节点指向的始终是 null。
    if(this._tail) {
        this._tail.next = node;
        this._tail = node;
    } else {
    	this._tail = node;
    	this._head = node;
    }
    this._size++;
};
// 在链表中的第 index 个节点之前添加值为 val  的节点。如果 index 等于链表的长度,则该节点将附加到链表的末尾。如果 index 大于链表长度,则不会插入节点。如果index小于0,则在头部插入节点。
MyLinkedList.prototype.addAtIndex = function(index, val) {
    if(index > this._size) return;
    if(index <= 0) {
        this.addAtHead(val);
        return;
    }
    if(index === this._size) {
        this.addAtTail(val);
        return;
    }
    // 获取目标节点的上一个的节点
    const node = this.getCurrentNode(index - 1);
    node.next = new LinkNode(val, node.next);
    this._size++;
};
// 如果索引 index 有效,则删除链表中的第 index 个节点。
MyLinkedList.prototype.deleteAtIndex = function(index) {
    if(index < 0 || index >= this._size) return;
    if(index === 0) {
        this._head = this._head.next;
        // 如果删除的这个节点同时是尾节点,要处理尾节点
        if(index === this._size - 1){
            this._tail = this._head
        }
        this._size--;
        return;
    }
    // 获取目标节点的上一个的节点
    const node = this.getCurrentNode(index - 1);    
    node.next = node.next.next;
    // 处理尾节点
    if(index === this._size - 1) {
        this._tail = node;
    }
    this._size--;
};

// 初始化链表
const linkedList = new MyLinkedList()
// 使用链表
linkedList.addAtHead(1);
linkedList.addAtTail(3);
linkedList.addAtIndex(1,2);   // 链表变为:1-> 2-> 3
linkedList.get(1);            // 返回:2
linkedList.deleteAtIndex(1);  // 现在链表是:1-> 3
linkedList.get(1);            // 返回:3

【解析】:对“获取链表中第 index 个节点”的解析
在这里插入图片描述
总结如下。

(1)、链表的增、删、查操作的总结归纳

注意:以下操作,凡是与 index 相关的,都是基于 index 是有效的情况来看的。

  • 【查】
    • 获取链表中第 index 个节点,需要先创建虚拟头节点,然后循环遍历出 index 所对应的当前节点(详见上文中——对“获取链表中第 index 个节点”的解析)。
  • 【增】
    • 在头部插入节点时,就让该链表的 head 直接等于该节点,然后需要注意的是:
      • 若尾节点是 null,则让尾节点也等于该节点。
    • 在尾部插入节点时,需要注意的是:
      • 若尾节点为 null,则让尾节点的下一个节点等于该节点,并且让尾节点也等于该节点;
      • 若尾节点不为 null,则让尾节点等于该节点,并且让头节点也等于该节点。
    • 在链表中的第 index 个节点之前添加值为 val 的节点:
      • 若 index 小于 0,则在头部插入节点,遵循头部插入节点的逻辑。
      • 若 index 等于链表长度,则该节点将附加到链表的末尾,遵循尾部插入节点的逻辑。
      • 若 index 大于链表长度,则不会插入节点。
  • 【删】
    • 删除链表中的第 index 个节点,分两种情况:
      • index 为 0 时,先让头节点等于下一个节点,然后需要注意的是——若删除的这个节点是尾节点时,则需要让尾节点等于头节点。
      • index 不为 0 时,需要获取当前节点的前一个节点(记为 node),然后让 node 的下一个节点等于其下下一个节点,然后需要注意的是——若删除的这个节点是尾节点时,需要让尾节点等于 node。

4、反转链表

给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。

示例 1:
![在这里插入图片描述](https://img-blog.csdnimg.cn/4f9368c2708f469088f351c81e621715.png# _=270x)

// 输入
head = [1,2,3,4,5]
// 输出:[5,4,3,2,1]

解答:

/**
 * Definition for singly-linked list.
 * function ListNode(val, next) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.next = (next===undefined ? null : next)
 * }
 */
// 双指针法(推荐)
var reverseList = function(head) {
    if(!head || !head.next) return head;
    let cur = head;
    let pre = null;
    let temp = null; // 临时存储
    while(cur) {
        temp = cur.next;
        cur.next = pre;
        pre = cur;
        cur = temp;
    }
    return pre;
}
// 递归法
var reverse = function(pre, cur) {
    if(!cur) return pre
    let temp = cur.next
    cur.next = pre
    pre = cur
    return reverse(pre, temp);
}
var reverseList = function(head) {
    return reverse(null, head)
}

【解析】:用双指针法实现反转链表的解析
在这里插入图片描述
递归的写法是基于双指针的写法演变而来的。

5、两两交换链表中的节点

给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。你必须在不修改节点内部的值的情况下完成本题(即,只能进行节点交换)。

示例 1:
在这里插入图片描述

// 输入
head = [1,2,3,4]
// 输出:[2,1,4,3]

解答:

/**
 * Definition for singly-linked list.
 * function ListNode(val, next) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.next = (next===undefined ? null : next)
 * }
 */
/**
 * @param {ListNode} head
 * @return {ListNode}
 */
var swapPairs = function(head) {
    let virNode = new ListNode(0, head);
    let cur = virNode;
    while (cur.next && cur.next.next) {
        let A = cur.next;
        let B = cur.next.next;
        A.next = B.next;
        B.next = A;
        cur.next = B;
        cur = A;
    }
    return virNode.next;
};

【解析】:
先看链表的节点是奇数还是偶数个:

  • 偶数个节点:相邻的两个节点两两交换即可。
  • 奇数个节点:从头开始相邻的两个节点两两交换,剩下的最后一个不用交换,直接继承下来就行。
    在这里插入图片描述
    解题步骤:
  • 定义虚拟节点(virNode),始终指向头节点(head);
  • 将虚拟头节点作为当前节点;
  • 定义双指针分别指向将要交换的节点;
  • 改变指针指向;
  • 返回交换后链表的头节点(virNode.next)。

6、删除链表的倒数第 N 个节点

给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。

示例:
在这里插入图片描述

// 输入
head = [1,2,3,4,5]
n = 2
// 输出:[1,2,3,5]

解答:

/**
 * Definition for singly-linked list.
 * function ListNode(val, next) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.next = (next===undefined ? null : next)
 * }
 */
// 双指针法(推荐)
var removeNthFromEnd = function(head, n) {
    let virNode = new ListNode(0, head)
    let F = S = virNode
    while(n--) F = F.next
    while(F.next) {
        F = F.next
        S = S.next
    }
    S.next = S.next.next
    return virNode.next
};
// 递归倒退 n 法
var removeNthFromEnd = function(head, n) {
    let virNode = new ListNode(0, head)
    let index = 0
    const recursion = (cur) => {
        if (!cur) return
        recursion(cur.next)
        index++
        if (index === n + 1) {
            cur.next = cur.next.next
        }
    }
    recursion(virNode)
    return virNode.next
};

【解析】:双指针法的解析
在这里插入图片描述
解题思路:

  • 使用虚拟头结点
  • 定义fast指针和slow指针,初始值为虚拟头结点。
  • fast首先走n + 1步。
  • fast和slow同时移动,直到fast.next为null为止。此时删除删除slow的下一个节点。
  • 返回删除后链表的头节点(virNode.next)。

7、链表相交

给你两个单链表的头节点 headA 和 headB ,请你找出并返回两个单链表相交的起始节点。如果两个链表没有交点,返回 null 。

  • 题目数据 保证 整个链式结构中不存在环。
  • 注意,函数返回结果后,链表必须 保持其原始结构。

示例:
在这里插入图片描述

// 输入
intersectVal = 8, listA = [4,1,8,4,5], listB = [5,0,1,8,4,5], skipA = 2, skipB = 3
// 输出:Intersected at '8'

对上述示例代码的解释:

  • 相交节点的值为 8 (注意,如果两个链表相交则不能为 0)。
  • 从各自的表头开始算起,链表 A 为 [4,1,8,4,5],链表 B 为 [5,0,1,8,4,5]。
  • 在 A 中,相交节点前有 2 个节点;在 B 中,相交节点前有 3 个节点。

【解答】:

/**
 * Definition for singly-linked list.
 * function ListNode(val) {
 *     this.val = val;
 *     this.next = null;
 * }
 */
// 双指针一法:同步走
// 都走 HeadA+headB 的长度,走完一个链表以后走另一个链表
var getIntersectionNode = function(headA, headB) {
    // 有一个空则无交点
    if(headA == null || headB == null) return null;
    let pA = headA;
    let pB = headB;
    while(pA !== pB) {
        pA = pA === null ? headB : pA.next
        pB = pB === null ? headA : pB.next
    }
    return pA;
};

// 双指针二法:差步走
// 长度大的先走,走到和长度短的相同的位置后开始比较。
var getListLen = function(head) {
    let len = 0, cur = head;
    while(cur) {
       len++;
       cur = cur.next;
    }
    return len;
}
var getIntersectionNode = function(headA, headB) {
    // 有一个空则无交点
    if(headA == null || headB == null) return null;

    let curA = headA;
    let curB = headB;
    // 求两表的长度
    let lenA = getListLen(headA)
    let lenB = getListLen(headB)
    // 保证 A 是最长的那个链表,若 A 的长度小于 B 的长度则交换
    if(lenA < lenB) {
        // 下面交换变量注意加 “分号” ,两个数组交换变量在同一个作用域下时
        // 如果不加分号,下面两条代码等同于一条代码: [curA, curB] = [lenB, lenA]
        [curA, curB] = [curB, curA];
        [lenA, lenB] = [lenB, lenA];
    }
    // 长度大的先走:让长的链表(A)先走与短的链表(B)相差的长度步数
    let i = lenA - lenB;
    while(i-- > 0) {
        curA = curA.next;
    }
    // 长的链表(A)走到与短的链表(B)相同的位置后开始比较,不想等则同时往下走,直到相等为止。
    while(curA && curA !== curB) {
        curA = curA.next;
        curB = curB.next;
    }
    return curA;
};

【解析】:

双指针一同步走法解析:
在这里插入图片描述
只有当链表 headA 和 headB 都不为空时,两个链表才可能相交。因此首先判断链表 headA 和 headB 是否为空,如果其中至少有一个链表为空,则两个链表一定不相交,此时返回 null。
当链表 headA 和 headB 都不为空时,创建两个指针 pA 和 pB,初始时分别指向两个链表的头节点 headA 和 headB,然后将两个指针依次遍历两个链表的每个节点。具体做法如下:

  • 每步操作需要同时更新指针 pA 和 pB。
  • 如果指针 pA 不为空,则将指针 pA 移到下一个节点;如果指针 pB 不为空,则将指针 pB 移到下一个节点。
  • 如果指针 pA 为空,则将指针 pA 移到链表 headB 的头节点;如果指针 pB 为空,则将指针 pB 移到链表 headA 的头节点。
  • 当指针 pA 和 pB 指向同一个节点或者都为空时,返回它们指向的节点或者 null。

双指针二差步走法解析:
在这里插入图片描述

  • curA 指向链表 A 的头结点,curB 指向链表 B 的头结点。
  • 若较长的链表不是 curA,则交换 curA 和 curB。
  • 求出两个链表的长度,并求出两个链表长度的差值 i,让 curA 先移动 i 步,此时 curA 的末尾与 curB 的末尾是对齐的。
  • 比较 curA 和 curB 是否相同,如果不相同,同时向后移动 curA 和 curB,如果相同则找到交点。否则循环退出返回 null。

8、环形链表

给定一个链表的头节点 head ,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。

不允许修改 链表。

示例:
在这里插入图片描述

// 输入
head = [3,2,0,-4], pos = 1
// 输出:返回索引为 1 的链表节点
// 解释:链表中有一个环,其尾部连接到第二个节点。

【解答】:

/**
 * Definition for singly-linked list.
 * function ListNode(val) {
 *     this.val = val;
 *     this.next = null;
 * }
 */
/**
 * @param {ListNode} head
 * @return {ListNode}
 */
 // 双指针法
var detectCycle = function(head) {
    let F = head;
    let S = head;
    while(F !== null && F.next !== null) {
        F = F.next.next; // 快指针,一次走两步
        S = S.next; // 慢指针,一次走一步
        // F 与 S 相等时则相遇,相遇则表示有环
        if (F === S) {
            let H = head;
            let M = F; // 相遇的节点
            // 相等则找到了入口节点,终止循环。否则 L 和 R 同时往下走一步
            while(H !== M) {
                H = H.next;
                M = M.next;
            }
            return H;
        }
    }
    return null;
};

【解析】:双指针法解析

  • 判断链表是否有环:可以使用快慢指针法,分别定义 fast 和 slow 指针,从头结点出发,fast 指针每次移动两个节点,slow 指针每次移动一个节点,如果 fast 和 slow指针在途中相遇 ,说明这个链表有环。
    在这里插入图片描述
  • 链表有环则则寻找环的入口:
    在这里插入图片描述
    总结如下。

(1)、找到恒等关系建立等式

解此题的关键在于——运用数学知识,找到恒等关系建立等式,并化简。

三、哈希表

1、哈希表概论

哈希表也有叫散列表的,指的是 “用关键码值直接访问数据” 的数据结构。

类比数组理解哈希表数据结构:哈希表中关键码就是数组的索引下标,然后通过下标直接访问数组中的元素。

三种哈希结构:

  • 数组:数组是一个限制了一定数值大小的简单哈希表。若题目没有限制数值的大小,就无法使用数组来做哈希表了。而且如果哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费。
  • set (集合):Set 对象允许你存储任何类型的唯一值,无论是原始值或者是对象引用。若题目要求返回值是唯一不重复的,而且没有限制数值的大小,并且哈希值比较少、特别分散、跨度非常大,此时适合使用 set。
  • map(映射):Map 对象保存键值对,并且能够记住键的原始插入顺序。任何值(对象或者基本类型)都可以作为一个键或一个值。

哈希表的特点:

  • 哈希表可以看作是一个集合,这个集合必须是大于原数据的,但却大大提高了查询的效率——是典型的空间换时间的算法——我们要使用额外的数组、set 或 map 来存放数据,才能实现快速的查找。将时间复杂度降为了O(1)。

(1)、Set、Map 比较

  • Set 定义:Set 对象允许你存储任何类型的唯一值,无论是原始值或者是对象引用。
  • Map 定义:Map 对象保存键值对,并且能够记住键的原始插入顺序。任何值(对象或者基本类型)都可以作为一个键或一个值。
Set、Map 对比表
Set Map
属性 描述 属性 描述
构造函数 Set() 创建一个新的 Set 对象。 Map() 创建 Map 对象。
实例属性 Set.prototype.size 返回 Set 对象中的值的个数。 Map.prototype.size 返回 Map 对象中的键值对数量。
实例方法 Set.prototype.add(value) 在Set对象尾部添加一个元素。返回该 Set 对象。 - -
- - Map.prototype.get(key) 返回与 key 关联的值,若不存在关联的值,则返回 undefined。
- - Map.prototype.set(key, value) 在 Map 对象中设置与指定的键 key 关联的值 value,并返回 Map 对象。
Set.prototype.clear() 移除Set对象内的所有元素。 Map.prototype.clear() 移除 Map 对象中所有的键值对。
Set.prototype.delete(value) 移除值为 value 的元素,并返回一个布尔值来表示是否移除成功。Set.prototype.has(value) 会在此之后返回 false。 Map.prototype.delete(key) 移除 Map 对象中指定的键值对,如果键值对存在并成功被移除,返回 true,否则返回 false。调用 delete 后再调用 Map.prototype.has(key) 将返回 false。
Set.prototype.has(value) 返回一个布尔值,表示该值在 Set 中存在与否。 Map.prototype.has(key) 返回一个布尔值,用来表明 Map 对象中是否存在与 key 关联的值。
Set.prototype[@@iterator]() 返回一个新的迭代器对象,该对象包含 Set 对象中的按插入顺序排列的所有元素的值。 Map.prototype[@@iterator]() 返回一个新的迭代对象,其为一个包含 Map 对象中所有键值对的 [key, value] 数组,并以插入 Map 对象的顺序排列。
Set.prototype.keys() (en-US) 与 values() 方法相同,返回一个新的迭代器对象,该对象包含 Set 对象中的按插入顺序排列的所有元素的值。 Map.prototype.keys() 返回一个新的迭代对象,其中包含 Map 对象中所有的键,并以插入 Map 对象的顺序排列。
Set.prototype.values() 返回一个新的迭代器对象,该对象包含 Set 对象中的按插入顺序排列的所有元素的值。 Map.prototype.values() 返回一个新的迭代对象,其中包含 Map 对象中所有的值,并以插入 Map 对象的顺序排列。
Set.prototype.entries() 返回一个新的迭代器对象,该对象包含 Set 对象中的按插入顺序排列的所有元素的值的 [value, value] 数组。为了使这个方法和 Map 对象保持相似, 每个值的键和值相等。 Map.prototype.entries() 返回一个新的迭代对象,其为一个包含 Map 对象中所有键值对的 [key, value] 数组,并以插入 Map 对象的顺序排列。
Set.prototype.forEach(callbackFn[, thisArg]) 按照插入顺序,为 Set 对象中的每一个值调用一次 callBackFn。如果提供了thisArg参数,回调中的 this 会是这个参数。 Map.prototype.forEach(callbackFn[, thisArg]) 以插入的顺序对 Map 对象中存在的键值对分别调用一次 callbackFn。如果给定了 thisArg 参数,这个参数将会是回调函数中 this 的值。

Set 使用案例
Map使用案例

(2)、哈希表的用途

什么时候使用哈希法?

  • 当我们需要查询一个元素是否出现过,或者一个元素是否在集合里的时候,就要第一时间想到哈希法。

(3)、举例说明哈希表

例如:要查询一个名字是否在这所学校里——我们需要把这所学校里学生的名字存在哈希表里,在查询的时候通过索引直接就可以知道这位同学在不在这所学校里了。

将学生姓名映射到哈希表的实现过程 就是——哈希函数。

(4)、哈希表常见的问题

1⃣️、把学生的姓名直接映射为哈希表上的索引,然后就可以通过查询索引下标快速知道这位同学是否在这所学校里了。

2⃣️、如果学生的数量大于哈希表的大小怎么办,此时就算哈希函数计算的再均匀,也避免不了会有几位学生的名字同时映射到哈希表 同一个索引下标的位置——这一现象叫做哈希碰撞。

一般哈希碰撞有两种解决方法:

  • 拉链法:刚刚小李和小王在索引1的位置发生了冲突,发生冲突的元素都被存储在链表中。 这样我们就可以通过索引找到小李和小王了。
  • 线性探测法:使用线性探测法,一定要保证 tableSize 大于 dataSize。 我们需要依靠哈希表中的空位来解决碰撞问题。例如:冲突的位置,放了小李,那么就向下找一个空位放置小王的信息。所以要求 tableSize 一定要大于 dataSize ,要不然哈希表上就没有空置的位置来存放 冲突的数据了。

2、有效的字母异位词——数组

给定两个字符串 s 和 t ,编写一个函数来判断 t 是否是 s 的字母异位词。

注意:若 s 和 t 中每个字符出现的次数都相同,则称 s 和 t 互为字母异位词。

示例:

// 输入
s = "anagram", t = "nagaram"
// 输出:true

// 输入
s = "rat", t = "car"
// 输出:false

解答:

/**
 * @param {string} s
 * @param {string} t
 * @return {boolean}
 */
var isAnagram = function(s, t) {
    if(s.length !== t.length) return false;
    let arr = new Array(26).fill(0);
    let base = 'a'.charCodeAt(0);
    for(let i = 0; i < s.length; i++) {
        arr[s[i].charCodeAt() - base]++;
        arr[t.charCodeAt(i) - base]--;
    }
    return arr.every(item => item === 0);
};

上述代码中,s[i].charCodeAt()t.charCodeAt(i) 表示的意思是一样的,只是写法不同——数组 s 或 t 中的第 i 项对应的 ASCII 码值。

【解析】:

在这里插入图片描述

数组是一个简单哈希表,这道题目中字符串只有小写字符,那么就可以定义一个大小为 26 且初始化为 0 的数组(因为字符 a 到字符 z 的 ASCII 也是 26 个连续的数值),来记录某个字符在字符串 s 或 t 里出现的次数。

然后,需要把字符映射到数组也就是哈希表的索引下标上,因为字符 a 到字符 z 的 ASCII 是 26 个连续的数值,所以字符 a 映射为下标 0,相应的字符 z 映射为下标 25。

在遍历时候需要:将 s 中出现的字符映射到哈希表索引上的数值做 +1 操作,将 t 中出现的字符映射到哈希表索引上的数值做 -1 的操作。

最后检查一下:如果数组中有的元素不为零 0,说明字符串 s 和 t 一定是谁多了字符或者谁少了字符,返回 false。如果数组所有元素都为 0,说明字符串 s 和 t 是字母异位词,返回 true。

3、两个数组的交集——Set

给定两个数组 nums1 和 nums2 ,返回 它们的交集 。输出结果中的每个元素一定是 唯一 的。我们可以 不考虑输出结果的顺序 。

示例:

// 输入
nums1 = [4,9,5], nums2 = [9,4,9,8,4]
// 输出:[9,4]
// 解释:[4,9] 也是可通过的

【解答】:

/**
 * @param {number[]} nums1
 * @param {number[]} nums2
 * @return {number[]}
 */
var intersection = function(nums1, nums2) {
    if(!nums1.length || !nums2.length) return []
    if(nums1.length < nums2.length) {
        let list = nums1
        nums1 = nums2
        nums2 = list
    }
    let setList = new Set(nums1) // 对较长的数组去重
    let resSet = new Set()
    for (let i = 0; i <= nums2.length; i++) {
        setList.has(nums2[i]) && resSet.add(nums2[i])
    }
    return [...resSet];
};

总结如下。

(1)、用 Set 对数组去重返回的仍是一个 Set 对象

例如:
在这里插入图片描述

(2)、往一个空的 Set 对象里添加数据时会自动去重

例如:
在这里插入图片描述

4、快乐数——Set、Map

编写一个算法来判断一个数 n 是不是快乐数。

「快乐数」 定义为:

对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。
然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。
如果这个过程 结果为 1,那么这个数就是快乐数。

如果 n 是 快乐数 就返回 true ;不是,则返回 false 。

示例:

// 输入
n = 19
// 输出:true
/** 解释:
 * 12 + 92 = 82
 * 82 + 22 = 68
 * 62 + 82 = 100
 * 12 + 02 + 02 = 1
 **/

// 输入
n = 2
// 输出:false

【解答】:

/**
 * @param {number} n
 * @return {boolean}
 */
// Set
var isHappy = function(n) {
	function getSum (n) {
        let sum = 0
        while(n > 0) {
            sum += (n % 10) ** 2
            n = Math.floor(n / 10)
        }
        return sum
    }
    let resSet = new Set()
    while(n > 0) {
        if (n === 1) return true
        if (resSet.has(n)) return false // n 出现过,证明已陷入无限循环
        resSet.add(n)
        n = getSum(n)
    }
};
// Map
var isHappy = function(n) {
    function getSum (n) {
        let sum = 0
        while(n > 0) {
            sum += (n % 10) ** 2
            n = Math.floor(n / 10)
        }
        return sum
    }
    let resMap = new Map()
    while(n > 0) {
        if (n === 1) return true
        if (resMap.has(n)) return false // n 出现过,证明已陷入无限循环
        resMap.set(n, 1) // 这一点与 Set 不同
        n = getSum(n)
    }
};

5、两数之和——Map

给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
你可以按任意顺序返回答案。

示例:

// 输入
nums = [2,7,11,15], target = 9
// 输出:[0,1]
// 解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。

【解题】:

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number[]}
 */
// Map hash 解法
var twoSum = function(nums, target) {
    let mHash = new Map()
    let i = 0
    while(i < nums.length) {
        let index = mHash.get(target - nums[i])
        if (index !== undefined) {
            return [i, index]
        }
        mHash.set(nums[i], i)
        i++
    }
};
// 双指针解法
/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number[]}
 */
var twoSum = function(nums, target) {
    let i = 0 // 慢指针
    let j = 1 // 快指针
    while(i < nums.length) {
        if (nums[i] + nums[j] === target) {
            return [i, j];
        } else if (j < nums.length - 1) {
            j++;
        } else {
            i++;
            j = i + 1;
        }
    };
};

对比下 hash 和 双指针 的耗时:
在这里插入图片描述
不难发现,hash 比双指针快了 2 倍。

【解析】:Map 哈希实现两数之和的步骤解析:

用 Map 做哈希一定要弄清楚 2 点:

  • map用来做什么:map目的用来存放我们访问过的元素,因为遍历数组的时候,需要记录我们之前遍历过哪些元素和对应的下表,这样才能找到与当前元素相匹配的(也就是相加等于target)。
  • map中key和value分别表示什么:这道题 我们需要 给出一个元素,判断这个元素是否出现过,如果出现过,返回这个元素的下标。那么判断元素是否出现,这个元素就要作为key,所以数组中的元素作为key,有key对应的就是value,value用来存下标。所以 map中的存储结构为 {key:数据元素,value:数组元素对应的下表}。

在遍历数组的时候,只需要向map去查询是否有和目前遍历元素比配的数值,如果有,就找到的匹配对,如果没有,就把目前遍历的元素放进map中,因为map存放的就是我们访问过的元素。

为什么会选择 Map 做哈希呢?请看下面的总结。

(1)、为什么用哈希?

当我们需要查询一个元素是否出现过,或者一个元素是否在集合里的时候,就要第一时间想到哈希法。

(2)、为什么用 Map 而不是 Set 或 数组?

使用 数组 和 set 来做哈希法的局限性:

  • 数组的大小是受限制的,而且如果元素很少,而哈希值太大会造成内存空间的浪费。
  • set是一个集合,里面放的元素只能是一个key,而两数之和这道题目,不仅要判断y是否存在而且还要记录y的下标位置,因为要返回x 和 y的下标。所以set 也不能用。

此时就要选择另一种数据结构:map ,map是一种key value的存储结构,可以用key保存数值,用value在保存数值所在的下标。


四、字符串

1、反转字符串

编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出。 不能使用现成的函数去实现。
不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。

你可以假设数组中的所有字符都是 ASCII 码表中的可打印字符。

示例:

// 输入
["h","e","l","l","o"]
// 输出:
["o","l","l","e","h"]

【解题】:

// 双指针解法
/**
 * @param {character[]} s
 * @return {void} 不要返回任何内容,而是原地修改 s。
 */
var reverseString = function (s) {
  let L = 0, R = s.length - 1;
  while (L < R) {
    [s[L], s[R]] = [s[R], s[L]];
    L++;
    R--;
  };
};

2、反转字符串 II

给定一个字符串 s 和一个整数 k,从字符串开头算起, 每计数至 2k 个字符,就反转这 2k 个字符中的前 k 个字符。
如果剩余字符少于 k 个,则将剩余字符全部反转。
如果剩余字符小于 2k 但大于或等于 k 个,则反转前 k 个字符,其余字符保持原样。

示例:

// 输入
s = "abcdefg", k = 2
// 输出: "bacdfeg"

【解题】:

/**
 * @param {string} s
 * @param {number} k
 * @return {string}
 */
var reverseStr = function(s, k) {
    const arr = [...s];
    for (let i = 0; i < s.length; i += 2 * k) {
        reverseString(arr, i, i + k - 1);
    }
    return arr.join('');
};

var reverseString = function (S, L, R) {
    while (L < R) {
        [S[L], S[R]] = [S[R], S[L]];
        L++;
        R--;
    };
};

3、替换空格

假定一段路径记作字符串 path,其中以 “.” 作为分隔符。现需将路径加密,加密方法为将 path 中的分隔符替换为空格 " ",请返回加密后的字符串。

示例:

// 输入
path = "a.aef.qerf.bb"
// 输出:"a aef qerf bb"

【解题】:

/**
 * @param {string} s
 * @return {string}
 */
var replaceSpace = function(s) {
    const arr = s.split('');
    for (let k in arr) {
        if (arr[k] === ' ') {
            arr[k] = '%20'
        }
    }
    return arr.join('');
};

4、左旋转字符串

字符串的左旋转操作是把字符串前面的若干个字符转移到字符串的尾部。请定义一个函数实现字符串左旋转操作的功能。
比如,输入字符串"abcdefg"和数字2,该函数将返回左旋转两位得到的结果"cdefgab"。

示例:

// 输入
s = "abcdefg", k = 2
// 输出: "cdefgab"

// 输入
s = "lrloseumgh", k = 6
// 输出: "umghlrlose"

【解题】:

/**
 * @param {string} s
 * @param {number} n
 * @return {string}
 */
var reverseLeftWords = function(s, n) {
    let arr = s.trim(' ').split('');
    let i = 0;
    while (i < n) {
        arr.push(arr.shift());
        i++;
    }
    return arr.join('');
};

5、实现 strStr() —— KMP

本题是KMP 经典题目。
给你两个字符串 haystack 和 needle ,请你在 haystack 字符串中找出 needle 字符串的第一个匹配项的下标(下标从 0 开始)。如果 needle 不是 haystack 的一部分,则返回 -1 。

示例:

// 输入
haystack = "sadbutsad", needle = "sad"
// 输出:0
// 解释:"sad" 在下标 0 和 6 处匹配。第一个匹配项的下标是 0 ,所以返回 0 。

// 输入
haystack = "leetcode", needle = "leeto"
// 输出:-1
// 解释:"leeto" 没有在 "leetcode" 中出现,所以返回 -1 。

【解题】:

// 一般解法
/**
 * @param {string} haystack
 * @param {string} needle
 * @return {number}
 */
const strStr = (haystack, needle) => {
    let list = haystack.split(needle);
    if (!list[0] || needle === '') return 0;
    if (list[0]) {
        if (list.length === 1) return -1;
        return list[0].length;
    }
}

// KMP 解法一
/**
 * @param {string} haystack
 * @param {string} needle
 * @return {number}
 */
var strStr = function (haystack, needle) {
    if (needle.length === 0)
        return 0;

    const getNext = (needle) => {
        let next = [];
        let j = 0;
        next.push(j);

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

        return next;
    }

    let next = getNext(needle);
    let j = 0;
    for (let i = 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;
};

// KMP 解法二(前缀表统一减一)
/**
 * @param {string} haystack
 * @param {string} needle
 * @return {number}
 */
var strStr = function (haystack, needle) {
    if (needle.length === 0)
        return 0;

    const getNext = (needle) => {
        let next = [];
        let j = -1;
        next.push(j);

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

        return next;
    }

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

    return -1;
};

【解析】:

(1)、什么是 KMP?

“KMP 算法”是由 Knuth、Morris 和 Pratt 三位学者发明的,故起名为 KMP。

KMP 主要应用在字符串匹配上。它的核心思想是:当出现字符串不匹配时,记录之前已经匹配的内容,这样就不用再从头匹配了。

KMP的重点难点是:如何记录已经匹配的文本内容——前缀表——next 数组。

前缀表用来:记录上次 “模式串与主串(文本串)” 匹配失败的位置,下次匹配时直接跳该位置。大大节省了执行的时间。

字符串的前缀、后缀 和 最长公共前后缀概念:

  • 前缀:包含 最后一个字符的,所有以第一个字符开头的连续子串。
  • 后缀:包含 第一个字符的,所有以最后一个字符结尾的连续子串。
  • 最长公共前后缀:字符串的 前缀 与 后缀 比较,从首位开始算起,相同部分的 最大长度。

举个例子:
在文本串 aabaabaafa 中查找是否出现过一个模式串 aabaaf。
文本串:aabaabaafa
模式串:aabaaf

显而易见,模式串出现在其中了:aab “aabaaf” a。查找过程如下:

首先,根据列出模式串的所有前缀,计算得出“前缀表”。
在这里插入图片描述
故得出:

  • “前缀表” 是:01012
  • 最长公共前后缀 aa 的长度是 2

形成如下关系:
在这里插入图片描述
下面开始比较:
第一次比较:
在这里插入图片描述

可见,主串的第 6 个(下标 5)字符 b 与模式串的第 6 个(下标 5)字符 f 不匹配了。使用前缀表,从上次已经匹配的内容开始再匹配——找到了模式串中第 3 个(下标 2)字符 b 继续开始匹配。

第二次比较:
在这里插入图片描述

可见,在主串与模式串匹配的过程中,当匹配失败时,找到的匹配失败的位置, 那么此时我们要看它的前一个字符的前缀表的数值是多少。前一个字符的前缀表的数值是2, 所以把下标移动到下标2的位置继续比配。最后就在文本串中找到了和模式串匹配的子串了。

也就是说,在某个字符匹配失败时,前缀表会告诉你下一步匹配中,模式串应该跳到哪个位置。这就是前缀表的任务。

很多 KMP 算法的实现都是使用 next 数组来做回退操作,其实:next数组就可以是前缀表

但是很多实现都是把前缀表统一减一(右移一位,初始位置为 -1)之后作为 next 数组。其实这并不涉及到 KMP 的原理,而是具体实现, next 数组既可以就是前缀表,也可以是前缀表统一减一(右移一位,初始位置为 -1)。
在这里插入图片描述
【拓展】力扣上,重复的子字符串 这道题也适合使用KMP算法实现。


五、栈与队列

队列(queue)是先进先出——管子。

栈(stack)是先进后出——杯子。

1、匹配相关的问题要考虑——栈

括号匹配是使用栈解决的经典问题。匹配问题都是栈的强项

(1)、括号匹配

给定一个只包括 ‘(’,‘)’,‘{’,‘}’,‘[’,‘]’ 的字符串,判断字符串是否有效。
有效字符串需满足:

  • 左括号必须用相同类型的右括号闭合。
  • 左括号必须以正确的顺序闭合。
  • 注意空字符串可被认为是有效字符串。

示例:

// 输入: "()"
// 输出: true

// 输入: "()[]{}"
// 输出: true

// 输入: "(]"
// 输出: false

// 输入: "([)]"
// 输出: false

// 输入: "{[]}"
// 输出: true

解答:

/**
 * @param {string} s
 * @return {boolean}
 */
var isValid = function(s) {
    const stack = []
    const map = {
        '(': ')',
        '[': ']',
        '{': '}'
    }
    for (const item of s) {
        if (item in map) {
            stack.push(item)
            continue
        }
        if(map[stack.pop()] !== item) return false;
    }
    return !stack.length;
}

2、栈和队列的互相转化

工作上一定没人这么搞,但是考察对栈、队列理解程度的好题。

(1)、用栈实现队列

使用栈实现队列的下列操作:

  • push(x):将一个元素放入队列的尾部。
  • pop():从队列首部移除元素。
  • peek():返回队列首部的元素。
  • empty():返回队列是否为空。

说明:

  • 你只能使用标准的栈操作 – 也就是只有 push to top, peek/pop from top, size, 和 is empty 操作是合法的。
  • 你所使用的语言也许不支持栈。你可以使用 list 或者 deque(双端队列)来模拟一个栈,只要是标准的栈操作即可。
  • 假设所有操作都是有效的 (例如,一个空的队列不会调用 pop 或者 peek 操作)。

示例:

// 输入:
["MyQueue", "push", "push", "peek", "pop", "empty"]
[[], [1], [2], [], [], []]
// 输出:
[null, null, null, 1, 1, false]

MyQueue myQueue = new MyQueue();
myQueue.push(1); // queue is: [1]
myQueue.push(2); // queue is: [1, 2] (leftmost is front of the queue)
myQueue.peek(); // return 1
myQueue.pop(); // return 1, queue is [2]
myQueue.empty(); // return false

解答:

// JS 版
/**
* 在此处初始化数据结构。
*/
var MyQueue = function() {
   // 使用两个数组的栈方法(push, pop) 实现队列
   this.stackIn = [];
   this.stackOut = [];
};

/**
* 将元素 x 推到队列的后面。
* @param {number} x
* @return {void}
*/
MyQueue.prototype.push = function(x) {
   this.stackIn.push(x);
};

/**
* 从队列前面移除元素并返回该元素。
* @return {number}
*/
MyQueue.prototype.pop = function() {
   const size = this.stackOut.length;
   if(size) {
       return this.stackOut.pop();
   }
   while(this.stackIn.length) {
       this.stackOut.push(this.stackIn.pop());
   }
   return this.stackOut.pop();
};

/**
* 获取前端元素。
* @return {number}
*/
MyQueue.prototype.peek = function() {
   const x = this.pop();
   this.stackOut.push(x);
   return x;
};

/**
* 返回队列是否为空。
* @return {boolean}
*/
MyQueue.prototype.empty = function() {
   return !this.stackIn.length && !this.stackOut.length
};
// TS 版
class MyQueue {
	// 使用两个数组的栈方法(push, pop) 实现队列
    private stackIn: number[]
    private stackOut: number[]
    constructor() {
        this.stackIn = [];
        this.stackOut = [];
    }

    push(x: number): void {
        this.stackIn.push(x);
    }

    pop(): number {
        if (this.stackOut.length === 0) {
            while (this.stackIn.length > 0) {
                this.stackOut.push(this.stackIn.pop()!);
            }
        }
        return this.stackOut.pop()!;
    }

    peek(): number {
        let temp: number = this.pop();
        this.stackOut.push(temp);
        return temp;
    }

    empty(): boolean {
        return this.stackIn.length === 0 && this.stackOut.length === 0;
    }
}

(2)、用队列实现栈

使用队列实现栈的下列操作:

  • push(x):元素 x 入栈。
  • pop():移除栈顶元素。
  • top():获取栈顶元素。
  • empty():返回栈是否为空。

注意:

  • 你只能使用队列的基本操作-- 也就是 push to back, peek/pop from front, size, 和 is empty 这些操作是合法的。
  • 你所使用的语言也许不支持队列。 你可以使用 list 或者 deque(双端队列)来模拟一个队列 , 只要是标准的队列操作即可。
  • 你可以假设所有操作都是有效的(例如, 对一个空的栈不会调用 pop 或者 top 操作)。

示例:

// 输入:
["MyStack", "push", "push", "top", "pop", "empty"]
[[], [1], [2], [], [], []]
// 输出:
[null, null, null, 2, 2, false]

MyStack myStack = new MyStack();
myStack.push(1);
myStack.push(2);
myStack.top(); // 返回 2
myStack.pop(); // 返回 2
myStack.empty(); // 返回 False

解答:
【使用两个队列实现】

// JS 版
/**
 * 在此处初始化数据结构。
 */
var MyStack = function() {
    this.queue1 = [];
    this.queue2 = [];
};

/**
 * 将元件 x 推到堆叠上 
 * @param {number} x
 * @return {void}
 */
MyStack.prototype.push = function(x) {
    this.queue1.push(x);
};

/**
 * 移除堆栈顶部的元素并返回该元素。
 * @return {number}
 */
MyStack.prototype.pop = function() {
    // 减少两个队列交换的次数, 只有当queue1为空时,交换两个队列
    if(!this.queue1.length) {
        [this.queue1, this.queue2] = [this.queue2, this.queue1];
    }
    while(this.queue1.length > 1) {
        this.queue2.push(this.queue1.shift());
    }
    return this.queue1.shift();
};

/**
 * 获取顶部元素。
 * @return {number}
 */
MyStack.prototype.top = function() {
    const x = this.pop();
    this.queue1.push(x);
    return x;
};

/**
 * 返回堆栈是否为空。
 * @return {boolean}
 */
MyStack.prototype.empty = function() {
    return !this.queue1.length && !this.queue2.length;
};
// TS 版
class MyStack {
    private queue: number[];
    private tempQueue: number[];
    constructor() {
        this.queue = [];
        this.tempQueue = [];
    }

    push(x: number): void {
        this.queue.push(x);
    }

    pop(): number {
        for (let i = 0, length = this.queue.length - 1; i < length; i++) {
            this.tempQueue.push(this.queue.shift()!);
        }
        let res: number = this.queue.pop()!;
        let temp: number[] = this.queue;
        this.queue = this.tempQueue;
        this.tempQueue = temp;
        return res;
    }

    top(): number {
        let res: number = this.pop();
        this.push(res);
        return res;
    }

    empty(): boolean {
        return this.queue.length === 0;
    }
}

【使用一个队列实现】

// JS 版
/**
 * 在此处初始化数据结构。
 */
var MyStack = function() {
    this.queue = [];
};

/**
 * 将元件x推到堆叠上。
 * @param {number} x
 * @return {void}
 */
MyStack.prototype.push = function(x) {
    this.queue.push(x);
};

/**
 * 移除堆栈顶部的元素并返回该元素。
 * @return {number}
 */
MyStack.prototype.pop = function() {
    let size = this.queue.length;
    while(size-- > 1) {
        this.queue.push(this.queue.shift());
    }
    return this.queue.shift();
};

/**
 * 获取顶部元素。
 * @return {number}
 */
MyStack.prototype.top = function() {
    const x = this.pop();
    this.queue.push(x);
    return x;
};

/**
 * 返回堆栈是否为空。
 * @return {boolean}
 */
MyStack.prototype.empty = function() {
    return !this.queue.length;
};
// TS 版
class MyStack {
    private queue: number[];
    constructor() {
        this.queue = [];
    }

    push(x: number): void {
        this.queue.push(x);
    }

    pop(): number {
        for (let i = 0, length = this.queue.length - 1; i < length; i++) {
            this.queue.push(this.queue.shift()!);
        }
        return this.queue.shift()!;
    }

    top(): number {
        let res: number = this.pop();
        this.push(res);
        return res;
    }

    empty(): boolean {
        return this.queue.length === 0;
    }
}

3、单调队列

(1)、滑动窗口最大值

给定一个数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
返回滑动窗口中的最大值。

示例 :

// 输入:
nums = [1,3,-1,-3,5,3,6,7], k = 3
// 输出:[3,3,5,5,6,7]

/** 解释:
滑动窗口的位置                最大值
[1  3  -1] -3  5  3  6  7       3
 1 [3  -1  -3] 5  3  6  7       3
 1  3 [-1  -3  5] 3  6  7       5
 1  3  -1 [-3  5  3] 6  7       5
 1  3  -1  -3 [5  3  6] 7       6
 1  3  -1  -3  5 [3  6  7]      7
**/

// 输入:
nums = [1], k = 1
// 输出:[1]

解答:

// JS 版
/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number[]}
 */
var maxSlidingWindow = function (nums, k) {
    class MonoQueue {
        queue;
        constructor() {
            this.queue = [];
        }
        enqueue(value) {
            let back = this.queue[this.queue.length - 1];
            while (back !== undefined && back < value) {
                this.queue.pop();
                back = this.queue[this.queue.length - 1];
            }
            this.queue.push(value);
        }
        dequeue(value) {
            let front = this.front();
            if (front === value) {
                this.queue.shift();
            }
        }
        front() {
            return this.queue[0];
        }
    }
    let helperQueue = new MonoQueue();
    let i = 0, j = 0;
    let resArr = [];
    while (j < k) {
        helperQueue.enqueue(nums[j++]);
    }
    resArr.push(helperQueue.front());
    while (j < nums.length) {
        helperQueue.enqueue(nums[j]);
        helperQueue.dequeue(nums[i]);
        resArr.push(helperQueue.front());
        i++, j++;
    }
    return resArr;
};
// TS 版
function maxSlidingWindow(nums: number[], k: number): number[] {
    /** 单调递减队列 */
    class MonoQueue {
        private queue: number[];
        constructor() {
            this.queue = [];
        };
        /** 入队:value如果大于队尾元素,则将队尾元素删除,直至队尾元素大于value,或者队列为空 */
        public enqueue(value: number): void {
            let back: number | undefined = this.queue[this.queue.length - 1];
            while (back !== undefined && back < value) {
                this.queue.pop();
                back = this.queue[this.queue.length - 1];
            }
            this.queue.push(value);
        };
        /** 出队:只有当队头元素等于value,才出队 */
        public dequeue(value: number): void {
            let top: number | undefined = this.top();
            if (top !== undefined && top === value) {
                this.queue.shift();
            }
        }
        public top(): number | undefined {
            return this.queue[0];
        }
    }
    const helperQueue: MonoQueue = new MonoQueue();
    let i: number = 0,
        j: number = 0;
    let resArr: number[] = [];
    while (j < k) {
        helperQueue.enqueue(nums[j++]);
    }
    resArr.push(helperQueue.top()!);
    while (j < nums.length) {
        helperQueue.enqueue(nums[j]);
        helperQueue.dequeue(nums[i]);
        resArr.push(helperQueue.top()!);
        j++, i++;
    }
    return resArr;
};

4、单调栈

(1)、每日温度

请根据每日 气温 列表,重新生成一个列表。对应位置的输出为:要想观测到更高的气温,至少需要等待的天数。如果气温在这之后都不会升高,请在该位置用 0 来代替。
例如,给定一个列表 temperatures = [73, 74, 75, 71, 69, 72, 76, 73],你的输出应该是 [1, 1, 4, 2, 1, 1, 0, 0]。
提示:气温 列表长度的范围是 [1, 30000]。每个气温的值的均为华氏度,都是在 [30, 100] 范围内的整数。

示例:

// 输入: 
temperatures = [73,74,75,71,69,72,76,73]
// 输出: [1,1,4,2,1,1,0,0]

// 输入: 
temperatures = [30,40,50,60]
// 输出: [1,1,1,0]

// 输入: 
temperatures = [30,60,90]
// 输出: [1,1,0]

解答:

var dailyTemperatures = function(temperatures) {
    const n = temperatures.length;
    const res = Array(n).fill(0);
    const stack = [];  // 递增栈:用于存储元素右面第一个比他大的元素下标
    stack.push(0);
    for (let i = 1; i < n; i++) {
        while (stack.length && temperatures[i] > temperatures[stack[stack.length - 1]]) {
            const top = stack.pop();
            res[top] = i - top;
        }
        stack.push(i);
    }
    return res;
};

5、栈与递归

递归的实现是栈:每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,然后递归返回的时候,从栈顶弹出上一次递归的各项参数,所以这就是递归为什么可以返回上一层位置的原因。

(1)、删除字符串中的所有相邻重复项

给出由小写字母组成的字符串 S,重复项删除操作会选择两个相邻且相同的字母,并删除它们。
在 S 上反复执行重复项删除操作,直到无法继续删除。
在完成所有重复项删除操作后返回最终的字符串。答案保证唯一。

示例:

// 输入:"abbaca"
// 输出:"ca"
// 解释:例如,在 "abbaca" 中,我们可以删除 "bb" 由于两字母相邻且相同,这是此时唯一可以执行删除操作的重复项。
// 之后我们得到字符串 "aaca",其中又只有 "aa" 可以执行重复项删除操作,所以最后的字符串为 "ca"。

解答:
【使用栈】

var removeDuplicates = function(s) {
    const stack = [];
    for(const x of s) {
        let c = null;
        if(stack.length && x === (c = stack.pop())) continue;
        c && stack.push(c);
        stack.push(x);
    }
    return stack.join("");
};

【双指针(模拟栈)】

var removeDuplicates = function(s) {
    s = [...s];
    let top = -1; // 指向栈顶元素的下标
    for(let i = 0; i < s.length; i++) {
        if(top === -1 || s[top] !== s[i]) { // top === -1 即空栈
            s[++top] = s[i]; // 入栈
        } else {
            top--; // 推出栈
        }
    }
    s.length = top + 1; // 栈顶元素下标 + 1 为栈的长度
    return s.join('');
};

6、优先级队列——堆

优先级队列其实就是一个披着队列外衣的堆。因为优先级队列对外接口只是从队头取元素,从队尾添加元素,再无其他取元素的方式,看起来就是一个队列。

堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。

(1)、前 K 个高频元素

给定一个非空的整数数组,返回其中出现频率前 k 高的元素。
提示:

  • 你可以假设给定的 k 总是合理的,且 1 ≤ k ≤ 数组中不相同的元素的个数。
  • 你的算法的时间复杂度必须优于 O ( n log ⁡ n ) O(n \log n) O(nlogn) , n 是数组的大小。
  • 题目数据保证答案唯一,换句话说,数组中前 k 个高频元素的集合是唯一的。
  • 你可以按任意顺序返回答案。

示例:

// 输入: 
nums = [1,1,1,2,2,3], k = 2
// 输出: [1,2]

// 输入: 
nums = [1], k = 1
// 输出: [1]

解答:

// JS 版
// js 没有堆 需要自己构造
class Heap {
    constructor(compareFn) {
        this.compareFn = compareFn;
        this.queue = [];
    }

    // 添加
    push(item) {
        // 推入元素
        this.queue.push(item);

        // 上浮
        let index = this.size() - 1; // 记录推入元素下标
        let parent = Math.floor((index - 1) / 2); // 记录父节点下标

        while (parent >= 0 && this.compare(parent, index) > 0) { // 注意compare参数顺序
            [this.queue[index], this.queue[parent]] = [this.queue[parent], this.queue[index]];

            // 更新下标
            index = parent;
            parent = Math.floor((index - 1) / 2);
        }
    }

    // 获取堆顶元素并移除
    pop() {
        // 堆顶元素
        const out = this.queue[0];

        // 移除堆顶元素 填入最后一个元素
        this.queue[0] = this.queue.pop();

        // 下沉
        let index = 0; // 记录下沉元素下标
        let left = 1; // left 是左子节点下标 left + 1 则是右子节点下标
        let searchChild = this.compare(left, left + 1) > 0 ? left + 1 : left;

        while (searchChild !== undefined && this.compare(index, searchChild) > 0) {
        	// 注意compare参数顺序
            [this.queue[index], this.queue[searchChild]] = [this.queue[searchChild], this.queue[index]];

            // 更新下标
            index = searchChild;
            left = 2 * index + 1;
            searchChild = this.compare(left, left + 1) > 0 ? left + 1 : left;
        }

        return out;
    }

    size() {
        return this.queue.length;
    }

    // 使用传入的 compareFn 比较两个位置的元素
    compare(index1, index2) {
        // 处理下标越界问题
        if (this.queue[index1] === undefined) return 1;
        if (this.queue[index2] === undefined) return -1;

        return this.compareFn(this.queue[index1], this.queue[index2]);
    }

}

const topKFrequent = function (nums, k) {
    const map = new Map();

    for (const num of nums) {
        map.set(num, (map.get(num) || 0) + 1);
    }

    // 创建小顶堆
    const heap= new Heap((a, b) => a[1] - b[1]);

    // entry 是一个长度为2的数组,0位置存储key,1位置存储value
    for (const entry of map.entries()) {
        heap.push(entry);

        if (heap.size() > k) {
            heap.pop();
        }
    }

    // return heap.queue.map(e => e[0]);

    const res = [];

    for (let i = heap.size() - 1; i >= 0; i--) {
        res[i] = heap.pop()[0];
    }

    return res;
};
// TS 版
function topKFrequent(nums: number[], k: number): number[] {
    const countMap: Map<number, number> = new Map();
    for (let num of nums) {
        countMap.set(num, (countMap.get(num) || 0) + 1);
    }
    // tS没有最小堆的数据结构,所以直接对整个数组进行排序,取前k个元素
    return [...countMap.entries()]
        .sort((a, b) => b[1] - a[1])
        .slice(0, k)
        .map(i => i[0]);
};

六、二叉树·入门

深入学习二叉树请戳这里

1、二叉树的基础知识

(1)、二叉树的定义

二叉树的定义和链表是差不多的,相对于链表 ,二叉树的节点里多了一个指针, 有两个指针,指向左右孩子。

例如:

// JS 版
function TreeNode(val, left, right) {
    this.val = (val===undefined ? 0 : val)
    this.left = (left===undefined ? null : left)
    this.right = (right===undefined ? null : right)
}

// TS 版
class TreeNode {
    public val: number;
    public left: TreeNode | null;
    public right: TreeNode | null;
    constructor(val?: number, left?: TreeNode, right?: TreeNode) {
        this.val = val === undefined ? 0 : val;
        this.left = left === undefined ? null : left;
        this.right = right === undefined ? null : right;
    }
}

(2)、二叉树的种类

二叉树有两种主要的形式:满二叉树 和 完全二叉树

①、满二叉树

满二叉树:如果一棵二叉树只有度为0的结点和度为2的结点,并且度为0的结点在同一层上,则这棵二叉树为满二叉树。

一棵深度为 k 的满二叉树有 2^k-1 个节点的二叉树。
在这里插入图片描述

②、完全二叉树

完全二叉树:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层,则该层包含 1~ 2^(h-1) 个节点。

在这里插入图片描述

③、二叉搜索树

二叉搜索树:是一个有序树——是有数值的树。

  • 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
  • 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
  • 它的左、右子树也分别为二叉排序树
④、平衡二叉搜索树

平衡二叉搜索树又被称为AVL(Adelson-Velsky and Landis)树,且具有以下性质:

  • 它是一棵空树或它的左右两个子树的高度差的绝对值不超过1。
  • 它的左右两个子树都是一棵平衡二叉树。

在这里插入图片描述

(3)、二叉树的存储方式

二叉树可以链式存储,也可以顺序存储。

①、二叉树的链式存储

在这里插入图片描述

②、二叉树的顺序存储

二叉树的顺序存储其实就是用数组来存储二叉树。
在这里插入图片描述
用数组来存储二叉树如何遍历的呢?

如果父节点的数组下标是 i,那么它的左孩子就是 i * 2 + 1,右孩子就是 i * 2 + 2。

但是用链式表示的二叉树,更有利于我们理解,所以一般我们都是用链式存储二叉树。

(4)、二叉树的遍历方式

二叉树主要有两种遍历方式:

  • 深度优先遍历:先往深走,遇到叶子节点再往回走。
    • 前序遍历(递归法,迭代法)——中左右。
    • 中序遍历(递归法,迭代法)——左中右。
    • 后序遍历(递归法,迭代法)——左右中。
  • 广度优先遍历:一层一层的去遍历。
    • 层次遍历(迭代法)

递归三要素:

  • 确定递归函数的参数和返回值
  • 确定终止条件
  • 确定单层递归的逻辑

2、二叉树的前序遍历

给你二叉树的根节点 root ,返回它节点值的 前序 遍历。

示例:

// 输入:
root = [1,null,2,3]
// 输出:[1,2,3]

// 输入:
root = []
// 输出:[]

// 输入:
root = [1]
// 输出:[1]

解答:
【递归】

var preorderTraversal = function(root) {
 let res=[];
 const dfs=function(root){
     if(root===null)return ;
     //先序遍历所以从父节点开始
     res.push(root.val);
     //递归左子树
     dfs(root.left);
     //递归右子树
     dfs(root.right);
 }
 //只使用一个参数 使用闭包进行存储结果
 dfs(root);
 return res;
};

【迭代】

// 入栈 右 -> 左
// 出栈 中 -> 左 -> 右
var preorderTraversal = function(root, res = []) {
    if(!root) return res;
    const stack = [root];
    let cur = null;
    while(stack.length) {
        cur = stack.pop();
        res.push(cur.val);
        cur.right && stack.push(cur.right);
        cur.left && stack.push(cur.left);
    }
    return res;
};

3、二叉树的中序遍历

给定一个二叉树的根节点 root ,返回 它的 中序 遍历 。

示例:

// 输入:
root = [1,null,2,3]
// 输出:[1,3,2]

// 输入:
root = []
// 输出:[]

// 输入:
root = [1]
// 输出:[1]

解答:
【递归】

var inorderTraversal = function(root) {
    let res=[];
    const dfs=function(root){
        if(root===null){
            return ;
        }
        dfs(root.left);
        res.push(root.val);
        dfs(root.right);
    }
    dfs(root);
    return res;
};

【迭代】

// 入栈 左 -> 右
// 出栈 左 -> 中 -> 右

var inorderTraversal = function(root, res = []) {
    const stack = [];
    let cur = root;
    while(stack.length || cur) {
        if(cur) {
            stack.push(cur);
            // 左
            cur = cur.left;
        } else {
            // --> 弹出 中
            cur = stack.pop();
            res.push(cur.val); 
            // 右
            cur = cur.right;
        }
    };
    return res;
};

4、二叉树的后序遍历

给你一棵二叉树的根节点 root ,返回其节点值的 后序遍历 。

示例:

// 输入:
root = [1,null,2,3]
// 输出:[3,2,1]

// 输入:
root = []
// 输出:[]

// 输入:
root = [1]
// 输出:[1]

解答:
【递归】

var postorderTraversal = function(root) {
    let res=[];
    const dfs=function(root){
        if(root===null){
            return ;
        }
        dfs(root.left);
        dfs(root.right);
        res.push(root.val);
    }
    dfs(root);
    return res;
};

【迭代】

// 入栈 左 -> 右
// 出栈 中 -> 右 -> 左 结果翻转

var postorderTraversal = function(root, res = []) {
    if (!root) return res;
    const stack = [root];
    let cur = null;
    do {
        cur = stack.pop();
        res.push(cur.val);
        cur.left && stack.push(cur.left);
        cur.right && stack.push(cur.right);
    } while(stack.length);
    return res.reverse();
};

七、回溯·入门

回溯是递归的副产品,只要有递归就会有回溯,所以回溯法也经常和二叉树遍历,深度优先搜索混在一起,因为这两种方式都是用了递归。

回溯法就是暴力搜索,并不是什么高效的算法。

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

  • 组合问题:N个数里面按一定规则找出k个数的集合
  • 切割问题:一个字符串按一定规则有几种切割方式
  • 子集问题:一个N个数的集合里有多少符合条件的子集
  • 排列问题:N个数按一定规则全排列,有几种排列方式
  • 棋盘问题:N皇后,解数独等等

回溯法确实不好理解,所以需要把回溯法抽象为一个树形结构图来理解就容易多了。具体结构剖析请参阅这里


八、贪心·入门

贪心算法并没有固定的套路,唯一的难点就是:如何通过局部最优,推出整体最优。

刷题或者面试的时候,手动模拟一下感觉可以局部最优推出整体最优,而且想不到反例,那么就试一试贪心。

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

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

具体请参阅这里


九、动态规划·入门

如果某一问题有很多重叠子问题,使用动态规划是最有效的。

动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的。不用死扣动规和贪心的理论区别,做做题目自然就知道了。

动态规划的五步曲:

  • 确定dp数组(dp table)以及下标的含义
  • 确定递推公式
  • dp数组如何初始化
  • 确定遍历顺序
  • 举例推导dp数组

做动规的题目,写代码之前一定要把状态转移在dp数组的上具体情况模拟一遍,心中有数,确定最后推出的是想要的结果。

做动态规划题目的灵魂三问:

  • 这道题目我举例推导状态转移公式了么?
  • 我打印dp数组的日志了么?
  • 打印出来了dp数组和我想的一样么?

找 动态规划 的问题的最好方式就是把dp数组打印出来,看看究竟是不是按照自己思路推导的。

具体请参阅这里




【参考文章】
leecode
用 js 写一个链表(详细注释)
代码随想录

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值