乐趣区

动态规划和摩尔投票法

动态规划
维基百科对动态规划(Dynamic programming,简称 DP)的定义是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
斐波那契数列
斐波那契数列是一个典型的可以把原问题分解为相对简单的子问题的方式求解复杂问题的例子。下面是斐波那契数列的数学定义:

F(0)=0,F(1)=1
F(2)=F(1)+F(0)=1+0=1
F(n)=F(n-1)+F(n-2) (n>=2)

根据这个数学定义,我们可以用递归的方式很轻松的实现这个算法。
package main

import (
“fmt”
“time”
)

func fib(n int) int {
if n <= 1 {
return n
}
return fib(n-1) + fib(n-2)
}

func main() {
start := time.Now().Unix()
fib(45)
end := time.Now().Unix()
fmt.Println(end-start)
}
上面代码我们求的是 F(45),代码非常的简单,但发现计算时间达到了 6 秒左右,效率十分低下,下面是根据刚刚的代码画出的一个 F(5) 的树状图:从图中可以看出 F(3) 计算了 2 次,F(2) 计算了 3 次,F(1) 计算了 4 次,发生了很多重复计算,这也是造成效率低下的原因,要优化的思路就是去除掉这些不必要的重复计算。现在我们将每个子问题的计算结果存储起来,当再次碰到同一个子问题时,就可以直接从之前存储的结果中取值,就不用再次计算了。比如第一次碰到计算 F(2) 时,可以用一个字典把 F(2) 的计算结果存储起来,当再次碰到计算 F(2) 时就可以直接从字典中取值,改造后的代码如下:
package main

import (
“fmt”
“time”
)

var m = map[int]int{0:0, 1:1}
func fib(n int) int {
if v, ok :=m[n]; ok {
return v
}
m[n-1],m[n-2] =fib(n-1),fib(n-2)
return m[n-1]+m[n-2]
}

func main() {
start := time.Now().UnixNano()
fib(45)
end := time.Now().UnixNano()
fmt.Println(end-start)
}
经过改造后再计算 F(45) 不到 1 秒。一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表这也是动态规划的重要内容。
所以动态规划两个最主要的点是:

将一个复杂的问题分解为若干多个子问题。
将每个子问题的结果存储起来,使每个子问题只解决一次。

House Robber
下面是用动态规划的方法来解决 LeetCode 上一道名为 House Robber 的题目:
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。假设现在各个房屋存放的金额分别为 2、7、9、3、1,求最大能偷窃到的金额。
我们用 P(n) 表示为总共有 n 间房屋时能偷取到的最大金额,用 r(n) 表示为第 n 间房屋中存放的金额。当 n 为 1 时 P(1)=r(1),n 为 2 时 P(2)=Max(r(1), r(2))。因为题目要求不能打劫相邻两间房,所以当有 n 间房时 P(n)=Max(P(n-2)+r(n), P(n-1))。用方程来表示就是:
P(1)=r(1)
P(2)=Max(r(1), r(2))
P(n)=Max(P(n-2)+r(n), P(n-1))
所以这个问题就被分解成了若干个子问题,下面是其代码实现:
package main

import “fmt”

var m = map[int]int{}

func rob(arr []int) int {
l := len(arr)
if l <= 0 {
return 0
}
if v,ok:=m[l-1];ok{
return v
}
if l == 1 {
m[0]=arr[0]
return arr[0]
}
if l == 2 {
if arr[0] >= arr[1] {
m[1]=arr[0]
return arr[0]
} else {
m[1]=arr[1]
return arr[1]
}
}
a, b:= rob(arr[:l-2])+arr[l-1],rob(arr[:l-1])
if a>=b{
m[l-1]=a
} else {
m[l-1]=b
}
return m[l-1]
}

func main() {
arr := []int{2,7,9,3,1}
m[0]=arr[0]
ret :=rob(arr)
fmt.Println(ret)
}
上面的代码就是我们根据方程无脑写出的算法就已经达到了偷窃最大金额的目的,但其实还是有一些优化空间的,我们要计算 P(n) 其实只需要记住之前的 P(n-2) 和 P(n-1) 就够了,但我们其实将 P(1)、P(2)、…、P(n-2) 都记住了,带来了一些内存浪费,之所以会有这个问题是因为我们求解 P(n) 时会依次求解 P(n-1)、P(n-2)、…、P(1) 是一种自顶向下的求解方式,如果换成自底向上的求解方式可以写出如下代码:
package main

import “fmt”

func rob(arr []int) int {
pre1, pre2 := 0, 0
for _,v := range arr {
if pre2+v >= pre1 {
pre1,pre2 = pre2+v,pre1
} else {
pre1,pre2= pre1,pre1
}
}
return pre1
}

func main() {
arr := []int{2,7,9,3,1}
ret :=rob(arr)
fmt.Println(ret)
}
上面的变量 pre1 和 pre2 分别表示 P(n-1) 和 P(n-2),这样通过自底向上的方式求出了结果,比自顶向下的方式更节省内存。
所以动态规划需要记住的几个关键点是将复杂问题拆分成若干个子问题、记住子问题的结果、自顶向下、自底向上。
摩尔投票法
假如有 10 个人参与投票,有的人投给 A,有的人投给 B,有的人投给 C,当我们想要找出 A、B、C 谁得票最多时,我们可以将两个不同的投票作为一对进行删除,直到不能再删时然后再查看结果中还剩下的投票就是得票最多的那个。比如上述 10 个人的投票情况是 [A,B,C,C,B,A,A,A,B,A],下面是进行删除的过程:
[A,B,C,C,B,A,A,A,B,A]==>[C,C,B,A,A,A,B,A] //A,B 为不同的投票所以可以
// 作为一对进行删除
[C,C,B,A,A,A,B,A]==>[C,A,A,A,B,A] //C,C 为相同的投票所以不删除,然后
// 再依次向后查找发现 C,B 不同可以删除
[C,A,A,A,B,A]==>[A,A,B,A]
[A,A,B,A]==>[A,A]
通过不断的对不同的投票作为一对进行删除,投票结果中最后只剩下了 [A,A],所以 A 就是得票最多的。摩尔投票法的核心就是将序列中两个不同的元素进行抵消或删除,序列最后剩下一个元素或多个相同的元素,那么这个元素就是出现次数最多的元素。
Majority Element
求众数就是摩尔投票法的一个典型运用场景,比如有下面这道算法题:
给定一个大小为 n 的数组,找到其中的众数。众数是指在数组中出现次数大于 n/2 的元素。给定数组 [2,2,1,1,1,2,2,4,5,2,3,2,2] 找出其众数。
实现代码如下:
package main

import “fmt”

func main() {
arr := []int{2,2,1,1,1,2,2,4,5,2,3,2,2}
maj, count := arr[0], 1
for i:=1;i<len(arr);i++ {
if maj == arr[i] {
count++
} else {
if count == 0 {
maj,count = arr[i],1
continue
}
count–
}
}
fmt.Println(maj)
}
代码中先假定数组的第一个元素就是众数,并用一个变量 count 来记录这个众数出现的次数,当被迭代到的数与这个众数相同时 count 就加 1,不同时就做抵消操作,即 count 减 1,当 count 为 0 时,就将被迭代到的数设为新的众数并将 count 置 1。
以上就是摩尔投票法的原理和应用。

退出移动版