判定字符是否唯一
面试题 01.01. 判定字符是否唯一 - 力扣(LeetCode)
问题
实现一个算法,确定一个字符串的所有字符是否全都不同。假使不允许使用额外的数据结构,又该如何处理?
自己的思考
如果允许使用额外的数据结构,那么能想到以下解法:
- 利用散列表,遍历字符串统计字符次数,碰到已经统计过的字符终止
如果不能使用额外的数据结构,不知道该如何做?😣
提示
- 用散列表试试。
- 位向量有用吗?
- 你能用 的时间复杂度解决它吗?这样的解法会是什么样呢?
参考
一开始先问面试官,字符串是ASCII字符串还是Unicode字符串,如果是Unicode需要扩大存储空间,这里假设是ASCII字符串。这里有个小技巧🌟,如果字符串的长度超过了字母表中不同字符的个数,可以立即返回false。
基础的算法是利用散列表。
优化: 🌟利用位向量可以将空间占用减少为原来的。
如果不用其他的数据结构。书上给出了一些参考方法
将字符串中的每一个字符与其余字符进行比较。这种方法的时间复杂度为 ,空间复 杂度为 。
若允许修改输入字符串,可以在 的时间复杂度内对字符串进行排序,然后线性 检查其中有无相邻字符完全相同的情况。不过,值得注意的是,很多排序算法会占用额外的空间。
从某些方面来看,这些算法算不上最优,不过,从问题的限制条件来看,或许还算是不错的。
代码
法一:利用散列表
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)
有的时候题目要求两个字符串顺序不能完全相同,这个需要注意❗
提示
- 描述两个字符串是否互为字符重排的含义。现在,看看你提供的定义,你能否根据这个定义检查字符串?
- 有一种解法需要 的时间。另一种解法需要使用一些空间,但需要运行 时间为 。
- 散列表有用吗?
- 两个重排的字符串应该具有相同的字符,但顺序不同。你可以让它们的顺序一样吗?
参考
首先应该向面试官确认一些细节,弄清楚变位词(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。原因在于,字符串长度相同,增加的次数与减少的次数也相同。若数组无负值, 则不会有正值。
经验
-
先思考某些必要条件,不满足的话可以提前判断,即思考是否可以提前判断。
充分条件和必要条件怎么区分 ? - 知乎 (zhihu.com)
这里解释了充分条件与必要条件,我是一直记不太清楚。
充分条件
必要条件
-
利用散列表统计次数
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?
这里可以参考剑指 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》的解释
计算整数 的二进制形式中 的个数有多种不同的方法,其中一种比较高效的方法是每次用“”将整数 的最右边的 变成 。整数 减去 ,那么它最右边你的 变成 。如果它的右边还有 ,则右边所有的 都变成 ,而其左边所有位都保持不变。下面对 和 进行位与运算,相当于将其最右边的 变成 。
一次编辑
面试题 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,中间变量来简化索引的推导!
零矩阵
编写一种算法,若 M × N 矩阵中某个元素为 0,则将其所在的行与列清零。
思考
一种很直接的想法就是两次遍历,第一次遍历统计哪些元素为0,第二次遍历进行清0操作。我一开始的想法是利用 set存储行和列,然后想到了如果判断当前行是否出现过用set查找不好,看了题解发现,可以用散列表,如果需要唯一性,并且查询,那么散列表一定是神兵利器🌟。要有这个思想!虽然这题不用判断唯一性,不用查询,只用访问,因此 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;
}
}
}
}
空间复杂度方法
总结
- 如果需要唯一性,并且查询,那么散列表一定是神兵利器🌟
字符串轮转
面试题 01.09. 字符串轮转 - 力扣(LeetCode)
假定有一种 isSubstring 方法,可检查一个单词是否为其他字符串的子串。 给定两个字符串 s1 和 s2,请编写代码检查 s2 是否为 s1 旋转而成,要求只能调用一次 isSubstring(比如,waterbottle 是 erbottlewat 旋转后的字符串)。
提示
- 如果一个字符串是另一个字符串的旋转,那么它就是在某个特定点上的旋转。例如,字符串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;
}
总结
- 统计 的个数
- 与 异或 可以翻转比特位
- 异或可以消去重复值
- 可以用位向量优化散列表空间复杂度
- 经常可以判断必要条件不成立,从而先行判断结论不成立;如字符串是否长度相等之类
- 有时候可能从后向前遍历优于从前向后遍历;向后比较优于向前比较;这需要判断那种实现容易
- 优先选择自己清楚的实现,而不是代码精炼的实现
- 如果需要唯一性,并且查询,那么散列表一定是神兵利器
- 对于循环、旋转串,可以将串进行前后拼接;非常多的场景采用这个思想
- 面对题目可以分析题目的几种情况,发现几种情况间的联系