From 2b859b438c3498cb1bd6fa61e06faaf01b683513 Mon Sep 17 00:00:00 2001 From: Kevin Conner Date: Mon, 10 Feb 2025 13:54:41 -0800 Subject: [PATCH] table: fix rebalancing of long merged columns; fixes #350 (#353) Signed-off-by: Kevin Conner --- table/render_automerge_test.go | 152 ++++++++++++++++++++++----------- table/render_init.go | 103 ++++++++++++++++++---- table/table.go | 25 +++--- table/util.go | 45 ++++------ 4 files changed, 220 insertions(+), 105 deletions(-) diff --git a/table/render_automerge_test.go b/table/render_automerge_test.go index 37819374..200c87a8 100644 --- a/table/render_automerge_test.go +++ b/table/render_automerge_test.go @@ -413,20 +413,20 @@ func TestTable_Render_AutoMerge(t *testing.T) { tw.AppendRow(Row{"a.a.a.a", "Pod 1A", "NS 1A", "C 2", "Y", "Y", "Y", "Y"}, rcAutoMerge) compareOutput(t, tw.Render(), ` -┌───┬──────────┬──────────┬──────────┬──────────┬─────────────┬─────────────┬─────────────┬─────────────┐ -│ │ COLUMN 1 │ COLUMN 2 │ COLUMN 3 │ COLUMN 4 │ COLUMN 5 │ COLUMN 6 │ COLUMN 7 │ COLUMN 8 │ -├───┼──────────┼──────────┼──────────┼──────────┼─────────────┴─────────────┼─────────────┴─────────────┤ -│ 1 │ a.a.a.a │ Pod 1A │ NS 1A │ C 1 │ 4F8F5CB531E3D49A61CF417C │ 4F8F5CB531E3D49A61CF417C │ -│ │ │ │ │ │ D133792CCFA501FD8DA53EE3 │ D133792CCFA501FD8DA53EE3 │ -│ │ │ │ │ │ 68FED20E5FE0248C3A0B64F9 │ 68FED20E5FE0248C3A0B64F9 │ -│ │ │ │ │ │ 8A6533CEE1DA614C3A8DDEC7 │ 8A6533CEE1DA614C3A8DDEC7 │ -│ │ │ │ │ │ 91FF05FEE6D971D57C134832 │ 91FF05FEE6D971D57C134832 │ -│ │ │ │ │ │ 0F4EB42DR │ 0F4EB42DRR │ -├───┼──────────┼──────────┼──────────┼──────────┼───────────────────────────┴───────────────────────────┤ -│ 2 │ a.a.a.a │ Pod 1A │ NS 1A │ C 2 │ Y │ -├───┼──────────┼──────────┼──────────┼──────────┼───────────────────────────────────────────────────────┤ -│ 3 │ a.a.a.a │ Pod 1A │ NS 1A │ C 2 │ Y │ -└───┴──────────┴──────────┴──────────┴──────────┴───────────────────────────────────────────────────────┘`) +┌───┬──────────┬──────────┬──────────┬──────────┬─────────────┬──────────────┬─────────────┬──────────────┐ +│ │ COLUMN 1 │ COLUMN 2 │ COLUMN 3 │ COLUMN 4 │ COLUMN 5 │ COLUMN 6 │ COLUMN 7 │ COLUMN 8 │ +├───┼──────────┼──────────┼──────────┼──────────┼─────────────┴──────────────┼─────────────┴──────────────┤ +│ 1 │ a.a.a.a │ Pod 1A │ NS 1A │ C 1 │ 4F8F5CB531E3D49A61CF417C │ 4F8F5CB531E3D49A61CF417C │ +│ │ │ │ │ │ D133792CCFA501FD8DA53EE3 │ D133792CCFA501FD8DA53EE3 │ +│ │ │ │ │ │ 68FED20E5FE0248C3A0B64F9 │ 68FED20E5FE0248C3A0B64F9 │ +│ │ │ │ │ │ 8A6533CEE1DA614C3A8DDEC7 │ 8A6533CEE1DA614C3A8DDEC7 │ +│ │ │ │ │ │ 91FF05FEE6D971D57C134832 │ 91FF05FEE6D971D57C134832 │ +│ │ │ │ │ │ 0F4EB42DR │ 0F4EB42DRR │ +├───┼──────────┼──────────┼──────────┼──────────┼────────────────────────────┴────────────────────────────┤ +│ 2 │ a.a.a.a │ Pod 1A │ NS 1A │ C 2 │ Y │ +├───┼──────────┼──────────┼──────────┼──────────┼─────────────────────────────────────────────────────────┤ +│ 3 │ a.a.a.a │ Pod 1A │ NS 1A │ C 2 │ Y │ +└───┴──────────┴──────────┴──────────┴──────────┴─────────────────────────────────────────────────────────┘`) }) t.Run("long column partially merged #2", func(t *testing.T) { @@ -628,27 +628,27 @@ func TestTable_Render_AutoMerge(t *testing.T) { tw.Style().Options.SeparateRows = true compareOutput(t, tw.Render(), ` -┌───┬───────────────────────────────────────────────────┐ -│ │ COLUMNS │ -├───┼─────────┬─────────┬─────────┬─────────┬───────────┤ -│ 1 │ a.a.a.a │ Pod 1A │ NS 1A │ C 1 │ Y │ -├───┤ │ │ ├─────────┼───────┬───┤ -│ 2 │ │ │ │ C 2 │ Y │ N │ -├───┤ │ ├─────────┼─────────┼───────┴───┤ -│ 3 │ │ │ NS 1B │ C 3 │ N │ -├───┤ ├─────────┼─────────┼─────────┼───┬───┬───┤ -│ 4 │ │ Pod 1B │ NS 2 │ C 4 │ N │ Y │ N │ -├───┤ │ │ ├─────────┼───┴───┴───┤ -│ 5 │ │ │ │ C 5 │ Y │ -├───┼─────────┼─────────┼─────────┼─────────┼───┬───────┤ -│ 6 │ b.b.b.b │ Pod 2 │ NS 3 │ C 6 │ N │ Y │ -├───┤ │ │ ├─────────┼───┴───────┤ -│ 7 │ │ │ │ C 7 │ Y │ -├───┼─────────┴─────────┴─────────┴─────────┼───────────┤ -│ │ FOO │ BAR │ -│ ├───────────────────────────────────────┴───────────┤ -│ │ 7 │ -└───┴───────────────────────────────────────────────────┘`) +┌───┬────────────────────────────────────────────┐ +│ │ COLUMNS │ +├───┼─────────┬────────┬───────┬─────┬───────────┤ +│ 1 │ a.a.a.a │ Pod 1A │ NS 1A │ C 1 │ Y │ +├───┤ │ │ ├─────┼───────┬───┤ +│ 2 │ │ │ │ C 2 │ Y │ N │ +├───┤ │ ├───────┼─────┼───────┴───┤ +│ 3 │ │ │ NS 1B │ C 3 │ N │ +├───┤ ├────────┼───────┼─────┼───┬───┬───┤ +│ 4 │ │ Pod 1B │ NS 2 │ C 4 │ N │ Y │ N │ +├───┤ │ │ ├─────┼───┴───┴───┤ +│ 5 │ │ │ │ C 5 │ Y │ +├───┼─────────┼────────┼───────┼─────┼───┬───────┤ +│ 6 │ b.b.b.b │ Pod 2 │ NS 3 │ C 6 │ N │ Y │ +├───┤ │ │ ├─────┼───┴───────┤ +│ 7 │ │ │ │ C 7 │ Y │ +├───┼─────────┴────────┴───────┴─────┼───────────┤ +│ │ FOO │ BAR │ +│ ├────────────────────────────────┴───────────┤ +│ │ 7 │ +└───┴────────────────────────────────────────────┘`) }) } @@ -703,20 +703,20 @@ func TestTable_Render_AutoMergeLongColumns(t *testing.T) { ) compareOutput(t, tw.Render(), ` -┌───┬──────────┬──────────┬──────────┬──────────┬─────────────┬─────────────┬─────────────┬─────────────┐ -│ │ COLUMN 1 │ COLUMN 2 │ COLUMN 3 │ COLUMN 4 │ COLUMN 5 │ COLUMN 6 │ COLUMN 7 │ COLUMN 8 │ -├───┼──────────┼──────────┼──────────┼──────────┼─────────────┴─────────────┼─────────────┴─────────────┤ -│ 1 │ a.a.a.a │ Pod 1A │ NS 1A │ C 1 │ 4F8F5CB531E3D49A61CF417C │ 4F8F5CB531E3D49A61CF417C │ -│ │ │ │ │ │ D133792CCFA501FD8DA53EE3 │ D133792CCFA501FD8DA53EE3 │ -│ │ │ │ │ │ 68FED20E5FE0248C3A0B64F9 │ 68FED20E5FE0248C3A0B64F9 │ -│ │ │ │ │ │ 8A6533CEE1DA614C3A8DDEC7 │ 8A6533CEE1DA614C3A8DDEC7 │ -│ │ │ │ │ │ 91FF05FEE6D971D57C134832 │ 91FF05FEE6D971D57C134832 │ -│ │ │ │ │ │ 0F4EB42DR │ 0F4EB42DRR │ -├───┼──────────┼──────────┼──────────┼──────────┼───────────────────────────┴───────────────────────────┤ -│ 2 │ a.a.a.a │ Pod 1A │ NS 1A │ C 2 │ Y │ -├───┼──────────┼──────────┼──────────┼──────────┼───────────────────────────────────────────────────────┤ -│ 3 │ a.a.a.a │ Pod 1A │ NS 1A │ C 2 │ Y │ -└───┴──────────┴──────────┴──────────┴──────────┴───────────────────────────────────────────────────────┘`) +┌───┬──────────┬──────────┬──────────┬──────────┬─────────────┬──────────────┬─────────────┬──────────────┐ +│ │ COLUMN 1 │ COLUMN 2 │ COLUMN 3 │ COLUMN 4 │ COLUMN 5 │ COLUMN 6 │ COLUMN 7 │ COLUMN 8 │ +├───┼──────────┼──────────┼──────────┼──────────┼─────────────┴──────────────┼─────────────┴──────────────┤ +│ 1 │ a.a.a.a │ Pod 1A │ NS 1A │ C 1 │ 4F8F5CB531E3D49A61CF417C │ 4F8F5CB531E3D49A61CF417C │ +│ │ │ │ │ │ D133792CCFA501FD8DA53EE3 │ D133792CCFA501FD8DA53EE3 │ +│ │ │ │ │ │ 68FED20E5FE0248C3A0B64F9 │ 68FED20E5FE0248C3A0B64F9 │ +│ │ │ │ │ │ 8A6533CEE1DA614C3A8DDEC7 │ 8A6533CEE1DA614C3A8DDEC7 │ +│ │ │ │ │ │ 91FF05FEE6D971D57C134832 │ 91FF05FEE6D971D57C134832 │ +│ │ │ │ │ │ 0F4EB42DR │ 0F4EB42DRR │ +├───┼──────────┼──────────┼──────────┼──────────┼────────────────────────────┴────────────────────────────┤ +│ 2 │ a.a.a.a │ Pod 1A │ NS 1A │ C 2 │ Y │ +├───┼──────────┼──────────┼──────────┼──────────┼─────────────────────────────────────────────────────────┤ +│ 3 │ a.a.a.a │ Pod 1A │ NS 1A │ C 2 │ Y │ +└───┴──────────┴──────────┴──────────┴──────────┴─────────────────────────────────────────────────────────┘`) }) t.Run("marge 3 columns", func(t *testing.T) { @@ -762,4 +762,58 @@ func TestTable_Render_AutoMergeLongColumns(t *testing.T) { │ 3 │ a.a.a.a │ Pod 1A │ NS 1A │ C 2 │ Y │ └───┴──────────┴──────────┴──────────┴──────────┴───────────────────────────────────────────┘`) }) + + t.Run("Rebalance long merged columns", func(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(Row{"ID", "Date", "From", "To", "Subject", "Size"}) + + tw.AppendRow(Row{ + 4, + "2024-11-18T09:30:54Z", + "Shaylee McDermott", + "", + "Tote bag photo booth lumbersexual normcore synth meh lumbersexual disrupt craft beer aesthetic.", + "4.2 MB", + }) + tw.AppendRow(Row{ + 4, + "2024-11-18T09:30:54Z", + "Anahi Braun ", + "", + "Mumblecore salvia mumblecore austin tofu viral asymmetrical small batch distillery you probably haven't heard of them.", + "4.2 MB", + }) + tw.AppendRow(Row{ + 4, + "2024-11-18T09:30:54Z", + "Brando Barton", + "", + "Before they sold out jean shorts chartreuse neutra fixie flexitarian goth art party small batch sriracha.", + "4.2 MB", + }) + const ID = "AAMkADdiOWM1OTBkLTBhZjEtNGFiNS1hOGYwLTY2YWFmOGQyNTMxNgBGAAAAAADBju3TxrP2SZIsqjNb8hmoBwA9J0gY/4/nQ73Lmp9F9NoaAABlJ281AAA9J0gY/4/nQ73Lmp9F9NoaAABlJ6bLAAA=" + tw.AppendRow( + Row{ID, ID, ID, ID, ID, ID}, + RowConfig{ + AutoMerge: true, + AutoMergeAlign: text.AlignLeft, + }) + + tw.SetStyle(StyleLight) + tw.Style().Options.SeparateRows = true + + compareOutput(t, tw.Render(), ` +┌────┬──────────────────────┬───────────────────┬────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┬────────┐ +│ ID │ DATE │ FROM │ TO │ SUBJECT │ SIZE │ +├────┼──────────────────────┼───────────────────┼────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼────────┤ +│ 4 │ 2024-11-18T09:30:54Z │ Shaylee McDermott │ │ Tote bag photo booth lumbersexual normcore synth meh lumbersexual disrupt craft beer aesthetic. │ 4.2 MB │ +├────┼──────────────────────┼───────────────────┼────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼────────┤ +│ 4 │ 2024-11-18T09:30:54Z │ Anahi Braun │ │ Mumblecore salvia mumblecore austin tofu viral asymmetrical small batch distillery you probably haven't heard of them. │ 4.2 MB │ +├────┼──────────────────────┼───────────────────┼────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼────────┤ +│ 4 │ 2024-11-18T09:30:54Z │ Brando Barton │ │ Before they sold out jean shorts chartreuse neutra fixie flexitarian goth art party small batch sriracha. │ 4.2 MB │ +├────┴──────────────────────┴───────────────────┴────┴────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┴────────┤ +│ AAMkADdiOWM1OTBkLTBhZjEtNGFiNS1hOGYwLTY2YWFmOGQyNTMxNgBGAAAAAADBju3TxrP2SZIsqjNb8hmoBwA9J0gY/4/nQ73Lmp9F9NoaAABlJ281AAA9J0gY/4/nQ73Lmp9F9NoaAABlJ6bLAAA= │ +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘`) + }) + } diff --git a/table/render_init.go b/table/render_init.go index 8a7a0f45..071c98b2 100644 --- a/table/render_init.go +++ b/table/render_init.go @@ -2,6 +2,7 @@ package table import ( "fmt" + "sort" "strings" "unicode" @@ -57,35 +58,99 @@ func (t *Table) extractMaxColumnLengths(rows []rowStr, hint renderHint) { } func (t *Table) extractMaxColumnLengthsFromRow(row rowStr, mci mergedColumnIndices) { - for colIdx, colStr := range row { + for colIdx := 0; colIdx < len(row); colIdx++ { + colStr := row[colIdx] longestLineLen := text.LongestLineLen(colStr) maxColWidth := t.getColumnWidthMax(colIdx) if maxColWidth > 0 && maxColWidth < longestLineLen { longestLineLen = maxColWidth } - mergedColumnsLength := mci.mergedLength(colIdx, t.maxColumnLengths) - if longestLineLen > mergedColumnsLength { - if mergedColumnsLength > 0 { - t.extractMaxColumnLengthsFromRowForMergedColumns(colIdx, longestLineLen, mci) - } else { - t.maxColumnLengths[colIdx] = longestLineLen + + if mergeEndIndex, ok := mci[colIdx]; ok { + startIndexMap := t.maxMergedColumnLengths[mergeEndIndex] + if startIndexMap == nil { + startIndexMap = make(map[int]int) + t.maxMergedColumnLengths[mergeEndIndex] = startIndexMap + } + for index := colIdx + 1; index <= mergeEndIndex; index++ { + maxColWidth := t.getColumnWidthMax(index) + if maxColWidth > 0 && maxColWidth < longestLineLen { + longestLineLen = maxColWidth + } + } + if longestLineLen > startIndexMap[colIdx] { + startIndexMap[colIdx] = longestLineLen } - } else if maxColWidth == 0 && longestLineLen > t.maxColumnLengths[colIdx] { + colIdx = mergeEndIndex + } else if longestLineLen > t.maxColumnLengths[colIdx] { t.maxColumnLengths[colIdx] = longestLineLen } } } -func (t *Table) extractMaxColumnLengthsFromRowForMergedColumns(colIdx int, mergedColumnLength int, mci mergedColumnIndices) { - numMergedColumns := mci.len(colIdx) - mergedColumnLength -= (numMergedColumns - 1) * text.StringWidthWithoutEscSequences(t.style.Box.MiddleSeparator) - maxLengthSplitAcrossColumns := mergedColumnLength / numMergedColumns - if maxLengthSplitAcrossColumns > t.maxColumnLengths[colIdx] { - t.maxColumnLengths[colIdx] = maxLengthSplitAcrossColumns - } - for otherColIdx := range mci[colIdx] { - if maxLengthSplitAcrossColumns > t.maxColumnLengths[otherColIdx] { - t.maxColumnLengths[otherColIdx] = maxLengthSplitAcrossColumns +// Try to rebalance the merged column lengths across all columns +// We rebalance from the lowest end index to the highest, +// and within that set from the highest start index to the lowest. +// We distribute the length across the columns not already exceeding +// the average. +func (t *Table) rebalanceMaxMergedColumnLengths() { + endIndexKeys, startIndexKeysMap := getSortedKeys(t.maxMergedColumnLengths) + for _, endIndexKey := range endIndexKeys { + startIndexKeys := startIndexKeysMap[endIndexKey] + for idx := len(startIndexKeys) - 1; idx >= 0; idx-- { + startIndexKey := startIndexKeys[idx] + columnBalanceMap := map[int]struct{}{} + for index := startIndexKey; index <= endIndexKey; index++ { + columnBalanceMap[index] = struct{}{} + } + mergedColumnLength := t.maxMergedColumnLengths[endIndexKey][startIndexKey] - (len(columnBalanceMap)-1)*text.StringWidthWithoutEscSequences(t.style.Box.MiddleSeparator) + + // keep reducing the set of columns until the remainder are the ones less than + // the average of the remaining length (total merged length - all lengths > average) + for { + // If mergedColumnLength is zero or less then we already exceed the merged length + if mergedColumnLength <= 0 { + columnBalanceMap = map[int]struct{}{} + break + } + numMergedColumns := len(columnBalanceMap) + maxLengthSplitAcrossColumns := mergedColumnLength / numMergedColumns + mapReduced := false + for mergedColumn := range columnBalanceMap { + maxColumnLength := t.maxColumnLengths[mergedColumn] + // If column is already greater then remove from the map and reduce the amount to rebalance + if maxColumnLength >= maxLengthSplitAcrossColumns { + mapReduced = true + mergedColumnLength -= maxColumnLength + delete(columnBalanceMap, mergedColumn) + } + } + if !mapReduced { + break + } + } + // Do we still have any columns to balance? + if len(columnBalanceMap) > 0 { + // remove the max column sizes from the remaining amount to balance, we then + // share out the remainder amongst the columns. + numRebalancedColumns := len(columnBalanceMap) + balanceColumns := make([]int, 0, numRebalancedColumns) + for balanceColumn := range columnBalanceMap { + mergedColumnLength -= t.maxColumnLengths[balanceColumn] + balanceColumns = append(balanceColumns, balanceColumn) + } + // pad out the columns one by one + sort.Ints(balanceColumns) + columnLengthRemaining := mergedColumnLength + columnsRemaining := numRebalancedColumns + for index := 0; index < numRebalancedColumns; index++ { + balancedSpace := columnLengthRemaining / columnsRemaining + balanceColumn := balanceColumns[index] + t.maxColumnLengths[balanceColumn] += balancedSpace + columnLengthRemaining -= balancedSpace + columnsRemaining-- + } + } } } } @@ -136,6 +201,7 @@ func (t *Table) initForRenderColumnConfigs() { func (t *Table) initForRenderColumnLengths() { t.maxColumnLengths = make([]int, t.numColumns) + t.maxMergedColumnLengths = make(map[int]map[int]int) t.extractMaxColumnLengths(t.rowsHeader, renderHint{isHeaderRow: true}) t.extractMaxColumnLengths(t.rows, renderHint{}) t.extractMaxColumnLengths(t.rowsFooter, renderHint{isFooterRow: true}) @@ -147,6 +213,7 @@ func (t *Table) initForRenderColumnLengths() { t.maxColumnLengths[colIdx] = minWidth } } + t.rebalanceMaxMergedColumnLengths() } func (t *Table) initForRenderHideColumns() { diff --git a/table/table.go b/table/table.go index ea2fae68..c15ccd76 100644 --- a/table/table.go +++ b/table/table.go @@ -36,6 +36,9 @@ type Table struct { indexColumn int // maxColumnLengths stores the length of the longest line in each column maxColumnLengths []int + // maxMergedColumnLengths stores the longest lengths for merged columns + // endIndex -> startIndex -> maxMergedLength + maxMergedColumnLengths map[int]map[int]int // maxRowLength stores the length of the longest row maxRowLength int // numColumns stores the (max.) number of columns seen @@ -619,19 +622,19 @@ func (t *Table) getMergedColumnIndices(row rowStr, hint renderHint) mergedColumn mci := make(mergedColumnIndices) for colIdx := 0; colIdx < t.numColumns-1; colIdx++ { - // look backward - for otherColIdx := colIdx - 1; colIdx >= 0 && otherColIdx >= 0; otherColIdx-- { - if row[colIdx] != row[otherColIdx] { - break - } - mci.safeAppend(colIdx, otherColIdx) - } - // look forward - for otherColIdx := colIdx + 1; colIdx < len(row) && otherColIdx < len(row); otherColIdx++ { - if row[colIdx] != row[otherColIdx] { + for otherColIdx := colIdx + 1; otherColIdx < len(row); otherColIdx++ { + colsEqual := row[colIdx] == row[otherColIdx] + if !colsEqual { + lastEqual := otherColIdx - 1 + if colIdx != lastEqual { + mci[colIdx] = lastEqual + colIdx = lastEqual + } break + } else if colsEqual && otherColIdx == len(row)-1 { + mci[colIdx] = otherColIdx + colIdx = otherColIdx } - mci.safeAppend(colIdx, otherColIdx) } } return mci diff --git a/table/util.go b/table/util.go index 6b7a6585..4636e881 100644 --- a/table/util.go +++ b/table/util.go @@ -2,6 +2,7 @@ package table import ( "reflect" + "sort" ) // AutoIndexColumnID returns a unique Column ID/Name for the given Column Number. @@ -40,33 +41,7 @@ func isNumber(x interface{}) bool { return false } -type mergedColumnIndices map[int]map[int]bool - -func (m mergedColumnIndices) mergedLength(colIdx int, maxColumnLengths []int) int { - mergedLength := maxColumnLengths[colIdx] - for otherColIdx := range m[colIdx] { - mergedLength += maxColumnLengths[otherColIdx] - } - return mergedLength -} - -func (m mergedColumnIndices) len(colIdx int) int { - return len(m[colIdx]) + 1 -} - -func (m mergedColumnIndices) safeAppend(colIdx, otherColIdx int) { - // map - if m[colIdx] == nil { - m[colIdx] = make(map[int]bool) - } - m[colIdx][otherColIdx] = true - - // reverse map - if m[otherColIdx] == nil { - m[otherColIdx] = make(map[int]bool) - } - m[otherColIdx][colIdx] = true -} +type mergedColumnIndices map[int]int func objAsSlice(in interface{}) []interface{} { var out []interface{} @@ -110,3 +85,19 @@ func objIsSlice(in interface{}) bool { k := reflect.TypeOf(in).Kind() return k == reflect.Slice || k == reflect.Array } + +func getSortedKeys(input map[int]map[int]int) ([]int, map[int][]int) { + keys := make([]int, 0, len(input)) + subkeysMap := make(map[int][]int) + for key, subMap := range input { + keys = append(keys, key) + subkeys := make([]int, 0, len(subMap)) + for subkey := range subMap { + subkeys = append(subkeys, subkey) + } + sort.Ints(subkeys) + subkeysMap[key] = subkeys + } + sort.Ints(keys) + return keys, subkeysMap +}