LeetCode 热题 100 快速通关指南 (优化完整版)
本指南提供基础完善的模块,可用于刷题记录与总结教训。建议复制粘贴至笔记软件,每个章节包含模板、题目记录和心得部分。可个性化调整,系统化记录有助于提升效率。
1. 哈希 (Hash)
- 核心思想:利用哈希映射快速查找、统计频率或建立键值对应关系。
- 常用集合:哈希表(如 )。
系统梳理了 LeetCode 热题 100 的核心算法分类,涵盖哈希、双指针、滑动窗口、数组、矩阵、链表、二叉树、图论、回溯、二分查找、栈、堆、贪心及动态规划等模块。提供各算法的心法总结、经典题目解析及 Java 代码模板,旨在帮助开发者建立系统化知识体系,提升算法解题效率与实战能力。
本指南提供基础完善的模块,可用于刷题记录与总结教训。建议复制粘贴至笔记软件,每个章节包含模板、题目记录和心得部分。可个性化调整,系统化记录有助于提升效率。
HashMap优化模板 (以"两数之和"为例):
import java.util.*;
public int[] twoSum(int[] nums, int target) {
// 边界检查
if (nums == null || nums.length < 2) return new int[0];
// 1. 创建哈希表
Map<Integer, Integer> map = new HashMap<>();
// 2. 遍历数组
for (int i = 0; i < nums.length; i++) {
int complement = target - nums[i];
// 3. 边遍历边检查
if (map.containsKey(complement)) {
// 重点!
// 4. 找到,立即返回
return new int[]{map.get(complement), i};
}
// 5. 没找到,将当前元素存入哈希表
map.put(nums[i], i);
}
// 6. 遍历完没找到,返回空数组(比异常更友好)
return new int[0];
}
public void moveZeroes(int[] nums) {
int slow = 0; // slow 指向下一个非零元素要放置的位置
for (int fast = 0; fast < nums.length; fast++) {
if (nums[fast] != 0) {
// 交换元素(更通用的写法)
int temp = nums[slow];
nums[slow] = nums[fast];
nums[fast] = temp;
slow++;
}
}
}
模板 (左右指针,适用于有序数组):
public int[] twoSumSorted(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left < right) {
int sum = nums[left] + nums[right];
if (sum == target) {
// 找到了,根据题目要求处理
return new int[]{left, right};
} else if (sum < target) {
left++; // 和太小,左指针右移增大和
} else {
right--; // 和太大,右指针左移减小和
}
}
return new int[0]; // 未找到
}
真人心得: 所谓配对,就是几个元素遵守某个规则得到预期值。我们要做的就是找出遵守这个规则的元素。既然是与规则相关,那么将整个数组排序,有利于我们根据规则找到值。这就是用双指针解决两数之和的思路,就是一个有序数组,左右指针,然后夹逼。注意:为什么开始的两数之和使用哈希解决,因为要返回的是下标,哈希可以直接存储下标。如果不考虑效率的话,对于哈希能解决的题目,双指针都能实现。
现在看三数之和:属于按照某个规则得到预期值的情况。a+b+c=0;变成 a+b=-c;这样就是变成两数之和了,只是将规则变化了一下。他与用双指针解决两数之和的区别就是它的合是变化的,这很好解决。记得排序,这是左右指针解决找到按某个规则都得到预期值的元素的关键!!
左右指针关键概念:排序,按某个规则得到预期值,夹逼。 前后指针关键概念:在原数组修改。
优化模板 (无重复字符的最长子串 - 具体化版本):
public int lengthOfLongestSubstring(String s) {
// 1. 初始化窗口和结果
Map<Character, Integer> window = new HashMap<>();
int left = 0, right = 0;
int maxLen = 0;
// 2. 右指针扩大窗口
while (right < s.length()) {
char c = s.charAt(right);
right++;
window.put(c, window.getOrDefault(c, 0) + 1);
// 3. 具体的收缩条件:当窗口中有重复字符时
while (window.get(c) > 1) {
char d = s.charAt(left);
left++;
window.put(d, window.get(d) - 1);
}
maxLen = Math.max(maxLen, right - left);
}
return maxLen;
}
真人心得: 我们之前学了双指针合哈希,滑动窗口就是双指针合哈希的结合!既然是结合了哈希合双指针,那就分析。哈希的优点就是可以将遍历的当前元素和某个附带信息存起来,供后面的遍历使用。而双指针的优点就是可以在遍历的过程中,动态调整窗口大小。那么这样看来双指针不就是等于移动窗口了?当然不是!前面的双指针的题目是(找到几个元素,这几个元素按照某个规则得到预期值),而我们前面碰到的规则比如三数之和,盛最多水的容器,这些规则都是简单的加法。而滑动窗口用到哈希的重点就是规则变复杂了,也就是需要哈希存储额外的信息来辅助实现规则。
比如无重复字符的最长子串,规则就是(子串内不能有重复字符),这个规则就比较复杂了,不能简单的用加法来实现。无重复字符的最长子串的思路:用快慢指针形成窗口,当出现重复了,一定是窗口里的某个元素和快指针目前指向的元素重复了,此时就按照规则一直移动左边的指针直到符合规则。它的规则用 hash 具体来辅助就是时刻记录窗口类的字符和对应的出现次数,当出现次数大于 1 了,就说明有重复字符了,这时候就需要收缩窗口了。
现在以【找到字符串中所有字母异位词】题目为例。规则就是窗口内出现字符的次数与给定的目标一致,潜在规则就是窗口的大小和给定目标的大小一样。就像上一题一样,用哈希来存储,key 就是字符,value 就是对应字符出现的次数。这里有个优化小提示:凡是用哈希存储连贯英文字符的值和对应的频率,都可以用数组来代替哈希表。因为有 26 个小写英文,可以直接用一个简单的大小为 26 的数组 a[] 来代替哈希表,a[1]=3 就代表 b 的出现次数是 3。
继续,依旧是快慢指针形成窗口,但是窗口初始大小和目标大小一样,然后通过判断数组(也就是哈希)是否等于目标的数组(就是对应字母和对应频率都相同)。然后每次移动一格。这一题反而比上一题简单,因为窗口是固定的。
关键概念:可以看出,关键就是规则的获取和转化,尤其考虑如何用哈希来辅助判定规则,这是重点!!
preSum[j] - preSum[i] = k 这个公式是解题关键。模板 (前缀和 + 哈希表,求和为 K 的子数组):
public int subarraySum(int[] nums, int k) {
// 1. 初始化哈希表(记录每种前缀和出现的次数)
Map<Integer, Integer> map = new HashMap<>();
// 重要:初始化前缀和为 0 的出现 1 次
// 为什么?处理从头开始的子数组(如整个数组)
map.put(0, 1);
int preSum = 0; // 当前累计的前缀和
int count = 0; // 满足条件的子数组个数
// 2. 遍历数组
for (int num : nums) {
// 更新到当前位置的前缀和
preSum += num;
// 3. 关键:查找符合条件的前缀和
// preSum[j] - preSum[i] = k ⇒ preSum[i] = preSum - k
// 看是否在之前出现过这种前缀和
if (map.containsKey(preSum - k)) {
count += map.get(preSum - k); // 添加出现次数
}
// 4. 将当前前缀和存入哈希表
// 用于后续查找(注意:必须在检查后再存入)
map.put(preSum, map.getOrDefault(preSum, 0) + 1);
}
return count;
}
真人心得:
我们分析一下字串问题的解决,一般此问题都是按照某个规则得到字串,然后有可能是获取字串,也有可能是获取符合子串的个数。其实同样是用移动窗口(现在学到这里了,移动窗口其实是个概念,可以是单个指针和开始元素形成窗口,也可以是双指针按照某个规则移动),本质就是指针移动,按照我们的概念,其实一个单遍历也是指针移动,也可以形成窗口。按照惯例,就是先形成窗口,然后再获取并且分析规则。以 LeetCode 560: 和为 K 的子数组为例:规则是:该数组中和为 k 的子数组的个数。关键是规则的分析:这里用到了前缀和的概念,某个元素的前缀和就是是从 0 开始到当前位置的元素和。preSum[j] - preSum[i] = K ⇒ preSum[i] = preSum[j] - K。简单点就是说我们可以将我们符合规则的数组转换为【当前元素的前缀合减去 k 值,如果得到的值是一个前缀和,并且是我们已经遍历过的,那就说明它们的差值,也就是相差的元素的合就是一个符合规则的数组。】关键词,已经遍历过的,说明要用哈希(因为可能会有负数,导致一个前缀和出现多次),可以看出,哈希的功能其实就是缓存,缓存了就不要像暴力法再次遍历了。其实没什么高大上的,前面我们解决三数之合问题时,不是就把 a+b+c=0,转换成了 a+b=-c 吗?然后一边遍历(缩小窗口),然后对窗口里的左右指针(也可以看作小窗口)进行夹逼,因为规则简单,就没有用到哈希。这里的转换不就同样是把左边的因子移动到了右边,这就是前缀和,明明这么简单的转换概念,如果是高中的我们肯定轻而易举,可是到算法里面就找不出来呢?因为这是隐藏的信息,我们要时刻拥有转换规则的思维。我们得到了每个遍历的前缀和(也就是数组),然后将其放入哈希表中,供后来使用,如果后面某个数组按照规则转换的公式得到了另一个数组能够在哈希表中找到,那么这两个数组中间的元素就是符合规则的子数组。
本质分析:根据上面一题的分析,那不就是移动窗口吗?为什么还要分为字串类型的题目了,我们要找到本质,从现在来看,我们学会了哈希(缓存数据,避免暴力遍历,为什么要缓存呢?因为遍历到后面时,规则可能要利用到前面的信息),它的本质就是缓存数据供后面使用。我们还学会了指针移动(包括双指针和移动窗口)它的本质其实就是帮助我们优化遍历(多样化的遍历,也避免了暴力遍历),至于附带的什么排序,夹逼,用哈希记录对应字符串的频率或者用哈希保存前缀和,这都是辅助我们利用规则。三个本质:缓存数据供后面使用,指针优化遍历,转换并利用规则,如果规则简单,题目要求的返回信息少,就不用哈希,如果题目规则可能复杂一点,就需要用到哈希提前缓存的数据辅助我们在后面的运算。现在我们就得出结论,哈希,指针移动(双指针,移动窗口)都是算法必备的技能,而转换并利用规则我们可以当作高中题目去转换思考,思考如何转换成可以用哈希存储的规则!有时候哈希不够用了或者说不是最佳的呢?那就还有队列和栈甚至是数组和单个 int 值!(这也就是前面存储英文字符时为什么可以用数组)。重点就是将规则转换为可以用某种数据结构存储的形式,避免暴力遍历!现在为止,我们已经认识了何为算法了!,算法的本质已经窥见一二了,避免了解题没有头绪和思路,跳出了就题论题的局限。
现在我们看题目 [LeetCode 239. 滑动窗口最大值] 三个本质:缓存数据供后面使用(可以用合适的数据结构辅助规则),指针优化遍历,转换并利用规则。让我们先把本质记录在这里。首先先获取规则:滑动窗口中的最大值。我们一开始想道可以这样解决,每次滑动窗口移动时,都用一个 int 值(这也可以算作是缓存)遍历一次窗口中的值,记录下窗口中最大的值,然后返回。每次遍历都是这样。可这是一个困难题目,怎么会这么简单,肯定需要优化,想想我们的要素,用于缓存的数据结构(辅助规则),指针优化遍历!很好,按照我们的三要素,我们很容易就得到了优化算法的思路,从数据结构和指针(遍历)移动方式和规则理解下手。我们是否可以这样考虑,用一个双端队列来缓存窗口中的值,当未来一个值进入窗口时,如果他比当前窗口中的最大值还大,那么他一定就是最大的值(此时删除队列中所有比他小的值)如果进来的值比他小,那么就这个小的值有可能成为将来最大的值(因为最大的值可能会移除窗口),当然还需要考虑判断队列头部的最大值是否已经移出窗口(这个逻辑需要用索引判定,只需对比'索引是否 ≥ 窗口左边界)。为什么要用双端队列呢?因为我们需要在窗口滑动时,能够快速地从两端添加和删除元素 (先进先出的特性)。这样最大值永远都是队列的头部元素(这里你可以尝试简单的举例)
总结:可以看出数据结构是优化规则的关键!理解各种数据结构的特点是理解和转换规则的关键!!
[1, N] 之间时,可以把数组本身当作哈希表,利用正负号或交换元素到对应索引位置来标记信息。这叫"原地哈希"。真人心得: 数组篇是在哈希,移动窗口(双指针),之后的,而前面我们获得了算法的基本能力(哈希缓存,遍历优化(指针或者窗口))。这里就是数组篇的基本操作了,包括动态规划(其实也可以用前缀和思想),排序,反转(需要记忆技巧),原地哈希,除了反转外,其他的多少都在前面接触过一些了,如果不用动态规划解题。主要讲原地哈希的思想:想想以前我们是怎么做的,用哈希缓存,遍历数组,这样空间复杂度就是 O(n) 了,对于某些题目,这样是为了判断某个值是否存在。==本质:原地哈希就是将这个判断功能用在原本数组上,比如判断 nums【i】是否存在,假设 nums【2】=3,那么就将 nums【3】变为负数,表示 3 存在。相当于值是 key(3),值代表的索引的符号是 value(通过负号判断),就是加入了一个负号,实现了功能复用。对于具体题目可能要遍历几次,因为至少有一次要调整符号。.也就是说通过索引判定一个数值是否存在,而索引是有序的。
模板 (原地哈希,找缺失的第一个正数):
public int firstMissingPositive(int[] nums) {
int n = nums.length;
// 1. 将所有数字放到它应该在的位置上 (nums[i] 应该在 nums[nums[i]-1])
for (int i = 0; i < n; i++) {
while (nums[i] > 0 && nums[i] <= n && nums[nums[i] - 1] != nums[i]) {
// 交换 nums[i] 和 nums[nums[i] - 1]
int temp = nums[nums[i] - 1];
nums[nums[i] - 1] = nums[i];
nums[i] = temp;
}
}
// 2. 再次遍历,找到第一个不在正确位置上的数字
for (int i = 0; i < n; i++) {
if (nums[i] != i + 1) {
return i + 1;
}
}
return n + 1;
}
int[][] dirs = {{0,1}, {1,0}, {0,-1}, {-1,0}} 来简化方向切换。真人心得,矩阵问题常常要考虑边界问题。
模板 (螺旋矩阵):
public List<Integer> spiralOrder(int[][] matrix) {
List<Integer> result = new ArrayList<>();
if (matrix == null || matrix.length == 0) return result;
int top = 0, bottom = matrix.length - 1;
int left = 0, right = matrix[0].length - 1;
while (top <= bottom && left <= right) {
// 从左到右
for (int i = left; i <= right; i++) {
result.add(matrix[top][i]);
}
top++;
// 从上到下
for (int i = top; i <= bottom; i++) {
result.add(matrix[i][right]);
}
right--;
// 从右到左 (检查边界)
if (top <= bottom) {
for (int i = right; i >= left; i--) {
result.add(matrix[bottom][i]);
}
bottom--;
}
// 从下到上 (检查边界)
if (left <= right) {
for (int i = bottom; i >= top; i--) {
result.add(matrix[i][left]);
}
left++;
}
}
return result;
}
prev 指针来保存前一个节点。对于复杂操作,引入一个虚拟头节点 (dummy head) 可以极大地简化边界条件处理(如删除头节点)。万能模板 (虚拟头节点版本):
public ListNode universalTemplate(ListNode head) {
// 1. 创建虚拟头节点,简化边界处理
ListNode dummy = new ListNode(0);
dummy.next = head;
// 2. 定义工作指针
ListNode prev = dummy; // 前驱指针
ListNode curr = head; // 当前指针
// 3. 遍历处理
while (curr != null) {
// ... 根据具体题目处理 curr 节点 ...
// 标准前进步骤
prev = curr;
curr = curr.next;
}
// 4. 返回新的头节点
return dummy.next;
}
模板 (反转链表):
public ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode nextTemp = curr.next; // 1. 保存下一个节点
curr.next = prev; // 2. 反转指针
prev = curr; // 3. prev 前进
curr = nextTemp; // 4. curr 前进
}
return prev; // 新的头节点是 prev
}
真人心得: 链表中经常用到双指针,这也导致了快指针会出现两张情况(快指针指向最后一个节点,快指针指向最后一个节点的前一个,也就是链表的节点个数可能是单个,也可能是双数),此时的循环停止条件要是 (fast!=null&&fast.next!=null)。还要注意循环停止条件,while(l1!=null&&l2!=null) 表示只要有一个为空就停止,while(l1!=null||l2!=null),只要有一个不为空就继续。还要注意边界问题,比如就是两个链表,两个指针分别在上面移动,例如两数之和,可以用三元式处理。
链表题常用的技巧:
二叉树前提概念
if (root == null) return ...;root 节点。dfs(root.left) 和 dfs(root.right),并思考如何利用它们的返回值。真人心得:递归的两种思想:自顶向下和自下向顶。 自顶向下,就是先判定当前层的条件,再递归处理子问题,当前层的结果依赖子问题的结果。(比如对称二叉树,就是先检查当前节点是否符合条件,再递归检查子节点)
模板 (层序遍历 - BFS):
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> result = new ArrayList<>();
if (root == null) return result;
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
while (!queue.isEmpty()) {
int levelSize = queue.size();
List<Integer> currentLevel = new ArrayList<>();
for (int i = 0; i < levelSize; i++) {
TreeNode node = queue.poll();
currentLevel.add(node.val);
if (node.left != null) queue.offer(node.left);
if (node.right != null) queue.offer(node.right);
}
result.add(currentLevel);
}
return result;
}
模板 (递归遍历):
void traverse(TreeNode root) {
if (root == null) {
return;
}
// 前序位置:在这里处理 root 节点
System.out.println(root.val);
traverse(root.left);
// 中序位置:在这里处理 root 节点
traverse(root.right);
// 后序位置:在这里处理 root 节点
}
public boolean check(TreeNode p, TreeNode q) {
// 1. 先判断当前层的基础条件(终止条件)
if (p == null && q == null) return true;
if (p == null || q == null) return false;
// 2. 再递归处理子问题(左对右,右对左),当前结果依赖子问题结果
return p.val == q.val && check(p.left, q.right) && check(p.right, q.left);
}
自下向顶:先递归处理子问题,再根据子问题的结果计算当前层的结果,子问题的结果决定当前层的结果。(比如二叉树的最大深度,==就是先递归到最深处,再逐层向上汇报深度。)
public int maxDepth(TreeNode root) {
// 1. 先递归到最底层(叶子节点的子节点为 null)
if (root == null) return 0;
// 2. 子问题结果:左子树深度和右子树深度
int leftDepth = maxDepth(root.left);
int rightDepth = maxDepth(root.right);
// 3. 当前层结果:子问题结果 + 1(当前节点)
return Math.max(leftDepth, rightDepth) + 1;
}
两者本质:就是一个可以不用递归到最深有可能就能得到结果,一个必须递归到最深处。差异仅在于'处理节点是在深入时还是回溯'。就参数数量来说,自下向顶一把只要一个方法参数,就是树节点,其他所有的信息可以通过递归返回的结果来获取。而自顶向下通常需要多个参数。
当递归时,附带了 root 节点以外的信息,比如要注意左递归和右递归还有当前节点返回时这个值的变化。特别是当递归需要返回一个值时,要特别考虑叶子节点的情况是返回 null 还是返回其他什么东西。
图论前提知识
Map<Integer, List<Integer>>,有时也可以直接用 List<List<Integer>>)。然后根据问题选择遍历方式:求最短路径用 BFS,求连通性/所有路径用 DFS。为了防止走回头路,需要一个 visited 集合 (比如三色标记法)。模板 (DFS 遍历图,课程表问题):
class Solution {
// 构建邻接表
List<List<Integer>> edges;
// 邻接表
int[] visited; // 0 未访问,1 正在访问,2 访问结束(已入栈)
boolean valid = true; // 合法标签,用于递归中标记环存在
public boolean canFinish(int numCourses, int[][] prerequisites) {
// 方法 1:深度优先搜索,三色标记法
// 构建邻接表
// 用状态标签标记每一个节点的状态:正在访问,未访问,访问结束
// 对每个节点进行深度优先递归,如果遇到环--检测到状态属于正在访问
// 就说明不可能完成课表
edges = new ArrayList<>(numCourses);
// 初始化空列表
for (int i = 0; i < numCourses; i++) {
edges.add(new ArrayList<>()); // 直接在列表中添加元素
}
// 初始化邻接表
for (int[] info : prerequisites) {
edges.get(info[1]).add(info[0]); // 我们以「出度节点的起点」作为当前节点,邻接表中存放的是「该起点指向的出度节点」。
}
visited = new int[numCourses]; // 初始化全部为 0
// 对每个节点进行递归
for (int i = 0; i < edges.size() && valid; i++) {
if (visited[i] == 0) dfs(i);
}
return valid;
}
private void dfs(int i) {
if (visited[i] == 1) {
valid = false;
return;
}
if (visited[i] == 2) return; // 必须加上,防止重复递归
visited[i] = 1;
for (int node : edges.get(i)) {
dfs(node);
}
visited[i] = 2;
}
}
模板 (BFS 遍历图):
public int bfs(int start, int target, Map<Integer, List<Integer>> graph) {
Queue<Integer> queue = new LinkedList<>();
Set<Integer> visited = new HashSet<>();
queue.offer(start);
visited.add(start);
int steps = 0;
while (!queue.isEmpty()) {
int size = queue.size();
for (int i = 0; i < size; i++) {
int node = queue.poll();
if (node == target) return steps;
for (int neighbor : graph.getOrDefault(node, new ArrayList<>())) {
if (!visited.contains(neighbor)) {
visited.add(neighbor);
queue.offer(neighbor);
}
}
}
steps++;
}
return -1; // 未找到路径
}
回溯前提知识
模板 (通用):
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>(); // 记录路径
void backtrack(int[] nums, int startIndex) {
// 1. 终止条件 (根据题目需求)
if (path.size() == k) {
result.add(new ArrayList<>(path)); // 注意深拷贝
return;
}
// 2. 遍历选择列表
for (int i = startIndex; i < nums.length; i++) {
// 剪枝操作 (可选,根据题目添加)
if (used[i]) continue;
// 3. 做选择
path.add(nums[i]);
used[i] = true;
// 4. 进入下一层决策树
backtrack(nums, i + 1); // 组合问题用 i+1,排列问题用 0
// 5. 撤销选择
path.removeLast();
used[i] = false;
}
}
关键词:'决策树','暴力,剪枝,选择。 真人心得:回溯题的本质就是暴力枚举,而减枝则降低了不必要的枚举。为什么叫减枝,回溯题就是对当前的某个位置或者状态选择一种可能性(比如有两个可能性),当前状态固定后再对下一个位置或者状态选择一种可能性。如果有两种可能性,那么就可以想象出一颗二叉树了,每个状态有两个选择,产生两个子节点,子节点同样如此。说到底就是所有的决策形成了一颗树,这棵树包含了所有可能的决策,所以叫暴力枚举,而减枝就是把不必要的决策分枝减去。
这种题目的固定做法就是传入必要的三个参数,最终返回的集合,路径集合(用作副本放入最终返回集合),当前处理位置索引。当然还要其他参数,比如标记数组等或其他记录(当然你也可以定义'全局变量'就不需要传参数进入递归了)。
每出现一次选择的操作,如果对路径集合进行了修改,就需要加上一个回溯。如果只有两种情况,加入和不加入,就可以不需要两个回溯,因为没有对路径集合进行修改。由此可见回溯不是树的倒走,return 才是到上层的入口。
回溯什么时候触发呢?:停止条件满足时,减枝条件满足时,直接 return 到上层,上层从一个递归操作中出来,可以想象栈顶栈帧被排除,执行当前栈顶栈帧。当前栈帧从一个选择出来后,如果对路径集合进行了修改,就需要回溯。
停止条件:因为我们一般传入当前处理的位置索引,所以停止条件就是当前处理位置索引递归到了最后,然后将路径集合结果放入最终返回集合。如果你发现最终的返回集合中多了许多杂质(不需要的路径),你可以将停止条件加入更多的限制语句,就比如括号生成题目,就需要加入左括号等于右括号的条件。当然,出现这种情况肯定还要考虑减枝是否正确。
有几个选择就要几次回溯,前提是选择对路径集合进行了修改。但是,也存在选择多个情况但是只要一次回溯的情况(单词搜索,最终返回是一个 boolean 标签,没有路径集合,只有标记访问标签,标记当前元素是否访问过,访问过就把标签置为 false,几个选择对于这个操作都是相同的,所以可以公用。所以只要一个回溯)
减枝:减枝是 continue 还是 return 要分析清楚,如果类似 n 皇后,就是 continue。因为不符合条件的选择可能后面还有符合条件的选择,所以要继续循环下去。
对于是否需要循环,就看时单个起始点还是多个起始点。对于每次都要做出相同的选择,需要显示将选择出来,对于类似于枚举分割点(分割回文串,则是用循环代替了选择,因为每次选择是递减的。)
left <= right) 和边界收缩 (left = mid + 1 或 right = mid - 1),写对这两个,就成功了 99%。模板 (左边界搜索):
public int leftBound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1; // 注意:即使找到也向左缩小范围
}
}
// 检查 left 是否越界或没找到
if (left >= nums.length || nums[left] != target) return -1;
return left;
}
模板 (标准二分查找):
public int binarySearch(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) return mid;
if (target > nums[mid]) left = mid + 1;
if (target < nums[mid]) right = mid - 1;
}
return -1;
}
当数组中有多个等于 target 的元素时,标准模板只会返回其中一个位置(不保证是第一个)。而左边界模板可以精确找到第一个出现的位置,常用于:
真人心得:重要规则,如果二分查找没有找到目标值,那么最终 left = right + 1,意味着 left 左侧([0, left-1])的所有元素都小于target;left 右侧([left, n-1])的所有元素都大于target。总结:用心理解上面两个模板,一个是找 mid,一个是找边界!找值就返回 mid,找边界就返回 left 或者 right。
处理有重复元素的场景
模板 (括号匹配):
public boolean isValid(String s) {
Stack<Character> stack = new Stack<>();
Map<Character, Character> map = new HashMap<>();
map.put(')', '(');
map.put('}', '{');
map.put(']', '[');
for (char c : s.toCharArray()) {
if (map.containsKey(c)) {
// 是右括号
if (stack.isEmpty() || !stack.pop().equals(map.get(c))) {
return false;
}
} else {
// 是左括号
stack.push(c);
}
}
return stack.isEmpty();
}
模板 (单调栈,找下一个更大元素):
public int[] nextGreaterElement(int[] nums) {
int[] result = new int[nums.length];
Stack<Integer> stack = new Stack<>(); // 存索引
for (int i = 0; i < nums.length; i++) {
while (!stack.isEmpty() && nums[stack.peek()] < nums[i]) {
int prevIndex = stack.pop();
result[prevIndex] = nums[i];
}
stack.push(i);
}
// 栈里剩下的元素没有下一个更大值
while (!stack.isEmpty()) {
result[stack.pop()] = -1;
}
return result;
}
堆前提知识点
PriorityQueue)。模板 (数据流中位数 - 双堆):
class MedianFinder {
PriorityQueue<Integer> maxHeap; // 左半部分,大顶堆
PriorityQueue<Integer> minHeap; // 右半部分,小顶堆
public MedianFinder() {
maxHeap = new PriorityQueue<>((a, b) -> b - a);
minHeap = new PriorityQueue<>();
}
public void addNum(int num) {
if (maxHeap.isEmpty() || num <= maxHeap.peek()) {
maxHeap.offer(num);
} else {
minHeap.offer(num);
}
// 保持平衡
if (maxHeap.size() > minHeap.size() + 1) {
minHeap.offer(maxHeap.poll());
} else if (minHeap.size() > maxHeap.size()) {
maxHeap.offer(minHeap.poll());
}
}
public double findMedian() {
if (maxHeap.size() == minHeap.size()) {
return (maxHeap.peek() + minHeap.peek()) / 2.0;
} else {
return maxHeap.peek();
}
}
}
模板 (求 Top K 大元素):
public List<Integer> topKLargest(int[] nums, int k) {
// 1. 创建一个大小为 K 的小顶堆
PriorityQueue<Integer> minHeap = new PriorityQueue<>(k);
// 2. 遍历数组
for (int num : nums) {
if (minHeap.size() < k) {
minHeap.offer(num);
} else if (num > minHeap.peek()) {
// 如果当前元素比堆顶大
minHeap.poll(); // 弹出最小的
minHeap.offer(num); // 加入当前元素
}
}
// 3. 堆中剩下的就是 Top K 大元素
return new ArrayList<>(minHeap);
}
真人心得:总是维护极值,比如最小的,最大的,最远的,然后一边遍历一边更新。
编码实现:
public int eraseOverlapIntervals(int[][] intervals) {
if (intervals.length == 0) return 0;
// 1. 按结束时间升序排序
Arrays.sort(intervals, (a, b) -> a[1] - b[1]);
int count = 1;
int end = intervals[0][1]; // 第一个区间的结束时间
// 2. 遍历,选择不重叠的区间
for (int i = 1; i < intervals.length; i++) {
if (intervals[i][0] >= end) {
// 找到了一个不重叠的区间
count++;
end = intervals[i][1];
}
}
return intervals.length - count; // 需要删除的区间数
}
dp 数组含义:dp[i] 或 dp[i][j] 代表什么?dp[i] 和 dp[i-1], dp[i-2]… 的关系是什么?dp 数组:dp[0], dp[1] 等初始值是什么?dp[n] 或 dp[n-1]。模板 (一维 DP,以"完全平方数"为例):外层决策遍历,内层状态遍历
import java.util.Arrays;
class Solution {
public int numSquares(int n) {
// 1. 初始化 dp 数组:dp[j] 表示'和为 j 的完全平方数的最少数量'
// 初始值设为 Integer.MAX_VALUE(表示'暂时无法组成'),避免后续取 min 时被干扰
int[] dp = new int[n + 1];
Arrays.fill(dp, Integer.MAX_VALUE);
// base case:和为 0 时,不需要任何平方数,数量为 0
dp[0] = 0;
// 2. 决策在外层循环:遍历所有'物品'(即所有 ≤n 的完全平方数)
// i 从 1 开始,i*i 就是当前要考虑的平方数(物品重量)
for (int i = 1; i * i <= n; i++) {
int square = i * i; // 当前平方数(物品重量)
// 3. 内层循环:遍历'背包容量'(从 square 到 n,确保 j - square ≥0)
// 完全背包允许物品重复选,所以容量从'物品重量'开始递增(与 0-1 背包的倒序区分)
for (int j = square; j <= n; j++) {
// 只有'dp[j - square] 不是无穷大'时,才可能更新 dp[j](避免 +1 后溢出)
if (dp[j - square] != Integer.MAX_VALUE) {
// 状态转移:选当前平方数(数量 +1),或不选(保持原 dp[j]),取最小值
dp[j] = Math.min(dp[j], dp[j - square] + 1);
}
}
}
// 最终 dp[n] 就是'和为 n 的最少平方数个数'
return dp[n];
}
}
public boolean wordBreak(String s, List<String> wordDict) {
// 1. 初始化基础参数
int n = s.length(); // n 是字符串 s 的长度(比如 s="leetcode",n=8)
boolean[] dp = new boolean[n + 1]; // dp 数组长度是 n+1,因为要存 dp[0] 到 dp[n]
dp[0] = true; // 关键初始值:空字符串(前 0 个字符)能拆分(不用任何单词),是所有子问题的起点
Set<String> wordSet = new HashSet<>(wordDict); // 字典转集合,优化查询速度
// 2. 外层循环:遍历所有'状态'(子问题)——判断 s 的前 i 个字符能否拆分
for (int i = 1; i <= n; i++) {
// 3. 内层循环:遍历所有'决策'(字典里的每个单词)——尝试用 word 作为 s 前 i 个字符的最后一段
for (String word : wordSet) {
int wordLen = word.length(); // 当前单词的长度(比如 word="leet",wordLen=4)
// 4. 验证当前决策是否有效(3 个条件必须同时满足)
// 条件 1:单词长度不能超过当前子问题的长度(比如 i=3 时,wordLen=4 就不可能匹配前 3 个字符)
// 条件 2:前(i - wordLen)个字符能拆分(dp[i - wordLen] 是 true)
// 条件 3:s 的第(i - wordLen)到第(i-1)个字符,正好等于当前单词(比如 i=4,wordLen=4,就看 s[0-3] 是不是"leet")
if (wordLen <= i && dp[i - wordLen] && s.substring(i - wordLen, i).equals(word)) {
dp[i] = true; // 只要有一个单词满足条件,当前子问题就有解(dp[i] 设为 true)
break; // 找到有效决策就不用再试其他单词了,直接下一个子问题(精准止损,提高效率)
}
}
}
return dp[n]; // 最终答案:s 的前 n 个字符(整个字符串)能否拆分
}
模板 (二维 DP,以"不同路径"为例):
public int uniquePaths(int m, int n) {
// 1. 定义 dp 数组:dp[i][j] 表示到达 (i,j) 的路径数
int[][] dp = new int[m][n];
// 3. 初始化第一行和第一列
for (int i = 0; i < m; i++) dp[i][0] = 1;
for (int j = 0; j < n; j++) dp[0][j] = 1;
// 4. 遍历
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
// 2. 状态转移方程
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
// 5. 返回结果
return dp[m - 1][n - 1];
}
//以下是二维动态规划,主要模拟循环 i 和 j,求 dp【i】【j】的子问题,单维的是有决策元素,通过最后一个决策元素,挑选更好的子问题。 二维则可以想象是表格,差不多,主要搞清楚 i 和 j 代表得什么,当前状态的子状态和 i 和 j 有什么关系,来自于 i 和 j 代表的什么前置状态。关键是思考从一个状态到状态一个需要做什么操作,这些操作分别执行就是决策,选择最优决策,也就是最好的操作。思考从求什么状态变成求什么状态。
真人心得:动态规划是子问题相互推导依赖的过程,也就是局部推导出全部,和贪心有些类似。动态规划的'局部最优',不是随便的'局部'。它有严格定义,就是问题的全局最优解,必然包含了某个子问题的局部最优解。 比如爬楼梯问题:要到第 10 成,假设全局最优为最小步数,那么要求到达第 9 层和第 8 层也是最小步数,这些局部最优解形成了全局最优解。 打家劫舍问题:偷前五家的最大金额(全局最优),要么偷第五家,要么不偷第五家,(依赖偷前 3 家或者偷前 4 家的局部最优)。 总结:问题本身具备'最优子结构'和'无后效性'。
深入理解:动态规划的本质是**'记忆化搜索',即把递归过程中重复计算的子问题结果存起来,避免重复计算。
假设你面前有 5 棵果树(对应 5 间房),每棵树上有苹果(对应房里的钱),规则是'不能摘相邻果树的苹果'**(对应不能偷相邻房)。
'决策树'就是你'从第一棵树到第五棵树,所有可能的摘苹果路径'
上面就是用递归解决,但是递归后路径太多了,可以采取减枝,如何减枝呢?就是记忆化搜索,把重复路径存储。
比如从树 1 到达树 3,有三条路径,1 摘,2 不摘(路径 1);1 不摘,2 摘(路径 2);1 不摘,2 不摘(路径 3)。此时在梳 3 选择,对于路径 1 和路径 3,这两个路径可以剪掉一条。
!
可以看出一个节点在做决策时,可能依赖保留了两种可能情况,那么在动态规划中,一个 dp【i】只能存储一个确定的情况?如何解决呢,初始化,dp【0】或者 dp【i】,也就是添加两个缓存空间,那么对于 dp【2】,做决策开始,它可以选择,由于 dp[i]=max(dp[i-2]+dp[i],dp[i-1]),也就是对于第一个节点 dp【2】做决策,它的两种情况分别来自于我们初始化的 dp【0】和 dp【1】.
dp【3】做决策的两种情况来自于 dp【1】和 dp【2】,也就是一个节点分别当作了两种情况,下一个节点选择的情况,下下个节点不选择的情况。
可以看出由于缓冲后,模拟了树的分支。
由上面的分析,我们对动态规划有了建模思想,就是决策树 + 减枝,模拟树种的分支,就用缓冲空间来实现,也就是初始化 dp,一个节点相当于好几个分支,是下一个决策的前提分支(没选择),又是下下个决策的前提分支(选择)。 相当于'用线性缓存链,替代了树的网状分支',既没漏掉任何合法选择,又极大提升了效率。 递归回溯 + 记忆化:这个递归其实是回溯。他会递归到最深处,计算最后面的 dp【i】,然后存储起来。相当于存储了这个叶子或者枝条,供其他相同的使用。
上面是属于本质疏通,可以不看,因为有人可能会奇怪为什么线性的数组能完成决策树的功能。如果搞不懂就可能陷入死循环,或者说无法理解动态规划的本质。
接下来是做题疏通,就是怎么做题,以零钱兑换为列:
1.可重复选择,相当于完全背包问题。
划分子问题:要解决这个问题,先思考:'凑 n 的最少硬币数'和'更小金额的硬币数'有什么关系?
我们的核心目标是:凑出总金额n的最少硬币个数。
要解决这个问题,先思考:'凑n的最少硬币数'和'更小金额的硬币数'有什么关系?
比如凑11(目标n=11),最后一步选的硬币只能是1、2或5(假设硬币是[1,2,5]):
1,则之前需要凑出11-1=10,此时'凑 11 的个数'='凑 10 的个数 + 1';2,则之前需要凑出11-2=9,此时'凑 11 的个数'='凑 9 的个数 + 1';5,则之前需要凑出11-5=6,此时'凑 11 的个数'='凑 6 的个数 + 1';//遍历每个 i,有选择和不选两个选择,可重复选
//假设 dp[i] 为目标为 i 时的最少硬币个数,也就是子问题。i 为目标钱的金额
//若选择 dp[i]=dp[i-当前硬币的金额]+1,不选择 dp[i]=dp[i]
//dp[i]=min(dp[i-coin]+1,dp[i]);
//初始化 dp[0]=0;
//其他为 amount+1,表示不可达
//具体代码就是遍历硬币 coin,在这个遍历里面处理面额 coin 到 n,因为如果目标小于 coin,就凑不出来
//就是双重遍历,实时更新 dp。在遍历选与不选里面遍历目标,每完成一次外层(遍历选与不选)都会优化一次 dp 数组。
//题外话,假设不可重复选,依旧是 dp[i] = min(dp[i], dp[i - coin] + 1),仅仅需要倒叙遍历目标金额即可,确保不会重复选。如何理解二重循环呢:原本我们是用二维动态规划,优化成了一维动态规划。这里的双重循环就是模拟二维。二维 DP 的'前 m 枚硬币'维度,被一维 DP 的'外层循环遍历 m 枚硬币'替代;二维 DP 的'金额 j'维度,被一维 DP 的'内层循环遍历 j'直接保留;
理解 2:在遍历最外层的的硬币时,每一个当前硬币纳入可选,就优化一次 dp 数组,因为我们的动态数组初始化了的。 出现一个硬币,就提供了一种选择,讲动态数组优化,可以想象最开始的动态数组是模糊的,每出现一种硬币的选择,就可以清晰一些。
那为什么打家劫舍需要一层循环,而硬币问题需要两层循环呢? 因为关键是状态和决策,打家劫舍的状态是 dp[i],偷到 i 房屋的最大金额;决策是每个状态 i 决策只有两个,也来自于当前集合数组,也就是房屋金额数组。 而硬币问题的状态是 dp[i],抽出金额 i 需要的最少硬币数。 决策是选什么硬币来凑得。 前者问题的状态和决策来自于一个数组,的状态依赖一个外部集合数组。 后者问题的状态来自于一个数组,决策来自于一个外部集合数组。 然而上面的总结不够全面,应该这么说,判断是否需要两层遍历,就通过 dp[i] 当前子问题依赖的是前面固定个数的子问题,还是,依赖前面所有的子问题,从 dp[0i],比如打家劫舍,需要一层遍历,是因为它的 dp[i] 只依赖固定的 dp[i-1] 和 dp[i-2],而硬币问题的 dp[i] 依赖于前面所有的子问题,也就是 dp[0i],因为每个子问题都可能是最优解。 以 dp[j] = min( dp[j - coins[i]] + 1 ,dp[i]) 为例,这是需要两层遍历的,因为 coins[i] 就是决策,需要遍历每个决策元素。 而打家劫舍的状态转移方程: dp[i] = max( dp[i - 2] + nums[i], dp[i - 1]),这里的 nums[i] 是状态 i 的值,并不是决策元素,决策元素是偷或者不偷,而偷或者不偷只有两个选择,不需要遍历决策元素。
对于一维的动态规划,模板非常简单。 对于二维动态规划,有这样的模板,外层遍历决策数组,内层遍历状态数组,内层遍历的内容就是状态转移方程,也就是某种决策的最佳选择。前提是要将 dp 数组初始化好,要整体初始,不是只初始化部分。这个模板的思想就是:每遍历一个决策数组的元素,状态转移方程做出了最佳决策后,就优化一次 dp 数组(从模糊到清晰)。 而对于同一个物体是否可重复选只需调整内层状态遍历的方向 即可。
//选择或者不选择,选择则 dp[i]=dp[i-coin]+1,不选择则 dp[i]=dp[i]
!
!
一下是对决策和状态的深度刨析(理解就无敌了!):
dp[i] 是上一论遍历的结果,而 dp[i-coin]+1,是这一轮遍历的结果,选择表示含义 - 由于外层遍历循环比上一轮多得到了一个决策元素,可以优化这一轮的结果。而不选择就表示这一轮多出来的决策元素不影响此次优化
决策 + 状态和状态 + 决策一般都可以互相转换。 如果是状态 + 决策,也就是外层遍历每个状态 dp[i],内层遍历决策集合或者数组,选择外层状态,内层决策,不用模糊初始化 dp 数组,再一轮一轮的做选择还是不选择的优化;而是对当前状态,遍历所有决策,直接选出最好的决策,进行从前往后的递推。 状态转移公式就不是看选择还是不选择了,而是看这样的思维推导:在最后选择哪一个决策元素后,dp[i-决策元素] 的结果是最优的。虽然思维不同,但通常状态转移公式是一样的,但表现形式不一样,比如 dp[i]=dp[i]+dp[i-jj] 可能会变成这样表现:先遍历每一个 dp[i-jj],选择最小的作为 dp[i]; 当然还有一个不同之处,就是循环,状态 + 决策的循环是前推导后的,也就是说内层遍历的结束可能只到当前状态,而决策 + 状态的内层遍历就是遍历完整的,因为它是一轮轮优化模糊初始化的 dp 数组。
总结两种思想:选择还是不选择,然后一轮轮优化模糊初始化的 dp 数组;用当前状态遍历每个决策元素,对于每个决策元素都有 dp[i-决策元素],选择最好的那个。
那这两个思想如何选择呢? 以 LeetCode 300:最长递增子序列为例:
//划分子问题,s=x+y,x 是枚举的某个最后一个选择,y 是子问题
//设 dp[i] 是 nums[i] 结尾的最长递增子序列长度。
//思想 1:采用外层决策 + 内层状态的思想,因为就是当前选与不选的问题。
//dp[i]=max/min(dp[i-当前决策元素]+1,dp[i])
//先模糊初始化整体 dp 数组,一轮轮根据状态方程优化 dp 数组。
//如果采用外层状态 + 内层决策的思想,就是选择哪一个决策最好的问题。
//思想 2:先初始化 dp 数组,然后每次循环梅每个决策的可能,选择当前最好的决策,然后往后递推。
//但由于 dp[i-当前决策元素] 不好表示,因为这一题不像硬币问题,决策元素直接有某个权重加入了计算。
//所以选用思想 2:当前 dp[i] 如果选定了某个最后的决策元素 j,那么对应的 dp[j] 要是最大.
a ^ a = 0, a ^ 0 = a。非常适合解决"只出现一次/两次"的问题。模板 (摩尔投票,找众数):
public int majorityElement(int[] nums) {
int candidate = nums[0];
int count = 1;
for (int i = 1; i < nums.length; i++) {
if (count == 0) {
candidate = nums[i];
count = 1;
} else if (nums[i] == candidate) {
count++;
} else {
count--;
}
}
return candidate;
}
模板 (位运算,找只出现一次的数字):
public int singleNumber(int[] nums) {
int result = 0;
for (int num : nums) {
result ^= num; // 所有成对的数都抵消为 0,剩下那个单独的
}
return result;
}
indexOf(),复杂匹配考虑 KMP 算法;对于统计,用哈希表;对于查找前缀,用 Trie 树。模板 (KMP 算法):
public int strStr(String haystack, String needle) {
if (needle.length() == 0) return 0;
// 构建 next 数组
int[] next = new int[needle.length()];
for (int i = 1, j = 0; i < needle.length(); i++) {
while (j > 0 && needle.charAt(i) != needle.charAt(j)) {
j = next[j - 1];
}
if (needle.charAt(i) == needle.charAt(j)) {
j++;
}
next[i] = j;
}
// 匹配过程
for (int i = 0, j = 0; i < haystack.length(); i++) {
while (j > 0 && haystack.charAt(i) != needle.charAt(j)) {
j = next[j - 1];
}
if (haystack.charAt(i) == needle.charAt(j)) {
j++;
}
if (j == needle.length()) {
return i - needle.length() + 1;
}
}
return -1;
}
模板 (Trie 树):
class Trie {
private TrieNode root;
public Trie() {
root = new TrieNode();
}
public void insert(String word) {
TrieNode node = root;
for (char c : word.toCharArray()) {
if (node.children[c - 'a'] == null) {
node.children[c - 'a'] = new TrieNode();
}
node = node.children[c - 'a'];
}
node.isEnd = true;
}
public boolean search(String word) {
TrieNode node = searchPrefix(word);
return node != null && node.isEnd;
}
public boolean startsWith(String prefix) {
return searchPrefix(prefix) != null;
}
private TrieNode searchPrefix(String prefix) {
TrieNode node = root;
for (char c : prefix.toCharArray()) {
if (node.children[c - 'a'] == null) {
return null;
}
node = node.children[c - 'a'];
}
return node;
}
class TrieNode {
TrieNode[] children = new TrieNode[26];
boolean isEnd = false;
}
}
模板:
class UnionFind {
private int[] parent;
private int[] rank; // 树的高度
private int count; // 连通分量数量
public UnionFind(int n) {
parent = new int[n];
rank = new int[n];
count = n;
for (int i = 0; i < n; i++) {
parent[i] = i; // 初始时每个节点的父节点是自己
}
}
// 查找根节点,包含路径压缩
public int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩
}
return parent[x];
}
// 合并两个集合
public void union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
// 按秩合并,将较低的树连接到较高的树下
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
parent[rootY] = rootX;
rank[rootX]++; // 高度增加
}
count--; // 连通分量减少
}
}
// 判断两个元素是否连通
public boolean connected(int x, int y) {
return find(x) == find(y);
}
// 获取连通分量数量
public int getCount() {
return count;
}
}
按照以下顺序学习算法,可以从易到难循序渐进:
| 关键词 | 立刻想到 | 模板 |
|---|---|---|
| '两数之和'、'配对' | 哈希表 | map.containsKey(target - num) |
| '有序数组'、'target' | 双指针/二分 | left < right 夹逼 |
| '连续子串'、'最长/最短' | 滑动窗口 | while (right < n) 扩窗口 |
| '链表环'、'中点' | 快慢指针 | slow/fast 经典 |
| '二叉树遍历' | 递归 | dfs(root.left), dfs(root.right) |
| '全排列'、'组合' | 回溯 | backtrack(path, choices) |
| '第 K 大'、'Top K' | 堆 | PriorityQueue |
| '最优解'、'可能性' | 动态规划 | dp[i] = max(dp[i-1], ...) |
| '判断连通' | 并查集/BFS | find(), union() |
| '布局/路径' | 图论 | BFS 寻最短路径 |
| '位操作' | 异或 | a ^ a = 0 |

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online