[TDD磕算法] 排序矩阵中找第n个数(一)隐喻的力量

本文是[TDD磕算法] 我为什么尝试用TDD解算法题系列的一篇。

题目

在一个行列都升序排列的矩阵中找第n个最小的数。

所谓行列都升序,是指矩阵中的没个数字都比它下面和左面的数字小。比如对于表格:

1 2 4
4 6 8
5 7 9

第3小的数字是4
第4小的数字是4
第6小的数字是6
题目见 Leetcode 378. Kth Smallest Element in a Sorted Matrix

初步尝试

这道题目是我第一道尝试TDD的题,如果那时候已经有了后面足够多的教训,结果也许会有不同吧。开始尝试失败的方式与前一篇非常的类似。这里只简单描述一下。

1. 只有一个数字的矩阵

Given the matrix is:
    |1|
Then I will get 1 at the 1 smallest element

最小的数字永远在左上角
2. 2x2的矩阵,第2小的数字

Given the matrix is:
    |1|2|
    |3|4|
Then I will get 2 at the 2 smallest element

Given the matrix is:
    |1|3|
    |2|4|
Then I will get 2 at the 2 smallest element

代码

if (index == 1) { ... }
if (at(0,1) < at(1,0)) {
    return at(0,1);
} else {
    return at(1,0);
}

4. 3x3矩阵,第3小的数字

Given the matrix is:
    |1|2|3|
    |4|5|6|
    |7|8|9|
Then I will get 3 at the 3 smallest element
...
if (index == 1) { ... }
if (index == 2) {
    return Math.min(at(0,1), at(1,0));
}
return at(0,2);

……
n. 取到第5个数字,分支越来越复杂,仍然没有重构出合适的结构

...
Given the matrix is:
    |1|2|3|9|9|
    |4|5|9|9|9|
    |9|9|9|9|9|
    |9|9|9|9|9|
    |9|9|9|9|9|
Then I will get 5 at the 5 smallest element
...
Given the matrix is:
    |1|2|3|9|9|
    |4|9|9|9|9|
    |5|9|9|9|9|
    |9|9|9|9|9|
    |9|9|9|9|9|
Then I will get 5 at the 5 smallest element

代码长成了这样

if (index > size()) { return max(at(0,1), at(1,0)); }
if (index == 1) { return at(0,0); }
if (index == 2) { return min(at(0,1), at(1,0)); }
if (index == 3) {
    if (at(1,0) > at(0,2)) { return at(0,2); } 
    if (at(0,1) > at(2,0)) { return at(2,0); }
    return max(at(0,1), at(1,0));
} 
if (index == 4) {
    if (at(1,0) > at(0,3)) { return at(0,3); }
    if (at(0,1) > at(3,0)) { return at(3,0); }
    if (at(1,0) > at(0,2)) { return at(1,0); }
    if (at(0,1) > at(2,0)) { return at(0,1); } 
    return min(at(0,2), at(2,0), at(1,1));
}
if (at(1,0) > at(0,4)) { return at(0,4); }
if (at(1,0) > at(0,3)) { return at(1,0); }
if (at(2,0) > at(0,3)) { return at(0,3); }
if (at(1,1) < at(0,2) || at(1,1) < at(2,0)) { return at(1,1); }
return max(at(2,0), at(0,2));

如果你看过这个系列第一篇的失败例子,会发现基本上是一样的。
简而言之,单纯的从少到多穷举罗列用例并不能保证浮现出设计。
这次回顾,最惊讶的是当初竟然坚持了这么久才放弃。看来反复失败还是学乖了一点。
想看失败历程的可以点这里,欢迎指点。
http://cyber-dojo.org/review/show/91F6A9AFC1?avatar=lobster&was_tag=-1&now_tag=-1

重新思考

放下键盘,开始重新思考这个问题。
先抛开性能方面的担心,想想怎么找出一个逻辑上必然能解决的方法。

有个方法是显而易见的,把所有数字放进一个列表,排序,然后取第n个。但是这个方法明显不是正解。原因是在解决的过程中已经抛弃了题面提供的重要信息——行列都有序。所以这种解法是不可能在后续优化来达到性能目标的。

因此还是要考虑能够利用到题目给出信息的解法。
非常类似的,这次解题的过程中我也感觉到了写Test Case的困难。虽然有两个最容易看到的维度可以让测试从简单到复杂演化。这两个维度就是矩阵的大小、和n的大小。但是可以看到随着这两个数字增大,出现的分支情况越来越多。

一个更明显的迹象是,我在写用例的时候从没想清楚过应该怎么产生一个这样的矩阵。
一个简单的规则是把一长队排序的数字从左往右,逐行排满矩阵。比如:1,2,3,4,5,6,7,8,9。可以分三行排成:

1,2,3
4,5,6
7,8,9

当然也可以从上往下,逐列排成:

1,4,7
2,5,8
3,6,9

但是还有更多的情况,比如:

1,2,6
3,4,7
5,8,9

而且随着数字增多,可能的排列也越多。怎么保证测试到了所有的可能呢?

在思考如何排出有代表性的组合的过程中,脑子里浮现出了一个意象:堆柴火。

想象一下把一堆均匀的木头堆在墙角,就像柱状图一样。
与柱状图不同的是,一根柱子不会独树一帜特别的高。否则上面的“木头”因为“太重”就会滚到旁边,这样才能保证每块木头都比它上面、右边的木头轻。
所以一个这样的木材堆可以这样产生:
第一根木头放好,第二根要么堆在它的顶上,要么滚下来堆在它旁边。第三根的时候要么堆在最靠近墙角一列的顶上,要么在旁边的一列的顶部。
把矩阵的左上角想象为墙角,数字想象成木头。那么只要按照堆积的这种逻辑,从小到大逐一判断每根“木头”堆在了哪个格子,直到第n个。也就解出了问题。
至此,心中豁然开朗,再也不担心无穷无尽可能的组合。信心满满的开始编码了。

以隐喻为模型重新实现

起始的套路一样,还是从1x1的矩阵开始,再到2x2。这时因为脑子里有了“木材堆”,实现不再是茫无头绪的if了,将找下一个的任务委托给了next()方法。

public int get(int index) {
    if (index == 2) {
        Integer result;
        result = next();
        result = next();
        return result;
    }
    return at(0,0);
}

显然,next()需要维持一个状态,这个状态就是上面想象中的“堆木头”的过程,随着每一块木头放好,记录每一列木柴柱的高度,这样下一块木头的可能位置就确定了。
根据这个想法写出单元测试。

@Test
public void should_get_smallest_from_avaliable_cells() {
    //given
    doReturn(new int[]{2,1,0,0,0}).when(retriever).rowRecords();
    doReturn(5).when(retriever).at(0,2);
    doReturn(4).when(retriever).at(1,1);
    doReturn(6).when(retriever).at(2,0);
    //when
    int actual = retriever.next();
    //then
    verify(retriever).at(0,2);
    verify(retriever).at(1,1);
    verify(retriever).at(2,0);
    verify(retriever).record(1,1);
    assertEquals(4, actual);
}

上面测试假定了如下的木材堆:

0 1 2 3 4
□ 5
□ 4
□ 6

■ 表示已经堆好的数字,□ 表示在这时新的数字可能堆在的位置。那么只需判断这几个点的数字大小就知道实际“堆”在了哪里。并且更新状态。比如这次next()取到数字4后,记录的状态变为。

0 1 2 3 4
□ 5
□ 6

经过一段重构后,next的实现如下

protected Integer next() {
    int[] rowRecords = rowRecords();
    int row = 0;
    int col = rowRecords[0];
    Integer result = at(0, col);
    for (int i = 1; i < rowRecords.length; i++) {
        Integer candidate = at(i, rowRecords[i]);
        if (result > candidate) {
            result = candidate;
            row = i;
            col = rowRecords[i];
        }
        if (rowRecords[i] == 0) {
            break;
        }
    }
    record(row, col);
    return result;
}

protected void record(int row, int col) {
    rowRecords[row]=col+1;
}  

这时主线的逻辑已经完成,而且与矩阵的大小无关。后面是把next遍历的职责与矩阵分离,一些处理边界值和优化效率的部分。有兴趣的话可以在下面地址看到实现过程:
http://cyber-dojo.org/review/show/91F6A9AFC1?avatar=bee&was_tag=-1&now_tag=-1&filename=undefined

回顾

通过两次开发过程的差别,可以发现在试图通过罗列测试来演进实现失败的时候,往往最先遇到的困难不是如何设计实现,而是如何选定测试用例。
常见的感受是列举了测试发现和已有的无差别,或是列出无数个用例还感觉无法完全覆盖。这体现的是对问题的性质理解不够,何谈浮现设计呢。
不过即便是失败的尝试,也会产生一些启发。当启发足够对问题产生成型的理解后。这时的测试用例的添加会是对问题有效的深化、分解,而非简单的罗列。在这个阶段的测试驱动开发,就可以比较好的确保分析到实现的转换。

附记

在自己琢磨实现后,看了看别人是如何解决的。
发现有一种解法是使用“最大/最小堆”这种数据结构,会比自己瞎琢磨的更有效率。
此前对这个数据结构没有了解,没想到不谋而合的找到了“堆”这个意象。

你可能感兴趣的:([TDD磕算法] 排序矩阵中找第n个数(一)隐喻的力量)