光线传播问题

问题重述

该问题源自我们 Linux 课题小组选择的期末课程项目,原题来自浙江大学的陈宇璟教授。

光线传播问题

光线传播问题——输入输出样例

问题分析

这个问题如果正向思考,就显得很复杂。光源从四个方向发射出去,每经过一个格子,对于该格子要记录其光源到达的时间。但问题是格子事先不好确定,而且光源不只一个,是存在很多光源的。这还要比较最短的时间。

经这样一分析,采取正向传播方案则必须选择某种算法,而下面介绍的反向查找光源就容易理解与实现很多。

思路一:反向查找光源

整体思路

题目已经限定,光源所在网格和墙所在网格的最短到达时间时间为 $0$ 和 $-1$,因此我们只需关心为 $0$ 的网格。

光源正向传播到该目标网格,知道光源的初始速度以及该方向上光源到该目标网格之间的网格数,即可累加算出到达该网格的总时间。

以 $x$ 方向为例,设光源在 $x_1$ 处,且初始速度为 $m$ ,目标网格在 $x_2$ 处,($x_2>x_1$)中间没有墙,则光源从 $x_1$ 传播到 $x_2$ 的时间 $t_x$ 为:

$$ \begin{aligned} t_x &= (x_2-x_1)m + \sum_{i=0}^{x_2-x_1}i \\ &= (x_2-x_1)m + \frac{(x_2-x_1)(x_2-x_1-1)}{2} \\ \end{aligned} $$

令 $d_x=x_2-x_1$,则

$$ t_x=d_x(m+\frac{d_x-1}{2}) $$

同理可得, $y$ 方向的计算公式,

$$ t_y = d_y(m+\frac{d_y-1}{2}) $$

现在我们可以很轻松地计算光源到网格的传播时间。

一个初始值为 $0$ 的网格,寻找光源传播到该网格的最短时间,可以分为四个方向,沿着每个方向查找光源,计算途径与途径的光源的传播时间,保留最小值,直至传播终止。四个方向的传播时间再一次选取最小值。

依照这个思路,我们组设计的 $bash$ 脚本的程序流程图如下,

反向查找法-流程图

实现难点及解决方法

二维数组的表示

bash 中没有二维数组,只有一维数组。这样无法像 C++Python 那样直接用一个数组表示。

因此我们选择使用多个一维数组,组合成一个二维数组。

比如要表示题目输入的矩阵 $A$ , 则可以写成

1
2
3
4
5
6
7
8
9
# 把输入的每行暂存到一个字符变量
for (( line1 = 1; line1 <= i; line1++ )); do
    eval "read trow_${line1}"
done

# 将暂存的字符变量 trow_(i) 转换为数组 row_A(i),构建原始矩阵A
for (( line3 = 1; line3 <= i; line3++ )); do
    eval "row_A${line3}=(\${trow_${line3}})"
done

这样就曲线救国地构建了一个“二维”数组。当想访问第($i,$j)个元素,就可以使用

1
eval "echo row_A${i}[\${j}]"

来输出其中一个元素。

要输出整个矩阵A, 则可以

1
2
3
for (( line = 1; line <= i; line++ )); do
    eval "echo \${row_A${line}[@]}"
done

这样就解决了 bash 里没有二维数组的问题。

解决代码重复的问题

在这个方案中,输入矩阵A中的每一个零元素都要向四个方向进行查找遍历,直至撞墙或到达边界。对于这种重复性的工作,如果使用 $for$ 循环会使代码臃肿,不好修改。

这里我们采用定义函数的方法,将这个过程独立出来,单独编写,避免代码的重复,同时有问题的时候便于修改。

因此我们定义了函数 find_shortest()

 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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
function find_shortest(){
    # 这里全部定义为局部变量,避免混乱
    local li_input=$1
    local lj_input=$2
    local way=$3
    local best_time=-1
    local ttime=0
    local d=0
    local cell=0

    # 转换为 0-based 下标, (用python习惯了)
    local li0=$(( li_input - 1 ))
    local lj0=$(( lj_input - 1 ))

    if [ ${way} -eq 0 ]; then
        # 向右遍历:列从 lj0+1 到 j-1
        for (( col = lj0 + 1; col < j; col++ )); do
            eval "cell=\${row_A$(( li0 + 1 ))[${col}]}"
            # cell值只有在大于0才额外操作, 故用线性的多个if(而不是if-elif的多分枝)
            if [ -z "${cell}" ]; then
                continue
            fi
            if [ "${cell}" -eq -1 ]; then
                break
            fi
            if [ "${cell}" -eq 0 ]; then
                continue
            fi
            d=$(( col - lj0 ))
            # 到达时间公式:T = d * s + (d*(d-1))/2,其中 s 为光源的初始速度
            ttime=$(( d * cell + (d * (d - 1) / 2) ))
            if [ ${best_time} -eq -1 ] || [ ${ttime} -lt ${best_time} ]; then
                best_time=${ttime}
            fi
        done
    elif [ ${way} -eq 2 ]; then
        # 向左遍历 
        for (( col = lj0 - 1; col >= 0; col-- )); do
            eval "cell=\${row_A$(( li0 + 1 ))[${col}]}"
            if [ -z "${cell}" ]; then
                continue
            fi
            if [ "${cell}" -eq -1 ]; then
                break
            fi
            if [ "${cell}" -eq 0 ]; then
                continue
            fi
            d=$(( lj0 - col ))
            ttime=$(( d * cell + (d * (d - 1) / 2) ))
            if [ ${best_time} -eq -1 ] || [ ${ttime} -lt ${best_time} ]; then
                best_time=${ttime}
            fi
        done
    elif [ ${way} -eq 1 ]; then
        # 向上遍历
        for (( row = li0 - 1; row >= 0; row-- )); do
            eval "cell=\${row_A$(( row + 1 ))[${lj0}]}"
            if [ -z "${cell}" ]; then
                continue
            fi
            if [ "${cell}" -eq -1 ]; then
                break
            fi
            if [ "${cell}" -eq 0 ]; then
                continue
            fi
            d=$(( li0 - row ))
            ttime=$(( d * cell + (d * (d - 1) / 2) ))
            if [ ${best_time} -eq -1 ] || [ ${ttime} -lt ${best_time} ]; then
                best_time=${ttime}
            fi
        done
    else
        # 向下遍历
        for (( row = li0 + 1; row < i; row++ )); do
            eval "cell=\${row_A$(( row + 1 ))[${lj0}]}"
            if [ -z "${cell}" ]; then
                continue
            fi
            if [ "${cell}" -eq -1 ]; then
                break
            fi
            if [ "${cell}" -eq 0 ]; then
                continue
            fi
            d=$(( row - li0 ))
            ttime=$(( d * cell + (d * (d - 1) / 2) ))
            if [ ${best_time} -eq -1 ] || [ ${ttime} -lt ${best_time} ]; then
                best_time=${ttime}
            fi
        done
    fi
    echo ${best_time}
}

上面这个函数也是这个算法的精华部分。

思路二:Dijkstra算法

特例下的分析

之前有提到,这个题目正向做确实不太好想,而反向查找的思路是容易想到,并且实现起来也是简单的。

但方法一反向查找光源的算法,在某些情况下效率并不高,特别是当网格较大,但光源较少时,如下图:

网格大,光源少

上图是一个 $8 \times 12$ 的网格,大部分网格都是 $0$,只有中间的三个网格是光源。

如果按照反向查找光源的算法,除光源外的每一个网格都要遍历一次,一共有 $8 \times 12-3 =93$ 个格子。而每个格子又要向四个方向遍历,平均每个各自还要遍历其他 $(8-1)+(12-1) = 77$ 个格子,这样实际遍历了格子 $93 \times 77 = 7623$ 次。这个数目都远远超过了网格的数量。

这时候反向查找光源算法就展现了它的缺点:在网格多,光源少的情况下会对许多光源不可达的网格进行遍历,但这其实是不必要的,会大大增加程序的运算量与运行时间。如果我们选择从正向传播的话,从光源本身出发向四周遍历,光源传播不到的点自然就是不可达的点,该点就标记为 $-1$ 。相比思路一的反向查找,这样的正向查找在网格大,光源少的情况,效率应该是更高的。

Dijkstra算法

上个世纪50年代,荷兰科学家迪杰斯特拉(Dijkstra)在咖啡馆喝咖啡时,无意间想出了有向带权图中寻找最短路径的问题。由于该算法对编程界的突出贡献,Dijkstra获得了1972年的图灵奖。

迪杰斯特拉-年轻时

在一个有向且带权的图中,怎么找到一点到其他各点的最短路径呢?这正是 Dijkstra 算法所要解决的问题。

该算法的核心思路是从起点开始,每次选择“当前到起点距离最小”的未处理节点,更新其邻接点的最短路径,直到所有节点都被处理。

    1. 初始化所有点的距离为 ∞,起点距离为 0
    1. 把所有点放入一个未处理的集合
    1. 当集合非空时:
    • a. 从集合中取出距离最小的点 u
    • b. 对 u 的每个邻居 v: 如果通过 u 到 v 更短,则更新 v 的距离
    • c. 把 u 标记为已处理(从集合中移除)

例如,对于一个有向带权图,利用Dijkstra算法的流程如下:

Dijkstra算法例题

Dijkstra算法例题解

算法应用

在知道了Dijkstra算法后,我们就可以将它应用到正向传播的方案中。

我们选择用关联数组来保存一个网格的位置(x,y),传播方向(d),以及到达的最短时间(t)。

declare -A best 这里 best 的键为 "x,y,d" 。通过光源传播到网格,不断更新网格的到达最短时间,最后得到的就是所有光源到达该网格的最短时间,光源不可达的网格就为 $-1$ 。

这个算法的程序流程图如下:

插入图片

实现难点及解决方案

定义无穷大量

在Dijkstra算法的具体实现中,是要使用无穷大来表示暂时不可达的。在 Python 里,无穷大常量可以用 float('inf') 表示。但在 bash 里没有这么高级的语法。

因此我们定义了一个尽可能大的量 INF=1000000000 。这个数字已经足够大了,一般来说输入的数字应该是不会太大的,因此这不失为一个解决方案。

实现动态更新最短时间

正向传播的特点就是要从每个光源网格出发,向四个方向传播光线,光线沿途的格子的最短时间。

代码实现如下(变量的定义详见附件中的代码):

 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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# 将光源位置的 ans 置为 0,并向四个方向生成初始状态
for ((i=0; i<m; i++)); do
    for ((j=0; j<n; j++)); do
        key="$i,$j"
        cell=${grid["$key"]}
        if (( cell > 0 )); then
            # 光源位置到达时间固定为 0
            ans["$key"]=0
            v=$cell
            # 对四个方向
            for d in {0..3}; do
                ni=$((i + dx[d]))
                nj=$((j + dy[d]))
                # 判断邻居是否在边界并且不为障碍
                if (( ni < 0 || ni >= m || nj < 0 || nj >= n )); then
                    continue
                fi
                nkey="$ni,$nj"
                if [[ "${grid["$nkey"]}" == "-1" ]]; then
                    continue
                fi
                # 到达第一个邻居的累计时间为 v,下一步耗时为 v+1
                t_new=$v
                s_new=$((v + 1))
                state="$ni,$nj,$t_new,$s_new,$d"
                key_state="$ni,$nj,$d"
                best["$key_state"]=$t_new
                states+=("$state")
                # 更新 ans:取更小的累计时间
                if (( t_new < ans["$nkey"] )); then
                    ans["$nkey"]=$t_new
                fi
            done
        fi
    done
done

# Dijkstra 过程:每次在 states 数组中选择 t 最小的状态扩展
while ((${#states[@]} > 0)); do
    # 找到数组中 t 最小的状态
    min_index=0
    IFS=',' read x_min y_min t_min s_min d_min <<< "${states[0]}"
    for idx in "${!states[@]}"; do
        IFS=',' read x y t s d <<< "${states[$idx]}"
        if (( t < t_min )); then
            min_index=$idx
            x_min=$x
            y_min=$y
            t_min=$t
            s_min=$s
            d_min=$d
        fi
    done

    # 取出最小状态并删除
    state="${states[$min_index]}"
    unset 'states[min_index]'
    # 重新构造 states 数组(去除中间空位置)
    states=("${states[@]}")

    IFS=',' read x y t s d <<< "$state"

    # 继续沿 d 方向扩展
    nx=$((x + dx[d]))
    ny=$((y + dy[d]))
    # 检查边界
    if (( nx < 0 || nx >= m || ny < 0 || ny >= n )); then
        continue
    fi
    nkey="$nx,$ny"
    # 遇到障碍停止
    if [[ "${grid["$nkey"]}" == "-1" ]]; then
        continue
    fi
    new_t=$((t + s))
    new_s=$((s + 1))
    state_key="$nx,$ny,$d"
    # 更新状态:如果未记录或更优,则更新
    prev=${best["$state_key"]}
    if [[ -z "$prev" || $new_t -lt $prev ]]; then
        best["$state_key"]=$new_t
        new_state="$nx,$ny,$new_t,$new_s,$d"
        states+=("$new_state")
        # 更新 ans 记录,取最小累计时间
        if (( new_t < ans["$nkey"] )); then
            ans["$nkey"]=$new_t
        fi
    fi
done

上面这部分是Dijkstra算法的精髓所在。

方法总结与讨论

我们组用了两个方案来完成这个题目。两个方案都有各自的优缺点及适应情景。

第一个方案就是反向查找光源的算法,光源的正向传播到一个零网格,可以反向看作这个零网格沿着传播方向的反方向查找光源。每个零网格有四个方向,只要向四个方向查找光源,取到达时间的最小值即是光线传播到该位置的最短时间。这个方案的优点是,容易想到且代码好写,没有用到复杂的算法,在光源较多时,反向查找光源的方案是最快的。但当输入的网格数变多,但光源网格很少的情况下,反向查找光源的方案会浪费在遍历很多其实是光线不可达的网格,而且每个网格还是遍历它的四个方向,这样效率就很低下。

第二个方案,也就是Dijkstra算法就适合于网格多、光源少的情况。我们只需要关注光源网格,从光源网格的四个方向出发,更新沿途经过网格的最短时间。这个方案虽然是正向的,但在具体实现上比较复杂,如这里我们就用到Dijkstra算法。但它的优点就是效率高,减少了许多无用的遍历。不过当网格数较少,但光源较多时,这个算法遍历的光源数目就多,这时候程序的运行效率是比不上方案一的。

项目收获与感悟

在参与本项目的过程中,我们实战写 bash 脚本,期间遇到了许多问题。如代码运行报错,陷入死循环,不会处理数据的输入等。在这些问题的驱使下,我们又再次翻开课本,复习学过的 LinuxShell 知识点。在这个过程中,进一步巩固了我们的 Linux 操作与应用技能。我们思考问题的解决方案,我们不仅只满足于反向查找光源的简单算法,还了解、学习并应用了Dijkstra算法。在学习算法的过程中,如同开启了一门新的课程,尝试去学习图论基础,去理解什么是有向图,去掌握大名鼎鼎的Dijkstra算法。这同时又增强了我们的编程能力和学习算法的信心。在查找资料的时候,我们阅读各大论坛的帖子、博客,观看视频网站的教学视频,这又增强了我们的资料检索能力。

编程的过程不是一帆风顺的,需要的是不断地调试与修改。编程圈一直流传着一句话:“到现在为止,所有的代码,所有的算法,都是人写出来的,别人能想到的,你也一定可以!”虽然以后生信专业用 bash 来编程的情况会很少,大多用 PythonR 等语言,但所有编程语言的本质无非只是我们思想表达的一个工具。我们不要过于在意编程语言的语法,重要的是,我们也没有解决问题的思路,有没有失败后再来的决心。

参考文献

[1] 蓝不过海.图-最短路径-Dijkstra(迪杰斯特拉)算法.哔哩哔哩.https://www.bilibili.com/video/BV1uT4y1p7Jy/

[2]

Licensed under CC BY-NC-SA 4.0
最后更新于 2025年5月16日 20:57
使用 Hugo 构建
主题 StackJimmy 设计