在C语言中,表达式和语句是构成程序的基本元素。本节和下一章节我们就围绕它们展开讲一讲其中的C语言基础语法。
首先,让我们区分这两个概念:
在C语言中,语句和表达式实际上并没有明显的绝对界限,它们的关系是:
a = 10
是一个赋值表达式,但只要加上";",就会变成一个赋值语句。表达式语句是语句最简单的形式。在表达式中,最重要、最核心的就是连接表达式中常量、变量的运算符了,所以本小节我们主要研究C语言的运算符。
C 语言拥有异常丰富的运算符,比较常见和常用的有以下运算符:
这些运算符,又可以根据操作数的多少,分为:
下面逐一学习一下。
为了更好的描述C语言当中运算符组成的表达式作用,我们引入两个重要的概念:
举几个例子:
比如表达式5 + 2
,其主要作用是计算得出了一个结果值,也就是7,当然它没有副作用。
但假如是表达式int x = 7
,**这是一个赋值表达式,赋值表达式也是表达式,也会计算出一个值,这个值就是赋值后变量的值,也就是表达式的右值。**在这个表达式中就是7。赋值表达式显然是有副作用的,副作用就是改变了变量x的值。
函数调用也是一个典型的表达式,比如test()
,假如这次函数调用返回值是100,那么这个表达式的主要作用就是100这个返回值。再假如函数内部进行了I/O操作,将数据打印到了屏幕上,那么I/O操作就是表达式的副作用。
副作用是表达式非常重要的一个效果,它使得表达式可以进行计算值之外的操作,这在很多时候都是非常有用的。
但过于依赖副作用也会导致程序的执行具有更多的不可预测性,使得代码可读性下降,比如:
代码块 1. 依赖副作用会导致代码可读性下降-演示代码
int arr[] = {1, 2, 3, 4, 5};
int i = 0;
while (i < 5) {
printf("%d ", arr[i++]); // 这行代码的执行需要依赖i++的副作用
}
循环控制变量i的增加,就需要依赖i++
的副作用,这无疑增加了复杂性,降低了可读性。如果改成下面:
代码块 2. 依赖副作用会导致代码可读性下降-演示代码2
printf("%d ", arr[i]);
i++;
代码可读性明显更好。
算术运算符包含最常见的+, -, *, /, %
,它们分别表示加,减,乘,除以及取余(取模)。这些运算符比较简单,只需要注意:
它们都是二元运算符,需要两个操作数。
如果 / 的两个操作数都是整数,那么结果也是整数 (向零取整)。因此,1 / 2 的结果为 0 而不是 0.5。
% 取模运算要求两个操作数都是整数,而其它的算术运算符可以用于浮点数。
在C语言中,%运算的结果符号总是和被除数保持一致。取模运算的结果遵循以下公式:
(a % b) = a - (a / b) * b
鉴于算术运算符比较简单,所以我们放在最开始讲,下面我们要讲C语言运算符的两个非常重要的性质:
搞清楚它们,对于分析清楚一个复杂的C语言的表达式至关重要。
下面这张表格就罗列出了常见的运算符的优先级,以及它的结合性,其中优先级数字越小,代表优先级越高,越先进行计算。
表 1. C语言常见运算符的优先级和结合性
优先级 | 运算符 | 描述 | 结合性 |
---|---|---|---|
0 | 小括号() | 最高优先级 | 从左到右 |
1 | ++ -- | 后缀自增与自减,即a++这种 | 从左到右 |
小括号() | 函数调用的小括号 | ||
[] | 数组下标 | ||
. | 结构体与联合体成员访问 | ||
-> | 结构体与联合体成员通过指针访问 | ||
2 | ++ -- | 前缀自增与自减,即–a这种 | 从右到左 |
+ - | 一元加与减,表示操作数的正负 | ||
! ~ | 逻辑非与按位非 | ||
(type_name) | 强制类型转换语法 | ||
* | 解引用运算符 | ||
& | 取地址运算符 | ||
sizeof | 取大小运算符 | ||
3 | * / % | 乘法、除法及取余运算符 | 从左到右 |
4 | + - | 二元运算符的加法及减法 | |
5 | << >> | 左移及右移位运算符 | |
6 | < <= | 分别为 < 与 ≤ 的关系运算符 | |
> >= | 分别为 > 与 ≥ 的关系运算符 | ||
7 | == != | 分别为 = 与 ≠ 的关系运算符 | |
8 | & | 按位与 | |
9 | ^ | 按位异或 | |
10 | ` | ` | 按位或 |
11 | && | 逻辑与 | |
12 | ` | ` | |
13 | ?: | 三目运算符 | 从右到左 |
14 | = | 简单赋值 | |
+= -= | 和及差复合赋值 | ||
*= /= %= | 积、商及余数复合赋值 | ||
<<= >>= | 左移及右移复合赋值 | ||
&= ^= ` | =` | 按位与、异或及或复合赋值 | |
15 | , | 逗号 | 从左到右 |
现在根据这张表格,我们尝试来分析几个复杂表达式的运算过程:
移位运算和算术运算结合:
对于表达式a + (b - a >> 1)
,该表达式是如何进行计算的呢?分析如下:
(b - a >> 1)
最先计算。>>
的优先级低于二元运算符-
,再加上小括号的存在,所以b - a
最先计算。b - a
结果右移。解引用运算符和自增自减运算符结合:
p是一个指向int变量的指针类型,对于表达式int num = *p++
,该表达式是如何进行计算的呢?分析如下:
p++
最先进行计算。p++
,它的主要作用是直接返回当前p的值,副作用是将p的值加1。*(p++)
就是*p
,也就是对指针p执行解引用运算。=
,将*p
的结果赋值给变量num。整个表达式执行完毕。p++
带来的副作用。*p
的结果赋值给变量num,并且p指针自增1。对于表达式int num = ++*p
,该表达式是如何进行计算的呢?分析如下:
++*p
中,前缀自增运算符 ++
和解引用运算符 *
都作用于指针 p
。*p
。所以整个表达式最先计算的是*p
=
会将*p
的结果自增1后再赋值给变量num。*p
的结果自增1后赋值给变量num。关于运算符优先级和结合性的建议
运算符的优先级和结合性显然是运算符非常核心的重要概念,但强行把这张表格记下来显然是不现实的,所以我们给出以下总结和建议:
以上。
表达式的主要作用是计算出一个结果,如果想要将这个结果保存下来,以便接下来使用,就需要用到赋值运算符了。
下面,我们将赋值运算符分为两类,来详细讲解赋值运算符:
=
,这里建议把它称为"赋值号",而不要直接叫"等号"。+=, -=, *=, %=
等一系列复合赋值运算符。在C语言中,简单赋值操作符 =
是最基本的赋值运算符,用于将右侧表达式的值赋给左侧的变量。这种操作是C语言中最常见的操作之一,几乎出现在每个程序中。
=
赋值运算符组成的赋值表达式也具有两个作用,即主要作用和副作用。
举例:
(ch = getchar()) != '\n'
这个表达式是我们前面讲基本数据类型时,给出的一个惯用法,现在我们来分析一下这个表达式:
ch = getchar()
getchar()
是函数调用表达式,它的优先级最高,先执行。getchar()
函数的返回值,也就是这一次读到的字符(ch = getchar()) != '\n'
整体就是:
getchar()
函数的返回值,也就是这一次读到的字符是否是换行符除此之外,以下细节也需要稍微注意下:
赋值的过程中如果右值和左边类型不匹配,会自动进行隐式类型转换,当然这个过程中往往伴随精度损失。如下:
代码块 3. 赋值过程中的隐式类型转换-示例代码
int i;
float f;
i = 72.99f; /* i is now 72 */
f = 136; /* f is now 136.0 */
由于赋值表达式的主要作用存在,所以可以用=
串联几个变量一起进行赋值,但这种方式可读性差,不推荐:
i = j = k = 0; // 三个变量的取值都是0
由于赋值运算符是右结合的,上述表达式等价于:
i = (j = (k = 0));
课堂小练习
代码块 4. 课堂练习题1
int i;
float f;
f = i = 33.3f;
请问变量 f 的值是多少?
利用变量原有的值去计算新的值,这在程序中是非常普遍的。例如:
i = i + 2;
C 语言的复合赋值运算符可以将上面的表达式简写为:
i += 2; /* same as i = i + 2 */
类似地,还有:
代码块 5. 复合赋值运算符-演示代码
i -= 2; /* same as i = i - 2 */
i *= 2; /* same as i = i * 2 */
i /= 2; /* same as i = i / 2 */
i %= 2; /* same as i = i % 2 */
...
复合赋值运算符组成的表达式同样存在主要作用和副作用,但一般它主要作用我们很少去使用,更多的还是关注它的副作用,即给变量赋值的作用。
自增 (加 1) 和自减 (减 1)是C语言编程中非常常见的基础操作。当然,这个操作可以用赋值运算符来完成,比如:
代码块 6. 用赋值运算符完成自增自减-参考代码
1
i = i + 1;
2
i = i - 1;
3
i += 1;
4
i -= 1;
但这样肯定是比较麻烦的,C 语言提供了一种更简洁的方式:
自增自减都有前缀运算符(如++i, --i)和后缀运算符(如 i++, i–)两种不同的形式,前后缀不同组成的表达式运算也不同。
大多数C语言学习者都会对前缀和后缀的不同运算方式头疼,这里我们将彻底给大家讲明白这个语法。
我们以两个简单的例子,来说明前缀和后缀运算符的区别:
前缀自增自减,具有右结合性
对于一个简单的前缀运算表达式:--a
--a
我们先分析它的主要作用和副作用。
a
自减1后的结果,这就是此表达式计算出来的一个值。a
自减1。--a
处于一个复杂的表达式当中,那么它的主要作用就发挥作用。在整个表达式中它代表a
自减1后的值。--a * 10 + 10
这样,--a
会优先计算,并且它在整个表达式中代表a
自减1后的值。所以我们可以对前缀自增自减运算符做一个总结:
前缀运算符总会先进行自增自减,表达式的主要作用是返回自增自减后的结果。副作用则是变量自增自减1。
小练习
代码块 7. 前缀自增自减-演示代码
int a = 1;
int b = ++a;
printf("%d\n", --b);
printf("a is %d now.\n", a);
printf("b is %d now.\n", b);
输出的结果分别是什么呢?
下面,我们可以用同样的手段来分析后缀运算符:
后缀自增自减,具有左结合性
对于一个简单的后缀运算表达式:a++
a++
同样具有主要作用和副作用:
a
的值,这就是此表达式计算出来的一个值。a
自加1。a++
处于一个复杂的表达式当中,那么它的主要作用就发挥作用。在整个表达式中它代表a
变量原本的值。a++ * 10 + 10
这样,a++
会优先计算,并且它在整个表达式中就代表a
的值。所以我们可以对后缀自增自减运算符做一个总结:
后缀运算符总会先直接返回被运算变量的值,表达式的主要作用是返回原本变量的值。副作用则是变量自增自减1。
课堂小练习
代码块 8. 自增自减符号课堂练习代码
int i = 1;
int j = 2;
int k = ++i + j++;
int x = 4;
int y = 5;
int z = x++ + ++y * 10;
请指出上述代码中,各变量的最终结果分别是什么。
首先我们来看一段代码:
代码块 9. 自增自减运算符陷阱-演示代码
int a = 1;
// a = a++;
// int b = a++ + --a;
分别释放两段注释处的代码,结果是什么呢?符合上述我们对自增自减符号的理解吗?
实际上,上述代码在不同平台编译器运行的结果,可能是不一样的。这又涉及到C语言的一个坑爹的语法陷阱。
在C语言中,类似表达式i = i++
这样,被自增自减的变量在同一个表达式中出现多次,会引发未定义行为。这意味着不同平台编译器可以选择任意方式来处理这种情况,包括但不限于返回任何值、程序错误崩溃或其余难以预测的结果。
那么为什么这种代码会导致未定义的行为呢?
这是因为同一个变量 i
在同一条语句中被修改了多次。
而按照 C 语言标准,同一个变量在同一个条语句之间只能被修改一次,如果多次修改会引发未定义行为。
于是不同的编译器平台,就可以自由地决定,同一变量多次修改的执行顺序(执行顺序不同结果必然不同),甚至可以返回任意值或者程序崩溃。
总之,基于程序员日常开发的经验以及出于规避C语言语法陷阱的考量。对于自增自减符号的使用,我们给出以下建议:
在C语言中,关系运算符用于比较两个值,并根据比较的结果返回一个布尔值(在C语言中,1表示true,0表示false)。
C语言中的关系运算符包括:
关系运算符都是比较简单的,我们不再赘述。一些小的注意事项细节如下:
C语言的逻辑运算符非常简单,只有三个:
注意:C 语言会把任何零值当作 false,任何非零值当作 true。
所谓的短路指的是:
短路的好处不仅仅是减少运算次数,提高效率,更重要的是避免程序中的一些错误,比如:
1
(i != 0) && (j / i > 0)
如果没有短路计算,上面的表达式可能就会出现除零错误,导致程序崩溃。
一个非常经典的问题是:
表达式i < j < k
在 C 语言中是合法的,但可能不是你所期望的含义。
比较运算符是左结合性的,所以i < j < k
等价于(i < j) < k
,换句话说,该表达式首先检测 i 是否小于 j,然后用比较后产生的结果 (0 或者 1) 和 k 进行比较。
若要测试 j 是否位于 i 和 k 之间,我们应该使用:i < j && j < k
。
位运算符在系统编程、嵌入式系统、加密程序、图形处理等需要底层数据控制和快速运算的领域中非常有用。
但是在偏向业务逻辑开发的应用级领域则不太需要使用,因为可读性和代码维护的重要性往往大于微小的性能提升。总得来说,在以后的学习工作中,位运算符不算是一个频繁使用的语法点,但考虑到面试常考常问,我们仍然把位运算符当成学习的重点看待。
在C语言中,一共提供了6个位运算符,它们都可以对整数数据进行位运算操作。
首先,我们讨论两个移位运算符(因为它们最常用),然后再讨论其他 4 个按位运算符 (按位取反~
,按位与&
,按位异或^
,按位或|
)。
为了更好的描述位运算符,这里给出一个概念定义:
**位模式:**某个变量的位模式,指的是该变量在计算机中的二进制存储形式。对于整数变量来说,位模式也就是该整数在内存中的补码形式。
在C语言中,有两种类型的移位运算符,它们分别是左移位运算符(<<
)和右移位运算符(>>
)。
移位运算符,可以通过将整数数据的二进制位向左或向右移动,来变换整数的二进制表示,从而改变整数值。
下面用两个表达式来描述移位运算符的基本作用,其中i
是整数,j
是一个正整数:
i << j
:将 i
的位模式向左移动 j
位。
i
乘以 2j(如果没有溢出的话)i
移位运算后的结果i
变量的取值,移位运算符没有赋值的作用!!i >> j
:将 i
的位模式向右移 j
位。
i
是无符号数或者非负值,则在左边补 0。i
是非负数的情况下,实际上该操作相当于将i
除以2j(注意整数除法的结果还是整数)i
移位运算后的结果i
变量的取值,移位运算符没有赋值的作用!!注意事项/细节/建议
右移位运算符会根据平台实现不同,会在左边补0或者补1,这是非常坑爹的一个设计。因为当你尝试对一个负整数做右移运算时:
所以我们给出关于关于位运算符使用的第一个建议:
在实际开发中,进行移位运算的数据往往是内存地址、数组长度、某个数据结构的容量等非负数数值,所以为了更好的平台移植性,我们建议大家最好仅对无符号数进行移位运算。
一个参考的代码示例如下:
代码块 10. 移位运算符的使用-参考代码
unsigned short i, j;
i = 10; // 0000 0000 0000 1010
j = i << 2; // 0000 0000 0010 1000 十进制40
j = i >> 2; // 0000 0000 0000 0010 十进制2
总结:
从上面的例子可以看出,对无符号整数左移 j 位,相当于乘以 2j (不发生溢出的情况下);对无符号整数右移 j 位,相当于除以 2j。
左移时如果发生了溢出将产生未定义行为,所以程序员在执行左移位运算时,需要认真思考移位的极限,避免溢出产生。
按位位运算符包含:按位取反~
,按位与&
,按位异或^
,按位或|
。其中按位取反是一元运算符,其余都是二元运算符。
对于以下表达式:
~a
:对a进行按位取反运算。也就是将 a 的每一位进行取反操作。即 0 变成 1,1 变成 0。
i & j
:对 i 和 j 的每一位进行逻辑与运算。只有两个数的相应位都为1时,结果位才为1,否则为0。
i | j
:对 i 和 j 的每一位进行逻辑或运算。只要两个数的相应位中有一个为1,结果位就为1,否则为0。
i ^ j
:对 i 和 j 的每一位进行异或运算。如果两个数的相应位一个为0,另一个为1,则结果位为1,否则为0。(对应位不同结果是1)
注意:
&
、|
不是逻辑运算符,而是位运算符,不要搞混淆了。下面的代码示例演示了这些运算符的作用:
代码块 11. 按位运算符的作用-演示代码
unsigned short i, j, k;
i = 21; /* 0000_0000_0001_0101 */
j = 56; /* 0000_0000_0011_1000 */
k = ~i; /* 1111_1111_1110_1010 */
k = i & j; /* 0000_0000_0001_0000 */ // 16
k = i | j; /* 0000_0000_0011_1101 */ // 61
k = i ^ j; /* 0000_0000_0010_1101 */ // 45
除了按位取反运算符外,其余的位运算符都有对应的复合赋值运算符形式:
代码块 12. 位运算符的复合赋值运算符形式-演示代码
i = 21;
j = 56;
i <<= 2;
i >>= 2;
i &= j;
i |= j;
i ^= j;
按位运算符中,比较需要留意的是按位异或。它具有以下一些非常优秀的性质:
a ^ 0 = a; 任何整数异或0得到的都是它本身
a ^ a = 0; 任意整数异或自己得到的都是0
a ^ b = b ^ a; 异或运算满足交换律
(a ^ b) ^ c = a ^ (b ^ c); 异或运算满足结合律
以下问题,请考虑使用位运算符来解决。
定义一个函数,判断某个整数是否为奇数。
这个函数在以前大家就都会写了,如下:
代码块 13. 判断奇数函数定义-参考代码
bool is_odd(int num) {
// 整数num取余不为0就是一个奇数
return num % 2 != 0;
}
但如果需求更好的运算效率,可以考虑用位运算符来进行奇数的判断。
原理是:
一个整数的位模式表示,如果它是偶数那么它二进制位的最后一个位必然是0,反之如果它是奇数最后一位必然是1。
为什么呢?
因为二进制的每一位代表的是2的幂次方,从右向左分别是 20,21,22,…等等。
这些组成中,只有最低位的20不是2的整数倍,所以只要最低位是0,那么这个整数就一定是偶数。(因为更高位都是2的倍数)
基于以上原理,我们就可以写出以下用位运算判断奇数的代码:
代码块 14. 判断奇数函数定义-参考代码2
bool is_odd(int num) {
/*
* 让num按位与...0001
* 如果是奇数,num的最后一位是1,返回true
* 如果是偶数,num的最后一位是0,返回false
*/
return num & 1;
}
下一题:
给定一个正整数,请定义一个函数判断它是否为2的幂(1, 2, 4, 8, 16, …)。
一个非常简单易想、易实现的方式就是:
只要这个数大于1且能被2整除,就一直将它除以2,判断最终得到的结果是不是1,若是1,则是2的幂。
参考代码如下:
代码块 15. 判断是否是2的幂-参考代码
bool is_power_2(int n) {
// 只要这个数大于1且能被2整除,那就一直除以2
while (n > 1 && n % 2 == 0) {
n /= 2;
}
// 判断最终结果是不是1
return n == 1;
}
这种解法可以用左移位运算符稍作优化,逆向思维一下。参考代码如下:
代码块 16. 判断是否是2的幂-参考代码2
bool is_power_2(int n) {
int x = 1;
// 只要x比n小就左移1位,乘以2
while (x < n){
x <<= 1;
} // 结束while的条件是x >= n
// 如果n是2的幂,那么x和n相等
return n == x;
}
但这些解法都还不是最优的,下面我们先观察一下2的幂整数的位模式:
1:0001
2:0010
4:0100
8:1000
…
很显然一个整数,如果它是2的幂,那么它的二进制位表示中仅有一位是1,其余位都是0。
利用这个性质,我们将该整数和该整数减1做按位与运算,结果是0就说明该整数是2的幂。如下:
4 (0100) & 3 (0011) = 0
8 (1000) & 7 (0111) = 0
参考代码如下:
代码块 17. 判断是否是2的幂-参考代码3
bool is_power_2(int n) {
return (n & (n - 1)) == 0;
}
这个实现代码更简洁,效率更高。
下一题:
给定一个不为0的整数,编写函数找出它值为1的最低有效位 (称之为Last Set Bit)。
一个示例如下:
输入:n = 24
输出:8
解释:24的二进制表示为 11000,值为 1 的最低有效位为 2^3,所以输出8。
如何实现呢?
一个仍然比较易想易实现的思路是:
将该数与1、2、4、8…等整数逐一做按位与运算,当结果不为0时,该整数的值为1的最低有效位就是它。
举例:
8:1000 值为1的最低有效位是8
8 & 1 = 1000 & 0001 = 0
8 & 2 = 1000 & 0010 = 0
…
8 & 8 = 1000 & 1000 != 0
于是8,就是整数8的值为 1 的最低有效位。
参考代码如下:
代码块 18. 寻找值为1的最低有效位-参考代码
int find_last_set_bit(int n) {
int x = 1;
while ((n & x) == 0) {
x <<= 1;
}
return x;
}
这种实现实际上已经很不错了,但它还不是最优解。
要想搞清楚最优解的思路,我们再来复习一个前面课程提到过的问题:给出一个整数的补码,请求它相反数的补码。
例如:
x的补码形式:0101 1100
-x的补码形式,可以利用补码的一个性质来实现:
x + (-x) = 1, 000…02进制补码(其中0一共有n个,高位的1溢出被舍掉) = 0
于是我们可以找到x的二进制补码中,值为1的最低有效位(也就是last set bit),那么它的相反数的补码就是:
于是-x的补码就是:1010 0100
通过这个案例,我们就发现:x和-x的位模式,last set bit是一致的,高于last set bit的位全部相反,低于last set bit的位全部是0。
所以只需要将x和-x进行按位与操作,就可以找到last set bit了。
参考代码如下:
代码块 19. 寻找值为1的最低有效位-参考代码2
int find_last_set_bit(int n) {
return n & (-n);
}
这个实现就非常简洁优雅了。
下一题:
给定两个不同的整数 a 和 b,请交换它们两个的值。(这里不要定义函数)
这个题目相信大家都可会用以下代码实现:
代码块 20. 实现两个数的交换-参考代码
int a = 100, b = 200;
int tmp = a;
a = b;
b = tmp;
这种方式也要求大家掌握,因为它具有以下优点:
当然它的缺点是:需要一个临时变量tmp,这可能会导致额外的内存空间占用。
所以在早期,出于节省内存空间,简化代码的考量,出现了下面利用异或位运算符交换两个元素的编程技巧(惯用法):
代码块 21. 实现两个数的交换-参考代码2
int a = 100, b = 200;
a = a ^ b;
b = a ^ b;
a = a ^ b;
这种写法之所以能够完成两个数的交换,利用的是异或运算符^
的性质。计算过程分析如下:
我们将原始的a 和 b的值记为a0,b0
第一个表达式执行后的a和b的值,记为a1和b1
…
于是整个计算过程为:
以上,三个表达式执行完毕后,a和b在不利用额外空间的前提下就完成了交换,nice!
总结:
交换元素是程序中特别常见和重要的操作,在大多数情况下,我们还是建议大家用临时变量的形式交换元素。因为它可读性更好,且不受元素类型限制,并且效率几乎没有差异,只是占用了一些额外空间罢了。
而且用^
交换元素还可能导致交换两个相同元素导致结果为0的情况出现,这也为程序带来了极大的风险。
下一题:
给定一个非空的整数数组nums,已知某个元素只出现了一次,其余元素均出现两次,那么请你找出这个只出现一次的元素。
怎么做呢?
使用异或运算即可,因为异或运算有以下性质:
于是只需要将数组中的所有元素,全部用^
运算符链接起来,这样最终的结果就是那个唯一的元素。
例如:
数组nums:[1, 4, 2, 1, 2]
将所有元素都^链接起来,1 ^ 4 ^ 2 ^ 1 ^ 2 = (1 ^ 1) ^ (2 ^ 2) ^ 4 = 0 ^ 0 ^ 4 = 4
于是4就是数组中仅存在一个的元素。
参考代码如下:
代码块 22. 查找数组中出现一次的元素-参考代码
int nums[] = { 1, 4, 2 ,1, 2 };
/*
* result就是最终找出的出现一次的元素
* 对于异或运算来说,0异或其它值都是其它值本身
* 所以result的初始值设置为0
*/
int result = 0;
// 遍历将数组中的所有元素都链接起来
for (int i = 0; i < 5; i++){
result ^= nums[i];
}
printf("数组中的唯一元素就是 %d.\n", result);
以上。
题目:
给定一个非空的整数数组nums,已知有两个元素只出现了一次,其余元素均出现两次,那么请你找出这两个只出现一次的元素。
既然有两个元素只出现一次,那么求解这个问题就稍微复杂一些了。有两个比较好的手段可以参考:
哈希表的实现方式,其实难点更多在于如何手写一个哈希表,因为C语言没有提供相关的库来实现哈希表。关于哈希表,我们等到后续《数据结构》阶段,再来详细探究,今天先略过。
这里,我们就重点探究一下异或运算如何实现求解这个问题。
利用异或运算,求解找出数组中的两个唯一元素
对于数组nums,我们设a和b是其中的两个唯一元素。
首先,我们仍然还是将数组中的所有元素都使用^
运算符链接起来,但此时我们将得到a ^ b
。
那么接下来咋办呢?
接下来需要分组:
只需要将nums数组,分为a和b单独存在的两个数组即可:
[a, …]
[b, …]
分组后,再分别将两个数组中的所有元素用^
链接起来,于是结果就是a和b。
那么分组如何实现呢?
我们已知a和b必然不是同一个整数,值是不一样的,那么它们的位模式必然存在某一位是不同的,如下面的例子:
a(假设a是3): 0011
b(假设b是5): 0101
a ^ b = 0011 ^ 0101 = 0110
这就说明,a ^ b
结果的位模式必然有一位是1,且该位为1意味着a和b在该位取值不同。
利用这样的一个特点,我们只需要找到a ^ b
的某一个为1的位,然后根据该位为1还是为0,就实现了对nums数组的分组。
那如何找到a ^ b
结果位模式中为1的那个位呢?
利用前面讲的求"last set bit"的算法即可。
于是,我们的分组是这样的:
但是需要注意的是,该分组不需要物理上分组,只需要逻辑分组就可以了:
找到"last set bit"位后,遍历整数数组,每个元素都和"last set bit"值做"&"按位与运算:
^
链接起来。最终结果就是一个只出现一次的元素。^
链接起来。最终结果就是令一个只出现一次的元素。参考代码如下:
代码块 23. 利用异或求解数组中的两个唯一元素-参考代码
int nums[] = { 1, 4, 3, 2 ,1, 2 };
// 第一轮异或全体数组的结果, 也就是两个唯一元素的异或结果
int xor_result = 0;
for (int i = 0; i < 6; i++) {
xor_result ^= nums[i];
}
// 找到last set bit
int lsb = xor_result & (-xor_result);
// 再次遍历数组,根据lsb将数组逻辑上分为两组
int a = 0, b = 0; // a和b代表两个唯一的元素
for (int i = 0; i < 6; i++) {
if (nums[i] & lsb) {
// 此组元素在lsb位的值是1
a ^= nums[i];
}else {
// 此组元素在lsb位的值是0
b ^= nums[i];
}
}
// 循环结束后,a和b的值就是数组中两个唯一的元素
以上。