作者: admin
今天的题是卖木头块
估计又要费一番功夫了
299.猜数字游戏后续—-关于字符串拼接的性能分析
起因
前面有一道猜数字游戏的题,去看性能分析,多数都是5ms。没错,我第一次提交的时候也是5ms。当时我就去看1ms的答案,想找找差距。经过仔细地对比,发现是返回结果时没有使用StringBuilder。乍一看好像可以说得过去,由于字符串的不可变性,拼接字符串实际上返回的是新对象。但是拼接了3次,就要创建3个对象吗?
在jsl8中有这样的描述:Java编译器可以使用StringBuffer或类似技术减少中间String对象。
Thinking in Java中也有提到:
尽管如此,书中建议我们不要在循环中使用字符串拼接,因为会重复创建StringBuilder对象。
题外话,也不是所有的字符串拼接都会使用StringBuilder,如果拼接的都是字符串常量,编译后会进行常量折叠。
那么问题来了,我们没有在循环中进行字符串拼接,为什么会有性能差异?
JMH测试
那我们就使用JMH分别测一测leetCode上提交5ms与1ms的代码。二者之间除了返回时字符串拼接的方式不同,其余代码均相同。
/**
* @date 2024-03-13 11:19
*/
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 10, time = 1)
@Threads(1)
@Fork(1)
@State(value = Scope.Benchmark)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class Test {
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(Test.class.getSimpleName())
.result("result.json")
.resultFormat(ResultFormatType.JSON).build();
new Runner(opt).run();
}
@Param(value = {"1122", "1807", "1123"})
private String secret;
@Param(value = {"2211", "7810", "0111"})
private String guess;
/** 5ms*/
@Benchmark
public String stringConcatenation() {
int bulls = 0;
int cows = 0;
char[] secretCharArray = secret.toCharArray();
int[] missArray = new int[10];
int[] cowsArray = new int[10];
for (int i = 0; i < secretCharArray.length; i++) {
int guessCharacter = guess.charAt(i) - '0';
int secretCharacter = secretCharArray[i] - '0';
if (guessCharacter == secretCharacter) {
bulls++;
} else {
missArray[secretCharacter] += 1;
cowsArray[guessCharacter] += 1;
}
}
for (int i = 0; i < cowsArray.length; i++) {
if (missArray[i] > 0 && cowsArray[i] > 0) {
cows += Math.min(missArray[i], cowsArray[i]);
}
}
return bulls + "A" + cows + "B";
}
/** 1ms*/
@Benchmark
public String stringBuilder() {
int bulls = 0;
int cows = 0;
char[] secretCharArray = secret.toCharArray();
int[] missArray = new int[10];
int[] cowsArray = new int[10];
for (int i = 0; i < secretCharArray.length; i++) {
int guessCharacter = guess.charAt(i) - '0';
int secretCharacter = secretCharArray[i] - '0';
if (guessCharacter == secretCharacter) {
bulls++;
} else {
missArray[secretCharacter] += 1;
cowsArray[guessCharacter] += 1;
}
}
for (int i = 0; i < cowsArray.length; i++) {
if (missArray[i] > 0 && cowsArray[i] > 0) {
cows += Math.min(missArray[i], cowsArray[i]);
}
}
StringBuilder sb = new StringBuilder();
return sb.append(bulls).append('A').append(cows).append('B').toString();
}
}
性能
测试结果发现性能差异不大。具体是什么原因就不清楚了,可能是leetcode没有进行编译优化或者是根据代码特征直接给的性能分析?
需要考虑 服务器负载、JVM的即时编译行为、测试用例的具体数据分布 等因素。
我发现同一道题,不同时间测试用例可能是不同的,比如代码提交后提示解答错误 73/74
,修改了代码过了一段时间提交发现问题没有解决,还提示解答错误 73/74
,但是输入的数据是不同的。
后续
我在评论区还发现了一位网友的一次遍历的写法,可以说是很巧妙了。但是,只要使用字符串拼接,性能就是5ms。
class Solution {
public String getHint(String secret, String guess) {
int[] nums = new int[10];
int countA = 0, countB = 0;
for (int i = 0; i < secret.length(); i++) {
if (secret.charAt(i) == guess.charAt(i)) countA++;
else {
if (nums[guess.charAt(i) - '0']-- > 0) countB++;
if (nums[secret.charAt(i) - '0']++ < 0) countB++;
}
}
StringBuilder sb = new StringBuilder();
return sb.append(countA).append('A').append(countB).append('B').toString();
}
}
最后说一下这个一次遍历的思路,如果猜测的数字与secret当前位置的数字不符:
当猜测数字计数却大于0,说明secret之前遇到过,算是位置不正确,应该将bulls加一。不论前面判断结果怎样,猜测数字计数总要减一。
当secret数字计数小于0,说明guess之前猜到过,但是由于当时没有多余的secret数字,所以没有加。这里遇到了,就将bulls加一。不论前面的判断结果,猜测数字计数总要加一。
1261.在受污染的二叉树中查找元素
目标
给出一个满足下述规则的二叉树:
- root.val == 0
- 如果 treeNode.val == x 且 treeNode.left != null,那么 treeNode.left.val == 2 * x + 1
- 如果 treeNode.val == x 且 treeNode.right != null,那么 treeNode.right.val == 2 * x + 2
现在这个二叉树受到「污染」,所有的 treeNode.val 都变成了 -1。
请你先还原二叉树,然后实现 FindElements 类:
- FindElements(TreeNode* root) 用受污染的二叉树初始化对象,你需要先把它还原。
- bool find(int target) 判断目标值 target 是否存在于还原后的二叉树中并返回结果。
说明:
- TreeNode.val == -1
- 二叉树的高度不超过 20
- 节点的总数在 [1, 10^4] 之间
- 调用 find() 的总次数在 [1, 10^4] 之间
- 0 <= target <= 10^6
思路
dfs还原节点val,并将其加入到Hash表中,直接contains判断。
这个题被标为medium,估计是想让我们自己实现Hash表来查找元素吧。
代码
/**
* @date 2024-03-12 2:41
*/
public class FindElements {
Set<Integer> elements;
public FindElements(TreeNode root) {
elements = new HashSet<>();
recover(root, 0);
}
public void recover(TreeNode root, int value) {
if (root == null) {
return;
}
root.val = value;
elements.add(value);
recover(root.left, 2 * value + 1);
recover(root.right, 2 * value + 2);
}
public boolean find(int target) {
return elements.contains(target);
}
}
性能
2129.将标题首字母大写
目标
给你一个字符串 title ,它由单个空格连接一个或多个单词组成,每个单词都只包含英文字母。请你按以下规则将每个单词的首字母 大写 :
- 如果单词的长度为 1 或者 2 ,所有字母变成小写。
- 否则,将单词首字母大写,剩余字母变成小写。
请你返回 大写后 的 title 。
示例 1:
输入:title = "capiTalIze tHe titLe"
输出:"Capitalize The Title"
解释:
由于所有单词的长度都至少为 3 ,将每个单词首字母大写,剩余字母变为小写。
示例 2:
输入:title = "First leTTeR of EACH Word"
输出:"First Letter of Each Word"
解释:
单词 "of" 长度为 2 ,所以它保持完全小写。
其他单词长度都至少为 3 ,所以其他单词首字母大写,剩余字母小写。
示例 3:
输入:title = "i lOve leetcode"
输出:"i Love Leetcode"
解释:
单词 "i" 长度为 1 ,所以它保留小写。
其他单词长度都至少为 3 ,所以其他单词首字母大写,剩余字母小写。
说明:
- 1 <= title.length <= 100
- title 由单个空格隔开的单词组成,且不含有任何前导或后缀空格。
- 每个单词由大写和小写英文字母组成,且都是 非空 的。
思路
这个要看清题目,单词的长度为 1 或者 2 ,所有字母变成小写。并不是指传入的title长度,而是title中的单词。此外要熟悉字符对应的ASCII码:
Character | ASCII |
---|---|
A-Z | 65-90 |
a-z | 97-122 |
空格 | 32 |
读取字符数组中的字符,记录第一个字符的index,跳过,将后续的字符均转为小写,直到遇到空格,记录单词字符个数。如果字符个数大于2,将first对应的字符转为大写,否则转为小写。
代码
/**
* @date 2024-03-11 0:16
*/
public class CapitalizeTitle {
public String capitalizeTitle(String title) {
// 使用System.arraycopy复制的数组,可以直接操作
char[] chars = title.toCharArray();
for (int i = 0; i < chars.length; i++) {
char first = (char) i++;
int counter = 1;
while (i < chars.length && chars[i] != 32) {
if (chars[i] < 97) {
chars[i] = (char) (chars[i] + 32);
}
i++;
counter++;
}
if (counter > 2) {
if (chars[first] >= 97) {
chars[first] = (char) (chars[first] - 32);
}
} else {
if (chars[first] < 97) {
chars[first] = (char) (chars[first] + 32);
}
}
}
return new String(chars);
}
}
性能
299.猜数字游戏
目标
你在和朋友一起玩 猜数字(Bulls and Cows)游戏,该游戏规则如下:
写出一个秘密数字,并请朋友猜这个数字是多少。朋友每猜测一次,你就会给他一个包含下述信息的提示:
- 猜测数字中有多少位属于数字和确切位置都猜对了(称为 "Bulls",公牛),
- 有多少位属于数字猜对了但是位置不对(称为 "Cows",奶牛)。也就是说,这次猜测中有多少位非公牛数字可以通过重新排列转换成公牛数字。
给你一个秘密数字 secret 和朋友猜测的数字 guess ,请你返回对朋友这次猜测的提示。
提示的格式为 "xAyB" ,x 是公牛个数, y 是奶牛个数,A 表示公牛,B 表示奶牛。
请注意秘密数字和朋友猜测的数字都可能含有重复数字。
示例 1:
输入:secret = "1807", guess = "7810"
输出:"1A3B"
解释:数字和位置都对(公牛)用 '|' 连接,数字猜对位置不对(奶牛)的采用斜体加粗标识。
"1807"
|
"7810"
示例 2:
输入:secret = "1123", guess = "0111"
输出:"1A1B"
解释:数字和位置都对(公牛)用 '|' 连接,数字猜对位置不对(奶牛)的采用斜体加粗标识。
"1123" "1123"
| or |
"0111" "0111"
注意,两个不匹配的 1 中,只有一个会算作奶牛(数字猜对位置不对)。通过重新排列非公牛数字,其中仅有一个 1 可以成为公牛数字。
说明:
- 1 <= secret.length, guess.length <= 1000
- secret.length == guess.length
- secret 和 guess 仅由数字组成
思路
这个题目的算法还是挺直接的,就是比较相应位置上的数字是否一致,如果一致bulls加1,否则就要累加错误数字的出现次数,但最多不能超过其在secret中的个数。
代码
/**
* @date 2024-03-10 0:36
*/
public class GetHint {
public String getHint(String secret, String guess) {
int bulls = 0;
int cows = 0;
char[] secretCharArray = secret.toCharArray();
int[] missArray = new int[10];
int[] cowsArray = new int[10];
for (int i = 0; i < secretCharArray.length; i++) {
int guessCharacter = guess.charAt(i) - '0';
int secretCharacter = secretCharArray[i] - '0';
if (guessCharacter == secretCharacter) {
bulls++;
} else {
missArray[secretCharacter] += 1;
cowsArray[guessCharacter] += 1;
}
}
for (int i = 0; i < cowsArray.length; i++) {
if (missArray[i] > 0 && cowsArray[i] > 0) {
cows += Math.min(missArray[i], cowsArray[i]);
}
}
StringBuilder sb = new StringBuilder();
return sb.append(bulls).append('A').append(cows).append('B').toString();
}
}
这里面有几点需要注意:
- 数组只需要保留0-9十个数字即可
- 数字字符减去
'0'
可以得到对应的整型数字 - 不要使用加号拼接返回的结果,因为每一次字符串的修改都会创建新的对象,会显著影响性能
性能
2917.找出数组中的K-or值
目标
给你一个下标从 0 开始的整数数组 nums 和一个整数 k 。
nums 中的 K-or 是一个满足以下条件的非负整数:
只有在 nums 中,至少存在 k 个元素的第 i 位值为 1 ,那么 K-or 中的第 i 位的值才是 1 。
返回 nums 的 K-or 值。
注意 :对于整数 x ,如果 (2^i AND x) == 2^i ,则 x 中的第 i 位值为 1 ,其中 AND 为按位与运算符。
示例 1:
输入:nums = [7,12,9,8,9,15], k = 4
输出:9
解释:nums[0]、nums[2]、nums[4] 和 nums[5] 的第 0 位的值为 1 。
nums[0] 和 nums[5] 的第 1 位的值为 1 。
nums[0]、nums[1] 和 nums[5] 的第 2 位的值为 1 。
nums[1]、nums[2]、nums[3]、nums[4] 和 nums[5] 的第 3 位的值为 1 。
只有第 0 位和第 3 位满足数组中至少存在 k 个元素在对应位上的值为 1 。因此,答案为 2^0 + 2^3 = 9 。
示例 2:
输入:nums = [2,12,1,11,4,5], k = 6
输出:0
解释:因为 k == 6 == nums.length ,所以数组的 6-or 等于其中所有元素按位与运算的结果。因此,答案为 2 AND 12 AND 1 AND 11 AND 4 AND 5 = 0 。
示例 3:
输入:nums = [10,8,5,9,11,6,8], k = 1
输出:15
解释:因为 k == 1 ,数组的 1-or 等于其中所有元素按位或运算的结果。因此,答案为 10 OR 8 OR 5 OR 9 OR 11 OR 6 OR 8 = 15 。
说明:
- 1 <= nums.length <= 50
- 0 <= nums[i] < 2^31
- 1 <= k <= nums.length
思路
这个目标看起来有些难以理解,其实简单来说就是要我们返回一个int类型的数字,这个数字的每一bit是由数组元素相应bit的值共同决定的。如果数组中在该bit位上为1的元素个数超过k,那么就将结果值的相应bit位置1,否则置0。
现在问题转化为累加数组元素在某一bit位的值,然后与k比较来确定输出结果相应bit的值。可以使用移位运算来判断数字在某一特定bit的值是否为1,例如:数字7的低四位为 0111
,想要判断第4个bit(从右边开始数)是否为1,可以将其右移3位,然后与1按位与即可。因为我们要判断的bit位经过右移变成了第一位,并且数字1只有第一位为1,其余位为0。
代码
/**
* @date 2024-03-06 0:26
*/
public class FindKOr {
public int findKOr_v1(int[] nums, int k) {
int res = 0;
for (int i = 0; i < 31; i++) {
int counter = 0;
for (int num : nums) {
// 判断可以省去,提高效率
// if ((num >> i & 1) == 1) {
// counter++;
// }
counter += num >> i & 1;
}
if (counter >= k) {
res |= 1 << i;
}
}
return res;
}
}
性能
2834.找出美丽数组的最小和
目标
给你两个正整数:n 和 target 。
如果数组 nums 满足下述条件,则称其为 美丽数组 。
- nums.length == n.
- nums 由两两互不相同的正整数组成。
- 在范围 [0, n-1] 内,不存在 两个 不同 下标 i 和 j ,使得 nums[i] + nums[j] == target 。
返回符合条件的美丽数组所可能具备的 最小 和,并对结果进行取模 10^9 + 7。
示例 1:
输入:n = 2, target = 3
输出:4
解释:nums = [1,3] 是美丽数组。
- nums 的长度为 n = 2 。
- nums 由两两互不相同的正整数组成。
- 不存在两个不同下标 i 和 j ,使得 nums[i] + nums[j] == 3 。
可以证明 4 是符合条件的美丽数组所可能具备的最小和。
示例 2:
输入:n = 3, target = 3
输出:8
解释:
nums = [1,3,4] 是美丽数组。
- nums 的长度为 n = 3 。
- nums 由两两互不相同的正整数组成。
- 不存在两个不同下标 i 和 j ,使得 nums[i] + nums[j] == 3 。
可以证明 8 是符合条件的美丽数组所可能具备的最小和。
示例 3:
输入:n = 1, target = 1
输出:1
解释:nums = [1] 是美丽数组。
说明:
- 1 <= n <= 10*9
- 1 <= target <= 10^9
思路
首先,美丽数组一个长度为n的数组,数组中的元素均不相同且为正整数,且任意两个元素之后不能等于target。
我们需要寻找所有可能的美丽数组中,所有元素和最小的那一个。并返回这个最小和。
如何才能使元素和最小?
很明显应该是从1开始的正整数序列,但是这个序列中的值相加不能等于target。
那么本质上是要求 1______mid~~~~~~target______
这一序列的值,其中~~~~~~
区间的值应舍去,因为会与前面的相加得到target。
刚开始想的是循环遍历 [1, n)
累加和,并记录target - i,如果遍历的过程中遇到了就直接跳过,但是提交后提示超出内存限制。于是进行修改,不保存不符合条件的元素,直接根据下标来判断,这次提示超时。
然后尝试不使用循环,直接使用等差数列求和公式计算,结果整型溢出。需要注意运算符优先级高的会先计算,如果没有显式地类型转换就会溢出,并且隐式类型转化使用的是溢出后的值。
代码
/**
* @date 2024-03-08 9:00
*/
public class MinimumPossibleSum {
public final int MOD = 1000000007;
public int minimumPossibleSum(int n, int target) {
int mid;
mid = target / 2;
if (n <= mid) {
return (int) ((n + n * (n - 1L) / 2L) % MOD);
} else {
int r = n - mid;
return (int) (((mid + mid * (mid - 1L) / 2L) + (long) target * r + r * (r - 1L) / 2L) % MOD);
}
}
}
性能
2575.找出字符串的可整除数组
给你一个下标从 0 开始的字符串 word ,长度为 n ,由从 0 到 9 的数字组成。另给你一个正整数 m 。
word 的 可整除数组 div 是一个长度为 n 的整数数组,并满足:
- 如果 word[0,...,i] 所表示的 数值 能被 m 整除,div[i] = 1
- 否则,div[i] = 0
- 返回 word 的可整除数组。
示例 1:
输入:word = "998244353", m = 3
输出:[1,1,0,0,0,1,1,0,0]
解释:仅有 4 个前缀可以被 3 整除:"9"、"99"、"998244" 和 "9982443" 。
示例 2:
输入:word = "1010", m = 10
输出:[0,1,0,1]
解释:仅有 2 个前缀可以被 10 整除:"10" 和 "1010" 。
说明:
- 1 <= n <= 10^5
- word.length == n
- word 由数字 0 到 9 组成
- 1 <= m <= 10^9
思路
题目要求的是从字符串第一个数字开始,验证数字能否被给定的正整数m整除。很容易想到的方法是创建一个StringBuilder,遍历字符串的Character,依次加入StringBuilder,然后转化为数字,求余判断能否整除。提交运行发现会溢出,虽然word的最大长度为10^5,但是Long的最大值也才 9223372036854775807
共19位,直接求解行不通。
很自然地想到需要分治处理,比方说先在Long的范围内算,每次处理19位,然后记录余数,再接上后面的18位。但是感觉这样具体实现起来很别扭,实际上求余数的时候也没必要每次都从头开始。
因此,我们可以用每一位对m(它不超过10^9,在Integer 2147483647
的范围内)进行MOD运算,同时记录结果。如果为0,则将输出数组中的相应位置赋值为1,否则为0。然后将余数 remainder
* 10
加上下一位字符所示的数字,再进行MOD运算以此类推即可。
代码
/**
* @date 2024-03-07 9:27
*/
public class DivisibilityArray {
public int[] divisibilityArray(String word, int m) {
long remainder = 0;
int n = word.length();
int[] res = new int[n];
for (int i = 0; i < n; i++) {
remainder = (remainder * 10 + (word.charAt(i) - '0')) % m;
if (remainder == 0) {
res[i] = 1;
}
}
return res;
}
public static void main(String[] args) {
DivisibilityArray main = new DivisibilityArray();
String str = "1000000000100000000030199999999610000000009199999999079999999938299999999010000000009999999991100000000010000000001000000000100000000071999999992100000000010000000003099999999759999999951000000000100000000010000000001000000000822999999988269999999921000000000100000000010000000005999999995900999999991100000000069999999947445979999999641999999999059999999957999999993100000000080609999999868999999992599999999867999999993100000000";
int m = 1000000000;
main.divisibilityArray(str, m);
}
}
这里面有个小技巧,就是使用 Character - '0'
来得到相应的数字,Character.getNumericValue
考虑的情况比较多,这里用不到。同时需要注意,余数应使用long类型防止整数溢出。
性能
2368.受限条件下可到达节点的数目
目标
现有一棵由 n 个节点组成的无向树,节点编号从 0 到 n - 1 ,共有 n - 1 条边。
给你一个二维整数数组 edges ,长度为 n - 1 ,其中 edges[i] = [ai, bi] 表示树中节点 ai 和 bi 之间存在一条边。另给你一个整数数组 restricted 表示 受限 节点。
在不访问受限节点的前提下,返回你可以从节点 0 到达的 最多 节点数目。
注意,节点 0 不 会标记为受限节点。
思路
自然的想法是构建图,将受限节点从中删除,然后深度优先遍历,同时记录节点个数。这里构建的图主要是为了获取其连通节点进行dfs,HashSet不太适合。因为数据可能并不是连续存储的,要先计算元素的Hash值,然后从桶中取出链表或者红黑树,才能找到元素。在本例中,性能会下降一倍。
代码
/**
* @date 2024-03-02 15:39
*/
public class ReachableNodes {
public int res = 1;
boolean[] isRestricted;
public int reachableNodes(int n, int[][] edges, int[] restricted) {
List<Integer>[] g = new ArrayList[edges.length + 1];
isRestricted = new boolean[edges.length + 1];
for (int i : restricted) {
isRestricted[i] = true;
}
for (int i = 0; i < g.length; i++) {
g[i] = new ArrayList<>();
}
for (int[] edge : edges) {
if (isRestricted[edge[0]] || isRestricted[edge[1]]) {
continue;
}
g[edge[0]].add(edge[1]);
g[edge[1]].add(edge[0]);
}
dfs(0, -1, g);
return res;
}
public void dfs(int root, int parent, List<Integer>[] g) {
for (Integer n : g[root]) {
if (n == parent) {
continue;
}
res++;
dfs(n, root, g);
}
}
}
性能
看了官网的答案还可以使用并查集,耗时只要10ms,有时间可以看看。