C#数组操作实战:从求和到滑动窗口的22个经典练习(附完整代码)
C#数组与算法实战:从基础遍历到滑动窗口的深度进阶指南
如果你刚开始接触C#,或者已经写过一些控制台应用,但总觉得对数组、字符串这类基础数据结构的操作停留在“会用”层面,那么这篇文章正是为你准备的。我见过不少开发者,能熟练写出for循环求和,但一旦遇到“滑动窗口最大值”这类问题,思路就卡住了。这背后的原因,往往不是算法本身多难,而是缺乏一套从基础操作到高级模式平滑过渡的实战训练体系。
本文将带你跳出孤立练习的陷阱,通过一组精心设计的实战案例,串联起C#数组操作的核心技能树。我们不止步于写出能运行的代码,更会深入探讨为何这样设计、不同方法间的性能差异以及如何将这些技巧应用到真实项目场景中。无论是准备技术面试,还是希望提升日常编码的健壮性与效率,这里的22个练习都将成为你坚实的垫脚石。
1. 夯实基础:数组操作的四种核心模式
在深入任何复杂算法之前,我们必须对数组这一最基本的数据结构了如指掌。C#中的数组不仅是数据的容器,更是理解内存访问、迭代逻辑和算法效率的起点。
1.1 遍历与聚合:超越简单的循环
求和、求积、求平均值,这些操作看似初级,却隐藏着编写高质量代码的关键:边界处理、溢出预防和选择正确的数据类型。
// 一个健壮的数组求和示例
public static long RobustArraySum(int[] numbers)
{
if (numbers == null) throw new ArgumentNullException(nameof(numbers));
if (numbers.Length == 0) return 0; // 空数组的和定义为0
long total = 0; // 使用long防止大数求和溢出
foreach (int num in numbers)
{
// 这里可以加入业务逻辑,例如过滤无效值
// if (num == int.MinValue) continue; // 示例:跳过特定值
total += num;
}
return total;
}
注意:对于大规模数据求和,务必使用
long甚至BigInteger类型。int类型的最大值约为21亿,很容易在累加过程中溢出,且不抛出异常,导致难以察觉的逻辑错误。
单纯计算聚合值意义有限,真正的价值在于将聚合模式抽象出来。C#的LINQ提供了Sum()、Average()等方法,但理解其背后的手动实现,能让你在无法使用LINQ的环境(如某些高性能或受限场景)或需要自定义聚合逻辑时游刃有余。
让我们对比几种常见聚合操作的性能考量:
| 操作类型 | 关键考虑点 | 常见陷阱 | 推荐做法 |
|---|---|---|---|
| 求和/求积 | 数据类型溢出、空数组处理 | 使用int累加可能溢出 | 使用long或checked关键字 |
| 求平均值 | 整数除法的精度丢失 | int / int 结果仍是int | 使用double类型进行除法 |
| 查找最值 | 数组为空的处理 | 假设数组非空直接取arr[0] | 先判空,或使用int.MinValue/int.MaxValue初始化 |
1.2 查找与筛选:效率与可读性的平衡
查找数组中的偶数,或筛选满足某个条件的所有元素,是日常编程中最频繁的操作之一。这里我们面临一个选择:返回新数组还是修改原数组?
// 方法1:使用List<T>作为缓冲,返回新数组(通用性好)
public static int[] FilterEvensWithList(int[] source)
{
var evensList = new List<int>();
foreach (var num in source)
{
if (num % 2 == 0) evensList.Add(num);
}
return evensList.ToArray(); // 分配新数组
}
// 方法2:使用yield return实现惰性求值(内存效率高)
public static IEnumerable<int> FilterEvensLazy(int[] source)
{
foreach (var num in source)
{
if (num % 2 == 0) yield return num;
}
}
第一种方法意图明确,结果立即可用,但需要额外内存分配。第二种方法采用了迭代器,只有在真正需要元素时才计算,适合处理大型数据集或链式操作。在实际项目中,我通常根据调用方的需求来决定:如果结果需要被多次访问或随机存取,用第一种;如果只是作为中间结果进行进一步处理,第二种更优。
查找最大值/最小值时,一个容易被忽略的优化是同时查找。在一次遍历中完成两项工作,虽然对现代CPU的流水线影响不大,但体现了减少迭代次数的思维。
public static (int Max, int Min) FindMaxAndMinInOnePass(int[] arr)
{
if (arr == null || arr.Length == 0)
throw new ArgumentException("数组不能为空");
int max = arr[0];
int min = arr[0];
// 从第二个元素开始遍历
for (int i = 1; i < arr.Length; i++)
{
if (arr[i] > max) max = arr[i];
else if (arr[i] < min) min = arr[i]; // 用else if,因为一个数不可能同时大于max又小于min
}
return (max, min);
}
2. 字符串处理:不可变性的艺术与实战
C#中的字符串是不可变的(immutable)。这意味着任何修改操作(如拼接、替换)都会产生新的字符串对象。理解这一点,是写出高效字符串处理代码的关键。
2.1 反转、回文与去重:理解字符数组
字符串反转是最经典的面试题之一,它至少有四种常见实现方式,各有优劣:
// 方式1:使用Array.Reverse (最简洁)
public static string ReverseWithArray(string input)
{
char[] charArray = input.ToCharArray();
Array.Reverse(charArray);
return new string(charArray);
}
// 方式2:使用StringBuilder (适合多次修改)
public static string ReverseWithStringBuilder(string input)
{
var sb = new StringBuilder(input.Length);
for (int i = input.Length - 1; i >= 0; i--)
{
sb.Append(input[i]);
}
return sb.ToString();
}
// 方式3:使用栈 (体现数据结构思维)
public static string ReverseWithStack(string input)
{
var stack = new Stack<char>();
foreach (char c in input) stack.Push(c);
return new string(stack.ToArray());
}
// 方式4:原地交换 (性能最优,但代码稍复杂)
public static string ReverseInPlace(string input)
{
char[] chars = input.ToCharArray();
int left = 0, right = chars.Length - 1;
while (left < right)
{
// 交换字符
(chars[left], chars[right]) = (chars[right], chars[left]);
left++;
right--;
}
return new string(chars);
}
对于回文判断,核心在于双指针技巧。但实际应用中,我们往往需要忽略大小写和标点符号。下面是一个工业级强度的回文检查器:
public static bool IsPalindromeRobust(string s)
{
if (string.IsNullOrEmpty(s)) return true;
int left = 0, right = s.Length - 1;
while (left < right)
{
// 跳过非字母数字字符
while (left < right && !char.IsLetterOrDigit(s[left])) left++;
while (left < right && !char.IsLetterOrDigit(s[right])) right--;
// 比较(忽略大小写)
if (char.ToLowerInvariant(s[left]) != char.ToLowerInvariant(s[right]))
return false;
left++;
right--;
}
return true;
}
字符串去重看似简单,但如何保留首次出现的顺序?HashSet<T>是解决这个问题的利器,它能提供O(1)时间复杂度的查找。
public static string RemoveDuplicateChars(string input)
{
if (string.IsNullOrEmpty(input)) return input;
var seen = new HashSet<char>();
var result = new StringBuilder();
foreach (char c in input)
{
if (seen.Add(c)) // HashSet.Add 返回bool,表示是否成功添加(即是否为新元素)
{
result.Append(c);
}
}
return result.ToString();
}
2.2 子串、压缩与公共前缀:滑动窗口的雏形
“最长无重复字符子串”是LeetCode上的经典题目,也是滑动窗口算法的绝佳入门案例。其核心思想是维护一个不包含重复字符的窗口,通过左右指针的移动来寻找最大窗口。
public static int LengthOfLongestSubstring(string s)
{
if (string.IsNullOrEmpty(s)) return 0;
// 字典存储字符最近一次出现的位置
var charIndex = new Dictionary<char, int>();
int maxLength = 0;
int windowStart = 0; // 窗口左边界
for (int windowEnd = 0; windowEnd < s.Length; windowEnd++)
{
char currentChar = s[windowEnd];
// 如果字符已在窗口中,需要收缩左边界
if (charIndex.ContainsKey(currentChar))
{
// 关键:窗口左边界只能向右移动,不能向左
windowStart = Math.Max(windowStart, charIndex[currentChar] + 1);
}
// 更新字符的最新位置
charIndex[currentChar] = windowEnd;
// 计算当前窗口长度
maxLength = Math.Max(maxLength, windowEnd - windowStart + 1);
}
return maxLength;
}
这个算法的精妙之处在于windowStart = Math.Max(windowStart, charIndex[currentChar] + 1)这一行。它确保了窗口左边界不会回退,从而保证了O(n)的时间复杂度。理解这个逻辑,就掌握了滑动窗口算法的精髓。
字符串压缩(如将"aabcccccaaa"压缩为"a2b1c5a3")则考验着状态维护和边界处理的能力。这里需要注意,如果压缩后的字符串没有变短,应返回原字符串。
public static string CompressString(string str)
{
if (string.IsNullOrEmpty(str) || str.Length <= 1)
return str;
var compressed = new StringBuilder();
int countConsecutive = 1;
for (int i = 1; i <= str.Length; i++) // 注意:这里 i <= length,为了处理最后一个字符
{
// 如果当前字符与前一个相同,计数增加
if (i < str.Length && str[i] == str[i - 1])
{
countConsecutive++;
}
else
{
// 追加前一个字符及其计数
compressed.Append(str[i - 1]);
compressed.Append(countConsecutive);
countConsecutive = 1; // 重置计数
}
}
// 只有压缩后更短才返回压缩结果
return compressed.Length < str.Length ? compressed.ToString() : str;
}
3. 算法思维进阶:从哈希映射到双指针
当基础操作熟练后,我们需要引入更高级的算法思维来解决实际问题。哈希表(在C#中常用Dictionary<TKey, TValue>)和双指针是其中最常用的两种工具。
3.1 哈希表的妙用:两数之和与频率统计
“两数之和”问题(给定数组和目标值,找出和为目标值的两个数的索引)是哈希表应用的典范。暴力解法需要O(n²)的时间,而哈希表可以将其优化到O(n)。
public static (int index1, int index2)? FindTwoSumIndices(int[] nums, int target)
{
if (nums == null || nums.Length < 2)
return null;
// 键:数值,值:该数值的索引
var numMap = new Dictionary<int, int>();
for (int i = 0; i < nums.Length; i++)
{
int complement = target - nums[i];
// 检查补数是否已在字典中
if (numMap.TryGetValue(complement, out int complementIndex))
{
return (complementIndex, i); // 找到一对
}
// 将当前数及其索引加入字典
// 注意:先检查再添加,避免同一个元素使用两次
if (!numMap.ContainsKey(nums[i]))
{
numMap[nums[i]] = i;
}
}
return null; // 未找到
}
这个算法的关键在于边遍历边构建查找表。对于每个元素,我们计算其“补数”(即target - current),然后查看这个补数是否已经出现过。如果出现过,我们就找到了一对解。这种“用空间换时间”的策略在算法设计中非常普遍。
哈希表的另一个常见用途是统计频率。例如,找出数组中出现次数超过一半的元素(众数):
public static int? FindMajorityElement(int[] nums)
{
if (nums == null || nums.Length == 0) return null;
var frequency = new Dictionary<int, int>();
int majorityThreshold = nums.Length / 2;
foreach (int num in nums)
{
// 更新频率计数
frequency[num] = frequency.ContainsKey(num) ? frequency[num] + 1 : 1;
// 如果某个元素的计数已超过阈值,直接返回
if (frequency[num] > majorityThreshold)
return num;
}
return null; // 没有众数
}
3.2 双指针技巧:快慢指针与左右指针
双指针技巧主要分为两类:快慢指针(常用于链表)和左右指针(常用于数组)。在数组操作中,左右指针尤为常见。
一个经典例子是移除有序数组中的重复项,要求原地修改并返回新长度:
public static int RemoveDuplicatesFromSortedArray(int[] nums)
{
if (nums == null || nums.Length == 0) return 0;
int uniqueIndex = 0; // 慢指针:指向最后一个唯一元素的位置
for (int current = 1; current < nums.Length; current++) // 快指针:遍历数组
{
if (nums[current] != nums[uniqueIndex])
{
uniqueIndex++;
nums[uniqueIndex] = nums[current]; // 将新唯一元素移到前面
}
}
return uniqueIndex + 1; // 新长度为索引+1
}
这个算法的核心是维护两个指针:uniqueIndex(慢)和current(快)。快指针遍历整个数组,慢指针标记唯一序列的末尾。当快指针遇到与慢指针不同的元素时,就将该元素复制到慢指针的下一个位置,然后慢指针前进。这样,所有唯一元素都被紧凑地排列在数组开头。
另一个双指针的变体是夹逼法,常用于在有序数组中寻找两个数的和或差。虽然我们的“两数之和”问题数组是无序的,但如果是已排序数组,可以有更优解:
// 假设数组已按升序排序
public static (int left, int right)? FindTwoSumInSortedArray(int[] sortedNums, int target)
{
if (sortedNums == null || sortedNums.Length < 2)
return null;
int left = 0;
int right = sortedNums.Length - 1;
while (left < right)
{
int sum = sortedNums[left] + sortedNums[right];
if (sum == target)
return (left, right);
else if (sum < target)
left++; // 和太小,左指针右移以增加和
else
right--; // 和太大,右指针左移以减少和
}
return null;
}
这种方法的时间复杂度是O(n),且不需要额外空间(哈希表需要O(n)空间)。它充分利用了数组有序的特性,通过调整左右指针来逼近目标值。
4. 高级模式:滑动窗口与矩阵操作
当问题涉及“连续子数组”或“固定大小窗口”时,滑动窗口算法往往是最优解。而矩阵操作则考验着我们对二维数据结构的理解。
4.1 滑动窗口最大值:双端队列的威力
“滑动窗口最大值”是滑动窗口算法的经典难题。暴力解法对每个窗口都重新查找最大值,时间复杂度为O(n*k),其中n是数组长度,k是窗口大小。使用双端队列(Deque)可以优化到O(n)。
public static int[] MaxSlidingWindow(int[] nums, int k)
{
if (nums == null || k <= 0 || nums.Length < k)
return Array.Empty<int>();
// 结果数组,长度为 n - k + 1
int[] result = new int[nums.Length - k + 1];
int resultIndex = 0;
// 双端队列,存储的是索引(不是值),且队列中的索引对应的值是递减的
var deque = new LinkedList<int>();
for (int i = 0; i < nums.Length; i++)
{
// 步骤1:移除队列中不在当前窗口范围内的索引
// 窗口范围是 [i-k+1, i],所以索引小于 i-k+1 的元素都不在窗口内
while (deque.Count > 0 && deque.First.Value < i - k + 1)
deque.RemoveFirst();
// 步骤2:维护队列的递减性
// 从队列尾部开始,移除所有小于当前元素的索引
while (deque.Count > 0 && nums[deque.Last.Value] < nums[i])
deque.RemoveLast();
// 步骤3:将当前索引加入队列
deque.AddLast(i);
// 步骤4:当窗口形成后(i >= k-1),记录当前窗口的最大值
// 队列头部始终是当前窗口最大值的索引
if (i >= k - 1)
{
result[resultIndex++] = nums[deque.First.Value];
}
}
return result;
}
这个算法的精妙之处在于双端队列的维护策略:
- 队列中存储的是索引,而不是值,这样我们可以判断索引是否还在窗口内
- 队列中的索引对应的数组值是递减的,这样队列头部就是当前窗口的最大值
- 添加新元素时,从队列尾部移除所有小于它的元素,保持递减性
理解这个算法后,你可以解决一系列滑动窗口问题,如“滑动窗口最小值”、“大小为K的子数组的最大和”等。
4.2 矩阵旋转:分层处理与坐标映射
矩阵旋转是面试中常见的问题,特别是顺时针旋转90度。最直观的方法是创建新矩阵,但题目往往要求原地旋转(不占用额外空间)。
public static void RotateMatrixClockwise(int[,] matrix)
{
int n = matrix.GetLength(0); // 假设是n x n矩阵
// 分层旋转:从外圈到内圈
for (int layer = 0; layer < n / 2; layer++)
{
int first = layer;
int last = n - 1 - layer;
for (int i = first; i < last; i++)
{
int offset = i - first;
// 保存上边
int top = matrix[first, i];
// 左到上
matrix[first, i] = matrix[last - offset, first];
// 下到左
matrix[last - offset, first] = matrix[last, last - offset];
// 右到下
matrix[last, last - offset] = matrix[i, last];
// 上到右(使用保存的top)
matrix[i, last] = top;
}
}
}
这个算法的关键是找到旋转时元素的对应关系。对于位置(row, col)的元素,顺时针旋转90度后的新位置是(col, n-1-row)。分层处理则让我们可以逐圈旋转,而不需要复杂的坐标计算。
为了更直观地理解,我们可以看一个3x3矩阵的旋转过程:
初始矩阵:
1 2 3
4 5 6
7 8 9
旋转过程(外层):
保存左上角1
7 -> 左上角 (0,0)
9 -> (0,2)的位置
3 -> (2,2)的位置
1 -> (2,0)的位置(但实际是放到(0,2)?需要仔细跟踪)
实际上,对于四角:
temp = 左上角(1)
左上角(1) = 左下角(7)
左下角(7) = 右下角(9)
右下角(9) = 右上角(3)
右上角(3) = temp(1)
除了旋转,矩阵的转置也是常见操作:
public static void TransposeMatrix(int[,] matrix)
{
int rows = matrix.GetLength(0);
int cols = matrix.GetLength(1);
// 转置:matrix[i,j] 与 matrix[j,i] 交换
for (int i = 0; i < rows; i++)
{
for (int j = i + 1; j < cols; j++) // 从i+1开始,避免重复交换
{
// 交换元素
int temp = matrix[i, j];
matrix[i, j] = matrix[j, i];
matrix[j, i] = temp;
}
}
}
矩阵转置后再反转每一行,就得到了顺时针旋转90度的结果。这种方法可能更直观,但需要理解矩阵操作的本质。
5. 综合实战:算法思维的融合应用
掌握了单个技巧后,真正的挑战在于如何将它们组合起来解决复杂问题。让我们看几个综合性的例子。
5.1 有效的括号序列:栈的经典应用
检查括号序列是否有效(如"()[]{}"有效,"(]"无效)是栈数据结构的教科书式应用。但实际面试中,问题往往会有所扩展。
基础版本使用栈:
public static bool IsValidParentheses(string s)
{
if (string.IsNullOrEmpty(s)) return true;
var stack = new Stack<char>();
var mapping = new Dictionary<char, char>
{
{ ')', '(' },
{ ']', '[' },
{ '}', '{' }
};
foreach (char c in s)
{
if (mapping.ContainsValue(c)) // 开括号
{
stack.Push(c);
}
else if (mapping.ContainsKey(c)) // 闭括号
{
if (stack.Count == 0 || stack.Peek() != mapping[c])
return false;
stack.Pop();
}
// 其他字符可以忽略或根据需求处理
}
return stack.Count == 0; // 栈应为空
}
扩展问题1:支持多种括号类型。上面的代码已经通过字典实现了这一点,添加新的括号类型只需更新字典。
扩展问题2:找出最长有效括号子串。这个问题就复杂多了,需要动态规划或栈的巧妙运用:
public static int LongestValidParentheses(string s)
{
if (string.IsNullOrEmpty(s)) return 0;
int maxLength = 0;
var stack = new Stack<int>();
stack.Push(-1); // 哨兵值,方便计算长度
for (int i = 0; i < s.Length; i++)
{
if (s[i] == '(')
{
stack.Push(i);
}
else // s[i] == ')'
{
stack.Pop();
if (stack.Count == 0)
{
// 栈为空,将当前位置作为新的基准
stack.Push(i);
}
else
{
// 计算当前有效长度
maxLength = Math.Max(maxLength, i - stack.Peek());
}
}
}
return maxLength;
}
这个算法中,栈存储的是索引。遇到'('时压入索引,遇到')'时弹出。栈底始终保持一个"基准"索引,用于计算有效括号的长度。当栈被弹空时,说明当前的')'没有匹配的'(',于是将其索引作为新的基准。
5.2 数字处理与边界情况
数字到字符串的转换、阶乘计算等问题看似简单,但隐藏着许多边界情况和陷阱。
阶乘计算不仅要处理负数,还要考虑溢出和大数问题:
public static BigInteger Factorial(int n)
{
if (n < 0)
throw new ArgumentException("阶乘未定义于负整数");
if (n == 0 || n == 1)
return 1;
// 使用BigInteger处理大数
BigInteger result = 1;
for (int i = 2; i <= n; i++)
{
result *= i;
}
return result;
}
对于数字转英文单词,我们需要处理各种边界情况:零、负数、大数(百万、十亿等)。下面是一个支持0到999,999,999的版本:
private static readonly string[] Ones =
{ "", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine" };
private static readonly string[] Teens =
{ "Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen",
"Sixteen", "Seventeen", "Eighteen", "Nineteen" };
private static readonly string[] Tens =
{ "", "", "Twenty", "Thirty", "Forty", "Fifty",
"Sixty", "Seventy", "Eighty", "Ninety" };
public static string NumberToWords(int num)
{
if (num == 0) return "Zero";
if (num < 0) return "Negative " + NumberToWords(-num);
return Convert(num).Trim();
}
private static string Convert(int num)
{
if (num >= 1000000000) // 十亿
return Convert(num / 1000000000) + " Billion " + Convert(num % 1000000000);
else if (num >= 1000000) // 百万
return Convert(num / 1000000) + " Million " + Convert(num % 1000000);
else if (num >= 1000) // 千
return Convert(num / 1000) + " Thousand " + Convert(num % 1000);
else if (num >= 100) // 百
return Convert(num / 100) + " Hundred " + Convert(num % 100);
else if (num >= 20) // 20-99
return Tens[num / 10] + " " + Convert(num % 10);
else if (num >= 10) // 10-19
return Teens[num - 10];
else // 1-9
return Ones[num];
}
这个实现使用了递归,将大数分解为更小的部分。注意处理空格和零的情况,确保输出格式正确。
5.3 最长公共前缀:分治与二分查找
找出一组字符串的最长公共前缀,最简单的方法是纵向扫描,但还有更高效的方法。
纵向扫描法:
public static string LongestCommonPrefixVertical(string[] strs)
{
if (strs == null || strs.Length == 0) return "";
// 以第一个字符串为基准
for (int i = 0; i < strs[0].Length; i++)
{
char currentChar = strs[0][i];
// 检查其他字符串在相同位置是否都有这个字符
for (int j = 1; j < strs.Length; j++)
{
if (i >= strs[j].Length || strs[j][i] != currentChar)
return strs[0].Substring(0, i);
}
}
return strs[0]; // 第一个字符串就是公共前缀
}
分治法将问题分解为子问题:
public static string LongestCommonPrefixDivide(string[] strs)
{
if (strs == null || strs.Length == 0) return "";
return Divide(strs, 0, strs.Length - 1);
}
private static string Divide(string[] strs, int left, int right)
{
if (left == right) return strs[left];
int mid = (left + right) / 2;
string leftPrefix = Divide(strs, left, mid);
string rightPrefix = Divide(strs, mid + 1, right);
return CommonPrefix(leftPrefix, rightPrefix);
}
private static string CommonPrefix(string str1, string str2)
{
int minLength = Math.Min(str1.Length, str2.Length);
for (int i = 0; i < minLength; i++)
{
if (str1[i] != str2[i])
return str1.Substring(0, i);
}
return str1.Substring(0, minLength);
}
二分查找法可以在某些情况下更高效:
public static string LongestCommonPrefixBinary(string[] strs)
{
if (strs == null || strs.Length == 0) return "";
// 找到最短字符串的长度
int minLen = int.MaxValue;
foreach (string str in strs)
minLen = Math.Min(minLen, str.Length);
int low = 1;
int high = minLen;
while (low <= high)
{
int middle = (low + high) / 2;
if (IsCommonPrefix(strs, middle))
low = middle + 1;
else
high = middle - 1;
}
return strs[0].Substring(0, (low + high) / 2);
}
private static bool IsCommonPrefix(string[] strs, int len)
{
string prefix = strs[0].Substring(0, len);
for (int i = 1; i < strs.Length; i++)
{
if (!strs[i].StartsWith(prefix))
return false;
}
return true;
}
二分查找法的时间复杂度是O(S*log(minLen)),其中S是所有字符串的总长度。当字符串很长但公共前缀很短时,这种方法比纵向扫描的O(S)可能更优。
在实际项目中,我通常从最简单的纵向扫描开始,只有在性能成为瓶颈时才考虑更复杂的算法。过早优化往往是浪费时间的根源。
更多推荐
所有评论(0)