互联网的广大朋友们,国庆快乐!
持续更新于个人博客网站:.idea | Blogs.欢迎访问
数组 && 链表
数组
数组最突出的特点是有索引,不同于其他的数据结构,数组问题不必借助哈希表就能达到O(1)的访问时间复杂度。
数组问题常用的套路无非就是遍历,双指针、三指针、从左往右、从右往左遍历。另外看数组是否有序,如果有序的话一般可以考虑结合分治算法来提高效率。下面结合一些代表性题目总结一些常规的思考方向:
Demo 01:双指针技巧遍历
demo 01
数组/链表有序然后合并的问题,直接双指针一次遍历。如果合并多个,一样的,多一个递归而已。这道题目没有要求原地合并,可以采用常规的双指针正序遍历。但最优的想法是,考虑到nums1作为返回数组,本身已足够长,可以做到原地合并。双指针从右往左遍历即可。
demo 02
将数组看作正数、负数的两部分,实质仍然就是合并两个有序数组。
Demo 02:数组原地操作,在没有多余长度的情况下,只能是交换元素
看到原地二字,大概率就是遍历过程交换元素。常规的多指针解法,左右指针+curr遍历指针,遍历的同时交换元素即可。
Demo 03:数组有序联想分治算法
容易想到的是合并两个有序数组然后返回中位数,时间复杂度O(m + n)。联想分治算法,这里只要求返回中位数,数组又是有序的,考虑二分查找,时间复杂度降为O(log(m + n))。
Demo 04:返回结果需要去重,可以考虑先对数组排序
数组元素三数之和,肯定至少需要三个指针指向三个元素。最后答案要去重,所以先对数组排序,这样遍历的时候指针可以跳过相同的元素。
链表
链表问题一般也是在遍历过程中用多指针记录结点,有时还需要记录中间结点;可引入虚拟头结点统一操作;用快慢指针来判断是否有环、快速定位中间结点。
常见的链表操作有:增加/删除一个结点、求链表长度、翻转链表等。
Demo 01:基本操作熟练默写
增加/删除结点和求链表长度就不说了,太简单,熟记翻转链表的递归和迭代写法。同时先来看一道关于删除结点的小技巧:
demo 01:只给定单链表的待删除结点,如何删除该结点?
我们知道,单链表删除一个结点需要得到它的前一个结点才能完成删除操作,而这道题只给出了待删除的结点。怎么做呢?——将下一个结点的值赋值给它,然后删除下一个结点。
demo 02:反转链表
递归解法:
迭代解法:
demo 03:反转链表基本操作的一个拓展
反转指定区间的链表+拼接即可。
Demo 02:快慢指针的应用
demo 01:判断链表是否有环
- 快指针一次走两步,慢指针走一步,快慢指针相遇即可判定链表有环。
- 结尾的时候fast指向有两种可能:一是fast指向null,二是fast.next指向null。
demo 02:确定链表入环位置
快指针速度恰好是慢指针的两倍,两者一定是在一个周期内相遇。结合图形从数学上推导a + b + c + b = 2(a + b),化简得a = c
demo 03:确定链表的中间结点
Demo 03:链表去重在未排序的情况下考虑哈希,添加不成功即为重复
如果链表有序要去重就简单了,类似数组去重一样比较curr.val和curr.next.val即可。但是在链表无序时去重,可以选择归并+递归+快慢指针寻找中间节点先排序链表再去重,时间复杂度O(nlogn)。若考虑哈希表去重,可降为O(n)级别。
Demo 04:类似数组,链表很多题目也是双指针技巧遍历记录结点
双指针技巧遍历即可。
其他还有合并两个有序链表、合并K个有序链表等等,不再一一赘述。
栈 && 队列
栈
栈的特点是先进后出,后进先出,元素呈现高度的对称性且只能在栈顶操作。一般就用于对称性问题以及表达式计算问题,特别地还有单调栈用于求解 Next Greater Element问题。
Demo 01:对称问题直接考虑栈
高度对称的括号问题符合栈的特点,直接采用栈来解决:
Demo 02:双栈技巧解决表达式计算问题
demo 01
逆波兰表达式做为栈的典型应用之一,既可以采用以上通用的双栈做法,即一个算术栈一个符号栈,也可以直接就在同一个栈里操作:遇上数字入栈,遇上符号就出栈两个数字计算并写回结果。实质也可以看作是符号栈始终为空的双栈做法,一样滴。
demo 02
双栈解法的优势在于套路固定通用,并且易于理解。因为它本身就是一个模拟人脑思考的算法。以下模板不仅仅适用于这道题,即便加入了乘/除运算甚至是自定义运算符,照样只需要简单修改即可。
class Solution {
public int calculate(String s) {
// 设置符号优先级
Map<Character,Integer> map = new HashMap();
map.put('+',1);
map.put('-',1);
// 算术栈和符号栈
Deque<Integer> nums = new LinkedList<>();
Deque<Character> ops = new LinkedList<>();
// 预置一个0
nums.push(0);
// 将所有的空格去掉
s = s.replaceAll(" ", "");
int n = s.length();
for (int i = 0; i < n; i++) {
char c = s.charAt(i);
if (c == '(') {
ops.push(c);
} else if (c == ')') {
// 计算到最近一个左括号为止
while (!ops.isEmpty()) {
char op = ops.peek();
if (op != '(') {
calc(nums, ops);
} else {
ops.poll();
break;
}
}
} else {
// 是一个数字
if (isNum(c)) {
int u = 0;
int j = i;
// 将从 i 位置开始后面的连续数字整体取出,加入 nums
while (j < n && isNum(s.charAt(j))) {
u = u * 10 + (int)(s.charAt(j++) - '0');
}
nums.push(u);
i = j - 1;
} else { // 是一个运算符
// 类似"1-(-2)"情况出现时预置一个0
if (i > 0 && (s.charAt(i - 1) == '(' || s.charAt(i - 1) == '+' || s.charAt(i - 1) == '-')) {
nums.push(0);
}
while (!ops.isEmpty() && ops.peek() != '(') {
char prev = ops.peek();
if (map.get(prev) >= map.get(c)) {
calc(nums, ops);
} else {
break;
}
}
ops.push(c);
}
}
}
while (!ops.isEmpty()) {
calc(nums,ops);
}
return nums.peek();
}
private void calc(Deque<Integer> nums, Deque<Character> ops) {
int b = nums.poll(), a = nums.poll();
char op = ops.poll();
int ans = 0;
if (op == '+') {
ans = a + b;
} else {
ans = a - b;
}
nums.push(ans);
}
private boolean isNum(char c) {
return Character.isDigit(c);
}
}
不过虽然如此,但是面试的时候感觉代码量太大了,而且思维更紧密,怕卡壳,提供一个思路嘛。
Demo 03:单调栈高效解决Next Greater Element
常规的思考方向是对每一个元素都单独遍历整个数组,直到找到第一个比该元素大的元素,记录下他们的距离。时间复杂度为O(n ^ 2)。采用单调栈,整个过程一次遍历即可,时间复杂度降为O(n)。
队列
队列与栈相对应,栈先进后出,队列先进先出。两者之间联系紧密,既可用栈实现队列,也可以用队列来实现栈。
特别地,单调队列非常适合用来解决滑动窗口问题(实际上滑动窗口技巧本身就是一个队列)。来看一个例子:
单调队列一方面需要在一端同时执行插入删除操作以维护队列的单调性,另一方面在另一端获取队列的最大值/最小值,因此我们需要用到双端队列Deque来做:
二叉树 && 二叉搜索树
二叉树
绝大多数的二叉树问题都可以用递归+遍历来解决。做二叉树及二叉搜索树相关的题目,重要的事情说三遍:
一定一定要会站在根节点的角度出发思考问题。
一定一定要会站在根节点的角度出发思考问题。
一定一定要会站在根节点的角度出发思考问题。
二叉树的结构本身就是递归结构,所以一般都可以用递归方法来解决二叉树问题,站在根节点的角度出发思考问题就非常容易想出二叉树问题的递归解法。
怎么理解呢?来看一道具体题目(包括后面其他问题的递归解法都是从根节点角度出发思考的):
Demo 01:从根节点角度出发思考问题的解
站在root节点的角度思考,要得到整个二叉树的镜像只需要得到左子树的镜像、右子树的镜像即可。对于子树里的每一个节点同样都是如此。递归整个过程,当节点为null时做为递归退出条件。
Demo 02:基本操作默写:二叉树的遍历
要求能够熟练默写二叉树的先序、中序、后序和层序遍历,递归和迭代两种方式都需要掌握。先序、中序、后序遍历的迭代实现考虑用栈,层序遍历用到队列。二叉树的前中后序遍历的递归解法都差不多,下面以中序遍历为例:
递归实现:
中序遍历的迭代实现:
前序遍历的迭代实现:
后序遍历的迭代实现与前序遍历的实现是一样的套路。前序遍历的res.add默认是在尾部add,后序遍历每次在最前面add即可,同时还因为添加顺序的改变,push节点的顺序也要改变:
res.add(0,node.val);
if (node.left != null) {
stack.push(node.left);
}
if (node.right != null) {
stack.push(node.right);
}
层序遍历(逐层访问):
Demo 03:记忆关于二叉树的一些数学公式
-
对二叉树而言,边数T = n - 1 = n0 + n1 + n2 - 1 = n1 + 2 * n2,即有n0 = n2 + 1
-
特别地,在完全二叉树中,非叶子节点个数为(n / 2),叶子节点个数为(n + 1)/ 2(皆默认向下取整);某个节点下标为index,则左子节点下标为(index * 2 + 1),右子节点为(index * 2 + 2)。 这两条性质在堆排序算法中会用到。
二叉搜索树
二叉搜索树的一个重要特点:中序遍历结果是一个递增序列。左子树节点值<root<右子树节点值。基于二叉搜索树的特点,类似于二分查找,查找一个元素的时间复杂度可降为O(log)级别。
如果是对二叉搜索树中的某个节点进行操作的话,解题框架模板如下:
void BST(TreeNode root,int target) {
if (root.val == target) {
// 找到目标元素,执行特定操作
}
if (root.val < target) {
BST(root.right,target);
} else if(root.val > target) {
BST(root.left,target);
}
}
下面来看几道模板的应用实例:
demo 01
删除操作可化为插入操作,从根节点角度出发递归即可:
demo 02
把val看作一个新的二叉树节点套用模板,秒杀:
demo 03
该题解法看起来与模板有些许出入,但实质还是一样滴:
B树 && B+树
B树
B树是一种多路平衡搜索树,在逻辑上B树和二叉搜索树是等价的,二叉搜索树的多个节点合并即可得到B树节点。
B树的每个节点既存储关键字也存储关键字记录;B树一个节点中的关键字数目如果为n,那么该节点下一层节点的数目就为n + 1。
B+树
B+树相比B树,非叶子节点只存储关键字,只有叶子节点中才存储关键字记录;B+树在叶子节点之间用指针连接起来;B+树节点中的关键字数目为n,则该节点下一层的节点数目也为n。
上图中的叶子节点只是图片基于自身排版啥的考虑,并不是什么多个节点共享存储一个关键字记录啥的。
为什么MySQL索引采用B+树结构而不用B树
这个问题在之前的MySQL复习博客里已经说过了,这里再简单写一下:
- B+树的非叶子节点不存储关键字记录,那么就有更多的空间来存储关键字,相比B树高度得到了降低,减少了磁盘IO的次数。
- B+树的数据搜索只有在叶子节点上才能找到,搜索效率稳定。
- MySQL索引用到的B+树更是在原B+树的基础上将单向链表转为了双向链表,更加便于记录的快速检索。
Tire前缀树(字典树)
还叫做单词查找树,查找单词的效率取决于字符串的长度。例如上图使用前缀树来存储了右边的六个单词。
Tire树逻辑上是一棵多叉树,但是与一般的树不同,它的节点不直接存储数据value,隐式地以数组链接位置表示一个元素是否存在。
Tire树的实现:(leetcode.208)
class Trie {
Trie[] children;
boolean isEnd;
public Trie() {
children = new Trie[26];
isEnd = false;
}
public void insert(String word) {
Trie node = this;
for (int i = 0;i < word.length();i++) {
char ch = word.charAt(i);
int index = ch - 'a';
if (node.children[index] == null) {
node.children[index] = new Trie();
}
// 如果已经存在就继续往下走
node = node.children[index];
}
node.isEnd = true;
}
// 找到该序列并且isEnd = true
public boolean search(String word) {
Trie node = searchString(word);
return node != null && node.isEnd;
}
// 只要找到该序列即可
public boolean startsWith(String prefix) {
return searchString(prefix) != null;
}
private Trie searchString(String word) {
Trie node = this;
for (int i = 0;i < word.length();i++) {
char ch = word.charAt(i);
int index = ch - 'a';
if (node.children[index] == null) {
return null;
}
node = node.children[index];
}
return node;
}
}
字符串
字符串问题涉及的思路算法比较多,不过一般那种子串啥的,处理思路一般就是两个方向:
- 动态规划
- 滑动窗口
动态规划的例子下一篇博客再说,示例一道用滑动窗口来解决的字符串问题:
滑动窗口移动过程中动态维护res值即可:
哈希表结构
哈希表结构最突出的特点是它的访问时间复杂度为O(1),这里就有一个面试题了,哈希表是如何达到O(1)的访问时间复杂度的?——以HashMap为例,底层数据结构就是一个数组+链表/红黑树。数据以key-value的形式存入哈希表当中,通过哈希算法计算出的下标可直接定位到数组位置,所以时间复杂度可以达到O(1)。另外,就算发生了哈希冲突,链表较短时就以链表形式链接,否则转为红黑树优化查询,整体查找效率依然很高。
要用到哈希表来解决问题的算法场景主要是两种:
- 明确要求元素访问时间复杂度为O(1)。
- 在对数据遍历的过程中需要存储下来,后续会判断或用到遍历过的元素。
两种场景各举一个例子:
Demo01:题目明确要求访问时间复杂度O(1)
LRU算法,老经典了。要求在O(1)时间复杂度内完成,条件反射哈希表;然后需要频繁在首尾插入移动元素,考虑双向链表结构。最终确定总体的数据结构:哈希表+双向链表(LinkedHashMap):
class LRUCache {
// 双向链表
private class Node {
Node prev;
Node next;
int key;
int val;
Node() {}
Node(int key,int val) {
this.key = key;
this.val = val;
}
}
// 虚拟头尾节点
Node dummyHead;
Node dummyTail;
Map<Integer,Node> map;
// 缓存容量
int capacity = 0;
// 当前大小
int size = 0;
public LRUCache(int capacity) {
dummyHead = new Node();
dummyTail = new Node();
dummyHead.next = dummyTail;
dummyTail.prev = dummyHead;
map = new HashMap();
this.capacity = capacity;
}
public int get(int key) {
if (map.containsKey(key)) {
Node node = map.get(key);
removeToTail(node);
return node.val;
} else {
return -1;
}
}
public void put(int key, int value) {
if (map.containsKey(key)) {
Node oldNode = map.get(key);
oldNode.val = value;
removeToTail(oldNode);
} else {
Node newNode = new Node(key,value);
size++;
if (size > capacity) {
Node delNode = deleteHead();
map.remove(delNode.key);
size--;
}
addToTail(newNode);
map.put(key,newNode);
}
}
private void addToTail(Node node) {
node.prev = dummyTail.prev;
node.next = dummyTail;
dummyTail.prev.next = node;
dummyTail.prev = node;
}
private void removeToTail(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
addToTail(node);
}
private Node deleteHead() {
Node node = dummyHead.next;
dummyHead.next = node.next;
node.next.prev = dummyHead;
return node;
}
}
Demo02:遍历过程存储元素用于后续判断
要返回交点,可以考虑先对headA遍历并存储记忆遍历元素;然后再对headB遍历,遍历的同时检查是否存储有该元素,有的话返回即是链表交点。
二叉堆 && 优先级队列
二叉堆是啥?——二叉堆的逻辑结构就是一颗完全二叉树,底层物理结构一般连续空间存储就好(数组)。二叉堆最重要的特点是它的父节点值一定>=(<=)子节点的值。
用来干嘛?——解决topK问题。获取数据集里的前K个元素,并不必完全排序整个数据集。
优先级队列的底层实现就适合采用二叉堆。所谓优先级队列,就是出队时优先级最高的元素率先出队。
来看二叉堆的实例:
相当于就是topK问题,用二叉堆来做再合适不过了:
布隆过滤器
布隆过滤器是什么?——布隆过滤器是一个空间效率和查询效率都很高的概率型数据结构。redis的缓存穿透问题中就可以用布隆过滤器来高效查询网络白名单。
用来干嘛?——用来确定一个元素一定不存在或者可能存在。
底层结构是怎样的?——实质就是一个二进制数组+一系列的hash映射函数。
实现原理?——当插入一个元素时,调用所有的hash函数返回索引位置下标并将其值设置为1;当查询一个元素时,同样调用所有的hash函数返回位置下标,检测这些位置上的值是否都为1。只要有一个位置不为1,该元素一定不存在;如果全为1,该元素可能存在。
优缺点?——空间效率和查询效率都很高,仅仅是一个二进制数组就够了,查询时间复杂度也是O(1)级别。缺点是元素删除很麻烦,且存在一定的误判率。(元素返回存在并不是说就真的一定存在)
误判率受什么影响?——二进制数组长度、hash映射个数、数据规模。
跳表
跳表是什么?——又叫跳跃表,跳表就是对有序链表的优化,一个节点可以存储多个指针域。redis的sorted_set类型里就用到了跳表结构。
跳表的作用?——有序链表并没有因为链表有序而得到操作上时间复杂度的降低,始终是O(n)。而跳表通过建立多个指针域跳跃着指向节点,将操作的时间复杂度降为了O(logn)级别。
跳表搜索的过程?——以在上图中查找节点17为例。先扫描上层节点,遇到21>17,返回来到当前层的上一个元素,转入扫描下一层到9,9<17,继续扫描到21>17再返回上一个元素跳到下一层9,最后找到17。整个过程指针只跳跃了四次就找到了元素17。