本章主要以算法思想介绍为主,具体算法实现可参考算法系列文章
一、算法思想
1、 分治法
分治法即分而治之,将一个复杂问题分解为若干个相同的
子问题,把子问题可以继续分解,直到求出简单子问题解,
把解逐个合并。
典型案例
- 快速排序
- 二分搜索法
2、动态规划
动态规划也是将问题分解为若干子问题,不同的是,动态规划的分解子问题存在依赖关系,前一个局部解为后一个的前提,最后一个接为问题最终结果。
动态规划比较适合用来求解最优问题,比如求最大值、最小值等等。它可以非常显著地降低时间复杂度,提高代码的执行效率。动态规划解决问题的思路。我们把问题分解为多个阶段,每个阶段对应一个决策。我们记录每一个阶段可达的状态集合(去掉重复的),然后通过当前阶段的状态集合,来推导下一个阶段的状态集合,动态地往前推进。
解题思路:
- 一般能用动态规划解决的问题,都可以使用回溯算法的暴力搜索解决。所以,当我们拿到问题的时候,我们可以先用简单的回溯算法解决,然后定义状态,每个状态表示一个节点,然后对应画出递归树。从递归树中,我们很容易可以看出来,是否存在重复子问题,以及重复子问题是如何产生的。以此来寻找规律,看是否能用动态规划解决。
- 找到重复子问题之后,接下来,我们有两种处理思路,
第一种是直接用回溯加“备忘录”的方法,来避免重复子问题。从执行效率上来讲,这跟动态规划的解决思路没有差别。
第二种是使用动态规划的解决方法,状态转移表法。状态表一般都是二维的,所以你可以把它想象成二维数组。其中,每个状态包含三个变量,行、列、数组值。我们根据决策的先后过程,从前往后,根据递推关系,分阶段填充状态表中的每个状态。
典型案例
- 最短路径算法
3、贪心算法
在对问题求解时,考虑局部最优
4、回溯算法
深度优先搜索算法利用的是回溯算法思想。这个算法思想非常简单,但是应用却非常广泛。它除了用来指导像深度优先搜索这种经典的算法设计之外,还可以用在很多实际的软件开发场景中,很多经典的数学问题都可以用回溯算法解决,比如数独、八皇后、0-1 背包、图的着色、旅行商问题、全排列等等。
回溯的处理思想,有点类似枚举搜索。我们枚举所有的解,找到满足期望的解。为了有规律地枚举所有可能的解,避免遗漏和重复,我们把问题求解的过程分为多个阶段。每个阶段,我们都会面对一个岔路口,我们先随意选一条路走,当发现这条路走不通的时候(不符合期望的解),就回退到上一个岔路口,另选一种走法继续走。
回溯算法的思想非常简单,大部分情况下,都是用来解决广义的搜索问题,也就是,从一组可能的解中,选择出一个满足要求的解。回溯算法非常适合用递归来实现,在实现的过程中,剪枝操作是提高回溯效率的一种技巧。利用剪枝,我们并不需要穷举搜索所有的情况,从而提高搜索效率。
- 递归需要满足的三个条件:
- 一个问题的解可以分解为几个子问题的解
- 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
- 存在递归终止条件
-
递归代码要警惕重复计算:
为了避免重复计算,我们可以通过一个数据结构(比如散列表)来保存已经求解过的 f(k)。当递归调用到 f(k) 时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算,这样就能避免刚讲的问题了。 -
怎么将递归代码改写为非递归代码
递归本身就是借助栈来实现的,只不过我们使用的栈是系统或者虚拟机本身提供的,我们没有感知罢了。如果我们自己在内存堆上实现栈,手动模拟入栈、出栈过程,这样任何递归代码都可以改写成看上去不是递归代码的样子。
5、分支限界法
类似于回溯算法,不同的是回溯是求解所有解,分支限界是找出满足
条件的一个解。
6、对比
回溯算法是个“万金油”。基本上能用的动态规划、贪心解决的问题,我们都可以用回溯算法解决。回溯算法相当于穷举搜索。穷举所有的情况,然后对比得到最优解。不过,回溯算法的时间复杂度非常高,是指数级别的,只能用来解决小规模数据的问题。对于大规模数据的问题,用回溯算法解决的执行效率就很低了。
动态规划比回溯算法高效,但是,并不是所有问题,都可以用动态规划来解决。能用动态规划解决的问题,需要满足三个特征,最优子结构、无后效性和重复子问题。在重复子问题这一点上,动态规划和分治算法的区分非常明显。分治算法要求分割成的子问题,不能有重复子问题,而动态规划正好相反,动态规划之所以高效,就是因为回溯算法实现中存在大量的重复子问题。
贪心算法实际上是动态规划算法的一种特殊情况。它解决问题起来更加高效,代码实现也更加简洁。不过,它可以解决的问题也更加有限。它能解决的问题需要满足三个条件,最优子结构、无后效性和贪心选择性.
- 贪心:一条路走到黑,就一次机会,只能哪边看着顺眼走哪边
- 回溯:一条路走到黑,无数次重来的机会,还怕我走不出来 (Snapshot View)
- 动态规划:拥有上帝视角,手握无数平行宇宙的历史存档, 同时发展出无数个未来
二、存储结构
1、 线性存储
必须先分配固定长度内存,查找数据时需要逐个
遍历对比,查询效率较差。
2、 散列存储
为了提高访问效率,使用间接存储地址的方式
来实现对数据快速访问。实际通常采用hash算法
为取模,即通过直接对数据计算获取存储地址,
访问数据量较大时速度快。但随之增加存储
地址的空间,增大内存消耗。
三、线性存储结构—特殊访问顺序
1、栈
数据的先入后出,作为临时数据存储
典型案例:
递归的非递归实现
数据的逆序访问
2、队列
数据的先入先出,保证顺序性
典型案例:
生产消费模型
循环队列
3、链表
在线性存储结构中能快速删除、插入数据,
在双向链表中可以快速访问局部数据。
四、排序算法
1、算法性能评估
- 时间复杂度
这里给出两个典型复杂度计算案例:
复杂度为:O(n^2)
遍历方式为双层嵌套,遍历次数为n^2
for(int i=0;i<n;i++){
for(int i=0;i<n;i++){
}
}
复杂度为O(n^2)
遍历次数为n的阶乘,n!=((n+1)/2)*n约为n^2
for(int i=0;i<n;i++){
for(int j=i;j<n;i++){
}
}
复杂度为O(nlogn)
logn表示每次进行折半,典型树形结构就是如此
for(int i=0;i<n;i++){
for(int j=0;j<n;i++){
j = 2*j
}
}
- 空间复杂度
与时间复杂度计算类似 - 稳定性(排序算法)
评估数据相对位置变化,避免多余的比较
2、基于数据结构的排序
1、桶排序
固定数量的桶容器,并且待排序的数据范围确定
适合于数量大且范围较小排序,即桶的个数少
时间复杂度为排序算法中较好的。
2、堆排序
3、基于访问顺序排序
主要是通过比较和交换实现排序
1、冒泡排序
其核心是比较相邻元素,
将最大或最小值向未排序部分的尾部移动,
即向上浮动。
4、排序算法详细分析
- 冒泡排序
冒泡排序是每个相邻的数逐个比较
例:
1 4 2 5 3
完全无序数组,利用冒泡排序
在数量较大时,时间复杂度将达到O(n2)
再看一个特殊数组:
例:
1 2 3 4 6 5 0
这个数组排序可以优化吗?显然是可以的,
6之前数列已经有序,在第一次排序时,记录
交换的最低位置即6,下次遍历以6位置开始。
还能不能再优化,我们先考虑数组是线性
且长度固定,那么从数组两侧比较是不是
循环次数就会减少一半:
1 4 2 5 3
1 4 2 3 5
1 2 3 4 5
- 快速排序
冒泡排序在于每次排序完成,只是相对有序
部分数组没有达到最终有序的位置,
那能不能减少重复排序的次数,
在双端排序基础,每次确定一个数位置,
然后再对剩下无序的数据比较,基于这个
思想就是快速排序。
1 4 2 5 3
以index=0,1作为基准值
1 4 2 5 3
2 3 4 5
2 3 5
2
1 2 3 4 5
可以看到快速排序是一种跳跃式排序
而冒泡排序是相邻排序,我们再来
看一组快速排序:
6 5 4 3 2 1
1 5 4 3 2 6
1 4 3 2 5 6
1 3 2 4 5 6
1 2 3 4 5 6
可以看到再完全逆序时,
排序算法的复杂度最大O(n2)。
平均时间复杂度O(nlogn),
空间复杂度最大为O(n)
平均为O(logn)
针对逆序时,时间复杂度问题,
我们可以改变基准值的选择,
这样还是利用二分法思想。
每次取中间值作为基准值。
我们在看一个案例:
1 6 3 7 3 3 5 4 2
以中间值index=5,3为基准值
1 6 3 7 2 3 5 4 —
1 — 3 7 3 3 5 4 6
1 3 - 7 3 3 5 4 6
1 3 3 7 3 — 5 4 6
1 3 3 - 3 3 5 4 6
1 3 3 3 3 — 5 4 6
1 3 3 3 3 5 4 6
这种情况原因是存在多个等于
基准值的数,如果要讲基准值
排到有序位置,只能将数据分为
三部分区域,大于等于小于
1 6 3 7 3 3 5 4 2
这种处理思路与荷兰国旗基本一致
- 插入与选择排序
插入排序:
插入排序在未排序数组部分
取出数字向有序的部分
逐个相邻数字比较进行插入,
当然也是一种稳定排序。
插入排序适合有序数组的排序。
插入排序的改进—二分插入排序
由于插入是向有序的数据部分插入
,这里我们会想到二分法插入,
这是一种优化方式。
插入排序的增强—希尔排序
根据插入排序的特定,
希尔排序在两个方面做了优化
一是设置增量,
二是增量缩减。
选择排序:
选择排序与插入相反
选择排序是从无序的部分
选出数组加入到有序部分。
选择元素时是以第一个元素为
基础,挑选出最大或最小值,
而不是相邻元素的比较。
也就是说它是不稳定的算法。
五、搜索算法
1、顺序查找
顺序查找:
顺序查找一次对数组的
每个元素遍历比较。
没有什么优点:
这里我们仅看一下顺序查找优化,
这个优化如下:
原:
for (int i = 0; i < arr.length; i++) {
if (arr[i] == num){
}
}
优化:
int first = arr[0];
if (first == num){
return 0;
}
arr[0] = num;
int i = arr.length-1;
while (arr[i] != num){
i--;
}
arr[0] = first;
这种方式其实只是减少比较的次数
在我们代码优化中可以借鉴这种思想。
2、二分查询
二分查找:
二分法是对有序顺序结构查找;
时间复杂度为O(logn)
二分法关键点在于
与中位数比较的条件,
可分为以下三种:
public static int binarySearch1(int[] arr, int num) {
int start = 0;
int end = arr.length - 1;
int index = -1;
// 这个条件决定是否后续处理
while (start <= end) {
// 最后剩下3个数据: 1 2 3 ,num = 2:正好查到
// 2个数据:1 4,
// num = 1: 正好查到,start + 1 = end
// num = 4: 需要再遍历一次,start = end
int mid = (start + end) / 2;
if (arr[mid] == num) {
index = mid;
break;
} else if (arr[mid] > num) {
end = mid - 1;
} else {
start = mid + 1;
}
}
System.out.println(start + ":" + end);
return index;
}
public static int binarySearch2(int[] arr, int num) {
int start = 0;
int end = arr.length - 1;
int index = -1;
while (start < end) {
// 最后剩下3个数据: 1 2 3 ,num = 2:正好查到
// 2个数据:1 4,
// num = 1: 正好查到,start + 1 = end
// num = 4: 需要外部处理, start = end
int mid = (start + end) / 2;
if (arr[mid] == num) {
index = mid;
break;
} else if (arr[mid] < num) {
start = mid + 1;
} else {
end = mid - 1;
}
}
if (arr[end] == num){
index = end;
}
System.out.println(start + ":" + end);
return index;
}
public static int binarySearch3(int[] arr, int num) {
int start = 0;
int end = arr.length - 1;
int index = -1;
while (start + 1 < end) {
// 最后剩下3个数据: 1 2 3
// num = 2:正好查到
// num = 1: 外部检查start
// num = 3: 外部检查end
int mid = (start + end) / 2;
if (arr[mid] == num) {
index = mid;
break;
} else if (arr[mid] < num) {
start = mid -1 ;
} else {
end = mid + 1;
}
}
if (arr[start] == num){
index = start;
}
if (arr[end] == num){
index = end;
}
System.out.println(start + ":" + end);
return index;
}
3、分块搜索
对于数据顺序变化不大的,采用二分查找最佳
对于那些经常变动的数组,数据的有序性会不断
被打乱。这里要保证整体有序性可以采用分区
思想,将数据按分块方式存储,保证整体的有序性。
分块查找包括两部分:
索引:索引部分保持有序可以使用二分法,每个索引存储块的最大或最小值
数据块:由于数据块的范围较小,可采取适当的查找算法
这种算法尽量要限制每个块的大小,避免在块内查找的耗时,
实际业务中的分表也是类似这种思想。
4、倒排索引
搜索引擎搜索数据使用算法是倒排索引,
其核心是对数据分词与建立索引表,例如:
doc-1:I am link
doc-2:I am coder
分词有对英文中文的不同分词器,如上:
I 1,2
am 1,2
link 1
coder 2
其中分词与索引查询是搜索算法核心,
java中常用的分词插件Lucene
六、二叉树
前面主要以线性数据结构为主,接下来我们
来说说一种高效的数据结构—树形结构,
树形结构存储在计算机系统中有广泛应用,
例如数据库mysql、磁盘文件管理。
1、二叉树基础
两种特殊类型:
满二叉树、完全二叉树
存储形式:
链表:每个节点存储数据与子节点的index
数组:数组也可以实现存储但是操作较为复杂
树的很多操作都是递归实现,因此也可以转换为
非递归的栈存储实现。
对应递归操作线性的递归很好理解,
例如线性递归:链表的逆序输出;
但是树形递归比较抽象:
我们可以将树形递归拆解:
1
2 3
4 5 6 7
可分为四种情况:
左直链:1 2 4
左折链:1 2 5
右直链:1 3 7
右折链:1 3 6
可以看到链与链存在交叉重复,
上面的插拆解方式不方便分析;
我们从叶子节点分析:
无左右节点
只有左节点
只有右节点
左右均存在
递归本质是先深入,再逐步按原路径
回退,直到退出所有入栈元素。
这里我们以二叉树深度算法为例:
int deep(Node node){
if(node==null){
return 0;
}
int left = deep(node.left);
int right = deep(node.right);
return left>right?left+1?right+1;
}
如上图所示:在到达G时,将前面遍历的节点存储栈中(A\B\D),到G无子节点返回0,
此时从栈中会弹出D节点,由于D的左节点在深入时已经遍历过,此时只需从当前右节点深入。
分析树的递归流程,我们再来考虑以非递归方式实现,
非递归实现要考虑存储回退时的父级节点以及每个节点当前返回所求数据。
最初想法:
首先,先分析递归深度遍历特点:在这个遍历过程中存在左节点则一直遍历最左节点,直到不存在左节点时,
回退到父节点,转向右节点,然后继续按最左遍历原则遍历,每次从栈中弹出节点,深度就减少一层,此时
对返回结果层数加1; 假如利用非递归后续遍历,实现复杂度很大,思想与上面类似。
利用非递归层次遍历思路实现对深度计算。
// 递归
public static int getDeep(Node node){
if (node == null){
return 0;
}
int left = getDeep(node.left);
int right = getDeep(node.right);
return left>right?left+1:right+1;
}
// 层次遍历实现
public static int getDeep2(Node node){
if (node == null){
return 0;
}
LinkedList<Node> queue = new LinkedList<>();
queue.add(node);
int deep = 0;
while (!queue.isEmpty()){
int size = queue.size();
for (int i = 0; i < size; i++) {
Node tmp = queue.pop();
if (tmp.left != null){
queue.add(tmp.left);
}
if (tmp.right != null){
queue.add(tmp.right);
}
}
deep++;
}
return deep;
}
2、堆存储与排序
在介绍二叉树时,我们重点说道到
完全二叉树,这种数据结构符合
父子节点为2i关系,在限定n范围内。
因此可以通过一维数组方式存储。
A(i)
B(2i) C(2i+1)
再来分析堆数据的特点,分为
大顶堆与小顶堆;
6
4 5
1 2 3 4
数组:6 4 5 1 2 3 4
6(i=0)的子节点:4(i=1),5(i=2)
以大顶堆为例:
在插入元素时,直接放入最后一位
再逐个与其父节点比较,比父大
则进行交换,从数组角度看类似于
插入排序;删除时将尾结点移动到
根节点逐级下沉直到叶子节点。
其插入与删除复杂度均为O(logn);
利用堆实现排序:
1、调整为最大顶:将无序数组逐个插入到堆中,插入复杂度为O(logn)*n
2、逐个放入尾部: 利用删除方法,下沉最尾部未排序的数,复杂度为O(logn)*n
整体复杂度为O(nlogn)
从堆的排序特点可以看到堆非常适合数据量较大排序;
3、搜索二叉树
之前我们曾讲过二分法查找,性能达到O(logn)
但是二分法条件比较严格要求数组有序;
树形结构本质是一种二分结构,那怎么利用
树形结构实现快速查找? 我们必须要添加
限制条件保证顺序规则:
大于当前接地插入右子树
小于当前节点插入左子树
4、搜索二叉树变种—平衡二叉树
高度差大于1时进行失衡调节:
LL\RR\RL\LR总体来说调节比较简单
复杂度为O(logn)
5、搜索二叉树变种—红黑树
根节点为黑色;
红色子节点必定为黑色;
每条路径上的黑色数量相同。
其复杂度均为O(logn)
由定义可以看出假如黑色数为3,
从根节点到叶子节点最短:黑-黑-黑
最长为红黑交替:黑-红-黑-红-黑
这样就限制最常路径不可能大于最短路径的2倍
红黑树是对平衡二叉树限制的降低,
可以存在限量的不平衡,但是对任何破坏
红黑树的操作均能在三次旋转调整完成。
6、其他
B/B+树是为了应对大量数据查询;
减少磁盘IO,是对搜索树的升级。
哈夫曼树:
利用二叉树根据数据的权重
构造最有效的编码树。
7、字符串处理
排列:
字典序处理法
递归迭代处理
全排列:
递归处理法:
递归每次先选择一个数;
然后逐个对剩余部分处理;
当剩余一个时输出。
字典序法:
abcd=>1234
1、选择k=3:右侧存在大于k的值
2、选择交换值y=4:从右侧选择最小值
3、交换:大于当前值的最小值进行交换
4、反转k右测部分:将右侧递减顺序转为递增
public static void fullStr(char[] arr,int start,int end){
if (start == end){
System.out.println(Arrays.toString(arr));
} else {
for (int i = start; i <= end; i++) {
swap(arr, start, i);
fullStr(arr, start + 1, end);
swap(arr, start, i);
}
}
}
public static void swap(char[] arr, int x,int y){
char tmp = arr[y];
arr[y] = arr[x];
arr[x] = tmp;
}
反转:
abc=>cba
abcde=>adcbe
对称交换法
旋转:
abcde=>deabc
交换移动法
双重反转法
回文:
判断回文:
对称判断法:两边向内侧
中心扩展法:中间向两侧扩展
字符\数字转换计算:
注意溢出处理
8、数组系列
两数和等于k的下标:
暴力破解法
hash遍历
排序后两侧移动累加尝试
和最大连续数组:
1、暴力破解:三层循环
2、动态规划
3、滑动窗口
1 3 -4 2 5 -1
lastMax 1 4 0 2 7 6
maxSum 1 4 4 4 7 7
start 0 0 0 0 4 4
end 0 1 1 1 4 4
三分数组:
荷兰国旗
旋转数组拐点:
逐个遍历法
二分法处理
9、查找算法
查找超过一半的数:
hash表法
排序后累加法
变量计数法:相同加1,不同减1
0 1 2 1 2 1 1
(个数)n 1 0 -1 0 -1 0 1
(目标值)t 0 1 1 1 1 1 1
寻找缺失数字:
排序后判断相邻数是否差值为1
总数减去累加和
大数查找:
查找最大1w个数:
1、内存保留1w个数,并记录最小值,大于最小值替换
2、大顶堆法
查找第k大数:
快速排序定位与k比较
10、数学计算
位运算:
数字交换:通过"^"实现
比较大小:
拿球问题:
100个球,连个人轮流拿,每次最多5个,最后一个为赢家。
逆推法:
只要最后剩下6个就行;
我们先拿就拿4个;
100=6*16+4
若别人先拿,就拿剩下数
逆推,同理如上方法取。