动态规划和摩尔投票法

动态规划

维基百科对动态规划(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)
    根据这个数学定义,我们可以用递归的方式很轻松的实现这个算法。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    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)的树状图:
fib5
从图中可以看出F(3)计算了2次,F(2)计算了3次,F(1)计算了4次,发生了很多重复计算,这也是造成效率低下的原因,要优化的思路就是去除掉这些不必要的重复计算。现在我们将每个子问题的计算结果存储起来,当再次碰到同一个子问题时,就可以直接从之前存储的结果中取值,就不用再次计算了。比如第一次碰到计算F(2)时,可以用一个字典把F(2)的计算结果存储起来,当再次碰到计算F(2)时就可以直接从字典中取值,改造后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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))。用方程来表示就是:

1
2
3
P(1)=r(1)
P(2)=Max(r(1), r(2))
P(n)=Max(P(n-2)+r(n), P(n-1))

所以这个问题就被分解成了若干个子问题,下面是其代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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) 是一种自顶向下的求解方式,如果换成自底向上的求解方式可以写出如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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],下面是进行删除的过程:

1
2
3
4
5
6
[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] 找出其众数。

实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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。

以上就是摩尔投票法的原理和应用。