【问题标题】:How can this code be better structured in Elixir?如何在 Elixir 中更好地结构化这段代码?
【发布时间】:2015-04-19 16:02:58
【问题描述】:

我正在学习 Elixir 作为我的第一个函数式语言。作为第一个熟悉环境和语法的简单项目,我选择构建一个简单的程序来计算命令行上提供的数字的质因数。这是我的第一个解决方案:

defmodule Prime do
  defp is_factor?(number, divisor) do
    cond do
      rem(number, divisor) == 0 -> divisor
      true                      -> nil
    end
  end

  defp not_nil?(thing) do
    !is_nil(thing)
  end

  def factors(number) when number == 1 do
    []
  end

  def factors(number) do
    1..div(number, 2)
      |> Enum.map(&(is_factor?(number, &1)))
      |> Enum.filter(&not_nil?/1)
  end

  def is_prime?(number) when number == 1 do
    true
  end

  def is_prime?(number) do
    factors(number) == [1]
  end

  def prime_factors(number) do
    factors(number)
      |> Enum.filter(&is_prime?/1)
  end
end

input = hd(System.argv)
number = String.strip(input) |> String.to_integer
IO.puts "Prime factors of #{number} are #{inspect Prime.prime_factors(number)}"

它可以工作,但运行速度很慢。在我的笔记本电脑上,计算 50,000,000 的质因数大约需要 11 秒。

随着我阅读的更多,这个原始解决方案似乎不是很像 Elixir。所以我将代码重组为:

defmodule PrimeFactors do
  def of(n) do
    _factors(n, div(n, 2))
  end

  defp _factors(_n, 1) do
    [1]
  end
  defp _factors(n, divisor) when rem(n, divisor) == 0 do
    cond do
      is_prime?(divisor) -> _factors(n, divisor - 1) ++ [divisor]
      true               -> _factors(n, divisor - 1)
    end
  end
  defp _factors(n, divisor) do
    _factors(n, divisor - 1)
  end

  defp is_prime?(1) do
    true
  end
  defp is_prime?(n) do
    of(n) == [1]
  end
end

input = hd(System.argv)
number = String.strip(input) |> String.to_integer
IO.puts "Prime factors of #{number} are #{inspect PrimeFactors.of(number)}"

此代码计算 50,000,000 的素因数的典型运行时间要差得多:超过 17 秒。

我用 Swift 和 Ruby 构建了等效的程序。优化后的 Swift 运行只需 0.5 秒多一点,而 Ruby(2.2,从未以速度着称)运行只需 6 秒多一点。

我的主要问题是:应该如何将 Elixir 代码的结构安排得更符合习惯并避免我看到的性能问题?

我还有些担心,考虑到这样一个简单的问题,编写效率差异很大的 Elixir 代码是可能的。也许这主要是我在功能样式展示方面的经验不足?

【问题讨论】:

  • 当您想将元素添加到列表时,您可以肯定做的一件事是避免使用++ 运算符。 Elixir 列表由cons cells 组成,因此将元素添加到列表末尾应该是 O(n) 操作,而将其添加到列表头部是 O(1)。一种常见的模式是将元素添加到列表 ([new_el|list]) 并在使用之前使用 Enum.reverse/1 反转列表。
  • 很高兴知道,但在这种情况下并没有太大的区别。我猜这是因为素数列表往往很短(对于 50,000,000,列表是 [1, 2, 5])。
  • 好的,另一个很好的建议,但算法在其他实现中是相同的。我觉得我缺少对如何构造 Elixir 代码以避免性能问题的基本理解。看起来 Ruby 实现和好的 Elixir 实现之间不应该有这么大的差距。
  • 该代码对于大数字肯定很慢,但我相信其主要原因是算法复杂性。这是我对解决方案的快速和草率的看法,该解决方案很可能远非最佳,但比原始代码运行得更快:gist.github.com/sasa1977/98e6465bf854a9d86cba
  • 原始速度不是 Erlang 的主要关注点,所以如果它对 CPU 的限制很大,我预计 Elixir/Erlang 会比大多数主流语言慢。在这个示例中,我怀疑 Ruby 会胜出,因为您使用 C 实现的 Ruby 函数(map、reject、compact)进行了大量迭代,而 Elixir 迭代是标准的 Erlang 字节码。此外,(在此示例中不相关),使用time 测量可能影响结果的 Erlang VM 启动时间。我真的不认为这是一个很好的例子,因为一旦你在算法上优化代码,差异可能变得微不足道。

标签: elixir


【解决方案1】:

让我从快速咆哮开始,然后我们将转向答案。我相信我们在这里担心的是错误的事情。发布 Ruby 代码后,我的第一个想法是:为什么 Elixir 代码看起来不像 Ruby 代码那么干净?

让我们先解决这个问题:

defmodule PrimeFactors do
  def of(n) do
    factors(n, div(n, 2)) |> Enum.filter(&is_prime?/1)
  end

  def factors(1, _), do: [1]
  def factors(_, 1), do: [1]
  def factors(n, i) do
    if rem(n, i) == 0 do
      [i|factors(n, i-1)]
    else
      factors(n, i-1)
    end
  end

  def is_prime?(n) do
    factors(n, div(n, 2)) == [1]
  end
end

IO.inspect PrimeFactors.of(50_000_000)

好多了。让我们运行这个更清洁的版本?在我的机器上是 3.5 秒(之前的机器是 24 秒)。

现在有了更简洁的代码,可以更轻松地比较实现中的问题。您的_factors 函数实际上是_factors_and_prime,因为您已经在检查其中的数字是否为素数。因此,当您检查 is_prime? 时,您实际上是在计算“因子和素数”,这比实际的“因子”计算成本要高得多,因为它最终会再次递归调用 is_prime?

正如某人在某处所说:

  1. 让它发挥作用
  2. 让它美丽
  3. 加快速度(如有必要)

:)

【讨论】:

  • factors(n, i) 函数从使用 ifcontrol 结构更改为使用带有保护的函数定义:def factors(n, i) when rem(n, i) == 0, do: [i|factors(n, i-1)]def factors(n, i), do: factors(n, i-1) 使此运行速度更快(~5%)。这似乎也更惯用。 José,在这种情况下,您是否有任何理由使用if 控制结构来支持带有警卫的功能?有趣的是详细了解为什么第二个运行得更快。
  • 确实会更好。 :) 虽然我不希望它实际上更快。它们编译成的底层代码非常相似。
  • 这正是我所希望的,何塞。 Ruby 代码更干净,因为我更熟悉它。我最终会带着 Elixir 到达那里。感谢您指出这两种算法不一样,这是我 Elixir 性能问题的根源。
  • 我不确定这是正确的解决方案;如果您计算 PrimeFactors.of(27) 的素因子,它会返回 [3, 1]。数字 1 不是质数,因此应从结果中排除。更大的问题是所有因素相乘应该得到原始数字,因此正确的结果应该是[3, 3, 3]。您可能可以修改 factors 定义以将数字除以并保持除数相同。
【解决方案2】:

优化在一秒钟内工作:

defmodule PF do

  @doc "Calculates the unique prime factors of a number"
  def of(num) do
    prime_factors(num)
    |> Enum.uniq
  end

  @doc """
  Calculates all prime factors of a number by finding a low factor
  and then recursively calculating the factors of the high factor.
  Skips all evens except 2.
  Could be further optimized by only using known primes to find factors.
  """
  def prime_factors(num , next \\ 2)
  def prime_factors(num, 2) do
    cond do
      rem(num, 2) == 0 -> [2 | prime_factors(div(num, 2))]
      4 > num          -> [num]
      true             -> prime_factors(num, 3)
    end
  end
  def prime_factors(num, next) do
    cond do
      rem(num, next) == 0 -> [next | prime_factors(div(num, next))]
      next + next > num   -> [num]
      true                -> prime_factors(num, next + 2)
    end
  end

end

奖金,测试:

ExUnit.start

defmodule PFTest do
  use ExUnit.Case

  test "prime factors are correct" do
    numbers = [4, 15, 22, 100, 1000, 2398, 293487,
               32409850, 95810934857, 50_000_000]
    Enum.map(numbers, fn (num) ->
      assert num == Enum.reduce(PF.prime_factors(num), &*/2)
    end)
  end
end

通过减少问题域,我们最终会写出更多有文化/惯用的灵丹妙药。可以实现进一步的优化,但可能会失去可读性而没有显着的性能提升。此外,由于平台内置了文档和测试,包括它们是无痛的,并且使代码更具可读性。 :)

【讨论】:

  • 利用 Elixir 的甜蜜单元测试和内置文档功能获得奖励积分。
猜你喜欢
  • 2021-01-31
  • 1970-01-01
  • 2012-02-06
  • 1970-01-01
  • 1970-01-01
  • 2013-03-11
  • 2011-03-09
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多