360校招试题解析(一):采用动态规划处理的博弈问题

 阅读约 13 分钟

JD的题目的解析已经写完了,我们继续来看看360和其他一些公司难度相对高一点,又或者比较有价值的题目。之后,我打算按其他的方式来写,可能会先写几个和平时工作相关性大的一些算法,然后会有一些应用的举例。

今天我们先来看360仅有的两个4星题目,其中一个和博弈问题相关,另外一个是个比较简单的问题。


分金子

<题目来源: 360 2017春招 原题链接-可在线提交(赛码网)>

问题描述

A、B两伙马贼意外地在一片沙漠中发现了一处金矿,双方都想独占金矿,但各自的实力都不足以吞下对方,经过谈判后,双方同意用一个公平的方式来处理这片金矿。处理的规则如下:他们把整个金矿分成n段,由A、B开始轮流从最左端或最右端占据一段,直到分完为止。
马贼A想提前知道他们能分到多少金子,因此请你帮忙计算他们最后各自拥有多少金子?(两伙马贼均会采取对己方有利的策略)

图片描述

简单地说,现在有一个长度为n的数组,两个人轮流从数组的两端取数,每次只能取左端或者右端的1个数,直到数组的所有元素被取完。两个人都想自己取到的元素总和尽可能的大,问两个人最大能取到的元素总和是多少。

考虑简单的情况:
1,2
注意到,两个人都要采用最佳的策略,先手肯定应该先取2,然后只剩下1给后手。
再来看下面这组:
1,2,10,5
先手如果取了两端较大的一个元素5,那么序列将变为:
1,2,10
此后手肯定会取10,对于先手来说,接下来无论怎么取都不是最优的。如果一开始取1,留给后手的序列是:
2,10,5
后手无论如何取都可以确保先手取到10,才能让自己取到的尽量的多。
显然一开始取5不是一个最优的策略。

通过观察和分析上面的简单例子,我们知道:
1.显然不是每次取两端中较大的一个元素(实际上这是一个错误的方法)
2.先手每次取的时候,实际是受到了剩下可能的状况的影响
这也是一类博弈问题的基本解决点

再来观察一种更为一般的情形:
a1, a2, a3, ... an
先手取,显然只能选择a1或者an,那么该如何选择呢?
我们会选择一个在剩下的数列中能取得最大收益的一个方案,具体来说就是:
a1, [a2, a3, ... an] (1)
在先手选择a1的时候(方案1),后手就只能在[]中进行选择,如果选择an
[a1, a2, a3, ...], an (2)
在先手选择an的时候(方案2),后手也只能在[]中进行选择,那么选择a1或者取决于先手在后手在[]取数后能获得的最大收益谁更多,当然,后手也必须按最优的策略来取。
这个时候,我们如果把先手取走两端元素,后手在剩余[]序列中的取数看做是先手的取数过程的时候,实际上我们发现了这个过程是具有一个最优子结构。

考虑在序列a1, a2, a3, ... an上的博弈,如果先手取走a1,问题转化为在a2, a3, ... an上的博弈。此时,我们可以将后手看做在a2, a3, ...an上博弈的先手。如果先手取走an,问题转化为在a1, a2, ... an-1上的博弈问题。并且,在序列其中任何一段上的博弈不会受到序列之外的元素影响。

考虑到该问题最优子结构无后效性,我们尝试采用动态规划*来解决。
设f(i, j)表示两个人在序列ai, ai+1, ... aj上博弈时,先手可以拿到的最大价值。注意到一个很重要的问题,如果先手在序列ai, ai+1, ... aj上拿到f(i, j)的价值,那么后手一定可以得到sum(i, j) - f(i, j)的价值。其中sum(i, j)表示ai + ai+1 + ... + aj。

*关于动态规划:
关于动态规划的基本概念,下面的文章可供参考:
这篇文章介绍动态规划的基本概念和一些入门的练习题目
http://blog.csdn.net/baidu_28...
这篇文章介绍了一些动态规划的方法,并且留有相关的练习题目,推荐!
https://www.topcoder.com/comm...

如果先手拿走ai,那么先手可以得到的最大的价值是:
a(i) + sum(i + 1, j) - f(i + 1, j) => sum(i, j) - f(i + 1, j)
因为先手拿走a(i)后,后手就会在i + 1, j中取元素,因此双方都要按照最佳的策略取,因此后手也会在ai+1, ... aj的序列上取得最大值,这个时候我们可以把在ai, ai+1, ... aj上的后手看做在i + 1, ...j上的先手能取得最大的价值

同理,如果先手拿走aj,那么先手可以得到的最大的价值是:
a(j) + sum(i, j - 1) - f(i, j - 1) => sum(i, j) - f(i, j - 1)

由此我们推出状态转移方程:
f(i, j) = max{sum(i, j) - f(i + 1, j), sum(i, j) - f(i, j - 1)}
=>
f(i, j) = sum(i, j) - min{f(i + 1, j), f(i, j - 1)}
0 <= i <= j <= L
其中L表示序列的总长度
f(i, j) = sum(i, j) 当 i == j时

最后,关于sum(i, j)的计算其实不需要使用二维数组来存储,因此这样非常浪费存储空间,类似的存储通常会采用下列的方式:
sum'(k)表示从数列开始(通常建议从1开始)到k位置的所有的元素的和
sum'(k) = sum'(k - 1) + a[k] (k >= 1, sum'[0] = 0_
那么sum(i, j) = sum'(j) - sum'(i - 1) (i >= 1)

通常,动态规划的代码都比较简短,其关键是分析问题,抽象模型,划分阶段,设计状态和决策。

import sys


def main():
    case = map(int, sys.stdin.readline().strip().split())[0]
    for c in range(case):
        n = map(int, sys.stdin.readline().strip().split())[0]
        au = map(int, sys.stdin.readline().strip().split())

        f = [[0 for i in range(n + 1)] for j in range(n + 1)]
        sum = [0 for i in range(n + 1)]

        for i in range(1, n + 1):
            sum[i] = sum[i - 1] + au[i - 1]
            f[i][i] = au[i - 1]  # 注意状态的初始化

        for j in range(n):
            for i in range(1, n):
                if i + j <= n:
                    f[i][i + j] = sum[i + j] - sum[i - 1] - min(f[i + 1][i + j], f[i][i + j - 1])

        print 'Case #%d: %d %d'%(c + 1, f[1][n], sum[n] - f[1][n])


if __name__ == '__main__':
    main()

任务列表

<题目来源: 360 2017春招 原题链接-可在线提交(赛码网)>

问题描述

现在现在有一台机器,这台机器可以接收两种形式任务:
(1)任务列表,任务列表里面有N个任务,对于第i个任务,机器在Ti时间开始执行,并在1个单位时间内做完。
(2)临时任务,机器可以在任意时间接收一个临时任务,但任务列表里面的任务优先级要高于临时任务,也就是说当机器空闲的时候才会执行临时任务。

现在机器已经接收一个任务列表。接下来会有M个临时任务,我们想知道每个临时任务何时被执行。为了简化问题我们可以认为这M个临时任务是独立无关即任务是可以同时执行的,互不影响的。

图片描述

这个题目相对比较简单一些,题目意思是说,一个机器会按任务列表中每个任务开始的时间顺序执行这些任务,但是中途可能会有一些临时任务加入,但前提必须是机器没有执行任务列表中的任务,否则该时间点以前没有执行的所有临时任务都会一直阻塞等待机器空闲的时候,将当前所有阻塞的任务一次性全部执行。

一种思路是,我们对任务列表中的每个任务进行扫描,并计算相邻的两个任务之间是否有空闲,一旦有空闲,我们需要将这个时间点以前的临时任务全部执行。为了便于扫描当前时间点以前的所有临时任务,我们将临时任务使用一个队列存储,已经执行过的就弹出队列。实际上,python使用list会比较方便,即使使用c,我们也可以用一个标记来替代这个出队的作用,标记的位置以及之前的位置都表示已经出队。

提交的时候,看了下大家的时间,发现就同一种语言python而言,差距也很大。
阅读了一些其他的代码,除开输入的原因不谈,一种不太好的思路(主要是计算效率较低):
读入临时列表中的任务时间t,对于当前读取的临时任务时间ti,在任务列表中去查询有无,如果没有,那么结果是ti,如果有,那么ti = ti + 1继续查询任务列表中,直到没有查寻到。
存在的问题是,如果当任务列表中如果有很长的一段连续的任务,而这段时间内都有大量的临时任务加入,每个临时任务都不得扫描很长的一段任务列表才能找到可以执行的问题。

如下图所示,“+”表示当前时刻有任务列表中的任务,“-”表示空闲,“|”表示当前时刻有临时任务
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
||||||||
显然我们可以看到,从第一个临时任务开始,就需要一直扫描到几乎在几乎任务列表结尾的位置。如果这个连续的任务表列足够长,那么可以预见,会在扫描上消耗大量的时间。

下图给出了题目提交语言为python的时间情况:

图片描述

import sys


def main():
    while True:
        line = map(int, sys.stdin.readline().strip().split())
        if len(line) <= 0:
            return

        n, m = line[0], line[1]

        plan_task = []
        line = map(int, sys.stdin.readline().strip().split())
        for e in line:
            plan_task.append(e)
        # plan_task.append(e + 2) # this is also a good solution

        temp_task = []
        for i in range(m):
            t = map(int, sys.stdin.readline().strip().split())[0]
            temp_task.append((i, t))

        plan_task.sort()
        temp_task.sort(key=lambda x: x[1])

        result = {}
        last_time = 0
        for time in plan_task:
            if time - last_time > 1:
                while len(temp_task) > 0:
                    head = temp_task[0]
                    if head[1] < time:
                        if head[1] <= last_time:
                            result[head[0]] = last_time + 1
                        else:
                            result[head[0]] = head[1]

                        temp_task.pop(0)
                    else:
                        break

            last_time = time

        while len(temp_task):
            result[temp_task[0][0]] = last_time + 1
            temp_task.pop(0)

        for i in range(m):
            print result.get(i)

if __name__ == '__main__':
    main()
阅读 2.1k更新于 2019-03-19

推荐阅读
Lite's home
用户专栏

和大家分享一些经典的算法,以及解决问题的思路和方法

1 人关注
12 篇文章
专栏主页
目录