前言
复习资料来自于 拉钩教育-重学数据结构与算法
时间复杂度和空间复杂度
复杂度是衡量代码运行效率的重要的度量因素。
一般而言一段代码会消耗计算时间和计算空间。不同的代码,耗时少,耗费内存少的,能给公司带来更多的收益,给用户更好的体验。综上所述,降低复杂度是每个程序员的必修课。
- 复杂度与具体的常系数无关
比如一串代码时间复杂度就是 O(n)+O(n),其实就是O(n)
- 多项式级的复杂度相加的时候,选择高者作为结果
- O(1) 表示一个特殊复杂度,与输入数据量 n 无关
空间是廉价的,而时间是宝贵的。降低时间复杂度的方法有递归、二分法、排序算法、动态规划等。而降低空间复杂度的方法,就要围绕数据结构做文章了。想法设法把昂贵的时间复杂度向空间复杂度的转移,需要设计合理的数据结构。
线性表
线性表的范围比较宽泛,参考下图
顺序表
以数组形式保存,表中的节点依次存放在计算机内存中一组地址连续的存储单元中。比如ArrayList就是顺序表,底层是数组,但是通过新建数组、拷贝数据、设置引用等一系列操作实现了可扩容的功能。
链表
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表中每一个元素称为结点。比如LinkedList就是链表。
特殊的线性表
之所以特殊是因为有特殊的限制。比如必须先进先出。
1. 栈
Stack继承自Vector,是实现了标准的后进先出的栈。是特殊的线性表。
数据的新增、删除、查找与线性表的操作原理极为相似,时间复杂度完全一样,都依赖当前位置的指针来进行数据对象的操作。区别仅仅在于新增和删除的对象,只能是栈顶的数据结点。
单从功能上讲,数组和链表是可以代替栈的。不过但凡一样事物的产生,必有他的道理。栈的操作受限,暴露的可操作性接口很少,服务器一旦受到攻击,风险小很多。
经典案例
给定一个只包括 ‘(’,’)’,’{’,’}’,’[’,’]’ 的字符串,判断字符串是否有效。有效字符串需满足:左括号必须与相同类型的右括号匹配,左括号必须以正确的顺序匹配。例如,{ [ ( ) ( ) ] } 是合法的,而 { ( [ ) ] } 是非法的。
public static void main(String[] args) {
String s = "{[()()]}";
System.out.println(isLegal(s));
}
private static int isLeft(char c) {
if (c == '{' || c == '(' || c == '[') {
return 1;
} else {
return 2;
}
}
private static int isPair(char p, char curr) {
if ((p == '{' && curr == '}') || (p == '[' && curr == ']') || (p == '(' && curr == ')')) {
return 1;
} else {
return 0;
}
}
private static String isLegal(String s) {
Stack stack = new Stack();
for (int i = 0; i < s.length(); i++) {
char curr = s.charAt(i);
if (isLeft(curr) == 1) {
stack.push(curr);
} else {
if (stack.empty()) {
return "非法";
}
char p = (char) stack.pop();
if (isPair(p, curr) == 0) {
return "非法";
}
}
}
if (stack.empty()) {
return "合法";
} else {
return "非法";
}
}
2. 队列
与栈类似,不同之处是队列是先进先出。
与线性表、栈一样,队列也存在两种存储方式,即顺序队列和链式队列:
- 顺序队列,依赖数组来实现,其中的数据在内存中也是顺序存储。
- 链式队列,依赖链表实现,其中的数据依赖每个结点的指针互联,在内存中不是顺序存储。链式队列,实际上就是只能尾进头出的线性表的单链表。
实现一个有 k 个元素的顺序存储的队列,我们需要建立一个长度比 k 大的数组,以便把所有的队列元素存储在数组中。
队列有两种存储形式:顺序存储和链式存储。采用顺序队列存储的队列称为顺序队列,采用链式存储的队列称为链式队列。顺序队列采用数组存储队列中的元素,使用两个指针尾指针(rear)和头指针(front)分别指向队列的队头和队尾。使用顺序队列由于在操作时会出现“假溢出现象”,所以可以使用顺序循环队列合理的使用队列空间。链式队列使用链表来实现,链表中的数据域用来存放队列中的元素,指针域用来存放队列中下一个元素的地址,同时使用队头指针指向队列的第一个元素和最后一个元素。
关于“假溢出”见下图
解决办法也很多,如开辟足够大的控件、不惜花费时间移动数据、循环队列(变量 flag 来区别队列是空还是满,移动front和rear指针实现)。
链式队列注意点:头节点不存储数据,只是用来辅助标识。
举个例子,链表除去头结点外只剩一个元素,那么删除仅剩的一个元素后,如果没有头结点做辅助标识,那rear指正就变野指针了。
案例-约瑟夫环问题
已知 n 个人(以编号 1,2,3…n 分别表示)围坐在一张圆桌周围。从编号为 k 的人开始报数,数到 m 的那个人出列;他的下一个人又从 1 开始报数,数到 m 的那个人又出列;依此规律重复下去,直到圆桌周围的人全部出列。打印出列标号。
设k=2,n=10,m=5
分析问题
- 应该使用循环队列,定义的循环队列长度大于等于10
- 从编号为k的人开始报数,所以从小于k的元素,需要添加到队列尾部去,这里一个for循环。
- 需要一个变量i记录当前的报数,如果i<5,则放到队尾(也就是入队),i=5为出队条件,出队重置i为1,从1开始报数,继续循环。
- 队列为空则所有人都出队列了。
数学问题-约瑟夫环示例代码
public static void main(String[] args) {
ring(10, 5);
}
public static void ring(int n, int m) {
LinkedList<Integer> q = new LinkedList<Integer>();
for (int i = 1; i <= n; i++) {
q.add(i);
}
int k = 2;
int element = 0;
int i = 1;
for (; i<k; i++) {
element = q.poll();
q.add(element);
}
i = 1;
while (q.size() > 0) {
//poll为队列数据结构实现类方法,意思是从队首获取元素,并把这个元素从原队列删除。顺带一提栈pop方法,获取栈顶元素并将其从栈顶删除。
element = q.poll();
if (i < m) {
q.add(element);
i++;
} else {
i = 1;
System.out.println(element);
}
}
}
数组
数组是数据结构中的最基本结构。数组在内存中是连续存放的。(因为数组在内存中是连续存放的,在数组中间某处插入数据或者删除某个数据,都会影响后面的元素,故时间复杂度比末尾操作高)
- 新增操作的时间复杂度
增加:若插入数据在最后,则时间复杂度为O(1);如果中间某处插入数据,则时间复杂度为 O(n)。 - 删除:对应位置的删除扫描全数组,时间复杂度为 O(n),末尾删除则时间复杂度O(1).
- 查找:根据索引值进行一次查找,时间复杂度是 O(1),查找满足指定条件的某个数据,时间复杂度为O(n)。
友情提示
对于增删改查,很多高级编程语言都已经封装了响应的函数方法。比如,新增系列的push(), unshift(), concat() 和 splice(),删除系列的 pop(),shift()和slice(),查找系列的 indexOf() 和 lastIndexOf()等等。但是不要被迷惑了,时间复杂度并没有减少。底层原理是我们需要牢记的。
小结
- 从数据存储的角度,使用顺序表和链表的数据在物理存储单元上存储方式有根本区别,一个是连续地址,一个不连续。
- 顺序表底层是由数组实现的。
- 合理使用java集合框架中的数据类型能提高开发效率,从事物本质出发,不管他封装的如何漂亮,时间复杂度并不会减少,还是由其底层实现数据决定。
- 栈和队列是特殊的线性表,注意是线性表。底层实现可以是数组也可以是链表(Node节点)。
- 一种数据结构的产生必有他的道理,数组和链表是可以代替栈的,但是为啥会产生栈呢,因为他的限制性大,正是这些限制性,让服务器防御力增加了。
字符串
字符串(string) 是由 n 个字符组成的一个有序整体( n >= 0 )。
几个概念:空串、空格串、子串、主串(原串)。都挺好理解的,提一下字串:串中任意连续字符组成的字符串叫作该串的子串。
字符串的存储结构
与线性表类似,有线性存储和链式存储。
线性存储一般串值后面加一个不计入串长度的结束标记符,比如 \0 。
链式存储最后一个结点未被占满时,可以使用 “#” 或其他非串值字符补全。
String底层实现的思考
为啥存储方式会有连续物理地址和不连续2种?jdk源码应该是固定的啊,后来查了一下资料。
Java无论是语言规范还是JVM规范都没有限定java.lang.String的背后一定要用char[]来作为实际的存储容器。只要背后的存储足够支撑java.lang.String表面上的API就可以了。
Sun/Oracle JDK就有几种不同的实现:
- 用char[]并且支持子串共享:这是Sun JDK 1.0一直到JDK6的默认实现。
- 用char[],但不支持子串共享:这是Oracle JDK7开始的实现方式;
- 可选用byte[]:这是在Sun JDK6上使用-XX:+UseCompressedStrings时的做法,应对场景是只包含ASCII范围内的字符的字符串;
- 用byte[]:这是Oracle JDK9开始的实现方式,叫做Compact Strings:JEP 254: Compact Strings
新增和删除
不多说,时间复杂度参考数组。
查找
示例:s = “goodgoogle”,判断字符串 t = “google” 在 s 中是否存在。
分析:是否存在也就是,子串查找或字符串匹配。从实现的角度来看,需要两层的循环。第一层循环,去查找第一个字符相等的位置,第二层循环基于此去匹配后续字符是否相等。时间复杂度为O(mn),m和n分别为2个字符串长度。
示例:假设有且仅有 1 个最大公共子串,a = “13452439”, b = “123456”,求最大公共字串。
思路:先用两层循环去找共同出现的字符,然后再开始找给公共出现的字符串,不断更新最大串的数据。
public void s2() {
String a = "123456";
String b = "13452439";
String maxSubStr = "";
int max_len = 0;
for (int i = 0; i < a.length(); i++) {
for (int j = 0; j < b.length(); j++){
if (a.charAt(i) == b.charAt(j)){
for (int m=i, n=j; m<a.length()&&n<b.length(); m++,n++) {
if (a.charAt(m) != b.charAt(n)){
break;
}
if (max_len < m-i+1){
max_len = m-i+1;
maxSubStr = a.substring(i, m+1);
}
}
}
}
}
System.out.println(maxSubStr);
}
树和二叉树
树是啥?
树是由结点和边组成的,不存在环的一种数据结构。
名词解释:
- 父节点、子节点、兄弟节点、根节点、叶子节点。
- 深度:从根算起的,树的层次越多,深度越深。
二叉树
二叉树中,每个结点最多有两个分支,即每个结点最多有两个子结点,分别称作左子结点和右子结点。
满二叉树:除了叶子结点外,所有结点都有 2 个子结点。
完全二叉树:除了最后一层以外,其他层的结点个数都达到最大,并且最后一层的叶子结点都靠左排列。
存储二叉树方法
- 链式存储法
- 顺序存储法
对于一个非完全二叉树而言,使用顺序存储并没有利用好数组的存储空间。如图
二叉查找树
二叉查找树(Binary Search Tree),也称有序二叉树(ordered binary tree),二叉排序树(sorted binary tree)。
空树或者具有以下性质的非空树
- 若任意节点的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
- 若任意节点的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
- 任意节点的左、右子树也分别为二叉查找树。
- 没有键值相等的节点(no duplicate nodes)。
- 每个节点最多只有两个子树
平衡二叉树:空树或者任何一个结点的左子树与右子树都是平衡二叉树,并且高度之差的绝对值不超过1。
二叉查找树的时间复杂度
查找
一般n个节点的平衡二叉查找树深度计算:lg(n+1)/lg2,比如7个节点,深度为3。其查找的时间复杂度为O(lg(n)/lg2(近似对半查找),从根节点开始比较,依次往下。
如果二叉排序树完全不平衡,则其深度可达到n,其查找的时间复杂度为O(n),见下图。
插入和删除
插入和删除可能造成结构发生改变,影响树的平衡性,时间复杂度介于O(lg(n)/lg2)到O(n)之间。插入好说,删除可能会复杂一点。打个比方,加入需要删除的节点是有左儿子和右儿子的,那么在删除前还需要替换以下,如图所示:
二叉树的遍历
- 二叉树的前序遍历(最简单)
前序遍历按照“根结点-左孩子-右孩子”的顺序进行访问。 - 二叉树的中序遍历
中序遍历按照“左孩子-根结点-右孩子”的顺序进行访问。 - 二叉树的后序遍历(最难)
后序遍历按照“左孩子-右孩子-根结点”的顺序进行访问。
后话
为了提高二叉排序树的查找效率,需要把树构建得更为平衡,从而不出现左右偏重的情况。
这就引出了AVL树和红黑树这两种平衡二叉树了。
红黑树
红黑树本质上是一种二叉查找树,但它在二叉查找树的基础上额外添加了一个标记(颜色),同时具有一定的规则。这些规则使红黑树保证了一种平衡,插入、删除、查找的最坏时间复杂度都为 O(logn)。
它的统计性能要好于平衡二叉树(AVL树),因此,红黑树在很多地方都有应用。比如在 Java 集合框架中,很多部分(HashMap, TreeMap, TreeSet 等)都有红黑树的应用,这些集合均提供了很好的性能。
特性:
- 每个节点要么是红色要么是黑色
- 根节点是黑色
- 所有的叶子节点(NIL节点)都是黑色
- 每个红色节点的两个子节点一定是黑色
- 任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点
补充1:B树
B树是对二叉查找树的改进。它的设计思想是,将相关数据尽量集中在一起,以便一次读取多个数据,减少硬盘操作次数。特点如下:
- 一个节点可以容纳多个值。
- 除非数据已经填满,否则不会增加新的层。也就是说,B树追求"层"越少越好。
- 子节点中的值,与父节点中的值,有严格的大小对应关系。一般来说,如果父节点有a个值,那么就有a+1个子节点。
这种数据结构,非常有利于减少读取硬盘的次数。假定一个节点可以容纳100个值,那么3层的B树可以容纳100万个数据,如果换成二叉查找树,则需要20层!假定操作系统一次读取一个节点,并且根节点保留在内存中,那么B树在100万个数据中查找目标值,只需要读取两次硬盘。
补充2:B+树
B+树是对B树的扩展,特点在于非叶子节点不存储data,只存储key。特点如下:
- 非叶子节点只存储key,叶子节点不存储指针。
- 每一个节点大小固定,需要一次读磁盘操作。
B+树所有的叶子节点数据构成了一个有序链表,在查询大小区间的数据时候更方便只需要水平指针依次向右遍历。
补充3:字典树
又称Trie 树,有如下特点
- 根节点不包含字符
- 除根节点外每个节点都包含一个字符
- 从根节点到任意叶子节点路径
二叉树遍历(前序、中序、后序、层次遍历、深度优先、广度优先
先来个示例-层次遍历
public static void levelTraverse(Node root) {
if (root == null) {
return;
}
LinkedList<Node> queue = new LinkedList<Node>();
Node current = null;
queue.offer(root); // 根节点入队
while (!queue.isEmpty()) { // 只要队列中有元素,就可以一直执行,非常巧妙地利用了队列的特性
current = queue.poll(); // 出队队头元素
System.out.print("-->" + current.data);
// 左子树不为空,入队
if (current.leftChild != null)
queue.offer(current.leftChild);
// 右子树不为空,入队
if (current.rightChild != null)
queue.offer(current.rightChild);
}
}
哈希表
前面我们提到了数组、字符串、线形表和树,并简单分析了他们对于数据的增删改查操作。
小结:
- 线性表中的栈和队列对数据增删有着严格要求,他们重视顺序。
- 数组和字符串注重数据类型的统一,并且基于索引的查找上会有优势。
- 树的优势在数据的层次结构上。
但是他们按条件查找数据的时候,都需要全局或者局部遍历。省去数据比较的过程,查找神器哈希表应运而生。
核心思想
之前我们学过的数据结构里,存储位置和数据的具体值之间没有任何关系。所以查找的时候需要去逐一比较。而哈希表使用了函数映射的思想,将存储位置与记录的关键字关联起来。
了解了函数映射思想,再来回顾数组的查询操作,根据索引取出数值,也就是说数组完成了索引值到实际地址的映射(即地址=f(index)),但是他的局限性在于只能根据索引查,不能根据数据数值去查。
如果有一种方法,可以实现“地址=f(关键字)”的映射关系,那么就可以快速完成基于数据的数值的查找了。这就是哈希表的核心思想。
不难看出Hash函数设计的好坏会直接影响到对哈希表的操作效率。
哈希冲突
举个例子,对一个手机通讯录进行存储,并要根据姓名找出一个人的手机号码,信息如下
张三 13900000001
李四 13900000002
王五 13600000001
章山 13855555555
如果映射函数设计为:姓名的每个字的拼音开头大写字母的 ASCII 码之和。
address (张三) = ASCII (Z) + ASCII (S) = 90 + 83 = 173;
address (李四) = ASCII (L) + ASCII (S) = 76 + 83 = 159;
address (王五) = ASCII (W) + ASCII (W) = 87 + 87 = 174;
address (章山) = ASCII (Z) + ASCII (S) = 90 + 83 = 173;
分析:张三、李四、王五3条数据是没问题的,但是“章山”不行。这就发生了哈希冲突。理论上来说,哈希冲突只能尽可能减少,不能完全避免。这是因为,输入数据的关键字是个开放集合。只要输入的数据量够多、分布够广,就完全有可能发生冲突的情况。因此,哈希表需要设计合理的哈希函数,并且对冲突有一套处理机制。
如何设计哈希函数
下面列举几种常用的设计方法。
- 直接定制法
使用关键字到地址的线性函数。示例如下:
H (key) = a*key + b,a、b为常数
-
数字分析法
每个关键字的key都是由s个数字组成(k1,k2,…,Ks),从中提取分布均匀的若干位组成哈希地址。示例就是上面手机通讯录张三。 -
平方取中法
如果关键字的每一位都有某些数字重复出现,并且频率很高,我们就可以先求关键字的平方值,通过平方扩大差异,然后取中间几位作为最终存储地址。 -
折叠法
如果关键字的位数很多,可以将关键字分割为几个等长的部分,取它们的叠加和的值(舍去进位)作为哈希地址。 -
除留余数法
预先设置一个数 p,然后对关键字进行取余运算。即地址为 key mod p。
如何解决哈希冲突
首先说明一下,在很多高级语言中,哈希函数、哈希冲突都已经在底层完成了黑盒化处理,是不需要开发者自己设计的。也就是说,哈希表完成了关键字到地址的映射,可以在常数级时间复杂度内通过关键字查找到数据。关于他是如何实现的,不需要开发者关注。
常用方法
- 开放地址法
使用某种探测技术在哈希表中形成一个探测序列,然后沿着这个探测序列依次查找下去。当碰到一个空的单元时,则插入其中。(常用的探测方法是线性探测法。)
例 1,将关键字序列 {7, 8, 30, 11, 18, 9, 14} 存储到哈希表中。哈希函数为: H (key) = (key * 3) % 7,处理冲突采用线性探测法。
H(7)=(7*3)%7=0
H(8)=(8*3)%7=3
H(30)=(30*3)%7=6
H(11)=(11*3)%7=5
H(18)=(18*3)%7=5
H(9)=(9*3)%7=6
H(14)=(14*3)%7=0
存也很好存,没有冲突直接存我们算出来的地址就行了,遇到冲突的就根据线形探测法,往后查,有空单元就插入。见下图
查也好查,根据函数算出地址,去地址取值,跟待匹配的关键字比较,不一样则根据线形探测法往后面地址取值,直到查到。
- 链地址法
补充1:例子-two sums
给定一个整数数组 arr 和一个目标值target,请你在该数组中找出加和等于目标值的那两个整数,并返回它们的在数组中下标。
分析一波:不需要返回多个下标组合。
public class ex {
public static void main(String[] args) {
twoSum(new int[] { 1, 2, 3, 4, 5, 7 }, 9);
// twoSumEx(new int[] { 1, 2, 3, 4, 5, 7}, 9);
}
/**
* 暴力解法,2层循环,时间复杂度为O(n*n),空间复杂度为O(1)
*/
public static int[] twoSum(int[] arr, int target) {
for (int i = 0; i < arr.length; i++) {
int f = arr[i];
for (int j = (arr.length - 1); j >= 0; j--) {
if ((arr[j] == target - f) && i != j) {
System.out.println(i + "和" + j);
return new int[]{i,j};
}
}
}
return new int[0];
}
/**
* 把时间复杂度往空间复杂度上转,使用哈希表来解
*/
public static int[] twoSumEx(int[] arr,int target){
HashMap<Integer,Integer> map=new HashMap<>(16);
for (int i = 0; i < arr.length; i++) {
//关于冲突不用管,java有他自己策略
map.put(arr[i], i);
}
for(int i=0;i<arr.length;i++){
int temp=target-arr[i];
if(map.containsKey(temp)&&i!=map.get(temp)){
System.out.println(i+"和"+map.get(temp));
return new int[]{i,map.get(temp)};
}
}
return new int[0];
}
}
递归
概念:数学与计算机科学中,递归 (Recursion))是指在函数的定义中使用函数自身的方法。
两层含义:
- 递归问题必须可以分解为若干个规模较小、与原问题形式相同的子问题。并且这些子问题可以用完全相同的解题思路来解决;
- 递归问题的演化过程是一个对原问题从大到小进行拆解的过程,并且会有一个明确的终点(临界点)。一旦原问题到达了这个临界点,就不用再往更小的问题上拆解了。最后,从这个临界点开始,把小问题的答案按照原路返回,原问题便得以解决。
总结:基本思想就是把规模大的问题转化为规模小的相同的子问题来解决;解决问题的函数必须有明确的结束条件。
算法思想
递归的数学模型其实就是数学归纳法。
示例-树的中序遍历
- 特点1:对树中的任意结点来说,先中序遍历它的左子树,然后打印这个结点,最后中序遍历它的右子树。
- 特点2:某个结点没有左子树和右子树时,则直接打印结点,完成终止。
这不是正好符合递归的要求嘛。
//伪代码,Node类需要自己实现 可参考TreeMap源码
public void travelsMiddle(Node node){
if(node==null){
return ;
}
travelsMiddle(node.left);
System.out.println(node.data+"");
travelsMiddle(node.right);
}
分治
分治法的价值
很多人有一个误区,认为计算机性能还不错的时候,采用分治法相对于全局遍历一遍没有什么差别。但是实际情况是,在大数据集合重分治对性能有爆发式的提升。
什么时候使用分治法
一般原问题具有以下几点时候:
- 难度在降低:原问题的解决难度随着数据量减少而减小。
- 问题可分:原问题可以分解为若干个规模较小的同类型问题。
- 解可合并:所有子问题的解可以合并成原问题的解。
- 相互独立:子问题间相互独立,不会相互影响。
常见示例:在数组 { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 } 中,查找 8 是否出现过
分析一波:数组元素有序并且逐渐增大,所以可以使用二分法来解决。
public static void main(String[] args) {
// 需要查找的数字
int targetNumb = 8;
// 目标有序数组
int[] arr = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
int middle = 0;
int low = 0;
int high = arr.length - 1;
int isfind = 0;
while (low <= high) {
middle = (high + low) / 2;
if (arr[middle] == targetNumb) {
System.out.println(targetNumb + " 在数组中,下标值为: " + middle);
isfind = 1;
break;
} else if (arr[middle] > targetNumb) {
// 说明该数在low~middle之间
high = middle - 1;
} else {
// 说明该数在middle~high之间
low = middle + 1;
}
}
if (isfind == 0) {
System.out.println("数组不含 " + targetNumb);
}
}
排序
排序,就是让一组无序数据变成有序的过程。一般情况下的有序指的是从小到大。
衡量一个排序算法优劣,从时间复杂度、空间复杂度、稳定性来分析,其中稳定性指的是对于相等的数据对象,排序后顺序是否能保持不变。
稳定的排序算法:冒泡排序、插入排序、归并排序
不稳定的排序算法:快速排序
冒泡排序
简单来说比较相邻的元素。如果第一个比第二个大,就交换他们两个。名字的由来就是因为越小的元素会经由交换慢慢"浮"到数列的顶端。通过多轮迭代,直到没有交换操作为止。(执行一次循环,右边固定的数据就+1)
时间复杂度不做评价,最快O(n),一般情况O(n*n)
#include <stdio.h>
void bubble_sort(int arr[], int len) {
int i, j, temp;
for (i = 0; i < len - 1; i++)
for (j = 0; j < len - 1 - i; j++)
if (arr[j] > arr[j + 1]) {
temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
int main() {
int arr[] = { 22, 34, 3, 32, 82, 55, 89, 50, 37, 5, 64, 35, 9, 70 };
int len = (int) sizeof(arr) / sizeof(*arr);
bubble_sort(arr, len);
int i;
for (i = 0; i < len; i++)
printf("%d ", arr[i]);
return 0;
}
归并排序
归并排序的实现由两种方法:
- 自上而下的递归(所有递归的方法都可以用迭代重写,所以就有了下面这种方法);
- 自下而上的迭代;
快速排序
本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法。他是处理大数据最快的排序算法之一。
重要概念:“基准”(pivot)
选择排序
动态规划
回顾,分治法的使用必须满足 4 个条件:
- 问题的解决难度与数据规模有关;
- 问题可被分解;
- 子问题的解可以合并为原问题的解;
- 所有的子问题相互独立。
但是现实生活中很多问题能满足前3个条件,但是子问题间不是相互独立的。这样就不能使用分治法,这时候就需要使用动态规划。
动态规划是一种运筹学方法,是在多轮决策过程中的最优方法。
示例:最短路径问题
分阶段,找状态,做决策,写方程,寻找终止条件。
public class testpath {
public static int minPath1(int[][] matrix) {
return process1(matrix, matrix[0].length-1);
}
// 递归
public static int process1(int[][] matrix, int i) {
// 到达A退出递归
if (i == 0) {
return 0;
}
// 状态转移
else{
int distance = 999;
for(int j=0; j<i; j++){
if(matrix[j][i]!=0){
int d_tmp = matrix[j][i] + process1(matrix, j);
if (d_tmp < distance){
distance = d_tmp;
}
}
}
return distance;
}
}
public static void main(String[] args) {
int[][] m = {{0,5,3,0,0,0,0,0,0,0,0,0,0,0,0,0},{0,0,0,1,3,6,0,0,0,0,0,0,0,0,0,0},{0,0,0,0,8,7,6,0,0,0,0,0,0,0,0,0},{0,0,0,0,0,0,0,6,8,0,0,0,0,0,0,0},{0,0,0,0,0,0,0,3,5,0,0,0,0,0,0,0},{0,0,0,0,0,0,0,0,3,3,0,0,0,0,0,0},{0,0,0,0,0,0,0,0,8,4,0,0,0,0,0,0},{0,0,0,0,0,0,0,0,0,0,2,2,0,0,0,0},{0,0,0,0,0,0,0,0,0,0,0,1,2,0,0,0},{0,0,0,0,0,0,0,0,0,0,0,3,3,0,0,0},{0,0,0,0,0,0,0,0,0,0,0,0,0,3,5,0},{0,0,0,0,0,0,0,0,0,0,0,0,0,5,2,0},{0,0,0,0,0,0,0,0,0,0,0,0,0,6,6,0},{0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4},{0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3}};
System.out.println(minPath1(m));
}
}
分析一波
- 二位数组,matrix[j][i]!=0时候证明起码是通路的。
- matrix[0].length-1代表6步到达,所以递归次数就是6。
- 动态规划的过程中,是从后往前不断地推进结果,这就是状态转移的过程。
- i减小到0即终止条件