封面来源:碧蓝航线 愚者的天平 活动 CG

本文涉及的代码:mofan-demo/new-feature/src/test/java/indi/mofan/lambda/backtracking

1. 背景介绍

Reduce Functions 一文从实际业务出发,围绕 Stream#reduce() 展开,介绍了整套的代码设计与实现。Java8 引入的 Stream 不仅可以完成集合的 filtermap 等基本操作,将其融入到代码设计中还能够进一步抽象业务代码。

在 Java8 以前,方法(或函数)并不是一等公民,而在 Java8 引入 Lambda 表达式后,以另一种思路解决了这个问题。在实际开发过程中,使用 Lambda 表达式进行代码重构是我乐此不疲的事,它能够很好地实现千变万化的需求,将实际处理逻辑交给上游实现,而无需变更下游的接口定义。

本文将从一个简单的 DFS 出发,利用 Lambda 表达式对其进行重构,并完成回溯、剪枝等操作。

为保证阅读的流畅性,阅读前需要掌握的前置知识:

  • 熟悉常见的函数式接口
  • 对回溯有一定的理解,如果从未接触过回溯,可以参考 回溯算法理论基础 速通一波

2. 现状分析

在第一版需求中,需要基于 DFS 完成多个节点的遍历。假设节点有以下类型:

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
public enum NodeType {
    /**
     * 单个节点 A
     */
    SINGLE_A,

    /**
     * 单个节点 B
     */
    SINGLE_B,

    /**
     * 单个节点 C
     */
    SINGLE_C,

    /**
     * 容器
     */
    CONTAINER,

    /**
     * 虚拟容器
     */
    VIRTUAL_CONTAINER
}

SINGLE_ASINGLE_BSINGLE_C 代表不同的 SINGLE 节点,这些节点内部不会嵌套其他节点。

如果想要将多个 SINGLE 节点进行组合,则需要使用容器节点。

VIRTUAL_CONTAINER 节点内部 只能 包含若干个 SINGLE 节点,CONTAINER 节点内部 只能 包含多个 VIRTUAL_CONTAINER 节点。

CONTAINER 节点内部不会直接嵌套 SINGLE 节点,但借由嵌套的 VIRTUAL_CONTAINER 节点,就好像嵌套了 SINGLE 节点一样。

进行节点遍历时,忽略 VIRTUAL_CONTAINER 类型的节点!

基于以上节点类型,可以新建出具有以下依赖关系的多个类:

NodeInfo-Class-Diagram

3. 第一需求

给出一个节点列表,列表内存在各种的节点,要求实现遍历每个节点(不包括 VIRTUAL_CONTAINER 类型的节点,就像它的名称一样,它是虚拟的),但对每个节点的处理由调用方决定。

实现这个需求不难,无非两点:

  1. 挨个遍历节点,遇到容器节点时挨个遍历内部嵌套节点
  2. 要想对节点的处理由调用方决定,使用函数式接口 Consumer 即可
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
public class NodeFindSimpleSupport {

    protected final List<? extends NodeInfo> nodes;

    protected NodeFindSimpleSupport(List<? extends NodeInfo> nodes) {
        this.nodes = nodes;
    }

    public static NodeFindSimpleSupport from(List<? extends NodeInfo> nodes) {
        return new NodeFindSimpleSupport(nodes);
    }

    public void findNodes(Consumer<NodeInfo> consumer) {
        for (NodeInfo nodeInfo : CollectionUtils.emptyIfNull(this.nodes)) {
            findNode(nodeInfo, consumer);
        }
    }

    private void findNode(NodeInfo nodeInfo, Consumer<NodeInfo> consumer) {
        if (nodeInfo == null) {
            return;
        }
        // 处理每个节点,不包含虚拟容器节点
        if (!NodeType.VIRTUAL_CONTAINER.equals(nodeInfo.getNodeType())) {
            consumer.accept(nodeInfo);
        }
        // 处理容器节点
        if (nodeInfo instanceof BaseContainerNode container) {
            from(container.getNodes()).findNodes(consumer);
        }
    }
}

编写单元测试验证一下:

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
private List<NodeInfo> buildNodes() {
    SingleA a1 = new SingleA("A1");
    SingleA a2 = new SingleA("A2");
    SingleA a3 = new SingleA("A3");
    SingleA a4 = new SingleA("A4");

    SingleB b1 = new SingleB("B1");
    SingleB b2 = new SingleB("B2");

    SingleC c1 = new SingleC("C1");
    SingleC c2 = new SingleC("C2");

    VirtualContainerNode containerA = new VirtualContainerNode("ContainerA", List.of(a1, b1));
    VirtualContainerNode containerB = new VirtualContainerNode("ContainerB", List.of(a2, b2, c1));

    ContainerNode parentContainer = new ContainerNode("parentContainer", List.of(containerA, containerB));

    return List.of(a3, a4, parentContainer, c2);
}

@Test
public void testNodeFindSimpleSupport() {
    NodeFindSimpleSupport support = NodeFindSimpleSupport.from(buildNodes());
    AtomicInteger count = new AtomicInteger(0);
    // 统计节点数量
    support.findNodes(node -> count.incrementAndGet());
    assertThat(count).hasValue(9);
}

4. 第二需求

4.1 作用域内的节点

现在新增了一个需求:返回某一节点所在【作用域】内所有节点。

对于【作用域】,则有:

  • 同一容器节点内的所有节点属于同一作用域,不同容器节点内的节点属于不同作用域

  • 嵌套在容器节点内的另一个容器节点下的作用域与外层作用域不同

  • 最初的节点列表下的节点属于同一个作用域

节点的作用域

以上图为例:

  • A3、A4、ContainerA、ContainerB 和 C2 属于同一作用域
  • A1 和 B1 属于同一作用域
  • A1 与 A2、A1 和 A3 并 不属于 同一作用域

4.2 需求剖析

新增需求的要点

新增的需求一共有两个要点:

  1. 先找到目标节点
  2. 然后返回目标节点所在作用域内的所有节点

找目标节点很简单,利用 DFS 遍历每个节点,如果当前节点 ID 等于目标节点 ID,那么就找到目标节点了。

为了能够更方便地获取目标作用域内的所有节点,在遍历节点时需要额外维护一个栈,栈内每个位置存放的元素类型是 List<? extends NodeInfo>

每当遍历到一个新的作用域时,先将包含该作用域内所有节点的集合入栈。如果目标节点存在于该作用域内,那么栈顶元素即为所求;如果遍历完该作用域内的所有节点都没有找到目标节点,那么弹出栈顶元素,继续遍历剩余节点,直到找到目标节点或遍历完所有节点。

当找到目标节点时,应当立即终止遍历, 后续的遍历没有意义。

新增需求带来的变化

与原始需求相比,新增需求后的代码实现也会存在变化:

  • 需要额外维护一个栈
  • 存在遍历终止条件

如果了解回溯算法,不难发现就是要将原先的简单遍历改写成回溯,但是 不能影响原来的功能。

5. 实现重构

回溯相比于普通 DFS 遍历,就是增加了遍历终止条件。如果又要实现回溯,由不能影响原来的功能,那么可以将是否终止遍历交由上层调用方决定。

对于每个节点来说,原先使用函数式接口 Consumer 进行处理,重构成回溯后,为了能够判断是否需要终止遍历,需要选用函数式接口 Predicate 进行处理。如果需要遍历完所有节点,那么 Predicate 始终返回 false 即可。

容器节点共有两种:

  • CONTAINER 类型:依次遍历内部的虚拟容器节点即可,该类型节点嵌套的节点并不构成一个作用域
  • VIRTUAL_CONTAINER 类型:该类型节点嵌套的节点构成一个作用域,因此遍历时需要先将内部节点组成的集合入栈,然后再依次遍历内部的节点

重构后的代码如下:

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
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
public class NodeFindSupport extends NodeFindSimpleSupport {

    private NodeFindSupport(List<? extends NodeInfo> nodes) {
        super(nodes);
    }

    public static NodeFindSupport from(List<? extends NodeInfo> nodes) {
        return new NodeFindSupport(nodes);
    }

    /**
     * 获取每个节点并对它们进行处理
     *
     * @param consumer 对每个节点的处理方式
     */
    @Override
    public void findNodes(Consumer<NodeInfo> consumer) {
        findNodes(objectNode -> {
            consumer.accept(objectNode);
            // 始终返回 false,遍历完所有节点
            return false;
        });
    }

    private void findNodes(Predicate<NodeInfo> predicate) {
        findNodes(this.nodes, predicate, (supplier, nodes) -> supplier.getAsBoolean());
    }

    /**
     * 查找某个节点所在作用域中的所有节点
     *
     * @param nodeId 节点 ID,用于确定作用域
     * @return 目标作用域中的所有节点
     */
    @SuppressWarnings("unchecked")
    public List<NodeInfo> findScopeNodes(String nodeId) {
        // 遍历节点时,记录当前作用域中的节点。先进后出,使用栈。
        Deque<List<? extends NodeInfo>> stack = new ArrayDeque<>();
        // 最外层也是一个作用域
        stack.push(this.nodes);
        findNodes(this.nodes, node -> Objects.equals(nodeId, node.getNodeId()), (supplier, nodes) -> {
            // 到达一个新的作用域时,添加域中所有节点到栈中
            stack.push(nodes);
            // 如果在当前作用域中找到目标节点,立即返回
            if (supplier.getAsBoolean()) {
                return true;
            }
            // 否则弹出当前作用域中的节点
            stack.pop();
            // 返回 false,继续遍历节点
            return false;
        });
        // 如果找到目标节点 ID 所在的作用域,那么这个作用域中的所有节点就是栈顶元素
        return Optional.of(stack.pop()).map(i -> (List<NodeInfo>) i).orElse(Collections.emptyList());
    }

    /**
     * @see NodeFindSupport#findNode(NodeInfo, Predicate, BiPredicate)
     */
    private boolean findNodes(List<? extends NodeInfo> nodeList,
                              Predicate<NodeInfo> predicate,
                              BiPredicate<BooleanSupplier, List<NodeInfo>> biPredicate) {
        for (NodeInfo node : nodeList) {
            if (findNode(node, predicate, biPredicate)) {
                return true;
            }
        }
        return false;
    }

    /**
     * 处理每个节点的底层实现
     *
     * @param node                          遍历的每个节点
     * @param eachNodePredicate             对每个节点的处理与判断,返回 true 时,结束遍历
     * @param virtualContainerNodePredicate 对虚拟容器节点的处理与判断。接收两个参数,第一个参数表示对容器内节点的处理结果,
     *                                      如果返回 true,结束对容器内节点的处理;第二个参数表示容器中的所有节点。整个
     *                                      谓词返回 true 时,结束遍历
     * @return 是否找到目标节点
     */
    private boolean findNode(NodeInfo node,
                             Predicate<NodeInfo> eachNodePredicate,
                             BiPredicate<BooleanSupplier, List<NodeInfo>> virtualContainerNodePredicate) {
        if (node == null) {
            return false;
        }
        // 忽略虚拟容器节点
        if (!NodeType.VIRTUAL_CONTAINER.equals(node.getNodeType()) && eachNodePredicate.test(node)) {
            return true;
        }
        switch (node) {
            case VirtualContainerNode virtual -> {
                List<NodeInfo> nodeList = virtual.getNodes();
                // 不仅要记录容器内的节点信息,如果容器中存在目标节点,还要立即返回
                if (virtualContainerNodePredicate.test(() -> findNodes(nodeList, eachNodePredicate, virtualContainerNodePredicate), nodeList)) {
                    return true;
                }
            }
            case ContainerNode container -> {
                if (findNodes(container.getNodes(), eachNodePredicate, virtualContainerNodePredicate)) {
                    return true;
                }
            }
            // 其他类型的节点,不做处理
            default -> {
            }
        }
        return false;
    }
}

编写单元测试验证一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
public void testNodeFindSupport() {
    NodeFindSupport support = NodeFindSupport.from(buildNodes());
    AtomicInteger count = new AtomicInteger(0);
    // 统计所有节点数量
    support.findNodes(node -> count.incrementAndGet());
    assertThat(count).hasValue(9);

    // 统计某个范围的节点数量
    count.set(0);
    List<NodeInfo> nodes = support.findScopeNodes("B2");
    assertThat(nodes).hasSize(3)
            .extracting(NodeInfo::getNodeId)
            .containsExactly("A2", "B2", "C1");
}

6. 总结思考

本文的篇幅并不长,核心内容也很简单,即:使用 Lambda 表达式将简单的 DFS 重构为回溯。

Consumer 修改成 Predicate 很好理解,但新增的 BiPredicate 或许有些难以理解,它主要用于获取某一作用域内的所有节点,并且能够在找到目标节点后立即停止遍历。

日常开发过程中可以参考本文思路利用 Lambda 表达式实现一些重构,但其中的度要把握好,不能世间万物皆用 Lambda 表达式完成,这种重构更适用于一些底层逻辑,上层需要一次或多次的封装,不能直接将接收函数式接口的方法暴露出来,以增加第三者维护这些代码的困难度。

当然,如果就是不想让其他人来维护这些代码,那当我没说,但希望后续自己维护时能够一眼看穿那些潜藏在 Lambda 表达式中的奥秘。 🤣