一点一点前进...

0%

三月,过完年从家中回北京,Kindle上下载了这本书,每晚都读一些,记点笔记。现总结成电子版。

开篇用金券、手(超级灵活,无法使用计算机模拟)、旅行问题、划分两个数组的事例,给大家一个直观的概念:P问题容易解决,NP问题不容易解决。若能证明P=NP,一切很美好;若不能,带来什么呢?长路漫漫,作者提出了很多问题,期待他能在后面一一解决。

作者假想了一个场景,经过一系列努力(这里面还YY了清华大学),人类解决了P=NP问题,并写出了能够生成代码解决NP问题的程序。有了这个程序,分析DNA易如反掌,战胜了癌症等各种现在开起来是绝症、疑难杂症;棒球比赛除了规则,一切都变了,计分、处理画面、安排赛事、训练等等,都可以快速的由计算机辅助完成;计算机程序能够创作小说、音乐、电影、游戏等等;不好的方面也有,比如RSA一瞬间就被破解了;人们没有隐私可言;很多很多人都要失业了。回到2012年(应该是作者写书的年代),不管怎样,有些问题终将被攻破,P是否等于NP呢?继续前进。

作者又假想了一个二万人组成的国家,来阐述NP问题。通过几个小问题告诉人们算法的重要性。P问题,多项式时间复杂度(也称为算术复杂度)就能解决的问题,是简单问题;NP问题,不确定性多项式时间,不知道有没有多项式时间复杂度的解,但是给出一个解,可以在多项式时间判断正误。但是遗憾的是,至今没能证明P=NP,但是不代表没有。P=NP证明不出来,P!=NP貌似更难证明,哈哈。

一个计算机科学家提出了P/NP问题,获得了图灵奖;一个计算机科学家归纳了NP问题,也获得了图灵奖;就连这个名字,也是赫赫有名的高德纳先生取的。

西方的美国人和东方的俄罗斯人,独立地发现了P/NP问题,可见其是自然孕育的事物,蕴含在生活之中。

不管P是否等于NP,即使等于,在找到P算法之前,我们仍然活在一个NP的世界。我们采用蛮力法来解决小规模问题,启发算法、近似算法等找一个可接受的解,亦或者,放弃解决某个问题,开心的玩去吧,因为目前为止,既没有证明P=NP,也没有证明P!=NP,且二者都很难。

计算机科学家试图去解决这些NP问题,比较重大的成果就是量子计算机。量子计算机能解决部分NP问题,比如RSA,很容易的分解很大很大的因数,世界显然就没有那么美妙了。当然,量子计算机也带来了量子加密算法以平衡这个世界,使之回归完美。

今年一月的时候,端着Kindle,经常随手翻翻,偶尔随手写写阅读笔记(真的是用笔和纸写的笔记)。
以下内容是当时写的笔记,现在总结成所谓的“电子版”。

这本书从光学讲起,微粒说(牛顿等人),波动说(胡克等人)。微粒说由于牛顿的权威性统治了约百年时间。波动说由托马斯杨双缝干涉,菲涅尔等人的贡献打了翻身仗,不过致命伤挺大的,引入了以太这么个看不见摸不着的东西。
看到这里,我觉得,相比时间简史这种科普书籍,这本更像是物理史方面的东西而不是“科普”,可能是我要求太高了吧。不过书中穿插了些小故事,茶余饭后的谈资,不过,谁又愿意听人扯物理而不是八卦呢。

普朗克沉下心,6年时间,“无意”中发现了量子,即将对已经建立起来的经典物理发起挑战。这本书还用了通俗的语言解释了量子——能力是一份一份的,很小很小,但是不能再分了。终于有点“科普”的意味了。
在普朗克提出(1900年)之后的若干年,发展很慢,直到爱因斯坦于1905年发表了关于光电效应的文章(那年,他有6篇伟大的论文,这是其中之一),该理论才有了进步。1913年,玻尔利用量子假说解释了原子模型、光谱等一系列问题,进一步发展了该理论。量子学说进入了“青年”,但是距离人们接受他还有一段路要走。同时,随着量子化的发展,光的“波”“粒”第三次大战即将到来。

原子模型被完善,各种实验证明量子的正确性,眼看量子要大爆发,但是“粒子”学说后院起火,德布罗意发表了他的博士论文,说电子是一种波。我擦,三次波粒大战被推向了高潮。不管是哪种学说,都不能说服所有人。
另一方面,海森堡和其他几个人发明了矩阵力学,把量子又往前推了一大把,曙光就在前方。

时势造英雄。在物理大发展的年代,物理学家稍不留神就跟不上节奏了。但一旦有什么突破成果,往往又再促进这个风起云涌的年代进一步快速的朝前发展。一个个闪耀的物理学家顺势而出,创造着新纪元。

薛定谔创造了波动方程,阐述了量子力学,与海森堡的矩阵力学在数学上是等价的。但是前者的解释是波,后者的解释是粒子。波恩对波动方程给出了相对正确的解释,波动方程描述的是粒子在某个空间位置出现的概率。就此,很快,物理学又引入了新的概念——不确定性。
不确定性原理,玻尔提出的互斥原理,加上波恩的概率解释,哥本哈根学派理论的三驾马车到齐了。

物理属性依赖于观察的方式,是波还是粒子,取决于人们观察这个事物的角度。这是物理,更是哲学。

玻尔和爱因斯坦三次华山论剑,玻尔大获全胜,量子物理日益壮大。不过,关于其物理意义的解释,就不那么顺利了。哥本哈根学派的弱点在于“观察者”,如何鉴定他,由此诞生了比薛定谔还出名的薛定谔的猫。而后,有用“意识”去解释的,这简直解释玄而又玄的东西,到了哲学范畴。

在物理学界,如何解释一个理论不是鉴定这个理论的标准,而是这个理论能否去预测未知的观察到的事实。

贝尔,站在爱因斯坦这一派,搞出了贝尔公式,等价于爱因斯坦向往的那种经典的一切都符合因果律的物理。但是,随着科学的进步,EPR实验越来越精确,一次又一次的突破了贝尔公式,预示着爱因斯坦是错误的。是的,上帝是掷骰子的。

还有一些关于量子物理的解释,有些很玄很扯的,靠谱的有什么多宇宙了,退相干理论等等。但是,都无法说服所有人。

量子力学确立并应用了近百年,深刻的变革着世界的方方面面。毫无夸张的说,今日的计算机科学、信息科学、生物科学等等,都有着量子力学的功劳。我们无法说20世纪的量子力学和相对论谁更伟大,但若说应用之广泛,影响之深刻,必定是量子力学。

量子方面最新最时髦的进展:弦,超对称下的超弦,粒子之间有一根弦,粒子共振产生的各种效应。超弦理论最新的进展是世界有11个维度,它能否或成为万能理论呢?量子史话就此结束,未来还等待人类去探索。

本书外送一篇海森堡之谜。他作为德国原子弹计划的领导者,高尚的品质还是算错了导致失败呢?事实是后者,虽然他为了他的荣誉,极力表达自己是高尚的。究其本质,他也是一个普通人,一个经历了二战的普通人。
不管怎么样,人们应该记住他,记住他的贡献,建立了量子力学。

预处理器把C语言源代码处理完之后,编译器会处理成对象文件(比如.o文件),链接器会找到相关文件,链接组成一个二进制文件。下面看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

int main()
{
void *mem = malloc(400);
assert(mem != NULL);
printf("HAHA\n");
free(mem);

return 0;
}

生成的main.o文件大致如下:
CALL malloc
CALL printf
CALL free
RET
为啥没有assert呢?因为assert是宏,预处理器处理完就没有了。

链接器把main.o和对应库文件链接生成main.out这个可执行文件。
运行这个文件,会打印HAHA。

如果我们把include stdio注释掉:
预处理器处理完成的文件是没有printf的声明的,编译时发现语法都是正确的,gcc会推测printf是一个输入string,返回int的函数,随之产生一个warning,链接器根据编译器给出的结果载入对应的库文件,生成的可执行文件和正常的是一样的。

如果我们把include stdlib注释掉:
编译器发现malloc free没有声明,推测其函数原型,产生warning,后续和上面情况类似,一切都正常。

如果我们把include assert注释掉:
预处理不会做对应的宏展开,编译器发现assert,推测原型,.o文件中多了一行CALL assert,链接器在库里面找不到assert函数,报错。

对于上面三种情况的一和二,自己写一个假的原型来欺骗编译器也可以,就去掉了warning。
其实,声明函数,定义一系列的参数列表,只要是为了能够在Stack上正确的记录上下文活动,而给编译器看是附带效果。
看下面的示例,程序是诡异的,但是能够正常输出的。

1
2
3
4
5
6
7
8
9
10
int strlen(char*, int);

int main()
{
int n = 65;
int length = strlen((char*)&num, num);
printf("%d\n", length);

return 0;
}

给了strlen的原型,但是和库里面的不一致,使用gcc编译之后,程序能正常运行,输出多少呢?
编译器看到strlen需要两个参数,且返回int。链接器去找strlen定义,很开心,找到了,链接,生成文件。这里需要说明下,.o文件没有参数类型,链接器只看名字。
运行时,栈里面是什么情况呢?
栈最底下是n,然后分配length需要的空间,再push num和num地址进去,两个参数,SP=SP-8,保存PC上下文,调用strlen,strlen需要一个参数char*,去SP的地址去拿,幸好,SP指向地方还真是个指针,指向num。strlen把num开始的地址作为char*去处理,遇到第一个0结束。在小端机器上,num内存是65 0 0 0,遇到第一个0结束,所以它认为这个字符串长度为1。
所以,这个诡异的程序输出了1!

将C语言源代码,处理成可执行的文件,大致需要三步,预处理、编译和链接。下面主要讲讲预处理器的作用,回头有时间再写一篇关于编译和链接的文章。

预处理器的作用是根据一定的规则,进行纯文本的替换,递归的执行,直到没有可替换的内容结束。使用gcc -E可以仅做预处理。

C语言中,大致有以下几种做法:
第一种,定义一个常量。

1
2
#define WIDTH 480
#define HEIGHT 720

上述就定义了两个常量,宽和高。我相信,没人想在源代码的很多地方写上480和720这两个数字。使用宏定义可以给这两个数字起一个有意义的名字,代码中使用有意义的名字来提升代码的可读性。预处理器会把代码中的WIDTH和HEIGHT全部替换成480和720。

第二种,定义函数。

1
#define MAX(a,b) (((a)>(b))?(a):(b))

上述就定义了一个返回最大值的函数。
为啥要套这么多括号,因为a和b可以是复杂的运算而不是简单的两个数,不写括号,可能会由于运算优先级被改变而导致出现非程序员期望的结果。
宏定义函数的优点是比普通的函数快,宏展开之后,不会有真正的函数调用。而普通调用需要保存上下文,传参,返回等等,浪费些许时间。
但是缺点很多:缺少必要的检查;
写不好会导致效率低下,比如int max = MAX(fib(a),fib(b)),展开后会调用三次fib函数;
多次或者不正确的副作用,比如int max = MAX(m++,n++),展开后,m和n回调用不同次++。
assert就是一个经典的宏定义函数,大致如下:

1
2
3
4
5
6
7
#ifdef NDEBUG
#define assert(cond) (void)0
#else
#define assert(cond) \
((cond)?(void)0:\
fprint(xxxx), exit(0))
#endif

对于宏函数,我个人觉得弊大于利,所以还是应该谨慎使用。
联系到C语言被创造的时间,上个世纪六七十年代,信条是程序员知道他做得事情,让他去做,甚至包括硬件。所以C语言设计的是基于硬件层的抽象,都能直接的映射到硬件操作。但是时至今日,操作系统,软件,硬件都变得日益复杂,程序员这个行业也越来越庞大,codebase也以数量级的方式增长,不能指望每个程序员都能明白他们在做的事情。所以,现代语言都从语言层面来限制程序员,最大效率的生产出软件,而软件本身的效率只是诸多关注点的一个罢了。

第三种,包含头文件。

1
2
#include <assert.h>
#include "yourheaderfile.h"

预处理器会查找对应文件并作替换,本来很小的文件会变的很大。
如果出现了循环依赖,那么预处理器就会一直递归的处理下去,无穷无尽。
如何解决这个问题呢?在需要的地方使用宏定义。

1
2
3
4
#ifndef XXX_H
#define XXX_H
// code
#endif

智能的现代预处理器应该会自己处理这种问题,即使你没写上述的代码。但是,写上述的东西很容易,而且就算没有循环依赖也没有什么副作用,所以最佳实践是,头文件都写上上述三行代码。

题目381的描述如下:
对于一个质数p来说,S(p) = (∑(p-k)!) mod(p) 其中 1 ≤ k ≤ 5
举个例子
(7-1)! + (7-2)! + (7-3)! + (7-4)! + (7-5)! = 6! + 5! + 4! + 3! + 2! = 720+120+24+6+2 = 872.
因为872 mod(7) = 4, 所以S(7) = 4.
对于5 ≤ p < 10 ^ 8,求∑S(p)

题目给了一个可以检验程序是否写正确的范例:∑S(p) = 480 其中5 ≤ p < 100.

根据Wilson’s theorem可以得到下面等式:
(p−1)!≡−1
(p−2)!≡(p−1)!(p−1)^−1≡1
(p−3)!≡(p−2)!(p−2)^−1≡(p−2)^−1
(p−4)!≡(p−3)!(p−3)^−1≡(p−2)^−1 * (p−3)^−1
(p−5)!≡(p−4)!(p−4)^−1≡(p−2)^−1 * (p−3)^−1 * (p−4)^−1
都是模p的,省略了。

(p−2)^−1 (p−3)^−1 (p−4)^−1这玩意如何整成整数呢?
下面给出逆元的定义:
对于整数n、p,如果存在整数b,满足nb mod p =1,则说,b是n的模p乘法逆元。并且:
n存在模p的乘法逆元的充要条件是gcd(n,p) = 1
显然,p-2 p-3 p-4与p互质,最大公约数就是1

那么如何求逆元呢?考虑扩展欧几里得算法
ap + b(p-2) = 1
显然,p-2的逆元就是扩展欧几里得算法中的b。

到此为止,基础的数学知识都已经准备妥当,开始写代码吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var primes = Utils.GenPrimes(100000000);
for (int i = 2; i < primes.Length; i++)
{
long p = primes[i];
long a = 0;
long p2 = 0;
long p3 = 0;
long p4 = 0;
Utils.GetGcd(p, p - 2, ref a, ref p2);
Utils.GetGcd(p, p - 3, ref a, ref p3);
Utils.GetGcd(p, p - 4, ref a, ref p4);

long s3 = ((p2 % p) + p) % p;
long s4 = ((s3 * p3) % p + p) % p;
long s5 = ((s4 * p4) % p + p) % p;
long s = ((s3 + s4 + s5) % p + p) % p;
sum += s;
}

return sum;

简单的解释一下,primes是2到10 ^ 8所有的质数,利用的是埃拉托斯特尼筛选法。
由于得到的逆元可能很大,也可能是负数,所以模p后加了p再进行一次模运算,实在懒的判断其是否大于0了,简单粗暴的写法。
后半段程序利用了点模的运算规则,都很基础,不再赘述。

扩展欧几里得算法,不仅可以得到整数m和n的最大公约数,还可以得到两个整数a和b,满足am + bn = d,其中,d是gcd(m, n)。

为了描述算法和证明方便,引入两个量c和d,其中d是上文指出的最大公约数,c的初始值是m
算法描述如下:
初始化,a = 0,b = 1,a’ = 1,b’ = 0,c = m,d = n // 步骤1
计算q = c / d,r = c % d // 步骤2
if r == 0:
算法结束,d是最大公约数,a和b就是要求的两个数。
else:
c = d,d = r // 这里和普通的欧几里得算法一致
t = a’,a’ = a,a = t - q * a;
t = b’,b’ = b,b = t - q * b;

现在我们来论证下算法的正确性,关于d为啥是最大公约数我们就不再赘述,因为它是普通的欧几里得算法已经明晰的东西了。
在这个算法中,除了要证明am + bn = d,还有一个辅助证明的等式,或者说是顺带证明的东西,a’m + b’n = c
初始化完成,也就是步骤1完成时,需要证明的两个等式显然成立。
如果r等于0,算法结束,不影响等式。
如果说else分支的一堆赋值操作,如果还是成立的,没有破环等式,那么,我们就证得两个等式成立。
先看这三个赋值语句:
c = d
a’ = a
b’ = b
a’m + b’n = c这个等式就被替换成了am + bn = d,左右两边显然是相等的。
那么第一个等式am + bn = d是否依旧成立呢?
d = r
a = a’ - q * a
b = b’ - q * b
步骤2隐含了一个等式c = qd + r,有了这个,推第一个等式就没有任何问题了
am + bn
= (a’ - qa)m + (b’ - qb)n
= (a’m + b’n) - q(am + bn)
= c - qd
= r
= d
也就是说,经过一系列的变换,两个等式依然成立。
总结下,初始时待证明的两个公式成立,循环不变,最终算法结束时,两个公式依旧成立。
证毕。

下面是一小段程序实现扩展欧几里得算法,写的有点啰嗦,但是是算法描述的直接翻译,很直观:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static long GetGcd(long m, long n, ref long a, ref long b)
{
a = 0;
b = 1;
long ap = 1; // a'
long bp = 0; // b'
long q = m / n;
long r = m % n;
while (r > 0)
{
m = n;
n = r;
long t = ap;
ap = a;
a = t - q * a;
t = bp;
bp = b;
b = t - q * b;
q = m / n;
r = m % n;
}

return n;
}

内容简介:定义Stack头文件,实现Stack;引出为什么会内存泄漏;修复这个问题。

首先,我们定义头文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Stack {
void *elements;
int eleSize;
int logicalLength;
int allocLength;
};

void StackNew(Stack *s, int eleSize);

void StackDispose(Stack *s);

void StackPush(Stack *s, void *eleAddr);

void StackPop(Stack *s, void *eleAddr);

我们需要用void*来保存内容;由于void*不包含其他信息,所以需要eleSize来指定要进出栈的对象的大小;内部两个量,用于记录有多少个元素在栈里面,分配了多少空间来存放这些对象。
接下来定义的几个函数很容易理解,新初始化一个栈,销毁处理栈,进栈、出栈操作,eleAddr有着不同的含义,进栈操作指的是待进栈对象的地址,出栈操作指的是会把待出栈对象拷贝到的地址。

阅读全文 »

在C语言中,没有提供任何泛型能力,但是,有神奇void*,只要运用恰当,能写出通用的『泛型』函数。在这里,『泛型』打了引号,表示并非真的是C#这种支持泛型语言中的含义,而是表示一种通用的意思。

关于这个主题,打算写上下两篇。上篇有关函数本身,写两个函数swap和lsearch来说明如何利用void*来写出通用的程序。下篇写一个通用的Stack来说明在C语言里面也能写出通用的数据结构。

首先来看第一个例子,swap函数不难写,很多人在刚开始学习C语言时候就写过。
比如想要交换两个int,可以写一个函数,函数声明大致如下:

1
void swap(int*, int*)

现在你又想交换两个double,交换两个自定义的结构体,怎么办呢?再写两个类似的函数,显然是不合适的。

阅读全文 »

题目链接

通常的Nim游戏的定义是这样的:有若干堆石子,每堆石子的数量都是有限的,合法的移动是“选择一堆石子并拿走若干颗(不能不拿)”,如果轮到某个人时所有的石子堆都已经被拿空了,则判负(因为他此刻没有任何合法的移动)。

假设有三堆石子,每堆有n1 n2 n3个,有一个函数,X(n1, n2, n3)。如果返回0,说明双方都在最佳移动的情况下,当前手的人必输。否则,返回非零值。

比如X(1, 2, 3),对于当前手就返回0,不过你怎么拿,对手都能使得其中两堆个数一样,之后他模仿你的操作使得两堆个数一样成立直到0,然后轮到他拿走剩余的一堆,你输了。

题目要求 n ≤ 2 ^ 30时,求X(n,2n,3n) = 0的个数。

n为什么的时候,X(n,2n,3n) = 0成立呢?

我们定义定义P-position和N-position,其中P代表Previous,N代表Next。直观的说,上一次move的人有必胜策略的局面是P-position,也就是“后手可保证必胜”或者“先手必败”,现在轮到move的人有必胜策略的局面是N-position,也就是“先手可保证必胜”。更严谨的定义是:

  1. 无法进行任何移动的局面(也就是terminal position)是P-position;
  2. 可以移动到P-position的局面是N-position;
  3. 所有移动都导致N-position的局面是P-position。

(Bouton’s Theorem)对于一个Nim游戏的局面(a1,a2,…,an),它是P-position当且仅当a1^a2^…^an=0,其中^表示异或(xor)运算。怎么样,是不是很神奇?我看到它的时候也觉得很神奇,完全没有道理的和异或运算扯上了关系。但这个定理的证明却也不复杂,基本上就是按照两种position的证明来的。

根据定义,证明一种判断position的性质的方法的正确性,只需证明三个命题:

  1. 这个判断将所有terminal position判为P-position;
  2. 根据这个判断被判为N-position的局面一定可以移动到某个P-position;
  3. 根据这个判断被判为P-position的局面无法移动到某个P-position。

第一个命题显然,terminal position只有一个,就是全0,异或仍然是0。
第二个命题,对于某个局面(a1,a2,…,an),若a1^a2^…^an<>0,一定存在某个合法的移动,将ai改变成ai’后满足a1^a2^…^ai’^…^an=0。不妨设a1^a2^…^an=k,则一定存在某个ai,它的二进制表示在k的最高位上是1(否则k的最高位那个1是怎么得到的)。这时ai^k<ai一定成立。则我们可以将ai改变成ai’=ai^k,此时a1^a2^…^ai’^…^an=a1^a2^…^an^k=0。
第三个命题,对于某个局面(a1,a2,…,an),若a1^a2^…^an=0,一定不存在某个合法的移动,将ai改变成ai’后满足a1^a2^…^ai’^…^an=0。因为异或运算满足消去率,由a1^a2^…^an=a1^a2^…^ai’^…^an可以得到ai=ai’。所以将ai改变成ai’不是一个合法的移动。证毕。

有了以上的定理,使得X(n,2n,3n) = 0,就是要求n ^ 2n ^ 3n = 0
代码很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static int GetAnswer()
{
int count = 0;
long max = 1 << 30;
for (long n = 1; n <= max; n++)
{
long n1 = n;
long n2 = n << 1;
long n3 = n1 + n2;
if ((n1 ^ n2 ^ n3) == 0)
{
count++;
}
}

return count;
}

值得说的是,求n2的时候用移位操作会比较快,不过好的编译器可能会*2做优化,不管怎样,自己显式的写出来比较好;求n3的时候用加法会比直接*3快,这一步用乘法的话比用加法慢20%+,加法7s多一点计算完成,而用乘法需要9秒。

申明一个结构体:

1
2
3
4
struct fraction{
int denom;
int num;
};

下面是一些对fraction结构体的操作:

1
2
3
4
5
6
7
fraction f;
f.denom = 22;
f.num = 7;
((fraction*)&(f.num))->denom = 12;
std::cout << f.num << std::endl;
((fraction*)&(f.num))->num = 33;
std::cout << (&f)[1].denom << std::endl;

下面逐行解释下上面的程序。
首先我们声明了类型是fraction的变量f,在栈上分配了两个4字节去存储,低位是denom,高位是num:
| mem |
| — |
| num |
| denom |
接下来是两个赋值语句,内存变成了:
| mem |
| — |
| 7 |
| 22 |
接下来是个复杂的赋值语句,&(f.num)拿到了num所在内存的地址,(fraction*)告诉编译器这个地址是上放的是fraction结构体,->denom是修改这个虚拟结构体的denom字段。好了,内存变成了:
| mem |
| — |
| 12 |
| 22 |
所以紧接着那句输出是12
接下来又是个类似的复杂赋值语句,不用详细解释了,内存变成了:
| mem |
| — |
| 33 |
| 12 |
| 22 |
使用原来的f能不能访问到33这个值呢?能,方法就是最后一个语句。(&f)取到f的地址,(&f)[1],编译器会向后偏移f指向的对象的大小,fraction大小是八字节,所以这句就是往后偏移八个字节,于是乎指向33所在的内存。最后,拿出denom的值。

数组在内存中的布局比较简单,很直接。需要注意的是C、C++并不检查数据越界,访问外面的数据会破坏上下文的数据或者访问不能访问的内存,很危险。
总结下数组和指针操作的等价性:

1
2
3
4
arr ≡ &arr[0]
arr + k ≡ &arr[k]
*arr ≡ arr[0]
*(arr + k) ≡ *arr[k]

其中,k可以为负数。
下面看个示例:

1
2
3
4
int arr[5];
((short*)(((char*)(&arr[1])) + 8))[2] = 100;
((short*)(((char*)(&arr[1])) + 8))[3] = 1;
std::cout << arr[4] << std::endl;

申明了五个int的数组,&arr[1]拿到第二个元素的地址,(char*)让编译器认为当前指针是char*类型,+8操作就是往后偏移8个字节,这时,指针指向了arr[3]。(short*)让编译器认为当前指针是short*类型,[2]和+2是等价的,移动2*2个字节,现在指针指向的是arr[4],但是编译器会认为这里是个short并赋值为100。所以arr[4]的内存就是0x64 0x00 0x00 0x00(小端)
下面一句类似,[3]就是移动2*3个字节,指向了arr[4]的后半段,赋值1,内存就是0x64 0x00 0x01 0x00
输出语句就是把arr[4]里面所有的内容以int的方式输出1 * 2 ^ 16 + 100 = 65636

最后,我们看一个综合的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct student{
char* name;
char suid[8];
int numUnits;
};

student pupils[2];
pupils[0].numUnits = 21;
pupils[1].name = pupils[0].suid + 6;
strcpy(pupils[0].suid, "40415xx");
std::cout << pupils[0].suid << std::endl;
strcpy(pupils[1].name, "123456");
std::cout << pupils[0].suid << std::endl;
std::cout << std::hex << pupils[0].numUnits << std::endl;

内存布局类似下面的形式(表格并没有完全对齐。。。):
| mem | 4 bytes | per | row |
| — |-|-|-|
| numUnits |
| s | s | u | u |
| i | i | d | d |
| name |
| numUnits |
| s | s | u | u |
| i | i | d | d |
| name |
下面的是[0]上面的是[1]
经过第一个输出之前的三个赋值语句后:
| mem | 4 bytes | per | row |
| — |-|-|-|
| numUnits |
| s | s | u | u |
| i | i | d | d |
| name |
| 21 |
| 5 | x | x | \0 |
| 4 | 0 | 4 | 1 |
| name |
pupils[1].name指向的是倒数第三行第二个x所在的内存
第一个输出语句很简单,40415xx,遇到\0就结束了
strcpy(pupils[1].name, “123456”);
这个语句的操作结果是:
| mem | 4 bytes | per | row |
| — |-|-|-|
| numUnits |
| s | s | u | u |
| \0 | i | d | d |
| 3 | 4 | 5 | 6 |
| 5 | x | 1 | 2 |
| 4 | 0 | 4 | 1 |
| name |
这时,输出的pupils[0].suid是40415x123456,遇到pupils[1]的\0才结束。
pupils[0].numUnits也已经被修改了,16进制输出是36353433,数字6的ASCII码对应的就是0x36,其他类似,为什么是反着的呢?我的机器是小端的。