[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

回顾

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

附记

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,538评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,800评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,329评论 0 238
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,725评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,089评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,449评论 1 212
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,758评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,448评论 0 195
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,152评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,432评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,933评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,289评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,921评论 3 234
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,023评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,781评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,477评论 2 270
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,381评论 2 263

推荐阅读更多精彩内容

  • 黑盒测试案例设计技术篇 1 概述 本章介绍黑盒测试的概念和进行黑盒测试的目的与意义,及关于等价类划分、边界值分析、...
    西边人阅读 16,471评论 0 41
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,095评论 18 139
  • 关于Mongodb的全面总结 MongoDB的内部构造《MongoDB The Definitive Guide》...
    中v中阅读 31,785评论 2 89
  • 我庆幸,天神给我安排了一段缘,在孤冷的冬夜,让我遇见,一个柔美的女子。 只因偶然,奇妙的缘分,让开学一个月还没印象...
    残殇千羽阅读 188评论 0 0
  • 易效能189期复训队员---焦亚楠 人生无非四件事:读书,见人,行路,历事。本月关键字:团圆,幸福,学习 【2月的...
    青春的阳光阅读 72评论 0 2