关于三角矩阵的定义,可以见维基百科:en.wikipedia.org/wiki/Triang…,简单来说,就是矩阵对角线一边的值为零,另外一边非零的矩阵,比如下面的矩阵就是:
先声明,为了与程序数组保持一致,本文所有矩阵、数组下标皆由开始。
这样的矩阵与“动态规划”有什么关系呢?我们知道所有动态规划都会使用表格来暂存计算过的子问题的解。如果子问题空间由两个变量决定,即子问题的形式为,那么我们通常会使用一张二维表格来存储这些子问题。显然我们可以把这张二维表格,看作一个矩阵。如果在求解过程中,我们只使用矩阵对角线的一半,那么产生的就是一个三角矩阵了。
显然,我们可以使用“三角数组”来存储子问题的值。在“三角数组”中每行元素的数量比上一行多一,或少一。下面的JavaScript代码就是典型的创建“三角矩阵”的例子:
function createUpperTriangle(n) {
const arr = [];
for (let i = 1; i <= n; i++) {
arr.push(new Array(i));
}
return arr;
}
function createLowerTriangle(n) {
const arr = [];
for (let i = n; i > 0; i--) {
arr.push(new Array(i));
}
}
createUpperTriangle(3);
// [
// [ empty ],
// [ empty, empty ],
// [ empty, empty, empty ]
// ]
createLowerTriangle(3);
// [
// [ empty, empty, empty ],
// [ empty, empty ],
// [ empty ]
// ]
我们显然能够使用三角矩阵记录矩阵、的值,但是稍加观察我们发件,三角矩阵并不适用于矩阵、——我们最后不得不使用一个完整的数组来保存矩阵的值。
这样的结果怎么看都不算完美,毕竟有将近一半的空间都被我们浪费掉了。有没有什么办法,能让矩阵、保存到三角数组中呢?答案是肯定的。稍微观察,我们可以发现,只要将矩阵“水平翻转”,它就变成了矩阵。根据这样的观察,我们可以大胆将矩阵的元素“水平翻转”,然后存放在三角矩阵中。尽管这样做解决的数据储存的问题,但却需要格外注意:三角数组的列并不等于矩阵的列,而是等于其“水平翻转”列。比方说,假设我想访问矩阵的第三行的第一个元素,那么在代码中,我就应该写arr[2][2],而不是arr[2][0]。(假设arr是我们的三角数组,下标从开始)。
这里给出一个水平翻转的公式。假设矩阵大小为,即有行、列,其中一个元素的位于第行,第列,那么该元素“水平翻转”后的位置为:
注意我们数组的下标由开始。有趣的是,如果数组下标从开始,那么变换公式就变为:
读者可以自行验证验证,由于数组下标我们统一从开始,所以这种情况不做讨论。
当编写一个算法时,我们通常会创建两三个局部变量:i、j,来保存三角数组的行列值。我们需要使用这些这些变量,遍历整个三角数组,算出动态规划的解,另外,我们往往需要使用这些变量访问三角数组,将这些变量与其他值相比较(如输入参数、三角数组的大小等等),甚至将这些变量作为参数传入令一个子函数中。在编写程序时,我们时刻需要注意变量的值是否已经过“水平翻转”,并根据“是否翻转”,决定相应的判断条件,使用正确的操作符。这在下面的章节会进一步举例子说明。不过不管如何,这种间接性增大了编码的难度。个人建议如果面试碰到这类的题目,在有限的时间内,不要去做这个空间优化,而是先使用完整的二维数组记录子问题的值,以便快速给出一份正确的答案。
写这篇文章的动机,是《算法导论》的十五章“动态规划”中,有两道例题恰好可以使用本文的方法优化(“矩阵链乘”和“最优二叉树”)。在亲自实现的时候,如果优化了空间,使用三角矩阵储存子问题的值,那么整个实现最难的部份,倒不是弄清楚问题递归式的由来,而是变成了弄清楚有关储存列的变量的正确的值,以及相关的操作——这增加了我数个小时的Debug时间。
有趣的是刚看《算法导论》的例图(图15-5和15-10)的时候,我以为他的算法使用了三角矩阵优化了空间,再仔细研究一下书上的伪代码,发现并没有。另外,我在这里稍微抱怨一下:我觉得这个章节大可以写的更加通俗易懂。