青训营 & 码上掘金:关于“寻友之旅”的研究

93 阅读5分钟

当青训营遇上码上掘金,我不仅学习到了许多关于后端工程开发的知识,也同时了解到了书本和竞赛中的算法与数据结构是如何落实到具体工程中,解决实际问题的。同时,掘金社区提供的“码上掘金”功能也帮助我更加方便快捷的与同学朋友一同分享、讨论代码。

专题3是一个有趣的实际问题,大致内容如下:

小青要找小码去玩,他们的家在一条直线上,当前小青在地点 N,而小码在地点 K,并且小码在自己家原地不动等待小青。小青有两种交通方式可选:步行和公交。

  • 步行:小青可以在一分钟内从任意节点 X 移动到节点 X-1 或 X+1

  • 公交:小青可以在一分钟内从任意节点 X 移动到节点 2X (公交不可以向后走)

数据范围:0N,K1050\leq N,K\leq 10^5

我们简化一下这个问题,得到了如下内容:给定从0开始的整数坐标数轴,一个位于位置 N 的小人想要到达位置 K,他每分钟要么向左或者向右移动一格,要么一下子将自己“传送”到当前坐标乘以2的地方,尝试求出最小路径。

当数据范围较小的时候,我们可以暴力枚举或者搜索,让他不断在数轴上反复移动,直到到达终点。这种方式一定能够得到最佳解,因为他遍历了整个解空间,因此能够对于所有可能的方案进行检查。同样的,这也是他的缺点:解的方案数量太多,一旦数据规模增大,解空间的大小呈指数级增长。(根据我的估计,可能当坐标规模到达40到50左右的时候,普通的计算机就很难在可接受的有限时间内计算出结果了)。

对于这种“反复移动”,我们可以使用随机数来优化他,因为对于朴素数据,随机的算法往往不会太差。因此,我们可以使用爬山算法或者模拟退火算法对程序进行优化,尝试得到一个可能的近似解。优点在于,这种方式的速度还不错,而且解一般来说也不会太差,问题在于,随机算法很难得到最优解,并且容易被极端情况或精心构造的数据给“骗”过去。

我们注意到,当小人在数轴上反复移动的时候,状态的变化仅仅在于坐标。那么,如果我们使用类似 f[x] 的状态,来表示当前小人在位置 x 时的最短步数,那么我们能不能设计出一个状态转移方程,使用动态规划的方式来解决呢?

我们沮丧的发现,小人的状态移动具有后效性,相邻的两个状态步数能够互相转移,这使得动态规划的性质显得似乎不再成立。也许高手们可以使用类似高斯消元法的方式来消去方程中的后效性影响,但这也使得算法的复杂度达到了立方级别,这对于十万规模的数据依旧难以承受。

该问题无法使用动态规划,是因为其拓扑顺序并不呈现有向无环图(DAG)的性质,而是一个标准的带环无边权无向图,很难通过类似拓扑排序的方式,计算前继状态来推算出后面的最优解。但是,一旦将问题模型变成一张有向图,那么问题就变成了两个端点之间的最短路径问题,而图中的所有边都是已知的(要么从左右走过来,要么公交跳一下),这不得不让我们好奇:是否可以使用最短路径算法来解决呢?

数轴上的点是 10510^5 左右,随后我们将其翻倍(可能会到达更向上的点来走回来,但是一定不会超过 21052*10^5),那么点的数量规模大约就是 n=2105n=2*10^5。每个点都有三条边相连(如上所述),因此边的数量规模就是 m=3n=6105m=3n=6*10^5。由于我们的起点和终点只有一对,无需计算其他信息,因此这就是一个单源最短路径问题。

对于单源最短路径问题,我们有很多计算方法,考虑到本题的规模较大,且无边权(所有边的边权均为1),因此我们可以使用堆优化的Dijkstra算法,它的复杂度是 O((n+m)logn)O((n+m)\log n),是此类问题中表现最为出色的算法之一。

我的代码基于C++(使用了一些C++11的语言特性)实现,堆则使用了优先队列(priority_queue)进行代替。

 // 第五届「青训营 X 码上掘金」主题创作活动
 // 主题3:寻友之旅
 // 建立有向图模型,使用最短路算法求解
 #include <bits/stdc++.h>
 using namespace std;
 #define LL long long
 const int N = 200010, M = 800010;
 int n, S, T;
 int tot = 0, Head[N], Next[M], ver[M];
 LL edge[M];
 void addEdge(int x, int y, LL z) {
     ver[++tot] = y, edge[tot] = z;
     Next[tot] = Head[x], Head[x] = tot;
 }
 //
 struct Node {
     int x; LL d;
     bool operator < (const Node &rhs) const {
         return d > rhs.d;
     }
 };
 priority_queue<Node> q;
 LL dis[N];
 bool vis[N];
 void Dijkstra() {
     memset(dis, 0x3f, sizeof(dis));
     memset(vis, 0, sizeof(vis));
     dis[S] = 0, q.push((Node){S, 0});
     while (!q.empty()) {
         int x = q.top().x; q.pop();
         if (vis[x]) continue;
         vis[x] = 1;
         for (int i = Head[x]; i; i = Next[i]) {
             int y = ver[i]; LL z = edge[i];
             if (dis[y] > dis[x] + z) {
                 dis[y] = dis[x] + z;
                 q.push((Node){y, dis[y]});
             }
         }
     }
 }
 int main() {
     cin >> S >> T;
     int n = 200002;
     for (int i = 0; i <= n; ++i) {
         if (i > 0) addEdge(i - 1, i, 1);
         if (i < n) addEdge(i + 1, i, 1);
         if (i % 2 == 0 && i > 0) addEdge(i / 2, i, 1);
     }
     Dijkstra();
     cout << dis[T] << endl;
     return 0;
 }

我的码上掘金地址如下:青训营 & 码上掘金:关于“寻友之旅”的研究