Flutter面试:算法基础1

158 阅读15分钟

文章目录

什么是算法的时间复杂度 ?什么是算法的空间复杂度?

一句话总结

这两个概念是用来衡量算法 "好坏" 的指标,帮助我们判断一个算法效率高不高、占不占内存。时间复杂度(Time Complexity)简单说:算法执行时,花费的时间随输入数据量增长的趋势。空间复杂度(Space Complexity)简单说:算法执行时,占用的内存空间随输入数据量增长的趋势

核心概念

1. 时间复杂度(Time Complexity)

算法执行时,花费的时间随输入数据量增长的趋势

  • 用 O(...) 表示,比如 O(n)O(1)O(n²)
  • 这里的 n 通常代表输入数据的规模(比如字符串长度、数组元素个数)。

举几个例子:

  • O(1) :无论输入数据多大,执行时间都不变。比如 "从数组中取第 1 个元素",不管数组有 10 个还是 10 万个元素,一步就能完成。
  • O(n) :执行时间随数据量增长成正比。比如 "遍历字符串的每个字符",字符串长度是 100,就要做 100 次操作;长度是 1000,就要做 1000 次操作(我们的括号匹配算法就是 O (n),因为每个字符只处理一次)。
  • O(n²) :执行时间随数据量增长成平方关系。比如 "嵌套循环遍历数组",如果数组长度是 100,就要做 100×100=10000 次操作,数据量增大时,时间会急剧增加。

时间复杂度越小,算法效率越高。

2. 空间复杂度(Space Complexity)

算法执行时,占用的内存空间随输入数据量增长的趋势

  • 也用 O(...) 表示,衡量的是额外消耗的空间(不算输入数据本身占用的空间)。

举几个例子:

  • O(1) :不管输入数据多大,额外占用的空间不变。比如 "计算两个数的和",只需要几个变量存储中间结果,和输入规模无关。
  • O(n) :额外占用的空间随数据量增长成正比。比如我们的括号匹配算法:最坏情况下(比如字符串全是左括号 "((((...)))"),栈会存储所有左括号,栈的大小和字符串长度 n 相等,所以空间复杂度是 O(n)
  • O(n²) :比如 "创建一个 n×n 的二维数组",数组占用的空间和  成正比。

空间复杂度越小,算法越节省内存。



给定一个只包括'(',')','{','}','[',']'的字符串s,判断字符串是否有效。

题目内容

给定一个只包括'(',')','{','}','[',']'的字符串s,判断字符串是否有效。 有效字符串需满足:

  1. 左括号必须用相同类型的右括号闭合。
  2. 左括号必须以正确的顺序闭合。
  3. 每个右括号都有一个对应的相同类型的左括号。

解答

这是一个经典的栈应用问题,通过栈可以高效判断括号是否匹配。基本思路是:

  1. 遍历字符串中的每个字符
  2. 遇到左括号时,将其入栈
  3. 遇到右括号时,检查栈顶元素是否为对应的左括号
    • 如果匹配,弹出栈顶元素
    • 如果不匹配或栈为空,则字符串无效
  4. 遍历结束后,栈必须为空才表示所有括号都正确匹配

代码演示:

bool isValid(String s) {
  // 创建一个栈来存储左括号
  final stack = <String>[];
  
  // 创建括号映射关系,右括号对应左括号
  final map = {
    ')': '(',
    '}': '{',
    ']': '['
  };
  
  for (var char in s.split('')) {
    // 如果是右括号
    if (map.containsKey(char)) {
      // stack.removeLast() 移除并返回栈顶元素(列表的最后一个元素)
      // 栈为空或栈顶元素不匹配,返回false  
      if (stack.isEmpty || stack.removeLast() != map[char]) {
        return false;
      }
    } else {
      // 如果是左括号,入栈
      stack.add(char);
    }
  }
  
  // 栈为空表示所有括号都匹配
  return stack.isEmpty;
}

void main() {
  print(isValid("()"));      // true
  print(isValid("()[]{}"));  // true
  print(isValid("(]"));      // false
  print(isValid("([)]"));    // false
  print(isValid("{[]}"));    // true
}

算法分析

  • 时间复杂度:O (n),其中 n 是字符串的长度。我们只需要遍历字符串一次。
  • 空间复杂度:O (n),在最坏情况下(如全是左括号),栈需要存储所有字符。


烧绳子计时,烧一根粗细不均匀的绳子,从头烧到尾总共需要60秒.现有若干条材质相同的绳子,问如何用烧绳的方法来计时15秒?

题目内容:

烧绳子计时,烧一根粗细不均匀的绳子,从头烧到尾总共需要60秒.现有若干条材质相同的绳子,问如何用烧绳的方法来计时15秒?

解答:

这是一道经典的逻辑思维题,核心在于利用绳子燃烧的特性和时间的叠加 / 分割来实现目标计时。

解决步骤:

  1. 准备两根材质相同的绳子,记为 A 和 B
  2. 同时点燃绳子 A 的两端和绳子 B 的一端
    • 绳子 A 两端同时燃烧,由于总燃烧时间是 60 秒,两端同时燃烧会让它在 30 秒后烧完(60 秒的一半)
  3. 当绳子 A 完全烧完时(此时刚好过去 30 秒),立即点燃绳子 B 的另一端
    • 此时绳子 B 已经燃烧了 30 秒,还剩下 30 秒的燃烧时间
    • 从两端同时燃烧剩下的部分,会让剩余时间减半
  4. 从点燃绳子 B 另一端开始,到绳子 B 完全烧完的这段时间,就是 15 秒
    • 因为剩下的 30 秒燃烧时间,从两端同时烧就变成了 15 秒

代码演示:

import 'dart:async';

// 绳子类,表示一根可燃烧的绳子
class Rope {
  final int totalBurningTime; // 总燃烧时间(秒)
  int _remainingTime; // 剩余燃烧时间
  bool _isBurningFromStart; // 是否从起点燃烧
  bool _isBurningFromEnd; // 是否从终点燃烧

  Rope(this.totalBurningTime)
    : _remainingTime = totalBurningTime,
      _isBurningFromStart = false,
      _isBurningFromEnd = false;

  // 从起点开始燃烧
  void burnFromStart() {
    _isBurningFromStart = true;
  }

  // 从终点开始燃烧
  void burnFromEnd() {
    _isBurningFromEnd = true;
  }

  // 停止燃烧
  void stopBurning() {
    _isBurningFromStart = false;
    _isBurningFromEnd = false;
  }

  // 检查是否正在燃烧
  bool get isBurning => _isBurningFromStart || _isBurningFromEnd;

  // 检查是否已烧完
  bool get isBurnedOut => _remainingTime <= 0;

  // 模拟1秒的燃烧过程
  void simulateOneSecond() {
    if (isBurnedOut) return;

    // 计算每秒燃烧的量
    int burnAmount = 0;
    if (_isBurningFromStart) burnAmount++;
    if (_isBurningFromEnd) burnAmount++;

    // 减少剩余燃烧时间
    _remainingTime -= burnAmount;
    if (_remainingTime < 0) _remainingTime = 0;
  }

  // 获取剩余燃烧时间
  int get remainingTime => _remainingTime;
}

// 模拟烧绳子计时15秒的过程
void simulate15SecondsTimer() {
  print("开始模拟烧绳子计时15秒...\n");

  // 创建两根绳子,每根燃烧时间60秒
  Rope ropeA = Rope(60);
  Rope ropeB = Rope(60);

  int elapsedTime = 0; // 已过去的时间
  bool is15SecondStarted = false;
  int fifteenSecondCount = 0;

  // 每秒执行一次的计时器
  Timer.periodic(Duration(seconds: 1), (timer) {
    // 第一阶段:同时点燃A的两端和B的一端
    if (elapsedTime == 0) {
      ropeA.burnFromStart();
      ropeA.burnFromEnd();
      ropeB.burnFromStart();
      print("时间 $elapsedTime 秒: 点燃绳子A两端和绳子B一端");
    }

    // 模拟燃烧1秒
    ropeA.simulateOneSecond();
    ropeB.simulateOneSecond();
    elapsedTime++;

    // 当A烧完时(30秒),点燃B的另一端
    if (ropeA.isBurnedOut && !is15SecondStarted) {
      ropeB.burnFromEnd();
      is15SecondStarted = true;
      print("\n时间 $elapsedTime 秒: 绳子A已烧完,点燃绳子B另一端,开始计时15秒");
    }

    // 显示15秒计时过程
    if (is15SecondStarted && !ropeB.isBurnedOut) {
      fifteenSecondCount++;
      print("15秒计时中: $fifteenSecondCount 秒");
    }

    // 当B烧完时(又过了15秒),计时完成
    if (ropeB.isBurnedOut) {
      print("\n时间 $elapsedTime 秒: 绳子B已烧完,15秒计时结束");
      timer.cancel();
    }
  });
}

void main() {
  simulate15SecondsTimer();
}
代码解析:

这个模拟程序主要包含两个部分:

  1. Rope 类:模拟一根可燃烧的绳子
    • 可以从两端分别点燃或同时点燃
    • 每秒钟根据燃烧点数量(1 个或 2 个)减少相应的剩余燃烧时间
    • 提供检查是否正在燃烧和是否已烧完的方法
  2. 模拟过程
    • 创建两根 60 秒燃烧时间的绳子 A 和 B
    • 0 秒时:点燃 A 的两端和 B 的一端
    • 当 A 烧完(30 秒时):点燃 B 的另一端,开始 15 秒计时
    • 当 B 烧完(又过 15 秒,总 45 秒时):计时结束,完成 15 秒的测量
算法分析:
  1. 时间复杂度:O(1)
    • 无论输入如何(绳子燃烧时间固定为 60 秒),整个模拟过程都是固定的 45 秒
    • 算法的执行步骤和时间不受输入规模影响,因此是常数时间复杂度
  2. 空间复杂度:O(1)
    • 只需要固定数量的变量(两根绳子和几个计时变量)
    • 空间使用量不随问题规模变化,因此是常数空间复杂度
  3. 核心思路
    • 利用 "从两端燃烧一根绳子会使燃烧时间减半" 的特性
    • 通过两次减半操作(60→30→15)实现 15 秒计时
    • 这种方法不需要精确切割绳子,只需要控制点燃和熄灭的时机


打家劫舍|,你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金.影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统...

题目内容:

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

示例 1:

输入: [1,2,3,1]
输出: 4
解释: 偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4

示例 2:

输入: [2,7,9,3,1]
输出: 12
解释: 偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
     偷窃到的最高金额 = 2 + 9 + 1 = 12

提示:

  • 1 <= nums.length <= 100
  • 0 <= nums[i] <= 400

解答:

这是一道经典的动态规划问题,核心是在不触发警报(不偷窃相邻房屋)的情况下,计算能偷窃到的最高金额。

问题分析

  • 核心约束:不能偷窃相邻的房屋(否则触发警报)
  • 目标:找到一条不包含相邻元素的子序列,使其元素之和最大
  • 动态规划思路:对于每个房屋,都有两种选择(偷或不偷),通过比较两种选择的收益来做决策

动态规划解法

  1. 状态定义
    设 dp[i] 表示偷窃前 i 间房屋(即索引 0 到 i)能获得的最大金额。
  2. 状态转移方程
    对于第 i 间房屋:
    • 如果偷第 i 间房屋,则不能偷第 i-1 间,收益为 dp[i-2] + nums[i]
    • 如果不偷第 i 间房屋,则收益等于前 i-1 间的最大收益 dp[i-1]
    • 因此:dp[i] = max(dp[i-1], dp[i-2] + nums[i])
  3. 边界条件
    • 只有 1 间房时:dp[0] = nums[0]
    • 有 2 间房时:dp[1] = max(nums[0], nums[1])

代码演示:

int rob(List<int> nums) {
  int n = nums.length;
  if (n == 0) return 0;
  if (n == 1) return nums[0];
  
  // 优化空间:用两个变量代替dp数组
  int prevPrev = nums[0]; // 表示dp[i-2]
  int prev = max(nums[0], nums[1]); // 表示dp[i-1]
  
  // 从第3间房开始计算(索引2)
  for (int i = 2; i < n; i++) {
    int current = max(prev, prevPrev + nums[i]);
    prevPrev = prev;
    prev = current;
  }
  
  return prev;
}

// 辅助函数:取两个数的最大值
int max(int a, int b) => a > b ? a : b;

void main() {
  print(rob([1,2,3,1]));    // 输出:4(1+3)
  print(rob([2,7,9,3,1]));  // 输出:12(2+9+1)
  print(rob([5]));          // 输出:5(只有一间房)
  print(rob([3, 1]));       // 输出:3(选择金额较大的)
  print(rob([2,1,1,2]));    // 输出:4(2+2)
}

算法分析:

  1. 时间复杂度:O(n)
    只需遍历一次数组,其中 n 是房屋数量,因此时间复杂度为线性级别。
  2. 空间复杂度:O(1)
    优化后仅使用了 3 个变量(prevPrevprevcurrent)来存储中间结果,无需额外数组,空间复杂度为常数级。
  3. 优化思路
    观察状态转移方程可知,计算 dp[i] 只需要 dp[i-1] 和 dp[i-2] 的值,因此可以用两个变量替代整个 dp 数组,将空间复杂度从 O (n) 优化到 O (1)。


打家劫舍||,你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金.这个地方所有的房屋都围成一圈...

题目内容:

你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。

给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。

示例 1:

输入: nums = [2,3,2]
输出: 3
解释: 你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。

示例 2:

输入: nums = [1,2,3,1]
输出: 4
解释: 你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4 。

示例 3:

输入: nums = [1,2,3]
输出: 3

提示:

  • 1 <= nums.length <= 100
  • 0 <= nums[i] <= 1000

解答:

这道题是经典动态规划问题 "打家劫舍" 的变种,核心差异在于房屋围成一圈,导致第一个和最后一个房屋不能同时偷窃。

问题分析

  • 约束条件:房屋环形排列(首末相邻),不能偷窃相邻房屋,否则触发警报
  • 核心难点:首末房屋的互斥性(不能同时偷)
  • 转化思路:将环形问题拆解为两个线性问题
    1. 不偷第一个房屋,考虑偷窃范围为 [1, n-1](从第 2 个到最后一个)
    2. 不偷最后一个房屋,考虑偷窃范围为 [0, n-2](从第一个到倒数第二个)
  1. 最终结果为两个范围的最大偷窃金额的最大值

动态规划解法

普通线性排列的打家劫舍问题可以用动态规划解决:

  • 状态定义:dp[i] 表示前 i 个房屋能偷窃的最大金额
  • 状态转移:对于第 i 个房屋,有两种选择
    • 不偷:dp[i] = dp[i-1]
    • 偷:dp[i] = dp[i-2] + nums[i](因为不能偷第 i-1 个)
    • 综上:dp[i] = max(dp[i-1], dp[i-2] + nums[i])

代码演示:

int rob(List<int> nums) {
  int n = nums.length;
  // 边界情况:只有一间房时直接返回该房屋金额
  if (n == 1) return nums[0];
  
  // 计算两个范围的最大金额并取最大值
  return max(
    _robLinear(nums, 0, n - 2),  // 不偷最后一个房屋
    _robLinear(nums, 1, n - 1)   // 不偷第一个房屋
  );
}

// 辅助函数:计算线性排列的房屋(start到end)的最大偷窃金额
int _robLinear(List<int> nums, int start, int end) {
  if (start > end) return 0;
  if (start == end) return nums[start];
  
  int prevPrev = nums[start];  // 前两个位置的最大金额(i-2)
  int prev = max(nums[start], nums[start + 1]);  // 前一个位置的最大金额(i-1)
  
  // 从start+2开始遍历
  for (int i = start + 2; i <= end; i++) {
    int current = max(prev, prevPrev + nums[i]);
    prevPrev = prev;
    prev = current;
  }
  
  return prev;
}

// 辅助函数:取两个数的最大值
int max(int a, int b) => a > b ? a : b;

void main() {
  print(rob([2,3,2]));    // 输出:3(正确,偷中间的3)
  print(rob([1,2,3,1]));  // 输出:4(正确,1+3)
  print(rob([1,2,3]));    // 输出:3(正确,偷最后一个3)
  print(rob([5]));        // 输出:5(边界情况)
  print(rob([1,9,1,1,9]));// 输出:10(9+1或1+9)
}
算法分析
  1. 时间复杂度:O(n)
    • 两个线性子问题各遍历一次数组,总遍历次数为 2n,属于线性时间
  2. 空间复杂度:O(1)
    • 未使用额外数组,仅用常数个变量保存中间状态,空间复杂度为常数级
  3. 优化点
    • 用两个变量替代动态规划数组,将空间复杂度从 O (n) 优化到 O (1)
    • 通过问题拆解,将环形约束转化为两个线性问题,简化了逻辑