哈希函数与哈希表
哈希函数
基本概念:
- 输⼊域是趋于⽆穷的,输出域是有界的(S域)
- 确定性:⼀个哈希函数对于相同的输入,⼀定返回相同的结果(说明哈希函数内部没有随机过程)
- 哈希碰撞:由于输出域有限⽽输⼊域⽆穷,不同的输⼊有可能产⽣相同的结果(但概率极⼩)
- 均匀性:输出域任⼀等⾯积区域内,样本的个数几乎相同==(同时做到离散性和均匀性)==!!!特别重要!!! ⼀个输出均匀分布的哈希函数,对输出进⾏%m取余运算,则结果在0~m-1上也均匀分布
常见应用:
- MDS算法
- SHa1算法
哈希表的实现:
- 基本逻辑:哈希函数计算⼀个哈希值,哈希值进行
%m
,同⼀位置放⼊⼀个单向链表,⼀个结点放⼀个数和出现次数, 当链表长度超过设定阈值时,扩容(如m翻倍) N个数放⼊哈希表中时,最多扩容O(logN)
次,每次代价为O(N)
,平摊到每个数上,⼀个数放⼊的时间复杂度为O(logN)
40亿整数中出现最多的
题⽬:40亿(0~2^32 -1)
个⽆符号整数,只给1G空间,要求给出出现次数最多的数
传统⽅法⽤HashMap来解题,但这⾥会超过1G,不可取
思路:⽤哈希函数得到⼀个映射,再对映射结果%100
,则分成100个⼩⽂件,相同的数⼀定在同⼀个⽂件中,每个⽂件中都有⼀个数出现次数最多,接下来再⽤哈希表统计每个⽂件⾥数据的词频,再各⽂件词频最⾼的作⽐较,看哪个数据个数最多
RandomPool结构
题⽬:设计⼀种结构,在该结构中有如下三个功能:
insert(key)
:将某个key加⼊到该结构,做到不重复加⼊
delete(key)
:将原本在结构中的某个key移除
getRandom()
: 等概率随机返回结构中的任何⼀个key。
要求:Insert、delete和getRandom⽅法的时间复杂度都是O(1)
思路:连续区间
public class RandomPool<K> {
private HashMap<K, Integer> keyIndexMap;
private HashMap<Integer, K> indexKeyMap;
private int size;
public RandomPool() {
keyIndexMap = new HashMap<>();
indexKeyMap = new HashMap<>();
size = 0;
}
public void insert(K key) {
if (!keyIndexMap.containsKey(key)) {
keyIndexMap.put(key, size);
indexKeyMap.put(size++, key);
}
}
public void delete(K key) {
if (keyIndexMap.containsKey(key)) {
Integer deleteIndex = keyIndexMap.get(key);
int lastIndex = --size;
K lastKey = indexKeyMap.get(lastIndex);
keyIndexMap.put(lastKey, deleteIndex);
indexKeyMap.put(deleteIndex, lastKey);
keyIndexMap.remove(key);
indexKeyMap.remove(lastIndex);
}
}
public K getRandom() {
if (this.size == 0) {
return null;
}
// 0 ~ size -1
int randomIndex = (int) (Math.random() * this.size);
return this.indexKeyMap.get(randomIndex);
}
}
布隆过滤器
-
去重、构造⿊名单之类的事情
-
没有删除⾏为,只要加⼊和查询两个⾏为,使⽤空间少
-
允许⼀定失误率:把不在容器⾥的当成在容器⾥的了
位图
如果用Hash表存记录会需要很大的存储空间,例如一个URL是64B,存100亿个就需要640G内存,由此引出位图,注意使用位图实现布隆过滤器失误不可避免
⼀个数组,每个位置只有⼀个bit
,相当于int的类型的1/32
-
⽤两个下标来找⼀个地⽅:⽐如要找第178位
-
去
int[]arr
⾥的第numIndex
个数字上找:numIndex = 178 / 32; -
去这个数字的第
bitIndex
位上找:bitIndex = 178 % 32;
-
-
如:第178位: 查:int s = ((arr[numIndex] >> (bitIndex))& 1);
-
查:int s = ((arr[ i / 32 ] >> ( i % 32 ))& 1);
-
改成1:arr[numIndex] = arr[numIndex] | (1 << (bitIndex))
-
改成0:arr[numIndex] = arr[numIndex] & (~(1 << (bitIndex)))
-
public class BitMap {
public static void main(String[] args) {
//32bit * 10 = 320 bit
//arr[0] int 0 ~ 31
//arr[1] int 32 ~ 63
int[] arr = new int[10];
//要找第178位
int i = 178;
int numIndex = 178 / 32;
int bitIndex = 178 % 32;
//拿到178位状态
int s = (arr[numIndex] >> bitIndex) & 1;
//178位改成1
arr[numIndex] = arr[numIndex] | (1 << bitIndex);
//178位改成0
arr[numIndex] = arr[numIndex] & (~(1 << bitIndex));
}
}
布隆过滤器实现
建⽴⼀个位图:bit[m]
加⼊数据:
将每个数据放⼊ k 个哈希函数,得到 k 个哈希值每个哈希值都 %m,得到的结果对应的 bit[] 位置置为1
查找数据在不在⾥⾯:
将⼀个变量放⼊ k 个哈希函数,得到 k 个哈希值,每个哈希值都 %m, 得到的结果和 bit[] 对应位置⽐较,如果全是1,那就是在⿊名单⾥
随着m的增加,失误率下降,对于确定的m,存在唯⼀的k使P最⼩
设计布隆过滤器:
条件:样本量N、失误率P
要求出的结果:m和k
⼀致性哈希原理
多服务器数据组织⽅法
分布式数据库,关于底层数据服务器怎么组织
⼀个数据 ----哈希函数----> 哈希值 ----%m----> a,放到m个服务器中的第a个⾥
⾼频、中频、低频数据如果都很多的话,平均分配在m个服务器上
哈希key的选择:⼀定要选择种类⽐较多,让⾼频、中频、低频的数负载均衡起来
哈希环技术
⽤上述⽅法,当机器数⽬从m变成了p个,那么数据迁移的代价是全量的
解决⽅法:
-
将 S 域变成⼀个圈(0和最⼤值⾸尾相连),m个服务器所对应的哈希值在圈上均匀分布
-
每当放⼊⼀个新的数字时,计算哈希值,在圈上找顺时针离得最近的数字
增加、减少⼀个服务器的时候,数据迁移的代价很少
⼀些问题:
-
机器较少时,均分较难 即便均分,
-
在增减机器时,也会破坏均匀性
虚拟节点技术
⽤来解决上述的两个问题:
每个机器1000个虚拟结点,计算哈希值放在哈希环上
-
环上任意⼀端等长度的区间⾥,可以认为属于各机器的结点数⽬相同
-
由此可以解决均分问题,以及增减问题
同时,可以根据机器性能分配不⼀样多的结点个数
并查集
岛问题
⼀个问题引出并查集
题⽬:⼀个矩阵中只有0和1两种值,每个位置都可以和⾃⼰的上、下、左、右 四个位置相连,如果有⼀⽚1连在⼀起,这个部分叫做⼀个岛,求⼀个矩阵中有多少个岛?
举例:输⼊:
0 0 1 0 1 0
1 1 1 0 1 0
1 0 0 1 0 0
0 0 0 0 0 0
返回:有3个岛
思路:infect – 从⼀个1出发,将相连的所有1都变成2
public class Lands {
@Test
public void test() {
System.out.println(countIslands(new int[][]{{0, 0, 1, 0, 1, 0}, {1, 1, 1, 0, 1, 0}, {1, 0, 0, 1, 0, 0}, {0, 0, 0, 0, 0, 0}}));
}
public int countIslands(int[][] m) {
if (m == null || m[0] == null) {
return 0;
}
int res = 0;
for (int i = 0; i < m.length; i++) {
for (int j = 0; j < m[0].length; j++) {
if (m[i][j] == 1) {
res++;
// 对这个岛进⾏感染
infect(m, i, j);
}
}
}
return res;
}
private void infect(int[][] m, int i, int j) {
if (i > m.length || i < 0 || j > m[0].length || j < 0 || m[i][j] != 1) {
return;
}
// 把这个位置上的数字改成 2
m[i][j] = 2;
// 这个位置下侧进⾏感染
infect(m, i + 1, j);
// 这个位置右侧进⾏感染
infect(m, i, j + 1);
// 这个位置上侧进⾏感染
infect(m, i - 1, j);
// 这个位置左侧进⾏感染
infect(m, i, j - 1);
}
}
时间复杂度:O(N×M)
如何并⾏算法来解决这个问题,如何⽤多核系统解决这个问题? – 并查集
并查集实现
成员:⼀个集合,存放⼀个或多个数据
查询操作:查询两个数据是否属于统⼀集合
合并操作:把a所在的集合和b所在的集合合并
要求两个操作都是O(1) ,传统的如链表拼接,Hash存集合都有一个操作不符合O(1)
实现⽅法:
-
⽤向上指的图查询时,两个元素都往上指,指到不能再指,若此时俩⼀样就是⼀个集合,否则不是
-
合并时,把数量⼩的顶指向数量多的顶
public class UnionFind {
class Element<V> {
public V value;
public Element(V value) {
this.value = value;
}
}
class UnionFindSet<V> {
// 值到结点的映射
public Map<V, Element<V>> elementMap;
// key - 元素;value - 该元素的⽗
public Map<Element<V>, Element<V>> fatherMap;
// key - 某集合代表元素 value - 集合⼤⼩
public Map<Element<V>, Integer> rankMap;
public UnionFindSet(List<V> list) {
// 初始化,⼀个列表⾥,每个元素都单独⼀个集合
elementMap = new HashMap<>();
fatherMap = new HashMap<>();
rankMap = new HashMap<>();
for (V value : list) {
elementMap.put(value, new Element<>(value));
Element<V> element = elementMap.get(value);
fatherMap.put(element, element);
rankMap.put(element, 1);
}
}
private Element<V> findHead(Element<V> element) {
Stack<Element<V>> stack = new Stack<>();
while (element != fatherMap.get(element)) {
stack.push(element);
element = fatherMap.get(element);
}
while (!stack.isEmpty()) {
// 把路径的⽗亲都改成“头”
fatherMap.put(stack.pop(), element);
}
return element;
}
public boolean isSameSet(V a, V b) {
if (elementMap.containsKey(a) && elementMap.containsKey(b)) {
Element<V> aEle = elementMap.get(a);
Element<V> bEle = elementMap.get(b);
return findHead(aEle) == findHead(bEle);
}
return false;
}
public void union(V a, V b) {
if (elementMap.containsKey(a) && elementMap.containsKey(b)) {
Element<V> aHead = findHead(elementMap.get(a));
Element<V> bHead = findHead(elementMap.get(b));
// 哪个集合⼤
Element<V> big = rankMap.get(aHead) > rankMap.get(bHead) ? aHead : bHead;
// 哪个集合⼩
Element<V> small = big == aHead ? bHead : aHead;
fatherMap.put(small, big);
// 修改big的⼤⼩
rankMap.put(big, rankMap.get(big) + rankMap.get(small));
// 删掉small结点
rankMap.remove(small);
}
}
}
}
岛问题可以分为多个模块用多个CPU并行处理,处理完成后用并查集判断边界
KMP算法
问题:字符串str1和str2,str1是否包含str2,如果包含返回str2在str1中开始的位置。
如何做到时间复杂度==O(N)==完成?
暴⼒⽅法:O(N×M)
KMP基本概念
⼀个str2 = abbabbk,k下⾯要对应⼀个数,这个数来⾃abbabb
前缀和后缀相等的最⼤长度:如abbabb = 3
1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|
a | ab | abb | abba | abbab |
b | bb | abb | babb | bbabb |
next数组
arr = [ a, a, b, a, a, b, s, a, a, b, a, a, b, t]
next = [-1, 0, 1, 0, 1, 2, 3, 0, 1, 2, 3, 4, 5, 6]
第⼀位填-1,第⼆位填0
后⾯每个位置前⾯【所有字符形成的字串】的【前缀和后缀相等的最⼤长度】填在next数组的这个位置上
具体过程
情况:
当 str2 的第 0 位来到了 str1 第 i 位
且 str2 的第 0~p-1位都匹配上了,但第 p 位和 str1的第 i+p 位匹配不上
操作:
此时看 next 数组的第 p 位的值为 m,
将 str2 的第 0 位移动到 str1 第 i+p-m 位
⽐较 str2 的第 m+1 位是不是等于str1 的第 i+p 位
举例说明:
如:abbstkscabbstks
和:abbstkscabbstkz
此时,str2 的 0 位置来到了 str1 的第 i 位,它们的末尾不⼀样,但 next 数组在 z 位置处的值为 6
表⽰,str2 的前 6 个字母 abbstk 和 str1的 s 前的最后 6 个字母 abbstk 是匹配的
故直接检查 str2 第 7 个字母是不是等于 s ,即 str2 开头的 a 直接和 str1 第 i+8 位的 a 对齐
public class KMP {
@Test
public void test() {
String str1 = "shfjhhjhhjdfgsdgdsgsdgdsgsdsaknnjslnnal";
String str2 = "knnjs";
System.out.println(getIndexOf(str1, str2));
}
/**
* @param s 长串
* @param m 短串
* @return
*/
public int getIndexOf(String s, String m) {
if (s == null || m == null || m.length() < 1 || s.length() < m.length()) {
return -1;
}
// 两个字符串都变成-字符数组
char[] str1 = s.toCharArray();
char[] str2 = m.toCharArray();
// 得到m串的next数组
int[] next = getNextArray(str2);
int i1 = 0;
int i2 = 0;
while (i1 < str1.length && i2 < str2.length) {
// 对应位相等则⽐较下⼀位
if (str1[i1] == str2[i2]) {
i1++;
i2++;
// 这两位不同 且 str2还在第1位,那就str2向后⾛
} else if (next[i2] == -1) {
i1++;
// 这两位不同 且 str2不在第⼀位,那i2移动到next[i2]位
} else {
i2 = next[i2];
}
}
return i2 == str2.length ? i1 - i2 : -1;
}
/**
* 得到ms串的next数组
*
* @param ms
* @return
*/
public int[] getNextArray(char[] ms) {
if (ms.length == 1) {
return new int[]{-1};
}
int[] next = new int[ms.length];
next[0] = -1;
next[1] = 0;
// 从第三位开始
int i = 2;
int cn = 0;
while (i < next.length) {
// 这⼀步结束以后,i移到了i+1,cn的值为next[i],每⼀步都假设前⼀步是对的
if (ms[i - 1] == ms[cn]) {
next[i++] = ++cn;
} else if (cn > 0) {
cn = next[cn];
}else {
next[i++] = 0;
}
}
return next;
}
}
0位置是-1,1位置是0
如果0位置和1位置相等,那1位置就是1,否则是0
第i位的借助第i-1位的值:
- 如果str[ i - 1 ] == str2[ next[ i - 1 ] ],那next[ i ] = next[ i - 1 ] + 1,不可能更长
- 如果不相等,则⽐较str[ i - 1] == str[ next[ next[ i - 1 ] ] ],只要相等,那next[ i ]就等于右边⽅括号⾥的值+1
- 如果已经到了0位置还不相等,那 next[ i ] = 0
Manacher算法
⽬标问题:
- 字符串str中,最长回⽂⼦串的长度如何求解?
- 如何做到时间复杂度O(N)完成?
如:abc1232ode1,其最⼤回⽂⼦串为232,长度为3
暴⼒解:遍历,以⼀个数为中⼼向两边扩。
⼀⽅⾯时间复杂度⾼O(N ),另⼀⽅⾯,偶数长度的回⽂字符串会被跳过
在了两个字符之间插⼊#,如 #1#2#2#1…
基本概念
回⽂直径
回⽂半径
回⽂半径数组
之前所扩的所有位置中 所到达的最右回⽂右边界 R,对称点 L
取得这个边界的时候,中⼼点在哪 C
C是伴随R改变⽽改变的,如果R不变,C也不变
具体过程
-
当前点不在回⽂右边界⾥⾯,暴⼒阔
-
当前点在回⽂右边界⾥,即 L < i’ < C < i < R
- i’ 的回⽂区域在[L,R]中 => i 的回⽂区域也在[L,R]中,长度与 i’ 相同
- i’ 的回⽂有⼀部分在区域在[L,R]外 => i 的半径只有 i 到 R
- i’ 的回⽂左边界正好是L,i 的半径有可能更⼤,从 R 外的第⼀个字符暴⼒扩,得到 i 的半径
为了优化代码,就不区分需不需要往右扩的情况了,不需要往右扩的最多判断多一次,不会影响时间复杂度
public class Manacher {
@Test
public void test() {
System.out.println(maxLcpsLength("asfxxxbasbsdssabsb"));
}
/**
* 返回最大回文长度
*
* @param str
* @return
*/
public int maxLcpsLength(String str) {
if (str == null || str.length() == 0) {
return 0;
}
char[] chars = manacherString(str);
//各点回文长度数组
int[] pArr = new int[chars.length];
//中点位置
int c = -1;
//右边界的下⼀个位置
int r = -1;
//返回值
int max = Integer.MIN_VALUE;
for (int i = 0; i < chars.length; i++) {
pArr[i] = r > i ? Math.min(r - i, c * 2 - i) : 1;
while (i + pArr[i] < chars.length && i - pArr[i] > -1) {
// 左边界往左⼀个和右边界向右加⼀个⽐较
if (chars[i + pArr[i]] == chars[i - pArr[i]]) {
// 相等就扩1
pArr[i]++;
}else {
// 不相等就进入下一个i判断
break;
}
}
if (i + pArr[i] > r) {
r = i + pArr[i];
c = i;
}
max = Math.max(max, pArr[i]);
}
return max;
}
/**
* 在字符串每个字符中间插⼊ #
*
* @param str
* @return
*/
public char[] manacherString(String str) {
char[] chars = str.toCharArray();
char[] res = new char[chars.length * 2 + 1];
int index = 0;
for (int i = 0; i < res.length; i++) {
res[i] = (i & 1) == 0 ? '#' : chars[index++];
}
return res;
}
}
滑动窗⼝和单调栈
滑动窗⼝
由⼀道题引出滑动窗⼝⽅法
题⽬:有⼀个整型数组arr和⼀个⼤⼩为w的窗⼝从数组的最左边滑到最右边,窗⼝每次向右边滑⼀个位置。
例如,数组为[4,3,5,4,3,3,6,7],窗⼝⼤⼩为3时:
[4 3 5] 4 3 3 6 7
4 [3 5 4] 3 3 6 7
4 3 [5 4 3] 3 6 7
4 3 5 [4 3 3] 6 7
4 3 5 4 [3 3 6] 7
4 3 5 4 3 [3 6 7]
如果数组长度为n,窗⼝⼤⼩为w,则⼀共产⽣n-w+1个窗⼝的最⼤值。
请实现⼀个函数。 输⼊:整型数组arr,窗⼝⼤⼩为w。
输出:⼀个长度为n-w+1的数组res,res[i]表⽰每⼀种窗⼝状态下的 以本题为例,结果应该返回{5,5,5,4,6,7}。
public class WindowMax {
@Test
public void test() {
System.out.println(Arrays.toString(getMaxWindow(new int[]{4, 3, 5, 4, 3, 3, 6, 7}, 3)));
}
/**
*窗⼝最⼤值题解
* @param arr
* @param w
* @return
*/
public int[] getMaxWindow(int[] arr, int w) {
if (arr == null || arr.length < w) {
return null;
}
LinkedList<Integer> qMax = new LinkedList<>();
int[] res = new int[arr.length - w + 1];
int index = 0;
for (int i = 0; i < arr.length; i++) {
// 右端扩充
while (!qMax.isEmpty() && arr[qMax.peekLast()] <= arr[i]) {
qMax.pollLast();
}
qMax.addLast(i);
// 最⼤值如果是左端第⼀个,那弹出
if (qMax.peekFirst() == i - w) {
qMax.pollFirst();
}
if (i >= w - 1) {
res[index++] = arr[qMax.peekFirst()];
}
}
return res;
}
}
滑动窗⼝基本结构
这⾥以最⼤值滑动窗⼝为例
-
窗⼝的左边界和右边界最开始都在最左边
-
左边界和右边界都只能往右⾛
-
左边界不能超过右边界
使⽤双端队列 – 两边都可以进出
在双端队列⾥放下标
确保⾥⾯的下标对应的数据是从⼤到⼩的
-
头部值最⼤
-
只能从尾部进⼊
-
如果尾部进⼊不能保证头->尾是单调递减,那就依次弹出,直到满⾜单调递减
-
弹出的数据不找回
-
L 往右动时,如果双端队列头部是 L 的前⼀个,那把这个数据从头部弹出,
- 不是就不⽤管
实质:双端队列⾥⾯维护的信息是:如果 R 不动,L 动的情况下,谁会依次成为最⼤值,这个优先级信息
每个位置的数,最多进队列⼀次,出队列⼀次
public class WindowMax {
private int L;
private int R;
private int[] arr;
private LinkedList<Integer> queue;
public WindowMax(int[] arr) {
L = -1;
R = 0;
this.arr = arr;
queue = new LinkedList<>();
}
public void addNumFromRight() {
if (R == arr.length) {
return;
}
while (!queue.isEmpty() && arr[queue.pollLast()] <= arr[R]) {
queue.pollLast();
}
queue.addLast(R);
R++;
}
public void removeNumFromLeft() {
if (L >= R - 1) {
return;
}
L++;
//有对应下标的值才需要弹出,对应下标可以在addNumFromRight时已经弹出了
if (queue.peekFirst() == L) {
queue.pollLast();
}
}
public Integer getMax() {
if (!queue.isEmpty()) {
return arr[queue.getFirst()];
}
return null;
}
}
单调栈
要解决的问题:
在数组中想找到⼀个数,左边和右边⽐这个数⼤、且离这个数最近的位置。
如果对每⼀个数都想求这样的信息,能不能整体代价达到O(N)?需要使⽤到单调栈结构
基本结构和流程
建⽴⼀个栈,放的是数据的下标,从底到顶数据从⼤到⼩
流程:
-
挨个往⾥放,确保下⾯的更⼤
-
如果进来的这个数,让栈不能继续是下⼤上⼩,那弹出⼀个数
-
弹出这个数左边较⼤的就是它下⾯的数
-
弹出这个数右边较⼤的就是让它弹出的那个数(刚刚要进来但是太⼤)
-
-
最后是清算阶段(栈⾥有东西):
-
依次弹出⾥⾯的数,它们都没有右侧较⼤的数
-
弹出这个数左边最⼤的就是它下⾯的数
-
每个数据只进栈⼀次,出栈⼀次以上是⽆重复值的情况,若有重复值,则:
- 将栈内存放的数据类型改成链表,相同⼤⼩的值压在⼀起
- 弹出⼀个数时,左边最近的较⼤值为:下⽅链表的尾端值
两侧最近的较⼩数
在数组中想找到⼀个数,左边和右边⽐这个数⼩、且离这个数最近的位置。
如果对每⼀个数都想求这样的信息,能不能整体代价达到O(N)?需要使⽤到单调栈结构
分有重复数字和⽆重复数字的情况
⽆重复数字:
public class MonotonousStack {
@Test
public void test() {
int[][] nearLessNoRepeat = getNearLessNoRepeat(new int[]{8, 9, 23, 4, 3, 58, 30, 5});
for (int[] ints : nearLessNoRepeat) {
System.out.println(Arrays.toString(ints));
}
}
public int[][] getNearLessNoRepeat(int[] arr) {
int[][] res = new int[arr.length][2];
Stack<Integer> stack = new Stack<>();
for (int i = 0; i < arr.length; i++) {
while (!stack.isEmpty() && arr[stack.peek()] > arr[i]) {
Integer pop = stack.pop();
int leftLessIndex = stack.isEmpty() ? -1 : stack.peek();
res[pop][0] = leftLessIndex;
res[pop][1] = i;
}
stack.push(i);
}
// 清算阶段
while (!stack.isEmpty()) {
Integer pop = stack.pop();
int leftLessIndex = stack.isEmpty() ? -1 : stack.peek();
res[pop][0] = leftLessIndex;
res[pop][1] = -1;
}
return res;
}
}
有重复数字:
public class MonotonousStack {
public int[][] getNearLess(int[] arr) {
int[][] res = new int[arr.length][2];
Stack<List<Integer>> stack = new Stack<>();
for (int i = 0; i < arr.length; i++) {
while (!stack.isEmpty() && arr[stack.peek().get(0)] > arr[i]) {
List<Integer> pop = stack.pop();
// 取位于下⾯位置的列表中,最晚加⼊的那个
int leftLessIndex = stack.isEmpty() ? -1 : stack.peek().get(stack.peek().size() - 1);
for (Integer integer : pop) {
res[integer][0] = leftLessIndex;
res[integer][1] = i;
}
}
if (!stack.isEmpty() && stack.peek().get(0) == arr[i]) {
stack.peek().add(i);
} else {
ArrayList<Integer> list = new ArrayList<>();
list.add(i);
stack.push(list);
}
}
// 清算阶段
while (!stack.isEmpty()) {
List<Integer> pop = stack.pop();
int leftLessIndex = stack.isEmpty() ? -1 : stack.peek().get(stack.peek().size() - 1);
for (Integer integer : pop) {
res[integer][0] = leftLessIndex;
res[integer][1] = -1;
}
}
return res;
}
}
(累计和×最⼩值)最⼤的⼦数组
题⽬:定义:数组中累积和与最⼩值的乘积,假设叫做指标A。
给定⼀个正数数组,请返回⼦数组中,指标A最⼤的值。
暴力解,时间复杂度O(N^2)
public class MaxProduct {
public static int max1(int[] arr) { // 暴⼒解
int max = Integer.MIN_VALUE;
// 对左区间遍历
for (int i = 0; i < arr.length; i++) {
// 对右区间遍历
for (int j = i; j < arr.length; j++) {
int minNum = Integer.MAX_VALUE;
int sum = 0;
for (int k = i; k <= j; k++) {
// 区间数字累加求和
sum += arr[k];
// 区间最⼩值
minNum = Math.min(minNum, arr[k]);
}
// 更新最⼤的(min*sum)
max = Math.max(max, minNum * sum);
}
}
return max;
}
}
单调栈:
public class MaxProduct {
@Test
public void test() {
System.out.println(max2(new int[]{4, 9}));
}
/**
* 单调栈解法
*
* @param arr
* @return
*/
public int max2(int[] arr) {
// sums[i]表⽰arr[0]~arr[i]的和
int[] sum = new int[arr.length];
sum[0] = arr[0];
for (int i = 1; i < sum.length; i++) {
sum[i] = sum[i - 1] + arr[i];
}
Stack<Integer> stack = new Stack<>();
int max = Integer.MIN_VALUE;
for (int i = 0; i < sum.length; i++) {
// 栈不空 且 栈顶对应的数⽐arr[i]⼤
while (!stack.isEmpty() && arr[stack.peek()] >= arr[i]) {
Integer pop = stack.pop();
// sum[i - 1] - sum[stack.peek()] 表⽰找到左边第⼀个⽐a[pop]⼤的
int value = stack.isEmpty() ? sum[i - 1] : (sum[i - 1] - sum[stack.peek()]);
max = Math.max(max, value * arr[pop]);
}
// 栈⾥要从底到顶依次增⼤ , 若进来⼀个⽐顶部⼩的, 要挨个弹出,直到进来这个⽐顶部⼤
stack.push(i);
}
while (!stack.isEmpty()) {
Integer pop = stack.pop();
int value = stack.isEmpty() ? sum[sum.length - 1] : (sum[sum.length - 1] - sum[stack.peek()]);
max = Math.max(max, value * arr[pop]);
}
return max;
}
}
树形DP和Morris遍历
树形动态规划**(DP)**
前提:如果题⽬求解⽬标是S规则,则求解流程可以定成:
以每⼀个节点为头节点的⼦树在S规则下的每⼀个答案,并且最终答案⼀定在其中
树形dp套路:
-
以某个节点X为头节点的⼦树中,分析答案有哪些可能性,
- 并且这种分析是以X的左⼦树、X的右⼦树和X整棵树的⾓度来考虑可能性的
-
根据第⼀步的可能性分析,列出所有需要的信息
-
合并第⼆步的信息,对左树和右树提出同样的要求,并写出信息结构
-
设计递归函数,递归函数是处理以X为头节点的情况下的答案。
- 包括设计递归的base case,默认直接得到左树和右树的所有信息,以及把可能性做整合,并且要返回第三步的信息结
构这四个⼩步骤
难点:情况分类
⼆叉树结点间的最⼤距离问题
题⽬:从⼆叉树的节点a出发,可以向上或者向下⾛,但沿途的节点只能经过⼀次,到达节点b时路径上的节点个数叫作a到b的距离
那么⼆叉树任何两个节点之间都有距离,求整棵树上的最⼤距离。
分析:最⼤距离有两种情况:
-
头节点不参与。那最⼤距离 = max(左树最⼤距离,右树最⼤距离)
-
头节点参与。那最⼤距离 = 左树⾼ + 右树⾼ + 1
故最⼤距离 = max(左树最⼤距离,右树最⼤距离,左树⾼ + 右树⾼ + 1)
public class MaxDistance {
class Node {
int value;
Node left;
Node right;
Node(int val) {
value = val;
}
}
class Info {
public int maxDistance;
public int height;
public Info(int maxDistance, int height) {
this.maxDistance = maxDistance;
this.height = height;
}
}
public int maxDistance(Node head) {
return process(head).maxDistance;
}
private Info process(Node head) {
if (head == null) {
return new Info(0, 0);
}
Info left = process(head.left);
Info right = process(head.right);
// 头节点参与
int includeHeadDistance = left.height + right.height + 1;
// 三者中最⼤的就是head结点的最⼤距离
int maxDistance = Math.max(Math.max(left.maxDistance, right.maxDistance), includeHeadDistance);
return new Info(maxDistance, Math.max(left.height, right.height) + 1);
}
}
派对的最⼤快乐值
员⼯信息的定义如下:
class Employee {
// 这名员⼯可以带来的快乐值
public int happy;
// 这名员⼯有哪些直接下级
List<Employee> subordinates;
}
公司的每个员⼯都符合 Employee 类的描述。
-
整个公司的⼈员结构可以看作是⼀棵标准的、 没有环的多叉树。
-
树的头节点是公司唯⼀的⽼板。
-
除⽼板之外的每个员⼯都有唯⼀的直接上级。
-
叶节点是没有任何下属的基层员⼯(subordinates列表为空),除基层员⼯外,每个员⼯都有⼀个或多个直接下级。
这个公司现在要办party,你可以决定哪些员⼯来,哪些员⼯不来。但是要遵循如下规则:
-
如果某个员⼯来了,那么这个员⼯的所有直接下级都不能来
-
派对的整体快乐值是所有到场员⼯快乐值的累加
-
你的⽬标是让派对的整体快乐值尽量⼤
给定⼀棵多叉树的头节点boss,请返回派对的最⼤快乐值。
分析:头节点 x 参与还是不参与
x 参与:X.happy + sum(x某个下级在⾃⼰不来时的整体快乐值)
x 不参与:sum(max(x某个下级⾃⼰来时的整体快乐值,x某个下级在⾃⼰不来时的整体快乐值))
public class MaxHappy {
@Test
public void test() {
ArrayList<Employee> list = new ArrayList<>();
list.add(new Employee(100,new ArrayList<>()));
list.add(new Employee(90,new ArrayList<>()));
Employee boss = new Employee(330,list);
System.out.println(maxHappy(boss));
}
class Employee {
// 这名员⼯可以带来的快乐值
public int happy;
// 这名员⼯有哪些直接下级
List<Employee> subordinates;
public Employee(int happy, List<Employee> subordinates) {
this.happy = happy;
this.subordinates = subordinates;
}
}
class Info {
public int presentHappy;
public int absentHappy;
public Info(int presentHappy, int absentHappy) {
this.presentHappy = presentHappy;
this.absentHappy = absentHappy;
}
}
public int maxHappy(Employee x) {
Info info = process(x);
return Math.max(info.presentHappy, info.absentHappy);
}
private Info process(Employee x) {
if (x.subordinates.isEmpty()) {
// base case : x是基层员⼯
return new Info(x.happy, 0);
}
// x来得到的收益
int present = x.happy;
// x不来得到的收益
int absent = 0;
for (Employee subordinate : x.subordinates) {
Info nextInfo = process(subordinate);
// x来得到的收益 + 直接员⼯不来的带的收益
present += nextInfo.absentHappy;
// x不来 + 直接员⼯来或不来的最⼤收益
absent += Math.max(nextInfo.presentHappy, nextInfo.absentHappy);
}
return new Info(present,absent);
}
}
Morris遍历
Morris遍历,是⼀种遍历⼆叉树的⽅式,并且时间复杂度O(N),额外空间复杂度O(1)
通过利⽤原树中⼤量空闲指针的⽅式,达到节省空间的⽬的
笔试⾥⾯不要⽤
遍历细节
假设来到当前节点cur,开始时cur来到头节点位置
-
如果 cur 没有左孩⼦,cur 向右移动 (cur = cur.right)
-
如果 cur 有左孩⼦,找到左⼦树上最右的节点 mostRight:
-
如果 mostRight 的右指针指向空,让其指向 cur, 然后 cur 向左移动 (cur = cur.left)
-
如果 mostRight 的右指针指向 cur,让其指向 null, 然后cur向右移动 (cur = cur.right)
-
-
cur 为空时遍历停⽌
特点:
有左树的结点可以到达两次,没有左树的结点可以到达1次
第⼀次到达的时候,左树的最右结点指向空
第⼆次到达的时候,左树的最右结点指向⾃⼰
遍历顺序:1,2,4,2,5,1,3,6,3,7
每个结点遍历左⼦树右边界的时候,路过的点都是不重复的,时间复杂度还是O(N)
public class Morris {
class Node {
int value;
Node left;
Node right;
Node(int val) {
value = val;
}
}
public void process(Node head) {
if (head == null) {
return;
}
Node cur = head;
Node mostRight = null;
while (cur != null) {
// mostRight访问cur的左孩⼦
mostRight = cur.left;
// 说明cur有左孩⼦
if (mostRight != null) {
while (mostRight.right != null && mostRight.right != cur) {
// mostRight向右⾛⼀步
mostRight = mostRight.right;
}
// mostRight来到了cur左⼦树的最右处
// 第⼀次来到cur
if (mostRight.right == null) {
mostRight.right = cur;
cur = cur.left;
continue;
}else {
// mostRight右孩⼦是cur , 第⼆次来到cur
mostRight.right = null;
}
}
cur = cur.right;
}
}
}
先序遍历
如果⼀个结点,只到它达⼀次,直接打印
如果可以到达它两次,第⼀次到达时打印
public void morrisPre(Node head) {
if (head == null) {
return;
}
Node cur = head;
Node mostRight = null;
while (cur != null) {
mostRight = cur.left;
if (mostRight != null) {
while (mostRight.right != null && mostRight.right != cur) {
mostRight = mostRight.right;
}
// cur第⼀次来到这个结点
if (mostRight.right == null) {
mostRight.right = cur;
// 这个点有左⼦树,可以到达两次,这是第⼀次到达,所以直接打印
System.out.println(cur.value + "");
cur = cur.left;
continue;
} else {
mostRight.right = null;
}
} else {
// 这个点没有左⼦树,所以只能到达⼀次,直接打印
System.out.println(cur.value + "");
}
cur = cur.right;
}
}
中序遍历
如果⼀个结点,只到它达⼀次,直接打印
如果可以到达它两次,第⼆次到达时打印
public void morrisIn(Node head) {
if (head == null) {
return;
}
Node cur = head;
Node mostRight = null;
while (cur != null) {
mostRight = cur.left;
if (mostRight != null) {
while (mostRight.right != null && mostRight.right != cur) {
mostRight = mostRight.right;
}
// cur第⼀次来到这个结点
if (mostRight.right == null) {
mostRight.right = cur;
cur = cur.left;
continue;
} else {
mostRight.right = null;
}
}
System.out.println(cur.value + "");
cur = cur.right;
}
}
后续遍历
较难
如果⼀个结点,只到它达⼀次,不操作
如果可以到达它两次,第⼆次到达时,逆序打印这个结点左树的右边界最后再逆序打印整树的右边界
public void morrisPost(Node head) {
if (head == null) {
return;
}
Node cur = head;
Node mostRight = null;
while (cur != null) {
mostRight = cur.left;
if (mostRight != null) {
while (mostRight.right != null && mostRight.right != cur) {
mostRight = mostRight.right;
}
// cur第⼀次来到这个结点
if (mostRight.right == null) {
mostRight.right = cur;
cur = cur.left;
continue;
} else {// 第⼆次到达这个点
mostRight.right = null;
// 逆序打印这个结点左树的右边界
printEdge(cur.left);
}
}
cur = cur.right;
}
// 逆序打印整树的右边界
printEdge(head);
}
/**
* 逆序打印
* @param head
*/
private void printEdge(Node head) {
Node tail = reverseEdge(head);
Node cur = tail;
while (cur != null) {
System.out.println(cur.value + "");
cur = cur.right;
}
reverseEdge(tail);
}
/**
* 翻转链表
* @param head
* @return
*/
private Node reverseEdge(Node head) {
Node cur = head;
Node pre = null;
Node next = null;
while (cur != null) {
next = cur.right;
cur.right = pre;
pre = cur;
cur = next;
}
return pre;
}
判断搜索⼆叉树
搜索⼆叉树中序遍历是递增的
public boolean isBST5(Node head) {
if (head == null) {
return true;
}
Node cur = head;
Node mostRight = null;
int preValue = Integer.MIN_VALUE;
while (cur != null) {
// mostRight访问cur的左孩⼦
mostRight = cur.left;
// 说明cur有左孩⼦
if (mostRight != null) {
while (mostRight.right != null && mostRight.right != cur) {
// mostRight向右⾛⼀步
mostRight = mostRight.right;
}
// mostRight来到了cur左⼦树的最右处
// 第⼀次来到cur
if (mostRight.right == null) {
mostRight.right = cur;
cur = cur.left;
continue;
} else {
// mostRight右孩⼦是cur , 第⼆次来到cur
mostRight.right = null;
}
}
// 要么是跳过了if,说明没有左树,直接打印
// 要么是从else⾥刚出来,说明第⼆次来到这个结点,打印
if (cur.value <= preValue) {
return false;
}
// 说明到这还是搜索⼆叉树,更新preValue
preValue = cur.value;
cur = cur.right;
}
return true;
}
这两种⽅法的选择
第三次⽅法的强整合:⽤树形DP套路
否则使⽤Morris遍历
⼤数据
⼤数据问题 或者 资源限制问题
基本技巧
-
哈希函数:哈希函数可以把数据按照种类均匀分流
-
布隆过滤器:布隆过滤器⽤于集合的建⽴与查询,并可以节省⼤量空间
-
⼀致性哈希:⼀致性哈希解决数据服务器的负载管理问题
-
并查集:利⽤并查集结构做岛问题的并⾏计算
-
位图:位图解决某⼀范围上数字的出现情况,并可以节省⼤量空间
-
分段统计:利⽤分段统计思想、并进⼀步节省⼤量空间
-
堆和外排序:利⽤堆、外排序来做多个处理单元的结果合并
题⽬
没出现过的数
分段统计思想
题⽬:32位⽆符号整数的范围是0~4,294,967,295(2^32 -1) ,现在有⼀个正好包含40亿
个⽆符号整数的⽂件,所以在整个范围中必然存在没出现过的数。
-
可以使⽤最多
1GB
的内存,怎么找到所有未出现过的数? -
内存限制为
10kB
,但是只⽤找到⼀个没出现过的数即可
对于题1:
取值范围和位图做对应,占⽤:2^32 /8 = 500M,
哪个数字有了就涂⿊,遍历结束且没有被涂⿊的位置就是没出现过的数
对于题2:
一个int是4B,将内存限制 10kB/4B = 2500, 得到⼀个数字2500,就是内存最多支持开辟2500个int,将这个数字向下取到最⼤的 2 的次⽅数 n = 2048 = 2^11,向下取是为了保证不超过内存
然后将 0~2^32 平均分成 2048 份,每份个数为 a = 2^32 / n
类似于桶排序思想,遍历⼀遍,看看哪个桶没满,说明区间有⼀个数没出现过
具体的:每个数 /a 得到 i,就把数组arr[i]++ ,因为只有40亿个数,肯定存在某个范围不够a个,里面肯定存在没出现过的数字,把这个范围在平均分成 2048 份,重复上面的步骤肯定可以得到答案
再极端⼀点:
只能申请10个以内的变量 – 在2^32范围上⼆分,总有⼀边不满,在不满的一边继续二分,最多过 32 次文件
找出重复的URL
题⽬:有⼀个包含100亿个URL的⼤⽂件,假设每个URL占⽤64B,请找出其中所有重复的URL
可以⽤布隆过滤器、哈希函数分流
补充:某搜索公司一天的用户搜索词汇是海量的(百亿数据量),请设计一种求出每天热门Top100
词汇的可行办法
-
哈希函数分流
-
建⽴⼆维堆
-
每个⽂件⼀个大顶堆统计词频
-
每个堆的堆顶拿出来组成总堆
-
返回总堆堆顶元素,找到该元素来自哪个堆,再拿出来堆顶组成总堆
-
⽤堆来实现多个单元的结果合并
出现两次的数和中位数
题⽬:现在有⼀个正好包含40亿
个⽆符号整数的⽂件
-
可以使⽤最多
1GB
的内存,怎么找到所有出现了两次的数? -
内存限制为
10kB
,找到中位数
对于题⽬1:
可以使⽤哈希函数分流
可以⽤位图中每两位表⽰⼀个数字出现的次数 00,01,10,11
需要空间 a = 2^32 * 2 / 8B, 1GB内存是足够的
对于题⽬2:
⽤分段统计的思想:
个数2^32
将内存限制 10kB/4 = 2500, 得到⼀个数字2500,将这个数字向下取到最⼤的 2 的次⽅数 n = 2048 = 2
然后将 0~2^32 平均分成 2048 份,每份个数为 a = 2^32 / n
遍历所有数字,每个数字/2048,如17/2048 = 0,那arr[0]++
最后可以知道每个区间有多少个,中位数即为第20亿个数和第20亿+1个数的平均值
做累加和,第⼀个超过20亿的区间就是⽬标区间,即定位到⼩范围,逐步缩⼩范围
⼤数据排序
题⽬:10G的⽂件,有符号整数( -2^31 ~ 2^31 - 1 ),⽆序的
,只给5G/5k
内存,输出⼀个新的⽂件,使其有序
⽅法⼀:分段统计 + 堆
- ⼩根堆中⼀个数据要占16字节,5G是5 * 2^30 ,也就是⼩根堆⾥能放 5 * 2^26 个,最近的是 2^27
- 将 -2^31 ~ 2^31 -1 分成 2^5 份(区间)
- 遍历⽂件,⼀个数如果在第⼀个区间,放到内存⾥组成⼩根堆,并记录它出现次数,完成后放到新⽂件⾥
- 再遍历⽂件,对下个区间继续⽤⼩根堆排序后依次弹出到新⽂件末尾
⽅法⼆:分段统计 + 堆
-
建⽴⼀个只能放少量数字的⼤根堆(如容量n=100万)
-
遍历⽂件,将最⼩的100万个数字放⼊⼤根堆,具体就是:
-
不到100万个直接往⾥放
-
否则,数字在⾥⾯,就记录数⽬++;
-
数字不在⾥⾯,若⼤于堆顶,不加⼊
-
数字不在⾥⾯,若⼩于堆顶,堆顶弹出,该数加⼊
-
-
遍历完成后,在新⽂件⾥从⼩到⼤打印⼤根堆⾥的数⽬,⽤⼀个全局变量 Y 记录⼤根堆顶的数字
-
重新遍历⽂件,将⼤于 Y 的最⼩的100万个数字放⼊⼤根堆,重复上述操作,具体就是
-
⼩于 Y 的数字直接跳过
-
⼤于 Y 的数字和上⾯⼀样
-
-
直到完全遍历完成
位运算
基本技巧
提取符号:n >> 31
相反数:~n+1
取到最右端的1: X & (~X + 1)
是不是只有⼀个1:X & (X-1) == 0
⽤16进制的⼀个数,表⽰2进制的4位:0x55 = 0101 0101
⼆进制的加、减、乘、除
题⽬
返回两数较⼤的
题⽬:给定两个有符号32位整数a和b,返回a和b中较⼤的。
要求:不⽤做任何⽐较判断
public class MaxNum {
@Test
public void test() {
System.out.println(getMax1(2, 6));
}
/**
* 有可能溢出
*
* @param a
* @param b
* @return
*/
public int getMax1(int a, int b) {
int c = a - b;
// 提取差的符号位,并返回正负号
int scA = sign(c);
// 反转scB
int scB = flip(scA);
return scA * a + scB * b;
}
/**
* 考虑溢出
*
* @param a
* @param b
* @return
*/
public int getMax2(int a, int b) {
int c = a - b;
int sa = sign(a);
int sb = sign(b);
int sc = sign(c);
int difSab = sa ^ sb;
int sameSab = flip(difSab);
// ab符号不同,且a正 或者 ab符号相同, a-b为正 ==> 返回a,否则返回b
int returnA = difSab * sa + sameSab * sc;
int returnB = flip(returnA);
return a * returnA + b * returnB;
}
/**
* 反转正负号
* @param n
* @return
*/
private int flip(int n) {
return n ^ 1;
}
/**
* 判断它是不是正数
*
* @param n
* @return
*/
private int sign(int n) {
return flip((n >> 31) & 1);
}
}
判断幂
题⽬:判断⼀个32位正数是不是2的幂、4的幂
分析:
1:0000 0001
2:0000 0010
4:0000 0100
8:0000 1000
如果⼀个数是2的幂,这个数的⼆进制只能有⼀个1
对于2的幂
⽅法⼀:
取到最右端的1:令Y = X & (~X + 1)
若Y == X,则说明只有⼀个1,是2的幂,否则有多个1,不是2的幂
⽅法⼆:
2^n -1 会把最后⼀位的1打散,如0000 1000 -1 = 0000 0111
故,若:X & (X-1) == 0,那说明只有⼀个1,是2的幂,否则有多个1,不是2的幂
对于4的幂
如果⼀个数是4的n次幂,这个数的⼆进制只能有⼀个1,且后⾯有2n个0
思路:
先判断是不是 2 的幂,如果不是 2的幂,那⼀定不是4的幂;如果是2的幂继续下⼀步
令Y = 0x55555555 = 0101 … 0101(32位),如果 X & Y != 0,那就是4的幂,否则不是
public class Power {
@Test
public void test() {
System.out.println(is4Power(16));
}
public boolean is2Power1(int n) {
return (n & (n - 1)) == 0;
}
public boolean is2Power2(int n) {
return (n & (~n + 1)) == n;
}
public boolean is4Power(int n) {
return is2Power1(n) && (n & 0x55555555) != 0;
}
}
加减乘除
题⽬:给定两个有符号32位整数a和b,不能使⽤算术运算符,分别实现a和b的加、减、乘、除运算
-
加法:
异或:a^b 为a、b⽆进位相加结果
与:a&b 为a、b的进位信息
a+b = a^b + a&b<<1
上⼀步使⽤了加法,继续使⽤异或和与,直到进位信息为0,那该步的⽆进位相加结果为最终结果
如:7+13 = 20
7 = 00111
13 = 01101
7^13 = 01010
7&13<<1 = 01010
再异或 = 00000
与左移 = 10100
再异或 = 10100 = 结果 = 20
与左移 = 00000 = 0
-
减法
a - b = a + (b的相反数)
b的相反数 = b取反 + 1
-
乘法
乘法竖式
13 = 01101
6 = 00110
00000 +
01101 + (13 << 1)
01101 + (13 << 1)
…
乘法转化成加法
b的末尾为0
a左移末尾补0
b⽆符号右移
b的末尾为1
累加a
a左移末尾补0
b⽆符号右移
当b==0时结束
-
除法
a / b
b不断左移,当b第⼀次⼤于a时,a = a-b,假如b移动了3位,那得到的第⼀个结果为1000
b左移2位,a再尝试减去b,若可以,那得到的第⼆个数为0100
最后不能再减,结果为⼏个数相加,1100
其实就是乘法的逆过程
public class ASMD {
@Test
public void test() {
System.out.println(divide(9, 6) );
}
/**
* a+b溢出不予以考虑
*/
public int add(int a, int b) {
int sum = a;
// 进位信息为0,结束
while (b != 0) {
// 不进位和
sum = a ^ b;
// 进位信息
b = (a & b) << 1;
a = sum;
}
return sum;
}
public int minus(int a, int b) {
return add(a, negNum(b));
}
public int multi(int a, int b) {
int res = 0;
while (b != 0) {
//b末尾为1
if ((b & 1) != 0) {
res = add(res, a);
}
a <<= 1;
b >>>= 1;
}
return res;
}
public int divide(int a, int b) {
int x = isNeg(a) ? negNum(a) : a;
int y = isNeg(b) ? negNum(b) : b;
int res = 0;
for (int i = 31; i >= 0; i = minus(i, 1)) {
// 让x左移,不让y右移(有可能溢出)
if ((x >> i) >= y) {
res |= (1 << i);
x = minus(x, y << i);
}
}
return isNeg(a) ^ isNeg(b) ? negNum(res) : res;
}
private boolean isNeg(int n) {
return n < 0;
}
private int negNum(int n) {
// n取反 + 1
return add(~n, 1);
}
}
从暴⼒递归到动态规划
基本思路
如何从⼀种暴⼒递归的⽅法优化到动态规划
-
尝试⽅法
-
对尝试⽅法进⾏优化
- 记忆搜索
- 位置依赖
- 记忆搜索
-
严格表结构
-
严格表(精)
⼀个尝试⼀旦确定,判断变量个数和范围,添加缓存空间,即为记忆化搜索
在此基础上,再加上最终的位置,根据basecase推出直接直到的位置,根据递归关系推导依赖关系,确定计算顺序,即为严格表结构
题⽬
机器⼈运动
题⽬:机器⼈初始位置 S = 1~N ,结尾位置 P = 1~N
机器⼈必须⾛ k 步,从S到P ,机器⼈每步必须⾛,往左⾛或往右⾛,⽅法数为多少
先想怎么试,再优化
版本1:暴⼒递归,时间复杂度:O(2 )
public int ways1(int N, int S, int K, int P) {
return walk1(N, P, S, K);
}
/**
* 暴力递归
*/
private int walk1(int N, int P, int cur, int rest) {
if (rest == 0) {
return cur == P ? 1 : 0;
}
if (cur == 1) {
return walk1(N, P, cur + 1, rest - 1);
}
if (cur == N) {
return walk1(N, P, cur - 1, rest - 1);
}
return walk1(N, P, cur + 1, rest - 1) + walk1(N, P, cur - 1, rest - 1);
}
版本2:记忆化搜索
时间复杂度:O(K*N)
public int ways2(int N, int S, int K, int P) {
int[][] dp = new int[S + 1][K + 1];
for (int i = 0; i <= S; i++) {
Arrays.fill(dp[i], -1);
}
return walk2(N, P, S, K, dp);
}
/**
* 记忆化搜索
*/
private int walk2(int N, int P, int cur, int rest, int[][] dp) {
//缓存中有就直接拿,避免重复计算
if (dp[cur][rest] != -1) {
return dp[cur][rest];
}
if (rest == 0) {
dp[cur][rest] = cur == P ? 1 : 0;
return dp[cur][rest];
}
if (cur == 1) {
dp[cur][rest] = walk1(N, P, cur + 1, rest - 1);
return walk1(N, P, cur + 1, rest - 1);
} else if (cur == N) {
dp[cur][rest] = walk1(N, P, cur - 1, rest - 1);
}else{
dp[cur][rest] = walk1(N, P, cur + 1, rest - 1) + walk1(N, P, cur - 1, rest - 1);
}
return dp[cur][rest];
}
版本3:严格表结构的DP
根据记忆化搜索中表内的位置依赖(递归关系),来依次填表
时间复杂度:O(K*N)
/**
* 严格表结构
*/
public int ways3(int N, int P, int cur, int rest) {
int[][] dp = new int[rest + 1][N + 1];
dp[0][P] = 1;
for (int i = 1; i <= rest; i++) {
for (int j = 1; j <= N; j++) {
if (j == 1) {
dp[i][j] = dp[i - 1][j + 1];
} else if (j == N) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j + 1];
}
}
}
return dp[rest][cur];
}
最少货币数
题⽬:给定数组 arr,arr 中所有的值都为正数且不重复。
每个值代表⼀种⾯值的货币,每种⾯值的货币可以使⽤1张
再给定⼀个整数 aim,代表要找的钱数,求组成 aim 的最少货币数。
从左往右的尝试模型
版本1:暴⼒递归
public int minCoins1(int[] arr, int aim) {
return process1(arr, aim, 0);
}
private int process1(int[] arr, int rest, int index) {
if (rest < 0) {
return -1;
}
if (rest == 0) {
return 0;
}
if (index == arr.length) {
return -1;
}
int p1 = process1(arr, rest, index + 1);
int p2 = process1(arr, rest - arr[index], index + 1);
if (p1 == -1 && p2 == -1) {
return -1;
}else{
if (p1 == -1) {
return p2 + 1;
}
if (p2 == -1) {
return p1;
}
return Math.min(p1, 1 + p2);
}
}
版本2:记忆化搜索
public int minCoins2(int[] arr, int aim) {
int[][] dp = new int[arr.length + 1][aim + 1];
for (int i = 0; i <= arr.length; i++) {
Arrays.fill(dp[i], -2);
}
return process2(arr, aim, 0, dp);
}
private int process2(int[] arr, int rest, int index, int[][] dp) {
if (rest < 0) {
return -1;
}
if (dp[index][rest] != -2) {
return dp[index][rest];
}
if (rest == 0) {
dp[index][rest] = 0;
} else if (index == arr.length) {
dp[index][rest] = -1;
} else {
int p1 = process1(arr, rest, index + 1);
int p2 = process1(arr, rest - arr[index], index + 1);
if (p1 == -1 && p2 == -1) {
dp[index][rest] = -1;
} else {
if (p1 == -1) {
dp[index][rest] = p2 + 1;
}
if (p2 == -1) {
dp[index][rest] = p1;
}
dp[index][rest] = Math.min(p1, 1 + p2);
}
}
return dp[index][rest];
}
版本3:严格表结构的DP
public int minCoins3(int[] arr, int aim) {
int N = arr.length;
int[][] dp = new int[N + 1][aim + 1];
for (int i = 0; i <= N; i++) {
dp[i][0] = 0;
}
for (int i = 1; i <= aim; i++) {
dp[N][i] = -1;
}
// 改写递归
for (int index = N - 1; index >= 0; index--) {
for (int rest = 1; rest <= aim; rest++) {
int p1 = dp[index + 1][rest];
int p2 = -1;
if (rest - arr[index] >= 0) {
p2 = dp[index + 1][rest - arr[index]];
}
if (p1 == -1 && p2 == -1) {
dp[index][rest] = -1;
} else {
if (p1 == -1) {
dp[index][rest] = p2 + 1;
} else if (p2 == -1) {
dp[index][rest] = p1;
} else {
dp[index][rest] = Math.min(p1, 1 + p2);
}
}
}
}
return dp[0][aim];
}
拿牌游戏
给定⼀个整型数组arr,代表数值不同的纸牌排成⼀条线。
玩家A和玩家B依次拿⾛每张纸牌,规定玩家A先拿,玩家B后拿,但是每个玩家每次只能拿⾛最左或最右的纸牌,玩家A和玩家B都绝顶聪 明。
请返回最后获胜者的分数。 0
版本1:暴⼒递归
public class CardsInLine {
@Test
public void test() {
System.out.println(win1(new int[]{1, 2, 100, 4}));
}
public int win1(int[] arr) {
if (arr == null || arr.length == 0) {
return 0;
}
return Math.max(f(arr, 0, arr.length - 1), s(arr, 0, arr.length - 1));
}
private int f(int[] arr, int l, int r) {
if (l == r) {
return arr[l];
}
return Math.max(arr[l] + s(arr, l + 1, r), arr[r] + s(arr, l, r - 1));
}
private int s(int[] arr, int l, int r) {
if (l == r) {
return 0;
}
return Math.min(f(arr, l + 1, r), f(arr, l, r - 1));
}
}
版本2:严格表结构的DP
public class CardsInLine {
@Test
public void test() {
System.out.println(win1(new int[]{1, 2, 100, 4}));
}
public int win2(int[] arr) {
if (arr == null || arr.length == 0) {
return 0;
}
int[][] f = new int[arr.length][arr.length];
int[][] s = new int[arr.length][arr.length];
for (int i = 0; i < arr.length; i++) {
f[i][i] = arr[i];
}
int row = 0;
int col = 1;
while (col < arr.length) {
int i = row;
int j = col;
while (i < arr.length && j < arr.length) {
f[i][j] = Math.max(arr[i] + s[i + 1][j], arr[j] + s[i][j - 1]);
s[i][j] = Math.min(f[i + 1][j], f[i][j - 1]);
i++;
j++;
}
col++;
}
return Math.max(f[0][arr.length - 1], s[0][arr.length - 1]);
}
}
象棋中马的跳法
题⽬:把整个棋盘放⼊第⼀象限,棋盘的最左下⾓是(0,0)位置。
那么整个棋盘就是横坐标上9条线、纵坐标上10条线的⼀个区域。
给三个参数,x,y,k,返回如果“马”从(0,0)位置出发,必须⾛k步,最后落在(x,y)上的⽅法数有多少种?
版本1:暴⼒递归
public class ChessHorse {
@Test
public void test() {
System.out.println(getWays(4, 5, 6));
}
public int getWays(int x, int y, int k) {
return process(x, y, k);
}
private int process(int x, int y, int step) {
if (x < 0 || x > 8 || y < 0 || y > 9) {
return 0;
}
if (step == 0) {
return (x == 0 && y == 0) ? 1 : 0;
}
return process(x - 1, y + 2, step - 1) +
process(x + 1, y + 2, step - 1) +
process(x + 2, y + 1, step - 1) +
process(x + 2, y - 1, step - 1) +
process(x + 1, y - 2, step - 1) +
process(x - 1, y - 2, step - 1) +
process(x - 2, y - 1, step - 1) +
process(x - 2, y + 1, step - 1);
}
}
版本2:严格表结构的DP
三维表结构
public class ChessHorse {
@Test
public void test() {
System.out.println(getWays(4, 5, 6));
}
public int getWays2(int x, int y, int step) {
if (x < 0 || x > 8 || y < 0 || y > 9) {
return 0;
}
int[][][] dp = new int[9][10][step + 1];
dp[0][0][0] = 1;
for (int h = 1; h <= step; h++) {
for (int r = 0; r < 9; r++) {
for (int c = 0; c < 10; c++) {
dp[r][c][h] += getValue(dp, r - 1, c + 2, h - 1);
dp[r][c][h] += getValue(dp, r + 1, c + 2, h - 1);
dp[r][c][h] += getValue(dp, r + 2, c + 1, h - 1);
dp[r][c][h] += getValue(dp, r + 2, c - 1, h - 1);
dp[r][c][h] += getValue(dp, r + 1, c - 2, h - 1);
dp[r][c][h] += getValue(dp, r - 1, c - 2, h - 1);
dp[r][c][h] += getValue(dp, r - 2, c - 1, h - 1);
dp[r][c][h] += getValue(dp, r - 2, c + 1, h - 1);
}
}
}
return dp[x][y][step];
}
public static int getValue(int[][][] dp, int row, int col, int step) {
if (row < 0 || row > 8 || col < 0 || col > 9) {
return 0;
}
return dp[row][col][step];
}
}
Bob的⽣存概率
题⽬:给定五个参数n,m,i,j,k。
表⽰在⼀个N*M的区域,Bob处在(i,j)点,每次Bob等概率的向上、下、左、右四个⽅向移动⼀步,Bob必须⾛K步。
如果⾛完之后,Bob还停留在这个区域上,并且期间没有出过格⼦,就算Bob存活,否则就算Bob死亡。
请求解Bob的⽣存概率,返回字符串表⽰分数的⽅式。
版本1:暴⼒递归
public class Survival {
@Test
public void test() {
System.out.println(bob1(5, 6, 1, 3, 5));
}
public String bob1(int N, int M, int i, int j, int K) {
long all = (long) Math.pow(4, K);
long live = process(N, M, i, j, K);
// 最⼤公约数
long gcd = gcd(all, live);
return (live / gcd) + "/" + (all / gcd);
}
private long gcd(long m, long n) {
return n == 0 ? m : gcd(n, m % n);
}
private int process(int n, int m, int i, int j, int k) {
if (i < 0 || i == n || j < 0 || j == m) {
return 0;
}
if (k == 0) {
return 1;
}
return process(n, m, i + 1, j, k - 1) +
process(n, m, i, j + 1, k - 1) +
process(n, m, i - 1, j, k - 1) +
process(n, m, i, j - 1, k - 1);
}
}
版本2:题:严格表结构DP
public class Survival {
@Test
public void test() {
System.out.println(bob1(5, 6, 1, 3, 5));
}
public String bob2(int N, int M, int i, int j, int K) {
long all = (long) Math.pow(4, K);
int[][][] dp = new int[N + 1][M + 1][K + 1];
for (int row = 0; row < N; row++) {
for (int col = 0; col < M; col++) {
dp[row][col][0] = 1;
}
}
for (int h = 1; h <= K; h++) {
for (int row = 0; row < N; row++) {
for (int col = 0; col < M; col++) {
int up = getValue(dp, N, M, row + 1, col, h - 1);
int down = getValue(dp, N, M, row, col + 1, h - 1);
int left = getValue(dp, N, M, row - 1, col, h - 1);
int right = getValue(dp, N, M, row, col - 1, h - 1);
dp[row][col][h] = up + down + left + right;
}
}
}
long live = dp[i][j][K];
// 最⼤公约数
long gcd = gcd(all, live);
return (live / gcd) + "/" + (all / gcd);
}
public int getValue(int[][][] dp, int N, int M, int row, int col, int K) {
if (row < 0 || row >= N || col < 0 || col >= M) {
return 0;
}
return dp[row][col][K];
}
private long gcd(long m, long n) {
return n == 0 ? m : gcd(n, m % n);
}
}
货币的⽅法数
题⽬:给定数组 arr,arr 中所有的值都为正数且不重复。
每个值代表⼀种⾯值的货币,每种⾯值的货币可以使⽤任意张
再给定⼀个整数 aim,代表要找的钱数,求组成 aim 的⽅法数。
版本1:暴⼒递归
从左往右尝试模型:
public class CoinsWay {
@Test
public void test() {
System.out.println(coinsWay1(new int[]{3, 5, 10, 1}, 1000));
}
public int coinsWay1(int arr[], int aim) {
return process(arr, 0, aim);
}
private int process(int[] arr, int index, int rest) {
if (index == arr.length) {
return rest == 0 ? 1 : 0;
}
int ways = 0;
for (int nums = 0; arr[index] * nums <= rest; nums++) {
ways += process(arr, index + 1, rest - arr[index] * nums);
}
return ways;
}
}
版本2:严格表结构DP
public class CoinsWay {
@Test
public void test() {
System.out.println(coinsWay1(new int[]{3, 5, 10, 1}, 1000));
}
public int coinsWay2(int[] arr, int aim) {
if (arr == null || arr.length == 0) {
return 0;
}
int N = arr.length;
int[][] dp = new int[N + 1][aim + 1];
dp[N][0] = 1;
for (int index = N - 1; index >= 0; index--) {
for (int rest = 0; rest <= aim; rest++) {
dp[index][rest] = 0;
for (int nums = 0; arr[index] * nums <= rest; nums++) {
dp[index][rest] += dp[index + 1][rest - arr[index] * nums];
}
}
}
return dp[0][aim];
}
}
枚举⾏为是否有必要?没必要!
枚举的过程中会有重复
继续优化:( 斜率优化 )
如果填表的时候有枚举⾏为,我看看临近的位置能不能替代枚举⾏为
public class CoinsWay {
@Test
public void test() {
System.out.println(coinsWay1(new int[]{3, 5, 10, 1}, 1000));
}
public int coinsWay3(int[] arr, int aim) {
if (arr == null || arr.length == 0) {
return 0;
}
int N = arr.length;
int[][] dp = new int[N + 1][aim + 1];
dp[N][0] = 1;
for (int index = N - 1; index >= 0; index--) {
for (int rest = 0; rest <= aim; rest++) {
dp[index][rest] = dp[index + 1][rest];
if (rest - arr[index] >= 0) {
dp[index][rest] += dp[index][rest - arr[index]];
}
}
}
return dp[0][aim];
}
如何尝试?
尝试过程中要注意两个事情:
-
每个可变参数⾃⼰的维度最好是零维
-
最好是零维参数–int类型
-
不要是数组类型,表的⼀个轴会超级长
-
-
可变参数的个数越少越好
- 可变参数的个数决定表结构的维度
有序表
哪些结构可以实现有序表?
-
平衡搜索⼆叉树(BST)系列
-
红⿊树(Red Black Tree)
-
AVL树(发明⼈:Adelson-Velskii 以及 Landis)
-
SB树(Size Balance Tree)
-
-
跳表(Skip List)
它们都可以O(logN)时间实现,四种结构区别不⼤
搜索⼆叉树
添加:
- 结点依次放⼊,⼩于头节点则放左⼦树,⼤于头节点则放右⼦树
- ⼩于某个点 N 则放 N 的左⼦树,⼤于 N 则放 N 的右⼦树
- 直到叶⼦
删除:
- 要删除的结点没有左右孩⼦,直接断掉
- 左右两个孩⼦不双全,直接孩⼦代替它
- 都全的⽐较⿇烦,⼀个结点 N 去掉时
- 让其左树最右 A 替代 N
- 让其右树最左 B 替代 N ,B 的⽗亲的左指针指向 B 的 右孩⼦
查找,⼀个节点 N ,⽬标值 aim
- 若aim > N,向 N 的右⼦树寻找
- 若aim < N,向 N 的左⼦树寻找
搜索⼆叉树不具有平衡性,是否为 O(logN) 取决与数据的输⼊和删除
平衡搜索⼆叉树
具有平衡性,任⼀结点的左树和右树⾼度相差都不超过1
通过左旋和右旋操作来保持搜索树的平衡
左旋:头节点 A 倒向左边,头节点右孩⼦ C 成为了头节点,A 成为 C 的左孩⼦,C 的左孩⼦ F 成为 A 的左孩⼦
左旋后:
右旋:头节点 A 倒向右边,头节点左孩⼦ B 成为了头节点,A 成为 B 的右孩⼦,B 的 右孩⼦ E 成为 A 的左孩⼦
右旋后:
怎么发现⾃⼰不平衡?
-
新增⼀个结点从该结点开始,依次查⽗亲结点是否平衡
-
删除⼀个结点 N 从替代结点开始,依次查⽗亲结点是否平衡
-
让其左树最右 A 替代 N
-
让其右树最左 B 替代 N ,B 的⽗亲的左指针指向 B 的 右孩⼦
-
⼏种不平衡的类型:
-
LL:左树的左孩⼦太长导致的不平衡:右旋
-
RR:右树的右孩⼦太长导致的不平衡:左旋
-
LR:左树的右孩⼦太长导致的不平衡:让左树的右孩⼦成为头部
-
先让左树头节点左旋,变成了LL型
-
再让头节点右旋,平衡
-
-
RL:右树的左孩⼦太长导致的不平衡:让右树的左孩⼦成为头部
-
先让右树投机点右旋,变成RR型
-
再让头节点左旋,平衡
-
调平衡的代价 O(logN)
-
调平衡的代价 O(logN)
SB树
平衡性:
⼀个结点的每棵⼦树的⼤⼩,不⼩于其兄弟的⼦树⼤⼩
即:每个⼦树的叔叔的⼤⼩,不⼩于其任何侄⼦树的⼤⼩
调整和AVL树差不多
调整:
LL:头节点 T 的左孩⼦ L 的左孩⼦ A,⽐ T 的右孩⼦ R ⼤:m(T)
-
右旋,T 左孩⼦ L 变为头节点
-
递归,对孩⼦发⽣变化的结点进⾏调整m(T)、m(L)
LR:头节点 T 的左孩⼦ L 的右孩⼦ B,⽐ T 的右孩⼦ R ⼤:m(T)
-
以 L 为头左旋
-
再以 T 为头左旋
-
递归,对孩⼦发⽣变化的结点进⾏调整m(L)、m(T)、m(B)
红⿊树
新增操作标准–5个
删除结点标准–8个
每个点不是红就是⿊
最底层的空结点(这⾥叫叶节点)和头节点必须为⿊
任何两个红节点不能相邻
任何⼀棵⼦树,每条到结束的路上⿊⾊的结点⼀样多
跳表
扔⾊⼦,0.5概率是0,0.5概率是1、
- 是1就加⼀个向外指的指针,直到是0停⽌仍
默认结点始终和指针数最多的指针数⼀样多
数据个数N,第0层有N个,1层有N/2个,…,m层有N/2m个
代码参考:https://gitee.com/Zesystem/hongheishuheavlshu/tree/master