以下仅是个人学习记录,不具备他人的参考意义
tip
虚拟头结点:
当你需要创造一条新链表的时候,可以使用虚拟头结点简化边界情况的处理。
二叉堆
是一种特殊的二叉树(完全二叉树),只不过存储在数组里。
注意数组的第一个索引 0 空着不用
// 父节点的索引
int parent(int root) {
return root / 2;
}
// 左孩子的索引
int left(int root) {
return root * 2;
}
// 右孩子的索引
int right(int root) {
return root * 2 + 1;
}
看到下图就明白了,这棵二叉树是「完全二叉树」,所以把 arr[1] 作为整棵树的根的话,每个节点的父节点和左右孩子的索引都可以通过简单的运算得到,这就是二叉堆设计的一个巧妙之处。
最大堆
每个节点都大于等于它的两个子节点。根据其性质,显然堆顶,也就是 arr[1] 一定是所有元素中最大的元素。
最小堆
每个节点都小于等于它的子节点。
优先级队列
优先级队列这种数据结构有一个很有用的功能,你插入或者删除元素的时候,元素会自动排序,这底层的原理就是二叉堆的操作。
优先级队列有两个主要 API,分别是 insert 插入一个元素和 delMax 删除最大元素(如果底层用最小堆,那么就是 delMin)。
对于最大堆,会破坏堆性质的有两种情况:
- 如果某个节点 A 比它的子节点(中的一个)小,那么 A 就不配做父节点,应该下去,下面那个更大的节点上来做父节点,这就是对 A 进行下沉。
- 如果某个节点 A 比它的父节点大,那么 A 不应该做子节点,应该把父节点换下来,自己去做父节点,这就是对 A 的上浮。
当然,错位的节点 A 可能要上浮(或下沉)很多次,才能到达正确的位置,恢复堆的性质。所以代码中肯定有一个 while 循环。
小结
- 二叉堆是一种完全二叉树,所以适合存储在数组中.
- 二叉堆的操作很简单,主要就是上浮和下沉,来维护堆的性质,使堆有序.
- 优先级队列是基于二叉堆来实现的,主要操作是插入和删除.
- 插入是先插入到最后,然后上浮到正确位置.
- 删除是先和最后一位调换位置后再删除,然后下沉到正确位置.
双指针技巧总结
左右指针
就是两个指针相向而行或者相背而行;
快慢指针
就是两个指针同向而行,一快一慢。
对于单链表来说,大部分技巧都属于快慢指针,比如链表环判断,倒数第 K 个链表节点等问题,它们都是通过一个 fast 快指针和一个 slow 慢指针配合完成任务。
在数组中并没有真正意义上的指针,但我们可以把索引当做数组中的指针,这样也可以在数组中施展双指针技巧。
滑动窗口
二分查找
思路很简单,细节是魔鬼。
代码模版如下:
int binarySearch(int[] nums, int target) {
int left = 0, right = ...;
while(...) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
...
} else if (nums[mid] < target) {
left = ...
} else if (nums[mid] > target) {
right = ...
}
}
return ...;
}
运用二分搜索的套路框架,效果绝了,典型例子:875. 爱吃香蕉的珂珂
// 函数 f 是关于自变量 x 的单调函数
int f(int x) {
// ...
}
// 主函数,在 f(x) == target 的约束下求 x 的最值
int solution(int[] nums, int target) {
if (nums.length == 0) return -1;
// 问自己:自变量 x 的最小值是多少?
int left = ...;
// 问自己:自变量 x 的最大值是多少?
int right = ... + 1;//注意这里+1 也是有讲究的,因为下面说开区间
while (left < right) {
int mid = left + (right - left) / 2;
if (f(mid) == target) {
// 问自己:题目是求左边界还是右边界?
// ...
} else if (f(mid) < target) {
// 问自己:怎么让 f(x) 大一点?
// ...
} else if (f(mid) > target) {
// 问自己:怎么让 f(x) 小一点?
// ...
}
}
return left;
}
遇到最大化最小值或最小化最大值,就是二分查找
滑动窗口问题
滑动窗口算法延伸 Rabin-Karp 算法
//字符串形式的正整数,把它转化成数字的形式
String s = "8264";
int number = 0;
for (int i = 0; i < s.length(); i++) {
// 将字符转化成数字
number = 10 * number + (s.charAt(i) - '0');
System.out.println(number);
}
//这个算法的核心思路就是不断的向最低位(个数)添加数字,同时把前面的数字整体左移一位
//删除数字的最高位,eg:8264 - 8000 = 264
/* 在最低位添加一个数字 */
int number = 8264;
// number 的进制
int R = 10;
// 想在 number 的最低位添加的数字
int appendVal = 3;
// 运算,在最低位添加一位
number = R * number + appendVal;
// 此时 number = 82643
/* 在最高位删除一个数字 */
int number = 8264;
// number 的进制
int R = 10;
// number 最高位的数字
int removeVal = 8;
// 此时 number 的位数
int L = 4;
// 运算,删除最高位数字
number = number - removeVal * R^(L-1);
// 此时 number = 264
N 为字符串长度,L 为子串长度
暴力解法 O(LN) -> 优化为初步的 滑动窗口算法框架 O(LN)-> 优化为 Rabin-Karp 算法,即不要把真的子字符串生成出来,用一些其他形式的唯一标识来表示滑动窗口中的子字符串,并且还能再窗口华东的过程中快速更新。
在华东窗口中快速计算窗口中元素的哈希值,叫做滑动哈希技巧。
题可以再写一遍
二叉树
二叉树解题的思维模式分两类:「遍历」,「分解问题」。 理解后可应用到 动态规划、回溯算法、分治算法、图论算法。
前序遍历:中前后
中序遍历:前中后
后序遍历:前后中
5
/
2 7
/ \ /
1 3 6 8
前中后序是遍历二叉树过程中处理每一个节点的三个特殊时间点。 前序位置的代码在刚刚进入一个二叉树节点的时候执行; 后序位置的代码在将要离开一个二叉树节点的时候执行; 中序位置的代码在一个二叉树节点左子树都遍历完,即将开始遍历右子树的时候执行。
多叉树节点没有「唯一」的中序遍历位置。
// 定义:输入一棵二叉树的根节点,返回这棵树的前序遍历结果
List<Integer> preorderTraverse(TreeNode root) {
List<Integer> res = new LinkedList<>();
if (root == null) {
return res;
}
// 前序遍历的结果,root.val 在第一个
res.add(root.val);
// 利用函数定义,后面接着左子树的前序遍历结果
res.addAll(preorderTraverse(root.left));
// 利用函数定义,最后接着右子树的前序遍历结果
res.addAll(preorderTraverse(root.right));
return res;
}
//中序和后序遍历也是类似的,只要把 `add(root.val)` 放到中序和后序对应的位置就行了。
**这个算法的复杂度不好把控**,比较依赖语言特性。
Java 的话无论 ArrayList 还是 LinkedList,`addAll` 方法的复杂度都是 O(N),所以总体的最坏时间复杂度会达到 O(N^2),除非你自己实现一个复杂度为 O(1) 的 `addAll` 方法,底层用链表的话是可以做到的,因为多条链表只要简单的指针操作就能连接起来。
综上,遇到一道二叉树的题目时的通用思考过程是:
-
是否可以通过遍历一遍二叉树得到答案**?如果可以,用一个
traverse函数配合外部变量来实现。 -
是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值。
-
无论使用哪一种思维模式,你都要明白二叉树的每一个节点需要做什么,需要在什么时候(前中后序)做。
对于二叉树中的每个节点,如果左子树节点的元素都小于根节点,而右子树的节点的元素都大于根节点,那么这样的树被叫做二叉搜索树(Binary Search Tree) 简称BST。
一旦你发现题目和子树有关,那大概率要给函数设置合理的定义和返回值,在后序位置写代码了。
tip1:
二叉树中用遍历思路解题时函数签名一般是 void traverse(...),没有返回值,靠更新外部变量来计算结果,而用分解问题思路解题时函数名根据该函数具体功能而定,而且一般会有返回值,返回值是子问题的计算结果。
tip2:
在 [回溯算法核心框架]中给出的函数签名一般也是没有返回值的 void backtrack(...),而在 [动态规划核心框架]中给出的函数签名是带有返回值的 dp 函数。这也说明它俩和二叉树之间千丝万缕的联系。
动归/DFS/回溯算法都可以看做二叉树问题的扩展,只是它们的关注点不同:
- 动态规划算法属于分解问题的思路,它的关注点在整棵「子树」。
- 回溯算法属于遍历的思路,它的关注点在节点间的「树枝」。
- DFS 算法属于遍历的思路,它的关注点在单个「节点」。
动态规划
三要素:
- 重叠子问题
- 最优子结构
- 状态转移方程:最困难,明确 base case -> 明确「状态」-> 明确「选择」 -> 定义
dp数组/函数的含义。
但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复杂度,寻找算法低效的原因都有巨大帮助。
二叉树节点总数为指数级别,所以子问题个数为 O(2^n)。
带备忘录的递归解法的效率已经和迭代的动态规划解法一样了。实际上,这种解法和常见的动态规划解法已经差不多了,只不过这种解法是「自顶向下」进行「递归」求解,我们更常见的动态规划代码是「自底向上」进行「递推」求解。
- 自顶向下:是从上向下延伸,都是从一个规模较大的原问题比如说
f(20),向下逐渐分解规模,直到f(1)和f(2)这两个 base case,然后逐层返回答案,这就叫「自顶向下」。 - 自底向上:反过来,我们直接从最底下、最简单、问题规模最小、已知结果的
f(1)和f(2)(base case)开始往上推,直到推到我们想要的答案f(20)。这就是「递推」的思路,这也是动态规划一般都脱离了递归,而是由循环迭代完成计算的原因。
自顶向下、自底向上两种解法本质其实是差不多的,大部分情况下,效率也基本相同。
状态转移方程
f(n) 的函数参数会不断变化,所以你把参数 n 想做一个状态,这个状态 n 是由状态 n - 1 和状态 n - 2 转移(相加)而来,这就叫状态转移。
降低空间复杂度 一般是最后一步优化,eg:数组可以去掉。
KMP 算法
KMP 是一个著名的字符串匹配算法,效率很高,但有些复杂。
pat 表示模式串,长度为M; txt 表示文本串,长度为N; KMP 算法实在 txt 中查找子串 pat,如果存在,返回这个子串的起始索引,否则返回 -1。
KMP 算法永不回退 txt 的指针 i,不走回头路,二是借助 dp 数组中存储的信息把 pat 移到正确的位置继续匹配,时间复杂度只需 O(N),空间换时间,所以我认为它是一种动态规划算法。
计算 dp 数组,只和 pat 串有关。
KMP算法最关键的步骤就是构造这个状态转移图,要确定状态转移的行为,得明确两个变量,一个是当前的匹配状态,另一个是遇到的字符。
//0 <= j < M, 代表当前的状态
//0 <= c < 256,代表遇到的字符「ASCII 码」
//0 <= next <= M,代表下一个状态
//dp[4]['A'] = 3,表示状态 4,如果遇到字符 A,pat应该转移到状态 3
//dp[1]['B'] = 2,表示状态 1,如果遇到字符 B,pat应该转移到状态 2
归并排序
代码框架
// 定义:排序 nums[lo..hi]
void sort(int[] nums, int lo, int hi) {
if (lo == hi) {
return;
}
int mid = (lo + hi) / 2;
// 利用定义,排序 nums[lo..mid]
sort(nums, lo, mid);
// 利用定义,排序 nums[mid+1..hi]
sort(nums, mid + 1, hi);
/****** 后序位置 ******/
// 此时两部分子数组已经被排好序
// 合并两个有序数组,使 nums[lo..hi] 有序
merge(nums, lo, mid, hi);
/*********************/
}
// 将有序数组 nums[lo..mid] 和有序数组 nums[mid+1..hi]
// 合并为有序数组 nums[lo..hi]
void merge(int[] nums, int lo, int mid, int hi);
归并排序就是先把左半边数组排好序,再把右半边数组排好序,然后把两半数组合并。
上述代码和二叉树的后序遍历很像:
/* 二叉树遍历框架 */
void traverse(TreeNode root) {
if (root == null) {
return;
}
traverse(root.left);
traverse(root.right);
/****** 后序位置 ******/
print(root.val);
/*********************/
}
归并排序利用的是分解问题的思路「分治算法」。 是稳定排序。
快速排序
void sort(int[] nums, int lo, int hi) {
if (lo >= hi) {
return;
}
// 对 nums[lo..hi] 进行切分
// 使得 nums[lo..p-1] <= nums[p] < nums[p+1..hi]
int p = partition(nums, lo, hi);
// 去左右子数组进行切分
sort(nums, lo, p - 1);
sort(nums, p + 1, hi);
}
快速排序就是一个二叉树的前序遍历. 归并排序:先把左半边数组排好序,再把右半边数组排好序,然后把两半数组合并。 快速排序:先将一个元素排好序,然后再将剩下的元素排好序。
快速排序的核心无疑是 partition 函数, partition 函数的作用是在 nums[lo..hi] 中寻找一个切分点 p,通过交换元素使得 nums[lo..p-1] 都小于等于 nums[p],且 nums[p+1..hi] 都大于 nums[p]。
快速排序的过程就是一个构造二叉搜索树的过程。
但谈到二叉搜索树的构造,那就不得不说二叉搜索树不平衡的极端情况,极端情况下二叉搜索树会退化成一个链表,导致操作效率大幅降低。
为了避免这种极端情况,需要引入随机性:常见的方式是在进行排序之前对整个数组执行 洗牌算法 进行打乱,或者在 partition 函数中随机选择数组元素作为切分点。
快速排序理想情况的时间复杂度是 O(NlogN),空间复杂度 O(logN),极端情况下的最坏时间复杂度是 O(N^2),空间复杂度是 O(N)
fun quickSort(nums: IntArray) {
shuffle(nums)
quickSort(nums, 0, nums.size - 1)
}
fun quickSort(nums: IntArray, low: Int, high: Int) {
if (low >= high) return
val p = partition(nums, low, high)
quickSort(nums, low, p - 1)
quickSort(nums, p + 1, high)
}
fun partition(nums: IntArray, low: Int, high: Int): Int {
val target = nums[low]
var i = low + 1
var j = high
while (i <= j) {
while (i < high && nums[i] < target) {
i++
}
while (j > low && nums[j] > target) {
j--
}
swap(nums,i,j)
}
swap(nums,low,j)
return j
}
/**
* 洗牌算法:将输入的数组随机打乱
*/
fun shuffle(nums: IntArray) {
val random = Random()
val size = nums.size
repeat(size) {
val r = it + random.nextInt(size - it)
swap(nums, it, r)
}
}
fun swap(nums: IntArray, i: Int, j: Int) {
val temp = nums[i]
nums[i] = nums[j]
nums[j] = temp
}
快速选择算法
PriorityQueue 这个类需要熟悉一下,可以解决这道题 力扣第 215 题「数组中的第 K 个最大元素open in new window」
解法一: 二叉堆的解法 解法二:快速选择算法
快速选择算法是快速排序的变体,效率更高,面试中如果能够写出快速选择算法,肯定是加分项。
LRU 算法
Least Recently Used 可以使用 LinkedHashMap 实现,也可以完全自己写,但是需要注意结合map和双向链表实现
LFU 算法
Least Frequently Use,按照访问频率来淘汰,每次淘汰那些使用次数最少的数据。 如果多个数据拥有相同的访问频次,我们就得删除最早插入的那个数据。也就是说 LFU 算法是淘汰访问频次最低的数据,如果访问频次最低的数据有多条,需要淘汰最旧的数据。 用三个hashmap 实现。
回溯算法
回溯算法是在遍历「树枝」,DFS 是在遍历「节点」。 解决一个回溯问题,实际上就是遍历一颗决策树的过程,树的每个叶子节点存放着一个合法答案,你把整棵树遍历一遍,把叶子节点上的答案都手机起来,就能得到所有的合法答案。
站在回溯树的一个节点上,你只需要思考 3 个问题:
- 路径:也就是已经做出的选择。
- 选择列表:也就是你当前可以做的选择。
- 结束条件:也就是到达决策树底层,无法再做选择的条件。
可以把这棵树称为回溯算法的「决策树」。
//多叉树的遍历框架
void traverse(TreeNode root) {
for (TreeNode child : root.childern) {
// 前序位置需要的操作
traverse(child);
// 后序位置需要的操作
}
}
前序遍历的代码在进入某一个节点之前的那个时间点执行,后序遍历代码在离开某个节点之后的那个时间点执行。
不像动态规划存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般都很高。
总结
回溯算法就是个多叉树的遍历问题,关键就是在前序遍历和后序遍历的位置做一些操作。
def backtrack(...):
for 选择 in 选择列表:
做选择
backtrack(...)
撤销选择
写 backtrack 函数时,需要维护走过的「路径」和当前可以做的「选择列表」,当触发「结束条件」时,将「路径」计入结果集。
排列、组合、子集问题
无论是排列、组合还是子集问题,简单说无非就是让你从序列 nums 中以给定规则取若干元素,主要有以下几种变体:
- 形式一:元素无重不可复选,即 nums 中的元素都是唯一的,每个元素最多只能被使用一次。这也是最基本的形式。
- 形式二:元素可重不可复选,即 nums 中的元素可以存在重复,每个元素最多只能被使用一次。
- 形式三:元素无重可复选,即 nums 中的元素都是唯一的,每个元素可以被使用若干次。
但无论形式怎么变化,其本质就是穷举所有解,这些解呈现树形结构,所以合理使用回溯算法框架,稍改代码框架即可把这些问题一网打尽。
removeLast() API 要正确使用
//47. 全排列 II -- middle
//
class Solution {
private val result = mutableListOf<List<Int>>()
private val track = mutableListOf<Int>()
private val used = mutableListOf<Int>()
fun permuteUnique(nums: IntArray): List<List<Int>> {
Arrays.sort(nums)
backtrack(nums)
return result.toList()
}
private fun backtrack(nums: IntArray) {
if (track.size == nums.size) {
result.add(track.toList())
return
}
(nums.indices).forEach {
if (used.contains(it)) {
return@forEach
}
//新添加的剪枝逻辑,固定相同的元素在排列中的相对位置
//保证相同元素在排列中的相对位置保持不变
if (it > 0 && nums[it] == nums[it - 1] && !used.contains(it - 1)) {
return@forEach
}
track.add(nums[it])
used.add(it)
backtrack(nums)
//注意 这里一定要死 removeLast,对于含有重复元素的时候,
// 这样才是正确写法
// track.remove(nums[it]) 这样是错误的
//这里是重点
track.removeLast()
used.remove(it)
}
}
}
形式一、元素无重不可复选,即 nums 中的元素都是唯一的,每个元素最多只能被使用一次,backtrack 核心代码如下:
/* 组合/子集问题回溯算法框架 */
void backtrack(int[] nums, int start) {
// 回溯算法标准框架
for (int i = start; i < nums.length; i++) {
// 做选择
track.addLast(nums[i]);
// 注意参数
backtrack(nums, i + 1);
// 撤销选择
track.removeLast();
}
}
/* 排列问题回溯算法框架 */
void backtrack(int[] nums) {
for (int i = 0; i < nums.length; i++) {
// 剪枝逻辑
if (used[i]) {
continue;
}
// 做选择
used[i] = true;
track.addLast(nums[i]);
backtrack(nums);
// 撤销选择
track.removeLast();
used[i] = false;
}
}
形式二、元素可重不可复选,即 nums 中的元素可以存在重复,每个元素最多只能被使用一次,其关键在于排序和剪枝,backtrack 核心代码如下:
Arrays.sort(nums);
/* 组合/子集问题回溯算法框架 */
void backtrack(int[] nums, int start) {
// 回溯算法标准框架
for (int i = start; i < nums.length; i++) {
// 剪枝逻辑,跳过值相同的相邻树枝
if (i > start && nums[i] == nums[i - 1]) {
continue;
}
// 做选择
track.addLast(nums[i]);
// 注意参数
backtrack(nums, i + 1);
// 撤销选择
track.removeLast();
}
}
Arrays.sort(nums);
/* 排列问题回溯算法框架 */
void backtrack(int[] nums) {
for (int i = 0; i < nums.length; i++) {
// 剪枝逻辑
if (used[i]) {
continue;
}
// 剪枝逻辑,固定相同的元素在排列中的相对位置
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
continue;
}
// 做选择
used[i] = true;
track.addLast(nums[i]);
backtrack(nums);
// 撤销选择
track.removeLast();
used[i] = false;
}
}
形式三、元素无重可复选,即 nums 中的元素都是唯一的,每个元素可以被使用若干次,只要删掉去重逻辑即可,backtrack 核心代码如下:
/* 组合/子集问题回溯算法框架 */
void backtrack(int[] nums, int start) {
// 回溯算法标准框架
for (int i = start; i < nums.length; i++) {
// 做选择
track.addLast(nums[i]);
// 注意参数
backtrack(nums, i);
// 撤销选择
track.removeLast();
}
}
/* 排列问题回溯算法框架 */
void backtrack(int[] nums) {
for (int i = 0; i < nums.length; i++) {
// 做选择
track.addLast(nums[i]);
backtrack(nums);
// 撤销选择
track.removeLast();
}
}
图
calss Vertex(val id:Int, val neighbors:Vertex[])
一般不用如上方式,而是使用邻接表和邻接矩阵的方式。
// 邻接表
// graph[x] 存储 x 的所有邻居节点
List<Integer>[] graph;
// 邻接矩阵
// matrix[x][y] 记录 x 是否有一条指向 y 的边
boolean[][] matrix;
- 邻接表:占用空间少,但是无法快速判断两个节点是否相邻
- 度:无向图中,度就是每个节点相连的边的条数;有向图中每个节点的度被细分为入度和出度
图可能是包含环的,遍历框架就要一个 visited 数组进行辅助。
拓扑排序
数组
前缀
前缀和主要适用的场景是原始数组不会被修改的情况下,频繁查询某个区间的累加和。
差分数组
差分数组的主要适用场景是频繁对原始数组的某个区间的元素进行增减。
对 nums 数组构造一个 diff 差分数组,diff[i] 就是 nums[i] 和 nums[i-1] 之差:
二维数组的花式操作
顺/逆时针旋转矩阵
二维数组顺时针旋转 90 度 = 左上到右下的对角线进行镜像对称 + 矩阵的每一行进行反转 二维数组逆时针旋转 90 度 = 右上到左下的对角线进行镜像对称 + 矩阵的每一行进行反转
矩阵的螺旋遍历
概念类
-
深度优先遍历(DFS)
Depth First Search, 实际上不管是前序遍历,还是中序遍历,亦或是后序遍历,都属于深度优先遍历。 -
广度优先遍历(BFS)
也叫层序遍历,指的是从图的一个未遍历的节点出发,先遍历这个节点的相邻节点,再依次遍历每个相邻节点的相邻节点。 -
树:图的特例,连通无环的图就是树
-
稳定排序:对于序列中的相同元素,如果排序之后他们的相对位置没有发生改变,则称该排序算法为稳定排序。
-
不稳定排序