10-1-2 数据结构与算法高级(下)
目录
- 三、数据结构与算法高级
- 3.1 树
- 3.1.1 树的概念
- 3.1.2 二叉树
- 3.1.3 二叉查找树
- 3.1.4 二叉树的遍历
- 3.1.5 红黑树
- 3.1.5.1 左旋(RotateLeft)
- 3.1.5.2 右旋(RotateRight)
- 3.1.5.3 颜色反转
- 3.1.6 多路查找树
- 3.1.6.1 B树
- 3.1.6.2 B+树
- 3.1.7 二叉堆
- 3.1.7.1 大顶堆(最大堆)
- 3.1.7.2 小顶堆(最小堆)
- 3.1.7.3 二叉堆的存储原理
- 3.2 排序
- 3.2.1 冒泡排序
- 3.2.2 快速排序
- 3.2.3 堆排序
- 3.2.4 计数排序
- 3.2.5 桶排序
- 3.3 字符串匹配
- 3.3.1 BF 算法
- 3.3.2 RK 算法
- 3.3.3 BM 算法
- 3.3.4 Trie 树
- 3.4 图
- 3.4.1 **图的概念**
- 3.4.2 图的存储
- 3.4.3 邻接表
- 3.4.4 图的遍历
- 3.5 算法思维
- 3.5.1 贪心算法
- 3.5.2 分治算法
- 3.5.3 回溯算法
- 3.5.4 动态规划
- 四、大厂面试题
- 4.1 环形链表问题
- 4.2 0-1 背包问题
三、数据结构与算法高级
3.1 树
3.1.1 树的概念
有很多数据的逻辑关系并不是线性关系,在实际场景中,常常存在着一对多,甚至是多对多的情况。
家谱:
组织结构:
书的目录:
以上的数据结构,我们称为树
在数据结构中,树的定义如下:
树(tree)是n(n≥0)个节点的有限集。
当n=0时,称为空树。在任意一个非空树中,有如下特点。
有且仅有一个特定的称为根的节点。
当n>1时,其余节点可分为m(m>0)个互不相交的有限集
每一个集合本身又是一个树,并称为根的子树。
一个标准的树结构:
节点1是根节点(root),没有父节点
节点5、6、7、8是树的末端,没有“孩子”,被称为叶子节点(leaf)
节点2、3、4、是树的中端,有父节点,有孩子,被称为中间节点或枝节点
图中的虚线部分,是根节点1的其中一个子树
树的最大层级数,被称为树的高度或深度,上图这个树的高度是4
树的分类如下:
树的种类非常多,我们会选几个有代表性的详细讲解。
3.1.2 二叉树
二叉树(binary tree)是树的一种特殊形式。二叉,顾名思义,这种树的每个节点最多有2个孩子节点。注意,这里是最多有2个,也可能只有1个,或者没有孩子节点。
二叉树节点的两个孩子节点,一个被称为左孩子(left child),一个被称为右孩子(right child)。这两个孩子节点的顺序是固定的,左孩子小于右孩子。
- 满二叉树
一个二叉树的所有非叶子节点都存在左右孩子,并且所有叶子节点都在同一层级上,那么这个树就
是满二叉树
- 完全二叉树
对一个有n个节点的二叉树,按层级顺序编号,则所有节点的编号为从1到n。如果这个树所有节点和同样深度的满二叉树的编号为从1到n的节点位置相同,则这个二叉树为完全二叉树
满二叉树要求所有分支都是满的;而完全二叉树只需保证最后一个节点之前的节点都齐全即可
二叉树的存储
二叉树属于逻辑结构,可以使用链表和数组进行存储。
- 链式存储
二叉树的每一个节点包含3部分
存储数据的data变量
指向左孩子的left指针
指向右孩子的right指针
数组存储
使用数组存储时,会按照层级顺序把二叉树的节点放到数组中对应的位置上。
如果某一个节点的左孩子或右孩子空缺,则数组的相应位置也空出来
寻址方式:
一个父节点的下标是n,那么它的左孩子节点下标就是2×n+1、右孩子节点下标就是2*(n+1)
对于一个稀疏的二叉树(孩子不满)来说,用数组表示法是非常浪费空间的
所以二叉树一般用链表存储实现。(二叉堆除外)
3.1.3 二叉查找树
二叉查找树(binary search tree),二叉查找树在二叉树的基础上增加了以下几个条件:
如果左子树不为空,则左子树上所有节点的值均小于根节点的值
如果右子树不为空,则右子树上所有节点的值均大于根节点的值
左、右子树也都是二叉查找树
如下图:
二叉查找树要求左子树小于父节点,右子树大于父节点,正是这样保证了二叉树的有序性。
因此二叉查找树还有另一个名字——二叉排序树(binary sort tree)。
查找
例如查找值为4的节点,步骤如下:
- 访问根节点6,发现4<6。
- 访问节点6的左孩子节点3,发现4>3
- 访问节点3的右孩子节点4,发现4=4,这正是要查找的节点
对于一个节点分布相对均衡的二叉查找树来说,如果节点总数是n,那么搜索节点的时间复杂度就
是O(logn),和树的深度是一样的。这种方式正是二分查找思想。
插入
例如插入新元素5,步骤如下:
4. 访问根节点6,发现5<6
5. 访问节点6的左孩子节点3,发现5>3
6. 访问节点3的右孩子节点4,发现5>4
7. 5最终会插入到节点4的右孩子位置
3.1.4 二叉树的遍历
二叉树,是典型的非线性数据结构,遍历时需要把非线性关联的节点转化成一个线性的序列,以不同的方式来遍历,遍历出的序列顺序也不同。
二叉树的遍历包括
- 深度优先遍历
所谓深度优先,顾名思义,就是偏向于纵深,“一头扎到底”的访问方式。它包括:
前序遍历
二叉树的前序遍历,输出顺序是根节点、左子树、右子树
步骤如下:
1、首先输出的是根节点1
2、由于根节点1存在左孩子,输出左孩子节点2
3、由于节点2也存在左孩子,输出左孩子节点4
4、节点4既没有左孩子,也没有右孩子,那么回到节点2,输出节点2的右孩子节点5
5、节点5既没有左孩子,也没有右孩子,那么回到节点1,输出节点1的右孩子节点3
6、节点3没有左孩子,但是有右孩子,因此输出节点3的右孩子节点6
到此为止,所有的节点都遍历输出完毕
中序遍历
二叉树的中序遍历,输出顺序是左子树、根节点、右子树
步骤如下:
1、首先访问根节点的左孩子,如果这个左孩子还拥有左孩子,则继续深入访问下去,一直找到不再有左孩子 的节点,并输出该节点。显然,第一个没有左孩子的节点是节点4
2、依照中序遍历的次序,接下来输出节点4的父节点2
3、再输出节点2的右孩子节点5
4、以节点2为根的左子树已经输出完毕,这时再输出整个二叉树的根节点1
5、由于节点3没有左孩子,所以直接输出根节点1的右孩子节点3
6、最后输出节点3的右孩子节点6
到此为止,所有的节点都遍历输出完毕
后序遍历
二叉树的后序遍历,输出顺序是左子树、右子树、根节点
步骤如下:
1、首先访问根节点的左孩子,如果这个左孩子还拥有左孩子,则继续深入访问下去,一直找到不再有左孩子 的节点,并输出该节点。显然,第一个没有左孩子的节点是节点4
2、输出右节点5
3、输出节点4的父节点2
4、以节点2为根的左子树已经输出完毕,这时再输出整个二叉树的右子树
5、访问根节点的右孩子,如果这个右孩子拥有左孩子,则继续深入访问下去,一直找到不再有左孩子 的节点,如果没有左孩子则找右孩子,并输出该节点6
6、输出节点6的父节点3
到此为止,所有的节点都遍历输出完毕
代码如下:
广度优先遍历
也叫层序遍历,顾名思义,就是二叉树按照从根节点到叶子节点的层次关系,一层一层横向遍历各个节点。
二叉树同一层次的节点之间是没有直接关联的,利用队列可以实现
1、根节点1进入队列
2、节点1出队,输出节点1,并得到节点1的左孩子节点2、右孩子节点3。让节点2和节点3入队
3、节点2出队,输出节点2,并得到节点2的左孩子节点4、右孩子节点5。让节点4和节点5入队
4、节点3出队,输出节点3,并得到节点3的右孩子节点6。让节点6入队
5、节点4出队,输出节点4,由于节点4没有孩子节点,所以没有新节点入队
6、节点5出队,输出节点5,由于节点5同样没有孩子节点,所以没有新节点入队
7、节点6出队,输出节点6,节点6没有孩子节点,没有新节点入队
时间复杂度
二叉查找树的插入和查找时间复杂度为:O(logn)
极端情况下二叉查找树退化成链表,时间复杂度为O(n),所以需要平衡二叉查找树。
应用
非线性数据:菜单,组织结构、家谱等等
线性数据:二叉查找树
二叉查找树是有序的,我们只需要中序遍历,就可以在 O(n) 的时间复杂度内,输出有序的数据序列。
二叉查找树的性能非常稳定,扩容很方便(链表实现)
3.1.5 红黑树
平衡二叉查找树
这种二叉查找树就退化成了链表,由于树的深度变得多了,查找的效率也会大幅下降
所以需要对这种二叉树进行自平衡,红黑树就是一种自平衡的二叉查找树。
红黑树(Red Black Tree)
除了二叉查找树(BST)的特征外,还有以下特征:
1、每个节点要么是黑色,要么是红色
2、根节点是黑色
3、每个叶子节点都是黑色的空结点(NIL结点)(为了简单期间,一般会省略该节点)
4、如果一个节点是红色的,则它的子节点必须是黑色的(父子不能同为红)
5、从任一结点到其每个叶子的所有路径都包含相同数目的黑色结点(平衡的关键)
6、新插入节点默认为红色,插入后需要校验红黑树是否符合规则,不符合则需要进行平衡
一颗典型的红黑树:
在对红黑树进行添加或者删除操作时可能会破坏这些特点,所以红黑树采取了很多方式来维护这些特点,从而维持平衡。主要包括:左旋转、右旋转和颜色反转
3.1.5.1 左旋(RotateLeft)
逆时针旋转红黑树的两个结点,使得父结点被自己的右孩子取代,而自己成为自己的左孩子
上图所示过程如下:
- 以X为基点逆时针旋转
- X的父节点被x原来的右孩子Y取代
- c保持不变
- Y节点原来的左孩子c变成X的右孩子
3.1.5.2 右旋(RotateRight)
顺时针旋转红黑树的两个结点,使得父结点被自己的左孩子取代,而自己成为自己的右孩子
上图所示过程如下:
- 以X为基点顺时针旋转
- X的父节点被x原来的左孩子Y取代
- b保持不变
- Y节点原来的右孩子c变成X的左孩子
3.1.5.3 颜色反转
就是当前节点与父节点、叔叔节点同为红色,这种情况违反了红黑树的规则,需要将红色向祖辈上传,父节点和叔叔节点红色变为黑色,爷爷节点从黑色变为红色(爷爷节点必为黑色,因为此前是符合红黑树规则的)。这样每条叶子结点到根节点的黑色节点数量并未发生变化,因此都其他树结构不产生影响
红黑树插入有五种情况,每种情况对应着不同的调整方法:
新结点(A)位于树根,没有父结点
直接让新结点变色为黑色,规则2得到满足。同时,黑色的根结点使得每条路径上的黑色结点数目
都增加了1,所以并没有打破规则5
新结点(B)的父结点是黑色
新插入的红色结点B并没有打破红黑树的规则,所以不需要做任何调整
新结点(D)的父结点和叔叔结点都是红色
两个红色结点B和D连续,违反了规则4。因此我们先让结点B变为黑色这样一来,结点B所在路径凭空多了一个黑色结点,打破了规则5。因此我们让结点A变为红色
结点A和C又成为了连续的红色结点,我们再让结点C变为黑色
经过上面的调整,这一局部重新符合了红黑树的规则新结点(D)的父结点是红色,叔叔结点是黑色或者没有叔叔,且新结点是父结点的右孩子,父结点(B)是祖父结点的左孩子
我们以结点B为轴,做一次左旋转,使得新结点D成为父结点,原来的父结点B成为D的左孩子
这样进入了情况5新结点(D)的父结点是红色,叔叔结点是黑色或者没有叔叔,且新结点是父结点的左孩子,父结点(B)是祖父结点的左孩子
我们以结点A为轴,做一次右旋转,使得结点B成为祖父结点,结点A成为结点B的右孩子
接下来,我们让结点B变为黑色,结点A变为红色
经过上面的调整,这一局部重新符合了红黑树的规则
红黑树构建过程
如下图:
上图所示过程如下:
- 新插入节点默认为红色,5<10,插入到左子节点,插入后左子树深度为2(叶子节点黑色+根节点黑色),右子树深度为也是2(叶子节点黑色+根节点黑色),满足红黑树规则。
- 新插入节点为红色,9<10,需要在左子树进行插入,再和5比较,大于5,放到5的右子树中,此时各个叶子节点到根节点的深度依然是2,但5和9两个节点都是红色,不满足规则第4条,需要进行左旋、右旋操作,使其符合规则。可以看出经过操作后,左右子树又维持了平衡。
上图所示过程如下:
- 插入节点3后,可以看到又不符合红黑树的规则了,而此时的情况,需要采用颜色反转的操作,就是把5、10两个节点变为黑色,5、10的父节点变为红色,但父节点9是根节点,不能为红色,于是再将9变为黑色,这样整个树的深度其实增加了1层。
- 继续插入6节点,对树深度没有影响。
- 插入7节点后,6、7节点都为红节点,不满足规则4,需要进行颜色反转调整,也就是7的父节点和叔叔节点变为黑色,爷爷节点5变为红色
上图所示过程如下:
- 继续插入节点19,对树深度没有影响,红黑树的规则都满足,无需调整
- 插入节点32后,又出现了不满足规则4的情况,此时节点32没有叔叔节点,如果颜色反转的话,左右子树的深度就出现不一致的情况,所以需要对爷爷节点进行左旋操作。
- 父节点取代爷爷节点的位置,父节点变为黑色,爷爷节点变为父节点的左子树变为红色。
上图所示过程如下:
- 插入节点24后,红黑树不满足规则4,需要调整。
- 此时父节点32和叔叔节点10都为红色,需要进行颜色反转,爷爷节点19变为红色,父节点、叔叔节点变为黑色,颜色反转树的深度不发生变化。
上图所示过程如下:
1.插入节点17后,未破坏红黑树规则,不需要调整。
代码实现
3.1.6 多路查找树
多路查找树(muitl-way search tree),其每一个节点的孩子数可以多于两个,且每一个节点处可以存储多个元素。
3.1.6.1 B树
B树(BalanceTree)是对二叉查找树的改进。它的设计思想是,将相关数据尽量集中在一起,以便一次读取多个数据,减少硬盘操作次数。
一棵m阶的B 树 (m叉树)的特性如下:
1、B树中所有节点的孩子节点数中的最大值称为B树的阶,记为M
2、树中的每个节点至多有M棵子树 —即:如果定了M,则这个B树中任何节点的子节点数量都不能超过M
3、若根节点不是终端节点,则至少有两棵子树
4、除根节点和叶节点外,所有点至少有m/2棵子树
5、所有的叶子结点都位于同一层
3.1.6.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+树只有叶子节点才会存储数据,而且存储的数据都是在一行上,而且这些数据都是有指针指向
的,也就是有顺序的
3.1.7 二叉堆
二叉堆本质上是一种完全二叉树,它分为两个类型
3.1.7.1 大顶堆(最大堆)
最大堆的任何一个父节点的值,都大于或等于它左、右孩子节点的值
3.1.7.2 小顶堆(最小堆)
最小堆的任何一个父节点的值,都小于或等于它左、右孩子节点的值
二叉堆的根节点叫作堆顶
最大堆和最小堆的特点决定了:最大堆的堆顶是整个堆中的最大元素;最小堆的堆顶是整个堆中的最小元素
3.1.7.3 二叉堆的存储原理
完全二叉树比较适合用数组来存储。用数组来存储完全二叉树是非常节省存储空间的。因为我们不需要存储左右子节点的指针,单纯地通过数组的下标,就可以找到一个节点的左右子节点和父节点
从图中我们可以看到,数组中下标为 i 的节点的左子节点,就是下标为 i∗2 的节点,右子节点就是下标为 i∗2+1 的节点,父节点就是下标为 i/2 取整的节点
二叉堆的典型应用
优先队列
利用堆求 Top K问题
在一个包含 n 个数据的数组中,我们可以维护一个大小为 K 的小顶堆,顺序遍历数组,从数组中取出数据与堆顶元素比较。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理,继续遍历数组。这样等数组中的数据都遍历完之后,堆中的数据就是前 K 大数据了
3.2 排序
在生活中,我们离不开排序,按大小个、按成绩等等
在计算机中也离不开排序:按编号、按价格、按远近等等
根据时间复杂度的不同,主流的排序算法可以分为3大类
- 时间复杂度为O( )的排序算法
冒泡排序、选择排序、插入排序、希尔排序 - 时间复杂度为O(nlogn)的排序算法
快速排序 、归并排序、堆排序 - 时间复杂度为线性的排序算法
计数排序、桶排序、基数排序
根据其稳定性,可以分为稳定排序和不稳定排序
4. 稳定排序:值相同的元素在排序后仍然保持着排序前的顺序
5. 不稳定排序:值相同的元素在排序后打乱了排序前的顺序
3.2.1 冒泡排序
冒泡排序是最基础的排序算法
冒泡排序的英文是bubble sort,它是一种基础的交换排序
冒泡排序这种排序算法的每一个元素都可以像小气泡一样,根据自身大小,一点一点地向着数组的一侧移动。
按照冒泡排序的思想,我们要把相邻的元素两两比较,当一个元素大于右侧相邻元素时,交换它们的位置;当一个元素小于或等于右侧相邻元素时,位置不变。
经过第一轮后:
元素9作为数列中最大的元素,就像是汽水里的小气泡一样,“漂”到了最右侧
每一轮结束都会有一个元素被移到最右侧
实现
冒泡排序的优化
思路:在外层循环处,设置标志isSort,默认为排好,如果不交换则跳出本次循环
内层循环优化
已经被移到右侧的元素不用再参与比较了
优化后的代码:
时间复杂度:O(n2)O( n^2)O(n2)
3.2.2 快速排序
同冒泡排序一样,快速排序也属于交换排序,通过元素之间的比较和交换位置来达到排序的目的。
不同的是,冒泡排序在每一轮中只把1个元素冒泡到数列的一端,而快速排序则在每一轮挑选一个基准元素,并让其他比它大的元素移动到数列一边,比它小的元素移动到数列的另一边,从而把数列拆解成两个部分,这种思路就叫作分治法。
基准元素的选择
基准元素,英文是pivot,在分治过程中,以基准元素为中心,把其他元素移动到它的左右两边
我们可以随机选择一个元素作为基准元素,并且让基准元素和数列首元素交换位置
元素的交换
选定了基准元素以后,我们要做的就是把其他元素中小于基准元素的都交换到基准元素一边,大于基准元素的都交换到基准元素另一边
双边循环法
首先,选定基准元素pivot,并且设置两个指针left和right,指向数列的最左和最右两个元素
接下来进行第1次循环:
从right指针开始,让指针所指向的元素和基准元素做比较。如果大于或等于pivot,则指针向左移动;
如果小于pivot,则right指针停止移动,切换到left指针
轮到left指针行动,让指针所指向的元素和基准元素做比较。如果小于或等于pivot,则指针向右移动;
如果大于pivot,则left指针停止移动
左右指针指向的元素交换位置
由于left开始指向的是基准元素,判断肯定相等,所以left右移1位
由于7>4,left指针在元素7的位置停下。这时,让left和right指针所指向的元素进行交换。
接下来,进入第2次循环,重新切换到right指针,向左移动。right指针先移动到8,8>4,继续左移。由于2<4,停止在2的位置
单边循环法
单边循环法只从数组的一边对元素进行遍历和交换。
开始和双边循环法相似,首先选定基准元素pivot。同时,设置一个mark指针指向数列起始位置,这个mark指针代表小于基准元素的区域边界。
接下来,从基准元素的下一个位置开始遍历数组。
如果遍历到的元素大于基准元素,就继续往后遍历
如果遍历到的元素小于基准元素,则需要做两件事:
第一,把mark指针右移1位,因为小于pivot的区域边界增大了1;
第二,让最新遍历到的元素和mark指针所在位置的元素交换位置,因为最新遍历的元素归属于小于pivot的区域
首先遍历到元素7,7>4,所以继续遍历。
接下来遍历到的元素是3,3<4,所以mark指针右移1位
随后,让元素3和mark指针所在位置的元素交换,因为元素3归属于小于pivot的区域。
按照这个思路,继续遍历,后续步骤如图所示
代码实现
快速排序的时间复杂度是:O(nlogn)O(nlogn)O(nlogn)
3.2.3 堆排序
堆排序:堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。
堆是具有以下性质的完全二叉树
大顶堆:每个结点的值都大于或等于其左右孩子结点的值
小顶堆:每个结点的值都小于或等于其左右孩子结点的值
我们对堆中的结点按层进行编号,将这种逻辑结构映射到数组中:
3.2.4 计数排序
计数排序,这种排序算法是利用数组下标来确定元素的正确位置的。
假设数组中有10个整数,取值范围为0~10,要求用最快的速度把这10个整数从小到大进行排序。
可以根据这有限的范围,建立一个长度为11的数组。数组下标从0到10,元素初始值全为0
假设数组数据为:9,1,2,7,8,1,3,6,5,3
下面就开始遍历这个无序的随机数列,每一个整数按照其值对号入座,同时,对应数组下标的元素进行加1操作
例如第1个整数是9,那么数组下标为9的元素加1
最终,当数列遍历完毕时,数组的状态如下:
该数组中每一个下标位置的值代表数列中对应整数出现的次数
直接遍历数组,输出数组元素的下标值,元素的值是几,就输出几次,0不输出
则顺序输出是:1、1、2、3、3、5、6、7、8、9
计数排序:适合于连续的取值范围不大的数组
不连续和取值范围过大会造成数组过大
如果起始数不是从0开始,比如分数排序:
95,94,91,98,99,90,99,93,91,92
数组起始数为90,这样数组前面的位置就浪费了
可以采用偏移量的方式:
代码实现:
计数排序的时间复杂度是O(n+m)
n: 数据个数
m: 数据范围
3.2.5 桶排序
桶排序同样是一种线性时间的排序算法
桶排序需要创建若干个桶来协助排序
每一个桶(bucket)代表一个区间范围,里面可以承载一个或多个元素
桶排序的第1步,就是创建这些桶,并确定每一个桶的区间范围具体需要建立多少个桶,如何确定桶的区间范围,有很多种不同的方式。我们这里创建的桶数量等于原始数列的元素数量,除最后一个桶只包含数列最大值外, 前面各个桶的区间按照比例来确定。
区间跨度 = (最大值-最小值)/ (桶的数量 - 1)
假设有一个非整数数列如下:
4.5,0.84,3.25,2.18,0.5
第2步,遍历原始数列,把元素对号入座放入各个桶中
第3步,对每个桶内部的元素分别进行排序(显然,只有第1个桶需要排序)
第4步,遍历所有的桶,输出所有元素
0.5,0.84,2.18,3.25,4.5
桶排序的时间复杂度是O(n)
各个排序比对表:
排序算法 | 时间复杂度 | 空间复杂度 | 是否稳定 |
---|---|---|---|
冒泡排序 | O(n2)O(n^2)O(n2) | O(1) | 稳定 |
快速排序 | O(nlogn)O(nlogn)O(nlogn) | O(logn) | 不稳定 |
堆排序 | O(nlogn)O(nlogn)O(nlogn) | O(1) | 不稳定 |
计数排序 | O(mn)O(m_n)O(mn) | O(m) | 稳定 |
桶排序 | O(n)O(n)O(n) | O(n) | 稳定 |
3.3 字符串匹配
字符串匹配这个功能,是非常常见的功能,比如"Hello"里是否包含"el"?
Java里用的是indexOf函数,其底层就是字符串匹配算法。主要分类如下:
3.3.1 BF 算法
BF 算法中的 BF 是 Brute Force 的缩写,中文叫作暴力匹配算法,也叫朴素匹配算法。
这种算法的字符串匹配方式很“暴力”,当然也就会比较简单、好懂,但相应的性能也不高
比方说,我们在字符串 A 中查找字符串 B,那字符串 A 就是主串,字符串 B 就是模式串
我们在主串中,检查起始位置分别是 0、1、2…n-m 且长度为 m 的 n-m+1 个子串,看有没有跟模式串匹配的。
时间复杂度
我们每次都比对 m 个字符,要比对 n-m+1 次,所以,这种算法的最坏情况时间复杂度是 O(n*m)。
m:为匹配串长度
n:为主串长度
应用
虽然BF算法效率不高但在实际情况下却很常用。因为:
主串不会太长,实现简单
3.3.2 RK 算法
RK 算法的全称叫 Rabin-Karp 算法,是由它的两位发明者 Rabin 和 Karp 的名字来命名的
每次检查主串与子串是否匹配,需要依次比对每个字符,所以 BF 算法的时间复杂度就比较高,是O(n*m)。我们对朴素的字符串匹配算法稍加改造,引入哈希算法,时间复杂度立刻就会降低
RK 算法的思路是这样的:我们通过哈希算法对主串中的 n-m+1 个子串分别求哈希值,然后逐个与模式串的哈希值比较大小。如果某个子串的哈希值与模式串相等,那就说明对应的子串和模式串匹配了(这里先不考虑哈希冲突的问题)。因为哈希值是一个数字,数字之间比较是否相等是非常快速的,所以模式串和子串比较的效率就提高了
可以设计一个hash算法:
将字符串转化成整数,利用K进制的方式
数字1-0 : 10进制
123的拆解
1∗1021* 10^21∗102 + 2∗1012*10^12∗101+ 3∗1003*10^03∗100
100+20+3=123
小写字母a-z:26进制
大小写字母a-Z:52进制
大小写字母+1-0:62进制
以只是小写字母的26进制为例
字符串“abc”转化成hash值的算法是:
a的ASCII码是97
b的ASCII码是98
c的ASCII码是99
97∗26297* 26^297∗262 + 98∗36198*36^198∗361+ 99∗26099*26^099∗260
65572+2548+99=68219
字符串“abc”转化成hash值是68219
如果觉得计算太麻烦也可以从97开始,即
字符串“abc”转化成hash值的算法是:
(97−97)∗262(97-97)* 26^2(97−97)∗262 + (98−97)∗361(98-97)*36^1(98−97)∗361+ (99−97)∗260(99-97)*26^0(99−97)∗260
0+26+2=28
代码如下:
3.3.3 BM 算法
BF 算法性能会退化的比较严重,而 RK 算法需要用到哈希算法,而设计一个可以应对各种类型字符的哈希算法并不简单。
BM(Boyer-Moore)算法。它是一种非常高效的字符串匹配算法,滑动算法
在这个例子里,主串中的 c,在模式串中是不存在的,所以,模式串向后滑动的时候,只要 c 与模式串有重合,肯定无法匹配。所以,我们可以一次性把模式串往后多滑动几位,把模式串移动到 c 的后面。
BM 算法,本质上其实就是在寻找这种规律。借助这种规律,在模式串与主串匹配的过程中,当模式串和主串某个字符不匹配的时候,能够跳过一些肯定不会匹配的情况,将模式串往后多滑动几位。
算法原理
BM 算法包含两部分,分别是坏字符规则(bad character rule)和好后缀规则(good suffix shift)。
坏字符规则
BM 算法的匹配顺序比较特别,它是按照模式串下标从大到小的顺序,倒着匹配的。
我们从模式串的末尾往前倒着匹配,当我们发现某个字符没法匹配的时候。我们把这个没有匹配的字符叫作坏字符(主串中的字符)。
字符 c 与模式串中的任何字符都不可能匹配。这个时候,我们可以将模式串直接往后滑动三位,将模式串滑动到 c 后面的位置,再从模式串的末尾字符开始比较。
坏字符 a 在模式串中是存在的,模式串中下标是 0 的位置也是字符 a。这种情况下,我们可以将模式串往后滑动两位,让两个 a 上下对齐,然后再从模式串的末尾字符开始,重新匹配。
当发生不匹配的时候,我们把坏字符对应的模式串中的字符下标记作 si。如果坏字符在模式串中存在,
我们把这个坏字符在模式串中的下标记作 xi。如果不存在,我们把 xi 记作 -1。那模式串往后移动的位
数就等于si−xisi-xisi−xi 。(下标,都是字符在模式串的下标)
第一次移动3位
c在模式串中不存在,所以 xi=−1xi=-1xi=−1,移动位数n=2−(−1)=3n=2-(-1)=3n=2−(−1)=3
第一次移动2位
a在模式串中存在,所以xi=0xi=0xi=0 ,移动位数n=2−0=2n=2-0=2n=2−0=2好后缀规则
我们把已经匹配的 我们拿它在模式串中查找,如果找到了另一个跟{u}相匹配的子串{u},那我们就 将模式串滑动到子{u}与主串中{u}对齐的位置。
如果在模式串中找不到另一个等于{u}的子串,我们就直接将模式串,滑动到主串中{u}的后面,因为之前的任何一次往后滑动,都没有匹配主串中{u}的情况
过度滑动情况:
当模式串滑动到前缀与主串中{u}的后缀有部分重合的时候,并且重合的部分相等的时候,就有可能会存在完全匹配的情况。
所以,针对这种情况,我们不仅要看好后缀在模式串中,是否有另一个匹配的子串,我们还要考察好后缀的后缀子串(c),是否存在跟模式串的前缀子串(c)匹配的。
如何选择坏字符和好后缀
我们可以分别计算好后缀和坏字符往后滑动的位数,然后取两个数中最大的,作为模式串往后滑动的位数。
算法实现
坏字符:
如果我们拿坏字符,在模式串中顺序遍历查找,这样就会比较低效
可以采用散列表,我们可以用一个256数组,来记录每个字符在模式串中的位置,数组下标可以直接对应字符的ASCII码值,数组的值为字符在模式串中的位置,没有的记为-1
bc[97]=a
bc[98]=b
bc[100]=d
有重复的字母以后面的位置为准
BM算法的时间复杂度是O(N/M)
n:主串长度
m:模式串长度
应用
BM算法比较高效,在实际开发中,特别是一些文本编辑器中,用于实现查找字符串功能。
3.3.4 Trie 树
Trie 树,也叫“字典树”。它是一个树形结构。它是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题。
比如:有 6 个字符串,它们分别是:how,hi,her,hello,so,see,我们可以将这六个字符串组成Trie树结构。
Trie 树的本质,就是利用字符串之间的公共前缀,将重复的前缀合并在一起。
其中,根节点不包含任何信息。每个节点表示一个字符串中的字符,从根节点到红色节点的一条路径表示一个字符串(红色节点为叶子节点)
Trie树的插入
Trie树的查找
当我们在 Trie 树中查找一个字符串的时候,比如查找字符串“her”,那我们将要查找的字符串分割成单个的字符 h,e,r,然后从 Trie 树的根节点开始匹配。如图所示,绿色的路径就是在 Trie 树中匹配的路径。
Trie 树是一个多叉树
我们通过一个下标与字符一一映射的数组,来存储子节点的指针
假设我们的字符串中只有从 a 到 z 这 26 个小写字母,我们在数组中下标为 0 的位置,存储指向子节点a 的指针,下标为 1 的位置存储指向子节点 b 的指针,以此类推,下标为 25 的位置,存储的是指向的子节点 z 的指针。如果某个字符的子节点不存在,我们就在对应的下标的位置存储 null。
当我们在 Trie 树中查找字符串的时候,我们就可以通过字符的 ASCII 码减去“a”的 ASCII 码,迅速找到匹配的子节点的指针。比如,d 的 ASCII 码减去 a 的 ASCII 码就是 3,那子节点 d 的指针就存储在数组中下标为 3 的位置中
时间复杂度
如果要在一组字符串中,频繁地查询某些字符串,用 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 展示在搜索提示框内。这就是搜索关键词提示的最基本的算法原理。
3.4 图
3.4.1 图的概念
图(Graph),是一种复杂的非线性表结构。
图中的元素我们就叫做顶点(vertex)
图中的一个顶点可以与任意其他顶点建立连接关系。我们把这种建立的关系叫做边(edge)
跟顶点相连接的边的条数叫做度(degree)
图这种结构有很广泛的应用,比如社交网络,电子地图,多对多的关系就可以用图来表示。
边有方向的图叫做有向图,比如A点到B点的直线距离,微信的添加好友是双向的
边无方向的图叫无向图,比如网络拓扑图
带权图(weighted graph)。在带权图中,每条边都有一个权重(weight),我们可以通过这个权重来表示 一些可度量的值
3.4.2 图的存储
图最直观的一种存储方法就是,邻接矩阵(Adjacency Matrix)。
邻接矩阵的底层是一个二维数组
无向图:如果顶点 i 与顶点 j 之间有边,我们就将 A[i][j]和 A[j][i]标记为 1
有向图:
如果顶点 i 到顶点 j 之间,有一条箭头从顶点 i 指向顶点 j 的边,那我们就将 A[i][j]标记为 1。同理,如果有一条箭头从顶点 j 指向顶点 i 的边,我们就将 A[j][i]标记为 1
带权图
数组中就存储相应的权重
3.4.3 邻接表
用邻接矩阵来表示一个图,虽然简单、直观,但是比较浪费存储空间
对于无向图来说,如果 A[i][j]等于 1,那 A[j][i]也肯定等于 1。实际上,我们只需要存储一个就可以了。也就是说,无向图的二维数组中,如果我们将其用对角线划分为上下两部分,那我们只需要利用上面或者下面这样一半的空间就足够了,另外一半白白浪费掉了
还有,如果我们存储的是稀疏图(Sparse Matrix),也就是说,顶点很多,但每个顶点的边并不多,那邻接矩阵的存储方法就更加浪费空间了。比如微信有好几亿的用户,对应到图上就是好几亿的顶点。但是每个用户的好友并不会很多,一般也就三五百个而已。如果我们用邻接矩阵来存储,那绝大部分的存储空间都被浪费了
针对上面邻接矩阵比较浪费内存空间的问题,我们来看另外一种图的存储方法,邻接表(AdjacencyList)。
每个顶点对应一条链表,链表中存储的是与这个顶点相连接的其他顶点。
图中画的是一个有向图的邻接表存储方式,每个顶点对应的链表里面,存储的是指向的顶点。
前面的数组存储的是所有的顶点,每一个顶点后面连接的块代表前面顶点所指向的顶点和路线的权值。如果该点还指向其他顶点,则继续在块后面添加。例如A指向了B权值是4,那么A后面就加上一块,之后发现A还指向D权值是5,那么就在块尾继续添加一块。其实也就是数组+链表的结构
根据邻接表的结构和图,我们不难发现,图其实是由顶点和边组成的。所以我们就抽象出两种类,一个是Vertex顶点类,一个是Edge边类
3.4.4 图的遍历
遍历是指从某个节点出发,按照一定的的搜索路线,依次访问对数据结构中的全部节点,且每个节点仅访问一次
前面已经讲过了二叉树的节点遍历
类似的,图的遍历是指,从给定图中任意指定的顶点(称为初始点)出发,按照某种搜索方法沿着图的边访问图中的所有顶点,使每个顶点仅被访问一次,这个过程称为图的遍历。遍历过程中得到的顶点序列称为图遍历序列
图的遍历过程中,根据搜索方法的不同,又可以划分为两种搜索策略:
深度优先搜索
广度优先搜索
深度优先搜索(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(V2)O( V^2)O(V2) 的时间
广度优先搜索(BFS,Breadth First Search)
直观地讲,它其实就是一种“地毯式”层层推进的搜索策略,即先查找离起始顶点最近的,然后是次近的,依次往外搜索。
假设我们有这么一个图,里面有A、B、C、D、E、F、G、H 8 个顶点,点和点之间的联系如下图所示,对这个图进行深度优先的遍历。
依赖队列(Queue),先进先出(FIFO)。
一层一层地把与某个点相连的点放入队列中,处理节点的时候正好按照它们进入队列的顺序进行。
第一步,选择一个起始顶点,让我们从顶点 A 开始。把 A 压入队列,标记它为访问过(用红色标记)。
第二步,从队列的头取出顶点 A,打印输出到结果中,同时将与它相连的尚未被访问过的点按照字母大小顺序压入队列,同时把它们都标记为访问过,防止它们被重复地添加到队列中
第三步,从队列的头取出顶点 B,打印输出它,同时将与它相连的尚未被访问过的点(也就是 E 和 F)压入队列,同时把它们都标记为访问过。
第四步,继续从队列的头取出顶点 D,打印输出它,此时我们发现,与 D 相连的顶点 A 和 F 都被标记访问过了,所以就不要把它们压入队列里。
第五步,接下来,队列的头是顶点 G,打印输出它,同样的,G 周围的点都被标记访问过了。我们不做任何处理。
第六步,队列的头是 E,打印输出它,它周围的点也都被标记为访问过了,我们不做任何处理。
第七步,接下来轮到顶点 F,打印输出它,将 C 压入队列,并标记 C 为访问过。
第八步,将 C 从队列中移出,打印输出它,与它相连的 H 还没被访问到,将 H 压入队列,将它标记为访问过。
第九步,队列里只剩下 H 了,将它移出,打印输出它,发现它的邻居都被访问过了,不做任何事情。
第十步,队列为空,表示所有的点都被处理完毕了,程序结束。
最短路径问题
广度优先搜索,一般用来解决最短路径的问题。
时间复杂度
邻接表
每个顶点都需要被访问一次,时间复杂度是 O(V);相连的顶点(也就是每条边)也都要被访问一次,加起来就是 O(E)。因此整体时间复杂度就是 O(V+E)。
邻接矩阵
V 个顶点,每次都要检查每个顶点与其他顶点是否有联系,因此时间复杂度是 O(v2)O(v^2 )O(v2)。
应用
广度优先的搜索可以同时从起始点和终点开始进行,称之为双端 BFS。这种算法往往可以大大地提高搜索的效率。
社交网络可以用图来表示。这个问题就非常适合用图的广度优先搜索算法来解决,因为广度优先搜索是层层往外推进的。首先,遍历与起始顶点最近的一层顶点,也就是用户的一度好友,然后再遍历与用户距离的边数为 2 的顶点,也就是二度好友关系,以及与用户距离的边数为 3 的顶点,也就是三度好友关系。
3.5 算法思维
3.5.1 贪心算法
概念
贪婪算法(Greedy)的定义:是一种在每一步选中都采取在当前状态下最好或最优的选择,从而希望导致结果是全局最好或最优的算法。
贪婪算法:当下做局部最优判断,不能回退(能回退的是回溯,最优+回退是动态规划)
由于贪心算法的高效性以及所求得答案比较接近最优结果,贪心算法可以作为辅助算法或解决一些要求结果不特别精确的问题
注意:当下是最优的,并不一定全局是最优的。举例如下:
有硬币分值为10、9、4若干枚,问如果组成分值18,最少需要多少枚硬币?
采用贪心算法,选择当下硬币分值最大的:10
18-10=8
8/4=2
即:1个10、2个4,共需要3枚硬币
实际上我们知道,选择分值为9的硬币,2枚就够了
18/9=2
如果改成:
有硬币分值为10、5、1若干枚,问如果组成分值16,最少需要多少枚硬币?
采用贪心算法,选择当下硬币分值最大的:10
16-10=6
6-5=1
即:1个10,1个5,1个1 ,共需要3枚硬币
即为最优解
由此可以看出贪心算法适合于一些特殊的情况,如果能用一定是最优解
经典问题:部分背包
背包问题是算法的经典问题,分为部分背包和0-1背包,主要区别如下:
部分背包:某件物品是一堆,可以带走其一部分
0-1背包:对于某件物品,要么被带走(选择了它),要么不被带走(没有选择它),不存在只带走一部分的情况。
部分背包问题可以用贪心算法求解,且能够得到最优解。
假设一共有N件物品,第 i 件物品的价值为 Vi ,重量为Wi,一个小偷有一个最多只能装下重量为W的背包,他希望带走的物品越有价值越好,可以带走某件物品的一部分,请问:他应该选择哪些物品?
假设背包可容纳50Kg的重量,物品信息如下表:
贪心算法的关键是贪心策略的选择
将物品按单位重量 所具有的价值排序。总是优先选择单位重量下价值最大的物品
按照我们的贪心策略,单位重量的价值排序: 物品A > 物品B > 物品C
因此,我们尽可能地多拿物品A,直到将物品1拿完之后,才去拿物品B,然后是物品C 可以只拿一部分…
时间复杂度
在不考虑排序的前提下,贪心算法只需要一次循环,所以时间复杂度是O(n)
优缺点
优点:性能高,能用贪心算法解决的往往是最优解
缺点:在实际情况下能用的不多,用贪心算法解的往往不是最好的
适用场景
针对一组数据,我们定义了限制值和期望值,希望从中选出几个数据,在满足限制值的情况下,期望值最大。
每次选择当前情况下,在对限制值同等贡献量的情况下,对期望值贡献最大的数据(局部最优而全局最优)
大部分能用贪心算法解决的问题,贪心算法的正确性都是显而易见的,也不需要严格的数学推导证明
在实际情况下,用贪心算法解决问题的思路,并不总能给出最优解
3.5.2 分治算法
概念
分治算法(divide and conquer)的核心思想其实就是四个字,分而治之 ,也就是将原问题划分成 n个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。
关于分治和递归的区别
分治算法是一种处理问题的思想,递归是一种编程技巧
分治算法的递归实现中,每一层递归都会涉及这样三个操作:
分解:将原问题分解成一系列子问题
解决:递归地求解各个子问题,若子问题足够小,则直接求解
合并:将子问题的结果合并成原问题
比如:
将字符串中的小写字母转化为大写字母
“abcde”转化为"ABCDE"
我们可以利用分治的思想将整个字符串转化成一个一个的字符处理
经典问题
上述问题代码如下:
求 问题
比如: 2^10 2的10次幂
一般的解法是循环10次
该方法的时间复杂度是:O(n)
采用分治法
2^10拆成
我们看到每次拆成n/2次幂,时间复杂度是O(logn)
时间复杂度
根据拆分情况可以是O(n)或O(logn)
优缺点
优势:将复杂的问题拆分成简单的子问题,解决更容易,另外根据拆分规则,性能有可能提高。
劣势:子问题必须要一样,用相同的方式解决
适用场景
分治算法能解决的问题,一般需要满足下面这几个条件:
原问题与分解成的小问题具有相同的模式;
原问题分解成的子问题可以独立求解,子问题之间没有相关性,这一点是分治算法跟动态规划的明显区别
具有分解终止条件,也就是说,当问题足够小时,可以直接求解;
可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法总体复杂度的效果了。
3.5.3 回溯算法
概念
回溯算法实际上一个类似枚举的深度优先搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回(也就是递归返回),尝试别的路径。
回溯的处理思想,有点类似枚举(列出所有的情况)搜索。我们枚举所有的解,找到满足期望的解。为了有规律地枚举所有可能的解,避免遗漏和重复,我们把问题求解的过程分为多个阶段。每个阶段,我们都会面对一个岔路口,我们先随意选一条路走,当发现这条路走不通的时候(不符合期望的解),就回退到上一个岔路口,另选一种走法继续走。
人生如果能够回退?那么你想回退到哪个阶段呢?
经典问题
N皇后问题
n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
我们把这个问题划分成 8 个阶段,依次将 8 个棋子放到第一行、第二行、第三行……第八行。在放置的过程中,我们不停地检查当前放法,是否满足要求。如果满足,则跳到下一行继续放置棋子;如果不满足,那就再换一种放法,继续尝试。
代码如下:
时间复杂度
N皇后问题的时间复杂度为: O(n!)O(n!)O(n!)实际为 O(n!/2)O(n!/2)O(n!/2)
优缺点
优点:
回溯算法的思想非常简单,大部分情况下,都是用来解决广义的搜索问题,也就是,从一组可能的解中,选择出一个满足要求的解。回溯算法非常适合用递归来实现,在实现的过程中,剪枝操作是提高回溯效率的一种技巧。利用剪枝,我们并不需要穷举搜索所有的情况,从而提高搜索效率。
劣势:
效率相对于低(动态规划)
适用场景
回溯算法是个“万金油”。基本上能用的动态规划、贪心解决的问题,我们都可以用回溯算法解决。回溯算法相当于穷举搜索。穷举所有的情况,然后对比得到最优解。不过,回溯算法的时间复杂度非常高,是指数级别的,只能用来解决小规模数据的问题。对于大规模数据的问题,用回溯算法解决的执行效率就很低了
3.5.4 动态规划
概念
动态规划(Dynamic Programming),是一种分阶段求解的方法。
动态规划算法是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推(或者说分治)的方式去解决。
首先是拆分问题,我的理解就是根据问题的可能性把问题划分成一步一步这样就可以通过递推或者递归来实现. 关键就是这个步骤,动态规划有一类问题就是从后往前推到,有时候我们很容易知道:如果只有一种情况时,最佳的选择应该怎么做.然后根据这个最佳选择往前一步推导,得到前一步的最佳选择 然后就是定义问题状态和状态之间的关系,我的理解是前面拆分的步骤之间的关系,用一种量化的形式表现出来,类似于高中学的推导公式,因为这种式子很容易用程序写出来,也可以说对程序比较亲和(也就是最后所说的状态转移方程式) 我们再来看定义的下面的两段,我的理解是比如我们找到最优解,我们应该讲最优解保存下来,为了往前推导时能够使用前一步的最优解,在这个过程中难免有一些相比于最优解差的解,此时我们应该放弃,只保存最优解,这样我们每一次都把最优解保存了下来,大大降低了时间复杂度
动态规划中有三个重要概念:
最优子结构
边界
状态转移公式(递推方程)dp方程
经典问题
再谈斐波那契数列
优化递归:
通过上边的递归树可以看出在树的每层和上层都有大量的重复计算,可以把计算结果存起来,下次再用的时候就不用再计算了,这种方式叫记忆搜索,也叫做备忘录模式
代码如下:
dp方程:
if(i<2) 则
dp[0],dp[1]=1dp[0],dp[1]=1dp[0],dp[1]=1
if(i>=2) 则
dp[i]=dp[i−1]+dp[i−2]dp[i]=dp[i-1]+dp[i-2]dp[i]=dp[i−1]+dp[i−2]
最优子结构: fib[9]=finb[8]+fib[7]
边界:a[0]=0; a[1]=1;
dp方程:fib[n]=fib[n-1]+fib[n-2]
实现代码如下:
使用动态规划四个步骤
- 把当前的复杂问题转化成一个个简单的子问题(分治)
- 寻找子问题的最优解法(最优子结构)
- 把子问题的解合并,存储中间状态
- 递归+记忆搜索或自底而上的形成递推方程(dp方程)
时间复杂度
新的斐波那契数列实现时间复杂度为O(n)
优缺点
优点:时间复杂度和空间复杂度都相当较低
缺点:难,有些场景不适用
适用场景
尽管动态规划比回溯算法高效,但是,并不是所有问题,都可以用动态规划来解决。能用动态规划解决的问题,需要满足三个特征,最优子结构、无后效性和重复子问题。在重复子问题这一点上,动态规划和分治算法的区分非常明显。分治算法要求分割成的子问题,不能有重复子问题,而动态规划正好相反,动态规划之所以高效,就是因为回溯算法实现中存在大量的重复子问题。
四、大厂面试题
4.1 环形链表问题
给定一个链表,判断链表中是否有环。存在环返回 true ,否则返回 false
分析:
该题可以理解为检测链表的某节点能否二次到达(重复访问)的问题。
需要一个容器记录已经访问过的节点 每次访问到新的节点,都与容器中的记录进行匹配,若相同则存在环 若匹配之后没有相同节点,则存入容器,继续访问新的节点 直到访问节点的next指针返回null,或者当前节点与容器的某个记录相同,操作结束。
实现简单,时间复杂度为O(n2)O(n^2)O(n2)
遍历整个链表:O(n)
每次遍历节点,再遍历数组进行匹配:O(n)
换个思路:
该题可以理解为“追击相遇”问题,如果存在环,跑得快的一定能追上跑得慢的。
比如:一快一慢两个运动员,如果在直道赛跑,不存在追击相遇问题;如果是在环道赛跑,快的绕了一圈肯定可以追上慢的。
解法:
- 定义快慢两个指针:slow=head; fast=head.next;
- 遍历链表:快指针步长为2:fast=fast.next.next; 慢指针步长为1:slow=slow.next;
- 当且仅当快慢指针重合(slow==fast),有环,返回true
- 快指针为null,或其next指向null,没有环,返回false,操作结束
代码
此种算法的时间复杂度为:O(n)
4.2 0-1 背包问题
有n件物品和一个最大承重为W的背包,每件物品的重量是w[i],价值是v[i]
在保证总重量不超过W的前提下,选择某些物品装入背包,背包的最大总价值是多少?
注意:每个物品只有一件,也就是每个物品只能选择0件或者1件
分析:
假设:W=10,有5件物品,重量和价值如下:
w[1]=2,v[1]=6
w[2]=2,v[2]=3
w[3]=6,v[3]=5
w[4]=5,v[4]=4
w[5]=4,v[5]=6
dp数组的计算结果如下表:
i:选择i件物品 j:最大承重
解法:
dp方程:
如果:
可以选这一件物品
j>w[i]j>w[i]j>w[i]
不选:
dp(i,j)=dp(i−1,j)dp(i,j)=dp(i-1,j)dp(i,j)=dp(i−1,j)
选了:
dp(i,j)=v[i]+dp(i−1,j−w[i])dp(i,j)=v[i]+dp(i-1,j-w[i])dp(i,j)=v[i]+dp(i−1,j−w[i])
两者取价值最大的数Max(dp(i-1,j)),v[i+dp(i-1,j-w[i])]
不可以选这件物品
j<w[i]j<w[i]j<w[i]
则:
dp(i,j)=dp(i−1,j)dp(i,j)=dp(i-1,j)dp(i,j)=dp(i−1,j)
代码:
10-1-2 数据结构与算法高级(下)相关推荐
- javascript数据结构与算法 --- 高级排序算法
高级排序算法总结 希尔排序 function shellsort(array, gaps) {for (var g = 0; g < gaps.length; g++) {for (var i ...
- python数据结构与算法第10讲_python数据结构与算法(10)
栈 栈(stack),有些地⽅称为堆栈,是⼀种容器,可存⼊数据元素.访问元 素.删除元素,它的特点在于只能允许在容器的⼀端(称为栈顶端指标,英 语:top)进⾏加⼊数据(英语:push)和输出数据(英 ...
- python数据结构与算法第10讲_Python数据结构与算法10:基本结构:练习题1
注:本文如涉及到代码,均经过Python 3.7实际运行检验,保证其严谨性. 本文阅读时间约为4分钟. 栈的编程练习题1:有效的括号 给定一个只包括 '(',')','{','}','[',']' 的 ...
- 数据结构与算法(无排版有兴趣的小伙伴可私我要原版)
数据结构与算法 课程介绍 数据结构和算法,一个非常古老的课题. 上学的时候就觉得数据结构和算法很难学,似乎就从来没学明白过. 工作的时候,只求程序能跑,并不太关注性能,所以尽量避坑(ArrayList ...
- 数据结构与算法--贪婪算法2
近似装箱问题 解决装箱问题(bin packing problem)的算法.也可以用贪婪算法来完成 给定N项物品,大小为s1,s2,s3-sn,所有的大小满足0 < si < 1.问题是要 ...
- python基础教程第三版豆瓣-数据结构与算法必读书单吐血整理推荐【附网盘链接】...
前言:技术书阅读方法论 一.速读一遍(最好在1~2天内完成) 人的大脑记忆力有限,在一天内快速看完一本书会在大脑里留下深刻印象,对于之后复习以及总结都会有特别好的作用. 对于每一章的知识,先阅读标题, ...
- 数据结构与算法之--高级排序:shell排序和快速排序
高级排序比简单排序要快的多,简单排序的时间复杂度是O(N^2),希尔(shell)排序大约是O(N*(logN)^2),而快速排序是O(N*logN). 说明:下面以int数组的从小到大排序为例. 希 ...
- 一周刷爆LeetCode,算法da神左神(左程云)耗时100天打造算法与数据结构基础到高级全家桶教程,直击BTAJ等一线大厂必问算法面试题真题详解 笔记
一周刷爆LeetCode,算法大神左神(左程云)耗时100天打造算法与数据结构基础到高级全家桶教程,直击BTAJ等一线大厂必问算法面试题真题详解 笔记 教程与代码地址 P1 出圈了!讲课之外我们来聊聊 ...
- 年后跳槽BAT必看:10种数据结构、算法和编程课助你面试通关
作者 | javinpaul 译者 | 大鱼 编辑 | 一一 出品 | AI 科技大本营 进入 BAT 这样的巨头企业工作,无疑是很多程序员的梦想.但事实上,能通过这些公司高难度编程面试的只是一小撮人 ...
最新文章
- Mysql中的DCL
- hibernate mysql annotation_hibernate学习笔记03-- hibernate + mysql + Annotation
- Linux段式管理与页式管理
- NYOJ 118 修路方案(次小生成树)
- 【Lucene】Lucene的工作原理
- 使用单例模式加载properties文件
- Java 8的惰性序列实现
- Flutter WillPopScope 双击返回与界面退出提示
- Mssql高级注入笔记.txt (转自:慕容小雨BLOG)
- CTO 深度解读 SMTX OS 3.5 产品特性
- Java并发(五)线程池使用番外-分析RejectedExecutionException异常
- type=xhr的500错误
- C++基本语法简介,C++程序简介
- spark 连接mysql读取数据
- AIDA64 硬件规格、系统信息查询工具附带序列号
- python使用神器_python 神器
- 03.豆豆的《背叛》与《天道》
- django 基础框架学习 (三)
- linux防火墙更改端口号,Linux防火墙开放某端口号
- 异或高斯消元+暴搜 lights 燈
热门文章
- html消除绝对定位的影响,css怎么清除绝对定位?
- 靳东神预测怎么回事?预测了什么内容
- 数据解读:​影视行业是不是没编剧了,怎么老是在翻拍网络小说?
- 服务器的1U ,2U分别是什么意思?
- VSFTPD配置方法手册
- 机器学习之MATLAB代码--SMA_LSSVM(十一)
- 嵌入式开发 面试问答
- 阿里电商故障治理和故障演练实践
- AVD Pixel_2_API_30 is already running. lf that is not the case, delete the files at
- python中tan函数如何表示_Python tan() 函数