字符串是若干字符组成的序列,因为使用频率较高在各语言中都做了特殊处理。
C\C++中的字符串,是以’\0’字符结尾的数组。Java中的字符串指的是一个包含了字符数组的类:String
。
在Java中,除了String
外和字符串相关的类还有StringBuffer
和StringBuilder
。搞清楚String
、StringBuffer
和StringBuilder
的应用和区别,对于Java基础的掌握非常重要,面试中往往也会出现相关问题。
String对象的主要作用是对字符串创建、查找、比较等基础操作。
String
的底层数据结构是固定长度的字符数组(char []
),该类的基础操作是和数组下标index
相关的查找操作,如indexOf
系列函数。该类的特点是不可变,任何拼接、截取、添加、替换等修改操作都将导致新String
对象的诞生,频繁的对象创建,将降低效率。
在代码中,类似于"hello"
的字符串,都代表了一个String对象,它通常也被成为字面量,区别于new String()
保存在堆内存,它被保存在虚拟机的常量池中。
如果你这样申明一个变量:String str = "hello";
,程序会首先在常量池中查看是否已有"hello"
对象,如果有则直接将该对象的引用赋值给str
变量,这种情况下,不会有对象被创建。当在常量池中找不到"hello"
对象时,会创建一个"hello"
对象,保存在常量池中,然后再将引用赋值给str
变量。这一点比较重要,以前经常在面试中被考察。
和String
对象关注字符串的基础操作不同,StringBuffer
和StringBuilder
一样,主要关注字符串拼接。
为什么String已经可以实现拼接操作,还专门设计StringBuilder
来做拼接的工作呢?还记得String对象的修改操作都会导致新对象的创建吧,在程序中,我们常常将字符串拼接的工作放在循环中执行,这将对系统资源造成极大的负担,而StringBuilder就是来解决这种问题的。
StringBuilder
继承了AbstractStringBuilder
,AbstractStringBuilder
的底层数据结构也是一个数组,通过一定的扩容机制,更改数组容量(创建新的更大的数组)。
之所以没有单独说明StringBuffer
,是因为它们太像了,不仅API类似(也意味着功能类似),也同样继承AbstractStringBuilder
,该类实现了主要功能,同样是维护了一个动态数组。
区别在于:
StringBuffer
是线程安全,效率较低StringBuilder
是线程不安全的,效率较高线程安全与否,全在于一个synchronized
关键字的使用。在StringBuffer
的函数申明中,都有synchronized
关键字,而StringBuilder
则没有。在代码中看看他们的区别:
StringBuffer
的 append函数:
@Override
public synchronized StringBuffer append(Object obj) {
toStringCache = null;
super.append(String.valueOf(obj));
return this;
}
StringBuilder
的 append函数:
@Override
public StringBuilder append(Object obj) {
return append(String.valueOf(obj));
}
StringBuilder
和StringBuffer
都继承AbstractStringBuilder
,扩容逻辑也都一样,在AbstractStringBuilder
类中定义。
先看扩容代码:
char[] value; // 保存字符的数组。
int count; // 当前字符数组中,已经保存的字符数。
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
public AbstractStringBuilder append(String str) {
if (str == null)
return appendNull();
int len = str.length();
ensureCapacityInternal(count + len); // 确保value数组长度足够,扩容逻辑函数。
str.getChars(0, len, value, count);
count += len;
return this;
}
private void ensureCapacityInternal(int minimumCapacity) {
// 参数minimumCapacity表示想要在旧数据中追加新数据所需要的最小数组容量。
// overflow-conscious code
if (minimumCapacity - value.length > 0) { // 如果当前数组容量小于最小容量,进入if代码块
value = Arrays.copyOf(value, newCapacity(minimumCapacity)); // 创建新数组,
}
}
private int newCapacity(int minCapacity) {
// overflow-conscious code
int newCapacity = (value.length << 1) + 2; // 新数组的容量,初始为原数组的2倍+2。
if (newCapacity - minCapacity < 0) { // 如果新数组容量小于最小容量,则新数组容量改为最小容量
newCapacity = minCapacity;
}
return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
? hugeCapacity(minCapacity)
: newCapacity;
}
private int hugeCapacity(int minCapacity) {
if (Integer.MAX_VALUE - minCapacity < 0) { // overflow
throw new OutOfMemoryError();
}
return (minCapacity > MAX_ARRAY_SIZE) ? minCapacity : MAX_ARRAY_SIZE;
}
整个逻辑还比较简单,梳理一下就是:
AbstractStringBuilder
每次append
字符串时,都会调用ensureCapacityInternal
确保容量足够同时存放新老字符串。MAX_ARRAY_SIZE
和Integer.MAX_VALUE
之间,那么新容量的值变为最小容量。区别的主要原因前面已经说清楚了,这里就不重复了。来看看String相关的面试题。
请实现一个函数,把字符串中的每一个空格替换成“%20”。例如输入“We are happy.”,则输出“We%20are%20happy.”。
背景:在网络编程中,如果URL参数中包含特殊字符,如空格,#等,可能导致服务器无法或者正确的参数。我们需要将这些特殊字符转换为服务器可以识别的字符。转化规则是在‘%’后面跟上ASCII码的两位16进制表示。比如空格的ASCII码是32,即十六进制的0x20。因此空格需要被替换为%20
。
使用Java为开发语言的小伙伴肯定觉得这个题如此简单,调用String类的replace函数就可以实现。是的,这么说没错。但实际上这个题是给C/C++语言设计的面试题。就Java而言,该题中字符串应该改为字符数组。
因为需要将一个空格替换成%20
,替换一次空间会增加2。所需,需要考虑原始字符数组空间是否足够。如果空间不够,则需要重新创建字符数组,将原数组字符拷贝一次,并不存在优化空间。这里就不考虑重新创建字符数组的情况。
那么我们假设原数组空间足够,会有两种实现方式:
从前到后遍历数组,遇到空格就存入%20
,同时将后面的所有函数向后位移2。假设字符串长度为n,对于每个空格字符,需要移动后面 O n O_{n} On个字符,因此对于含有 O n O_{n} On个空格字符的字符串而言总的时间效率为 O n 2 O_{n^2} On2。这可不是能够拿到Offer的。
这是一个时间复杂度为 O n O_{n} On的解法,它的思路是:
因为每替换一个空格,实际长度会增长2,所以,最终数组的实际长度为
实 际 长 度 = 原 数 组 实 际 长 度 + 空 格 数 ∗ 2 实际长度 = 原数组实际长度 + 空格数*2 实际长度=原数组实际长度+空格数∗2
所以,我们需要先遍历数组,以获得 原 数 组 实 际 长 度 原数组实际长度 原数组实际长度值,以及数组中的 空 格 数 空格数 空格数,计算出替换后的实际长度。
然后从后遍历数组,提供两个指针,p1
、p2
,p1
指向原字符串的末尾,p2
指向替换后字符串的末尾。接下来逐步向前移动p1
指针,将它指向的字符赋值到p2
指向的位置,同时p2
向前移动1格。碰到空格后,p1
向前移动一格,并在p2
前插入字符串%20
,%20
向前移动3格。最终p1
和p2
将指向同一个位置,替换完毕。
从分析来看,所有的需要移动的字符都只会移动一次,因此这个算法的时间复杂度是 O n O_{n} On,比上一个快。后面将以这个思路实现算法。
public class Main {
public static void main(String args[]) {
// 用0占位,使数组有充足的长度。
char[] str = {'w', 'e', ' ', 'a', 'r', 'e', ' ', 'h', 'a', 'p', 'p', 'y',
0, 0, 0, 0};
int rawLength = 0; // 原字符串长度
int emptyBlanks = 0; // 空格数量
for (char cha : str) { // 遍历,获得原字符串长度和字符串中空格的数量
if (cha == 0) {
break;
}
rawLength++;
if (cha == ' ') {
emptyBlanks++;
}
}
System.out.println(new String(str));
int p1 = rawLength - 1; // 减1是为了让指针从0开始计算
// 替换后的数组长度为原字符串长度加上空格数量的两倍,减1是为了让指针从0开始计算
int p2 = rawLength + 2 * emptyBlanks - 1;
while (p1 != p2 && p1 >= 0 && p2 >= 0) {
if (str[p1] != ' ') {
str[p2] = str[p1];
p1--;
p2--;
} else {
p1--;
str[p2--] = '0';
str[p2--] = '2';
str[p2--] = '%';
}
}
System.out.println(new String(str));
}
}
运行结果:
we are happy
we%20are%20happy
解:C++
C++的代码和Java代码几乎一致:
int main() {
char str[16] = {'w', 'e', ' ', 'a', 'r', 'e', ' ', 'h', 'a', 'p', 'p', 'y'};
int rawLength = 0; // 原字符串长度
int emptyBlanks = 0; // 空格数量
for (char cha : str) { // 遍历,获得原字符串长度和字符串中空格的数量
if (cha == 0) {
break;
}
rawLength++;
if (cha == ' ') {
emptyBlanks++;
}
}
cout << str << endl;
int p1 = rawLength - 1; // 减1是为了让指针从0开始计算
// 替换后的数组长度为原字符串长度加上空格数量的两倍,减1是为了让指针从0开始计算
int p2 = rawLength + 2 * emptyBlanks - 1;
while (p1 != p2 && p1 >= 0 && p2 >= 0) {
if (str[p1] != ' ') {
str[p2] = str[p1];
p1--;
p2--;
} else {
p1--;
str[p2--] = '0';
str[p2--] = '2';
str[p2--] = '%';
}
}
cout << str << endl;
return 0;
}
运行结果:
we are happy
we%20are%20happy