程序员的算法趣题2
上QQ阅读APP看书,第一时间看更新

序章 解答谜题的技巧

掌握典型的处理方式

假设我们现在要实现一个业务系统。有时客户会提供现成的计算公式,我们只要按照公式去实现就可以了,但有时又需要我们自己去思考如何计算,这时就必须考虑算法的效率问题了。

如果遇到的是未知的问题,想出一个新的算法简直难于登天。好在大多数时候,我们可以直接使用研究人员已经发现的那些高效的算法,毫不费力地进行编程。但是,要想使用那些算法,也必须提前了解各种问题的解题方法才行。

以入学考试为例,我们在备考期间做历年真题的意义就在于此。如果事先知道“有没有类似的问题”“需要花多长时间才能解决”,就能判断出某个程序是否可以实现,并估算出实现需要花费的时间。

但是,针对某个问题,如果完全没见过与之类似的问题,那么可能就很难去解决了。比如在解答谜题时,如果连下面这些基本的算法知识都不懂,解题所花的时间可能就会超乎想象。

● 排序(选择排序、冒泡排序、快速排序、归并排序等)

● 搜索(线性搜索、二分查找、深度优先搜索、广度优先搜索、双向搜索等)

● 最短路径问题[迪杰斯特拉(Dijkstra)算法、贝尔曼 - 福特(Bellman-Ford)算法等]

如果你没有听说过这些名词,最好在阅读本书之前看一下其他的算法入门书。对于学过这些算法的读者,本书的练习能够帮助你进一步学习算法。

例题 1 内存化和动态规划算法

在解答谜题时,很多处理会重复相同的计算,尤其是在解决递归问题时。将计算过一次的结果用在其他地方,可以提高程序的处理速度。

例如,我们来思考下面的问题。虽然单纯用递归处理也能解决,但如果用一些技巧,能大幅度缩短处理时间。

例题

一群人去餐厅用餐,决定分桌坐,在分桌时要避免出现 1 张桌子只有 1 个人的情况。

此时,如果只考虑基于人数的分法,不考虑谁坐在哪一桌,那么以 6 人为例,共有以下 4 种分法。

● 2 人+2 人+2 人

● 2 人+4 人

● 3 人+3 人

● 6 人

如果 1 张桌子最多能坐 10 人,那么当有 100 人需要分桌坐时,有多少种分法?

思路

如果给第 1 张桌子分配的人数已定,那么剩下的人只能被分配到剩余的桌子上。这时,桌子数要减去 1,剩下的人数则是总人数减去分配到第 1 张桌子的人数(图 0.1)。

图 0.1 第 1 张桌子和剩余的桌子

由于桌子的数量没有上限,所以只要考虑人数的分配方式即可。因此,我们来考虑接下来是分配与之前那桌相等的人数,还是分配多于之前那桌的人数。

换句话说,只要知道剩余人数和之前那桌分配的人数,就可以递归地进行搜索。这样一来,我们就能知道程序的处理可以用“剩余人数”和“之前那桌分配的人数”这两个参数来实现。

我们先根据问题描述来实现程序,请看代码清单 pre01.01 和代码清单 pre01.02。

代码清单 pre01.01pre1_1.rb

M, N = 10, 100

def check(remain, pre)
  # 没人可分配则结束
  return 0 if remain < 0
  return 1 if remain == 0

  cnt = 0
  pre.upto(M) do |i| # 分配到桌子的人数
    cnt += check(remain - i, i)
  end
  cnt
end

puts check(N, 2)

代码清单 pre01.02pre1_1.js

M = 10;
N = 100;

function check(remain, pre){
  // 没人可分配则结束
  if (remain < 0) return 0;
  if (remain == 0) return 1;
  var cnt = 0;
  for (var i = pre; i <= M; i++){ // 分配到桌子的人数
    cnt += check(remain - i, i);
  }
  return cnt;
}

console.log(check(N, 2));

答案 437 420 种

按照上面的方式虽然可以求出答案,但随着人数的增加,处理时间会呈指数级增长。这种处理方式的问题在于重复进行了相同人数的计算。我们可以试着改一下,把用过一次的值保存起来,在下次需要给同一个参数赋值时,直接返回保存的值就可以了(代码清单 pre01.03 和代码清单 pre01.04)。

代码清单 pre01.03pre1_2.rb

M, N = 10, 100

@memo = {}
def check(remain, pre)
  # 如果前面计算过,则返回前面计算后得到的值
  return @memo[[remain, pre]] if @memo[[remain, pre]]

  # 没人可分配则结束
  return 0 if remain < 0
  return 1 if remain == 0

  cnt = 0
  pre.upto(M) do |i|
    cnt += check(remain - i, i)
  end
  # 保存计算结果
  @memo[[remain, pre]] = cnt
end

puts check(N, 2)

代码清单 pre01.04pre1_2.js

M = 10;
N = 100;

var memo = {};
function check(remain, pre){
  // 如果前面计算过,则返回前面计算后得到的值
  if (memo[[remain, pre]]) return memo[[remain, pre]];

  // 没人可分配则结束
  if (remain < 0) return 0;
  if (remain == 0) return 1;

  var cnt = 0;
  for (var i = pre; i <= M; i++){
    cnt += check(remain - i, i);
  }
  // 保存计算结果
  return memo[[remain, pre]] = cnt;
}

console.log(check(N, 2));

上述例子都在处理的最后保存了计算结果,这样一来,在后面的处理中,只要在处理的开头让程序返回保存的那个值就可以了。像这样使用存储好的值,可以迅速提升处理速度。在进行递归处理时,保存执行的结果,并在之后的处理中重复使用该结果的方法叫作内存化。

另外,上面这个解题方法是用递归函数来实现的,我们也可以用循环的方式来实现。如果用两个轴来表示就餐人数和每张桌子可分配人数的最大值,并像图 0.2 那样统计分法,那么就可以通过从比较小的数(图 0.2 的左上角)开始,按顺序填入的方式来实现。

图 0.2 通过循环方式推演的示意图

也就是说,先把就餐人数是 0 的那一列赋为 1,然后从每张桌子可分配人数的最大值为 2 开始,反复计算所求单元格左边的数和上边的数之和,就能得到上面的表。

代码清单 pre01.05 和代码清单 pre01.06 是基于二重循环编写的示例代码,非常简洁。

代码清单 pre01.05pre1_3.rb

M, N = 10, 100

table = Array.new(M + 1){Array.new(N + 1){0}}

0.upto(M){|i| table[i][0] = 1}

1.upto(M) do |i|
  2.upto(N) do |j|
    if (i >= 2) && (j >= i)
      table[i][j] = table[i][j - i]
    end
    table[i][j] += table[i - 1][j] if i > 2
  end
end

puts table[M][N]

代码清单 pre01.06pre1_3.js

M = 10;
N = 100;

var table = new Array(M + 1);
for (var i = 0; i <= M; i++){
  table[i] = new Array(N + 1);
  for (var j = 0; j <= N; j++) table[i][j] = 0;
}

for (var i = 0; i <= M; i++)
  table[i][0] = 1;

for (var i = 1; i <= M; i++){
  for (var j = 2; j <= N; j++){
    if ((i >= 2) && (j >= i))
      table[i][j] = table[i][j - i];
    if (i > 2) table[i][j] += table[i - 1][j];
  }
}

console.log(table[M][N]);

这种方法叫作动态规划算法。听起来好像有点难,但如果你把它理解成就是将用过一次的计算结果存储起来,也就不难了。

本书会多次用到内存化这一实现方式。

例题 2 排列组合

我们再来看看排列组合,它们经常作为数学类解题方法被用来破解谜题。高中数学中经常出现“从个物品里取出个”这类问题。这类问题可以分为考虑排列顺序的“排列”和考虑选取方法的“组合”。

排列通常写作,通过简单的乘法就能求解。例如。一般来说,排列可以用下面的数学公式计算出来。

这部分按顺序相乘即可,用简单的处理逻辑就能实现(代码清单 pre02.01 和代码清单 pre02.02)。

代码清单 pre02.01pre2_1.rb

def nPr(n, r)
  result = 1
  n - r + 1.upto(n) do |i|
    result *= i
  end
  result
end

代码清单 pre02.02pre2_1.js

function nPr(n, r){
  var result = 1;
  for (var i = n - r + 1; i <= n; i++){
    result *= i;
  }
  return result;
}

组合个数的计算则相对复杂一些,我们可以用多种编程方式来实现。组合通常写作,可以用下面的数学公式计算出来。

如果用 Ruby 或 JavaScript 通过递归处理内存化的方式来实现,则程序如代码清单 pre02.03 和代码清单 pre02.04 所示。

代码清单 pre02.03pre2_2.rb

@memo = [1]
def factorial(n)
  return @memo[n] if @memo[n]
  @memo[n] = n * factorial(n - 1)
end

def nCr(n, r)
  factorial(n) / (factorial(r) * factorial(n - r))
end

代码清单 pre02.04pre2_2.js

var memo = [1];
function factorial(n){
  if (memo[n]) return memo[n];
  return memo[n] = n * factorial(n - 1);
}

function nCr(n, r){
  return factorial(n) / (factorial(r) * factorial(n - r));
}

但是,在使用这种方法的情况下,如果的值变大,分母和分子的值就会变大,在使用某些编程语言时就无法正确地进行计算。例如,在使用 JavaScript 的情况下,计算中途会变成对浮点型数据的计算。

因此,在编程中也经常用到下面这种递归的定义。这种方法可以使程序更加简洁,把程序的大小控制在一定范围内,所以本书中大量采用了这种定义。具体实现如 pre02.05 和代码清单 pre02.06 所示。

代码清单 pre02.05pre2_3.rb

@memo = {}
def nCr(n, r)
  return @memo[[n, r]] if @memo[[n, r]]
  return 1 if (r == 0) || (r == n)
  @memo[[n, r]] = nCr(n - 1, r - 1) + nCr(n - 1, r)
end

代码清单 pre02.06pre2_3.js

var memo = {};
function nCr(n, r){
  if (memo[[n, r]]) return memo[[n, r]];
  if ((r == 0) || (r == n)) return 1;
  return memo[[n, r]] = nCr(n - 1, r - 1) + nCr(n - 1, r);
}

但这种方法也有缺陷:由于递归层级较深,所以如果的值变大,就会导致栈空间不足。因此,有时也会使用下面这种递推公式来实现。当使用递推公式实现的时候,可以通过循环处理来实现,这样就不会消耗栈空间了。

如果处理会反复用到,我们还可以考虑像代码清单 pre02.07 和代码清单 pre02.08 这样让处理内存化。

代码清单 02.07pre2_4.rb

def nCr(n, r)
  result = 1
  1.upto(r) do |i|
    result = result * (n - i + 1) / i
  end
  result
end

代码清单 02.08pre2_4.js

function nCr(n, r){
  var result = 1;
  for (var i = 1; i <= r; i++){
    result = result * (n - i + 1) / i;
  }
  return result;
}

读者可以从下一章开始试着用本章列举的技巧来解决实际问题。另外,本书中的源代码仅用作示例,不排除有更高效的实现方式。各位不妨开动脑筋想一想。