孙宇的技术专栏 大数据/机器学习

贪婪算法/动态规划


贪婪算法/动态规划

有时候,一个问题找不到一种很精确的算法(NP完全问题)。这时候我们想到的是通过局部的最优解,然后把它近似认为是全局最优。这就是贪婪算法。

NP完全问题,通常有一些特性:

  1. 元素少时,算法运算速度快,但随着元素增多,速度会非常慢。
  2. 涉及“所有组合”的问题,通常是。如:要找经过几个点的最短路径。
  3. 不能将问题分成小问题,因为可能的情况太多。
  4. 如果涉及到序列或集合的问题。或者可以转换成这种形式的问题。

如:快递员要给 20 户人送快递,如何找出经过这 20 户人的最短路径。

和广度优先的方式不同的是,广度优先是肯定了起点和终点,而且不限制必须经过哪些点。而这里不固定起点和终点,但规定必须经过 20 个点。这时候的路线组合方式就太多了。最笨的办法就是尝试各种路线,但速度会非常慢。所以这里可以尝试用贪婪算法。

又如:在你的朋友圈中找出最大的圈子(其中任何两个人都是好友)。这是集合覆盖的问题。

又如:要画一个中国地图,需要用不同的颜色表示出相邻的两个省。需要用多少种颜色才能做到? 这也可以转换为集合覆盖问题。

贪婪算法

教室调度问题

假如有一间教室,有一个课程表安排如下:

课程 开始时间 结束时间
美术 9 am 10 am
英语 9:30 am 10:30 am
数学 10 am 11 am
计算机 10:30 am 11:30 am
音乐 11 am 12 pm

现在希望这间教室能最大化利用。也就是要安排最多的课程。首先,肯定是不能全部安排上的,因为这些课程之间有时间的重叠。所以需要放弃某些课程。

如果用贪婪算法,它的逻辑是这样的:

  1. 选出最早结束的课程,它作为这间教室的第一堂课
  2. 选择上一堂课结束后才开始,并结束时间最早的课。
  3. 重复上面一步选择。

这样,我们找出了这样的组合:美术–>数学–>音乐。我们每一次选择的都是结束时间最早的课程。并不断重复这一过程,相当于将整个问题分解为若干个小问题,然后为每个小问题求最优解。

贪婪算法,每一步都采取最优算法,并假设这样的组合是全局问题的最优解。

集合覆盖问题

现在有一个电视节目。要让全国各省都能接收到。因此,需要决定在哪些电视台播。不同的电视台可以在不同的省份播放(一个台可以覆盖多个省),不同的台收费不一样。现在要求在尽可能收支出的情况下覆盖全国的省。

电台和省的关系可能如下: 北京台:北京、天津、河北、山东。 天津:天津、河北、山西。 山东:山东、山西、河南。 等等(本数据纯属虚构)。

比较笨的办法是:尝试各种不同的电视台组合。然后找出覆盖了全国的,且电视台最少的集合。

但是,如果电视台有 2 个(A 和 B),组合形式有:A, B, AB。如果有 3 个(A,B,C),组合形式有:A, B, C, AB, AC, BC, ABC。相当于有 2n-1 种组合,n 是集合里元素的个数。在这时,有 32 个省。那就有 232-1 种组合,那就是 4 亿多种。显然这种办法不可行。

这种时候我们就可以用贪婪算法,找到近似的解就行了:

  1. 选一个电台,它覆盖了最多的未覆盖的省。
  2. 重复上一步骤,直到覆盖所有的省。

代码如下:

// 需要覆盖省的集合
$provinceNeed = array('bj', 'tj', 'hb', 'sd', 'sx', 'hn', 'hub', 'hlj', 'jn', 
	'ln', 'ah', 'zj', 'gd', 'gx', 'js', 'fj', 'yn', 'nmg', 'shx', 'xz', 
	'xj', 'gs', 'sc', 'cq', 'qh', 'sh', 'jx');

// asort($provinceNeed);
// print_r($provinceNeed);

// 电视台及其覆盖的省的集合.为方便测试,假设电视台只覆盖相邻的几个省.只简单写了几个数据
$stations = array();
$stations['bj'] = array('bj', 'tj', 'hb', 'sd', 'nmg');
$stations['hlj'] = array('bj', 'tj', 'hlj', 'jn', 'ln');
$stations['ln'] = array('bj', 'tj', 'hlj', 'jn', 'ln', 'sd');
$stations['tj'] = array('tj', 'hb', 'sx', 'hn');
$stations['sd'] = array('sd', 'sx', 'hn', 'ln', 'js', 'ah', 'zj');
$stations['hb'] = array('hb', 'sd', 'sx', 'hn', 'shx', 'nmg', 'sx', 'ah');
$stations['sx'] = array('sd', 'sx', 'sc', 'nmg', 'xj');
$stations['cq'] = array('sc', 'cq', 'hb', 'gx', 'gd', 'xj');
$stations['xj'] = array('xj', 'xz', 'qh', 'nmg', 'gs');
$stations['hub'] = array('sc', 'hn', 'sh', 'gd', 'yn', 'hub');
$stations['sh'] = array('sh', 'fj', 'sd', 'gd', 'gx', 'js', 'jx', 'zj');

// 最终的电台列表
$finalStation = array();

while (count($provinceNeed) > 0){
	// 当前最佳的选择:包含未覆盖省最多的电台
	$bestStation = NULL;
	// 已经覆盖的省的列表
	$provonceCovered = array();
	
	// 遍历所有电台.选一个电台,它覆盖了最多的未覆盖的省
	foreach ($stations as $province => $provinceStationList) {
		$covered = array_unique(array_intersect($provinceNeed, $provinceStationList));
		
		if(count($covered) > count($provonceCovered)){
			$bestStation = $province;
			$provonceCovered = $covered;
		}
	}
	
	$provinceNeed = array_diff($provinceNeed, $provonceCovered);
	array_push($finalStation, $bestStation);
}

print_r($finalStation);

运行后输出:

Array
(
    [0] => sh
    [1] => hb
    [2] => hlj
    [3] => xj
    [4] => hub
    [5] => cq
)

可以把答案中的一些电台对应的省改一下,再运行发现结果变了。而且运行速度是很快的。

旅行商问题

一个旅行团的一条路线,需要去 5 个城市。为了节约成本,需要找到通过这 5 个城市的最短路径。起点和终点不固定。

如果只有两个城市 A,B,线路可能有两条:A–>B, B–>A。有时候这两条路线不一样,因为可能存在单行线。如果有三个城市 A,B,C,路线有:A–>B–>C, A–>C–>B, B–>A–>C, B–>C–>A, C–>A–>B, C–>B–>A,共 6 条路线。如果 4 个城市,会有 24 条路线;5 个城市共有 120 条;6 个城市有 720 条;7 个城市 5040 条;8 个城市 40320 条。规律是:L = n!,路线条数是城市数的阶乘。

如果来个欧洲十国游,那…L = 10! = 10 * 9 * 8 * …1 = 3,628,800 条。这时候想每条路线分别计算距离再比较,效率就非常非常低了。

如果用贪婪算法的思想,可行的算法是:

  1. 随便选择一个城市作为起点。
  2. 选择一个还没去的城市,离当前城市最近的城市作为下一个点。
  3. 重复上一步。

动态规划

有一个案例:小偷去偷东西,他只有一个包。要往包里装尽量贵重的物品。要让他偷的物品价值最大化。

最简单的办法就是穷兴趣所有能够偷的物品,找出价值最大的组合。这也是一个集合覆盖的问题。当商品只有 3 件的时候,有 8 种组合。4 件的时候有 16 种组合。组合数和商品数的关系是 C = n2 。如果有 32 件商品,就有 4 亿多种组合了。显然穷举法就不合适了。

如果用贪婪算法。我们的做法是:

  1. 找到能装入背包的最贵的物品。
  2. 再装入还可以装入的最贵的物品。
  3. 重复上一步。

这样可以得到近似解,但不一定是最优解。比如:

背包可以装下 35 斤重的物品。现在有三种商品:音响 30 斤,价值 3000 元;电视 20斤,价值 2000 元;收音机 15 斤,价值 1500 元。

按照贪婪算法的逻辑,先装音响;但这时候装不下其它物品了,这时候价值是 3000 元。但如果装电视和收音机这种组合,则可以让价值达到 3500 元。这是因为装音响的时候浪费了 5 斤的额度。

在这种场景下,我们可以使用动态规划。它的原理是:先解决子问题,然后上升到大问题。

在此例中,包容量是 35 斤。那么,我们先考虑 5 斤的包能装多少价值,然后 10 斤,15 斤,20 斤,25, 30, 35 斤。由于现在商品最轻的就是15斤的,我们直接从 15 斤的开始考虑。列出下面的表格:

商品 15斤 20斤 25斤 30斤 35斤
收音机(R)
电视(T)
音响(H)

该不及格描述的是背包在对应容量下,装对应商品的价值。

对于第一行收音机来说,我们只能选收音机,不管包是 15 斤的还是 35 斤的,最大价值都是 1500 元。这时候表格如下:

商品 15斤 20斤 25斤 30斤 35斤
收音机(R) 1500
R
1500
R
1500
R
1500
R
1500
R
电视(T)
音响(H)

这个表格表示,如果只偷收音机,35 斤容量的包最多能偷价值 1500 元的东西。 现在开始计算表格的第二行,这时候可以用两种商品的组合:电视+收音机。当容量是 15 斤的时候还是只能选收音机,当容量是 20 斤的时候,开始选电视;25, 30 的时候都是选电视,但 35 的时候,选完电视后,还有 15 斤的容量剩余,这时候查看表前面,15 斤对应的最大价值是 R,所以在 35 的时候可以用 R + T 的组合。这时候是当前情况下的最大价值:

商品 15斤 20斤 25斤 30斤 35斤
收音机(R) 1500
R
1500
R
1500
R
1500
R
1500
R
电视(T) 1500
R
2000
T
2000
T
2000
T
3500
T + R
音响(H)

上表说明在两种商品的情况下,35 斤的包最大价值是 3500 元,是收音机+电视的组合。这时候再看三种商品组合的情况:

商品 15斤 20斤 25斤 30斤 35斤
收音机(R) 1500
R
1500
R
1500
R
1500
R
1500
R
电视(T) 1500
R
2000
T
2000
T
2000
T
3500
T + R
音响(H) 1500
R
2000
T
2000
T
3000
H
3000
H

从上表可以看到,当包的容量是 30 斤的时候,音响的价值最大;35 斤的时候,电视+收音机的价值最大。

比如现在再增加一个商品,笔记本,5 斤 价值 5000 元。这时候表信息如下:

商品 5斤 10斤 15斤 20斤 25斤 30斤 35斤
收音机(R) 0 0 1500
R
1500
R
1500
R
1500
R
1500
R
电视(T) 0 0 1500
R
2000
T
2000
T
2000
T
3500
T + R
音响(H) 0 0 1500
R
2000
T
2000
T
3000
H
3000
H
笔记本(B) 5000
B
5000
B
5000
B
6500
B + R
7000
B + T
7000
B + T
8000
B + H

在 20 斤的时候,先选了 B,剩下 15 斤容量,这时候再选 15 斤对应的最大值 R,这时候的组合就是 B + R,6500 元。

25 斤的时候,选完 B剩下 20,这时候再选 20 斤对应的最大值,是 T,同理 30 的时候也是。

通过这样从小到大的依次求解,然后把小问题的最优解带到后面的大问题里这样算出大问题的最优解。

测试代码如下:

<?php

$bagMax = 35;
// 待解析的商品.顺序可以打乱
$goods = array('R', 'T', 'H', 'B');

$weight['R'] = 15;
$weight['T'] = 20;
$weight['H'] = 30;
$weight['B'] = 5;

$value['R'] = 1500;
$value['T'] = 2000;
$value['H'] = 3000;
$value['B'] = 5000;

$bagList = array();
// 划分背包为各个小包,即表格的列.比如以 5 为区间递减
for ($i = 35; $i > 0; $i -= 5) {
    array_push($bagList, $i);
}
asort($bagList);
// print_r($bagList);
// 双重循环填充表格
$info = array();
// 用来存放不同容量包裹的最大价值及物品及各列的信息.为了方便查询
$maxInfo = array();

foreach ($goods as $loopGoods) {
    foreach ($bagList as $loopBag) {
        // 1. 如果刚好能放下当前物品.直接设置当前信息为当前商品
        // 2. 如果当前容量放不下当前物品.找当前容量下最大价值的商品.把它作为当前位置的商品信息
        // 3. 如果当前容量大于当前商品占位.先把当前商品加上,然后在找剩余重量对应的最大价值商品并加上
        // 4. 把当前位置的信息添加到列信息和表格信息中

        // 需要查找的额外的商品的信息
        $addValue = 0;
        $addGood = array();
        // 基础商品信息
        $baseValue = 0;
        $baseGood = array();

        // 要去查找的商品的重量
        $toFindWeight = 0;

        if ($loopBag >= $weight[$loopGoods]) {
            $toFindWeight = $loopBag - $weight[$loopGoods];

            $baseValue = $value[$loopGoods];
            array_push($baseGood, $loopGoods);
        } else {
            $toFindWeight = $loopBag;
        }

        // 去找附加商品的信息
        if ($toFindWeight > 0 && array_key_exists($toFindWeight, $maxInfo)) {
            foreach ($maxInfo[$toFindWeight] as $maxgood => $maxgoodinfo) {
                $loopValue = $maxgoodinfo['value'];
                $loopGood = $maxgoodinfo['goods'];

                // 和当前商品相同的不添加.意思是一个格子里不能添加两个相同的商品
                if ($maxgood == $loopGoods) {
                    continue;
                }
                // 找到价值最大的商品
                if ($loopValue > $addValue) {
                    $addValue = $loopValue;
                    $addGood = $loopGood;
                }
            }
        }

        // 设置表格信息和列信息
        $info[$loopGoods][$loopBag]['value'] = $baseValue + $addValue;
        $info[$loopGoods][$loopBag]['goods'] = array_merge($baseGood, $addGood);

        $maxInfo[$loopBag][$loopGoods]['value'] = $info[$loopGoods][$loopBag]['value'];
        $maxInfo[$loopBag][$loopGoods]['goods'] = $info[$loopGoods][$loopBag]['goods'];
    }
}

foreach ($info as $good => $bagList) {
    print_r($good . '    ');
    foreach ($bagList as $weight => $bag) {
        print_r($bag['value'] . ' : ' . implode(' + ', $bag['goods']) . '    ');
    }
    print_r(PHP_EOL);
}

?>

运行后输出:

R    0 :     0 :     1500 : R    1500 : R    1500 : R    1500 : R    1500 : R
T    0 :     0 :     1500 : R    2000 : T    2000 : T    2000 : T    3500 : T + R
H    0 :     0 :     1500 : R    2000 : T    2000 : T    3000 : H    3000 : H
B    5000 : B    5000 : B    5000 : B    6500 : B + R    7000 : B + T    7000 : B + T    8000 : B + H

最长公共子串

动态规划的原则是将大问题分解为若干个小问题,求出各小问题的最优解。

一个字典应用,如果用户输入了 hish,他有可能输错了。那么,他是想输入 fish 还是 vista 呢?

我们可以根据 hish 和 fish 以及 hish 和 vista 的相似度来推测是谁。相似度的衡量又可以通过最长公共子串来比较。我们可以通过图示来表示:

h i s h
f 0 0 0 0
i 0 1 0 0
s 0 0 2 0
h 1 0 0 3

它的比较方式是:行和列的各个字母相比较。如果不同,把当前位置设为 0;如果相同,值为当前位置左上角的值+1。

最后表格中的最大值就是最大公共子串。在这里是 3。同样,我们可以比较 hish 和 vista ,得到的值是 2。它的值没有 fish 的值大,所以我们认为他输入的值应该是 fish。

最长公共子序列

如果用户输入了 fosh,他有可能输入的是 fish 或 fort。通过最长公共子串的计算方式,得到这两个单词和 fish 相比较公共子串的长度都是 2。但实际上,fosh 和 fish 有三个字母一样,和 fort 只有两个,所以 fish 的相似度要更高一些。这里长度 3 指的就是两者的最长公共子序列。

它的计算方式也是用表格的方式,但计算逻辑不一样:行和列的字母分别比较。如果两者不同,就选择上方和左侧邻居中较大的那个作为当前值。如果相同,当前的值是左上方单元格的值+1。

如:

f o s h
f 1 1 1 1
i 1 1 1 1
s 1 1 2 2
h 1 1 2 3

最长公共子序列的最大值是表格最右下方表格的值。


下一篇 Memcached

评论

文章