数据结构与算法之美笔记(十)几种基本算法

贪心算法

作者总结一下贪心算法解决问题的步骤

  1. 看到以下类型题目要联想到贪心:我们规定了限制值期望值,并要求在满足限制值的情况下通过选取几个数据使期望最大化
  2. 我们尝试看下这个问题是否可以用贪心算法解决,即操作为每次选择当前情况下,在对限制值同等贡献量的情况下,对期望值贡献最大的数据。
  3. 我们举几个例子看下贪心算法产生的结果是否是最优的。大部分情况下,举几个例子验证一下就可以了。严格地证明贪心算法的正确性,是非常复杂的,需要涉及比较多的数学推理。而且,从实践的角度来说,大部分能用贪心算法解决的问题,贪心算法的正确性都是显而易见的,也不需要严格的数学推导证明。

作者出的几个便于入门理解的例题:

  1. 分糖果:m个糖果、n个孩子,糖果数比孩子数少,糖果大小不均,为s1、s2、s3…孩子的需求也不同,为g1、g2、g3…,要满足一个孩子的话,即给他的糖果大小必须大于等于他的需求。求满足的孩子书最多的方案。

    答:我们很容易发现,如果小糖果能满足一个孩子,就不需要给他大糖果了,因为需求大的孩子不能吃小糖果,但需求小的孩子都能吃。所以,我们可以从需求小的孩子开始分配糖果,每次从剩下的孩子中,找出对糖果大小需求最小的,然后发给他剩下的糖果中能满足他的最小的糖果,这样得到的分配方案,也就是满足的孩子个数最多的方案。

  2. 钱币找零
    我们有1元、2元、5元、10元、20元、50元、100元这些面额的纸币,它们的张数分别是c1、c2、c5、c10、c20、c50、c100。我们现在要用这些钱来支付K元,最少要用多少张纸币呢?

    答:先用大面额的,如果面额大于剩下要付的钱或者张数为0,就用面额较小的

  3. 区间覆盖
    假设我们有n个区间,区间的起始端点和结束端点分别是[l1, r1],[l2, r2],[l3, r3],……,[ln, rn]。我们从这n个区间中选出一部分区间,这部分区间满足两两不相交(端点相交的情况不算相交),最多能选出多少个区间呢?

在这里插入图片描述
这个问题的解决思路是这样的:我们假设这n个区间中最左端点是lmin,最右端点是rmax。这个问题就相当于,我们选择几个不相交的区间,从左到右将[lmin, rmax]覆盖上。我们按照起始端点从小到大的顺序对这n个区间排序。

我们每次选择的时候,左端点跟前面的已经覆盖的区间不重合的,右端点又尽量小的,这样可以让剩下的未覆盖区间尽可能的大,就可以放置更多的区间
在这里插入图片描述

贪心算法实现哈夫曼编码
哈夫曼编码是一种十分有效的编码方法,广泛用于数据压缩中
哈夫曼编码不仅会考察文本中有多少个不同字符,还会考察每个字符出现的频率,根据频率的不同,选择不同长度的编码。哈夫曼编码试图用这种不等长的编码方法,来进一步增加压缩的效率。
由于使用了不等长编码,为了避免解压缩过程中的歧义,霍夫曼编码要求各个字符的编码之间,不会出现某个编码是另一个编码前缀的情况。
在这里插入图片描述
下面两张图是哈夫曼树的构造过程

  1. 每次拿出优先队列(小顶堆)中的前两个元素,并将他们的和作为他们两个结点的父节点,并向优先队列中放入这个父节点的值,遵守左子结点比右子结点小的规则,就得到了下面这棵树
    在这里插入图片描述
  2. 将所有指向左子结点的边赋权值0,指向右子结点的边赋权值为1,那从根节点到叶节点的路径就是叶节点对应字符的霍夫曼编码。
    在这里插入图片描述

分治算法

分治:分而治之 ,也就是将原问题划分成n个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。
分治算法是一种处理问题的思想,递归是一种编程技巧。实际上,分治算法一般都比较适合用递归来实现。

分治算法的递归实现中,每一层递归都会涉及这样三个操作:

  • 分解:将原问题分解成一系列子问题;
  • 解决:递归地求解各个子问题,若子问题足够小,则直接求解;
  • 合并:将子问题的结果合并成原问题。

分治算法能解决的问题,一般需要满足下面这几个条件:

  • 原问题与分解成的小问题具有相同的模式
  • 原问题分解成的子问题可以独立求解子问题之间没有相关性,这一点是分治算法跟动态规划的明显区别
  • 具有分解终止条件,也就是说,当问题足够小时,可以直接求解;
  • 可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法总体复杂度的效果了。

应用举例:

  1. 编程求出一组数据的有序对个数或者逆序对个数
    先放一下归并排序时的合并数组操作中如何进行逆序对个数的求
    原数组为[1,5,6,2,3,4],我们这里是分为两个子数组arr1=[1,5,6]和arr2=[2,3,4]并进行两个有序数组的合并
    我们往辅助数组里放入arr1元素不管,但是放入arr2时,因为arr1还有两个数没放,意思是2前面还有两个比他大的数,即这里能得到两个逆序对,原理就这样
    然后在整个递归的回溯过程中累加这个合并过程中得到的逆序对个数,就得到了整个数组的逆序数
    在这里插入图片描述

关于作者留的问题之一:二维平面上有n个点,如何快速计算出两个距离最近的点对?
我看了博客说的好像是把点按x轴或y轴二分,求出二分不是会出来两边嘛,假设是按x轴二分,那就求左边的距离最近的点对和右边的以及横跨左右的,然后在比较求出这个父平面的距离最近的点对,这就解决了一个子问题。然后整个数组是要用分治的,其实基本思想还就是分治算法。

分治思想在海量数据处理中的应用
比如,给10GB的订单文件按照金额排序这样一个需求,如果机器只有2G内存,就不能直接全部读取再排序。我们可以把数据划分为小的数据集合,在内存中就可以处理这些小集合了,再不断整合成大的数据集合。而且,利用这种分治的处理思路,不仅仅能克服内存的限制,还能利用多线程或者多机处理,加快处理的速度。


回溯算法
先放例题

  1. 01背包
    如果用回溯法穷举对每个物品进行选或不选的所有情况,即便可以有一点剪枝的方法,但是我们还是要发现,有些子问题的求解是重复的,比如图中f(2, 2)和f(3,4)都被重复计算了两次。
    在这里插入图片描述
    我们用一个二维数组states[n][w+1],来记录每层可以达到的不同状态。

第0个(下标从0开始编号)物品的重量是2,要么装入背包,要么不装入背包,决策完之后,会对应背包的两种状态,背包中物品的总重量是0或者2。我们用states[0][0]=true和states[0][2]=true来表示这两种状态。
下面为整个过程,你可以看看。图中0表示false,1表示true。我们只需要在最后一层,找一个值为true的最接近w(这里是9)的值,就是背包中物品总重量的最大值。
在这里插入图片描述
我们把问题分解为多个阶段,每个阶段对应一个决策。我们记录每一个阶段可达的状态集合(去掉重复的),然后通过当前阶段的状态集合,来推导下一个阶段的状态集合,动态地往前推进,这也是动态规划这个名字的由来,

尽管动态规划的执行效率比较高,但是就刚刚的代码实现来说,我们需要额外申请一个n二维数组,对空间的消耗比较多。所以,有时候,我们会说,动态规划是一种空间换时间的解决思路。我们其实还可以用一个一维数组,具体的可以去看dp的空间优化


“一个模型三个特征”理论讲解

解决动态规划问题,一般有两种思路。作者把它们分别叫作,状态转移表法状态转移方程法

  1. 状态转移表法
    1.1 当我们拿到问题的时候,我们可以先用简单的回溯算法解决,然后定义状态,每个状态表示一个节点,然后对应画出递归树。从递归树中,我们很容易可以看出来,是否存在重复子问题,以及重复子问题是如何产生的。以此来寻找规律,看是否能用动态规划解决。
    1.2 我们先画出一个状态表。状态表一般都是二维的,所以你可以把它想象成二维数组。其中,每个状态包含三个变量,行、列、数组值。我们根据决策的先后过程,从前往后,根据递推关系,分阶段填充状态表中的每个状态。最后,我们将这个递推填表的过程,翻译成代码,就是动态规划代码了。
    尽管大部分状态表都是二维的,但是如果问题的状态比较复杂,需要很多变量来表示,那对应的状态表可能就是高维的,比如三维、四维。那这个时候,我们就不适合用状态转移表法来解决了。一方面是因为高维状态转移表不好画图表示,另一方面是因为人脑确实很不擅长思考高维的东西。

    例子:
    从图中,我们看出,尽管(i, j, dist)不存在重复的,但是(i, j)重复的有很多。对于(i, j)重复的节点,我们只需要选择dist最小的节点,继续递归求解,其他节点就可以舍弃了。
    在这里插入图片描述
    既然存在重复子问题,我们就可以尝试看下,是否可以用动态规划来解决呢?

    我们画出一个二维状态表,表中的行、列表示棋子所在的位置,表中的数值表示从起点到这个位置的最短路径。我们按照决策过程,通过不断状态递推演进,将状态表填好。为了方便代码实现,我们按行来进行依次填充。

在这里插入图片描述

  1. 状态转移方程法
    状态转移方程是解决动态规划的关键。如果我们能写出状态转移方程,那动态规划问题基本上就解决一大半了,而翻译成代码非常简单。但是很多动态规划问题的状态本身就不好定义,状态转移方程也就更不好想到。

课后练习:
如果我们要支付w元,求最少需要多少个硬币。比如,我们有3种不同的硬币,1元、3元、5元,我们要支付9元,最少需要3个硬币(3个3元的硬币)。
答:可认为是爬楼梯模型,即dp[9]=1+max(dp[8],dp[6],dp[4])


小总结

尽管动态规划比回溯算法高效,但是,并不是所有问题,都可以用动态规划来解决。能用动态规划解决的问题,需要满足三个特征,最优子结构、无后效性和重复子问题。在重复子问题这一点上,动态规划和分治算法的区分非常明显。分治算法要求分割成的子问题,不能有重复子问题,而动态规划正好相反,动态规划之所以高效,就是因为回溯算法实现中存在大量的重复子问题。

贪心算法实际上是动态规划算法的一种特殊情况。它解决问题起来更加高效,代码实现也更加简洁。不过,它可以解决的问题也更加有限。它能解决的问题需要满足三个条件,最优子结构、无后效性和贪心选择性(这里我们不怎么强调重复子问题)。


动态规划实战:

  1. 如何量化两个字符串的相似度?

有一个非常著名的量化方法,那就是编辑距离(Edit Distance)。

  • 顾名思义,编辑距离指的就是,将一个字符串转化成另一个字符串,需要的最少编辑操作次数(比如增加一个字符、删除一个字符、替换一个字符)。编辑距离越大,说明两个字符串的相似程度越小;相反,编辑距离就越小,说明两个字符串的相似程度越大。对于两个完全相同的字符串来说,编辑距离就是0。
  • 根据所包含的编辑操作种类的不同,编辑距离有多种不同的计算方式,比较著名的有莱文斯坦距离(Levenshtein distance)和最长公共子串长度(Longest common substring length)。其中,莱文斯坦距离允许增加、删除、替换字符这三个编辑操作,最长公共子串长度只允许增加、删除字符这两个编辑操作。
  • 而且,莱文斯坦距离和最长公共子串长度,从两个截然相反的角度,分析字符串的相似程度。莱文斯坦距离的大小,表示两个字符串差异的大小;而最长公共子串的大小,表示两个字符串相似程度的大小。
    在这里插入图片描述

如何编程计算莱文斯坦距离?
递归到某一状态时:

  1. 如果a[i]与b[j]匹配,我们递归考察a[i+1]和b[j+1]。
  2. 如果a[i]与b[j]不匹配,那我们有多种处理方式可选:
    • 可以删除a[i],然后递归考察a[i+1]和b[j];
    • 可以删除b[j],然后递归考察a[i]和b[j+1];
    • 可以在a[i]前面添加一个跟b[j]相同的字符,然后递归考察a[i]和b[j+1];
    • 可以在b[j]前面添加一个跟a[i]相同的字符,然后递归考察a[i+1]和b[j];
    • 可以将a[i]替换成b[j],然后递归考察a[i+1]和b[j+1]。
    • 可以将b[j]替换成a[i],然后递归考察a[i+1]和b[j+1]。

根据回溯算法的代码实现,我们可以画出递归树,看是否存在重复子问题。如果存在重复子问题,那我们就可以考虑能否用动态规划来解决;如果不存在重复子问题,那回溯就是最好的解决方法。

在这里插入图片描述
在递归树中,每个节点代表一个状态,状态包含三个变量(i, j, edist),其中,edist表示处理到a[i]和b[j]时,已经执行的编辑操作的次数

在递归树中,(i, j)两个变量重复的节点很多,比如(3, 2)和(2, 3)。对于(i, j)相同的节点,我们只需要保留edist最小的,继续递归处理就可以了,剩下的节点都可以舍弃。所以,状态就从(i, j, edist)变成了(i, j, min_edist),其中min_edist表示处理到a[i]和b[j],已经执行的最少编辑次数。
在这里插入图片描述
可得到状态转移方程:

如果: a [ i ] ! = b [ j ] a[i]!=b[j] a[i]!=b[j],那么:
m i n E d i s t ( i , j ) = m i n ( m i n E d i s t ( i − 1 , j ) + 1 , m i n E d i s t ( i , j − 1 ) + 1 , m i n E d i s t ( i − 1 , j − 1 ) + 1 ) minEdist(i, j)=min(minEdist(i-1,j)+1, minEdist(i,j-1)+1, minEdist(i-1,j-1)+1) minEdist(i,j)=min(minEdist(i1,j)+1,minEdist(i,j1)+1,minEdist(i1,j1)+1)

如果: a [ i ] = = b [ j ] a[i]==b[j] a[i]==b[j],那么:
minEdist(i, j)就等于: m i n E d i s t ( i , j ) = m i n ( m i n E d i s t ( i − 1 , j ) + 1 , m i n E d i s t ( i , j − 1 ) + 1 , m i n E d i s t ( i − 1 , j − 1 ) ) minEdist(i, j)=min(minEdist(i-1,j)+1, minEdist(i,j-1)+1,minEdist(i-1,j-1)) minEdist(i,j)=min(minEdist(i1,j)+1,minEdist(i,j1)+1minEdist(i1,j1))

其中, m i n min min表示求三数中的最小值。


如何编程计算最长公共子串长度?
最长公共子串作为编辑距离中的一种,只允许增加、删除字符两种编辑操作。
这里定义每个状态还是包括三个变量(i, j, max_lcs),max_lcs表示a[0…i]和b[0…j]的最长公共子串长度。
我们从a[0]和b[0]开始,依次考察两个字符串中的字符是否匹配。

  1. 如果a[i]与b[j]互相匹配,我们将最大公共子串长度加一,并且继续考察a[i+1]和b[j+1]。
  2. 如果a[i]与b[j]不匹配,最长公共子串长度不变,这个时候,有两个不同的决策路线:
    2.1 删除a[i],或者在b[j]前面加上一个字符a[i],然后继续考察a[i+1]和b[j];
    2.2 删除b[j],或者在a[i]前面加上一个字符b[j],然后继续考察a[i]和b[j+1]’

即状态转移方程为:
如果:a[i]==b[j],那么:
m a x ( m a x L c s ( i − 1 , j − 1 ) + 1 , m a x L c s ( i − 1 , j ) , m a x L c s ( i , j − 1 ) ) ; max(maxLcs(i-1,j-1)+1, maxLcs(i-1, j),maxLcs(i, j-1)); max(maxLcs(i1,j1)+1,maxLcs(i1,j),maxLcs(i,j1))

如果:a[i]!=b[j],那么:
m a x ( m a x L c s ( i − 1 , j − 1 ) , m a x L c s ( i − 1 , j ) , m a x L c s ( i , j − 1 ) ) ; max(maxLcs(i-1,j-1), maxLcs(i-1, j), maxLcs(i, j-1)); max(maxLcs(i1,j1),maxLcs(i1,j),maxLcs(i,j1))

其中 m a x max max表示求三数中的最大值。


如何实现搜索引擎中的拼写纠错功能
拼写纠错最基本的原理:
当用户在搜索框内,输入一个拼写错误的单词时,我们就拿这个单词跟词库中的单词一一进行比较,计算编辑距离将编辑距离最小的单词,作为纠正之后的单词,提示给用户。
不过,真正用于商用的搜索引擎,拼写纠错功能显然不会就这么简单。一方面,单纯利用编辑距离来纠错,效果并不一定好;另一方面,词库中的数据量可能很大,搜索引擎每天要支持海量的搜索,所以对纠错的性能要求很高

  • 针对纠错效果的几种优化思路:
  1. 我们并不仅仅取出编辑距离最小的那个单词,而是取出编辑距离最小的TOP 10,然后根据其他参数(如搜索频率),决策选择哪个单词作为拼写纠错单词。
  2. 我们还可以用多种编辑距离计算方法,然后取交集,用交集的结果,再继续优化处理。
  3. 统计用户的搜索日志,得到最常被拼错的单词列表,以及对应的拼写正确的单词。搜索引擎在拼写纠错的时候,首先在这个最长被拼错单词列表中查找。如果一旦找到,直接返回对应的正确的单词。这样纠错的效果非常好。
  4. 我们还有更加高级一点的做法,引入个性化因素。针对每个用户,维护这个用户特有的搜索喜好,也就是常用的搜索关键词。当用户输入错误的单词的时候,我们首先在这个用户常用的搜索关键词中,计算编辑距离,查找编辑距离最小的单词。
  • 针对纠错性能的优化思路
  1. 如果纠错功能的TPS(系统吞吐量)不高,我们可以部署多台机器,每台机器运行一个独立的纠错功能。当有一个纠错请求的时候,我们通过负载均衡,分配到其中一台机器,来计算编辑距离,得到纠错单词。

  2. 如果纠错系统的响应时间太长,也就是,每个纠错请求处理时间过长,我们可以将纠错的词库,分割到很多台机器。当有一个纠错请求的时候,我们就将这个拼写错误的单词,同时发送到多台机器,让多台机器并行处理,分别得到编辑距离最小的单词,然后再比对合并,最终决定出一个最优的纠错单词。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值