这一章在干嘛?

前面几章我们都在「一条语句一条语句」地写代码,这一章教你怎么让计算机重复干活:for 管「重复几次早就知道」,while 管「重复到条件不成立为止」。但对 MATLAB 来说,更重要的反而是别用循环——能用矩阵运算和逻辑索引一次算完的事,就不要一个元素一个元素地搬。这种「整体运算」的写法叫向量化(vectorization),是 MATLAB 与 C 语言思维方式差异最大的地方,也是本章的核心。

5.1 for 循环

5.2 嵌套 for 循环

5.3 while 循环

5.4 循环 vs 向量化:MATLAB 的正确姿势

5.5 计时:用数据说话

5.1 for 循环

5.1.1 基本形式

写惯 C 的人对 for 的理解是「初始化; 条件; 自增」三段式。MATLAB 的 for 长得不一样,它更像 C 的 for(i = 1; i <= 5; i++) 与「遍历数组」的结合体:

for loopvar = range
    action        % 循环体,被重复执行的语句
end

range 是一个向量,循环变量 loopvar 依次取这个向量里的每一个值——取完最后一个,循环结束。最常用的写法是用冒号运算符生成等差数列:

>> for i = 1:5
       fprintf('%d\n', i)
   end
1
2
3
4
5

执行过程:i 先取 1,执行循环体;再取 2,再执行……直到取完 5。循环结束后 i 的值保留为最后一次的取值(5),这点和 C 一样。

当然,同样的输出一行向量化代码就够了——disp((1:5)')。先别急,5.4 节专门讲这个。

几个 C 程序员容易踩的点:

  • 步长写在中间1:0.5:3 表示从 1 到 3、步长 0.5,等价于 C 的 for(x=1; x<=3; x+=0.5)。步长可以是负数,如 10:-1:1 是倒着数。
  • 循环变量可以用任意向量,不必是等差数列:for i = [3 5 2 6] 也合法,i 依次取 3、5、2、6。
  • 别随便用 ij:在 C 里 i 是天经地义的循环变量名,但 MATLAB 里 ij 是内置的虚数单位()。复数运算(信号处理、电气工程里到处都是)中用它当循环变量会把它覆盖掉。循环内部不用复数时问题不大,但养成用 ivrowcol 这类名字的习惯更安全。
  • 循环变量只读。你可以在循环体里给 i 赋值,但下一轮迭代它照样被覆盖成 range 里的下一个值——和 Java 的 for-each 类似,和 C 的 for 不同。

5.1.2 不用循环变量的循环

有时候循环变量只是「计数器」,循环体根本不用它的值:

for i = 1:3
    fprintf('I will not chew gum\n')
end

这和 C 里 for(i=0;i<3;i++)i 没被用到是一个意思:循环变量只负责「重复 3 次」。

5.1.3 累加与累乘:必须先初始化

for 循环最经典的应用是求和与求积。算法和 C 完全一样——累加器(running sum)从 0 开始,累乘器(running product)从 1 开始

% sumnnums.m:求用户输入的 n 个数之和
n = randi([3 10]);       % 生成 3~10 之间的随机整数
runsum = 0;              % 累加器清零(关键!)
for i = 1:n
    inputnum = input('Enter a number: ');
    runsum = runsum + inputnum;   % 老和 + 新数 = 新和
end
fprintf('The sum is %.2f\n', runsum)

运行效果:

>> sumnnums
Enter a number: 4
Enter a number: 3.2
Enter a number: 1.1
The sum is 8.30

常见坑:忘记初始化累加器

忘了写 runsum = 0 就直接 runsum = runsum + inputnum,MATLAB 会报「变量未定义」;如果工作区恰好有个同名的旧变量,更糟——程序不报错,但结果是错的。求和用 0,求乘积用 1(乘 0 全完),这个顺序千万别反。

5.1.4 预分配向量:一个重要的性能习惯

如果要把循环中产生的数存进向量,有两种写法。先看「顺手就写」的版本——从空向量开始不断追加:

numvec = [];                 % 空向量
for iv = 1:n
    numvec(iv) = inputnum;   % 每次追加一个元素
end

这在语法上没问题,但性能很差。MATLAB 的向量存在内存里,每次追加长度都要变,MATLAB 只能另找一块更大的内存,把旧数据整个拷过去再接着写——相当于 C 里每次 realloc 都触发搬家的最坏情况。数据量一大,时间全浪费在搬运上。

正确做法是预分配(preallocation):既然事先知道要存 n 个数,就先用 zeros 把向量开好,再逐个往里填:

n = randi([4 8]);
numvec = zeros(1, n);        % 预分配 1×n 的向量
for iv = 1:n
    numvec(iv) = input('Enter a number: ');
end
disp(numvec)

注意 iv 在这里身兼二职:既是循环变量,又是向量的下标。这是「编程式写法」的标配模式。5.5 节会用 tic/toc 实测两种写法的速度差。

什么时候才需要把数据存进向量?

如果只要「求和/求平均」,边读边累加就够了,不必存;但如果之后还要再访问这些数(比如先求平均、再统计有多少个数大于平均),就必须存进向量。先想清楚数据会不会被二次使用,再决定存不存。

5.1.5 循环 + subplot:批量画图

subplot(r, c, n) 把当前图形窗口切成 的网格,并把第 n 块设为「当前绘图区」,编号按行优先从左上角 1 开始——和矩阵下标的遍历顺序一致。它自己不画任何东西,只是指定「接下来画在哪儿」。

subplot 放进 for 循环,前两个参数固定,第三个参数用循环变量驱动,就能批量产出对比图。下面的脚本对比用 20 个点和 40 个点画 的效果:

% subplotex.m:用 for 循环生成 1×2 的子图
for i = 1:2
    x = linspace(0, 2*pi, 20*i);   % i=1 时 20 个点,i=2 时 40 个点
    y = sin(x);
    subplot(1, 2, i)               % 激活第 i 块子图
    plot(x, y, 'ko')
    xlabel('x'); ylabel('sin(x)')
    title('sin plot')
end

子图对比(左半):只用 20 个采样点时,sin 曲线的波峰明显缺角、失真严重

子图对比(右半):加密到 40 个采样点后,曲线明显更平滑、更接近真实 sin 形状

这两张图合起来正是教材的图 5.1:同一个窗口里左右并排,直观展示采样点数对绘图质量的影响。点太少,曲线会出现明显折角;点够密,形状才可信。用循环让「只改一个参数、重复同一套画图流程」变得非常省事。

5.2 嵌套 for 循环

循环体里可以再放循环,这就是嵌套循环(nested loop):外层的叫外循环(outer loop),里层的叫内循环(inner loop)。C 语言里二维数组靠双层 for 遍历,MATLAB 的矩阵同理——只是再次提醒:矩阵操作多数情况下根本不用手写双层循环(见 5.4)。

5.2.1 打印星号框:外层管行,内层管列

% printstars.m:打印 rows×columns 的星号方框
rows = 3; columns = 5;
for i = 1:rows              % 外循环:一行一行来
    for j = 1:columns       % 内循环:打完一行里的星星
        fprintf('*')
    end
    fprintf('\n')           % 一行结束才换行
end
>> printstars
*****
*****
*****

执行顺序:外循环变量 i 固定为 1 期间,内循环 j 从 1 跑到 5,打出第一行的 5 颗星,然后换行;i 变 2,再打一行……总共执行 rows 轮。换行的 fprintf('\n') 必须放在外循环里、内循环外——如果图省事写成 fprintf('*\n'),输出的就不是 3×5 的方框,而是一列 15 颗星。

想打成三角形?让内循环的上限跟着行号走即可:把 for j = 1:columns 改成 for j = 1:i,第几行就打几颗星。

5.2.2 乘法表:循环变量当下标

真正有用的场景是让循环变量充当矩阵的行、列下标。下面的函数生成乘法表矩阵:

function outmat = multtable(rows, columns)
% multtable 返回 rows×columns 的乘法表矩阵
outmat = zeros(rows, columns);      % 先预分配
for i = 1:rows
    for j = 1:columns
        outmat(i,j) = i * j;        % 行号×列号 填入对应元素
    end
end
end
>> multtable(3,5)
ans =
     1     2     3     4     5
     2     4     6     8    10
     3     6     9    12    15

执行顺序是「行优先」:i=1j 扫完第 1 行的所有列,然后 i=2 扫第 2 行……这正是矩阵在内存中的存放顺序。

5.2.3 嵌套循环 + if:跳过坏数据

嵌套循环里可以放任何语句,包括选择语句。下面的脚本读入实验数据矩阵,只把每行的正数加起来:

% sumonlypos.m:逐行求正数之和
load datavals.dat
[r, c] = size(datavals);
for row = 1:r
    runsum = 0;                          % 每行开始前必须清零!
    for col = 1:c
        if datavals(row, col) >= 0       % 只累加非负数
            runsum = runsum + datavals(row, col);
        end
    end
    fprintf('The sum for row %d is %d\n', row, runsum)
end

两个结构性的要点:

  • 需求决定外循环是谁。要「每行一个和」,外循环就必须遍历行;改成「每列一个和」,把两层循环对调即可(注意矩阵下标永远是 (行,列),与循环嵌套顺序无关)。
  • runsum = 0 必须放在外循环内、内循环外——每换一行都要重新清零。放错到外循环外,第二行的和会累到第一行上,这是初学者最常见的逻辑错误。

常见坑:内外循环顺序与初始化位置

记住两个口诀:「对谁输出,外层遍历谁」「谁的和/积,清零放在谁的循环层里」。写完先手动走一遍 i=1 的完整流程,检查初始化时机对不对。

5.3 while 循环

for 适合「重复几次早就知道」的场景;事先不知道要重复多少次时,用条件循环 while

while condition
    action
end

逻辑和 C 的 while 一模一样:先判断条件,为真就执行循环体,回头再判断,直到条件为假。所以循环体里必须有东西能让条件最终变成假,否则就是死循环(infinite loop)——命令窗口按 Ctrl-C 可以强制中断。

5.3.1 典型场景:不知道循环到哪儿为止

求 n! 用 for 很自然(n 已知);但「求第一个大于 5000 的阶乘」就没人知道 n 是几了,只能一边乘一边检查:

function facgt = factgthigh(high)
% factgthigh 返回第一个大于 high 的阶乘
i = 0;  fac = 1;
while fac <= high        % 还没超过就继续
    i = i + 1;
    fac = fac * i;       % fac 依次变成 1!, 2!, 3!, ...
end
facgt = fac;
end
>> factgthigh(5000)
ans =
        5040            % 7! = 5040,第一个超过 5000 的阶乘

i 从 0 起步是为了第一次进循环后 i=1fac=1!,和状态正好对齐。

常见坑:while 的循环体可能一次都不执行

while 是「先判断后执行」——如果条件第一次就是假,循环体直接跳过。上面求阶乘的例子若一开始 fac 就大于 high,什么都不会发生。设计算法时要考虑这种「零次执行」的情况是否符合预期。

条件可以写得更复杂,用逻辑运算符组合:while x >= 0 && x <= 100(两个都在范围内才继续)、while x < 50 || y < 100(任一满足就继续)。

5.3.2 哨兵值:读到标记为止

数据文件里常见这种格式:一串有效数据后面跟一个约定好的标记值,表示「数据到此为止」。这个标记叫哨兵(sentinel)。比如 experd.dat 内容为 3.1 11 5.2 8.9 -99 4.4 62,其中 -99 是分隔符,我们只想画 -99 之前的数据:

图 5.2:脚本只绘出哨兵值 -99 之前的 4 个有效数据点(横轴为读数次序,纵轴为重量),-99 之后的 4.4 和 62 被正确丢弃

「编程式」写法用一个 while 循环逐个拷贝元素,直到撞上哨兵:

% 编程式:while 循环 + 逐元素拷贝
load experd.dat
i = 1;
while experd(i) ~= -99
    newvec(i) = experd(i);   % 边拷贝边扩展向量(低效!)
    i = i + 1;
end
plot(newvec, 'ko')

「高效式」写法根本不循环——用 find 直接定位哨兵的下标,再用冒号切片一次截取:

% 高效式:find + 切片,一行顶一个循环
load experd.dat
where = find(experd == -99);   % 哨兵的位置
newvec = experd(1:where-1);    % 截取哨兵之前的所有元素
plot(newvec, 'ko')

这个例子是本章精神的缩影:循环能干的事,向量化往往一句就干完了,而且更快、更不易错

5.3.3 while 循环读用户输入与错误校验

while 最实用的场景之一是输入校验(error-checking):用户输错了不报错退出,而是反复提示重新输入,直到输对为止:

% 循环直到用户输入一个正数
inputnum = input('Enter a positive number: ');
while inputnum < 0
    inputnum = input('Invalid! Enter a positive number: ');
end
fprintf('Thanks, you entered a %.1f\n', inputnum)
>> readonenum
Enter a positive number: -5
Invalid! Enter a positive number: 44
Thanks, you entered a 44.0

结构是「先读一次 → 条件不合法就再读」:input 出现了两次,循环前一次、循环体内一次,保证每次判断条件时都有一个新值可查。

想校验「必须是整数」,可以利用 MATLAB 默认一切数值都是 double 的特点:把输入转成 int32 会四舍五入,若转完还和原值相等,说明它本来就是整数:

>> num = 3.3;
>> num == int32(num)
ans =
  logical
   0              % 3 ≠ 3.3,说明不是整数

把「非负」和「是整数」两个条件用 || 串起来,就能校验「正整数」:

inputnum = input('Enter a positive integer: ');
num2 = int32(inputnum);
while num2 ~= inputnum || num2 < 0
    inputnum = input('Invalid! Enter a positive integer: ');
    num2 = int32(inputnum);
end

如果还想统计用户到底输对了多少次,加一个计数器变量:循环前 counter = 0,循环体里 counter = counter + 1——和 C 一样,但同样别忘了初始化。

事先不知道长度怎么办?

校验输入时若想把合法值存进向量,长度无法预知、没法预分配,只能边存边扩展:numvec = [numvec inputnum](拼接)或 i = i+1; numvec(i) = inputnum(递增下标)。能用但低效;若能估计出最大长度,可以先按上限预分配,结束后用 vec = vec(1:count) 裁掉多余部分。

5.4 循环 vs 向量化:MATLAB 的正确姿势

终于到了本章最重要的一节。先建立一个对比模板——对向量 vec 的每个元素做同样操作,C 程序员的第一反应是:

% 「C 语言式」编程思维:逐个元素处理
for i = 1:length(vec)
    % 对 vec(i) 做点什么
end

对矩阵则是 [r,c] = size(mat) 后的双层循环。这在 C 里是天经地义,在 MATLAB 里多数时候是多余的——因为 MATLAB 的运算符和函数天生就是「整队操作」的:vec + 1 给每个元素加 1,sqrt(mat) 对每个元素开方。用内置函数和运算符代替遍历循环的写法,就叫向量化(vectorized code)。

5.4.1 标量乘法:一行顶五行

把向量 v = [3 7 2 1] 的每个元素乘 3:

% 编程式:循环
>> for i = 1:length(v)
       v(i) = v(i) * 3;
   end
 
% 高效式:一句
>> v = v * 3

再比如阶乘 。循环版要先初始化累乘器再逐个乘:

function runprod = myfact(n)     % 编程式
runprod = 1;
for i = 1:n
    runprod = runprod * i;
end
end

向量化版直接对向量 求积,MATLAB 甚至自带现成函数:

>> factorial(5)     % 内置函数
ans =
   120
>> prod(1:5)        % 对 1~5 求积,一样的结果
ans =
   120

累积和(running sum)也一样:自己写循环模拟 cumsum 要十几行,而内置函数一行:

>> cumsum([5 9 4])
ans =
     5    14    18       % 依次是 5、5+9、5+9+4

5.4.2 逻辑索引:把「循环 + if」压缩成一个表达式

这是向量化最精彩的部分。回想 5.2.3 节「只累加正数」的嵌套循环——在 MATLAB 里,判断每个元素是否大于某值,一个关系表达式就生成了一个逻辑向量(logical vector)

% 编程式:循环 + if,逐个判断
function outvec = testvecgtn(vec, n)
outvec = false(size(vec));       % 预分配全 false
for i = 1:length(vec)
    if vec(i) > n
        outvec(i) = true;
    end
end
end
 
% 高效式:一个关系表达式
function outvec = testvecgtnii(vec, n)
outvec = vec > n;                % 就这一句
end
>> ov = testvecgtnii([44 2 11 -3 5 8], 6)
ov =
  1×6 logical 数组
   1   0   1   0   0   1        % 44、11、8 大于 6

逻辑向量真正的威力在于可以当下标用vec(vec > n) 直接取出所有大于 n 的元素,sum(vec > n) 直接统计个数(逻辑值求和时 true 算 1)。同理,一个手写双层循环 + 三分支 if 模仿的「符号函数」,就是内置的 sign;手写找最小值的循环,就是内置的 min;数非零元素的循环,就是 any/all/nnz动手写「遍历 + 判断」的循环前,先想想是不是已有内置函数或一个逻辑表达式能干这事

你想干的事C 式循环写法MATLAB 向量化写法
每个元素乘 3for 循环逐个乘v = v * 3
求和 / 求积累加器/累乘器循环sum(v) / prod(v)
累积和 / 累积积循环 + 输出向量cumsum(v) / cumprod(v)
找最大 / 最小工作最大值 + 循环比较max(v) / min(v)
统计大于 n 的个数循环 + if + 计数器sum(v > n)
取出所有正数循环 + if + 追加v(v > 0)
判断是否全为非零循环 + 标志变量all(v ~= 0) / any(v == 0)

什么时候循环反而更清晰?

向量化不是教条。三种情况老老实实写循环:(1) 各次迭代之间有依赖(后一步要用前一步的结果且无法用 cumsum 类函数表达);(2) 操作的是「流程」而非「数据」,比如反复弹输入框、逐轮游戏逻辑;(3) 数据结构不是规整的数值矩阵。另外,for i = 1:length(vec) 这种遍历模板在处理文件列表、单元格数组时依然常用。原则是:先想有没有向量化写法,没有或可读性差时再用循环

5.4.3 高效代码四要素

教材总结的写高效 MATLAB 代码的要点:

  1. 整体(数组)运算代替逐元素循环;
  2. 逻辑向量代替「循环 + if」;
  3. 内置函数优先——sumcumsumminmaxanyallfind 以及各类 is* 函数;
  4. 预分配向量/矩阵(5.1.4 节)。

几乎所有情况下,程序员写得快的代码,MATLAB 跑得也快——省行数和省时间往往是一回事。拿不准时,用 checkcode('文件名') 做静态检查,它会明确提示「变量 appears to change size on every loop iteration, consider preallocating for speed」这类问题。

5.5 计时:用数据说话

「预分配到底快多少?向量化到底值不值?」不要背结论,实测。tic 启动秒表,toc 报告从 tic 到现在经过的秒数:

% fortictoc.m:2000 万次累加的耗时
tic
mysum = 0;
for i = 1:20000000
    mysum = mysum + i;
end
toc
>> fortictoc
Elapsed time is 0.087294 seconds.

再对比预分配与不预分配:

disp('No preallocation')
tic
for i = 1:10000
    x(i) = sqrt(i);      % 边循环边扩展向量
end
toc
 
disp('Preallocation')
tic
y = zeros(1, 10000);     % 先开好
for i = 1:10000
    y(i) = sqrt(i);
end
toc

在我的机器上不预分配版本耗时约是预分配版本的十几倍,规模越大差距越大。(更进一步的向量化写法 y = sqrt(1:10000) 还能再快一个量级。)要更详细的性能报告,可用性能分析器(Profiler):编辑器里点「Run and Time」,或命令行 profile on → 跑代码 → profile viewer,能看到每一行、每个函数各花了多久。

计时的注意事项

计时结果受后台进程影响(开着浏览器、杀毒扫描都会干扰)。比较两种写法时,同机同负载多跑几次取大致水平才有意义。

本章通关标准:

  1. 能不假思索地写出「累加器清零 + for 循环 + 预分配」的求和模板,并说清累加器为什么必须放在正确的循环层里初始化。
  2. 遇到「读到哨兵/输到合法为止」这类次数未知的问题,能条件反射地选 while,并记得循环体里必须有让条件变假的语句。
  3. 看到 for i = 1:length(v) ... if v(i) > n ... end end 这类代码,能立刻改写成 v > nv(v > n)sum(v > n) 的向量化形式,并能用 tic/toc 证明谁快。