数组与字符串-程序员面试金典

140 阅读12分钟

判定字符是否唯一

面试题 01.01. 判定字符是否唯一 - 力扣(LeetCode)

问题

实现一个算法,确定一个字符串的所有字符是否全都不同。假使不允许使用额外的数据结构,又该如何处理?

自己的思考

如果允许使用额外的数据结构,那么能想到以下解法:

  1. 利用散列表,遍历字符串统计字符次数,碰到已经统计过的字符终止

如果不能使用额外的数据结构,不知道该如何做?😣

提示

  • 用散列表试试。
  • 位向量有用吗?
  • 你能用 O(nlogn)O(n\log n)的时间复杂度解决它吗?这样的解法会是什么样呢?

参考

一开始先问面试官,字符串是ASCII字符串还是Unicode字符串,如果是Unicode需要扩大存储空间,这里假设是ASCII字符串。这里有个小技巧🌟,如果字符串的长度超过了字母表中不同字符的个数,可以立即返回false

基础的算法是利用散列表。

优化: 🌟利用位向量可以将空间占用减少为原来的1/81/8

如果不用其他的数据结构。书上给出了一些参考方法

  1. 将字符串中的每一个字符与其余字符进行比较。这种方法的时间复杂度为 O(n2)O(n^2),空间复 杂度为 O(1)O(1)

  2. 若允许修改输入字符串,可以在 O(nlog(n))O(n\log(n))的时间复杂度内对字符串进行排序,然后线性 检查其中有无相邻字符完全相同的情况。不过,值得注意的是,很多排序算法会占用额外的空间。

从某些方面来看,这些算法算不上最优,不过,从问题的限制条件来看,或许还算是不错的。

代码

法一:利用散列表

boolean isUniqueChars(String str) {
    if (str.length() > 128) {
        return false;
    }
    boolean[] charSet = new boolean[128];
    for (int i = 0; i < str.length(); i++) {
        int val = str.charAt(i);
        if (charSet[val]) {
            return false;
        }
        charSet[val] = true;
    }
    return true;
}

优化:使用位向量,优化空间复杂度

boolean isUniqueChars(String str) {
    int checker = 0;
    for (int i = 0; i < str.length(); i++) {
        int val = str.charAt(i) - 'a';
        if ((checker & (1 << val)) == 1) {
            return false;
        }
        checker |= (1 << val);
    }
    return true;
}

再思考

本来以为不采用其他数据结构是什么奇技淫巧的算法,可能性能很高;实际上作者给出参考答案的角度,这些解法的时间复杂度并不优异,可能面试考察只是想考查思想吧。

经验

  • 利用散列表实现查找,去重
  • 可以利用位运算优化散列表

判定是否互为字符重排

面试题 01.02. 判定是否互为字符重排 - 力扣(LeetCode)

问题

给定两个字符串,请编写程序,确定其中一个字符串的字符重新排列后,能否变成另一个字符串。

自己的思考

这道题是leetcode 各种变位词,首先:两个字符串长度相同(A),然后字符相同(B),字符出现次数相同(C)

242. 有效的字母异位词 - 力扣(LeetCode)

有的时候题目要求两个字符串顺序不能完全相同,这个需要注意❗

提示

  • 描述两个字符串是否互为字符重排的含义。现在,看看你提供的定义,你能否根据这个定义检查字符串?
  • 有一种解法需要 O(nlogn)O(n\log n)的时间。另一种解法需要使用一些空间,但需要运行 时间为 O(n)O(n)
  • 散列表有用吗?
  • 两个重排的字符串应该具有相同的字符,但顺序不同。你可以让它们的顺序一样吗?

参考

首先应该向面试官确认一些细节,弄清楚变位词(anagram)比较是否区分大小写,以及是否需要考虑空白字符,字符集大小。🌟首先请注意不同长度的字符串不可能互为重排字符串。

法一:排序字符串

法二:检查两个字符串的字符数是否相同

public boolean isAnagram(String s, String t) {
    if (s.length() != t.length()) {
        return false;
    }
    int[] letters = new int[26];
    for (int i = 0; i < s.length(); i++) {
        letters[s.charAt(i)-'a']++;
    }
    for (int i = 0; i < t.length(); i++) {
        letters[t.charAt(i)-'a']--;
        if (letters[t.charAt(i)-'a'] < 0) {
            return false;
        }
    }
    return true;
}

参考不是一次遍历同时更改散列表,而是两次遍历,提前终止,稍微快点。

若值为负值(一旦为负,则值将永为负值,不会为非 0),就提早终止。若不这样做,则数 组就会为 0。原因在于,字符串长度相同,增加的次数与减少的次数也相同。若数组无负值, 则不会有正值。

经验

URL 化

面试题 01.03. URL化 - 力扣(LeetCode)

编写一种方法,将字符串中的空格全部替换为 %20。假定该字符串尾部有足够的空间存放新增字符,并且知道字符串的“真实”长度。(注:用 Java 实现的话,请使用字符数组实现,以便直接在数组上操作。)

示例:

输入:"Mr John Smith ", 13 
输出:"Mr%20John%20Smith" 

提示

  • 从尾到头开始修改字符串通常最容易。
  • 你可能需要知道空格的数量。你能数一下吗?

参考

处理字符串操作问题时,常见做法是从字符串尾部开始编辑,从后往前反向操作。该做法是上佳之选,因为字符串尾部有额外的缓冲,可以直接修改,不必担心会覆写原有数据。

该算法会进行两次扫描。第一次扫描先数出字符串中有多少空 格,从而算出最终的字符串长度。第二次扫描才真正开始反向编辑字符串。如果检测到空格, 就将 %20 复制到下一个位置;若不是空格,就复制原先的字符。

void replaceSpaces(char[] str, int trueLength) {
    int spaceCount = 0;
    for (int i = 0; i < truelength; i++) {
        if (str[i] == ' ') {
            spaceCount++;
        }
    }
    int index = trueLength + 2 * spaceCount;
    for (int i = trueLength-1; i>=0; i--) {
        if (str[i] == ' ') {
            str[index-1] = '0';
            str[index-2] = '2';
            str[index-3] = '%';
        	index = index - 3;
        } else {
            str[index-1] = str[i];
            index--;
        }
    }
}

思考

这道题借鉴了双指针的思想,核心要点是 从后往前反向操作

经验

  • 字符串编辑可以 从后往前反向操作🌟,这样可以避免覆写数据,便于实现

回文排列

面试题 01.04. 回文排列 - 力扣(LeetCode)

给定一个字符串,编写一个函数判定其是否为某个回文串的排列之一。回文串是指正反两个方向都一样的单词或短语。排列是指字母的重新排列。回文串不一定是字典当中的单词。

示例:

输入:Tact Coa 
输出:True(排列有"taco cat""atco cta",等等)

提示

  • 你不必且也不应该生成所有的排列。这将极为低效。
  • 作为回文排列的字符串有什么特征?
  • 你试过散列表吗?你应该能把它降到O(N)的时间。
  • 使用位向量可以减少空间使用吗?

参考

为什么不用分字符串奇偶长度单独判断,因为可以统一条件!

更准确地说,所有偶数长度的字符串(不包括非字母字符)所有的字符必须出现 偶数次。奇数长度的字符串必须刚好有一个字符出现奇数次。当然,偶数长度的字符 串不可能只包括一个出现奇数次的字符,否则其不会为偶数长度(一个出现奇数次的 字符+若干个出现偶数次的字符=奇数个字符)。以此类推,奇数长度的字符串不可能 所有的字符都出现偶数次(偶数的和仍然是偶数)。因此我们可以得知,一个回文串的 排列不可能包含超过一个“出现奇数次的字符”。该推论同时涵盖了奇数长度和偶数长 度字符串的例子。

其实就是 odd==0必然为偶数长度的字符串,odd==1必然为奇数长度的字符串。

代码

自己写的代码,实际leetcode测试用例,字符集大小为ASCII字符集,因此一个int数不太合适。

常规做法:

class Solution {
    public boolean canPermutePalindrome(String s) {
        int[] letters = new int[128];
        int odd = 0;
        for (int i = 0; i < s.length(); i++) {
            letters[s.charAt(i)]++;
            if (letters[s.charAt(i)]%2==0) {
                odd--;
            } else {
                odd++;
            }
        }
        // return s.length()%2==0 ? odd==0 : odd==1; // 实际上不用奇偶
        return odd <= 1;
    }
}

位运算:

public boolean canPermutePalindrome(String s) {
    BitSet bitSet = new BitSet(128);
    for (int i = 0; i < s.length(); i++) {
        bitSet.flip(s.charAt(i)); // 翻转bit
    }
    return bitSet.cardinality() <= 1;
}
// 小写字符
public boolean canPermutePalindrome(String s) {
    int checker = 0;
    for (int i = 0; i < s.length(); i++) {
        int val = s.charAt(i) - 'a';
        checker ^= (1 << val); // 与1异或翻转字符
    }
    return checker == 0 || (checker & (checker - 1)) == 0;
}

思考:如何判断 bits 只有1bit 为 1?

经验

🌟如何判断 bits 只有 1bit 为 1?

i&(i1)i \& (i-1)

这里可以参考剑指 Offer II 003. 前 n 个数字二进制中 1 的个数 - 力扣(LeetCode),这里有更详细的解释及应用!

《程序员面试金典》的解释不太好

判断整数数值 中是否刚好有一个比特位为 1,则有一个很巧妙的办法。 例如有一个整数数值 00 010 000。我们当然可以通过重复的移位操作判断是否只有一个比 特位为 1。另一种方法是,如果将该数字减 1,则会得到 00 001 111。可以发现,这两个数字之 间比特位没有重叠(而对于 00 101 000,将其减 1 会得到 00 100 111,比特位发生了重叠)。因 此,判断一个数是否刚好有一个比特位为 1,可以通过将其减 1 的结果与该数本身进行与操作, 如果其结果为 0,则比特位中 1 刚好出现一次。

《剑指Offer》的解释

计算整数 ii 的二进制形式中 11 的个数有多种不同的方法,其中一种比较高效的方法是每次用“i&(i1)i\&(i-1)”将整数 ii 的最右边的 11 变成 00。整数 ii 减去 11,那么它最右边你的 11 变成 00。如果它的右边还有 00,则右边所有的 00 都变成 11,而其左边所有位都保持不变。下面对 iii1i-1 进行位与运算,相当于将其最右边的 11 变成 00

一次编辑

面试题 01.05. 一次编辑 - 力扣(LeetCode)

字符串有三种编辑操作:插入一个字符、删除一个字符或者替换一个字符。 给定两个字符串,编写一个函数判定它们是否只需要一次(或者零次)编辑。

示例:

pale, ple -> true  
pales, pale -> true  
pale, bale -> true  
pale, bake -> false  

自己的思考

难点在于对于位置的判断,还有等价性可以利用

A --> B 添加 === B --> A 删除;字符串长度相差 1
A --> B 删除 === B --> A 添加;
A --> B 修改 === B --> A 修改;

实际需要掌握的是如何实现的更清晰、迅速

我个人更欣赏方法一的处理方式:更清晰,明了;从而不易出错。

提示

  • 从容易的事情开始。你能分别检查一下每一个条件吗?
  • “插入字符”选项和“删除字符”选项之间是何关系?这些需要分开检查吗?
  • 你能一次完成三次检查吗?

参考

对于此类问题,思考一下每一种操作的“意义”大有裨益。两个字符串之间需要一次插入、 替换或删除操作意味着什么?

代码

法一:分开处理

public boolean oneEditAway(String first, String second) {
    if (first.length() == second.length()) {
        return oneEditReplace(first, second);
    } else if (first.length()+1==second.length()) {
        return oneEditInsert(first, second);
    } else if (first.length() == second.length()+1) {
        return oneEditInsert(second, first);
    }
    return false;
}

public boolean oneEditReplace(String first, String second) {
    boolean foundDifference = false;
    for (int i = 0; i < first.length(); i++) {
        if (first.charAt(i) != second.charAt(i)) {
            // 这几行的处理,模板性很好,建议把条件判断放在前面;
            // 即表达的意思是,一开始就判断是否又发现重复了,发现就return 或者 break
            if (foundDifference) {
                return false;
            }
            foundDifference = true;
        }
    }
    return true;
}

public boolean oneEditInsert(String first, String second) {
    int index1 = 0, index2 = 0;
    while (index1 < first.length() && index2 < second.length()) {
        if (first.charAt(index1) != second.charAt(index2)) {
            if (index1 != index2) {
                return false;
            }
            index2++;
        } else {
            index1++;
            index2++;
        }
    }
    return true;
}

法二:统一处理

public boolean oneEditAway(String first, String second) {
    if (Math.abs(first.length() - second.length()) > 1) {
        return false;
    }
	// 获取较长,较短的字符串;常用技巧🌟
    String s1 = first.length() > second.length() ? second : first;
    String s2 = first.length() > second.length() ? first : second;
    int index1 = 0, index2 = 0;
    boolean foundDifference = false;
    while (index1 < s1.length() && index2 < s2.length()) {
        if (s1.charAt(index1) != s2.charAt(index2)) {
            // 确保发现第一处不同
            if (foundDifference) {
                return false;
            }
            foundDifference = true;
            if (s1.length() == s2.length()) {
                index1++; // 更换后,移动指针
            }
            index2++; // 当然,则可以放到最外侧
        } else {
            index1++;
            index2++;
        }
    }
    return true;
}

有些人或许会认为第一种方法更好,因为它更为清晰且更易理解。另外一些人则会认为第 二种方法更好,因为该方法更加紧凑且重复代码更少(有助于代码的维护)。 你并不需要站队,只需和面试官权衡利弊。

字符串压缩

面试题 01.06. 字符串压缩 - 力扣(LeetCode)

利用字符重复出现的次数,编写一种方法,实现基本的字符串压缩功能。 比如,字符串 aabcccccaaa 会变为 a2b1c5a3。若“压缩”后的字符串没有变短,则返回 原先的字符串。你可以假设字符串中只包含大小写英文字母(a 至 z)。

自己的思考

实现这个算法并不难,注意学习如何实现的精简及清晰!

提示

  • 先做容易的事。压缩字符串,然后再比较长度。
  • 注意不要把字符串重复连接在一起。这会非常低效。

参考

方法一:利用String 直接拼接

执行速度慢的原因是字符串拼接操作的时间 复杂度为 O(n2 )

方法二:利用StringBuilder优化字符串拼接

方法三:先便利一遍摸排情况,从而不会构造没有使用的StringBuilder对象,然后能够预先初始化StringBuilder对象。

代码

方法二:

题解的代码实现的就很简洁,采用的思想是向后看

public String compressString(String S) {
    StringBuilder compressed = new StringBuilder();
    int countConsecutive = 0;
    for (int i = 0; i < S.length(); i++) {
        countConsecutive++;
        if (i+1>= S.length() || S.charAt(i) != S.charAt(i+1)) {
            compressed.append(S.charAt(i));
            compressed.append(countConsecutive);
            countConsecutive = 0;
        }
    }
    return compressed.length() < S.length() ? compressed.toString() : S;
}

自己写的代码:如果优化表达是这样的

// 往前看
public String compressString(String S) {
    // 无法统一处理空字符串,单独处理
    if (S.length() == 0) {
        return S;
    }
    StringBuilder result = new StringBuilder();
    int cnt = 0;
    for (int i = 0; i <= S.length(); i++) { // i要求 能够==S.lengt()
        if (i == S.length() || (i > 0 && S.charAt(i) != S.charAt(i-1))) {
            result.append(S.charAt(i-1));
            result.append(cnt);
            cnt=0;       
        }
        cnt++;
    }
    return result.length() < S.length() ? result.toString() : S;
}

总结

  • 遍历有的时候向后比较好于向前比较,向前比较面临初始化的问题;向后比较只用处理后方元素没有的问题
    • 此外,部分题目可能从后往前遍历好于从前往后遍历,具体题目具体分析。

旋转矩阵

面试题 01.07. 旋转矩阵 - 力扣(LeetCode)

给定一幅由 N × N 矩阵表示的图像,其中每个像素的大小为 4 字节,编写一种 方法,将图像旋转 90 度。不占用额外内存空间能否做到?

思考

如果不考虑原地旋转是很容易的。原地的话,我能想到的是由外到内,一层层交换,每层的操作逻辑相同,具体这个逻辑怎么实现,直接用索引来?感觉有点繁琐,不知道如何清晰表达。

提示

  • 尝试逐层思考。你能旋转某个特定图层吗?

  • 旋转一个特定的层只意味着在4个数组中交换值。如果要求你在2个数组中交换值,你能做到吗?你能把它扩展到4个数组吗?

矩阵示例

参考

先抽象出4个数如何交换

for (int i = 0; i < n; i++) {
    temp = top[i];
    top[i] = left[i];
    left[i] = bottom[i];
    bottom[i] = right[i];
    right[i] = temp;
}

代码

方法一

public void rotate(int[][] matrix) {
    int n = matrix.length;
    for (int layer = 0; layer < n/2; layer++) {
        int first = layer;
        int last = n-1-layer;
        for (int i = first; i < last; i++) {
            int offset = i - first; // 神来之笔
            int top = matrix[first][i];
            // left -> top
            matrix[first][i] = matrix[last-offset][first];
            // bottom -> left
            matrix[last-offset][first] = matrix[last][last-offset];
            // right -> bottom
            matrix[last][last-offset] = matrix[i][last];
            // top -> right
            matrix[i][last] = top;
        }
    }
}

总结

  • 利用 offset,中间变量来简化索引的推导!

零矩阵

面试题 01.08. 零矩阵 - 力扣(LeetCode)

编写一种算法,若 M × N 矩阵中某个元素为 0,则将其所在的行与列清零。

思考

一种很直接的想法就是两次遍历,第一次遍历统计哪些元素为0,第二次遍历进行清0操作。我一开始的想法是利用 set存储行和列,然后想到了如果判断当前行是否出现过用set查找不好,看了题解发现,可以用散列表,如果需要唯一性,并且查询O(1)O(1),那么散列表一定是神兵利器🌟。要有这个思想!虽然这题不用判断唯一性,不用查询,只用访问,因此 set 和 散列表均可以。

另外,题解提到的如果直接删除,更改了数组结构,产生了新的0元素,并且继续操作肯定是错的!错误原因是操作的同时改变了数组结构!

提示

  • 如果你在找到0时清除了行和列,则可能会清理整个矩阵。在对矩阵进行任何更改之前,首先尝试找到所有的0。
  • 你能只用额外的O(N)空间而不是O(N2)吗?在为0的单元格列表中你真正需要的是什么信息?
  • 你可能需要一些数据存储来维护一个需要清零的行与列的列表。通过使用矩阵本身来存储数据,你是否可以把额外的空间占用减小到O(1)?

参考

  • 为了提高空间利用率,可以选用位向量替代布尔数组。存储空间的复杂度仍然为 O(N)。

  • 通过使用第一行替代 row 数组,第一列替代 column 数组,可以将算法的空间复杂度降为 O(1)。

    我理解的思路就是利用原有数组的一行一列,先做标记,然后作为散列表使用,最后根据标记状态还原。

  • 在面试中,你可以通过添加注释与待完成(TODO)这样的标注来简化代码,以便于解释下一段 代码与先前一段代码相同,只是操作对象为行。这样会让你专注于算法中最重要的部分。

答案

基础方法

public void setZeroes(int[][] matrix) {
    int m = matrix.length;
    int n = matrix[0].length;
    int[] row = new int[m];
    int[] col = new int[n];
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            if (matrix[i][j] == 0) {
                row[i] = 1;
                col[j] = 1;
            }
        }
    }
    for (int i = 0; i < m; i++) {
        if (row[i] == 1) {
            for (int j = 0; j < n; j++) {
                matrix[i][j] = 0;
            }
        }
    } 
    for (int j = 0; j < n; j++) {
        if (col[j] == 1) {
            for(int i = 0; i < m; i++) {
                matrix[i][j] = 0;
            }
        }
    }
}

O(1)O(1) 空间复杂度方法

总结

  • 如果需要唯一性,并且查询O(1)O(1),那么散列表一定是神兵利器🌟

字符串轮转

面试题 01.09. 字符串轮转 - 力扣(LeetCode)

假定有一种 isSubstring 方法,可检查一个单词是否为其他字符串的子串。 给定两个字符串 s1 和 s2,请编写代码检查 s2 是否为 s1 旋转而成,要求只能调用一次 isSubstring(比如,waterbottleerbottlewat 旋转后的字符串)。

提示

  • 如果一个字符串是另一个字符串的旋转,那么它就是在某个特定点上的旋转。例如,字符串waterbottle在3处的旋转意味着在第三个字符处切割waterbottle,并在左半部分(wat)之前放置右半部分(erbottle)。
  • 本质上,我们是在寻找是否有一种方式可以把第一个字符串分成两部分,即x和y,如此一来,第一个字符串就是xy,第二个字符串就是yx。例如,x = wat,y = erbottle。那么,第一个字符串xy = waterbottle,第二个字符串yx = erbottlewat。
  • 想想前面的提示。再想想当你将erbottlewat与它本身连接会发生什么。你得到了erbottlewaterbottlewat。

思考

这里给出的方法是说对旋转,环形的处理方法:扩充拼接(拼接起来,这是一个很好用的技巧)🌟;即满足 xyxy中一定可以发现 yx的子串。题目提供的子串匹配算法的实现参考其他内容。

代码

boolean isRotation(String s1, String s2) {
    if (s1.length() == s2.length() && s1.length() > 0) {
        String s1s1 = s1 + s1;
        return isSubstring(s1s1, s2);
    }
    return false;
}

总结

  • i&(i1)i\&(i-1) 统计 11 的个数
  • 11 异或 可以翻转比特位
  • 异或可以消去重复值
  • 可以用位向量优化散列表空间复杂度
  • 经常可以判断必要条件不成立,从而先行判断结论不成立;如字符串是否长度相等之类
  • 有时候可能从后向前遍历优于从前向后遍历;向后比较优于向前比较;这需要判断那种实现容易
  • 优先选择自己清楚的实现,而不是代码精炼的实现
  • 如果需要唯一性,并且查询O(1)O(1),那么散列表一定是神兵利器
  • 对于循环、旋转串,可以将串进行前后拼接;非常多的场景采用这个思想
  • 面对题目可以分析题目的几种情况,发现几种情况间的联系