9
其他题目

从5随机到7随机及其扩展

【题目】

给定一个等概率随机产生1~5的随机函数rand1To5如下:

          public int rand1To5() {
                  return (int) (Math.random() * 5) + 1;
          }

除此之外,不能使用任何额外的随机机制,请用rand1To5实现等概率随机产生1~7的随机函数rand1To7。

【补充题目】

给定一个以p 概率产生0,以1-p 概率产生1的随机函数rand01p如下:

          public int rand01p() {
                  // 可随意改变p
                  double p = 0.83;
                  return Math.random() < p ? 0 : 1;
          }

除此之外,不能使用任何额外的随机机制,请用rand01p实现等概率随机产生1~6的随机函数rand1To6。

【进阶题目】

给定一个等概率随机产生1~M 的随机函数rand1ToM如下:

          public int rand1ToM(int m) {
                  return (int) (Math.random() * m) + 1;
          }

除此之外,不能使用任何额外的随机机制。有两个输入参数,分别为mn ,请用rand1ToM(m)实现等概率随机产生1~n 的随机函数rand1ToN。

【难度】

原问题 尉 ★★☆☆

补充问题 尉 ★★☆☆

进阶问题 校 ★★★☆

【解答】

先解决原问题,具体步骤如下:

1.rand1To5()等概率随机产生1,2,3,4,5。

2.rand1To5()-1等概率随机产生0,1,2,3,4。

3.(rand1To5()-1)*5等概率随机产生0,5,10,15,20。

4.(rand1To5()-1)*5+(rand1To5()-1)等概率随机产生0,1,2,3,…,23,24。注意,这两个rand1To5()是指独立的两次调用,请不要化简。这是“插空儿”的过程。

5.如果步骤4产生的结果大于20,则重复进行步骤4,直到产生的结果在0~20之间。同时可以轻易知道出现21~24的概率,会平均分配到0~20上。这是“筛”过程。

6.步骤5会等概率随机产生0~20,所以步骤5的结果再进行%7操作,就会等概率的随机产生0~6。

7.步骤6的结果再加1,就会等概率地随机产生1~7。

具体请参看如下代码中的rand1To7方法。

          public int rand1To5() {
                  return (int) (Math.random() * 5) + 1;
          }

          public int rand1To7() {
                  int num = 0;
                  do {
                          num = (rand1To5() - 1) * 5 + rand1To5() - 1;
                  } while (num > 20);
                  return num % 7 + 1;
          }

然后是补充问题。虽然rand01p方法以p 的概率产生0,以1-p 的概率产生1,但是rand01p产生01和10的概率却都是p (1-p ),可以利用这一点来实现等概率随机产生0和1的函数。具体过程请参看如下代码中的rand01方法。

          public int rand01p() {
                  // 可随意改变p
                  double p = 0.83;
                  return Math.random() < p ? 0 : 1;
          }

          public int rand01() {
                  int num;
                  do {
                          num = rand01p();
                  } while (num == rand01p());
                  return num;
          }

有了等概率随机产生0和1的函数后,再按照如下步骤生成等概率随机产生1~6的函数:

1.rand01()方法可以等概率随机产生0和1。

2.rand01()*2等概率随机产生0和2。

3.rand01()*2+rand01()等概率随机产生0,1,2,3。注意,这两个rand01()是指独立的两次调用,请不要化简。这是“插空儿”过程。

步骤3已经实现了等概率随机产生0~3的函数,具体请参看如下代码中的rand0To3方法:

          public int rand0To3() {
                  return rand01() * 2 + rand01();
          }

4.rand0To3()*4+rand0To3()等概率随机产生0,1,2,…,14,15。注意,这两个rand0To3()是指独立的两次调用,请不要化简。这还是“插空儿”过程。

5.如果步骤4产生的结果大于11,则重复进行步骤4,直到产生的结果在0~11之间。那么可以知道出现12~15的概率会平均分配到0~11上。这是“筛”过程。

6.因为步骤5的结果是等概率随机产生0~11,所以用第5步的结果再进行%6操作,就会等概率随机产生0~5。

7.第6步的结果再加1,就会等概率随机产生1~6。

具体请参看如下代码中的rand1To6方法。

          public int rand1To6() {
                  int num = 0;
                  do {
                          num = rand0To3() * 4 + rand0To3();
                  } while (num > 11);
                  return num % 6 + 1;
          }

最后是进阶问题。如果读者真正理解了“插空儿”过程和“筛”过程,就可以知道,只要给定某一个区间上的等概率随机函数,就可以实现任意区间上的随机函数。所以,如果MN ,直接进入如上所述的“筛”过程;如果M <N ,先进入如上所述“插空儿”过程,直到产生比N 的范围还大的随机范围后,再进入“筛”过程。具体地说,是调用k 次rand1ToM(m),生成有k 位的M 进制数,并且产生的范围要大于或等于N 。比如随机5到随机7的问题,首先生成0~24范围的数,其实就是0~(52 -1)范围的数。在把范围扩到大于或等于N 的级别之后,如果真实生成的数大于或等于N ,就忽略,也就是“筛”过程。只留下小于或等于N 的数,那么在0~N -1上就可以做到均匀分布。具体请参看如下代码中的rand1ToN方法。

          public int rand1ToM(int m) {
                  return (int) (Math.random() * m) + 1;
          }

          public int rand1ToN(int n, int m) {
                  int[] nMSys = getMSysNum(n - 1, m);
                  int[] randNum = getRanMSysNumLessN(nMSys, m);
                  return getNumFromMSysNum(randNum, m) + 1;
          }

          // 把value转成m进制数
          public int[] getMSysNum(int value, int m) {
                  int[] res = new int[32];
                  int index = res.length - 1;
                  while (value ! = 0) {
                          res[index--] = value % m;
                          value = value / m;
                  }
                  return res;
          }

          // 等概率随机产生一个0~nMsys范围的数,只不过是用m进制表达的
          public int[] getRanMSysNumLessN(int[] nMSys, int m) {
                  int[] res = new int[nMSys.length];
                  int start = 0;
                  while (nMSys[start] == 0) {
                          start++;
                  }
                  int index = start;
                  boolean lastEqual = true;
                  while (index ! = nMSys.length) {
                          res[index] = rand1ToM(m) - 1;
                          if (lastEqual) {
                                  if (res[index] > nMSys[index]) {
                                          index = start;
                                          lastEqual = true;
                                          continue;
                                  } else {
                                          lastEqual = res[index] == nMSys[index];
                                  }
                          }
                          index++;
                  }
                  return res;
          }

          // 把m进制数转成十进制数
          public int getNumFromMSysNum(int[] mSysNum, int m) {
                  int res = 0;
                  for (int i = 0; i ! = mSysNum.length; i++) {
                          res = res * m + mSysNum[i];
                  }
                  return res;
          }

一行代码求两个数的最大公约数

【题目】

给定两个不等于0的整数MN ,求MN 的最大公约数。

【难度】

士 ★★☆☆

【解答】

一个很简单的求两个数最大公约数的算法是欧几里得在其《几何原本》中提出的欧几里得算法,又称为辗转相除法。

具体做法为:如果qr 分别是m 除以n 的商及余数,即m =nq +r ,那么mn 的最大公约数等于nr 的最大公约数。详细证明略。

具体请参看如下代码中的gcd方法。

          public int gcd(int m, int n) {
                  return n == 0 ? m : gcd(n, m % n);
          }

有关阶乘的两个问题

【题目】

给定一个非负整数N ,返回N !结果的末尾为0的数量。

例如:3! =6,结果的末尾没有0,则返回0。5! =120,结果的末尾有1个0,返回1。1000000000!,结果的末尾有249999998个0,返回249999998。

【进阶题目】

给定一个非负整数N ,如果用二进制数表达N !的结果,返回最低位的1在哪个位置上,认为最右的位置为位置0。

例如:1! =1,最低位的1在0位置上。2! =2,最低位的1在1位置上。1000000000!,最低位的1在999999987位置上。

【难度】

原问题 尉 ★★☆☆

进阶问题 校 ★★★☆

【解答】

无论是原问题还是进阶问题,通过算出真实的阶乘结果后再处理的方法无疑是不合适的,因为阶乘的结果通常很大,非常容易溢出,而且会增加计算的复杂性。

先来介绍原问题的一个普通解法。对原问题来说,N !结果的末尾有多少个0的问题可以转换为1,2,3,…,N -1,N 的序列中一共有多少个因子5。这是因为1×2×3×…×N 的过程中,因子2的数目比因子5的数目多,所以不管有多少个因子5,都有足够的因子2与其相乘得到10。所以只要找出1~N 所有的数中,一共含有多少个因子5就可以。具体参看如下代码中的zeroNum1方法。

          public int zeroNum1(int num) {
                  if (num < 0) {
                          return 0;
                  }
                  int res = 0;
                  int cur = 0;
                  for (int i = 5; i < num + 1; i = i + 5) {
                          cur = i;
                          while (cur % 5 == 0) {
                                  res++;
                                  cur /= 5;
                          }
                  }
                  return res;
          }

以上方法的效率并不高,对每一个数i 来说,处理的代价是logi (以5为底),一共有O (N )个数。所以时间复杂度为O (N logN )。

现在介绍原问题的最优解。我们把1~N 的数列出来。1,2,3,4,5,6,7,8,9,10…,15…,20…,25…,30…,35…,40…,45…,50…,75…,100…,125…

读者观察一下上面的数就会发现:

若每5个含有0个因子5的数(1,2,3,4,5)组成一组,这一组中的第5个数就含有51 的因子(5)。若每5个含有1个因子5的数(5,10,15,20,25)组成一组,这一组中的第5个数就含有52 的因子(25)。若每5个含有2个因子5的数(25,50,75,100,125)组成一组,这一组中的第5个数就含有53 的因子(125)。若每5个含有i 个因子5的数组成一组,这一组中的第5个数就含有5i+ 1 的因子……

所以,如果把N !的结果中因子5的总个数记为Z ,就可以得到如下关系:

Z =N /5+N /(52 )+N /(53 )+...+N /(5i )(i 一直增长,直到5i >N )。

用上文的例子来理解就是,1~N 中有N /5个数,这每个数都能贡献一个5;然后1~N 中有N /(52 )个数,这每个数又都能贡献一个5……。具体请参看如下代码中的zeroNum2方法:

          public int zeroNum2(int num) {
                  if (num < 0) {
                          return 0;
                  }
                  int res = 0;
                  while (num ! = 0) {
                          res += num / 5;
                          num /= 5;
                  }
                  return res;
          }

可以看到,如果一共有N 个数,最优解的时间复杂度为O (logN ),以5为底。

进阶问题。本书提供两种方法,先来介绍解法一。与原问题的解法类似,最低位的1在哪个位置上,完全取决于1~N 的数中因子2有多少个,因为只要出现一个因子2,最低位的1就会向左位移一位。所以,如果把N !的结果中因子2的总个数记为Z ,我们就可以得到如下关系Z = N /2 + N /4 + N /8 + … + N /(2i ) (i 一直增长,直到2i >N )。具体请参看如下代码中的rightOne1方法。

          public int rightOne1(int num) {
                  if (num < 1) {
                          return -1;
                  }
                  int res = 0;
                  while (num ! = 0) {
                          num >>>= 1;
                          res += num;
                  }
                  return res;
          }

再来介绍解法二。如果把N !的结果中因子2的总个数记为Z ,把N 的二进制数表达式中1的个数记为m ,还存在如下一个关系Z = N - m ,也就是可以证明N /2 + N /4 + N /8 + …=N -m 。注意,这里的/不是数学上的除法,而是计算科学中的除法,即结果要向下取整。首先,如果一个整数K 正好为2的某次方(K =2i ),那么求和公式K /2+K /4+K /8+…=K /2+K /4+K /8+…+1,也就是在K =2i 时,计算科学中的除法和数学上的除法等效。所以根据等比数列求和公式S =(末项×公比-首项)/(公比-1),可以得到K /2+K /4+K /8+…=K -1。

如果在N 的二进制表达中有m 个1,那么N 可以表达为:N =K 1+K 2+K 3+…+Km ,其中的所有K 都等于2的某次方,例如,N =10110时,N =10000+100+10。于是有N /2+N /4+…=(K 1+K 2+K 3+…+Km )/2+(K 1+K 2+K 3+…+Km )/4+…=K 1/2+K 1/4+K 1/8+…+1+K 2/2+K 2/4+…+1+…+K m/2+K m/4+…+1。

K 1,K 2,…,Km 都等于2的某次方。所以等式右边=K 1-1+K 2-1+K 3-1+…+Km -1=(K 1+…+Km )-m =N -m 。至此,Z =N -m 证明完毕。具体过程请参看如下代码中rightOne2方法。

          public int rightOne2(int num) {
                  if (num < 1) {
                          return -1;
                  }
                  int ones = 0;
                  int tmp = num;
                  while (tmp ! = 0) {
                          ones += (tmp & 1) ! = 0 ? 1 : 0;
                          tmp >>>= 1;
                  }
                  return num - ones;
          }

判断一个点是否在矩形内部

【题目】

在二维坐标系中,所有的值都是double类型,那么一个矩形可以由4个点来代表,(x 1,y 1)为最左的点、(x 2,y 2)为最上的点、(x 3,y 3)为最下的点、(x 4,y 4)为最右的点。给定4个点代表的矩形,再给定一个点(xy ),判断(xy )是否在矩形中。

【难度】

尉 ★★☆☆

【解答】

本题的解法有很多种,本书提供的方法先解决如果矩形的边不是平行于x 轴就是平行于y 轴的情况下,该如何判断点(xy )是否在其中,具体请参看如下代码中的isInside方法。

          public boolean isInside(double x1, double y1, double x4, double y4,
                          double x, double y) {
                  if (x <= x1) {
                          return false;
                  }
                  if (x >= x4) {
                          return false;
                  }
                  if (y >= y1) {
                          return false;
                  }
                  if (y <= y4) {
                          return false;
                  }
                  return true;
          }

这种情况是比较简单的,因为矩形的边不是平行于x 轴就是平行于y 轴,所以判断该点是否完全在矩形的左侧、右侧、上侧或下侧,如果都不是,就一定在其中。如果矩形的边不平行于坐标轴呢?也非常简单,就是高中数学的知识,通过坐标变换把矩阵转成平行的情况,在旋转时所有的点跟着转动就可以。旋转完成后,再用上面的方式进行判断。具体请参看如下代码中的isInside方法。

          public boolean isInside(double x1, double y1, double x2, double y2,
                          double x3, double y3, double x4, double y4, double x, double y)
          {
                  if (y1 == y2) {
                          return isInside(x1, y1, x4, y4, x, y);
                  }
                  double l = Math.abs(y4 - y3);
                  double k = Math.abs(x4 - x3);
                  double s = Math.sqrt(k * k + l * l);
                  double sin = l / s;
                  double cos = k / s;
                  double x1R = cos * x1 + sin * y1;
                  double y1R = -x1 * sin + y1 * cos;
                  double x4R = cos * x4 + sin * y4;
                  double y4R = -x4 * sin + y4 * cos;
                  double xR = cos * x + sin * y;
                  double yR = -x * sin + y * cos;
                  return isInside(x1R, y1R, x4R, y4R, xR, yR);
          }

判断一个点是否在三角形内部

【题目】

在二维坐标系中,所有的值都是double类型,那么一个三角形可以由3个点来代表,给定3个点代表的三角形,再给定一个点(xy ),判断(xy )是否在三角形中。

【难度】

尉 ★★☆☆

【解答】

本书提供两种解法,第一种解法是从面积的角度来解决这道题,第二种解法是从向量的角度来解决。解法一在逻辑上没有问题,但是没有解法二好,下面会给出详细的解释。

先来介绍解法一,如果点O 在三角形ABC 内部,如图9-1所示,那么,有面积ABC =面积ABO +面积BCO +面积CAO 。如果点O 在三角形ABC 外部,如图9-2所示,那么,有面积ABC <面积ABO +面积BCO +面积CAO 。既然得知了这样一种评判标准,实现代码就变得很简单了。首先实现求两个点(x 1,y 1)和(x 2,y 2)之间距离的函数,具体请参看如下代码中的getSideLength方法。

image

图9-1

image

图9-2

          public double getSideLength(double x1, double y1, double x2, double y2) {
                  double a = Math.abs(x1 - x2);
                  double b = Math.abs(y1 - y2);
                  return Math.sqrt(a * a + b * b);
          }

有了如上函数后,就可以求出一条边的边长。下面根据边长来求三角形的面积,用海伦公式来求解三角形面积是非常合适的,具体请参看如下代码中的getArea方法。

          public double getArea(double x1, double y1, double x2, double y2,
                          double x3, double y3) {
                  double side1Len = getSideLength(x1, y1, x2, y2);
                  double side2Len = getSideLength(x1, y1, x3, y3);
                  double side3Len = getSideLength(x2, y2, x3, y3);
                  double p = (side1Len + side2Len + side3Len) / 2;
                  return Math.sqrt((p - side1Len) * (p - side2Len) * (p - side3Len) * p);
          }

最后就可以根据我们的标准来求解,具体请参看如下代码中的isInside1方法。

          public boolean isInside1(double x1, double y1, double x2, double y2,
                          double x3, double y3, double x, double y) {
                  double area1 = getArea(x1, y1, x2, y2, x, y);
                  double area2 = getArea(x1, y1, x3, y3, x, y);
                  double area3 = getArea(x2, y2, x3, y3, x, y);
                  double allArea = getArea(x1, y1, x2, y2, x3, y3);
                  return area1 + area2 + area3 <= allArea;
          }

虽然解法一的逻辑是正确的,但double类型的值在计算时会出现一定程度的偏差。所以经常会发生明明O 点在三角形内,但是面积却对不准的情况出现,最后导致判断出错。所以解法一并不推荐。

解法二使用了和解法一完全不同的标准,而且几乎不会受精度损耗的影响。如果点O 在三角形ABC 内部,除面积上的关系外,还有其他关系存在,如图9-3所示。

image

图9-3

如果点O 在三角形ABC 中,那么如果从三角形的一点出发,逆时针走过所有边的过程中,点O 始终都在走过边的左侧。比如,图9-3中,O 都在ABBCCA 的左侧。如果点O 在三角形ABC 外部,则不满足这个关系。

新的标准有了,接下来解决一个棘手的问题。我们知道作为参数传入的三个点的坐标代表一个三角形,可是这三个点依次的顺序不一定是逆时针的。比如,如果参数的顺序为A 坐标、B 坐标和C 坐标,那就没问题,因为这是逆时针的。但如果参数的顺序为C 坐标、B 坐标和A 坐标,就有问题,因为这是顺时针的。作为程序的实现者,要求用户按你规定的顺序传入三角形的三个点坐标,这明显是不合适的。所以需要自己来解决这个问题。假设得到的坐标依次为点1、点2、点3。顺序可能是顺时针,也可能是逆时针,如图9-4所示。

image

图9-4

如果点2在1->3边的右边,此时按照点1、点2和点3的顺序没有问题,这个顺序本来就是逆时针的。但如果如图9-5所示,如果点2在1->3边的左边,那么按照点1、点2和点3的顺序就有问题,因为这个顺序是顺时针的,所以应该按照点1、点3和点2的顺序。

image

图9-5

如何判断一个点在一条有向边的左边还是右边?这个利用几何上向量积(叉积)的求解公式即可。如果有向边1->2叉乘有向边1->3的结果为正,说明2在有向边1->3的左边,比如图9-4;如果有向边1->2叉乘有向边1->3的结果为负,说明2在有向边1->3的右边,比如图9-5。

具体过程请参看如下代码中的crossProduct方法,该方法描述了向量(x 1,y 1)叉乘向量(x 2,y 2),两个向量的开始点都是原点。

          public double crossProduct(double x1, double y1, double x2, double y2) {
                  return x1 * y2 - x2 * y1;
          }

至此,我们已经解释了解法二的所有细节,全部过程请参看如下代码中的isInside2方法。

          public boolean isInside2(double x1, double y1, double x2, double y2,
                          double x3, double y3, double x, double y) {
                  // 如果三角形的点不是逆时针输入,改变一下顺序
                  if (crossProduct(x3 - x1, y3 - y1, x2 - x1, y2 - y1) >= 0) {
                          double tmpx = x2;
                          double tmpy = y2;
                          x2 = x3;
                          y2 = y3;
                          x3 = tmpx;
                          y3 = tmpy;
                  }
                  if (crossProduct(x2 - x1, y2 - y1, x - x1, y - y1) < 0) {
                          return false;
                  }
                  if (crossProduct(x3 - x2, y3 - y2, x - x2, y - y2) < 0) {
                          return false;
                  }
                  if (crossProduct(x1 - x3, y1 - y3, x - x3, y - y3) < 0) {
                          return false;
                  }
                  return true;
          }

折纸问题

【题目】

请把一段纸条竖着放在桌子上,然后从纸条的下边向上方对折1次,压出折痕后展开。此时折痕是凹下去的,即折痕突起的方向指向纸条的背面。如果从纸条的下边向上方连续对折2次,压出折痕后展开,此时有三条折痕,从上到下依次是下折痕、下折痕和上折痕。给定一个输入参数N ,代表纸条都从下边向上方连续对折N 次,请从上到下打印所有折痕的方向。

例如:N =1时,打印:

down

N =2时,打印:

down

down

up

【难度】

尉 ★★☆☆

【解答】

对折第1次产生的折痕:         下

对折第2次产生的折痕:     上     下

对折第3次产生的折痕:  上   下   上  下

对折第4次产生的折痕: 上 下 上 下 上 下 上 下

如上图关系可以总结出:

● 产生第i +1次折痕的过程,就是在对折i 次产生的每一条折痕的左右两侧,依次插入上折痕和下折痕的过程。

● 所有折痕的结构是一棵满二叉树,在这棵满二叉树中,头节点为下折痕,每一棵左子树的头节点为上折痕,每一棵右子树的头节点为下折痕。

● 从上到下打印所有折痕方向的过程,就是二叉树的先右、再中、最后左的中序遍历。

具体过程请参看如下代码中的printAllFolds方法。

          public void printAllFolds(int N) {
                  printProcess(1, N, true);
          }
          public void printProcess(int i, int N, boolean down) {
                  if (i > N) {
                          return;
                  }
                  printProcess(i + 1, N, true);
                  System.out.println(down ? "down " : "up ");
                  printProcess(i + 1, N, false);
          }

纸条连续对折n 次之后一定产生2n- 1 条折痕,所以要打印所有的节点,不管用什么方法,其时间复杂度肯定都是O (2n ),因为解的空间本身就有这么大,但是本书提供的方法的额外空间复杂度为O (n ),也就是这棵满二叉树的高度,额外空间主要用来维持递归函数的运行,也就是函数栈的大小。

蓄水池算法

【题目】

有一个机器按自然数序列的方式吐出球(1号球,2号球,3号球,……),你有一个袋子,袋子最多只能装下K 个球,并且除袋子以外,你没有更多的空间。设计一种选择方式,使得当机器吐出第N 号球的时候(N >K ),你袋子中的球数是K 个,同时可以保证从1号球到N 号球中的每一个,被选进袋子的概率都是K /N 。举一个更具体的例子,有一个只能装下10个球的袋子,当吐出100个球时,袋子里有10个球,并且1~100号中的每一个球被选中的概率都是10/100。然后继续吐球,当吐出1000个球时,袋子里有10个球,并且1~1000号中的每一个球被选中的概率都是10/1000。继续吐球,当吐出i 个球时,袋子里有10个球,并且1~i 号中的每一个球被选中的概率都是10/i ,即吐球的同时,已经吐出的球被选中的概率也动态地变化。

【难度】

尉 ★★☆☆

【解答】

这道题的核心解法就是蓄水池算法,我们先说这个算法的过程,然后再证明。

1.处理1~k 号球时,直接放进袋子里。

2.处理第i 号球时(i >k ),以k /i 的概率决定是否将第i 号球放进袋子。如果不决定将第i 号球放进袋子,直接扔掉第i 号球。如果决定将第i 号球放进袋子,那么就从袋子里的k 个球中随机扔掉一个,然后把第i 号球放入袋子。

3.处理第i +1号球时重复步骤1或步骤2。

过程非常简单,但为什么这个过程就能保证从1号球到n 号球中的每一个,被选进袋子的概率都是k /n 呢?以下是证明过程。

假设第i 号球被选中(1≤ik ),那么在选第k +1号球之前,第i 号球留在袋子中的概率是1。

在选第k +1号球时,在什么样的情况下第i 号球会被淘汰呢?只有决定将第k +1号球放进袋子,同时在袋子中的第i 号球被随机选中并决定扔掉,这两个事件同时发生时第i 号球才会被淘汰。也就是说,第i 号球会被淘汰的概率是(k /(k +1))×(1/k )=1/(k +1),所以第i 号球留下来的概率就是1-(1/(k +1))=k /(k +1),这也是1号球到第k +1号球的过程中,第i 号球留下来的概率。

在选第k +2号球时,什么样的情况下第i 号球会被淘汰?只有决定将第k +2号球放进袋子,同时在袋子中的第i 号球被随机选中并决定扔掉,这两个事件同时发生时第i 号球才会被淘汰。也就是说,第i 号球会被淘汰的概率是(k /(k +2))×(1/k )=1/(k +2),则第i 号球留下来的概率就是1-(1/(k +2)) = (k +1)/(k +2),那么从1号球到第k +2号球的过程中,第i 号球留在袋子中的概率是k /(k +1)×(k +1)/(k +2)。

在选第k +3号球时,……。那么从1号球到第k +3号球的过程中,第i 号球留在袋子中的概率是k /(k +1)×(k +1)/(k +2)×(k +2)/(k +3)。

依此类推,在选第N 号球时,从1号球到第N 号球的全部过程中,第i 号球最终留在袋子中的概率是k /(k +1)×(k +1)/(k +2)×(k +2)/(k +3)×(k +3)/(k +4)×…×(N -1)/N =k /N

假设第i 号被选中(k <ik ),那么在选第i 号球时,第i 号球被选进袋子的概率是k /i

在选第i +1号球时,在什么样的情况下第i 号球会被淘汰?只有决定将第i +1号球放进袋子,同时在袋子中的第i 号球被随机选中决定扔掉,这两个事件同时发生时第i 号球才会被淘汰。也就是说,第i 号球会被淘汰的概率是(k /(i +1)) × (1/k ) = 1/(i +1)。那么第i 号球留下来的概率就是1 - 1/(i +1) = i /(i +1),那么从i 号球被选中到第i +1号球的过程中,第i 号球留在袋子中的概率是(k /i ) × (i /(i +1))。

在选第i +2号球时,从i 号球被选中到第i +2号球的过程中,第i 号球留在袋子中的概率是(k /i ) × (i /(i +1)) × ((i +1)/(i +2))。

依此类推,在选第N 号球时,从i 号球被选中到第N 号球的过程中,第i 号球最终留在袋子中的概率是(k /i ) × (i /(i +1)) × ((i +1)/(i +2)) ×… × (N -1)/N = k /N

综上所述,按照步骤1~3操作,当吐出球数为N 时,每一个球被选进袋子都是k /N 。具体过程请参看如下代码中的getKNumsRand方法。

          // 一个简单的随机函数,决定一件事情做还是不做
          public int rand(int max) {
                  return (int) (Math.random() * max) + 1;
          }

          public int[] getKNumsRand(int k, int max) {
                  if (max < 1 || k < 1) {
                          return null;
                  }
                  int[] res = new int[Math.min(k, max)];
                  for (int i = 0; i ! = res.length; i++) {
                          res[i] = i + 1; // 前k个数直接进袋子
                  }
                  for (int i = k + 1; i < max + 1; i++) {
                          if (rand(i) <= k) { // 决定i进不进袋子
                                  res[rand(k) - 1] = i; // i随机替掉袋子中的一个
                          }
                  }
                  return res;
          }

设计有setAll功能的哈希表

【题目】

哈希表常见的三个操作是put、get和containsKey,而且这三个操作的时间复杂度为O (1)。现在想加一个setAll功能,就是把所有记录的value都设成统一的值。请设计并实现这种有setAll功能的哈希表,并且put、get、containsKey和setAll四个操作的时间复杂度都为O (1)。

【难度】

士 ★☆☆☆

【解答】

加入一个时间戳结构,一切问题就变得非常简单了。具体步骤如下:

1.把每一个记录都加上一个时间,标记每条记录是何时建立的。

2.设置一个setAll记录也加上一个时间,标记setAll记录建立的时间。

3.查询记录时,如果某条记录的时间早于setAll记录的时间,说明setAll是最新数据,返回setAll记录的值。如果某条记录的时间晚于setAll记录的时间,说明记录的值是最新数组,返回该条记录的值。

具体请参看如下的MyHashMap类。

          public class MyValue<V> {
                  private V value;
                  private long time;

                  public MyValue(V value, long time) {
                          this.value = value;
                          this.time = time;
                  }

                  public V getValue() {
                          return this.value;
                  }

                  public long getTime() {
                          return this.time;
                  }
          }

          public class MyHashMap<K, V> {
                  private HashMap<K, MyValue<V>> baseMap;
                  private long time;
                  private MyValue<V> setAll;

                  public MyHashMap() {
                          this.baseMap = new HashMap<K, MyValue<V>>();
                          this.time = 0;
                          this.setAll = new MyValue<V>(null, -1);
                  }

                  public boolean containsKey(K key) {
                          return this.baseMap.containsKey(key);
                  }

                  public void put(K key, V value) {
                          this.baseMap.put(key, new MyValue<V>(value, this.time++));
                  }

                  public void setAll(V value) {
                          this.setAll = new MyValue<V>(value, this.time++);
                  }

                  public V get(K key) {
                      if (this.containsKey(key)) {
                          if (this.baseMap.get(key).getTime() > this.setAll.getTime()) {
                                  return this.baseMap.get(key).getValue();
                          } else {
                                  return this.setAll.getValue();
                          }
                      } else {
                          return null;
                      }
                  }
          }

最大的leftMax与rightMax之差的绝对值

【题目】

给定一个长度为NN >1)的整型数组arr,可以划分成左右两个部分,左部分为arr[0..K],右部分为arr[K+1..N-1],K 可以取值的范围是[0,N -2]。求这么多划分方案中,左部分中的最大值减去右部分最大值的绝对值中,最大是多少?

例如:[2,7,3,1,1],当左部分为[2,7],右部分为[3,1,1]时,左部分中的最大值减去右部分最大值的绝对值为4。当左部分为[2,7,3],右部分为[1,1]时,左部分中的最大值减去右部分最大值的绝对值为6。还有很多划分方案,但最终返回6。

【难度】

校 ★★★☆

【解答】

方法一:时间复杂度为O (N 2 ),额外空间复杂度为O (1)。这是最笨的方法,在数组的每个位置i 都做一次这种划分,找到arr[0..i]的最大值maxLeft,找到arr[i+1..N-1]的最大值maxRight,然后计算两个值相减的绝对值。每次划分都这样求一次,自然可以得到最大的相减的绝对值。具体请参看如下代码中的maxABS1方法。

          public int maxABS1(int[] arr) {
                  int res = Integer.MIN_VALUE;
                  int maxLeft = 0;
                  int maxRight = 0;
                  for (int i = 0; i ! = arr.length - 1; i++) {
                          maxLeft = Integer.MIN_VALUE;
                          for (int j = 0; j ! = i + 1; j++) {
                                  maxLeft = Math.max(arr[j], maxLeft);
                          }
                          maxRight = Integer.MIN_VALUE;
                          for (int j = i + 1; j ! = arr.length; j++) {
                                  maxRight = Math.max(arr[j], maxRight);
                          }
                          res = Math.max(Math.abs(maxLeft - maxRight), res);
                  }
                  return res;
          }

方法二:时间复杂度为O (N ),额外空间复杂度为O (N )。使用预处理数组的方法,先从左到右遍历一次生成lArr,lArr[i]表示arr[0..i]中的最大值。再从右到左遍历一次生成rArr,rArr[i]表示arr[i..N-1]中的最大值。最后一次遍历看哪种划分的情况下可以得到两部分最大的相减的绝对值,因为预处理数组已经保存了所有划分的max值,所以过程得到了加速。具体请参看如下代码中的maxABS2方法。

          public int maxABS2(int[] arr) {
                  int[] lArr = new int[arr.length];
                  int[] rArr = new int[arr.length];
                  lArr[0] = arr[0];
                  rArr[arr.length - 1] = arr[arr.length - 1];
                  for (int i = 1; i < arr.length; i++) {
                          lArr[i] = Math.max(lArr[i - 1], arr[i]);
                  }
                  for (int i = arr.length - 2; i > -1; i--) {
                          rArr[i] = Math.max(rArr[i + 1], arr[i]);
                  }
                  int max = 0;
                  for (int i = 0; i < arr.length - 1; i++) {
                          max = Math.max(max, Math.abs(lArr[i] - rArr[i + 1]));
                  }
                  return max;
          }

方法三:最优解,时间复杂度为O (N ),额外空间复杂度为O (1)。先求整个arr的最大值max,因为max是全局最大值,所以不管怎么划分,max要么会成为左部分的最大值,要么会成为右部分的最大值。如果max作为左部分的最大值,接下来只要让右部分的最大值尽量小就可以。右部分的最大值怎么尽量小呢?右部分只含有arr[N-1]的时候就是尽量小的时候。同理,如果max作为右部分的最大值,只要让左部分的最大值尽量小就可以,左部分只含有arr[0]的时候就是尽量小的时候。所以整个求解过程会变得异常简单。具体请参看如下代码中的maxABS3方法。

          public int maxABS3(int[] arr) {
                  int max = Integer.MIN_VALUE;
                  for (int i = 0; i < arr.length; i++) {
                          max = Math.max(arr[i], max);
                  }
                  return max - Math.min(arr[0], arr[arr.length - 1]);
          }

设计可以变更的缓存结构

【题目】

设计一种缓存结构,该结构在构造时确定大小,假设大小为K ,并有两个功能:

● set(key,value):将记录(key,value)插入该结构。

● get(key):返回key对应的value值。

【要求】

1.set和get方法的时间复杂度为O (1)。

2.某个key的set或get操作一旦发生,认为这个key的记录成了最经常使用的。

3.当缓存的大小超过K 时,移除最不经常使用的记录,即set或get最久远的。

【举例】

假设缓存结构的实例是cache,大小为3,并依次发生如下行为:

1.cache.set("A",1)。最经常使用的记录为("A",1)。

2.cache.set("B",2)。最经常使用的记录为("B",2),("A",1)变为最不经常的。

3.cache.set("C",3)。最经常使用的记录为("C",2),("A",1)还是最不经常的。

4.cache.get("A")。最经常使用的记录为("A",1),("B",2)变为最不经常的。

5.cache.set("D",4)。大小超过了3,所以移除此时最不经常使用的记录("B",2),加入记录("D",4),并且为最经常使用的记录,然后("C",2)变为最不经常使用的记录。

【难度】

尉 ★★☆☆

【解答】

这种缓存结构可以由双端队列与哈希表相结合的方式实现。首先实现一个基本的双向链表节点的结构,请参看如下代码中的Node类。

          public class Node<V> {
                  public V value;
                  public Node<V> last;
                  public Node<V> next;
                  public Node(V value) {
                          this.value = value;
                  }
          }

根据双向链表节点结构Node,实现一种双向链表结构NodeDoubleLinkedList,在该结构中优先级最低的节点是head(头),优先级最高的节点是tail(尾)。这个结构有以下三种操作:

● 当加入一个节点时,将新加入的节点放在这个链表的尾部,并将这个节点设置为新的尾部,参见如下代码中的addNode方法。

● 对这个结构中的任意节点,都可以分离出来并放到整个链表的尾部,参见如下代码中的moveNodeToTail方法。

● 移除head节点并返回这个节点,然后将head设置成老head节点的下一个,参见如下代码中的removeHead方法。

NodeDoubleLinkedList结构全部实现如下。

          public class NodeDoubleLinkedList<V> {
                  private Node<V> head;
                  private Node<V> tail;

                  public NodeDoubleLinkedList() {
                          this.head = null;
                          this.tail = null;
                  }

                  public void addNode(Node<V> newNode) {
                          if (newNode == null) {
                                  return;
                          }
                          if (this.head == null) {
                                  this.head = newNode;
                                  this.tail = newNode;
                          } else {
                                  this.tail.next = newNode;
                                  newNode.last = this.tail;
                                  this.tail = newNode;
                          }
                  }

                  public void moveNodeToTail(Node<V> node) {
                          if (this.tail == node) {
                                  return;
                          }
                          if (this.head == node) {
                                  this.head = node.next;
                                  this.head.last = null;
                          } else {
                                  node.last.next = node.next;
                                  node.next.last = node.last;
                          }
                          node.last = this.tail;
                          node.next = null;
                          this.tail.next = node;
                          this.tail = node;
                  }

                  public Node<V> removeHead() {
                          if (this.head == null) {
                                  return null;
                          }
                          Node<V> res = this.head;
                          if (this.head == this.tail) {
                                  this.head = null;
                                  this.tail = null;
                          } else {
                                  this.head = res.next;
                                  res.next = null;
                                  this.head.last = null;
                          }
                          return res;
                  }
          }

最后实现最终的缓存结构。如何把记录之间按照“访问经常度”来排序,就是上文提到的NodeDoubleLinkedList结构。一旦加入新的记录,就把该记录加到NodeDouble LinkedList的尾部(addNode)。一旦获得(get)或设置(set)一个记录的key,就将这个key对应的node在NodeDoubleLinkedList中调整到尾部(moveNodeToTail)。一旦cache满了,就删除“最不经常使用”的记录,也就是移除NodeDoubleLinkedList的当前头部(removeHead)。

为了能让每一个key都能找到在NodeDoubleLinkedList所对应的节点,同时让每一个node都能找到各自的key,我们还需要两个map分别记录key到node的映射,以及node到key的映射,就是如下MyCache结构中的keyNodeMap和nodeKeyMap。具体实现请参看如下代码中的MyCache类。

          public class MyCache<K, V> {
                  private HashMap<K, Node<V>> keyNodeMap;
                  private HashMap<Node<V>, K> nodeKeyMap;
                  private NodeDoubleLinkedList<V> nodeList;
                  private int capacity;

                  public MyCache(int capacity) {
                          if (capacity < 1) {
                              throw new RuntimeException("should be more than 0.");
                          }
                          this.keyNodeMap = new HashMap<K, Node<V>>();
                          this.nodeKeyMap = new HashMap<Node<V>, K>();
                          this.nodeList = new NodeDoubleLinkedList<V>();
                          this.capacity = capacity;
                  }

                  public V get(K key) {
                          if (this.keyNodeMap.containsKey(key)) {
                                  Node<V> res = this.keyNodeMap.get(key);
                                  this.nodeList.moveNodeToTail(res);
                                  return res.value;
                          }
                          return null;
                  }

                  public void set(K key, V value) {
                          if (this.keyNodeMap.containsKey(key)) {
                                  Node<V> node = this.keyNodeMap.get(key);
                                  node.value = value;
                                  this.nodeList.moveNodeToTail(node);
                          } else {
                                  Node<V> newNode = new Node<V>(value);
                                  this.keyNodeMap.put(key, newNode);
                                  this.nodeKeyMap.put(newNode, key);
                                  this.nodeList.addNode(newNode);
                                  if (this.keyNodeMap.size() == this.capacity + 1) {
                                          this.removeMostUnusedCache();
                                  }
                          }
                  }

                  private void removeMostUnusedCache() {
                          Node<V> removeNode = this.nodeList.removeHead();
                          K removeKey = this.nodeKeyMap.get(removeNode);
                          this.nodeKeyMap.remove(removeNode);
                          this.keyNodeMap.remove(removeKey);
                  }
          }

设计RandomPool结构

【题目】

设计一种结构,在该结构中有如下三个功能:

● insert(key):将某个key加入到该结构,做到不重复加入。

● delete(key):将原本在结构中的某个key移除。

● getRandom():等概率随机返回结构中的任何一个key。

【要求】

Insert、delete和getRandom方法的时间复杂度都是O (1)。

【难度】

尉 ★★☆☆

【解答】

这种结构假设叫Pool,具体实现如下:

1.包含两个哈希表keyIndexMap和indexKeyMap。

2.keyIndexMap用来记录key到index的对应关系。

3.indexKeyMap用来记录index到key的对应关系。

4.包含一个整数size,用来记录目前Pool的大小,初始时size为0。

5.执行insert(newKey)操作时,将(newKey,size)放入keyIndexMap,将(size,newKey)放入indexKeyMap,然后把size加1,即每次执行insert操作之后size自增。

6.执行delete(deleteKey)操作时(关键步骤),假设Pool最新加入的key记为lastKey,lastKey对应的index信息记为lastIndex。要删除的key为deleteKey,对应的index信息记为deleteIndex。那么先把lastKey的index信息换成deleteKey,即在keyIndexMap中把记录(lastKey,lastIndex)变为(lastKey,deleteIndex),并在indexKeyMap中把记录(deleteIndex,deleteKey)变为(deleteIndex,lastKey)。然后在keyIndexMap中删除记录(deleteKey,deleteIndex),并在indexKeyMap中把记录(lastIndex,lastKey)删除。最后size减1。这么做相当于把lastKey放到了deleteKey的位置上,保证记录的index还是连续的。

7.进行getRandom操作时,根据当前的size随机得到一个index,步骤6可保证index在范围[0~size-1]上,都对应着有效的key,然后把index对应的key返回即可。

具体请参看如下代码中的Pool类。

          public class Pool<K> {
                  private HashMap<K, Integer> keyIndexMap;
                  private HashMap<Integer, K> indexKeyMap;
                  private int size;

                  public Pool() {
                          this.keyIndexMap = new HashMap<K, Integer>();
                          this.indexKeyMap = new HashMap<Integer, K>();
                          this.size = 0;
                  }

                  public void insert(K key) {
                          if (! this.keyIndexMap.containsKey(key)) {
                                  this.keyIndexMap.put(key, this.size);
                                  this.indexKeyMap.put(this.size++, key);
                          }
                  }

                  public void delete(K key) {
                          if (this.keyIndexMap.containsKey(key)) {
                                  int deleteIndex = this.keyIndexMap.get(key);
                                  int lastIndex = --this.size;
                                  K lastKey = this.indexKeyMap.get(lastIndex);
                                  this.keyIndexMap.put(lastKey, deleteIndex);
                                  this.indexKeyMap.put(deleteIndex, lastKey);
                                  this.keyIndexMap.remove(key);
                                  this.indexKeyMap.remove(lastIndex);
                          }
                  }

                  public K getRandom() {
                          if (this.size == 0) {
                                  return null;
                          }
                          int randomIndex = (int) (Math.random() * this.size);
                          return this.indexKeyMap.get(randomIndex);
                  }
          }

调整[0,x )区间上的数出现的概率

【题目】

假设函数Math.random()等概率随机返回一个在[0,1)范围上的数,那么我们知道,在[0,x )区间上的数出现的概率为x (0<x ≤1)。给定一个大于0的整数k ,并且可以使用Math.random()函数,请实现一个函数依然返回在[0,1)范围上的数,但是在[0,x )区间上的数出现的概率为x k (0<x ≤1)。

【难度】

士 ★☆☆☆

【解答】

实现在区间[0,x )上的数返回的概率是x 2 ,只用调用2次Math.random(),返回最大的那个数即可。即如下代码中的randXPower2方法。

          public double randXPower2() {
                  return Math.max(Math.random(), Math.random());
          }

解释起来也很简单,如果randXPower2要想返回在[0,x )区间上的数,两次调用Math.random()的返回值都必须落在[0,x )区间上,否则会返回大于x 的数,所以概率为x 2

同理,想让区间[0,x )上的数返回的概率是x k ,只用调用k 次Math.random(),返回最大的那个数即可。具体请参看如下代码中的randXPowerK方法。

          public double randXPowerK(int k) {
                  if (k < 1) {
                          return 0;
                  }
                  double res = -1;
                  for (int i = 0; i ! = k; i++) {
                          res = Math.max(res, Math.random());
                  }
                  return res;
          }

路径数组变为统计数组

【题目】

给定一个路径数组paths,表示一张图。paths[i]==j代表城市i连向城市j,如果paths[i]==i,则表示i城市是首都,一张图里只会有一个首都且图中除首都指向自己之外不会有环。例如,paths=[9,1,4,9,0,4,8,9,0,1],代表的图如图9-6所示。

image

图9-6

由数组表示的图可以知道,城市1是首都,所以距离为0,离首都距离为1的城市只有城市9,离首都距离为2的城市有城市0、3和7,离首都距离为3的城市有城市4和8,离首都距离为4的城市有城市2、5和6。所以距离为0的城市有1座,距离为1的城市有1座,距离为2的城市有3座,距离为3的城市有2座,距离为4的城市有3座。那么统计数组为nums=[1,1,3,2,3,0,0,0,0,0],nums[i]==j代表距离为i的城市有j座。要求实现一个void类型的函数,输入一个路径数组paths,直接在原数组上调整,使之变为nums数组,即paths=[9,1,4,9,0,4,8,9,0,1]经过这个函数处理后变成[1,1,3,2,3,0,0,0,0,0]。

【要求】

如果paths长度为N ,请达到时间复杂度为O (N ),额外空间复杂度为O (1)。

【难度】

校 ★★★☆

【解答】

本题完全考查代码实现技巧,怎么在一个数组上不停地折腾且不出错是非常锻炼边界处理能力的。本书提供的解法分为两步,第一步是将paths数组转换为距离数组。以题目中的例子来说,paths=[9,1,4,9,0,4,8,9,0,1]转换为[-2,0,-4,-2,-3,-4,-4,-2,-3,-1]。转换后的paths[i]==j代表城市i距离首都的距离为j的绝对值。至于为什么距离数组中的值要设置为负数,在以下过程中会说明。转换成距离数组的过程如下:

1.从左到右遍历paths,先遍历位置0。

paths[0]==9,首先令paths[0]=-1,因为城市0指向城市9,所以跳到城市9。

跳到城市9之后,paths[9]==1,说明城市9下一步应该跳到城市1,因为城市9是由城市0跳过来的,所以先令paths[9]=0,然后跳到城市1。

跳到城市1之后,此时paths[1]==1,说明城市1是首都,停止向首都跳的过程。城市1是由城市9跳过来的,所以跳回城市9。

根据之前的设置(paths[9]==0),我们可以知道城市9下一步应该跳回城市0,在跳回之前先设置paths[9]==-1,表示城市9距离为1,然后跳回城市0。

根据之前的设置(paths[0]==-1),我们知道城市0是整个过程的发起城市,所以不需要再回跳,设置paths[0]=-2,表示城市0距离为2。

以上在跳向首都的过程中,paths数组有一个路径反指的过程,这是为了保证找到首都之后,能够完全跳回来。在跳回来的过程中,设置好这一路所跳城市的距离即可,此时paths=[-2,1,4,9,0,4,8,9,0,-1]。

2.遍历到位置1,此时paths[1]==1,说明城市1是首都,令一个单独的变量cap=1,然后不再做任何操作。

3.遍历到位置2,paths[2]==4,先令paths[2]=-1,因为城市2指向城市4,跳到城市4。

跳到城市4之后,paths[4]==0,说明城市4下一步应该跳到城市0,因为城市4是由城市2跳过来的,所以先令paths[4]=2,然后跳到城市0。

跳到城市0之后,发现paths[0]==-2,此时将距离设置为负数的作用就显现出来了,是负数标记着这是一个之前已经计算过与首都的距离的值,而不是下一跳的城市,所以向前跳的过程停止,开始跳回城市4。

跳回到城市4之后,根据之前的设置(paths[4]==2),可以知道城市4下一步应该跳回城市2。但先设置paths[4]=-3,因为城市4跳到城市0之后发现paths[0]已经等于-2,所以自己距离首都的距离应该再远一步,然后跳回城市2。

跳回到城市2之后,根据之前的设置(paths[2]==-1),我们知道城市2是整个过程的发起城市,所以不需要再回跳,设置paths[2]=-4,表示城市2距离为4,此时paths=[-2,1,-4,9,-3,4,8,9,0,-1]

4.遍历到位置3,paths[3]==9,先令paths[3]=-1,因为城市3指向城市9,跳到城市9。

跳到城市9之后,发现paths[9]==-1,说明城市9之前已经计算过与首都的距离,所以向前跳的过程停止,开始跳回城市3。

跳回到城市3之后,根据之前的设置(paths[3]==-1),知道城市3是整个过程的发起城市,所以不需要再回跳,设置paths[3]=-2(因为之前paths[9]==-1)。所以此时paths=[-2,1,-4,-2,-3,4,8,9,0,-1]

5.遍历到位置4,发现paths[4]==-3,说明之前计算过城市4的值,直接继续下一步。

6.遍历到位置5,paths[5]==4,首先令paths[5]=-1,因为城市5指向城市4,跳到城市4。

跳到城市4之后,发现paths[4]==-3,说明城市4之前已经计算过与首都的距离,所以向前跳的过程停止,跳回城市5。

跳回到城市5之后,根据之前的设置(paths[5]==-1),我们知道城市5是整个过程的发起城市,所以不需要再回跳,设置paths[5]=-4,此时paths=[-2,1,-4,-2,-3,-4,8,9,0,-1]

7.遍历到位置6,paths[6]==8,先令paths[6]=-1,因为城市6指向城市8,跳到城市8。

跳到城市8之后,发现paths[8]==0,说明城市8下一步应该跳到城市0,因为城市8是由城市6跳过来的,所以先令paths[8]=6,然后跳到城市0。

跳到城市0之后,发现paths[0]==-2,说明城市0计算过了,向前跳停止,跳回城市8。

跳回城市8之后,根据之前的设置(paths[8]==6),知道城市8下一步应该跳回城市6,依然与步骤1的情况一样,通过之前paths数组的反指找到回去的路径。先设置paths[8]=-3,然后跳回城市6。

跳回城市6之后,根据之前的设置(paths[6]==-1),我们知道城市6是整个过程的发起城市,所以不需要再回跳,设置paths[6]=-4,此时paths=[-2,1,-4,-2,-3,-4,-4,9,-3,-1]

8.遍历到位置7,paths[7]==9,先令paths[7]=-1,因为城市7指向城市9,跳到城市9。

跳到城市9之后,发现paths[9]==-1,说明城市9之前已经计算过与首都的距离,所以向前跳的过程停止,跳回城市7。

跳回到城市7之后,根据之前的设置(paths[7]==-1),我们知道城市7是整个过程的发起城市,所以不需要再回跳,设置paths[7]=-2(因为之前paths[9]==-1),此时paths=[-2,1,-4,-2,-3,-4,-4,-2,-3,-1]

9.位置8和位置9都已经是负数,所以可知之前已经计算过,所以不用调整,遍历结束。

10.根据步骤2的cap变量,可知首都是城市1,所以单独设置paths[1]=0,此时paths=[-2,0,-4,-2,-3,-4,-4,-2,-3,-1]。

paths数组转换为距离数组的详细过程请参看如下代码中的pathsToDistans方法。

          public void pathsToDistans(int[] paths) {
                  int cap = 0;
                  for (int i = 0; i ! = paths.length; i++) {
                          if (paths[i] == i) {
                                  cap = i;
                          } else if (paths[i] > -1) {
                                  int curI = paths[i];
                                  paths[i] = -1;
                                  int preI = i;
                                  while (paths[curI] ! = curI) {
                                          if (paths[curI] > -1) {
                                                  int nextI = paths[curI];
                                                  paths[curI] = preI;
                                                  preI = curI;
                                                  curI = nextI;
                                          } else {
                                                  break;
                                          }
                                  }
                                  int value = paths[curI] == curI ? 0 : paths[curI];
                                  while (paths[preI] ! = -1) {
                                          int lastPreI = paths[preI];
                                          paths[preI] = --value;
                                          curI = preI;
                                          preI = lastPreI;
                                  }
                                  paths[preI] = --value;
                          }
                  }
                  paths[cap] = 0;
          }

paths变成了距离数组,数组中的距离值都用负数表示,接下来进行第二步,将paths转换为我们最终想要的统计数组的过程,即paths=[-2,0,-4,-2,-3,-4,-4,-2,-3,-1]需要变为[1,1,3,2,3,0,0,0,0,0]。转换过程如下:

1.从左到右遍历paths,遍历到位置0,paths[0]==-2,说明距离为2的城市发现了1座。先把paths[0]设置为0,表示paths[0]的值已经不表示城市0与首都的距离,表示以后可以用来统计距离为0的城市数量。

因为距离为2的城市发现了1座,所以应该设置paths[2]=1,说明此时paths[2]开始表示距离2的城市数量,而不再是城市2与首都的距离。

但在设置paths[2]时发现paths[2]==-4,说明paths[2]在改变它的意义之前,还代表城市2与首都的距离为4,所以先设置paths[2]=1,然后设置paths[4]的值,因为距离4的城市又发现了1座。

但在设置paths[4]时发现paths[4]==-3,依然说明paths[4]在改变它的意义之前,还代表城市4与首都的距离为3,所以先设置paths[4]=1,然后设置paths[3]的值,因为距离3的城市又发现了1座。

但在设置paths[3]时发现paths[3]==-2,依然说明paths[3]在改变它的意义之前,还代表城市3与首都的距离为2,所以先设置paths[3]=1,然后设置paths[2]的值,因为距离2的城市又发现了1座。

此时paths={0,0,1,1,1,-4,-4,-2,-3,-1},所以在设置paths[2]时发现paths[2]==1,值已经为正数,说明paths[2]的意义已经不代表城市2与首都的距离,而完全是距离为2的城市数量统计,所以直接令paths[2]++,跳的过程停止,此时paths=[0,0,2,1,1,-4,-4,-2,-3,-1]。

2.遍历到位置1,paths[1]==0,如果是正值,可以直接忽略,因为意义已经变成城市数量统计。这里值是0,我们也忽略,因为一张图上距离为0的城市只有首都,所以等全部过程完毕后单独设置距离为0的城市数量。

3.位置2~4上值已经为正数,一律忽略。

4.遍历到位置5,paths[5]==-4,说明距离为4的城市发现了1座。先把paths[5]设置为0,表示paths[5]的值已经不表示城市5与首都的距离,表示以后可以用来统计距离为5的城市数量。此时发现paths[4]==1,说明不需要跳,直接进行paths[4]++操作,过程停止。此时paths=[0,0,2,1,2,0,-4,-2,-3,-1]。

5.遍历位置6~8,过程与步骤4基本相同,处理后paths=[0,1,3,2,3,0,0,0,0,0]。

6.单独设置paths[0]==1,因为距离为0的城市只有首都。

此时可以说明为什么生成距离数组的时候要把值都弄成负数,因为可以标记状态来让转换成统计数组的过程变得更加顺利。距离数组转换为统计数组的过程请参看如下代码中的distansToNums方法。

          public void distansToNums(int[] disArr) {
                  for (int i = 0; i ! = disArr.length; i++) {
                          int index = disArr[i];
                          if (index < 0) {
                                  disArr[i] = 0; // 重要
                                  while (true) {
                                          index = -index;
                                          if (disArr[index] > -1) {
                                                  disArr[index]++;
                                                  break;
                                          } else {
                                                  int nextIndex = disArr[index];
                                                  disArr[index] = 1;
                                                  index = nextIndex;
                                          }
                                  }
                          }
                  }
                  disArr[0] = 1;
          }

paths转成距离数组的过程中,每一个城市只经历跳出去和跳回来两个过程,距离数组转成统计数组的过程也是如此,所以时间复杂度为O (N ),整个过程没有使用额外的数据结构,只使用了有限几个变量,所以额外空间复杂度为O (1)。全部过程请参看如下代码中的pathsToNums方法,这也是主方法。

          public void pathsToNums(int[] paths) {
                  if (paths == null || paths.length == 0) {
                          return;
                  }
                  // citiesPath -> distancesArray
                  pathsToDistans(paths);
                  // distancesArray -> numArray
                  distansToNums(paths);
          }

正数数组的最小不可组成和

【题目】

给定一个正数数组arr,其中所有的值都为整数,以下是最小不可组成和的概念:

● 把arr每个子集内的所有元素加起来会出现很多值,其中最小的记为min,最大的记为max。

● 在区间[min,max]上,如果有数不可以被arr某一个子集相加得到,那么其中最小的那个数是arr的最小不可组成和。

● 在区间[min,max]上,如果所有的数都可以被arr的某一个子集相加得到,那么max+1是arr的最小不可组成和。

请写函数返回正数数组arr的最小不可组成和。

【举例】

arr=[3,2,5]。子集{2}相加产生2为min,子集{3,2,5}相加产生10为max。在区间[2,10]上,4、6和9不能被任何子集相加得到,其中4是arr的最小不可组成和。

arr=[1,2,4]。子集{1}相加产生1为min,子集{1,2,4}相加产生7为max。在区间[1,7]上,任何数都可以被子集相加得到,所以8是arr的最小不可组成和。

【进阶题目】

如果已知正数数组arr中肯定有1这个数,是否能更快地得到最小不可组成和?

【难度】

尉 ★★☆☆

【解答】

解法一为暴力递归的方法,即收集每一个子集的累加和,存到一个哈希表里,然后从min开始递增检查,看哪个正数不在哈希表中,第一个不在哈希表中的正数就是结果。具体请参见如下代码中的unformedSum1方法。

          public int unformedSum1(int[] arr) {
                  if (arr == null || arr.length == 0) {
                          return 1;
                  }
                  HashSet<Integer> set = new HashSet<Integer>();
                  process(arr, 0, 0, set); // 收集所有子集的和
                  int min = Integer.MAX_VALUE;
                  for (int i = 0; i ! = arr.length; i++) {
                          min = Math.min(min, arr[i]);
                  }
                  for (int i = min + 1; i ! = Integer.MIN_VALUE; i++) {
                          if (! set.contains(i)) {
                                  return i;
                          }
                  }
                  return 0;
          }

          public void process(int[] arr, int i, int sum, HashSet<Integer> set) {
                  if (i == arr.length) {
                          set.add(sum);
                          return;
                  }
                  process(arr, i + 1, sum, set); // 包含当前数arr[i]的情况
                  process(arr, i + 1, sum + arr[i], set); // 不包含当前数arr[i]的情况
          }

如果arr长度为N ,那么子集的个数为O (2N ),所以暴力递归方法的时间复杂度为O (2N ),收集子集和的过程中,递归函数process最多有N 层,所以额外空间复杂度为O (N )。

解法二是动态规划的方法。假设arr所有数的累加和为sum,那么arr子集的累加和必然都在[0,sum]区间上。于是生成长度为sum+1的boolean型数组dp[],dp[j]如果为true,则表示j这个累加和能够被arr的子集相加得到,如果为false,则表示不能。如果arr[0..i]这个范围上的数组成的所有子集可以累加出k,那么arr[0..i+1]这个范围上的数组成的所有子集则必然可以累加出k+arr[i+1]。具体过程请参看如下代码中的unformedSum2方法。

          public int unformedSum2(int[] arr) {
                  if (arr == null || arr.length == 0) {
                          return 1;
                  }
                  int sum = 0;
                  int min = Integer.MAX_VALUE;
                  for (int i = 0; i ! = arr.length; i++) {
                          sum += arr[i];
                          min = Math.min(min, arr[i]);
                  }
                  boolean[] dp = new boolean[sum + 1];
                  dp[0] = true;
                  for (int i = 0; i ! = arr.length; i++) {
                          for (int j = sum; j >= arr[i]; j--) {
                                  dp[j] = dp[j - arr[i]] ? true : dp[j];
                          }
                  }
                  for (int i = min; i ! = dp.length; i++) {
                          if (! dp[i]) {
                                  return i;
                          }
                  }
                  return sum + 1;
          }

更新dp[]时,从arr[0..i]的子集和状态更新到arr[0..i+1]的子集和状态的过程中,0~sum的累加和都要看是否能被加出来,所以每次更新的时间复杂度为O (sum)。子集和状态从arr[0]的范围增长到arr[0..N-1],所以更新的次数为N 。所以解法二的时间复杂度为O (N ×sum),额外空间就是dp[]的长度,即额外空间复杂度为O (N )。

进阶问题,如果正数数组arr中肯定有1这个数,求最小不可组成和的过程可以得到很好的优化,优化后可以做到时间复杂度为O (N logN ),额外空间复杂度为O (1)。具体过程为:

1.把arr排序,排序之后则必有arr[0]==1。

2.从左往右计算每个位置i 的range(0≤i <N )。range代表当计算到arr[i]时,[1,range]区间内的所有正数都可以被arr[0..i-1]的某一个子集加出来,初始时,arr[0]==1,range=0。

3.如果arr[i]>range+1,因为arr是有序的,所以arr[i]往后的数都不会出现range+1,所以直接返回range+1。如果arr[i]<=range+1,说明[1,range+arr[i]]区间上的所有正数都可以被arr[0..i]的某一个子集加出来,所以令range+=arr[i],继续计算下一个位置。

4.如果所有的位置都没有出现arr[i]>range+1的情况,直接返回range+1。

步骤1的时间复杂度为O (N logN ),步骤2~步骤4的时间复杂度为O (N )。所以整个过程的时间复杂度为O (N logN ),额外空间复杂度为O (1)。

举例说明一下,arr=[3,8,1,2],排序后为[1,2,3,8],计算开始前range=0。

计算到1时,range更新成1,表示[1,1]区间上的正数都可以被arr[0]的某个子集加出来。

计算到2时,range更新成3,表示[1,3]区间上的正数都可以被arr[0..1]某个子集加出来。

计算到3时,range更新成6,表示[1,6]区间上的正数都可以被arr[0..2]某个子集加出来。

计算到8时,第一次出现8>range+1,此时可知7这个数永无可能被得到,直接返回7。

具体过程请参看如下代码中的unformedSum3方法。

          public int unformedSum3(int[] arr) {
                  if (arr == null || arr.length == 0) {
                          return 0;
                  }
                  Arrays.sort(arr); // 把arr排序
                  int range = 0;
                  for (int i = 0; i ! = arr.length; i++) {
                          if (arr[i] > range + 1) {
                                  return range + 1;
                          } else {
                                  range += arr[i];
                          }
                  }
                  return range + 1;
          }

一种字符串和数字的对应关系

【题目】

一个char类型的数组chs,其中所有的字符都不同。

例如,chs=['A' ,'B' ,'C' ,… 'Z' ],则字符串与整数的对应关系如下:

A,B… Z,AA,AB…AZ,BA,BB...ZZ,AAA... ZZZ,AAAA...

1,2…26,27,28… 52,53,54…702,703...18278,18279...

例如,chs=['A' ,'B' ,'C' ],则字符串与整数的对应关系如下:

A,B,C,AA,AB...CC,AAA...CCC,AAAA...

1,2,3,4,5 …12,13 ... 39,40...

给定一个数组chs,实现根据对应关系完成字符串与整数相互转换的两个函数。

【难度】

校 ★★★☆

【解答】

面试者在分析本题时,往往会将字符串与数字的对应关系与K 进制数联系起来,K 指chs的长度,比如,第一个例子中chs的长度为26。最终会发现用K 进制数是不能实现的。下面就解释一下本题的对应关系与K 进制数不同的地方。

K 进制数是每一个位置上的值只能在[0,K- 1]之间取值。例如,十进制数的72,高位为7,低位为2。十进制数的72转换成三进制数的表达为“2200”,也就是72=27×2+9×2+3×0+1×0。但是本题描述的对应方式却不是这样,我们暂时把题目描述的对应方式叫作K 伪进制数,K 伪进制数是每一个位置上的值只能在[1,K ]之间取值。以chs=['A' ,'B' ,'C' ]来举例,即3伪进制数。如果把十进制数的72用这个chs的3伪进制数表示,是“BABC”,也就是72=27×2+9×1+3×2+1×3。也就是对K 进制数来讲,每个位(如:27、9、3、1)上的值是可以取0的,但如果位上的值不为0,也在[1,K -1]范围上。而对K 伪进制数来讲,每个位上的值绝对不能取0,而是必须在[1,K ]之间。所以用K 进制的思路是不能实现本题的对应关系的。

下面解释一下本书提供的解法,先看从数字如何得到字符串。还是以chs=['A' ,'B' ,'C' ]来举例,以下是十进制数的72得到表达它的字符串的过程:

1.chs的长度为3,所以这是一个3伪进制,从低位到高位依次为1,3,9,27,81…。

2.从1开始减,72减去1,剩下71;71减去3,剩下68;68减去9,剩下59;59减去27,剩下32;32减去81时,发现不够减,此时就知道想要表达十进制数的72,只需使用3伪进制的前4位,也就是27,9,3,1,而不必扩到第5位的81。换句话说,既然K 伪进制中每个位上的值都不能为0,就从低位到高位把每个位置上的值都先减去1遍,看这个数到底需要前几位。

3.步骤2剩下的数是32,同时前四位的值已经使用了1次,即72 - 32 = 40 = 27×1 + 9×1 +3×1 + 1×1 = "AAAA"。接下来看剩下的32最多可以用几个27呢?最多用1个(32/27=1),再算上之前的一个27,一共要2个27(B)。32%27的结果是5,这表示让32减去尽量多的27而剩下来的数。然后看5最多可以用几个9,一个也用不了,再算上之前的一个9,一共要1个9(A)。5%9=5,接下来看5最多可以用几个3,1个,再算上之前的一个3,一共要2个3(B)。5%3=2,最后看2最多可以用几个1,2个,算上之前的一个1,一共3个1(C)。所以结果是"BABC"。

上文所描述的K 伪进制虽然和K 进制不同,但是把十进制数转换成K 伪进制数的过程却和把十进制数转换成K 进制数的过程相似。具体说来,步骤2中是从低位到高位看一个数N 最多用几个K 伪进制的位,时间复杂度为O (logN )(以K 为底),步骤3是从高位到低位反着回去看每个位上的值最多是多少,时间复杂度也是O (logN )(以K 为底),K 为chs的长度,所以以上过程的时间复杂度为O (logN )(以chs的长度为底)。

数字到字符串的全部过程请参看如下代码中的getString方法。

          public String getString(char[] chs, int n) {
                  if (chs == null || chs.length == 0 || n < 1) {
                          return "";
                  }
                  int cur = 1;
                  int base = chs.length;
                  int len = 0;
                  while (n >= cur) {
                          len++;
                          n -= cur;
                          cur *= base;
                  }
                  char[] res = new char[len];
                  int index = 0;
                  int nCur = 0;
                  do {
                          cur /= base;
                          nCur = n / cur;
                          res[index++] = getKthCharAtChs(chs, nCur + 1);
                          n %= cur;
                  } while (index ! = res.length);
                  return String.valueOf(res);
          }

          public char getKthCharAtChs(char[] chs, int k) {
                  if (k < 1 || k > chs.length) {
                          return 0;
                  }
                  return chs[k - 1];
          }

接下来介绍如何通过字符串得到对应的数字。其实如果理解了K 伪进制数的含义,算出字符串对应的数字就十分容易了。例如,chs=['A' ,'B' ,'C' ],字符串是"ABBA",可以知道这个字符串的含义是27有1个,9有2个,3有2个,1有1个,所以对应的数字是52。具体过程请参看如下代码中的getNum方法。

          public int getNum(char[] chs, String str) {
                  if (chs == null || chs.length == 0) {
                          return 0;
                  }
                  char[] strc = str.toCharArray();
                  int base = chs.length;
                  int cur = 1;
                  int res = 0;
                  for (int i = strc.length - 1; i ! = -1; i--) {
                          res += getNthFromChar(chs, strc[i]) * cur;
                          cur *= base;
                  }
                  return res;
          }

          public int getNthFromChar(char[] chs, char ch) {
                  int res = -1;
                  for (int i = 0; i ! = chs.length; i++) {
                          if (chs[i] == ch) {
                                  res = i + 1;
                                  break;
                          }
                  }
                  return res;
          }

1到n 中1出现的次数

【题目】

给定一个整数n ,返回从1到n 的数字中1出现的个数。

例如:

n =5,1~n 为1,2,3,4,5。那么1出现了1次,所以返回1。

n =11,1~n 为1,2,3,4,5,6,7,8,9,10,11。那么1出现的次数为1(出现1次),10(出现1次),11(有两个1,所以出现了2次),所以返回4。

【难度】

校 ★★★☆

【解答】

方法一:容易理解但是复杂度较高的方法,即逐一考查1~n 的每一个数里有多少个1。具体请参看如下代码中的solution1方法。

          public int solution1(int num) {
                  if (num < 1) {
                          return 0;
                  }
                  int count = 0;
                  for (int i = 1; i ! = num + 1; i++) {
                          count += get1Nums(i);
                  }
                  return count;
          }

          public int get1Nums(int num) {
                  int res = 0;
                  while (num ! = 0) {
                          if (num % 10 == 1) {
                                  res++;
                          }
                          num /= 10;
                  }
                  return res;
          }

十进制的整数N 有logN 位(以10为底),所以考察一个整数含有多少个1的代价是O (logN ),一共需要考察N 个数,所以方法一的时间复杂度为ON logN )(以10为底)。

方法二:不再依次考察每一个数,而是分析1出现的规律。

先看n ,如果只有1位的情况,因为1~9的数中,1只出现1次,所以如果n 只有1位时,返回1。接下来以n =114为例来介绍方法二。先不看1~14之间出现了多少个1,而是先求出15~114的数之间一共出现了多少个1。15~114之间,哪些数百位上能出现1呢?毫无疑问,100~114这些数百位上才有1,所以百位上的1出现的次数为15个,即114%100+1。15~114之间,哪些数十位上有1呢?110,111,112,113,114,15,16,17,18,19。这些数的十位上才有1,一共10个。15~114之间,哪些数个位上有1呢?101,111,21,31,41,51,61,71,81,91。这些数的个位上才有1,一共10个。

所以,观察发现如下规律:

1.十位上固定是1的话,个位从0变到9都是可以的。

2.个位上固定是1的话,十位从0变到9都是可以的。

3.无非就是最高位取值跟着变化,使构成的数落在15~114区间上即可。

所以,15~114之间的数在十位和个位上的1的数量=10+10=20=1×2×10,即(最高位的数字)×(除去最高位后剩下的位数)×(某一位固定是1的情况下,剩下的1位数都可以从0到9自由变化,所以是10的1次方)。这样就求出了15~114之间1的个数,然后1~14的数字出现1的个数可以按照如上方式递归求解。

再举一例,n =21345。先不看1~1345之间出现了多少个1,而是先求出1346~21345的数之间一共出现了多少个1。1346~21345之间,哪些数万位上能出现1呢?毫无疑问,10000~19999这些数百位上都有1,所以百位上的1出现的次数为10000个。与上一例不同的是,上一例n 的最高位是1,而这里大于1。如果像上例那样最高位的数字等于1,那么最高位上1的数量=除去最高位后剩下的数+1。而如果像本例那样最高位的数字大于1,那么最高位上1的数量=10000=10k- 1kn 的位数,本例中k 为5)。1346~21345之间,哪些数千位上有1呢?在1346~11345范围上,千位上固定是1的话,百位、十位和个位可自由从0~9变换,103 个,在11346~21345范围上,千位上固定是1的话,百位、十位、个位可自由从0~9变换,103 个,所以有2×103 个千位上是1。哪些数百位上有1呢?在1346~11345范围上,百位上固定是1的话,千位、十位、个位可自由从0~9变换,103 个,在11346~21345范围上,百位上固定是1的话,千位、十位、个位可自由从0~9变换,103 个,所以有2×103 个百位上是1。十位和个位也是一样的情况,所以千位、百位、十位、个位是1的总数量=2×4×103 ,即(最高位的数字)×(除去最高位后剩下的位数)×(某一位固定是1的情况下,剩下的3位数都可以从0到9自由变化,所以是103 )。这样就求出了1346~21345之间1的个数,然后1~1345的数字上出现1的个数可以按照如上方式递归求解。

具体过程请参看如下代码中的solution2方法。

          public int solution2(int num) {
                  if (num < 1) {
                          return 0;
                  }
                  int len = getLenOfNum(num);
                  if (len == 1) {
                          return 1;
                  }
                  int tmp1 = powerBaseOf10(len - 1);
                  int first = num / tmp1;
                  int firstOneNum = first == 1 ? num % tmp1 + 1 : tmp1;
                  int otherOneNum = first * (len - 1) * (tmp1 / 10);
                  return firstOneNum + otherOneNum + solution2(num % tmp1);
          }

          public int getLenOfNum(int num) {
                  int len = 0;
                  while (num ! = 0) {
                          len++;
                          num /= 10;
                  }
                  return len;
          }

          public int powerBaseOf10(int base) {
                  return (int) Math.pow(10, base);
          }

仅通过分析如上代码就可以知道,n 一共有多少位,递归函数最多就会被调用多少次,即logN 次。在递归函数内部getLenOfNum方法和powerBaseOf10方法的复杂度分别为O (logN )和O (log(logN ))。求一个数的A 次方的问题在系统内部实现的复杂度为O (logA ),AN 的位数(A =logN ),所以powerBaseOf10方法的时间复杂度为O (log(logN ))。所以方法二的总时间复杂度为O (logN ×logN )。

N 个数中等概率打印M 个数

【题目】

给定一个长度为N 且没有重复元素的数组arr和一个整数n ,实现函数等概率随机打印arr中的M 个数。

【要求】

1.相同的数不要重复打印。

2.时间复杂度为O (M ),额外空间复杂度为O (1)。

3.可以改变arr数组。

【难度】

士 ★☆☆☆

【解答】

如果没有空间复杂度的限制,可以用哈希表标记一个数之前是否被打印过,就可以做到不重复打印。解法的关键点是利用要求3改变数组arr。打印过程如下:

1.在[0,N -1]中随机得到一个位置a,然后打印arr[a]。

2.把arr[a]和arr[N-1]交换。

3.在[0,N-2]中随机得到一个位置b,然后打印arr[b],因为打印过的arr[a]已被换到了N -1位置,所以这次打印不可能再次出现。

4.把arr[b]和arr[N-2]交换。

5.在[0,N -3]中随机得到一个位置c,然后打印arr[c],因为打印过的arr[a]和arr[b]已被换到了N -1位置和N -2位置,所以这次打印都不可能再出现。

6.依此类推,直到打印M 个数。

总之,就是把随机选出来的数打印出来,然后将打印的数交换到范围中的最后位置,再把范围缩小,使得被打印的数下次不可能再被选中,直到打印结束。很多有关等概率随机的面试题都是用这种和最后一个位置交换的解法,希望这种小技巧能引起读者的重视。具体过程请参看如下代码中的printRandM方法。

          public void printRandM(int[] arr, int m) {
                  if (arr == null || arr.length == 0 || m < 0) {
                          return;
                  }
                  m = Math.min(arr.length, m);
                  int count = 0;
                  int i = 0;
                  while (count < m) {
                          i = (int) (Math.random() * (arr.length - count));
                          System.out.println(arr[i]);
                          swap(arr, arr.length - count++ - 1, i);
                  }
          }

          public void swap(int[] arr, int index1, int index2) {
                  int tmp = arr[index1];
                  arr[index1] = arr[index2];
                  arr[index2] = tmp;
          }

判断一个数是否是回文数

【题目】

定义回文数的概念如下:

● 如果一个非负数左右完全对应,则该数是回文数,例如:121,22等。

● 如果一个负数的绝对值左右完全对应,也是回文数,例如:-121,-22等。

给定一个32位整数num,判断num是否是回文数。

【难度】

士 ★☆☆☆

【解答】

本题的实现方法当然有很多种,本书介绍一种仅用一个整型变量就可以实现的方法,步骤如下:

1.假设判断的数字为非负数n ,先生成变量help,开始时help=1。

2.用help不停地乘以10,直到变得与num的位数一样。例如:num等于123321时,help就是100000。num如果是131,help就是100,总之,让help与num的位数一样。

3.那么num/help的结果就是最高位的数字,num%10就是最低位的数字,比较这两个数字,不相同则直接返回false。相同则令num=(num%help)/10,即num变成除去最高位和最低位两个数字之后的值。令help/=100,即让help变得继续和新的num位数一样。

4.如果num==0,表示所有的数字都已经对应判断完,返回true,否则重复步骤3。

上述方法就是让num每次剥掉最左和最右两个数,然后逐渐完成所有对应的判断。需要注意的是,如上方法只适用于非负数的判断,如果n 为负数,则先把n 变成其绝对值,然后用上面的方法进行判断。同时还需注意,32位整数中的最小值为-2147483648,它是转不成相应的绝对值的,可这个数也很明显不是回文数。所以,如果n 为-2147483648,直接返回false。具体过程请参看如下代码中的isPalindrome方法。

          public boolean isPalindrome(int n) {
                  if (n == Integer.MIN_VALUE) {
                          return false;
                  }
                  n = Math.abs(n);
                  int help = 1;
                  while (n / help >= 10) { // 防止help溢出
                          help *= 10;
                  }
                  while (n ! = 0) {
                          if (n / help ! = n % 10) {
                                  return false;
                          }
                          n = (n % help) / 10;
                          help /= 100;
                  }
                  return true;
          }

在有序旋转数组中找到最小值

【题目】

有序数组arr可能经过一次旋转处理,也可能没有,且arr可能存在重复的数。例如,有序数组[1,2,3,4,5,6,7],可以旋转处理成[4,5,6,7,1,2,3]等。给定一个可能旋转过的有序数组arr,返回arr中的最小值。

【难度】

尉 ★★☆☆

【解答】

为了方便描述,我们把没经过旋转前,有序数组arr最左边的数,在经过旋转之后所处的位置叫作“断点”。例如,题目例子里的数组,旋转后断点在1所处的位置,也就是位置4。如果没有经过旋转处理,断点在位置0。那么只要找到断点,就找到了最小值。

本书提供的方式做到了尽可能多地利用二分查找,但是最差情况下仍无法避免ON )的时间复杂度。我们假设目前想在arr[low..high]这个范围上找到这个范围的最小值(那么初始时low==0,high==arr.length-1),以下是具体过程:

1.如果arr[low]<arr[high],说明arr[low..high]上没有旋转,断点就是arr[low],返回arr[low]即可。

2.令mid=(low+high)/2,mid即arr[low..high]中间的位置。

1)如果arr[low]>arr[mid],说明断点一定在arr[low..mid]上,则令high=mid,然后回到步骤1。

2)如果arr[mid]>arr[high],说明断点一定在arr[mid..high]上,令low=mid,然后回到步骤1。

3.如果步骤1和步骤2的逻辑都没有命中,说明什么呢?步骤1没有命中说明arr[low]>=arr[high],步骤2的1)没有命中说明arr[low]<=arr[mid],步骤2的2)没有命中说明,arr[mid]<=arr[high]。此时只有一种情况,也就是arr[low]==arr[mid]==arr[high]。面对这种情况根本无法判断断点的位置在哪里,很多书籍在面对这种情况时都选择直接遍历arr[low..high]的方法找出断点。但其实还是可以继续为二分创造条件,生成变量i,初始时令i=low,开始向右遍历arr(i++),那么会有以下三种情况:

● 情况1:遍历到某个位置时发现arr[low]>arr[i],那么arr[i]就是断点处的值,因为在arr中发现的降序必然是断点,所以直接返回arr[i]。

● 情况2:遍历到某个位置时发现arr[low]<arr[i],说明arr[i]>arr[mid],那么说明断点在arr[i..mid]上。此时又可以开始二分,令high=mid,重新回到步骤1。

● 情况3:如果i==mid都没有出现情况1和情况2,说明从arr的low位置到mid位置,值全部都一样。那么断点只可能在arr[mid..high]上,所以令low=mid,进行后续的二分过程,重新回到步骤1。

全部过程请参看如下代码中的getMin方法。

          public int getMin(int[] arr) {
                  int low = 0;
                  int high = arr.length - 1;
                  int mid = 0;
                  while (low < high) {
                          if (low == high - 1) {
                                  break;
                          }
                          if (arr[low] < arr[high]) {
                                  return arr[low];
                          }
                          mid = (low + high) / 2;
                          if (arr[low] > arr[mid]) {
                                  high = mid;
                                  continue;
                          }
                          if (arr[mid] > arr[high]) {
                                  low = mid;
                                  continue;
                          }
                          while (low < mid) {
                                  if (arr[low] == arr[mid]) {
                                          low++;
                                  } else if (arr[low] < arr[mid]) {
                                          return arr[low];
                                  } else {
                                          high = mid;
                                          break;
                                  }
                          }
                  }
                  return Math.min(arr[low], arr[high]);
          }

在有序旋转数组中找到一个数

【题目】

有序数组arr可能经过一次旋转处理,也可能没有,且arr可能存在重复的数。例如,有序数组[1,2,3,4,5,6,7],可以旋转处理成[4,5,6,7,1,2,3]等。给定一个可能旋转过的有序数组arr,再给定一个数num,返回arr中是否含有num。

【难度】

尉 ★★☆☆

【解答】

为了方便描述,我们把没经过旋转前,有序数组arr最左边的数,在经过旋转之后所处的位置叫作断点。例如,题目例子里的数组,在旋转后断点在1所处的位置,也就是位置4。如果一个数组没有经过旋转处理,断点在位置0。

本书提供的方式做到了尽可能多地利用二分查找,但是最差情况下仍无法避免O (N )的时间复杂度,以下是具体过程:

1.用low和high变量表示arr上的一个范围,每次判断num是否在arr[low..high]上,初始时,low=0,high=arr.length-1,然后进入步骤2。

2.如果low>high,直接进入步骤5,否则令变量mid=(low+high)/2,也就是二分的位置。如果arr[mid]==num,直接返回true,否则进入步骤3。

3.此时arr[mid]! =num。如果发现arr[low]、arr[mid]、arr[high]三个值不都相等,直接进入步骤4。如果发现三个值都相等,此时根本无法知道断点的位置在mid的哪一侧。例如:7(low)…7(mid)…7(high),举一个极端的例子,如果这个数组中只有一个值为num的数,其他的数都是7,那么num除了不在low、mid、high这三个位置以外,剩下的位置都是可能的。所以num也既可能在mid的左边,也可能在右边。所以进行这样的处理:

1)只要arr[low]等于arr[mid],就让low不断地向右移动(low++),如果在low移到mid的期间,都没有发现arr[low]和arr[mid]不等的情况,说明num只可能在mid的右侧,因为左侧全都扫过了,此时令low=mid+1,high不变,进入步骤2。

2)只要arr[low]等于arr[mid],就让low不断地向右移动(low++),如果期间一旦发现arr[low]和arr[mid]不等,说明在此时的arr[low(递增后的)..mid..right]上是可以判断出断点位置的,则进入步骤4。

4.此时arr[mid]! =num,并且arr[low]、arr[mid]、arr[high]三个值不都相等,那么是一定可以二分的,具体判断如下:

如果arr[low]! =arr[mid],如何判断断点位置呢?分以下两种情况。

情况一:arr[mid]>arr[low],断点一定在mid的右侧,此时arr[low..mid]上有序。

1)如果num>=arr[low]&&num<arr[mid],说明num只需要在arr[low..mid]上寻找。这是因为如果num==arr[low]&&num<arr[mid]。很显然,在arr[low..mid]上能找到num。如果num>arr[low]&&num<arr[mid],则说明断点在右侧,假设断点在mid和high之间的break位置上,那么arr[mid..break-1]上的值都大于或等于arr[mid],也都大于num,arr[break..high]上的值都小于或等于arr[low],也都小于num,所以整个mid的右侧都没有num。综上所述,num只需要在arr[low..mid]上寻找,令high=mid-1,进入步骤2。

2)若不满足条件1),说明要么num<arr[low],此时整个arr[low..mid]上都大于num。要么num>arr[mid],此时整个arr[low..mid]上都小于num。无论是哪种,num都只可能出现在mid的右侧,所以令low=mid+1,进入步骤2。

情况二:不满足情况一则断点一定在mid位置或在mid左侧,不管是哪一种,arr[mid..high]都一定是有序的。

1)如果num>arr[mid]&&num<=arr[high]与情况一的条件1)相同的分析方式,令low=mid+1,进入步骤2。

2)若不满足条件1),与情况一的条件2)相同的分析方式,令high=mid-1,进入步骤2。

如果arr[mid]! =arr[high],如何判断断点的位置呢?和arr[low]! =arr[mid]时一样的分析方式,这里不再详述。

5.如果low在high的右边(low>high),说明arr中没有num,返回false。

全部的过程请参看如下代码中的isContains方法。

          public boolean isContains(int[] arr, int num) {
                  int low = 0;
                  int high = arr.length - 1;
                  int mid = 0;
                  while (low <= high) {
                          mid = (low + high) / 2;
                          if (arr[mid] == num) {
                                  return true;
                          }
                          if (arr[low] == arr[mid] && arr[mid] == arr[high]) {
                                  while (low ! = mid && arr[low] == arr[mid]) {
                                          low++;
                                  }
                                  if (low == mid) {
                                          low = mid + 1;
                                          continue;
                                  }
                          }
                          if (arr[low] ! = arr[mid]) {
                                  if (arr[mid] > arr[low]) {
                                          if (num >= arr[low] && num < arr[mid]) {
                                                  high = mid - 1;
                                          } else {
                                                  low = mid + 1;
                                          }
                                  } else {
                                          if (num > arr[mid] && num <= arr[high]) {
                                                  low = mid + 1;
                                          } else {
                                                  high = mid - 1;
                                          }
                                  }
                          } else {
                                  if (arr[mid] < arr[high]) {
                                          if (num > arr[mid] && num <= arr[high]) {
                                                  low = mid + 1;
                                          } else {
                                                  high = mid - 1;
                                          }
                                  } else {
                                          if (num >= arr[low] && num < arr[mid]) {
                                                  high = mid - 1;
                                          } else {
                                                  low = mid + 1;
                                          }
                                  }
                          }
                  }
                  return false;
          }

数字的英文表达和中文表达

【题目】

给定一个32位整数num,写两个函数分别返回num的英文与中文表达字符串。

【举例】

num=319

英文表达字符串为:Three Hundred Nineteen

中文表达字符串为:三百一十九

num=1014

英文表达字符串为:One Thousand,Fourteen

中文表达字符串为:一千零十四

num=-2147483648

英文表达字符串为:Negative,Two Billion,One Hundred Forty Seven Million,Four Hundred Eighty Three Thousand,Six Hundred Forty Eight

中文表达字符串为:负二十一亿四千七百四十八万三千六百四十八

num=0

英文表达字符串为:Zero

中文表达字符串为:零

【难度】

校 ★★★☆

【解答】

本题的重点是考查面试者分析业务场景并实际解决问题的能力。本题实现的方式当然是多种多样的,本书提供的方法仅是作者的实现,希望读者也能写出自己的实现。

英文表达的实现。英文的表达是以三个数为单位成一组的,所以先要解决数字1~999的表达问题。首先看数字1~19的表达问题,具体过程请参看如下代码中的num1To19方法。

          public String num1To19(int num) {
                  if (num < 1 || num > 19) {
                          return "";
                  }
                  String[] names = { "One ", "Two ", "Three ", "Four ", "Five ", "Six ",
                          "Seven ", "Eight ", "Nine ", "Ten ", "Eleven ", "Twelve",
                          "Thirteen ", "Fourteen ", "Fifteen ", "Sixteen", "Sixteen",
                          "Eighteen ", "Nineteen " };
                  return names[num - 1];
          }

然后利用num1To99函数来解决数字1~99的表达问题。具体参看如下的num1To99方法。

          public String num1To99(int num) {
                  if (num < 1 || num > 99) {
                          return "";
                  }
                  if (num < 20) {
                          return num1To19(num);
                  }
                  int high = num / 10;
                  String[] tyNames = { "Twenty ", "Thirty ", "Forty ", "Fifty ",
                                  "Sixty ", "Seventy ", "Eighty ", "Ninety " };
                  return tyNames[high - 2] + num1To19(num % 10);
          }

有以上两个函数,再解决数字1~999。具体请参看如下代码中的num1To999方法。

          public String num1To999(int num) {
                  if (num < 1 || num > 999) {
                          return "";
                  }
                  if (num < 100) {
                          return num1To99(num);
                  }
                  int high = num / 100;
                  return num1To19(high) + "Hundred " + num1To99(num % 100);
          }

最后可以解决最终的问题,需要注意如下几个特殊情况:

● num为0的情况要单独处理。

● num为负的处理,对于负数,一律以处理其绝对值的方式来得到表达字符串,然后加上“Negative.”的前缀,所以num为Integer.MIN_VALUE时,也是特殊情况。

● 把32位整数分解成十亿组、百万组、千组、1~999组。对每个组的表达利用num1To999方法,再把组与组之间各自的表达字符串连接起来即可。

最后是英文表达的主方法,参见如下代码中的getNumEngExp方法。

          public String getNumEngExp(int num) {
                  if (num == 0) {
                          return "Zero";
                  }
                  String res = "";
                  if (num < 0) {
                          res = "Negative, ";
                  }
                  if (num == Integer.MIN_VALUE) {
                          res += "Two Billion, ";
                          num %= -2000000000;
                  }
                  num = Math.abs(num);
                  int high = 1000000000;
                  int highIndex = 0;
                  String[] names = { "Billion", "Million", "Thousand", "" };
                  while (num ! = 0) {
                          int cur = num / high;
                          num %= high;
                          if (cur ! = 0) {
                                  res += num1To999(cur);
                                  res += names[highIndex] + (num == 0 ? " " : ", ");
                          }
                          high /= 1000;
                          highIndex++;
                  }
                  return res;
          }

中文表达的实现。与英文表达的处理过程类似,都是由小范围的数向大范围的数扩张的过程,这个过程有非常不同的处理细节。

首先解决数字1~9的中文表达问题,具体参看如下代码中的num1To9方法

          public String num1To9(int num) {
                  if (num < 1 || num > 9) {
                          return "";
                  }
                  String[] names = { "一", "二", "三", "四", "五", "六", "七", "八", "九" };
                  return names[num - 1];
          }

利用num1To9方法,我们来看看数字1~99如何表达。其中有一个很值得注意的细节,16的表达是十六,116的表达是一百一十六,1016的表达可以是一千零十六,也可以是一千零一十六。这个细节说明,对10~19来说,如果其前一位(也就是百位)有数字,则表达该是一十~一十九。如果百位上没数字,则表达应该一律规定为十~十九。具体过程请参看如下代码中的num1To99方法,boolean型参数hasBai表示是否其前一位(百位)有数字。

          public String num1To99(int num, boolean hasBai) {
                  if (num < 1 || num > 99) {
                          return "";
                  }
                  if (num < 10) {
                          return num1To9(num);
                  }
                  int shi = num / 10;
                  if (shi == 1 && (! hasBai)) {
                          return "十" + num1To9(num % 10);
                  } else {
                          return num1To9(shi) + "十" + num1To9(num % 10);
                  }
          }

利用num1To9与num1To99方法后,接下来解决数字1~999的表达,具体过程请参看如下代码中的num1To999方法。

          public String num1To999(int num) {
                  if (num < 1 || num > 999) {
                          return "";
                  }
                  if (num < 100) {
                          return num1To99(num, false);
                  }
                  String res = num1To9(num / 100) + "百";
                  int rest = num % 100;
                  if (rest == 0) {
                          return res;
                  } else if (rest >= 10) {
                          res += num1To99(rest, true);
                  } else {
                          res += "零" + num1To9(rest);
                  }
                  return res;
          }

然后是数字1~9999的表达问题,见如下代码中的num1To9999方法。

          public String num1To9999(int num) {
                  if (num < 1 || num > 9999) {
                          return "";
                  }
                  if (num < 1000) {
                          return num1To999(num);
                  }
                  String res = num1To9(num / 1000) + "千";
                  int rest = num % 1000;
                  if (rest == 0) {
                          return res;
                  } else if (rest >= 100) {
                          res += num1To999(rest);
                  } else {
                          res += "零" + num1To99(rest, false);
                  }
                  return res;
          }

接下来是数字1~99999999的表达问题,见如下代码中的num1To99999999方法。

          public String num1To99999999(int num) {
                  if (num < 1 || num > 99999999) {
                          return "";
                  }
                  int wan = num / 10000;
                  int rest = num % 10000;
                  if (wan == 0) {
                          return num1To9999(rest);
                  }
                  String res = num1To9999(wan) + "万";
                  if (rest == 0) {
                          return res;
                  } else {
                          if (rest < 1000) {
                                  return res + "零" + num1To999(rest);
                          } else {
                                  return res + num1To9999(rest);
                          }
                  }
          }

最后是中文表达的主方法,参见如下代码中的getNumChiExp方法。

          public String getNumChiExp(int num) {
                  if (num == 0) {
                          return "零";
                  }
                  String res = num < 0 ? "负" : "";
                  int yi = Math.abs(num / 100000000);
                  int rest = Math.abs((num % 100000000));
                  if (yi == 0) {
                          return res + num1To99999999(rest);
                  }
                  res += num1To9999(yi) + "亿";
                  if (rest == 0) {
                          return res;
                  } else {
                          if (rest < 10000000) {
                                  return res + "零" + num1To99999999(rest);
                          } else {
                                  return res + num1To99999999(rest);
                          }
                  }
          }

该类型的代码面试题目实际上是相当棘手的。通常是由小的、简单的场景出发,把复杂的事情拆解成简单的场景,最终得到想要的结果。

分糖果问题

【题目】

一群孩子做游戏,现在请你根据游戏得分来发糖果,要求如下:

1.每个孩子不管得分多少,起码分到1个糖果。

2.任意两个相邻的孩子之间,得分较多的孩子必须拿多一些的糖果。

给定一个数组arr代表得分数组,请返回最少需要多少糖果。

例如:arr=[1,2,2],糖果分配为[1,2,1],即可满足要求且数量最少,所以返回4。

【进阶题目】

原题目中的两个规则不变,再加一条规则:

3.任意两个相邻的孩子之间如果得分一样,糖果数必须相同。

给定一个数组arr代表得分数组,返回最少需要多少糖果。

例如:arr=[1,2,2],糖果分配为[1,2,2],即可满足要求且数量最少,所以返回5。

【要求】

arr长度为N ,原题与进阶题都要求时间复杂度为O (N ),额外空间复杂度为O (1)。

【难度】

校 ★★★☆

【解答】

原问题。先引入爬坡和下坡的概念,从左到右依次考虑每个孩子,如果一个孩子的右邻居比他大,那么爬坡过程开始。如果一直单调递增,就一直爬坡,否则爬坡结束,下坡开始。如果一直单调递减,就一直下坡,直到遇到一个孩子的右邻居大于或等于他,则下坡结束。爬坡中的叫左坡,下坡中的叫右坡。

比如[1,2,3,2,1],左坡为[1,2,3],右坡为[3,2,1]。比如[1,2,2,1],第一个左坡为[1,2],第一个右坡为[2](只含有第一个2),第二个左坡为[2](只含有第二个2),第二个右坡为[2,1]。比如[1,2,3,1,2],第一个左坡[1,2,3],第一个右坡为[3,1],第二个左坡为[1,2],第二个右坡为[2]。

定义了爬坡过程和下坡过程之后,大家可以看到,arr数组可以被分解成很多对左坡和右坡,利用左坡和右坡来看糖果如何分。假设有一对左坡和右坡,分别为[1,4,5,9]和[9,3,2]。对左坡来说,从左到右分的糖果应该为[1,2,3,4],对右坡来说,从左到右分的糖果应该为[3,2,1]。但这两种分配方式对9这个坡顶的分配是不同的,怎么决定呢?看左坡和右坡的坡度哪个更大,坡度是指坡中除去相同的数字之后(也就是纯升序或纯降序)的序列长度。而根据我们定义的爬坡和下坡过程,左坡和右坡中都不可能有重复数字,所以坡度就是各自的序列长度。[1,2,3,4]坡度为4,[3,2,1]坡度为3。如果左坡的坡度更大,坡顶就按左坡的分配,如果右坡的坡度更大,就按右坡的分配,所以最终分配为[1,2,3,4,2,1]。

成对的左坡和右坡都按照这种处理方式,从左到右处理得分数组arr,统计总体的糖果数即可。具体过程请参看如下代码中的candy1方法。

          public int candy1(int[] arr) {
                  if (arr == null || arr.length == 0) {
                          return 0;
                  }
                  int index = nextMinIndex1(arr, 0);
                  int res = rightCands(arr, 0, index++);
                  int lbase = 1;
                  int next = 0;
                  int rcands = 0;
                  int rbase = 0;
                  while (index ! = arr.length) {
                          if (arr[index] > arr[index - 1]) {
                                  res += ++lbase;
                                  index++;
                          } else if (arr[index] < arr[index - 1]) {
                                  next = nextMinIndex1(arr, index - 1);
                                  rcands = rightCands(arr, index - 1, next++);
                                  rbase = next - index + 1;
                                  res += rcands + (rbase > lbase ? -lbase : -rbase);
                                  lbase = 1;
                                  index = next;
                          } else {
                                  res += 1;
                                  lbase = 1;
                                  index++;
                          }
                  }
                  return res;
          }

          public int nextMinIndex1(int[] arr, int start) {
                  for (int i = start; i ! = arr.length - 1; i++) {
                          if (arr[i] <= arr[i + 1]) {
                                  return i;
                          }
                  }
                  return arr.length - 1;
          }

          public int rightCands(int[] arr, int left, int right) {
                  int n = right - left + 1;
                  return n + n * (n - 1) / 2;
          }

进阶问题。针对进阶问题所加的新规则,需要对爬坡和下坡的过程进行修改。从左到右依次考虑每个孩子,如果一个孩子的右邻居大于或等于他,那么爬坡过程开始,如果一直不降序,就一直爬坡,否则爬坡结束,下坡开始。如果一直不升序,就一直下坡,直到遇到一个孩子的右邻居大于他,则下坡结束。爬坡中的叫左坡,下坡中的叫右坡。比如,[1,2,3,2,1],左坡为[1,2,3],右坡为[3,2,1]。再如,[1,2,2,1],左坡为[1,2,2],右坡为[2,1]。

依然是利用左坡和右坡来决定糖果如何分配,还是举例说明整个分配过程。比如,[0,1,2,3,3,3,2,2,2,2,2,1,1],左坡为[0,1,2,3,3,3],右坡为[3,2,2,2,2,2,1,1]。对左坡来说,从左到右分的糖果应该为[1,2,3,4,4,4],对右坡来说,从左到右分的糖果应该为[3,2,2,2,2,2,1,1]。所以左坡和右坡的分配方案对整个坡顶的分配其实是矛盾的。注意,在这种情况下,其实坡顶为3个元素,即[3,3,3]。根据新的规则,相邻的且得分相等的孩子拿的糖果数要一样。所以坡顶究竟按谁的来呢?同样是根据左坡和右坡的坡度决定,左坡[0,1,2,3,3,3]的坡度为4,右坡[3,2,2,2,2,2,1,1]的坡度为3,坡顶分的糖果数同样按照坡度大的来决定。所以总的分配方案为[1,2,3,4,4,4,2,2,2,2,2,1,1],也就是说,坡顶的所有小朋友都根据坡度大的一方决定。具体过程请参看如下代码中的candy2方法。

          public int candy2(int[] arr) {
              if (arr == null || arr.length == 0) {
                  return 0;
              }
              int index = nextMinIndex2(arr, 0);
              int[] data = rightCandsAndBase(arr, 0, index++);
              int res = data[0];
              int lbase = 1;
              int same = 1;
              int next = 0;
              while (index ! = arr.length) {
                  if (arr[index] > arr[index - 1]) {
                      res += ++lbase;
                      same = 1;
                      index++;
                  } else if (arr[index] < arr[index - 1]) {
                      next = nextMinIndex2(arr, index - 1);
                      data = rightCandsAndBase(arr, index - 1, next++);
                      if (data[1] <= lbase) {
                          res += data[0] - data[1];
                      } else {
                          res += -lbase * same + data[0] - data[1] + data[1] * same;
                      }
                      index = next;
                      lbase = 1;
                      same = 1;
                  } else {
                      res += lbase;
                      same++;
                      index++;
                  }
              }
              return res;
          }

          public int nextMinIndex2(int[] arr, int start) {
                  for (int i = start; i ! = arr.length - 1; i++) {
                          if (arr[i] < arr[i + 1]) {
                                  return i;
                          }
                  }
                  return arr.length - 1;
          }

          public int[] rightCandsAndBase(int[] arr, int left, int right) {
                  int base = 1;
                  int cands = 1;
                  for (int i = right - 1; i >= left; i--) {
                          if (arr[i] == arr[i + 1]) {
                                  cands += base;
                          } else {
                                  cands += ++base;
                          }
                  }
                  return new int[] { cands, base };
          }

一种消息接收并打印的结构设计

【题目】

消息流吐出2,一种结构接收而不打印2,因为1还没出现。

消息流吐出1,一种结构接收1,并且打印:1,2。

消息流吐出4,一种结构接收而不打印4,因为3还没出现。

消息流吐出5,一种结构接收而不打印5,因为3还没出现。

消息流吐出7,一种结构接收而不打印7,因为3还没出现。

消息流吐出3,一种结构接收3,并且打印:3,4,5。

消息流吐出9,一种结构接收而不打印9,因为6还没出现。

消息流吐出8,一种结构接收而不打印8,因为6还没出现。

消息流吐出6,一种结构接收6,并且打印:6,7,8,9。

已知一个消息流会不断地吐出整数1~N ,但不一定按照顺序吐出。如果上次打印的数为i ,那么当i +1出现时,请打印i +1及其之后接收过的并且连续的所有数,直到1~N 全部接收并打印完,请设计这种接收并打印的结构。

【要求】

消息流最终会吐出全部的1~N ,当然最终也会打印完所有的1~N ,要求接收和打印1~N 的整个过程,时间复杂度为O (N )。

【难度】

尉 ★★☆☆

【解答】

本题的设计方法有很多,本书提供一种设计实现供读者参考。结构假设叫MessageBox,先以一个与题目不同的例子来简单说明过程:

1.消息流吐出2,MessageBox接收并生成连续区间{2},此时不打印,因为1没出现。

2.消息流吐出1,MessageBox接收并生成连续区间{1},发现可以与{2}连在一起,所以连成整个连续区间{1,2}。此时1出现了,所以打印1,2,打印后删除连续区间{1,2}。

3.消息流吐出4,MessageBox接收并生成连续区间{4}。

4.消息流吐出5,MessageBox接收并生成连续区间{5},发现可以与{4}连在一起,所以连成整个连续区间{4,5}。

5.消息流吐出7,MessageBox接收并生成连续区间{7},此时MessageBox中有两个连续区间,分别为{4,5}和{7}。但3还没出现,所以不打印。

6.消息流吐出9,MessageBox接收并生成连续区间{9},此时MessageBox中有三个连续区间,分别为{4,5}、{7}和{9}。但3还没出现,所以不打印。

7.消息流吐出8,MessageBox接收并生成连续区间{8},此时发现{8}的出现可以把{7}和{9}连在一起,所以连成整个连续区间{7,8,9}。此时MessageBox中有两个连续区间,分别为{4,5}和{7,8,9}。但3还没出现,所以不打印。

8.消息流吐出6,MessageBox接收并生成连续区间{6},此时发现{6}的出现可以把{4,5}和{7,8,9}连在一起,所以连成整个连续区间{4,5,6,7,8,9}。但3还没出现,所以不打印。

9.消息流吐出3,MessageBox接收并生成连续区间{3},发现可以与{4,5,6,7,8,9}连在一起,所以连成整个连续区间{3,4,5,6,7,8,9}。此时3出现了,所以打印3,4,5,6,7,8,9。打印后删除连续区间{3,4,5,6,7,8,9},整个过程结束。

分析如上过程可以知道,如果达到整个过程,其时间复杂度为O (N ),我们需要设计好的连续区间结构,并且在一个数出现时,还要方便地将这个数上下有关的连续区间连接在一起。下面就介绍MessageBox结构的具体设计细节:

1.当接收一个数num时,先根据num生成一个单链表节点的实例,单链表结构记为Node,具体请参看如下的Node类。

          public class Node {

                  public int num;

                  public Node next;

                  public Node(int num) {
                          this.num = num;
                  }
          }

2.连续结构就是一个单链表结构,但这是不够的,为了可以快速合并,MessageBox中还有三个重要的部分:headMap、tailMap和lastPrint。headMap是一个哈希表,key为整型,表示一个连续区间开始的数,value为Node类型,表示根据key这个数生成的节点,也是连续区间的第一个节点。tailMap也是一个哈希表,key为整型,表示一个连续区间结束的数,value为Node类型,表示根据key这个数生成的节点,也是连续区间的最后一个节点。比如连续区间{4,5,6,7,8,9},假设节点值为4的节点记为start,节点值为9的节点记为end,从start到end是一条单链表,上面有节点值从4到9的所有节点,而且在headMap中还有记录(4,start),在tailMap中还有记录(9,end)。lastPrint表示上次打印的是什么数。

3.接收num之后,假设根据num生成的单链表节点实例为cur。现在的num可以自己成为一个连续区间,即在headMap中加上记录(num,cur),在tailMap中也加上记录(num,cur)。然后依次进行如下处理:

1)在tailMap中查询是否有key==num-1的记录。如果有,说明存在一个连续区间以num-1结尾,记为连续区间A ,那么A 可以和num自己的连续区间合并。假设A 最后的数num-1对应的节点为end,那么令end.next=cur,表示A 的单向链表在最后加了一个节点cur。然后在tailMap中删除记录(num-1,end),因为以num-1结尾的连续区间已经不存在,大的连续区间是以num结尾的。最后在headMap中删除记录(num,cur),因为以num开始的连续区间已经不存在,大的连续区间的头是合并前连续区间A 的头。如果没有key==num-1的记录,则什么也不用做。

2)在headMap中查询是否有key==num+1的记录。如果有,说明存在一个连续区间以num+1开始,记为连续区间B ,那么B 可以和以num结尾的连续区间合并。假设B 开始的数num+1对应的节点为start,那么令cur.next=start,表示以num结尾的连续区间的链表合和B 的链表合并。然后在headMap中删除记录(num+1,start),因为以num+1开始的连续区间已经不存在。最后在tailMap中删除记录(num,cur),因为以num结束的连续区间也已经不存在。如果没有key==num+1的记录,则什么也不用做。

整个步骤3就是做一件事情,看num上下的连续区域有没有因为自己的出现可以进行合并,能合并的全部都合并在一起。

4.加入num之后,能不能打印。如果能打印,把打印的连续区域一律删除。

如上过程中,连续区域的合并全是O (1)的时间复杂度,因为都是简单的哈希表查询操作或者是把某个节点的next指针赋值而已。整体过程的时间复杂度为O (N ),MessageBox结构的具体实现请参看如下代码中的MessageBox类。

          public class MessageBox {
                  private HashMap<Integer, Node> headMap;
                  private HashMap<Integer, Node> tailMap;
                  private int lastPrint;

                  public MessageBox() {
                          headMap = new HashMap<Integer, Node>();
                          tailMap = new HashMap<Integer, Node>();
                          lastPrint = 0;
                  }

                  public void receive(int num) {
                          if (num < 1) {
                                  return;
                          }
                          Node cur = new Node(num);
                          headMap.put(num, cur);
                          tailMap.put(num, cur);
                          if (tailMap.containsKey(num - 1)) {
                                  tailMap.get(num - 1).next = cur;
                                  tailMap.remove(num - 1);
                                  headMap.remove(num);
                          }
                          if (headMap.containsKey(num + 1)) {
                                  cur.next = headMap.get(num + 1);
                                  tailMap.remove(num);
                                  headMap.remove(num + 1);
                          }
                          if (headMap.containsKey(lastPrint + 1)) {
                                  print();
                          }
                  }

                  private void print() {
                          Node node = headMap.get(++lastPrint);
                          headMap.remove(lastPrint);
                          while (node ! = null) {
                                  System.out.print(node.num + " ");
                                  node = node.next;
                                  lastPrint++;
                          }
                          tailMap.remove(--lastPrint);
                          System.out.println();
                  }
          }

设计一个没有扩容负担的堆结构

【题目】

堆结构一般是使用固定长度的数组结构来实现的。这样的实现虽然足够经典,但存在扩容的负担,比如不断向堆中增加元素,使得固定数组快耗尽时,就不得不申请一个更大的固定数组,然后把原来数组中的对象复制到新的数组里完成堆的扩容,所以,如果扩容时堆中的元素个数为N ,那么扩容行为的时间复杂度为O (N )。请设计一种没有扩容负担的堆结构,即在任何时刻有关堆的操作时间复杂度都不超过O (logN )。

【要求】

1.没有扩容的负担。

2.可以生成小根堆,也可以生成大根堆。

3.包含getHead方法,返回当前堆顶的值。

4.包含getSize方法,返回当前堆的大小。

5.包含add(x)方法,即向堆中新加元素x,操作后依然是小根堆/大根堆。

6.包含popHead方法,即删除并返回堆顶的值,操作后依然是小根堆/大根堆。

7.如果堆中的节点个数为N ,那么各个方法的时间复杂度为:

getHead:O (1)。

getSize:O (1)。

add:O (logN )。

popHead:O (logN )。

【难度】

将 ★★★★

【解答】

本题的设计方法有很多,本书提供的方法实际上是实现了完全二叉树结构,并含有堆的调整过程。二叉树的节点类型如下,比经典的二叉树节点多一条指向父节点的parent指针:

          public class Node<K> {

                  public K value;

                  public Node<K> left;

                  public Node<K> right;

                  public Node<K> parent;

                  public Node(K data) {
                          value = data;
                  }
          }

本书实现的堆结构叫MyHeap类,MyHeap中有四个重要的组成部分。

● head:Node类型的变量,表示当前堆的头节点。

● last:Node类型的变量,表示当前堆的堆尾节点,也就是最后一排的最右节点。

● size:整型变量,表示当前堆的大小。

● comp:继承了Comparator接口的比较器类型的变量。在构造Myheap实例时由用户定义,通过定义堆中元素的比较方式,自然可以将堆实现成大根堆或小根堆。comp变量是在构造时一经设定就不能更改。

所有堆的操作在执行时,变量head、last和size都能够正确更新是MyHeap类实现的重点。其中getHead方法和getSize方法是很容易实现的,就是直接取值返回即可。那么接下来就重点介绍add方法和popHead方法的实现细节。

add方法的实现。如果想要把元素value加入到堆中,首先生成二叉树节点类型的实例,即new Node<value的类型>(value),假设生成的节点为newNode。把newNode加到二叉树上的具体过程如下:

1.如果size==0,说明当前的堆没有节点,三个变量简单赋值即可:

                          if (size == 0) {
                                  head = newNode;
                                  last = newNode;
                                  size++;
                                  return;
                          }

2.如果size>0,说明当前的堆有节点,此时想要加上newNode的困难在于,不知道newNode应该加到二叉树的什么位置。此时利用last的位置来找到newNode应该加的位置。

1)last具体在堆中的什么位置特别关键,具体有如下三种情况:

情况一,last是当前层的最后一个节点,也就是当前层已经满,无法再加新的节点,那么newNode应该加在新一层最左的位置。

情况二,如果last是last父节点的左孩子,那么newNode应该加在last父节点的右孩子的位置。

情况三,如果last既不是情况一,也不是情况二,则参见图9-7。

image

图9-7

图9-7代表情况三,即当前层并没有添加满,但是last的父节点(比如图中的D节点)已经添加满,此时需要一个向上寻找的过程。先以last作为当前节点,然后看看当前节点是不是当前节点的父节点的左孩子,如果不是,就一直向上。比如图9-7中的节点I,它不是其父节点的左孩子,那么向上寻找开始,节点D成为当前节点。此时发现节点D是其父节点(即节点B)的左孩子,此时寻找结束。新节点newNode应该加在节点B的右子树的最左节点的左孩子的位置上,即节点E的左孩子位置。下面再举一例,如图9-8所示。

image

图9-8

图9-8中last节点是节点K,如何找到newNode应该加的位置呢?和图9-7的方式相同,也是往上寻找的过程。开始时当前节点为节点K,发现它不是其父节点(E)的左孩子,那么节点E变成当前节点,发现也不是其父节点(B)的左孩子,那么节点B变成当前节点,发现节点B是其父节点A的左孩子,此时向上的过程停止。新节点newNode应该加在节点A的右子树的最左节点的左孩子的位置上,即节点F的左孩子位置。

2)加完newNode之后,newNode就成为新的last,令last=newNode,同时size++。

3)此时的last节点就是新加节点,虽然加在了二叉树上,但还没有经历建堆的调整过程。比如,如果整个堆是大根堆,而新加节点的值又很大,按道理,这个节点应该经历向上交换的过程,所以最后应该从last节点向上经历堆的调整过程,即heapInsert过程。同时需要特别注意的是,在交换的过程中,last和head的值可能会变化,如图9-9所示。

image

图9-9

假设加上新节点(值为8的节点)之后的完全二叉树如图9-9所示,很明显,last节点需要往上调整的过程。调整之后的二叉树应该为图9-10。

image

图9-10

如果在经历调整之后,新加的节点最后没有占据头节点的位置,那么head的值当然是不用改变的,但如果最后占据了头节点的位置,则head的值应该调整,比如图9-10中head的值应该变为节点8。同理,如果在经历调整时发现,新加的节点并不比它的父节点大,说明新加的节点不需要向上移动,那么last的值当然还是新加的节点,但如果新加的节点需要向上移动,比如图9-10,那么last的值也需要调整,应该设为新加的节点的父节点(图9-10中的节点6)。只有head和last在调整的每一步都正确地更新,整个设计才能不出错。具体请参看如下代码中MyHeap类实现的heapInsertModify方法。

popHead方法的实现。删除堆顶节点并返回堆顶的值,具体过程如下:

1.如果size==0,说明当前堆为空,直接返回null,也不需要任何调整。

2.如果size==1,说明当前堆里只有一个节点,返回节点值并将堆清空,即如下代码:

                          Node<K> res = head;
                          if (size == 1) {
                                  head = null;
                                  last = null;
                                  size--;
                                  return res.value;
                          }

3.如果size>1,把当前堆顶节点记为res,把最后一个元素(last)放在堆顶位置作为新的头,同时从头部开始进行堆的调整,使其继续是大根/小根堆,最后返回res.value即可。话虽如此,但是这个过程还是要保证head和last的正确更新,具体细节如下:

1)先把堆中最后一个节点(last)和整个堆结构断开,记为oldLast。因为oldLast要放在头节点的位置,所以last的值应该变成oldLast节点之前的那个节点,同样有三种情况。

情况一,如果oldLast在断开之前是其所在层的最左节点,那么在断开之后,last应该变为上一层的最右节点。

情况二,如果oldLast在断开之前是oldLast的父节点的右孩子,那么在断开之后,last应该变为oldLast的父节点的左孩子。

情况三,除情况一和情况二外,还有一种情况,如图9-11所示。

image

图9-11

图9-11代表了情况三,即oldLast并不是当前层的最左节点,也不是其父节点的右孩子,此时需要一个向上寻找的过程。先以oldLast作为当前节点,然后看当前节点是不是当前节点的父节点的右孩子,如果不是,就一直向上。比如,图9-11中的节点J,它不是其父节点的右孩子,那么向上寻找开始,节点E成为当前节点,此时发现节点E是其父节点(即节点B)的右孩子,寻找结束。last节点应该设成节点B的左子树的最右节点(即节点I)。我们再举一例,如图9-12所示。

image

图9-12

图9-12中的oldLast节点是节点L,如何设置last节点的值呢?和图9-11的方式相同,也是往上寻找的过程。开始时当前节点为节点L,发现它不是其父节点(F)的右孩子,那么节点F变成当前节点,发现也不是其父节点(C)的右孩子,那么节点C变成当前节点,发现节点C是其父节点A的右孩子,此时向上的过程停止。Last节点应该设成节点A的左子树的最右节点,即节点K。步骤1)的具体过程请参看MyHeap类实现的popLastAndSetPreviousLast方法。

2)断开oldLast节点后,堆中的元素少了一个,所以size减1。如果size在减1之后有size==1,说明一开始堆的大小为2,断开oldLast之后堆中只剩一个头节点。那么此时令oldLast作为新的头节点,并返回旧的头节点的值即可,代码如下:

                          Node<K> res = head;
                          Node<K> oldLast = popLastAndSetPreviousLast();
                          if (size == 1) {
                                  head = oldLast;
                                  last = oldLast;
                                  return res.value;
                          }

3)如果断开oldLast节点后,size依然大于1。那么将oldLast设成新的头节点,然后从堆顶开始往下调整堆结构,即heapify的过程,此时依然要注意head和last可能改变的情况,因为调整的过程中新的头节点(即oldLast)还可能会移动,使得head和last位置上的节点发生变化,具体过程请参看MyHeap类实现的heapify方法。

MyHeap类的设计就介绍完了,与经典堆结构是一个数组结构不同的是,MyHeap类是一个完全二叉树结构,所以两个相邻节点在交换位置时的处理会更复杂,都考虑彼此的拓扑关系,才能做到正确地进行交换。具体请参看MyHeap类实现的swapClosedTwoNodes方法。当然也可以不进行结构上的交换,而只是交换两个节点的值,即Node.value。

add和popHead方法的所有操作都是在完全二叉树的一条或两条路径上进行的操作,所以每一个操作的代价都是完全二叉树的高度级别,一个节点数为N 的完全二叉树高度为O (logN ),所以add和popHead方法的时间复杂度为O (logN )。MyHeap类的全部实现如下:

          public class MyHeap<K> {
                  private Node<K> head; // 堆头节点
                  private Node<K> last; // 堆尾节点
                  private long size; // 当前堆的大小
                  private Comparator<K> comp; // 大根堆或小根堆

                  public MyHeap(Comparator<K> compare) {
                          head = null;
                          last = null;
                          size = 0;
                          comp = compare; // 基于比较器决定是大根堆还是小根堆
                  }

                  public K getHead() {
                          return head == null ? null : head.value;
                  }

                  public long getSize() {
                          return size;
                  }

                  public boolean isEmpty() {
                          return size == 0 ? true : false;
                  }

                  // 添加一个新节点到堆中
                  public void add(K value) {
                          Node<K> newNode = new Node<K>(value);
                          if (size == 0) {
                                  head = newNode;
                                  last = newNode;
                                  size++;
                                  return;
                          }
                          Node<K> node = last;
                          Node<K> parent = node.parent;
                          // 找到正确的位置并插入到新节点
                          while (parent ! = null && node ! = parent.left) {
                                  node = parent;
                                  parent = node.parent;
                          }
                          Node<K> nodeToAdd = null;
                          if (parent == null) {
                                  nodeToAdd = mostLeft(head);
                                  nodeToAdd.left = newNode;
                                  newNode.parent = nodeToAdd;
                          } else if (parent.right == null) {
                                  parent.right = newNode;
                                  newNode.parent = parent;
                          } else {
                                  nodeToAdd = mostLeft(parent.right);
                                  nodeToAdd.left = newNode;
                                  newNode.parent = nodeToAdd;
                          }
                          last = newNode;
                          // 建堆过程及其调整
                          heapInsertModify();
                          size++;
                  }

                  public K popHead() {
                          if (size == 0) {
                                  return null;
                          }
                          Node<K> res = head;
                          if (size == 1) {
                                  head = null;
                                  last = null;
                                  size--;
                                  return res.value;
                          }
                          Node<K> oldLast = popLastAndSetPreviousLast();
                          // 如果弹出堆尾节点后,堆的大小等于1的处理
                          if (size == 1) {
                                  head = oldLast;
                                  last = oldLast;
                                  return res.value;
                          }
                          // 如果弹出堆尾节点后,堆的大小大于1的处理
                          Node<K> headLeft = res.left;
                          Node<K> headRight = res.right;
                          oldLast.left = headLeft;
                          if (headLeft ! = null) {
                                  headLeft.parent = oldLast;
                          }
                          oldLast.right = headRight;
                          if (headRight ! = null) {
                                  headRight.parent = oldLast;
                          }
                          res.left = null;
                          res.right = null;
                          head = oldLast;
                          // 堆heapify过程
                          heapify(oldLast);
                          return res.value;
                  }

                  // 找到以node为头的子树中,最左的节点
                  private Node<K> mostLeft(Node<K> node) {
                          while (node.left ! = null) {
                                  node = node.left;
                          }
                          return node;
                  }

                  // 找到以node为头的子树中,最右的节点
                  private Node<K> mostRight(Node<K> node) {
                          while (node.right ! = null) {
                                  node = node.right;
                          }
                          return node;
                  }

                  // 建堆及调整的过程
                  private void heapInsertModify() {
                    Node<K> node = last;
                    Node<K> parent = node.parent;
                    if (parent ! = null && comp.compare(node.value, parent.value) < 0) {
                          last = parent;
                    }
                    while (parent ! = null && comp.compare(node.value, parent.value) < 0) {
                          swapClosedTwoNodes(node, parent);
                          parent = node.parent;
                    }
                    if (head.parent ! = null) {
                          head = head.parent;
                    }
                  }

                  // 堆heapify过程
                private void heapify(Node<K> node) {
                    Node<K> left = node.left;
                    Node<K> right = node.right;
                    Node<K> most = node;
                    while (left ! = null) {
                        if (left ! = null && comp.compare(left.value, most.value) < 0) {
                                  most = left;
                          }
                        if (right ! = null && comp.compare(right.value, most.value) < 0) {
                                  most = right;
                        }
                        if (most ! = node) {
                                  swapClosedTwoNodes(most, node);
                        } else {
                                  break;
                        }
                        left = node.left;
                        right = node.right;
                        most = node;
                    }
                    if (node.parent == last) {
                          last = node;
                    }
                    while (node.parent ! = null) {
                          node = node.parent;
                    }
                    head = node;
                }

                  // 交换相邻的两个节点
                  private void swapClosedTwoNodes(Node<K> node, Node<K> parent) {
                          if (node == null || parent == null) {
                                  return;
                          }
                          Node<K> parentParent = parent.parent;
                          Node<K> parentLeft = parent.left;
                          Node<K> parentRight = parent.right;
                          Node<K> nodeLeft = node.left;
                          Node<K> nodeRight = node.right;
                          node.parent = parentParent;
                          if (parentParent ! = null) {
                                  if (parent == parentParent.left) {
                                          parentParent.left = node;
                                  } else {
                                          parentParent.right = node;
                                  }
                          }
                          parent.parent = node;
                          if (nodeLeft ! = null) {
                                  nodeLeft.parent = parent;
                          }
                          if (nodeRight ! = null) {
                                  nodeRight.parent = parent;
                          }
                          if (node == parent.left) {
                                  node.left = parent;
                                  node.right = parentRight;
                                  if (parentRight ! = null) {
                                          parentRight.parent = node;
                                  }
                          } else {
                                  node.left = parentLeft;
                                  node.right = parent;
                                  if (parentLeft ! = null) {
                                          parentLeft.parent = node;
                                  }
                          }
                          parent.left = nodeLeft;
                          parent.right = nodeRight;
                  }

                  // 在树中弹出堆尾节点后,找到原来的倒数第二个节点设置成新的队尾节点
                  private Node<K> popLastAndSetPreviousLast() {
                          Node<K> node = last;
                          Node<K> parent = node.parent;
                          while (parent ! = null && node ! = parent.right) {
                                  node = parent;
                                  parent = node.parent;
                          }
                          if (parent == null) {
                                  node = last;
                                  parent = node.parent;
                                  node.parent = null;
                                  if (node == parent.left) {
                                          parent.left = null;
                                  } else {
                                          parent.right = null;
                                  }
                                  last = mostRight(head);
                          } else {
                                  Node<K> newLast = mostRight(parent.left);
                                  node = last;
                                  parent = node.parent;
                                  node.parent = null;
                                  if (node == parent.left) {
                                          parent.left = null;
                                  } else {
                                          parent.right = null;
                                  }
                                  last = newLast;
                          }
                          size--;
                          return node;
                  }
          }






随时找到数据流的中位数


【题目】
有一个源源不断地吐出整数的数据流,假设你有足够的空间来保存吐出的数。请设计一个名叫MedianHolder的结构,MedianHolder可以随时取得之前吐出所有数的中位数。
【要求】
1.如果MedianHolder已经保存了吐出的N 个数,那么任意时刻将一个新数加入到MedianHolder的过程,其时间复杂度是O (logN )。
2.取得已经吐出的N 个数整体的中位数的过程,时间复杂度为O (1)。
【难度】
将 ★★★★
【解答】
本书设计的MedianHolder中有两个堆,一个是大根堆,一个是小根堆。大根堆中含有接收的所有数中较小的一半,并且按大根堆的方式组织起来,那么这个堆的堆顶就是较小一半的数中最大的那个。小根堆中含有接收的所有数中较大的一半,并且按小根堆的方式组织起来,那么这个堆的堆顶就是较大一半的数中最小的那个。
例如,如果已经吐出的数为6,1,3,0,9,8,7,2。
较小的一半为:0,1,2,3,那么3就是这一半的数组成的大根堆的堆顶。
较大的一半为:6,7,8,9,那么6就是这一半的数组成的小根堆的堆顶。
因为此时数的总个数为偶数,所以中位数就是两个堆顶相加,再除以2。
如果此时新加入一个数10,那么这个数应该放进较大的一半里,所以此时较大一半的数为:6,7,8,9,10。此时6依然是这一半的数组成的小根堆的堆顶,因为此时数的总个数为奇数,所以中位数应该是正好处在中间位置的数,而此时大根堆有4个数,小根堆有5个数,那么小根堆的堆顶6就是此时的中位数。如果此时又新加入了一个数11,那么这个数也应该放进较大的一半里,此时较大一半的数为:6,7,8,9,10,11。这时小根堆大小为6,而大根堆的大小为4,所以要进行如下调整:
1.如果大根堆的size比小根堆的size大2,那么从大根堆里将堆顶弹出,并放入小根堆里。
2.如果小根堆的size比大根堆的size大2,那么从小根堆里将堆顶弹出,并放入大根堆里。进行这样的调整后,大根堆和小根堆的size相同。
总结如下:
1.大根堆每时每刻都是较小的一半的数,堆顶为这一堆数的最大值。
2.小根堆每时每刻都是较大的一半的数,堆顶为这一堆数的最小值。
3.新加入的数根据与两个堆的堆顶的大小关系,选择放进大根堆或者小根堆里。
4.当任何一个堆的size比另一个的size大2时,进行如上调整过程。
这样随时都可以知道已经吐出的所有数处于中间位置的两个数是什么,取得中位数的操作时间复杂度为O (1),同时根据堆的性质,向堆中加一个新的数,并且调整堆的代价为O (logN )。然而题目有一个很重要的限制“任何时刻将一个新数加入到MedianHolder的过程,时间复杂度是O (logN )”,为了做到“任何时刻”的要求,那么堆的设计不能采用固定数组的实现方式,因为会有扩容的代价,但是在Java中诸如优先级队列(PriorityQueue)等很多库提供的数据结构却都是使用固定数组的方式实现的。所以严格地说,使用这些结构的实现并不符合题目要求。本书“设计一个没有扩容负担的堆结构”问题中完成了符合要求的堆结构实现,即其中的MyHeap类,请读者先理解这个类的实现,然后参看本题的实现(即如下代码中的MedianHolder类)。
          public class MedianHolder {
                  private MyHeap<Integer> minHeap;
                  private MyHeap<Integer> maxHeap;

                  public MedianHolder() {
                          this.minHeap = new MyHeap<Integer>(new MinHeapComparator());
                          this.maxHeap = new MyHeap<Integer>(new MaxHeapComparator());
                  }

                  public void addNumber(Integer num) {
                          if (this.maxHeap.isEmpty()) {
                                  this.maxHeap.add(num);
                                  return;
                          }
                          if (this.maxHeap.getHead() >= num) {
                                  this.maxHeap.add(num);
                          } else {
                                  if (this.minHeap.isEmpty()) {
                                          this.minHeap.add(num);
                                          return;
                                  }
                                  if (this.minHeap.getHead() > num) {
                                          this.maxHeap.add(num);
                                  } else {
                                          this.minHeap.add(num);
                                  }
                          }
                          this.modifyTwoHeapsSize();
                  }

                  public Integer getMedian() {
                          long maxHeapSize = this.maxHeap.getSize();
                          long minHeapSize = this.minHeap.getSize();
                          if (maxHeapSize + minHeapSize == 0) {
                                  return null;
                          }
                          Integer maxHeapHead = this.maxHeap.getHead();
                          Integer minHeapHead = this.minHeap.getHead();
                          if (((maxHeapSize + minHeapSize) & 1) == 0) {
                                  return (maxHeapHead + minHeapHead) / 2;
                          } else if (maxHeapSize > minHeapSize) {
                                  return maxHeapHead;
                          } else {
                                  return minHeapHead;
                          }
                  }

                  private void modifyTwoHeapsSize() {
                          if (this.maxHeap.getSize() == this.minHeap.getSize() + 2) {
                                  this.minHeap.add(this.maxHeap.popHead());
                          }
                          if (this.minHeap.getSize() == this.maxHeap.getSize() + 2) {
                                  this.maxHeap.add(this.minHeap.popHead());
                          }
                  }
          }

          //生成大根堆的比较器
          public class MaxHeapComparator implements Comparator<Integer> {
                  @Override
                  public int compare(Integer o1, Integer o2) {
                          if (o2 > o1) {
                                  return 1;
                          } else {
                                  return -1;
                          }
                  }
          }

          //生成小根堆的比较器
          public class MinHeapComparator implements Comparator<Integer> {
                  @Override

                  public int compare(Integer o1, Integer o2) {
                          if (o2 < o1) {
                                  return 1;
                          } else {
                                  return -1;
                          }
                  }
          }

在两个长度相等的排序数组中找到上中位数


【题目】
给定两个有序数组arr1和arr2,已知两个数组的长度都为N ,求两个数组中所有数的上中位数。
【举例】
arr1=[1,2,3,4],arr2=[3,4,5,6]
总共有8个数,那么上中位数是第4小的数,所以返回3。
arr1=[0,1,2],arr2 =[3,4,5]
总共有6个数,那么上中位数是第3小的数,所以返回2。
【要求】
时间复杂度为O (logN ),额外空间复杂度为O (1)。
【难度】
尉 ★★☆☆
【解答】
根据时间复杂度的要求可知,应该利用二分的方式寻找上中位数,具体过程为:
1.重新定义一下问题,现在我们在arr1[start1..end1]与arr2[start2..end2]上寻找这两段数组共同的上中位数,并且这两段的长度应该相等(end1-star1==end2-start2)。
2.初始时start1=0,end1=N-1,即arr1[start1..end1]代表arr1的全部。start2=0,end2=N-1,即arr2[start2..end2]代表arr2的全部。
3.如果start1==end1,那么也有start2==end2,找寻的过程中始终保证两段长度一致。这种情况下说明每一段都只有一个元素,这时元素总个数是2个,上中位数为较小的那个,则应该直接返回min{ arr1[start1],arr2[start2] }。
4.如果start1! =end1,此时说明两段数组的长度都大于1,则令mid1=(start1+end1)/2,代表arr1[start1..end1]的中间位置。令mid2=(start2+end2)/2,代表arr2[start2..end2]的中间位置。那么具体情况有三种。
情况一,如果arr1[mid1]==arr2[mid2]。为了方便理解,举两个例子说明这种情况。
1)arr1和arr2的长度为奇数的例子。arr1的长度为5,{1,2,3,4,5}依次表示arr1的第1个数,第2个数……第5个数,注意,这个数字表示arr1第几个数的意思,并不代表值。arr2长度为5,{1' ,2' ,3' ,4' ,5' }依次表示arr2的第1个数,第2个数……第5个数,注意,这个数字表示arr2的第几个数的意思,并不代表值。如果arr1的第3个数等于arr2的第3个数(3==3' ),那么对这两个数来说,在arr1中把1和2压在底下,在arr2中把1’和2’压在底下。所以这两个数的值就是上中位数,直接返回arr1[mid1]即可(当然也是arr2[mid2])。
2)arr1和arr2的长度为偶数的例子。arr1的长度为4,{1,2,3,4}的含义同上。arr2的长度为4,{1' ,2' ,3' ,4' }的含义同上。如果arr1的第2个数等于arr2的第2个数(2==2' ),那么对这两个数来说,在arr1中把1压在底下,在arr2中把1压在底下。所以这两个数的值就是上中位数,直接返回arr1[mid1]即可(当然也是arr2[mid2])。
综上所述,情况一中,如果arr1[mid1]==arr2[mid2],直接返回arr1[mid1]。
情况二,如果arr1[mid1]>arr2[mid2]。为了方便理解,仍然举两个例子说明。
1)arr1和arr2的长度为奇数的例子。arr1长度为5,{1,2,3,4,5}的含义同上。arr2长度为5,{1' ,2' ,3' ,4' ,5' }的含义同上。如果arr1的第3个数大于arr2的第3个数(3>3'),对4来说,它可能是第5个数吗?不可能。因为在arr1中,4把三个数压在底下,同时又有(3>3'),所以4在arr2中又起码把三个数压在底下,所以4最好情况下是第7个数。那么对5来说,则更不可能。对2’来说,它可能是第5个数吗?不可能。因为在arr2中,2’只压了一个数,同时又有(3>3' >=2'),所以2’在arr1中最多只能把两个数压在底下,所以2’最好情况下是第4个数。那么对1’来说,则更不可能。现在我们看一下,{1,2,3}和{3' ,4' ,5' }这两段共同的上中位数,也就是这6个数中第3小的数记为a,代表什么?a在{1,2,3}和{3' ,4' ,5' }这两段中,会把两个数压在下面,同时也会把原来arr2中的1’和2’压在下面。那么a正好就是{1,2,3,4,5}和{1' ,2' ,3' ,4' ,5' }整体第5小的数,也就是想求的结果。所以只要求{1,2,3}和{3' ,4' ,5' }的上中位数即可,即令end1=mid1,start2=mid2,然后重复步骤3。
2)arr1和arr2的长度为偶数的例子。arr1长度为4,{1,2,3,4}的含义同上。arr1长度为4,{1' ,2' ,3' ,4' }的含义同上。如果arr1的第2个数大于arr2的第2个数(2>2'),对3来说,它可能是第4个数吗?不可能,因为它起码把四个数压在底下,最好情况也是第5个数,则4更不可能。对2’来说,它可能是第4个数吗?也不可能,因为它最多只把两个数压在底下,最好情况也仅是第3个数,则1’更不可能。现在我们看一下,{1,2}和{3' ,4' }这两段共同的上中位数,也就是这4个数中第2小的数记为b,代表什么?b在{1,2}和{3' ,4' }这两段中,会把一个数压在下面,同时也会把原来arr2中的1’和2’压在下面。那么b正好就是{1,2,3,4}和{1' ,2' ,3' ,4' }整体第4小的数,也就是想求的结果。所以只要求{1,2}和{3' ,4' }的上中位数即可,即令end1=mid1,start2=mid2+1,然后重复步骤3。
综上所述,情况二中,无论怎样,在arr1和arr2的范围上都可以二分。
情况三,如果arr1[mid1]<arr2[mid2]。分析方式类似情况二,这里不再详细解释,肯定可以二分。arr1和arr2如果长度为奇数,令start1=mid1,end2=mid2,然后重复步骤3。arr1和arr2如果长度为偶数,令start1=mid1+1,end2=mid2,然后重复步骤3。
具体过程请参看如下代码中的getUpMedian方法。
          public int getUpMedian(int[] arr1, int[] arr2) {
                  if (arr1 == null || arr2 == null || arr1.length ! = arr2.length) {
                          throw new RuntimeException("Your arr is invalid! ");
                  }
                  int start1 = 0;
                  int end1 = arr1.length - 1;
                  int start2 = 0;
                  int end2 = arr2.length - 1;
                  int mid1 = 0;
                  int mid2 = 0;
                  int offset = 0;
                  while (start1 < end1) {
                          mid1 = (start1 + end1) / 2;
                          mid2 = (start2 + end2) / 2;
                          // 元素个数为奇数,则offset为0,元素个数为偶数,则offset为1。
                          offset = ((end1 - start1 + 1) & 1) ^ 1;
                          if (arr1[mid1] > arr2[mid2]) {
                                  end1 = mid1;
                                  start2 = mid2 + offset;
                          } else if (arr1[mid1] < arr2[mid2]) {
                                  start1 = mid1 + offset;
                                  end2 = mid2;
                          } else {
                                  return arr1[mid1];
                          }
                  }
                  return Math.min(arr1[start1], arr2[start2]);
          }

在两个排序数组中找到第K 小的数


【题目】
给定两个有序数组arr1和arr2,再给定一个整数k ,返回所有的数中第K 小的数。
【举例】
arr1=[1,2,3,4,5],arr2=[3,4,5],k =1。
1是所有数中第1小的数,所以返回1。
arr1=[1,2,3],arr2=[3,4,5,6],k =4。
3是所有数中第4小的数,所以返回3。
【要求】
如果arr1的长度为N ,arr2的长度为M ,时间复杂度请达到O (log(min{MN })),额外空间复杂度为O (1)。
【难度】
将 ★★★★
【解答】
在了解本题的解法之前,请读者先阅读上一题“在两个长度相等的排序数组中找到上中位数”这个问题的解答。本题也深度利用了这个问题的解法。以下的getUpMedian方法就是上中位数这个问题的代码,在a1[s1..e1]和a2[s2..e2]两段长度相等的范围上找上中位数。
          public int getUpMedian(int[] a1, int s1, int e1, int[] a2, int s2, int e2) {
                  int mid1 = 0;
                  int mid2 = 0;
                  int offset = 0;
                  while (s1 < e1) {
                          mid1 = (s1 + e1) / 2;
                          mid2 = (s2 + e2) / 2;
                          offset = ((e1 - s1 + 1) & 1) ^ 1;
                          if (a1[mid1] > a2[mid2]) {
                                  e1 = mid1;
                                  s2 = mid2 + offset;
                          } else if (a1[mid1] < a2[mid2]) {
                                  s1 = mid1 + offset;
                                  e2 = mid2;
                          } else {
                                  return a1[mid1];
                          }
                  }
                  return Math.min(a1[s1], a2[s2]);
          }

下面开始求解本题,为了方便理解,我们用举例说明的方式。长度较短的数组为shortArr,长度记为lenS;长度较长的数组为longArr,长度记为lenL。假设shortArr长度为10。{1,2,3,…,10}依次表示shortArr的第1个数,第2个数……第10个数,注意,这个数字表示shortArr的第几个数的意思,并不代表值。假设longArr长度为27。{1' ,2' ,…,27' }依次表示longArr的第1个数,第2个数……第27个数,注意,这个数字表示longArr的第几个数的意思,并不代表值。下面是找到整体第k 个最小的数的过程:
情况1,如果k <1或者k >lenS+lenL,那么k 值是无效的。
情况2,如果k ≤lenS。那么在shortArr中选前面的k 个数,在longArr中也选前面的k 个数,这两段数组中的上中位数就是整体第k 个最小的数。比如k =5时,那么{1...5}和{1' ...5' }这两段数组整体的上中位数就是整体第5小的数。
情况3,如果k >lenL。举一个具体的例子来说,一共有37个数,求第33个最小的数(33>lenL==27)就是这种情况。在{1...10}中,5不可能成为第33个最小的数,因为即便是5比27’还要大。也就是说,即使5在longArr中把27个数全压在下面,5在shortArr中也只把4个数压在下面,所以5最好的情况就是第32个最小的数。那么{1...4}就更不可能,所以{1...5}一律不可能。那么6可能是吗?可能。6如果大于27',那么6就是第33个最小的数,直接返回,否则6也不是。同理,在{1' ...27' }中,{1' ...22' }绝不可能是第33个最小的数。23’如果大于10,那么23’就是第33个最小的数,直接返回,否则23’也不是。如果发现6和23’有一个满足条件,就可以直接返回。否则可以知道{1...6}和{1' ...23' }这一共29个数都是不可能的,那么{7...10}和{24' ...27' }这两段数组整体的上中位数,即这8个数里的第4小数,就是整体第33个最小的数。
情况4,如果不是情况1、情况2和情况3,说明lenS<k≤lenL。举一个具体的例子来说,求第17个最小的数(10<17≤27)就是这种情况。在{1...10}中,任何数都有可能是第17个最小的数。在{1' ...27' }中,6’不可能是第17个最小的数,因为即使6’在shortArr中把10个数全压在下面,6’在longArr中也只把5个数压在下面,所以6’最好的情况就是第16个最小的数,所以{1' ...6' }一律不可能。在{1' ...27' }中,18’也不可能是第17个最小的数,18'最好的情况也只能做第18个最小的数,所以{18' ...27' }一律不可能。只剩下{7' ...17' },7’可能是吗?可能。7’如果大于10,那么7’就是第17个最小的数,直接返回。否则7’也是不可能的,这时{1' ...7' }这一共7个数都是不可能的,那么{1...10}和{8' ...17' }这两段数组整体的上中位数,即这20个数里第10小的数,就是整体第17个最小的数。
不管是以上4种情况的哪一种,在求arr1和arr2长度相等的两个范围上的上中位数时,范围最多也只是shortArr数组的长度,所以时间复杂度为O (log(min{MN }))。具体过程请参看如下代码中的findKthNum方法。
          public int findKthNum(int[] arr1, int[] arr2, int kth) {
                  if (arr1 == null || arr2 == null) {
                      throw new RuntimeException("Your arr is invalid! ");
                  }
                  if (kth < 1 || kth > arr1.length + arr2.length) {
                      throw new RuntimeException("K is invalid! ");
                  }
                  int[] longs = arr1.length >= arr2.length ? arr1 : arr2;
                  int[] shorts = arr1.length < arr2.length ? arr1 : arr2;
                  int l = longs.length;
                  int s = shorts.length;
                  if (kth <= s) {
                      return getUpMedian(shorts, 0, kth - 1, longs, 0, kth - 1);
                  }
                  if (kth > l) {
                      if (shorts[kth - l - 1] >= longs[l - 1]) {
                          return shorts[kth - l - 1];
                      }
                      if (longs[kth - s - 1] >= shorts[s - 1]) {
                          return longs[kth - s - 1];
                      }
                      return getUpMedian(shorts, kth - l, s - 1, longs, kth - s, l - 1);
                  }
                  if (longs[kth - s - 1] >= shorts[s - 1]) {
                      return longs[kth - s - 1];
                  }
                  return getUpMedian(shorts, 0, s - 1, longs, kth - s, kth - 1);
          }

两个有序数组间相加和的TOP K 问题


【题目】
给定两个有序数组arr1和arr2,再给定一个整数k ,返回来自arr1和arr2的两个数相加和最大的前k 个,两个数必须分别来自两个数组。
【举例】
arr1=[1,2,3,4,5],arr2=[3,5,7,9,11],k =4。
返回数组[16,15,14,14]。
【要求】
时间复杂度达到O (k logk )。
【难度】
尉 ★★☆☆
【解答】
哪两个分别来自两个排序数组的数相加最大?自然是arr1的最后一个数和arr2的最后一个数,假设arr1长度为N ,arr2长度为M ,如图9-13所示。
image

图9-13


既然arr2[M-1]+arr1[N-1]无疑是所有和中最大的,那么先把这个和放到大根堆里。然后从堆中弹出一个堆顶,此时这个堆顶肯定是(M -1,N -1)位置的和,即arr2[M-1]+arr1[N-1]。然后把两个位置的和再放进堆里,分别是(M -2,N -1)和(M -1,N -2),因为除(M -1,N -1)位置的和之外,其他任何位置的和都不会比(M -2,N -1)和(M -1,N -2)位置的和更大。每放入一个位置的和,都经过堆的调整(heapInsert调整)。当再从堆中弹出一个堆顶时,此时的堆顶必然是堆中最大的和,假设是(ij )位置的和。弹出之后再把堆调整成大根堆,即把堆中最后一个元素放到堆顶的位置进行从上到下的heapify调整,调整之后再依次把(ij -1)和(i -1,j )位置的和放入到堆中。也就是说,每次从堆中拿出一个位置和,然后把拿出位置和的左位置和上位置放入到堆里。每次弹出的位置和就是从大到小排列的我们想得到的Top K 。这个过程再次总结为:
1.初始时把位置(M -1,N -1)放入堆中,因为这个位置代表的相加和就是最大的相加和。
2.此时堆顶为(M -1,N -1),把这个位置代表的相加和(arr2[M-1]+arr1[N-1])收集起来,然后把堆尾放到堆顶的位置,再经历堆的调整(heapify),最后把(M -2,N -1)和(M -1,N -2)放入堆中,并根据代表的相加和来重新调整堆(heapInsert)。
3.每次堆顶都会有一个位置记为(ij ),把这个位置代表的相加和(arr2[i]+arr1[j])收集起来,然后把堆尾放到堆顶的位置,再经历堆的调整(heapify)。最后把这个位置上边的(i -1,j )和左边的(ij -1)放入堆中,并根据代表的相加和调整堆(heapInsert)。
4.直到收集的个数为k ,整个过程结束。
堆的大小为k ,每次堆的调整为O (logK )级别,并且一共收集k 个数,所以时间复杂度为O (k logk )。需要注意的是,要利用哈希表来防止同一个位置重复进堆的情况。
全部过程请参看如下代码中的topKSum方法。
          public class HeapNode {
                  public int row;
                  public int col;
                  public int value;

                  public HeapNode(int row, int col, int value) {
                          this.row = row;
                          this.col = col;
                          this.value = value;
                  }
          }

          public int[] topKSum(int[] a1, int[] a2, int topK) {
                  if (a1 == null || a2 == null || topK < 1) {
                          return null;
                  }
                  topK = Math.min(topK, a1.length * a2.length);
                  HeapNode[] heap = new HeapNode[topK + 1];
                  int heapSize = 0;
                  int headR = a1.length - 1;
                  int headC = a2.length - 1;
                  int uR = -1;
                  int uC = -1;
                  int lR = -1;
                  int lC = -1;
                  heapInsert(heap, heapSize++, headR, headC, a1[headR] + a2[headC]);
                  HashSet<String> positionSet = new HashSet<String>();
                  int[] res = new int[topK];
                  int resIndex = 0;
                  while (resIndex ! = topK) {
                          HeapNode head = popHead(heap, heapSize--);
                          res[resIndex++] = head.value;
                          headR = head.row;
                          headC = head.col;
                          uR = headR - 1;
                          uC = headC;
                          if (headR ! = 0 && ! isContains(uR, uC, positionSet)) {
                                  heapInsert(heap, heapSize++, uR, uC, a1[uR] + a2[uC]);
                                  addPositionToSet(uR, uC, positionSet);
                          }
                          lR = headR;
                          lC = headC - 1;
                          if (headC ! = 0 && ! isContains(lR, lC, positionSet)) {
                                  heapInsert(heap, heapSize++, lR, lC, a1[lR] + a2[lC]);
                                  addPositionToSet(lR, lC, positionSet);
                          }
                  }
                  return res;
          }

          public HeapNode popHead(HeapNode[] heap, int heapSize) {
                  HeapNode res = heap[0];
                  swap(heap, 0, heapSize - 1);
                  heap[--heapSize] = null;
                  heapify(heap, 0, heapSize);
                  return res;
          }

          public void heapify(HeapNode[] heap, int index, int heapSize) {
                  int left = index * 2 + 1;
                  int right = index * 2 + 2;
                  int largest = index;
                  while (left < heapSize) {
                      if (heap[left].value > heap[index].value) {
                          largest = left;
                      }
                      if (right < heapSize && heap[right].value > heap[largest].value) {
                          largest = right;
                      }
                      if (largest ! = index) {
                          swap(heap, largest, index);
                      } else {
                          break;
                      }
                      index = largest;
                      left = index * 2 + 1;
                      right = index * 2 + 2;
                  }
          }

          public void heapInsert(HeapNode[] heap, int index, int row, int col,
                          int value) {
                  heap[index] = new HeapNode(row, col, value);
                  int parent = (index - 1) / 2;
                  while (index ! = 0) {
                          if (heap[index].value > heap[parent].value) {
                                  swap(heap, parent, index);
                                  index = parent;
                                  parent = (index - 1) / 2;
                          } else {
                                  break;
                          }
                  }
          }

          public void swap(HeapNode[] heap, int index1, int index2) {
                  HeapNode tmp = heap[index1];
                  heap[index1] = heap[index2];
                  heap[index2] = tmp;
          }

          public boolean isContains(int row, int col, HashSet<String> set) {
                  return set.contains(String.valueOf(row + "_" + col));
          }

          public void addPositionToSet(int row, int col, HashSet<String> set) {
                  set.add(String.valueOf(row + "_" + col));
          }

出现次数的TOP K 问题


【题目】
给定String类型的数组strArr,再给定整数k ,请严格按照排名顺序打印出现次数前k 名的字符串。
【举例】
strArr=["1","2","3","4"],k =2
No.1: 1,times: 1
No.2: 2,times: 1
这种情况下,所有的字符串都出现一样多,随便打印任何两个字符串都可以。
strArr=["1","1","2","3"],k =2
输出:
No.1: 1,times: 2
No.2: 2,times: 1
或者输出:
No.1: 1,times: 2
No.2: 3,times: 1
【要求】
如果strArr长度为N ,时间复杂度请达到O (N logk )。
【进阶题目】
设计并实现TopKRecord结构,可以不断地向其中加入字符串,并且可以根据字符串出现的情况随时打印加入次数最多前k 个字符串,具体为:
1.k 在TopKRecord实例生成时指定,并且不再变化(k 是构造函数的参数)。
2.含有add(String str)方法,即向TopKRecord中加入字符串。
3.含有printTopK()方法,即打印加入次数最多的前k 个字符串,打印有哪些字符串和对应的次数即可,不要求严格按排名顺序打印。
【举例】
TopKRecord record = new TopKRecord(2); // 打印Top 2的结构
record.add("A");
record.printTopK();
此时打印:
TOP:
Str: A Times: 1
record.add("B");
record.add("B");
record.printTopK();
此时打印:
TOP:
Str: A Times: 1
Str: B Times: 2
或者打印
TOP:
Str: B Times: 2
Str: A Times: 1
record.add("C");
record.add("C");
record.printTopK();
此时打印:
TOP:
Str: B Times: 2
Str: C Times: 2
或者打印
TOP:
Str: C Times: 2
Str: B Times: 2
【要求】
1.在任何时刻,add方法的时间复杂度不超过O (logk )。
2.在任何时刻,printTopK方法的时间复杂度不超过O (k )。
【难度】
原问题 尉 ★★☆☆
进阶问题 校 ★★★☆
【解答】
原问题。首先遍历strArr并统计字符串的词频,例如,strArr=["a","b","b","a","c"],遍历后可以生成每种字符串及其相关词频的哈希表如下:
key(字符串) value(相关词频)
"a" 2
"b" 2
"c" 1

用哈希表的每条信息可以生成Node类的实例,Node类如下:

          public class Node {

                  public String str;

                  public int times;

                  public Node(String s, int t) {
                          str = s;
                          times = t;
                  }
          }

哈希表中有多少信息,就建立多少Node类的实例,并且依次放入堆中,具体过程为:

1.建立一个大小为k 的小根堆,这个堆放入的是Node类的实例。

2.遍历哈希表的每条记录,假设一条记录为(s,t),s表示一种字符串,s的词频为t,则生成Node类的实例,记为(str,times)。

1)如果小根堆没有满,就直接将(str,times)加入堆,然后进行建堆调整(heapInsert调整),堆中Node类实例之间都以词频(times)来进行比较,词频越小,位置越往上。

2)如果小根堆已满,说明此时小根堆已经选出k 个最高词频的字符串,那么整个小根堆的堆顶自然代表已经选出的k 个最高词频的字符串中,词频最低的那个。堆顶的元素记为(headStr,minTimes)。如果minTimes<times,说明字符串str有资格进入当前k 个最高词频字符串的范围。而headStr应该被移出这个范围,所以把当前的堆顶(headStr,minTimes)替换成(str,times),然后从堆顶的位置进行堆的调整(heapify)。如果minTimes>=times,说明字符串str没有资格进入当前k 个最高词频字符串的范围,因为str的词频还不如目前选出的k 个最高词频字符串中词频最少的那个,所以什么也不做。

3.遍历完strArr之后,小根堆里就是所有字符串中k 个最高词频的字符串,但要求严格按排名打印,所以还需要根据词频从大到小完成k 个元素间的排序。

遍历strArr建立哈希表的过程为O (N )。哈希表中记录的条数最多为N 条,每一条记录进堆时,堆的调整时间复杂度为O (logk ),所以根据记录更新小根堆的过程为O (N logk )。k 条记录排序的时间复杂度为O (k logk )。所以总的时间复杂度为O (N )+O (N logk )+O (k logk ),即O (N logk )。具体过程请参看如下代码中的printTopKAndRank方法。

          public void printTopKAndRank(String[] arr, int topK) {
                  if (arr == null || topK < 1) {
                          return;
                  }
                  HashMap<String, Integer> map = new HashMap<String, Integer>();
                  // 生成哈希表(字符串词频)
                  for (int i = 0; i ! = arr.length; i++) {
                          String cur = arr[i];
                          if (! map.containsKey(cur)) {
                                  map.put(cur, 1);
                          } else {
                                  map.put(cur, map.get(cur) + 1);
                          }
                  }
                  Node[] heap = new Node[topK];
                  int index = 0;
                  // 遍历哈希表,决定每条信息是否进堆
                  for (Entry<String, Integer> entry : map.entrySet()) {
                          String str = entry.getKey();
                          int times = entry.getValue();
                          Node node = new Node(str, times);
                          if (index ! = topK) {
                                  heap[index] = node;
                                  heapInsert(heap, index++);
                          } else {
                                  if (heap[0].times < node.times) {
                                          heap[0] = node;
                                          heapify(heap, 0, topK);
                                  }
                          }
                  }
                  // 把小根堆的所有元素按词频从大到小排序
                  for (int i = index - 1; i ! = 0; i--) {
                          swap(heap, 0, i);
                          heapify(heap, 0, i);
                  }
                  // 严格按照排名打印k条记录
                  for (int i = 0; i ! = heap.length; i++) {
                          if (heap[i] == null) {
                                  break;
                          } else {
                                  System.out.print("No." + (i + 1) + ": ");
                                  System.out.print(heap[i].str + ", times: ");
                                  System.out.println(heap[i].times);
                          }
                  }
          }

          public void heapInsert(Node[] heap, int index) {
                  while (index ! = 0) {
                          int parent = (index - 1) / 2;
                          if (heap[index].times < heap[parent].times) {
                                  swap(heap, parent, index);
                                  index = parent;
                          } else {
                                  break;
                          }
                  }
          }

          public void heapify(Node[] heap, int index, int heapSize) {
              int left = index * 2 + 1;
              int right = index * 2 + 2;
              int smallest = index;
              while (left < heapSize) {
                  if (heap[left].times < heap[index].times) {
                      smallest = left;
                  }
                  if (right < heapSize && heap[right].times < heap[smallest].times) {
                      smallest = right;
                  }
                  if (smallest ! = index) {
                      swap(heap, smallest, index);
                  } else {
                      break;
                  }
                  index = smallest;
                  left = index * 2 + 1;
                  right = index * 2 + 2;
              }
          }

          public void swap(Node[] heap, int index1, int index2) {
                  Node tmp = heap[index1];
                  heap[index1] = heap[index2];
                  heap[index2] = tmp;
          }

进阶问题。原问题是已经存在不再变化的字符串数组,所以可以一次性统计词频哈希表,然后建小根堆。可是进阶问题不一样,每个字符串词频可能会随时增加,这个过程一直是动态的。当然也可以在加入一个字符串时,在词频哈希表中增加这种字符串的词频,这样,add方法的时间复杂度就是O (1)。可是当有printTopK操作时,你只能像原问题一样,根据所有字符串的词频表来建立小根堆,假设此时哈希表的记录数为N ,那么printTopK方法的时间复杂度就成了O (N logk ),但明显是不达标的。本书提供的解法依然是利用小根堆这个数据结构,但在设计上更复杂。下面介绍TopKRecord的结构设计。

TopKRecord结构重要的4个部分如下:

● 依然有一个小根堆heap。小根堆里装的依然是原问题中Node类的实例,每个实例表示一个字符串及其词频统计的信息。小根堆里装的都是加入过的所有字符串中词频最高的Top K 。heap的大小在初始化时就确定,是Node类型的数组结构,数组的总大小为k

● 整型变量index。表示如果新的Node类的实例想加入到heap,该放在heap的哪个位置。

● 哈希表strNodeMap。key为字符串类型,表示加入的某种字符串。value为Node类型。strNodeMap上的每条信息表示一种字符串及其所对应的Node实例。

● 哈希表nodeIndexMap,key为Node类型,表示一种字符串及其词频信息。value为整型,表示key这个Node类的实例对应到heap上的位置,如果不在heap上,为-1。

关于strNodeMap和nodeIndexMap的说明如下:

比如,"A"这个字符串加入了10次,那么在strNodeMap表中就会有类似这样的记录(key="A",value=("A",10)),value是一个Node类的实例。如果"A"加入的次数很多,使"A"成为加入的所有字符中词频最高的Top K 之一,那么"A"应该在堆上。假设"A"在堆上的位置为5,那么在nodeIndexMap表中就会有类似这样的记录(key=("A",10),value=5)。如果"A"加入的次数不算多,没有使"A"成为加入的所有字符中词频最高的Top K 之一,那么"A"不在堆上,则在nodeIndexMap表中就会有这样的记录(key=("A",10),value=-1)。strNodeMap是字符串及其所对应的Node实例信息的哈希表,nodeIndexMap是字符串的Node实例信息对应在堆中(heap)位置的哈希表。

以下为加入一个字符串时,TopKRecord类中add方法所做的事情:

1.当加入一个字符串时,假设为str。首先在strNodeMap中查询str之前出现的词频,如果查不到,说明str为第一次出现,在strNodeMap中加入一条记录(key=str,value=(str,1))。如果可以查到,说明str之前出现过,此时需要把str的词频增加,假设之前出现过10次,那么查到的记录为(key=str,value=(str,10)),变更为(key=str,value=(str,11))。

2.建立或调整完str的Node实例信息之后,需要考虑这个Node的实例信息是否已经在堆上,通过查询nodeIndexMap表可以得到Node的实例对应的堆上的位置,如果没有或者查询结果为-1,表示不在堆上,否则表示在堆上,位置记为pos。

1)如果在堆上,说明str词频没增加之前就是Top K 之一,现在词频既然增加了,就需要考虑调整str对应的Node实例信息在堆中的位置,从pos位置开始向下调整小根堆即可(heapify)。特别注意:为了保证nodeIndexMap表中位置信息的始终准确,调整堆时,每一次两个堆元素(Node实例)之间的位置交换都要更新在nodeIndexMap表中的位置。比如,在堆上的一个Node实例("A",10)原来在2位置,在nodeIndexMap表中的信息为(key=("A",10),value=2)。现在又加入了一个"A",词频增加,信息当然要变成(key=("A",11),value=2)。然后从位置2调整堆时,发现这个实例需要和自己的一个孩子实例("B",10)交换,假设这个Node实例的位置是6,即在nodeIndexMap表中记录为(key=("B",10),value=6)。那么在彼此交换位置之后,在heap数组中的两个实例当然很容易互换位置,但同时在nodeIndexMap上各自的信息也要变更,分别变更为(key=("A",11),value=6),(key=("B",10),value=2)。也就是说,任何Node实例在堆中的位置调整都要改相应的nodeIndexMap表信息,这也是整个TopKRecord结构设计中最关键的逻辑。

2)如果不在堆中,则看当前的小根堆是否已满(index? =k)。如果没有满(index<k),那么把str的Node实例放入堆底(heap的index位置),自然也要在nodeIndexMap表中加上位置信息。然后做堆在插入时的调整(heapInsert),同样,任何交换都要改nodeIndexMap表。如果已满(index==k),则看str的词频是否大于小根堆堆顶的词频(heap[0]),如果不大于,则什么都不做。如果大于堆顶的词频,把str的Node实例设为新的堆顶,然后从位置0开始向下调整堆(heapify),同样,任何堆中位置的变更都要改nodeIndexMap表。

3.过程结束。

在加入新的字符串时,都可能会调整堆,而堆最大也仅是k 的大小,所以add方法时间复杂度为O (logK )。随时更新的小根堆就是每时每刻的Top K ,打印时又没有排序的要求,所以printTopK方法直接依次打印小根堆数组即可,时间复杂度为O (K )。

TopKRecord类的全部实现请参看如下代码:

          public class Node {

                  public String str;

                  public int times;

                  public Node(String s, int t) {
                          str = s;
                          times = t;
                  }
          }

          public class TopKRecord {
                  private Node[] heap;
                  private int index;
                  private HashMap<String, Node> strNodeMap;
                  private HashMap<Node, Integer> nodeIndexMap;

                  public TopKRecord(int size) {
                          heap = new Node[size];
                          index = 0;
                          strNodeMap = new HashMap<String, Node>();
                          nodeIndexMap = new HashMap<Node, Integer>();
                  }

                  public void add(String str) {
                          Node curNode = null;
                          int preIndex = -1;
                          if (! strNodeMap.containsKey(str)) {
                                  curNode = new Node(str, 1);
                                  strNodeMap.put(str, curNode);
                                  nodeIndexMap.put(curNode, -1);
                          } else {
                                  curNode = strNodeMap.get(str);
                                  curNode.times++;
                                  preIndex = nodeIndexMap.get(curNode);
                          }
                          if (preIndex == -1) {
                                  if (index == heap.length) {
                                          if (heap[0].times < curNode.times) {
                                                  nodeIndexMap.put(heap[0], -1);
                                                  nodeIndexMap.put(curNode, 0);
                                                  heap[0] = curNode;
                                                  heapify(0, index);
                                          }
                                  } else {
                                          nodeIndexMap.put(curNode, index);
                                          heap[index] = curNode;
                                          heapInsert(index++);
                                  }
                          } else {
                                  heapify(preIndex, index);
                          }
                  }

                  public void printTopK() {
                          System.out.println("TOP: ");
                          for (int i = 0; i ! = heap.length; i++) {
                                  if (heap[i] == null) {
                                          break;
                                  }
                                  System.out.print("Str: " + heap[i].str);
                                  System.out.println(" Times: " + heap[i].times);
                          }
                  }

                  private void heapInsert(int index) {
                          while (index ! = 0) {
                                  int parent = (index - 1) / 2;
                                  if (heap[index].times < heap[parent].times) {
                                          swap(parent, index);
                                          index = parent;
                                  } else {
                                          break;
                                  }
                          }
                  }

                  private void heapify(int index, int heapSize) {
                          int l = index * 2 + 1;
                          int r = index * 2 + 2;
                          int smallest = index;
                          while (l < heapSize) {
                              if (heap[l].times < heap[index].times) {
                                  smallest = l;
                              }
                              if (r < heapSize && heap[r].times < heap[smallest].times) {
                                  smallest = r;
                              }
                              if (smallest ! = index) {
                                  swap(smallest, index);
                              } else {
                                  break;
                              }
                              index = smallest;
                              l = index * 2 + 1;
                              r = index * 2 + 2;
                          }
                  }

                  private void swap(int index1, int index2) {
                          nodeIndexMap.put(heap[index1], index2);
                          nodeIndexMap.put(heap[index2], index1);
                          Node tmp = heap[index1];
                          heap[index1] = heap[index2];
                          heap[index2] = tmp;
                  }
          }

Manacher算法

【题目】

给定一个字符串str,返回str中最长回文子串的长度。

【举例】

str="123",其中的最长回文子串为"1"、"2"或者"3",所以返回1。

str="abc1234321ab",其中的最长回文子串为"1234321",所以返回7。

【进阶题目】

给定一个字符串str,想通过添加字符的方式使得str整体都变成回文字符串,但要求只能在str的末尾添加字符,请返回在str后面添加的最短字符串。

【举例】

str="12"。在末尾添加"1"之后,str变为"121",是回文串。在末尾添加"21"之后,str变为"1221",也是回文串。但"1"是所有添加方案中最短的,所以返回"1"。

【要求】

如果str的长度为N ,解决原问题和进阶问题的时间复杂度都达到O (N )。

【难度】

将 ★★★★

【解答】

本文的重点是介绍Manacher算法,该算法是由Glenn Manacher于1975年首次发明的。Manacher算法解决的问题是在线性时间内找到一个字符串的最长回文子串,比起能够解决该问题的其他算法,Manacher算法算比较好理解和实现的。

先来说一个很好理解的方法。从左到右遍历字符串,遍历到每个字符的时候,都看看以这个字符作为中心能够产生多大的回文字符串。比如str="abacaba",以str[0]=='a’为中心的回文字符串最大长度为1,以str[1]=='b’为中心的回文字符串最大长度为3,……其中最大的回文子串是以str[3]=='c’为中心的时候。这种方法非常容易理解,只要解决奇回文和偶回文寻找方式的不同就可以。比如"121"是奇回文,有确定的轴’2'。"1221"是偶回文,没有确定的轴,回文的虚轴在"22"中间。但是这种方法有明显的问题,之前遍历过的字符完全无法指导后面遍历的过程,也就是对每个字符来说都是从自己的位置出发,往左右两个方向扩出去检查。这样,对每个字符来说,往外扩的代价都是一个级别的。举一个极端的例子"aaaaaaaaaaaaaaa",对每一个’a’来讲,都是扩到边界才停止。所以每一个字符扩出去检查的代价都是O (N ),所以总的时间复杂度为O (N 2 )。Manacher算法可以做到O (N )的时间复杂度,精髓是之前字符的“扩”过程,可以指导后面字符的“扩”过程,使得每次的“扩”过程不都是从无开始。以下是Manacher算法解决原问题的过程:

1.因为奇回文和偶回文在判断时比较麻烦,所以对str进行处理,把每个字符开头、结尾和中间插入一个特殊字符’#’来得到一个新的字符串数组。比如str="bcbaa",处理后为"#b#c#b#a#a#",然后从每个字符左右扩出去的方式找最大回文子串就方便多了。对奇回文来说,不这么处理也能通过扩的方式找到,比如"bcb",从’c’开始向左右两侧扩出去能找到最大回文。处理后为"#b#c#b#",从’c’开始向左右两侧扩出去依然能找到最大回文。对偶回文来说,不处理而直接通过扩的方式是找不到的,比如"aa",因为没有确定的轴,但是处理后为"#a#a#",就可以通过从中间的’#’扩出去的方式找到最大回文。所以通过这样的处理方式,最大回文子串无论是偶回文还是奇回文,都可以通过统一的“扩”过程找到,解决了差异性的问题。同时要说的是,这个特殊字符是什么无所谓,甚至可以是字符串中出现的字符,也不会影响最终的结果,就是一个纯辅助的作用。

具体的处理过程请参看如下代码中的manacherString方法。

          public char[] manacherString(String str) {
                  char[] charArr = str.toCharArray();
                  char[] res = new char[str.length() * 2 + 1];
                  int index = 0;
                  for (int i = 0; i ! = res.length; i++) {
                          res[i] = (i & 1) == 0 ? ' #' : charArr[index++];
                  }
                  return res;
          }

2.假设str处理之后的字符串记为charArr。对每个字符(包括特殊字符)都进行“优化后”的扩过程。在介绍“优化后”的扩过程之前,先解释如下三个辅助变量的意义。

● 数组pArr。长度与charArr长度一样。pArr[i]的意义是以i 位置上的字符(charArr[i])作为回文中心的情况下,扩出去得到的最大回文半径是多少。举个例子来说明,对"#c#a#b#a#c#"来说,pArr[0..9]为[1,2,1,2,1,6,1,2,1,2,1]。我们的整个过程就是在从左到右遍历的过程中,依次计算每个位置的最大回文半径值。

● 整数pR。这个变量的意义是之前遍历的所有字符的所有回文半径中,最右即将到达的位置。还是以"#c#a#b#a#c#"为例来说,还没遍历之前pR,初始设置为-1。charArr[0]==' #’的回文半径为1,所以目前回文半径向右只能扩到位置0,回文半径最右即将到达的位置变为1(pR=1)。charArr[1]==' #’的回文半径为2,此时所有的回文半径向右能扩到位置2,所以回文半径最右即将到达的位置变为3(pR=3)。charArr[2]==' #’的回文半径为1,所以位置2向右只能扩到位置2,回文半径最右即将到达的位置不变,仍是3(pR=3)。charArr[3]=='a’的回文半径为2,所以位置3向右能扩到位置4,所以回文半径最右即将到达的位置变为5(pR=5)。charArr[4]==' #’的回文半径为1,所以位置4向右只能扩到位置4,回文半径最右即将到达的位置不变仍是5(pR=5)。charArr[5]=='b’的回文半径为6,所以位置4向右能扩到位置10,回文半径最右即将到达的位置变为11(pR=11)。此时已经到达整个字符数组的结尾,所以之后的过程中pR将不再变化。换句话说,pR就是遍历过的所有字符中向右扩出来的最大右边界。只要右边界更往右,pR就更新。

● 整数index。这个变量表示最近一次pR更新时,那个回文中心的位置。以刚刚的例子来说,遍历到charArr[0]时pR更新,index就更新为0。遍历到charArr[1]时pR更新,index就更新为1……遍历到charArr[5]时pR更新,index就更新为5。之后的过程中,pR将不再更新,所以index将一直是5。

3.只要能够从左到右依次算出数组pArr每个位置的值,最大的那个值实际上就是处理后的charArr中最大的回文半径,根据最大的回文半径,再对应回原字符串的话,整个问题就解决了。步骤3就是从左到右依次计算出pArr数组每个位置的值的过程。

1)假设现在计算到位置i 的字符charArr[i],在i 之前位置的计算过程中,都会不断地更新pR和index的值,即位置i 之前的index这个回文中心扩出了一个目前最右的回文边界pR。

2)如果pR-1位置没有包住当前的i 位置。比如"#c#a#b#a#c#",计算到charArr[1]=='c'时,pR为1。也就是说,右边界在1位置,1位置为最右回文半径即将到达但还没有达到的位置,所以当前的pR-1位置没有包住当前的i 位置。此时和普通做法一样,从i 位置字符开始,向左右两侧扩出去检查,此时的“扩”过程没有获得加速。

3)如果pR-1位置包住了当前的i 位置。比如"#c#a#b#a#c#",计算到charArr[6...10]时,pR都为11,此时pR-1包住了位置6~10。这种情况下,检查过程是可以获得优化的,这也是manacher算法的核心内容,如图9-14所示。

image

图9-14

在图9-14中,位置i 是要计算回文半径(pArr[i])的位置。pR-1位置此时是包住位置i 的。同时根据index的定义,index是pR更新时那个回文中心的位置,所以如果pR-1位置以index为中心对称,即图9-14中的“左大”位置,那么从“左大”位置到pR-1位置一定是以index为中心的回文串,我们把这个回文串叫作大回文串,同时把pR-1位置称为“右大”位置。既然回文半径数组pArr是从左到右计算的,所以位置i 之前的所有位置都已经算过回文半径。假设位置i 以index为中心向左对称过去的位置为i' ,那么位置i’ 的回文半径也是计算过的。那么以i’ 为中心的最大回文串大小(pArr[i' ])必然只有三种情况,我们依次来分析一下,假设以i’ 为中心的最大回文串的左边界和右边界分别记为“左小”和“右小”。

情况一,“左小”和“右小”完全在“左大”和“右大”内部,即以i’ 为中心的最大回文串完全在以index为中心的最大回文串的内部,如图9-15所示。

image

图9-15

图9-15中,a’是“左小”位置的前一个字符,b’是“右小”位置的后一个字符,b是b'以index为中心的对称字符,a是a’以index为中心的对称字符。“左小’”是“左小”以index为中心的对称位置,“右小’”是“右小”以index为中心的对称位置。如果处在情况一下,那么以位置i 为中心的最大回文串可以直接确定,就是从“右小’”到“左小’”这一段。这是什么原因呢?首先,“左小”到“右小”这一段如果以index为回文中心,对应过去就是“右小’”到“左小’”这一段,那么“右小’”到“左小’”这一段就完全是“左小”到“右小”这一段的逆序。同时有“左小”到“右小”这一段又是回文串(以i’ 为回文中心),所以“右小’”到“左小’”这一段一定也是回文串,也就是说,以位置i 为中心的最大回文串起码是“右小’”到“左小’”这一段。另外,以位置i’ 为中心的最大回文串只是“右小’”到“左小’”这一段,说明a' ! =b'。那么与a’相等的a也必然不等于与b’相等的b,既然a! =b,说明以位置i 为中心的最大回文串就是“右小’”到“左小’”这一段,而不会扩得更大。

情况一举例如图9-16所示。

image

图9-16

情况二,“左小”和“右小”的左侧部分在“左大”和“右大”的外部,如图9-17所示。

image

图9-17

图9-17中,a是“左大”位置的前一个字符,d是“右大”位置的后一个字符,“左大’”是“左大”以位置i’ 为中心的对称位置,“右大’”是“右大”以位置i 为中心的对称位置,b是“左大’”位置的后一个字符,c是“右大’”位置的前一个字符。如果处在情况二下,那么以位置i 为中心的最大回文串可以直接确定,就是从“右大’”到“右大”这一段。这是什么原因呢?首先“左大”到“左大’”这一段和“右大’”到“右大”这一段是关于index对称的,所以“右大’”到“右大”这一段是“左大”到“左大’”这一段的逆序。同时“左小”到“右小”这一段是回文串(以i’ 位置为中心),那么“左大”到“左大’”这一段也是回文串,所以“左大”到“左大’”这一段的逆序也是回文串,所以“右大’”到“右大”这一段一定是回文串。也就是说,以位置i 为中心的最大回文串起码是“右大’”到“右大”这一段。另外,“左小”到“右小”这一段的是回文串,说明a==b,b和c关于index对称说明b==c,“左大”到“右大”这一段没有扩得更大,说明a! =d,所以d! =c。说明以位置i 为中心的最大回文串就是“右大’”到“右大”这一段,而不会扩得更大。

情况二举例如图9-18所示。

image

图9-18

情况三,“左小”和“左大”是同一个位置,即以i’ 为中心的最大回文串压在了以index为中心的最大回文串的边界上,如图9-19所示。

image

图9-19

图9-19中,“左大”与“左小”的位置重叠,“右小’”是“右小”位置以index为中心的对称位置,“右大’”是“右大”位置以i 为中心的对称位置,可以很容易的证明“右小’”和“右大’”位置也重叠。如果处在情况三下,那么以位置i 为中心的最大回文串起码是“右大’”和“右大”这一段,但可能会扩得更大。因为“右大’”和“右大”这一段是“左小”和“右小”这一段以index为中心对称过去的,所以两段互为逆序关系,同时“左小”和“右小”这一段又是回文串,所以“右大’”和“右大”这一段肯定是回文串,但以位置i 为中心的最大回文串是可能扩得更大的。比如图9-20的例子。

image

图9-20

图9-20中,以位置i 为中心的最大回文串起码是“右大’”到“右大”这一段,但可以扩得更大。说明在情况三下,扩出去的过程可以得到优化,但还是无法避免扩出去的检查。

4.按照步骤3的逻辑从左到右计算出pArr数组,计算完成后再遍历一遍pArr数组,找出最大的回文半径,假设位置i 的回文半径最大,即pArr[i]==max。但max只是charArr的最大回文半径,还得对应回原来的字符串,求出最大回文半径的长度(其实就是max-1)。比如原字符串为"121",处理成charArr之后为"#1#2#1#"。在charArr中位置3的回文半径最大,最大值为4(即pArr[3]==4),对应原字符串的最大回文子串长度为4-1=3。

Manacher算法时间复杂度是O (N )的证明。虽然我们可以很明显地看到Manacher算法与普通方法相比,在扩出去检查这一行为上有明显的优化,但如何证明该算法的时间复杂度就是O (N )呢?关键之处在于估算扩出去检查这一行为发生的数量。原字符串在处理后的长度由N 变为2N ,从步骤3的主要逻辑来看,要么在计算一个位置的回文半径时完全不需要扩出去检查,比如,步骤3的中3)介绍的情况一和情况二,都可以直接获得位置i 的回文半径长度;要么每一次扩出去检查都会导致pR变量的更新,比如步骤3中的2)和3)介绍的情况三,扩出去检查时都让回文半径到达更右的位置,当然会使pR更新。然而pR最多是从-1增加到2N (右边界),并且从来不减小,所以扩出去检查的次数就是O (N )的级别。所以Manacher算法时间复杂度是O (N )。具体请参看如下代码中的maxLcpsLength方法。

          public int maxLcpsLength(String str) {
                  if (str == null || str.length() == 0) {
                          return 0;
                  }
                  char[] charArr = manacherString(str);
                  int[] pArr = new int[charArr.length];
                  int index = -1;
                  int pR = -1;
                  int max = Integer.MIN_VALUE;
                  for (int i = 0; i ! = charArr.length; i++) {
                          pArr[i] = pR > i ? Math.min(pArr[2 * index - i], pR - i) : 1;
                          while (i + pArr[i] < charArr.length && i - pArr[i] > -1) {
                                  if (charArr[i + pArr[i]] == charArr[i - pArr[i]])
                                          pArr[i]++;
                                  else {
                                          break;
                                  }
                          }
                          if (i + pArr[i] > pR) {
                                  pR = i + pArr[i];
                                  index = i;
                          }
                          max = Math.max(max, pArr[i]);
                  }
                  return max - 1;
          }

进阶问题。在字符串的最后添加最少字符,使整个字符串都成为回文串,其实就是查找在必须包含最后一个字符的情况下,最长的回文子串是什么。那么之前不是最长回文子串的部分逆序过来,就是应该添加的部分。比如"abcd123321",在必须包含最后一个字符的情况下,最长的回文子串是"123321",之前不是最长回文子串的部分是"abcd",所以末尾应该添加的部分就是"dcba"。那么只要把manacher算法稍作修改就可以。具体改成:从左到右计算回文半径时,关注回文半径最右即将到达的位置(pR),一旦发现已经到达最后(pR==charArr.length),说明必须包含最后一个字符的最长回文半径已经找到,直接退出检查过程,返回该添加的字符串即可。具体过程参看如下代码中的shortestEnd方法。

          public String shortestEnd(String str) {
                  if (str == null || str.length() == 0) {
                          return null;
                  }
                  char[] charArr = manacherString(str);
                  int[] pArr = new int[charArr.length];
                  int index = -1;
                  int pR = -1;
                  int maxContainsEnd = -1;
                  for (int i = 0; i ! = charArr.length; i++) {
                          pArr[i] = pR > i ? Math.min(pArr[2 * index - i], pR - i) : 1;
                          while (i + pArr[i] < charArr.length && i - pArr[i] > -1) {
                                  if (charArr[i + pArr[i]] == charArr[i - pArr[i]])
                                          pArr[i]++;
                                  else {
                                          break;
                                  }
                          }
                          if (i + pArr[i] > pR) {
                                  pR = i + pArr[i];
                                  index = i;
                          }
                          if (pR == charArr.length) {
                                  maxContainsEnd = pArr[i];
                                  break;
                          }
                  }
                  char[] res = new char[str.length() - maxContainsEnd + 1];
                  for (int i = 0; i < res.length; i++) {
                          res[res.length - 1 - i] = charArr[i * 2 + 1];
                  }
                  return String.valueOf(res);
          }

KMP算法

【题目】

给定两个字符串str和match,长度分别为NM 。实现一个算法,如果字符串str中含有子串match,则返回match在str中的开始位置,不含有则返回-1。

【举例】

str="acbc",match="bc",返回2。

str="acbc",match="bcc",返回-1。

【要求】

如果match的长度大于str的长度(M >N ),str必然不会含有match,可直接返回-1。但如果NM ,要求算法复杂度为O (N )。

【难度】

将 ★★★★

【解答】

本文是想重点介绍一下KMP算法,该算法是由Donald Knuth、Vaughan Pratt和James H. Morris于1977年联合发明的。在介绍KMP算法之前,我们先来看普通解法怎么做。

最普通的解法是从左到右遍历str的每一个字符,然后看如果以当前字符作为第一个字符出发是否匹配出match。比如str="aaaaaaaaaaaaaaaaab",match="aaaab"。从str[0]出发,开始匹配,匹配到str[4]=='a’时发现和match[4]=='b’不一样,所以匹配失败,说明从str[0]出发是不行的。从str[1]出发,开始匹配,匹配到str[5]=='a’时发现和match[4]=='b’不一样,所以匹配失败,说明从str[1]出发是不行的。从str[2..12]出发,都会一直失败。从str[13]出发,开始匹配,匹配到str[17]=='b’时发现和match[4]=='b’一样,match已经全部匹配完,说明匹配成功,返回13。普通解法的时间复杂度较高,从每个字符出发时,匹配的代价都可能是O (M ),那么一共有N 个字符,所以整体的时间复杂度为O (N ×M )。普通解法的时间复杂度这么高,是因为每次遍历到一个字符时,检查工作相当于从无开始,之前的遍历检查不能优化当前的遍历检查。

下面介绍KMP算法是如何快速解决字符串匹配问题的。

1.首先生成match字符串的nextArr数组,这个数组的长度与match字符串的长度一样,nextArr[i]的含义是在match[i]之前的字符串match[0..i-1]中,必须以match[i-1]结尾的后缀子串(不能包含match[0])与必须以match[0]开头的前缀子串(不能包含match[i-1])最大匹配长度是多少。这个长度就是nextArr[i]的值。比如,match="aaaab"字符串,nextArr[4]的值该是多少呢?match[4]=='b',所以它之前的字符串为"aaaa",根据定义这个字符串的后缀子串和前缀子串最大匹配为"aaa"。也就是当后缀子串等于match[1..3]=="aaa",前缀子串等于match[0..2]=="aaa"时,这时前缀和后缀不仅相等,而且是所有前缀和后缀的可能性中最大的匹配。所以nextArr[4]的值等于3。再如,match="abc1abc1"字符串,nextArr[7]的值该是多少呢?match[7]=='1',所以它之前的字符串为"abc1abc",根据定义这个字符串的后缀子串和前缀子串最大匹配为"abc"。也就是当后缀子串等于match[4..6]=="abc",前缀子串等于match[0..2]=="abc"时,这时前缀和后缀不仅相等,而且是所有前缀和后缀的可能性中最大的匹配。所以nextArr[7]的值等于3。关于如何快速得到nextArr数组的问题,我们在把KMP算法的大概过程介绍完毕之后再详细说明,接下来先看如果有了match的nextArr数组,如何加速进行str和match的匹配过程。

2.假设从str[i]字符出发时,匹配到j位置的字符发现与match中的字符不一致。也就是说,str[i]与match[0]一样,并且从这个位置开始一直可以匹配,即str[i..j-1]与match[0..j-i-1]一样,直到发现str[j]! =match[j-i],匹配停止。如图9-21所示。

image

图9-21

因为现在已经有了match字符串的nextArr数组,nextArr[j-i]的值表示match[0..j-i-1]这一段字符串前缀与后缀的最长匹配。假设前缀是图9-22中的a区域这一段,后缀是图9-22中的b区域这一段,再假设a区域的下一个字符为match[k],如图9-22所示。

image

图9-22

那么下一次的匹配检查不再像普通解法那样退回到str[i+1]重新开始与match[0]的匹配过程,而是直接让str[j]与match[k]进行匹配检查,如图9-23所示。

image

图9-23

在图9-23中,在str中要匹配的位置仍是j,而不进行退回。对match来说,相当于向右滑动,让match[k]滑动到与str[j]同一个位置上,然后进行后续的匹配检查。普通解法str要退回到i +1位置,然后让str[i+1]与match[0]进行匹配,而我们的解法在匹配的过程中一直进行这样的滑动匹配的过程,直到在str的某一个位置把match完全匹配完,就说明str中有match。如果match滑到最后也没匹配出来,就说明str中没有match。那么为什么这样做是正确的呢?如图9-24所示。

image

图9-24

在图9-24中,匹配到A字符和B字符才发生的不匹配,所以c区域等于b区域,b区域又与a区域相等(因为nextArr的含义如此),所以c区域和a区域是不需要检查的,必然会相等。所以直接把字符C滑到字符A的位置开始检查即可。其实这个过程相当于是从str的c区域中第一个字符重新开始的匹配过程(c区域的第一个字符和match[0]匹配,并往右的过程),只不过因为c区域与a区域一定相等,所以省去了这个区域的匹配检查而已,直接从字符A和字符C往后继续匹配检查。读者看到这里肯定会问,为什么开始的字符从str[i]直接跳到c区域的第一个字符呢?中间的这一段为什么是“不用检查”的区域呢?因为在这个区域中,从任何一个字符出发都肯定匹配不出match,下面还是图解来解释这一点。如图9-25所示。

image

图9-25

在图9-25中,假设d区域开始的字符是“不用检查”区域的其中一个位置,如果从这个位置开始能够匹配出match,那么毫无疑问,起码整个d区域应该和从match[0]开始的e区域匹配,即d区域与e区域长度一样,且两个区域的字符都相等。同时我们注意到,d区域比c区域大,e区域比a区域大。如果这种情况发生了,假设d区域对应到match字符串中是d’区域,也就是字符B之前的字符串的后缀,而e区域本身就是match的前缀,所以对match来说,相当于找到了B这个字符之前的字符串(match[0..j-i-1])的一个更大的前缀与后缀匹配,一个比a区域和b区域更大的前缀后缀匹配,e区域和d’区域。这与nextArr[j-i]的值是自相矛盾的,因为nextArr[j-i]的值代表的含义就是match[0..j-i-1]字符串上最大的前缀与后缀匹配长度。所以如果match字符串的nextArr数组计算正确,这种情况绝不会发生。也就是说,根本不会有更大的d’区域和e区域,所以d区域与e区域也必然不会相等。

匹配过程分析完毕,我们知道,str中匹配的位置是不退回的,match则一直向右滑动,如果在str中的某个位置完全匹配出match,整个过程停止。否则match滑到str的最右侧过程也停止,所以滑动的长度最大为N ,所以时间复杂度为O (N )。匹配的全部过程参看如下代码中的getIndexOf方法。

          public int getIndexOf(String s, String m) {
              if (s == null || m == null || m.length() < 1 || s.length() < m.length()) {
                  return -1;
              }
              char[] ss = s.toCharArray();
              char[] ms = m.toCharArray();
              int si = 0;
              int mi = 0;
              int[] next = getNextArray(ms);
              while (si < ss.length && mi < ms.length) {
                  if (ss[si] == ms[mi]) {
                          si++;
                          mi++;
                  } else if (next[mi] == -1) {
                          si++;
                  } else {
                          mi = next[mi];
                  }
              }
              return mi == ms.length ? si - mi : -1;
          }

最后需要解释如何快速得到match字符串的nextArr数组,并且要证明得到nextArr数组的时间复杂度为O (M )。对match[0]来说,在它之前没有字符,所以nextArr[0]规定为-1。对match[1]来说,在它之前有match[0],但nextArr数组的定义要求任何子串的后缀不能包括第一个字符(match[0]),所以match[1]之前的字符串只有长度为0的后缀字符串,所以nextArr[1]为0。之后对match[i](i>1)来说,求解过程如下:

1.因为是左到右依次求解nextArr,所以在求解nextArr[i]时,nextArr[0..i-1]的值都已经求出。假设match[i]字符为图9-26中的A字符,match[i-1]为图9-26中的B字符,如图9-26所示。

image

图9-26

通过nextArr[i-1]的值可以知道B字符前的字符串的最长前缀与后缀匹配区域,图9-26中的l 区域为最长匹配的前缀子串,k 区域为最长匹配的后缀子串,图9-26中字符C为l 区域之后的字符。然后看字符C与字符B是否相等。

2.如果字符C与字符B相等,那么A字符之前的字符串的最长前缀与后缀匹配区域就可以确定,前缀子串为l 区域+C字符,后缀子串为k 区域+B字符,即nextArr[i]=nextArr[i-1]+1。

3.如果字符C与字符B不相等,就看字符C之前的前缀和后缀匹配情况,假设字符C是第cn个字符(match[cn]),那么nextArr[cn]就是其最长前缀和后缀匹配长度,如图9-27所示。

image

图9-27

在图9-27中,m 区域和n 区域分别是字符C之前的字符串的最长匹配的后缀与前缀区域,这是通过nextArr[cn]的值确定的,当然两个区域是相等的,m’ 区域为k 区域最右的区域且长度与m 区域一样,因为k 区域和l 区域是相等的,所以m 区域和m’ 区域也相等,字符D为n 区域之后的一个字符,接下来比较字符D是否与字符B相等。

1)如果相等,A字符之前的字符串的最长前缀与后缀匹配区域就可以确定,前缀子串为n 区域+D字符,后缀子串为m’区域+B字符,则令nextArr[i]=nextArr[cn]+1。

2)如果不等,继续往前跳到字符D,之后的过程与跳到字符C类似,一直进行这样的跳过程,跳的每一步都会有一个新的字符和B比较(就像C字符和D字符一样),只要有相等的情况,nextArr[i]的值就能确定。

4.如果向前跳到最左位置(即match[0]的位置),此时nextArr[0]==-1,说明字符A之前的字符串不存在前缀和后缀匹配的情况,则令nextArr[i]=0。用这种不断向前跳的方式可以算出正确的nextArr[i]值的原因还是因为每跳到一个位置cn,nextArr[cn]的意义就表示它之前字符串的最大匹配长度。求解nextArr数组的具体过程请参看如下代码中的getNextArray方法,先看代码,然后分析这个过程的时间复杂度为什么为O (M )。

          public int[] getNextArray(char[] ms) {
                  if (ms.length == 1) {
                          return new int[] { -1 };
                  }
                  int[] next = new int[ms.length];
                  next[0] = -1;
                  next[1] = 0;
                  int pos = 2;
                  int cn = 0;
                  while (pos < next.length) {
                          if (ms[pos - 1] == ms[cn]) {
                                  next[pos++] = ++cn;
                          } else if (cn > 0) {
                                  cn = next[cn];
                          } else {
                                  next[pos++] = 0;
                          }
                  }
                  return next;
          }

getNextArray方法中的while循环就是求解nextArr数组的过程,现在证明这个循环发生的次数不会超过2M 这个数量。先来看两个量,一个为pos量,一个为(pos-cn)的量。对pos量来说,从2开始又必然不会大于match的长度,即pos<M。对(pos-cn)量来说,pos最大为M -1,cn最小为0,所以(pos-cn)<=M。

循环的第一个逻辑分支会让pos的值增加,(pos-cn)的值不变。循环的第二个逻辑分支为cn向左跳的过程,所以会让cn减小,pos值在这个分支中不变,所以(pos-cn)的值会增加。循环的第三个逻辑分支会让pos的值增加,(pos-cn)的值也增加。如下表所示:

Pos pos-cn
循环的第一个逻辑分支 增加 不变
循环的第二个逻辑分支 不变 增加
循环的第三个逻辑分支 增加 增加

因为pos+(pos-cn)<2M ,又有上表的关系,所以循环发生的总体次数小于pos量和(pos-cn)量的增加次数,也必然小于2M ,证明完毕。

所以整个KMP算法的复杂度为OM )(求解nextArr数组的过程)+ON )(匹配的过程),因为有NM ,所以时间复杂度为ON )。

丢棋子问题

【题目】

一座大楼有0~N 层,地面算作第0层,最高的一层为第N 层。已知棋子从第0层掉落肯定不会摔碎,从第i 层掉落可能会摔碎,也可能不会摔碎(1≤iN )。给定整数N 作为楼层数,再给定整数K 作为棋子数,返回如果想找到棋子不会摔碎的最高层数,即使在最差的情况下扔的最少次数。一次只能扔一个棋子。

【举例】

N =10,K =1。

返回10。因为只有1棵棋子,所以不得不从第1层开始一直试到第10层,在最差的情况下,即第10层是不会摔坏的最高层,最少也要扔10次。

N =3,K =2。

返回2。先在2层扔1棵棋子,如果碎了,试第1层,如果没碎,试第3层。

N =105,K =2

返回14。

第一个棋子先在14层扔,碎了则用仅存的一个棋子试1~13。

若没碎,第一个棋子继续在27层扔,碎了则用仅存的一个棋子试15~26。

若没碎,第一个棋子继续在39层扔,碎了则用仅存的一个棋子试28~38。

若没碎,第一个棋子继续在50层扔,碎了则用仅存的一个棋子试40~49。

若没碎,第一个棋子继续在60层扔,碎了则用仅存的一个棋子试51~59。

若没碎,第一个棋子继续在69层扔,碎了则用仅存的一个棋子试61~68。

若没碎,第一个棋子继续在77层扔,碎了则用仅存的一个棋子试70~76。

若没碎,第一个棋子继续在84层扔,碎了则用仅存的一个棋子试78~83。

若没碎,第一个棋子继续在90层扔,碎了则用仅存的一个棋子试85~89。

若没碎,第一个棋子继续在95层扔,碎了则用仅存的一个棋子试91~94。

若没碎,第一个棋子继续在99层扔,碎了则用仅存的一个棋子试96~98。

若没碎,第一个棋子继续在102层扔,碎了则用仅存的一个棋子试100、101。

若没碎,第一个棋子继续在104层扔,碎了则用仅存的一个棋子试103。

若没碎,第一个棋子继续在105层扔,若到这一步还没碎,那么105便是结果。

【难度】

校 ★★★☆

【解答】

方法一。假设P (NK )的返回值是N 层楼有K 个棋子在最差情况下扔的最少次数。

1.如果N==0,也就是楼层只有第0层,那不用试,肯定不碎,即P (0,K )=0。

2.如果K==1,也就是楼层有N 层,但只有1个棋子了,这时只能从第1层开始试,一直试到第N 层,即P (N ,1)=N

3.以上两种情况较为特殊,对一般情况(N >0,K >1),我们需要考虑第1个棋子从哪层楼开始扔一次,如果第1个棋子从第i 层开始扔,有以下两种情况:

1)碎了。那么可以知道,没有必要去试第i 层以上的楼层,接下来的问题就变成了还剩下i -1层楼,还剩下K -1个棋子,所以总步数为1+P(i-1,K-1)。

2)没碎。那么可以知道,没有必要去试第i 层以下的楼层,接下来的问题就变成了还剩下N -i 层楼,仍有K 个棋子,所以总步数为1+P(N-i,K)。

根据题意,在1)和2)中哪个是最差的情况,最后的取值就应该来自哪个,所以最后取值为max{ P(i-1,K-1),P(N-i,K) } + 1。那么i 可以选择哪些值呢?从1到N 都可以选择,这就是说,第1个棋子丢在哪里呢?从第1层到第N 层都可以试试,那么在这么多尝试中,我们应该选择哪个尝试呢?应该选择最终步数最少的那种情况。所以,P(N,K)=min{max{P(i-1,K-1),P(N-i,K)}(1<=i<=N)}+1。具体请参看如下代码中的solution1方法。

          public int solution1(int nLevel, int kChess) {
                  if (nLevel < 1 || kChess < 1) {
                          return 0;
                  }
                  return Process1(nLevel, kChess);
          }

          public int Process1(int nLevel, int kChess) {
                  if (nLevel == 0) {
                          return 0;
                  }
                  if (kChess == 1) {
                          return nLevel;
                  }
                  int min = Integer.MAX_VALUE;
                  for (int i = 1; i ! = nLevel + 1; i++) {
                          if (i == nLevel) {
                          }
                          min = Math.min(min,
                                          Math.max(Process1(i - 1, kChess - 1),
                                                        Process1(nLevel - i, kChess)));
                  }
                  return min + 1;
          }

方法一为暴力递归的方法,如果楼数为N ,将尝试N 种可能。在下一步的递归中,楼数最多为N -1,将尝试N -1种可能,所以时间复杂度为O (N ! ),这个时间复杂度非常高。

方法二,动态规划方法。通过研究如上递归函数我们发现,P (NK )过程依赖P (0..N -1,K -1)和P (0..N -1,K )。所以,若把所有递归过程的返回值看作是一个二维数组,可以用动态规划的方式优化整个递归过程,从而减少递归重复计算,如下所示:

dp[0][K] = 0,dp[N][1] = N,dp[N][K] = min{max{dp[i-1][K-1],dp[N-i][K]} (1<= i<=N) } +1。

动态规划的具体过程参看如下代码中的solution2方法。

          public int solution2(int nLevel, int kChess) {
                  if (nLevel < 1 || kChess < 1) {
                          return 0;
                  }
                  if (kChess == 1) {
                          return nLevel;
                  }
                  int[][] dp = new int[nLevel + 1][kChess + 1];
                  for (int i = 1; i ! = dp.length; i++) {
                          dp[i][1] = i;
                  }
                  for (int i = 1; i ! = dp.length; i++) {
                          for (int j = 2; j ! = dp[0].length; j++) {
                                  int min = Integer.MAX_VALUE;
                                  for (int k = 1; k ! = i + 1; k++) {
                                          min = Math.min(min,
                                                  Math.max(dp[k - 1][j - 1], dp[i - k][j]));
                                  }
                                  dp[i][j] = min + 1;
                          }
                  }
                  return dp[nLevel][kChess];
          }

求每个位置(ab )(即P (ab ))的过程中,需要枚举P (0..a -1,b )和P (0..a -1,b -1),所以每个位置枚举过程的时间复杂度为O (N )。递归过程,即P (ij ),i 从0到Nj 从0到K ,所以用一张N ×K 的二维表可以表示所有递归过程的返回值,即一共有O (N ×K )个位置。所以方法二整体的时间复杂度为O (N 2 ×K )。

方法三,把方法二的额外空间复杂度从使用N ×K 的矩阵,减少为2个长度为N 的数组。分析动态规划的过程我们发现,dp[N][K]只需要它左边的数据dp[0..N-1][K-1],和它上面一排的数据dp[0..N-1][K]。那么在动态规划计算时,就可以用两个数组不停地复用的方式实现,而并不真的需要申请整个二维数组的空间。具体请参看如下代码中的solution3方法。

          public int solution3(int nLevel, int kChess) {
              if (nLevel < 1 || kChess < 1) {
                  return 0;
              }
              if (kChess == 1) {
                  return nLevel;
              }
              int[] preArr = new int[nLevel + 1];
              int[] curArr = new int[nLevel + 1];
              for (int i = 1; i ! = curArr.length; i++) {
                  curArr[i] = i;
              }
              for (int i = 1; i ! = kChess; i++) {
                  int[] tmp = preArr;
                  preArr = curArr;
                  curArr = tmp;
                  for (int j = 1; j ! = curArr.length; j++) {
                      int min = Integer.MAX_VALUE;
                      for (int k = 1; k ! = j + 1; k++) {
                          min = Math.min(min, Math.max(preArr[k - 1], curArr[j - k]));
                      }
                      curArr[j] = min + 1;
                  }
              }
              return curArr[curArr.length - 1];
          }

方法二和方法三的时间复杂度为O (N 2 ×K ),还是很高。但我们注意到,求解动态规划表中的值时,有枚举过程,此时往往可以用“四边形不等式”及其相关猜想来进行优化。

优化的方式——四边形不等式及其相关猜想:

1.如果已经求出了k +1个棋子在解决n 层楼时的最少步骤(dp[n][k+1]),那么如果在这个尝试的过程中发现,第1个棋子扔在m 层楼的这种尝试最终导致了最优解。则在求k 个棋子在解决n 层楼时(dp[n][k]),第1个棋子不需要去尝试m 层以上的楼。

举一个例子,3个棋子在解决100层楼时,第1个棋子扔在37层楼时最终导致了最优解。那么2个棋子在解决100层楼时,第1个棋子不需要去试37层楼以上的楼层。

2.如果已经求出了k 个棋子在解决n 层楼时的最少步骤(dp[n][k]),那么如果在这个尝试的过程中发现,第1个棋子扔在m 层楼的这种尝试最终导致了最优解。则在求k 个棋子在解决n +1层楼时(dp[n+1][k]),不需要去尝试m 层以下的楼。

举一个例子,2个棋子在解决10层楼时,第1个棋子扔在4层楼时最终导致了最优解。那么2个棋子在解决11层楼或更多的层楼时(想象一下100层),第1个棋子也不需要去试1、2、3层楼,只用从4层及其以上的楼层试起。

也就是说,动态规划表中的两个参数分别为棋子数和楼数,楼数变多之后,第1个棋子的尝试楼层的下限是可以确定的。棋子数变少之后,第1个棋子的尝试楼层的上限也是可以确定的。这样就省去了很多无效的枚举过程。证明略。注:“四边形不等式”的相关内容及其证明是相当复杂而烦琐的,本书由于篇幅所限,不再进行进一步的展开,有兴趣的读者可以搜集相关资料进行深入学习。本书是想用本题给面试者提一个醒,如果在面试时发现某一道面试题解法是动态规划,但在计算动态规划二维表的过程中,发现计算每一个值时有类似本题和本书的“画匠问题”、“邮局选址问题”这样的枚举过程,则往往可以通过“四边形不等式”的优化把时间复杂度降一个维度,可以从O (N 2 ×k )或O (N 3 )降到O (N 2 )。具体过程请参看如下代码中的solution4方法。

          public int solution4(int nLevel, int kChess) {
                  if (nLevel < 1 || kChess < 1) {
                          return 0;
                  }
                  if (kChess == 1) {
                          return nLevel;
                  }
                  int[][] dp = new int[nLevel + 1][kChess + 1];
                  for (int i = 1; i ! = dp.length; i++) {
                          dp[i][1] = i;
                  }
                  int[] cands = new int[kChess + 1];
                  for (int i = 1; i ! = dp[0].length; i++) {
                          dp[1][i] = 1;
                          cands[i] = 1;
                  }
                  for (int i = 2; i < nLevel + 1; i++) {
                          for (int j = kChess; j > 1; j--) {
                                  int min = Integer.MAX_VALUE;
                                  int minEnum = cands[j];
                                  int maxEnum = j == kChess ? i / 2 + 1 : cands[j + 1];
                                  for (int k = minEnum; k < maxEnum + 1; k++) {
                                    int cur = Math.max(dp[k - 1][j - 1], dp[i - k][j]);
                                    if (cur <= min) {
                                          min = cur;
                                          cands[j] = k;
                                    }
                                  }
                                  dp[i][j] = min + 1;
                          }
                  }
                  return dp[nLevel][kChess];
          }

最优解。最优解比以上各种方法都要快。首先我们换个角度来看这个问题,以上各种方法解决的问题是N 层楼有K 个棋子最少扔多少次。现在反过来看K 个棋子如果可以扔M 次,最多可以解决多少层楼这个问题。根据上文实现的函数可以生成下表。在这个表中记为map,map[i][j]的意义为i 个棋子扔j 次最多搞定的楼数。

0 1 2 3 4 5 6 7 8 9 10 -> 次数

1 0 1 2 3 4 5 6 7 8 9 10

2 0 1 3 6 10 15 21 28 36 45 55

3 0 1 3 7 14 25 41 63 92 129 175

4 0 1 3 7 15 30 56 98 162 255 385

5 0 1 3 7 15 31 62 119 218 381 637

|

V

棋子数

通过研究map表我们发现,第一横排的值从左到右依次为1,2,3,...,第一纵列都为0,除此之外的其他位置(ij ),都有map[i][j]==map[i][j-1]+map[i-1][j-1]+1。

如何理解这个公式呢?假设i 个棋子扔j 次最多搞定m 层楼,“搞定最多”说明每次扔的位置都是最优的且棋子肯定够用的情况,假设第1个棋子扔在a 层楼是最优的尝试。

1.如果第1个棋子已碎,那就向下,看i -1个棋子扔j -1次最多搞定多少层楼。

2.如果第1个棋子没碎,那就向上,看i 个棋子扔j -1次最多搞定多少层楼。

3.a 层楼本身也是被搞定的1层。

1、2、3的总楼数就是i 个棋子扔j 次最多搞定的楼数,map表的生成过程极为简单,同时数值增长极快。原始问题可以用map表得到很好的解决,比如,想求5个棋子搞定200层楼最少扔多少次的问题。注意到第5行(表示5个棋子的情况)第8列(表示扔8次的情况)对应的值为218,是第5行的所有值中第一次超过200的值,则可以知道5个棋子搞定200层楼最少扔8次。同时在map表中其实9列10列的值也完全可以不需要计算,因为算到第8列(即扔8次)就已经搞定,那么时间复杂度也可以进一步得到优化。另外还有一个特别重要的优化,我们知道N 层楼完全用二分的方式扔logN +1次就可以确定哪层楼是会碎的最低层楼,所以当棋子数(k )大于logN +1时,我们就可以直接返回logN +1。

如果棋子数为K 、楼数为N ,最终的结果为M 次,那么最优解的时间复杂度为O (K ×M ),在棋子数大于logN +1时,时间复杂度为O (logN )。在只有一个棋子的时候,K ×M 等于N ,在其他情况下,K ×MN 要小得多。最优解求解过程参看如下代码中的solution5方法。

          public int solution5(int nLevel, int kChess) {
                  if (nLevel < 1 || kChess < 1) {
                          return 0;
                  }
                  int bsTimes = log2N(nLevel) + 1;
                  if (kChess >= bsTimes) {
                          return bsTimes;
                  }
                  int[] dp = new int[kChess];
                  int res = 0;
                  while (true) {
                          res++;
                          int previous = 0;
                          for (int i = 0; i < dp.length; i++) {
                                  int tmp = dp[i];
                                  dp[i] = dp[i] + previous + 1;
                                  previous = tmp;
                                  if (dp[i] >= nLevel) {
                                          return res;
                                  }
                          }
                  }
          }

          public int log2N(int n) {
                  int res = -1;
                  while (n ! = 0) {
                          res++;
                          n >>>= 1;
                  }
                  return res;
          }

画匠问题

【题目】

给定一个整型数组arr,数组中的每个值都为正数,表示完成一幅画作需要的时间,再给定一个整数num表示画匠的数量,每个画匠只能画连在一起的画作。所有的画家并行工作,请返回完成所有的画作需要的最少时间。

【举例】

arr=[3,1,4],num=2。

最好的分配方式为第一个画匠画3和1,所需时间为4。第二个画匠画4,所需时间为4。因为并行工作,所以最少时间为4。如果分配方式为第一个画匠画3,所需时间为3。第二个画匠画1和4,所需的时间为5。那么最少时间为5,显然没有第一种分配方式好。所以返回4。

arr=[1,1,1,4,3],num=3。

最好的分配方式为第一个画匠画前三个1,所需时间为3。第二个画匠画4,所需时间为4。第三个画匠画3,所需时间为3。返回4。

【难度】

校 ★★★☆

【解答】

方法一。如果只有1个画匠,那么对这个画匠来说,arr[0..j]上的画作最少时间就是arr[0..j]的累加和。如果有2个画匠,对他们来说,画完arr[0..j]上的画作有如下方案:

方案1:画匠1负责arr[0],画匠2负责arr[1..j],时间为Max{sum[0],sum[1..j]}。

方案2:画匠1负责arr[0..1],画匠2负责arr[2..j],时间为Max{sum[0..1],sum[2..j]}。

……

方案k :画匠1负责arr[0..k],画匠2负责arr[k+1..j],时间为Max{sum[0..k],sum[k+1..j]}。

方案j :画匠1负责arr[0..j-1],画匠2负责arr[j]。时间为Max{sum[0..j-1],sum[j]}。

每一种方案其实都是一种划分,把arr[0..j]分成两部分,第一部分由画匠1来负责,第二部分由画匠2来负责,两部分的累加和哪个大,哪个就是这种方案的所需时间。最后选所需时间最小的方案,就是答案。当画匠数量为ii >2)时,假设dp[i][j]的值代表i 个画匠搞定arr[0..j]这些画所需的最少时间。那么有如下方案:

方案1:画匠1~i -1负责arr[0],画匠i 负责arr[1..j] -> max{dp[i-1][0],sum[1..j]}。

方案2:画匠1~i -1负责arr[0..1],画匠i 负责arr[2..j] -> max{dp[i-1][1],sum[2..j]}。

……

方案k :画匠1~i -1负责arr[0..k],画匠i 负责arr[k+1..j] -> max{dp[i-1][k],sum[k+1..j]}。

方案j :画匠1~i -1负责arr[0..j-1],画匠i 负责arr[j] -> max{dp[i-1][j-1] ,sum[j]}。

哪种方案所需的时间最少,dp[i][j]的值就是那种方案所需的时间,即

dp[i][j] = min { max { dp[i-1][k] ,sum[k+1..j] } (0<=k<j) }

具体过程参见如下代码中的solution1方法,此方法使用动态规划常见的空间优化技巧。因为dp[i][j]的值仅依赖dp[i-1][…]的值,所以我们不必生成规模为Num×N 大小的矩阵,仅用一个长度为N 的数组结构滚动更新、不断复用即可。

          public int solution1(int[] arr, int num) {
                  if (arr == null || arr.length == 0 || num < 1) {
                          throw new RuntimeException("err");
                  }
                  int[] sumArr = new int[arr.length];
                  int[] map = new int[arr.length];
                  sumArr[0] = arr[0];
                  map[0] = arr[0];
                  for (int i = 1; i < sumArr.length; i++) {
                          sumArr[i] = sumArr[i - 1] + arr[i];
                          map[i] = sumArr[i];
                  }
                  for (int i = 1; i < num; i++) {
                          for (int j = map.length - 1; j > i - 1; j--) {
                                  int min = Integer.MAX_VALUE;
                                  for (int k = i - 1; k < j; k++) {
                                      int cur = Math.max(map[k], sumArr[j] - sumArr[k]);
                                      min = Math.min(min, cur);
                                  }
                                  map[j] = min;
                          }
                  }
                  return map[arr.length - 1];
          }

画匠数目为num,画作数量为N ,所以一共是num×N 个位置需要计算,每一个位置都需要枚举所有的方案来找出最好的方案,所以方法一的时间复杂度为O (N 2 ×num)。

方法二,动态规划用四边形不等式优化后的解法。计算动态规划的每个值都需要去枚举,自然想到用“四边形不等式”及其相关猜想来做枚举优化。具体地说,假设计算dp[i-1][j]时,在最好的划分方案中,第i -1个画匠负责arr[l..j]的画作。在计算dp[i][j+1]时,在最好的划分方案中,第i 个画匠负责arr[m..j+1]的画作。那么在计算dp[i][j]时,假设最好的划分方案是让第i 个画匠负责arr[k..j],那么k 的范围一定是[l,m],而不可能在这个范围之外。四边形不等式的相关内容及其证明比较复杂且烦琐,本书因篇幅所限,不再详述,有兴趣的读者可以自行学习。利用四边形不等式对枚举过程的优化可以将时间复杂度从O (N 2 ×num)降至O (N 2 )。具体过程请参看如下代码中的solution2方法。

          public int solution2(int[] arr, int num) {
                  if (arr == null || arr.length == 0 || num < 1) {
                          throw new RuntimeException("err");
                  }
                  int[] sumArr = new int[arr.length];
                  int[] map = new int[arr.length];
                  sumArr[0] = arr[0];
                  map[0] = arr[0];
                  for (int i = 1; i < sumArr.length; i++) {
                          sumArr[i] = sumArr[i - 1] + arr[i];
                          map[i] = sumArr[i];
                  }
                  int[] cands = new int[arr.length];
                  for (int i = 1; i < num; i++) {
                          for (int j = map.length - 1; j > i - 1; j--) {
                                  int minPar = cands[j];
                                  int maxPar = j == map.length - 1 ? j : cands[j + 1];
                                  int min = Integer.MAX_VALUE;
                                  for (int k = minPar; k < maxPar + 1; k++) {
                                      int cur = Math.max(map[k], sumArr[j] - sumArr[k]);
                                      if (cur <= min) {
                                          min = cur;
                                          cands[j] = k;
                                      }
                                  }
                                  map[j] = min;
                          }
                  }
                  return map[arr.length - 1];
          }

最优解。本题最优解反而是三个方法中最好理解的,先来重新思考这样一个问题,arr数组中的值依然表示完成一幅画作需要的时间,但是规定每个画匠画画的时间不能多于limit,那么要几个画匠才够呢?这个问题的实现非常简单,从左到右遍历arr的过程中做累加,一旦累加超过limit,则认为当前的画(arr[i])必须分给下一个画匠,那么就让累加和清零,并从arr[i]开始重新累加。遍历的过程中如果发现有某一幅画的时间大于limit,说明即使是单独分配一个画匠只画这一幅画,也不能满足每个画匠所需时间小于或等于limit这个要求。遇到这种情况就直接返回系统最大值,表示无论分多少个画匠,limit都满足不了。这个过程请参看如下代码中的getNeedNum方法。如果arr的长度为N ,该方法的时间复杂度为O (N )。

          public int getNeedNum(int[] arr, int lim) {
                  int res = 1;
                  int stepSum = 0;
                  for (int i = 0; i ! = arr.length; i++) {
                          if (arr[i] > lim) {
                                  return Integer.MAX_VALUE;
                          }
                          stepSum += arr[i];
                          if (stepSum > lim) {
                                  res++;
                                  stepSum = arr[i];
                          }
                  }
                  return res;
          }

理解了上面的小问题后,画匠问题最优解的思路就很好理解了——利用二分法。通过调整limit的大小,看看需要的画匠数目是大于画匠总数还是少于画匠总数,然后决定是将答案往上调整还是往下调整,那么limit的范围一开始为[0,arr所有值的累加和],然后不断二分,即可缩小范围,最终确定limit到底是多少。具体过程参看如下代码中的solution3方法。

          public int solution3(int[] arr, int num) {
                  if (arr == null || arr.length == 0 || num < 1) {
                          throw new RuntimeException("err");
                  }
                  if (arr.length < num) {
                          int max = Integer.MIN_VALUE;
                          for (int i = 0; i ! = arr.length; i++) {
                                  max = Math.max(max, arr[i]);
                          }
                          return max;
                  } else {
                          int minSum = 0;
                          int maxSum = 0;
                          for (int i = 0; i < arr.length; i++) {
                                  maxSum += arr[i];
                          }
                          while (minSum ! = maxSum - 1) {
                                  int mid = (minSum + maxSum) / 2;
                                  if (getNeedNum(arr, mid) > num) {
                                          minSum = mid;
                                  } else {
                                          maxSum = mid;
                                  }
                          }
                          return maxSum;
                  }
          }

假设arr所有值的累加和为S,那么二分的次数为logS ,每次调用getNeedNum方法,然后进行二分,getNeedNum方法的时间复杂度为O (N )。所以solution3的时间复杂度为O (N logS )。

邮局选址问题

【题目】

一条直线上有居民点,邮局只能建在居民点上。给定一个有序整型数组arr,每个值表示居民点的一维坐标,再给定一个正数num,表示邮局数量。选择num个居民点建立num个邮局,使所有的居民点到邮局的总距离最短,返回最短的总距离。

【举例】

arr=[1,2,3,4,5,1000],num=2。

第一个邮局建立在3位置,第二个邮局建立在1000位置。那么1位置到邮局距离为2,2位置到邮局距离为1,3位置到邮局距离为0,4位置到邮局距离为1,5位置到邮局距离为2,1000位置到邮局距离为0。所以这种方案下的总距离为6,其他任何方案的总距离都不会比该方案的总距离更短,所以返回6。

【难度】

校 ★★★☆

【解答】

方法一,动态规划。首先解决一个问题,如果在arr[i..j](0<=i<=j<N)区域上只能建一个邮局,并且这个区域上的居民点都前往这个邮局,要让arr[i..j]上所有的居民点到邮局的总距离最短,这个邮局应该建在哪里?如果arr[i..j]上有奇数个民居点,邮局建在中点位置会使总距离最短;如果arr[i..j]上有偶数个民居点,此时认为中点有两个,邮局建在哪个中点上都行,都会使总距离最短。根据这种思路,我们先生成一个规模为N ×N 的矩阵w ,w[i][j](0<=i<=j<N)的值代表如果在arr[i..j](0<=i<=j<N)区域上只建一个邮局,这一区间上的总距离为多少。因为始终有i<=j的要求,所以我们求w 矩阵的时候,实际上只求w 矩阵的一半。

w 矩阵的过程。在求每一个位置w[i][j]的时候,求法并不是把区间arr[i..j]上的每个位置到中点的距离求出后累加,这样求虽然肯定正确,但会很慢。更快速的求法是如果已经求出了w[i][j-1]的值,那么w[i][j]=w[i][j-1]+arr[j]-arr[(i+j)/2]。解释一下这是为什么,如果arr[i..j-1]上有奇数个点,那么中点是arr[(i+j-1)/2],加上arr[j]之后,arr[i..j]有偶数个点,第一个中点是arr[(i+j)/2]。在这种情况下,(i+j-1)/2和(i+j)/2其实是同一个位置。比如,arr[i..j-1]=[4,15,26],中点是15。arr[i..j]=[4,15,26,47],第一个中点是15。所以,此时w[i][j]比w[i][j-1]多出来的距离就是arr[j]到arr[(i+j)/2]的距离,即w[i][j]=w[i][j-1]+arr[j]-arr[(i+j)/2]。如果arr[i..j-1]上有偶数个点,中点有两个,无论选在哪一个,w[i][j-1]的值都是一样的。加上arr[j]之后,arr[i..j]有奇数个点,中点是arr[(i+j)/2]。在这种情况下,arr[i..j-1]上的第二个中点和arr[i..j]上唯一的中点其实是同一个位置。比如,arr[i..j-1]=[4,15,26,47],第二个中点是26。arr[i..j]=[4,15,26,47,53],唯一的中点是26。所以,此时w[i][j]比w[i][j-1]多出来的距离还是arr[j]到arr[(i+j)/2]的距离,即w[i][j]=w[i][j-1]+arr[j]-arr[(i+j)/2]。所以w 矩阵求解的代码片段如下:

                  int[][] w = new int[arr.length + 1][arr.length + 1];
                  for (int i = 0; i < arr.length; i++) {
                          for (int j = i + 1; j < arr.length; j++) {
                                  w[i][j] = w[i][j - 1] + arr[j] - arr[(i + j) / 2];
                          }
                  }

如上代码中让把w 申请成规模(N +1)×(N +1)的原因是为了在接下来的代码实现中,省去很多越界的判断,实际上w 的有效区域就是w[0..N][0..N]中的一半,剩下的部分都是0。

有了w 矩阵之后,接下来介绍动态规划的过程。dp[a][b]的值代表如果在arr[0..b]上建设a +1个邮局,总距离最少是多少。所以dp[0][b]的值代表如果在arr[0..b]上建设1个邮局,总距离最少是多少。很明显,总距离最少是w[0][b]。那么dp[0][0..N-1]上的所有值都可以直接赋值,即如下的代码片段:

                  int[][] dp = new int[num][arr.length];
                  for (int j = 0; j ! = arr.length; j++) {
                          dp[0][j] = w[0][j];
                  }

当arr[0..b]上可以建设不止1个邮局时,即dp[a][b](a>0)时,应该如何计算?举例说明,比如arr=[-3,-2,-1,0,1,2],要计算dp[2][5]的值,即可以在arr[0..5]上建立3个邮局的情况下,最少的最距离是多少,并且此时已经有dp[0..1][0..5]的所有值。

方案1:邮局1、2负责[-3],邮局3负责[-2,-1,0,1,2],距离dp[1][0]+w[1][5]。

方案2:邮局1、2负责[-3,-2],邮局3负责[-1,0,1,2],距离dp[1][1]+w[2][5]。

方案3:邮局1、2负责[-3,-2,-1],邮局3负责[0,1,2],距离dp[1][2]+w[3][5]。

方案4:邮局1、2负责[-3,-2,-1,0],邮局3负责[1,2],距离dp[1][3]+w[4][5]。

方案5:邮局1、2负责[-3,-2,-1,0,1],邮局3负责[2],距离dp[1][4]+w[5][5]。

方案6:邮局1、2负责[-3,-2,-1,0,1,2],邮局3负责[],距离dp[1][5]+w[6][5](w越界为0)。

枚举所有的划分方案,选一个距离最短的即可,所以,dp[a][b] = Min { dp[a - 1][k] +w[k + 1] [b] (0<=k<N) }。

方法一的全部过程请参看如下代码中的minDistances1方法。

          public int minDistances1(int[] arr, int num) {
                  if (arr == null || num < 1 || arr.length < num) {
                          return 0;
                  }
                  int[][] w = new int[arr.length + 1][arr.length + 1];
                  for (int i = 0; i < arr.length; i++) {
                          for (int j = i + 1; j < arr.length; j++) {
                                  w[i][j] = w[i][j - 1] + arr[j] - arr[(i + j) / 2];
                          }
                  }
                  int[][] dp = new int[num][arr.length];
                  for (int j = 0; j ! = arr.length; j++) {
                          dp[0][j] = w[0][j];
                  }
                  for (int i = 1; i < num; i++) {
                      for (int j = i + 1; j < arr.length; j++) {
                          dp[i][j] = Integer.MAX_VALUE;
                          for (int k = 0; k <= j; k++) {
                              dp[i][j] = Math.min(dp[i][j], dp[i - 1][k] + w[k + 1][j]);
                          }
                      }
                  }
                  return dp[num - 1][arr.length - 1];
          }

w 矩阵的求解过程O (N 2 ),动态规划的求解过程O (N 2 ×num)。所以方法一总的时间复杂度为O (N 2 )+O (N 2 ×num),即O (N 2 ×num)。

方法二,用四边形不等式优化动态规划的枚举过程,使整个过程的时间复杂度降低至O (N 2 )。在方法一中求解dp[a][b]的时候,几乎枚举了所有的dp[a-1][0..b],但这个枚举过程其实是可以得到加速的。具体解释为:

1.当邮局为a -1个,区间为arr[0..b]时,如果在其最优划分方案中发现,邮局1~a -2负责arr[0..l],邮局a -1负责arr[l+1..b]。那么当邮局为a 个,区间为arr[0..b]时,如果想得到最优方案,邮局1~a -1负责的区域不必尝试比arr[0..l]小的区域,只需尝试arr[0..k](k>=l)。

2.当邮局为a 个,区间为arr[0..b+1]时,如果在其最优划分方案中发现,邮局1~a -1负责arr[0..m],邮局a 负责arr[m+1..b+1]。那么当邮局为a 个,区间为arr[0..b]时,如果想得到最优方案,邮局1~a -1负责的区域不必尝试比arr[0..m]大的区域,只尝试arr[0..k](k<=m)。

本题为何能用四边形不等式进行优化的证明略。有兴趣的读者可以自行学习“四边形不等式”的相关内容。有了这个枚举优化过程后,在算dp[a][b]时,只用在dp[a-1][b]的最优尝试位置l和dp[a][b+1]的最优尝试位置m 之间进行枚举,其他的位置一概不用再试。具体过程请参看如下代码中的minDistances2方法。

          public int minDistances2(int[] arr, int num) {
                  if (arr == null || num < 1 || arr.length < num) {
                          return 0;
                  }
                  int[][] w = new int[arr.length + 1][arr.length + 1];
                  for (int i = 0; i < arr.length; i++) {
                          for (int j = i + 1; j < arr.length; j++) {
                                  w[i][j] = w[i][j - 1] + arr[j] - arr[(i + j) / 2];
                          }
                  }
                  int[][] dp = new int[num][arr.length];
                  int[][] s = new int[num][arr.length];
                  for (int j = 0; j ! = arr.length; j++) {
                          dp[0][j] = w[0][j];
                          s[0][j] = 0;
                  }
                  int minK = 0;
                  int maxK = 0;
                  int cur = 0;
                  for (int i = 1; i < num; i++) {
                      for (int j = arr.length - 1; j > i; j--) {
                          minK = s[i - 1][j];
                          maxK = j == arr.length - 1 ? arr.length - 1 : s[i][j + 1];
                          dp[i][j] = Integer.MAX_VALUE;
                          for (int k = minK; k <= maxK; k++) {
                              cur = dp[i - 1][k] + w[k + 1][j];
                              if (cur <= dp[i][j]) {
                                  dp[i][j] = cur;
                                  s[i][j] = k;
                              }
                          }
                      }
                  }
                  return dp[num - 1][arr.length - 1];
          }