「青训营 X 码上掘金」主题 3:寻友之旅

68 阅读3分钟

当青训营遇上码上掘金

题目简述

主题 3:寻友之旅 小青要找小码去玩,他们的家在一条直线上,当前小青在地点 NN ,小码在地点 KK (0N,K1000000\le N , K\le 100 000),并且小码在自己家原地不动等待小青。
小青有两种交通方式可选:步行和公交。

  • 步行:小青可以在一分钟内从任意节点 XX 移动到节点 X1X-1X+1X+1
  • 公交:小青可以在一分钟内从任意节点 XX 移动到节点 2X\mathbb{2}X (公交不可以向后走)

请帮助小青通知小码,小青最快到达时间是多久? 输入: 两个整数 N 和 K 输出: 小青到小码家所需的最短时间(以分钟为单位)

题目分析

这道题目如下图所示:

image.png 对于 NN 从上一个时刻位置 N(i)N(i) 到下一步位置 N(i+1)N(i+1) ,有如下关系

N(i+1)={N(i)+1,N(i)1,2N(i)N(i+1)= \begin{cases} N(i) + 1,\\ N(i) - 1,\\ 2N(i) \end{cases}

题目要求最短时间,即要求最少操作步数能够从 NN 到达 KK

首先分析 NN 在这些操作步数中可能移动的范围和情况

  1. 首先,如果 NN 移动到负数,显然不可能会有最少操作步数能够到达(这是由 K0K\ge0 数据情况决定)。
  2. 然后,分 KKNN 的大小进行讨论:
    • 如果 NKN\ge K,显然最少步数为 NKN-K.(只能往回走)。
    • 如果 KNK\ge N,这里是求解的难点
      此时,NN最小移动步骤中,N肯定不会移动到的范围有N2K+2N\le 2K+2
      这是因为 NN 到达 2K+12K+1 进行返回步骤 K+1K+1 必然要小于 NN 直接到达 KK 的步骤,即: K+1>KNK+1>K-N

因此,我们确定我们主要求解的情况KNK\ge N ,并且在这些最少操作步数中,NN的移动范围只有可能在 0N2K+10\le N \le 2K+1

下面我们通过两种思路进行优化求解

问题求解

  1. 广度优先遍历(BFS)
    一个最简单的思路就是遍历,将 NN 的所有可能移动范围(看作是图的节点)进行遍历搜索即可得到最少时间。
    其中我们注意到 BFS 首次到达该节点时刻必然是最少移动次数。所以我们在 BFS 和 DFS 中选择了 BFS 方法。
    我们只需要将首次遍历到的节点压入队列中遍历,不停弹出处理完的节点即可。
    Go 语言代码如下:

    type Node struct {
            pos    int
            minute int
    }
    
    // Brute-force method for the problem -> BFS
    func bf_method(n int, k int) int {
            if k <= n {
                    return n - k
            }
    
            steps := make([]int, (k<<1)+2)
            for i := 0; i <= (k<<1)+1; i += 1 {
                    steps[i] = MAX_STEPS
            }
    
            queue := list.New()
            queue.PushBack(Node{n, 0})
    
            for queue.Len() > 0 {
                    node := queue.Front()
                    cur_pos, cur_minute := node.Value.(Node).pos, node.Value.(Node).minute
                    queue.Remove(node)
    
                    // move next steps
    
                    next_minute := cur_minute + 1
    
                    next_pos := cur_pos + 1
    
                    if next_pos >= 0 && next_pos <= (k<<1)+1 && steps[next_pos] == MAX_STEPS {
                            steps[next_pos] = next_minute
                            queue.PushBack(Node{next_pos, next_minute})
                    }
    
                    next_pos = cur_pos - 1
    
                    if next_pos >= 0 && next_pos <= (k<<1)+1 && steps[next_pos] == MAX_STEPS {
                            steps[next_pos] = next_minute
                            queue.PushBack(Node{next_pos, next_minute})
                    }
    
                    next_pos = cur_pos * 2
                    if next_pos >= 0 && next_pos <= (k<<1)+1 && steps[next_pos] == MAX_STEPS {
                            steps[next_pos] = next_minute
                            queue.PushBack(Node{next_pos, next_minute})
                    }
    
            }
            return steps[k]
    }
    

    复杂度分析

    • 时间复杂度:由于本代码遍历中会除去之后遍历到的节点(不进入队列遍历),实际最坏只将 00KK 个节点依次遍历求解,所以时间复杂度为 O(K)O(K)
    • 空间复杂度:由于本代码需要保存之前遍历结果和一个队列维护,并且该队列只会出现之前遍历结果(即:每一个节点最多出现一次),所以空间复杂度为 O(K)O(K)
    • P.S:
      • 如果没有保存之前遍历结果进行去重剪枝,时间复杂度将会是 O(2K)O(2^K) ,这是因为将之后抵达节点的时间重复遍历了。
      • 空间复杂度没有经过优化也会变为 O(2K)O(2^K) ,这是因为加入了重复节点的抵达时间。
  2. 动态规划(DP)

    • 这道题根据1中加入之前保存结果(进行简化),很容易转化为一个动态规划的优化方法。
    • 这里我们依然保存之前计算结果(不同位置到达时间)。遍历对象进行改变(之前遍历对象是时间从小到大遍历
    • 考虑遍历对象为从 N(i)N(i) 位置,如果我们保证N(i1)N(i-1) 位置所用的时间最少,那么它的下一个步骤移动的位置 N(i)N(i) 所用时间必然最少
    • 这里还需要注意如果 N(i)N(i) 使用2N(i)2N(i) 方式移动超出 KK 的位置时候,那么如果往回走超过2步 (设为 mm 步) 才到达 KK ,我们一定能找到一种少的步骤比这种方式少。
    • 所以即使超过,我们只需要计算超过 1 步的位置进行推演。
      • 直观理解最后两点表述:我们只需要在进行该步骤前做 m>>1m>>1 步骤前移,就能够比上述方案使用步骤(时间)更短。

    设到达xx位置的最短时间为DP[x]DP[x]
    边界条件DP[x]=NDP[x],where 0xNDP[x] = N - DP[x] \quad,where\ 0\le x \le N
    转移方程

    DP[x]={min(DP[x],DP[x/2]+1), where x=2kmin(DP[x],DP[x/2+1]+2,DP[x/21]+2), where x=2k+1where k{0,1,...,2K,2K+1}DP[x]= \begin{cases} min(DP[x],DP[x/2]+1),\ where\ x = 2k\\ min(DP[x],DP[x/2+1]+2,DP[x/2-1]+2),\ where\ x = 2k+1\\ \end{cases}\\ where \ k \in \{0,1,...,2K,2K+1\}

    Go 代码如下

    func dp_method(n int, k int) int {
            if k <= n {
                    return n - k
            }
    
            // define min function
            min := func(a int, b int) int {
                    if a < b {
                            return a
                    }
                    return b
            }
    
            steps := make([]int, (k<<1)+2)
    
            for i := 0; i <= (k<<1)+1; i += 1 {
                    if i <= n {
                            steps[i] = n - i
                    } else {
                            steps[i] = steps[i-1] + 1
                    }
            }
    
            for i := 0; i <= (k<<1)+1; i += 1 {
                    if i%2 == 0 {
                            steps[i] = min(steps[i], steps[i>>1]+1)
                    } else {
                            steps[i] = min(steps[i], min(steps[(i+1)>>1]+2, steps[(i-1)>>1]+2))
                    }
            }
            return steps[k]
    }
    

    复杂度分析
    时间复杂度:O(K)O(K) 。具体分析1中已经进行过。
    空间复杂度:O(K)O(K)

实际运行

这里我们编写了一个随机对数器对两个运行时间进行检查(检查次数为1000次),如下: 得到运行时间(可能有一定不同):
image.png
虽然两个时间复杂度都是O(K)O(K),但是实际实现以及每一个对象操作次数有所不同,导致实际效果不同。

  1. 方法1使用一个 container\list 实现队列操作,并且其中有大量操作需要对队列进行pop,并且检查判断次数相比方法2也多。
  2. 方法2使用一个 切片进行实现,访问过程顺序进行,并且操作次数比1少,所以相对会更快一些。