Самый длинный возрастающий путь в матричном анализе временной сложности

Я работаю по алгоритму ниже:

Given an integer matrix, find the length of the longest increasing path.

From each cell, you can either move to four directions: left, right, up or down. You may NOT move diagonally or move outside of the boundary (i.e. wrap-around is not allowed).

Example 1:

nums = [
  [9,9,4],
  [6,6,8],
  [2,1,1]
]
Return 4
The longest increasing path is [1, 2, 6, 9].

Example 2:

nums = [
  [3,4,5],
  [3,2,6],
  [2,2,1]
]
Return 4
The longest increasing path is [3, 4, 5, 6]. Moving diagonally is not allowed.

Мой первоначальный импульс состоял в том, чтобы иметь 4 функции в каждом направлении, которые имеют возрастающее значение. Например, если целое число 1 находится в [1,1] и четыре значения, окружающие эту ячейку, увеличиваются, то будут созданы три функции. Я полагаю, что этот процесс для каждой ячейки в худшем случае составляет O (4 ^ (M * N) [4 вызова функций на каждый элемент, и есть mxn элементов].

Однако решения предлагают следующую грубую силу (прежде чем они перейдут к оптимизации с помощью мемоизации):

Algorithm

Each cell can be seen as a vertex in a graph G. If two adjacent cells have value a < ba<b, i.e. increasing then we have a directed edge (a, b)(a,b). The problem then becomes:

Search the longest path in the directed graph G.
Naively, we can use DFS or BFS to visit all the cells connected starting from a root. We update the maximum length of the path during search and find the answer when it finished.

Usually, in DFS or BFS, we can employ a set visited to prevent the cells from duplicate visits. We will introduce a better algorithm based on this in the next section.

Java

// Naive DFS Solution
// Time Limit Exceeded
public class Solution {
  private static final int[][] dirs = {{0, 1}, {1, 0}, {0, -1}, {-1, 0}};
  private int m, n;

  public int longestIncreasingPath(int[][] matrix) {
      if (matrix.length == 0) return 0;
      m = matrix.length;
      n = matrix[0].length;
      int ans = 0;
      for (int i = 0; i < m; ++i)
          for (int j = 0; j < n; ++j)
              ans = Math.max(ans, dfs(matrix, i, j));
      return ans;
  }

  private int dfs(int[][] matrix, int i, int j) {
      int ans = 0;
      for (int[] d : dirs) {
          int x = i + d[0], y = j + d[1];
          if (0 <= x && x < m && 0 <= y && y < n && matrix[x][y] > matrix[i][j])
              ans = Math.max(ans, dfs(matrix, x, y));
      }
      return ++ans;
  }
}

Временная сложность для этого алгоритма, который очень похож (просто запускает 4 функции dfs в худшем случае для одной ячейки), имеет следующий анализ сложности:

Complexity Analysis

Time complexity : O(2^{m+n))
​​ ). The search is repeated for each valid increasing path. In the worst case we can have O(2^{m+n)) calls. For example:
1 2 3 . . . n
2 3 . . .   n+1
3 . . .     n+2
.           .
.           .
.           .
m m+1 . . . n+m-1
Space complexity : O(mn). For each DFS we need O(h) space used by the system stack, where hh is the maximum depth of the recursion. In the worst case, O(h) =O(mn).

Я не понимаю, как они получили эту временную или пространственную сложность. Что касается стоимости пространства, я думаю, что наихудший сценарий — это матрица, отсортированная по возрастанию в строках и столбцах, но один стек будет примерно пропорционален длине диагонали квадрата mxn. Не поэтому ли пространство в худшем случае пропорционально O(mn)? Кроме того, как стоимость пространства O (2 ^ (m + n)), а не O (4 ^ (m * n)?


person segue_segway    schedule 27.02.2017    source источник


Ответы (1)


Я считаю, что Санни прав в том, что поиск методом грубой силы имеет временную сложность O(4^(m*n)), где n и m — это количество строк и столбцов в матрице, связанной с данным массивом. Я хотел бы предложить алгоритм O(n*m) и код Ruby, который его реализует.

В таких задачах, как эта, существует тенденция рассматривать переход от каждого элемента в матрице ко всем соседним элементам с большим значением (до 4), а затем от каждого из этих элементов ко всем их соседним элементам с большим значением, и и так далее (отсюда O(4^m*n)). Однако, поскольку пути должны увеличиваться, мы можем посмотреть на проблему по-другому, что позволит нам разработать высокоэффективный алгоритм оптимизации.

Предположим, мы сгруппировали все местоположения по значению. Для примера, приведенного в вопросе, это может быть выражено:

value_to_locs
  #=> { 9=>[[0, 0], [0, 1]], 4=>[[0, 2]], 6=>[[1, 0], [1, 1]], 8=>[[1, 2]],
  #     2=>[[2, 0]], 1=>[[2, 1], [2, 2]]}

Далее рассмотрим значения хеша в порядке убывания:

to_process = value_to_locs.keys.sort.reverse
  #=> [9, 8, 6, 4, 2, 1]

Сначала мы обрабатываем два местоположения со значением 9. Длина растущего пути из любого из этих мест явно равна 1 (пути [[0, 0]] и [[1, 0]]), так как идти некуда. Мы сохраняем эту информацию в ранее пустой хэш processed, который теперь выглядит следующим образом:

processed = { [0, 0]=>{ len: 1, next: nil }, [1, 1]=>{ len: 1, next: nil } }

Далее рассмотрим одно местоположение со значением 8 (второй элемент to_process), [1, 2]. Если возрастающий путь из этого местоположения имеет длину больше 1, он должен перейти к одному из элементов processed. [1, 2] не является смежным ни с [0, 0], ни с [1, 1], поэтому мы добавляем пару ключ-значение:

[1, 2]=>{ len: 1, next: nil }

до processed, получив

processed
  #=> { [0, 0]=>{ len: 1, next: nil }, [1, 1]=>{ len: 1, next: nil },
  #     [1, 2]=>{ len: 1, next: nil } }

Следующее значение (в to_process) для проверки — 6, которое встречается в двух местах: [1, 0] и [1, 1]. Первый находится рядом с одним местом в processed ([0, 0]), поэтому мы добавляем к processed пару ключ-значение

[1, 0]=>{ len: 1 + processed[[0, 0]][:len], next: [0, 0] }
     #=>{ len: 2, next: [0, 0] } 

Другой элемент со значением 6, [1, 1] имеет два соседних элемента в processed, [0, 1] и [1, 2], поэтому добавьте

[1, 1]=>{ len: 1 + processed[[0, 1]][:len], next: [0, 1] }
     #=>{ len: 2, next: [0, 1] } 

or

[1, 1]=>{ len: 1 + processed[[1, 2]][:len], next: [1, 2] }
     #=>{ len: 2, next: [1, 2] }

до processed, а именно тот, для которого значение :len наибольшее. (Здесь ничья, так что можно выбрать любой.)

Продолжаем так до тех пор, пока все элементы исходного массива не станут ключами в processed. Затем мы выбираем в качестве начальной точки самого длинного возрастающего пути местоположение loc, для которого processed[loc][:len] является максимальным, и реконструируем связанный путь.

Обратите внимание, что ключ :next необходим только для восстановления самого длинного пути. Если нужна только длина самого длинного пути, этот ключ не нужен.

Код

def longest_increasing_path(arr)
  row_indices, col_indices = 0..arr.size-1, 0..arr.first.size-1
  value_to_locs = row_indices.each_with_object(Hash.new { |h,k| h[k] = [] }) { |r,h|
    col_indices.each { |c| h[arr[r][c]] << [r,c] } }
  processed = {}
  value_to_locs.keys.sort.reverse.each do |x|
    value_to_locs[x].each do |loc|
      next_on_path = greater_neighbors(loc, arr, row_indices, col_indices).
        max_by { |nloc| processed[nloc][:len] }
      processed[loc] = next_on_path ? 
          { len: 1+processed[next_on_path][:len], next: next_on_path } :
          { len: 1, next: nil }
    end
  end
  extract_path(processed)
end  

def longest_increasing_path(arr)
  row_indices, col_indices = 0..arr.size-1, 0..arr.first.size-1
  value_to_locs = row_indices.each_with_object(Hash.new { |h,k| h[k] = [] }) { |r,h|
    col_indices.each { |c| h[arr[r][c]] << [r,c] } }
  processed = {}
  low, high = value_to_locs.keys.minmax
  high.downto(low) do |x|
    next unless value_to_locs.key?(x)
    value_to_locs[x].each do |loc|
      next_on_path = greater_neighbors(loc, arr, row_indices, col_indices).
        max_by { |nloc| processed[nloc][:len] }
      processed[loc] = next_on_path ? 
          { len: 1+processed[next_on_path][:len], next: next_on_path } :
          { len: 1, next: nil }
    end
  end
  extract_path(processed)
end  

def greater_neighbors((r,c), arr, row_indices, col_indices)
  curr_val = arr[r][c]
  [[-1,0], [1,0], [0,-1], [0, 1]].each_with_object([]) do |(rdelta, cdelta), a|
    ra = r + rdelta
    ca = c + cdelta
    a << [ra, ca] if row_indices.cover?(ra) && col_indices.cover?(ca) &&
      curr_val < arr[ra][ca]
  end
end

def extract_path(processed)
  loc, g = processed.max_by { |loc,g| g[:len] }
  len = g[:len]
  path = [loc]
  loop do
    break if g[:next].nil?
    loc = g[:next]
    path << loc        
    g = processed[loc]
  end
  [len, path]
end

Examples

№1

arr = [
  [9,9,4],
  [6,6,8],
  [2,1,1]
]

longest_increasing_path(arr)
  #=> [4, [[2, 1], [2, 0], [1, 0], [0, 0]]] 

№2

rows = 10
cols = 10
a = (1..9).to_a
arr = Array.new(rows) { Array.new(cols) { a.sample } }
  #=> [[4, 7, 5, 3, 5, 4, 2, 2, 9, 3],
  #    [8, 3, 3, 5, 4, 2, 8, 1, 8, 3],
  #    [7, 1, 9, 4, 2, 7, 1, 4, 4, 6],
  #    [3, 7, 5, 5, 2, 3, 9, 1, 9, 7],
  #    [2, 6, 7, 1, 5, 9, 3, 5, 2, 9],
  #    [4, 4, 6, 7, 8, 4, 9, 7, 6, 1],
  #    [9, 7, 5, 4, 6, 8, 8, 4, 4, 8],
  #    [3, 1, 9, 9, 5, 7, 9, 6, 7, 2],
  #    [5, 6, 4, 8, 2, 3, 4, 3, 3, 9],
  #    [7, 9, 6, 9, 5, 2, 9, 7, 6, 3]] 

require 'time'
t = Time.now
longest_increasing_path(arr)
  #=> [5, [[6, 3], [6, 2], [5, 2], [5, 3], [5, 4]]] 
Time.now - t
  #=> 0.003606 seconds

Таким образом, самый длинный путь имеет длину 5 и содержит элементы 4, [6, 3], затем слева до 5, до 6, справа до 7, справа до 8.

#3

rows = 100
cols = 200
a = (1..20).to_a
arr = Array.new(rows) { Array.new(cols) { a.sample } }

t = Time.now
len, path = longest_increasing_path(arr)
  #=> [12, [[86, 121], [86, 120], [86, 119], [87, 119], [87, 118], [86, 118],
  #   [85, 118], [85, 117], [86, 117], [87, 117], [88, 117], [89, 117]]] 
Time.now - t
  #=> 0.153562 seconds 

path.map { |r,c| arr[r][c] }
  #=> [1, 2, 3, 5, 7, 8, 9, 10, 11, 13, 19, 20]

Пояснение

Сначала рассмотрим вспомогательный метод greater_neighbors для примера №1. Для arr, как показано,

row_indices = 0..arr.size-1
  #=> 0..2 
col_indices = 0..arr.first.size-1
  #=> 0..2 

Затем (думая о arr как о матрице) мы группируем местоположения с одинаковым значением:

value_to_locs = row_indices.each_with_object(Hash.new { |h,k| h[k] = [] }) { |r,h|
  col_indices.each { |c| h[arr[r][c]] << [r,c] } }
  #=> {9=>[[0, 0], [0, 1]], 4=>[[0, 2]], 6=>[[1, 0], [1, 1]],
  #    8=>[[1, 2]], 2=>[[2, 0]], 1=>[[2, 1], [2, 2]]} 

processed = {}

Этот хэш будет содержать проверенные местоположения.

low, high = value_to_locs.keys.minmax
  #=> [1, 9]

Это обеспечивает порядок, в котором обрабатываются местоположения с заданными значениями, от high до low.

enum0 = high.downto(low)
  #=> #<Enumerator: 9:downto(1)> 

Генерируется первый элемент enum0 и передается в блок:

x = enum0.next
  #=> 9

а также

value_to_locs.key?(x)
  #=> true

выполняется, поэтому мы не выполняем next.

Теперь мы рассматриваем все местоположения со значением 9, затем со значением 8 и так далее.

После того, как 9 сгенерировано и передано в блок, а next не выполнено, выполняется следующий расчет:

b = value_to_locs[x]
  #=> [[0, 0], [0, 1]] 
enum1 = b.each
  #=> #<Enumerator: [[0, 0], [0, 1]]:each> 
loc = enum1.next
  #=> [0, 0]
c = greater_neighbors(loc, arr, row_indices, col_indices)
  #=> []

Метод greater_neighbors просто создает массив всех местоположений, смежных с loc, значения которых больше, чем значение в loc.

next_on_path = c.max_by { |nloc| processed[nloc][:len] }
  #=> nil
processed[loc] = next_on_path ? 
  { len: 1+processed[next_on_path][:len], next: next_on_path } :
  { len: 1, next: nil }
  #=> {:len=>1, :next=>nil}
processed
  #=> {[0, 0]=>{:len=>1, :next=>nil}}

Теперь мы генерируем следующий и последний элемент enum1 и передаем его в блок:

loc = enum1.next
  #=> [0, 1]

Расчеты для этого местоположения аналогичны расчетам для [0, 0], в результате чего:

processed
  #=> {[0, 0]=>{:len=>1, :next=>nil}, [0, 1]=>{:len=>1, :next=>nil}}

Мы достигли последнего элемента enum1, поэтому выполняем следующее:

x = enum0.next
  #=> 8
value_to_locs.key?(x)
  #=> true # so next is not executed
b = value_to_locs[x]
  #=> [[1, 2]] 
enum1 = b.each
  #=> #<Enumerator: [[1, 2]]:each> 
loc = enum1.next
  #=> [1, 2] 
c = greater_neighbors(loc, arr, row_indices, col_indices)
  #=> [] 

Опять же, деваться из этой локации некуда, поэтому получаем:

processed
  #=> {[0, 0]=>{:len=>1, :next=>nil}, [0, 1]=>{:len=>1, :next=>nil},
  #    [1, 2]=>{:len=>1, :next=>nil}}

Продолжая,

x = enum0.next
  #=> 7
value_to_locs.key?(x)
  #=> false # so next is executed

x = enum0.next
  #=> 6 
value_to_locs.key?(x)
  #=> true # so next is executed
b = value_to_locs[x]
  #=> [[1, 0], [1, 1]] 
enum1 = b.each
  #=> #<Enumerator: [[1, 0], [1, 1]]:each> 
loc = enum1.next
  #=> [1, 0] 
c = greater_neighbors(loc, arr, row_indices, col_indices)
  #=> [[0, 0]] 
next_on_path = c.max_by { |nloc| processed[nloc][:len] }
  #=> [0, 0] 
processed[loc] = next_on_path ? 
  { len: 1+processed[next_on_path][:len], next: next_on_path } :
  { len: 1, next: nil }
  #=> {:len=>2, :next=>[0, 0]} 
processed
  #=> {[0, 0]=>{:len=>1, :next=>nil}, [0, 1]=>{:len=>1, :next=>nil},
  #    [1, 2]=>{:len=>1, :next=>nil}, [1, 0]=>{:len=>2, :next=>[0, 0]}} 

Наконец, мы подошли к локации ([1, 0]), которая находится рядом с локацией с более высоким значением ([0, 0]).

Вычисления продолжаются таким образом, пока не получим:

processed
  #=> {[0, 0]=>{:len=>1, :next=>nil},    [0, 1]=>{:len=>1, :next=>nil},
  #    [1, 2]=>{:len=>1, :next=>nil},    [1, 0]=>{:len=>2, :next=>[0, 0]},
  #    [1, 1]=>{:len=>2, :next=>[0, 1]}, [0, 2]=>{:len=>2, :next=>[1, 2]},
  #    [2, 0]=>{:len=>3, :next=>[1, 0]}, [2, 1]=>{:len=>4, :next=>[2, 0]},
  #    [2, 2]=>{:len=>2, :next=>[1, 2]}}

Остается только найти пару ключ-значение k=>v, для которой v[:len] является максимальным, а затем извлечь самый длинный путь. Это делает помощник extract, который, как и greater_neighbors, прост.

person Cary Swoveland    schedule 27.02.2017
comment
Блестящее решение - спасибо за понимание. На более концептуальной основе - как вы думаете, в чем разница между использованием DFS во всех 4 направлениях и простой передачей функции грубой силы таким образом? Мой вопрос о временной сложности этих алгоритмов не ориентирован на Java - это был просто пример кода - я просто пытаюсь интуитивно понять, как получается эта среда выполнения. - person segue_segway; 28.02.2017
comment
Я не читал весь этот пост (я на смартфоне, извините), но если он включает в себя сортировку всей матрицы, то это не будет O (mn), потому что один только этот шаг O (mn log mn) . - person ruakh; 28.02.2017
comment
Санни, я думаю, ты прав, что поиск методом грубой силы будет O(4^(mn)). Мой алгоритм — O(n*m), потому что для каждого из n*m элементов требуется количество операций, которое не увеличивается в зависимости от n и m. - person Cary Swoveland; 28.02.2017
comment
@ruakh, временная сложность должна быть не менее O (n * m), так как каждый элемент должен быть проверен. - person Cary Swoveland; 28.02.2017
comment
Солнышко, я не уверен в космической сложности методов грубой силы. - person Cary Swoveland; 28.02.2017
comment
@CarySwoveland: Я думаю, вы, должно быть, как-то неправильно поняли мой комментарий. Я хочу сказать, что в худшем случае value_to_locs.keys.sort не находится в O(mn). - person ruakh; 28.02.2017
comment
@ruakh, понятно. Спасибо. Я исправлю это утром. Тем не менее, O(nm log nm) — это не нарезанная печень. :-) - person Cary Swoveland; 28.02.2017
comment
@CarySwoveland: O (mn log mn) неплохо, но довольно простая комбинация динамического программирования и поиска в ширину (от локальных максимумов) занимает всего O (mn) времени. - person ruakh; 28.02.2017
comment
@ruakh, я внес небольшое изменение, чтобы сделать мой метод O (mn). В моем решении используется динамическое программирование, при этом переменная состояния является нижней границей первых элементов самых длинных растущих путей. Вы имели в виду подход динамического программирования с той же переменной состояния? Не могли бы вы уточнить, как будет работать поиск в ширину? - person Cary Swoveland; 01.03.2017
comment
@CarySwoveland: Мой подход: создать массив m на n path_lengths, представляющий длину самого длинного возрастающего пути, который начинается в этой позиции. Найдите все локальные максимумы (= элементы без соседей с большим значением), добавьте их местоположения в очередь обработки. Пока очередь не пуста: удалите элемент из очереди, установите path_lengths[m][n], проверьте его соседей с меньшим значением, чтобы увидеть, готовы ли какие-либо из них сейчас к обработке, если да, добавьте их в очередь обработки. В конце осмотрите path_lengths, чтобы найти самый длинный путь. - person ruakh; 01.03.2017