数据结构与算法
内容导读
互联网集市收集整理的这篇技术教程文章主要介绍了数据结构与算法,小编现在分享给大家,供广大互联网技能从业者学习和参考。文章包含46610字,纯文字阅读大概需要67分钟。
内容图文
![数据结构与算法](/upload/InfoBanner/zyjiaocheng/597/f4d21ed5cded44efa19b67143ad7f50d.jpg)
目录
前言
文章内容输出来源:拉勾教育JAVA就业训练营
第一部分 数据结构与算法概述
第1节 数据结构的概念
1.1 什么是数据结构
数据结构(data structure)是计算机存储、组织数据的方式。数据结构是指相互之间存在一种或多种特定关系的数据元素的集合。(百度百科)
一句话解释:存数据的,而且是在内存中存!
1.2 常见的数据结构
第2节 算法的概念
2.1 什么是算法
- 算法(Algorithm)是指解题方案的准确而完整的描述,是一系列解决问题的清晰指令,算法代表着用系统的方法描述解决问题的策略机制。
- 一句话描述:算法是一种解决特定问题的思路
- 比如:LRU算法,最近最少使用,解决的就是当空间不够用时,应该淘汰谁的问题,这是一种策略,不是唯一的答案,所以算法无对错,只有好和不好。
2.2 常见算法
第3节 算法复杂度
3.1 时间复杂度
3.2 空间复杂度
第二部分 数据结构与算法基础
第1节 线性表
线性表(Linear List)就是数据排成像一条线一样的结构,数据只有前后两个方向
1.1 数组
概念
- 数组(Array)是有限个相同类型的变量所组成的有序集合,数组中的每一个变量被称为元素。数组是最为简单、最为常用的数据结构
存储原理
- 数组用一组连续的内存空间来存储一组具有相同类型的数据
时间复杂度
- 读取和更新都是随机访问,所以是O(1)
- 插入数组扩容的时间复杂度是O(n),插入并移动元素的时间复杂度也是O(n),综合起来插入操作的时间复杂度是O(n)。
- 删除操作,只涉及元素的移动,时间复杂度也是O(n)
优缺点
- 优点:
数组拥有非常高效的随机访问能力,只要给出下标,就可以用常量时间找到对应元素 - 缺点:
- 插入和删除元素方面。由于数组元素连续紧密地存储在内存中,插入、删除元素都会导致大量元素被迫移动,影响效率。 (ArrayList LinkedList )
- 申请的空间必须是连续的,也就是说即使有空间也可能因为没有足够的连续空间而创建失败
- 如果超出范围,需要重新申请内存进行存储,原空间就浪费了
应用
- 数组是基础的数据结构,应用太广泛了,ArrayList、Redis、消息队列等等。
数据结构和算法的可视化网站:https://www.cs.usfca.edu/~galles/visualization/Algorithms.html
1.2 链表
概念
- 链表(linked list)是一种在物理上非连续、非顺序的数据结构,由若干节点(node)所组成。
- 链表中数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。(百度百科)
- 常见的链表包括:单链表、双向链表、循环链表
存储原理
- 数组在内存中的存储方式是顺序存储(连续存储),链表在内存中的存储方式则是随机存储(链式存储)
- 链表的每一个节点分布在内存的不同位置,依靠next指针关联起来。这样可以灵活有效地利用零散的碎片空间
时间复杂度
- 查找节点 : O(n)
- 插入节点:O(1)
- 更新节点:O(1)
- 删除节点:O(1)
优缺点
- 优势
- 插入、删除、更新效率高
- 省空间
- 劣势
- 查询效率较低,不能随机访问
应用
- 链表的应用也非常广泛,比如树、图、Redis的列表、LRU算法实现、消息队列等
数组与链表的对比
- 数据结构没有绝对的好与坏,数组和链表各有千秋。
1.3 栈
栈和队列都属于线性数据的逻辑存储结构
概念
- 栈(stack)是一种线性数据结构,栈中的元素只能先入后出(First In Last Out,简称FILO)。
- 最早进入的元素存放的位置叫作栈底(bottom),最后进入的元素存放的位置叫作栈顶 (top)。
存储原理
- 栈既可以用数组来实现,也可以用链表来实现
时间复杂度
- 入栈和出栈的时间复杂度都是O(1)
- 支持动态扩容的顺序栈
- 当数组空间不够时,我们就重新申请一块更大的内存,将原来数组中数据统统拷贝过去。这样就实现了一个支持动态扩容的数组,通过前面学过的知识,可以得知入栈的时间复杂度是O(n)
应用
- 函数调用
每进入一个函数,就会将临时变量作为一个栈入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈 - 浏览器的后退功能
我们使用两个栈,X 和 Y,我们把首次浏览的页面依次压入栈 X,当点击后退按钮时,再依次从栈X 中出栈,并将出栈的数据依次放入栈 Y。当我们点击前进按钮时,我们依次从栈 Y 中取出数据,放入栈 X 中。当栈 X 中没有数据时,那就说明没有页面可以继续后退浏览了。当栈 Y 中没有数据,那就说明没有页面可以点击前进按钮浏览了
1.4 队列
概念
- 队列(queue)是一种线性数据结构,队列中的元素只能先入先出(First In First Out,简称 FIFO)。
- 队列的出口端叫作队头(front),队列的入口端叫作队尾(rear)。
存储原理
- 队列这种数据结构既可以用数组来实现,也可以用链表来实现
时间复杂度
- 入队和出队都是O(1)
应用
- 资源池、消息队列、命令队列等等
第2节 散列表
概念
- 散列表也叫作哈希表(hash table),这种数据结构提供了键(Key)和值(Value)的映射关系。只要给出一个Key,就可以高效查找到它所匹配的Value,时间复杂度接近于O(1)。
存储原理 - 哈希函数
- 散列表在本质上也是一个数组
- 散列表的Key则是以字符串类型为主的
- 通过hash函数把Key和数组下标进行转换
- 作用是把任意长度的输入通过散列算法转换成固定类型、固定长度的散列值
- hash函数:CRC16、CRC32、siphash 、murmurHash、times 33等
- 此种Hash计算方式为固定Hash方式,也称为传统Hash
- 该方式在数组固定时,可以快速检索
- 但当数组长度变化时,需要重新计算数组下标,此时根据key检索将出现问题
- 所以说传统Hash法虽然比较简单,但不利于扩展,如果要扩展可以采用一致性Hash法
Hash冲突(碰撞)
- 由于数组的长度是有限的,当插入的Entry越来越多时,不同的Key通过哈希函数获得的下标有可能是相同的,这种情况,就叫作哈希冲突。
解决哈希冲突的方法主要有两种:
- 开放寻址法
- 开放寻址法的原理是当一个Key通过哈希函数获得对应的数组下标已被占用时,就寻找下一个空档位置
- 链表法
- 数组的每一个元素不仅是一个Entry对象,还是一个链表的头节点。每一个Entry对象通过next指针指向它的下一个Entry节点。当新来的Entry映射到与之冲突的数组位置时,只需要插入到对应的链表中即可,默认next指向null
Hash扩容(resize)
- 散列表是基于数组实现的,所以散列表需要扩容
- 当经过多次元素插入,散列表达到一定饱和度时,Key映射位置发生冲突的概率会逐渐提高。这样一来,大量元素拥挤在相同的数组下标位置,形成很长的链表,对后续插入操作和查询操作的性能都有很大影响
- 影响扩容的因素有两个
Capacity:HashMap的当前长度
LoadFactor:HashMap的负载因子(阈值),默认值为0.75f
当HashMap.Size >= Capacity×LoadFactor时,需要进行扩容
扩容的步骤:
- 扩容,创建一个新的Entry空数组,长度是原数组的2倍
Entry{
int key;
Object value;
Entry next;
} - 重新Hash,遍历原Entry数组,把所有的Entry重新Hash到新数组中
- 关于HashMap的实现,JDK 8和以前的版本有着很大的不同。当多个Entry被Hash到同一个数组下标位置时,为了提升插入和查找的效率,HashMap会把Entry的链表转化为红黑树- 这种数据结构。
- JDK1.8前在HashMap扩容时,会反序单链表,这样在高并发时会有死循环的可能
时间复杂度
- 写操作: O(1) + O(m) = O(m) m为单链元素个数
- 读操作:O(1) + O(m) m为单链元素个数
- Hash冲突写单链表:O(m)
- Hash扩容:O(n) n是数组元素个数 rehash
- Hash冲突读单链表:O(m) m为单链元素个数
散列表是基于数组实现的
优缺点 - 优点:读写快
- 缺点:哈希表中的元素是没有被排序的、Hash冲突、扩容 重新计算
应用
- HashMap
JDK1.7中HashMap使用一个table数组来存储数据,用key的hashcode取模来决定key会被放到数组里的位置,如果hashcode相同,或者hashcode取模后的结果相同,那么这些key会被定位到Entry数组的同一个格子里,这些key会形成一个链表,在极端情况下比如说所有key的hashcode都相同,将会导致这个链表会很长,那么put/get操作需要遍历整个链表,那么最差情况下时间复杂度变为O(n)。 - 扩容死链
针对JDK1.7中的这个性能缺陷,JDK1.8中的table数组中可能存放的是链表结构,也可能存放的是红黑树结构,如果链表中节点数量不超过8个则使用链表存储,超过8个会调用treeifyBin函数,将链表转换为红黑树。那么即使所有key的hashcode完全相同,由于红黑树的特点,查找某个特定元素,也只需要
O(logn)的开销。
字典
- Redis字典dict又称散列表(hash),是用来存储键值对的一种数据结构。
- Redis整个数据库是用字典来存储的。(K-V结构)
- 对Redis进行CURD操作其实就是对字典中的数据进行CURD操作。
- Redis字典实现包括:字典(dict)、Hash表(dictht)、Hash表节点(dictEntry)。
布隆过滤器 - 布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机hash映射函数。
- 布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法。
- 布隆过滤器的原理是,当一个元素被加入集合时,通过K个Hash函数将这个元素映射成一个数组中的K个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的基本思想。
位图
-
Bitmap 的基本原理就是用一个 bit 来标记某个元素对应的 Value,而 Key 即是该元素。由于采用一个bit 来存储一个数据,因此可以大大的节省空间。
-
Java 中 int 类型占用 4 个字节,即 4 byte,又 1 byte = 8 bit,所以 一个 int 数字的表示大概如下,
- 试想以下,如果有一个很大的 int 数组,如 10000000,数组中每一个数值都要占用 4 个字节,则一共需要占用 10000000 * 4 = 40000000 个字节,即 40000000 / 1024.0 / 1024.0 = 38 M
- 如果使用 bit 来存放上述 10000000 个元素,只需要 10000000 个 bit 即可, 10000000 / 8.0 / 1024.0/ 1024.0 = 1.19 M 左右,可以看到 bitmap 可以大大的节约内存。
- 使用 bit 来表示数组 [1, 2, 5] 如下所示,可以看到只用 1 字节即可表示:
第3节 递归
概念
- 递归,在数学与计算机科学中,是指在函数的定义中使用函数自身的方法。也就是说,递归算法是一种直接或者间接调用自身函数或者方法的算法。
本质
- 递归,去的过程叫"递",回来的过程叫”归“
- 递是调用,归是结束后回来
- 是一种循环,而且在循环中执行的就是调用自己
- 递归调用将每次返回的结果存在栈帧中
递归三要素
- 递归结束条件
既然是循环就必须要有结束,不结束就会OOM了 - 函数的功能
这个函数要干什么,打印,计算…
-函数的等价关系式
递归公式,一般是每次执行之间,或者与个数之间的逻辑关系
时间复杂度
斐波那契数列 普通递归解法为
优缺点
- 优点:代码简单
- 缺点:占用空间较大、如果递归太深,可能会发生栈溢出、可能会有重复计算 通过备忘录或递归的方式去优化(动态规划)
应用
- 递归作为基础算法,应用非常广泛,比如在二分查找、快速排序、归并排序、树的遍历上都有使用递归回溯算法、分治算法、动态规划中也大量使用递归算法实现
第4节 二分查找
概念
- 二分查找(Binary Search)算法,也叫折半查找算法
- 当我们要从一个序列中查找一个元素的时候,二分查找是一种非常快速的查找算法
- 二分查找是针对有序数据集合的查找算法,如果是无序数据集合就遍历查找
本质 - 二分查找之所以快速,是因为它在匹配不成功的时候,每次都能排除剩余元素中一半的元素。因此可能包含目标元素的有效范围就收缩得很快,而不像顺序查找那样,每次仅能排除一个元素。
时间复杂度
- 时间复杂度就是 O(logn)
优缺点 - 优点:速度快,不占空间,不开辟新空间
-缺点:必须是有序的数组,数据量太小没有意义,但数据量也不能太大,因为数组要占用连续的空间
应用
- 有序的查找都可以使用二分法。
如何快速定位出一个 IP 地址的归属地?
- 如果 IP 区间与归属地的对应关系不经常更新,我们可以先预处理这 12 万条数据,让其按照起始 IP 从小到大排序。如何来排序呢?我们知道,IP 地址可以转化为32 位的整型数。所以,我们可以将起始地址,按照对应的整型值的大小关系,从小到大进行排序。
- 当我们要查询某个 IP 归属地时,我们可以先通过二分查找,找到最后一个起始 IP 小于等于这个 IP 的IP 区间,然后,检查这个 IP 是否在这个 IP 区间内,如果在,我们就取出对应的归属地显示;如果不在,就返回未查找到
第三部分 数据结构与算法高级
第1节 树
树的概念
- 有很多数据的逻辑关系并不是线性关系,在实际场景中,常常存在着一对多,甚至是多对多的情况。
家谱:
二叉树 - 二叉树(binary tree)是树的一种特殊形式。二叉,顾名思义,这种树的每个节点最多有2个孩子节点。注意,这里是最多有2个,也可能只有1个,或者没有孩子节点。
满二叉树
- 一个二叉树的所有非叶子节点都存在左右孩子,并且所有叶子节点都在同一层级上,那么这个树就
是满二叉树
完全二叉树
- 对一个有n个节点的二叉树,按层级顺序编号,则所有节点的编号为从1到n。如果这个树所有节点和同样深度的满二叉树的编号为从1到n的节点位置相同,则这个二叉树为完全二叉树
满二叉树要求所有分支都是满的;而完全二叉树只需保证最后一个节点之前的节点都齐全即可
二叉树的存储
- 二叉树属于逻辑结构,可以使用链表和数组进行存储。
- 链式存储
二叉查找树 - 叉查找树要求左子树小于父节点,右子树大于父节点,正是这样保证了二叉树的有序性。
- 因此二叉查找树还有另一个名字——二叉排序树(binary sort tree)。
二叉树的遍历
- 深度优先遍历
所谓深度优先,顾名思义,就是偏向于纵深,“一头扎到底”的访问方式。它包括:
使用栈的数据结构- 前序遍历
二叉树的前序遍历,输出顺序是根节点、左子树、右子树 - 中序遍历
- 后序遍历
- 前序遍历
- 广度优先遍历
- 使用队列的数据结构
时间复杂度
- 二叉查找树的插入和查找时间复杂度为:O(logn)
- 极端情况下二叉查找树退化成链表,时间复杂度为O(n),所以需要平衡二叉查找树。
应用
- 非线性数据:菜单,组织结构、家谱等等
- 线性数据:二叉查找树
- 二叉查找树是有序的,我们只需要中序遍历,就可以在 O(n) 的时间复杂度内,输出有序的数据序列。
- 二叉查找树的性能非常稳定,扩容很方便(链表实现)
红黑树
- 平衡二叉查找树
这种二叉查找树就退化成了链表,由于树的深度变得多了,查找的效率也会大幅下降所以需要对这种二叉树进行自平衡,红黑树就是一种自平衡的二叉查找树。
红黑树(Red Black Tree)
除了二叉查找树(BST)的特征外,还有以下特征:
- 每个节点要么是黑色,要么是红色
- 根节点是黑色
- 每个叶子节点都是黑色的空结点(NIL结点)(为了简单期间,一般会省略该节点)
- 如果一个节点是红色的,则它的子节点必须是黑色的(父子不能同为红)
- 从任一结点到其每个叶子的所有路径都包含相同数目的黑色结点(平衡的关键)
- 新插入节点默认为红色,插入后需要校验红黑树是否符合规则,不符合则需要进行平衡
package com.lagou.tree.redblack;
/**
* 红黑树结点
*/
public class RBTreeNode {
private int key;
private boolean isBlack;
private RBTreeNode left;
private RBTreeNode right;
private RBTreeNode parent;
public RBTreeNode(int key) {
this.key = key;
this.isBlack = false; //新节点默认是红色
}
public int getKey() {
return key;
}
public boolean isBlack() {
return isBlack;
}
public RBTreeNode getLeft() {
return left;
}
public RBTreeNode getRight() {
return right;
}
public RBTreeNode getParent() {
return parent;
}
public void setKey(int key) {
this.key = key;
}
public void setBlack(boolean black) {
isBlack = black;
}
public void setLeft(RBTreeNode left) {
this.left = left;
}
public void setRight(RBTreeNode right) {
this.right = right;
}
public void setParent(RBTreeNode parent) {
this.parent = parent;
}
@Override
public String toString() {
return "RBTreeNode{" +
"key=" + key +
", color=" + (isBlack==true?"BLACK":"RED") +
'}';
}
}
package com.lagou.tree.redblack;
/**
* 红黑树
*/
public class RBTree {
RBTreeNode root; //根节点
/**
* 遍历节点
*
*/
public void list(RBTreeNode node) {
if(node==null) return ;
//叶子
if(node.getLeft()==null&&node.getRight()==null){
System.out.println(node);
return ;
}
System.out.println(node);
//递归 左孩子
list(node.getLeft());
//递归 右孩子
list(node.getRight());
}
public void insert(int key) {
RBTreeNode node = new RBTreeNode(key);
if (root == null) {
node.setBlack(true);//根是黑色的
root = node;
return;
}
RBTreeNode parent = root;
RBTreeNode son = null;
if (key <= parent.getKey()) {
son = parent.getLeft();
} else {
son = parent.getRight();
}
//find the position
while (son != null) {
parent = son;
if (key <= parent.getKey()) {
son = parent.getLeft();
} else {
son = parent.getRight();
}
}
if (key <= parent.getKey()) {
parent.setLeft(node);
} else {
parent.setRight(node);
}
node.setParent(parent);
// 自平衡
banlanceInsert(node);
}
/**
* 自平衡
*
* @param node
*/
private void banlanceInsert(RBTreeNode node) {
RBTreeNode father, grandFather;
while ((father = node.getParent()) != null && father.isBlack() == false)
{
grandFather = father.getParent();
//父为祖左孩子
if (grandFather.getLeft() == father) {
RBTreeNode uncle = grandFather.getRight();
if (uncle != null && uncle.isBlack() == false) {
setBlack(father);
setBlack(uncle);
setRed(grandFather);
node = grandFather;
continue;
}
if (node == father.getRight()) {
//左旋
leftRotate(father);
RBTreeNode tmp = node;
node = father;
father = tmp;
}
setBlack(father);
setRed(grandFather);
//右旋
rightRotate(grandFather);
}
//父为祖右孩子
else {
RBTreeNode uncle = grandFather.getLeft();
if (uncle != null && uncle.isBlack() == false) {
setBlack(father);
setBlack(uncle);
setRed(grandFather);
node = grandFather;
continue;
}
if (node == father.getLeft()) {
//右旋
rightRotate(father);
RBTreeNode tmp = node;
node = father;
father = tmp;
}
setBlack(father);
setRed(grandFather);
//左旋
leftRotate(grandFather);
}
}
setBlack(root);
}
/**
* 左旋
*
* @param node
*/
private void leftRotate(RBTreeNode node) {
RBTreeNode right = node.getRight();
RBTreeNode parent = node.getParent();
if (parent == null) {
root = right;
right.setParent(null);
} else {
if (parent.getLeft() != null && parent.getLeft() == node) {
parent.setLeft(right);
} else {
parent.setRight(right);
}
right.setParent(parent);
}
node.setParent(right);
node.setRight(right.getLeft());
if (right.getLeft() != null) {
right.getLeft().setParent(node);
}
right.setLeft(node);
}
/**
* 右旋
*
* @param node
*/
private void rightRotate(RBTreeNode node) {
RBTreeNode left = node.getLeft();
RBTreeNode parent = node.getParent();
if (parent == null) {
root = left;
left.setParent(null);
} else {
if (parent.getLeft() != null && parent.getLeft() == node) {
parent.setLeft(left);
} else {
parent.setRight(left);
}
left.setParent(parent);
}
node.setParent(left);
node.setLeft(left.getRight());
if (left.getRight() != null) {
left.getRight().setParent(node);
}
left.setRight(node);
}
private void setBlack(RBTreeNode node) {
node.setBlack(true);
}
private void setRed(RBTreeNode node) {
node.setBlack(false);
}
public static void main(String[] args) {
RBTree rb=new RBTree();
rb.insert(10);//根节点
rb.insert(5);
rb.insert(9);
rb.insert(3);
rb.insert(6);
rb.insert(7);
rb.insert(19);
rb.insert(32);
rb.insert(24);
rb.insert(17);
rb.list(rb.root);
}
}
时间复杂度:O(logn)
应用
- 在JDK1.8中HashMap使用数组+链表+红黑树的数据结构。内部维护着一个数组table,该数组保存着每个链表的表头结点或者树的根节点。HashMap存储数据的数组定义如下,里面存放的是Node<K,V>实体:
多路查找树
- 多路查找树(muitl-way search tree),其每一个节点的孩子数可以多于两个,且每一个节点处可以存储多个元素。
- B树
B树(BalanceTree)是对二叉查找树的改进。它的设计思想是,将相关数据尽量集中在一起,以便一次读取多个数据,减少硬盘操作次数。
一棵m阶的B 树 (m叉树)的特性如下:
- B树中所有节点的孩子节点数中的最大值称为B树的阶,记为M
- 树中的每个节点至多有M棵子树 —即:如果定了M,则这个B树中任何节点的子节点数量都不能超过M
- 若根节点不是终端节点,则至少有两棵子树
- 除根节点和叶节点外,所有点至少有m/2棵子树
- 所有的叶子结点都位于同一层
B+树
B+树是B-树的变体,也是一种多路搜索树,其定义基本与B树相同,它的自身特征是: - 非叶子结点的子树指针与关键字个数相同
- 非叶子结点的子树指针P[i],指向关键字值属于[K[i], K[i+1])的子树
- 为所有叶子结点增加一个链指针
- 所有关键字都在叶子结点出现
典型应用
MySQL索引B+Tree
B树是为了磁盘或其它存储设备而设计的一种多叉(下面你会看到,相对于二叉,B树每个内结点有多个分支,即多叉)平衡查找树。 多叉平衡
- B树的高度一般都是在2-4这个高度,树的高度直接影响IO读写的次数。
- 如果是三层树结构—支撑的数据可以达到20G,如果是四层树结构—支撑的数据可以达到几十T
B和B+的区别
B树和B+树的最大区别在于非叶子节点是否存储数据的问题。
B树是非叶子节点和叶子节点都会存储数据。
B+树只有叶子节点才会存储数据,而且存储的数据都是在一行上,而且这些数据都是有指针指向的,也就是有顺序的。
二叉堆
二叉堆本质上是一种完全二叉树,它分为两个类型
- 大顶堆(最大堆)
最大堆的任何一个父节点的值,都大于或等于它左、右孩子节点的值
2. 小顶堆(最小堆)
最小堆的任何一个父节点的值,都小于或等于它左、右孩子节点的值
二叉堆的存储原理
完全二叉树比较适合用数组来存储。用数组来存储完全二叉树是非常节省存储空间的。因为我们不需要存储左右子节点的指针,单纯地通过数组的下标,就可以找到一个节点的左右子节点和父节点。
从图中我们可以看到,数组中下标为 i 的节点的左子节点,就是下标为 i?2 的节点,右子节点就是下标
为 i?2+1 的节点,父节点就是下标为 i/2 取整的节点
二叉堆的典型应用
- 优先队列
- 利用堆求 Top K问题
在一个包含 n 个数据的数组中,我们可以维护一个大小为 K 的小顶堆,顺序遍历数组,从数组中取出数据与堆顶元素比较。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理,继续遍历数组。这样等数组中的数据都遍历完之后,堆中的数据就是前 K 大数据了
第2节 排序
1.1 冒泡排序
package com.lagou.sort;
/**
* 冒泡排序
*/
public class BubbleSort {
public static void main(String[] args) {
int[] nums = new int[]{5,8,6,3,9,2,1,7};
for(int i = 0; i < nums.length - 1; i++){
for(int j = 0; j < nums.length - 1; j++){
//临时变量 用于交换
int tmp = 0;
if(nums[j] > nums[j+1]){
tmp = nums[j];
nums[j] = nums[j+1];
nums[j+1] = tmp;
}
}
}
for(int n:nums){
System.out.println(n);
}
}
}
冒泡排序的优化
- 外层循环优化
第6轮已经可以结束了,也就是如果不需要交换了,则说明已经排好序了
思路:在外层循环处,设置标志isSort,默认为排好,如果不交换则跳出本次循环 - 内层循环优化
已经被移到右侧的元素不用再参与比较了
package com.lagou.sort;
/**
* 冒泡排序
*/
public class BuddleSort {
public static void main(String[] args) {
int[] nums = new int[]{5,8,6,3,9,2,1,7};
for(int i = 0; i < nums.length - 1; i++){
//默认排好了
boolean isSort=true;
for(int j = 0; j < nums.length - 1 - i; j++){
//临时变量 用于交换
int tmp = 0;
if(nums[j] > nums[j+1]){
// 需要交换 则isSort=false
isSort=false;
tmp = nums[j];
nums[j] = nums[j+1];
nums[j+1] = tmp;
}
}
//排好了跳出循环
if(isSort){
break;
}
}
for(int n:nums){
System.out.println(n);
}
}
}
1.2 快速排序
双边循环法
import java.util.Arrays;
/**
* 快速排序:双边循环法
*/
public class QuickSort1 {
public static void quickSort(int[] arr, int startIndex,
int endIndex){
// 递归结束条件:startIndex大于或等于endIndex时
if (startIndex >= endIndex) {
return;
}
// 得到基准元素位置
int pivotIndex = partition(arr, startIndex, endIndex);
// 根据基准元素,分成两部分进行递归排序
quickSort(arr, startIndex, pivotIndex - 1);
quickSort(arr, pivotIndex + 1, endIndex);
}
/**
* 分治(双边循环法)
* @param arr 待交换的数组
* @param startIndex 起始下标
* @param endIndex 结束下标
* @return
*/
private static int partition(int[] arr, int startIndex, int endIndex){
// 取第1个位置(也可以选择随机位置)的元素作为基准元素
int pivot = arr[startIndex];
int left = startIndex;
int right = endIndex;
while( left != right) {
//控制right 指针比较并左移
while(left<right && arr[right] > pivot){
right--;
}
//控制left指针比较并右移
while( left<right && arr[left] <= pivot){
left++;
}
//交换left和right 指针所指向的元素
if(left<right) {
int p = arr[left];
arr[left] = arr[right];
arr[right] = p;
}
}
//pivot 和指针重合点交换
arr[startIndex] = arr[left];
arr[left] = pivot;
return left;
}
public static void main(String[] args) {
nt[] arr = new int[] {4,7,3,5,6,2,8,1};
quickSort(arr, 0, arr.length-1);
System.out.println(Arrays.toString(arr));
}
}
单边循环法
import java.util.Arrays;
/**
* 快速排序:单边循环法
*/
public class QuickSort2 {
public static void quickSort(int[] arr, int startIndex,
int endIndex) {
// 递归结束条件:startIndex大于或等于endIndex时
if (startIndex >= endIndex) {
return;
}
// 得到基准元素位置
int pivotIndex = partition(arr, startIndex, endIndex);
// 根据基准元素,分成两部分进行递归排序
quickSort(arr, startIndex, pivotIndex - 1);
quickSort(arr, pivotIndex + 1, endIndex);
}
/**
* 分治(单边循环法)
*
* @param arr 待交换的数组
* @param startIndex 起始下标
* @param endIndex 结束下标
* @return
*/
private static int partition(int[] arr, int startIndex, int endIndex) {
// 取第1个位置(也可以选择随机位置)的元素作为基准元素
int pivot = arr[startIndex];
int mark = startIndex;
for (int i = startIndex + 1; i <= endIndex; i++) {
if (arr[i] < pivot) {
mark++;
int p = arr[mark];
arr[mark] = arr[i];
arr[i] = p;
}
}
arr[startIndex] = arr[mark];
arr[mark] = pivot;
return mark;
}
public static void main(String[] args) {
int[] arr = new int[]{4, 7, 3, 5, 6, 2, 8, 1};
quickSort(arr, 0, arr.length - 1);
System.out.println(Arrays.toString(arr));
}
}
快速排序的时间复杂度是:O(nlogn)
1.3 堆排序
堆排序:堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。
import java.util.Arrays;
public class Hp {
public static void main(String []args){
int []arr = {7,6,4,3,5,2,10,9,8};
System.out.println("排序前:"+ Arrays.toString(arr));
sort(arr);
System.out.println("排序后:"+Arrays.toString(arr));
}
public static void sort(int[] array){
// 1. 把无序数组构建成最大堆
for (int i = array.length/2-1; i >= 0; i--){
adjustHeap(array, i, array.length);
}
// 2. 调整堆结构+交换堆顶元素与末尾元素,调整堆产生新的堆顶
for (int i = array.length - 1; i > 0; i--){
// 最后1个元素和第1个元素进行交换
int temp = array[i];
array[i] = array[0];
array[0] = temp;
// “下沉”调整最大堆
adjustHeap(array, 0, i);
}
}
public static void adjustHeap(int []array,int parentIndex,int length){
// temp 保存父节点值,用于最后的赋值
int temp = array[parentIndex];
int childIndex = 2 * parentIndex + 1;
while (childIndex < length){
// 如果有右孩子,且右孩子大于左孩子的值,则定位到右孩子
if (childIndex + 1 < length && array[childIndex + 1] >
array[childIndex]){
childIndex++;
}
// 如果父节点大于任何一个孩子的值,则直接跳出
if (temp >= array[childIndex])
break;
//无须真正交换,单向赋值即可
array[parentIndex] = array[childIndex];
parentIndex = childIndex;
//下一个左孩子
childIndex = 2 * childIndex + 1;
}
array[parentIndex] = temp;
}
}
堆排序的时间复杂度是: O(nlogn)
1.4 计数排序
计数排序,这种排序算法是利用数组下标来确定元素的正确位置的。
\/**
* 计数排序
*/
public class CountSort {
public static int[] countSort(int[] array, int offset) {
int[] nums = new int[array.length];
for (int i = 0; i < array.length; i++) {
int n = (array[i] - offset);
//数字自增
nums[n]++;
}
int[] nums2 = new int[array.length];
// i是计数数组下标,k是新数组下标
for (int i = 0, k = 0; i < nums.length; i++) {
for (int j = 0; j < nums[i]; j++) {
nums2[k++] = i + offset;
}
}
return nums2;
}
public static void main(String[] args) {
int[] scores = {95, 94, 91, 98, 99, 90, 99, 93, 91, 92};
for (int n : countSort(scores, 90)) {
System.out.println(n);
}
}
}
计数排序的时间复杂度是O(n+m)
n: 数据个数
m: 数据范围
1.5 桶排序
- 桶排序同样是一种线性时间的排序算法
- 桶排序需要创建若干个桶来协助排序
- 每一个桶(bucket)代表一个区间范围,里面可以承载一个或多个元素
- 桶排序的第1步,就是创建这些桶,并确定每一个桶的区间范围具体需要建立多少个桶,如何确定桶的区间范围,有很多种不同的方式。我们这里创建的桶数量等于原始数列的元素数量,除最后一个桶只包含数列最大值外, 前面各个桶的区间按照比例来确定。
- 区间跨度 = (最大值-最小值)/ (桶的数量 - 1)
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
public class BucketSort {
public static double[] bucketSort(double[] array) {
double max = 0;
double min = 0;
//获得最大值和最小值之间的差
for (int i = 0; i < array.length; i++) {
if (array[i] > max) {
max = array[i];
}
if (array[i] < min) {
min = array[i];
}
}
double d = max - min;
//桶初始化
int bucketNum = array.length;
ArrayList<LinkedList<Double>> bucketList =
new ArrayList<LinkedList<Double>>(bucketNum);
for (int i = 0; i < bucketNum; i++) {
bucketList.add(new LinkedList<Double>());
}
//将每个元素放入桶中
for (int i = 0; i < array.length; i++) {
int num = (int) ((array[i] - min) * (bucketNum - 1) / d);
bucketList.get(num).add(array[i]);
}
//对每个桶内部进行排序
for (int i = 0; i < bucketList.size(); i++) {
Collections.sort(bucketList.get(i));
}
//输出全部元素
double[] sortedArray = new double[array.length];
int index = 0;
for (LinkedList<Double> list : bucketList) {
for (double element : list) {
sortedArray[index] = element;
index++;
}
}
return sortedArray;
}
public static void main(String[] args) {
double[] array = {4.12, 6.421, 0.0023, 3.0, 2.123, 8.122, 4.12, 10.09};
double[] sortedArray = bucketSort(array);
System.out.println(Arrays.toString(sortedArray));
}
}
桶排序的时间复杂度是O(n)
各个排序比对表:
第3节 字符串匹配
BF 算法
- BF 算法中的 BF 是 Brute Force 的缩写,中文叫作暴力匹配算法,也叫朴素匹配算法。
- 这种算法的字符串匹配方式很“暴力”,当然也就会比较简单、好懂,但相应的性能也不高
- 比方说,我们在字符串 A 中查找字符串 B,那字符串 A 就是主串,字符串 B 就是模式串我们在主串中,检查起始位置分别是 0、1、2…n-m 且长度为 m 的 n-m+1 个子串,看有没有跟模式串匹配的。
/**
* 暴力匹配算法
*/
public class Bfalth {
public static boolean isMatch(String main,String sub){
for(int i=0;i<=(main.length()-sub.length());i++){
// 实际实现是每个字符串做字符比较 ,需要循环子串
if(main.substring(i,i+sub.length()).equals(sub)){
return true;
}
}
return false;
}
public static void main(String[] args) {
System.out.println(isMatch("ccaaac","caaac"));
}
}
时间复杂度
- 我们每次都比对 m 个字符,要比对 n-m+1 次,所以,这种算法的最坏情况时间复杂度是 O(n*m)。
m:为匹配串长度
n:为主串长度
应用
- 虽然BF算法效率不高但在实际情况下却很常用。因为:
主串不会太长,实现简单
RK 算法
- RK 算法的全称叫 Rabin-Karp 算法,是由它的两位发明者 Rabin 和 Karp 的名字来命名的
- 每次检查主串与子串是否匹配,需要依次比对每个字符,所以 BF 算法的时间复杂度就比较高,是O(n*m)。我们对朴素的字符串匹配算法稍加改造,引入哈希算法,时间复杂度立刻就会降低
- RK 算法的思路是这样的:我们通过哈希算法对主串中的 n-m+1 个子串分别求哈希值,然后逐个与模式串的哈希值比较大小。如果某个子串的哈希值与模式串相等,那就说明对应的子串和模式串匹配了(这里先不考虑哈希冲突的问题)。因为哈希值是一个数字,数字之间比较是否相等是非常快速的,所以模式串和子串比较的效率就提高了
/**
* 字符串hash值匹配
*/
public class RKalth {
public static boolean isMatch(String main, String sub) {
//算出子串的hash值
int hash_sub=strToHash(sub);
for (int i = 0; i <= (main.length() - sub.length()); i++) {
// 主串截串后与子串的hash值比较
if (hash_sub==strToHash(main.substring(i, i + sub.length()))) {
return true;
}
}
return false;
}
/**
* 支持 a-z 二十六进制
* 获得字符串的hash值
* @param src
* @return
*/
public static int strToHash(String src) {
int hash = 0;
for (int i = 0; i < src.length(); i++) {
hash *= 26;
hash += src.charAt(i) - 97;
}
return hash;
}
public static void main(String[] args) {
System.out.println(isMatch("abcvdcd","vdcd"));
}
}
时间复杂度
- RK 算法的的时间复杂度为O(m+n)
- m:为匹配串长度
- n:为主串长度
应用
- 适用于匹配串类型不多的情况,比如:字母、数字或字母加数字的组合 62 (大小写字母+数字)
BM 算法
- BF 算法性能会退化的比较严重,而 RK 算法需要用到哈希算法,而设计一个可以应对各种类型字符的哈希算法并不简单。
- BM(Boyer-Moore)算法。它是一种非常高效的字符串匹配算法,滑动算法
-0 在这个例子里,主串中的 c,在模式串中是不存在的,所以,模式串向后滑动的时候,只要 c 与模式串有重合,肯定无法匹配。所以,我们可以一次性把模式串往后多滑动几位,把模式串移动到 c 的后面。 - BM 算法,本质上其实就是在寻找这种规律。借助这种规律,在模式串与主串匹配的过程中,当模式串和主串某个字符不匹配的时候,能够跳过一些肯定不会匹配的情况,将模式串往后多滑动几位。
算法原理
- BM 算法包含两部分,分别是坏字符规则(bad character rule)和好后缀规则(good suffix shift)。
- 坏字符规则
BM 算法的匹配顺序比较特别,它是按照模式串下标从大到小的顺序,倒着匹配的。
- 坏字符规则
public class BMalth {
private static final int SIZE = 256; // 全局变量或成员变量
/**
*
* @param b
* @param m
* @param dc
*/
private static void generateBC(char[] b, int m, int[] dc) {
for (int i = 0; i < SIZE; ++i) {
dc[i] = -1; // 初始化 bc 模式串中没有的字符值都是-1
}
//将模式串中的字符希写入到字典中
for (int i = 0; i < m; ++i) {
int ascii = (int)b[i]; // 计算 b[i] 的 ASCII 值
dc[ascii] = i;
}
}
/**
* 坏字符
* @param a 主串
* @param b 模式串
* @return
*/
public static int bad(char[] a, char[] b) {
//主串长度
int n=a.length;
//模式串长度
int m=b.length;
//创建字典
int[] bc = new int[SIZE];
// 构建坏字符哈希表,记录模式串中每个字符最后出现的位置
generateBC(b, m, bc);
// i表示主串与模式串对齐的第一个字符
int i = 0;
while (i <= n - m) {
int j;
for (j = m - 1; j >= 0; --j) { // 模式串从后往前匹配
//i+j : 不匹配的位置
if (a[i+j] != b[j]) break; // 坏字符对应模式串中的下标是j
}
if (j < 0) {
return i; // 匹配成功,返回主串与模式串第一个匹配的字符的位置
}
// 这里等同于将模式串往后滑动j-bc[(int)a[i+j]]位
// j:si bc[(int)a[i+j]]:xi
i = i + (j - bc[(int)a[i+j]]);
}
return -1;
}
public static void main(String[] args) {
String s1="abcabcabc";
String s2="cab";
System.out.println(bad(s1.toCharArray(),s2.toCharArray()));
}
}
BM算法的时间复杂度是O(N/M)
- n:主串长度
-m:模式串长度
应用
- BM算法比较高效,在实际开发中,特别是一些文本编辑器中,用于实现查找字符串功能。
Trie 树
- Trie 树,也叫“字典树”。它是一个树形结构。它是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题。
- 比如:有 6 个字符串,它们分别是:how,hi,her,hello,so,see,我们可以将这六个字符串组成Trie树结构。
- Trie 树的本质,就是利用字符串之间的公共前缀,将重复的前缀合并在一起。
public class TrieNode {
public char data;
public TrieNode[] children = new TrieNode[26];
public boolean isEndingChar = false;
public TrieNode(char data) {
this.data = data;
}
}
public class Trie {
private TrieNode root = new TrieNode('/'); // 存储无意义字符
// 往Trie树中插入一个字符串
public void insert(char[] text) {
TrieNode p = root;
for (int i = 0; i < text.length; ++i) {
int index = text[i] - 97;
if (p.children[index] == null) {
TrieNode newNode = new TrieNode(text[i]);
p.children[index] = newNode;
}
p = p.children[index];
}
p.isEndingChar = true;
}
// 在Trie树中查找一个字符串
public boolean find(char[] pattern) {
TrieNode p = root;
for (int i = 0; i < pattern.length; ++i) {
int index = pattern[i] - 97;
if (p.children[index] == null) {
return false; // 不存在pattern
}
p = p.children[index];
}
if (p.isEndingChar == false) return false; // 不能完全匹配,只是前缀
else return true; // 找到pattern
}
public static void main(String[] args) {
Trie trie=new Trie();
trie.insert("hello".toCharArray());
trie.insert("her".toCharArray());
trie.insert("hi".toCharArray());
trie.insert("how".toCharArray());
trie.insert("see".toCharArray());
trie.insert("so".toCharArray());
System.out.println(trie.find("how".toCharArray()));
}
}
时间复杂度
- 如果要在一组字符串中,频繁地查询某些字符串,用 Trie 树会非常高效。构建 Trie 树的过程,需要扫描所有的字符串,时间复杂度是 O(n)(n 表示所有字符串的长度和)。但是一旦构建成功之后,后续的查询操作会非常高效。每次查询时,如果要查询的字符串长度是 k,那我们只需要比对大约 k 个节点,
就能完成查询操作。跟原本那组字符串的长度和个数没有任何关系。所以说,构建好 Trie 树后,在其中查找字符串的时间复杂度是 O(k),k 表示要查找的字符串的长度。
典型应用
- 利用 Trie 树,实现搜索关键词的提示功能
- 我们假设关键词库由用户的热门搜索关键词组成。我们将这个词库构建成一个 Trie 树。当用户输入其中某个单词的时候,把这个词作为一个前缀子串在 Trie 树中匹配。为了讲解方便,我们假设词库里只有hello、her、hi、how、so、see 这 6 个关键词。当用户输入了字母 h 的时候,我们就把以 h 为前缀的
hello、her、hi、how 展示在搜索提示框内。当用户继续键入字母 e 的时候,我们就把以 he 为前缀的hello、her 展示在搜索提示框内。这就是搜索关键词提示的最基本的算法原理。
第4节 图
- 图的概念
- 图的存储
图最直观的一种存储方法就是,邻接矩阵(Adjacency Matrix)。
import java.util.ArrayList;
import java.util.List;
/**
* 邻接矩阵实现
*/
public class Graph1 {
private List vertexList;//存储点的链表
private int[][] edges;//邻接矩阵,用来存储边
private int numOfEdges;//边的数目
public Graph1(int n) {
//初始化矩阵,一维数组,和边的数目
edges=new int[n][n];
vertexList=new ArrayList(n);
numOfEdges=0;
}
//得到结点的个数
public int getNumOfVertex() {
return vertexList.size();
}
//得到边的数目
public int getNumOfEdges() {
return numOfEdges;
}
//返回结点i的数据
public Object getValueByIndex(int i) {
return vertexList.get(i);
}
//返回v1,v2的权值
public int getWeight(int v1,int v2) {
return edges[v1][v2];
}
//插入结点
public void insertVertex(Object vertex) {
vertexList.add(vertex);
}
//插入边
public void insertEdge(int v1,int v2,int weight) {
edges[v1][v2]=weight;
numOfEdges++;
}
public static void main(String args[]) {
int n=4,e=4;//分别代表结点个数和边的数目
String labels[]={"V1","V1","V3","V4"};//结点的标识
Graph1 graph=new Graph1(n);
for(String label:labels) {
graph.insertVertex(label);//插入结点
}
//插入四条边
graph.insertEdge(0, 1, 2);
graph.insertEdge(0, 2, 5);
graph.insertEdge(2, 3, 8);
graph.insertEdge(3, 0, 7);
System.out.println("结点个数是:"+graph.getNumOfVertex());
System.out.println("边的个数是:"+graph.getNumOfEdges());
}
}
邻接表
- 用邻接矩阵来表示一个图,虽然简单、直观,但是比较浪费存储空间
/**
* 顶点
*/
public class Vertex {
String name; //顶点名称
Edge next; //从该定点出发的边
public Vertex(String name, Edge next){
this.name = name;
this.next = next;
}
}
/**
* 边
*/
public class Edge {
String name; //被指向的顶点
int weight; //弧的权值
Edge next; //被指向的下一个边
public Edge(String name, int weight, Edge next){
this.name = name;
this.weight = weight;
this.next = next;
}
}
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
/**
* 邻接表实现
*/
public class Graph2 {
Map<String, Vertex> vertexsMap; //存储所有的顶点
Graph2(){
this.vertexsMap = new HashMap<>();
}
public void insertVertex(String vertexName){ //添加顶点
Vertex vertex = new Vertex(vertexName, null);
vertexsMap.put(vertexName, vertex);
}
public void insertEdge(String begin, String end, int weight){ //添
加弧
Vertex beginVertex = vertexsMap.get(begin);
if(beginVertex == null){
beginVertex = new Vertex(begin, null);
vertexsMap.put(begin, beginVertex);
}
Edge edge = new Edge(end, weight, null);
if(beginVertex.next == null){
beginVertex.next = edge;
}else{
Edge lastEdge = beginVertex.next;
while(lastEdge.next != null){
lastEdge = lastEdge.next;
}
lastEdge.next = edge;
}
}
public void print(){ //打印图
Set<Map.Entry<String, Vertex>> set = vertexsMap.entrySet();
Iterator<Map.Entry<String, Vertex>> iterator = set.iterator();
while(iterator.hasNext()){
Map.Entry<String, Vertex> entry = iterator.next();
Vertex vertex = entry.getValue();
Edge edge = vertex.next;
while(edge != null){
System.out.println(vertex.name + " 指向 " + edge.name + " 权值
为:" + edge.weight);
edge = edge.next;
}
}
}
public static void main(String[] args) {
Graph2 graph = new Graph2();
graph.insertVertex("A");
graph.insertVertex("B");
graph.insertVertex("C");
graph.insertVertex("D");
graph.insertVertex("E");
graph.insertVertex("F");
graph.insertEdge("C", "A", 1);
graph.insertEdge("F", "C", 2);
graph.insertEdge("A", "B", 4);
graph.insertEdge("E", "B", 2);
graph.insertEdge("A", "D", 5);
graph.insertEdge("D", "F", 4);
graph.insertEdge("D", "E", 3);
graph.print();
}
}
图的遍历
- 遍历是指从某个节点出发,按照一定的的搜索路线,依次访问对数据结构中的全部节点,且每个节点仅访问一次
- 前面已经讲过了二叉树的节点遍历
- 类似的,图的遍历是指,从给定图中任意指定的顶点(称为初始点)出发,按照某种搜索方法沿着图的边访问图中的所有顶点,使每个顶点仅被访问一次,这个过程称为图的遍历。遍历过程中得到的顶点序列称为图遍历序列
- 图的遍历过程中,根据搜索方法的不同,又可以划分为两种搜索策略:
- 深度优先搜索
- 广度优先搜索
- 深度优先搜索(DFS,Depth First Search)
深度优先搜索,从起点出发,从规定的方向中选择其中一个不断地向前走,直到无法继续为止,然后尝试另外一种方向,直到最后走到终点。就像走迷宫一样,尽量往深处走。 - DFS 解决的是连通性的问题,即,给定两个点,一个是起始点,一个是终点,判断是不是有一条路径能从起点连接到终点。起点和终点,也可以指的是某种起始状态和最终的状态。问题的要求并不在乎路径是长还是短,只在乎有还是没有。
- 假设我们有这么一个图,里面有A、B、C、D、E、F、G、H 8 个顶点,点和点之间的联系如下图所示,对这个图进行深度优先的遍历。
必须依赖栈(Stack),特点是后进先出(LIFO)。
第一步,选择一个起始顶点,例如从顶点 A 开始。把 A 压入栈,标记它为访问过(用红色标记),并输出到结果中。
第二步,寻找与 A 相连并且还没有被访问过的顶点,顶点 A 与 B、D、G 相连,而且它们都还没有被访问过,我们按照字母顺序处理,所以将 B 压入栈,标记它为访问过,并输出到结果中。
第三步,现在我们在顶点 B 上,重复上面的操作,由于 B 与 A、E、F 相连,如果按照字母顺序处理的话,A 应该是要被访问的,但是 A 已经被访问了,所以我们访问顶点 E,将 E 压入栈,标记它为访问过,并输出到结果中。
第四步,从 E 开始,E 与 B、G 相连,但是B刚刚被访问过了,所以下一个被访问的将是G,把G压入栈,标记它为访问过,并输出到结果中。
第五步,现在我们在顶点 G 的位置,由于与 G 相连的顶点都被访问过了,类似于我们走到了一个死胡同,必须尝试其他的路口了。所以我们这里要做的就是简单地将 G 从栈里弹出,表示我们从 G 这里已经无法继续走下去了,看看能不能从前一个路口找到出路。
如果发现周围的顶点都被访问了,就把当前的顶点弹出。
第六步,现在栈的顶部记录的是顶点 E,我们来看看与 E 相连的顶点中有没有还没被访问到的,发现它们都被访问了,所以把 E 也弹出去。
第七步,当前栈的顶点是 B,看看它周围有没有还没被访问的顶点,有,是顶点 F,于是把 F 压入栈,标记它为访问过,并输出到结果中
第八步,当前顶点是 F,与 F 相连并且还未被访问到的点是 C 和 D,按照字母顺序来,下一个被访问的点是 C,将 C 压入栈,标记为访问过,输出到结果中。
第九步,当前顶点为 C,与 C 相连并尚未被访问到的顶点是 H,将 H 压入栈,标记为访问过,输出到结果中。
第十步,当前顶点是 H,由于和它相连的点都被访问过了,将它弹出栈。
第十一步,当前顶点是 C,与 C 相连的点都被访问过了,将 C 弹出栈
第十二步,当前顶点是 F,与 F 相连的并且尚未访问的点是 D,将 D 压入栈,输出到结果中,并标记为访问过。
第十三步,当前顶点是 D,与它相连的点都被访问过了,将它弹出栈。以此类推,顶点 F,B,A 的邻居都被访问过了,将它们依次弹出栈就好了。最后,当栈里已经没有顶点需要处理了,我们的整个遍历结束。
时间复杂度
- 邻接表
- 访问所有顶点的时间为 O(V),而查找所有顶点的邻居一共需要 O(E) 的时间,所以总的时间复杂度是O(V + E)。
- 邻接矩阵
- 查找每个顶点的邻居需要 O(V) 的时间,所以查找整个矩阵的时候需要 O( ) 的时间
广度优先搜索(BFS,Breadth First Search)
- 直观地讲,它其实就是一种“地毯式”层层推进的搜索策略,即先查找离起始顶点最近的,然后是次近的,依次往外搜索。
时间复杂度
- 邻接表
- 每个顶点都需要被访问一次,时间复杂度是 O(V);相连的顶点(也就是每条边)也都要被访问一次,加起来就是 O(E)。因此整体时间复杂度就是 O(V+E)。
- 邻接矩阵
- V 个顶点,每次都要检查每个顶点与其他顶点是否有联系,因此时间复杂度是 O( )。
应用
- 广度优先的搜索可以同时从起始点和终点开始进行,称之为双端 BFS。这种算法往往可以大大地提高搜索的效率。
- 社交网络可以用图来表示。这个问题就非常适合用图的广度优先搜索算法来解决,因为广度优先搜索是层层往外推进的。首先,遍历与起始顶点最近的一层顶点,也就是用户的一度好友,然后再遍历与用户距离的边数为 2 的顶点,也就是二度好友关系,以及与用户距离的边数为 3 的顶点,也就是三度好友关系
第5节 算法思维
贪心算法
概念
- 贪婪算法(Greedy)的定义:是一种在每一步选中都采取在当前状态下最好或最优的选择,从而希望导致结果是全局最好或最优的算法。
- 贪婪算法:当下做局部最优判断,不能回退
(能回退的是回溯,最优+回退是动态规划) - 由于贪心算法的高效性以及所求得答案比较接近最优结果,贪心算法可以作为辅助算法或解决一些要求结果不特别精确的问题
经典问题:部分背包
贪心算法的关键是贪心策略的选择
时间复杂度
- 在不考虑排序的前提下,贪心算法只需要一次循环,所以时间复杂度是O(n)
优缺点
- 优点:性能高,能用贪心算法解决的往往是最优解
- 缺点:在实际情况下能用的不多,用贪心算法解的往往不是最好的
适用场景
- 针对一组数据,我们定义了限制值和期望值,希望从中选出几个数据,在满足限制值的情况下,期望值最大。
- 每次选择当前情况下,在对限制值同等贡献量的情况下,对期望值贡献最大的数据(局部最优而全局最优)
- 大部分能用贪心算法解决的问题,贪心算法的正确性都是显而易见的,也不需要严格的数学推导证明
- 在实际情况下,用贪心算法解决问题的思路,并不总能给出最优解
分治算法
概念
- 分治算法(divide and conquer)的核心思想其实就是四个字,分而治之 ,也就是将原问题划分成 n个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。
关于分治和递归的区别
- 分治算法是一种处理问题的思想,递归是一种编程技巧
分治算法的递归实现中,每一层递归都会涉及这样三个操作:
分解:将原问题分解成一系列子问题
解决:递归地求解各个子问题,若子问题足够小,则直接求解
合并:将子问题的结果合并成原问题
时间复杂度
- 根据拆分情况可以是O(n)或O(logn)
优缺点
- 优势:将复杂的问题拆分成简单的子问题,解决更容易,另外根据拆分规则,性能有可能提高。
- 劣势:子问题必须要一样,用相同的方式解决
适用场景
- 分治算法能解决的问题,一般需要满足下面这几个条件:
- 原问题与分解成的小问题具有相同的模式;
- 原问题分解成的子问题可以独立求解,子问题之间没有相关性,这一点是分治算法跟动态规划的明显区别
- 具有分解终止条件,也就是说,当问题足够小时,可以直接求解;
- 可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法总体复杂度的效果了。
回溯算法
概念
- 回溯算法实际上一个类似枚举的深度优先搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回(也就是递归返回),尝试别的路径。
- 回溯的处理思想,有点类似枚举(列出所有的情况)搜索。我们枚举所有的解,找到满足期望的解。为了有规律地枚举所有可能的解,避免遗漏和重复,我们把问题求解的过程分为多个阶段。每个阶段,我们都会面对一个岔路口,我们先随意选一条路走,当发现这条路走不通的时候(不符合期望的解),就回退到上一个岔路口,另选一种走法继续走。
经典问题
- N皇后问题
/**
* 回溯算法:N皇后问题
*/
public class NQueens {
//皇后数
static int QUEENS = 8;
//下标表示行,值表示queen存储在哪一列
int[] result = new int[QUEENS];
/**
* 在每行放置Queen
*
* @param row
*/
public void setQueens(int row) {
//递归中断
if (row == QUEENS) {
printQueens();
return ;
}
//在每行依次放置列 没有合适的则回到上一层
for(int col=0;col<QUEENS;col++){
if(isOk(row,col)){
//设置列
result[row]=col;
//开始下一行
setQueens(row+1);
}
}
}
/**
* 打印输出
*/
private void printQueens() {
for (int i = 0; i < QUEENS; i++) {
for (int j = 0; j < QUEENS; j++) {
if (result[i] == j) {
System.out.print("Q| ");
}
else {
System.out.print("*| ");
}
}
System.out.println();
}
System.out.println("-----------------------");
}
/**
* 判断是否可以放置
*
* @param row 行
* @param col 列
* @return
*/
private boolean isOk(int row, int col) {
int leftup = col - 1;
int rightup = col + 1;
// 逐行往上考察每一行
for (int i = row - 1; i >= 0; i--) {
//列上存在queen
if (result[i] == col) return false;
//左上对角线存在queen
if (leftup >= 0) {
if (result[i] == leftup) return false;
}
//右下对角线存在queen
if (rightup < QUEENS) {
if (result[i] == rightup) return false;
}
leftup--;
rightup++;
}
return true;
}
public static void main(String[] args) {
NQueens queens=new NQueens();
queens.setQueens(0);
}
}
时间复杂度
- N皇后问题的时间复杂度为: 实际为
优缺点
- 优点:
回溯算法的思想非常简单,大部分情况下,都是用来解决广义的搜索问题,也就是,从一组可能的解中,选择出一个满足要求的解。回溯算法非常适合用递归来实现,在实现的过程中,剪枝操作是提高回溯效率的一种技巧。利用剪枝,我们并不需要穷举搜索所有的情况,从而提高搜索效率。 - 劣势:
效率相对于低(动态规划)
适用场景
- 回溯算法是个“万金油”。基本上能用的动态规划、贪心解决的问题,我们都可以用回溯算法解决。回溯算法相当于穷举搜索。穷举所有的情况,然后对比得到最优解。不过,回溯算法的时间复杂度非常高,是指数级别的,只能用来解决小规模数据的问题。对于大规模数据的问题,用回溯算法解决的执行效率就很低了
动态规划
概念
- 动态规划(Dynamic Programming),是一种分阶段求解的方法。
- 动态规划算法是通过拆分问题,定义问题状态和状态之间的关- 系,使得问题能够以递推(或者说分治)的方式去解决。
- 首先是拆分问题,我的理解就是根据问题的可能性把问题划分成一步一步这样就可以通过递推或者递归来实现. 关键就是这个步骤,动态规划有一类问题就是从后往前推到,有时候我们很容易知道:如果只有一种情况时,最佳的选择应该怎么做.然后根据这个最佳选择往前一步推导,得到前一步的最佳选择 然后就是定义问题状态和状态之间的关系,我的理解是前面拆分的步骤之间的关系,用一种量化的形式表现出来,类似于高中学的推导公式,因为这种式子很容易用程序写出来,也可以说对程序比较亲和(也就是最后所说的状态转移方程式) 我们再来看定义的下面的两段,我的理解是比如我们找到最优解,我们应该讲最优解保存下来,为了往前推导时能够使用前一步的最优解,在这个过程中难免有一些相比于最优解差的解,此时我们应该放弃,只保存最优解,这样我们每一次都把最优解保存了下来,大大降低了
时间复杂度
动态规划中有三个重要概念:
- 最优子结构
- 边界
- 状态转移公式(递推方程)dp方程
经典问题
- 再谈斐波那契数列
优化递归:
通过上边的递归树可以看出在树的每层和上层都有大量的重复计算,可以把计算结果存起来,下次再用的时候就不用再计算了,这种方式叫记忆搜索,也叫做备忘录模式
/**
* 斐波那契数列: 递归分治+记忆搜索(备忘录)
*/
public class Fib2 {
//用于存储每次的计算结果
static int[] sub=new int[10];
public static int fib(int n){
if(n<=1) return n;
//没有计算过则计算
if(sub[n]==0){
sub[n]=fib(n-1)+fib(n-2);
}
//计算过直接返回
return sub[n];
}
public static void main(String[] args) {
System.out.println(fib(9));
}
}
/**
* 斐波那契数列 自底向上 递推
*/
public class Fib3 {
public static int fib(int n){
int a[]=new int[n+1];
a[0]=0;
a[1]=1;
int i;
// a[n]= a[n-1]+a[n-2]
for(i=2;i<=n;i++){
a[i]=a[i-1]+a[i-2];
}
// i已经加1了,所以要减掉1
return a[i-1];
}
public static void main(String[] args) {
System.out.println(fib(9));
}
}
使用动态规划四个步骤
- 把当前的复杂问题转化成一个个简单的子问题(分治)
- 寻找子问题的最优解法(最优子结构)
- 把子问题的解合并,存储中间状态
- 递归+记忆搜索或自底而上的形成递推方程(dp方程)
时间复杂度
- 新的斐波那契数列实现时间复杂度为O(n)
优缺点
- 优点:时间复杂度和空间复杂度都相当较低
- 缺点:难,有些场景不适用
适用场景
- 尽管动态规划比回溯算法高效,但是,并不是所有问题,都可以用动态规划来解决。能用动态规划解决的问题,需要满足三个特征,最优子结构、无后效性和重复子问题。在重复子问题这一点上,动态规划和分治算法的区分非常明显。分治算法要求分割成的子问题,不能有重复子问题,而动态规划正好相反,动态规划之所以高效,就是因为回溯算法实现中存在大量的重复子问题。
内容总结
以上是互联网集市为您收集整理的数据结构与算法全部内容,希望文章能够帮你解决数据结构与算法所遇到的程序开发问题。 如果觉得互联网集市技术教程内容还不错,欢迎将互联网集市网站推荐给程序员好友。
内容备注
版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 gblab@vip.qq.com 举报,一经查实,本站将立刻删除。
内容手机端
扫描二维码推送至手机访问。