5
字符串问题

判断两个字符串是否互为变形词

【题目】

给定两个字符串str1和str2,如果str1和str2中出现的字符种类一样且每种字符出现的次数也一样,那么str1与str2互为变形词。请实现函数判断两个字符串是否互为变形词。

【举例】

str1="123",str2="231",返回true。

str1="123",str2="2331",返回false。

【难度】

士 ★☆☆☆

【解答】

如果字符串str1和str2长度不同,直接返回false。如果长度相同,假设出现字符的编码值在0~255之间,那么先申请一个长度为256的整型数组map,map[a]=b代表字符编码为a的字符出现了b次,初始时map[0..255]的值都是0。然后遍历字符串str1,统计每种字符出现的数量,比如遍历到字符’a',其编码值为97,则令map[97]++。这样map就成了str1中每种字符的词频统计表。然后遍历字符串str2,每遍历到一个字符都在map中把词频减下来,比如遍历到字符’a',其编码值为97,则令map[97]--,如果减少之后的值小于0,直接返回false。如果遍历完str2,map中的值也没出现负值,则返回true。

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

          public boolean isDeformation(String str1, String str2) {
                  if (str1 == null || str2 == null || str1.length() ! = str2.length()) {
                          return false;
                  }
                  char[] chas1 = str1.toCharArray();
                  char[] chas2 = str2.toCharArray();
                  int[] map = new int[256];
                  for (int i = 0; i < chas1.length; i++) {
                          map[chas1[i]]++;
                  }
                  for (int i = 0; i < chas2.length; i++) {
                          if (map[chas2[i]]-- == 0) {
                                  return false;
                          }
                  }
                  return true;
          }

如果字符的类型很多,可以用哈希表代替长度为256的整型数组,但整体过程不变。如果字符的种类为M ,str1和str2的长度为N ,那么该方法的时间复杂度为O (N ),额外空间复杂度为O (M )。

字符串中数字子串的求和

【题目】

给定一个字符串str,求其中全部数字串所代表的数字之和。

【要求】

1.忽略小数点字符,例如"A1.3",其中包含两个数字1和3。

2.如果紧贴数字子串的左侧出现字符"-",当连续出现的数量为奇数时,则数字视为负,连续出现的数量为偶数时,则数字视为正。例如,"A-1BC--12",其中包含数字为-1和12。

【举例】

str="A1CD2E33",返回36。

str="A-1B--2C--D6E",返回7。

【难度】

士 ★☆☆☆

【解答】

解决本题能做到时间复杂度为O (N )、额外空间复杂度为O (1)的方法有很多。本书仅提供一种供读者参考。解法的关键是如何在从左到右遍历str时,准确收集每个数字并累加起来。具体过程如下:

1.生成三个变量。整型变量res,表示目前的累加和;整型变量num,表示当前收集到的数字;布尔型变量posi,表示如果把num累加到res里,num是正还是负。初始时,res=0,num=0,posi=true。

2.从左到右遍历str,假设遍历到字符cha,根据具体的cha有不同的处理。

3.如果cha是’0' ~'9' ,cha-'0’的值记为cur,假设之前收集的数字为num,此时举例说明。比如str="123",初始时num=0,posi=true。当cha=='1’时,num变成1; cha=='2’时,num变成12; cha=='3’时,num变成123。再如str="-123",初始时num=0,posi=true。当cha==' -’时,posi变成false,cha不是’0' ~'9’的情况接下来会说明,读者可以先认为在收集数字时posi的符号一定是正确的。cha=='1’时,num变成-1,cha=='2’时,num变成-12。cha=='3'时,num变成-123。总之,num = num * 10 + (posi ? cur : -cur)。

4.如果cha不是’0' ~'9',此时不管cha具体是什么,都是累加时,令res+=num,然后令num=0,累加完num当然要清零。累加完成后,再看cha具体的情况。如果cha不是字符’-',令posi=true,即如果cha既不是数字字符,也不是’-’字符,posi都变为true。如果cha是字符’-',此时看cha的前一个字符,如果前一个字符也是’-’字符,则posi改变符号,即posi=! posi;否则令posi=false。

5.既然我们把累加的时机放在了cha不是数字字符的时候,那么如果str是以数字字符结尾的,会出现最后一个数字没有累加的情况。所以遍历完成后,令res+=num,防止最后的数字累加不上的情况发生。

6.最后返回res。

具体实现请参看如下代码中的numSum方法。

          public int numSum(String str) {
                  if (str == null) {
                          return 0;
                  }
                  char[] charArr = str.toCharArray();
                  int res = 0;
                  int num = 0;
                  boolean posi = true;
                  int cur = 0;
                  for (int i = 0; i < charArr.length; i++) {
                          cur = charArr[i] - '0' ;
                          if (cur < 0 || cur > 9) {
                                  res += num;
                                  num = 0;
                                  if (charArr[i] == ' -') {
                                          if (i - 1 > -1 && charArr[i - 1] == ' -') {
                                                  posi = ! posi;
                                          } else {
                                                  posi = false;
                                          }
                                  } else {
                                          posi = true;
                                  }
                          } else {
                                  num = num * 10 + (posi ? cur : -cur);
                          }
                  }
                  res += num;
                  return res;
          }

去掉字符串中连续出现k 个0的子串

【题目】

给定一个字符串str和一个整数k ,如果str中正好有连续的k 个’0’字符出现时,把k 个连续的’0’字符去除,返回处理后的字符串。

【举例】

str="A00B",k=2,返回"A00B"。

str="A0000B000",k=3,返回"A0000B"。

【难度】

士 ★☆☆☆

【解答】

解决本题能做到时间复杂度为O (N )、额外空间复杂度为O (1)的方法有很多。本书仅提供一种供读者参考。解法的关键是如何在从左到右遍历str时,将正好有连续的k 个’0’的字符串都找到,然后把字符’0’去掉。具体过程如下:

1.生成两个变量。整型变量count,表示目前连续个’0’的数量;整型变量start,表示连续个’0’出现的开始位置。初始时,count=0,start=-1。

2.从左到右遍历str,假设遍历到i 位置的字符为cha,根据具体的cha有不同的处理。

3.如果cha是字符’0',令start = start == -1 ? i : start,表示如果start等于-1,说明之前没处在发现连续的’0’的阶段,那么令start=i ,表示连续的’0’从i 位置开始,如果start不等于-1,说明之前就已经处在发现连续的’0’的阶段,所以start不变。令count++。

4.如果cha不是字符’0',是去掉连续’0’的时刻。首先看此时count是否等于k ,如果等于,说明之前发现的连续k 个’0’可以从start位置开始去掉,如果不等于,说明之前发现的连续的’0’数量不是k 个,则不能去掉。最后令count=0,start=-1。

5.既然把去掉连续’0’的时机放在了cha不是字符’0’的时候,那么如果str是以字符’0'结尾的,可能会出现最后一组正好有连续的k 个’0’字符出现而没有去掉的情况。所以遍历完成后,再检查一下count是否等于k ,如果等于,就去掉最后一组连续的k 个’0'。

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

          public String removeKZeros(String str, int k) {
                  if (str == null || k < 1) {
                          return str;
                  }
                  char[] chas = str.toCharArray();
                  int count = 0, start = -1;
                  for (int i = 0; i ! = chas.length; i++) {
                          if (chas[i] == '0') {
                                  count++;
                                  start = start == -1 ? i : start;
                          } else {
                                  if (count == k) {
                                          while (count-- ! = 0)
                                                  chas[start++] = 0;
                                  }
                                  count = 0;
                                  start = -1;
                          }
                  }
                  if (count == k) {
                          while (count-- ! = 0)
                                  chas[start++] = 0;
                  }
                  return String.valueOf(chas);
          }

判断两个字符串是否互为旋转词

【题目】

如果一个字符串str,把字符串str前面任意的部分挪到后面形成的字符串叫作str的旋转词。比如str="12345",str的旋转词有"12345"、"23451"、"34512"、"45123"和"51234"。给定两个字符串a和b,请判断a和b是否互为旋转词。

【举例】

a="cdab",b="abcd",返回true。

a="1ab2",b="ab12",返回false。

a="2ab1",b="ab12",返回true。

【要求】

如果a和b长度不一样,那么a和b必然不互为旋转词,可以直接返回false。当a和b长度一样,都为N 时,要求解法的时间复杂度为O (N )。

【难度】

士 ★☆☆☆

【解答】

本题的解法非常简单,如果a和b的长度不一样,字符串a和b不可能互为旋转词。如果a和b长度一样,先生成一个大字符串b2,b2是两个字符串b拼在一起的结果,即String b2 = b + b。然后看b2中是否包含字符串a,如果包含,说明字符串a和b互为旋转词,否则说明两个字符串不互为旋转词。这是为什么呢?举例说明,假设a="cdab",b="abcd"。b2="abcdabcd",b2[0..3]=="abcd"是b的旋转词,b2[1..4]=="bcda"是b的旋转词……b2[i..i+3]都是b的旋转词,b2[4..7]=="abcd"是b的旋转词。由此可见,如果一个字符串b长度为N 。在通过b生成的b2中,任意长度为N 的子串都是b的旋转词,并且b2中包含字符串b的所有旋转词。所以这种方法是有效的,请参看如下代码中的isRotation方法。

          public boolean isRotation(String a, String b) {
                  if (a == null || b == null || a.length() ! = b.length()) {
                          return false;
                  }
                  String b2 = b + b;
                  return getIndexOf(b2, a) ! = -1; // getIndexOf -> KMP Algorithm
          }

isRotation方法中getIndexOf函数的功能是如果b2中包含a,则返回a在b2中的开始位置,如果不包含a,则返回-1,即getIndexOf是解决匹配问题的函数,如果想让整个过程在O (N )的时间复杂度内完成,那么字符串匹配问题也需要在O (N )的时间复杂度内完成。这正是KMP算法做的事情,getIndexOf函数就是KMP算法的实现。若要了解KMP算法的过程和实现,请参看本书“KMP算法”的内容。

将整数字符串转成整数值

【题目】

给定一个字符串str,如果str符合日常书写的整数形式,并且属于32位整数的范围,返回str所代表的整数值,否则返回0。

【举例】

str="123",返回123。

str="023",因为"023"不符合日常的书写习惯,所以返回0。

str="A13",返回0。

str="0",返回0。

str="2147483647",返回2147483647。

str="2147483648",因为溢出了,所以返回0。

str="-123",返回-123。

【难度】

尉 ★★☆☆

【解答】

解决本题的方法有很多,本书仅提供一种供读者参考。首先检查str是否符合日常书写的整数形式,具体判断如下:

1.如果str不以“-”开头,也不以数字字符开头,例如,str=="A12",返回false。

2.如果str以“-”开头。但是str的长度为1,即str=="-",返回false。如果str的长度大于1,但是“-”的后面紧跟着“0”,例如,str=="-0"或"-012",返回false。

3.如果str以“0”开头,但是str的长度大于1,例如,str=="023",返回false。

4.如果经过步骤1~步骤3都没有返回,接下来检查str[1..N-1]是否都是数字字符,如果有一个不是数字字符,返回false。如果都是数字字符,说明str符合日常书写,返回true。

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

          public boolean isValid(char[] chas) {
                  if (chas[0] ! = ' -' && (chas[0] < '0' || chas[0] > '9')) {
                          return false;
                  }
                  if (chas[0] == ' -' && (chas.length == 1 || chas[1] == '0')) {
                          return false;
                  }
                  if (chas[0] == '0' && chas.length > 1) {
                          return false;
                  }
                  for (int i = 1; i < chas.length; i++) {
                          if (chas[i] < '0' || chas[i] > '9') {
                                  return false;
                          }
                  }
                  return true;
          }

如果str不符合日常书写的整数形式,根据题目要求,直接返回0即可。如果符合,则进行如下转换过程:

1.生成4个变量。布尔型常量posi,表示转换的结果是负数还是非负数,这完全由str开头的字符决定,如果以“-”开头,那么转换的结果一定是负数,则posi为false,否则posi为true。整型常量minq,minq等于Integer.MIN_VALUE/10,即32位整数最小值除以10得到的商,其意义稍后说明。整型常量minr,minr等于Integer.MIN_VALUE%10,即32位整数最小值除以10得到的余数,其意义稍后说明。整型变量res,转换的结果,初始时res=0。

2.32位整数的最小值为-2147483648,32位整数的最大值为2147483647。可以看出,最小值的绝对值比最大值的绝对值大1,所以转换过程中的绝对值一律以负数的形式出现,然后根据posi决定最后返回什么。比如str="123",转换完成后的结果是-123,posi=true,所以最后返回123。再如str="-123",转换完成后的结果是-123,posi=false,所以最后返回-123。比如str="-2147483648",转换完成后的结果是-2147483648,posi=false,所以最后返回-2147483648。比如str="2147483648",转换完成后的结果是-2147483648,posi=true,此时发现-2147483648变成2147483648会产生溢出,所以返回0。也就是说,既然负数比正数拥有更大的绝对值范围,那么转换过程中一律以负数的形式记录绝对值,最后再决定返回的数到底是什么。

3.如果str以’-’开头,从str[1]开始从左往右遍历str,否则从str[0]开始从左往右遍历str。举例说明转换过程,比如str="123",遍历到’1’时,res=res*10+(-1)==-1,遍历到’2’时,res=res*10+(-2)==-12,遍历到’3’时,res=res*10+(-3)==-123。比如str="-123",字符’-’跳过,从字符’1’开始遍历,res=res*10+(-1)==-1,遍历到’2’时,res=res*10+(-2)==-12,遍历到’3’时,res=res*10+(-3)==-123。遍历的过程中如何判断res已经溢出了?假设当前字符为a,那么’0' -a就是当前字符所代表的数字的负数形式,记为cur。如果在res加上cur之前,发现res已经小于minq,那么当res加上cur之后一定会溢出,比如str="3333333333",遍历完倒数第二个字符后,res==-333333333 < minq==-214748364,所以当遍历到最后一个字符时,res*10肯定会产生溢出。如果在res加上cur之前,发现res等于minq,但又发现cur小于minr,那么当res加上cur之后一定会溢出,比如str="2147483649",遍历完倒数第二个字符后,res==-214748364 == minq,当遍历到最后一个字符时发现有res==minq,同时也发现cur==-9 < minr==-8,那么当res加上cur之后一定会溢出。出现任何一种溢出情况时,直接返回0。

4.遍历后得到的res根据posi的符号决定返回值。如果posi为true,说明结果应该返回正,否则说明应该返回负。如果res正好是32位整数的最小值,同时又有posi为true,说明溢出,直接返回0。

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

          public int convert(String str) {
                  if (str == null || str.equals("")) {
                          return 0; // 不能转
                  }
                  char[] chas = str.toCharArray();
                  if (! isValid(chas)) {
                          return 0; // 不能转
                  }
                  boolean posi = chas[0] == ' -' ? false : true;
                  int minq = Integer.MIN_VALUE / 10;
                  int minr = Integer.MIN_VALUE % 10;
                  int res = 0;
                  int cur = 0;
                  for (int i = posi ? 0 : 1; i < chas.length; i++) {
                          cur = '0' - chas[i];
                          if ((res < minq) || (res == minq && cur < minr)) {
                                  return 0; // 不能转
                          }
                          res = res * 10 + cur;
                  }
                  if (posi && res == Integer.MIN_VALUE) {
                          return 0; // 不能转
                  }
                  return posi ? -res : res;
          }

替换字符串中连续出现的指定字符串

【题目】

给定三个字符串str、from和to,把str中所有from的子串全部替换成to字符串,对连续出现from的部分要求只替换成一个to字符串,返回最终的结果字符串。

【举例】

str="123abc",from="abc",to="4567",返回"1234567"。

str="123",from="abc",to="456",返回"123"。

str="123abcabc",from="abc",to="X",返回"123X"。

【难度】

士 ★☆☆☆

【解答】

解决本题的方法有很多。本书仅提供一种供读者参考。如果把str看作字符类型的数组,首先把str中from部分所有位置的字符编码设为0(即空字符),比如,str="12abcabca4",from="abc",处理后str为['1' ,'2' ,0,0,0,0,0,0,'a' ,'4' ]。具体过程如下:

1.生成整型变量match,表示目前匹配到from字符串的什么位置,初始时,match=0。

2.从左到右遍历str中的每个字符,假设当前遍历到str[i]。

3.如果str[i]==from[match]。如果match是from最后一个字符的位置,说明在str中发现了from字符串,则从i 位置向左的M 个位置,都把字符编码设为0,M 为from的长度,设置完成后令match=0。如果match不是from最后一个字符的位置,令match++。继续遍历str的下一个字符。

4.如果str[i]! =from[match],说明匹配失败,令match=0,即回到from开头重新匹配。继续遍历str的下一个字符。

通过上面的过程,接下来替换就比较容易,比如['1' ,'2' ,0,0,0,0,0,0,'a' ,'4' ],将不为0的区域拼在一起,连续为0的部分用to来替换,即"12"+to+"a4"即可。

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

          public String replace(String str, String from, String to) {
                  if (str == null || from == null || str.equals("") || from.equals("")) {
                          return str;
                  }
                  char[] chas = str.toCharArray();
                  char[] chaf = from.toCharArray();
                  int match = 0;
                  for (int i = 0; i < chas.length; i++) {
                          if (chas[i] == chaf[match++]) {
                                  if (match == chaf.length) {
                                          clear(chas, i, chaf.length);
                                          match = 0;
                                  }
                          } else {
                                  match = 0;
                          }
                  }
                  String res = "";
                  String cur = "";
                  for (int i = 0; i < chas.length; i++) {
                          if (chas[i] ! = 0) {
                                  cur = cur + String.valueOf(chas[i]);
                          }
                          if (chas[i] == 0 && (i == 0 || chas[i - 1] ! = 0)) {
                                  res = res + cur + to;
                                  cur = "";
                          }
                  }
                  if (! cur.equals("")) {
                          res = res + cur;
                  }
                  return res;
          }
          public void clear(char[] chas, int end, int len) {
                  while (len-- ! = 0) {
                          chas[end--] = 0;
                  }
          }

字符串的统计字符串

【题目】

给定一个字符串str,返回str的统计字符串。例如,"aaabbadddffc"的统计字符串为"a_3_b_2_a_1_d_3_f_2_c_1"。

【补充题目】

给定一个字符串的统计字符串cstr,再给定一个整数index,返回cstr所代表的原始字符串上的第index个字符。例如,"a_1_b_100"所代表的原始字符串上第0个字符是’a',第50个字符是’b'。

【难度】

士 ★☆☆☆

【解答】

原问题。解决原问题的方法有很多,本书仅提供一种供读者参考。具体过程如下:

1.如果str为空,那么统计字符串不存在。

2.如果str不为空。首先生成String类型的变量res,表示统计字符串,还有整型变量num,代表当前字符的数量。初始时字符串res只包含str的第0个字符(str[0]),同时num=1。

3.从str[1]位置开始,从左到右遍历str,假设遍历到i 位置。如果str[i]==str[i-1],说明当前连续出现的字符(str[i-1])还没结束,令num++,然后继续遍历下一个字符。如果str[i]! =str[i-1],说明当前连续出现的字符(str[i-1])已经结束,令res=res+"_"+num+"_"+str[i],然后令num=1,继续遍历下一个字符。以题目给出的例子进行说明,在开始遍历"aaabbadddffc"之前,res="a",num=1。遍历str[1~2]时,字符’a’一直处在连续的状态,所以num增加到3。遍历str[3]时,字符’a’连续状态停止,令res=res+"_"+"3"+"_"+"b"(即"a_3_b"),num=1。遍历str[4],字符’b’在连续状态,num增加到2。遍历str[5]时,字符’a’连续状态停止,令res为"a_3_b_2_a",num=1。依此类推,当遍历到最后一个字符时,res为"a_3_b_2_a_1_d_3_f_2_c",num=1。

4.对于步骤3中的每一个字符,无论连续还是不连续,都是在发现一个新字符的时候再将这个字符连续出现的次数放在res的最后。所以当遍历结束时,最后字符的次数还没有放入res,所以最后令res=res+"_"+num。在步骤3的例子中,当遍历结束时,res为"a_3_b_2_a_1_d_3_f_2_c",num=1,最后需要把num加在res后面,令res变为"a_3_b_2_a_1_d_3_f_2_c_1",然后再返回。

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

          public String getCountString(String str) {
                  if (str == null || str.equals("")) {
                          return "";
                  }
                  char[] chs = str.toCharArray();
                  String res = String.valueOf(chs[0]);
                  int num = 1;
                  for (int i = 1; i < chs.length; i++) {
                      if (chs[i] ! = chs[i - 1]) {
                          res = concat(res, String.valueOf(num), String.valueOf(chs[i]));
                          num = 1;
                      } else {
                          num++;
                      }
                  }
                  return concat(res, String.valueOf(num), "");
          }
          public String concat(String s1, String s2, String s3) {
                  return s1 + "_" + s2 + (s3.equals("") ? s3 : "_" + s3);
          }

补充问题。求解的具体过程如下:

1.布尔型变量stage,stage为true表示目前处在遇到字符的阶段,stage为false表示目前处在遇到连续字符统计的阶段。字符型变量cur,表示在上一个遇到字符阶段时,遇到的是cur字符。整型变量num,表示在上一个遇到连续字符统计的阶段时,字符出现的数量。整型变量sum,表示目前遍历到cstr的位置相当于原字符串的什么位置。初始时,stage=true,cur=0(字符编码为0表示空字符),num=0,sum=0。

2.从左到右遍历cstr,举例说明这个过程,cstr="a_100_b_2_c_4",index=105。遍历完str[0]=='a’后,记录下遇到字符’a',即cur='a'。遇到str[1]==' _',表示该转阶段了,从遇到字符的阶段变为遇到连续字符统计的阶段,即stage=! stage。遇到str[2]=='1’时,num=1;遇到str[3]=='0’时,num=10;遇到str[4]=='0’时,num=100;遇到str[5]==' _',表示遇到连续字符统计的阶段变为遇到字符的阶段;遇到str[6]=='b',一个新的字符出现了,此时令sum+=num(即sum=100),sum表示目前原字符串走到什么位置了,此时发现sum并未到达index位置,说明还要继续遍历,记录下遇到了字符’b',即cur='b',然后令num=0,因为字符’a’的统计已经完成,现在num开始表示字符’b’的连续数量。也就是说,每遇到一个新的字符,都把上一个已经完成的统计数num加到sum上,再看sum是否到达index,如果已到达,就返回上一个字符cur,如果没到达,就继续遍历。

3.每个字符的统计都在遇到新字符时加到sum上,所以当遍历完成时,最后一个字符的统计数并不会加到sum上,最后要单独加。

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

          public char getCharAt(String cstr, int index) {
                  if (cstr == null || cstr.equals("")) {
                          return 0;
                  }
                  char[] chs = cstr.toCharArray();
                  boolean stage = true;
                  char cur = 0;
                  int num = 0;
                  int sum = 0;
                  for (int i = 0; i ! = chs.length; i++) {
                          if (chs[i] == ' _') {
                                  stage = ! stage;
                          } else if (stage) {
                                  sum += num;
                                  if (sum > index) {
                                          return cur;
                                  }
                                  num = 0;
                                  cur = chs[i];
                          } else {
                                  num = num * 10 + chs[i] - '0' ;
                          }
                  }
                  return sum + num > index ? cur : 0;
          }

判断字符数组中是否所有的字符都只出现过一次

【题目】

给定一个字符类型数组chas[],判断chas中是否所有的字符都只出现过一次,请根据以下不同的两种要求实现两个函数。

【举例】

chas=['a' ,'b' ,'c' ],返回true; chas=['1' ,'2' ,'1' ],返回false。

【要求】

1.实现时间复杂度为O (N )的方法。

2.在保证额外空间复杂度为O (1)的前提下,请实现时间复杂度尽量低的方法。

【难度】

按要求1实现的方法 士 ★☆☆☆

按要求2实现的方法 尉 ★★☆☆

【解答】

要求1。遍历一遍chas,用map记录每种字符的出现情况,这样就可以在遍历时发现字符重复出现的情况,map可以用长度固定的数组实现,也可以用哈希表实现。具体请参看如下代码中的isUnique1方法。

          public boolean isUnique1(char[] chas) {
                  if (chas == null) {
                          return true;
                  }
                  boolean[] map = new boolean[256];
                  for (int i = 0; i < chas.length; i++) {
                          if (map[chas[i]]) {
                                  return false;
                          }
                          map[chas[i]] = true;
                  }
                  return true;
          }

要求2。整体思路是先将chas排序,排序后相同的字符就放在一起,然后判断有没有重复字符就会变得非常容易,所以问题的关键是选择什么样的排序算法。因为必须保证额外空间复杂度为O (1),所以本题是考查面试者对经典排序算法在额外空间复杂度方面的理解程度。首先,任何时间复杂度为O (N )的排序算法做不到额外空间复杂度为O (1),因为这些排序算法不是基于比较的排序算法,所以有多少个数都得“装下”,然后按照一定顺序“倒出”来完成排序。具体细节请读者查阅相关图书中有关桶排序、基数排序、计数排序等内容。然后看时间复杂度O (N logN )的排序算法,常见的有归并排序、快速排序、希尔排序和堆排序。归并排序首先被排除,因为归并排序中有两个小组合并成一个大组的过程,这个过程需要辅助数组才能完成,尽管归并排序可以使用手摇算法将额外空间复杂度降至O (1),但这样最差情况下的时间复杂度会因此上升至O (N 2 )。快速排序也被排除,因为无论选择递归实现还是非递归实现,快速排序的额外空间复杂度最低,为O (logN ),不能达到O (1)的程度。希尔排序同样被排除,因为希尔排序的时间复杂度并不固定,成败完全在于步长的选择,如果选择不当,时间复杂度会变成O (N 2 )。这四种经典排序中,只有堆排序可以做到额外空间复杂度为O (1)的情况下,时间复杂度还能稳定地保持O (N logN )。那么堆排序就是答案,面试者似乎只要写出堆排序的大体过程,要求2的实现就能完成。

但遗憾的是,虽然堆排序的确是答案,但大部分资料提供的堆排序的实现却是基于递归函数实现的。而我们知道递归函数需要使用函数栈空间,这样堆排序的额外空间复杂度就增加至O (logN )。所以,如果真正想达到要求2的实现,面试者需要用非递归的方式实现堆排序。要求2的实现请参看如下代码中的isUnique2方法,其中的heapSort方法是堆排序的非递归实现。

          public boolean isUnique2(char[] chas) {
                  if (chas == null) {
                          return true;
                  }
                  heapSort(chas);
                  for (int i = 1; i < chas.length; i++) {
                          if (chas[i] == chas[i - 1]) {
                                  return false;
                          }
                  }
                  return true;
          }

          public void heapSort(char[] chas) {
                  for (int i = 0; i < chas.length; i++) {
                          heapInsert(chas, i);
                  }
                  for (int i = chas.length - 1; i > 0; i--) {
                          swap(chas, 0, i);
                          heapify(chas, 0, i);
                  }
          }

          public void heapInsert(char[] chas, int i) {
                  int parent = 0;
                  while (i ! = 0) {
                          parent = (i - 1) / 2;
                          if (chas[parent] < chas[i]) {
                                  swap(chas, parent, i);
                                  i = parent;
                          } else {
                                  break;
                          }
                  }
          }

          public void heapify(char[] chas, int i, int size) {
                  int left = i * 2 + 1;
                  int right = i * 2 + 2;
                  int largest = i;
                  while (left < size) {
                          if (chas[left] > chas[i]) {
                                  largest = left;
                          }
                          if (right < size && chas[right] > chas[largest]) {
                                  largest = right;
                          }
                          if (largest ! = i) {
                                  swap(chas, largest, i);
                          } else {
                                  break;
                          }
                          i = largest;
                          left = i * 2 + 1;
                          right = i * 2 + 2;
                  }
          }

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

在有序但含有空的数组中查找字符串

【题目】

给定一个字符串数组strs[],在strs中有些位置为null,但在不为null的位置上,其字符串是按照字典顺序由小到大依次出现的。再给定一个字符串str,请返回str在strs中出现的最左的位置。

【举例】

strs=[null,"a",null,"a",null,"b",null,"c"],str="a",返回1。

strs=[null,"a",null,"a",null,"b",null,"c"],str=null,只要str为null,就返回-1。

strs=[null,"a",null,"a",null,"b",null,"c"],str="d",返回-1。

【难度】

尉 ★★☆☆

【解答】

本题的解法尽可能多地使用了二分查找,具体过程如下:

1.假设在strs[left..right]上进行查找的过程,全局整型变量res表示字符串str在strs中最左的位置。初始时,left=0,right=strs.length-1,res=-1。

2.令mid=(left+right)/2,则strs[mid]为strs[left..right]中间位置的字符串。

3.如果字符串strs[mid]与str一样,说明找到了str,令res=mid。但要找的是最左的位置,所以还要在左半区寻找,看有没有更左的str出现,所以令right=mid-1,然后重复步骤2。

4.如果字符串strs[mid]与str不一样,并且strs[mid]! =null,此时可以比较strs[mid]和str,如果strs[mid]的字典顺序比str小,说明整个左半区不会出现str,需要在右半区寻找,所以令left=mid+1,然后重复步骤2。

5.如果字符串strs[mid]与str不一样,并且strs[mid]==null,此时从mid开始,从右到左遍历左半区(即strs[left..mid])。如果整个左半区都为null,那么继续用二分的方式在右半区上查找(即令left=mid+1),然后重复步骤2。如果整个左半区不都为null,假设从右到左遍历strs[left..mid]时,发现第一个不为null的位置是i ,那么把str和strs[i]进行比较。如果strs[i]字典顺序小于str,同样说明整个左半区没有str,令left=mid+1,然后重复步骤2。如果strs[i]字典顺序等于str,说明找到str,令res=mid,但要找的是最左的位置,所以还要在strs[left..i-1]上寻找,看有没有更左的str出现,所以令right=i -1,然后重复步骤2。如果strs[i]字典顺序大于str,说明strs[i..right]上都没有str,需要在strs[left..i-1]上,所以令right=i -1,然后重复步骤2。

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

          public int getIndex(String[] strs, String str) {
                  if (strs == null || strs.length == 0 || str == null) {
                          return -1;
                  }
                  int res = -1;
                  int left = 0;
                  int right = strs.length - 1;
                  int mid = 0;
                  int i = 0;
                  while (left <= right) {
                          mid = (left + right) / 2;
                          if (strs[mid] ! = null && strs[mid].equals(str)) {
                                  res = mid;
                                  right = mid - 1;
                          } else if (strs[mid] ! = null) {
                                  if (strs[mid].compareTo(str) < 0) {
                                          left = mid + 1;
                                  } else {
                                          right = mid - 1;
                                  }
                          } else {
                                  i = mid;
                                  while (strs[i] == null && --i >= left)
                                          ;
                                  if (i < left || strs[i].compareTo(str) < 0) {
                                          left = mid + 1;
                                  } else {
                                          res = strs[i].equals(str) ? i : res;
                                          right = i - 1;
                                  }
                          }
                  }
                  return res;
          }

字符串的调整与替换

【题目】

给定一个字符类型的数组chas[],chas右半区全是空字符,左半区不含有空字符。现在想将左半区中所有的空格字符替换成"%20",假设chas右半区足够大,可以满足替换所需要的空间,请完成替换函数。

【举例】

如果把chas的左半区看作字符串,为"a b c",假设chas的右半区足够大。替换后,chas的左半区为"a%20b%20%20c"。

【要求】

替换函数的时间复杂度为O (N ),额外空间复杂度为O (1)。

【补充题目】

给定一个字符类型的数组chas[],其中只含有数字字符和“*”字符。现在想把所有的“*”字符挪到chas的左边,数字字符挪到chas的右边。请完成调整函数。

【举例】

如果把chas看作字符串,为"12**345"。调整后chas为"**12345"。

【要求】

1.调整函数的时间复杂度为O (N ),额外空间复杂度为O (1)。

2.不得改变数字字符从左到右出现的顺序。

【难度】

士 ★☆☆☆

【解答】

原问题。遍历一遍可以得到两个信息,chas的左半区有多大,记为len,左半区的空格数有多少,记为num,那么可知空格字符被“%20”替代后,长度将是len+2*num。接下来从左半区的最后一个字符开始倒着遍历,同时将字符复制到新长度最后的位置,并依次向左倒着复制。遇到空格字符就依次把“0”、“2”和“%”进行复制。这样就可以得到替换后的chas数组。具体过程请参看如下代码中的replace方法。

          public void replace(char[] chas) {
                  if (chas == null || chas.length == 0) {
                          return;
                  }
                  int num = 0;
                  int len = 0;
                  for (len = 0; len < chas.length && chas[len] ! = 0; len++) {
                          if (chas[len] == ' ') {
                                  num++;
                          }
                  }
                  int j = len + num * 2 - 1;
                  for (int i = len - 1; i > -1; i--) {
                          if (chas[i] ! = ' ') {
                                  chas[j--] = chas[i];
                          } else {
                                  chas[j--] = '0' ;
                                  chas[j--] = '2' ;
                                  chas[j--] = ' %' ;
                          }
                  }
          }

补充问题。依然是从右向左倒着复制,遇到数字字符则直接复制,遇到“*”字符不复制。当把数字字符复制完,把左半区全部设置成“*”即可。具体请参看如下代码中的modify方法。

          public void modify(char[] chas) {
                  if (chas == null || chas.length == 0) {
                          return;
                  }
                  int j = chas.length - 1;
                  for (int i = chas.length - 1; i > -1; i--) {
                          if (chas[i] ! = ' *') {
                                  chas[j--] = chas[i];
                          }
                  }
                  for (; j > -1; ) {
                          chas[j--] = ' *' ;
                  }
          }

以上两道题目都是利用倒着复制这个技巧,其实很多字符串问题也和这个小技巧有关。字符串的面试题一般不会太难,很多题目都是考查代码实现能力的。

翻转字符串

【题目】

给定一个字符类型的数组chas,请在单词间做逆序调整。只要做到单词顺序逆序即可,对空格的位置没有特别要求。

【举例】

如果把chas看作字符串为"dog loves pig",调整成"pig Loves dog"。

如果把chas看作字符串为"I'm a student.",调整成"student. a I'm"。

【补充题目】

给定一个字符类型的数组chas和一个整数size,请把大小为size的左半区整体移到右半区,右半区整体移到左边。

【举例】

如果把chas看作字符串为"ABCDE",size=3,调整成"DEABC"。

【要求】

如果chas长度为N ,两道题都要求时间复杂度为O (N ),额外空间复杂度为O (1)。

【难度】

士 ★☆☆☆

【解答】

原问题。首先把chas整体逆序。在逆序之后,遍历chas找到每一个单词,然后把每个单词里的字符逆序即可。比如“dog loves pig”,先整体逆序变为“gip sevol god”,然后每个单词进行逆序处理就变成了“pig loves dog”。逆序之后找每一个单词的逻辑,做到不出错即可。全部过程请参看如下代码中的rotateWord方法。

          public void rotateWord(char[] chas) {
                  if (chas == null || chas.length == 0) {
                          return;
                  }
                  reverse(chas, 0, chas.length - 1);
                  int l = -1;
                  int r = -1;
                  for (int i = 0; i < chas.length; i++) {
                          if (chas[i] ! = ' ') {
                              l = i == 0 || chas[i - 1] == ' ' ? i : l;
                              r = i == chas.length - 1 || chas[i + 1] == ' ' ? i : r;
                          }
                          if (l ! = -1 && r ! = -1) {
                              reverse(chas, l, r);
                              l = -1;
                              r = -1;
                          }
                  }
          }

          public void reverse(char[] chas, int start, int end) {
                  char tmp = 0;
                  while (start < end) {
                          tmp = chas[start];
                          chas[start] = chas[end];
                          chas[end] = tmp;
                          start++;
                          end--;
                  }
          }

补充问题,方法一。先把chas[0..size-1]部分逆序,再把chas[size..N-1]部分逆序,最后把chas整体逆序即可。比如,chas="ABCDE",size=3。先把chas[0..2]部分逆序,chas变为"CBADE",再把chas[3..4]部分逆序,chas变为"CBAED",最后把chas整体逆序,chas变为"DEABC"。具体过程请参看如下代码中的rotate1方法。

          public static void rotate1(char[] chas, int size) {
                  if (chas == null || size <= 0 || size >= chas.length) {
                          return;
                  }
                  reverse(chas, 0, size - 1);
                  reverse(chas, size, chas.length - 1);
                  reverse(chas, 0, chas.length - 1);
          }

方法二。用举例的方式来说明这个过程,chas="1234567ABCD",size=7。

1.左部分为"1234567",右部分为"ABCD",右部分的长度为4,比左部分小,所以把左部分前4个字符与右部分交换,chas[0..10]变为"ABCD5671234"。右部分小,所以右部分"ABCD"换过去再也不需要移动,剩下的部分为chas[4..10]= "5671234"。左部分大,所以换过来的"1234"视为下一步的右部分,下一步的左部分为“567”。

2.左部分为"567",右部分为"1234",左部分的长度为3,比右部分小,所以把右部分的后3个字符与左部分交换,chas[4..10]变为"2341567"。左部分小,所以左部分"567"换过去再也不需要移动,剩下的部分为chas[4..7]= "2341"。右部分大,所以换过来的"234"视为下一步的左部分,下一步的右部分为"1"。

3.左部分为"234",右部分为"1"。右部分的长度为1,比左部分小,所以把左部分前1个字符与右部分交换,chas[4..7]变为"1342"。右部分小,所以右部分"1"换过去再也不需要移动,剩下的部分为chas[5..7]= "342"。左部分大,所以换过来的"2"视为下一步的右部分,下一步的左部分为"34"。

4.左部分为"34",右部分为"2"。右部分的长度为1,比左部分小,所以把左部分前1个字符与右部分交换,chas[5..7]变为"243"。右部分小,所以右部分"2"换过去再也不需要移动,剩下的部分为chas[6..7]= "43"。左部分大,所以换过来的"3"视为下一步的右部分,下一步的左部分为"4"。

5.左部分为"4",右部分为"3"。一旦发现左部分跟右部分的长度一样,那么左部分和右部分完全交换即可,chas[6..7]变为"34",整个过程结束,chas已经变为"ABCD1234567"。

如果每一次左右部分的划分进行M 次交换,那么都有M 个字符再也不需要移动,而字符数一共为N ,所以交换行为最多发生N 次。另外,如果某一次划分出的左右部分长度一样,那么交换完成后将不会再有新的划分,所以在很多时候交换行为会少于N 次。比如,chas="1234ABCD",size=4,最开始左部分为"1234",右部分为"ABCD",左右两个部分完全交换后为"ABCD1234",同时不会有后续的划分,所以这种情况下一共只有4次交换行为。具体过程请参看如下代码中的rotate2方法。

          public void rotate2(char[] chas, int size) {
                  if (chas == null || size <= 0 || size >= chas.length) {
                          return;
                  }
                  int start = 0;
                  int end = chas.length - 1;
                  int lpart = size;
                  int rpart = chas.length - size;
                  int s = Math.min(lpart, rpart);
                  int d = lpart - rpart;
                  while (true) {
                          exchange(chas, start, end, s);
                          if (d == 0) {
                                  break;
                          } else if (d > 0) {
                                  start += s;
                                  lpart = d;
                          } else {
                                  end -= s;
                                  rpart = -d;
                          }
                          s = Math.min(lpart, rpart);
                          d = lpart - rpart;
                  }
          }
          public void exchange(char[] chas, int start, int end, int size) {
                  int i = end - size + 1;
                  char tmp = 0;
                  while (size-- ! = 0) {
                          tmp = chas[start];
                          chas[start] = chas[i];
                          chas[i] = tmp;
                          start++;
                          i++;
                  }
          }

数组中两个字符串的最小距离

【题目】

给定一个字符串数组strs,再给定两个字符串str1和str2,返回在strs中str1与str2的最小距离,如果str1或str2为null,或不在strs中,返回-1。

【举例】

strs=["1","3","3","3","2","3","1"],str1="1",str2="2",返回2。

strs=["CD"],str1="CD",str2="AB",返回-1。

【进阶题目】

如果查询发生的次数有很多,如何把每次查询的时间复杂度降为O (1)?

【难度】

尉 ★★☆☆

【解答】

原问题。从左到右遍历strs,用变量last1记录最近一次出现的str1的位置,用变量last2记录最近一次出现的str2的位置。如果遍历到str1,那么i-last2的值就是当前的str1和左边最它最近的str2之间的距离。如果遍历到str2,那么i-last1的值就是当前的str2和左边最它最近的str1之间的距离。用变量min记录这些距离的最小值即可。请参看如下的minDistance方法。

          public int minDistance(String[] strs, String str1, String str2) {
                  if (str1 == null || str2 == null) {
                          return -1;
                  }
                  if (str1.equals(str2)) {
                          return 0;
                  }
                  int last1 = -1;
                  int last2 = -1;
                  int min = Integer.MAX_VALUE;
                  for (int i = 0; i ! = strs.length; i++) {
                          if (strs[i].equals(str1)) {
                                  min = Math.min(min, last2 == -1 ? min : i - last2);
                                  last1 = i;
                          }
                          if (strs[i].equals(str2)) {
                                  min = Math.min(min, last1 == -1 ? min : i - last1);
                                  last2 = i;
                          }
                  }
                  return min == Integer.MAX_VALUE ? -1 : min;
          }

进阶问题。其实是通过数组strs先生成某种记录,在查询时通过记录进行查询,本书提供了一种记录的结构供读者参考,如果strs的长度为N ,那么生成记录的时间复杂度为O (N 2 ),记录的空间复杂度为O (N 2 ),在生成记录之后,单次查询操作的时间复杂度可降为O(1)。本书实现的记录其实是一个哈希表HashMap<String,HashMap<String,Integer>>,这是一个key为string类型、value为哈希表类型的哈希表。为了描述清楚,我们把这个哈希表叫作外哈希表,把value代表的哈希表叫作内哈希表。外哈希表的key代表strs中的某种字符串,key所对应的内哈希表表示其他字符串到key字符串的最小距离。比如,当strs为["1","3","3","3","2","3","1"]时,生成的记录如下(外哈希表):

key Value(Value仍为一个哈希表,记为内哈希表)
"1" ("2",2) -> "1"到"2"的最小距离为2
("3",1) -> "1"到"3"的最小距离为1
"2" ("1",2) -> "2"到"1"的最小距离为2
("3",1) -> "2"到"3"的最小距离为1
"3" ("1",1) -> "3"到"1"的最小距离为1
("2",1) -> "3"到"2"的最小距离为1

如果生成了这种结构的记录,那么查询str1和str2的最小距离时只用两次哈希查询操作就可以完成。

如下代码的Record类就是这种记录结构的具体实现,建立记录过程就是Record类的构造函数,Record类中的minDistance方法就是做单次查询的方法。

          public class Record {
              private HashMap<String, HashMap<String, Integer>> record;

              public Record(String[] strArr) {
                      record = new HashMap<String, HashMap<String, Integer>>();
                      HashMap<String, Integer> indexMap = new HashMap<String, Integer>();
                      for (int i = 0; i ! = strArr.length; i++) {
                          String curStr = strArr[i];
                          update(indexMap, curStr, i);
                          indexMap.put(curStr, i);
                      }
                  }

              private void update(HashMap<String, Integer> indexMap, String str, int i) {
                  if (! record.containsKey(str)) {
                      record.put(str, new HashMap<String, Integer>());
                  }
                  HashMap<String, Integer> strMap = record.get(str);
                  for (Entry<String, Integer> lastEntry : indexMap.entrySet()) {
                      String key = lastEntry.getKey();
                      int index = lastEntry.getValue();
                      if (! key.equals(str)) {
                          HashMap<String, Integer> lastMap = record.get(key);
                          int curMin = i - index;
                          if (strMap.containsKey(key)) {
                                  int preMin = strMap.get(key);
                                  if (curMin < preMin) {
                                      strMap.put(key, curMin);
                                      lastMap.put(str, curMin);
                                  }
                          } else {
                                  strMap.put(key, curMin);
                                  lastMap.put(str, curMin);
                          }
                      }
                  }
              }

              public int minDistance(String str1, String str2) {
                  if (str1 == null || str2 == null) {
                          return -1;
                  }
                  if (str1.equals(str2)) {
                          return 0;
                  }
                  if (record.containsKey(str1) && record.get(str1).containsKey(str2)) {
                          return record.get(str1).get(str2);
                  }
                  return -1;
              }
          }

添加最少字符使字符串整体都是回文字符串

【题目】

给定一个字符串str,如果可以在str的任意位置添加字符,请返回在添加字符最少的情况下,让str整体都是回文字符串的一种结果。

【举例】

str="ABA"。str本身就是回文串,不需要添加字符,所以返回"ABA"。

str="AB"。可以在’A’之前添加’B',使str整体都是回文串,故可以返回"BAB"。也可以在’B’之后添加’A',使str整体都是回文串,故也可以返回"ABA"。总之,只要添加的字符数最少,只返回其中一种结果即可。

【进阶题目】

给定一个字符串str,再给定str的最长回文子序列字符串strlps,请返回在添加字符最少的情况下,让str整体都是回文字符串的一种结果。进阶问题比原问题多了一个参数,请做到时间复杂度比原问题的实现低。

【举例】

str="A1B21C",strlps="121",返回"AC1B2B1CA"或者"CA1B2B1AC"。总之,只要是添加的字符数最少,只返回其中一种结果即可。

【难度】

校 ★★★☆

【解答】

原问题。在求解原问题之前,我们先来解决下面这个问题,如果可以在str的任意位置添加字符,最少需要添几个字符可以让str整体都是回文字符串。这个问题可以用动态规划的方法求解。如果str的长度为N ,动态规划表是一个N ×N 的矩阵记为dp[][]。dp[i][j]值的含义代表子串str[i..j]最少添加几个字符可以使str[i..j]整体都是回文串。那么,如果求dp[i][j]的值呢?有如下三种情况:

● 如果字符串str[i..j]只有一个字符,此时dp[i][j]=0,这是很明显的,如果str[i..j]只有一个字符,那么str[i..j]已经是回文串了,自然不必添加任何字符。

● 如果字符串str[i..j]只有两个字符。如果两个字符相等,那么dp[i][j]=0。比如,如果str[i..j]为"AA",两字符相等,说明str[i..j]已经是回文串,自然不必添加任何字符。如果两个字符不相等,那么dp[i][j]=1。比如,如果str[i..j]为"AB",只用添加一个字符就可以令str[i..j]变成回文串,所以dp[i][j]=1。

● 如果字符串str[i..j]多于两个字符。如果str[i]==str[j],那么dp[i][j]=dp[i+1][j-1]。比如,如果str[i..j]为"A124521A",str[i..j]需要添加的字符数与str[i+1..j-1](即"124521")需要添加的字符数是相等的,因为只要能把"124521"整体变成回文串,然后在左右两头加上字符’A',就是str[i..j]整体变成回文串的结果。如果str[i]! =str[j],要让str[i..j]整体变为回文串有两种方法,一种方法是让str[i..j-1]先变成回文串,然后在左边加上字符str[j],就是str[i..j]整体变成回文串的结果。另一种方法是让str[i+1..j]先变成回文串,然后在右边加上字符str[i],就是str[i..j]整体变成回文串的结果。两种方法中哪个代价最小就选择哪个,即dp[i][j] = min { dp[i][j-1] ,dp[i+1][j] }+1。

既然dp[i][j]值代表子串str[i..j]最少添加几个字符可以使str[i..j]整体都是回文串,所以根据上面的方法求出整个dp矩阵之后,我们就得到了str中任何一个子串添加几个字符后可以变成回文串。具体请参看如下代码中的getDP方法。

          public int[][] getDP(char[] str) {
                  int[][] dp = new int[str.length][str.length];
                  for (int j = 1; j < str.length; j++) {
                          dp[j - 1][j] = str[j - 1] == str[j] ? 0 : 1;
                          for (int i = j - 2; i > -1; i--) {
                                  if (str[i] == str[j]) {
                                    dp[i][j] = dp[i + 1][j - 1];
                                  } else {
                                    dp[i][j] = Math.min(dp[i + 1][j], dp[i][j - 1]) + 1;
                                  }
                          }
                  }
                  return dp;
          }

下面介绍如何根据dp矩阵,求在添加字符最少的情况下,让str整体都是回文字符串的一种结果。首先,dp[0][N-1]的值代表整个字符串最少需要添加几个字符,所以,如果最后的结果记为字符串res,res的长度=dp[0][N-1]+str的长度,然后依次设置res左右两头的字符。具体过程如下:

1.如果str[i..j]中str[i]==str[j],那么str[i..j]变成回文串的最终结果=str[i]+str[i+1..j-1]变成回文串的结果+str[j],此时res左右两头的字符为str[i](也是str[j]),然后继续根据str[i+1..j-1]和矩阵dp来设置res的中间部分。

2.如果str[i..j]中str[i]! =str[j],看dp[i][j-1]和dp[i+1][j]哪个小。如果dp[i][j-1]更小,那么str[i..j]变成回文串的最终结果=str[j]+str[i..j-1]变成回文串的结果+str[j],所以此时res左右两头的字符为str[j],然后继续根据str[i..j-1]和矩阵dp来设置res的中间部分。而如果dp[i+1][j]更小,那么str[i..j]变成回文串的最终结果=str[i]+str[i+1..j]变成回文串的结果+str[i],所以此时res左右两头的字符为str[i],然后继续根据str[i+1..j]和矩阵dp来设置res的中间部分。如果一样大,任选一种设置方式都可以得出最终结果。

3.如果发现res所有的位置都已设置完毕,过程结束。

原问题解法的全部过程请参看如下代码中的getPalindrome1方法。

          public String getPalindrome1(String str) {
                  if (str == null || str.length() < 2) {
                          return str;
                  }
                  char[] chas = str.toCharArray();
                  int[][] dp = getDP(chas);
                  char[] res = new char[chas.length + dp[0][chas.length - 1]];
                  int i = 0;
                  int j = chas.length - 1;
                  int resl = 0;
                  int resr = res.length - 1;
                  while (i <= j) {
                          if (chas[i] == chas[j]) {
                                  res[resl++] = chas[i++];
                                  res[resr--] = chas[j--];
                          } else if (dp[i][j - 1] < dp[i + 1][j]) {
                                  res[resl++] = chas[j];
                                  res[resr--] = chas[j--];
                          } else {
                                  res[resl++] = chas[i];
                                  res[resr--] = chas[i++];
                          }
                  }
                  return String.valueOf(res);
          }

求解dp矩阵的时间复杂度为O (N 2 ),根据str和dp矩阵求解最终结果的过程为O (N ),所以原问题解法中总的时间复杂度为O (N 2 )。

进阶问题。如果有最长回文子序列字符串strlps,那么求解的时间复杂度可以加速到O (N )。如果str的长度为N ,strlps的长度为M ,则整体回文串的长度应该是2×N -M 。本书提供的解法类似“剥洋葱”的过程,给出示例来具体说明:

str="A1BC22DE1F",strlps = "1221"。res=...长度为2×N -M ...

洋葱的第0层由strlps[0]和strlps[M-1]组成,即"1...1"。从str最左侧开始找字符’1',发现’A’是str第0个字符,'1’是str第1个字符,所以左侧第0层洋葱圈外的部分为"A",记为leftPart。从str最右侧开始找字符’1',发现右侧第0层洋葱圈外的部分为"F",记为rightPart。把(leftPart+rightPart的逆序)复制到res左侧未设值的部分,把(rightPart+leftPart逆序)复制到res的右侧未设值的部分,即result变为"AF...FA"。把洋葱的第0层复制进res的左右两侧未设值的部分,即result变为"AF1…1FA"。至此,洋葱第0层被剥掉。洋葱的第1层由strlps[1]和strlps[M-2]组成,即"2...2"。从str左侧的洋葱第0层往右找"2",发现左侧第1层洋葱圈外的部分为"BC",记为leftPart。从str右侧的洋葱第0层往左找"2",发现右侧第1层洋葱圈外的部分为"DE",记为rightPart。把(leftPart+rightPart的逆序)复制到res左侧未设值的部分,把(rightPart+leftPart逆序)复制到res的右侧未设值的部分,res变为"AF1BCED..DECB1FA"。把洋葱的第1层复制进res的左右两侧未设值的部分,即result变为"AF1BCED2..2DECB1FA"。第1层被剥掉,洋葱剥完了,返回"AF1BCED22DECB1FA"。整个过程就是不断找到洋葱圈的左部分和右部分,把(leftPart+rightPart的逆序)复制到res左侧未设值的部分,把(rightPart+leftPart逆序)复制到res的右侧未设值的部分,洋葱剥完则过程结束。具体请参看如下的getPalindrome2方法。

          public String getPalindrome2(String str, String strlps) {
                  if (str == null || str.equals("")) {
                          return "";
                  }
                  char[] chas = str.toCharArray();
                  char[] lps = strlps.toCharArray();
                  char[] res = new char[2 * chas.length - lps.length];
                  int chasl = 0;
                  int chasr = chas.length - 1;
                  int lpsl = 0;
                  int lpsr = lps.length - 1;
                  int resl = 0;
                  int resr = res.length - 1;
                  int tmpl = 0;
                  int tmpr = 0;
                  while (lpsl <= lpsr) {
                          tmpl = chasl;
                          tmpr = chasr;
                          while (chas[chasl] ! = lps[lpsl]) {
                                  chasl++;
                          }
                          while (chas[chasr] ! = lps[lpsr]) {
                                  chasr--;
                          }
                          set(res, resl, resr, chas, tmpl, chasl, chasr, tmpr);
                          resl += chasl - tmpl + tmpr - chasr;
                          resr -= chasl - tmpl + tmpr - chasr;
                          res[resl++] = chas[chasl++];
                          res[resr--] = chas[chasr--];
                          lpsl++;
                          lpsr--;
                  }
                  return String.valueOf(res);
          }

          public void set(char[] res, int resl, int resr, char[] chas, int ls,
                          int le, int rs, int re) {
                  for (int i = ls; i < le; i++) {
                          res[resl++] = chas[i];
                          res[resr--] = chas[i];
                  }
                  for (int i = re; i > rs; i--) {
                          res[resl++] = chas[i];
                          res[resr--] = chas[i];
                  }
          }

括号字符串的有效性和最长有效长度

【题目】

给定一个字符串str,判断是不是整体有效的括号字符串。

【举例】

str="()",返回true; str="(()())",返回true; str="(())",返回true。

str="())"。返回false; str="()(",返回false; str="()a()",返回false。

【补充题目】

给定一个括号字符串str,返回最长的有效括号子串。

【举例】

str="(()())",返回6; str="())",返回2; str="()(()()(",返回4。

【难度】

原问题 士 ★☆☆☆

补充问题 尉 ★★☆☆

【解答】

原问题。判断过程如下:

1.从左到右遍历字符串str,判断每一个字符是不是’(’或’)',如果不是,就直接返回false。

2.遍历到每一个字符时,都检查到目前为止’(’和’)’的数量,如果’)’更多,则直接返回false。

3.遍历后检查’(’和’)’的数量,如果一样多,则返回true,否则返回false。

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

          public boolean isValid(String str) {
                  if (str == null || str.equals("")) {
                          return false;
                  }
                  char[] chas = str.toCharArray();
                  int status = 0;
                  for (int i = 0; i < chas.length; i++) {
                          if (chas[i] ! = ')' && chas[i] ! = ' (') {
                                  return false;
                          }
                          if (chas[i] == ')' && --status < 0) {
                                  return false;
                          }
                          if (chas[i] == ' (') {
                                  status++;
                          }
                  }
                  return status == 0;
          }

补充问题。用动态规划求解,可以做到时间复杂度为O (N ),额外空间复杂度为O (N )。首先生成长度和str字符串一样的数组dp[],dp[i]值的含义为str[0..i]中必须以字符str[i]结尾的最长的有效括号子串长度。那么dp[i]值可以按如下方式求解:

1.dp[0]=0。只含有一个字符肯定不是有效括号字符串,长度自然是0。

2.从左到右依次遍历str[1..N-1]的每个字符,假设遍历到str[i]。

3.如果str[i]==' (',有效括号字符串必然是以’)’结尾,而不是以’(’结尾,所以dp[i] = 0。

4.如果str[i]==')',那么以str[i]结尾的最长有效括号子串可能存在。dp[i-1]的值代表必须以str[i-1]结尾的最长有效括号子串的长度,所以如果i-dp[i-1]-1位置上的字符是’(',就能与当前位置的str[i]字符再配出一对有效括号。比如"(()())",假设遍历到最后一个字符’)',必须以倒数第二个字符结尾的最长有效括号子串是"()()",找到这个子串之前的字符,即i-dp[i-1]-1位置的字符,发现是’(',所以它可以和最后一个字符再配出一对有效括号。如果该情况发生,dp[i]的值起码是dp[i-1]+2,但还有一部分长度容易被人忽略。比如,"()(())",假设遍历到最后一个字符’)',通过上面的过程找到的必须以最后字符结尾的最长有效括号子串起码是"(())",但是前面还有一段"()",可以和"(())"结合在一起构成更大的有效括号子串。也就是说,str[i-dp[i-1]-1]和str[i]配成了一对,这时还应该把dp[i-dp[i-1]-2]的值加到dp[i]中,这么做表示把str[i-dp[i-1]-2]结尾的最长有效括号子串接到前面,才能得到以当前字符结尾的最长有效括号子串。

5.dp[0..N-1]中的最大值就是最终的结果。

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

          public int maxLength(String str) {
                  if (str == null || str.equals("")) {
                          return 0;
                  }
                  char[] chas = str.toCharArray();
                  int[] dp = new int[chas.length];
                  int pre = 0;
                  int res = 0;
                  for (int i = 1; i < chas.length; i++) {
                          if (chas[i] == ')') {
                              pre = i - dp[i - 1] - 1;
                              if (pre >= 0 && chas[pre] == ' (') {
                                  dp[i] = dp[i - 1] + 2 + (pre > 0 ? dp[pre - 1] : 0);
                              }
                          }
                          res = Math.max(res, dp[i]);
                  }
                  return res;
          }

公式字符串求值

【题目】

给定一个字符串str,str表示一个公式,公式里可能有整数、加减乘除符号和左右括号,返回公式的计算结果。

【举例】

str="48*((70-65)-43)+8*1",返回-1816。

str="3+1*4",返回7。

str="3+(1*4)",返回7。

【说明】

1.可以认为给定的字符串一定是正确的公式,即不需要对str做公式有效性检查。

2.如果是负数,就需要用括号括起来,比如"4*(-3)"。但如果负数作为公式的开头或括号部分的开头,则可以没有括号,比如"-3*4"和"(-3*4)"都是合法的。

3.不用考虑计算过程中会发生溢出的情况。

【难度】

校 ★★★☆

【解答】

本题考查面试者设计程序和代码实现的能力,实现方式有很多,本书提供一种方法供读者参考。假设value方法是一个递归过程,具体解释如下。

从左到右遍历str,开始遍历或者遇到字符’(’时,就进行递归过程。当发现str遍历完,或者遇到字符’)’时,递归过程就结束。比如"3*(4+5)+7",一开始遍历就进入递归过程value(str,0),在递归过程value(str,0)中继续遍历str,当遇到字符’(’时,递归过程value(str,0)又重复调用递归过程value(str,3)。然后在递归过程value(str,3)中继续遍历str,当遇到字符’)'时,递归过程value(str,3)结束,并向递归过程value(str,0)返回两个结果,第一结果是value(str,3)遍历过的公式字符子串的结果,即"4+5"==9,第二个结果是value(str,3)遍历到的位置,即字符")"的位置==6。递归过程value(str,0)收到这两个结果后,既可知道交给value(str,3)过程处理的字符串结果是多少("(4+5)"的结果是9),又可知道自己下一步该从什么位置继续遍历(该从位置6的下一个位置(即位置7)继续遍历)。总之,value方法的第二个参数代表递归过程是从什么位置开始的,返回的结果是一个长度为2的数组,记为res。res[0]表示这个递归过程计算的结果,res[1]表示这个递归过程遍历到str的什么位置。

既然在递归过程中遇到’(’就交给下一层的递归过程处理,自己只用接收’(’和’)’之间的公式字符子串的结果,所以对所有的递归过程来说,可以看作计算的公式都是不含有’(’和’)'字符的。比如,对递归过程value(str,0)来说,实际上计算的公式是"3*9+7","(4+5)"的部分交给递归过程value(str,3)处理,拿到结果9之后,再从字符’+’继续。所以,只要想清楚如何计算一个不含有’(’和’)’的公式字符串,整个实现就完成了。

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

          public int getValue(String exp) {
                  return value(exp.toCharArray(), 0)[0];
          }

          public int[] value(char[] chars, int i) {
                  Deque<String> deq = new LinkedList<String>();
                  int pre = 0;
                  int[] bra = null;
                  while (i < chars.length && chars[i] ! = ')') {
                          if (chars[i] >= '0' && chars[i] <= '9') {
                                  pre = pre * 10 + chars[i++] - '0' ;
                          } else if (chars[i] ! = ' (') {
                                  addNum(deq, pre);
                                  deq.addLast(String.valueOf(chars[i++]));
                                  pre = 0;
                          } else {
                                  bra = value(chars, i + 1);
                                  pre = bra[0];
                                  i = bra[1] + 1;
                          }
                  }
                  addNum(deq, pre);
                  return new int[] { getNum(deq), i };
          }

          public void addNum(Deque<String> deq, int num) {
                  if (! deq.isEmpty()) {
                          int cur = 0;
                          String top = deq.pollLast();
                          if (top.equals("+") || top.equals("-")) {
                                  deq.addLast(top);
                          } else {
                                  cur = Integer.valueOf(deq.pollLast());
                                  num = top.equals("*") ? (cur * num) : (cur / num);
                          }
                  }
                  deq.addLast(String.valueOf(num));
          }

          public int getNum(Deque<String> deq) {
                  int res = 0;
                  boolean add = true;
                  String cur = null;
                  int num = 0;
                  while (! deq.isEmpty()) {
                          cur = deq.pollFirst();
                          if (cur.equals("+")) {
                                  add = true;
                          } else if (cur.equals("-")) {
                                  add = false;
                          } else {
                                  num = Integer.valueOf(cur);
                                  res += add ? num : (-num);
                          }
                  }
                  return res;
          }

0左边必有1的二进制字符串数量

【题目】

给定一个整数N ,求由"0"字符与"1"字符组成的长度为N 的所有字符串中,满足"0"字符的左边必有"1"字符的字符串数量。

【举例】

N =1。只由"0"与"1"组成,长度为1的所有字符串:"0"、"1"。只有字符串"1"满足要求,所以返回1。

N =2。只由"0"与"1"组成,长度为2的所有字符串为:"00"、"01"、"10"、"11"。只有字符串"10"和"11"满足要求,所以返回2。

N =3。只由"0"与"1"组成,长度为3的所有字符串为:"000"、"001"、"010"、"011"、"100"、"101"、"110"、"111"。字符串"101"、"110"、"111"满足要求,所以返回3。

【难度】

校 ★★★☆

【解答】

先说一种最暴力的方法,就是检查每一个长度为N 的二进制字符串,看有多少符合要求。一个长度为N 的二进制字符串,检查是否符合要求的时间复杂度为O (N ),长度为N 的二进制字符串数量为O (2N ),所以该方法整体的时间复杂度为O (2N ×N ),本书不再详述。

O (2N )的方法。假设第0位的字符为最高位字符,很明显,第0位的字符不能为’0'。假设p (i )表示0~i -1位置上的字符已经确定,这一段符合要求且第i -1位置的字符为’1’时,如果穷举iN -1位置上的所有情况会产生多少种符合要求的字符串。比如N =5,p (3)表示0~2位置上的字符已经确定,这一段符合要求且位置2上的字符为’1’时,假设为"101.."。在这种情况下,穷举3~4位置所有可能的情况会产生多少种符合要求的字符串,因为只有"10101"、"10110"和"10111",所以p (3)=3。也可以假设前三位是"111..",p (3)同样等于3。有了p (i)的定义,同时知道不管N 是多少,最高位的字符只能为’1',那么只要求出p (1)就是所有符合要求的字符串数量。

那到底p (i )应该怎么求呢?根据p (i )的定义,在位置i -1的字符已经为’1’的情况下,位置i 的字符可以是’1',也可以是’0'。如果位置i 的字符是’1',那么穷举剩下字符的所有可能性,并且符合要求的字符串数量就是p (i +1)的值。如果位置i 的字符是’0',那么位置i +1的字符必须是’1',穷举剩下字符的所有可能性,符合要求的字符串数量就是p (i +1)的值。所以p (i )=p (i +1)+p (i +2)。p (N -1)表示除了最后位置的字符,前面的子串全符合要求,并且倒数第二个字符为’1',此时剩下的最后一个字符既可以是’1',也可以是’0',所以p (N -1)=2。p(N )表示所有的字符串已经完全确定,并且符合要求,最后一个字符(N -1)为’1',所以,此时符合要求的字符串数量就是0~N -1的全体,而不再有后续的可能性,所以p (N )=1。即p (i )如下:

i < N -1时,p (i ) = p (i +1)+p (i +2)

i = N -1时,p (i ) = 2

i = N 时,p (i ) = 1

很明显,可以写成时间复杂度为O (2N )的递归方法。具体请参看如下的getNum1方法。

          public int getNum1(int n) {
                  if (n < 1) {
                          return 0;
                  }
                  return process(1, n);
          }

          public int process(int i, int n) {
                  if (i == n - 1) {
                          return 2;
                  }
                  if (i == n) {
                          return 1;
                  }
                  return process(i + 1, n) + process(i + 2, n);
          }

根据O (2N )的方法,当N 分别为1,2,3,4,5,6,7,8时,结算的结果为1,2,3,5,8,13,21,34。可以看出,这就是一个形如斐波那契数列的结果,唯一的区别就是斐波那契数列的初始项为1,1。而这个数列的初始项为1,2。所以可很轻易地写出时间复杂度为O (N ),额外空间复杂度为O (1)的方法。具体请参看如下代码中的getNum2方法。

          public int getNum2(int n) {
                  if (n < 1) {
                          return 0;
                  }
                  if (n == 1) {
                          return 1;
                  }
                  int pre = 1;
                  int cur = 1;
                  int tmp = 0;
                  for (int i = 2; i < n + 1; i++) {
                          tmp = cur;
                          cur += pre;
                          pre = tmp;
                  }
                  return cur;
          }

打开了斐波那契数列的这个天窗,我们知道求解斐波那契数列的过程,有时间复杂度为O (logN )方法就是用矩阵乘法的办法求解,具体解释请参考本书“斐波那契数列的3种解法”,这里不再详述。代码实现请参看如下代码中的getNum3方法。

          public int getNum3(int n) {
                  if (n < 1) {
                          return 0;
                  }
                  if (n == 1 || n == 2) {
                          return n;
                  }
                  int[][] base = { { 1, 1 }, { 1, 0 } };
                  int[][] res = matrixPower(base, n - 2);
                  return 2 * res[0][0] + res[1][0];
          }

          public int[][] matrixPower(int[][] m, int p) {
                  int[][] res = new int[m.length][m[0].length];
                  for (int i = 0; i < res.length; i++) {
                          res[i][i] = 1;
                  }
                  int[][] tmp = m;
                  for (; p ! = 0; p >>= 1) {
                          if ((p & 1) ! = 0) {
                                  res = muliMatrix(res, tmp);
                          }
                          tmp = muliMatrix(tmp, tmp);
                  }
                  return res;
          }

          public int[][] muliMatrix(int[][] m1, int[][] m2) {
                  int[][] res = new int[m1.length][m2[0].length];
                  for (int i = 0; i < m2[0].length; i++) {
                          for (int j = 0; j < m1.length; j++) {
                                  for (int k = 0; k < m2.length; k++) {
                                          res[i][j] += m1[i][k] * m2[k][j];
                                  }
                          }
                  }
                  return res;
          }

拼接所有字符串产生字典顺序最小的大写字符串

【题目】

给定一个字符串类型的数组strs,请找到一种拼接顺序,使得将所有的字符串拼接起来组成的大写字符串是所有可能性中字典顺序最小的,并返回这个大写字符串。

【举例】

strs=[ "abc","de" ],可以拼成"abcde",也可以拼成"deabc",但前者的字典顺序更小,所以返回"abcde"。

strs=["b","ba" ],可以拼成"bba",也可以拼成"bab",但后者的字典顺序更小,所以返回"bab"。

【难度】

校 ★★★☆

【解答】

有一种思路为:先把strs中的字符串按照字典顺序排序,然后将串起来的结果返回。这么做是错误的,比如题目中的例子2,按照字典排序结果是B、BA,串起来的大写字符串为"BBA",但是字典顺序最小的大写字符串是"BAB",所以按照单个字符串的字典顺序进行排序的想法是行不通的。如果要排序,应该按照下文描述的标准进行排序。

假设有两个字符串,分别记为a和b,a和b拼起来的字符串表示为a.b。那么如果a.b的字典顺序小于b.a,就把字符串a放在前面,否则把字符串b放在前面。每两个字符串之间都按照这个标准进行比较,以此标准排序后,再依次串起来的大写字符串就是结果。这样做为什么对呢?当然需要证明。

证明的关键步骤是证明这种比较方式具有传递性。

假设有a、b、c三个字符串,它们有如下关系:

a.b < b.a

b.c < c.b

如果能够根据上面两式证明出a.c < c.a,说明这种比较方式具有传递性,证明过程如下:字符串的本质是K 进制数,比如,只由字符’a' ~'z’组成的字符串其实可以看作26进制的数。那么字符串a.b这个数可以看作a这个数是它的高位,b是低位,即a.b=a*K的b长度次方+b。举一个十进制数的例子,x=123,y=6789,x.y=x*10000+y=1230000+6789,其中,10000=10的4次方,4是y的长度。为了让证明过程便于阅读,我们把“K的b长度次方”记为k(b)。则原来的不等式可化简为:

a.b < b.a => a*k(b) + b < b*k(a) + a不等式1

b.c < c.b => b*k(c) + c < c*k(b) + b不等式2

现在要证明a.c < c.a,即证明a*k(c)+c < c*k(a)+a。

不等式1的左右两边同时减去b,再乘以c,变为a*k(b)*c < b*k(a)*c+a*c-b*c。

不等式2的左右两边同时减去b,再乘以a,变为b*k(c)*a + c*a - b*a < c*k(b)*a。

a,b,c是K 进制数,服从乘法交换律,有a*k(b)*c == c*k(b)*a,所以有如下不等式:

b*k(c)*a + c*a-b*a < c*k(b)*a == a*k(b)*c < b*k(a)*c + a*c - b*c

=> b*k(c)*a + c*a - b*a < b*k(a)*c + a*c-b*c

=> b*k(c)*a - b*a < b*k(a)*c - b*c

=> a*k(c) - a < c*k(a) - c

=> a*k(c) + c < c*k(a) + a

即a.c < c.a,传递性证明完毕。

证明传递性后,还需要证明通过这种比较方式排序后,如果交换任意两个字符串的位置所得到的总字符串,将拥有更大的字典顺序。

假设通过如上比较方式排序后,得到字符串的序列为:

...A.M1.M2...M(n-1).M(n).L...

该序列表示,代号为A的字符串之前与代号为L的字符串之后都有若干字符串用“…”表示,A和L中间有若干字符串,用M1..M(n)。现在交换A和L这两个字符串,交换之前和交换之后两个总字符串就分别为:

...A.M1.M2...M(n-1).M(n).L...换之前

...L.M1.M2...M(n-1).M(n).A...换之后

现在需要证明交换之后的总字符串字典顺序大于交换之前的,具体过程如下。

在排好序的序列中,M1排在L的前面,所以有M1.L < L.M1,进一步有:

...L.M1.M2...M(n-1).M(n).A... > ...M1.L.M2...M(n-1).M(n).A...

在排好序的序列中,M2排在L的前面,所以有M2.L < L.M2,进一步有:

...M1.L.M2...M(n-1).M(n).A... > ...M1.M2.L...M(n-1).M(n).A...

在排好序的序列中,M(i)排在L的前面,所以有M(i).L < L.M(i),进一步有:

...M1.M2...L.M(i)...M(n-1).M(n).A... > ...M1.M2...M(i).L...M(n-1).M(n).A...

最终,...M1.M2...M(n-1).M(n).L.A... > ...M1.M2...M(n-1).M(n).A.L...

在排好序的序列中,A排在M(N)的前面,所以有A.M(n) < M(n).A,进一步有:

...M1.M2...M(n-1).M(n).A.L... > ...M1.M2...M(n-1).A.M(n).L...

在排好序的序列中,A排在M(n-1)的前面,所以有A.M(n-1) < M(n-1).A,进一步有:

...M1.M2...M(n-1).A.M(n).L... > ...M1.M2...A.M(n-1).M(n).L...

最终,...M1.A.M2...M(n-1).M(n).L... > ...A.M1.M2...M(n-1).M(n).L...

所以,...A.M1.M2...M(n-1).M(n).L... < ... < ...L.M1.M2...M(n-1).M(n).A...

解法有效性证明完毕。

那么整个解法的时间复杂度就是排序本身的复杂度,即O (N logN )。具体请参看如下代码中的lowestString方法。

          public class MyComparator implements Comparator<String> {
                  @Override
                  public int compare(String a, String b) {
                          return (a + b).compareTo(b + a);
                  }
          }

          public String lowestString(String[] strs) {
                  if (strs == null || strs.length == 0) {
                          return "";
                  }
                  // 根据新的比较方式排序
                  Arrays.sort(strs, new MyComparator());
                  String res = "";
                  for (int i = 0; i < strs.length; i++) {
                          res += strs[i];
                  }
                  return res;
          }

本题的解法看似非常简单,但解法有效性的证明却比较复杂。在这里不得不提醒读者,这道题的解题方法可以划进贪心算法的范畴,这种有效的比较方式就是我们的贪心策略。

正如本题所展示的一样,贪心策略容易大胆假设,但策略有效性的证明可就不容易求证了。在面试中,如果哪一个题目决定用贪心方法求解,则必须用较大的篇幅去证明你提出的贪心策略是有效的。所以建议面试准备时间不充裕的读者不要轻易去啃有关贪心策略的题目,那将占用大量的时间和精力。

在面试中,实际上也较少出现需要用到贪心策略的题目,造成这个现象有两个很重要的原因,其一是考查贪心策略的面试题目,关键点在于数学上对策略的证明过程,偏离考查编程能力的面试初衷。其二是纯用贪心策略的面试题,解法的正确性完全在于贪心策略的成败,而缺少其他解法的多样性,这样就会使这一类面试题的区分度极差,所以往往不会成为大公司的面试题。贪心策略在算法上的地位当然重要,但对初期准备代码面试的读者来说,性价比不高。

找到字符串的最长无重复字符子串

【题目】

给定一个字符串str,返回str的最长无重复字符子串的长度。

【举例】

str="abcd",返回4

str="aabcb",最长无重复字符子串为"abc",返回3。

【要求】

如果str的长度为N ,请实现时间复杂度为O (N )的方法。

【难度】

尉 ★★☆☆

【解答】

如果str长度为N ,字符编码范围是M ,本题可做到的时间复杂度为O (N ),额外空间复杂度为O (M )。下面介绍这种方法的具体实现。

1.在遍历str之前,先申请几个变量。哈希表map,key表示某个字符,value为这个字符最近一次出现的位置。整型变量pre,如果当前遍历到字符str[i],pre表示在必须以str[i-1]字符结尾的情况下,最长无重复字符子串开始位置的前一个位置,初始时pre=-1。整型变量len,记录以每一个字符结尾的情况下,最长无重复字符子串长度的最大值,初始时,len=0。从左到右依次遍历str,假设现在遍历到str[i],接下来求在必须以str[i]结尾的情况下,最长无重复字符子串的长度。

2.map(str[i])的值表示之前的遍历中最近一次出现str[i]字符的位置,假设在a位置。想要求以str[i]结尾的最长无重复子串,a位置必然不能包含进来,因为str[a]等于str[i]。

3.根据pre的定义,pre+1表示在必须以str[i-1]字符结尾的情况下,最长无重复字符子串的开始位置,也就是说,以str[i-1]结尾的最长无重复子串是向左扩到pre位置停止的。

4.如果pre位置在a位置的左边,因为str[a]不能包含进来,而str[a+1..i-1]上都是不重复的,所以以str[i]结尾的最长无重复字符子串就是str[a+1..i]。如果pre位置在a位置的右边,以str[i-1]结尾的最长无重复子串是向左扩到pre位置停止的。所以以str[i]结尾的最长无重复子串向左扩到pre位置也必然会停止,而且str[pre+1..i-1]这一段上肯定不含有str[i],所以以str[i]结尾的最长无重复字符子串就是str[pre+1..i]。

5.计算完长度之后,pre位置和a位置哪一个在右边,就作为新的pre值。然后去计算下一个位置的字符,整个过程中求得所有长度的最大值用len记录下来返回即可。

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

          public int maxUnique(String str) {
                  if (str == null || str.equals("")) {
                          return 0;
                  }
                  char[] chas = str.toCharArray();
                  int[] map = new int[256];
                  for (int i = 0; i < 256; i++) {
                          map[i] = -1;
                  }
                  int len = 0;
                  int pre = -1;
                  int cur = 0;
                  for (int i = 0; i ! = chas.length; i++) {
                          pre = Math.max(pre, map[chas[i]]);
                          cur = i - pre;
                          len = Math.max(len, cur);
                          map[chas[i]] = i;
                  }
                  return len;
          }

找到被指的新类型字符

【题目】

新类型字符的定义如下:

1.新类型字符是长度为1或者2的字符串。

2.表现形式可以仅是小写字母,例如,"e";也可以是大写字母+小写字母,例如,"Ab";还可以是大写字母+大写字母,例如,"DC"。

现在给定一个字符串str,str一定是若干新类型字符正确组合的结果。比如"eaCCBi",由新类型字符"e"、"a"、"CC"和"Bi"拼成。再给定一个整数k ,代表str中的位置。请返回被k 位置指中的新类型字符。

【举例】

str="aaABCDEcBCg"。

1.k =7时,返回"Ec"。

2.k =4时,返回"CD"。

3.k =10时,返回"g"。

【难度】

士 ★☆☆☆

【解答】

一种笨方法是从str[0]开始,从左到右依次划分出新类型字符,到k 位置的时候就知道指向的新类型字符是什么。比如str="aaABCDEcBCg",k =7。从左到右可以依次划分出"a"、"a"、"AB"、"CD"。然后发现str[7]是大写字母’E',所以被指中的新类型字符一定是"EC",返回即可。

更快的方法。从k -1位置开始,向左统计连续出现的大写字母的数量记为uNum,遇到小写字母就停止。如果uNum为奇数,str[k-1..k]是被指中的新类型字符,见例子1。如果uNum为偶数且str[k]是大写字母,str[k..k+1]是被指中的新类型字符,见例子2。如果uNum为偶数且str[k]是小写字母,str[k]是被指中的新类型字符,见例子3。

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

          public String pointNewchar(String s, int k) {
                  if (s == null || s.equals("") || k < 0 || k >= s.length()) {
                          return "";
                  }
                  char[] chas = s.toCharArray();
                  int uNum = 0;
                  for (int i = k - 1; i >= 0; i--) {
                          if (! isUpper(chas[i])) {
                                  break;
                          }
                          uNum++;
                  }
                  if ((uNum & 1) == 1) {
                          return s.substring(k - 1, k + 1);
                  }
                  if (isUpper(chas[k])) {
                          return s.substring(k, k + 2);
                  }
                  return String.valueOf(chas[k]);
          }

最小包含子串的长度

【题目】

给定字符串str1和str2,求str1的子串中含有str2所有字符的最小子串长度。

【举例】

str1="abcde",str2="ac"。因为"abc"包含str2的所有字符,并且在满足这一条件的str1的所有子串中,"abc"是最短的,返回3。

str1="12345",str2="344"。最小包含子串不存在,返回0。

【难度】

校 ★★★☆

【解答】

如果str1的长度为N ,str2的长度为M ,本书提供的方法时间复杂度为O (N )。

如果str1或者str2为空,或者N 小于M ,那么最小包含子串必然不存在,直接返回0。接下来讨论一般情况,即str1和str2不为空且N 不小于M 。为了便于理解,现在以str1="adabbca",str2="acb"来举例说明整个过程。

1.在开始遍历str1之前,先通过遍历str2来生成哈希表map的一些记录如下:

        Map   key   value
              'a'    1
              'b'    1
              'c'    1

哈希表记为map,key为char类型,value为int型。每条记录的意义是,对于key字符,str1字符串目前还欠str2字符串value个。

2.需要定义如下4个变量。

1)left:遍历str1的过程中,str1[left..right]表示被框住的子串,所以left表示这个子串的左边界,初始时,left=0。

2)right:right表示被框住子串的右边界,初始时,right=0。

3)match:表示对所有的字符来说,str1[left..right]目前一共欠str2多少个。对本例来说,初始时,match=3,即开始时欠1个’a'、1个’c’和1个’b'。

4)minLen:最终想要的结果为最小包含子串的长度,初始时为32位整数最大值。

3.接下来开始通过right变量从左到右遍历str1。

1)right==0,str[0]=='a'。在map中把key为’a’的value减1,减完后变为('a' ,0)。减完之后value为0,说明减之前大于0,那么str1归还了1个’a' ,match值也要减1,表示对str2的所有字符来说,str1目前归还了1个。目前变量状况如下:

        map   key   value
              'a'    0
              'b'    1
              'c'    1
        match==2, left==0, right==0, minLen==Integer.MAX_VALUE

2)right==1,str[1]=='d'。在map中,把key为’d’的value减1,但是发现map中没有key为’d’的记录,就加一条记录('d' ,-1),表示’d’字符str1多归还了1个。此时value为-1,说明当前这个字符是str2不需要的,所以match不变。目前变量状况如下:

        map   key   value
              'a'    0
              'b'    1
              'c'    1
              'd'   -1
        match==2, left==0, right==1, minLen==Integer.MAX_VALUE

3)right==2,str[2]=='a'。在map中,把key为’a’的value减1,变为('a' ,-1)。减之后value为-1,说明减之前str1根本就不欠str2当前的字符,还是多归还的,故match不变。

        map   key   value
              'a'    -1
              'b'    1
              'c'    1
              'd'   -1
        match==2, left==0, right==2, minLen==Integer.MAX_VALUE

4)right==3,str[3]=='b'。('b' ,1)变为('b' ,0),减之后value为0,说明当前字符’b’归还有效,match值减1。

        Map   key   value
              'a'    -1
              'b'     0
              'c'     1
              'd'    -1
        match==1, left==0, right==3, minLen==Integer.MAX_VALUE

5)right==4,str[4]=='b'。('b' ,0)变为('b' ,-1),减之后value为-1,说明当前字符’b’归还无效,match值不变。

        Map   key   value
              'a'    -1
              'b'    -1
              'c'    1
              'd'   -1
        match==1, left==0, right==4, minLen==Integer.MAX_VALUE

6)right==5,str[5]=='c'。('c' ,1)变为('c' ,0),减之后value为0,说明当前字符’c’归还有效,match值减1。

        Map   key   value
              'a'     -1
              'b'     -1
              'c'     0
              'd'     -1
        match==0, left==0, right==5, minLen==Integer.MAX_VALUE

此时match第一次变成了0,说明遍历到目前为止,str1把需要归还的字符都还完了,此时被框住的子串也就是str1[0..5],肯定是包含str2所有字符的。但是当前被框住的子串是在必须以位置5结尾的情况下最短的吗?不一定,因为有些字符归还得很多余,所以步骤6)还要继续如下过程。

left开始往右移动,left==0,str1[0]=='a' ,key为’a’的记录为('a' ,-1),当前value==-1,说明str1即便拿回这个字符,也不会欠str2。所以拿回来,令记录变为('a' ,0),left++。left==1,str1[1]=='d' ,key为’d’的记录为('d' ,-1),当前value==-1,说明str1即便拿回’d',也不会欠str2。所以拿回来,令记录变为('d' ,0),left++。left==2,str1[2]=='a' ,key为’a’的记录为('a' ,0),当前value==0,说明str1如果拿回这个位置的字符,就要亏欠str2了,所以此时left停止向右移动。str1[2..5]就是在必须以位置5结尾的情况下的最小窗口子串。minLen更新为4。

步骤6)(即right==5)这一步揭示了整个解法最关键的逻辑,先通过right向右扩,让所有的字符被“有效”地还完,都还完时,被框住的子串肯定是符合要求的,但还要经过left向右缩的过程来看被框住的子串能不能变得更短。至此,关于位置5结尾的情况下的最短窗口子串已经找到。同时从left位置开始的最短窗口子串也是str1[left..right]。所以,之后如果更小的窗口子串也一定不会从left的位置开始,而是从left之后的位置开始。str1[2]=='a',令记录('a' ,0)变为('a' ,1),match++,然后left++。表示现在的str1[3..5]又开始欠str2字符了,right继续往右扩。目前变量的状况如下:

        map   key   value
              'a'    1
              'b'   -1
              'c'    0
              'd'   -1
        match==1, left==3, right==5, minLen==4

7)right==6,str[6]=='a'。('a' ,1)变为('a' ,0),减之后value为0,说明当前字符’a’归还有效,match值减1。match又一次等于0,进入left向右缩的过程。left==3,str1[0]=='b' ,key为’b’的记录为('b' ,-1),当前value==-1,说明str1即便拿回这个位置的字符,也不会欠str2,所以拿回,记录变为('b' ,0),left++。left==4,str1[1]=='b' ,key为’b’的记录为('b' ,0),当前value==0,说明如果拿回当前字符’b',就要亏欠str2。所以此时的str1[4..6]就是在必须以位置6结尾的情况下的最小窗口子串,令minLen更新为3。同步骤6)的逻辑一样,left==4,str1[4]=='b',令('b' ,0)变为('b' ,1),match++,left++。表示现在的str1[5..6]又开始欠str2字符,right继续往右扩。

        Map   key   value
              'a'    0
              'b'    1
              'c'    0
              'd'    -1
        match==1, left==5, right==6, minLen==3

8)right==7,遍历结束。

4.如果minLen此时依然等于Integer.MAX_VALUE,说明从始至终都没有符合条件的窗口出现过,当然minLen也从未被设置过,则返回0,否则返回minLen的值。

left和right始终向右移动,right移动到右边界过程停止,所以该时间复杂度必然是O (N )。具体请参看如下代码中的minLength方法。

          public int minLength(String str1, String str2) {
                  if (str1 == null || str2 == null || str1.length() < str2.length()) {
                          return 0;
                  }
                  char[] chas1 = str1.toCharArray();
                  char[] chas2 = str2.toCharArray();
                  int[] map = new int[256];
                  for (int i = 0; i ! = chas2.length; i++) {
                          map[chas2[i]]++;
                  }
                  int left = 0;
                  int right = 0;
                  int match = chas2.length;
                  int minLen = Integer.MAX_VALUE;
                  while (right ! = chas1.length) {
                          map[chas1[right]]--;
                          if (map[chas1[right]] >= 0) {
                                  match--;
                          }
                          if (match == 0) {
                                  while (map[chas1[left]] < 0) {
                                          map[chas1[left++]]++;
                                  }
                                  minLen = Math.min(minLen, right - left + 1);
                                  match++;
                                  map[chas1[left++]]++;
                          }
                          right++;
                  }
                  return minLen == Integer.MAX_VALUE ? 0 : minLen;
          }

回文最少分割数

【题目】

给定一个字符串str,返回把str全部切成回文子串的最小分割数。

【举例】

str="ABA"。

不需要切割,str本身就是回文串,所以返回0。

str="ACDCDCDAD"。

最少需要切2次变成3个回文子串,比如"A"、"CDCDC"和"DAD",所以返回2。

【难度】

尉 ★★★☆

【解答】

本题是一个经典的动态规划的题目。定义动态规划数组dp,dp[i]的含义是子串str[i..len-1]至少需要切割几次,才能把str[i..len-1]全部切成回文子串。那么,dp[0]就是最后的结果。

从右往左依次计算dp[i]的值,i初始为len-1,具体计算过程如下:

1.假设j 位置处在i 与len-1位置之间(i<=j<len),如果str[i..j]是回文串,那么dp[i]的值可能是dp[j+1]+1,其含义是在str[i..len-1]上,既然str[i..j]是一个回文串,那么它可以自己作为一个分割的部分,剩下的部分(即str[j+1..len-1])继续做最经济的切割,而dp[j+1]值的含义正好是str[j+1..len-1]的最少回文分割数。

2.根据步骤2的方式,让ji 到len-1位置上枚举,那么所有可能情况中的最小值就是dp[i]的值,即dp[i] = Min { dp[j+1]+1 (i<=j<len,且str[i..j]必须是回文串) }。

3.如何方便快速地判断str[i..j]是否是回文串呢?具体过程如下。

1)定义一个二维数组boolean[][] p,如果p[i][j]值为true,说明字符串str[i..j]是回文串,否则不是。在计算dp数组的过程中,希望能够同步、快速地计算出矩阵p。

2)p[i][j]如果为true,一定是以下三种情况:

● str[i..j]由1个字符组成。

● str[i..j]由2个字符组成且2个字符相等。

● str[i+1..j-1]是回文串,即p[i+1][j-1]为true,且str[i]==str[j],即str[i..j]上首尾两个字符相等。

3)在计算dp数组的过程中,位置i 是从右向左依次计算的。而对每一个i 来说,又依次从i 位置向右枚举所有的位置j (i<=j<len),以此来决策出dp[i]的值。所以对p[i][j]来说,p[i+1][j-1]值一定已经计算过。这就使判断一个子串是否为回文串变得极为方便。

4.最终返回dp[0]的值,过程结束。全部过程请参看如下代码中的minCut方法。

          public int minCut(String str) {
                  if (str == null || str.equals("")) {
                          return 0;
                  }
                  char[] chas = str.toCharArray();
                  int len = chas.length;
                  int[] dp = new int[len + 1];
                  dp[len] = -1;
                  boolean[][] p = new boolean[len][len];
                  for (int i = len - 1; i >= 0; i--) {
                      dp[i] = Integer.MAX_VALUE;
                      for (int j = i; j < len; j++) {
                          if (chas[i] == chas[j] && (j - i < 2 || p[i + 1][j - 1])) {
                                  p[i][j] = true;
                                  dp[i] = Math.min(dp[i], dp[j + 1] + 1);
                          }
                      }
                  }
                  return dp[0];
          }

字符串匹配问题

【题目】

给定字符串str,其中绝对不含有字符’.’和’*'。再给定字符串exp,其中可以含有’.’或’*' ,'*’字符不能是exp的首字符,并且任意两个’*’字符不相邻。exp中的’.’代表任何一个字符,exp中的’*’表示’*’的前一个字符可以有0个或者多个。请写一个函数,判断str是否能被exp匹配。

【举例】

str="abc",exp="abc",返回true。

str="abc",exp="a.c",exp中单个’.’可以代表任意字符,所以返回true。

str="abcd",exp=".*"。exp中’*’的前一个字符是’.',所以可表示任意数量的’.’字符,当exp是"...."时与"abcd"匹配,返回true。

str="",exp="..*"。exp中’*’的前一个字符是’.',可表示任意数量的’.’字符,但是".*"之前还有一个’.’字符,该字符不受’*’的影响,所以str起码有一个字符才能被exp匹配。所以返回false。

【难度】

校 ★★★☆

【解答】

首先解决str和exp有效性的问题。根据描述,str中不能含有’.’和’*' ,exp中’*’字符不能是首字符,并且任意两个’*’字符不相邻。具体请参看如下代码中的isValid方法。

          public boolean isValid(char[] s, char[] e) {
                  for (int i = 0; i < s.length; i++) {
                          if (s[i] == ' *' || s[i] == ' .') {
                                  return false;
                          }
                  }
                  for (int i = 0; i < e.length; i++) {
                          if (e[i] == ' *' && (i == 0 || e[i - 1] == ' *')) {
                                  return false;
                          }
                  }
                  return true;
          }

接下来看如何用递归方法来解这道题,如下代码中的isMatch方法是递归解法的主函数,process方法是递归的主要过程,先列出代码,然后详细解释过程。

          public boolean isMatch(String str, String exp) {
                  if (str == null || exp == null) {
                          return false;
                  }
                  char[] s = str.toCharArray();
                  char[] e = exp.toCharArray();
                  return isValid(s, e) ? process(s, e, 0, 0) : false;
          }

          public boolean process(char[] s, char[] e, int si, int ei) {
                  if (ei == e.length) {
                          return si == s.length;
                  }
                  if (ei + 1 == e.length || e[ei + 1] ! = ' *') {
                          return si ! = s.length && (e[ei] == s[si] || e[ei] == ' .')
                                          && process(s, e, si + 1, ei + 1);
                  }
                  while (si ! = s.length && (e[ei] == s[si] || e[ei] == ' .')) {
                          if (process(s, e, si, ei + 2)) {
                                  return true;
                          }
                          si++;
                  }
                  return process(s, e, si, ei + 2);
          }

下面解释一下递归过程,process函数的意义是,从str的si位置开始,一直到str结束位置的子串,即str[si...slen],是否能被从exp的ei位置开始一直到exp结束位置的子串(即exp[ei...elen])匹配,所以process(s,e,0,0)就是最终返回的结果。

那么在递归过程中如何判断str[si...slen]是否能被exp[ei...elen]匹配呢?

假设当前判断到str的si位置和exp的ei位置,即process(s,e,si,ei)。

1.如果ei为exp的结束位置(ei==elen),si也是str的结束位置,返回true,因为“”可以匹配“”。如果si不是str的结束位置,返回false,这是显而易见的。

2.如果ei位置的下一个字符(e[ei+1])不为’*'。那么就必须关注str[si]字符能否和exp[ei]字符匹配。如果str[si]与exp[ei]能匹配(e[ei] == s[si] || e[ei] == ' .'),还要关注str后续的部分能否被exp后续的部分匹配,即process(s,e,si+1,ei+1)的返回值。如果str[si]与exp[ei]不能匹配,当前字符都匹配,当然不用计算后续的,直接返回false。

3.如果当前ei位置的下一个字符(e[ei+1])为’*’字符。

1)如果str[si]与exp[ei]不能匹配,那么只能让exp[ei..ei+1]这个部分为"",也就是exp[ei+1]==' *’字符的前一个字符exp[ei]的数量为0才行,然后考查process(s,e,si,ei+2)的返回值。举个例子,str[si..slen]为"bXXX","XXX"代指字符’b’之后的字符串。exp[ei..elen]为"a*YYY","YYY"代指字符’*’之后的字符串。当前无法匹配('a' ! ='b'),所以让"a*"为"",然后考查str[si..slen](即"bXXX")能否被exp[ei+2..elen](即"YYY")匹配。

2)如果str[si]与exp[ei]能匹配,这种情况下举例说明。

str[si...slen]为"aaaaaXXX","XXX"指不再连续出现’a’字符的后续字符串。exp[ei...elen])为"a*YYY","YYY"指字符’*’之后的后续字符串。

如果令"a"和"a*"匹配,且有"aaaaXXX"和"YYY"匹配,可以返回true。

如果令"aa"和"a*"匹配,且有"aaaXXX"和"YYY"匹配,可以返回true。

如果令"aaa"和"a*"匹配,且有"aaXXX"和"YYY"匹配,可以返回true。

如果令"aaaa"和"a*"匹配,且有"aXXX"和"YYY"匹配,可以返回true。

如果令"aaaaa"和"a*"匹配,且有"XXX"和"YYY"匹配,可以返回true。

也就是说,exp[ei..ei+1](即"a*")的部分如果能匹配str后续很多位置的时候,只要有一个返回true,就可以直接返回true。

整体递归过程结束。

在分析完如上递归过程之后,来看递归函数的结构。我们很容易发现递归函数process(s,e,si,ei)在每次调用的时候,有两个参数是始终不变的(s和e),所以代表process函数状态的就是si和ei值的组合。所以,如果把递归函数p 在所有不同参数(si和ei)的情况下的所有返回值看作一个范围,这个范围就是一个(slen+1)*(elen+1)的二维数组,并且p(si,ei)在整个递归过程中,依赖的总是p(si+1,ei+1)或者p(si+k(k>=0),ei+2),假设二维数组dp[i][j]代表p (ij )的返回值,dp[i][j]就只是依赖dp[i+1][j+1]或者dp[i+k(k>=0)][j+2]的值。进一步可以看出,想要求dp[i][j]的值,只需要(ij )位置右下方的某些值。所以只要从二维数组的右下角开始,从右到左、再从下到上地计算出二维数组dp中每个位置的值就可以,dp[0][0]就是最终的结果。p (ij )的递归过程如何,dp[i][j]的值就怎样去计算。这种方法实际上就是动态规划的方法,省去了递归过程中很多重复计算的过程。

先从右到左计算dp[slen][...],也就是二维数组dp中的最后一行,dp[slen][elen]值的含义是str已经结束,剩下的字符串为"",exp也已经结束,剩下的字符串为"",所以此时exp可以匹配str,dp[slen][elen]=true。对于dp[slen][0..elen-1]的部分,dp[slen][i]的含义是str已经结束,剩下的字符串为"",exp却没有结束,剩下的字符串为exp[i..elen-1],什么情况下exp[i..elen-1]可以匹配""?只能是不停地重复出现"X*"这种方式。比如,exp[i..elen-1]为"*",这种情况下,exp[i+1..elen-1]根本不合法,匹配不了""。如果exp[i..elen-1]="A*",可以匹配""。如果exp[i..elen-1]="A*B*",也能匹配""。也就是说,在从右向左计算dp[slen][0..elen-1]的过程中,看exp是不是从右往左重复出现"X*",如果是重复出现,那么如果exp[i]='X' ,exp[i+1]=' *',令dp[slen][i]=true,如果exp[i]=' *' ,exp[i+1]='X',令dp[slen][i]=false。如果不是重复出现,最后一行后面的部分(即dp[slen][0..i]),全都是false。这样就搞定了dp[][]最后一行的值。

再看看dp[][]除右下角的值之外,最后一列其他位置的值,即dp[0..slen-1][elen]。这表示如果exp已经结束,而str还没结束,显然,exp为""匹配不了任何非空字符串,所以dp[0..slen-1][elen]都为false。

接着看dp[][]倒数第二列的值,即dp[0..slen-1][elen-1]。这表示如果exp还剩一个字符即(exp[elen-1]),而str还剩1个字符或多个字符。很明显,str还剩多个字符的情况下,exp匹配不了。str还剩1个字符的情况下(即str[slen-1]),如果和exp[elen-1]相等,则可以匹配,或者exp[elen-1]==' .’的情况下可以匹配。

因为dp[i][j]只依赖dp[i+1][j+1]或者dp[i+k][j+2](k>=0)的值,所以在单独计算完最后一行、最后一列与倒数第二列之后,剩下的位置在从右到左、再从下到上计算dp值的时候,所有依赖的值都被计算出来,直接拿过来用即可。如果str的长度为N ,exp的长度为M ,因为有枚举的过程,所以时间复杂度为O (N 2 ×M ),额外空间复杂度为O (N ×M )。具体请参看如下代码中的isMatchDP方法。

          public boolean isMatchDP(String str, String exp) {
                  if (str == null || exp == null) {
                          return false;
                  }
                  char[] s = str.toCharArray();
                  char[] e = exp.toCharArray();
                  if (! isValid(s, e)) {
                          return false;
                  }
                  boolean[][] dp = initDPMap(s, e);
                  for (int i = s.length - 1; i > -1; i--) {
                      for (int j = e.length - 2; j > -1; j--) {
                          if (e[j + 1] ! = ' *') {
                              dp[i][j] = (s[i] == e[j] || e[j] == ' .')
                                            && dp[i + 1][j + 1];
                          } else {
                              int si = i;
                              while (si ! = s.length && (s[si] == e[j] || e[j] == ' .')) {
                                  if (dp[si][j + 2]) {
                                          dp[i][j] = true;
                                          break;
                                  }
                                  si++;
                              }
                              if (dp[i][j] ! = true) {
                                  dp[i][j] = dp[si][j + 2];
                              }
                          }
                      }
                  }
                  return dp[0][0];
          }

          public boolean[][] initDPMap(char[] s, char[] e) {
                  int slen = s.length;
                  int elen = e.length;
                  boolean[][] dp = new boolean[slen + 1][elen + 1];
                  dp[slen][elen] = true;
                  for (int j = elen - 2; j > -1; j = j - 2) {
                          if (e[j] ! = ' *' && e[j + 1] == ' *') {
                                  dp[slen][j] = true;
                          } else {
                                  break;
                          }
                  }
                  if (slen > 0 && elen > 0) {
                          if ((e[elen - 1] == ' .' || s[slen - 1] == e[elen - 1])) {
                                  dp[slen - 1][elen - 1] = true;
                          }
                  }
                  return dp;
          }

字典树(前缀树)的实现

【题目】

字典树又称为前缀树或Trie树,是处理字符串常见的数据结构。假设组成所有单词的字符仅是“a”~“z”,请实现字典树结构,并包含以下四个主要功能。

● void insert(String word):添加word,可重复添加。

● void delete(String word):删除word,如果word添加过多次,仅删除一个。

● boolean search(String word):查询word是否在字典树中。

● int prefixNumber(String pre):返回以字符串pre为前缀的单词数量。

【难度】

尉 ★★☆☆

【解答】

字典树的介绍。字典树是一种树形结构,优点是利用字符串的公共前缀来节约存储空间,比如加入"abc"、"abcd"、"abd"、"b"、"bcd"、"efg"、"hik"之后,字典树如图5-1所示。

image

图5-1

字典树的基本性质如下:

● 根节点没有字符路径。除根节点外,每一个节点都被一个字符路径找到。

● 从根节点到某一节点,将路径上经过的字符连接起来,为扫过的对应字符串。

● 每个节点向下所有的字符路径上的字符都不同。

在字典树上搜索添加过的单词的步骤为:

1.从根节点开始搜索。

2.取得要查找单词的第一个字母,并根据该字母选择对应的字符路径向下继续搜索。

3.字符路径指向的第二层节点上,根据第二个字母选择对应的字符路径向下继续搜索。

4.一直向下搜索,如果单词搜索完后,找到的最后一个节点是一个终止节点,比如图5-1中的实心节点,说明字典树中含有这个单词,如果找到的最后一个节点不是一个终止节点,说明单词不是字典树中添加过的单词。如果单词没搜索完,但是已经没有后续的节点了,也说明单词不是字典树中添加过的单词。

在字典树上添加一个单词的步骤同理,不再详述。下面介绍有关字典树节点的类型。参见如下代码中的TrieNode类。

          public class TrieNode {
                  public int path;
                  public int end;
                  public TrieNode[] map;
                  public TrieNode() {
                          path = 0;
                          end = 0;
                          map = new TrieNode[26];
                  }
          }

TrieNode类中,path表示有多少个单词共用这个节点,end表示有多少个单词以这个节点结尾,map是一个哈希表结构,key代表该节点的一条字符路径,value表示字符路径指向的节点,根据题目的说明,map为长度为26的数组,在字符种类较多的情况下,可以选择用真实的哈希表结构实现map。介绍完TrieNode后,下面详细介绍本题的Trie树类如何实现。

● void insert(String word):假设单词word的长度为N 。从左到右遍历word中的每个字符,并依次从头节点开始根据每一个word[i],找到下一个节点。如果找的过程中节点不存在,就建立新节点,记为a,并令a.path=1。如果节点存在,记为b,令b.path++。通过最后一个字符(word[N-1])找到最后一个节点时记为e,令e.path++,e.end++。

● boolean search(String word):从左到右遍历word中的每个字符,并依次从头节点开始根据每一个word[i],找到下一个节点。如果找的过程中节点不存在,说明这个单词的整个部分没有添加进Trie树,否则不可能找的过程中节点不存在,直接返回false。如果能通过word[N-1]找到最后一个节点,记为e,如果e.end! =0,说明有单词通过word[N-1]的字符路径,并以节点e结尾,返回true,如果e.end==0,返回false。

● void delete(String word):先调用search(word),看word在不在Trie树中,若在,则执行后面的过程,若不在,则直接返回。从左到右遍历word中的每个字符,并依次从头节点开始根据每一个word[i]找到下一个的节点。在找的过程中,把扫过每一个节点的path值减1。如果发现下一个节点的path值减完之后已经为0,直接从当前节点的map中删除后续的所有路径,返回即可。如果扫到最后一个节点,记为e,令e.path--,e.end--。

● int prefixNumber(String pre):和查找操作同理,根据pre不断找到节点,假设最后的节点记为e,返回e.path的值即可。

全部实现过程请参看如下代码中的Trie类。