【算法】【基础】【力扣】【leetcode】原理、实际问题和应用
前言
《编程珠玑》确实是一本好书,它里面对算法和数据结构的解读,对问题的分析可以很好帮助编程人员转变以往对数据结构和算法的态度发生改变,转向重视;通常会把本书内容总结为:
- 问题定义;
- 算法设计;
- 数据结构选择;
一、基础篇
1.1 循环:循环不变量
循环不变量:什么是循环不变量呢?
A loop invariant is a relation among program variables that is true when control enters a loop, remains true each time the program executes the body of the loop, and is still true when control exits the loop. Understanding loop invariants can help us analyze programs, check for errors, and derive programs from specifications.
可以结合下面的例子看,因为这里的循环不变量是s必然等于前i的数字和,所以当i等于数组长度,s就是整个数组和。
public static int sum(int a[]) { int s = 0; for (int i = 0; i < a.length; i++) { // s is the sum of the first i array elements // s == a[0] + .. + a[i-1] s = s + a[i]; } return s; }
循环和递归是两种不同的实现方式,他们之间的区别是循环基本上,是把状态存在在堆中,而不是在方法栈上,循环一般用while()或者do while,或者for,但他们都有共同的特点,如下:
- 明确子问题:循环在每一次循环中,都是在解决子问题,所以先确定子问题,是循环最重要的事情;
- 寻找结束条件:结束条件有两种形式,一种是可判断的前置条件不满足,第二种是完成了我们需要在循环中完成的事情;
- 明确循环不变量:明确循环不变量后,可以保证你的程序的正确性;
- 虚拟(dummy)节点:做循环的时候,明确子问题后,发现很多情况下第一个节点不好处理,所以为了可以在第一个节点前先造一个虚拟节点,例如leetCode203,弄个虚拟头节点。另一种情况是双指针很多时候第一个指针会设置为最左元素下标减一,以满足子条件。
例子:二分查找,二分查找中,给一个升序数组,一个目标值,那么子问题就是:给定一个数组,找到中间的值,判断是否有目标值,有则返回,无则把该数组范围 再缩小。
1.2 递归
如何写递归?递归的本质是栈的应用,在于用函数运行时栈帧作为调度的状态。
调度:一般我们回考虑调度和过程分离。但递归的调度是一种循环调度,所以必须要有退出条件。同时,递归的调度必须要考虑栈的存在。栈帧是状态的存储单元。
过程:递归的栈帧记录状态,这个状态包括运行到过程的哪一步。调用递归方法代表产生新栈帧。
二、数据结构篇
2.1 哈希:
哈希其实就是数据的随机访问特性,然后让数据均衡的分布在数组上面;所以我们下面说的位图也算是哈希的一种。
- 数组形式:适合value范围是有限数量可控的。
- Set形式:因为用了哈希算法,所以value可以大范围的,但是尽量要选用散列的,避免冲突。
- Map形式:带key的映射
2.2 位图:
有一些题目很明显,例如存电话号码的磁盘文件排序,只给1m内存,只能用归并排序,算法的确定的,这个时候,数据结构的选择,就决定了算法运行的时间和空间顺序,假设电话号码7位,那么如何表示这7位呢?
方法:
1、编码方式:一个码位,一个数字,7位的号码只需要到千万个码位就可,可用 4 Byte = 32位整数,取值范围-2147483648~2147483647,足够支持一亿个号码;
2、位图方式:用坐标代表一个编码,第一位为编码1,第二位为编码2,那么7位共需要多少bit呢?少于1000万位,也就是1000万 bit = 12500 Byte = 12.5KB
可见,第二种方式可以用位图排序法:这样算法的效率会得到质的提升。但方法2并非适用所有,它要求,第一,数字不重复,第二,范围较少,如果13位整数,那么就是10000000000000bit,2500GB,这样位图就划不来了。
三、算法篇
2.1、锻炼操作(翻转算法):
旋转一个向量 ,如abcdefg,旋转位defgabc,该问题有多种解法,但不同的解法对效率的影响很大,很多问题场景仅仅都是有限的内存空间和时间下解决的。
方法:
1、如果从i点旋转,那么先用临时空间存0~i的元素,然后i之后的元素全部向前移动i位,再把临时数组复制到最后i位;
2、如果没有临时空间,那么可以用方法1,一位一位的旋转,旋转i次;
3、可以用求逆的算法,例如ab,可以计算a'b',再对整个求逆(a'b')',得到ba;
每一种算法对应一个基本操作,如果基本操作得当,那么灵机一动后,方法3对时间,空间的效果都是最高的。
2.2 二分查找:
查找,查找之中,最重要的概念是二分查找,很多数据结构其实都应用了二分查找的理念,例如跳表、红黑树、B+树;
二分查找的中间:二分查找通常需要一个中间数,可以用(left + right)>>1;或者 ((right - left) >> 1) + left; 这里都会用到除以2的操作,因为计算机的除以2等同于右移一位,所以奇数会会失去精度1,当真正的中位数是小数的时候,其实是取比小数少的那个整数。
- 尽量用 mid = (right - left) / 2 ,因为这样不会溢出
- 二分有两种查找方式,第一种是查找确定值找到就结束,第二种是找最合适的值,这种必须要遍历完所有有可能的值
- 剑指 Offer 53 - I. 在排序数组中查找数字 I
2.3 双指针法
双指针,也叫快慢指针法
- 双指针,一般是一个指针遍历,记为right,一个指针用作定义已经处理好的元素,记为left。
- 确定循环是用for还是用while,一般都是用for,一个时刻很可能两个指针都在动
- 明确退出条件,明确目标值的,小心边界。
2.4 滑动窗口
- 滑动窗口,最主要的和双指针一样,都是要明确left指针和right指针的意义MindA,然后明确left和right之间的意义MindB。
- 确定循环是用for还是用while,一般都是用while,因为一个循环只有left和right一个在移动
- 明确退出条件,明确目标值的,小心边界。
2.5 单调栈
- 通常是一维数组,要寻找任一个元素的右边或者左边第一个比自己大或者小的元素的位置,此时我们就要想到可以用单调栈了,时间复杂度为O(n)。
- 比起暴力,单调栈的原理是空间换时间,具有单调特性的所有元素都在等待一个不单调的元素出来,然后统一处理。
- 单调栈的原理中,最主要的是求出了的元素就不需要逗留栈内,
- 首先确定,栈内元素必然单调,而且栈的元素是动态的。
- 在新元素和栈内元素的比较中,看看能不能求出答案,求出就记录
- 不管求出还是不求出,我们都要思考新的值加入后前面的值是否还有必要存在,把没必要的出栈。
2.5 单调队列
单调栈的变形,主要是为了维护一个窗口,所以需要在队列头弹出元素。
- 通常是一维数组,寻找一个范围内的最大值,最小值。
- 数组是队列,但也是栈,为了形成窗口,同样的道理:
- 首先确定,队列内元素单调,而且队列的构造是动态的
- 在新元素和队列内元素的比较重,看看能不能求出答案,求出就记录
- 不管求出还是不求出,我们都要思考新的值加入后前面的值是否还有必要存在,把没必要的出栈。
- 每次还要看队列是否和窗口大小相同,大于的话就出队列
2.6 回溯
回溯是递归的一种应用,的本质是树的遍历,既然是遍历,所以副作用第一要注意区分的就是副作用
- 确认参数的副作用,在回溯中,一般递归函数要求无副对作用参数进行补偿消除副作用,每个函数的语义都要保证无副作用参数。
组合问题:回溯的组合问题,一般是对元素列表进行按顺序选取,每一次选取针对一个元素做两种操作:选 or 不选,这两种操作对应两种解法。
- 递归函数的语义是:一个列表,在当前上下文中,求出针对列表第i个元素起,后面的所有有可能符合条件的元素的组合。
- 因此在递归函数体中第i个元素可以有两种可能:选 or不选。这个时候需要函数体调用两次递归函数,一个是选该元素的递归调用,另一个是不选该元素的递归调用
- 我们还可以在函数体中决定对列表中的剩下的n个元素决定选或者不选,所以可以利用循环做 length - i 次,每一次循环代表选index这个元素,不选i ~ index的这些元素。
练习题目:组合总和2
四、应用篇
3.1 数据库
一个应用问题要选对数据结构是最重要的事情,我们知道很多时候,程序员需要在时间和空间上面做取舍,选对结构后是时间和空间的开销兼得的重要方式之一;
为什么是B+树?经常会有人对数据库的数据结构提这个疑问,其实答案就是因为B+树能满足以下三点,而这三点正好是SQL语句的常用操作:
- 可以快速根据id定位到某个元素;
- 支持快速查找某个元素所在的某个范围之中的所有元素;
- 可以支持快速查找到某个元素值前或者后的n个元素;
所以问题定义好之后,我们就开始找对应的数据结构了,第一点,我们想到Hash,但明显,Hash不支持2和3,所以我们需要妥协,选择二分查找,而二分查找可选的数据结构:平衡二叉树、跳表、红黑树、B+树;
第三点,很自然的就是想到双向链表,但链表不满足第二点;这个时候,跳表这种数据结构就比较合适了:
【图片来源网络】
然后对跳表加以改进一下,便有了B+树,看下图,为什么一个节点需要多个元素呢?其实原因就是因为磁盘和内存速度的差距,所以最好是一次性从磁盘加载的数据刚好是一个节点,所以节点便存元素最好了,而这个值的大小,就是缓存页的大小16K;
【图片来源网络】
其实还有一个值得注意的,MongoDB用的数据结构就不是B+树;而是B-树,其实很好理解,文档数据库的查找需求通常不一样,他们只需找到值就可以了,很少做范围查找;
3.2 9宫格键盘
这个例子大多数90后都熟悉,那一代的程序员估计很多人都遇到这种需求;下图是一个9宫格键盘,其中如果数据某个词,只能按0、1、2、3、4、5、6、7、8、9这十个键盘,例如fan和dan这两个英文或者拼音,都是按相同的键盘数字,这意味着,所有的单词中,他们都有自己的相同按键的小伙伴,需求:如何根据用户的按键输入,快速在上亿个记录中查找到单词名称为fan的记录呢?
这道题,用到的有两种,第一是排序,第二是查找,排序自然是把上亿个记录排序,查找,是用到二分查找;问题的重点是,按照什么排序?
标识:这个词在编程珠玑第一件见,作为把具有相同按键的称之为同位词,而同位词的正序序列是唯一的,所以这这个序列就是同位词的表示;给上亿个记录都贴上标示后,我们就可以按照标识再进行一次排序,于是,一个有序的记录就出现了,假设我们用的是归并排序。后面的查找就不多说了。这个问题,到此,迎刃而解。
五、练习篇
下面为用作练习的快速例子,全部出自LeetCode
- 27. 移除元素,练练手
- 142、环形链表,该题目最重要的点是怎么算出入口节点;
- 面试题 02.07. 链表相交;怎么找到相交节点?主要是能走相同的路,就可以找到相交节点;
- 59 螺旋矩阵 II,十分考操作能力;
- 35.查找插入的位置,遍历,明确循环的不变量,退出条件;
- 15.三数量之和,正确的路很难走;
- 154.旋转数组中最小的值
- 240.二维数组的查找
- 79.单词搜索
- 剑指offer.33.二叉搜索树的后序遍历序列
- 剑指offer.38. 字符串的排列 —— 这题要特别注重aab,第一个选择a以及包含了第一位选a的所有可能性,所以在第一个选第二个a的时候可以忽略。
- 剑指 Offer 46. 把数字翻译成字符串
- 剑指 Offer 53 - I. 在排序数组中查找数字 I
- 剑指 Offer 54. 二叉搜索树的第k大节点
- 这道题和《剑指 Offer 53 - I. 在排序数组中查找数字 I》遍历找范围很像
- 用的是递归便利,遍历中记录结果,而且是倒序,所以比较有价值。
- 剑指 Offer II 004. 只出现一次的数字
- 977. 有序数组的平方,用这道题复习一下快排,并且知道双指针的最优解
- 209. 长度最小的子数组,算法会但你不一定能写出来,害
- 剑指 Offer 41. 数据流中的中位数
Java算法常用API :
String s = "fan"; s.toCharArray(); s.trim(); StringBuilder sb =new StringBuffer("can"); sb.charAt(1); sb.setCharAt(1, 'h');
sb.deleteCharAt(sb.length()-1);
List<String> strList = new ArraysList<>(); String.join(" ",strList); PriorityQueue<Integer> dump = new PriorityQueue<>((o1, o2) -> o1 - o2); //默认小顶堆 //插入堆 dump.offer(1); dump.poll(); //弹出堆顶 //字符串转int, String strInt; int rst = 0; for(int i=0;i<strInt.length();i++){ rst = rst*10+(strInt.charAt(i)-'0') } //双端队列 Deque<Integer> deque = new LinkedList<>(); //队列尾 deque.add(1);
//队列首 deque.push(3);
//队列尾 deque.remove();
//队列首 deque.pop();
//队列尾 deque.offer(1);
//数组赋值
int[] numbers = {1, 2, 3, 4, 5};
int[] numbers = new int[]{1, 2, 3, 4, 5};
int[][] a = new int[][]{{1,0},{0,1}};
//数组排序
Arrays.sort(s.toCharArrays());