写代码这些年,我发现自己花在“遍历字符串、数组还要顺手取下标”上的时间,远比想象中多。无论是解析一段JSON、处理一份日志,还是刷LeetCode时判断两个字符串是否同构,本质上都在干同一件事:搞清楚此刻游走到哪个位置,以及这个位置的元素是什么。很多人觉得遍历嘛,for循环套进去就完事了,但真到了要“取下标”的环节,各种问题就浮出水面——要么下标越界,要么删元素时索引错乱,要么中文字符串的长度和下标关系搞不清楚。这篇博文就把“遍历取字符串/数组下标”这件事彻底讲透,包括各语言的主流写法、常见翻车现场、以及工程里的真实取舍。
1. 为什么“取下标”会成为一个独立问题
先说个最容易忽略的事实:遍历的本质分两种,一种是只关心值,一种是要同时关心位置。
比如要判断一个数组里存不存在某个数字,你只需要值就够了,下标只是个配角。但如果你要“把字符串里第一个数字字符替换成*”、要“找出数组中连续递增子序列的起点”、要“把二维数组按行优先顺序输出编号”,下标就成了主角。没有位置信息,后面的操作全都无从谈起。
1.1 下标到底是什么,为什么总有人弄错
下标(index)就是元素在容器中的偏移量。字符串本质是字符的数组,所以字符串和数组在下标这件事上共享同一套逻辑:从0开始,最大到len-1。
最容易出错的点就两个:
- 从1开始数。人在自然语言里数东西习惯从1,但程序的下标永远从0起步。于是“取第3个元素”经常被写成
arr[3],实际取到的是第4个。这个错误在面试手写代码时出现率极高。 - 越界看成空值。很多动态语言(Python、JavaScript)越界访问数组不报错,返回undefined,导致后续逻辑在一堆奇怪的值上继续跑。C语言数组越界更危险,它可能读到相邻内存里的随机数据,而且往往不立刻崩溃,等释放内存时才爆雷。
1.2 遍历时“需要下标”的典型场景拆解
凭我实际写代码的经验,需要下标才能完成的场景大概能分成这几类:
| 场景 | 为什么必须要下标 | 典型需求 |
|---|---|---|
| 位置关联 | 元素值和它在容器中的位置强相关 | 找出第一个重复字符、第k大的数、回文判断里的镜像位 |
| 位移运算 | 要对相邻元素或前后元素做比较 | 冒泡排序、滑动窗口、区间合并 |
| 删除与替换 | 修改容器时需要精确定位操作目标 | 遍历删除特定元素、原地去重 |
| 双指针/首尾指针 | 多个游标同时移动,必须有独立计数 | 有序数组两数之和、反转字符串 |
| 分组/分页 | 按位置切分数据块 | 每3个一组、分页取数据前半段 |
这些场景的共同点是:光知道元素本身还不够,你还得回答“这个元素排在第几位”。
2. 各主流语言遍历取下标的正确姿势
不同语言在这个问题上的设计差异,挺能体现语言哲学的。下面按我日常写过的语言逐一拆解,并给一份可以直接抄的对照表。
2.1 Python:优雅与陷阱并存
Python里遍历数组最舒服的写法是for item in arr,但它拿不到下标。要下标,标准答案是enumerate:
# 同时拿下标和值,最推荐 for idx, ch in enumerate(s): print(idx, ch) # 只拿下标,不关心值,用 range(len) for idx in range(len(arr)): print(idx, arr[idx])enumerate还有一个容易忽略的参数start:
# 从1开始编号,适合做序号输出 for idx, name in enumerate(student_list, start=1): print(f"{idx}. {name}")坑在哪?Python的字符串是不可变对象,你没法通过下标直接修改某个字符,必须先转成列表。这个“不能改”的特性让很多从C转过来的开发者不适应:
s = "hello" # s[0] = 'H' # TypeError: 'str' object does not support item assignment chars = list(s) chars[0] = 'H' s_new = ''.join(chars)另外一提,reversed和切片[::-1]虽好,但如果你在反转时需要同时拿原下标,就得老老实实记录位置信息。常用技巧是倒序遍历:
# 倒序同时搞删除,规避正向删除时下标位移问题 for idx in range(len(lst) - 1, -1, -1): if lst[idx] % 2 == 0: lst.pop(idx)2.2 JavaScript/TypeScript:三个循环三种脾气
JavaScript提供了三种遍历思路,新手上路时经常混着用,而它们的行为差异还是挺大的。
// 传统 for 循环:最可控,下标随手取 const arr = ['a', 'b', 'c']; for (let i = 0; i < arr.length; i++) { console.log(i, arr[i]); } // forEach:能拿到下标,但无法 break / return arr.forEach((item, index) => { console.log(index, item); }); // for...of:拿值方便,拿下标要靠 entries() for (const [index, item] of arr.entries()) { console.log(index, item); }这里想提醒一个老坑:for...in是用来遍历对象键名的,虽然它也能作用于数组,但拿到的“下标”是字符串,而且会包含数组上自定义的扩展属性,现实中不建议用它遍历数组。
字符串也是同理:String.prototype没有直接的entries()方法,但可以先用split('')把字符串变成字符数组,或者直接用for...of遍历字符。要注意的是,for...of遍历字符串时按Unicode码点迭代,遇到emoji这类代理对字符能正确处理,这算是它的一大优势。
const s = 'hello'; for (const [i, ch] of s.split('')) { console.log(i, ch); }2.3 Java:增强for循环拿不到下标
Java的for (String s : list)写起来很爽,但你拿不到当前元素的下标。如果想同时拿到下标和值,传统做法是:
String[] arr = {"a", "b", "c"}; for (int i = 0; i < arr.length; i++) { System.out.println(i + ": " + arr[i]); }对List<Integer>这类集合,用List的indexOf去反查下标是新手常干的事,但元素重复时indexOf永远只返回第一个匹配的位置,很容易翻车。
更推荐Java 8+ 的IntStream方式来按索引流式处理:
IntStream.range(0, list.size()) .forEach(i -> System.out.println(i + ": " + list.get(i)));这样既保留了流式风格,又拿到了下标。或者借助AtomicInteger作为计数器(不太优雅,但常见于旧代码):
AtomicInteger idx = new AtomicInteger(0); list.forEach(item -> { int current = idx.getAndIncrement(); System.out.println(current + ": " + item); });2.4 C/C++:指针与下标的暧昧关系
C语言里数组下标本质就是指针偏移:arr[i]等价于*(arr + i)。所以C语言中遍历取下标最自然的方式也是for (int i = 0; ...)。但实际干活时会发现,其实还可以“用指针移动代替下标”,二者各有适用场合。
#include <stdio.h> #include <string.h> int main() { char *s = "hello"; // 方式一:下标 for (int i = 0; i < strlen(s); i++) { printf("%d: %c\n", i, s[i]); } // 方式二:指针 int pos = 0; for (char *p = s; *p != '\0'; p++) { printf("%d: %c\n", pos++, *p); } return 0; }这里有几个容易翻车的细节:
strlen每次循环都算一遍,没有别的问题,但字符串一长性能会受影响。习惯做法是循环前先用size_t len = strlen(s);存起来。sizeof(s)和strlen(s)不是一回事。在函数内部拿到的是指针变量的大小(8字节),不是字符串长度。这个我见过的bug不下十次。- C字符串没有长度字段,只能靠结尾的
'\0'判定停止。所以如果你拿到的字符数组没有正确处理终止符,遍历就会越界。
C++里std::string和std::vector的下标用法更安全,但同样的坑还在:字符串下标操作返回的是字符引用,但std::string在C++11之后保证内存连续,因此&s[0]可以当字符指针用——利用这个特性可以把C++ string直接传给C接口。
2.5 Go:range关键字直接给下标或键
Go在这个问题上设计得很干脆:for i, v := range arr一步到位,i就是下标。而且当你只需要下标、不需要值时,可以写for i := range arr,这在很多C系语言里做不到这么简洁。
arr := []string{"a", "b", "c"} for idx, val := range arr { fmt.Println(idx, val) } // 只要下标 for idx := range arr { fmt.Println(idx) } // 遍历字符串:得到的是字节位置和Unicode码点 s := "hello" for idx, r := range s { fmt.Printf("byte=%d, rune=%c\n", idx, r) }Go中范围遍历字符串时,第一个值是该字符的起始字节位置,不是字符序号,这对处理中文影响很大。比如“你好”中“好”的字节下标是3(一个中文字符占3字节),不是1。如果你不关心字节位置,用for i, r := range s时i会“跳着走”,首次接触时容易疑惑。
2.6 语言写法速查对照表
| 语言 | 同时取值+下标 | 只取下标 | 备注 |
|---|---|---|---|
| Python | for i, c in enumerate(s) | for i in range(len(arr)) | enumerate可直接设置起始值 |
| JavaScript | arr.entries()+ for...of | for (let i=0; i<arr.length; i++) | for...in请用于对象 |
| Java | IntStream.range(0, n) | for (int i=0; i<n; i++) | 增强for拿不到下标 |
| C | for (int i=0; i<len; i++) | 同左 | 指针等价:arr[i]==*(arr+i) |
| C++ | 同C,或用迭代器+std::distance | 同左 | std::string内存连续 |
| Go | for i, v := range arr | for i := range arr | 字符串range给字节位置 |
3. 下标相关的经典翻车现场
这一节写的全是真实发生过的事。每个坑我都踩过或者帮人排查过,具备很强的复现性。
3.1 Python“边遍历边删除”的索引错乱
这个坑在网络热词里独占一条——“python+list遍历删除”。它为什么会成为热词?因为错误写法太反直觉了:
lst = [1, 2, 3, 4, 5] for i in range(len(lst)): if lst[i] % 2 == 0: del lst[i]这段代码不会崩,但结果莫名其妙。拿lst = [1, 2, 3, 4, 5]跑一遍:i=1时删除2,列表变成[1, 3, 4, 5];i=2时拿到的是4(而不是期望的3),删除4后变成[1, 3, 5];i=3越界停止。最后结果[1, 3, 5]看着像对了,但如果原列表是[1, 2, 3, 4, 5, 6],结果会变成[1, 3, 5],偶数6被跳过了。因为每次删除都让右侧元素整体左移,后续下标全部错位。
可靠解法是倒序遍历:
for i in range(len(lst) - 1, -1, -1): if lst[i] % 2 == 0: lst.pop(i)倒序删除不会影响尚未访问的元素位置。另一个思路是改造成“保留符合条件的元素”,用列表推导式:
lst = [x for x in lst if x % 2 != 0]这个方案最省心,但如果你需要在删除时基于位置做额外逻辑,列表推导式就没那么灵活了。
3.2 字符串长度与下标:中文字符的认知偏差
字符串的问题比数组更容易踩坑,因为“长度”在不同语言里定义不同。
- Python:
len('你好')返回2,按Unicode字符计数;但字符串的字节长度是len('你好'.encode('utf-8')),返回6。按下标访问'你好'[0]得到'你',没问题。然而一旦做切片,切的也是字符位置不是字节位置,需要和网络传输(UTF-8字节)打交道时,下标就对不上了。 - Java:
String.length()按UTF-16码元计算,绝大多数常用汉字一个char搞定,但遇到生僻字或emoji会算成2个char,导致下标遍历出现半个字符的尴尬。Java 8+可用codePoints()遍历完整码点。 - C语言:
"你好"在代码里是字节串,strlen("你好")返回6。s[0]拿到的是字符'你'的第一个字节。你要是按1个下标取一个“字符”来处理,中文一定会被拆散乱码。 - Go:
len("你好")也是6字节,range按rune遍历时拿到的下标是字节偏移,不是字符序号。
实际项目中我遇到最多的情况,是把用户输入的字符串按utf-8字节位置去截断,导致最后一位乱码。这种问题的正确做法是:先用语言自带的方式把字符串拆成字符数组(或rune数组),再按数组下标操作。比如在Go中写[]rune(s),在Python中"字符本身就可直接取",在Java里先toCharArray()再遍历。
3.3 C语言指针移动与数组下标的混淆
C语言中数组下标和指针是可以互换的,但这个“方便”也带来了经典误解。
先看一个让许多人迷惑的句子:arr[i]和i[arr]在C语言里是等价的。因为下标运算符的定义就是*(arr + i),加法交换律使得i[arr]也成立。这个写法没人会真的用,但它能帮你理解下标底层就是指针偏移。
容易出错的是“指针移动”和“数组下标”同步问题:
char *p = buf; while (*p != '\0') { // 处理 buf[i] 时,总是拿不到 p -> buf 的偏移 }如果你用指针遍历的同时还要用下标做随机访问,最稳的方式是维护一个int idx = p - buf;,或者干脆“指针循环 + pos自增”双轨并行。我看到过有人把p直接当数组用:p[2]确实没问题,因为p本身是指针,但一旦p偏移过,p[2]就不再是buf[2]了。
这个问题的核心心法:下标应绑定“容器的原点”,指针的偏移量应绑定“当前游标位置”。不要把两者混用。
3.4 二维数组下标错位:谁先谁后
二维数组(矩阵)的遍历顺序,是下标问题的另一大重灾区。
int matrix[3][4]; // 3行4列 for (int i = 0; i < 3; i++) { for (int j = 0; j < 4; j++) { // matrix[i][j] 是第i行第j列 } }看起来简单,但一旦代码写得多了,很容易把i < 4和j < 3写反,而编译器不会报错。更隐蔽的坑是:遍历时行列使用的下标若内外层互换,访问的元素就变成了转置结果,数据对不上,但代码不崩溃。
处理二维下标问题,我的经验是:永远先写行,再写列;所有循环边界变量用rows和cols命名,别用m、n这种容易混淆的变量。例如:
rows, cols = len(matrix), len(matrix[0]) for r in range(rows): for c in range(cols): print(matrix[r][c])3.5 遍历删除后的“数组下标”与“集合”问题
C#等语言里“遍历时删除”同样有坑,List<T>在foreach里删除会直接抛InvalidOperationException,因为集合版式在迭代期间发生了修改。这点很多从JavaScript(forEach中splice不报错但会跳项)转过来的人尤其容易中招。
正确姿势是倒序for循环,或使用List.RemoveAll(Predicate)方法:
list.RemoveAll(x => x % 2 == 0);RemoveAll内部自己处理了遍历逻辑,你不需要关心下标位移。这给我们的通用启发是:优先使用容器自带的批量删除API,而不是断言你在循环里处理下标。因为API作者已经把元素位移问题处理好了。
4. 工程里真正用到“下标”的设计思路
刷题和写业务代码有个差别:刷题时下标是主角,而业务里下标往往只是个工具人。知道工具怎么用,还要知道什么时候不用它。
4.1 用“语义下标”代替“物理下标”
很多业务场景需要的不是物理下标,而是逻辑编号。比如分页展示中的序号、列表项的行号,这些未必等于数组下标。物理下标是0开始的,展示序号一般从1开始,所以业务侧常见的做法是:
for i, item in enumerate(items, start=1): print(f"{i}. {item}")这和刷题中“返回第k个元素”要搞清楚k是从0数还是从1数属于同类问题。我给自己定的规则是:接口边界处做一次转换,内部全部用物理下标;对外输出时再做+1映射。切忌一半代码用0基、一半用1基,混乱必出Bug。
4.2 用下标做Key时要三思
前端渲染列表时,经常用数组下标作为元素的key。这在拿数据后不做增删排序时没问题,但一旦列表支持删除、插入、排序,用下标做key就容易出现渲染错乱——React会根据key来复用DOM节点,下标变化会导致状态串位。
正确准则:
- 静态列表:下标key可用。
- 动态列表:优先用数据的唯一id;实在没有唯一id,再考虑“下标+内容哈希”之类的组合。
这背后其实又是一个“下标不稳定”的问题:下标只是容器在某个时间点的快照,它不是元素的身份。这个认知放之四海皆准。
4.3 遍历取下标在算法题里的高频模板
拿“判断两个字符串是否同构”这种经典题来说,核心就用到下标映射:
def is_isomorphic(s: str, t: str) -> bool: if len(s) != len(t): return False map_s = {} map_t = {} for i in range(len(s)): ch_s, ch_t = s[i], t[i] if ch_s in map_s: if map_s[ch_s] != ch_t: return False else: map_s[ch_s] = ch_t if ch_t in map_t: if map_t[ch_t] != ch_s: return False else: map_t[ch_t] = ch_s return True这里必须用下标同步遍历两个字符串。如果用zip当然可以,但当两个字符串不等长时,zip会悄悄截断,反而隐藏了bug。所以涉及“对齐比较”时,用下标同步更稳妥。
再比如“删除有序数组中的重复项”要求原地操作,也是典型的既要下标又要值:
def remove_duplicates(nums): if not nums: return 0 write_idx = 0 for read_idx in range(1, len(nums)): if nums[read_idx] != nums[write_idx]: write_idx += 1 nums[write_idx] = nums[read_idx] return write_idx + 1慢指针write_idx和快指针read_idx两个下标的配合,是这类问题的题眼。学这种题时,刻意练习“下标变量”的命名和步进逻辑,比背解法更有价值。
4.4 字符串截取与切片时,下标边界怎么定
切片操作里最容易出错的是“结束位置到底算不算”。Python中s[0:5]取前5个;Java中substring(0, 5)同样取前5个。但很多其他语言或自研框架里,substring(0, 5)可能是“包含5”,也可能“不包含5”。更糟糕的是有些框架的结束位置是“从0开始的索引”,且包含这个索引。
我处理这种问题有一个习惯:拿到API第一件事,看它官网的示例代码,而不是翻参数说明。参数说明里“inclusive/exclusive”最容易让人犯迷糊,示例一看就懂。
另外,字符串截断场景下还要考虑“如果结束位置大于字符串长度会怎样”。有的语言直接抛异常,有的则截到末尾。业务代码防御性写法建议统一做一次Math.min:
end = min(end, len(s)) s = s[:end]4.5 数组下标与循环变量关系的进阶思考
写遍历时,名义上是“数组下标”,实际它常常承担“循环计数”“时间段编号”“坐标位置”等多种角色。比如按二维数组输出棋盘时,i+j的奇偶可以决定格子颜色。这就要求你理解下标不只是访问元素的门牌号,它本身可能是计算素材。
处理这类问题时,我喜欢先把下标画在草稿纸上:i从0到n-1,j从0到m-1,然后把需要的数据关系标出来。下标问题的绝大部分Bug都能靠画图暴露。
5. 最后的实操心得
聊到这里,其实“遍历取下标”已经超越了语法层面,变成一套思维习惯。我自己的代码习惯是:先判断这个场景是否真的需要位置信息,不需要就用值遍历;需要时,优先用语言自带的下标获取方式(enumerate、range、IntStream等),不要自己造轮子。手动维护计数器能不用就不用,因为计数器变量最容易在循环分支里被漏更新。
最后分享一个我写遍历时的小技巧:当下标涉及“目标位置”和“当前位置”时,给变量起名别用i、j了事,用left、right、writePos、readPos这类带语义的名字。排查下标越界时,这种命名能帮你省下一个小时。另一个小习惯是,每个循环入口先打印一次i和len(arr),肉眼确认边界条件正确再进入循环体。这做法看着笨,但排查越界准确率极高,尤其用C和Go这些运行时检查偏弱的语言时,早检查早安心。