0262 翻山越岭
* * * *
拉格朗日计划
* * * *
翻山越岭

设有一座坐标在$0\le x, y\le 1600$之间的山,每一点$(x,y)$处高度为 $$h=\left(5000-\frac{x^2+y^2+xy}{200}+\frac{25(x+y)}{2}\right)\times e^{-\left|\frac{x^2+y^2}{1000000}-\frac{3(x + y)}{2000}+\frac{7}{10}\right|}.$$ 一只蚊子试图从点$A (200,200)$飞往点$B (1400,1400)$,它选择首先上升到高度f,随后在维持该高度不变的情况下绕过可能的障碍而飞到B的上方。

显然这样的路线有很多,请确定其中的最小高度$f_{min}$,并求出在$f_{min}$高度时从A到B的最短路径之长度,四舍五入到三位小数。

本题难度:



解答

本题非常麻烦,本人花费了大约三周左右的时间才找出答案。

本题中的地形连续变化,常规的思维是将其离散化再使用最短路径算法,但地图的范围是$1600\times 1600$,即使只用0.1的mesh size数据量也将十分庞大,且未必能达到要求的精度。因此首先需要找出最小高度$f_{min}$,再将其转化为平面问题。

先取1/3的间隔,从而将地图范围视作$4800\times 4800$的网格,计算出网格上所有点的高度并排序,随后二分查找可以从起点到达终点的最低高度。

对于每个给定的高度值f,将地图范围视作一张图,高度不超过f的点可以到达,超过f的点不可到达,每个点可以访问其相邻八个点中可以到达的点。

使用Dijkstra算法计算从 $(200,200)$到$(1400,1400)$之间是否存在通路,以下是计算最短高度的代码:

import math,heapq

height=lambda x,y:(5000-0.005*(x*x+y*y+x*y)+12.5*(x+y))*math.exp(-abs(0.000001*(x*x+y*y)-0.0015*(x+y)+0.7))

infty=999999999999
scale=3
mesh=1600*scale
sides=1/scale
diagonal=math.sqrt(2)/scale

ups=sorted([*{height(x,y) for x in range(mesh+1) for y in range(mesh+1)}])
a,b=0,len(ups)-1
while a < b-1:
    c=(a+b)//2
    z=ups[c]
    reachable=False
    d={(200*scale,200*scale):0}
    q=[(0,200*scale,200*scale)]
    visited=set()
    while q and not reachable:
        m,x,y=heapq.heappop(q)
        k=x*mesh+y
        if k not in visited:
            visited.add(k)
        if x==y==1400*scale:
            reachable=True
            break
        for u,v in [(x-1,y-1),(x-1,y),(x-1,y+1),(x,y-1),(x,y+1),(x+1,y-1),(x+1,y),(x+1,y+1)]:
            if 0 <= u <= mesh and 0 <= v <= mesh and height(u/scale,v/scale) <= z:
                r=sides if u==x or v==y else diagonal
                if d.get((u,v),infty)>d[(x,y)]+r:
                    d[(u,v)]=d[(x,y)]+r
                    heapq.heappush(q,(d[(u,v)],u,v))
    if reachable:
        b=c
    else:
        a=c

print("min height:",ups[b])
如此可得最小高度约为10396.462。这段代码耗时很长,且难以估计进度,需要耐心等待其运行。如取1/2的间隔,则精度只能达到小数点后两位。

接下来作图观察一下地形,以下是用matplotlib库作地形图的代码及生成的图片:

import math
import matplotlib.pyplot as plt
import numpy as np
from matplotlib import cm
from matplotlib.ticker import LinearLocator

height=lambda x,y:(5000-0.005*(x*x+y*y+x*y)+12.5*(x+y))*math.exp(-abs(0.000001*(x*x+y*y)-0.0015*(x+y)+0.7))

step=0.5

fig=plt.figure()
ax=fig.add_subplot(projection='3d')

X=np.arange(0,1600,step)
Y=np.arange(0,1600,step)
Z=np.array([[height(x,y) for y in Y] for x in X])
X,Y=np.meshgrid(X,Y)

surf = ax.plot_surface(X,Y,Z,cmap=cm.coolwarm,linewidth=0,antialiased=False)

ax.set_zlim(0,20000)
ax.zaxis.set_major_locator(LinearLocator(20))

fig.colorbar(surf, shrink=0.5, aspect=5)

plt.show()


可以看出地形呈火山状,因数据范围很大,图片不太精细,但事实上主要的起伏都在火山口处。

从图中容易想到最短路径的策略应当不外乎先直线前进一段距离,直到遇到障碍,然后沿着等高线所在的圆弧运动一段距离,直到与终点之间无障碍,最后直线前进到终点。

这一走向大致是正确的,但并非最优路径,这是因为显然在沿着等高线的圆弧运动需要移动的距离更多,所以应当尽可能减少这部分移动的长度。

上述策略可以改进为:在等高线上取一点,该点出的切线恰能通过起点(或尽可能接近),从起点处直接沿着这条切线运动进入等高线(这一过程类似跑步或赛车时入弯的最短路径)。

具体实现时,先预设合理的步长,此处取0.0001。从起点$(200,200)$出发,不断沿着直线方向向终点$(1400,1400)$运动,直到无法前进,就达到了所需要进入的等高线。

接下来沿着等高线向终点移动充分远的距离(此处取1500),并计算每一点处与起点$(200,200)$练习的斜率,并与该点处的切线斜率比较,记录下差异最小的点,就是切入点。

此处涉及到的计算有
  • 沿着等高线的运动:利用梯度垂直于等高线的切线这一性质,先计算出当前点的梯度,然后在两个正交的方向中取移动后离目的地更近的作为移动方向。


  • 梯度计算:直接用公式计算,注意由于表示式中有绝对值,事实上可能存在一些不可微的点,不过此处似乎可以忽略这一情形。此外,由于表达式具有对称性,容易看出$f_y(x,y)=f_x(x,y)$。


  • 切线斜率:由多元隐函数求导法则可知切线斜率是$-f_x/f_y$。


类似地,交换上文中的终点和起点就可以求得切出点。以下是上述计算的代码:
import math

height=lambda x,y:(5000-0.005*(x*x+y*y+x*y)+12.5*(x+y))*math.exp(-abs(0.000001*(x*x+y*y)-0.0015*(x+y)+0.7))
sub=lambda x,y: math.exp(-(0.000001*(x*x+y*y)-0.0015*(x+y)+0.7))*(-(0.000001*(2*x)-0.0015)) if 0.000001*(x*x+y*y)-0.0015*(x+y)+0.7>=0 else math.exp(0.000001*(x*x+y*y)-0.0015*(x+y)+0.7)*(0.000001*(2*x)-0.0015)
partial=lambda x,y:(-0.005*(2*x+y)+12.5)*math.exp(-abs(0.000001*(x*x+y*y)-0.0015*(x+y)+0.7))+(5000-0.005*(x*x+y*y+x*y)+12.5*(x+y))*sub(x,y)
norm=lambda x,y:math.sqrt(x*x+y*y)
colinear=lambda x,y,a,b:abs(partial(x,y)*(x-a)+(y-b)*partial(y,x))

def findTangent(x1,y1,x2,y2,z,h,limit=1500):
    x,y=x1,y1
    r=norm(x-x2,y-y2)
    dx,dy=(x2-x)/r,(y2-y)/r
    while height(x+h*dx,y+h*dy) <= z:
        x,y=x+h*dx,y+h*dy
        r=norm(x-x2,y-y2)
        dx,dy=(x2-x)/r,(y2-y)/r
    print(f"Stopped at ({x,y,height(x,y)})")
    j=0
    mt=99999999
    mx=my=-1
    while j*h < limit:
        dx,dy=partial(x,y),partial(y,x)
        r=norm(dx,dy)
        dx,dy=h*dx/r,h*dy/r
        u1,v1=x-dy,y+dx
        u2,v2=x+dy,y-dx
        m1,m2=norm(x2-u1,y2-v1),norm(x2-u2,y2-v2)
        if m1 < m2:
            if 0 <= u1 <= 1600 and 0 <= v1 <= 1600:
                x,y=u1,v1
            else:
                break
        else:
            if 0 <= u2 <= 1600 and 0 <= v2 <= 1600:
                x,y=u2,v2
            else:
                break
        t=colinear(x,y,x1,y1)
        if t < mt:
            mt,mx,my=t,x,y
        j+=1
    if j*h==limit:
        print("completed")
    else:
        print(f"stucked at {x,y}")
    return mx,my,norm(mx-x1,my-y1),mt

z=10396.462
x=y=200
a=b=1400
h=0.0001

u,v,d,t=findTangent(x,y,a,b,z,h)
print(f"Tangent in at{u,v,height(u,v)}, with distance {d}, tangents {t}")

u,v,d,t=findTangent(a,b,x,y,z,h)
print(f"Tangent out at{u,v,height(u,v)}, with distance {d}, tangents {t}")  
根据运行的结果,切入点大约是$(624.651,48.253)$,切出点大约是$(873.039,1536.042)$,切入前/切除后各自需要移动的直线距离分别大约是450.948和544.238。

有高度函数的对称型可以看出$(1536.042, 873.039)$也可以是切出点,而从该点移动到$(624.651,48.253)$显然更近,因此以这一点作为切出点。

最后,计算等高线上这两点间的测地线距离,代码如下,最终结果是$2531.205$。

import math

height=lambda x,y:(5000-0.005*(x*x+y*y+x*y)+12.5*(x+y))*math.exp(-abs(0.000001*(x*x+y*y)-0.0015*(x+y)+0.7))
sub=lambda x,y: math.exp(-(0.000001*(x*x+y*y)-0.0015*(x+y)+0.7))*(-(0.000001*(2*x)-0.0015)) if 0.000001*(x*x+y*y)-0.0015*(x+y)+0.7>=0 else math.exp(0.000001*(x*x+y*y)-0.0015*(x+y)+0.7)*(0.000001*(2*x)-0.0015)
partial=lambda x,y:(-0.005*(2*x+y)+12.5)*math.exp(-abs(0.000001*(x*x+y*y)-0.0015*(x+y)+0.7))+(5000-0.005*(x*x+y*y+x*y)+12.5*(x+y))*sub(x,y)
norm=lambda x,y:math.sqrt(x*x+y*y)

def getGeodesic(x,y,a,b,h,epsilon,limit):
    j=0
    d=99999999
    while d>epsilon and j*h < limit:
        dx,dy=partial(x,y),partial(y,x)
        r=norm(dx,dy)
        dx,dy=h*dx/r,h*dy/r
        u1,v1=x-dy,y+dx
        u2,v2=x+dy,y-dx
        m1,m2=norm(a-u1,b-v1),norm(a-u2,b-v2)
        if m1 < m2:
            if 0 <= u1 <= 1600 and 0 <= v1 <= 1600:
                x,y=u1,v1
            else:
                break
        else:
            if 0 <= u2 <= 1600 and 0 <= v2 <= 1600:
                x,y=u2,v2
            else:
                break
        d=norm(a-x,b-y)
        j+=1
    if d < epsilon:
        print(f"Completed with {j} steps, total distance {450.948315086643+544.2381822641057+j*h}")
    elif j*h==limit:
        print("Not Found")
    else:
        print(f"Stucked at {x,y}")
    return j*h,x,y

x,y,r1=624.6510689712037,48.253075473368014,450.94973054840307
b,a,r2=873.0389052039443,1536.0424611611559,544.2385016400826

h=0.0001

mx=my=mj=-1
limit=2500
epsilon=1

d1,x1,y1=getGeodesic(x,y,a,b,h,epsilon,limit)
d2,x2,y2=getGeodesic(a,b,x,y,h,epsilon,limit)

print(r1+r2+d1+d2+norm(x1-x2,y1-y2))
实际运行中可以发现在测地线在某一位置处十分靠近地图边界,此处的等高线方向不易处理,因此从上面的代码中从起点和终点分别出发计算测地线,最后在地图边界处直接将两者相连(距离为1左右)。

把上面的代码稍加修改可以绘制出路线图,绘图代码和所得的图片如下:

import math
import matplotlib.pyplot as plt
import numpy as np

from matplotlib import cm
from matplotlib.ticker import LinearLocator

height=lambda x,y:(5000-0.005*(x*x+y*y+x*y)+12.5*(x+y))*math.exp(-abs(0.000001*(x*x+y*y)-0.0015*(x+y)+0.7))
sub=lambda x,y: math.exp(-(0.000001*(x*x+y*y)-0.0015*(x+y)+0.7))*(-(0.000001*(2*x)-0.0015)) if 0.000001*(x*x+y*y)-0.0015*(x+y)+0.7>=0 else math.exp(0.000001*(x*x+y*y)-0.0015*(x+y)+0.7)*(0.000001*(2*x)-0.0015)
partial=lambda x,y:(-0.005*(2*x+y)+12.5)*math.exp(-abs(0.000001*(x*x+y*y)-0.0015*(x+y)+0.7))+(5000-0.005*(x*x+y*y+x*y)+12.5*(x+y))*sub(x,y)
norm=lambda x,y:math.sqrt(x*x+y*y)

def getGeodesicPath(x,y,a,b,h,epsilon,limit):
    pathx=[x]
    pathy=[y]
    pathz=[height(x,y)]
    j=0
    d=99999999
    while d>epsilon and j*h < limit:
        dx,dy=partial(x,y),partial(y,x)
        r=norm(dx,dy)
        dx,dy=h*dx/r,h*dy/r
        u1,v1=x-dy,y+dx
        u2,v2=x+dy,y-dx
        m1,m2=norm(a-u1,b-v1),norm(a-u2,b-v2)
        if m1 < m2:
            if 0 <= u1 <= 1600 and 0 <= v1 <= 1600:
                x,y=u1,v1
            else:
                break
        else:
            if 0 <= u2 <= 1600 and 0 <= v2 <= 1600:
                x,y=u2,v2
            else:
                break
        d=norm(a-x,b-y)
        j+=1
        pathx.append(x)
        pathy.append(y)
        pathz.append(height(x,y))
    if d < epsilon:
        print(f"Completed with {j} steps, total distance {450.948315086643+544.2381822641057+j*h}")
    elif j*h==limit:
        print("Not Found")
    else:
        print(f"Stucked at {x,y}")
    return pathx,pathy,pathz

x,y,r1=624.6510689712037,48.253075473368014,450.94973054840307
b,a,r2=873.0389052039443,1536.0424611611559,544.2385016400826

h=0.0001

mx=my=mj=-1
limit=2500
epsilon=1

resolution1=500
resolution2=2000

px1,py1,pz1=getGeodesicPath(x,y,a,b,h,epsilon,limit)
px2,py2,pz2=getGeodesicPath(a,b,x,y,h,epsilon,limit)
pathx=px1+px2[::-1]
pathy=py1+py2[::-1]
pathz=pz1+pz2[::-1]
t=len(pathx)//resolution2

r1,r2=norm(x-200,y-200),norm(a-1400,b-1400)
dx1,dy1,dx2,dy2=(x-200)/r1,(y-200)/r1,(a-1400)/r2,(b-1400)/r2
linex1=[200+i*dx1*r1/resolution1 for i in range(resolution1)]
liney1=[200+i*dy1*r1/resolution1 for i in range(resolution1)]
linez1=[height(200+i*dx1*r1/resolution1,200+i*dy1*r1/resolution1) for i in range(resolution1)]

linex2=[1400+i*dx2*r2/resolution1 for i in range(resolution1)][::-1]
liney2=[1400+i*dy2*r2/resolution1 for i in range(resolution1)][::-1]
linez2=[height(1400+i*dx2*r2/resolution1,1400+i*dy2*r2/resolution1) for i in range(resolution1)][::-1]

fig=plt.figure()
ax=fig.add_subplot(projection='3d')

ax.scatter(linex1+pathx[::t]+linex2,liney1+pathy[::t]+liney2,linez1+pathz[::t]+linez2,marker="o")
plt.show()     


注:本题中的代码均为Python 3,原因一是便于作浮点数计算,二是所使用的库和函数只支持Python 3。

注2:本题涉及的微分内容一般在我国《高等数学》课程中的多元微积分部分讲授。