Kiner算法刷题记(十五):中段综合训练刷题

系列文章导引

  • 系列文章导引

开源项目

本系列所有文章都将会收录到GitHub中统一收藏与管理,欢迎ISSUEStar

GitHub传送门:Kiner算法算题记

前言

经过了数个月的学习,我们了解了包括链表、队列、栈、二叉树、堆(优先队列)、并查集、哈希表、单调队列、单调栈等数据结构,知道了他们的概念、性质、基本代码实现和应用场景,还学习了常见的排序算法如:快速排序、归并排序、计数排序、基数排序、拓扑排序,常见的查找算法:二分查找、深度优先搜索和广度优先搜索。

那么,现在就来一个“期中考试”测验一下对这些数据结构已经算法的掌握程度吧。

题目列表

1367. 二叉树中的列表

  • 涉及知识点:二叉树、链表、递归函数设计

958. 二叉树的完全性检验

  • 涉及知识点:完全二叉树

剑指 Offer 36. 二叉搜索树与双向链表

  • 涉及知识点:二叉搜索树(二叉排序树)、双向链表、循环链表、二叉树的中序遍历

464. 我能赢吗

  • 涉及知识点:深度优先搜索(dfs)、函数与数组的关系、空间换时间、记忆化剪枝、二进制标记位、二进制基本操作、哈希表

437. 路径总和 III

  • 涉及知识点:二叉树遍历、前缀和、哈希表、回溯思想

190. 颠倒二进制位

  • 涉及知识点:二进制基础、二进制运算、双指针思想

402. 移掉 K 位数字

  • 涉及知识点:单调栈思想、数据结构与数据结构思想的转变

1081. 不同字符的最小子序列

  • 涉及知识点:单调栈思想、数据结构与数据结构思想的转变、字典序

1499. 满足不等式的最大值

  • 涉及知识点:单调队列、滑动窗口、公式推导

题解

1367. 二叉树中的列表
解题思路

这道题涉及到多个我们之前学习过的知识点,其中包括了二叉树、链表、递归函数的设计。那么,首先,我们先来思考一下这样的题目要如何思考。

首先,我们先考虑一下特殊的边界条件。

  1. 假设链表的头结点是空节点,那么一个空节点肯定是任意二叉树的子路径。
  2. 假设链表头结点不为空,但二叉树根节点为空,那么一个空的二叉树不可能找到任意非空链表的子路径

上述两种情况都排除之后,我们就来考虑一下,如何在非空二叉树中匹配一段非空链表。首先,要能够匹配上,至少链表的头结点的值需要跟二叉树的根节点或左右子树中的某个节点的值匹配上,如果一个匹配的节点都找不到,那么不可能存在匹配的子路径。

如果根节点匹配上了,那么我们只需要递归了让链表节点的下一个节点与二叉树的左子树和右子树继续新一轮的匹配即可。

好了,思路说完了。我们先来设计递归函数。

这道题起始我们需要设计两个递归函数:

  1. 用于在二叉树的根节点、左子树、右子树中找到第一个与链表头结点值相同的节点的函数
    • 定义递归函数的含义
      • 在以root为根节点的二叉树中查找是否存在与head节点值相同的节点
    • 设置边界条件
      • 链表头结点为空
      • 链表头结点不为空,二叉树头结点为空
      • 链表头结点与二叉树根节点值相等并且在当前二叉树的左右子树中可以找到匹配链表所有节点值的连续节点
    • 实现递归过程
      • 递归遍历二叉树根节点的左子树
      • 递归遍历二叉树根节点的右子树
  2. 用于匹配链表所有节点的值的递归函数
    • 定义递归函数的含义
      • 在以root为根节点的二叉树是否存在完全匹配head链表中每一个节点值的子路径
    • 设置边界条件
      • 链表头结点为空
      • 链表头结点不为空,二叉树头结点为空
      • 当前链表头结点的值不等于当前二叉树根节点的值
    • 实现递归过程
      • 在二叉树的左子树中匹配链表的下一个节点
      • 在二叉树的右子树中匹配链表的下一个节点
代码实现
/*
 * @lc app=leetcode.cn id=1367 lang=typescript
 *
 * [1367] 二叉树中的列表
 *
 * https://leetcode-cn.com/problems/linked-list-in-binary-tree/description/
 *
 * algorithms
 * Medium (41.28%)
 * Likes:    93
 * Dislikes: 0
 * Total Accepted:    14.3K
 * Total Submissions: 34.5K
 * Testcase Example:  '[4,2,8]\n[1,4,4,null,2,2,null,1,null,6,8,null,null,null,null,1,3]'
 *
 * 给你一棵以 root 为根的二叉树和一个 head 为第一个节点的链表。
 * 
 * 如果在二叉树中,存在一条一直向下的路径,且每个点的数值恰好一一对应以 head 为首的链表中每个节点的值,那么请你返回 True ,否则返回 False
 * 。
 * 
 * 一直向下的路径的意思是:从树中某个节点开始,一直连续向下的路径。
 * 
 * 
 * 
 * 示例 1:
 * 
 * 
 * 
 * 输入:head = [4,2,8], root =
 * [1,4,4,null,2,2,null,1,null,6,8,null,null,null,null,1,3]
 * 输出:true
 * 解释:树中蓝色的节点构成了与链表对应的子路径。
 * 
 * 
 * 示例 2:
 * 
 * 
 * 
 * 输入:head = [1,4,2,6], root =
 * [1,4,4,null,2,2,null,1,null,6,8,null,null,null,null,1,3]
 * 输出:true
 * 
 * 
 * 示例 3:
 * 
 * 输入:head = [1,4,2,6,8], root =
 * [1,4,4,null,2,2,null,1,null,6,8,null,null,null,null,1,3]
 * 输出:false
 * 解释:二叉树中不存在一一对应链表的路径。
 * 
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 二叉树和链表中的每个节点的值都满足 1 <= node.val <= 100 。
 * 链表包含的节点数目在 1 到 100 之间。
 * 二叉树包含的节点数目在 1 到 2500 之间。
 * 
 * 
 */

// @lc code=start
/**
 * Definition for singly-linked list.
 * class ListNode {
 *     val: number
 *     next: ListNode | null
 *     constructor(val?: number, next?: ListNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.next = (next===undefined ? null : next)
 *     }
 * }
 */

/**
 * Definition for a binary tree node.
 * class TreeNode {
 *     val: number
 *     left: TreeNode | null
 *     right: TreeNode | null
 *     constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.left = (left===undefined ? null : left)
 *         this.right = (right===undefined ? null : right)
 *     }
 * }
 */

function judge(head, root): boolean {
    // 如果链表是一个空链表,那么这个链表在任意二叉树中找到子路径
    if(!head) return true;
    // 如果二叉树是一个空树,那么不可能有子路径存在
    if(!root) return false;
    // 如果当前二叉树节点与链表节点的值不相等,说明不匹配
    if(root.val !== head.val) return false;
    // 如果相等,则分别让二叉树的左子树和右子树匹配链表下一个节点,只要任意一条子树能匹配就可以
    return judge(head.next, root.left) || judge(head.next, root.right);
}

function isSubPath(head: ListNode | null, root: TreeNode | null): boolean {
    // 如果链表是一个空链表,那么这个链表在任意二叉树中找到子路径
    if(!head) return true;
    // 如果二叉树是一个空树,那么不可能有子路径存在
    if(!root) return false;
    // 如果链表和二叉树都不为空,则用链表头结点与二叉树的节点对齐,找到一个与链表头结点值相同的二叉树节点
    if(root.val === head.val && judge(head, root)) return true;

    // 如果根节点没有对齐,则递归往左右子树查找对齐的节点
    return isSubPath(head, root.left) || isSubPath(head, root.right);
};
// @lc code=end


958. 二叉树的完全性检验
解题思路

这题其实有很多种解题思路,这里给出其中两种:

广搜 + 完全二叉树节点可编号特性求解
  • 复杂度:
    • 时间复杂度:O(n)
    • 空间复杂度:O(n)

由于完全二叉树拥有节点可编号特性,即:

设根节点的编号为:i,那么根节点左子树根节点的编号则是2 * i,根节点右子树根节点的编号则是2 * i + 1

利用这个特性,我们可以先使用广搜遍历一遍二叉树,并在遍历的过程中给二叉树的每个节点进行编号,遍历完成后,我们只需要拿最后一个节点的编号对比是否等于二叉树的节点总数即可。

利用完全二叉树节点数量特性求解
  • 复杂度:
    • 时间复杂度:O(n)
    • 空间复杂度:O(1)

这道题涉及到了完全二叉树的特殊性质,我们可以这么思考:我们要判断一颗树是否是完全二叉树,可以把问题拆分成判断左子树是否是完全二叉树和右子树是否是完全二叉树,如果左右子树都是完全二叉树,那么,这个二叉树就是完全二叉树。

接下来,我们要解决的最重要的问题还是如何判断一棵树是完全二叉树呢?

依据完全二叉树的性质,除了最后一层外,其他层都是满的,那么假如说我们的二叉树总共有n个节点,那么我们从第一层到倒数第二层的节点总数量为:k = 2 * m - 1,推导过程如下:

# 第一层节点数量:1           节点总数为:1
# 第二层节点数量:2		        节点总数为:3
# 第三层节点数量:4           节点总数为:7
# ...
# 第m层节点数量:2^(m-1)    节点总数为:2 * m - 1

那么,我们最后一层的节点数量就应该为n - k

然后,我们再来想想,最后一层属于左子树部分的节点数量L,根据上面的规律,最后一层左子树部分节点的数量最多应为上一层即倒数第二层的节点数量(当最后一层的节点数量不足m个时,根据完全二叉树的性质,其右侧不可能有节点,那么此时最后一层左子树节点数量为n - k),因此,L = Math.min(m, n-k)

那么,最后一层右子树的节点数量R = n - k - L

根据上述推到出来的公式,其实我们只需要判断左右子树是否都满足上述条件的公式即可。

代码演示
广搜 + 完全二叉树节点可编号特性求解
/*
 * @lc app=leetcode.cn id=958 lang=typescript
 *
 * [958] 二叉树的完全性检验
 *
 * https://leetcode-cn.com/problems/check-completeness-of-a-binary-tree/description/
 *
 * algorithms
 * Medium (52.99%)
 * Likes:    139
 * Dislikes: 0
 * Total Accepted:    20.4K
 * Total Submissions: 38.4K
 * Testcase Example:  '[1,2,3,4,5,6]'
 *
 * 给定一个二叉树,确定它是否是一个完全二叉树。
 * 
 * 百度百科中对完全二叉树的定义如下:
 * 
 * 若设二叉树的深度为 h,除第 h 层外,其它各层 (1~h-1) 的结点数都达到最大个数,第 h
 * 层所有的结点都连续集中在最左边,这就是完全二叉树。(注:第 h 层可能包含 1~ 2^h 个节点。)
 * 
 * 
 * 
 * 示例 1:
 * 
 * 
 * 
 * 输入:[1,2,3,4,5,6]
 * 输出:true
 * 解释:最后一层前的每一层都是满的(即,结点值为 {1} 和 {2,3} 的两层),且最后一层中的所有结点({4,5,6})都尽可能地向左。
 * 
 * 
 * 示例 2:
 * 
 * 
 * 
 * 输入:[1,2,3,4,5,null,7]
 * 输出:false
 * 解释:值为 7 的结点没有尽可能靠向左侧。
 * 
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 树中将会有 1 到 100 个结点。
 * 
 * 
 */

// @lc code=start
/**
 * Definition for a binary tree node.
 * class TreeNode {
 *     val: number
 *     left: TreeNode | null
 *     right: TreeNode | null
 *     constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.left = (left===undefined ? null : left)
 *         this.right = (right===undefined ? null : right)
 *     }
 * }
 */

// 定义一个用于存储节点和编号关系的数据结构
type Data = {
    node: TreeNode,
    no: number
}

function isCompleteTree(root: TreeNode | null): boolean {
    // 使用广搜法+完全二叉树节点可编号的特点
    if(!root) return true;
    // 在广搜队列中加入根节点,并编号为1
    let queue: Data[] = [{node: root, no: 1}];
    // 用于记录最后一个节点
    let lastNode: Data;
    // 用于记录二叉树总结点数量
    let count = 0;
    while(queue.length) {
        // 累加总结点数量
        count++;
        const data = queue.shift();
        lastNode = data;
        const {node, no} = data;
        // 按照规则对二叉树的左子树根节点和右子树根节点进行编号
        node.left && queue.push({node: node.left, no: no*2});
        node.right && queue.push({node: node.right, no: no*2+1});
    }
    // 比较编号是否等于节点数量即可
    return lastNode.no === count;
}

// @lc code=end


利用完全二叉树节点数量特性求解
/*
 * @lc app=leetcode.cn id=958 lang=typescript
 *
 * [958] 二叉树的完全性检验
 *
 * https://leetcode-cn.com/problems/check-completeness-of-a-binary-tree/description/
 *
 * algorithms
 * Medium (52.99%)
 * Likes:    139
 * Dislikes: 0
 * Total Accepted:    20.4K
 * Total Submissions: 38.4K
 * Testcase Example:  '[1,2,3,4,5,6]'
 *
 * 给定一个二叉树,确定它是否是一个完全二叉树。
 * 
 * 百度百科中对完全二叉树的定义如下:
 * 
 * 若设二叉树的深度为 h,除第 h 层外,其它各层 (1~h-1) 的结点数都达到最大个数,第 h
 * 层所有的结点都连续集中在最左边,这就是完全二叉树。(注:第 h 层可能包含 1~ 2^h 个节点。)
 * 
 * 
 * 
 * 示例 1:
 * 
 * 
 * 
 * 输入:[1,2,3,4,5,6]
 * 输出:true
 * 解释:最后一层前的每一层都是满的(即,结点值为 {1} 和 {2,3} 的两层),且最后一层中的所有结点({4,5,6})都尽可能地向左。
 * 
 * 
 * 示例 2:
 * 
 * 
 * 
 * 输入:[1,2,3,4,5,null,7]
 * 输出:false
 * 解释:值为 7 的结点没有尽可能靠向左侧。
 * 
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 树中将会有 1 到 100 个结点。
 * 
 * 
 */

// @lc code=start
/**
 * Definition for a binary tree node.
 * class TreeNode {
 *     val: number
 *     left: TreeNode | null
 *     right: TreeNode | null
 *     constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.left = (left===undefined ? null : left)
 *         this.right = (right===undefined ? null : right)
 *     }
 * }
 */

function getCnt(root: TreeNode | null): number {
    if(!root) return 0;
    return getCnt(root.left) + getCnt(root.right) + 1;
}
/**
 * 判断以root为根节点的二叉树是否满足完全二叉树的性质
 * @param root 根节点
 * @param n 以root为根节点的二叉树的节点总数
 * @param m 以root为根节点的二叉树的倒数第二层节点数量
 */
function judge(root: TreeNode | null, n: number, m: number): boolean {
    // 如果根节点为空,那么只有节点总数为0时才是完全二叉树,否则不是
    if(!root) return n === 0;
    // 如果根节点不为空,但是节点总数为0,则不可能是完全二叉树
    if(n===0) return false;
    // 如果n为1时,说明当前节点没有子节点,因此,root需要满足此条件才是完全二叉树
    if(n===1) return !root.left && !root.right;
    // 计算第一层到倒数第二层的节点总数
    let k = Math.max(0, 2 * m - 1);
    // 计算最后一层左子树部分的节点数量
    let l = Math.min(n-k, m);
    // 最后一层右子树部分节点数量
    let r = n - k - l;

    // 递归判断左子树和右子树是否满足完全二叉树的定义
    // 左子树节点总数l1 = (第一层到倒数第二层节点总数(k) - 根节点数量(1)) / 2 + 最后一层左子树节点数量(l)
    let l1 = (k - 1) / 2 + l;
    // 右子树节点总数r1 = (第一层到倒数第二层节点总数(k) - 根节点数量(1)) / 2 + 最后一层右子树节点数量(r)
    let r1 = (k - 1) / 2 + r;
    // 左子树和右子树倒数第二层节点数量应该为当前m的一般
    let m1 = m / 2;
    return judge(root.left, l1, m1) && judge(root.right, r1, m1);
}

function isCompleteTree(root: TreeNode | null): boolean {
    if(!root) return true;
    // 首先计算二叉树总共有多少个节点n
    const n = getCnt(root);
    // 计算从第一层到倒数第二层的节点数量以及倒数第二层的节点数量
    let count = 1, m = 1;
    // count为累计节点数量,m为倒数第二层的节点数量,我们每一层的累计节点数量=上一层的累计节点数量+当前层的节点数量(当前层节点数量=2*上一层的节点数量)
    // 计算到最后一个全满的层为止
    while(count + 2 * m <= n) {
        // 每次m乘以2,代表当前层级的节点数量
        m<<=1;
        // 累计节点数
        count+=m;
    }

    return judge(root, n, m);
};
// @lc code=end


剑指 Offer 36. 二叉搜索树与双向链表
解题思路

根据题意,我们需要将一个二叉搜索树转成一个有序的双向循环链表。我们知道,二叉搜索树又叫二叉排序树,他的中序遍历结果就是一个有序的序列,而我们也可以在中序遍历的过程中,将原本的树节点转换成双向链表。最后,中序遍历结束之后,我们只需要将双向链表的头尾链接起来就是有序双向循环链表了。

代码演示
/**
 * // Definition for a Node.
 * function Node(val,left,right) {
 *    this.val = val;
 *    this.left = left;
 *    this.right = right;
 * };
 */
/**
 * @param {Node} root
 * @return {Node}
 */
// 中序遍历二叉搜索树实现排序的目的,并在遍历过程中建立双向指向关系
function in_order(root, info){
    if(!root) return;
    in_order(root.left, info);
    // pre为null说明此时root是第一个节点,设置root为链表头结点
    if(info.pre === null) {
        info.head = root;
    } else {
        // 否则,将当前节点串在上一个节点后面
        info.pre.right = root;
    }
    // 让当前节点的前驱指向上一个节点
    root.left = info.pre;
    // 将上一个节点指向当前节点,继续下一轮的操作
    info.pre = root;
    in_order(root.right, info);
}
var treeToDoublyList = function(root) {
    if(!root) return root;
    // 用于记录头结点和上一个节点的信息
    let info = {
        head: null,
        pre: null
    };
    // 在中序遍历过程中维护链表的双向指向关系
    in_order(root, info);
    // 经过上面的步骤之后,我们已经得到了一个以head为头结点的双向链表,接下来就要把头尾串起来变成双向循环链表
    // 将头结点的前驱指向链表末尾节点,经过中序遍历后,info.pre就指向了末尾节点
    info.head.left = info.pre;
    // 将末尾节点的后继指向头结点
    info.pre.right = info.head;
    return info.head;
};
464. 我能赢吗
解题思路

遇到这种问题,我们要学会把一个问题转换成他的对立问题来看,只要对立问题无解,那么就说明当前问题有解。拿这道题来说,我们要确保我方一定能赢,是不是就一定要对方输我方才能赢呢?那这样,我们就把问题转换成了如何判断对方会输的问题了。那么这样转换究竟有何意义呢?因为在某些情况下,我们要找到一个问题成立的证据或条件时比较困难的,那么,我们此时就可以反其道而行之,只要证明对立问题不成立,那么我们原问题就是成立的。

  • 深度优先搜索:那么,接下来,我们要想办法求解对方必输的情况。首先,依据题意,我方是先手,那么我们就可以先优先挑选一个满足题意的数字,然后让对方在当前局面的基础上继续往下走,直到走到最后,如果最终结果是对方输,那么就说明我方在当前手选择当前数字的情况下是可以获胜的。如果我方找完了所有合法数字都不能赢,那么我方必输无疑。

  • 记忆化剪枝:到此,我们已经有了基本实现思路了,但是,上面的解法有一些瑕疵,上面的操作中,有大量的重复操作,会导致我们出现大量的无用的重复计算的分支,因此,我们需要进行一定的剪枝操作。

  • 函数与数组的关系、空间换时间:我们之前有聊过,函数式压缩的数组,数组时展开的函数的说法,函数是从参数值到返回值的映射,而数组则是从下标到值的映射,本质上没有多少区别,因此,我们可以使用数组来临时存储我们某种参数序列所对应的返回值的关系,即:下标对应参数序列,值代表函数返回值。

  • 哈希表原理:又因为,我们之前学习哈希表时了解过,哈希表底层其实就是一个数组,只是通过哈希函数计算出哈希值映射到了数组的某个下标。因此,我们在这里直接使用哈希表存储上述的关系会更加方便

    将已经计算出结果的局面存储起来,下次遇到相同局面时,直接返回。

  • 二进制标记位:上面说了,我们需要记录一个参数序列用来唯一映射到某个返回值,那么,我们要如何记录这个参数序列呢?在编程中,我们可以使用二进制标记位的方式方便快捷高效的标记每一个我们曾经使用过的数字,通过一些简单的位运算就可以知道当前标记位中是否包含某个数字、也可以很容易的将某个数字加入到标记中。

代码演示
/*
 * @lc app=leetcode.cn id=464 lang=typescript
 *
 * [464] 我能赢吗
 *
 * https://leetcode-cn.com/problems/can-i-win/description/
 *
 * algorithms
 * Medium (35.32%)
 * Likes:    239
 * Dislikes: 0
 * Total Accepted:    10.1K
 * Total Submissions: 28.4K
 * Testcase Example:  '10\n11'
 *
 * 在 "100 game" 这个游戏中,两名玩家轮流选择从 1 到 10 的任意整数,累计整数和,先使得累计整数和达到或超过 100 的玩家,即为胜者。
 * 
 * 如果我们将游戏规则改为 “玩家不能重复使用整数” 呢?
 * 
 * 例如,两个玩家可以轮流从公共整数池中抽取从 1 到 15 的整数(不放回),直到累计整数和 >= 100。
 * 
 * 给定一个整数 maxChoosableInteger (整数池中可选择的最大数)和另一个整数
 * desiredTotal(累计和),判断先出手的玩家是否能稳赢(假设两位玩家游戏时都表现最佳)?
 * 
 * 你可以假设 maxChoosableInteger 不会大于 20, desiredTotal 不会大于 300。
 * 
 * 示例:
 * 
 * 输入:
 * maxChoosableInteger = 10
 * desiredTotal = 11
 * 
 * 输出:
 * false
 * 
 * 解释:
 * 无论第一个玩家选择哪个整数,他都会失败。
 * 第一个玩家可以选择从 1 到 10 的整数。
 * 如果第一个玩家选择 1,那么第二个玩家只能选择从 2 到 10 的整数。
 * 第二个玩家可以通过选择整数 10(那么累积和为 11 >= desiredTotal),从而取得胜利.
 * 同样地,第一个玩家选择任意其他整数,第二个玩家都会赢。
 * 
 * 
 */

// @lc code=start

function dfs(mask: number, n: number, total: number, map: Map<number, boolean>): boolean {
    // 如果哈希表中已经存储过相同标记位的结果,则无需计算,直接取值返回
    if(map.has(mask)) return map.get(mask);
    // 不存在则循环一遍可能使用的所有数字
    for(let i=1;i<=n;i++) {
        // 如果当前数字在标记为中已经存在,则跳过当前数字
        if(mask & (1 << i)) continue;
        // 如果当前数字大于或等于我们要凑的总数或者队手通过当前的局面继续下的结果是输,则代表我方赢了
        if(i >= total || !dfs(mask | (1 << i), n, total - i, map)) {
            map.set(mask, true);
            return true;
        }
    }
    // 如果上线条件都没有匹配到,则我方必输无疑
    map.set(mask, false);
    return false;
}

function canIWin(maxChoosableInteger: number, desiredTotal: number): boolean {
    // 用于存储已经标记过的mask对应的结果,辅助程序进行记忆化剪枝,解决执行时间超限制的问题
    const map: Map<number, boolean> = new Map<number, boolean>();
    // 二进制标记位,用于标记哪一位的数字被使用过了
    // 如使用过了5,那么(1<<5) | 0 = 0b100000,那么此时需要为5的位置就被标记为1,如果再此基础上有使用了4,
    // 那么(1<<4) | 0b100000 = 0b10000 | 0b100000 = 0b110000,第四位也被标记成了1,以此类推,我们就可以
    // 通过指定位上的二进位是否被标记为1判断是否有使用过当前数字了。
    const mask: number = 0;
    // 如果每个人可能拿到的最大数的总和都无法达到目标总和,则不可能赢
    if((maxChoosableInteger + 1) * maxChoosableInteger / 2 < desiredTotal) return false;
    return dfs(mask, maxChoosableInteger, desiredTotal, map);
};
// @lc code=end


437. 路径总和 III
解题思路

看到题目,有些同学可能会无从下手,那么,我们先来将复杂问题简单化,先不看二叉树的路径总和,现在让你在一个数组中,找到所有和为targetNum的子数组,是否有思路了呢?我们之前有学习过,要求一段区间的和值,我们一般会使用前缀和,当我们当前访问到某个元素时,就会将该前缀和的累计出现次数加1,并存储在一个哈希表中,这样,我们就可以用当前的前缀和与目标和值求得的差值,在哈希表中查询是否已经存在答案记录,如果存在就可以累加。

既然数组可以使用这种方法,其实二叉树也可以这么做,只是二叉树有分支结构,我们需要递归向下查找,递归完成之后,还需要回溯状态,从一个新的状态开始继续往下查找。

代码演示
/*
 * @lc app=leetcode.cn id=437 lang=typescript
 *
 * [437] 路径总和 III
 *
 * https://leetcode-cn.com/problems/path-sum-iii/description/
 *
 * algorithms
 * Medium (56.68%)
 * Likes:    933
 * Dislikes: 0
 * Total Accepted:    90.3K
 * Total Submissions: 159.4K
 * Testcase Example:  '[10,5,-3,3,2,null,11,3,-2,null,1]\n8'
 *
 * 给定一个二叉树的根节点 root ,和一个整数 targetSum ,求该二叉树里节点值之和等于 targetSum 的 路径 的数目。
 * 
 * 路径 不需要从根节点开始,也不需要在叶子节点结束,但是路径方向必须是向下的(只能从父节点到子节点)。
 * 
 * 
 * 
 * 示例 1:
 * 
 * 
 * 
 * 
 * 输入:root = [10,5,-3,3,2,null,11,3,-2,null,1], targetSum = 8
 * 输出:3
 * 解释:和等于 8 的路径有 3 条,如图所示。
 * 
 * 
 * 示例 2:
 * 
 * 
 * 输入:root = [5,4,8,11,null,13,4,7,2,null,null,5,1], targetSum = 22
 * 输出:3
 * 
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 二叉树的节点个数的范围是 [0,1000]
 * -10^9  
 * -1000  
 * 
 * 
 */

// @lc code=start
/**
 * Definition for a binary tree node.
 * class TreeNode {
 *     val: number
 *     left: TreeNode | null
 *     right: TreeNode | null
 *     constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.left = (left===undefined ? null : left)
 *         this.right = (right===undefined ? null : right)
 *     }
 * }
 */

function count(root: TreeNode | null, curSum: number, targetSum: number, map: Record<number, number>): number {
    // 根节点不存在则直接返回0
    if(!root) return 0;
    // 将当前节点的值累积到当前前缀和中
    curSum += root.val;
    // 尝试获取当前前缀和距离目标和值的差值有没有已经计算出来的数量,如果没有,则初始设置为0
    let res = map[curSum - targetSum] || 0;
    // 如果当前前缀和在哈希表中无法找到,则初始化为0
    if(map[curSum] === undefined) map[curSum] = 0;
    // 累加当前前缀和出现次数,并记录在哈希表中
    map[curSum] += 1;
    // 递归查询左右子树的出现的次数并累加到res中
    res+=count(root.left, curSum, targetSum, map);
    res+=count(root.right, curSum, targetSum, map);
    // 递归完成需要回溯,将之前累加的次数减掉,重新开始新一轮的匹配
    map[curSum] -= 1;
    return res;
}

function pathSum(root: TreeNode | null, targetSum: number): number {
    // 定义一个用于存储前缀和与出现次数关系的哈希表,其中key为当前节点的前缀和,value为该前缀和出现的次数
    const map: Record<number, number> = {};
    // 初始化前缀和为0时出现次数为1,即根节点
    map[0] = 1;
    return count(root, 0, targetSum, map);
};
// @lc code=end


190. 颠倒二进制位
解题思路

这道题考察的是我们对于二进制运算和双指针思想的运用。在js中,整数是一个32位有符号二进制数,我们要颠倒二进制位,看似简单,但实际想要有比较好的性能,还是需要一定的思考的,我们先来举个例子:

n: 0b01011001010101010101010101011100
     ^                              ^
     k                              j
j: 0b00000000000000000000000000000001
k: 0b10000000000000000000000000000000
m: 0b00000000000000000000000000000000
# 其中,n为待颠倒的整数的二进制位表示,我们可以定义两个指针j和k,其中j初始指向二进制位的最低位,而k则初始指向二进制位的最高位
# 然后看一下使用按位与运算判断n中的最低位是否为1,如果为1,则直接将k通过按位或运算加入到结果当中,然后让两个指针向中间移动一位
# 继续下一轮判断。
# 总结一下,就是用j去右边找到每一个1的位置,然后让k在与j对称的位置上设置一个1,这样,当j扫描完之后,k也将每一位的数字设置到结果总了

在这里需要特别注意的是,由于在js中的整数是32位有符号二进制数,因此,我们再处理按位右移时,应使用无符号按位右移操作符>>>,并且要对最终求得的结果与0进行无符号按位右移运算,保证我们的符号正确。

代码演示
/*
 * @lc app=leetcode.cn id=190 lang=typescript
 *
 * [190] 颠倒二进制位
 *
 * https://leetcode-cn.com/problems/reverse-bits/description/
 *
 * algorithms
 * Easy (69.04%)
 * Likes:    416
 * Dislikes: 0
 * Total Accepted:    121.8K
 * Total Submissions: 175.7K
 * Testcase Example:  '00000010100101000001111010011100'
 *
 * 颠倒给定的 32 位无符号整数的二进制位。
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 请注意,在某些语言(如
 * Java)中,没有无符号整数类型。在这种情况下,输入和输出都将被指定为有符号整数类型,并且不应影响您的实现,因为无论整数是有符号的还是无符号的,其内部的二进制表示形式都是相同的。
 * 在 Java 中,编译器使用二进制补码记法来表示有符号整数。因此,在上面的 示例 2 中,输入表示有符号整数 -3,输出表示有符号整数
 * -1073741825。
 * 
 * 
 * 
 * 
 * 进阶:
 * 如果多次调用这个函数,你将如何优化你的算法?
 * 
 * 
 * 
 * 示例 1:
 * 
 * 
 * 输入: 00000010100101000001111010011100
 * 输出: 00111001011110000010100101000000
 * 解释: 输入的二进制串 00000010100101000001111010011100 表示无符号整数 43261596,
 * ⁠    因此返回 964176192,其二进制表示形式为 00111001011110000010100101000000。
 * 
 * 示例 2:
 * 
 * 
 * 输入:11111111111111111111111111111101
 * 输出:10111111111111111111111111111111
 * 解释:输入的二进制串 11111111111111111111111111111101 表示无符号整数 4294967293,
 * 因此返回 3221225471 其二进制表示形式为 10111111111111111111111111111111 。
 * 
 * 示例 1:
 * 
 * 
 * 输入:n = 00000010100101000001111010011100
 * 输出:964176192 (00111001011110000010100101000000)
 * 解释:输入的二进制串 00000010100101000001111010011100 表示无符号整数 43261596,
 * ⁠    因此返回 964176192,其二进制表示形式为 00111001011110000010100101000000。
 * 
 * 示例 2:
 * 
 * 
 * 输入:n = 11111111111111111111111111111101
 * 输出:3221225471 (10111111111111111111111111111111)
 * 解释:输入的二进制串 11111111111111111111111111111101 表示无符号整数 4294967293,
 * ⁠    因此返回 3221225471 其二进制表示形式为 10111111111111111111111111111111 。
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 输入是一个长度为 32 的二进制字符串
 * 
 * 
 */

// @lc code=start
function reverseBits(n: number): number {
	// 类似于双指针位置交换的思想
	// n: 0b01011001010101010101010101011100
	//      ^                              ^
	//      k                              j
	// j: 0b00000000000000000000000000000001
	// k: 0b10000000000000000000000000000000
	// m: 0b00000000000000000000000000000000
	// 如上,初始时让k指向二进制的最高位,j指向最低位,这样我们就有了双指针了
	// 如果n中j所对应的位数是1的话,我们就同步将k所指向的位的数值合并到结果中
	// 然后再让k向右移动一位,j向左移动一位,继续下一轮操作
	// 注意:由于js的整数是有符号整数,因此,我们再按位右移的时候,需要使用无符号右移运算符>>>
	// 并对最终的结果无符号按位右移0位将符号位的差异抹平
	let m = 0;
	for(let i=0,j=1,k=(1<<31);i<32;i++,j<<=1,k>>>=1){
    // 由于初始时m的每一位都是0,因此,一旦j在右边找到一个1,只需要让他的对称位即k所指向的位置也标记为1,就可以实现颠倒的目的了,而要将第k位标记为1,直接让结果与k进行按位与运算即可
		if(n & j) m |= k;
		// 以下效果相同,j和k为左右指针,可随意调换
		// if(n & k) m |= j;
	}
	return m>>>0;
};
// @lc code=end


402. 移掉 K 位数字
解题思路

首先,我们要先搞清楚,移除哪些数字才能让最终的结果尽可能小呢?是不是尽可能的让小的数字向前走,当我们发现后面的数比前面的数小时,在操作数允许的情况下,我们就将前面的比这个数大的数删掉。大家有没有发现,这样就像是一个单调递增栈的操作,当有一个小于栈顶元素的元素要入栈时,为了维护栈的单调性,我们就把所有比当前元素小的元素都出栈。如:

# 原始数字:1432219   k = 3
# 因为我们要在移除k个数后让结果尽可能的小,那么我们就要让高位的数字尽可能小(最小是0,如果可以,尽量把高位直接删除)
# 我们可以将每一个数字都压入到一个单调递增栈当中,由于栈是单调递增的,当我们新加入的数字比栈顶数字的小时,就把栈顶数字剔除,同时删除操作数k应该减一,直到我们不能再删除或原始数字串为空。
入栈:1                 结果:1      操作数k:3
入栈:4                 结果:14     操作数k:3
出栈:4 入栈:3          结果:13     操作数k:2
出栈:3 入栈:2          结果:12     操作数k:1
出栈:2 入栈:2          结果:12     操作数k:0
# 由于此时操作数k为0,我们无法再删除了,因此,此时入栈时无法再维护栈的单调性,直接入栈即可
入栈:1                 结果:121    操作数k:0
入栈:9                 结果:1219   操作数k:0

# 因此,最终的数字为:1219

此处我们使用了单调栈的思想,但不一定要定义一个实实在在的单调栈,我们甚至可以直接使用字符串的操作来代替单调栈,如:入栈->字符串末尾添加字符,出栈->字符串末尾删除字符。

从这里,我们其实也可以看出,数据结构并非死板一块,而是非常灵活的,就像是之前也有讲过用“链表思想”解决快乐数的问题,用“栈思想”解决括号匹配问题一样,我们并没有一个墨守成规的数据结构,但是又使用到了某种数据结构的特点。

代码演示
/*
 * @lc app=leetcode.cn id=402 lang=typescript
 *
 * [402] 移掉 K 位数字
 *
 * https://leetcode-cn.com/problems/remove-k-digits/description/
 *
 * algorithms
 * Medium (32.80%)
 * Likes:    611
 * Dislikes: 0
 * Total Accepted:    74.5K
 * Total Submissions: 227.2K
 * Testcase Example:  '"1432219"\n3'
 *
 * 给你一个以字符串表示的非负整数 num 和一个整数 k ,移除这个数中的 k 位数字,使得剩下的数字最小。请你以字符串形式返回这个最小的数字。
 * 
 * 
 * 示例 1 :
 * 
 * 
 * 输入:num = "1432219", k = 3
 * 输出:"1219"
 * 解释:移除掉三个数字 4, 3, 和 2 形成一个新的最小的数字 1219 。
 * 
 * 
 * 示例 2 :
 * 
 * 
 * 输入:num = "10200", k = 1
 * 输出:"200"
 * 解释:移掉首位的 1 剩下的数字为 200. 注意输出不能有任何前导零。
 * 
 * 
 * 示例 3 :
 * 
 * 
 * 输入:num = "10", k = 2
 * 输出:"0"
 * 解释:从原数字移除所有的数字,剩余为空就是 0 。
 * 
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 1 
 * num 仅由若干位数字(0 - 9)组成
 * 除了 0 本身之外,num 不含任何前导零
 * 
 * 
 */

// @lc code=start
function removeKdigits(num: string, k: number): string {
    // 如果当前字符串的长度小于或等于k,那么,证明我们可以把所有的数字都弹出,因此返回0
    if(num.length<=k) return "0";
    // 用res字符串来模拟单调栈操作
    // 其实单调栈并不是固定的一种实现方式,我们可以使用单调栈的思想,辅助我们解决一些问题,而不一定要使用单调栈
    let res: string = "";
    // 遍历每一个数字的同时,将所有违反单调递增特性的数从res(单调栈)中弹出,弹出的这些数字,都是比当前我要加入
    // 的数字大的数字
    for(let n of num) {
        while(k && res && parseInt(res[res.length-1],10) > parseInt(n, 10)) {
            // 将栈顶元素弹出(字符串末尾的数字移除)
            res = res.substring(0, res.length-1);
            k-=1;
        }
        // 维护了res的单调性后,我们再将当前数字加到目标字符串末尾
        res+=n;
    }
    // 如果循环结束后,k不等于0,说明我们还能再剔除res.length-k个元素,为了使得当前的结果保持最小,由于我们的栈是单调递增的,越后面的数字就越大,如果把前面比较小的数字删除,后面会越来越大,因此我们选择从后面剔除
    if(k!==0) res = res.substr(0, res.length - k);
    // 如果最终计算出来的数字存在前导0,则将其删除
    let idx = 0;
    while(res[idx] === "0") ++idx;
    res = res.substring(idx);
    // 如果最终结果是空字符串,说明全部数字都被删掉了,返回0
    if(res==="") return "0";
    return res;
};
// @lc code=end


1081. 不同字符的最小子序列
解题思路

这道题与上一道题很类似,上一题是让我们删掉k个数字后尽可能保证数字最小,而这道题则是让我们找出包含所有字符的字典序最小的子序列。我们依然可以沿用上一题的解题思路,假设我们将一每一个字符压入到一个按照字典序单调递增的单调栈中,那么,当我们要压入一个比栈顶元素小的元素时,我们在保证后面依然存在与栈顶元素相同字符的前提下,将栈顶元素弹出,直至恢复单调递增的性质,然后再将新的字符压入。这样,就可以让我们最终的字符的字典序尽可能的小了。(因为我们已经确保了高位尽可能小)。

代码演示
/*
 * @lc app=leetcode.cn id=1081 lang=typescript
 *
 * [1081] 不同字符的最小子序列
 *
 * https://leetcode-cn.com/problems/smallest-subsequence-of-distinct-characters/description/
 *
 * algorithms
 * Medium (57.05%)
 * Likes:    108
 * Dislikes: 0
 * Total Accepted:    13.4K
 * Total Submissions: 23.5K
 * Testcase Example:  '"bcabc"'
 *
 * 返回 s 字典序最小的子序列,该子序列包含 s 的所有不同字符,且只包含一次。
 * 
 * 注意:该题与 316 https://leetcode.com/problems/remove-duplicate-letters/ 相同
 * 
 * 
 * 
 * 示例 1:
 * 
 * 
 * 输入:s = "bcabc"
 * 输出:"abc"
 * 
 * 
 * 示例 2:
 * 
 * 
 * 输入:s = "cbacdcbc"
 * 输出:"acdb"
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 1 
 * s 由小写英文字母组成
 * 
 * 
 */

// @lc code=start
function smallestSubsequence(s: string): string {
    // 利用单调栈的思想操作字符串
    let res = "";
    // 用于记录每个字符出现的次数,方便判断当前字符后面是否还存在相同字符
    const map: Record<string, number> = {};
    // 用于记录单调栈中已经存在的字符
    const set: Set<string> = new Set();

    // 计算每个字符出现的次数
    for(let char of s) map[char]=(map[char]===undefined?0:map[char]) + 1;

    for(let char of s) {
        // 如果当前字符不在单调栈内,则需要进行入栈操作。(注:因为就算后面有相同的字符,加入单调栈,由于需要考虑字典序,他的解也不可能比之前的更优,因此,忽略已经在单调栈中存在的字符)
        if(!set.has(char)) {
            // 栈顶元素,即字符串最后一个字符
            let lastChar = res[res.length-1];
            // 当单调栈(字符串)不为空并且当前字符在后面还有相同字符,并且栈顶字符的字典序比要加入的字符的字典序要大时,进行出栈操作
            while(res.length && map[lastChar] && lastChar.charCodeAt(0) > char.charCodeAt(0)) {
                // 出栈的同事需要将栈顶字符从set中移除
                set.delete(lastChar);
                // 栈顶元素出栈,即删除字符串最后一个字符
                res = res.substring(0, res.length-1);
                // 更新最后一个字符
                lastChar = res[res.length-1];
            }
            // 新字符入栈时同步加入到set中
            set.add(char);
            // 新字符入栈
            res+=char;
        }
        // 无论当前字符是否在单调栈中,我们都要将当前字符出现次数减一,代表已经处理过了。
        map[char]-=1;
    }

    return res;
};
// @lc code=end


1499. 满足不等式的最大值
解题思路

首先,根据题意,我们的xj>xi恒成立,那么,换句话来说,所有点的x坐标,从可以看做是单调递增的。

# 假设以下为x坐标的表示,而我们将j作为固定点,向前最多k位查找i
# 设j=4,k=2,那么i=2
x1  x2  x3  x4  x5 ...  xn
    ^       ^
# 在j不断向前或向后移动时,我们的i点也会相应的依据规则向前或向后移动,这样就形成了一个固定末尾的滑动窗口了
# 而假如说j=5,k=2,那么,由于j-i>k了,i=2的元素已经移出了滑动窗口的范围了,因此需要把出了滑动窗口范围的元素x2从队列中剔除

由于xj>xi,因此我们可以把绝对值符号去掉,公式化简为:yi+yj+xj-xi = xj-xi+yj+yi,这就是我们最终用于求解公式值的公式了。

但是,这还不够,咱们如果想要让目标解最大,就需要让yi-xi尽可能的大,而想让这个值尽可能的大,也就是要确保xi尽可能的小,再根据我们上面分析的,x的单调性以及固定末尾的滑动窗口性质,我们就可以将问题转换为求一个滑动窗口内部的最小值的问题,即RMQ问题。看见RMQ我们就会想到,这丫的用单调队列解决贼好,因为要求的是滑动窗口的最小值,所以应该是一个单调递增队列。

代码演示
/*
 * @lc app=leetcode.cn id=1499 lang=typescript
 *
 * [1499] 满足不等式的最大值
 *
 * https://leetcode-cn.com/problems/max-value-of-equation/description/
 *
 * algorithms
 * Hard (37.59%)
 * Likes:    36
 * Dislikes: 0
 * Total Accepted:    2.4K
 * Total Submissions: 6.5K
 * Testcase Example:  '[[1,3],[2,0],[5,10],[6,-10]]\n1'
 *
 * 给你一个数组 points 和一个整数 k 。数组中每个元素都表示二维平面上的点的坐标,并按照横坐标 x 的值从小到大排序。也就是说 points[i]
 * = [xi, yi] ,并且在 1 <= i < j <= points.length 的前提下, xi < xj 总成立。
 * 
 * 请你找出 yi + yj + |xi - xj| 的 最大值,其中 |xi - xj| <= k 且 1 <= i < j <=
 * points.length。
 * 
 * 题目测试数据保证至少存在一对能够满足 |xi - xj| <= k 的点。
 * 
 * 
 * 
 * 示例 1:
 * 
 * 输入:points = [[1,3],[2,0],[5,10],[6,-10]], k = 1
 * 输出:4
 * 解释:前两个点满足 |xi - xj| <= 1 ,代入方程计算,则得到值 3 + 0 + |1 - 2| = 4 。第三个和第四个点也满足条件,得到值
 * 10 + -10 + |5 - 6| = 1 。
 * 没有其他满足条件的点,所以返回 4 和 1 中最大的那个。
 * 
 * 示例 2:
 * 
 * 输入:points = [[0,0],[3,0],[9,2]], k = 3
 * 输出:3
 * 解释:只有前两个点满足 |xi - xj| <= 3 ,代入方程后得到值 0 + 0 + |0 - 3| = 3 。
 * 
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 2 <= points.length <= 10^5
 * points[i].length == 2
 * -10^8 <= points[i][0], points[i][1] <= 10^8
 * 0 <= k <= 2 * 10^8
 * 对于所有的1 <= i < j <= points.length ,points[i][0] < points[j][0] 都成立。也就是说,xi
 * 是严格递增的。
 * 
 * 
 */

// @lc code=start
function findMaxValueOfEquation(points: number[][], k: number): number {
    // 滑动窗口最大值,单调递减栈
    // 根据题意,坐标的x点时单调递增的,并且xj > xi,那么,我们就可以把公式转换成:
    // yi+yj+xj-xi = xj-xi+yj+yi
    // 我们要找出上述公式的最大值,就相当于找出固定末尾xj滑动窗口中的最小值xi,让xi尽可能小,才能时公式的结果尽可能大
    // 那么,要求滑动窗口内的最小值,我们就会用到单调递增队列来辅助求解
    // 单调队列中仅存放坐标的索引
    const q: number[] = [];
    // 先将第一个坐标的索引加入队列中
    q.push(0);
    // 结果,初始设置成最小值,之后通过max对比使初始值必定会被剔除
    let res = Number.MIN_SAFE_INTEGER;
    // 由于我们已经把第一个坐标压入队列了,因此,从第二个坐标开始循环
    for(let i=1;i<points.length;i++) {
        // 队列不为空且当xj-xi不满足小于等于k的条件时,需要将所有不满足的x坐标从队列中剔除
        while(q.length && points[i][0] - points[q[0]][0] > k) q.shift();
        // 如果将所有不满足上述条件的x坐标剔除之后,队列中还存在元素,说明这些元素都是满足题目条件的,那么
        // 我们按照公式求解并与上一次计算的结果取最大值
        if(q.length) {
            // res = Math.max(res, xj - xi + yj + yi)
            res = Math.max(res, points[i][0] - points[q[0]][0] + points[i][1] + points[q[0]][1]);
        }
        // 准备将当前元素入队列,但入队列之前,需要维护队列的单调性
        // 根据题意xj>xi,那么,我们的原始公式可变为:yi+yj+(xj-xi)=yj+xj+yi-xi。
        // 而我们想要让这个最终结果竟可能的大,在固定j点的情况下,就需要保证yi-xi是一个尽可能大的值
        // 因此,这个就作为了我们单调递增队列的入队条件了,当新入队的元素yi-xi>队列尾部部元素的yi-xi的时候,就需要将队列尾部元素出队
        while(q.length && points[i][1] - points[i][0] > points[q[q.length-1]][1] - points[q[q.length-1]][0]) q.pop();
        // 将当前坐标索引压入单调队列
        q.push(i);
    }

    return res;
};
// @lc code=end


你可能感兴趣的:(数据结构,前端基础,知识梳理,算法,数据结构,刷题)